@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/balloon-layout.d.ts +111 -0
- package/dist/balloon-layout.js +286 -0
- package/dist/balloon.d.ts +80 -0
- package/dist/balloon.js +335 -0
- package/dist/cast-manifest.d.ts +60 -0
- package/dist/cast-manifest.js +10 -0
- package/dist/emotion.d.ts +29 -0
- package/dist/emotion.js +38 -0
- package/dist/figure.d.ts +65 -0
- package/dist/figure.js +93 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +8 -0
- package/dist/layout.d.ts +71 -0
- package/dist/layout.js +71 -0
- package/dist/pose.d.ts +64 -0
- package/dist/pose.js +165 -0
- package/dist/render.d.ts +190 -0
- package/dist/render.js +511 -0
- package/package.json +30 -0
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
|
+
}
|