diff --git a/index.html b/index.html index ea5bf21..b805381 100644 --- a/index.html +++ b/index.html @@ -120,6 +120,7 @@ + Turn-Based
diff --git a/js/game.js b/js/game.js index df2f551..88a97e9 100644 --- a/js/game.js +++ b/js/game.js @@ -158,6 +158,7 @@ export class Level { this.height = stored_level.size_y; this.size_x = stored_level.size_x; this.size_y = stored_level.size_y; + this.turn_based = false; this.restart(compat); } @@ -185,7 +186,8 @@ export class Level { else { 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 // clock alteration shenanigans this.tic_counter = 0; @@ -349,10 +351,25 @@ export class Level { } // 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) { + 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 // it wasn't held the last time the player started moving 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 // immediately), we still want every time's animation to finish, or it'll look like some // objects move backwards when the death screen appears! - let cell_steppers = []; - for (let actor of this.actors) { - // Actors with no cell were destroyed - if (! actor.cell) - 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); - } - - 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]; + if (!skip_to_third_pass) { + let cell_steppers = []; + for (let actor of this.actors) { + // Actors with no cell were destroyed + if (! actor.cell) 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) { - 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; + 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) { + 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 @@ -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 // their cooldown. Return true if successful. @@ -945,6 +982,7 @@ export class Level { } undo() { + this.waiting_for_input = false; this.aid = Math.max(1, this.aid); let entry = this.undo_stack.pop(); diff --git a/js/main.js b/js/main.js index 8c865e2..773a2a8 100644 --- a/js/main.js +++ b/js/main.js @@ -397,6 +397,13 @@ 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; + this.level.turn_based = this.turn_based; + }); // Bind buttons 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 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.level.waiting_for_input) { + this.last_advance = performance.now(); + } + 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();