diff --git a/PROJECT.md b/PROJECT.md index fc5827c..5ae5ce0 100644 --- a/PROJECT.md +++ b/PROJECT.md @@ -20,8 +20,9 @@ anything, so if you add a `return` for an awkward deal, check the body still run the state-machine contracts that have historically produced the show-stoppers. The Dockerfile runs it as a build stage, so a red suite cannot deploy — but finding out from Coolify is the slow way. -Last updated 2026-08-14 (the live 1v1 socket lands: VS A FRIEND on the mode screen, the relay in -server.py, the receiver-is-judge netcode in src/net.js — phone verdict pending). **Start with "How to work on this", "OPEN — leftovers" and "LATER — +Last updated 2026-08-15 (the batch below: six new opponent families and an 18-rung ladder, the +tournament DRAW screen, three netplay fixes off Oliver's cellular report, the night court, and +camera flashes in the stand). **Start with "How to work on this", "OPEN — leftovers" and "LATER — shipping it"** (all three just above the CLOSED sections) — the first tells you how Oliver's feedback should be handled, the second is everything known to be outstanding in the game, the third is the demo/store/campaign plan, which is decided but entirely unbuilt. All four playtest rounds are closed @@ -411,6 +412,122 @@ zombie (now an occasional opponent, `TUNE.racketEvery`, not the every-game fixtu `RETURNED`, `DOUBLE KILL`) is a footnote to something you already watched happen. Sharing the loud FACE with the milestone cards is right; sharing their SIZE made every footnote read as a milestone. +## Session log: the roster doubles, the draw is made on screen, and netplay stops locking (2026-08-15) + +Oliver's seven-item batch. All of it is on STAGING and pending his phone verdict; items 3-5 in +particular need two phones on cellular, which is the only place the bug they fix was ever seen. + +### 1. SIX NEW OPPONENT FAMILIES, and the ladder goes 12 -> 18 rungs + +*"expand the roster for the zombie tennis, use the proven way of generating the roster with the +refcheck... head coach, stringer, strawberry seller, chair umpire, spectator, roller man."* + +Rolled through `_famroll.py` -> `_zsheet.py` exactly as the WHICH-TOOL table says, keys `hc sg sb +cu sp rm`, each anchored on the body it already has in the gore mode (`z_coach1`, `z_string1`, +`z_berry1`, `z_chair1`, `z_s1`, `z_k1`) — the rung and the thing that hunts you stay one person. +186 new sprites, 6 new head icons, `_heads.py` SRC/BOX rows for all of them. Placed by HIERARCHY +rather than stacked on the end (spectator 3, roller man 5, seller 9, stringer 10, chair umpire 12, +head coach 14), so the ladder still reads as the club's staff from the bottom up, and **the +champion took the step up Oliver asked for**: 1.45 speed / 1.42 pace / 1.36 width / 0.38 flub, +against 1.35 / 1.30 / 1.30 / 0.50. `CUP_ROUNDS` bands widened to match, `LADDER` is exported so the +ramp test and the draw screen read the table instead of a pinned 12. + +**TWO PIPELINE LESSONS, both paid for in rolls:** + +- **THE RACKET IS THE POSE, NOT A PROP — say it out loud.** All six of these characters carry + something in their anchor frame (a ball basket, a coil of string, a tray, an umpire's chair, a + folding chair, a court roller), so each description had to say the thing is gone — and the model + read "he carries nothing in either hand" as covering the RACKET too. Two families came back + playing tennis bare-handed and the strawberry seller returned every shot with his tray. There is + now a permanent clause in `_famroll.prompt()` saying the racket belongs to image 1's pose and is + never removed or replaced. Describe a prop's REMOVAL by naming the prop ("the tray image 2 shows + is NOT part of him"), never by talking about what his hands hold. +- **A prop at head height defeats `_heads.py`'s shoulder-widening rule every time.** All six needed + a hand `BOX`; the automatic crops came back 88x18, 36x17 and 35x16 — a letterbox of scalp. If a + new family's head icon is a thin strip, that is the diagnosis, and a `BOX` row is the fix. +- The club pro finally has a head icon too (`z_rhead.png`, cut from `z_c1` — the turned opponent + IS him), so `SPR` preloads all of ZFAMS and the draw screen's portrait column is consistent. +- **`ENEMIES['L' + i]` was reading BOSS_ART for the NAME and OPPONENTS for the line** — the same + answer only while the two lists were the same list, and they were decoupled on 2026-08-13. The + body-count screen has been printing THE PHYSIO over the mascot's line ever since. It reads one + list now, which is also what stops rungs 11-18 having no row at all. + +### 2. THE DRAW — the tournament's bracket, settled in front of you + +*"when starting the tournament there should be a view of the tournament hierarchy but only the +finale is settled... random browsing through the roster in the individual spots which begins fast, +gets slower and then settles on a random character, so that every tournament is new."* + +A new menu stage (`#bracket`, `main.buildBracket`), not a court-owning mode: SERVE UP lands here +when the picked mode is `cup`, four rows roll their own band of the ladder at a gap that grows with +p², they land in order at 1.5 / 2.05 / 2.6 s, and ENTER THE DRAW is `disabled` until the last one +stops. The final wears its settled look from the first frame — it was never in doubt. + +**THE SCREEN IS THE DRAW, NOT A TEASER.** `startMatch(cup, draw)` takes the settled rungs and +`cupDraw` reads them off instead of rolling its own, so the face you watched land in ROUND ONE is +the body across the net. A screen that dealt a different tournament from the one it just showed you +would teach the player that the whole ceremony is a lie. `cupDraw` still validates each rung +against its round's band and rolls honestly if it does not fit, so a stale or hand-passed draw +cannot deal THE CHAMPION in round one. `#cup` (the back door) passes no draw and behaves as before. + +### 3-5. NETPLAY: the deathmatch lock, the court, and the ball that froze + +**The lock — "sometimes both players stand on the same side and nobody can serve" (cellular).** +ROOT CAUSE FOUND AND IT IS ONE LINE: the mid-flight early-decide in `update()` was missing the +`!G.net` gate its bounce-1 sibling in `onBounce` has carried since the netplay session. It exists to +predict THE CPU'S LEGS; against a human on another phone it made the STRIKER a judge of its own +outbound flight (`resolveInterlude` sends the verdict), so on a slow link both phones scored the +same rally. The two scoreboards then sat a point apart forever — and that is not cosmetic, because +`setupPoint` reads `score.pts` for the deuce/ad side and `score.server` for who tosses. One point of +drift puts both players on the same half of their own court, each waiting for the other to serve, +with no way out. **The one rule is that the receiving phone judges; this was the last place still +breaking it.** The 2026-08-14 log claims the early-decide was gated — only the bounce-1 half was. + +Belt and braces, because that failure mode is a soft-lock rather than a wrong number: **the HOST's +10 Hz position stream now carries its scoreboard while it sits in `pretoss`, and the guest adopts +it mirrored** (`netSync`). Gated to `pretoss` ONLY — nothing is mid-beat there and any verdict in +flight has long landed — so a live rally is never reconciled underneath itself. One authority, no +new message type. `score.games` is deliberately NOT synced (ponytail): it costs a wrong number on a +board rather than a court nobody can serve on. + +**The court rides the room.** `?court=` at creation, stored beside `death` on the host's room in +server.py, carried in the `start` message, applied by `netStart` -> `selectCourt`. Safe mid-run: the +bounce pair is copied into TUNE and the backdrop cache keys on `court.id`. Two phones playing one +rally on two surfaces disagreed about the bounce as well as the picture, and the guest never chose. + +**A called ball plays out.** *"as soon as the ball goes out the gameloop stops — it should just play +until it came to a natural halt."* Every scoring path used to switch the ball off where it stood, +which on the STRIKER's phone is a whole court and a latency away from the call. `ball.scored` is the +new flag: the flight is decided, physics keeps running, and the ball dies where a real one would — +by rolling to a stop under the existing `|vy| < 0.8` dead-ball rule. `endFlight()` in gameplay.js is +the one place that decides which it is (netplay rolls, everything else stops dead, so no +single-player feel changed), and `onBounce` returns on `ball.scored` at the top, which covers every +call site in one line. The three consequences further down the physics block (the dead-roll branch, +the baseline crossing, the retire) carry the same guard. `setupPoint` clears it. + +### 6. THE NIGHT COURT + +A `COURTS` row (`gloom`, THE NIGHT COURT) plus one generated menu tile. It needed no new palette +machinery — its TENNIS palette is simply darker than every other court's SPLATTER one, and +`paintBackdrop` already paints every line in `UI.cream`, so the lines read as chalk under +floodlights with no new knob. It borrows the hard court's stands and boards rather than growing two +more sheets. **The one thing it did need is `dim`**: a fraction the STAND is taken down by before +the rot even starts (`render.standDim`, 0.62 here and absent everywhere else). Without it a +floodlit blue crowd sat above a near-black court and the picture stopped being a night. `standDim` +is ONE function because the number is applied twice — to the cached sheet and to the live clap +columns drawn over it — and two copies of it is a stand whose clapping columns pop brighter than +the rest of it, the same class of bug as the two crowd frames drifting apart. + +### 7. CAMERA FLASHES IN THE STAND + +`render.tickFlashes`, an OVERLAY like the clap ripple and for the same reason: the backdrop is one +cached blit and anything animated inside it repaints the whole scene every frame. Clocked off +`G.idleT` (the function has no dt, and idleT is the clock that never stops). A flash is a hot core +plus a cold bloom, `TUNE.flashLife` 0.16 s — deliberately short, because a flash you can look AT is +a lamp and one you only catch is a photograph being taken. The first cut drew a 6 px disc and read +as a floodlight; it is 1-2 px now. `TUNE.flashEvery` (5 s, jittered 0.35x..1.95x) is the one knob, +halved again while `crowd.cheer` runs — which is the "slightly increase during applause". + ## Session log: MULTIPLAYER — the live 1v1 socket (2026-08-14). PHONE VERDICT PENDING Oliver: *"alright multiplayer"* — and, offered the recorded ghost-match step one, he picked the diff --git a/assets/_famroll.py b/assets/_famroll.py index f8207ee..b80e9d2 100644 --- a/assets/_famroll.py +++ b/assets/_famroll.py @@ -54,6 +54,41 @@ FAMS = { 'grey-green head pokes out of a hole in the ball, thin ragged green arms and ' 'legs stick out of it, one white glove, worn sneakers. The huge yellow ball IS ' 'his torso in every pose'), + # the 2026-08-15 six (Oliver: expand the ladder). Four anchor on their own settled BOSS frame and + # two on their gore-mode body, same rule as above — the rung and the thing that hunts you are one + # person. EVERY ONE OF THEM CARRIES A PROP IN ITS ANCHOR FRAME (a ball basket, a coil of string, a + # tray, an umpire's chair, a spectator's chair, a court roller) and a prop is exactly what a + # 31-figure sheet cannot hold: it merges frames in the cut and it has no business in a tennis pose. + # So each description names the prop and says it is GONE — the model needs telling, because the + # reference picture is holding one and a picture beats a paragraph. + 'hc': ('z_coach1', 'a ZOMBIE HEAD COACH in a NAVY BLUE TRACKSUIT: a zip-up navy jacket with a pale ' + 'blue-and-white panel across the chest and white stripes down the sleeves, a ' + 'club crest, a whistle on a cord round his neck, matching navy tracksuit ' + 'trousers with white side stripes, dark trainers, and a bald grey-green head. ' + 'He carries NO basket and NO balls: both hands are empty of props in every pose'), + 'sg': ('z_string1', 'a ZOMBIE RACKET STRINGER: a grubby off-white shirt with the sleeves rolled to ' + 'the elbow under a heavy TAN LEATHER APRON, a row of awls and pliers across ' + 'the apron chest, a tool belt, faded blue-grey jeans and scuffed brown boots. ' + 'He carries NO loose coils of string and nothing floats above his head — the ' + 'apron and the tools on it are the whole costume'), + 'sb': ('z_berry1', 'a ZOMBIE STRAWBERRY SELLER: a folded WHITE PAPER SERVING CAP, a cream shirt, a ' + 'WHITE APRON blotched and dripping with crushed red strawberry stain, dark ' + 'brown trousers and heavy boots. The tray of strawberries and the ladle image 2 ' + 'shows are NOT part of him: leave the tray and the ladle out of the picture ' + 'entirely, and never draw them in place of a racket'), + 'cu': ('z_chair1', 'a ZOMBIE CHAIR UMPIRE: a pale CREAM LINEN BLAZER over a white shirt with a ' + 'dark navy tie, a pale straw sun hat with a band, olive-brown trousers and dark ' + 'shoes. The umpire\'s high chair, the ladder and the microphone image 2 shows ' + 'are NOT part of him: leave all three out of the picture entirely'), + 'sp': ('z_s1', 'a gaunt scrawny ZOMBIE SPECTATOR in a drab pale OLIVE-KHAKI summer jacket over a ' + 'grubby shirt, dark olive trousers and worn shoes, thin and round-shouldered. The ' + 'wooden folding chair image 2 shows over his shoulder is NOT part of him: leave the ' + 'chair out of the picture entirely, and never draw it in place of a racket'), + 'rm': ('z_k1', 'a big heavy ZOMBIE ROLLER MAN: a sleeveless OLIVE-GREEN WORK VEST over bare ' + 'grey-green arms, a brown leather tool belt with a pouch on it, olive work trousers ' + 'and heavy dark boots, bald. The court roller image 2 shows him hauling is NOT part ' + 'of him: leave the roller and its handle out of the picture entirely, and never ' + 'draw them in place of a racket'), } @@ -110,6 +145,14 @@ def prompt(who): 'THE CHARACTER\'S OWN BUILD OVERRIDES IMAGE 1\'S: if image 2 shows a wider, heavier or fatter ' 'body than the figures of image 1, every redrawn figure keeps that wider, heavier body in ' 'every pose. Only the POSE comes from image 1 — never the physique.\n\n' + # THE RACKET IS THE POSE, NOT A PROP, and it has to be said out loud (2026-08-15). Four of the + # six rolls that day were of characters who carry something in their anchor frame — a tray, a + # court roller, a folding chair — so their descriptions had to say the thing is gone, and the + # model read "he carries nothing" as covering the racket too: two families came back playing + # tennis bare-handed and one returned every shot with a strawberry tray. + 'EVERY FIGURE STILL HOLDS THE TENNIS RACKET image 1 gives it, in the same hand, in the same ' + 'frames. The racket belongs to the POSE and is never removed, never replaced by anything the ' + 'character is carrying in image 2, and no figure that has one in image 1 has an empty hand.\n\n' # EVERY UNDAMAGED FIGURE HAS A HEAD, said out loud. The clause above ("a figure with no head # keeps no head") is there for the wound rows and the old normalisation pass took it as licence # to remove one from the play row — which is how six families came back with a decapitated diff --git a/assets/_heads.py b/assets/_heads.py index 98a3ecc..2edd703 100644 --- a/assets/_heads.py +++ b/assets/_heads.py @@ -24,9 +24,12 @@ SCRATCH = os.path.join(HERE, '_refcheck') # fam -> the gore-mode body it anchors on. KEEP IN SYNC with _famroll.FAMS (not imported: that # module wants `requests` at import time, which the pillow/numpy env running this does not carry). -SRC = {'br': 'z_g1', 'sh': 'z_a1', 'bb': 'z_e1', 'st': 'z_d1', 'ru': 'z_f1', +SRC = {'r': 'z_c1', # the club pro: the turned opponent IS him, and it is his goriest print + 'br': 'z_g1', 'sh': 'z_a1', 'bb': 'z_e1', 'st': 'z_d1', 'ru': 'z_f1', 'gk': 'z_boss1', 'lj': 'z_judge1', 'dr': 'z_dir1', 'ch': 'z_champ1', - 'ph': 'z_physio1', 'ma': 'z_mascot1'} + 'ph': 'z_physio1', 'ma': 'z_mascot1', + 'hc': 'z_coach1', 'sg': 'z_string1', 'sb': 'z_berry1', 'cu': 'z_chair1', + 'sp': 'z_s1', 'rm': 'z_k1'} # head depth as a fraction of the standing figure, where the shoulder-widening rule fails: # the mascot's head IS partly the ball, and the ball is his identity — the wide crop is right. DEPTH = {'ma': 0.37} @@ -39,7 +42,18 @@ DEPTH = {'ma': 0.37} BOX = {'gk': (0.45, 0.14, 0.70, 0.44), 'dr': (0.20, 0.00, 0.55, 0.30), 'ph': (0.41, 0.02, 0.75, 0.33), 'ru': (0.25, 0.00, 0.80, 0.24), 'lj': (0.30, 0.00, 0.65, 0.32), 'br': (0.28, 0.00, 0.66, 0.30), - 'ch': (0.32, 0.00, 0.66, 0.30)} + 'ch': (0.32, 0.00, 0.66, 0.30), + # the 2026-08-15 six. EVERY ONE of them needed a hand box, and for the same reason each + # needed its prop talked out of the family roll: the thing they carry rides at head height. + # The stringer's coil, the seller's cap-and-tray, the umpire's chair, the spectator's ladder + # and the roller man's shoulder all fired the shoulder-widening rule inside the first few + # rows, and the automatic crop came back 88x18 / 36x17 / 35x16 — a letterbox of scalp. + 'r': (0.40, 0.00, 0.82, 0.31), + 'hc': (0.36, 0.00, 0.74, 0.26), 'sg': (0.42, 0.00, 0.72, 0.28), + 'sb': (0.36, 0.00, 0.72, 0.30), 'cu': (0.36, 0.12, 0.92, 0.44), + # the spectator KEEPS his ladder in frame — it is behind and around his head in the only + # drawing of him there is, and a crop tight enough to lose it loses half his face too + 'sp': (0.50, 0.21, 0.77, 0.47), 'rm': (0.10, 0.00, 0.40, 0.26)} def head_box(a): diff --git a/assets/court_gloom.png b/assets/court_gloom.png new file mode 100644 index 0000000..700727a Binary files /dev/null and b/assets/court_gloom.png differ diff --git a/assets/z_cu1.png b/assets/z_cu1.png new file mode 100644 index 0000000..671fbec Binary files /dev/null and b/assets/z_cu1.png differ diff --git a/assets/z_cu2.png b/assets/z_cu2.png new file mode 100644 index 0000000..a8cccb5 Binary files /dev/null and b/assets/z_cu2.png differ diff --git a/assets/z_cu3.png b/assets/z_cu3.png new file mode 100644 index 0000000..5fd78f0 Binary files /dev/null and b/assets/z_cu3.png differ diff --git a/assets/z_cu4.png b/assets/z_cu4.png new file mode 100644 index 0000000..f1526ca Binary files /dev/null and b/assets/z_cu4.png differ diff --git a/assets/z_cubounce.png b/assets/z_cubounce.png new file mode 100644 index 0000000..0de0c06 Binary files /dev/null and b/assets/z_cubounce.png differ diff --git a/assets/z_cufall1.png b/assets/z_cufall1.png new file mode 100644 index 0000000..089b456 Binary files /dev/null and b/assets/z_cufall1.png differ diff --git a/assets/z_cufall2.png b/assets/z_cufall2.png new file mode 100644 index 0000000..7ff317f Binary files /dev/null and b/assets/z_cufall2.png differ diff --git a/assets/z_cufall3.png b/assets/z_cufall3.png new file mode 100644 index 0000000..29e437e Binary files /dev/null and b/assets/z_cufall3.png differ diff --git a/assets/z_cuhead.png b/assets/z_cuhead.png new file mode 100644 index 0000000..9262881 Binary files /dev/null and b/assets/z_cuhead.png differ diff --git a/assets/z_curise.png b/assets/z_curise.png new file mode 100644 index 0000000..e1c1844 Binary files /dev/null and b/assets/z_curise.png differ diff --git a/assets/z_cuserve.png b/assets/z_cuserve.png new file mode 100644 index 0000000..e3f34b4 Binary files /dev/null and b/assets/z_cuserve.png differ diff --git a/assets/z_cutoss.png b/assets/z_cutoss.png new file mode 100644 index 0000000..39978f0 Binary files /dev/null and b/assets/z_cutoss.png differ diff --git a/assets/z_cuw1.png b/assets/z_cuw1.png new file mode 100644 index 0000000..878c467 Binary files /dev/null and b/assets/z_cuw1.png differ diff --git a/assets/z_cuw1b.png b/assets/z_cuw1b.png new file mode 100644 index 0000000..2f64234 Binary files /dev/null and b/assets/z_cuw1b.png differ diff --git a/assets/z_cuw1bounce.png b/assets/z_cuw1bounce.png new file mode 100644 index 0000000..19c6f18 Binary files /dev/null and b/assets/z_cuw1bounce.png differ diff --git a/assets/z_cuw1fall1.png b/assets/z_cuw1fall1.png new file mode 100644 index 0000000..bc9da0b Binary files /dev/null and b/assets/z_cuw1fall1.png differ diff --git a/assets/z_cuw1fall2.png b/assets/z_cuw1fall2.png new file mode 100644 index 0000000..3154882 Binary files /dev/null and b/assets/z_cuw1fall2.png differ diff --git a/assets/z_cuw1fall3.png b/assets/z_cuw1fall3.png new file mode 100644 index 0000000..3799d8f Binary files /dev/null and b/assets/z_cuw1fall3.png differ diff --git a/assets/z_cuw1rise.png b/assets/z_cuw1rise.png new file mode 100644 index 0000000..b5554bf Binary files /dev/null and b/assets/z_cuw1rise.png differ diff --git a/assets/z_cuw1s.png b/assets/z_cuw1s.png new file mode 100644 index 0000000..c0b4475 Binary files /dev/null and b/assets/z_cuw1s.png differ diff --git a/assets/z_cuw1serve.png b/assets/z_cuw1serve.png new file mode 100644 index 0000000..ce3b1ba Binary files /dev/null and b/assets/z_cuw1serve.png differ diff --git a/assets/z_cuw1toss.png b/assets/z_cuw1toss.png new file mode 100644 index 0000000..001c446 Binary files /dev/null and b/assets/z_cuw1toss.png differ diff --git a/assets/z_cuw2.png b/assets/z_cuw2.png new file mode 100644 index 0000000..25e9c6b Binary files /dev/null and b/assets/z_cuw2.png differ diff --git a/assets/z_cuw2b.png b/assets/z_cuw2b.png new file mode 100644 index 0000000..0d54896 Binary files /dev/null and b/assets/z_cuw2b.png differ diff --git a/assets/z_cuw2bounce.png b/assets/z_cuw2bounce.png new file mode 100644 index 0000000..747ad47 Binary files /dev/null and b/assets/z_cuw2bounce.png differ diff --git a/assets/z_cuw2fall1.png b/assets/z_cuw2fall1.png new file mode 100644 index 0000000..5742357 Binary files /dev/null and b/assets/z_cuw2fall1.png differ diff --git a/assets/z_cuw2fall2.png b/assets/z_cuw2fall2.png new file mode 100644 index 0000000..af94176 Binary files /dev/null and b/assets/z_cuw2fall2.png differ diff --git a/assets/z_cuw2fall3.png b/assets/z_cuw2fall3.png new file mode 100644 index 0000000..00491d7 Binary files /dev/null and b/assets/z_cuw2fall3.png differ diff --git a/assets/z_cuw2rise.png b/assets/z_cuw2rise.png new file mode 100644 index 0000000..3683d60 Binary files /dev/null and b/assets/z_cuw2rise.png differ diff --git a/assets/z_cuw2s.png b/assets/z_cuw2s.png new file mode 100644 index 0000000..8f51044 Binary files /dev/null and b/assets/z_cuw2s.png differ diff --git a/assets/z_cuw2serve.png b/assets/z_cuw2serve.png new file mode 100644 index 0000000..cb53dec Binary files /dev/null and b/assets/z_cuw2serve.png differ diff --git a/assets/z_cuw2toss.png b/assets/z_cuw2toss.png new file mode 100644 index 0000000..7933bce Binary files /dev/null and b/assets/z_cuw2toss.png differ diff --git a/assets/z_hc1.png b/assets/z_hc1.png new file mode 100644 index 0000000..95177e5 Binary files /dev/null and b/assets/z_hc1.png differ diff --git a/assets/z_hc2.png b/assets/z_hc2.png new file mode 100644 index 0000000..f5d5727 Binary files /dev/null and b/assets/z_hc2.png differ diff --git a/assets/z_hc3.png b/assets/z_hc3.png new file mode 100644 index 0000000..3bb0070 Binary files /dev/null and b/assets/z_hc3.png differ diff --git a/assets/z_hc4.png b/assets/z_hc4.png new file mode 100644 index 0000000..421f986 Binary files /dev/null and b/assets/z_hc4.png differ diff --git a/assets/z_hcbounce.png b/assets/z_hcbounce.png new file mode 100644 index 0000000..5da98b8 Binary files /dev/null and b/assets/z_hcbounce.png differ diff --git a/assets/z_hcfall1.png b/assets/z_hcfall1.png new file mode 100644 index 0000000..759431b Binary files /dev/null and b/assets/z_hcfall1.png differ diff --git a/assets/z_hcfall2.png b/assets/z_hcfall2.png new file mode 100644 index 0000000..fc89599 Binary files /dev/null and b/assets/z_hcfall2.png differ diff --git a/assets/z_hcfall3.png b/assets/z_hcfall3.png new file mode 100644 index 0000000..fbb0724 Binary files /dev/null and b/assets/z_hcfall3.png differ diff --git a/assets/z_hchead.png b/assets/z_hchead.png new file mode 100644 index 0000000..0f0654c Binary files /dev/null and b/assets/z_hchead.png differ diff --git a/assets/z_hcrise.png b/assets/z_hcrise.png new file mode 100644 index 0000000..455eb3f Binary files /dev/null and b/assets/z_hcrise.png differ diff --git a/assets/z_hcserve.png b/assets/z_hcserve.png new file mode 100644 index 0000000..01877ad Binary files /dev/null and b/assets/z_hcserve.png differ diff --git a/assets/z_hctoss.png b/assets/z_hctoss.png new file mode 100644 index 0000000..42eb986 Binary files /dev/null and b/assets/z_hctoss.png differ diff --git a/assets/z_hcw1.png b/assets/z_hcw1.png new file mode 100644 index 0000000..466b341 Binary files /dev/null and b/assets/z_hcw1.png differ diff --git a/assets/z_hcw1b.png b/assets/z_hcw1b.png new file mode 100644 index 0000000..4d82590 Binary files /dev/null and b/assets/z_hcw1b.png differ diff --git a/assets/z_hcw1bounce.png b/assets/z_hcw1bounce.png new file mode 100644 index 0000000..a73d687 Binary files /dev/null and b/assets/z_hcw1bounce.png differ diff --git a/assets/z_hcw1fall1.png b/assets/z_hcw1fall1.png new file mode 100644 index 0000000..3745fe6 Binary files /dev/null and b/assets/z_hcw1fall1.png differ diff --git a/assets/z_hcw1fall2.png b/assets/z_hcw1fall2.png new file mode 100644 index 0000000..6c5c631 Binary files /dev/null and b/assets/z_hcw1fall2.png differ diff --git a/assets/z_hcw1fall3.png b/assets/z_hcw1fall3.png new file mode 100644 index 0000000..76de9e3 Binary files /dev/null and b/assets/z_hcw1fall3.png differ diff --git a/assets/z_hcw1rise.png b/assets/z_hcw1rise.png new file mode 100644 index 0000000..7050474 Binary files /dev/null and b/assets/z_hcw1rise.png differ diff --git a/assets/z_hcw1s.png b/assets/z_hcw1s.png new file mode 100644 index 0000000..8500f2c Binary files /dev/null and b/assets/z_hcw1s.png differ diff --git a/assets/z_hcw1serve.png b/assets/z_hcw1serve.png new file mode 100644 index 0000000..c52f4df Binary files /dev/null and b/assets/z_hcw1serve.png differ diff --git a/assets/z_hcw1toss.png b/assets/z_hcw1toss.png new file mode 100644 index 0000000..b5d38a5 Binary files /dev/null and b/assets/z_hcw1toss.png differ diff --git a/assets/z_hcw2.png b/assets/z_hcw2.png new file mode 100644 index 0000000..3fe8b3d Binary files /dev/null and b/assets/z_hcw2.png differ diff --git a/assets/z_hcw2b.png b/assets/z_hcw2b.png new file mode 100644 index 0000000..b462d07 Binary files /dev/null and b/assets/z_hcw2b.png differ diff --git a/assets/z_hcw2bounce.png b/assets/z_hcw2bounce.png new file mode 100644 index 0000000..f64218c Binary files /dev/null and b/assets/z_hcw2bounce.png differ diff --git a/assets/z_hcw2fall1.png b/assets/z_hcw2fall1.png new file mode 100644 index 0000000..12b7827 Binary files /dev/null and b/assets/z_hcw2fall1.png differ diff --git a/assets/z_hcw2fall2.png b/assets/z_hcw2fall2.png new file mode 100644 index 0000000..405bf73 Binary files /dev/null and b/assets/z_hcw2fall2.png differ diff --git a/assets/z_hcw2fall3.png b/assets/z_hcw2fall3.png new file mode 100644 index 0000000..cd0bb6e Binary files /dev/null and b/assets/z_hcw2fall3.png differ diff --git a/assets/z_hcw2rise.png b/assets/z_hcw2rise.png new file mode 100644 index 0000000..9400b8b Binary files /dev/null and b/assets/z_hcw2rise.png differ diff --git a/assets/z_hcw2s.png b/assets/z_hcw2s.png new file mode 100644 index 0000000..16aa6f6 Binary files /dev/null and b/assets/z_hcw2s.png differ diff --git a/assets/z_hcw2serve.png b/assets/z_hcw2serve.png new file mode 100644 index 0000000..933bb30 Binary files /dev/null and b/assets/z_hcw2serve.png differ diff --git a/assets/z_hcw2toss.png b/assets/z_hcw2toss.png new file mode 100644 index 0000000..a8601b2 Binary files /dev/null and b/assets/z_hcw2toss.png differ diff --git a/assets/z_rhead.png b/assets/z_rhead.png new file mode 100644 index 0000000..869a28a Binary files /dev/null and b/assets/z_rhead.png differ diff --git a/assets/z_rm1.png b/assets/z_rm1.png new file mode 100644 index 0000000..9709733 Binary files /dev/null and b/assets/z_rm1.png differ diff --git a/assets/z_rm2.png b/assets/z_rm2.png new file mode 100644 index 0000000..5cb01ff Binary files /dev/null and b/assets/z_rm2.png differ diff --git a/assets/z_rm3.png b/assets/z_rm3.png new file mode 100644 index 0000000..2cdd7c0 Binary files /dev/null and b/assets/z_rm3.png differ diff --git a/assets/z_rm4.png b/assets/z_rm4.png new file mode 100644 index 0000000..fb45bca Binary files /dev/null and b/assets/z_rm4.png differ diff --git a/assets/z_rmbounce.png b/assets/z_rmbounce.png new file mode 100644 index 0000000..a422f26 Binary files /dev/null and b/assets/z_rmbounce.png differ diff --git a/assets/z_rmfall1.png b/assets/z_rmfall1.png new file mode 100644 index 0000000..9a6dc54 Binary files /dev/null and b/assets/z_rmfall1.png differ diff --git a/assets/z_rmfall2.png b/assets/z_rmfall2.png new file mode 100644 index 0000000..46ab941 Binary files /dev/null and b/assets/z_rmfall2.png differ diff --git a/assets/z_rmfall3.png b/assets/z_rmfall3.png new file mode 100644 index 0000000..aac7afc Binary files /dev/null and b/assets/z_rmfall3.png differ diff --git a/assets/z_rmhead.png b/assets/z_rmhead.png new file mode 100644 index 0000000..19f9a01 Binary files /dev/null and b/assets/z_rmhead.png differ diff --git a/assets/z_rmrise.png b/assets/z_rmrise.png new file mode 100644 index 0000000..a396ea8 Binary files /dev/null and b/assets/z_rmrise.png differ diff --git a/assets/z_rmserve.png b/assets/z_rmserve.png new file mode 100644 index 0000000..573fdba Binary files /dev/null and b/assets/z_rmserve.png differ diff --git a/assets/z_rmtoss.png b/assets/z_rmtoss.png new file mode 100644 index 0000000..25f5dce Binary files /dev/null and b/assets/z_rmtoss.png differ diff --git a/assets/z_rmw1.png b/assets/z_rmw1.png new file mode 100644 index 0000000..09caaff Binary files /dev/null and b/assets/z_rmw1.png differ diff --git a/assets/z_rmw1b.png b/assets/z_rmw1b.png new file mode 100644 index 0000000..93d8009 Binary files /dev/null and b/assets/z_rmw1b.png differ diff --git a/assets/z_rmw1bounce.png b/assets/z_rmw1bounce.png new file mode 100644 index 0000000..fb71b8f Binary files /dev/null and b/assets/z_rmw1bounce.png differ diff --git a/assets/z_rmw1fall1.png b/assets/z_rmw1fall1.png new file mode 100644 index 0000000..c3069f5 Binary files /dev/null and b/assets/z_rmw1fall1.png differ diff --git a/assets/z_rmw1fall2.png b/assets/z_rmw1fall2.png new file mode 100644 index 0000000..942b0e8 Binary files /dev/null and b/assets/z_rmw1fall2.png differ diff --git a/assets/z_rmw1fall3.png b/assets/z_rmw1fall3.png new file mode 100644 index 0000000..0f2f89a Binary files /dev/null and b/assets/z_rmw1fall3.png differ diff --git a/assets/z_rmw1rise.png b/assets/z_rmw1rise.png new file mode 100644 index 0000000..15a3934 Binary files /dev/null and b/assets/z_rmw1rise.png differ diff --git a/assets/z_rmw1s.png b/assets/z_rmw1s.png new file mode 100644 index 0000000..cf7dc6a Binary files /dev/null and b/assets/z_rmw1s.png differ diff --git a/assets/z_rmw1serve.png b/assets/z_rmw1serve.png new file mode 100644 index 0000000..bb77da8 Binary files /dev/null and b/assets/z_rmw1serve.png differ diff --git a/assets/z_rmw1toss.png b/assets/z_rmw1toss.png new file mode 100644 index 0000000..105c17e Binary files /dev/null and b/assets/z_rmw1toss.png differ diff --git a/assets/z_rmw2.png b/assets/z_rmw2.png new file mode 100644 index 0000000..4e4d19a Binary files /dev/null and b/assets/z_rmw2.png differ diff --git a/assets/z_rmw2b.png b/assets/z_rmw2b.png new file mode 100644 index 0000000..930e549 Binary files /dev/null and b/assets/z_rmw2b.png differ diff --git a/assets/z_rmw2bounce.png b/assets/z_rmw2bounce.png new file mode 100644 index 0000000..139b39a Binary files /dev/null and b/assets/z_rmw2bounce.png differ diff --git a/assets/z_rmw2fall1.png b/assets/z_rmw2fall1.png new file mode 100644 index 0000000..20baeba Binary files /dev/null and b/assets/z_rmw2fall1.png differ diff --git a/assets/z_rmw2fall2.png b/assets/z_rmw2fall2.png new file mode 100644 index 0000000..641b33f Binary files /dev/null and b/assets/z_rmw2fall2.png differ diff --git a/assets/z_rmw2fall3.png b/assets/z_rmw2fall3.png new file mode 100644 index 0000000..44dafb2 Binary files /dev/null and b/assets/z_rmw2fall3.png differ diff --git a/assets/z_rmw2rise.png b/assets/z_rmw2rise.png new file mode 100644 index 0000000..437b8b5 Binary files /dev/null and b/assets/z_rmw2rise.png differ diff --git a/assets/z_rmw2s.png b/assets/z_rmw2s.png new file mode 100644 index 0000000..4d8fcb0 Binary files /dev/null and b/assets/z_rmw2s.png differ diff --git a/assets/z_rmw2serve.png b/assets/z_rmw2serve.png new file mode 100644 index 0000000..e236134 Binary files /dev/null and b/assets/z_rmw2serve.png differ diff --git a/assets/z_rmw2toss.png b/assets/z_rmw2toss.png new file mode 100644 index 0000000..b43ae78 Binary files /dev/null and b/assets/z_rmw2toss.png differ diff --git a/assets/z_sb1.png b/assets/z_sb1.png new file mode 100644 index 0000000..942e6d1 Binary files /dev/null and b/assets/z_sb1.png differ diff --git a/assets/z_sb2.png b/assets/z_sb2.png new file mode 100644 index 0000000..1da569a Binary files /dev/null and b/assets/z_sb2.png differ diff --git a/assets/z_sb3.png b/assets/z_sb3.png new file mode 100644 index 0000000..79b8d4a Binary files /dev/null and b/assets/z_sb3.png differ diff --git a/assets/z_sb4.png b/assets/z_sb4.png new file mode 100644 index 0000000..2007f39 Binary files /dev/null and b/assets/z_sb4.png differ diff --git a/assets/z_sbbounce.png b/assets/z_sbbounce.png new file mode 100644 index 0000000..ce7e607 Binary files /dev/null and b/assets/z_sbbounce.png differ diff --git a/assets/z_sbfall1.png b/assets/z_sbfall1.png new file mode 100644 index 0000000..ec39cb9 Binary files /dev/null and b/assets/z_sbfall1.png differ diff --git a/assets/z_sbfall2.png b/assets/z_sbfall2.png new file mode 100644 index 0000000..54dda41 Binary files /dev/null and b/assets/z_sbfall2.png differ diff --git a/assets/z_sbfall3.png b/assets/z_sbfall3.png new file mode 100644 index 0000000..d6ff861 Binary files /dev/null and b/assets/z_sbfall3.png differ diff --git a/assets/z_sbhead.png b/assets/z_sbhead.png new file mode 100644 index 0000000..b4831bf Binary files /dev/null and b/assets/z_sbhead.png differ diff --git a/assets/z_sbrise.png b/assets/z_sbrise.png new file mode 100644 index 0000000..8dfc88d Binary files /dev/null and b/assets/z_sbrise.png differ diff --git a/assets/z_sbserve.png b/assets/z_sbserve.png new file mode 100644 index 0000000..4fadd3d Binary files /dev/null and b/assets/z_sbserve.png differ diff --git a/assets/z_sbtoss.png b/assets/z_sbtoss.png new file mode 100644 index 0000000..40248b1 Binary files /dev/null and b/assets/z_sbtoss.png differ diff --git a/assets/z_sbw1.png b/assets/z_sbw1.png new file mode 100644 index 0000000..b588ee1 Binary files /dev/null and b/assets/z_sbw1.png differ diff --git a/assets/z_sbw1b.png b/assets/z_sbw1b.png new file mode 100644 index 0000000..577ea1a Binary files /dev/null and b/assets/z_sbw1b.png differ diff --git a/assets/z_sbw1bounce.png b/assets/z_sbw1bounce.png new file mode 100644 index 0000000..39fde99 Binary files /dev/null and b/assets/z_sbw1bounce.png differ diff --git a/assets/z_sbw1fall1.png b/assets/z_sbw1fall1.png new file mode 100644 index 0000000..bc9389c Binary files /dev/null and b/assets/z_sbw1fall1.png differ diff --git a/assets/z_sbw1fall2.png b/assets/z_sbw1fall2.png new file mode 100644 index 0000000..bfcf3d6 Binary files /dev/null and b/assets/z_sbw1fall2.png differ diff --git a/assets/z_sbw1fall3.png b/assets/z_sbw1fall3.png new file mode 100644 index 0000000..a39f0fd Binary files /dev/null and b/assets/z_sbw1fall3.png differ diff --git a/assets/z_sbw1rise.png b/assets/z_sbw1rise.png new file mode 100644 index 0000000..a71427e Binary files /dev/null and b/assets/z_sbw1rise.png differ diff --git a/assets/z_sbw1s.png b/assets/z_sbw1s.png new file mode 100644 index 0000000..2189283 Binary files /dev/null and b/assets/z_sbw1s.png differ diff --git a/assets/z_sbw1serve.png b/assets/z_sbw1serve.png new file mode 100644 index 0000000..97124a2 Binary files /dev/null and b/assets/z_sbw1serve.png differ diff --git a/assets/z_sbw1toss.png b/assets/z_sbw1toss.png new file mode 100644 index 0000000..b64deb4 Binary files /dev/null and b/assets/z_sbw1toss.png differ diff --git a/assets/z_sbw2.png b/assets/z_sbw2.png new file mode 100644 index 0000000..a7a86a8 Binary files /dev/null and b/assets/z_sbw2.png differ diff --git a/assets/z_sbw2b.png b/assets/z_sbw2b.png new file mode 100644 index 0000000..13e324a Binary files /dev/null and b/assets/z_sbw2b.png differ diff --git a/assets/z_sbw2bounce.png b/assets/z_sbw2bounce.png new file mode 100644 index 0000000..51980b3 Binary files /dev/null and b/assets/z_sbw2bounce.png differ diff --git a/assets/z_sbw2fall1.png b/assets/z_sbw2fall1.png new file mode 100644 index 0000000..3fa0590 Binary files /dev/null and b/assets/z_sbw2fall1.png differ diff --git a/assets/z_sbw2fall2.png b/assets/z_sbw2fall2.png new file mode 100644 index 0000000..c17297e Binary files /dev/null and b/assets/z_sbw2fall2.png differ diff --git a/assets/z_sbw2fall3.png b/assets/z_sbw2fall3.png new file mode 100644 index 0000000..e88e399 Binary files /dev/null and b/assets/z_sbw2fall3.png differ diff --git a/assets/z_sbw2rise.png b/assets/z_sbw2rise.png new file mode 100644 index 0000000..3e2e1dd Binary files /dev/null and b/assets/z_sbw2rise.png differ diff --git a/assets/z_sbw2s.png b/assets/z_sbw2s.png new file mode 100644 index 0000000..32e5899 Binary files /dev/null and b/assets/z_sbw2s.png differ diff --git a/assets/z_sbw2serve.png b/assets/z_sbw2serve.png new file mode 100644 index 0000000..708605c Binary files /dev/null and b/assets/z_sbw2serve.png differ diff --git a/assets/z_sbw2toss.png b/assets/z_sbw2toss.png new file mode 100644 index 0000000..05cca1d Binary files /dev/null and b/assets/z_sbw2toss.png differ diff --git a/assets/z_sg1.png b/assets/z_sg1.png new file mode 100644 index 0000000..adc0000 Binary files /dev/null and b/assets/z_sg1.png differ diff --git a/assets/z_sg2.png b/assets/z_sg2.png new file mode 100644 index 0000000..e51d6f4 Binary files /dev/null and b/assets/z_sg2.png differ diff --git a/assets/z_sg3.png b/assets/z_sg3.png new file mode 100644 index 0000000..becc4a2 Binary files /dev/null and b/assets/z_sg3.png differ diff --git a/assets/z_sg4.png b/assets/z_sg4.png new file mode 100644 index 0000000..81df693 Binary files /dev/null and b/assets/z_sg4.png differ diff --git a/assets/z_sgbounce.png b/assets/z_sgbounce.png new file mode 100644 index 0000000..a5b7bab Binary files /dev/null and b/assets/z_sgbounce.png differ diff --git a/assets/z_sgfall1.png b/assets/z_sgfall1.png new file mode 100644 index 0000000..5785cd5 Binary files /dev/null and b/assets/z_sgfall1.png differ diff --git a/assets/z_sgfall2.png b/assets/z_sgfall2.png new file mode 100644 index 0000000..6bf45ef Binary files /dev/null and b/assets/z_sgfall2.png differ diff --git a/assets/z_sgfall3.png b/assets/z_sgfall3.png new file mode 100644 index 0000000..9844f97 Binary files /dev/null and b/assets/z_sgfall3.png differ diff --git a/assets/z_sghead.png b/assets/z_sghead.png new file mode 100644 index 0000000..341ed5b Binary files /dev/null and b/assets/z_sghead.png differ diff --git a/assets/z_sgrise.png b/assets/z_sgrise.png new file mode 100644 index 0000000..fc2ad9d Binary files /dev/null and b/assets/z_sgrise.png differ diff --git a/assets/z_sgserve.png b/assets/z_sgserve.png new file mode 100644 index 0000000..3165363 Binary files /dev/null and b/assets/z_sgserve.png differ diff --git a/assets/z_sgtoss.png b/assets/z_sgtoss.png new file mode 100644 index 0000000..34a6689 Binary files /dev/null and b/assets/z_sgtoss.png differ diff --git a/assets/z_sgw1.png b/assets/z_sgw1.png new file mode 100644 index 0000000..8f07af1 Binary files /dev/null and b/assets/z_sgw1.png differ diff --git a/assets/z_sgw1b.png b/assets/z_sgw1b.png new file mode 100644 index 0000000..ecbce9e Binary files /dev/null and b/assets/z_sgw1b.png differ diff --git a/assets/z_sgw1bounce.png b/assets/z_sgw1bounce.png new file mode 100644 index 0000000..ebbde1a Binary files /dev/null and b/assets/z_sgw1bounce.png differ diff --git a/assets/z_sgw1fall1.png b/assets/z_sgw1fall1.png new file mode 100644 index 0000000..d93db07 Binary files /dev/null and b/assets/z_sgw1fall1.png differ diff --git a/assets/z_sgw1fall2.png b/assets/z_sgw1fall2.png new file mode 100644 index 0000000..927a508 Binary files /dev/null and b/assets/z_sgw1fall2.png differ diff --git a/assets/z_sgw1fall3.png b/assets/z_sgw1fall3.png new file mode 100644 index 0000000..60580a8 Binary files /dev/null and b/assets/z_sgw1fall3.png differ diff --git a/assets/z_sgw1rise.png b/assets/z_sgw1rise.png new file mode 100644 index 0000000..6f656b1 Binary files /dev/null and b/assets/z_sgw1rise.png differ diff --git a/assets/z_sgw1s.png b/assets/z_sgw1s.png new file mode 100644 index 0000000..adebec3 Binary files /dev/null and b/assets/z_sgw1s.png differ diff --git a/assets/z_sgw1serve.png b/assets/z_sgw1serve.png new file mode 100644 index 0000000..f5065b0 Binary files /dev/null and b/assets/z_sgw1serve.png differ diff --git a/assets/z_sgw1toss.png b/assets/z_sgw1toss.png new file mode 100644 index 0000000..a7d4c9f Binary files /dev/null and b/assets/z_sgw1toss.png differ diff --git a/assets/z_sgw2.png b/assets/z_sgw2.png new file mode 100644 index 0000000..a732478 Binary files /dev/null and b/assets/z_sgw2.png differ diff --git a/assets/z_sgw2b.png b/assets/z_sgw2b.png new file mode 100644 index 0000000..0556cd1 Binary files /dev/null and b/assets/z_sgw2b.png differ diff --git a/assets/z_sgw2bounce.png b/assets/z_sgw2bounce.png new file mode 100644 index 0000000..7cf9b1c Binary files /dev/null and b/assets/z_sgw2bounce.png differ diff --git a/assets/z_sgw2fall1.png b/assets/z_sgw2fall1.png new file mode 100644 index 0000000..6c178f8 Binary files /dev/null and b/assets/z_sgw2fall1.png differ diff --git a/assets/z_sgw2fall2.png b/assets/z_sgw2fall2.png new file mode 100644 index 0000000..fdaa90e Binary files /dev/null and b/assets/z_sgw2fall2.png differ diff --git a/assets/z_sgw2fall3.png b/assets/z_sgw2fall3.png new file mode 100644 index 0000000..8329370 Binary files /dev/null and b/assets/z_sgw2fall3.png differ diff --git a/assets/z_sgw2rise.png b/assets/z_sgw2rise.png new file mode 100644 index 0000000..23ec2b4 Binary files /dev/null and b/assets/z_sgw2rise.png differ diff --git a/assets/z_sgw2s.png b/assets/z_sgw2s.png new file mode 100644 index 0000000..74fb43b Binary files /dev/null and b/assets/z_sgw2s.png differ diff --git a/assets/z_sgw2serve.png b/assets/z_sgw2serve.png new file mode 100644 index 0000000..451133c Binary files /dev/null and b/assets/z_sgw2serve.png differ diff --git a/assets/z_sgw2toss.png b/assets/z_sgw2toss.png new file mode 100644 index 0000000..0aec461 Binary files /dev/null and b/assets/z_sgw2toss.png differ diff --git a/assets/z_sp1.png b/assets/z_sp1.png new file mode 100644 index 0000000..5f6b1cc Binary files /dev/null and b/assets/z_sp1.png differ diff --git a/assets/z_sp2.png b/assets/z_sp2.png new file mode 100644 index 0000000..44dde38 Binary files /dev/null and b/assets/z_sp2.png differ diff --git a/assets/z_sp3.png b/assets/z_sp3.png new file mode 100644 index 0000000..bfc8120 Binary files /dev/null and b/assets/z_sp3.png differ diff --git a/assets/z_sp4.png b/assets/z_sp4.png new file mode 100644 index 0000000..cfe87af Binary files /dev/null and b/assets/z_sp4.png differ diff --git a/assets/z_spbounce.png b/assets/z_spbounce.png new file mode 100644 index 0000000..8b265f4 Binary files /dev/null and b/assets/z_spbounce.png differ diff --git a/assets/z_spfall1.png b/assets/z_spfall1.png new file mode 100644 index 0000000..b84ed60 Binary files /dev/null and b/assets/z_spfall1.png differ diff --git a/assets/z_spfall2.png b/assets/z_spfall2.png new file mode 100644 index 0000000..b114e5b Binary files /dev/null and b/assets/z_spfall2.png differ diff --git a/assets/z_spfall3.png b/assets/z_spfall3.png new file mode 100644 index 0000000..e7f5f4f Binary files /dev/null and b/assets/z_spfall3.png differ diff --git a/assets/z_sphead.png b/assets/z_sphead.png new file mode 100644 index 0000000..b1d7db2 Binary files /dev/null and b/assets/z_sphead.png differ diff --git a/assets/z_sprise.png b/assets/z_sprise.png new file mode 100644 index 0000000..7de9cd9 Binary files /dev/null and b/assets/z_sprise.png differ diff --git a/assets/z_spserve.png b/assets/z_spserve.png new file mode 100644 index 0000000..f4279af Binary files /dev/null and b/assets/z_spserve.png differ diff --git a/assets/z_sptoss.png b/assets/z_sptoss.png new file mode 100644 index 0000000..4262941 Binary files /dev/null and b/assets/z_sptoss.png differ diff --git a/assets/z_spw1.png b/assets/z_spw1.png new file mode 100644 index 0000000..9d89fba Binary files /dev/null and b/assets/z_spw1.png differ diff --git a/assets/z_spw1b.png b/assets/z_spw1b.png new file mode 100644 index 0000000..6592243 Binary files /dev/null and b/assets/z_spw1b.png differ diff --git a/assets/z_spw1bounce.png b/assets/z_spw1bounce.png new file mode 100644 index 0000000..38c220c Binary files /dev/null and b/assets/z_spw1bounce.png differ diff --git a/assets/z_spw1fall1.png b/assets/z_spw1fall1.png new file mode 100644 index 0000000..968c77e Binary files /dev/null and b/assets/z_spw1fall1.png differ diff --git a/assets/z_spw1fall2.png b/assets/z_spw1fall2.png new file mode 100644 index 0000000..28d0be7 Binary files /dev/null and b/assets/z_spw1fall2.png differ diff --git a/assets/z_spw1fall3.png b/assets/z_spw1fall3.png new file mode 100644 index 0000000..5d856a7 Binary files /dev/null and b/assets/z_spw1fall3.png differ diff --git a/assets/z_spw1rise.png b/assets/z_spw1rise.png new file mode 100644 index 0000000..a1b6d5a Binary files /dev/null and b/assets/z_spw1rise.png differ diff --git a/assets/z_spw1s.png b/assets/z_spw1s.png new file mode 100644 index 0000000..4276ae0 Binary files /dev/null and b/assets/z_spw1s.png differ diff --git a/assets/z_spw1serve.png b/assets/z_spw1serve.png new file mode 100644 index 0000000..ccaafc2 Binary files /dev/null and b/assets/z_spw1serve.png differ diff --git a/assets/z_spw1toss.png b/assets/z_spw1toss.png new file mode 100644 index 0000000..4dd0c30 Binary files /dev/null and b/assets/z_spw1toss.png differ diff --git a/assets/z_spw2.png b/assets/z_spw2.png new file mode 100644 index 0000000..923a500 Binary files /dev/null and b/assets/z_spw2.png differ diff --git a/assets/z_spw2b.png b/assets/z_spw2b.png new file mode 100644 index 0000000..4b1b554 Binary files /dev/null and b/assets/z_spw2b.png differ diff --git a/assets/z_spw2bounce.png b/assets/z_spw2bounce.png new file mode 100644 index 0000000..b7b1114 Binary files /dev/null and b/assets/z_spw2bounce.png differ diff --git a/assets/z_spw2fall1.png b/assets/z_spw2fall1.png new file mode 100644 index 0000000..3b33719 Binary files /dev/null and b/assets/z_spw2fall1.png differ diff --git a/assets/z_spw2fall2.png b/assets/z_spw2fall2.png new file mode 100644 index 0000000..0d6df29 Binary files /dev/null and b/assets/z_spw2fall2.png differ diff --git a/assets/z_spw2fall3.png b/assets/z_spw2fall3.png new file mode 100644 index 0000000..ea12a14 Binary files /dev/null and b/assets/z_spw2fall3.png differ diff --git a/assets/z_spw2rise.png b/assets/z_spw2rise.png new file mode 100644 index 0000000..80a5810 Binary files /dev/null and b/assets/z_spw2rise.png differ diff --git a/assets/z_spw2s.png b/assets/z_spw2s.png new file mode 100644 index 0000000..5eb205c Binary files /dev/null and b/assets/z_spw2s.png differ diff --git a/assets/z_spw2serve.png b/assets/z_spw2serve.png new file mode 100644 index 0000000..a2478fd Binary files /dev/null and b/assets/z_spw2serve.png differ diff --git a/assets/z_spw2toss.png b/assets/z_spw2toss.png new file mode 100644 index 0000000..74f9550 Binary files /dev/null and b/assets/z_spw2toss.png differ diff --git a/index.html b/index.html index baa70d7..438df34 100644 --- a/index.html +++ b/index.html @@ -155,7 +155,7 @@ if (location.hostname !== 'splatter-tennis.wuwi.one') { /* ONE overlay, four panels, swapped by #intro[data-stage]. Keeping the backdrop and the card frame alive across every screen is the point — a screen that unmounts the whole overlay flashes the court through between menus. */ - #splash, #setup, #stats, #ranks, #newbest, #secret, #bosses, #stmode, #stsub { display: none; } + #splash, #setup, #stats, #ranks, #newbest, #secret, #bosses, #stmode, #stsub, #bracket { display: none; } #intro[data-stage="splash"] #splash { display: flex; flex-direction: column; flex: 1; } /* flex COLUMN, not block: it is what lets the footer's margin-top:auto reach the bottom of a screen whose content does not fill it */ @@ -166,6 +166,7 @@ if (location.hostname !== 'splatter-tennis.wuwi.one') { #intro[data-stage="ranks"] #ranks, #intro[data-stage="secret"] #secret, #intro[data-stage="bosses"] #bosses, + #intro[data-stage="bracket"] #bracket, #intro[data-stage="newbest"] #newbest { display: flex; flex-direction: column; flex: 1; animation: drop 0.3s ease-out; } /* the overlay is HIDDEN during play, not removed: a restart puts it straight back, and the @@ -234,6 +235,37 @@ if (location.hostname !== 'splatter-tennis.wuwi.one') { text-align: center; line-height: 1.35; } #bossList i { font: 400 8px Silkscreen, monospace; color: var(--dim); font-style: normal; letter-spacing: 1px; } + /* THE DRAW. Four rungs read top-down as a ladder, because that is what the mode is — the bracket + shape a real draw is printed in needs width a phone has not got, and half of it would be blank + anyway (you only ever play one side of it). The rows spin their portrait and their name while + the draw is being made, and .set is what a landed one wears: the placeholder look is on the + ROW, so a slot that has stopped is obvious at a glance and START stays out of reach until the + last of them lands. The final wears .set from the first frame — it was never in doubt. */ + #cupRows { display: flex; flex-direction: column; gap: 8px; } + .cupRow { display: grid; grid-template-columns: 54px 1fr auto; grid-template-rows: auto auto; + align-items: center; column-gap: 10px; row-gap: 2px; + padding: 7px 10px; background: var(--panel); border: 2px solid var(--line); + /* the connector: every round but the first hangs off the one above it */ + position: relative; } + .cupRow + .cupRow::before { content: ''; position: absolute; left: 35px; top: -10px; width: 2px; + height: 10px; background: var(--line); } + .cupRow img { grid-column: 1; grid-row: 1 / span 2; width: 54px; height: 54px; + object-fit: contain; object-position: center; image-rendering: pixelated; + /* mid-spin the faces flicker past unreadable; dimming them says "still rolling" + without a second element to keep in sync */ + opacity: 0.5; filter: grayscale(0.7); } + .cupRow u { grid-column: 2; grid-row: 1; font: 400 8px Silkscreen, monospace; color: var(--dim); + letter-spacing: 1.5px; text-decoration: none; text-align: left; } + .cupRow b { grid-column: 2; grid-row: 2; font: 400 11px Silkscreen, monospace; color: var(--dim); + letter-spacing: 0.5px; line-height: 1.35; text-align: left; } + .cupRow i { grid-column: 3; grid-row: 1 / span 2; font: 400 8px Silkscreen, monospace; + font-style: normal; color: var(--dim); letter-spacing: 1px; white-space: nowrap; } + .cupRow.set img { opacity: 1; filter: none; } + .cupRow.set b { color: var(--cream); } + .cupRow.fin { border-color: var(--red); } + .cupRow.fin.set b { color: var(--acid); } + /* START is genuinely unreachable until the draw has landed — the wait IS the ceremony */ + .btn[disabled] { opacity: 0.32; cursor: default; } /* a row, not a stacked tile: three 3:2 tiles stacked fill a phone twice over, and the art still reads at this size. -webkit-tap-highlight-color kills iOS's blue flash over the pixel art. */ /* MODE posters are pure BUTTONS, not selectors (Oliver: the remembered highlight made each one @@ -305,7 +337,7 @@ if (location.hostname !== 'splatter-tennis.wuwi.one') { font: 400 9px Silkscreen, monospace; color: var(--dim); letter-spacing: 2px; } #splash.busy #boot { display: block; } #splash.busy .btn, #splash.busy .two, #splash.busy #build { display: none; } - #sSum { display: flex; justify-content: space-between; gap: 8px; margin: 0 0 8px; + #sSum, #brSum { display: flex; justify-content: space-between; gap: 8px; margin: 0 0 8px; font: 400 9px Silkscreen, monospace; color: var(--dim); letter-spacing: 1px; } .tabs { display: flex; gap: 6px; margin: 12px 0 14px; } .tabs button { flex: 1; padding: 11px 2px; background: var(--panel); border: 2px solid var(--line); @@ -430,6 +462,19 @@ if (location.hostname !== 'splatter-tennis.wuwi.one') { + +
+

