The burning ball and the gore grade (v389)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
0c67d32519
commit
f18680d497
6 files changed
+94
-6
No files matched your search
+15
@@ -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
@@ -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>
|
||||
@@ -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
@@ -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
@@ -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); }
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user