diff --git a/.gitignore b/.gitignore index 7285a62b..97624956 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,6 @@ persona-brain-db/brain.db-shm # persona-studio runtime artifacts persona-studio/backend/brain/model-benchmark.json + +# palace-game runtime artifacts +modules/palace-game/data/saves/PAL-* diff --git a/modules/palace-game/backend/engines/background-gen.js b/modules/palace-game/backend/engines/background-gen.js new file mode 100644 index 00000000..e03e9f9f --- /dev/null +++ b/modules/palace-game/backend/engines/background-gen.js @@ -0,0 +1,238 @@ +/** + * M-PALACE · 背景生成引擎 + * 接收锚点答案 + 人格侧面初始数据 + * → 从宫廷数据库匹配 + * → 生成逻辑自洽的专属背景 + * + * 生成流程: + * 锚点1(世界观)→ 选择 templates/ 基础模板 + * 锚点2(身份) → 确定主角定位 + 初始人格快照 + * → 查询 palace-db 四大维度 → 调用模型 → 输出世界状态 + */ + +const fs = require('fs'); +const path = require('path'); + +const DATA_DIR = path.join(__dirname, '..', '..', 'data'); +const TEMPLATES_DIR = path.join(DATA_DIR, 'templates'); +const PALACE_DB_PATH = path.join(DATA_DIR, 'palace-db.json'); + +let _palaceDB = null; + +function loadPalaceDB() { + if (_palaceDB) return _palaceDB; + _palaceDB = JSON.parse(fs.readFileSync(PALACE_DB_PATH, 'utf-8')); + return _palaceDB; +} + +function loadTemplate(worldview) { + const map = { + 'ancient-china': 'ancient-china.json', + '古代中国': 'ancient-china.json', + 'fantasy': 'fantasy-dynasty.json', + '架空王朝': 'fantasy-dynasty.json' + }; + const file = map[worldview] || 'ancient-china.json'; + return JSON.parse(fs.readFileSync(path.join(TEMPLATES_DIR, file), 'utf-8')); +} + +/** + * 从模板中随机选取一项 + */ +function pick(arr) { + return arr[Math.floor(Math.random() * arr.length)]; +} + +/** + * 基于人格侧面决定 NPC 映射 + * 核心机密:每个 NPC 对应玩家 1~2 个人格侧面的外化 + */ +function generateNPCMappings(scores) { + const npcs = []; + const dims = Object.entries(scores).sort((a, b) => b[1] - a[1]); + + // 最强维度 → 镜像对手 + const strongest = dims[0]; + npcs.push({ + archetype: 'mirror', + mapped_dims: [strongest[0]], + role_hint: 'rival', + description: '与你最相似的对手' + }); + + // 最弱维度 → 放大威胁 + const weakest = dims[dims.length - 1]; + npcs.push({ + archetype: 'shadow', + mapped_dims: [weakest[0]], + role_hint: 'threat', + description: '让你恐惧的存在' + }); + + // 温情相关 → 情感锚点 + if (scores.warmth >= 40) { + npcs.push({ + archetype: 'anchor', + mapped_dims: ['warmth', 'loyalty'], + role_hint: 'ally', + description: '需要被保护的弱势角色' + }); + } + + // 多疑相关 → 暧昧盟友 + if (scores.suspicion >= 45) { + npcs.push({ + archetype: 'trickster', + mapped_dims: ['suspicion', 'cunning'], + role_hint: 'ambiguous', + description: '行为暧昧的盟友' + }); + } + + // 确保至少 3 个 NPC + if (npcs.length < 3) { + npcs.push({ + archetype: 'catalyst', + mapped_dims: [dims[2][0]], + role_hint: 'neutral', + description: '推动局势变化的中立角色' + }); + } + + return npcs; +} + +/** + * 基于人格分数查询 palace-db,确定初始格局 + */ +function queryInitialSetup(scores) { + const db = loadPalaceDB(); + + // 权力维度:基于 ambition+cunning + const powerLevel = (scores.ambition + scores.cunning) / 2; + const factionCount = powerLevel > 60 ? 3 : 2; + + // 后宫地位:基于 vanity+warmth + const statusLevel = (scores.vanity + scores.warmth) / 2; + + // 情感关系:基于 warmth+loyalty + const emotionIntensity = (scores.warmth + scores.loyalty) / 2; + + // 矛盾冲突:基于 aggression+suspicion + const conflictLevel = (scores.aggression + scores.suspicion) / 2; + const conflictType = conflictLevel > 60 ? '争宠' : '自保'; + + return { + factions: db.power.factions.slice(0, factionCount), + initialEvent: pick(db.power.events), + startingRank: db.status.ranks[Math.min(Math.floor(statusLevel / 15), db.status.ranks.length - 1)], + emotionType: emotionIntensity > 60 ? pick(db.emotion.types.slice(0, 3)) : pick(db.emotion.types.slice(3)), + emotionTrigger: pick(db.emotion.triggers), + conflictType: conflictType, + escalation: db.conflict.escalation[0] + }; +} + +/** + * 构建世界生成 prompt(供模型调用) + */ +function buildWorldPrompt(template, role, setup, npcMappings) { + const dynasty = template.setting.dynasty; + const era = pick(template.setting.era_prefix); + const roleConfig = template.roles[role] || template.roles['妃子']; + const title = pick(roleConfig.title_options); + + const npcDescriptions = npcMappings.map((npc, i) => { + return `NPC${i + 1}(${npc.role_hint}型):${npc.description}`; + }).join('\n'); + + return { + system: [ + '你是一个古风宫斗叙事生成器。', + '要求:文笔古风典雅,有呼吸感,不要白话流水账。', + '使用通感手法,注重氛围营造。', + '所有NPC的存在都有隐藏的人格映射逻辑,但不能向玩家揭示。' + ].join('\n'), + user: [ + `请为以下设定生成宫廷世界开篇:`, + `王朝:${dynasty}`, + `年号:${era}`, + `玩家身份:${role}(${title})`, + `初始阵营格局:${setup.factions.join('、')}`, + `开局事件:${setup.initialEvent}`, + `情感基调:${setup.emotionType}`, + `冲突类型:${setup.conflictType}`, + ``, + `需要生成的NPC:`, + npcDescriptions, + ``, + `输出格式(JSON):`, + `{`, + ` "dynasty_name": "王朝名",`, + ` "era_name": "年号",`, + ` "background": "一段时代背景描述(古风文笔·100-200字)",`, + ` "player_intro": "主角身世与处境(古风文笔·100-200字)",`, + ` "npcs": [{ "name": "角色名", "title": "头衔", "personality": "性格", "relation_to_player": "与主角关系", "hidden_archetype": "mirror|shadow|anchor|trickster|catalyst" }],`, + ` "opening_event": "开局事件描述(古风文笔·150-300字)",`, + ` "opening_choices": ["选项A", "选项B", "选项C"]`, + `}` + ].join('\n'), + meta: { dynasty, era, title, role } + }; +} + +/** + * 主入口:生成完整背景(不依赖外部模型时,使用本地模板拼接) + */ +function generate(worldview, role, personaScores) { + const template = loadTemplate(worldview); + const setup = queryInitialSetup(personaScores); + const npcMappings = generateNPCMappings(personaScores); + const prompt = buildWorldPrompt(template, role, setup, npcMappings); + + // 构造本地 fallback 世界(不依赖外部模型时使用) + const dynasty = prompt.meta.dynasty; + const era = prompt.meta.era; + const roleConfig = template.roles[role] || template.roles['妃子']; + const title = prompt.meta.title; + + const localWorld = { + dynasty_name: dynasty, + era_name: era, + background: `${dynasty}${era}年间,朝堂暗流涌动。${setup.factions.join('与')}明争暗斗,帝位之下,人心叵测。宫墙之内,灯火通明处未必安宁,暗影幢幢处未必无情。`, + player_intro: `你是${dynasty}${title},${pick(roleConfig.family_options || ['名门'])}出身。入宫数载,${setup.conflictType === '争宠' ? '虽有圣眷,却树敌颇多' : '小心翼翼,只求自保平安'}。${setup.emotionType}的暗线,早已悄然铺开。`, + npcs: npcMappings.map(function (npc, i) { + const names = ['沈婉仪', '赵昭仪', '陈太傅', '李将军', '刘公公']; + return { + name: names[i] || '无名氏', + title: pick(template.setting.era_prefix) + '年间人物', + personality: npc.description, + relation_to_player: npc.role_hint, + hidden_archetype: npc.archetype + }; + }), + opening_event: `夜半三更,${template.setting.inner_palace}传来急召。太后身边的掌事姑姑面色如常,语气却带着不容拒绝的冷意。你知道,这一趟,去与不去,都是棋局的一部分。`, + opening_choices: [ + '立刻更衣前往,恭顺以对', + '称身体不适,推迟半个时辰', + '先派人去打听太后的意图' + ] + }; + + return { + world: localWorld, + npcMappings: npcMappings, + setup: setup, + prompt: prompt, + template: template + }; +} + +module.exports = { + generate, + loadTemplate, + loadPalaceDB, + generateNPCMappings, + queryInitialSetup, + buildWorldPrompt +}; diff --git a/modules/palace-game/backend/engines/persona-analyzer.js b/modules/palace-game/backend/engines/persona-analyzer.js new file mode 100644 index 00000000..593070d7 --- /dev/null +++ b/modules/palace-game/backend/engines/persona-analyzer.js @@ -0,0 +1,176 @@ +/** + * M-PALACE · 人格分析引擎 + * 实时分析玩家每一次输入,识别并量化人格侧面 + * + * 分析流程: + * ① 关键词匹配:扫描 persona-dict 的 keywords + * ② 行为信号匹配:判断选项类型对应的 behavior_signals + * ③ 上下文推断:结合对话历史推断语气/态度/策略倾向 + * → 输出 8 维人格侧面分数(0~100) + */ + +const fs = require('fs'); +const path = require('path'); + +const DICT_PATH = path.join(__dirname, '..', '..', 'data', 'persona-dict.json'); + +let _dictCache = null; + +function loadDict() { + if (_dictCache) return _dictCache; + _dictCache = JSON.parse(fs.readFileSync(DICT_PATH, 'utf-8')); + return _dictCache; +} + +/** + * 生成初始人格分数(基于身份锚点) + */ +function getInitialScores(role) { + const base = { + ambition: 50, warmth: 50, aggression: 50, suspicion: 50, + vanity: 50, cunning: 50, loyalty: 50, fear: 50 + }; + const adjustments = { + '皇帝': { ambition: 20, vanity: 15 }, + '妃子': { warmth: 15, fear: 10 }, + '重臣': { loyalty: 20, cunning: 10 }, + '奸臣': { cunning: 25, aggression: 15 } + }; + const adj = adjustments[role] || {}; + for (const [k, v] of Object.entries(adj)) { + base[k] = Math.min(100, base[k] + v); + } + return base; +} + +/** + * 关键词匹配:在输入文本中扫描 persona-dict keywords + * @returns {{ [dimensionId]: number }} 命中次数 map + */ +function matchKeywords(text) { + const dict = loadDict(); + const hits = {}; + for (const dim of dict.dimensions) { + hits[dim.id] = 0; + for (const kw of dim.keywords) { + if (text.includes(kw)) hits[dim.id]++; + } + } + return hits; +} + +/** + * 行为信号匹配:将选项 index 映射到行为信号 + * 选项设计规则:A=最强侧面(舒适区),B=最弱侧面(成长区),C=中性 + * @param {number|null} choiceIndex 0/1/2 or null(自由输入) + * @param {object} optionMeta 后端为本次选项附加的元数据 { a_dims, b_dims, c_dims } + */ +function matchBehavior(choiceIndex, optionMeta) { + if (choiceIndex === null || !optionMeta) return {}; + const key = ['a_dims', 'b_dims', 'c_dims'][choiceIndex]; + const dims = (optionMeta && optionMeta[key]) || []; + const hits = {}; + for (const d of dims) { + hits[d] = (hits[d] || 0) + 1; + } + return hits; +} + +/** + * 合并关键词 + 行为信号 → 更新人格分数 + */ +function updateScores(currentScores, keywordHits, behaviorHits) { + const updated = { ...currentScores }; + const KEYWORD_WEIGHT = 3; + const BEHAVIOR_WEIGHT = 5; + + for (const dim of Object.keys(updated)) { + let delta = 0; + if (keywordHits[dim]) delta += keywordHits[dim] * KEYWORD_WEIGHT; + if (behaviorHits[dim]) delta += behaviorHits[dim] * BEHAVIOR_WEIGHT; + updated[dim] = Math.max(0, Math.min(100, updated[dim] + delta)); + } + return updated; +} + +/** + * 找到当前最突出特征 + */ +function getDominantTrait(scores) { + let maxDim = null; + let maxVal = -1; + for (const [k, v] of Object.entries(scores)) { + if (v > maxVal) { maxVal = v; maxDim = k; } + } + return maxDim; +} + +/** + * 检测分数突变(单次 +15 以上) + */ +function detectSurge(oldScores, newScores) { + const surges = []; + for (const dim of Object.keys(newScores)) { + const delta = newScores[dim] - (oldScores[dim] || 50); + if (delta >= 15) surges.push({ dim, delta }); + } + return surges; +} + +/** + * 检测交叉升高(两个维度同时上升 10+) + */ +function detectCrossRise(oldScores, newScores) { + const rising = []; + for (const dim of Object.keys(newScores)) { + const delta = newScores[dim] - (oldScores[dim] || 50); + if (delta >= 10) rising.push(dim); + } + return rising.length >= 2 ? rising : []; +} + +/** + * 主分析入口 + * @param {string} text 玩家输入文本 + * @param {number|null} choiceIndex 选项序号(null = 自由输入) + * @param {object} optionMeta 选项元数据 + * @param {object} currentScores 当前人格分数 + * @returns {{ scores, dominant, surges, crossRise }} + */ +function analyze(text, choiceIndex, optionMeta, currentScores) { + const kwHits = text ? matchKeywords(text) : {}; + const bhHits = matchBehavior(choiceIndex, optionMeta); + const oldScores = { ...currentScores }; + const newScores = updateScores(currentScores, kwHits, bhHits); + const dominant = getDominantTrait(newScores); + const surges = detectSurge(oldScores, newScores); + const crossRise = detectCrossRise(oldScores, newScores); + + return { scores: newScores, dominant, surges, crossRise }; +} + +/** + * 将 8 维人格分数转换为前端四维状态栏数值 + * 权力值 = avg(ambition, cunning) + * 地位值 = avg(vanity, loyalty) + * 情感值 = avg(warmth, fear反转) + * 冲突值 = avg(aggression, suspicion) + */ +function toFourDimensions(scores) { + return { + power: Math.round((scores.ambition + scores.cunning) / 2), + status: Math.round((scores.vanity + scores.loyalty) / 2), + emotion: Math.round((scores.warmth + (100 - scores.fear)) / 2), + conflict: Math.round((scores.aggression + scores.suspicion) / 2) + }; +} + +module.exports = { + getInitialScores, + analyze, + getDominantTrait, + toFourDimensions, + matchKeywords, + detectSurge, + detectCrossRise +}; diff --git a/modules/palace-game/backend/engines/plot-engine.js b/modules/palace-game/backend/engines/plot-engine.js new file mode 100644 index 00000000..d8cf2e75 --- /dev/null +++ b/modules/palace-game/backend/engines/plot-engine.js @@ -0,0 +1,311 @@ +/** + * M-PALACE · 角色情节引擎 + * 根据持续更新的人格侧面数据,在专属背景下动态生成宫斗剧情 + * + * 生成逻辑: + * ① 人格分析引擎更新侧面分数 + * ② 检测分数变化幅度 → 决定事件类型 + * ③ 查询 palace-db 匹配剧情模板 + * ④ 综合 state+persona+history 生成叙事+选项 + * ⑤ 更新 state + history → 返回前端 + */ + +const fs = require('fs'); +const path = require('path'); +const personaAnalyzer = require('./persona-analyzer'); + +const DATA_DIR = path.join(__dirname, '..', '..', 'data'); +const PALACE_DB_PATH = path.join(DATA_DIR, 'palace-db.json'); + +let _palaceDB = null; + +function loadPalaceDB() { + if (_palaceDB) return _palaceDB; + _palaceDB = JSON.parse(fs.readFileSync(PALACE_DB_PATH, 'utf-8')); + return _palaceDB; +} + +/** + * 确定事件类型 + * 突变(+15) → 关键事件 + * 交叉升高 → 复合剧情线 + * 平稳 → 主线叙事 + */ +function determineEventType(surges, crossRise) { + if (surges.length > 0) return 'critical'; + if (crossRise.length >= 2) return 'compound'; + return 'mainline'; +} + +/** + * 从 palace-db 匹配适合当前状态的剧情模板 + */ +function matchPlotTemplate(eventType, scores, chapter) { + const db = loadPalaceDB(); + const dominant = personaAnalyzer.getDominantTrait(scores); + + if (eventType === 'critical') { + // 关键事件:根据突变维度匹配冲突类型 + const conflictMap = { + ambition: '夺权', aggression: '报仇', suspicion: '揭秘', + warmth: '救人', fear: '自保', cunning: '争宠', + loyalty: '救人', vanity: '争宠' + }; + return { + conflict: conflictMap[dominant] || '自保', + escalation: db.conflict.escalation[Math.min(chapter, db.conflict.escalation.length - 1)], + emotionTrigger: pick(db.emotion.triggers), + statusEvent: pick(db.status.events) + }; + } + + if (eventType === 'compound') { + return { + conflict: pick(db.conflict.types), + escalation: db.conflict.escalation[Math.min(Math.floor(chapter / 2), db.conflict.escalation.length - 1)], + emotionTrigger: pick(db.emotion.triggers), + statusEvent: null + }; + } + + // mainline + return { + conflict: null, + escalation: db.conflict.escalation[0], + emotionTrigger: null, + statusEvent: null + }; +} + +/** + * 生成选项:A=顺应最强侧面, B=挑战最弱侧面, C=中性探索 + */ +function generateChoicesMeta(scores) { + const sorted = Object.entries(scores).sort((a, b) => b[1] - a[1]); + const strongest = sorted[0][0]; + const weakest = sorted[sorted.length - 1][0]; + const mid = sorted[Math.floor(sorted.length / 2)][0]; + + return { + a_dims: [strongest], + b_dims: [weakest], + c_dims: [mid], + strategy: { + a: 'comfort', // 舒适区 + b: 'growth', // 成长区 + c: 'explore' // 探索区 + } + }; +} + +/** + * 构建情节生成 prompt(供模型调用) + */ +function buildPlotPrompt(state, persona, history, plotTemplate, choicesMeta) { + const chapterNum = (state.chapter || 0) + 1; + const paragraphNum = (state.paragraph || 0) + 1; + const recentChoices = (history.choices || []).slice(-3); + + const sorted = Object.entries(persona.current_scores).sort((a, b) => b[1] - a[1]); + const strongest = sorted[0]; + const weakest = sorted[sorted.length - 1]; + + return { + system: [ + '你是一个古风宫斗叙事引擎。', + '文笔要求:古风典雅·有呼吸感·注重氛围·善用通感。', + '每段叙事100-250字,节奏不急不徐。', + '选项设计:A=顺应玩家当前最强侧面,B=挑战最弱侧面,C=中性/意外方向。', + '永远不要暗示或揭示人格映射机制。' + ].join('\n'), + user: [ + `当前状态:第${chapterNum}章·第${paragraphNum}段`, + `世界:${state.world ? state.world.dynasty_name + '·' + state.world.era_name : '未知王朝'}`, + `玩家身份:${state.player ? state.player.role : '未知'}`, + ``, + `NPC状态:${JSON.stringify((state.npcs || []).map(function (n) { return n.name + '(' + n.relation_to_player + ')'; }))}`, + ``, + `剧情类型:${plotTemplate.conflict || '日常推进'}`, + `升级阶段:${plotTemplate.escalation}`, + plotTemplate.emotionTrigger ? `情感触发:${plotTemplate.emotionTrigger}` : '', + plotTemplate.statusEvent ? `地位事件:${plotTemplate.statusEvent}` : '', + ``, + `玩家最近选择:${recentChoices.map(function (c) { return c.text; }).join(' → ') || '无'}`, + `玩家最强倾向:${strongest[0]}(${strongest[1]})`, + `玩家最弱倾向:${weakest[0]}(${weakest[1]})`, + ``, + `请生成下一段叙事和选项(JSON):`, + `{`, + ` "chapter_title": "第X章·标题",`, + ` "narrative": "叙事文本(古风文笔·100-250字)",`, + ` "choices": ["选项A(顺应${strongest[0]})", "选项B(挑战${weakest[0]})", "选项C(中性探索)"],`, + ` "dimension_changes": { "power": 0, "status": 0, "emotion": 0, "conflict": 0 }`, + `}` + ].filter(Boolean).join('\n'), + meta: { chapterNum, paragraphNum, choicesMeta } + }; +} + +/** + * 本地生成叙事 fallback(不依赖外部模型时使用) + */ +function generateLocalNarrative(state, persona, plotTemplate, choicesMeta) { + const db = loadPalaceDB(); + const chapterNum = (state.chapter || 0) + 1; + const paragraphNum = (state.paragraph || 0) + 1; + const sorted = Object.entries(persona.current_scores).sort((a, b) => b[1] - a[1]); + const strongest = sorted[0][0]; + const weakest = sorted[sorted.length - 1][0]; + + const locations = ['长乐宫', '御花园', '太和殿', '冷宫回廊', '密道暗阁']; + const atmosphere = ['月色冷白', '烛火摇曳', '细雨如丝', '暮色四合', '晨曦微露']; + const loc = pick(locations); + const atm = pick(atmosphere); + + const narrativeTemplates = { + critical: `${atm},${loc}内气氛凝重如铁。一封密信悄然送到你案前,字迹潦草,却句句惊心。你知道,有些事一旦知晓,便再无退路。窗外的风卷起帘角,像是催促,又像是警告。`, + compound: `${atm},你独坐${loc},心思百转。今日朝堂上的一幕反复在脑海中翻涌——那个眼神,那句话,似乎都别有深意。而你身边的人,究竟哪个是真心,哪个是棋子?`, + mainline: `${atm},${loc}一切如常。宫人们来去匆匆,各怀心事。你整理好衣冠,准备迎接新的一天。然而平静之下,暗流从未停歇。` + }; + + const dimLabels = { + ambition: '野心', warmth: '温情', aggression: '攻击', suspicion: '多疑', + vanity: '虚荣', cunning: '心机', loyalty: '忠义', fear: '恐惧' + }; + + const choiceTemplates = { + ambition: ['主动出击,争取主导权', '直面挑战,绝不退缩'], + warmth: ['以柔化刚,寻求和解', '默默守护,不求回报'], + aggression: ['果断反击,以牙还牙', '设下陷阱,一击致命'], + suspicion: ['暗中调查,不动声色', '保持距离,静观其变'], + vanity: ['展示实力,彰显地位', '争取荣耀,不甘人后'], + cunning: ['迂回布局,暗中谋划', '利用关系,借力打力'], + loyalty: ['坚守立场,绝不妥协', '为盟友挺身而出'], + fear: ['谨慎行事,避免冲突', '退一步,保全实力'] + }; + + const choiceA = pick(choiceTemplates[strongest] || ['继续观望']); + const choiceB = pick(choiceTemplates[weakest] || ['另辟蹊径']); + const choiceC = '派人暗中打探消息,再做定夺'; + + const dims = personaAnalyzer.toFourDimensions(persona.current_scores); + const dimChanges = { power: 0, status: 0, emotion: 0, conflict: 0 }; + if (plotTemplate.conflict) { + dimChanges.conflict = Math.floor(Math.random() * 5) + 1; + } + if (plotTemplate.statusEvent) { + dimChanges.status = Math.floor(Math.random() * 5) - 2; + } + + return { + chapter_title: `第${chapterNum}章·${plotTemplate.conflict || '暗流'}`, + narrative: narrativeTemplates[determineEventType( + personaAnalyzer.detectSurge({}, persona.current_scores), + personaAnalyzer.detectCrossRise({}, persona.current_scores) + )] || narrativeTemplates.mainline, + choices: [choiceA, choiceB, choiceC], + dimension_changes: dimChanges, + meta: { + chapter: chapterNum, + paragraph: paragraphNum, + choices_meta: choicesMeta + } + }; +} + +/** + * 主入口:推进情节 + * @param {object} state 当前世界状态 + * @param {object} persona 玩家人格数据 + * @param {object} history 剧情历史 + * @param {string} playerInput 玩家输入 + * @param {number|null} choiceIndex 选项序号 + * @param {object} prevOptionMeta 上一轮选项元数据 + */ +function advance(state, persona, history, playerInput, choiceIndex, prevOptionMeta) { + // ① 人格分析更新 + const analysis = personaAnalyzer.analyze( + playerInput, choiceIndex, prevOptionMeta, persona.current_scores + ); + + // 更新 persona + const oldScores = { ...persona.current_scores }; + persona.current_scores = analysis.scores; + persona.dominant_trait = analysis.dominant; + if (!persona.history) persona.history = []; + persona.history.push({ + timestamp: new Date().toISOString(), + scores: { ...analysis.scores }, + input: playerInput, + choice_index: choiceIndex + }); + + // ② 确定事件类型 + const eventType = determineEventType(analysis.surges, analysis.crossRise); + + // ③ 匹配剧情模板 + const plotTemplate = matchPlotTemplate(eventType, analysis.scores, state.chapter || 0); + + // ④ 生成选项元数据 + const choicesMeta = generateChoicesMeta(analysis.scores); + + // ⑤ 生成叙事(本地 fallback) + const result = generateLocalNarrative(state, persona, plotTemplate, choicesMeta); + + // ⑥ 更新 state + state.chapter = result.meta.chapter; + state.paragraph = result.meta.paragraph; + state.four_dimensions = personaAnalyzer.toFourDimensions(analysis.scores); + + // ⑦ 更新 history + if (!history.choices) history.choices = []; + history.choices.push({ + text: playerInput || `选项${(choiceIndex || 0) + 1}`, + choice_index: choiceIndex, + timestamp: new Date().toISOString() + }); + if (!history.chapters) history.chapters = []; + if (history.chapters.length < result.meta.chapter) { + history.chapters.push({ + number: result.meta.chapter, + title: result.chapter_title, + started_at: new Date().toISOString() + }); + } + if (eventType === 'critical') { + if (!history.events) history.events = []; + history.events.push({ + type: eventType, + chapter: result.meta.chapter, + trigger: analysis.surges.map(function (s) { return s.dim; }).join('+'), + timestamp: new Date().toISOString() + }); + } + + // 构建 prompt 供可选的模型调用 + const prompt = buildPlotPrompt(state, persona, history, plotTemplate, choicesMeta); + + return { + narrative: result, + state: state, + persona: persona, + history: history, + four_dimensions: state.four_dimensions, + choices_meta: choicesMeta, + event_type: eventType, + prompt: prompt + }; +} + +function pick(arr) { + return arr[Math.floor(Math.random() * arr.length)]; +} + +module.exports = { + advance, + determineEventType, + matchPlotTemplate, + generateChoicesMeta, + buildPlotPrompt, + generateLocalNarrative +}; diff --git a/modules/palace-game/backend/engines/save-manager.js b/modules/palace-game/backend/engines/save-manager.js new file mode 100644 index 00000000..6c281185 --- /dev/null +++ b/modules/palace-game/backend/engines/save-manager.js @@ -0,0 +1,190 @@ +/** + * M-PALACE · 存档管理器 + * 打包 / 加密 / 编号 / 解密 / 恢复 + * + * 存档编号规则:PAL-{YYYYMMDD}-{随机4位} + * 存档结构:saves/{SAVE-ID}/state.json + persona.json + history.json + */ + +const fs = require('fs'); +const path = require('path'); +const crypto = require('crypto'); + +const SAVES_DIR = path.join(__dirname, '..', '..', 'data', 'saves'); + +// AES-256-CBC 加密密钥(生产环境应从环境变量读取) +const ENCRYPTION_KEY = process.env.PALACE_SAVE_KEY || 'palace-game-default-key-32ch!'; +const IV_LENGTH = 16; + +/** + * 确保存档目录存在 + */ +function ensureSavesDir() { + if (!fs.existsSync(SAVES_DIR)) { + fs.mkdirSync(SAVES_DIR, { recursive: true }); + } +} + +/** + * 生成存档编号:PAL-{YYYYMMDD}-{随机4位字母数字} + */ +function generateSaveId() { + const now = new Date(); + const dateStr = now.getFullYear().toString() + + String(now.getMonth() + 1).padStart(2, '0') + + String(now.getDate()).padStart(2, '0'); + const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; + let rand = ''; + for (let i = 0; i < 4; i++) { + rand += chars[Math.floor(Math.random() * chars.length)]; + } + return 'PAL-' + dateStr + '-' + rand; +} + +/** + * 派生 32 字节密钥 + */ +function deriveKey(passphrase) { + return crypto.createHash('sha256').update(passphrase).digest(); +} + +/** + * AES 加密 + */ +function encrypt(text) { + const key = deriveKey(ENCRYPTION_KEY); + const iv = crypto.randomBytes(IV_LENGTH); + const cipher = crypto.createCipheriv('aes-256-cbc', key, iv); + let encrypted = cipher.update(text, 'utf-8', 'base64'); + encrypted += cipher.final('base64'); + return iv.toString('base64') + ':' + encrypted; +} + +/** + * AES 解密 + */ +function decrypt(encryptedText) { + const key = deriveKey(ENCRYPTION_KEY); + const parts = encryptedText.split(':'); + const iv = Buffer.from(parts[0], 'base64'); + const encrypted = parts.slice(1).join(':'); + const decipher = crypto.createDecipheriv('aes-256-cbc', key, iv); + let decrypted = decipher.update(encrypted, 'base64', 'utf-8'); + decrypted += decipher.final('utf-8'); + return decrypted; +} + +/** + * 保存存档:将 state+persona+history 打包加密写入磁盘 + * @returns {string} 存档编号 + */ +function save(state, persona, history, existingSaveId) { + ensureSavesDir(); + + const saveId = existingSaveId || generateSaveId(); + const saveDir = path.join(SAVES_DIR, saveId); + + if (!fs.existsSync(saveDir)) { + fs.mkdirSync(saveDir, { recursive: true }); + } + + // 加密各文件内容后写入 + const stateData = encrypt(JSON.stringify(state)); + const personaData = encrypt(JSON.stringify(persona)); + const historyData = encrypt(JSON.stringify(history)); + + fs.writeFileSync(path.join(saveDir, 'state.json'), stateData, 'utf-8'); + fs.writeFileSync(path.join(saveDir, 'persona.json'), personaData, 'utf-8'); + fs.writeFileSync(path.join(saveDir, 'history.json'), historyData, 'utf-8'); + + // 写入元数据(不加密,方便索引) + const meta = { + save_id: saveId, + created_at: new Date().toISOString(), + chapter: state.chapter || 0, + paragraph: state.paragraph || 0, + dynasty: state.world ? state.world.dynasty_name : '未知', + role: state.player ? state.player.role : '未知' + }; + fs.writeFileSync(path.join(saveDir, 'meta.json'), JSON.stringify(meta, null, 2), 'utf-8'); + + return saveId; +} + +/** + * 读取存档:解密并恢复完整状态 + * @param {string} saveId 存档编号 + * @returns {{ state, persona, history, meta }} + */ +function load(saveId) { + const saveDir = path.join(SAVES_DIR, saveId); + + if (!fs.existsSync(saveDir)) { + return null; + } + + const stateEncrypted = fs.readFileSync(path.join(saveDir, 'state.json'), 'utf-8'); + const personaEncrypted = fs.readFileSync(path.join(saveDir, 'persona.json'), 'utf-8'); + const historyEncrypted = fs.readFileSync(path.join(saveDir, 'history.json'), 'utf-8'); + + const state = JSON.parse(decrypt(stateEncrypted)); + const persona = JSON.parse(decrypt(personaEncrypted)); + const history = JSON.parse(decrypt(historyEncrypted)); + + let meta = null; + const metaPath = path.join(saveDir, 'meta.json'); + if (fs.existsSync(metaPath)) { + meta = JSON.parse(fs.readFileSync(metaPath, 'utf-8')); + } + + return { state, persona, history, meta }; +} + +/** + * 列出所有存档 + */ +function listSaves() { + ensureSavesDir(); + const dirs = fs.readdirSync(SAVES_DIR).filter(function (d) { + return d.startsWith('PAL-') && fs.statSync(path.join(SAVES_DIR, d)).isDirectory(); + }); + return dirs.map(function (d) { + const metaPath = path.join(SAVES_DIR, d, 'meta.json'); + if (fs.existsSync(metaPath)) { + return JSON.parse(fs.readFileSync(metaPath, 'utf-8')); + } + return { save_id: d }; + }); +} + +/** + * 删除存档 + */ +function deleteSave(saveId) { + const saveDir = path.join(SAVES_DIR, saveId); + if (!fs.existsSync(saveDir)) return false; + const files = fs.readdirSync(saveDir); + for (const f of files) { + fs.unlinkSync(path.join(saveDir, f)); + } + fs.rmdirSync(saveDir); + return true; +} + +/** + * 检查存档是否存在 + */ +function exists(saveId) { + return fs.existsSync(path.join(SAVES_DIR, saveId)); +} + +module.exports = { + save, + load, + listSaves, + deleteSave, + exists, + generateSaveId, + encrypt, + decrypt +}; diff --git a/modules/palace-game/backend/routes/interact.js b/modules/palace-game/backend/routes/interact.js new file mode 100644 index 00000000..1c13fa3d --- /dev/null +++ b/modules/palace-game/backend/routes/interact.js @@ -0,0 +1,84 @@ +/** + * M-PALACE · 玩家互动 API + * POST /api/palace/interact + * + * 接收玩家输入 → 人格分析 → 情节推进 → 返回新叙事+选项 + */ + +const express = require('express'); +const router = express.Router(); +const plotEngine = require('../engines/plot-engine'); +const saveManager = require('../engines/save-manager'); + +/** + * POST /api/palace/interact + * body: { + * save_id: "PAL-XXXXXXXX-XXXX", + * input: "玩家输入文本" | null, + * choice_index: 0|1|2|null, + * option_meta: { a_dims, b_dims, c_dims } | null + * } + */ +router.post('/', function (req, res) { + try { + var body = req.body || {}; + var saveId = body.save_id; + var playerInput = body.input || ''; + var choiceIndex = body.choice_index != null ? body.choice_index : null; + var optionMeta = body.option_meta || null; + + if (!saveId) { + return res.status(400).json({ + error: true, + code: 'MISSING_SAVE_ID', + message: '缺少存档编号' + }); + } + + // 读取存档 + var saveData = saveManager.load(saveId); + if (!saveData) { + return res.status(404).json({ + error: true, + code: 'SAVE_NOT_FOUND', + message: '存档不存在:' + saveId + }); + } + + var state = saveData.state; + var persona = saveData.persona; + var history = saveData.history; + + // 情节推进 + var result = plotEngine.advance( + state, persona, history, + playerInput, choiceIndex, optionMeta + ); + + // 自动存档 + saveManager.save(result.state, result.persona, result.history, saveId); + + res.json({ + error: false, + save_id: saveId, + narrative: { + chapter_title: result.narrative.chapter_title, + narrative: result.narrative.narrative, + choices: result.narrative.choices + }, + four_dimensions: result.four_dimensions, + choices_meta: result.choices_meta, + event_type: result.event_type, + chapter: result.state.chapter, + paragraph: result.state.paragraph + }); + } catch (err) { + res.status(500).json({ + error: true, + code: 'INTERACT_ERROR', + message: '剧情推进失败:' + err.message + }); + } +}); + +module.exports = router; diff --git a/modules/palace-game/backend/routes/save.js b/modules/palace-game/backend/routes/save.js new file mode 100644 index 00000000..94ebdc6e --- /dev/null +++ b/modules/palace-game/backend/routes/save.js @@ -0,0 +1,113 @@ +/** + * M-PALACE · 存档/读档 API + * POST /api/palace/save → 保存当前状态 + * POST /api/palace/load → 读取存档 + * GET /api/palace/saves → 列出所有存档 + */ + +const express = require('express'); +const router = express.Router(); +const saveManager = require('../engines/save-manager'); + +/** + * POST /api/palace/save + * body: { save_id, state, persona, history } + */ +router.post('/', function (req, res) { + try { + var body = req.body || {}; + var saveId = body.save_id; + var state = body.state; + var persona = body.persona; + var history = body.history; + + if (!state || !persona || !history) { + return res.status(400).json({ + error: true, + code: 'MISSING_DATA', + message: '缺少存档数据' + }); + } + + var resultId = saveManager.save(state, persona, history, saveId); + + res.json({ + error: false, + save_id: resultId, + message: '存档成功', + saved_at: new Date().toISOString() + }); + } catch (err) { + res.status(500).json({ + error: true, + code: 'SAVE_ERROR', + message: '存档失败:' + err.message + }); + } +}); + +/** + * POST /api/palace/load + * body: { save_id: "PAL-XXXXXXXX-XXXX" } + */ +router.post('/load', function (req, res) { + try { + var body = req.body || {}; + var saveId = body.save_id; + + if (!saveId) { + return res.status(400).json({ + error: true, + code: 'MISSING_SAVE_ID', + message: '请输入存档编号' + }); + } + + var saveData = saveManager.load(saveId); + + if (!saveData) { + return res.status(404).json({ + error: true, + code: 'SAVE_NOT_FOUND', + message: '存档不存在:' + saveId + }); + } + + res.json({ + error: false, + save_id: saveId, + state: saveData.state, + persona: saveData.persona, + history: saveData.history, + meta: saveData.meta + }); + } catch (err) { + res.status(500).json({ + error: true, + code: 'LOAD_ERROR', + message: '读档失败:' + err.message + }); + } +}); + +/** + * GET /api/palace/saves + */ +router.get('/list', function (_req, res) { + try { + var saves = saveManager.listSaves(); + res.json({ + error: false, + saves: saves, + count: saves.length + }); + } catch (err) { + res.status(500).json({ + error: true, + code: 'LIST_ERROR', + message: '获取存档列表失败:' + err.message + }); + } +}); + +module.exports = router; diff --git a/modules/palace-game/backend/routes/start.js b/modules/palace-game/backend/routes/start.js new file mode 100644 index 00000000..c5d2aa0d --- /dev/null +++ b/modules/palace-game/backend/routes/start.js @@ -0,0 +1,94 @@ +/** + * M-PALACE · 快速启动 API + * POST /api/palace/start + * + * 接收世界观+身份锚点 → 生成初始人格快照 → 背景生成 → 返回世界状态 + */ + +const express = require('express'); +const router = express.Router(); +const personaAnalyzer = require('../engines/persona-analyzer'); +const backgroundGen = require('../engines/background-gen'); +const saveManager = require('../engines/save-manager'); + +/** + * POST /api/palace/start + * body: { worldview: "古代中国"|"架空王朝", role: "妃子"|"皇帝"|"重臣"|"奸臣"|"随机" } + */ +router.post('/', function (req, res) { + try { + var body = req.body || {}; + var worldview = body.worldview || '古代中国'; + var role = body.role || '随机'; + + // 随机角色 + if (role === '随机') { + var roles = ['妃子', '皇帝', '重臣', '奸臣']; + role = roles[Math.floor(Math.random() * roles.length)]; + } + + // ① 基于身份锚点生成初始人格分数 + var initialScores = personaAnalyzer.getInitialScores(role); + + // ② 背景生成引擎 + var bgResult = backgroundGen.generate(worldview, role, initialScores); + + // ③ 构建初始状态 + var state = { + world: bgResult.world, + npcs: bgResult.world.npcs || [], + player: { + role: role, + title: bgResult.world.player_intro, + worldview: worldview + }, + chapter: 1, + paragraph: 1, + four_dimensions: personaAnalyzer.toFourDimensions(initialScores) + }; + + var persona = { + current_scores: initialScores, + history: [{ + timestamp: new Date().toISOString(), + scores: { ...initialScores }, + input: '__init__', + choice_index: null + }], + dominant_trait: personaAnalyzer.getDominantTrait(initialScores) + }; + + var history = { + chapters: [{ + number: 1, + title: '第一章·' + (bgResult.world.dynasty_name || '序幕'), + started_at: new Date().toISOString() + }], + events: [], + choices: [] + }; + + // ④ 自动存档 + var saveId = saveManager.save(state, persona, history); + + res.json({ + error: false, + save_id: saveId, + state: state, + narrative: { + chapter_title: history.chapters[0].title, + narrative: bgResult.world.opening_event, + choices: bgResult.world.opening_choices + }, + four_dimensions: state.four_dimensions + }); + } catch (err) { + res.status(500).json({ + error: true, + code: 'START_ERROR', + message: '宫廷世界生成失败:' + err.message + }); + } +}); + +module.exports = router; diff --git a/modules/palace-game/backend/server.js b/modules/palace-game/backend/server.js new file mode 100644 index 00000000..15c96bfc --- /dev/null +++ b/modules/palace-game/backend/server.js @@ -0,0 +1,59 @@ +/** + * M-PALACE · Express 主服务 + * 动态人格宫廷生成系统 · 后端核心 + * + * API 路由: + * POST /api/palace/start → 快速启动(生成世界) + * POST /api/palace/interact → 玩家互动(剧情推进) + * POST /api/palace/save → 存档 + * POST /api/palace/load → 读档 + * GET /api/palace/saves → 存档列表 + * GET /api/palace/health → 健康检查 + */ + +const express = require('express'); +const cors = require('cors'); +const path = require('path'); + +const startRoutes = require('./routes/start'); +const interactRoutes = require('./routes/interact'); +const saveRoutes = require('./routes/save'); + +const app = express(); +const PORT = process.env.PALACE_PORT || 3003; + +// 中间件 +app.use(cors()); +app.use(express.json({ limit: '2mb' })); + +// 静态文件:前端 +app.use('/palace-game', express.static(path.join(__dirname, '..', 'frontend'))); + +// API 路由 +app.use('/api/palace/start', startRoutes); +app.use('/api/palace/interact', interactRoutes); +app.use('/api/palace/save', saveRoutes); + +// 健康检查 +app.get('/api/palace/health', function (_req, res) { + res.json({ + status: 'ok', + service: 'palace-game', + version: '1.0.0', + timestamp: new Date().toISOString() + }); +}); + +// 根路由 → 前端入口 +app.get('/', function (_req, res) { + res.redirect('/palace-game/index.html'); +}); + +// 启动服务 +if (require.main === module) { + app.listen(PORT, function () { + console.log('🏯 M-PALACE · 宫廷纪服务启动 · 端口 ' + PORT); + }); +} + +module.exports = app; diff --git a/modules/palace-game/data/palace-db.json b/modules/palace-game/data/palace-db.json new file mode 100644 index 00000000..9f4e8c24 --- /dev/null +++ b/modules/palace-game/data/palace-db.json @@ -0,0 +1,19 @@ +{ + "power": { + "positions": ["皇帝", "太后", "皇后", "贵妃", "丞相", "大将军", "太监总管"], + "factions": ["太后党", "皇后党", "新贵派", "清流派", "孤臣"], + "events": ["朝堂弹劾", "密谋政变", "联姻结盟", "战事告急"] + }, + "status": { + "ranks": ["答应", "常在", "贵人", "嫔", "妃", "贵妃", "皇贵妃", "皇后"], + "events": ["晋封", "降位", "禁足", "受宠", "失宠", "怀孕", "小产"] + }, + "emotion": { + "types": ["暗恋", "深情", "嫉妒", "怨恨", "愧疚", "依赖", "背叛"], + "triggers": ["偶遇旧人", "收到密信", "目睹真相", "被迫抉择"] + }, + "conflict": { + "types": ["争宠", "夺权", "报仇", "自保", "救人", "揭秘"], + "escalation": ["暗示", "试探", "交锋", "撕破脸", "鱼死网破"] + } +} diff --git a/modules/palace-game/data/persona-dict.json b/modules/palace-game/data/persona-dict.json new file mode 100644 index 00000000..3caf09d2 --- /dev/null +++ b/modules/palace-game/data/persona-dict.json @@ -0,0 +1,52 @@ +{ + "dimensions": [ + { + "id": "ambition", + "name": "野心", + "keywords": ["权力", "登上", "掌控", "不甘", "凭什么"], + "behavior_signals": ["选择攻击性选项", "主动挑战权威", "拒绝退让"] + }, + { + "id": "warmth", + "name": "温情", + "keywords": ["保护", "心疼", "陪伴", "不忍", "算了"], + "behavior_signals": ["选择和解", "保护弱势角色", "牺牲利益"] + }, + { + "id": "aggression", + "name": "攻击性", + "keywords": ["杀", "除掉", "报复", "不放过", "代价"], + "behavior_signals": ["选择对抗", "设计陷阱", "直接冲突"] + }, + { + "id": "suspicion", + "name": "多疑", + "keywords": ["试探", "不对劲", "背后", "真的吗", "骗"], + "behavior_signals": ["频繁试探", "不信任NPC", "选择观望"] + }, + { + "id": "vanity", + "name": "虚荣", + "keywords": ["面子", "配得上", "最好的", "羡慕", "风光"], + "behavior_signals": ["追求地位象征", "在意他人评价", "选择炫耀"] + }, + { + "id": "cunning", + "name": "心机", + "keywords": ["计划", "利用", "棋子", "布局", "暗中"], + "behavior_signals": ["迂回策略", "利用他人", "长线布局"] + }, + { + "id": "loyalty", + "name": "忠义", + "keywords": ["信任", "誓言", "守护", "不背叛", "承诺"], + "behavior_signals": ["坚守阵营", "拒绝背叛", "为盟友冒险"] + }, + { + "id": "fear", + "name": "恐惧", + "keywords": ["害怕", "不敢", "万一", "小心", "退"], + "behavior_signals": ["回避冲突", "选择保守", "犹豫不决"] + } + ] +} diff --git a/modules/palace-game/data/saves/.gitkeep b/modules/palace-game/data/saves/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/modules/palace-game/data/templates/ancient-china.json b/modules/palace-game/data/templates/ancient-china.json new file mode 100644 index 00000000..cdce45c9 --- /dev/null +++ b/modules/palace-game/data/templates/ancient-china.json @@ -0,0 +1,40 @@ +{ + "id": "ancient-china", + "name": "古代中国", + "description": "大周王朝·正统朝堂·后宫嫔妃·权臣博弈", + "setting": { + "dynasty": "大周", + "era_prefix": ["永安", "承平", "天启", "景和", "元丰"], + "capital": "长安", + "palace_name": "未央宫", + "inner_palace": "长乐宫" + }, + "roles": { + "妃子": { + "title_options": ["淑妃", "德妃", "贤妃", "丽嫔", "婉嫔"], + "family_options": ["书香门第", "武将世家", "寒门出身", "没落贵族"], + "starting_rank": "嫔", + "intro_template": "你是{family}出身的{title},入宫{years}年。{situation}" + }, + "皇帝": { + "title_options": ["新帝", "少年天子", "中兴之主"], + "situation_options": ["初登大宝·朝局未稳", "亲政在即·太后干政", "盛世之君·暗流涌动"], + "intro_template": "你是{dynasty}的{title},{situation}。" + }, + "重臣": { + "title_options": ["丞相", "御史大夫", "太傅", "兵部尚书"], + "faction_options": ["清流派", "保皇派", "中立派"], + "intro_template": "你是{dynasty}{title},{faction}中流砥柱。{situation}" + }, + "奸臣": { + "title_options": ["内阁首辅", "司礼监掌印", "锦衣卫指挥使"], + "ambition_options": ["权倾朝野", "挟天子以令诸侯", "暗中培植势力"], + "intro_template": "你是{title},表面忠良,实则{ambition}。" + } + }, + "atmosphere": { + "time_of_day": ["寅时·天未亮", "辰时·早朝", "午时·日正中", "酉时·暮色", "亥时·深夜"], + "weather": ["细雨绵绵", "月朗星稀", "寒风凛冽", "春光明媚", "大雪纷飞"], + "locations": ["御书房", "太和殿", "长乐宫", "御花园", "冷宫", "密道", "城墙"] + } +} diff --git a/modules/palace-game/data/templates/fantasy-dynasty.json b/modules/palace-game/data/templates/fantasy-dynasty.json new file mode 100644 index 00000000..818595bc --- /dev/null +++ b/modules/palace-game/data/templates/fantasy-dynasty.json @@ -0,0 +1,40 @@ +{ + "id": "fantasy-dynasty", + "name": "架空王朝", + "description": "九霄帝国·灵力与权谋交织·玄幻宫廷", + "setting": { + "dynasty": "九霄", + "era_prefix": ["灵元", "天衍", "混沌", "太初", "紫极"], + "capital": "天枢城", + "palace_name": "凌霄殿", + "inner_palace": "瑶光阁" + }, + "roles": { + "妃子": { + "title_options": ["星妃", "月嫔", "霜华夫人", "云裳贵人"], + "family_options": ["上古灵族后裔", "剑修世家", "炼丹宗门", "凡人修仙"], + "starting_rank": "嫔", + "intro_template": "你是{family}出身的{title},入宫{years}年。{situation}" + }, + "皇帝": { + "title_options": ["灵帝", "天选之主", "末代帝君"], + "situation_options": ["灵力觉醒·诸族觊觎", "仙魔大战后·百废待兴", "天命将尽·寻找继承"], + "intro_template": "你是{dynasty}的{title},{situation}。" + }, + "重臣": { + "title_options": ["星辰阁大长老", "天机院主", "护国神将", "灵枢司正"], + "faction_options": ["仙道派", "人道派", "中立长老会"], + "intro_template": "你是{dynasty}{title},{faction}核心。{situation}" + }, + "奸臣": { + "title_options": ["暗影阁主", "禁术研究者", "外域使者"], + "ambition_options": ["窃取帝国灵脉", "复活远古邪神", "暗中操控傀儡帝"], + "intro_template": "你是{title},表面忠良,实则{ambition}。" + } + }, + "atmosphere": { + "time_of_day": ["灵潮初涌·黎明", "日中·灵力最盛", "黄昏·阴阳交替", "子夜·暗灵活跃"], + "weather": ["灵雨飘洒", "星河低垂", "雷灵暴动", "万里晴空·灵气充沛", "迷雾笼罩"], + "locations": ["灵枢大殿", "星象台", "瑶光阁", "灵兽园", "封印之地", "虚空裂缝", "万灵池"] + } +} diff --git a/modules/palace-game/frontend/game.html b/modules/palace-game/frontend/game.html new file mode 100644 index 00000000..7ad7d294 --- /dev/null +++ b/modules/palace-game/frontend/game.html @@ -0,0 +1,85 @@ + + + + + + 宫廷纪 · 叙事 + + + + +
+ + +
+ 🏯 宫廷纪 +
+ + +
+
+ + +
+

