Two phones, one match: the shot is the message, the receiver is the judge

VS A FRIEND on the mode screen (host a code, join a code), a ~110-line
RFC6455 relay in server.py that holds no game state, and src/net.js: each
phone plays itself near-side and the opponent's court is this one rotated
half a turn about the net. The phone a ball flies toward makes every call
on it; the striker's copy is picture. Six tests pin the rule from both
seats. No reconnect yet — a dropped radio is a card and a reload.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Fable 5 committed 2026-08-14 17:36:02 +02:00
1 parent 108ed695eb
commit f302354bb2
11 files changed
+493 -21

No files matched your search

+31 -7
View File
@@ -14,6 +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';
// 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.
@@ -64,7 +65,7 @@ function startChangeover() {
export function startDuel() {
// Hard mutual exclusion, HERE, where every caller routes through: a duel must never begin while
// another control mode owns the court. Each of these has ended a run when they overlapped.
if (G.interlude || G.horde || G.bossFight || G.drill || G.match || G.bench) return;
if (G.interlude || G.horde || G.bossFight || G.drill || G.match || G.bench || G.net) return;
if (!zombies.some(z => z.variant === 'r')) spawnRacketZombie(); // the schedule guarantees an opponent
for (const z of zombies) if (z.variant === 'r') z.dueled = true; // a veteran can be challenged again — this marks the fresh duel
G.weapon = 0; // tennis rules for the duel: plain balls, no tins
@@ -87,7 +88,7 @@ export function startDuel() {
// It threatens BOTH channels: its volleys chip your hp, and letting it reach you is the ordinary
// instant death. G.bossFight holds the chapter number while it runs.
export function startBossFight(chapter) {
if (G.bossFight || G.drill || G.match || G.bench) return;
if (G.bossFight || G.drill || G.match || G.bench || G.net) return;
G.bossFight = chapter;
zombies.length = 0; pellets.length = 0; extras.length = 0; shots.length = 0; // a clean stage
G.horde = null; G.mgT = 0; endBurst(); G.clearing = false;
@@ -136,7 +137,7 @@ export function endBossFight() {
// was walking around a moment ago is still walking around inside a drill round. The drill's own
// clock and rules live in drill.js; only the transition is here, where the rule can be enforced.
export function startDrill() {
if (G.drill || G.interlude || G.horde || G.bossFight || G.match || G.bench) return false;
if (G.drill || G.interlude || G.horde || G.bossFight || G.match || G.bench || G.net) return false;
G.drill = { t: 0, spawnT: 0, score: 0, streak: 0, kills: 0, faults: 0, lost: 0, best: 0, msg: null };
G.changeover = false; G.clearing = false; G.afterClear = null;
// ...and DISARM the ordinary schedule. Taking the court is not enough on its own: a pending
@@ -171,7 +172,7 @@ export function endDrill() {
const BENCH_HP = 12; // a deep pool so a wound stage is set by FRACTION, cleanly
export function startBench() {
if (G.interlude || G.horde || G.bossFight || G.drill || G.match || G.bench) return false;
if (G.interlude || G.horde || G.bossFight || G.drill || G.match || G.bench || G.net) return false;
G.bench = { i: 0, stage: 0, fam: 0, dir: 1 };
zombies.length = 0; pellets.length = 0; extras.length = 0; shots.length = 0;
// ...and DISARM the ordinary schedule, exactly as the drill does: a pending horde or an owed
@@ -342,7 +343,7 @@ export function watchdog(dt) {
// it, and killing it outright wins the game early (killZombie fires resolveInterlude('P'), which
// lands in matchGameWon via the empty-court check).
export function startMatch(cup = false) {
if (G.interlude || G.horde || G.bossFight || G.drill || G.match || G.bench) return false;
if (G.interlude || G.horde || G.bossFight || G.drill || G.match || G.bench || G.net) return false;
G.match = { opp: 1, ending: false };
// THE TOURNAMENT rides the whole match machinery and differs in exactly three places: who walks
// on (a rung DRAWN from the round's band — cupDraw sets d.opp, so every ladder dial downstream
@@ -827,12 +828,35 @@ export function resolveInterlude(side) {
}
}
// ---- NETPLAY: 1v1 classic tennis against another phone. The wire, the protocol and the judging
// rule all live in src/net.js; ONLY the court ownership lives here, where the contract can be
// enforced — same shape as every mode above. It runs in G.mode 'tennis' with G.classic set (the
// caller does that via start('classic')), so the whole splatter schedule is structurally inert;
// the disarm lines below are the same belt the drill and the bench wear anyway.
export function startNetVs() {
if (G.net || G.interlude || G.horde || G.bossFight || G.drill || G.match || G.bench) return false;
G.net = { ready: false, dead: false, side: null, code: null, opp: null };
G.pendingHorde = false; G.hordeWarn = 0; G.bossDue = 0; G.duelDue = false;
G.changeover = false; G.clearing = false; G.afterClear = null;
G.weapon = 0; G.mgT = 0; endBurst(); G.boy = null;
zombies.length = 0; pellets.length = 0; extras.length = 0; shots.length = 0;
ball.active = false; setupPoint();
return true;
}
export function endNetVs() {
if (!G.net) return;
netClose();
G.net = null;
ball.active = false; setupPoint();
}
// THE BREATHER BEFORE THE WAVE. The horde used to begin on the same frame as its own announcement,
// so the flash was already gone by the time the crowd was on you — "the horde is not announced
// anymore". It is an ordinary 'over' beat (no serving, the world keeps walking, the RESUMING clock
// counts it down under the flash), and startHorde still owns the transition itself.
export function warnHorde() {
if (G.horde || G.interlude || G.bossFight || G.drill || G.match || G.bench) return false; // same court-ownership contract as the rest
if (G.horde || G.interlude || G.bossFight || G.drill || G.match || G.bench || G.net) return false; // same court-ownership contract as the rest
G.hordeWarn = TUNE.hordeWarnT;
G.flash = { text: 'THEY KEEP COMING', t: TUNE.hordeWarnT };
ball.active = false; G.phase = 'over'; G.overT = TUNE.hordeWarnT;
@@ -843,7 +867,7 @@ export function warnHorde() {
// Returns false when another mode owns the court — the caller re-arms the latch rather than
// silently eating the wave the set paid for.
export function startHorde() {
if (G.horde || G.interlude || G.bossFight || G.drill || G.match || G.bench) return false; // same contract as startDuel: one mode owns the court
if (G.horde || G.interlude || G.bossFight || G.drill || G.match || G.bench || G.net) return false; // same contract as startDuel: one mode owns the court
G.changeover = false; // a cheat-forced horde mid-changeover must not leave spawns locked
const grow = 1 + (G.sets[0] + G.sets[1]) * TUNE.hordeGrow; // each set banked makes the next wave worse
// the gun grows WITH the wave: spawn rate and length both scale by grow, so a flat gun fell
+3
View File
@@ -655,6 +655,9 @@ export const MENU = [
{ mode: 'classic', diffs: true },
{ name: 'ZOMBIE TENNIS', sub: 'THE DEAD CLUB PLAYS YOU', art: 'mode_zombie', kids: ['zombie', 'cup'] },
{ mode: 'gore' },
// NETPLAY (prototype): a poster with no run mode of its own — its second step is HOST / JOIN,
// and either chip starts classic tennis against the other phone (beats.startNetVs + src/net.js)
{ name: 'VS A FRIEND', sub: 'LIVE 1V1 · REAL TENNIS · TWO PHONES', art: 'mode_vs', net: true },
];
// CLASSIC's difficulty. The first cut scaled only flub/quality-cap/speed and Oliver felt NOTHING —
+26 -5
View File
@@ -9,6 +9,7 @@ import { updateDrill } from './drill.js';
import { endRun, beginRun, MODE_CLASSIC } from './stats.js';
import { updateZombies, updateParts, spawnZombie, spawnRacketZombie, spawnMachine, spawnChapterBoss, zombies, gore, explode, meleeSwipe, extras, parts, pellets, shots, playerGore, eatenBleed, resetZombies , firePellet , spawnBarrel , fireBeam , pops, popText } from './zombies.js';
import { proceedAfterClear, startDuel, startHorde, warnHorde, resolveInterlude, watchdog, updateBench, benchKey, tickMatchFall } from './beats.js'; // runtime-only cycle: beats calls back into cheer/aside/endBurst/deliverTin/showModal/logSwing
import { netSendShot, netTick, netJudging, netSendPoint, netSendFault, netClose } from './net.js'; // runtime-only cycle: net calls back into pointTo/fault/showModal
// ------------------------------------------------------------- ballistics ----
// Launch ball from (x0,y0,z0) so it arrives at (tx, tz) with horizontal speed s under gravity g.
@@ -227,6 +228,7 @@ function doHit(ch, quality, aim) {
if (w) popText(ball.x, ball.y, ball.z, w);
}
ch.pose = 'swing'; ch.poseT = 0;
if (G.net && ch.side === 'P') netSendShot(); // netplay: the shot IS the sync unit
}
// ----------------------------------------------------------------- serve ----
@@ -460,6 +462,7 @@ function hitServe(sv, quality, aim = null, perfect = quality > 0.999) {
// bounce. Praising at release meant the HUD spoke while the ball was still mid-air.
ball.praise = praiseWord();
}
if (G.net && sv.side === 'P') netSendShot(); // netplay: a serve is a shot event like any other
}
// ---------------------------------------------------------------- scoring ----
@@ -539,7 +542,11 @@ export function advancePoint(side) { // shared scoring math; returns whether a g
return false;
}
function pointTo(side, reason) {
export function pointTo(side, reason) { // exported for net.js: a remote verdict lands here
// netplay: this phone judged the live flight — the verdict goes over the wire before it is
// applied, so both scoreboards move on the same call. netJudging() is false while a REMOTE
// verdict is being applied, which is what stops the two phones echoing a point back and forth.
if (netJudging()) netSendPoint(side, reason);
if (G.mode === 'tennis' && side === 'O' && ball.lastHit === 'O' && G.rallyNear && !G.swung) { // their ball died on your side untouched — and no whiff already explains it
const n = G.rallyNear;
logSwing({ result: 'PASSED', reason: `never swung in range — ball got ${r2(n.d)}m away at y=${r2(n.y)}m`,
@@ -859,6 +866,7 @@ const xLimit = () => Math.max(xVisible(), COURT.W / 2);
// bag has a pristine copy (state.resetState) and the two modules with timers of their own put them
// back themselves, so nothing here has to know the shape of any of it.
export function restartRun() {
netClose(); // netplay: resetState wipes G.net below; the socket must not outlive the run it carried
resetZombies(); resetInput(); resetState(); setupPoint();
beginRun(); // ...and the stats bag, which is the only state that does not live on G
}
@@ -917,8 +925,12 @@ function transition(dt) {
});
}
function fault() {
if (G.serveNum === 1) { G.serveNum = 2; voice('fault'); G.phase = 'over'; G.overT = 1.4; sendBoy(); }
export function fault() { // exported for net.js: the receiver's fault call re-arms the server's second serve
if (G.serveNum === 1) {
if (netJudging()) netSendFault(); // netplay: only the first-serve branch wires — the double
// fault below goes out through pointTo as the point it is
G.serveNum = 2; voice('fault'); G.phase = 'over'; G.overT = 1.4; sendBoy();
}
else pointTo(other(score.server), 'double_fault');
}
@@ -1031,6 +1043,11 @@ function onBounce() {
return;
}
if (G.mode !== 'tennis') return; // no lines, no faults — the umpire has other concerns
// NETPLAY: THE RECEIVING PHONE IS THE ONLY JUDGE OF A FLIGHT. Our outbound ball is picture, not
// evidence — the two sims are ~a latency apart, and this one would call a double bounce before
// the other one's player had swung. Every call on this ball (box, line, second bounce) arrives
// as a message instead; the ball rolls on harmlessly until it does. (See net.js.)
if (G.net && ball.lastHit === 'P') return;
if (ball.bounces === 1) {
const wasServe = G.wasServe; G.wasServe = false;
if (wasServe) {
@@ -1365,6 +1382,7 @@ export function keyPressed(code) {
if (G.horde || G.mgT > 0) return; // auto-fire modes: a tap only swishes, never serves
if (G.changeover) return; // the breather is mandatory — the melee swish above still works
if (G.interlude && G.interlude.introT > 0) return; // let the umpire finish announcing the duel
if (G.net && (!G.net.ready || G.net.dead)) return; // netplay: no serving into an empty room
// splatter: a tap/flick during the brief 'over' beat re-arms the next serve immediately
if (G.mode === 'splatter' && G.phase === 'over') { ball.active = false; setupPoint(); }
// splatter serve stays a TWO-STAGE ritual — toss up, THEN fire. It can only start from
@@ -1693,6 +1711,7 @@ export function update(dt) {
if (G.meleeT > 0) G.meleeT -= dt;
if (G.rackGlow && (G.rackGlow.t -= dt) <= 0) G.rackGlow = null; // the fresh-pickup pulse fades out
watchdog(dt);
if (G.net) netTick(dt); // netplay: the 10 Hz position stream out, the opponent's body in
// !G.horde: a horde mows the court down to an EMPTY FRAME between spawns, and a clear latched
// before the wave began (a game won during the set card) then advanced the beat mid-horde —
// which is how a sudden-death duel once started INSIDE a horde. The clear waits for endHorde.
@@ -1717,7 +1736,8 @@ export function update(dt) {
// own baseline and arms aiServeT; this is the one condition that was keeping it switched off.
// The intro beat holds it: a body still walking on must not open with a serve.
const oServes = (G.mode === 'tennis' || G.match) && score.server === 'O'
&& !(G.interlude && G.interlude.introT > 0);
&& !(G.interlude && G.interlude.introT > 0)
&& !G.net; // netplay: the far side serves on the OTHER phone — the serve arrives as a shot event
if (oServes && G.phase === 'pretoss') {
G.aiServeT -= dt;
if (G.aiServeT <= 0) tossBall(O);
@@ -1728,7 +1748,8 @@ export function update(dt) {
if (G.match) { const rz = zombies.find(z => z.variant === 'r'); if (rz) rz.serveHold = 0.4; }
}
}
if (G.mode === 'tennis' && G.phase === 'rally') {
if (G.mode === 'tennis' && G.phase === 'rally' && !G.net) { // netplay: O is a real person on
// another phone — netTick drives the body
G.aiReactT += dt;
const incoming = ball.active && ball.lastHit === 'P'
&& G.aiReactT >= (G.diff ? G.diff.reaction : TUNE.aiReaction);
+39 -3
View File
@@ -6,7 +6,8 @@
import { TUNE, COURTS, TOURS, MODES, MENU, DIFFS, EATEN_RESTART, DONE_RESTART, WEAPONS, bossArt } from './config.js';
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 } from './beats.js';
import { startDuel, resolveInterlude, startBossFight, endBossFight, startDrill, endDrill, startMatch, endMatch, startBench, endBench, FALL, benchFall, startNetVs } from './beats.js';
import { netConnect } 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';
@@ -99,10 +100,35 @@ const banner = (card, cls, last) => {
const pickMode = id => { pickedMode = id; try { localStorage.setItem('mode', id); } catch {} };
// TAP ADVANCES. Each screen's tap both answers and moves on — the wizard's whole promise is one
// decision per screen, and a NEXT button under a single row of choices is a second tap for nothing.
function buildSub() {
// NETPLAY's second step: two chips, and either one IS the start — a room needs a socket, not a
// 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) {
const b = document.createElement('button');
b.className = 'chip';
b.textContent = label;
b.addEventListener('click', () => {
let code = null;
if (join) {
code = (prompt('ROOM CODE FROM YOUR OPPONENT') || '').trim().toUpperCase();
if (!code) return;
}
start('classic');
if (startNetVs()) netConnect(code);
});
return b;
}
function buildSub(top) {
modeSub.innerHTML = '';
const t = topOf(pickedMode);
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('JOIN WITH A CODE', true));
return;
}
if (t.diffs) for (const d of DIFFS) {
const b = document.createElement('button');
b.className = 'chip';
@@ -131,6 +157,9 @@ function buildTops() {
for (const t of MENU) {
const b = banner(t.mode ? modeOf(t.mode) : t, 'mode', topOf(pickedMode) === t);
b.addEventListener('click', () => {
// the netplay poster deals HOST/JOIN and never touches pickedMode — it is not a run mode
// the stats screen or the summary could name, and either chip starts the game itself
if (t.net) { buildSub(t); go('modesub'); return; }
// a group remembers which child you were on; entering it fresh lands on its first card
pickMode(t.mode || (t.kids.includes(pickedMode) ? pickedMode : t.kids[0]));
buildSub();
@@ -349,6 +378,8 @@ document.getElementById('scMatch').addEventListener('click', () => {
start(null); intoSplatter();
startMatch();
});
// (netplay has no back-room button: the VS A FRIEND poster on the mode screen IS the door, and
// #net / #net-CODE below covers sending a phone straight in from a URL)
// THE SPRITE BENCH — the art test. Same "drop into a real run first" trick as its neighbours, so the
// body is drawn by the real renderer at the real size, on the real court, under the real palette.
// Judging a walk cycle anywhere else is judging a different picture.
@@ -552,6 +583,11 @@ 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(() => {
start('classic');
if (startNetVs()) netConnect(location.hash.slice(5).toUpperCase() || null);
}, 700);
requestAnimationFrame(frame);
+146
View File
@@ -0,0 +1,146 @@
// 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 } from './config.js';
import { G, P, O, ball, score, setupPoint } from './state.js';
import { pointTo, fault, showModal } from './gameplay.js';
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' });
// 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.active = false;
pointTo(m.win === 'me' ? 'O' : 'P', m.reason);
applying = false;
}
function netApplyFault() {
applying = true;
ball.active = false;
fault(); // we are the server here: second serve arms exactly as ever
applying = false;
}
function netStart(side) {
G.net.ready = true; G.net.side = side;
G.modal = null; G.modalQ.length = 0; // the waiting card has done its job
score.server = side === 'A' ? 'P' : 'O'; // the host serves first, on both scoreboards
score.pts = [0, 0];
G.serveNum = 1;
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;
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;
// a modal is legal here: an empty court waiting for a stranger is the calmest court there is
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 === '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 === 'shot') netApplyShot(m);
else if (m.t === 'pt') netApplyPoint(m);
else if (m.t === 'fault') netApplyFault();
else if (m.t === 'pos') G.net.opp = m;
}
// 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;
send({ t: 'pos', x: r3(P.x), z: r3(P.z), m: P.pose === 'run' ? 1 : 0 });
}
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) {
const url = (location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host
+ '/ws' + (code ? '?room=' + encodeURIComponent(code) : '');
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; }
}
+1
View File
@@ -53,6 +53,7 @@ export const G = {
idleT: 0,
boy: null, // ball boy sprinting across: {x, z, dir, runT}
aiServeT: 0,
net: null, // netplay (src/net.js): { ready, dead, side, code, opp } while a 1v1 runs
aiSwingCool: 0,
aiReactT: 9, // time since the player's last hit; CPU reads the shot after TUNE.aiReaction
wasServe: false, // the ball in flight is a serve (first bounce must hit the box)