发布咸鱼喵喵截图辅助工具公开版
This commit is contained in:
@@ -0,0 +1,216 @@
|
||||
// 静态数据加载
|
||||
//
|
||||
// 数据来源全部是**之前分析阶段已产出的本地文件**(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 };
|
||||
Reference in New Issue
Block a user