Files

1427 lines
72 KiB
JavaScript
Raw Permalink 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.
const { installPhotoTools } = require('./photo-state');
const { chooseStoryPosition } = require('./story-position');
const { installTrainer } = require('./trainer');
const { installMotion } = require('./motion');
const { readEncyclopedia } = require('./encyclopedia');
const { readWorldMap } = require('./world-map');
const { readItemIcons } = require('./item-icons');
const { installCombat } = require('./combat');
const { catalogCellBounds,findCatalogLanding,planCatalogLanding } = require('./catalog-position');
const CATALOG = `const catalogCellBounds=(${catalogCellBounds.toString()}),findCatalogLanding=(${findCatalogLanding.toString()}),planCatalogLanding=(${planCatalogLanding.toString()});`;
const PHOTO = `const photo = (${installPhotoTools.toString()})();`;
// 游戏操作脚本库
//
// 这里每个函数都返回一段「将被送进游戏页面执行的 JavaScript 源码」。
// 之所以存成字符串而非直接执行,是因为这些代码必须在游戏进程里跑,
// 才能访问 $gamePlayer / $gameMap 这些游戏全局对象。
//
// ⚠️ 关键设计约束(当前规则见 tool/开发文档.md):
// 1. 传送必须用 $gamePlayer.locate(x, y),不能手写 _x/_y/_realX/_realY
// —— 后者会漏掉物理引擎的 px/py,导致"画面过去了、碰撞体还在原地"
// 2. 游戏有 4 套坐标系,locate() 内部会一次同步全部
// 3. 无敌用 lockInvincible()(常驻)而非 makeInvincible()(会自己失效)
// 4. 读档由用户在游戏正常菜单完成,不能裸调 loadGame(还需重建物理网格)
/**
* 包一层 IIFE,统一处理:游戏未进地图的守卫、异常捕获、JSON 返回格式。
*
* **用 async IIFE**,因为跨地图传送需要 `await` 等地图加载完
* (`reserveTransfer` 是异步的,下一个 update 才真正切图)。
* CDP 那边开了 `awaitPromise: true`,所以返回 Promise 会被自动等待。
* 同步的操作写成 async 也无妨,只是多包一层 Promise。
*/
function wrap(body, { needMap = true, allowMenu = false } = {}) {
const guard = needMap ? `
if (typeof $gamePlayer === 'undefined' || !$gamePlayer ||
typeof $gameMap === 'undefined' || !$gameMap || $gameMap.mapId() === 0 ||
(typeof SceneManager !== 'undefined' && !${JSON.stringify(allowMenu ? ['Scene_Map', 'Scene_Menu', 'Scene_Shop'] : ['Scene_Map'])}.includes(SceneManager._scene?.constructor.name))) {
return JSON.stringify({ ok: false, error: typeof SceneManager !== 'undefined' && ['Scene_Menu','Scene_Shop'].includes(SceneManager._scene?.constructor.name)
? '请先关闭游戏菜单,回到地图后再执行移动或剧情操作。'
: '游戏还没进入地图。请先在游戏里读档或开始游戏。' });
}
` : '';
return `(async function(){ try { ${guard} ${body} } catch (e) { return JSON.stringify({ ok:false, error: String(e && e.message || e) }); } })()`;
}
/** 游戏里可用的「等一会儿」 */
const SLEEP = `const sleep = (ms) => new Promise(r => setTimeout(r, ms));`;
/**
* 防"坠落复位"的公共代码片段。
*
* 玩家和**莉莉娅是两个独立对象**,任何位置/物理改动都要两边都做 ——
* 只处理玩家的话,同伴会掉进坑里、被坠落复位反复弹回,
* 并且一直播 player_fall 音效(用户实测反馈过)。
* 机制见开发文档第 6 节。
*/
const SAFE_CHAR = `
const motion = (${installMotion.toString()})();
// 清残留碰撞 + 重置落地锚点,否则 isFallDown() 会一直为真
const safeChar = (c) => {
if (!c) return;
try {
c.clearImpact && c.clearImpact();
c._impact = null;
c.clearGrounding && c.clearGrounding();
c._groundingX = c._terrainGroundingX = c.px;
c._groundingY = c._terrainGroundingY = c.py;
c.vx = 0; c.vy = 0; c._vx = 0; c._vy = 0;
c.dotX = 0; c.dotY = 0;
} catch (e) {}
};
// 让角色进入悬浮状态(不接触地形,自然不会触发坠落判定)
const floatChar = (c) => {
if (!c) return;
safeChar(c);
motion.enable(c, true);
};
// 莉莉娅在不在(她被移除时不该动她)
const lilyAny = () => typeof $gameLily !== 'undefined' ? $gameLily : null;
const lilyHere = () => {
try {
return (typeof $gameLily !== 'undefined' && $gameLily
&& !(($gameLily.isRemove && $gameLily.isRemove()))) ? $gameLily : null;
} catch (e) { return null; }
};
`;
const OPS = {
// 发布版独有:从运行时对象表建立全文内存索引,不写文件。
readStoryIndex() {
return wrap(`
if (typeof $dataStory === 'undefined' || !$dataStory) {
return JSON.stringify({ ok: false, error: '$dataStory 不存在(游戏还没启动完?)' });
}
const out = {};
for (const id of Object.keys($dataStory)) {
if (!/^\\d+$/.test(id)) continue;
const seq = $dataStory[id];
if (!Array.isArray(seq) || !seq.length) continue;
const lines = [];
for (const c of seq) {
if (!c || c.name !== 'story') continue;
const text = String(c.contents === undefined || c.contents === null ? '' : c.contents).trim();
if (!text) continue;
if (text.startsWith('#') || text.startsWith('///')) continue; // 指令 / 备注,不是台词
let speaker = '';
if (c.speaker !== undefined && c.speaker !== null && c.speaker !== '') {
const a = (typeof $dataActors !== 'undefined' && $dataActors) ? $dataActors[c.speaker] : null;
speaker = (a && a.name) ? String(a.name).trim() : '';
}
lines.push({ text: text, speaker: speaker });
}
if (!lines.length) continue;
out[id] = { first: lines[0].text, speaker: lines[0].speaker, count: lines.length, lines,
searchText: lines.map(l => l.text + ' ' + l.speaker).join('\\n') };
}
if (!Object.keys(out).length) return JSON.stringify({ ok: false, error: '故事表尚未就绪或没有可读台词' });
return JSON.stringify({ ok: true, index: out, count: Object.keys(out).length });
`, { needMap: false });
},
worldMapTravel(mapId,cellX,cellY,page){
if(!Number.isInteger(mapId)||mapId<1||![cellX,cellY,page].every(Number.isInteger))throw Error('地图分块坐标无效');
return this.teleport(0,0,{mapId,catalog:{cellX,cellY,page}});
},
worldMap(){
return wrap(`const trainer=(${installTrainer.toString()})();
const encyclopedia=(${readEncyclopedia.toString()})(trainer.read());
return JSON.stringify((${readWorldMap.toString()})(encyclopedia));`,{allowMenu:true});
},
catalogTravel(mapId,eventId){
if(!Number.isInteger(mapId)||mapId<1||!Number.isInteger(eventId)||eventId<1)throw Error('收集地点无效');
return this.teleport(0,0,{mapId,catalog:{eventId}});
},
combat(body=null){return wrap(`const api=(${installCombat.toString()})();return JSON.stringify(${body?`api.change(${JSON.stringify(body)})`:'api.read()'});`,{allowMenu:true});},
itemIcons() {
return wrap(`const trainer = (${installTrainer.toString()})();
return JSON.stringify(await (${readItemIcons.toString()})(trainer.read().items));`, { allowMenu: true });
},
encyclopedia() {
return wrap(`const trainer = (${installTrainer.toString()})();
return JSON.stringify((${readEncyclopedia.toString()})(trainer.read()));`, { allowMenu: true });
},
trainer(body = null) {
return wrap(`const trainer = (${installTrainer.toString()})();
return JSON.stringify(${body ? `trainer.change(${JSON.stringify(body)})` : 'trainer.read()'});`, { allowMenu: true });
},
/** 读取当前游戏状态(供控制面板顶栏显示) */
snapshot() {
return wrap(`
if (window.__nyaruruMotion && window.__nyaruruMotion.version !== 5) (${installMotion.toString()})();
window.__nyaruruMotion?.touch();
window.__nyaruruCombat?.touch();
if (window.__nyaruruPhoto?.touch) window.__nyaruruPhoto.touch();
if (window.__nyaruruTrainer) window.__nyaruruTrainer.touch();
const p = $gamePlayer, m = $gameMap;
const mi = $dataMapInfos && $dataMapInfos[m.mapId()];
let bo = null;
try { bo = p.battleObject(); } catch (e) {}
return JSON.stringify({
ok: true,
scene: SceneManager._scene && SceneManager._scene.constructor.name,
mapId: m.mapId(),
mapName: mi ? mi.name : ('地图 ' + m.mapId()),
mapSize: [m.width(), m.height()],
x: p.tileXToCenterX ? Math.round((p.px - p.tileXToCenterX(0)) / m.tileWidth() * 10000) / 10000 : p._x,
y: p.tileYToCenterY ? Math.round((p.py - p.tileYToCenterY(0)) / m.tileHeight() * 10000) / 10000 : p._y,
realX: p._realX,
realY: p._realY,
hp: bo ? bo.hp : null,
gravitySuspended: !!window.__nyaruruMotion?.has(p),
invincible: !!p.isInvincible(),
noclip: !!p._suspendPhysicsHitTest,
// 莉莉娅在不在画面里(通关后她会一直跟着,可以用开关藏起来)
lilyVisible: (typeof $gameLily !== 'undefined' && $gameLily && $gameLily.isRemove)
? !$gameLily.isRemove() : null,
tileSize: [m.tileWidth(), m.tileHeight()],
realScale: Graphics._realScale,
canvasSize: [Graphics.width, Graphics.height],
stagedActors: window.__nyaruruPhoto ? window.__nyaruruPhoto.count() : 0,
uiHidden: SceneManager._scene.__nyaruruUiHidden || {message:false, artwork:false, hud:false},
switchesOn: (function(){ let n=0; for (let i=1;i<$dataSystem.switches.length;i++) if ($gameSwitches.value(i)) n++; return n; })(),
});
`, { allowMenu: true });
},
/** 实机 NW.js 用 chrome-extension URL;身份以 Node 入口和实际存档目录为准。 */
selfPath() {
return wrap(`
window.__nyaruruMotion?.touch();
window.__nyaruruCombat?.touch();
if (window.__nyaruruTrainer) window.__nyaruruTrainer.touch();
if (!window.__nyaruruIdentity) Object.defineProperty(window, '__nyaruruIdentity', {
value: { maps: new WeakMap(), next: 0, epoch: Date.now().toString(36) + Math.random().toString(36).slice(2) }
});
const state = window.__nyaruruIdentity;
const player = typeof $gamePlayer !== 'undefined' && $gamePlayer;
if (player && !state.maps.has(player)) state.maps.set(player, ++state.next);
const mapData = typeof $dataMap !== 'undefined' && $dataMap;
if (mapData && !state.maps.has(mapData)) state.maps.set(mapData, ++state.next);
const mapId = typeof $gameMap !== 'undefined' && $gameMap ? $gameMap.mapId() : 0;
const gameDir = require('path').dirname(process.mainModule.filename);
const saveDir = typeof StorageManager !== 'undefined' ? StorageManager.fileDirectoryPath() : '';
return JSON.stringify({ ok: true, gameDir, saveDir, href: String(location.href), pathname: String(location.pathname),
mapToken: state.epoch + ':' + (player ? state.maps.get(player) : 0) + ':' + mapId + ':' + (mapData ? state.maps.get(mapData) : 0),
mapId, inMap: typeof SceneManager !== 'undefined' && SceneManager._scene?.constructor.name === 'Scene_Map',
inMenu: mapId > 0 && ['Scene_Menu','Scene_Shop'].includes(SceneManager._scene?.constructor.name) });
`, { needMap: false });
},
/**
* 传送到指定格。
*
* ⚠️ 这个函数处理了游戏物理坐标同步问题,改动前先读 `tool/开发文档.md` 的「功能模块与边界」。
*
* 游戏的"坠落复位"机制(nya_statem.js:5986):
* isFallDown() 在玩家碰到 type===2 的地形(坑、虚空)时返回 true,
* 然后 updateFallDown() 会播 player_fall 音效并调 revertToLastGroundingPos(),
* 把人弹回 `_terrainGroundingX/Y`(最后一次站在实地上位置)。
*
* 两个连带问题:
* 1. 传送到坑里 → 立刻触发坠落复位,听起来像"掉出地图被弹回来"
* 2. 如果是传送过来的、在本图从没真正落地过,`_terrainGroundingX/Y`
* 还是**上一张地图的陈值** → 人被扔到画面外,表现为"人物消失"
*
* 所以传送必须做三件事(缺一不可):
* a. 清掉残留碰撞信息,否则 isFallDown() 会一直为 true 而反复触发
* (穿墙状态下碰撞检测整个跳过,_impact 永远不会被自动清掉)
* b. 把两个"最后落地位置"锚点重置到目标点,让万一触发的复位落在正确位置
* c. 默认进入"悬浮安全模式"(暂停重力 + 穿墙),从根上不让角色接触地形
*
* 用户想真正站到地上走动时,调用 land()。
*/
teleport(x, y, { float = true, mapId = null, story = null, catalog = null } = {}) {
if (x === null || y === null || x === '' || y === '' || !Number.isFinite(Number(x)) || !Number.isFinite(Number(y))) throw new Error('坐标必须是有限数值');
if (mapId !== null && (!Number.isInteger(Number(mapId)) || Number(mapId) < 1)) throw new Error('地图编号无效');
return wrap(`
${SLEEP}
${catalog ? CATALOG : ''}
const p = $gamePlayer, m = $gameMap;
const targetMap = ${mapId !== null && mapId !== undefined ? Number(mapId) : 'null'};
const sameMap = (targetMap === null || targetMap === m.mapId());
const fromMap = m.mapId();
const previous = { x: p._x, y: p._y };
const priorPhysical={px:p.px,py:p.py};
let catalogPlacement = ${catalog ? `planCatalogLanding(targetMap,${JSON.stringify(catalog.eventId ?? catalog)})` : 'null'};
const destination=catalogPlacement||{x:${Number(x)},y:${Number(y)}};
const tx = catalogPlacement?destination.x:Math.max(0, Math.min(m.width() - 1, Math.round(destination.x)));
const ty = catalogPlacement?destination.y:Math.max(0, Math.min(m.height() - 1, Math.round(destination.y)));
if (!sameMap) {
if (typeof $dataMapInfos === 'undefined' || !$dataMapInfos[targetMap]) return JSON.stringify({ok:false,error:'目标地图不存在'});
if (window.__nyaruruPhoto) window.__nyaruruPhoto.unpinAll();
// 跨地图:必须走游戏自己的 reserveTransfer,它会触发
// Game_Map.setup() / completeLoad() 把新地图的事件、
// 地形、物理网格全部重建好。直接改 mapId 是行不通的。
//
// ⚠️ 传送前必须关掉对话窗口。链路是这样的:
// updateTransferPlayer() → SceneManager.goto(Scene_Map)
// 而 goto 只在场景"不忙"时生效,Scene_Map.isBusy() 在
// 消息窗口未关闭时为 true。
// 实测:残留的对话窗口会让跨地图传送一直卡到超时。
let closedMessage = false;
try {
closedMessage = $gameMessage.hasText ? $gameMessage.hasText() : false;
if (closedMessage) $gameMessage.clear();
} catch (e) {}
p.reserveTransfer(targetMap, tx, ty, 2, 0); // 2=朝下, 0=黑屏渐变
const t0 = Date.now();
while (Date.now() - t0 < 12000) {
await sleep(100);
if ($gameMap !== m || $gamePlayer !== p) break;
if (m.mapId() === targetMap && !p.isTransferring() && SceneManager._scene?.constructor.name === 'Scene_Map') break;
}
if ($gameMap !== m || $gamePlayer !== p || m.mapId() !== targetMap || p.isTransferring() || SceneManager._scene?.constructor.name !== 'Scene_Map') {
if (p.clearTransferInfo) p.clearTransferInfo();
return JSON.stringify({
ok: false,
error: '跨地图传送超时:目标地图 ' + targetMap + ' 没能加载(当前仍在 ' + m.mapId() + ')。'
+ '常见原因是场景被占用(对话窗口、菜单、演出),先在游戏里结束当前操作再试。',
});
}
// 换图后坐标可能被目标地图的出生点逻辑改过,再定一次位
if(!catalogPlacement){
const nx = Math.max(0, Math.min(m.width() - 1, Math.round(destination.x)));
const ny = Math.max(0, Math.min(m.height() - 1, Math.round(destination.y)));
p.locate(nx, ny);
}
} else {
if(!catalogPlacement)p.locate(tx, ty);
}
${catalog ? `
try{catalogPlacement=planCatalogLanding(targetMap,${JSON.stringify(catalog.eventId ?? catalog)},true);}
catch(error){
// A live gate/actor can differ from the cached terrain preview.
// Return to the starting map/position instead of forcing a landing.
if(!sameMap){
p.reserveTransfer(fromMap,previous.x,previous.y,2,0);
const rollbackStart=Date.now();
while(Date.now()-rollbackStart<5000){await sleep(100);if($gameMap!==m||$gamePlayer!==p)break;if(m.mapId()===fromMap&&!p.isTransferring()&&SceneManager._scene?.constructor.name==='Scene_Map')break;}
if($gameMap!==m||$gamePlayer!==p||m.mapId()!==fromMap||p.isTransferring())throw Error('目标分块未找到落点,返回原地图超时或游戏状态已变化,请检查游戏状态');
const bx=(priorPhysical.px-p.tileXToCenterX(0))/m.tileWidth(),by=(priorPhysical.py-p.tileYToCenterY(0))/m.tileHeight();
Game_CharacterBase.prototype.locate.call(p,bx,by);p.unflush(bx,by);
}
throw Error('目标分块暂时被地形或角色占用,已保留或返回原位置:'+error.message);
}
Game_CharacterBase.prototype.locate.call(p,catalogPlacement.x,catalogPlacement.y);
p.unflush(catalogPlacement.x,catalogPlacement.y);
` : ''}
let storyPlacement = null;
const storyRequest = ${JSON.stringify(story ? { eventId: Number(story.eventId), pageIndex: story.pageIndex == null ? null : Number(story.pageIndex), storyId: Number(story.storyId) } : null)};
if (storyRequest) {
storyPlacement = (${chooseStoryPosition.toString()})({ ...storyRequest, map: m, player: p, data: $dataMap,
sameMap, previous, requested: { x: p._x, y: p._y } });
if (storyPlacement.ok) p.locate(storyPlacement.x, storyPlacement.y);
else if (sameMap) p.locate(previous.x, previous.y);
}
// 清零速度,否则残留惯性会让角色传送后立刻滑走
p.vx = 0; p.vy = 0;
p._vx = 0; p._vy = 0;
p.dotX = 0; p.dotY = 0;
p.setMovementSuccess && p.setMovementSuccess(true);
// (a) 清掉传送前残留的碰撞信息 —— 尤其是从致命地形带过来的 type===2
p.clearImpact && p.clearImpact();
p._impact = null;
p.clearGrounding && p.clearGrounding();
// (b) 重置"最后落地位置"锚点,否则坠落复位会把人弹到旧地图的陈值坐标
p._groundingX = p._terrainGroundingX = p.px;
p._groundingY = p._terrainGroundingY = p.py;
// (c) 悬浮安全模式
// ⚠️ 莉莉娅也要一起处理。她是独立对象,只让玩家悬浮的话,
// 同伴会掉进坑里、被坠落复位反复弹回(用户实测反馈过)。
const floatMode = ${float ? 'true' : 'false'};
if (floatMode) {
${SAFE_CHAR}
${catalog ? 'motion.enable(p);motion.setNoclip(p,false);' : 'floatChar(p);'}
const companion = lilyHere();
if (!sameMap && storyPlacement?.ok && storyPlacement.controller && companion?.locate) companion.locate(p._x, p._y);
${catalog ? `if(companion){
const lx=(catalogPlacement.px-companion.tileXToCenterX(0))/m.tileWidth(),ly=(catalogPlacement.py-companion.tileYToCenterY(0))/m.tileHeight();
Game_CharacterBase.prototype.locate.call(companion,lx,ly);companion.unflush(lx,ly);
motion.enable(companion);motion.setNoclip(companion,false);
}` : 'floatChar(companion);'}
}
// 检测是否正在播放剧情 / 有事件在跑。
// 演出期间角色的位置由事件系统接管,会立刻覆盖我们的传送
// (实测表现为"传到 20 却停在 13")。这种情况必须告诉用户,
// 否则会以为是工具坏了。
let busy = false;
try {
busy = m.isAnyEventStarting ? m.isAnyEventStarting() : false;
if (!busy && m._updateEvents) {
busy = m._updateEvents.some(e => e && e.isStarting && e.isStarting());
}
} catch (e) { /* 检测失败不影响传送本身 */ }
return JSON.stringify({
ok: storyPlacement?.ok !== false, error: storyPlacement?.error,
positionNote: catalogPlacement?.positionNote||storyPlacement?.positionNote,
catalogCell:catalogPlacement?.bounds,
x: p._x, y: p._y, px: p.px, py: p.py,
mapId: m.mapId(),
changedMap: fromMap !== m.mapId(),
float: floatMode,
gravitySuspended: !!p.isSuspendGravity(),
noclip: !!p._suspendPhysicsHitTest,
busy: busy,
});
`);
},
/**
* ★ 直接播放一段剧情序列。
*
* **为什么需要这个**:通关存档上,NPC 的事件页早就翻到后面去了,
* 或者所需的开关组合已经变了,走过去根本不会触发那段对话。
* 这条路绕开地图事件和开关条件,直接从指令层调用故事。
*
* 原理:把一条合成出来的 `call_story` 插件指令喂给地图解释器
* (`$gameMap._interpreter.setup(list, 0)`),游戏自己就会当它是
* 事件触发的,正常播放。
*
* 插件指令参数格式实测为 [插件名, 命令名, 显示文本, 参数对象] —— 4 个元素,
* 参数在**第 3 位**(rmmz_objects.js:11315 的 command357 取的就是 params[3])。
*/
nudge(dx, dy, pixels = 4) {
if (![dx, dy].every(Number.isInteger) || Math.abs(dx) + Math.abs(dy) !== 1 || !Number.isFinite(pixels) || pixels < 1 || pixels > 96) throw new Error('微调方向或像素步长无效');
return wrap(`
${SAFE_CHAR}
const p = $gamePlayer, m = $gameMap;
const x = Math.max(0, Math.min(m.width() - 1, (p.px - p.tileXToCenterX(0) + ${dx * pixels}) / m.tileWidth()));
const y = Math.max(0, Math.min(m.height() - 1, (p.py - p.tileYToCenterY(0) + ${dy * pixels}) / m.tileHeight()));
// Base locate synchronizes physical coordinates without player locate's
// automatic floor search or companion transfer, which defeats tiny steps.
if (typeof Game_CharacterBase !== 'undefined') Game_CharacterBase.prototype.locate.call(p, x, y);
else p.locate(x, y);
p.unflush(x, y); floatChar(p);
const lily = lilyHere(); if (lily) floatChar(lily);
return JSON.stringify({ok:true,x,y,px:p.px,py:p.py});
`);
},
playStory(storyId) {
return wrap(`
${SLEEP}
const scenePlayer = $gamePlayer, sceneMap = $gameMap, sceneData = $dataMap;
const stillHere = () => scenePlayer === $gamePlayer && sceneMap === $gameMap && sceneData === $dataMap;
const id = ${Number(storyId)};
if (!$dataStory || !$dataStory[id] || !$dataStory[id].length) {
return JSON.stringify({ ok: false, error: '找不到故事序列 ' + id });
}
// ---- 播放前先彻底清场 ----
// 如果不清理,上一条剧情的剩余指令会和新剧情排队执行,
// 结果就是"请求播放 #16,画面上显示的却是别的剧情"(实测踩到过)。
try {
$gameMessage.clear();
const it = $gameMap._interpreter;
if (it) {
if (it._childInterpreter) it._childInterpreter.clear();
it.clear();
}
} catch (e) {}
await sleep(150); // 给游戏一帧时间把窗口收干净
if (!stillHere()) return JSON.stringify({ok:false,error:'地图或存档已经变化,播放已取消'});
// 已经读过的剧情会被游戏标记,某些情况下会走"跳过模式"一闪而过。
// 显式重置一次,保证按正常速度播放,方便截图。
try { $gameMessage.resetSkipMode(); } catch (e) {}
const cmd = {
indent: 0,
code: 357,
parameters: ['NYA.ACHIEVES_Adapter', 'call_story', '调用故事序列', { id: String(id) }],
};
$gameMap._interpreter.setup([cmd], 0);
// 等它真的开始播(消息窗口出现文字)
const t1 = Date.now();
let started = false;
while (Date.now() - t1 < 3000) {
await sleep(80);
if (!stillHere()) return JSON.stringify({ok:false,error:'地图或存档已经变化,播放已取消'});
if ($gameMessage.hasText && $gameMessage.hasText()) { started = true; break; }
}
const shownText = ($gameMessage._texts && $gameMessage._texts[0]) || '';
// 期望的首句台词,来自 $dataStory(第一条 story 指令的 contents)
let expected = '';
try {
const first = $dataStory[id].find(c => c && c.name === 'story');
expected = first ? String(first.contents || '') : '';
} catch (e) {}
return JSON.stringify({
ok: true,
started: started,
lines: $dataStory[id].length,
speaker: ($gameMessage._speakerName || ''),
firstText: shownText,
expected: expected,
// 显示的不是这一段,说明有残留剧情串台了
matched: !expected || shownText.startsWith(expected.slice(0, 8)),
});
`);
},
/**
* ★ 运行整段过场(而不是只念台词)。
*
* **为什么需要这个**:`playStory()` 只调用 `call_story`,等于跳过了"导演"。
* 实测反馈是"只有主角和对话框,在和虚空对话" —— 因为那些 NPC 是由
* 事件页里前面的指令(移动、显示、等待)摆到位的,光念台词当然没人。
*
* 定位方式:在当前地图上找**哪一页含目标故事的 call_story**,
* 比信任 CSV 里的页码更可靠(页码是 1-based,而且可能和实际不符)。
*
* 起始位置按**目标指令的缩进**决定(详见函数内注释):
* 在条件分支里就从目标指令开始跑,否则从页头跑。
*/
runStoryScene(storyId, eventId = null, pageIndex = null) {
return wrap(`
${SLEEP}
const scenePlayer = $gamePlayer, sceneMap = $gameMap, sceneData = $dataMap;
const stillHere = () => scenePlayer === $gamePlayer && sceneMap === $gameMap && sceneData === $dataMap;
${SAFE_CHAR}
const id = ${Number(storyId)};
const m = $gameMap;
// 找到含这段 call_story 的事件页,**同时记下它在指令列表里的位置和缩进**
let found = null;
for (const ev of m.events()) {
if (!ev) continue;
if (${eventId === null ? 'false' : 'ev.eventId() !== ' + Number(eventId)}) continue;
const data = ev.event();
if (!data || !data.pages) continue;
data.pages.forEach((pg, idx) => {
if (${pageIndex === null ? 'false' : 'idx !== ' + Number(pageIndex)}) return;
if (found) return;
const list = pg.list || [];
const ci = list.findIndex(c => {
if (c.code !== 357) return false;
const p = c.parameters || [];
if (p[1] !== 'call_story') return false;
const arg = p[p.length - 1];
return arg && Number(arg.id) === id;
});
if (ci >= 0) {
found = {
eventId: ev.eventId(), pageIndex: idx, list: list,
name: data.name, cmdIndex: ci,
indent: list[ci].indent || 0,
};
}
});
if (found) break;
}
if (!found) {
return JSON.stringify({ ok: false, error: '当前地图上找不到含故事 ' + id + ' 的事件页。可能触发点在别的地图。' });
}
// 清场,避免和正在播的东西打架
try {
$gameMessage.clear();
if (m._interpreter) {
if (m._interpreter._childInterpreter) m._interpreter._childInterpreter.clear();
m._interpreter.clear();
}
} catch (e) {}
await sleep(200);
if (!stillHere()) return JSON.stringify({ok:false,error:'地图或存档已经变化,播放已取消'});
/*
* ★ 从哪儿开始跑,取决于目标指令在不在条件分支里。
*
* 事件页常常是个"分支选择器":按开关/独立开关挑一条支线播。
* 例:地图 191 的 EV003 第 0 页,98 条指令里有 358 / 357 / 356 三个
* call_story 分布在不同分支里。从页头跑会走**游戏状态选中的那条**,
* 于是"点了 356 却播 358"(用户实际踩到)。
*
* 判据是缩进:顶层指令 indent=0,分支体内的指令 indent>=1。
* · indent > 0 → 目标在分支里 → **从目标指令开始跑**,保证播的是它
* · indent = 0 → 目标在顶层 → 从页头跑,前面的摆位/演出也一并执行
* (地图 6 的 EV009 就是这种:开头一堆 moveTo 把 NPC 摆好,再 call_story)
*/
const insideBranch = found.indent > 0;
/*
* ★ 让"说话者事件"本身也有身体。
*
* 事件的形象是**跟着页走**的:页不对,画面上就只有对话框没有人
* (用户实测反馈"和虚空对话")。
*
* 这里只处理**触发这段故事的那一个事件**,不做全图扫描 ——
* 实测地图 191 有 4 个事件(EV003/004/006/007)第 0 页都是
* battleObject 202(同一个 laine_stand),全图拉一遍就会出现
* **4 个一模一样的莲**(用户反馈"3 个一样的莲,奇奇怪怪的")。
*/
let speakerBody = null;
let speakerPick = null; // {ev, page},等过场跑起来之后再应用
try {
const se = m.events().filter(Boolean).find(e => e.eventId() === found.eventId);
if (se && !(se.characterName && se.characterName())) {
const sd = se.event();
const T = (typeof Game_BattleObject !== 'undefined') && Game_BattleObject._template;
// 返回某一页会给事件什么身体(没有则空串)
const bodyOfPage = (pg) => {
const cn = String((pg.image && pg.image.characterName) || '');
if (cn) return cn;
const ai = (pg.list || []).find(c => c.code === 357 && (c.parameters || [])[1] === 'ai');
if (ai) {
const a = (ai.parameters || []).slice(-1)[0];
const bo = a && a.battleObject;
if (bo !== undefined && bo !== '' && bo !== '0') {
const t = T && T[bo];
const s = t && (t.standingImage || t.movingImage);
if (s) return String(s);
}
}
return '';
};
let proper = -1;
try { proper = se.findProperPageIndex ? se.findProperPageIndex() : -1; } catch (e) {}
let pick = -1;
// 优先"条件本来就该选中的那页",否则第一个有身体的页
if (proper >= 0 && sd.pages[proper] && bodyOfPage(sd.pages[proper])) pick = proper;
else {
for (let i = 0; i < sd.pages.length; i++) {
if (bodyOfPage(sd.pages[i])) { pick = i; break; }
}
}
if (pick >= 0) {
speakerPick = { ev: se, page: pick };
} else {
speakerBody = { forced: false, reason: '这个事件的任何一页都没有立绘,救不了' };
}
} else if (se) {
speakerBody = { forced: false, sprite: String(se.characterName()), reason: '本来就有身体' };
}
} catch (e) {
speakerBody = { error: String(e && e.message || e) };
}
m._interpreter.setup(found.list, found.eventId);
if (insideBranch) m._interpreter._index = found.cmdIndex;
// 等对话窗口出现(过场前面可能先有移动、等待等指令)
const t0 = Date.now();
let started = false;
while (Date.now() - t0 < 8000) {
await sleep(100);
if (!stillHere()) return JSON.stringify({ok:false,error:'地图或存档已经变化,播放已取消'});
if ($gameMessage.hasText && $gameMessage.hasText()) { started = true; break; }
}
/*
* ★ 补身体必须放在**过场跑起来之后**,不能提前。
*
* 过场前面的指令会改开关/变量 → 触发 Game_Map.requestRefresh() →
* 游戏下一帧把所有事件的页号**重算一遍**,提前设好的页会被冲掉。
* 实测踩到:设完页、跑完过场,角色的身体又没了(开发文档第 6 节)。
* 等对话窗口出现(过场已跑完、正等着玩家按键)再设,就稳了。
*/
if (speakerPick) {
${PHOTO}
const se = speakerPick.ev;
photo.pin(se, speakerPick.page);
speakerBody = { page: speakerPick.page, sprite: String(se.characterName()), forced: true, pinned: true, spriteFixed: true };
/*
* ★ 把玩家挪开一格。
*
* 传送是把玩家送到"触发点坐标",而说话者事件通常**就站在那个坐标上**
* —— 两个人重叠,画面上只看得到玩家(实测:莲在 (4,5),玩家也在 (4,5),
* 截图里完全没有莲)。
*
* ⚠️ 挪动必须走**和 teleport 一样的清理流程**,不能只 locate()。
* 实测只 locate() 会让玩家掉进坑里、被坠落复位反复弹回
* (用户反馈"主角掉下去一直被传送回来又继续掉下去")。
* 原因见开发文档第 6 节:残留的 _impact + 陈旧的落地锚点会让
* isFallDown() 恒为真。这里照抄 teleport 里那套安全动作。
*/
const p = $gamePlayer;
if (se._x === p._x && se._y === p._y) {
/*
* ⚠️ 方向**优先左右**,不要优先上下。
*
* 这类游戏是"站在某一行地板上"的表现方式:往下挪一格等于
* 站到地板**里面**,画面上主角会显得陷进去、和对方不在一个高度
* (用户实测反馈"主角明显和莲不在一个高度上面…在地板下面")。
* 同一行左右相邻才是自然站位。
*/
const dirs = [[-1, 0], [1, 0], [0, -1], [0, 1]]; // 左 右 上 下
for (const [dx, dy] of dirs) {
const nx = se._x + dx, ny = se._y + dy;
if (nx < 0 || ny < 0 || nx >= m.width() || ny >= m.height()) continue;
p.locate(nx, ny);
// 用和 teleport 同一套安全处理(清残留碰撞 + 重置锚点 + 悬浮)
floatChar(p);
// 莉莉娅跟着一起处理,否则她会掉下去、坠落复位反复触发
const lh = lilyHere();
if (lh) { floatChar(lh); }
speakerBody.playerMovedTo = [nx, ny];
break;
}
}
// 主角可能刚从同一格让开,必须按最终站位给摆拍说话者定朝向。
photo.facePlayer?.(se);
}
const played = String($gameMessage._storyId || '');
return JSON.stringify({
ok: true,
started: started,
eventId: found.eventId,
eventName: found.name,
pageIndex: found.pageIndex,
commands: found.list.length,
// 从哪开始跑的,以及播出来的是不是用户选的那段
insideBranch: insideBranch,
startAt: insideBranch ? found.cmdIndex : 0,
startIndex: insideBranch ? found.cmdIndex : 0,
// 说话者事件的身体有没有被补上(只有触发这段故事的那一个事件)
speakerBody: speakerBody,
playedStoryId: played,
matched: played === String(id),
// 在分支里时前面那些摆位指令被跳过了,场景里的 NPC 可能不全
skippedSetup: insideBranch ? found.cmdIndex : 0,
speaker: ($gameMessage._speakerName || ''),
firstText: ($gameMessage._texts && $gameMessage._texts[0]) || '',
});
`);
},
/**
* 扫描本图"本该可见却没出现"的事件。
*
* 两种情况都算(判据来自开发文档第 6 节 的实测结论):
* 1. **被抹除**:`_erased === true`。游戏把 RM 原版的选页函数改成了
* `_pageIndex = this._erased ? -1 : this.findProperPageIndex()`,
* 所以被抹除的事件页号被钉死在 -1,**补开关永远救不回来**
* (`Game_Event.updateParallel` → `command214`「暂时消除事件」)
* 2. **页号没选中带立绘的那页**:当前页没立绘,但别的页有
*
* 只列出**至少有一页带立绘**的事件 —— 任何页都没立绘的(身体来自运行时 AI)
* 是救不了的,列出来只会误导用户(见开发文档第 6 节 情况 B)。
*/
scanLostEvents() {
return wrap(`
const m = $gameMap;
const lost = [];
const visible = [];
// ⚠️ 判断"事件现在有没有身体"必须用 characterName(),不能只看
// page.image.characterName —— 实测地图 45 的 EV005 等事件,
// image 是空的,身体是通过事件页里的 ai 指令
// ({"battleObject":"61"})在运行时挂上去的。
// 只看 image 会把明明在场的角色误报成"丢失"(踩过)。
const bodyOf = (ev) => {
try { return String(ev.characterName ? ev.characterName() : ''); }
catch (e) { return ''; }
};
// 把 battleObject 编号解析成真实立绘名。
// 事件页里的 ai 指令存的是 {"battleObject":"202"} 这种编号,
// 直接给用户看等于没说 —— 实测用户拿到"battleObject 202"完全没法判断
// 该不该拉出来。真正的立绘名在战斗对象模板的 standingImage 里。
const resolveSprite = (bo) => {
try {
const T = (typeof Game_BattleObject !== 'undefined') && Game_BattleObject._template;
const t = T && T[bo];
const s = t && (t.standingImage || t.movingImage);
return s ? String(s) : ('battleObject ' + bo);
} catch (e) { return 'battleObject ' + bo; }
};
for (const ev of m.events()) {
if (!ev) continue;
const d = ev.event();
if (!d || !d.pages || !d.pages.length) continue;
// 当前就看得见 → 记下来,不算丢失
if (bodyOf(ev)) {
visible.push({ id: ev.eventId(), sprite: bodyOf(ev), x: ev._x, y: ev._y });
continue;
}
// 找出所有"有图像的页"。判据放宽到两种:
// a. 页自带的 image(characterName / tileId)
// b. 页的指令里有 ai 配置(battleObject)—— 运行时才长出身体
const spritePages = [];
d.pages.forEach((pg, i) => {
const cn = String((pg.image && pg.image.characterName) || '');
if (cn) { spritePages.push({ page: i, sprite: cn, via: 'image' }); return; }
const ai = (pg.list || []).find(c => c.code === 357
&& (c.parameters || [])[1] === 'ai');
if (ai) {
const arg = (ai.parameters || [])[(ai.parameters || []).length - 1];
const bo = arg && arg.battleObject;
if (bo !== undefined && bo !== '' && bo !== '0') {
spritePages.push({ page: i, sprite: resolveSprite(bo), via: 'ai', battleObject: bo });
}
}
});
if (!spritePages.length) continue; // 任何页都没身体 → 救不了,不列
let proper = -1;
try { proper = ev.findProperPageIndex ? ev.findProperPageIndex() : -1; } catch (e) {}
const erased = !!ev._erased;
// 建议页:优先"条件本来就该选中的那页",否则第一个有身体的页
const hit = spritePages.find(s => s.page === proper) || spritePages[0];
lost.push({
id: ev.eventId(),
name: d.name,
x: ev._x, y: ev._y,
erased: erased,
currentPage: ev._pageIndex,
properPage: proper,
suggestPage: hit.page,
suggestSprite: hit.sprite,
suggestVia: hit.via,
spritePages: spritePages,
});
}
return JSON.stringify({
ok: true, mapId: m.mapId(),
lost: lost, count: lost.length,
visible: visible, visibleCount: visible.length,
});
`);
},
/**
* 强行把事件切到指定页,让它带着立绘出现。
*
* ⚠️ 三条实测得来的硬性约束(开发文档第 6 节):
* 1. **页号必须做边界检查** —— 越界时 `setupPageSettings()` 会抛
* `TypeError: Cannot read properties of undefined (reading 'image')`
* 2. 静态摆拍由 photo-state 管理,冻结事件更新;撤销时恢复原始字段。
* 3. **要清 `_erased`** —— 否则下一次 refresh 会把它打回 -1
*
* 这是**摆拍**:被抹除的事件强行拉出来,它不会执行任何指令,
* 只是一个站着的立绘。UI 上必须跟用户说清楚。
*/
forcePage(eventId, pageIndex) {
return wrap(`
const m = $gameMap;
const id = ${Number(eventId)};
const want = ${Number(pageIndex)};
const ev = m.events().filter(Boolean).find(e => e.eventId() === id);
if (!ev) return JSON.stringify({ ok: false, error: '本图找不到事件 ' + id });
const d = ev.event();
if (!d || !d.pages) return JSON.stringify({ ok: false, error: '事件 ' + id + ' 没有页数据' });
// (1) 边界检查 —— 不做的话会抛异常
const n = d.pages.length;
if (!Number.isInteger(want) || want < 0 || want >= n) {
return JSON.stringify({ ok: false, error: '页号越界:' + want + '(该事件共 ' + n + ' 页,合法范围 0~' + (n - 1) + ')' });
}
${PHOTO}
const wasErased = !!ev._erased;
photo.pin(ev, want);
const cn = String(ev.characterName ? ev.characterName() : '');
return JSON.stringify({
ok: true,
id: id, name: d.name,
page: ev._pageIndex,
sprite: cn,
wasErased: wasErased,
visible: !!cn,
pinned: true,
warning: wasErased
? '这个事件原本被游戏自己抹除了,强拉出来只是"站着的立绘",不会执行任何指令'
: '',
});
`);
},
/**
* 一键还原本图所有"丢失"的角色(扫描 + 逐个强切)。
*
* ⚠️ 这是**摆拍**。调用方应当:
* - 在 UI 上明确标注
* - 提示摆拍状态不保证跨读档恢复,保存由用户决定。
*/
restoreLostCast({ includeHidden = false } = {}) {
return wrap(`
${PHOTO}
const m = $gameMap;
const results = [];
const skipped = [];
const bodyOf = (ev) => { try { return String(ev.characterName ? ev.characterName() : ''); } catch (e) { return ''; } };
for (const ev of m.events()) {
if (!ev) continue;
const d = ev.event();
if (!d || !d.pages || !d.pages.length) continue;
const spritePages = [];
d.pages.forEach((pg, i) => {
const cn = String((pg.image && pg.image.characterName) || '');
if (cn) { spritePages.push({ page: i, sprite: cn, via: 'image' }); return; }
const ai = (pg.list || []).find(c => c.code === 357 && (c.parameters || [])[1] === 'ai');
if (ai) {
const arg = (ai.parameters || [])[(ai.parameters || []).length - 1];
const bo = arg && arg.battleObject;
if (bo !== undefined && bo !== '' && bo !== '0') {
spritePages.push({ page: i, sprite: 'battleObject ' + bo, via: 'ai' });
}
}
});
if (!spritePages.length) continue;
if (bodyOf(ev)) continue; // 已经看得见
const wasErased = !!ev._erased;
let proper = -1;
try { proper = ev.findProperPageIndex ? ev.findProperPageIndex() : -1; } catch (e) {}
// ★ 区分两种情况:
// 1. 被抹除的(wasErased)—— 该在场上却被游戏删了,拉出来是**正当恢复**
// 2. 没被抹除、但游戏选的页没身体 —— 游戏是**有意**不显示它
// (比如剧情推进到后面了,这个 NPC 本来就该退场)
// 强拉出来会凭空多出人物,画面上是没发生过的事。
// 默认不动它们,除非调用方明确要求。
if (!wasErased && !${includeHidden ? 'true' : 'false'}) {
skipped.push({
id: ev.eventId(), name: d.name,
properPage: proper,
reason: '游戏有意隐藏(不是被抹除)',
});
continue;
}
const pick = spritePages.find(s => s.page === proper) || spritePages[0];
photo.pin(ev, pick.page);
results.push({
id: ev.eventId(), name: d.name,
page: pick.page, sprite: pick.sprite, via: pick.via,
wasErased: wasErased,
x: ev._x, y: ev._y,
});
}
// 检查"多个事件显示成同一个立绘"的情况。
// 实测地图 191 有 4 个事件(EV003/004/006/007)第 0 页都是同一个
// battleObject —— 全拉出来会变成好几个一模一样的人(用户反馈过
// "3 个一样的莲,奇奇怪怪的")。这里如实报出来,别让用户自己数。
const bySprite = {};
for (const r of results) { bySprite[r.sprite] = (bySprite[r.sprite] || 0) + 1; }
const duplicates = Object.keys(bySprite)
.filter(k => bySprite[k] > 1)
.map(k => ({ sprite: k, count: bySprite[k] }));
return JSON.stringify({
ok: true,
restored: results.length,
list: results,
erasedCount: results.filter(r => r.wasErased).length,
skipped: skipped,
skippedCount: skipped.length,
duplicates: duplicates,
includeHidden: ${includeHidden ? 'true' : 'false'},
});
`);
},
/**
* 取消本图所有"钉住"的页号,让游戏恢复自己选页。
*
* 强切页现在是靠**覆盖 `findProperPageIndex`** 实现的,所以会一直生效
* (扛得住开关/变量变动引起的重算)。用完想恢复原状时调这个。
*/
unpinPages() {
return wrap(`
const n = window.__nyaruruPhoto ? window.__nyaruruPhoto.unpinAll() : 0;
return JSON.stringify({ok:true,unpinned:n});
`);
},
/**
* 把当前游戏状态存进指定槽。允许摆拍时保存,不承诺跨读档恢复摆拍。
*
* 存档是 zlib + main.y() 加密的,外部改不了;但可以让**游戏自己存** ——
* `DataManager.saveGame()` 走的就是游戏自己的加密路径。
* 实测无弹窗、不需要玩家交互、`_globalInfo` 立即更新(开发文档第 6 节)。
*
* ⚠️ **会覆盖该槽位原有的进度。** 调用方必须先备份、并让用户明确选槽。
* 实测 24 个槽位(0=自动存档,1~23 手动)在用户的档里全满,没有空槽可用。
*
* 注意:这是"存"不是"读"。读档仍然必须走游戏自己的标题界面(见开发文档第 3 节)。
*/
savePhotoSlot(slot) {
return wrap(`
${SLEEP}
const s = ${Number(slot)};
const max = DataManager.maxSavefiles();
if (!Number.isInteger(s) || s < 1 || s > max) {
return JSON.stringify({ ok: false, error: '槽位越界:' + s + '(合法范围 1~' + max + ',0 是自动存档槽)' });
}
try {
const pending = [], originalZip = StorageManager.saveZip, originalObject = StorageManager.saveObject;
StorageManager.saveZip = function(...args) { const job = originalZip.apply(this, args); pending.push(Promise.resolve(job)); return job; };
StorageManager.saveObject = function(...args) { const job = originalObject.apply(this, args); pending.push(Promise.resolve(job)); return job; };
try {
$gameSystem.onBeforeSave();
SceneManager.snapForBackground();
await DataManager.saveGame(s);
for (let i = 0; i < pending.length; i++) await pending[i];
} finally { StorageManager.saveZip = originalZip; StorageManager.saveObject = originalObject; }
return JSON.stringify({
ok: true, slot: s,
mapId: $gameMap.mapId(),
x: $gamePlayer._x, y: $gamePlayer._y,
});
} catch (e) {
return JSON.stringify({ ok: false, error: '存档失败: ' + String(e && e.message || e) });
}
`);
},
/**
* 拍照辅助:隐藏 / 显示画面上的各种 UI。
*
* 三个开关互不影响,各自可以 null(表示"别动它"):
* message —— 对话框(消息窗口 + 选项窗口)
* artwork —— 立绘
* hud —— 游戏 HUD(血条、小地图、Boss 条、存档条等)
*
* ⚠️ 与 `closeMessage()` 的区别:
* - `closeMessage()` 是**真的关掉**对话(清空消息、停掉剧情),关了就回不来了
* - 这个只是**把窗口藏起来**,随时能显示回来。想拍"有对话框"和"没对话框"
* 两张对照图,用这个。
*
* 实现上是直接改 `visible`。游戏每帧不会强制改回来(实测),
* 但如果窗口自己重开了(比如新的对话),需要再调一次。
*/
setUiHidden(opts = {}) {
const m = opts.message, a = opts.artwork, h = opts.hud;
const arg = (v) => (v === null || v === undefined) ? 'null' : (v ? 'true' : 'false');
return wrap(`
const sc = SceneManager._scene;
if (!sc || sc.constructor.name !== 'Scene_Map') {
return JSON.stringify({ ok: false, error: '当前不在地图场景(请先读档进入游戏)' });
}
// 游戏 HUD 的字段名(实测自 Scene_Map,见开发文档 4.x)
const HUD = ['_floatingSoftGuideBar','_weaponChange','_damageDigit','_playerBar',
'_enemyBar','_smallMap','_bossBar','_autoSaveBar','_skipStory',
'_ornamentsUI','_battleBar','_bossRushPanel','_taskNotificationBar',
'_notificationBar','_bossDestination','_playerFloatingTips',
'_zxCArtwork','_mapThumbnailBig','_artwork'];
const put = (v, visible) => { if (v && typeof v === 'object') v.visible = visible; };
const applied = {};
const wantMsg = ${arg(m)};
if (wantMsg !== null) {
put(sc._messageWindow, !wantMsg);
put(sc._choiceWindow, !wantMsg);
put(sc._eventItemWindow, !wantMsg);
applied.message = wantMsg;
}
const wantArt = ${arg(a)};
if (wantArt !== null) {
// ⚠️ 立绘不能只改父容器的 visible —— Sprite_Artwork 自己的状态机
// 每帧会把它改回来(实测 1.8 秒内恢复 true)。
// 必须连**子精灵**一起藏,那个不会被改回去。
put(sc._artwork, !wantArt);
let n = 0;
if (sc._artwork && sc._artwork.children) {
for (const c of sc._artwork.children) {
if (c && c.visible !== undefined) { c.visible = !wantArt; n++; }
}
}
applied.artwork = wantArt;
applied.artworkParts = n;
}
const wantHud = ${arg(h)};
if (wantHud !== null) {
let n = 0;
for (const k of HUD) {
if (k === '_artwork') continue; // 立绘单独控制
if (sc[k] && typeof sc[k] === 'object' && sc[k].visible !== undefined) {
sc[k].visible = !wantHud; n++;
}
}
applied.hud = wantHud;
applied.hudCount = n;
}
sc.__nyaruruUiHidden = { ...(sc.__nyaruruUiHidden || {message:false,artwork:false,hud:false}) };
for (const k of ['message','artwork','hud']) if (applied[k] !== undefined) sc.__nyaruruUiHidden[k] = applied[k];
return JSON.stringify({ ok: true, applied: applied, state: sc.__nyaruruUiHidden });
`);
},
/**
* 莉莉娅(跟随的同伴)显示 / 隐藏。
*
* 用的是游戏自己的 API —— 插件指令 `setLilyPartner` 内部就是
* `$gameLily.setRemove(args.open !== "true")`(NYA.EVENT.AI_Adapter.js:1293)。
*
* ⚠️ 如果原场景里莉莉娅本来就该在场,把她藏起来会让画面和原作不符。
* UI 上要说清楚这一点。
*/
setLily(show) {
return wrap(`
if (typeof $gameLily === 'undefined' || !$gameLily) {
return JSON.stringify({ ok: false, error: '当前没有莉莉娅这个对象' });
}
$gameLily.setRemove(${show ? 'false' : 'true'});
return JSON.stringify({ ok: true, visible: !$gameLily.isRemove() });
`);
},
/**
* 关掉当前的消息窗口,并停掉正在播放的剧情。
* 截完图收尾用 —— 否则对话窗口会一直挂在那儿等输入。
*/
closeMessage() {
return wrap(`
let closed = false;
try {
closed = $gameMessage.hasText ? $gameMessage.hasText() : false;
$gameMessage.clear();
} catch (e) {}
// 解释器可能卡在等待消息的状态,一起清掉,否则它会继续跑后面的指令
try {
const it = $gameMap._interpreter;
if (it) {
if (it._childInterpreter) it._childInterpreter.clear();
if (it.isRunning()) it.clear();
}
} catch (e) {}
return JSON.stringify({ ok: true, hadMessage: closed });
`);
},
/**
* 探测当前位置脚下是什么,供"落地"判断安不安全。
*
* 实现要点(都是踩过坑才写对的):
* 1. 用 hitPreview(0, dy) 从当前位置往下逐段测碰撞(它会临时挪动碰撞体再还原)
* 2. **必须用 `o.terrain === true` 过滤出地形**。直接取第一个碰撞对象是错的——
* 同伴角色($gameLily)常常和玩家重叠,会把"撞到同伴"误判成"脚下有地面"。
* 实测:Game_Terrain 的 terrain 为 true,玩家和同伴都是 false。
* 3. 步进用 1/4 格。用 1/2 格会跳过薄地形(实测同一位置 1/2 格测得 336px、
* 1/4 格测得 312px,差了半格多)。
* 4. type === 2 是"碰到会掉血/复位"的致命地形(坑、虚空)
*/
probeGround() {
return wrap(`
const p = $gamePlayer, m = $gameMap;
const th = m.tileHeight();
const step = Math.max(8, Math.round(th / 4));
const maxScan = (m.height() - p._y) * th + th;
let found = null, hazard = false, scanned = 0;
for (let dy = 0; dy <= maxScan; dy += step) {
scanned++;
const hit = p.hitPreview(0, dy);
if (!hit || !hit.length) continue;
// 只看地形,忽略角色和道具(否则会被同伴挡出误判)
const terrains = hit.filter(o => o && o.terrain === true);
if (!terrains.length) continue;
if (terrains.some(o => o.type === 2)) hazard = true;
else found = dy;
break;
}
return JSON.stringify({
ok: true,
distance: found, // 距脚下实地的像素数,null 表示没找到
hazard: hazard, // 正下方是致命地形
clear: found !== null && !hazard,
scanned: scanned,
step: step,
});
`);
},
/**
* 落地:让人从悬浮状态站到实地上。
* 先探测脚下,正下方是坑就拒绝执行并说明原因,避免再踩一次坠落复位。
*/
land() {
return wrap(`
const p = $gamePlayer, m = $gameMap;
const th = m.tileHeight();
const step = Math.max(8, Math.round(th / 4));
const maxScan = (m.height() - p._y) * th + th;
let found = null, hazard = false;
for (let dy = 0; dy <= maxScan; dy += step) {
const hit = p.hitPreview(0, dy);
if (!hit || !hit.length) continue;
const terrains = hit.filter(o => o && o.terrain === true);
if (!terrains.length) continue;
if (terrains.some(o => o.type === 2)) hazard = true;
else found = dy;
break;
}
if (hazard) {
return JSON.stringify({ ok: false, error: '当前位置正下方是陷阱地形(掉下去会被弹回原位)。横向挪几格再试。' });
}
if (found === null) {
return JSON.stringify({ ok: false, error: '当前位置下方没有可站立的地面(可能是无底洞)。横向挪几格再试。' });
}
// 恢复碰撞;锚点设到落点,确保万一出岔子也落在附近而不是旧地图的位置
window.__nyaruruMotion?.disable(p);
p.resumePhysicsHitTest();
p.clearImpact && p.clearImpact();
p._impact = null;
p._groundingX = p._terrainGroundingX = p.px;
p._groundingY = p._terrainGroundingY = p.py;
p.resumeGravity();
${SAFE_CHAR}
const lily = lilyAny();
let lilyLanded = !lily, warning = '';
if (lily) {
let safe = !!(lily.isRemove && lily.isRemove());
for (let dy = 0; !safe && dy <= Math.max(0, (m.height() - lily._y) * th + th); dy += step) {
const hits = (lily.hitPreview(0, dy) || []).filter(o => o && o.terrain === true);
if (hits.length) { safe = !hits.some(o => o.type === 2); break; }
}
if (safe) { motion.disable(lily); safeChar(lily); lily.resumeGravity(); lily.resumePhysicsHitTest(); lilyLanded = true; }
else { floatChar(lily); warning = '主角已落地;莉莉娅脚下不安全,仍保持悬浮。请移动到安全地面再点落地'; }
}
return JSON.stringify({ ok: true, dropDistance: found, lilyLanded, warning });
`);
},
/** 读取所有开关的当前值(360 个布尔,数据量很小) */
readSwitches() {
return wrap(`
const out = {};
for (let i = 1; i < $dataSystem.switches.length; i++) {
out[i] = $gameSwitches.value(i) ? 1 : 0;
}
return JSON.stringify({ ok: true, values: out });
`, { allowMenu: true });
},
/** 设置单个开关 */
setSwitch(id, value) {
return wrap(`
const id = ${Number(id)};
$gameSwitches.setValue(id, ${value ? 'true' : 'false'});
return JSON.stringify({ ok: true, id: id, value: $gameSwitches.value(id) });
`);
},
/** 批量设置开关(杀手锏功能"一键满足条件"会用到) */
setSwitches(pairs) {
return wrap(`
const pairs = ${JSON.stringify(pairs)};
const done = [];
for (const [id, v] of pairs) {
$gameSwitches.setValue(Number(id), !!v);
done.push([id, $gameSwitches.value(Number(id))]);
}
return JSON.stringify({ ok: true, applied: done.length });
`);
},
/**
* 关重力 / 开重力。
* ⚠️ 莉莉娅要一起处理:她是独立对象,只关玩家的重力,同伴会掉下去
* 并触发坠落复位循环(用户实测反馈过)。
*/
setGravity(suspended) {
if (!suspended) return this.land();
const on = suspended ? 'true' : 'false';
return wrap(`
${SAFE_CHAR}
const p = $gamePlayer;
const lh = lilyAny();
if (${on}) {
safeChar(p); motion.enable(p);
if (lh) { safeChar(lh); motion.enable(lh); }
} else {
p.resumeGravity();
p.resumePhysicsHitTest();
if (lh) { lh.resumeGravity(); lh.resumePhysicsHitTest(); }
}
return JSON.stringify({
ok: true,
suspended: !!p.isSuspendGravity(),
lilyTouched: !!lh,
});
`);
},
/** 无敌开关(用 lockInvincible,常驻且不会让角色闪烁) */
setInvincible(on) {
return wrap(`
const p = $gamePlayer;
if (${on ? 'true' : 'false'}) { p.lockInvincible(); }
else { p.unlockInvincible(); }
return JSON.stringify({ ok: true, invincible: !!p.isInvincible() });
`);
},
/**
* 穿墙(屏蔽碰撞检测)。
*
* ⚠️ 关闭穿墙时必须清一次残留碰撞:穿墙期间碰撞检测整个被跳过
* (nya_physics.js:744 直接 return),`_impact` 不会被自动清理。
* 如果残留的是 type===2 的致命地形,恢复碰撞后 isFallDown() 会立刻为 true,
* 角色会被坠落复位弹走。这里顺手清掉。
*/
setNoclip(on) {
return wrap(`
${SAFE_CHAR}
const p = $gamePlayer;
if (!${on ? 'true' : 'false'} && (p.hitPreview(0, 0) || []).some(o => o?.terrain === true)) return JSON.stringify({ok:false,error:'当前与地形重叠,请先微调到空处再关闭穿墙'});
let warning = '';
for (const c of [p, lilyAny()]) {
if (!c) continue;
safeChar(c);
if (${on ? 'true' : 'false'}) motion.enable(c, true);
else if (c !== p && (c.hitPreview(0,0) || []).some(o => o?.terrain === true)) warning = '莉莉娅与地形重叠,仍保持穿墙悬停';
else motion.setNoclip(c,false);
}
return JSON.stringify({ok:true,noclip:!!p._suspendPhysicsHitTest,warning});
`);
},
/**
* 一键还原所有作弊状态 —— 断开连接前、或工具出错后用它兜底。
* 同样要清残留碰撞,理由见 setNoclip 的注释。
*/
restoreAll() {
return wrap(`
${SAFE_CHAR}
const p = $gamePlayer;
const lh = lilyAny();
if (window.__nyaruruPhoto) window.__nyaruruPhoto.unpinAll();
if (window.__nyaruruTrainer) window.__nyaruruTrainer.clear();
window.__nyaruruCombat?.clear();
motion.clear();
// 玩家和莉莉娅都要清 —— 只清玩家的话,同伴会继续坠落复位循环
for (const c of [p, lh]) {
if (!c) continue;
safeChar(c);
try { c.resumeGravity(); c.resumeFriction(); } catch (e) {}
try { c.unlockInvincible(); } catch (e) {}
try { c.resumePhysicsHitTest(); } catch (e) {}
}
return JSON.stringify({
ok: true, gravity: !p.isSuspendGravity(),
noclip: !!p._suspendPhysicsHitTest,
invincible: !!p.isInvincible(),
lilyTouched: !!lh,
});
`);
},
/**
* 列出当前地图上的所有事件(用于"从事件列表选点"传送)。
*
* 光返回 `EV058` 这种内部编号对用户毫无意义(和"地图170"一个毛病),
* 所以这里多挖两样东西交给服务端加工:
*
* storyIds —— 从事件页的 `call_story` 插件指令里读出的故事序号。
* 指令参数结构实测为:
* ["NYA.ACHIEVES_Adapter", "call_story", "调用故事序列", {"id":"92"}]
* 服务端拿它去查台词,把事件变成「台词」— 说话人
*
* sprite —— 事件用的角色图名,能说明这是什么东西。
* 实测有 ;moving_platform_upper(移动平台)、
* ;cat_coin_copper(铜币)这类可读的名字。
*
* 当前页触发类型(trigger)说明它是怎么被触发的:按确定键 / 玩家接触 / 自动执行…
*/
listEvents() {
return wrap(`
const m = $gameMap;
const TRIG = { 0: '按确定键', 1: '玩家接触', 2: '事件接触', 3: '自动执行', 4: '并行' };
const list = [];
for (const ev of m.events()) {
if (!ev) continue;
const data = ev.event();
const storyIds = [];
if (data && data.pages) {
for (const page of data.pages) {
for (const c of (page.list || [])) {
if (c.code !== 357) continue; // 357 = 插件指令
const p = c.parameters || [];
if (p[1] !== 'call_story') continue;
// 故事序号在最后一个参数的 id 字段里
const arg = p[p.length - 1];
if (arg && arg.id !== undefined) {
const n = Number(arg.id);
if (!Number.isNaN(n)) storyIds.push(n);
}
}
}
}
let sprite = '';
try { sprite = ev.characterName ? ev.characterName() : ''; } catch (e) {}
// 事件还没被激活时 _trigger 是 null(_pageIndex 为 -1),
// 这时退回读第一页的 trigger,避免显示成 "null"
let trigCode = ev._trigger;
if (trigCode === null || trigCode === undefined) {
const firstPage = data && data.pages && data.pages.find(p => p);
trigCode = firstPage ? firstPage.trigger : null;
}
list.push({
id: ev.eventId(),
name: data ? data.name : '(无名)',
x: ev._x, y: ev._y,
trigger: TRIG[trigCode] !== undefined ? TRIG[trigCode] : (trigCode === null ? '未激活' : String(trigCode)),
sprite: String(sprite || '').replace(/^;/, ''),
storyIds: [...new Set(storyIds)],
// 被游戏自己抹除的事件(开发文档第 6 节):页号钉死在 -1,
// 走过去也触发不了,开关怎么补都没用 —— 只能走"运行过场"
erased: !!ev._erased,
pageIndex: ev._pageIndex,
});
}
return JSON.stringify({ ok: true, events: list, mapId: m.mapId() });
`);
},
};
module.exports = { OPS, wrap };