diff --git a/src/web/public/story-mode.css b/src/web/public/story-mode.css index 14ee75a5..f28c9e32 100644 --- a/src/web/public/story-mode.css +++ b/src/web/public/story-mode.css @@ -429,7 +429,7 @@ body.story-open .toast-container { top: auto; bottom: calc(env(safe-area-inset-bottom, 0px) + 0.75rem); } -/* story-mode.js sets --story-lines when a balloon would leave its panel or cover Claude's head. */ +/* story-mode.js sets --story-lines when a balloon would leave its panel or cover the art or Claude's face. */ .story-balloon-text { display: -webkit-box; -webkit-box-orient: vertical; @@ -503,8 +503,8 @@ body.story-open .toast-container { .story-balloon--note::after { display: none; } -/* Claude's balloon is placed over its head by story-mode.js (_storyPlaceBalloon); a scene with no - Claude in it (the loop) keeps it in the top corner, clear of the art below. */ +/* Every balloon is placed by story-mode.js (_storyFitBalloon), from the scene's layout: these are its + places before that (or without a scene layout). */ .story-panel[data-kind='loop'] .story-balloon--claude { top: 10px; right: 12px; diff --git a/src/web/public/story-mode.js b/src/web/public/story-mode.js index f0533cc7..c9496a08 100644 --- a/src/web/public/story-mode.js +++ b/src/web/public/story-mode.js @@ -338,7 +338,10 @@ const StoryArt = (() => { /** The agent: a terminal-headed character. (x, y) is the point between its feet. */ /** Every edit an event made: an Edit/Write call's one, or the files a Bash here-document wrote. */ const editsOf = (e) => (e?.edit ? [e.edit] : e?.edits || []); - /** Where Claude's head was last drawn (stage units): the speech balloon is placed over it. */ + /** + * Where Claude's head was last drawn (stage units): the speech balloon is placed over it, and no + * balloon covers `face`, the box of his head. + */ let speaker = null; /** A scene with no Claude in it names the spot its balloon should point at. */ function aim(x, top) { @@ -355,8 +358,21 @@ const StoryArt = (() => { const L = o.l ?? [-32, -44]; const R = o.r ?? [32, -44]; const hy = -120 + (o.headBob ?? 0); - // Claude is the cyan one; a helper in another colour never holds the balloon. - if (body === C.cyan) speaker = { x, top: y + (hy - 36) * s }; + // Claude is the one drawn in his own colour (no `body` given); a helper never holds the balloon, + // not even the cyan fourth one. `rise`: how much higher a jump lifts him on other frames. + if (!o.body) { + const lift = o.rise ?? 0; + const t = o.tilt ?? 0; + // The head turns with the body about the feet: its centre moves sideways by the tilt. + const cy = (hy + 1) * s; + const fx = x - cy * Math.sin(t); + const fy = y + cy * Math.cos(t) - lift; + speaker = { + x, + top: y + (hy - 36) * s - lift, + face: { x: fx - 33 * s, y: fy - 28 * s, w: 66 * s, h: 56 * s + lift }, + }; + } ctx.save(); ctx.translate(x, y); ctx.rotate(o.tilt ?? 0); @@ -1133,10 +1149,12 @@ const StoryArt = (() => { ].forEach(([x, y], k) => star4(ctx, x, y, 7 + Math.sin(f / 5 + k * 1.7) * 3, C.yellow)); } const mood = running ? 'focus' : p.panel.ok ? 'happy' : 'shock'; - const jump = p.panel.ok && f > 30 ? Math.abs(Math.sin((f - 30) / 7)) * 12 : 0; + const jumping = p.panel.ok && f > 30; + const jump = jumping ? Math.abs(Math.sin((f - 30) / 7)) * 12 : 0; agent(ctx, W - 40, 254 - jump, { frame: f, scale: 0.66, + rise: jumping ? 12 - jump : 0, mood, tilt: p.panel.ok === false ? interpolate(f, [28, 36], [0, 0.14]) : 0, l: p.panel.ok ? [-32, -58] : [-36, -98], @@ -1520,7 +1538,33 @@ const StoryArt = (() => { }, }; - return { FPS, HERO, VH, C, readColors, spring, SCENES, takeSpeaker, editsOf, inBand }; + /** + * A scene's layout at its finished frame, drawn on a 1x1 scratch canvas: its click regions and where + * Claude stands (stage units). Balloons are placed from this, never from the frame on screen, where + * an entrance still has objects in flight and the panel would change height with them. + */ + let scratch; + function layout(kind, W, p) { + const scene = SCENES[kind]; + if (scratch === undefined) { + const c = document.createElement('canvas'); + c.width = c.height = 1; + scratch = c.getContext('2d'); + } + if (!scene || !scratch) return null; + const hits = []; + takeSpeaker(); + scratch.save(); + scratch.setTransform(1, 0, 0, 1, 0, 0); + try { + scene.draw(scratch, W, VH, HERO, p, (id, x, y, w, h) => hits.push({ id, x, y, w, h })); + } finally { + scratch.restore(); + } + return { hits, speaker: takeSpeaker() }; + } + + return { FPS, HERO, VH, C, readColors, spring, SCENES, takeSpeaker, editsOf, inBand, layout }; })(); /** Panel kinds as the wire's group headings name them. */ @@ -1567,6 +1611,25 @@ function storyLayoutSpans(prefs, cols) { return spans; } +/** + * How far down a speech balloon may reach, and how much taller its panel must grow for the balloon to + * keep `keep` px (its padding and the lines it must show). Everything is in panel px at the panel's + * usual height: `obstacles` ({ x0, x1, y }: the scene's objects and Claude's face) and `floor` (the + * bottom edge, Claude's head) stand on the stage, so a panel grown by g moves each of them down by g + * while the balloon keeps its `top`. The result therefore does not depend on the grown height: a + * second pass over a grown panel asks for the same growth, never more. + * An obstacle under the body (`x0`..`x1`) must start below it; one under the tail (`tail`: { x0, x1, + * h }, the point below the body) must start below the tail's tip. + */ +function storyBalloonRoom({ top, x0, x1, tail = null, obstacles = [], floor, keep, gap = 4 }) { + let limit = floor; + for (const o of obstacles) { + if (o.x1 > x0 - gap && o.x0 < x1 + gap) limit = Math.min(limit, o.y - gap); + if (tail && o.x1 > tail.x0 - gap && o.x0 < tail.x1 + gap) limit = Math.min(limit, o.y - tail.h - gap); + } + return { limit, grow: Math.max(0, top + keep - limit) }; +} + Object.assign(CodemanApp.prototype, { /** Per-device setting, default OFF. */ isStoryButtonEnabled() { @@ -2659,6 +2722,8 @@ Object.assign(CodemanApp.prototype, { /** * A caption taller than the band above the art makes its panel taller by the difference, and the * scene stands below it (`rt.stageH`): a long caption never covers a test board or a code page. + * The speech balloon is placed here too (`_storyFitBalloon`), and when it has to sit below the + * caption the panel grows for both, so the balloon covers no object and not Claude's face either. */ _storyFitCaption(rt) { if (rt.splash) return; @@ -2666,11 +2731,136 @@ Object.assign(CodemanApp.prototype, { const base = parseFloat(getComputedStyle(rt.el).getPropertyValue('--story-panel-min')) || 0; if (!cap || !base) return; const border = rt.el.offsetHeight - rt.el.clientHeight; + const s = (base - border) / StoryArt.VH; // The art starts about 88 scene units down (a terminal at 92, a code page at 104, a board at 106). - const room = (88 * (base - border)) / StoryArt.VH; - const need = cap.offsetTop + cap.offsetHeight - room; + const capBottom = cap.offsetTop + cap.offsetHeight; + // Never more than one more usual height for the balloon: a guard, the room it asks for is smaller. + const balloon = Math.min(base, this._storyFitBalloon(rt, base - border, s, cap)); + const need = Math.max(capBottom - 88 * s, balloon); const grow = need > 2 ? `${Math.ceil(base + need)}px` : ''; if (rt.el.style.minHeight !== grow) rt.el.style.minHeight = grow; + if (rt.balloonFit) this._storyClampBalloon(rt, rt.el.clientHeight); + }, + + /** + * The scene's objects and Claude in panel px at the panel's usual height, from the scene's finished + * frame (`StoryArt.layout`), kept until the panel's data, width or mode changes. A click region that + * holds Claude's face is Claude himself (the mission's prompt, the ask panel's question): it is not + * an object a balloon must avoid, only his face is. + */ + _storySceneLayout(rt, w, s) { + const st = this._story; + const key = `${rt.kind}|${w}|${s}|${st.director}`; + if (rt.layoutKey === key && rt.layoutFor === rt.panel) return rt.layout; + const scene = StoryArt.SCENES[rt.kind]; + const W = w / s; + const stageW = scene?.fullWidth ? W : Math.min(W, STORY_STAGE_MAX); + const ox = (W - stageW) / 2; + const raw = + rt.panel && StoryArt.layout(rt.kind, stageW, { panel: rt.panel, page: st.data?.page, director: st.director }); + let lay = null; + if (raw) { + const box = (r) => ({ x0: (r.x + ox) * s, x1: (r.x + ox + r.w) * s, y: r.y * s, y1: (r.y + r.h) * s }); + const sp = raw.speaker; + const face = sp?.face ? box(sp.face) : null; + const cx = face && (face.x0 + face.x1) / 2; + const cy = face && (face.y + face.y1) / 2; + const objects = raw.hits.map(box).filter((o) => !(face && o.x0 <= cx && cx <= o.x1 && o.y <= cy && cy <= o.y1)); + lay = { objects, face, head: sp ? { x: (sp.x + ox) * s, top: sp.top * s } : null }; + } + rt.layoutKey = key; + rt.layoutFor = rt.panel; + rt.layout = lay; + return lay; + }, + + /** + * Places the balloon (left or right, top, max-width; nothing of it reads the panel's height) and + * returns how much taller the panel must be for the balloon to show two lines (or all of a shorter + * one) above every object it would cover and above Claude's face. Claude's own balloon goes in the + * band above his head, the tail pointing down at it: beside the caption when there is room, else + * below it. The ask panel's stays left of his face; yours and a notice stand under the caption. + * The room it may use is kept in `rt.balloonFit` for the clamp (`_storyClampBalloon`). + */ + _storyFitBalloon(rt, stageH, s, cap) { + const b = rt.balloon; + const text = b?.querySelector('.story-balloon-text'); + rt.balloonFit = null; + if (!b || b.hidden || !text) return 0; + const w = rt.el.clientWidth; + const lay = this._storySceneLayout(rt, w, s); + if (!lay) { + // No scene layout (a browser out of canvas memory): the stylesheet's places, clamped to the panel. + for (const prop of ['left', 'right', 'top', 'max-width']) b.style.removeProperty(prop); + return 0; + } + const capRight = cap.offsetLeft + cap.offsetWidth; + const capBottom = cap.offsetTop + cap.offsetHeight; + const face = lay.face; + const claude = b.classList.contains('story-balloon--claude'); + const placed = claude && rt.kind !== 'ask' && !!lay.head; + let floor = stageH - 12; + let tail = null; + if (placed) { + const hx = lay.head.x; + const right = Math.max(10, Math.min(w - 160, w - hx - 38)); + // The loop has no Claude to keep clear of on the left: its balloon may run wide, so it stays + // short and ends above the board it points at. + let maxW = Math.min(w * (rt.kind === 'loop' ? 0.9 : 0.5), w - right - 14); + let top = 10; + const besideCap = w - right - capRight - 10; + if (besideCap >= 150) maxW = Math.min(maxW, besideCap); + else top = capBottom + 8; + // The LIVE tag sits in the top-right corner: start below it when the balloon reaches that far. + if (!rt.liveTag.hidden && right < w - rt.liveTag.offsetLeft) + top = Math.max(top, rt.liveTag.offsetTop + rt.liveTag.offsetHeight + 6); + b.style.left = 'auto'; + b.style.right = `${Math.round(right)}px`; + b.style.top = `${Math.round(top)}px`; + b.style.maxWidth = `${Math.round(Math.max(140, maxW))}px`; + // The tail's tip ends just above the head (it points at it). + floor = Math.min(floor, lay.head.top - 22); + } else { + // The ask panel's balloon and yours stand at the left: under the caption, and left of his face. + const left = claude ? 14 : 16; + const top = Math.max(claude ? 0.3 * stageH : 74, capBottom + (claude ? 8 : 10)); + let maxW = w * (claude ? 0.46 : 0.54); + if (face && face.x0 - 8 - left < maxW) maxW = Math.max(120, face.x0 - 8 - left); + b.style.left = `${left}px`; + b.style.right = 'auto'; + b.style.top = `${Math.round(top)}px`; + b.style.maxWidth = `${Math.round(maxW)}px`; + } + text.style.removeProperty('--story-lines'); + const top = b.offsetTop; + const x0 = b.offsetLeft; + const x1 = x0 + b.offsetWidth; + const lh = parseFloat(getComputedStyle(text).lineHeight) || 19; + const pad = b.offsetHeight - text.offsetHeight; + if (claude) { + // The tail (the ink half, ::before) hangs below the body's right end, set inside its border. + const bs = getComputedStyle(b); + const tb = getComputedStyle(b, '::before'); + const inset = parseFloat(bs.borderRightWidth) || 0; + const right = x1 - inset - (parseFloat(tb.right) || 0); + const below = -(parseFloat(tb.bottom) || 0) - (parseFloat(bs.borderBottomWidth) || 0); + if (below > 0 && tb.display !== 'none') tail = { x0: right - (parseFloat(tb.width) || 0), x1: right, h: below }; + } + const obstacles = lay.objects.slice(); + if (face) obstacles.push(face); + // The ask panel's answer stamp stands at the bottom, so it moves with a taller panel like the art. + if (rt.answer && !rt.answer.hidden) + obstacles.push({ + x0: rt.answer.offsetLeft, + x1: rt.answer.offsetLeft + rt.answer.offsetWidth, + y: stageH - (rt.el.clientHeight - rt.answer.offsetTop) - 6, + }); + const lines = Math.max(1, Math.round(text.offsetHeight / lh)); + const keep = pad + Math.min(2, lines) * lh; + const room = storyBalloonRoom({ top, x0, x1, tail, obstacles, floor, keep }); + rt.balloonSerial = (rt.balloonSerial || 0) + 1; + rt.balloonFit = { top, limit: room.limit, stageH, serial: rt.balloonSerial }; + return room.grow; }, /** Draws on the next frame, within its budget (now, when the frame loop is not running). */ @@ -2748,61 +2938,39 @@ Object.assign(CodemanApp.prototype, { hit ); this._storySyncHits(rt, hits, s); - this._storyPlaceBalloon(rt, StoryArt.takeSpeaker(), ox, s, oy); + StoryArt.takeSpeaker(); // the balloon is placed from the scene's layout (_storyFitBalloon), not this frame + this._storyPlaceBalloon(rt); }, /** - * Claude's balloon goes in the band above its head, the tail pointing down at it, so it never - * covers the art the scene draws below (a test board, a burst, the code page). It keeps clear of - * the caption box: narrower beside it, or below it when there is no room. Recomputed only when - * the panel's size, the text or the head's column changes. + * Keeps the balloon's clamp right for the panel's height, which can change without a refit (a + * taller neighbour stretches the row). Cheap when nothing changed: it runs on every draw. */ - _storyPlaceBalloon(rt, head, ox, s, oy = 0) { + _storyPlaceBalloon(rt) { const b = rt.balloon; if (!b || b.hidden || rt.splash) return; - const placed = !!head && rt.kind !== 'ask' && b.classList.contains('story-balloon--claude'); - const hx = placed ? (head.x + ox) * s : 0; - const cap = rt.capStory.parentElement; - const capW = cap?.offsetWidth || 0; - const live = !rt.liveTag.hidden; - const key = `${rt.w}x${rt.h}|${placed ? Math.round(hx / 6) : '-'}|${capW}|${live}|${b.className}|${b.textContent}`; + const key = `${rt.h}|${rt.balloonFit?.serial ?? '-'}|${b.className}|${b.textContent}`; if (key === rt.balloonKey) return; - rt.balloonKey = key; - if (placed) { - const capRight = cap ? cap.offsetLeft + capW : 0; - const capBottom = cap ? cap.offsetTop + cap.offsetHeight : 0; - const right = Math.max(10, Math.min(rt.w - 160, rt.w - hx - 38)); - // The loop has no Claude to keep clear of on the left: its balloon may run wide, so it stays - // short and ends above the board it points at. - let maxW = Math.min(rt.w * (rt.kind === 'loop' ? 0.9 : 0.5), rt.w - right - 14); - let top = 10; - const besideCap = rt.w - right - capRight - 10; - if (besideCap >= 150) maxW = Math.min(maxW, besideCap); - else top = capBottom + 8; - // The LIVE tag sits in the top-right corner: start below it when the balloon reaches that far. - if (live && right < rt.w - rt.liveTag.offsetLeft) - top = Math.max(top, rt.liveTag.offsetTop + rt.liveTag.offsetHeight + 6); - b.style.left = 'auto'; - b.style.right = `${Math.round(right)}px`; - b.style.top = `${Math.round(top)}px`; - b.style.maxWidth = `${Math.round(Math.max(140, maxW))}px`; - } else { - for (const prop of ['left', 'right', 'top', 'max-width']) b.style.removeProperty(prop); - } - // Clamp a long text: inside the panel always, and off Claude's head when three lines allow. - const text = b.querySelector('.story-balloon-text'); + this._storyClampBalloon(rt, rt.h); + }, + + /** + * Clamps a long balloon text (`--story-lines`) to the room its fit left it at panel height `h`: a + * taller panel moves the scene's objects and Claude down, so the room grows with it. Without a fit + * (no stylesheet, no scene layout) the text only stays inside the panel. + */ + _storyClampBalloon(rt, h) { + const b = rt.balloon; + const text = b?.querySelector('.story-balloon-text'); if (!text) return; + const fit = rt.balloonFit; + rt.balloonKey = `${h}|${fit?.serial ?? '-'}|${b.className}|${b.textContent}`; text.style.removeProperty('--story-lines'); + const limit = fit ? fit.limit + h - fit.stageH : h - 12; const lh = parseFloat(getComputedStyle(text).lineHeight) || 19; const pad = b.offsetHeight - text.offsetHeight; - // The loop's "head" is its board: down to one line rather than over the test count. - const least = rt.kind === 'loop' ? 1 : placed ? 3 : 2; - const limit = placed ? Math.max(b.offsetTop + pad + least * lh, (head.top + oy) * s - 22) : rt.h - 12; if (b.offsetHeight && b.offsetTop + b.offsetHeight > limit) - text.style.setProperty( - '--story-lines', - String(Math.max(Math.min(least, 2), Math.floor((limit - b.offsetTop - pad) / lh))) - ); + text.style.setProperty('--story-lines', String(Math.max(1, Math.floor((limit - b.offsetTop - pad) / lh)))); }, /** Canvas click regions become real buttons: focusable, labelled, keyboard-reachable. */ diff --git a/test/story-mode-ui.test.ts b/test/story-mode-ui.test.ts index 8b458ee7..675356de 100644 --- a/test/story-mode-ui.test.ts +++ b/test/story-mode-ui.test.ts @@ -1300,3 +1300,99 @@ describe('story-mode.js layout', () => { expect(spans([1, 1, 1], 2)).toEqual([1, 1, 2]); }); }); + +describe('story-mode.js speech balloons', () => { + const room = (o: Record) => win.storyBalloonRoom(o) as { limit: number; grow: number }; + + it('a balloon reaches down to the first object under it, its tail to the first under the tail', () => { + boot(null); + const base = { top: 10, x0: 100, x1: 300, floor: 250, keep: 62 }; + expect(room(base)).toEqual({ limit: 250, grow: 0 }); + // Under the body: the balloon ends above it (4 px gap). + expect(room({ ...base, obstacles: [{ x0: 200, x1: 260, y: 104 }] }).limit).toBe(100); + // Beside it (more than the gap away): ignored. + expect(room({ ...base, obstacles: [{ x0: 20, x1: 90, y: 40 }] }).limit).toBe(250); + // Under the tail only: the tail's tip ends above it, so the body ends a tail higher. + const tail = { x0: 330, x1: 352, h: 16.5 }; + expect(room({ ...base, tail, obstacles: [{ x0: 340, x1: 400, y: 120 }] }).limit).toBe(120 - 16.5 - 4); + // No room for two lines below a long caption: the panel grows by the shortfall. + expect(room({ ...base, top: 168, obstacles: [{ x0: 200, x1: 260, y: 104 }] })).toEqual({ limit: 100, grow: 130 }); + }); + + /** A panel with a box model: JSDOM lays nothing out, so every box the fit reads is given here. */ + function boxes(rt: Record, capHeight: number) { + const def = (el: HTMLElement, props: Record number>) => { + for (const [k, get] of Object.entries(props)) Object.defineProperty(el, k, { get, configurable: true }); + }; + const state = { grown: 0 }; + rt.el.style.setProperty('--story-panel-min', '270px'); + def(rt.el, { + offsetHeight: () => 270 + state.grown, + clientHeight: () => 264 + state.grown, + clientWidth: () => 394, + }); + def(rt.capStory.parentElement, { + offsetTop: () => 10, + offsetLeft: () => 10, + offsetHeight: () => capHeight, + offsetWidth: () => 250, + }); + const b = rt.balloon as HTMLElement; + const text = b.querySelector('.story-balloon-text') as HTMLElement; + // Three lines of 19 px, fewer once clamped; 24 px of padding and border around them. + const lines = () => Number(text.style.getPropertyValue('--story-lines')) || 3; + const right = () => parseFloat(b.style.right); + def(b, { + offsetTop: () => parseFloat(b.style.top) || 0, + offsetWidth: () => 200, + offsetLeft: () => (Number.isNaN(right()) ? parseFloat(b.style.left) || 0 : 394 - right() - 200), + offsetHeight: () => 24 + lines() * 19, + }); + def(text, { offsetHeight: () => lines() * 19 }); + return state; + } + + it('a balloon pushed below a long caption grows its panel, the same growth on the grown panel', async () => { + boot(storyOf(demoTurn())); + app.openStoryMode(SID); + await settle(); + sizeAndDraw(); + const rt = app._story.panels.find((p: { kind: string }) => p.kind === 'investigate'); + const box = boxes(rt, 150); + app._storyFitCaption(rt); + // No room beside the caption: the balloon stands below it, over Claude's head at the right. + expect(rt.balloon.style.top).toBe('168px'); + const grown = rt.el.style.minHeight; + // Two lines (24 + 38 px) end above Claude's head (91 px down the usual stage, the tail 22 px + // over it), so the panel grows by 168 + 62 - 69: more than the caption alone asks for (71). + expect(grown).toBe('431px'); + // The resize observer refits the grown panel: the same height, and the text clamps to two lines. + box.grown = parseFloat(grown) - 270; + app._storyFitCaption(rt); + expect(rt.el.style.minHeight).toBe(grown); + const text = rt.balloon.querySelector('.story-balloon-text') as HTMLElement; + expect(text.style.getPropertyValue('--story-lines')).toBe('2'); + // A taller row (a neighbour grew more) gives the text its third line back. + rt.h = 264 + box.grown + 40; + app._storyPlaceBalloon(rt); + expect(text.style.getPropertyValue('--story-lines')).toBe(''); + }); + + it('your balloon stands below a long caption, never under it', async () => { + boot(storyOf(demoTurn())); + app.openStoryMode(SID); + await settle(); + sizeAndDraw(); + const rt = app._story.panels.find((p: { kind: string }) => p.kind === 'mission'); + boxes(rt, 120); + app._storyFitCaption(rt); + expect(rt.balloon.className).toContain('story-balloon--you'); + expect(rt.balloon.style.top).toBe('140px'); + expect(rt.balloon.style.left).toBe('16px'); + // The mission's click region is Claude himself: not an object to avoid, only his face is. + expect(rt.layout.objects).toEqual([]); + // Claude stands at the right: the balloon ends left of his face. + const face = rt.layout.face; + expect(16 + parseFloat(rt.balloon.style.maxWidth)).toBeLessThanOrEqual(face.x0 - 8); + }); +});