@kawaijs/renderer-dom 0.1.11 → 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 +63 -56
  83. package/dist/renderer.d.ts.map +1 -1
  84. package/dist/renderer.js +643 -734
  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 -685
package/dist/renderer.js CHANGED
@@ -1,125 +1,126 @@
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
- }
50
- }
51
- export const SVG_ICONS = {
52
- back: '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 12H5M12 19l-7-7 7-7"/></svg>',
53
- history: '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><polyline points="12 7 12 12 15 15"/></svg>',
54
- save: '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></svg>',
55
- load: '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>',
56
- auto: '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="5 3 19 12 5 21 5 3"/></svg>',
57
- skip: '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="5 4 15 12 5 20 5 4"/><line x1="19" y1="5" x2="19" y2="19"/></svg>',
58
- settings: '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>',
59
- volumeOn: '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/><path d="M19.07 4.93a10 10 0 0 1 0 14.14M15.54 8.46a5 5 0 0 1 0 7.07"/></svg>',
60
- volumeMute: '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/><line x1="23" y1="9" x2="17" y2="15"/><line x1="17" y1="9" x2="23" y2="15"/></svg>',
61
- trash: '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>',
62
- close: '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>',
63
- arrowDown: '<svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg>',
64
- replay: '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"/></svg>'
65
- };
66
- /**
67
- * Parses inline rich text markup: {b}...{/b}, {i}...{/i}, {color=#hex}...{/color}, {size=1.2em}...{/size}
68
- */
69
- export function formatRichText(raw) {
70
- let formatted = raw
71
- .replace(/&/g, '&amp;')
72
- .replace(/</g, '&lt;')
73
- .replace(/>/g, '&gt;');
74
- // {b}...{/b}
75
- formatted = formatted.replace(/\{b\}(.*?)\{\/b\}/gi, '<strong class="kawa-bold">$1</strong>');
76
- // {i}...{/i}
77
- formatted = formatted.replace(/\{i\}(.*?)\{\/i\}/gi, '<em class="kawa-italic">$1</em>');
78
- // {color=#hex}...{/color}
79
- formatted = formatted.replace(/\{color=([^}]+)\}(.*?)\{\/color\}/gi, '<span style="color:$1">$2</span>');
80
- // {size=1.2em}...{/size}
81
- formatted = formatted.replace(/\{size=([^}]+)\}(.*?)\{\/size\}/gi, '<span style="font-size:$1">$2</span>');
82
- return formatted;
64
+ return Array.from(itemsMap.values());
83
65
  }
84
66
  export class DOMRenderer {
85
67
  vm;
86
68
  container;
69
+ options;
87
70
  typewriterSpeed;
88
71
  autoDelayMs;
72
+ musicVolume;
73
+ soundVolume;
89
74
  assetResolver;
75
+ mainMenuOptions;
76
+ audioManager;
90
77
  rootEl;
91
- stageEl;
92
- backgroundEl;
93
- bgLayerA;
94
- bgLayerB;
95
- activeBgLayer = 'A';
96
- currentBgUrl = '';
97
- charactersEl;
98
- activeCharacters = new Map();
99
- modeBadgeEl;
100
- dialogueBoxEl;
101
- speakerTagEl;
102
- dialogueTextEl;
103
- choiceContainerEl;
104
- backBtnEl;
105
- autoBtnEl;
106
- skipBtnEl;
107
- currentTypewriterInterval = null;
108
- isTypewriting = false;
109
- fullCurrentText = '';
110
- isChoicePending = false;
78
+ stageLayer;
79
+ dialogueBox;
80
+ choiceMenu;
81
+ hotspotLayer;
82
+ quickMenu;
83
+ mainMenu;
84
+ viewportAdapter;
85
+ appliedStyleTargets = new Set();
111
86
  isAutoMode = false;
112
87
  isSkipMode = false;
113
88
  autoTimer = null;
114
89
  skipInterval = null;
90
+ pauseTimer = null;
91
+ errorToastTimer = null;
92
+ advanceLockUntil = 0;
93
+ destroyed = false;
115
94
  unsubscribeVMState;
95
+ unsubscribeCamera;
96
+ unsubscribeAudio;
97
+ unsubscribeError;
116
98
  boundKeyHandler;
99
+ touchControls;
117
100
  constructor(vm, options) {
118
101
  this.vm = vm;
119
102
  this.container = options.container;
103
+ this.options = options;
120
104
  this.typewriterSpeed = options.typewriterSpeed ?? 20;
121
105
  this.autoDelayMs = options.autoDelayMs ?? 1800;
122
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;
123
124
  // Load saved settings if present
124
125
  if (typeof localStorage !== 'undefined') {
125
126
  try {
@@ -130,6 +131,14 @@ export class DOMRenderer {
130
131
  this.typewriterSpeed = parsed.typewriterSpeed;
131
132
  if (typeof parsed.autoDelayMs === 'number')
132
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
+ }
133
142
  }
134
143
  }
135
144
  catch { }
@@ -138,47 +147,152 @@ export class DOMRenderer {
138
147
  this.bindEvents();
139
148
  this.unsubscribeVMState = this.vm.onStateChange((state) => {
140
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
+ }
160
+ });
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);
141
191
  });
142
- // Render current initial state
143
- this.render(this.vm.getState());
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
+ }
144
199
  }
