Fix some subtle issues caused by 60 FPS updating

This commit is contained in:
Eevee (Evelyn Woods) 2021-03-08 18:53:11 -07:00
parent a36862e65b
commit cf2f399371
4 changed files with 104 additions and 79 deletions

View File

@ -32,21 +32,20 @@ export class Tile {
return Object.assign(tile, tile_template);
}
movement_progress(tic_offset, interpolate_backwards_by = 3) {
// FIXME this will need altering if 60fps actually updates at 60fps
return ((this.movement_speed - this.movement_cooldown - interpolate_backwards_by) + tic_offset * 3) / this.movement_speed;
movement_progress(update_progress, update_rate) {
return (this.movement_speed - this.movement_cooldown + update_rate * (update_progress - 1)) / this.movement_speed;
}
// Gives the effective position of an actor in motion, given smooth scrolling
visual_position(tic_offset = 0, interpolate_backwards_by = 0) {
visual_position(update_progress = 0, update_rate = 0) {
if (! this.previous_cell || this.movement_speed === null) {
return [this.cell.x, this.cell.y];
}
let cell = this.destination_cell ?? this.cell;
// For a movement speed of N, the cooldown is set to N during the tic an actor starts
// moving, and we interpolate it from there to N - 1 over the course of the duration
let p = this.movement_progress(tic_offset, interpolate_backwards_by);
// For a movement speed of N, the cooldown is set to N - R at the end of the frame/tic an
// actor starts moving, and we interpolate it from N to that
let p = this.movement_progress(update_progress, update_rate);
return [
(1 - p) * this.previous_cell.x + p * cell.x,
(1 - p) * this.previous_cell.y + p * cell.y,
@ -445,6 +444,15 @@ export class Level extends LevelInterface {
this.restart(compat);
}
get update_rate() {
if (this.compat.use_lynx_loop && this.compat.emulate_60fps) {
return 1;
}
else {
return 3;
}
}
restart(compat) {
this.compat = compat;
@ -885,6 +893,14 @@ export class Level extends LevelInterface {
return;
}
// If someone is mixing tics and frames, run in frames until the end of the tic
if (this.frame_offset > 0) {
for (let i = this.frame_offset; i < 3; i++) {
this.advance_frame(p1_input);
}
return;
}
this._do_init_phase();
this._set_p1_input(p1_input);

View File

@ -597,7 +597,20 @@ class Player extends PrimaryView {
this.set_state('paused');
}
}
this.advance_by(1, true, ev.altKey && this.level.compat.emulate_60fps);
if (this.level.update_rate === 1) {
if (ev.altKey) {
// Advance one frame
this.advance_by(1, true, true);
}
else {
// Advance until the next decision frame, when frame_offset === 2
this.advance_by((5 - this.level.frame_offset) % 3 || 3, true, true);
}
}
else {
// Advance one tic
this.advance_by(1, true);
}
this._redraw();
}
return;
@ -1317,6 +1330,11 @@ class Player extends PrimaryView {
this.debug.replay_recording = false;
}
// We promise we're updating at 60fps if the level supports it, so tell the renderer
// (This happens here because we could technically still do 20tps if we wanted, and the
// renderer doesn't actually have any way to know that)
this.renderer.update_rate = this.level.update_rate;
this.update_ui();
// Force a redraw, which won't happen on its own since the game isn't running
this._redraw();
@ -1445,7 +1463,7 @@ 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';
let use_frames = this.state === 'playing' && this.level.update_rate === 1;
if (use_frames) {
dt /= 3;
}
@ -1484,42 +1502,37 @@ class Player extends PrimaryView {
this.level.undo();
}
_max_tic_offset() {
return this.level.compat.emulate_60fps ? 0.333 : 0.999;
}
// Redraws every frame, unless the game isn't running
redraw() {
// 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!
let tic_offset;
let max = this._max_tic_offset();
let update_progress;
if (this.turn_based_mode_waiting || ! 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 = max;
update_progress = 1;
}
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(max, (performance.now() - this.last_advance) / 1000 * TICS_PER_SECOND * this.play_speed);
// Figure out how far we are between the last game update and the next one, so the
// renderer can interpolate appropriately.
let now = performance.now();
let elapsed = (performance.now() - this.last_advance) / 1000;
let speed = this.play_speed;
if (this.state === 'rewinding') {
tic_offset = max - tic_offset;
speed *= 2;
}
update_progress = elapsed * TICS_PER_SECOND * (3 / this.level.update_rate) * speed;
update_progress = Math.min(1, update_progress);
if (this.state === 'rewinding') {
update_progress = 1 - update_progress;
}
}
this._redraw(tic_offset);
this._redraw(update_progress);
// Check for a stopped game *after* drawing, so that when the game ends, we still animate
// its final tic before stopping the draw loop
// TODO stop redrawing when waiting on turn-based mode? but then, when is it restarted
if (this.state === 'playing' || this.state === 'rewinding' ||
(this.state === 'stopped' && tic_offset < 0.99))
(this.state === 'stopped' && update_progress < 0.99))
{
this._redraw_handle = requestAnimationFrame(this._redraw_bound);
}
@ -1530,18 +1543,19 @@ class Player extends PrimaryView {
// Actually redraw. Used to force drawing outside of normal play, in which case we don't
// interpolate (because we're probably paused)
_redraw(tic_offset = null) {
if (tic_offset === null) {
_redraw(update_progress = null) {
if (update_progress === null) {
// Default to drawing the "end" state of the tic when we're paused; the renderer
// interpolates backwards, so this will show the actual state of the game
if (this.state === 'paused') {
tic_offset = this._max_tic_offset();
update_progress = 1;
}
else {
tic_offset = 0;
update_progress = 0;
}
}
this.renderer.draw(tic_offset);
// Never try to draw past the next actual update
this.renderer.draw(Math.min(0.999, update_progress));
}
render_inventory_tile(name) {

View File

@ -4,8 +4,8 @@ import { DrawPacket } from './tileset.js';
import TILE_TYPES from './tiletypes.js';
class CanvasRendererDrawPacket extends DrawPacket {
constructor(renderer, ctx, tic, perception) {
super(tic, perception);
constructor(renderer, ctx, perception, clock, update_progress, update_rate) {
super(perception, clock, update_progress, update_rate);
this.renderer = renderer;
this.ctx = ctx;
// Canvas position of the cell being drawn
@ -62,6 +62,7 @@ export class CanvasRenderer {
this.show_actor_order = false;
this.use_rewind_effect = false;
this.perception = 'normal'; // normal, xray, editor, palette
this.update_rate = 3;
this.use_cc2_anim_speed = false;
this.active_player = null;
}
@ -140,7 +141,7 @@ export class CanvasRenderer {
this.canvas.style.setProperty('--tile-height', `${this.tileset.size_y}px`);
}
draw(tic_offset = 0) {
draw(update_progress = 0) {
if (! this.level) {
console.warn("CanvasRenderer.draw: No level to render");
return;
@ -148,9 +149,12 @@ export class CanvasRenderer {
this._adjust_viewport_if_dirty();
let tic = (this.level.tic_counter ?? 0) + tic_offset;
let packet = new CanvasRendererDrawPacket(this, this.ctx, tic, this.perception);
packet.update_rate = this.level.compat.emulate_60fps ? 1 : 3;
// Compute the effective current time. Note that this might come out negative before the
// game starts, because we're trying to interpolate backwards from 0, hence the Math.max()
let clock = (this.level.tic_counter ?? 0) + (
(this.level.frame_offset ?? 0) + (update_progress - 1) * this.update_rate) / 3;
let packet = new CanvasRendererDrawPacket(
this, this.ctx, this.perception, Math.max(0, clock), update_progress, this.update_rate);
let tw = this.tileset.size_x;
let th = this.tileset.size_y;
@ -160,7 +164,7 @@ export class CanvasRenderer {
// TODO what about levels smaller than the viewport...? shrink the canvas in set_level?
let xmargin = (this.viewport_size_x - 1) / 2;
let ymargin = (this.viewport_size_y - 1) / 2;
let [px, py] = this.level.player.visual_position(tic_offset, packet.update_rate);
let [px, py] = this.level.player.visual_position(update_progress, packet.update_rate);
// Figure out where to start drawing
// TODO support overlapping regions better
let x0 = px - xmargin;
@ -221,7 +225,7 @@ export class CanvasRenderer {
continue;
// Handle smooth scrolling
let [vx, vy] = actor.visual_position(tic_offset, packet.update_rate);
let [vx, vy] = actor.visual_position(update_progress, packet.update_rate);
// Round this to the pixel grid too!
vx = Math.floor(vx * tw + 0.5) / tw;
vy = Math.floor(vy * th + 0.5) / th;
@ -269,7 +273,7 @@ export class CanvasRenderer {
}
if (this.use_rewind_effect) {
this.draw_rewind_effect(tic);
this.draw_rewind_effect(packet.clock);
}
// Debug overlays
@ -280,7 +284,7 @@ export class CanvasRenderer {
let actor = this.level.cell(x, y).get_actor();
if (! actor)
continue;
let [vx, vy] = actor.visual_position(tic_offset, packet.update_rate);
let [vx, vy] = actor.visual_position(update_progress, packet.update_rate);
// Don't round to the pixel grid; we want to know if the bbox is misaligned!
this.ctx.fillRect((vx - x0) * tw, (vy - y0) * th, 1 * tw, 1 * th);
}
@ -300,7 +304,7 @@ export class CanvasRenderer {
if (cell.x < xf0 || cell.x > x1 || cell.y < yf0 || cell.y > y1)
continue;
let [vx, vy] = actor.visual_position(tic_offset, packet.update_rate);
let [vx, vy] = actor.visual_position(update_progress, packet.update_rate);
let x = (vx + 0.5 - x0) * tw;
let y = (vy + 0.5 - y0) * th;
let label = String(this.level.actors.length - 1 - n);
@ -310,9 +314,9 @@ export class CanvasRenderer {
}
}
draw_rewind_effect(tic) {
draw_rewind_effect(clock) {
// Shift several rows over in a recurring pattern, like a VHS, whatever that is
let rewind_start = tic / 20 % 1;
let rewind_start = clock / 20 % 1;
for (let chunk = 0; chunk < 4; chunk++) {
let y = Math.floor(this.canvas.height * (chunk + rewind_start) / 4);
for (let dy = 1; dy < 5; dy++) {
@ -329,7 +333,7 @@ export class CanvasRenderer {
draw_static_region(x0, y0, x1, y1, destx = x0, desty = y0) {
this._adjust_viewport_if_dirty();
let packet = new CanvasRendererDrawPacket(this, this.ctx, 0.0, this.perception);
let packet = new CanvasRendererDrawPacket(this, this.ctx, this.perception);
for (let x = x0; x <= x1; x++) {
for (let y = y0; y <= y1; y++) {
let cell = this.level.cell(x, y);
@ -369,7 +373,7 @@ export class CanvasRenderer {
let ctx = canvas.getContext('2d');
// Individual tile types always reveal what they are
let packet = new CanvasRendererDrawPacket(this, ctx, 0.0, 'palette');
let packet = new CanvasRendererDrawPacket(this, ctx, 'palette');
this.tileset.draw_type(name, tile, packet);
return canvas;
}

View File

@ -1991,17 +1991,14 @@ export const TILESET_LAYOUTS = {
// Bundle of arguments for drawing a tile, containing some standard state about the game
export class DrawPacket {
constructor(tic = 0, perception = 'normal') {
this.tic = tic;
constructor(perception = 'normal', clock = 0, update_progress = 0, update_rate = 3) {
this.perception = perception;
this.use_cc2_anim_speed = false;
this.clock = clock;
this.update_progress = update_progress;
this.update_rate = update_rate;
// this.x
// this.y
// Distinguishes between interpolation of 20tps and 60fps; 3 means 20tps, 1 means 60fps
// XXX this isn't actually about update /rate/; it's about how many "frames" of cooldown
// pass between a decision and the end of a tic
this.update_rate = 3;
}
// Draw a tile (or region) from the tileset. The caller is presumed to know where the tile
@ -2057,12 +2054,7 @@ export class Tileset {
// explosion or splash) and just plays over the course of its lifetime
if (coords[0] instanceof Array) {
if (tile && tile.movement_speed) {
let p = tile.movement_progress(packet.tic % 1, packet.update_rate);
// FIXME still get p > 1 in steam-strict
if (p >= 1) {
//console.warn(name, "p =", p, "tic =", packet.tic, "duration =", duration);
p = 0.999;
}
let p = tile.movement_progress(packet.update_progress, packet.update_rate);
coords = coords[Math.floor(p * coords.length)];
}
else {
@ -2085,7 +2077,7 @@ export class Tileset {
frames = drawspec.south;
}
// Shortcut: when drawing statically, skip all of this
if (! tile || packet.tic === 0) {
if (! tile || packet.update_progress === 0) {
packet.blit(...frames[drawspec.idle_frame_index ?? 0]);
return;
}
@ -2099,7 +2091,7 @@ export class Tileset {
let n;
if (is_global) {
// This tile animates on a global timer, looping every 'duration' frames
let p = packet.tic * 3 / duration;
let p = packet.clock * 3 / duration;
// Lilypads bob at pseudo-random. CC2 has a much simpler approach to this, but it looks
// kind of bad with big patches of lilypads. It's 202x so let's use that CPU baby
if (drawspec.positionally_hashed) {
@ -2113,7 +2105,7 @@ export class Tileset {
}
else if (tile && tile.movement_speed) {
// This tile is in motion and its animation runs 'duration' times each move.
let p = tile.movement_progress(packet.tic % 1, packet.update_rate);
let p = tile.movement_progress(packet.update_progress, packet.update_rate);
duration = duration ?? 1;
if (duration < 1) {
// The 'duration' may be fractional; for example, the player's walk cycle is two
@ -2123,9 +2115,10 @@ export class Tileset {
// Thus we add an integer in [0, 2) to offset us into which half to play, then
// divide by 2 to renormalize. Which half to use is determined by when the
// animation /started/, as measured in animation lengths.
let start_time = (packet.tic * 3 / tile.movement_speed) - p;
let start_time = (packet.clock * 3 / tile.movement_speed) - p;
// Rounding smooths out float error (assuming the framerate never exceeds 1000)
let segment = Math.floor(Math.round(start_time * 1000) / 1000 % (1 / duration));
let chunk_size = 1 / duration;
let segment = Math.floor(Math.round(start_time * 1000) / 1000 % chunk_size);
p = (p + segment) * duration;
}
else if (duration > 1) {
@ -2133,10 +2126,6 @@ export class Tileset {
// (Note that large fractional durations like 2.5 will not work.)
p = p * duration % 1;
}
if (p >= 1) {
//console.warn(name, "p =", p, "tic =", packet.tic, "duration =", duration);
p = 0.999;
}
n = Math.floor(p * frames.length);
}
else {
@ -2172,8 +2161,8 @@ export class Tileset {
if (packet.use_cc2_anim_speed && drawspec.cc2_duration) {
duration = drawspec.cc2_duration;
}
x += drawspec.scroll_region[0] * (packet.tic * 3 / duration % 1);
y += drawspec.scroll_region[1] * (packet.tic * 3 / duration % 1);
x += drawspec.scroll_region[0] * (packet.clock * 3 / duration % 1);
y += drawspec.scroll_region[1] * (packet.clock * 3 / duration % 1);
// Round to pixels
x = Math.floor(x * this.size_x + 0.5) / this.size_x;
y = Math.floor(y * this.size_y + 0.5) / this.size_y;
@ -2355,7 +2344,7 @@ export class Tileset {
// It might be random! I'm gonna say it loops every 0.3 seconds = 18 frames, so 4.5 frames
// per cel, I guess. No one will know. (But... I'll know.)
// Also it's drawn in the upper right, that's important.
let cel = Math.floor(packet.tic / 0.3 * 4) % 4;
let cel = Math.floor(packet.clock / 0.3 * 4) % 4;
packet.blit(...drawspec.fuse, 0.5 * (cel % 2), 0.5 * Math.floor(cel / 2), 0.5, 0.5, 0.5, 0);
}
@ -2398,7 +2387,6 @@ export class Tileset {
}
_draw_double_size_monster(drawspec, name, tile, packet) {
// FIXME at 60fps, the first step draws slightly offset, looks funky
// CC2's tileset has double-size art for blobs and walkers that spans the tile they're
// moving from AND the tile they're moving into.
// First, of course, this only happens if they're moving at all.
@ -2410,33 +2398,36 @@ export class Tileset {
// They only support horizontal and vertical moves, not all four directions. The other two
// directions are simply the animations played in reverse.
let axis_cels;
let w = 1, h = 1, x = 0, y = 0, reverse = false;
let w = 1, h = 1, x = 0, y = 0, sx = 0, sy = 0, reverse = false;
if (tile.direction === 'north') {
axis_cels = drawspec.vertical;
reverse = true;
h = 2;
sy = 1;
}
else if (tile.direction === 'south') {
axis_cels = drawspec.vertical;
h = 2;
y = -1;
sy = -1;
}
else if (tile.direction === 'west') {
axis_cels = drawspec.horizontal;
reverse = true;
w = 2;
sx = 1;
}
else if (tile.direction === 'east') {
axis_cels = drawspec.horizontal;
w = 2;
x = -1;
sx = -1;
}
let p = tile.movement_progress(packet.tic % 1, packet.update_rate);
p = Math.min(p, 0.999); // FIXME hack for differing movement counters
let p = tile.movement_progress(packet.update_progress, packet.update_rate);
let index = Math.floor(p * (axis_cels.length + 1));
if (index === 0 || index > axis_cels.length) {
this.draw_drawspec(drawspec.base, name, tile, packet);
packet.blit_aligned(...drawspec.base, 0, 0, 1, 1, sx, sy);
}
else {
let cel = reverse ? axis_cels[axis_cels.length - index] : axis_cels[index - 1];