feat: 发布咸鱼喵喵全能助手,加入全收集地图与图文说明

This commit is contained in:
Kitasami_Nya
2026-10-24 01:37:15 +08:00
parent 8f6ac4ff04
commit 3823e38b07
39 changed files with 2918 additions and 194 deletions
+121 -30
View File
@@ -1,6 +1,13 @@
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()})();`;
// 游戏操作脚本库
//
@@ -8,12 +15,12 @@ const PHOTO = `const photo = (${installPhotoTools.toString()})();`;
// 之所以存成字符串而非直接执行,是因为这些代码必须在游戏进程里跑,
// 才能访问 $gamePlayer / $gameMap 这些游戏全局对象。
//
// ⚠️ 关键设计约束(来自 2026-10-02 实机探查,详见 aitemp/探查结果.md):
// ⚠️ 关键设计约束(当前规则见 tool/开发文档.md):
// 1. 传送必须用 $gamePlayer.locate(x, y),不能手写 _x/_y/_realX/_realY
// —— 后者会漏掉物理引擎的 px/py,导致"画面过去了、碰撞体还在原地"
// 2. 游戏有 4 套坐标系,locate() 内部会一次同步全部
// 3. 无敌用 lockInvincible()(常驻)而非 makeInvincible()(会自己失效)
// 4. 工具不负责读档 —— CDP 读档会崩(物理网格无法反序列化)
// 4. 读档由用户在游戏正常菜单完成,不能裸调 loadGame(还需重建物理网格)
/**
* 包一层 IIFE,统一处理:游戏未进地图的守卫、异常捕获、JSON 返回格式。
@@ -23,12 +30,14 @@ const PHOTO = `const photo = (${installPhotoTools.toString()})();`;
* CDP 那边开了 `awaitPromise: true`,所以返回 Promise 会被自动等待。
* 同步的操作写成 async 也无妨,只是多包一层 Promise。
*/
function wrap(body, { needMap = true } = {}) {
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' && SceneManager._scene?.constructor.name !== 'Scene_Map')) {
return JSON.stringify({ ok: false, error: '游戏还没进入地图。请先在游戏里读档或开始游戏。' });
(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) }); } })()`;
@@ -46,6 +55,7 @@ const SLEEP = `const sleep = (ms) => new Promise(r => setTimeout(r, ms));`;
* 机制见开发文档第 6 节。
*/
const SAFE_CHAR = `
const motion = (${installMotion.toString()})();
// 清残留碰撞 + 重置落地锚点,否则 isFallDown() 会一直为真
const safeChar = (c) => {
if (!c) return;
@@ -63,7 +73,7 @@ const SAFE_CHAR = `
const floatChar = (c) => {
if (!c) return;
safeChar(c);
try { c.suspendGravity(); c.suspendPhysicsHitTest(); } catch (e) {}
motion.enable(c, true);
};
// 莉莉娅在不在(她被移除时不该动她)
const lilyAny = () => typeof $gameLily !== 'undefined' ? $gameLily : null;
@@ -110,13 +120,38 @@ const OPS = {
`, { 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()'});`);
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;
@@ -129,12 +164,12 @@ const OPS = {
mapId: m.mapId(),
mapName: mi ? mi.name : ('地图 ' + m.mapId()),
mapSize: [m.width(), m.height()],
x: p._x,
y: p._y,
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: !!p.isSuspendGravity(),
gravitySuspended: !!window.__nyaruruMotion?.has(p),
invincible: !!p.isInvincible(),
noclip: !!p._suspendPhysicsHitTest,
// 莉莉娅在不在画面里(通关后她会一直跟着,可以用开关藏起来)
@@ -147,12 +182,14 @@ const OPS = {
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) }
@@ -167,14 +204,15 @@ const OPS = {
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' });
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 });
},
/**
* 传送到指定格。
*
* ⚠️ 这个函数处理了一个**实测踩出来的坑**,改动前务必先读 `探查结果.md` 与开发文档第 6 节。
* ⚠️ 这个函数处理了游戏物理坐标同步问题,改动前先读 `tool/开发文档.md` 的「功能模块与边界」。
*
* 游戏的"坠落复位"机制(nya_statem.js:5986):
* isFallDown() 在玩家碰到 type===2 的地形(坑、虚空)时返回 true,
@@ -194,19 +232,23 @@ const OPS = {
*
* 用户想真正站到地上走动时,调用 land()。
*/
teleport(x, y, { float = true, mapId = null, story = null } = {}) {
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 = Math.max(0, Math.min(m.width() - 1, Math.round(${Number(x)})));
const ty = Math.max(0, Math.min(m.height() - 1, Math.round(${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:'目标地图不存在'});
@@ -242,13 +284,34 @@ const OPS = {
});
}
// 换图后坐标可能被目标地图的出生点逻辑改过,再定一次位
const nx = Math.max(0, Math.min(m.width() - 1, Math.round(${Number(x)})));
const ny = Math.max(0, Math.min(m.height() - 1, Math.round(${Number(y)})));
p.locate(nx, ny);
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 {
p.locate(tx, ty);
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) {
@@ -279,10 +342,14 @@ const OPS = {
const floatMode = ${float ? 'true' : 'false'};
if (floatMode) {
${SAFE_CHAR}
floatChar(p);
${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);
floatChar(companion);
${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);'}
}
// 检测是否正在播放剧情 / 有事件在跑。
@@ -299,7 +366,8 @@ const OPS = {
return JSON.stringify({
ok: storyPlacement?.ok !== false, error: storyPlacement?.error,
positionNote: storyPlacement?.positionNote,
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(),
@@ -325,6 +393,22 @@ const OPS = {
* 插件指令参数格式实测为 [插件名, 命令名, 显示文本, 参数对象] —— 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}
@@ -1116,6 +1200,7 @@ const OPS = {
}
// 恢复碰撞;锚点设到落点,确保万一出岔子也落在附近而不是旧地图的位置
window.__nyaruruMotion?.disable(p);
p.resumePhysicsHitTest();
p.clearImpact && p.clearImpact();
p._impact = null;
@@ -1132,7 +1217,7 @@ const OPS = {
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) { safeChar(lily); lily.resumeGravity(); lily.resumePhysicsHitTest(); lilyLanded = true; }
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 });
@@ -1147,7 +1232,7 @@ const OPS = {
out[i] = $gameSwitches.value(i) ? 1 : 0;
}
return JSON.stringify({ ok: true, values: out });
`);
`, { allowMenu: true });
},
/** 设置单个开关 */
@@ -1178,14 +1263,15 @@ const OPS = {
* 并触发坠落复位循环(用户实测反馈过)。
*/
setGravity(suspended) {
if (!suspended) return this.land();
const on = suspended ? 'true' : 'false';
return wrap(`
${SAFE_CHAR}
const p = $gamePlayer;
const lh = lilyAny();
if (${on}) {
floatChar(p);
if (lh) floatChar(lh);
safeChar(p); motion.enable(p);
if (lh) { safeChar(lh); motion.enable(lh); }
} else {
p.resumeGravity();
p.resumePhysicsHitTest();
@@ -1221,13 +1307,16 @@ const OPS = {
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'}) c.suspendPhysicsHitTest();
else c.resumePhysicsHitTest();
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});
return JSON.stringify({ok:true,noclip:!!p._suspendPhysicsHitTest,warning});
`);
},
@@ -1242,6 +1331,8 @@ const OPS = {
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;