145
200
  destroy() {
201
+ if (this.destroyed)
202
+ return;
203
+ this.destroyed = true;
146
204
  if (this.unsubscribeVMState) {
147
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;
148
222
  }
149
- if (this.currentTypewriterInterval) {
150
- clearInterval(this.currentTypewriterInterval);
223
+ if (this.dialogueBox) {
224
+ this.dialogueBox.destroy();
225
+ }
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;
151
235
  }
152
236
  if (this.autoTimer) {
153
237
  clearTimeout(this.autoTimer);
238
+ this.autoTimer = null;
154
239
  }
155
240
  if (this.skipInterval) {
156
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;
157
251
  }
158
252
  if (this.boundKeyHandler && typeof window !== 'undefined') {
159
253
  window.removeEventListener('keydown', this.boundKeyHandler);
254
+ this.boundKeyHandler = undefined;
255
+ }
256
+ if (this.touchControls) {
257
+ this.touchControls.destroy();
258
+ this.touchControls = undefined;
160
259
  }
161
260
  this.container.innerHTML = '';
162
261
  }
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
+ }
163
291
  shakeScreen(type = 'shake') {
164
- if (!this.stageEl)
165
- return;
166
- this.stageEl.classList.remove('kawa-shake', 'kawa-vpunch', 'kawa-hpunch');
167
- // Trigger reflow
168
- void this.stageEl.offsetWidth;
169
- const cls = type === 'vpunch' ? 'kawa-vpunch' : type === 'hpunch' ? 'kawa-hpunch' : 'kawa-shake';
170
- this.stageEl.classList.add(cls);
292
+ this.stageLayer.shakeScreen(type);
171
293
  }
172
294
  flashScreen() {
173
- if (!this.stageEl)
174
- return;
175
- const existing = this.stageEl.querySelector('.kawa-flash-overlay');
176
- if (existing)
177
- existing.remove();
178
- const flash = document.createElement('div');
179
- flash.className = 'kawa-flash-overlay';
180
- this.stageEl.appendChild(flash);
181
- setTimeout(() => flash.remove(), 600);
295
+ this.stageLayer.flashScreen();
182
296
  }
