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';
export class Tile {
@ -165,6 +165,11 @@ Cell.prototype.powered_edges = 0;
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 {
constructor(stored_level, compat = {}) {
this.stored_level = stored_level;
@ -225,7 +230,7 @@ export class Level {
}
this.undo_stack = [];
this.pending_undo = [];
this.pending_undo = this.create_undo_entry();
let n = 0;
let connectables = [];
@ -376,15 +381,6 @@ export class Level {
// 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);
@ -396,7 +392,6 @@ export class Level {
// 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)
@ -455,9 +450,19 @@ export class Level {
}
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
// 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
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,
// because stepping on cells in the next block might reset it
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) {
// 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.type.ttl) {
// This is purely an animation so it disappears once it's played
this.remove_tile(actor);
continue;
}
this._set_prop(actor, 'previous_cell', null);
this._set_prop(actor, 'animation_progress', null);
this._set_prop(actor, 'animation_speed', null);
this._set_tile_prop(actor, 'previous_cell', null);
this._set_tile_prop(actor, 'animation_progress', null);
this._set_tile_prop(actor, 'animation_speed', null);
if (! this.compat.tiles_react_instantly) {
// 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
@ -552,13 +557,13 @@ export class Level {
if (this.compat.sliding_tanks_ignore_button &&
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
// stop sliding, regardless of what they landed on
if (actor.pending_push) {
actor.decision = actor.pending_push;
this._set_prop(actor, 'pending_push', null);
this._set_tile_prop(actor, 'pending_push', null);
continue;
}
if (actor.slide_mode === 'ice') {
@ -578,12 +583,12 @@ export class Level {
actor.last_move_was_force)
{
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 {
actor.decision = actor.direction;
if (actor === this.player) {
this._set_prop(actor, 'last_move_was_force', true);
this._set_tile_prop(actor, 'last_move_was_force', true);
}
}
continue;
@ -591,7 +596,7 @@ export class Level {
else if (actor === this.player) {
if (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;
}
@ -600,7 +605,7 @@ export class Level {
let direction = actor.direction;
if (actor.pending_reverse) {
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
// 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;
this.tic_counter += 1;
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;
if (this.time_remaining <= 0) {
this.fail('time');
@ -805,11 +804,6 @@ export class Level {
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
@ -891,7 +885,7 @@ export class Level {
{
// If the push failed and the obstacle is in the middle of a slide,
// 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) {
actor.is_pushing = true;
@ -937,7 +931,7 @@ export class Level {
this.move_to(actor, goal_cell, speed);
// Set movement cooldown since we just moved
this._set_prop(actor, 'movement_cooldown', speed);
this._set_tile_prop(actor, 'movement_cooldown', speed);
return true;
}
@ -948,9 +942,9 @@ export class Level {
if (actor.cell === goal_cell)
return;
this._set_prop(actor, 'previous_cell', actor.cell);
this._set_prop(actor, 'animation_speed', speed);
this._set_prop(actor, 'animation_progress', 0);
this._set_tile_prop(actor, 'previous_cell', actor.cell);
this._set_tile_prop(actor, 'animation_speed', speed);
this._set_tile_prop(actor, 'animation_progress', 0);
let original_cell = actor.cell;
this.remove_tile(actor);
@ -972,7 +966,7 @@ export class Level {
// Check for a couple effects that always apply immediately
// TODO do blocks smash monsters?
if (actor === this.player) {
this._set_prop(this, 'hint_shown', null);
this.hint_shown = null;
}
for (let tile of goal_cell) {
if (actor.type.is_player && tile.type.is_monster) {
@ -990,7 +984,7 @@ export class Level {
}
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
create_undo_entry() {
let entry = [];
entry.tile_changes = new Map;
entry.level_props = {};
return entry;
}
commit() {
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 > 200) {
this.undo_stack.splice(0, this.undo_stack.length - 200);
if (this.undo_stack.length > UNDO_STACK_SIZE) {
this.undo_stack.splice(0, this.undo_stack.length - UNDO_STACK_SIZE);
}
}
@ -1335,18 +1335,27 @@ export class Level {
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
this.pending_undo.reverse();
for (let undo of this.pending_undo) {
undo();
}
this.pending_undo = [];
this._undo_entry(this.pending_undo);
this.pending_undo = this.create_undo_entry();
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
entry.reverse();
for (let undo of entry) {
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
// for undo/rewind purposes
_set_prop(obj, key, val) {
let old_val = obj[key];
if (val === old_val)
_set_tile_prop(tile, key, val) {
if (tile[key] === val)
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() {
let current = this.chips_remaining;
if (current > 0) {
if (this.chips_remaining > 0) {
this.sfx.play_once('get-chip');
this.pending_undo.push(() => this.chips_remaining = current);
this.chips_remaining--;
}
}
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;
}
@ -1381,14 +1401,10 @@ export class Level {
if (this.time_remaining === null)
return;
this.pending_undo.push(() => this.timer_paused = ! this.timer_paused);
this.timer_paused = ! this.timer_paused;
}
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
this.time_remaining = Math.max(0, (this.time_remaining ?? 0) + dt * 20);
if (this.time_remaining <= 0) {
@ -1410,7 +1426,6 @@ export class Level {
}
this.pending_undo.push(() => {
this.state = 'playing';
this.fail_reason = null;
this.player.fail_reason = null;
});
@ -1422,7 +1437,6 @@ export class Level {
win() {
this.sfx.play_once('win');
this.pending_undo.push(() => this.state = 'playing');
this.state = 'success';
throw new GameEnded;
}
@ -1472,8 +1486,8 @@ export class Level {
spawn_animation(cell, name) {
let type = TILE_TYPES[name];
let tile = new Tile(type);
this._set_prop(tile, 'animation_speed', tile.type.ttl);
this._set_prop(tile, 'animation_progress', 0);
this._set_tile_prop(tile, 'animation_speed', tile.type.ttl);
this._set_tile_prop(tile, 'animation_progress', 0);
cell._add(tile);
this.actors.push(tile);
this.pending_undo.push(() => {
@ -1492,8 +1506,8 @@ export class Level {
if (! TILE_TYPES[current].is_actor) {
console.warn("Transmuting a non-actor into an animation!");
}
this._set_prop(tile, 'animation_speed', tile.type.ttl);
this._set_prop(tile, 'animation_progress', 0);
this._set_tile_prop(tile, 'animation_speed', tile.type.ttl);
this._set_tile_prop(tile, 'animation_progress', 0);
}
}
@ -1566,23 +1580,15 @@ export class Level {
// Mark an actor as sliding
make_slide(actor, mode) {
let current = actor.slide_mode;
this.pending_undo.push(() => actor.slide_mode = current);
actor.slide_mode = mode;
this._set_tile_prop(actor, 'slide_mode', mode);
}
// Change an actor's direction
set_actor_direction(actor, direction) {
let current = actor.direction;
this.pending_undo.push(() => actor.direction = current);
actor.direction = direction;
this._set_tile_prop(actor, 'direction', direction);
}
set_actor_stuck(actor, is_stuck) {
let current = actor.stuck;
if (current === is_stuck)
return;
this.pending_undo.push(() => actor.stuck = current);
actor.stuck = is_stuck;
this._set_tile_prop(actor, 'stuck', is_stuck);
}
}

View File

@ -747,7 +747,7 @@ const TILE_TYPES = {
}
},
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) {
// 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)) {
@ -761,7 +761,7 @@ const TILE_TYPES = {
}
},
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) {
// Trap everything on us, if we went from 1 to 0 presses (i.e. open to closed)
for (let tile of me.cell) {
@ -920,7 +920,7 @@ const TILE_TYPES = {
for (let actor of level.actors) {
// TODO generify somehow??
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);
}
}
},