You aim it yourself now, and no two rounds are dealt alike

Oliver played it: "the auto aim just mean no skills involved at all. this
should just go away.. there is no penalty at the moment.. make some
variations of the position on the court and the color".

The first two notes are one finding. With the shot locked to the called body
it was unmissable, so the fault could never fire — there was no penalty
because there was no way to earn one. Both snaps are deleted. What replaced
them is one line: aimTie is 0 during a drill. That is not auto-aim, it is
removing the guess — the ordinary magnet lets a nearer body inside the same
cone steal a shot aimed past it, which is right for a crowd you are mowing
down and exactly wrong where hitting the wrong body IS the failure. An
unaimed tap now lands on grass.

And the variation:
- six colours (purple, white) with the court dealt three to five of them, so
  which colours turn up and how many decoys there are are live questions. At
  four out of four every round dealt every colour and only the order moved.
- PLACEMENTS: fan, cluster, column, stagger, wing — weighted, mirrored per
  round, lanes shuffled against the bib order. column stacks bodies in one
  lane, and the ball hits the first thing it touches, so feet matter.
- pips are dice faces now, shared by bib and HUD chip. A row of five dots has
  to be counted; a die is recognised.

Measured over 30 deals: all five shapes, counts even, all six colours, and
the first target screened by another body in ~10% of rounds. Verified end to
end in the browser that FAULT and DOUBLE FAULT both fire.

26 tests, including a named regression for the auto-aim staying gone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Opus 5 committed 2026-08-11 18:16:25 +02:00
1 parent 22df96cc1d
commit ef8a413031
10 files changed
+173 -64

No files matched your search

