kawaijs 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 (48) hide show
  1. package/README.md +51 -4
  2. package/dist/commands/build.d.ts +1 -0
  3. package/dist/commands/build.d.ts.map +1 -1
  4. package/dist/commands/build.js +152 -1147
  5. package/dist/commands/build.js.map +1 -1
  6. package/dist/commands/create.d.ts.map +1 -1
  7. package/dist/commands/create.js +132 -55
  8. package/dist/commands/create.js.map +1 -1
  9. package/dist/commands/dev.d.ts +1 -0
  10. package/dist/commands/dev.d.ts.map +1 -1
  11. package/dist/commands/dev.js +301 -1258
  12. package/dist/commands/dev.js.map +1 -1
  13. package/dist/commands/embed.d.ts +25 -0
  14. package/dist/commands/embed.d.ts.map +1 -0
  15. package/dist/commands/embed.js +110 -0
  16. package/dist/commands/embed.js.map +1 -0
  17. package/dist/commands/validate.d.ts.map +1 -1
  18. package/dist/commands/validate.js +104 -6
  19. package/dist/commands/validate.js.map +1 -1
  20. package/dist/config.d.ts +119 -0
  21. package/dist/config.d.ts.map +1 -0
  22. package/dist/config.js +93 -0
  23. package/dist/config.js.map +1 -0
  24. package/dist/i18n.d.ts +8 -0
  25. package/dist/i18n.d.ts.map +1 -0
  26. package/dist/i18n.js +49 -0
  27. package/dist/i18n.js.map +1 -0
  28. package/dist/index.d.ts +7 -1
  29. package/dist/index.d.ts.map +1 -1
  30. package/dist/index.js +68 -33
  31. package/dist/index.js.map +1 -1
  32. package/dist/parse-args.d.ts +29 -0
  33. package/dist/parse-args.d.ts.map +1 -0
  34. package/dist/parse-args.js +106 -0
  35. package/dist/parse-args.js.map +1 -0
  36. package/dist/pwa.d.ts +22 -0
  37. package/dist/pwa.d.ts.map +1 -0
  38. package/dist/pwa.js +151 -0
  39. package/dist/pwa.js.map +1 -0
  40. package/dist/runtime-bundle.d.ts +3 -0
  41. package/dist/runtime-bundle.d.ts.map +1 -0
  42. package/dist/runtime-bundle.js +76 -0
  43. package/dist/runtime-bundle.js.map +1 -0
  44. package/dist/seo.d.ts +81 -0
  45. package/dist/seo.d.ts.map +1 -0
  46. package/dist/seo.js +534 -0
  47. package/dist/seo.js.map +1 -0
  48. package/package.json +8 -7
@@ -1,268 +1,359 @@
1
1
  import * as http from 'node:http';
2
2
  import * as fs from 'node:fs';
3
3
  import * as path from 'node:path';
4
- import { fileURLToPath } from 'node:url';
5
4
  import { compileScript, formatDiagnostic, KawaError } from '@kawaijs/parser';
