Move input handling into Level and clean it up a ton; add a bulk test gizmo
This commit is contained in:
parent
189ab96e3c
commit
f3f73a5e41
@ -55,9 +55,10 @@
|
|||||||
<header id="header-pack">
|
<header id="header-pack">
|
||||||
<h2 id="level-pack-name">Chip's Challenge Level Pack 1</h2>
|
<h2 id="level-pack-name">Chip's Challenge Level Pack 1</h2>
|
||||||
<nav>
|
<nav>
|
||||||
|
<button id="main-test-pack" type="button">Bulk test</button>
|
||||||
<button id="main-change-pack" type="button">Change pack</button>
|
<button id="main-change-pack" type="button">Change pack</button>
|
||||||
<button id="player-edit" type="button">Edit</button>
|
<button id="player-edit" type="button">Edit</button>
|
||||||
<button id="editor-play" type="button">Test</button>
|
<button id="editor-play" type="button">Play</button>
|
||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
<header id="header-level">
|
<header id="header-level">
|
||||||
|
|||||||
@ -49,6 +49,8 @@ export const INPUT_BITS = {
|
|||||||
up: 0x10,
|
up: 0x10,
|
||||||
swap: 0x20,
|
swap: 0x20,
|
||||||
cycle: 0x40,
|
cycle: 0x40,
|
||||||
|
// Not real input; used to force advancement for turn-based mode
|
||||||
|
wait: 0x8000,
|
||||||
};
|
};
|
||||||
|
|
||||||
// TODO cc2 order is: swivel, thinwalls, canopy (and yes you can have them all in the same tile)
|
// TODO cc2 order is: swivel, thinwalls, canopy (and yes you can have them all in the same tile)
|
||||||
|
|||||||
196
js/game.js
196
js/game.js
@ -1,4 +1,4 @@
|
|||||||
import { DIRECTIONS, TICS_PER_SECOND } from './defs.js';
|
import { DIRECTIONS, DIRECTION_ORDER, INPUT_BITS, TICS_PER_SECOND } from './defs.js';
|
||||||
import TILE_TYPES from './tiletypes.js';
|
import TILE_TYPES from './tiletypes.js';
|
||||||
|
|
||||||
export class Tile {
|
export class Tile {
|
||||||
@ -390,6 +390,7 @@ export class Level {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
// TODO complain if no player
|
// TODO complain if no player
|
||||||
|
// FIXME this is not how multiple players works
|
||||||
this.player = this.players[0];
|
this.player = this.players[0];
|
||||||
this.player_index = 0;
|
this.player_index = 0;
|
||||||
// Used for doppelgangers
|
// Used for doppelgangers
|
||||||
@ -518,18 +519,18 @@ export class Level {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Move the game state forwards by one tic.
|
// Move the game state forwards by one tic.
|
||||||
// FIXME i have absolutely definitely broken turn-based mode
|
// Input is a bit mask of INPUT_BITS.
|
||||||
advance_tic(p1_actions) {
|
advance_tic(p1_input) {
|
||||||
if (this.state !== 'playing') {
|
if (this.state !== 'playing') {
|
||||||
console.warn(`Level.advance_tic() called when state is ${this.state}`);
|
console.warn(`Level.advance_tic() called when state is ${this.state}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.begin_tic(p1_actions);
|
this.begin_tic(p1_input);
|
||||||
this.finish_tic(p1_actions);
|
this.finish_tic(p1_input);
|
||||||
}
|
}
|
||||||
|
|
||||||
begin_tic(p1_actions) {
|
begin_tic(p1_input) {
|
||||||
// Store some current level state in the undo entry. (These will often not be modified, but
|
// Store some current level state in the undo entry. (These will often not be modified, but
|
||||||
// they only take a few bytes each so that's fine.)
|
// they only take a few bytes each so that's fine.)
|
||||||
for (let key of [
|
for (let key of [
|
||||||
@ -602,7 +603,7 @@ export class Level {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
finish_tic(p1_actions) {
|
finish_tic(p1_input) {
|
||||||
// SECOND PASS: actors decide their upcoming movement simultaneously
|
// SECOND PASS: actors decide their upcoming movement simultaneously
|
||||||
for (let i = this.actors.length - 1; i >= 0; i--) {
|
for (let i = this.actors.length - 1; i >= 0; i--) {
|
||||||
let actor = this.actors[i];
|
let actor = this.actors[i];
|
||||||
@ -618,7 +619,7 @@ export class Level {
|
|||||||
continue;
|
continue;
|
||||||
|
|
||||||
if (actor === this.player) {
|
if (actor === this.player) {
|
||||||
this.make_player_decision(actor, p1_actions);
|
this.make_player_decision(actor, p1_input);
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
this.make_actor_decision(actor);
|
this.make_actor_decision(actor);
|
||||||
@ -638,13 +639,13 @@ export class Level {
|
|||||||
|
|
||||||
// Check for special player actions, which can only happen when not moving
|
// Check for special player actions, which can only happen when not moving
|
||||||
if (actor === this.player) {
|
if (actor === this.player) {
|
||||||
if (p1_actions.cycle) {
|
if (p1_input & INPUT_BITS.cycle) {
|
||||||
this.cycle_inventory(this.player);
|
this.cycle_inventory(this.player);
|
||||||
}
|
}
|
||||||
if (p1_actions.drop) {
|
if (p1_input & INPUT_BITS.drop) {
|
||||||
this.drop_item(this.player);
|
this.drop_item(this.player);
|
||||||
}
|
}
|
||||||
if (p1_actions.swap) {
|
if (p1_input & INPUT_BITS.swap) {
|
||||||
// This is delayed until the end of the tic to avoid screwing up anything
|
// This is delayed until the end of the tic to avoid screwing up anything
|
||||||
// checking this.player
|
// checking this.player
|
||||||
swap_player1 = true;
|
swap_player1 = true;
|
||||||
@ -674,6 +675,8 @@ export class Level {
|
|||||||
|
|
||||||
// Handle wiring, now that a bunch of buttons may have been pressed. Do it three times,
|
// Handle wiring, now that a bunch of buttons may have been pressed. Do it three times,
|
||||||
// because CC2 runs it once per frame, not once per tic
|
// because CC2 runs it once per frame, not once per tic
|
||||||
|
// FIXME not sure this is close enough to emulate cc2; might need one after cooldown pass,
|
||||||
|
// then two more here??
|
||||||
this.update_wiring();
|
this.update_wiring();
|
||||||
this.update_wiring();
|
this.update_wiring();
|
||||||
this.update_wiring();
|
this.update_wiring();
|
||||||
@ -728,81 +731,140 @@ export class Level {
|
|||||||
|
|
||||||
// TODO player in a cloner can't move (but player in a trap can still turn)
|
// TODO player in a cloner can't move (but player in a trap can still turn)
|
||||||
|
|
||||||
// The player is unusual in several ways.
|
// Extract directions from the input mask
|
||||||
// - Only the current player can override a force floor (and only if their last move was an
|
let dir1 = null, dir2 = null;
|
||||||
// involuntary force floor slide, perhaps before some number of ice slides).
|
if (((input & INPUT_BITS['up']) && (input & INPUT_BITS['down'])) ||
|
||||||
// - The player "block slaps", a phenomenon where they physically attempt to make both of
|
((input & INPUT_BITS['left']) && (input & INPUT_BITS['right'])))
|
||||||
// their desired movements, having an impact on the world if appropriate, before deciding
|
|
||||||
// which of them to use
|
|
||||||
let direction_preference = [];
|
|
||||||
if (actor.slide_mode && ! (
|
|
||||||
actor.slide_mode === 'force' &&
|
|
||||||
input.primary !== null && actor.last_move_was_force))
|
|
||||||
{
|
{
|
||||||
direction_preference.push(actor.direction);
|
// If two opposing directions are held at the same time, all input is ignored, so we
|
||||||
|
// can't end up with more than 2 directions
|
||||||
if (actor.slide_mode === 'force') {
|
|
||||||
this._set_tile_prop(actor, 'last_move_was_force', true);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
// FIXME this isn't right; if primary is blocked, they move secondary, but they also
|
for (let [direction, dirinfo] of Object.entries(DIRECTIONS)) {
|
||||||
// ignore railroad redirection until next tic
|
if (input & INPUT_BITS[dirinfo.action]) {
|
||||||
this.remember_player_move(input.primary);
|
if (dir1 === null) {
|
||||||
|
dir1 = direction;
|
||||||
if (input.primary) {
|
}
|
||||||
// FIXME something is wrong with direction preferences! if you hold both keys
|
else {
|
||||||
// in a corner, no matter which you pressed first, cc2 always tries vert first
|
dir2 = direction;
|
||||||
// and horiz last (so you're pushing horizontally)!
|
break;
|
||||||
// FIXME starting to think the game should just pass all the held keys down
|
}
|
||||||
// here; i have to repeat this check because the "step" phase may have changed
|
|
||||||
// our direction
|
|
||||||
// XXX if this is a slide override, and the override is into a wall, the slide
|
|
||||||
// direction becomes primary again; i think "slide bonk" happens to cover this at
|
|
||||||
// the moment, is that cromulent?
|
|
||||||
let d1 = input.primary, d2 = input.secondary;
|
|
||||||
if (d2 && d2 === actor.direction) {
|
|
||||||
[d1, d2] = [d2, d1];
|
|
||||||
}
|
}
|
||||||
direction_preference.push(d1);
|
|
||||||
if (d2) {
|
|
||||||
direction_preference.push(d2);
|
|
||||||
}
|
|
||||||
this._set_tile_prop(actor, 'last_move_was_force', false);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (direction_preference.length === 0)
|
let try_direction = (direction, push_mode) => {
|
||||||
return;
|
|
||||||
|
|
||||||
// Note that we do this even if only one direction is requested, meaning that we get a
|
|
||||||
// chance to push blocks before anything else has moved!
|
|
||||||
// TODO TW's lynx source has one exception to that rule: if there are two directions,
|
|
||||||
// and neither one is our current facing, then we only check the horizontal one!
|
|
||||||
let directions_ok = direction_preference.map(direction => {
|
|
||||||
direction = actor.cell.redirect_exit(actor, direction);
|
direction = actor.cell.redirect_exit(actor, direction);
|
||||||
let dest_cell = this.get_neighboring_cell(actor.cell, direction);
|
let dest_cell = this.get_neighboring_cell(actor.cell, direction);
|
||||||
return (dest_cell &&
|
return (dest_cell &&
|
||||||
! actor.cell.blocks_leaving(actor, direction) &&
|
! actor.cell.blocks_leaving(actor, direction) &&
|
||||||
// FIXME if the player steps into a monster cell here, they die instantly! but only
|
// FIXME if the player steps into a monster cell here, they die instantly! but only
|
||||||
// if the cell doesn't block them??
|
// if the cell doesn't block them??
|
||||||
! dest_cell.blocks_entering(actor, direction, this, 'move'));
|
! dest_cell.blocks_entering(actor, direction, this, push_mode));
|
||||||
});
|
};
|
||||||
|
|
||||||
if (directions_ok.length === 1) {
|
// The player is unusual in several ways.
|
||||||
actor.decision = direction_preference[0];
|
// - Only the current player can override a force floor (and only if their last move was an
|
||||||
|
// involuntary force floor slide, perhaps before some number of ice slides).
|
||||||
|
// - The player "block slaps", a phenomenon where they physically attempt to make both of
|
||||||
|
// their desired movements, having an impact on the world if appropriate, before deciding
|
||||||
|
// which of them to use.
|
||||||
|
// - These two properties combine in a subtle way. If we're on a force floor sliding right
|
||||||
|
// under a row of blue walls, then if we hold up, we will bump every wall along the way.
|
||||||
|
// If we hold up /and right/, we will only bump every other wall. That is, if we're on a
|
||||||
|
// force floor and attempt to override but /fail/, it's not held against us -- but if we
|
||||||
|
// succeed, even if overriding in the same direction we're already moving, that does count
|
||||||
|
// as an override.
|
||||||
|
let xxx_overriding = false;
|
||||||
|
if (actor.slide_mode && ! (
|
||||||
|
actor.slide_mode === 'force' &&
|
||||||
|
dir1 !== null && actor.last_move_was_force))
|
||||||
|
{
|
||||||
|
// This is a forced move, in which case we don't even check it
|
||||||
|
actor.decision = actor.direction;
|
||||||
|
|
||||||
|
if (actor.slide_mode === 'force') {
|
||||||
|
this._set_tile_prop(actor, 'last_move_was_force', true);
|
||||||
|
}
|
||||||
|
else if (actor.slide_mode === 'ice') {
|
||||||
|
// A sliding player that bonks into a wall still needs to turn around, but in this
|
||||||
|
// case they do NOT start pushing blocks early
|
||||||
|
if (! try_direction(actor.direction, 'trace')) {
|
||||||
|
this._handle_slide_bonk(actor);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
else if (! directions_ok[0] && directions_ok[1]) {
|
else if (dir1 === null) {
|
||||||
// Only turn if we're blocked in our current direction AND free in the other one
|
// Not attempting to move, so do nothing
|
||||||
actor.decision = direction_preference[1];
|
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
actor.decision = direction_preference[0];
|
// At this point, we have exactly 1 or 2 directions, and deciding between them requires
|
||||||
|
// checking which ones are blocked. Note that we do this even if only one direction is
|
||||||
|
// requested, meaning that we get to push blocks before anything else has moved!
|
||||||
|
let open;
|
||||||
|
if (dir2 === null) {
|
||||||
|
// Only one direction is held, but for consistency, "check" it anyway
|
||||||
|
open = try_direction(dir1, 'move');
|
||||||
|
actor.decision = dir1;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
// We have two directions. If one of them is our current facing, we prefer that
|
||||||
|
// one, UNLESS it's blocked AND the other isn't
|
||||||
|
if (dir1 === actor.direction || dir2 === actor.direction) {
|
||||||
|
let other_direction = dir1 === actor.direction ? dir2 : dir1;
|
||||||
|
let curr_open = try_direction(actor.direction, 'move');
|
||||||
|
let other_open = try_direction(other_direction, 'move');
|
||||||
|
if (! curr_open && other_open) {
|
||||||
|
actor.decision = other_direction;
|
||||||
|
open = true;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
actor.decision = actor.direction;
|
||||||
|
open = curr_open;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
// Neither direction is the way we're moving, so try both and prefer horizontal
|
||||||
|
// FIXME i'm told cc2 prefers orthogonal actually, but need to check on that
|
||||||
|
// FIXME lynx only checks horizontal, what about cc2? it must check both
|
||||||
|
// because of the behavior where pushing into a corner always pushes horizontal
|
||||||
|
let open1 = try_direction(dir1, 'move');
|
||||||
|
let open2 = try_direction(dir2, 'move');
|
||||||
|
if (open1 && ! open2) {
|
||||||
|
actor.decision = dir1;
|
||||||
|
open = true;
|
||||||
|
}
|
||||||
|
else if (! open1 && open2) {
|
||||||
|
actor.decision = dir2;
|
||||||
|
open = true;
|
||||||
|
}
|
||||||
|
else if (dir1 === 'east' || dir1 === 'west') {
|
||||||
|
actor.decision = dir1;
|
||||||
|
open = open1;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
actor.decision = dir2;
|
||||||
|
open = open2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we're overriding a force floor but the direction we're moving in is blocked, the
|
||||||
|
// force floor takes priority (and we've already bumped the wall(s))
|
||||||
|
if (actor.slide_mode === 'force' && ! open) {
|
||||||
|
actor.decision = actor.direction;
|
||||||
|
this._set_tile_prop(actor, 'last_move_was_force', true);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
// Otherwise this is 100% a conscious move so we lose our override power next tic
|
||||||
|
this._set_tile_prop(actor, 'last_move_was_force', false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (actor.slide_mode && ! directions_ok[0]) {
|
// Remember our choice for the sake of doppelgangers
|
||||||
this._handle_slide_bonk(actor);
|
// FIXME still a bit unclear on how they handle secondary direction, but i'm not sure that's
|
||||||
}
|
// even a real concept in lynx, so maybe this is right??
|
||||||
|
this.remember_player_move(actor.decision);
|
||||||
}
|
}
|
||||||
|
|
||||||
make_actor_decision(actor) {
|
make_actor_decision(actor) {
|
||||||
|
|||||||
270
js/main.js
270
js/main.js
@ -327,7 +327,7 @@ class Player extends PrimaryView {
|
|||||||
else {
|
else {
|
||||||
if (this.turn_mode === 2) {
|
if (this.turn_mode === 2) {
|
||||||
// Finish up the tic with dummy input
|
// Finish up the tic with dummy input
|
||||||
this.level.finish_tic({primary: null, secondary: null});
|
this.level.finish_tic(0);
|
||||||
this.advance_by(1);
|
this.advance_by(1);
|
||||||
}
|
}
|
||||||
this.turn_mode = 0;
|
this.turn_mode = 0;
|
||||||
@ -442,7 +442,6 @@ class Player extends PrimaryView {
|
|||||||
this.player_used_move = false;
|
this.player_used_move = false;
|
||||||
let key_target = document.body;
|
let key_target = document.body;
|
||||||
this.previous_input = new Set; // actions that were held last tic
|
this.previous_input = new Set; // actions that were held last tic
|
||||||
this.previous_action = null; // last direction we were moving, if any
|
|
||||||
this.using_touch = false; // true if using touch controls
|
this.using_touch = false; // true if using touch controls
|
||||||
this.current_keys = new Set; // keys that are currently held
|
this.current_keys = new Set; // keys that are currently held
|
||||||
this.current_keys_new = new Set; // keys that were pressed since input was last read
|
this.current_keys_new = new Set; // keys that were pressed since input was last read
|
||||||
@ -638,7 +637,7 @@ class Player extends PrimaryView {
|
|||||||
// Link up the debug panel and enable debug features
|
// Link up the debug panel and enable debug features
|
||||||
// (note that this might be called /before/ setup!)
|
// (note that this might be called /before/ setup!)
|
||||||
setup_debug() {
|
setup_debug() {
|
||||||
this.root.classList.add('--debug');
|
document.body.classList.add('--debug');
|
||||||
let debug_el = this.root.querySelector('#player-debug');
|
let debug_el = this.root.querySelector('#player-debug');
|
||||||
this.debug = {
|
this.debug = {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
@ -1069,30 +1068,26 @@ class Player extends PrimaryView {
|
|||||||
get_input() {
|
get_input() {
|
||||||
let input;
|
let input;
|
||||||
if (this.debug && this.debug.replay && ! this.debug.replay_recording) {
|
if (this.debug && this.debug.replay && ! this.debug.replay_recording) {
|
||||||
let mask = this.debug.replay.get(this.level.tic_counter);
|
input = this.debug.replay.get(this.level.tic_counter);
|
||||||
input = new Set(Object.entries(INPUT_BITS).filter(([action, bit]) => mask & bit).map(([action, bit]) => action));
|
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
// Convert input keys to actions. This is only done now
|
// Convert input keys to actions
|
||||||
// because there might be multiple keys bound to one
|
input = 0;
|
||||||
// action, and it still counts as pressed as long as at
|
|
||||||
// least one key is held
|
|
||||||
input = new Set;
|
|
||||||
for (let key of this.current_keys) {
|
for (let key of this.current_keys) {
|
||||||
input.add(this.key_mapping[key]);
|
input |= INPUT_BITS[this.key_mapping[key]];
|
||||||
}
|
}
|
||||||
for (let key of this.current_keys_new) {
|
for (let key of this.current_keys_new) {
|
||||||
input.add(this.key_mapping[key]);
|
input |= INPUT_BITS[this.key_mapping[key]];
|
||||||
}
|
}
|
||||||
this.current_keys_new.clear();
|
this.current_keys_new.clear();
|
||||||
for (let action of Object.values(this.current_touches)) {
|
for (let action of Object.values(this.current_touches)) {
|
||||||
input.add(action);
|
input |= INPUT_BITS[action];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.debug.enabled) {
|
if (this.debug.enabled) {
|
||||||
for (let [action, el] of Object.entries(this.debug.input_els)) {
|
for (let [action, el] of Object.entries(this.debug.input_els)) {
|
||||||
el.classList.toggle('--held', input.has(action));
|
el.classList.toggle('--held', (input & INPUT_BITS[action]) !== 0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1101,62 +1096,18 @@ class Player extends PrimaryView {
|
|||||||
|
|
||||||
advance_by(tics) {
|
advance_by(tics) {
|
||||||
for (let i = 0; i < tics; i++) {
|
for (let i = 0; i < tics; i++) {
|
||||||
|
// FIXME turn-based mode should be disabled during a replay
|
||||||
let input = this.get_input();
|
let input = this.get_input();
|
||||||
|
// Extract the fake 'wait' bit, if any
|
||||||
|
let wait = input & INPUT_BITS['wait'];
|
||||||
|
input &= ~wait;
|
||||||
|
|
||||||
if (this.debug && this.debug.replay && this.debug.replay_recording) {
|
if (this.debug && this.debug.replay && this.debug.replay_recording) {
|
||||||
let input_mask = 0;
|
this.debug.replay.set(this.level.tic_counter, input);
|
||||||
for (let [action, bit] of Object.entries(INPUT_BITS)) {
|
|
||||||
if (input.has(action)) {
|
|
||||||
input_mask |= bit;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
this.debug.replay.set(this.level.tic_counter, input_mask);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Replica of CC2 input handling, based on experimentation
|
|
||||||
let primary_action = null, secondary_action = null;
|
|
||||||
let current_action = DIRECTIONS[this.level.player.direction].action;
|
|
||||||
if ((input.has('up') && input.has('down')) || (input.has('left') && input.has('right'))) {
|
|
||||||
// If opposing keys are ever held, stop moving and forget our state
|
|
||||||
primary_action = null;
|
|
||||||
secondary_action = null;
|
|
||||||
}
|
|
||||||
else if (input.has(current_action)) {
|
|
||||||
// If we're already holding in the same direction we're facing, that wins
|
|
||||||
primary_action = current_action;
|
|
||||||
// Any other key we're holding is secondary; remember, there can't be two opposing
|
|
||||||
// keys held, because we just checked for that, so at most one of these qualifies
|
|
||||||
for (let action of ['down', 'left', 'right', 'up']) {
|
|
||||||
if (action !== primary_action && input.has(action)) {
|
|
||||||
secondary_action = action;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
// Check for other keys, horizontal first
|
|
||||||
for (let action of ['left', 'right', 'up', 'down']) {
|
|
||||||
if (! input.has(action))
|
|
||||||
continue;
|
|
||||||
|
|
||||||
if (primary_action === null) {
|
|
||||||
primary_action = action;
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
secondary_action = action;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let player_actions = {
|
|
||||||
primary: primary_action ? ACTION_DIRECTIONS[primary_action] : null,
|
|
||||||
secondary: secondary_action ? ACTION_DIRECTIONS[secondary_action] : null,
|
|
||||||
cycle: input.has('cycle') && ! this.previous_input.has('cycle'),
|
|
||||||
drop: input.has('drop') && ! this.previous_input.has('drop'),
|
|
||||||
swap: input.has('swap') && ! this.previous_input.has('swap'),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FIXME cycle/drop/swap depend on this but that's currently broken; should Level handle
|
||||||
|
// it? probably
|
||||||
this.previous_input = input;
|
this.previous_input = input;
|
||||||
|
|
||||||
this.sfx_player.advance_tic();
|
this.sfx_player.advance_tic();
|
||||||
@ -1167,14 +1118,14 @@ class Player extends PrimaryView {
|
|||||||
this.level.aid = Math.max(1, this.level.aid);
|
this.level.aid = Math.max(1, this.level.aid);
|
||||||
}
|
}
|
||||||
|
|
||||||
let has_input = input.has('wait') || Object.values(player_actions).some(x => x);
|
let has_input = wait || input;
|
||||||
// Turn-based mode complicates this slightly; it aligns us to the middle of a tic
|
// Turn-based mode complicates this slightly; it aligns us to the middle of a tic
|
||||||
if (this.turn_mode === 2) {
|
if (this.turn_mode === 2) {
|
||||||
if (has_input) {
|
if (has_input) {
|
||||||
// Finish the current tic, then continue as usual. This means the end of the
|
// Finish the current tic, then continue as usual. This means the end of the
|
||||||
// tic doesn't count against the number of tics to advance -- because it already
|
// tic doesn't count against the number of tics to advance -- because it already
|
||||||
// did, the first time we tried it
|
// did, the first time we tried it
|
||||||
this.level.finish_tic(player_actions);
|
this.level.finish_tic(input);
|
||||||
this.turn_mode = 1;
|
this.turn_mode = 1;
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
@ -1183,14 +1134,14 @@ class Player extends PrimaryView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// We should now be at the start of a tic
|
// We should now be at the start of a tic
|
||||||
this.level.begin_tic(player_actions);
|
this.level.begin_tic(input);
|
||||||
if (this.turn_mode > 0 && this.level.can_accept_input() && ! has_input) {
|
if (this.turn_mode > 0 && this.level.can_accept_input() && ! has_input) {
|
||||||
// If we're in turn-based mode and could provide input here, but don't have any,
|
// If we're in turn-based mode and could provide input here, but don't have any,
|
||||||
// then wait until we do
|
// then wait until we do
|
||||||
this.turn_mode = 2;
|
this.turn_mode = 2;
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
this.level.finish_tic(player_actions);
|
this.level.finish_tic(input);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.level.state !== 'playing') {
|
if (this.level.state !== 'playing') {
|
||||||
@ -2110,6 +2061,180 @@ class OptionsOverlay extends DialogOverlay {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class PackTestDialog extends DialogOverlay {
|
||||||
|
constructor(conductor) {
|
||||||
|
super(conductor);
|
||||||
|
this.root.classList.add('packtest-dialog');
|
||||||
|
this.set_title("full pack test");
|
||||||
|
this.button = mk('button', {type: 'button'}, "Begin test");
|
||||||
|
this.button.addEventListener('click', async ev => {
|
||||||
|
if (this._handle) {
|
||||||
|
this._handle.cancel = true;
|
||||||
|
this._handle = null;
|
||||||
|
ev.target.textContent = "Start";
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
this._handle = {cancel: false};
|
||||||
|
ev.target.textContent = "Abort";
|
||||||
|
await this.run(this._handle);
|
||||||
|
this._handle = null;
|
||||||
|
ev.target.textContent = "Start";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.results_summary = mk('ol.packtest-summary.packtest-colorcoded');
|
||||||
|
for (let i = 0; i < this.conductor.stored_game.level_metadata.length; i++) {
|
||||||
|
this.results_summary.append(mk('li'));
|
||||||
|
}
|
||||||
|
|
||||||
|
this.current_status = mk('p', "Ready");
|
||||||
|
|
||||||
|
this.results = mk('ol.packtest-results.packtest-colorcoded');
|
||||||
|
this.results.addEventListener('click', ev => {
|
||||||
|
let li = ev.target.closest('li');
|
||||||
|
if (! li)
|
||||||
|
return;
|
||||||
|
let index = li.getAttribute('data-index');
|
||||||
|
if (index === undefined)
|
||||||
|
return;
|
||||||
|
this.close();
|
||||||
|
this.conductor.change_level(index);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.main.append(
|
||||||
|
mk('p', "This will run the replay for every level in the current pack, as fast as possible, and report the results."),
|
||||||
|
mk('p', mk('strong', "This is an intensive process and may lag your browser!"), " Mostly intended for testing LL itself."),
|
||||||
|
mk('p', "Note that currently, only C2Ms with embedded replays are supported."),
|
||||||
|
mk('p', "(Results will be saved until you change packs.)"),
|
||||||
|
mk('hr'),
|
||||||
|
this.results_summary,
|
||||||
|
mk('div.packtest-row', this.current_status, this.button),
|
||||||
|
this.results,
|
||||||
|
);
|
||||||
|
|
||||||
|
this.add_button("close", () => {
|
||||||
|
this.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
this.renderer = new CanvasRenderer(this.conductor.tileset, 16);
|
||||||
|
}
|
||||||
|
|
||||||
|
async run(handle) {
|
||||||
|
this.results.textContent = '';
|
||||||
|
let pack = this.conductor.stored_game;
|
||||||
|
let dummy_sfx = {
|
||||||
|
set_player_position() {},
|
||||||
|
play() {},
|
||||||
|
play_once() {},
|
||||||
|
};
|
||||||
|
let num_levels = pack.level_metadata.length;
|
||||||
|
let num_passed = 0;
|
||||||
|
let total_tics = 0;
|
||||||
|
let t0 = performance.now();
|
||||||
|
let last_pause = t0;
|
||||||
|
for (let i = 0; i < num_levels; i++) {
|
||||||
|
let stored_level = pack.load_level(i);
|
||||||
|
let status_li = this.results_summary.childNodes[i];
|
||||||
|
let record_result = (token, title, comment, include_canvas) => {
|
||||||
|
status_li.setAttribute('data-status', token);
|
||||||
|
status_li.setAttribute('title', title);
|
||||||
|
let li = mk(
|
||||||
|
'li', {'data-status': token, 'data-index': i},
|
||||||
|
`#${i + 1} ${stored_level.title}: `,
|
||||||
|
comment);
|
||||||
|
if (include_canvas) {
|
||||||
|
let canvas = mk('canvas', {
|
||||||
|
width: this.renderer.canvas.width,
|
||||||
|
height: this.renderer.canvas.height,
|
||||||
|
});
|
||||||
|
this.renderer.set_level(level);
|
||||||
|
this.renderer.draw();
|
||||||
|
canvas.getContext('2d').drawImage(this.renderer.canvas, 0, 0, canvas.width, canvas.height);
|
||||||
|
li.append(canvas);
|
||||||
|
}
|
||||||
|
this.results.append(li);
|
||||||
|
|
||||||
|
total_tics += level.tic_counter;
|
||||||
|
};
|
||||||
|
if (! stored_level.has_replay) {
|
||||||
|
record_result('no-replay', "N/A", "No replay available");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.current_status.textContent = `Testing level ${i + 1}/${num_levels} ${stored_level.title}...`;
|
||||||
|
|
||||||
|
// TODO compat options here??
|
||||||
|
let replay = stored_level.replay;
|
||||||
|
let level = new Level(stored_level, {});
|
||||||
|
level.sfx = dummy_sfx;
|
||||||
|
level.force_floor_direction = replay.initial_force_floor_direction;
|
||||||
|
level._blob_modifier = replay.blob_seed;
|
||||||
|
|
||||||
|
try {
|
||||||
|
while (true) {
|
||||||
|
let input = replay.get(level.tic_counter);
|
||||||
|
level.advance_tic(input);
|
||||||
|
|
||||||
|
if (level.state === 'success') {
|
||||||
|
// TODO warn if exit early?
|
||||||
|
record_result(
|
||||||
|
'success', "Won",
|
||||||
|
`Exited successfully after ${util.format_duration(level.tic_counter / TICS_PER_SECOND)} (delta ${level.tic_counter - replay.duration})`);
|
||||||
|
num_passed += 1;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
else if (level.state === 'failure') {
|
||||||
|
record_result(
|
||||||
|
'failure', "Lost",
|
||||||
|
`Died at ${util.format_duration(level.tic_counter / TICS_PER_SECOND)} (tic ${level.tic_counter}/${replay.duration}, ${Math.floor(level.tic_counter / replay.duration * 100)}%)`,
|
||||||
|
true);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
else if (level.tic_counter >= replay.duration + 200) {
|
||||||
|
record_result(
|
||||||
|
'short', "Out of input",
|
||||||
|
`Replay completed without exiting; ran for ${util.format_duration(replay.duration / TICS_PER_SECOND)}, gave up after 10 more seconds`,
|
||||||
|
true);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (level.tic_counter % 20 === 1) {
|
||||||
|
if (handle.cancel) {
|
||||||
|
record_result(
|
||||||
|
'interrupted', "Interrupted",
|
||||||
|
"Interrupted");
|
||||||
|
this.current_status.textContent = `Interrupted on level ${i + 1}/${num_levels}; ${num_passed} passed`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Don't run for more than 50ms at a time, to avoid janking the browser...
|
||||||
|
// TOO much. I mean, we still want it to reflow the stuff we've added, but
|
||||||
|
// we also want to be pretty aggressive so this finishes quickly
|
||||||
|
let now = performance.now();
|
||||||
|
if (now - last_pause > 50) {
|
||||||
|
// TODO measure the impact this has
|
||||||
|
last_pause = now;
|
||||||
|
await util.sleep(5);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
record_result(
|
||||||
|
'error', "Error",
|
||||||
|
"Replay failed due to internal error (see console for traceback): ${e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let final_status = `Finished! Simulated ${util.format_duration(total_tics / TICS_PER_SECOND)} of play time in ${util.format_duration((performance.now() - t0) / 1000)}; ${num_passed}/${num_levels} levels passed`;
|
||||||
|
if (num_passed === num_levels) {
|
||||||
|
final_status += "! Congratulations! 🎆";
|
||||||
|
}
|
||||||
|
this.current_status.textContent = final_status;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// List of levels, used in the player
|
// List of levels, used in the player
|
||||||
class LevelBrowserOverlay extends DialogOverlay {
|
class LevelBrowserOverlay extends DialogOverlay {
|
||||||
constructor(conductor) {
|
constructor(conductor) {
|
||||||
@ -2271,6 +2396,12 @@ class Conductor {
|
|||||||
this.current.open_level_browser();
|
this.current.open_level_browser();
|
||||||
ev.target.blur();
|
ev.target.blur();
|
||||||
});
|
});
|
||||||
|
document.querySelector('#main-test-pack').addEventListener('click', ev => {
|
||||||
|
if (! this._pack_test_dialog) {
|
||||||
|
this._pack_test_dialog = new PackTestDialog(this);
|
||||||
|
}
|
||||||
|
this._pack_test_dialog.open();
|
||||||
|
});
|
||||||
document.querySelector('#main-change-pack').addEventListener('click', ev => {
|
document.querySelector('#main-change-pack').addEventListener('click', ev => {
|
||||||
// TODO confirm
|
// TODO confirm
|
||||||
this.switch_to_splash();
|
this.switch_to_splash();
|
||||||
@ -2336,6 +2467,7 @@ class Conductor {
|
|||||||
|
|
||||||
load_game(stored_game, identifier = null) {
|
load_game(stored_game, identifier = null) {
|
||||||
this.stored_game = stored_game;
|
this.stored_game = stored_game;
|
||||||
|
this._pack_test_dialog = null;
|
||||||
|
|
||||||
this._pack_identifier = identifier;
|
this._pack_identifier = identifier;
|
||||||
this.current_pack_savefile = null;
|
this.current_pack_savefile = null;
|
||||||
|
|||||||
@ -104,6 +104,12 @@ export function handle_drop(element, options) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function sleep(t) {
|
||||||
|
return new Promise(res => {
|
||||||
|
setTimeout(res, t);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function promise_event(element, success_event, failure_event) {
|
export function promise_event(element, success_event, failure_event) {
|
||||||
let resolve, reject;
|
let resolve, reject;
|
||||||
let promise = new Promise((res, rej) => {
|
let promise = new Promise((res, rej) => {
|
||||||
|
|||||||
81
style.css
81
style.css
@ -165,9 +165,9 @@ svg.svg-icon {
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
||||||
min-width: 33%;
|
min-width: 33vw;
|
||||||
max-width: 75%;
|
max-width: 75vw;
|
||||||
max-height: 75%;
|
max-height: 75vh;
|
||||||
border: 1px solid black;
|
border: 1px solid black;
|
||||||
color: black;
|
color: black;
|
||||||
background: #f4f4f4;
|
background: #f4f4f4;
|
||||||
@ -195,6 +195,7 @@ svg.svg-icon {
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
.dialog > section {
|
.dialog > section {
|
||||||
|
flex: auto;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
padding: 1em;
|
padding: 1em;
|
||||||
}
|
}
|
||||||
@ -545,6 +546,76 @@ button.level-pack-button p {
|
|||||||
color: #c0c0c0;
|
color: #c0c0c0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* "Bulk test" button, only available in debug mode */
|
||||||
|
#main-test-pack {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
body.--debug #main-test-pack {
|
||||||
|
display: initial;
|
||||||
|
}
|
||||||
|
.packtest-dialog {
|
||||||
|
width: 75vw;
|
||||||
|
height: 75vh;
|
||||||
|
}
|
||||||
|
ol.packtest-summary {
|
||||||
|
display: flex;
|
||||||
|
align-items: stretch;
|
||||||
|
height: 1em;
|
||||||
|
border: 1px solid #606060;
|
||||||
|
}
|
||||||
|
ol.packtest-summary > li {
|
||||||
|
/* Give a meaty flex-basis; the dialog has a max-width so it won't blow out, and these will
|
||||||
|
* simply shrink if necessary */
|
||||||
|
flex: 1 1 1em;
|
||||||
|
background: white;
|
||||||
|
}
|
||||||
|
.packtest-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5em;
|
||||||
|
align-items: center;
|
||||||
|
margin: 0.5em 0;
|
||||||
|
}
|
||||||
|
.packtest-row > p {
|
||||||
|
flex: 9;
|
||||||
|
margin: 0;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.packtest-row > button {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.packtest-results {
|
||||||
|
margin-bottom: 1em;
|
||||||
|
}
|
||||||
|
.packtest-results > li {
|
||||||
|
padding: 0.25em;
|
||||||
|
margin: 0.25em 0;
|
||||||
|
}
|
||||||
|
.packtest-results > li > canvas {
|
||||||
|
display: block;
|
||||||
|
margin: 0.5em auto;
|
||||||
|
}
|
||||||
|
ol.packtest-colorcoded > li[data-status=no-replay] {
|
||||||
|
background: hsl(0, 0%, 25%);
|
||||||
|
}
|
||||||
|
ol.packtest-colorcoded > li[data-status=running] {
|
||||||
|
background: hsl(30, 100%, 75%);
|
||||||
|
}
|
||||||
|
ol.packtest-colorcoded > li[data-status=success] {
|
||||||
|
background: hsl(120, 60%, 75%);
|
||||||
|
}
|
||||||
|
ol.packtest-colorcoded > li[data-status=failure] {
|
||||||
|
background: hsl(0, 60%, 60%);
|
||||||
|
}
|
||||||
|
ol.packtest-colorcoded > li[data-status=short] {
|
||||||
|
background: hsl(330, 60%, 75%);
|
||||||
|
}
|
||||||
|
ol.packtest-colorcoded > li[data-status=error] {
|
||||||
|
background: black;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
/**************************************************************************************************/
|
/**************************************************************************************************/
|
||||||
/* Player */
|
/* Player */
|
||||||
|
|
||||||
@ -626,7 +697,7 @@ button.level-pack-button p {
|
|||||||
text-shadow: 0 2px 1px black;
|
text-shadow: 0 2px 1px black;
|
||||||
}
|
}
|
||||||
/* Allow clicking through the overlay in debug mode */
|
/* Allow clicking through the overlay in debug mode */
|
||||||
#player.--debug .overlay-message {
|
body.--debug .overlay-message {
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
#player .overlay-message p {
|
#player .overlay-message p {
|
||||||
@ -858,7 +929,7 @@ dl.score-chart .-sum {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Debug stuff */
|
/* Debug stuff */
|
||||||
#player.--debug #player-debug {
|
body.--debug #player-debug {
|
||||||
display: flex;
|
display: flex;
|
||||||
}
|
}
|
||||||
#player-debug {
|
#player-debug {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user