The burning ball and the gore grade (v389)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Opus 5.5 committed 2026-10-16 15:41:02 +02:00
1 parent 0c67d32519
commit f18680d497
6 files changed
+94 -6

No files matched your search

+15
View File
@@ -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
+1 -1
View File
@@ -1211,6 +1211,6 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat
</div>
</div>
<span id="recLamp" hidden>REC</span>
<script type="module" src="src/main.js?v=388"></script>
<script type="module" src="src/main.js?v=389"></script>
</body>
</html>
+2
View File
@@ -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) —
+6 -1
View File
@@ -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
+41 -4
View File
@@ -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); }
+29
View File
@@ -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