Three from Oliver. THE LOB. He picked option (c) from the written comparison — "no additional gesture to learn" — and corrected the premise: the zombie does come to the net (racketAI chases to the far service line, the classic CPU to z 9.5). So the forward flick keeps its one meaning, "put it deep behind him", and the game picks the trajectory that actually does that: a smash while he is back, over his head once he is in. oppAtNet() is the whole switch, and it reads the BODY, not O — in the match O is the invisible tennis body and only moves between points, so it would have answered "on his mark" all rally. lobArm 7.0 m is measured, not taste: a lob peaking at lobApex passes a man 7 m from the net at almost exactly CONTACT.maxY, so the shot arms exactly as far out as it can still clear him. The pace is derived from the height, so the same arc comes off the baseline and off mid-court, and SHOT_DEPTH.lob is deeper than deep — at the ordinary deep target the ball passed 2.05 m over him at the edge of the band, which is inside his reach. PACE, SEEN FROM THE STRIKER. "the opponent sees the ball really fast - the player barely sees the speed increase." Measured: the ball is the same ball on both phones, the camera is not — it compresses your own ball into the distance and expands theirs as it arrives. Pace untouched; three channels added on the striker's side: the ghost streak now reads pace (3-8 ghosts, was 3 at every speed), the crack is pitched by it, and a fast ball kicks the camera. The serve's own ladder is 49.2 plain / 50.8-52.4 hard, because serveArc's loft margin takes 10% of the +9 back — widening that is his call, and PROJECT.md records the knob and the risk. THE DEATH MATCH. "each player has 8 blood points but the player explodes for the winner already at 0:40." Two clocks: a game is four points, a life is eight. A won game is only a game now (flash, serve, heal); the burst moved to the death itself, on the dead message, where both phones agree. The summary waits 1.4 s for the gore, because update() freezes the world on G.done and would have hung a still frame of an explosion behind the trophy screen. And bleedO tracks THEIR blood off the pos stream instead of our points-this-game, which reset every game and ignored the heal. 4 new tests, 113 pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
261 lines
15 KiB
JavaScript
261 lines
15 KiB
JavaScript
// NETPLAY — 1v1 classic tennis between two phones, over the dumb relay in server.py.
|
|
// (gameplay <-> net is a runtime-only import cycle, same pattern as input <-> gameplay: the
|
|
// imported bindings are only ever touched inside functions, never during module evaluation.)
|
|
//
|
|
// THE SYNC UNIT IS THE SHOT, NOT WORLD STATE (PROJECT.md, LATER item 7). Each phone runs the whole
|
|
// sim with ITSELF as P; the opponent's court is this one rotated half a turn about the net centre
|
|
// (mirror() below). A struck ball is ~9 numbers; each ~1 s flight swallows the latency.
|
|
//
|
|
// WHO JUDGES WHAT — the one rule that keeps two skewed sims from double-scoring a rally:
|
|
// THE PHONE THE BALL IS FLYING TOWARD IS THE ONLY JUDGE OF THAT FLIGHT. Your swing window is
|
|
// judged on your phone (lag never eats your tap), and so are the line call, the service box and
|
|
// the double bounce of everything that comes AT you. The striker's phone renders its outbound
|
|
// ball but judges nothing (gameplay's bounce block returns early on it) and waits for one of
|
|
// three verdicts: 'shot' (returned — snap the ball to the mirrored strike), 'fault' (re-serve),
|
|
// 'pt' (the point, winner relative to the SENDER). The 10 Hz 'pos' stream is pure picture — it
|
|
// drives the O sprite and carries no authority at all.
|
|
//
|
|
// 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, KITS, SKINS, TOURS, NATIVE, HOST } from './config.js';
|
|
import { G, P, O, ball, score, setupPoint, selectCourt, court, kit, skin, tour } from './state.js';
|
|
import { pointTo, fault, 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';
|
|
import { savedName, tidyName } from './stats.js';
|
|
|
|
// main.js hangs its court builders and its LOBBY here, so this module never has to know how a
|
|
// splatter court is entered or what the menu looks like. Headless tests leave them empty and net
|
|
// falls back to the minimal direct entry, which is exactly what the suite's own freshSplatter does.
|
|
// intoSplatter the death match's court rebuild
|
|
// paired the room filled: leave the menu and take the court, BEFORE netStart touches it
|
|
// room the code the relay minted (the lobby shows it and shares the link)
|
|
// lost the line died; returns true if the lobby said so itself and needs no card
|
|
export const netHooks = { intoSplatter: null, paired: null, room: null, lost: null };
|
|
|
|
let ws = null, hb = 0, posT = 0;
|
|
let applying = false; // a remote verdict being applied must not echo back out
|
|
export const netTest = { sent: null }; // test seam: an array here catches outgoing messages
|
|
|
|
const r3 = v => Math.round(v * 1000) / 1000;
|
|
|
|
// the half-turn about the net centre: x -> -x, z -> COURT.L - z, velocities with them
|
|
export const mirror = m => ({ ...m, x: -m.x, z: COURT.L - m.z, vx: -m.vx, vz: -m.vz });
|
|
|
|
function send(m) {
|
|
if (netTest.sent) { netTest.sent.push(m); return; }
|
|
if (ws && ws.readyState === 1) ws.send(JSON.stringify(m));
|
|
}
|
|
|
|
// true while THIS phone is the judge of the live flight and the verdict is its own (not an echo)
|
|
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
|
|
// uncaught-toss reset drops a cancelled one back to pretoss on both phones by plain physics, and
|
|
// the strike's shot event overwrites the ball wherever the two sims had drifted to.
|
|
function netApplyToss() {
|
|
if (score.server !== 'O' || G.phase === 'rally') return; // a stray toss must not touch a live rally
|
|
tossBall(O);
|
|
}
|
|
|
|
// called by gameplay the moment a local strike has finished writing the ball — the ball IS the message
|
|
export function netSendShot() {
|
|
if (!G.net || !G.net.ready) return;
|
|
send({ t: 'shot', x: r3(ball.x), y: r3(ball.y), z: r3(ball.z),
|
|
vx: r3(ball.vx), vy: r3(ball.vy), vz: r3(ball.vz),
|
|
g: r3(ball.g), pow: r3(ball.pow || 0), serve: !!G.wasServe });
|
|
}
|
|
|
|
// the opponent struck: their ball, mirrored, becomes the live ball — and the authority flips here
|
|
export function netApplyShot(m) {
|
|
const s = mirror(m);
|
|
ball.active = true; ball.collected = false; ball.bounces = 0;
|
|
ball.x = s.x; ball.y = m.y; ball.z = s.z;
|
|
ball.vx = s.vx; ball.vy = m.vy; ball.vz = s.vz;
|
|
ball.g = m.g; ball.pow = m.pow;
|
|
ball.lastHit = 'O';
|
|
ball.bomb = false; ball.flame = false; ball.wid = 'ball'; ball.chain = 0; ball.praise = null; ball.kills = 0;
|
|
G.wasServe = !!m.serve; // arms the receiver's own service-box call
|
|
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;
|
|
}
|
|
|
|
function netApplyPoint(m) {
|
|
applying = true; // their verdict — apply it without judging or echoing
|
|
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);
|
|
applying = false;
|
|
}
|
|
|
|
function netApplyFault() {
|
|
applying = true;
|
|
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;
|
|
}
|
|
|
|
// 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) {
|
|
// THE LOBBY HANDS THE COURT OVER FIRST. Hosting and joining both happen on the menu now (the
|
|
// code and its share link are DOM, not a canvas modal), so the game is still parked behind the
|
|
// overlay when the room fills — and everything below writes to a court that must already exist,
|
|
// with the kit and tour this phone picked already selected (the look message reads them).
|
|
if (netHooks.paired) netHooks.paired();
|
|
G.net.ready = true; G.net.side = side;
|
|
// tell them how we look AND WHO WE ARE, the moment there is somebody to tell. Sent once, on
|
|
// pairing: neither phone can change its pick mid-match, because the setup screen is only
|
|
// reachable between runs.
|
|
// `u` is the TOUR — which body they play in, not just what colour it wears. It rides the same
|
|
// message because it is the same fact: what the friend across the net LOOKS like. Without it both
|
|
// phones drew the opponent in whatever tour THIS phone picked, so a men's/women's pairing showed
|
|
// each player a body their friend was not using.
|
|
send({ t: 'look', k: kit.id, s: skin.id, n: savedName(), u: tour.id });
|
|
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
|
|
// 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;
|
|
// 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];
|
|
G.serveNum = 1;
|
|
if (G.match) matchReady(); else setupPoint();
|
|
G.flash = { text: side === 'A' ? 'AN OPPONENT — YOUR SERVE' : 'AN OPPONENT — THEIR SERVE', t: 2.4 };
|
|
}
|
|
|
|
function netDead(text) {
|
|
if (!G.net || G.net.dead) return;
|
|
G.net.ready = false; G.net.dead = true;
|
|
ball.active = false;
|
|
G.modal = null; G.modalQ.length = 0;
|
|
// the lobby is a menu screen: while it is up there is no court to lay a card on, and it says the
|
|
// bad news in its own words (a wrong code is a typo to fix, not a match that ended)
|
|
if (netHooks.lost && netHooks.lost(text)) return;
|
|
G.card = { title: 'CONNECTION LOST', sub1: text, color: UI.bad, t: 30 };
|
|
}
|
|
|
|
export function netMsg(m) {
|
|
if (!G.net) return;
|
|
if (m.t === 'room') {
|
|
G.net.code = m.code;
|
|
if (netHooks.room) netHooks.room(m.code); // the lobby: the code big, and a link to send them
|
|
}
|
|
else if (m.t === 'start') netStart(m.side, m.death, m.court);
|
|
// 'gone' can only ever reach a phone that is JOINING, and joining happens in the lobby — so it is
|
|
// a typo to fix on the screen you are already looking at, not a match that ended
|
|
else if (m.t === 'gone') netDead('NO SUCH ROOM — CHECK THE CODE');
|
|
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; if (m.srv) netSync(m); }
|
|
// WHAT THEY LOOK LIKE AND WHAT THEY ARE CALLED. Their phone's kit and skin, resolved against our
|
|
// own tables so a build that has never heard of a colour falls back to the drawn one rather than
|
|
// drawing nothing. Unknown message types are ignored above, so an older build simply never sends
|
|
// this and their friend sees the opponent as drawn, called FRIEND — the match still plays.
|
|
// The name is a stranger's string on OUR scoreboard: it goes through the same tidyName the world
|
|
// board uses, then into the board's 8-character slot.
|
|
else if (m.t === 'look') G.net.look = {
|
|
kit: KITS.find(k => k.id === m.k) || KITS[0],
|
|
skin: SKINS.find(s => s.id === m.s) || SKINS[0],
|
|
// a build that predates the tour field sends none, and the fallback is what it used to do:
|
|
// draw them in OUR tour. Same rule as the kit — an unknown id resolves to the drawn default.
|
|
tour: TOURS.find(t => t.id === m.u) || tour,
|
|
name: tidyName(m.n || '').slice(0, 8) || 'FRIEND',
|
|
};
|
|
}
|
|
|
|
// ticked from update() while G.net: the position stream out, the O sprite in
|
|
export function netTick(dt) {
|
|
const n = G.net;
|
|
if (!n || !n.ready) return;
|
|
if ((posT += dt) >= 0.1) {
|
|
posT = 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
|
|
// ...and it carries MY BLOOD. Each phone bookkeeps its own hp (matchPoint), so without this the
|
|
// other side has to GUESS how close I am to death — it was reading its own points-this-game
|
|
// instead, which resets every game and ignores the heal, so the friend's body showed a wound
|
|
// state that had nothing to do with the blood left in it (Oliver, 2026-08-22: "the winner sees
|
|
// the zombie explode but the player on the other side is not dead yet"). One number on a stream
|
|
// that already exists, ten times a second: a dropped one corrects itself 100 ms later.
|
|
send({ t: 'pos', x: r3(P.x), z: r3(P.z), m: P.pose === 'run' ? 1 : 0, hp: G.hp,
|
|
...(n.side === 'A' && G.phase === 'pretoss' ? { srv: score.server, p: score.pts } : {}) });
|
|
}
|
|
const o = n.opp;
|
|
if (o) {
|
|
const tx = -o.x, tz = COURT.L - o.z;
|
|
const k = Math.min(1, dt * 9); // a short lerp: smooths the 10 Hz stream, adds no lag worth feeling
|
|
if (o.m && Math.abs(tx - O.x) > 0.2) O.facing = tx > O.x ? 1 : -1;
|
|
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;
|
|
}
|
|
}
|
|
|
|
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 = (NATIVE ? 'wss://' + HOST // the app: the live host, by name
|
|
: (location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host)
|
|
+ '/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');
|
|
hb = setInterval(() => send({ t: 'hb' }), 20000); // keeps phone radios and proxies from idling us out
|
|
}
|
|
|
|
export function netClose() {
|
|
if (hb) { clearInterval(hb); hb = 0; }
|
|
if (ws) { try { ws.onclose = null; ws.close(); } catch { /* already gone */ } ws = null; }
|
|
}
|