Polish after the phone test: grade the place only, a softer ball, the tableau waits, the last point is seen (v391)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Opus 5.5 committed 2026-10-16 17:08:00 +02:00
1 parent af8cd7a513
commit b0ea694733
6 files changed
+97 -37

No files matched your search

+20
View File
@@ -636,6 +636,26 @@ 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: Oliver's polish test (2026-10-16, v391)
- **The gore grade grades the PLACE only.** v389 filled the whole world pass, so blood, barrels and the ball
machine went pale with it. The grade now runs right after `drawBackdrop` (stands, boards, court) and
before anything stands on it; rects are oversized because the shake/zoom transform is live there. The
weather is no longer under it either.
- **The burning ball a shade softer:** halo 2.8x/0.18 + 1.8x/0.3 -> 2.4x/0.13 + 1.6x/0.22, impact frame
1.5x -> 1.3x, gold streak alpha 0.55 -> 0.48.
- **The tableau waits for its TAP** (no auto-close), and its frame sits on the round you are about to play
(`.cur`), not on the final. Found on the way: a screen tapped away early left its auto timer running and
it closed the NEXT screen (the act card's 2.8 s killed the tableau) — the auto close now rides
`styTimers`, which every close clears.
- **The last point is seen:** `storyMorph` no longer opens the loot screen on the winning frame. It waits
`MORPH_WAIT` 2.2 s (ball left flying, crowd up, flash THE CUP IS YOURS.) in `tickMorph` before the screen
pauses the world; the morph timeline starts after it as before. Test: >= 2 s between win and loot.
- **The loot screen's chapter track** had a fixed 10-column grid from the 10-game chapters; it takes
`STORY.length` columns now.
- **"RESUMING IN 99"** in the tennis->gore morph: the morph holds play on 99 s timers; the resuming clock
ignores `G.story.part === 'morph'`.
## Session log: the weather (2026-10-16, v390)
Shown first as a mock-up over real v389 frames (artifact "Splatter Tennis Wetter", five kinds, one per
+2 -2
View File
@@ -162,7 +162,7 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat
#sty .draw { width: 100%; display: grid; gap: 8px; }
#sty .draw > div { display: flex; align-items: center; gap: 12px; height: 64px; padding: 0 10px; text-align: left;
border: 1px solid #ffffff22; border-radius: 4px; background: #0006; }
#sty .draw > div.fin { border-color: #e0cc72; }
#sty .draw > div.cur { border-color: #e0cc72; box-shadow: 0 0 0 1px #e0cc72, 0 0 14px #e0cc7240; }
#sty .draw > div.done img, #sty .draw > div.done .mid { opacity: .5; }
#sty .draw img { height: 52px; width: 52px; }
#sty .draw .st { margin-left: auto; }
@@ -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=390"></script>
<script type="module" src="src/main.js?v=391"></script>
</body>
</html>
+16 -6
View File
@@ -1214,18 +1214,28 @@ function storyMorph(zb) {
G.match = null; G.matchFall = null;
if (zb) { zb.cine = true; zb.vx = zb.vz = 0; zb.pose = 'idle'; }
G.interlude = { introT: 99 }; // nothing serves, spawns or attacks through the scene
ball.active = false; G.phase = 'over'; G.overT = 99;
G.phase = 'over'; G.overT = 99; // (the ball is left to finish its flight)
G.story.part = 'morph';
G.story.morph = { t: 0, x: zb ? zb.x : 0, z: zb ? zb.z : -1, body: zb, boss: null, stock };
// THE LAST POINT IS SEEN (Oliver, 2026-10-16: "man sieht gar nicht mehr den letzten Punkt"): the loot
// screen waits MORPH_WAIT s over the won point — the ball lands, the crowd is up — before it pauses
// the world, and the timeline starts when it is gone
G.story.morph = { t: 0, x: zb ? zb.x : 0, z: zb ? zb.z : -1, body: zb, boss: null, stock,
wait: MORPH_WAIT, rounds: d.cup.rounds.length };
G.flash = { text: 'THE CUP IS YOURS.', t: MORPH_WAIT };
prof.story.tries = 0; prof.story.help = 0; // a checkpoint passed: the help offer starts over
// the ceremony: the loot screen pauses the world, and the timeline starts when it is gone
if (storyUI.show) storyUI.show('loot', { act, stock, rounds: d.cup.rounds.length });
else showModal({ title: 'THE CUP IS YOURS', color: UI.pick,
body: ['…AND THESE FOR THE HORDES TO COME', stock.map(e => WEAPONS[e.id].label).join(' · ')] });
}
const MORPH_WAIT = 2.2;
function tickMorph(dt) {
const m = G.story.morph, act = G.story.act;
if (m.wait > 0) {
if ((m.wait -= dt) > 0) return;
m.wait = 0; ball.active = false;
if (storyUI.show) storyUI.show('loot', { act, stock: m.stock, rounds: m.rounds });
else showModal({ title: 'THE CUP IS YOURS', color: UI.pick,
body: ['…AND THESE FOR THE HORDES TO COME', m.stock.map(e => WEAPONS[e.id].label).join(' · ')] });
return;
}
const t0 = m.t, t = (m.t += dt);
const at = k => t0 < MORPH[k] && t >= MORPH[k];
if (at('quiet')) {
+8 -5
View File
@@ -789,8 +789,9 @@ function storyScreen(kind, d) {
} else if (kind === 'draw') {
// THE TOURNAMENT TABLEAU (storyboard 03): the cup's rounds up the league, the final the act boss's
// body. After the act card it is the checkpoint (⚑ — a loss throws you back here); between rounds
// it stamps the round just won and points at the next face.
auto = 3.2;
// it stamps the round just won and points at the next face. It waits for the tap it asks for
// (v388 also closed itself after 3.2 s under a blinking TAP — Oliver: the tap should be real).
// The frame sits on the round you are about to play, not on the final.
glow = '#1a2030';
const R = storyRounds(act), last = R.length - 1;
html = `<div class="bgc dim" style="background-image:url(${art(court.tile)})"></div><div class="col">
@@ -798,7 +799,7 @@ function storyScreen(kind, d) {
<div class="big r" ${delay(1)}>${['', 'ONE', 'TWO', 'THREE', 'FOUR', 'FIVE'][R.length]} ROUNDS</div>
<div class="draw">${R.map((r, i) => { const f = matchFoe(r.band[0]);
const st = i < d.round ? '<span class="won">WON</span>' : i === d.round ? '<span class="gold tap">NEXT</span>' : '';
return `<div class="r${i === last ? ' fin' : ''}${i < d.round ? ' done' : ''}" ${delay(2 + i)}>
return `<div class="r${i === d.round ? ' cur' : ''}${i < d.round ? ' done' : ''}" ${delay(2 + i)}>
<img src="${foeArt(f)}" alt=""><div><div class="lab${i === last ? ' gold' : ''}">${r.name}</div>
<div class="mid">${f.name}</div></div><div class="lab st">${st}</div></div>`; }).join('')}</div>
<div class="lab tap">TAP</div>
@@ -819,7 +820,7 @@ function storyScreen(kind, d) {
<img src="${art(WEAPONS[e.id].icon)}" alt=""><span class="lab pink">${WEAPONS[e.id].label} ×${e.n}</span></div>`).join('')}</div>
<div class="r" style="width:100%;display:grid;gap:6px;animation-delay:${(0.9 + d.stock.length * 0.22).toFixed(2)}s">
<div class="lab hot">CHAPTER ${ROMAN[act]} · ${STORY.length} GAMES</div>
<div class="pips">${Array.from({ length: STORY.length }, (_, k) => `<i class="${k === STORY.midAt - 1 ? 'm' : k === STORY.length - 1 ? 'b' : ''}"></i>`).join('')}</div>
<div class="pips" style="grid-template-columns:repeat(${STORY.length},1fr)">${Array.from({ length: STORY.length }, (_, k) => `<i class="${k === STORY.midAt - 1 ? 'm' : k === STORY.length - 1 ? 'b' : ''}"></i>`).join('')}</div>
<div class="row"><span class="lab gold">${STORY.midAt} · ${mid}</span><span class="lab hot">${STORY.length} · ???</span></div></div>
<button class="btn big r" ${delay(9)} data-door="go">TO THE COURT</button>
</div>`;
@@ -864,7 +865,9 @@ function storyScreen(kind, d) {
styTimers.forEach(clearTimeout); styTimers = [];
res(door);
};
if (auto) setTimeout(() => styClose && styClose('auto'), auto * 1000);
// through styTimers, so a screen tapped away early cannot close the NEXT one with its own timer
// (the act card's 2.8 s used to fire into the tableau that followed it)
if (auto) at(auto, () => styClose && styClose('auto'));
});
}
sty.addEventListener('click', e => {
+26 -24
View File
@@ -694,14 +694,14 @@ function drawBall(x, y, z, glint = false, bomb = false, flame = false) {
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]]) {
for (const [rf, af] of [[2.4, 0.13], [1.6, 0.22]]) { // a shade softer after the phone test (v391)
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();
ctx.fillStyle = '#ffffff'; ctx.beginPath(); ctx.arc(bp.x, bp.y, r * 1.3, 0, 7); ctx.fill();
return;
}
ctx.fillStyle = UI.ball; ctx.beginPath(); ctx.arc(bp.x, bp.y, r, 0, 7); ctx.fill();
@@ -2608,6 +2608,26 @@ export function render() {
// the clap ripple is overlaid live inside. The ad strip sits flush on the court's back edge (the
// out-area quad reaches z=-6), which keeps the boards clear of the lawn in BOTH orientations.
drawBackdrop(pal, pt);
// THE GORE GRADE (2026-10-16): when the slaughter owns the court, the PLACE 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. It grades the
// BACKDROP only (stands, boards, court), right here before anything stands on it: v389 graded the
// whole world and Oliver found the blood, the barrels and the ball machine pale with it (v391).
{
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(-40, -40, W + 80, H + 80); // oversized: the shake and zoom are live here
ctx.globalCompositeOperation = 'multiply'; ctx.globalAlpha = k;
ctx.fillStyle = '#ffa898'; ctx.fillRect(-40, -40, W + 80, H + 80);
ctx.restore();
}
}
// (a random floodlight stutter darkened the stands here for one build — on the phone it read as
// the crowd area flickering black, i.e. as a rendering bug, not as ambience. Gone.)
@@ -2802,7 +2822,7 @@ 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 = (burn ? 0.55 : 0.4) * (1 - i / ghosts);
ctx.globalAlpha = (burn ? 0.48 : 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();
}
@@ -2917,32 +2937,13 @@ 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 WEATHER (src/weather.js): over the world, under the grade (a red sky in gore) and the HUD
// THE WEATHER (src/weather.js): over the world, under the HUD
{
const now = performance.now(), dt = Math.min(0.05, (now - (render.skyT || now)) / 1000);
render.skyT = now;
tickSky(dt, court.id);
drawWeather(ctx, W, H, proj, dt, 0);
}
// 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); }
@@ -3286,7 +3287,8 @@ export function render() {
// excluded on purpose — that one waits for YOU, and says so.
// 'transition' is excluded: startTransition parks overT at 9999 to hold the point flow while its
// own scripted timeline runs, and counting that down read as "resuming in 9999".
const waitT = G.modal || G.mode === 'eaten' || G.mode === 'transition' ? 0
// (the story's morph holds play on 99 s timers — a scripted scene, not a pause to count down)
const waitT = G.modal || G.mode === 'eaten' || G.mode === 'transition' || (G.story && G.story.part === 'morph') ? 0
: Math.max(G.changeover > 0 ? G.changeover : 0,
G.phase === 'over' ? G.overT : 0,
G.interlude ? G.interlude.introT : 0);
+25
View File
@@ -3785,6 +3785,31 @@ test('THE STORY: every round won shows the cup\'s tableau, stamped up to the nex
assert.deepEqual(shown.filter(k => k.startsWith('draw')), Array.from({ length: R - 1 }, (_, i) => 'draw:' + (i + 1)),
'one tableau per won round before the final (the act card opens round 0\'s)');
assert.equal(shown[0], 'act:', 'the act card comes first');
assert.ok(shown.includes('loot:'), 'the cup ends on its loot screen');
restartRun();
});
test('THE STORY: the won final is seen before the loot screen pauses the world', () => {
// Oliver, 2026-10-16: "man kommt sofort in den Screen mit den Waffen und sieht den letzten Punkt nicht"
const shown = [], was = storyUI.show;
storyUI.show = kind => { shown.push(kind); return Promise.resolve(); };
try {
intoStory(1, 'cup');
const cup = G.match.cup;
cup.round = cup.rounds.length - 1; cup.g = cup.rounds[cup.round].need - 1;
G.match.opp = cup.rung = cup.rounds[cup.round].band[0];
let morphAt = -1, lootAt = -1;
for (let i = 0; i < 60 * 60 && lootAt < 0; i++) {
G.modal = null; G.modalQ.length = 0; G.hp = G.hpMax;
if (G.match && !G.match.ending && !G.matchFall && G.phase === 'pretoss' && !(G.interlude && G.interlude.introT > 0))
resolveInterlude('P', 'test');
update(DT);
if (morphAt < 0 && G.story && G.story.part === 'morph') morphAt = i;
if (lootAt < 0 && shown.includes('loot')) lootAt = i;
}
assert.ok(morphAt >= 0 && lootAt > morphAt, 'the loot screen comes after the win');
assert.ok((lootAt - morphAt) * DT >= 2, 'about two seconds of the won point first: ' + ((lootAt - morphAt) * DT).toFixed(2));
} finally { storyUI.show = was; }
restartRun();
});