THE DRAW

+

FOUR ROUNDSDRAWING…

+
+
+

+ +
+

BODY COUNT

@@ -494,6 +539,6 @@ if (location.hostname !== 'splatter-tennis.wuwi.one') {
- + diff --git a/server.py b/server.py index 136a2ad..6de0fa3 100644 --- a/server.py +++ b/server.py @@ -161,15 +161,20 @@ class Handler(SimpleHTTPRequestHandler): code = ''.join(random.choice(WS_ALPHABET) for _ in range(4)) ROOMS[code] = [me] me['room'] = code - # the host's ONE decision rides the room: classic tennis or the death match. The - # start message carries it to both phones, so the joiner never has to be asked. + # the host's decisions ride the room: classic tennis or the death match, and WHICH + # COURT. The start message carries both to both phones, so the joiner is never + # asked — and never ends up playing the same rally on a different surface. me['death'] = 'death=1' in self.path + me['court'] = re.sub(r'[^a-z]', '', self.path.split('court=')[1].split('&')[0] + if 'court=' in self.path else '')[:12] if len(ROOMS) > 200: # a lobby is two people; 200 stale rooms is a flood ROOMS.pop(next(iter(ROOMS))) if me['peer']: # paired: the host serves first, on both scoreboards - death = 'true' if me['peer'].get('death') else 'false' - ws_send(me['peer'], '{"t":"start","side":"A","death":%s}' % death) - ws_send(me, '{"t":"start","side":"B","death":%s}' % death) + host = me['peer'] # ...and the host's picks are what both phones get + for who, side in ((host, 'A'), (me, 'B')): + ws_send(who, json.dumps({'t': 'start', 'side': side, + 'death': bool(host.get('death')), + 'court': host.get('court') or ''})) else: ws_send(me, json.dumps({'t': 'room', 'code': me['room']})) try: diff --git a/src/beats.js b/src/beats.js index a3b0ed5..79fe93b 100644 --- a/src/beats.js +++ b/src/beats.js @@ -342,14 +342,17 @@ export function watchdog(dt) { // the match mode ends. Regicide is inherited for free: smashing the ball into the body still wounds // it, and killing it outright wins the game early (killZombie fires resolveInterlude('P'), which // lands in matchGameWon via the empty-court check). -export function startMatch(cup = false) { +// `draw` is the bracket screen's settled rungs, one per CUP_ROUNDS entry (main.buildBracket). It is +// optional and null means "roll each round as it comes", which is what the mode did before the +// screen existed and is still what the #cup back door gets. +export function startMatch(cup = false, draw = null) { if (G.interlude || G.horde || G.bossFight || G.drill || G.match || G.bench || G.net) return false; G.match = { opp: 1, ending: false }; // THE TOURNAMENT rides the whole match machinery and differs in exactly three places: who walks // on (a rung DRAWN from the round's band — cupDraw sets d.opp, so every ladder dial downstream // scales off the drawn rung untouched), how deep the body's pool is (matchOpponent multiplies // by the round's `need`), and where a won game goes (cupGameWon). See config.CUP_ROUNDS. - if (cup) { G.match.cup = { round: 0, g: 0 }; cupDraw(); } + if (cup) { G.match.cup = { round: 0, g: 0, draw: draw || null }; cupDraw(); } 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 @@ -380,9 +383,15 @@ export function startMatch(cup = false) { // The round's rung, drawn from its band. d.opp carries it so matchFoe, the ladder speed dials and // the felled arithmetic all read the drawn rung with no cup-awareness of their own. +// A DRAW MADE ON SCREEN IS THE DRAW THAT IS PLAYED. When the bracket screen settled the rungs they +// arrive here as cup.draw and this simply reads them off — rolling again would mean the faces the +// player watched land had nothing to do with what walks on, which is worse than showing nothing. +// Guarded on the band all the same: a saved or hand-passed draw that names a rung this round does +// not contain falls back to an honest roll rather than dealing the champion in round one. function cupDraw() { const cup = G.match.cup, R = CUP_ROUNDS[cup.round]; - cup.rung = R.band[Math.floor(Math.random() * R.band.length)]; + const drawn = cup.draw && cup.draw[cup.round]; + cup.rung = R.band.includes(drawn) ? drawn : R.band[Math.floor(Math.random() * R.band.length)]; G.match.opp = cup.rung; } diff --git a/src/config.js b/src/config.js index 5246d7b..108a825 100644 --- a/src/config.js +++ b/src/config.js @@ -233,6 +233,13 @@ export const TUNE = { asideEvery: 55, // s between the umpire's sarcastic asides in splatter (jittered +-40%). He never // acknowledged that the match had changed; now he comments on it, disapprovingly. racketHpPerGame: 0.12, // the racket zombie stiffens as the run goes on (floored into whole hp) + // CAMERA FLASHES IN THE STAND (render.tickFlashes). MEAN seconds between them — the actual gap is + // jittered 0.35x..1.95x so they never tick, and halved again while the crowd is cheering. Turn + // flashEvery UP to quieten them; it is the one knob. Life is deliberately shorter than a frame + // pair at 60 fps would need: a flash you can look AT is a light, a flash you only catch is a + // photograph being taken. + flashEvery: 5.0, + flashLife: 0.16, // ---- damage. Bodies with hp (racket boss 'r', brute 'g') take damage in these units, so a big // boss is just a bigger hp number and every weapon stays proportional. Fractions are legal: they // bank on zb.chip and only whole points come off hp (the wound SKINS switch on hp thresholds). @@ -624,6 +631,24 @@ export const COURTS = [ out: '#2c7a52', lawn: '#1f5c93', stripe: '#1f5c93' }, splatter: { stands: '#141a26', tierA: '#1a2130', tierB: '#161d2a', board: '#3a1212', out: '#25452f', lawn: '#1c3e5c', stripe: '#1c3e5c' } }, + // THE NIGHT COURT (Oliver, 2026-08-15: "a doomy gloomy court, the lines are bright"). The only + // court whose TENNIS palette is already darker than everything else's SPLATTER one — which is + // the whole idea: it starts where the others end up, and the rot then takes it further still. + // The lines need no new knob to be bright: paintBackdrop paints every line in UI.cream, so on a + // near-black surface they read as chalk under floodlights on their own. The ball is UI.ball and + // is bright for the same reason. It borrows the hard court's night-blue stands and boards rather + // than growing two more sheets — the stands are meant to be a wall of dark up there. + // `dim` is the one new knob: how far down the STAND is taken before the rot even starts (render's + // standDim). Without it the borrowed hard-court crowd sits floodlit and blue above a near-black + // court and the whole picture stops being a night. Nothing else in COURTS carries it, so it is 0 + // everywhere else and reads as absent. + { id: 'gloom', name: 'THE NIGHT COURT', sub: 'AFTER DARK · TRUE BOUNCE, NO LIGHT', tile: 'court_gloom', + ads: 'x_ads_h', crowd: 'x_crowd_h', stripes: false, line: '#dff6ff', border: '#c8dbe6', dim: 0.62, + bounceY: 0.58, bounceXZ: 0.80, + tennis: { stands: '#0a0d15', tierA: '#0f131e', tierB: '#0c1019', board: '#131a2b', + out: '#0f1a15', lawn: '#16261e', stripe: '#16261e' }, + splatter: { stands: '#06050b', tierA: '#0b0813', tierB: '#08060f', board: '#2c0e0e', + out: '#0a120d', lawn: '#101c15', stripe: '#101c15' } }, ]; // The tour, picked on the same screen as the court. Only the two TENNIS PLAYERS and the turned CPU @@ -692,11 +717,15 @@ export const DIFFS = [ // across the whole round instead of being spent in game one. Blood carries between rounds; the // trainer's heal-per-game is what makes the distance survivable. Deathmatch (id 'zombie') stays // beside it as the arcade run. +// The bands widened with the ladder (12 rungs -> 18, 2026-08-15): the shape is unchanged — the +// bottom third opens, the middle third is the quarter, everything up to management is the semi, +// and the final is the honours board — but every round now draws from a deeper hat, which is +// exactly what the bracket screen's shuffle is showing you. export const CUP_ROUNDS = [ - { name: 'ROUND ONE', band: [1, 2, 3], need: 1 }, - { name: 'QUARTER-FINAL', band: [4, 5, 6], need: 2 }, - { name: 'SEMI-FINAL', band: [7, 8, 9, 10, 11], need: 2 }, - { name: 'THE FINAL', band: [12], need: 2 }, + { name: 'ROUND ONE', band: [1, 2, 3, 4, 5], need: 1 }, + { name: 'QUARTER-FINAL', band: [6, 7, 8, 9, 10], need: 2 }, + { name: 'SEMI-FINAL', band: [11, 12, 13, 14, 15, 16, 17], need: 2 }, + { name: 'THE FINAL', band: [18], need: 2 }, ]; export const TOURS = [ @@ -826,41 +855,62 @@ export const bossArt = ch => ch % 10 === 0 ? BOSS_ART[BOSS_ART.length - 1] // hierarchy the thing across the net used to be. That is the narrative, and the difficulty ramp is // the same list read as numbers — every dial climbs monotonically, so "start easy and get harder" // is a property of the table rather than something the code has to arrange. -// 1-2 the ones who fetch and sweep. Slow, wild, forgiving. -// 3-4 the ones who keep order. They start covering ground. -// 5 the entertainment. The costume still fits. -// 6-8 the ones who cover and judge. Width arrives: they begin painting lines. -// 9-10 the ones who actually played. The club pro's own body, and the brute behind him. -// 11-12 management, and then the man whose name is on the honours board. +// 1-3 the ones who fetch, sweep and watch. Slow, wild, forgiving. +// 4-6 the ground staff and the ones who keep order. They start covering ground. +// 7-8 the entertainment and the legs. +// 9-12 the trades and the officials. Width arrives: they begin painting lines. +// 13-16 the ones who actually worked the players, and then played themselves. +// 17-18 management, and then the man whose name is on the honours board. +// EIGHTEEN RUNGS since 2026-08-15 (Oliver: expand the roster, "the champion can then be even a +// little bit harder to beat"). The six new ones are interleaved rather than stacked on the end — +// a ladder is a hierarchy, not an arrival order, so the spectator belongs at the bottom with the +// ball boy and the head coach belongs up beside the club pro. Every new rung wears a family rolled +// off the body it already has in the gore mode, same rule as the twelve before it. const OPPONENTS = [ { short: 'SHAMBLER', name: 'THE SHAMBLER', fam: 'sh', line: 'HE WAS HERE BEFORE ANY OF THIS.', speed: 0.72, pace: 0.72, wide: 0.75, flub: 1.35 }, { short: 'BALL BOY', name: 'THE BALL BOY', fam: 'bb', - line: 'HE STILL FETCHES. HE JUST BITES NOW.', speed: 0.82, pace: 0.8, wide: 0.82, flub: 1.2 }, + line: 'HE STILL FETCHES. HE JUST BITES NOW.', speed: 0.80, pace: 0.78, wide: 0.82, flub: 1.25 }, + { short: 'PUNTER', name: 'THE SPECTATOR', fam: 'sp', + line: 'HE PAID FOR THE WHOLE SEAT.', speed: 0.86, pace: 0.82, wide: 0.86, flub: 1.18 }, { short: 'KEEPER', name: 'THE GROUNDSKEEPER', fam: 'gk', line: 'HE MOWED THIS LAWN FOR THIRTY YEARS.', speed: 0.9, pace: 0.88, wide: 0.9, flub: 1.05 }, + // 5: the heavy of the ground staff. Slow legs, but everything he touches comes back flat and hard + { short: 'ROLLER', name: 'THE ROLLER MAN', fam: 'rm', h: 1.95, + line: 'HE FLATTENED THIS COURT. THEN THE REST.', speed: 0.88, pace: 0.98, wide: 0.92, flub: 1.0 }, { short: 'STEWARD', name: 'THE STEWARD', fam: 'st', - line: 'HE IS ASKING YOU TO LEAVE.', speed: 0.98, pace: 0.95, wide: 1.0 }, - // 5-6: the entertainment, and then width arrives with the ones who judge + line: 'HE IS ASKING YOU TO LEAVE.', speed: 0.98, pace: 0.95, wide: 1.0, flub: 0.98 }, + // 7-8: the entertainment, and then the legs { short: 'MASCOT', name: 'THE MASCOT', fam: 'ma', - line: 'THE CROWD STILL LOVES HIM.', speed: 1.02, pace: 0.98, wide: 1.05, flub: 1.0 }, + line: 'THE CROWD STILL LOVES HIM.', speed: 1.02, pace: 0.98, wide: 1.05, flub: 0.95 }, { short: 'RUNNER', name: 'THE RUNNER', fam: 'ru', - line: 'HE COVERS THE COURT. ALL OF IT.', speed: 1.22, pace: 1.0, wide: 1.05, flub: 0.95 }, + line: 'HE COVERS THE COURT. ALL OF IT.', speed: 1.22, pace: 1.0, wide: 1.05, flub: 0.92 }, + // 9-12: the trades and the officials — width arrives here + { short: 'SELLER', name: 'THE STRAWBERRY SELLER', fam: 'sb', + line: 'CREAM WITH THAT?', speed: 1.04, pace: 1.06, wide: 1.10, flub: 0.90 }, + { short: 'STRINGER', name: 'THE STRINGER', fam: 'sg', + line: 'HE STRUNG YOUR RACKET. HE KNOWS ITS LIMIT.', speed: 1.06, pace: 1.12, wide: 1.14, flub: 0.86 }, { short: 'JUDGE', name: 'THE LINE JUDGE', fam: 'lj', - line: 'EVERY BALL OF YOURS IS OUT.', speed: 1.05, pace: 1.05, wide: 1.28, flub: 0.85 }, + line: 'EVERY BALL OF YOURS IS OUT.', speed: 1.05, pace: 1.08, wide: 1.28, flub: 0.84 }, + { short: 'UMPIRE', name: 'THE CHAIR UMPIRE', fam: 'cu', + line: 'HIS CALL WAS ALWAYS FINAL.', speed: 1.08, pace: 1.14, wide: 1.26, flub: 0.80 }, { short: 'PHYSIO', name: 'THE PHYSIO', fam: 'ph', - line: 'HE KNOWS EXACTLY WHERE IT HURTS.', speed: 1.1, pace: 1.1, wide: 1.22, flub: 0.82 }, + line: 'HE KNOWS EXACTLY WHERE IT HURTS.', speed: 1.12, pace: 1.16, wide: 1.24, flub: 0.78 }, + { short: 'COACH', name: 'THE HEAD COACH', fam: 'hc', + line: 'HE TAUGHT YOU EVERY SHOT YOU HAVE.', speed: 1.20, pace: 1.20, wide: 1.22, flub: 0.72 }, { short: 'CLUB PRO', name: 'THE CLUB PRO', - line: 'HE GAVE LESSONS ON THIS COURT.', speed: 1.12, pace: 1.15, wide: 1.15, flub: 0.8 }, + line: 'HE GAVE LESSONS ON THIS COURT.', speed: 1.16, pace: 1.22, wide: 1.20, flub: 0.70 }, { short: 'THE BULK', name: 'THE BULK', fam: 'br', h: 2.0, - line: 'NOTHING GETS PAST HIM TWICE.', speed: 1.0, pace: 1.3, wide: 1.2, flub: 0.75 }, + line: 'NOTHING GETS PAST HIM TWICE.', speed: 1.02, pace: 1.34, wide: 1.24, flub: 0.66 }, { short: 'DIRECTOR', name: 'THE DIRECTOR', fam: 'dr', - line: 'HE CAN HAVE YOU REMOVED.', speed: 1.22, pace: 1.22, wide: 1.25, flub: 0.65 }, - // 12: the man whose name is on the honours board. `h` is WORLD height (zombieH reads it, so the + line: 'HE CAN HAVE YOU REMOVED.', speed: 1.26, pace: 1.28, wide: 1.28, flub: 0.60 }, + // 18: the man whose name is on the honours board. `h` is WORLD height (zombieH reads it, so the // serve aims at the body actually drawn): four family rolls could not make the art itself bulky // against the pose sheet — see _famroll.py's pair-reference note — so his size is stature. + // Six new rungs stand between him and the start now, so he took the step up Oliver asked for: + // he reads the court faster, hits harder, paints wider, and misses half as often as he did. { short: 'CHAMPION', name: 'THE CHAMPION', fam: 'ch', h: 2.1, - line: 'HE RETURNS EVERYTHING.', speed: 1.35, pace: 1.3, wide: 1.3, flub: 0.5 }, + line: 'HE RETURNS EVERYTHING.', speed: 1.45, pace: 1.42, wide: 1.36, flub: 0.38 }, ]; // Who opponent N is. This used to reuse the chapter roster's cycling (bossArt) so the two lists @@ -872,6 +922,10 @@ export const matchFoe = n => { const i = (n - 1) % OPPONENTS.length; return { key: 'L' + i, fam: 'r', ...OPPONENTS[i] }; }; +// How many rungs there ARE, for everything that has to know the shape of the ladder rather than +// one rung of it: the ramp test, and the bracket screen's shuffle. OPPONENTS stays private — +// exporting the length is the whole of what anyone outside needs. +export const LADDER = OPPONENTS.length; // ---- THE SPRITE BENCH's stops (back room only, see beats.startBench). Each is either an ANIMATED // state or ONE NAMED FRAME, so frames the game cannot reach yet — the serve, the fall — can still be @@ -905,7 +959,8 @@ export const BENCH_STAGES = ['HEALTHY', 'ARM OFF', 'HEADLESS']; // that (15 to 48 away from z_br1, where a good frame scores under 8.5) and refuses them, which is // 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', 'sh', 'bb', 'st', 'ru', 'gk', 'lj', 'dr', 'ch', 'ph', 'ma']; +export const ZFAMS = ['r', 'br', 'sh', 'bb', 'st', 'ru', 'gk', 'lj', 'dr', 'ch', 'ph', 'ma', + 'hc', 'sg', 'sb', 'cu', 'sp', 'rm']; // HEAD CROPS for the opponent intro card — a straight source-pixel crop out of each family's own // healthy frame (z_1.png), so the card's icon is the body on court, never separate art. @@ -977,8 +1032,14 @@ for (let i = 0; i < BOSS_ART.length; i++) // the chapter bosses, named wher // Separate keys, not shared ones: the two are different fights with different bodies, they are // counted on different screens (the mode split in stats.js), and one row cannot mean both. The 'L' // prefix is kept as-is because it is written into saved run records — renaming it would orphan them. -for (let i = 0; i < BOSS_ART.length; i++) - ENEMIES['L' + i] = { name: BOSS_ART[i].name, spr: 'z_r1', sub: OPPONENTS[i].line }; +// ...and it reads OPPONENTS, not BOSS_ART. It used to take the NAME off the boss roster and the +// line off this one — which was the same answer only while the two lists were the same list. They +// were decoupled 2026-08-13 and the screen has been mislabelling rungs ever since (L4 printed THE +// PHYSIO over the mascot's line); at 18 rungs against 10 designs it would also have left the top +// six with no row at all. One list, its own names, its own bodies. +for (let i = 0; i < OPPONENTS.length; i++) + ENEMIES['L' + i] = { name: OPPONENTS[i].name, sub: OPPONENTS[i].line, + spr: 'z_' + (OPPONENTS[i].fam || 'r') + '1' }; // Which wound stage a body is in: 0 = unhurt, 1..n = the skins, spread evenly over its full health. // This is the rule the chapter boss already used, and it turns out to reproduce the brute's old diff --git a/src/gameplay.js b/src/gameplay.js index e73897d..a9c9ab3 100644 --- a/src/gameplay.js +++ b/src/gameplay.js @@ -545,6 +545,12 @@ export function advancePoint(side) { // shared scoring math; returns whether a g return false; } +// A DECIDED FLIGHT. Everywhere but netplay the ball stops dead where it always has; over the wire +// it keeps its physics and rolls to a natural halt, because the striker's phone is a whole court +// and a latency away from the call and freezing its ball mid-roll read as the game loop stopping +// (Oliver, 2026-08-15). `ball.scored` is what switches off every scoring consequence downstream. +const endFlight = () => { if (G.net) ball.scored = true; else ball.active = false; }; + export function pointTo(side, reason) { // exported for net.js: a remote verdict lands here // netplay: this phone judged the live flight — the verdict goes over the wire before it is // applied, so both scoreboards move on the same call. netJudging() is false while a REMOTE @@ -1005,6 +1011,7 @@ function matchOutOfReachFlight(rz, pred) { function onBounce() { ball.bounces++; pock(0.35, 500); + if (ball.scored) return; // a called ball still bounces and still sounds; it just cannot score twice // a deferred praise word that met no body lands with the ball — the DRAW was still perfect if (ball.praise) { popText(ball.x, 0.3, ball.z, ball.praise); ball.praise = null; } if (G.mode === 'splatter' && ball.bomb && ball.bounces === 1) { // a bomb that missed everyone still blows up @@ -1022,7 +1029,7 @@ function onBounce() { if (G.net && ball.lastHit === 'P') return; if (ball.bounces === 1) { const okSide = ball.lastHit === 'P' ? ball.z < COURT.netZ : ball.z > COURT.netZ; - if (!inCourt(ball.x, ball.z) || !okSide) { ball.active = false; resolveInterlude(other(ball.lastHit)); voice('out'); } + if (!inCourt(ball.x, ball.z) || !okSide) { endFlight(); resolveInterlude(other(ball.lastHit)); voice('out'); } // THE FALL BEATS THE BALL (Oliver: "the zombie falls after the ball is lost although we know // this earlier — it could play out as ball lost BECAUSE of the falling"). A winner that just // bounced in and is provably beyond the body's reach has already decided the point, so it @@ -1047,7 +1054,7 @@ function onBounce() { resolveInterlude('P'); G.matchEarly = false; } - } else if (ball.bounces === 2) { ball.active = false; resolveInterlude(ball.lastHit); } + } else if (ball.bounces === 2) { endFlight(); resolveInterlude(ball.lastHit); } return; } if (G.mode !== 'tennis') return; // no lines, no faults — the umpire has other concerns @@ -1059,16 +1066,16 @@ function onBounce() { if (ball.bounces === 1) { const wasServe = G.wasServe; G.wasServe = false; if (wasServe) { - if (!inServeBox(ball.x, ball.z)) { ball.active = false; fault(); } + if (!inServeBox(ball.x, ball.z)) { endFlight(); fault(); } return; } // must land on the side opposite the hitter const okSide = ball.lastHit === 'P' ? ball.z < COURT.netZ : ball.z > COURT.netZ; if (!inCourt(ball.x, ball.z) || !okSide) { - ball.active = false; pointTo(other(ball.lastHit), 'out'); + endFlight(); pointTo(other(ball.lastHit), 'out'); } } else if (ball.bounces === 2) { - ball.active = false; pointTo(ball.lastHit, null); + endFlight(); pointTo(ball.lastHit, null); } } @@ -1845,7 +1852,14 @@ export function update(dt) { // (predictLanding is exact), so a ball that will bounce in and is provably beyond the body's // reach resolves MID-FLIGHT — the collapse starts while the ball is still in the air. The // bounce-1 hook in onBounce stays as the fallback for anything too close to call up here. - if (G.match && !G.match.ending && !G.match.decided && G.phase === 'rally' + // !G.net for the same reason its bounce-1 sibling carries it, and MISSING IT WAS THE DEATHMATCH + // DESYNC ("both players stand on the same side and nobody can serve", cellular): this predicts + // the CPU's legs, and a human on another phone is not predictable. Worse, it made the STRIKER a + // judge of its own outbound flight — resolveInterlude sends the verdict — so on a slow link both + // phones scored the same rally, the two scoreboards drifted a point apart, and setupPoint then + // placed each P on its own deuce/ad side while each waited for the other to serve. The one rule + // is that the receiving phone judges; this was the one place still breaking it. + if (G.match && !G.net && !G.match.ending && !G.match.decided && G.phase === 'rally' && ball.active && ball.lastHit === 'P' && ball.vz < 0 && ball.z < COURT.netZ - 0.3 && matchFallWillPlay()) { const arz = zombies.find(z => z.variant === 'r'); @@ -1875,7 +1889,8 @@ export function update(dt) { onBounce(); if (ball.active && Math.abs(ball.vy) < 0.8) { // rolled out — dead ball ball.active = false; - if (G.mode === 'splatter' && G.interlude) { // a roller nobody returned: point to the hitter + if (ball.scored) { /* already called: this IS the natural halt it was left rolling for */ } + else if (G.mode === 'splatter' && G.interlude) { // a roller nobody returned: point to the hitter if (ball.lastHit === 'X') spentBall(); // spent on the boss's body — no point, re-serve the duel else resolveInterlude(ball.lastHit); } else if (G.mode === 'splatter') { // hand the server a fresh one, fast — they're coming @@ -1885,7 +1900,7 @@ export function update(dt) { } } // a racket zombie's return that gets past you (crosses your baseline) is a point for THEM - if (G.mode === 'splatter' && ball.active && ball.lastHit === 'O' && ball.z > COURT.L + 0.3) { + if (G.mode === 'splatter' && ball.active && !ball.scored && ball.lastHit === 'O' && ball.z > COURT.L + 0.3) { ball.active = false; if (G.interlude) resolveInterlude('O'); // sudden death: its passing shot ends the duel else { if (!G.horde && !G.clearing) splatPoint('O'); spentBall(); } // clearing = mop-up, no scoring either way diff --git a/src/main.js b/src/main.js index 6fd9918..c1f44fe 100644 --- a/src/main.js +++ b/src/main.js @@ -3,7 +3,7 @@ // Module map: config (tuning/constants) · state (mutable game state) · gameplay (logic/update) // render (camera/drawing) · input (keys/touch) · audio (sfx/umpire) · panel (T tuning UI) -import { TUNE, COURTS, TOURS, MODES, MENU, DIFFS, EATEN_RESTART, DONE_RESTART, WEAPONS, bossArt } from './config.js'; +import { TUNE, COURTS, TOURS, MODES, MENU, DIFFS, EATEN_RESTART, DONE_RESTART, WEAPONS, bossArt, CUP_ROUNDS, matchFoe } from './config.js'; import { P, O, ball, score, G, setupPoint, selectCourt, selectTour } from './state.js'; import { update, restartRun, keyPressed, solveShot, inServeBox, inCourt, forceZombies, splatPoint, deliverTin, selectWeapon, grantWeapon, showModal, dismissModal, flickTarget, dragServe, toggleTestMode } from './gameplay.js'; import { startDuel, resolveInterlude, startBossFight, endBossFight, startDrill, endDrill, startMatch, endMatch, startBench, endBench, FALL, benchFall, startNetVs } from './beats.js'; @@ -117,7 +117,8 @@ function netChip(label, join, death = false) { if (!code) return; } start('classic'); // the waiting court is classic either way; a death room swaps it on pairing - if (startNetVs()) netConnect(code, death); + // the host's court rides the room too (server.py) — the guest plays where the host plays + if (startNetVs()) netConnect(code, death, picked); }); return b; } @@ -219,6 +220,69 @@ for (const c of COURTS) { courtList.appendChild(b); } +// ---- THE DRAW (Oliver, 2026-08-15: "when starting the tournament there should be a view of the +// tournament hierarchy but only the finale is settled — the game against the champion... random +// browsing through the roster in the individual spots which begins fast, gets slower and then +// settles on a random character, so that every tournament is new"). +// +// THE SCREEN IS THE DRAW, NOT A TEASER. `cupPicks` is what walks on: startMatch takes it and +// cupDraw reads it instead of rolling its own, so the face you watched land in ROUND ONE is the +// body across the net. A screen that showed a different draw from the one being played would be +// worse than no screen — it would teach the player that the ceremony is a lie. +// +// The roll is one recursive setTimeout per row whose gap grows with p^2 — fast, then visibly +// slowing, then stopped. No rAF: the menu deliberately parks the game loop (see menuUp), and three +// staggered timers are the whole animation. +const cupRows = document.getElementById('cupRows'), cupGo = document.getElementById('cupGo'); +let cupPicks = null, cupSpins = []; +// the portrait: every family's own head crop (assets/_heads.py), the club pro's included — the +// same file the in-play walk-on card uses, so the draw and the court show the same face +const foeArt = f => `assets/z_${f.fam}head.png?v=${SPRV}`; + +function buildBracket() { + for (const t of cupSpins) clearTimeout(t); + cupSpins = []; + cupPicks = CUP_ROUNDS.map(r => r.band[Math.floor(Math.random() * r.band.length)]); + cupRows.innerHTML = ''; + cupGo.disabled = true; + document.getElementById('brState').textContent = 'DRAWING…'; + document.getElementById('brSum').innerHTML = + `ONLY THE FINAL WAS EVER SETTLED${ + (COURTS.find(c => c.id === picked) || COURTS[0]).name}`; + CUP_ROUNDS.forEach((r, i) => { + const fixed = r.band.length === 1; // the final: one rung, nothing to roll + const row = document.createElement('div'); + row.className = 'cupRow' + (fixed ? ' fin set' : ''); + row.innerHTML = `${r.name}${ + r.need === 1 ? 'ONE GAME' : r.need + ' GAMES'}`; + cupRows.appendChild(row); + const img = row.querySelector('img'), nm = row.querySelector('b'); + const show = n => { const f = matchFoe(n); img.src = foeArt(f); nm.textContent = f.name; }; + if (fixed) return show(cupPicks[i]); + const t0 = performance.now(), len = 1500 + i * 550; // 1.5 / 2.05 / 2.6 s — they land in order + (function roll() { + const p = Math.min(1, (performance.now() - t0) / len); + if (p >= 1) { + show(cupPicks[i]); + row.classList.add('set'); + if (cupRows.querySelectorAll('.set').length === CUP_ROUNDS.length) { + cupGo.disabled = false; + document.getElementById('brState').textContent = 'THE DRAW IS MADE'; + } + return; + } + show(r.band[Math.floor(Math.random() * r.band.length)]); + cupSpins.push(setTimeout(roll, 45 + 330 * p * p)); + })(); + }); +} +document.getElementById('brBack').addEventListener('click', () => { + for (const t of cupSpins) clearTimeout(t); // a screen you walked out of must stop spinning + cupSpins = []; + go('setup'); +}); +document.getElementById('cupGo').addEventListener('click', () => start('cup')); + // what the setup footer says you are about to play, and what it is worth beating function summary() { const t = TOURS.find(x => x.id === pickedTour) || TOURS[0]; @@ -267,7 +331,9 @@ function start(modeId = pickedMode) { // the difficulty rides on G like G.classic does, so a mid-run restart keeps the CPU you chose G.diff = G.classic ? (DIFFS.find(d => d.id === pickedDiff) || DIFFS[1]) : null; if (modeId === 'zombie') { intoSplatter(); startMatch(); } - if (modeId === 'cup') { intoSplatter(); startMatch(true); } + // ...and the cup carries the draw the bracket screen just made. Null (the #cup back door) still + // means "roll it yourself", which is what the mode did before the screen existed. + if (modeId === 'cup') { intoSplatter(); startMatch(true, cupPicks); } } let stPeriod = 'all', stMode = stats.MODE_GORE; // the stats screen opens on the mode you last PICKED to play — that is the one whose numbers you @@ -328,7 +394,12 @@ nbName.addEventListener('keydown', e => { if (e.key === 'Enter') document.getEle // SERVE UP dropped you into the gore mode whatever the setup screen said, and picking CLASSIC or // ZOMBIE TENNIS did nothing at all. A default parameter only applies to `undefined`, and a click // handler always passes something. -document.getElementById('cStart').addEventListener('click', () => start()); +// SERVE UP starts the run — except in the cup, where the draw has to be made first. That screen +// carries its own start button, so this stays the one place a run begins from the setup screen. +document.getElementById('cStart').addEventListener('click', () => { + if (pickedMode === 'cup') { buildBracket(); go('bracket'); return; } + start(); +}); for (const id of ['mBack', 'stBack', 'rkBack', 'scBack']) document.getElementById(id).addEventListener('click', () => go('splash')); // the wizard walks back the way it came: who-and-where returns to the mode's own second step when @@ -476,7 +547,9 @@ addEventListener('keydown', e => { if (stage !== 'setup') return; // stats and ranks are read-only: Escape is the whole interface const n = parseInt(e.key, 10); if (n >= 1 && n <= COURTS.length) courtList.children[n - 1].click(); - else if (e.key === 'Enter' || e.key === ' ') start(); + // through the BUTTON, not start() — the cup's draw screen sits between the two and a keyboard + // must not be the one way to walk past it + else if (e.key === 'Enter' || e.key === ' ') document.getElementById('cStart').click(); }); // A run the player walks out on is still a run. killPlayer banks a death; this banks everything @@ -590,11 +663,11 @@ if (location.hash === '#bench') setTimeout(() => { start(null); intoSplatter(); // #net hosts classic, #netd hosts a death match, #net-ABCD joins — the netplay doors from a URL if (location.hash === '#netd') setTimeout(() => { start('classic'); - if (startNetVs()) netConnect(null, true); + if (startNetVs()) netConnect(null, true, picked); }, 700); else if (location.hash.startsWith('#net')) setTimeout(() => { start('classic'); - if (startNetVs()) netConnect(location.hash.slice(5).toUpperCase() || null); + if (startNetVs()) netConnect(location.hash.slice(5).toUpperCase() || null, false, picked); }, 700); requestAnimationFrame(frame); diff --git a/src/net.js b/src/net.js index df0ba55..59a903e 100644 --- a/src/net.js +++ b/src/net.js @@ -18,7 +18,7 @@ // ponytail: no reconnect-and-resume. A dropped radio ends the match with a card; the recorded // plan calls reconnection "the bulk of the real work" and a prototype must not start there. import { COURT, UI } from './config.js'; -import { G, P, O, ball, score, setupPoint } from './state.js'; +import { G, P, O, ball, score, setupPoint, selectCourt, court } from './state.js'; import { pointTo, fault, showModal, tossBall } from './gameplay.js'; import { startMatch, matchReady, resolveInterlude, netMatchWon } from './beats.js'; // runtime-only cycle: beats sends verdicts back through this module import { zombies } from './zombies.js'; @@ -83,7 +83,7 @@ export function netApplyShot(m) { function netApplyPoint(m) { applying = true; // their verdict — apply it without judging or echoing - ball.active = false; + ball.scored = true; // ...and OUR ball keeps rolling to its own halt (see endFlight) const side = m.win === 'me' ? 'O' : 'P'; if (G.match) resolveInterlude(side); // death match: the interlude's plumbing owns the point else pointTo(side, m.reason); @@ -92,14 +92,36 @@ function netApplyPoint(m) { function netApplyFault() { applying = true; - ball.active = false; + ball.scored = true; // the fault is called; the serve still lands and rolls away fault(); // we are the server here: second serve arms exactly as ever applying = false; } -function netStart(side, death) { +// THE SCOREBOARDS RECONCILE, and the host is the tie-break. Every verdict is a message, and a +// message that never lands leaves the two sims a point apart forever — which is not a cosmetic +// drift: setupPoint reads score.pts for the deuce/ad side and score.server for who tosses, so one +// lost 'pt' put BOTH players on the same half of their own court, each waiting for the other to +// serve, with no way out (Oliver, over cellular). The gate is `pretoss` ONLY: that is the settled +// state between points, where nothing is mid-beat and any verdict still in flight has long landed. +// Mirrored like everything else here — their P is our O. +// ponytail: server + points only. score.games can drift too, but it costs a wrong number on a +// board rather than a court nobody can serve on; sync it if that ever actually happens. +function netSync(m) { + if (!G.net.ready || G.net.dead || G.phase !== 'pretoss' || !m.p) return; + const srv = m.srv === 'P' ? 'O' : 'P', pts = [m.p[1], m.p[0]]; + if (score.server === srv && score.pts[0] === pts[0] && score.pts[1] === pts[1]) return; + score.server = srv; score.pts = pts; + if (G.match) matchReady(); else setupPoint(); +} + +function netStart(side, death, courtId) { G.net.ready = true; G.net.side = side; G.modal = null; G.modalQ.length = 0; // the waiting card has done its job + // THE HOST'S COURT RIDES THE ROOM, exactly as the death-match pick does — two phones playing the + // same rally on two different surfaces disagree about the bounce as well as the picture, and the + // guest never chose. Safe mid-run: selectCourt copies the bounce pair into TUNE and the backdrop + // cache keys on court.id, so the next frame repaints. + if (courtId && courtId !== court.id) selectCourt(courtId); if (death && !G.match) { // THE HOST PICKED DEATH MATCH, and the start message carries it to both phones — the joiner // (and the host itself) has been waiting on an ordinary classic court, so the court is rebuilt @@ -145,7 +167,7 @@ export function netMsg(m) { showModal({ title: m.code, color: UI.pick, body: ['THE ROOM CODE — TELL YOUR OPPONENT', 'WAITING FOR THEM TO JOIN'] }); } - else if (m.t === 'start') netStart(m.side, m.death); + else if (m.t === 'start') netStart(m.side, m.death, m.court); else if (m.t === 'gone') netDead('NO SUCH ROOM — RELOAD AND HOST ONE'); else if (m.t === 'bye') netDead('YOUR OPPONENT LEFT — RELOAD TO PLAY AGAIN'); else if (m.t === 'toss') netApplyToss(); @@ -153,7 +175,7 @@ export function netMsg(m) { else if (m.t === 'pt') netApplyPoint(m); else if (m.t === 'fault') netApplyFault(); else if (m.t === 'dead') netMatchWon(); // their blood ran out: this side's win ending - else if (m.t === 'pos') G.net.opp = m; + else if (m.t === 'pos') { G.net.opp = m; if (m.srv) netSync(m); } } // ticked from update() while G.net: the position stream out, the O sprite in @@ -162,7 +184,10 @@ export function netTick(dt) { if (!n || !n.ready) return; if ((posT += dt) >= 0.1) { posT = 0; - send({ t: 'pos', x: r3(P.x), z: r3(P.z), m: P.pose === 'run' ? 1 : 0 }); + // the HOST's stream carries the scoreboard as well (netSync) — one authority, no new message + // type, and it only rides the frames where the guest can actually act on it + send({ t: 'pos', x: r3(P.x), z: r3(P.z), m: P.pose === 'run' ? 1 : 0, + ...(n.side === 'A' && G.phase === 'pretoss' ? { srv: score.server, p: score.pts } : {}) }); } const o = n.opp; if (o) { @@ -175,9 +200,12 @@ export function netTick(dt) { } } -export function netConnect(code, death = false) { +export function netConnect(code, death = false, courtId = null) { + // joining carries only the code — the host's room already knows the mode and the court + const q = code ? ['room=' + encodeURIComponent(code)] + : [death ? 'death=1' : '', courtId ? 'court=' + encodeURIComponent(courtId) : ''].filter(Boolean); const url = (location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host - + '/ws' + (code ? '?room=' + encodeURIComponent(code) : death ? '?death=1' : ''); + + '/ws' + (q.length ? '?' + q.join('&') : ''); ws = new WebSocket(url); ws.onmessage = e => { let m; try { m = JSON.parse(e.data); } catch { return; } netMsg(m); }; ws.onclose = () => netDead('THE LINE DROPPED — RELOAD TO PLAY AGAIN'); diff --git a/src/render.js b/src/render.js index 51881a9..9745b2b 100644 --- a/src/render.js +++ b/src/render.js @@ -221,7 +221,7 @@ function farB(z) { // Asset cache-buster — bump with main.js?v=N (see the img.src note below). EXPORTED because the // menu screens hang tags on the same files, and two copies of this number is one copy too // many: the one that does not get bumped is the one that serves a cached 404. -export const SPRV = 125; +export const SPRV = 129; // The boot bar in main.js watches this. Nothing here CHANGES when things load — every sprite was // already requested at import — it just makes the wait visible and holds the menu back until the // last sheet has landed. See the onerror note below for the other half of "sometimes missing". @@ -273,9 +273,10 @@ for (const n of ['p_idle1','p_idle2','p_bounce','p_bounce2','p_toss','p_smash',' // filenames typed out here (each of which is a chance to typo one and have a body // vanish for a frame — the oldest bug in this file). ...ZFAMS.flatMap(famFrames), - // the card's 4K-cut head icons (assets/_heads.py). 'r' has no fam sheet — his - // card keeps the HEAD_CROP fallback, so no file and no 404 for him. - ...ZFAMS.filter(k => k !== 'r').map(k => 'z_' + k + 'head'), + // the card's head icons (assets/_heads.py), 'r' included since 2026-08-15: the + // club pro's is cut from z_c1, the turned opponent, who IS him. The HEAD_CROP + // fallback below him stays as the net for a family that has no head file yet. + ...ZFAMS.map(k => 'z_' + k + 'head'), 'w_ball','w_bomb','w_triple','w_fire','w_gun', // weapon-rack icons 'w_beacon','w_laser','w_rocket','w_flamer', // ...and the four that come out of a barrel 'z_barrel1','z_barrel2', // the weapon barrel, two frames so it ROLLS @@ -348,6 +349,50 @@ function buildCrowd() { }); } +// CAMERA FLASHES IN THE STAND (Oliver, 2026-08-15: "random sporadic subtle flashes like someone is +// photographing, slightly increasing during applause"). AN OVERLAY, never backdrop content: the +// backdrop is one cached blit and anything that moves in it would repaint the whole scene every +// frame (see drawBackdrop's key). Clocked off G.idleT rather than dt for the same reason the clap +// ripple is — this function has no dt, and idleT is the one clock that never stops. +// Each flash is a two-frame pop: a hot core, a soft bloom, gone in ~TUNE.flashLife. Rate doubles +// while the crowd is on its feet, which is the "slightly increase during applause" — a stand that +// went white during a point would be a different, much louder feature. +const flashes = []; // {x, y, r, t0} +let flashNext = 0; +function tickFlashes(standsBottom) { + const now = G.idleT, top = Math.max(4, standsBottom - AD_H - 4); + if (now >= flashNext) { + // the gap is exponential-ish so they never look metronomic; cheer halves the mean wait + const mean = TUNE.flashEvery / (crowd.cheer > 0 ? 2.2 : 1); + flashNext = now + mean * (0.35 + Math.random() * 1.6); + flashes.push({ x: 6 + Math.random() * (W - 12), y: 6 + Math.random() * (top - 6), + // small. The first cut was r 2.2-4.2 and its bloom drew a 6 px disc that read as + // a LAMP in the stand rather than a shutter going off half a second ago + r: 0.9 + Math.random() * 1.1, t0: now }); + if (flashes.length > 12) flashes.shift(); + } + for (let i = flashes.length - 1; i >= 0; i--) { + const f = flashes[i], a = 1 - (now - f.t0) / TUNE.flashLife; + if (a <= 0) { flashes.splice(i, 1); continue; } + ctx.save(); + ctx.globalCompositeOperation = 'lighter'; + ctx.globalAlpha = a * a * 0.30; ctx.fillStyle = '#8fa6d8'; // the bloom, cold and dim + ctx.beginPath(); ctx.arc(f.x, f.y, f.r * 2.4, 0, 7); ctx.fill(); + ctx.globalAlpha = Math.min(1, a * 1.5); ctx.fillStyle = '#fdfdff'; // the bulb itself + ctx.beginPath(); ctx.arc(f.x, f.y, f.r * 0.8, 0, 7); ctx.fill(); + ctx.restore(); + } + ctx.globalAlpha = 1; +} + +// How dark the STAND is: the court's own night (`dim` on its COURTS row — 0 everywhere but the +// night court) plus the rot the splatter palette brings with it. ONE function because it is applied +// in two places and they must agree exactly — the cached sheet and the live clap columns drawn over +// it. Two copies of this number is a stand whose clapping columns pop brighter than the rest of it, +// which is the same class of bug as the crowd frames drifting apart. Capped: past ~0.85 the stand +// is a black rectangle and the applause stops reading at all. +const standDim = pt => Math.min(0.85, (court.dim || 0) + 0.26 * pt); + // ---------------------------------------------------------------- backdrop ---- // THE STATIC SCENE, PRE-COMPOSED. Stands, ads, rot tint, out area, court, lines and the run-off // apron are pixel-identical frame to frame except while a palette lerps (the 6 s transition, the @@ -389,7 +434,8 @@ function paintBackdrop(c, pal, pt) { // the rot dims the stadium: one tint over crowd + boards, fading home during duels. 0.26, not // 0.5: on an already-dark crowd sheet 0.5 buried the stands; 0.26 still reads as "the lights // went wrong". - if (pt > 0) { c.fillStyle = `rgba(12,8,20,${0.26 * pt})`; c.fillRect(0, 0, W, standsBottom); } + const dim = standDim(pt); + if (dim > 0) { c.fillStyle = `rgba(12,8,20,${dim})`; c.fillRect(0, 0, W, standsBottom); } // ground + court (Super Tennis lawn); everything outside the doubles court is OUT const dw = COURT.dblW / 2; quadXZ(-14, -6, 14, 30, pal.out); // out area. NEVER let z approach @@ -460,7 +506,7 @@ function drawBackdrop(pal, pt) { if (crowd.cheer > 0 && crowdImg && clapImg) { const crowdH = standsBottom - AD_H + 2; const cols = 16, sw = clapImg.width / cols, cw = W / cols; - const tint = pt > 0 ? `rgba(12,8,20,${0.26 * pt})` : null; + const d = standDim(pt), tint = d > 0 ? `rgba(12,8,20,${d})` : null; ctx.save(); ctx.beginPath(); ctx.rect(0, 0, W, standsBottom - AD_H); ctx.clip(); for (let i = 0; i < cols; i++) { if (!(Math.floor(G.idleT * 9 + i * 0.7) % 2)) continue; @@ -469,6 +515,7 @@ function drawBackdrop(pal, pt) { } ctx.restore(); } + tickFlashes(standsBottom); // ...and somebody in there is taking pictures return standsBottom; } diff --git a/src/state.js b/src/state.js index 989cfdb..1984e6d 100644 --- a/src/state.js +++ b/src/state.js @@ -8,6 +8,12 @@ export const P = { x: 1.3, z: 22.8, side: 'P', pose: 'idle', poseT: 0, swingT: - export const O = { x: -1.3, z: 1.0, side: 'O', pose: 'idle', poseT: 0, swingT: -1, facing: 1, runT: 0 }; export const ball = { x: 0, y: 1, z: 20, vx: 0, vy: 0, vz: 0, g: 9.2, active: false, lastHit: 'P', bounces: 0, collected: false, kills: 0, bomb: false, flame: false, + // NETPLAY: THE CALLED BALL PLAYS OUT. A verdict used to freeze the ball where it was — on the + // striker's phone that is mid-roll, a whole court away from the call, and it read as the game + // loop stopping. `scored` means "this flight is decided": physics keeps running, every scoring + // consequence below is switched off, and the ball dies where a real one would, by rolling to a + // stop. Cleared by setupPoint with everything else that arms a point. + scored: false, praise: null, // a perfect serve ARMS its praise word here; it pops at the impact, not the release // which weapon launched the ball in flight — the stats screen files a kill under it long after // the WEAPONS row itself is out of scope. Lives on the ball so PRISTINE resets it for free. @@ -171,7 +177,7 @@ export const other = s => s === 'P' ? 'O' : 'P'; export const deuceSide = () => (score.pts[0] + score.pts[1]) % 2 === 0; // even points → deuce (right) side export function setupPoint() { - G.phase = 'pretoss'; ball.active = false; ball.bounces = 0; + G.phase = 'pretoss'; ball.active = false; ball.bounces = 0; ball.scored = false; G.swingSnaps = null; G.rallyNear = null; G.swung = false; if (G.mode === 'splatter' && !G.match) { // always your serve, from wherever you're standing // (the MATCH falls through to the tennis branch below: diff --git a/test.js b/test.js index 4af4b3d..07b5d7b 100644 --- a/test.js +++ b/test.js @@ -82,10 +82,11 @@ globalThis.ResizeObserver = class { observe() {} disconnect() {} }; globalThis.getComputedStyle = () => ({ paddingTop: '0' }); // ---- game modules (real code) ---------------------------------------------- -const { G, P, O, score, ball, setupPoint } = await import('./src/state.js'); +const state = await import('./src/state.js'); +const { G, P, O, score, ball, setupPoint } = state; const { update, forceZombies, splatPoint, restartRun, flickTarget, keyPressed, classicGame, serveApexY, assistK, rallyFeel } = await import('./src/gameplay.js'); const { startDuel, resolveInterlude, startBossFight, endBossFight, startDrill: _startDrill, endDrill, startMatch: _startMatch, endMatch, FALL_LEN, startNetVs, endNetVs } = await import('./src/beats.js'); -const { mirror, netMsg, netTest } = await import('./src/net.js'); +const { mirror, netMsg, netTest, netTick } = await import('./src/net.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; }; @@ -695,9 +696,11 @@ test('the opponent card is a RAMP: it starts easy and only gets harder', () => { // "the players should start easy and get harder over time." That is a property of the table, not // of any code, so the table is what gets asserted — a row edited into the wrong slot is otherwise // invisible until someone plays ten opponents deep. - const { matchFoe, TUNE } = cfg; + const { matchFoe, TUNE, LADDER } = cfg; let lastSpeed = 0, lastWhiff = 1e9; - for (let n = 1; n <= 12; n++) { // twelve rungs since 2026-08-13 (champion caps 12) + // read off the table rather than pinned: the ladder went 12 -> 18 on 2026-08-15 and a hardcoded + // count here would simply have stopped testing the six new rungs without failing + for (let n = 1; n <= LADDER; n++) { const f = matchFoe(n); const speed = (TUNE.matchSpeedBase + (n - 1) * TUNE.matchSpeedPer) * (f.speed ?? 1); const whiff = TUNE.matchFlub * Math.pow(TUNE.matchFlubKeep, n - 1) * (f.flub ?? 1); @@ -713,11 +716,22 @@ test('the opponent card is a RAMP: it starts easy and only gets harder', () => { assert.ok(f.short.length <= 8, `${f.short} fits the board's 8-char slot`); } // ...and the opening must actually be gentle: opponent one is the softest thing on the card. - assert.ok((matchFoe(1).speed ?? 1) < (matchFoe(12).speed ?? 1) * 0.6, 'the shambler is no champion'); - // the four 2026-08-13 families wear their own bodies, and the big two carry real stature - for (const [n, fam] of [[5, 'ma'], [8, 'ph'], [11, 'dr'], [12, 'ch']]) + assert.ok((matchFoe(1).speed ?? 1) < (matchFoe(LADDER).speed ?? 1) * 0.6, 'the shambler is no champion'); + // every rung that has its own family wears it — the 2026-08-15 six included, whose whole point is + // that the rung and the thing that hunts you in the gore mode are the same person + for (const [n, fam] of [[3, 'sp'], [5, 'rm'], [7, 'ma'], [9, 'sb'], [10, 'sg'], [12, 'cu'], + [13, 'ph'], [14, 'hc'], [17, 'dr'], [18, 'ch']]) assert.equal(matchFoe(n).fam, fam, `rung ${n} wears its own family`); - assert.ok(matchFoe(12).h > 1.85, 'the champion stands taller than a stock body'); + // ...and every named family is one the preloader actually loads + for (let n = 1; n <= LADDER; n++) + assert.ok(cfg.ZFAMS.includes(matchFoe(n).fam), `rung ${n}'s family is registered in ZFAMS`); + assert.ok(matchFoe(LADDER).h > 1.85, 'the champion stands taller than a stock body'); + // the cup's final draws the top rung and nothing else: a band pointing past the ladder would + // wrap through matchFoe's modulo and quietly deal the shambler as THE CHAMPION + const fin = cfg.CUP_ROUNDS[cfg.CUP_ROUNDS.length - 1]; + assert.deepEqual(fin.band, [LADDER], 'the final is the top of the ladder'); + for (const r of cfg.CUP_ROUNDS) for (const rung of r.band) + assert.ok(rung >= 1 && rung <= LADDER, `${r.name} draws a rung that exists (${rung})`); }); test('the first opponent cannot paint lines: its returns are width-clamped', () => { @@ -863,6 +877,28 @@ test('THE TOURNAMENT: bands draw, bodies deepen, rounds hold their body, the cha assert.equal(G.match.felled, CUP_ROUNDS.length, 'four bodies felled, banked as a match run'); }); +test('THE DRAW: a bracket settled on screen is the bracket that is played', () => { + // The screen would be worse than nothing if it dealt a different tournament from the one it just + // showed you settling (main.buildBracket hands its rungs straight to startMatch). The guard is + // the other half: a rung that is not in the round's band falls back to an honest roll rather + // than dealing THE CHAMPION in round one. + const { CUP_ROUNDS, LADDER } = cfg; + const drawn = CUP_ROUNDS.map(r => r.band[r.band.length - 1]); + _startMatch(true, drawn); G.modal = null; G.modalQ.length = 0; + assert.equal(G.match.opp, drawn[0], 'round one walks on the body the screen landed on'); + assert.deepEqual(G.match.cup.draw, drawn, 'and the rest of the draw rides with the run'); + endMatch(); + // a nonsense draw is refused per round, not trusted + _startMatch(true, [LADDER, LADDER, LADDER, LADDER]); G.modal = null; G.modalQ.length = 0; + assert.ok(CUP_ROUNDS[0].band.includes(G.match.opp), + 'a rung round one cannot contain is rolled instead of dealt'); + endMatch(); + // ...and no draw at all is the old behaviour, which the #cup back door still uses + _startMatch(true); G.modal = null; G.modalQ.length = 0; + assert.ok(CUP_ROUNDS[0].band.includes(G.match.opp), 'no draw: the round rolls its own'); + endMatch(); +}); + test('cup rallies never strand the latch: no-crossing points, the fall, and the mid-round game', () => { // Oliver's second lock: in the quarter-final, matchFallWillPlay still divided by 4 while // matchPoint divided by 4*need — it promised a fall on a point that crossed nothing, the @@ -1268,8 +1304,60 @@ test('netplay: a serve fault is the receiver\'s call, and re-arms the server\'s G.serveNum = 1; G.phase = 'rally'; ball.active = true; ball.lastHit = 'P'; G.wasServe = true; netMsg({ t: 'fault' }); assert.equal(G.serveNum, 2, 'their call re-armed the second serve'); - assert.ok(!ball.active, 'the dead serve is off the court'); + // THE CALLED BALL PLAYS OUT (2026-08-15). It used to be switched off where it stood — which on + // the striker's phone is a whole court and a latency away from the call, and read as the game + // loop stopping. It is `scored` (nothing it does can move the scoreboard again) and still LIVE. + assert.ok(ball.scored, 'the call is made: this flight can never score again'); + assert.ok(ball.active, '...but the ball keeps rolling instead of freezing where the call caught it'); assert.ok(!netTest.sent.some(m => m.t === 'fault'), 'applying a fault does not echo one back'); + const pts = score.pts.slice(), sn = G.serveNum; + for (let i = 0; i < 600 && ball.active; i++) update(DT); + assert.ok(!ball.active, 'and it comes to a natural halt by itself'); + assert.deepEqual(score.pts, pts, 'the roll scored nothing on the way'); + assert.equal(G.serveNum, sn, '...and faulted nothing either'); +}); + +test('netplay: the host\'s court rides the room, so both phones play the same surface', () => { + const { court, selectCourt } = state; + selectCourt('grass'); + freshNet('B'); // ...and the start message says otherwise + netMsg({ t: 'start', side: 'B', court: 'clay' }); + assert.equal(state.court.id, 'clay', 'the guest is moved onto the host\'s court'); + assert.equal(TUNE.bounceXZ, cfg.COURTS.find(c => c.id === 'clay').bounceXZ, + '...and onto its physics, not just its picture'); + netMsg({ t: 'start', side: 'B' }); // an old build sends no court: keep what we have + assert.equal(state.court.id, 'clay', 'a start without a court changes nothing'); + selectCourt('grass'); +}); + +test('netplay: the scoreboards reconcile, so one lost verdict cannot lock the court', () => { + // THE BUG (Oliver, over cellular): "both players stand on the same side and nobody can serve." + // setupPoint reads score.pts for the deuce/ad side and score.server for who tosses, so two sims + // a point apart place both players on the same half and each waits for the other. The host's + // pretoss stream carries its scoreboard; the guest adopts it, mirrored. + freshNet('B'); // we are the guest: the host is the authority + score.pts = [2, 1]; score.server = 'P'; // ...and we have drifted a point away from it + const myX = P.x; + netMsg({ t: 'pos', x: 0, z: 1, m: 0, srv: 'P', p: [2, 3] }); // their P serves, their 2 to our 3 + assert.equal(score.server, 'O', 'the server is taken from the host, mirrored'); + assert.deepEqual(score.pts, [3, 2], '...and so are the points'); + assert.notEqual(P.x, myX, 'the point is re-armed, so both bodies stand where the new score puts them'); + // an ordinary position frame carries no scoreboard and must never touch one + score.pts = [1, 1]; + netMsg({ t: 'pos', x: 0, z: 1, m: 0 }); + assert.deepEqual(score.pts, [1, 1], 'a plain pos frame is picture only'); + // ...and a live rally is never reconciled underneath itself + G.phase = 'rally'; + netMsg({ t: 'pos', x: 0, z: 1, m: 0, srv: 'P', p: [3, 3] }); + assert.deepEqual(score.pts, [1, 1], 'mid-rally the scoreboard is left alone'); + // only the HOST publishes it: two authorities is the drift, not the cure + freshNet('B'); netTest.sent = []; G.phase = 'pretoss'; + netTick(0.2); + assert.ok(netTest.sent.some(m => m.t === 'pos'), 'the guest still streams its position'); + assert.ok(!netTest.sent.some(m => m.srv), '...but never a scoreboard'); + freshNet('A'); netTest.sent = []; G.phase = 'pretoss'; + netTick(0.2); + assert.ok(netTest.sent.some(m => m.t === 'pos' && m.srv), 'the host publishes its scoreboard'); }); // ---- NETPLAY DEATH MATCH: the host's pick rides the room's start message, and the match