@kawaijs/renderer-dom 0.1.8 → 0.1.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/renderer.d.ts +47 -2
- package/dist/renderer.d.ts.map +1 -1
- package/dist/renderer.js +532 -91
- package/dist/renderer.js.map +1 -1
- package/package.json +3 -3
- package/src/theme.css +213 -9
package/dist/renderer.js
CHANGED
|
@@ -19,30 +19,121 @@ export function defaultAssetResolver(path, type) {
|
|
|
19
19
|
}
|
|
20
20
|
return path;
|
|
21
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();
|
|
30
|
+
for (const label of Object.values(story.labels)) {
|
|
31
|
+
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);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
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, '&')
|
|
72
|
+
.replace(/</g, '<')
|
|
73
|
+
.replace(/>/g, '>');
|
|
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;
|
|
83
|
+
}
|
|
22
84
|
export class DOMRenderer {
|
|
23
85
|
vm;
|
|
24
86
|
container;
|
|
25
87
|
typewriterSpeed;
|
|
88
|
+
autoDelayMs;
|
|
26
89
|
assetResolver;
|
|
27
90
|
rootEl;
|
|
28
91
|
stageEl;
|
|
29
92
|
backgroundEl;
|
|
93
|
+
bgLayerA;
|
|
94
|
+
bgLayerB;
|
|
95
|
+
activeBgLayer = 'A';
|
|
96
|
+
currentBgUrl = '';
|
|
30
97
|
charactersEl;
|
|
98
|
+
activeCharacters = new Map();
|
|
99
|
+
modeBadgeEl;
|
|
31
100
|
dialogueBoxEl;
|
|
32
101
|
speakerTagEl;
|
|
33
102
|
dialogueTextEl;
|
|
34
103
|
choiceContainerEl;
|
|
35
104
|
backBtnEl;
|
|
105
|
+
autoBtnEl;
|
|
106
|
+
skipBtnEl;
|
|
36
107
|
currentTypewriterInterval = null;
|
|
37
108
|
isTypewriting = false;
|
|
38
109
|
fullCurrentText = '';
|
|
39
110
|
isChoicePending = false;
|
|
111
|
+
isAutoMode = false;
|
|
112
|
+
isSkipMode = false;
|
|
113
|
+
autoTimer = null;
|
|
114
|
+
skipInterval = null;
|
|
40
115
|
unsubscribeVMState;
|
|
116
|
+
boundKeyHandler;
|
|
41
117
|
constructor(vm, options) {
|
|
42
118
|
this.vm = vm;
|
|
43
119
|
this.container = options.container;
|
|
44
120
|
this.typewriterSpeed = options.typewriterSpeed ?? 20;
|
|
121
|
+
this.autoDelayMs = options.autoDelayMs ?? 1800;
|
|
45
122
|
this.assetResolver = options.assetResolver ?? defaultAssetResolver;
|
|
123
|
+
// Load saved settings if present
|
|
124
|
+
if (typeof localStorage !== 'undefined') {
|
|
125
|
+
try {
|
|
126
|
+
const saved = localStorage.getItem('kawaijs_settings');
|
|
127
|
+
if (saved) {
|
|
128
|
+
const parsed = JSON.parse(saved);
|
|
129
|
+
if (typeof parsed.typewriterSpeed === 'number')
|
|
130
|
+
this.typewriterSpeed = parsed.typewriterSpeed;
|
|
131
|
+
if (typeof parsed.autoDelayMs === 'number')
|
|
132
|
+
this.autoDelayMs = parsed.autoDelayMs;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
catch { }
|
|
136
|
+
}
|
|
46
137
|
this.buildDOM();
|
|
47
138
|
this.bindEvents();
|
|
48
139
|
this.unsubscribeVMState = this.vm.onStateChange((state) => {
|
|
@@ -58,15 +149,25 @@ export class DOMRenderer {
|
|
|
58
149
|
if (this.currentTypewriterInterval) {
|
|
59
150
|
clearInterval(this.currentTypewriterInterval);
|
|
60
151
|
}
|
|
152
|
+
if (this.autoTimer) {
|
|
153
|
+
clearTimeout(this.autoTimer);
|
|
154
|
+
}
|
|
155
|
+
if (this.skipInterval) {
|
|
156
|
+
clearInterval(this.skipInterval);
|
|
157
|
+
}
|
|
158
|
+
if (this.boundKeyHandler && typeof window !== 'undefined') {
|
|
159
|
+
window.removeEventListener('keydown', this.boundKeyHandler);
|
|
160
|
+
}
|
|
61
161
|
this.container.innerHTML = '';
|
|
62
162
|
}
|
|
63
|
-
shakeScreen() {
|
|
163
|
+
shakeScreen(type = 'shake') {
|
|
64
164
|
if (!this.stageEl)
|
|
65
165
|
return;
|
|
66
|
-
this.stageEl.classList.remove('kawa-shake');
|
|
67
|
-
// Trigger reflow
|
|
166
|
+
this.stageEl.classList.remove('kawa-shake', 'kawa-vpunch', 'kawa-hpunch');
|
|
167
|
+
// Trigger reflow
|
|
68
168
|
void this.stageEl.offsetWidth;
|
|
69
|
-
|
|
169
|
+
const cls = type === 'vpunch' ? 'kawa-vpunch' : type === 'hpunch' ? 'kawa-hpunch' : 'kawa-shake';
|
|
170
|
+
this.stageEl.classList.add(cls);
|
|
70
171
|
}
|
|
71
172
|
flashScreen() {
|
|
72
173
|
if (!this.stageEl)
|
|
@@ -79,71 +180,214 @@ export class DOMRenderer {
|
|
|
79
180
|
this.stageEl.appendChild(flash);
|
|
80
181
|
setTimeout(() => flash.remove(), 600);
|
|
81
182
|
}
|
|
183
|
+
toggleAutoMode(force) {
|
|
184
|
+
this.isAutoMode = force !== undefined ? force : !this.isAutoMode;
|
|
185
|
+
if (this.isAutoMode && this.isSkipMode) {
|
|
186
|
+
this.toggleSkipMode(false);
|
|
187
|
+
}
|
|
188
|
+
this.updateModeUI();
|
|
189
|
+
if (this.isAutoMode && !this.isTypewriting) {
|
|
190
|
+
this.scheduleAutoAdvance();
|
|
191
|
+
}
|
|
192
|
+
else if (!this.isAutoMode && this.autoTimer) {
|
|
193
|
+
clearTimeout(this.autoTimer);
|
|
194
|
+
this.autoTimer = null;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
toggleSkipMode(force) {
|
|
198
|
+
this.isSkipMode = force !== undefined ? force : !this.isSkipMode;
|
|
199
|
+
if (this.isSkipMode && this.isAutoMode) {
|
|
200
|
+
this.toggleAutoMode(false);
|
|
201
|
+
}
|
|
202
|
+
this.updateModeUI();
|
|
203
|
+
if (this.isSkipMode) {
|
|
204
|
+
if (this.autoTimer) {
|
|
205
|
+
clearTimeout(this.autoTimer);
|
|
206
|
+
this.autoTimer = null;
|
|
207
|
+
}
|
|
208
|
+
if (!this.skipInterval) {
|
|
209
|
+
this.skipInterval = window.setInterval(() => {
|
|
210
|
+
const state = this.vm.getState();
|
|
211
|
+
if (state.isFinished || (state.choices && state.choices.length > 0)) {
|
|
212
|
+
this.toggleSkipMode(false);
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
if (this.isTypewriting) {
|
|
216
|
+
this.finishTypewriter();
|
|
217
|
+
}
|
|
218
|
+
this.vm.next();
|
|
219
|
+
}, 55);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
else {
|
|
223
|
+
if (this.skipInterval) {
|
|
224
|
+
clearInterval(this.skipInterval);
|
|
225
|
+
this.skipInterval = null;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
updateModeUI() {
|
|
230
|
+
if (this.autoBtnEl) {
|
|
231
|
+
this.autoBtnEl.classList.toggle('active', this.isAutoMode);
|
|
232
|
+
}
|
|
233
|
+
if (this.skipBtnEl) {
|
|
234
|
+
this.skipBtnEl.classList.toggle('active', this.isSkipMode);
|
|
235
|
+
}
|
|
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
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
scheduleAutoAdvance() {
|
|
251
|
+
if (this.autoTimer) {
|
|
252
|
+
clearTimeout(this.autoTimer);
|
|
253
|
+
this.autoTimer = null;
|
|
254
|
+
}
|
|
255
|
+
if (!this.isAutoMode)
|
|
256
|
+
return;
|
|
257
|
+
const state = this.vm.getState();
|
|
258
|
+
if (state.isFinished || (state.choices && state.choices.length > 0)) {
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
const duration = Math.max(800, this.autoDelayMs + (state.dialogue?.text.length ?? 0) * 15);
|
|
262
|
+
this.autoTimer = window.setTimeout(() => {
|
|
263
|
+
if (this.isAutoMode) {
|
|
264
|
+
const cur = this.vm.getState();
|
|
265
|
+
if (!cur.isFinished && (!cur.choices || cur.choices.length === 0)) {
|
|
266
|
+
this.vm.next();
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
}, duration);
|
|
270
|
+
}
|
|
82
271
|
buildDOM() {
|
|
83
272
|
this.container.innerHTML = '';
|
|
84
273
|
this.rootEl = document.createElement('div');
|
|
85
274
|
this.rootEl.className = 'kawa-root';
|
|
86
275
|
this.stageEl = document.createElement('div');
|
|
87
276
|
this.stageEl.className = 'kawa-stage';
|
|
277
|
+
this.stageEl.setAttribute('role', 'main');
|
|
278
|
+
this.stageEl.setAttribute('aria-label', 'Visual Novel Stage');
|
|
88
279
|
this.backgroundEl = document.createElement('div');
|
|
89
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);
|
|
90
288
|
this.charactersEl = document.createElement('div');
|
|
91
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';
|
|
92
295
|
const uiLayerEl = document.createElement('div');
|
|
93
296
|
uiLayerEl.className = 'kawa-ui-layer';
|
|
94
297
|
// Choice Container
|
|
95
298
|
this.choiceContainerEl = document.createElement('div');
|
|
96
299
|
this.choiceContainerEl.className = 'kawa-choice-container kawa-choices';
|
|
300
|
+
this.choiceContainerEl.setAttribute('role', 'group');
|
|
301
|
+
this.choiceContainerEl.setAttribute('aria-label', 'Choices');
|
|
97
302
|
this.choiceContainerEl.style.display = 'none';
|
|
98
303
|
// Dialogue Box
|
|
99
304
|
this.dialogueBoxEl = document.createElement('div');
|
|
100
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');
|
|
101
309
|
this.speakerTagEl = document.createElement('div');
|
|
102
310
|
this.speakerTagEl.className = 'kawa-speaker-tag kawa-speaker';
|
|
103
311
|
this.dialogueTextEl = document.createElement('div');
|
|
104
312
|
this.dialogueTextEl.className = 'kawa-dialogue-text kawa-text';
|
|
313
|
+
this.dialogueTextEl.setAttribute('aria-live', 'polite');
|
|
105
314
|
const indicatorEl = document.createElement('div');
|
|
106
315
|
indicatorEl.className = 'kawa-continue-indicator';
|
|
107
|
-
indicatorEl.
|
|
316
|
+
indicatorEl.setAttribute('aria-hidden', 'true');
|
|
317
|
+
indicatorEl.innerHTML = SVG_ICONS.arrowDown;
|
|
108
318
|
this.dialogueBoxEl.appendChild(this.speakerTagEl);
|
|
109
319
|
this.dialogueBoxEl.appendChild(this.dialogueTextEl);
|
|
110
320
|
this.dialogueBoxEl.appendChild(indicatorEl);
|
|
111
321
|
// Quick Menu
|
|
112
322
|
const quickMenuEl = document.createElement('nav');
|
|
113
323
|
quickMenuEl.className = 'kawa-quick-menu';
|
|
324
|
+
quickMenuEl.setAttribute('role', 'toolbar');
|
|
325
|
+
quickMenuEl.setAttribute('aria-label', 'Game Menu');
|
|
114
326
|
this.backBtnEl = document.createElement('button');
|
|
115
327
|
this.backBtnEl.className = 'kawa-btn';
|
|
116
|
-
this.backBtnEl.
|
|
328
|
+
this.backBtnEl.innerHTML = `${SVG_ICONS.back} <span>Back</span>`;
|
|
329
|
+
this.backBtnEl.setAttribute('aria-label', 'Rollback to previous dialogue');
|
|
117
330
|
const histBtn = document.createElement('button');
|
|
118
331
|
histBtn.className = 'kawa-btn';
|
|
119
|
-
histBtn.
|
|
332
|
+
histBtn.innerHTML = `${SVG_ICONS.history} <span>History</span>`;
|
|
333
|
+
histBtn.setAttribute('aria-label', 'Open dialogue history log');
|
|
120
334
|
histBtn.addEventListener('click', (e) => {
|
|
121
335
|
e.stopPropagation();
|
|
122
336
|
this.showHistoryModal();
|
|
123
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
|
+
});
|
|
124
354
|
const saveBtn = document.createElement('button');
|
|
125
355
|
saveBtn.className = 'kawa-btn';
|
|
126
|
-
saveBtn.
|
|
356
|
+
saveBtn.innerHTML = `${SVG_ICONS.save} <span>Save</span>`;
|
|
357
|
+
saveBtn.setAttribute('aria-label', 'Open save game menu');
|
|
127
358
|
saveBtn.addEventListener('click', (e) => {
|
|
128
359
|
e.stopPropagation();
|
|
129
360
|
this.showSaveLoadModal('save');
|
|
130
361
|
});
|
|
131
362
|
const loadBtn = document.createElement('button');
|
|
132
363
|
loadBtn.className = 'kawa-btn';
|
|
133
|
-
loadBtn.
|
|
364
|
+
loadBtn.innerHTML = `${SVG_ICONS.load} <span>Load</span>`;
|
|
365
|
+
loadBtn.setAttribute('aria-label', 'Open load game menu');
|
|
134
366
|
loadBtn.addEventListener('click', (e) => {
|
|
135
367
|
e.stopPropagation();
|
|
136
368
|
this.showSaveLoadModal('load');
|
|
137
369
|
});
|
|
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();
|
|
377
|
+
});
|
|
138
378
|
quickMenuEl.appendChild(this.backBtnEl);
|
|
139
379
|
quickMenuEl.appendChild(histBtn);
|
|
380
|
+
quickMenuEl.appendChild(this.autoBtnEl);
|
|
381
|
+
quickMenuEl.appendChild(this.skipBtnEl);
|
|
140
382
|
quickMenuEl.appendChild(saveBtn);
|
|
141
383
|
quickMenuEl.appendChild(loadBtn);
|
|
384
|
+
quickMenuEl.appendChild(settingsBtn);
|
|
142
385
|
uiLayerEl.appendChild(this.choiceContainerEl);
|
|
143
386
|
uiLayerEl.appendChild(this.dialogueBoxEl);
|
|
144
387
|
uiLayerEl.appendChild(quickMenuEl);
|
|
145
388
|
this.stageEl.appendChild(this.backgroundEl);
|
|
146
389
|
this.stageEl.appendChild(this.charactersEl);
|
|
390
|
+
this.stageEl.appendChild(this.modeBadgeEl);
|
|
147
391
|
this.stageEl.appendChild(uiLayerEl);
|
|
148
392
|
this.rootEl.appendChild(this.stageEl);
|
|
149
393
|
this.container.appendChild(this.rootEl);
|
|
@@ -151,15 +395,24 @@ export class DOMRenderer {
|
|
|
151
395
|
bindEvents() {
|
|
152
396
|
// Clicking on dialogue advances or finishes typewriter
|
|
153
397
|
this.dialogueBoxEl.addEventListener('click', () => {
|
|
398
|
+
if (this.isAutoMode) {
|
|
399
|
+
this.toggleAutoMode(false);
|
|
400
|
+
}
|
|
401
|
+
if (this.isSkipMode) {
|
|
402
|
+
this.toggleSkipMode(false);
|
|
403
|
+
}
|
|
154
404
|
this.handleUserAdvance();
|
|
155
405
|
});
|
|
156
406
|
this.backBtnEl.addEventListener('click', (e) => {
|
|
157
407
|
e.stopPropagation();
|
|
408
|
+
if (this.isAutoMode)
|
|
409
|
+
this.toggleAutoMode(false);
|
|
410
|
+
if (this.isSkipMode)
|
|
411
|
+
this.toggleSkipMode(false);
|
|
158
412
|
this.vm.rollback();
|
|
159
413
|
});
|
|
160
414
|
// Keyboard controls
|
|
161
|
-
|
|
162
|
-
// If modal is open, let Escape close it
|
|
415
|
+
this.boundKeyHandler = (e) => {
|
|
163
416
|
const modal = this.rootEl.querySelector('.kawa-modal-overlay');
|
|
164
417
|
if (modal) {
|
|
165
418
|
if (e.key === 'Escape')
|
|
@@ -167,13 +420,31 @@ export class DOMRenderer {
|
|
|
167
420
|
return;
|
|
168
421
|
}
|
|
169
422
|
if (e.code === 'Space' || e.code === 'Enter') {
|
|
423
|
+
if (document.activeElement && document.activeElement.tagName === 'BUTTON') {
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
170
426
|
e.preventDefault();
|
|
427
|
+
if (this.isAutoMode)
|
|
428
|
+
this.toggleAutoMode(false);
|
|
429
|
+
if (this.isSkipMode)
|
|
430
|
+
this.toggleSkipMode(false);
|
|
171
431
|
this.handleUserAdvance();
|
|
172
432
|
}
|
|
173
433
|
else if (e.code === 'Backspace') {
|
|
174
434
|
e.preventDefault();
|
|
435
|
+
if (this.isAutoMode)
|
|
436
|
+
this.toggleAutoMode(false);
|
|
437
|
+
if (this.isSkipMode)
|
|
438
|
+
this.toggleSkipMode(false);
|
|
175
439
|
this.vm.rollback();
|
|
176
440
|
}
|
|
441
|
+
else if (e.key === 'a' || e.key === 'A') {
|
|
442
|
+
this.toggleAutoMode();
|
|
443
|
+
}
|
|
444
|
+
else if (e.key === 'Tab' || e.key === 'Control') {
|
|
445
|
+
e.preventDefault();
|
|
446
|
+
this.toggleSkipMode();
|
|
447
|
+
}
|
|
177
448
|
else if (e.key === 's' || e.key === 'S') {
|
|
178
449
|
this.showSaveLoadModal('save');
|
|
179
450
|
}
|
|
@@ -183,7 +454,13 @@ export class DOMRenderer {
|
|
|
183
454
|
else if (e.key === 'h' || e.key === 'H') {
|
|
184
455
|
this.showHistoryModal();
|
|
185
456
|
}
|
|
186
|
-
|
|
457
|
+
else if (e.key === 'p' || e.key === 'P' || e.key === 'o' || e.key === 'O') {
|
|
458
|
+
this.showSettingsModal();
|
|
459
|
+
}
|
|
460
|
+
};
|
|
461
|
+
if (typeof window !== 'undefined') {
|
|
462
|
+
window.addEventListener('keydown', this.boundKeyHandler);
|
|
463
|
+
}
|
|
187
464
|
}
|
|
188
465
|
handleUserAdvance() {
|
|
189
466
|
if (this.isTypewriting) {
|
|
@@ -196,84 +473,120 @@ export class DOMRenderer {
|
|
|
196
473
|
render(state) {
|
|
197
474
|
this.isChoicePending = false;
|
|
198
475
|
this.backBtnEl.disabled = !this.vm.canRollback();
|
|
199
|
-
//
|
|
476
|
+
// 1. Dual-Layer Background Crossfading
|
|
200
477
|
if (state.visual.background) {
|
|
478
|
+
this.backgroundEl.dataset.transition = state.visual.transition ?? 'none';
|
|
201
479
|
const rawBg = state.visual.background;
|
|
202
480
|
const cleanBg = rawBg.replace(/^bg[\s_]+/i, '').trim();
|
|
203
481
|
const baseName = cleanBg.replace(/\.(svg|png|jpg|jpeg|webp)$/i, '');
|
|
204
482
|
const primaryUrl = this.assetResolver(cleanBg, 'background');
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
this.
|
|
208
|
-
this.
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
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;
|
|
221
503
|
};
|
|
222
|
-
|
|
223
|
-
}
|
|
224
|
-
img.src = primaryUrl;
|
|
504
|
+
testImg.src = primaryUrl;
|
|
505
|
+
}
|
|
225
506
|
}
|
|
226
507
|
else {
|
|
227
|
-
this.
|
|
508
|
+
this.currentBgUrl = '';
|
|
509
|
+
this.bgLayerA.classList.remove('active');
|
|
510
|
+
this.bgLayerB.classList.remove('active');
|
|
228
511
|
}
|
|
229
|
-
//
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
spriteDiv.dataset.expression = charState.expression;
|
|
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);
|
|
238
520
|
}
|
|
239
|
-
|
|
521
|
+
}
|
|
522
|
+
// Add or update active sprites
|
|
523
|
+
for (const [charId, charState] of Object.entries(currentChars)) {
|
|
524
|
+
const posClass = `kawa-pos-${charState.position ?? 'center'}`;
|
|
240
525
|
const charAssetKey = charState.expression ? `${charId}/${charState.expression}` : charId;
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
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;
|
|
249
534
|
}
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
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'})`;
|
|
564
|
+
}
|
|
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);
|
|
575
|
+
}
|
|
576
|
+
else {
|
|
577
|
+
existing.div.className = `kawa-sprite ${posClass}`;
|
|
578
|
+
if (charState.expression) {
|
|
579
|
+
existing.div.dataset.expression = charState.expression;
|
|
257
580
|
}
|
|
258
581
|
else {
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
spriteDiv.style.borderRadius = '16px';
|
|
266
|
-
spriteDiv.style.display = 'flex';
|
|
267
|
-
spriteDiv.style.alignItems = 'center';
|
|
268
|
-
spriteDiv.style.justifyContent = 'center';
|
|
269
|
-
spriteDiv.style.color = '#ffffff';
|
|
270
|
-
spriteDiv.style.fontWeight = '600';
|
|
271
|
-
spriteDiv.style.fontSize = '1.1rem';
|
|
272
|
-
spriteDiv.textContent = `${charId}\n(${charState.expression ?? 'normal'})`;
|
|
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;
|
|
273
588
|
}
|
|
274
|
-
}
|
|
275
|
-
spriteDiv.appendChild(img);
|
|
276
|
-
this.charactersEl.appendChild(spriteDiv);
|
|
589
|
+
}
|
|
277
590
|
}
|
|
278
591
|
// Render dialogue
|
|
279
592
|
if (state.dialogue) {
|
|
@@ -295,6 +608,10 @@ export class DOMRenderer {
|
|
|
295
608
|
}
|
|
296
609
|
// Render choices
|
|
297
610
|
if (state.choices && state.choices.length > 0) {
|
|
611
|
+
if (this.isAutoMode)
|
|
612
|
+
this.toggleAutoMode(false);
|
|
613
|
+
if (this.isSkipMode)
|
|
614
|
+
this.toggleSkipMode(false);
|
|
298
615
|
this.choiceContainerEl.innerHTML = '';
|
|
299
616
|
this.choiceContainerEl.style.display = 'flex';
|
|
300
617
|
this.choiceContainerEl.style.pointerEvents = 'auto';
|
|
@@ -320,17 +637,21 @@ export class DOMRenderer {
|
|
|
320
637
|
}
|
|
321
638
|
// Render Ending Card if Story is Finished
|
|
322
639
|
if (state.isFinished) {
|
|
640
|
+
if (this.isAutoMode)
|
|
641
|
+
this.toggleAutoMode(false);
|
|
642
|
+
if (this.isSkipMode)
|
|
643
|
+
this.toggleSkipMode(false);
|
|
323
644
|
this.choiceContainerEl.style.display = 'none';
|
|
324
645
|
const existingEnd = this.rootEl.querySelector('.kawa-ending-card');
|
|
325
646
|
if (!existingEnd) {
|
|
326
647
|
const endCard = document.createElement('div');
|
|
327
648
|
endCard.className = 'kawa-ending-card';
|
|
328
649
|
endCard.innerHTML = `
|
|
329
|
-
<div class="kawa-ending-title"
|
|
650
|
+
<div class="kawa-ending-title">The End</div>
|
|
330
651
|
<div class="kawa-ending-subtitle">Story complete! Thank you for playing.</div>
|
|
331
652
|
<div style="display:flex;gap:12px;margin-top:16px;">
|
|
332
|
-
<button class="kawa-btn kawa-btn-replay"
|
|
333
|
-
<button class="kawa-btn kawa-btn-load-end"
|
|
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>
|
|
334
655
|
</div>
|
|
335
656
|
`;
|
|
336
657
|
endCard.querySelector('.kawa-btn-replay')?.addEventListener('click', () => {
|
|
@@ -352,18 +673,24 @@ export class DOMRenderer {
|
|
|
352
673
|
if (this.currentTypewriterInterval) {
|
|
353
674
|
clearInterval(this.currentTypewriterInterval);
|
|
354
675
|
}
|
|
676
|
+
if (this.autoTimer) {
|
|
677
|
+
clearTimeout(this.autoTimer);
|
|
678
|
+
this.autoTimer = null;
|
|
679
|
+
}
|
|
355
680
|
this.fullCurrentText = text;
|
|
356
|
-
if (this.typewriterSpeed <= 0) {
|
|
357
|
-
this.dialogueTextEl.
|
|
681
|
+
if (this.typewriterSpeed <= 0 || this.isSkipMode) {
|
|
682
|
+
this.dialogueTextEl.innerHTML = formatRichText(text);
|
|
358
683
|
this.isTypewriting = false;
|
|
684
|
+
if (this.isAutoMode)
|
|
685
|
+
this.scheduleAutoAdvance();
|
|
359
686
|
return;
|
|
360
687
|
}
|
|
361
688
|
this.isTypewriting = true;
|
|
362
689
|
let index = 0;
|
|
363
|
-
this.dialogueTextEl.
|
|
690
|
+
this.dialogueTextEl.innerHTML = '';
|
|
364
691
|
this.currentTypewriterInterval = window.setInterval(() => {
|
|
365
692
|
index += 1;
|
|
366
|
-
this.dialogueTextEl.
|
|
693
|
+
this.dialogueTextEl.innerHTML = formatRichText(this.fullCurrentText.slice(0, index));
|
|
367
694
|
if (index >= this.fullCurrentText.length) {
|
|
368
695
|
this.finishTypewriter();
|
|
369
696
|
}
|
|
@@ -374,8 +701,111 @@ export class DOMRenderer {
|
|
|
374
701
|
clearInterval(this.currentTypewriterInterval);
|
|
375
702
|
this.currentTypewriterInterval = null;
|
|
376
703
|
}
|
|
377
|
-
this.dialogueTextEl.
|
|
704
|
+
this.dialogueTextEl.innerHTML = formatRichText(this.fullCurrentText);
|
|
378
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}">
|
|
745
|
+
`;
|
|
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();
|
|
752
|
+
});
|
|
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();
|
|
770
|
+
});
|
|
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);
|
|
798
|
+
}
|
|
799
|
+
saveSettings() {
|
|
800
|
+
if (typeof localStorage !== 'undefined') {
|
|
801
|
+
try {
|
|
802
|
+
localStorage.setItem('kawaijs_settings', JSON.stringify({
|
|
803
|
+
typewriterSpeed: this.typewriterSpeed,
|
|
804
|
+
autoDelayMs: this.autoDelayMs
|
|
805
|
+
}));
|
|
806
|
+
}
|
|
807
|
+
catch { }
|
|
808
|
+
}
|
|
379
809
|
}
|
|
380
810
|
async showSaveLoadModal(mode) {
|
|
381
811
|
const existing = this.rootEl.querySelector('.kawa-modal-overlay');
|
|
@@ -385,14 +815,18 @@ export class DOMRenderer {
|
|
|
385
815
|
overlay.className = 'kawa-modal-overlay';
|
|
386
816
|
const card = document.createElement('div');
|
|
387
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');
|
|
388
821
|
const header = document.createElement('div');
|
|
389
822
|
header.className = 'kawa-modal-header';
|
|
390
823
|
const title = document.createElement('div');
|
|
391
824
|
title.className = 'kawa-modal-title';
|
|
392
|
-
title.
|
|
825
|
+
title.innerHTML = `${mode === 'save' ? SVG_ICONS.save : SVG_ICONS.load} <span>${mode === 'save' ? 'Save Game' : 'Load Game'}</span>`;
|
|
393
826
|
const closeBtn = document.createElement('button');
|
|
394
827
|
closeBtn.className = 'kawa-btn';
|
|
395
|
-
closeBtn.
|
|
828
|
+
closeBtn.innerHTML = `${SVG_ICONS.close} <span>Close</span>`;
|
|
829
|
+
closeBtn.setAttribute('aria-label', 'Close modal');
|
|
396
830
|
closeBtn.addEventListener('click', () => overlay.remove());
|
|
397
831
|
header.appendChild(title);
|
|
398
832
|
header.appendChild(closeBtn);
|
|
@@ -434,7 +868,8 @@ export class DOMRenderer {
|
|
|
434
868
|
if (mode === 'save') {
|
|
435
869
|
const saveActionBtn = document.createElement('button');
|
|
436
870
|
saveActionBtn.className = 'kawa-slot-btn kawa-slot-btn-save';
|
|
437
|
-
saveActionBtn.
|
|
871
|
+
saveActionBtn.innerHTML = `${SVG_ICONS.save} Save Here`;
|
|
872
|
+
saveActionBtn.setAttribute('aria-label', `Save to slot ${slotNum}`);
|
|
438
873
|
saveActionBtn.addEventListener('click', async () => {
|
|
439
874
|
await this.vm.save(slotNum);
|
|
440
875
|
overlay.remove();
|
|
@@ -446,7 +881,8 @@ export class DOMRenderer {
|
|
|
446
881
|
if (slot) {
|
|
447
882
|
const loadActionBtn = document.createElement('button');
|
|
448
883
|
loadActionBtn.className = 'kawa-slot-btn kawa-slot-btn-load';
|
|
449
|
-
loadActionBtn.
|
|
884
|
+
loadActionBtn.innerHTML = `${SVG_ICONS.load} Load`;
|
|
885
|
+
loadActionBtn.setAttribute('aria-label', `Load slot ${slotNum}`);
|
|
450
886
|
loadActionBtn.addEventListener('click', async () => {
|
|
451
887
|
const ok = await this.vm.load(slotNum);
|
|
452
888
|
if (ok)
|
|
@@ -458,8 +894,9 @@ export class DOMRenderer {
|
|
|
458
894
|
if (slot) {
|
|
459
895
|
const delBtn = document.createElement('button');
|
|
460
896
|
delBtn.className = 'kawa-slot-btn kawa-slot-btn-del';
|
|
461
|
-
delBtn.
|
|
897
|
+
delBtn.innerHTML = SVG_ICONS.trash;
|
|
462
898
|
delBtn.title = 'Delete Save';
|
|
899
|
+
delBtn.setAttribute('aria-label', `Delete slot ${slotNum}`);
|
|
463
900
|
delBtn.addEventListener('click', async () => {
|
|
464
901
|
await saveManager.deleteSlot(slotNum);
|
|
465
902
|
overlay.remove();
|
|
@@ -484,14 +921,18 @@ export class DOMRenderer {
|
|
|
484
921
|
overlay.className = 'kawa-modal-overlay';
|
|
485
922
|
const card = document.createElement('div');
|
|
486
923
|
card.className = 'kawa-modal-card';
|
|
924
|
+
card.setAttribute('role', 'dialog');
|
|
925
|
+
card.setAttribute('aria-modal', 'true');
|
|
926
|
+
card.setAttribute('aria-label', 'Dialogue History');
|
|
487
927
|
const header = document.createElement('div');
|
|
488
928
|
header.className = 'kawa-modal-header';
|
|
489
929
|
const title = document.createElement('div');
|
|
490
930
|
title.className = 'kawa-modal-title';
|
|
491
|
-
title.
|
|
931
|
+
title.innerHTML = `${SVG_ICONS.history} <span>Dialogue History</span>`;
|
|
492
932
|
const closeBtn = document.createElement('button');
|
|
493
933
|
closeBtn.className = 'kawa-btn';
|
|
494
|
-
closeBtn.
|
|
934
|
+
closeBtn.innerHTML = `${SVG_ICONS.close} <span>Close</span>`;
|
|
935
|
+
closeBtn.setAttribute('aria-label', 'Close history modal');
|
|
495
936
|
closeBtn.addEventListener('click', () => overlay.remove());
|
|
496
937
|
header.appendChild(title);
|
|
497
938
|
header.appendChild(closeBtn);
|
|
@@ -515,7 +956,7 @@ export class DOMRenderer {
|
|
|
515
956
|
item.appendChild(spk);
|
|
516
957
|
}
|
|
517
958
|
const txt = document.createElement('div');
|
|
518
|
-
txt.
|
|
959
|
+
txt.innerHTML = formatRichText(entry.text);
|
|
519
960
|
item.appendChild(txt);
|
|
520
961
|
body.appendChild(item);
|
|
521
962
|
}
|