diff --git a/PROJECT.md b/PROJECT.md index e685cff..fc5827c 100644 --- a/PROJECT.md +++ b/PROJECT.md @@ -456,19 +456,23 @@ creating the room could decide whether it is classic tennis or death match.. in wounded opponent would show increased dynamic bleeding before exploding."* The VS second step is three chips now (HOST — CLASSIC TENNIS / HOST — DEATH MATCH / JOIN WITH A CODE); the host's pick rides the room (`?death=1` at creation, carried in the relay's start message), so the joiner never -answers a question. On pairing BOTH phones rebuild the court into the zombie-tennis match — the -same verdicts drive `resolveInterlude`/`matchPoint` instead of `pointTo`, so wounds, blood, the -fall, the mop beat and the burst all come along unmodified. On each phone YOUR OPPONENT IS THE -ZOMBIE: their lost points tear its wound stages (your win = their body comes apart), their won -points draw your blood, and bleeding out sends `dead` — the other phone banks a NOT-dead match run -and holds the win screen. Wired details: the same friend walks back on after each burst (`d.opp` -frozen — the roster climb would hand them a new face; returns get an IT RISES AGAIN flash, not a -repeated name card), racketAI and the early-decide are gated off (`!G.net` — you cannot predict a -human's legs), match balls already passed through the body so no contact work was needed, and -`gushProne()` in zombies.js is the asked-for bleed-out: LOW anchors around the corpse during the -kill fall's prone hold (bleed() stays suppressed there — its standing-height anchors are the -"blood fountains midair" bug), `TUNE.killGush` attempts per frame until the burst. #netd hosts a -death room from a URL. 80 tests; the wound/blood/burst loop verified across two panes. +answers a question. On pairing BOTH phones rebuild the court into the match machinery — the same +verdicts drive `resolveInterlude`/`matchPoint` instead of `pointTo`, so blood, the mop beat and +the scoring come along unmodified. **THE OPPONENT IS THE FRIEND, NOT A ROSTER BODY** (his retest: +*"the opponent reads as zombie — it should be the normal opponent"*, and no SHAMBLER teaser +card): nothing spawns, the living O carries the whole picture, and since the human sprite has no +wound skins, **the score reads on him as BLOOD** — `bleedO()` in zombies.js opens an anchor per +point taken off him this game and thickens the flow, love → game point, thin drops throughout +(the settled rule: more emissions, never heavier drops). The game-winning point BURSTS him where +he stands (`explode` — the human has no fall art, and procedural gore is the sanctioned fake), +the mop beat runs over the gap, and IT RISES AGAIN returns him with the ramp reset. Their won +points draw your blood; bleeding out sends `dead` — the other phone banks a NOT-dead match run +and holds the win screen. Wired details: `d.opp` frozen (no roster climb — same friend every +game), racketAI / the early-decide / the empty-court-regicide check all gated `!G.net`, the heal +beat is a CARD in netplay (the modal froze this world while the other phone's kept moving), and +`gushProne()` gives the SOLO deathmatch's kill fall the same bleed-out from prone-height anchors +(bleed() stays suppressed there — standing anchors are the "blood fountains midair" bug). #netd +hosts a death room from a URL. 79 tests; the blood-ramp/burst/return loop verified across panes. ## Session log: the far court, without spending the lens (2026-08-13) diff --git a/index.html b/index.html index 04105ae..baa70d7 100644 --- a/index.html +++ b/index.html @@ -494,6 +494,6 @@ if (location.hostname !== 'splatter-tennis.wuwi.one') { - + diff --git a/src/beats.js b/src/beats.js index 84e064a..a3b0ed5 100644 --- a/src/beats.js +++ b/src/beats.js @@ -575,6 +575,17 @@ function tickMatchServe(dt) { } function matchOpponent() { + // NETPLAY: THE OPPONENT IS THE FRIEND, NOT A ROSTER BODY (Oliver: "it should be the normal + // opponent"). Nothing spawns, nobody is renamed, no walk-on card — the living O carries the + // whole picture and the score reads on it as BLOOD (bleedO in zombies.js), because the human + // sprite has no wound skins to climb. This beat only returns the body after a burst and + // re-arms the intro bar. + if (G.net) { + G.hideO = false; // the burst took him; the walk-on returns him + G.flash = { text: 'IT RISES AGAIN.', t: 1.8 }; + G.interlude = { introT: 1.6 }; + return; + } spawnRacketZombie(); const zb = zombies.find(z => z.variant === 'r'); zb.dueled = true; // wakes racketAI, same as a fresh duel @@ -587,15 +598,9 @@ function matchOpponent() { // 4.0 s: at 2.4 the head + name + punchline were gone before they were read ("the intro card // of the player should last longer"). In the cup the round names itself on the third line. const cup = G.match.cup; - // netplay: the same body keeps coming back, and the identical name card twice running reads as - // a stuck HUD (the praise-word lesson). The first walk-on introduces; every return is a flash. - if (G.net && G.match.introduced) G.flash = { text: 'IT RISES AGAIN.', t: 1.8 }; - else { - G.match.introduced = true; - G.card = { title: zb.foe.name, sub1: zb.foe.line, color: UI.hot, t: 4.0, head: zb.fam, - sub2: cup ? CUP_ROUNDS[cup.round].name + (CUP_ROUNDS[cup.round].need > 1 - ? ' · FIRST TO ' + CUP_ROUNDS[cup.round].need + ' GAMES' : '') : undefined }; - } + G.card = { title: zb.foe.name, sub1: zb.foe.line, color: UI.hot, t: 4.0, head: zb.fam, + sub2: cup ? CUP_ROUNDS[cup.round].name + (CUP_ROUNDS[cup.round].need > 1 + ? ' · FIRST TO ' + CUP_ROUNDS[cup.round].need + ' GAMES' : '') : undefined }; // a FLAT pool per body: with regicide gone the body is pure scoreboard (matchPoint takes a // game's-quarter per point), so a deeper pool would change nothing — the ladder escalates in // racketAI instead (matchSpeedPer / matchFlubKeep). THE CUP MULTIPLIES BY THE ROUND'S NEED and @@ -668,9 +673,14 @@ function matchPoint(side) { function matchHealBeat() { const healed = Math.min(G.hpMax, G.hp + TUNE.matchHeal) - G.hp; G.hp += healed; - if (healed > 0) - showModal({ title: 'WOUNDS TREATED', icon: 'x_cross', iconH: 52, color: UI.pick, - body: ['THE TRAINER PATCHED YOU UP', G.hp + ' OF ' + G.hpMax + ' BACK'] }); + if (healed > 0) { + // NETPLAY: a CARD, never the modal — a modal freezes THIS world while the other phone's keeps + // moving, and an unread popup would hold the opponent's next serve hostage (the popup policy's + // live-court rule, applied to a court that is live on someone else's screen). + if (G.net) G.card = { title: 'WOUNDS TREATED', sub1: G.hp + ' OF ' + G.hpMax + ' BACK', color: UI.pick, t: 3.0 }; + else showModal({ title: 'WOUNDS TREATED', icon: 'x_cross', iconH: 52, color: UI.pick, + body: ['THE TRAINER PATCHED YOU UP', G.hp + ' OF ' + G.hpMax + ' BACK'] }); + } } // The game-winning point fells the body but no longer bursts it on the same frame: the KILL FALL @@ -708,6 +718,10 @@ function matchGameWon(zb, banked = false) { // the game but never touches the server — that flip lives in tennis's own pointTo, which the // match bypasses (the same shape as the G.serveNum bug this file already carries a note about). score.server = other(score.server); + // NETPLAY: the human body has no fall art to play, so the game-winning point bursts him where + // he stands (procedural gore is the one sanctioned fake) and the mop beat runs over the gap; + // matchOpponent's net branch un-hides him when the beat hands the court back. + if (G.net) { explode(O.x, O.z); G.hideO = true; } // Everything past the burst waits for it — the heal card, the boy, the walk-on clock. d.afterFall = () => { matchHealBeat(); @@ -830,7 +844,9 @@ export function resolveInterlude(side) { // before it is applied, exactly as pointTo does for classic. netJudging() is false while a // remote verdict is being applied, which is what stops the echo. if (netJudging()) netSendPoint(side, null); - if (!zombies.some(z => z.variant === 'r') && !G.match.ending) return matchGameWon(null); + // the empty-court check reads "no body = regicide" — meaningless in netplay, where the court + // NEVER carries a body (the friend is the living O) and every point would read as a game + if (!G.net && !zombies.some(z => z.variant === 'r') && !G.match.ending) return matchGameWon(null); return matchPoint(side); } G.interlude = false; diff --git a/src/net.js b/src/net.js index a148f68..df0ba55 100644 --- a/src/net.js +++ b/src/net.js @@ -79,10 +79,6 @@ export function netApplyShot(m) { G.phase = 'rally'; G.swung = false; G.rallyNear = null; O.x = s.x; O.z = s.z; // the strike happened where the ball says it did O.pose = m.serve ? 'serve' : 'swing'; O.poseT = 0; - if (G.net.death) { // ...and in a death match the PICTURE is the zombie, not O - const rz = zombies.find(z => z.variant === 'r'); - if (rz) { rz.x = s.x; rz.z = s.z; if (m.serve) rz.serveHold = 0.4; } - } } function netApplyPoint(m) { @@ -117,7 +113,14 @@ function netStart(side, death) { G.classic = false; startMatch(); G.net = n; - G.modal = null; G.modalQ.length = 0; // the DEATH MATCH explainer must not eat the first serve + // THE OPPONENT IS THE FRIEND, NOT A ROSTER BODY: startMatch walked THE SHAMBLER on (spawn + + // name card) because it could not see the swap — take both back. The living O is the whole + // picture (render draws him whenever hideO is false, splatter included — the bench proved it), + // and the score reads on him as blood (bleedO), not as wound skins he does not have. + zombies.length = 0; + G.hideO = false; + G.card = null; + G.modal = null; G.modalQ.length = 0; // ...and the DEATH MATCH explainer must not eat the first serve } score.server = side === 'A' ? 'P' : 'O'; // the host serves first, on both scoreboards score.pts = [0, 0]; @@ -169,23 +172,6 @@ export function netTick(dt) { O.x += (tx - O.x) * k; O.z += (tz - O.z) * k; if (O.pose !== 'swing' && O.pose !== 'serve') O.pose = o.m ? 'run' : 'idle'; if (o.m) O.runT += dt; - // DEATH MATCH: the body on screen is the racket zombie; O is only the serve-machinery ghost it - // always is in a match. The zombie shadows O's driven position — stride bookkeeping like the - // bench's walk, so the frames step off ground covered — except while the fall or a named serve - // frame owns the picture. vx is recorded so a fall inherits the run, clamped against snaps. - if (n.death && !G.matchFall) { - const rz = zombies.find(z => z.variant === 'r'); - if (rz && !rz.showFrame) { - const dx = O.x - rz.x, step = Math.hypot(dx, O.z - rz.z); - if (step > 0.03) { - rz.dist = (rz.dist || 0) + step; - rz.vx = Math.max(-3, Math.min(3, dx / Math.max(dt, 1e-3))); - if (Math.abs(dx) > 0.02) rz.face = dx > 0 ? 1 : -1; - rz.pose = rz.pose === 'swing' ? 'swing' : 'run'; - } else { rz.pose = rz.pose === 'swing' ? 'swing' : 'idle'; rz.vx = 0; } - rz.x = O.x; rz.z = O.z; - } - } } } diff --git a/src/zombies.js b/src/zombies.js index 4c2255c..aad6c08 100644 --- a/src/zombies.js +++ b/src/zombies.js @@ -4,7 +4,7 @@ // (zombies <-> gameplay is a runtime-only import cycle, same pattern as input.) import { TUNE, UI, COURT, CONTACT, BOSS_ART, bossArt, WEAPONS, BARREL_WEAPONS, woundStage, zombieH, EATEN_BURST, EATEN_SUMMARY, EATEN_FEED_R } from './config.js'; -import { G, P, ball, score, sendBoy, setupPoint } from './state.js'; +import { G, P, O, ball, score, sendBoy, setupPoint } from './state.js'; import { splat, moan, pock, thud, zap, voice, boom, swish, shriek, aww, sfx , whoosh, flame } from './audio.js'; import { drillKill, drillCaught } from './drill.js'; import { splatPoint, cheer, spentBall, aside, grantWeapon, endBurst, sliceCarom } from './gameplay.js'; @@ -921,6 +921,24 @@ function gushProne(zb) { } } +// NETPLAY DEATH MATCH: the opponent is the LIVING O — a human sprite with no wound skins to climb +// (Oliver: "the normal opponent does not have wound states — this was the reason I said increased +// dynamic bleeding"). So the score reads on him as BLOOD: each point taken off him this game opens +// another anchor and thickens the flow, love → game point, and the burst (matchGameWon) resets it +// with the body. Same thin-drop discipline as bleed() — more EMISSIONS, never heavier drops. +const O_BLEED = [[0.12, 1.55, 0.9], [-0.22, 1.2, 0.8], [0.16, 0.9, 0.7]]; +function bleedO() { + const k = Math.min(3, score.pts[0]); // points off them THIS game + if (!k || G.hideO || parts.length > 120) return; + for (const w of O_BLEED.slice(0, k)) { + if (Math.random() > 0.14 * k) continue; // ramps: one light nick → gushing at game point + parts.push({ + x: O.x + w[0] + (Math.random() * 2 - 1) * 0.04, y: w[1] + (Math.random() * 2 - 1) * 0.04, z: O.z + (Math.random() * 2 - 1) * 0.04, + vx: (Math.random() * 2 - 1) * 0.35, vy: w[2] * (0.6 + Math.random() * 0.5), vz: (Math.random() * 2 - 1) * 0.3, + t: 0.3 + Math.random() * 0.3, dark: Math.random() < 0.55 }); + } +} + function bleed(zb) { if (parts.length > 90) return; let wounds; @@ -962,6 +980,7 @@ export function updateParts(dt) { export function updateZombies(dt) { moanT -= dt; if (moanT <= 0) { moan(); moanT = 2.5 + Math.random() * 4; } + if (G.net && G.net.death && G.match) bleedO(); // the friend's damage, read as blood (see bleedO) if (G.mode === 'splatter') { // transition spawns are scripted, not timed G.splatT += dt; diff --git a/test.js b/test.js index ca3279c..4af4b3d 100644 --- a/test.js +++ b/test.js @@ -1283,26 +1283,37 @@ function freshNetDeath(side) { netMsg({ t: 'start', side, death: true }); } -test('netplay death match: the host\'s pick rebuilds the court on pairing', () => { +test('netplay death match: the host\'s pick rebuilds the court on pairing — friend, not roster body', () => { freshNetDeath('B'); assert.ok(G.net && G.net.ready && G.net.death, 'the wire survives the mode swap'); assert.equal(G.mode, 'splatter'); assert.ok(G.match && G.interlude, 'the match machinery owns the court'); - assert.ok(zombies.some(z => z.variant === 'r'), 'the body is on court'); + assert.ok(!zombies.some(z => z.variant === 'r'), 'NO roster body — the friend IS the opponent'); + assert.equal(G.hideO, false, 'the living opponent is the picture'); + assert.equal(G.card, null, 'and no SHAMBLER walk-on card'); assert.equal(score.server, 'O', 'the host serves first here too'); assert.equal(G.modal, null, 'no popup may eat the first remote serve'); }); -test('netplay death match: their verdicts wound the body, mine cost blood — no echo', () => { +test('netplay death match: their lost points read as BLOOD, and the game bursts the body', () => { freshNetDeath('A'); - const rz = zombies.find(z => z.variant === 'r'); - const hp0 = rz.hp; netMsg({ t: 'pt', win: 'you' }); // their phone judged: my ball beat them - assert.ok(rz.hp < hp0, 'my point tears at the body'); + assert.equal(score.pts[0], 1, 'the point banks — bleedO rides this ramp'); assert.ok(!netTest.sent.some(m => m.t === 'pt'), 'applying a verdict does not echo one back'); const blood = G.hp; netMsg({ t: 'pt', win: 'me' }); // their phone judged: their ball beat me assert.equal(G.hp, blood - 1, 'their point draws my blood'); + score.pts = [3, 0]; // game point against them: the flow at full ramp + const before = parts.length; + for (let i = 0; i < 30; i++) update(DT); + assert.ok(parts.length > before, 'a deeply wounded friend bleeds dynamically (bleedO)'); + netMsg({ t: 'pt', win: 'you' }); // the game-winning point + assert.equal(score.games[0], 1, 'game banked'); + assert.deepEqual(score.pts, [0, 0], 'the blood ramp resets with the body'); + assert.equal(G.hideO, true, 'the burst took the body'); + for (let i = 0; i < 60 * 25 && G.hideO; i++) update(DT); + assert.equal(G.hideO, false, 'the mop beat hands the court back and the friend rises again'); + assert.equal(G.match.opp, 1, 'no roster climb — the same friend, every game'); }); test('netplay death match: the receiver judges — an untouched winner draws blood and crosses the wire', () => { @@ -1329,18 +1340,9 @@ test('netplay death match: bleeding out is said on the wire, and the other phone assert.equal(G.match.felled, 3, 'felled = the games that actually burst the body'); }); -test('netplay death match: the same friend walks back on — no roster climb', () => { - freshNetDeath('A'); - const opp0 = G.match.opp; - for (let i = 0; i < 4; i++) netMsg({ t: 'pt', win: 'you' }); // four points = the game = the burst - assert.equal(G.match.opp, opp0, 'the opponent number holds through the game win'); - for (let i = 0; i < 60 * 25 && !zombies.some(z => z.variant === 'r'); i++) update(DT); - assert.ok(zombies.some(z => z.variant === 'r'), 'the body returns after the mop beat'); - assert.equal(G.match.opp, opp0, 'as the same opponent, not the roster\'s next rung'); -}); - -test('death match: the game-winning body bleeds out on the ground before it bursts', () => { - freshNetDeath('A'); +test('death match (solo): the game-winning body bleeds out on the ground before it bursts', () => { + assert.ok(_startMatch(), 'solo match on a clean court'); + G.modal = null; G.modalQ.length = 0; const rz = zombies.find(z => z.variant === 'r'); G.matchFall = { t: 0.3, from: 1, vx: 0, x0: rz.x, kill: true }; // landed, prone, pre-burst const before = parts.length;