+8 -4
View File
@@ -348,11 +348,15 @@ export const TUNE = {
// ---- 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
drillBodiesMin: 3,// bibs on court: a RANGE, drawn per round out of the six-colour palette. Fixed at
drillBodies: 5, // four out of four, every round dealt every colour and only the order changed —
// three quarters of the round was known before the umpire opened his mouth.
// Capped by DRILL_COLS. Above five the court stops reading at phone scale.
drillSeq: 3, // colours CALLED in round one; whatever is left on 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.
// list is a memory test and memory tests fatigue. What keeps getting harder is
// the GEOMETRY — the PLACEMENTS table in drill.js, and 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...
+60 -11
View File
@@ -29,7 +29,45 @@ export const DRILL_COLS = [
{ 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' },
{ key: 'purple', voice: 'drill_purple', col: '#9b3fd8', ink: '#1c0530', pips: 5, name: 'PURPLE' },
{ key: 'white', voice: 'drill_white', col: '#f2f0e8', ink: '#20201c', pips: 6, name: 'WHITE' },
];
// The pips are laid out as DICE FACES, not as a row. Counting five dots against a clock is work;
// recognising a die is not — and the whole redundant channel is pointless if reading it costs more
// time than reading the hue. Unit coords in a -1..1 box, scaled onto the bib and onto the HUD chip
// by the same table, so the two readouts can never drift.
export const PIP_FACES = [
[[0, 0]],
[[-0.6, -0.6], [0.6, 0.6]],
[[-0.65, -0.65], [0, 0], [0.65, 0.65]],
[[-0.6, -0.6], [0.6, -0.6], [-0.6, 0.6], [0.6, 0.6]],
[[-0.7, -0.7], [0.7, -0.7], [0, 0], [-0.7, 0.7], [0.7, 0.7]],
[[-0.7, -0.7], [0.7, -0.7], [-0.7, 0], [0.7, 0], [-0.7, 0.7], [0.7, 0.7]],
];
// WHERE THEY STAND, and it is the difficulty dial with the real headroom — a longer list is a memory
// test and memory tests fatigue, but a called order that fights the geometry is a fresh problem
// every round. Each shape returns lanes and depths in the same order the bibs were dealt, so the
// colour assignment is independent of the shape: the same five positions ask a different question
// depending on which of them the umpire calls first.
// Fractions, not metres — `lane` is scaled by TUNE.drillSpread and `depth` by the mark band, so the
// shapes keep their character if either knob moves.
const PLACEMENTS = [
{ key: 'fan', w: 3, lane: (i, n) => -1 + 2 * i / Math.max(1, n - 1), depth: () => Math.random() },
// everything bunched into one half: short lanes, near-identical angles, and the sidestep matters
{ key: 'cluster', w: 2, lane: (i, n, s) => s * (0.15 + 0.5 * i / Math.max(1, n - 1)), depth: () => 0.25 + Math.random() * 0.5 },
// TWO OR THREE SHARING A LANE at different depths. The ball hits the first body it touches, so a
// target behind another is unreachable until you move — this is the shape that makes feet matter.
{ key: 'column', w: 3, lane: (i, n, s) => (i % 2 ? s * 0.75 : s * -0.15) + (i > 2 ? s * 0.1 : 0), depth: (i, n) => i / Math.max(1, n - 1) },
// near/far alternating across the full width: the depth order and the left-right order disagree
{ key: 'stagger', w: 3, lane: (i, n) => -1 + 2 * i / Math.max(1, n - 1), depth: i => (i % 2 ? 0.75 : 0.1) + Math.random() * 0.2 },
// all of them down one wing, one lonely outlier on the other — the outlier is often the one called
{ key: 'wing', w: 2, lane: (i, n, s) => i === 0 ? -s * 0.9 : s * (0.2 + 0.6 * i / Math.max(1, n - 1)), depth: () => Math.random() },
];
const pickPlacement = () => {
const pool = PLACEMENTS.flatMap(pl => Array(pl.w).fill(pl));
return pool[Math.floor(Math.random() * pool.length)];
};
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; };
@@ -42,7 +80,12 @@ 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);
// HOW MANY varies too. With a fixed four out of a four-colour palette, every round put every
// colour on court and the only thing that changed was the order — which is three quarters of a
// round already known before it starts. The palette is SIX and the court takes three to five, so
// which colours turn up is a real question again and the decoy count moves with it.
const n = Math.min(DRILL_COLS.length,
TUNE.drillBodiesMin + Math.floor(Math.random() * (TUNE.drillBodies - TUNE.drillBodiesMin + 1)));
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
@@ -51,18 +94,22 @@ export function newDrillRound() {
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))));
// The SHAPE is drawn per round and the lanes are shuffled against the bib order, so the called
// order is unrelated to both the left-right order and the depth order. `side` mirrors the
// asymmetric shapes so cluster/wing/column do not always favour the same half of the court.
const pl = pickPlacement(), side = Math.random() < 0.5 ? -1 : 1;
d.shape = pl.key; // for the miss log — "which shape was that" is the first question
const slots = shuffle(bibs.map((_, i) => i));
bibs.forEach((bib, i) => {
spawnZombie(lanes[i], 1.2 + Math.random() * 1.6, Math.random() < 0.25 ? 'b' : 'a');
const slot = slots[i];
const x = Math.max(-TUNE.drillSpread, Math.min(TUNE.drillSpread, pl.lane(slot, n, side) * TUNE.drillSpread));
const z = TUNE.drillMarkZ + pl.depth(slot, n) * TUNE.drillMarkSpread;
spawnZombie(x, 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 };
zb.mark = { x, z };
});
voiceSeq(['drill_call', ...d.seq.map(b => DRILL_COLS[b].voice)]);
}
@@ -88,9 +135,11 @@ export function updateDrill(dt) {
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);
const dz = zb.mark.z - zb.z, dx = zb.mark.x - zb.x;
const step = TUNE.drillWalkOn * dt;
zb.z += Math.sign(dz) * Math.min(Math.abs(dz), step);
zb.x += Math.sign(dx) * Math.min(Math.abs(dx), step * 0.7); // they drift into the shape, not snap to it
zb.dist = (zb.dist || 0) + Math.min(Math.abs(dz), step);
zb.walkT += dt;
}
return;
+8 -19
View File
@@ -5,7 +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 { updateDrill } 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
@@ -73,16 +73,12 @@ 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 };
}
// NO AUTO-AIM IN THE DRILL. This used to lock the shot to the called body wherever you pointed,
// and Oliver's verdict was the only one it could be: "no skills involved at all" — and with the
// shot unmissable the fault could never fire either, so the mode had no penalty in it. You aim.
// What DOES change is the tie-break: aimTie lets a nearer body inside the same cone steal a shot
// aimed past it, which is right for a crowd you are mowing down and exactly wrong here, where
// hitting the wrong body IS the failure. Zero slack — the body you are pointing at wins, always.
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) {
@@ -97,7 +93,7 @@ export function flickTarget(ch, aim) { // exported: render draws the live reticl
// clearly aimed past it — the magnet Oliver felt. Range only breaks ties between equally
// well-aimed targets (TUNE.aimTie of slack), so a deliberate far shot still beats a sloppy
// near one while a genuine straight-ahead lane still eats the nearest body first.
const tie = TUNE.aimTie;
const tie = G.drill ? 0 : TUNE.aimTie; // the drill never guesses for you — see the note above
if (err < bestErr - tie || (Math.abs(err - bestErr) <= tie && along < near)) {
bestErr = Math.min(bestErr, err); near = along; zt = zb;
}
@@ -112,13 +108,6 @@ 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.
+2 -2
View File
@@ -5,7 +5,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 { update, restartRun, keyPressed, solveShot, inServeBox, inCourt, forceZombies, splatPoint, deliverTin, selectWeapon, grantWeapon, showModal, dismissModal, flickTarget, dragServe, toggleTestMode } from './gameplay.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';
@@ -390,6 +390,6 @@ if (location.hash === '#drill') setTimeout(() => { start(); intoSplatter(); star
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, startDrill, endDrill,
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, dragServe, 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; } };
+10 -12
View File
@@ -5,7 +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';
import { DRILL_COLS, PIP_FACES } from './drill.js';
// ---------------------------------------------------------------- palette ----
// tennis palette -> drained splatter palette; the transition lerps between them.
@@ -707,12 +707,15 @@ function drawBib(zb, hMeters, bob) {
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;
pips(C, cx, (y0 + y1) / 2, Math.max(0.8, h * 0.024), h * 0.055);
}
// The redundant channel, as a DIE FACE (PIP_FACES). A row of five dots has to be counted; a die is
// recognised. Shared by the bib and the HUD chip so the two can never disagree.
function pips(C, cx, cy, r, spread) {
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();
for (const [ux, uy] of PIP_FACES[C.pips - 1]) {
ctx.beginPath(); ctx.arc(cx + ux * spread, cy + uy * spread, r, 0, 7); ctx.fill();
}
}
@@ -731,12 +734,7 @@ function drawDrillHud() {
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();
}
pips(C, x + w / 2, y + h / 2, 2.2, 6); // the same die face as the bib
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);