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:
parent
a1041c3e6f
commit
b6ed3b6502
110
js/game.js
110
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,8 +827,10 @@ export class Level extends LevelInterface {
|
||||
if (! actor.cell)
|
||||
continue;
|
||||
|
||||
if (! actor.type.ttl) {
|
||||
this._do_actor_cooldown(actor, 3);
|
||||
}
|
||||
}
|
||||
|
||||
// Mini extra pass: deal with teleporting separately. Otherwise, actors may have been in
|
||||
// the way of the teleporter but finished moving away during the above loop; this is
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user