183
297
  toggleAutoMode(force) {
184
298
  this.isAutoMode = force !== undefined ? force : !this.isAutoMode;
@@ -186,7 +300,7 @@ export class DOMRenderer {
186
300
  this.toggleSkipMode(false);
187
301
  }
188
302
  this.updateModeUI();
189
- if (this.isAutoMode && !this.isTypewriting) {
303
+ if (this.isAutoMode && !this.dialogueBox.getIsTypewriting()) {
190
304
  this.scheduleAutoAdvance();
191
305
  }
192
306
  else if (!this.isAutoMode && this.autoTimer) {
@@ -200,23 +314,33 @@ export class DOMRenderer {
200
314
  this.toggleAutoMode(false);
201
315
  }
202
316
  this.updateModeUI();
317
+ // Suspend decorative particle loops while skipping to cut CPU during fast-forward.
318
+ this.stageLayer.vfxLayer.setSuspended(this.isSkipMode);
203
319
  if (this.isSkipMode) {
204
320
  if (this.autoTimer) {
205
321
  clearTimeout(this.autoTimer);
206
322
  this.autoTimer = null;
207
323
  }
208
324
  if (!this.skipInterval) {
325
+ // Coalesce advances (~10/s) instead of hammering every 55ms.
209
326
  this.skipInterval = window.setInterval(() => {
327
+ if (this.destroyed) {
328
+ this.toggleSkipMode(false);
329
+ return;
330
+ }
210
331
  const state = this.vm.getState();
211
- if (state.isFinished || (state.choices && state.choices.length > 0)) {
332
+ if (state.isFinished ||
333
+ (state.choices && state.choices.length > 0) ||
334
+ (state.hotspots && state.hotspots.length > 0) ||
335
+ state.pendingInput) {
212
336
  this.toggleSkipMode(false);
213
337
  return;
214
338
  }
215
- if (this.isTypewriting) {
216
- this.finishTypewriter();
339
+ if (this.dialogueBox.getIsTypewriting()) {
340
+ this.dialogueBox.finishTypewriter();
217
341
  }
218
342
  this.vm.next();
219
- }, 55);
343
+ }, 100);
220
344
  }
221
345
  }
222
346
  else {
@@ -226,25 +350,91 @@ export class DOMRenderer {
226
350
  }
227
351
  }
228
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
+ }
229
425
  updateModeUI() {
230
- if (this.autoBtnEl) {
231
- this.autoBtnEl.classList.toggle('active', this.isAutoMode);
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';
232
431
  }
233
- if (this.skipBtnEl) {
234
- this.skipBtnEl.classList.toggle('active', this.isSkipMode);
432
+ else if (this.isAutoMode) {
433
+ this.stageLayer.modeBadgeEl.innerHTML = `${SVG_ICONS.auto} AUTO`;
434
+ this.stageLayer.modeBadgeEl.style.display = 'inline-flex';
235
435
  }
236
- if (this.modeBadgeEl) {
237
- if (this.isSkipMode) {
238
- this.modeBadgeEl.innerHTML = `${SVG_ICONS.skip} SKIP`;
239
- this.modeBadgeEl.style.display = 'inline-flex';
240
- }
241
- else if (this.isAutoMode) {
242
- this.modeBadgeEl.innerHTML = `${SVG_ICONS.auto} AUTO`;
243
- this.modeBadgeEl.style.display = 'inline-flex';
244
- }
245
- else {
246
- this.modeBadgeEl.style.display = 'none';
247
- }
436
+ else {
437
+ this.stageLayer.modeBadgeEl.style.display = 'none';
248
438
  }
249
439
  }
250
440
  scheduleAutoAdvance() {
@@ -255,14 +445,20 @@ export class DOMRenderer {
255
445
  if (!this.isAutoMode)
256
446
  return;
257
447
  const state = this.vm.getState();
258
- if (state.isFinished || (state.choices && state.choices.length > 0)) {
448
+ if (state.isFinished ||
449
+ (state.choices && state.choices.length > 0) ||
450
+ (state.hotspots && state.hotspots.length > 0) ||
451
+ state.pendingInput) {
259
452
  return;
260
453
  }
261
454
  const duration = Math.max(800, this.autoDelayMs + (state.dialogue?.text.length ?? 0) * 15);
262
455
  this.autoTimer = window.setTimeout(() => {
263
456
  if (this.isAutoMode) {
264
457
  const cur = this.vm.getState();
265
- if (!cur.isFinished && (!cur.choices || cur.choices.length === 0)) {
458
+ if (!cur.isFinished &&
459
+ (!cur.choices || cur.choices.length === 0) &&
460
+ (!cur.hotspots || cur.hotspots.length === 0) &&
461
+ !cur.pendingInput) {
266
462
  this.vm.next();
267
463
  }
268
464
  }
@@ -272,155 +468,137 @@ export class DOMRenderer {
272
468
  this.container.innerHTML = '';
273
469
  this.rootEl = document.createElement('div');
274
470
  this.rootEl.className = 'kawa-root';
275
- this.stageEl = document.createElement('div');
276
- this.stageEl.className = 'kawa-stage';
277
- this.stageEl.setAttribute('role', 'main');
278
- this.stageEl.setAttribute('aria-label', 'Visual Novel Stage');
279
- this.backgroundEl = document.createElement('div');
280
- this.backgroundEl.className = 'kawa-background';
281
- this.backgroundEl.setAttribute('aria-hidden', 'true');
282
- this.bgLayerA = document.createElement('div');
283
- this.bgLayerA.className = 'kawa-bg-layer active';
284
- this.bgLayerB = document.createElement('div');
285
- this.bgLayerB.className = 'kawa-bg-layer';
286
- this.backgroundEl.appendChild(this.bgLayerA);
287
- this.backgroundEl.appendChild(this.bgLayerB);
288
- this.charactersEl = document.createElement('div');
289
- this.charactersEl.className = 'kawa-characters kawa-sprites';
290
- this.charactersEl.setAttribute('aria-hidden', 'true');
291
- // Mode Badge (Auto / Skip indicator)
292
- this.modeBadgeEl = document.createElement('div');
293
- this.modeBadgeEl.className = 'kawa-mode-badge';
294
- this.modeBadgeEl.style.display = 'none';
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);
295
477
  const uiLayerEl = document.createElement('div');
296
478
  uiLayerEl.className = 'kawa-ui-layer';
297
- // Choice Container
298
- this.choiceContainerEl = document.createElement('div');
299
- this.choiceContainerEl.className = 'kawa-choice-container kawa-choices';
300
- this.choiceContainerEl.setAttribute('role', 'group');
301
- this.choiceContainerEl.setAttribute('aria-label', 'Choices');
302
- this.choiceContainerEl.style.display = 'none';
303
- // Dialogue Box
304
- this.dialogueBoxEl = document.createElement('div');
305
- this.dialogueBoxEl.className = 'kawa-dialogue-box kawa-dialogue';
306
- this.dialogueBoxEl.setAttribute('role', 'region');
307
- this.dialogueBoxEl.setAttribute('aria-label', 'Dialogue');
308
- this.dialogueBoxEl.setAttribute('tabindex', '0');
309
- this.speakerTagEl = document.createElement('div');
310
- this.speakerTagEl.className = 'kawa-speaker-tag kawa-speaker';
311
- this.dialogueTextEl = document.createElement('div');
312
- this.dialogueTextEl.className = 'kawa-dialogue-text kawa-text';
313
- this.dialogueTextEl.setAttribute('aria-live', 'polite');
314
- const indicatorEl = document.createElement('div');
315
- indicatorEl.className = 'kawa-continue-indicator';
316
- indicatorEl.setAttribute('aria-hidden', 'true');
317
- indicatorEl.innerHTML = SVG_ICONS.arrowDown;
318
- this.dialogueBoxEl.appendChild(this.speakerTagEl);
319
- this.dialogueBoxEl.appendChild(this.dialogueTextEl);
320
- this.dialogueBoxEl.appendChild(indicatorEl);
321
- // Quick Menu
322
- const quickMenuEl = document.createElement('nav');
323
- quickMenuEl.className = 'kawa-quick-menu';
324
- quickMenuEl.setAttribute('role', 'toolbar');
325
- quickMenuEl.setAttribute('aria-label', 'Game Menu');
326
- this.backBtnEl = document.createElement('button');
327
- this.backBtnEl.className = 'kawa-btn';
328
- this.backBtnEl.innerHTML = `${SVG_ICONS.back} <span>Back</span>`;
329
- this.backBtnEl.setAttribute('aria-label', 'Rollback to previous dialogue');
330
- const histBtn = document.createElement('button');
331
- histBtn.className = 'kawa-btn';
332
- histBtn.innerHTML = `${SVG_ICONS.history} <span>History</span>`;
333
- histBtn.setAttribute('aria-label', 'Open dialogue history log');
334
- histBtn.addEventListener('click', (e) => {
335
- e.stopPropagation();
336
- this.showHistoryModal();
337
- });
338
- this.autoBtnEl = document.createElement('button');
339
- this.autoBtnEl.className = 'kawa-btn';
340
- this.autoBtnEl.innerHTML = `${SVG_ICONS.auto} <span>Auto</span>`;
341
- this.autoBtnEl.setAttribute('aria-label', 'Toggle Auto Forward Mode');
342
- this.autoBtnEl.addEventListener('click', (e) => {
343
- e.stopPropagation();
344
- this.toggleAutoMode();
345
- });
346
- this.skipBtnEl = document.createElement('button');
347
- this.skipBtnEl.className = 'kawa-btn';
348
- this.skipBtnEl.innerHTML = `${SVG_ICONS.skip} <span>Skip</span>`;
349
- this.skipBtnEl.setAttribute('aria-label', 'Toggle Fast Forward Skip Mode');
350
- this.skipBtnEl.addEventListener('click', (e) => {
351
- e.stopPropagation();
352
- this.toggleSkipMode();
353
- });
354
- const saveBtn = document.createElement('button');
355
- saveBtn.className = 'kawa-btn';
356
- saveBtn.innerHTML = `${SVG_ICONS.save} <span>Save</span>`;
357
- saveBtn.setAttribute('aria-label', 'Open save game menu');
358
- saveBtn.addEventListener('click', (e) => {
359
- e.stopPropagation();
360
- this.showSaveLoadModal('save');
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);
361
492
  });
362
- const loadBtn = document.createElement('button');
363
- loadBtn.className = 'kawa-btn';
364
- loadBtn.innerHTML = `${SVG_ICONS.load} <span>Load</span>`;
365
- loadBtn.setAttribute('aria-label', 'Open load game menu');
366
- loadBtn.addEventListener('click', (e) => {
367
- e.stopPropagation();
368
- this.showSaveLoadModal('load');
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()
369
511
  });
370
- const settingsBtn = document.createElement('button');
371
- settingsBtn.className = 'kawa-btn';
372
- settingsBtn.innerHTML = `${SVG_ICONS.settings} <span>Settings</span>`;
373
- settingsBtn.setAttribute('aria-label', 'Open Settings Menu');
374
- settingsBtn.addEventListener('click', (e) => {
375
- e.stopPropagation();
376
- this.showSettingsModal();
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
+ })
377
544
  });
378
- quickMenuEl.appendChild(this.backBtnEl);
379
- quickMenuEl.appendChild(histBtn);
380
- quickMenuEl.appendChild(this.autoBtnEl);
381
- quickMenuEl.appendChild(this.skipBtnEl);
382
- quickMenuEl.appendChild(saveBtn);
383
- quickMenuEl.appendChild(loadBtn);
384
- quickMenuEl.appendChild(settingsBtn);
385
- uiLayerEl.appendChild(this.choiceContainerEl);
386
- uiLayerEl.appendChild(this.dialogueBoxEl);
387
- uiLayerEl.appendChild(quickMenuEl);
388
- this.stageEl.appendChild(this.backgroundEl);
389
- this.stageEl.appendChild(this.charactersEl);
390
- this.stageEl.appendChild(this.modeBadgeEl);
391
- this.stageEl.appendChild(uiLayerEl);
392
- this.rootEl.appendChild(this.stageEl);
545
+ this.mainMenu.mount(this.stageLayer.stageEl);
546
+ this.rootEl.appendChild(this.stageLayer.stageEl);
393
547
  this.container.appendChild(this.rootEl);
548
+ this.viewportAdapter = new ViewportAdapter(this.container, this.stageLayer.stageEl, this.options.virtualCanvas);
394
549
  }
395
550
  bindEvents() {
396
- // Clicking on dialogue advances or finishes typewriter
397
- this.dialogueBoxEl.addEventListener('click', () => {
398
- if (this.isAutoMode) {
399
- this.toggleAutoMode(false);
400
- }
401
- if (this.isSkipMode) {
402
- this.toggleSkipMode(false);
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;
403
563
  }
404
- this.handleUserAdvance();
405
- });
406
- this.backBtnEl.addEventListener('click', (e) => {
407
- e.stopPropagation();
408
564
  if (this.isAutoMode)
409
565
  this.toggleAutoMode(false);
410
566
  if (this.isSkipMode)
411
567
  this.toggleSkipMode(false);
412
- this.vm.rollback();
568
+ this.handleUserAdvance();
413
569
  });
414
570
  // Keyboard controls
415
571
  this.boundKeyHandler = (e) => {
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
+ }
416
588
  const modal = this.rootEl.querySelector('.kawa-modal-overlay');
417
589
  if (modal) {
418
590
  if (e.key === 'Escape')
419
591
  modal.remove();
420
592
  return;
421
593
  }
594
+ if (this.isMainMenuActive()) {
595
+ return;
596
+ }
422
597
  if (e.code === 'Space' || e.code === 'Enter') {
423
- 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')) {
424
602
  return;
425
603
  }
426
604
  e.preventDefault();
@@ -441,15 +619,16 @@ export class DOMRenderer {
441
619
  else if (e.key === 'a' || e.key === 'A') {
442
620
  this.toggleAutoMode();
443
621
  }
444
- else if (e.key === 'Tab' || e.key === 'Control') {
622
+ else if (e.key === 'Control') {
623
+ // Ctrl toggles skip (Ren'Py-style). Tab is left for accessibility focus navigation.
445
624
  e.preventDefault();
446
625
  this.toggleSkipMode();
447
626
  }
448
627
  else if (e.key === 's' || e.key === 'S') {
449
- this.showSaveLoadModal('save');
628
+ void this.showSaveLoadModal('save');
450
629
  }
451
630
  else if (e.key === 'l' || e.key === 'L') {
452
- this.showSaveLoadModal('load');
631
+ void this.showSaveLoadModal('load');
453
632
  }
454
633
  else if (e.key === 'h' || e.key === 'H') {
455
634
  this.showHistoryModal();
@@ -457,514 +636,244 @@ export class DOMRenderer {
457
636
  else if (e.key === 'p' || e.key === 'P' || e.key === 'o' || e.key === 'O') {
458
637
  this.showSettingsModal();
459
638
  }
639
+ else if (e.key === 'Escape') {
640
+ this.showMainMenu();
641
+ }
460
642
  };
461
643
  if (typeof window !== 'undefined') {
462
644
  window.addEventListener('keydown', this.boundKeyHandler);
463
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
+ }
464
670
  }
465
671
  handleUserAdvance() {
466
- if (this.isTypewriting) {
467
- 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;
468
681
  }
469
- else {
470
- this.vm.next();
682
+ if (Date.now() < this.advanceLockUntil) {
683
+ return;
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;
471
691
  }
692
+ this.vm.next();
472
693
  }
473
- render(state) {
474
- this.isChoicePending = false;
475
- this.backBtnEl.disabled = !this.vm.canRollback();
476
- // 1. Dual-Layer Background Crossfading
477
- if (state.visual.background) {
478
- this.backgroundEl.dataset.transition = state.visual.transition ?? 'none';
479
- const rawBg = state.visual.background;
480
- const cleanBg = rawBg.replace(/^bg[\s_]+/i, '').trim();
481
- const baseName = cleanBg.replace(/\.(svg|png|jpg|jpeg|webp)$/i, '');
482
- const primaryUrl = this.assetResolver(cleanBg, 'background');
483
- if (primaryUrl !== this.currentBgUrl) {
484
- this.currentBgUrl = primaryUrl;
485
- const incomingLayer = this.activeBgLayer === 'A' ? this.bgLayerB : this.bgLayerA;
486
- const outgoingLayer = this.activeBgLayer === 'A' ? this.bgLayerA : this.bgLayerB;
487
- incomingLayer.style.backgroundImage = `url("${primaryUrl}")`;
488
- incomingLayer.classList.add('active');
489
- outgoingLayer.classList.remove('active');
490
- this.activeBgLayer = this.activeBgLayer === 'A' ? 'B' : 'A';
491
- // Verify and fallback if image fails to load
492
- const testImg = new Image();
493
- testImg.onerror = () => {
494
- const fallbackPng = this.assetResolver(`${baseName}.png`, 'background');
495
- const img2 = new Image();
496
- img2.onload = () => {
497
- incomingLayer.style.backgroundImage = `url("${fallbackPng}")`;
498
- };
499
- img2.onerror = () => {
500
- incomingLayer.style.backgroundImage = 'radial-gradient(ellipse at center, #334155 0%, #0f172a 100%)';
501
- };
502
- img2.src = fallbackPng;
503
- };
504
- testImg.src = primaryUrl;
505
- }
694
+ syncUrlLabel(state) {
695
+ if (!this.options.syncUrlLabel || typeof window === 'undefined' || typeof history === 'undefined') {
696
+ return;
506
697
  }
507
- else {
508
- this.currentBgUrl = '';
509
- this.bgLayerA.classList.remove('active');
510
- this.bgLayerB.classList.remove('active');
511
- }
512
- // 2. Character Sprites DOM Reconciliation
513
- const currentChars = state.visual.characters;
514
- const currentIds = new Set(Object.keys(currentChars));
515
- // Remove sprites no longer active
516
- for (const [id, record] of this.activeCharacters.entries()) {
517
- if (!currentIds.has(id)) {
518
- record.div.remove();
519
- this.activeCharacters.delete(id);
520
- }
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}`);
521
708
  }
522
- // Add or update active sprites
523
- for (const [charId, charState] of Object.entries(currentChars)) {
524
- const posClass = `kawa-pos-${charState.position ?? 'center'}`;
525
- const charAssetKey = charState.expression ? `${charId}/${charState.expression}` : charId;
526
- const primarySpriteUrl = this.assetResolver(charAssetKey, 'character');
527
- let existing = this.activeCharacters.get(charId);
528
- if (!existing) {
529
- const spriteDiv = document.createElement('div');
530
- spriteDiv.className = `kawa-sprite ${posClass}`;
531
- spriteDiv.dataset.character = charId;
532
- if (charState.expression) {
533
- spriteDiv.dataset.expression = charState.expression;
534
- }
535
- const img = document.createElement('img');
536
- img.src = primarySpriteUrl;
537
- img.alt = `${charId} ${charState.expression ?? ''}`;
538
- let fallbackStep = 0;
539
- img.onerror = () => {
540
- fallbackStep++;
541
- if (fallbackStep === 1) {
542
- img.src = this.assetResolver(`${charAssetKey}.svg`, 'character');
543
- }
544
- else if (fallbackStep === 2 && charState.expression) {
545
- img.src = this.assetResolver(`${charId}_${charState.expression}`, 'character');
546
- }
547
- else if (fallbackStep === 3 && charState.expression) {
548
- img.src = this.assetResolver(`${charId}_${charState.expression}.svg`, 'character');
549
- }
550
- else {
551
- img.style.display = 'none';
552
- spriteDiv.style.width = '220px';
553
- spriteDiv.style.height = '420px';
554
- spriteDiv.style.background = 'rgba(244, 63, 94, 0.25)';
555
- spriteDiv.style.border = '2px dashed #f43f5e';
556
- spriteDiv.style.borderRadius = '16px';
557
- spriteDiv.style.display = 'flex';
558
- spriteDiv.style.alignItems = 'center';
559
- spriteDiv.style.justifyContent = 'center';
560
- spriteDiv.style.color = '#ffffff';
561
- spriteDiv.style.fontWeight = '600';
562
- spriteDiv.style.fontSize = '1.1rem';
563
- spriteDiv.textContent = `${charId}\n(${charState.expression ?? 'normal'})`;
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;
727
+ }
728
+ else {
729
+ delete this.rootEl.dataset.kawaTheme;
730
+ }
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);
564
737
  }
565
- };
566
- spriteDiv.appendChild(img);
567
- this.charactersEl.appendChild(spriteDiv);
568
- existing = {
569
- div: spriteDiv,
570
- img,
571
- expression: charState.expression,
572
- position: charState.position
573
- };
574
- this.activeCharacters.set(charId, existing);
738
+ });
575
739
  }
576
- else {
577
- existing.div.className = `kawa-sprite ${posClass}`;
578
- if (charState.expression) {
579
- existing.div.dataset.expression = charState.expression;
580
- }
581
- else {
582
- delete existing.div.dataset.expression;
583
- }
584
- if (existing.expression !== charState.expression) {
585
- existing.expression = charState.expression;
586
- existing.img.style.display = 'block';
587
- 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);
588
746
  }
589
- }
747
+ });
748
+ this.rootEl.classList.add(`${prefix}${name}`);
590
749
  }
591
- // Render dialogue
592
- if (state.dialogue) {
593
- this.dialogueBoxEl.style.display = 'block';
594
- if (state.dialogue.speakerDisplayName) {
595
- this.speakerTagEl.style.display = 'inline-block';
596
- this.speakerTagEl.textContent = state.dialogue.speakerDisplayName;
597
- if (state.dialogue.speakerColor) {
598
- 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();
599
769
  }
600
- }
601
- else {
602
- this.speakerTagEl.style.display = 'none';
603
- }
604
- 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);
605
787
  }
606
788
  else {
607
- 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
+ });
608
798
  }
609
- // Render choices
610
- if (state.choices && state.choices.length > 0) {
611
- if (this.isAutoMode)
612
- this.toggleAutoMode(false);
613
- if (this.isSkipMode)
614
- this.toggleSkipMode(false);
615
- this.choiceContainerEl.innerHTML = '';
616
- this.choiceContainerEl.style.display = 'flex';
617
- this.choiceContainerEl.style.pointerEvents = 'auto';
618
- this.choiceContainerEl.style.opacity = '1';
619
- state.choices.forEach((choice, index) => {
620
- const btn = document.createElement('button');
621
- btn.className = 'kawa-choice-btn kawa-choice';
622
- btn.textContent = choice.text;
623
- btn.addEventListener('click', (e) => {
624
- e.stopPropagation();
625
- if (this.isChoicePending)
626
- return;
627
- this.isChoicePending = true;
628
- this.choiceContainerEl.style.pointerEvents = 'none';
629
- this.choiceContainerEl.style.opacity = '0.5';
630
- 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)
631
810
  });
632
- this.choiceContainerEl.appendChild(btn);
633
- });
811
+ }
634
812
  }
635
813
  else {
636
- this.choiceContainerEl.style.display = 'none';
814
+ this.rootEl.querySelector('.kawa-input-overlay')?.remove();
637
815
  }
638
- // Render Ending Card if Story is Finished
816
+ // 7. Ending Screen if finished
639
817
  if (state.isFinished) {
640
- if (this.isAutoMode)
641
- this.toggleAutoMode(false);
642
- if (this.isSkipMode)
643
- this.toggleSkipMode(false);
644
- this.choiceContainerEl.style.display = 'none';
645
- const existingEnd = this.rootEl.querySelector('.kawa-ending-card');
646
- if (!existingEnd) {
647
- const endCard = document.createElement('div');
648
- endCard.className = 'kawa-ending-card';
649
- endCard.innerHTML = `
650
- <div class="kawa-ending-title">The End</div>
651
- <div class="kawa-ending-subtitle">Story complete! Thank you for playing.</div>
652
- <div style="display:flex;gap:12px;margin-top:16px;">
653
- <button class="kawa-btn kawa-btn-replay">${SVG_ICONS.replay} Play Again</button>
654
- <button class="kawa-btn kawa-btn-load-end">${SVG_ICONS.load} Load Slot</button>
655
- </div>
656
- `;
657
- endCard.querySelector('.kawa-btn-replay')?.addEventListener('click', () => {
658
- this.vm.jump('start');
659
- });
660
- endCard.querySelector('.kawa-btn-load-end')?.addEventListener('click', () => {
661
- this.showSaveLoadModal('load');
662
- });
663
- this.rootEl.appendChild(endCard);
664
- }
818
+ this.renderEndingCard();
665
819
  }
666
820
  else {
667
- const existingEnd = this.rootEl.querySelector('.kawa-ending-card');
668
- if (existingEnd)
669
- existingEnd.remove();
821
+ const endingEl = this.stageLayer.stageEl.querySelector('.kawa-ending-card');
822
+ if (endingEl)
823
+ endingEl.remove();
670
824
  }
671
825
  }
672
- startTypewriter(text) {
673
- if (this.currentTypewriterInterval) {
674
- clearInterval(this.currentTypewriterInterval);
675
- }
676
- if (this.autoTimer) {
677
- clearTimeout(this.autoTimer);
678
- this.autoTimer = null;
679
- }
680
- this.fullCurrentText = text;
681
- if (this.typewriterSpeed <= 0 || this.isSkipMode) {
682
- this.dialogueTextEl.innerHTML = formatRichText(text);
683
- this.isTypewriting = false;
684
- if (this.isAutoMode)
685
- this.scheduleAutoAdvance();
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')) {
686
829
  return;
687
830
  }
688
- this.isTypewriting = true;
689
- let index = 0;
690
- this.dialogueTextEl.innerHTML = '';
691
- this.currentTypewriterInterval = window.setInterval(() => {
692
- index += 1;
693
- this.dialogueTextEl.innerHTML = formatRichText(this.fullCurrentText.slice(0, index));
694
- if (index >= this.fullCurrentText.length) {
695
- this.finishTypewriter();
696
- }
697
- }, this.typewriterSpeed);
698
- }
699
- finishTypewriter() {
700
- if (this.currentTypewriterInterval) {
701
- clearInterval(this.currentTypewriterInterval);
702
- this.currentTypewriterInterval = null;
703
- }
704
- this.dialogueTextEl.innerHTML = formatRichText(this.fullCurrentText);
705
- this.isTypewriting = false;
706
- if (this.isAutoMode) {
707
- this.scheduleAutoAdvance();
708
- }
709
- }
710
- showSettingsModal() {
711
- const existing = this.rootEl.querySelector('.kawa-modal-overlay');
712
- if (existing)
713
- existing.remove();
714
- const overlay = document.createElement('div');
715
- overlay.className = 'kawa-modal-overlay';
716
- const card = document.createElement('div');
717
- card.className = 'kawa-modal-card';
718
- card.setAttribute('role', 'dialog');
719
- card.setAttribute('aria-modal', 'true');
720
- card.setAttribute('aria-label', 'Settings');
721
- const header = document.createElement('div');
722
- header.className = 'kawa-modal-header';
723
- const title = document.createElement('div');
724
- title.className = 'kawa-modal-title';
725
- title.innerHTML = `${SVG_ICONS.settings} <span>Settings</span>`;
726
- const closeBtn = document.createElement('button');
727
- closeBtn.className = 'kawa-btn';
728
- closeBtn.innerHTML = `${SVG_ICONS.close} <span>Close</span>`;
729
- closeBtn.setAttribute('aria-label', 'Close settings modal');
730
- closeBtn.addEventListener('click', () => overlay.remove());
731
- header.appendChild(title);
732
- header.appendChild(closeBtn);
733
- const body = document.createElement('div');
734
- body.className = 'kawa-modal-body kawa-settings-group';
735
- // 1. Text Speed Slider
736
- const speedRow = document.createElement('div');
737
- speedRow.className = 'kawa-setting-row';
738
- const speedVal = this.typewriterSpeed;
739
- speedRow.innerHTML = `
740
- <div class="kawa-setting-header">
741
- <span>Text Speed</span>
742
- <span class="kawa-setting-value" id="kawa-speed-val">${speedVal === 0 ? 'Instant' : `${speedVal}ms`}</span>
743
- </div>
744
- <input type="range" class="kawa-slider" id="kawa-speed-slider" min="0" max="60" step="5" value="${speedVal}">
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>
745
838
  `;
746
- const speedInput = speedRow.querySelector('#kawa-speed-slider');
747
- const speedValEl = speedRow.querySelector('#kawa-speed-val');
748
- speedInput.addEventListener('input', () => {
749
- this.typewriterSpeed = Number(speedInput.value);
750
- speedValEl.textContent = this.typewriterSpeed === 0 ? 'Instant' : `${this.typewriterSpeed}ms`;
751
- this.saveSettings();
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();
752
849
  });
753
- // 2. Auto Forward Delay Slider
754
- const autoRow = document.createElement('div');
755
- autoRow.className = 'kawa-setting-row';
756
- const autoVal = (this.autoDelayMs / 1000).toFixed(1);
757
- autoRow.innerHTML = `
758
- <div class="kawa-setting-header">
759
- <span>Auto Forward Delay</span>
760
- <span class="kawa-setting-value" id="kawa-auto-val">${autoVal}s</span>
761
- </div>
762
- <input type="range" class="kawa-slider" id="kawa-auto-slider" min="500" max="5000" step="250" value="${this.autoDelayMs}">
763
- `;
764
- const autoInput = autoRow.querySelector('#kawa-auto-slider');
765
- const autoValEl = autoRow.querySelector('#kawa-auto-val');
766
- autoInput.addEventListener('input', () => {
767
- this.autoDelayMs = Number(autoInput.value);
768
- autoValEl.textContent = `${(this.autoDelayMs / 1000).toFixed(1)}s`;
769
- this.saveSettings();
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();
770
860
  });
771
- // 3. Audio Volume Sliders
772
- const musicRow = document.createElement('div');
773
- musicRow.className = 'kawa-setting-row';
774
- musicRow.innerHTML = `
775
- <div class="kawa-setting-header">
776
- <span>${SVG_ICONS.volumeOn} Music Volume</span>
777
- <span class="kawa-setting-value" id="kawa-music-val">80%</span>
778
- </div>
779
- <input type="range" class="kawa-slider" id="kawa-music-slider" min="0" max="100" step="5" value="80">
780
- `;
781
- const sfxRow = document.createElement('div');
782
- sfxRow.className = 'kawa-setting-row';
783
- sfxRow.innerHTML = `
784
- <div class="kawa-setting-header">
785
- <span>${SVG_ICONS.volumeOn} SFX Volume</span>
786
- <span class="kawa-setting-value" id="kawa-sfx-val">100%</span>
787
- </div>
788
- <input type="range" class="kawa-slider" id="kawa-sfx-slider" min="0" max="100" step="5" value="100">
789
- `;
790
- body.appendChild(speedRow);
791
- body.appendChild(autoRow);
792
- body.appendChild(musicRow);
793
- body.appendChild(sfxRow);
794
- card.appendChild(header);
795
- card.appendChild(body);
796
- overlay.appendChild(card);
797
- this.rootEl.appendChild(overlay);
861
+ endingCard.appendChild(restartBtn);
862
+ endingCard.appendChild(menuBtn);
863
+ this.stageLayer.stageEl.appendChild(endingCard);
798
864
  }
799
865
  saveSettings() {
800
866
  if (typeof localStorage !== 'undefined') {
801
867
  try {
802
868
  localStorage.setItem('kawaijs_settings', JSON.stringify({
803
869
  typewriterSpeed: this.typewriterSpeed,
804
- autoDelayMs: this.autoDelayMs
870
+ autoDelayMs: this.autoDelayMs,
871
+ musicVolume: this.musicVolume,
872
+ soundVolume: this.soundVolume
805
873
  }));
806
874
  }
807
875
  catch { }
808
876
  }
809
877
  }
810
- async showSaveLoadModal(mode) {
811
- const existing = this.rootEl.querySelector('.kawa-modal-overlay');
812
- if (existing)
813
- existing.remove();
814
- const overlay = document.createElement('div');
815
- overlay.className = 'kawa-modal-overlay';
816
- const card = document.createElement('div');
817
- card.className = 'kawa-modal-card';
818
- card.setAttribute('role', 'dialog');
819
- card.setAttribute('aria-modal', 'true');
820
- card.setAttribute('aria-label', mode === 'save' ? 'Save Game' : 'Load Game');
821
- const header = document.createElement('div');
822
- header.className = 'kawa-modal-header';
823
- const title = document.createElement('div');
824
- title.className = 'kawa-modal-title';
825
- title.innerHTML = `${mode === 'save' ? SVG_ICONS.save : SVG_ICONS.load} <span>${mode === 'save' ? 'Save Game' : 'Load Game'}</span>`;
826
- const closeBtn = document.createElement('button');
827
- closeBtn.className = 'kawa-btn';
828
- closeBtn.innerHTML = `${SVG_ICONS.close} <span>Close</span>`;
829
- closeBtn.setAttribute('aria-label', 'Close modal');
830
- closeBtn.addEventListener('click', () => overlay.remove());
831
- header.appendChild(title);
832
- header.appendChild(closeBtn);
833
- const body = document.createElement('div');
834
- body.className = 'kawa-modal-body';
835
- const grid = document.createElement('div');
836
- grid.className = 'kawa-slots-grid';
837
- const saveManager = this.vm.getSaveManager();
838
- const slots = await saveManager.listSlots(6);
839
- slots.forEach((slot, idx) => {
840
- const slotNum = String(idx + 1);
841
- const slotCard = document.createElement('div');
842
- slotCard.className = 'kawa-slot-card';
843
- const slotHeader = document.createElement('div');
844
- slotHeader.className = 'kawa-slot-header';
845
- const badge = document.createElement('span');
846
- badge.className = 'kawa-slot-badge';
847
- badge.textContent = `Slot ${slotNum}`;
848
- const timeSpan = document.createElement('span');
849
- timeSpan.className = 'kawa-slot-time';
850
- timeSpan.textContent = slot ? new Date(slot.timestamp).toLocaleString() : 'Empty';
851
- slotHeader.appendChild(badge);
852
- slotHeader.appendChild(timeSpan);
853
- slotCard.appendChild(slotHeader);
854
- if (slot) {
855
- const preview = document.createElement('div');
856
- preview.className = 'kawa-slot-preview';
857
- preview.textContent = slot.previewText || `Label: ${slot.snapshot.state.currentLabel}`;
858
- slotCard.appendChild(preview);
859
- }
860
- else {
861
- const emptyText = document.createElement('div');
862
- emptyText.className = 'kawa-slot-empty-text';
863
- emptyText.textContent = 'No save data';
864
- slotCard.appendChild(emptyText);
865
- }
866
- const actions = document.createElement('div');
867
- actions.className = 'kawa-slot-actions';
868
- if (mode === 'save') {
869
- const saveActionBtn = document.createElement('button');
870
- saveActionBtn.className = 'kawa-slot-btn kawa-slot-btn-save';
871
- saveActionBtn.innerHTML = `${SVG_ICONS.save} Save Here`;
872
- saveActionBtn.setAttribute('aria-label', `Save to slot ${slotNum}`);
873
- saveActionBtn.addEventListener('click', async () => {
874
- await this.vm.save(slotNum);
875
- overlay.remove();
876
- this.showSaveLoadModal('save');
877
- });
878
- actions.appendChild(saveActionBtn);
879
- }
880
- else {
881
- if (slot) {
882
- const loadActionBtn = document.createElement('button');
883
- loadActionBtn.className = 'kawa-slot-btn kawa-slot-btn-load';
884
- loadActionBtn.innerHTML = `${SVG_ICONS.load} Load`;
885
- loadActionBtn.setAttribute('aria-label', `Load slot ${slotNum}`);
886
- loadActionBtn.addEventListener('click', async () => {
887
- const ok = await this.vm.load(slotNum);
888
- if (ok)
889
- overlay.remove();
890
- });
891
- actions.appendChild(loadActionBtn);
892
- }
893
- }
894
- if (slot) {
895
- const delBtn = document.createElement('button');
896
- delBtn.className = 'kawa-slot-btn kawa-slot-btn-del';
897
- delBtn.innerHTML = SVG_ICONS.trash;
898
- delBtn.title = 'Delete Save';
899
- delBtn.setAttribute('aria-label', `Delete slot ${slotNum}`);
900
- delBtn.addEventListener('click', async () => {
901
- await saveManager.deleteSlot(slotNum);
902
- overlay.remove();
903
- this.showSaveLoadModal(mode);
904
- });
905
- actions.appendChild(delBtn);
906
- }
907
- slotCard.appendChild(actions);
908
- grid.appendChild(slotCard);
909
- });
910
- body.appendChild(grid);
911
- card.appendChild(header);
912
- card.appendChild(body);
913
- overlay.appendChild(card);
914
- this.rootEl.appendChild(overlay);
915
- }
916
- showHistoryModal() {
917
- const existing = this.rootEl.querySelector('.kawa-modal-overlay');
918
- if (existing)
919
- existing.remove();
920
- const overlay = document.createElement('div');
921
- overlay.className = 'kawa-modal-overlay';
922
- const card = document.createElement('div');
923
- card.className = 'kawa-modal-card';
924
- card.setAttribute('role', 'dialog');
925
- card.setAttribute('aria-modal', 'true');
926
- card.setAttribute('aria-label', 'Dialogue History');
927
- const header = document.createElement('div');
928
- header.className = 'kawa-modal-header';
929
- const title = document.createElement('div');
930
- title.className = 'kawa-modal-title';
931
- title.innerHTML = `${SVG_ICONS.history} <span>Dialogue History</span>`;
932
- const closeBtn = document.createElement('button');
933
- closeBtn.className = 'kawa-btn';
934
- closeBtn.innerHTML = `${SVG_ICONS.close} <span>Close</span>`;
935
- closeBtn.setAttribute('aria-label', 'Close history modal');
936
- closeBtn.addEventListener('click', () => overlay.remove());
937
- header.appendChild(title);
938
- header.appendChild(closeBtn);
939
- const body = document.createElement('div');
940
- body.className = 'kawa-modal-body';
941
- const entries = this.vm.getHistoryManager().getEntries();
942
- if (entries.length === 0) {
943
- const emptyMsg = document.createElement('div');
944
- emptyMsg.textContent = 'No dialogue history yet.';
945
- emptyMsg.style.opacity = '0.6';
946
- body.appendChild(emptyMsg);
947
- }
948
- else {
949
- for (const entry of entries) {
950
- const item = document.createElement('div');
951
- item.className = 'kawa-history-item';
952
- if (entry.speakerDisplayName) {
953
- const spk = document.createElement('div');
954
- spk.className = 'kawa-history-speaker';
955
- spk.textContent = entry.speakerDisplayName;
956
- item.appendChild(spk);
957
- }
958
- const txt = document.createElement('div');
959
- txt.innerHTML = formatRichText(entry.text);
960
- item.appendChild(txt);
961
- body.appendChild(item);
962
- }
963
- }
964
- card.appendChild(header);
965
- card.appendChild(body);
966
- overlay.appendChild(card);
967
- this.rootEl.appendChild(overlay);
968
- }
969
878
  }
970
879
  //# sourceMappingURL=renderer.js.map