diff --git a/js/format-c2g.js b/js/format-c2g.js index 7cb5ecb..793e980 100644 --- a/js/format-c2g.js +++ b/js/format-c2g.js @@ -421,9 +421,33 @@ const TILE_ENCODING = { name: 'no_player1_sign', }, 0x5c: { - // TODO (modifier chooses logic gate) name: 'doppelganger2', - // TODO modifier: ... - error: "Logic gates are not yet implemented, sorry!", + name: 'logic_gate', + modifier: { + decode(tile, modifier) { + if (modifier >= 0x1e && modifier <= 0x27) { + // Counter, which can't be rotated + tile.direction = 'north'; + tile.gate_type = 'counter'; + tile.counter_value = modifier - 0x1e; + } + else { + tile.direction = ['north', 'east', 'south', 'west'][modifier & 0x03]; + let type = modifier >> 2; + if (type < 6) { + tile.gate_type = ['not', 'and', 'or', 'xor', 'latch-cw', 'nand'][type]; + } + else if (type === 16) { + tile.gate_type = 'latch-ccw'; + } + else { + tile.gate_type = 'bogus'; + } + } + }, + encode(tile) { + // FIXME implement + }, + }, }, 0x5e: { name: 'button_pink', @@ -503,10 +527,10 @@ const TILE_ENCODING = { }, }, 0x72: { - name: 'purple_wall', + name: 'purple_floor', }, 0x73: { - name: 'purple_floor', + name: 'purple_wall', }, 0x76: { name: '#mod8', diff --git a/js/game.js b/js/game.js index 2fdd27e..df61246 100644 --- a/js/game.js +++ b/js/game.js @@ -95,6 +95,7 @@ export class Tile { } } } +Tile.prototype.emitting_edges = 0; export class Cell extends Array { constructor(x, y) { @@ -159,8 +160,8 @@ export class Cell extends Array { return false; } } -Cell.prototype.was_powered = false; -Cell.prototype.is_powered = false; +Cell.prototype.prev_powered_edges = 0; +Cell.prototype.powered_edges = 0; class GameEnded extends Error {} @@ -228,9 +229,7 @@ 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 = []; + this.power_sources = []; // 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++) { @@ -251,6 +250,10 @@ export class Level { tile.specific_hint = template_tile.specific_hint ?? null; } + if (tile.type.is_power_source) { + this.power_sources.push(tile); + } + // TODO well this is pretty special-casey. maybe come up // with a specific pass at the beginning of the level? // TODO also assumes a specific order... @@ -272,9 +275,9 @@ export class Level { // list? tile.stuck = true; } - } - if (! tile.stuck) { - this.actors.push(tile); + if (! tile.stuck) { + this.actors.push(tile); + } } cell._add(tile); @@ -991,7 +994,8 @@ export class Level { continue; // TODO some actors can pick up some items... - if (actor.type.is_player && tile.type.is_item && + if (tile.type.is_item && + (actor.type.is_player || cell.some(t => t.allows_all_pickup)) && this.attempt_take(actor, tile)) { if (tile.type.is_key) { @@ -1075,87 +1079,132 @@ export class Level { // this needs to happen at all // FIXME none of this is currently undoable update_wiring() { + // Gather every tile that's emitting power. Along the way, check whether any of them have + // changed since last tic, so we can skip this work entirely if none did + let neighbors = []; + let any_changed = false; + for (let tile of this.power_sources) { + if (! tile.cell) + continue; + let emitting = tile.type.get_emitting_edges(tile, this); + if (emitting) { + neighbors.push([tile.cell, emitting]); + } + if (emitting !== tile.emitting_edges) { + any_changed = true; + tile.emitting_edges = emitting; + } + } + // Also check actors, since any of them might be holding a lightning bolt (argh) + for (let actor of this.actors) { + if (! actor.cell) + continue; + // Only count when they're on a tile, not in transit! + let emitting = actor.movement_cooldown === 0 && actor.has_item('lightning_bolt'); + if (emitting) { + neighbors.push([actor.cell, emitting]); + } + if (emitting !== actor.emitting_edges) { + any_changed = true; + actor.emitting_edges = emitting; + } + } + // If none changed, we're done + if (! any_changed) + return; + // 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; + cell.prev_powered_edges = cell.powered_edges; + cell.powered_edges = 0; } } - // Iterate through the grid looking for emitters — tiles that are generating current — and + // Iterate over emitters and flood-fill outwards one edge at a time // 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))) + while (neighbors.length > 0) { + let [cell, source_direction] = neighbors.shift(); + let wire = cell.get_wired_tile(); + + // Power this cell + if (typeof(source_direction) === 'number') { + // This cell is emitting power itself, and the source direction is actually a + // bitmask of directions + cell.powered_edges = source_direction; + } + else { + let bit = DIRECTIONS[source_direction].bit; + if (wire === null || (wire.wire_directions & bit) === 0) { + // No wire on this side, so the power doesn't actually propagate, but it DOES + // stay on this edge (so if this is e.g. a purple tile, it'll be powered) + cell.powered_edges |= bit; + continue; + } + + // Common case: power entering a wired edge and propagating outwards. The only + // special case is that four-way wiring is two separate wires, N/S and E/W + if (wire.wire_directions === 0x0f) { + cell.powered_edges |= bit; + cell.powered_edges |= DIRECTIONS[DIRECTIONS[source_direction].opposite].bit; + } + else { + cell.powered_edges = wire.wire_directions; + } + } + + // Propagate current to neighbors + for (let [direction, dirinfo] of Object.entries(DIRECTIONS)) { + if (direction === source_direction) + continue; + if ((cell.powered_edges & dirinfo.bit) === 0) 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 neighbor, neighbor_wire; + let opposite_bit = DIRECTIONS[dirinfo.opposite].bit; + if (wire && (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 = cell.x; + let y = cell.y; + let nesting = 0; + while (true) { + x += dirinfo.movement[0]; + y += dirinfo.movement[1]; + if (! this.is_point_within_bounds(x, y)) + break; - 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); + let candidate = this.cells[y][x]; + neighbor_wire = candidate.get_wired_tile(); + if (neighbor_wire && ((neighbor_wire.wire_tunnel_directions ?? 0) & opposite_bit)) { + neighbor = candidate; + break; } } } + else { + // No tunnel; this is easy + neighbor = this.get_neighboring_cell(cell, direction); + neighbor_wire = neighbor.get_wired_tile(); + } + + if (neighbor && (neighbor.powered_edges & opposite_bit) === 0 && + // Unwired tiles are OK; they might be something activated by power. + // Wired tiles that do NOT connect to us are ignored. + (! neighbor_wire || neighbor_wire.wire_directions & opposite_bit)) + { + neighbors.push([neighbor, dirinfo.opposite]); + } } } // 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'; + if ((cell.prev_powered_edges === 0) !== (cell.powered_edges === 0)) { + let method = cell.powered_edges ? 'on_power' : 'on_depower'; for (let tile of cell) { if (tile.type[method]) { tile.type[method](tile, this); diff --git a/js/main-editor.js b/js/main-editor.js index a2e838e..514a919 100644 --- a/js/main-editor.js +++ b/js/main-editor.js @@ -485,6 +485,11 @@ const EDITOR_PALETTE = [{ 'wall_invisible', 'wall_appearing', 'gravel', 'dirt', + 'dirt', + + 'floor_custom_green', 'floor_custom_pink', 'floor_custom_yellow', 'floor_custom_blue', + 'wall_custom_green', 'wall_custom_pink', 'wall_custom_yellow', 'wall_custom_blue', + 'door_blue', 'door_red', 'door_yellow', 'door_green', 'water', 'turtle', 'fire', 'ice', 'ice_nw', 'ice_ne', 'ice_sw', 'ice_se', @@ -495,6 +500,7 @@ const EDITOR_PALETTE = [{ tiles: [ 'key_blue', 'key_red', 'key_yellow', 'key_green', 'flippers', 'fire_boots', 'cleats', 'suction_boots', + 'no_sign', // 'bestowal_bow', ], }, { title: "Creatures", diff --git a/js/tileset.js b/js/tileset.js index 4bc24be..a27b78a 100644 --- a/js/tileset.js +++ b/js/tileset.js @@ -35,6 +35,7 @@ export const CC2_TILESET_LAYOUT = { // Wiring! base: [0, 2], wired: [8, 26], + wired_cross: [10, 26], is_wired_optional: true, }, wall_invisible: [0, 2], @@ -206,6 +207,7 @@ export const CC2_TILESET_LAYOUT = { // Wiring! base: [15, 10], wired: [9, 26], + wired_cross: [11, 26], is_wired_optional: true, }, @@ -369,6 +371,55 @@ export const CC2_TILESET_LAYOUT = { [15, 24], ], + logic_gate: { + // TODO currently, 'wired' can't coexist with visual state etc... + // TODO *long sigh* of course, logic gates have parts with independent current too + 'latch-ccw': { + north: [8, 21], + east: [9, 21], + south: [10, 21], + west: [11, 21], + }, + not: { + north: [0, 25], + east: [1, 25], + south: [2, 25], + west: [3, 25], + }, + and: { + north: [4, 25], + east: [5, 25], + south: [6, 25], + west: [7, 25], + }, + or: { + north: [8, 25], + east: [9, 25], + south: [10, 25], + west: [11, 25], + }, + xor: { + north: [12, 25], + east: [13, 25], + south: [14, 25], + west: [15, 25], + }, + 'latch-cw': { + north: [0, 26], + east: [1, 26], + south: [2, 26], + west: [3, 26], + }, + nand: { + north: [4, 26], + east: [5, 26], + south: [6, 26], + west: [7, 26], + }, + // FIXME need to draw the number as well + counter: [14, 26], + }, + '#unpowered': [13, 26], '#powered': [15, 26], @@ -600,7 +651,6 @@ export const LL_TILESET_LAYOUT = Object.assign({}, CC2_TILESET_LAYOUT, { teeth: Object.assign({}, CC2_TILESET_LAYOUT.teeth, { north: [[0, 32], [1, 32], [2, 32], [1, 32]], }), - popwall2: [9, 32], // Extra player sprites player: Object.assign({}, CC2_TILESET_LAYOUT.player, { @@ -627,6 +677,10 @@ export const LL_TILESET_LAYOUT = Object.assign({}, CC2_TILESET_LAYOUT, { overlay: [6, 33], base: 'floor', }, + + // Custom tiles + popwall2: [9, 32], + bestowal_bow: [10, 32], }); export class Tileset { @@ -673,31 +727,51 @@ export class Tileset { coords = drawspec.tile; } else if (drawspec.wired) { - 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(tile.cell.is_powered ? '#powered' : '#unpowered', tile, tic, blit); + // This /should/ match CC2's draw order exactly, based on experimentation + let wire_radius = this.layout['#wire-width'] / 2; + if (tile && tile.wire_directions === 0x0f) { + // This is a wired tile with crossing wires, which acts a little differently + // Draw the base tile + blit(drawspec.base[0], drawspec.base[1]); - // Draw a masked part of the base tile - let wiredir = tile.wire_directions; - let wire_radius = this.layout['#wire-width'] / 2; - let wire0 = 0.5 - wire_radius; - let wire1 = 0.5 + wire_radius; - let [bx, by] = drawspec.base; - if ((wiredir & DIRECTIONS['north'].bit) === 0) { - blit(bx, by, 0, 0, 1, wire0); - } - if ((wiredir & DIRECTIONS['south'].bit) === 0) { - blit(bx, by + wire1, 0, wire1, 1, wire0); - } - if ((wiredir & DIRECTIONS['west'].bit) === 0) { - blit(bx, by, 0, 0, wire0, 1); - } - if ((wiredir & DIRECTIONS['east'].bit) === 0) { - blit(bx + wire1, by, wire1, 0, wire0, 1); - } + // Draw the two wires as separate rectangles, NS then EW + let wire_inset = 0.5 - wire_radius; + let wire_coords_ns = this.layout[ + tile.cell && tile.cell.powered_edges & DIRECTIONS['north'].bit ? '#powered' : '#unpowered']; + let wire_coords_ew = this.layout[ + tile.cell && tile.cell.powered_edges & DIRECTIONS['east'].bit ? '#powered' : '#unpowered']; + blit(wire_coords_ns[0] + wire_inset, wire_coords_ns[1], wire_inset, 0, wire_radius * 2, 1); + blit(wire_coords_ew[0], wire_coords_ew[1] + wire_inset, 0, wire_inset, 1, wire_radius * 2); - // Then draw the wired tile as normal + // Draw the cross tile on top + coords = drawspec.wired_cross ?? drawspec.wired; + } + else if (tile && tile.wire_directions) { + // Draw the base tile + blit(drawspec.base[0], drawspec.base[1]); + + // Draw the wire part as a single rectangle, initially just a small dot in the + // center, but extending out to any edge that has a wire present + let x0 = 0.5 - wire_radius; + let x1 = 0.5 + wire_radius; + let y0 = 0.5 - wire_radius; + let y1 = 0.5 + wire_radius; + if (tile.wire_directions & DIRECTIONS['north'].bit) { + y0 = 0; + } + if (tile.wire_directions & DIRECTIONS['east'].bit) { + x1 = 1; + } + if (tile.wire_directions & DIRECTIONS['south'].bit) { + y1 = 1; + } + if (tile.wire_directions & DIRECTIONS['west'].bit) { + x0 = 0; + } + let wire_coords = this.layout[tile.cell && tile.cell.powered_edges ? '#powered' : '#unpowered']; + blit(wire_coords[0] + x0, wire_coords[1] + y0, x0, y0, x1 - x0, y1 - y0); + + // Then draw the wired tile on top of it all coords = drawspec.wired; } else { diff --git a/js/tiletypes.js b/js/tiletypes.js index d036568..67f6155 100644 --- a/js/tiletypes.js +++ b/js/tiletypes.js @@ -4,7 +4,7 @@ import { random_choice } from './util.js'; // Draw layers const LAYER_TERRAIN = 0; const LAYER_ITEM = 1; -const LAYER_NO_SIGN = 2; +const LAYER_ITEM_MOD = 2; const LAYER_ACTOR = 3; const LAYER_OVERLAY = 4; // TODO cc2 order is: swivel, thinwalls, canopy (and yes you can have them all in the same tile) @@ -549,7 +549,7 @@ const TILE_TYPES = { }, }, no_sign: { - draw_layer: LAYER_NO_SIGN, + draw_layer: LAYER_ITEM_MOD, disables_pickup: true, blocks(me, level, other) { let item; @@ -562,6 +562,10 @@ const TILE_TYPES = { return item && other.has_item(item); }, }, + bestowal_bow: { + draw_layer: LAYER_ITEM_MOD, + allows_all_pickup: true, + }, // Mechanisms dirt_block: { @@ -1046,9 +1050,15 @@ const TILE_TYPES = { }, button_pink: { 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); + is_power_source: true, + get_emitting_edges(me, level) { + // We emit current as long as there's an actor fully on us + if (me.cell.some(tile => tile.type.is_actor && tile.movement_cooldown === 0)) { + return me.wire_directions; + } + else { + return 0; + } }, on_arrive(me, level, other) { level.sfx.play_once('button-press', me.cell); @@ -1060,6 +1070,16 @@ const TILE_TYPES = { button_black: { // TODO not implemented draw_layer: LAYER_TERRAIN, + is_power_source: true, + get_emitting_edges(me, level) { + // We emit current as long as there's NOT an actor fully on us + if (! me.cell.some(tile => tile.type.is_actor && tile.movement_cooldown === 0)) { + return me.wire_directions; + } + else { + return 0; + } + }, }, button_gray: { // TODO only partially implemented @@ -1086,6 +1106,51 @@ const TILE_TYPES = { level.sfx.play_once('button-release', me.cell); }, }, + // Logic gates, all consolidated into a single tile type + logic_gate: { + // gate_type: not, and, or, xor, nand, latch-cw, latch-ccw, counter, bogus + _gate_types: { + not: ['out', null, 'in1', null], + and: ['out', 'in2', null, 'in1'], + or: [], + xor: [], + nand: [], + 'latch-cw': [], + 'latch-ccw': [], + }, + draw_layer: LAYER_TERRAIN, + is_power_source: true, + get_emitting_edges(me, level) { + if (me.gate_type === 'and') { + let vars = {}; + let out_bit = 0; + let dir = me.direction; + for (let name of me.type._gate_types[me.gate_type]) { + let dirinfo = DIRECTIONS[dir]; + if (name === 'out') { + out_bit |= dirinfo.bit; + } + else if (name) { + vars[name] = (me.cell.powered_edges & dirinfo.bit) !== 0; + } + dir = dirinfo.right; + } + + if (vars.in1 && vars.in2) { + return out_bit; + } + else { + return 0; + } + } + else { + return 0; + } + }, + visual_state(me) { + return me.gate_type; + }, + }, // Time alteration stopwatch_bonus: { @@ -1390,6 +1455,14 @@ const TILE_TYPES = { blocks_monsters: true, blocks_blocks: true, }, + lightning_bolt: { + // TODO not implemented + draw_layer: LAYER_ITEM, + is_item: true, + is_tool: true, + blocks_monsters: true, + blocks_blocks: true, + }, // Progression player: {