The host names the stakes: classic tennis, or the death match
Three chips under VS A FRIEND — the host's pick rides the room and the start message rebuilds both courts on pairing. In the death match your opponent IS the zombie: their lost points tear its wound stages, their won points draw your blood, and bleeding out crosses the wire as the other phone's win. The same receiver-is-judge verdicts drive the match machinery through resolveInterlude, so the fall, the mop beat and the burst come along unmodified. New: gushProne — the game-winning body bleeds out on the ground, from prone-height anchors, until it bursts. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
5e80c4e69d
commit
c0b59eee90
10 files changed
+241
-26
No files matched your search
+19
@@ -451,6 +451,25 @@ agree on the same point from opposite perspectives). 73 tests.
|
||||
- **Test on staging with two phones** — the relay deploys with the ordinary push, nothing to
|
||||
provision. Coolify's proxy passes WebSocket upgrades as-is.
|
||||
|
||||
**SAME DAY: THE HOST PICKS THE MODE, and DEATH MATCH is the second pick.** Oliver: *"the one
|
||||
creating the room could decide whether it is classic tennis or death match.. in deathmatch the
|
||||
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.
|
||||
|
||||
## Session log: the far court, without spending the lens (2026-08-13)
|
||||
|
||||
Oliver: the far-side opponent is barely visible in portrait, and the lens (camZ/camF, already at its
|
||||
|
||||
+1
-1
@@ -494,6 +494,6 @@ if (location.hostname !== 'splatter-tennis.wuwi.one') {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script type="module" src="src/main.js?v=254"></script>
|
||||
<script type="module" src="src/main.js?v=255"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -161,11 +161,15 @@ 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.
|
||||
me['death'] = 'death=1' in self.path
|
||||
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
|
||||
ws_send(me['peer'], '{"t":"start","side":"A"}')
|
||||
ws_send(me, '{"t":"start","side":"B"}')
|
||||
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)
|
||||
else:
|
||||
ws_send(me, json.dumps({'t': 'room', 'code': me['room']}))
|
||||
try:
|
||||
|
||||
+42
-7
@@ -14,7 +14,7 @@ import { voice, zombieDuck, aww } from './audio.js';
|
||||
import { zombies, pellets, extras, shots, spawnRacketZombie, spawnChapterBoss, spawnZombie, explode, killPlayer } from './zombies.js';
|
||||
import { cheer, aside, endBurst, deliverTin, showModal, logSwing, advancePoint, scoreKey } from './gameplay.js';
|
||||
import { startDrillStream } from './drill.js';
|
||||
import { netClose } from './net.js';
|
||||
import { netClose, netJudging, netSendPoint, netSendDead } from './net.js'; // runtime-only cycle: net calls back into startMatch/matchReady/netMatchWon
|
||||
|
||||
// the court is mopped up (or was already clear): play advances to whatever the won game earned —
|
||||
// a changeover breather, a plain even-game beat, or (on a set) the centered SET card then a horde.
|
||||
@@ -394,7 +394,7 @@ function cupDraw() {
|
||||
// ...and G.serveNum resets HERE, because the match bypasses tennis's pointTo (the only other place
|
||||
// that resets it). Without this, the first fault of the match armed 'second serve' PERMANENTLY:
|
||||
// every missed serve after it was a double fault, a point, and blood — the death loop Oliver hit.
|
||||
function matchReady() {
|
||||
export function matchReady() { // exported for net.js: a paired death match re-places both bodies
|
||||
ball.active = false;
|
||||
G.serveNum = 1;
|
||||
if (G.match) G.match.decided = false; // the next rally may be decided early again
|
||||
@@ -587,9 +587,15 @@ 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;
|
||||
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 };
|
||||
// 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 };
|
||||
}
|
||||
// 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
|
||||
@@ -635,7 +641,12 @@ function matchPoint(side) {
|
||||
// is still talking (one man, one microphone) — and matchPoint calls scoreKey() at 0.4 s with a
|
||||
// clip over a second long, so every one of these was being swallowed silently.
|
||||
aside(G.hp <= 2 ? 'ump_low_blood' : 'ump_wound_you2', 2.2);
|
||||
if (G.hp <= 0) return killPlayer(); // eaten — the run summary, as ever
|
||||
if (G.hp <= 0) {
|
||||
// netplay: my blood is bookkept only on MY phone, so my death has to be said out loud —
|
||||
// the other phone turns it into its win screen (netMatchWon)
|
||||
if (G.net && G.net.ready) netSendDead();
|
||||
return killPlayer(); // eaten — the run summary, as ever
|
||||
}
|
||||
}
|
||||
const over = advancePoint(side); // banks the game and resets the points itself
|
||||
if (over) return side === 'P' ? matchGameWon(zb, true) : matchGameLost();
|
||||
@@ -689,7 +700,10 @@ function matchGameWon(zb, banked = false) {
|
||||
// round and matchOpponent's plate landed on the same slot a frame later, so 'GAME.' never drew
|
||||
// once — and the plate is the better card anyway: it announces the win by replacing the loser.
|
||||
G.flash = { text: 'GAME.', t: 1.4 };
|
||||
d.opp++;
|
||||
// netplay: the SAME opponent walks back on — the body across the net is one particular friend,
|
||||
// and handing them the roster's next name and face would break the fiction (the ladder dials the
|
||||
// opp number drives are AI-only and dormant here anyway)
|
||||
if (!G.net) d.opp++;
|
||||
// ALTERNATING SERVICE, as the sport has it. The match calls advancePoint directly, which banks
|
||||
// the game but never touches the server — that flip lives in tennis's own pointTo, which the
|
||||
// match bypasses (the same shape as the G.serveNum bug this file already carries a note about).
|
||||
@@ -776,6 +790,23 @@ function cupWon() {
|
||||
G.done = { won: true, t: 0, cup: true };
|
||||
}
|
||||
|
||||
// NETPLAY'S WIN ENDING: the other phone said 'dead' — their player bled out, so this side is the
|
||||
// last one standing. The classic win's whole shape (held summary, gated tap), banked NOT-dead
|
||||
// through the same endRun as every match run; felled = the games that actually burst the body.
|
||||
export function netMatchWon() {
|
||||
if (!G.match) return;
|
||||
const d = G.match;
|
||||
d.ending = false; d.mopT = 0;
|
||||
G.interlude = false; G.matchFall = null;
|
||||
ball.active = false; G.flash = null; G.modal = null; G.card = null;
|
||||
cheer(4, 1); voice('unbelievable', 0.4);
|
||||
const games = score.games[0] + score.games[1];
|
||||
const b = endRun(score.games[0], games, G.splatT, false, MODE_MATCH);
|
||||
G.best = b; G.newBest = b.newBest;
|
||||
d.felled = score.games[0]; d.games = games; d.best = b.games;
|
||||
G.done = { won: true, t: 0 };
|
||||
}
|
||||
|
||||
function matchGameLost() {
|
||||
// Reachable only through deuce (four straight points is exactly your hp) — the body keeps its
|
||||
// wounds, the scoreboard banks their game, and the match simply plays on.
|
||||
@@ -795,6 +826,10 @@ export function resolveInterlude(side) {
|
||||
// for the picture still reaches its ordinary bounce-2 / rolled-out calls, and they land here.
|
||||
// G.matchEarly exempts the deciding resolution ITSELF — the latch goes up before it runs.
|
||||
if (G.match.decided && !G.matchEarly) { ball.active = false; return; }
|
||||
// netplay (death match): this phone judged the live flight — the verdict crosses the wire
|
||||
// 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);
|
||||
return matchPoint(side);
|
||||
}
|
||||
|
||||
@@ -498,6 +498,8 @@ export const TUNE = {
|
||||
matchWidePer: 0.25, // ...earning this much more width per opponent felled (softened with speedPer)
|
||||
matchHeal: 2, // hp back for a game won (the duel's healPerDuel rate)
|
||||
matchBoomHold: 0.5, // s the game-winning body lies prone before it bursts — the fall must be SEEN
|
||||
killGush: 3, // prone bleed-out during the kill fall: particles ATTEMPTED per frame (gushProne)
|
||||
// — the body visibly bleeds out on the ground before it bursts
|
||||
matchBoomCard: 0.6, // s after the burst before the WOUNDS TREATED card may land on top of it
|
||||
// The timing windows, DRAWN (the match's whole ask: "what is the possible tap window, what is the
|
||||
// sweet spot"). Numbers shared with the mechanics they visualise, so the picture cannot lie:
|
||||
|
||||
+7
-2
@@ -1017,6 +1017,9 @@ function onBounce() {
|
||||
// below read 'X' as HIS ball — bounce out and you were handed the duel, bounce twice in-court
|
||||
// and you LOST it for hitting him. Same rule as the dead-roller path in update().
|
||||
if (ball.lastHit === 'X') { spentBall(); return; }
|
||||
// NETPLAY (death match): same rule as the tennis block below — the receiving phone is the only
|
||||
// judge of a flight; our outbound ball is picture and its verdict arrives as a message.
|
||||
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'); }
|
||||
@@ -1035,7 +1038,9 @@ function onBounce() {
|
||||
// from before"). No more ordering proofs: the latch goes up BEFORE, the resolution in
|
||||
// progress is exempted via G.matchEarly in resolveInterlude's swallow, and any path that
|
||||
// re-arms play clears it in matchReady. Stale by construction impossible.
|
||||
else if (G.match && !G.match.ending && !G.match.decided && ball.lastHit === 'P'
|
||||
else if (G.match && !G.net && !G.match.ending && !G.match.decided && ball.lastHit === 'P'
|
||||
// !G.net: the early decide predicts the CPU's legs — a human on another phone is
|
||||
// not predictable, and the receiver is the only judge of this flight anyway
|
||||
&& matchFallWillPlay() && matchOutOfReach()) {
|
||||
G.match.decided = true; // scope: the REMAINING ball events of this rally
|
||||
G.matchEarly = true; // startFall leaves the ball rolling; the swallow exempts us
|
||||
@@ -1804,7 +1809,7 @@ export function update(dt) {
|
||||
if (G.mode === 'splatter' && !G.horde)
|
||||
// ...and not while the body is on the ground: the fall owns the sprite AND the pose for its
|
||||
// whole run, and racketAI would put a collapsing corpse back on its feet to chase a stray ball.
|
||||
if (!G.bench && !G.matchFall) for (const rz of zombies) if (rz.variant === "r") racketAI(rz, dt); // the bench drives its own body
|
||||
if (!G.bench && !G.matchFall && !G.net) for (const rz of zombies) if (rz.variant === "r") racketAI(rz, dt); // the bench drives its own body; netplay's body is the other phone (netTick)
|
||||
|
||||
// --- ball physics
|
||||
if (ball.active) {
|
||||
|
||||
+15
-7
@@ -7,7 +7,7 @@ import { TUNE, COURTS, TOURS, MODES, MENU, DIFFS, EATEN_RESTART, DONE_RESTART, W
|
||||
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';
|
||||
import { netConnect } from './net.js';
|
||||
import { netConnect, netHooks } from './net.js';
|
||||
import { fitViewport, render, proj, RES, SPRV, LOAD, SPR } from './render.js';
|
||||
import { voice, ambient, menuMusic, menuDuck } from './audio.js';
|
||||
import { zombies, spawnZombie, spawnRacketZombie, spawnMachine, spawnChapterBoss, piles, gore, parts, pellets, extras, shots, pops, explode, hurtPlayer, killPlayer , spawnBarrel } from './zombies.js';
|
||||
@@ -104,7 +104,9 @@ const pickMode = id => { pickedMode = id; try { localStorage.setItem('mode', id)
|
||||
// setup screen (court and tour ride whatever was last picked). HOST shows the code to share in a
|
||||
// modal over the frozen court; JOIN asks for a friend's code.
|
||||
// ponytail: prompt() is the join UI — replace with a drawn code entry if the mode ships.
|
||||
function netChip(label, join) {
|
||||
// The host's one decision — classic or the death match — rides the room, so the joiner never picks
|
||||
// a mode: the start message carries it to both phones and both courts are rebuilt on pairing.
|
||||
function netChip(label, join, death = false) {
|
||||
const b = document.createElement('button');
|
||||
b.className = 'chip';
|
||||
b.textContent = label;
|
||||
@@ -114,8 +116,8 @@ function netChip(label, join) {
|
||||
code = (prompt('ROOM CODE FROM YOUR OPPONENT') || '').trim().toUpperCase();
|
||||
if (!code) return;
|
||||
}
|
||||
start('classic');
|
||||
if (startNetVs()) netConnect(code);
|
||||
start('classic'); // the waiting court is classic either way; a death room swaps it on pairing
|
||||
if (startNetVs()) netConnect(code, death);
|
||||
});
|
||||
return b;
|
||||
}
|
||||
@@ -125,7 +127,8 @@ function buildSub(top) {
|
||||
const t = top || topOf(pickedMode);
|
||||
document.getElementById('subTitle').textContent = t.diffs ? 'DIFFICULTY' : (t.name || '');
|
||||
if (t.net) {
|
||||
modeSub.appendChild(netChip('HOST — GET A ROOM CODE', false));
|
||||
modeSub.appendChild(netChip('HOST — CLASSIC TENNIS', false));
|
||||
modeSub.appendChild(netChip('HOST — DEATH MATCH', false, true));
|
||||
modeSub.appendChild(netChip('JOIN WITH A CODE', true));
|
||||
return;
|
||||
}
|
||||
@@ -363,6 +366,7 @@ function intoSplatter() {
|
||||
zombies.length = 0; shots.length = 0; pellets.length = 0;
|
||||
score.server = 'P'; setupPoint();
|
||||
}
|
||||
netHooks.intoSplatter = intoSplatter; // netplay's death match enters splatter through the same hack
|
||||
document.getElementById('scWeapons').addEventListener('click', () => {
|
||||
start(null); intoSplatter(); toggleTestMode();
|
||||
});
|
||||
@@ -583,8 +587,12 @@ if (location.hash === '#drill') setTimeout(() => { start(null); intoSplatter();
|
||||
if (location.hash === '#match') setTimeout(() => { start(null); intoSplatter(); startMatch(); }, 700);
|
||||
if (location.hash === '#cup') setTimeout(() => { start(null); intoSplatter(); startMatch(true); }, 700);
|
||||
if (location.hash === '#bench') setTimeout(() => { start(null); intoSplatter(); startBench(); }, 700);
|
||||
// #net hosts a room, #net-ABCD joins one — the netplay door from a URL (slice(5) skips '#net-')
|
||||
if (location.hash.startsWith('#net')) setTimeout(() => {
|
||||
// #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);
|
||||
}, 700);
|
||||
else if (location.hash.startsWith('#net')) setTimeout(() => {
|
||||
start('classic');
|
||||
if (startNetVs()) netConnect(location.hash.slice(5).toUpperCase() || null);
|
||||
}, 700);
|
||||
|
||||
+53
-6
@@ -20,6 +20,13 @@
|
||||
import { COURT, UI } from './config.js';
|
||||
import { G, P, O, ball, score, setupPoint } 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';
|
||||
|
||||
// main.js hangs its court builders here, so this module never has to know how a splatter court is
|
||||
// entered (the transition hack lives in main). Headless tests leave it empty and net falls back to
|
||||
// the minimal direct entry, which is exactly what the suite's own freshSplatter does.
|
||||
export const netHooks = { intoSplatter: null };
|
||||
|
||||
let ws = null, hb = 0, posT = 0;
|
||||
let applying = false; // a remote verdict being applied must not echo back out
|
||||
@@ -40,6 +47,7 @@ export const netJudging = () => !!(G.net && G.net.ready) && !applying;
|
||||
export const netSendPoint = (side, reason) => send({ t: 'pt', win: side === 'P' ? 'me' : 'you', reason: reason || null });
|
||||
export const netSendFault = () => send({ t: 'fault' });
|
||||
export const netSendToss = () => { if (G.net && G.net.ready) send({ t: 'toss' }); };
|
||||
export const netSendDead = () => send({ t: 'dead' }); // my blood ran out — their phone wins on it
|
||||
|
||||
// their toss: replay it as a REAL toss over O, so the receiver watches the serve coming. Everything
|
||||
// downstream is machinery that already exists — update() poses the server off phase 'toss', the
|
||||
@@ -71,12 +79,18 @@ 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) {
|
||||
applying = true; // their verdict — apply it without judging or echoing
|
||||
ball.active = false;
|
||||
pointTo(m.win === 'me' ? 'O' : 'P', m.reason);
|
||||
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);
|
||||
applying = false;
|
||||
}
|
||||
|
||||
@@ -87,13 +101,28 @@ function netApplyFault() {
|
||||
applying = false;
|
||||
}
|
||||
|
||||
function netStart(side) {
|
||||
function netStart(side, death) {
|
||||
G.net.ready = true; G.net.side = side;
|
||||
G.modal = null; G.modalQ.length = 0; // the waiting card has done its job
|
||||
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
|
||||
// here, under the same frame the pairing lands on. G.net steps aside for the builders because
|
||||
// startMatch refuses a court netplay owns (the guard is right; this composition is the one
|
||||
// sanctioned exception and it happens through this swap, never by weakening the guard).
|
||||
G.net.death = true;
|
||||
const n = G.net; G.net = null;
|
||||
if (netHooks.intoSplatter) netHooks.intoSplatter();
|
||||
else { G.mode = 'splatter'; G.everTransitioned = true; } // headless: the suite's own entry
|
||||
G.classic = false;
|
||||
startMatch();
|
||||
G.net = n;
|
||||
G.modal = null; G.modalQ.length = 0; // 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];
|
||||
G.serveNum = 1;
|
||||
setupPoint();
|
||||
if (G.match) matchReady(); else setupPoint();
|
||||
G.flash = { text: side === 'A' ? 'AN OPPONENT — YOUR SERVE' : 'AN OPPONENT — THEIR SERVE', t: 2.4 };
|
||||
}
|
||||
|
||||
@@ -113,13 +142,14 @@ 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);
|
||||
else if (m.t === 'start') netStart(m.side, m.death);
|
||||
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();
|
||||
else if (m.t === 'shot') netApplyShot(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;
|
||||
}
|
||||
|
||||
@@ -139,12 +169,29 @@ 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function netConnect(code) {
|
||||
export function netConnect(code, death = false) {
|
||||
const url = (location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host
|
||||
+ '/ws' + (code ? '?room=' + encodeURIComponent(code) : '');
|
||||
+ '/ws' + (code ? '?room=' + encodeURIComponent(code) : death ? '?death=1' : '');
|
||||
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');
|
||||
|
||||
@@ -907,6 +907,20 @@ const BLEED = {
|
||||
g: [[-0.30, 1.95, 1.10], [0.42, 1.90, 1.00], [0.05, 2.35, 2.00], [0.00, 1.20, 0.70]], // arm, both, head, gut
|
||||
k: [[-0.36, 2.30, 1.15], [0.50, 2.24, 1.05], [0.06, 2.80, 2.10], [0.00, 1.50, 0.75]], // the same, a head higher
|
||||
};
|
||||
// the kill fall's bleed-out: low anchors around the corpse itself, thin drops but many — the flow
|
||||
// reads as the body emptying, not as a heavier version of the standing trickle (blood stays thin,
|
||||
// the settled rule; this is more EMISSIONS from where the body actually is)
|
||||
function gushProne(zb) {
|
||||
if (parts.length > 150) return;
|
||||
for (let i = 0; i < TUNE.killGush; i++) {
|
||||
if (Math.random() > 0.75) continue;
|
||||
parts.push({
|
||||
x: zb.x + (Math.random() * 2 - 1) * 0.55, y: 0.22 + Math.random() * 0.3, z: zb.z + (Math.random() * 2 - 1) * 0.3,
|
||||
vx: (Math.random() * 2 - 1) * 0.9, vy: 0.5 + Math.random() * 1.1, vz: (Math.random() * 2 - 1) * 0.5,
|
||||
t: 0.35 + Math.random() * 0.35, dark: Math.random() < 0.55 });
|
||||
}
|
||||
}
|
||||
|
||||
function bleed(zb) {
|
||||
if (parts.length > 90) return;
|
||||
let wounds;
|
||||
@@ -1025,6 +1039,11 @@ export function updateZombies(dt) {
|
||||
// be, two metres above itself. Oliver: "this looks like blood fountains midair." The falling body
|
||||
// is the one G.matchFall owns; every other body on court bleeds as before.
|
||||
if (!(G.matchFall && zb.variant === 'r')) bleed(zb); // open wounds bleed every frame they're standing
|
||||
// ...but a game-winning body BLEEDS OUT where it lies: once the drop has landed it, the prone
|
||||
// hold gushes from ground height until the burst (Oliver: "increased dynamic bleeding before
|
||||
// exploding"). Deliberately NOT bleed() — its anchors are standing heights, and a corpse
|
||||
// spraying from where its neck used to be is the "blood fountains midair" bug fixed above.
|
||||
else if (G.matchFall.kill && G.matchFall.t > 0.25) gushProne(zb);
|
||||
|
||||
if (zb.shieldT > 0) zb.shieldT -= dt; // staggered chair comes back up when this runs out
|
||||
if (zb.hitT > 0) zb.hitT -= dt; // boss contact cooldown (see TUNE.bossHitCool)
|
||||
|
||||
@@ -89,7 +89,7 @@ const { mirror, netMsg, netTest } = 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; };
|
||||
const { zombies, spawnZombie, pellets } = await import('./src/zombies.js');
|
||||
const { zombies, spawnZombie, pellets, parts } = await import('./src/zombies.js');
|
||||
const { killPlayer } = await import('./src/zombies.js');
|
||||
const { drillQueue, drillLegal, arrival, drillMult, DRILL_TYPES } = await import('./src/drill.js');
|
||||
const cfg = await import('./src/config.js');
|
||||
@@ -1272,6 +1272,82 @@ test('netplay: a serve fault is the receiver\'s call, and re-arms the server\'s
|
||||
assert.ok(!netTest.sent.some(m => m.t === 'fault'), 'applying a fault does not echo one back');
|
||||
});
|
||||
|
||||
// ---- NETPLAY DEATH MATCH: the host's pick rides the room's start message, and the match
|
||||
// machinery is driven by the same receiver-is-judge verdicts classic uses — routed through
|
||||
// resolveInterlude instead of pointTo, so wounds, blood, the fall and the burst all come along.
|
||||
function freshNetDeath(side) {
|
||||
restartRun();
|
||||
G.classic = true; // the waiting court, exactly as the menu builds it
|
||||
assert.ok(startNetVs(), 'net vs takes a clean court');
|
||||
netTest.sent = [];
|
||||
netMsg({ t: 'start', side, death: true });
|
||||
}
|
||||
|
||||
test('netplay death match: the host\'s pick rebuilds the court on pairing', () => {
|
||||
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.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', () => {
|
||||
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.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');
|
||||
});
|
||||
|
||||
test('netplay death match: the receiver judges — an untouched winner draws blood and crosses the wire', () => {
|
||||
freshNetDeath('B');
|
||||
netMsg({ t: 'shot', x: 0, y: 1.5, z: 21.77, vx: 0, vy: 3, vz: -16, g: 9.2, pow: 0.6, serve: false });
|
||||
const blood = G.hp;
|
||||
for (let i = 0; i < 900 && ball.active; i++) update(DT);
|
||||
assert.ok(!ball.active, 'the rally resolved locally — this phone was the judge');
|
||||
assert.equal(G.hp, blood - 1, 'their untouched winner draws my blood');
|
||||
const pt = netTest.sent.find(m => m.t === 'pt');
|
||||
assert.ok(pt && pt.win === 'you', 'and the verdict went to their phone');
|
||||
});
|
||||
|
||||
test('netplay death match: bleeding out is said on the wire, and the other phone wins on it', () => {
|
||||
freshNetDeath('B');
|
||||
G.hp = 1;
|
||||
netMsg({ t: 'pt', win: 'me' }); // the point that empties me
|
||||
assert.equal(G.mode, 'eaten', 'my run ends as every match death does');
|
||||
assert.ok(netTest.sent.some(m => m.t === 'dead'), 'the death is said out loud');
|
||||
freshNetDeath('A'); // ...and the winner's phone, receiving it
|
||||
score.games[0] = 3;
|
||||
netMsg({ t: 'dead' });
|
||||
assert.ok(G.done && G.done.won, 'the last one standing gets the win ending');
|
||||
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');
|
||||
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;
|
||||
for (let i = 0; i < 20; i++) update(DT);
|
||||
assert.ok(parts.length > before, 'the prone hold gushes (gushProne), where bleed() is suppressed');
|
||||
});
|
||||
|
||||
// stats.js ships its own fixture-based check — the period filter, the mode split and the name
|
||||
// normalisation that has to agree with server.py. It was exported and never called by anything.
|
||||
test("stats.js's own check", () => { assert.equal(statsCheck(), 'ok'); });
|
||||
|
||||
Reference in new issue
Block a user