diff --git a/PROJECT.md b/PROJECT.md index a5eed6e..b6f5e4d 100644 --- a/PROJECT.md +++ b/PROJECT.md @@ -794,6 +794,28 @@ check the game's read against his own — and it answers the exact question the be off in anything shipped. If his read and the marker disagree often, tune `drillSlack` before touching `arrival`. +**TWELFTH PASS — the marker stops lying, and the rule announces itself.** Oliver: *"the marker +flips all the time even during a ball being midair.. i am not sure that a player would get the +mechanic intuitively let alone without the marker"*. + +**The flip had a specific cause and it was a design bug, not jitter**: with lanes held and speeds +constant, arrival DIFFERENCES never change while bodies walk — the queue only reorders on a kill or +an arrival. So the marker was flipping because it showed ONLY `q[0]` while the rule accepts any +near-tie within the slack: every kill made it jump between tied bodies, i.e. **the marker was +stricter than the rule it was explaining**. `drillLegal()` now marks the whole slack set — bright +head, dim ties — which is stable by construction. Measured: zero head-changes across 12 s of +walking with no kills. + +**Learnability, two answers:** +- the rule gets ONE modal, once per run, on the mode's own calm court (the seeded bodies are all at + the far baseline — this is the popup policy's sanctioned case): *CLEARANCE DRILL — DROP WHOEVER + WOULD REACH YOU FIRST · FAST BEATS CLOSE · STREAKS PAY*. "Most urgent first" is not guessable + from a chevron alone; one sentence makes it learnable in the first five seconds. +- **the markers are training wheels that take themselves off**: they hide at streak >= + `drillMarkOff` (8) — the streak IS the evidence you can read the court — and any fault brings + them back. The teacher steps in exactly when you demonstrably need it and out when you don't, + and `drillMark: 0` still kills them outright. + **Open, and every one of them is Oliver's call after he plays it:** 1. ~~**A ball that carries through the lane can complete two steps at once.**~~ Largely CLOSED by the slice removal (seventh pass) — a cut ball picking its own next victim was the main way this diff --git a/index.html b/index.html index 8e4cabc..b2d001f 100644 --- a/index.html +++ b/index.html @@ -399,6 +399,6 @@ - + diff --git a/src/config.js b/src/config.js index f245e1b..96d9edd 100644 --- a/src/config.js +++ b/src/config.js @@ -369,9 +369,12 @@ export const TUNE = { drillSlack: 0.9, drillMultEvery: 4, // correct kills per multiplier step... drillMultCap: 5, // ...capped: a deep streak should feel valuable, not become the whole score - drillMark: 1, // >= 0.5 draws a chevron over the queue head — the CALIBRATION AID, so a - // playtest can check the game's read against the player's. Off for the real - // thing: the marker answers the exact question the mode exists to ask + drillMark: 1, // >= 0.5 draws chevrons over every LEGAL kill (the slack set, bright head, + // dim ties). Training wheels, not a cheat: see drillMarkOff + drillMarkOff: 8, // streak at which the markers hide — you have shown you can read the court, so + // the teacher steps back. Any fault (streak to 0) brings them back. This is the + // learnability answer: the marker teaches, the streak decides when you have + // learned, unlearning re-teaches drillOneShot: 0, // >= 0.5: every drill body drops to 1 hp. OFF after one playtest: "way too easy" // AND a 1 hp body skips every ZWOUND stage, so the gore art never renders. // Also flattens triage — pre-softening a heavy IS the planning layer diff --git a/src/drill.js b/src/drill.js index 0243a32..fbf16bd 100644 --- a/src/drill.js +++ b/src/drill.js @@ -22,10 +22,11 @@ // THE MODE TRANSITIONS ARE NOT HERE — startDrill/endDrill live in beats.js with every other one // (CLAUDE.md rule 3). This file is the stream, the urgency rule and the scoring. -import { TUNE, COURT } from './config.js'; +import { TUNE, COURT, UI } from './config.js'; import { G, ball } from './state.js'; import { zombies, spawnZombie } from './zombies.js'; import { voice } from './audio.js'; +import { showModal } from './gameplay.js'; // runtime-only cycle (gameplay imports drill), same pattern as beats // The roster in the stream, weighted: shamblers are the crowd, heavies are events. vMul is the // drill's own speed character (deliberately narrower than the roster's 3.2x..0.45x so the queue @@ -52,8 +53,20 @@ const spawnGap = d => lerp(TUNE.drillSpawnEvery, TUNE.drillSpawnMin, ramp(d)); // ---- THE URGENCY RULE, and it is the whole mode. Seconds until this body crosses your baseline at // its own pace down its own lane (drill bodies hold their lane, so this is honest — nothing swerves). export const arrival = zb => (COURT.L - zb.z) / Math.max(0.01, zb.v); -// The queue, most urgent first. Exported: render marks the head, tests assert against it. +// The queue, most urgent first. Exported: render marks the legal set, tests assert against it. export const drillQueue = () => zombies.filter(z => z.drill).sort((a, b) => arrival(a) - arrival(b)); +// EVERY legal kill right now — the head plus anything within the slack of it. THE MARKER MUST DRAW +// THIS SET, not the bare head: the rule accepts any near-tie, so a marker showing only q[0] was +// STRICTER than the rule it explained, and every kill made it jump between tied bodies in a way +// that read as the rule changing mid-flight. (With lanes held and speeds constant, arrival +// differences never change while bodies walk — the queue only reorders on a kill or an arrival, so +// marking the whole legal set is stable by construction.) +export const drillLegal = () => { + const q = drillQueue(); + if (!q.length) return q; + const t0 = arrival(q[0]); + return q.filter(z => arrival(z) <= t0 + TUNE.drillSlack); +}; function pickType() { const pool = DRILL_TYPES.flatMap(t => Array(t.w).fill(t)); @@ -87,6 +100,14 @@ function spawnDrillBody(d) { export function startDrillStream() { const d = G.drill; if (!d) return; zombies.length = 0; + // The rule gets ONE sentence, once per run, on the sanctioned calm court (the seeded bodies are + // all at the far baseline — nothing is near). Oliver's worry is right: "most urgent first" is NOT + // guessable from a chevron alone, and this is the modal policy's exact use case. + if (!G.seen.drillRule) { + G.seen.drillRule = true; + showModal({ title: 'CLEARANCE DRILL', color: UI.pick, + body: ['DROP WHOEVER WOULD REACH YOU FIRST', 'FAST BEATS CLOSE · STREAKS PAY'] }); + } d.t = 0; d.spawnT = 0; d.score = 0; d.streak = 0; d.kills = 0; d.faults = 0; d.lost = 0; d.msg = null; try { d.best = Number(localStorage.getItem('drillBest')) || 0; } catch { d.best = 0; } // seed a small spread so there is a queue to read from the first second diff --git a/src/render.js b/src/render.js index c053b51..0921708 100644 --- a/src/render.js +++ b/src/render.js @@ -4,7 +4,7 @@ import { cv, ctx } from './dom.js'; import { TUNE, UI, EATEN_BURST, EATEN_SUMMARY, EATEN_RESTART, COURT, P_BOUNDS, PLAYER_H, BOY_H, UMP_H, UMP_X, MACH_H, BOSS_ART, ZWOUND, woundStage, BOUNCE_IDLE, SPRITE_H, zombieH , WEAPONS, RACK_ROW, TOSS } from './config.js'; import { P, O, ball, score, crowd, G, court, tour } from './state.js'; import { predictLanding, previewServe, slotId, slotStock } from './gameplay.js'; -import { drillQueue, drillMult } from './drill.js'; +import { drillQueue, drillLegal, drillMult } from './drill.js'; import { zombies, gore, piles, parts, pellets, extras, shots, pops } from './zombies.js'; // ---------------------------------------------------------------- palette ---- @@ -696,21 +696,28 @@ function drawDrillHud() { ctx.font = F(8); shadowText('BEST ' + Math.max(d.best, d.score), W / 2, y + 44, UI.dim); if (d.msg && d.msg.t > 0) { ctx.font = B(20); shadowText(d.msg.text, W / 2, y + 60, d.msg.bad ? UI.bad : UI.pick); } - // THE CALIBRATION MARKER (TUNE.drillMark): a chevron over the body the rule says is next. For the - // playtest only — it exists so Oliver can check the game's read against his own, and it answers - // the exact question the mode is asking, so it must be OFF in anything shipped. - if (TUNE.drillMark >= 0.5) { - const head = drillQueue()[0]; - if (head) { - const p = proj(head.x, zombieH(head) + 0.5, head.z); - if (p.s > 0) { - const bob = Math.sin(G.idleT * 6) * 2; - const px = Math.round(p.x), py = Math.round(p.y + bob); - ctx.fillStyle = UI.warm; - ctx.beginPath(); - ctx.moveTo(px - 5, py - 7); ctx.lineTo(px + 5, py - 7); ctx.lineTo(px, py); - ctx.closePath(); ctx.fill(); - } + // THE MARKERS ARE TRAINING WHEELS THAT TAKE THEMSELVES OFF. Every LEGAL kill is marked — the + // whole slack set, not the bare head: the rule accepts any near-tie, so a single chevron was + // stricter than the rule it explained and every kill made it jump between tied bodies. Marking + // the set is also stable by construction (arrival differences never change while bodies walk; + // the set only moves on a kill or an arrival). + // They HIDE once the streak shows you have the read (drillMarkOff) and come back on any fault — + // which answers "would a player get this without the marker": the marker teaches, the streak + // decides when you have learned it, and unlearning (a fault) brings the teacher back. + if (TUNE.drillMark >= 0.5 && d.streak < TUNE.drillMarkOff) { + const legal = drillLegal(); + for (let i = 0; i < legal.length; i++) { + const zb = legal[i]; + const p = proj(zb.x, zombieH(zb) + 0.5, zb.z); + if (p.s <= 0) continue; + const bob = Math.sin(G.idleT * 6) * 2; + const px = Math.round(p.x), py = Math.round(p.y + bob); + ctx.globalAlpha = i === 0 ? 1 : 0.45; // the head is bright; a tied body is a quiet "also fine" + ctx.fillStyle = UI.warm; + ctx.beginPath(); + ctx.moveTo(px - 5, py - 7); ctx.lineTo(px + 5, py - 7); ctx.lineTo(px, py); + ctx.closePath(); ctx.fill(); + ctx.globalAlpha = 1; } } } diff --git a/test.js b/test.js index befe43d..d38e7c3 100644 --- a/test.js +++ b/test.js @@ -83,10 +83,13 @@ globalThis.getComputedStyle = () => ({ paddingTop: '0' }); // ---- game modules (real code) ---------------------------------------------- const { G, P, score, ball, setupPoint } = await import('./src/state.js'); const { update, forceZombies, splatPoint, restartRun, flickTarget } = await import('./src/gameplay.js'); -const { startDuel, resolveInterlude, startBossFight, endBossFight, startDrill, endDrill } = await import('./src/beats.js'); +const { startDuel, resolveInterlude, startBossFight, endBossFight, startDrill: _startDrill, endDrill } = await import('./src/beats.js'); +// the drill's first-sight modal freezes the world (that is what modals do) — dismiss it the way a +// player would, so the tests step a live court +const startDrill = (...a) => { const r = _startDrill(...a); G.modal = null; G.modalQ.length = 0; return r; }; const { zombies, spawnZombie, pellets } = await import('./src/zombies.js'); const { killPlayer } = await import('./src/zombies.js'); -const { drillQueue, arrival, drillMult, DRILL_TYPES } = await import('./src/drill.js'); +const { drillQueue, drillLegal, arrival, drillMult, DRILL_TYPES } = await import('./src/drill.js'); const { TUNE, bossArt, COURT } = await import('./src/config.js'); const DT = 1 / 60; @@ -467,6 +470,26 @@ test('drillOneShot is a switch, and OFF means the roster HP is intact', () => { else assert.ok(heavy.hp > 1, 'one-shot off: a ' + heavy.variant + ' keeps its ' + heavy.hp + ' hp'); }); +test('the rule announces itself once, and the markers are the legal SET', () => { + _startDrill(); // the raw one: we WANT the modal this time + assert.ok(G.modal, 'first drill of a run: the rule gets its one sentence'); + assert.equal(G.modal.title, 'CLEARANCE DRILL'); + G.modal = null; + endDrill(); _startDrill(); + assert.equal(G.modal, null, 'second drill of the same run: no re-teaching'); + G.modal = null; + // the legal set: everything within the slack of the head, and never anything beyond it + zombies.length = 0; G.drill.spawnT = 999; + for (const [dz, v] of [[6, 1.0], [6.5, 1.0], [16, 1.0]]) { // 6 s, 6.5 s, 16 s out + spawnZombie(0, COURT.L - dz, 'a'); + const zb = zombies[zombies.length - 1]; + zb.drill = true; zb.pts = 20; zb.v = v; zb.hp = zb.hpMax = 1; + } + const legal = drillLegal(); + assert.equal(legal.length, 2, 'the 0.5 s tie is IN the marker set (slack ' + TUNE.drillSlack + ')'); + assert.ok(!legal.some(z => Math.abs(arrival(z) - 16) < 0.5), 'the 16 s body is not'); +}); + test('endDrill hands the court back', () => { drillPlaying(); endDrill();