Shrink size of undo buffer by 40%

Using simple maps of changed properties, rather than a big pile of
closures, takes up significantly less space.
This commit is contained in:
Eevee (Evelyn Woods) 2020-11-03 11:48:51 -07:00
parent 84840d2b02
commit 350ac08d4d
2 changed files with 92 additions and 86 deletions

View File

@ -1,4 +1,4 @@
import { DIRECTIONS } from './defs.js'; import { DIRECTIONS, TICS_PER_SECOND } from './defs.js';
import TILE_TYPES from './tiletypes.js'; import TILE_TYPES from './tiletypes.js';
export class Tile { export class Tile {
@ -165,6 +165,11 @@ Cell.prototype.powered_edges = 0;
class GameEnded extends Error {} class GameEnded extends Error {}
// The undo stack is implemented with a ring buffer, and this is its size. One entry per tic.
// Based on Chrome measurements made against the pathological level CCLP4 #40 (Periodic Lasers) and
// sitting completely idle, undo consumes about 2 MB every five seconds.
// TODO actually make it a ring buffer
const UNDO_STACK_SIZE = TICS_PER_SECOND * 10;
export class Level { export class Level {
constructor(stored_level, compat = {}) { constructor(stored_level, compat = {}) {
this.stored_level = stored_level; this.stored_level = stored_level;
@ -225,7 +230,7 @@ export class Level {
} }
this.undo_stack = []; this.undo_stack = [];
this.pending_undo = []; this.pending_undo = this.create_undo_entry();
let n = 0; let n = 0;
let connectables = []; let connectables = [];
@ -376,15 +381,6 @@ export class Level {
// Lynx PRNG, used unchanged in CC2 // Lynx PRNG, used unchanged in CC2
prng() { 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; let n = (this._rng1 >> 2) - this._rng1;
if (!(this._rng1 & 0x02)) --n; if (!(this._rng1 & 0x02)) --n;
this._rng1 = (this._rng1 >> 1) | (this._rng2 & 0x80); this._rng1 = (this._rng1 >> 1) | (this._rng2 & 0x80);
@ -396,7 +392,6 @@ export class Level {
// 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;
this.pending_undo.push(() => this._blob_modifier = mod);
if (this.stored_level.blob_behavior === 1) { if (this.stored_level.blob_behavior === 1) {
// "4 patterns" just increments by 1 every time (but /after/ returning) // "4 patterns" just increments by 1 every time (but /after/ returning)
@ -455,9 +450,19 @@ export class Level {
} }
advance_tic_finish_movement(p1_primary_direction, p1_secondary_direction) { advance_tic_finish_movement(p1_primary_direction, p1_secondary_direction) {
// 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',
'tic_counter', 'time_remaining', 'timer_paused',
'chips_remaining', 'bonus_points', 'hint_shown', 'state'
]) {
this.pending_undo.level_props[key] = this[key];
}
// Player's secondary direction is set immediately; it applies on arrival to cells even if // Player's secondary direction is set immediately; it applies on arrival to cells even if
// it wasn't held the last time the player started moving // it wasn't held the last time the player started moving
this._set_prop(this.player, 'secondary_direction', p1_secondary_direction); this._set_tile_prop(this.player, 'secondary_direction', p1_secondary_direction);
// Used to check for a monster chomping the player's tail // Used to check for a monster chomping the player's tail
this.player_leaving_cell = this.player.cell; this.player_leaving_cell = this.player.cell;
@ -491,21 +496,21 @@ export class Level {
// Decrement the cooldown here, but don't check it quite yet, // Decrement the cooldown here, but don't check it quite yet,
// because stepping on cells in the next block might reset it // because stepping on cells in the next block might reset it
if (actor.movement_cooldown > 0) { if (actor.movement_cooldown > 0) {
this._set_prop(actor, 'movement_cooldown', actor.movement_cooldown - 1); this._set_tile_prop(actor, 'movement_cooldown', actor.movement_cooldown - 1);
} }
if (actor.animation_speed) { if (actor.animation_speed) {
// Deal with movement animation // Deal with movement animation
this._set_prop(actor, 'animation_progress', actor.animation_progress + 1); this._set_tile_prop(actor, 'animation_progress', actor.animation_progress + 1);
if (actor.animation_progress >= actor.animation_speed) { if (actor.animation_progress >= actor.animation_speed) {
if (actor.type.ttl) { if (actor.type.ttl) {
// This is purely an animation so it disappears once it's played // This is purely an animation so it disappears once it's played
this.remove_tile(actor); this.remove_tile(actor);
continue; continue;
} }
this._set_prop(actor, 'previous_cell', null); this._set_tile_prop(actor, 'previous_cell', null);
this._set_prop(actor, 'animation_progress', null); this._set_tile_prop(actor, 'animation_progress', null);
this._set_prop(actor, 'animation_speed', null); this._set_tile_prop(actor, 'animation_speed', null);
if (! this.compat.tiles_react_instantly) { if (! this.compat.tiles_react_instantly) {
// We need to track the actor AND the cell explicitly, because it's possible // We need to track the actor AND the cell explicitly, because it's possible
// that one actor's step will cause another actor to start another move, and // that one actor's step will cause another actor to start another move, and
@ -552,13 +557,13 @@ export class Level {
if (this.compat.sliding_tanks_ignore_button && if (this.compat.sliding_tanks_ignore_button &&
actor.slide_mode && actor.pending_reverse) actor.slide_mode && actor.pending_reverse)
{ {
this._set_prop(actor, 'pending_reverse', false); this._set_tile_prop(actor, 'pending_reverse', false);
} }
// Blocks that were pushed while sliding will move in the push direction as soon as they // Blocks that were pushed while sliding will move in the push direction as soon as they
// stop sliding, regardless of what they landed on // stop sliding, regardless of what they landed on
if (actor.pending_push) { if (actor.pending_push) {
actor.decision = actor.pending_push; actor.decision = actor.pending_push;
this._set_prop(actor, 'pending_push', null); this._set_tile_prop(actor, 'pending_push', null);
continue; continue;
} }
if (actor.slide_mode === 'ice') { if (actor.slide_mode === 'ice') {
@ -578,12 +583,12 @@ export class Level {
actor.last_move_was_force) actor.last_move_was_force)
{ {
actor.decision = p1_primary_direction; actor.decision = p1_primary_direction;
this._set_prop(actor, 'last_move_was_force', false); this._set_tile_prop(actor, 'last_move_was_force', false);
} }
else { else {
actor.decision = actor.direction; actor.decision = actor.direction;
if (actor === this.player) { if (actor === this.player) {
this._set_prop(actor, 'last_move_was_force', true); this._set_tile_prop(actor, 'last_move_was_force', true);
} }
} }
continue; continue;
@ -591,7 +596,7 @@ export class Level {
else if (actor === this.player) { else if (actor === this.player) {
if (p1_primary_direction) { if (p1_primary_direction) {
actor.decision = p1_primary_direction; actor.decision = p1_primary_direction;
this._set_prop(actor, 'last_move_was_force', false); this._set_tile_prop(actor, 'last_move_was_force', false);
} }
continue; continue;
} }
@ -600,7 +605,7 @@ export class Level {
let direction = actor.direction; let direction = actor.direction;
if (actor.pending_reverse) { if (actor.pending_reverse) {
direction = DIRECTIONS[actor.direction].opposite; direction = DIRECTIONS[actor.direction].opposite;
this._set_prop(actor, 'pending_reverse', false); this._set_tile_prop(actor, 'pending_reverse', false);
} }
// Tanks are controlled explicitly so they don't check if they're blocked // Tanks are controlled explicitly so they don't check if they're blocked
// TODO tanks in traps turn around, but tanks on cloners do not, and i use the same // TODO tanks in traps turn around, but tanks on cloners do not, and i use the same
@ -791,12 +796,6 @@ export class Level {
let tic_counter = this.tic_counter; let tic_counter = this.tic_counter;
this.tic_counter += 1; this.tic_counter += 1;
if (this.time_remaining !== null && ! this.timer_paused) { if (this.time_remaining !== null && ! this.timer_paused) {
let time_remaining = this.time_remaining;
this.pending_undo.push(() => {
this.tic_counter = tic_counter;
this.time_remaining = time_remaining;
});
this.time_remaining -= 1; this.time_remaining -= 1;
if (this.time_remaining <= 0) { if (this.time_remaining <= 0) {
this.fail('time'); this.fail('time');
@ -805,11 +804,6 @@ export class Level {
this.sfx.play_once('tick'); this.sfx.play_once('tick');
} }
} }
else {
this.pending_undo.push(() => {
this.tic_counter = tic_counter;
});
}
} }
// Try to move the given actor one tile in the given direction and update // Try to move the given actor one tile in the given direction and update
@ -891,7 +885,7 @@ export class Level {
{ {
// If the push failed and the obstacle is in the middle of a slide, // If the push failed and the obstacle is in the middle of a slide,
// remember this as the next move it'll make // remember this as the next move it'll make
this._set_prop(tile, 'pending_push', direction); this._set_tile_prop(tile, 'pending_push', direction);
} }
if (actor === this.player) { if (actor === this.player) {
actor.is_pushing = true; actor.is_pushing = true;
@ -937,7 +931,7 @@ export class Level {
this.move_to(actor, goal_cell, speed); this.move_to(actor, goal_cell, speed);
// Set movement cooldown since we just moved // Set movement cooldown since we just moved
this._set_prop(actor, 'movement_cooldown', speed); this._set_tile_prop(actor, 'movement_cooldown', speed);
return true; return true;
} }
@ -948,9 +942,9 @@ export class Level {
if (actor.cell === goal_cell) if (actor.cell === goal_cell)
return; return;
this._set_prop(actor, 'previous_cell', actor.cell); this._set_tile_prop(actor, 'previous_cell', actor.cell);
this._set_prop(actor, 'animation_speed', speed); this._set_tile_prop(actor, 'animation_speed', speed);
this._set_prop(actor, 'animation_progress', 0); this._set_tile_prop(actor, 'animation_progress', 0);
let original_cell = actor.cell; let original_cell = actor.cell;
this.remove_tile(actor); this.remove_tile(actor);
@ -972,7 +966,7 @@ export class Level {
// Check for a couple effects that always apply immediately // Check for a couple effects that always apply immediately
// TODO do blocks smash monsters? // TODO do blocks smash monsters?
if (actor === this.player) { if (actor === this.player) {
this._set_prop(this, 'hint_shown', null); this.hint_shown = null;
} }
for (let tile of goal_cell) { for (let tile of goal_cell) {
if (actor.type.is_player && tile.type.is_monster) { if (actor.type.is_player && tile.type.is_monster) {
@ -990,7 +984,7 @@ export class Level {
} }
if (actor === this.player && tile.type.is_hint) { if (actor === this.player && tile.type.is_hint) {
this._set_prop(this, 'hint_shown', tile.specific_hint ?? this.stored_level.hint); this.hint_shown = tile.specific_hint ?? this.stored_level.hint;
} }
} }
@ -1321,13 +1315,19 @@ export class Level {
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
// Undo handling // Undo handling
create_undo_entry() {
let entry = [];
entry.tile_changes = new Map;
entry.level_props = {};
return entry;
}
commit() { commit() {
this.undo_stack.push(this.pending_undo); this.undo_stack.push(this.pending_undo);
this.pending_undo = []; this.pending_undo = this.create_undo_entry();
// Limit the stack to, idk, 200 tics (10 seconds) if (this.undo_stack.length > UNDO_STACK_SIZE) {
if (this.undo_stack.length > 200) { this.undo_stack.splice(0, this.undo_stack.length - UNDO_STACK_SIZE);
this.undo_stack.splice(0, this.undo_stack.length - 200);
} }
} }
@ -1335,18 +1335,27 @@ export class Level {
this.aid = Math.max(1, this.aid); this.aid = Math.max(1, this.aid);
// In turn-based mode, we might still be in mid-tic with a partial undo stack; do that first // In turn-based mode, we might still be in mid-tic with a partial undo stack; do that first
this.pending_undo.reverse(); this._undo_entry(this.pending_undo);
for (let undo of this.pending_undo) { this.pending_undo = this.create_undo_entry();
undo();
}
this.pending_undo = [];
let entry = this.undo_stack.pop(); this._undo_entry(this.undo_stack.pop());
}
// Reverse a single undo entry
_undo_entry(entry) {
// Undo in reverse order! There's no redo, so it's okay to destroy this // Undo in reverse order! There's no redo, so it's okay to destroy this
entry.reverse(); entry.reverse();
for (let undo of entry) { for (let undo of entry) {
undo(); undo();
} }
for (let [tile, changes] of entry.tile_changes) {
for (let [key, value] of changes) {
tile[key] = value;
}
}
for (let [key, value] of Object.entries(entry.level_props)) {
this[key] = value;
}
} }
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
@ -1354,26 +1363,37 @@ export class Level {
// including the state of a single tile, should do it through one of these // including the state of a single tile, should do it through one of these
// for undo/rewind purposes // for undo/rewind purposes
_set_prop(obj, key, val) { _set_tile_prop(tile, key, val) {
let old_val = obj[key]; if (tile[key] === val)
if (val === old_val)
return; return;
this.pending_undo.push(() => obj[key] = old_val);
obj[key] = val; let changes = this.pending_undo.tile_changes.get(tile);
if (! changes) {
changes = new Map;
this.pending_undo.tile_changes.set(tile, changes);
}
// If we haven't yet done so, log the original value
if (! changes.has(key)) {
changes.set(key, tile[key]);
}
// If there's an original value already logged, and it's the value we're about to change
// back to, then delete the change
else if (changes.get(key) === val) {
changes.delete(key);
}
tile[key] = val;
} }
collect_chip() { collect_chip() {
let current = this.chips_remaining; if (this.chips_remaining > 0) {
if (current > 0) {
this.sfx.play_once('get-chip'); this.sfx.play_once('get-chip');
this.pending_undo.push(() => this.chips_remaining = current);
this.chips_remaining--; this.chips_remaining--;
} }
} }
adjust_bonus(add, mult = 1) { adjust_bonus(add, mult = 1) {
let current = this.bonus_points;
this.pending_undo.push(() => this.bonus_points = current);
this.bonus_points = Math.ceil(this.bonus_points * mult) + add; this.bonus_points = Math.ceil(this.bonus_points * mult) + add;
} }
@ -1381,14 +1401,10 @@ export class Level {
if (this.time_remaining === null) if (this.time_remaining === null)
return; return;
this.pending_undo.push(() => this.timer_paused = ! this.timer_paused);
this.timer_paused = ! this.timer_paused; this.timer_paused = ! this.timer_paused;
} }
adjust_timer(dt) { adjust_timer(dt) {
let current = this.time_remaining;
this.pending_undo.push(() => this.time_remaining = current);
// Untimed levels become timed levels with 0 seconds remaining // Untimed levels become timed levels with 0 seconds remaining
this.time_remaining = Math.max(0, (this.time_remaining ?? 0) + dt * 20); this.time_remaining = Math.max(0, (this.time_remaining ?? 0) + dt * 20);
if (this.time_remaining <= 0) { if (this.time_remaining <= 0) {
@ -1410,7 +1426,6 @@ export class Level {
} }
this.pending_undo.push(() => { this.pending_undo.push(() => {
this.state = 'playing';
this.fail_reason = null; this.fail_reason = null;
this.player.fail_reason = null; this.player.fail_reason = null;
}); });
@ -1422,7 +1437,6 @@ export class Level {
win() { win() {
this.sfx.play_once('win'); this.sfx.play_once('win');
this.pending_undo.push(() => this.state = 'playing');
this.state = 'success'; this.state = 'success';
throw new GameEnded; throw new GameEnded;
} }
@ -1472,8 +1486,8 @@ export class Level {
spawn_animation(cell, name) { spawn_animation(cell, name) {
let type = TILE_TYPES[name]; let type = TILE_TYPES[name];
let tile = new Tile(type); let tile = new Tile(type);
this._set_prop(tile, 'animation_speed', tile.type.ttl); this._set_tile_prop(tile, 'animation_speed', tile.type.ttl);
this._set_prop(tile, 'animation_progress', 0); this._set_tile_prop(tile, 'animation_progress', 0);
cell._add(tile); cell._add(tile);
this.actors.push(tile); this.actors.push(tile);
this.pending_undo.push(() => { this.pending_undo.push(() => {
@ -1492,8 +1506,8 @@ export class Level {
if (! TILE_TYPES[current].is_actor) { if (! TILE_TYPES[current].is_actor) {
console.warn("Transmuting a non-actor into an animation!"); console.warn("Transmuting a non-actor into an animation!");
} }
this._set_prop(tile, 'animation_speed', tile.type.ttl); this._set_tile_prop(tile, 'animation_speed', tile.type.ttl);
this._set_prop(tile, 'animation_progress', 0); this._set_tile_prop(tile, 'animation_progress', 0);
} }
} }
@ -1566,23 +1580,15 @@ export class Level {
// Mark an actor as sliding // Mark an actor as sliding
make_slide(actor, mode) { make_slide(actor, mode) {
let current = actor.slide_mode; this._set_tile_prop(actor, 'slide_mode', mode);
this.pending_undo.push(() => actor.slide_mode = current);
actor.slide_mode = mode;
} }
// Change an actor's direction // Change an actor's direction
set_actor_direction(actor, direction) { set_actor_direction(actor, direction) {
let current = actor.direction; this._set_tile_prop(actor, 'direction', direction);
this.pending_undo.push(() => actor.direction = current);
actor.direction = direction;
} }
set_actor_stuck(actor, is_stuck) { set_actor_stuck(actor, is_stuck) {
let current = actor.stuck; this._set_tile_prop(actor, 'stuck', is_stuck);
if (current === is_stuck)
return;
this.pending_undo.push(() => actor.stuck = current);
actor.stuck = is_stuck;
} }
} }

View File

@ -747,7 +747,7 @@ const TILE_TYPES = {
} }
}, },
add_press(me, level) { add_press(me, level) {
level._set_prop(me, 'presses', (me.presses ?? 0) + 1); level._set_tile_prop(me, 'presses', (me.presses ?? 0) + 1);
if (me.presses === 1) { if (me.presses === 1) {
// Free everything on us, if we went from 0 to 1 presses (i.e. closed to open) // Free everything on us, if we went from 0 to 1 presses (i.e. closed to open)
for (let tile of Array.from(me.cell)) { for (let tile of Array.from(me.cell)) {
@ -761,7 +761,7 @@ const TILE_TYPES = {
} }
}, },
remove_press(me, level) { remove_press(me, level) {
level._set_prop(me, 'presses', me.presses - 1); level._set_tile_prop(me, 'presses', me.presses - 1);
if (me.presses === 0) { if (me.presses === 0) {
// Trap everything on us, if we went from 1 to 0 presses (i.e. open to closed) // Trap everything on us, if we went from 1 to 0 presses (i.e. open to closed)
for (let tile of me.cell) { for (let tile of me.cell) {
@ -920,7 +920,7 @@ const TILE_TYPES = {
for (let actor of level.actors) { for (let actor of level.actors) {
// TODO generify somehow?? // TODO generify somehow??
if (actor.type.name === 'tank_blue') { if (actor.type.name === 'tank_blue') {
level._set_prop(actor, 'pending_reverse', ! actor.pending_reverse); level._set_tile_prop(actor, 'pending_reverse', ! actor.pending_reverse);
} }
} }
}, },