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

+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;