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:
1 parent
b0ea694733
commit
3f206f6088
5 files changed
+140
-34
No files matched your search
+17
@@ -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
@@ -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
@@ -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
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user