Implement turn based mode

Seems to work mechanically though I haven't extensively stress tested it yet. Force floors work the way you'd want them to though (you're given control whenever you can make an input and not otherwise).
There are some graphical bugs with rewinding, but there were some without turn based mode anyway...
This commit is contained in:
Timothy Stiles 2020-09-26 22:10:42 +10:00
parent 12066072ec
commit 5c6cd01b39
3 changed files with 257 additions and 205 deletions

View File

@ -120,6 +120,7 @@
<button class="control-restart" type="button">Restart</button> <button class="control-restart" type="button">Restart</button>
<button class="control-undo" type="button">Undo</button> <button class="control-undo" type="button">Undo</button>
<button class="control-rewind" type="button">Rewind (z)</button> <button class="control-rewind" type="button">Rewind (z)</button>
<input class="turn-based" type="checkbox">Turn-Based</input>
</div> </div>
<div class="demo-controls"> <div class="demo-controls">
<button class="demo-play" type="button">View replay</button> <button class="demo-play" type="button">View replay</button>

View File

@ -158,6 +158,7 @@ export class Level {
this.height = stored_level.size_y; this.height = stored_level.size_y;
this.size_x = stored_level.size_x; this.size_x = stored_level.size_x;
this.size_y = stored_level.size_y; this.size_y = stored_level.size_y;
this.turn_based = false;
this.restart(compat); this.restart(compat);
} }
@ -185,7 +186,8 @@ export class Level {
else { else {
this.time_remaining = this.stored_level.time_limit * 20; this.time_remaining = this.stored_level.time_limit * 20;
} }
this.timer_paused = false; this.timer_paused = false
this.waiting_for_input = false
// Note that this clock counts *up*, even on untimed levels, and is unaffected by CC2's // Note that this clock counts *up*, even on untimed levels, and is unaffected by CC2's
// clock alteration shenanigans // clock alteration shenanigans
this.tic_counter = 0; this.tic_counter = 0;
@ -349,10 +351,25 @@ export class Level {
} }
// Commit the undo state at the end of each tic // Commit the undo state at the end of each tic
this.commit(); if (!this.waiting_for_input) {
this.commit();
}
} }
_advance_tic(p1_primary_direction, p1_secondary_direction) { _advance_tic(p1_primary_direction, p1_secondary_direction) {
var skip_to_third_pass = false;
//if we're waiting for input, then we want to skip straight to phase 3 with a player decision filled out when they have one ready
if (this.waiting_for_input) {
this.actor_decision(this.player, p1_primary_direction);
if (this.player.decision != null) {
skip_to_third_pass = true;
}
else {
return;
}
}
// Player's secondary direction is set immediately; it applies on arrival to cells even if // 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 // it wasn't held the last time the player started moving
this._set_prop(this.player, 'secondary_direction', p1_secondary_direction); this._set_prop(this.player, 'secondary_direction', p1_secondary_direction);
@ -370,212 +387,69 @@ export class Level {
// arrival as its own mini pass, for one reason: if the player dies (which will end the game // arrival as its own mini pass, for one reason: if the player dies (which will end the game
// immediately), we still want every time's animation to finish, or it'll look like some // immediately), we still want every time's animation to finish, or it'll look like some
// objects move backwards when the death screen appears! // objects move backwards when the death screen appears!
let cell_steppers = []; if (!skip_to_third_pass) {
for (let actor of this.actors) { let cell_steppers = [];
// Actors with no cell were destroyed for (let actor of this.actors) {
if (! actor.cell) // Actors with no cell were destroyed
continue; if (! actor.cell)
// Clear any old decisions ASAP. Note that this prop is only used internally within a
// single tic, so it doesn't need to be undoable
actor.decision = null;
// Decrement the cooldown here, but don't check it quite yet,
// because stepping on cells in the next block might reset it
if (actor.movement_cooldown > 0) {
this._set_prop(actor, 'movement_cooldown', actor.movement_cooldown - 1);
}
if (actor.animation_speed) {
// Deal with movement animation
this._set_prop(actor, 'animation_progress', actor.animation_progress + 1);
if (actor.animation_progress >= actor.animation_speed) {
if (actor.type.ttl) {
// This is purely an animation so it disappears once it's played
this.remove_tile(actor);
continue;
}
this._set_prop(actor, 'previous_cell', null);
this._set_prop(actor, 'animation_progress', null);
this._set_prop(actor, 'animation_speed', null);
if (! this.compat.tiles_react_instantly) {
// We need to track the actor AND the cell explicitly, because it's possible
// that one actor's step will cause another actor to start another move, and
// then they'd end up stepping on the new cell they're moving to instead of
// the one they just landed on!
cell_steppers.push([actor, actor.cell]);
}
}
}
}
for (let [actor, cell] of cell_steppers) {
this.step_on_cell(actor, cell);
}
// Only reset the player's is_pushing between movement, so it lasts for the whole push
if (this.player.movement_cooldown <= 0) {
this.player.is_pushing = false;
}
// Second pass: actors decide their upcoming movement simultaneously
for (let actor of this.actors) {
if (! actor.cell)
continue;
if (actor.movement_cooldown > 0)
continue;
// XXX does the cooldown drop while in a trap? is this even right?
if (actor.stuck && ! actor.type.is_player)
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)
{
continue;
}
let direction_preference;
// Actors can't make voluntary moves on ice, so they're stuck with
// whatever they've got
if (actor.slide_mode === 'ice') {
direction_preference = [actor.direction];
}
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)
{
direction_preference = [p1_primary_direction];
this._set_prop(actor, 'last_move_was_force', false);
}
else {
direction_preference = [actor.direction];
if (actor === this.player) {
this._set_prop(actor, 'last_move_was_force', true);
}
}
}
else if (actor === this.player) {
if (p1_primary_direction) {
direction_preference = [p1_primary_direction];
this._set_prop(actor, 'last_move_was_force', false);
}
}
else if (actor.type.movement_mode === 'forward') {
// blue tank behavior: keep moving forward
direction_preference = [actor.direction];
}
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
// TODO unclear if this is right in cc2 as well. definitely not in ms, which chooses a legal move
direction_preference = [actor.direction, ['north', 'south', 'east', 'west'][Math.floor(Math.random() * 4)]];
}
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
// TODO cc2 has twiddles for how this works per-level, as well as the initial seed for demo playback
direction_preference = [['north', 'south', 'east', 'west'][Math.floor(Math.random() * 4)]];
}
// Check which of those directions we *can*, probably, move in
// TODO i think player on force floor will still have some issues here
if (direction_preference) {
// Players and sliding actors always move the way they want, even if blocked
if (actor.type.is_player || actor.slide_mode) {
actor.decision = direction_preference[0];
continue; continue;
// Clear any old decisions ASAP. Note that this prop is only used internally within a
// single tic, so it doesn't need to be undoable
actor.decision = null;
// Decrement the cooldown here, but don't check it quite yet,
// because stepping on cells in the next block might reset it
if (actor.movement_cooldown > 0) {
this._set_prop(actor, 'movement_cooldown', actor.movement_cooldown - 1);
} }
for (let direction of direction_preference) { if (actor.animation_speed) {
let dest_cell = this.cell_with_offset(actor.cell, direction); // Deal with movement animation
if (! dest_cell) this._set_prop(actor, 'animation_progress', actor.animation_progress + 1);
continue; if (actor.animation_progress >= actor.animation_speed) {
if (actor.type.ttl) {
if (! actor.cell.blocks_leaving(actor, direction) && // This is purely an animation so it disappears once it's played
! dest_cell.blocks_entering(actor, direction, this, true)) this.remove_tile(actor);
{ continue;
// We found a good direction! Stop here }
actor.decision = direction; this._set_prop(actor, 'previous_cell', null);
break; this._set_prop(actor, 'animation_progress', null);
this._set_prop(actor, 'animation_speed', null);
if (! this.compat.tiles_react_instantly) {
// We need to track the actor AND the cell explicitly, because it's possible
// that one actor's step will cause another actor to start another move, and
// then they'd end up stepping on the new cell they're moving to instead of
// the one they just landed on!
cell_steppers.push([actor, actor.cell]);
}
} }
} }
} }
for (let [actor, cell] of cell_steppers) {
this.step_on_cell(actor, cell);
}
// Only reset the player's is_pushing between movement, so it lasts for the whole push
if (this.player.movement_cooldown <= 0) {
this.player.is_pushing = false;
}
// Second pass: actors decide their upcoming movement simultaneously
for (let actor of this.actors) {
this.actor_decision(actor, p1_primary_direction);
}
}
//in Turn-Based mode, wait for input if the player can voluntarily move on tic_counter % 4 == 0 and isn't
if (this.turn_based && this.player.movement_cooldown == 0 && this.player.decision == null && this.tic_counter % 4 == 0)
{
this.waiting_for_input = true;
return;
}
else
{
this.waiting_for_input = false;
} }
// Third pass: everyone actually moves // Third pass: everyone actually moves
@ -664,6 +538,169 @@ export class Level {
}); });
} }
} }
actor_decision(actor, p1_primary_direction) {
if (! actor.cell)
return;
if (actor.movement_cooldown > 0)
return;
// XXX does the cooldown drop while in a trap? is this even right?
if (actor.stuck && ! actor.type.is_player)
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;
// Actors can't make voluntary moves on ice, so they're stuck with
// whatever they've got
if (actor.slide_mode === 'ice') {
direction_preference = [actor.direction];
}
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 || this.turn_based) &&
actor.last_move_was_force)
{
if (p1_primary_direction != null)
{
direction_preference = [p1_primary_direction];
this._set_prop(actor, 'last_move_was_force', false);
}
}
else {
direction_preference = [actor.direction];
if (actor === this.player) {
this._set_prop(actor, 'last_move_was_force', true);
}
}
}
else if (actor === this.player) {
if (p1_primary_direction) {
direction_preference = [p1_primary_direction];
this._set_prop(actor, 'last_move_was_force', false);
}
}
else if (actor.type.movement_mode === 'forward') {
// blue tank behavior: keep moving forward
direction_preference = [actor.direction];
}
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
// TODO unclear if this is right in cc2 as well. definitely not in ms, which chooses a legal move
direction_preference = [actor.direction, ['north', 'south', 'east', 'west'][Math.floor(Math.random() * 4)]];
}
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
// TODO cc2 has twiddles for how this works per-level, as well as the initial seed for demo playback
direction_preference = [['north', 'south', 'east', 'west'][Math.floor(Math.random() * 4)]];
}
// Check which of those directions we *can*, probably, move in
// TODO i think player on force floor will still have some issues here
if (direction_preference) {
// Players and sliding actors always move the way they want, even if blocked
if (actor.type.is_player || actor.slide_mode) {
actor.decision = direction_preference[0];
return;
}
for (let direction of direction_preference) {
let dest_cell = this.cell_with_offset(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;
}
}
}
}
// Try to move the given actor one tile in the given direction and update // Try to move the given actor one tile in the given direction and update
// their cooldown. Return true if successful. // their cooldown. Return true if successful.
@ -945,6 +982,7 @@ export class Level {
} }
undo() { undo() {
this.waiting_for_input = false;
this.aid = Math.max(1, this.aid); this.aid = Math.max(1, this.aid);
let entry = this.undo_stack.pop(); let entry = this.undo_stack.pop();

View File

@ -397,6 +397,13 @@ class Player extends PrimaryView {
this.music_audio_el.pause(); 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;
this.level.turn_based = this.turn_based;
});
// Bind buttons // Bind buttons
this.pause_button = this.root.querySelector('.controls .control-pause'); this.pause_button = this.root.querySelector('.controls .control-pause');
@ -809,9 +816,15 @@ class Player extends PrimaryView {
// TODO this is not gonna be right while pausing lol // TODO this is not gonna be right while pausing lol
// TODO i'm not sure it'll be right when rewinding either // TODO i'm not sure it'll be right when rewinding either
// TODO or if the game's speed changes. wow! // 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.level.waiting_for_input) {
if (this.state === 'rewinding') { this.last_advance = performance.now();
this.tic_offset = 1 - this.tic_offset; }
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(); this._redraw();