@kawaijs/renderer-dom 0.1.10 → 0.1.12

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.
Files changed (107) hide show
  1. package/README.md +34 -12
  2. package/dist/asset-fallbacks.d.ts +13 -0
  3. package/dist/asset-fallbacks.d.ts.map +1 -0
  4. package/dist/asset-fallbacks.js +41 -0
  5. package/dist/asset-fallbacks.js.map +1 -0
  6. package/dist/components/choice-menu.d.ts +12 -0
  7. package/dist/components/choice-menu.d.ts.map +1 -0
  8. package/dist/components/choice-menu.js +44 -0
  9. package/dist/components/choice-menu.js.map +1 -0
  10. package/dist/components/dialogue-box.d.ts +29 -0
  11. package/dist/components/dialogue-box.d.ts.map +1 -0
  12. package/dist/components/dialogue-box.js +155 -0
  13. package/dist/components/dialogue-box.js.map +1 -0
  14. package/dist/components/hotspot-layer.d.ts +13 -0
  15. package/dist/components/hotspot-layer.d.ts.map +1 -0
  16. package/dist/components/hotspot-layer.js +47 -0
  17. package/dist/components/hotspot-layer.js.map +1 -0
  18. package/dist/components/main-menu.d.ts +36 -0
  19. package/dist/components/main-menu.d.ts.map +1 -0
  20. package/dist/components/main-menu.js +257 -0
  21. package/dist/components/main-menu.js.map +1 -0
  22. package/dist/components/quick-menu.d.ts +22 -0
  23. package/dist/components/quick-menu.d.ts.map +1 -0
  24. package/dist/components/quick-menu.js +59 -0
  25. package/dist/components/quick-menu.js.map +1 -0
  26. package/dist/components/stage-layer.d.ts +39 -0
  27. package/dist/components/stage-layer.d.ts.map +1 -0
  28. package/dist/components/stage-layer.js +253 -0
  29. package/dist/components/stage-layer.js.map +1 -0
  30. package/dist/components/vfx-layer.d.ts +28 -0
  31. package/dist/components/vfx-layer.d.ts.map +1 -0
  32. package/dist/components/vfx-layer.js +310 -0
  33. package/dist/components/vfx-layer.js.map +1 -0
  34. package/dist/embed.d.ts +5 -0
  35. package/dist/embed.d.ts.map +1 -0
  36. package/dist/embed.js +17 -0
  37. package/dist/embed.js.map +1 -0
  38. package/dist/icons.d.ts +31 -0
  39. package/dist/icons.d.ts.map +1 -0
  40. package/dist/icons.js +31 -0
  41. package/dist/icons.js.map +1 -0
  42. package/dist/index.d.ts +31 -1
  43. package/dist/index.d.ts.map +1 -1
  44. package/dist/index.js +69 -3
  45. package/dist/index.js.map +1 -1
  46. package/dist/layout/viewport-adapter.d.ts +52 -0
  47. package/dist/layout/viewport-adapter.d.ts.map +1 -0
  48. package/dist/layout/viewport-adapter.js +123 -0
  49. package/dist/layout/viewport-adapter.js.map +1 -0
  50. package/dist/modals/about-modal.d.ts +3 -0
  51. package/dist/modals/about-modal.d.ts.map +1 -0
  52. package/dist/modals/about-modal.js +59 -0
  53. package/dist/modals/about-modal.js.map +1 -0
  54. package/dist/modals/achievements-modal.d.ts +4 -0
  55. package/dist/modals/achievements-modal.d.ts.map +1 -0
  56. package/dist/modals/achievements-modal.js +103 -0
  57. package/dist/modals/achievements-modal.js.map +1 -0
  58. package/dist/modals/confirm-modal.d.ts +9 -0
  59. package/dist/modals/confirm-modal.d.ts.map +1 -0
  60. package/dist/modals/confirm-modal.js +80 -0
  61. package/dist/modals/confirm-modal.js.map +1 -0
  62. package/dist/modals/gallery-modal.d.ts +4 -0
  63. package/dist/modals/gallery-modal.d.ts.map +1 -0
  64. package/dist/modals/gallery-modal.js +128 -0
  65. package/dist/modals/gallery-modal.js.map +1 -0
  66. package/dist/modals/history-modal.d.ts +3 -0
  67. package/dist/modals/history-modal.d.ts.map +1 -0
  68. package/dist/modals/history-modal.js +66 -0
  69. package/dist/modals/history-modal.js.map +1 -0
  70. package/dist/modals/input-modal.d.ts +10 -0
  71. package/dist/modals/input-modal.d.ts.map +1 -0
  72. package/dist/modals/input-modal.js +62 -0
  73. package/dist/modals/input-modal.js.map +1 -0
  74. package/dist/modals/save-load-modal.d.ts +3 -0
  75. package/dist/modals/save-load-modal.d.ts.map +1 -0
  76. package/dist/modals/save-load-modal.js +262 -0
  77. package/dist/modals/save-load-modal.js.map +1 -0
  78. package/dist/modals/settings-modal.d.ts +14 -0
  79. package/dist/modals/settings-modal.d.ts.map +1 -0
  80. package/dist/modals/settings-modal.js +128 -0
  81. package/dist/modals/settings-modal.js.map +1 -0
  82. package/dist/renderer.d.ts +75 -34
  83. package/dist/renderer.d.ts.map +1 -1
  84. package/dist/renderer.js +763 -529
  85. package/dist/renderer.js.map +1 -1
  86. package/dist/share-meta.d.ts +27 -0
  87. package/dist/share-meta.d.ts.map +1 -0
  88. package/dist/share-meta.js +113 -0
  89. package/dist/share-meta.js.map +1 -0
  90. package/dist/touch.d.ts +16 -0
  91. package/dist/touch.d.ts.map +1 -0
  92. package/dist/touch.js +60 -0
  93. package/dist/touch.js.map +1 -0
  94. package/dist/types.d.ts +134 -0
  95. package/dist/types.d.ts.map +1 -0
  96. package/dist/types.js +60 -0
  97. package/dist/types.js.map +1 -0
  98. package/dist/utils/focus-trap.d.ts +6 -0
  99. package/dist/utils/focus-trap.d.ts.map +1 -0
  100. package/dist/utils/focus-trap.js +52 -0
  101. package/dist/utils/focus-trap.js.map +1 -0
  102. package/dist/utils/rich-text.d.ts +7 -0
  103. package/dist/utils/rich-text.d.ts.map +1 -0
  104. package/dist/utils/rich-text.js +31 -0
  105. package/dist/utils/rich-text.js.map +1 -0
  106. package/package.json +17 -3
  107. package/src/theme.css +1995 -523
