From eff62a9765ecf20d96c3de17475beae4ab59829c Mon Sep 17 00:00:00 2001 From: "Eevee (Evelyn Woods)" Date: Wed, 28 Apr 2021 22:05:01 -0600 Subject: [PATCH] Merge trap/cloner connections; round-trip them through C2M; stub out connect tool --- js/format-base.js | 7 +- js/format-c2g.js | 29 ++++++ js/format-dat.js | 34 +++++-- js/game.js | 8 +- js/main-editor.js | 219 ++++++++++++++++++++++++++++++++++++++-------- js/util.js | 16 ++++ style.css | 30 ++++--- 7 files changed, 278 insertions(+), 65 deletions(-) diff --git a/js/format-base.js b/js/format-base.js index b40c614..f13b453 100644 --- a/js/format-base.js +++ b/js/format-base.js @@ -127,12 +127,9 @@ export class StoredLevel extends LevelInterface { this.size_y = 0; this.linear_cells = []; - // Maps of button positions to trap/cloner positions, as scalar indexes - // in the linear cell list - // TODO merge these imo + // Maps of button positions to trap/cloner positions, as scalars this.has_custom_connections = false; - this.custom_trap_wiring = {}; - this.custom_cloner_wiring = {}; + this.custom_connections = {}; // New LL feature: custom camera regions, as lists of {x, y, width, height} this.camera_regions = []; diff --git a/js/format-c2g.js b/js/format-c2g.js index 35e125e..a684d2c 100644 --- a/js/format-c2g.js +++ b/js/format-c2g.js @@ -1307,6 +1307,20 @@ export function parse_level(buf, number = 1) { p += 4; } } + else if (type === 'LXCX') { + // Custom connections, like MSCC (but more! maybe) + if (bytes.length % 4 !== 0) + throw new Error(`Expected LXCX chunk to be a multiple of 4 bytes; got ${bytes.length}`); + + level.has_custom_connections = true; + let p = 0; + while (p < bytes.length) { + let src = view.getUint16(p, true); + let dest = view.getUint16(p + 2, true); + level.custom_connections[src] = dest; + p += 4; + } + } else { console.warn(`Unrecognized section type '${type}' at offset ${bytes.byteOffset}`, view); // TODO save it, persist when editing level @@ -1536,6 +1550,21 @@ export function synthesize_level(stored_level) { c2m.add_section('LXCM', bytes.buffer); } + // Store MSCC-like custom connections + // TODO LL feature, should be distinguished somehow + let num_connections = Object.keys(stored_level.custom_connections).length; + if (num_connections > 0) { + let buf = new ArrayBuffer(4 * num_connections); + let view = new DataView(buf); + let p = 0; + for (let [src, dest] of Object.entries(stored_level.custom_connections)) { + view.setUint16(p + 0, src, true); + view.setUint16(p + 2, dest, true); + p += 4; + } + c2m.add_section('LXCX', buf); + } + let map_bytes = new Uint8Array(1024); let map_view = new DataView(map_bytes.buffer); map_bytes[0] = stored_level.size_x; diff --git a/js/format-dat.js b/js/format-dat.js index 66bb52c..2ab85dd 100644 --- a/js/format-dat.js +++ b/js/format-dat.js @@ -348,7 +348,13 @@ function parse_level(bytes, number) { let trap_y = field_view.getUint16(q + 6, true); // Fifth u16 is always zero, possibly live game state q += 10; - level.custom_trap_wiring[button_x + button_y * level.size_x] = trap_x + trap_y * level.size_x; + // Connections are ignored if they're on the wrong tiles anyway, and we use a single + // mapping that's a bit more flexible, so only store valid connections + let s = level.coords_to_scalar(button_x, button_y); + let d = level.coords_to_scalar(trap_x, trap_y); + if (level.linear_cells[s][LAYERS.terrain].type.name === 'button_brown') { + level.custom_connections[s] = d; + } } } else if (field_type === 0x05) { @@ -361,7 +367,13 @@ function parse_level(bytes, number) { let cloner_x = field_view.getUint16(q + 4, true); let cloner_y = field_view.getUint16(q + 6, true); q += 8; - level.custom_cloner_wiring[button_x + button_y * level.size_x] = cloner_x + cloner_y * level.size_x; + // Connections are ignored if they're on the wrong tiles anyway, and we use a single + // mapping that's a bit more flexible, so only store valid connections + let s = level.coords_to_scalar(button_x, button_y); + let d = level.coords_to_scalar(cloner_x, cloner_y); + if (level.linear_cells[s][LAYERS.terrain].type.name === 'button_red') { + level.custom_connections[s] = d; + } } } else if (field_type === 0x06) { @@ -460,9 +472,11 @@ export function synthesize_level(stored_level) { let top_layer = []; let bottom_layer = []; let hint_text = null; + let monster_coords = []; let error_found_wires = false; // TODO i could be a little kinder and support, say, items on terrain; do those work in mscc? tw lynx? for (let [i, cell] of stored_level.linear_cells.entries()) { + let [x, y] = stored_level.scalar_to_coords(i); let actor = null; let other = null; for (let tile of cell) { @@ -480,12 +494,15 @@ export function synthesize_level(stored_level) { continue; } else if (other) { - let [x, y] = stored_level.scalar_to_coords(i); errors.push(`A cell can only contain one static tile, but cell (${x}, ${y}) has both ${other.type.name} and ${tile.type.name}`); } else { other = tile; } + + if (tile.type.is_monster) { + monster_coords.push(x, y); + } } let actor_byte = null; @@ -584,9 +601,14 @@ export function synthesize_level(stored_level) { if (hint_text !== null) { add_block(7, util.bytestring_to_buffer(hint_text.substring(0, 127) + "\0")); } - // Monster positions - // TODO this is dumb as hell but do it too - add_block(10, new ArrayBuffer); + // Monster positions (dumb as hell and only used in MS mode) + if (monster_coords.length > 0) { + if (monster_coords.length > 256) { + errors.push(`Level has ${monster_coords.length >> 1} monsters, but MS only supports up to 128`); + monster_coords.length = 256; + } + add_block(10, new Uint8Array(monster_coords).buffer); + } if (errors.length > 0) { throw new CCLEncodingErrors(errors); diff --git a/js/game.js b/js/game.js index 7009aa5..46b858f 100644 --- a/js/game.js +++ b/js/game.js @@ -637,14 +637,12 @@ export class Level extends LevelInterface { // Check for custom wiring, for MSCC .DAT levels // TODO would be neat if this applied to orange buttons too + // TODO RAINBOW TELEPORTER, ARBITRARY TILE TARGET HAHA if (this.stored_level.has_custom_connections) { let n = this.stored_level.coords_to_scalar(x, y); let target_cell_n = null; - if (connectable.type.name === 'button_brown') { - target_cell_n = this.stored_level.custom_trap_wiring[n] ?? null; - } - else if (connectable.type.name === 'button_red') { - target_cell_n = this.stored_level.custom_cloner_wiring[n] ?? null; + if (connectable.type.name === 'button_brown' || connectable.type.name === 'button_red') { + target_cell_n = this.stored_level.custom_connections[n] ?? null; } if (target_cell_n && target_cell_n < this.width * this.height) { let [tx, ty] = this.stored_level.scalar_to_coords(target_cell_n); diff --git a/js/main-editor.js b/js/main-editor.js index ecf0849..850f0bb 100644 --- a/js/main-editor.js +++ b/js/main-editor.js @@ -1313,6 +1313,103 @@ class TrackOperation extends MouseOperation { } } +class SVGConnection { + constructor(sx, sy, dx, dy) { + this.source = mk_svg('rect.-source', {width: 1, height: 1}); + this.line = mk_svg('line.-arrow', {}); + this.element = mk_svg('g.overlay-connection', this.source, this.line); + this.set_source(sx, sy); + this.set_dest(dx, dy); + } + + set_source(sx, sy) { + this.sx = sx; + this.sy = sy; + this.source.setAttribute('x', sx); + this.source.setAttribute('y', sy); + this.line.setAttribute('x1', sx + 0.5); + this.line.setAttribute('y1', sy + 0.5); + } + + set_dest(dx, dy) { + this.dx = dx; + this.dy = dy; + this.line.setAttribute('x2', dx + 0.5); + this.line.setAttribute('y2', dy + 0.5); + } +} +class ConnectOperation extends MouseOperation { + handle_press(x, y, ev) { + // TODO restrict to button/cloner unless holding shift + // TODO what do i do when you erase a button/cloner? can i detect if you're picking it up? + let src = this.editor.stored_level.coords_to_scalar(x, y); + if (this.alt_mode) { + // Auto connect using Lynx rules + let cell = this.cell(x, y); + let terrain = cell[LAYERS.terrain]; + let other = null; + let swap = false; + if (terrain.type.name === 'button_red') { + other = this.search_for(src, 'cloner', 1); + } + else if (terrain.type.name === 'cloner') { + other = this.search_for(src, 'button_red', -1); + swap = true; + } + else if (terrain.type.name === 'button_brown') { + other = this.search_for(src, 'trap', 1); + } + else if (terrain.type.name === 'trap') { + other = this.search_for(src, 'button_brown', -1); + swap = true; + } + + if (other !== null) { + if (swap) { + this.editor.set_custom_connection(other, src); + } + else { + this.editor.set_custom_connection(src, other); + } + this.editor.commit_undo(); + } + return; + } + this.pending_cxn = new SVGConnection(x, y, x, y); + this.editor.svg_overlay.append(this.pending_cxn.element); + } + // FIXME this is hella the sort of thing that should be on Editor, or in algorithms + search_for(i0, name, dir) { + let l = this.editor.stored_level.linear_cells.length; + let i = i0; + while (true) { + i += dir; + if (i < 0) { + i += l; + } + else if (i >= l) { + i -= l; + } + if (i === i0) + return null; + + let cell = this.editor.stored_level.linear_cells[i]; + let tile = cell[LAYERS.terrain]; + if (tile.type.name === name) { + return i; + } + } + } + handle_drag(client_x, client_y, frac_cell_x, frac_cell_y, cell_x, cell_y) { + } + commit_press() { + } + abort_press() { + this.pending_cxn.element.remove(); + } + cleanup_press() { + } +} class WireOperation extends MouseOperation { handle_press(x, y) { if (this.alt_mode) { @@ -1825,13 +1922,16 @@ const EDITOR_TOOLS = { shortcut: 'a', }, connect: { - // TODO not implemented icon: 'icons/tool-connect.png', name: "Connect", - desc: "Set up CC1 clone and trap connections", + // XXX shouldn't you be able to drag the destination? + // TODO mod + right click for RRO or diamond alg? ah but we only have ctrl available + desc: "Set up CC1-style clone and trap connections.\n(WIP)\nNOTE: Not supported in CC2!\nRight click: auto link using Lynx rules", + //desc: "Set up CC1-style clone and trap connections.\nNOTE: Not supported in CC2!\nLeft drag: link button with valid target\nCtrl-click: erase link\nRight click: auto link using Lynx rules", + op1: ConnectOperation, + op2: ConnectOperation, }, wire: { - // TODO not implemented icon: 'icons/tool-wire.png', name: "Wire", desc: "Edit CC2 wiring.\nLeft click: draw wires\nCtrl-click: erase wires\nRight click: toggle tunnels (floor only)", @@ -1851,7 +1951,7 @@ const EDITOR_TOOLS = { // slade when you have some selected? // TODO ah, railroads... }; -const EDITOR_TOOL_ORDER = ['pencil', 'select_box', 'fill', 'adjust', 'force-floors', 'tracks', 'wire', 'camera']; +const EDITOR_TOOL_ORDER = ['pencil', 'select_box', 'fill', 'adjust', 'force-floors', 'tracks', 'connect', 'wire', 'camera']; const EDITOR_TOOL_SHORTCUTS = {}; for (let [tool, tooldef] of Object.entries(EDITOR_TOOLS)) { if (tooldef.shortcut) { @@ -3256,7 +3356,20 @@ export class Editor extends PrimaryView { // FIXME need this in load_level which is called even if we haven't been setup yet this.connections_g = mk_svg('g'); // This SVG draws vectors on top of the editor, like monster paths and button connections - this.svg_overlay = mk_svg('svg.level-editor-overlay', {viewBox: '0 0 32 32'}, this.connections_g); + this.svg_overlay = mk_svg('svg.level-editor-overlay', {viewBox: '0 0 32 32'}, + mk_svg('defs', + mk_svg('marker', {id: 'overlay-arrowhead', markerWidth: 4, markerHeight: 4, refX: 3, refY: 2, orient: 'auto'}, + mk_svg('polygon', {points: '0 0, 4 2, 0 4'}), + ), + ), + mk_svg('filter', {id: 'overlay-filter-outline'}, + mk_svg('feMorphology', {'in': 'SourceAlpha', result: 'dilated', operator: 'dilate', radius: 0.03125}), + mk_svg('feFlood', {'flood-color': '#0009', result: 'fill'}), + mk_svg('feComposite', {'in': 'fill', in2: 'dilated', operator: 'in'}), + mk_svg('feComposite', {'in': 'SourceGraphic'}), + ), + this.connections_g, + ); this.viewport_el.append(this.renderer.canvas, this.svg_overlay); // This is done more correctly in setup(), but we need a sensible default so levels can be @@ -3436,12 +3549,20 @@ export class Editor extends PrimaryView { this.mouse_coords = [ev.clientX, ev.clientY]; // TODO move this into MouseOperation let [x, y] = this.renderer.cell_coords_from_event(ev); - if (this.is_in_bounds(x, y)) { + // TODO only do this stuff if the cell coords changed + let cell = this.cell(x, y); + if (cell) { this.svg_cursor.classList.add('--visible'); this.svg_cursor.setAttribute('x', x); this.svg_cursor.setAttribute('y', y); this.statusbar_cursor.textContent = `(${x}, ${y})`; + + // TODO don't /always/ do this. maybe make it optionally always visible, and have + // an inspection tool that does it on point + let terrain = cell[LAYERS.terrain]; + if (terrain.type.name === 'button_gray') { + } } else { this.svg_cursor.classList.remove('--visible'); @@ -3633,27 +3754,12 @@ export class Editor extends PrimaryView { url.searchParams.append('level', data); new EditorShareOverlay(this.conductor, url.toString()).open(); }], - ["Download level as C2M", () => { + ["Download level as C2M (new CC2 format)", () => { // TODO support getting warnings + errors out of synthesis let buf = c2g.synthesize_level(this.stored_level); util.trigger_local_download((this.stored_level.title || 'untitled') + '.c2m', new Blob([buf])); }], - ["Download level as MSCC DAT/CCL", () => { - // TODO support getting warnings out of synthesis? - let buf; - try { - buf = dat.synthesize_level(this.stored_level); - } - catch (errs) { - if (errs instanceof dat.CCLEncodingErrors) { - new EditorExportFailedOverlay(this.conductor, errs.errors).open(); - return; - } - throw errs; - } - util.trigger_local_download((this.stored_level.title || 'untitled') + '.ccl', new Blob([buf])); - }], - ["Download pack as C2G", () => { + ["Download pack as C2G (new CC2 format)", () => { let stored_pack = this.conductor.stored_game; // This is pretty heckin' best-effort for now; TODO move into format-c2g? @@ -3699,6 +3805,21 @@ export class Editor extends PrimaryView { // TODO support getting warnings + errors out of synthesis util.trigger_local_download((stored_pack.title || 'untitled') + '.zip', new Blob([u8array])); }], + ["Download level as CCL (old CC1 format)", () => { + // TODO support getting warnings out of synthesis? + let buf; + try { + buf = dat.synthesize_level(this.stored_level); + } + catch (errs) { + if (errs instanceof dat.CCLEncodingErrors) { + new EditorExportFailedOverlay(this.conductor, errs.errors).open(); + return; + } + throw errs; + } + util.trigger_local_download((this.stored_level.title || 'untitled') + '.ccl', new Blob([buf])); + }], ]; this.export_menu = new MenuOverlay( this.conductor, @@ -4186,15 +4307,17 @@ export class Editor extends PrimaryView { } // Load connections - // TODO cloners too + // TODO what if the source tile is not connectable? + // TODO there's a has_custom_connections flag, is that important here or is it just because + // i can't test an object as a bool this.connections_g.textContent = ''; - for (let [src, dest] of Object.entries(this.stored_level.custom_trap_wiring)) { + this.connections_elements = {}; + for (let [src, dest] of Object.entries(this.stored_level.custom_connections)) { let [sx, sy] = this.stored_level.scalar_to_coords(src); let [dx, dy] = this.stored_level.scalar_to_coords(dest); - this.connections_g.append( - mk_svg('rect.overlay-cxn', {x: sx, y: sy, width: 1, height: 1}), - mk_svg('line.overlay-cxn', {x1: sx + 0.5, y1: sy + 0.5, x2: dx + 0.5, y2: dy + 0.5}), - ); + let el = new SVGConnection(sx, sy, dx, dy).element; + this.connections_elements[src] = el; + this.connections_g.append(el); } // TODO why are these in connections_g lol for (let [i, region] of this.stored_level.camera_regions.entries()) { @@ -4505,16 +4628,11 @@ export class Editor extends PrimaryView { // Utility/inspection is_in_bounds(x, y) { - return 0 <= x && x < this.stored_level.size_x && 0 <= y && y < this.stored_level.size_y; + return this.stored_level.is_point_within_bounds(x, y); } cell(x, y) { - if (this.is_in_bounds(x, y)) { - return this.stored_level.linear_cells[this.stored_level.coords_to_scalar(x, y)]; - } - else { - return null; - } + return this.stored_level.cell(x, y); } // ------------------------------------------------------------------------------------------------ @@ -4655,6 +4773,35 @@ export class Editor extends PrimaryView { this.commit_undo(); } + // Create a connection between two cells and update the UI accordingly. If dest is null or + // undefined, delete any existing connection instead. + set_custom_connection(src, dest) { + let prev = this.stored_level.custom_connections[src]; + this._do( + () => this._set_custom_connection(src, dest), + () => this._set_custom_connection(src, prev), + ); + } + _set_custom_connection(src, dest) { + if (this.connections_elements[src]) { + this.connections_elements[src].remove(); + } + + if ((dest ?? null) === null) { + delete this.stored_level.custom_connections[src]; + delete this.connections_elements[src]; + } + else { + this.stored_level.custom_connections[src] = dest; + let el = new SVGConnection( + ...this.stored_level.scalar_to_coords(src), + ...this.stored_level.scalar_to_coords(dest), + ).element; + this.connections_elements[src] = el; + this.connections_g.append(el); + } + } + // ------------------------------------------------------------------------------------------------ // Undo/redo diff --git a/js/util.js b/js/util.js index 8122e88..66f5fb0 100644 --- a/js/util.js +++ b/js/util.js @@ -48,6 +48,22 @@ export function mk_svg(tag_selector, ...children) { return _mk(el, children); } +export function trigger_local_download(filename, blob) { + let url = URL.createObjectURL(blob); + // To download a file, um, make an and click it. Not kidding + let a = mk('a', { + href: url, + download: filename, + }); + document.body.append(a); + a.click(); + // Absolutely no idea when I'm allowed to revoke this, but surely a minute is safe + window.setTimeout(() => { + a.remove(); + URL.revokeObjectURL(url); + }, 60 * 1000); +} + export function handle_drop(element, options) { let dropzone_class = options.dropzone_class ?? null; let on_drop = options.on_drop; diff --git a/style.css b/style.css index 9a41060..94b2746 100644 --- a/style.css +++ b/style.css @@ -1839,7 +1839,7 @@ body.--debug #player-debug { --scale: 1; } /* SVG overlays */ -#editor svg.level-editor-overlay { +svg.level-editor-overlay { position: absolute; top: 0; bottom: 0; @@ -1852,21 +1852,21 @@ body.--debug #player-debug { stroke-width: 0.0625; fill: none; } -#editor .level-editor-overlay .overlay-transient { +svg.level-editor-overlay .overlay-transient { display: none; } -#editor .level-editor-overlay .overlay-transient.--visible { +svg.level-editor-overlay .overlay-transient.--visible { display: initial; } -#editor .level-editor-overlay rect.overlay-cursor { +svg.level-editor-overlay rect.overlay-cursor { x-stroke: hsla(225, 100%, 60%, 0.5); fill: hsla(225, 100%, 75%, 0.25); } -#editor .level-editor-overlay rect.overlay-pending-selection { +svg.level-editor-overlay rect.overlay-pending-selection { stroke: hsla(225, 100%, 60%, 0.5); fill: hsla(225, 100%, 75%, 0.25); } -#editor .level-editor-overlay rect.overlay-selection { +svg.level-editor-overlay rect.overlay-selection { stroke: #000c; fill: hsla(225, 0%, 75%, 0.25); stroke-dasharray: 0.125, 0.125; @@ -1882,22 +1882,26 @@ body.--debug #player-debug { stroke-dashoffset: 0; } } -#editor .level-editor-overlay rect.overlay-cxn { - stroke: red; +#overlay-arrowhead { + fill: hsl(345, 75%, 75%); } -#editor .level-editor-overlay line.overlay-cxn { - stroke: red; +svg.level-editor-overlay g.overlay-connection { + stroke: hsl(345, 75%, 75%); + filter: url(#overlay-filter-outline); } -#editor .level-editor-overlay rect.overlay-camera { +svg.level-editor-overlay g.overlay-connection line.-arrow { + marker-end: url(#overlay-arrowhead); +} +svg.level-editor-overlay rect.overlay-camera { stroke: #808080; fill: #80808040; pointer-events: auto; } -#editor .level-editor-overlay text { +svg.level-editor-overlay text { /* Each cell is one "pixel", so text needs to be real small */ font-size: 1px; } -#editor .level-editor-overlay text.overlay-edit-tip { +svg.level-editor-overlay text.overlay-edit-tip { stroke: none; fill: black; }