feat(M-PALACE): create complete palace-game module structure

- Data layer: palace-db.json, persona-dict.json, world templates (古代中国 + 架空王朝)
- Backend engines: persona-analyzer, background-gen, plot-engine, save-manager
- Backend routes: start.js, interact.js, save.js
- Backend server: Express on port 3003
- Frontend: index.html, game.html, save.html, game.js, save.js, style.css
- .gitignore: exclude save data files

Co-authored-by: qinfendebingshuo <207279273+qinfendebingshuo@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot] 2026-03-11 12:52:31 +00:00
parent b92a8d4cb1
commit 331b50268b
20 changed files with 2691 additions and 0 deletions

3
.gitignore vendored
View File

@ -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-*

View File

@ -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
};

View File

@ -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
};

View File

@ -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
};

View File

@ -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
};

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -0,0 +1,19 @@
{
"power": {
"positions": ["皇帝", "太后", "皇后", "贵妃", "丞相", "大将军", "太监总管"],
"factions": ["太后党", "皇后党", "新贵派", "清流派", "孤臣"],
"events": ["朝堂弹劾", "密谋政变", "联姻结盟", "战事告急"]
},
"status": {
"ranks": ["答应", "常在", "贵人", "嫔", "妃", "贵妃", "皇贵妃", "皇后"],
"events": ["晋封", "降位", "禁足", "受宠", "失宠", "怀孕", "小产"]
},
"emotion": {
"types": ["暗恋", "深情", "嫉妒", "怨恨", "愧疚", "依赖", "背叛"],
"triggers": ["偶遇旧人", "收到密信", "目睹真相", "被迫抉择"]
},
"conflict": {
"types": ["争宠", "夺权", "报仇", "自保", "救人", "揭秘"],
"escalation": ["暗示", "试探", "交锋", "撕破脸", "鱼死网破"]
}
}

View File

@ -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": ["回避冲突", "选择保守", "犹豫不决"]
}
]
}

View File

View File

@ -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": ["御书房", "太和殿", "长乐宫", "御花园", "冷宫", "密道", "城墙"]
}
}

View File

@ -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": ["灵枢大殿", "星象台", "瑶光阁", "灵兽园", "封印之地", "虚空裂缝", "万灵池"]
}
}

View File

@ -0,0 +1,85 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>宫廷纪 · 叙事</title>
<link rel="stylesheet" href="style.css">
<link href="https://fonts.googleapis.com/css2?family=Noto+Serif+SC:wght@400;600&display=swap" rel="stylesheet">
</head>
<body>
<div class="game-page">
<!-- Top Bar -->
<div class="top-bar">
<span class="top-bar-title" id="top-title">🏯 宫廷纪</span>
<div class="top-bar-actions">
<button class="btn btn-small" id="btn-save">存档</button>
<button class="btn btn-small" id="btn-exit" onclick="window.location.href='index.html'">退出</button>
</div>
</div>
<!-- Narrative Area -->
<div class="narrative-area fade-in">
<h2 class="chapter-title" id="chapter-title">第一章 · 序幕</h2>
<div class="narrative-text serif" id="narrative-text">
<span class="loading">
<span class="loading-dot"></span>
<span class="loading-dot"></span>
<span class="loading-dot"></span>
</span>
</div>
</div>
<!-- Choices Area -->
<div class="choices-area" id="choices-area">
<p class="choices-label">┌ 选项区 ─</p>
<div id="choices-container"></div>
<div class="free-input-row">
<input type="text" id="free-input" placeholder="④ 自由输入你的回应…">
<button class="btn btn-small" id="btn-free-submit">发送</button>
</div>
</div>
<!-- Status Bar -->
<div class="status-bar" id="status-bar">
<div class="status-toggle" id="status-toggle">▼ 状态栏</div>
<div class="status-grid" id="status-grid">
<div class="stat-row">
<span class="stat-icon">🔴</span>
<span class="stat-label">权力值</span>
<div class="stat-bar-bg"><div class="stat-bar-fill power" id="bar-power" style="width:50%"></div></div>
<span class="stat-value" id="val-power">50</span>
</div>
<div class="stat-row">
<span class="stat-icon">💜</span>
<span class="stat-label">地位值</span>
<div class="stat-bar-bg"><div class="stat-bar-fill status" id="bar-status" style="width:50%"></div></div>
<span class="stat-value" id="val-status">50</span>
</div>
<div class="stat-row">
<span class="stat-icon">💛</span>
<span class="stat-label">情感值</span>
<div class="stat-bar-bg"><div class="stat-bar-fill emotion" id="bar-emotion" style="width:50%"></div></div>
<span class="stat-value" id="val-emotion">50</span>
</div>
<div class="stat-row">
<span class="stat-icon">⚔️</span>
<span class="stat-label">冲突值</span>
<div class="stat-bar-bg"><div class="stat-bar-fill conflict" id="bar-conflict" style="width:50%"></div></div>
<span class="stat-value" id="val-conflict">50</span>
</div>
</div>
</div>
<!-- Bottom Bar -->
<div class="bottom-bar">
<span id="progress-info">📖 第1章 · 第1段</span>
<span id="save-id-display">存档号: —</span>
</div>
</div>
<script src="game.js"></script>
</body>
</html>

