@yagejs-addons/dialogue 0.1.0 → 0.2.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/DialogueController-BMeNLi0v.d.cts +1204 -0
- package/dist/DialogueController-Cs5IUc-u.d.ts +1204 -0
- package/dist/chunk-7QVYU63E.js +7 -0
- package/dist/chunk-7QVYU63E.js.map +1 -0
- package/dist/chunk-CU47RPEB.js +410 -0
- package/dist/chunk-CU47RPEB.js.map +1 -0
- package/dist/chunk-GJQKZCOL.js +983 -0
- package/dist/chunk-GJQKZCOL.js.map +1 -0
- package/dist/index.cjs +3441 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +591 -0
- package/dist/index.d.ts +591 -0
- package/dist/index.js +2048 -0
- package/dist/index.js.map +1 -0
- package/dist/presenters.cjs +3149 -0
- package/dist/presenters.cjs.map +1 -0
- package/dist/presenters.d.cts +1817 -0
- package/dist/presenters.d.ts +1817 -0
- package/dist/presenters.js +2920 -0
- package/dist/presenters.js.map +1 -0
- package/dist/types-DSbBSlh7.d.cts +375 -0
- package/dist/types-DSbBSlh7.d.ts +375 -0
- package/dist/yaml.cjs +726 -0
- package/dist/yaml.cjs.map +1 -0
- package/dist/yaml.d.cts +23 -0
- package/dist/yaml.d.ts +23 -0
- package/dist/yaml.js +37 -0
- package/dist/yaml.js.map +1 -0
- package/package.json +4 -4
|
@@ -0,0 +1,3149 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __name = (target, value) => __defProp(target, "name", { value, configurable: true });
|
|
7
|
+
var __export = (target, all) => {
|
|
8
|
+
for (var name in all)
|
|
9
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
10
|
+
};
|
|
11
|
+
var __copyProps = (to, from, except, desc) => {
|
|
12
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
13
|
+
for (let key of __getOwnPropNames(from))
|
|
14
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
15
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
16
|
+
}
|
|
17
|
+
return to;
|
|
18
|
+
};
|
|
19
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
20
|
+
|
|
21
|
+
// src/presenters.ts
|
|
22
|
+
var presenters_exports = {};
|
|
23
|
+
__export(presenters_exports, {
|
|
24
|
+
ActorRegistry: () => ActorRegistry,
|
|
25
|
+
BoxLayout: () => BoxLayout,
|
|
26
|
+
BoxTextView: () => BoxTextView,
|
|
27
|
+
BubbleAnchorResolver: () => BubbleAnchorResolver,
|
|
28
|
+
BubbleAvatarPresenter: () => BubbleAvatarPresenter,
|
|
29
|
+
BubbleChoicePresenter: () => BubbleChoicePresenter,
|
|
30
|
+
BubbleChrome: () => BubbleChrome,
|
|
31
|
+
BubbleLayout: () => BubbleLayout,
|
|
32
|
+
BubbleTextView: () => BubbleTextView,
|
|
33
|
+
CHROME_STYLE_DEFAULT: () => CHROME_STYLE_DEFAULT,
|
|
34
|
+
CHROME_STYLE_NONE: () => CHROME_STYLE_NONE,
|
|
35
|
+
ChoiceListPresenter: () => ChoiceListPresenter,
|
|
36
|
+
CompositeAvatarPresenter: () => CompositeAvatarPresenter,
|
|
37
|
+
CompositeChoicePresenter: () => CompositeChoicePresenter,
|
|
38
|
+
CompositeChrome: () => CompositeChrome,
|
|
39
|
+
CompositeTextPresenter: () => CompositeTextPresenter,
|
|
40
|
+
DEFAULT_BUBBLE: () => DEFAULT_BUBBLE,
|
|
41
|
+
DEFAULT_CARET_BLINK_MS: () => DEFAULT_CARET_BLINK_MS,
|
|
42
|
+
DEFAULT_CARET_SIZE: () => DEFAULT_CARET_SIZE,
|
|
43
|
+
DEFAULT_CHOICE_GAP: () => DEFAULT_CHOICE_GAP,
|
|
44
|
+
DEFAULT_TAIL_LEAN: () => DEFAULT_TAIL_LEAN,
|
|
45
|
+
DIALOGUE_LAYERS: () => DIALOGUE_LAYERS,
|
|
46
|
+
DIALOGUE_LAYER_AVATAR: () => DIALOGUE_LAYER_AVATAR,
|
|
47
|
+
DIALOGUE_LAYER_FRAME: () => DIALOGUE_LAYER_FRAME,
|
|
48
|
+
DIALOGUE_LAYER_TEXT: () => DIALOGUE_LAYER_TEXT,
|
|
49
|
+
DialogueActor: () => DialogueActor,
|
|
50
|
+
DialogueChrome: () => DialogueChrome,
|
|
51
|
+
DialogueTextView: () => DialogueTextView,
|
|
52
|
+
InBoxAvatarPresenter: () => InBoxAvatarPresenter,
|
|
53
|
+
NullAvatarPresenter: () => NullAvatarPresenter,
|
|
54
|
+
PortraitPresenter: () => PortraitPresenter,
|
|
55
|
+
RadialChoicePresenter: () => RadialChoicePresenter,
|
|
56
|
+
SceneFigurePresenter: () => SceneFigurePresenter,
|
|
57
|
+
actorRegistryFor: () => actorRegistryFor,
|
|
58
|
+
createBoxDialogue: () => createBoxDialogue,
|
|
59
|
+
createBubbleDialogue: () => createBubbleDialogue,
|
|
60
|
+
createMixedDialogue: () => createMixedDialogue,
|
|
61
|
+
defaultTheme: () => defaultTheme,
|
|
62
|
+
effectDrivesTint: () => effectDrivesTint,
|
|
63
|
+
evaluateEffect: () => evaluateEffect,
|
|
64
|
+
fixedRoute: () => fixedRoute,
|
|
65
|
+
makeDefaultRoute: () => makeDefaultRoute,
|
|
66
|
+
routeWithActor: () => routeWithActor,
|
|
67
|
+
stackChoiceRows: () => stackChoiceRows
|
|
68
|
+
});
|
|
69
|
+
module.exports = __toCommonJS(presenters_exports);
|
|
70
|
+
|
|
71
|
+
// src/render/DialogueTextView.ts
|
|
72
|
+
var import_core = require("@yagejs/core");
|
|
73
|
+
|
|
74
|
+
// src/core/markup.ts
|
|
75
|
+
var EMPTY_PARSED = Object.freeze({
|
|
76
|
+
runs: Object.freeze([]),
|
|
77
|
+
tokens: Object.freeze([]),
|
|
78
|
+
length: 0
|
|
79
|
+
});
|
|
80
|
+
var GRAPHEME_SEGMENTER = new Intl.Segmenter();
|
|
81
|
+
function splitGraphemes(text) {
|
|
82
|
+
const out = [];
|
|
83
|
+
for (const s of GRAPHEME_SEGMENTER.segment(text)) out.push(s.segment);
|
|
84
|
+
return out;
|
|
85
|
+
}
|
|
86
|
+
__name(splitGraphemes, "splitGraphemes");
|
|
87
|
+
|
|
88
|
+
// src/core/LineReveal.ts
|
|
89
|
+
var LineReveal = class {
|
|
90
|
+
/** @param charsPerSec base reveal rate (graphemes/second), scaled by the
|
|
91
|
+
* hold, per-line, and per-run multipliers. */
|
|
92
|
+
constructor(charsPerSec) {
|
|
93
|
+
this.charsPerSec = charsPerSec;
|
|
94
|
+
}
|
|
95
|
+
charsPerSec;
|
|
96
|
+
static {
|
|
97
|
+
__name(this, "LineReveal");
|
|
98
|
+
}
|
|
99
|
+
parsed;
|
|
100
|
+
/** Reveal cursor, in graphemes (fractional while typing). */
|
|
101
|
+
cursor = 0;
|
|
102
|
+
pauseTimer = 0;
|
|
103
|
+
/** Next un-drained token in `parsed.tokens` (one ordered cursor over pauses +
|
|
104
|
+
* markers — source order is drain order). */
|
|
105
|
+
tokenIdx = 0;
|
|
106
|
+
/** Graphemes already ticked (so each grapheme ticks exactly once). */
|
|
107
|
+
tickCount = 0;
|
|
108
|
+
/** Hold-to-fast-forward rate (1 = normal). */
|
|
109
|
+
speedMul = 1;
|
|
110
|
+
/** Per-line `say.speed` multiplier (1 = base). */
|
|
111
|
+
lineSpeed = 1;
|
|
112
|
+
done = false;
|
|
113
|
+
completed = false;
|
|
114
|
+
/** Fired exactly once when the line finishes revealing. The consuming view
|
|
115
|
+
* wires this to the session-owned reveal listener (NOT a public mutable
|
|
116
|
+
* field a game could clobber). */
|
|
117
|
+
onComplete;
|
|
118
|
+
/** Per-grapheme ticks + inline markers, wired by the consuming view to the
|
|
119
|
+
* session-owned beat listener (like {@link onComplete}, never a public field). */
|
|
120
|
+
onBeat;
|
|
121
|
+
/**
|
|
122
|
+
* Register the completion listener — fires once per line, the moment the
|
|
123
|
+
* cursor reaches the end (or synchronously from {@link begin} for an empty
|
|
124
|
+
* line, or from {@link complete}). Pass `undefined` to clear.
|
|
125
|
+
*/
|
|
126
|
+
setCompletionListener(listener) {
|
|
127
|
+
this.onComplete = listener;
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Register the reveal-beat listener — per-grapheme ticks and inline markers,
|
|
131
|
+
* in char order, the moment the cursor reaches each. Session-owned (set once,
|
|
132
|
+
* like {@link setCompletionListener}); pass `undefined` to clear.
|
|
133
|
+
*/
|
|
134
|
+
setBeatListener(listener) {
|
|
135
|
+
this.onBeat = listener;
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Start revealing a new line. Resets the cursor, pauses, and the hold
|
|
139
|
+
* multiplier (a stale fast-forward must not leak into the next line — an
|
|
140
|
+
* active binding re-asserts it on its next poll). An **empty** line
|
|
141
|
+
* (`parsed.length === 0`) is complete immediately and fires the completion
|
|
142
|
+
* listener synchronously, matching the no-typewriter contract.
|
|
143
|
+
*/
|
|
144
|
+
begin(parsed, lineSpeed = 1) {
|
|
145
|
+
this.parsed = parsed;
|
|
146
|
+
this.lineSpeed = lineSpeed > 0 ? lineSpeed : 1;
|
|
147
|
+
this.cursor = 0;
|
|
148
|
+
this.pauseTimer = 0;
|
|
149
|
+
this.tokenIdx = 0;
|
|
150
|
+
this.tickCount = 0;
|
|
151
|
+
this.speedMul = 1;
|
|
152
|
+
this.done = parsed.length === 0;
|
|
153
|
+
this.completed = false;
|
|
154
|
+
this.drainTokens();
|
|
155
|
+
if (this.done) this.finish();
|
|
156
|
+
}
|
|
157
|
+
/** Hold-to-fast-forward multiplier (1 = normal, e.g. 4 while skip is held). */
|
|
158
|
+
setSpeedMultiplier(m) {
|
|
159
|
+
this.speedMul = Math.max(1, m);
|
|
160
|
+
}
|
|
161
|
+
/** Advance the reveal cursor by `dt` (ms). Honours armed pauses and per-run
|
|
162
|
+
* speed; fires completion once the cursor reaches the end. No-op after the
|
|
163
|
+
* line is done or before the first {@link begin}. */
|
|
164
|
+
update(dt) {
|
|
165
|
+
const parsed = this.parsed;
|
|
166
|
+
if (!parsed || this.done) return;
|
|
167
|
+
if (this.pauseTimer > 0) {
|
|
168
|
+
this.pauseTimer = Math.max(0, this.pauseTimer - dt);
|
|
169
|
+
} else {
|
|
170
|
+
this.drainTokens();
|
|
171
|
+
if (this.pauseTimer === 0) {
|
|
172
|
+
const rate = this.charsPerSec * this.speedMul * this.lineSpeed * this.runSpeedAt(this.cursor);
|
|
173
|
+
this.cursor = Math.min(parsed.length, this.cursor + rate * dt / 1e3);
|
|
174
|
+
this.drainTokens();
|
|
175
|
+
this.emitTicks();
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
if (this.cursor >= parsed.length && this.pauseTimer === 0) this.finish();
|
|
179
|
+
}
|
|
180
|
+
/** Reveal everything now (skip-to-end on a click/tap). Drains any not-yet-fired
|
|
181
|
+
* markers in order so their consequences still happen (`viaSkip=true` lets a
|
|
182
|
+
* host suppress a loud one-shot) and blows straight through pending pauses (a
|
|
183
|
+
* skip ignores holds), but DISCARDS pending ticks — replaying dozens of
|
|
184
|
+
* typewriter blips at once would machine-gun. Fires completion. */
|
|
185
|
+
complete() {
|
|
186
|
+
const parsed = this.parsed;
|
|
187
|
+
if (!parsed) return;
|
|
188
|
+
this.cursor = parsed.length;
|
|
189
|
+
this.pauseTimer = 0;
|
|
190
|
+
this.drainTokens(true);
|
|
191
|
+
this.tickCount = parsed.length;
|
|
192
|
+
this.finish();
|
|
193
|
+
}
|
|
194
|
+
/** Revealed grapheme count (fractional while typing). The view floors this to
|
|
195
|
+
* map onto its glyph prefix table. */
|
|
196
|
+
get revealed() {
|
|
197
|
+
return this.cursor;
|
|
198
|
+
}
|
|
199
|
+
/** True once the line is fully revealed (also true for an empty line). */
|
|
200
|
+
isComplete() {
|
|
201
|
+
return this.done;
|
|
202
|
+
}
|
|
203
|
+
/** True while glyphs are still appearing. */
|
|
204
|
+
isRevealing() {
|
|
205
|
+
return !this.done;
|
|
206
|
+
}
|
|
207
|
+
finish() {
|
|
208
|
+
this.done = true;
|
|
209
|
+
if (this.completed) return;
|
|
210
|
+
this.completed = true;
|
|
211
|
+
this.onComplete?.();
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* Drain tokens whose offset the cursor has reached, IN SOURCE ORDER. A `marker`
|
|
215
|
+
* emits a beat; a `pause` arms the hold, clamps the cursor to its offset, and
|
|
216
|
+
* STOPS the drain for this frame (a one-frame advance can overshoot the offset,
|
|
217
|
+
* so the clamp keeps glyphs past the beat from popping in early, and a later
|
|
218
|
+
* token waits until the hold resumes). `viaSkip` (from {@link complete}) tags
|
|
219
|
+
* drained markers and blows straight through pauses without holding. Monotonic
|
|
220
|
+
* `tokenIdx` → each token is handled exactly once.
|
|
221
|
+
*/
|
|
222
|
+
drainTokens(viaSkip = false) {
|
|
223
|
+
const tokens = this.parsed?.tokens;
|
|
224
|
+
if (!tokens) return;
|
|
225
|
+
while (this.tokenIdx < tokens.length && this.cursor >= tokens[this.tokenIdx].atChar) {
|
|
226
|
+
const tok = tokens[this.tokenIdx];
|
|
227
|
+
this.tokenIdx++;
|
|
228
|
+
if (tok.kind === "marker") {
|
|
229
|
+
this.onBeat?.({ kind: "marker", marker: tok, viaSkip });
|
|
230
|
+
} else if (!viaSkip && tok.ms > 0) {
|
|
231
|
+
this.pauseTimer = tok.ms;
|
|
232
|
+
this.cursor = Math.min(this.cursor, tok.atChar);
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
/** Emit a `tick` for each grapheme newly revealed since the last call (raw —
|
|
238
|
+
* no whitespace test; the host filters). Multiple in order on a large-dt
|
|
239
|
+
* frame; `tickCount` is monotonic so none repeat. */
|
|
240
|
+
emitTicks() {
|
|
241
|
+
const next = Math.floor(this.cursor);
|
|
242
|
+
for (let i = this.tickCount; i < next; i++) {
|
|
243
|
+
this.onBeat?.({ kind: "tick", index: i });
|
|
244
|
+
}
|
|
245
|
+
this.tickCount = next;
|
|
246
|
+
}
|
|
247
|
+
/** Reveal speed multiplier for whichever run the cursor currently sits in. */
|
|
248
|
+
runSpeedAt(reveal) {
|
|
249
|
+
const parsed = this.parsed;
|
|
250
|
+
if (!parsed) return 1;
|
|
251
|
+
const at = Math.floor(reveal);
|
|
252
|
+
let acc = 0;
|
|
253
|
+
for (const run of parsed.runs) {
|
|
254
|
+
if (at < acc + run.graphemeCount) return run.style.speed ?? 1;
|
|
255
|
+
acc += run.graphemeCount;
|
|
256
|
+
}
|
|
257
|
+
return 1;
|
|
258
|
+
}
|
|
259
|
+
};
|
|
260
|
+
|
|
261
|
+
// src/render/DialogueTextView.ts
|
|
262
|
+
var import_renderer = require("@yagejs/renderer");
|
|
263
|
+
|
|
264
|
+
// src/render/textEffects.ts
|
|
265
|
+
function evaluateEffect(effect, timeMs, phase, out = { dx: 0, dy: 0, scale: 1, tint: void 0 }) {
|
|
266
|
+
out.dx = 0;
|
|
267
|
+
out.dy = 0;
|
|
268
|
+
out.scale = 1;
|
|
269
|
+
out.tint = void 0;
|
|
270
|
+
switch (effect) {
|
|
271
|
+
case "wave":
|
|
272
|
+
out.dy = Math.sin(timeMs / 260 + phase / 14) * 1.6;
|
|
273
|
+
break;
|
|
274
|
+
case "shake":
|
|
275
|
+
out.dx = pseudoNoise(timeMs, phase) * 1.3;
|
|
276
|
+
out.dy = pseudoNoise(timeMs, phase + 99) * 1.3;
|
|
277
|
+
break;
|
|
278
|
+
case "pulse":
|
|
279
|
+
out.scale = 1 + 0.09 * Math.sin(timeMs / 220 + phase / 18);
|
|
280
|
+
break;
|
|
281
|
+
case "rainbow":
|
|
282
|
+
out.tint = hsv((timeMs / 18 + phase * 4) % 360, 0.55, 1);
|
|
283
|
+
break;
|
|
284
|
+
}
|
|
285
|
+
return out;
|
|
286
|
+
}
|
|
287
|
+
__name(evaluateEffect, "evaluateEffect");
|
|
288
|
+
function effectDrivesTint(effect) {
|
|
289
|
+
return effect === "rainbow";
|
|
290
|
+
}
|
|
291
|
+
__name(effectDrivesTint, "effectDrivesTint");
|
|
292
|
+
function pseudoNoise(timeMs, seed) {
|
|
293
|
+
const t = Math.floor(timeMs / 33);
|
|
294
|
+
const x = Math.sin(t * 12.9898 + seed * 78.233) * 43758.5453;
|
|
295
|
+
return x - Math.floor(x) - 0.5;
|
|
296
|
+
}
|
|
297
|
+
__name(pseudoNoise, "pseudoNoise");
|
|
298
|
+
function hsv(h, s, v) {
|
|
299
|
+
const c = v * s;
|
|
300
|
+
const hp = h / 60;
|
|
301
|
+
const x = c * (1 - Math.abs(hp % 2 - 1));
|
|
302
|
+
let rgb;
|
|
303
|
+
if (hp < 1) rgb = [c, x, 0];
|
|
304
|
+
else if (hp < 2) rgb = [x, c, 0];
|
|
305
|
+
else if (hp < 3) rgb = [0, c, x];
|
|
306
|
+
else if (hp < 4) rgb = [0, x, c];
|
|
307
|
+
else if (hp < 5) rgb = [x, 0, c];
|
|
308
|
+
else rgb = [c, 0, x];
|
|
309
|
+
const [r, g, b] = rgb;
|
|
310
|
+
const m = v - c;
|
|
311
|
+
return Math.round((r + m) * 255) << 16 | Math.round((g + m) * 255) << 8 | Math.round((b + m) * 255);
|
|
312
|
+
}
|
|
313
|
+
__name(hsv, "hsv");
|
|
314
|
+
|
|
315
|
+
// src/render/DialogueTextView.ts
|
|
316
|
+
var ITALIC_SKEW = -0.21;
|
|
317
|
+
var BOLD_OFFSET = 0.6;
|
|
318
|
+
var DialogueTextView = class {
|
|
319
|
+
constructor(cfg) {
|
|
320
|
+
this.cfg = cfg;
|
|
321
|
+
this.reveal = new LineReveal(cfg.charsPerSec);
|
|
322
|
+
this.reveal.setCompletionListener(() => this.revealListener?.());
|
|
323
|
+
this.reveal.setBeatListener((beat) => this.beatListener?.(beat));
|
|
324
|
+
if (cfg.box) this.setBox(cfg.box.x, cfg.box.y, cfg.box.width);
|
|
325
|
+
}
|
|
326
|
+
cfg;
|
|
327
|
+
static {
|
|
328
|
+
__name(this, "DialogueTextView");
|
|
329
|
+
}
|
|
330
|
+
scene;
|
|
331
|
+
line;
|
|
332
|
+
parsed;
|
|
333
|
+
boxX = 0;
|
|
334
|
+
boxY = 0;
|
|
335
|
+
wrapWidth = 200;
|
|
336
|
+
/** Top-left the split container sits at (box origin). */
|
|
337
|
+
layoutOriginX = 0;
|
|
338
|
+
layoutOriginY = 0;
|
|
339
|
+
/** Optional per-frame origin (a moving NPC's head) for diegetic bubbles. */
|
|
340
|
+
originProvider;
|
|
341
|
+
/** `nonSpacePrefix[k]` = count of non-space graphemes among the first `k`. */
|
|
342
|
+
nonSpacePrefix = new Int32Array(1);
|
|
343
|
+
/** Non-space glyphs currently visible. */
|
|
344
|
+
shownCount = -1;
|
|
345
|
+
/** Headless reveal clock — owns the grapheme cursor, `[pause]` arming, hold +
|
|
346
|
+
* per-line + per-run speed, and the fired-once completion. This view keeps
|
|
347
|
+
* only the pixi-`SplitText` concerns (glyph prefix mapping + per-glyph style
|
|
348
|
+
* fan-out) and maps the clock's grapheme cursor onto them. */
|
|
349
|
+
reveal;
|
|
350
|
+
/** Elapsed ms for animated per-glyph EFFECTS (wave/shake/…) — distinct from
|
|
351
|
+
* the reveal cursor, which LineReveal owns. */
|
|
352
|
+
elapsedMs = 0;
|
|
353
|
+
/** Scratch for {@link evaluateEffect} — one object reused across all glyphs. */
|
|
354
|
+
effectScratch = { dx: 0, dy: 0, scale: 1, tint: void 0 };
|
|
355
|
+
/** Reveal-completed listener, registered by the Session through
|
|
356
|
+
* {@link setRevealListener} (a private seam, not a public field, so a
|
|
357
|
+
* game can't clobber the session's wiring). */
|
|
358
|
+
revealListener;
|
|
359
|
+
/** Reveal-beat listener (ticks + inline markers), registered by the Session
|
|
360
|
+
* through {@link setBeatListener} — same private-seam discipline. */
|
|
361
|
+
beatListener;
|
|
362
|
+
/** Master visibility gate ({@link setVisible}); hides the line WITHOUT
|
|
363
|
+
* clearing it, so a hide/show round-trip resumes mid-typewriter. */
|
|
364
|
+
hidden = false;
|
|
365
|
+
/** Attach to a scene (host lifecycle). Must run before the first `present`. */
|
|
366
|
+
mount(scene) {
|
|
367
|
+
this.scene = scene;
|
|
368
|
+
}
|
|
369
|
+
/** Top-left of the text region, in screen px, plus the wrap width. */
|
|
370
|
+
setBox(x, y, width) {
|
|
371
|
+
this.boxX = x;
|
|
372
|
+
this.boxY = y;
|
|
373
|
+
this.wrapWidth = width;
|
|
374
|
+
}
|
|
375
|
+
/**
|
|
376
|
+
* Make the text track a per-frame origin (a diegetic bubble following an
|
|
377
|
+
* NPC). The provider returns the top-left the laid-out box should sit at;
|
|
378
|
+
* pass `undefined` to pin the text (the default box-dialogue behaviour).
|
|
379
|
+
*/
|
|
380
|
+
setOrigin(provider) {
|
|
381
|
+
this.originProvider = provider;
|
|
382
|
+
}
|
|
383
|
+
/** TextChannel entry point: render + reveal a fully-resolved line. */
|
|
384
|
+
present(line) {
|
|
385
|
+
this.show(line.text, line.speed);
|
|
386
|
+
}
|
|
387
|
+
/** TextChannel: reveal everything now. */
|
|
388
|
+
completeReveal() {
|
|
389
|
+
this.skipToEnd();
|
|
390
|
+
}
|
|
391
|
+
/** TextChannel: true once the line is fully revealed. */
|
|
392
|
+
isRevealComplete() {
|
|
393
|
+
return this.reveal.isComplete();
|
|
394
|
+
}
|
|
395
|
+
/** Hold-to-speed multiplier (1 = normal, e.g. 3 while the skip key is held). */
|
|
396
|
+
setSpeedMultiplier(m) {
|
|
397
|
+
this.reveal.setSpeedMultiplier(m);
|
|
398
|
+
}
|
|
399
|
+
isRevealing() {
|
|
400
|
+
return this.reveal.isRevealing();
|
|
401
|
+
}
|
|
402
|
+
/**
|
|
403
|
+
* Show or hide the body text WITHOUT disturbing reveal progress. Toggles
|
|
404
|
+
* the laid-out split container's visibility; the per-glyph reveal cursor,
|
|
405
|
+
* timers, and styling are untouched, so a cutscene can hide mid-typewriter and
|
|
406
|
+
* show again to resume exactly where it left off.
|
|
407
|
+
*/
|
|
408
|
+
setVisible(visible) {
|
|
409
|
+
this.hidden = !visible;
|
|
410
|
+
this.applyHidden();
|
|
411
|
+
}
|
|
412
|
+
/** Register the reveal-completed listener. Session-owned (a private seam, not a
|
|
413
|
+
* public field a game could clobber); pass `undefined` to clear. */
|
|
414
|
+
setRevealListener(listener) {
|
|
415
|
+
this.revealListener = listener;
|
|
416
|
+
}
|
|
417
|
+
/** Register the reveal-beat listener (ticks + inline markers). Session-owned;
|
|
418
|
+
* pass `undefined` to clear. The clock emits in char order as glyphs reveal. */
|
|
419
|
+
setBeatListener(listener) {
|
|
420
|
+
this.beatListener = listener;
|
|
421
|
+
}
|
|
422
|
+
/** Build the split for a parsed line and start revealing. */
|
|
423
|
+
show(parsed, lineSpeed = 1) {
|
|
424
|
+
this.clearLine();
|
|
425
|
+
this.parsed = parsed;
|
|
426
|
+
this.elapsedMs = 0;
|
|
427
|
+
this.shownCount = -1;
|
|
428
|
+
if (parsed.length > 0) this.buildLine(parsed);
|
|
429
|
+
this.reveal.begin(parsed, lineSpeed);
|
|
430
|
+
this.applyReveal();
|
|
431
|
+
this.reposition();
|
|
432
|
+
this.applyHidden();
|
|
433
|
+
}
|
|
434
|
+
/** Apply the master visibility gate to the laid-out line — toggles the split
|
|
435
|
+
* container, leaving the per-glyph reveal state intact. */
|
|
436
|
+
applyHidden() {
|
|
437
|
+
if (this.line) this.line.comp.splitText.visible = !this.hidden;
|
|
438
|
+
}
|
|
439
|
+
/** Reveal everything immediately (jump-to-end on a click/tap). */
|
|
440
|
+
skipToEnd() {
|
|
441
|
+
this.reveal.complete();
|
|
442
|
+
this.applyReveal();
|
|
443
|
+
}
|
|
444
|
+
update(dt) {
|
|
445
|
+
if (!this.parsed) return;
|
|
446
|
+
this.elapsedMs += dt;
|
|
447
|
+
this.reveal.update(dt);
|
|
448
|
+
this.applyReveal();
|
|
449
|
+
this.reposition();
|
|
450
|
+
}
|
|
451
|
+
clear() {
|
|
452
|
+
this.clearLine();
|
|
453
|
+
this.originProvider = void 0;
|
|
454
|
+
}
|
|
455
|
+
/** Per-line teardown (also the first step of `show()`). The reveal clock is
|
|
456
|
+
* re-armed by the next `show()` via {@link LineReveal.begin}, so there is no
|
|
457
|
+
* reveal state to reset here. */
|
|
458
|
+
clearLine() {
|
|
459
|
+
this.line?.entity.destroy();
|
|
460
|
+
this.line = void 0;
|
|
461
|
+
this.parsed = void 0;
|
|
462
|
+
this.shownCount = -1;
|
|
463
|
+
}
|
|
464
|
+
/** Permanent teardown. (No measurer nodes to free — SplitText owns layout.) */
|
|
465
|
+
dispose() {
|
|
466
|
+
this.clear();
|
|
467
|
+
}
|
|
468
|
+
// ── build ───────────────────────────────────────────────────────────────────
|
|
469
|
+
buildLine(parsed) {
|
|
470
|
+
if (!this.scene) return;
|
|
471
|
+
const text = parsed.runs.map((r) => r.text).join("");
|
|
472
|
+
this.layoutOriginX = this.boxX;
|
|
473
|
+
this.layoutOriginY = this.boxY;
|
|
474
|
+
const entity = this.scene.spawn("dlg-line");
|
|
475
|
+
entity.add(new import_core.Transform()).setPosition(this.layoutOriginX, this.layoutOriginY);
|
|
476
|
+
const comp = entity.add(new import_renderer.SplitTextComponent(this.lineSplitOptions(text)));
|
|
477
|
+
const chars = comp.chars;
|
|
478
|
+
const root = comp.splitText;
|
|
479
|
+
const styles = this.buildRevealTables(parsed);
|
|
480
|
+
const effectMetas = [];
|
|
481
|
+
chars.forEach((node, i) => {
|
|
482
|
+
const style = styles[i] ?? {};
|
|
483
|
+
node.tint = style.color ?? this.cfg.textColor;
|
|
484
|
+
node.visible = false;
|
|
485
|
+
this.applyWeight(node, style);
|
|
486
|
+
if (style.effect) {
|
|
487
|
+
effectMetas.push({
|
|
488
|
+
node,
|
|
489
|
+
effect: style.effect,
|
|
490
|
+
baseX: node.position.x,
|
|
491
|
+
baseY: node.position.y,
|
|
492
|
+
splitX: localInSplit(node, root).x
|
|
493
|
+
});
|
|
494
|
+
}
|
|
495
|
+
});
|
|
496
|
+
this.line = { entity, comp, chars, effectMetas };
|
|
497
|
+
}
|
|
498
|
+
/**
|
|
499
|
+
* One grapheme-segmentation pass per line (build-time only — nothing
|
|
500
|
+
* re-segments per frame), producing both reveal tables:
|
|
501
|
+
* - `nonSpacePrefix`: grapheme cursor → count of non-space glyphs shown
|
|
502
|
+
* - returned styles: the run-style for each NON-SPACE glyph, in reading
|
|
503
|
+
* order (1:1 with SplitText's `chars`, which drops whitespace)
|
|
504
|
+
* Segmenting per run matches how markup.ts counted `length`/`atChar`, so
|
|
505
|
+
* the cursor, pauses, and styles all share one basis.
|
|
506
|
+
*/
|
|
507
|
+
buildRevealTables(parsed) {
|
|
508
|
+
const styles = [];
|
|
509
|
+
const prefix = [];
|
|
510
|
+
let shown = 0;
|
|
511
|
+
for (const run of parsed.runs) {
|
|
512
|
+
for (const g of splitGraphemes(run.text)) {
|
|
513
|
+
prefix.push(shown);
|
|
514
|
+
if (/\s/.test(g)) continue;
|
|
515
|
+
shown++;
|
|
516
|
+
styles.push(run.style);
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
prefix.push(shown);
|
|
520
|
+
this.nonSpacePrefix = Int32Array.from(prefix);
|
|
521
|
+
return styles;
|
|
522
|
+
}
|
|
523
|
+
/**
|
|
524
|
+
* Apply a run's bold/italic to one glyph. On the bitmap path we must NOT swap
|
|
525
|
+
* to the baked variant atlas — each atlas has its own `baseLineOffset`, which
|
|
526
|
+
* lifts/drops a swapped glyph out of line with the regular runs. So we keep the
|
|
527
|
+
* regular-atlas glyph (baseline intact) and synthesise:
|
|
528
|
+
* - italic → shear it in place (`skew.x`)
|
|
529
|
+
* - bold → overlay a 1px-offset copy as a CHILD, so it rides the parent's
|
|
530
|
+
* reveal/visibility, tint cascade, skew, and per-frame effects for
|
|
531
|
+
* free (no separate bookkeeping).
|
|
532
|
+
* On the canvas path, real bold/italic of the same family is baseline-safe, so
|
|
533
|
+
* we just set the style flags.
|
|
534
|
+
*/
|
|
535
|
+
applyWeight(node, style) {
|
|
536
|
+
if (this.cfg.bitmapFont) {
|
|
537
|
+
if (style.italic) node.skew.x = ITALIC_SKEW;
|
|
538
|
+
if (style.bold) {
|
|
539
|
+
const Ctor = node.constructor;
|
|
540
|
+
const dup = new Ctor({ text: node.text, style: node.style });
|
|
541
|
+
dup.position.set(BOLD_OFFSET, 0);
|
|
542
|
+
dup.tint = 16777215;
|
|
543
|
+
node.addChild(dup);
|
|
544
|
+
}
|
|
545
|
+
return;
|
|
546
|
+
}
|
|
547
|
+
if (style.bold || style.italic) {
|
|
548
|
+
const s = { fontSize: this.cfg.textSize, fill: 16777215, lineHeight: this.cfg.lineHeight };
|
|
549
|
+
if (this.cfg.fontFamily) s.fontFamily = this.cfg.fontFamily;
|
|
550
|
+
if (style.bold) s.fontWeight = "bold";
|
|
551
|
+
if (style.italic) s.fontStyle = "italic";
|
|
552
|
+
node.style = s;
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
// ── reveal + effects ─────────────────────────────────────────────────────────
|
|
556
|
+
applyReveal() {
|
|
557
|
+
if (!this.line) return;
|
|
558
|
+
const cursor = import_core.MathUtils.clamp(Math.floor(this.reveal.revealed), 0, this.nonSpacePrefix.length - 1);
|
|
559
|
+
const shown = this.nonSpacePrefix[cursor];
|
|
560
|
+
if (shown === this.shownCount) return;
|
|
561
|
+
const prev = this.shownCount < 0 ? 0 : this.shownCount;
|
|
562
|
+
const chars = this.line.chars;
|
|
563
|
+
const lo = Math.min(prev, shown);
|
|
564
|
+
const hi = Math.max(prev, shown);
|
|
565
|
+
for (let i = lo; i < hi; i++) chars[i].visible = i < shown;
|
|
566
|
+
this.shownCount = shown;
|
|
567
|
+
}
|
|
568
|
+
/**
|
|
569
|
+
* Per-frame placement: follow a moving origin (bubble mode) by moving the
|
|
570
|
+
* split container's `Transform` — every glyph inherits it — then apply
|
|
571
|
+
* per-glyph animated effects on top, in the glyph's own (parent) space.
|
|
572
|
+
* Only effect-bearing glyphs are walked; a static pinned line costs nothing.
|
|
573
|
+
*/
|
|
574
|
+
reposition() {
|
|
575
|
+
const line = this.line;
|
|
576
|
+
if (!line) return;
|
|
577
|
+
if (this.originProvider) {
|
|
578
|
+
const o = this.originProvider();
|
|
579
|
+
line.entity.get(import_core.Transform).setPosition(o.x, o.y);
|
|
580
|
+
}
|
|
581
|
+
for (const m of line.effectMetas) {
|
|
582
|
+
if (!m.node.visible) continue;
|
|
583
|
+
const out = evaluateEffect(m.effect, this.elapsedMs, m.splitX, this.effectScratch);
|
|
584
|
+
m.node.position.set(m.baseX + out.dx, m.baseY + out.dy);
|
|
585
|
+
if (out.scale !== 1) m.node.scale.set(out.scale, out.scale);
|
|
586
|
+
if (effectDrivesTint(m.effect) && out.tint !== void 0) m.node.tint = out.tint;
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
// ── node construction ─────────────────────────────────────────────────────────
|
|
590
|
+
/** Options for the per-line split: regular atlas, wrap to the box, white fill
|
|
591
|
+
* (per-glyph `tint` carries the real colour). */
|
|
592
|
+
lineSplitOptions(text) {
|
|
593
|
+
const style = {
|
|
594
|
+
fontSize: this.cfg.textSize,
|
|
595
|
+
fill: 16777215,
|
|
596
|
+
wordWrap: true,
|
|
597
|
+
wordWrapWidth: this.wrapWidth,
|
|
598
|
+
lineHeight: this.cfg.lineHeight
|
|
599
|
+
};
|
|
600
|
+
const font = this.cfg.bitmapFont ?? this.cfg.fontFamily;
|
|
601
|
+
if (font) style.fontFamily = font;
|
|
602
|
+
const base = {
|
|
603
|
+
text,
|
|
604
|
+
style,
|
|
605
|
+
layer: this.cfg.layer,
|
|
606
|
+
visible: true
|
|
607
|
+
};
|
|
608
|
+
if (this.cfg.bitmapFont) base.bitmap = true;
|
|
609
|
+
return base;
|
|
610
|
+
}
|
|
611
|
+
};
|
|
612
|
+
function localInSplit(node, root) {
|
|
613
|
+
let x = 0;
|
|
614
|
+
let y = 0;
|
|
615
|
+
let n = node;
|
|
616
|
+
while (n && n !== root) {
|
|
617
|
+
x += n.position.x;
|
|
618
|
+
y += n.position.y;
|
|
619
|
+
n = n.parent ?? void 0;
|
|
620
|
+
}
|
|
621
|
+
return { x, y };
|
|
622
|
+
}
|
|
623
|
+
__name(localInSplit, "localInSplit");
|
|
624
|
+
|
|
625
|
+
// src/render/BubbleTextView.ts
|
|
626
|
+
var BubbleTextView = class extends DialogueTextView {
|
|
627
|
+
constructor(cfg, layout) {
|
|
628
|
+
super({
|
|
629
|
+
...cfg,
|
|
630
|
+
// Initial wrap width; updated per line in present() as the bubble widens.
|
|
631
|
+
box: { x: 0, y: 0, width: 0 }
|
|
632
|
+
});
|
|
633
|
+
this.layout = layout;
|
|
634
|
+
}
|
|
635
|
+
layout;
|
|
636
|
+
static {
|
|
637
|
+
__name(this, "BubbleTextView");
|
|
638
|
+
}
|
|
639
|
+
sceneRef;
|
|
640
|
+
mount(scene) {
|
|
641
|
+
super.mount(scene);
|
|
642
|
+
this.sceneRef = scene;
|
|
643
|
+
}
|
|
644
|
+
/** Route the missing-actor warning to the engine Logger (the layout owns the
|
|
645
|
+
* shared anchor resolver). The base view has no diagnostics of its own. */
|
|
646
|
+
setDiagnostics(warn) {
|
|
647
|
+
this.layout.setDiagnostics(warn);
|
|
648
|
+
}
|
|
649
|
+
present(line) {
|
|
650
|
+
const size = this.layout.sizeFor(line);
|
|
651
|
+
this.setBox(0, 0, this.layout.textWrapWidth(size));
|
|
652
|
+
const speakerId = line.speaker?.id;
|
|
653
|
+
this.setOrigin(() => {
|
|
654
|
+
const anchor = this.sceneRef ? this.layout.anchorFor(this.sceneRef, speakerId) : { x: 0, y: 0 };
|
|
655
|
+
return this.layout.originFor(anchor, size);
|
|
656
|
+
});
|
|
657
|
+
super.present(line);
|
|
658
|
+
}
|
|
659
|
+
};
|
|
660
|
+
|
|
661
|
+
// src/render/BoxTextView.ts
|
|
662
|
+
var BoxTextView = class extends DialogueTextView {
|
|
663
|
+
constructor(cfg, layout) {
|
|
664
|
+
super({ ...cfg, box: { x: 0, y: 0, width: 0 } });
|
|
665
|
+
this.layout = layout;
|
|
666
|
+
}
|
|
667
|
+
layout;
|
|
668
|
+
static {
|
|
669
|
+
__name(this, "BoxTextView");
|
|
670
|
+
}
|
|
671
|
+
present(line) {
|
|
672
|
+
const region = this.layout.textRegion();
|
|
673
|
+
this.setBox(0, 0, region.width);
|
|
674
|
+
this.setOrigin(() => {
|
|
675
|
+
const r = this.layout.textRegion();
|
|
676
|
+
return { x: r.x, y: r.y };
|
|
677
|
+
});
|
|
678
|
+
super.present(line);
|
|
679
|
+
}
|
|
680
|
+
};
|
|
681
|
+
|
|
682
|
+
// src/render/layers.ts
|
|
683
|
+
var DIALOGUE_LAYER_FRAME = "dialogue-frame";
|
|
684
|
+
var DIALOGUE_LAYER_TEXT = "dialogue-text";
|
|
685
|
+
var DIALOGUE_LAYER_AVATAR = "dialogue-avatar";
|
|
686
|
+
var DIALOGUE_LAYERS = [
|
|
687
|
+
{ name: DIALOGUE_LAYER_FRAME, order: 1100, space: "screen" },
|
|
688
|
+
// Avatar between frame and text so a portrait can tuck behind the box edge.
|
|
689
|
+
{ name: DIALOGUE_LAYER_AVATAR, order: 1105, space: "screen" },
|
|
690
|
+
{ name: DIALOGUE_LAYER_TEXT, order: 1110, space: "screen" }
|
|
691
|
+
];
|
|
692
|
+
|
|
693
|
+
// src/actor/DialogueActor.ts
|
|
694
|
+
var import_core2 = require("@yagejs/core");
|
|
695
|
+
|
|
696
|
+
// src/actor/ActorRegistry.ts
|
|
697
|
+
var ActorRegistry = class {
|
|
698
|
+
static {
|
|
699
|
+
__name(this, "ActorRegistry");
|
|
700
|
+
}
|
|
701
|
+
actors = /* @__PURE__ */ new Map();
|
|
702
|
+
register(speaker, actor) {
|
|
703
|
+
this.actors.set(speaker, actor);
|
|
704
|
+
}
|
|
705
|
+
unregister(speaker, actor) {
|
|
706
|
+
if (this.actors.get(speaker) === actor) this.actors.delete(speaker);
|
|
707
|
+
}
|
|
708
|
+
resolve(speaker) {
|
|
709
|
+
return speaker ? this.actors.get(speaker) : void 0;
|
|
710
|
+
}
|
|
711
|
+
};
|
|
712
|
+
var registries = /* @__PURE__ */ new WeakMap();
|
|
713
|
+
function actorRegistryFor(scene) {
|
|
714
|
+
let registry = registries.get(scene);
|
|
715
|
+
if (!registry) {
|
|
716
|
+
registry = new ActorRegistry();
|
|
717
|
+
registries.set(scene, registry);
|
|
718
|
+
}
|
|
719
|
+
return registry;
|
|
720
|
+
}
|
|
721
|
+
__name(actorRegistryFor, "actorRegistryFor");
|
|
722
|
+
|
|
723
|
+
// src/actor/DialogueActor.ts
|
|
724
|
+
var DialogueActor = class extends import_core2.Component {
|
|
725
|
+
constructor(opts) {
|
|
726
|
+
super();
|
|
727
|
+
this.opts = opts;
|
|
728
|
+
}
|
|
729
|
+
opts;
|
|
730
|
+
static {
|
|
731
|
+
__name(this, "DialogueActor");
|
|
732
|
+
}
|
|
733
|
+
get speaker() {
|
|
734
|
+
return this.opts.speaker;
|
|
735
|
+
}
|
|
736
|
+
onAdd() {
|
|
737
|
+
actorRegistryFor(this.scene).register(this.opts.speaker, this);
|
|
738
|
+
}
|
|
739
|
+
onDestroy() {
|
|
740
|
+
actorRegistryFor(this.scene).unregister(this.opts.speaker, this);
|
|
741
|
+
}
|
|
742
|
+
/** Bubble anchor in world space: the entity position plus the configured offset. */
|
|
743
|
+
anchorWorld() {
|
|
744
|
+
const t = this.entity.tryGet(import_core2.Transform);
|
|
745
|
+
const p = t?.position ?? { x: 0, y: 0 };
|
|
746
|
+
const a = this.opts.anchor ?? { x: 0, y: 0 };
|
|
747
|
+
return { x: p.x + a.x, y: p.y + a.y };
|
|
748
|
+
}
|
|
749
|
+
setExpression(expression) {
|
|
750
|
+
this.opts.onExpression?.(this.entity, expression);
|
|
751
|
+
}
|
|
752
|
+
setSpeaking(speaking) {
|
|
753
|
+
this.opts.onSpeaking?.(this.entity, speaking);
|
|
754
|
+
}
|
|
755
|
+
};
|
|
756
|
+
|
|
757
|
+
// src/render/bubbleAnchor.ts
|
|
758
|
+
var BubbleAnchorResolver = class {
|
|
759
|
+
/**
|
|
760
|
+
* @param fallback Ultimate anchor when nothing better is known (a speaker
|
|
761
|
+
* never seen and no prior bubble). Defaults to the world origin; a
|
|
762
|
+
* pure-bubble bundle that shows narrator lines should point this at its
|
|
763
|
+
* camera centre so a speakerless line lands on screen.
|
|
764
|
+
*/
|
|
765
|
+
constructor(fallback = () => ({ x: 0, y: 0 })) {
|
|
766
|
+
this.fallback = fallback;
|
|
767
|
+
}
|
|
768
|
+
fallback;
|
|
769
|
+
static {
|
|
770
|
+
__name(this, "BubbleAnchorResolver");
|
|
771
|
+
}
|
|
772
|
+
lastKnown = /* @__PURE__ */ new Map();
|
|
773
|
+
lastAnchor;
|
|
774
|
+
warned = /* @__PURE__ */ new Set();
|
|
775
|
+
warn;
|
|
776
|
+
/** Wire the diagnostics sink (the controller injects the engine-Logger one). */
|
|
777
|
+
setDiagnostics(warn) {
|
|
778
|
+
this.warn = warn;
|
|
779
|
+
}
|
|
780
|
+
/**
|
|
781
|
+
* World anchor for `speakerId`. Live actor → its anchor (caches refreshed).
|
|
782
|
+
* Missing → last-known for that speaker, else the most recent any-speaker
|
|
783
|
+
* anchor, else {@link fallback}. Warns at most once per declared speaker id
|
|
784
|
+
* (per resolver instance); a speakerless line never warns.
|
|
785
|
+
*/
|
|
786
|
+
resolve(scene, speakerId) {
|
|
787
|
+
const actor = actorRegistryFor(scene).resolve(speakerId);
|
|
788
|
+
if (actor) {
|
|
789
|
+
const anchor = actor.anchorWorld();
|
|
790
|
+
if (speakerId !== void 0) this.lastKnown.set(speakerId, anchor);
|
|
791
|
+
this.lastAnchor = anchor;
|
|
792
|
+
return anchor;
|
|
793
|
+
}
|
|
794
|
+
if (speakerId !== void 0 && !this.warned.has(speakerId)) {
|
|
795
|
+
this.warned.add(speakerId);
|
|
796
|
+
this.warn?.(
|
|
797
|
+
`no DialogueActor is registered for speaker "${speakerId}"; anchoring its bubble at the last-known / fallback position. Register a DialogueActor (even on an invisible entity) to place it deliberately.`
|
|
798
|
+
);
|
|
799
|
+
}
|
|
800
|
+
const known = speakerId !== void 0 ? this.lastKnown.get(speakerId) : void 0;
|
|
801
|
+
return known ?? this.lastAnchor ?? this.fallback();
|
|
802
|
+
}
|
|
803
|
+
};
|
|
804
|
+
|
|
805
|
+
// src/render/bubbleSizing.ts
|
|
806
|
+
var import_renderer2 = require("@yagejs/renderer");
|
|
807
|
+
function bubbleSize(plainText, cfg) {
|
|
808
|
+
const oneLine = cfg.lineHeight + 2 * cfg.padding;
|
|
809
|
+
const font = cfg.bitmapFont ?? cfg.fontFamily;
|
|
810
|
+
const fontOpt = font !== void 0 ? { fontFamily: font } : {};
|
|
811
|
+
const bitmapOpt = cfg.bitmapFont !== void 0 ? { bitmap: true } : {};
|
|
812
|
+
const natural = (0, import_renderer2.measureWrappedText)(plainText, {
|
|
813
|
+
fontSize: cfg.textSize,
|
|
814
|
+
lineHeight: cfg.lineHeight,
|
|
815
|
+
...fontOpt,
|
|
816
|
+
...bitmapOpt
|
|
817
|
+
});
|
|
818
|
+
const wantWidth = natural.width + 2 * cfg.padding;
|
|
819
|
+
if (wantWidth <= cfg.maxWidth) {
|
|
820
|
+
return {
|
|
821
|
+
width: Math.max(cfg.minWidth, wantWidth),
|
|
822
|
+
height: Math.max(cfg.minHeight, oneLine)
|
|
823
|
+
};
|
|
824
|
+
}
|
|
825
|
+
const inner = cfg.maxWidth - 2 * cfg.padding;
|
|
826
|
+
const wrapped = (0, import_renderer2.measureWrappedText)(plainText, {
|
|
827
|
+
fontSize: cfg.textSize,
|
|
828
|
+
lineHeight: cfg.lineHeight,
|
|
829
|
+
wordWrapWidth: inner,
|
|
830
|
+
...fontOpt,
|
|
831
|
+
...bitmapOpt
|
|
832
|
+
});
|
|
833
|
+
return {
|
|
834
|
+
width: cfg.maxWidth,
|
|
835
|
+
height: Math.max(cfg.minHeight, wrapped.lineCount * cfg.lineHeight + 2 * cfg.padding)
|
|
836
|
+
};
|
|
837
|
+
}
|
|
838
|
+
__name(bubbleSize, "bubbleSize");
|
|
839
|
+
|
|
840
|
+
// src/render/BubbleLayout.ts
|
|
841
|
+
var BubbleLayout = class {
|
|
842
|
+
constructor(cfg) {
|
|
843
|
+
this.cfg = cfg;
|
|
844
|
+
this.anchors = new BubbleAnchorResolver(cfg.fallbackAnchor);
|
|
845
|
+
}
|
|
846
|
+
cfg;
|
|
847
|
+
static {
|
|
848
|
+
__name(this, "BubbleLayout");
|
|
849
|
+
}
|
|
850
|
+
anchors;
|
|
851
|
+
/** One-line memo: the session presents one line to chrome then text, so a
|
|
852
|
+
* one-deep cache makes the second `sizeFor` free (no redundant measure pass). */
|
|
853
|
+
memoLine;
|
|
854
|
+
memoSize;
|
|
855
|
+
/** Reserved portrait column for the current line (set by the in-bubble avatar
|
|
856
|
+
* before the chrome/text present). */
|
|
857
|
+
inset;
|
|
858
|
+
/** The current bubble content size — the say bubble (from {@link sizeFor}) or a
|
|
859
|
+
* choice panel (from {@link setChoicePanelSize}). The in-bubble avatar centres
|
|
860
|
+
* in this, so it follows whichever is on screen. */
|
|
861
|
+
active;
|
|
862
|
+
listeners = [];
|
|
863
|
+
/** Reserve (or clear with `undefined`) a portrait column inside the bubble.
|
|
864
|
+
* The bubble (and a bubble choice panel) then grows to contain it and the
|
|
865
|
+
* text/rows reflow to the narrowed column; the avatar sets this per line
|
|
866
|
+
* before the chrome/text/choices present. */
|
|
867
|
+
setPortraitInset(inset) {
|
|
868
|
+
this.inset = inset;
|
|
869
|
+
this.memoLine = void 0;
|
|
870
|
+
}
|
|
871
|
+
/** The reserved portrait column (or undefined) — a bubble choice presenter
|
|
872
|
+
* reads it to reflow its panel around the portrait. */
|
|
873
|
+
portraitInset() {
|
|
874
|
+
return this.inset;
|
|
875
|
+
}
|
|
876
|
+
/** Register a callback fired when the active bubble content size changes (a
|
|
877
|
+
* say line sizes its bubble, or a choice commits its panel) — the in-bubble
|
|
878
|
+
* avatar re-places. */
|
|
879
|
+
onChange(listener) {
|
|
880
|
+
this.listeners.push(listener);
|
|
881
|
+
}
|
|
882
|
+
/** The current bubble content size the avatar centres in (say bubble or choice
|
|
883
|
+
* panel). */
|
|
884
|
+
activeSize() {
|
|
885
|
+
return this.active;
|
|
886
|
+
}
|
|
887
|
+
/** A bubble choice presenter commits its (inset-grown) panel size here so the
|
|
888
|
+
* in-bubble avatar follows the panel, not the say bubble. */
|
|
889
|
+
setChoicePanelSize(size) {
|
|
890
|
+
this.setActive(size);
|
|
891
|
+
}
|
|
892
|
+
setActive(size) {
|
|
893
|
+
if (this.active && this.active.width === size.width && this.active.height === size.height) return;
|
|
894
|
+
this.active = size;
|
|
895
|
+
for (const fn of this.listeners) fn();
|
|
896
|
+
}
|
|
897
|
+
/** Inner padding (px) — the presenters position the name/caret/text by it. */
|
|
898
|
+
get padding() {
|
|
899
|
+
return this.cfg.padding;
|
|
900
|
+
}
|
|
901
|
+
/** Gap between the speaker anchor and the bubble's bottom edge (px). */
|
|
902
|
+
get offsetY() {
|
|
903
|
+
return this.cfg.offsetY;
|
|
904
|
+
}
|
|
905
|
+
/** Wire the missing-actor warning to the engine Logger (the controller's sink). */
|
|
906
|
+
setDiagnostics(warn) {
|
|
907
|
+
this.anchors.setDiagnostics(warn);
|
|
908
|
+
}
|
|
909
|
+
/** Outer bubble size to fit this line's text (+ a reserved portrait column,
|
|
910
|
+
* if one is registered) — measured once, then memoized for the companion
|
|
911
|
+
* presenter's read of the same line. */
|
|
912
|
+
sizeFor(line) {
|
|
913
|
+
if (line === this.memoLine && this.memoSize) return this.memoSize;
|
|
914
|
+
const plain = line.text.runs.map((r) => r.text).join("");
|
|
915
|
+
const reserve = this.inset?.width ?? 0;
|
|
916
|
+
const textSize = bubbleSize(plain, {
|
|
917
|
+
minWidth: this.cfg.minWidth,
|
|
918
|
+
maxWidth: Math.max(this.cfg.minWidth, this.cfg.maxWidth - reserve),
|
|
919
|
+
padding: this.cfg.padding,
|
|
920
|
+
minHeight: this.cfg.height,
|
|
921
|
+
textSize: this.cfg.textSize,
|
|
922
|
+
lineHeight: this.cfg.lineHeight,
|
|
923
|
+
fontFamily: this.cfg.fontFamily,
|
|
924
|
+
bitmapFont: this.cfg.bitmapFont
|
|
925
|
+
});
|
|
926
|
+
const size = {
|
|
927
|
+
width: textSize.width + reserve,
|
|
928
|
+
height: Math.max(textSize.height, (this.inset?.height ?? 0) + 2 * this.cfg.padding)
|
|
929
|
+
};
|
|
930
|
+
this.memoLine = line;
|
|
931
|
+
this.memoSize = size;
|
|
932
|
+
this.setActive(size);
|
|
933
|
+
return size;
|
|
934
|
+
}
|
|
935
|
+
/** Body-text wrap width inside the bubble — the inner width minus the
|
|
936
|
+
* reserved portrait column (so the text reflows past an in-bubble avatar). */
|
|
937
|
+
textWrapWidth(size) {
|
|
938
|
+
return size.width - 2 * this.cfg.padding - (this.inset?.width ?? 0);
|
|
939
|
+
}
|
|
940
|
+
/** World anchor for a speaker: a live {@link DialogueActor}'s head, else the
|
|
941
|
+
* last-known / fallback position (and a once-per-speaker warning). Shared by
|
|
942
|
+
* all three bubble presenters so they track the same actor. */
|
|
943
|
+
anchorFor(scene, speakerId) {
|
|
944
|
+
return this.anchors.resolve(scene, speakerId);
|
|
945
|
+
}
|
|
946
|
+
/** Inner top-left a content-sized bubble's body sits at, from the speaker
|
|
947
|
+
* anchor + the bubble size (the once-derived origin formula). Shifts past a
|
|
948
|
+
* left-side portrait column so the text reflows beside it. */
|
|
949
|
+
originFor(anchor, size) {
|
|
950
|
+
let x = anchor.x - size.width / 2 + this.cfg.padding;
|
|
951
|
+
if (this.inset?.side === "left") x += this.inset.width;
|
|
952
|
+
return { x, y: anchor.y - (this.cfg.offsetY + size.height) + this.cfg.padding };
|
|
953
|
+
}
|
|
954
|
+
};
|
|
955
|
+
|
|
956
|
+
// src/render/BoxLayout.ts
|
|
957
|
+
var import_renderer3 = require("@yagejs/renderer");
|
|
958
|
+
var TEXT_GAP = 4;
|
|
959
|
+
function stackChoiceRows(rowHeights, box, padding) {
|
|
960
|
+
const x = box.x + padding;
|
|
961
|
+
const width = box.width - 2 * padding;
|
|
962
|
+
const rects = [];
|
|
963
|
+
let bottom = box.y + box.height - padding;
|
|
964
|
+
for (let i = rowHeights.length - 1; i >= 0; i--) {
|
|
965
|
+
const h = rowHeights[i] ?? 0;
|
|
966
|
+
bottom -= h;
|
|
967
|
+
rects[i] = { x, y: bottom, width, height: h };
|
|
968
|
+
}
|
|
969
|
+
return rects;
|
|
970
|
+
}
|
|
971
|
+
__name(stackChoiceRows, "stackChoiceRows");
|
|
972
|
+
var BoxLayout = class {
|
|
973
|
+
constructor(cfg) {
|
|
974
|
+
this.cfg = cfg;
|
|
975
|
+
this.frame = this.frameAt("bottom", cfg.box.height);
|
|
976
|
+
}
|
|
977
|
+
cfg;
|
|
978
|
+
static {
|
|
979
|
+
__name(this, "BoxLayout");
|
|
980
|
+
}
|
|
981
|
+
/** Design viewport (the renderer's `virtualSize`) the box is placed within —
|
|
982
|
+
* bound at mount via {@link setViewport}. Defaults to a sane size so headless
|
|
983
|
+
* use (no renderer) and pre-mount calls still produce a valid frame. */
|
|
984
|
+
viewW = 800;
|
|
985
|
+
viewH = 600;
|
|
986
|
+
insets = /* @__PURE__ */ new Map();
|
|
987
|
+
listeners = [];
|
|
988
|
+
/** The committed frame — moved by `meta.position`, grown for a choice. */
|
|
989
|
+
frame;
|
|
990
|
+
/** The line currently laid out (for the choice panel's prompt + nameplate). */
|
|
991
|
+
line;
|
|
992
|
+
/**
|
|
993
|
+
* Bind the design viewport (the renderer's `virtualSize`), read at mount, so
|
|
994
|
+
* the box is a full-width bottom bar at any resolution and `meta.position`
|
|
995
|
+
* places the frame against the true screen. Recomputes the resting frame.
|
|
996
|
+
*/
|
|
997
|
+
setViewport(width, height) {
|
|
998
|
+
this.viewW = width;
|
|
999
|
+
this.viewH = height;
|
|
1000
|
+
this.commit(this.frameAt(positionOf(this.line), this.cfg.box.height));
|
|
1001
|
+
}
|
|
1002
|
+
/** Register a callback fired when the committed frame changes (a choice grows
|
|
1003
|
+
* it, or an inset reflows the text) — the chrome redraws, the text re-places. */
|
|
1004
|
+
onChange(listener) {
|
|
1005
|
+
this.listeners.push(listener);
|
|
1006
|
+
}
|
|
1007
|
+
/** The frame rect for the current line (read by the chrome to draw + place). */
|
|
1008
|
+
frameRect() {
|
|
1009
|
+
return this.frame;
|
|
1010
|
+
}
|
|
1011
|
+
/** Inner content width — choice rows wrap to this. Frame minus padding minus
|
|
1012
|
+
* any registered insets, so choices reflow around an in-box avatar the same
|
|
1013
|
+
* way the body text does. */
|
|
1014
|
+
contentWidth() {
|
|
1015
|
+
return this.viewW - 2 * this.cfg.box.marginX - 2 * this.cfg.padding - this.insetWidth("left") - this.insetWidth("right");
|
|
1016
|
+
}
|
|
1017
|
+
/** Inner padding between the frame and its contents — an in-box presenter
|
|
1018
|
+
* aligns its column to this so it sits inside the border, like the text. */
|
|
1019
|
+
padding() {
|
|
1020
|
+
return this.cfg.padding;
|
|
1021
|
+
}
|
|
1022
|
+
/** Lay out a say/prompt line: place the base-height frame at its
|
|
1023
|
+
* `meta.position`. Commits the frame (firing {@link onChange} if it moved). */
|
|
1024
|
+
layoutLine(line) {
|
|
1025
|
+
this.line = line;
|
|
1026
|
+
this.commit(this.frameAt(positionOf(line), this.cfg.box.height));
|
|
1027
|
+
return this.frame;
|
|
1028
|
+
}
|
|
1029
|
+
/**
|
|
1030
|
+
* Grow the frame to fit a choice: nameplate band + optional prompt + the
|
|
1031
|
+
* rows, capped at the field. Commits the grown frame (firing {@link onChange}
|
|
1032
|
+
* so the chrome/nameplate/prompt follow) and returns the row rects stacked
|
|
1033
|
+
* inside it.
|
|
1034
|
+
*/
|
|
1035
|
+
layoutChoicePanel(rowHeights) {
|
|
1036
|
+
const promptH = this.promptHeight();
|
|
1037
|
+
const headH = promptH > 0 ? promptH + this.cfg.choiceGap : 0;
|
|
1038
|
+
const rows = rowHeights.reduce((a, h) => a + h, 0);
|
|
1039
|
+
const content = this.cfg.padding + this.bodyOffset() + headH + rows + this.cfg.padding;
|
|
1040
|
+
const maxH = this.viewH - 2 * this.cfg.box.marginY;
|
|
1041
|
+
const height = Math.min(Math.max(this.cfg.box.height, content), maxH);
|
|
1042
|
+
this.commit(this.frameAt(positionOf(this.line), height));
|
|
1043
|
+
const insetL = this.insetWidth("left");
|
|
1044
|
+
const insetR = this.insetWidth("right");
|
|
1045
|
+
const inner = {
|
|
1046
|
+
x: this.frame.x + insetL,
|
|
1047
|
+
y: this.frame.y,
|
|
1048
|
+
width: this.frame.width - insetL - insetR,
|
|
1049
|
+
height: this.frame.height
|
|
1050
|
+
};
|
|
1051
|
+
return stackChoiceRows(rowHeights, inner, this.cfg.padding);
|
|
1052
|
+
}
|
|
1053
|
+
/** Body-text region inside the current frame: below the nameplate band, inset
|
|
1054
|
+
* by padding, minus any registered insets (so text reflows around an avatar).
|
|
1055
|
+
* For a choice, this is the prompt region above the rows. */
|
|
1056
|
+
textRegion() {
|
|
1057
|
+
let x = this.frame.x + this.cfg.padding;
|
|
1058
|
+
let width = this.frame.width - 2 * this.cfg.padding;
|
|
1059
|
+
for (const inset of this.insets.values()) {
|
|
1060
|
+
width -= inset.width;
|
|
1061
|
+
if (inset.side === "left") x += inset.width;
|
|
1062
|
+
}
|
|
1063
|
+
return { x, y: this.frame.y + this.cfg.padding + this.bodyOffset(), width };
|
|
1064
|
+
}
|
|
1065
|
+
/** Top-left of the nameplate inside the current frame. */
|
|
1066
|
+
nameplatePos() {
|
|
1067
|
+
return { x: this.frame.x + this.cfg.padding, y: this.frame.y + this.cfg.padding - 1 };
|
|
1068
|
+
}
|
|
1069
|
+
/** Bottom-right continue-caret position inside the current frame. */
|
|
1070
|
+
caretPos(size) {
|
|
1071
|
+
return {
|
|
1072
|
+
x: this.frame.x + this.frame.width - this.cfg.padding - size.width,
|
|
1073
|
+
y: this.frame.y + this.frame.height - this.cfg.padding - size.height - 1
|
|
1074
|
+
};
|
|
1075
|
+
}
|
|
1076
|
+
/**
|
|
1077
|
+
* Reserve (or clear with `undefined`) a left/right column the body text
|
|
1078
|
+
* reflows around — the avatar-reflow seam. The reference in-box avatar
|
|
1079
|
+
* presenter registers one keyed by its own id. Fires {@link onChange} so a
|
|
1080
|
+
* text view already showing this line reflows.
|
|
1081
|
+
*/
|
|
1082
|
+
setInset(key, inset) {
|
|
1083
|
+
const prev = this.insets.get(key);
|
|
1084
|
+
if (inset) this.insets.set(key, inset);
|
|
1085
|
+
else this.insets.delete(key);
|
|
1086
|
+
if (!sameInset(prev, inset)) this.notify();
|
|
1087
|
+
}
|
|
1088
|
+
/** The reserved width on a side (0 if none) — an avatar presenter reads it to
|
|
1089
|
+
* place itself in the column it reserved. */
|
|
1090
|
+
insetWidth(side) {
|
|
1091
|
+
let w = 0;
|
|
1092
|
+
for (const inset of this.insets.values()) if (inset.side === side) w += inset.width;
|
|
1093
|
+
return w;
|
|
1094
|
+
}
|
|
1095
|
+
/** Distance from the frame top to the body text (nameplate band + gap). */
|
|
1096
|
+
bodyOffset() {
|
|
1097
|
+
return this.cfg.nameSize + TEXT_GAP;
|
|
1098
|
+
}
|
|
1099
|
+
/** Measure the current choice line's prompt (0 when there is none). */
|
|
1100
|
+
promptHeight() {
|
|
1101
|
+
const text = this.line?.text;
|
|
1102
|
+
if (!text || text.length === 0) return 0;
|
|
1103
|
+
const plain = text.runs.map((r) => r.text).join("");
|
|
1104
|
+
const font = this.cfg.bitmapFont ?? this.cfg.fontFamily;
|
|
1105
|
+
const measured = (0, import_renderer3.measureWrappedText)(plain, {
|
|
1106
|
+
fontSize: this.cfg.textSize,
|
|
1107
|
+
lineHeight: this.cfg.lineHeight,
|
|
1108
|
+
wordWrapWidth: this.textRegion().width,
|
|
1109
|
+
...font !== void 0 ? { fontFamily: font } : {},
|
|
1110
|
+
...this.cfg.bitmapFont !== void 0 ? { bitmap: true } : {}
|
|
1111
|
+
});
|
|
1112
|
+
return measured.height;
|
|
1113
|
+
}
|
|
1114
|
+
/** Place a full-width frame of `height` at `position` within the design
|
|
1115
|
+
* viewport: `bottom` anchors `marginY` from the bottom edge, `top` mirrors it
|
|
1116
|
+
* to the top, `center` centres. The width is the viewport minus side margins. */
|
|
1117
|
+
frameAt(position, height) {
|
|
1118
|
+
const { marginX, marginY } = this.cfg.box;
|
|
1119
|
+
let y;
|
|
1120
|
+
if (position === "top") y = marginY;
|
|
1121
|
+
else if (position === "center") y = (this.viewH - height) / 2;
|
|
1122
|
+
else y = this.viewH - marginY - height;
|
|
1123
|
+
return { x: marginX, y, width: this.viewW - 2 * marginX, height };
|
|
1124
|
+
}
|
|
1125
|
+
commit(frame) {
|
|
1126
|
+
if (sameRect(this.frame, frame)) return;
|
|
1127
|
+
this.frame = frame;
|
|
1128
|
+
this.notify();
|
|
1129
|
+
}
|
|
1130
|
+
notify() {
|
|
1131
|
+
for (const fn of this.listeners) fn();
|
|
1132
|
+
}
|
|
1133
|
+
};
|
|
1134
|
+
function positionOf(line) {
|
|
1135
|
+
const p = line?.meta?.["position"];
|
|
1136
|
+
return p === "top" || p === "center" ? p : "bottom";
|
|
1137
|
+
}
|
|
1138
|
+
__name(positionOf, "positionOf");
|
|
1139
|
+
function sameRect(a, b) {
|
|
1140
|
+
return a.x === b.x && a.y === b.y && a.width === b.width && a.height === b.height;
|
|
1141
|
+
}
|
|
1142
|
+
__name(sameRect, "sameRect");
|
|
1143
|
+
function sameInset(a, b) {
|
|
1144
|
+
if (a === void 0 || b === void 0) return a === b;
|
|
1145
|
+
return a.side === b.side && a.width === b.width;
|
|
1146
|
+
}
|
|
1147
|
+
__name(sameInset, "sameInset");
|
|
1148
|
+
|
|
1149
|
+
// src/chrome/DialogueChrome.ts
|
|
1150
|
+
var import_core3 = require("@yagejs/core");
|
|
1151
|
+
var import_renderer4 = require("@yagejs/renderer");
|
|
1152
|
+
|
|
1153
|
+
// src/factory/theme.ts
|
|
1154
|
+
var DEFAULT_CARET_BLINK_MS = 260;
|
|
1155
|
+
var DEFAULT_CARET_SIZE = {
|
|
1156
|
+
width: 7,
|
|
1157
|
+
height: 5
|
|
1158
|
+
};
|
|
1159
|
+
var DEFAULT_CHOICE_GAP = 6;
|
|
1160
|
+
var DEFAULT_TAIL_LEAN = { x: -3, y: -2 };
|
|
1161
|
+
var CHROME_STYLE_DEFAULT = "default";
|
|
1162
|
+
var CHROME_STYLE_NONE = "none";
|
|
1163
|
+
function boxFrameStyles(textured) {
|
|
1164
|
+
if (!textured) return void 0;
|
|
1165
|
+
const out = {};
|
|
1166
|
+
for (const [name, style] of Object.entries(textured)) out[name] = style.frame;
|
|
1167
|
+
return out;
|
|
1168
|
+
}
|
|
1169
|
+
__name(boxFrameStyles, "boxFrameStyles");
|
|
1170
|
+
function defaultBubbleFrame(textured) {
|
|
1171
|
+
return textured?.[CHROME_STYLE_DEFAULT]?.bubble;
|
|
1172
|
+
}
|
|
1173
|
+
__name(defaultBubbleFrame, "defaultBubbleFrame");
|
|
1174
|
+
|
|
1175
|
+
// src/chrome/caret.ts
|
|
1176
|
+
function caretAlpha(timeMs, blinkMs = DEFAULT_CARET_BLINK_MS) {
|
|
1177
|
+
return 0.35 + 0.65 * (0.5 + 0.5 * Math.sin(timeMs / blinkMs));
|
|
1178
|
+
}
|
|
1179
|
+
__name(caretAlpha, "caretAlpha");
|
|
1180
|
+
function drawCaret(g, color, size = DEFAULT_CARET_SIZE) {
|
|
1181
|
+
const w = size.width;
|
|
1182
|
+
const h = size.height;
|
|
1183
|
+
g.poly([0, 0, w, 0, w / 2, h]).fill({ color, alpha: 1 });
|
|
1184
|
+
}
|
|
1185
|
+
__name(drawCaret, "drawCaret");
|
|
1186
|
+
|
|
1187
|
+
// src/chrome/textOptions.ts
|
|
1188
|
+
function makeTextOptions(fonts, text, size, color, layer, anchor = { x: 0, y: 0 }) {
|
|
1189
|
+
const style = { fontSize: size, fill: color };
|
|
1190
|
+
if (fonts.bitmapFont) style.fontFamily = fonts.bitmapFont;
|
|
1191
|
+
else if (fonts.fontFamily) style.fontFamily = fonts.fontFamily;
|
|
1192
|
+
const base = { text, style, layer, anchor };
|
|
1193
|
+
if (fonts.bitmapFont) base.bitmap = true;
|
|
1194
|
+
else if (fonts.resolution !== void 0) base.resolution = fonts.resolution;
|
|
1195
|
+
return base;
|
|
1196
|
+
}
|
|
1197
|
+
__name(makeTextOptions, "makeTextOptions");
|
|
1198
|
+
|
|
1199
|
+
// src/chrome/DialogueChrome.ts
|
|
1200
|
+
function resolveActiveFrame(styleKey, styles) {
|
|
1201
|
+
if (styleKey === CHROME_STYLE_NONE) return { kind: "none" };
|
|
1202
|
+
if (styleKey !== void 0 && styles.has(styleKey)) return { kind: "nineSlice", key: styleKey };
|
|
1203
|
+
if (styles.has(CHROME_STYLE_DEFAULT)) return { kind: "nineSlice", key: CHROME_STYLE_DEFAULT };
|
|
1204
|
+
return { kind: "graphics" };
|
|
1205
|
+
}
|
|
1206
|
+
__name(resolveActiveFrame, "resolveActiveFrame");
|
|
1207
|
+
var DialogueChrome = class {
|
|
1208
|
+
constructor(cfg, layout) {
|
|
1209
|
+
this.cfg = cfg;
|
|
1210
|
+
this.layout = layout;
|
|
1211
|
+
this.layout.onChange(() => this.applyGeometry());
|
|
1212
|
+
}
|
|
1213
|
+
cfg;
|
|
1214
|
+
layout;
|
|
1215
|
+
static {
|
|
1216
|
+
__name(this, "DialogueChrome");
|
|
1217
|
+
}
|
|
1218
|
+
frame;
|
|
1219
|
+
frameGfx;
|
|
1220
|
+
/** Separate entity hosting the nine-slice sprites (one per textured style);
|
|
1221
|
+
* only spawned when {@link DialogueChromeConfig.frameStyles} has entries. Its
|
|
1222
|
+
* Transform tracks the per-line frame origin so the sprites draw at local 0. */
|
|
1223
|
+
frameTex;
|
|
1224
|
+
frameTexTransform;
|
|
1225
|
+
nineSliceHost;
|
|
1226
|
+
nineSlices = /* @__PURE__ */ new Map();
|
|
1227
|
+
name;
|
|
1228
|
+
indicator;
|
|
1229
|
+
indicatorTime = 0;
|
|
1230
|
+
/** Selected textured-style name from the line's `meta.chrome`, or undefined
|
|
1231
|
+
* when the line names none. */
|
|
1232
|
+
styleKey;
|
|
1233
|
+
warn;
|
|
1234
|
+
warnedKeys = /* @__PURE__ */ new Set();
|
|
1235
|
+
/** Master gate (from {@link setVisible}); the Session drives it. Hidden at
|
|
1236
|
+
* mount until a line shows. The name/caret also need their own content
|
|
1237
|
+
* sub-state — each renders only when shown AND its content is present. */
|
|
1238
|
+
visible = false;
|
|
1239
|
+
nameShown = false;
|
|
1240
|
+
caretShown = false;
|
|
1241
|
+
/** Route the unknown-`meta.chrome` warning to the engine Logger. */
|
|
1242
|
+
setDiagnostics(warn) {
|
|
1243
|
+
this.warn = warn;
|
|
1244
|
+
}
|
|
1245
|
+
mount(scene) {
|
|
1246
|
+
const cfg = this.cfg;
|
|
1247
|
+
const renderer = scene.context.tryResolve(import_renderer4.RendererKey);
|
|
1248
|
+
if (renderer) this.layout.setViewport(renderer.virtualSize.width, renderer.virtualSize.height);
|
|
1249
|
+
const frame = scene.spawn("dlg-frame");
|
|
1250
|
+
frame.add(new import_core3.Transform()).setPosition(0, 0);
|
|
1251
|
+
this.frameGfx = frame.add(new import_renderer4.GraphicsComponent({ layer: cfg.layerFrame }));
|
|
1252
|
+
this.frameGfx.graphics.visible = false;
|
|
1253
|
+
this.frame = frame;
|
|
1254
|
+
const styles = cfg.frameStyles;
|
|
1255
|
+
if (styles && Object.keys(styles).length > 0) {
|
|
1256
|
+
const texEntity = scene.spawn("dlg-frame-tex");
|
|
1257
|
+
this.frameTexTransform = texEntity.add(new import_core3.Transform());
|
|
1258
|
+
const host = texEntity.add(new import_renderer4.GraphicsComponent({ layer: cfg.layerFrame }));
|
|
1259
|
+
for (const [key, spec] of Object.entries(styles)) {
|
|
1260
|
+
const sprite = (0, import_renderer4.createNineSlice)({
|
|
1261
|
+
texture: spec.texture,
|
|
1262
|
+
leftWidth: spec.insets.left,
|
|
1263
|
+
topHeight: spec.insets.top,
|
|
1264
|
+
rightWidth: spec.insets.right,
|
|
1265
|
+
bottomHeight: spec.insets.bottom,
|
|
1266
|
+
width: this.layout.frameRect().width,
|
|
1267
|
+
height: this.layout.frameRect().height
|
|
1268
|
+
});
|
|
1269
|
+
sprite.visible = false;
|
|
1270
|
+
host.graphics.addChild(sprite);
|
|
1271
|
+
this.nineSlices.set(key, sprite);
|
|
1272
|
+
}
|
|
1273
|
+
this.frameTex = texEntity;
|
|
1274
|
+
this.nineSliceHost = host;
|
|
1275
|
+
}
|
|
1276
|
+
const nameEntity = scene.spawn("dlg-name");
|
|
1277
|
+
const nameTransform = nameEntity.add(new import_core3.Transform());
|
|
1278
|
+
const nameComp = nameEntity.add(
|
|
1279
|
+
new import_renderer4.TextComponent(makeTextOptions(cfg, "", cfg.nameSize, cfg.nameColor, cfg.layerText))
|
|
1280
|
+
);
|
|
1281
|
+
this.name = { entity: nameEntity, transform: nameTransform, comp: nameComp };
|
|
1282
|
+
const caretSize = cfg.caret?.size ?? DEFAULT_CARET_SIZE;
|
|
1283
|
+
const ind = scene.spawn("dlg-indicator");
|
|
1284
|
+
const indTransform = ind.add(new import_core3.Transform());
|
|
1285
|
+
const indGfx = ind.add(new import_renderer4.GraphicsComponent({ layer: cfg.layerFrame }));
|
|
1286
|
+
indGfx.draw((g) => drawCaret(g, cfg.indicatorColor, caretSize));
|
|
1287
|
+
indGfx.graphics.visible = false;
|
|
1288
|
+
this.indicator = { entity: ind, transform: indTransform, gfx: indGfx };
|
|
1289
|
+
this.applyGeometry();
|
|
1290
|
+
}
|
|
1291
|
+
setNameplate(name, color) {
|
|
1292
|
+
if (!this.name) return;
|
|
1293
|
+
this.nameShown = name !== void 0;
|
|
1294
|
+
if (name !== void 0) {
|
|
1295
|
+
this.name.comp.text.style.fill = color ?? this.cfg.nameColor;
|
|
1296
|
+
this.name.comp.setText(name);
|
|
1297
|
+
}
|
|
1298
|
+
this.apply();
|
|
1299
|
+
}
|
|
1300
|
+
setContinueVisible(visible) {
|
|
1301
|
+
this.caretShown = visible;
|
|
1302
|
+
this.indicatorTime = 0;
|
|
1303
|
+
this.apply();
|
|
1304
|
+
}
|
|
1305
|
+
/** Place this line's frame at its `meta.position` and pick its `meta.chrome`
|
|
1306
|
+
* style. Box only; the bubble ignores both. `undefined` (no line) resets to
|
|
1307
|
+
* the default look at the resting position. */
|
|
1308
|
+
present(line) {
|
|
1309
|
+
const metaChrome = line?.meta?.["chrome"];
|
|
1310
|
+
this.styleKey = typeof metaChrome === "string" ? metaChrome : void 0;
|
|
1311
|
+
if (this.styleKey !== void 0 && this.styleKey !== CHROME_STYLE_NONE && this.nineSlices.get(this.styleKey) === void 0 && !this.warnedKeys.has(this.styleKey)) {
|
|
1312
|
+
this.warnedKeys.add(this.styleKey);
|
|
1313
|
+
this.warn?.(`unknown meta.chrome style "${this.styleKey}" \u2014 using the default frame`);
|
|
1314
|
+
}
|
|
1315
|
+
this.layout.layoutLine(line);
|
|
1316
|
+
this.applyGeometry();
|
|
1317
|
+
this.apply();
|
|
1318
|
+
}
|
|
1319
|
+
/** Show or hide the whole box. State-preserving — the name/caret content
|
|
1320
|
+
* sub-state survives, so showing again restores exactly what was up. */
|
|
1321
|
+
setVisible(visible) {
|
|
1322
|
+
this.visible = visible;
|
|
1323
|
+
this.apply();
|
|
1324
|
+
}
|
|
1325
|
+
/** Redraw the frame + reposition the nameplate and caret from the owner's
|
|
1326
|
+
* current geometry (per line, and when a choice grows the frame). */
|
|
1327
|
+
applyGeometry() {
|
|
1328
|
+
const r = this.layout.frameRect();
|
|
1329
|
+
this.frameGfx?.draw((g) => {
|
|
1330
|
+
g.clear();
|
|
1331
|
+
g.roundRect(r.x, r.y, r.width, r.height, this.cfg.cornerRadius).fill({ color: this.cfg.frameColor, alpha: this.cfg.frameAlpha }).stroke({ color: this.cfg.borderColor, alpha: 1, width: 2 });
|
|
1332
|
+
});
|
|
1333
|
+
if (this.frameTexTransform) this.frameTexTransform.setPosition(r.x, r.y);
|
|
1334
|
+
for (const sprite of this.nineSlices.values()) {
|
|
1335
|
+
sprite.width = r.width;
|
|
1336
|
+
sprite.height = r.height;
|
|
1337
|
+
}
|
|
1338
|
+
if (this.name) {
|
|
1339
|
+
const p = this.layout.nameplatePos();
|
|
1340
|
+
this.name.transform.setPosition(p.x, p.y);
|
|
1341
|
+
}
|
|
1342
|
+
if (this.indicator) {
|
|
1343
|
+
const p = this.layout.caretPos(this.cfg.caret?.size ?? DEFAULT_CARET_SIZE);
|
|
1344
|
+
this.indicator.transform.setPosition(p.x, p.y);
|
|
1345
|
+
}
|
|
1346
|
+
}
|
|
1347
|
+
/** Render each piece = master-visible AND its own content present. */
|
|
1348
|
+
apply() {
|
|
1349
|
+
const active = resolveActiveFrame(this.styleKey, this.nineSlices);
|
|
1350
|
+
if (this.frameGfx) {
|
|
1351
|
+
this.frameGfx.graphics.visible = this.visible && active.kind === "graphics";
|
|
1352
|
+
}
|
|
1353
|
+
if (this.nineSliceHost) {
|
|
1354
|
+
this.nineSliceHost.graphics.visible = this.visible && active.kind === "nineSlice";
|
|
1355
|
+
for (const [key, sprite] of this.nineSlices) {
|
|
1356
|
+
sprite.visible = active.kind === "nineSlice" && active.key === key;
|
|
1357
|
+
}
|
|
1358
|
+
}
|
|
1359
|
+
if (this.name) this.name.comp.text.visible = this.visible && this.nameShown;
|
|
1360
|
+
if (this.indicator) {
|
|
1361
|
+
this.indicator.gfx.graphics.visible = this.visible && this.caretShown;
|
|
1362
|
+
}
|
|
1363
|
+
}
|
|
1364
|
+
update(dt) {
|
|
1365
|
+
const gfx = this.indicator?.gfx.graphics;
|
|
1366
|
+
if (gfx?.visible) {
|
|
1367
|
+
this.indicatorTime += dt;
|
|
1368
|
+
gfx.alpha = caretAlpha(this.indicatorTime, this.cfg.caret?.blinkMs);
|
|
1369
|
+
}
|
|
1370
|
+
}
|
|
1371
|
+
dispose() {
|
|
1372
|
+
this.frame?.destroy();
|
|
1373
|
+
this.frameTex?.destroy();
|
|
1374
|
+
this.name?.entity.destroy();
|
|
1375
|
+
this.indicator?.entity.destroy();
|
|
1376
|
+
this.nineSlices.clear();
|
|
1377
|
+
this.frame = void 0;
|
|
1378
|
+
this.frameTex = void 0;
|
|
1379
|
+
this.frameTexTransform = void 0;
|
|
1380
|
+
this.frameGfx = void 0;
|
|
1381
|
+
this.nineSliceHost = void 0;
|
|
1382
|
+
this.name = void 0;
|
|
1383
|
+
this.indicator = void 0;
|
|
1384
|
+
}
|
|
1385
|
+
};
|
|
1386
|
+
|
|
1387
|
+
// src/chrome/ChoiceListPresenter.ts
|
|
1388
|
+
var import_core4 = require("@yagejs/core");
|
|
1389
|
+
var import_renderer5 = require("@yagejs/renderer");
|
|
1390
|
+
|
|
1391
|
+
// src/chrome/choiceRow.ts
|
|
1392
|
+
var DISABLED_CHOICE_ALPHA = 0.4;
|
|
1393
|
+
function choiceRowLabel(choice) {
|
|
1394
|
+
return choice.disabled && choice.disabledReason ? `${choice.label} (${choice.disabledReason})` : choice.label;
|
|
1395
|
+
}
|
|
1396
|
+
__name(choiceRowLabel, "choiceRowLabel");
|
|
1397
|
+
function firstEnabledIndex(rows) {
|
|
1398
|
+
const i = rows.findIndex((r) => !r.disabled);
|
|
1399
|
+
return i < 0 ? 0 : i;
|
|
1400
|
+
}
|
|
1401
|
+
__name(firstEnabledIndex, "firstEnabledIndex");
|
|
1402
|
+
function clampSelection(position, count) {
|
|
1403
|
+
return Math.min(Math.max(position, 0), count - 1);
|
|
1404
|
+
}
|
|
1405
|
+
__name(clampSelection, "clampSelection");
|
|
1406
|
+
function applyChoiceTint(rows, selected, color, selectedColor) {
|
|
1407
|
+
rows.forEach((row, i) => {
|
|
1408
|
+
const active = i === selected && !row.disabled;
|
|
1409
|
+
row.comp.text.style.fill = active ? selectedColor : color;
|
|
1410
|
+
row.comp.text.alpha = row.disabled ? DISABLED_CHOICE_ALPHA : 1;
|
|
1411
|
+
});
|
|
1412
|
+
}
|
|
1413
|
+
__name(applyChoiceTint, "applyChoiceTint");
|
|
1414
|
+
|
|
1415
|
+
// src/chrome/ChoiceListPresenter.ts
|
|
1416
|
+
var DEFAULT_SOFT_MAX_CHOICES = 8;
|
|
1417
|
+
var ROW_TEXT_INDENT = 6;
|
|
1418
|
+
var ChoiceListPresenter = class {
|
|
1419
|
+
constructor(cfg, layout) {
|
|
1420
|
+
this.cfg = cfg;
|
|
1421
|
+
this.layout = layout;
|
|
1422
|
+
}
|
|
1423
|
+
cfg;
|
|
1424
|
+
layout;
|
|
1425
|
+
static {
|
|
1426
|
+
__name(this, "ChoiceListPresenter");
|
|
1427
|
+
}
|
|
1428
|
+
scene;
|
|
1429
|
+
highlightBar;
|
|
1430
|
+
rows = [];
|
|
1431
|
+
selected = -1;
|
|
1432
|
+
warn;
|
|
1433
|
+
/** Master visibility gate — hides the list WITHOUT clearing it, so a
|
|
1434
|
+
* hide/show round-trip keeps the rows + selection. */
|
|
1435
|
+
hidden = false;
|
|
1436
|
+
onChoiceChosen;
|
|
1437
|
+
/** Route the soft-cap advisory to the engine Logger. */
|
|
1438
|
+
setDiagnostics(warn) {
|
|
1439
|
+
this.warn = warn;
|
|
1440
|
+
}
|
|
1441
|
+
mount(scene) {
|
|
1442
|
+
this.scene = scene;
|
|
1443
|
+
const hl = scene.spawn("dlg-highlight");
|
|
1444
|
+
hl.add(new import_core4.Transform()).setPosition(0, 0);
|
|
1445
|
+
const hlGfx = hl.add(new import_renderer5.GraphicsComponent({ layer: this.cfg.layerFrame }));
|
|
1446
|
+
this.highlightBar = { entity: hl, gfx: hlGfx };
|
|
1447
|
+
}
|
|
1448
|
+
// Screen-space box list — ignores the choice context (the box frame is its
|
|
1449
|
+
// backing; routing is the composite's job).
|
|
1450
|
+
present(choices) {
|
|
1451
|
+
this.clear();
|
|
1452
|
+
if (!this.scene) return;
|
|
1453
|
+
const cfg = this.cfg;
|
|
1454
|
+
const gap = cfg.choiceGap ?? DEFAULT_CHOICE_GAP;
|
|
1455
|
+
const softMax = cfg.softMaxChoices ?? DEFAULT_SOFT_MAX_CHOICES;
|
|
1456
|
+
if (choices.length > softMax) {
|
|
1457
|
+
this.warn?.(
|
|
1458
|
+
`choice list: ${choices.length} options exceeds the soft max of ${softMax} \u2014 the list grows to fit, but a shorter menu (or a sub-menu) reads better`
|
|
1459
|
+
);
|
|
1460
|
+
}
|
|
1461
|
+
const innerWidth = this.layout.contentWidth();
|
|
1462
|
+
const wrapWidth = innerWidth - ROW_TEXT_INDENT - 2;
|
|
1463
|
+
const built = choices.map((choice) => {
|
|
1464
|
+
const entity = this.scene.spawn("dlg-choice");
|
|
1465
|
+
entity.add(new import_core4.Transform());
|
|
1466
|
+
const comp = entity.add(new import_renderer5.TextComponent(this.textOptions(choiceRowLabel(choice), wrapWidth)));
|
|
1467
|
+
comp.text.visible = true;
|
|
1468
|
+
const slotHeight = Math.ceil(comp.text.height) + gap;
|
|
1469
|
+
return { entity, comp, disabled: choice.disabled ?? false, slotHeight };
|
|
1470
|
+
});
|
|
1471
|
+
const rects = this.layout.layoutChoicePanel(built.map((b) => b.slotHeight));
|
|
1472
|
+
this.rows = built.map((b, i) => {
|
|
1473
|
+
const rect = rects[i] ?? { x: 0, y: 0, width: innerWidth, height: b.slotHeight };
|
|
1474
|
+
b.entity.get(import_core4.Transform).setPosition(rect.x + ROW_TEXT_INDENT, rect.y);
|
|
1475
|
+
return { entity: b.entity, comp: b.comp, disabled: b.disabled, rect };
|
|
1476
|
+
});
|
|
1477
|
+
this.highlightAt(firstEnabledIndex(this.rows));
|
|
1478
|
+
this.applyHidden();
|
|
1479
|
+
}
|
|
1480
|
+
highlight(position) {
|
|
1481
|
+
this.highlightAt(position);
|
|
1482
|
+
}
|
|
1483
|
+
/** Show or hide the list without clearing it — state-preserving. */
|
|
1484
|
+
setVisible(visible) {
|
|
1485
|
+
this.hidden = !visible;
|
|
1486
|
+
this.applyHidden();
|
|
1487
|
+
}
|
|
1488
|
+
/** Render = rows present AND not hidden. Disabled rows still show (greyed). */
|
|
1489
|
+
applyHidden() {
|
|
1490
|
+
for (const row of this.rows) row.comp.text.visible = !this.hidden;
|
|
1491
|
+
if (this.highlightBar) {
|
|
1492
|
+
const onEnabled = this.selected >= 0 && !this.rows[this.selected]?.disabled;
|
|
1493
|
+
this.highlightBar.gfx.graphics.visible = !this.hidden && onEnabled;
|
|
1494
|
+
}
|
|
1495
|
+
}
|
|
1496
|
+
/** {@link PointerChoiceTarget}: which row (if any) a screen point falls in —
|
|
1497
|
+
* from the same grown rects the rows are drawn at. */
|
|
1498
|
+
choiceAtPoint(x, y) {
|
|
1499
|
+
for (let i = 0; i < this.rows.length; i++) {
|
|
1500
|
+
const r = this.rows[i]?.rect;
|
|
1501
|
+
if (r && x >= r.x && x <= r.x + r.width && y >= r.y && y <= r.y + r.height) return i;
|
|
1502
|
+
}
|
|
1503
|
+
return void 0;
|
|
1504
|
+
}
|
|
1505
|
+
clear() {
|
|
1506
|
+
for (const row of this.rows) row.entity.destroy();
|
|
1507
|
+
this.rows = [];
|
|
1508
|
+
this.selected = -1;
|
|
1509
|
+
this.highlightBar?.gfx.draw((g) => g.clear());
|
|
1510
|
+
}
|
|
1511
|
+
dispose() {
|
|
1512
|
+
this.clear();
|
|
1513
|
+
this.highlightBar?.entity.destroy();
|
|
1514
|
+
this.highlightBar = void 0;
|
|
1515
|
+
}
|
|
1516
|
+
highlightAt(position) {
|
|
1517
|
+
if (this.rows.length === 0) return;
|
|
1518
|
+
this.selected = clampSelection(position, this.rows.length);
|
|
1519
|
+
applyChoiceTint(this.rows, this.selected, this.cfg.choiceColor, this.cfg.choiceSelectedColor);
|
|
1520
|
+
this.drawHighlight();
|
|
1521
|
+
}
|
|
1522
|
+
drawHighlight() {
|
|
1523
|
+
if (!this.highlightBar || this.selected < 0) return;
|
|
1524
|
+
const row = this.rows[this.selected];
|
|
1525
|
+
if (!row || row.disabled) {
|
|
1526
|
+
this.highlightBar.gfx.draw((g) => g.clear());
|
|
1527
|
+
return;
|
|
1528
|
+
}
|
|
1529
|
+
const r = row.rect;
|
|
1530
|
+
this.highlightBar.gfx.draw((g) => {
|
|
1531
|
+
g.clear();
|
|
1532
|
+
g.roundRect(r.x, r.y, r.width, r.height - 1, 3).fill({
|
|
1533
|
+
color: this.cfg.highlightColor,
|
|
1534
|
+
alpha: 0.3
|
|
1535
|
+
});
|
|
1536
|
+
});
|
|
1537
|
+
}
|
|
1538
|
+
textOptions(text, wrapWidth) {
|
|
1539
|
+
const opts = makeTextOptions(
|
|
1540
|
+
this.cfg,
|
|
1541
|
+
text,
|
|
1542
|
+
this.cfg.choiceSize,
|
|
1543
|
+
this.cfg.choiceColor,
|
|
1544
|
+
this.cfg.layerText
|
|
1545
|
+
);
|
|
1546
|
+
opts.style.wordWrap = true;
|
|
1547
|
+
opts.style.wordWrapWidth = wrapWidth;
|
|
1548
|
+
return opts;
|
|
1549
|
+
}
|
|
1550
|
+
};
|
|
1551
|
+
|
|
1552
|
+
// src/chrome/BubbleChrome.ts
|
|
1553
|
+
var import_core5 = require("@yagejs/core");
|
|
1554
|
+
var import_renderer6 = require("@yagejs/renderer");
|
|
1555
|
+
var BubbleChrome = class {
|
|
1556
|
+
constructor(cfg, layout) {
|
|
1557
|
+
this.cfg = cfg;
|
|
1558
|
+
this.layout = layout;
|
|
1559
|
+
this.currentWidth = 0;
|
|
1560
|
+
this.currentHeight = 0;
|
|
1561
|
+
}
|
|
1562
|
+
cfg;
|
|
1563
|
+
layout;
|
|
1564
|
+
static {
|
|
1565
|
+
__name(this, "BubbleChrome");
|
|
1566
|
+
}
|
|
1567
|
+
scene;
|
|
1568
|
+
root;
|
|
1569
|
+
gfx;
|
|
1570
|
+
transform;
|
|
1571
|
+
/** Nine-slice body sprite (child of {@link gfx}) when textured; resized per
|
|
1572
|
+
* line. The tail stays a drawn triangle on {@link gfx}. */
|
|
1573
|
+
bubbleSlice;
|
|
1574
|
+
name;
|
|
1575
|
+
nameTransform;
|
|
1576
|
+
caret;
|
|
1577
|
+
caretTransform;
|
|
1578
|
+
caretTime = 0;
|
|
1579
|
+
/** Current (content-sized) bubble size; recomputed per line from the layout. */
|
|
1580
|
+
currentWidth;
|
|
1581
|
+
currentHeight;
|
|
1582
|
+
// Master visibility + content sub-state; rendered = visible && hasLine.
|
|
1583
|
+
visible = false;
|
|
1584
|
+
// master (from setVisible)
|
|
1585
|
+
hasLine = false;
|
|
1586
|
+
// a line is up (from present)
|
|
1587
|
+
nameShown = false;
|
|
1588
|
+
// the speaker has a name to show
|
|
1589
|
+
caretShown = false;
|
|
1590
|
+
// continue caret requested
|
|
1591
|
+
/** Speaker id of the line on screen — re-resolved each frame by `follow()` so
|
|
1592
|
+
* the bubble tracks a live actor and falls back when one is missing. */
|
|
1593
|
+
speakerId;
|
|
1594
|
+
/** Route the missing-actor warning to the engine Logger (the layout owns the
|
|
1595
|
+
* shared anchor resolver). */
|
|
1596
|
+
setDiagnostics(warn) {
|
|
1597
|
+
this.layout.setDiagnostics(warn);
|
|
1598
|
+
}
|
|
1599
|
+
mount(scene) {
|
|
1600
|
+
this.scene = scene;
|
|
1601
|
+
const c = this.cfg;
|
|
1602
|
+
const root = scene.spawn("dlg-bubble");
|
|
1603
|
+
this.transform = root.add(new import_core5.Transform());
|
|
1604
|
+
this.gfx = root.add(new import_renderer6.GraphicsComponent({ layer: c.layer }));
|
|
1605
|
+
if (c.frame) {
|
|
1606
|
+
const slice = (0, import_renderer6.createNineSlice)({
|
|
1607
|
+
texture: c.frame.texture,
|
|
1608
|
+
leftWidth: c.frame.insets.left,
|
|
1609
|
+
topHeight: c.frame.insets.top,
|
|
1610
|
+
rightWidth: c.frame.insets.right,
|
|
1611
|
+
bottomHeight: c.frame.insets.bottom,
|
|
1612
|
+
width: this.currentWidth,
|
|
1613
|
+
height: this.currentHeight
|
|
1614
|
+
});
|
|
1615
|
+
this.gfx.graphics.addChild(slice);
|
|
1616
|
+
this.bubbleSlice = slice;
|
|
1617
|
+
}
|
|
1618
|
+
this.drawBubble();
|
|
1619
|
+
this.gfx.graphics.visible = false;
|
|
1620
|
+
const nameEntity = scene.spawn("dlg-bubble-name");
|
|
1621
|
+
this.nameTransform = nameEntity.add(new import_core5.Transform());
|
|
1622
|
+
this.name = nameEntity.add(
|
|
1623
|
+
new import_renderer6.TextComponent(makeTextOptions(c, "", c.nameSize, c.nameColor, c.layer))
|
|
1624
|
+
);
|
|
1625
|
+
this.name.text.visible = false;
|
|
1626
|
+
const caretEntity = scene.spawn("dlg-bubble-caret");
|
|
1627
|
+
this.caretTransform = caretEntity.add(new import_core5.Transform());
|
|
1628
|
+
this.caret = caretEntity.add(new import_renderer6.GraphicsComponent({ layer: c.layer }));
|
|
1629
|
+
this.caret.draw((g) => drawCaret(g, c.indicatorColor, c.caret?.size));
|
|
1630
|
+
this.caret.graphics.visible = false;
|
|
1631
|
+
this.root = root;
|
|
1632
|
+
}
|
|
1633
|
+
/** Re-anchor to the line's speaker, grow to fit the text, and reveal. The
|
|
1634
|
+
* bubble stays visible even when the speaker has no live actor — the layout
|
|
1635
|
+
* anchors it at the last-known / fallback position instead of vanishing. */
|
|
1636
|
+
present(line) {
|
|
1637
|
+
this.hasLine = line !== void 0;
|
|
1638
|
+
this.speakerId = line?.speaker?.id;
|
|
1639
|
+
if (line) {
|
|
1640
|
+
const size = this.layout.sizeFor(line);
|
|
1641
|
+
this.currentWidth = size.width;
|
|
1642
|
+
this.currentHeight = size.height;
|
|
1643
|
+
this.drawBubble();
|
|
1644
|
+
const label = line.speaker?.name;
|
|
1645
|
+
this.nameShown = label !== void 0 && label.length > 0;
|
|
1646
|
+
if (label && this.name) {
|
|
1647
|
+
this.name.text.style.fill = line.speaker?.color ?? this.cfg.nameColor;
|
|
1648
|
+
this.name.setText(label);
|
|
1649
|
+
}
|
|
1650
|
+
} else {
|
|
1651
|
+
this.nameShown = false;
|
|
1652
|
+
}
|
|
1653
|
+
this.apply();
|
|
1654
|
+
this.follow();
|
|
1655
|
+
}
|
|
1656
|
+
setNameplate(name) {
|
|
1657
|
+
if (name === void 0) {
|
|
1658
|
+
this.nameShown = false;
|
|
1659
|
+
this.apply();
|
|
1660
|
+
}
|
|
1661
|
+
}
|
|
1662
|
+
setContinueVisible(visible) {
|
|
1663
|
+
this.caretShown = visible;
|
|
1664
|
+
this.caretTime = 0;
|
|
1665
|
+
this.apply();
|
|
1666
|
+
}
|
|
1667
|
+
/** Show or hide the whole bubble — state-preserving: the line, name, and
|
|
1668
|
+
* caret content survive a hide, so showing again restores them in place
|
|
1669
|
+
* (used by a composite chrome to hide the bubble while a box line plays). */
|
|
1670
|
+
setVisible(visible) {
|
|
1671
|
+
this.visible = visible;
|
|
1672
|
+
this.apply();
|
|
1673
|
+
}
|
|
1674
|
+
/** Render each piece = master-visible AND a line is up AND its content present. */
|
|
1675
|
+
apply() {
|
|
1676
|
+
if (this.gfx) this.gfx.graphics.visible = this.visible && this.hasLine;
|
|
1677
|
+
if (this.name) {
|
|
1678
|
+
this.name.text.visible = this.visible && this.hasLine && this.nameShown;
|
|
1679
|
+
}
|
|
1680
|
+
if (this.caret) {
|
|
1681
|
+
this.caret.graphics.visible = this.visible && this.hasLine && this.caretShown;
|
|
1682
|
+
}
|
|
1683
|
+
}
|
|
1684
|
+
update(dt) {
|
|
1685
|
+
this.follow();
|
|
1686
|
+
const gfx = this.caret?.graphics;
|
|
1687
|
+
if (gfx?.visible) {
|
|
1688
|
+
this.caretTime += dt;
|
|
1689
|
+
gfx.alpha = caretAlpha(this.caretTime, this.cfg.caret?.blinkMs);
|
|
1690
|
+
}
|
|
1691
|
+
}
|
|
1692
|
+
dispose() {
|
|
1693
|
+
this.root?.destroy();
|
|
1694
|
+
this.name?.entity.destroy();
|
|
1695
|
+
this.caret?.entity.destroy();
|
|
1696
|
+
this.root = void 0;
|
|
1697
|
+
this.gfx = void 0;
|
|
1698
|
+
this.transform = void 0;
|
|
1699
|
+
this.bubbleSlice = void 0;
|
|
1700
|
+
this.name = void 0;
|
|
1701
|
+
this.nameTransform = void 0;
|
|
1702
|
+
this.caret = void 0;
|
|
1703
|
+
this.caretTransform = void 0;
|
|
1704
|
+
}
|
|
1705
|
+
/** Move the bubble + name + caret to sit above the speaker — its live actor,
|
|
1706
|
+
* or the last-known / fallback anchor when the actor is missing. */
|
|
1707
|
+
follow() {
|
|
1708
|
+
if (!this.scene || !this.hasLine) return;
|
|
1709
|
+
const a = this.layout.anchorFor(this.scene, this.speakerId);
|
|
1710
|
+
const c = this.cfg;
|
|
1711
|
+
const padding = this.layout.padding;
|
|
1712
|
+
const offsetY = this.layout.offsetY;
|
|
1713
|
+
const w = this.currentWidth;
|
|
1714
|
+
const h = this.currentHeight;
|
|
1715
|
+
const caretSize = c.caret?.size ?? DEFAULT_CARET_SIZE;
|
|
1716
|
+
this.transform?.setPosition(a.x, a.y);
|
|
1717
|
+
this.nameTransform?.setPosition(a.x - w / 2 + padding, a.y - (offsetY + h) - c.nameSize - 1);
|
|
1718
|
+
this.caretTransform?.setPosition(
|
|
1719
|
+
a.x + w / 2 - padding - caretSize.width,
|
|
1720
|
+
a.y - offsetY - padding - caretSize.height + 3
|
|
1721
|
+
);
|
|
1722
|
+
}
|
|
1723
|
+
drawBubble() {
|
|
1724
|
+
const c = this.cfg;
|
|
1725
|
+
const offsetY = this.layout.offsetY;
|
|
1726
|
+
const w = this.currentWidth;
|
|
1727
|
+
const h = this.currentHeight;
|
|
1728
|
+
const L = -w / 2;
|
|
1729
|
+
const R = w / 2;
|
|
1730
|
+
const T = -(offsetY + h);
|
|
1731
|
+
const B = -offsetY;
|
|
1732
|
+
const half = c.tail;
|
|
1733
|
+
const lean = c.tailLean ?? DEFAULT_TAIL_LEAN;
|
|
1734
|
+
const tipX = lean.x;
|
|
1735
|
+
const tipY = lean.y;
|
|
1736
|
+
this.gfx?.graphics.clear();
|
|
1737
|
+
if (this.bubbleSlice) {
|
|
1738
|
+
this.bubbleSlice.position.set(L, T);
|
|
1739
|
+
this.bubbleSlice.width = w;
|
|
1740
|
+
this.bubbleSlice.height = h;
|
|
1741
|
+
this.gfx?.draw((g) => {
|
|
1742
|
+
g.poly([-half, B, half, B, tipX, tipY]).fill({ color: c.frameColor, alpha: c.frameAlpha });
|
|
1743
|
+
});
|
|
1744
|
+
return;
|
|
1745
|
+
}
|
|
1746
|
+
const r = Math.max(0, Math.min(c.cornerRadius, w / 2 - 1, h / 2 - 1));
|
|
1747
|
+
this.gfx?.draw((g) => {
|
|
1748
|
+
g.moveTo(L + r, T).lineTo(R - r, T).arcTo(R, T, R, T + r, r).lineTo(R, B - r).arcTo(R, B, R - r, B, r).lineTo(half, B).lineTo(tipX, tipY).lineTo(-half, B).lineTo(L + r, B).arcTo(L, B, L, B - r, r).lineTo(L, T + r).arcTo(L, T, L + r, T, r).closePath().fill({ color: c.frameColor, alpha: c.frameAlpha }).stroke({ color: c.borderColor, alpha: 1, width: 2 });
|
|
1749
|
+
});
|
|
1750
|
+
}
|
|
1751
|
+
};
|
|
1752
|
+
|
|
1753
|
+
// src/chrome/BubbleChoicePresenter.ts
|
|
1754
|
+
var import_core6 = require("@yagejs/core");
|
|
1755
|
+
var import_renderer7 = require("@yagejs/renderer");
|
|
1756
|
+
var BubbleChoicePresenter = class {
|
|
1757
|
+
constructor(cfg, layout) {
|
|
1758
|
+
this.cfg = cfg;
|
|
1759
|
+
this.layout = layout;
|
|
1760
|
+
}
|
|
1761
|
+
cfg;
|
|
1762
|
+
layout;
|
|
1763
|
+
static {
|
|
1764
|
+
__name(this, "BubbleChoicePresenter");
|
|
1765
|
+
}
|
|
1766
|
+
pointerSpace = "world";
|
|
1767
|
+
scene;
|
|
1768
|
+
bg;
|
|
1769
|
+
highlightBar;
|
|
1770
|
+
prompt;
|
|
1771
|
+
rows = [];
|
|
1772
|
+
selected = -1;
|
|
1773
|
+
/** Master visibility gate — state-preserving hide/show. */
|
|
1774
|
+
hidden = false;
|
|
1775
|
+
onChoiceChosen;
|
|
1776
|
+
/** Route the missing-actor warning to the engine Logger (the layout owns the
|
|
1777
|
+
* shared anchor resolver). */
|
|
1778
|
+
setDiagnostics(warn) {
|
|
1779
|
+
this.layout.setDiagnostics(warn);
|
|
1780
|
+
}
|
|
1781
|
+
/** This panel is self-contained (own bg + prompt header), so it owns the
|
|
1782
|
+
* prompt — the Session hides the chrome/body prompt for these choices. */
|
|
1783
|
+
ownsPrompt() {
|
|
1784
|
+
return true;
|
|
1785
|
+
}
|
|
1786
|
+
mount(scene) {
|
|
1787
|
+
this.scene = scene;
|
|
1788
|
+
const bg = scene.spawn("dlg-bchoice-bg");
|
|
1789
|
+
bg.add(new import_core6.Transform()).setPosition(0, 0);
|
|
1790
|
+
this.bg = { entity: bg, gfx: bg.add(new import_renderer7.GraphicsComponent({ layer: this.cfg.layer })) };
|
|
1791
|
+
const hl = scene.spawn("dlg-bchoice-hl");
|
|
1792
|
+
hl.add(new import_core6.Transform()).setPosition(0, 0);
|
|
1793
|
+
this.highlightBar = { entity: hl, gfx: hl.add(new import_renderer7.GraphicsComponent({ layer: this.cfg.layer })) };
|
|
1794
|
+
}
|
|
1795
|
+
present(choices, context) {
|
|
1796
|
+
this.clear();
|
|
1797
|
+
if (!this.scene) return;
|
|
1798
|
+
const c = this.cfg;
|
|
1799
|
+
const a = this.layout.anchorFor(this.scene, context?.speaker?.id);
|
|
1800
|
+
const innerW = c.width - 2 * c.padding;
|
|
1801
|
+
const inset = this.layout.portraitInset();
|
|
1802
|
+
const reserve = inset?.width ?? 0;
|
|
1803
|
+
const panelWidth = c.width + reserve;
|
|
1804
|
+
const promptStr = context?.prompt && context.prompt.length > 0 ? context.prompt.runs.map((r) => r.text).join("") : "";
|
|
1805
|
+
let promptH = 0;
|
|
1806
|
+
if (promptStr) {
|
|
1807
|
+
const e = this.scene.spawn("dlg-bchoice-prompt");
|
|
1808
|
+
e.add(new import_core6.Transform());
|
|
1809
|
+
const comp = e.add(new import_renderer7.TextComponent(this.textOptions(promptStr, c.textColor, innerW)));
|
|
1810
|
+
comp.text.visible = true;
|
|
1811
|
+
promptH = Math.ceil(comp.text.height);
|
|
1812
|
+
this.prompt = { entity: e, comp };
|
|
1813
|
+
}
|
|
1814
|
+
const n = choices.length;
|
|
1815
|
+
const gap = c.choiceGap ?? DEFAULT_CHOICE_GAP;
|
|
1816
|
+
const lineH = c.choiceSize + gap;
|
|
1817
|
+
const headH = promptH > 0 ? promptH + gap : 0;
|
|
1818
|
+
const contentH = c.padding + headH + n * lineH + c.padding;
|
|
1819
|
+
const panelH = Math.max(contentH, (inset?.height ?? 0) + 2 * c.padding);
|
|
1820
|
+
const left = a.x - panelWidth / 2;
|
|
1821
|
+
const bottom = a.y - c.offsetY;
|
|
1822
|
+
const top = bottom - panelH;
|
|
1823
|
+
const contentX = left + c.padding + (inset?.side === "left" ? reserve : 0);
|
|
1824
|
+
this.drawPanel(left, top, panelWidth, panelH, a.x, bottom);
|
|
1825
|
+
this.prompt?.entity.get(import_core6.Transform).setPosition(contentX, top + c.padding);
|
|
1826
|
+
const optionsTop = top + c.padding + headH;
|
|
1827
|
+
choices.forEach((choice, i) => {
|
|
1828
|
+
const rowY = optionsTop + i * lineH;
|
|
1829
|
+
const entity = this.scene.spawn("dlg-bchoice");
|
|
1830
|
+
entity.add(new import_core6.Transform()).setPosition(contentX + 4, rowY);
|
|
1831
|
+
const comp = entity.add(new import_renderer7.TextComponent(this.textOptions(choiceRowLabel(choice), c.choiceColor)));
|
|
1832
|
+
comp.text.visible = true;
|
|
1833
|
+
this.rows.push({
|
|
1834
|
+
entity,
|
|
1835
|
+
comp,
|
|
1836
|
+
x0: contentX,
|
|
1837
|
+
x1: contentX + innerW,
|
|
1838
|
+
y0: rowY,
|
|
1839
|
+
y1: rowY + lineH,
|
|
1840
|
+
disabled: choice.disabled ?? false
|
|
1841
|
+
});
|
|
1842
|
+
});
|
|
1843
|
+
this.highlight(firstEnabledIndex(this.rows));
|
|
1844
|
+
this.applyHidden();
|
|
1845
|
+
this.layout.setChoicePanelSize({ width: panelWidth, height: panelH });
|
|
1846
|
+
}
|
|
1847
|
+
/** Show or hide the whole panel without clearing it — state-preserving. */
|
|
1848
|
+
setVisible(visible) {
|
|
1849
|
+
this.hidden = !visible;
|
|
1850
|
+
this.applyHidden();
|
|
1851
|
+
}
|
|
1852
|
+
/** Render every piece (bg, prompt, rows, highlight) gated by the master.
|
|
1853
|
+
* Disabled rows still show (greyed); only the highlight bar is suppressed. */
|
|
1854
|
+
applyHidden() {
|
|
1855
|
+
const shown = !this.hidden;
|
|
1856
|
+
if (this.bg) this.bg.gfx.graphics.visible = shown && this.rows.length > 0;
|
|
1857
|
+
if (this.highlightBar) {
|
|
1858
|
+
const onEnabled = this.selected >= 0 && !this.rows[this.selected]?.disabled;
|
|
1859
|
+
this.highlightBar.gfx.graphics.visible = shown && onEnabled;
|
|
1860
|
+
}
|
|
1861
|
+
if (this.prompt) this.prompt.comp.text.visible = shown;
|
|
1862
|
+
for (const row of this.rows) row.comp.text.visible = shown;
|
|
1863
|
+
}
|
|
1864
|
+
highlight(position) {
|
|
1865
|
+
if (this.rows.length === 0) return;
|
|
1866
|
+
this.selected = clampSelection(position, this.rows.length);
|
|
1867
|
+
applyChoiceTint(this.rows, this.selected, this.cfg.choiceColor, this.cfg.choiceSelectedColor);
|
|
1868
|
+
this.drawHighlight();
|
|
1869
|
+
}
|
|
1870
|
+
/** {@link ChoicePresenter}: which option a *world* point falls in. */
|
|
1871
|
+
choiceAtPoint(x, y) {
|
|
1872
|
+
for (let i = 0; i < this.rows.length; i++) {
|
|
1873
|
+
const r = this.rows[i];
|
|
1874
|
+
if (x >= r.x0 && x <= r.x1 && y >= r.y0 && y <= r.y1) return i;
|
|
1875
|
+
}
|
|
1876
|
+
return void 0;
|
|
1877
|
+
}
|
|
1878
|
+
clear() {
|
|
1879
|
+
for (const r of this.rows) r.entity.destroy();
|
|
1880
|
+
this.rows = [];
|
|
1881
|
+
this.prompt?.entity.destroy();
|
|
1882
|
+
this.prompt = void 0;
|
|
1883
|
+
this.selected = -1;
|
|
1884
|
+
this.bg?.gfx.draw((g) => g.clear());
|
|
1885
|
+
this.highlightBar?.gfx.draw((g) => g.clear());
|
|
1886
|
+
}
|
|
1887
|
+
dispose() {
|
|
1888
|
+
this.clear();
|
|
1889
|
+
this.bg?.entity.destroy();
|
|
1890
|
+
this.highlightBar?.entity.destroy();
|
|
1891
|
+
this.bg = void 0;
|
|
1892
|
+
this.highlightBar = void 0;
|
|
1893
|
+
}
|
|
1894
|
+
drawPanel(left, top, width, h, tailX, bottom) {
|
|
1895
|
+
const c = this.cfg;
|
|
1896
|
+
this.bg?.gfx.draw((g) => {
|
|
1897
|
+
g.clear();
|
|
1898
|
+
g.roundRect(left, top, width, h, c.cornerRadius).fill({ color: c.frameColor, alpha: c.frameAlpha }).stroke({ color: c.borderColor, alpha: 1, width: 2 });
|
|
1899
|
+
g.poly([tailX - c.tail, bottom, tailX + c.tail, bottom, tailX, bottom + c.tail]).fill({
|
|
1900
|
+
color: c.frameColor,
|
|
1901
|
+
alpha: c.frameAlpha
|
|
1902
|
+
});
|
|
1903
|
+
});
|
|
1904
|
+
}
|
|
1905
|
+
drawHighlight() {
|
|
1906
|
+
const row = this.rows[this.selected];
|
|
1907
|
+
if (!this.highlightBar || !row) return;
|
|
1908
|
+
if (row.disabled) {
|
|
1909
|
+
this.highlightBar.gfx.draw((g) => g.clear());
|
|
1910
|
+
return;
|
|
1911
|
+
}
|
|
1912
|
+
this.highlightBar.gfx.draw((g) => {
|
|
1913
|
+
g.clear();
|
|
1914
|
+
g.roundRect(row.x0, row.y0, row.x1 - row.x0, row.y1 - row.y0 - 1, 3).fill({
|
|
1915
|
+
color: this.cfg.highlightColor,
|
|
1916
|
+
alpha: 0.3
|
|
1917
|
+
});
|
|
1918
|
+
});
|
|
1919
|
+
}
|
|
1920
|
+
textOptions(text, color, wrapWidth) {
|
|
1921
|
+
const opts = makeTextOptions(this.cfg, text, this.cfg.choiceSize, color, this.cfg.layer);
|
|
1922
|
+
if (wrapWidth != null) {
|
|
1923
|
+
opts.style.wordWrap = true;
|
|
1924
|
+
opts.style.wordWrapWidth = wrapWidth;
|
|
1925
|
+
}
|
|
1926
|
+
return opts;
|
|
1927
|
+
}
|
|
1928
|
+
};
|
|
1929
|
+
|
|
1930
|
+
// src/chrome/RadialChoicePresenter.ts
|
|
1931
|
+
var import_core7 = require("@yagejs/core");
|
|
1932
|
+
var import_renderer8 = require("@yagejs/renderer");
|
|
1933
|
+
var RadialChoicePresenter = class {
|
|
1934
|
+
constructor(cfg) {
|
|
1935
|
+
this.cfg = cfg;
|
|
1936
|
+
}
|
|
1937
|
+
cfg;
|
|
1938
|
+
static {
|
|
1939
|
+
__name(this, "RadialChoicePresenter");
|
|
1940
|
+
}
|
|
1941
|
+
scene;
|
|
1942
|
+
hub;
|
|
1943
|
+
spokes = [];
|
|
1944
|
+
selected = -1;
|
|
1945
|
+
/** Master visibility gate — state-preserving hide/show. */
|
|
1946
|
+
hidden = false;
|
|
1947
|
+
onChoiceChosen;
|
|
1948
|
+
mount(scene) {
|
|
1949
|
+
this.scene = scene;
|
|
1950
|
+
const hub = scene.spawn("dlg-radial-hub");
|
|
1951
|
+
hub.add(new import_core7.Transform()).setPosition(0, 0);
|
|
1952
|
+
this.hub = { entity: hub, gfx: hub.add(new import_renderer8.GraphicsComponent({ layer: this.cfg.layerFrame })) };
|
|
1953
|
+
}
|
|
1954
|
+
present(choices) {
|
|
1955
|
+
this.clear();
|
|
1956
|
+
if (!this.scene) return;
|
|
1957
|
+
const { center: c, radius } = this.cfg;
|
|
1958
|
+
const n = choices.length;
|
|
1959
|
+
choices.forEach((choice, i) => {
|
|
1960
|
+
const angle = -Math.PI / 2 + i / n * Math.PI * 2;
|
|
1961
|
+
const x = c.x + Math.cos(angle) * radius;
|
|
1962
|
+
const y = c.y + Math.sin(angle) * radius;
|
|
1963
|
+
const entity = this.scene.spawn("dlg-radial");
|
|
1964
|
+
entity.add(new import_core7.Transform()).setPosition(x, y);
|
|
1965
|
+
const comp = entity.add(
|
|
1966
|
+
new import_renderer8.TextComponent(
|
|
1967
|
+
makeTextOptions(
|
|
1968
|
+
this.cfg,
|
|
1969
|
+
choice.label,
|
|
1970
|
+
this.cfg.choiceSize,
|
|
1971
|
+
this.cfg.choiceColor,
|
|
1972
|
+
this.cfg.layerText,
|
|
1973
|
+
{ x: 0.5, y: 0.5 }
|
|
1974
|
+
)
|
|
1975
|
+
)
|
|
1976
|
+
);
|
|
1977
|
+
comp.text.visible = true;
|
|
1978
|
+
this.spokes.push({ entity, comp, x, y, disabled: choice.disabled ?? false });
|
|
1979
|
+
});
|
|
1980
|
+
this.highlight(firstEnabledIndex(this.spokes));
|
|
1981
|
+
this.applyHidden();
|
|
1982
|
+
}
|
|
1983
|
+
highlight(position) {
|
|
1984
|
+
if (this.spokes.length === 0) return;
|
|
1985
|
+
this.selected = clampSelection(position, this.spokes.length);
|
|
1986
|
+
applyChoiceTint(this.spokes, this.selected, this.cfg.choiceColor, this.cfg.choiceSelectedColor);
|
|
1987
|
+
this.spokes.forEach((s, i) => {
|
|
1988
|
+
const scale = i === this.selected && !s.disabled ? 1.15 : 1;
|
|
1989
|
+
s.entity.get(import_core7.Transform).setScale(scale, scale);
|
|
1990
|
+
});
|
|
1991
|
+
this.drawHub();
|
|
1992
|
+
}
|
|
1993
|
+
/** Show or hide the wheel without clearing it — state-preserving. */
|
|
1994
|
+
setVisible(visible) {
|
|
1995
|
+
this.hidden = !visible;
|
|
1996
|
+
this.applyHidden();
|
|
1997
|
+
}
|
|
1998
|
+
applyHidden() {
|
|
1999
|
+
for (const s of this.spokes) s.comp.text.visible = !this.hidden;
|
|
2000
|
+
if (this.hub) this.hub.gfx.graphics.visible = !this.hidden && this.spokes.length > 0;
|
|
2001
|
+
}
|
|
2002
|
+
/** {@link ChoicePresenter}: nearest spoke within a small radius. */
|
|
2003
|
+
choiceAtPoint(x, y) {
|
|
2004
|
+
let best;
|
|
2005
|
+
let bestD = 22;
|
|
2006
|
+
this.spokes.forEach((s, i) => {
|
|
2007
|
+
const d = Math.hypot(s.x - x, s.y - y);
|
|
2008
|
+
if (d < bestD) {
|
|
2009
|
+
bestD = d;
|
|
2010
|
+
best = i;
|
|
2011
|
+
}
|
|
2012
|
+
});
|
|
2013
|
+
return best;
|
|
2014
|
+
}
|
|
2015
|
+
clear() {
|
|
2016
|
+
for (const s of this.spokes) s.entity.destroy();
|
|
2017
|
+
this.spokes = [];
|
|
2018
|
+
this.selected = -1;
|
|
2019
|
+
this.hub?.gfx.draw((g) => g.clear());
|
|
2020
|
+
}
|
|
2021
|
+
dispose() {
|
|
2022
|
+
this.clear();
|
|
2023
|
+
this.hub?.entity.destroy();
|
|
2024
|
+
this.hub = void 0;
|
|
2025
|
+
}
|
|
2026
|
+
drawHub() {
|
|
2027
|
+
if (!this.hub) return;
|
|
2028
|
+
const { center: c } = this.cfg;
|
|
2029
|
+
const sel = this.spokes[this.selected];
|
|
2030
|
+
const active = sel && !sel.disabled ? sel : void 0;
|
|
2031
|
+
this.hub.gfx.draw((g) => {
|
|
2032
|
+
g.clear();
|
|
2033
|
+
if (active) {
|
|
2034
|
+
g.moveTo(c.x, c.y).lineTo(active.x, active.y).stroke({ color: this.cfg.choiceSelectedColor, width: 2, alpha: 0.7 });
|
|
2035
|
+
}
|
|
2036
|
+
g.circle(c.x, c.y, 4).fill({ color: this.cfg.hubColor, alpha: 1 });
|
|
2037
|
+
});
|
|
2038
|
+
}
|
|
2039
|
+
};
|
|
2040
|
+
|
|
2041
|
+
// src/composite/route.ts
|
|
2042
|
+
function routeWithActor(line, hasActor) {
|
|
2043
|
+
const speaker = line?.speaker;
|
|
2044
|
+
if (speaker === void 0) return "box";
|
|
2045
|
+
const view = line?.view;
|
|
2046
|
+
if (view === "bubble") return "bubble";
|
|
2047
|
+
if (view === "box") return "box";
|
|
2048
|
+
return hasActor(speaker.id) ? "bubble" : "box";
|
|
2049
|
+
}
|
|
2050
|
+
__name(routeWithActor, "routeWithActor");
|
|
2051
|
+
function makeDefaultRoute() {
|
|
2052
|
+
let hasActor = /* @__PURE__ */ __name(() => false, "hasActor");
|
|
2053
|
+
return {
|
|
2054
|
+
bind(scene) {
|
|
2055
|
+
const registry = actorRegistryFor(scene);
|
|
2056
|
+
hasActor = /* @__PURE__ */ __name((id) => registry.resolve(id) !== void 0, "hasActor");
|
|
2057
|
+
},
|
|
2058
|
+
route: /* @__PURE__ */ __name((line) => routeWithActor(line, hasActor), "route")
|
|
2059
|
+
};
|
|
2060
|
+
}
|
|
2061
|
+
__name(makeDefaultRoute, "makeDefaultRoute");
|
|
2062
|
+
function fixedRoute(route) {
|
|
2063
|
+
return { route, bind() {
|
|
2064
|
+
} };
|
|
2065
|
+
}
|
|
2066
|
+
__name(fixedRoute, "fixedRoute");
|
|
2067
|
+
function choiceAsLine(context) {
|
|
2068
|
+
return {
|
|
2069
|
+
view: context?.view,
|
|
2070
|
+
speaker: context?.speaker,
|
|
2071
|
+
meta: context?.meta,
|
|
2072
|
+
text: context?.prompt ?? EMPTY_PARSED,
|
|
2073
|
+
speed: 1
|
|
2074
|
+
};
|
|
2075
|
+
}
|
|
2076
|
+
__name(choiceAsLine, "choiceAsLine");
|
|
2077
|
+
function lineRoutesToBubble(route, line) {
|
|
2078
|
+
return route(line) === "bubble";
|
|
2079
|
+
}
|
|
2080
|
+
__name(lineRoutesToBubble, "lineRoutesToBubble");
|
|
2081
|
+
function choiceRoutesToBubble(route, context) {
|
|
2082
|
+
return route(choiceAsLine(context)) === "bubble";
|
|
2083
|
+
}
|
|
2084
|
+
__name(choiceRoutesToBubble, "choiceRoutesToBubble");
|
|
2085
|
+
|
|
2086
|
+
// src/composite/CompositeTextPresenter.ts
|
|
2087
|
+
var CompositeTextPresenter = class {
|
|
2088
|
+
constructor(box, bubble, routing = makeDefaultRoute()) {
|
|
2089
|
+
this.box = box;
|
|
2090
|
+
this.bubble = bubble;
|
|
2091
|
+
this.routing = routing;
|
|
2092
|
+
this.box.setRevealListener(() => this.fireReveal(this.box));
|
|
2093
|
+
this.bubble.setRevealListener(() => this.fireReveal(this.bubble));
|
|
2094
|
+
this.box.setBeatListener((beat) => this.fireBeat(this.box, beat));
|
|
2095
|
+
this.bubble.setBeatListener((beat) => this.fireBeat(this.bubble, beat));
|
|
2096
|
+
}
|
|
2097
|
+
box;
|
|
2098
|
+
bubble;
|
|
2099
|
+
routing;
|
|
2100
|
+
static {
|
|
2101
|
+
__name(this, "CompositeTextPresenter");
|
|
2102
|
+
}
|
|
2103
|
+
active;
|
|
2104
|
+
revealListener;
|
|
2105
|
+
beatListener;
|
|
2106
|
+
/** Master visibility gate from the Session's setVisible. */
|
|
2107
|
+
visible = false;
|
|
2108
|
+
/** Register the Session's reveal-completed listener. */
|
|
2109
|
+
setRevealListener(listener) {
|
|
2110
|
+
this.revealListener = listener;
|
|
2111
|
+
}
|
|
2112
|
+
/** Register the Session's reveal-beat listener (ticks + inline markers). */
|
|
2113
|
+
setBeatListener(listener) {
|
|
2114
|
+
this.beatListener = listener;
|
|
2115
|
+
}
|
|
2116
|
+
fireReveal(view) {
|
|
2117
|
+
if (this.active === view) this.revealListener?.();
|
|
2118
|
+
}
|
|
2119
|
+
fireBeat(view, beat) {
|
|
2120
|
+
if (this.active === view) this.beatListener?.(beat);
|
|
2121
|
+
}
|
|
2122
|
+
setDiagnostics(warn) {
|
|
2123
|
+
this.box.setDiagnostics?.(warn);
|
|
2124
|
+
this.bubble.setDiagnostics?.(warn);
|
|
2125
|
+
}
|
|
2126
|
+
mount(scene) {
|
|
2127
|
+
this.routing.bind(scene);
|
|
2128
|
+
this.box.mount(scene);
|
|
2129
|
+
this.bubble.mount(scene);
|
|
2130
|
+
}
|
|
2131
|
+
present(line) {
|
|
2132
|
+
const target = lineRoutesToBubble(this.routing.route, line) ? this.bubble : this.box;
|
|
2133
|
+
const other = target === this.box ? this.bubble : this.box;
|
|
2134
|
+
other.clear();
|
|
2135
|
+
this.active = target;
|
|
2136
|
+
target.present(line);
|
|
2137
|
+
target.setVisible(this.visible);
|
|
2138
|
+
}
|
|
2139
|
+
/** Show/hide the body text — forwarded to both views (the inactive one is
|
|
2140
|
+
* cleared, so its setVisible is a no-op); state-preserving on the active. */
|
|
2141
|
+
setVisible(visible) {
|
|
2142
|
+
this.visible = visible;
|
|
2143
|
+
this.box.setVisible(visible);
|
|
2144
|
+
this.bubble.setVisible(visible);
|
|
2145
|
+
}
|
|
2146
|
+
completeReveal() {
|
|
2147
|
+
this.active?.completeReveal();
|
|
2148
|
+
}
|
|
2149
|
+
isRevealComplete() {
|
|
2150
|
+
return this.active ? this.active.isRevealComplete() : true;
|
|
2151
|
+
}
|
|
2152
|
+
isRevealing() {
|
|
2153
|
+
return this.active ? this.active.isRevealing() : false;
|
|
2154
|
+
}
|
|
2155
|
+
setSpeedMultiplier(multiplier) {
|
|
2156
|
+
this.box.setSpeedMultiplier(multiplier);
|
|
2157
|
+
this.bubble.setSpeedMultiplier(multiplier);
|
|
2158
|
+
}
|
|
2159
|
+
update(dt) {
|
|
2160
|
+
this.box.update(dt);
|
|
2161
|
+
this.bubble.update(dt);
|
|
2162
|
+
}
|
|
2163
|
+
clear() {
|
|
2164
|
+
this.box.clear();
|
|
2165
|
+
this.bubble.clear();
|
|
2166
|
+
this.active = void 0;
|
|
2167
|
+
}
|
|
2168
|
+
dispose() {
|
|
2169
|
+
this.box.dispose();
|
|
2170
|
+
this.bubble.dispose();
|
|
2171
|
+
}
|
|
2172
|
+
};
|
|
2173
|
+
|
|
2174
|
+
// src/composite/CompositeChrome.ts
|
|
2175
|
+
var CompositeChrome = class {
|
|
2176
|
+
constructor(box, bubble, routing = makeDefaultRoute()) {
|
|
2177
|
+
this.box = box;
|
|
2178
|
+
this.bubble = bubble;
|
|
2179
|
+
this.routing = routing;
|
|
2180
|
+
}
|
|
2181
|
+
box;
|
|
2182
|
+
bubble;
|
|
2183
|
+
routing;
|
|
2184
|
+
static {
|
|
2185
|
+
__name(this, "CompositeChrome");
|
|
2186
|
+
}
|
|
2187
|
+
active;
|
|
2188
|
+
pendingName = {};
|
|
2189
|
+
pendingContinue = false;
|
|
2190
|
+
/** Master gate from the Session's setVisible — composed with the active
|
|
2191
|
+
* variant's own content state. Hidden at mount. */
|
|
2192
|
+
visible = false;
|
|
2193
|
+
mount(scene) {
|
|
2194
|
+
this.routing.bind(scene);
|
|
2195
|
+
this.box.mount(scene);
|
|
2196
|
+
this.bubble.mount(scene);
|
|
2197
|
+
this.box.setVisible(false);
|
|
2198
|
+
this.bubble.setVisible(false);
|
|
2199
|
+
}
|
|
2200
|
+
setDiagnostics(warn) {
|
|
2201
|
+
this.box.setDiagnostics?.(warn);
|
|
2202
|
+
this.bubble.setDiagnostics?.(warn);
|
|
2203
|
+
}
|
|
2204
|
+
setNameplate(name, color) {
|
|
2205
|
+
this.pendingName = { name, color };
|
|
2206
|
+
this.active?.setNameplate(name, color);
|
|
2207
|
+
}
|
|
2208
|
+
setContinueVisible(visible) {
|
|
2209
|
+
this.pendingContinue = visible;
|
|
2210
|
+
this.active?.setContinueVisible(visible);
|
|
2211
|
+
}
|
|
2212
|
+
present(line) {
|
|
2213
|
+
if (line === void 0) {
|
|
2214
|
+
this.active?.present?.(void 0);
|
|
2215
|
+
return;
|
|
2216
|
+
}
|
|
2217
|
+
const target = lineRoutesToBubble(this.routing.route, line) ? this.bubble : this.box;
|
|
2218
|
+
const other = target === this.box ? this.bubble : this.box;
|
|
2219
|
+
other.setVisible(false);
|
|
2220
|
+
this.active = target;
|
|
2221
|
+
target.setNameplate(this.pendingName.name, this.pendingName.color);
|
|
2222
|
+
target.setContinueVisible(this.pendingContinue);
|
|
2223
|
+
target.present?.(line);
|
|
2224
|
+
target.setVisible(this.visible);
|
|
2225
|
+
}
|
|
2226
|
+
/** Show/hide the chrome. On show, restore ONLY the active variant
|
|
2227
|
+
* and re-apply the buffered caret; the other stays hidden. On hide, hide both
|
|
2228
|
+
* but RETAIN `active` so the next show brings back the right one. */
|
|
2229
|
+
setVisible(visible) {
|
|
2230
|
+
this.visible = visible;
|
|
2231
|
+
if (visible) {
|
|
2232
|
+
if (this.active) {
|
|
2233
|
+
const other = this.active === this.box ? this.bubble : this.box;
|
|
2234
|
+
other.setVisible(false);
|
|
2235
|
+
this.active.setContinueVisible(this.pendingContinue);
|
|
2236
|
+
this.active.setVisible(true);
|
|
2237
|
+
}
|
|
2238
|
+
} else {
|
|
2239
|
+
this.box.setVisible(false);
|
|
2240
|
+
this.bubble.setVisible(false);
|
|
2241
|
+
}
|
|
2242
|
+
}
|
|
2243
|
+
update(dt) {
|
|
2244
|
+
this.box.update(dt);
|
|
2245
|
+
this.bubble.update(dt);
|
|
2246
|
+
}
|
|
2247
|
+
dispose() {
|
|
2248
|
+
this.box.dispose();
|
|
2249
|
+
this.bubble.dispose();
|
|
2250
|
+
}
|
|
2251
|
+
};
|
|
2252
|
+
|
|
2253
|
+
// src/composite/CompositeChoicePresenter.ts
|
|
2254
|
+
var CompositeChoicePresenter = class {
|
|
2255
|
+
constructor(box, bubble, routing = makeDefaultRoute()) {
|
|
2256
|
+
this.box = box;
|
|
2257
|
+
this.bubble = bubble;
|
|
2258
|
+
this.routing = routing;
|
|
2259
|
+
this.box.onChoiceChosen = (p) => this.onChoiceChosen?.(p);
|
|
2260
|
+
this.bubble.onChoiceChosen = (p) => this.onChoiceChosen?.(p);
|
|
2261
|
+
}
|
|
2262
|
+
box;
|
|
2263
|
+
bubble;
|
|
2264
|
+
routing;
|
|
2265
|
+
static {
|
|
2266
|
+
__name(this, "CompositeChoicePresenter");
|
|
2267
|
+
}
|
|
2268
|
+
active;
|
|
2269
|
+
/** Master visibility gate from the Session's setVisible. */
|
|
2270
|
+
visible = false;
|
|
2271
|
+
onChoiceChosen;
|
|
2272
|
+
/** The active list's pointer space (so the binding hit-tests correctly). */
|
|
2273
|
+
get pointerSpace() {
|
|
2274
|
+
return this.active?.pointerSpace ?? "screen";
|
|
2275
|
+
}
|
|
2276
|
+
setDiagnostics(warn) {
|
|
2277
|
+
this.box.setDiagnostics?.(warn);
|
|
2278
|
+
this.bubble.setDiagnostics?.(warn);
|
|
2279
|
+
}
|
|
2280
|
+
/** Routes to the variant this choice will use, so the Session knows whether
|
|
2281
|
+
* to suppress its chrome/body prompt before `present` picks the active one. */
|
|
2282
|
+
ownsPrompt(context) {
|
|
2283
|
+
const target = choiceRoutesToBubble(this.routing.route, context) ? this.bubble : this.box;
|
|
2284
|
+
return target.ownsPrompt?.(context) ?? false;
|
|
2285
|
+
}
|
|
2286
|
+
mount(scene) {
|
|
2287
|
+
this.routing.bind(scene);
|
|
2288
|
+
this.box.mount(scene);
|
|
2289
|
+
this.bubble.mount(scene);
|
|
2290
|
+
}
|
|
2291
|
+
present(choices, context) {
|
|
2292
|
+
const target = choiceRoutesToBubble(this.routing.route, context) ? this.bubble : this.box;
|
|
2293
|
+
const other = target === this.box ? this.bubble : this.box;
|
|
2294
|
+
other.clear();
|
|
2295
|
+
this.active = target;
|
|
2296
|
+
target.present(choices, context);
|
|
2297
|
+
target.setVisible(this.visible);
|
|
2298
|
+
}
|
|
2299
|
+
/** Show/hide the choices — forwarded to both (the inactive one is
|
|
2300
|
+
* cleared, so its setVisible is a no-op); state-preserving on the active. */
|
|
2301
|
+
setVisible(visible) {
|
|
2302
|
+
this.visible = visible;
|
|
2303
|
+
this.box.setVisible(visible);
|
|
2304
|
+
this.bubble.setVisible(visible);
|
|
2305
|
+
}
|
|
2306
|
+
highlight(position) {
|
|
2307
|
+
this.active?.highlight(position);
|
|
2308
|
+
}
|
|
2309
|
+
choiceAtPoint(x, y) {
|
|
2310
|
+
return this.active?.choiceAtPoint?.(x, y);
|
|
2311
|
+
}
|
|
2312
|
+
clear() {
|
|
2313
|
+
this.box.clear();
|
|
2314
|
+
this.bubble.clear();
|
|
2315
|
+
this.active = void 0;
|
|
2316
|
+
}
|
|
2317
|
+
dispose() {
|
|
2318
|
+
this.box.dispose();
|
|
2319
|
+
this.bubble.dispose();
|
|
2320
|
+
}
|
|
2321
|
+
};
|
|
2322
|
+
|
|
2323
|
+
// src/composite/CompositeAvatarPresenter.ts
|
|
2324
|
+
var CompositeAvatarPresenter = class {
|
|
2325
|
+
constructor(box, bubble, routing) {
|
|
2326
|
+
this.box = box;
|
|
2327
|
+
this.bubble = bubble;
|
|
2328
|
+
this.routing = routing;
|
|
2329
|
+
}
|
|
2330
|
+
box;
|
|
2331
|
+
bubble;
|
|
2332
|
+
routing;
|
|
2333
|
+
static {
|
|
2334
|
+
__name(this, "CompositeAvatarPresenter");
|
|
2335
|
+
}
|
|
2336
|
+
mount(scene) {
|
|
2337
|
+
this.routing.bind(scene);
|
|
2338
|
+
this.box.mount(scene);
|
|
2339
|
+
this.bubble.mount(scene);
|
|
2340
|
+
}
|
|
2341
|
+
setSpeaker(speaker) {
|
|
2342
|
+
this.box.setSpeaker(speaker);
|
|
2343
|
+
this.bubble.setSpeaker(speaker);
|
|
2344
|
+
}
|
|
2345
|
+
setExpression(expression) {
|
|
2346
|
+
this.box.setExpression(expression);
|
|
2347
|
+
this.bubble.setExpression(expression);
|
|
2348
|
+
}
|
|
2349
|
+
/** Forward an inline reveal marker to both (cheap + idempotent, like
|
|
2350
|
+
* setExpression — the inactive side's setExpression no-ops with no speaker). */
|
|
2351
|
+
marker(marker) {
|
|
2352
|
+
this.box.marker?.(marker);
|
|
2353
|
+
this.bubble.marker?.(marker);
|
|
2354
|
+
}
|
|
2355
|
+
setSpeaking(speaking) {
|
|
2356
|
+
this.box.setSpeaking(speaking);
|
|
2357
|
+
this.bubble.setSpeaking(speaking);
|
|
2358
|
+
}
|
|
2359
|
+
present(line) {
|
|
2360
|
+
if (line === void 0) {
|
|
2361
|
+
this.box.present?.(void 0);
|
|
2362
|
+
this.bubble.present?.(void 0);
|
|
2363
|
+
return;
|
|
2364
|
+
}
|
|
2365
|
+
const toBubble = lineRoutesToBubble(this.routing.route, line);
|
|
2366
|
+
const active = toBubble ? this.bubble : this.box;
|
|
2367
|
+
const other = toBubble ? this.box : this.bubble;
|
|
2368
|
+
other.present?.(void 0);
|
|
2369
|
+
active.present?.(line);
|
|
2370
|
+
}
|
|
2371
|
+
setVisible(visible) {
|
|
2372
|
+
this.box.setVisible?.(visible);
|
|
2373
|
+
this.bubble.setVisible?.(visible);
|
|
2374
|
+
}
|
|
2375
|
+
update(dt) {
|
|
2376
|
+
this.box.update(dt);
|
|
2377
|
+
this.bubble.update(dt);
|
|
2378
|
+
}
|
|
2379
|
+
dispose() {
|
|
2380
|
+
this.box.dispose();
|
|
2381
|
+
this.bubble.dispose();
|
|
2382
|
+
}
|
|
2383
|
+
};
|
|
2384
|
+
|
|
2385
|
+
// src/avatar/AvatarPresenter.ts
|
|
2386
|
+
function applyExpressionMarker(avatar, marker) {
|
|
2387
|
+
if (marker.name === "expression") avatar.setExpression(marker.props["expression"]);
|
|
2388
|
+
}
|
|
2389
|
+
__name(applyExpressionMarker, "applyExpressionMarker");
|
|
2390
|
+
var NullAvatarPresenter = class {
|
|
2391
|
+
static {
|
|
2392
|
+
__name(this, "NullAvatarPresenter");
|
|
2393
|
+
}
|
|
2394
|
+
mount() {
|
|
2395
|
+
}
|
|
2396
|
+
setSpeaker() {
|
|
2397
|
+
}
|
|
2398
|
+
setExpression() {
|
|
2399
|
+
}
|
|
2400
|
+
setSpeaking() {
|
|
2401
|
+
}
|
|
2402
|
+
update() {
|
|
2403
|
+
}
|
|
2404
|
+
dispose() {
|
|
2405
|
+
}
|
|
2406
|
+
};
|
|
2407
|
+
|
|
2408
|
+
// src/avatar/PortraitPresenter.ts
|
|
2409
|
+
var import_core8 = require("@yagejs/core");
|
|
2410
|
+
var import_renderer9 = require("@yagejs/renderer");
|
|
2411
|
+
var PortraitPresenter = class {
|
|
2412
|
+
constructor(cfg) {
|
|
2413
|
+
this.cfg = cfg;
|
|
2414
|
+
}
|
|
2415
|
+
cfg;
|
|
2416
|
+
static {
|
|
2417
|
+
__name(this, "PortraitPresenter");
|
|
2418
|
+
}
|
|
2419
|
+
// Explicit `| undefined` (not `?`) so reassigning `undefined` in dispose() /
|
|
2420
|
+
// setSpeaker() is legal under the repo's exactOptionalPropertyTypes.
|
|
2421
|
+
scene;
|
|
2422
|
+
entity;
|
|
2423
|
+
sprite;
|
|
2424
|
+
transform;
|
|
2425
|
+
current;
|
|
2426
|
+
speaking = false;
|
|
2427
|
+
bobMs = 0;
|
|
2428
|
+
baseX = 0;
|
|
2429
|
+
baseY = 0;
|
|
2430
|
+
/** Host-hidden gate — a cutscene hides the portrait with the rest of the
|
|
2431
|
+
* UI. Composes with "is a portrait speaker active": shown = current && !hidden. */
|
|
2432
|
+
hidden = false;
|
|
2433
|
+
handles = /* @__PURE__ */ new Map();
|
|
2434
|
+
mount(scene) {
|
|
2435
|
+
this.scene = scene;
|
|
2436
|
+
}
|
|
2437
|
+
setSpeaker(speaker) {
|
|
2438
|
+
const av = speaker?.avatar;
|
|
2439
|
+
if (!av || av.kind !== "portrait") {
|
|
2440
|
+
this.hide();
|
|
2441
|
+
this.current = void 0;
|
|
2442
|
+
return;
|
|
2443
|
+
}
|
|
2444
|
+
this.current = av;
|
|
2445
|
+
this.ensureSprite(av.ref);
|
|
2446
|
+
this.baseX = av.side === "right" ? this.cfg.rightX : this.cfg.leftX;
|
|
2447
|
+
this.baseY = this.cfg.y;
|
|
2448
|
+
this.transform?.setPosition(this.baseX, this.baseY);
|
|
2449
|
+
this.applyTexture(av.ref);
|
|
2450
|
+
this.applyVisibility();
|
|
2451
|
+
}
|
|
2452
|
+
/** Host-hidden gate — hide the portrait during a cutscene, restore on
|
|
2453
|
+
* show. Composes with the active-speaker state, so showing again only
|
|
2454
|
+
* re-reveals the portrait if a portrait speaker is still current. */
|
|
2455
|
+
setVisible(visible) {
|
|
2456
|
+
this.hidden = !visible;
|
|
2457
|
+
this.applyVisibility();
|
|
2458
|
+
}
|
|
2459
|
+
applyVisibility() {
|
|
2460
|
+
if (this.sprite) {
|
|
2461
|
+
this.sprite.sprite.visible = this.current !== void 0 && !this.hidden;
|
|
2462
|
+
}
|
|
2463
|
+
}
|
|
2464
|
+
setExpression(expression) {
|
|
2465
|
+
if (!this.current) return;
|
|
2466
|
+
const variant = expression ? this.current.expressions?.[expression] : void 0;
|
|
2467
|
+
this.applyTexture(variant ?? this.current.ref);
|
|
2468
|
+
}
|
|
2469
|
+
/** Interpret a mid-line `[expression=…/]` reveal marker as a face swap (the
|
|
2470
|
+
* Session name-matches nothing — the presenter owns the convention). */
|
|
2471
|
+
marker(marker) {
|
|
2472
|
+
applyExpressionMarker(this, marker);
|
|
2473
|
+
}
|
|
2474
|
+
setSpeaking(speaking) {
|
|
2475
|
+
this.speaking = speaking;
|
|
2476
|
+
if (!speaking) {
|
|
2477
|
+
this.bobMs = 0;
|
|
2478
|
+
this.transform?.setPosition(this.baseX, this.baseY);
|
|
2479
|
+
}
|
|
2480
|
+
}
|
|
2481
|
+
update(dt) {
|
|
2482
|
+
if (!this.speaking || !this.transform) return;
|
|
2483
|
+
this.bobMs += dt;
|
|
2484
|
+
this.transform.setPosition(this.baseX, this.baseY + Math.sin(this.bobMs / 110) * 1.5);
|
|
2485
|
+
}
|
|
2486
|
+
dispose() {
|
|
2487
|
+
this.entity?.destroy();
|
|
2488
|
+
this.entity = void 0;
|
|
2489
|
+
this.sprite = void 0;
|
|
2490
|
+
this.transform = void 0;
|
|
2491
|
+
}
|
|
2492
|
+
ensureSprite(initialPath) {
|
|
2493
|
+
if (this.sprite || !this.scene) return;
|
|
2494
|
+
const entity = this.scene.spawn("dlg-portrait");
|
|
2495
|
+
this.transform = entity.add(new import_core8.Transform());
|
|
2496
|
+
this.transform.setScale(this.cfg.scale, this.cfg.scale);
|
|
2497
|
+
this.sprite = entity.add(
|
|
2498
|
+
new import_renderer9.SpriteComponent({
|
|
2499
|
+
texture: this.handle(initialPath),
|
|
2500
|
+
layer: this.cfg.layer,
|
|
2501
|
+
anchor: { x: 0.5, y: 0.5 },
|
|
2502
|
+
visible: false
|
|
2503
|
+
})
|
|
2504
|
+
);
|
|
2505
|
+
this.entity = entity;
|
|
2506
|
+
}
|
|
2507
|
+
applyTexture(path) {
|
|
2508
|
+
this.sprite?.setTexture(this.handle(path));
|
|
2509
|
+
}
|
|
2510
|
+
handle(path) {
|
|
2511
|
+
let h = this.handles.get(path);
|
|
2512
|
+
if (!h) {
|
|
2513
|
+
h = (0, import_renderer9.texture)(path);
|
|
2514
|
+
this.handles.set(path, h);
|
|
2515
|
+
}
|
|
2516
|
+
return h;
|
|
2517
|
+
}
|
|
2518
|
+
hide() {
|
|
2519
|
+
if (this.sprite) this.sprite.sprite.visible = false;
|
|
2520
|
+
}
|
|
2521
|
+
};
|
|
2522
|
+
|
|
2523
|
+
// src/avatar/SceneFigurePresenter.ts
|
|
2524
|
+
var import_core9 = require("@yagejs/core");
|
|
2525
|
+
var SceneFigurePresenter = class {
|
|
2526
|
+
constructor(cfg = {}) {
|
|
2527
|
+
this.cfg = cfg;
|
|
2528
|
+
}
|
|
2529
|
+
cfg;
|
|
2530
|
+
static {
|
|
2531
|
+
__name(this, "SceneFigurePresenter");
|
|
2532
|
+
}
|
|
2533
|
+
// Explicit `| undefined` (not `?`) so reassigning `undefined` in dispose() /
|
|
2534
|
+
// setSpeaker() is legal under the repo's exactOptionalPropertyTypes.
|
|
2535
|
+
scene;
|
|
2536
|
+
figure;
|
|
2537
|
+
actor;
|
|
2538
|
+
transform;
|
|
2539
|
+
speaking = false;
|
|
2540
|
+
bobMs = 0;
|
|
2541
|
+
/** Bob displacement currently applied to the figure's Transform. The bob is
|
|
2542
|
+
* a *relative* offset (delta-translated each frame), so external movement —
|
|
2543
|
+
* an NPC walking mid-line — is preserved instead of being pinned back to a
|
|
2544
|
+
* position captured at setSpeaker time. */
|
|
2545
|
+
bobOffset = 0;
|
|
2546
|
+
mount(scene) {
|
|
2547
|
+
this.scene = scene;
|
|
2548
|
+
}
|
|
2549
|
+
setSpeaker(speaker) {
|
|
2550
|
+
this.releaseBob();
|
|
2551
|
+
const av = speaker?.avatar;
|
|
2552
|
+
if (!av || av.kind !== "scene" || !this.scene) {
|
|
2553
|
+
this.figure = void 0;
|
|
2554
|
+
this.actor = void 0;
|
|
2555
|
+
this.transform = void 0;
|
|
2556
|
+
return;
|
|
2557
|
+
}
|
|
2558
|
+
this.actor = actorRegistryFor(this.scene).resolve(speaker?.id);
|
|
2559
|
+
this.figure = this.actor?.entity ?? this.scene.findEntity(av.ref);
|
|
2560
|
+
this.transform = this.figure?.tryGet(import_core9.Transform);
|
|
2561
|
+
}
|
|
2562
|
+
setExpression(expression) {
|
|
2563
|
+
if (this.actor) this.actor.setExpression(expression);
|
|
2564
|
+
else if (this.figure) this.cfg.onExpression?.(this.figure, expression);
|
|
2565
|
+
}
|
|
2566
|
+
/** Mid-line `[expression=…/]` reveal marker → the figure's own expression
|
|
2567
|
+
* (actor or the `onExpression` callback). The Session name-matches nothing. */
|
|
2568
|
+
marker(marker) {
|
|
2569
|
+
applyExpressionMarker(this, marker);
|
|
2570
|
+
}
|
|
2571
|
+
setSpeaking(speaking) {
|
|
2572
|
+
this.speaking = speaking;
|
|
2573
|
+
if (this.actor) this.actor.setSpeaking(speaking);
|
|
2574
|
+
else if (this.figure) this.cfg.onSpeaking?.(this.figure, speaking);
|
|
2575
|
+
if (!speaking) this.releaseBob();
|
|
2576
|
+
}
|
|
2577
|
+
update(dt) {
|
|
2578
|
+
if (!this.speaking || !this.transform || this.cfg.bob === false) return;
|
|
2579
|
+
this.bobMs += dt;
|
|
2580
|
+
const next = Math.sin(this.bobMs / 130) * 1.2;
|
|
2581
|
+
this.transform.translate(0, next - this.bobOffset);
|
|
2582
|
+
this.bobOffset = next;
|
|
2583
|
+
}
|
|
2584
|
+
dispose() {
|
|
2585
|
+
this.releaseBob();
|
|
2586
|
+
this.figure = void 0;
|
|
2587
|
+
this.transform = void 0;
|
|
2588
|
+
}
|
|
2589
|
+
/** Remove only the residual bob displacement — never teleport to a captured
|
|
2590
|
+
* base, which would undo legitimate movement since speaking began. */
|
|
2591
|
+
releaseBob() {
|
|
2592
|
+
if (this.transform && this.bobOffset !== 0) {
|
|
2593
|
+
this.transform.translate(0, -this.bobOffset);
|
|
2594
|
+
}
|
|
2595
|
+
this.bobOffset = 0;
|
|
2596
|
+
this.bobMs = 0;
|
|
2597
|
+
}
|
|
2598
|
+
};
|
|
2599
|
+
|
|
2600
|
+
// src/avatar/InBoxAvatarPresenter.ts
|
|
2601
|
+
var import_core10 = require("@yagejs/core");
|
|
2602
|
+
var import_renderer10 = require("@yagejs/renderer");
|
|
2603
|
+
var nextId = 0;
|
|
2604
|
+
var InBoxAvatarPresenter = class {
|
|
2605
|
+
constructor(layout, cfg) {
|
|
2606
|
+
this.layout = layout;
|
|
2607
|
+
this.cfg = cfg;
|
|
2608
|
+
this.layout.onChange(() => this.place());
|
|
2609
|
+
}
|
|
2610
|
+
layout;
|
|
2611
|
+
cfg;
|
|
2612
|
+
static {
|
|
2613
|
+
__name(this, "InBoxAvatarPresenter");
|
|
2614
|
+
}
|
|
2615
|
+
insetKey = `avatar:${nextId++}`;
|
|
2616
|
+
// Explicit `| undefined` (not `?`) so reassigning `undefined` is legal under
|
|
2617
|
+
// the repo's exactOptionalPropertyTypes.
|
|
2618
|
+
scene;
|
|
2619
|
+
entity;
|
|
2620
|
+
sprite;
|
|
2621
|
+
transform;
|
|
2622
|
+
/** Optional background panel (behind the portrait), its own entity so it draws
|
|
2623
|
+
* under the sprite on the same layer. */
|
|
2624
|
+
bgEntity;
|
|
2625
|
+
bg;
|
|
2626
|
+
bgTransform;
|
|
2627
|
+
side = "left";
|
|
2628
|
+
/** A portrait is up for the current line (from `meta.portrait` + presence). */
|
|
2629
|
+
shown = false;
|
|
2630
|
+
/** Host-hidden gate (a cutscene hides the avatar with the rest of the UI). */
|
|
2631
|
+
hidden = false;
|
|
2632
|
+
handles = /* @__PURE__ */ new Map();
|
|
2633
|
+
mount(scene) {
|
|
2634
|
+
this.scene = scene;
|
|
2635
|
+
}
|
|
2636
|
+
// Image/side/presence are line-driven via `present`, not the speaker def — so
|
|
2637
|
+
// setSpeaker / setExpression / setSpeaking are intentionally inert here.
|
|
2638
|
+
setSpeaker() {
|
|
2639
|
+
}
|
|
2640
|
+
setExpression() {
|
|
2641
|
+
}
|
|
2642
|
+
setSpeaking() {
|
|
2643
|
+
}
|
|
2644
|
+
/** Routes a mid-line `[expression=…/]` marker to its own setExpression (inert
|
|
2645
|
+
* here — this avatar is portrait-by-`meta`, not expression-mapped — so it's
|
|
2646
|
+
* the uniform contract, not a visible face swap). */
|
|
2647
|
+
marker(marker) {
|
|
2648
|
+
applyExpressionMarker(this, marker);
|
|
2649
|
+
}
|
|
2650
|
+
/** Read the line's `meta` to show/hide the portrait and reserve (or clear) the
|
|
2651
|
+
* text-reflow inset. Called before the body text presents, so the text wraps
|
|
2652
|
+
* to the narrowed region. */
|
|
2653
|
+
present(line) {
|
|
2654
|
+
const meta = line?.meta;
|
|
2655
|
+
const portrait = typeof meta?.["portrait"] === "string" ? meta["portrait"] : void 0;
|
|
2656
|
+
const visible = portrait !== void 0 && meta?.["presence"] !== false;
|
|
2657
|
+
this.side = meta?.["side"] === "right" ? "right" : "left";
|
|
2658
|
+
if (visible && portrait !== void 0) {
|
|
2659
|
+
this.ensureSprite(portrait);
|
|
2660
|
+
this.applyTexture(portrait);
|
|
2661
|
+
this.shown = true;
|
|
2662
|
+
this.layout.setInset(this.insetKey, { side: this.side, width: this.cfg.width + (this.cfg.gap ?? 8) });
|
|
2663
|
+
} else {
|
|
2664
|
+
this.shown = false;
|
|
2665
|
+
this.layout.setInset(this.insetKey, void 0);
|
|
2666
|
+
}
|
|
2667
|
+
this.place();
|
|
2668
|
+
this.applyVisibility();
|
|
2669
|
+
}
|
|
2670
|
+
/** Host-hidden gate — hide with a cutscene, restore on show (only if a
|
|
2671
|
+
* portrait is still current). */
|
|
2672
|
+
setVisible(visible) {
|
|
2673
|
+
this.hidden = !visible;
|
|
2674
|
+
this.applyVisibility();
|
|
2675
|
+
}
|
|
2676
|
+
update() {
|
|
2677
|
+
}
|
|
2678
|
+
dispose() {
|
|
2679
|
+
this.layout.setInset(this.insetKey, void 0);
|
|
2680
|
+
this.entity?.destroy();
|
|
2681
|
+
this.bgEntity?.destroy();
|
|
2682
|
+
this.entity = void 0;
|
|
2683
|
+
this.bgEntity = void 0;
|
|
2684
|
+
this.sprite = void 0;
|
|
2685
|
+
this.bg = void 0;
|
|
2686
|
+
this.transform = void 0;
|
|
2687
|
+
this.bgTransform = void 0;
|
|
2688
|
+
}
|
|
2689
|
+
/** Centre the portrait (+ its panel) in its reserved column, inset by the box
|
|
2690
|
+
* padding so it sits inside the border like the text — at the frame's current
|
|
2691
|
+
* rect, so it follows `meta.position` and a grown choice panel. */
|
|
2692
|
+
place() {
|
|
2693
|
+
if (!this.transform) return;
|
|
2694
|
+
const frame = this.layout.frameRect();
|
|
2695
|
+
const pad = this.layout.padding();
|
|
2696
|
+
const half = this.cfg.width / 2;
|
|
2697
|
+
const x = this.side === "left" ? frame.x + pad + half : frame.x + frame.width - pad - half;
|
|
2698
|
+
const y = this.cfg.align === "top" ? this.layout.textRegion().y + half : frame.y + frame.height / 2;
|
|
2699
|
+
this.transform.setPosition(x, y);
|
|
2700
|
+
this.bgTransform?.setPosition(x, y);
|
|
2701
|
+
}
|
|
2702
|
+
applyVisibility() {
|
|
2703
|
+
const shown = this.shown && !this.hidden;
|
|
2704
|
+
if (this.sprite) this.sprite.sprite.visible = shown;
|
|
2705
|
+
if (this.bg) this.bg.graphics.visible = shown;
|
|
2706
|
+
}
|
|
2707
|
+
ensureSprite(initialKey) {
|
|
2708
|
+
if (this.sprite || !this.scene) return;
|
|
2709
|
+
const bgCfg = this.cfg.background;
|
|
2710
|
+
if (bgCfg) {
|
|
2711
|
+
const bgEntity = this.scene.spawn("dlg-inbox-avatar-bg");
|
|
2712
|
+
this.bgTransform = bgEntity.add(new import_core10.Transform());
|
|
2713
|
+
const w = this.cfg.width;
|
|
2714
|
+
const bg = bgEntity.add(new import_renderer10.GraphicsComponent({ layer: this.cfg.layer }));
|
|
2715
|
+
bg.draw(
|
|
2716
|
+
(g) => g.roundRect(-w / 2, -w / 2, w, w, bgCfg.radius ?? 8).fill({ color: bgCfg.color, alpha: bgCfg.alpha ?? 1 })
|
|
2717
|
+
);
|
|
2718
|
+
bg.graphics.visible = false;
|
|
2719
|
+
this.bg = bg;
|
|
2720
|
+
this.bgEntity = bgEntity;
|
|
2721
|
+
}
|
|
2722
|
+
const entity = this.scene.spawn("dlg-inbox-avatar");
|
|
2723
|
+
this.transform = entity.add(new import_core10.Transform());
|
|
2724
|
+
const scale = this.cfg.scale ?? 1;
|
|
2725
|
+
this.transform.setScale(scale, scale);
|
|
2726
|
+
this.sprite = entity.add(
|
|
2727
|
+
new import_renderer10.SpriteComponent({
|
|
2728
|
+
texture: this.handle(initialKey),
|
|
2729
|
+
layer: this.cfg.layer,
|
|
2730
|
+
anchor: { x: 0.5, y: 0.5 },
|
|
2731
|
+
visible: false
|
|
2732
|
+
})
|
|
2733
|
+
);
|
|
2734
|
+
this.entity = entity;
|
|
2735
|
+
}
|
|
2736
|
+
applyTexture(key) {
|
|
2737
|
+
this.sprite?.setTexture(this.handle(key));
|
|
2738
|
+
}
|
|
2739
|
+
handle(key) {
|
|
2740
|
+
let h = this.handles.get(key);
|
|
2741
|
+
if (!h) {
|
|
2742
|
+
h = (0, import_renderer10.texture)(key);
|
|
2743
|
+
this.handles.set(key, h);
|
|
2744
|
+
}
|
|
2745
|
+
return h;
|
|
2746
|
+
}
|
|
2747
|
+
};
|
|
2748
|
+
|
|
2749
|
+
// src/avatar/BubbleAvatarPresenter.ts
|
|
2750
|
+
var import_core11 = require("@yagejs/core");
|
|
2751
|
+
var import_renderer11 = require("@yagejs/renderer");
|
|
2752
|
+
var BubbleAvatarPresenter = class {
|
|
2753
|
+
constructor(layout, cfg) {
|
|
2754
|
+
this.layout = layout;
|
|
2755
|
+
this.cfg = cfg;
|
|
2756
|
+
this.layout.onChange(() => this.follow());
|
|
2757
|
+
}
|
|
2758
|
+
layout;
|
|
2759
|
+
cfg;
|
|
2760
|
+
static {
|
|
2761
|
+
__name(this, "BubbleAvatarPresenter");
|
|
2762
|
+
}
|
|
2763
|
+
// Explicit `| undefined` (not `?`) for exactOptionalPropertyTypes reassignment.
|
|
2764
|
+
scene;
|
|
2765
|
+
entity;
|
|
2766
|
+
sprite;
|
|
2767
|
+
transform;
|
|
2768
|
+
bgEntity;
|
|
2769
|
+
bg;
|
|
2770
|
+
bgTransform;
|
|
2771
|
+
side = "left";
|
|
2772
|
+
speakerId;
|
|
2773
|
+
shown = false;
|
|
2774
|
+
hidden = false;
|
|
2775
|
+
handles = /* @__PURE__ */ new Map();
|
|
2776
|
+
mount(scene) {
|
|
2777
|
+
this.scene = scene;
|
|
2778
|
+
}
|
|
2779
|
+
// Line-driven (via present), so the speaker-def hooks are inert here.
|
|
2780
|
+
setSpeaker() {
|
|
2781
|
+
}
|
|
2782
|
+
setExpression() {
|
|
2783
|
+
}
|
|
2784
|
+
setSpeaking() {
|
|
2785
|
+
}
|
|
2786
|
+
/** Uniform marker contract: routes `[expression=…/]` to its own setExpression
|
|
2787
|
+
* (inert here — this avatar is portrait-by-`meta`). */
|
|
2788
|
+
marker(marker) {
|
|
2789
|
+
applyExpressionMarker(this, marker);
|
|
2790
|
+
}
|
|
2791
|
+
present(line) {
|
|
2792
|
+
const meta = line?.meta;
|
|
2793
|
+
const portrait = typeof meta?.["portrait"] === "string" ? meta["portrait"] : void 0;
|
|
2794
|
+
const visible = portrait !== void 0 && meta?.["presence"] !== false;
|
|
2795
|
+
this.side = meta?.["side"] === "right" ? "right" : "left";
|
|
2796
|
+
this.speakerId = line?.speaker?.id;
|
|
2797
|
+
if (visible && portrait !== void 0 && line) {
|
|
2798
|
+
const gap = this.cfg.gap ?? 8;
|
|
2799
|
+
this.layout.setPortraitInset({ side: this.side, width: this.cfg.size + gap, height: this.cfg.size });
|
|
2800
|
+
this.ensureSprite(portrait);
|
|
2801
|
+
this.applyTexture(portrait);
|
|
2802
|
+
this.shown = true;
|
|
2803
|
+
} else {
|
|
2804
|
+
this.layout.setPortraitInset(void 0);
|
|
2805
|
+
this.shown = false;
|
|
2806
|
+
}
|
|
2807
|
+
this.follow();
|
|
2808
|
+
this.applyVisibility();
|
|
2809
|
+
}
|
|
2810
|
+
setVisible(visible) {
|
|
2811
|
+
this.hidden = !visible;
|
|
2812
|
+
this.applyVisibility();
|
|
2813
|
+
}
|
|
2814
|
+
update() {
|
|
2815
|
+
this.follow();
|
|
2816
|
+
}
|
|
2817
|
+
dispose() {
|
|
2818
|
+
this.entity?.destroy();
|
|
2819
|
+
this.bgEntity?.destroy();
|
|
2820
|
+
this.entity = void 0;
|
|
2821
|
+
this.bgEntity = void 0;
|
|
2822
|
+
this.sprite = void 0;
|
|
2823
|
+
this.bg = void 0;
|
|
2824
|
+
this.transform = void 0;
|
|
2825
|
+
this.bgTransform = void 0;
|
|
2826
|
+
}
|
|
2827
|
+
/** Place the portrait in its reserved column INSIDE the active bubble (say
|
|
2828
|
+
* bubble or choice panel), vertically centred on the body, tracking the
|
|
2829
|
+
* speaker's (moving) anchor. */
|
|
2830
|
+
follow() {
|
|
2831
|
+
const size = this.layout.activeSize();
|
|
2832
|
+
if (!this.transform || !this.scene || !size || !this.shown) return;
|
|
2833
|
+
const a = this.layout.anchorFor(this.scene, this.speakerId);
|
|
2834
|
+
const pad = this.layout.padding;
|
|
2835
|
+
const half = this.cfg.size / 2;
|
|
2836
|
+
const x = this.side === "left" ? a.x - size.width / 2 + pad + half : a.x + size.width / 2 - pad - half;
|
|
2837
|
+
const bodyTop = a.y - this.layout.offsetY - size.height;
|
|
2838
|
+
const y = this.cfg.align === "top" ? bodyTop + pad + half : a.y - this.layout.offsetY - size.height / 2;
|
|
2839
|
+
this.transform.setPosition(x, y);
|
|
2840
|
+
this.bgTransform?.setPosition(x, y);
|
|
2841
|
+
}
|
|
2842
|
+
applyVisibility() {
|
|
2843
|
+
const shown = this.shown && !this.hidden;
|
|
2844
|
+
if (this.sprite) this.sprite.sprite.visible = shown;
|
|
2845
|
+
if (this.bg) this.bg.graphics.visible = shown;
|
|
2846
|
+
}
|
|
2847
|
+
ensureSprite(initialKey) {
|
|
2848
|
+
if (this.sprite || !this.scene) return;
|
|
2849
|
+
const bgCfg = this.cfg.background;
|
|
2850
|
+
if (bgCfg) {
|
|
2851
|
+
const bgEntity = this.scene.spawn("dlg-bubble-avatar-bg");
|
|
2852
|
+
this.bgTransform = bgEntity.add(new import_core11.Transform());
|
|
2853
|
+
const w = this.cfg.size;
|
|
2854
|
+
const bg = bgEntity.add(new import_renderer11.GraphicsComponent({ layer: this.cfg.layer }));
|
|
2855
|
+
bg.draw(
|
|
2856
|
+
(g) => g.roundRect(-w / 2, -w / 2, w, w, bgCfg.radius ?? 8).fill({ color: bgCfg.color, alpha: bgCfg.alpha ?? 1 })
|
|
2857
|
+
);
|
|
2858
|
+
bg.graphics.visible = false;
|
|
2859
|
+
this.bg = bg;
|
|
2860
|
+
this.bgEntity = bgEntity;
|
|
2861
|
+
}
|
|
2862
|
+
const entity = this.scene.spawn("dlg-bubble-avatar");
|
|
2863
|
+
this.transform = entity.add(new import_core11.Transform());
|
|
2864
|
+
const scale = this.cfg.scale ?? 1;
|
|
2865
|
+
this.transform.setScale(scale, scale);
|
|
2866
|
+
this.sprite = entity.add(
|
|
2867
|
+
new import_renderer11.SpriteComponent({
|
|
2868
|
+
texture: this.handle(initialKey),
|
|
2869
|
+
layer: this.cfg.layer,
|
|
2870
|
+
anchor: { x: 0.5, y: 0.5 },
|
|
2871
|
+
visible: false
|
|
2872
|
+
})
|
|
2873
|
+
);
|
|
2874
|
+
this.entity = entity;
|
|
2875
|
+
}
|
|
2876
|
+
applyTexture(key) {
|
|
2877
|
+
this.sprite?.setTexture(this.handle(key));
|
|
2878
|
+
}
|
|
2879
|
+
handle(key) {
|
|
2880
|
+
let h = this.handles.get(key);
|
|
2881
|
+
if (!h) {
|
|
2882
|
+
h = (0, import_renderer11.texture)(key);
|
|
2883
|
+
this.handles.set(key, h);
|
|
2884
|
+
}
|
|
2885
|
+
return h;
|
|
2886
|
+
}
|
|
2887
|
+
};
|
|
2888
|
+
|
|
2889
|
+
// src/factory/defaultTheme.ts
|
|
2890
|
+
function defaultTheme() {
|
|
2891
|
+
return {
|
|
2892
|
+
// Viewport-relative: a full-width bottom bar resolved against the renderer's
|
|
2893
|
+
// design size at mount, so this works at any resolution with no override.
|
|
2894
|
+
box: { marginX: 32, marginY: 24, height: 160 },
|
|
2895
|
+
padding: 16,
|
|
2896
|
+
frameColor: 1710638,
|
|
2897
|
+
frameAlpha: 0.92,
|
|
2898
|
+
borderColor: 4868746,
|
|
2899
|
+
cornerRadius: 8,
|
|
2900
|
+
nameColor: 16767078,
|
|
2901
|
+
nameSize: 16,
|
|
2902
|
+
indicatorColor: 16777215,
|
|
2903
|
+
textSize: 18,
|
|
2904
|
+
lineHeight: 24,
|
|
2905
|
+
textColor: 15790320,
|
|
2906
|
+
charsPerSec: 45,
|
|
2907
|
+
choiceSize: 16,
|
|
2908
|
+
choiceColor: 11184810,
|
|
2909
|
+
choiceSelectedColor: 16777215,
|
|
2910
|
+
highlightColor: 4868746,
|
|
2911
|
+
// No bitmapFont → canvas SplitText/Text path (zero assets).
|
|
2912
|
+
fontFamily: "sans-serif",
|
|
2913
|
+
layerFrame: DIALOGUE_LAYER_FRAME,
|
|
2914
|
+
layerText: DIALOGUE_LAYER_TEXT
|
|
2915
|
+
};
|
|
2916
|
+
}
|
|
2917
|
+
__name(defaultTheme, "defaultTheme");
|
|
2918
|
+
|
|
2919
|
+
// src/factory/themeFonts.ts
|
|
2920
|
+
function themeFonts(theme) {
|
|
2921
|
+
return {
|
|
2922
|
+
bitmapFont: theme.bitmapFont,
|
|
2923
|
+
fontFamily: theme.fontFamily,
|
|
2924
|
+
resolution: theme.resolution
|
|
2925
|
+
};
|
|
2926
|
+
}
|
|
2927
|
+
__name(themeFonts, "themeFonts");
|
|
2928
|
+
|
|
2929
|
+
// src/factory/createBoxDialogue.ts
|
|
2930
|
+
function createBoxDialogue(theme = defaultTheme(), opts = {}) {
|
|
2931
|
+
const fonts = themeFonts(theme);
|
|
2932
|
+
const layout = new BoxLayout({
|
|
2933
|
+
box: theme.box,
|
|
2934
|
+
padding: theme.padding,
|
|
2935
|
+
nameSize: theme.nameSize,
|
|
2936
|
+
textSize: theme.textSize,
|
|
2937
|
+
lineHeight: theme.lineHeight,
|
|
2938
|
+
choiceGap: theme.choiceGap ?? DEFAULT_CHOICE_GAP,
|
|
2939
|
+
...fonts
|
|
2940
|
+
});
|
|
2941
|
+
const chrome = new DialogueChrome(
|
|
2942
|
+
{
|
|
2943
|
+
frameColor: theme.frameColor,
|
|
2944
|
+
frameAlpha: theme.frameAlpha,
|
|
2945
|
+
borderColor: theme.borderColor,
|
|
2946
|
+
cornerRadius: theme.cornerRadius,
|
|
2947
|
+
nameColor: theme.nameColor,
|
|
2948
|
+
nameSize: theme.nameSize,
|
|
2949
|
+
indicatorColor: theme.indicatorColor,
|
|
2950
|
+
caret: theme.caret,
|
|
2951
|
+
frameStyles: boxFrameStyles(theme.textured),
|
|
2952
|
+
layerFrame: theme.layerFrame,
|
|
2953
|
+
layerText: theme.layerText,
|
|
2954
|
+
...fonts
|
|
2955
|
+
},
|
|
2956
|
+
layout
|
|
2957
|
+
);
|
|
2958
|
+
const choices = new ChoiceListPresenter(
|
|
2959
|
+
{
|
|
2960
|
+
choiceSize: theme.choiceSize,
|
|
2961
|
+
choiceColor: theme.choiceColor,
|
|
2962
|
+
choiceSelectedColor: theme.choiceSelectedColor,
|
|
2963
|
+
highlightColor: theme.highlightColor,
|
|
2964
|
+
choiceGap: theme.choiceGap,
|
|
2965
|
+
layerFrame: theme.layerFrame,
|
|
2966
|
+
layerText: theme.layerText,
|
|
2967
|
+
...fonts
|
|
2968
|
+
},
|
|
2969
|
+
layout
|
|
2970
|
+
);
|
|
2971
|
+
const text = new BoxTextView(
|
|
2972
|
+
{
|
|
2973
|
+
textSize: theme.textSize,
|
|
2974
|
+
lineHeight: theme.lineHeight,
|
|
2975
|
+
textColor: theme.textColor,
|
|
2976
|
+
charsPerSec: theme.charsPerSec,
|
|
2977
|
+
layer: theme.layerText,
|
|
2978
|
+
...fonts
|
|
2979
|
+
},
|
|
2980
|
+
layout
|
|
2981
|
+
);
|
|
2982
|
+
const avatar = opts.avatar?.(layout);
|
|
2983
|
+
return {
|
|
2984
|
+
chrome,
|
|
2985
|
+
text,
|
|
2986
|
+
choices,
|
|
2987
|
+
...avatar ? { avatar } : {},
|
|
2988
|
+
skipMultiplier: theme.skipMultiplier
|
|
2989
|
+
};
|
|
2990
|
+
}
|
|
2991
|
+
__name(createBoxDialogue, "createBoxDialogue");
|
|
2992
|
+
|
|
2993
|
+
// src/factory/createBubbleDialogue.ts
|
|
2994
|
+
var DEFAULT_BUBBLE = {
|
|
2995
|
+
minWidth: 90,
|
|
2996
|
+
maxWidth: 260,
|
|
2997
|
+
height: 40,
|
|
2998
|
+
padding: 8,
|
|
2999
|
+
offsetY: 24,
|
|
3000
|
+
tail: 6
|
|
3001
|
+
};
|
|
3002
|
+
function createBubbleDialogue(theme = defaultTheme(), opts) {
|
|
3003
|
+
const geo = { ...DEFAULT_BUBBLE, ...opts.bubble };
|
|
3004
|
+
const fonts = themeFonts(theme);
|
|
3005
|
+
const layout = new BubbleLayout({
|
|
3006
|
+
minWidth: geo.minWidth,
|
|
3007
|
+
maxWidth: geo.maxWidth,
|
|
3008
|
+
height: geo.height,
|
|
3009
|
+
padding: geo.padding,
|
|
3010
|
+
offsetY: geo.offsetY,
|
|
3011
|
+
textSize: theme.textSize,
|
|
3012
|
+
lineHeight: theme.lineHeight,
|
|
3013
|
+
fallbackAnchor: opts.fallbackAnchor,
|
|
3014
|
+
...fonts
|
|
3015
|
+
});
|
|
3016
|
+
const chrome = new BubbleChrome(
|
|
3017
|
+
{
|
|
3018
|
+
layer: opts.worldLayer,
|
|
3019
|
+
tail: geo.tail,
|
|
3020
|
+
tailLean: theme.tailLean,
|
|
3021
|
+
frameColor: theme.frameColor,
|
|
3022
|
+
frameAlpha: theme.frameAlpha,
|
|
3023
|
+
borderColor: theme.borderColor,
|
|
3024
|
+
cornerRadius: theme.cornerRadius,
|
|
3025
|
+
nameColor: theme.nameColor,
|
|
3026
|
+
nameSize: theme.nameSize,
|
|
3027
|
+
indicatorColor: theme.indicatorColor,
|
|
3028
|
+
caret: theme.caret,
|
|
3029
|
+
frame: defaultBubbleFrame(theme.textured),
|
|
3030
|
+
...fonts
|
|
3031
|
+
},
|
|
3032
|
+
layout
|
|
3033
|
+
);
|
|
3034
|
+
const text = new BubbleTextView(
|
|
3035
|
+
{
|
|
3036
|
+
textSize: theme.textSize,
|
|
3037
|
+
lineHeight: theme.lineHeight,
|
|
3038
|
+
textColor: theme.textColor,
|
|
3039
|
+
charsPerSec: theme.charsPerSec,
|
|
3040
|
+
layer: opts.worldLayer,
|
|
3041
|
+
...fonts
|
|
3042
|
+
},
|
|
3043
|
+
layout
|
|
3044
|
+
);
|
|
3045
|
+
const choices = new BubbleChoicePresenter(
|
|
3046
|
+
{
|
|
3047
|
+
layer: opts.worldLayer,
|
|
3048
|
+
width: geo.maxWidth,
|
|
3049
|
+
padding: geo.padding,
|
|
3050
|
+
offsetY: geo.offsetY,
|
|
3051
|
+
tail: geo.tail,
|
|
3052
|
+
choiceSize: theme.choiceSize,
|
|
3053
|
+
choiceColor: theme.choiceColor,
|
|
3054
|
+
choiceSelectedColor: theme.choiceSelectedColor,
|
|
3055
|
+
highlightColor: theme.highlightColor,
|
|
3056
|
+
choiceGap: theme.choiceGap,
|
|
3057
|
+
textColor: theme.textColor,
|
|
3058
|
+
frameColor: theme.frameColor,
|
|
3059
|
+
frameAlpha: theme.frameAlpha,
|
|
3060
|
+
borderColor: theme.borderColor,
|
|
3061
|
+
cornerRadius: theme.cornerRadius,
|
|
3062
|
+
...fonts
|
|
3063
|
+
},
|
|
3064
|
+
layout
|
|
3065
|
+
);
|
|
3066
|
+
const avatar = opts.avatar?.(layout);
|
|
3067
|
+
return {
|
|
3068
|
+
chrome,
|
|
3069
|
+
text,
|
|
3070
|
+
choices,
|
|
3071
|
+
...avatar ? { avatar } : {},
|
|
3072
|
+
skipMultiplier: theme.skipMultiplier
|
|
3073
|
+
};
|
|
3074
|
+
}
|
|
3075
|
+
__name(createBubbleDialogue, "createBubbleDialogue");
|
|
3076
|
+
|
|
3077
|
+
// src/factory/createMixedDialogue.ts
|
|
3078
|
+
function createMixedDialogue(theme = defaultTheme(), opts) {
|
|
3079
|
+
const box = createBoxDialogue(theme, opts.avatar?.box ? { avatar: opts.avatar.box } : {});
|
|
3080
|
+
const bubble = createBubbleDialogue(theme, {
|
|
3081
|
+
worldLayer: opts.worldLayer,
|
|
3082
|
+
...opts.bubble !== void 0 ? { bubble: opts.bubble } : {},
|
|
3083
|
+
...opts.fallbackAnchor !== void 0 ? { fallbackAnchor: opts.fallbackAnchor } : {},
|
|
3084
|
+
...opts.avatar?.bubble ? { avatar: opts.avatar.bubble } : {}
|
|
3085
|
+
});
|
|
3086
|
+
const routing = opts.route ? fixedRoute(opts.route) : makeDefaultRoute();
|
|
3087
|
+
let avatar;
|
|
3088
|
+
if (box.avatar && bubble.avatar) {
|
|
3089
|
+
avatar = new CompositeAvatarPresenter(box.avatar, bubble.avatar, routing);
|
|
3090
|
+
} else {
|
|
3091
|
+
avatar = box.avatar ?? bubble.avatar;
|
|
3092
|
+
}
|
|
3093
|
+
return {
|
|
3094
|
+
text: new CompositeTextPresenter(box.text, bubble.text, routing),
|
|
3095
|
+
chrome: new CompositeChrome(box.chrome, bubble.chrome, routing),
|
|
3096
|
+
choices: new CompositeChoicePresenter(box.choices, bubble.choices, routing),
|
|
3097
|
+
...avatar ? { avatar } : {},
|
|
3098
|
+
skipMultiplier: theme.skipMultiplier
|
|
3099
|
+
};
|
|
3100
|
+
}
|
|
3101
|
+
__name(createMixedDialogue, "createMixedDialogue");
|
|
3102
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
3103
|
+
0 && (module.exports = {
|
|
3104
|
+
ActorRegistry,
|
|
3105
|
+
BoxLayout,
|
|
3106
|
+
BoxTextView,
|
|
3107
|
+
BubbleAnchorResolver,
|
|
3108
|
+
BubbleAvatarPresenter,
|
|
3109
|
+
BubbleChoicePresenter,
|
|
3110
|
+
BubbleChrome,
|
|
3111
|
+
BubbleLayout,
|
|
3112
|
+
BubbleTextView,
|
|
3113
|
+
CHROME_STYLE_DEFAULT,
|
|
3114
|
+
CHROME_STYLE_NONE,
|
|
3115
|
+
ChoiceListPresenter,
|
|
3116
|
+
CompositeAvatarPresenter,
|
|
3117
|
+
CompositeChoicePresenter,
|
|
3118
|
+
CompositeChrome,
|
|
3119
|
+
CompositeTextPresenter,
|
|
3120
|
+
DEFAULT_BUBBLE,
|
|
3121
|
+
DEFAULT_CARET_BLINK_MS,
|
|
3122
|
+
DEFAULT_CARET_SIZE,
|
|
3123
|
+
DEFAULT_CHOICE_GAP,
|
|
3124
|
+
DEFAULT_TAIL_LEAN,
|
|
3125
|
+
DIALOGUE_LAYERS,
|
|
3126
|
+
DIALOGUE_LAYER_AVATAR,
|
|
3127
|
+
DIALOGUE_LAYER_FRAME,
|
|
3128
|
+
DIALOGUE_LAYER_TEXT,
|
|
3129
|
+
DialogueActor,
|
|
3130
|
+
DialogueChrome,
|
|
3131
|
+
DialogueTextView,
|
|
3132
|
+
InBoxAvatarPresenter,
|
|
3133
|
+
NullAvatarPresenter,
|
|
3134
|
+
PortraitPresenter,
|
|
3135
|
+
RadialChoicePresenter,
|
|
3136
|
+
SceneFigurePresenter,
|
|
3137
|
+
actorRegistryFor,
|
|
3138
|
+
createBoxDialogue,
|
|
3139
|
+
createBubbleDialogue,
|
|
3140
|
+
createMixedDialogue,
|
|
3141
|
+
defaultTheme,
|
|
3142
|
+
effectDrivesTint,
|
|
3143
|
+
evaluateEffect,
|
|
3144
|
+
fixedRoute,
|
|
3145
|
+
makeDefaultRoute,
|
|
3146
|
+
routeWithActor,
|
|
3147
|
+
stackChoiceRows
|
|
3148
|
+
});
|
|
3149
|
+
//# sourceMappingURL=presenters.cjs.map
|