diff --git a/js/game.js b/js/game.js index 0d69e59..e463268 100644 --- a/js/game.js +++ b/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); diff --git a/js/main.js b/js/main.js index 196b623..b49fc4c 100644 --- a/js/main.js +++ b/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) { diff --git a/js/renderer-canvas.js b/js/renderer-canvas.js index c07a553..221ec37 100644 --- a/js/renderer-canvas.js +++ b/js/renderer-canvas.js @@ -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; } diff --git a/js/tileset.js b/js/tileset.js index 712345e..f84b080 100644 --- a/js/tileset.js +++ b/js/tileset.js @@ -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];