@toonstrip/core 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/render.js ADDED
@@ -0,0 +1,511 @@
1
+ /**
2
+ * A panel, drawn. The renderer is a pure function of a {@link Panel} plus the
3
+ * cast: it reads no external state, which is what makes it testable in Node
4
+ * without a caller-side game loop or session — a shot arrives as a name
5
+ * (`establishing`, `close-up`) chosen by the caller, and this file decides
6
+ * what that shot looks like at the current panel size.
7
+ */
8
+ import { drawFigure, figureFor, newCycle } from "./figure.js";
9
+ import { BALLOON_FONT, drawBalloon, measureBalloon } from "./balloon.js";
10
+ import { canvasMetrics, layoutBalloons, } from "./balloon-layout.js";
11
+ /** The bodiless caption channel — a line with no speaking body. */
12
+ export const CAPTION = "caption";
13
+ /** The six camera presets, as framing. */
14
+ const SHOTS = {
15
+ // Tight on the subject: the head fills the frame and the body is cropped
16
+ // away. Carries no balloon, so nothing else holds the head to one size.
17
+ reaction: { headFill: 0.54, headTop: 0.27, backdrop: 0.18 },
18
+ // A new location is the one moment the backdrop is the subject.
19
+ establishing: { fill: 0.5, reveal: 1, backdrop: 1 },
20
+ // A location already seen: both figures, small, the backdrop incidental.
21
+ wide: { fill: 0.58, reveal: 1, backdrop: 0.9 },
22
+ "close-up": { fill: 1.35, reveal: 0.62, backdrop: 0.5 },
23
+ "over-shoulder": { fill: 0.86, reveal: 0.78, backdrop: 0.6 },
24
+ medium: { fill: 0.78, reveal: 0.95, backdrop: 0.75 },
25
+ };
26
+ /**
27
+ * Panel scale is capped rather than free: this art is roughly 400x400 at
28
+ * native size, so the common case on any real viewport is downscaling, and
29
+ * the narrow risk is a large panel blowing a figure up into its own pixels.
30
+ */
31
+ const MAX_SCALE = 1.6;
32
+ /**
33
+ * When a panel carries a speech or thought balloon, no figure may grow wider
34
+ * than this fraction of the panel — a speech balloon straddles the head from
35
+ * above, so it needs to be wider than the head to sit over it and still
36
+ * read. Left uncapped on a panel with no such balloon (a `reaction` beat,
37
+ * whose whole point is a face that fills the frame).
38
+ */
39
+ export const MAX_FIGURE_WIDTH = 0.55;
40
+ /**
41
+ * When a panel carries a speech or thought balloon, no head may grow taller
42
+ * than this fraction of the panel — `fill` scales a figure by its *body*
43
+ * height, but what a reader reads as "how close is this" is head size, and
44
+ * heads vary as a fraction of figure height across a cast. This is a
45
+ * per-figure clamp on top of `fill`, binding hardest at `close-up`.
46
+ */
47
+ export const MAX_HEAD_HEIGHT = 0.38;
48
+ /**
49
+ * Reserved band at the top of the panel for a straddling speech balloon and
50
+ * its tail. A speech balloon sits above its speaker and drops a near-
51
+ * vertical tail, which only works if the head is far enough down to leave
52
+ * room — so when such a balloon is present, a figure is pushed down until
53
+ * its head-top clears this fraction of the panel height.
54
+ */
55
+ export const BALLOON_HEADROOM = 0.44;
56
+ /** A gesture sentinel angle per {@link Body.pose} value — outside the emotion wheel's metric. */
57
+ const GESTURE_ANGLE = {
58
+ wave: 1001,
59
+ point: 1002,
60
+ shrug: 1005,
61
+ };
62
+ /**
63
+ * The priority-ordered emotion options a body's pose selection reads. A
64
+ * gesture (if present) is offered first — it only ever resolves a torso —
65
+ * and the body's own emotion is always offered too, so the face still
66
+ * expresses it even while the torso gestures.
67
+ */
68
+ function emotionOptionsFor(body) {
69
+ const options = [{ ...body.emotion, priority: 1 }];
70
+ if (body.pose)
71
+ options.unshift({ angle: GESTURE_ANGLE[body.pose], intensity: 1, priority: 2 });
72
+ return options;
73
+ }
74
+ export function renderPanel(ctx, panel, cast, width, height, options = {}) {
75
+ const shot = SHOTS[panel.camera ?? "medium"];
76
+ const cycles = options.cycles ?? new Map();
77
+ // A speech or thought balloon needs a column beside the head; a caption is
78
+ // a top box and does not, so a caption-only panel never shrinks its figures.
79
+ const hasColumnBalloon = panel.speakers.some((line) => line.balloon !== "caption");
80
+ const widthCap = hasColumnBalloon ? width * MAX_FIGURE_WIDTH : Infinity;
81
+ ctx.save();
82
+ ctx.clearRect(0, 0, width, height);
83
+ ctx.beginPath();
84
+ ctx.rect(0, 0, width, height);
85
+ ctx.clip();
86
+ if (panel.dark)
87
+ drawDarkness(ctx, width, height);
88
+ else
89
+ drawBackdrop(ctx, cast, width, height, shot, panel.backdrop);
90
+ const bodies = selectFigures(panel, cast, cycles);
91
+ const placed = layoutAvatars(bodies, width, height, shot, widthCap);
92
+ // Reserve headroom for a straddling speech balloon: push each figure down
93
+ // until its head-top clears the top band, so the balloon has room to sit
94
+ // above and drop its tail. Only when such a balloon exists.
95
+ if (hasColumnBalloon)
96
+ applyHeadroom(placed, height);
97
+ if (options.trace) {
98
+ options.trace.bodies = placed.map(({ speaker, x, y, scale, mirror }) => ({ speaker, x, y, scale, mirror }));
99
+ options.trace.balloons = [];
100
+ options.trace.stacked = false;
101
+ }
102
+ // The figures are laid out in the dark and then not drawn, deliberately,
103
+ // and in that order: balloons are placed *over their speakers*, so
104
+ // skipping the layout would move every balloon in an unlit panel and the
105
+ // tails would point at nothing. Darkness removes what the reader can see,
106
+ // not where anyone is standing.
107
+ if (!panel.dark) {
108
+ for (const item of placed) {
109
+ const sheet = cast.sheets.get(item.speaker);
110
+ if (sheet)
111
+ drawFigure(ctx, sheet, item.figure, item.x, item.y, item.scale, item.mirror);
112
+ }
113
+ }
114
+ // Metadata, over the figures and under nothing: it owns its band, so it
115
+ // can never be covered by a balloon or a body.
116
+ if (panel.roomTitle !== undefined)
117
+ drawLocationTitle(ctx, panel.roomTitle, width);
118
+ drawBalloons(ctx, panel, width, height, placed, options.trace);
119
+ // The panel border last, so nothing overdraws it.
120
+ ctx.strokeStyle = "#000";
121
+ ctx.lineWidth = 2;
122
+ ctx.strokeRect(1, 1, width - 2, height - 2);
123
+ ctx.restore();
124
+ }
125
+ /** A body earns the stage if requested, or if it speaks this panel. */
126
+ export function isSpeaker(body, speakers) {
127
+ if (body.requested)
128
+ return true;
129
+ return speakers.some((line) => line.speaker === body.character);
130
+ }
131
+ /**
132
+ * Stage order and facing. Order is stable by insertion (no talk-to graph
133
+ * exists to score a better one). Facing is scored: a body is charged for
134
+ * each neighbour it does not face and each neighbour that does not face it;
135
+ * reduced over a fixed row this means each body faces its **larger side** —
136
+ * the leftmost faces right, the rightmost faces left, and the row turns
137
+ * inward. An exact tie (the middle of an odd row) keeps the rightward default.
138
+ */
139
+ export function orderBodies(bodies) {
140
+ const n = bodies.length;
141
+ return bodies.map((body, i) => {
142
+ const left = i;
143
+ const right = n - 1 - i;
144
+ return { body, flip: right < left };
145
+ });
146
+ }
147
+ /**
148
+ * Reserve the top band for a straddling speech balloon, and keep the head
149
+ * that sits under it small enough to leave a body in frame.
150
+ *
151
+ * 1. Push down to the band: a figure whose head-top is above
152
+ * {@link BALLOON_HEADROOM} is moved down until it clears. A figure
153
+ * already below the band is left alone.
154
+ * 2. Cap the head: with the head-top pinned, head size is the whole of "how
155
+ * close are we", and `shot.fill` scales by *body* height — a poor proxy
156
+ * across a cast whose head-to-body ratio varies. See {@link MAX_HEAD_HEIGHT}.
157
+ *
158
+ * Only figures the band actually pinned are capped, and they shrink about
159
+ * their own centre so the balloon column stays over them.
160
+ */
161
+ export function applyHeadroom(placed, height) {
162
+ const minHeadTop = height * BALLOON_HEADROOM;
163
+ const headCap = height * MAX_HEAD_HEIGHT;
164
+ for (const item of placed) {
165
+ if (item.y >= minHeadTop)
166
+ continue;
167
+ item.y = minHeadTop;
168
+ const head = item.figure.headHeight * item.scale;
169
+ if (head <= headCap)
170
+ continue;
171
+ const centre = item.x + (item.figure.width * item.scale) / 2;
172
+ item.scale *= headCap / head;
173
+ item.x = centre - (item.figure.width * item.scale) / 2;
174
+ }
175
+ }
176
+ /**
177
+ * Place N bodies across the panel floor. Each body is scaled to the
178
+ * camera's fill height, normalized to a common target height across the
179
+ * cast. Widths are summed; if the row overruns the panel the whole set is
180
+ * reduced by one factor. Bodies are then spread with equal margins,
181
+ * including the two edges.
182
+ */
183
+ export function layoutAvatars(bodies, width, height, shot, widthCap = Infinity) {
184
+ const n = bodies.length;
185
+ if (n === 0)
186
+ return [];
187
+ const ordered = orderBodies(bodies);
188
+ const scales = bodies.map((b) => fit(b.figure, height, shot.fill ?? 0, widthCap, shot.headFill));
189
+ const widths = bodies.map((b, i) => b.figure.width * scales[i]);
190
+ const sumWidth = widths.reduce((a, w) => a + w, 0);
191
+ // Shrink the whole row to fit when it overruns the panel.
192
+ if (sumWidth > width) {
193
+ const reduction = width / sumWidth;
194
+ for (let i = 0; i < n; i++) {
195
+ scales[i] *= reduction;
196
+ widths[i] *= reduction;
197
+ }
198
+ }
199
+ const bodyWidth = widths.reduce((a, w) => a + w, 0);
200
+ const margin = (width - bodyWidth) / (n + 1);
201
+ const placed = [];
202
+ let x = margin;
203
+ for (let i = 0; i < n; i++) {
204
+ const { body, figure } = bodies[i];
205
+ const scale = scales[i];
206
+ const w = widths[i];
207
+ placed.push({
208
+ speaker: body.character, figure, scale, x,
209
+ y: shot.headTop === undefined
210
+ ? height - figure.height * scale * shot.reveal
211
+ : height * shot.headTop,
212
+ mirror: ordered[i].flip,
213
+ });
214
+ x += w + margin;
215
+ }
216
+ return placed;
217
+ }
218
+ /**
219
+ * Who is on stage in this panel, and posed how. Each figure's pose comes
220
+ * from its **own** body's emotion, so N participants pose independently,
221
+ * never sharing one panel emotion. Unknown character ids (no sheet, no
222
+ * manifest entry) are skipped.
223
+ *
224
+ * Exported because it is the step that advances `cycles`, and it takes no
225
+ * width — a caller that needs a panel's round-robin advanced without
226
+ * drawing it (a virtualized strip that stages every panel but paints only
227
+ * the ones on screen) can call this and get exactly what {@link renderPanel}
228
+ * would have consumed. Calling it mutates `cycles`; pass a copy to avoid that.
229
+ */
230
+ export function selectFigures(panel, cast, cycles) {
231
+ const bodies = [];
232
+ for (const body of panel.bodies) {
233
+ if (!isSpeaker(body, panel.speakers))
234
+ continue;
235
+ const avatar = cast.manifest.cast[body.character];
236
+ if (!avatar || !cast.sheets.get(body.character))
237
+ continue;
238
+ const figure = figureFor(avatar, emotionOptionsFor(body), cycleFor(cycles, body.character));
239
+ if (figure)
240
+ bodies.push({ body, figure });
241
+ }
242
+ return bodies;
243
+ }
244
+ function cycleFor(cycles, character) {
245
+ let cycle = cycles.get(character);
246
+ if (!cycle) {
247
+ cycle = newCycle();
248
+ cycles.set(character, cycle);
249
+ }
250
+ return cycle;
251
+ }
252
+ export function fit(figure, height, fill, widthCap = Infinity, headFill) {
253
+ // `headFill` replaces the body term rather than joining it as another
254
+ // clamp: it is a different way of asking for the same thing.
255
+ const wanted = headFill === undefined
256
+ ? (height * fill) / figure.height
257
+ : (height * headFill) / figure.headHeight;
258
+ return Math.min(wanted, MAX_SCALE, widthCap / figure.width);
259
+ }
260
+ /**
261
+ * An unlit panel: the frame is black, and that is the whole drawing. Not a
262
+ * dimmed backdrop and not a silhouette — total darkness means no figures are
263
+ * drawn either, just balloons over a black frame. `#0b0b0b` rather than
264
+ * `#000` so the panel border (a 2px `#000` stroke) still reads as a border
265
+ * against it.
266
+ */
267
+ function drawDarkness(ctx, width, height) {
268
+ ctx.fillStyle = "#0b0b0b";
269
+ ctx.fillRect(0, 0, width, height);
270
+ }
271
+ function drawBackdrop(ctx, cast, width, height, shot, id) {
272
+ ctx.fillStyle = "#fdfcf7";
273
+ ctx.fillRect(0, 0, width, height);
274
+ const image = id ? cast.backdrops.get(id) : undefined;
275
+ if (!image)
276
+ return;
277
+ // Cover, anchored low: backdrops are square and the horizon typically sits
278
+ // in their lower half, so anchoring to the top would put figures in the
279
+ // sky on any panel wider than it is tall.
280
+ const scale = Math.max(width / 315, height / 315);
281
+ const w = 315 * scale;
282
+ const h = 315 * scale;
283
+ ctx.save();
284
+ ctx.globalAlpha = shot.backdrop;
285
+ ctx.drawImage(image, (width - w) / 2, height - h, w, h);
286
+ ctx.restore();
287
+ }
288
+ /**
289
+ * Balloons live in the top band; the figures own the bottom — the
290
+ * arrangement that keeps a tail short enough to read. Placement inside that
291
+ * band is the layout driver's ({@link "./balloon-layout.js".layoutBalloons});
292
+ * {@link stackBalloons} is the fallback for the one case the driver has no
293
+ * answer for — a panel it refuses.
294
+ */
295
+ function drawBalloons(ctx, panel, width, height, placed, trace) {
296
+ ctx.font = BALLOON_FONT;
297
+ const heads = headGeometry(placed);
298
+ const free = balloonRect(width, height, panel.roomTitle !== undefined);
299
+ const inputs = panel.speakers.map((balloon) => ({
300
+ speaker: balloon.speaker,
301
+ isBox: balloon.balloon === "caption",
302
+ // A caption points at nobody; so does a line whose speaker has no body
303
+ // on stage. Both are placed at the free rect's left edge and block no route.
304
+ arrowX: balloon.speaker === CAPTION
305
+ ? null
306
+ : (heads.anchors.get(balloon.speaker)?.x ?? null),
307
+ text: balloon.text,
308
+ metrics: canvasMetrics(ctx, balloon),
309
+ }));
310
+ const laid = layoutBalloons(inputs, free);
311
+ if (!laid.fits) {
312
+ if (trace)
313
+ trace.stacked = true;
314
+ stackBalloons(ctx, panel, width, heads);
315
+ return;
316
+ }
317
+ for (const [i, item] of laid.balloons.entries()) {
318
+ const balloon = panel.speakers[i];
319
+ // Force-fit may have shortened the text; draw the lines the driver measured.
320
+ const face = balloon.speaker === CAPTION ? undefined : heads.faceBox.get(balloon.speaker);
321
+ const isSpeech = balloon.balloon === "speech";
322
+ const target = isSpeech && face
323
+ ? headEdgeTip(item.box, face)
324
+ : (heads.anchors.get(balloon.speaker) ?? null);
325
+ drawBalloon(ctx, balloon, item.box, item.lines, target);
326
+ trace?.balloons.push({
327
+ speaker: balloon.speaker, box: item.box, lines: item.lines.length,
328
+ tail: target ? { x: target.x, y: target.y } : null,
329
+ });
330
+ }
331
+ }
332
+ /** The band balloons may occupy, from the panel's top down to {@link BALLOON_HEADROOM}. */
333
+ export function balloonRect(width, height, titled = false) {
334
+ return {
335
+ left: BALLOON_MARGIN,
336
+ // A location title is metadata drawn over the top-left corner, so the
337
+ // band gives way to it rather than a balloon landing on it.
338
+ top: BALLOON_MARGIN + (titled ? TITLE_HEIGHT : 0),
339
+ right: width - BALLOON_MARGIN,
340
+ bottom: height * BALLOON_HEADROOM,
341
+ };
342
+ }
343
+ /** The inset from the panel border. */
344
+ export const BALLOON_MARGIN = 10;
345
+ /** The location title's height, and the type it is set in. Not a balloon: no tail, no draw order. */
346
+ export const TITLE_HEIGHT = 22;
347
+ const TITLE_FONT = 'bold 12px "Comic Sans MS", "Comic Neue", ui-rounded, cursive';
348
+ const TITLE_PAD = 6;
349
+ /** Draw the location title, clipped to the panel's width. */
350
+ export function drawLocationTitle(ctx, title, width) {
351
+ ctx.save();
352
+ ctx.font = TITLE_FONT;
353
+ const text = ctx.measureText(title).width > width - BALLOON_MARGIN * 2 - TITLE_PAD * 2
354
+ ? ellipsize(ctx, title, width - BALLOON_MARGIN * 2 - TITLE_PAD * 2)
355
+ : title;
356
+ const boxWidth = ctx.measureText(text).width + TITLE_PAD * 2;
357
+ ctx.fillStyle = "#fff";
358
+ ctx.strokeStyle = "#000";
359
+ ctx.lineWidth = 1.5;
360
+ ctx.beginPath();
361
+ ctx.rect(BALLOON_MARGIN, BALLOON_MARGIN, boxWidth, TITLE_HEIGHT);
362
+ ctx.fill();
363
+ ctx.stroke();
364
+ ctx.fillStyle = "#000";
365
+ ctx.textBaseline = "middle";
366
+ ctx.fillText(text, BALLOON_MARGIN + TITLE_PAD, BALLOON_MARGIN + TITLE_HEIGHT / 2);
367
+ ctx.restore();
368
+ }
369
+ function ellipsize(ctx, text, max) {
370
+ let out = text;
371
+ while (out.length > 1 && ctx.measureText(`${out}…`).width > max)
372
+ out = out.slice(0, -1);
373
+ return `${out}…`;
374
+ }
375
+ /** Each on-stage head as a tail anchor and a keep-out box, in panel space. */
376
+ function headGeometry(placed) {
377
+ // A balloon over a face is the one thing comics never do, so balloons
378
+ // route *around* these; a speech tail lands on the box's near edge.
379
+ const anchors = new Map();
380
+ const faceBox = new Map();
381
+ const faces = [];
382
+ for (const item of placed) {
383
+ // A mirrored figure's face is at `width - faceX` from its left edge, so
384
+ // the balloon tail must follow it or it would point at the back of the head.
385
+ const faceX = item.mirror ? item.figure.width - item.figure.faceX : item.figure.faceX;
386
+ const ax = item.x + faceX * item.scale;
387
+ const headH = item.figure.headHeight * item.scale;
388
+ anchors.set(item.speaker, {
389
+ x: ax,
390
+ // The crown anchor, used by *thought* balloons. Depth is a capped
391
+ // fraction of head height so the cap stays in the hairline at any
392
+ // scale — a speech tail does not use this, it lands via {@link headEdgeTip}.
393
+ y: item.y + Math.min(headH * 0.2, 16),
394
+ });
395
+ const halfW = headH * 0.44;
396
+ const box = { left: ax - halfW, right: ax + halfW, top: item.y, bottom: item.y + headH };
397
+ faceBox.set(item.speaker, box);
398
+ faces.push(box);
399
+ }
400
+ return { anchors, faceBox, faces };
401
+ }
402
+ /**
403
+ * The pre-driver vertical stack, kept as {@link drawBalloons}'s fallback for
404
+ * a panel the layout driver refuses. Not the driver's algorithm — nothing
405
+ * but the fallback path calls it.
406
+ */
407
+ function stackBalloons(ctx, panel, width, heads) {
408
+ const { anchors, faceBox, faces } = heads;
409
+ const faceCentre = (f) => f ? (f.left + f.right) / 2 : null;
410
+ // Empty margins, still used to float a *thought* aside off in open space;
411
+ // a *speech* balloon straddles its speaker.
412
+ const faceLeft = faces.length > 0 ? Math.min(...faces.map((f) => f.left)) : width;
413
+ const faceRight = faces.length > 0 ? Math.max(...faces.map((f) => f.right)) : 0;
414
+ const asideSide = width - 10 - faceRight >= faceLeft - 10 ? "right" : "left";
415
+ // The panel's balloons in draw order, each bound to its speaker's head:
416
+ // the caption and any bodiless speaker route with no anchor, the rest straddle.
417
+ const queue = panel.speakers.map((balloon) => {
418
+ const face = balloon.speaker === CAPTION ? undefined : faceBox.get(balloon.speaker);
419
+ const anchor = balloon.speaker === CAPTION ? null : (anchors.get(balloon.speaker) ?? null);
420
+ return { balloon, anchor, face, headX: faceCentre(face) };
421
+ });
422
+ // A balloon that owns the panel spreads across most of it; the original
423
+ // only tightens the free rect when *other* balloons share the width.
424
+ // Since balloons always stack vertically here, "shares the width" reduces
425
+ // to "there is more than one".
426
+ const cap = queue.length === 1 ? width - 20 : width * 0.66;
427
+ // The tail is capped, not stretched: a balloon docks near its head with a
428
+ // short tail. We pin balloons to the panel top while figures stand at the
429
+ // bottom, so `TAIL_MAX` drops a tailed balloon toward its head until the
430
+ // tail is no longer than this, leaving the empty space above it.
431
+ const TAIL_MAX = 70;
432
+ let y = 8;
433
+ for (const item of queue) {
434
+ const balloon = item.balloon;
435
+ const isSpeech = balloon.balloon === "speech";
436
+ // A thought balloon's scallops bulge outward; keep that bulge clear too.
437
+ const bulge = balloon.balloon === "thought" ? 14 : 0;
438
+ const measured = measureBalloon(ctx, balloon, cap);
439
+ // Drop a tailed balloon toward its speaker so the tail stays short. The
440
+ // head top is the face box's top for speech, the crown anchor for a thought.
441
+ const headTopY = item.face ? item.face.top : (item.anchor ? item.anchor.y : null);
442
+ if (headTopY !== null && balloon.balloon !== "caption") {
443
+ y = Math.max(y, headTopY - TAIL_MAX - measured.height);
444
+ }
445
+ let x;
446
+ if (isSpeech && item.headX !== null) {
447
+ // Straddle the speaker: the head's x sits inside the balloon's span,
448
+ // so the bottom tail drops straight to it. The symmetric midpoint of
449
+ // the legal range is taken deterministically rather than rolled.
450
+ x = Math.max(10, Math.min(item.headX - measured.width / 2, width - measured.width - 10));
451
+ }
452
+ else if (balloon.balloon === "caption") {
453
+ x = 10; // a caption is a top box, pinned left, spoken by nobody.
454
+ }
455
+ else {
456
+ // A thought aside floats in the roomier margin, clear of every face.
457
+ x = asideSide === "right" ? width - measured.width - 10 : 10;
458
+ x = clearOfFaces(x, y, measured.width, measured.height, bulge, faces, width);
459
+ }
460
+ const box = { x, y, width: measured.width, height: measured.height };
461
+ // A speech tail lands on the head's silhouette nearest the balloon (its
462
+ // crown, since the balloon straddles from above); a thought aside keeps
463
+ // the crown anchor.
464
+ const target = isSpeech && item.face ? headEdgeTip(box, item.face) : item.anchor;
465
+ drawBalloon(ctx, balloon, box, measured.lines, target);
466
+ y += measured.height + 8;
467
+ }
468
+ }
469
+ /**
470
+ * Where a speech tail should end: the point on the head's silhouette (its
471
+ * face keep-out box) nearest the balloon — the crown when the balloon is
472
+ * above, the temple when it is to the side. Aiming at the head's centre
473
+ * would cross the face and land on the nose when the balloon sits beside it.
474
+ */
475
+ function headEdgeTip(box, face) {
476
+ const headCx = (face.left + face.right) / 2;
477
+ // The tail root rides the balloon's bottom edge, as near the head as it fits.
478
+ const rootX = Math.min(Math.max(headCx, box.x), box.x + box.width);
479
+ const rootY = box.y + box.height;
480
+ return {
481
+ x: Math.min(Math.max(rootX, face.left), face.right),
482
+ y: Math.min(Math.max(rootY, face.top), face.bottom),
483
+ };
484
+ }
485
+ /**
486
+ * Slide a box horizontally until it no longer overlaps any face box, moving
487
+ * it toward whichever side needs less travel and still fits the panel. When
488
+ * the box is too wide to clear at all, it is left where it was and only
489
+ * nudged back inside the panel edges.
490
+ */
491
+ export function clearOfFaces(x, y, w, h, bulge, faces, width) {
492
+ for (const f of faces) {
493
+ const top = y - bulge;
494
+ const bottom = y + h + bulge + 24; // the tail can reach below the box
495
+ if (bottom <= f.top || top >= f.bottom)
496
+ continue; // clears vertically
497
+ const left = x - bulge;
498
+ const right = x + w + bulge;
499
+ if (right <= f.left || left >= f.right)
500
+ continue; // already clears horizontally
501
+ const toRight = f.right + bulge + 6 - left; // x delta to sit right of the head
502
+ const toLeft = f.left - bulge - 6 - right; // (negative) to sit left of it
503
+ const canRight = x + toRight + w + 10 <= width;
504
+ const canLeft = x + toLeft >= 10;
505
+ if (canRight && (!canLeft || toRight <= -toLeft))
506
+ x += toRight;
507
+ else if (canLeft)
508
+ x += toLeft;
509
+ }
510
+ return Math.max(10, Math.min(x, width - w - 10));
511
+ }
package/package.json ADDED
@@ -0,0 +1,30 @@
1
+ {
2
+ "name": "@toonstrip/core",
3
+ "version": "0.1.0",
4
+ "license": "MIT",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "types": "./dist/index.d.ts",
8
+ "files": [
9
+ "dist"
10
+ ],
11
+ "scripts": {
12
+ "build": "tsc -p tsconfig.json",
13
+ "test": "vitest run",
14
+ "lint": "tsc -p tsconfig.json --noEmit",
15
+ "example:render-fixture": "node --experimental-strip-types examples/render-fixture.ts"
16
+ },
17
+ "dependencies": {
18
+ "@toonstrip/schema": "workspace:*",
19
+ "@napi-rs/canvas": "^0.1.65"
20
+ },
21
+ "devDependencies": {
22
+ "@toonstrip/pack-comic-chat": "workspace:*",
23
+ "@toonstrip/pack-example": "workspace:*",
24
+ "typescript": "^5.6.3",
25
+ "vitest": "^2.1.4"
26
+ },
27
+ "publishConfig": {
28
+ "access": "public"
29
+ }
30
+ }