package/dist/renderer.js CHANGED
@@ -1,645 +1,879 @@
1
- export function defaultAssetResolver(path, type) {
2
- if (path.startsWith('http://') ||
3
- path.startsWith('https://') ||
4
- path.startsWith('data:') ||
5
- path.startsWith('/') ||
6
- path.startsWith('./')) {
7
- return path;
8
- }
9
- if (type === 'background') {
10
- const clean = path.replace(/^bg[\s_]+/i, '').trim();
11
- return clean.includes('.') ? `assets/backgrounds/${clean}` : `assets/backgrounds/${clean}.svg`;
12
- }
13
- if (type === 'character') {
14
- const clean = path.replace(/_/g, '/').replace(/\s+/g, '/').trim();
15
- return clean.includes('.') ? `assets/characters/${clean}` : `assets/characters/${clean}.svg`;
16
- }
17
- if (type === 'audio') {
18
- return path.includes('.') ? `assets/audio/${path}` : `assets/audio/${path}.mp3`;
19
- }
20
- return path;
21
- }
22
- /**
23
- * Preload all background and character assets mentioned in a story to eliminate visual flashes.
24
- */
25
- export function preloadStoryAssets(story, assetResolver = defaultAssetResolver) {
26
- if (typeof window === 'undefined')
27
- return;
28
- const bgSet = new Set();
29
- const charSet = new Set();
1
+ import { AudioManager } from '@kawaijs/audio';
2
+ import { SVG_ICONS } from './icons.js';
3
+ import { defaultAssetResolver } from './types.js';
4
+ import { StageLayerComponent } from './components/stage-layer.js';
5
+ import { DialogueBoxComponent } from './components/dialogue-box.js';
6
+ import { ChoiceMenuComponent } from './components/choice-menu.js';
7
+ import { QuickMenuComponent } from './components/quick-menu.js';
8
+ import { MainMenuComponent } from './components/main-menu.js';
9
+ import { HotspotLayerComponent } from './components/hotspot-layer.js';
10
+ import { showSaveLoadModal } from './modals/save-load-modal.js';
11
+ import { showSettingsModal } from './modals/settings-modal.js';
12
+ import { showHistoryModal } from './modals/history-modal.js';
13
+ import { showAboutModal } from './modals/about-modal.js';
14
+ import { showGalleryModal } from './modals/gallery-modal.js';
15
+ import { showInputModal } from './modals/input-modal.js';
16
+ import { escapeHtml } from './utils/rich-text.js';
17
+ import { ViewportAdapter } from './layout/viewport-adapter.js';
18
+ import { applyDocumentShareMeta, buildSceneShareMeta, resolveShareAssetUrl } from './share-meta.js';
19
+ import { bindSwipeControls } from './touch.js';
20
+ export * from './icons.js';
21
+ export * from './types.js';
22
+ export * from './asset-fallbacks.js';
23
+ export * from './utils/rich-text.js';
24
+ export * from './utils/focus-trap.js';
25
+ export * from './layout/viewport-adapter.js';
26
+ export * from './modals/save-load-modal.js';
27
+ export * from './modals/settings-modal.js';
28
+ export * from './modals/history-modal.js';
29
+ export * from './modals/about-modal.js';
30
+ export * from './modals/gallery-modal.js';
31
+ export * from './modals/achievements-modal.js';
32
+ export * from './modals/confirm-modal.js';
33
+ export * from './modals/input-modal.js';
34
+ export * from './components/main-menu.js';
35
+ export * from './components/dialogue-box.js';
36
+ export * from './components/choice-menu.js';
37
+ export * from './components/quick-menu.js';
38
+ export * from './components/stage-layer.js';
39
+ export * from './components/vfx-layer.js';
40
+ export * from './share-meta.js';
41
+ export * from './embed.js';
42
+ export * from './touch.js';
43
+ export function extractGalleryItems(story) {
44
+ if (!story || !story.labels)
45
+ return [];
46
+ const itemsMap = new Map();
30
47
  for (const label of Object.values(story.labels)) {
31
48
  for (const inst of label) {
32
- if (inst.type === 'scene' && inst.background) {
33
- const clean = inst.background.replace(/^bg[\s_]+/i, '').trim();
34
- bgSet.add(clean);
35
- }
36
- else if (inst.type === 'show') {
37
- const key = inst.expression ? `${inst.character}/${inst.expression}` : inst.character;
38
- charSet.add(key);
49
+ if (inst.type === 'cg' && inst.image) {
50
+ const id = inst.unlockId ?? inst.image;
51
+ if (!itemsMap.has(id)) {
52
+ const rawBase = (inst.unlockId || inst.image).split(/[/\\]/).pop() || '';
53
+ const cleanName = rawBase.replace(/^cg[\s_]+/i, '').replace(/\.[^/.]+$/, '').replace(/[_-]+/g, ' ');
54
+ const title = cleanName ? cleanName.charAt(0).toUpperCase() + cleanName.slice(1) : 'Event CG';
55
+ itemsMap.set(id, {
56
+ id,
57
+ title,
58
+ image: inst.image
59
+ });
60
+ }
39
61
  }
40
62
  }
41
63
  }
42
- for (const bg of bgSet) {
43
- const img = new Image();
44
- img.src = assetResolver(bg, 'background');
45
- }
46
- for (const char of charSet) {
47
- const img = new Image();
48
- img.src = assetResolver(char, 'character');
49
- }
64
+ return Array.from(itemsMap.values());
50
65
  }
51
66
  export class DOMRenderer {
52
67
  vm;
53
68
  container;
69
+ options;
54
70
  typewriterSpeed;
71
+ autoDelayMs;
72
+ musicVolume;
73
+ soundVolume;
55
74
  assetResolver;
75
+ mainMenuOptions;
76
+ audioManager;
56
77
  rootEl;
57
- stageEl;
58
- backgroundEl;
59
- bgLayerA;
60
- bgLayerB;
61
- activeBgLayer = 'A';
62
- currentBgUrl = '';
63
- charactersEl;
64
- activeCharacters = new Map();
65
- dialogueBoxEl;
66
- speakerTagEl;
67
- dialogueTextEl;
68
- choiceContainerEl;
69
- backBtnEl;
70
- currentTypewriterInterval = null;
71
- isTypewriting = false;
72
- fullCurrentText = '';
73
- isChoicePending = false;
78
+ stageLayer;
79
+ dialogueBox;
80
+ choiceMenu;
81
+ hotspotLayer;
82
+ quickMenu;
83
+ mainMenu;
84
+ viewportAdapter;
85
+ appliedStyleTargets = new Set();
86
+ isAutoMode = false;
87
+ isSkipMode = false;
88
+ autoTimer = null;
89
+ skipInterval = null;
90
+ pauseTimer = null;
91
+ errorToastTimer = null;
92
+ advanceLockUntil = 0;
93
+ destroyed = false;
74
94
  unsubscribeVMState;
95
+ unsubscribeCamera;
96
+ unsubscribeAudio;
97
+ unsubscribeError;
75
98
  boundKeyHandler;
99
+ touchControls;
76
100
  constructor(vm, options) {
77
101
  this.vm = vm;
78
102
  this.container = options.container;
103
+ this.options = options;
79
104
  this.typewriterSpeed = options.typewriterSpeed ?? 20;
105
+ this.autoDelayMs = options.autoDelayMs ?? 1800;
80
106
  this.assetResolver = options.assetResolver ?? defaultAssetResolver;
107
+ let finalMainMenu = options.mainMenu;
108
+ if (options.embed) {
109
+ finalMainMenu = { ...finalMainMenu, enabled: false };
110
+ }
111
+ if (finalMainMenu?.enabled !== false && (!finalMainMenu?.galleryItems || finalMainMenu.galleryItems.length === 0)) {
112
+ const autoItems = extractGalleryItems(this.vm.getStory());
113
+ if (autoItems.length > 0) {
114
+ finalMainMenu = {
115
+ ...finalMainMenu,
116
+ galleryItems: autoItems
117
+ };
118
+ }
119
+ }
120
+ this.mainMenuOptions = finalMainMenu;
121
+ this.audioManager = options.audioManager ?? (typeof window !== 'undefined' ? new AudioManager() : undefined);
122
+ this.musicVolume = this.audioManager?.getMusicVolume?.() ?? 0.8;
123
+ this.soundVolume = this.audioManager?.getSoundVolume?.() ?? 1.0;
124
+ // Load saved settings if present
125
+ if (typeof localStorage !== 'undefined') {
126
+ try {
127
+ const saved = localStorage.getItem('kawaijs_settings');
128
+ if (saved) {
129
+ const parsed = JSON.parse(saved);
130
+ if (typeof parsed.typewriterSpeed === 'number')
131
+ this.typewriterSpeed = parsed.typewriterSpeed;
132
+ if (typeof parsed.autoDelayMs === 'number')
133
+ this.autoDelayMs = parsed.autoDelayMs;
134
+ if (typeof parsed.musicVolume === 'number') {
135
+ this.musicVolume = parsed.musicVolume;
136
+ this.audioManager?.setMusicVolume(this.musicVolume);
137
+ }
138
+ if (typeof parsed.soundVolume === 'number') {
139
+ this.soundVolume = parsed.soundVolume;
140
+ this.audioManager?.setSoundVolume(this.soundVolume);
141
+ }
142
+ }
143
+ }
144
+ catch { }
145
+ }
81
146
  this.buildDOM();
82
147
  this.bindEvents();
83
148
  this.unsubscribeVMState = this.vm.onStateChange((state) => {
84
149
  this.render(state);
150
+ this.syncUrlLabel(state);
151
+ this.syncShareMeta(state);
152
+ });
153
+ this.unsubscribeCamera = this.vm.onCameraEvent((e) => {
154
+ if (e.action === 'flash') {
155
+ this.flashScreen();
156
+ }
157
+ else {
158
+ this.shakeScreen(e.action);
159
+ }
85
160
  });
86
- // Render current initial state
87
- this.render(this.vm.getState());
161
+ this.unsubscribeAudio = this.vm.onAudioEvent((event) => {
162
+ if (!this.audioManager)
163
+ return;
164
+ if (event.action === 'play' && event.track) {
165
+ const url = this.assetResolver(event.track, 'audio');
166
+ if (event.channel === 'music') {
167
+ this.audioManager.playMusic?.(url, { fadein: event.fade, loop: event.loop });
168
+ }
169
+ else if (event.channel === 'sound') {
170
+ this.audioManager.playSound?.(url);
171
+ }
172
+ else if (event.channel === 'voice') {
173
+ this.audioManager.playVoice?.(url);
174
+ }
175
+ }
176
+ else if (event.action === 'stop') {
177
+ if (event.channel === 'music') {
178
+ this.audioManager.stopMusic?.({ fadeout: event.fade });
179
+ }
180
+ else if (event.channel === 'sound') {
181
+ this.audioManager.stopSound?.();
182
+ }
183
+ else if (event.channel === 'voice') {
184
+ this.audioManager.stopVoice?.();
185
+ }
186
+ }
187
+ });
188
+ this.unsubscribeError = this.vm.onError((err) => {
189
+ this.showErrorToast(err.message);
190
+ this.options.onError?.(err);
191
+ });
192
+ // Default: Show Ren'Py-style Main Menu before entering story, unless explicitly disabled
193
+ if (this.mainMenuOptions?.enabled !== false) {
194
+ this.showMainMenu();
195
+ }
196
+ else {
197
+ this.render(this.vm.getState());
198
+ }
88
199
  }
89
200
  destroy() {
201
+ if (this.destroyed)
202
+ return;
203
+ this.destroyed = true;
90
204
  if (this.unsubscribeVMState) {
91
205
  this.unsubscribeVMState();
206
+ this.unsubscribeVMState = undefined;
207
+ }
208
+ if (this.unsubscribeCamera) {
209
+ this.unsubscribeCamera();
210
+ this.unsubscribeCamera = undefined;
211
+ }
212
+ if (this.unsubscribeAudio) {
213
+ this.unsubscribeAudio();
214
+ this.unsubscribeAudio = undefined;
215
+ }
216
+ if (this.audioManager && typeof this.audioManager.destroy === 'function') {
217
+ this.audioManager.destroy();
218
+ }
219
+ if (this.unsubscribeError) {
220
+ this.unsubscribeError();
221
+ this.unsubscribeError = undefined;
222
+ }
223
+ if (this.dialogueBox) {
224
+ this.dialogueBox.destroy();
92
225
  }
93
- if (this.currentTypewriterInterval) {
94
- clearInterval(this.currentTypewriterInterval);
226
+ if (this.hotspotLayer) {
227
+ this.hotspotLayer.destroy();
228
+ }
229
+ if (this.stageLayer) {
230
+ this.stageLayer.destroy();
231
+ }
232
+ if (this.viewportAdapter) {
233
+ this.viewportAdapter.destroy();
234
+ this.viewportAdapter = undefined;
235
+ }
236
+ if (this.autoTimer) {
237
+ clearTimeout(this.autoTimer);
238
+ this.autoTimer = null;
239
+ }
240
+ if (this.skipInterval) {
241
+ clearInterval(this.skipInterval);
242
+ this.skipInterval = null;
243
+ }
244
+ if (this.pauseTimer) {
245
+ clearTimeout(this.pauseTimer);
246
+ this.pauseTimer = null;
247
+ }
248
+ if (this.errorToastTimer) {
249
+ clearTimeout(this.errorToastTimer);
250
+ this.errorToastTimer = null;
95
251
  }
96
252
  if (this.boundKeyHandler && typeof window !== 'undefined') {
97
253
  window.removeEventListener('keydown', this.boundKeyHandler);
254
+ this.boundKeyHandler = undefined;
255
+ }
256
+ if (this.touchControls) {
257
+ this.touchControls.destroy();
258
+ this.touchControls = undefined;
98
259
  }
99
260
  this.container.innerHTML = '';
100
261
  }
101
- shakeScreen() {
102
- if (!this.stageEl)
103
- return;
104
- this.stageEl.classList.remove('kawa-shake');
105
- // Trigger reflow to restart animation
106
- void this.stageEl.offsetWidth;
107
- this.stageEl.classList.add('kawa-shake');
262
+ showErrorToast(message) {
263
+ const existing = this.rootEl.querySelector('.kawa-error-toast');
264
+ if (existing)
265
+ existing.remove();
266
+ if (this.errorToastTimer) {
267
+ clearTimeout(this.errorToastTimer);
268
+ this.errorToastTimer = null;
269
+ }
270
+ const toast = document.createElement('div');
271
+ toast.className = 'kawa-error-toast';
272
+ toast.setAttribute('role', 'alert');
273
+ toast.innerHTML = `
274
+ <span>⚠️</span>
275
+ <span class="kawa-error-toast-msg">${escapeHtml(message)}</span>
276
+ <button type="button" class="kawa-error-toast-close" aria-label="Dismiss error">${SVG_ICONS.close}</button>
277
+ `;
278
+ toast.querySelector('.kawa-error-toast-close')?.addEventListener('click', () => {
279
+ toast.remove();
280
+ });
281
+ this.rootEl.appendChild(toast);
282
+ this.errorToastTimer = window.setTimeout(() => {
283
+ this.errorToastTimer = null;
284
+ if (toast.parentElement)
285
+ toast.remove();
286
+ }, 8000);
287
+ }
288
+ getViewportAdapter() {
289
+ return this.viewportAdapter;
290
+ }
291
+ shakeScreen(type = 'shake') {
292
+ this.stageLayer.shakeScreen(type);
108
293
  }
109
294
  flashScreen() {
110
- if (!this.stageEl)
295
+ this.stageLayer.flashScreen();
296
+ }
297
+ toggleAutoMode(force) {
298
+ this.isAutoMode = force !== undefined ? force : !this.isAutoMode;
299
+ if (this.isAutoMode && this.isSkipMode) {
300
+ this.toggleSkipMode(false);
301
+ }
302
+ this.updateModeUI();
303
+ if (this.isAutoMode && !this.dialogueBox.getIsTypewriting()) {
304
+ this.scheduleAutoAdvance();
305
+ }
306
+ else if (!this.isAutoMode && this.autoTimer) {
307
+ clearTimeout(this.autoTimer);
308
+ this.autoTimer = null;
309
+ }
310
+ }
311
+ toggleSkipMode(force) {
312
+ this.isSkipMode = force !== undefined ? force : !this.isSkipMode;
313
+ if (this.isSkipMode && this.isAutoMode) {
314
+ this.toggleAutoMode(false);
315
+ }
316
+ this.updateModeUI();
317
+ // Suspend decorative particle loops while skipping to cut CPU during fast-forward.
318
+ this.stageLayer.vfxLayer.setSuspended(this.isSkipMode);
319
+ if (this.isSkipMode) {
320
+ if (this.autoTimer) {
321
+ clearTimeout(this.autoTimer);
322
+ this.autoTimer = null;
323
+ }
324
+ if (!this.skipInterval) {
325
+ // Coalesce advances (~10/s) instead of hammering every 55ms.
326
+ this.skipInterval = window.setInterval(() => {
327
+ if (this.destroyed) {
328
+ this.toggleSkipMode(false);
329
+ return;
330
+ }
331
+ const state = this.vm.getState();
332
+ if (state.isFinished ||
333
+ (state.choices && state.choices.length > 0) ||
334
+ (state.hotspots && state.hotspots.length > 0) ||
335
+ state.pendingInput) {
336
+ this.toggleSkipMode(false);
337
+ return;
338
+ }
339
+ if (this.dialogueBox.getIsTypewriting()) {
340
+ this.dialogueBox.finishTypewriter();
341
+ }
342
+ this.vm.next();
343
+ }, 100);
344
+ }
345
+ }
346
+ else {
347
+ if (this.skipInterval) {
348
+ clearInterval(this.skipInterval);
349
+ this.skipInterval = null;
350
+ }
351
+ }
352
+ }
353
+ showMainMenu() {
354
+ if (this.isAutoMode)
355
+ this.toggleAutoMode(false);
356
+ if (this.isSkipMode)
357
+ this.toggleSkipMode(false);
358
+ this.mainMenu.show();
359
+ }
360
+ hideMainMenu() {
361
+ this.mainMenu.hide();
362
+ }
363
+ isMainMenuActive() {
364
+ return this.mainMenu.isActive();
365
+ }
366
+ startNewGame() {
367
+ // Ignore residual click-through from the Start button onto the stage (Opera / touchpads).
368
+ this.advanceLockUntil = Date.now() + 350;
369
+ this.vm.start();
370
+ this.hideMainMenu();
371
+ this.mainMenuOptions?.onStart?.();
372
+ }
373
+ async continueLatestGame() {
374
+ const saveManager = this.vm.getSaveManager();
375
+ const slots = await saveManager.listSlots(10);
376
+ const validSlots = slots.filter((s) => s !== null);
377
+ if (validSlots.length === 0)
378
+ return false;
379
+ validSlots.sort((a, b) => b.timestamp - a.timestamp);
380
+ const latest = validSlots[0];
381
+ const ok = await this.vm.load(latest.id);
382
+ if (ok) {
383
+ this.hideMainMenu();
384
+ return true;
385
+ }
386
+ return false;
387
+ }
388
+ async showSaveLoadModal(mode) {
389
+ await showSaveLoadModal(this.rootEl, this.vm, mode, () => {
390
+ this.hideMainMenu();
391
+ });
392
+ }
393
+ showSettingsModal() {
394
+ showSettingsModal(this.rootEl, {
395
+ typewriterSpeed: this.typewriterSpeed,
396
+ autoDelayMs: this.autoDelayMs,
397
+ musicVolume: this.musicVolume,
398
+ soundVolume: this.soundVolume,
399
+ onSettingsChange: (s) => {
400
+ this.typewriterSpeed = s.typewriterSpeed;
401
+ this.autoDelayMs = s.autoDelayMs;
402
+ if (s.musicVolume !== undefined) {
403
+ this.musicVolume = s.musicVolume;
404
+ this.audioManager?.setMusicVolume(this.musicVolume);
405
+ }
406
+ if (s.soundVolume !== undefined) {
407
+ this.soundVolume = s.soundVolume;
408
+ this.audioManager?.setSoundVolume(this.soundVolume);
409
+ }
410
+ this.dialogueBox.setTypewriterSpeed(this.typewriterSpeed);
411
+ this.saveSettings();
412
+ }
413
+ });
414
+ }
415
+ showHistoryModal() {
416
+ showHistoryModal(this.rootEl, this.vm);
417
+ }
418
+ showAboutModal() {
419
+ showAboutModal(this.rootEl, this.mainMenuOptions);
420
+ }
421
+ showGalleryModal() {
422
+ const galleryItems = this.mainMenuOptions?.galleryItems ?? [];
423
+ showGalleryModal(this.rootEl, this.vm, galleryItems, (path) => this.assetResolver(path, 'background'));
424
+ }
425
+ updateModeUI() {
426
+ this.quickMenu.setAutoActive(this.isAutoMode);
427
+ this.quickMenu.setSkipActive(this.isSkipMode);
428
+ if (this.isSkipMode) {
429
+ this.stageLayer.modeBadgeEl.innerHTML = `${SVG_ICONS.skip} SKIP`;
430
+ this.stageLayer.modeBadgeEl.style.display = 'inline-flex';
431
+ }
432
+ else if (this.isAutoMode) {
433
+ this.stageLayer.modeBadgeEl.innerHTML = `${SVG_ICONS.auto} AUTO`;
434
+ this.stageLayer.modeBadgeEl.style.display = 'inline-flex';
435
+ }
436
+ else {
437
+ this.stageLayer.modeBadgeEl.style.display = 'none';
438
+ }
439
+ }
440
+ scheduleAutoAdvance() {
441
+ if (this.autoTimer) {
442
+ clearTimeout(this.autoTimer);
443
+ this.autoTimer = null;
444
+ }
445
+ if (!this.isAutoMode)
111
446
  return;
112
- const existing = this.stageEl.querySelector('.kawa-flash-overlay');
113
- if (existing)
114
- existing.remove();
115
- const flash = document.createElement('div');
116
- flash.className = 'kawa-flash-overlay';
117
- this.stageEl.appendChild(flash);
118
- setTimeout(() => flash.remove(), 600);
447
+ const state = this.vm.getState();
448
+ if (state.isFinished ||
449
+ (state.choices && state.choices.length > 0) ||
450
+ (state.hotspots && state.hotspots.length > 0) ||
451
+ state.pendingInput) {
452
+ return;
453
+ }
454
+ const duration = Math.max(800, this.autoDelayMs + (state.dialogue?.text.length ?? 0) * 15);
455
+ this.autoTimer = window.setTimeout(() => {
456
+ if (this.isAutoMode) {
457
+ const cur = this.vm.getState();
458
+ if (!cur.isFinished &&
459
+ (!cur.choices || cur.choices.length === 0) &&
460
+ (!cur.hotspots || cur.hotspots.length === 0) &&
461
+ !cur.pendingInput) {
462
+ this.vm.next();
463
+ }
464
+ }
465
+ }, duration);
119
466
  }
120
467
  buildDOM() {
121
468
  this.container.innerHTML = '';
122
469
  this.rootEl = document.createElement('div');
123
470
  this.rootEl.className = 'kawa-root';
124
- this.stageEl = document.createElement('div');
125
- this.stageEl.className = 'kawa-stage';
126
- this.stageEl.setAttribute('role', 'main');
127
- this.stageEl.setAttribute('aria-label', 'Visual Novel Stage');
128
- this.backgroundEl = document.createElement('div');
129
- this.backgroundEl.className = 'kawa-background';
130
- this.backgroundEl.setAttribute('aria-hidden', 'true');
131
- this.bgLayerA = document.createElement('div');
132
- this.bgLayerA.className = 'kawa-bg-layer active';
133
- this.bgLayerB = document.createElement('div');
134
- this.bgLayerB.className = 'kawa-bg-layer';
135
- this.backgroundEl.appendChild(this.bgLayerA);
136
- this.backgroundEl.appendChild(this.bgLayerB);
137
- this.charactersEl = document.createElement('div');
138
- this.charactersEl.className = 'kawa-characters kawa-sprites';
139
- this.charactersEl.setAttribute('aria-hidden', 'true');
471
+ this.rootEl.style.touchAction = 'manipulation';
472
+ if (this.options.embed) {
473
+ this.rootEl.classList.add('kawa-embed');
474
+ this.rootEl.dataset.kawaEmbed = '1';
475
+ }
476
+ this.stageLayer = new StageLayerComponent(this.assetResolver);
140
477
  const uiLayerEl = document.createElement('div');
141
478
  uiLayerEl.className = 'kawa-ui-layer';
142
- // Choice Container
143
- this.choiceContainerEl = document.createElement('div');
144
- this.choiceContainerEl.className = 'kawa-choice-container kawa-choices';
145
- this.choiceContainerEl.setAttribute('role', 'group');
146
- this.choiceContainerEl.setAttribute('aria-label', 'Choices');
147
- this.choiceContainerEl.style.display = 'none';
148
- // Dialogue Box
149
- this.dialogueBoxEl = document.createElement('div');
150
- this.dialogueBoxEl.className = 'kawa-dialogue-box kawa-dialogue';
151
- this.dialogueBoxEl.setAttribute('role', 'region');
152
- this.dialogueBoxEl.setAttribute('aria-label', 'Dialogue');
153
- this.dialogueBoxEl.setAttribute('tabindex', '0');
154
- this.speakerTagEl = document.createElement('div');
155
- this.speakerTagEl.className = 'kawa-speaker-tag kawa-speaker';
156
- this.dialogueTextEl = document.createElement('div');
157
- this.dialogueTextEl.className = 'kawa-dialogue-text kawa-text';
158
- this.dialogueTextEl.setAttribute('aria-live', 'polite');
159
- const indicatorEl = document.createElement('div');
160
- indicatorEl.className = 'kawa-continue-indicator';
161
- indicatorEl.setAttribute('aria-hidden', 'true');
162
- indicatorEl.innerHTML = '&#9660;';
163
- this.dialogueBoxEl.appendChild(this.speakerTagEl);
164
- this.dialogueBoxEl.appendChild(this.dialogueTextEl);
165
- this.dialogueBoxEl.appendChild(indicatorEl);
166
- // Quick Menu
167
- const quickMenuEl = document.createElement('nav');
168
- quickMenuEl.className = 'kawa-quick-menu';
169
- quickMenuEl.setAttribute('role', 'toolbar');
170
- quickMenuEl.setAttribute('aria-label', 'Game Menu');
171
- this.backBtnEl = document.createElement('button');
172
- this.backBtnEl.className = 'kawa-btn';
173
- this.backBtnEl.textContent = 'Back';
174
- this.backBtnEl.setAttribute('aria-label', 'Rollback to previous dialogue');
175
- const histBtn = document.createElement('button');
176
- histBtn.className = 'kawa-btn';
177
- histBtn.textContent = 'History';
178
- histBtn.setAttribute('aria-label', 'Open dialogue history log');
179
- histBtn.addEventListener('click', (e) => {
180
- e.stopPropagation();
181
- this.showHistoryModal();
479
+ this.choiceMenu = this.options.components?.createChoiceMenu
480
+ ? this.options.components.createChoiceMenu({
481
+ onSelect: (index) => {
482
+ this.vm.choose(index);
483
+ }
484
+ })
485
+ : new ChoiceMenuComponent({
486
+ onSelect: (index) => {
487
+ this.vm.choose(index);
488
+ }
489
+ });
490
+ this.hotspotLayer = new HotspotLayerComponent((id) => {
491
+ this.vm.selectHotspot(id);
182
492
  });
183
- const saveBtn = document.createElement('button');
184
- saveBtn.className = 'kawa-btn';
185
- saveBtn.textContent = 'Save';
186
- saveBtn.setAttribute('aria-label', 'Open save game menu');
187
- saveBtn.addEventListener('click', (e) => {
188
- e.stopPropagation();
189
- this.showSaveLoadModal('save');
493
+ this.dialogueBox = this.options.components?.createDialogueBox
494
+ ? this.options.components.createDialogueBox(this.typewriterSpeed)
495
+ : new DialogueBoxComponent(this.typewriterSpeed);
496
+ this.quickMenu = new QuickMenuComponent({
497
+ onTitle: () => this.showMainMenu(),
498
+ onBack: () => {
499
+ if (this.isAutoMode)
500
+ this.toggleAutoMode(false);
501
+ if (this.isSkipMode)
502
+ this.toggleSkipMode(false);
503
+ this.vm.rollback();
504
+ },
505
+ onHistory: () => this.showHistoryModal(),
506
+ onAuto: () => this.toggleAutoMode(),
507
+ onSkip: () => this.toggleSkipMode(),
508
+ onSave: () => void this.showSaveLoadModal('save'),
509
+ onLoad: () => void this.showSaveLoadModal('load'),
510
+ onSettings: () => this.showSettingsModal()
190
511
  });
191
- const loadBtn = document.createElement('button');
192
- loadBtn.className = 'kawa-btn';
193
- loadBtn.textContent = 'Load';
194
- loadBtn.setAttribute('aria-label', 'Open load game menu');
195
- loadBtn.addEventListener('click', (e) => {
196
- e.stopPropagation();
197
- this.showSaveLoadModal('load');
512
+ if (this.options.features?.quickMenu === false || this.options.embed) {
513
+ this.quickMenu.el.style.display = 'none';
514
+ }
515
+ uiLayerEl.appendChild(this.choiceMenu.el);
516
+ uiLayerEl.appendChild(this.dialogueBox.el);
517
+ uiLayerEl.appendChild(this.quickMenu.el);
518
+ this.stageLayer.stageEl.appendChild(uiLayerEl);
519
+ this.stageLayer.stageEl.appendChild(this.hotspotLayer.el);
520
+ this.mainMenu = new MainMenuComponent(this.rootEl, this.vm, this.mainMenuOptions, {
521
+ onStartNewGame: () => this.startNewGame(),
522
+ onContinueGame: () => this.continueLatestGame(),
523
+ assetResolver: this.assetResolver,
524
+ getSettingsConfig: () => ({
525
+ typewriterSpeed: this.typewriterSpeed,
526
+ autoDelayMs: this.autoDelayMs,
527
+ musicVolume: this.musicVolume,
528
+ soundVolume: this.soundVolume,
529
+ onSettingsChange: (s) => {
530
+ this.typewriterSpeed = s.typewriterSpeed;
531
+ this.autoDelayMs = s.autoDelayMs;
532
+ if (s.musicVolume !== undefined) {
533
+ this.musicVolume = s.musicVolume;
534
+ this.audioManager?.setMusicVolume(this.musicVolume);
535
+ }
536
+ if (s.soundVolume !== undefined) {
537
+ this.soundVolume = s.soundVolume;
538
+ this.audioManager?.setSoundVolume(this.soundVolume);
539
+ }
540
+ this.dialogueBox.setTypewriterSpeed(this.typewriterSpeed);
541
+ this.saveSettings();
542
+ }
543
+ })
198
544
  });
199
- quickMenuEl.appendChild(this.backBtnEl);
200
- quickMenuEl.appendChild(histBtn);
201
- quickMenuEl.appendChild(saveBtn);
202
- quickMenuEl.appendChild(loadBtn);
203
- uiLayerEl.appendChild(this.choiceContainerEl);
204
- uiLayerEl.appendChild(this.dialogueBoxEl);
205
- uiLayerEl.appendChild(quickMenuEl);
206
- this.stageEl.appendChild(this.backgroundEl);
207
- this.stageEl.appendChild(this.charactersEl);
208
- this.stageEl.appendChild(uiLayerEl);
209
- this.rootEl.appendChild(this.stageEl);
545
+ this.mainMenu.mount(this.stageLayer.stageEl);
546
+ this.rootEl.appendChild(this.stageLayer.stageEl);
210
547
  this.container.appendChild(this.rootEl);
548
+ this.viewportAdapter = new ViewportAdapter(this.container, this.stageLayer.stageEl, this.options.virtualCanvas);
211
549
  }
212
550
  bindEvents() {
213
- // Clicking on dialogue advances or finishes typewriter
214
- this.dialogueBoxEl.addEventListener('click', () => {
551
+ // Clicking on stage or dialogue box advances or finishes typewriter
552
+ this.stageLayer.stageEl.addEventListener('click', (e) => {
553
+ if (this.isMainMenuActive())
554
+ return;
555
+ const target = e.target;
556
+ if (target.closest('button') ||
557
+ target.closest('.kawa-quick-menu') ||
558
+ target.closest('.kawa-choice-container') ||
559
+ target.closest('.kawa-hotspot-layer') ||
560
+ target.closest('.kawa-modal-overlay') ||
561
+ target.closest('.kawa-ending-card')) {
562
+ return;
563
+ }
564
+ if (this.isAutoMode)
565
+ this.toggleAutoMode(false);
566
+ if (this.isSkipMode)
567
+ this.toggleSkipMode(false);
215
568
  this.handleUserAdvance();
216
569
  });
217
- this.backBtnEl.addEventListener('click', (e) => {
218
- e.stopPropagation();
219
- this.vm.rollback();
220
- });
221
570
  // Keyboard controls
222
571
  this.boundKeyHandler = (e) => {
223
- // If modal is open, let Escape close it
572
+ const confirm = this.rootEl.querySelector('.kawa-confirm-overlay');
573
+ if (confirm) {
574
+ if (e.key === 'Escape') {
575
+ e.preventDefault();
576
+ confirm.remove();
577
+ }
578
+ return;
579
+ }
580
+ const lightbox = this.rootEl.querySelector('.kawa-gallery-lightbox');
581
+ if (lightbox) {
582
+ if (e.key === 'Escape') {
583
+ e.preventDefault();
584
+ lightbox.remove();
585
+ }
586
+ return;
587
+ }
224
588
  const modal = this.rootEl.querySelector('.kawa-modal-overlay');
225
589
  if (modal) {
226
590
  if (e.key === 'Escape')
227
591
  modal.remove();
228
592
  return;
229
593
  }
594
+ if (this.isMainMenuActive()) {
595
+ return;
596
+ }
230
597
  if (e.code === 'Space' || e.code === 'Enter') {
231
- // If focusing a button, let the default click happen
232
- if (document.activeElement && document.activeElement.tagName === 'BUTTON') {
598
+ if (document.activeElement &&
599
+ (document.activeElement.tagName === 'BUTTON' ||
600
+ document.activeElement.tagName === 'INPUT' ||
601
+ document.activeElement.tagName === 'TEXTAREA')) {
233
602
  return;
234
603
  }
235
604
  e.preventDefault();
605
+ if (this.isAutoMode)
606
+ this.toggleAutoMode(false);
607
+ if (this.isSkipMode)
608
+ this.toggleSkipMode(false);
236
609
  this.handleUserAdvance();
237
610
  }
238
611
  else if (e.code === 'Backspace') {
239
612
  e.preventDefault();
613
+ if (this.isAutoMode)
614
+ this.toggleAutoMode(false);
615
+ if (this.isSkipMode)
616
+ this.toggleSkipMode(false);
240
617
  this.vm.rollback();
241
618
  }
619
+ else if (e.key === 'a' || e.key === 'A') {
620
+ this.toggleAutoMode();
621
+ }
622
+ else if (e.key === 'Control') {
623
+ // Ctrl toggles skip (Ren'Py-style). Tab is left for accessibility focus navigation.
624
+ e.preventDefault();
625
+ this.toggleSkipMode();
626
+ }
242
627
  else if (e.key === 's' || e.key === 'S') {
243
- this.showSaveLoadModal('save');
628
+ void this.showSaveLoadModal('save');
244
629
  }
245
630
  else if (e.key === 'l' || e.key === 'L') {
246
- this.showSaveLoadModal('load');
631
+ void this.showSaveLoadModal('load');
247
632
  }
248
633
  else if (e.key === 'h' || e.key === 'H') {
249
634
  this.showHistoryModal();
250
635
  }
636
+ else if (e.key === 'p' || e.key === 'P' || e.key === 'o' || e.key === 'O') {
637
+ this.showSettingsModal();
638
+ }
639
+ else if (e.key === 'Escape') {
640
+ this.showMainMenu();
641
+ }
251
642
  };
252
643
  if (typeof window !== 'undefined') {
253
644
  window.addEventListener('keydown', this.boundKeyHandler);
254
645
  }
646
+ // Mobile: swipe left = advance, swipe right = rollback
647
+ if (this.options.features?.touchGestures !== false) {
648
+ this.touchControls = bindSwipeControls(this.stageLayer.stageEl, {
649
+ onSwipeLeft: () => {
650
+ if (this.isMainMenuActive())
651
+ return;
652
+ if (this.isAutoMode)
653
+ this.toggleAutoMode(false);
654
+ if (this.isSkipMode)
655
+ this.toggleSkipMode(false);
656
+ this.handleUserAdvance();
657
+ },
658
+ onSwipeRight: () => {
659
+ if (this.isMainMenuActive())
660
+ return;
661
+ if (this.isAutoMode)
662
+ this.toggleAutoMode(false);
663
+ if (this.isSkipMode)
664
+ this.toggleSkipMode(false);
665
+ if (this.vm.canRollback())
666
+ this.vm.rollback();
667
+ }
668
+ });
669
+ }
255
670
  }
256
671
  handleUserAdvance() {
257
- if (this.isTypewriting) {
258
- this.finishTypewriter();
672
+ if (this.isMainMenuActive())
673
+ return;
674
+ if (this.vm.getState().pendingInput)
675
+ return;
676
+ if (this.dialogueBox.getIsTypewriting()) {
677
+ this.dialogueBox.finishTypewriter();
678
+ const debounce = this.options.features?.advanceDebounceMs ?? 220;
679
+ this.advanceLockUntil = Date.now() + Math.max(0, debounce);
680
+ return;
259
681
  }
260
- else {
261
- this.vm.next();
682
+ if (Date.now() < this.advanceLockUntil) {
683
+ return;
262
684
  }
685
+ const state = this.vm.getState();
686
+ if (state.choices && state.choices.length > 0) {
687
+ return;
688
+ }
689
+ if (state.hotspots && state.hotspots.length > 0) {
690
+ return;
691
+ }
692
+ this.vm.next();
263
693
  }
264
- render(state) {
265
- this.isChoicePending = false;
266
- this.backBtnEl.disabled = !this.vm.canRollback();
267
- // 1. Dual-Layer Background Crossfading
268
- if (state.visual.background) {
269
- this.backgroundEl.dataset.transition = state.visual.transition ?? 'none';
270
- const rawBg = state.visual.background;
271
- const cleanBg = rawBg.replace(/^bg[\s_]+/i, '').trim();
272
- const baseName = cleanBg.replace(/\.(svg|png|jpg|jpeg|webp)$/i, '');
273
- const primaryUrl = this.assetResolver(cleanBg, 'background');
274
- if (primaryUrl !== this.currentBgUrl) {
275
- this.currentBgUrl = primaryUrl;
276
- const incomingLayer = this.activeBgLayer === 'A' ? this.bgLayerB : this.bgLayerA;
277
- const outgoingLayer = this.activeBgLayer === 'A' ? this.bgLayerA : this.bgLayerB;
278
- incomingLayer.style.backgroundImage = `url("${primaryUrl}")`;
279
- incomingLayer.classList.add('active');
280
- outgoingLayer.classList.remove('active');
281
- this.activeBgLayer = this.activeBgLayer === 'A' ? 'B' : 'A';
282
- // Verify and fallback if image fails to load
283
- const testImg = new Image();
284
- testImg.onerror = () => {
285
- const fallbackPng = this.assetResolver(`${baseName}.png`, 'background');
286
- const img2 = new Image();
287
- img2.onload = () => {
288
- incomingLayer.style.backgroundImage = `url("${fallbackPng}")`;
289
- };
290
- img2.onerror = () => {
291
- incomingLayer.style.backgroundImage = 'radial-gradient(ellipse at center, #334155 0%, #0f172a 100%)';
292
- };
293
- img2.src = fallbackPng;
294
- };
295
- testImg.src = primaryUrl;
296
- }
694
+ syncUrlLabel(state) {
695
+ if (!this.options.syncUrlLabel || typeof window === 'undefined' || typeof history === 'undefined') {
696
+ return;
697
+ }
698
+ const label = state.currentLabel;
699
+ if (!label || label.startsWith('__'))
700
+ return;
701
+ try {
702
+ const url = new URL(window.location.href);
703
+ if (url.searchParams.get('at') === label)
704
+ return;
705
+ url.searchParams.delete('label');
706
+ url.searchParams.set('at', label);
707
+ history.replaceState(null, '', `${url.pathname}${url.search}${url.hash}`);
708
+ }
709
+ catch {
710
+ // ignore malformed URL environments
711
+ }
712
+ }
713
+ syncShareMeta(state) {
714
+ const share = this.options.share;
715
+ if (!share && !this.options.syncUrlLabel)
716
+ return;
717
+ const pageUrl = typeof window !== 'undefined' ? window.location.href : undefined;
718
+ const meta = buildSceneShareMeta(this.vm.getStory(), state, share, pageUrl);
719
+ applyDocumentShareMeta({
720
+ ...meta,
721
+ image: meta.image ? resolveShareAssetUrl(meta.image) : undefined
722
+ }, share);
723
+ }
724
+ applyThemeAndStyles(state) {
725
+ if (state.theme) {
726
+ this.rootEl.dataset.kawaTheme = state.theme;
297
727
  }
298
728
  else {
299
- this.currentBgUrl = '';
300
- this.bgLayerA.classList.remove('active');
301
- this.bgLayerB.classList.remove('active');
302
- }
303
- // 2. Character Sprites DOM Reconciliation
304
- const currentChars = state.visual.characters;
305
- const currentIds = new Set(Object.keys(currentChars));
306
- // Remove sprites no longer active
307
- for (const [id, record] of this.activeCharacters.entries()) {
308
- if (!currentIds.has(id)) {
309
- record.div.remove();
310
- this.activeCharacters.delete(id);
311
- }
729
+ delete this.rootEl.dataset.kawaTheme;
312
730
  }
313
- // Add or update active sprites
314
- for (const [charId, charState] of Object.entries(currentChars)) {
315
- const posClass = `kawa-pos-${charState.position ?? 'center'}`;
316
- const charAssetKey = charState.expression ? `${charId}/${charState.expression}` : charId;
317
- const primarySpriteUrl = this.assetResolver(charAssetKey, 'character');
318
- let existing = this.activeCharacters.get(charId);
319
- if (!existing) {
320
- const spriteDiv = document.createElement('div');
321
- spriteDiv.className = `kawa-sprite ${posClass}`;
322
- spriteDiv.dataset.character = charId;
323
- if (charState.expression) {
324
- spriteDiv.dataset.expression = charState.expression;
325
- }
326
- const img = document.createElement('img');
327
- img.src = primarySpriteUrl;
328
- img.alt = `${charId} ${charState.expression ?? ''}`;
329
- let fallbackStep = 0;
330
- img.onerror = () => {
331
- fallbackStep++;
332
- if (fallbackStep === 1) {
333
- img.src = this.assetResolver(`${charAssetKey}.svg`, 'character');
334
- }
335
- else if (fallbackStep === 2 && charState.expression) {
336
- img.src = this.assetResolver(`${charId}_${charState.expression}`, 'character');
337
- }
338
- else if (fallbackStep === 3 && charState.expression) {
339
- img.src = this.assetResolver(`${charId}_${charState.expression}.svg`, 'character');
731
+ const nextTargets = new Set(Object.keys(state.styleClasses ?? {}));
732
+ for (const target of this.appliedStyleTargets) {
733
+ if (!nextTargets.has(target)) {
734
+ this.rootEl.classList.forEach((cls) => {
735
+ if (cls.startsWith(`kawa-style-${target}-`)) {
736
+ this.rootEl.classList.remove(cls);
340
737
  }
341
- else {
342
- img.style.display = 'none';
343
- spriteDiv.style.width = '220px';
344
- spriteDiv.style.height = '420px';
345
- spriteDiv.style.background = 'rgba(244, 63, 94, 0.25)';
346
- spriteDiv.style.border = '2px dashed #f43f5e';
347
- spriteDiv.style.borderRadius = '16px';
348
- spriteDiv.style.display = 'flex';
349
- spriteDiv.style.alignItems = 'center';
350
- spriteDiv.style.justifyContent = 'center';
351
- spriteDiv.style.color = '#ffffff';
352
- spriteDiv.style.fontWeight = '600';
353
- spriteDiv.style.fontSize = '1.1rem';
354
- spriteDiv.textContent = `${charId}\n(${charState.expression ?? 'normal'})`;
355
- }
356
- };
357
- spriteDiv.appendChild(img);
358
- this.charactersEl.appendChild(spriteDiv);
359
- existing = {
360
- div: spriteDiv,
361
- img,
362
- expression: charState.expression,
363
- position: charState.position
364
- };
365
- this.activeCharacters.set(charId, existing);
738
+ });
366
739
  }
367
- else {
368
- // Update existing sprite classes and image if changed
369
- existing.div.className = `kawa-sprite ${posClass}`;
370
- if (charState.expression) {
371
- existing.div.dataset.expression = charState.expression;
372
- }
373
- else {
374
- delete existing.div.dataset.expression;
375
- }
376
- if (existing.expression !== charState.expression) {
377
- existing.expression = charState.expression;
378
- existing.img.style.display = 'block';
379
- existing.img.src = primarySpriteUrl;
740
+ }
741
+ for (const [target, name] of Object.entries(state.styleClasses ?? {})) {
742
+ const prefix = `kawa-style-${target}-`;
743
+ this.rootEl.classList.forEach((cls) => {
744
+ if (cls.startsWith(prefix) && cls !== `${prefix}${name}`) {
745
+ this.rootEl.classList.remove(cls);
380
746
  }
381
- }
747
+ });
748
+ this.rootEl.classList.add(`${prefix}${name}`);
382
749
  }
383
- // Render dialogue
384
- if (state.dialogue) {
385
- this.dialogueBoxEl.style.display = 'block';
386
- if (state.dialogue.speakerDisplayName) {
387
- this.speakerTagEl.style.display = 'inline-block';
388
- this.speakerTagEl.textContent = state.dialogue.speakerDisplayName;
389
- if (state.dialogue.speakerColor) {
390
- this.speakerTagEl.style.backgroundColor = state.dialogue.speakerColor;
750
+ this.appliedStyleTargets = nextTargets;
751
+ }
752
+ render(state) {
753
+ this.quickMenu.setBackDisabled(!this.vm.canRollback());
754
+ this.applyThemeAndStyles(state);
755
+ // Timed pause: auto-advance after pendingPauseMs (click still skips via handleUserAdvance)
756
+ if (this.pauseTimer) {
757
+ clearTimeout(this.pauseTimer);
758
+ this.pauseTimer = null;
759
+ }
760
+ if (state.pendingPauseMs != null && state.pendingPauseMs > 0 && !state.isFinished) {
761
+ const delay = state.pendingPauseMs;
762
+ this.pauseTimer = window.setTimeout(() => {
763
+ this.pauseTimer = null;
764
+ if (this.destroyed)
765
+ return;
766
+ const cur = this.vm.getState();
767
+ if (cur.pendingPauseMs != null && !cur.isFinished) {
768
+ this.vm.next();
391
769
  }
392
- }
393
- else {
394
- this.speakerTagEl.style.display = 'none';
395
- }
396
- this.startTypewriter(state.dialogue.text);
770
+ }, delay);
771
+ }
772
+ // 1. Background scene
773
+ this.stageLayer.updateBackground(state.visual.background, state.visual.transition);
774
+ // 2. Character sprites
775
+ this.stageLayer.updateCharacters(state.visual.characters);
776
+ // 3. VFX & Atmospheric Layer
777
+ this.stageLayer.updateVfx(state.visual.vfx);
778
+ // 4. Fullscreen CG Layer
779
+ this.stageLayer.updateCG(state.visual.activeCG);
780
+ // 5. Branching Choice Menu
781
+ this.choiceMenu.render(state.choices);
782
+ // 5b. CG / stage hotspots
783
+ this.hotspotLayer.render(state.hotspots);
784
+ // 6. Dialogue Box (respect `window hide` / `window show`)
785
+ if (state.windowVisible === false) {
786
+ this.dialogueBox.render(null);
397
787
  }
398
788
  else {
399
- this.dialogueBoxEl.style.display = 'none';
789
+ this.dialogueBox.render(state.dialogue, () => {
790
+ if (this.isAutoMode &&
791
+ !state.isFinished &&
792
+ (!state.choices || state.choices.length === 0) &&
793
+ (!state.hotspots || state.hotspots.length === 0) &&
794
+ !state.pendingInput) {
795
+ this.scheduleAutoAdvance();
796
+ }
797
+ });
400
798
  }
401
- // Render choices
402
- if (state.choices && state.choices.length > 0) {
403
- this.choiceContainerEl.innerHTML = '';
404
- this.choiceContainerEl.style.display = 'flex';
405
- this.choiceContainerEl.style.pointerEvents = 'auto';
406
- this.choiceContainerEl.style.opacity = '1';
407
- state.choices.forEach((choice, index) => {
408
- const btn = document.createElement('button');
409
- btn.className = 'kawa-choice-btn kawa-choice';
410
- btn.textContent = choice.text;
411
- btn.addEventListener('click', (e) => {
412
- e.stopPropagation();
413
- if (this.isChoicePending)
414
- return;
415
- this.isChoicePending = true;
416
- this.choiceContainerEl.style.pointerEvents = 'none';
417
- this.choiceContainerEl.style.opacity = '0.5';
418
- this.vm.choose(index);
799
+ // 6b. Text input prompt (`input var "prompt"`)
800
+ if (state.pendingInput) {
801
+ if (!this.rootEl.querySelector('.kawa-input-overlay')) {
802
+ const prompt = state.pendingInput.prompt;
803
+ const current = typeof state.variables[state.pendingInput.variable] === 'string'
804
+ ? String(state.variables[state.pendingInput.variable])
805
+ : '';
806
+ showInputModal(this.rootEl, {
807
+ prompt,
808
+ defaultValue: current,
809
+ onSubmit: (value) => this.vm.submitInput(value)
419
810
  });
420
- this.choiceContainerEl.appendChild(btn);
421
- });
811
+ }
422
812
  }
423
813
  else {
424
- this.choiceContainerEl.style.display = 'none';
814
+ this.rootEl.querySelector('.kawa-input-overlay')?.remove();
425
815
  }
426
- // Render Ending Card if Story is Finished
816
+ // 7. Ending Screen if finished
427
817
  if (state.isFinished) {
428
- this.choiceContainerEl.style.display = 'none';
429
- const existingEnd = this.rootEl.querySelector('.kawa-ending-card');
430
- if (!existingEnd) {
431
- const endCard = document.createElement('div');
432
- endCard.className = 'kawa-ending-card';
433
- endCard.innerHTML = `
434
- <div class="kawa-ending-title">🌸 The End</div>
435
- <div class="kawa-ending-subtitle">Story complete! Thank you for playing.</div>
436
- <div style="display:flex;gap:12px;margin-top:16px;">
437
- <button class="kawa-btn kawa-btn-replay">🔄 Play Again</button>
438
- <button class="kawa-btn kawa-btn-load-end">📂 Load Slot</button>
439
- </div>
440
- `;
441
- endCard.querySelector('.kawa-btn-replay')?.addEventListener('click', () => {
442
- this.vm.jump('start');
443
- });
444
- endCard.querySelector('.kawa-btn-load-end')?.addEventListener('click', () => {
445
- this.showSaveLoadModal('load');
446
- });
447
- this.rootEl.appendChild(endCard);
448
- }
818
+ this.renderEndingCard();
449
819
  }
450
820
  else {
451
- const existingEnd = this.rootEl.querySelector('.kawa-ending-card');
452
- if (existingEnd)
453
- existingEnd.remove();
821
+ const endingEl = this.stageLayer.stageEl.querySelector('.kawa-ending-card');
822
+ if (endingEl)
823
+ endingEl.remove();
454
824
  }
455
825
  }
456
- startTypewriter(text) {
457
- if (this.currentTypewriterInterval) {
458
- clearInterval(this.currentTypewriterInterval);
459
- }
460
- this.fullCurrentText = text;
461
- if (this.typewriterSpeed <= 0) {
462
- this.dialogueTextEl.textContent = text;
463
- this.isTypewriting = false;
826
+ renderEndingCard() {
827
+ // Don't remount — recreating replayed the pop animation and looked like a jump.
828
+ if (this.stageLayer.stageEl.querySelector('.kawa-ending-card')) {
464
829
  return;
465
830
  }
466
- this.isTypewriting = true;
467
- let index = 0;
468
- this.dialogueTextEl.textContent = '';
469
- this.currentTypewriterInterval = window.setInterval(() => {
470
- index += 1;
471
- this.dialogueTextEl.textContent = this.fullCurrentText.slice(0, index);
472
- if (index >= this.fullCurrentText.length) {
473
- this.finishTypewriter();
474
- }
475
- }, this.typewriterSpeed);
476
- }
477
- finishTypewriter() {
478
- if (this.currentTypewriterInterval) {
479
- clearInterval(this.currentTypewriterInterval);
480
- this.currentTypewriterInterval = null;
481
- }
482
- this.dialogueTextEl.textContent = this.fullCurrentText;
483
- this.isTypewriting = false;
484
- }
485
- async showSaveLoadModal(mode) {
486
- const existing = this.rootEl.querySelector('.kawa-modal-overlay');
487
- if (existing)
488
- existing.remove();
489
- const overlay = document.createElement('div');
490
- overlay.className = 'kawa-modal-overlay';
491
- const card = document.createElement('div');
492
- card.className = 'kawa-modal-card';
493
- card.setAttribute('role', 'dialog');
494
- card.setAttribute('aria-modal', 'true');
495
- card.setAttribute('aria-label', mode === 'save' ? 'Save Game' : 'Load Game');
496
- const header = document.createElement('div');
497
- header.className = 'kawa-modal-header';
498
- const title = document.createElement('div');
499
- title.className = 'kawa-modal-title';
500
- title.textContent = mode === 'save' ? '💾 Save Game' : '📂 Load Game';
501
- const closeBtn = document.createElement('button');
502
- closeBtn.className = 'kawa-btn';
503
- closeBtn.textContent = '✕ Close';
504
- closeBtn.setAttribute('aria-label', 'Close modal');
505
- closeBtn.addEventListener('click', () => overlay.remove());
506
- header.appendChild(title);
507
- header.appendChild(closeBtn);
508
- const body = document.createElement('div');
509
- body.className = 'kawa-modal-body';
510
- const grid = document.createElement('div');
511
- grid.className = 'kawa-slots-grid';
512
- const saveManager = this.vm.getSaveManager();
513
- const slots = await saveManager.listSlots(6);
514
- slots.forEach((slot, idx) => {
515
- const slotNum = String(idx + 1);
516
- const slotCard = document.createElement('div');
517
- slotCard.className = 'kawa-slot-card';
518
- const slotHeader = document.createElement('div');
519
- slotHeader.className = 'kawa-slot-header';
520
- const badge = document.createElement('span');
521
- badge.className = 'kawa-slot-badge';
522
- badge.textContent = `Slot ${slotNum}`;
523
- const timeSpan = document.createElement('span');
524
- timeSpan.className = 'kawa-slot-time';
525
- timeSpan.textContent = slot ? new Date(slot.timestamp).toLocaleString() : 'Empty';
526
- slotHeader.appendChild(badge);
527
- slotHeader.appendChild(timeSpan);
528
- slotCard.appendChild(slotHeader);
529
- if (slot) {
530
- const preview = document.createElement('div');
531
- preview.className = 'kawa-slot-preview';
532
- preview.textContent = slot.previewText || `Label: ${slot.snapshot.state.currentLabel}`;
533
- slotCard.appendChild(preview);
534
- }
535
- else {
536
- const emptyText = document.createElement('div');
537
- emptyText.className = 'kawa-slot-empty-text';
538
- emptyText.textContent = 'No save data';
539
- slotCard.appendChild(emptyText);
540
- }
541
- const actions = document.createElement('div');
542
- actions.className = 'kawa-slot-actions';
543
- if (mode === 'save') {
544
- const saveActionBtn = document.createElement('button');
545
- saveActionBtn.className = 'kawa-slot-btn kawa-slot-btn-save';
546
- saveActionBtn.textContent = 'Save Here';
547
- saveActionBtn.setAttribute('aria-label', `Save to slot ${slotNum}`);
548
- saveActionBtn.addEventListener('click', async () => {
549
- await this.vm.save(slotNum);
550
- overlay.remove();
551
- this.showSaveLoadModal('save');
552
- });
553
- actions.appendChild(saveActionBtn);
554
- }
555
- else {
556
- if (slot) {
557
- const loadActionBtn = document.createElement('button');
558
- loadActionBtn.className = 'kawa-slot-btn kawa-slot-btn-load';
559
- loadActionBtn.textContent = 'Load';
560
- loadActionBtn.setAttribute('aria-label', `Load slot ${slotNum}`);
561
- loadActionBtn.addEventListener('click', async () => {
562
- const ok = await this.vm.load(slotNum);
563
- if (ok)
564
- overlay.remove();
565
- });
566
- actions.appendChild(loadActionBtn);
567
- }
568
- }
569
- if (slot) {
570
- const delBtn = document.createElement('button');
571
- delBtn.className = 'kawa-slot-btn kawa-slot-btn-del';
572
- delBtn.textContent = '🗑';
573
- delBtn.title = 'Delete Save';
574
- delBtn.setAttribute('aria-label', `Delete slot ${slotNum}`);
575
- delBtn.addEventListener('click', async () => {
576
- await saveManager.deleteSlot(slotNum);
577
- overlay.remove();
578
- this.showSaveLoadModal(mode);
579
- });
580
- actions.appendChild(delBtn);
581
- }
582
- slotCard.appendChild(actions);
583
- grid.appendChild(slotCard);
831
+ const endingCard = document.createElement('div');
832
+ endingCard.className = 'kawa-ending-card';
833
+ endingCard.setAttribute('role', 'alert');
834
+ endingCard.setAttribute('aria-label', 'Story Finished');
835
+ endingCard.innerHTML = `
836
+ <div class="kawa-ending-title">The End</div>
837
+ <div class="kawa-ending-subtitle">Thank you for playing!</div>
838
+ `;
839
+ const restartBtn = document.createElement('button');
840
+ restartBtn.type = 'button';
841
+ restartBtn.className = 'kawa-choice-btn kawa-ending-btn';
842
+ restartBtn.style.marginTop = '16px';
843
+ restartBtn.innerHTML = `${SVG_ICONS.replay} Play Again`;
844
+ restartBtn.addEventListener('click', (e) => {
845
+ e.preventDefault();
846
+ e.stopPropagation();
847
+ endingCard.remove();
848
+ this.startNewGame();
849
+ });
850
+ const menuBtn = document.createElement('button');
851
+ menuBtn.type = 'button';
852
+ menuBtn.className = 'kawa-choice-btn kawa-ending-btn';
853
+ menuBtn.style.marginTop = '10px';
854
+ menuBtn.innerHTML = `${SVG_ICONS.home} Main Menu`;
855
+ menuBtn.addEventListener('click', (e) => {
856
+ e.preventDefault();
857
+ e.stopPropagation();
858
+ endingCard.remove();
859
+ this.showMainMenu();
584
860
  });
585
- body.appendChild(grid);
586
- card.appendChild(header);
587
- card.appendChild(body);
588
- overlay.appendChild(card);
589
- this.rootEl.appendChild(overlay);
861
+ endingCard.appendChild(restartBtn);
862
+ endingCard.appendChild(menuBtn);
863
+ this.stageLayer.stageEl.appendChild(endingCard);
590
864
  }
591
- showHistoryModal() {
592
- const existing = this.rootEl.querySelector('.kawa-modal-overlay');
593
- if (existing)
594
- existing.remove();
595
- const overlay = document.createElement('div');
596
- overlay.className = 'kawa-modal-overlay';
597
- const card = document.createElement('div');
598
- card.className = 'kawa-modal-card';
599
- card.setAttribute('role', 'dialog');
600
- card.setAttribute('aria-modal', 'true');
601
- card.setAttribute('aria-label', 'Dialogue History');
602
- const header = document.createElement('div');
603
- header.className = 'kawa-modal-header';
604
- const title = document.createElement('div');
605
- title.className = 'kawa-modal-title';
606
- title.textContent = '📜 Dialogue History';
607
- const closeBtn = document.createElement('button');
608
- closeBtn.className = 'kawa-btn';
609
- closeBtn.textContent = '✕ Close';
610
- closeBtn.setAttribute('aria-label', 'Close history modal');
611
- closeBtn.addEventListener('click', () => overlay.remove());
612
- header.appendChild(title);
613
- header.appendChild(closeBtn);
614
- const body = document.createElement('div');
615
- body.className = 'kawa-modal-body';
616
- const entries = this.vm.getHistoryManager().getEntries();
617
- if (entries.length === 0) {
618
- const emptyMsg = document.createElement('div');
619
- emptyMsg.textContent = 'No dialogue history yet.';
620
- emptyMsg.style.opacity = '0.6';
621
- body.appendChild(emptyMsg);
622
- }
623
- else {
624
- for (const entry of entries) {
625
- const item = document.createElement('div');
626
- item.className = 'kawa-history-item';
627
- if (entry.speakerDisplayName) {
628
- const spk = document.createElement('div');
629
- spk.className = 'kawa-history-speaker';
630
- spk.textContent = entry.speakerDisplayName;
631
- item.appendChild(spk);
632
- }
633
- const txt = document.createElement('div');
634
- txt.textContent = entry.text;
635
- item.appendChild(txt);
636
- body.appendChild(item);
865
+ saveSettings() {
866
+ if (typeof localStorage !== 'undefined') {
867
+ try {
868
+ localStorage.setItem('kawaijs_settings', JSON.stringify({
869
+ typewriterSpeed: this.typewriterSpeed,
870
+ autoDelayMs: this.autoDelayMs,
871
+ musicVolume: this.musicVolume,
872
+ soundVolume: this.soundVolume
873
+ }));
637
874
  }
875
+ catch { }
638
876
  }
639
- card.appendChild(header);
640
- card.appendChild(body);
641
- overlay.appendChild(card);
642
- this.rootEl.appendChild(overlay);
643
877
  }
644
878
  }
645
879
  //# sourceMappingURL=renderer.js.map