The flick stops losing the point, and the corpse falls and serves
THE FLICK. "when returning a ball with a spin it seems too sensitive, 8/10 times the ball lands out." 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 +-2.4 m at quality 0 — so a flicked return landed as wide as 5.7 m, and the only clamp in the path sat at +-5, outside the court. An UNAIMED return targets 0 + the same scatter and is therefore always in: aiming was the thing losing the point, which is backwards for the mode's one act of skill. TUNE.flickReach 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, and a perfectly timed flick still gets the full 3.3 with nothing left to scatter. The 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 22 m aim ray) and the duplicate silently won until a test caught it. The placement ring carries `q` now and is 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 rule is invisible: the shot just goes less wide. THE FALL (handoff item 4). A point that takes a limb knocks the body down and it drags itself up short of it. The three collapse frames wear the stage it STILL WAS and the rise wears the stage it has BECOME — that order is the whole beat, and it is what turns a popping wound stage into something the point did. Own clock beside the horde's warning, for the reason this file keeps relearning (a beat that moves the phase under a latch strands it); the 'over' beat it sets outlives the fall so it cannot expire into setupPoint() mid-collapse. Only fires when the stage actually moved — a quarter off a full pool is 0.75, so point one has no limb to explain and a second of collapse for nothing is a beat the player waits out. THE OPPONENT SERVES (handoff item 7), and it was mostly switched off rather than missing. setupPoint already placed a non-player server behind its own baseline and armed 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. Genuinely new: service alternates at each game boundary (the match calls advancePoint directly and the flip lives in tennis's pointTo, which it bypasses — the same shape as the G.serveNum bug), the zombie takes O's mark so picture and ballistics agree on where it stands, and the serve poses are named onto it because O is invisible here. racketStage() moves to config: drawZombie and the fall must not answer the same question twice, which is how the boss's skins and its blood drifted apart. Docs: PROJECT.md gains THE ZOMBIE FAMILY PIPELINE — the measured API contract (4K is a parameter and the default is 1K; n is 1 so "return both" is not a thing; aspect is an enum so pad to it), why magenta stays despite green being the documented key, and the two things _zsheet.py must do globally. Verified in the browser: brute in the match, z_brfall2 -> z_brw1rise on a wounding point, and bounce -> toss -> serve off the opponent's own baseline. 58 tests green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
cba2fb8a8b
commit
c1f7268a04
9 files changed
+412
-37
No files matched your search
+52
-14
@@ -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 <batches>` 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
|
||||
|
||||
|
||||
+116
@@ -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 <sheet.png> <famkey> --out assets/_refcheck/sprites # review first
|
||||
python3 assets/_zsheet.py <sheet.png> <famkey> --install # then install
|
||||
```
|
||||
|
||||
Then: add the key to `config.ZFAMS`, put `fam: '<key>'` 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)
|
||||
|
||||
|
||||
+1
-1
@@ -431,6 +431,6 @@ if (location.hostname !== 'splatter-tennis.wuwi.one') {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script type="module" src="src/main.js?v=191"></script>
|
||||
<script type="module" src="src/main.js?v=192"></script>
|
||||
</body>
|
||||
</html>
|
||||
+92
-4
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
+43
-11
@@ -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) {
|
||||
|
||||
+9
-5
@@ -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 —
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user