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
+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');
|
||||
|
||||
Reference in new issue
Block a user