TECHNIQUES: a skill page with no stat sticks on it
The last of P12, and the atlas was emphatic: "+5% score" nodes are
BANNED. A number that quietly improves is not a skill, it is an apology
for the last hour. So every one of these six is either a NEW VERB or a
FLOOR, and you can feel all of them inside one service:
KNIFE — THE PINCH GRIP: you hold the small board still with the heel of
your hand, so its wobble stops reaching your stroke. It still cannot
hold a diced onion; the catalogue is for that. → THE TOURNÉ: seven flat
sides on a barrel the size of your thumb. Nobody needs it. It appears on
the STAGE menu.
HEAT — THE SPARE HAND: you seat the mandoline guard in one movement, so
guarding stops costing handling time. Same safety, none of the fumbling.
→ THE STEADY SWEEP: the torch now leaves a faint ring where its cone
actually lands. It tells you nothing about the score — only about your
own hand, which is the only thing an info tool should ever do.
PALATE — THE PALATE: the tasting spoon stops guessing and the readout
sits still instead of wobbling. The pot is no easier; you are better at
reading it. → CONFIDENT BLIND: cook a whole service under the
Inspector's Shadow and the room tips for the nerve.
Points are earned, never bought: an S on a day, a gold or better in the
drills, and time served. Prerequisites are enforced and the page says
what is missing ("THE PINCH GRIP first") rather than greying out in
silence.
Verified the flags actually reach the benches rather than just sitting
in the save: with two branches learned, prep.pinchGrip and
spice.steadyPalate read true while mandoline.fastGuard and
brulee.showCone read FALSE, then both flipped true the moment their
nodes were learned. The tourné drill was hidden and appeared on unlock.
Carve 9.8, mandoline 9.6, curry 10.0, brûlée 7.5, chips 10.0, grill 9.4.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
66e819a1fd
commit
34e08321fd
@ -18,6 +18,8 @@ import type { PrepResult } from './judging';
|
||||
export type Medal = 'S' | 'gold' | 'silver' | 'bronze' | null;
|
||||
|
||||
export interface Drill {
|
||||
/** Present when the drill only appears once a skill unlocks it. */
|
||||
needsSkill?: string;
|
||||
id: string;
|
||||
label: string;
|
||||
/** What the ticket-less bench says you are practising. */
|
||||
@ -67,6 +69,18 @@ export const DRILLS: Drill[] = [
|
||||
time: [28, 38, 52, 72],
|
||||
pieces: 12,
|
||||
},
|
||||
{
|
||||
id: 'tourne_cucumber',
|
||||
label: 'TOURNÉ — seven sides',
|
||||
brief: 'Seven flat faces on a barrel the size of your thumb. Nobody needs this. Do it anyway.',
|
||||
needsSkill: 'tourne',
|
||||
ingredient: 'cucumber',
|
||||
knife: 'the_best_thing',
|
||||
pattern: { kind: 'wedges', n: 7 },
|
||||
cv: [0.07, 0.11, 0.17, 0.26],
|
||||
time: [34, 46, 62, 85],
|
||||
pieces: 7,
|
||||
},
|
||||
];
|
||||
|
||||
export interface DrillScore {
|
||||
|
||||
@ -42,6 +42,7 @@ import { Title } from '../ui/title';
|
||||
import { DRILLS, scoreDrill, medalRank, medalWord, type Drill, type Medal } from './drills';
|
||||
import { openCure, CURE_DAYS, type CureRecord } from '../sim/cure';
|
||||
import { CATALOGUE, tipFor, tierOf, boardName } from './shop';
|
||||
import { SKILLS, canLearn, knows } from './skills';
|
||||
import { eye } from '../ui/eye';
|
||||
import { assetUrl } from '../scenes/assets';
|
||||
|
||||
@ -62,6 +63,8 @@ interface Save {
|
||||
/** Money in the till, and what you have bought with it (P12). */
|
||||
money?: number;
|
||||
owned?: string[];
|
||||
/** Techniques learned (P12). Verbs and floors, never stat sticks. */
|
||||
skills?: string[];
|
||||
/** Days SERVED, ever — a monotonic clock that only goes up. maxDay is the
|
||||
* wrong clock for a cure: it only advances when you reach a NEW day, so a
|
||||
* cook at day 45 replaying day 30 could never age a jar. Any service is a
|
||||
@ -359,6 +362,9 @@ export class Game {
|
||||
}, (id) => {
|
||||
audio.resume();
|
||||
return this.buyItem(id);
|
||||
}, (id) => {
|
||||
audio.resume();
|
||||
return this.learnSkill(id);
|
||||
});
|
||||
}
|
||||
|
||||
@ -637,6 +643,7 @@ export class Game {
|
||||
}
|
||||
this.prep.allowEarlyServe = false; // service wants the whole cut
|
||||
this.prep.boardTier = tierOf('board', this.save.owned ?? []);
|
||||
this.prep.pinchGrip = knows(this.save, 'pinch_grip');
|
||||
this.enterPrep(step.ingredient, step.pattern, step.knife ?? 'dinner_knife', prepAsk(step), (result) => {
|
||||
this.prepResult = mergePrep(this.prepResult, result);
|
||||
this.runNextPrep();
|
||||
@ -739,6 +746,7 @@ export class Game {
|
||||
this.judgeView.root.visible = false;
|
||||
this.hideAllScenes();
|
||||
this.prep.boardTier = tierOf('board', this.save.owned ?? []);
|
||||
this.prep.pinchGrip = knows(this.save, 'pinch_grip');
|
||||
this.prep.reset(d.ingredient, d.knife, d.pattern, d.brief);
|
||||
this.prep.allowEarlyServe = true;
|
||||
this.prep.root.visible = true;
|
||||
@ -1249,6 +1257,17 @@ export class Game {
|
||||
this.ticket.hide();
|
||||
}
|
||||
|
||||
/** Learn a technique. Points are earned, never bought. */
|
||||
learnSkill(id: string): { ok: boolean; note: string } {
|
||||
const check = canLearn(this.save, id);
|
||||
if (!check.ok) return { ok: false, note: check.why };
|
||||
(this.save.skills ??= []).push(id);
|
||||
writeSave(this.save);
|
||||
audio.clatter(0.4, 1.5);
|
||||
const node = SKILLS.find((n) => n.id === id);
|
||||
return { ok: true, note: node ? node.blurb : 'learned' };
|
||||
}
|
||||
|
||||
/** Buy from the catalogue. Returns what the counter says back. */
|
||||
buyItem(id: string): { ok: boolean; note: string } {
|
||||
const it = CATALOGUE.find((c) => c.id === id);
|
||||
@ -1279,6 +1298,7 @@ export class Game {
|
||||
}
|
||||
|
||||
private enterBrulee(): void {
|
||||
this.brulee.showCone = knows(this.save, 'steady_sweep');
|
||||
this.brulee.reset();
|
||||
this.hideAllScenes();
|
||||
this.brulee.root.visible = true;
|
||||
@ -1307,6 +1327,7 @@ export class Game {
|
||||
|
||||
private enterMandoline(): void {
|
||||
const m = this.order.mandoline!;
|
||||
this.mandoline.fastGuard = knows(this.save, 'spare_hand');
|
||||
this.mandoline.reset(`${m.slices} slices \u2014 and your hands intact`);
|
||||
this.hideAllScenes();
|
||||
this.mandoline.root.visible = true;
|
||||
@ -1335,6 +1356,7 @@ export class Game {
|
||||
|
||||
private enterSpice(): void {
|
||||
const sp = this.order.spice!;
|
||||
this.spice.steadyPalate = knows(this.save, 'palate');
|
||||
this.spice.reset(sp.target, 'season it \u2014 warm, bright, and properly salted', sp.base);
|
||||
this.hideAllScenes();
|
||||
this.spice.root.visible = true;
|
||||
@ -1616,7 +1638,12 @@ export class Game {
|
||||
extras.push('The Daily. Same deal for everyone today — come back tomorrow.');
|
||||
}
|
||||
// THE TILL. Tips scale with the grade because that is how it works.
|
||||
const tip = tipFor(v.grade, v.total);
|
||||
let tip = tipFor(v.grade, v.total);
|
||||
if (tip > 0 && this.shadowMode && knows(this.save, 'confident_blind')) {
|
||||
// They watched you cook a whole service without a word of help.
|
||||
tip = Math.round(tip * 1.5);
|
||||
extras.push('You did that blind and the room saw it.');
|
||||
}
|
||||
if (tip > 0) {
|
||||
this.save.money = (this.save.money ?? 0) + tip;
|
||||
extras.push(`${v.grade === 'S' ? 'They left you' : 'A tip:'} $${tip}.`);
|
||||
@ -1785,7 +1812,7 @@ function loadSave(): Save {
|
||||
const maxDay = Math.max(day, num(s.maxDay, day));
|
||||
const st = s.starter;
|
||||
const starter = st && Number.isFinite(st.born) && Number.isFinite(st.fed) ? { born: Math.min(st.born, maxDay), fed: Math.min(st.fed, maxDay), deaths: num(st.deaths, 0) } : undefined;
|
||||
return { day, maxDay, total: num(s.total, 0), best: s.best ?? {}, fridge: s.fridge, streak: num(s.streak, 0), customers: s.customers ?? {}, daily: s.daily ?? {}, shadowed: s.shadowed ?? {}, starter, drills: s.drills ?? {}, cures: s.cures ?? [], daysServed: num(s.daysServed, 0), money: num(s.money, 0), owned: s.owned ?? [] };
|
||||
return { day, maxDay, total: num(s.total, 0), best: s.best ?? {}, fridge: s.fridge, streak: num(s.streak, 0), customers: s.customers ?? {}, daily: s.daily ?? {}, shadowed: s.shadowed ?? {}, starter, drills: s.drills ?? {}, cures: s.cures ?? [], daysServed: num(s.daysServed, 0), money: num(s.money, 0), owned: s.owned ?? [], skills: s.skills ?? [] };
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
|
||||
115
src/game/skills.ts
Normal file
115
src/game/skills.ts
Normal file
@ -0,0 +1,115 @@
|
||||
import { gradeOf } from './judging';
|
||||
import { medalRank, type Medal } from './drills';
|
||||
|
||||
/**
|
||||
* THE SKILL PAGE — three short branches, and not one stat stick on it.
|
||||
*
|
||||
* The atlas banned "+5% score" nodes and it was right to: a number that
|
||||
* quietly improves is not a skill, it is an apology for the last hour. Every
|
||||
* node here is either a NEW VERB (something you could not do before) or a
|
||||
* FLOOR (something that used to be able to go wrong and now cannot, because
|
||||
* you have learned it). You should be able to feel every one of them in your
|
||||
* hands within a single service.
|
||||
*
|
||||
* Points come from things you actually did: an S on a day, a gold or better
|
||||
* in the drills, and simple time served.
|
||||
*/
|
||||
|
||||
export interface SkillNode {
|
||||
id: string;
|
||||
branch: 'KNIFE' | 'HEAT' | 'PALATE';
|
||||
label: string;
|
||||
/** What it does, in the house voice — and it always names a verb. */
|
||||
blurb: string;
|
||||
cost: number;
|
||||
needs?: string;
|
||||
}
|
||||
|
||||
export const SKILLS: SkillNode[] = [
|
||||
{
|
||||
id: 'pinch_grip',
|
||||
branch: 'KNIFE',
|
||||
label: 'THE PINCH GRIP',
|
||||
blurb: 'You hold the board with the heel of your hand now without thinking about it. The small board stops moving under the knife. It still cannot hold a diced onion.',
|
||||
cost: 1,
|
||||
},
|
||||
{
|
||||
id: 'tourne',
|
||||
branch: 'KNIFE',
|
||||
label: 'THE TOURNÉ',
|
||||
blurb: 'Seven flat sides on a barrel the size of your thumb. Nobody needs it. It goes on the STAGE menu.',
|
||||
cost: 2,
|
||||
needs: 'pinch_grip',
|
||||
},
|
||||
{
|
||||
id: 'spare_hand',
|
||||
branch: 'HEAT',
|
||||
label: 'THE SPARE HAND',
|
||||
blurb: 'The mandoline guard stops costing you time — you seat it in one movement now. Same safety, none of the fumbling.',
|
||||
cost: 1,
|
||||
},
|
||||
{
|
||||
id: 'steady_sweep',
|
||||
branch: 'HEAT',
|
||||
label: 'THE STEADY SWEEP',
|
||||
blurb: 'You can see where the flame has actually been: the torch leaves a faint ring on the sugar where its cone lands. It tells you nothing about the score, only about your own hand.',
|
||||
cost: 2,
|
||||
needs: 'spare_hand',
|
||||
},
|
||||
{
|
||||
id: 'palate',
|
||||
branch: 'PALATE',
|
||||
label: 'THE PALATE',
|
||||
blurb: 'The tasting spoon stops guessing. The readout it gives you sits still instead of wobbling — the pot is no easier, you are just better at reading it.',
|
||||
cost: 1,
|
||||
},
|
||||
{
|
||||
id: 'confident_blind',
|
||||
branch: 'PALATE',
|
||||
label: 'CONFIDENT BLIND',
|
||||
blurb: "Cook a whole service under the Inspector's Shadow and the room notices. They tip for the nerve.",
|
||||
cost: 2,
|
||||
needs: 'palate',
|
||||
},
|
||||
];
|
||||
|
||||
export interface SkillSave {
|
||||
best: Record<string, number>;
|
||||
drills?: Record<string, { medal: Medal }>;
|
||||
daysServed?: number;
|
||||
skills?: string[];
|
||||
}
|
||||
|
||||
/** Points you have EARNED, ever. Nothing here is a purchase. */
|
||||
export function earnedPoints(save: SkillSave): number {
|
||||
const esses = Object.values(save.best ?? {}).filter((b) => gradeOf(b) === 'S').length;
|
||||
const medals = Object.values(save.drills ?? {}).filter((d) => medalRank(d.medal) >= 3).length;
|
||||
const served = Math.floor((save.daysServed ?? 0) / 5);
|
||||
return esses + medals + served;
|
||||
}
|
||||
|
||||
export function spentPoints(save: SkillSave): number {
|
||||
const have = save.skills ?? [];
|
||||
return SKILLS.filter((n) => have.includes(n.id)).reduce((a, n) => a + n.cost, 0);
|
||||
}
|
||||
|
||||
export function freePoints(save: SkillSave): number {
|
||||
return Math.max(0, earnedPoints(save) - spentPoints(save));
|
||||
}
|
||||
|
||||
export function canLearn(save: SkillSave, id: string): { ok: boolean; why: string } {
|
||||
const node = SKILLS.find((n) => n.id === id);
|
||||
if (!node) return { ok: false, why: 'no such thing' };
|
||||
const have = save.skills ?? [];
|
||||
if (have.includes(id)) return { ok: false, why: 'you know this' };
|
||||
if (node.needs && !have.includes(node.needs)) {
|
||||
const need = SKILLS.find((n) => n.id === node.needs);
|
||||
return { ok: false, why: `${need?.label ?? node.needs} first` };
|
||||
}
|
||||
if (freePoints(save) < node.cost) return { ok: false, why: `wants ${node.cost} point${node.cost > 1 ? 's' : ''}` };
|
||||
return { ok: true, why: '' };
|
||||
}
|
||||
|
||||
export function knows(save: { skills?: string[] } | undefined, id: string): boolean {
|
||||
return !!save?.skills?.includes(id);
|
||||
}
|
||||
@ -58,6 +58,9 @@ export class BruleeView implements View {
|
||||
private hintEl!: HTMLElement;
|
||||
|
||||
onDone: ((result: ReturnType<typeof bruleeResult>) => void) | null = null;
|
||||
/** THE STEADY SWEEP (skill): show where the cone actually lands. */
|
||||
showCone = false;
|
||||
private coneRing!: THREE.Mesh;
|
||||
|
||||
constructor(private app: App) {
|
||||
this.buildScenery();
|
||||
@ -150,6 +153,15 @@ export class BruleeView implements View {
|
||||
this.flame.rotation.x = Math.PI; // pointing down at the sugar
|
||||
this.root.add(this.flame);
|
||||
|
||||
// THE STEADY SWEEP's ring: where the flame is actually landing. It says
|
||||
// nothing about the score — only about your own hand.
|
||||
this.coneRing = new THREE.Mesh(
|
||||
new THREE.RingGeometry(0.9, 1.0, 32),
|
||||
new THREE.MeshBasicMaterial({ color: 0x9ec8ff, transparent: true, opacity: 0, side: THREE.DoubleSide }),
|
||||
);
|
||||
this.coneRing.rotation.x = -Math.PI / 2;
|
||||
this.root.add(this.coneRing);
|
||||
|
||||
// The spoon he taps it with. It arrives only at the verdict.
|
||||
this.spoon = new THREE.Mesh(
|
||||
new THREE.SphereGeometry(0.2, 14, 10, 0, Math.PI * 2, 0, Math.PI / 2.3),
|
||||
@ -281,6 +293,17 @@ export class BruleeView implements View {
|
||||
fm.opacity += (0 - fm.opacity) * (1 - Math.exp(-dt / 0.06));
|
||||
}
|
||||
|
||||
// The ring tracks the cone's real footprint, scaled by the height dial.
|
||||
const rm = this.coneRing.material as THREE.MeshBasicMaterial;
|
||||
if (this.showCone && at && !s.tapped) {
|
||||
const world = radius * DISH_R * 1.88;
|
||||
this.coneRing.position.set(at.x, TOP_Y + 0.012, at.z);
|
||||
this.coneRing.scale.setScalar(world);
|
||||
rm.opacity += (0.4 - rm.opacity) * (1 - Math.exp(-dt / 0.08));
|
||||
} else {
|
||||
rm.opacity += (0 - rm.opacity) * (1 - Math.exp(-dt / 0.1));
|
||||
}
|
||||
|
||||
// The tap: the spoon comes down once, and stays where it landed.
|
||||
if (this.tapAnim > 0) {
|
||||
this.tapAnim = Math.max(0, this.tapAnim - dt * 2.2);
|
||||
|
||||
@ -127,8 +127,12 @@ export class MandolineView implements View {
|
||||
this.root.add(this.guardMesh);
|
||||
}
|
||||
|
||||
/** THE SPARE HAND (skill): the guard stops costing handling time. */
|
||||
fastGuard = false;
|
||||
|
||||
reset(askText = 'slices — as many as you can get, with your hands intact'): void {
|
||||
this.session = newMandolineSession();
|
||||
this.session.fastGuard = this.fastGuard;
|
||||
this.dragStartZ = null;
|
||||
this.strokeAnim = 0;
|
||||
for (const m of this.sliceMeshes) this.root.remove(m);
|
||||
|
||||
@ -85,6 +85,8 @@ export class PrepView implements View {
|
||||
* a sawing knife and it cannot hold a whole diced onion, so the outer
|
||||
* pieces go on the floor. Tiers above it simply do not do that. */
|
||||
boardTier = 0;
|
||||
/** THE PINCH GRIP (skill): you hold a wobbly board still without thinking. */
|
||||
pinchGrip = false;
|
||||
private lostPieces = 0;
|
||||
|
||||
constructor(private app: App) {
|
||||
@ -308,7 +310,7 @@ export class PrepView implements View {
|
||||
// it is literal lateral noise in the stroke, so the wedge and the CV
|
||||
// it produces are earned by the hardware, exactly like a thin pan's
|
||||
// hot spots. Buy the maple and the noise is simply gone.
|
||||
const wob = boardWobble(this.boardTier);
|
||||
const wob = this.pinchGrip ? 0 : boardWobble(this.boardTier);
|
||||
const dxBoard = wob > 0 ? dx + (this.rng.next() - 0.5) * wob : dx;
|
||||
const f = cutFrame(s, dy, dxBoard, dt, () => this.rng.next());
|
||||
if (Math.abs(dy) > 0.004) {
|
||||
|
||||
@ -58,6 +58,8 @@ export class SpiceView implements View {
|
||||
private hintEl!: HTMLElement;
|
||||
|
||||
onDone: ((result: ReturnType<typeof flavorResult>) => void) | null = null;
|
||||
/** THE PALATE (skill): the readout stops guessing and sits still. */
|
||||
steadyPalate = false;
|
||||
|
||||
constructor(private app: App) {
|
||||
this.buildScenery();
|
||||
@ -271,8 +273,9 @@ export class SpiceView implements View {
|
||||
(zone as HTMLElement).style.left = `${Math.min(100, band[0] * 100)}%`;
|
||||
(zone as HTMLElement).style.width = `${Math.min(100 - band[0] * 100, (band[1] - band[0]) * 100)}%`;
|
||||
const pip = el('div', 'taste-pip', bar);
|
||||
// The wobble: the spoon is a tongue, not an instrument.
|
||||
const wob = (s.rng.next() - 0.5) * 0.03;
|
||||
// The wobble: the spoon is a tongue, not an instrument — unless you have
|
||||
// learned the palate, in which case it reports what it actually found.
|
||||
const wob = this.steadyPalate ? 0 : (s.rng.next() - 0.5) * 0.03;
|
||||
(pip as HTMLElement).style.left = `${Math.max(0, Math.min(99, (s.lastTaste[a] / s.volume + wob) * 100))}%`;
|
||||
}
|
||||
}
|
||||
|
||||
@ -38,12 +38,14 @@ export interface MandolineSession {
|
||||
bled: boolean;
|
||||
/** Strokes refused because the guard had nothing to hold. */
|
||||
refused: number;
|
||||
/** THE SPARE HAND: you seat the guard in one movement, so it costs no time. */
|
||||
fastGuard: boolean;
|
||||
seconds: number;
|
||||
rng: Rng;
|
||||
}
|
||||
|
||||
export function newMandolineSession(seed = 20260726): MandolineSession {
|
||||
return { length: 1, slices: 0, guard: false, nicks: 0, bled: false, refused: 0, seconds: 0, rng: new Rng(seed) };
|
||||
return { length: 1, slices: 0, guard: false, nicks: 0, bled: false, refused: 0, fastGuard: false, seconds: 0, rng: new Rng(seed) };
|
||||
}
|
||||
|
||||
export function setGuard(s: MandolineSession, on: boolean): void {
|
||||
@ -73,7 +75,7 @@ export function stroke(s: MandolineSession): StrokeResult {
|
||||
return { sliced: false, nicked: false, refused: true };
|
||||
}
|
||||
const risk = riskOf(s);
|
||||
s.seconds += s.guard ? GUARD_COST : 0;
|
||||
s.seconds += s.guard && !s.fastGuard ? GUARD_COST : 0;
|
||||
if (risk > 0 && s.rng.next() < risk) {
|
||||
s.nicks++;
|
||||
s.bled = true;
|
||||
|
||||
@ -994,3 +994,7 @@ body:has(#ui > .title) #touchpad { display: none; }
|
||||
/* The catalogue: owned gear reads as settled, not as a disabled control. */
|
||||
.cat-row.owned { opacity: 0.8; }
|
||||
.cat-row.owned .drill-best { color: #8fce8f; }
|
||||
|
||||
.skill-row.owned .drill-best { color: #d8c8f0; }
|
||||
.skill-row:disabled:not(.owned) .drill-best { opacity: 0.6; }
|
||||
.skill-row.owned .drill-name { white-space: normal; text-align: left; }
|
||||
|
||||
@ -5,6 +5,7 @@ import { DAYS } from '../game/orders';
|
||||
import { DRILLS, medalWord, type Medal } from '../game/drills';
|
||||
import { CURE_DAYS, type CureRecord } from '../sim/cure';
|
||||
import { CATALOGUE } from '../game/shop';
|
||||
import { SKILLS, freePoints, canLearn } from '../game/skills';
|
||||
|
||||
/**
|
||||
* The front door. One button, one piece of art, one joke — and now the ledger:
|
||||
@ -15,11 +16,12 @@ export class Title {
|
||||
private panel: Panel;
|
||||
|
||||
constructor(
|
||||
save: { day: number; maxDay: number; best: Record<string, number>; shadowed?: Record<string, boolean>; starter?: { born: number; fed: number; deaths: number }; drills?: Record<string, { medal: Medal; cv: number; seconds: number }>; cures?: CureRecord[]; daysServed?: number; money?: number; owned?: string[] },
|
||||
save: { day: number; maxDay: number; best: Record<string, number>; shadowed?: Record<string, boolean>; starter?: { born: number; fed: number; deaths: number }; drills?: Record<string, { medal: Medal; cv: number; seconds: number }>; cures?: CureRecord[]; daysServed?: number; money?: number; owned?: string[]; skills?: string[] },
|
||||
onStart: (day?: number | 'daily' | string, shadow?: boolean) => void,
|
||||
onFeed?: () => void,
|
||||
onOpenCure?: (id: string) => { note: string; score: number } | null,
|
||||
onBuy?: (id: string) => { ok: boolean; note: string },
|
||||
onLearn?: (id: string) => { ok: boolean; note: string },
|
||||
) {
|
||||
this.panel = new Panel('title');
|
||||
const wrap = el('div', 'title-wrap', this.panel.root);
|
||||
@ -58,6 +60,7 @@ export class Title {
|
||||
void stageLbl;
|
||||
const stage = el('div', 'title-drills', txt);
|
||||
for (const d of DRILLS) {
|
||||
if (d.needsSkill && !(save.skills ?? []).includes(d.needsSkill)) continue;
|
||||
const best = save.drills?.[d.id];
|
||||
const b = el('button', 'btn ghost drill-btn', stage);
|
||||
el('span', 'drill-name', b, d.label);
|
||||
@ -65,6 +68,33 @@ export class Title {
|
||||
b.addEventListener('click', () => go(`drill:${d.id}`));
|
||||
}
|
||||
|
||||
// THE SKILL PAGE. Three short branches; every node is a verb or a floor.
|
||||
{
|
||||
const free = freePoints(save as never);
|
||||
const have = save.skills ?? [];
|
||||
if (free > 0 || have.length) {
|
||||
el('div', 'title-days-lbl', txt, `TECHNIQUES \u2014 ${free} point${free === 1 ? '' : 's'} to spend`);
|
||||
const page = el('div', 'title-drills', txt);
|
||||
for (const n of SKILLS) {
|
||||
const known = have.includes(n.id);
|
||||
const check = canLearn(save as never, n.id);
|
||||
const b = el('button', `btn ghost drill-btn skill-row${known ? ' owned' : ''}`, page);
|
||||
el('span', 'drill-name', b, `${n.branch} \u00b7 ${n.label}`);
|
||||
el('span', 'drill-best', b, known ? 'LEARNED' : check.ok ? `${n.cost} pt${n.cost > 1 ? 's' : ''}` : check.why);
|
||||
b.title = n.blurb;
|
||||
if (known || !check.ok) { (b as HTMLButtonElement).disabled = true; continue; }
|
||||
b.addEventListener('click', () => {
|
||||
const r = onLearn?.(n.id);
|
||||
if (!r || !r.ok) return;
|
||||
b.textContent = '';
|
||||
el('span', 'drill-name', b, r.note);
|
||||
b.classList.add('owned');
|
||||
(b as HTMLButtonElement).disabled = true;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// THE CATALOGUE. Cheap gear does not tax your score — it wobbles, and it
|
||||
// drops things on the floor. This is where that stops.
|
||||
{
|
||||
|
||||
Loading…
Reference in New Issue
Block a user