A room you can share by link, a code you can type, and a name to be called
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>
This commit is contained in:
1 parent
ad258ace97
commit
425c18dcb6
7 files changed
+255
-75
No files matched your search
+42
-2
@@ -414,6 +414,46 @@ zombie (now an occasional opponent, `TUNE.racketEvery`, not the every-game fixtu
|
||||
`RETURNED`, `DOUBLE KILL`) is a footnote to something you already watched happen. Sharing the loud
|
||||
FACE with the milestone cards is right; sharing their SIZE made every footnote read as a milestone.
|
||||
|
||||
## Session log: THE NETPLAY LOBBY — a link to send, a code to type, a name to be (2026-08-15)
|
||||
|
||||
Oliver: *"multiplayer should be easier to setup — a share with link next to the code; also entering
|
||||
the code is done in some plain js confirm dialog thingy, this could be embedded more nicely;
|
||||
somewhere when hosting or also when joining the player should name themselves."* All three are one
|
||||
screen (`#vs`, `index.html` + THE LOBBY in `main.js`); the protocol underneath is untouched.
|
||||
|
||||
- **THE LOBBY IS A MENU SCREEN, and that is the whole structural change.** Hosting used to call
|
||||
`start('classic')` first and show the room code in a CANVAS modal over an empty court — which
|
||||
cannot carry a share button, cannot be typed into, and turned a mistyped code into a CONNECTION
|
||||
LOST card over a match that never began. The socket is opened from the menu now and the game
|
||||
stays parked behind the overlay until the room actually fills. **`netHooks.paired` is what takes
|
||||
the court**, called at the top of `netStart` — one line before everything that writes to it, so
|
||||
the kit/tour selection `start()` performs is already done when the `look` message reads it.
|
||||
Two more hooks join `intoSplatter`: `room` (the code landed) and `lost` (returns true when the
|
||||
lobby said so itself, which is what suppresses the card). Headless leaves them null.
|
||||
- **SHARE THE LINK, then the code.** `navigator.share` is the phone's own sheet — the friend is in
|
||||
WhatsApp, not in a room reading letters aloud — with `clipboard.writeText` as the desktop
|
||||
fallback and the four letters still big on screen for when they are in the same room. The link is
|
||||
the URL door that already existed (`#net-ABCD`): it opens the lobby with the code prefilled,
|
||||
ONE tap from the court. Deliberately not an auto-join — that tap is where the joiner sees, and
|
||||
can change, the name the other phone will call them.
|
||||
- **ONE NAME PER PLAYER.** The field is prefilled from `stats.savedName()` and written back through
|
||||
the new `stats.saveName` (which `submit` now shares), so the world board's name and the netplay
|
||||
name cannot become two things. It crosses on the EXISTING `look` message as `n` — no new message
|
||||
type, and an older build simply sends none. **The scoreboard names the person** (`render`, the
|
||||
names block) in both netplay modes: FRIEND until the look lands, never CPU. A stranger's string
|
||||
lands in an 8-character slot, so it is normalised at the RECEIVING end through the same
|
||||
`tidyName` the board and `server.py`'s `clean()` agree on.
|
||||
- **Failure is a typo now, not a card.** A short code never opens a socket; `gone` puts
|
||||
NO SUCH ROOM — CHECK THE CODE over the picker with the field still there. CANCEL and the header
|
||||
chevron both `endNetVs`, so a room you walked out of gives its socket back.
|
||||
- Verified through the real relay in two panes: host → share link → join → both take the court,
|
||||
host serves, the board reads YOU / GUEST, and the death-match room rebuilds both courts the same
|
||||
way (blood pips, the friend as the living opponent, no roster body). 96 tests.
|
||||
- Smaller: the JOIN button is the crimson plate stretched to the field's own height (`#vs .joinrow
|
||||
.btn` — a bare `.joinrow .btn` loses to `#vs .btn`'s margin, and a margin on a stretched flex
|
||||
item is exactly the 8px it was coming up short by); `.tin` is now the one typed-field look, worn
|
||||
by the name prompt and both lobby fields.
|
||||
|
||||
## Session log: gore opens in the gore, the theme comes back, three boards (2026-08-15, same day)
|
||||
|
||||
Oliver's finetuning pass on the batch below.
|
||||
@@ -678,8 +718,8 @@ agree on the same point from opposite perspectives). 73 tests.
|
||||
serve and rally AI are gated off, `restartRun` closes the socket.
|
||||
- **Honest limits, all deliberate (ponytail)**: NO reconnect-and-resume — a dropped radio is a
|
||||
CONNECTION LOST card and a reload (the recorded plan calls reconnection "the bulk of the real
|
||||
work"; a prototype must not start there). prompt() is the join UI. The scoreboard still says
|
||||
CPU. A return snaps the ball to the mirrored strike (~latency of drift eaten in one hop; the
|
||||
work"; a prototype must not start there). ~~prompt() is the join UI. The scoreboard still says
|
||||
CPU.~~ *(both gone — see THE NETPLAY LOBBY above.)* A return snaps the ball to the mirrored strike (~latency of drift eaten in one hop; the
|
||||
flight swallows it). Runs bank as classic runs. The wound-decay hook (losing IS zombification)
|
||||
is the recorded next step once the feel survives a two-phone playtest.
|
||||
- **Test on staging with two phones** — the relay deploys with the ordinary push, nothing to
|
||||
|
||||
+76
-12
@@ -155,7 +155,7 @@ if (location.hostname !== 'splatter-tennis.wuwi.one') {
|
||||
/* ONE overlay, four panels, swapped by #intro[data-stage]. Keeping the backdrop and the card frame
|
||||
alive across every screen is the point — a screen that unmounts the whole overlay flashes the
|
||||
court through between menus. */
|
||||
#splash, #setup, #stats, #ranks, #newbest, #secret, #bosses, #stmode, #stsub, #bracket { display: none; }
|
||||
#splash, #setup, #stats, #ranks, #newbest, #secret, #bosses, #stmode, #stsub, #bracket, #vs { display: none; }
|
||||
#intro[data-stage="splash"] #splash { display: flex; flex-direction: column; flex: 1; }
|
||||
/* flex COLUMN, not block: it is what lets the footer's margin-top:auto reach the bottom of a
|
||||
screen whose content does not fill it */
|
||||
@@ -167,6 +167,7 @@ if (location.hostname !== 'splatter-tennis.wuwi.one') {
|
||||
#intro[data-stage="secret"] #secret,
|
||||
#intro[data-stage="bosses"] #bosses,
|
||||
#intro[data-stage="bracket"] #bracket,
|
||||
#intro[data-stage="vs"] #vs,
|
||||
#intro[data-stage="newbest"] #newbest { display: flex; flex-direction: column; flex: 1;
|
||||
animation: drop 0.3s ease-out; }
|
||||
/* the overlay is HIDDEN during play, not removed: a restart puts it straight back, and the
|
||||
@@ -400,21 +401,53 @@ if (location.hostname !== 'splatter-tennis.wuwi.one') {
|
||||
.rkrow span { flex: 1; min-width: 0; font: 400 11px Silkscreen, monospace; color: var(--cream);
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.rkrow i { font: 400 11px Silkscreen, monospace; font-style: normal; color: var(--dim); }
|
||||
/* ---- the name prompt. It is the only screen in the game that asks for typing, so the field is
|
||||
deliberately big, centred and already focused: on a phone this appears with the keyboard
|
||||
rising under it and there is no room for a second tap to find the input. */
|
||||
/* ---- typed fields. Two screens ask for typing — the name prompt and the netplay lobby — and
|
||||
both want the same field: big, centred, acid on the panel, no rounded corners anywhere near it.
|
||||
One class, two size overrides. */
|
||||
.cent { text-align: center; }
|
||||
.tin { display: block; width: 100%; box-sizing: border-box; margin: 0 auto;
|
||||
padding: 12px 10px; background: var(--solid); border: 2px solid var(--line);
|
||||
border-radius: 0; color: var(--acid); font: 700 16px Silkscreen, monospace;
|
||||
letter-spacing: 3px; text-align: center; text-transform: uppercase;
|
||||
-webkit-appearance: none; -webkit-tap-highlight-color: transparent; }
|
||||
.tin:focus { outline: none; border-color: var(--acid); }
|
||||
.tin::placeholder { color: var(--dim); letter-spacing: 2px; font-size: 12px; }
|
||||
#newbest h2 { font-size: 30px; margin-bottom: 6px; }
|
||||
#nbScore { margin: 0 0 22px; font: 400 11px Silkscreen, monospace; color: var(--dim);
|
||||
letter-spacing: 1px; }
|
||||
#nbScore b { color: var(--acid); font-weight: 400; }
|
||||
#nbName { display: block; width: 100%; box-sizing: border-box; margin: 0 auto 10px;
|
||||
padding: 14px 10px; background: var(--solid); border: 2px solid var(--line);
|
||||
border-radius: 0; color: var(--acid); font: 700 20px Silkscreen, monospace;
|
||||
letter-spacing: 3px; text-align: center; text-transform: uppercase;
|
||||
-webkit-appearance: none; -webkit-tap-highlight-color: transparent; }
|
||||
#nbName:focus { outline: none; border-color: var(--acid); }
|
||||
#nbName::placeholder { color: var(--dim); letter-spacing: 2px; font-size: 14px; }
|
||||
#nbName { margin-bottom: 10px; padding: 14px 10px; font-size: 20px; }
|
||||
#nbName::placeholder { font-size: 14px; }
|
||||
/* ---- THE NETPLAY LOBBY. Everything the mode needs before there is a court: who you are, the
|
||||
two ways to host, and the friend's code. The room you are waiting in replaces the picker in
|
||||
place (#vsWait), so the code and the SHARE button stand where the buttons were. */
|
||||
#vsErr:empty { display: none; }
|
||||
#vsErr { margin: 10px 0 0; padding: 8px; border: 2px solid #6e2a24; background: rgba(110,42,36,0.22);
|
||||
font: 400 10px Silkscreen, monospace; color: var(--bad); letter-spacing: 1px;
|
||||
text-align: center; }
|
||||
#vs .btn { margin-bottom: 8px; }
|
||||
/* the room takes the whole screen the picker had: the code floats in the middle band and CANCEL
|
||||
sits under your thumb, same three-band anchoring as every other screen */
|
||||
#vsWait { display: flex; flex-direction: column; flex: 1; }
|
||||
#vsWait[hidden] { display: none; }
|
||||
.joinrow { display: flex; gap: 8px; align-items: stretch; }
|
||||
.joinrow .tin { flex: 1; letter-spacing: 8px; }
|
||||
/* JOIN is the act, not a label beside a box: the crimson plate every primary button in the game
|
||||
wears, and NO padding of its own — the row stretches it to exactly the field's height, which
|
||||
its own 13px padding could only ever approximate. */
|
||||
/* #vs .btn's margin-bottom outranks a bare .joinrow .btn — and a margin on a stretched flex item
|
||||
is exactly the 8px the button was coming up short by */
|
||||
#vs .joinrow .btn { flex: none; width: 104px; margin: 0; padding: 0 6px; background: var(--red);
|
||||
border-color: #69201c; color: #f6e7dc; font: 400 22px Bangers, cursive;
|
||||
letter-spacing: 2px; }
|
||||
/* the code is the biggest thing on the screen — it is read aloud across a room as often as it is
|
||||
shared, and a four-letter code you have to squint at is the whole friction being removed */
|
||||
#vsBig { margin: 4px 0 20px; font: 700 42px Silkscreen, monospace; color: var(--acid);
|
||||
letter-spacing: 10px; text-indent: 10px; }
|
||||
#vsMsg { margin-top: 12px; }
|
||||
/* the h3 rows are label-left/hint-right everywhere else; this one has no hint and sits over a
|
||||
centred code, so it centres too (text-align cannot reach it — the row is a flex box) */
|
||||
#vsWHdr { justify-content: center; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -509,6 +542,37 @@ if (location.hostname !== 'splatter-tennis.wuwi.one') {
|
||||
<button class="btn big" id="cupGo" disabled>ENTER THE DRAW</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- VS A FRIEND. The whole lobby: your name (the world board's name — one name per player),
|
||||
the host's one decision, and the friend's code. #vsWait replaces the picker in place once
|
||||
a socket is open, so the room code and its SHARE button stand where the buttons were and
|
||||
the game stays parked behind this overlay until the room fills (main.js, THE LOBBY). -->
|
||||
<div id="vs">
|
||||
<div class="hdr"><button class="chev" id="vsBack">‹</button><h2>VS A FRIEND</h2></div>
|
||||
<div id="vsPick">
|
||||
<h3>01 · YOUR NAME<span>ON THEIR SCOREBOARD</span></h3>
|
||||
<input id="vsName" class="tin" maxlength="12" placeholder="YOUR NAME" autocomplete="off"
|
||||
autocapitalize="characters" spellcheck="false">
|
||||
<h3>02 · HOST A MATCH<span>THEY COME TO YOU</span></h3>
|
||||
<button class="btn" id="vsHostC">CLASSIC TENNIS</button>
|
||||
<button class="btn" id="vsHostD">DEATH MATCH</button>
|
||||
<h3>OR JOIN THEIRS<span>WITH THEIR CODE</span></h3>
|
||||
<div class="joinrow">
|
||||
<input id="vsCode" class="tin" maxlength="4" placeholder="CODE" autocomplete="off"
|
||||
autocapitalize="characters" spellcheck="false">
|
||||
<button class="btn" id="vsJoin">JOIN</button>
|
||||
</div>
|
||||
<p id="vsErr"></p>
|
||||
</div>
|
||||
<div id="vsWait" hidden>
|
||||
<div class="mid cent">
|
||||
<h3 id="vsWHdr" class="cent">ROOM CODE</h3>
|
||||
<p id="vsBig">····</p>
|
||||
<button class="btn big" id="vsShare">SHARE THE LINK</button>
|
||||
<p class="none cent" id="vsMsg">WAITING FOR THEM TO JOIN</p>
|
||||
</div>
|
||||
<div class="foot bare"><button class="btn" id="vsCancel">CANCEL</button></div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- BODY COUNT. Every row in here is built by stats.js from the run records in localStorage. -->
|
||||
<div id="stats">
|
||||
<div class="hdr"><button class="chev" id="stBack">‹</button><h2 id="stTitle">BODY COUNT</h2></div>
|
||||
@@ -581,6 +645,6 @@ if (location.hostname !== 'splatter-tennis.wuwi.one') {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script type="module" src="src/main.js?v=265"></script>
|
||||
<script type="module" src="src/main.js?v=266"></script>
|
||||
</body>
|
||||
</html>
|
||||
+78
-42
@@ -6,7 +6,7 @@
|
||||
import { TUNE, COURTS, TOURS, KITS, SKINS, MODES, MENU, DIFFS, EATEN_RESTART, DONE_RESTART, WEAPONS, bossArt, CUP_ROUNDS, matchFoe } from './config.js';
|
||||
import { P, O, ball, score, G, setupPoint, selectCourt, selectTour, selectKit, selectSkin } from './state.js';
|
||||
import { update, restartRun, keyPressed, solveShot, inServeBox, inCourt, forceZombies, startGore, 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, startNetVs } from './beats.js';
|
||||
import { startDuel, resolveInterlude, startBossFight, endBossFight, startDrill, endDrill, startMatch, endMatch, startBench, endBench, FALL, benchFall, startNetVs, endNetVs } from './beats.js';
|
||||
import { netConnect, netHooks } from './net.js';
|
||||
import { fitViewport, render, proj, RES, SPRV, LOAD, SPR, drawLook } from './render.js';
|
||||
import { voice, ambient, menuMusic, menuDuck } from './audio.js';
|
||||
@@ -100,39 +100,11 @@ 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.
|
||||
// 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.
|
||||
// The host's one decision — classic or the death match — rides the room, so the joiner never picks
|
||||
// a mode: the start message carries it to both phones and both courts are rebuilt on pairing.
|
||||
function netChip(label, join, death = false) {
|
||||
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'); // the waiting court is classic either way; a death room swaps it on pairing
|
||||
// the host's court rides the room too (server.py) — the guest plays where the host plays
|
||||
if (startNetVs()) netConnect(code, death, picked);
|
||||
});
|
||||
return b;
|
||||
}
|
||||
|
||||
// (NETPLAY is the exception and has a screen of its own — see THE LOBBY below.)
|
||||
function buildSub(top) {
|
||||
modeSub.innerHTML = '';
|
||||
const t = top || topOf(pickedMode);
|
||||
document.getElementById('subTitle').textContent = t.diffs ? 'DIFFICULTY' : (t.name || '');
|
||||
if (t.net) {
|
||||
modeSub.appendChild(netChip('HOST — CLASSIC TENNIS', false));
|
||||
modeSub.appendChild(netChip('HOST — DEATH MATCH', false, true));
|
||||
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';
|
||||
@@ -161,9 +133,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; }
|
||||
// the netplay poster opens THE LOBBY and never touches pickedMode — it is not a run mode
|
||||
// the stats screen or the summary could name, and the lobby starts the game itself
|
||||
if (t.net) { vsOpen(); 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();
|
||||
@@ -176,6 +148,75 @@ function buildTops() {
|
||||
buildTops();
|
||||
buildSub();
|
||||
|
||||
// ---- THE LOBBY. Oliver, 2026-08-15: *"multiplayer should be easier to setup — a share with link
|
||||
// next to the code; entering the code is done in some plain js confirm dialog thingy; somewhere
|
||||
// when hosting or also when joining the player should name themselves."* All three are one screen.
|
||||
//
|
||||
// WHY IT IS A MENU SCREEN AND NOT A COURT. Hosting used to drop you straight onto an empty court
|
||||
// and show the code in a canvas modal — which cannot carry a SHARE button, cannot be typed into,
|
||||
// and turns a mistyped code into a CONNECTION LOST card over a match that never started. The
|
||||
// socket is now opened from the menu and the game stays parked behind the overlay until the room
|
||||
// actually fills: `netHooks.paired` is what takes the court, one frame before netStart writes to
|
||||
// it. Everything else about the protocol is untouched.
|
||||
//
|
||||
// THE NAME IS THE WORLD BOARD'S NAME (stats.saveName). One name per player, wherever it shows.
|
||||
const vsPick = document.getElementById('vsPick'), vsWait = document.getElementById('vsWait');
|
||||
const vsName = document.getElementById('vsName'), vsCode = document.getElementById('vsCode');
|
||||
const vsSay = (id, text) => { document.getElementById(id).textContent = text; };
|
||||
// the link IS the invitation: the joiner's phone opens the lobby with the code already in the field
|
||||
const vsLink = code => location.origin + location.pathname + '#net-' + code;
|
||||
|
||||
function vsOpen(code = '') {
|
||||
vsName.value = stats.savedName();
|
||||
vsCode.value = code;
|
||||
vsPick.hidden = false; vsWait.hidden = true;
|
||||
vsSay('vsErr', '');
|
||||
go('vs');
|
||||
}
|
||||
// join === null hosts; `death` is the host's one decision and rides the room to both phones
|
||||
function vsGo(join, death = false) {
|
||||
stats.saveName(vsName.value); // whatever is in the field is who the other phone sees
|
||||
const code = join ? stats.tidyName(vsCode.value).replace(/[^A-Z0-9]/g, '') : null;
|
||||
if (join && code.length < 4) { vsSay('vsErr', 'THE CODE IS FOUR LETTERS'); return; }
|
||||
if (!startNetVs()) return; // the court-ownership contract, same as every other mode
|
||||
netConnect(code, death, picked); // the host's court rides the room (server.py)
|
||||
vsPick.hidden = true; vsWait.hidden = false;
|
||||
vsSay('vsWHdr', join ? 'JOINING' : 'ROOM CODE');
|
||||
vsSay('vsBig', join ? code : '····');
|
||||
vsSay('vsMsg', join ? 'KNOCKING ON THEIR DOOR' : 'WAITING FOR THEM TO JOIN');
|
||||
document.getElementById('vsShare').hidden = !!join; // nothing to share on the way in
|
||||
}
|
||||
document.getElementById('vsHostC').addEventListener('click', () => vsGo(null));
|
||||
document.getElementById('vsHostD').addEventListener('click', () => vsGo(null, true));
|
||||
document.getElementById('vsJoin').addEventListener('click', () => vsGo(true));
|
||||
vsCode.addEventListener('keydown', e => { if (e.key === 'Enter') vsGo(true); });
|
||||
// SHARE, then COPY. navigator.share is the phone's own sheet (WhatsApp, Signal, wherever the
|
||||
// friend actually is) and is exactly the "easier setup" — the clipboard is the desktop fallback,
|
||||
// and the code stays on screen for the two of them in the same room reading it aloud.
|
||||
document.getElementById('vsShare').addEventListener('click', async () => {
|
||||
const url = vsLink(G.net && G.net.code || '');
|
||||
try {
|
||||
if (navigator.share) await navigator.share({ title: 'SPLATTER TENNIS', text: 'COME AND PLAY ME', url });
|
||||
else { await navigator.clipboard.writeText(url); vsSay('vsMsg', 'LINK COPIED — SEND IT TO THEM'); }
|
||||
} catch { /* a cancelled share sheet is not an error */ }
|
||||
});
|
||||
const vsQuit = () => { endNetVs(); vsOpen(); }; // a room you walked out of must give its socket back
|
||||
document.getElementById('vsCancel').addEventListener('click', vsQuit);
|
||||
document.getElementById('vsBack').addEventListener('click', () => {
|
||||
if (!vsWait.hidden) return vsQuit(); // waiting: the chevron cancels rather than leaves
|
||||
endNetVs(); goMode();
|
||||
});
|
||||
// the three hooks net.js calls back through — all of them no-ops once the court has been taken
|
||||
netHooks.paired = () => start('classic'); // start() refuses when the menu is already down (URL entry)
|
||||
netHooks.room = code => { vsSay('vsBig', code); vsSay('vsMsg', 'WAITING FOR THEM TO JOIN'); };
|
||||
netHooks.lost = text => {
|
||||
if (!menuUp()) return false; // mid-match: the court's own card says it
|
||||
endNetVs();
|
||||
vsPick.hidden = false; vsWait.hidden = true;
|
||||
vsSay('vsErr', text);
|
||||
return true;
|
||||
};
|
||||
|
||||
// --- tour tiles, built from the table. Same "tap selects, SERVE UP starts" rule as the court rows —
|
||||
// the setup screen picks WHO and WHERE, and nothing on it commits you.
|
||||
const tourList = document.getElementById('tours');
|
||||
@@ -733,15 +774,10 @@ 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 classic, #netd hosts a death match, #net-ABCD joins — the netplay doors from a URL
|
||||
if (location.hash === '#netd') setTimeout(() => {
|
||||
start('classic');
|
||||
if (startNetVs()) netConnect(null, true, picked);
|
||||
}, 700);
|
||||
else if (location.hash.startsWith('#net')) setTimeout(() => {
|
||||
start('classic');
|
||||
if (startNetVs()) netConnect(location.hash.slice(5).toUpperCase() || null, false, picked);
|
||||
}, 700);
|
||||
// #net opens the lobby, #net-ABCD opens it with a friend's code already in the field — that link
|
||||
// IS what the SHARE button sends, so the invited phone lands one tap from the court (the tap is
|
||||
// not ceremony: it is where they see, and can change, the name the other phone will call them).
|
||||
if (location.hash.startsWith('#net')) setTimeout(() => vsOpen(location.hash.slice(5).toUpperCase()), 700);
|
||||
|
||||
requestAnimationFrame(frame);
|
||||
|
||||
|
||||
+33
-16
@@ -19,14 +19,19 @@
|
||||
// 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, showModal, tossBall } from './gameplay.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 here, so this module never has to know how a splatter court is
|
||||
// entered (the transition hack lives in main). Headless tests leave it empty and net falls back to
|
||||
// the minimal direct entry, which is exactly what the suite's own freshSplatter does.
|
||||
export const netHooks = { intoSplatter: null };
|
||||
// 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
|
||||
@@ -115,10 +120,16 @@ function netSync(m) {
|
||||
}
|
||||
|
||||
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, 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 });
|
||||
// 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
|
||||
@@ -159,6 +170,9 @@ function netDead(text) {
|
||||
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 };
|
||||
}
|
||||
|
||||
@@ -166,12 +180,12 @@ 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'] });
|
||||
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);
|
||||
else if (m.t === 'gone') netDead('NO SUCH ROOM — RELOAD AND HOST ONE');
|
||||
// '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);
|
||||
@@ -179,13 +193,16 @@ export function netMsg(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. 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 — the match still plays.
|
||||
// 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',
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+5
-1
@@ -2519,7 +2519,11 @@ export function render() {
|
||||
// ...and the match names the thing across the net rather than calling it THEM. Eight characters,
|
||||
// which is the name slot — see `short` on the opponent rows in config.
|
||||
const foe = G.match && zombies.find(z => z.variant === 'r' && z.foe);
|
||||
const names = G.match ? ['YOU', (foe && foe.foe.short) || 'THEM']
|
||||
// NETPLAY NAMES THE PERSON, in either mode — the friend typed it into the lobby and it crossed
|
||||
// on the look message. FRIEND until it lands (or an older build never sends one), never CPU:
|
||||
// there is no CPU in this mode and the board saying so was the netplay prototype's last lie.
|
||||
const names = G.net ? ['YOU', (G.net.look && G.net.look.name) || 'FRIEND']
|
||||
: G.match ? ['YOU', (foe && foe.foe.short) || 'THEM']
|
||||
: splat ? ['YOU', 'THEM'] : ['YOU', 'CPU'];
|
||||
for (let i = 0; i < 2 && !benchOn; i++) {
|
||||
const cy = 12 + i * 18, svr = (score.server === 'P') === (i === 0); // rows aligned to the art's two bands
|
||||
|
||||
+8
-2
@@ -208,13 +208,19 @@ export async function board(mode = MODE_GORE) {
|
||||
// appear on the board.
|
||||
export const tidyName = s =>
|
||||
String(s).toUpperCase().replace(/[^A-Z0-9 ._-]/g, '').trim().slice(0, 12);
|
||||
// ...and the one place it is remembered. The world board and the netplay lobby ask for the same
|
||||
// name and must not keep two of them: whoever you signed the board as is who the other phone sees.
|
||||
export const saveName = s => {
|
||||
const name = tidyName(s);
|
||||
try { localStorage.setItem(NAME_KEY, name); } catch {} // type it once, ever
|
||||
return name;
|
||||
};
|
||||
|
||||
// `k` is the BOARD's number and `g` its second column — what those mean is the mode's business
|
||||
// (BOARDS), and the server only ever sorts them. `m` is which board the row goes on.
|
||||
export async function submit(rawName, k, g, mode = MODE_GORE) {
|
||||
const name = tidyName(rawName);
|
||||
const name = saveName(rawName);
|
||||
if (!name) throw new Error('empty name');
|
||||
try { localStorage.setItem(NAME_KEY, name); } catch {} // type it once, ever
|
||||
const r = await fetch('api/scores', { method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ n: name, k, g, m: mode, id: myId() }) });
|
||||
|
||||
@@ -1645,6 +1645,19 @@ test('netplay: your look goes over the wire on pairing, and theirs comes back',
|
||||
state.selectKit(cfg.KITS[0].id); state.selectSkin(cfg.SKINS[0].id); // leave the table as found
|
||||
});
|
||||
|
||||
// THE NAME RIDES THE SAME MESSAGE — the lobby's one typed field, on the other phone's scoreboard.
|
||||
// It is a stranger's string landing in an 8-character slot, so it is normalised at the receiving
|
||||
// end (the same tidyName the world board and server.py's clean() agree on), never as sent.
|
||||
test('netplay: the friend names themselves, and the name lands tidy in the board\'s slot', () => {
|
||||
freshNet('A');
|
||||
const look = netTest.sent.find(m => m.t === 'look');
|
||||
assert.equal(look.n, statsMod.savedName(), 'this phone announces the name it remembers');
|
||||
netMsg({ t: 'look', k: 'red', s: 'pale', n: ' <b>bob</b>ranathan ' });
|
||||
assert.equal(G.net.look.name, 'BBOBBRAN', 'upper, stripped and cut to the eight-character slot');
|
||||
netMsg({ t: 'look', k: 'red', s: 'pale' }); // an older build sends no name at all
|
||||
assert.equal(G.net.look.name, 'FRIEND', '...and an unnamed opponent is FRIEND, never CPU');
|
||||
});
|
||||
|
||||
test('every kit and skin in the tables is a live, distinct pick', () => {
|
||||
for (const table of [cfg.KITS, cfg.SKINS]) {
|
||||
assert.equal(new Set(table.map(e => e.id)).size, table.length, 'ids are unique');
|
||||
|
||||
Reference in new issue
Block a user