diff --git a/js/algorithms.js b/js/algorithms.js index 3038695..3b65bac 100644 --- a/js/algorithms.js +++ b/js/algorithms.js @@ -1,4 +1,65 @@ -import { DIRECTIONS } from './defs.js'; +import { DIRECTIONS, LAYERS } from './defs.js'; + +// Iterates over every terrain tile in the grid that has one of the given types (a Set of type +// names), in linear order, optionally in reverse. The starting cell is checked last. +// Yields [tile, cell]. +export function* find_terrain_linear(levelish, start_cell, type_names, reverse = false) { + let i = levelish.coords_to_scalar(start_cell.x, start_cell.y); + while (true) { + if (reverse) { + i -= 1; + if (i < 0) { + i += levelish.size_x * levelish.size_y; + } + } + else { + i += 1; + i %= levelish.size_x * levelish.size_y; + } + + let cell = levelish.linear_cells[i]; + let tile = cell[LAYERS.terrain]; + if (tile && type_names.has(tile.type.name)) { + yield [tile, cell]; + } + + if (cell === start_cell) + return; + } +} + +// Iterates over every terrain tile in the grid that has one of the given types (a Set of type +// names), spreading outward in a diamond pattern. The starting cell is not included. +// Only used by orange buttons. +// Yields [tile, cell]. +export function* find_terrain_diamond(levelish, start_cell, type_names) { + let max_search_radius = ( + Math.max(start_cell.x, levelish.size_x - start_cell.x) + + Math.max(start_cell.y, levelish.size_y - start_cell.y)); + for (let dist = 1; dist <= max_search_radius; dist++) { + // Start east and move counterclockwise + let sx = start_cell.x + dist; + let sy = start_cell.y; + for (let direction of [[-1, -1], [-1, 1], [1, 1], [1, -1]]) { + for (let i = 0; i < dist; i++) { + let cell = levelish.cell(sx, sy); + sx += direction[0]; + sy += direction[1]; + + if (! cell) + continue; + let terrain = cell[LAYERS.terrain]; + if (type_names.has(terrain.type.name)) { + yield [terrain, cell]; + } + } + } + } +} + +// TODO make this guy work generically for orange, red, brown buttons? others...? +export function find_implicit_connection() { +} export function trace_floor_circuit(level, start_cell, start_edge, on_wire, on_dead_end) { let is_first = true; @@ -120,28 +181,3 @@ export function find_matching_wire_tunnel(level, x, y, direction) { } } } - -// TODO make this guy work generically for orange, red, brown buttons? others...? -export function find_implicit_connection() { -} - -// Iterates over a grid in a diamond pattern, spreading out from the given start cell (but not -// including it). Only used for connecting orange buttons. -export function* iter_cells_in_diamond(levelish, x0, y0) { - let max_search_radius = Math.max(levelish.size_x, levelish.size_y) + 1; - for (let dist = 1; dist <= max_search_radius; dist++) { - // Start east and move counterclockwise - let sx = x0 + dist; - let sy = y0; - for (let direction of [[-1, -1], [-1, 1], [1, 1], [1, -1]]) { - for (let i = 0; i < dist; i++) { - let cell = levelish.cell(sx, sy); - if (cell) { - yield cell; - } - sx += direction[0]; - sy += direction[1]; - } - } - } -} diff --git a/js/editor/helpers.js b/js/editor/helpers.js index f87cd53..c23d50d 100644 --- a/js/editor/helpers.js +++ b/js/editor/helpers.js @@ -4,12 +4,15 @@ import { BitVector, mk, mk_svg } from '../util.js'; export class SVGConnection { constructor(sx, sy, dx, dy) { - this.source = mk_svg('circle.-source', {r: 0.5}); + this.source = mk_svg('circle.-source', {r: 0.5, cx: sx + 0.5, cy: sy + 0.5}); this.line = mk_svg('line.-arrow', {}); - this.dest = mk_svg('rect.-dest', {width: 1, height: 1}); + this.dest = mk_svg('rect.-dest', {x: dx, y: dy, width: 1, height: 1}); this.element = mk_svg('g.overlay-connection', this.source, this.line, this.dest); - this.set_source(sx, sy); - this.set_dest(dx, dy); + this.sx = sx; + this.sy = sy; + this.dx = dx; + this.dy = dy; + this._update_line_endpoints(); } set_source(sx, sy) { @@ -17,17 +20,35 @@ export class SVGConnection { this.sy = sy; this.source.setAttribute('cx', sx + 0.5); this.source.setAttribute('cy', sy + 0.5); - this.line.setAttribute('x1', sx + 0.5); - this.line.setAttribute('y1', sy + 0.5); + this._update_line_endpoints(); } set_dest(dx, dy) { this.dx = dx; this.dy = dy; - this.line.setAttribute('x2', dx + 0.5); - this.line.setAttribute('y2', dy + 0.5); this.dest.setAttribute('x', dx); this.dest.setAttribute('y', dy); + this._update_line_endpoints(); + } + + _update_line_endpoints() { + // Start the line at the edge of the circle, so, add 0.5 in the direction of the line + let vx = this.dx - this.sx; + let vy = this.dy - this.sy; + let line_length = Math.sqrt(vx*vx + vy*vy); + let trim_x = 0; + let trim_y = 0; + if (line_length >= 1) { + trim_x = 0.5 * vx / line_length; + trim_y = 0.5 * vy / line_length; + } + this.line.setAttribute('x1', this.sx + 0.5 + trim_x); + this.line.setAttribute('y1', this.sy + 0.5 + trim_y); + // Technically this isn't quite right, since the ending is a square and the arrowhead will + // poke into it a bit from angles near 45°, but that requires a bit more trig than seems + // worth it + this.line.setAttribute('x2', this.dx + 0.5 - trim_x); + this.line.setAttribute('y2', this.dy + 0.5 - trim_y); } } diff --git a/js/editor/main.js b/js/editor/main.js index e2ac8f1..7647661 100644 --- a/js/editor/main.js +++ b/js/editor/main.js @@ -1,5 +1,6 @@ import * as fflate from '../vendor/fflate.js'; +import * as algorithms from '../algorithms.js'; import { DIRECTIONS, LAYERS } from '../defs.js'; import * as format_base from '../format-base.js'; import * as c2g from '../format-c2g.js'; @@ -81,7 +82,7 @@ export class Editor extends PrimaryView { this.renderer.show_facing = true; // FIXME need this in load_level which is called even if we haven't been setup yet - this.connections_g = mk_svg('g'); + this.connections_g = mk_svg('g', {'data-name': 'connections'}); // 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'}, mk_svg('defs', @@ -89,9 +90,13 @@ export class Editor extends PrimaryView { 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'}), + this._filter_morphology_element = mk_svg('feMorphology', {'in': 'SourceAlpha', result: 'dilated', operator: 'dilate', radius: 0.03125}), + this._filter_morphology_element2 = mk_svg('feMorphology', {'in': 'SourceAlpha', result: 'dilated2', operator: 'dilate', radius: 0.0625}), + mk_svg('feFlood', {'flood-color': '#000'}), + mk_svg('feComposite', {in2: 'dilated', operator: 'in', result: 'fill'}), + mk_svg('feFlood', {'flood-color': '#fffc'}), + mk_svg('feComposite', {in2: 'dilated2', operator: 'in', result: 'fill2'}), + mk_svg('feComposite', {'in': 'fill', in2: 'fill2'}), mk_svg('feComposite', {'in': 'SourceGraphic'}), ), ), @@ -1043,16 +1048,16 @@ export class Editor extends PrimaryView { // Load connections // 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 = ''; - this.connections_elements = {}; - for (let [src, dest] of Object.entries(this.stored_level.custom_connections)) { + this.connections_arrows = {}; + for (let [src, dest] of 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); - let el = new SVGConnection(sx, sy, dx, dy).element; - this.connections_elements[src] = el; - this.connections_g.append(el); + let arrow = new SVGConnection(sx, sy, dx, dy); + this.connections_arrows[src] = arrow; + arrow.element.setAttribute( + 'data-source', this.stored_level.linear_cells[src][LAYERS.terrain].type.name); + this.connections_g.append(arrow.element); } // TODO why are these in connections_g lol for (let [i, region] of this.stored_level.camera_regions.entries()) { @@ -1060,6 +1065,9 @@ export class Editor extends PrimaryView { this.connections_g.append(el); } + // Load *implicit* connections + this.recreate_implicit_connections(); + this.renderer.set_level(stored_level); if (this.active) { this.redraw_entire_level(); @@ -1112,6 +1120,9 @@ export class Editor extends PrimaryView { this.svg_overlay.style.setProperty('--scale', this.zoom); this.actual_viewport_el.classList.toggle('--crispy', this.zoom >= 1); this.statusbar_zoom.textContent = `${this.zoom * 100}%`; + // The arrow outline isn't CSS, so we have to fix it manually + this._filter_morphology_element.setAttribute('radius', 0.03125 / this.zoom); + this._filter_morphology_element2.setAttribute('radius', 0.0625 / this.zoom); let index = ZOOM_LEVELS.findIndex(el => el >= this.zoom); if (index < 0) { @@ -1737,35 +1748,96 @@ export class Editor extends PrimaryView { ); } + // ------------------------------------------------------------------------------------------------ + // Connections (buttons to things they control) + // 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]; + let prev = this.stored_level.custom_connections.get(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]; + if (this.connections_arrows[src]) { + this.connections_arrows[src].element.remove(); + } + this.stored_level.custom_connections.delete(src) + delete this.connections_arrows[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); + this.stored_level.custom_connections.set(src, dest); + + if (this.connections_arrows[src]) { + this.connections_arrows[src].set_dest( + ...this.stored_level.scalar_to_coords(dest)); + } + else { + let arrow = new SVGConnection( + ...this.stored_level.scalar_to_coords(src), + ...this.stored_level.scalar_to_coords(dest)); + this.connections_arrows[src] = arrow; + this.connections_g.append(arrow.element); + } + this.connections_arrows[src].element.setAttribute( + 'data-source', this.stored_level.linear_cells[src][LAYERS.terrain].type.name); } } + // TODO also use this to indicate traps or flame jets that are initially toggled (trickier with + // flame jets since that can make them look like they're the wrong tile...) + recreate_implicit_connections() { + this.implicit_connections = new Map; + + for (let el of this.connections_g.querySelectorAll(':scope > .--implicit')) { + el.remove(); + } + + for (let [n, cell] of this.stored_level.linear_cells.entries()) { + if (this.stored_level.custom_connections.has(n)) + continue; + + let terrain = cell[LAYERS.terrain]; + if (! terrain.type.connects_to) + continue; + + let find_func; + if (terrain.type.connect_order === 'forward') { + find_func = algorithms.find_terrain_linear; + } + else if (terrain.type.connect_order === 'diamond') { + find_func = algorithms.find_terrain_diamond; + } + + let target_cell = null; + for (let [found_tile, found_cell] of find_func(this.stored_level, cell, terrain.type.connects_to)) { + target_cell = found_cell; + break; + } + if (target_cell === null) + continue; + + let svg = new SVGConnection( + ...this.stored_level.scalar_to_coords(n), + target_cell.x, target_cell.y); + this.implicit_connections.set(n, { + index: this.stored_level.coords_to_scalar(target_cell.x, target_cell.y), + svg_connection: svg, + }); + svg.element.classList.add('--implicit'); + svg.element.setAttribute('data-source', terrain.type.name); + this.connections_g.append(svg.element); + } + } + + update_implicit_connection(cell, tile, previous_type) { + // TODO actually this should also update explicit ones, if the source/dest types are changed + // in such a way as to make the connection invalid + } + // ------------------------------------------------------------------------------------------------ // Undo/redo diff --git a/js/editor/mouseops.js b/js/editor/mouseops.js index 38199f2..b448ef3 100644 --- a/js/editor/mouseops.js +++ b/js/editor/mouseops.js @@ -17,6 +17,7 @@ import { TILES_WITH_PROPS } from './tile-overlays.js'; // - no ice drawing tool // - cursor box shows with selection tool which seems inappropriate // - controls do not exactly stand out and are just plain text +// - set trap as initially open? feels like a weird hack. but it does appear in cc2lp1 const MOUSE_BUTTON_MASKS = [1, 4, 2]; // MouseEvent.button/buttons are ordered differently export class MouseOperation { constructor(editor, physical_button) { @@ -57,7 +58,7 @@ export class MouseOperation { // appropriate, and its position will be updated to match the position of the cursor set_cursor_element(el) { this.cursor_element = el; - el.setAttribute('data-mouseop', this.constructor.name); + el.setAttribute('data-name', this.constructor.name); if (! this.is_hover_visible) { el.style.display = 'none'; } @@ -834,14 +835,40 @@ export class TrackOperation extends MouseOperation { } export class ConnectOperation extends MouseOperation { + constructor(...args) { + super(...args); + + // This is the SVGConnection structure but with only the source circle + this.connectable_circle = mk_svg('circle.-source', {r: 0.5}); + this.connectable_cursor = mk_svg('g.overlay-connection', this.connectable_circle); + this.connectable_cursor.style.display = 'none'; + // TODO how do i distinguish from existing ones + this.connectable_cursor.style.stroke = 'lime'; + this.editor.svg_overlay.append(this.connectable_cursor); + } + + handle_hover(client_x, client_y, frac_cell_x, frac_cell_y, cell_x, cell_y) { + let cell = this.cell(cell_x, cell_y); + let terrain = cell[LAYERS.terrain]; + if (terrain.type.connects_to) { + this.connectable_cursor.style.display = ''; + this.connectable_circle.setAttribute('cx', cell_x + 0.5); + this.connectable_circle.setAttribute('cy', cell_y + 0.5); + } + else { + this.connectable_cursor.style.display = 'none'; + } + } + handle_press(x, y) { // 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); + let cell = this.cell(x, y); + let terrain = cell[LAYERS.terrain]; if (this.alt_mode) { // Auto connect using Lynx rules - let cell = this.cell(x, y); - let terrain = cell[LAYERS.terrain]; + // TODO just use the editor's existing implicits for this? let other = null; let swap = false; if (terrain.type.name === 'button_red') { @@ -870,8 +897,17 @@ export class ConnectOperation extends MouseOperation { } return; } + + // Otherwise, this is the start of a drag + if (! terrain.type.connects_to) + return; + this.pending_cxn = new SVGConnection(x, y, x, y); + this.pending_source = src; + this.pending_type = terrain.type.name; this.editor.svg_overlay.append(this.pending_cxn.element); + // Hide the normal cursor for the duration + this.connectable_cursor.style.display = 'none'; } // FIXME this is hella the sort of thing that should be on Editor, or in algorithms search_for(i0, name, dir) { @@ -896,14 +932,46 @@ export class ConnectOperation extends MouseOperation { } } handle_drag(client_x, client_y, frac_cell_x, frac_cell_y, cell_x, cell_y) { + if (! this.pending_cxn) + return; + + this.pending_cxn.set_dest(cell_x, cell_y); + + let cell = this.cell(cell_x, cell_y); + if (TILE_TYPES[this.pending_type].connects_to.has(cell[LAYERS.terrain].type.name)) { + this.pending_target = this.editor.stored_level.coords_to_scalar(cell_x, cell_y); + this.pending_cxn.element.style.opacity = 0.5; + } + else { + this.pending_target = null; + this.pending_cxn.element.style.opacity = ''; + } } commit_press() { + // TODO + if (! this.pending_cxn) + return; + + if (this.pending_target !== null) { + this.editor.set_custom_connection(this.pending_source, this.pending_target); + } + + this.pending_cxn.element.remove(); + this.pending_cxn = null; } abort_press() { - this.pending_cxn.element.remove(); + if (this.pending_cxn) { + this.pending_cxn.element.remove(); + this.pending_cxn = null; + } } cleanup_press() { } + + do_destroy() { + this.connectable_cursor.remove(); + super.do_destroy(); + } } export class WireOperation extends MouseOperation { handle_press() { diff --git a/js/format-base.js b/js/format-base.js index f13b453..fe8c8fb 100644 --- a/js/format-base.js +++ b/js/format-base.js @@ -1,4 +1,4 @@ -import { LAYERS } from './defs.js'; +import { DIRECTIONS, LAYERS } from './defs.js'; import * as util from './util.js'; export class StoredCell extends Array { @@ -89,6 +89,11 @@ export class LevelInterface { return null; } } + + get_neighboring_cell(cell, direction) { + let move = DIRECTIONS[direction].movement; + return this.cell(cell.x + move[0], cell.y + move[1]); + } } export class StoredLevel extends LevelInterface { @@ -128,8 +133,10 @@ export class StoredLevel extends LevelInterface { this.linear_cells = []; // Maps of button positions to trap/cloner positions, as scalars - this.has_custom_connections = false; - this.custom_connections = {}; + // Not supported by Steam CC2, but supported by Tile World even in Lynx mode + this.custom_connections = new Map; + // If true, Lynx-style implicit connections don't work at all + this.only_custom_connections = false; // 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 752308d..21abbcc 100644 --- a/js/format-c2g.js +++ b/js/format-c2g.js @@ -1332,12 +1332,11 @@ export function parse_level(buf, number = 1) { 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; + level.custom_connections.set(src, dest); p += 4; } } @@ -1572,12 +1571,12 @@ export function synthesize_level(stored_level) { // Store MSCC-like custom connections // TODO LL feature, should be distinguished somehow - let num_connections = Object.keys(stored_level.custom_connections).length; + let num_connections = stored_level.custom_connections.size; 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)) { + for (let [src, dest] of stored_level.custom_connections) { view.setUint16(p + 0, src, true); view.setUint16(p + 2, dest, true); p += 4; diff --git a/js/format-dat.js b/js/format-dat.js index a5e41e9..f1d97c9 100644 --- a/js/format-dat.js +++ b/js/format-dat.js @@ -209,7 +209,7 @@ export function parse_level_metadata(bytes) { function parse_level(bytes, number) { let level = new format_base.StoredLevel(number); - level.has_custom_connections = true; + level.only_custom_connections = true; level.format = 'ccl'; level.uses_ll_extensions = false; level.chips_required = 0; @@ -353,7 +353,7 @@ function parse_level(bytes, number) { 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; + level.custom_connections.set(s, d); } } } @@ -372,7 +372,7 @@ function parse_level(bytes, number) { 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; + level.custom_connections.set(s, d); } } } @@ -561,9 +561,10 @@ export function synthesize_level(stored_level) { else if (other.type.name === 'button_brown') { cxn_target = 'trap'; } - if (cxn_target && i in stored_level.custom_connections) { - let dest = stored_level.custom_connections[i]; + if (cxn_target && stored_level.custom_connections.has(i)) { + let dest = stored_level.custom_connections.get(i); let dest_cell = stored_level.linear_cells[dest]; + // FIXME these need to be sorted by destination actually if (dest_cell && dest_cell[LAYERS.terrain].type.name === cxn_target) { if (other.type.name === 'button_red') { cloner_cxns.push(x, y, ...stored_level.scalar_to_coords(dest)); @@ -620,7 +621,7 @@ export function synthesize_level(stored_level) { // TODO do something with not-ascii; does TW support utf8 or latin1 or anything? add_block(3, util.bytestring_to_buffer(stored_level.title.substring(0, 63) + "\0")); // Trap and cloner connections - function to_words(cxns) { + function encode_connections(cxns) { let words = new ArrayBuffer(cxns.length * 2); let view = new DataView(words); for (let [i, val] of cxns.entries()) { @@ -629,10 +630,10 @@ export function synthesize_level(stored_level) { return words; } if (trap_cxns.length > 0) { - add_block(4, to_words(trap_cxns)); + add_block(4, encode_connections(trap_cxns)); } if (cloner_cxns.length > 0) { - add_block(5, to_words(cloner_cxns)); + add_block(5, encode_connections(cloner_cxns)); } // Password // TODO support this for real lol diff --git a/js/game.js b/js/game.js index 703dfd9..f542276 100644 --- a/js/game.js +++ b/js/game.js @@ -476,53 +476,39 @@ export class Level extends LevelInterface { let cell = connectable.cell; let x = cell.x; let y = cell.y; - // FIXME this is a single string for red/brown buttons (to match iter_tiles_in_RO) but a - // set for orange buttons (because flame jet states are separate tiles), which sucks ass let goals = connectable.type.connects_to; // 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) { + if (this.stored_level.custom_connections.size > 0) { let n = this.stored_level.coords_to_scalar(x, y); - let target_cell_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 target_cell_n = this.stored_level.custom_connections.get(n) ?? null; + if (target_cell_n !== null && target_cell_n < this.width * this.height) { let [tx, ty] = this.stored_level.scalar_to_coords(target_cell_n); for (let tile of this.cell(tx, ty)) { - if (tile && goals === tile.type.name) { + if (tile && goals.has(tile.type.name)) { connectable.connection = tile; break; } } } - return; + + if (this.stored_level.only_custom_connections) + return; } // Orange buttons do a really weird diamond search if (connectable.type.connect_order === 'diamond') { - for (let cell of algorithms.iter_cells_in_diamond( - this, connectable.cell.x, connectable.cell.y)) - { - let target = null; - for (let tile of cell) { - if (tile && goals.has(tile.type.name)) { - target = tile; - break; - } - } - if (target !== null) { - connectable.connection = target; - break; - } + for (let [tile, _cell] of algorithms.find_terrain_diamond(this, cell, goals)) { + connectable.connection = tile; + break; } return; } // Otherwise, look in reading order - for (let tile of this.iter_tiles_in_reading_order(cell, goals)) { + for (let [tile, _cell] of algorithms.find_terrain_linear(this, cell, goals)) { // TODO ideally this should be a weak connection somehow, since dynamite can destroy // empty cloners and probably traps too connectable.connection = tile; @@ -2366,67 +2352,6 @@ export class Level extends LevelInterface { // Level inspection ------------------------------------------------------------------------------- - get_neighboring_cell(cell, direction) { - let move = DIRECTIONS[direction].movement; - return this.cell(cell.x + move[0], cell.y + move[1]); - } - - // Iterates over the grid in (reverse?) reading order and yields all tiles with the given name. - // The starting cell is iterated last. - *iter_tiles_in_reading_order(start_cell, name, reverse = false) { - let i = this.coords_to_scalar(start_cell.x, start_cell.y); - let index = TILE_TYPES[name].layer; - while (true) { - if (reverse) { - i -= 1; - if (i < 0) { - i += this.size_x * this.size_y; - } - } - else { - i += 1; - i %= this.size_x * this.size_y; - } - - let cell = this.linear_cells[i]; - let tile = cell[index]; - if (tile && tile.type.name === name) { - yield tile; - } - - if (cell === start_cell) - return; - } - } - - // Same as above, but accepts multiple tiles - *iter_tiles_in_reading_order_multiple(start_cell, names, reverse = false) { - let i = this.coords_to_scalar(start_cell.x, start_cell.y); - let index = TILE_TYPES[names[0]].layer; - while (true) { - if (reverse) { - i -= 1; - if (i < 0) { - i += this.size_x * this.size_y; - } - } - else { - i += 1; - i %= this.size_x * this.size_y; - } - - let cell = this.linear_cells[i]; - let tile = cell[index]; - // FIXME probably uh do a lookup here - if (tile && names.indexOf(tile.type.name) >= 0) { - yield tile; - } - - if (cell === start_cell) - return; - } - } - // FIXME require_stub should really just care whether we ourselves /can/ contain wire, and also // we should check that on our neighbor is_tile_wired(tile, require_stub = true) { diff --git a/js/tiletypes.js b/js/tiletypes.js index caf4207..3c46945 100644 --- a/js/tiletypes.js +++ b/js/tiletypes.js @@ -1,3 +1,4 @@ +import { find_terrain_linear } from './algorithms.js'; import { COLLISION, DIRECTIONS, DIRECTION_ORDER, LAYERS, TICS_PER_SECOND, PICKUP_PRIORITIES } from './defs.js'; // TODO factor out some repeated stuff: common monster bits, common item bits, repeated collision @@ -731,6 +732,8 @@ const TILE_TYPES = { speed_factor: 0.5, }, grass: { + // TODO should bugs leave if they have no other option...? that seems real hard. + // TODO teeth move at full speed? that also seems hard. layer: LAYERS.terrain, blocks_collision: COLLISION.block_cc1 | COLLISION.block_cc2, blocks(me, level, other) { @@ -1778,7 +1781,9 @@ const TILE_TYPES = { // TODO cc2 has a bug where, once it wraps around to the bottom right, it seems to // forget that it was ever looking for an unwired teleport and will just grab the // first one it sees - for (let dest of level.iter_tiles_in_reading_order_multiple(me.cell, ['teleport_blue', 'teleport_blue_exit'], true)) { + for (let [dest, cell] of find_terrain_linear( + level, me.cell, new Set(['teleport_blue', 'teleport_blue_exit']), true)) + { if (! dest.wire_directions) { yield [dest, exit_direction]; } @@ -1871,13 +1876,13 @@ const TILE_TYPES = { // wires are directly connected to another neighboring wire. let iterable; if (me.is_active) { - iterable = level.iter_tiles_in_reading_order(me.cell, 'teleport_red'); + iterable = find_terrain_linear(level, me.cell, new Set(['teleport_red'])); } else { - iterable = [me]; + iterable = [[me, me.cell]]; } let exit_direction = other.direction; - for (let tile of iterable) { + for (let [tile, cell] of iterable) { // Red teleporters allow exiting in any direction, searching clockwise, except for // the teleporter you entered if (tile === me) { @@ -1926,7 +1931,7 @@ const TILE_TYPES = { // This iterator starts on the /next/ teleporter, so we appear last, and we can index // from zero to the second-to-last element. - let all = Array.from(level.iter_tiles_in_reading_order(me.cell, 'teleport_green')); + let all = Array.from(find_terrain_linear(level, me.cell, new Set(['teleport_green']))); if (all.length <= 1) { // If this is the only teleporter, just walk out the other side — and, crucially, do // NOT advance the PRNG @@ -1938,6 +1943,7 @@ const TILE_TYPES = { let exit_direction = DIRECTION_ORDER[level.prng() % 4]; let candidates; + all = all.map(([tile, cell]) => tile); if (level.compat.green_teleports_can_fail) { // CC2 bug emulation: only look through "unclogged" exits candidates = all.filter(tile => tile === me || ! tile.cell.get_actor()); @@ -1982,7 +1988,7 @@ const TILE_TYPES = { allow_player_override: true, *teleport_dest_order(me, level, other) { let exit_direction = other.direction; - for (let dest of level.iter_tiles_in_reading_order(me.cell, 'teleport_yellow', true)) { + for (let [dest, cell] of find_terrain_linear(level, me.cell, new Set(['teleport_yellow']), true)) { yield [dest, exit_direction]; } }, @@ -2113,7 +2119,7 @@ const TILE_TYPES = { }, button_brown: { layer: LAYERS.terrain, - connects_to: 'trap', + connects_to: new Set(['trap']), connect_order: 'forward', on_ready(me, level) { // Inform the trap of any actors that start out holding us down @@ -2145,7 +2151,7 @@ const TILE_TYPES = { }, button_red: { layer: LAYERS.terrain, - connects_to: 'cloner', + connects_to: new Set(['cloner']), connect_order: 'forward', on_arrive(me, level, other) { level.sfx.play_once('button-press', me.cell); diff --git a/style.css b/style.css index 9c44b09..238bddb 100644 --- a/style.css +++ b/style.css @@ -1514,7 +1514,7 @@ body.--debug .player-overlay-message { background: radial-gradient(#0004, hsla(330, 10%, 10%, 0.5) 40%, hsl(330, 20%, 10%)); } .player-overlay-message[data-reason=success] { - background: radial-gradient(hsla(var(--main-hue), 60%, 5%, 0.75), 60%, hsla(var(--main-hue), 60%, 25%, 0.75)); + background: radial-gradient(hsla(30, 80%, 10%, 0.75), 60%, hsla(40, 100%, 30%, 0.75)); } .player-overlay-message[data-reason=ended] { /* Rearrange this entirely, to fit the ending image in */ @@ -2219,7 +2219,7 @@ svg.level-editor-overlay { * consistent size at any zoom level */ --scale: 1; /* default svg properties */ - stroke-width: calc(0.0625px / var(--scale)); + stroke-width: calc(0.125px / var(--scale)); fill: none; } svg.level-editor-overlay .overlay-transient { @@ -2228,7 +2228,7 @@ svg.level-editor-overlay .overlay-transient { svg.level-editor-overlay .overlay-transient.--visible { display: initial; } -svg.level-editor-overlay rect.overlay-cursor { +svg.level-editor-overlay rect.overlay-pencil-cursor { stroke: hsla(var(--main-hue), 100%, 90%, 0.75); fill: hsla(var(--main-hue), 100%, 75%, 0.25); } @@ -2264,11 +2264,24 @@ svg.level-editor-overlay path.overlay-selection.--floating { } #overlay-arrowhead { fill: white; + fill: context-stroke; } svg.level-editor-overlay g.overlay-connection { - stroke: white; + stroke: #e4e4e4; filter: url(#overlay-filter-outline); } +svg.level-editor-overlay g.overlay-connection[data-source=button_red] { + stroke: hsl(0, 90%, 60%); +} +svg.level-editor-overlay g.overlay-connection[data-source=button_brown] { + stroke: hsl(20, 60%, 60%); +} +svg.level-editor-overlay g.overlay-connection[data-source=button_orange] { + stroke: hsl(30, 90%, 60%); +} +svg.level-editor-overlay g.overlay-connection.--implicit line.-arrow { + stroke-dasharray: calc(0.25px / var(--scale)), calc(0.25px / var(--scale)); +} svg.level-editor-overlay g.overlay-connection line.-arrow { marker-end: url(#overlay-arrowhead); }