Fix some subtle issues caused by 60 FPS updating
This commit is contained in:
parent
a36862e65b
commit
cf2f399371
30
js/game.js
30
js/game.js
@ -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);
|
||||
|
||||
|
||||
68
js/main.js
68
js/main.js
@ -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) {
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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];
|
||||
|
||||
Loading…
Reference in New Issue
Block a user