6
- function getBaseThemeCss() {
7
- const dirname = path.dirname(fileURLToPath(import.meta.url));
8
- const candidates = [
9
- path.resolve(dirname, '..', '..', '..', 'renderer-dom', 'src', 'theme.css'),
10
- path.resolve(dirname, '..', '..', '..', 'renderer-dom', 'dist', 'theme.css'),
11
- path.resolve(dirname, '..', '..', 'node_modules', '@kawaijs', 'renderer-dom', 'src', 'theme.css'),
12
- path.resolve(dirname, '..', '..', 'node_modules', '@kawaijs', 'renderer-dom', 'dist', 'theme.css')
13
- ];
14
- for (const c of candidates) {
15
- if (fs.existsSync(c)) {
16
- return fs.readFileSync(c, 'utf-8');
17
- }
18
- }
19
- // Built-in fallback base CSS
20
- return `
21
- :root {
22
- --kawa-font-family: 'Inter', system-ui, -apple-system, sans-serif;
23
- --kawa-bg-color: #0b0f19;
24
- --kawa-text-color: #f3f4f6;
25
- --kawa-primary-accent: #f43f5e;
26
- --kawa-dialogue-bg: rgba(15, 23, 42, 0.85);
27
- --kawa-dialogue-border: rgba(244, 63, 94, 0.3);
28
- --kawa-dialogue-radius: 12px;
29
- --kawa-dialogue-padding: 24px 32px;
30
- --kawa-speaker-bg: #f43f5e;
31
- --kawa-speaker-color: #ffffff;
32
- --kawa-speaker-radius: 6px;
33
- --kawa-choice-bg: rgba(30, 41, 59, 0.9);
34
- --kawa-choice-hover-bg: rgba(244, 63, 94, 0.85);
35
- --kawa-choice-color: #ffffff;
36
- --kawa-choice-border: 1px solid rgba(255, 255, 255, 0.15);
37
- --kawa-choice-radius: 8px;
38
- --kawa-choice-padding: 14px 28px;
39
- --kawa-menu-btn-bg: rgba(15, 23, 42, 0.6);
40
- --kawa-menu-btn-hover-bg: rgba(244, 63, 94, 0.8);
41
- --kawa-stage-aspect-ratio: 16 / 9;
42
- }
43
- html, body { margin: 0; padding: 0; width: 100%; height: 100%; background: #030712; overflow: hidden; }
44
- .kawa-root { position: relative; width: 100vw; height: 100vh; background: radial-gradient(circle at 50% 50%, #0f172a 0%, #020617 100%); color: var(--kawa-text-color); font-family: var(--kawa-font-family); display: flex; align-items: center; justify-content: center; overflow: hidden; user-select: none; box-sizing: border-box; }
45
- .kawa-root * { box-sizing: border-box; }
46
- .kawa-stage { position: relative; width: 100%; max-width: calc(100vh * (16 / 9)); aspect-ratio: var(--kawa-stage-aspect-ratio); background: radial-gradient(ellipse at center, #1e293b 0%, #0f172a 100%); overflow: hidden; box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.8); }
47
- .kawa-background { position: absolute; inset: 0; z-index: 1; background: radial-gradient(ellipse at center, #1e293b 0%, #0f172a 100%); overflow: hidden; }
48
- .kawa-bg-layer { position: absolute; inset: 0; background-size: cover; background-position: center; background-repeat: no-repeat; transition: opacity 0.4s ease-in-out; opacity: 0; }
49
- .kawa-bg-layer.active { opacity: 1; }
50
- .kawa-characters { position: absolute; inset: 0; pointer-events: none; z-index: 2; display: flex; align-items: flex-end; }
51
- .kawa-sprite { position: absolute; bottom: 0; height: 85%; transition: transform 0.35s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.35s ease; display: flex; align-items: flex-end; justify-content: center; }
52
- .kawa-sprite img { max-height: 100%; width: auto; object-fit: contain; filter: drop-shadow(0 10px 15px rgba(0, 0, 0, 0.5)); }
53
- .kawa-sprite.kawa-pos-left { left: 15%; transform: translateX(-50%); }
54
- .kawa-sprite.kawa-pos-center { left: 50%; transform: translateX(-50%); }
55
- .kawa-sprite.kawa-pos-right { left: 85%; transform: translateX(-50%); }
56
- .kawa-ui-layer { position: absolute; inset: 0; z-index: 10; display: flex; flex-direction: column; justify-content: flex-end; padding: 32px 48px; pointer-events: none; }
57
- .kawa-dialogue-box { position: relative; width: 100%; min-height: 140px; background: var(--kawa-dialogue-bg); backdrop-filter: blur(12px); border: 1px solid var(--kawa-dialogue-border); border-radius: var(--kawa-dialogue-radius); padding: var(--kawa-dialogue-padding); box-shadow: 0 10px 30px rgba(0, 0, 0, 0.4); pointer-events: auto; cursor: pointer; }
58
- .kawa-speaker-tag { display: inline-block; font-weight: 700; font-size: 1.1rem; color: var(--kawa-speaker-color); background-color: var(--kawa-speaker-bg); padding: 4px 14px; border-radius: var(--kawa-speaker-radius); margin-bottom: 10px; }
59
- .kawa-dialogue-text { font-size: 1.15rem; line-height: 1.65; color: var(--kawa-text-color); word-break: break-word; }
60
- .kawa-continue-indicator { position: absolute; right: 24px; bottom: 16px; font-size: 1rem; color: var(--kawa-primary-accent); animation: kawa-bounce 1.2s infinite ease-in-out; }
61
- @keyframes kawa-bounce { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(4px); } }
62
- .kawa-choice-container { position: absolute; top: 45%; left: 50%; transform: translate(-50%, -50%); display: flex; flex-direction: column; gap: 14px; width: 80%; max-width: 500px; pointer-events: auto; z-index: 20; }
63
- .kawa-choice-btn { background: var(--kawa-choice-bg); backdrop-filter: blur(8px); color: var(--kawa-choice-color); border: var(--kawa-choice-border); border-radius: var(--kawa-choice-radius); padding: var(--kawa-choice-padding); font-size: 1.05rem; font-weight: 600; cursor: pointer; text-align: center; }
64
- .kawa-choice-btn:hover { background: var(--kawa-choice-hover-bg); transform: translateY(-2px) scale(1.02); }
65
- .kawa-quick-menu { display: flex; gap: 10px; justify-content: flex-end; margin-top: 10px; pointer-events: auto; }
66
- .kawa-btn { background: var(--kawa-menu-btn-bg); color: #cbd5e1; border: 1px solid rgba(255, 255, 255, 0.1); border-radius: 4px; padding: 4px 12px; font-size: 0.85rem; font-weight: 500; cursor: pointer; display: inline-flex; align-items: center; gap: 6px; }
67
- .kawa-btn:hover { background: var(--kawa-menu-btn-hover-bg); color: #ffffff; }
68
- .kawa-btn.active { background: var(--kawa-primary-accent); color: #ffffff; }
69
- .kawa-mode-badge { position: absolute; top: 20px; right: 24px; z-index: 30; background: rgba(15, 23, 42, 0.85); backdrop-filter: blur(6px); border: 1px solid var(--kawa-primary-accent); border-radius: 20px; padding: 6px 14px; font-size: 0.85rem; font-weight: 700; letter-spacing: 0.05em; color: var(--kawa-primary-accent); display: inline-flex; align-items: center; gap: 6px; }
70
- .kawa-modal-overlay { position: absolute; inset: 0; background: rgba(0, 0, 0, 0.82); backdrop-filter: blur(8px); z-index: 100; display: flex; align-items: center; justify-content: center; pointer-events: auto; }
71
- .kawa-modal-card { background: #1e293b; border: 1px solid rgba(255, 255, 255, 0.15); border-radius: 14px; width: 90%; max-width: 750px; max-height: 85%; display: flex; flex-direction: column; padding: 24px 28px; }
72
- .kawa-modal-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; border-bottom: 1px solid rgba(255, 255, 255, 0.1); padding-bottom: 12px; }
73
- .kawa-modal-title { font-size: 1.35rem; font-weight: 700; color: #ffffff; display: flex; align-items: center; gap: 8px; }
74
- .kawa-modal-body { overflow-y: auto; flex: 1; display: flex; flex-direction: column; gap: 12px; }
75
- .kawa-slots-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 16px; }
76
- .kawa-slot-card { background: rgba(15, 23, 42, 0.7); border: 1px solid rgba(255, 255, 255, 0.1); border-radius: 10px; padding: 16px; display: flex; flex-direction: column; justify-content: space-between; min-height: 130px; }
77
- .kawa-ending-card { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); background: rgba(15, 23, 42, 0.9); backdrop-filter: blur(16px); border: 1px solid rgba(244, 63, 94, 0.4); border-radius: 16px; padding: 32px 48px; display: flex; flex-direction: column; align-items: center; text-align: center; z-index: 50; }
78
- .kawa-ending-title { font-size: 2rem; font-weight: 800; color: var(--kawa-primary-accent); margin-bottom: 8px; }
79
- .kawa-ending-subtitle { font-size: 1.1rem; color: #cbd5e1; margin-bottom: 16px; }
80
- .kawa-setting-row { display: flex; flex-direction: column; gap: 6px; background: rgba(15, 23, 42, 0.6); padding: 12px 16px; border-radius: 8px; border: 1px solid rgba(255, 255, 255, 0.08); }
81
- .kawa-setting-header { display: flex; justify-content: space-between; align-items: center; font-size: 0.95rem; font-weight: 600; color: #e2e8f0; }
82
- .kawa-setting-value { font-size: 0.85rem; color: var(--kawa-primary-accent); font-weight: 700; }
83
- .kawa-slider { width: 100%; height: 6px; border-radius: 3px; background: #334155; outline: none; cursor: pointer; }
84
- .kawa-bold { font-weight: 700; }
85
- .kawa-italic { font-style: italic; }
86
- `;
87
- }
5
+ import { getBaseThemeCss, getInlineRuntimeScript } from '../runtime-bundle.js';
6
+ import { loadProjectConfig } from '../config.js';
7
+ import { buildPwaAssets, buildPwaIconSvg, renderPwaHeadTags, renderPwaRegisterScript } from '../pwa.js';
8
+ import { renderSeoHeadTags, resolveAppleTouchIcon, resolveFavicon, resolveHtmlLang } from '../seo.js';
9
+ import { enrichStoryPackage } from '../i18n.js';
88
10
  export function startDevServer(projectDir = '.', options = {}) {
89
11
  const rootDir = path.resolve(process.cwd(), projectDir);
90
- const scriptPath = path.join(rootDir, 'game', 'script.kawa');
91
- const stylePath = path.join(rootDir, 'game', 'style.css');
92
- const assetsDir = path.join(rootDir, 'game', 'assets');
12
+ let scriptPath = path.join(rootDir, 'game', 'script.kawa');
13
+ if (!fs.existsSync(scriptPath)) {
14
+ const rootCandidate = path.join(rootDir, 'script.kawa');
15
+ if (fs.existsSync(rootCandidate)) {
16
+ scriptPath = rootCandidate;
17
+ }
18
+ }
19
+ let stylePath = path.join(rootDir, 'game', 'style.css');
20
+ if (!fs.existsSync(stylePath)) {
21
+ const rootCandidate = path.join(rootDir, 'style.css');
22
+ if (fs.existsSync(rootCandidate)) {
23
+ stylePath = rootCandidate;
24
+ }
25
+ }
26
+ let assetsDir = path.join(rootDir, 'game', 'assets');
27
+ if (!fs.existsSync(assetsDir)) {
28
+ const rootCandidate = path.join(rootDir, 'assets');
29
+ if (fs.existsSync(rootCandidate)) {
30
+ assetsDir = rootCandidate;
31
+ }
32
+ }
93
33
  let port = options.port ?? 3000;
94
34
  if (!fs.existsSync(scriptPath)) {
95
35
  console.error(`❌ Error: Cannot find '${scriptPath}'. Make sure you are inside a Kawaijs project directory.`);
96
36
  return;
97
37
  }
98
38
  const clients = new Set();
99
- // Debounced file watcher for auto-reload
39
+ // Debounced file watcher for auto-reload (script/style/config only — never assets).
40
+ // Watching assets caused full page reloads when the browser fetched audio/images on Start
41
+ // (especially noticeable in Opera / Windows fs.watch noise).
100
42
  let reloadTimeout = null;
101
- const gameDir = path.join(rootDir, 'game');
102
- if (fs.existsSync(gameDir)) {
103
- fs.watch(gameDir, { recursive: true }, (_eventType, filename) => {
104
- if (filename &&
105
- (filename.endsWith('.kawa') || filename.endsWith('.css') || filename.startsWith('assets')) &&
106
- !filename.includes('~') &&
107
- !filename.startsWith('.') &&
108
- !filename.endsWith('.tmp')) {
109
- if (reloadTimeout)
110
- clearTimeout(reloadTimeout);
111
- reloadTimeout = setTimeout(() => {
112
- console.log(`🔄 [Kawa Dev] File changed: ${filename}. Reloading...`);
113
- for (const client of clients) {
114
- try {
115
- client.write(`data: reload\n\n`);
116
- }
117
- catch { }
118
- }
119
- }, 250);
43
+ const watchTargets = [scriptPath, stylePath, path.join(rootDir, 'game', 'kawa.config.json'), path.join(rootDir, 'kawa.config.json')]
44
+ .filter((p) => fs.existsSync(p));
45
+ const watchDirs = [...new Set(watchTargets.map((p) => path.dirname(p)))];
46
+ for (const watchDir of watchDirs) {
47
+ fs.watch(watchDir, { recursive: false }, (_eventType, filename) => {
48
+ if (!filename)
49
+ return;
50
+ const base = path.basename(filename);
51
+ const shouldReload = base.endsWith('.kawa') ||
52
+ base.endsWith('.css') ||
53
+ base === 'kawa.config.json';
54
+ if (!shouldReload || base.includes('~') || base.startsWith('.') || base.endsWith('.tmp')) {
55
+ return;
120
56
  }
57
+ if (reloadTimeout)
58
+ clearTimeout(reloadTimeout);
59
+ reloadTimeout = setTimeout(() => {
60
+ console.log(`🔄 [${new Date().toLocaleTimeString()}] Change detected in ${base}, reloading...`);
61
+ for (const client of clients) {
62
+ client.write('data: reload\n\n');
63
+ }
64
+ }, 200);
121
65
  });
122
66
  }
123
67
  const server = http.createServer((req, res) => {
124
- const url = req.url ?? '/';
125
- // 1. SSE Live Reload Endpoint
126
- if (url === '/__kawa_reload') {
68
+ const parsedUrl = new URL(req.url ?? '/', `http://localhost:${port}`);
69
+ const pathname = parsedUrl.pathname;
70
+ // 1. SSE Live Reload endpoint
71
+ if (pathname === '/__kawa_reload') {
127
72
  res.writeHead(200, {
128
73
  'Content-Type': 'text/event-stream',
129
74
  'Cache-Control': 'no-cache',
130
- 'Connection': 'keep-alive',
131
- 'Access-Control-Allow-Origin': '*'
75
+ 'Connection': 'keep-alive'
132
76
  });
133
77
  clients.add(res);
134
78
  req.on('close', () => clients.delete(res));
135
79
  return;
136
80
  }
137
- // 2. Dynamic Story JSON API
138
- if (url === '/api/story.json') {
81
+ // 2. Story compilation API
82
+ if (pathname === '/api/story.json') {
139
83
  try {
140
84
  const source = fs.readFileSync(scriptPath, 'utf-8');
141
- const story = compileScript(source, path.basename(scriptPath));
85
+ const compiledStory = compileScript(source, path.basename(scriptPath));
142
86
  res.writeHead(200, { 'Content-Type': 'application/json' });
143
- res.end(JSON.stringify(story));
87
+ res.end(JSON.stringify(enrichStoryPackage(compiledStory, rootDir)));
144
88
  }
145
89
  catch (err) {
146
- res.writeHead(500, { 'Content-Type': 'application/json' });
147
- let errMsg = 'Compilation Error';
90
+ let formatted = 'Compilation Error';
148
91
  if (err instanceof KawaError) {
149
92
  const source = fs.readFileSync(scriptPath, 'utf-8');
150
- errMsg = formatDiagnostic(err.diagnostic, source);
93
+ formatted = formatDiagnostic(err.diagnostic, source);
151
94
  }
152
95
  else if (err instanceof Error) {
153
- errMsg = err.message;
96
+ formatted = err.message;
154
97
  }
155
- res.end(JSON.stringify({ error: errMsg }));
98
+ res.writeHead(200, { 'Content-Type': 'application/json' });
99
+ res.end(JSON.stringify({ error: formatted }));
156
100
  }
157
101
  return;
158
102
  }
159
- // 3. User & Default Stylesheet
160
- if (url === '/style.css') {
161
- res.writeHead(200, { 'Content-Type': 'text/css' });
162
- let combinedCss = `/* Kawaijs Base Theme */\n` + getBaseThemeCss() + '\n\n';
163
- if (fs.existsSync(stylePath)) {
164
- combinedCss += `/* User Custom Styles */\n` + fs.readFileSync(stylePath, 'utf-8');
103
+ // 3b. PWA + favicon assets (dev preview)
104
+ if (pathname === '/manifest.webmanifest' ||
105
+ pathname === '/sw.js' ||
106
+ pathname === '/icon.svg' ||
107
+ pathname === '/favicon.svg' ||
108
+ pathname === '/favicon.png' ||
109
+ pathname === '/favicon.ico' ||
110
+ pathname === '/apple-touch-icon.png' ||
111
+ pathname === '/apple-touch-icon.svg') {
112
+ const activeConfig = loadProjectConfig(rootDir);
113
+ const favicon = resolveFavicon(rootDir, activeConfig);
114
+ const appleTouch = resolveAppleTouchIcon(rootDir, activeConfig, favicon);
115
+ if (pathname === '/manifest.webmanifest' || pathname === '/sw.js') {
116
+ if (activeConfig.pwa?.enabled === false) {
117
+ res.writeHead(404);
118
+ res.end('PWA disabled');
119
+ return;
120
+ }
121
+ const iconFile = favicon.isGenerated
122
+ ? 'icon.svg'
123
+ : favicon.outFileName.replace(/^favicon/, 'icon');
124
+ const pwa = buildPwaAssets(activeConfig, 'kawa-dev', {
125
+ iconFile: iconFile.endsWith('.ico') ? 'icon.svg' : iconFile,
126
+ iconMime: favicon.mimeType === 'image/x-icon' ? 'image/svg+xml' : favicon.mimeType
127
+ });
128
+ if (pathname === '/manifest.webmanifest') {
129
+ res.writeHead(200, { 'Content-Type': 'application/manifest+json' });
130
+ res.end(pwa.manifestJson);
131
+ return;
132
+ }
133
+ res.writeHead(200, { 'Content-Type': 'application/javascript; charset=utf-8' });
134
+ res.end(pwa.serviceWorkerJs);
135
+ return;
136
+ }
137
+ // Serve user favicon / apple-touch / generated icon
138
+ const serveFile = (abs, fallbackSvg, mime) => {
139
+ if (abs && fs.existsSync(abs)) {
140
+ res.writeHead(200, { 'Content-Type': mime });
141
+ fs.createReadStream(abs).pipe(res);
142
+ return;
143
+ }
144
+ res.writeHead(200, { 'Content-Type': 'image/svg+xml' });
145
+ res.end(fallbackSvg);
146
+ };
147
+ const generated = buildPwaIconSvg(activeConfig.seo?.themeColor || activeConfig.pwa?.themeColor || activeConfig.theme?.primaryColor);
148
+ if (pathname.startsWith('/apple-touch')) {
149
+ serveFile(appleTouch.absolutePath, generated, appleTouch.mimeType);
150
+ return;
165
151
  }
166
- res.end(combinedCss);
152
+ if (pathname === '/icon.svg' && favicon.isGenerated) {
153
+ res.writeHead(200, { 'Content-Type': 'image/svg+xml' });
154
+ res.end(generated);
155
+ return;
156
+ }
157
+ serveFile(favicon.absolutePath, generated, favicon.mimeType);
167
158
  return;
168
159
  }
169
- // 4. Game Assets (/assets/*)
170
- if (url.startsWith('/assets/')) {
171
- const cleanUrl = url.split('?')[0];
172
- const relPath = decodeURIComponent(cleanUrl.replace(/^\/?assets\//, ''));
173
- let filePath = path.join(assetsDir, relPath);
174
- if (!fs.existsSync(filePath) || !fs.statSync(filePath).isFile()) {
175
- const parsed = path.parse(filePath);
176
- const cleanName = parsed.name.replace(/^bg[\s_]+/i, '');
177
- for (const nameCandidate of [parsed.name, cleanName]) {
178
- for (const ext of ['', '.svg', '.png', '.webp', '.jpg', '.jpeg', '.mp3', '.ogg', '.wav']) {
179
- const candidate = path.join(parsed.dir, nameCandidate + ext);
180
- if (fs.existsSync(candidate) && fs.statSync(candidate).isFile()) {
181
- filePath = candidate;
182
- break;
183
- }
184
- }
185
- if (fs.existsSync(filePath) && fs.statSync(filePath).isFile())
186
- break;
187
- }
160
+ // 3. Static Assets serving
161
+ if (pathname.startsWith('/assets/') ||
162
+ pathname.startsWith('/backgrounds/') ||
163
+ pathname.startsWith('/characters/') ||
164
+ pathname.startsWith('/audio/')) {
165
+ let relativeAssetPath = pathname;
166
+ if (relativeAssetPath.startsWith('/assets/')) {
167
+ relativeAssetPath = relativeAssetPath.slice('/assets/'.length);
168
+ }
169
+ else if (relativeAssetPath.startsWith('/')) {
170
+ relativeAssetPath = relativeAssetPath.slice(1);
188
171
  }
189
- if (fs.existsSync(filePath) && fs.statSync(filePath).isFile()) {
172
+ const safeAssetPath = path.normalize(relativeAssetPath).replace(/^(\.\.[/\\])+/, '');
173
+ const assetsRoot = path.resolve(assetsDir);
174
+ const filePath = path.resolve(assetsDir, safeAssetPath);
175
+ const isInsideAssets = filePath === assetsRoot ||
176
+ filePath.startsWith(assetsRoot + path.sep);
177
+ if (isInsideAssets && fs.existsSync(filePath) && fs.statSync(filePath).isFile()) {
190
178
  const ext = path.extname(filePath).toLowerCase();
191
179
  const mimeTypes = {
192
180
  '.png': 'image/png',
193
181
  '.jpg': 'image/jpeg',
194
182
  '.jpeg': 'image/jpeg',
195
183
  '.webp': 'image/webp',
184
+ '.gif': 'image/gif',
196
185
  '.svg': 'image/svg+xml',
197
186
  '.mp3': 'audio/mpeg',
198
187
  '.ogg': 'audio/ogg',
199
- '.wav': 'audio/wav'
188
+ '.wav': 'audio/wav',
189
+ '.m4a': 'audio/mp4'
200
190
  };
201
- res.writeHead(200, { 'Content-Type': mimeTypes[ext] ?? 'application/octet-stream' });
191
+ const contentType = mimeTypes[ext] ?? 'application/octet-stream';
192
+ res.writeHead(200, { 'Content-Type': contentType });
202
193
  fs.createReadStream(filePath).pipe(res);
203
194
  return;
204
195
  }
205
- else {
206
- res.writeHead(404, { 'Content-Type': 'text/plain' });
207
- res.end('Asset not found');
208
- return;
209
- }
196
+ }
197
+ // 4. Combined Stylesheet
198
+ const activeConfig = loadProjectConfig(rootDir);
199
+ const sanitizeCssValue = (v) => v.replace(/[;{}<>\\]/g, '').slice(0, 120);
200
+ let configCss = ':root {\n';
201
+ if (activeConfig.theme?.primaryColor) {
202
+ configCss += ` --kawa-primary-accent: ${sanitizeCssValue(activeConfig.theme.primaryColor)};\n`;
203
+ }
204
+ if (activeConfig.theme?.fontFamily) {
205
+ configCss += ` --kawa-font-body: ${sanitizeCssValue(activeConfig.theme.fontFamily)};\n`;
206
+ }
207
+ if (activeConfig.theme?.headingFont) {
208
+ configCss += ` --kawa-font-heading: ${sanitizeCssValue(activeConfig.theme.headingFont)};\n`;
209
+ }
210
+ configCss += '}\n';
211
+ let combinedCss = getBaseThemeCss() + '\n\n' + configCss + '\n\n';
212
+ if (fs.existsSync(stylePath)) {
213
+ combinedCss += fs.readFileSync(stylePath, 'utf-8');
210
214
  }
211
215
  // 5. HTML Shell & Web Runtime
212
- res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
213
- res.end(`<!DOCTYPE html>
214
- <html lang="en">
215
- <head>
216
- <meta charset="UTF-8">
217
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
218
- <title>Kawaijs Visual Novel</title>
219
- <link rel="stylesheet" href="/style.css">
220
- <style>
221
- body { margin: 0; padding: 0; background: #000; overflow: hidden; }
222
- #error-overlay {
223
- display: none; position: fixed; inset: 0; background: rgba(15, 23, 42, 0.95);
224
- color: #f43f5e; font-family: monospace; padding: 32px; z-index: 9999;
225
- white-space: pre-wrap; font-size: 1.1rem; line-height: 1.5;
226
- }
227
- </style>
228
- </head>
229
- <body>
230
- <div id="error-overlay"></div>
231
- <div id="app"></div>
232
-
233
- <script type="module">
234
- // Live Reload Connection
235
- const sse = new EventSource('/__kawa_reload');
236
- sse.onmessage = (e) => {
237
- if (e.data === 'reload') window.location.reload();
238
- };
239
-
240
- // Load story and start game
241
- async function init() {
242
- const errorEl = document.getElementById('error-overlay');
243
- try {
244
- const res = await fetch('/api/story.json');
245
- const story = await res.json();
246
- if (story.error) {
247
- errorEl.style.display = 'block';
248
- errorEl.textContent = story.error;
249
- return;
216
+ let storyJson = 'null';
217
+ let initialError = '';
218
+ try {
219
+ const source = fs.readFileSync(scriptPath, 'utf-8');
220
+ const compiledStory = compileScript(source, path.basename(scriptPath));
221
+ storyJson = JSON.stringify(enrichStoryPackage(compiledStory, rootDir));
250
222
  }
251
-
252
- // Inline runtime VM + DOM Renderer
253
- ${getInlineRuntimeScript()}
254
-
255
- const app = mountKawaApp(story, document.getElementById('app'));
256
- window.__kawa_app = app;
257
- } catch (err) {
258
- errorEl.style.display = 'block';
259
- errorEl.textContent = err.stack || err.message;
260
- }
261
- }
262
-
263
- init();
264
- </script>
265
- </body>
223
+ catch (err) {
224
+ if (err instanceof KawaError) {
225
+ const source = fs.readFileSync(scriptPath, 'utf-8');
226
+ initialError = formatDiagnostic(err.diagnostic, source);
227
+ }
228
+ else if (err instanceof Error) {
229
+ initialError = err.message;
230
+ }
231
+ else {
232
+ initialError = String(err);
233
+ }
234
+ }
235
+ const gameTitle = (activeConfig.title || 'Kawaijs Visual Novel')
236
+ .replace(/&/g, '&amp;')
237
+ .replace(/</g, '&lt;')
238
+ .replace(/>/g, '&gt;')
239
+ .replace(/"/g, '&quot;');
240
+ const favicon = resolveFavicon(rootDir, activeConfig);
241
+ const appleTouch = resolveAppleTouchIcon(rootDir, activeConfig, favicon);
242
+ // In dev, point at the live routes we serve above
243
+ const faviconHref = favicon.isGenerated
244
+ ? './icon.svg'
245
+ : `./${favicon.outFileName}`;
246
+ const appleHref = appleTouch.absolutePath
247
+ ? `./${appleTouch.outFileName}`
248
+ : faviconHref;
249
+ const seoMeta = renderSeoHeadTags({
250
+ config: activeConfig,
251
+ favicon: { ...favicon, href: faviconHref },
252
+ appleTouchIcon: { ...appleTouch, href: appleHref },
253
+ pageUrl: activeConfig.seo?.canonicalUrl,
254
+ includeIconLinks: true
255
+ });
256
+ const cliStartLabel = options.startLabel ? JSON.stringify(options.startLabel) : 'undefined';
257
+ const pwaEnabled = activeConfig.pwa?.enabled !== false;
258
+ const pwaHead = pwaEnabled ? renderPwaHeadTags(activeConfig) : '';
259
+ const pwaRegister = pwaEnabled ? renderPwaRegisterScript() : '';
260
+ const htmlLang = resolveHtmlLang(activeConfig);
261
+ res.writeHead(200, {
262
+ 'Content-Type': 'text/html; charset=utf-8',
263
+ 'Cache-Control': 'no-cache, no-store, must-revalidate'
264
+ });
265
+ res.end(`<!DOCTYPE html>
266
+ <html lang="${htmlLang}">
267
+ <head>
268
+ <meta charset="UTF-8">
269
+ <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
270
+ <title>${gameTitle}</title>
271
+ ${seoMeta}
272
+ ${pwaHead}
273
+ <style>
274
+ ${combinedCss}
275
+ html, body, #app {
276
+ width: 100vw;
277
+ height: 100vh;
278
+ height: 100dvh;
279
+ margin: 0;
280
+ padding: 0;
281
+ overflow: hidden;
282
+ background: #000;
283
+ touch-action: manipulation;
284
+ -webkit-tap-highlight-color: transparent;
285
+ }
286
+ #error-overlay {
287
+ display: ${initialError ? 'block' : 'none'}; position: fixed; inset: 0; background: rgba(15, 23, 42, 0.95);
288
+ color: #f43f5e; font-family: monospace; padding: 32px; z-index: 9999;
289
+ white-space: pre-wrap; font-size: 1.1rem; line-height: 1.5; overflow: auto;
290
+ }
291
+ </style>
292
+ </head>
293
+ <body>
294
+ <div id="error-overlay">${initialError ? initialError.replace(/</g, '&lt;').replace(/>/g, '&gt;') : ''}</div>
295
+ <div id="app"></div>
296
+
297
+ <script type="module">
298
+ // Live Reload Connection
299
+ try {
300
+ const sse = new EventSource('/__kawa_reload');
301
+ sse.onmessage = (e) => {
302
+ if (e.data === 'reload') window.location.reload();
303
+ };
304
+ } catch {}
305
+
306
+ // Inline runtime VM + DOM Renderer with Start Menu
307
+ ${getInlineRuntimeScript('/')}
308
+
309
+ // Load story and mount game app
310
+ async function init() {
311
+ const errorEl = document.getElementById('error-overlay');
312
+ if (errorEl.style.display === 'block' && errorEl.textContent.trim()) {
313
+ return;
314
+ }
315
+
316
+ try {
317
+ let story = ${storyJson};
318
+ if (!story) {
319
+ const res = await fetch('/api/story.json');
320
+ if (!res.ok) throw new Error('Failed to load story: HTTP ' + res.status);
321
+ story = await res.json();
322
+ if (story.error) {
323
+ errorEl.style.display = 'block';
324
+ errorEl.textContent = story.error;
325
+ return;
326
+ }
327
+ }
328
+
329
+ const projectConfig = ${JSON.stringify(activeConfig)};
330
+ const cliStartLabel = ${cliStartLabel};
331
+
332
+ const app = mountKawaApp(story, document.getElementById('app'), {
333
+ startLabel: cliStartLabel,
334
+ share: projectConfig.share,
335
+ mainMenu: {
336
+ title: projectConfig.title,
337
+ galleryItems: projectConfig.gallery
338
+ },
339
+ typewriterSpeed: projectConfig.settings?.textSpeed,
340
+ autoDelayMs: projectConfig.settings?.autoDelay,
341
+ virtualCanvas: {
342
+ width: projectConfig.window?.width || 1280,
343
+ height: projectConfig.window?.height || 720
344
+ }
345
+ });
346
+ window.__kawa_app = app;
347
+ ${pwaRegister}
348
+ } catch (err) {
349
+ errorEl.style.display = 'block';
350
+ errorEl.textContent = err.stack || err.message;
351
+ }
352
+ }
353
+
354
+ init();
355
+ </script>
356
+ </body>
266
357
  </html>`);
267
358
  });
268
359
  server.on('error', (e) => {
@@ -278,1059 +369,11 @@ export function startDevServer(projectDir = '.', options = {}) {
278
369
  server.listen(port, () => {
279
370
  console.log(`\n🌸 Kawaijs Dev Server running at:`);
280
371
  console.log(` > Local: \x1b[36mhttp://localhost:${port}\x1b[0m`);
372
+ if (options.startLabel) {
373
+ console.log(` > Start: label "${options.startLabel}" (--at)`);
374
+ }
281
375
  console.log(` > Project: ${rootDir}`);
282
376
  console.log(` > Watching: game/script.kawa, game/style.css, game/assets/\n`);
283
377
  });
284
378
  }
285
- function getInlineRuntimeScript() {
286
- return `
287
- const SVG_ICONS = {
288
- 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>',
289
- 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>',
290
- 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>',
291
- 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>',
292
- 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>',
293
- 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>',
294
- 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>',
295
- 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>',
296
- 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>',
297
- 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>',
298
- 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>',
299
- 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>',
300
- 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>'
301
- };
302
-
303
- function formatRichText(raw) {
304
- return (raw || '')
305
- .replace(/&/g, '&amp;')
306
- .replace(/</g, '&lt;')
307
- .replace(/>/g, '&gt;')
308
- .replace(/\\{b\\}(.*?)\\{\\/b\\}/gi, '<strong class="kawa-bold">$1</strong>')
309
- .replace(/\\{i\\}(.*?)\\{\\/i\\}/gi, '<em class="kawa-italic">$1</em>')
310
- .replace(/\\{color=([^}]+)\\}(.*?)\\{\\/color\\}/gi, '<span style="color:$1">$2</span>')
311
- .replace(/\\{size=([^}]+)\\}(.*?)\\{\\/size\\}/gi, '<span style="font-size:$1">$2</span>');
312
- }
313
-
314
- class MemoryStorageAdapter {
315
- constructor() { this.store = new Map(); }
316
- getItem(k) { return this.store.get(k) || null; }
317
- setItem(k, v) { this.store.set(k, v); }
318
- removeItem(k) { this.store.delete(k); }
319
- }
320
- class LocalStorageAdapter {
321
- getItem(k) { return localStorage.getItem(k); }
322
- setItem(k, v) { localStorage.setItem(k, v); }
323
- removeItem(k) { localStorage.removeItem(k); }
324
- }
325
- class SaveManager {
326
- constructor() { this.storage = typeof localStorage !== 'undefined' ? new LocalStorageAdapter() : new MemoryStorageAdapter(); }
327
- async saveSlot(id, snapshot, previewText) {
328
- const slot = { id, name: 'Slot ' + id, timestamp: Date.now(), snapshot, previewText };
329
- this.storage.setItem('kawaijs_save_' + id, JSON.stringify(slot));
330
- return slot;
331
- }
332
- async loadSlot(id) {
333
- const raw = this.storage.getItem('kawaijs_save_' + id);
334
- return raw ? JSON.parse(raw) : null;
335
- }
336
- async deleteSlot(id) {
337
- this.storage.removeItem('kawaijs_save_' + id);
338
- }
339
- async listSlots(total = 6) {
340
- const slots = [];
341
- for (let i = 1; i <= total; i++) {
342
- slots.push(await this.loadSlot(String(i)));
343
- }
344
- return slots;
345
- }
346
- }
347
- function evaluateCondition(cond, vars) {
348
- const t = (cond || '').trim();
349
- if (!t || t === 'true') return true;
350
- if (t === 'false') return false;
351
- const toNum = (v) => {
352
- if (typeof v === 'number') return v;
353
- if (v === true) return 1;
354
- if (v === false || v === undefined || v === null || v === '') return 0;
355
- const n = Number(v);
356
- return isNaN(n) ? 0 : n;
357
- };
358
- const ops = ['>=', '<=', '!=', '==', '>', '<'];
359
- for (const op of ops) {
360
- const idx = t.indexOf(op);
361
- if (idx !== -1) {
362
- const l = resolveVal(t.slice(0, idx), vars);
363
- const r = resolveVal(t.slice(idx + op.length), vars);
364
- if (op === '>=') return toNum(l) >= toNum(r);
365
- if (op === '<=') return toNum(l) <= toNum(r);
366
- if (op === '>') return toNum(l) > toNum(r);
367
- if (op === '<') return toNum(l) < toNum(r);
368
- if (op === '==') return l === r || String(l) === String(r);
369
- if (op === '!=') return l !== r && String(l) !== String(r);
370
- }
371
- }
372
- if (t.startsWith('!')) {
373
- const v = vars[t.slice(1).trim()];
374
- return v === false || v === 'false' || v === 0 || v === '0' || v === undefined || v === null || v === '';
375
- }
376
- const val = t in vars ? vars[t] : resolveVal(t, vars);
377
- if (val === false || val === 'false' || val === 0 || val === '0' || val === undefined || val === null || val === '') return false;
378
- return Boolean(val);
379
- }
380
- function resolveVal(token, vars) {
381
- const t = token.trim();
382
- if (t === 'true') return true;
383
- if (t === 'false') return false;
384
- if (!isNaN(Number(t)) && t !== '') return Number(t);
385
- if ((t.startsWith('"') && t.endsWith('"')) || (t.startsWith("'") && t.endsWith("'"))) return t.slice(1, -1);
386
- if (t in vars) return vars[t];
387
- return t;
388
- }
389
- function applySetOp(curr, op, val, vars) {
390
- const res = (typeof val === 'string' && val in vars) ? vars[val] : val;
391
- if (op === '+=') return Number(curr || 0) + Number(res);
392
- if (op === '-=') return Number(curr || 0) - Number(res);
393
- return res;
394
- }
395
- class StoryVM {
396
- constructor(story) {
397
- this.story = story;
398
- const startLabel = story.meta.startLabel || 'start';
399
- this.state = {
400
- currentLabel: startLabel,
401
- instructionPointer: 0,
402
- callStack: [],
403
- variables: {},
404
- visual: { background: null, transition: null, characters: {} },
405
- audio: { music: null, voice: null },
406
- dialogue: null,
407
- choices: null,
408
- isWaitingForInput: false,
409
- isFinished: false
410
- };
411
- this.snapshotStack = [];
412
- this.listeners = new Set();
413
- this.audioListeners = new Set();
414
- this.history = [];
415
- this.saveManager = new SaveManager();
416
- this.isExecuting = false;
417
- }
418
- getState() { return this.state; }
419
- onStateChange(cb) { this.listeners.add(cb); return () => this.listeners.delete(cb); }
420
- onAudioEvent(cb) { this.audioListeners.add(cb); return () => this.audioListeners.delete(cb); }
421
- start() {
422
- this.state.currentLabel = this.story.meta.startLabel || 'start';
423
- this.state.instructionPointer = 0;
424
- this.state.isFinished = false;
425
- this.state.isWaitingForInput = false;
426
- this.snapshotStack = [];
427
- this.execute();
428
- }
429
- jump(target) {
430
- this.state.currentLabel = target;
431
- this.state.instructionPointer = 0;
432
- this.state.choices = null;
433
- this.state.isWaitingForInput = false;
434
- this.state.isFinished = false;
435
- this.execute();
436
- }
437
- next() {
438
- if (this.state.isFinished || this.isExecuting || (this.state.choices && this.state.choices.length > 0)) return;
439
- this.state.isWaitingForInput = false;
440
- this.execute();
441
- }
442
- choose(idx) {
443
- if (this.isExecuting || !this.state.choices || !this.state.choices[idx]) return;
444
- const choice = this.state.choices[idx];
445
- this.state.choices = null;
446
- this.state.isWaitingForInput = false;
447
- this.state.currentLabel = choice.targetLabel;
448
- this.state.instructionPointer = 0;
449
- this.execute();
450
- }
451
- rollback() {
452
- if (this.snapshotStack.length <= 1) return false;
453
- this.snapshotStack.pop();
454
- const prev = this.snapshotStack[this.snapshotStack.length - 1];
455
- if (prev) {
456
- this.state = JSON.parse(JSON.stringify(prev));
457
- this.notify();
458
- return true;
459
- }
460
- return false;
461
- }
462
- canRollback() { return this.snapshotStack.length > 1; }
463
- async save(slot) {
464
- await this.saveManager.saveSlot(slot, JSON.parse(JSON.stringify(this.state)), this.state.dialogue ? this.state.dialogue.text : '');
465
- }
466
- async load(slot) {
467
- const data = await this.saveManager.loadSlot(slot);
468
- if (data && data.snapshot) {
469
- this.state = JSON.parse(JSON.stringify(data.snapshot.state || data.snapshot));
470
- this.snapshotStack = [JSON.parse(JSON.stringify(this.state))];
471
- this.notify();
472
- return true;
473
- }
474
- return false;
475
- }
476
- execute() {
477
- if (this.isExecuting) return;
478
- this.isExecuting = true;
479
- try {
480
- while (!this.state.isWaitingForInput && !this.state.isFinished) {
481
- const currentInstructions = this.story.labels[this.state.currentLabel];
482
- if (!currentInstructions || this.state.instructionPointer >= currentInstructions.length) {
483
- if (this.state.callStack.length > 0) {
484
- const ret = this.state.callStack.pop();
485
- this.state.currentLabel = ret.returnLabel || ret.label;
486
- this.state.instructionPointer = ret.returnPointer || ret.pointer;
487
- continue;
488
- }
489
- this.state.isFinished = true;
490
- this.state.dialogue = null;
491
- this.state.choices = null;
492
- this.notify();
493
- break;
494
- }
495
- const inst = currentInstructions[this.state.instructionPointer];
496
- this.state.instructionPointer++;
497
- this.executeInstruction(inst);
498
- }
499
- } finally {
500
- this.isExecuting = false;
501
- }
502
- }
503
- executeInstruction(inst) {
504
- switch (inst.type) {
505
- case 'dialogue':
506
- case 'say': {
507
- let spkName = null;
508
- let spkColor = null;
509
- if (inst.speaker) {
510
- const decl = this.story.characters ? this.story.characters[inst.speaker] : null;
511
- spkName = decl ? (decl.name || decl.displayName || inst.speaker) : inst.speaker;
512
- spkColor = decl ? decl.color : null;
513
- }
514
- this.state.dialogue = { speaker: inst.speaker || null, speakerDisplayName: spkName, speakerColor: spkColor, text: inst.text };
515
- this.state.choices = null;
516
- this.state.isWaitingForInput = true;
517
- this.history.push({ speakerDisplayName: spkName, text: inst.text });
518
- this.snapshotStack.push(JSON.parse(JSON.stringify(this.state)));
519
- this.notify();
520
- break;
521
- }
522
- case 'scene': {
523
- this.state.visual.background = inst.background;
524
- this.state.visual.transition = inst.transition || null;
525
- this.state.visual.characters = {};
526
- this.notify();
527
- break;
528
- }
529
- case 'show': {
530
- const charDef = this.state.visual.characters[inst.character] || {};
531
- this.state.visual.characters[inst.character] = {
532
- expression: inst.expression || charDef.expression,
533
- position: inst.position || charDef.position || 'center'
534
- };
535
- this.notify();
536
- break;
537
- }
538
- case 'hide': {
539
- delete this.state.visual.characters[inst.character];
540
- this.notify();
541
- break;
542
- }
543
- case 'play_audio':
544
- case 'play': {
545
- if (inst.channel === 'music') this.state.audio.music = inst.track;
546
- else if (inst.channel === 'voice') this.state.audio.voice = inst.track;
547
- this.notifyAudio({ action: 'play', channel: inst.channel, track: inst.track, fade: inst.fade, loop: inst.loop !== false });
548
- break;
549
- }
550
- case 'stop_audio':
551
- case 'stop': {
552
- if (inst.channel === 'music') this.state.audio.music = null;
553
- else if (inst.channel === 'voice') this.state.audio.voice = null;
554
- this.notifyAudio({ action: 'stop', channel: inst.channel, fade: inst.fade });
555
- break;
556
- }
557
- case 'choice':
558
- case 'menu': {
559
- const avail = [];
560
- const list = inst.choices || [];
561
- for (const item of list) {
562
- if (item.condition) {
563
- if (evaluateCondition(item.condition, this.state.variables)) avail.push(item);
564
- } else {
565
- avail.push(item);
566
- }
567
- }
568
- this.state.choices = avail;
569
- this.state.dialogue = null;
570
- this.state.isWaitingForInput = true;
571
- this.snapshotStack.push(JSON.parse(JSON.stringify(this.state)));
572
- this.notify();
573
- break;
574
- }
575
- case 'jump': {
576
- this.state.currentLabel = inst.targetLabel;
577
- this.state.instructionPointer = 0;
578
- break;
579
- }
580
- case 'branch': {
581
- const pass = evaluateCondition(inst.condition, this.state.variables);
582
- const target = pass ? inst.thenLabel : (inst.elseLabel || inst.thenLabel);
583
- this.state.currentLabel = target;
584
- this.state.instructionPointer = 0;
585
- break;
586
- }
587
- case 'if': {
588
- const pass = evaluateCondition(inst.condition, this.state.variables);
589
- if (pass) {
590
- this.state.currentLabel = inst.thenLabel;
591
- this.state.instructionPointer = 0;
592
- } else if (inst.elseLabel) {
593
- this.state.currentLabel = inst.elseLabel;
594
- this.state.instructionPointer = 0;
595
- }
596
- break;
597
- }
598
- case 'set': {
599
- const cur = this.state.variables[inst.variable];
600
- this.state.variables[inst.variable] = applySetOp(cur, inst.operator || '=', inst.value, this.state.variables);
601
- break;
602
- }
603
- case 'call': {
604
- this.state.callStack.push({ returnLabel: this.state.currentLabel, returnPointer: this.state.instructionPointer });
605
- this.state.currentLabel = inst.targetLabel;
606
- this.state.instructionPointer = 0;
607
- break;
608
- }
609
- case 'return': {
610
- if (this.state.callStack.length > 0) {
611
- const ret = this.state.callStack.pop();
612
- this.state.currentLabel = ret.returnLabel || ret.label;
613
- this.state.instructionPointer = ret.returnPointer || ret.pointer;
614
- } else {
615
- this.state.isFinished = true;
616
- this.state.dialogue = null;
617
- this.state.choices = null;
618
- this.state.isWaitingForInput = false;
619
- this.notify();
620
- }
621
- break;
622
- }
623
- }
624
- }
625
- notify() { for (const l of this.listeners) l(this.state); }
626
- notifyAudio(e) { for (const l of this.audioListeners) l(e); }
627
- }
628
- class AudioManager {
629
- constructor() {
630
- this.masterVolume = 1.0;
631
- this.musicVolume = 0.8;
632
- this.soundVolume = 1.0;
633
- this.voiceVolume = 1.0;
634
- this.isMuted = false;
635
- this.currentMusicAudio = null;
636
- this.currentVoiceAudio = null;
637
- this.soundPool = [];
638
- }
639
- setMasterVolume(v) { this.masterVolume = Math.max(0, Math.min(1, v)); this.updateVolumes(); }
640
- setMusicVolume(v) { this.musicVolume = Math.max(0, Math.min(1, v)); this.updateVolumes(); }
641
- setSoundVolume(v) { this.soundVolume = Math.max(0, Math.min(1, v)); }
642
- setVoiceVolume(v) { this.voiceVolume = Math.max(0, Math.min(1, v)); this.updateVolumes(); }
643
- toggleMute() { this.isMuted = !this.isMuted; this.updateVolumes(); return this.isMuted; }
644
- updateVolumes() {
645
- if (this.currentMusicAudio) {
646
- this.currentMusicAudio.volume = this.isMuted ? 0 : this.masterVolume * this.musicVolume;
647
- }
648
- if (this.currentVoiceAudio) {
649
- this.currentVoiceAudio.volume = this.isMuted ? 0 : this.masterVolume * this.voiceVolume;
650
- }
651
- }
652
- playMusic(src, options = {}) {
653
- if (this.currentMusicAudio) {
654
- this.currentMusicAudio.pause();
655
- this.currentMusicAudio.src = '';
656
- }
657
- const audio = new Audio(src);
658
- audio.loop = options.loop !== false;
659
- const targetVolume = this.isMuted ? 0 : this.masterVolume * this.musicVolume;
660
- this.currentMusicAudio = audio;
661
- if (options.fadein && options.fadein > 0) {
662
- audio.volume = 0;
663
- audio.play().catch(() => {});
664
- this.fadeVolume(audio, 0, targetVolume, options.fadein * 1000);
665
- } else {
666
- audio.volume = targetVolume;
667
- audio.play().catch(() => {});
668
- }
669
- }
670
- stopMusic(options = {}) {
671
- const audio = this.currentMusicAudio;
672
- if (!audio) return;
673
- this.currentMusicAudio = null;
674
- if (options.fadeout && options.fadeout > 0) {
675
- this.fadeVolume(audio, audio.volume, 0, options.fadeout * 1000, () => {
676
- audio.pause();
677
- audio.src = '';
678
- });
679
- } else {
680
- audio.pause();
681
- audio.src = '';
682
- }
683
- }
684
- playSound(src, volume = 1) {
685
- if (this.isMuted) return;
686
- const audio = new Audio(src);
687
- audio.volume = volume * this.masterVolume * this.soundVolume;
688
- audio.play().catch(() => {});
689
- this.soundPool.push(audio);
690
- audio.addEventListener('ended', () => {
691
- const idx = this.soundPool.indexOf(audio);
692
- if (idx !== -1) this.soundPool.splice(idx, 1);
693
- });
694
- }
695
- playVoice(src, volume = 1) {
696
- if (this.currentVoiceAudio) {
697
- this.currentVoiceAudio.pause();
698
- this.currentVoiceAudio.src = '';
699
- }
700
- if (this.isMuted) return;
701
- const audio = new Audio(src);
702
- audio.volume = volume * this.masterVolume * this.voiceVolume;
703
- audio.play().catch(() => {});
704
- this.currentVoiceAudio = audio;
705
- }
706
- stopVoice() {
707
- if (this.currentVoiceAudio) {
708
- this.currentVoiceAudio.pause();
709
- this.currentVoiceAudio.src = '';
710
- this.currentVoiceAudio = null;
711
- }
712
- }
713
- fadeVolume(audio, from, to, durationMs, onComplete) {
714
- const steps = 20;
715
- const stepTime = durationMs / steps;
716
- const volumeStep = (to - from) / steps;
717
- let currentStep = 0;
718
- const interval = setInterval(() => {
719
- currentStep++;
720
- if (this.isMuted) {
721
- audio.volume = 0;
722
- } else {
723
- audio.volume = Math.max(0, Math.min(1, from + volumeStep * currentStep));
724
- }
725
- if (currentStep >= steps) {
726
- clearInterval(interval);
727
- if (!this.isMuted) audio.volume = to;
728
- if (onComplete) onComplete();
729
- }
730
- }, stepTime);
731
- }
732
- attachToVM(vm, assetResolver) {
733
- return vm.onAudioEvent((event) => {
734
- if (event.action === 'play' && event.track) {
735
- const url = assetResolver(event.track, event.channel);
736
- if (event.channel === 'music') {
737
- this.playMusic(url, { fadein: event.fade, loop: event.loop });
738
- } else if (event.channel === 'sound') {
739
- this.playSound(url);
740
- } else if (event.channel === 'voice') {
741
- this.playVoice(url);
742
- }
743
- } else if (event.action === 'stop') {
744
- if (event.channel === 'music') {
745
- this.stopMusic({ fadeout: event.fade });
746
- } else if (event.channel === 'voice') {
747
- this.stopVoice();
748
- }
749
- }
750
- });
751
- }
752
- }
753
-
754
- class DOMRenderer {
755
- constructor(vm, container, audioManager) {
756
- this.vm = vm;
757
- this.container = container;
758
- this.audioManager = audioManager;
759
- this.isChoicePending = false;
760
- this.activeBgLayer = 'A';
761
- this.currentBgUrl = '';
762
- this.activeChars = new Map();
763
- this.typewriterSpeed = 20;
764
- this.autoDelayMs = 1800;
765
- this.isAutoMode = false;
766
- this.isSkipMode = false;
767
- this.autoTimer = null;
768
- this.skipInterval = null;
769
- this.typewriterInterval = null;
770
- this.isTypewriting = false;
771
- this.fullText = '';
772
-
773
- if (typeof localStorage !== 'undefined') {
774
- try {
775
- const s = JSON.parse(localStorage.getItem('kawaijs_settings') || '{}');
776
- if (typeof s.typewriterSpeed === 'number') this.typewriterSpeed = s.typewriterSpeed;
777
- if (typeof s.autoDelayMs === 'number') this.autoDelayMs = s.autoDelayMs;
778
- } catch {}
779
- }
780
-
781
- this.build();
782
- vm.onStateChange(s => this.render(s));
783
- this.render(vm.getState());
784
- }
785
-
786
- build() {
787
- this.container.innerHTML = \`
788
- <div class="kawa-root">
789
- <div class="kawa-stage">
790
- <div class="kawa-background">
791
- <div class="kawa-bg-layer kawa-bg-a active"></div>
792
- <div class="kawa-bg-layer kawa-bg-b"></div>
793
- </div>
794
- <div class="kawa-characters kawa-sprites"></div>
795
- <div class="kawa-mode-badge" style="display:none"></div>
796
- <div class="kawa-ui-layer">
797
- <div class="kawa-choice-container kawa-choices" style="display:none"></div>
798
- <div class="kawa-dialogue-box kawa-dialogue">
799
- <div class="kawa-speaker-tag kawa-speaker" style="display:none"></div>
800
- <div class="kawa-dialogue-text kawa-text"></div>
801
- <div class="kawa-continue-indicator">\${SVG_ICONS.arrowDown}</div>
802
- </div>
803
- <nav class="kawa-quick-menu">
804
- <button class="kawa-btn kawa-back">\${SVG_ICONS.back} <span>Back</span></button>
805
- <button class="kawa-btn kawa-hist">\${SVG_ICONS.history} <span>History</span></button>
806
- <button class="kawa-btn kawa-auto">\${SVG_ICONS.auto} <span>Auto</span></button>
807
- <button class="kawa-btn kawa-skip">\${SVG_ICONS.skip} <span>Skip</span></button>
808
- <button class="kawa-btn kawa-save">\${SVG_ICONS.save} <span>Save</span></button>
809
- <button class="kawa-btn kawa-load">\${SVG_ICONS.load} <span>Load</span></button>
810
- <button class="kawa-btn kawa-settings">\${SVG_ICONS.settings} <span>Settings</span></button>
811
- </nav>
812
- </div>
813
- </div>
814
- </div>\`;
815
-
816
- this.rootEl = this.container.querySelector('.kawa-root');
817
- this.bgLayerA = this.container.querySelector('.kawa-bg-a');
818
- this.bgLayerB = this.container.querySelector('.kawa-bg-b');
819
- this.charsEl = this.container.querySelector('.kawa-characters');
820
- this.modeBadgeEl = this.container.querySelector('.kawa-mode-badge');
821
- this.boxEl = this.container.querySelector('.kawa-dialogue-box');
822
- this.spkEl = this.container.querySelector('.kawa-speaker-tag');
823
- this.txtEl = this.container.querySelector('.kawa-dialogue-text');
824
- this.choiceEl = this.container.querySelector('.kawa-choice-container');
825
- this.backBtn = this.container.querySelector('.kawa-back');
826
- this.histBtn = this.container.querySelector('.kawa-hist');
827
- this.autoBtn = this.container.querySelector('.kawa-auto');
828
- this.skipBtn = this.container.querySelector('.kawa-skip');
829
- this.saveBtn = this.container.querySelector('.kawa-save');
830
- this.loadBtn = this.container.querySelector('.kawa-load');
831
- this.settingsBtn = this.container.querySelector('.kawa-settings');
832
-
833
- this.boxEl.addEventListener('click', () => {
834
- if (this.isAutoMode) this.toggleAuto(false);
835
- if (this.isSkipMode) this.toggleSkip(false);
836
- this.advance();
837
- });
838
- this.backBtn.addEventListener('click', (e) => {
839
- e.stopPropagation();
840
- if (this.isAutoMode) this.toggleAuto(false);
841
- if (this.isSkipMode) this.toggleSkip(false);
842
- this.vm.rollback();
843
- });
844
- this.histBtn.addEventListener('click', (e) => { e.stopPropagation(); this.showHistory(); });
845
- this.autoBtn.addEventListener('click', (e) => { e.stopPropagation(); this.toggleAuto(); });
846
- this.skipBtn.addEventListener('click', (e) => { e.stopPropagation(); this.toggleSkip(); });
847
- this.saveBtn.addEventListener('click', (e) => { e.stopPropagation(); this.showSaveLoad('save'); });
848
- this.loadBtn.addEventListener('click', (e) => { e.stopPropagation(); this.showSaveLoad('load'); });
849
- this.settingsBtn.addEventListener('click', (e) => { e.stopPropagation(); this.showSettings(); });
850
-
851
- window.addEventListener('keydown', (e) => {
852
- const modal = this.rootEl.querySelector('.kawa-modal-overlay');
853
- if (modal) {
854
- if (e.key === 'Escape') modal.remove();
855
- return;
856
- }
857
- if (e.code === 'Space' || e.code === 'Enter') {
858
- if (document.activeElement && document.activeElement.tagName === 'BUTTON') return;
859
- e.preventDefault();
860
- if (this.isAutoMode) this.toggleAuto(false);
861
- if (this.isSkipMode) this.toggleSkip(false);
862
- this.advance();
863
- } else if (e.code === 'Backspace') {
864
- e.preventDefault();
865
- if (this.isAutoMode) this.toggleAuto(false);
866
- if (this.isSkipMode) this.toggleSkip(false);
867
- this.vm.rollback();
868
- } else if (e.key === 'a' || e.key === 'A') {
869
- this.toggleAuto();
870
- } else if (e.key === 'Tab' || e.key === 'Control') {
871
- e.preventDefault();
872
- this.toggleSkip();
873
- } else if (e.key === 's' || e.key === 'S') {
874
- this.showSaveLoad('save');
875
- } else if (e.key === 'l' || e.key === 'L') {
876
- this.showSaveLoad('load');
877
- } else if (e.key === 'h' || e.key === 'H') {
878
- this.showHistory();
879
- } else if (e.key === 'p' || e.key === 'P' || e.key === 'o' || e.key === 'O') {
880
- this.showSettings();
881
- }
882
- });
883
- }
884
-
885
- toggleAuto(force) {
886
- this.isAutoMode = force !== undefined ? force : !this.isAutoMode;
887
- if (this.isAutoMode && this.isSkipMode) this.toggleSkip(false);
888
- this.updateModeUI();
889
- if (this.isAutoMode && !this.isTypewriting) this.scheduleAuto();
890
- else if (!this.isAutoMode && this.autoTimer) {
891
- clearTimeout(this.autoTimer);
892
- this.autoTimer = null;
893
- }
894
- }
895
-
896
- toggleSkip(force) {
897
- this.isSkipMode = force !== undefined ? force : !this.isSkipMode;
898
- if (this.isSkipMode && this.isAutoMode) this.toggleAuto(false);
899
- this.updateModeUI();
900
- if (this.isSkipMode) {
901
- if (this.autoTimer) { clearTimeout(this.autoTimer); this.autoTimer = null; }
902
- if (!this.skipInterval) {
903
- this.skipInterval = setInterval(() => {
904
- const s = this.vm.getState();
905
- if (s.isFinished || (s.choices && s.choices.length > 0)) {
906
- this.toggleSkip(false);
907
- return;
908
- }
909
- if (this.isTypewriting) this.finishTypewriter();
910
- this.vm.next();
911
- }, 55);
912
- }
913
- } else {
914
- if (this.skipInterval) { clearInterval(this.skipInterval); this.skipInterval = null; }
915
- }
916
- }
917
-
918
- updateModeUI() {
919
- if (this.autoBtn) this.autoBtn.classList.toggle('active', this.isAutoMode);
920
- if (this.skipBtn) this.skipBtn.classList.toggle('active', this.isSkipMode);
921
- if (this.modeBadgeEl) {
922
- if (this.isSkipMode) {
923
- this.modeBadgeEl.innerHTML = SVG_ICONS.skip + ' SKIP';
924
- this.modeBadgeEl.style.display = 'inline-flex';
925
- } else if (this.isAutoMode) {
926
- this.modeBadgeEl.innerHTML = SVG_ICONS.auto + ' AUTO';
927
- this.modeBadgeEl.style.display = 'inline-flex';
928
- } else {
929
- this.modeBadgeEl.style.display = 'none';
930
- }
931
- }
932
- }
933
-
934
- scheduleAuto() {
935
- if (this.autoTimer) { clearTimeout(this.autoTimer); this.autoTimer = null; }
936
- if (!this.isAutoMode) return;
937
- const s = this.vm.getState();
938
- if (s.isFinished || (s.choices && s.choices.length > 0)) return;
939
- const dur = Math.max(800, this.autoDelayMs + (s.dialogue ? s.dialogue.text.length * 15 : 0));
940
- this.autoTimer = setTimeout(() => {
941
- if (this.isAutoMode) {
942
- const cur = this.vm.getState();
943
- if (!cur.isFinished && (!cur.choices || cur.choices.length === 0)) this.vm.next();
944
- }
945
- }, dur);
946
- }
947
-
948
- advance() {
949
- if (this.isTypewriting) this.finishTypewriter();
950
- else this.vm.next();
951
- }
952
-
953
- render(state) {
954
- this.isChoicePending = false;
955
- this.backBtn.disabled = !this.vm.canRollback();
956
-
957
- // 1. Dual Background Crossfade
958
- if (state.visual.background) {
959
- const rawBg = state.visual.background;
960
- const bg = rawBg.replace(/^bg[\s_]+/i, '').trim();
961
- const baseName = bg.replace(/\\.(svg|png|jpg|jpeg|webp)$/i, '');
962
- const svgUrl = '/assets/backgrounds/' + (bg.includes('.') ? bg : bg + '.svg');
963
- const pngUrl = '/assets/backgrounds/' + baseName + '.png';
964
-
965
- if (svgUrl !== this.currentBgUrl) {
966
- this.currentBgUrl = svgUrl;
967
- const inLayer = this.activeBgLayer === 'A' ? this.bgLayerB : this.bgLayerA;
968
- const outLayer = this.activeBgLayer === 'A' ? this.bgLayerA : this.bgLayerB;
969
-
970
- inLayer.style.backgroundImage = 'url("' + svgUrl + '")';
971
- inLayer.classList.add('active');
972
- outLayer.classList.remove('active');
973
- this.activeBgLayer = this.activeBgLayer === 'A' ? 'B' : 'A';
974
-
975
- const testImg = new Image();
976
- testImg.onerror = () => {
977
- const img2 = new Image();
978
- img2.onload = () => {
979
- inLayer.style.backgroundImage = 'url("' + pngUrl + '")';
980
- };
981
- img2.onerror = () => {
982
- inLayer.style.backgroundImage = 'radial-gradient(ellipse at center, #334155 0%, #0f172a 100%)';
983
- };
984
- img2.src = pngUrl;
985
- };
986
- testImg.src = svgUrl;
987
- }
988
- } else {
989
- this.currentBgUrl = '';
990
- this.bgLayerA.classList.remove('active');
991
- this.bgLayerB.classList.remove('active');
992
- }
993
-
994
- // 2. Character Reconciliation
995
- const currChars = state.visual.characters;
996
- const currIds = new Set(Object.keys(currChars));
997
-
998
- for (const [id, rec] of this.activeChars.entries()) {
999
- if (!currIds.has(id)) {
1000
- rec.div.remove();
1001
- this.activeChars.delete(id);
1002
- }
1003
- }
1004
-
1005
- for (const [id, char] of Object.entries(currChars)) {
1006
- const posClass = 'kawa-sprite kawa-pos-' + (char.position || 'center');
1007
- const expr = char.expression ? '/' + char.expression : '';
1008
- const primarySrc = '/assets/characters/' + id + expr + '.svg';
1009
-
1010
- let rec = this.activeChars.get(id);
1011
- if (!rec) {
1012
- const div = document.createElement('div');
1013
- div.className = posClass;
1014
- const img = document.createElement('img');
1015
- img.src = primarySrc;
1016
- img.alt = id;
1017
- let fallback = 0;
1018
- img.onerror = () => {
1019
- fallback++;
1020
- if (fallback === 1) img.src = '/assets/characters/' + id + expr + '.png';
1021
- else if (fallback === 2 && char.expression) img.src = '/assets/characters/' + id + '_' + char.expression + '.svg';
1022
- else if (fallback === 3 && char.expression) img.src = '/assets/characters/' + id + '_' + char.expression + '.png';
1023
- else {
1024
- img.style.display = 'none';
1025
- div.style.width = '220px'; div.style.height = '420px';
1026
- div.style.background = 'rgba(244,63,94,0.25)'; div.style.border = '2px dashed #f43f5e';
1027
- div.style.borderRadius = '16px'; div.style.display = 'flex'; div.style.alignItems = 'center';
1028
- div.style.justifyContent = 'center'; div.style.color = '#fff'; div.style.fontWeight = '600';
1029
- div.textContent = id + (char.expression ? ' (' + char.expression + ')' : '');
1030
- }
1031
- };
1032
- div.appendChild(img);
1033
- this.charsEl.appendChild(div);
1034
- rec = { div, img, expression: char.expression, position: char.position };
1035
- this.activeChars.set(id, rec);
1036
- } else {
1037
- rec.div.className = posClass;
1038
- if (rec.expression !== char.expression) {
1039
- rec.expression = char.expression;
1040
- rec.img.style.display = 'block';
1041
- rec.img.src = primarySrc;
1042
- }
1043
- }
1044
- }
1045
-
1046
- if (state.dialogue) {
1047
- this.boxEl.style.display = 'block';
1048
- if (state.dialogue.speakerDisplayName) {
1049
- this.spkEl.style.display = 'inline-block';
1050
- this.spkEl.textContent = state.dialogue.speakerDisplayName;
1051
- if (state.dialogue.speakerColor) this.spkEl.style.backgroundColor = state.dialogue.speakerColor;
1052
- } else {
1053
- this.spkEl.style.display = 'none';
1054
- }
1055
- this.startTypewriter(state.dialogue.text);
1056
- } else {
1057
- this.boxEl.style.display = 'none';
1058
- }
1059
-
1060
- if (state.choices && state.choices.length > 0) {
1061
- if (this.isAutoMode) this.toggleAuto(false);
1062
- if (this.isSkipMode) this.toggleSkip(false);
1063
- this.choiceEl.innerHTML = '';
1064
- this.choiceEl.style.display = 'flex';
1065
- this.choiceEl.style.pointerEvents = 'auto';
1066
- this.choiceEl.style.opacity = '1';
1067
- state.choices.forEach((c, idx) => {
1068
- const btn = document.createElement('button');
1069
- btn.className = 'kawa-choice-btn kawa-choice';
1070
- btn.textContent = c.text;
1071
- btn.addEventListener('click', (e) => {
1072
- e.stopPropagation();
1073
- if (this.isChoicePending) return;
1074
- this.isChoicePending = true;
1075
- this.choiceEl.style.pointerEvents = 'none';
1076
- this.choiceEl.style.opacity = '0.5';
1077
- this.vm.choose(idx);
1078
- });
1079
- this.choiceEl.appendChild(btn);
1080
- });
1081
- } else {
1082
- this.choiceEl.style.display = 'none';
1083
- }
1084
-
1085
- if (state.isFinished) {
1086
- if (this.isAutoMode) this.toggleAuto(false);
1087
- if (this.isSkipMode) this.toggleSkip(false);
1088
- this.choiceEl.style.display = 'none';
1089
- const exEnd = this.rootEl.querySelector('.kawa-ending-card');
1090
- if (!exEnd) {
1091
- const endCard = document.createElement('div');
1092
- endCard.className = 'kawa-ending-card';
1093
- endCard.innerHTML = \`
1094
- <div class="kawa-ending-title">The End</div>
1095
- <div class="kawa-ending-subtitle">Story complete! Thank you for playing.</div>
1096
- <div style="display:flex;gap:12px;margin-top:16px;">
1097
- <button class="kawa-btn kawa-btn-replay">\${SVG_ICONS.replay} Play Again</button>
1098
- <button class="kawa-btn kawa-btn-load-end">\${SVG_ICONS.load} Load Slot</button>
1099
- </div>\`;
1100
- endCard.querySelector('.kawa-btn-replay').addEventListener('click', () => {
1101
- this.vm.jump('start');
1102
- });
1103
- endCard.querySelector('.kawa-btn-load-end').addEventListener('click', () => {
1104
- this.showSaveLoad('load');
1105
- });
1106
- this.rootEl.appendChild(endCard);
1107
- }
1108
- } else {
1109
- const exEnd = this.rootEl.querySelector('.kawa-ending-card');
1110
- if (exEnd) exEnd.remove();
1111
- }
1112
- }
1113
-
1114
- startTypewriter(text) {
1115
- if (this.typewriterInterval) clearInterval(this.typewriterInterval);
1116
- if (this.autoTimer) { clearTimeout(this.autoTimer); this.autoTimer = null; }
1117
- this.fullText = text;
1118
-
1119
- if (this.typewriterSpeed <= 0 || this.isSkipMode) {
1120
- this.txtEl.innerHTML = formatRichText(text);
1121
- this.isTypewriting = false;
1122
- if (this.isAutoMode) this.scheduleAuto();
1123
- return;
1124
- }
1125
-
1126
- this.isTypewriting = true;
1127
- let idx = 0;
1128
- this.txtEl.innerHTML = '';
1129
- this.typewriterInterval = setInterval(() => {
1130
- idx++;
1131
- this.txtEl.innerHTML = formatRichText(this.fullText.slice(0, idx));
1132
- if (idx >= this.fullText.length) {
1133
- this.finishTypewriter();
1134
- }
1135
- }, this.typewriterSpeed);
1136
- }
1137
-
1138
- finishTypewriter() {
1139
- if (this.typewriterInterval) {
1140
- clearInterval(this.typewriterInterval);
1141
- this.typewriterInterval = null;
1142
- }
1143
- this.txtEl.innerHTML = formatRichText(this.fullText);
1144
- this.isTypewriting = false;
1145
- if (this.isAutoMode) this.scheduleAuto();
1146
- }
1147
-
1148
- showSettings() {
1149
- const ex = this.rootEl.querySelector('.kawa-modal-overlay');
1150
- if (ex) ex.remove();
1151
- const ov = document.createElement('div');
1152
- ov.className = 'kawa-modal-overlay';
1153
- const card = document.createElement('div');
1154
- card.className = 'kawa-modal-card';
1155
- card.innerHTML = \`
1156
- <div class="kawa-modal-header">
1157
- <div class="kawa-modal-title">\${SVG_ICONS.settings} <span>Settings</span></div>
1158
- <button class="kawa-btn kawa-close-btn">\${SVG_ICONS.close} <span>Close</span></button>
1159
- </div>
1160
- <div class="kawa-modal-body">
1161
- <div class="kawa-setting-row">
1162
- <div class="kawa-setting-header">
1163
- <span>Text Speed</span>
1164
- <span class="kawa-setting-value" id="kawa-sp-val">\${this.typewriterSpeed === 0 ? 'Instant' : this.typewriterSpeed + 'ms'}</span>
1165
- </div>
1166
- <input type="range" class="kawa-slider" id="kawa-sp-sl" min="0" max="60" step="5" value="\${this.typewriterSpeed}">
1167
- </div>
1168
- <div class="kawa-setting-row">
1169
- <div class="kawa-setting-header">
1170
- <span>Auto Delay</span>
1171
- <span class="kawa-setting-value" id="kawa-au-val">\${(this.autoDelayMs / 1000).toFixed(1)}s</span>
1172
- </div>
1173
- <input type="range" class="kawa-slider" id="kawa-au-sl" min="500" max="5000" step="250" value="\${this.autoDelayMs}">
1174
- </div>
1175
- </div>\`;
1176
- card.querySelector('.kawa-close-btn').addEventListener('click', () => ov.remove());
1177
- const spSl = card.querySelector('#kawa-sp-sl');
1178
- const spVal = card.querySelector('#kawa-sp-val');
1179
- spSl.addEventListener('input', () => {
1180
- this.typewriterSpeed = Number(spSl.value);
1181
- spVal.textContent = this.typewriterSpeed === 0 ? 'Instant' : this.typewriterSpeed + 'ms';
1182
- this.saveSettings();
1183
- });
1184
- const auSl = card.querySelector('#kawa-au-sl');
1185
- const auVal = card.querySelector('#kawa-au-val');
1186
- auSl.addEventListener('input', () => {
1187
- this.autoDelayMs = Number(auSl.value);
1188
- auVal.textContent = (this.autoDelayMs / 1000).toFixed(1) + 's';
1189
- this.saveSettings();
1190
- });
1191
- ov.appendChild(card);
1192
- this.rootEl.appendChild(ov);
1193
- }
1194
-
1195
- saveSettings() {
1196
- if (typeof localStorage !== 'undefined') {
1197
- try {
1198
- localStorage.setItem('kawaijs_settings', JSON.stringify({
1199
- typewriterSpeed: this.typewriterSpeed,
1200
- autoDelayMs: this.autoDelayMs
1201
- }));
1202
- } catch {}
1203
- }
1204
- }
1205
-
1206
- async showSaveLoad(mode) {
1207
- const ex = this.rootEl.querySelector('.kawa-modal-overlay');
1208
- if (ex) ex.remove();
1209
- const ov = document.createElement('div');
1210
- ov.className = 'kawa-modal-overlay';
1211
- const card = document.createElement('div');
1212
- card.className = 'kawa-modal-card';
1213
- card.innerHTML = \`
1214
- <div class="kawa-modal-header">
1215
- <div class="kawa-modal-title">\${mode === 'save' ? SVG_ICONS.save : SVG_ICONS.load} <span>\${mode === 'save' ? 'Save Game' : 'Load Game'}</span></div>
1216
- <button class="kawa-btn kawa-close-btn">\${SVG_ICONS.close} <span>Close</span></button>
1217
- </div>
1218
- <div class="kawa-modal-body"><div class="kawa-slots-grid"></div></div>\`;
1219
- card.querySelector('.kawa-close-btn').addEventListener('click', () => ov.remove());
1220
- const grid = card.querySelector('.kawa-slots-grid');
1221
- const slots = await this.vm.saveManager.listSlots(6);
1222
- slots.forEach((s, i) => {
1223
- const num = String(i + 1);
1224
- const c = document.createElement('div');
1225
- c.className = 'kawa-slot-card';
1226
- c.innerHTML = \`
1227
- <div class="kawa-slot-header">
1228
- <span class="kawa-slot-badge">Slot \${num}</span>
1229
- <span class="kawa-slot-time">\${s ? new Date(s.timestamp).toLocaleTimeString() : 'Empty'}</span>
1230
- </div>
1231
- <div class="kawa-slot-preview">\${s ? (s.previewText || 'Game in progress') : '<span class="kawa-slot-empty-text">No save data</span>'}</div>
1232
- <div class="kawa-slot-actions"></div>\`;
1233
- const act = c.querySelector('.kawa-slot-actions');
1234
- if (mode === 'save') {
1235
- const b = document.createElement('button');
1236
- b.className = 'kawa-slot-btn kawa-slot-btn-save';
1237
- b.innerHTML = SVG_ICONS.save + ' Save Here';
1238
- b.addEventListener('click', async () => {
1239
- await this.vm.save(num);
1240
- ov.remove();
1241
- this.showSaveLoad('save');
1242
- });
1243
- act.appendChild(b);
1244
- } else if (s) {
1245
- const b = document.createElement('button');
1246
- b.className = 'kawa-slot-btn kawa-slot-btn-load';
1247
- b.innerHTML = SVG_ICONS.load + ' Load';
1248
- b.addEventListener('click', async () => {
1249
- if (await this.vm.load(num)) ov.remove();
1250
- });
1251
- act.appendChild(b);
1252
- }
1253
- if (s) {
1254
- const d = document.createElement('button');
1255
- d.className = 'kawa-slot-btn kawa-slot-btn-del';
1256
- d.innerHTML = SVG_ICONS.trash;
1257
- d.addEventListener('click', async () => {
1258
- await this.vm.saveManager.deleteSlot(num);
1259
- ov.remove();
1260
- this.showSaveLoad(mode);
1261
- });
1262
- act.appendChild(d);
1263
- }
1264
- grid.appendChild(c);
1265
- });
1266
- ov.appendChild(card);
1267
- this.rootEl.appendChild(ov);
1268
- }
1269
-
1270
- showHistory() {
1271
- const ex = this.rootEl.querySelector('.kawa-modal-overlay');
1272
- if (ex) ex.remove();
1273
- const ov = document.createElement('div');
1274
- ov.className = 'kawa-modal-overlay';
1275
- const card = document.createElement('div');
1276
- card.className = 'kawa-modal-card';
1277
- card.innerHTML = \`
1278
- <div class="kawa-modal-header">
1279
- <div class="kawa-modal-title">\${SVG_ICONS.history} <span>Dialogue History</span></div>
1280
- <button class="kawa-btn kawa-close-btn">\${SVG_ICONS.close} <span>Close</span></button>
1281
- </div>
1282
- <div class="kawa-modal-body"></div>\`;
1283
- card.querySelector('.kawa-close-btn').addEventListener('click', () => ov.remove());
1284
- const body = card.querySelector('.kawa-modal-body');
1285
- if (this.vm.history.length === 0) {
1286
- body.innerHTML = '<div style="opacity:0.6">No dialogue history yet.</div>';
1287
- } else {
1288
- this.vm.history.forEach(h => {
1289
- const item = document.createElement('div');
1290
- item.className = 'kawa-history-item';
1291
- if (h.speakerDisplayName) {
1292
- item.innerHTML = '<div class="kawa-history-speaker">' + h.speakerDisplayName + '</div>';
1293
- }
1294
- const txt = document.createElement('div');
1295
- txt.innerHTML = formatRichText(h.text);
1296
- item.appendChild(txt);
1297
- body.appendChild(item);
1298
- });
1299
- }
1300
- ov.appendChild(card);
1301
- this.rootEl.appendChild(ov);
1302
- }
1303
- }
1304
-
1305
- function preloadAssets(story) {
1306
- if (typeof window === 'undefined') return;
1307
- for (const list of Object.values(story.labels || {})) {
1308
- for (const inst of list) {
1309
- if (inst.type === 'scene' && inst.background) {
1310
- const clean = inst.background.replace(/^bg[\s_]+/i, '').trim();
1311
- const img = new Image();
1312
- img.src = '/assets/backgrounds/' + (clean.includes('.') ? clean : clean + '.svg');
1313
- } else if (inst.type === 'show') {
1314
- const expr = inst.expression ? '/' + inst.expression : '';
1315
- const img = new Image();
1316
- img.src = '/assets/characters/' + inst.character + expr + '.svg';
1317
- }
1318
- }
1319
- }
1320
- }
1321
-
1322
- function mountKawaApp(story, container) {
1323
- preloadAssets(story);
1324
- const vm = new StoryVM(story);
1325
- const audio = new AudioManager();
1326
- const audioResolver = (track, channel) => {
1327
- return track.includes('.') ? '/assets/audio/' + track : '/assets/audio/' + track + '.mp3';
1328
- };
1329
- audio.attachToVM(vm, audioResolver);
1330
- const renderer = new DOMRenderer(vm, container, audio);
1331
- vm.start();
1332
- return { vm, renderer, audio };
1333
- }
1334
- `;
1335
- }
1336
379
  //# sourceMappingURL=dev.js.map