From 4a5f0e36c67f9f6d8cad71116a34fdf0e86b03d1 Mon Sep 17 00:00:00 2001 From: "Eevee (Evelyn Woods)" Date: Sat, 6 Mar 2021 22:20:46 -0700 Subject: [PATCH] Run Steam mode at 60 FPS; fix turn-based mode, again (fixes #17, fixes #54) --- js/game.js | 193 ++++++++++++++++++++++++++++------------------------- js/main.js | 116 ++++++++++++++------------------ 2 files changed, 153 insertions(+), 156 deletions(-) diff --git a/js/game.js b/js/game.js index 89a8c8f..51ce50d 100644 --- a/js/game.js +++ b/js/game.js @@ -481,6 +481,8 @@ export class Level extends LevelInterface { // Note that this clock counts *up*, even on untimed levels, and is unaffected by CC2's // clock alteration shenanigans this.tic_counter = 0; + // 0 to 2, counting which frame within a tic we're on in CC2 + this.frame_offset = 0; // 0 to 7, indicating the first tic that teeth can move on. // 0 is equivalent to even step; 4 is equivalent to odd step. // 5 is the default in CC2. Lynx can use any of the 8. MSCC uses @@ -818,12 +820,10 @@ export class Level extends LevelInterface { 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)); + // not in an un-overrideable slide + 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 @@ -874,81 +874,33 @@ export class Level extends LevelInterface { // Input is a bit mask of INPUT_BITS. advance_tic(p1_input) { if (this.state !== 'playing') { - console.warn(`Level.advance_tic() called when state is ${this.state}`); + console.warn(`Attempting to advance game when state is ${this.state}`); return; } - this.begin_tic(p1_input); - this.finish_tic(p1_input); - } - - // FIXME a whole bunch of these comments are gonna be wrong or confusing now - begin_tic(p1_input) { - // At the beginning of the very first tic, some tiles want to do initialization that's not - // appropriate to do before the game begins. (For example, bombs blow up anything that - // starts on them in CC2, but we don't want to do that before the game has run at all. We - // DEFINITELY don't want to blow the PLAYER up before the game starts!) - if (! this.done_on_begin) { - // Run backwards, to match actor order - for (let i = this.linear_cells.length - 1; i >= 0; i--) { - let cell = this.linear_cells[i]; - for (let tile of cell) { - if (tile && tile.type.on_begin) { - tile.type.on_begin(tile, this); - } - } - } - // It's not possible to rewind to before this happened, so clear undo and permanently - // set a flag - this.pending_undo = this.create_undo_entry(); - this.done_on_begin = true; - } - - if (this.undo_enabled) { - // 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', '_tw_rng', 'force_floor_direction', - 'tic_counter', 'time_remaining', 'timer_paused', - 'chips_remaining', 'bonus_points', 'state', - 'player1_move', 'player2_move', 'remaining_players', 'player', - ]) { - this.pending_undo.level_props[key] = this[key]; - } - } - this.p1_input = p1_input; - this.p1_released |= ~p1_input; // Action keys released since we last checked them - this.swap_player1 = false; - - this.sfx.set_player_position(this.player.cell); + this._do_init_phase(); + this._set_p1_input(p1_input); if (this.compat.use_lynx_loop) { if (this.compat.emulate_60fps) { - this._begin_tic_lynx60(); + this._advance_tic_lynx60(); } else { - this._begin_tic_lynx(); + this._advance_tic_lynx(); } } else { - this._begin_tic_lexy(); + this._advance_tic_lexy(); } } - // FIXME merge this a bit more with the lynx loop, which should be more in finish_tic anyway - // FIXME fix turn-based mode - // FIXME you are now not synched with something coming out of a trap or cloner, but i don't know - // how to fix that with this loop - // Finish a tic, i.e., apply input just before the player can make a decision and then do it - finish_tic(p1_input) { - this.p1_input = p1_input; - this.p1_released |= ~p1_input; // Action keys released since we last checked them - - if (this.compat.use_lynx_loop) { - if (this.compat.emulate_60fps) { - this._finish_tic_lynx60(); - } - return; + // Default loop: run at 20 tics per second, split things into some more loops + _advance_tic_lexy() { + // Under CC2 rules, there are two wire updates at the very beginning of the game before the + // player can actually move. That means the first tic has five wire phases total. + if (this.tic_counter === 0) { + this._do_wire_phase(); + this._do_wire_phase(); } this._do_decision_phase(); @@ -996,6 +948,7 @@ export class Level extends LevelInterface { this._swap_players(); + // Wire updates every frame, which means thrice per tic this._do_wire_phase(); this._do_wire_phase(); this._do_wire_phase(); @@ -1003,50 +956,110 @@ export class Level extends LevelInterface { this._do_cleanup_phase(); } - // Lexy-style loop, similar to Lynx but with some things split out into separate phases - _begin_tic_lexy() { - // CC2 wiring runs every frame, not every tic, so we need to do it three times, but dealing - // with it is delicate. Ideally the player would see the current state of the game when - // they move, so all the wire updates should be at the end, BUT under CC2 rules, there are - // two wire updates at the start of the game before any movement can actually happen. - // Do those here, then otherwise do three wire phases when finishing. - if (this.tic_counter === 0) { - this._do_wire_phase(); - this._do_wire_phase(); - } - } - - // Lynx-style loop: everyone decides, then everyone moves/cools. - _begin_tic_lynx() { - // FIXME this should have three wire passes too, chief + // Lynx loop: everyone decides, then everyone moves/cools in a single pass + _advance_tic_lynx() { this._do_decision_phase(); this._do_combined_action_phase(3); this._do_wire_phase(); + this._do_wire_phase(); + this._do_wire_phase(); this._do_cleanup_phase(); } - // Same as above, but split up to run at 60fps, where only every third frame allows for - // decisions. This is how CC2 works. - _begin_tic_lynx60() { + // CC2 loop: similar to the Lynx loop, but run three times per tic, and non-forced decisions can + // only be made every third frame + _advance_tic_lynx60() { this._do_decision_phase(true); this._do_combined_action_phase(1, true); this._do_wire_phase(); + this.frame_offset = 1; this._do_decision_phase(true); this._do_combined_action_phase(1, true); this._do_wire_phase(); - } - // This is in the "finish" part to preserve the property turn-based mode expects, where "finish" - // picks up right when the player could provide input - _finish_tic_lynx60() { + + this.frame_offset = 2; this._do_decision_phase(); this._do_combined_action_phase(1); this._do_wire_phase(); + this.frame_offset = 0; this._do_cleanup_phase(); } + // Attempt to advance by one FRAME at a time. Primarily useful for running 60 FPS mode at, + // well, 60 FPS. + advance_frame(p1_input) { + if (this.compat.use_lynx_loop && this.compat.emulate_60fps) { + // Lynx 60, i.e. CC2 + if (this.frame_offset === 0) { + this._do_init_phase(p1_input); + } + this._set_p1_input(p1_input); + let is_decision_frame = this.frame_offset === 2; + + this._do_decision_phase(! is_decision_frame); + this._do_combined_action_phase(1, ! is_decision_frame); + this._do_wire_phase(); + + if (this.frame_offset === 2) { + this._do_cleanup_phase(); + } + } + else { + // This is either Lexy mode or Lynx mode, and either way we run at 20 tps + if (this.frame_offset === 0) { + this.advance_tic(p1_input); + } + } + + this.frame_offset = (this.frame_offset + 1) % 3; + } + + _set_p1_input(p1_input) { + this.p1_input = p1_input; + this.p1_released |= ~p1_input; // Action keys released since we last checked them + } + + _do_init_phase() { + // At the beginning of the very first tic, some tiles want to do initialization that's not + // appropriate to do before the game begins. (For example, bombs blow up anything that + // starts on them in CC2, but we don't want to do that before the game has run at all. We + // DEFINITELY don't want to blow the PLAYER up before the game starts!) + if (! this.done_on_begin) { + // Run backwards, to match actor order + for (let i = this.linear_cells.length - 1; i >= 0; i--) { + let cell = this.linear_cells[i]; + for (let tile of cell) { + if (tile && tile.type.on_begin) { + tile.type.on_begin(tile, this); + } + } + } + // It's not possible to rewind to before this happened, so clear undo and permanently + // set a flag + this.pending_undo = this.create_undo_entry(); + this.done_on_begin = true; + } + + if (this.undo_enabled) { + // 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', '_tw_rng', 'force_floor_direction', + 'tic_counter', 'frame_offset', 'time_remaining', 'timer_paused', + 'chips_remaining', 'bonus_points', 'state', + 'player1_move', 'player2_move', 'remaining_players', 'player', + ]) { + this.pending_undo.level_props[key] = this[key]; + } + } + this.swap_player1 = false; + + this.sfx.set_player_position(this.player.cell); + } + // Decision phase: all actors decide on their movement "simultaneously" _do_decision_phase(forced_only = false) { // Before decisions happen, remember the player's /current/ direction, which may be affected diff --git a/js/main.js b/js/main.js index cabb903..dbe7c2c 100644 --- a/js/main.js +++ b/js/main.js @@ -444,25 +444,12 @@ class Player extends PrimaryView { this.music_audio_el = this.music_el.querySelector('audio'); this.music_index = null; - // 0: normal realtime mode - // 1: turn-based mode, at the start of a tic - // 2: turn-based mode, in mid-tic, with the game frozen waiting for input - this.turn_mode = 0; + this.turn_based_mode = false; + this.turn_based_mode_waiting = false; this.turn_based_checkbox = this.root.querySelector('.control-turn-based'); this.turn_based_checkbox.checked = false; this.turn_based_checkbox.addEventListener('change', ev => { - if (this.turn_based_checkbox.checked) { - // If we're leaving real-time mode then we're between tics - this.turn_mode = 1; - } - else { - if (this.turn_mode === 2) { - // Finish up the tic with dummy input - this.level.finish_tic(0); - this.advance_by(1); - } - this.turn_mode = 0; - } + this.turn_based_mode = this.turn_based_checkbox.checked; }); // Bind buttons @@ -592,7 +579,7 @@ class Player extends PrimaryView { // Per-tic navigation; only useful if the game isn't running if (ev.key === ',') { - if (this.state === 'stopped' || this.state === 'paused' || this.turn_mode > 0) { + if (this.state === 'stopped' || this.state === 'paused' || this.turn_based_mode) { this.set_state('paused'); this.undo(); this.update_ui(); @@ -601,11 +588,16 @@ class Player extends PrimaryView { return; } if (ev.key === '.') { - if (this.state === 'waiting' || this.state === 'paused' || this.turn_mode > 0) { - if (this.state === 'waiting' || this.turn_mode === 1) { - this.set_state('paused'); + if (this.state === 'waiting' || this.state === 'paused' || this.turn_based_mode) { + if (this.state === 'waiting') { + if (this.turn_based_mode) { + this.set_state('playing'); + } + else { + this.set_state('paused'); + } } - this.advance_by(1, true); + this.advance_by(1, true, ev.altKey && this.level.compat.emulate_60fps); this._redraw(); } return; @@ -1301,7 +1293,7 @@ class Player extends PrimaryView { _clear_state() { this.set_state('waiting'); - this.turn_mode = this.turn_based_checkbox.checked ? 1 : 0; + this.turn_based_mode_waiting = false; this.last_advance = 0; this.current_keyring = {}; this.current_toolbelt = []; @@ -1382,7 +1374,8 @@ class Player extends PrimaryView { return input; } - advance_by(tics, force = false) { + advance_by(tics, force = false, use_frames = false) { + let crossed_tic_boundary = false; for (let i = 0; i < tics; i++) { // FIXME turn-based mode should be disabled during a replay let input = this.get_input(); @@ -1394,38 +1387,32 @@ class Player extends PrimaryView { this.debug.replay.set(this.level.tic_counter, input); } - // Turn-based mode is considered assistance, but only if the game actually attempts to - // progress while it's enabled - if (this.turn_mode > 0) { + if (this.turn_based_mode) { + // Turn-based mode is considered assistance, but only if the game actually attempts + // to progress while it's enabled this.level.aid = Math.max(1, this.level.aid); - } - let has_input = wait || input; - // Turn-based mode complicates this slightly; it aligns us to the middle of a tic - if (this.turn_mode === 2) { - if (has_input || force) { - // Finish the current tic, then continue as usual. This means the end of the - // tic doesn't count against the number of tics to advance -- because it already - // did, the first time we tried it - this.level.finish_tic(input); - this.turn_mode = 1; - } - else { + // If we're in turn-based mode and could provide input here, but don't have any, + // then wait until we do + if (this.level.can_accept_input() && ! input && ! wait && ! force) { + this.turn_based_mode_waiting = true; continue; } } - // We should now be at the start of a tic - this.level.begin_tic(input); - if (this.turn_mode > 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_mode = 2; + this.turn_based_mode_waiting = false; + if (use_frames) { + this.level.advance_frame(input); + if (this.level.frame_offset === 0) { + crossed_tic_boundary = true; + } } else { - this.level.finish_tic(input); + this.level.advance_tic(input); + crossed_tic_boundary = true; } + // FIXME don't do this til we would next advance? or some other way let it play out if (this.level.state !== 'playing') { // We either won or lost! this.set_state('stopped'); @@ -1455,6 +1442,10 @@ class Player extends PrimaryView { // tracking fractional updates, but asking to run at 10× and only getting 2× would suck) let num_advances = 1; let dt = 1000 / (TICS_PER_SECOND * this.play_speed); + let use_frames = this.level.compat.emulate_60fps && this.state === 'playing'; + if (use_frames) { + dt /= 3; + } if (dt < 10) { num_advances = Math.ceil(10 / dt); dt = 10; @@ -1469,7 +1460,7 @@ class Player extends PrimaryView { this._advance_handle = window.setTimeout(this._advance_bound, dt); if (this.state === 'playing') { - this.advance_by(num_advances); + this.advance_by(num_advances, false, use_frames); } else if (this.state === 'rewinding') { if (this.level.has_undo()) { @@ -1488,10 +1479,6 @@ class Player extends PrimaryView { undo() { this.level.undo(); - // Undo always returns to the start of a tic - if (this.turn_mode === 2) { - this.turn_mode = 1; - } } // Redraws every frame, unless the game isn't running @@ -1500,29 +1487,25 @@ class Player extends PrimaryView { // TODO i'm not sure it'll be right when rewinding either // TODO or if the game's speed changes. wow! let tic_offset; - if (this.turn_mode === 2) { + if (this.turn_based_mode_waiting || this.state === 'stopped' || ! this.use_interpolation) { // We're dawdling between tics, so nothing is actually animating, but the clock hasn't // advanced yet; pretend whatever's currently animating has finished // FIXME this creates bizarre side effects like actors making a huge first step when // stepping forwards one tic at a time, but without it you get force floors animating // and then abruptly reversing in turn-based mode (maybe we should just not interpolate // at all in that case??) - tic_offset = 0.999; - } - else if (this.state === 'stopped') { // Once the game is over, interpolating backwards makes less sense // FIXME this /appears/ to skip a whole tic of movement though. hm. - tic_offset = 0.999; + tic_offset = this.level.compat.emulate_60fps ? 0.333 : 0.999; } - else if (this.use_interpolation) { + else { + // Note that, conveniently, when running at 60 FPS this ranges from 0 to 1/3, so nothing + // actually needs to change tic_offset = Math.min(0.9999, (performance.now() - this.last_advance) / 1000 * TICS_PER_SECOND * this.play_speed); if (this.state === 'rewinding') { tic_offset = 1 - tic_offset; } } - else { - tic_offset = 0.999; - } this._redraw(tic_offset); @@ -1661,12 +1644,14 @@ class Player extends PrimaryView { if (this.debug.enabled) { let t = this.level.tic_counter; - if (this.turn_mode === 2) { - this.debug.time_tics_el.textContent = `${t}½`; + let current_tic = String(t); + if (this.level.frame_offset === 1) { + current_tic += "⅓"; } - else { - this.debug.time_tics_el.textContent = `${t}`; + else if (this.level.frame_offset === 2) { + current_tic += "⅔"; } + this.debug.time_tics_el.textContent = current_tic; this.debug.time_moves_el.textContent = `${Math.floor(t/4)}`; this.debug.time_secs_el.textContent = (t / 20).toFixed(2); @@ -1687,10 +1672,9 @@ class Player extends PrimaryView { } autopause() { - if (this.turn_mode > 0) { - // Turn-based mode doesn't need this + // Turn-based mode doesn't need this + if (this.turn_based_mode) return; - } this.set_state('paused'); }