diff --git a/js/format-base.js b/js/format-base.js index 4720533..f9aaa18 100644 --- a/js/format-base.js +++ b/js/format-base.js @@ -8,14 +8,25 @@ export class StoredCell extends Array { } export class Replay { - constructor(initial_force_floor_direction, blob_seed, inputs = null) { + constructor(initial_force_floor_direction, blob_seed, inputs = null, step_parity = null, tw_seed = 0) { this.initial_force_floor_direction = initial_force_floor_direction; this.blob_seed = blob_seed; + this.step_parity = step_parity; + this.tw_seed = tw_seed; this.inputs = inputs ?? new Uint8Array; this.duration = this.inputs.length; this.cursor = 0; } + configure_level(level) { + level.force_floor_direction = this.initial_force_floor_direction; + level._blob_modifier = this.blob_seed; + level._tw_rng = this.tw_seed; + if (this.step_parity !== null) { + level.step_parity = this.step_parity; + } + } + get(t) { if (this.duration <= 0) { return 0; diff --git a/js/format-tws.js b/js/format-tws.js new file mode 100644 index 0000000..824f8d4 --- /dev/null +++ b/js/format-tws.js @@ -0,0 +1,131 @@ +import { INPUT_BITS } from './defs.js'; +import * as format_base from './format-base.js'; + + +const TW_DIRECTION_TO_INPUT_BITS = [ + INPUT_BITS.up, + INPUT_BITS.left, + INPUT_BITS.down, + INPUT_BITS.right, + INPUT_BITS.up | INPUT_BITS.left, + INPUT_BITS.down | INPUT_BITS.left, + INPUT_BITS.up | INPUT_BITS.right, + INPUT_BITS.down | INPUT_BITS.right, +]; + +export function parse_solutions(bytes) { + let buf; + if (bytes.buffer) { + buf = bytes.buffer; + } + else { + buf = bytes; + bytes = new Uint8Array(buf); + } + let view = new DataView(buf); + let magic = view.getUint32(0, true); + if (magic !== 0x999b3335) + return; + + // 1 for lynx, 2 for ms; also extended to 3 for cc2, 4 for ll + let ruleset = bytes[4]; + let extra_bytes = bytes[7]; + + let ret = { + ruleset: ruleset, + levels: [], + }; + + let p = 8 + extra_bytes; + let is_first = true; + while (p < buf.byteLength) { + let len = view.getUint32(p, true); + p += 4; + if (len === 0xffffffff) + break; + + if (len === 0) { + // Empty, do nothing + } + else if (len < 6) { + // This should never happen + // TODO gripe? + } + else if (bytes[p] === 0 && bytes[p + 1] === 0 && bytes[p + 2] === 0 && + bytes[p + 3] === 0 && bytes[p + 5] === 0 && bytes[p + 6] === 0) + { + // This record is special and contains the name of the set; it's optional but, if present, must be first + if (! is_first) { + // TODO gripe? + } + } + else if (len === 6) { + // Short record; password only, no replay + } + else { + // Long record + let number = view.getUint16(p, true); + // Password is 2–5 but we don't care + let flags = bytes[p + 6]; + let initial_state = bytes[p + 7]; + let step_parity = initial_state >> 3; + let initial_rff = ['north', 'west', 'south', 'east'][initial_state & 0x7]; + let initial_rng = view.getUint32(p + 8, true); // FIXME how is this four bytes?? lynx rng doesn't even have four bytes of STATE + console.log(number, initial_state.toString(16), initial_rng.toString(16)); + let total_duration = view.getUint32(p + 12, true); + + // TODO split this off though + let inputs = []; + let q = p + 16; + while (q < p + len) { + // There are four formats for packing solutions, identified by the lowest two bits, + // except that format 3 is actually two formats, don't ask + let fmt = bytes[q] & 0x3; + let fmt2 = (bytes[q] >> 4) & 0x1; + if (fmt === 0) { + let val = bytes[q]; + q += 1; + let input1 = TW_DIRECTION_TO_INPUT_BITS[(val >> 2) & 0x3]; + let input2 = TW_DIRECTION_TO_INPUT_BITS[(val >> 4) & 0x3]; + let input3 = TW_DIRECTION_TO_INPUT_BITS[(val >> 6) & 0x3]; + inputs.push( + 0, 0, 0, input1, + 0, 0, 0, input2, + 0, 0, 0, input3, + ); + } + else if (fmt === 1 || fmt === 2 || (fmt === 3 && fmt2 === 0)) { + let val; + if (fmt === 1) { + val = bytes[q]; + q += 1; + } + else if (fmt === 2) { + val = view.getUint16(q, true); + q += 2; + } + else { + val = view.getUint32(q, true); + q += 4; + } + let input = TW_DIRECTION_TO_INPUT_BITS[(val >> 2) & 0x7]; + let duration = val >> 5; + for (let i = 0; i < duration; i++) { + inputs.push(0); + } + inputs.push(input); + } + else { // 3-1 + // variable-size and only needed for ms so let's just hope not + throw new Error; + } + } + + ret.levels[number - 1] = new format_base.Replay(initial_rff, 0, inputs, step_parity, initial_rng); + } + + is_first = false; + p += len; + } + return ret; +} diff --git a/js/game.js b/js/game.js index b8e4da7..97559f0 100644 --- a/js/game.js +++ b/js/game.js @@ -59,7 +59,7 @@ export class Tile { // Extremely awkward special case: items don't block monsters if the cell also contains an // item modifier (i.e. "no" sign) or a real player // TODO would love to get this outta here - if (this.type.is_item || this.type.is_chip) { + if ((this.type.is_item || this.type.is_chip) && ! level.compat.monsters_blocked_by_items) { let item_mod = this.cell.get_item_mod(); if (item_mod && item_mod.type.item_modifier) return false; @@ -286,6 +286,7 @@ export class Cell extends Array { // - null: Default. Do not impact game state. Treat pushable objects as blocking. // - 'bump': Fire bump triggers. Don't move pushable objects, but do check whether they /could/ // be pushed, recursively if necessary. + // - 'slap': Like 'bump', but also sets the 'decision' of pushable objects. // - 'push': Fire bump triggers. Attempt to move pushable objects out of the way immediately. try_entering(actor, direction, level, push_mode = null) { let pushable_tiles = []; @@ -352,7 +353,7 @@ export class Cell extends Array { for (let tile of pushable_tiles) { if (tile._trying_to_push) return false; - if (push_mode === 'bump') { + if (push_mode === 'bump' || push_mode === 'slap') { // FIXME this doesn't take railroad curves into account, e.g. it thinks a // rover can't push a block through a curve if (tile.movement_cooldown > 0 || @@ -360,6 +361,13 @@ export class Cell extends Array { { return false; } + else if (push_mode === 'slap') { + if (actor === level.player) { + level._set_tile_prop(actor, 'is_pushing', true); + level.sfx.play_once('push'); + } + tile.decision = direction; + } } else if (push_mode === 'push') { if (actor === level.player) { @@ -485,6 +493,7 @@ export class Level extends LevelInterface { // PRNG is initialized to zero this._rng1 = 0; this._rng2 = 0; + this._tw_rng = Math.floor(Math.random() * 0x80000000); if (this.stored_level.blob_behavior === 0) { this._blob_modifier = 0x55; } @@ -553,6 +562,12 @@ export class Level extends LevelInterface { } } } + if (this.compat.player_moves_last) { + let i = this.actors.indexOf(this.player); + if (i > 0) { + [this.actors[0], this.actors[i]] = [this.actors[i], this.actors[0]]; + } + } // TODO complain if no player // Used for doppelgängers this.player1_move = null; @@ -822,6 +837,16 @@ export class Level extends LevelInterface { return this._rng1 ^ this._rng2; } + // Tile World's PRNG, used for blobs in Tile World Lynx + _advance_tw_prng() { + this._tw_rng = ((Math.imul(this._tw_rng, 1103515245) & 0x7fffffff) + 12345) & 0x7fffffff; + } + tw_prng_random4() { + let x = this._tw_rng; + this._advance_tw_prng(); + return this._tw_rng >> 29; + } + // Weird thing done by CC2 to make blobs... more... random get_blob_modifier() { let mod = this._blob_modifier; @@ -883,7 +908,7 @@ export class Level extends LevelInterface { // Store some current level state in the undo entry. (These will often not be modified, but // they only take a few bytes each so that's fine.) for (let key of [ - '_rng1', '_rng2', '_blob_modifier', 'force_floor_direction', + '_rng1', '_rng2', '_blob_modifier', '_tw_rng', 'force_floor_direction', 'tic_counter', 'time_remaining', 'timer_paused', 'chips_remaining', 'bonus_points', 'state', 'player1_move', 'player2_move', 'remaining_players', 'player', @@ -966,15 +991,7 @@ export class Level extends LevelInterface { if (actor.type.ttl) continue; - if (actor.movement_cooldown <= 0) { - let terrain = actor.cell.get_terrain(); - if (terrain.type.on_stand && ! actor.ignores(terrain.type.name)) { - terrain.type.on_stand(terrain, this, actor); - } - } - if (actor.just_stepped_on_teleporter) { - this.attempt_teleport(actor); - } + this._do_actor_idle(actor); } this._swap_players(); @@ -1098,15 +1115,7 @@ export class Level extends LevelInterface { continue; this._do_actor_cooldown(actor, cooldown); - if (actor.movement_cooldown <= 0) { - let terrain = actor.cell.get_terrain(); - if (terrain.type.on_stand && ! actor.ignores(terrain.type.name)) { - terrain.type.on_stand(terrain, this, actor); - } - } - if (actor.just_stepped_on_teleporter) { - this.attempt_teleport(actor); - } + this._do_actor_idle(actor); } this._swap_players(); @@ -1159,7 +1168,7 @@ export class Level extends LevelInterface { terrain.type.on_arrive(terrain, this, actor); } // If we got a new direction, try moving again - if (direction !== actor.direction) { + if (direction !== actor.direction && ! this.compat.bonking_isnt_instant) { success = this.attempt_step(actor, actor.direction); } } @@ -1212,6 +1221,18 @@ export class Level extends LevelInterface { } } + _do_actor_idle(actor) { + if (actor.movement_cooldown <= 0) { + let terrain = actor.cell.get_terrain(); + if (terrain.type.on_stand && ! actor.ignores(terrain.type.name)) { + terrain.type.on_stand(terrain, this, actor); + } + } + if (actor.just_stepped_on_teleporter) { + this.attempt_teleport(actor); + } + } + _swap_players() { if (this.remaining_players <= 0) { this.win(); @@ -1250,6 +1271,9 @@ export class Level extends LevelInterface { if (actor.type.is_real_player) { this.player = actor; + if (this.compat.player_moves_last && i !== 0) { + [this.actors[0], this.actors[i]] = [this.actors[i], this.actors[0]]; + } break; } } @@ -1257,24 +1281,37 @@ export class Level extends LevelInterface { } _do_cleanup_phase() { - // Strip out any destroyed actors from the acting order - // FIXME this is O(n), where n is /usually/ small, but i still don't love it. not strictly - // necessary, either; maybe only do it every few tics? - let p = 0; - for (let i = 0, l = this.actors.length; i < l; i++) { - let actor = this.actors[i]; - if (actor.cell) { - if (p !== i) { - this.actors[p] = actor; + // Lynx compat: Any blue tank that still has the reversal flag set here, but is in motion, + // should ignore it. Unfortunately this has to be done as its own pass (as it is in Lynx!) + // because of acting order issues + if (this.compat.tanks_ignore_button_while_moving) { + for (let actor of this.actors) { + if (actor.cell && actor.pending_reverse && actor.movement_cooldown > 0) { + this._set_tile_prop(actor, 'pending_reverse', false); } - p++; - } - else { - let local_p = p; - this._push_pending_undo(() => this.actors.splice(local_p, 0, actor)); } } - this.actors.length = p; + + // Strip out any destroyed actors from the acting order + if (! this.compat.reuse_actor_slots) { + // FIXME this is O(n), where n is /usually/ small, but i still don't love it. not strictly + // necessary, either; maybe only do it every few tics? + let p = 0; + for (let i = 0, l = this.actors.length; i < l; i++) { + let actor = this.actors[i]; + if (actor.cell) { + if (p !== i) { + this.actors[p] = actor; + } + p++; + } + else { + let local_p = p; + this._push_pending_undo(() => this.actors.splice(local_p, 0, actor)); + } + } + this.actors.length = p; + } // Advance the clock // TODO i suspect cc2 does this at the beginning of the tic, but even if you've won? if you @@ -1399,11 +1436,11 @@ export class Level extends LevelInterface { // At this point, we have exactly 1 or 2 directions, and deciding between them requires // checking which ones are blocked. Note that we do this even if only one direction is // requested, meaning that we get to push blocks before anything else has moved! - let push_mode = this.compat.no_early_push ? 'bump' : 'push'; + let push_mode = this.compat.no_early_push ? 'slap' : 'push'; let open; if (dir2 === null) { // Only one direction is held, but for consistency, "check" it anyway - open = try_direction(dir1, 'push'); + open = try_direction(dir1, push_mode); actor.decision = dir1; } else { @@ -1413,8 +1450,8 @@ export class Level extends LevelInterface { // interpret our input! if (dir1 === actor.direction || dir2 === actor.direction) { let other_direction = dir1 === actor.direction ? dir2 : dir1; - let curr_open = try_direction(actor.direction, 'push'); - let other_open = try_direction(other_direction, 'push'); + let curr_open = try_direction(actor.direction, push_mode); + let other_open = try_direction(other_direction, push_mode); if (! curr_open && other_open) { actor.decision = other_direction; open = true; @@ -1429,8 +1466,8 @@ export class Level extends LevelInterface { // FIXME i'm told cc2 prefers orthogonal actually, but need to check on that // FIXME lynx only checks horizontal, what about cc2? it must check both // because of the behavior where pushing into a corner always pushes horizontal - let open1 = try_direction(dir1, 'push'); - let open2 = try_direction(dir2, 'push'); + let open1 = try_direction(dir1, push_mode); + let open2 = try_direction(dir2, push_mode); if (open1 && ! open2) { actor.decision = dir1; open = true; @@ -1451,8 +1488,8 @@ export class Level extends LevelInterface { } // If we're overriding a force floor but the direction we're moving in is blocked, this - // counts as a forced move - if (actor.slide_mode === 'force' && ! open) { + // counts as a forced move (but only under the CC2 behavior of instant bonking) + if (actor.slide_mode === 'force' && ! open && ! this.compat.bonking_isnt_instant) { this._set_tile_prop(actor, 'last_move_was_force', true); } else { @@ -1536,6 +1573,14 @@ export class Level extends LevelInterface { } check_movement(actor, orig_cell, direction, push_mode) { + // Lynx: Players can't override backwards on force floors, and it functions like blocking, + // but does NOT act like a bonk (hence why it's here) + if (this.compat.no_backwards_override && actor === this.player && + actor.slide_mode === 'force' && direction === DIRECTIONS[actor.direction].opposite) + { + return false; + } + let dest_cell = this.get_neighboring_cell(orig_cell, direction); if (! dest_cell) { if (push_mode === 'push') { @@ -1577,7 +1622,9 @@ export class Level extends LevelInterface { // Try to move the given actor one tile in the given direction and update their cooldown. // Return true if successful. - attempt_step(actor, direction) { + // ('frameskip' is an absolute number of frames subtracted from the normal speed, only used for + // Lynx's odd trap ejection behavior.) + attempt_step(actor, direction, frameskip = 0) { // In mid-movement, we can't even change direction! if (actor.movement_cooldown > 0) return false; @@ -1594,15 +1641,17 @@ export class Level extends LevelInterface { direction = redirected_direction; } - this.set_actor_direction(actor, direction); // Grab speed /first/, in case the movement or on_blocked turns us into an animation // immediately (and then we won't have a speed!) // FIXME that's a weird case actually since the explosion ends up still moving let speed = actor.type.movement_speed; - let move = DIRECTIONS[direction].movement; - if (! this.check_movement(actor, actor.cell, direction, 'push')) + let success = this.check_movement(actor, actor.cell, direction, 'push'); + // Only set direction after checking movement; check_movement needs it for preventing + // backwards overriding in Lynx + this.set_actor_direction(actor, direction); + if (! success) return false; // We're clear! Compute our speed and move us @@ -1621,9 +1670,10 @@ export class Level extends LevelInterface { let orig_cell = actor.cell; this._set_tile_prop(actor, 'previous_cell', orig_cell); - this._set_tile_prop(actor, 'movement_cooldown', speed * 3); - this._set_tile_prop(actor, 'movement_speed', speed * 3); - this.move_to(actor, goal_cell, speed); + let duration = Math.max(3, speed * 3 - frameskip); + this._set_tile_prop(actor, 'movement_cooldown', duration); + this._set_tile_prop(actor, 'movement_speed', duration); + this.move_to(actor, goal_cell); // Do Lexy-style hooking here: only attempt to pull things just after we've actually moved // successfully, which means the hook can never stop us from moving and hook slapping is not @@ -1646,8 +1696,8 @@ export class Level extends LevelInterface { return true; } - attempt_out_of_turn_step(actor, direction) { - let success = this.attempt_step(actor, direction); + attempt_out_of_turn_step(actor, direction, frameskip = 0) { + let success = this.attempt_step(actor, direction, frameskip); if (success) { this._do_extra_cooldown(actor); } @@ -1665,7 +1715,7 @@ export class Level extends LevelInterface { // Move the given actor to the given position and perform any appropriate // tile interactions. Does NOT check for whether the move is actually // legal; use attempt_step for that! - move_to(actor, goal_cell, speed) { + move_to(actor, goal_cell) { if (actor.cell === goal_cell) return; @@ -2524,13 +2574,42 @@ export class Level extends LevelInterface { } add_actor(actor) { + if (this.compat.reuse_actor_slots) { + // Place the new actor in the first slot taken up by a nonexistent one + for (let i = 0, l = this.actors.length; i < l; i++) { + let old_actor = this.actors[i]; + if (old_actor !== this.player && ! old_actor.cell) { + this.actors[i] = actor; + this._push_pending_undo(() => this.actors[i] = old_actor); + return; + } + } + } + this.actors.push(actor); this._push_pending_undo(() => this.actors.pop()); } + _init_animation(tile) { + // Co-opt movement_cooldown/speed for these despite that they aren't moving, since those + // properties are also used to animate everything else anyway. Decrement the cooldown + // immediately, as Lynx does; note that Lynx also ticks /and destroys/ animations early in + // the decision phase, but this seems to work out just as well + let duration = tile.type.ttl; + if (this.compat.force_lynx_animation_lengths) { + // Lynx animation duration is 12 tics, but it drops one if necessary to make the + // animation end on an even tic (???) and that takes step parity into account + // because I guess it uses the global clock (?????????????????) + duration = (12 - (this.tic_counter + this.step_parity) % 1) * 3; + } + this._set_tile_prop(tile, 'movement_speed', duration); + this._set_tile_prop(tile, 'movement_cooldown', duration); + this._do_extra_cooldown(tile); + } + spawn_animation(cell, name) { let type = TILE_TYPES[name]; - // Spawned VFX erase any existing VFX + // Spawned VFX silently erase any existing VFX if (type.layer === LAYERS.vfx) { let vfx = cell[type.layer]; if (vfx) { @@ -2538,19 +2617,9 @@ export class Level extends LevelInterface { } } let tile = new Tile(type); - // Co-opt movement_cooldown/speed for these despite that they aren't moving, since those - // properties are also used to animate everything else anyway. Decrement the cooldown - // immediately, as Lynx does; note that Lynx also ticks /and destroys/ animations early in - // the decision phase, but this seems to work out just as well - this._set_tile_prop(tile, 'movement_speed', tile.type.ttl); - this._set_tile_prop(tile, 'movement_cooldown', tile.type.ttl); - this._do_extra_cooldown(tile); - cell._add(tile); - this.actors.push(tile); - this._push_pending_undo(() => { - this.actors.pop(); - cell._remove(tile); - }); + this._init_animation(tile); + this.add_tile(tile, cell); + this.add_actor(tile); } transmute_tile(tile, name) { @@ -2585,15 +2654,13 @@ export class Level extends LevelInterface { if (! old_type.is_actor) { console.warn("Transmuting a non-actor into an animation!"); } - this._set_tile_prop(tile, 'previous_cell', null); - this._set_tile_prop(tile, 'movement_speed', tile.type.ttl); - this._set_tile_prop(tile, 'movement_cooldown', tile.type.ttl); // This is effectively a completely new object, so remove double cooldown prevention; - // this cooldown MUST happen, because the renderer can't handle cooldown == speed + // the initial cooldown MUST happen, because the renderer can't handle cooldown == speed if (tile.last_extra_cooldown_tic) { this._set_tile_prop(tile, 'last_extra_cooldown_tic', null); } - this._do_extra_cooldown(tile); + this._init_animation(tile); + this._set_tile_prop(tile, 'previous_cell', null); } if (old_type.on_death) { diff --git a/js/main.js b/js/main.js index adda7a4..f3e2bc7 100644 --- a/js/main.js +++ b/js/main.js @@ -6,6 +6,7 @@ import { DIRECTIONS, INPUT_BITS, TICS_PER_SECOND } from './defs.js'; import * as c2g from './format-c2g.js'; import * as dat from './format-dat.js'; import * as format_base from './format-base.js'; +import * as format_tws from './format-tws.js'; import { Level } from './game.js'; import { PrimaryView, Overlay, DialogOverlay, ConfirmOverlay, flash_button, load_json_from_storage, save_json_to_storage } from './main-base.js'; import { Editor } from './main-editor.js'; @@ -1122,7 +1123,7 @@ class Player extends PrimaryView { return; let [x, y] = this.renderer.cell_coords_from_event(ev); - this.level.move_to(this.level.player, this.level.cell(x, y), 1); + this.level.move_to(this.level.player, this.level.cell(x, y)); // TODO this behaves a bit weirdly when paused (doesn't redraw even with a force), i // think because we're still claiming a speed of 1 so time has to pass before the move // actually "happens" @@ -1341,8 +1342,7 @@ class Player extends PrimaryView { this.debug.replay_duration_el.textContent = format_replay_duration(t); if (! record) { - this.level.force_floor_direction = replay.initial_force_floor_direction; - this.level._blob_modifier = replay.blob_seed; + replay.configure_level(this.level); // FIXME should probably start playback on first real input this.set_state('playing'); } @@ -2979,10 +2979,22 @@ const COMPAT_FLAGS = [ key: 'use_lynx_loop', label: "Game uses the Lynx-style update loop", rulesets: new Set(['steam', 'steam-strict', 'lynx', 'ms']), +}, { + key: 'player_moves_last', + label: "Player always moves last", + rulesets: new Set(['lynx', 'ms']), }, { key: 'emulate_60fps', label: "Game runs at 60 FPS", rulesets: new Set(['steam', 'steam-strict']), +}, { + key: 'reuse_actor_slots', + label: "Game reuses slots in the actor list", + rulesets: new Set(['lynx']), +}, { + key: 'force_lynx_animation_lengths', + label: "Animations use Lynx duration", + rulesets: new Set(['lynx']), }, // Tiles @@ -2995,6 +3007,14 @@ const COMPAT_FLAGS = [ key: 'rff_actually_random', label: "Random force floors are actually random", rulesets: new Set(['ms']), +}, { + key: 'no_backwards_override', + label: "Player can't override backwards on a force floor", + rulesets: new Set(['lynx']), +}, { + key: 'traps_like_lynx', + label: "Traps eject faster, and even when already open", + rulesets: new Set(['lynx']), }, // Items @@ -3010,6 +3030,10 @@ const COMPAT_FLAGS = [ key: 'monsters_ignore_keys', label: "Monsters completely ignore keys", rulesets: new Set(['ms']), +}, { + key: 'monsters_blocked_by_items', + label: "Monsters can't step on items to get the player", + rulesets: new Set(['lynx']), }, // Blocks @@ -3047,10 +3071,26 @@ const COMPAT_FLAGS = [ key: 'tanks_always_obey_button', label: "Blue tanks always obey blue buttons", rulesets: new Set(['steam-strict']), +}, { + key: 'tanks_ignore_button_while_moving', + label: "Blue tanks ignore blue buttons while moving", + rulesets: new Set(['lynx']), +}, { + key: 'blobs_use_tw_prng', + label: "Blobs use the Tile World RNG", + rulesets: new Set(['lynx']), +}, { + key: 'teeth_target_internal_position', + label: "Teeth target the player's internal position", + rulesets: new Set(['lynx']), }, { key: 'rff_blocks_monsters', label: "Random force floors block monsters", rulesets: new Set(['ms']), +}, { + key: 'bonking_isnt_instant', + label: "Bonking while sliding doesn't apply instantly", + rulesets: new Set(['lynx', 'ms']), }, { key: 'fire_allows_monsters', label: "Fire doesn't block monsters", @@ -3355,9 +3395,8 @@ class PackTestDialog extends DialogOverlay { let replay = stored_level.replay; level = new Level(stored_level, compat); level.sfx = dummy_sfx; - level.force_floor_direction = replay.initial_force_floor_direction; - level._blob_modifier = replay.blob_seed; level.undo_enabled = false; // slight performance boost + replay.configure_level(level); while (true) { let input = replay.get(level.tic_counter); diff --git a/js/tiletypes.js b/js/tiletypes.js index d2c6d61..7644dcf 100644 --- a/js/tiletypes.js +++ b/js/tiletypes.js @@ -191,7 +191,14 @@ function pursue_player(me, level) { let player = level.player; // CC2 behavior (not Lynx (TODO compat?)): pursue the player's apparent position, not just the // cell they're in - let [px, py] = player.visual_position(); + let px, py; + if (level.compat.teeth_target_internal_position) { + px = player.cell.x; + py = player.cell.y; + } + else { + [px, py] = player.visual_position(); + } let dx = me.cell.x - px; let dy = me.cell.y - py; @@ -1495,11 +1502,17 @@ const TILE_TYPES = { level._set_tile_prop(me, 'presses', 0); } }, + on_arrive(me, level, other) { + // Lynx (not cc2): open traps immediately eject their contents on arrival, if possible, + // and also do it slightly faster + if (level.compat.traps_like_lynx) { + level.attempt_out_of_turn_step(other, other.direction, 3); + } + }, add_press_ready(me, level, other) { // Same as below, but without ejection level._set_tile_prop(me, 'presses', (me.presses ?? 0) + 1); }, - // Lynx (not cc2): open traps immediately eject their contents on arrival, if possible add_press(me, level, is_wire = false) { level._set_tile_prop(me, 'presses', me.presses + 1); // TODO weird cc2 case that may or may not be a bug: actors aren't ejected if the trap @@ -1510,7 +1523,9 @@ const TILE_TYPES = { if (actor) { // Forcibly move anything released from a trap, which keeps it in sync with // whatever pushed the button - level.attempt_out_of_turn_step(actor, actor.direction); + level.attempt_out_of_turn_step( + actor, actor.direction, + level.compat.traps_like_lynx ? 3 : 0); } } }, @@ -2399,8 +2414,15 @@ const TILE_TYPES = { movement_speed: 8, decide_movement(me, level) { // move completely at random - let modifier = level.get_blob_modifier(); - return [DIRECTION_ORDER[(level.prng() + modifier) % 4]]; + let d; + if (level.compat.blobs_use_tw_prng) { + d = level.tw_prng_random4(); + } + else { + let modifier = level.get_blob_modifier(); + d = (level.prng() + modifier) % 4; + } + return [DIRECTION_ORDER[d]]; }, }, teeth: {