Merge pull request #3 from Patashu/master

Implement Turn-Based Mode
This commit is contained in:
Eevee 2020-11-02 15:39:06 -07:00 committed by GitHub
commit bf74530aa2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 322 additions and 206 deletions

View File

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

View File

@ -197,7 +197,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;
@ -360,6 +360,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
@ -404,14 +409,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) {
@ -422,11 +439,13 @@ export class Level {
}
}
// Commit the undo state at the end of each tic
// 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);
@ -501,21 +520,123 @@ 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 != this.player)
{
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];
if (! actor.cell)
continue;
// Check this again, because one actor's movement might caused a later actor to move
// (e.g. by pressing a red or brown button)
if (actor.movement_cooldown > 0)
continue;
if (! actor.decision)
continue;
let old_cell = actor.cell;
let success = this.attempt_step(actor, actor.decision);
// Track whether the player is blocked, for visual effect
if (actor === this.player && p1_primary_direction && ! success) {
this.sfx.play_once('blocked');
actor.is_blocked = true;
}
// Players can also bump the tiles in the cell next to the one they're leaving
let dir2 = actor.secondary_direction;
if (actor.type.is_player && dir2 &&
! old_cell.blocks_leaving(actor, dir2))
{
let neighbor = this.get_neighboring_cell(old_cell, dir2);
if (neighbor) {
let could_push = ! neighbor.blocks_entering(actor, dir2, this, true);
for (let tile of Array.from(neighbor)) {
if (tile.type.on_bump) {
tile.type.on_bump(tile, this, actor);
}
if (could_push && actor.can_push(tile)) {
// Block slapping: you can shove a block by walking past it sideways
// TODO i think cc2 uses the push pose and possibly even turns you here?
this.attempt_step(tile, dir2);
}
}
}
}
}
// Strip out any destroyed actors from the acting order
// FIXME this is O(n), where n is /usually/ small, but i still don't love it
let p = 0;
for (let i = 0, l = this.actors.length; i < l; i++) {
let actor = this.actors[i];
if (actor.cell) {
if (p !== i) {
this.actors[p] = actor;
}
p++;
}
else {
let local_p = p;
this.pending_undo.push(() => this.actors.splice(local_p, 0, actor));
}
}
this.actors.length = p;
// Advance the clock
let tic_counter = this.tic_counter;
this.tic_counter += 1;
if (this.time_remaining !== null && ! this.timer_paused) {
let time_remaining = this.time_remaining;
this.pending_undo.push(() => {
this.tic_counter = tic_counter;
this.time_remaining = time_remaining;
});
this.time_remaining -= 1;
if (this.time_remaining <= 0) {
this.fail('time');
}
else if (this.time_remaining % 20 === 0 && this.time_remaining < 30 * 20) {
this.sfx.play_once('tick');
}
}
else {
this.pending_undo.push(() => {
this.tic_counter = tic_counter;
});
}
}
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)
{
continue;
return;
}
let direction_preference;
@ -529,12 +650,12 @@ export class Level {
if (actor.pending_push) {
actor.decision = actor.pending_push;
this._set_prop(actor, 'pending_push', null);
continue;
return;
}
else if (actor.slide_mode === 'ice') {
if (actor.slide_mode === 'ice') {
// Actors can't make voluntary moves on ice; they just slide
actor.decision = actor.direction;
continue;
return;
}
else if (actor.slide_mode === 'force') {
// Only the player can make voluntary moves on a force floor,
@ -556,14 +677,14 @@ export class Level {
this._set_prop(actor, 'last_move_was_force', true);
}
}
continue;
return;
}
else if (actor === this.player) {
if (p1_primary_direction) {
actor.decision = p1_primary_direction;
this._set_prop(actor, 'last_move_was_force', false);
}
continue;
return;
}
else if (actor.type.movement_mode === 'forward') {
// blue tank behavior: keep moving forward, reverse if the flag is set
@ -578,7 +699,7 @@ export class Level {
if (! actor.cell.some(tile => tile.type.name === 'cloner')) {
actor.decision = direction;
}
continue;
return;
}
else if (actor.type.movement_mode === 'follow-left') {
// bug behavior: always try turning as left as possible, and
@ -694,94 +815,6 @@ export class Level {
}
}
// Third pass: everyone actually moves
for (let i = this.actors.length - 1; i >= 0; i--) {
let actor = this.actors[i];
if (! actor.cell)
continue;
// Check this again, because one actor's movement might caused a later actor to move
// (e.g. by pressing a red or brown button)
if (actor.movement_cooldown > 0)
continue;
if (! actor.decision)
continue;
let old_cell = actor.cell;
let success = this.attempt_step(actor, actor.decision);
// Track whether the player is blocked, for visual effect
if (actor === this.player && p1_primary_direction && ! success) {
this.sfx.play_once('blocked');
actor.is_blocked = true;
}
// Players can also bump the tiles in the cell next to the one they're leaving
let dir2 = actor.secondary_direction;
if (actor.type.is_player && dir2 &&
! old_cell.blocks_leaving(actor, dir2))
{
let neighbor = this.get_neighboring_cell(old_cell, dir2);
if (neighbor) {
let could_push = ! neighbor.blocks_entering(actor, dir2, this, true);
for (let tile of Array.from(neighbor)) {
if (tile.type.on_bump) {
tile.type.on_bump(tile, this, actor);
}
if (could_push && actor.can_push(tile)) {
// Block slapping: you can shove a block by walking past it sideways
// TODO i think cc2 uses the push pose and possibly even turns you here?
this.attempt_step(tile, dir2);
}
}
}
}
}
// Strip out any destroyed actors from the acting order
// FIXME this is O(n), where n is /usually/ small, but i still don't love it
let p = 0;
for (let i = 0, l = this.actors.length; i < l; i++) {
let actor = this.actors[i];
if (actor.cell) {
if (p !== i) {
this.actors[p] = actor;
}
p++;
}
else {
let local_p = p;
this.pending_undo.push(() => this.actors.splice(local_p, 0, actor));
}
}
this.actors.length = p;
// Advance the clock
let tic_counter = this.tic_counter;
this.tic_counter += 1;
if (this.time_remaining !== null && ! this.timer_paused) {
let time_remaining = this.time_remaining;
this.pending_undo.push(() => {
this.tic_counter = tic_counter;
this.time_remaining = time_remaining;
});
this.time_remaining -= 1;
if (this.time_remaining <= 0) {
this.fail('time');
}
else if (this.time_remaining % 20 === 0 && this.time_remaining < 30 * 20) {
this.sfx.play_once('tick');
}
}
else {
this.pending_undo.push(() => {
this.tic_counter = tic_counter;
});
}
}
// Try to move the given actor one tile in the given direction and update
// their cooldown. Return true if successful.
attempt_step(actor, direction) {
@ -1256,6 +1289,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();

View File

@ -243,6 +243,8 @@ class Player extends PrimaryView {
ArrowRight: 'right',
ArrowUp: 'up',
ArrowDown: 'down',
Spacebar: 'wait',
" ": 'wait',
w: 'up',
a: 'left',
s: 'down',
@ -305,6 +307,12 @@ class Player extends PrimaryView {
}
});
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');
this.pause_button.addEventListener('click', ev => {
@ -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,8 +438,10 @@ class Player extends PrimaryView {
}
else {
// Restart
if (!this.current_keys.has(ev.key)) {
this.restart_level();
}
}
return;
}
// Don't scroll pls
@ -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,6 +706,10 @@ 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);
}
@ -700,6 +717,8 @@ class Player extends PrimaryView {
}
}
waiting_for_input = false;
advance_by(tics) {
for (let i = 0; i < tics; i++) {
let input = this.get_input();
@ -762,10 +781,43 @@ class Player extends PrimaryView {
this.previous_input = input;
this.sfx_player.advance_tic();
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(
this.primary_action ? ACTION_DIRECTIONS[this.primary_action] : null,
this.secondary_action ? ACTION_DIRECTIONS[this.secondary_action] : null,
);
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!
@ -785,6 +837,7 @@ class Player extends PrimaryView {
}
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,10 +883,16 @@ 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!
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);
}

View File

@ -61,13 +61,15 @@ export class CanvasRenderer {
dx * tw, dy * th, w * tw, h * th);
}
waiting_for_input = false;
draw(tic_offset = 0) {
if (! this.level) {
console.warn("CanvasRenderer.draw: No level to render");
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);