diff --git a/js/game.js b/js/game.js
index df61246..a8d9f67 100644
--- a/js/game.js
+++ b/js/game.js
@@ -199,7 +199,7 @@ export class Level {
else {
this.time_remaining = this.stored_level.time_limit * 20;
}
- this.timer_paused = false;
+ this.timer_paused = false
// Note that this clock counts *up*, even on untimed levels, and is unaffected by CC2's
// clock alteration shenanigans
this.tic_counter = 0;
@@ -364,6 +364,11 @@ export class Level {
}
}
+
+ player_awaiting_input() {
+ return this.player.movement_cooldown === 0 && (this.player.slide_mode === null || (this.player.slide_mode === 'force' && this.player.last_move_was_force))
+ }
+
// Lynx PRNG, used unchanged in CC2
prng() {
// TODO what if we just saved this stuff, as well as the RFF direction, at the beginning of
@@ -408,14 +413,26 @@ export class Level {
}
// Move the game state forwards by one tic
- advance_tic(p1_primary_direction, p1_secondary_direction) {
+ // split into two parts for turn-based mode: first part is the consequences of the previous tick, second part depends on the player's input
+ advance_tic(p1_primary_direction, p1_secondary_direction, pass) {
if (this.state !== 'playing') {
console.warn(`Level.advance_tic() called when state is ${this.state}`);
return;
}
try {
- this._advance_tic(p1_primary_direction, p1_secondary_direction);
+ if (pass == 1)
+ {
+ this._advance_tic_part1(p1_primary_direction, p1_secondary_direction);
+ }
+ else if (pass == 2)
+ {
+ this._advance_tic_part2(p1_primary_direction, p1_secondary_direction);
+ }
+ else
+ {
+ console.warn(`What pass is this?`);
+ }
}
catch (e) {
if (e instanceof GameEnded) {
@@ -426,11 +443,13 @@ export class Level {
}
}
- // Commit the undo state at the end of each tic
- this.commit();
+ // Commit the undo state at the end of each tic (pass 2)
+ if (pass == 2) {
+ this.commit();
+ }
}
- _advance_tic(p1_primary_direction, p1_secondary_direction) {
+ _advance_tic_part1(p1_primary_direction, p1_secondary_direction) {
// Player's secondary direction is set immediately; it applies on arrival to cells even if
// it wasn't held the last time the player started moving
this._set_prop(this.player, 'secondary_direction', p1_secondary_direction);
@@ -505,199 +524,21 @@ export class Level {
}
// Second pass: actors decide their upcoming movement simultaneously
+ // (we'll do the player's decision in part 2!)
for (let i = this.actors.length - 1; i >= 0; i--) {
let actor = this.actors[i];
- if (! actor.cell)
- continue;
-
- if (actor.movement_cooldown > 0)
- continue;
-
- // Teeth can only move the first 4 of every 8 tics, though "first"
- // can be adjusted
- if (actor.slide_mode === null &&
- actor.type.uses_teeth_hesitation &&
- (this.tic_counter + this.step_parity) % 8 >= 4)
+ if (actor != this.player)
{
- continue;
- }
-
- let direction_preference;
- if (this.compat.sliding_tanks_ignore_button &&
- actor.slide_mode && actor.pending_reverse)
- {
- this._set_prop(actor, 'pending_reverse', false);
- }
- // Blocks that were pushed while sliding will move in the push direction as soon as they
- // stop sliding, regardless of what they landed on
- if (actor.pending_push) {
- actor.decision = actor.pending_push;
- this._set_prop(actor, 'pending_push', null);
- continue;
- }
- else if (actor.slide_mode === 'ice') {
- // Actors can't make voluntary moves on ice; they just slide
- actor.decision = actor.direction;
- continue;
- }
- else if (actor.slide_mode === 'force') {
- // Only the player can make voluntary moves on a force floor,
- // and only if their previous move was an /involuntary/ move on
- // a force floor. If they do, it overrides the forced move
- // XXX this in particular has some subtleties in lynx (e.g. you
- // can override forwards??) and DEFINITELY all kinds of stuff
- // in ms
- if (actor === this.player &&
- p1_primary_direction &&
- actor.last_move_was_force)
- {
- actor.decision = p1_primary_direction;
- this._set_prop(actor, 'last_move_was_force', false);
- }
- else {
- actor.decision = actor.direction;
- if (actor === this.player) {
- this._set_prop(actor, 'last_move_was_force', true);
- }
- }
- continue;
- }
- else if (actor === this.player) {
- if (p1_primary_direction) {
- actor.decision = p1_primary_direction;
- this._set_prop(actor, 'last_move_was_force', false);
- }
- continue;
- }
- else if (actor.type.movement_mode === 'forward') {
- // blue tank behavior: keep moving forward, reverse if the flag is set
- let direction = actor.direction;
- if (actor.pending_reverse) {
- direction = DIRECTIONS[actor.direction].opposite;
- this._set_prop(actor, 'pending_reverse', false);
- }
- // Tanks are controlled explicitly so they don't check if they're blocked
- // TODO tanks in traps turn around, but tanks on cloners do not, and i use the same
- // prop for both
- if (! actor.cell.some(tile => tile.type.name === 'cloner')) {
- actor.decision = direction;
- }
- continue;
- }
- else if (actor.type.movement_mode === 'follow-left') {
- // bug behavior: always try turning as left as possible, and
- // fall back to less-left turns when that fails
- let d = DIRECTIONS[actor.direction];
- direction_preference = [d.left, actor.direction, d.right, d.opposite];
- }
- else if (actor.type.movement_mode === 'follow-right') {
- // paramecium behavior: always try turning as right as
- // possible, and fall back to less-right turns when that fails
- let d = DIRECTIONS[actor.direction];
- direction_preference = [d.right, actor.direction, d.left, d.opposite];
- }
- else if (actor.type.movement_mode === 'turn-left') {
- // glider behavior: preserve current direction; if that doesn't
- // work, turn left, then right, then back the way we came
- let d = DIRECTIONS[actor.direction];
- direction_preference = [actor.direction, d.left, d.right, d.opposite];
- }
- else if (actor.type.movement_mode === 'turn-right') {
- // fireball behavior: preserve current direction; if that doesn't
- // work, turn right, then left, then back the way we came
- let d = DIRECTIONS[actor.direction];
- direction_preference = [actor.direction, d.right, d.left, d.opposite];
- }
- else if (actor.type.movement_mode === 'bounce') {
- // bouncy ball behavior: preserve current direction; if that
- // doesn't work, bounce back the way we came
- let d = DIRECTIONS[actor.direction];
- direction_preference = [actor.direction, d.opposite];
- }
- else if (actor.type.movement_mode === 'bounce-random') {
- // walker behavior: preserve current direction; if that doesn't work, pick a random
- // direction, even the one we failed to move in (but ONLY then)
- direction_preference = [actor.direction, 'WALKER'];
- }
- else if (actor.type.movement_mode === 'pursue') {
- // teeth behavior: always move towards the player
- let target_cell = this.player.cell;
- // CC2 behavior (not Lynx (TODO compat?)): pursue the cell the player is leaving, if
- // they're still mostly in it
- if (this.player.previous_cell && this.player.animation_speed &&
- this.player.animation_progress <= this.player.animation_speed / 2)
- {
- target_cell = this.player.previous_cell;
- }
- let dx = actor.cell.x - target_cell.x;
- let dy = actor.cell.y - target_cell.y;
- let preferred_horizontal, preferred_vertical;
- if (dx > 0) {
- preferred_horizontal = 'west';
- }
- else if (dx < 0) {
- preferred_horizontal = 'east';
- }
- if (dy > 0) {
- preferred_vertical = 'north';
- }
- else if (dy < 0) {
- preferred_vertical = 'south';
- }
- // Chooses the furthest direction, vertical wins ties
- if (Math.abs(dx) > Math.abs(dy)) {
- // Horizontal first
- direction_preference = [preferred_horizontal, preferred_vertical].filter(x => x);
- }
- else {
- // Vertical first
- direction_preference = [preferred_vertical, preferred_horizontal].filter(x => x);
- }
- }
- else if (actor.type.movement_mode === 'random') {
- // blob behavior: move completely at random
- let modifier = this.get_blob_modifier();
- direction_preference = [['north', 'east', 'south', 'west'][(this.prng() + modifier) % 4]];
- }
-
- // Check which of those directions we *can*, probably, move in
- // TODO i think player on force floor will still have some issues here
- // FIXME probably bail earlier for stuck actors so the prng isn't advanced? what is the
- // lynx behavior? also i hear something about blobs on cloners??
- if (direction_preference && ! actor.stuck) {
- let fallback_direction;
- for (let direction of direction_preference) {
- if (direction === 'WALKER') {
- // Walkers roll a random direction ONLY if their first attempt was blocked
- direction = actor.direction;
- let num_turns = this.prng() % 4;
- for (let i = 0; i < num_turns; i++) {
- direction = DIRECTIONS[direction].right;
- }
- }
- fallback_direction = direction;
-
- let dest_cell = this.get_neighboring_cell(actor.cell, direction);
- if (! dest_cell)
- continue;
-
- if (! actor.cell.blocks_leaving(actor, direction) &&
- ! dest_cell.blocks_entering(actor, direction, this, true))
- {
- // We found a good direction! Stop here
- actor.decision = direction;
- break;
- }
- }
-
- // If all the decisions are blocked, actors still try the last one (and might even
- // be able to move that way by the time their turn comes around!)
- if (actor.decision === null) {
- actor.decision = fallback_direction;
- }
+ this.actor_decision(actor, p1_primary_direction);
}
}
-
+ }
+
+
+ _advance_tic_part2(p1_primary_direction, p1_secondary_direction) {
+ //player now makes a decision based on input
+ this.actor_decision(this.player, p1_primary_direction);
+
// Third pass: everyone actually moves
for (let i = this.actors.length - 1; i >= 0; i--) {
let actor = this.actors[i];
@@ -785,6 +626,198 @@ export class Level {
});
}
}
+
+ actor_decision(actor, p1_primary_direction) {
+ if (! actor.cell)
+ return;
+
+ if (actor.movement_cooldown > 0)
+ return;
+
+ // Teeth can only move the first 4 of every 8 tics, though "first"
+ // can be adjusted
+ if (actor.slide_mode === null &&
+ actor.type.uses_teeth_hesitation &&
+ (this.tic_counter + this.step_parity) % 8 >= 4)
+ {
+ return;
+ }
+
+ let direction_preference;
+ if (this.compat.sliding_tanks_ignore_button &&
+ actor.slide_mode && actor.pending_reverse)
+ {
+ this._set_prop(actor, 'pending_reverse', false);
+ }
+ // Blocks that were pushed while sliding will move in the push direction as soon as they
+ // stop sliding, regardless of what they landed on
+ if (actor.pending_push) {
+ actor.decision = actor.pending_push;
+ this._set_prop(actor, 'pending_push', null);
+ return;
+ }
+ if (actor.slide_mode === 'ice') {
+ // Actors can't make voluntary moves on ice; they just slide
+ actor.decision = actor.direction;
+ return;
+ }
+ else if (actor.slide_mode === 'force') {
+ // Only the player can make voluntary moves on a force floor,
+ // and only if their previous move was an /involuntary/ move on
+ // a force floor. If they do, it overrides the forced move
+ // XXX this in particular has some subtleties in lynx (e.g. you
+ // can override forwards??) and DEFINITELY all kinds of stuff
+ // in ms
+ if (actor === this.player &&
+ p1_primary_direction &&
+ actor.last_move_was_force)
+ {
+ actor.decision = p1_primary_direction;
+ this._set_prop(actor, 'last_move_was_force', false);
+ }
+ else {
+ actor.decision = actor.direction;
+ if (actor === this.player) {
+ this._set_prop(actor, 'last_move_was_force', true);
+ }
+ }
+ return;
+ }
+ else if (actor === this.player) {
+ if (p1_primary_direction) {
+ actor.decision = p1_primary_direction;
+ this._set_prop(actor, 'last_move_was_force', false);
+ }
+ return;
+ }
+ else if (actor.type.movement_mode === 'forward') {
+ // blue tank behavior: keep moving forward, reverse if the flag is set
+ let direction = actor.direction;
+ if (actor.pending_reverse) {
+ direction = DIRECTIONS[actor.direction].opposite;
+ this._set_prop(actor, 'pending_reverse', false);
+ }
+ // Tanks are controlled explicitly so they don't check if they're blocked
+ // TODO tanks in traps turn around, but tanks on cloners do not, and i use the same
+ // prop for both
+ if (! actor.cell.some(tile => tile.type.name === 'cloner')) {
+ actor.decision = direction;
+ }
+ return;
+ }
+ else if (actor.type.movement_mode === 'follow-left') {
+ // bug behavior: always try turning as left as possible, and
+ // fall back to less-left turns when that fails
+ let d = DIRECTIONS[actor.direction];
+ direction_preference = [d.left, actor.direction, d.right, d.opposite];
+ }
+ else if (actor.type.movement_mode === 'follow-right') {
+ // paramecium behavior: always try turning as right as
+ // possible, and fall back to less-right turns when that fails
+ let d = DIRECTIONS[actor.direction];
+ direction_preference = [d.right, actor.direction, d.left, d.opposite];
+ }
+ else if (actor.type.movement_mode === 'turn-left') {
+ // glider behavior: preserve current direction; if that doesn't
+ // work, turn left, then right, then back the way we came
+ let d = DIRECTIONS[actor.direction];
+ direction_preference = [actor.direction, d.left, d.right, d.opposite];
+ }
+ else if (actor.type.movement_mode === 'turn-right') {
+ // fireball behavior: preserve current direction; if that doesn't
+ // work, turn right, then left, then back the way we came
+ let d = DIRECTIONS[actor.direction];
+ direction_preference = [actor.direction, d.right, d.left, d.opposite];
+ }
+ else if (actor.type.movement_mode === 'bounce') {
+ // bouncy ball behavior: preserve current direction; if that
+ // doesn't work, bounce back the way we came
+ let d = DIRECTIONS[actor.direction];
+ direction_preference = [actor.direction, d.opposite];
+ }
+ else if (actor.type.movement_mode === 'bounce-random') {
+ // walker behavior: preserve current direction; if that doesn't work, pick a random
+ // direction, even the one we failed to move in (but ONLY then)
+ direction_preference = [actor.direction, 'WALKER'];
+ }
+ else if (actor.type.movement_mode === 'pursue') {
+ // teeth behavior: always move towards the player
+ let target_cell = this.player.cell;
+ // CC2 behavior (not Lynx (TODO compat?)): pursue the cell the player is leaving, if
+ // they're still mostly in it
+ if (this.player.previous_cell && this.player.animation_speed &&
+ this.player.animation_progress <= this.player.animation_speed / 2)
+ {
+ target_cell = this.player.previous_cell;
+ }
+ let dx = actor.cell.x - target_cell.x;
+ let dy = actor.cell.y - target_cell.y;
+ let preferred_horizontal, preferred_vertical;
+ if (dx > 0) {
+ preferred_horizontal = 'west';
+ }
+ else if (dx < 0) {
+ preferred_horizontal = 'east';
+ }
+ if (dy > 0) {
+ preferred_vertical = 'north';
+ }
+ else if (dy < 0) {
+ preferred_vertical = 'south';
+ }
+ // Chooses the furthest direction, vertical wins ties
+ if (Math.abs(dx) > Math.abs(dy)) {
+ // Horizontal first
+ direction_preference = [preferred_horizontal, preferred_vertical].filter(x => x);
+ }
+ else {
+ // Vertical first
+ direction_preference = [preferred_vertical, preferred_horizontal].filter(x => x);
+ }
+ }
+ else if (actor.type.movement_mode === 'random') {
+ // blob behavior: move completely at random
+ let modifier = this.get_blob_modifier();
+ direction_preference = [['north', 'east', 'south', 'west'][(this.prng() + modifier) % 4]];
+ }
+
+ // Check which of those directions we *can*, probably, move in
+ // TODO i think player on force floor will still have some issues here
+ // FIXME probably bail earlier for stuck actors so the prng isn't advanced? what is the
+ // lynx behavior? also i hear something about blobs on cloners??
+ if (direction_preference && ! actor.stuck) {
+ let fallback_direction;
+ for (let direction of direction_preference) {
+ if (direction === 'WALKER') {
+ // Walkers roll a random direction ONLY if their first attempt was blocked
+ direction = actor.direction;
+ let num_turns = this.prng() % 4;
+ for (let i = 0; i < num_turns; i++) {
+ direction = DIRECTIONS[direction].right;
+ }
+ }
+ fallback_direction = direction;
+
+ let dest_cell = this.get_neighboring_cell(actor.cell, direction);
+ if (! dest_cell)
+ continue;
+
+ if (! actor.cell.blocks_leaving(actor, direction) &&
+ ! dest_cell.blocks_entering(actor, direction, this, true))
+ {
+ // We found a good direction! Stop here
+ actor.decision = direction;
+ break;
+ }
+ }
+
+ // If all the decisions are blocked, actors still try the last one (and might even
+ // be able to move that way by the time their turn comes around!)
+ if (actor.decision === null) {
+ actor.decision = fallback_direction;
+ }
+ }
+ }
// Try to move the given actor one tile in the given direction and update
// their cooldown. Return true if successful.
@@ -1306,6 +1339,13 @@ export class Level {
}
undo() {
+ //reverse the pending_undo too
+ this.pending_undo.reverse();
+ for (let undo of this.pending_undo) {
+ undo();
+ }
+ this.pending_undo = [];
+
this.aid = Math.max(1, this.aid);
let entry = this.undo_stack.pop();
diff --git a/js/main.js b/js/main.js
index a4642ff..2e9a560 100644
--- a/js/main.js
+++ b/js/main.js
@@ -243,6 +243,8 @@ class Player extends PrimaryView {
ArrowRight: 'right',
ArrowUp: 'up',
ArrowDown: 'down',
+ Spacebar: 'wait',
+ " ": 'wait',
w: 'up',
a: 'left',
s: 'down',
@@ -304,6 +306,12 @@ class Player extends PrimaryView {
this.music_audio_el.pause();
}
});
+
+ this.turn_based = false;
+ this.turn_based_checkbox = this.root.querySelector('.controls .turn-based');
+ this.turn_based_checkbox.addEventListener('change', ev => {
+ this.turn_based = !this.turn_based;
+ });
// Bind buttons
this.pause_button = this.root.querySelector('.controls .control-pause');
@@ -327,7 +335,7 @@ class Player extends PrimaryView {
while (this.level.undo_stack.length > 0 &&
! (moved && this.level.player.slide_mode === null))
{
- this.level.undo();
+ this.undo();
if (player_cell !== this.level.player.cell) {
moved = true;
}
@@ -409,6 +417,7 @@ class Player extends PrimaryView {
this.previous_action = null; // last direction we were moving, if any
this.using_touch = false; // true if using touch controls
this.current_keys = new Set; // keys that are currently held
+ this.current_keys_new = new Set; //for keys that have only been held a frame
// TODO this could all probably be more rigorous but it's fine for now
key_target.addEventListener('keydown', ev => {
if (ev.key === 'p' || ev.key === 'Pause') {
@@ -429,7 +438,9 @@ class Player extends PrimaryView {
}
else {
// Restart
- this.restart_level();
+ if (!this.current_keys.has(ev.key)) {
+ this.restart_level();
+ }
}
return;
}
@@ -447,6 +458,7 @@ class Player extends PrimaryView {
if (this.key_mapping[ev.key]) {
this.current_keys.add(ev.key);
+ this.current_keys_new.add(ev.key);
ev.stopPropagation();
ev.preventDefault();
@@ -647,6 +659,7 @@ class Player extends PrimaryView {
_clear_state() {
this.set_state('waiting');
+ this.waiting_for_input = false;
this.tic_offset = 0;
this.last_advance = 0;
this.demo_faucet = null;
@@ -693,12 +706,18 @@ class Player extends PrimaryView {
for (let key of this.current_keys) {
input.add(this.key_mapping[key]);
}
+ for (let key of this.current_keys_new) {
+ input.add(this.key_mapping[key]);
+ }
+ this.current_keys_new = new Set;
for (let action of Object.values(this.current_touches)) {
input.add(action);
}
return input;
}
}
+
+ waiting_for_input = false;
advance_by(tics) {
for (let i = 0; i < tics; i++) {
@@ -762,10 +781,43 @@ class Player extends PrimaryView {
this.previous_input = input;
this.sfx_player.advance_tic();
- this.level.advance_tic(
- this.primary_action ? ACTION_DIRECTIONS[this.primary_action] : null,
- this.secondary_action ? ACTION_DIRECTIONS[this.secondary_action] : null,
- );
+
+ var primary_dir = this.primary_action ? ACTION_DIRECTIONS[this.primary_action] : null;
+ var secondary_dir = this.secondary_action ? ACTION_DIRECTIONS[this.secondary_action] : null;
+
+ //turn based logic
+ //first, handle a part 2 we just got input for
+ if (this.waiting_for_input)
+ {
+ if (!this.turn_based || primary_dir != null || input.has('wait'))
+ {
+ this.waiting_for_input = false;
+ this.level.advance_tic(
+ primary_dir,
+ secondary_dir,
+ 2);
+
+ }
+ }
+ else
+ {
+ this.level.advance_tic(
+ primary_dir,
+ secondary_dir,
+ 1);
+ //then if we should wait for input, the player needs input and we don't have input, we set waiting_for_input, else we run part 2
+ if (this.turn_based && this.level.player_awaiting_input() && !(primary_dir != null || input.has('wait')))
+ {
+ this.waiting_for_input = true;
+ }
+ else
+ {
+ this.level.advance_tic(
+ primary_dir,
+ secondary_dir,
+ 2);
+ }
+ }
if (this.level.state !== 'playing') {
// We either won or lost!
@@ -783,8 +835,9 @@ class Player extends PrimaryView {
this._advance_handle = null;
return;
}
-
+
this.last_advance = performance.now();
+
if (this.state === 'playing') {
this.advance_by(1);
}
@@ -798,10 +851,17 @@ class Player extends PrimaryView {
}
else {
// Rewind by undoing one tic every tic
- this.level.undo();
+ this.undo();
this.update_ui();
}
}
+
+ if (this.waiting_for_input)
+ {
+ //freeze tic_offset in time so we don't try to interpolate to the next frame too soon
+ this.tic_offset = 0;
+ }
+
let dt = 1000 / TICS_PER_SECOND;
if (this.state === 'rewinding') {
// Rewind faster than normal time
@@ -810,6 +870,12 @@ class Player extends PrimaryView {
this._advance_handle = window.setTimeout(this._advance_bound, dt);
}
+ undo() {
+ //if we were waiting for input and undo, well, now we're not
+ this.waiting_for_input = false;
+ this.level.undo();
+ }
+
// Redraws every frame, unless the game isn't running
redraw() {
// Calculate this here, not in _redraw, because that's called at weird
@@ -817,9 +883,15 @@ class Player extends PrimaryView {
// 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!
- this.tic_offset = Math.min(0.9999, (performance.now() - this.last_advance) / 1000 / (1 / TICS_PER_SECOND));
- if (this.state === 'rewinding') {
- this.tic_offset = 1 - this.tic_offset;
+ if (this.waiting_for_input) {
+ //freeze tic_offset in time
+ }
+ else
+ {
+ this.tic_offset = Math.min(0.9999, (performance.now() - this.last_advance) / 1000 / (1 / TICS_PER_SECOND));
+ if (this.state === 'rewinding') {
+ this.tic_offset = 1 - this.tic_offset;
+ }
}
this._redraw();
@@ -837,6 +909,7 @@ class Player extends PrimaryView {
// Actually redraw. Used to force drawing outside of normal play
_redraw() {
+ this.renderer.waiting_for_input = this.waiting_for_input;
this.renderer.draw(this.tic_offset);
}
diff --git a/js/renderer-canvas.js b/js/renderer-canvas.js
index 1ee1b4b..a854a65 100644
--- a/js/renderer-canvas.js
+++ b/js/renderer-canvas.js
@@ -60,6 +60,8 @@ export class CanvasRenderer {
sx * tw, sy * th, w * tw, h * th,
dx * tw, dy * th, w * tw, h * th);
}
+
+ waiting_for_input = false;
draw(tic_offset = 0) {
if (! this.level) {
@@ -67,7 +69,7 @@ export class CanvasRenderer {
return;
}
- let tic = (this.level.tic_counter ?? 0) + tic_offset;
+ let tic = (this.level.tic_counter ?? 0) + tic_offset + (this.waiting_for_input ? 1 : 0);
let tw = this.tileset.size_x;
let th = this.tileset.size_y;
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);