[lane E+F] The game remembers you: a save file, a level select and an ending
Audit items 9, 10 and 11 — three features whose backends were finished and whose front ends did not exist. DIFFICULTY WAS UNREACHABLE. levels/difficulty.js has carried three fully specified tiers since round 2 — Endoscopy, Clinical, Terminal, with per-tier assists and a note explaining each — and every single call site in the codebase took the default. `easy` and `hard` were a table nobody could select. getLevel now takes the saved difficulty (?diff= still wins, so a test URL stays absolute), and the title has the switch. Verified live: the world boots at 11 spawns on Terminal against 8 on Clinical. THE CAMPAIGN WAS SIX LEVELS WITH ONE WAY IN. Reaching anything but L1 meant hand-editing ?lvl= in the address bar. The title now lists the campaign, unlocking each segment as the one before it is filed, showing the medal you hold on each. `LS_` is C's secret prefix, so the appendix stays a row of question marks until you have earned your way in — naming it in the menu would have given away the one thing in this game you have to discover. THE CAMPAIGN HAD NO ENDING. C's anal_verge gate carries `to: null` — the only gate in the game that does, its own note calling it "the end of the campaign" — and boot's ui:continue handler opened with `if (!e.to) return`. Finishing GUTS parked you on a medal card forever with no way out but the address bar, and the card said "any key", the same words it uses when it has nothing to tell you. Now the clear is filed and counted, the card says you are out, and the title carries CANAL CLEARED. New core/save.js is the whole persistence layer: one localStorage key, one flat object, best time / best score / best medal kept per column (they need not come from the same run) plus an attempt count. Every entry point is wrapped — Safari private mode throws on setItem, and a save file must never be able to stop the game booting. Its selfcheck (`node web/js/core/save.js --selfcheck`, now in qa.sh) covers the best-of comparisons and feeds it corrupt, future-versioned and malformed saves. cards.js records rather than boot: this file computes the grade, so routing it through the bus for boot to re-derive would be two copies of the medal table waiting to disagree. One bug found by clicking rather than reading: the first cut of the chips had no pointer-events:auto. #ui is pointer-events:none and it inherits — the file's own header says so — so the buttons rendered, highlighted nothing, and passed the click through to the canvas, which on the title starts the run. It looked like a menu and behaved like wallpaper. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
628ee150ca
commit
4e4311b568
@ -42,6 +42,11 @@ if [ -f web/js/world/spline.js ]; then
|
|||||||
node web/js/world/spline.js --selfcheck >/dev/null 2>&1 && green "world selfcheck" || red "world selfcheck failed"
|
node web/js/world/spline.js --selfcheck >/dev/null 2>&1 && green "world selfcheck" || red "world selfcheck failed"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# 4c. save file (headless; a shimmed localStorage)
|
||||||
|
if [ -f web/js/core/save.js ]; then
|
||||||
|
node web/js/core/save.js --selfcheck >/dev/null 2>&1 && green "save selfcheck" || red "save selfcheck failed"
|
||||||
|
fi
|
||||||
|
|
||||||
# 5. Lane D manifest validation (when it exists)
|
# 5. Lane D manifest validation (when it exists)
|
||||||
if [ -f pipeline/validate_manifest.py ]; then
|
if [ -f pipeline/validate_manifest.py ]; then
|
||||||
python3 pipeline/validate_manifest.py && green "manifest valid" || red "manifest errors"
|
python3 pipeline/validate_manifest.py && green "manifest valid" || red "manifest errors"
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import * as THREE from 'three';
|
|||||||
import { parseFlags } from './core/flags.js';
|
import { parseFlags } from './core/flags.js';
|
||||||
import { createRng } from './core/rng.js';
|
import { createRng } from './core/rng.js';
|
||||||
import { createBus } from './core/bus.js';
|
import { createBus } from './core/bus.js';
|
||||||
|
import * as store from './core/save.js';
|
||||||
import { createAssets } from './core/assets.js';
|
import { createAssets } from './core/assets.js';
|
||||||
import { createWorld as createStubWorld, STUB_LEVEL } from './stub/world_stub.js';
|
import { createWorld as createStubWorld, STUB_LEVEL } from './stub/world_stub.js';
|
||||||
import { createPlayer } from './flight/player.js';
|
import { createPlayer } from './flight/player.js';
|
||||||
@ -56,13 +57,20 @@ renderer.domElement.addEventListener('webglcontextlost', (e) => {
|
|||||||
|
|
||||||
const assets = await createAssets({ flags, renderer });
|
const assets = await createAssets({ flags, renderer });
|
||||||
|
|
||||||
|
let campaign = []; // C's level order, captured at load so the title can offer a level select
|
||||||
|
|
||||||
async function loadLevel(id) {
|
async function loadLevel(id) {
|
||||||
try {
|
try {
|
||||||
const mod = await import('./levels/index.js'); // Lane C's registry
|
const mod = await import('./levels/index.js'); // Lane C's registry
|
||||||
|
campaign = mod.CAMPAIGN ?? [];
|
||||||
// `await`, not a bare return: without it the promise is handed to the caller and a rejection
|
// `await`, not a bare return: without it the promise is handed to the caller and a rejection
|
||||||
// (a mistyped ?lvl=, a 404 on the JSON) escapes this catch entirely, kills top-level module
|
// (a mistyped ?lvl=, a 404 on the JSON) escapes this catch entirely, kills top-level module
|
||||||
// evaluation, and the player gets a black page with no DBG, no HUD and no message.
|
// evaluation, and the player gets a black page with no DBG, no HUD and no message.
|
||||||
return await mod.getLevel(id); // default level lives there
|
// DIFFICULTY IS NOW REACHABLE. levels/difficulty.js has carried three fully specified tiers
|
||||||
|
// since round 2 and every caller in the codebase took the default, so `easy` and `hard`
|
||||||
|
// existed only as a table nobody could select. ?diff= wins over the save so a test URL is
|
||||||
|
// still absolute; the save is what an actual player set on the title.
|
||||||
|
return await mod.getLevel(id, { difficulty: flags.diff ?? store.load().diff });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.info('[boot] Lane C registry not available —', e.message);
|
console.info('[boot] Lane C registry not available —', e.message);
|
||||||
return null;
|
return null;
|
||||||
@ -110,7 +118,7 @@ if (player) {
|
|||||||
import('./ui/hud.js'), import('./ui/comms.js'), import('./ui/feedback.js'),
|
import('./ui/hud.js'), import('./ui/comms.js'), import('./ui/feedback.js'),
|
||||||
import('./ui/cards.js'), import('./audio/engine.js'),
|
import('./ui/cards.js'), import('./audio/engine.js'),
|
||||||
]);
|
]);
|
||||||
const args = { bus, flags, assets, level: world.level, world };
|
const args = { bus, flags, assets, level: world.level, world, campaign };
|
||||||
// Each module is constructed independently: one that throws must not cost us the rest.
|
// Each module is constructed independently: one that throws must not cost us the rest.
|
||||||
// A missing score readout is a blemish; a UI layer that fails to build in a game whose
|
// A missing score readout is a blemish; a UI layer that fails to build in a game whose
|
||||||
// only other feedback is the wall rushing past is an unplayable build.
|
// only other feedback is the wall rushing past is an unplayable build.
|
||||||
@ -239,7 +247,15 @@ bus.on('level:event', (ev) => {
|
|||||||
// Flags ride along, so ?dbg=1 / ?seed= survive the hop. Unknown/absent `to` = campaign over:
|
// Flags ride along, so ?dbg=1 / ?seed= survive the hop. Unknown/absent `to` = campaign over:
|
||||||
// stay on the card rather than dumping the player into the stub tube.
|
// stay on the card rather than dumping the player into the stub tube.
|
||||||
bus.on('ui:continue', async (e) => {
|
bus.on('ui:continue', async (e) => {
|
||||||
if (!e?.to) return;
|
// THE CAMPAIGN HAS AN ENDING. C's anal_verge gate carries `to: null` — the only gate in the
|
||||||
|
// game that does, and its own note calls it "the end of the campaign". That arrived at a
|
||||||
|
// handler which returned early, so finishing GUTS left you parked on a medal card forever
|
||||||
|
// with no way back but the address bar. Now it is filed and you are returned to the title,
|
||||||
|
// which is where the clear count is displayed.
|
||||||
|
if (!e?.to) {
|
||||||
|
if (e?.from) { store.clearCampaign(); location.search = ''; }
|
||||||
|
return;
|
||||||
|
}
|
||||||
const mod = await import('./levels/index.js').catch(() => null);
|
const mod = await import('./levels/index.js').catch(() => null);
|
||||||
if (!mod?.CAMPAIGN?.includes(e.to)) { console.info(`[boot] no level "${e.to}" yet — campaign ends here`); return; }
|
if (!mod?.CAMPAIGN?.includes(e.to)) { console.info(`[boot] no level "${e.to}" yet — campaign ends here`); return; }
|
||||||
const q = new URLSearchParams(location.search);
|
const q = new URLSearchParams(location.search);
|
||||||
@ -299,7 +315,7 @@ window.DBG = {
|
|||||||
get draws() { return stats.draws; },
|
get draws() { return stats.draws; },
|
||||||
get tris() { return stats.tris; },
|
get tris() { return stats.tris; },
|
||||||
get fps() { return fps; },
|
get fps() { return fps; },
|
||||||
world, player, combat, assets, ui,
|
world, player, combat, assets, ui, bus, // bus: so a UI screen can be driven without playing to it
|
||||||
shot(name = 'shot') {
|
shot(name = 'shot') {
|
||||||
const a = document.createElement('a');
|
const a = document.createElement('a');
|
||||||
a.download = `${name}.png`;
|
a.download = `${name}.png`;
|
||||||
|
|||||||
@ -12,6 +12,7 @@ export function parseFlags(search = location.search) {
|
|||||||
fakebus: on('fakebus'),
|
fakebus: on('fakebus'),
|
||||||
localassets: p.get('localassets') !== '0', // ?localassets=0 => empty-manifest boot
|
localassets: p.get('localassets') !== '0', // ?localassets=0 => empty-manifest boot
|
||||||
lvl: p.get('lvl') || null,
|
lvl: p.get('lvl') || null,
|
||||||
|
diff: p.get('diff') || null, // overrides the saved difficulty; null = use the save
|
||||||
seed: p.has('seed') ? (parseInt(p.get('seed'), 10) >>> 0) : null,
|
seed: p.has('seed') ? (parseInt(p.get('seed'), 10) >>> 0) : null,
|
||||||
raw: p,
|
raw: p,
|
||||||
};
|
};
|
||||||
|
|||||||
107
web/js/core/save.js
Normal file
107
web/js/core/save.js
Normal file
@ -0,0 +1,107 @@
|
|||||||
|
// core/save.js (Lane F) — the only thing this game remembers between visits.
|
||||||
|
//
|
||||||
|
// One localStorage key holding one flat object. ponytail: no IndexedDB, no profiles, no server,
|
||||||
|
// no migration ladder — a `v` mismatch starts you over, which is the honest cost of a save that
|
||||||
|
// weighs 200 bytes. Add more when a run is worth more than a browser's clear-site-data checkbox.
|
||||||
|
//
|
||||||
|
// EVERY entry point here is wrapped, because localStorage is not a reliable API: Safari private
|
||||||
|
// mode throws on setItem, a user can disable site data entirely, and another tab can leave half
|
||||||
|
// a write behind. A save file must never be able to stop the game from booting — the worst it is
|
||||||
|
// allowed to do is forget.
|
||||||
|
|
||||||
|
const KEY = 'guts.save';
|
||||||
|
|
||||||
|
// Ascending, so index IS the ranking. cards.js grades a run; this decides whether that grade
|
||||||
|
// beats the one on file.
|
||||||
|
const RANK = ['none', 'bronze', 'silver', 'gold', 'platinum'];
|
||||||
|
|
||||||
|
const EMPTY = () => ({ v: 1, diff: 'normal', cleared: 0, best: {} });
|
||||||
|
|
||||||
|
export function load() {
|
||||||
|
try {
|
||||||
|
const s = JSON.parse(localStorage.getItem(KEY));
|
||||||
|
if (!s || s.v !== 1) return EMPTY();
|
||||||
|
// Trust nothing that came off disk. A hand-edited file, a half-written record, or a save
|
||||||
|
// from a future version must degrade to defaults rather than crash the title screen.
|
||||||
|
return {
|
||||||
|
...EMPTY(), ...s,
|
||||||
|
best: (s.best && typeof s.best === 'object') ? s.best : {},
|
||||||
|
cleared: Number.isFinite(s.cleared) ? s.cleared : 0,
|
||||||
|
};
|
||||||
|
} catch { return EMPTY(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function save(s) {
|
||||||
|
try { localStorage.setItem(KEY, JSON.stringify(s)); } catch { /* forget, don't fail */ }
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setDiff(id) { const s = load(); s.diff = id; return save(s); }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* File a finished run.
|
||||||
|
*
|
||||||
|
* Each column keeps its own best INDEPENDENTLY: your fastest time and your best medal need not
|
||||||
|
* have come from the same run, and collapsing them to "the best run" throws away a record the
|
||||||
|
* player actually earned. `runs` counts attempts — the only honest denominator if we ever want
|
||||||
|
* to show a completion rate.
|
||||||
|
*/
|
||||||
|
export function record(id, stats = {}, medal = 'none') {
|
||||||
|
if (!id) return null;
|
||||||
|
const s = load();
|
||||||
|
const b = s.best[id] ?? { runs: 0, medal: 'none', time: null, score: 0 };
|
||||||
|
b.runs++;
|
||||||
|
if (RANK.indexOf(medal) > RANK.indexOf(b.medal)) b.medal = medal;
|
||||||
|
if (Number.isFinite(stats.time) && (b.time == null || stats.time < b.time)) b.time = stats.time;
|
||||||
|
if (Number.isFinite(stats.score) && stats.score > b.score) b.score = stats.score;
|
||||||
|
s.best[id] = b;
|
||||||
|
return save(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The anal verge. Counted rather than flagged: a second clear is a thing worth knowing. */
|
||||||
|
export function clearCampaign() { const s = load(); s.cleared++; return save(s); }
|
||||||
|
|
||||||
|
export function wipe() { try { localStorage.removeItem(KEY); } catch { /* already gone */ } }
|
||||||
|
|
||||||
|
// --- selfcheck: `node web/js/core/save.js --selfcheck` -------------------------------------
|
||||||
|
// The per-column best-of is the only logic in here worth a check, and it is exactly the kind
|
||||||
|
// that fails silently — a wrong comparison just quietly loses a record and nobody notices for
|
||||||
|
// a month. Node has no localStorage, so it gets six lines of one.
|
||||||
|
if (typeof process !== 'undefined' && process.argv?.[1]?.endsWith('save.js') && process.argv.includes('--selfcheck')) {
|
||||||
|
const mem = new Map();
|
||||||
|
globalThis.localStorage = {
|
||||||
|
getItem: (k) => (mem.has(k) ? mem.get(k) : null),
|
||||||
|
setItem: (k, v) => mem.set(k, String(v)),
|
||||||
|
removeItem: (k) => mem.delete(k),
|
||||||
|
};
|
||||||
|
const ok = (c, m) => { if (!c) { console.error(`save selfcheck FAILED: ${m}`); process.exit(1); } };
|
||||||
|
|
||||||
|
ok(load().diff === 'normal', 'empty save defaults to normal');
|
||||||
|
record('L1_mouth', { time: 120, score: 900 }, 'silver');
|
||||||
|
record('L1_mouth', { time: 200, score: 1500 }, 'bronze'); // slower, richer, worse medal
|
||||||
|
const b = load().best.L1_mouth;
|
||||||
|
ok(b.time === 120, `keeps the FASTEST time (got ${b.time})`);
|
||||||
|
ok(b.score === 1500, `keeps the HIGHEST score (got ${b.score})`);
|
||||||
|
ok(b.medal === 'silver', `keeps the BEST medal (got ${b.medal})`);
|
||||||
|
ok(b.runs === 2, `counts every attempt (got ${b.runs})`);
|
||||||
|
record('L1_mouth', { time: 90, score: 100 }, 'platinum');
|
||||||
|
ok(load().best.L1_mouth.medal === 'platinum', 'a better medal replaces');
|
||||||
|
ok(load().best.L1_mouth.score === 1500, 'a worse score does not');
|
||||||
|
|
||||||
|
setDiff('hard');
|
||||||
|
ok(load().diff === 'hard', 'difficulty persists');
|
||||||
|
ok(load().best.L1_mouth, 'setDiff does not clobber records');
|
||||||
|
clearCampaign(); clearCampaign();
|
||||||
|
ok(load().cleared === 2, 'clears count');
|
||||||
|
|
||||||
|
mem.set(KEY, '{ this is not json');
|
||||||
|
ok(load().diff === 'normal', 'a corrupt save reads as empty rather than throwing');
|
||||||
|
mem.set(KEY, JSON.stringify({ v: 99, diff: 'hard' }));
|
||||||
|
ok(load().diff === 'normal', 'a future version reads as empty');
|
||||||
|
mem.set(KEY, JSON.stringify({ v: 1, best: 'not an object' }));
|
||||||
|
ok(typeof load().best === 'object', 'a malformed best[] is replaced, not trusted');
|
||||||
|
record('L2_esophagus', { time: 5 }, 'gold'); // must not throw on the repaired save
|
||||||
|
ok(load().best.L2_esophagus.time === 5, 'recovers and records');
|
||||||
|
|
||||||
|
console.log('save selfcheck: ok');
|
||||||
|
}
|
||||||
@ -32,6 +32,11 @@
|
|||||||
// Never put pointer-events:auto on the full-screen root — that swallows the canvas mouse-look
|
// Never put pointer-events:auto on the full-screen root — that swallows the canvas mouse-look
|
||||||
// and aim that flight/input.js binds to renderer.domElement, i.e. the whole game.
|
// and aim that flight/input.js binds to renderer.domElement, i.e. the whole game.
|
||||||
|
|
||||||
|
// The one import in this file. cards.js owns the grade a run is given, so it is also the
|
||||||
|
// honest place to file it — routing the record through the bus so boot could re-derive the
|
||||||
|
// same grade would be two copies of the medal table waiting to disagree.
|
||||||
|
import * as store from '../core/save.js';
|
||||||
|
|
||||||
const C = {
|
const C = {
|
||||||
line: '#39e6ff', // ART_BIBLE emissive code — neutral/interactive cyan
|
line: '#39e6ff', // ART_BIBLE emissive code — neutral/interactive cyan
|
||||||
soft: '#7fdfff',
|
soft: '#7fdfff',
|
||||||
@ -171,6 +176,21 @@ const CSS = `
|
|||||||
/* the title sits over the live view; a hairline top/bottom band frames it without hiding it */
|
/* the title sits over the live view; a hairline top/bottom band frames it without hiding it */
|
||||||
.crd .title .band { width:min(520px, 76vw); height:1px; background:currentColor; opacity:.3;
|
.crd .title .band { width:min(520px, 76vw); height:1px; background:currentColor; opacity:.3;
|
||||||
margin:0 auto; }
|
margin:0 auto; }
|
||||||
|
/* --- title menu: level select + difficulty. Real <button>s, so they are tab-reachable and
|
||||||
|
announce themselves; the title is the only screen a player may arrive at cold. */
|
||||||
|
.crd .title .menu { margin:26px auto 0; display:flex; flex-wrap:wrap; gap:5px;
|
||||||
|
justify-content:center; max-width:min(560px, 84vw); }
|
||||||
|
/* pointer-events:auto, per the header note — #ui is pointer-events:none and it inherits, so a
|
||||||
|
button without this is a button that renders, highlights nothing, and silently lets the click
|
||||||
|
through to the canvas (which, on the title, starts the run). */
|
||||||
|
.crd .title .chip { pointer-events:auto;
|
||||||
|
font:inherit; font-size:9px; letter-spacing:.2em; text-transform:uppercase;
|
||||||
|
padding:5px 9px; border:1px solid currentColor; border-radius:2px; background:rgba(2,16,13,.35);
|
||||||
|
color:${C.ink}; opacity:.5; cursor:pointer; transition:opacity .12s linear; }
|
||||||
|
.crd .title .chip:hover, .crd .title .chip:focus-visible { opacity:1; outline:none; }
|
||||||
|
.crd .title .chip[disabled] { opacity:.15; cursor:default; }
|
||||||
|
.crd .title .chip .m { margin-left:7px; font-size:11px; line-height:0; vertical-align:-1px; }
|
||||||
|
.crd .title .foot { font-size:9px; letter-spacing:.28em; opacity:.42; margin-top:16px; }
|
||||||
|
|
||||||
@keyframes crdIn { from { opacity:0; transform:translateY(7px) } to { opacity:1; transform:none } }
|
@keyframes crdIn { from { opacity:0; transform:translateY(7px) } to { opacity:1; transform:none } }
|
||||||
/* Floor at .55, not .28: the ONLY call-to-action on the screen must never be near-invisible
|
/* Floor at .55, not .28: the ONLY call-to-action on the screen must never be near-invisible
|
||||||
@ -217,6 +237,26 @@ const mmssPar = (sec) => {
|
|||||||
};
|
};
|
||||||
const num = (v) => Math.round(v || 0).toLocaleString('en-US');
|
const num = (v) => Math.round(v || 0).toLocaleString('en-US');
|
||||||
|
|
||||||
|
// A medal on a title-screen chip is one dot. Colour carries the tier — the same ART_BIBLE
|
||||||
|
// emissive code the rest of the game reads by, so nobody has to learn a second language.
|
||||||
|
const MEDAL_DOT = { platinum: C.surf, gold: C.warn, silver: C.ink, bronze: '#c8813c' };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Go somewhere, keeping every flag the player is already carrying.
|
||||||
|
*
|
||||||
|
* A full page load, deliberately, and for the same reason boot.js reloads between segments:
|
||||||
|
* rebuilding world + player + combat + UI in place to save a second on a transition that
|
||||||
|
* happens a handful of times is a leak hunt nobody asked for. A key with a null value is
|
||||||
|
* REMOVED, which is how the difficulty toggle drops a stale ?diff= override.
|
||||||
|
*/
|
||||||
|
function go(patch) {
|
||||||
|
const q = new URLSearchParams(location.search);
|
||||||
|
for (const [k, v] of Object.entries(patch)) {
|
||||||
|
if (v == null) q.delete(k); else q.set(k, v);
|
||||||
|
}
|
||||||
|
location.search = q.toString();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The grade rule, in one place, deterministic, and printed on the card as the four verdict
|
* The grade rule, in one place, deterministic, and printed on the card as the four verdict
|
||||||
* chips so the player can see exactly which criterion they missed.
|
* chips so the player can see exactly which criterion they missed.
|
||||||
@ -258,7 +298,7 @@ function gradeOf(stats, par) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function createCards({
|
export function createCards({
|
||||||
bus, flags = {}, assets = null, level = null, world = null, mount = null,
|
bus, flags = {}, assets = null, level = null, world = null, mount = null, campaign = [],
|
||||||
} = {}) {
|
} = {}) {
|
||||||
const host = mount ?? document.getElementById('ui');
|
const host = mount ?? document.getElementById('ui');
|
||||||
// ?shots=1 => construct nothing at all (comms.js's route, not the HUD's display:none one —
|
// ?shots=1 => construct nothing at all (comms.js's route, not the HUD's display:none one —
|
||||||
@ -329,6 +369,65 @@ export function createCards({
|
|||||||
keys.innerHTML =
|
keys.innerHTML =
|
||||||
'WASD move · mouse aim · LMB cannon · RMB torpedo<br>' +
|
'WASD move · mouse aim · LMB cannon · RMB torpedo<br>' +
|
||||||
'SHIFT / CTRL throttle · SPACE boost · ESC or P pause';
|
'SHIFT / CTRL throttle · SPACE boost · ESC or P pause';
|
||||||
|
|
||||||
|
// ---- level select + difficulty ------------------------------------------------------
|
||||||
|
// The campaign has been six levels long for two rounds with exactly one way to reach any
|
||||||
|
// of them: hand-editing ?lvl= in the address bar. And levels/difficulty.js has carried
|
||||||
|
// three complete tiers since round 2 that no player could select. Both live here, on the
|
||||||
|
// one screen every player already sees, rather than behind a menu system this game does
|
||||||
|
// not otherwise need. (ponytail: no menu stack, no focus trap, no options screen — six
|
||||||
|
// buttons and a toggle. Build the stack when there is a fourth thing to put in it.)
|
||||||
|
const S = store.load();
|
||||||
|
const menu = el('div', 'menu', t);
|
||||||
|
const nameOf = (id) => id.replace(/^L[S\d]+_/, '').replace(/_/g, ' ');
|
||||||
|
// A level is open once the one before it has been filed. `LS_` is C's secret prefix — the
|
||||||
|
// appendix is reached by earning it at the ileocecal valve, so it stays a row of question
|
||||||
|
// marks until you have actually been there. Naming it in the menu would give it away.
|
||||||
|
const secret = (id) => id.startsWith('LS_');
|
||||||
|
const open = (id, i) => !!S.best[id] || (!secret(id) && (i === 0 || !!S.best[campaign[i - 1]]));
|
||||||
|
|
||||||
|
for (let i = 0; i < campaign.length; i++) {
|
||||||
|
const id = campaign[i];
|
||||||
|
const isOpen = open(id, i);
|
||||||
|
const b = el('button', 'chip', menu);
|
||||||
|
b.type = 'button';
|
||||||
|
b.textContent = isOpen ? nameOf(id) : (secret(id) ? '???' : `${nameOf(id)} · locked`);
|
||||||
|
if (id === lvl?.id) b.style.opacity = '1';
|
||||||
|
if (!isOpen) { b.disabled = true; continue; }
|
||||||
|
const rec = S.best[id];
|
||||||
|
if (rec && MEDAL_DOT[rec.medal]) {
|
||||||
|
const m = el('span', 'm', b);
|
||||||
|
m.textContent = '●';
|
||||||
|
m.style.color = MEDAL_DOT[rec.medal];
|
||||||
|
b.title = `best ${mmss(rec.time ?? 0)} · ${num(rec.score)} · ${rec.medal} · ${rec.runs} run${rec.runs === 1 ? '' : 's'}`;
|
||||||
|
}
|
||||||
|
b.addEventListener('click', () => go({ lvl: id }));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Difficulty reloads rather than re-applying live: difficulty.js is applied at getLevel(),
|
||||||
|
// before world generation, so enemy counts are already baked into the world standing behind
|
||||||
|
// this card. A reload is two lines and always correct; re-authoring a live level is neither.
|
||||||
|
const dOrder = ['easy', 'normal', 'hard'];
|
||||||
|
const dName = { easy: 'endoscopy', normal: 'clinical', hard: 'terminal' };
|
||||||
|
const dBtn = el('button', 'chip', menu);
|
||||||
|
dBtn.type = 'button';
|
||||||
|
dBtn.style.color = C.warn;
|
||||||
|
const paintDiff = () => { dBtn.textContent = `difficulty · ${dName[S.diff] ?? S.diff}`; };
|
||||||
|
paintDiff();
|
||||||
|
dBtn.addEventListener('click', () => {
|
||||||
|
S.diff = dOrder[(dOrder.indexOf(S.diff) + 1) % dOrder.length];
|
||||||
|
store.setDiff(S.diff);
|
||||||
|
paintDiff();
|
||||||
|
go({ diff: null }); // drop any ?diff= override so the save is what takes effect
|
||||||
|
});
|
||||||
|
|
||||||
|
// The one thing a save is FOR: proof you finished. Nothing in this game acknowledged a
|
||||||
|
// completed campaign anywhere the player could see it.
|
||||||
|
if (S.cleared > 0) {
|
||||||
|
const f = el('div', 'foot', t);
|
||||||
|
f.style.color = C.good;
|
||||||
|
f.textContent = S.cleared === 1 ? 'canal cleared' : `canal cleared × ${S.cleared}`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================ PAUSE =====================================================
|
// ============================ PAUSE =====================================================
|
||||||
@ -574,6 +673,10 @@ export function createCards({
|
|||||||
par: e?.par ?? null,
|
par: e?.par ?? null,
|
||||||
g: gradeOf(e?.stats ?? {}, e?.par ?? null),
|
g: gradeOf(e?.stats ?? {}, e?.par ?? null),
|
||||||
};
|
};
|
||||||
|
// File it. The latch above guarantees once per segment per run, so this cannot double-count
|
||||||
|
// attempts. (ponytail: one best per level, not one per difficulty — a difficulty-split
|
||||||
|
// record table is three times the storage to answer a question nobody has asked yet.)
|
||||||
|
store.record(result.id, result.stats, result.g.grade.medal);
|
||||||
if (paused) setPaused(false); // a completion beats a pause; never stack them
|
if (paused) setPaused(false); // a completion beats a pause; never stack them
|
||||||
titleUp = false;
|
titleUp = false;
|
||||||
titleS.classList.remove('on');
|
titleS.classList.remove('on');
|
||||||
@ -587,7 +690,10 @@ export function createCards({
|
|||||||
D.spec.p(`of ${result.g.needS}`);
|
D.spec.p(`of ${result.g.needS}`);
|
||||||
D.kills.p('');
|
D.kills.p('');
|
||||||
D.deaths.p('');
|
D.deaths.p('');
|
||||||
D.next(e?.to ? `next · ${e.to}` : 'any key');
|
// A gate with no `to` is C's end-of-campaign marker (the anal verge is the only one). This
|
||||||
|
// used to read "any key", which is what the card says when it has nothing to tell you —
|
||||||
|
// finishing the whole game was acknowledged in exactly the same words as clearing a segment.
|
||||||
|
D.next(e?.to ? `next · ${e.to}` : 'canal cleared · you are out');
|
||||||
// deaths from the payload is authoritative; our local count is only for the pause card
|
// deaths from the payload is authoritative; our local count is only for the pause card
|
||||||
deaths = st.deaths ?? deaths;
|
deaths = st.deaths ?? deaths;
|
||||||
|
|
||||||
@ -654,6 +760,10 @@ export function createCards({
|
|||||||
// the run, and neither should a browser shortcut the user is halfway through.
|
// the run, and neither should a browser shortcut the user is halfway through.
|
||||||
if (e.metaKey || e.ctrlKey || e.altKey) return;
|
if (e.metaKey || e.ctrlKey || e.altKey) return;
|
||||||
if (code === 'ShiftLeft' || code === 'ShiftRight' || code === 'Tab') return;
|
if (code === 'ShiftLeft' || code === 'ShiftRight' || code === 'Tab') return;
|
||||||
|
// A tabbed-to level chip must be activatable. Without this, "any key starts the run" ate
|
||||||
|
// the Enter that was meant to press the button under the focus ring — the menu would have
|
||||||
|
// been keyboard-visible and keyboard-unusable, which is worse than not having it.
|
||||||
|
if (root.contains(document.activeElement) && (code === 'Enter' || code === 'Space')) return;
|
||||||
if (dismissTitle()) { e.preventDefault(); e.stopPropagation(); }
|
if (dismissTitle()) { e.preventDefault(); e.stopPropagation(); }
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user