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:
1 parent
22df96cc1d
commit
ef8a413031
10 files changed
+173
-64
No files matched your search
+44
-9
@@ -487,22 +487,57 @@ round is theirs. Settled in discussion: **audio-first, and shown as a pattern to
|
||||
- **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.
|
||||
|
||||
**SECOND PASS, same day — 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".** All three landed, and the first two are the same finding: with the
|
||||
shot locked to the called body the mode was unmissable, so **the fault could never fire** — there was
|
||||
no penalty because there was no way to earn one.
|
||||
|
||||
- **Both snaps deleted.** You aim. 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 targets nothing:
|
||||
`splatServeTarget` returns null and `hitServe` falls back to the service box, so the ball lands on
|
||||
grass. Verified end to end in the browser: aiming at a decoy kills the decoy, raises FAULT, and
|
||||
sets the rush; a second wrong body prints DOUBLE FAULT and the round is theirs.
|
||||
- **The palette is SIX** (purple and white added, two more `say -v Daniel` recordings) **and the
|
||||
court takes three to five of them** (`drillBodiesMin`/`drillBodies`). Four bibs out of a
|
||||
four-colour palette meant every round dealt every colour and only the order changed — three
|
||||
quarters of the round was known before the umpire opened his mouth. Now which colours turn up,
|
||||
how many, and how many decoys are all live questions.
|
||||
- **`PLACEMENTS` in drill.js — five shapes, weighted, mirrored left/right per round**: `fan`,
|
||||
`cluster`, `column` (two or three SHARING A LANE at different depths — the shape that makes feet
|
||||
matter, because the ball hits the first body it touches), `stagger`, `wing`. Lanes are shuffled
|
||||
against the bib order, so the called order is unrelated to both the left-right order and the depth
|
||||
order. Measured over 30 deals: all five shapes appear, counts 3/4/5 roughly even, all six colours
|
||||
rotate, and **the first called target is screened by another body in about 10% of rounds** — often
|
||||
enough to matter, rare enough not to be a tax.
|
||||
- **The pips are DICE FACES now** (`PIP_FACES`, shared by the bib and the HUD chip so they cannot
|
||||
drift). A row of five dots has to be counted; a die is recognised — and the redundant channel is
|
||||
pointless if reading it costs more time than reading the hue. **Known limit: the die reads on the
|
||||
HUD chip at any distance but degrades on the BODY as it recedes.** The bib colour carries; the
|
||||
face does not. If that turns out to matter, the answer is a bigger bib, not more pips.
|
||||
- Ten tests now (26 total), including a named regression for the auto-aim being gone and one that
|
||||
deals 40 rounds and asserts colours, counts and shapes all move.
|
||||
|
||||
**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.
|
||||
the lane is part of the puzzle (it is the same occlusion the `column` shape is built on) or the
|
||||
pierce has to stop on a drill kill. Do not patch it silently — it is a design fork, not a bug.
|
||||
**More likely to come up now** that `column` deliberately stacks bodies in one lane.
|
||||
2. ~~**The tap aims itself.**~~ CLOSED by the second pass above — the snap is gone, you aim.
|
||||
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.
|
||||
and is deliberately capped low: a longer list is a memory test and memory tests fatigue. The
|
||||
`PLACEMENTS` table is where the real headroom is, and it is currently drawn at random with fixed
|
||||
weights — nothing ramps. Weighting it toward `column`/`cluster` as the rounds go on is the
|
||||
obvious next dial and is about four lines.
|
||||
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.
|
||||
5. **A shambler takes two splat-quality hits.** In a mode where every shot is aimed by hand that is
|
||||
two aimed serves per body, which paces a round more than the order does. Worth checking on feel
|
||||
whether drill bodies should be one-shot glass.
|
||||
|
||||
## Session log: beats.js, the rename, and the build gate (2026-08-11, same day)
|
||||
|
||||
|
||||
Binary file not shown.
Binary file not shown.
+1
-1
@@ -399,6 +399,6 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script type="module" src="src/main.js?v=152"></script>
|
||||
<script type="module" src="src/main.js?v=153"></script>
|
||||
</body>
|
||||
</html>
|
||||
+8
-4
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
@@ -61,7 +61,7 @@ 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 { update, forceZombies, splatPoint, restartRun, flickTarget } = await import('./src/gameplay.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');
|
||||
@@ -274,23 +274,31 @@ test('a burst fires through the beat that follows the kill that emptied the cour
|
||||
// ---- 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() {
|
||||
function drillPlaying({ bodies } = {}) {
|
||||
// Pin the court size where a test needs a guaranteed decoy (bodies > the called sequence). The
|
||||
// count is a RANGE in play, so an unpinned round can legitimately deal no decoy at all.
|
||||
const min = TUNE.drillBodiesMin, max = TUNE.drillBodies;
|
||||
if (bodies) { TUNE.drillBodiesMin = TUNE.drillBodies = bodies; }
|
||||
startDrill();
|
||||
TUNE.drillBodiesMin = min; TUNE.drillBodies = max;
|
||||
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.
|
||||
// Returns false when the body is already down — a ball carries through the lane, so a shot at one
|
||||
// body can take the one standing behind it (see PROJECT.md's open question 1).
|
||||
const drillKillBib = bib => {
|
||||
const zb = zombies.find(z => z.bib === bib);
|
||||
assert.ok(zb, 'bib ' + bib + ' should be on court');
|
||||
if (!zb) return false;
|
||||
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');
|
||||
return true;
|
||||
};
|
||||
|
||||
test('a drill owns the court: every other mode refuses while it runs', () => {
|
||||
@@ -315,7 +323,8 @@ 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.ok(zombies.length >= TUNE.drillBodiesMin && zombies.length <= TUNE.drillBodies,
|
||||
'the whole court is placed up front, ' + TUNE.drillBodiesMin + '..' + TUNE.drillBodies + ' of them');
|
||||
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');
|
||||
@@ -335,7 +344,7 @@ test('killing in the called order wins the round; the tennis ladder stays out of
|
||||
});
|
||||
|
||||
test('two wrong bodies is a double fault, and the round goes to them', () => {
|
||||
drillPlaying();
|
||||
drillPlaying({ bodies: 5 }); // a full court, so a decoy is guaranteed
|
||||
// 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');
|
||||
@@ -350,8 +359,33 @@ test('two wrong bodies is a double fault, and the round goes to them', () => {
|
||||
assert.equal(G.drill.won, 0);
|
||||
});
|
||||
|
||||
test('a wrong kill never leaves an un-winnable round', () => {
|
||||
test('THE AUTO-AIM IS GONE: the shot goes where you point it', () => {
|
||||
// It used to lock onto the called body wherever the pull pointed, which made the shot unmissable
|
||||
// and the fault unreachable — Oliver: "no skills involved at all... there is no penalty".
|
||||
drillPlaying();
|
||||
const called = G.drill.seq[G.drill.step];
|
||||
const wrong = zombies.find(z => z.bib !== called);
|
||||
assert.ok(wrong, 'setup: something else is on court');
|
||||
const ft = flickTarget(P, { fx: Math.max(-1, Math.min(1, (wrong.x - P.x) / 9)), fy: -0.95, mag: 140 });
|
||||
assert.notEqual(ft.zt && ft.zt.bib, called, 'pointing away from the called body must not hit it');
|
||||
});
|
||||
|
||||
test('rounds vary: which colours turn up, how many, and where they stand', () => {
|
||||
startDrill();
|
||||
const seen = { cols: new Set(), counts: new Set(), shapes: new Set() };
|
||||
for (let r = 0; r < 40; r++) {
|
||||
for (const z of zombies) seen.cols.add(z.bib);
|
||||
seen.counts.add(zombies.length);
|
||||
seen.shapes.add(G.drill.shape);
|
||||
G.drill.beat = 0.001; update(DT); update(DT); // deal the next round
|
||||
}
|
||||
assert.ok(seen.cols.size > 4, 'the palette is bigger than any one court: ' + seen.cols.size + ' colours seen');
|
||||
assert.ok(seen.counts.size > 1, 'the body count moves: ' + [...seen.counts]);
|
||||
assert.ok(seen.shapes.size > 2, 'the placement shape moves: ' + [...seen.shapes]);
|
||||
});
|
||||
|
||||
test('a wrong kill never leaves an un-winnable round', () => {
|
||||
drillPlaying({ bodies: 5 });
|
||||
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');
|
||||
|
||||
Reference in new issue
Block a user