From 3f206f6088e9c6a9eed2f3a37d70f776258eb8cd Mon Sep 17 00:00:00 2001 From: Oliver Rieger Date: Fri, 16 Oct 2026 17:16:34 +0200 Subject: [PATCH] The walk-on card, weighted: who, how he plays, how you stand (v392) Co-Authored-By: Claude Opus 5.5 --- PROJECT.md | 17 +++++++ index.html | 2 +- src/beats.js | 11 +++-- src/render.js | 129 ++++++++++++++++++++++++++++++++++++++------------ test.js | 15 ++++++ 5 files changed, 140 insertions(+), 34 deletions(-) diff --git a/PROJECT.md b/PROJECT.md index 21daeb0..fe8873d 100644 --- a/PROJECT.md +++ b/PROJECT.md @@ -636,6 +636,23 @@ 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 walk-on card, weighted (2026-10-16, v392) + +Oliver on the phone: "sehr viel Infos, alles gleich gewichtet". Previewed as the "Walk-on Karte" artifact +(his screenshot beside an HTML mock), taken as shown; round in the story: gone; no advice text per style. +`render.drawFoeCard` replaces the generic zoned modal for `m.foe` (the old 'split' row and the foe paths +in modalRows/modalLayout are deleted): +- head 56 px, centred, rising 55% of its height over the box's top edge; the name in Bangers 24, two + balanced lines when it does not fit (THE STRAWBERRY / SELLER would be the split — it fits at 24 in the + 244-px box, shrinking only below that); the slogan small and grey under it. +- the style as the second weight: Bangers 18 name + the tag's description, behind a 2-px warm bar. +- ONE chip for how you stand: green below you, yellow level-to-5-above, red past that, the numbers small + on its right (stepping down to `54/42`, then out, before they touch the words). New tier: EASY PREY at + 10+ levels under you (beats.matchOpponent). +- the eyebrow (`foe.brow`): the arcade cup's round (+ FIRST TO N GAMES); in the story only the length — + the tableau named the round. The climb's NOBODY STRONGER lines ride under the chip (`foe.sub`). +No rules between rows. Test: the story card has no round, a far weaker body is EASY PREY. + ## 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 diff --git a/index.html b/index.html index 9238063..a79d7a6 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/beats.js b/src/beats.js index 1121e66..8d1b3f6 100644 --- a/src/beats.js +++ b/src/beats.js @@ -703,19 +703,22 @@ function matchOpponent() { // stack was "a wall of text"). `foe` drives the drawing; title/body still carry the same // words for the suite and any legacy reader. The level pair is also the cup's readiness measure: // gameplay.peerK turns closing this gap into extra legs and timing, without weakening the body. - const sub = cup ? [cup.rounds[cup.round].name + (cup.rounds[cup.round].need > 1 - ? ' · FIRST TO ' + cup.rounds[cup.round].need + ' GAMES' : '')] + const need = cup && cup.rounds[cup.round].need > 1 ? 'FIRST TO ' + cup.rounds[cup.round].need + ' GAMES' : ''; + const sub = cup ? [cup.rounds[cup.round].name + (need ? ' · ' + need : '')] // the top of the band walking BACK on is the arcade's natural "what now?" moment — the // intro answers it in place (THE LEAGUES: the cup is the only promotion) : G.match.again ? ['NOBODY STRONGER IS LEFT', 'THE CUP IS THE WAY UP'] : []; const lvl = foeLevel(G.match.opp), mine = levelAt(prof.xp); const gap = lvl - mine; const peer = gap > 5 ? gap + ' LEVELS ABOVE YOU' : gap > 0 ? 'ALMOST EVEN' - : gap === 0 ? 'EVENLY MATCHED' : 'YOU HAVE THE EDGE'; + : gap === 0 ? 'EVENLY MATCHED' : gap <= -10 ? 'EASY PREY' : 'YOU HAVE THE EDGE'; + // the walk-on card's eyebrow: the round (and its length) in the arcade cup; in the story only the + // length — the tableau named the round a moment ago. The climb's lines ride under the chip. + const brow = cup ? (G.story ? need : sub[0]) : ''; showModal({ title: zb.foe.name, color: UI.hot, body: [zb.foe.line, 'LEVEL ' + lvl + ' — YOURS: ' + mine].concat(sub), foe: { head: 'z_' + zb.fam + 'head', name: zb.foe.name, line: zb.foe.line, - lvl, mine, peer, sub, style: STYLES[zb.foe.style]?.tag } }); + lvl, mine, peer, sub: cup ? [] : sub, brow, style: STYLES[zb.foe.style]?.tag } }); // ...and a fresh body has read NOTHING yet: the habit tracker starts clean (see THE READ, config) G.match.laneRun = 0; G.match.lastLane = undefined; G.match.surprised = G.match.expected = false; // a FLAT pool per body: with regicide gone the body is pure scoreboard (matchPoint takes a diff --git a/src/render.js b/src/render.js index aaa61d7..dfcd004 100644 --- a/src/render.js +++ b/src/render.js @@ -2397,14 +2397,7 @@ const MODAL_RULE = 'rgba(120,150,130,0.22)'; function modalRows(m, inner, skipBody) { const rows = []; const push = r => { rows.push(r); return r; }; - if (m.foe) { - push({ kind: 'split', h: m.foe.peer ? 38 : 26 }); - // HOW IT PLAYS (the styles, step 2): one bold line, because a plan needs to know what it faces - if (m.foe.style) { const lead = wrapText(m.foe.style, F(8, 700), inner); push({ kind: 'rule', lead, h: lead.length * 12 + 4 }); } - const sub = []; - for (const l of m.foe.sub || []) sub.push(...wrapText(l, F(8, 0), inner)); - if (sub.length) push({ kind: 'text', lines: sub, dim: true, h: sub.length * 12 + 2 }); - } else if (m.rules) { + if (m.rules) { // ONE RULE, ONE LINE (2026-08-31, the menu-text-reduction handoff). Every row used to carry a // statement and a consequence, and the consequence was usually the statement again in other // words ("YOUR LEAGUE, ROUND BY ROUND" / "WIN IT ALL — MOVE UP A LEAGUE"). The line that @@ -2431,11 +2424,11 @@ function modalRows(m, inner, skipBody) { // has to know whether it fits beside the art before either is placed. function modalLayout(m) { const boxW = Math.min(W - 44, 244), pad = MODAL_PAD; - const art = m.foe ? (m.foe.head && SPR[m.foe.head]) : (m.icon && SPR[m.icon]); + const art = m.icon && SPR[m.icon]; // (the walk-on draws its own card: drawFoeCard) // an illustration that shares its row with two lines of copy gives some height back, so the text // column stays wide enough to hold a sentence without orphaning its last word const bodyLines = (m.body || []).filter(l => l); - const pairs = !!art && !m.foe && !m.rules && bodyLines.length && bodyLines.length <= 3; + const pairs = !!art && !m.rules && bodyLines.length && bodyLines.length <= 3; const artH = art ? Math.round(Math.min(m.iconH || 46, pairs ? 42 : 46, H * 0.2)) : 0; const artW = art ? Math.round(artH * art.width / art.height) : 0; const headW = boxW - pad * 2 - (art ? artW + 10 : 0); @@ -2455,8 +2448,7 @@ function modalLayout(m) { // ONE line under the title: the body's slogan on a walk-on, the mode's own fact on a rules card // (`m.strap` — LEAGUE 3 · 4 ROUNDS, the round count the fixture button gave up), or the two // lines of an illustrated announcement. - const strap = m.foe ? wrapText(m.foe.line, F(8, 0), headW) - : m.strap ? wrapText(m.strap, F(8, 0), headW) : beside ? wrapped : []; + const strap = m.strap ? wrapText(m.strap, F(8, 0), headW) : beside ? wrapped : []; const headH = Math.max(artH, 22 + strap.length * 12); const rows = modalRows(m, boxW - pad * 2, beside); let h = pad + headH + MODAL_GAP + MODAL_FOOT + MODAL_BTN + pad; @@ -2465,6 +2457,100 @@ function modalLayout(m) { x: Math.round(W / 2 - boxW / 2), y: Math.round(H / 2 - h / 2) }; } +// THE WALK-ON CARD (2026-10-16, Oliver: "sehr viel Infos, alles gleich gewichtet"; previewed in the +// "Walk-on Karte" artifact and taken as shown). One question, answered in order of weight: WHO is this +// (the head over the box's top edge, the name), HOW does he play (the style, the one line that changes +// your game), and HOW do you stand (one chip whose colour says it before the words do). The slogan +// whispers under the name; the round is a small eyebrow, and in the story it is gone — the tableau +// showed it a moment ago. No rules between rows: spacing groups them. +const FOE_ART_H = 56; +const FOE_TIER = { good: ['#9fe0a8', 'rgba(23,51,31,0.95)'], even: [UI.warm, 'rgba(58,51,24,0.95)'], + bad: [UI.hot, 'rgba(59,26,23,0.95)'] }; +function foeLayout(m) { + const f = m.foe, boxW = Math.min(W - 44, 244), pad = MODAL_PAD, inner = boxW - pad * 2; + const art = f.head && SPR[f.head]; + const artH = art ? FOE_ART_H : 0, artW = art ? Math.round(artH * art.width / art.height) : 0; + const over = Math.round(artH * 0.55); // how far the head rises above the box + // the name: one line if it fits at 24, else two balanced lines (THE STRAWBERRY / SELLER) + ctx.font = B(24); + let name = [f.name]; + if (ctx.measureText(f.name).width > inner) { + const w = f.name.split(' '); let best = null; + for (let i = 1; i < w.length; i++) { + const a = w.slice(0, i).join(' '), b = w.slice(i).join(' '); + const wid = Math.max(ctx.measureText(a).width, ctx.measureText(b).width); + if (!best || wid < best.wid) best = { wid, lines: [a, b] }; + } + if (best) name = best.lines; + } + ctx.font = B(24); + const widest = Math.max(...name.map(l => ctx.measureText(l).width)); + const nSize = widest > inner ? Math.max(14, Math.floor(24 * inner / widest)) : 24; + const tag = wrapText(f.line, F(7, 0), inner); + const [sName, sDesc] = (f.style || '').split(' — '); + const desc = sDesc ? wrapText(sDesc, F(8, 700), inner - 8) : []; + const brow = f.brow ? wrapText(f.brow, F(7, 0), inner) : []; + const extra = (f.sub || []).flatMap(l => wrapText(l, F(7, 0), inner)); + let h = Math.max(pad, artH - over + 6); + h += brow.length * 10 + (brow.length ? 4 : 0); + h += name.length * (nSize + 1) + 4 + tag.length * 10; + if (sName) h += 14 + 18 + desc.length * 11 + 2; + h += 12 + 18; // the chip + if (extra.length) h += 8 + extra.length * 10; + h += 14 + MODAL_BTN + pad; + const top = Math.round(H / 2 - (h + over) / 2) + over; + return { boxW, pad, inner, art, artH, artW, over, name, nSize, tag, sName, desc, brow, extra, h, + x: Math.round(W / 2 - boxW / 2), y: top }; +} +function drawFoeCard(m, t, dx) { + const f = m.foe, L = foeLayout(m), x = L.x + dx, cx = x + L.boxW / 2, inX = x + L.pad; + panel(x, L.y, L.boxW, L.h, 4, MODAL_FILL, MODAL_EDGE); + if (L.art) { + ctx.imageSmoothingEnabled = false; + ctx.drawImage(L.art, Math.round(cx - L.artW / 2), L.y - L.over, L.artW, L.artH); + } + let y = L.y + Math.max(L.pad, L.artH - L.over + 6); + ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; + ctx.font = F(7, 0); + for (const l of L.brow) { shadowText(l, cx, y + 4, UI.dim); y += 10; } + if (L.brow.length) y += 4; + ctx.font = B(L.nSize); + for (const l of L.name) { shadowText(l, cx, y + L.nSize / 2, m.color || UI.hot); y += L.nSize + 1; } + y += 4; + ctx.font = F(7, 0); + for (const l of L.tag) { shadowText(l, cx, y + 4, '#9a9a90'); y += 10; } + if (L.sName) { + y += 14; + const top = y; + ctx.textAlign = 'left'; + ctx.font = B(18); shadowText(L.sName, inX + 8, y + 9, UI.cream); y += 18; + ctx.font = F(8, 700); + for (const l of L.desc) { shadowText(l, inX + 8, y + 6, UI.cream); y += 11; } + y += 2; + ctx.fillStyle = UI.warm; ctx.fillRect(inX, top, 2, y - top); + } + // HOW YOU STAND: one pill. Green below you, yellow level or up to five above, red past that. + y += 12; + const gap = f.lvl - f.mine, tier = gap > 5 ? 'bad' : gap >= 0 ? 'even' : 'good'; + const [fg, bg] = FOE_TIER[tier]; + panel(inX, y, L.inner, 18, 9, bg, null); + ctx.beginPath(); ctx.arc(inX + 10, y + 9, 2.5, 0, 7); ctx.fillStyle = fg; ctx.fill(); + ctx.textAlign = 'left'; ctx.font = F(8, 700); ctx.fillStyle = fg; + ctx.fillText(f.peer || '', inX + 18, y + 10); + const room = L.inner - 26 - ctx.measureText(f.peer || '').width - 8; + ctx.textAlign = 'right'; ctx.font = F(7, 0); + // the numbers step down to a bare pair (and then out) before they ever touch the words + const nums = [f.lvl + ' · YOU ' + f.mine, f.lvl + '/' + f.mine].find(n => ctx.measureText(n).width <= room); + ctx.globalAlpha = 0.8; if (nums) ctx.fillText(nums, inX + L.inner - 8, y + 10); ctx.globalAlpha = 1; + y += 18; + if (L.extra.length) { + y += 8; ctx.textAlign = 'center'; ctx.font = F(7, 0); + for (const l of L.extra) { shadowText(l, cx, y + 4, UI.dim); y += 10; } + } + if (t >= 1) drawButton(inX, y + 14, L.inner, MODAL_BTN, m.btn || 'WALK ON', m.color || UI.hot, true); + ctx.textAlign = 'center'; ctx.textBaseline = 'top'; +} + function drawModal(m) { // The entrance (m.in, 0..1, ticked in gameplay's update behind the world freeze). The box flies // in from the RIGHT as ONE object — the old per-line stagger would tear a box apart — and the @@ -2475,6 +2561,7 @@ function drawModal(m) { const dx = Math.round((1 - (p >= 1 ? 1 : easeBack(p))) * W * 0.9); ctx.fillStyle = `rgba(8,6,10,${(0.82 * Math.min(1, t / 0.35)).toFixed(3)})`; ctx.fillRect(0, 0, W, H); + if (m.foe) return drawFoeCard(m, t, dx); const L = modalLayout(m); const col = m.color || UI.warm; const x = L.x + dx, pad = L.pad, inX = x + pad; @@ -2515,29 +2602,13 @@ function drawModal(m) { ctx.strokeStyle = 'rgba(0,0,0,0.5)'; ctx.lineWidth = 1; ctx.stroke(); } } - } else if (r.kind === 'split') { - // HIS LEVEL AGAINST YOURS, on ONE line: his on the left, yours hard right. SAME FACE, SAME - // SIZE for both (Oliver) — a comparison drawn at two sizes is not a comparison, it is a - // headline with a footnote. Only the COLOUR differs, and only where it means something: his - // number wears the modal's hot colour while he outranks you, the pick green once he does not. - const f = m.foe; - ctx.font = F(10, 700); - ctx.textAlign = 'left'; - shadowText('LEVEL ' + f.lvl, inX, y + 9, f.lvl > f.mine ? col : UI.pick); - ctx.textAlign = 'right'; - shadowText('YOURS ' + f.mine, x + L.boxW - pad, y + 9, UI.dim); - if (f.peer) { - ctx.font = F(8, 700); ctx.textAlign = 'left'; - shadowText(f.peer, inX, y + 25, f.lvl > f.mine ? col : UI.pick); - } - ctx.textAlign = 'left'; } y += r.h + MODAL_GAP; } // THE FOOTER BUTTON — only once the box has landed. Offering the press mid-flight would be a // lie: dismissModal swallows the gesture until then. if (t >= 1) { - const label = m.btn || (m.foe ? 'WALK ON' : m.rules ? 'GOT IT' : 'PLAY ON'); + const label = m.btn || (m.rules ? 'GOT IT' : 'PLAY ON'); ctx.strokeStyle = MODAL_RULE; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(x + 6, y - Math.round(MODAL_GAP / 2) + 0.5); ctx.lineTo(x + L.boxW - 6, y - Math.round(MODAL_GAP / 2) + 0.5); ctx.stroke(); diff --git a/test.js b/test.js index 5419b0b..0682d5b 100644 --- a/test.js +++ b/test.js @@ -3789,6 +3789,21 @@ test('THE STORY: every round won shows the cup\'s tableau, stamped up to the nex restartRun(); }); +test('THE WALK-ON CARD: in the story it drops the round (the tableau named it), and a far weaker body is EASY PREY', () => { + const saved = prof.prof.xp; + prof.prof.xp = 1e7; // far above round one's body + prof.prof.story = { act: 1, cp: 'cup', rack: [], tries: 0 }; + G.mode = 'splatter'; G.everTransitioned = true; setupPoint(); + assert.ok(startStory()); + const f = [G.modal, ...G.modalQ].map(q => q && q.foe).find(Boolean); + G.modal = null; G.modalQ.length = 0; prof.prof.xp = saved; + assert.ok(f, 'the walk-on carries its card data'); + assert.ok(!f.brow.includes('ROUND'), 'no round on the story card: ' + JSON.stringify(f.brow)); + assert.deepEqual(f.sub, [], 'nothing rides under the chip in the cup'); + assert.equal(f.peer, 'EASY PREY', 'ten and more levels under you'); + 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;