diff --git a/README.md b/README.md index 9cb7475..03ee3b4 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,9 @@ Give it a try, I guess! [https://c.eev.ee/lexys-labyrinth/](https://c.eev.ee/le ## Special thanks -- The incredible nerds who put together the [Chip Wiki](https://wiki.bitbusters.club/) and also reside on the Bit Busters Discord +- The incredible nerds who put together the [Chip Wiki](https://wiki.bitbusters.club/) and also reside on the Bit Busters Discord, including: + - ruben for documenting the CC2 PRNG + - The Architect for documenting the CC2 C2G parser - Everyone who worked on [Chip's Challenge Level Pack 1](https://wiki.bitbusters.club/Chip%27s_Challenge_Level_Pack_1), the default set of levels - [Tile World](https://wiki.bitbusters.club/Tile_World) for being an incredible reference on Lynx mechanics - Everyone who contributed music — see [`js/soundtrack.js`](js/soundtrack.js) for a list! diff --git a/js/format-base.js b/js/format-base.js index f9cf4d0..0c43eb5 100644 --- a/js/format-base.js +++ b/js/format-base.js @@ -16,6 +16,10 @@ export class StoredLevel { this.extra_chunks = []; this.use_cc1_boots = false; this.use_ccl_compat = false; + // 0 - deterministic (PRNG + simple convolution) + // 1 - 4 patterns (default; PRNG + rotating through 0-3) + // 2 - extra random (like deterministic, but initial seed is "actually" random) + this.blob_behavior = 1; this.size_x = 0; this.size_y = 0; diff --git a/js/format-c2g.js b/js/format-c2g.js index f4464d8..7cb5ecb 100644 --- a/js/format-c2g.js +++ b/js/format-c2g.js @@ -536,7 +536,7 @@ const TILE_ENCODING = { name: 'popdown_floor', }, 0x7f: { - name: 'forbidden', + name: 'no_sign', has_next: true, }, 0x80: { @@ -835,7 +835,7 @@ export function parse_level(buf, number = 1) { if (view.byteLength <= 24) continue; - //level.blob_behavior = view.getUint8(24, true); + level.blob_behavior = view.getUint8(24, true); } else if (type === 'MAP ' || type === 'PACK') { if (type === 'PACK') { diff --git a/js/game.js b/js/game.js index 431a759..f8e383c 100644 --- a/js/game.js +++ b/js/game.js @@ -212,6 +212,16 @@ export class Level { this.hint_shown = null; // TODO in lynx/steam, this carries over between levels; in tile world, you can set it manually this.force_floor_direction = 'north'; + // PRNG is initialized to zero + this._rng1 = 0; + this._rng2 = 0; + if (this.stored_level.blob_behavior === 0) { + this._blob_modifier = 0x55; + } + else { + // The other two modes are initialized to a random seed + this._blob_modifier = Math.floor(Math.random() * 256); + } this.undo_stack = []; this.pending_undo = []; @@ -322,15 +332,62 @@ export class Level { if (tile.type.on_ready) { tile.type.on_ready(tile, this); } + if (cell === this.player.cell && tile.type.is_hint) { + this.hint_shown = tile.specific_hint ?? this.stored_level.hint; + } } } } } + player_awaiting_input() { return this.player.movement_cooldown === 0 && (this.player.slide_mode === null || (this.player.slide_mode === 'force' && this.player.last_move_was_force)) } + // Lynx PRNG, used unchanged in CC2 + prng() { + // TODO what if we just saved this stuff, as well as the RFF direction, at the beginning of + // each tic? + let rng1 = this._rng1; + let rng2 = this._rng2; + this.pending_undo.push(() => { + this._rng1 = rng1; + this._rng2 = rng2; + }); + + let n = (this._rng1 >> 2) - this._rng1; + if (!(this._rng1 & 0x02)) --n; + this._rng1 = (this._rng1 >> 1) | (this._rng2 & 0x80); + this._rng2 = (this._rng2 << 1) | (n & 0x01); + let ret = (this._rng1 ^ this._rng2) & 0xff; + return ret; + } + + // Weird thing done by CC2 to make blobs... more... random + get_blob_modifier() { + let mod = this._blob_modifier; + this.pending_undo.push(() => this._blob_modifier = mod); + + if (this.stored_level.blob_behavior === 1) { + // "4 patterns" just increments by 1 every time (but /after/ returning) + //this._blob_modifier = (this._blob_modifier + 1) % 4; + mod = (mod + 1) % 4; + this._blob_modifier = mod; + } + else { + // Other modes do this curious operation + mod *= 2; + if (mod < 255) { + mod ^= 0x1d; + } + mod &= 0xff; + this._blob_modifier = mod; + } + + return mod; + } + // Move the game state forwards by one tic // split into two parts for turn-based mode: first part is the consequences of the previous tick, second part depends on the player's input advance_tic(p1_primary_direction, p1_secondary_direction, pass) { @@ -649,11 +706,9 @@ export class Level { direction_preference = [actor.direction, d.opposite]; } else if (actor.type.movement_mode === 'bounce-random') { - // walker behavior: preserve current direction; if that - // doesn't work, pick a random direction, even the one we - // failed to move in - // TODO unclear if this is right in cc2 as well. definitely not in ms, which chooses a legal move - direction_preference = [actor.direction, ['north', 'south', 'east', 'west'][Math.floor(Math.random() * 4)]]; + // walker behavior: preserve current direction; if that doesn't work, pick a random + // direction, even the one we failed to move in (but ONLY then) + direction_preference = [actor.direction, 'WALKER']; } else if (actor.type.movement_mode === 'pursue') { // teeth behavior: always move towards the player @@ -692,14 +747,27 @@ export class Level { } else if (actor.type.movement_mode === 'random') { // blob behavior: move completely at random - // TODO cc2 has twiddles for how this works per-level, as well as the initial seed for demo playback - direction_preference = [['north', 'south', 'east', 'west'][Math.floor(Math.random() * 4)]]; + let modifier = this.get_blob_modifier(); + direction_preference = [['north', 'east', 'south', 'west'][(this.prng() + modifier) % 4]]; } // Check which of those directions we *can*, probably, move in // TODO i think player on force floor will still have some issues here + // FIXME probably bail earlier for stuck actors so the prng isn't advanced? what is the + // lynx behavior? also i hear something about blobs on cloners?? if (direction_preference && ! actor.stuck) { + let fallback_direction; for (let direction of direction_preference) { + if (direction === 'WALKER') { + // Walkers roll a random direction ONLY if their first attempt was blocked + direction = actor.direction; + let num_turns = this.prng() % 4; + for (let i = 0; i < num_turns; i++) { + direction = DIRECTIONS[direction].right; + } + } + fallback_direction = direction; + let dest_cell = this.get_neighboring_cell(actor.cell, direction); if (! dest_cell) continue; @@ -712,6 +780,12 @@ export class Level { break; } } + + // If all the decisions are blocked, actors still try the last one (and might even + // be able to move that way by the time their turn comes around!) + if (actor.decision === null) { + actor.decision = fallback_direction; + } } } @@ -917,14 +991,15 @@ export class Level { continue; // TODO some actors can pick up some items... - if (actor.type.is_player && tile.type.is_item && this.give_actor(actor, tile.type.name)) { + if (actor.type.is_player && tile.type.is_item && + this.attempt_take(actor, tile)) + { if (tile.type.is_key) { this.sfx.play_once('get-key', cell); } else { this.sfx.play_once('get-tool', cell); } - this.remove_tile(tile); } else if (tile.type.teleport_dest_order) { teleporter = tile; @@ -935,25 +1010,55 @@ export class Level { } // Handle teleporting, now that the dust has cleared - // FIXME something funny happening here, your input isn't ignore while walking out of it? + // FIXME something funny happening here, your input isn't ignored while walking out of it? if (teleporter) { - for (let dest of teleporter.type.teleport_dest_order(teleporter, this)) { + let original_direction = actor.direction; + let success = false; + for (let dest of teleporter.type.teleport_dest_order(teleporter, this, actor)) { // Teleporters already containing an actor are blocked and unusable if (dest.cell.some(tile => tile.type.is_actor && tile !== actor)) continue; // Physically move the actor to the new teleporter - // XXX is this right, compare with tile world? i overhear it's actually implemented as a slide? + // XXX lynx treats this as a slide and does it in a pass in the main loop // XXX not especially undo-efficient this.remove_tile(actor); this.add_tile(actor, dest.cell); - if (this.attempt_step(actor, actor.direction)) { - // Success, teleportation complete - // Sound plays from the origin cell simply because that's where the sfx player - // thinks the player is currently; position isn't updated til next turn - this.sfx.play_once('teleport', dest.cell); + + // Red and green teleporters attempt to spit you out in every direction before + // giving up on a destination (but not if you return to the original). + // Note that we use actor.direction here (rather than original_direction) because + // green teleporters modify it in teleport_dest_order, to randomize the exit + // direction + let direction = actor.direction; + let num_directions = 1; + if (teleporter.type.teleport_try_all_directions && dest !== teleporter) { + num_directions = 4; + } + for (let i = 0; i < num_directions; i++) { + if (this.attempt_step(actor, direction)) { + success = true; + // Sound plays from the origin cell simply because that's where the sfx player + // thinks the player is currently; position isn't updated til next turn + this.sfx.play_once('teleport', teleporter.cell); + break; + } + else { + direction = DIRECTIONS[direction].right; + } + } + + if (success) { break; } + else if (num_directions === 4) { + // Restore our original facing before continuing + // (For red teleports, we try every possible destination in our original + // movement direction, so this is correct. For green teleports, we only try one + // destination and then fall back to walking through the source in our original + // movement direction, so this is still correct.) + this.set_actor_direction(actor, original_direction); + } } } } @@ -1297,6 +1402,18 @@ export class Level { } } + // Have an actor try to pick up a particular tile; it's prevented if there's a no sign, and the + // tile is removed if successful + attempt_take(actor, tile) { + if (! tile.cell.some(t => t.type.disables_pickup) && + this.give_actor(actor, tile.type.name)) + { + this.remove_tile(tile); + return true; + } + return false; + } + // Give an item to an actor, even if it's not supposed to have an inventory give_actor(actor, name) { if (! actor.type.is_actor) diff --git a/js/main.js b/js/main.js index b8e9941..2e9a560 100644 --- a/js/main.js +++ b/js/main.js @@ -679,8 +679,10 @@ class Player extends PrimaryView { play_demo() { this.restart_level(); - this.demo_faucet = this.level.stored_level.demo[Symbol.iterator](); - this.level.force_floor_direction = this.level.stored_level.demo.initial_force_floor_direction; + let demo = this.level.stored_level.demo; + this.demo_faucet = demo[Symbol.iterator](); + this.level.force_floor_direction = demo.initial_force_floor_direction; + this.level._blob_modifier = demo.blob_seed; // FIXME should probably start playback on first real input this.set_state('playing'); } diff --git a/js/renderer-canvas.js b/js/renderer-canvas.js index 16ba67f..a854a65 100644 --- a/js/renderer-canvas.js +++ b/js/renderer-canvas.js @@ -128,7 +128,9 @@ export class CanvasRenderer { // Draw one layer at a time, so animated objects aren't overdrawn by // neighboring terrain // XXX layer count hardcoded here - for (let layer = 0; layer < 4; layer++) { + // FIXME this is a bit inefficient when there are a lot of rarely-used layers; consider + // instead drawing everything under actors, then actors, then everything above actors? + for (let layer = 0; layer < 5; layer++) { for (let x = xf0; x <= x1; x++) { for (let y = yf0; y <= y1; y++) { for (let tile of this.level.cells[y][x]) { diff --git a/js/tileset.js b/js/tileset.js index 2b9f058..5fd9da5 100644 --- a/js/tileset.js +++ b/js/tileset.js @@ -98,7 +98,7 @@ export const CC2_TILESET_LAYOUT = { popdown_wall: [12, 5], popdown_floor: [12, 5], popdown_floor_visible: [13, 5], - forbidden: [14, 5], + no_sign: [14, 5], // TODO arrows overlay at [3, 10] directional_block: [15, 5], diff --git a/js/tiletypes.js b/js/tiletypes.js index 3e55c8b..b41685e 100644 --- a/js/tiletypes.js +++ b/js/tiletypes.js @@ -4,8 +4,9 @@ import { random_choice } from './util.js'; // Draw layers const LAYER_TERRAIN = 0; const LAYER_ITEM = 1; -const LAYER_ACTOR = 2; -const LAYER_OVERLAY = 3; +const LAYER_NO_SIGN = 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) function player_visual_state(me) { @@ -543,8 +544,19 @@ const TILE_TYPES = { } }, }, - forbidden: { - draw_layer: LAYER_TERRAIN, + no_sign: { + draw_layer: LAYER_NO_SIGN, + disables_pickup: true, + blocks(me, level, other) { + let item; + for (let tile of me.cell) { + if (tile.type.is_item) { + item = tile.type.name; + break; + } + } + return item && other.has_item(item); + }, }, // Mechanisms @@ -806,30 +818,42 @@ const TILE_TYPES = { }, teleport_blue: { draw_layer: LAYER_TERRAIN, - teleport_dest_order(me, level) { + teleport_dest_order(me, level, other) { return level.iter_tiles_in_reading_order(me.cell, 'teleport_blue', true); }, }, teleport_red: { draw_layer: LAYER_TERRAIN, - teleport_dest_order(me, level) { - // FIXME you can control your exit direction from red teleporters + teleport_try_all_directions: true, + teleport_allow_override: true, + teleport_dest_order(me, level, other) { return level.iter_tiles_in_reading_order(me.cell, 'teleport_red'); }, }, teleport_green: { draw_layer: LAYER_TERRAIN, - teleport_dest_order(me, level) { - // FIXME exit direction is random; unclear if it's any direction or only unblocked ones + teleport_try_all_directions: true, + teleport_dest_order(me, level, other) { let all = Array.from(level.iter_tiles_in_reading_order(me.cell, 'teleport_green')); - // FIXME this should use the lynxish rng - return [random_choice(all), me]; + if (all.length <= 1) { + // If this is the only teleporter, just walk out the other side — and, crucially, do + // NOT advance the PRNG + return [me]; + } + // Note the iterator starts on the /next/ teleporter, so there's an implicit +1 here. + // The -1 is to avoid spitting us back out of the same teleporter, which will be last in + // the list + let target = all[level.prng() % (all.length - 1)]; + // Also set the actor's (initial) exit direction + level.set_actor_direction(other, ['north', 'east', 'south', 'west'][level.prng() % 4]); + return [target, me]; }, }, teleport_yellow: { draw_layer: LAYER_TERRAIN, - teleport_dest_order(me, level) { - // FIXME special pickup behavior + teleport_allow_override: true, + teleport_dest_order(me, level, other) { + // FIXME special pickup behavior; NOT an item though, does not combine with no sign return level.iter_tiles_in_reading_order(me.cell, 'teleport_yellow', true); }, }, @@ -1207,9 +1231,7 @@ const TILE_TYPES = { // TODO make this a... flag? i don't know? // TODO major difference from lynx... if (other.type.name !== 'ice_block' && other.type.name !== 'directional_block') { - if (level.give_actor(other, me.type.name)) { - level.remove_tile(me); - } + level.attempt_take(other, me); } }, }, @@ -1507,7 +1529,7 @@ for (let [name, type] of Object.entries(TILE_TYPES)) { if (type.draw_layer === undefined || type.draw_layer !== Math.floor(type.draw_layer) || - type.draw_layer >= 4) + type.draw_layer >= 5) { console.error(`Tile type ${name} has a bad draw layer`); } diff --git a/tileset-src/tileset-lexy.aseprite b/tileset-src/tileset-lexy.aseprite index 616556f..581ba7f 100644 Binary files a/tileset-src/tileset-lexy.aseprite and b/tileset-src/tileset-lexy.aseprite differ