From 8face714a6fc13a1b2971cd678a555fc03188d18 Mon Sep 17 00:00:00 2001 From: Oliver Rieger Date: Fri, 14 Aug 2026 17:44:00 +0200 Subject: [PATCH] The toss travels: the receiver watches the serve coming MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One event on the toss, replayed as a real toss over O — update() already poses the server off the phase and drops an uncaught ball back to pretoss by plain physics, so the wind-up, the cancel and the re-toss all carry for free. The strike's shot event overwrites whatever drifted. Co-Authored-By: Claude Fable 5 --- index.html | 2 +- src/gameplay.js | 7 +++++-- src/net.js | 13 ++++++++++++- test.js | 18 ++++++++++++++++++ 4 files changed, 36 insertions(+), 4 deletions(-) diff --git a/index.html b/index.html index 453f9c0..0ce43dd 100644 --- a/index.html +++ b/index.html @@ -494,6 +494,6 @@ if (location.hostname !== 'splatter-tennis.wuwi.one') { - + diff --git a/src/gameplay.js b/src/gameplay.js index fe1332d..0999956 100644 --- a/src/gameplay.js +++ b/src/gameplay.js @@ -9,7 +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 +import { netSendShot, netSendToss, netTick, netJudging, netSendPoint, netSendFault, netClose } from './net.js'; // runtime-only cycle: net calls back into pointTo/fault/tossBall/showModal // ------------------------------------------------------------- ballistics ---- // Launch ball from (x0,y0,z0) so it arrives at (tx, tz) with horizontal speed s under gravity g. @@ -232,7 +232,7 @@ function doHit(ch, quality, aim) { } // ----------------------------------------------------------------- serve ---- -function tossBall(sv) { +export function tossBall(sv) { // exported for net.js: a remote toss replays as a real toss over O ball.active = true; ball.bounces = 0; ball.collected = false; // The ball rises over the tossing HAND, not the head — TOSS.dx/oDx are measured off the toss // frames' raised fingertips (see config). `* facing` because drawChar mirrors the whole sprite @@ -250,6 +250,9 @@ function tossBall(sv) { // same latent bug for anyone tossing from deep behind the line. ball.lastHit = sv.side; G.phase = 'toss'; + // netplay: the toss travels too, so the receiver watches the serve COMING instead of a ball + // appearing from nothing — return timing is half the sport and it needs the wind-up + if (G.net && sv.side === 'P') netSendToss(); } export function serveApexY() { return TOSS.y0 + TOSS.vy * TOSS.vy / (2 * TUNE.gravity); } // ~2.95 diff --git a/src/net.js b/src/net.js index 6d70842..51e8c72 100644 --- a/src/net.js +++ b/src/net.js @@ -19,7 +19,7 @@ // 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'; +import { pointTo, fault, showModal, tossBall } from './gameplay.js'; let ws = null, hb = 0, posT = 0; let applying = false; // a remote verdict being applied must not echo back out @@ -39,6 +39,16 @@ function send(m) { 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' }); }; + +// 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() { @@ -106,6 +116,7 @@ export function netMsg(m) { 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 === 'toss') netApplyToss(); else if (m.t === 'shot') netApplyShot(m); else if (m.t === 'pt') netApplyPoint(m); else if (m.t === 'fault') netApplyFault(); diff --git a/test.js b/test.js index 8e11040..f746162 100644 --- a/test.js +++ b/test.js @@ -1239,6 +1239,24 @@ test('netplay: an outbound ball is picture — this phone never judges its own s assert.ok(!netTest.sent.some(m => m.t === 'pt'), 'applying a verdict does not echo one back'); }); +test('netplay: the toss travels — the receiver watches the serve coming', () => { + freshNet('A'); // this phone serves: its toss must go over the wire + keyPressed('Space'); + assert.equal(G.phase, 'toss', 'the tap tossed'); + assert.ok(netTest.sent.some(m => m.t === 'toss'), 'the toss is an event'); + freshNet('B'); // ...and on the receiving phone + netMsg({ t: 'toss' }); + assert.equal(G.phase, 'toss', 'their toss is a live toss here'); + assert.ok(ball.active && ball.lastHit === 'O' && ball.vy > 0, 'a real ball rising over O'); + assert.ok(ball.z < COURT.netZ, 'on the far half'); + for (let i = 0; i < 300 && G.phase === 'toss'; i++) update(DT); // nobody strikes it + assert.equal(G.phase, 'pretoss', 'an uncaught remote toss falls back by plain physics'); + netMsg({ t: 'toss' }); // the re-toss, then the strike over it + netMsg({ t: 'shot', x: 1.3, y: 2.6, z: 24.3, vx: -2, vy: 2, vz: -30, g: 12, pow: 0.8, serve: true }); + assert.equal(G.phase, 'rally', 'the strike lands cleanly over the replayed toss'); + assert.ok(ball.vz > 0, 'and the serve is inbound'); +}); + test('netplay: a serve fault is the receiver\'s call, and re-arms the server\'s second serve', () => { freshNet('B'); // they serve — long, out of the box netMsg({ t: 'shot', x: 0.5, y: 2.6, z: 24.3, vx: 0, vy: 1, vz: -35, g: 9.2, pow: 1, serve: true });