The walk-on card, weighted: who, how he plays, how you stand (v392)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Opus 5.5 committed 2026-10-16 17:16:34 +02:00
1 parent b0ea694733
commit 3f206f6088
5 files changed
+140 -34

No files matched your search

+17
View File
@@ -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
+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=391"></script>
<script type="module" src="src/main.js?v=392"></script>
</body>
</html>
+7 -4
View File
@@ -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
+100 -29
View File
@@ -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();
+15
View File
@@ -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;