Change the Lexy loop to be more Lynx-like

This simplifies the renderer by having movement cooldowns only work one
way, and thus removes the jank from Steam rendering.

This commit also applies cooldowns for animations at decision time, as
Lynx does, which eliminates a weird special case from their spawning.

Also, Lexy mode now explicitly does not allow an actor to get cooled
twice in one tic.  However, this change does make clone machines no
longer be aligned with the thing that pressed the button to clone them,
which is unfortunate.
This commit is contained in:
Eevee (Evelyn Woods) 2021-01-13 01:34:05 -07:00
parent a1041c3e6f
commit b6ed3b6502
4 changed files with 85 additions and 49 deletions

View File

@ -30,8 +30,13 @@ export class Tile {
return Object.assign(tile, tile_template); 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 // 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 x = this.cell.x;
let y = this.cell.y; let y = this.cell.y;
if (! this.previous_cell || this.movement_speed === null) { if (! this.previous_cell || this.movement_speed === null) {
@ -40,7 +45,7 @@ export class Tile {
else { else {
// For a movement speed of N, the cooldown is set to N during the tic an actor starts // 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 // 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 [ return [
(1 - p) * this.previous_cell.x + p * x, (1 - p) * this.previous_cell.x + p * x,
(1 - p) * this.previous_cell.y + p * y, (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 // FIXME merge this a bit more with the lynx loop, which should be more in finish_tic anyway
// decision phase happens in the /middle/ // 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) { finish_tic(p1_input) {
this.p1_input = p1_input; this.p1_input = p1_input;
this.p1_released |= ~p1_input; // Action keys released since we last checked them 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_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 // Advance everyone's cooldowns
// Note that we iterate in reverse order, DESPITE keeping dead actors around with null // 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; // 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) if (! actor.cell)
continue; 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 // 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(); this._do_wire_phase();
// TODO should this also happen three times? // TODO should this also happen three times?
this._do_static_phase(); 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. // Lynx-style loop: everyone decides, then everyone moves/cools.
@ -909,9 +914,25 @@ export class Level extends LevelInterface {
if (! actor.cell) if (! actor.cell)
continue; 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) if (actor.movement_cooldown > 0)
continue; 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) { if (! forced_only && actor.type.on_tic) {
actor.type.on_tic(actor, this); actor.type.on_tic(actor, this);
if (! actor.cell) if (! actor.cell)
@ -935,6 +956,9 @@ export class Level extends LevelInterface {
continue; continue;
this._do_actor_movement(actor, actor.decision); this._do_actor_movement(actor, actor.decision);
if (actor.type.ttl)
continue;
this._do_actor_cooldown(actor, cooldown); this._do_actor_cooldown(actor, cooldown);
if (actor.just_stepped_on_teleporter) { if (actor.just_stepped_on_teleporter) {
this.attempt_teleport(actor); this.attempt_teleport(actor);
@ -1009,6 +1033,9 @@ export class Level extends LevelInterface {
if (actor.movement_cooldown <= 0) if (actor.movement_cooldown <= 0)
return; return;
if (actor.last_extra_cooldown_tic === this.tic_counter)
return;
if (actor.cooldown_delay_hack) { if (actor.cooldown_delay_hack) {
// See the extensive comment in attempt_out_of_turn_step // See the extensive comment in attempt_out_of_turn_step
actor.cooldown_delay_hack += 1; actor.cooldown_delay_hack += 1;
@ -1027,17 +1054,11 @@ export class Level extends LevelInterface {
if (! this.compat.tiles_react_instantly) { if (! this.compat.tiles_react_instantly) {
this.step_on_cell(actor, actor.cell); this.step_on_cell(actor, actor.cell);
} }
// Erase any trace of being in mid-movement, however: // Note that we don't erase the movement bookkeeping until next decision phase, because
// - This has to happen after stepping on cells, because some effects care about // the renderer interpolates back in time and needs to know to draw us finishing the
// the cell we're arriving from // move; this should be fine since everything checks for "in motion" by looking at
// - Don't do it if stepping on the cell caused us to move again // movement_cooldown, which is already zero. (Also saves some undo budget, since
if (actor.movement_cooldown <= 0) { // movement_speed is never null for an actor in constant motion.)
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);
}
}
} }
} }
@ -1485,6 +1506,7 @@ export class Level extends LevelInterface {
} }
if (this.attempt_step(actor, direction)) { if (this.attempt_step(actor, direction)) {
this._do_extra_cooldown(actor);
// Here's the problem. // Here's the problem.
// In CC2, cooldown is measured in frames, not tics, and it decrements every frame, not // 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 // 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 // cannot possibly work
// TODO now that i have steam-strict mode this is largely pointless, just do what seems // TODO now that i have steam-strict mode this is largely pointless, just do what seems
// correct // 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; return true;
} }
else { 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 // Move the given actor to the given position and perform any appropriate
// tile interactions. Does NOT check for whether the move is actually // tile interactions. Does NOT check for whether the move is actually
// legal; use attempt_step for that! // 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 // 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 // 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_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); cell._add(tile);
this.actors.push(tile); this.actors.push(tile);
this._push_pending_undo(() => { 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, 'previous_cell', null);
this._set_tile_prop(tile, 'movement_speed', tile.type.ttl); 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);
} }
} }

View File

@ -479,7 +479,6 @@ class Player extends PrimaryView {
} }
}); });
// Game actions // 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 = this.root.querySelector('#player-actions .action-drop');
this.drop_button.addEventListener('click', ev => { this.drop_button.addEventListener('click', ev => {
// Use the set of "buttons pressed between tics" because it's cleared automatically; // Use the set of "buttons pressed between tics" because it's cleared automatically;

View File

@ -141,6 +141,9 @@ export class CanvasRenderer {
this._adjust_viewport_if_dirty(); this._adjust_viewport_if_dirty();
let tic = (this.level.tic_counter ?? 0) + tic_offset; 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 tw = this.tileset.size_x;
let th = this.tileset.size_y; let th = this.tileset.size_y;
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height); 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? // TODO what about levels smaller than the viewport...? shrink the canvas in set_level?
let xmargin = (this.viewport_size_x - 1) / 2; let xmargin = (this.viewport_size_x - 1) / 2;
let ymargin = (this.viewport_size_y - 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 // Figure out where to start drawing
// TODO support overlapping regions better // TODO support overlapping regions better
let x0 = px - xmargin; 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, // 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 // so draw in three passes: everything below actors, actors, and everything above actors
// neighboring terrain // neighboring terrain
let packet = new CanvasRendererDrawPacket(this, this.ctx, tic, this.perception);
for (let x = xf0; x <= x1; x++) { for (let x = xf0; x <= x1; x++) {
for (let y = yf0; y <= y1; y++) { for (let y = yf0; y <= y1; y++) {
let cell = this.level.cell(x, y); let cell = this.level.cell(x, y);
@ -211,7 +213,7 @@ export class CanvasRenderer {
continue; continue;
// Handle smooth scrolling // 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! // Round this to the pixel grid too!
vx = Math.floor(vx * tw + 0.5) / tw; vx = Math.floor(vx * tw + 0.5) / tw;
vy = Math.floor(vy * th + 0.5) / th; vy = Math.floor(vy * th + 0.5) / th;
@ -265,7 +267,7 @@ export class CanvasRenderer {
let actor = this.level.cell(x, y).get_actor(); let actor = this.level.cell(x, y).get_actor();
if (! actor) if (! actor)
continue; 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! // 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); this.ctx.fillRect((vx - x0) * tw, (vy - y0) * th, 1 * tw, 1 * th);
} }

View File

@ -954,6 +954,11 @@ export class DrawPacket {
constructor(tic = 0, perception = 'normal') { constructor(tic = 0, perception = 'normal') {
this.tic = tic; this.tic = tic;
this.perception = perception; 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 // 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 // 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. // 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 // 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) { if (this.animation_slowdown > 1 && ! tile.type.ttl) {
// The players have full walk animations, but they look very silly when squeezed // 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 // 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; x = -1;
} }
// FIXME lexy is n to 1, cc2 n-1 to 0, and this mixes them let p = tile.movement_progress(packet.tic % 1, packet.update_rate);
let p = tile.movement_speed - tile.movement_cooldown;
p = (p + packet.tic % 1 * 3) / tile.movement_speed;
p = Math.min(p, 0.999); // FIXME hack for differing movement counters p = Math.min(p, 0.999); // FIXME hack for differing movement counters
let index = Math.floor(p * (axis_cels.length + 1)); let index = Math.floor(p * (axis_cels.length + 1));
if (index === 0 || index > axis_cels.length) { if (index === 0 || index > axis_cels.length) {