Move input handling into Level and clean it up a ton; add a bulk test gizmo

This commit is contained in:
Eevee (Evelyn Woods) 2020-12-14 17:01:10 -07:00
parent 189ab96e3c
commit f3f73a5e41
6 changed files with 416 additions and 142 deletions

View File

@ -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">

View File

@ -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)

View File

@ -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,82 +731,141 @@ 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).
else if (! directions_ok[0] && directions_ok[1]) { // - The player "block slaps", a phenomenon where they physically attempt to make both of
// Only turn if we're blocked in our current direction AND free in the other one // their desired movements, having an impact on the world if appropriate, before deciding
actor.decision = direction_preference[1]; // which of them to use.
} // - These two properties combine in a subtle way. If we're on a force floor sliding right
else { // under a row of blue walls, then if we hold up, we will bump every wall along the way.
actor.decision = direction_preference[0]; // 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 && ! directions_ok[0]) { 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); this._handle_slide_bonk(actor);
} }
} }
}
else if (dir1 === null) {
// Not attempting to move, so do nothing
}
else {
// 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);
}
}
// Remember our choice for the sake of doppelgangers
// 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) {
// Teeth can only move the first 4 of every 8 tics, and mimics only the first 4 of every // Teeth can only move the first 4 of every 8 tics, and mimics only the first 4 of every

View File

@ -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;

View File

@ -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) => {

View File

@ -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 {