From e7e02281a2f54469374ed0844d0def6f1f2548e1 Mon Sep 17 00:00:00 2001 From: "Eevee (Evelyn Woods)" Date: Tue, 3 Nov 2020 09:50:37 -0700 Subject: [PATCH] Clean up turn-based code Mostly style nits, but also: - Renamed some stuff in anticipation of removing GameEnded. - Actor decisions are independent, so there's no need to do most of them in the first part of a tic and the player in the second part; they can all happen together in the second part. - waiting_for_input was merged into turn_based, which I think makes it easier to follow what's going on between tics. Although I just realized it introduces a bug, so, better fix that next. - The canvas didn't need to know if we were waiting or not if we just force the tic offset to 1 while waiting. This also fixed some slight jitter with force floors. --- index.html | 2 +- js/game.js | 433 +++++++++++++++++++++--------------------- js/main.js | 102 +++++----- js/renderer-canvas.js | 4 +- 4 files changed, 266 insertions(+), 275 deletions(-) diff --git a/index.html b/index.html index a434932..04e9bd8 100644 --- a/index.html +++ b/index.html @@ -121,7 +121,7 @@ - Turn-Based +
diff --git a/js/game.js b/js/game.js index a8d9f67..83ef51d 100644 --- a/js/game.js +++ b/js/game.js @@ -199,7 +199,7 @@ export class Level { else { this.time_remaining = this.stored_level.time_limit * 20; } - this.timer_paused = false + this.timer_paused = false; // Note that this clock counts *up*, even on untimed levels, and is unaffected by CC2's // clock alteration shenanigans this.tic_counter = 0; @@ -364,9 +364,14 @@ export class Level { } } - - player_awaiting_input() { - return this.player.movement_cooldown === 0 && (this.player.slide_mode === null || (this.player.slide_mode === 'force' && this.player.last_move_was_force)) + can_accept_input() { + // We can accept input anytime the player can move, i.e. when they're not already moving and + // not in an un-overrideable slide. + // Note that this only makes sense in the middle of a tic; at the beginning of one, the + // player's movement cooldown may very well be 1, but it'll be decremented before they + // attempt to move + return this.player.movement_cooldown === 0 && (this.player.slide_mode === null || ( + this.player.slide_mode === 'force' && this.player.last_move_was_force)); } // Lynx PRNG, used unchanged in CC2 @@ -412,25 +417,25 @@ export class Level { return mod; } - // Move the game state forwards by one tic - // split into two parts for turn-based mode: first part is the consequences of the previous tick, second part depends on the player's input + // Move the game state forwards by one tic. + // For turn-based mode, this is split into two parts: advance_tic_finish_movement completes any + // ongoing movement started in the previous tic, and advance_tic_act allows actors to make new + // decisions. The player makes decisions between these two parts. advance_tic(p1_primary_direction, p1_secondary_direction, pass) { if (this.state !== 'playing') { console.warn(`Level.advance_tic() called when state is ${this.state}`); return; } + // TODO rip out this try/catch, it's not how the game actually works try { - if (pass == 1) - { - this._advance_tic_part1(p1_primary_direction, p1_secondary_direction); + if (pass == 1) { + this.advance_tic_finish_movement(p1_primary_direction, p1_secondary_direction); } - else if (pass == 2) - { - this._advance_tic_part2(p1_primary_direction, p1_secondary_direction); + else if (pass == 2) { + this.advance_tic_act(p1_primary_direction, p1_secondary_direction); } - else - { + else { console.warn(`What pass is this?`); } } @@ -449,7 +454,7 @@ export class Level { } } - _advance_tic_part1(p1_primary_direction, p1_secondary_direction) { + advance_tic_finish_movement(p1_primary_direction, p1_secondary_direction) { // 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); @@ -522,23 +527,203 @@ export class Level { if (this.player.movement_cooldown <= 0) { this.player.is_pushing = false; } + } + advance_tic_act(p1_primary_direction, p1_secondary_direction) { // Second pass: actors decide their upcoming movement simultaneously - // (we'll do the player's decision in part 2!) for (let i = this.actors.length - 1; i >= 0; i--) { let actor = this.actors[i]; - if (actor != this.player) + if (! actor.cell) + continue; + + if (actor.movement_cooldown > 0) + continue; + + // Teeth can only move the first 4 of every 8 tics, though "first" + // can be adjusted + if (actor.slide_mode === null && + actor.type.uses_teeth_hesitation && + (this.tic_counter + this.step_parity) % 8 >= 4) { - this.actor_decision(actor, p1_primary_direction); + continue; + } + + let direction_preference; + if (this.compat.sliding_tanks_ignore_button && + actor.slide_mode && actor.pending_reverse) + { + this._set_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); + continue; + } + if (actor.slide_mode === 'ice') { + // Actors can't make voluntary moves on ice; they just slide + actor.decision = actor.direction; + continue; + } + else if (actor.slide_mode === 'force') { + // Only the player can make voluntary moves on a force floor, + // and only if their previous move was an /involuntary/ move on + // a force floor. If they do, it overrides the forced move + // XXX this in particular has some subtleties in lynx (e.g. you + // can override forwards??) and DEFINITELY all kinds of stuff + // in ms + if (actor === this.player && + p1_primary_direction && + actor.last_move_was_force) + { + actor.decision = p1_primary_direction; + this._set_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); + } + } + continue; + } + else if (actor === this.player) { + if (p1_primary_direction) { + actor.decision = p1_primary_direction; + this._set_prop(actor, 'last_move_was_force', false); + } + continue; + } + else if (actor.type.movement_mode === 'forward') { + // blue tank behavior: keep moving forward, reverse if the flag is set + let direction = actor.direction; + if (actor.pending_reverse) { + direction = DIRECTIONS[actor.direction].opposite; + this._set_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 + // prop for both + if (! actor.cell.some(tile => tile.type.name === 'cloner')) { + actor.decision = direction; + } + continue; + } + else if (actor.type.movement_mode === 'follow-left') { + // bug behavior: always try turning as left as possible, and + // fall back to less-left turns when that fails + let d = DIRECTIONS[actor.direction]; + direction_preference = [d.left, actor.direction, d.right, d.opposite]; + } + else if (actor.type.movement_mode === 'follow-right') { + // paramecium behavior: always try turning as right as + // possible, and fall back to less-right turns when that fails + let d = DIRECTIONS[actor.direction]; + direction_preference = [d.right, actor.direction, d.left, d.opposite]; + } + else if (actor.type.movement_mode === 'turn-left') { + // glider behavior: preserve current direction; if that doesn't + // work, turn left, then right, then back the way we came + let d = DIRECTIONS[actor.direction]; + direction_preference = [actor.direction, d.left, d.right, d.opposite]; + } + else if (actor.type.movement_mode === 'turn-right') { + // fireball behavior: preserve current direction; if that doesn't + // work, turn right, then left, then back the way we came + let d = DIRECTIONS[actor.direction]; + direction_preference = [actor.direction, d.right, d.left, d.opposite]; + } + else if (actor.type.movement_mode === 'bounce') { + // bouncy ball behavior: preserve current direction; if that + // doesn't work, bounce back the way we came + let d = DIRECTIONS[actor.direction]; + direction_preference = [actor.direction, d.opposite]; + } + else if (actor.type.movement_mode === 'bounce-random') { + // walker behavior: preserve current direction; if that doesn't work, pick a random + // direction, even the one we failed to move in (but ONLY then) + direction_preference = [actor.direction, 'WALKER']; + } + else if (actor.type.movement_mode === 'pursue') { + // teeth behavior: always move towards the player + let target_cell = this.player.cell; + // CC2 behavior (not Lynx (TODO compat?)): pursue the cell the player is leaving, if + // they're still mostly in it + if (this.player.previous_cell && this.player.animation_speed && + this.player.animation_progress <= this.player.animation_speed / 2) + { + target_cell = this.player.previous_cell; + } + let dx = actor.cell.x - target_cell.x; + let dy = actor.cell.y - target_cell.y; + let preferred_horizontal, preferred_vertical; + if (dx > 0) { + preferred_horizontal = 'west'; + } + else if (dx < 0) { + preferred_horizontal = 'east'; + } + if (dy > 0) { + preferred_vertical = 'north'; + } + else if (dy < 0) { + preferred_vertical = 'south'; + } + // Chooses the furthest direction, vertical wins ties + if (Math.abs(dx) > Math.abs(dy)) { + // Horizontal first + direction_preference = [preferred_horizontal, preferred_vertical].filter(x => x); + } + else { + // Vertical first + direction_preference = [preferred_vertical, preferred_horizontal].filter(x => x); + } + } + else if (actor.type.movement_mode === 'random') { + // blob behavior: move completely at random + let modifier = this.get_blob_modifier(); + direction_preference = [['north', 'east', 'south', 'west'][(this.prng() + modifier) % 4]]; + } + + // Check which of those directions we *can*, probably, move in + // TODO i think player on force floor will still have some issues here + // FIXME probably bail earlier for stuck actors so the prng isn't advanced? what is the + // lynx behavior? also i hear something about blobs on cloners?? + if (direction_preference && ! actor.stuck) { + let fallback_direction; + for (let direction of direction_preference) { + if (direction === 'WALKER') { + // Walkers roll a random direction ONLY if their first attempt was blocked + direction = actor.direction; + let num_turns = this.prng() % 4; + for (let i = 0; i < num_turns; i++) { + direction = DIRECTIONS[direction].right; + } + } + fallback_direction = direction; + + let dest_cell = this.get_neighboring_cell(actor.cell, direction); + if (! dest_cell) + continue; + + if (! actor.cell.blocks_leaving(actor, direction) && + ! dest_cell.blocks_entering(actor, direction, this, true)) + { + // We found a good direction! Stop here + actor.decision = direction; + break; + } + } + + // If all the decisions are blocked, actors still try the last one (and might even + // be able to move that way by the time their turn comes around!) + if (actor.decision === null) { + actor.decision = fallback_direction; + } } } - } - - - _advance_tic_part2(p1_primary_direction, p1_secondary_direction) { - //player now makes a decision based on input - this.actor_decision(this.player, p1_primary_direction); - + // Third pass: everyone actually moves for (let i = this.actors.length - 1; i >= 0; i--) { let actor = this.actors[i]; @@ -626,198 +811,6 @@ export class Level { }); } } - - actor_decision(actor, p1_primary_direction) { - if (! actor.cell) - return; - - if (actor.movement_cooldown > 0) - return; - - // Teeth can only move the first 4 of every 8 tics, though "first" - // can be adjusted - if (actor.slide_mode === null && - actor.type.uses_teeth_hesitation && - (this.tic_counter + this.step_parity) % 8 >= 4) - { - return; - } - - let direction_preference; - if (this.compat.sliding_tanks_ignore_button && - actor.slide_mode && actor.pending_reverse) - { - this._set_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); - return; - } - if (actor.slide_mode === 'ice') { - // Actors can't make voluntary moves on ice; they just slide - actor.decision = actor.direction; - return; - } - else if (actor.slide_mode === 'force') { - // Only the player can make voluntary moves on a force floor, - // and only if their previous move was an /involuntary/ move on - // a force floor. If they do, it overrides the forced move - // XXX this in particular has some subtleties in lynx (e.g. you - // can override forwards??) and DEFINITELY all kinds of stuff - // in ms - if (actor === this.player && - p1_primary_direction && - actor.last_move_was_force) - { - actor.decision = p1_primary_direction; - this._set_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); - } - } - return; - } - else if (actor === this.player) { - if (p1_primary_direction) { - actor.decision = p1_primary_direction; - this._set_prop(actor, 'last_move_was_force', false); - } - return; - } - else if (actor.type.movement_mode === 'forward') { - // blue tank behavior: keep moving forward, reverse if the flag is set - let direction = actor.direction; - if (actor.pending_reverse) { - direction = DIRECTIONS[actor.direction].opposite; - this._set_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 - // prop for both - if (! actor.cell.some(tile => tile.type.name === 'cloner')) { - actor.decision = direction; - } - return; - } - else if (actor.type.movement_mode === 'follow-left') { - // bug behavior: always try turning as left as possible, and - // fall back to less-left turns when that fails - let d = DIRECTIONS[actor.direction]; - direction_preference = [d.left, actor.direction, d.right, d.opposite]; - } - else if (actor.type.movement_mode === 'follow-right') { - // paramecium behavior: always try turning as right as - // possible, and fall back to less-right turns when that fails - let d = DIRECTIONS[actor.direction]; - direction_preference = [d.right, actor.direction, d.left, d.opposite]; - } - else if (actor.type.movement_mode === 'turn-left') { - // glider behavior: preserve current direction; if that doesn't - // work, turn left, then right, then back the way we came - let d = DIRECTIONS[actor.direction]; - direction_preference = [actor.direction, d.left, d.right, d.opposite]; - } - else if (actor.type.movement_mode === 'turn-right') { - // fireball behavior: preserve current direction; if that doesn't - // work, turn right, then left, then back the way we came - let d = DIRECTIONS[actor.direction]; - direction_preference = [actor.direction, d.right, d.left, d.opposite]; - } - else if (actor.type.movement_mode === 'bounce') { - // bouncy ball behavior: preserve current direction; if that - // doesn't work, bounce back the way we came - let d = DIRECTIONS[actor.direction]; - direction_preference = [actor.direction, d.opposite]; - } - else if (actor.type.movement_mode === 'bounce-random') { - // walker behavior: preserve current direction; if that doesn't work, pick a random - // direction, even the one we failed to move in (but ONLY then) - direction_preference = [actor.direction, 'WALKER']; - } - else if (actor.type.movement_mode === 'pursue') { - // teeth behavior: always move towards the player - let target_cell = this.player.cell; - // CC2 behavior (not Lynx (TODO compat?)): pursue the cell the player is leaving, if - // they're still mostly in it - if (this.player.previous_cell && this.player.animation_speed && - this.player.animation_progress <= this.player.animation_speed / 2) - { - target_cell = this.player.previous_cell; - } - let dx = actor.cell.x - target_cell.x; - let dy = actor.cell.y - target_cell.y; - let preferred_horizontal, preferred_vertical; - if (dx > 0) { - preferred_horizontal = 'west'; - } - else if (dx < 0) { - preferred_horizontal = 'east'; - } - if (dy > 0) { - preferred_vertical = 'north'; - } - else if (dy < 0) { - preferred_vertical = 'south'; - } - // Chooses the furthest direction, vertical wins ties - if (Math.abs(dx) > Math.abs(dy)) { - // Horizontal first - direction_preference = [preferred_horizontal, preferred_vertical].filter(x => x); - } - else { - // Vertical first - direction_preference = [preferred_vertical, preferred_horizontal].filter(x => x); - } - } - else if (actor.type.movement_mode === 'random') { - // blob behavior: move completely at random - let modifier = this.get_blob_modifier(); - direction_preference = [['north', 'east', 'south', 'west'][(this.prng() + modifier) % 4]]; - } - - // Check which of those directions we *can*, probably, move in - // TODO i think player on force floor will still have some issues here - // FIXME probably bail earlier for stuck actors so the prng isn't advanced? what is the - // lynx behavior? also i hear something about blobs on cloners?? - if (direction_preference && ! actor.stuck) { - let fallback_direction; - for (let direction of direction_preference) { - if (direction === 'WALKER') { - // Walkers roll a random direction ONLY if their first attempt was blocked - direction = actor.direction; - let num_turns = this.prng() % 4; - for (let i = 0; i < num_turns; i++) { - direction = DIRECTIONS[direction].right; - } - } - fallback_direction = direction; - - let dest_cell = this.get_neighboring_cell(actor.cell, direction); - if (! dest_cell) - continue; - - if (! actor.cell.blocks_leaving(actor, direction) && - ! dest_cell.blocks_entering(actor, direction, this, true)) - { - // We found a good direction! Stop here - actor.decision = direction; - break; - } - } - - // If all the decisions are blocked, actors still try the last one (and might even - // be able to move that way by the time their turn comes around!) - if (actor.decision === null) { - actor.decision = fallback_direction; - } - } - } // Try to move the given actor one tile in the given direction and update // their cooldown. Return true if successful. @@ -1339,14 +1332,14 @@ export class Level { } undo() { - //reverse the pending_undo too + 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.aid = Math.max(1, this.aid); let entry = this.undo_stack.pop(); // Undo in reverse order! There's no redo, so it's okay to destroy this diff --git a/js/main.js b/js/main.js index 2e9a560..986a892 100644 --- a/js/main.js +++ b/js/main.js @@ -307,10 +307,24 @@ class Player extends PrimaryView { } }); - this.turn_based = false; - this.turn_based_checkbox = this.root.querySelector('.controls .turn-based'); + // 0: normal realtime mode + // 1: turn-based mode, and the next tic starts at the beginning + // 2: turn-based mode, and we're in mid-tic waiting for input + this.turn_based = 0; + this.turn_based_checkbox = this.root.querySelector('.controls .control-turn-based'); + this.turn_based_checkbox.checked = false; this.turn_based_checkbox.addEventListener('change', ev => { - this.turn_based = !this.turn_based; + if (this.turn_based_checkbox.checked) { + // If we're leaving real-time mode then we're between tics + this.turn_based = 1; + } + else { + if (this.turn_based === 2) { + // Finish up the tic + this.advance_by(1); + } + this.turn_based = 0; + } }); // Bind buttons @@ -329,8 +343,9 @@ class Player extends PrimaryView { this.undo_button = this.root.querySelector('.controls .control-undo'); this.undo_button.addEventListener('click', ev => { let player_cell = this.level.player.cell; - // Keep undoing until (a) we're on another cell and (b) we're not - // sliding, i.e. we're about to make a conscious move + // Keep undoing until (a) we're on another cell and (b) we're not sliding, i.e. we're + // about to make a conscious move. Note that this means undoing all the way through + // force floors, even if you could override them! let moved = false; while (this.level.undo_stack.length > 0 && ! (moved && this.level.player.slide_mode === null)) @@ -417,7 +432,7 @@ class Player extends PrimaryView { this.previous_action = null; // last direction we were moving, if any this.using_touch = false; // true if using touch controls this.current_keys = new Set; // keys that are currently held - this.current_keys_new = new Set; //for keys that have only been held a frame + this.current_keys_new = new Set; // keys that were pressed since input was last read // TODO this could all probably be more rigorous but it's fine for now key_target.addEventListener('keydown', ev => { if (ev.key === 'p' || ev.key === 'Pause') { @@ -659,7 +674,7 @@ class Player extends PrimaryView { _clear_state() { this.set_state('waiting'); - this.waiting_for_input = false; + this.turn_based = this.turn_based_checkbox.checked ? 1 : 0; this.tic_offset = 0; this.last_advance = 0; this.demo_faucet = null; @@ -709,7 +724,7 @@ class Player extends PrimaryView { for (let key of this.current_keys_new) { input.add(this.key_mapping[key]); } - this.current_keys_new = new Set; + this.current_keys_new.clear(); for (let action of Object.values(this.current_touches)) { input.add(action); } @@ -717,8 +732,6 @@ class Player extends PrimaryView { } } - waiting_for_input = false; - advance_by(tics) { for (let i = 0; i < tics; i++) { let input = this.get_input(); @@ -785,37 +798,26 @@ class Player extends PrimaryView { var primary_dir = this.primary_action ? ACTION_DIRECTIONS[this.primary_action] : null; var secondary_dir = this.secondary_action ? ACTION_DIRECTIONS[this.secondary_action] : null; - //turn based logic - //first, handle a part 2 we just got input for - if (this.waiting_for_input) - { - if (!this.turn_based || primary_dir != null || input.has('wait')) - { - this.waiting_for_input = false; - this.level.advance_tic( - primary_dir, - secondary_dir, - 2); - + let has_input = primary_dir !== null || input.has('wait'); + // Turn-based mode complicates this slightly + // TODO advance_by(1) no longer advances by 1 tic necessarily... + if (this.turn_based === 2) { + if (has_input) { + this.level.advance_tic(primary_dir, secondary_dir, 2); + // TODO what if we just do the next tic part now? but then we can never realign to a tic boundary. + this.turn_based = 1; } } - else - { - this.level.advance_tic( - primary_dir, - secondary_dir, - 1); - //then if we should wait for input, the player needs input and we don't have input, we set waiting_for_input, else we run part 2 - if (this.turn_based && this.level.player_awaiting_input() && !(primary_dir != null || input.has('wait'))) - { - this.waiting_for_input = true; + else { + // Start from a tic boundary + this.level.advance_tic(primary_dir, secondary_dir, 1); + if (this.turn_based > 0 && this.level.can_accept_input() && ! has_input) { + // If we're in turn-based mode and could provide input here, but don't have any, + // then wait until we do + this.turn_based = 2; } - else - { - this.level.advance_tic( - primary_dir, - secondary_dir, - 2); + else { + this.level.advance_tic(primary_dir, secondary_dir, 2); } } @@ -855,12 +857,8 @@ class Player extends PrimaryView { this.update_ui(); } } - - if (this.waiting_for_input) - { - //freeze tic_offset in time so we don't try to interpolate to the next frame too soon - this.tic_offset = 0; - } + + // XXX tic_offset = 0 was here, what does that change let dt = 1000 / TICS_PER_SECOND; if (this.state === 'rewinding') { @@ -871,9 +869,11 @@ class Player extends PrimaryView { } undo() { - //if we were waiting for input and undo, well, now we're not - this.waiting_for_input = false; this.level.undo(); + // Undo always returns to the start of a tic + if (this.turn_based === 2) { + this.turn_based = 1; + } } // Redraws every frame, unless the game isn't running @@ -883,11 +883,12 @@ class Player extends PrimaryView { // TODO this is not gonna be right while pausing lol // TODO i'm not sure it'll be right when rewinding either // TODO or if the game's speed changes. wow! - if (this.waiting_for_input) { - //freeze tic_offset in time + if (this.turn_based === 2) { + // We're frozen in mid-tic, so the clock hasn't advanced yet, but everything has already + // finished moving; pretend we're already on the next tic + this.tic_offset = 1; } - else - { + else { this.tic_offset = Math.min(0.9999, (performance.now() - this.last_advance) / 1000 / (1 / TICS_PER_SECOND)); if (this.state === 'rewinding') { this.tic_offset = 1 - this.tic_offset; @@ -909,7 +910,6 @@ class Player extends PrimaryView { // Actually redraw. Used to force drawing outside of normal play _redraw() { - this.renderer.waiting_for_input = this.waiting_for_input; this.renderer.draw(this.tic_offset); } diff --git a/js/renderer-canvas.js b/js/renderer-canvas.js index a854a65..8f558d0 100644 --- a/js/renderer-canvas.js +++ b/js/renderer-canvas.js @@ -61,15 +61,13 @@ export class CanvasRenderer { dx * tw, dy * th, w * tw, h * th); } - waiting_for_input = false; - draw(tic_offset = 0) { if (! this.level) { console.warn("CanvasRenderer.draw: No level to render"); return; } - let tic = (this.level.tic_counter ?? 0) + tic_offset + (this.waiting_for_input ? 1 : 0); + let tic = (this.level.tic_counter ?? 0) + tic_offset; let tw = this.tileset.size_x; let th = this.tileset.size_y; this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);