diff --git a/PROJECT.md b/PROJECT.md index a9166c5..a64be1b 100644 --- a/PROJECT.md +++ b/PROJECT.md @@ -491,6 +491,18 @@ zombie (now an occasional opponent, `TUNE.racketEvery`, not the every-game fixtu `RETURNED`, `DOUBLE KILL`) is a footnote to something you already watched happen. Sharing the loud FACE with the milestone cards is right; sharing their SIZE made every footnote read as a milestone. +## Session log: the walk-on gets its own face (2026-08-29, Oliver: "buried in a wall of text") + +The opponent intro carried six kinds of information as one modal line stack. It wears designated +zones now (`render.drawFoeModal`, driven by `m.foe` from matchOpponent): the MASTHEAD (mugshot +large on the left, the name in the announcement face, the slogan wrapped beneath — the shared +wrapText, measured against the space beside the mug), THE MEASURE (a split .figs-language panel: +ITS LEVEL boxed against YOURS, the number in the modal's hot red while the body outranks you and +the pick green once it does not), the round/lesson strip, and the blinking tap. Same modal +machinery throughout — entrance lanes (masthead from the right, the measure from the left), the +frozen court, dismiss rules. m.title/m.body still carry the same words, so the suite and any +legacy reader are untouched; the one icon-shaped assertion moved to m.foe.head. + ## Session log: the look stops lying (2026-08-29, Oliver: "the man with a pink shirt") After a reload with the women's tour picked, the live-player preview showed THE MAN from behind diff --git a/index.html b/index.html index fc07a76..ad355f5 100644 --- a/index.html +++ b/index.html @@ -871,6 +871,6 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat - + diff --git a/src/beats.js b/src/beats.js index cc075ba..e45c750 100644 --- a/src/beats.js +++ b/src/beats.js @@ -649,15 +649,19 @@ function matchOpponent() { // 1.6 s intro beat until the tap, and the head wears the same z_head crop the card wore. // In the cup the round names itself on the second line. const cup = G.match.cup; - // the "am I its peer yet?" line (Oliver, 2026-08-29): the body's ladder position spread over - // the profile's own level scale, beside yours — display only, nothing scales off it - const vs = 'LEVEL ' + foeLevel(G.match.opp) + ' — YOURS: ' + levelAt(prof.xp); - showModal({ title: zb.foe.name, color: UI.hot, icon: 'z_' + zb.fam + 'head', iconH: 64, - body: [zb.foe.line, vs].concat(cup ? [cup.rounds[cup.round].name + (cup.rounds[cup.round].need > 1 + // THE WALK-ON wears its own zoned layout (render.drawFoeModal — Oliver: the generic line + // 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 display only. + const sub = cup ? [cup.rounds[cup.round].name + (cup.rounds[cup.round].need > 1 ? ' · FIRST TO ' + cup.rounds[cup.round].need + ' GAMES' : '')] - // 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'] : []) }); + // 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); + 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, sub } }); // ...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 e82a5ec..cfc0588 100644 --- a/src/render.js +++ b/src/render.js @@ -2341,6 +2341,65 @@ function drawFlickHint() { const BACK_C = 1.70158; const easeBack = p => 1 + (BACK_C + 1) * (p - 1) ** 3 + BACK_C * (p - 1) ** 2; +// THE WALK-ON'S OWN FACE (2026-08-29, Oliver: the opponent card was "a wall of text" — six kinds +// of information in one line stack). Designated zones instead: the MASTHEAD (mugshot beside the +// name with the slogan under it), THE MEASURE (its level boxed against yours — the number wears +// the modal's hot red while the body outranks you, the pick green once it does not), and the +// round/lesson strip. Same machinery as every modal otherwise: the entrance lanes, the frozen +// court, the blinking tap (drawModal below). m.title/m.body still carry the same words for the +// suite and any legacy reader — this is a LAYOUT, not new state. +function drawFoeModal(m, t, dx) { + const f = m.foe, img = f.head && SPR[f.head]; + const boxW = Math.min(W - 24, 250), bx = Math.round(W / 2 - boxW / 2); + const mugH = Math.round(Math.min(56, H * 0.22)); + const mugW = img ? Math.round(mugH * img.width / img.height) : 0; + const slogan = wrapText(f.line, F(8, 0), boxW - (mugW ? mugW + 12 : 0)); // the shared wrapper sets the font + const headH = Math.max(mugH, 26 + slogan.length * 12); + const measureH = 36, gap = 14; + const subH = f.sub.length ? f.sub.length * 12 + 4 : 0; + const blockH = headH + gap + measureH + (subH ? gap - 4 + subH : 0); + let y = Math.round(H / 2 - blockH / 2); + // THE MASTHEAD flies from the right as one piece + const hx = dx(0, 1); + const nx = bx + (mugW ? mugW + 12 : 0) + hx; + if (img) { ctx.imageSmoothingEnabled = false; ctx.drawImage(img, bx + hx, y, mugW, mugH); } + ctx.textAlign = 'left'; + ctx.font = B(20); + ctx.fillStyle = m.color || UI.hot; + ctx.fillText(f.name, nx, y + 10); + ctx.font = F(8, 0); + slogan.forEach((l, i) => shadowText(l, nx, y + 28 + i * 12, UI.cream)); + y += headH + gap; + // THE MEASURE flies from the left: two boxed figures sharing one frame (the .figs language) + const mx = dx(1, -1); + const half = Math.round(boxW / 2); + panel(bx + mx, y, boxW, measureH, 3, 'rgba(8,26,18,0.85)', 'rgba(120,150,130,0.4)'); + ctx.strokeStyle = 'rgba(120,150,130,0.4)'; ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(bx + mx + half + 0.5, y + 4); ctx.lineTo(bx + mx + half + 0.5, y + measureH - 4); + ctx.stroke(); + ctx.textAlign = 'center'; + ctx.font = F(7, 0); + shadowText('ITS LEVEL', bx + mx + half / 2, y + 10, UI.dim); + shadowText('YOURS', bx + mx + half * 1.5, y + 10, UI.dim); + ctx.font = F(13, 700); + shadowText(String(f.lvl), bx + mx + half / 2, y + 25, f.lvl > f.mine ? (m.color || UI.hot) : UI.pick); + shadowText(String(f.mine), bx + mx + half * 1.5, y + 25, UI.cream); + y += measureH; + // the round / the lesson, from the left a beat later + if (f.sub.length) { + y += gap - 4; + ctx.font = F(8, 0); + f.sub.forEach((l, i) => shadowText(l, W / 2 + dx(2, -1), y + 6 + i * 12, UI.dim)); + } + if (t >= 1 && Math.floor(G.idleT * 2) % 2) { + ctx.font = F(8); + shadowText(IS_TOUCH ? 'TAP TO CONTINUE' : 'PRESS ANY KEY', W / 2, + Math.round(H / 2 + blockH / 2 + 24), UI.warm); + } + ctx.textBaseline = 'top'; +} + function drawModal(m) { // The entrance (m.in, 0..1, ticked in gameplay's update behind the world freeze): the icon and // title fly in from the RIGHT, the body lines cascade in from the LEFT one after another, each @@ -2357,6 +2416,7 @@ function drawModal(m) { ctx.fillStyle = `rgba(8,6,10,${(0.82 * Math.min(1, t / 0.35)).toFixed(3)})`; // the dim washes in first ctx.fillRect(0, 0, W, H); ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; + if (m.foe) return drawFoeModal(m, t, dx); const body = m.body || [], img = m.icon && SPR[m.icon]; // cap the illustration against the viewport, not just the asset: at 270px tall (landscape) a // portrait-sized icon pushed the TAP prompt down onto the weapon rack diff --git a/test.js b/test.js index 3d58b0b..ecd5ea3 100644 --- a/test.js +++ b/test.js @@ -2058,7 +2058,8 @@ test('the walk-on is a MODAL the player taps away, and it holds the world', () = assert.ok(_startMatch()); assert.ok(G.modal, 'the opponent announces as a modal, not a band card'); assert.equal(G.modal.title, cfg.matchFoe(1).name, 'it names the body'); - assert.equal(G.modal.icon, 'z_' + cfg.matchFoe(1).fam + 'head', '...and wears its head'); + assert.equal(G.modal.foe.head, 'z_' + cfg.matchFoe(1).fam + 'head', '...and wears its head (the zoned foe layout)'); + assert.ok(G.modal.foe.lvl >= 1 && G.modal.foe.mine >= 1, '...and carries the level pair'); const z0 = oppOf().z, intro0 = G.interlude.introT; for (let i = 0; i < 60; i++) update(DT); assert.equal(oppOf().z, z0, 'the frozen court holds while it is read');