The umpire reads a list, and the bibs come out to meet it
The late game is repetitive because the optimal move never changes: kill the nearest thing, now. This is the first swing at a mode where it does — a coaching drill where the umpire CALLS a colour sequence aloud while bibbed bodies take their marks, and you must put them down in that order while they walk at you. Uncalled colours are decoys you have to ignore. Two faults is a double fault and the round is theirs. Back room only (DRILL TEST, or #drill): this is a feel-test for one question — tense, or just noisy? — so it keeps its own round count and touches neither tennis scoring nor the run. - voiceSeq: voice() drops a line that would start while the umpire is still talking, which is fatal for a list. Decode first, queue at accumulated offsets, and the anti-overlap rule is satisfied instead of fought. - Four colours with PIPS as well as hue — red/green alone makes the mode unplayable for ~8% of men. - drawBib is a ponytail-marked overlay that knowingly breaks the sprite rule; it is the thing to delete if the mode survives. - BOTH target pickers snap (flickTarget and zombieTarget). Snapping only the slingshot left fifteen tap-serves in a row scoring nothing. - Contact costs the point, not the run — permadeath stays in the gore mode. - startDrill disarms the ordinary schedule; updateDrill redeals and logs an INVARIANT if anything empties its court. Eight new tests (24 total) pin the ownership contract both ways, the call beat, the win, the double fault, the un-winnable-round guard and the death. PROJECT.md carries the four open questions for the playtest. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
c659b6882f
commit
22df96cc1d
17 files changed
+556
-19
No files matched your search
+76
-3
@@ -10,7 +10,7 @@ detail of any individual change, read the git log.
|
||||
the state-machine contracts that have historically produced the show-stoppers. The Dockerfile runs
|
||||
it as a build stage, so a red suite cannot deploy — but finding out from Coolify is the slow way.
|
||||
|
||||
Last updated 2026-08-11 (the horde's breather restored, the boss walk-on overlay; beats.js
|
||||
Last updated 2026-08-11 (the coaching drill lands as a back-room prototype; the horde's breather restored, the boss walk-on overlay; beats.js
|
||||
extraction, this file renamed, tests gate the build). **Start with "How to work on this", "OPEN — leftovers" and "LATER —
|
||||
shipping it"** (all three just above the CLOSED sections) — the first tells you how Oliver's feedback
|
||||
should be handled, the second is everything known to be outstanding in the game, the third is the
|
||||
@@ -57,8 +57,10 @@ the primary target.
|
||||
`config.js` TUNE (live T panel) + structural constants + `UI` palette · `state.js` the `G` object +
|
||||
P/O/ball/score + `setupPoint`/`sendBoy` · `gameplay.js` ballistics/swings/serve/rules/AI/`update()` +
|
||||
transition + `splatPoint` + ball-boy routines · `beats.js` **the beat machinery: every mode
|
||||
transition (duel/horde/boss/changeover/`proceedAfterClear`) — ONE MODE OWNS THE COURT, and each
|
||||
`start*` enforces that itself; a new mode starts there and refuses there** · `zombies.js`
|
||||
transition (duel/horde/boss/changeover/drill/`proceedAfterClear`) — ONE MODE OWNS THE COURT, and
|
||||
each `start*` enforces that itself; a new mode starts there and refuses there** · `drill.js` the
|
||||
coaching drill's own clock, bodies and rules (its transitions live in beats.js like every other
|
||||
mode's) · `zombies.js`
|
||||
zombie/gore/particle state + `updateZombies` · `render.js` camera/palette-lerp/sprites/all drawing ·
|
||||
`input.js` keys/touch/flick · `audio.js` sfx + recorded umpire (`assets/audio/*`, macOS
|
||||
`say -v Daniel`) · `panel.js` the T panel · `main.js` boot, loop, scoreboard test-mode taps,
|
||||
@@ -431,6 +433,77 @@ WELL drains instead: a gold wash across the whole tile, emptying leftward, clipp
|
||||
rounded path (`panel` leaves it as the current path) and drawn under the icon and labels. Measured
|
||||
lit vs empty on the live tile: rgb(88,125,68) against rgb(31,84,49).
|
||||
|
||||
## Session log: THE COACHING DRILL — the cognitive mode, as a prototype (2026-08-11, same day)
|
||||
|
||||
Oliver and his team-mate decided the game needs **a different angle alongside the gore mode**, after
|
||||
he reported the late game going repetitive: *"something that requires some immediate strategy other
|
||||
than saving specific weapons for a boss I was killed by last time"*. Three modes planned — classic
|
||||
tennis, **splatter tennis** (this), splatter tennis GORE (what exists). What went in today is the
|
||||
middle one's feel-test, not the mode: **back room only** (`DRILL TEST`, or `#drill`), no menu entry,
|
||||
nothing wired to tennis scoring.
|
||||
|
||||
**Why the late game is flat, for the record** (diagnosed before proposing anything, per the
|
||||
meta-lesson). The optimal move never changes: kill the nearest thing, now. Every interruption is on
|
||||
a fixed metronome (duel/3, machine/4, horde/6, boss/10, barrel/every game past 16), the director's
|
||||
dials all bottom out by ~game 25, nothing is scarce once the rack is full — and **the zombie side's
|
||||
score is unreachable by construction**: contact is a radius around the player and bodies home on his
|
||||
x, so anything that could cross the baseline eats him first. The crossing branch in `updateZombies`
|
||||
(and the `ump_unsportsmanlike` line with it) is very nearly dead code. Oliver caught that himself —
|
||||
the first design direction proposed this session was built on it and had to be thrown away.
|
||||
|
||||
**The mode.** The umpire CALLS a colour sequence aloud while bibbed bodies jog on and take their
|
||||
marks; then they come for you and you put them down in the called order. Colours never called are
|
||||
DECOYS — bodies walking at you that you are required to ignore. Two faults = double fault = the
|
||||
round is theirs. Settled in discussion: **audio-first, and shown as a pattern too.**
|
||||
|
||||
- **`voiceSeq` in audio.js** is what makes the call possible at all. `voice()` DROPS a line that
|
||||
would start while the umpire is still talking (one man, one microphone) — correct for unrelated
|
||||
triggers, fatal for a list. It decodes every clip FIRST, then queues them all at accumulated
|
||||
offsets, so the anti-overlap rule is satisfied by construction rather than fought. Five new
|
||||
recordings (`say -v Daniel -r 168` -> `afconvert`, the existing pipeline).
|
||||
- **Four colours, not six**, and every bib carries PIPS as well as a hue: ~8% of men cannot separate
|
||||
red from green, and a mode whose entire input is "which colour is that" owes them a second
|
||||
channel. The same pips appear on the HUD strip, so the two readouts teach each other.
|
||||
- **The bib is a DRAWN OVERLAY and it knowingly breaks the sprite rule** (`ponytail:` marked in
|
||||
`drawBib`). Six bib sheets x every walk frame is a week of art to answer a question a trapezoid
|
||||
answers today. If the mode survives its playtest, `drawBib` is the thing to DELETE and replace
|
||||
with a real bib sprite per colour, drawn like a wound skin. Shoulders wider than waist — the first
|
||||
cut flared the other way and every body was wearing a sandwich board.
|
||||
- **TWO functions pick a target, and snapping only one of them was the bug.** `flickTarget` (the
|
||||
slingshot) and `zombieTarget` (the plain tap) are separate paths; with only the first snapped,
|
||||
fifteen tap-serves in a row scored zero and the round just ran out. Both snap now. Anything that
|
||||
changes how a shot chooses a body has to touch both — same shape as the "three of five weapons
|
||||
silently no-oped on the racket zombie" bug.
|
||||
- **Contact costs the POINT, not the run** — a deliberate contract change, hooked in `killPlayer`
|
||||
(the single death path for both damage channels, which is why one hook covers every way in).
|
||||
Permadeath stays settled in the gore mode: a memory game where one lapse deletes a thirty-minute
|
||||
run punishes the exact skill it is trying to teach.
|
||||
- **The empty-court net** in `updateDrill`: a live round with nothing bibbed on court means something
|
||||
outside the drill emptied it (seen once while wiring up, not reproducible afterwards). Unrepaired
|
||||
that is a dead mode — no targets, no clock, nothing to tap. It redeals and logs `INVARIANT`, the
|
||||
same contract `watchdog` uses. `startDrill` also DISARMS the ordinary schedule (`pendingHorde`,
|
||||
`hordeWarn`, `bossDue`, `duelDue`): taking the court is not enough if someone else's arming latch
|
||||
is still loaded behind you.
|
||||
- **Eight new tests** (24 total) pin the ownership contract both ways, the call beat, the clean win,
|
||||
the double fault, the un-winnable-round guard and the softer death.
|
||||
|
||||
**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** — measured: one serve
|
||||
killed bib 1 and then bib 2 standing behind it, banking two correct steps in one shot. Had the
|
||||
second body been out of order that is an instant fault the player could not have intended. Either
|
||||
the lane is part of the puzzle (it is the same occlusion that makes footwork matter) or the pierce
|
||||
has to stop on a drill kill. Do not patch it silently — it is a design fork, not a bug.
|
||||
2. **The tap now aims itself completely.** With `zombieTarget` snapped, a bare tap is a legal shot at
|
||||
the called body, so the mode currently has NO aiming skill in it at all — only order and tempo.
|
||||
That is probably right for a cognitive mode played one-thumbed, and occlusion is the only thing
|
||||
left that makes standing position matter. Worth a deliberate look rather than an accident.
|
||||
3. Difficulty climbs on sequence LENGTH today (`drillSeq` 3 -> `drillSeqMax` 4, +1 every 3 rounds)
|
||||
and is deliberately capped low: a longer list is a memory test and memory tests fatigue. The dial
|
||||
with real headroom is the GEOMETRY — how often the called order fights the order they reach you
|
||||
in, and how often the target is behind someone. Unbuilt.
|
||||
4. Nothing is wired to tennis scoring, the menu or the run, and the mode has no ending. That is the
|
||||
next job IF the answer to "tense or noisy?" comes back tense.
|
||||
|
||||
## Session log: beats.js, the rename, and the build gate (2026-08-11, same day)
|
||||
|
||||
Oliver's structural question ("framework? smaller files?") answered with a deliberate NO on both —
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+2
-1
@@ -373,6 +373,7 @@
|
||||
<div class="foot bare">
|
||||
<button class="btn big" id="scWeapons">WEAPON TEST</button>
|
||||
<button class="btn big" id="scBosses">BOSS TEST</button>
|
||||
<button class="btn big" id="scDrill">DRILL TEST</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- one tile per chapter boss, built from config.bossArt in main.js: adding a design to the
|
||||
@@ -398,6 +399,6 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script type="module" src="src/main.js?v=151"></script>
|
||||
<script type="module" src="src/main.js?v=152"></script>
|
||||
</body>
|
||||
</html>
|
||||
+32
-4
@@ -43,11 +43,39 @@ export function voice(name, delay = 0, rate = 1) {
|
||||
src.start(ac.currentTime + delay);
|
||||
};
|
||||
if (VOICE[name]) return play(VOICE[name]);
|
||||
fetch('assets/audio/' + name + '.m4a')
|
||||
loadVoice(name).then(play).catch(() => say(name.replace(/_/g, ' '), delay));
|
||||
}
|
||||
|
||||
function loadVoice(name) {
|
||||
if (VOICE[name]) return Promise.resolve(VOICE[name]);
|
||||
return fetch('assets/audio/' + name + '.m4a')
|
||||
.then(r => { if (!r.ok) throw 0; return r.arrayBuffer(); })
|
||||
.then(ab => ac.decodeAudioData(ab))
|
||||
.then(buf => { VOICE[name] = buf; play(buf); })
|
||||
.catch(() => say(name.replace(/_/g, ' '), delay));
|
||||
.then(ab => audio().decodeAudioData(ab))
|
||||
.then(buf => (VOICE[name] = buf));
|
||||
}
|
||||
|
||||
// THE UMPIRE READS A LIST. voice() DROPS anything that would start while he is still talking (one
|
||||
// man, one microphone) — correct for unrelated triggers, fatal for a called sequence, where the
|
||||
// whole point is several lines back to back. This schedules them as one utterance instead: every
|
||||
// clip is decoded FIRST, then all of them are queued at accumulated offsets, so the anti-overlap
|
||||
// rule is satisfied by construction rather than fought. Fire-and-forget — the caller's beat clock
|
||||
// runs at its own pace, and a late decode simply means he starts a moment later. Returns the total
|
||||
// speaking time (0 until the clips are cached) so a beat CAN be sized to it if one ever needs to be.
|
||||
export function voiceSeq(names, gap = 0.12) {
|
||||
Promise.all(names.map(loadVoice)).then(bufs => {
|
||||
const ac = audio();
|
||||
let at = ac.currentTime + 0.05;
|
||||
for (const buf of bufs) {
|
||||
const src = ac.createBufferSource(); src.buffer = buf;
|
||||
src.connect(ac.destination);
|
||||
const send = ac.createGain(); send.gain.value = 1.8;
|
||||
src.connect(send).connect(reverb(ac));
|
||||
src.start(at);
|
||||
at += buf.duration + gap;
|
||||
}
|
||||
voiceUntil = at; // he is busy for the whole list — ordinary lines stay out of the way
|
||||
}).catch(() => { for (const n of names) say(n.replace(/^drill_/, '').replace(/_/g, ' ')); });
|
||||
return names.reduce((t, n) => t + (VOICE[n] ? VOICE[n].duration + gap : 0), 0);
|
||||
}
|
||||
|
||||
// Recorded one-shot SFX (assets/audio/tennis-*.wav): ball hits, applause, the crowd's aww.
|
||||
|
||||
+33
-4
@@ -12,6 +12,7 @@ import { G, P, ball, score, setupPoint, sendBoy } from './state.js';
|
||||
import { voice, zombieDuck, aww } from './audio.js';
|
||||
import { zombies, pellets, extras, shots, spawnRacketZombie, spawnChapterBoss, spawnZombie } from './zombies.js';
|
||||
import { cheer, aside, endBurst, deliverTin, showModal, logSwing } from './gameplay.js';
|
||||
import { newDrillRound } from './drill.js';
|
||||
|
||||
// the court is mopped up (or was already clear): play advances to whatever the won game earned —
|
||||
// a changeover breather, a plain even-game beat, or (on a set) the centered SET card then a horde.
|
||||
@@ -62,7 +63,7 @@ function startChangeover() {
|
||||
export function startDuel() {
|
||||
// Hard mutual exclusion, HERE, where every caller routes through: a duel must never begin while
|
||||
// another control mode owns the court. Each of these has ended a run when they overlapped.
|
||||
if (G.interlude || G.horde || G.bossFight) return;
|
||||
if (G.interlude || G.horde || G.bossFight || G.drill) return;
|
||||
if (!zombies.some(z => z.variant === 'r')) spawnRacketZombie(); // the schedule guarantees an opponent
|
||||
for (const z of zombies) if (z.variant === 'r') z.dueled = true; // a veteran can be challenged again — this marks the fresh duel
|
||||
G.weapon = 0; // tennis rules for the duel: plain balls, no tins
|
||||
@@ -85,7 +86,7 @@ export function startDuel() {
|
||||
// It threatens BOTH channels: its volleys chip your hp, and letting it reach you is the ordinary
|
||||
// instant death. G.bossFight holds the chapter number while it runs.
|
||||
export function startBossFight(chapter) {
|
||||
if (G.bossFight) return;
|
||||
if (G.bossFight || G.drill) return;
|
||||
G.bossFight = chapter;
|
||||
zombies.length = 0; pellets.length = 0; extras.length = 0; shots.length = 0; // a clean stage
|
||||
G.horde = null; G.mgT = 0; endBurst(); G.clearing = false;
|
||||
@@ -129,6 +130,34 @@ export function endBossFight() {
|
||||
if (!G.interlude) { G.phase = 'over'; G.overT = Math.max(G.overT, TUNE.bossWinT + TUNE.bossDeathT); }
|
||||
}
|
||||
|
||||
// ---- THE COACHING DRILL: the cognitive mode. Same contract as every mode above — it refuses to
|
||||
// start while anything else owns the court, and it clears the court it takes over, so nothing that
|
||||
// was walking around a moment ago is still walking around inside a drill round. The drill's own
|
||||
// clock and rules live in drill.js; only the transition is here, where the rule can be enforced.
|
||||
export function startDrill() {
|
||||
if (G.drill || G.interlude || G.horde || G.bossFight) return false;
|
||||
G.drill = { seq: [], step: 0, faults: 0, callT: 0, beat: 0, rush: 0, won: 0, lost: 0, msg: null };
|
||||
G.changeover = false; G.clearing = false; G.afterClear = null;
|
||||
// ...and DISARM the ordinary schedule. Taking the court is not enough on its own: a pending
|
||||
// horde, an owed chapter or a due duel is a latch that keeps trying to fire behind the drill,
|
||||
// and the mode it belongs to has no bodies of its own here to fire at.
|
||||
G.pendingHorde = false; G.hordeWarn = 0; G.bossDue = 0; G.duelDue = false;
|
||||
G.mgT = 0; endBurst(); pellets.length = 0; extras.length = 0; shots.length = 0;
|
||||
G.boy = null; // nobody mops through a drill
|
||||
G.weapon = 0; // plain balls: the mode is about order, not about the rack
|
||||
ball.active = false; setupPoint();
|
||||
voice('quiet_please', 0.1);
|
||||
newDrillRound();
|
||||
return true;
|
||||
}
|
||||
|
||||
export function endDrill() {
|
||||
if (!G.drill) return;
|
||||
G.drill = null;
|
||||
zombies.length = 0;
|
||||
ball.active = false; setupPoint();
|
||||
}
|
||||
|
||||
// ---- THE DEADLOCK WATCHDOG.
|
||||
// A run that hard-locks costs the player everything, and this file has form: `mgT` was once left
|
||||
// above zero by a beat that moved the phase underneath it, and `mgT > 0` blocks serving, so the
|
||||
@@ -211,7 +240,7 @@ export function resolveInterlude(side) {
|
||||
// anymore". It is an ordinary 'over' beat (no serving, the world keeps walking, the RESUMING clock
|
||||
// counts it down under the flash), and startHorde still owns the transition itself.
|
||||
export function warnHorde() {
|
||||
if (G.horde || G.interlude || G.bossFight) return false; // same court-ownership contract as the rest
|
||||
if (G.horde || G.interlude || G.bossFight || G.drill) return false; // same court-ownership contract as the rest
|
||||
G.hordeWarn = TUNE.hordeWarnT;
|
||||
G.flash = { text: 'THEY KEEP COMING', t: TUNE.hordeWarnT };
|
||||
ball.active = false; G.phase = 'over'; G.overT = TUNE.hordeWarnT;
|
||||
@@ -222,7 +251,7 @@ export function warnHorde() {
|
||||
// Returns false when another mode owns the court — the caller re-arms the latch rather than
|
||||
// silently eating the wave the set paid for.
|
||||
export function startHorde() {
|
||||
if (G.horde || G.interlude || G.bossFight) return false; // same contract as startDuel: one mode owns the court
|
||||
if (G.horde || G.interlude || G.bossFight || G.drill) return false; // same contract as startDuel: one mode owns the court
|
||||
G.changeover = false; // a cheat-forced horde mid-changeover must not leave spawns locked
|
||||
const grow = 1 + (G.sets[0] + G.sets[1]) * TUNE.hordeGrow; // each set banked makes the next wave worse
|
||||
// the gun grows WITH the wave: spawn rate and length both scale by grow, so a flat gun fell
|
||||
|
||||
@@ -345,6 +345,24 @@ export const TUNE = {
|
||||
barrelRoll: 2.6, // m/s it trundles across the far court — fast enough to have to be led
|
||||
barrelHp: 1, // anything pops it: ball, pellet, bomb, flame, swish. It is a target, not a body
|
||||
barrelH: 1.15, // drawn height (a body is 1.8)
|
||||
// ---- THE COACHING DRILL (src/drill.js) — the cognitive mode, currently a feel-test prototype.
|
||||
// The umpire calls a colour sequence while bibbed bodies take their marks; you put them down in
|
||||
// that order. Uncalled colours are decoys. Two faults and they take the point.
|
||||
drillBodies: 4, // bibs on court. Capped by DRILL_COLS — four hues is what reads at phone scale
|
||||
drillSeq: 3, // colours CALLED in round one; the rest of the court is decoys
|
||||
drillSeqMax: 4, // ...and the ceiling it climbs to (+1 every 3 rounds). Deliberately low: a longer
|
||||
// list is a memory test and memory tests fatigue. What should keep getting harder
|
||||
// is the GEOMETRY — how badly the called order fights the order they reach you in.
|
||||
drillCallT: 4.2, // s of walk-on while the umpire reads the list. No serving during it
|
||||
drillWalkOn: 3.4, // m/s they jog out to their marks (a walk-on is brisk; the approach is not)
|
||||
drillMarkZ: 5.0, // near edge of the band they line up in...
|
||||
drillMarkSpread: 6.5, // ...and how deep that band runs, so they never arrive as a flat rank
|
||||
drillSpread: 3.4, // m either side of the centre line the lanes fan across
|
||||
drillSpeed: 0.95, // m/s they advance once the call is over
|
||||
drillSpeedPerRound: 0.06, // ...climbing per round survived
|
||||
drillRush: 1.9, // speed multiplier for drillRushT after a FAULT — the punishment is tempo, and
|
||||
drillRushT: 1.6, // it has to be felt in the body, not read off a scoreline
|
||||
drillBeat: 2.0, // s between rounds
|
||||
fps: 60, // frame cap — 30 halves battery/heat but Oliver felt it; smoothness wins (0 = uncapped)
|
||||
lowPower: 1, // >= 0.5 caps RES at 3 (−44% of pixels painted per frame) — the battery saver,
|
||||
// flippable live from the T panel. DEFAULT ON since 2026-08-08: Oliver judged
|
||||
|
||||
+162
@@ -0,0 +1,162 @@
|
||||
// THE COACHING DRILL — the cognitive mode. A prototype, deliberately: it exists to answer one
|
||||
// question ("is reading a colour order off small moving bodies TENSE or just NOISY?") and nothing
|
||||
// else, so it keeps its own round counter and does not touch tennis scoring. Wire it to score when
|
||||
// the answer comes back yes.
|
||||
//
|
||||
// The beat: the umpire calls a sequence out loud while bibbed bodies walk on and take their marks,
|
||||
// then they come for you and you must put them down IN THE CALLED ORDER. Bodies whose colour was
|
||||
// never called are DECOYS and must be left alone. Two faults and they take the point — the double
|
||||
// fault is the sport's own rule for "you had two goes at it", and the recording already exists.
|
||||
//
|
||||
// THE MODE TRANSITIONS ARE NOT HERE — startDrill/endDrill live in beats.js with every other one
|
||||
// (see CLAUDE.md rule 3: one mode owns the court, and each start* refuses there). This file is the
|
||||
// drill's own clock, its bodies and its rules. It imports no renderer: render.js reads DRILL_COLS
|
||||
// and draws, which keeps the cycle out of it.
|
||||
|
||||
import { TUNE } from './config.js';
|
||||
import { G, P } from './state.js';
|
||||
import { zombies, spawnZombie } from './zombies.js';
|
||||
import { voice, voiceSeq, sfx } from './audio.js';
|
||||
import { logSwing } from './gameplay.js'; // runtime-only cycle (gameplay imports drill), same pattern as beats/zombies
|
||||
|
||||
// The bib colours. FOUR, not six: at 270 logical px across, a body at the far baseline is a
|
||||
// thumbnail, and six hues that read fine in a swatch strip do not read at all on a walking sprite.
|
||||
// Every entry carries `pips` as well as a colour — roughly 8% of men cannot separate the red from
|
||||
// the green, and a mode whose whole input is "which colour is that" is unplayable for them on hue
|
||||
// alone. The pip count is the redundant channel and it is drawn on the bib AND in the HUD strip.
|
||||
export const DRILL_COLS = [
|
||||
{ key: 'red', voice: 'drill_red', col: '#e5342c', ink: '#2a0806', pips: 1, name: 'RED' },
|
||||
{ key: 'blue', voice: 'drill_blue', col: '#2f6bff', ink: '#050f2c', pips: 2, name: 'BLUE' },
|
||||
{ key: 'yellow', voice: 'drill_yellow', col: '#f2c318', ink: '#2e2202', pips: 3, name: 'YELLOW' },
|
||||
{ key: 'green', voice: 'drill_green', col: '#27bd4a', ink: '#04240f', pips: 4, name: 'GREEN' },
|
||||
];
|
||||
|
||||
const shuffle = a => { for (let i = a.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [a[i], a[j]] = [a[j], a[i]]; } return a; };
|
||||
|
||||
// How long the drill has been running decides how hard it is. Sequence length climbs first and
|
||||
// then STOPS (TUNE.drillSeqMax): a longer list is a memory test, and memory tests fatigue — what
|
||||
// keeps getting harder is the geometry, i.e. how often the called order fights the order the
|
||||
// bodies are actually going to reach you in. That is the dial with the headroom.
|
||||
const seqLen = r => Math.min(TUNE.drillSeqMax, TUNE.drillSeq + Math.floor(r / 3));
|
||||
|
||||
export function newDrillRound() {
|
||||
const d = G.drill; if (!d) return;
|
||||
zombies.length = 0;
|
||||
const n = Math.min(DRILL_COLS.length, TUNE.drillBodies);
|
||||
const k = Math.min(n, seqLen(d.won + d.lost));
|
||||
// Every body on court wears a DISTINCT bib, so "the red one" is never ambiguous. The sequence
|
||||
// names k of them; the other n-k are decoys, and a decoy is a body actively walking at you that
|
||||
// you are required to ignore. That is where the pressure comes from, and it costs nothing.
|
||||
const bibs = shuffle(DRILL_COLS.map((_, i) => i)).slice(0, n);
|
||||
d.seq = shuffle(bibs.slice()).slice(0, k);
|
||||
d.step = 0; d.faults = 0; d.beat = 0;
|
||||
d.callT = TUNE.drillCallT;
|
||||
// Distinct lanes, spread wide, shuffled against the bib order — so the called order and the
|
||||
// left-to-right order are unrelated, and so are the called order and the DEPTH order. Two bodies
|
||||
// sharing a lane occlude each other (the ball hits the first thing it touches), which is the
|
||||
// footwork half of the mode: sometimes the only way to reach your target is to sidestep first.
|
||||
const lanes = shuffle(bibs.map((_, i) => -TUNE.drillSpread + i * (2 * TUNE.drillSpread / Math.max(1, n - 1))));
|
||||
bibs.forEach((bib, i) => {
|
||||
spawnZombie(lanes[i], 1.2 + Math.random() * 1.6, Math.random() < 0.25 ? 'b' : 'a');
|
||||
const zb = zombies[zombies.length - 1];
|
||||
zb.bib = bib;
|
||||
zb.drill = true;
|
||||
zb.v = 0; // updateDrill owns their movement for the whole round
|
||||
zb.mark = { x: lanes[i], z: TUNE.drillMarkZ + Math.random() * TUNE.drillMarkSpread };
|
||||
});
|
||||
voiceSeq(['drill_call', ...d.seq.map(b => DRILL_COLS[b].voice)]);
|
||||
}
|
||||
|
||||
// THE ROUND CLOCK. Drill bodies carry v = 0 so the ordinary shamble in updateZombies leaves them
|
||||
// alone; everything else in that loop — the ball contact test, the instant-death contact test —
|
||||
// still runs against wherever this puts them, which is exactly the split we want.
|
||||
export function updateDrill(dt) {
|
||||
const d = G.drill; if (!d) return;
|
||||
if (d.msg) d.msg.t -= dt;
|
||||
if (d.beat > 0) { if ((d.beat -= dt) <= 0) newDrillRound(); return; }
|
||||
// THE EMPTY-COURT NET. A drill round is only ever ended by drillWon/drillLost, both of which set
|
||||
// `beat` — so a live round with nothing bibbed on court means something OUTSIDE the drill emptied
|
||||
// it. I saw this exactly once while wiring the mode up (a warm-up had left the ordinary schedule's
|
||||
// latches loaded) and could not reproduce it afterwards, which is precisely the kind of bug that
|
||||
// comes back in front of a player. Unrepaired it is a dead mode: no targets, no timer, nothing to
|
||||
// tap. Deal a fresh round and leave evidence, same contract as watchdog()'s invariant net.
|
||||
if (!zombies.some(z => z.bib !== undefined)) {
|
||||
logSwing({ result: 'INVARIANT', reason: 'drill court emptied from outside the round — redealt' });
|
||||
return newDrillRound();
|
||||
}
|
||||
if (d.callT > 0) { // the walk-on: they take their marks
|
||||
d.callT -= dt;
|
||||
for (const zb of zombies) {
|
||||
if (!zb.mark) continue;
|
||||
const dz = zb.mark.z - zb.z;
|
||||
zb.z += Math.sign(dz) * Math.min(Math.abs(dz), TUNE.drillWalkOn * dt);
|
||||
zb.dist = (zb.dist || 0) + Math.min(Math.abs(dz), TUNE.drillWalkOn * dt);
|
||||
zb.walkT += dt;
|
||||
}
|
||||
return;
|
||||
}
|
||||
const v = TUNE.drillSpeed * (1 + (d.won + d.lost) * TUNE.drillSpeedPerRound) * (d.rush > 0 ? TUNE.drillRush : 1);
|
||||
if (d.rush > 0) d.rush -= dt;
|
||||
for (const zb of zombies) {
|
||||
if (!zb.drill) continue;
|
||||
zb.z += v * dt; zb.dist = (zb.dist || 0) + v * dt; zb.walkT += dt;
|
||||
}
|
||||
}
|
||||
|
||||
// A drill body went down. The ONE rule of the mode, and it is called from killZombie — the single
|
||||
// kill funnel — so it cannot be missed by a weapon path (the bug this codebase has had twice).
|
||||
export function drillKill(zb) {
|
||||
const d = G.drill; if (!d || zb.bib === undefined) return;
|
||||
if (zb.bib === d.seq[d.step]) {
|
||||
d.step++;
|
||||
sfx('pock', 0.7);
|
||||
if (d.step >= d.seq.length) return drillWon();
|
||||
return;
|
||||
}
|
||||
// Wrong body — a decoy, or one that was due later. A body killed out of turn is still DEAD, so
|
||||
// drop it from the list rather than leaving a slot nobody can ever fill: an un-winnable round
|
||||
// reads as the game being broken, and the fault below is the punishment already.
|
||||
d.faults++;
|
||||
d.seq = d.seq.filter((c, i) => i < d.step || c !== zb.bib);
|
||||
d.rush = TUNE.drillRushT; // and the rest of them lose patience — the cost is TEMPO
|
||||
if (d.faults >= 2) return drillLost('double_fault', 'DOUBLE FAULT');
|
||||
voice('fault', 0.1);
|
||||
d.msg = { text: 'FAULT', t: 1.4, bad: true };
|
||||
if (d.step >= d.seq.length) drillWon(); // the wrong kill happened to be the last one outstanding
|
||||
}
|
||||
|
||||
// A drill body reached you. Called from killPlayer, which is the single death path for BOTH damage
|
||||
// channels — so this intercepts every way the round can end badly, in one place.
|
||||
// NOTE, and it is a real design decision rather than an oversight: contact here costs the POINT,
|
||||
// not the run. Permadeath is settled for the gore mode and it should stay there; a memory game
|
||||
// where one lapse of concentration deletes a thirty-minute run punishes the exact skill it is
|
||||
// trying to teach, and the retry loop is the whole reason a drill is fun.
|
||||
export function drillCaught() {
|
||||
drillLost('game_opponent', 'CAUGHT');
|
||||
}
|
||||
|
||||
function drillWon() {
|
||||
const d = G.drill;
|
||||
d.won++;
|
||||
voice(d.faults ? 'game_player' : 'unbelievable', 0.15);
|
||||
d.msg = { text: d.faults ? 'GAME' : 'CLEAN SHEET', t: 1.8 };
|
||||
d.beat = TUNE.drillBeat;
|
||||
zombies.length = 0;
|
||||
}
|
||||
|
||||
function drillLost(line, text) {
|
||||
const d = G.drill;
|
||||
d.lost++;
|
||||
voice(line, 0.1);
|
||||
d.msg = { text, t: 1.8, bad: true };
|
||||
d.beat = TUNE.drillBeat;
|
||||
zombies.length = 0;
|
||||
}
|
||||
|
||||
// Which body the shot is allowed to lock onto — see flickTarget. During the call nothing is a
|
||||
// target yet (you cannot serve then either).
|
||||
export const drillTarget = () => {
|
||||
const d = G.drill;
|
||||
if (!d || d.callT > 0 || d.beat > 0) return null;
|
||||
return zombies.find(z => z.bib === d.seq[d.step]) || null;
|
||||
};
|
||||
+29
-2
@@ -5,6 +5,7 @@ import { P, O, ball, score, crowd, G, court, other, deuceSide, setupPoint, sendB
|
||||
import { audio, pock, swish, whoosh, applause, voice, sfx, aww, zombieMusic, zombieDuck, menuDuck, splat, shriek, boom } from './audio.js';
|
||||
import { axisX, axisZ, takeFlickAim, touchPX, resetInput } from './input.js';
|
||||
import { W } from './render.js'; // runtime-only cycle (render imports gameplay), same pattern as input/zombies
|
||||
import { updateDrill, drillTarget } from './drill.js';
|
||||
import { updateZombies, updateParts, spawnZombie, spawnRacketZombie, spawnMachine, spawnChapterBoss, zombies, gore, explode, meleeSwipe, extras, parts, pellets, shots, playerGore, eatenBleed, resetZombies , firePellet , spawnBarrel , fireBeam , pops, popText } from './zombies.js';
|
||||
import { proceedAfterClear, startDuel, startHorde, warnHorde, resolveInterlude, watchdog } from './beats.js'; // runtime-only cycle: beats calls back into cheer/aside/endBurst/deliverTin/showModal/logSwing
|
||||
|
||||
@@ -72,6 +73,16 @@ function dustPuff(x, z, n = 5, sp = 1) {
|
||||
export function flickTarget(ch, aim) { // exported: render draws the live reticle with it
|
||||
const q = pullQuality(aim.mag), f = pullFrac(aim.mag);
|
||||
if (aim.mag < TUNE.pullMin) return { q, f }; // barely drawn: no lane yet, so no target
|
||||
// THE DRILL SNAPS. Ordinary splatter wants a forgiving magnet (one thumb, a moving crowd); the
|
||||
// drill's whole subject is WHICH body, so the magnet must never pick one for you — it locks to
|
||||
// the called target and to nothing else. The shot can still be BLOCKED by a body standing in the
|
||||
// lane (updateZombies hits the first thing the ball touches), which is the footwork half of the
|
||||
// mode and is deliberately left alone: sidestep to open the angle.
|
||||
if (G.drill) {
|
||||
const zt = drillTarget();
|
||||
if (!zt) return { q, f, tx: ch.x + aim.fx * TUNE.aimReach, tz: ch.z + aim.fy * TUNE.aimReach };
|
||||
return { q, f, zt, tx: zt.x, tz: zt.z };
|
||||
}
|
||||
const ax = aim.fx, az = aim.fy; // unit vector; input already inverted the pull. screen up = -z
|
||||
let zt = null, near = 1e9, bestErr = 1e9;
|
||||
for (const zb of zombies) {
|
||||
@@ -101,6 +112,13 @@ export function flickTarget(ch, aim) { // exported: render draws the live reticl
|
||||
// anywhere on the court — including your own side of the net.
|
||||
function zombieTarget(aim) {
|
||||
if (!zombies.length) return null;
|
||||
// THE DRILL SNAPS HERE TOO. flickTarget (the slingshot) and this (the plain tap) are the two ways
|
||||
// a splatter shot ever picks a body, and they are separate functions — snapping only the first one
|
||||
// left every tap-fired serve in the drill aiming at whatever zombieTarget liked, which in practice
|
||||
// was nothing at all: fifteen serves in a row scored zero. Unconditional, and ABOVE the
|
||||
// no-direction guard: in this mode a bare tap is a legal shot at the called body, because the
|
||||
// subject is which body comes next and not which lane you can find it in.
|
||||
if (G.drill) return drillTarget();
|
||||
const none = !aim || (!aim.ax && !aim.az);
|
||||
if (none) return null; // no direction, no lock. Free predictive auto-aim was the reason a
|
||||
// bare rhythm of taps could clear the court without any aiming at all.
|
||||
@@ -471,6 +489,8 @@ function pointTo(side, reason) {
|
||||
const blocksClear = z => !'rmo'.includes(z.variant);
|
||||
|
||||
export function splatPoint(side, quiet = false) {
|
||||
if (G.drill) return; // the drill keeps its own round count — see drill.js. Routing it through here
|
||||
// would arm the barrel/machine/boss schedule inside a mode that has no wave.
|
||||
// The duel is one clean tennis point and resolveInterlude is its ONLY exit. A machine or leftover
|
||||
// killed mid-duel used to score here, and a game banked mid-duel re-entered proceedAfterClear —
|
||||
// which can start a boss, a card, or a horde while the duel still owns the court.
|
||||
@@ -1017,6 +1037,7 @@ function tryLaser() {
|
||||
export function dragServe(aim) {
|
||||
if (G.mode !== 'splatter' || score.server !== 'P') return;
|
||||
if (G.interlude || G.horde || G.mgT > 0 || G.changeover) return; // the duel serves like tennis, not the reticle
|
||||
if (G.drill && (G.drill.callT > 0 || G.drill.beat > 0)) return; // ...and the drill's call is not play
|
||||
if (G.phase === 'over') { ball.active = false; setupPoint(); } // mid-beat pull: arm the next shot now
|
||||
if (G.phase !== 'pretoss' && G.phase !== 'toss' && G.phase !== 'rally') return;
|
||||
if (tryArmGun()) return;
|
||||
@@ -1057,7 +1078,9 @@ export function keyPressed(code) {
|
||||
audio(); // unlock on first gesture
|
||||
// a swish cleaves adjacent shamblers — but it's on a cooldown now: the panic button, not the
|
||||
// default attack. A whiff costs nothing (nothing was in range), a connect locks it for meleeCool.
|
||||
if (G.mode === 'splatter' && G.meleeT <= 0) {
|
||||
if (G.mode === 'splatter' && G.meleeT <= 0 && !G.drill) { // no swish in the drill: it cleaves whatever
|
||||
// is adjacent, which is the mode picking
|
||||
// your targets for you
|
||||
// measure the crowd BEFORE the swipe — it kills everything in reach, so counting afterwards
|
||||
// counts the SURVIVORS and the cooldown barely moved no matter how swarmed you were
|
||||
const cool = meleeCooldown();
|
||||
@@ -1075,6 +1098,7 @@ export function keyPressed(code) {
|
||||
}
|
||||
if (G.horde || G.mgT > 0) return; // auto-fire modes: a tap only swishes, never serves
|
||||
if (G.changeover) return; // the breather is mandatory — the melee swish above still works
|
||||
if (G.drill && (G.drill.callT > 0 || G.drill.beat > 0)) return; // let the umpire finish the list
|
||||
if (G.interlude && G.interlude.introT > 0) return; // let the umpire finish announcing the duel
|
||||
// splatter: a tap/flick during the brief 'over' beat re-arms the next serve immediately
|
||||
if (G.mode === 'splatter' && G.phase === 'over') { ball.active = false; setupPoint(); }
|
||||
@@ -1330,7 +1354,10 @@ export function update(dt) {
|
||||
|
||||
// --- zombies + transition timeline
|
||||
if (G.mode !== 'tennis') updateZombies(dt);
|
||||
else updateParts(dt); // tennis has no zombies but its bounces still kick up dust that must settle
|
||||
else updateParts(dt);
|
||||
updateDrill(dt); // the coaching drill's round clock. It MOVES its bodies (they carry v = 0, so
|
||||
// updateZombies' shamble skips them) and updateZombies still runs the contact
|
||||
// and ball-hit tests against wherever this put them — that split is the design. // tennis has no zombies but its bounces still kick up dust that must settle
|
||||
tickTestMode(dt); // keeps the sandbox stocked and rolls it a barrel now and then
|
||||
if (G.meleeT > 0) G.meleeT -= dt;
|
||||
if (G.rackGlow && (G.rackGlow.t -= dt) <= 0) G.rackGlow = null; // the fresh-pickup pulse fades out
|
||||
|
||||
+11
-2
@@ -6,7 +6,7 @@
|
||||
import { TUNE, COURTS, TOURS, EATEN_RESTART, WEAPONS, bossArt } from './config.js';
|
||||
import { P, O, ball, score, G, setupPoint, selectCourt, selectTour } from './state.js';
|
||||
import { update, restartRun, keyPressed, solveShot, inServeBox, inCourt, forceZombies, splatPoint, deliverTin, selectWeapon, grantWeapon, showModal, dismissModal, flickTarget, toggleTestMode } from './gameplay.js';
|
||||
import { startDuel, resolveInterlude, startBossFight, endBossFight } from './beats.js';
|
||||
import { startDuel, resolveInterlude, startBossFight, endBossFight, startDrill, endDrill } from './beats.js';
|
||||
import { fitViewport, render, proj, RES, SPRV, LOAD } from './render.js';
|
||||
import { voice, ambient, menuMusic, menuDuck } from './audio.js';
|
||||
import { zombies, spawnZombie, spawnRacketZombie, spawnMachine, spawnChapterBoss, piles, gore, parts, pellets, extras, shots, pops, explode, hurtPlayer, killPlayer , spawnBarrel } from './zombies.js';
|
||||
@@ -220,6 +220,13 @@ document.getElementById('scWeapons').addEventListener('click', () => {
|
||||
start(); intoSplatter(); toggleTestMode();
|
||||
});
|
||||
document.getElementById('scBosses').addEventListener('click', () => go('bosses'));
|
||||
// THE DRILL, from the back room — the cognitive mode's feel-test. Like the two above it drops
|
||||
// into a real run first (same loop, same contact rules) and then hands the court to startDrill,
|
||||
// which refuses if anything else still owns it.
|
||||
document.getElementById('scDrill').addEventListener('click', () => {
|
||||
start(); intoSplatter();
|
||||
startDrill();
|
||||
});
|
||||
|
||||
// The list is built by ASKING bossArt which design each chapter gets, so it can never drift from
|
||||
// what the run actually spawns — including the champion sitting on chapter 10 rather than 7.
|
||||
@@ -377,10 +384,12 @@ function laserFire() {
|
||||
}
|
||||
// a plain timer, not the load event: this module runs as a module, so `load` may already have gone
|
||||
if (location.hash === '#laser') setTimeout(laserScene, 600);
|
||||
// #drill — the same door from a URL, so a phone can be sent straight into the mode
|
||||
if (location.hash === '#drill') setTimeout(() => { start(); intoSplatter(); startDrill(); }, 700);
|
||||
|
||||
requestAnimationFrame(frame);
|
||||
|
||||
// exposed for in-browser sanity checks and simulated playtests
|
||||
window.T = { TUNE, ball, P, O, score, G, zombies, laserScene, laserFire, pause: v => { paused = v !== false; }, spawnZombie, spawnRacketZombie, spawnMachine, spawnChapterBoss, spawnBarrel, grantWeapon, toggleTestMode, piles, gore, parts, pellets, extras, shots, pops, explode, hurtPlayer, killPlayer, splatPoint, solveShot, inServeBox, inCourt, proj, setupPoint, forceZombies, deliverTin, selectWeapon, showModal, dismissModal, startDuel, resolveInterlude, flickTarget, startBossFight, endBossFight,
|
||||
window.T = { TUNE, ball, P, O, score, G, zombies, laserScene, laserFire, pause: v => { paused = v !== false; }, spawnZombie, spawnRacketZombie, spawnMachine, spawnChapterBoss, spawnBarrel, grantWeapon, toggleTestMode, piles, gore, parts, pellets, extras, shots, pops, explode, hurtPlayer, killPlayer, splatPoint, solveShot, inServeBox, inCourt, proj, setupPoint, forceZombies, deliverTin, selectWeapon, showModal, dismissModal, startDuel, resolveInterlude, flickTarget, startBossFight, endBossFight, startDrill, endDrill,
|
||||
stats, step: dt => update(dt), render, press: keyPressed,
|
||||
get phase() { return G.phase; }, set phase(v) { G.phase = v; } };
|
||||
+75
-1
@@ -5,6 +5,7 @@ import { TUNE, UI, EATEN_BURST, EATEN_SUMMARY, EATEN_RESTART, COURT, P_BOUNDS, P
|
||||
import { P, O, ball, score, crowd, G, court, tour } from './state.js';
|
||||
import { predictLanding, previewServe, slotId, slotStock } from './gameplay.js';
|
||||
import { zombies, gore, piles, parts, pellets, extras, shots, pops } from './zombies.js';
|
||||
import { DRILL_COLS } from './drill.js';
|
||||
|
||||
// ---------------------------------------------------------------- palette ----
|
||||
// tennis palette -> drained splatter palette; the transition lerps between them.
|
||||
@@ -662,6 +663,7 @@ function drawZombie(zb) {
|
||||
// moment they close on you across your own half. The front-facing ones (the turned CPU, the
|
||||
// runner, the machine, a chapter boss) have no left or right to turn and are left alone.
|
||||
drawStatic(name, zb.x, zb.z, h, bob, zb.face > 0 && !'cfmB'.includes(v));
|
||||
if (zb.bib !== undefined) drawBib(zb, h, bob);
|
||||
if (zb.burnT > 0) drawFlames(zb, h);
|
||||
if (zb.windT > 0 && zb.variant === 'B') { // the boss telegraphs too: it rears up, glowing, then throws
|
||||
const k = Math.max(0, 1 - zb.windT / (zb.windMax || TUNE.bossWind)), p = proj(zb.x, h * 0.62, zb.z); // fade over the design's own wind
|
||||
@@ -679,6 +681,77 @@ function drawZombie(zb) {
|
||||
if (v === 'B' && zb.hpMax) drawBossHp(zb, h);
|
||||
}
|
||||
|
||||
// THE TRAINING BIB — the drill's colour, worn. ponytail: this is a DRAWN OVERLAY, and it knowingly
|
||||
// breaks the house rule that says add sprite frames rather than fake them (see PROJECT.md). It is
|
||||
// here because the mode is a feel-test and six bib sheets x every walk frame is a week of art to
|
||||
// answer a question a rectangle answers today. If the drill survives its playtest, this function is
|
||||
// the thing to delete: the shipping version is a real bib sprite per colour, drawn like a wound skin.
|
||||
//
|
||||
// It is a TRAPEZOID, not a rectangle — a flat bar over a walking body reads as a UI element stuck to
|
||||
// the screen; narrowing at the shoulders reads as cloth on a torso. And it carries PIPS as well as a
|
||||
// hue: about 8% of men cannot separate the red from the green, and a mode whose entire input is
|
||||
// "which colour is that" owes them a second channel.
|
||||
function drawBib(zb, hMeters, bob) {
|
||||
const C = DRILL_COLS[zb.bib]; if (!C) return;
|
||||
const p = proj(zb.x, 0, zb.z); if (p.s <= 0) return;
|
||||
const h = hMeters * p.s;
|
||||
const y0 = p.y - h * 0.68 - bob, y1 = p.y - h * 0.42 - bob; // chest to waist
|
||||
// SHOULDERS WIDER THAN WAIST. The first cut flared the other way and every body was wearing a
|
||||
// sandwich board — a shape that widens as it goes down is furniture, not cloth on a torso.
|
||||
const wTop = h * 0.185, wBot = h * 0.155;
|
||||
const cx = Math.round(p.x);
|
||||
ctx.fillStyle = C.col;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(cx - wTop, y0); ctx.lineTo(cx + wTop, y0);
|
||||
ctx.lineTo(cx + wBot, y1); ctx.lineTo(cx - wBot, y1);
|
||||
ctx.closePath(); ctx.fill();
|
||||
ctx.fillStyle = 'rgba(0,0,0,0.30)'; // a seam, so it sits ON the body
|
||||
ctx.fillRect(cx - wTop, Math.round(y0), wTop * 2, Math.max(1, h * 0.012));
|
||||
// the pips: the redundant channel, big enough to count at a glance and never more than four
|
||||
const r = Math.max(0.8, h * 0.026), gap = r * 2.6;
|
||||
ctx.fillStyle = C.ink;
|
||||
for (let i = 0; i < C.pips; i++) {
|
||||
const px = cx + (i - (C.pips - 1) / 2) * gap;
|
||||
ctx.beginPath(); ctx.arc(px, (y0 + y1) / 2, r, 0, 7); ctx.fill();
|
||||
}
|
||||
}
|
||||
|
||||
// THE CALLED LIST, on screen. Oliver's call: the umpire reads it aloud AND it shows as a pattern —
|
||||
// audio is the channel that makes the mode this game's own, the strip is what stops a missed word
|
||||
// costing a round. Chips left to right, the done ones struck through and dimmed, so the strip
|
||||
// doubles as "where am I" and never has to be read from the start again mid-round.
|
||||
function drawDrillHud() {
|
||||
const d = G.drill; if (!d) return;
|
||||
const n = d.seq.length; if (!n) return;
|
||||
const w = 26, gap = 4, totalW = n * w + (n - 1) * gap;
|
||||
let x = Math.round(W / 2 - totalW / 2);
|
||||
const y = CARD_TOP + 6, h = 22;
|
||||
for (let i = 0; i < n; i++, x += w + gap) {
|
||||
const C = DRILL_COLS[d.seq[i]], done = i < d.step;
|
||||
ctx.globalAlpha = done ? 0.28 : 1;
|
||||
ctx.fillStyle = 'rgba(6,4,6,0.8)'; ctx.fillRect(x - 1, y - 1, w + 2, h + 2);
|
||||
ctx.fillStyle = C.col; ctx.fillRect(x, y, w, h);
|
||||
ctx.fillStyle = C.ink; // the same pips as the bib
|
||||
const r = 2, gp = r * 2.8;
|
||||
for (let k = 0; k < C.pips; k++) {
|
||||
const px = x + w / 2 + (k - (C.pips - 1) / 2) * gp;
|
||||
ctx.beginPath(); ctx.arc(px, y + h / 2, r, 0, 7); ctx.fill();
|
||||
}
|
||||
if (i === d.step) { // the one you owe them right now
|
||||
ctx.strokeStyle = UI.cream; ctx.lineWidth = 2;
|
||||
ctx.strokeRect(x - 2, y - 2, w + 4, h + 4);
|
||||
}
|
||||
ctx.globalAlpha = 1;
|
||||
}
|
||||
ctx.textAlign = 'center';
|
||||
if (d.callT > 0) { ctx.font = B(15); shadowText('IN ORDER, PLEASE', W / 2, y - 6, UI.cream); }
|
||||
else if (d.msg && d.msg.t > 0) { ctx.font = B(20); shadowText(d.msg.text, W / 2, y + h + 20, d.msg.bad ? UI.bad : UI.pick); }
|
||||
// the round tally, quiet register, TUCKED UNDER THE STRIP — floated lower it sat in the middle
|
||||
// of the crowd with nothing to belong to
|
||||
ctx.font = F(8);
|
||||
shadowText(d.won + ' - ' + d.lost, W / 2, y + h + 5, UI.dim);
|
||||
}
|
||||
|
||||
// The boss's remaining life, over its head, in the scoreboard's own 16-bit language: a dark sunk
|
||||
// well, a bevel, and a fill that runs gold -> orange -> red as the pool empties, so the colour says
|
||||
// how the fight is going without reading the length. Integer-snapped and a FIXED pixel size — this
|
||||
@@ -1931,7 +2004,8 @@ export function render() {
|
||||
}
|
||||
ctx.restore();
|
||||
ctx.textBaseline = 'top'; ctx.textAlign = 'center';
|
||||
if (G.mode === 'splatter' && !G.interlude) drawRack(); // courtside weapon rack (hidden during the tennis-rules duel)
|
||||
if (G.mode === 'splatter' && !G.interlude && !G.drill) drawRack(); // courtside weapon rack (hidden during the tennis-rules duel AND the drill, which is played with plain balls)
|
||||
drawDrillHud(); // the called list — no-op unless a drill is running
|
||||
// phase C: the summary fades UP over the death animation instead of cutting on top of it
|
||||
if (G.mode === 'eaten' && G.eatenT > EATEN_SUMMARY) {
|
||||
const fade = Math.min(1, (G.eatenT - EATEN_SUMMARY) / 0.6);
|
||||
|
||||
@@ -104,6 +104,8 @@ export const G = {
|
||||
interlude: false, // sudden-death duel vs the racket zombie: falsy, or {introT} while announcing
|
||||
duelLerp: 0, // 0..1 — how far the court has returned to tennis colors for the duel
|
||||
horde: null, // active horde wave {t, spawnT, mgT} — machine-gun racket, no serving
|
||||
drill: null, // the coaching drill (src/drill.js): {seq, step, faults, callT, beat, rush, won, lost, msg}.
|
||||
// Owns the court like every other mode — see beats.startDrill.
|
||||
killCount: 0, // splatter: kills banked toward the next point (see TUNE.killsPerPoint)
|
||||
runGames: 0, // games finished this run (either side) — the difficulty director
|
||||
sets: [0, 0], // splatter sets won [you, them]; 6 games = a set = a horde
|
||||
|
||||
+8
-1
@@ -6,6 +6,7 @@
|
||||
import { TUNE, UI, COURT, CONTACT, BOSS_ART, bossArt, WEAPONS, BARREL_WEAPONS, woundStage, zombieH, EATEN_BURST, EATEN_SUMMARY, EATEN_FEED_R } from './config.js';
|
||||
import { G, P, ball, sendBoy, setupPoint } from './state.js';
|
||||
import { splat, moan, pock, thud, zap, voice, boom, swish, shriek, aww, sfx , whoosh, flame } from './audio.js';
|
||||
import { drillKill, drillCaught } from './drill.js';
|
||||
import { splatPoint, cheer, spentBall, aside, grantWeapon, endBurst, sliceCarom } from './gameplay.js';
|
||||
import { resolveInterlude, endBossFight } from './beats.js'; // runtime-only cycle, like the gameplay one
|
||||
import { tally, multiKill, endRun } from './stats.js';
|
||||
@@ -280,6 +281,9 @@ export function hurtPlayer(n) {
|
||||
// here exactly once.
|
||||
export function killPlayer() {
|
||||
if (G.mode === 'eaten') return;
|
||||
// In the drill, being caught costs the POINT and not the run — a deliberate contract change,
|
||||
// see drillCaught. Permadeath stays where it was settled: the gore mode.
|
||||
if (G.drill) return drillCaught();
|
||||
gore.push({ x: P.x, z: P.z, r: 0.9, a: 0.9 }); if (gore.length > 40) gore.shift();
|
||||
for (let k = 0; k < 30; k++) parts.push({
|
||||
x: P.x, y: 1.1, z: P.z,
|
||||
@@ -471,6 +475,9 @@ function bossDeath(zb) {
|
||||
|
||||
function killZombie(i, quiet) {
|
||||
const zb = zombies[i];
|
||||
// The drill's ONE rule, read at the single point every weapon routes through — hooking the ball
|
||||
// path instead would have the swish, the pellets and the blast silently exempt from the order.
|
||||
if (G.drill && zb.bib !== undefined) { drillKill(zb); zombies.splice(i, 1); return; }
|
||||
const mul = GORE_MUL[zb.variant] || 1;
|
||||
// BEFORE the early returns below: the ball machine is furniture but it is still a thing you put
|
||||
// down, so it earns a row. The barrel is loot and scores nothing anywhere else in the game —
|
||||
@@ -935,7 +942,7 @@ export function updateZombies(dt) {
|
||||
if (gw.rate && (G.mgCool -= dt) <= 0) { firePellet(gw); G.mgCool = 1 / gw.rate; }
|
||||
if (G.mgT <= 0) { endBurst(); setupPoint(); } // window over: spend the tin, resume the serve loop
|
||||
}
|
||||
if (!G.horde && !G.changeover && !G.interlude && !G.clearing && !G.bossFight) { // breathers, duels, the mop-up after a won game AND a boss fight all get a quiet court (no new spawns)
|
||||
if (!G.horde && !G.changeover && !G.interlude && !G.clearing && !G.bossFight && !G.drill) { // breathers, duels, the mop-up after a won game, a boss fight AND a drill round all get a quiet court (no new spawns)
|
||||
spawnT -= dt;
|
||||
// The spawn director: games finished sets both crowd size and tempo. Both curves used to hit a
|
||||
// wall — the cap pinned at 14 by game 11 and the gap floored at 1.0 s by game 10, after which
|
||||
|
||||
@@ -62,8 +62,10 @@ globalThis.getComputedStyle = () => ({ paddingTop: '0' });
|
||||
// ---- game modules (real code) ----------------------------------------------
|
||||
const { G, P, score, ball, setupPoint } = await import('./src/state.js');
|
||||
const { update, forceZombies, splatPoint, restartRun } = await import('./src/gameplay.js');
|
||||
const { startDuel, resolveInterlude, startBossFight, endBossFight } = await import('./src/beats.js');
|
||||
const { startDuel, resolveInterlude, startBossFight, endBossFight, startDrill, endDrill } = await import('./src/beats.js');
|
||||
const { zombies, spawnZombie, pellets } = await import('./src/zombies.js');
|
||||
const { killPlayer } = await import('./src/zombies.js');
|
||||
const { drillTarget } = await import('./src/drill.js');
|
||||
const { TUNE, bossArt } = await import('./src/config.js');
|
||||
|
||||
const DT = 1 / 60;
|
||||
@@ -269,5 +271,110 @@ test('a burst fires through the beat that follows the kill that emptied the cour
|
||||
assert.ok(G.mgT > 0, 'and its window is still open');
|
||||
});
|
||||
|
||||
// ---- the coaching drill: a new mode, so it owes the ownership contract a test ------------------
|
||||
|
||||
// Drive a drill round past its announcement: no serving and no targets while the umpire reads.
|
||||
function drillPlaying() {
|
||||
startDrill();
|
||||
for (let i = 0; i < TUNE.drillCallT * 60 + 4; i++) update(DT);
|
||||
}
|
||||
// kill a body the way every weapon does — through the shared funnel, via the ball's contact test
|
||||
// A shambler soaks TUNE.shamblerHp splat-quality hits, so this re-arms the ball until the body is
|
||||
// actually down — the drill keeps the roster's toughness on purpose (Oliver: "some are easier, some
|
||||
// more difficult to kill"), which means a round is paced by hp as well as by order.
|
||||
const drillKillBib = bib => {
|
||||
const zb = zombies.find(z => z.bib === bib);
|
||||
assert.ok(zb, 'bib ' + bib + ' should be on court');
|
||||
for (let i = 0; i < 40 && zombies.includes(zb); i++) {
|
||||
ball.active = true; ball.lastHit = 'P'; ball.y = 1; ball.pow = 1; ball.bounces = 0;
|
||||
ball.x = zb.x; ball.z = zb.z; ball.vx = ball.vy = ball.vz = 0;
|
||||
update(DT);
|
||||
}
|
||||
assert.ok(!zombies.includes(zb), 'the body should be down');
|
||||
};
|
||||
|
||||
test('a drill owns the court: every other mode refuses while it runs', () => {
|
||||
assert.ok(startDrill(), 'a drill starts on a clean court');
|
||||
startDuel();
|
||||
assert.equal(G.interlude, false, 'startDuel must refuse during a drill');
|
||||
forceZombies();
|
||||
assert.equal(G.horde, null, 'startHorde must refuse during a drill');
|
||||
startBossFight(1);
|
||||
assert.equal(G.bossFight, 0, 'startBossFight must refuse during a drill');
|
||||
assert.equal(startDrill(), false, 'and a drill must refuse a second drill');
|
||||
});
|
||||
|
||||
test('a drill refuses to start while another mode owns the court', () => {
|
||||
startDuel();
|
||||
assert.ok(G.interlude, 'setup: the duel has the court');
|
||||
assert.equal(startDrill(), false, 'startDrill must refuse during a duel');
|
||||
assert.equal(G.drill, null, 'and leave no half-built round behind');
|
||||
});
|
||||
|
||||
test('the call is not play: no target and no serving until the umpire finishes', () => {
|
||||
startDrill();
|
||||
assert.ok(G.drill.callT > 0, 'the walk-on is running');
|
||||
assert.equal(drillTarget(), null, 'nothing is a legal target during the call');
|
||||
assert.equal(zombies.length, TUNE.drillBodies, 'the whole court is placed up front');
|
||||
assert.equal(new Set(zombies.map(z => z.bib)).size, zombies.length, 'every bib on court is distinct');
|
||||
for (let i = 0; i < TUNE.drillCallT * 60 + 4; i++) update(DT);
|
||||
assert.ok(drillTarget(), 'and the first target arms when the call ends');
|
||||
assert.equal(drillTarget().bib, G.drill.seq[0], 'which is the first colour called');
|
||||
});
|
||||
|
||||
test('killing in the called order wins the round; the tennis ladder stays out of it', () => {
|
||||
drillPlaying();
|
||||
const seq = G.drill.seq.slice(), games = score.games.slice(), pts = score.pts.slice();
|
||||
for (const bib of seq) drillKillBib(bib);
|
||||
assert.equal(G.drill.won, 1, 'the round is won');
|
||||
assert.equal(G.drill.faults, 0, 'cleanly');
|
||||
assert.deepEqual(score.games, games, 'the drill does not bank tennis games');
|
||||
assert.deepEqual(score.pts, pts, '...nor tennis points — it keeps its own count');
|
||||
for (let i = 0; i < TUNE.drillBeat * 60 + 4; i++) update(DT);
|
||||
assert.ok(zombies.length > 0, 'and the next round places a fresh court');
|
||||
});
|
||||
|
||||
test('two wrong bodies is a double fault, and the round goes to them', () => {
|
||||
drillPlaying();
|
||||
// a colour the sequence never called is a DECOY — the body you must let walk at you
|
||||
const decoy = zombies.find(z => !G.drill.seq.includes(z.bib));
|
||||
assert.ok(decoy, 'setup: the court carries at least one decoy');
|
||||
drillKillBib(decoy.bib);
|
||||
assert.equal(G.drill.faults, 1, 'a decoy is a fault');
|
||||
assert.equal(G.drill.lost, 0, 'but not the round');
|
||||
assert.ok(G.drill.rush > 0, 'the rest lose patience — the punishment is tempo');
|
||||
const wrong = zombies.find(z => z.bib !== G.drill.seq[G.drill.step]);
|
||||
assert.ok(wrong, 'setup: something is still out of turn');
|
||||
drillKillBib(wrong.bib);
|
||||
assert.equal(G.drill.lost, 1, 'the second one takes the round off you');
|
||||
assert.equal(G.drill.won, 0);
|
||||
});
|
||||
|
||||
test('a wrong kill never leaves an un-winnable round', () => {
|
||||
drillPlaying();
|
||||
const later = G.drill.seq[G.drill.seq.length - 1]; // kill the LAST call first
|
||||
drillKillBib(later);
|
||||
assert.equal(G.drill.faults, 1, 'out of turn is a fault');
|
||||
assert.ok(!G.drill.seq.includes(later), 'and the dead colour leaves the list rather than blocking it');
|
||||
assert.ok(G.drill.seq.every(b => zombies.some(z => z.bib === b)),
|
||||
'every colour still on the list is still on the court');
|
||||
});
|
||||
|
||||
test('being caught costs the point, not the run', () => {
|
||||
drillPlaying();
|
||||
killPlayer();
|
||||
assert.notEqual(G.mode, 'eaten', 'permadeath stays in the gore mode');
|
||||
assert.equal(G.drill.lost, 1, 'they take the round instead');
|
||||
assert.equal(zombies.length, 0, 'and the court is cleared for the next one');
|
||||
});
|
||||
|
||||
test('endDrill hands the court back', () => {
|
||||
drillPlaying();
|
||||
endDrill();
|
||||
assert.equal(G.drill, null);
|
||||
assert.equal(zombies.length, 0, 'the drill takes its bodies with it');
|
||||
assert.ok(startDuel() !== false && G.interlude, 'and an ordinary mode can have the court again');
|
||||
});
|
||||
|
||||
console.log(`\n${passed} tests passed`);
|
||||
process.exit(0); // render.js schedules viewport timers at load; don't let them outlive the report
|
||||
Reference in new issue
Block a user