diff --git a/PROJECT.md b/PROJECT.md index 1eb9487..1d2bdb9 100644 --- a/PROJECT.md +++ b/PROJECT.md @@ -636,6 +636,21 @@ Each phase ships to staging and gets a phone verdict, and tests ride along with 5. **LATER.** The metal bed (when Oliver has a track: start at the transformation, gore only) and Members' Night (option C). +## Session log: the burning ball and the gore grade (2026-10-16, v389) + +Oliver picked two of the polish ideas (1 and 5; weather only after he has seen a preview): +- **The burning ball** (idea 1, reshaped by the old verdict): the spark ring around a perfect contact was + "too much" once (see the comment where it lived in render), so nothing is drawn AROUND the contact. + A perfect strike (rally or serve, yours only) stamps `ball.hotT = G.idleT`; render draws the ball + white and 1.5x through the 50 ms hitstop (the impact frame), a warm additive halo cooling over + `TUNE.hotGlowT` 0.45 s, and the ghost streak in gold, at least six long, for the whole flight. Any other + hit and every toss clear it. Test: every perfect strike burns, the reply is cold. +- **The gore grade** (idea 5): when the slaughter owns the court (splatter/eaten, no match, no bench or + drill) the world pass gets a `saturation` grey fill and a `multiply` blood cast, weight eased in over + ~1.5 s; `TUNE.goreGrade` 0.5 (1.0 tried — the zombies lose their green). The HUD is drawn after it + and stays clean. In the story it starts at the TURN (`morph.boss` set), so the boss growing is the + moment the picture drains — the metal follows at `run`. + ## Session log: the cup's tableau returns, the act card shows its court (2026-10-16, v388) Oliver after a full replay: "das im Storyboard benannte 03 Turnier-Tableau sehe ich nie" — right, phases diff --git a/index.html b/index.html index faedf1a..cc3f685 100644 --- a/index.html +++ b/index.html @@ -1211,6 +1211,6 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat - + diff --git a/src/config.js b/src/config.js index 2bb3237..307696d 100644 --- a/src/config.js +++ b/src/config.js @@ -63,6 +63,8 @@ export const TUNE = { faceFlip: 0.13, // s an auto-steered direction must persist before the body mirrors (earnFlip). // The rolled cycles hold a real racket, and an un-earned flip teleports it // across the body — the "racket flashing left and right" report + hotGlowT: 0.45, // s: the halo a PERFECT strike's ball wears off the racket (render, the burning ball) + goreGrade: 0.5, // 0-1: how far the gore modes' colour grade goes (render: desaturate + blood cast) trailSpeed: 13, // m/s of horizontal pace at which a rally ball earns the ghost streak in every // mode (the pow>0.8 gate alone was only ever reached by the gore slingshot) oppBright: 1.3, // brightness baked into every OPPONENT sprite copy at load (render.brighten) — diff --git a/src/gameplay.js b/src/gameplay.js index da29f4f..ec352e1 100644 --- a/src/gameplay.js +++ b/src/gameplay.js @@ -434,6 +434,10 @@ function doHit(ch, quality, aim) { // CONTACT IS THE TICK. Only YOUR racket: the far side's strike is something you watch, not // something your hand did, and buzzing for both halves of every rally is the numbing failure mode. if (ch.side === 'P') haptic(quality > 0.999 ? 'medium' : 'light'); // a perfect hit is felt as one + // THE BURNING BALL (2026-10-16): a perfect strike's ball is marked for the whole flight — render + // draws it white-hot through the hitstop, haloed for hotGlowT, and streaks it gold. Any other + // hit (the reply included) clears it. + ball.hotT = quality > 0.999 && ch.side === 'P' ? G.idleT : null; if (quality > 0.999 && ch.side === 'P') { // the rally hit: a real 45 ms window (perfectMs) G.hitstop = 0.05; pock(0.5, 1600); const w = praiseWord(); // a rally hit IS the impact — the word pops right on the contact @@ -445,7 +449,7 @@ function doHit(ch, quality, aim) { // ----------------------------------------------------------------- serve ---- 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; + ball.active = true; ball.bounces = 0; ball.collected = false; ball.hotT = null; // 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 // when the server faces left, exactly like the idle bounce's offset. @@ -679,6 +683,7 @@ export function hitServe(sv, quality, aim = null, perfect = quality > 0.999) { if (sv.side === 'P') strikeKick(); if (sv.side === 'P') haptic(perfect ? 'medium' : 'light'); // the strike, same tick as a rally hit sv.pose = 'serve'; sv.poseT = 0; + ball.hotT = perfect && sv.side === 'P' ? G.idleT : null; // the burning ball, as doHit if (perfect && sv.side === 'P') { G.hitstop = 0.05; // the word is ARMED here and pops at the IMPACT — first body the ball touches, or its first diff --git a/src/render.js b/src/render.js index 2918014..86159a1 100644 --- a/src/render.js +++ b/src/render.js @@ -688,6 +688,21 @@ function drawBall(x, y, z, glint = false, bomb = false, flame = false) { ctx.fillStyle = '#fffbe0'; ctx.beginPath(); ctx.arc(bp.x, bp.y, Math.max(1, r * 0.42), 0, 7); ctx.fill(); return; } + // THE BURNING BALL: a perfect strike holds white-hot through its hitstop, then cools through a + // warm halo. On the ball, not around it — the spark ring that sat around the contact was "too much". + const hot = glint && ball.hotT != null ? Math.max(0, 1 - (G.idleT - ball.hotT) / TUNE.hotGlowT) : 0; + if (hot > 0) { + ctx.save(); ctx.globalCompositeOperation = 'lighter'; + for (const [rf, af] of [[2.8, 0.18], [1.8, 0.3]]) { + ctx.globalAlpha = af * hot; ctx.fillStyle = '#ffd860'; + ctx.beginPath(); ctx.arc(bp.x, bp.y, r * rf, 0, 7); ctx.fill(); + } + ctx.restore(); ctx.globalAlpha = 1; + } + if (glint && ball.hotT != null && G.hitstop > 0) { // the impact frame: one white, swollen ball + ctx.fillStyle = '#ffffff'; ctx.beginPath(); ctx.arc(bp.x, bp.y, r * 1.5, 0, 7); ctx.fill(); + return; + } ctx.fillStyle = UI.ball; ctx.beginPath(); ctx.arc(bp.x, bp.y, r, 0, 7); ctx.fill(); if (glint) { ctx.fillStyle = '#f8f8d0'; ctx.fillRect(bp.x - r * 0.3, bp.y - r * 0.5, Math.max(1, r * 0.5), Math.max(1, r * 0.5)); } } @@ -2553,6 +2568,7 @@ function drawButton(x, y, w, h, label, col, primary) { } // ------------------------------------------------------------------ frame ---- +let goreK = 0; // the gore grade's eased weight (render-owned, see THE GORE GRADE) const ballHist = []; // hot-ball ghost trail positions (render-owned, see the streak block in render) const AD_H = 14; // px tall the courtside ad strip is drawn — also the ceiling for centred HUD text // (FLASH_Y is gone — the flash centres on CARD_MID with everything else in the band now.) @@ -2772,9 +2788,11 @@ export function render() { // is the striker's only cue that the ball he just hit is quick. Sampled every frame, so a fast // ball's ghosts are ALSO further apart: pace shows as reach and as spacing at once. const bspd = Math.hypot(ball.vx, ball.vz); + // ...and a PERFECT strike's ball always streaks, gold and at least six long (the burning ball) + const burn = ball.hotT != null; const hot = ball.active && G.phase === 'rally' && !ball.collected - && ((ball.pow || 0) > 0.8 || bspd > TUNE.trailSpeed); - const ghosts = Math.max(3, Math.min(8, Math.round((bspd - TUNE.trailSpeed) / 6))); + && (burn || (ball.pow || 0) > 0.8 || bspd > TUNE.trailSpeed); + const ghosts = Math.max(burn ? 6 : 3, Math.min(8, Math.round((bspd - TUNE.trailSpeed) / 6))); if (hot) { ballHist.push({ x: ball.x, y: ball.y, z: ball.z }); if (ballHist.length > 10) ballHist.shift(); } else if (ballHist.length) ballHist.length = 0; if (hot) { @@ -2783,8 +2801,8 @@ export function render() { const p = proj(h.x, h.y, h.z); // 0.26 was tuned against the gore mode's dark rot palette; on the bright tennis lawn it // vanished entirely — "i see no ball trails" (Oliver, in the tournament) - ctx.globalAlpha = 0.4 * (1 - i / ghosts); - ctx.fillStyle = UI.ball; + ctx.globalAlpha = (burn ? 0.55 : 0.4) * (1 - i / ghosts); + ctx.fillStyle = burn ? (i < 2 ? '#fff4c0' : '#ffc040') : UI.ball; ctx.beginPath(); ctx.arc(p.x, p.y, Math.max(1.5, p.s * 0.09 * (0.95 - 0.6 * i / ghosts)), 0, 7); ctx.fill(); } ctx.globalAlpha = 1; @@ -2898,6 +2916,25 @@ export function render() { // holding his own racket is the picture. ctx.setTransform(RES, 0, 0, RES, 0, 0); // un-shake: the HUD and overlays stay rock steady + // THE GORE GRADE (2026-10-16): when the slaughter owns the court, the world drains toward grey and + // takes a blood cast — the picture's half of the metal coming in. It eases in over ~1.5 s, so the + // story's turn (the boss growing, m.boss set) bleeds into it instead of cutting. The world only; + // the HUD is drawn after it and stays clean. + { + const m = G.story && G.story.morph; + const gore = (G.mode === 'splatter' || G.mode === 'eaten') && !G.match && !G.bench && !G.drill && !(m && !m.boss); + goreK += ((gore ? 1 : 0) - goreK) * 0.03; + if (goreK < 0.002) goreK = 0; + const k = goreK * TUNE.goreGrade; + if (k > 0) { + ctx.save(); + ctx.globalCompositeOperation = 'saturation'; ctx.globalAlpha = k * 0.7; + ctx.fillStyle = '#808080'; ctx.fillRect(0, 0, W, H); + ctx.globalCompositeOperation = 'multiply'; ctx.globalAlpha = k; + ctx.fillStyle = '#ffa898'; ctx.fillRect(0, 0, W, H); + ctx.restore(); + } + } // bomb bloom: a warm blood-orange wash, not a white-out. At 0.85 white it blinded you on a phone // and hid the very gore eruption it was announcing. if (G.boomFlash > 0) { ctx.fillStyle = `rgba(255,150,70,${Math.min(1, G.boomFlash / 0.06) * 0.3})`; ctx.fillRect(0, 0, W, H); } diff --git a/test.js b/test.js index 2e00651..95f95c9 100644 --- a/test.js +++ b/test.js @@ -1310,6 +1310,35 @@ test('a groundstroke a little off PERFECT is a little worse, not a weak ball (no } finally { TUNE.autoAssist = assist; } }); +test('THE BURNING BALL: a perfect strike marks its ball for the flight, and the reply clears it', () => { + // render reads ball.hotT (the white-hot impact frame, the halo, the gold streak); the state is here + const assist = TUNE.autoAssist; TUNE.autoAssist = 1.15; + try { + startMatch(); G.interlude.introT = 0; G.match.opp = 6; + for (let i = 0; i < 2.2 * 60; i++) update(DT); + let armed = false, perfect = 0, marked = 0, cleared = 0, hot = false; + for (let i = 0; i < 60 * 60; i++) { + G.modal = null; G.modalQ.length = 0; G.hp = G.hpMax; G.match.opp = 6; + if (G.phase === 'pretoss' && !(G.interlude && G.interlude.introT > 0)) { if (i % 40 === 0) keyPressed('Space'); } + else if (G.phase === 'toss') { if (ball.vy < 0.4 && ball.y > 2.4) keyPressed('Space'); } + else if (G.phase === 'rally' && ball.active && ball.lastHit === 'O' && ball.vz > 0 && !armed) { + const c = ringCue(); + if (c && Math.abs(c.eta - c.sweet) < 0.5 / 60) { armed = true; keyPressed('Space'); } + } + const before = ball.lastHit; + update(DT); + if (armed && before === 'O' && ball.lastHit === 'P') { + armed = false; + if (G.hitstop > 0) { perfect++; if (ball.hotT != null) { marked++; hot = true; } } + } else if (armed && (!ball.active || G.phase !== 'rally')) armed = false; + if (hot && before === 'P' && ball.lastHit === 'O') { hot = false; if (ball.hotT == null) cleared++; } + } + assert.ok(perfect >= 5, 'a real sample of perfect strikes: ' + perfect); + assert.equal(marked, perfect, 'every perfect strike burns'); + assert.ok(cleared >= 3, 'the reply is a new, cold ball: ' + cleared); + } finally { TUNE.autoAssist = assist; } +}); + test('a TAP is timed from touch-down: a normal 120 ms tap on the gold ring still returns cleanly', () => { // Oliver, 2026-10-14: "manchmal scheine ich den Ball trotz gutem Timing nicht mehr zu bekommen". // A tap only proves it is a tap when the finger LIFTS, so it swung ~100-150 ms late — inside the