1427 lines
72 KiB
JavaScript
1427 lines
72 KiB
JavaScript
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 };
|