mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 10:39:32 +01:00
fix(story-mode): a speech balloon never covers the art or Claude's face
A balloon that found no room beside a long caption dropped to just below it, inside the art band: on the fix loop it covered the code page and the "19 tests" label of the board, and elsewhere it sat on Claude's face (a balloon of three lines or fewer was exempt from the clamp). A sweep over the 11 corpus sessions, every turn, both modes, at 390, 820, 1100 and 1440 px and beside the diff drawer found 131 of 690 balloons covering a click region or Claude's face. Every balloon (Claude's, the loop's aimed one, the ask panel's, yours and a notice) is now placed by _storyFitBalloon from the scene's layout at its finished frame (StoryArt.layout, a dry run on a scratch canvas), so an entrance with objects still in flight never moves it or the panel's height. It must end above every click region under it (its tail above those under the tail) and above Claude's face; a click region holding his face is Claude himself and only the face rule applies to it. When two lines do not fit, the panel grows for caption and balloon together and the scene stands lower. The growth is computed at the panel's usual height (storyBalloonRoom, pure), so a refit of the grown panel asks for the same height: one extra pass, never an oscillation. The rest of a long text clamps as before. agent() now names Claude by his default colour, not by being cyan, so the cyan fourth helper no longer takes the balloon, and the check scene reports how high Claude's jump lifts his face. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
ed6c89ff98
commit
24abd5a8cb
3 files changed
+317
-53
No files matched your search
@@ -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<string, unknown>) => 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<string, any>, capHeight: number) {
|
||||
const def = (el: HTMLElement, props: Record<string, () => 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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user