Files
Nyaruru_modifier_public/tool/lib/data.js
T

217 lines
8.3 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 静态数据加载
//
// 数据来源全部是**之前分析阶段已产出的本地文件**(aitemp/out 和 aietmp/unpacked),
// 工具运行时只读它们,不碰游戏目录里的任何文件。
//
// unpacked/data/System.json → 360 个开关的中文名
// unpacked/data/MapInfos.json → 393 张地图
// out/triggers.csv → 897 条剧情触发点(其中 731 条有坐标)
//
// ⚠️ **台词不在这里。** 这是"C 方案"(见开发文档 12.5)的核心约定:
// 随包分发的只有**结构索引**(哪里有个触发点),
// 一个字创作内容都不带。台词在连上游戏后从 `$dataStory` 实时读,
// 只存在于内存,**绝不落盘**(读取逻辑在 lib/ops.js 的 readStoryIndex)。
//
// 历史:早期版本这里有个 loadStoryIndex(),启动时读 ../out/story.json
// (1.9 MB,全部 870 条剧情的完整台词)。那是作者的原创剧本,
// 随包外传等于分发游戏内容,发布版必须去掉。
const fs = require('fs');
const path = require('path');
/** 极简 CSV 解析(RFC4180:支持引号包裹、字段内逗号、双引号转义) */
function parseCsv(text) {
const rows = [];
let row = [], field = '', inQuotes = false;
// 去掉 BOM
if (text.charCodeAt(0) === 0xFEFF) text = text.slice(1);
for (let i = 0; i < text.length; i++) {
const c = text[i];
if (inQuotes) {
if (c === '"') {
if (text[i + 1] === '"') { field += '"'; i++; }
else inQuotes = false;
} else field += c;
} else if (c === '"') {
inQuotes = true;
} else if (c === ',') {
row.push(field); field = '';
} else if (c === '\n') {
row.push(field); rows.push(row); row = []; field = '';
} else if (c !== '\r') {
field += c;
}
}
if (field !== '' || row.length) { row.push(field); rows.push(row); }
return rows;
}
function readJson(file) {
return JSON.parse(fs.readFileSync(file, 'utf8'));
}
/**
* 载入每张地图的 BGM 名,用来做**粗略的区域提示**。
*
* 游戏的地图在 MapInfos.json 里只有 "MAP170" 这种编号,没有地点名
* (Map*.json 里的 displayName 和 meta 都是空的)。
* 但 BGM 文件名有区分度:cave(24张) / black_forest(26张) / lake(23张)…
* 所以拿它当"这片区域大概是什么"的线索,聊胜于无。
*/
const AREA_NAMES = {
cave: '洞窟', black_forest: '黑森林', lake: '湖畔',
town: '城镇', boss: 'Boss 战', title: '标题',
};
function loadMapAreas(dataDir) {
const areas = new Map();
let files;
try { files = fs.readdirSync(dataDir); } catch { return areas; }
for (const f of files) {
const m = /^Map(\d+)\.json$/.exec(f);
if (!m) continue;
try {
const d = readJson(path.join(dataDir, f));
const bgm = (d.bgm && d.bgm.name) || '';
if (bgm) areas.set(Number(m[1]), AREA_NAMES[bgm] || bgm);
} catch { /* 个别地图文件损坏不影响整体 */ }
}
return areas;
}
/**
* 载入全部静态数据。
* @param {string} baseDir aitemp 目录
*/
function loadAll(baseDir) {
const outDir = path.join(baseDir, 'out');
const dataDir = path.join(baseDir, 'unpacked', 'data');
// ---- 开关 ----
const sys = readJson(path.join(dataDir, 'System.json'));
const switches = [];
const switchNameToId = new Map();
for (let i = 1; i < sys.switches.length; i++) {
const name = sys.switches[i];
if (name === undefined || name === null) continue;
switches.push({ id: i, name: String(name) });
// 同名开关极少见,出现时保留首个
if (!switchNameToId.has(String(name))) switchNameToId.set(String(name), i);
}
// ---- 变量 ----
const variables = [];
for (let i = 1; i < sys.variables.length; i++) {
const name = sys.variables[i];
if (name === undefined || name === null) continue;
variables.push({ id: i, name: String(name) });
}
// ---- 地图 ----
const mapInfos = readJson(path.join(dataDir, 'MapInfos.json'));
const maps = mapInfos
.filter(m => m && m.id)
.map(m => ({ id: m.id, name: String(m.name || ''), parentId: m.parentId || 0 }));
// ---- 区域名 ----
// 台词**不在这里**:它由 server.js 在连上游戏后从 $dataStory 实时补进内存
const mapAreas = loadMapAreas(dataDir);
// ---- 剧情触发点 ----
const csvPath = path.join(outDir, 'triggers.csv');
const raw = parseCsv(fs.readFileSync(csvPath, 'utf8'));
const header = raw[0];
const col = (row, key) => row[header.indexOf(key)] ?? '';
const triggers = [];
for (let i = 1; i < raw.length; i++) {
const r = raw[i];
if (!r || r.length < 2) continue;
// 地图字段形如 "Map004.json MAP004",从中取出数字 ID
const mapField = col(r, '地图');
const mapMatch = /Map0*(\d+)\.json/i.exec(mapField);
const mapId = mapMatch ? Number(mapMatch[1]) : null;
// 坐标字段形如 "(0,0)"
const coordField = col(r, '坐标');
const coordMatch = /\(\s*(\d+)\s*,\s*(\d+)\s*\)/.exec(coordField);
const x = coordMatch ? Number(coordMatch[1]) : null;
const y = coordMatch ? Number(coordMatch[2]) : null;
const condition = col(r, '条件').trim();
// 条件里引用的开关名(形如 开关[第一天夜晚莉莉亚回家](需ON))
const needSwitchNames = [];
for (const m of condition.matchAll(/开关\[([^\]]+)\]/g)) needSwitchNames.push(m[1]);
const needVarNames = [];
for (const m of condition.matchAll(/变量\[([^\]]+)\]/g)) needVarNames.push(m[1]);
const storyId = Number(col(r, '故事序号')) || 0;
triggers.push({
storyId,
source: col(r, '来源'),
mapId,
mapArea: mapId !== null ? (mapAreas.get(mapId) || '') : '',
eventId: col(r, '事件ID'),
eventName: col(r, '事件名'),
x, y,
page: col(r, '页'),
pageIndex: /^\d+\//.test(col(r, '页')) ? Number(col(r, '页').split('/')[0]) - 1 : null,
triggerType: col(r, '触发方式'),
condition,
needSwitchNames,
needVarNames,
// ⚠️ 这里**故意没有** line / speaker / lineCount —— 只有 storyId。
// UI 上那句台词预览由 server.js 在拼响应时从内存里的台词表补上
// (见 /api/triggers),没连游戏时就是空的,前端退回显示地图+坐标。
// 有地图和坐标的才能"一键前往"
actionable: mapId !== null && x !== null && y !== null,
});
}
// 仅合并完全相同的候选;不同事件、页和条件必须保留。
const seen = new Set();
const uniqueTriggers = [];
for (const t of triggers) {
const k = `${t.storyId}|${t.mapId}|${t.eventId}|${t.page}|${t.x}|${t.y}|${t.condition}`;
if (seen.has(k)) continue;
seen.add(k);
uniqueTriggers.push(t);
}
// ---- 常用开关:被触发点条件引用到的那些 ----
// 这是"截图工作真正会用到的开关"的精确集合,用来做开关面板的默认视图
const commonSwitchIds = [];
for (const name of new Set(uniqueTriggers.flatMap(t => t.needSwitchNames))) {
const id = switchNameToId.get(name);
if (id !== undefined) commonSwitchIds.push(id);
}
commonSwitchIds.sort((a, b) => a - b);
return {
switches,
variables,
maps,
triggers: uniqueTriggers,
commonSwitchIds,
switchNameToId,
stats: {
switches: switches.length,
variables: variables.length,
maps: maps.length,
triggersTotal: triggers.length,
triggersUnique: uniqueTriggers.length,
triggersActionable: uniqueTriggers.filter(t => t.actionable).length,
commonSwitches: commonSwitchIds.length,
mapAreas: mapAreas.size,
// 注意:storyLoaded / triggersWithLine **不在这里** ——
// 台词要连上游戏才知道,它们是运行时值,由 server.js 的 /api/status 现算。
},
};
}
module.exports = { loadAll, parseCsv };