View File

@ -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 = '<span class="choice-num">' + (nums[i] || '·') + '</span>' + 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();
}
})();

View File

@ -0,0 +1,123 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>宫·廷·纪 — 动态人格宫廷生成系统</title>
<link rel="stylesheet" href="style.css">
<link href="https://fonts.googleapis.com/css2?family=Noto+Serif+SC:wght@400;600&display=swap" rel="stylesheet">
</head>
<body>
<div class="start-page">
<div class="start-icon">🏯</div>
<h1 class="start-title">宫 · 廷 · 纪</h1>
<p class="start-subtitle">动态人格宫廷生成系统</p>
<div class="start-card">
<div class="form-group">
<label for="worldview">选择你的世界观</label>
<select id="worldview">
<option value="古代中国">古代中国</option>
<option value="架空王朝">架空王朝</option>
</select>
</div>
<div class="form-group">
<label for="role">选择你的身份</label>
<select id="role">
<option value="随机">随机</option>
<option value="妃子">妃子</option>
<option value="皇帝">皇帝</option>
<option value="重臣">重臣</option>
<option value="奸臣">奸臣</option>
</select>
</div>
<button class="btn btn-primary" id="btn-start">🏯 踏入宫廷</button>
</div>
<div class="load-section">
<div class="divider"></div>
<p class="divider-text">已有存档?输入编号继续</p>
<div class="load-row">
<input type="text" id="save-id-input" placeholder="PAL-20260311-A3K9">
<button class="btn btn-small" id="btn-load">读档</button>
</div>
</div>
<p class="start-footer">🏮 HoloLake Era · 光湖语言人格系统</p>
</div>
<script>
(function () {
var API_BASE = window.location.origin;
document.getElementById('btn-start').addEventListener('click', function () {
var worldview = document.getElementById('worldview').value;
var role = document.getElementById('role').value;
var btn = this;
btn.disabled = true;
btn.textContent = '正在生成宫廷世界…';
fetch(API_BASE + '/api/palace/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ worldview: worldview, role: role })
})
.then(function (r) { return r.json(); })
.then(function (data) {
if (data.error) {
showToast('生成失败:' + data.message);
btn.disabled = false;
btn.textContent = '🏯 踏入宫廷';
return;
}
sessionStorage.setItem('palace_save_id', data.save_id);
sessionStorage.setItem('palace_state', JSON.stringify(data));
window.location.href = 'game.html';
})
.catch(function (err) {
showToast('网络错误:' + err.message);
btn.disabled = false;
btn.textContent = '🏯 踏入宫廷';
});
});
document.getElementById('btn-load').addEventListener('click', function () {
var saveId = document.getElementById('save-id-input').value.trim();
if (!saveId) { showToast('请输入存档编号'); return; }
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);
});
});
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);
}
})();
</script>
</body>
</html>

View File

@ -0,0 +1,50 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>宫廷纪 · 存档管理</title>
<link rel="stylesheet" href="style.css">
<link href="https://fonts.googleapis.com/css2?family=Noto+Serif+SC:wght@400;600&display=swap" rel="stylesheet">
</head>
<body>
<div class="save-page container">
<h1>🏯 存档管理</h1>
<div class="divider"></div>
<!-- Manual Load -->
<div class="start-card" style="margin: 0 auto 2rem; max-width:500px;">
<div class="form-group">
<label for="load-id">输入存档编号读档</label>
<div class="load-row">
<input type="text" id="load-id" placeholder="PAL-20260311-A3K9">
<button class="btn btn-small" id="btn-load">读档</button>
</div>
</div>
</div>
<div class="divider"></div>
<!-- Save List -->
<h2 style="text-align:center; margin-bottom:1rem; font-size:1.1rem;">已有存档</h2>
<div class="save-list" id="save-list">
<div class="loading">
<span class="loading-dot"></span>
<span class="loading-dot"></span>
<span class="loading-dot"></span>
</div>
</div>
<div class="divider" style="margin-top:2rem;"></div>
<div style="text-align:center;">
<button class="btn btn-small" onclick="window.location.href='index.html'">← 返回首页</button>
</div>
</div>
<script src="save.js"></script>
</body>
</html>

View File

@ -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 = '<div class="empty-state">获取存档列表失败</div>';
return;
}
renderSaveList(data.saves || []);
})
.catch(function () {
container.innerHTML = '<div class="empty-state">无法连接服务器</div>';
});
}
function renderSaveList(saves) {
var container = document.getElementById('save-list');
if (saves.length === 0) {
container.innerHTML = '<div class="empty-state">暂无存档</div>';
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();
}
})();

View File

@ -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; }
}