From f3f73a5e41473e74f248f5a0db9c7055ea35e127 Mon Sep 17 00:00:00 2001 From: "Eevee (Evelyn Woods)" Date: Mon, 14 Dec 2020 17:01:10 -0700 Subject: [PATCH] Move input handling into Level and clean it up a ton; add a bulk test gizmo --- index.html | 3 +- js/defs.js | 2 + js/game.js | 196 +++++++++++++++++++++++++------------- js/main.js | 270 +++++++++++++++++++++++++++++++++++++++-------------- js/util.js | 6 ++ style.css | 81 +++++++++++++++- 6 files changed, 416 insertions(+), 142 deletions(-) diff --git a/index.html b/index.html index 341b8f3..aa2b87e 100644 --- a/index.html +++ b/index.html @@ -55,9 +55,10 @@

Chip's Challenge Level Pack 1

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 {