diff --git a/js/game.js b/js/game.js index fc77e51..527849d 100644 --- a/js/game.js +++ b/js/game.js @@ -30,8 +30,13 @@ 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; + } + // Gives the effective position of an actor in motion, given smooth scrolling - visual_position(tic_offset = 0) { + visual_position(tic_offset = 0, interpolate_backwards_by = 0) { let x = this.cell.x; let y = this.cell.y; if (! this.previous_cell || this.movement_speed === null) { @@ -40,7 +45,7 @@ export class Tile { else { // 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_speed - this.movement_cooldown) + tic_offset * 3) / this.movement_speed; + let p = this.movement_progress(tic_offset, interpolate_backwards_by); return [ (1 - p) * this.previous_cell.x + p * x, (1 - p) * this.previous_cell.y + p * y, @@ -783,8 +788,11 @@ export class Level extends LevelInterface { } } - // Only the Lexy-style loop has a notion of "finishing" a tic, since (unlike the Lynx loop) the - // decision phase happens in the /middle/ + // 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 @@ -807,27 +815,6 @@ export class Level extends LevelInterface { this._do_actor_movement(actor, actor.decision); } - this._do_cleanup_phase(); - } - - // Lexy-style loop, the one I stumbled upon accidentally. Here, cooldowns happen /first/ as - // their own phase, then decisions are made, then movement happens. This approach has several - // advantages: there's no cooldown on the same tic that movement begins, which lets the renderer - // interpolate between tics more easily; there's no jitter when pushing a block, as is seen in - // CC2; and generally more things happen in parallel, which improves the illusion that all the - // game objects are acting simultaneously. - _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. We want the result of a button press to draw, but not last longer - // than intended, so we only want one update between the end of the cooldown pass and the - // end of the tic. That means the other two have to go here. When a level starts, there - // are only two wiring updates before everything gets its first chance to move, so we skip - // the very first one here. - if (this.tic_counter !== 0) { - this._do_wire_phase(); - } - this._do_wire_phase(); - // Advance everyone's cooldowns // Note that we iterate in reverse order, DESPITE keeping dead actors around with null // cells, to match the Lynx and CC2 behavior. This is actually important in some cases; @@ -840,7 +827,9 @@ export class Level extends LevelInterface { if (! actor.cell) continue; - this._do_actor_cooldown(actor, 3); + if (! actor.type.ttl) { + this._do_actor_cooldown(actor, 3); + } } // Mini extra pass: deal with teleporting separately. Otherwise, actors may have been in @@ -860,6 +849,22 @@ export class Level extends LevelInterface { this._do_wire_phase(); // TODO should this also happen three times? this._do_static_phase(); + + 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. We want the result of a button press to draw, but not last longer + // than intended, so we only want one update between the end of the cooldown pass and the + // end of the tic. That means the other two have to go here. When a level starts, there + // are only two wiring updates before everything gets its first chance to move, so we skip + // the very first one here. + if (this.tic_counter !== 0) { + this._do_wire_phase(); + } + this._do_wire_phase(); } // Lynx-style loop: everyone decides, then everyone moves/cools. @@ -909,9 +914,25 @@ export class Level extends LevelInterface { if (! actor.cell) continue; + if (actor.type.ttl) { + // Animations, bizarrely, do their cooldown at decision time, so they're removed + // early on the tic that they expire + this._do_actor_cooldown(actor, this.compat.emulate_60fps ? 1 : 3); + continue; + } + if (actor.movement_cooldown > 0) continue; + // Erase old traces of movement now + if (actor.movement_speed) { + this._set_tile_prop(actor, 'previous_cell', null); + this._set_tile_prop(actor, 'movement_speed', null); + if (actor.is_pulled) { + this._set_tile_prop(actor, 'is_pulled', false); + } + } + if (! forced_only && actor.type.on_tic) { actor.type.on_tic(actor, this); if (! actor.cell) @@ -935,6 +956,9 @@ export class Level extends LevelInterface { continue; this._do_actor_movement(actor, actor.decision); + if (actor.type.ttl) + continue; + this._do_actor_cooldown(actor, cooldown); if (actor.just_stepped_on_teleporter) { this.attempt_teleport(actor); @@ -1009,6 +1033,9 @@ export class Level extends LevelInterface { if (actor.movement_cooldown <= 0) return; + if (actor.last_extra_cooldown_tic === this.tic_counter) + return; + if (actor.cooldown_delay_hack) { // See the extensive comment in attempt_out_of_turn_step actor.cooldown_delay_hack += 1; @@ -1027,17 +1054,11 @@ export class Level extends LevelInterface { if (! this.compat.tiles_react_instantly) { this.step_on_cell(actor, actor.cell); } - // Erase any trace of being in mid-movement, however: - // - This has to happen after stepping on cells, because some effects care about - // the cell we're arriving from - // - Don't do it if stepping on the cell caused us to move again - if (actor.movement_cooldown <= 0) { - this._set_tile_prop(actor, 'previous_cell', null); - this._set_tile_prop(actor, 'movement_speed', null); - if (actor.is_pulled) { - this._set_tile_prop(actor, 'is_pulled', false); - } - } + // Note that we don't erase the movement bookkeeping until next decision phase, because + // the renderer interpolates back in time and needs to know to draw us finishing the + // move; this should be fine since everything checks for "in motion" by looking at + // movement_cooldown, which is already zero. (Also saves some undo budget, since + // movement_speed is never null for an actor in constant motion.) } } @@ -1485,6 +1506,7 @@ export class Level extends LevelInterface { } if (this.attempt_step(actor, direction)) { + this._do_extra_cooldown(actor); // Here's the problem. // In CC2, cooldown is measured in frames, not tics, and it decrements every frame, not // every tic. You usually don't notice because actors can only initiate moves every @@ -1508,7 +1530,9 @@ export class Level extends LevelInterface { // cannot possibly work // TODO now that i have steam-strict mode this is largely pointless, just do what seems // correct - actor.cooldown_delay_hack = 1; + // FIXME remove this once i'm sure that it doesn't break cloners OR that cc1 tail-bite + // trap + //actor.cooldown_delay_hack = 1; return true; } else { @@ -1516,6 +1540,12 @@ export class Level extends LevelInterface { } } + _do_extra_cooldown(actor) { + this._do_actor_cooldown(actor, this.compat.emulate_60fps ? 1 : 3); + // FIXME not for lynx i /think/? + this._set_tile_prop(actor, 'last_extra_cooldown_tic', this.tic_counter); + } + // Move the given actor to the given position and perform any appropriate // tile interactions. Does NOT check for whether the move is actually // legal; use attempt_step for that! @@ -2292,7 +2322,8 @@ export class Level extends LevelInterface { // immediately, as Lynx does; note that Lynx also ticks /and destroys/ animations early in // the decision phase, but this seems to work out just as well this._set_tile_prop(tile, 'movement_speed', tile.type.ttl); - this._set_tile_prop(tile, 'movement_cooldown', tile.type.ttl - 1); + this._set_tile_prop(tile, 'movement_cooldown', tile.type.ttl); + this._do_extra_cooldown(tile); cell._add(tile); this.actors.push(tile); this._push_pending_undo(() => { @@ -2328,7 +2359,8 @@ export class Level extends LevelInterface { } this._set_tile_prop(tile, 'previous_cell', null); this._set_tile_prop(tile, 'movement_speed', tile.type.ttl); - this._set_tile_prop(tile, 'movement_cooldown', tile.type.ttl - 1); + this._set_tile_prop(tile, 'movement_cooldown', tile.type.ttl); + this._do_extra_cooldown(tile); } } diff --git a/js/main.js b/js/main.js index 130d0c3..5c277f1 100644 --- a/js/main.js +++ b/js/main.js @@ -479,7 +479,6 @@ class Player extends PrimaryView { } }); // Game actions - // TODO do these need buttons?? feel like they're not discoverable otherwise this.drop_button = this.root.querySelector('#player-actions .action-drop'); this.drop_button.addEventListener('click', ev => { // Use the set of "buttons pressed between tics" because it's cleared automatically; diff --git a/js/renderer-canvas.js b/js/renderer-canvas.js index f338edc..7db0c9f 100644 --- a/js/renderer-canvas.js +++ b/js/renderer-canvas.js @@ -141,6 +141,9 @@ 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; + let tw = this.tileset.size_x; let th = this.tileset.size_y; this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height); @@ -149,7 +152,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); + let [px, py] = this.level.player.visual_position(tic_offset, packet.update_rate); // Figure out where to start drawing // TODO support overlapping regions better let x0 = px - xmargin; @@ -188,7 +191,6 @@ export class CanvasRenderer { // Tiles in motion (i.e., actors) don't want to be overdrawn by neighboring tiles' terrain, // so draw in three passes: everything below actors, actors, and everything above actors // neighboring terrain - let packet = new CanvasRendererDrawPacket(this, this.ctx, tic, this.perception); for (let x = xf0; x <= x1; x++) { for (let y = yf0; y <= y1; y++) { let cell = this.level.cell(x, y); @@ -211,7 +213,7 @@ export class CanvasRenderer { continue; // Handle smooth scrolling - let [vx, vy] = actor.visual_position(tic_offset); + let [vx, vy] = actor.visual_position(tic_offset, 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; @@ -265,7 +267,7 @@ export class CanvasRenderer { let actor = this.level.cell(x, y).get_actor(); if (! actor) continue; - let [vx, vy] = actor.visual_position(tic_offset); + let [vx, vy] = actor.visual_position(tic_offset, 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); } diff --git a/js/tileset.js b/js/tileset.js index 73d4ffb..7958426 100644 --- a/js/tileset.js +++ b/js/tileset.js @@ -954,6 +954,11 @@ export class DrawPacket { constructor(tic = 0, perception = 'normal') { this.tic = tic; this.perception = perception; + + // 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 @@ -999,7 +1004,7 @@ export class Tileset { // This tile reports its own animation timing (in frames), so trust that, and use // the current tic's fraction. If we're between tics, interpolate. // FIXME if the game ever runs every frame we will have to adjust the interpolation - let p = ((tile.movement_speed - tile.movement_cooldown) + packet.tic % 1 * 3) / tile.movement_speed; + let p = tile.movement_progress(packet.tic % 1, packet.update_rate); if (this.animation_slowdown > 1 && ! tile.type.ttl) { // The players have full walk animations, but they look very silly when squeezed // into the span of a single step, so instead we only play half at a time. The @@ -1148,9 +1153,7 @@ export class Tileset { x = -1; } - // FIXME lexy is n to 1, cc2 n-1 to 0, and this mixes them - let p = tile.movement_speed - tile.movement_cooldown; - p = (p + packet.tic % 1 * 3) / tile.movement_speed; + let p = tile.movement_progress(packet.tic % 1, packet.update_rate); p = Math.min(p, 0.999); // FIXME hack for differing movement counters let index = Math.floor(p * (axis_cels.length + 1)); if (index === 0 || index > axis_cels.length) {