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

+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);