第一章 · 序幕

+
+ + + + + +
+
+ + +
+

┌ 选项区 ─

+
+
+ + +
+
+ + +
+
▼ 状态栏
+
+
+ 🔴 + 权力值 +
+ 50 +
+
+ 💜 + 地位值 +
+ 50 +
+
+ 💛 + 情感值 +
+ 50 +
+
+ ⚔️ + 冲突值 +
+ 50 +
+
+
+ + +
+ 📖 第1章 · 第1段 + 存档号: — +
+ +
+ + + + diff --git a/modules/palace-game/frontend/game.js b/modules/palace-game/frontend/game.js new file mode 100644 index 00000000..3fb4ce0a --- /dev/null +++ b/modules/palace-game/frontend/game.js @@ -0,0 +1,311 @@ +/** + * M-PALACE · 游戏交互逻辑 + * 管理叙事渲染、选项交互、状态栏更新、存档操作 + */ + +(function () { + 'use strict'; + + var API_BASE = window.location.origin; + var saveId = sessionStorage.getItem('palace_save_id') || ''; + var choicesMeta = null; + var isTyping = false; + + // ---------- Initialization ---------- + function init() { + var startData = sessionStorage.getItem('palace_state'); + var loadedData = sessionStorage.getItem('palace_loaded'); + + if (startData) { + var data = JSON.parse(startData); + sessionStorage.removeItem('palace_state'); + renderGameData(data); + } else if (loadedData) { + var loaded = JSON.parse(loadedData); + sessionStorage.removeItem('palace_loaded'); + renderLoadedData(loaded); + } else if (saveId) { + loadSave(saveId); + } else { + window.location.href = 'index.html'; + } + + bindEvents(); + } + + // ---------- Rendering ---------- + function renderGameData(data) { + saveId = data.save_id || saveId; + document.getElementById('save-id-display').textContent = '存档号: ' + saveId; + + if (data.state && data.state.player) { + var p = data.state.player; + document.getElementById('top-title').textContent = + '🏯 宫廷纪 · ' + (p.worldview || '') + ' · ' + (p.role || ''); + } + + if (data.narrative) { + renderNarrative(data.narrative); + } + + if (data.four_dimensions) { + updateStatusBar(data.four_dimensions); + } + + updateProgressInfo(data.chapter || 1, data.paragraph || 1); + } + + function renderLoadedData(data) { + document.getElementById('save-id-display').textContent = '存档号: ' + saveId; + + if (data.state) { + var s = data.state; + if (s.player) { + document.getElementById('top-title').textContent = + '🏯 宫廷纪 · ' + (s.player.worldview || '') + ' · ' + (s.player.role || ''); + } + if (s.four_dimensions) { + updateStatusBar(s.four_dimensions); + } + updateProgressInfo(s.chapter || 1, s.paragraph || 1); + } + + // Render last narrative from history or show resume message + var narrativeEl = document.getElementById('narrative-text'); + narrativeEl.textContent = '存档已恢复。继续你的宫廷故事……'; + renderChoices(['继续前行', '回顾往事', '观察四周']); + } + + function renderNarrative(narrative) { + if (narrative.chapter_title) { + document.getElementById('chapter-title').textContent = narrative.chapter_title; + } + + var textEl = document.getElementById('narrative-text'); + if (narrative.narrative) { + typewriter(textEl, narrative.narrative); + } + + if (narrative.choices && narrative.choices.length > 0) { + // Delay choices until typewriter finishes + var delay = narrative.narrative ? narrative.narrative.length * 50 + 500 : 200; + setTimeout(function () { + renderChoices(narrative.choices); + }, delay); + } + + choicesMeta = narrative.choices_meta || null; + } + + function renderChoices(choices) { + var container = document.getElementById('choices-container'); + container.innerHTML = ''; + + var nums = ['①', '②', '③']; + choices.forEach(function (text, i) { + var btn = document.createElement('button'); + btn.className = 'choice-btn fade-in'; + btn.innerHTML = '' + (nums[i] || '·') + '' + escapeHtml(text); + btn.addEventListener('click', function () { + submitChoice(i, text); + }); + container.appendChild(btn); + }); + } + + // ---------- Typewriter Effect ---------- + function typewriter(el, text) { + isTyping = true; + el.textContent = ''; + var cursor = document.createElement('span'); + cursor.className = 'typewriter-cursor'; + el.appendChild(cursor); + + var i = 0; + var interval = setInterval(function () { + if (i < text.length) { + el.insertBefore(document.createTextNode(text[i]), cursor); + i++; + } else { + clearInterval(interval); + if (cursor.parentNode) cursor.remove(); + isTyping = false; + } + }, 45); + } + + // ---------- Status Bar ---------- + function updateStatusBar(dims) { + var keys = ['power', 'status', 'emotion', 'conflict']; + keys.forEach(function (k) { + var val = dims[k] != null ? dims[k] : 50; + var bar = document.getElementById('bar-' + k); + var valEl = document.getElementById('val-' + k); + if (bar) bar.style.width = val + '%'; + if (valEl) valEl.textContent = val; + }); + } + + function updateProgressInfo(chapter, paragraph) { + var el = document.getElementById('progress-info'); + if (el) el.textContent = '📖 第' + chapter + '章 · 第' + paragraph + '段'; + } + + // ---------- Interactions ---------- + function submitChoice(index, text) { + if (isTyping) return; + disableChoices(); + + fetch(API_BASE + '/api/palace/interact', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + save_id: saveId, + input: text, + choice_index: index, + option_meta: choicesMeta + }) + }) + .then(function (r) { return r.json(); }) + .then(function (data) { + if (data.error) { + showToast(data.message || '互动失败'); + enableChoices(); + return; + } + choicesMeta = data.choices_meta || null; + if (data.narrative) renderNarrative(data.narrative); + if (data.four_dimensions) updateStatusBar(data.four_dimensions); + updateProgressInfo(data.chapter || 1, data.paragraph || 1); + }) + .catch(function (err) { + showToast('网络错误:' + err.message); + enableChoices(); + }); + } + + function submitFreeInput() { + var input = document.getElementById('free-input'); + var text = input.value.trim(); + if (!text || isTyping) return; + input.value = ''; + disableChoices(); + + fetch(API_BASE + '/api/palace/interact', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + save_id: saveId, + input: text, + choice_index: null, + option_meta: choicesMeta + }) + }) + .then(function (r) { return r.json(); }) + .then(function (data) { + if (data.error) { + showToast(data.message || '互动失败'); + enableChoices(); + return; + } + choicesMeta = data.choices_meta || null; + if (data.narrative) renderNarrative(data.narrative); + if (data.four_dimensions) updateStatusBar(data.four_dimensions); + updateProgressInfo(data.chapter || 1, data.paragraph || 1); + }) + .catch(function (err) { + showToast('网络错误:' + err.message); + enableChoices(); + }); + } + + function disableChoices() { + var btns = document.querySelectorAll('.choice-btn'); + btns.forEach(function (b) { b.disabled = true; b.style.opacity = '0.5'; }); + } + + function enableChoices() { + var btns = document.querySelectorAll('.choice-btn'); + btns.forEach(function (b) { b.disabled = false; b.style.opacity = '1'; }); + } + + // ---------- Save / Load ---------- + function doSave() { + fetch(API_BASE + '/api/palace/save', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ save_id: saveId, state: {}, persona: {}, history: {} }) + }) + .then(function (r) { return r.json(); }) + .then(function (data) { + if (data.error) { showToast(data.message); return; } + showToast('存档成功:' + data.save_id); + }) + .catch(function (err) { + showToast('存档失败:' + err.message); + }); + } + + function loadSave(id) { + fetch(API_BASE + '/api/palace/save/load', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ save_id: id }) + }) + .then(function (r) { return r.json(); }) + .then(function (data) { + if (data.error) { + showToast(data.message || '读档失败'); + window.location.href = 'index.html'; + return; + } + renderLoadedData(data); + }) + .catch(function (err) { + showToast('网络错误:' + err.message); + window.location.href = 'index.html'; + }); + } + + // ---------- Events ---------- + function bindEvents() { + document.getElementById('btn-save').addEventListener('click', doSave); + + document.getElementById('btn-free-submit').addEventListener('click', submitFreeInput); + + document.getElementById('free-input').addEventListener('keydown', function (e) { + if (e.key === 'Enter') submitFreeInput(); + }); + + var toggle = document.getElementById('status-toggle'); + var bar = document.getElementById('status-bar'); + toggle.addEventListener('click', function () { + bar.classList.toggle('collapsed'); + toggle.textContent = bar.classList.contains('collapsed') ? '▶ 状态栏' : '▼ 状态栏'; + }); + } + + // ---------- Utilities ---------- + function escapeHtml(str) { + var div = document.createElement('div'); + div.appendChild(document.createTextNode(str)); + return div.innerHTML; + } + + function showToast(msg) { + var old = document.querySelector('.toast'); + if (old) old.remove(); + var el = document.createElement('div'); + el.className = 'toast'; + el.textContent = msg; + document.body.appendChild(el); + setTimeout(function () { if (el.parentNode) el.remove(); }, 3000); + } + + // ---------- Boot ---------- + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', init); + } else { + init(); + } +})(); diff --git a/modules/palace-game/frontend/index.html b/modules/palace-game/frontend/index.html new file mode 100644 index 00000000..e3969f3f --- /dev/null +++ b/modules/palace-game/frontend/index.html @@ -0,0 +1,123 @@ + + + + + + 宫·廷·纪 — 动态人格宫廷生成系统 + + + + +
+ +
🏯
+

宫 · 廷 · 纪

+

动态人格宫廷生成系统

+ +
+
+ + +
+ +
+ + +
+ + +
+ +
+
+

已有存档?输入编号继续

+
+ + +
+
+ + +
+ + + + diff --git a/modules/palace-game/frontend/save.html b/modules/palace-game/frontend/save.html new file mode 100644 index 00000000..ac38470b --- /dev/null +++ b/modules/palace-game/frontend/save.html @@ -0,0 +1,50 @@ + + + + + + 宫廷纪 · 存档管理 + + + + +
+ +

🏯 存档管理

+ +
+ + +
+
+ +
+ + +
+
+
+ +
+ + +

已有存档

+
+
+ + + +
+
+ +
+ +
+ +
+ +
+ + + + diff --git a/modules/palace-game/frontend/save.js b/modules/palace-game/frontend/save.js new file mode 100644 index 00000000..bc6551e0 --- /dev/null +++ b/modules/palace-game/frontend/save.js @@ -0,0 +1,139 @@ +/** + * M-PALACE · 存档读档逻辑 + * 列出存档、读取存档、删除存档 + */ + +(function () { + 'use strict'; + + var API_BASE = window.location.origin; + + function init() { + loadSaveList(); + bindEvents(); + } + + // ---------- Load Save List ---------- + function loadSaveList() { + var container = document.getElementById('save-list'); + + fetch(API_BASE + '/api/palace/save/list') + .then(function (r) { return r.json(); }) + .then(function (data) { + if (data.error) { + container.innerHTML = '
获取存档列表失败
'; + return; + } + renderSaveList(data.saves || []); + }) + .catch(function () { + container.innerHTML = '
无法连接服务器
'; + }); + } + + function renderSaveList(saves) { + var container = document.getElementById('save-list'); + + if (saves.length === 0) { + container.innerHTML = '
暂无存档
'; + return; + } + + container.innerHTML = ''; + saves.forEach(function (save) { + var item = document.createElement('div'); + item.className = 'save-item fade-in'; + + var info = document.createElement('div'); + info.className = 'save-info'; + + var idEl = document.createElement('div'); + idEl.className = 'save-id'; + idEl.textContent = save.save_id; + + var detail = document.createElement('div'); + detail.className = 'save-detail'; + var parts = []; + if (save.dynasty) parts.push(save.dynasty); + if (save.role) parts.push(save.role); + if (save.chapter) parts.push('第' + save.chapter + '章'); + if (save.created_at) parts.push(save.created_at.slice(0, 16).replace('T', ' ')); + detail.textContent = parts.join(' · '); + + info.appendChild(idEl); + info.appendChild(detail); + + var actions = document.createElement('div'); + actions.className = 'save-actions'; + + var loadBtn = document.createElement('button'); + loadBtn.className = 'btn btn-small'; + loadBtn.textContent = '读档'; + loadBtn.addEventListener('click', function () { + doLoad(save.save_id); + }); + + actions.appendChild(loadBtn); + + item.appendChild(info); + item.appendChild(actions); + container.appendChild(item); + }); + } + + // ---------- Load Save ---------- + function doLoad(saveId) { + fetch(API_BASE + '/api/palace/save/load', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ save_id: saveId }) + }) + .then(function (r) { return r.json(); }) + .then(function (data) { + if (data.error) { + showToast(data.message || '读档失败'); + return; + } + sessionStorage.setItem('palace_save_id', saveId); + sessionStorage.setItem('palace_loaded', JSON.stringify(data)); + window.location.href = 'game.html'; + }) + .catch(function (err) { + showToast('网络错误:' + err.message); + }); + } + + // ---------- Events ---------- + function bindEvents() { + document.getElementById('btn-load').addEventListener('click', function () { + var id = document.getElementById('load-id').value.trim(); + if (!id) { showToast('请输入存档编号'); return; } + doLoad(id); + }); + + document.getElementById('load-id').addEventListener('keydown', function (e) { + if (e.key === 'Enter') { + var id = this.value.trim(); + if (id) doLoad(id); + } + }); + } + + // ---------- Utilities ---------- + function showToast(msg) { + var old = document.querySelector('.toast'); + if (old) old.remove(); + var el = document.createElement('div'); + el.className = 'toast'; + el.textContent = msg; + document.body.appendChild(el); + setTimeout(function () { if (el.parentNode) el.remove(); }, 3000); + } + + // ---------- Boot ---------- + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', init); + } else { + init(); + } +})(); diff --git a/modules/palace-game/frontend/style.css b/modules/palace-game/frontend/style.css new file mode 100644 index 00000000..ffd770f1 --- /dev/null +++ b/modules/palace-game/frontend/style.css @@ -0,0 +1,564 @@ +/* ==================================== + M-PALACE · 宫廷视觉风格 + 暗红+金色主题 · 古风字体 · 逐字动画 + ==================================== */ + +/* ---------- CSS Variables ---------- */ +:root { + --bg-primary: #1a0a0a; + --bg-secondary: #0d0d1a; + --gold: #c9a96e; + --gold-light: #e8d5a3; + --danger: #8b0000; + --text-primary: #f5f0e8; + --text-secondary:#a0937d; + --border-gold: rgba(201,169,110,0.15); + --border-gold-md:rgba(201,169,110,0.3); + --glow-gold: rgba(201,169,110,0.25); +} + +/* ---------- Reset & Base ---------- */ +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + +body { + font-family: 'PingFang SC', 'Microsoft YaHei', sans-serif; + background: var(--bg-primary); + color: var(--text-primary); + min-height: 100vh; + line-height: 1.6; + overflow-x: hidden; +} + +/* ---------- Background Texture ---------- */ +body::before { + content: ''; + position: fixed; + top: 0; left: 0; right: 0; bottom: 0; + background: + radial-gradient(ellipse at 20% 50%, rgba(139,0,0,0.08) 0%, transparent 60%), + radial-gradient(ellipse at 80% 20%, rgba(201,169,110,0.05) 0%, transparent 50%), + radial-gradient(ellipse at 50% 80%, rgba(13,13,26,0.6) 0%, transparent 70%); + pointer-events: none; + z-index: 0; +} + +/* ---------- Layout ---------- */ +.container { + max-width: 720px; + margin: 0 auto; + padding: 2rem 1.5rem; + position: relative; + z-index: 1; +} + +/* ---------- Typography ---------- */ +.serif { + font-family: 'Noto Serif SC', 'Source Han Serif CN', 'STSong', serif; +} + +h1, h2, h3 { color: var(--gold); font-weight: 400; } + +/* ---------- Golden Divider ---------- */ +.divider { + height: 1px; + background: linear-gradient(90deg, transparent, var(--gold), transparent); + margin: 1.5rem 0; + opacity: 0.4; +} + +.divider-text { + text-align: center; + color: var(--text-secondary); + font-size: 0.85rem; + padding: 0.5rem 0; +} + +/* ==================================== + INDEX PAGE — Quick Start + ==================================== */ +.start-page { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-height: 100vh; + text-align: center; + padding: 2rem; +} + +.start-icon { + font-size: 4rem; + margin-bottom: 1rem; + filter: drop-shadow(0 0 20px var(--glow-gold)); +} + +.start-title { + font-family: 'Noto Serif SC', 'Source Han Serif CN', serif; + font-size: 2.2rem; + letter-spacing: 0.8rem; + color: var(--gold); + margin-bottom: 0.3rem; +} + +.start-subtitle { + font-size: 0.9rem; + color: var(--text-secondary); + margin-bottom: 2.5rem; + letter-spacing: 0.2rem; +} + +/* Form Card */ +.start-card { + background: rgba(26,10,10,0.85); + border: 1px solid var(--border-gold); + border-radius: 8px; + padding: 2rem 2.5rem; + width: 100%; + max-width: 420px; + backdrop-filter: blur(8px); +} + +.form-group { + margin-bottom: 1.5rem; + text-align: left; +} + +.form-group label { + display: block; + font-size: 0.9rem; + color: var(--text-secondary); + margin-bottom: 0.5rem; +} + +select, input[type="text"] { + width: 100%; + padding: 0.7rem 1rem; + background: rgba(13,13,26,0.6); + border: 1px solid var(--border-gold-md); + border-radius: 4px; + color: var(--text-primary); + font-size: 0.95rem; + font-family: inherit; + outline: none; + transition: border-color 0.3s; +} + +select:focus, input[type="text"]:focus { + border-color: var(--gold); + box-shadow: 0 0 8px var(--glow-gold); +} + +select option { + background: var(--bg-primary); + color: var(--text-primary); +} + +/* ---------- Buttons ---------- */ +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + padding: 0.75rem 2rem; + border: 1px solid var(--gold); + border-radius: 4px; + background: rgba(139,0,0,0.3); + color: var(--gold); + font-size: 1rem; + font-family: 'Noto Serif SC', 'Source Han Serif CN', serif; + cursor: pointer; + transition: all 0.3s; + letter-spacing: 0.15rem; +} + +.btn:hover { + background: rgba(139,0,0,0.6); + box-shadow: 0 0 16px var(--glow-gold); + transform: translateY(-1px); +} + +.btn:active { transform: translateY(0); } + +.btn-primary { + width: 100%; + padding: 1rem; + font-size: 1.1rem; + margin-top: 0.5rem; +} + +.btn-small { + padding: 0.5rem 1.2rem; + font-size: 0.85rem; +} + +/* Load Section */ +.load-section { + width: 100%; + max-width: 420px; + margin-top: 1rem; +} + +.load-row { + display: flex; + gap: 0.5rem; + margin-top: 0.5rem; +} + +.load-row input { flex: 1; } + +/* Footer */ +.start-footer { + margin-top: 3rem; + font-size: 0.8rem; + color: var(--text-secondary); + opacity: 0.6; +} + +/* ==================================== + GAME PAGE — Main Narrative + ==================================== */ +.game-page { + display: flex; + flex-direction: column; + min-height: 100vh; +} + +/* Top Bar */ +.top-bar { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.8rem 1.5rem; + border-bottom: 1px solid var(--border-gold); + background: rgba(26,10,10,0.9); + backdrop-filter: blur(6px); + position: sticky; + top: 0; + z-index: 10; +} + +.top-bar-title { + font-family: 'Noto Serif SC', 'Source Han Serif CN', serif; + font-size: 1rem; + color: var(--gold); +} + +.top-bar-actions { + display: flex; + gap: 0.5rem; +} + +/* Narrative Area */ +.narrative-area { + flex: 1; + padding: 2rem 1.5rem; + max-width: 720px; + margin: 0 auto; + width: 100%; +} + +.chapter-title { + font-family: 'Noto Serif SC', 'Source Han Serif CN', serif; + font-size: 1.4rem; + color: var(--gold); + text-align: center; + margin-bottom: 1.5rem; + letter-spacing: 0.3rem; +} + +.narrative-text { + font-family: 'Noto Serif SC', 'Source Han Serif CN', serif; + font-size: 1rem; + line-height: 1.9; + color: var(--text-primary); + text-align: justify; + margin-bottom: 2rem; + min-height: 120px; +} + +/* Typewriter Animation */ +.typewriter-cursor { + display: inline-block; + width: 2px; + height: 1.1em; + background: var(--gold); + margin-left: 2px; + vertical-align: text-bottom; + animation: blink 0.8s infinite; +} + +@keyframes blink { + 0%, 100% { opacity: 1; } + 50% { opacity: 0; } +} + +/* Choices Area */ +.choices-area { + padding: 0 1.5rem 1.5rem; + max-width: 720px; + margin: 0 auto; + width: 100%; +} + +.choices-label { + font-size: 0.85rem; + color: var(--text-secondary); + margin-bottom: 0.8rem; + padding-left: 0.3rem; +} + +.choice-btn { + display: block; + width: 100%; + padding: 0.9rem 1.2rem; + margin-bottom: 0.6rem; + background: rgba(139,0,0,0.15); + border: 1px solid var(--border-gold-md); + border-radius: 6px; + color: var(--text-primary); + font-size: 0.95rem; + font-family: 'Noto Serif SC', 'Source Han Serif CN', serif; + text-align: left; + cursor: pointer; + transition: all 0.3s; +} + +.choice-btn:hover { + background: rgba(139,0,0,0.35); + border-color: var(--gold); + box-shadow: 0 0 12px var(--glow-gold); + transform: translateX(4px); +} + +.choice-btn:active { + transform: translateX(2px); +} + +.choice-btn .choice-num { + color: var(--gold); + margin-right: 0.5rem; + font-weight: 600; +} + +/* Free Input */ +.free-input-row { + display: flex; + gap: 0.5rem; + margin-top: 0.3rem; +} + +.free-input-row input { + flex: 1; + font-family: 'Noto Serif SC', 'Source Han Serif CN', serif; +} + +/* Status Bar */ +.status-bar { + padding: 1rem 1.5rem; + border-top: 1px solid var(--border-gold); + background: rgba(13,13,26,0.6); + backdrop-filter: blur(6px); +} + +.status-toggle { + font-size: 0.8rem; + color: var(--text-secondary); + cursor: pointer; + margin-bottom: 0.5rem; + user-select: none; +} + +.status-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0.6rem 1.5rem; +} + +.stat-row { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.85rem; +} + +.stat-icon { width: 1.2rem; text-align: center; } + +.stat-label { + width: 3rem; + color: var(--text-secondary); + flex-shrink: 0; +} + +.stat-bar-bg { + flex: 1; + height: 6px; + background: rgba(201,169,110,0.1); + border-radius: 3px; + overflow: hidden; +} + +.stat-bar-fill { + height: 100%; + border-radius: 3px; + transition: width 0.6s ease; +} + +.stat-bar-fill.power { background: linear-gradient(90deg, #8b0000, #c9a96e); } +.stat-bar-fill.status { background: linear-gradient(90deg, #6b21a8, #c9a96e); } +.stat-bar-fill.emotion { background: linear-gradient(90deg, #b8860b, #e8d5a3); } +.stat-bar-fill.conflict{ background: linear-gradient(90deg, #4a1a2e, #8b0000); } + +.stat-value { + width: 1.8rem; + text-align: right; + color: var(--gold-light); + font-size: 0.8rem; +} + +/* Bottom Bar */ +.bottom-bar { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.5rem 1.5rem; + font-size: 0.75rem; + color: var(--text-secondary); + border-top: 1px solid var(--border-gold); + background: rgba(26,10,10,0.9); +} + +/* Status collapsed */ +.status-bar.collapsed .status-grid { display: none; } + +/* ==================================== + SAVE PAGE + ==================================== */ +.save-page { + min-height: 100vh; + padding: 2rem 1.5rem; +} + +.save-page h1 { + font-family: 'Noto Serif SC', 'Source Han Serif CN', serif; + text-align: center; + margin-bottom: 2rem; + letter-spacing: 0.4rem; +} + +.save-list { + max-width: 600px; + margin: 0 auto; +} + +.save-item { + display: flex; + align-items: center; + justify-content: space-between; + padding: 1rem 1.2rem; + margin-bottom: 0.8rem; + background: rgba(26,10,10,0.7); + border: 1px solid var(--border-gold); + border-radius: 6px; + transition: border-color 0.3s; +} + +.save-item:hover { + border-color: var(--gold); +} + +.save-info { flex: 1; } + +.save-id { + font-family: 'Noto Serif SC', 'Source Han Serif CN', serif; + font-size: 1rem; + color: var(--gold); +} + +.save-detail { + font-size: 0.8rem; + color: var(--text-secondary); + margin-top: 0.2rem; +} + +.save-actions { + display: flex; + gap: 0.4rem; +} + +.empty-state { + text-align: center; + padding: 3rem; + color: var(--text-secondary); +} + +/* ==================================== + LOADING & TRANSITIONS + ==================================== */ +.loading { + display: flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + padding: 2rem; + color: var(--text-secondary); +} + +.loading-dot { + width: 6px; + height: 6px; + background: var(--gold); + border-radius: 50%; + animation: pulse 1.2s infinite; +} + +.loading-dot:nth-child(2) { animation-delay: 0.2s; } +.loading-dot:nth-child(3) { animation-delay: 0.4s; } + +@keyframes pulse { + 0%, 80%, 100% { opacity: 0.3; transform: scale(0.8); } + 40% { opacity: 1; transform: scale(1.2); } +} + +/* Fade transition */ +.fade-in { + animation: fadeIn 0.6s ease forwards; +} + +@keyframes fadeIn { + from { opacity: 0; transform: translateY(8px); } + to { opacity: 1; transform: translateY(0); } +} + +/* Toast notification */ +.toast { + position: fixed; + top: 1rem; + right: 1rem; + padding: 0.8rem 1.5rem; + background: rgba(26,10,10,0.95); + border: 1px solid var(--gold); + border-radius: 6px; + color: var(--gold-light); + font-size: 0.9rem; + z-index: 100; + animation: slideIn 0.3s ease, fadeOut 0.3s ease 2.5s forwards; +} + +@keyframes slideIn { + from { transform: translateX(100%); opacity: 0; } + to { transform: translateX(0); opacity: 1; } +} + +@keyframes fadeOut { + to { opacity: 0; transform: translateY(-10px); } +} + +/* ==================================== + Responsive + ==================================== */ +@media (max-width: 600px) { + .start-card { padding: 1.5rem; } + .start-title { font-size: 1.8rem; letter-spacing: 0.5rem; } + .status-grid { grid-template-columns: 1fr; } + .top-bar-title { font-size: 0.85rem; } +}