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