@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/balloon.js
ADDED
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one font every balloon is measured and drawn in. Exported because
|
|
3
|
+
* measurement happens in more than one place (the renderer, and any
|
|
4
|
+
* caller-side pre-check), and both must break lines identically.
|
|
5
|
+
*/
|
|
6
|
+
export const BALLOON_FONT = '13px "Comic Sans MS", "Comic Neue", ui-rounded, cursive';
|
|
7
|
+
const PADDING = 10;
|
|
8
|
+
/**
|
|
9
|
+
* A thought balloon's scallops bulge outward from its text box, so the text
|
|
10
|
+
* is inset by a bump radius or the first/last words sit under the cloud's
|
|
11
|
+
* own outline. A speech balloon and a caption need no such allowance.
|
|
12
|
+
*/
|
|
13
|
+
const THINK_INSET = 9;
|
|
14
|
+
export const LINE_HEIGHT = 15;
|
|
15
|
+
/** How far a tail must clear its balloon's edge before angling toward its speaker. */
|
|
16
|
+
const TAIL_MIN_EXIT = 2;
|
|
17
|
+
/**
|
|
18
|
+
* The balloon outline ripples instead of running dead straight: each edge is
|
|
19
|
+
* walked and alternate waypoints pushed outward along the edge normal, then
|
|
20
|
+
* the whole ring (corners included) is splined through one closed curve —
|
|
21
|
+
* what makes the outline read hand-drawn instead of a rounded rect.
|
|
22
|
+
*
|
|
23
|
+
* `WAVE_INTERVAL` is the spacing between bumps, `WAVE_AMP` their outward depth.
|
|
24
|
+
*/
|
|
25
|
+
const WAVE_INTERVAL = 22;
|
|
26
|
+
const WAVE_AMP = 6;
|
|
27
|
+
/** Cardinal-spline tension for the outline — 0.5 is the Catmull-Rom value. */
|
|
28
|
+
const SPLINE_TENSION = 0.5;
|
|
29
|
+
/** Below this one-line width, a balloon stays a single line rather than being wrapped for shape. */
|
|
30
|
+
export const ONE_LINE_THRESHOLD = 100;
|
|
31
|
+
/** The width:height a wrapped balloon trends toward — square-ish but a touch wide. */
|
|
32
|
+
const TARGET_ASPECT = 1.9;
|
|
33
|
+
/** Ink-area fudge factor used when estimating a compact wrap width. */
|
|
34
|
+
export const AREA_FUDGE = 1.3;
|
|
35
|
+
/** How far the compact goal is pushed toward the free width. */
|
|
36
|
+
const WIDTH_BIAS = 0.5;
|
|
37
|
+
/** Widest single word, the floor a balloon may not wrap below. */
|
|
38
|
+
export function widestWord(ctx, text) {
|
|
39
|
+
const words = text.split(/\s+/).filter((w) => w.length > 0);
|
|
40
|
+
return words.length === 0 ? 0 : Math.max(...words.map((w) => ctx.measureText(w).width));
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* The width a balloon *wants* to wrap at. Short text stays one line; longer
|
|
44
|
+
* text is sized from its ink area so the balloon is compact rather than a
|
|
45
|
+
* wide flat strip, floored by the widest word and capped at the free width.
|
|
46
|
+
* Wrapping at this width is what makes a balloon read as a balloon shape
|
|
47
|
+
* rather than a text block.
|
|
48
|
+
*/
|
|
49
|
+
export function goalWidth(ctx, text, maxWidth) {
|
|
50
|
+
const oneLine = ctx.measureText(text).width;
|
|
51
|
+
if (oneLine <= ONE_LINE_THRESHOLD)
|
|
52
|
+
return Math.min(oneLine, maxWidth);
|
|
53
|
+
// area ~= ink area laid on one line; for a box of width W the height is ~= area/W,
|
|
54
|
+
// so aspect W/(area/W) = W^2/area = TARGET_ASPECT => W = sqrt(TARGET_ASPECT * area).
|
|
55
|
+
const area = AREA_FUDGE * oneLine * LINE_HEIGHT;
|
|
56
|
+
const compact = Math.sqrt(TARGET_ASPECT * area);
|
|
57
|
+
// Spread toward the free width, but never past the one-line width.
|
|
58
|
+
const ceiling = Math.min(maxWidth, oneLine);
|
|
59
|
+
const goal = compact + WIDTH_BIAS * (ceiling - compact);
|
|
60
|
+
return Math.min(Math.max(goal, widestWord(ctx, text)), ceiling);
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Greedy fit: pack words onto a line until the next would overflow, then
|
|
64
|
+
* break. A word longer than the whole width is kept intact on its own line
|
|
65
|
+
* rather than split mid-character.
|
|
66
|
+
*/
|
|
67
|
+
function greedyWrap(ctx, text, maxWidth) {
|
|
68
|
+
const lines = [];
|
|
69
|
+
let line = "";
|
|
70
|
+
for (const word of text.split(/\s+/).filter((w) => w.length > 0)) {
|
|
71
|
+
const candidate = line === "" ? word : `${line} ${word}`;
|
|
72
|
+
if (line !== "" && ctx.measureText(candidate).width > maxWidth) {
|
|
73
|
+
lines.push(line);
|
|
74
|
+
line = word;
|
|
75
|
+
}
|
|
76
|
+
else {
|
|
77
|
+
line = candidate;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
if (line !== "")
|
|
81
|
+
lines.push(line);
|
|
82
|
+
return lines;
|
|
83
|
+
}
|
|
84
|
+
export function measureBalloon(ctx, balloon, maxWidth) {
|
|
85
|
+
const pad = PADDING + (balloon.balloon === "thought" ? THINK_INSET : 0);
|
|
86
|
+
const lines = greedyWrap(ctx, balloon.text, goalWidth(ctx, balloon.text, maxWidth - pad * 2));
|
|
87
|
+
const width = Math.min(maxWidth, Math.max(...lines.map((line) => ctx.measureText(line).width)) + pad * 2);
|
|
88
|
+
return { lines, width, height: lines.length * LINE_HEIGHT + pad * 2 };
|
|
89
|
+
}
|
|
90
|
+
/** The chrome a balloon kind puts between its outline and its text, each side. */
|
|
91
|
+
export function chromeFor(balloon) {
|
|
92
|
+
return PADDING + (balloon.balloon === "thought" ? THINK_INSET : 0);
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Wrap and measure at a width the *caller* chose, rather than at the width
|
|
96
|
+
* {@link goalWidth} would pick. The entry point the multi-balloon layout
|
|
97
|
+
* driver needs, once the free rect and intervening routes have narrowed a
|
|
98
|
+
* balloon's span below its own preferred width.
|
|
99
|
+
*/
|
|
100
|
+
export function measureAtWidth(ctx, balloon, boxWidth) {
|
|
101
|
+
const pad = chromeFor(balloon);
|
|
102
|
+
const inner = Math.max(1, boxWidth - pad * 2);
|
|
103
|
+
const lines = greedyWrap(ctx, balloon.text, inner);
|
|
104
|
+
const width = Math.min(boxWidth, Math.max(...lines.map((line) => ctx.measureText(line).width)) + pad * 2);
|
|
105
|
+
return { lines, width, height: lines.length * LINE_HEIGHT + pad * 2 };
|
|
106
|
+
}
|
|
107
|
+
/** Appended to the part of a too-tall balloon that fit, prepended to the rest. */
|
|
108
|
+
export const CONTINUATION = "...";
|
|
109
|
+
/**
|
|
110
|
+
* Reflow the text at `boxWidth`, keep as many lines as `boxHeight` holds, and
|
|
111
|
+
* hand the rest back for the caller to re-add. Lets a balloon too tall for
|
|
112
|
+
* its free rect force-fit into it and split rather than being refused
|
|
113
|
+
* outright — the layout driver's ({@link "./balloon-layout.js".layoutBalloons})
|
|
114
|
+
* last resort for a lone balloon in an otherwise empty panel.
|
|
115
|
+
*
|
|
116
|
+
* At least one word is always kept: a word that fits nowhere is drawn
|
|
117
|
+
* clipped rather than handing back a leftover identical to the input.
|
|
118
|
+
*/
|
|
119
|
+
export function splitHeight(ctx, balloon, boxWidth, boxHeight) {
|
|
120
|
+
const pad = chromeFor(balloon);
|
|
121
|
+
const inner = Math.max(1, boxWidth - pad * 2);
|
|
122
|
+
const lines = greedyWrap(ctx, balloon.text, inner);
|
|
123
|
+
const maxLines = Math.max(1, Math.floor(boxHeight / LINE_HEIGHT));
|
|
124
|
+
if (lines.length < maxLines)
|
|
125
|
+
return { text: balloon.text, rest: null };
|
|
126
|
+
const words = balloon.text.split(/\s+/).filter((w) => w.length > 0);
|
|
127
|
+
// Whole lines above the one being broken.
|
|
128
|
+
let kept = lines.slice(0, maxLines - 1)
|
|
129
|
+
.reduce((n, line) => n + line.split(/\s+/).filter((w) => w.length > 0).length, 0);
|
|
130
|
+
// ...then as much of that line as fits beside the continuation mark.
|
|
131
|
+
const room = inner - ctx.measureText(CONTINUATION).width;
|
|
132
|
+
let line = "";
|
|
133
|
+
for (const word of lines[maxLines - 1]?.split(/\s+/).filter((w) => w.length > 0) ?? []) {
|
|
134
|
+
const candidate = line === "" ? word : `${line} ${word}`;
|
|
135
|
+
if (line !== "" && ctx.measureText(candidate).width > room)
|
|
136
|
+
break;
|
|
137
|
+
line = candidate;
|
|
138
|
+
kept += 1;
|
|
139
|
+
}
|
|
140
|
+
kept = Math.max(1, Math.min(kept, words.length));
|
|
141
|
+
if (kept >= words.length)
|
|
142
|
+
return { text: balloon.text, rest: null };
|
|
143
|
+
return {
|
|
144
|
+
text: `${words.slice(0, kept).join(" ")}${CONTINUATION}`,
|
|
145
|
+
rest: `${CONTINUATION}${words.slice(kept).join(" ")}`,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* Draw a balloon in its kind's shape, with a tail toward `target` when the
|
|
150
|
+
* kind has one. Captions do not: a box is the narrator's, spoken by nobody.
|
|
151
|
+
*/
|
|
152
|
+
export function drawBalloon(ctx, balloon, box, lines, target) {
|
|
153
|
+
ctx.save();
|
|
154
|
+
ctx.lineJoin = "round";
|
|
155
|
+
ctx.strokeStyle = "#000";
|
|
156
|
+
ctx.fillStyle = "#fff";
|
|
157
|
+
ctx.lineWidth = 1.5;
|
|
158
|
+
switch (balloon.balloon) {
|
|
159
|
+
case "caption":
|
|
160
|
+
ctx.beginPath();
|
|
161
|
+
ctx.rect(box.x, box.y, box.width, box.height);
|
|
162
|
+
ctx.fill();
|
|
163
|
+
ctx.stroke();
|
|
164
|
+
break;
|
|
165
|
+
case "thought":
|
|
166
|
+
cloud(ctx, box);
|
|
167
|
+
ctx.fill();
|
|
168
|
+
ctx.stroke();
|
|
169
|
+
if (target)
|
|
170
|
+
bubbleTrail(ctx, box, target);
|
|
171
|
+
break;
|
|
172
|
+
case "speech":
|
|
173
|
+
roundedSpeech(ctx, box, target);
|
|
174
|
+
ctx.fill();
|
|
175
|
+
ctx.stroke();
|
|
176
|
+
break;
|
|
177
|
+
}
|
|
178
|
+
ctx.fillStyle = "#000";
|
|
179
|
+
ctx.textBaseline = "top";
|
|
180
|
+
const pad = PADDING + (balloon.balloon === "thought" ? THINK_INSET : 0);
|
|
181
|
+
for (const [i, line] of lines.entries()) {
|
|
182
|
+
// Centred, so the ragged wrap edge reads as shared between both sides.
|
|
183
|
+
const width = ctx.measureText(line).width;
|
|
184
|
+
ctx.fillText(line, box.x + (box.width - width) / 2, box.y + pad + i * LINE_HEIGHT);
|
|
185
|
+
}
|
|
186
|
+
ctx.restore();
|
|
187
|
+
}
|
|
188
|
+
/** A scalloped outline, drawn as one continuous closed path of outward-bulging arcs. */
|
|
189
|
+
function cloud(ctx, box) {
|
|
190
|
+
const cols = Math.max(4, Math.round(box.width / 26));
|
|
191
|
+
const rows = Math.max(2, Math.round(box.height / 24));
|
|
192
|
+
const rx = box.width / (cols * 2);
|
|
193
|
+
const ry = box.height / (rows * 2);
|
|
194
|
+
const { x, y, width: w, height: h } = box;
|
|
195
|
+
ctx.beginPath();
|
|
196
|
+
ctx.moveTo(x, y);
|
|
197
|
+
// Top edge, left -> right, each scallop bulging up.
|
|
198
|
+
for (let i = 0; i < cols; i++) {
|
|
199
|
+
ctx.arc(x + (w * (i + 0.5)) / cols, y, rx, Math.PI, 2 * Math.PI, false);
|
|
200
|
+
}
|
|
201
|
+
// Right edge, top -> bottom, bulging right.
|
|
202
|
+
for (let i = 0; i < rows; i++) {
|
|
203
|
+
ctx.arc(x + w, y + (h * (i + 0.5)) / rows, ry, -Math.PI / 2, Math.PI / 2, false);
|
|
204
|
+
}
|
|
205
|
+
// Bottom edge, right -> left, bulging down.
|
|
206
|
+
for (let i = cols - 1; i >= 0; i--) {
|
|
207
|
+
ctx.arc(x + (w * (i + 0.5)) / cols, y + h, rx, 0, Math.PI, false);
|
|
208
|
+
}
|
|
209
|
+
// Left edge, bottom -> top, bulging left.
|
|
210
|
+
for (let i = rows - 1; i >= 0; i--) {
|
|
211
|
+
ctx.arc(x, y + (h * (i + 0.5)) / rows, ry, Math.PI / 2, (3 * Math.PI) / 2, false);
|
|
212
|
+
}
|
|
213
|
+
ctx.closePath();
|
|
214
|
+
}
|
|
215
|
+
/**
|
|
216
|
+
* A thought balloon's tail: a line of bubbles rather than an arrow — the
|
|
217
|
+
* mark that says *interior* at a glance. Three bubbles at even spacing,
|
|
218
|
+
* smallest nearest the speaker.
|
|
219
|
+
*/
|
|
220
|
+
function bubbleTrail(ctx, box, target) {
|
|
221
|
+
const from = { x: box.x + box.width / 2, y: box.y + box.height };
|
|
222
|
+
for (let i = 1; i <= 3; i++) {
|
|
223
|
+
const t = i / 4;
|
|
224
|
+
ctx.beginPath();
|
|
225
|
+
ctx.arc(from.x + (target.x - from.x) * t, from.y + (target.y - from.y) * t, 6 - i * 1.4, 0, Math.PI * 2);
|
|
226
|
+
ctx.fill();
|
|
227
|
+
ctx.stroke();
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
/**
|
|
231
|
+
* Walk edge `a->b` and append its outward bumps to `pts`: alternating
|
|
232
|
+
* waypoints, a bump pushed out by `WAVE_AMP` along the edge normal `(nx,ny)`
|
|
233
|
+
* then a point left on the edge, so the outline only ever bulges outward,
|
|
234
|
+
* never pinches in. The corner points that bracket each edge are pushed by
|
|
235
|
+
* the caller; this adds only the between-corner bumps.
|
|
236
|
+
*
|
|
237
|
+
* Below two intervals there are no bumps — the edge stays a straight
|
|
238
|
+
* spline chord.
|
|
239
|
+
*/
|
|
240
|
+
function pushWaves(pts, ax, ay, bx, by, nx, ny) {
|
|
241
|
+
const dist = Math.hypot(bx - ax, by - ay);
|
|
242
|
+
const n = Math.floor(dist / WAVE_INTERVAL);
|
|
243
|
+
if (n < 2)
|
|
244
|
+
return;
|
|
245
|
+
const ux = (bx - ax) / dist, uy = (by - ay) / dist;
|
|
246
|
+
const seg = dist / n;
|
|
247
|
+
for (let i = 1; i < n; i++) {
|
|
248
|
+
const px = ax + ux * seg * i, py = ay + uy * seg * i;
|
|
249
|
+
// Odd steps bulge out, even steps stay on the edge.
|
|
250
|
+
if (i % 2 === 1)
|
|
251
|
+
pts.push({ x: px + nx * WAVE_AMP, y: py + ny * WAVE_AMP });
|
|
252
|
+
else
|
|
253
|
+
pts.push({ x: px, y: py });
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
/**
|
|
257
|
+
* Render a ring of waypoints as one smooth closed (or open) loop: a
|
|
258
|
+
* Catmull-Rom curve through every point, each segment converted to a cubic
|
|
259
|
+
* Bezier with control points `p +/- (next-prev)*t/3`; `t = SPLINE_TENSION`.
|
|
260
|
+
* `open` leaves a gap between the last and first point (the bottom stretch a
|
|
261
|
+
* tail bridges); otherwise the loop closes on itself. Assumes the path is
|
|
262
|
+
* already open and positions the current point itself.
|
|
263
|
+
*/
|
|
264
|
+
function strokeCardinal(ctx, pts, open) {
|
|
265
|
+
const n = pts.length;
|
|
266
|
+
if (n < 2)
|
|
267
|
+
return;
|
|
268
|
+
const f = SPLINE_TENSION / 3;
|
|
269
|
+
const at = (i) => open ? pts[Math.min(Math.max(i, 0), n - 1)] : pts[(i + n) % n];
|
|
270
|
+
ctx.moveTo(pts[0].x, pts[0].y);
|
|
271
|
+
const last = open ? n - 1 : n;
|
|
272
|
+
for (let i = 0; i < last; i++) {
|
|
273
|
+
const p0 = at(i - 1), p1 = at(i), p2 = at(i + 1), p3 = at(i + 2);
|
|
274
|
+
ctx.bezierCurveTo(p1.x + (p2.x - p0.x) * f, p1.y + (p2.y - p0.y) * f, p2.x - (p3.x - p1.x) * f, p2.y - (p3.y - p1.y) * f, p2.x, p2.y);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
/**
|
|
278
|
+
* A speech balloon: a hand-drawn outline with a bowed tail rooted on the
|
|
279
|
+
* bottom edge. The tail always leaves the bottom edge, never a side, its
|
|
280
|
+
* root sliding along that edge to sit under the speaker, bowed as two curves
|
|
281
|
+
* with a sharp tip (kept off the body spline so the point stays crisp). A
|
|
282
|
+
* 45-degree-from-vertical clamp keeps a head off to the side from dragging
|
|
283
|
+
* the tip out past a readable angle.
|
|
284
|
+
*/
|
|
285
|
+
function roundedSpeech(ctx, box, target) {
|
|
286
|
+
const { x, y, width: w, height: h } = box;
|
|
287
|
+
const right = x + w, bottom = y + h;
|
|
288
|
+
if (!target) {
|
|
289
|
+
// No speaker: one closed ring — top, right, bottom, left — all corners
|
|
290
|
+
// and bumps splined together into a plain blob.
|
|
291
|
+
const ring = [];
|
|
292
|
+
ring.push({ x, y });
|
|
293
|
+
pushWaves(ring, x, y, right, y, 0, -1); // top, bulging up
|
|
294
|
+
ring.push({ x: right, y });
|
|
295
|
+
pushWaves(ring, right, y, right, bottom, 1, 0); // right, bulging right
|
|
296
|
+
ring.push({ x: right, y: bottom });
|
|
297
|
+
pushWaves(ring, right, bottom, x, bottom, 0, 1); // bottom, bulging down
|
|
298
|
+
ring.push({ x, y: bottom });
|
|
299
|
+
pushWaves(ring, x, bottom, x, y, -1, 0); // left, bulging left
|
|
300
|
+
ctx.beginPath();
|
|
301
|
+
strokeCardinal(ctx, ring, false);
|
|
302
|
+
ctx.closePath();
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
// Tail roots ride the bottom edge under the head, far enough from a corner
|
|
306
|
+
// that both roots stay on the flat run.
|
|
307
|
+
const root = Math.min(Math.max(target.x, x + 16), right - 16);
|
|
308
|
+
const rootR = root + 9, rootL = root - 9;
|
|
309
|
+
const tipY = Math.max(target.y, bottom + TAIL_MIN_EXIT);
|
|
310
|
+
// 45-degree clamp: the tip's horizontal run may not exceed its vertical
|
|
311
|
+
// drop, so a side speaker cannot flatten the tail.
|
|
312
|
+
const dy = tipY - bottom;
|
|
313
|
+
const tipX = Math.min(Math.max(target.x, root - dy), root + dy);
|
|
314
|
+
const alt = 0.05 * Math.hypot(tipX - root, dy); // bow depth
|
|
315
|
+
// Body ring, open along the bottom between the two roots: start at rootR,
|
|
316
|
+
// run the bottom out to the right corner, up and over and down, back along
|
|
317
|
+
// the bottom to rootL. The tail then bridges rootL->tip->rootR as sharp quads.
|
|
318
|
+
const ring = [];
|
|
319
|
+
ring.push({ x: rootR, y: bottom });
|
|
320
|
+
pushWaves(ring, rootR, bottom, right, bottom, 0, 1); // bottom, right of the tail
|
|
321
|
+
ring.push({ x: right, y: bottom });
|
|
322
|
+
pushWaves(ring, right, bottom, right, y, 1, 0); // right, bulging right
|
|
323
|
+
ring.push({ x: right, y });
|
|
324
|
+
pushWaves(ring, right, y, x, y, 0, -1); // top, bulging up
|
|
325
|
+
ring.push({ x, y });
|
|
326
|
+
pushWaves(ring, x, y, x, bottom, -1, 0); // left, bulging left
|
|
327
|
+
ring.push({ x, y: bottom });
|
|
328
|
+
pushWaves(ring, x, bottom, rootL, bottom, 0, 1); // bottom, left of the tail
|
|
329
|
+
ring.push({ x: rootL, y: bottom });
|
|
330
|
+
ctx.beginPath();
|
|
331
|
+
strokeCardinal(ctx, ring, true); // ends at rootL (current point)
|
|
332
|
+
ctx.quadraticCurveTo((rootL + tipX) / 2 - alt, (bottom + tipY) / 2, tipX, tipY);
|
|
333
|
+
ctx.quadraticCurveTo((tipX + rootR) / 2 + alt, (tipY + bottom) / 2, rootR, bottom);
|
|
334
|
+
ctx.closePath();
|
|
335
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The pack contract: what an asset pack ships and what the renderer reads.
|
|
3
|
+
*
|
|
4
|
+
* Types only. A complex character composites faces onto torsos at draw time
|
|
5
|
+
* (one face x one torso = one figure), so a pack ships parts, not baked
|
|
6
|
+
* figures — the join is the few lines of arithmetic in {@link figureFor}, and
|
|
7
|
+
* keeping parts separate is what lets {@link PoseCycle} round-robin distinct
|
|
8
|
+
* pairings instead of freezing one drawing per emotion.
|
|
9
|
+
*/
|
|
10
|
+
/** A rectangle within a sprite sheet. */
|
|
11
|
+
export interface SheetRect {
|
|
12
|
+
x: number;
|
|
13
|
+
y: number;
|
|
14
|
+
width: number;
|
|
15
|
+
height: number;
|
|
16
|
+
}
|
|
17
|
+
/** One drawing in a sheet, plus everything the face/torso join needs. */
|
|
18
|
+
export interface PackedPart {
|
|
19
|
+
kind: "face" | "torso" | "body";
|
|
20
|
+
index: number;
|
|
21
|
+
emotion: string;
|
|
22
|
+
angle: number;
|
|
23
|
+
intensity: number;
|
|
24
|
+
/** Neck socket (torso), or its counterpart inside the face image. */
|
|
25
|
+
cx: number;
|
|
26
|
+
cy: number;
|
|
27
|
+
/** Per-face nudge on the socket — a tilted head, a thrown-back laugh. */
|
|
28
|
+
cxDelta: number;
|
|
29
|
+
cyDelta: number;
|
|
30
|
+
/** Where a balloon tail attaches. */
|
|
31
|
+
x: number;
|
|
32
|
+
figure: SheetRect;
|
|
33
|
+
/** The halo, when the pose has one. Drawn before any figure. */
|
|
34
|
+
aura: SheetRect | null;
|
|
35
|
+
}
|
|
36
|
+
export interface PackedAvatar {
|
|
37
|
+
name: string;
|
|
38
|
+
type: string;
|
|
39
|
+
/** Whether the torso is drawn over the face's collar, or under it. */
|
|
40
|
+
torsoFirst: boolean;
|
|
41
|
+
sheet: string;
|
|
42
|
+
sheetWidth: number;
|
|
43
|
+
sheetHeight: number;
|
|
44
|
+
faces: PackedPart[];
|
|
45
|
+
torsos: PackedPart[];
|
|
46
|
+
/** Simple avatars only; complex avatars leave this empty. */
|
|
47
|
+
bodies: PackedPart[];
|
|
48
|
+
}
|
|
49
|
+
export interface PackedBackdrop {
|
|
50
|
+
id: string;
|
|
51
|
+
file: string;
|
|
52
|
+
width: number;
|
|
53
|
+
height: number;
|
|
54
|
+
}
|
|
55
|
+
export interface CastManifest {
|
|
56
|
+
version: 1;
|
|
57
|
+
/** Character id (a {@link "@toonstrip/schema".Body.character}) → avatar. */
|
|
58
|
+
cast: Record<string, PackedAvatar>;
|
|
59
|
+
backdrops: PackedBackdrop[];
|
|
60
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The pack contract: what an asset pack ships and what the renderer reads.
|
|
3
|
+
*
|
|
4
|
+
* Types only. A complex character composites faces onto torsos at draw time
|
|
5
|
+
* (one face x one torso = one figure), so a pack ships parts, not baked
|
|
6
|
+
* figures — the join is the few lines of arithmetic in {@link figureFor}, and
|
|
7
|
+
* keeping parts separate is what lets {@link PoseCycle} round-robin distinct
|
|
8
|
+
* pairings instead of freezing one drawing per emotion.
|
|
9
|
+
*/
|
|
10
|
+
export {};
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The emotion wheel's fixed points: an angle (which emotion), an intensity
|
|
3
|
+
* 0.0-1.0 (how much), and eight gesture sentinels placed outside the wheel's
|
|
4
|
+
* metric so a gesture never nearest-neighbour-matches a facial expression.
|
|
5
|
+
*/
|
|
6
|
+
export interface Emotion {
|
|
7
|
+
/** Radians on the wheel, or a gesture sentinel above 2*PI. */
|
|
8
|
+
angle: number;
|
|
9
|
+
/** 0.0-1.0. */
|
|
10
|
+
intensity: number;
|
|
11
|
+
}
|
|
12
|
+
/** One weighted candidate emotion. Higher priority is tried first. */
|
|
13
|
+
export interface EmotionOption extends Emotion {
|
|
14
|
+
priority: number;
|
|
15
|
+
}
|
|
16
|
+
export declare const EMOTION_NAMES: readonly [null, "HAPPY", "COY", "BORED", "SCARED", "SAD", "ANGRY", "SHOUT", "LAUGH", "NEUTRAL", "WAVE", "POINTOTHER", "POINTSELF", "DOUBLEPOINT", "SHRUG", "3QRWALK", "SIDEWALK", "3QFWALK"];
|
|
17
|
+
export type EmotionName = Exclude<(typeof EMOTION_NAMES)[number], null>;
|
|
18
|
+
/** Points on the emotion wheel that have an angle. */
|
|
19
|
+
export declare const NEMOTIONS = 8;
|
|
20
|
+
/**
|
|
21
|
+
* Named-emotion index to runtime angle. Indices 1-8 are angles around the
|
|
22
|
+
* wheel; 9 (NEUTRAL) is 0.0, the same angle as HAPPY, distinguished only by
|
|
23
|
+
* intensity 0. The eight gestures (10-17) are sentinels above 2*PI,
|
|
24
|
+
* deliberately outside the angular metric.
|
|
25
|
+
*/
|
|
26
|
+
export declare const EMOTION_ANGLES: readonly number[];
|
|
27
|
+
export declare function emotionAngle(index: number): number;
|
|
28
|
+
/** True for the gesture sentinels, which no angular metric can reach. */
|
|
29
|
+
export declare function isGesture(angle: number): boolean;
|
package/dist/emotion.js
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The emotion wheel's fixed points: an angle (which emotion), an intensity
|
|
3
|
+
* 0.0-1.0 (how much), and eight gesture sentinels placed outside the wheel's
|
|
4
|
+
* metric so a gesture never nearest-neighbour-matches a facial expression.
|
|
5
|
+
*/
|
|
6
|
+
export const EMOTION_NAMES = [
|
|
7
|
+
null, "HAPPY", "COY", "BORED", "SCARED", "SAD", "ANGRY", "SHOUT", "LAUGH",
|
|
8
|
+
"NEUTRAL", "WAVE", "POINTOTHER", "POINTSELF", "DOUBLEPOINT", "SHRUG",
|
|
9
|
+
"3QRWALK", "SIDEWALK", "3QFWALK",
|
|
10
|
+
];
|
|
11
|
+
/** Points on the emotion wheel that have an angle. */
|
|
12
|
+
export const NEMOTIONS = 8;
|
|
13
|
+
/**
|
|
14
|
+
* Named-emotion index to runtime angle. Indices 1-8 are angles around the
|
|
15
|
+
* wheel; 9 (NEUTRAL) is 0.0, the same angle as HAPPY, distinguished only by
|
|
16
|
+
* intensity 0. The eight gestures (10-17) are sentinels above 2*PI,
|
|
17
|
+
* deliberately outside the angular metric.
|
|
18
|
+
*/
|
|
19
|
+
export const EMOTION_ANGLES = [
|
|
20
|
+
0, // index 0 unused
|
|
21
|
+
(0 * 2 * Math.PI) / 8, // HAPPY
|
|
22
|
+
(1 * 2 * Math.PI) / 8, // COY
|
|
23
|
+
(2 * 2 * Math.PI) / 8, // BORED
|
|
24
|
+
(3 * 2 * Math.PI) / 8, // SCARED
|
|
25
|
+
(4 * 2 * Math.PI) / 8, // SAD
|
|
26
|
+
(5 * 2 * Math.PI) / 8, // ANGRY
|
|
27
|
+
(6 * 2 * Math.PI) / 8, // SHOUT
|
|
28
|
+
(7 * 2 * Math.PI) / 8, // LAUGH
|
|
29
|
+
0.0, // NEUTRAL — same angle as HAPPY by design
|
|
30
|
+
1001, 1002, 1003, 1004, 1005, 1006, 1007, 1008, // gesture sentinels
|
|
31
|
+
];
|
|
32
|
+
export function emotionAngle(index) {
|
|
33
|
+
return EMOTION_ANGLES[index] ?? 0;
|
|
34
|
+
}
|
|
35
|
+
/** True for the gesture sentinels, which no angular metric can reach. */
|
|
36
|
+
export function isGesture(angle) {
|
|
37
|
+
return angle > 2 * Math.PI;
|
|
38
|
+
}
|
package/dist/figure.d.ts
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Figures: pose selection and the face-onto-torso join.
|
|
3
|
+
*
|
|
4
|
+
* A complex character's poses are not baked figures — a face and a torso are
|
|
5
|
+
* separate drawings, composited at draw time — so what is left at render
|
|
6
|
+
* time is the emotion wheel (pure arithmetic, `./pose.js`) plus the join
|
|
7
|
+
* geometry below, re-expressed as `drawImage` calls against a sprite sheet.
|
|
8
|
+
*/
|
|
9
|
+
import type { EmotionOption } from "./emotion.js";
|
|
10
|
+
import type { CastManifest, PackedAvatar, PackedPart } from "./cast-manifest.js";
|
|
11
|
+
/** A drawable sheet image — an `HTMLImageElement`/`ImageBitmap` in a browser,
|
|
12
|
+
* an `@napi-rs/canvas` `Image` in Node. Anything `drawImage` accepts. */
|
|
13
|
+
export type SheetImage = CanvasImageSource;
|
|
14
|
+
export interface Cast {
|
|
15
|
+
manifest: CastManifest;
|
|
16
|
+
/** Character id -> decoded sheet, ready for `drawImage`. */
|
|
17
|
+
sheets: Map<string, SheetImage>;
|
|
18
|
+
backdrops: Map<string, SheetImage>;
|
|
19
|
+
}
|
|
20
|
+
/** A chosen figure: which parts, and the box they occupy in figure space. */
|
|
21
|
+
export interface Figure {
|
|
22
|
+
avatar: PackedAvatar;
|
|
23
|
+
face: PackedPart;
|
|
24
|
+
torso: PackedPart;
|
|
25
|
+
width: number;
|
|
26
|
+
height: number;
|
|
27
|
+
/** Where the head ends, from the top — the balloon tail's vertical target. */
|
|
28
|
+
headHeight: number;
|
|
29
|
+
/** Horizontal anchor for a balloon tail. */
|
|
30
|
+
faceX: number;
|
|
31
|
+
facePos: {
|
|
32
|
+
x: number;
|
|
33
|
+
y: number;
|
|
34
|
+
};
|
|
35
|
+
torsoPos: {
|
|
36
|
+
x: number;
|
|
37
|
+
y: number;
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Per-character memory for the round-robin: begin the scan after the pose
|
|
42
|
+
* used last, so a repeated emotion cycles through whatever variants exist
|
|
43
|
+
* instead of returning the same drawing forever.
|
|
44
|
+
*/
|
|
45
|
+
export interface PoseCycle {
|
|
46
|
+
face: number;
|
|
47
|
+
torso: number;
|
|
48
|
+
}
|
|
49
|
+
export declare function newCycle(): PoseCycle;
|
|
50
|
+
/**
|
|
51
|
+
* Choose a figure for an emotion set and assemble its geometry. The face
|
|
52
|
+
* lands at `torso.cx + face.cxDelta - face.cx`, and the union box may extend
|
|
53
|
+
* above and left of the torso, so everything is rebased onto it.
|
|
54
|
+
*/
|
|
55
|
+
export declare function figureFor(avatar: PackedAvatar, options: readonly EmotionOption[], cycle: PoseCycle): Figure | null;
|
|
56
|
+
/**
|
|
57
|
+
* Draw a figure at `(x, y)`, scaled uniformly — a face never scales
|
|
58
|
+
* differently from the torso it is composited onto. Compositing happens in
|
|
59
|
+
* figure space; the viewport scale is applied afterwards, here, to the
|
|
60
|
+
* assembled result.
|
|
61
|
+
*
|
|
62
|
+
* Draw order: both halos before either figure, then `torsoFirst` decides
|
|
63
|
+
* whether the collar sits over the jaw.
|
|
64
|
+
*/
|
|
65
|
+
export declare function drawFigure(ctx: CanvasRenderingContext2D, sheet: CanvasImageSource, figure: Figure, x: number, y: number, scale: number, mirror?: boolean): void;
|
package/dist/figure.js
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Figures: pose selection and the face-onto-torso join.
|
|
3
|
+
*
|
|
4
|
+
* A complex character's poses are not baked figures — a face and a torso are
|
|
5
|
+
* separate drawings, composited at draw time — so what is left at render
|
|
6
|
+
* time is the emotion wheel (pure arithmetic, `./pose.js`) plus the join
|
|
7
|
+
* geometry below, re-expressed as `drawImage` calls against a sprite sheet.
|
|
8
|
+
*/
|
|
9
|
+
import { selectComplexPose } from "./pose.js";
|
|
10
|
+
export function newCycle() {
|
|
11
|
+
return { face: -1, torso: -1 };
|
|
12
|
+
}
|
|
13
|
+
function rotate(items, start) {
|
|
14
|
+
if (items.length === 0)
|
|
15
|
+
return [];
|
|
16
|
+
const from = ((start % items.length) + items.length) % items.length;
|
|
17
|
+
return [...items.slice(from), ...items.slice(0, from)];
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Choose a figure for an emotion set and assemble its geometry. The face
|
|
21
|
+
* lands at `torso.cx + face.cxDelta - face.cx`, and the union box may extend
|
|
22
|
+
* above and left of the torso, so everything is rebased onto it.
|
|
23
|
+
*/
|
|
24
|
+
export function figureFor(avatar, options, cycle) {
|
|
25
|
+
const faces = rotate(avatar.faces, cycle.face + 1);
|
|
26
|
+
const torsos = rotate(avatar.torsos, cycle.torso + 1);
|
|
27
|
+
const chosen = selectComplexPose(faces, torsos, options);
|
|
28
|
+
const face = chosen.face;
|
|
29
|
+
const torso = chosen.torso;
|
|
30
|
+
if (!face || !torso)
|
|
31
|
+
return null;
|
|
32
|
+
cycle.face = avatar.faces.indexOf(face);
|
|
33
|
+
cycle.torso = avatar.torsos.indexOf(torso);
|
|
34
|
+
const xOffset = torso.cx + face.cxDelta - face.cx;
|
|
35
|
+
const yOffset = torso.cy + face.cyDelta - face.cy;
|
|
36
|
+
const left = Math.min(0, xOffset);
|
|
37
|
+
const top = Math.min(0, yOffset);
|
|
38
|
+
const right = Math.max(torso.figure.width, xOffset + face.figure.width);
|
|
39
|
+
const bottom = Math.max(torso.figure.height, yOffset + face.figure.height);
|
|
40
|
+
const facePos = { x: xOffset - left, y: yOffset - top };
|
|
41
|
+
const torsoPos = { x: -left, y: -top };
|
|
42
|
+
return {
|
|
43
|
+
avatar, face, torso,
|
|
44
|
+
width: right - left,
|
|
45
|
+
height: bottom - top,
|
|
46
|
+
headHeight: yOffset + face.figure.height - top,
|
|
47
|
+
faceX: face.x + xOffset - left,
|
|
48
|
+
facePos, torsoPos,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Draw a figure at `(x, y)`, scaled uniformly — a face never scales
|
|
53
|
+
* differently from the torso it is composited onto. Compositing happens in
|
|
54
|
+
* figure space; the viewport scale is applied afterwards, here, to the
|
|
55
|
+
* assembled result.
|
|
56
|
+
*
|
|
57
|
+
* Draw order: both halos before either figure, then `torsoFirst` decides
|
|
58
|
+
* whether the collar sits over the jaw.
|
|
59
|
+
*/
|
|
60
|
+
export function drawFigure(ctx, sheet, figure, x, y, scale, mirror = false) {
|
|
61
|
+
const { face, torso, facePos, torsoPos } = figure;
|
|
62
|
+
// `mirror` flips the figure horizontally about its own box so it faces the
|
|
63
|
+
// other way — in a two-shot the right-hand figure is mirrored so the two
|
|
64
|
+
// look at each other instead of both facing out. Done with a transform
|
|
65
|
+
// rather than per-part x maths so the face/torso join flips as one unit.
|
|
66
|
+
ctx.save();
|
|
67
|
+
if (mirror) {
|
|
68
|
+
ctx.translate(x + figure.width * scale, y);
|
|
69
|
+
ctx.scale(-1, 1);
|
|
70
|
+
}
|
|
71
|
+
else {
|
|
72
|
+
ctx.translate(x, y);
|
|
73
|
+
}
|
|
74
|
+
const part = (p, rect, pos) => {
|
|
75
|
+
if (!rect)
|
|
76
|
+
return;
|
|
77
|
+
ctx.drawImage(sheet, rect.x, rect.y, rect.width, rect.height,
|
|
78
|
+
// The aura rect and the figure rect share dimensions (packed as a pair),
|
|
79
|
+
// so one placement serves both.
|
|
80
|
+
pos.x * scale, pos.y * scale, rect.width * scale, rect.height * scale);
|
|
81
|
+
};
|
|
82
|
+
part(torso, torso.aura, torsoPos);
|
|
83
|
+
part(face, face.aura, facePos);
|
|
84
|
+
if (figure.avatar.torsoFirst) {
|
|
85
|
+
part(torso, torso.figure, torsoPos);
|
|
86
|
+
part(face, face.figure, facePos);
|
|
87
|
+
}
|
|
88
|
+
else {
|
|
89
|
+
part(face, face.figure, facePos);
|
|
90
|
+
part(torso, torso.figure, torsoPos);
|
|
91
|
+
}
|
|
92
|
+
ctx.restore();
|
|
93
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export * from "./cast-manifest.js";
|
|
2
|
+
export * from "./emotion.js";
|
|
3
|
+
export * from "./pose.js";
|
|
4
|
+
export * from "./layout.js";
|
|
5
|
+
export { BALLOON_FONT, LINE_HEIGHT, ONE_LINE_THRESHOLD, AREA_FUDGE, CONTINUATION, widestWord, goalWidth, measureBalloon, chromeFor, measureAtWidth, splitHeight, drawBalloon, type BalloonBox, type TailTarget, } from "./balloon.js";
|
|
6
|
+
export * from "./balloon-layout.js";
|
|
7
|
+
export * from "./figure.js";
|
|
8
|
+
export * from "./render.js";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export * from "./cast-manifest.js";
|
|
2
|
+
export * from "./emotion.js";
|
|
3
|
+
export * from "./pose.js";
|
|
4
|
+
export * from "./layout.js";
|
|
5
|
+
export { BALLOON_FONT, LINE_HEIGHT, ONE_LINE_THRESHOLD, AREA_FUDGE, CONTINUATION, widestWord, goalWidth, measureBalloon, chromeFor, measureAtWidth, splitHeight, drawBalloon, } from "./balloon.js";
|
|
6
|
+
export * from "./balloon-layout.js";
|
|
7
|
+
export * from "./figure.js";
|
|
8
|
+
export * from "./render.js";
|