diff --git a/HANDOFF-MATCH.md b/HANDOFF-MATCH.md index 21b4210..14b95cb 100644 --- a/HANDOFF-MATCH.md +++ b/HANDOFF-MATCH.md @@ -70,11 +70,16 @@ communicating its own rules. Model A only works if the player can see they are b *The art is now there for it:* every wound stage has its own ready stance, shuffle pair and forehand (`z_rw1*`, `z_rw2*`), so a wounded opponent still plays rather than reverting to a walk. -4. **The fall animation** *(art pass — Oliver's idea, and a good one)* — point lost → the corpse - COLLAPSES → drags itself up minus the limb. It explains the wound stage popping between points and - gives every point a payoff beat. `matchReady()` is the natural slot. **Constraint: draw frames, - do not fake them** (settled rule) — this needs a small fall/rise sheet per stage through - `assets/_regen.py`, not a JS rotate/sink. The `racket_*` batches are the template. +4. ~~**The fall animation**~~ **DONE** (2026-08-12). Point won → the body COLLAPSES → drags itself up + minus the limb. `beats.tickMatchFall`, on its OWN clock beside the horde's warning (a beat that + moves the phase under a latch strands it — the mgT lock, again); the 'over' beat it also sets is + only there to keep the court quiet and is deliberately set to OUTLIVE the fall, so it cannot + expire into `setupPoint()` mid-collapse and hand the player a serve over a body on the ground. + The contract, and the whole point of the beat: **the three collapse frames wear the stage it + STILL WAS, the rise wears the stage it has BECOME.** Frames are named through `zb.showFrame` + (the bench's own override, which already degrades a missing stage to the healthy frame). + **Only when the stage actually moved** — a quarter off a full pool is 0.75, so point one crosses + nothing and a second of collapse for nothing is a beat the player waits out. 5. ~~**Menu entry**~~ **DONE.** Three modes on the setup screen, from `config.MODES`. The back room keeps its door (MATCH TEST still works) — the menu did not replace it, it just stopped being the @@ -85,8 +90,17 @@ communicating its own rules. Model A only works if the player can see they are b nowhere on touch. The placement ring (`G.placeMark`) already shows the result; nothing teaches the cause. -7. **Opponent serve games** *(bigger, later)* — the zombie never serves today. Alternating service - is real tennis and doubles the skill surface (return timing). +7. ~~**Opponent serve games**~~ **DONE** (2026-08-12), and it was almost entirely switched off rather + than missing. `setupPoint`'s tennis branch already placed a non-player server behind its own + baseline and armed `G.aiServeT`; `update()` already tossed and struck for O. Both were gated on + `G.mode === 'tennis'`, and the match runs in splatter under a permanent interlude — one condition. + What was genuinely new: **service alternates at each game boundary** (the match calls + `advancePoint` directly, and the flip lives in tennis's `pointTo`, which the match bypasses — + the same shape as the `G.serveNum` bug above), the zombie takes O's mark when it serves so the + picture and the ballistics agree on where it stands, and the serve POSES are named onto it + (`bounce` → `toss` → `serve`, held briefly after contact) because O is invisible here. + *Known simplification:* the interlude's line-call branch has no serve box and no second serve, so + a missed opponent serve is simply the player's point. Add faults when it matters. --- @@ -169,6 +183,25 @@ he ran, where the ball died. That triple diagnosed every defect in the contact c --- +## THE FLICK WAS LOSING THE POINT (fixed 2026-08-12) + +Oliver: *"when returning a ball with a spin on the screen that seems to be too sensitive, 8/10 times +the ball lands out."* It was arithmetic, not feel. The singles line is **4.115 m**. An aimed shot +targeted a flat `ax * 3.3` and THEN added `err()` on top — up to `±aimError` (2.4 m) at quality 0 — +so a flicked return landed as wide as **5.7 m**, and the only clamp in the path was at ±5, outside +the court. An UNAIMED return targets `0 + the same scatter` and is therefore always in. **Aiming was +the thing that lost the point**, which is exactly backwards for the mode's one act of skill. + +`TUNE.flickReach` (0.45) scales the aim width by the same quality the scatter shrinks with, so the +two can never sum past the line: worst case `1.49 + 2.4 = 3.89` at quality 0, and a perfectly timed +flick still gets the full 3.3 with no scatter left on it. A test asserts that sum across the whole +quality range rather than sampling rallies. **Not named `aimReach`** — that key already exists further +down TUNE (the slingshot's aim-ray length, 22 m) and the duplicate silently won, which a test caught. + +The placement ring carries `q` now and reads as the flick's receipt: **tight and hot when your timing +earned the full width, wide and dim when you barely got a racket on it.** Without it the new rule is +invisible — the shot simply goes less wide and nothing says why. + ## Traps this mode has already sprung (do not re-learn these) - **`autoAssist` is 0 on `pointer: fine`.** The desktop pane has no auto-run. The match floors it @@ -201,14 +234,19 @@ he ran, where the ball died. That triple diagnosed every defect in the contact c ## The art pipeline -Everything learned generating this mode's sprites is in **PROJECT.md, "THE ART PIPELINE, corrected"** -— read it before touching `assets/_regen.py`, `_fine.py` or `_master.py`. The one-line version: the -generator does identity and pose given REFERENCE IMAGES, the pipeline does exact colour with -arithmetic, and the gates only catch layout accidents. A day was spent discovering that the middle -one is not a prompting problem. +A new opponent FAMILY is now **one API call and one script** — read **PROJECT.md, "THE ZOMBIE FAMILY +PIPELINE"** before generating one. The one-line version: ask for all 31 figures in a single sheet +(one roll, one character, by construction), poses from an existing family and identity from one +reference frame with each image's JOB named in the prompt, then `assets/_zsheet.py` cuts it back down +with ONE global scale and ONE global palette. -The match opponent's own art is complete — 33 frames, three wound stages, the serve and the fall — -and `assets/_master.py` turns that pose set into the master sheets a new opponent is restyled from. +The older nine-batch flow (`_regen.py`, `_master.py`) is still right for single sheets, remasters and +the boss roster, and **PROJECT.md, "THE ART PIPELINE, corrected"** still governs it. Run +`python3 assets/_regen.py --verify ` before trusting any chained batch: it is what proved +27 of the parked brute's 31 frames were never art. + +The match opponent's own art is complete — 31 frames, three wound stages, the serve and the fall — +and the BRUTE is live beside it (`ZFAMS` has `'br'`, the KEEPER wears it), generated by the new path. ## Do not diff --git a/PROJECT.md b/PROJECT.md index e4fbc2a..fbd76ae 100644 --- a/PROJECT.md +++ b/PROJECT.md @@ -1751,6 +1751,122 @@ The gates, and the numbers they were calibrated on: wound half needs the reference discipline applied harder than prose currently applies it. - The brute is PARKED, not deleted: `config.ZFAMS` is one entry away from making it live, its 31 frames are on disk, and its nine batches are in `_regen_batches.json`. + **SUPERSEDED 2026-08-12 — see the next section.** Those 31 frames were not art: `--verify` found + 27 of them dead. The brute is now live (`ZFAMS` has `'br'`, the KEEPER wears it) off ONE sheet. + +## THE ZOMBIE FAMILY PIPELINE — one sheet, one character, 31 sprites (2026-08-12) + +**This replaces the nine-batch flow above for a whole opponent family.** `_regen.py` still exists and +is still right for single sheets, remasters and the boss roster; what it could not do is make a +FAMILY that is one character, because nine separate rolls is nine chances to drift and it took every +one of them — three different brutes, and a corpse that came back as a living man. + +### The rule the whole thing rests on + +**ASK FOR THE WHOLE FAMILY IN ONE IMAGE.** One roll, one light, one palette, 31 figures. Consistency +stops being something you police with gates and becomes a property of the request. This is also what +the model's own guidance says: frames generated in bulk as a sheet hold their character; frames +generated one at a time do not. + +### The four steps + +```bash +# 1. the pose sheet — 31 existing frames, one PNG (any family that is already good; today z_r*) +# Composed locally, no API. See assets/_refcheck/brute_poses_in.png for the shape. +# 2. the character — ONE frame of the new body, upscaled NEAREST, on magenta +# 3. ONE call: poses from image 1, character from image 2 +# 4. cut it back down +python3 assets/_zsheet.py --out assets/_refcheck/sprites # review first +python3 assets/_zsheet.py --install # then install +``` + +Then: add the key to `config.ZFAMS`, put `fam: ''` on an `OPPONENTS` row, bump `SPRV` and +`main.js?v=`, `node test.js`, and look at it in `#bench` (arrow keys walk families and wound stages). + +### The API facts, measured, not read off a blog + +Queried from `/api/v1/images/models` — the endpoint's own capability descriptor: + +| | | +|---|---| +| `resolution` | `1K` / `2K` / `4K`, **default 1K**, uppercase K required | +| `aspect_ratio` | a 10-value enum (1:1 … 21:9). No arbitrary ratios — a sheet gets SNAPPED | +| `n` | **min 1, max 1** | +| `input_references` | 0…14 | + +- **Use `/api/v1/images`, not `/chat/completions`.** Chat completions has no resolution control, so + every call silently returns 1K. The same sheet came back 1616x656 through chat and **6336x2688** + at `"resolution": "4K"` — 15x the pixels, for one JSON field. +- **`n` is 1. Always.** "Return both halves" is not a thing the model can do; asked for two, it + merges them into one picture and returns that same image twice. Two byte-identical files is the + tell. +- **Pad the input to the aspect you ask for.** The ratio is an enum, so a 1:3.2 sheet handed to a + 9:16 request gets rearranged to fit. Pad with magenta and the figures stay where they are. +- **Multiple references compose ONE output.** They are not a batch. Google's sub-limits: <=10 object + images, <=5 character images, <=3 style references. + +### What the model will and will not do + +- **It cannot output alpha.** No Google image model can. "Transparent background" returns white, + black, or a *painted* checkerboard. The chroma key is not a workaround, it is the method — and + green is the documented default, which is wrong here because our zombies are green. Magenta stays. +- **Its magenta is NOISY**: 8,000–22,000 distinct tones per sheet, corners anywhere from (250,1,250) + to (254,3,253). Key a RANGE. An exact `#FF00FF` match leaves a magenta fringe on every figure. +- **It draws pixel-art-STYLE illustration, not pixel art** — uneven pixel sizes, too many colours, + and it adds anti-aliasing and gradients unless forbidden. Prohibitions help and do not solve it. + `_fine.py`'s block-snap + box-filter + palette quantise is what makes it read as sprite art; the + softness does NOT wash out in the downscale alone (measured against `z_g1`, it stays visibly + smoother). +- **Name each image's JOB in the prompt.** "IMAGE 1 is the layout and poses only; IMAGE 2 is the + ONLY source for who the character is." Without that the majority wins: `racket_play`'s reference + is one zombie and three living humans, and a re-roll came back as a **living man** despite the + prompt carrying the CORPSE rule verbatim. A picture beats a paragraph — including when you did not + mean it to. +- **Frame it as an EDIT, not a composition**, and tell it to match image 1's *drawing style*. Every + "draw a new sheet" roll came back painterly; the edits kept the technique. + +### `_zsheet.py`, and the two things that must be GLOBAL + +It reuses `_fine.py`'s keyer, art-block detection and column cutter, adds row-band splitting, and +fixes the two things that break a family if they are done per-row: + +- **SCALE** — taken once, from the play row's standing frame. `_fine.py` anchors one scale per sheet, + so nine bands would each normalise to their own tallest figure and the serve strike would come out + the same height as the idle stance. The game sizes every sprite off its own PNG height, so that is + a body that pops. Measured after: serve **319**, prone fall **83** — the racket family's own are + 307 and 85, arrived at independently. +- **PALETTE** — taken once, from the largest figure anywhere, so a wound row cannot quantise onto a + slightly different set of colours than the row above it. + +**And it INVERTS `_fine.py`'s enclosed-magenta rule, deliberately.** `_fine.py` repaints magenta +trapped inside a figure as pale grey, because the generator used to fill racket string beds with the +backdrop colour BY ACCIDENT. Here the prompt asks for exactly that and gets it, so the bed cuts to +transparency and the racket is an open hoop. That is only safe because nothing else on the figure is +magenta — check a new sheet. + +### Three fixes this found in `_regen.py`, which still apply to it + +- **A margin is a PROPORTION, never a pixel count.** "At least 150 pixels of empty magenta at every + edge" means nothing to a model that picks its own output size; it composed at 92% of a canvas it + chose and clipped 3 rolls in 4. Stated as a fraction, edge rejections went to 1 in 3. +- **A reference sheet must not show a racket as a magenta hole.** Older sprites were cut before + `_fine.py` learned to keep enclosed background, so their string beds are transparent — `o_smash` + has 3,425 such pixels — and pasted on the magenta canvas they came back as magenta. Four of the + racket family's nine references were showing the generator exactly what the prompt forbade. + `ref_sheet` fills them with a woven pale bed now. +- **`--verify` asks the question no gate here ever asked**: is the art already on disk what it claims + to be? Every other check fires on a NEW roll and says nothing about installed frames — and a + chained batch draws its reference from installed frames. It found 27 of the brute's 31 dead: + `z_brw1fall` was ONE DRAWING PASTED FOUR TIMES (0.011–0.055 apart, where a real fall sequence is + 0.53–0.89). Run it before trusting any chained batch. + +### Still open + +- The style is close but not identical to the hand-made sprites: softer outline, blended shading + rather than dithering. Oliver accepted it ("I like the style") on the grounds that a RELIABLE way + to generate art is worth more right now than an exact match. +- The 9:16 padding spends most of the 4K budget on empty magenta — the figures came back at 243x288 + before downscaling. A layout that fills the canvas would give each figure far more real pixels. ## Session log: Oliver's playtest of the barrel weapons (2026-08-03, same day) diff --git a/index.html b/index.html index 313f7c9..2b65eae 100644 --- a/index.html +++ b/index.html @@ -431,6 +431,6 @@ if (location.hostname !== 'splatter-tennis.wuwi.one') { - + diff --git a/src/beats.js b/src/beats.js index 765928e..b378fbe 100644 --- a/src/beats.js +++ b/src/beats.js @@ -7,8 +7,8 @@ // (beats <-> gameplay and beats <-> zombies are runtime-only import cycles, same pattern as // input <-> gameplay: the imported bindings are only touched inside functions, never at load.) -import { TUNE, UI, bossArt, matchFoe, BENCH, ZFAMS, COURT, HOME } from './config.js'; -import { G, P, O, ball, score, setupPoint, sendBoy } from './state.js'; +import { TUNE, UI, bossArt, matchFoe, BENCH, ZFAMS, COURT, HOME, racketStage } from './config.js'; +import { G, P, O, ball, score, setupPoint, sendBoy, other } from './state.js'; import { voice, zombieDuck, aww } from './audio.js'; import { zombies, pellets, extras, shots, spawnRacketZombie, spawnChapterBoss, spawnZombie, explode, killPlayer } from './zombies.js'; import { cheer, aside, endBurst, deliverTin, showModal, logSwing, advancePoint, scoreKey } from './gameplay.js'; @@ -305,6 +305,7 @@ export function watchdog(dt) { export function startMatch() { if (G.interlude || G.horde || G.bossFight || G.drill || G.match || G.bench) return false; G.match = { opp: 1, ending: false }; + G.matchFall = null; G.hp = G.hpMax = TUNE.matchPlayerHp; // a real match is longer than four mistakes — the gore // mode's 4 was "too weak" here, felt not calculated score.server = 'P'; // you serve every point (the corpse never learned to toss) @@ -337,7 +338,83 @@ function matchReady() { G.serveNum = 1; setupPoint(); const rz = zombies.find(z => z.variant === 'r'); - if (rz) { rz.x = O.x; rz.z = 1.0; rz.pose = 'idle'; rz.swingT = -1; rz.swingCool = 0.3; } + // THE OPPONENT STANDS WHERE THE POINT PUTS IT: on its receiving mark, or behind its own baseline + // when it is the one serving. setupPoint has just placed O — the invisible tennis body the serve + // machinery is driven off — on exactly that spot, so the zombie simply takes it. One source for + // the mark, which is what stops the picture and the ballistics disagreeing about where it stands. + if (rz) { + rz.x = O.x; rz.z = score.server === 'O' ? O.z : 1.0; + rz.pose = 'idle'; rz.swingT = -1; rz.swingCool = 0.3; + rz.showFrame = null; rz.serveHold = 0; + } +} + +// ---- THE FALL. A point won knocks the body DOWN and it drags itself back up MINUS THE LIMB. +// +// It exists to explain a thing the mode was doing silently: the wound stage POPPED between points, +// so an arm simply stopped being there and nothing said why. Now the three collapse frames are the +// body it STILL WAS and the rise is the body it has BECOME — the order is the whole beat, and it is +// what makes the missing arm read as something the point did rather than a sprite swap. +// +// On its OWN clock, deliberately, and not on the 'over' beat it also sets. Any beat that moves the +// phase underneath a latch strands it forever — that is the mgT lock this file already carries a +// section about, and the horde's warning is on its own clock for the same reason. The 'over' beat is +// only there to keep the court quiet, and it is set to outlive the fall so it cannot expire into +// setupPoint() mid-collapse and hand the player a serve over a body that is still on the ground. +const FALL = [['fall1', 0.16], ['fall2', 0.18], ['fall3', 0.30], ['rise', 0.34]]; +export const FALL_LEN = FALL.reduce((s, f) => s + f[1], 0); + +function startFall(from) { + G.matchFall = { t: 0, from }; + ball.active = false; + G.phase = 'over'; G.overT = FALL_LEN + 0.6; // outlives the fall on purpose — see above +} + +// Ticked from update(), beside the horde's warning, for the same reason it is. It also drives the +// OPPONENT'S SERVE POSES, because both are the same job — naming the frame while the body is doing +// something the walk cycle has no picture for. +export function tickMatchFall(dt) { + if (!G.matchFall) return tickMatchServe(dt); + const f = G.matchFall; + const zb = zombies.find(z => z.variant === 'r'); + if (!zb) { G.matchFall = null; return matchReady(); } // felled by something else mid-fall + f.t += dt; + let acc = 0, cur = null; + for (const [suf, len] of FALL) { acc += len; if (f.t < acc) { cur = suf; break; } } + if (!cur) { // back on its feet: hand the point back + G.matchFall = null; zb.showFrame = null; + return matchReady(); + } + // ...and this is the line the beat is for: the collapse wears the OLD stage, the rise wears the + // NEW one. drawZombie's showFrame override degrades a missing stage to the healthy frame, so a + // family whose fall rows have not been drawn yet still plays the beat instead of blinking out. + const st = cur === 'rise' ? racketStage(zb) : f.from; + zb.showFrame = 'z_' + (zb.fam || 'r') + (st ? 'w' + st : '') + cur; + zb.pose = 'idle'; zb.swingT = -1; +} + +// ---- THE OPPONENT SERVES. Alternating service is real tennis, and it doubles the mode's skill +// surface: until now the corpse only ever RETURNED, so return timing — half of the sport — was never +// asked for. Almost all of it already existed and was switched off: setupPoint's tennis branch +// already places a non-player server behind its own baseline and arms G.aiServeT, and update() +// already tosses and strikes for O. Both were gated on `G.mode === 'tennis'`, and the match runs in +// splatter with a permanent interlude, so the whole machine was one condition away from running. +// +// What is genuinely new is the PICTURE. O is invisible here — the body you see is the zombie — so +// the serve poses have to be named onto it, exactly as the fall names its collapse: bounce while it +// waits, toss as the ball goes up, and the strike held briefly after contact so the swing reads as +// something the body did rather than a ball that simply departed. +function tickMatchServe(dt) { + if (!G.match || score.server !== 'O') return; + const zb = zombies.find(z => z.variant === 'r'); + if (!zb) return; + if (zb.serveHold > 0) zb.serveHold -= dt; + const st = racketStage(zb); + const pre = 'z_' + (zb.fam || 'r') + (st ? 'w' + st : ''); + zb.showFrame = zb.serveHold > 0 ? pre + 'serve' + : G.phase === 'pretoss' ? pre + 'bounce' + : G.phase === 'toss' ? pre + 'toss' + : null; } function matchOpponent() { @@ -359,7 +436,7 @@ function matchOpponent() { export function endMatch() { if (!G.match) return; - G.match = null; G.interlude = false; + G.match = null; G.interlude = false; G.matchFall = null; // a latch left armed outlives its mode zombies.length = 0; shots.length = 0; zombieDuck(false); ball.active = false; setupPoint(); @@ -372,11 +449,16 @@ function matchPoint(side) { const d = G.match; if (d.ending) return; const zb = zombies.find(z => z.variant === 'r'); + let fellFrom = -1; if (side === 'P' && zb) { // a QUARTER of its pool per point, floored at 1 — four points walk it through every wound stage // and only the game-winning point actually fells it, so the body and the scoreboard agree all // the way to the end. (Ball hits wound it on top of this; that is the regicide path, kept.) + const was = racketStage(zb); zb.hp = Math.max(1, zb.hp - Math.ceil(zb.hpMax / 4)); + // ONLY when the stage actually moved. A point that chips the pool without crossing a threshold + // has no limb to explain, and a second of collapse for nothing is a beat the player waits out. + if (racketStage(zb) !== was) fellFrom = was; } if (side === 'O') { G.hp--; G.hurtT = TUNE.hurtGrace; G.hurtFlash = 0.6; @@ -387,6 +469,7 @@ function matchPoint(side) { if (over) return side === 'P' ? matchGameWon(zb, true) : matchGameLost(); voice(scoreKey(), 0.4); if (side === 'P') cheer(0.8, 0.3); + if (fellFrom >= 0) return startFall(fellFrom); // it goes down, and gets up short of a limb matchReady(); // next point, same opponent, both on their marks } @@ -405,6 +488,10 @@ function matchGameWon(zb, banked = false) { // once — and the plate is the better card anyway: it announces the win by replacing the loser. G.flash = { text: 'GAME.', t: 1.4 }; d.opp++; + // ALTERNATING SERVICE, as the sport has it. The match calls advancePoint directly, which banks + // the game but never touches the server — that flip lives in tennis's own pointTo, which the + // match bypasses (the same shape as the G.serveNum bug this file already carries a note about). + score.server = other(score.server); matchOpponent(); // fresh body, its own intro beat d.ending = false; matchReady(); @@ -415,6 +502,7 @@ function matchGameLost() { // Reachable only through deuce (four straight points is exactly your hp) — the body keeps its // wounds, the scoreboard banks their game, and the match simply plays on. voice('game_opponent', 0.3); aww(1); + score.server = other(score.server); // their game, and now their serve matchReady(); G.phase = 'over'; G.overT = 1.8; } diff --git a/src/config.js b/src/config.js index 0906f7e..d5bff38 100644 --- a/src/config.js +++ b/src/config.js @@ -23,6 +23,13 @@ export const TUNE = { // tape AND come down on a target, so it got throttled back to walking pace. // Heavy topspin lets it leave flat and DIVE, which is what a smash actually is. aimError: 2.4, // m of scatter at quality 0 + // NOT `aimReach` — that name is taken further down by the slingshot's aim-ray LENGTH (22 m), and + // the duplicate key silently won: every aimed shot reached 22x its width until a test caught it. + flickReach: 0.45, // how much of an aimed shot's width a MISTIMED flick gets (1 = all of it). + // Reach x quality must stay under the sideline once aimError is added on + // top, or the flick goes out — which is exactly the bug this fixed: + // 3.3 * 0.45 + 2.4 = 3.89 against a 4.11 m line. Raise it and the flick + // gets punchy again AND starts sailing wide on sloppy timing. perfectMs: 45, // |offset from window center| for PERFECT // The tennis CPU only exists in the OPENING match (after the transition, tennis never comes // back — duels use racket* dials), so these three are the "how long until the first game is won" @@ -735,6 +742,15 @@ export const BENCH_STAGES = ['HEALTHY', 'ARM OFF', 'HEADLESS']; // the gate working, not the gate being wrong. Re-registering it is one entry here; see the note at // the top of _master.py for the fix its next attempt needs. export const ZFAMS = ['r', 'br']; + +// THE RACKET FAMILY'S THREE STAGES, as a FRACTION of the pool — never against literal hp, the rule +// this whole file already holds (see woundStage). It lived inline in drawZombie; the FALL BEAT needs +// the same answer to know which body collapses and which one gets back up, and two copies of a +// threshold is exactly how the boss's skins and its blood drifted apart before. +export const racketStage = zb => { + const f = zb.hp / (zb.hpMax || TUNE.racketHp); + return f <= 0.34 ? 2 : f <= 0.67 ? 1 : 0; // 0 unhurt, 1 arm off, 2 headless +}; const POSES = ['bounce', 'toss', 'serve', 'fall1', 'fall2', 'fall3', 'rise']; export const famFrames = f => [ `z_${f}1`, `z_${f}2`, `z_${f}3`, `z_${f}4`, // stance, shuffle A, shuffle B, forehand diff --git a/src/gameplay.js b/src/gameplay.js index 0b9153d..f227a11 100644 --- a/src/gameplay.js +++ b/src/gameplay.js @@ -8,7 +8,7 @@ import { W } from './render.js'; // runtime-only cycle (render imports gameplay) import { updateDrill } from './drill.js'; import { endRun, beginRun, MODE_CLASSIC } from './stats.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, updateBench, benchKey } from './beats.js'; // runtime-only cycle: beats calls back into cheer/aside/endBurst/deliverTin/showModal/logSwing +import { proceedAfterClear, startDuel, startHorde, warnHorde, resolveInterlude, watchdog, updateBench, benchKey, tickMatchFall } from './beats.js'; // runtime-only cycle: beats calls back into cheer/aside/endBurst/deliverTin/showModal/logSwing // ------------------------------------------------------------- ballistics ---- // Launch ball from (x0,y0,z0) so it arrives at (tx, tz) with horizontal speed s under gravity g. @@ -130,7 +130,7 @@ function doHit(ch, quality, aim) { ball.x = ch.x + ch.facing * 0.5; ball.z = ch.z + (toFar ? -0.3 : 0.3); const err = () => (Math.random() * 2 - 1) * TUNE.aimError * (1 - quality); - let tx, tz; + let tx, tz, aimedLane = false; let s = TUNE.baseShotSpeed + quality * TUNE.qualityShotSpeed; const ft = G.mode !== 'tennis' && !G.interlude && ch.side === 'P' // the duel aims like tennis: lanes, not free-aim ? (aim && aim.fx !== undefined ? flickTarget(ch, aim) : { zt: zombieTarget(aim) }) : null; @@ -142,9 +142,20 @@ function doHit(ch, quality, aim) { } else if (ft && ft.tx !== undefined) { tx = ft.tx + err() * 0.4; tz = ft.tz + err() * 0.3; // no magnet: the ball goes where you flicked } else { - tx = aim.ax * (toFar ? 3.3 : 2.7) + err(); // your aimed shots go properly wide + // AN AIMED SHOT REACHES ONLY AS FAR AS YOUR TIMING EARNED IT. The singles line is 4.11 m out. + // This used to aim a flick at a flat 3.3 m and then add err() on top — up to +-2.4 m at quality + // 0 — so a flicked return with ordinary timing landed as wide as 5.7 m and the only clamp was + // at 5. An UNAIMED return, targeting 0 + the same scatter, was always in. The flick, the one + // thing in the mode that is supposed to be skill, was what lost the point: "8/10 times the ball + // lands out". Reach now scales with the same quality the scatter shrinks with, so the two can + // never add up past the line — worst case is 1.49 + 2.4 = 3.89 at quality 0, and a perfectly + // timed flick is the full 3.3 with no scatter left on it at all. Placement is still earned; + // it just stops being a coin toss you are punished for calling. + const reach = ch.side === 'P' ? TUNE.flickReach + (1 - TUNE.flickReach) * quality : 1; + tx = aim.ax * (toFar ? 3.3 : 2.7) * reach + err(); const tzBase = aim.az < 0 ? SHOT_DEPTH.deep : aim.az > 0 ? SHOT_DEPTH.short : SHOT_DEPTH.mid; tz = (toFar ? tzBase : COURT.L - tzBase) + err() * 0.8; + aimedLane = true; } tx = Math.max(-5, Math.min(5, tx)); if (G.match && ch.side !== 'P') { @@ -165,10 +176,15 @@ function doHit(ch, quality, aim) { const wide = (TUNE.matchWideBase + (G.match.opp - 1) * TUNE.matchWidePer) * (ch.foe?.wide || 1); tx = Math.max(-wide, Math.min(wide, tx)); } - // THE PLACEMENT, MADE VISIBLE: your aimed rally hit stamps its landing point, and render draws a - // fading ring there — flick, see the ring bite the corner, learn the cause. Only when a flick or - // held direction actually aimed it: an unaimed hit marking its scatter would teach noise. - else if (aim && (aim.ax || aim.az || aim.fx !== undefined)) G.placeMark = { x: tx, z: tz, t: 0.8 }; + } + // THE PLACEMENT, MADE VISIBLE: your aimed rally hit stamps its landing point, and render draws a + // fading ring there — flick, see the ring bite the corner, learn the cause. Only when a flick or + // held direction actually aimed it: an unaimed hit marking its scatter would teach noise. + // It carries `q` now, so the ring also says HOW MUCH OF THE FLICK LANDED — tight and hot when your + // timing earned the full reach, wide and dim when it barely applied. Without that the new + // quality-scaled reach above is invisible: the shot simply goes less wide and nothing says why. + if (aimedLane && ch.side === 'P' && aim && (aim.ax || aim.az)) { + G.placeMark = { x: tx, z: tz, t: 0.8, q: quality }; } // topspin: quality shots dive under extra effective gravity, which is what lets real // pace arc over the net and still land in — the core tennis trade-off @@ -1506,11 +1522,22 @@ export function update(dt) { G.duelLerp = Math.max(0, Math.min(1, G.duelLerp + (G.interlude ? dt : -dt) / 0.8)); // --- AI (the opponent only exists in tennis mode) - if (G.mode === 'tennis' && score.server === 'O' && G.phase === 'pretoss') { + // THE MATCH SERVES TOO. This was gated on `G.mode === 'tennis'` and the match runs in splatter + // under a permanent interlude, so the corpse could only ever RETURN — return timing, half the + // sport, was never asked of the player. setupPoint already places a non-player server behind its + // own baseline and arms aiServeT; this is the one condition that was keeping it switched off. + // The intro beat holds it: a body still walking on must not open with a serve. + const oServes = (G.mode === 'tennis' || G.match) && score.server === 'O' + && !(G.interlude && G.interlude.introT > 0); + if (oServes && G.phase === 'pretoss') { G.aiServeT -= dt; if (G.aiServeT <= 0) tossBall(O); - } else if (G.mode === 'tennis' && score.server === 'O' && G.phase === 'toss') { - if (ball.vy < 0 && ball.y < serveApexY() - 0.1) { G.wasServe = true; hitServe(O, 0.6 + Math.random() * 0.35); } + } else if (oServes && G.phase === 'toss') { + if (ball.vy < 0 && ball.y < serveApexY() - 0.1) { + G.wasServe = true; hitServe(O, 0.6 + Math.random() * 0.35); + // ...and the body that just served holds the strike, so the swing reads as its own (beats) + if (G.match) { const rz = zombies.find(z => z.variant === 'r'); if (rz) rz.serveHold = 0.4; } + } } if (G.mode === 'tennis' && G.phase === 'rally') { G.aiReactT += dt; @@ -1549,7 +1576,9 @@ export function update(dt) { // --- racket zombies rally from the far court (splatter only, between hordes) if (G.mode === 'splatter' && !G.horde) - if (!G.bench) for (const rz of zombies) if (rz.variant === 'r') racketAI(rz, dt); // the bench drives its own body + // ...and not while the body is on the ground: the fall owns the sprite AND the pose for its + // whole run, and racketAI would put a collapsing corpse back on its feet to chase a stray ball. + if (!G.bench && !G.matchFall) for (const rz of zombies) if (rz.variant === "r") racketAI(rz, dt); // the bench drives its own body // --- ball physics if (ball.active) { @@ -1644,6 +1673,9 @@ export function update(dt) { G.hordeWarn = 0; if (!startHorde()) G.pendingHorde = true; } + // ...and the match's FALL runs on its own clock for exactly the same reason, above the 'over' + // block so it can hand the point back before that block puts the player in a serve stance. + tickMatchFall(dt); if (G.phase === 'over') { G.overT -= dt; if (G.overT <= 0) { diff --git a/src/render.js b/src/render.js index c27c5a7..a2818b7 100644 --- a/src/render.js +++ b/src/render.js @@ -1,7 +1,7 @@ // Camera, viewport, sprites and all drawing. Owns the crowd dots (visual-only state). import { cv, ctx } from './dom.js'; -import { BENCH, BENCH_STAGES, ZFAMS, famFrames, TUNE, UI, EATEN_BURST, EATEN_SUMMARY, EATEN_RESTART, DONE_RESTART, COURT, P_BOUNDS, PLAYER_H, BOY_H, UMP_H, UMP_X, MACH_H, BOSS_ART, ZWOUND, woundStage, BOUNCE_IDLE, SPRITE_H, zombieH , WEAPONS, RACK_ROW, TOSS } from './config.js'; +import { BENCH, BENCH_STAGES, ZFAMS, famFrames, racketStage, TUNE, UI, EATEN_BURST, EATEN_SUMMARY, EATEN_RESTART, DONE_RESTART, COURT, P_BOUNDS, PLAYER_H, BOY_H, UMP_H, UMP_X, MACH_H, BOSS_ART, ZWOUND, woundStage, BOUNCE_IDLE, SPRITE_H, zombieH , WEAPONS, RACK_ROW, TOSS } from './config.js'; import { P, O, ball, score, crowd, G, court, tour } from './state.js'; import { predictLanding, previewServe, slotId, slotStock, serveApexY } from './gameplay.js'; import { drillQueue, drillLegal, drillMult } from './drill.js'; @@ -633,8 +633,7 @@ function drawZombie(zb) { // (TUNE.racketHpPerGame), so the old `hp === 2` / `hp <= 1` thresholds would have left a late-run // boss looking untouched until the killing blow. Keep these in step with bleed()'s anchors. else if (rk) { - const f = zb.hp / (zb.hpMax || TUNE.racketHp); - const st = f <= 0.34 ? 2 : f <= 0.67 ? 1 : 0; // 0 unhurt, 1 arm off, 2 headless + const st = racketStage(zb); // config's rule — the fall beat reads the same one if (st === 2) h = 1.52; // headless: a head shorter, so the shoulders land where a whole zombie's do if (st && rNew) { // every wound stage carries the SAME three poses as the healthy body, so a one-armed @@ -1842,8 +1841,13 @@ export function render() { // THE PLACEMENT RING (match): where your aimed rally shot is going, drawn the moment you flick. // Gold, fading over 0.8 s — flick, watch the ring bite the corner, learn what the gesture does. if (G.placeMark) { - ctx.globalAlpha = Math.min(1, G.placeMark.t / 0.5); - drawRing(G.placeMark.x, G.placeMark.z, UI.warm, 0.9); + // THE RING IS THE FLICK'S RECEIPT. Its POSITION says where the ball is going; its SIZE and + // COLOUR say how much of your flick was actually applied, because reach scales with timing + // (see doHit) and that would otherwise be an invisible rule. Tight and hot = you earned the + // full width; wide and dim = you got a racket on it and little else. + const q = G.placeMark.q ?? 1; + ctx.globalAlpha = Math.min(1, G.placeMark.t / 0.5) * (0.55 + 0.45 * q); + drawRing(G.placeMark.x, G.placeMark.z, q > 0.62 ? UI.hot : UI.warm, 1.35 - 0.6 * q); ctx.globalAlpha = 1; } // landing ring: white = incoming ball (a PREDICTION, gone the moment the ball touches down — diff --git a/src/state.js b/src/state.js index f8797bf..5d1269f 100644 --- a/src/state.js +++ b/src/state.js @@ -131,6 +131,7 @@ export const G = { clearWinner: null, // 'P'|'O' — who took the game/set being cleared (for the milestone card) pendingHorde: false,// a set was won; the horde is WARNED after the set card's pause hordeWarn: 0, // s left of the THEY KEEP COMING breather; the wave lands when it expires (beats.warnHorde) + matchFall: null, // {t, from} while a wounded match opponent collapses and drags itself up (beats.tickMatchFall) card: null, // {title, sub1, sub2, color, t} — centered milestone card (set won, duel); the reusable 'block' // the modal: a real interruption. Unlike card/flash it FREEZES the world (update() early-returns) // and only a tap or key clears it, so a popup can be read without being eaten while you read it. diff --git a/test.js b/test.js index 765b38e..f022727 100644 --- a/test.js +++ b/test.js @@ -83,14 +83,14 @@ globalThis.getComputedStyle = () => ({ paddingTop: '0' }); // ---- game modules (real code) ---------------------------------------------- const { G, P, O, score, ball, setupPoint } = await import('./src/state.js'); const { update, forceZombies, splatPoint, restartRun, flickTarget, keyPressed, classicGame } = await import('./src/gameplay.js'); -const { startDuel, resolveInterlude, startBossFight, endBossFight, startDrill: _startDrill, endDrill, startMatch: _startMatch, endMatch } = await import('./src/beats.js'); +const { startDuel, resolveInterlude, startBossFight, endBossFight, startDrill: _startDrill, endDrill, startMatch: _startMatch, endMatch, FALL_LEN } = await import('./src/beats.js'); // the drill's first-sight modal freezes the world (that is what modals do) — dismiss it the way a // player would, so the tests step a live court const startDrill = (...a) => { const r = _startDrill(...a); G.modal = null; G.modalQ.length = 0; return r; }; const { zombies, spawnZombie, pellets } = await import('./src/zombies.js'); const { killPlayer } = await import('./src/zombies.js'); const { drillQueue, drillLegal, arrival, drillMult, DRILL_TYPES } = await import('./src/drill.js'); -const { TUNE, bossArt, COURT } = await import('./src/config.js'); +const { TUNE, bossArt, COURT, racketStage } = await import('./src/config.js'); const { read: readStats, check: statsCheck } = await import('./src/stats.js'); const DT = 1 / 60; @@ -642,6 +642,86 @@ test('the first opponent cannot paint lines: its returns are width-clamped', () assert.ok(TUNE.matchWidePer > 0, 'and the width is earned back per rung'); }); +test('an AIMED return cannot be sent out by its own aim, at any timing', () => { + // The bug this pins: an aimed shot targeted a flat 3.3 m and THEN took err() on top — +-aimError + // at quality 0 — so a flicked return landed as wide as 5.7 m against a 4.115 m singles line and + // went out about eight times in ten, while an unaimed one (0 + the same scatter) was always in. + // Flicking, the mode's one act of skill, was what lost the point. Reach scales with the same + // quality the scatter shrinks with, so the two can never sum past the line — that sum is the + // whole rule and it is arithmetic, so assert the arithmetic rather than a sampled rally. + const line = COURT.W / 2, aimW = 3.3; // doHit's far-court aim width + for (let q = 0; q <= 1.0001; q += 0.05) { + const reach = TUNE.flickReach + (1 - TUNE.flickReach) * q; + const worst = aimW * reach + TUNE.aimError * (1 - q); + assert.ok(worst <= line, + `aimed reach + worst scatter must stay inside the line at quality ${q.toFixed(2)}: ` + + `${worst.toFixed(2)} vs ${line.toFixed(3)}`); + } + // ...and it must still be worth flicking: perfect timing keeps the full width. + assert.ok(aimW * (TUNE.flickReach + (1 - TUNE.flickReach)) > line * 0.75, + 'a perfectly timed flick still reaches the corner'); +}); + +test('a wounding point knocks the body down and it gets up MINUS the limb', () => { + // The beat exists because the wound stage used to POP between points — an arm stopped being there + // and nothing said why. The contract: the collapse wears the stage it STILL WAS, the rise wears + // the stage it has BECOME, and the point is handed back only when it is standing again. + startMatch(); + for (let i = 0; i < 2.0 * 60; i++) update(DT); + const zb = zombies.find(z => z.variant === 'r'); + assert.equal(racketStage(zb), 0, 'setup: opponent starts unhurt'); + // A quarter off a full pool is 0.75 — still stage 0, so point ONE has no limb to explain. The + // stage crosses on the second point (0.5) and the third (0.25); that progression is the beat. + resolveInterlude('P'); + assert.equal(G.matchFall, null, 'point one takes a quarter but crosses nothing'); + resolveInterlude('P'); // ...this one takes the arm + assert.ok(G.matchFall, 'a stage-crossing point starts the fall'); + assert.equal(G.matchFall.from, 0, 'it collapses as the body it still was'); + update(DT); + assert.match(zb.showFrame, /fall1$/, 'first frame is the buckle'); + assert.ok(!/w\d/.test(zb.showFrame), 'and it is the UNWOUNDED buckle'); + // ...run to the rise, which must wear the NEW stage + for (let i = 0; i < 0.75 * 60; i++) update(DT); + assert.match(zb.showFrame, /w1rise$/, 'it drags itself up as the one-armed body'); + // ...and the point comes back only once it is standing + for (let i = 0; i < 0.6 * 60; i++) update(DT); + assert.equal(G.matchFall, null, 'the fall ends on its own clock'); + assert.equal(zb.showFrame, null, 'and hands the sprite back to drawZombie'); + assert.equal(G.serveNum, 1, 'matchReady ran: the next point is armed'); +}); + +test('service alternates by game, and the opponent actually serves', () => { + // Until now the corpse only ever returned: score.server was pinned to 'P' because the match calls + // advancePoint directly and the flip lives in tennis's pointTo, which the match bypasses. Return + // timing is half the sport and it was never asked for. + startMatch(); + for (let i = 0; i < 2.0 * 60; i++) update(DT); + assert.equal(score.server, 'P', 'you open the match'); + for (let pt = 0; pt < 4; pt++) { // four straight points is the game + resolveInterlude('P'); + for (let i = 0; i < 2 * 60 && G.matchFall; i++) update(DT); // let each collapse finish + } + assert.equal(score.server, 'O', 'their serve next game'); + // the opponent stands behind ITS baseline to serve, not on the receiving mark + const rz = zombies.find(z => z.variant === 'r'); + assert.ok(rz.z < 1.0, 'it steps back to serve: z=' + rz.z.toFixed(2)); + // ...and it tosses and strikes on its own, once the walk-on beat is over + let served = false; + for (let i = 0; i < 8 * 60 && !served; i++) { update(DT); if (ball.active && ball.lastHit === 'O') served = true; } + assert.ok(served, 'the opponent serves without the player touching anything'); +}); + +test('a lost point never fells the opponent', () => { + // The fall is what a WOUND looks like. A point the player loses costs blood, not a limb, and a + // collapse there would read as the opponent being hurt by winning. + startMatch(); + for (let i = 0; i < 2.0 * 60; i++) update(DT); + const hp = G.hp; + resolveInterlude('O'); + assert.equal(G.matchFall, null, 'their point starts no fall'); + assert.equal(G.hp, hp - 1, '...it draws your blood instead'); +}); + test('the placement mark fades on its own clock', () => { // The stamp itself lives inside doHit (aimed match hits only) and is exercised by play; what the // state machine owes is that a mark never lingers — a permanent ring would read as a target the