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:
parent
b92a8d4cb1
commit
331b50268b
|
|
@ -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-*
|
||||
|
|
|
|||
|
|
@ -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
|
||||
};
|
||||
|
|
@ -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
|
||||
};
|
||||
|
|
@ -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
|
||||
};
|
||||
|
|
@ -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
|
||||
};
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
{
|
||||
"power": {
|
||||
"positions": ["皇帝", "太后", "皇后", "贵妃", "丞相", "大将军", "太监总管"],
|
||||
"factions": ["太后党", "皇后党", "新贵派", "清流派", "孤臣"],
|
||||
"events": ["朝堂弹劾", "密谋政变", "联姻结盟", "战事告急"]
|
||||
},
|
||||
"status": {
|
||||
"ranks": ["答应", "常在", "贵人", "嫔", "妃", "贵妃", "皇贵妃", "皇后"],
|
||||
"events": ["晋封", "降位", "禁足", "受宠", "失宠", "怀孕", "小产"]
|
||||
},
|
||||
"emotion": {
|
||||
"types": ["暗恋", "深情", "嫉妒", "怨恨", "愧疚", "依赖", "背叛"],
|
||||
"triggers": ["偶遇旧人", "收到密信", "目睹真相", "被迫抉择"]
|
||||
},
|
||||
"conflict": {
|
||||
"types": ["争宠", "夺权", "报仇", "自保", "救人", "揭秘"],
|
||||
"escalation": ["暗示", "试探", "交锋", "撕破脸", "鱼死网破"]
|
||||
}
|
||||
}
|
||||
|
|
@ -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": ["回避冲突", "选择保守", "犹豫不决"]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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": ["御书房", "太和殿", "长乐宫", "御花园", "冷宫", "密道", "城墙"]
|
||||
}
|
||||
}
|
||||
|
|
@ -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": ["灵枢大殿", "星象台", "瑶光阁", "灵兽园", "封印之地", "虚空裂缝", "万灵池"]
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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();
|
||||
}
|
||||
})();
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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();
|
||||
}
|
||||
})();
|
||||
|
|
@ -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; }
|
||||
}
|
||||
Loading…
Reference in New Issue