{"repo": "1366sll/ginkgoengine", "files": {"js/epilogue/ep-api.js": {"fc49fe9fb62b6319ac7de262f8c167b71e5f925a": {"t": 1785746020.3983562, "n": 8595, "h": "681e0621cc1d2cd5", "c": "/**\n * ep-api.js — DeepSeek API 客户端 (流式输出)\n * 后日谈 AI 对话模块\n */\n\nconst EpilogueAPI = (() => {\n // ─── 配置 ───────────────────────────────────\n const DEEPSEEK_BASE = 'https://api.deepseek.com';\n const MODEL = 'deepseek-chat';\n const STORAGE_KEY = 'epilogue_deepseek_key';\n let apiKey = '';\n let _totalPromptTokens = 0; // ★ 累计输入 token\n let _totalCompletionTokens = 0; // ★ 累计输出 token\n let _callCount = 0; // ★ API 调用次数\n\n /** ★ 粗估 token 数: 中文 ≈ 1.5 tok/字, 英文 ≈ 1.3 tok/词 */\n function estimateTokens(text) {\n if (!text) return 0;\n var cn = (text.match(/[一-鿿㐀-䶿]/g) || []).length;\n var en = (text.match(/[a-zA-Z]+/g) || []).length;\n var other = text.length - cn;\n return Math.ceil(cn * 1.5 + en * 1.3 + other * 0.3);\n }\n\n function getUsage() {\n return { prompt: _totalPromptTokens, completion: _totalCompletionTokens, calls: _callCount };\n }\n\n function resetUsage() {\n _totalPromptTokens = 0;\n _totalCompletionTokens = 0;\n _callCount = 0;\n }\n\n // ★ 简单加密:基于浏览器指纹的 XOR + base64\n // 比明文存储安全,防止 localStorage 被直接复制走 API Key\n function _deriveEncryptionKey() {\n // ★ 只用稳定不变的浏览器指纹(屏幕尺寸会变化,用 platform 代替)\n var fp = [\n navigator.userAgent || '',\n navigator.platform || '',\n navigator.language || '',\n navigator.hardwareConcurrency || '',\n !!window.chrome + '',\n location.hostname || 'localhost'\n ].join('::');\n // 简单 hash → 32 字符 hex\n var hash = 0;\n for (var i = 0; i < fp.length; i++) {\n var ch = fp.charCodeAt(i);\n hash = ((hash << 5) - hash) + ch;\n hash = hash | 0;\n }\n // 将 hash 扩展为可重复的 key 字符串\n var h = Math.abs(hash).toString(16);\n while (h.length < 32) h = h + h;\n return h.substring(0, 32);\n }\n\n function _encrypt(plaintext) {\n if (!plaintext) return '';\n try {\n var key = _deriveEncryptionKey();\n var result = '';\n for (var i = 0; i < plaintext.length; i++) {\n var cc = plaintext.charCodeAt(i) ^ key.charCodeAt(i % key.length);\n result += String.fromCharCode(cc);\n }\n return btoa(result);\n } catch (e) {\n return '';\n }\n }\n\n function _decrypt(ciphertext) {\n if (!ciphertext) return '';\n try {\n var raw = atob(ciphertext);\n var key = _deriveEncryptionKey();\n var result = '';\n for (var i = 0; i < raw.length; i++) {\n var cc = raw.charCodeAt(i) ^ key.charCodeAt(i % key.length);\n result += String.fromCharCode(cc);\n }\n return result;\n } catch (e) {\n // 降级:可能是旧版明文存储\n return ciphertext.startsWith('sk-') ? ciphertext : '';\n }\n }\n\n // 从 localStorage 读取保存的 API Key\n function loadKey() {\n var stored = localStorage.getItem(STORAGE_KEY) || '';\n if (!stored) return '';\n // 兼容旧版明文存储\n if (stored.startsWith('sk-')) return stored;\n return _decrypt(stored);\n }\n\n function saveKey(key) {\n if (key) {\n localStorage.setItem(STORAGE_KEY, _encrypt(key));\n } else {\n localStorage.removeItem(STORAGE_KEY);\n }\n apiKey = key;\n }\n\n function getKey() {\n return apiKey || loadKey();\n }\n\n function hasKey() {\n return !!(apiKey || loadKey());\n }\n\n // ─── 构建 messages 数组 ─────────────────────\n function buildMessages(systemPrompt, memorySummary, recentChats, userMessage) {\n // 替换 prompt 模板中的占位符\n const system = systemPrompt\n .replace('{{memory_summary}}', memorySummary || '(这是你们的第一次对话)')\n .replace('{{recent_chat}}', recentChats || '(这是你们的第一次对话)');\n\n const messages = [{ role: 'system', content: system }];\n\n // 添加最近的历史对话(最近 8 轮)\n if (recentChats && Array.isArray(recentChats)) {\n for (const turn of recentChats) {\n messages.push({ role: 'user', content: turn.user });\n messages.push({ role: 'assistant', content: turn.assistant });\n }\n }\n\n // 添加当前用户消息\n messages.push({ role: 'user', content: userMessage });\n\n return messages;\n }\n\n // ─── 流式调用 (核心) ────────────────────────\n /**\n * 发送消息并流式接收回复\n * @param {Object} options\n * @param {string} options.systemPrompt - 完整的 system prompt\n * @param {string} options.memorySummary - 长期记忆摘要\n * @param {Array} options.recentChats - 最近对话 [{user, assistant}, ...]\n * @param {string} options.userMessage - 用户输入\n * @param {Function} options.onToken - 每收到一个 token 时回调 (token: string)\n * @param {Function} options.onComplete - 流结束时回调 (fullText: string)\n * @param {Function} options.onError - 出错时回调 (error: Error)\n */\n async function chatStream({ systemPrompt, memorySummary, recentChats, userMessage, onToken, onComplete, onError }) {\n if (!hasKey()) {\n onError(new Error('NO_API_KEY'));\n return;\n }\n\n const key = getKey();\n const messages = buildMessages(systemPrompt, memorySummary, recentChats, userMessage);\n\n // ★ 累计输入 token\n var inputTokens = 0;\n messages.forEach(function(m) { inputTokens += estimateTokens(m.content); });\n _totalPromptTokens += inputTokens;\n _callCount++;\n\n try {\n const response = await fetch(`${DEEPSEEK_BASE}/v1/chat/completions`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'Authorization': `Bearer ${key}`\n },\n body: JSON.stringify({\n model: MODEL,\n messages: messages,\n stream: true,\n temperature: 0.8,\n max_tokens: 512\n })\n });\n\n if (!response.ok) {\n const errText = await response.text().catch(() => '');\n if (response.status === 401) {\n throw new Error('INVALID_API_KEY');\n }\n throw new Error(`API_ERROR: ${response.status} ${errText}`);\n }\n\n const reader = response.body.getReader();\n const decoder = new TextDecoder('utf-8');\n let fullText = '';\n let buffer = '';\n\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n\n buffer += decoder.decode(value, { stream: true });\n const lines = buffer.split('\\n');\n // 最后一个可能是不完整的行,保留在 buffer\n buffer = lines.pop() || '';\n\n for (const line of lines) {\n const trimmed = line.trim();\n if (!trimmed || !trimmed.startsWith('data: ')) continue;\n const data = trimmed.slice(6);\n if (data === '[DONE]') continue;\n\n try {\n const json = JSON.parse(data);\n const delta = json.choices?.[0]?.delta?.content;\n if (delta) {\n fullText += delta;\n onToken(delta, fullText);\n }\n } catch (e) {\n // 跳过无法解析的行\n }\n }\n }\n\n // ★ 累计输出 token\n _totalCompletionTokens += estimateTokens(fullText);\n onComplete(fullText);\n\n } catch (error) {\n console.error('[EpilogueAPI] Stream error:', error);\n onError(error);\n }\n }\n\n // ─── 快捷话题生成 (可选增强) ────────────────\n /**\n * 让 AI 生成几个快捷话题建议,帮助不知道聊什么的玩家\n * @param {Function} callback - (topics: string[]) => void\n */\n async function suggestTopics() {\n // 返回预设话题,避免额外 API 调用\n const topics = [\n '今天天气真好,想出去走走',\n '你最喜欢的音乐是什么?',\n '最近有什么有趣的事吗?',\n '下雨了……你带伞了吗',\n '你奶奶还有其他故事吗',\n '今天拍了一张很满意的照片,给你看看',\n '(沉默一会儿,什么也不说)',\n '下周社团有个活动,要不要一起去'\n ];\n return topics;\n }\n\n // ─── 公开 API ──────────────────────────────\n return {\n loadKey,\n saveKey,\n hasKey,\n getKey,\n chatStream,\n suggestTopics,\n estimateTokens,\n getUsage,\n resetUsage\n };\n})();\n"}, "740762071dab4203e1e5fb17603e3ad684b14f26": {"t": 1785758844.57053, "n": 8595, "h": "681e0621cc1d2cd5", "c": "/**\n * ep-api.js — DeepSeek API 客户端 (流式输出)\n * 后日谈 AI 对话模块\n */\n\nconst EpilogueAPI = (() => {\n // ─── 配置 ───────────────────────────────────\n const DEEPSEEK_BASE = 'https://api.deepseek.com';\n const MODEL = 'deepseek-chat';\n const STORAGE_KEY = 'epilogue_deepseek_key';\n let apiKey = '';\n let _totalPromptTokens = 0; // ★ 累计输入 token\n let _totalCompletionTokens = 0; // ★ 累计输出 token\n let _callCount = 0; // ★ API 调用次数\n\n /** ★ 粗估 token 数: 中文 ≈ 1.5 tok/字, 英文 ≈ 1.3 tok/词 */\n function estimateTokens(text) {\n if (!text) return 0;\n var cn = (text.match(/[一-鿿㐀-䶿]/g) || []).length;\n var en = (text.match(/[a-zA-Z]+/g) || []).length;\n var other = text.length - cn;\n return Math.ceil(cn * 1.5 + en * 1.3 + other * 0.3);\n }\n\n function getUsage() {\n return { prompt: _totalPromptTokens, completion: _totalCompletionTokens, calls: _callCount };\n }\n\n function resetUsage() {\n _totalPromptTokens = 0;\n _totalCompletionTokens = 0;\n _callCount = 0;\n }\n\n // ★ 简单加密:基于浏览器指纹的 XOR + base64\n // 比明文存储安全,防止 localStorage 被直接复制走 API Key\n function _deriveEncryptionKey() {\n // ★ 只用稳定不变的浏览器指纹(屏幕尺寸会变化,用 platform 代替)\n var fp = [\n navigator.userAgent || '',\n navigator.platform || '',\n navigator.language || '',\n navigator.hardwareConcurrency || '',\n !!window.chrome + '',\n location.hostname || 'localhost'\n ].join('::');\n // 简单 hash → 32 字符 hex\n var hash = 0;\n for (var i = 0; i < fp.length; i++) {\n var ch = fp.charCodeAt(i);\n hash = ((hash << 5) - hash) + ch;\n hash = hash | 0;\n }\n // 将 hash 扩展为可重复的 key 字符串\n var h = Math.abs(hash).toString(16);\n while (h.length < 32) h = h + h;\n return h.substring(0, 32);\n }\n\n function _encrypt(plaintext) {\n if (!plaintext) return '';\n try {\n var key = _deriveEncryptionKey();\n var result = '';\n for (var i = 0; i < plaintext.length; i++) {\n var cc = plaintext.charCodeAt(i) ^ key.charCodeAt(i % key.length);\n result += String.fromCharCode(cc);\n }\n return btoa(result);\n } catch (e) {\n return '';\n }\n }\n\n function _decrypt(ciphertext) {\n if (!ciphertext) return '';\n try {\n var raw = atob(ciphertext);\n var key = _deriveEncryptionKey();\n var result = '';\n for (var i = 0; i < raw.length; i++) {\n var cc = raw.charCodeAt(i) ^ key.charCodeAt(i % key.length);\n result += String.fromCharCode(cc);\n }\n return result;\n } catch (e) {\n // 降级:可能是旧版明文存储\n return ciphertext.startsWith('sk-') ? ciphertext : '';\n }\n }\n\n // 从 localStorage 读取保存的 API Key\n function loadKey() {\n var stored = localStorage.getItem(STORAGE_KEY) || '';\n if (!stored) return '';\n // 兼容旧版明文存储\n if (stored.startsWith('sk-')) return stored;\n return _decrypt(stored);\n }\n\n function saveKey(key) {\n if (key) {\n localStorage.setItem(STORAGE_KEY, _encrypt(key));\n } else {\n localStorage.removeItem(STORAGE_KEY);\n }\n apiKey = key;\n }\n\n function getKey() {\n return apiKey || loadKey();\n }\n\n function hasKey() {\n return !!(apiKey || loadKey());\n }\n\n // ─── 构建 messages 数组 ─────────────────────\n function buildMessages(systemPrompt, memorySummary, recentChats, userMessage) {\n // 替换 prompt 模板中的占位符\n const system = systemPrompt\n .replace('{{memory_summary}}', memorySummary || '(这是你们的第一次对话)')\n .replace('{{recent_chat}}', recentChats || '(这是你们的第一次对话)');\n\n const messages = [{ role: 'system', content: system }];\n\n // 添加最近的历史对话(最近 8 轮)\n if (recentChats && Array.isArray(recentChats)) {\n for (const turn of recentChats) {\n messages.push({ role: 'user', content: turn.user });\n messages.push({ role: 'assistant', content: turn.assistant });\n }\n }\n\n // 添加当前用户消息\n messages.push({ role: 'user', content: userMessage });\n\n return messages;\n }\n\n // ─── 流式调用 (核心) ────────────────────────\n /**\n * 发送消息并流式接收回复\n * @param {Object} options\n * @param {string} options.systemPrompt - 完整的 system prompt\n * @param {string} options.memorySummary - 长期记忆摘要\n * @param {Array} options.recentChats - 最近对话 [{user, assistant}, ...]\n * @param {string} options.userMessage - 用户输入\n * @param {Function} options.onToken - 每收到一个 token 时回调 (token: string)\n * @param {Function} options.onComplete - 流结束时回调 (fullText: string)\n * @param {Function} options.onError - 出错时回调 (error: Error)\n */\n async function chatStream({ systemPrompt, memorySummary, recentChats, userMessage, onToken, onComplete, onError }) {\n if (!hasKey()) {\n onError(new Error('NO_API_KEY'));\n return;\n }\n\n const key = getKey();\n const messages = buildMessages(systemPrompt, memorySummary, recentChats, userMessage);\n\n // ★ 累计输入 token\n var inputTokens = 0;\n messages.forEach(function(m) { inputTokens += estimateTokens(m.content); });\n _totalPromptTokens += inputTokens;\n _callCount++;\n\n try {\n const response = await fetch(`${DEEPSEEK_BASE}/v1/chat/completions`, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'Authorization': `Bearer ${key}`\n },\n body: JSON.stringify({\n model: MODEL,\n messages: messages,\n stream: true,\n temperature: 0.8,\n max_tokens: 512\n })\n });\n\n if (!response.ok) {\n const errText = await response.text().catch(() => '');\n if (response.status === 401) {\n throw new Error('INVALID_API_KEY');\n }\n throw new Error(`API_ERROR: ${response.status} ${errText}`);\n }\n\n const reader = response.body.getReader();\n const decoder = new TextDecoder('utf-8');\n let fullText = '';\n let buffer = '';\n\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n\n buffer += decoder.decode(value, { stream: true });\n const lines = buffer.split('\\n');\n // 最后一个可能是不完整的行,保留在 buffer\n buffer = lines.pop() || '';\n\n for (const line of lines) {\n const trimmed = line.trim();\n if (!trimmed || !trimmed.startsWith('data: ')) continue;\n const data = trimmed.slice(6);\n if (data === '[DONE]') continue;\n\n try {\n const json = JSON.parse(data);\n const delta = json.choices?.[0]?.delta?.content;\n if (delta) {\n fullText += delta;\n onToken(delta, fullText);\n }\n } catch (e) {\n // 跳过无法解析的行\n }\n }\n }\n\n // ★ 累计输出 token\n _totalCompletionTokens += estimateTokens(fullText);\n onComplete(fullText);\n\n } catch (error) {\n console.error('[EpilogueAPI] Stream error:', error);\n onError(error);\n }\n }\n\n // ─── 快捷话题生成 (可选增强) ────────────────\n /**\n * 让 AI 生成几个快捷话题建议,帮助不知道聊什么的玩家\n * @param {Function} callback - (topics: string[]) => void\n */\n async function suggestTopics() {\n // 返回预设话题,避免额外 API 调用\n const topics = [\n '今天天气真好,想出去走走',\n '你最喜欢的音乐是什么?',\n '最近有什么有趣的事吗?',\n '下雨了……你带伞了吗',\n '你奶奶还有其他故事吗',\n '今天拍了一张很满意的照片,给你看看',\n '(沉默一会儿,什么也不说)',\n '下周社团有个活动,要不要一起去'\n ];\n return topics;\n }\n\n // ─── 公开 API ──────────────────────────────\n return {\n loadKey,\n saveKey,\n hasKey,\n getKey,\n chatStream,\n suggestTopics,\n estimateTokens,\n getUsage,\n resetUsage\n };\n})();\n"}}, "README_CN.md": {"fc49fe9fb62b6319ac7de262f8c167b71e5f925a": {"t": 1785747362.1200545, "n": 11759, "h": "9ef318208edd51b0", "c": "# 🍂 Ginkgo Engine · 开源 Web Galgame 引擎\n\nGinkgo Engine 是一个**零依赖、纯 Web** 的视觉小说引擎。你只需要一个浏览器和一个文本编辑器,就能创作、测试和发布你自己的 Galgame。引擎得名于首发项目《银杏落雨时》中贯穿始终的银杏意象——象征着记忆、时间与温柔的力量。\n\n---\n\n## 目录\n\n1. [文档导航](#文档导航)\n2. [快速开始](#快速开始)\n3. [功能特性](#功能特性)\n4. [文件结构](#文件结构)\n5. [如何创作你自己的游戏](#如何创作你自己的游戏)\n6. [JSON 项目格式](#json-项目格式)\n7. [AI 后日谈配置](#ai-后日谈配置)\n8. [部署发布](#部署发布)\n9. [技术栈](#技术栈)\n10. [许可证](#许可证)\n\n---\n\n## 文档导航\n\n| 文档 | 说明 |\n|------|------|\n| 📝 **[编辑器使用指南](编辑器使用指南.md)** | 剧本编辑器的完整操作手册(23 章) |\n| 🎮 **[游戏使用指南](显示器使用指南.md)** | 游戏播放器的玩家操作手册 |\n| 💬 **[后日谈技术文档](EPILOGUE_README.md)** | AI 后日谈架构、API 参考、Live2D 渲染管线 |\n| 🌐 **[English README](README.md)** | README in English |\n\n> 英文副本:[Editor Guide](Editor_Guide_EN.md) · [Player Guide](Player_Guide_EN.md) · [Epilogue Docs](EPILOGUE_README_EN.md)\n\n---\n\n## 快速开始\n\n**3 步启动:**\n\n```bash\n# 1. 启动本地服务器\nnode server.js\n\n# 2. 打开浏览器\n# 游戏播放器: http://localhost:3000\n# 剧本编辑器: http://localhost:3000/editor.html\n# AI 后日谈: http://localhost:3000/epilogue.html\n\n# 3. 在游戏标题画面 → 设置 → 项目管理 → 加载项目文件\n# 选择 demo-project.json 即可体验引擎功能演示\n```\n\n> **需要 Node.js**(仅用于本地开发服务器)。部署到线上后不需要 Node.js——只需静态文件托管。\n\n---\n\n## 功能特性\n\n### 🎮 核心引擎\n\n| 功能 | 说明 |\n|------|------|\n| 📝 JSON 驱动 | 所有剧情、资源、分支全部由 JSON 定义,无需编程 |\n| 🎨 BBCode 标记 | 文字颜色、斜体、粗体、大小、震动等效果 |\n| 👥 多立绘系统 | 同一场景最多 3 个角色同屏,各自可调位置/透明/呼吸动画 |\n| 💾 存档系统 | 10 个手动存档位 + 快速存档 + 自动存档 |\n| ⚡ 自适应阅读速度 | 引擎自动学习玩家的阅读节奏,动态调整文字显示速度 |\n| 📦 懒加载 | 长篇项目可分章导出,引擎自动后台加载后续章节 |\n| 🔗 存档分享码 | 将游戏状态压缩为短码 + QR 码,分享给其他玩家跳转 |\n| 🎬 演出效果 | 震动、白闪、暗角、电影黑条、暖/冷色调、模糊等 15+ 滤镜 |\n| 🌿 粒子系统 | 银杏叶飘落粒子(可自定义 SVG 形状) |\n| 🎥 视频/CG/语音 | 支持 MP4 视频、全屏 CG 插画、角色配音 |\n| 🔀 分支选项 | 玩家选择驱动剧情分支 |\n| 🔧 条件跳转 | 变量驱动的自动分支(好感度判定等) |\n| 📖 章节系统 | 章节过渡动画、章节选择、锁定/解锁 |\n| 🎮 手柄支持 | Xbox / PlayStation 手柄(Gamepad API) |\n| ⌨️ 快捷键 | 空格/Enter 继续,Ctrl 快进,Alt 自动,F5 存档… |\n| 🖱️ 右键菜单 | 存档/读档/分享/设置/返回标题 一键操作 |\n| 🌐 PWA 离线 | Service Worker 缓存,可安装到桌面,离线游玩 |\n\n### 📝 内置编辑器\n\n| 功能 | 说明 |\n|------|------|\n| 🖼️ 实时预览 | 选中场景即显示游戏画面效果 |\n| 🤖 AI 协同写作 | 选中对白 → 选风格 → AI 改写润色(需要 DeepSeek Key) |\n| 🗺️ 流程图 | 交互式路线图可视化,拖拽平移/滚轮缩放 |\n| ⏪ 撤销/重做 | Ctrl+Z / Ctrl+Y,支持最近 50 步 |\n| 🔍 项目诊断 | 导出前自动检查:越界跳转、孤立场景、缺失资源 |\n| 🎭 角色档案 | 设定角色 personality/background/speechStyle,自动同步到后日谈 AI |\n| ☁️ ImgBB 图床 | 批量上传图片到云端,导出 JSON 自动替换为 CDN 链接 |\n| 📦 分章导出 | 按章节分割 JSON,配合引擎懒加载模式 |\n| 🎬 画面效果 | 可视化勾选滤镜/震动/白闪等效果 |\n| 🔀 分支编辑 | 可视化编辑选项和条件跳转 |\n\n### 💬 AI 后日谈\n\n| 功能 | 说明 |\n|------|------|\n| 🧠 DeepSeek 流式对话 | AI 实时生成角色回复(流式输出,逐字显示) |\n| 🎭 Live2D 角色 | Cubism 5 动态模型:呼吸、眨眼、口型同步、物理模拟 |\n| 🗣️ TTS 语音朗读 | Web Speech API 朗读 AI 回复 |\n| 😊 情绪检测 | AI 回复内容 → 自动匹配角色表情和画面滤镜 |\n| 📝 双层记忆 | 短期记忆(最近 8 轮)+ AI 摘要长期记忆 |\n| 🏁 结局感知 | AI 知道玩家达成的结局,呈现不同对话态度 |\n| 🎬 画面演出联动 | AI 回复中的 BBCode 触发画面特效 |\n| 📥 对话导出 | 下载 TXT + 精美 HTML 对话记录 |\n| 🔑 Key 加密 | API Key 浏览器指纹加密存储 |\n\n---\n\n## 文件结构\n\n```\nGinkgoEngine/\n├── index.html # 🎮 游戏播放器\n├── editor.html # 📝 剧本编辑器\n├── epilogue.html # 💬 AI 后日谈\n├── server.js # 本地开发服务器\n├── sw.js # PWA Service Worker\n├── manifest.json # PWA 清单\n├── demo-project.json # 最小示例项目(快速体验)\n├── epilogue_topics.json # 后日谈快捷话题\n├── imgbb-urls.json # 图床映射(游戏运行时加载)\n│\n├── css/\n│ ├── gal-core.css # 播放器核心样式\n│ ├── gal-beautify.css # 播放器界面美化\n│ └── epilogue.css # 后日谈样式\n│\n├── js/\n│ ├── config.js # 资源配置 + 常量 + 内置彩蛋\n│ ├── core.js # ★ 核心引擎(场景调度/演出/存档/预加载/分享码)\n│ ├── storage.js # 存储管理(IndexedDB/localStorage/存档缩略图)\n│ ├── gamepad.js # 手柄 + 鉴赏 + 封面 + 项目加载\n│ ├── main.js # UI 绑定 + 设置 + 鉴赏渲染 + 主线入口\n│ ├── epilogue-entry.js # 后日谈主菜单入口按钮\n│ └── epilogue/ # AI 后日谈模块\n│ ├── ep-api.js # DeepSeek API 客户端(流式 + Key 加密)\n│ ├── ep-engine.js # ★ 主状态机 + 结局感知 + 角色档案\n│ ├── ep-ui.js # 聊天面板 + BBCode + 设置 + 导出\n│ ├── ep-tts.js # 语音合成\n│ ├── ep-emotion.js # 情感检测 → 表情映射\n│ ├── ep-memory.js # 对话记忆(短期 + 长期摘要)\n│ ├── ep-effects.js # 画面演出效果\n│ ├── ep-customize.js # 场景自定义面板(背景/音乐/立绘)\n│ ├── ep-live2d.js # Live2D 渲染适配层\n│ └── ep-gaze.js # 注视追踪\n│\n├── live2d/ # Live2D Cubism 5 SDK\n│ ├── core/ # 运行时核心\n│ ├── live2dcubismframework.js # 官方框架\n│ ├── models/Haru/ # Haru 免费示例模型\n│ └── shaders/ # GLSL 着色器 (13 个)\n│\n├── assets/ # 📁 资源目录(占位 README)\n│ ├── bg/ # 背景图片\n│ ├── sprites/ # 角色立绘\n│ ├── bgm/ # 背景音乐\n│ ├── cg/ # 全屏插图\n│ ├── se/ # 音效\n│ ├── voice/ # 角色配音\n│ └── video/ # 过场视频\n│\n└── characters/template/ # 角色 Prompt 模板\n └── prompt_template.txt\n```\n\n---\n\n## 如何创作你自己的游戏\n\n### 第一步:准备素材\n\n把图片、音乐放到 `assets/` 对应的文件夹中:\n\n```\nassets/bg/教室.png\nassets/sprites/主角_微笑.png\nassets/bgm/日常.mp3\nassets/cg/结局图.jpg\n```\n\n### 第二步:打开编辑器\n\n```\nhttp://localhost:3000/editor.html\n```\n\n在左侧把素材**上传/注册**到资源库(给每个文件起一个键名):\n\n| 素材文件 | 键名 |\n|----------|------|\n| `assets/bg/教室.png` | `classroom` |\n| `assets/sprites/主角_微笑.png` | `hero_smile` |\n| `assets/bgm/日常.mp3` | `bgm_daily` |\n\n### 第三步:编写场景\n\n1. 在中间场景列表点击 **\"+ 新增场景\"** 或按 `Ctrl+N`\n2. 在右侧编辑面板填写:\n - **说话人**:角色名\n - **对话文本**:对白(可加 BBCode 如 `[c=red]重要的话[/c]`)\n - **背景键**:`classroom`\n - **立绘键**:`hero_smile`\n - **BGM键**:`bgm_daily`\n3. 点击 **\"💾 更新场景\"** 或按 `Ctrl+S` 保存\n4. 按 `↑` `↓` 键切换场景,中间预览区实时显示效果\n\n### 第四步:添加分支\n\n1. 在某个场景中将 **\"是否含有分支选项\"** 设为\"是\"\n2. 添加选项和跳转目标:\n ```\n 选项1: 接受邀请 → 跳转到场景 5\n 选项2: 婉拒 → 跳转到场景 8\n ```\n\n### 第五步:使用流程图\n\n点击预览区下方 **\"🗺️ 路线流程图\"** 查看你的故事分支结构。用滚轮缩放、拖拽平移来检查所有路线是否完整。\n\n### 第六步:诊断与导出\n\n1. 点击 **\"🔍 项目诊断\"** 检查问题\n2. 点击 **\"📦 导出完整项目\"**(`Ctrl+E`)导出 `galgame_full_project.json`\n3. (可选)点击 **\"📦 分章导出\"** 分割长篇\n\n### 第七步:测试游玩\n\n在游戏播放器中加载导出的 JSON 文件,测试完整流程。\n\n### 可选:AI 协同写作\n\n- 选中一句对白 → 选择改写风格(更温柔/更活泼/更文艺/更简洁/更深情)\n- 点击 **\"🤖 AI 改写\"**\n- 需要 DeepSeek API Key(与后日谈共用)\n\n### 可选:配置角色档案\n\n在编辑器底部 **\"🎭 角色设定档案\"** 面板填写角色信息。导出 JSON 后,将这些信息放到 `epilogue_characters.json` 中。后日谈 AI 会自动加载角色设定,让对话更符合人设。\n\n---\n\n## JSON 项目格式\n\n导出的 `galgame_full_project.json` 结构:\n\n```json\n{\n \"scenes\": [\n {\n \"speaker\": \"旁白\",\n \"text\": \"新学期第一天,他走进了教室。\",\n \"bg\": \"classroom\",\n \"sprite\": null,\n \"sprites\": [],\n \"bgm\": \"bgm_daily\",\n \"seKey\": null,\n \"voice\": null,\n \"locationHint\": \"教学楼\",\n \"next\": 1,\n \"triggerChapterTransition\": false,\n \"subtitle\": \"\",\n \"cgKey\": null,\n \"videoKey\": null,\n \"clearStage\": false,\n \"hasChoices\": false,\n \"choices\": [],\n \"effects\": [{ \"type\": \"vignette\", \"duration\": 3000 }],\n \"transition\": \"\",\n \"endingKey\": null,\n \"endingTitle\": null,\n \"affinity\": null,\n \"affinityLabel\": \"\",\n \"setVar\": null,\n \"conditionNext\": null\n }\n ],\n \"assets\": {\n \"bg\": { \"classroom\": \"assets/bg/教室.png\" },\n \"sprites\": { \"hero_smile\": \"assets/sprites/主角_微笑.png\" },\n \"bgm\": { \"bgm_daily\": \"assets/bgm/日常.mp3\" },\n \"cg\": {},\n \"se\": {},\n \"voice\": {},\n \"video\": {}\n },\n \"characters\": {\n \"hero\": {\n \"name\": \"主角\",\n \"personality\": \"温柔善良,有点害羞……\",\n \"background\": \"从小在南方小镇长大……\",\n \"speechStyle\": \"说话温柔,常用\"……\"停顿\",\n \"extraNotes\": \"\"\n }\n },\n \"imgbbUrls\": {}\n}\n```\n\n### 场景字段完整参考\n\n| 字段 | 类型 | 说明 | 默认值 |\n|------|------|------|--------|\n| `speaker` | string | 说话人名称(`\" \"` 表示不显示) | - |\n| `text` | string | 对话正文(支持 BBCode + `{变量}` 插值) | `\"\"` |\n| `bg` | string | 背景图键名 | - |\n| `sprite` | string\\|null | 单选立绘键名(sprites 为空时生效) | `null` |\n| `sprites` | array | 多立绘数组 `[{key, position, breathing, opacity}]` | `[]` |\n| `bgm` | string\\|null | BGM 键名 | `null` |\n| `seKey` | string\\|null | 音效键名 | `null` |\n| `voice` | string\\|null | 语音键名 | `null` |\n| `locationHint` | string | 画面左上角地点提示 | `\"\"` |\n| `next` | number | 默认跳转场景索引(-1=结束) | - |\n| `triggerChapterTransition` | bool | 是否在此处切章节 | `false` |\n| `subtitle` | string | 章节副标题(切章节时显示) | `\"\"` |\n| `cgKey` | string\\|null | 全屏 CG 键名 | `null` |\n| `videoKey` | string\\|null | 视频键名 | `null` |\n| `clearStage` | bool | 是否清空所有立绘 | `false` |\n| `hasChoices` | bool | 是否含有分支选项 | `false` |\n| `choices` | array | 选项列表 `[{text, nextIdx}]` | `[]` |\n| `effects` | array | 画面效果列表 `[{type, duration}]` | `[]` |\n| `transition` | string | 场景过渡效果(fade/slideLeft/zoomIn…) | `\"\"` |\n| `endingKey` | string\\|null | 结局标识符 | `null` |\n| `endingTitle` | string\\|null | 结局标题 | `null` |\n| `affinity` | number\\|undefined | 好感度 0-100(-1=隐藏) | `undefined` |\n| `affinityLabel` | string | 好感度标签 | `\"\"` |\n| `setVar` | object\\|null | 变量赋值 `{name: value}` | `null` |\n| `conditionNext` | object\\|null | 条件跳转 `{expression: nextIdx, default: idx}` | `null` |\n\n---\n\n## AI 后日谈配置\n\n### 1. 获取 DeepSeek API Key\n\n访问 [platform.deepseek.com/api_keys](https://platform.deepseek.com/api_keys) 注册并获取 Key(格式:`sk-xxxxxxxx`)。\n\n### 2. 配置 Key\n\n打开后日谈页面,点击 ⚙️ 设置 → 输入 API Key。Key 会使用浏览器指纹加密存储在本地。\n\n### 3. 配置角色 Prompt(可选)\n\n如果你在编辑器中填写了角色档案,将导出的 `characters` 字段保存为项目目录下的 `epilogue_characters.json`。后日谈会自动加载。\n\n或者直接编辑 `characters/template/prompt_template.txt` 创建角色设定模板。\n\n### 4. 配置 Live2D(可选)\n\n1. 从 [Live2D 官网](https://www.live2d.com/download/cubism-sdk/) 下载 Cubism 5 SDK for Web\n2. 将 `Core/live2dcubismcore.min.js` 放入 `live2d/core/`\n3. 将你的角色模型文件放入 `live2d/models/`\n4. 修改 `epilogue.html` 中的 `EpilogueLive2D.init()` 路径\n\n> **注意**:Live2D Cubism SDK 有独立的许可条款。年收入低于 1000 万日元的个人开发者免费使用。\n\n---\n\n## 部署发布\n\n### 方式一:静态托管(推荐)\n\n将整个 `GinkgoEngine/` 文件夹上传到任意静态文件服务:\n\n| 平台 | 说明 |\n|------|------|\n| **GitHub Pages** | 免费,上传后自动部署 |\n| **Netlify** | 拖拽文件夹即可,支持自定义域名 |\n| **Vercel** | 同上,全球 CDN |\n| **Cloudflare Pages** | 免费 500 次/月构建 |\n\n**注意**:线上部署时加上 `https://` 才能启用 PWA 离线缓存。\n\n### 方式二:自托管\n\n```bash\n# 方式 A: 用内置 server.js\nnode server.js\n\n# 方式 B: 用 npx serve\nnpx serve . -p 3000\n\n# 方式 C: 用 nginx\n# 将 root 指向 GinkgoEngine 目录即可\n```\n\n### 方式三:Docker(可选)\n\n```dockerfile\nFROM node:18-alpine\nWORKDIR /app\nCOPY . .\nEXPOSE 3000\nCMD [\"node\", \"server.js\"]\n```\n\n### 离线模式\n\n部署后,玩家首次访问时 Service Worker 会自动缓存核心文件(HTML/CSS/JS)。后续访问即使断网也能游玩。但以下情况需要在线:\n\n- CDN 外部资源(Google Fonts、Font Awesome)首次加载\n- AI 后日谈(需要访问 DeepSeek API)\n- 图床图片(如果使用了 ImgBB)\n\n---\n\n## 技术栈\n\n| 层 | 技术 | 说明 |\n|---|------|------|\n| 语言 | 纯 JavaScript (ES5+) | 零构建工具,浏览器原生运行 |\n| 样式 | CSS3 | 动画、滤镜、过渡、Grid/Flexbox |\n| 音频 | Howler.js | 外部音频引擎(CDN 加载) |\n| 动画 | GSAP 3 | 外部动画引擎(CDN 加载) |\n| 粒子 | tsParticles 2 | 银杏叶等粒子特效(CDN 加载) |\n| Live2D | Cubism 5 SDK | 动态角色渲染(本地文件) |\n| AI | DeepSeek Chat API | 流式对话生成 |\n| TTS | Web Speech API | 浏览器内置语音合成 |\n| 存储 | localStorage + IndexedDB | 存档/设置/解锁/记忆 |\n| PWA | Service Worker | 离线缓存 + 桌面安装 |\n| 图标 | Font Awesome 6 | 外部矢量图标(CDN 加载) |\n| 字体 | Google Fonts | Noto Serif SC 等(CDN 加载) |\n\n---\n\n## 许可证\n\n```\nMIT License\n\nCopyright (c) 2025-2026 Ginkgo Engine Contributors\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.\n```\n\n**第三方组件许可**:\n\n| 组件 | 许可 |\n|------|------|\n| Live2D Cubism SDK | [Live2D 专有许可](https://www.live2d.com/eula/) — 年收入 < 1000 万日元免费 |\n| Howler.js | MIT |\n| GSAP | 标准许可(免费用于非商业项目) |\n| tsParticles | MIT |\n| Font Awesome 6 | [CC BY 4.0 + SIL OFL 1.1](https://fontawesome.com/license/free) |\n\n---\n\n**🍂 银杏叶飘落的季节,属于你自己的故事,从这里开始。**\n"}, "740762071dab4203e1e5fb17603e3ad684b14f26": {"t": 1785758846.0241797, "n": 11759, "h": "9ef318208edd51b0", "c": "# 🍂 Ginkgo Engine · 开源 Web Galgame 引擎\n\nGinkgo Engine 是一个**零依赖、纯 Web** 的视觉小说引擎。你只需要一个浏览器和一个文本编辑器,就能创作、测试和发布你自己的 Galgame。引擎得名于首发项目《银杏落雨时》中贯穿始终的银杏意象——象征着记忆、时间与温柔的力量。\n\n---\n\n## 目录\n\n1. [文档导航](#文档导航)\n2. [快速开始](#快速开始)\n3. [功能特性](#功能特性)\n4. [文件结构](#文件结构)\n5. [如何创作你自己的游戏](#如何创作你自己的游戏)\n6. [JSON 项目格式](#json-项目格式)\n7. [AI 后日谈配置](#ai-后日谈配置)\n8. [部署发布](#部署发布)\n9. [技术栈](#技术栈)\n10. [许可证](#许可证)\n\n---\n\n## 文档导航\n\n| 文档 | 说明 |\n|------|------|\n| 📝 **[编辑器使用指南](编辑器使用指南.md)** | 剧本编辑器的完整操作手册(23 章) |\n| 🎮 **[游戏使用指南](显示器使用指南.md)** | 游戏播放器的玩家操作手册 |\n| 💬 **[后日谈技术文档](EPILOGUE_README.md)** | AI 后日谈架构、API 参考、Live2D 渲染管线 |\n| 🌐 **[English README](README.md)** | README in English |\n\n> 英文副本:[Editor Guide](Editor_Guide_EN.md) · [Player Guide](Player_Guide_EN.md) · [Epilogue Docs](EPILOGUE_README_EN.md)\n\n---\n\n## 快速开始\n\n**3 步启动:**\n\n```bash\n# 1. 启动本地服务器\nnode server.js\n\n# 2. 打开浏览器\n# 游戏播放器: http://localhost:3000\n# 剧本编辑器: http://localhost:3000/editor.html\n# AI 后日谈: http://localhost:3000/epilogue.html\n\n# 3. 在游戏标题画面 → 设置 → 项目管理 → 加载项目文件\n# 选择 demo-project.json 即可体验引擎功能演示\n```\n\n> **需要 Node.js**(仅用于本地开发服务器)。部署到线上后不需要 Node.js——只需静态文件托管。\n\n---\n\n## 功能特性\n\n### 🎮 核心引擎\n\n| 功能 | 说明 |\n|------|------|\n| 📝 JSON 驱动 | 所有剧情、资源、分支全部由 JSON 定义,无需编程 |\n| 🎨 BBCode 标记 | 文字颜色、斜体、粗体、大小、震动等效果 |\n| 👥 多立绘系统 | 同一场景最多 3 个角色同屏,各自可调位置/透明/呼吸动画 |\n| 💾 存档系统 | 10 个手动存档位 + 快速存档 + 自动存档 |\n| ⚡ 自适应阅读速度 | 引擎自动学习玩家的阅读节奏,动态调整文字显示速度 |\n| 📦 懒加载 | 长篇项目可分章导出,引擎自动后台加载后续章节 |\n| 🔗 存档分享码 | 将游戏状态压缩为短码 + QR 码,分享给其他玩家跳转 |\n| 🎬 演出效果 | 震动、白闪、暗角、电影黑条、暖/冷色调、模糊等 15+ 滤镜 |\n| 🌿 粒子系统 | 银杏叶飘落粒子(可自定义 SVG 形状) |\n| 🎥 视频/CG/语音 | 支持 MP4 视频、全屏 CG 插画、角色配音 |\n| 🔀 分支选项 | 玩家选择驱动剧情分支 |\n| 🔧 条件跳转 | 变量驱动的自动分支(好感度判定等) |\n| 📖 章节系统 | 章节过渡动画、章节选择、锁定/解锁 |\n| 🎮 手柄支持 | Xbox / PlayStation 手柄(Gamepad API) |\n| ⌨️ 快捷键 | 空格/Enter 继续,Ctrl 快进,Alt 自动,F5 存档… |\n| 🖱️ 右键菜单 | 存档/读档/分享/设置/返回标题 一键操作 |\n| 🌐 PWA 离线 | Service Worker 缓存,可安装到桌面,离线游玩 |\n\n### 📝 内置编辑器\n\n| 功能 | 说明 |\n|------|------|\n| 🖼️ 实时预览 | 选中场景即显示游戏画面效果 |\n| 🤖 AI 协同写作 | 选中对白 → 选风格 → AI 改写润色(需要 DeepSeek Key) |\n| 🗺️ 流程图 | 交互式路线图可视化,拖拽平移/滚轮缩放 |\n| ⏪ 撤销/重做 | Ctrl+Z / Ctrl+Y,支持最近 50 步 |\n| 🔍 项目诊断 | 导出前自动检查:越界跳转、孤立场景、缺失资源 |\n| 🎭 角色档案 | 设定角色 personality/background/speechStyle,自动同步到后日谈 AI |\n| ☁️ ImgBB 图床 | 批量上传图片到云端,导出 JSON 自动替换为 CDN 链接 |\n| 📦 分章导出 | 按章节分割 JSON,配合引擎懒加载模式 |\n| 🎬 画面效果 | 可视化勾选滤镜/震动/白闪等效果 |\n| 🔀 分支编辑 | 可视化编辑选项和条件跳转 |\n\n### 💬 AI 后日谈\n\n| 功能 | 说明 |\n|------|------|\n| 🧠 DeepSeek 流式对话 | AI 实时生成角色回复(流式输出,逐字显示) |\n| 🎭 Live2D 角色 | Cubism 5 动态模型:呼吸、眨眼、口型同步、物理模拟 |\n| 🗣️ TTS 语音朗读 | Web Speech API 朗读 AI 回复 |\n| 😊 情绪检测 | AI 回复内容 → 自动匹配角色表情和画面滤镜 |\n| 📝 双层记忆 | 短期记忆(最近 8 轮)+ AI 摘要长期记忆 |\n| 🏁 结局感知 | AI 知道玩家达成的结局,呈现不同对话态度 |\n| 🎬 画面演出联动 | AI 回复中的 BBCode 触发画面特效 |\n| 📥 对话导出 | 下载 TXT + 精美 HTML 对话记录 |\n| 🔑 Key 加密 | API Key 浏览器指纹加密存储 |\n\n---\n\n## 文件结构\n\n```\nGinkgoEngine/\n├── index.html # 🎮 游戏播放器\n├── editor.html # 📝 剧本编辑器\n├── epilogue.html # 💬 AI 后日谈\n├── server.js # 本地开发服务器\n├── sw.js # PWA Service Worker\n├── manifest.json # PWA 清单\n├── demo-project.json # 最小示例项目(快速体验)\n├── epilogue_topics.json # 后日谈快捷话题\n├── imgbb-urls.json # 图床映射(游戏运行时加载)\n│\n├── css/\n│ ├── gal-core.css # 播放器核心样式\n│ ├── gal-beautify.css # 播放器界面美化\n│ └── epilogue.css # 后日谈样式\n│\n├── js/\n│ ├── config.js # 资源配置 + 常量 + 内置彩蛋\n│ ├── core.js # ★ 核心引擎(场景调度/演出/存档/预加载/分享码)\n│ ├── storage.js # 存储管理(IndexedDB/localStorage/存档缩略图)\n│ ├── gamepad.js # 手柄 + 鉴赏 + 封面 + 项目加载\n│ ├── main.js # UI 绑定 + 设置 + 鉴赏渲染 + 主线入口\n│ ├── epilogue-entry.js # 后日谈主菜单入口按钮\n│ └── epilogue/ # AI 后日谈模块\n│ ├── ep-api.js # DeepSeek API 客户端(流式 + Key 加密)\n│ ├── ep-engine.js # ★ 主状态机 + 结局感知 + 角色档案\n│ ├── ep-ui.js # 聊天面板 + BBCode + 设置 + 导出\n│ ├── ep-tts.js # 语音合成\n│ ├── ep-emotion.js # 情感检测 → 表情映射\n│ ├── ep-memory.js # 对话记忆(短期 + 长期摘要)\n│ ├── ep-effects.js # 画面演出效果\n│ ├── ep-customize.js # 场景自定义面板(背景/音乐/立绘)\n│ ├── ep-live2d.js # Live2D 渲染适配层\n│ └── ep-gaze.js # 注视追踪\n│\n├── live2d/ # Live2D Cubism 5 SDK\n│ ├── core/ # 运行时核心\n│ ├── live2dcubismframework.js # 官方框架\n│ ├── models/Haru/ # Haru 免费示例模型\n│ └── shaders/ # GLSL 着色器 (13 个)\n│\n├── assets/ # 📁 资源目录(占位 README)\n│ ├── bg/ # 背景图片\n│ ├── sprites/ # 角色立绘\n│ ├── bgm/ # 背景音乐\n│ ├── cg/ # 全屏插图\n│ ├── se/ # 音效\n│ ├── voice/ # 角色配音\n│ └── video/ # 过场视频\n│\n└── characters/template/ # 角色 Prompt 模板\n └── prompt_template.txt\n```\n\n---\n\n## 如何创作你自己的游戏\n\n### 第一步:准备素材\n\n把图片、音乐放到 `assets/` 对应的文件夹中:\n\n```\nassets/bg/教室.png\nassets/sprites/主角_微笑.png\nassets/bgm/日常.mp3\nassets/cg/结局图.jpg\n```\n\n### 第二步:打开编辑器\n\n```\nhttp://localhost:3000/editor.html\n```\n\n在左侧把素材**上传/注册**到资源库(给每个文件起一个键名):\n\n| 素材文件 | 键名 |\n|----------|------|\n| `assets/bg/教室.png` | `classroom` |\n| `assets/sprites/主角_微笑.png` | `hero_smile` |\n| `assets/bgm/日常.mp3` | `bgm_daily` |\n\n### 第三步:编写场景\n\n1. 在中间场景列表点击 **\"+ 新增场景\"** 或按 `Ctrl+N`\n2. 在右侧编辑面板填写:\n - **说话人**:角色名\n - **对话文本**:对白(可加 BBCode 如 `[c=red]重要的话[/c]`)\n - **背景键**:`classroom`\n - **立绘键**:`hero_smile`\n - **BGM键**:`bgm_daily`\n3. 点击 **\"💾 更新场景\"** 或按 `Ctrl+S` 保存\n4. 按 `↑` `↓` 键切换场景,中间预览区实时显示效果\n\n### 第四步:添加分支\n\n1. 在某个场景中将 **\"是否含有分支选项\"** 设为\"是\"\n2. 添加选项和跳转目标:\n ```\n 选项1: 接受邀请 → 跳转到场景 5\n 选项2: 婉拒 → 跳转到场景 8\n ```\n\n### 第五步:使用流程图\n\n点击预览区下方 **\"🗺️ 路线流程图\"** 查看你的故事分支结构。用滚轮缩放、拖拽平移来检查所有路线是否完整。\n\n### 第六步:诊断与导出\n\n1. 点击 **\"🔍 项目诊断\"** 检查问题\n2. 点击 **\"📦 导出完整项目\"**(`Ctrl+E`)导出 `galgame_full_project.json`\n3. (可选)点击 **\"📦 分章导出\"** 分割长篇\n\n### 第七步:测试游玩\n\n在游戏播放器中加载导出的 JSON 文件,测试完整流程。\n\n### 可选:AI 协同写作\n\n- 选中一句对白 → 选择改写风格(更温柔/更活泼/更文艺/更简洁/更深情)\n- 点击 **\"🤖 AI 改写\"**\n- 需要 DeepSeek API Key(与后日谈共用)\n\n### 可选:配置角色档案\n\n在编辑器底部 **\"🎭 角色设定档案\"** 面板填写角色信息。导出 JSON 后,将这些信息放到 `epilogue_characters.json` 中。后日谈 AI 会自动加载角色设定,让对话更符合人设。\n\n---\n\n## JSON 项目格式\n\n导出的 `galgame_full_project.json` 结构:\n\n```json\n{\n \"scenes\": [\n {\n \"speaker\": \"旁白\",\n \"text\": \"新学期第一天,他走进了教室。\",\n \"bg\": \"classroom\",\n \"sprite\": null,\n \"sprites\": [],\n \"bgm\": \"bgm_daily\",\n \"seKey\": null,\n \"voice\": null,\n \"locationHint\": \"教学楼\",\n \"next\": 1,\n \"triggerChapterTransition\": false,\n \"subtitle\": \"\",\n \"cgKey\": null,\n \"videoKey\": null,\n \"clearStage\": false,\n \"hasChoices\": false,\n \"choices\": [],\n \"effects\": [{ \"type\": \"vignette\", \"duration\": 3000 }],\n \"transition\": \"\",\n \"endingKey\": null,\n \"endingTitle\": null,\n \"affinity\": null,\n \"affinityLabel\": \"\",\n \"setVar\": null,\n \"conditionNext\": null\n }\n ],\n \"assets\": {\n \"bg\": { \"classroom\": \"assets/bg/教室.png\" },\n \"sprites\": { \"hero_smile\": \"assets/sprites/主角_微笑.png\" },\n \"bgm\": { \"bgm_daily\": \"assets/bgm/日常.mp3\" },\n \"cg\": {},\n \"se\": {},\n \"voice\": {},\n \"video\": {}\n },\n \"characters\": {\n \"hero\": {\n \"name\": \"主角\",\n \"personality\": \"温柔善良,有点害羞……\",\n \"background\": \"从小在南方小镇长大……\",\n \"speechStyle\": \"说话温柔,常用\"……\"停顿\",\n \"extraNotes\": \"\"\n }\n },\n \"imgbbUrls\": {}\n}\n```\n\n### 场景字段完整参考\n\n| 字段 | 类型 | 说明 | 默认值 |\n|------|------|------|--------|\n| `speaker` | string | 说话人名称(`\" \"` 表示不显示) | - |\n| `text` | string | 对话正文(支持 BBCode + `{变量}` 插值) | `\"\"` |\n| `bg` | string | 背景图键名 | - |\n| `sprite` | string\\|null | 单选立绘键名(sprites 为空时生效) | `null` |\n| `sprites` | array | 多立绘数组 `[{key, position, breathing, opacity}]` | `[]` |\n| `bgm` | string\\|null | BGM 键名 | `null` |\n| `seKey` | string\\|null | 音效键名 | `null` |\n| `voice` | string\\|null | 语音键名 | `null` |\n| `locationHint` | string | 画面左上角地点提示 | `\"\"` |\n| `next` | number | 默认跳转场景索引(-1=结束) | - |\n| `triggerChapterTransition` | bool | 是否在此处切章节 | `false` |\n| `subtitle` | string | 章节副标题(切章节时显示) | `\"\"` |\n| `cgKey` | string\\|null | 全屏 CG 键名 | `null` |\n| `videoKey` | string\\|null | 视频键名 | `null` |\n| `clearStage` | bool | 是否清空所有立绘 | `false` |\n| `hasChoices` | bool | 是否含有分支选项 | `false` |\n| `choices` | array | 选项列表 `[{text, nextIdx}]` | `[]` |\n| `effects` | array | 画面效果列表 `[{type, duration}]` | `[]` |\n| `transition` | string | 场景过渡效果(fade/slideLeft/zoomIn…) | `\"\"` |\n| `endingKey` | string\\|null | 结局标识符 | `null` |\n| `endingTitle` | string\\|null | 结局标题 | `null` |\n| `affinity` | number\\|undefined | 好感度 0-100(-1=隐藏) | `undefined` |\n| `affinityLabel` | string | 好感度标签 | `\"\"` |\n| `setVar` | object\\|null | 变量赋值 `{name: value}` | `null` |\n| `conditionNext` | object\\|null | 条件跳转 `{expression: nextIdx, default: idx}` | `null` |\n\n---\n\n## AI 后日谈配置\n\n### 1. 获取 DeepSeek API Key\n\n访问 [platform.deepseek.com/api_keys](https://platform.deepseek.com/api_keys) 注册并获取 Key(格式:`sk-xxxxxxxx`)。\n\n### 2. 配置 Key\n\n打开后日谈页面,点击 ⚙️ 设置 → 输入 API Key。Key 会使用浏览器指纹加密存储在本地。\n\n### 3. 配置角色 Prompt(可选)\n\n如果你在编辑器中填写了角色档案,将导出的 `characters` 字段保存为项目目录下的 `epilogue_characters.json`。后日谈会自动加载。\n\n或者直接编辑 `characters/template/prompt_template.txt` 创建角色设定模板。\n\n### 4. 配置 Live2D(可选)\n\n1. 从 [Live2D 官网](https://www.live2d.com/download/cubism-sdk/) 下载 Cubism 5 SDK for Web\n2. 将 `Core/live2dcubismcore.min.js` 放入 `live2d/core/`\n3. 将你的角色模型文件放入 `live2d/models/`\n4. 修改 `epilogue.html` 中的 `EpilogueLive2D.init()` 路径\n\n> **注意**:Live2D Cubism SDK 有独立的许可条款。年收入低于 1000 万日元的个人开发者免费使用。\n\n---\n\n## 部署发布\n\n### 方式一:静态托管(推荐)\n\n将整个 `GinkgoEngine/` 文件夹上传到任意静态文件服务:\n\n| 平台 | 说明 |\n|------|------|\n| **GitHub Pages** | 免费,上传后自动部署 |\n| **Netlify** | 拖拽文件夹即可,支持自定义域名 |\n| **Vercel** | 同上,全球 CDN |\n| **Cloudflare Pages** | 免费 500 次/月构建 |\n\n**注意**:线上部署时加上 `https://` 才能启用 PWA 离线缓存。\n\n### 方式二:自托管\n\n```bash\n# 方式 A: 用内置 server.js\nnode server.js\n\n# 方式 B: 用 npx serve\nnpx serve . -p 3000\n\n# 方式 C: 用 nginx\n# 将 root 指向 GinkgoEngine 目录即可\n```\n\n### 方式三:Docker(可选)\n\n```dockerfile\nFROM node:18-alpine\nWORKDIR /app\nCOPY . .\nEXPOSE 3000\nCMD [\"node\", \"server.js\"]\n```\n\n### 离线模式\n\n部署后,玩家首次访问时 Service Worker 会自动缓存核心文件(HTML/CSS/JS)。后续访问即使断网也能游玩。但以下情况需要在线:\n\n- CDN 外部资源(Google Fonts、Font Awesome)首次加载\n- AI 后日谈(需要访问 DeepSeek API)\n- 图床图片(如果使用了 ImgBB)\n\n---\n\n## 技术栈\n\n| 层 | 技术 | 说明 |\n|---|------|------|\n| 语言 | 纯 JavaScript (ES5+) | 零构建工具,浏览器原生运行 |\n| 样式 | CSS3 | 动画、滤镜、过渡、Grid/Flexbox |\n| 音频 | Howler.js | 外部音频引擎(CDN 加载) |\n| 动画 | GSAP 3 | 外部动画引擎(CDN 加载) |\n| 粒子 | tsParticles 2 | 银杏叶等粒子特效(CDN 加载) |\n| Live2D | Cubism 5 SDK | 动态角色渲染(本地文件) |\n| AI | DeepSeek Chat API | 流式对话生成 |\n| TTS | Web Speech API | 浏览器内置语音合成 |\n| 存储 | localStorage + IndexedDB | 存档/设置/解锁/记忆 |\n| PWA | Service Worker | 离线缓存 + 桌面安装 |\n| 图标 | Font Awesome 6 | 外部矢量图标(CDN 加载) |\n| 字体 | Google Fonts | Noto Serif SC 等(CDN 加载) |\n\n---\n\n## 许可证\n\n```\nMIT License\n\nCopyright (c) 2025-2026 Ginkgo Engine Contributors\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.\n```\n\n**第三方组件许可**:\n\n| 组件 | 许可 |\n|------|------|\n| Live2D Cubism SDK | [Live2D 专有许可](https://www.live2d.com/eula/) — 年收入 < 1000 万日元免费 |\n| Howler.js | MIT |\n| GSAP | 标准许可(免费用于非商业项目) |\n| tsParticles | MIT |\n| Font Awesome 6 | [CC BY 4.0 + SIL OFL 1.1](https://fontawesome.com/license/free) |\n\n---\n\n**🍂 银杏叶飘落的季节,属于你自己的故事,从这里开始。**\n"}}}}