diff --git a/js/defs.js b/js/defs.js
index afb6397..b71f914 100644
--- a/js/defs.js
+++ b/js/defs.js
@@ -49,6 +49,8 @@ export const INPUT_BITS = {
up: 0x10,
swap: 0x20,
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)
diff --git a/js/game.js b/js/game.js
index 9253a81..82b6634 100644
--- a/js/game.js
+++ b/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';
export class Tile {
@@ -390,6 +390,7 @@ export class Level {
}
}
// TODO complain if no player
+ // FIXME this is not how multiple players works
this.player = this.players[0];
this.player_index = 0;
// Used for doppelgangers
@@ -518,18 +519,18 @@ export class Level {
}
// Move the game state forwards by one tic.
- // FIXME i have absolutely definitely broken turn-based mode
- advance_tic(p1_actions) {
+ // Input is a bit mask of INPUT_BITS.
+ advance_tic(p1_input) {
if (this.state !== 'playing') {
console.warn(`Level.advance_tic() called when state is ${this.state}`);
return;
}
- this.begin_tic(p1_actions);
- this.finish_tic(p1_actions);
+ this.begin_tic(p1_input);
+ 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
// they only take a few bytes each so that's fine.)
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
for (let i = this.actors.length - 1; i >= 0; i--) {
let actor = this.actors[i];
@@ -618,7 +619,7 @@ export class Level {
continue;
if (actor === this.player) {
- this.make_player_decision(actor, p1_actions);
+ this.make_player_decision(actor, p1_input);
}
else {
this.make_actor_decision(actor);
@@ -638,13 +639,13 @@ export class Level {
// Check for special player actions, which can only happen when not moving
if (actor === this.player) {
- if (p1_actions.cycle) {
+ if (p1_input & INPUT_BITS.cycle) {
this.cycle_inventory(this.player);
}
- if (p1_actions.drop) {
+ if (p1_input & INPUT_BITS.drop) {
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
// checking this.player
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,
// 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();
@@ -728,81 +731,140 @@ export class Level {
// TODO player in a cloner can't move (but player in a trap can still turn)
- // The player is unusual in several ways.
- // - 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
- let direction_preference = [];
- if (actor.slide_mode && ! (
- actor.slide_mode === 'force' &&
- input.primary !== null && actor.last_move_was_force))
+ // Extract directions from the input mask
+ let dir1 = null, dir2 = null;
+ if (((input & INPUT_BITS['up']) && (input & INPUT_BITS['down'])) ||
+ ((input & INPUT_BITS['left']) && (input & INPUT_BITS['right'])))
{
- direction_preference.push(actor.direction);
-
- if (actor.slide_mode === 'force') {
- this._set_tile_prop(actor, 'last_move_was_force', true);
- }
+ // 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
}
else {
- // FIXME this isn't right; if primary is blocked, they move secondary, but they also
- // ignore railroad redirection until next tic
- this.remember_player_move(input.primary);
-
- if (input.primary) {
- // FIXME something is wrong with direction preferences! if you hold both keys
- // in a corner, no matter which you pressed first, cc2 always tries vert first
- // and horiz last (so you're pushing horizontally)!
- // 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];
+ for (let [direction, dirinfo] of Object.entries(DIRECTIONS)) {
+ if (input & INPUT_BITS[dirinfo.action]) {
+ if (dir1 === null) {
+ dir1 = direction;
+ }
+ else {
+ dir2 = direction;
+ break;
+ }
}
- 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)
- 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 => {
+ let try_direction = (direction, push_mode) => {
direction = actor.cell.redirect_exit(actor, direction);
let dest_cell = this.get_neighboring_cell(actor.cell, direction);
return (dest_cell &&
! actor.cell.blocks_leaving(actor, direction) &&
// FIXME if the player steps into a monster cell here, they die instantly! but only
// 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) {
- actor.decision = direction_preference[0];
+ // The player is unusual in several ways.
+ // - 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]) {
- // Only turn if we're blocked in our current direction AND free in the other one
- actor.decision = direction_preference[1];
+ else if (dir1 === null) {
+ // Not attempting to move, so do nothing
}
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]) {
- this._handle_slide_bonk(actor);
- }
+ // 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) {
diff --git a/js/main.js b/js/main.js
index 727c962..27d0f66 100644
--- a/js/main.js
+++ b/js/main.js
@@ -327,7 +327,7 @@ class Player extends PrimaryView {
else {
if (this.turn_mode === 2) {
// 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.turn_mode = 0;
@@ -442,7 +442,6 @@ class Player extends PrimaryView {
this.player_used_move = false;
let key_target = document.body;
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.current_keys = new Set; // keys that are currently held
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
// (note that this might be called /before/ setup!)
setup_debug() {
- this.root.classList.add('--debug');
+ document.body.classList.add('--debug');
let debug_el = this.root.querySelector('#player-debug');
this.debug = {
enabled: true,
@@ -1069,30 +1068,26 @@ class Player extends PrimaryView {
get_input() {
let input;
if (this.debug && this.debug.replay && ! this.debug.replay_recording) {
- let mask = this.debug.replay.get(this.level.tic_counter);
- input = new Set(Object.entries(INPUT_BITS).filter(([action, bit]) => mask & bit).map(([action, bit]) => action));
+ input = this.debug.replay.get(this.level.tic_counter);
}
else {
- // Convert input keys to actions. This is only done now
- // because there might be multiple keys bound to one
- // action, and it still counts as pressed as long as at
- // least one key is held
- input = new Set;
+ // Convert input keys to actions
+ input = 0;
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) {
- input.add(this.key_mapping[key]);
+ input |= INPUT_BITS[this.key_mapping[key]];
}
this.current_keys_new.clear();
for (let action of Object.values(this.current_touches)) {
- input.add(action);
+ input |= INPUT_BITS[action];
}
}
if (this.debug.enabled) {
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) {
for (let i = 0; i < tics; i++) {
+ // FIXME turn-based mode should be disabled during a replay
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) {
- let input_mask = 0;
- 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'),
+ this.debug.replay.set(this.level.tic_counter, input);
}
+ // FIXME cycle/drop/swap depend on this but that's currently broken; should Level handle
+ // it? probably
this.previous_input = input;
this.sfx_player.advance_tic();
@@ -1167,14 +1118,14 @@ class Player extends PrimaryView {
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
if (this.turn_mode === 2) {
if (has_input) {
// 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
// did, the first time we tried it
- this.level.finish_tic(player_actions);
+ this.level.finish_tic(input);
this.turn_mode = 1;
}
else {
@@ -1183,14 +1134,14 @@ class Player extends PrimaryView {
}
// 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 we're in turn-based mode and could provide input here, but don't have any,
// then wait until we do
this.turn_mode = 2;
}
else {
- this.level.finish_tic(player_actions);
+ this.level.finish_tic(input);
}
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
class LevelBrowserOverlay extends DialogOverlay {
constructor(conductor) {
@@ -2271,6 +2396,12 @@ class Conductor {
this.current.open_level_browser();
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 => {
// TODO confirm
this.switch_to_splash();
@@ -2336,6 +2467,7 @@ class Conductor {
load_game(stored_game, identifier = null) {
this.stored_game = stored_game;
+ this._pack_test_dialog = null;
this._pack_identifier = identifier;
this.current_pack_savefile = null;
diff --git a/js/util.js b/js/util.js
index 0906656..cbd36a7 100644
--- a/js/util.js
+++ b/js/util.js
@@ -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) {
let resolve, reject;
let promise = new Promise((res, rej) => {
diff --git a/style.css b/style.css
index 6c3c56e..2ec84be 100644
--- a/style.css
+++ b/style.css
@@ -165,9 +165,9 @@ svg.svg-icon {
display: flex;
flex-direction: column;
- min-width: 33%;
- max-width: 75%;
- max-height: 75%;
+ min-width: 33vw;
+ max-width: 75vw;
+ max-height: 75vh;
border: 1px solid black;
color: black;
background: #f4f4f4;
@@ -195,6 +195,7 @@ svg.svg-icon {
display: none;
}
.dialog > section {
+ flex: auto;
overflow: auto;
padding: 1em;
}
@@ -545,6 +546,76 @@ button.level-pack-button p {
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 */
@@ -626,7 +697,7 @@ button.level-pack-button p {
text-shadow: 0 2px 1px black;
}
/* Allow clicking through the overlay in debug mode */
-#player.--debug .overlay-message {
+body.--debug .overlay-message {
pointer-events: none;
}
#player .overlay-message p {
@@ -858,7 +929,7 @@ dl.score-chart .-sum {
}
/* Debug stuff */
-#player.--debug #player-debug {
+body.--debug #player-debug {
display: flex;
}
#player-debug {