Run Steam mode at 60 FPS; fix turn-based mode, again (fixes #17, fixes #54)

This commit is contained in:
Eevee (Evelyn Woods) 2021-03-06 22:20:46 -07:00
parent ed7c7461b6
commit 4a5f0e36c6
2 changed files with 153 additions and 156 deletions

View File

@ -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

View File

@ -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');
}