发布咸鱼喵喵截图辅助工具公开版
This commit is contained in:
@@ -0,0 +1,97 @@
|
||||
// 单连接 CDP 客户端,要求 Node.js 22+(内置 fetch / WebSocket)。
|
||||
const RECONNECT_DELAY_MS = 3000;
|
||||
class CdpClient {
|
||||
constructor(port = 9223) {
|
||||
this.port = port; this.ws = null; this.sessionId = null;
|
||||
this._id = 0; this._pending = new Map(); this._connecting = null; this.generation = 0;
|
||||
}
|
||||
get base() { return `http://127.0.0.1:${this.port}`; }
|
||||
async isAlive() {
|
||||
try {
|
||||
const r = await fetch(`${this.base}/json/version`, { signal: AbortSignal.timeout(1500) });
|
||||
return r.ok && !!(await r.json()).webSocketDebuggerUrl;
|
||||
} catch { return false; }
|
||||
}
|
||||
connect() {
|
||||
if (this.ws?.readyState === 1 && this.sessionId) return Promise.resolve();
|
||||
if (this._connecting) return this._connecting;
|
||||
const generation = ++this.generation;
|
||||
const attempt = this._connect(generation);
|
||||
this._connecting = attempt;
|
||||
attempt.finally(() => { if (this._connecting === attempt) this._connecting = null; }).catch(() => {});
|
||||
return attempt;
|
||||
}
|
||||
async _connect(generation) {
|
||||
let ws;
|
||||
try {
|
||||
const r = await fetch(`${this.base}/json/version`, { signal: AbortSignal.timeout(3000) });
|
||||
if (!r.ok) throw new Error('调试端口没有返回 CDP 信息');
|
||||
const v = await r.json();
|
||||
if (this.generation !== generation) throw new Error('连接已取消');
|
||||
ws = new WebSocket(v.webSocketDebuggerUrl);
|
||||
this.ws = ws;
|
||||
await new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => { reject(new Error('连接游戏超时')); try { ws.close(); } catch {} }, 4000);
|
||||
const fail = () => { clearTimeout(timer); reject(new Error('无法建立游戏调试连接')); };
|
||||
ws.onopen = () => { clearTimeout(timer); resolve(); };
|
||||
ws.onerror = fail; ws.onclose = fail;
|
||||
});
|
||||
if (this.generation !== generation) throw new Error('连接已取消');
|
||||
ws.onmessage = ev => {
|
||||
let msg; try { msg = JSON.parse(ev.data); } catch { return; }
|
||||
const p = this._pending.get(msg.id);
|
||||
if (p && p.ws === ws) {
|
||||
this._pending.delete(msg.id); clearTimeout(p.timer);
|
||||
if (msg.error) p.reject(new Error(JSON.stringify(msg.error))); else p.resolve(msg.result);
|
||||
}
|
||||
if (msg.method === 'Target.detachedFromTarget' && msg.params?.sessionId === this.sessionId && this.ws === ws) this.close();
|
||||
};
|
||||
ws.onclose = () => this._drop(ws, new Error('游戏连接已断开'));
|
||||
ws.onerror = () => { this._drop(ws, new Error('游戏调试连接出错')); try { ws.close(); } catch {} };
|
||||
const { targetInfos } = await this._send('Target.getTargets', {}, false);
|
||||
const pages = targetInfos.filter(t => t.type === 'page' && /\/index\.html?(?:[?#]|$)/i.test(t.url || ''));
|
||||
if (pages.length !== 1) throw new Error('无法唯一识别游戏主页面,请关闭多余游戏窗口');
|
||||
const { sessionId } = await this._send('Target.attachToTarget', { targetId: pages[0].targetId, flatten: true }, false);
|
||||
if (this.ws !== ws || this.generation !== generation) throw new Error('连接已更换');
|
||||
this.sessionId = sessionId;
|
||||
} catch (e) {
|
||||
if (ws) { this._drop(ws, e); try { ws.close(); } catch {} }
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
_drop(ws, error) {
|
||||
for (const [id, p] of this._pending) if (p.ws === ws) {
|
||||
clearTimeout(p.timer); p.reject(error); this._pending.delete(id);
|
||||
}
|
||||
if (this.ws === ws) { this.ws = null; this.sessionId = null; }
|
||||
}
|
||||
_send(method, params = {}, session = true) {
|
||||
const ws = this.ws;
|
||||
if (!ws || ws.readyState !== 1) return Promise.reject(new Error('游戏连接已断开'));
|
||||
const id = ++this._id, payload = { id, method, params };
|
||||
if (session && this.sessionId) payload.sessionId = this.sessionId;
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => { this._pending.delete(id); reject(new Error(`CDP 调用超时: ${method};不要立即重复操作,请先检查游戏状态`)); }, 20000);
|
||||
this._pending.set(id, { resolve, reject, timer, ws });
|
||||
try { ws.send(JSON.stringify(payload)); }
|
||||
catch (e) { clearTimeout(timer); this._pending.delete(id); reject(e); }
|
||||
});
|
||||
}
|
||||
async eval(expression) {
|
||||
await this.connect();
|
||||
const res = await this._send('Runtime.evaluate', { expression, returnByValue: true, awaitPromise: true });
|
||||
if (res.exceptionDetails) throw new Error(res.exceptionDetails.exception?.description || res.exceptionDetails.text);
|
||||
return res.result?.value;
|
||||
}
|
||||
async evalJson(expression) {
|
||||
const value = await this.eval(expression);
|
||||
return typeof value === 'string' ? JSON.parse(value) : value;
|
||||
}
|
||||
async screenshot() { await this.connect(); return (await this._send('Page.captureScreenshot', { format: 'png' })).data; }
|
||||
close() {
|
||||
++this.generation; this._connecting = null;
|
||||
const ws = this.ws;
|
||||
if (ws) { this._drop(ws, new Error('游戏连接已关闭或切换')); try { ws.close(); } catch {} }
|
||||
}
|
||||
}
|
||||
module.exports = { CdpClient, RECONNECT_DELAY_MS };
|
||||
@@ -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 };
|
||||
@@ -0,0 +1,145 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
const { execFile } = require('child_process');
|
||||
const { canonical, samePath, contains } = require('./safety');
|
||||
const MANIFEST = 'backup-manifest.json';
|
||||
const COMPLETE = '.nyaruru-sandbox-complete.json';
|
||||
const INCOMPLETE = '.nyaruru-sandbox-incomplete';
|
||||
const hash = file => crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
|
||||
const saveNames = dir => fs.existsSync(dir) ? fs.readdirSync(dir).filter(n => /\.rmmzsave$/i.test(n)).sort() : [];
|
||||
function safeFile(dir, name) {
|
||||
if (path.basename(name) !== name || !/\.rmmzsave$/i.test(name)) throw new Error('非法存档文件名');
|
||||
const file = path.join(dir, name), st = fs.lstatSync(file);
|
||||
if (!st.isFile() || st.isSymbolicLink()) throw new Error('存档必须是普通文件:' + name);
|
||||
return file;
|
||||
}
|
||||
function backupSaves(root, sourceDir, label = 'auto') {
|
||||
const saveDir = path.join(canonical(sourceDir), 'save');
|
||||
if (!fs.existsSync(saveDir) || !samePath(saveDir, path.join(sourceDir, 'save')) || fs.lstatSync(saveDir).isSymbolicLink()) throw new Error('存档目录不存在或是目录链接');
|
||||
const names = saveNames(saveDir);
|
||||
if (!names.length) throw new Error('没有找到 .rmmzsave 存档,未创建空备份');
|
||||
fs.mkdirSync(root, { recursive: true });
|
||||
const dest = fs.mkdtempSync(path.join(root, `${label.replace(/[^a-z0-9_-]/gi, '')}_${new Date().toISOString().replace(/[:.]/g, '-')}_`));
|
||||
// 未完成目录没有 manifest,不会被列为可恢复备份。
|
||||
const files = names.map(name => ({ name, sha256: hash(safeFile(saveDir, name)) }));
|
||||
for (const f of files) fs.copyFileSync(safeFile(saveDir, f.name), path.join(dest, f.name), fs.constants.COPYFILE_EXCL);
|
||||
if (JSON.stringify(names) !== JSON.stringify(saveNames(saveDir)) || files.some(f => hash(safeFile(saveDir, f.name)) !== f.sha256 || hash(path.join(dest, f.name)) !== f.sha256)) {
|
||||
throw new Error('备份期间存档发生变化或复制校验失败,请停止存档操作后重试。未完成目录:' + dest);
|
||||
}
|
||||
const manifest = { version: 1, sourceDir: canonical(sourceDir), createdAt: new Date().toISOString(), files };
|
||||
fs.writeFileSync(path.join(dest, MANIFEST), JSON.stringify(manifest, null, 2), { flag: 'wx' });
|
||||
return { dir: dest, name: path.basename(dest), files: files.length, from: saveDir };
|
||||
}
|
||||
function inspectBackup(root, name) {
|
||||
if (typeof name !== 'string' || name === '.' || name === '..' || /[\\/:]/.test(name)) throw new Error('非法备份名');
|
||||
const dir = path.join(root, name);
|
||||
if (!fs.existsSync(dir) || fs.lstatSync(dir).isSymbolicLink() || !fs.statSync(dir).isDirectory()) throw new Error('备份不存在或是目录链接');
|
||||
let manifest = null;
|
||||
const meta = path.join(dir, MANIFEST);
|
||||
if (fs.existsSync(meta)) {
|
||||
manifest = JSON.parse(fs.readFileSync(meta, 'utf8'));
|
||||
if (!Array.isArray(manifest.files) || !manifest.files.length) throw new Error('备份清单无效');
|
||||
} else if (!/^\d{8}_\d{6}$|^[a-z][a-z0-9_-]*_\d{8}_\d{6}$/i.test(name)) {
|
||||
throw new Error('备份没有完成标记');
|
||||
}
|
||||
const files = manifest ? manifest.files : saveNames(dir).map(name => ({ name }));
|
||||
if (!files.length || new Set(files.map(f => f.name)).size !== files.length) throw new Error('备份为空或文件重复');
|
||||
for (const f of files) {
|
||||
const digest = hash(safeFile(dir, f.name));
|
||||
if (f.sha256 && digest !== f.sha256) throw new Error('备份内容校验失败:' + f.name);
|
||||
f.sha256 = digest;
|
||||
}
|
||||
return { dir, manifest, files };
|
||||
}
|
||||
function listBackups(root) {
|
||||
if (!fs.existsSync(root)) return [];
|
||||
return fs.readdirSync(root).flatMap(name => {
|
||||
try {
|
||||
const b = inspectBackup(root, name);
|
||||
return [{ name, files: b.files.length, sourceDir: b.manifest?.sourceDir || null, legacy: !b.manifest, mtime: fs.statSync(b.dir).mtimeMs }];
|
||||
} catch { return []; }
|
||||
}).sort((a, b) => b.mtime - a.mtime);
|
||||
}
|
||||
function restoreBackup(root, name, targetDir) {
|
||||
const b = inspectBackup(root, name);
|
||||
const target = canonical(targetDir), saveDir = path.join(target, 'save');
|
||||
if (fs.existsSync(saveDir) && fs.lstatSync(saveDir).isSymbolicLink()) throw new Error('不能还原到存档目录链接');
|
||||
const safety = saveNames(saveDir).length ? backupSaves(root, target, 'prerestore') : null;
|
||||
const stage = fs.mkdtempSync(path.join(target, '.save-restore-'));
|
||||
// 保留云配置等非存档文件;存档集合严格来自选中的备份。
|
||||
if (fs.existsSync(saveDir)) {
|
||||
for (const entry of fs.readdirSync(saveDir, { withFileTypes: true })) {
|
||||
if (entry.isSymbolicLink() || entry.isDirectory()) throw new Error('存档目录含链接或子目录,停止还原');
|
||||
if (!/\.rmmzsave$/i.test(entry.name)) fs.copyFileSync(path.join(saveDir, entry.name), path.join(stage, entry.name));
|
||||
}
|
||||
}
|
||||
for (const f of b.files) {
|
||||
fs.copyFileSync(path.join(b.dir, f.name), path.join(stage, f.name));
|
||||
if (hash(path.join(stage, f.name)) !== f.sha256) throw new Error('还原暂存校验失败');
|
||||
}
|
||||
const old = path.join(target, `.save-before-restore-${crypto.randomUUID()}`);
|
||||
const hadSave = fs.existsSync(saveDir);
|
||||
if (hadSave) fs.renameSync(saveDir, old);
|
||||
try { fs.renameSync(stage, saveDir); }
|
||||
catch (e) { if (hadSave) fs.renameSync(old, saveDir); throw e; }
|
||||
return { restored: b.files.length, target: saveDir, safetyBackup: safety?.name || null, previousDirectory: hadSave ? old : null };
|
||||
}
|
||||
function excludedDirs(gameDir, toolDir) {
|
||||
return fs.readdirSync(gameDir, { withFileTypes: true }).filter(e => e.isDirectory() && (
|
||||
/^aitemp/i.test(e.name) || samePath(path.join(gameDir, e.name), toolDir) || fs.existsSync(path.join(gameDir, e.name, 'tool', 'server.js'))
|
||||
)).map(e => path.join(gameDir, e.name));
|
||||
}
|
||||
function gameFiles(gameDir, excluded) {
|
||||
const result = [];
|
||||
function walk(dir, prefix = '') {
|
||||
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const file = path.join(dir, e.name), rel = path.join(prefix, e.name);
|
||||
if (!prefix && (['save', 'error', 'debug.log'].includes(e.name.toLowerCase()) || /^aitemp/i.test(e.name) || e.name.startsWith('.save-') || excluded.some(d => samePath(d, file)))) continue;
|
||||
if (e.isSymbolicLink()) throw new Error('游戏目录含链接,无法验证完整副本:' + rel);
|
||||
if (e.isDirectory()) walk(file, rel);
|
||||
else if (e.isFile()) result.push({ name: rel, size: fs.statSync(file).size });
|
||||
}
|
||||
}
|
||||
walk(gameDir); return result;
|
||||
}
|
||||
function sandboxStatus(gameDir, sandboxDir, toolDir, checkingCopy = false) {
|
||||
try {
|
||||
if (contains(gameDir, sandboxDir) || contains(sandboxDir, gameDir)) return { ready: false, reason: '沙盒路径与原版重叠' };
|
||||
if (!fs.existsSync(sandboxDir)) return { ready: false, reason: '尚未创建' };
|
||||
if (!checkingCopy && fs.existsSync(path.join(sandboxDir, INCOMPLETE))) return { ready: false, reason: '上次复制尚未完成,请另选空目录重新创建' };
|
||||
const required = gameFiles(gameDir, excludedDirs(gameDir, toolDir));
|
||||
if (!required.some(f => f.name === 'package.json') || !required.some(f => f.name === 'index.html')) throw new Error('原版游戏目录不完整');
|
||||
const bad = required.find(f => {
|
||||
const dest = path.join(sandboxDir, f.name);
|
||||
return !fs.existsSync(dest) || !fs.statSync(dest).isFile() || fs.statSync(dest).size !== f.size || !contains(sandboxDir, canonical(dest));
|
||||
});
|
||||
if (bad) return { ready: false, reason: '副本缺失或大小不符:' + bad.name };
|
||||
return { ready: true, legacy: !fs.existsSync(path.join(sandboxDir, COMPLETE)), files: required.length };
|
||||
} catch (e) { return { ready: false, reason: e.message }; }
|
||||
}
|
||||
function copySandbox(gameDir, sandboxDir, toolDir) {
|
||||
if (contains(gameDir, sandboxDir) || contains(sandboxDir, gameDir)) throw new Error('沙盒路径与原版重叠');
|
||||
if (fs.existsSync(sandboxDir) && fs.readdirSync(sandboxDir).length) throw new Error('目标目录非空,不能覆盖;请选择新的空目录');
|
||||
const excluded = excludedDirs(gameDir, toolDir);
|
||||
const originalSave = path.join(gameDir, 'save');
|
||||
const saves = saveNames(originalSave).map(name => ({ name, sha256: hash(safeFile(originalSave, name)) }));
|
||||
if (!saves.length) throw new Error('原版没有存档,请先正常开始游戏并保存');
|
||||
fs.mkdirSync(sandboxDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(sandboxDir, INCOMPLETE), new Date().toISOString(), { flag: 'wx' });
|
||||
return new Promise((resolve, reject) => {
|
||||
execFile('robocopy', [gameDir, sandboxDir, '/E', '/XJ', '/NFL', '/NDL', '/NJH', '/NJS', '/NP', '/R:1', '/W:1', '/XF', 'aitemp*', 'debug.log', '/XD', ...excluded, 'error', '.save-*'],
|
||||
{ windowsHide: true, maxBuffer: 32 * 1024 * 1024 }, (err, out, stderr) => {
|
||||
try {
|
||||
if (err && !(Number.isInteger(err.code) && err.code <= 7 && err.code >= 0)) throw new Error(stderr || err.message);
|
||||
const status = sandboxStatus(gameDir, sandboxDir, toolDir, true);
|
||||
if (!status.ready) throw new Error(status.reason);
|
||||
if (JSON.stringify(saveNames(originalSave)) !== JSON.stringify(saves.map(f => f.name)) || saves.some(f => hash(safeFile(originalSave, f.name)) !== f.sha256 || hash(safeFile(path.join(sandboxDir, 'save'), f.name)) !== f.sha256)) throw new Error('复制期间存档发生变化或副本校验失败,请退出游戏后重建');
|
||||
fs.writeFileSync(path.join(sandboxDir, COMPLETE), JSON.stringify({ createdAt: new Date().toISOString(), files: status.files }));
|
||||
fs.unlinkSync(path.join(sandboxDir, INCOMPLETE));
|
||||
resolve(status);
|
||||
} catch (e) { reject(e); }
|
||||
});
|
||||
});
|
||||
}
|
||||
module.exports = { backupSaves, listBackups, restoreBackup, inspectBackup, sandboxStatus, copySandbox, excludedDirs };
|
||||
+1335
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,120 @@
|
||||
// 此函数在游戏上下文中运行。记录留在 window 的非枚举属性中,不进入存档。
|
||||
function installPhotoTools() {
|
||||
if (window.__nyaruruPhoto) { window.__nyaruruPhoto.touch?.(); return window.__nyaruruPhoto; }
|
||||
const records = new Map();
|
||||
let lastTouch = Date.now();
|
||||
const refreshSprite = ev => {
|
||||
const walk = node => {
|
||||
if (!node) return;
|
||||
if (node._character === ev && node.setCharacterBitmap) {
|
||||
node._characterName = ev.bitmapName ? ev.bitmapName() : ev.characterName();
|
||||
node._characterIndex = ev.characterIndex();
|
||||
node._tileId = ev.tileId();
|
||||
if (node._tileId) node.setTileBitmap(); else node.setCharacterBitmap();
|
||||
// 换位图会重置为整张图;角色帧号即使没变,也必须重新裁切。
|
||||
// 图片异步加载完成时还会重置一次,因此在加载回调中刷新游戏自己的帧逻辑。
|
||||
const bitmap = node._contentSprite ? node._contentSprite.bitmap : node.bitmap;
|
||||
const refreshFrame = () => {
|
||||
const current = node._contentSprite ? node._contentSprite.bitmap : node.bitmap;
|
||||
if (node._character !== ev || current !== bitmap) return;
|
||||
node._framePos = -1;
|
||||
if (node.updateFrame) node.updateFrame();
|
||||
};
|
||||
node._framePos = -1;
|
||||
if (bitmap && bitmap.addLoadListener) bitmap.addLoadListener(refreshFrame);
|
||||
else refreshFrame();
|
||||
}
|
||||
for (const child of node.children || []) walk(child);
|
||||
};
|
||||
try { walk(SceneManager._scene); } catch { /* 精灵尚未建立时由下一帧刷新 */ }
|
||||
};
|
||||
const settle = (ev, page) => {
|
||||
const ai = ev.event?.().pages?.[page]?.list?.find(c => c.code === 357 && c.parameters?.[1] === 'ai')?.parameters?.[3];
|
||||
// 只有原页明确要求贴地的角色参与;飞行角色、固定摆件及无近处地面保持原位。
|
||||
if (String(ai?.smoothfloor) !== 'true' || String(ai?.skipSmoothfloor) === 'true' || String(ai?.fix) === 'true' || !ev.hitPreview || !ev.locate) return;
|
||||
const th = $gameMap.tileHeight(), tw = $gameMap.tileWidth();
|
||||
for (let dy = 0; dy <= th; dy++) {
|
||||
const hits = (ev.hitPreview(0, dy) || []).filter(o => o?.terrain === true);
|
||||
if (!hits.length) continue;
|
||||
if (hits.some(o => o.type === 2)) return;
|
||||
const drop = Math.max(0, dy - 1); // 停在地形外侧,不能把碰撞体塞进地板。
|
||||
if (!drop) return;
|
||||
const x = ev.tileXToCenterX ? (ev.px - ev.tileXToCenterX(0)) / tw : (ev._realX ?? ev._x);
|
||||
const y = ev.tileYToCenterY ? (ev.py + drop - ev.tileYToCenterY(0)) / th : (ev._realY ?? ev._y) + drop / th;
|
||||
ev.locate(x, y);
|
||||
ev.clearImpact?.(); ev.clearGrounding?.();
|
||||
ev._groundingX = ev._terrainGroundingX = ev.px;
|
||||
ev._groundingY = ev._terrainGroundingY = ev.py;
|
||||
return;
|
||||
}
|
||||
};
|
||||
const unpin = ev => {
|
||||
const record = records.get(ev);
|
||||
if (!record) return;
|
||||
// 还原被我们覆盖的自有字段和方法,包括原始 _erased、解释器及物理状态。
|
||||
for (const key of Reflect.ownKeys(ev)) if (!Object.hasOwn(record, key)) delete ev[key];
|
||||
Object.defineProperties(ev, record);
|
||||
records.delete(ev);
|
||||
refreshSprite(ev);
|
||||
};
|
||||
const api = {
|
||||
touch: () => { lastTouch = Date.now(); },
|
||||
count: () => records.size,
|
||||
facePlayer(ev) {
|
||||
if (!records.has(ev) || typeof $gamePlayer === 'undefined' || !ev.setDirection) return;
|
||||
const dx = $gamePlayer.px - ev.px;
|
||||
if (!Number.isFinite(dx) || Math.abs(dx) < 1) return;
|
||||
const fixed = ev._directionFix;
|
||||
try { ev._directionFix = false; ev.setDirection(dx < 0 ? 4 : 6); }
|
||||
finally { ev._directionFix = fixed; }
|
||||
},
|
||||
unpinAll() {
|
||||
const n = records.size;
|
||||
for (const ev of [...records.keys()]) unpin(ev);
|
||||
if ($gameMap?.requestRefresh) $gameMap.requestRefresh();
|
||||
return n;
|
||||
},
|
||||
pin(ev, page) {
|
||||
if (records.has(ev)) unpin(ev);
|
||||
const record = Object.getOwnPropertyDescriptors(ev);
|
||||
records.set(ev, record);
|
||||
try {
|
||||
// setupPageSettings 会写 statemMembers,隔离这层避免污染撤销快照。
|
||||
if (ev._statemMembers) ev._statemMembers = { ...ev._statemMembers };
|
||||
ev._erased = false;
|
||||
ev._pageIndex = page;
|
||||
ev.setupPageSettings();
|
||||
settle(ev, page);
|
||||
for (const name of ['start', 'update', 'updateParallel', 'checkEventTriggerAuto', 'checkEventTriggerTouch', 'physicsTriggerTouch', 'refresh']) {
|
||||
Object.defineProperty(ev, name, { value() {}, configurable: true, writable: true });
|
||||
}
|
||||
// 只更新图片帧,不调用 Game_Event.update(其中包含 AI、移动和事件触发)。
|
||||
Object.defineProperty(ev, 'update', { configurable: true, writable: true, value() {
|
||||
if (typeof ImageFrame !== 'undefined' && this.updateFrameAnimation) {
|
||||
this.updateFrameAnimation(ImageFrame.info(this.characterName()));
|
||||
}
|
||||
} });
|
||||
Object.defineProperty(ev, 'findProperPageIndex', { value: () => page, configurable: true, writable: true });
|
||||
ev._starting = false; ev._trigger = null; ev._interpreter = null; ev._moveType = 0;
|
||||
ev.physics = false; ev.fix = true;
|
||||
ev.vx = ev.vy = ev._vx = ev._vy = 0;
|
||||
api.facePlayer(ev);
|
||||
refreshSprite(ev);
|
||||
} catch (e) { unpin(ev); throw e; }
|
||||
},
|
||||
};
|
||||
Object.defineProperty(window, '__nyaruruPhoto', { value: api, configurable: true });
|
||||
// 面板关闭或断线后两分钟撤销摆拍,恢复事件原有行为。
|
||||
if (typeof setInterval === 'function') setInterval(() => {
|
||||
if (records.size && Date.now() - lastTouch > 120000) api.unpinAll();
|
||||
}, 10000);
|
||||
// 不拦截游戏的手动或自动存档;摆拍跨读档的限制由面板提示。
|
||||
// 正常切图或读档会结束摆拍,清除旧地图的记录。
|
||||
for (const [object, name] of [[Game_Map.prototype, 'setup'], [DataManager, 'extractSaveContents']]) {
|
||||
const original = object[name];
|
||||
if (typeof original !== 'function') continue;
|
||||
object[name] = function(...args) { api.unpinAll(); return original.apply(this, args); };
|
||||
}
|
||||
return api;
|
||||
}
|
||||
module.exports = { installPhotoTools };
|
||||
@@ -0,0 +1,11 @@
|
||||
// 与启动器、服务端一致:读取实际游戏及存档目录,不解析页面 URL。
|
||||
const { CdpClient } = require('./cdp');
|
||||
const { OPS } = require('./ops');
|
||||
async function probe(port) {
|
||||
if (!Number.isInteger(port) || port < 1024 || port > 65535) throw new Error('端口无效');
|
||||
const c = new CdpClient(port);
|
||||
try { const r = await c.evalJson(OPS.selfPath()); if (!r.ok || !r.gameDir) throw new Error(r.error || '无法识别游戏'); return r; }
|
||||
finally { c.close(); }
|
||||
}
|
||||
if (require.main === module) probe(Number(process.argv[2])).then(r => console.log(r.gameDir)).catch(e => { console.error(e.message); process.exitCode = 1; });
|
||||
module.exports = { probe };
|
||||
@@ -0,0 +1,9 @@
|
||||
const { execFile } = require('child_process');
|
||||
async function assertGamesStopped() {
|
||||
if (process.platform !== 'win32') throw new Error('当前平台无法核实游戏进程,停止还原');
|
||||
const script = '[Console]::OutputEncoding=[System.Text.Encoding]::UTF8; @(Get-CimInstance Win32_Process -ErrorAction Stop | Where-Object { $_.Name -match "^(nw|Nyaruru).*\\.exe$" } | Select-Object ProcessId,Name) | ConvertTo-Json -Compress';
|
||||
const text = await new Promise((resolve, reject) => execFile('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', script], { windowsHide: true, timeout: 6000, encoding: 'utf8' }, (e, out) => e ? reject(new Error('无法检查游戏进程,停止还原:' + e.message)) : resolve(out)));
|
||||
const rows = text.trim() ? JSON.parse(text) : [];
|
||||
if ((Array.isArray(rows) ? rows : [rows]).length) throw new Error('请先退出游戏及 NW.js 游戏窗口,再还原存档,避免运行中的游戏把旧状态写回');
|
||||
}
|
||||
module.exports = { assertGamesStopped };
|
||||
@@ -0,0 +1,43 @@
|
||||
// 启动器与服务端共用的路径、配置校验。
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
function canonical(input) {
|
||||
if (typeof input !== 'string' || !path.isAbsolute(input)) throw new Error('必须填写绝对路径');
|
||||
let p = path.resolve(input);
|
||||
const tail = [];
|
||||
while (!fs.existsSync(p)) {
|
||||
const parent = path.dirname(p);
|
||||
if (parent === p) throw new Error('目录所在磁盘不存在');
|
||||
tail.unshift(path.basename(p)); p = parent;
|
||||
}
|
||||
return path.join(fs.realpathSync.native(p), ...tail);
|
||||
}
|
||||
function samePath(a, b) { return canonical(a).toLowerCase() === canonical(b).toLowerCase(); }
|
||||
function contains(a, b) {
|
||||
const rel = path.relative(canonical(a).toLowerCase(), canonical(b).toLowerCase());
|
||||
return rel === '' || (!rel.startsWith('..' + path.sep) && rel !== '..' && !path.isAbsolute(rel));
|
||||
}
|
||||
function validateConfig(config, gameDir) {
|
||||
const next = { ...config };
|
||||
for (const key of ['cdpPort', 'webPort']) {
|
||||
const n = Number(next[key]);
|
||||
if (!Number.isInteger(n) || n < 1024 || n > 65535) throw new Error(`${key} 必须是 1024~65535 的整数`);
|
||||
next[key] = n;
|
||||
}
|
||||
if (next.cdpPort === next.webPort) throw new Error('游戏调试端口和控制面板端口不能相同');
|
||||
next.sandboxDir = canonical(String(next.sandboxDir || '').trim());
|
||||
if (contains(gameDir, next.sandboxDir) || contains(next.sandboxDir, gameDir)) {
|
||||
throw new Error('沙盒与原版目录必须完全分开,不能相同,也不能互相包含');
|
||||
}
|
||||
return next;
|
||||
}
|
||||
function readConfig(file, defaults, gameDir) {
|
||||
let patch = {};
|
||||
if (fs.existsSync(file)) {
|
||||
try { patch = JSON.parse(fs.readFileSync(file, 'utf8')); }
|
||||
catch { throw new Error('config.json 损坏,请修正配置后重试;不会静默换用其他目录'); }
|
||||
}
|
||||
return validateConfig({ ...defaults, ...patch }, gameDir);
|
||||
}
|
||||
module.exports = { canonical, samePath, contains, validateConfig, readConfig };
|
||||
@@ -0,0 +1,78 @@
|
||||
// 发布版专用。全文索引只存在于内存,不读写台词文件。
|
||||
class StoryCache {
|
||||
constructor(cdp, ops, triggers, { retryMs = 3000, maxAttempts = 4, now = Date.now } = {}) {
|
||||
Object.assign(this, { cdp, ops, triggers, retryMs, maxAttempts, now });
|
||||
this.revision = 0;
|
||||
this.reset();
|
||||
}
|
||||
key() {
|
||||
return this.cdp.ws?.readyState === 1 && this.cdp.sessionId
|
||||
? `${this.cdp.port}:${this.cdp.generation}:${this.cdp.sessionId}` : null;
|
||||
}
|
||||
reset(key = null) {
|
||||
this.connection = key;
|
||||
this.index = null;
|
||||
this.pending = null;
|
||||
this.attempts = 0;
|
||||
this.nextTryAt = 0;
|
||||
this.error = '';
|
||||
this.at = 0;
|
||||
++this.revision;
|
||||
}
|
||||
sync() {
|
||||
const key = this.key();
|
||||
if (key !== this.connection) this.reset(key);
|
||||
return key;
|
||||
}
|
||||
status() {
|
||||
const key = this.sync();
|
||||
const loaded = !!this.index;
|
||||
return {
|
||||
state: !key ? 'disconnected' : loaded ? 'ready' : this.pending ? 'loading'
|
||||
: this.error ? (this.attempts >= this.maxAttempts ? 'error' : 'retrying') : 'waiting',
|
||||
loaded, revision: this.revision, connection: key, error: this.error,
|
||||
attempts: this.attempts, at: this.at,
|
||||
count: loaded ? Object.keys(this.index).length : 0,
|
||||
triggersWithLine: loaded ? this.triggers.filter(t => this.index[String(t.storyId)]).length : 0,
|
||||
};
|
||||
}
|
||||
get(id) { this.sync(); return this.index?.[String(id)] || null; }
|
||||
async ensure({ force = false } = {}) {
|
||||
let key = this.sync();
|
||||
if (!key) {
|
||||
if (!(await this.cdp.isAlive())) return null;
|
||||
try { await this.cdp.connect(); } catch { this.sync(); return null; }
|
||||
key = this.sync();
|
||||
}
|
||||
if (!key) return null;
|
||||
if (this.pending) return this.pending;
|
||||
if (force) { this.reset(key); }
|
||||
else if (this.index || this.attempts >= this.maxAttempts || this.now() < this.nextTryAt) return this.index;
|
||||
const revision = this.revision;
|
||||
++this.attempts;
|
||||
const task = (async () => {
|
||||
try {
|
||||
const result = await this.cdp.evalJson(this.ops.readStoryIndex());
|
||||
if (this.key() !== key || this.revision !== revision) return null;
|
||||
if (!result?.ok || !result.index || !Object.keys(result.index).length) {
|
||||
throw new Error(result?.error || '故事表尚未就绪或没有可读台词');
|
||||
}
|
||||
this.index = result.index;
|
||||
this.error = '';
|
||||
this.at = this.now();
|
||||
++this.revision;
|
||||
return this.index;
|
||||
} catch (e) {
|
||||
if (this.key() !== key || this.revision !== revision) return null;
|
||||
this.error = String(e.message || e);
|
||||
this.nextTryAt = this.now() + this.retryMs;
|
||||
++this.revision;
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
this.pending = task;
|
||||
try { return await task; }
|
||||
finally { if (this.pending === task) this.pending = null; }
|
||||
}
|
||||
}
|
||||
module.exports = { StoryCache };
|
||||
@@ -0,0 +1,82 @@
|
||||
// 已核对的剧情落脚点。控制事件的位置不一定是角色演出的位置。
|
||||
// Map181 EV002 第 5 页将玩家送到 Map182 (11,11),随后由 EV003 自动执行
|
||||
// 298/299 -> 305。EV003 本身放在 (0,0),仅用于控制剧情。
|
||||
// 此表只有结构坐标,不含台词;发布版可直接共用,无需本地解包数据。
|
||||
function storyPosition(trigger) {
|
||||
const { x, y } = trigger;
|
||||
const entries = [
|
||||
{ map: 182, event: 3, page: 0, stories: [298, 299, 305], from: [0, 0], to: [11, 11] },
|
||||
// 原游戏 Map006 EV017 第 1 页的入场位置。
|
||||
{ map: 9, event: 10, page: 5, stories: [449], from: [0, 0], to: [11, 9] },
|
||||
// 原游戏 Map301 EV005 第 1 页的村落入口。
|
||||
{ map: 296, event: 34, page: 5, stories: [449], from: [4, 0], to: [28, 13] },
|
||||
// 原游戏 Map004 EV005 第 5 页的梦境入场位置。
|
||||
{ map: 254, event: 2, page: 0, stories: [467], from: [0, 0], to: [6, 5] },
|
||||
];
|
||||
const entry = entries.find(e => Number(trigger.mapId) === e.map && Number(trigger.eventId) === e.event &&
|
||||
trigger.pageIndex !== null && trigger.pageIndex !== undefined && Number(trigger.pageIndex) === e.page &&
|
||||
e.stories.includes(Number(trigger.storyId)) &&
|
||||
x !== null && y !== null && x !== '' && y !== '' && Number(x) === e.from[0] && Number(y) === e.from[1]);
|
||||
if (entry) {
|
||||
return { x: entry.to[0], y: entry.to[1], positionNote: `使用原游戏入场位置;剧情控制事件位于 (${entry.from.join(',')})` };
|
||||
}
|
||||
return { x, y };
|
||||
}
|
||||
// 在游戏运行时调用;只读取当前地图与碰撞,不加载/导出台词或解包文件。
|
||||
function chooseStoryPosition({ map, player, data, eventId, pageIndex, storyId, sameMap, previous, preferred, requested }) {
|
||||
const event = data.events?.[eventId];
|
||||
const containsStory = page => page.list?.some(c => c.code === 357 && c.parameters?.[1] === 'call_story' && Number(c.parameters[3]?.id) === Number(storyId));
|
||||
const page = event?.pages?.[pageIndex] || event?.pages?.find(containsStory);
|
||||
const hasBody = pg => !!(pg?.image?.characterName || pg?.image?.tileId || pg?.list?.some(c => c.code === 357 && c.parameters?.[1] === 'ai' && Number(c.parameters[3]?.battleObject) > 0));
|
||||
if (!page || ![3, 4].includes(page.trigger) || hasBody(page)) return { ok: true, ...requested, controller: false };
|
||||
if (!preferred && (requested.x !== event.x || requested.y !== event.y)) preferred = requested;
|
||||
|
||||
const tw = map.tileWidth(), th = map.tileHeight(), step = Math.max(8, Math.floor(th / 4));
|
||||
let probes = 0;
|
||||
const floor = (x, y, depth) => {
|
||||
x = Math.round(x); y = Math.round(y);
|
||||
if (!Number.isFinite(x) || !Number.isFinite(y) || x < 0 || y < 0 || x >= map.width() || y >= map.height()) return null;
|
||||
for (let dy = 0; dy <= Math.min(depth * th, (map.height() - y) * th); dy += step) {
|
||||
if (++probes > 800) return null;
|
||||
const hits = (player.hitPreview((x - player._x) * tw, (y - player._y) * th + dy) || []).filter(o => o?.terrain === true);
|
||||
if (!hits.length) continue;
|
||||
if (hits.some(o => o.type === 2)) return null;
|
||||
return { x, y: y + Math.max(0, Math.floor((dy - step) / th)) };
|
||||
}
|
||||
return null;
|
||||
};
|
||||
// 同图已站在正常场景里时,不因点击控制事件而把人重新送到角落。
|
||||
let pick = sameMap && previous ? floor(previous.x, previous.y, 1) : null;
|
||||
let reason = pick ? '保留当前可落地位置' : '';
|
||||
if (!pick && preferred) { pick = floor(preferred.x, preferred.y, 2); if (pick) reason = '使用已核实的入场位置'; }
|
||||
const events = (map.events() || []).filter(e => e && e.eventId() !== Number(eventId));
|
||||
const refs = new Set((page.list || []).filter(c => [203, 205, 213].includes(c.code)).map(c => Number(c.parameters?.[0])).filter(id => id > 0));
|
||||
const ordered = [
|
||||
...events.filter(e => refs.has(e.eventId())),
|
||||
...events.filter(e => e.characterName?.()),
|
||||
...events.filter(e => e.event()?.pages?.some(hasBody)),
|
||||
];
|
||||
const seen = new Set();
|
||||
for (const ev of ordered) {
|
||||
if (pick || probes >= 600) break;
|
||||
if (seen.has(ev)) continue;
|
||||
seen.add(ev);
|
||||
for (const dx of [-1, 1, -2, 2]) {
|
||||
pick = floor(ev._x + dx, Math.max(0, ev._y - 1), 4);
|
||||
if (pick) { reason = '按场景人物附近的安全地面定位'; break; }
|
||||
}
|
||||
}
|
||||
// 场景人物不可用时,仅在地图内部做有上限的地形扫描,不猜测原作站位。
|
||||
if (!pick) {
|
||||
const xs = Array.from({ length: Math.min(map.width(), 31) }, (_, i) => Math.round((i + 0.5) * map.width() / Math.min(map.width(), 31)))
|
||||
.sort((a, b) => Math.abs(a - map.width() / 2) - Math.abs(b - map.width() / 2));
|
||||
for (const x of xs) {
|
||||
if (probes >= 800) break;
|
||||
pick = floor(x, 0, map.height());
|
||||
if (pick) { reason = '按当前地图的安全地面定位'; break; }
|
||||
}
|
||||
}
|
||||
if (!pick) return { ok: false, controller: true, error: '这是无形剧情控制事件,未找到可确认的安全落脚点;已保留悬浮,请手动定位后再运行过场' };
|
||||
return { ok: true, ...pick, controller: true, positionNote: reason };
|
||||
}
|
||||
module.exports = { storyPosition, chooseStoryPosition };
|
||||
@@ -0,0 +1,148 @@
|
||||
// Injected into the game. Uses NYA's actor inventory/battle values, not RPG Maker HP/gold.
|
||||
function installTrainer() {
|
||||
if (window.__nyaruruTrainer) return window.__nyaruruTrainer;
|
||||
const fields = { coin: '_coin', hp: '_hp', sp: 'sp', zp: 'zp' };
|
||||
const locks = new Map();
|
||||
const ids = new WeakMap();
|
||||
let serial = 0, touched = Date.now(), actors = $gameActors, party = $gameParty;
|
||||
const id = o => { if (!ids.has(o)) ids.set(o, ++serial); return ids.get(o); };
|
||||
const active = () => {
|
||||
const actor = $gamePlayer.actor(), battle = $gamePlayer.battleObject();
|
||||
if (!actor || !battle) throw new Error('当前角色数据尚未就绪');
|
||||
return { actor, battle, subject: `${id(actor)}:${id(battle)}:${!!$gamePlayer.isLilyMode()}` };
|
||||
};
|
||||
const maximum = (field, b) => field === 'coin' ? 99999 : Math.max(0, Math.floor(b['m' + field]));
|
||||
const integer = (value, max) => {
|
||||
if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 0 || value > max) {
|
||||
throw new Error(`请输入 0~${max} 的整数`);
|
||||
}
|
||||
return value;
|
||||
};
|
||||
// Accessor intercepts the actual storage slot, so even lethal damage cannot reach zero
|
||||
// before a later polling refill. Descriptor flags and the current value survive unlock.
|
||||
function detach(lock) {
|
||||
if (!lock.object) return;
|
||||
const value = lock.value();
|
||||
Object.defineProperty(lock.object, fields[lock.field], { ...lock.descriptor, value });
|
||||
lock.object = null;
|
||||
}
|
||||
function clear() {
|
||||
for (const lock of locks.values()) detach(lock);
|
||||
locks.clear();
|
||||
}
|
||||
function sync() {
|
||||
if (actors !== $gameActors || party !== $gameParty || Date.now() - touched > 120000) {
|
||||
clear(); actors = $gameActors; party = $gameParty;
|
||||
}
|
||||
if (!locks.size) return;
|
||||
const { actor, battle } = active();
|
||||
for (const lock of locks.values()) {
|
||||
const object = lock.field === 'coin' ? actor : battle;
|
||||
if (lock.object === object) continue;
|
||||
detach(lock);
|
||||
const key = fields[lock.field], descriptor = Object.getOwnPropertyDescriptor(object, key);
|
||||
if (!descriptor?.configurable || !descriptor.writable || !('value' in descriptor)) {
|
||||
throw new Error('当前游戏的数值结构不支持锁定');
|
||||
}
|
||||
lock.object = object; lock.descriptor = descriptor;
|
||||
lock.value = () => Math.min(lock.target, maximum(lock.field, battle));
|
||||
Object.defineProperty(object, key, {
|
||||
configurable: true, enumerable: descriptor.enumerable,
|
||||
get: () => lock.value(), set: () => {},
|
||||
});
|
||||
}
|
||||
}
|
||||
function touch() { sync(); touched = Date.now(); }
|
||||
// Only one timer per game page; neither hooks nor lock settings enter save data.
|
||||
setInterval(() => { try { sync(); } catch { clear(); } }, 100);
|
||||
|
||||
const categories = ['consumables', 'weapons', 'stones', 'ornaments'];
|
||||
const specials = {
|
||||
joycoin: ['joyCoinData', 'joyCoinNum', '_joyCoin'],
|
||||
hpst: ['hpStrengthData', 'hpStrengthNum', '_hpStrengthItem'],
|
||||
skillst: ['skillStrengthData', 'skillStrengthNum', '_skillStrengthItem'],
|
||||
};
|
||||
const localized = key => String(TextManager[key] ?? key ?? '');
|
||||
function read() {
|
||||
touch();
|
||||
const { actor, battle, subject } = active();
|
||||
const stats = Object.fromEntries(Object.keys(fields).map(field => [field, {
|
||||
value: field === 'coin' ? actor.coin() : battle[field],
|
||||
max: maximum(field, battle), locked: locks.has(field), target: locks.get(field)?.target ?? null,
|
||||
}]));
|
||||
const items = [];
|
||||
for (const type of categories) {
|
||||
const container = actor['_' + type];
|
||||
for (const [key, data] of Object.entries(container.data())) {
|
||||
const itemId = Number(key);
|
||||
if (!Number.isSafeInteger(itemId) || itemId < 0 || !data) continue;
|
||||
items.push({ type, id: itemId, name: localized(data.name), desc: localized(data.desc),
|
||||
count: type === 'consumables' ? container.itemNum(itemId) : Number(container.isUnlock(itemId)),
|
||||
max: type === 'consumables' ? container.maxNum(itemId) : 1,
|
||||
equipped: type === 'weapons' ? container.currentWeapon() === itemId : !!container.isEquip?.(itemId),
|
||||
});
|
||||
}
|
||||
}
|
||||
for (const [type, [dataMethod, countMethod]] of Object.entries(specials)) {
|
||||
const data = actor[dataMethod]();
|
||||
items.push({ type, id: 0, name: localized(data.name), desc: localized(data.desc), count: actor[countMethod](), max: 99999, equipped: false });
|
||||
}
|
||||
return { ok: true, subject, role: $gamePlayer.isLilyMode() ? '莉莉娅' : actor.name(), stats, items };
|
||||
}
|
||||
function change(body) {
|
||||
touch();
|
||||
const { actor, battle, subject } = active();
|
||||
if (body.subject !== subject) throw new Error('操作角色已变化,请刷新角色与背包后重试');
|
||||
if (body.action === 'stat') {
|
||||
const field = body.field;
|
||||
if (!Object.prototype.hasOwnProperty.call(fields, field)) throw new Error('未知数值');
|
||||
if (body.locked !== undefined && typeof body.locked !== 'boolean') throw new Error('锁定参数无效');
|
||||
if (body.locked === false) {
|
||||
const old = locks.get(field);
|
||||
if (old) { detach(old); locks.delete(field); }
|
||||
return read();
|
||||
}
|
||||
const value = integer(body.value, maximum(field, battle));
|
||||
if (field === 'hp' && battle.hp === 0 && value > 0) throw new Error('角色已倒下,请先在游戏里复活或读档,再修改血量');
|
||||
const old = locks.get(field), keepLocked = body.locked === true || !!old;
|
||||
if (old) { detach(old); locks.delete(field); }
|
||||
if (field === 'coin') {
|
||||
const delta = value - actor.coin();
|
||||
if (delta >= 0) actor.addCoin(delta); else actor.deductCoin(-delta);
|
||||
} else battle['add' + field[0].toUpperCase() + field.slice(1)](value - battle[field]);
|
||||
if (keepLocked) {
|
||||
locks.set(field, { field, target: value, object: null });
|
||||
try { sync(); } catch (e) { clear(); throw e; }
|
||||
}
|
||||
} else if (body.action === 'item') {
|
||||
const { type } = body;
|
||||
const itemId = integer(body.id, 99999);
|
||||
if (Object.prototype.hasOwnProperty.call(specials, type)) {
|
||||
if (itemId !== 0) throw new Error('物品编号无效');
|
||||
actor[specials[type][2]] = integer(body.count, 99999);
|
||||
} else {
|
||||
if (!categories.includes(type)) throw new Error('物品分类无效');
|
||||
const container = actor['_' + type], data = container.data();
|
||||
if (!Object.prototype.hasOwnProperty.call(data, itemId) || !data[itemId]) throw new Error('物品不存在');
|
||||
const max = type === 'consumables' ? (body.overCap === true ? 99999 : container.maxNum(itemId)) : 1;
|
||||
const count = integer(body.count, max);
|
||||
if (type === 'consumables') container.add(itemId, count - container.itemNum(itemId), body.overCap === true);
|
||||
else if (count) container.unlock(itemId);
|
||||
else if (type === 'weapons') {
|
||||
const remaining = container._unlock.filter(n => n !== itemId);
|
||||
if (!remaining.length) throw new Error('至少需要保留一件武器,避免游戏攻击时找不到武器');
|
||||
if (container.currentWeapon() === itemId) { actor.changeWeapon(remaining[0]); $gamePlayer.setWeaponChange(); }
|
||||
container._unlock = remaining;
|
||||
} else if (type === 'ornaments') container.remove(itemId);
|
||||
else {
|
||||
if (container.isEquip(itemId)) container.equip(itemId);
|
||||
container._unlock = container._unlock.filter(n => n !== itemId);
|
||||
}
|
||||
}
|
||||
} else throw new Error('未知修改操作');
|
||||
return read();
|
||||
}
|
||||
return window.__nyaruruTrainer = { read, change, touch, clear };
|
||||
}
|
||||
|
||||
module.exports = { installTrainer };
|
||||
Reference in New Issue
Block a user