From b0ea6947339f51ad77cf5f412ee1f365cd0cf108 Mon Sep 17 00:00:00 2001 From: Oliver Rieger Date: Fri, 16 Oct 2026 17:08:00 +0200 Subject: [PATCH] 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 --- PROJECT.md | 20 ++++++++++++++++++++ index.html | 4 ++-- src/beats.js | 22 ++++++++++++++++------ src/main.js | 13 ++++++++----- src/render.js | 50 ++++++++++++++++++++++++++------------------------ test.js | 25 +++++++++++++++++++++++++ 6 files changed, 97 insertions(+), 37 deletions(-) diff --git a/PROJECT.md b/PROJECT.md index 232066f..21daeb0 100644 --- a/PROJECT.md +++ b/PROJECT.md @@ -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 diff --git a/index.html b/index.html index a338ced..9238063 100644 --- a/index.html +++ b/index.html @@ -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 - + diff --git a/src/beats.js b/src/beats.js index ab00947..1121e66 100644 --- a/src/beats.js +++ b/src/beats.js @@ -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')) { diff --git a/src/main.js b/src/main.js index 421c334..296a1f0 100644 --- a/src/main.js +++ b/src/main.js @@ -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 = `
@@ -798,7 +799,7 @@ function storyScreen(kind, d) {
${['', 'ONE', 'TWO', 'THREE', 'FOUR', 'FIVE'][R.length]} ROUNDS
${R.map((r, i) => { const f = matchFoe(r.band[0]); const st = i < d.round ? 'WON' : i === d.round ? 'NEXT' : ''; - return `
+ return `
${r.name}
${f.name}
${st}
`; }).join('')}
TAP
@@ -819,7 +820,7 @@ function storyScreen(kind, d) { ${WEAPONS[e.id].label} ×${e.n}
`).join('')}
CHAPTER ${ROMAN[act]} · ${STORY.length} GAMES
-
${Array.from({ length: STORY.length }, (_, k) => ``).join('')}
+
${Array.from({ length: STORY.length }, (_, k) => ``).join('')}
${STORY.midAt} · ${mid}${STORY.length} · ???
`; @@ -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 => { diff --git a/src/render.js b/src/render.js index 131196b..aaa61d7 100644 --- a/src/render.js +++ b/src/render.js @@ -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); diff --git a/test.js b/test.js index fffb419..5419b0b 100644 --- a/test.js +++ b/test.js @@ -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(); });