Multiplayer setup moves out of a canvas modal and a prompt() dialog into one menu screen: your name, the two ways to host, and the friend's code. The socket opens from the menu and the game stays parked behind the overlay until the room fills — netHooks.paired takes the court one line before netStart writes to it — so the room code can carry a real SHARE button and a wrong code is a typo you fix rather than a CONNECTION LOST card over a match that never began. The name is the world board's name (stats.saveName), crosses on the existing look message, and the scoreboard finally names the person instead of CPU. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
247 lines
14 KiB
JavaScript
247 lines
14 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 } from './config.js';
|
|
import { G, P, O, ball, score, setupPoint, selectCourt, court, kit, skin } 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.
|
|
send({ t: 'look', k: kit.id, s: skin.id, n: savedName() });
|
|
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],
|
|
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
|
|
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) {
|
|
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 = (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; }
|
|
}
|