@kawaijs/renderer-dom 0.1.11 → 0.1.13

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 +43 -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 +48 -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 +258 -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 +257 -0
  29. package/dist/components/stage-layer.js.map +1 -0
  30. package/dist/components/vfx-layer.d.ts +32 -0
  31. package/dist/components/vfx-layer.d.ts.map +1 -0
  32. package/dist/components/vfx-layer.js +357 -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 +146 -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 +65 -56
  83. package/dist/renderer.d.ts.map +1 -1
  84. package/dist/renderer.js +655 -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 +66 -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 +85 -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 +2081 -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,103 @@ 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
+ this.setGameplayUiInert(true);
360
+ }
361
+ hideMainMenu() {
362
+ this.mainMenu.hide();
363
+ this.setGameplayUiInert(false);
364
+ }
365
+ /** Keep title-screen a11y tree clean: gameplay controls must not stay focusable under the menu. */
366
+ setGameplayUiInert(inert) {
367
+ const ui = this.stageLayer.stageEl.querySelector('.kawa-ui-layer');
368
+ if (!ui)
369
+ return;
370
+ if (inert)
371
+ ui.setAttribute('inert', '');
372
+ else
373
+ ui.removeAttribute('inert');
374
+ }
375
+ isMainMenuActive() {
376
+ return this.mainMenu.isActive();
377
+ }
378
+ startNewGame() {
379
+ // Ignore residual click-through from the Start button onto the stage (Opera / touchpads).
380
+ this.advanceLockUntil = Date.now() + 350;
381
+ this.vm.start();
382
+ this.hideMainMenu();
383
+ this.mainMenuOptions?.onStart?.();
384
+ }
385
+ async continueLatestGame() {
386
+ const saveManager = this.vm.getSaveManager();
387
+ const slots = await saveManager.listSlots(10);
388
+ const validSlots = slots.filter((s) => s !== null);
389
+ if (validSlots.length === 0)
390
+ return false;
391
+ validSlots.sort((a, b) => b.timestamp - a.timestamp);
392
+ const latest = validSlots[0];
393
+ const ok = await this.vm.load(latest.id);
394
+ if (ok) {
395
+ this.hideMainMenu();
396
+ return true;
397
+ }
398
+ return false;
399
+ }
400
+ async showSaveLoadModal(mode) {
401
+ await showSaveLoadModal(this.rootEl, this.vm, mode, () => {
402
+ this.hideMainMenu();
403
+ });
404
+ }
405
+ showSettingsModal() {
406
+ showSettingsModal(this.rootEl, {
407
+ typewriterSpeed: this.typewriterSpeed,
408
+ autoDelayMs: this.autoDelayMs,
409
+ musicVolume: this.musicVolume,
410
+ soundVolume: this.soundVolume,
411
+ onSettingsChange: (s) => {
412
+ this.typewriterSpeed = s.typewriterSpeed;
413
+ this.autoDelayMs = s.autoDelayMs;
414
+ if (s.musicVolume !== undefined) {
415
+ this.musicVolume = s.musicVolume;
416
+ this.audioManager?.setMusicVolume(this.musicVolume);
417
+ }
418
+ if (s.soundVolume !== undefined) {
419
+ this.soundVolume = s.soundVolume;
420
+ this.audioManager?.setSoundVolume(this.soundVolume);
421
+ }
422
+ this.dialogueBox.setTypewriterSpeed(this.typewriterSpeed);
423
+ this.saveSettings();
424
+ }
425
+ });
426
+ }
427
+ showHistoryModal() {
428
+ showHistoryModal(this.rootEl, this.vm);
429
+ }
430
+ showAboutModal() {
431
+ showAboutModal(this.rootEl, this.mainMenuOptions);
432
+ }
433
+ showGalleryModal() {
434
+ const galleryItems = this.mainMenuOptions?.galleryItems ?? [];
435
+ showGalleryModal(this.rootEl, this.vm, galleryItems, (path) => this.assetResolver(path, 'background'));
436
+ }
229
437
  updateModeUI() {
230
- if (this.autoBtnEl) {
231
- this.autoBtnEl.classList.toggle('active', this.isAutoMode);
438
+ this.quickMenu.setAutoActive(this.isAutoMode);
439
+ this.quickMenu.setSkipActive(this.isSkipMode);
440
+ if (this.isSkipMode) {
441
+ this.stageLayer.modeBadgeEl.innerHTML = `${SVG_ICONS.skip} SKIP`;
442
+ this.stageLayer.modeBadgeEl.style.display = 'inline-flex';
232
443
  }
233
- if (this.skipBtnEl) {
234
- this.skipBtnEl.classList.toggle('active', this.isSkipMode);
444
+ else if (this.isAutoMode) {
445
+ this.stageLayer.modeBadgeEl.innerHTML = `${SVG_ICONS.auto} AUTO`;
446
+ this.stageLayer.modeBadgeEl.style.display = 'inline-flex';
235
447
  }
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
- }
448
+ else {
449
+ this.stageLayer.modeBadgeEl.style.display = 'none';
248
450
  }
249
451
  }
250
452
  scheduleAutoAdvance() {
@@ -255,14 +457,20 @@ export class DOMRenderer {
255
457
  if (!this.isAutoMode)
256
458
  return;
257
459
  const state = this.vm.getState();
258
- if (state.isFinished || (state.choices && state.choices.length > 0)) {
460
+ if (state.isFinished ||
461
+ (state.choices && state.choices.length > 0) ||
462
+ (state.hotspots && state.hotspots.length > 0) ||
463
+ state.pendingInput) {
259
464
  return;
260
465
  }
261
466
  const duration = Math.max(800, this.autoDelayMs + (state.dialogue?.text.length ?? 0) * 15);
262
467
  this.autoTimer = window.setTimeout(() => {
263
468
  if (this.isAutoMode) {
264
469
  const cur = this.vm.getState();
265
- if (!cur.isFinished && (!cur.choices || cur.choices.length === 0)) {
470
+ if (!cur.isFinished &&
471
+ (!cur.choices || cur.choices.length === 0) &&
472
+ (!cur.hotspots || cur.hotspots.length === 0) &&
473
+ !cur.pendingInput) {
266
474
  this.vm.next();
267
475
  }
268
476
  }
@@ -272,155 +480,137 @@ export class DOMRenderer {
272
480
  this.container.innerHTML = '';
273
481
  this.rootEl = document.createElement('div');
274
482
  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';
483
+ this.rootEl.style.touchAction = 'manipulation';
484
+ if (this.options.embed) {
485
+ this.rootEl.classList.add('kawa-embed');
486
+ this.rootEl.dataset.kawaEmbed = '1';
487
+ }
488
+ this.stageLayer = new StageLayerComponent(this.assetResolver);
295
489
  const uiLayerEl = document.createElement('div');
296
490
  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');
491
+ this.choiceMenu = this.options.components?.createChoiceMenu
492
+ ? this.options.components.createChoiceMenu({
493
+ onSelect: (index) => {
494
+ this.vm.choose(index);
495
+ }
496
+ })
497
+ : new ChoiceMenuComponent({
498
+ onSelect: (index) => {
499
+ this.vm.choose(index);
500
+ }
501
+ });
502
+ this.hotspotLayer = new HotspotLayerComponent((id) => {
503
+ this.vm.selectHotspot(id);
361
504
  });
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');
505
+ this.dialogueBox = this.options.components?.createDialogueBox
506
+ ? this.options.components.createDialogueBox(this.typewriterSpeed)
507
+ : new DialogueBoxComponent(this.typewriterSpeed);
508
+ this.quickMenu = new QuickMenuComponent({
509
+ onTitle: () => this.showMainMenu(),
510
+ onBack: () => {
511
+ if (this.isAutoMode)
512
+ this.toggleAutoMode(false);
513
+ if (this.isSkipMode)
514
+ this.toggleSkipMode(false);
515
+ this.vm.rollback();
516
+ },
517
+ onHistory: () => this.showHistoryModal(),
518
+ onAuto: () => this.toggleAutoMode(),
519
+ onSkip: () => this.toggleSkipMode(),
520
+ onSave: () => void this.showSaveLoadModal('save'),
521
+ onLoad: () => void this.showSaveLoadModal('load'),
522
+ onSettings: () => this.showSettingsModal()
369
523
  });
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();
524
+ if (this.options.features?.quickMenu === false || this.options.embed) {
525
+ this.quickMenu.el.style.display = 'none';
526
+ }
527
+ uiLayerEl.appendChild(this.choiceMenu.el);
528
+ uiLayerEl.appendChild(this.dialogueBox.el);
529
+ uiLayerEl.appendChild(this.quickMenu.el);
530
+ this.stageLayer.stageEl.appendChild(uiLayerEl);
531
+ this.stageLayer.stageEl.appendChild(this.hotspotLayer.el);
532
+ this.mainMenu = new MainMenuComponent(this.rootEl, this.vm, this.mainMenuOptions, {
533
+ onStartNewGame: () => this.startNewGame(),
534
+ onContinueGame: () => this.continueLatestGame(),
535
+ assetResolver: this.assetResolver,
536
+ getSettingsConfig: () => ({
537
+ typewriterSpeed: this.typewriterSpeed,
538
+ autoDelayMs: this.autoDelayMs,
539
+ musicVolume: this.musicVolume,
540
+ soundVolume: this.soundVolume,
541
+ onSettingsChange: (s) => {
542
+ this.typewriterSpeed = s.typewriterSpeed;
543
+ this.autoDelayMs = s.autoDelayMs;
544
+ if (s.musicVolume !== undefined) {
545
+ this.musicVolume = s.musicVolume;
546
+ this.audioManager?.setMusicVolume(this.musicVolume);
547
+ }
548
+ if (s.soundVolume !== undefined) {
549
+ this.soundVolume = s.soundVolume;
550
+ this.audioManager?.setSoundVolume(this.soundVolume);
551
+ }
552
+ this.dialogueBox.setTypewriterSpeed(this.typewriterSpeed);
553
+ this.saveSettings();
554
+ }
555
+ })
377
556
  });
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);
557
+ this.mainMenu.mount(this.stageLayer.stageEl);
558
+ this.rootEl.appendChild(this.stageLayer.stageEl);
393
559
  this.container.appendChild(this.rootEl);
560
+ this.viewportAdapter = new ViewportAdapter(this.container, this.stageLayer.stageEl, this.options.virtualCanvas);
394
561
  }
395
562
  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);
563
+ // Clicking on stage or dialogue box advances or finishes typewriter
564
+ this.stageLayer.stageEl.addEventListener('click', (e) => {
565
+ if (this.isMainMenuActive())
566
+ return;
567
+ const target = e.target;
568
+ if (target.closest('button') ||
569
+ target.closest('.kawa-quick-menu') ||
570
+ target.closest('.kawa-choice-container') ||
571
+ target.closest('.kawa-hotspot-layer') ||
572
+ target.closest('.kawa-modal-overlay') ||
573
+ target.closest('.kawa-ending-card')) {
574
+ return;
403
575
  }
404
- this.handleUserAdvance();
405
- });
406
- this.backBtnEl.addEventListener('click', (e) => {
407
- e.stopPropagation();
408
576
  if (this.isAutoMode)
409
577
  this.toggleAutoMode(false);
410
578
  if (this.isSkipMode)
411
579
  this.toggleSkipMode(false);
412
- this.vm.rollback();
580
+ this.handleUserAdvance();
413
581
  });
414
582
  // Keyboard controls
415
583
  this.boundKeyHandler = (e) => {
584
+ const confirm = this.rootEl.querySelector('.kawa-confirm-overlay');
585
+ if (confirm) {
586
+ if (e.key === 'Escape') {
587
+ e.preventDefault();
588
+ confirm.remove();
589
+ }
590
+ return;
591
+ }
592
+ const lightbox = this.rootEl.querySelector('.kawa-gallery-lightbox');
593
+ if (lightbox) {
594
+ if (e.key === 'Escape') {
595
+ e.preventDefault();
596
+ lightbox.remove();
597
+ }
598
+ return;
599
+ }
416
600
  const modal = this.rootEl.querySelector('.kawa-modal-overlay');
417
601
  if (modal) {
418
602
  if (e.key === 'Escape')
419
603
  modal.remove();
420
604
  return;
421
605
  }
606
+ if (this.isMainMenuActive()) {
607
+ return;
608
+ }
422
609
  if (e.code === 'Space' || e.code === 'Enter') {
423
- if (document.activeElement && document.activeElement.tagName === 'BUTTON') {
610
+ if (document.activeElement &&
611
+ (document.activeElement.tagName === 'BUTTON' ||
612
+ document.activeElement.tagName === 'INPUT' ||
613
+ document.activeElement.tagName === 'TEXTAREA')) {
424
614
  return;
425
615
  }
426
616
  e.preventDefault();
@@ -441,15 +631,16 @@ export class DOMRenderer {
441
631
  else if (e.key === 'a' || e.key === 'A') {
442
632
  this.toggleAutoMode();
443
633
  }
444
- else if (e.key === 'Tab' || e.key === 'Control') {
634
+ else if (e.key === 'Control') {
635
+ // Ctrl toggles skip (Ren'Py-style). Tab is left for accessibility focus navigation.
445
636
  e.preventDefault();
446
637
  this.toggleSkipMode();
447
638
  }
448
639
  else if (e.key === 's' || e.key === 'S') {
449
- this.showSaveLoadModal('save');
640
+ void this.showSaveLoadModal('save');
450
641
  }
451
642
  else if (e.key === 'l' || e.key === 'L') {
452
- this.showSaveLoadModal('load');
643
+ void this.showSaveLoadModal('load');
453
644
  }
454
645
  else if (e.key === 'h' || e.key === 'H') {
455
646
  this.showHistoryModal();
@@ -457,514 +648,244 @@ export class DOMRenderer {
457
648
  else if (e.key === 'p' || e.key === 'P' || e.key === 'o' || e.key === 'O') {
458
649
  this.showSettingsModal();
459
650
  }
651
+ else if (e.key === 'Escape') {
652
+ this.showMainMenu();
653
+ }
460
654
  };
461
655
  if (typeof window !== 'undefined') {
462
656
  window.addEventListener('keydown', this.boundKeyHandler);
463
657
  }
658
+ // Mobile: swipe left = advance, swipe right = rollback
659
+ if (this.options.features?.touchGestures !== false) {
660
+ this.touchControls = bindSwipeControls(this.stageLayer.stageEl, {
661
+ onSwipeLeft: () => {
662
+ if (this.isMainMenuActive())
663
+ return;
664
+ if (this.isAutoMode)
665
+ this.toggleAutoMode(false);
666
+ if (this.isSkipMode)
667
+ this.toggleSkipMode(false);
668
+ this.handleUserAdvance();
669
+ },
670
+ onSwipeRight: () => {
671
+ if (this.isMainMenuActive())
672
+ return;
673
+ if (this.isAutoMode)
674
+ this.toggleAutoMode(false);
675
+ if (this.isSkipMode)
676
+ this.toggleSkipMode(false);
677
+ if (this.vm.canRollback())
678
+ this.vm.rollback();
679
+ }
680
+ });
681
+ }
464
682
  }
465
683
  handleUserAdvance() {
466
- if (this.isTypewriting) {
467
- this.finishTypewriter();
684
+ if (this.isMainMenuActive())
685
+ return;
686
+ if (this.vm.getState().pendingInput)
687
+ return;
688
+ if (this.dialogueBox.getIsTypewriting()) {
689
+ this.dialogueBox.finishTypewriter();
690
+ const debounce = this.options.features?.advanceDebounceMs ?? 220;
691
+ this.advanceLockUntil = Date.now() + Math.max(0, debounce);
692
+ return;
468
693
  }
469
- else {
470
- this.vm.next();
694
+ if (Date.now() < this.advanceLockUntil) {
695
+ return;
696
+ }
697
+ const state = this.vm.getState();
698
+ if (state.choices && state.choices.length > 0) {
699
+ return;
471
700
  }
701
+ if (state.hotspots && state.hotspots.length > 0) {
702
+ return;
703
+ }
704
+ this.vm.next();
472
705
  }
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
- }
706
+ syncUrlLabel(state) {
707
+ if (!this.options.syncUrlLabel || typeof window === 'undefined' || typeof history === 'undefined') {
708
+ return;
506
709
  }
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
- }
710
+ const label = state.currentLabel;
711
+ if (!label || label.startsWith('__'))
712
+ return;
713
+ try {
714
+ const url = new URL(window.location.href);
715
+ if (url.searchParams.get('at') === label)
716
+ return;
717
+ url.searchParams.delete('label');
718
+ url.searchParams.set('at', label);
719
+ history.replaceState(null, '', `${url.pathname}${url.search}${url.hash}`);
521
720
  }
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'})`;
721
+ catch {
722
+ // ignore malformed URL environments
723
+ }
724
+ }
725
+ syncShareMeta(state) {
726
+ const share = this.options.share;
727
+ if (!share && !this.options.syncUrlLabel)
728
+ return;
729
+ const pageUrl = typeof window !== 'undefined' ? window.location.href : undefined;
730
+ const meta = buildSceneShareMeta(this.vm.getStory(), state, share, pageUrl);
731
+ applyDocumentShareMeta({
732
+ ...meta,
733
+ image: meta.image ? resolveShareAssetUrl(meta.image) : undefined
734
+ }, share);
735
+ }
736
+ applyThemeAndStyles(state) {
737
+ if (state.theme) {
738
+ this.rootEl.dataset.kawaTheme = state.theme;
739
+ }
740
+ else {
741
+ delete this.rootEl.dataset.kawaTheme;
742
+ }
743
+ const nextTargets = new Set(Object.keys(state.styleClasses ?? {}));
744
+ for (const target of this.appliedStyleTargets) {
745
+ if (!nextTargets.has(target)) {
746
+ this.rootEl.classList.forEach((cls) => {
747
+ if (cls.startsWith(`kawa-style-${target}-`)) {
748
+ this.rootEl.classList.remove(cls);
564
749
  }
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);
750
+ });
575
751
  }
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;
752
+ }
753
+ for (const [target, name] of Object.entries(state.styleClasses ?? {})) {
754
+ const prefix = `kawa-style-${target}-`;
755
+ this.rootEl.classList.forEach((cls) => {
756
+ if (cls.startsWith(prefix) && cls !== `${prefix}${name}`) {
757
+ this.rootEl.classList.remove(cls);
588
758
  }
589
- }
759
+ });
760
+ this.rootEl.classList.add(`${prefix}${name}`);
590
761
  }
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;
762
+ this.appliedStyleTargets = nextTargets;
763
+ }
764
+ render(state) {
765
+ this.quickMenu.setBackDisabled(!this.vm.canRollback());
766
+ this.applyThemeAndStyles(state);
767
+ // Timed pause: auto-advance after pendingPauseMs (click still skips via handleUserAdvance)
768
+ if (this.pauseTimer) {
769
+ clearTimeout(this.pauseTimer);
770
+ this.pauseTimer = null;
771
+ }
772
+ if (state.pendingPauseMs != null && state.pendingPauseMs > 0 && !state.isFinished) {
773
+ const delay = state.pendingPauseMs;
774
+ this.pauseTimer = window.setTimeout(() => {
775
+ this.pauseTimer = null;
776
+ if (this.destroyed)
777
+ return;
778
+ const cur = this.vm.getState();
779
+ if (cur.pendingPauseMs != null && !cur.isFinished) {
780
+ this.vm.next();
599
781
  }
600
- }
601
- else {
602
- this.speakerTagEl.style.display = 'none';
603
- }
604
- this.startTypewriter(state.dialogue.text);
782
+ }, delay);
783
+ }
784
+ // 1. Background scene
785
+ this.stageLayer.updateBackground(state.visual.background, state.visual.transition);
786
+ // 2. Character sprites
787
+ this.stageLayer.updateCharacters(state.visual.characters);
788
+ // 3. VFX & Atmospheric Layer
789
+ this.stageLayer.updateVfx(state.visual.vfx);
790
+ // 4. Fullscreen CG Layer
791
+ this.stageLayer.updateCG(state.visual.activeCG);
792
+ // 5. Branching Choice Menu
793
+ this.choiceMenu.render(state.choices);
794
+ // 5b. CG / stage hotspots
795
+ this.hotspotLayer.render(state.hotspots);
796
+ // 6. Dialogue Box (respect `window hide` / `window show`)
797
+ if (state.windowVisible === false) {
798
+ this.dialogueBox.render(null);
605
799
  }
606
800
  else {
607
- this.dialogueBoxEl.style.display = 'none';
801
+ this.dialogueBox.render(state.dialogue, () => {
802
+ if (this.isAutoMode &&
803
+ !state.isFinished &&
804
+ (!state.choices || state.choices.length === 0) &&
805
+ (!state.hotspots || state.hotspots.length === 0) &&
806
+ !state.pendingInput) {
807
+ this.scheduleAutoAdvance();
808
+ }
809
+ });
608
810
  }
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);
811
+ // 6b. Text input prompt (`input var "prompt"`)
812
+ if (state.pendingInput) {
813
+ if (!this.rootEl.querySelector('.kawa-input-overlay')) {
814
+ const prompt = state.pendingInput.prompt;
815
+ const current = typeof state.variables[state.pendingInput.variable] === 'string'
816
+ ? String(state.variables[state.pendingInput.variable])
817
+ : '';
818
+ showInputModal(this.rootEl, {
819
+ prompt,
820
+ defaultValue: current,
821
+ onSubmit: (value) => this.vm.submitInput(value)
631
822
  });
632
- this.choiceContainerEl.appendChild(btn);
633
- });
823
+ }
634
824
  }
635
825
  else {
636
- this.choiceContainerEl.style.display = 'none';
826
+ this.rootEl.querySelector('.kawa-input-overlay')?.remove();
637
827
  }
638
- // Render Ending Card if Story is Finished
828
+ // 7. Ending Screen if finished
639
829
  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
- }
830
+ this.renderEndingCard();
665
831
  }
666
832
  else {
667
- const existingEnd = this.rootEl.querySelector('.kawa-ending-card');
668
- if (existingEnd)
669
- existingEnd.remove();
833
+ const endingEl = this.stageLayer.stageEl.querySelector('.kawa-ending-card');
834
+ if (endingEl)
835
+ endingEl.remove();
670
836
  }
671
837
  }
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();
838
+ renderEndingCard() {
839
+ // Don't remount — recreating replayed the pop animation and looked like a jump.
840
+ if (this.stageLayer.stageEl.querySelector('.kawa-ending-card')) {
686
841
  return;
687
842
  }
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}">
843
+ const endingCard = document.createElement('div');
844
+ endingCard.className = 'kawa-ending-card';
845
+ endingCard.setAttribute('role', 'alert');
846
+ endingCard.setAttribute('aria-label', 'Story Finished');
847
+ endingCard.innerHTML = `
848
+ <div class="kawa-ending-title">The End</div>
849
+ <div class="kawa-ending-subtitle">Thank you for playing!</div>
745
850
  `;
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();
851
+ const restartBtn = document.createElement('button');
852
+ restartBtn.type = 'button';
853
+ restartBtn.className = 'kawa-choice-btn kawa-ending-btn';
854
+ restartBtn.style.marginTop = '16px';
855
+ restartBtn.innerHTML = `${SVG_ICONS.replay} Play Again`;
856
+ restartBtn.addEventListener('click', (e) => {
857
+ e.preventDefault();
858
+ e.stopPropagation();
859
+ endingCard.remove();
860
+ this.startNewGame();
752
861
  });
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();
862
+ const menuBtn = document.createElement('button');
863
+ menuBtn.type = 'button';
864
+ menuBtn.className = 'kawa-choice-btn kawa-ending-btn';
865
+ menuBtn.style.marginTop = '10px';
866
+ menuBtn.innerHTML = `${SVG_ICONS.home} Main Menu`;
867
+ menuBtn.addEventListener('click', (e) => {
868
+ e.preventDefault();
869
+ e.stopPropagation();
870
+ endingCard.remove();
871
+ this.showMainMenu();
770
872
  });
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);
873
+ endingCard.appendChild(restartBtn);
874
+ endingCard.appendChild(menuBtn);
875
+ this.stageLayer.stageEl.appendChild(endingCard);
798
876
  }
799
877
  saveSettings() {
800
878
  if (typeof localStorage !== 'undefined') {
801
879
  try {
802
880
  localStorage.setItem('kawaijs_settings', JSON.stringify({
803
881
  typewriterSpeed: this.typewriterSpeed,
804
- autoDelayMs: this.autoDelayMs
882
+ autoDelayMs: this.autoDelayMs,
883
+ musicVolume: this.musicVolume,
884
+ soundVolume: this.soundVolume
805
885
  }));
806
886
  }
807
887
  catch { }
808
888
  }
809
889
  }
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
890
  }
970
891
  //# sourceMappingURL=renderer.js.map