发布咸鱼喵喵截图辅助工具公开版

This commit is contained in:
2026-10-03 01:16:35 +08:00
commit 178e1d8714
26 changed files with 5694 additions and 0 deletions
+216
View File
@@ -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 };