Add a bunch of Lynx compat options

This commit is contained in:
Eevee (Evelyn Woods) 2021-03-05 13:54:38 -07:00
parent a750a569ab
commit be275d380d
5 changed files with 355 additions and 85 deletions

View File

@ -8,14 +8,25 @@ export class StoredCell extends Array {
} }
export class Replay { 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.initial_force_floor_direction = initial_force_floor_direction;
this.blob_seed = blob_seed; this.blob_seed = blob_seed;
this.step_parity = step_parity;
this.tw_seed = tw_seed;
this.inputs = inputs ?? new Uint8Array; this.inputs = inputs ?? new Uint8Array;
this.duration = this.inputs.length; this.duration = this.inputs.length;
this.cursor = 0; 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) { get(t) {
if (this.duration <= 0) { if (this.duration <= 0) {
return 0; return 0;

131
js/format-tws.js Normal file
View File

@ -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 25 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;
}

View File

@ -59,7 +59,7 @@ export class Tile {
// Extremely awkward special case: items don't block monsters if the cell also contains an // 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 // item modifier (i.e. "no" sign) or a real player
// TODO would love to get this outta here // 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(); let item_mod = this.cell.get_item_mod();
if (item_mod && item_mod.type.item_modifier) if (item_mod && item_mod.type.item_modifier)
return false; return false;
@ -286,6 +286,7 @@ export class Cell extends Array {
// - null: Default. Do not impact game state. Treat pushable objects as blocking. // - 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/ // - 'bump': Fire bump triggers. Don't move pushable objects, but do check whether they /could/
// be pushed, recursively if necessary. // 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. // - 'push': Fire bump triggers. Attempt to move pushable objects out of the way immediately.
try_entering(actor, direction, level, push_mode = null) { try_entering(actor, direction, level, push_mode = null) {
let pushable_tiles = []; let pushable_tiles = [];
@ -352,7 +353,7 @@ export class Cell extends Array {
for (let tile of pushable_tiles) { for (let tile of pushable_tiles) {
if (tile._trying_to_push) if (tile._trying_to_push)
return false; 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 // FIXME this doesn't take railroad curves into account, e.g. it thinks a
// rover can't push a block through a curve // rover can't push a block through a curve
if (tile.movement_cooldown > 0 || if (tile.movement_cooldown > 0 ||
@ -360,6 +361,13 @@ export class Cell extends Array {
{ {
return false; 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') { else if (push_mode === 'push') {
if (actor === level.player) { if (actor === level.player) {
@ -485,6 +493,7 @@ export class Level extends LevelInterface {
// PRNG is initialized to zero // PRNG is initialized to zero
this._rng1 = 0; this._rng1 = 0;
this._rng2 = 0; this._rng2 = 0;
this._tw_rng = Math.floor(Math.random() * 0x80000000);
if (this.stored_level.blob_behavior === 0) { if (this.stored_level.blob_behavior === 0) {
this._blob_modifier = 0x55; 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 // TODO complain if no player
// Used for doppelgängers // Used for doppelgängers
this.player1_move = null; this.player1_move = null;
@ -822,6 +837,16 @@ export class Level extends LevelInterface {
return this._rng1 ^ this._rng2; 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 // Weird thing done by CC2 to make blobs... more... random
get_blob_modifier() { get_blob_modifier() {
let mod = this._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 // 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.) // they only take a few bytes each so that's fine.)
for (let key of [ 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', 'tic_counter', 'time_remaining', 'timer_paused',
'chips_remaining', 'bonus_points', 'state', 'chips_remaining', 'bonus_points', 'state',
'player1_move', 'player2_move', 'remaining_players', 'player', 'player1_move', 'player2_move', 'remaining_players', 'player',
@ -966,15 +991,7 @@ export class Level extends LevelInterface {
if (actor.type.ttl) if (actor.type.ttl)
continue; continue;
if (actor.movement_cooldown <= 0) { this._do_actor_idle(actor);
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._swap_players(); this._swap_players();
@ -1098,15 +1115,7 @@ export class Level extends LevelInterface {
continue; continue;
this._do_actor_cooldown(actor, cooldown); this._do_actor_cooldown(actor, cooldown);
if (actor.movement_cooldown <= 0) { this._do_actor_idle(actor);
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._swap_players(); this._swap_players();
@ -1159,7 +1168,7 @@ export class Level extends LevelInterface {
terrain.type.on_arrive(terrain, this, actor); terrain.type.on_arrive(terrain, this, actor);
} }
// If we got a new direction, try moving again // 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); 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() { _swap_players() {
if (this.remaining_players <= 0) { if (this.remaining_players <= 0) {
this.win(); this.win();
@ -1250,6 +1271,9 @@ export class Level extends LevelInterface {
if (actor.type.is_real_player) { if (actor.type.is_real_player) {
this.player = actor; this.player = actor;
if (this.compat.player_moves_last && i !== 0) {
[this.actors[0], this.actors[i]] = [this.actors[i], this.actors[0]];
}
break; break;
} }
} }
@ -1257,24 +1281,37 @@ export class Level extends LevelInterface {
} }
_do_cleanup_phase() { _do_cleanup_phase() {
// Strip out any destroyed actors from the acting order // Lynx compat: Any blue tank that still has the reversal flag set here, but is in motion,
// FIXME this is O(n), where n is /usually/ small, but i still don't love it. not strictly // should ignore it. Unfortunately this has to be done as its own pass (as it is in Lynx!)
// necessary, either; maybe only do it every few tics? // because of acting order issues
let p = 0; if (this.compat.tanks_ignore_button_while_moving) {
for (let i = 0, l = this.actors.length; i < l; i++) { for (let actor of this.actors) {
let actor = this.actors[i]; if (actor.cell && actor.pending_reverse && actor.movement_cooldown > 0) {
if (actor.cell) { this._set_tile_prop(actor, 'pending_reverse', false);
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;
// 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 // Advance the clock
// TODO i suspect cc2 does this at the beginning of the tic, but even if you've won? if you // 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 // 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 // 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! // 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; let open;
if (dir2 === null) { if (dir2 === null) {
// Only one direction is held, but for consistency, "check" it anyway // 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; actor.decision = dir1;
} }
else { else {
@ -1413,8 +1450,8 @@ export class Level extends LevelInterface {
// interpret our input! // interpret our input!
if (dir1 === actor.direction || dir2 === actor.direction) { if (dir1 === actor.direction || dir2 === actor.direction) {
let other_direction = dir1 === actor.direction ? dir2 : dir1; let other_direction = dir1 === actor.direction ? dir2 : dir1;
let curr_open = try_direction(actor.direction, 'push'); let curr_open = try_direction(actor.direction, push_mode);
let other_open = try_direction(other_direction, 'push'); let other_open = try_direction(other_direction, push_mode);
if (! curr_open && other_open) { if (! curr_open && other_open) {
actor.decision = other_direction; actor.decision = other_direction;
open = true; 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 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 // FIXME lynx only checks horizontal, what about cc2? it must check both
// because of the behavior where pushing into a corner always pushes horizontal // because of the behavior where pushing into a corner always pushes horizontal
let open1 = try_direction(dir1, 'push'); let open1 = try_direction(dir1, push_mode);
let open2 = try_direction(dir2, 'push'); let open2 = try_direction(dir2, push_mode);
if (open1 && ! open2) { if (open1 && ! open2) {
actor.decision = dir1; actor.decision = dir1;
open = true; 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 // If we're overriding a force floor but the direction we're moving in is blocked, this
// counts as a forced move // counts as a forced move (but only under the CC2 behavior of instant bonking)
if (actor.slide_mode === 'force' && ! open) { if (actor.slide_mode === 'force' && ! open && ! this.compat.bonking_isnt_instant) {
this._set_tile_prop(actor, 'last_move_was_force', true); this._set_tile_prop(actor, 'last_move_was_force', true);
} }
else { else {
@ -1536,6 +1573,14 @@ export class Level extends LevelInterface {
} }
check_movement(actor, orig_cell, direction, push_mode) { 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); let dest_cell = this.get_neighboring_cell(orig_cell, direction);
if (! dest_cell) { if (! dest_cell) {
if (push_mode === 'push') { 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. // Try to move the given actor one tile in the given direction and update their cooldown.
// Return true if successful. // 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! // In mid-movement, we can't even change direction!
if (actor.movement_cooldown > 0) if (actor.movement_cooldown > 0)
return false; return false;
@ -1594,15 +1641,17 @@ export class Level extends LevelInterface {
direction = redirected_direction; direction = redirected_direction;
} }
this.set_actor_direction(actor, direction);
// Grab speed /first/, in case the movement or on_blocked turns us into an animation // Grab speed /first/, in case the movement or on_blocked turns us into an animation
// immediately (and then we won't have a speed!) // immediately (and then we won't have a speed!)
// FIXME that's a weird case actually since the explosion ends up still moving // FIXME that's a weird case actually since the explosion ends up still moving
let speed = actor.type.movement_speed; let speed = actor.type.movement_speed;
let move = DIRECTIONS[direction].movement; let success = this.check_movement(actor, actor.cell, direction, 'push');
if (! 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; return false;
// We're clear! Compute our speed and move us // We're clear! Compute our speed and move us
@ -1621,9 +1670,10 @@ export class Level extends LevelInterface {
let orig_cell = actor.cell; let orig_cell = actor.cell;
this._set_tile_prop(actor, 'previous_cell', orig_cell); this._set_tile_prop(actor, 'previous_cell', orig_cell);
this._set_tile_prop(actor, 'movement_cooldown', speed * 3); let duration = Math.max(3, speed * 3 - frameskip);
this._set_tile_prop(actor, 'movement_speed', speed * 3); this._set_tile_prop(actor, 'movement_cooldown', duration);
this.move_to(actor, goal_cell, speed); 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 // 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 // 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; return true;
} }
attempt_out_of_turn_step(actor, direction) { attempt_out_of_turn_step(actor, direction, frameskip = 0) {
let success = this.attempt_step(actor, direction); let success = this.attempt_step(actor, direction, frameskip);
if (success) { if (success) {
this._do_extra_cooldown(actor); 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 // Move the given actor to the given position and perform any appropriate
// tile interactions. Does NOT check for whether the move is actually // tile interactions. Does NOT check for whether the move is actually
// legal; use attempt_step for that! // legal; use attempt_step for that!
move_to(actor, goal_cell, speed) { move_to(actor, goal_cell) {
if (actor.cell === goal_cell) if (actor.cell === goal_cell)
return; return;
@ -2524,13 +2574,42 @@ export class Level extends LevelInterface {
} }
add_actor(actor) { 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.actors.push(actor);
this._push_pending_undo(() => this.actors.pop()); 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) { spawn_animation(cell, name) {
let type = TILE_TYPES[name]; let type = TILE_TYPES[name];
// Spawned VFX erase any existing VFX // Spawned VFX silently erase any existing VFX
if (type.layer === LAYERS.vfx) { if (type.layer === LAYERS.vfx) {
let vfx = cell[type.layer]; let vfx = cell[type.layer];
if (vfx) { if (vfx) {
@ -2538,19 +2617,9 @@ export class Level extends LevelInterface {
} }
} }
let tile = new Tile(type); let tile = new Tile(type);
// Co-opt movement_cooldown/speed for these despite that they aren't moving, since those this._init_animation(tile);
// properties are also used to animate everything else anyway. Decrement the cooldown this.add_tile(tile, cell);
// immediately, as Lynx does; note that Lynx also ticks /and destroys/ animations early in this.add_actor(tile);
// 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);
});
} }
transmute_tile(tile, name) { transmute_tile(tile, name) {
@ -2585,15 +2654,13 @@ export class Level extends LevelInterface {
if (! old_type.is_actor) { if (! old_type.is_actor) {
console.warn("Transmuting a non-actor into an animation!"); 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 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) { if (tile.last_extra_cooldown_tic) {
this._set_tile_prop(tile, 'last_extra_cooldown_tic', null); 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) { if (old_type.on_death) {

View File

@ -6,6 +6,7 @@ import { DIRECTIONS, INPUT_BITS, TICS_PER_SECOND } from './defs.js';
import * as c2g from './format-c2g.js'; import * as c2g from './format-c2g.js';
import * as dat from './format-dat.js'; import * as dat from './format-dat.js';
import * as format_base from './format-base.js'; import * as format_base from './format-base.js';
import * as format_tws from './format-tws.js';
import { Level } from './game.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 { PrimaryView, Overlay, DialogOverlay, ConfirmOverlay, flash_button, load_json_from_storage, save_json_to_storage } from './main-base.js';
import { Editor } from './main-editor.js'; import { Editor } from './main-editor.js';
@ -1122,7 +1123,7 @@ class Player extends PrimaryView {
return; return;
let [x, y] = this.renderer.cell_coords_from_event(ev); 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 // 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 // think because we're still claiming a speed of 1 so time has to pass before the move
// actually "happens" // actually "happens"
@ -1341,8 +1342,7 @@ class Player extends PrimaryView {
this.debug.replay_duration_el.textContent = format_replay_duration(t); this.debug.replay_duration_el.textContent = format_replay_duration(t);
if (! record) { if (! record) {
this.level.force_floor_direction = replay.initial_force_floor_direction; replay.configure_level(this.level);
this.level._blob_modifier = replay.blob_seed;
// FIXME should probably start playback on first real input // FIXME should probably start playback on first real input
this.set_state('playing'); this.set_state('playing');
} }
@ -2979,10 +2979,22 @@ const COMPAT_FLAGS = [
key: 'use_lynx_loop', key: 'use_lynx_loop',
label: "Game uses the Lynx-style update loop", label: "Game uses the Lynx-style update loop",
rulesets: new Set(['steam', 'steam-strict', 'lynx', 'ms']), 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', key: 'emulate_60fps',
label: "Game runs at 60 FPS", label: "Game runs at 60 FPS",
rulesets: new Set(['steam', 'steam-strict']), 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 // Tiles
@ -2995,6 +3007,14 @@ const COMPAT_FLAGS = [
key: 'rff_actually_random', key: 'rff_actually_random',
label: "Random force floors are actually random", label: "Random force floors are actually random",
rulesets: new Set(['ms']), 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 // Items
@ -3010,6 +3030,10 @@ const COMPAT_FLAGS = [
key: 'monsters_ignore_keys', key: 'monsters_ignore_keys',
label: "Monsters completely ignore keys", label: "Monsters completely ignore keys",
rulesets: new Set(['ms']), 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 // Blocks
@ -3047,10 +3071,26 @@ const COMPAT_FLAGS = [
key: 'tanks_always_obey_button', key: 'tanks_always_obey_button',
label: "Blue tanks always obey blue buttons", label: "Blue tanks always obey blue buttons",
rulesets: new Set(['steam-strict']), 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', key: 'rff_blocks_monsters',
label: "Random force floors block monsters", label: "Random force floors block monsters",
rulesets: new Set(['ms']), 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', key: 'fire_allows_monsters',
label: "Fire doesn't block monsters", label: "Fire doesn't block monsters",
@ -3355,9 +3395,8 @@ class PackTestDialog extends DialogOverlay {
let replay = stored_level.replay; let replay = stored_level.replay;
level = new Level(stored_level, compat); level = new Level(stored_level, compat);
level.sfx = dummy_sfx; 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 level.undo_enabled = false; // slight performance boost
replay.configure_level(level);
while (true) { while (true) {
let input = replay.get(level.tic_counter); let input = replay.get(level.tic_counter);

View File

@ -191,7 +191,14 @@ function pursue_player(me, level) {
let player = level.player; let player = level.player;
// CC2 behavior (not Lynx (TODO compat?)): pursue the player's apparent position, not just the // CC2 behavior (not Lynx (TODO compat?)): pursue the player's apparent position, not just the
// cell they're in // 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 dx = me.cell.x - px;
let dy = me.cell.y - py; let dy = me.cell.y - py;
@ -1495,11 +1502,17 @@ const TILE_TYPES = {
level._set_tile_prop(me, 'presses', 0); 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) { add_press_ready(me, level, other) {
// Same as below, but without ejection // Same as below, but without ejection
level._set_tile_prop(me, 'presses', (me.presses ?? 0) + 1); 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) { add_press(me, level, is_wire = false) {
level._set_tile_prop(me, 'presses', me.presses + 1); 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 // 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) { if (actor) {
// Forcibly move anything released from a trap, which keeps it in sync with // Forcibly move anything released from a trap, which keeps it in sync with
// whatever pushed the button // 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, movement_speed: 8,
decide_movement(me, level) { decide_movement(me, level) {
// move completely at random // move completely at random
let modifier = level.get_blob_modifier(); let d;
return [DIRECTION_ORDER[(level.prng() + modifier) % 4]]; 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: { teeth: {