diff --git a/js/format-c2m.js b/js/format-c2m.js index 243d66d..1b9fb54 100644 --- a/js/format-c2m.js +++ b/js/format-c2m.js @@ -69,6 +69,7 @@ class CC2Demo { let modifier_wire = { decode(tile, modifier) { tile.wire_directions = modifier & 0x0f; + // TODO wait, what happens if you use wire tunnels on steel or something other than floor? tile.wire_tunnel_directions = (modifier & 0xf0) >> 4; }, encode(tile) { diff --git a/js/game.js b/js/game.js index f4b7289..4a88727 100644 --- a/js/game.js +++ b/js/game.js @@ -125,6 +125,17 @@ export class Cell extends Array { return index; } + get_wired_tile() { + let ret = null; + for (let tile of this) { + if (tile.wire_directions || tile.wire_tunnel_directions) { + ret = tile; + // Don't break; we want the topmost tile! + } + } + return ret; + } + blocks_leaving(actor, direction) { for (let tile of this) { if (tile !== actor && @@ -148,6 +159,8 @@ export class Cell extends Array { return false; } } +Cell.prototype.was_powered = false; +Cell.prototype.is_powered = false; class GameEnded extends Error {} @@ -205,6 +218,9 @@ export class Level { let n = 0; let connectables = []; + // Handle CC2 wiring; a contiguous region of wire is all updated as a single unit, so detect + // those units ahead of time for simplicity and call them "clusters" + this.wire_clusters = []; // FIXME handle traps correctly: // - if an actor is in the cell, set the trap to open and unstick everything in it for (let y = 0; y < this.height; y++) { @@ -398,6 +414,9 @@ export class Level { this.step_on_cell(actor, cell); } + // Now we handle wiring + this.update_wiring(); + // Only reset the player's is_pushing between movement, so it lasts for the whole push if (this.player.movement_cooldown <= 0) { this.player.is_pushing = false; @@ -558,7 +577,7 @@ export class Level { } for (let direction of direction_preference) { - let dest_cell = this.cell_with_offset(actor.cell, direction); + let dest_cell = this.get_neighboring_cell(actor.cell, direction); if (! dest_cell) continue; @@ -600,7 +619,7 @@ export class Level { if (actor.type.is_player && dir2 && ! old_cell.blocks_leaving(actor, dir2)) { - let neighbor = this.cell_with_offset(old_cell, dir2); + let neighbor = this.get_neighboring_cell(old_cell, dir2); if (neighbor) { let could_push = ! neighbor.blocks_entering(actor, dir2, this, true); for (let tile of Array.from(neighbor)) { @@ -677,7 +696,7 @@ export class Level { let move = DIRECTIONS[direction].movement; if (!actor.cell) console.error(actor); - let goal_cell = this.cell_with_offset(actor.cell, direction); + let goal_cell = this.get_neighboring_cell(actor.cell, direction); // TODO this could be a lot simpler if i could early-return! should ice bumping be // somewhere else? @@ -903,11 +922,126 @@ export class Level { } } - cell_with_offset(cell, direction) { + // Update the state of all wired tiles in the game. + // XXX need to be clear on the order of events here. say everything starts out unpowered. + // then: + // 1. you step on a pink button, which flags itself as going to be powered next frame + // 2. this pass happens. every unpowered-but-wired cell is inspected. if a powered one is + // found, floodfill from there + // FIXME can probably skip this if we know there are no wires at all, like in a CCL, or just an + // unwired map + // FIXME this feels inefficient. most of the time none of the inputs have changed so none of + // this needs to happen at all + // FIXME none of this is currently undoable + update_wiring() { + // Turn off power to every cell + // TODO wonder if i need a linear cell list, or even a flat list of all tiles (that sounds + // like hell to keep updated though) + for (let row of this.cells) { + for (let cell of row) { + cell.was_powered = cell.is_powered; + cell.is_powered = false; + } + } + + // Iterate through the grid looking for emitters — tiles that are generating current — and + // propagated it via flood-fill through neighboring wires + for (let row of this.cells) { + for (let cell of row) { + // TODO probably this should set a prop on the tile + if (! cell.some(tile => tile.type.is_emitting && tile.type.is_emitting(tile, this))) + continue; + + // We have an emitter! Flood-fill outwards + let neighbors = [cell]; + for (let neighbor of neighbors) { + // Power it even if it's not wired itself, so that e.g. purple tiles work + neighbor.is_powered = true; + + let wire = neighbor.get_wired_tile(); + if (! wire) + continue; + + // Emit along every wire direction, and add any unpowered neighbors to the + // pending list to continue the floodfill + // TODO but only if wires connect + // TODO handle wire tunnels + for (let [direction, dirinfo] of Object.entries(DIRECTIONS)) { + if (! (wire.wire_directions & dirinfo.bit)) + continue; + + let neighbor2, wire2; + let opposite_bit = DIRECTIONS[dirinfo.opposite].bit; + if (wire.wire_tunnel_directions & dirinfo.bit) { + // Search in the given direction until we find a matching tunnel + // FIXME these act like nested parens! + let x = neighbor.x; + let y = neighbor.y; + let nesting = 0; + while (true) { + x += dirinfo.movement[0]; + y += dirinfo.movement[1]; + if (! this.is_point_within_bounds(x, y)) + break; + + let candidate = this.cells[y][x]; + wire2 = candidate.get_wired_tile(); + if (wire2 && (wire2.wire_tunnel_directions ?? 0) & opposite_bit) { + neighbor2 = candidate; + break; + } + } + } + else { + // Otherwise this is easy + neighbor2 = this.get_neighboring_cell(neighbor, direction); + wire2 = neighbor2.get_wired_tile(); + } + + if (neighbor2 && ! neighbor2.is_powered && + // Unwired tiles are OK; they might be something activated by power. + // Wired tiles that do NOT connect to us are ignored. + (! wire2 || wire2.wire_directions & opposite_bit)) + { + neighbors.push(neighbor2); + } + } + } + } + } + + // Inform any affected cells of power changes + for (let row of this.cells) { + for (let cell of row) { + if (cell.was_powered !== cell.is_powered) { + let method = cell.is_powered ? 'on_power' : 'on_depower'; + for (let tile of cell) { + if (tile.type[method]) { + tile.type[method](tile, this); + } + } + } + } + } + } + + // Performs a depth-first search for connected wires and wire objects, extending out from the + // given starting cell + *follow_circuit(cell) { + } + + // ------------------------------------------------------------------------- + // Board inspection + + is_point_within_bounds(x, y) { + return (x >= 0 && x < this.width && y >= 0 && y < this.height); + } + + get_neighboring_cell(cell, direction) { let move = DIRECTIONS[direction].movement; let goal_x = cell.x + move[0]; let goal_y = cell.y + move[1]; - if (goal_x >= 0 && goal_x < this.width && goal_y >= 0 && goal_y < this.height) { + if (this.is_point_within_bounds(goal_x, goal_y)) { return this.cells[goal_y][goal_x]; } else { diff --git a/js/tileset.js b/js/tileset.js index b7bcd26..2b9f058 100644 --- a/js/tileset.js +++ b/js/tileset.js @@ -200,7 +200,12 @@ export const CC2_TILESET_LAYOUT = { popwall2: [8, 10], gravel: [9, 10], ball: [[10, 10], [11, 10], [12, 10], [13, 10], [14, 10]], - steel: [15, 10], + steel: { + // Wiring! + base: [15, 10], + wired: [9, 26], + is_wired_optional: true, + }, // TODO only animates while moving teeth: { @@ -216,7 +221,7 @@ export const CC2_TILESET_LAYOUT = { swivel_ne: [11, 11], swivel_se: [12, 11], swivel_floor: [13, 11], - // TODO some kinda four-edges thing again + '#wire-tunnel': [14, 11], stopwatch_penalty: [15, 11], paramecium: { north: [[0, 12], [1, 12], [2, 12]], @@ -666,10 +671,10 @@ export class Tileset { coords = drawspec.tile; } else if (drawspec.wired) { - if (tile && tile.wire_directions !== undefined && tile.wire_directions !== 0) { - // TODO all four is a different thing entirely + if (tile && tile.wire_directions) { + // TODO all four is a different thing entirely with two separate parts, ugh // Draw the appropriate wire underlay - this.draw_type('#unpowered', tile, tic, blit); + this.draw_type(tile.cell.is_powered ? '#powered' : '#unpowered', tile, tic, blit); // Draw a masked part of the base tile let wiredir = tile.wire_directions; @@ -726,7 +731,7 @@ export class Tileset { } // Apply custom per-type visual states - if (TILE_TYPES[name].visual_state) { + if (TILE_TYPES[name] && TILE_TYPES[name].visual_state) { // Note that these accept null, too, and return a default let state = TILE_TYPES[name].visual_state(tile); // If it's a string, that's an alias for another state @@ -797,6 +802,30 @@ export class Tileset { blit(coords[0], coords[1]); } + // Wired tiles may also have tunnels, drawn on top of everything else + if (drawspec.wired && tile && tile.wire_tunnel_directions) { + let tunnel_coords = this.layout['#wire-tunnel']; + let tunnel_width = 6/32; + let tunnel_length = 12/32; + let tunnel_offset = (1 - tunnel_width) / 2; + if (tile.wire_tunnel_directions & DIRECTIONS['north'].bit) { + blit(tunnel_coords[0] + tunnel_offset, tunnel_coords[1], + tunnel_offset, 0, tunnel_width, tunnel_length); + } + if (tile.wire_tunnel_directions & DIRECTIONS['south'].bit) { + blit(tunnel_coords[0] + tunnel_offset, tunnel_coords[1] + 1 - tunnel_length, + tunnel_offset, 1 - tunnel_length, tunnel_width, tunnel_length); + } + if (tile.wire_tunnel_directions & DIRECTIONS['west'].bit) { + blit(tunnel_coords[0], tunnel_coords[1] + tunnel_offset, + 0, tunnel_offset, tunnel_length, tunnel_width); + } + if (tile.wire_tunnel_directions & DIRECTIONS['east'].bit) { + blit(tunnel_coords[0] + 1 - tunnel_length, tunnel_coords[1] + tunnel_offset, + 1 - tunnel_length, tunnel_offset, tunnel_length, tunnel_width); + } + } + // Special behavior for special objects // TODO? hardcode this less? if (name === 'floor_letter') { diff --git a/js/tiletypes.js b/js/tiletypes.js index 300dc93..684060e 100644 --- a/js/tiletypes.js +++ b/js/tiletypes.js @@ -588,10 +588,16 @@ const TILE_TYPES = { }, green_floor: { draw_layer: LAYER_TERRAIN, + on_gray_button(me, level) { + level.transmute_tile(me, 'green_wall'); + }, }, green_wall: { draw_layer: LAYER_TERRAIN, blocks_all: true, + on_gray_button(me, level) { + level.transmute_tile(me, 'green_floor'); + }, }, green_chip: { draw_layer: LAYER_ITEM, @@ -605,6 +611,7 @@ const TILE_TYPES = { level.remove_tile(me); } }, + // Not affected by gray buttons }, green_bomb: { draw_layer: LAYER_ITEM, @@ -619,15 +626,32 @@ const TILE_TYPES = { level.transmute_tile(other, 'explosion'); } }, + // Not affected by gray buttons }, purple_floor: { - // TODO wired draw_layer: LAYER_TERRAIN, + on_gray_button(me, level) { + level.transmute_tile(me, 'purple_wall'); + }, + on_power(me, level) { + me.type.on_gray_button(me, level); + }, + on_depower(me, level) { + me.type.on_gray_button(me, level); + }, }, purple_wall: { - // TODO wired draw_layer: LAYER_TERRAIN, blocks_all: true, + on_gray_button(me, level) { + level.transmute_tile(me, 'purple_floor'); + }, + on_power(me, level) { + me.type.on_gray_button(me, level); + }, + on_depower(me, level) { + me.type.on_gray_button(me, level); + }, }, cloner: { draw_layer: LAYER_TERRAIN, @@ -658,6 +682,13 @@ const TILE_TYPES = { } } }, + // Also clones on rising pulse or gray button + on_power(me, level) { + me.type.activate(me, level); + }, + on_gray_button(me, level) { + me.type.activate(me, level); + }, }, trap: { draw_layer: LAYER_TERRAIN, @@ -693,7 +724,7 @@ const TILE_TYPES = { level.set_actor_stuck(other, true); } }, - add_press(me, level, is_begin) { + add_press(me, level) { level._set_prop(me, 'presses', (me.presses ?? 0) + 1); if (me.presses === 1) { // Free everything on us, if we went from 0 to 1 presses (i.e. closed to open) @@ -718,6 +749,15 @@ const TILE_TYPES = { } } }, + on_power(me, level) { + // Treat being powered or not as an extra kind of brown button press + me.type.add_press(me, level); + console.log("powering UP", me.presses); + }, + on_depower(me, level) { + me.type.remove_press(me, level); + console.log("powering down...", me.presses); + }, visual_state(me) { if (me && me.presses) { return 'open'; @@ -793,13 +833,30 @@ const TILE_TYPES = { return level.iter_tiles_in_reading_order(me.cell, 'teleport_yellow', true); }, }, - // FIXME do i want these as separate objects? what would they do, turn into each other? or should it be one with state? flame_jet_off: { draw_layer: LAYER_TERRAIN, + activate(me, level) { + level.transmute_tile(me, 'flame_jet_on'); + }, + on_gray_button(me, level) { + me.type.activate(me, level); + }, + on_power(me, level) { + me.type.activate(me, level); + }, }, flame_jet_on: { draw_layer: LAYER_TERRAIN, // FIXME every tic, kills every actor in the cell + activate(me, level) { + level.transmute_tile(me, 'flame_jet_off'); + }, + on_gray_button(me, level) { + me.type.activate(me, level); + }, + on_power(me, level) { + me.type.activate(me, level); + }, }, // Buttons button_blue: { @@ -920,8 +977,17 @@ const TILE_TYPES = { // FIXME toggles flame jets, connected somehow, ??? }, button_pink: { - // TODO not implemented draw_layer: LAYER_TERRAIN, + is_emitting(me, level) { + // We emit current as long as there's an actor on us + return me.cell.some(tile => tile.type.is_actor); + }, + on_arrive(me, level, other) { + level.sfx.play_once('button-press', me.cell); + }, + on_depart(me, level, other) { + level.sfx.play_once('button-release', me.cell); + }, }, button_black: { // TODO not implemented @@ -941,11 +1007,8 @@ const TILE_TYPES = { continue; for (let tile of cell) { - if (tile.type.name === 'green_floor') { - level.transmute_tile(tile, 'green_wall'); - } - else if (tile.type.name === 'green_wall') { - level.transmute_tile(tile, 'green_floor'); + if (tile.type.on_gray_button) { + tile.type.on_gray_button(tile, level); } } } diff --git a/tileset-lexy.png b/tileset-lexy.png index f926ac1..67f28e7 100644 Binary files a/tileset-lexy.png and b/tileset-lexy.png differ diff --git a/tileset-src/tileset-lexy.aseprite b/tileset-src/tileset-lexy.aseprite index d5b3138..616556f 100644 Binary files a/tileset-src/tileset-lexy.aseprite and b/tileset-src/tileset-lexy.aseprite differ