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:
1 parent
108ed695eb
commit
f302354bb2
11 files changed
+493
-21
No files matched your search
+146
@@ -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; }
|
||||
}
|
||||
Reference in new issue
Block a user