From 178e1d871444f723a7d5a8bec5844a73bda7f553 Mon Sep 17 00:00:00 2001
From: GJJ-Desktop <810131285@qq.com>
Date: Sat, 3 Oct 2026 01:16:35 +0800
Subject: [PATCH] =?UTF-8?q?=E5=8F=91=E5=B8=83=E5=92=B8=E9=B1=BC=E5=96=B5?=
=?UTF-8?q?=E5=96=B5=E6=88=AA=E5=9B=BE=E8=BE=85=E5=8A=A9=E5=B7=A5=E5=85=B7?=
=?UTF-8?q?=E5=85=AC=E5=BC=80=E7=89=88?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.gitignore | 37 +
README.md | 26 +
out/triggers.csv | 898 +++++++++++++++++++++++
tool/README.md | 26 +
tool/launcher.js | 106 +++
tool/lib/cdp.js | 97 +++
tool/lib/data.js | 216 ++++++
tool/lib/files.js | 145 ++++
tool/lib/ops.js | 1335 +++++++++++++++++++++++++++++++++++
tool/lib/photo-state.js | 120 ++++
tool/lib/probe-game.js | 11 +
tool/lib/processes.js | 9 +
tool/lib/safety.js | 43 ++
tool/lib/story-cache.js | 78 ++
tool/lib/story-position.js | 82 +++
tool/lib/trainer.js | 148 ++++
tool/server.js | 497 +++++++++++++
tool/web/app.js | 1046 +++++++++++++++++++++++++++
tool/web/index.html | 335 +++++++++
tool/web/style.css | 308 ++++++++
tool/使用须知.md | 76 ++
tool/停止面板.bat | 5 +
tool/发布清单.json | 33 +
tool/启动.bat | 15 +
unpacked/data/MapInfos.json | 1 +
unpacked/data/System.json | 1 +
26 files changed, 5694 insertions(+)
create mode 100644 .gitignore
create mode 100644 README.md
create mode 100644 out/triggers.csv
create mode 100644 tool/README.md
create mode 100644 tool/launcher.js
create mode 100644 tool/lib/cdp.js
create mode 100644 tool/lib/data.js
create mode 100644 tool/lib/files.js
create mode 100644 tool/lib/ops.js
create mode 100644 tool/lib/photo-state.js
create mode 100644 tool/lib/probe-game.js
create mode 100644 tool/lib/processes.js
create mode 100644 tool/lib/safety.js
create mode 100644 tool/lib/story-cache.js
create mode 100644 tool/lib/story-position.js
create mode 100644 tool/lib/trainer.js
create mode 100644 tool/server.js
create mode 100644 tool/web/app.js
create mode 100644 tool/web/index.html
create mode 100644 tool/web/style.css
create mode 100644 tool/使用须知.md
create mode 100644 tool/停止面板.bat
create mode 100644 tool/发布清单.json
create mode 100644 tool/启动.bat
create mode 100644 unpacked/data/MapInfos.json
create mode 100644 unpacked/data/System.json
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..c041bd4
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,37 @@
+# 本机配置、日志和存档不提交。
+/tool/config.json
+/tool/.server-runtime.json
+/tool/index/
+/backups/
+/backup_save_*/
+**/save/
+*.rmmzsave
+*.log
+*.tmp
+*.bak
+.last_backup
+
+# 运行环境另行下载。
+/nwjs-sdk-*/
+*.zip
+
+# 仅提交运行必需的结构索引,排除完整提取数据。
+/out/*
+!/out/triggers.csv
+/unpacked/*
+!/unpacked/data/
+/unpacked/data/*
+!/unpacked/data/System.json
+!/unpacked/data/MapInfos.json
+
+# 开发资料和本地备份。
+/probe/
+/dumpapp/
+/samples/
+/_同步前备份_*/
+/_撤回误改留档_*/
+/claude-update-*/
+/公开发布/
+Thumbs.db
+desktop.ini
+.DS_Store
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..2050f68
--- /dev/null
+++ b/README.md
@@ -0,0 +1,26 @@
+# 咸鱼喵喵截图辅助工具:简单使用方法
+
+一个在浏览器里操作的本地辅助工具,支持剧情全文搜索、传送、播放对白、角色摆拍,以及角色数值和背包调整,方便回看剧情和截图。
+
+需要 Windows、已安装的《咸鱼喵喵》、[Node.js 22 或更新版本](https://nodejs.org/),以及 [NW.js SDK 0.64.1(Windows x64)](https://dl.nwjs.io/v0.64.1/nwjs-sdk-v0.64.1-win-x64.zip)。发布包不含游戏本体、存档、完整剧本和上述运行环境;无需 npm install。
+
+1. 将包内的 `aitemp截图工具` 文件夹放进游戏安装目录,与游戏的 `package.json` 同级。可从 Steam 的“管理 → 浏览本地文件”找到游戏目录。
+2. 解压 NW.js SDK,把 `nwjs-sdk-v0.64.1-win-x64` 文件夹放进 `aitemp截图工具`。确认路径如下,别多套一层文件夹:
+
+ ```text
+ 游戏目录/
+ package.json
+ aitemp截图工具/
+ tool/启动.bat
+ nwjs-sdk-v0.64.1-win-x64/nw.exe
+ out/triggers.csv
+ unpacked/data/...
+ ```
+
+3. 先在原游戏中正常保存一次并退出。双击 `tool/启动.bat`,首次选择 **1:只启动面板**,在“存档与沙盒”页创建游戏副本。副本需要额外磁盘空间,默认放在游戏所在盘的 `Nyaruru_shots` 文件夹。
+4. 副本创建完成后,再次双击 `启动.bat`,工具会备份存档并启动副本游戏。在游戏里正常读档进入地图,确认面板顶部连接的是沙盒。
+5. 在面板搜索台词或故事编号,按需要选择“前往”“运行过场”或“只播台词”;摆拍后可点“撤销场景还原”。全文台词在连接游戏后自动读取,断开连接后清空。
+
+面板默认地址为 `http://127.0.0.1:8787`。用完双击 `tool/停止面板.bat`,游戏窗口另行关闭。升级前先退出旧面板和游戏。
+
+建议始终在沙盒里使用:剧情操作可能改变进度,自动存档和手动保存照常生效。“全部还原”会解除临时效果和锁定,但不会回退已经改过的数值、背包或剧情进度;摆拍状态也不保证能通过读档完整恢复。详细说明见包内 `tool/使用须知.md`。
diff --git a/out/triggers.csv b/out/triggers.csv
new file mode 100644
index 0000000..252f6b1
--- /dev/null
+++ b/out/triggers.csv
@@ -0,0 +1,898 @@
+故事序号,来源,地图,事件ID,事件名,坐标,页,触发方式,条件,位置,备注
+0,JS:_storyId=,,,,,,,,js/nya/nya_achieves.js:656,
+0,JS:_storyId=,,,,,,,,js/nya/nya_achieves.js:787,
+0,JS:_beforeDestoryTime=,,,,,,,,js/nya/nya_ai.js:8896,
+0,JS:_beforeDestoryTime=,,,,,,,,js/nya/nya_ai.js:8916,
+0,JS:_floatMessageStoryID=,,,,,,,,js/nya/nya_effect.js:179,
+0,JS:_floatMessageStoryID=,,,,,,,,js/nya/nya_effect.js:325,
+0,JS:_storyTime=,,,,,,,,js/nya/nya_game.js:5154,
+0,JS:storyWaitTime=,,,,,,,,js/nya/nya_statem_ext.js:5653,
+0,JS:storyModeStep=,,,,,,,,js/nya/nya_statem_ext.js:14494,
+0,JS:storyModeStep=,,,,,,,,js/nya/nya_statem_ext.js:14617,
+1,地图事件,Map004.json MAP004,1,EV001,"(0,0)",1/4,自动执行,无条件,,
+1,JS数组(浮动消息),,,,,,,,js/nya/nya_game_system.js:3027,
+1,JS:storyModeStep=,,,,,,,,js/nya/nya_statem_ext.js:14621,
+2,地图事件,Map012.json MAP012,4,EV004,"(4,11)",1/3,按确定键,无条件,,
+2,JS数组(浮动消息),,,,,,,,js/nya/nya_game_system.js:3027,
+2,JS:storyModeStep=,,,,,,,,js/nya/nya_statem_ext.js:14625,
+3,JS数组(浮动消息),,,,,,,,js/nya/nya_game_system.js:3440,
+3,JS:storyModeStep=,,,,,,,,js/nya/nya_statem_ext.js:14605,
+4,JS:lockTextBalloon(),,,,,,,,js/nya/nya_game.js:3827,
+4,JS数组(浮动消息),,,,,,,,js/nya/nya_game_system.js:3911,
+5,JS数组(浮动消息),,,,,,,,js/nya/nya_game_system.js:3911,
+7,地图事件,Map012.json MAP012,6,EV006,"(1,0)",1/8,自动执行,开关[第一天夜晚莉莉亚回家](需ON),,
+10,地图事件,Map004.json MAP004,3,EV003,"(4,6)",1/1,按确定键,无条件,,
+11,地图事件,Map004.json MAP004,4,EV004,"(3,4)",1/4,按确定键,无条件,,
+11,地图事件,Map004.json MAP004,4,EV004,"(3,4)",2/4,按确定键,开关[前往天空区域](需ON),,
+11,地图事件,Map004.json MAP004,4,EV004,"(3,4)",2/4,按确定键,开关[前往天空区域](需ON),,
+12,地图事件,Map004.json MAP004,5,EV005,"(1,4)",1/6,按确定键,无条件,,
+13,地图事件,Map004.json MAP004,5,EV005,"(1,4)",2/6,按确定键,独立开关A=ON,,
+15,地图事件,Map006.json MAP006,9,EV009,"(0,0)",1/3,自动执行,无条件,,
+16,地图事件,Map006.json MAP006,9,EV009,"(0,0)",1/3,自动执行,无条件,,
+17,地图事件,Map006.json MAP006,9,EV009,"(0,0)",1/3,自动执行,无条件,,
+18,地图事件,Map006.json MAP006,9,EV009,"(0,0)",1/3,自动执行,无条件,,
+18,JS数组(浮动消息),,,,,,,,js/nya/nya_game_system.js:7284,
+19,地图事件,Map006.json MAP006,7,EV007,"(13,9)",1/2,按确定键,无条件,,
+20,地图事件,Map006.json MAP006,12,EV012,"(22,9)",1/2,按确定键,无条件,,
+21,地图事件,Map006.json MAP006,12,EV012,"(22,9)",1/2,按确定键,无条件,,
+22,地图事件,Map006.json MAP006,13,EV013,"(31,9)",1/2,按确定键,无条件,,
+34,地图事件,Map007.json MAP007,5,EV005,"(24,9)",1/2,按确定键,无条件,,
+34,地图事件,Map007.json MAP007,7,EV007,"(26,9)",1/2,按确定键,无条件,,
+35,地图事件,Map007.json MAP007,5,EV005,"(24,9)",1/2,按确定键,无条件,,
+35,地图事件,Map007.json MAP007,7,EV007,"(26,9)",1/2,按确定键,无条件,,
+36,地图事件,Map010.json MAP010,2,EV002,"(6,6)",1/2,按确定键,无条件,,
+39,地图事件,Map009.json MAP009,3,EV003,"(4,9)",1/16,按确定键,无条件,,
+40,公共事件,,3,初遇幽暗森林,,,公共事件#3,开关[开场白完成],,
+41,公共事件,,3,初遇幽暗森林,,,公共事件#3,开关[开场白完成],,
+43,地图事件,Map007.json MAP007,3,EV003,"(7,9)",1/4,按确定键,无条件,,
+44,地图事件,Map012.json MAP012,2,EV002,"(0,0)",1/2,自动执行,开关[带任务进莉莉亚家](需ON),,
+45,地图事件,Map009.json MAP009,3,EV003,"(4,9)",1/16,按确定键,无条件,,
+46,地图事件,Map012.json MAP012,3,EV003,"(13,5)",1/3,按确定键,无条件,,
+46,地图事件,Map012.json MAP012,3,EV003,"(13,5)",2/3,按确定键,开关[前往天空区域](需ON),,
+46,地图事件,Map012.json MAP012,3,EV003,"(13,5)",3/3,按确定键,开关[天空莉莉亚分头行动](需ON),,
+47,地图事件,Map009.json MAP009,4,EV004,"(14,6)",1/2,按确定键,无条件,,
+49,地图事件,Map009.json MAP009,4,EV004,"(14,6)",1/2,按确定键,无条件,,
+57,地图事件,Map008.json MAP008,6,EV006,"(12,7)",1/3,按确定键,队伍含角色#1,,
+57,地图事件,Map024.json MAP024,7,EV007,"(7,8)",1/3,按确定键,队伍含角色#1,,
+57,地图事件,Map027.json MAP027,5,EV005,"(5,5)",1/2,按确定键,无条件,,
+61,公共事件,,7,目前的难度是~~,,,公共事件#7,开关[开场白完成],,
+62,公共事件,,7,目前的难度是~~,,,公共事件#7,开关[开场白完成],,
+63,公共事件,,7,目前的难度是~~,,,公共事件#7,开关[开场白完成],,
+65,公共事件,,8,选择简单难度,,,公共事件#8,开关[开场白完成],,
+65,公共事件,,9,选择普通难度,,,公共事件#9,开关[开场白完成],,
+65,公共事件,,10,选择困难难度,,,公共事件#10,开关[开场白完成],,
+66,地图事件,Map008.json MAP008,6,EV006,"(12,7)",1/3,按确定键,队伍含角色#1,,
+66,地图事件,Map024.json MAP024,7,EV007,"(7,8)",1/3,按确定键,队伍含角色#1,,
+66,地图事件,Map027.json MAP027,5,EV005,"(5,5)",1/2,按确定键,无条件,,
+67,JS:lockTextBalloon(),,,,,,,,js/nya/nya_game.js:3837,
+68,地图事件,Map040.json MAP040,5,EV005,"(12,5)",1/3,按确定键,开关[第一天NPC位置变化1](需ON),,
+69,地图事件,Map040.json MAP040,5,EV005,"(12,5)",1/3,按确定键,开关[第一天NPC位置变化1](需ON),,
+70,地图事件,Map013.json MAP013,3,EV003,"(7,8)",1/3,按确定键,开关[第一天NPC位置变化1](需ON),,
+72,地图事件,Map013.json MAP013,5,EV005,"(4,8)",1/2,按确定键,开关[依铃回到小镇](需ON),,
+72,地图事件,Map013.json MAP013,6,EV006,"(6,8)",1/2,按确定键,开关[依铃回到小镇](需ON),,
+73,地图事件,Map013.json MAP013,6,EV006,"(6,8)",1/2,按确定键,开关[依铃回到小镇](需ON),,
+74,地图事件,Map013.json MAP013,5,EV005,"(4,8)",1/2,按确定键,开关[依铃回到小镇](需ON),,
+75,地图事件,Map032.json MAP032,2,EV002,"(5,4)",1/3,按确定键,开关[第一天NPC位置变化1](需ON),,
+76,地图事件,Map032.json MAP032,2,EV002,"(5,4)",1/3,按确定键,开关[第一天NPC位置变化1](需ON),,
+78,地图事件,Map011.json MAP011,4,EV004,"(6,8)",1/2,按确定键,开关[夏洛克对话后回到小镇](需ON),,
+79,地图事件,Map006.json MAP006,21,EV021,"(20,9)",1/2,按确定键,开关[第一天NPC位置变化1](需ON),,
+80,地图事件,Map006.json MAP006,21,EV021,"(20,9)",1/2,按确定键,开关[第一天NPC位置变化1](需ON),,
+83,地图事件,Map005.json MAP005,6,EV006,"(4,8)",1/2,按确定键,开关[第一天NPC位置变化1](需ON),,
+84,地图事件,Map005.json MAP005,6,EV006,"(4,8)",1/2,按确定键,开关[第一天NPC位置变化1](需ON),,
+87,地图事件,Map005.json MAP005,8,EV008,"(20,8)",1/2,按确定键,开关[第一天NPC位置变化1](需ON),,
+88,地图事件,Map005.json MAP005,8,EV008,"(20,8)",1/2,按确定键,开关[第一天NPC位置变化1](需ON),,
+91,地图事件,Map032.json MAP032,10,EV010,"(0,0)",1/1,按确定键,无条件,,
+92,地图事件,Map039.json MAP039,3,EV003,"(0,0)",1/4,自动执行,无条件,,
+93,地图事件,Map044.json MAP044,4,EV004,"(10,4)",1/3,按确定键,无条件,,
+94,地图事件,Map044.json MAP044,8,EV008,"(1,0)",1/2,自动执行,开关[莉莉亚boss战-胜利](需ON),,
+95,地图事件,Map044.json MAP044,10,EV010,"(3,4)",1/2,按确定键,开关[莉莉亚boss战-胜利](需ON),,
+96,地图事件,Map009.json MAP009,4,EV004,"(14,6)",1/2,按确定键,无条件,,
+101,地图事件,Map042.json MAP042,7,EV007,"(0,0)",1/2,自动执行,开关[莉莉亚boss战-胜利](需ON),,
+104,地图事件,Map048.json MAP048,4,EV004,"(6,6)",1/2,按确定键,开关[老板娘在校长室](需ON),,
+105,地图事件,Map048.json MAP048,4,EV004,"(6,6)",1/2,按确定键,开关[老板娘在校长室](需ON),,
+106,地图事件,Map048.json MAP048,4,EV004,"(6,6)",1/2,按确定键,开关[老板娘在校长室](需ON),,
+107,地图事件,Map057.json MAP057,24,EV024,"(54,5)",1/2,按确定键,开关[第一天NPC位置移动2](需ON),,
+108,地图事件,Map057.json MAP057,24,EV024,"(54,5)",1/2,按确定键,开关[第一天NPC位置移动2](需ON),,
+110,地图事件,Map057.json MAP057,35,EV035,"(55,0)",1/3,自动执行,开关[山洞防玩家走岔路提示](需ON) 且 开关[第一天NPC位置移动2](需ON),,
+111,地图事件,Map063.json MAP063,9,EV009,"(3,6)",1/2,按确定键,开关[第一天NPC位置移动2](需ON),,
+114,地图事件,Map067.json MAP067,20,EV020,"(32,9)",1/2,按确定键,开关[第一天NPC位置移动2](需ON),,
+114,地图事件,Map067.json MAP067,21,EV021,"(34,9)",1/2,按确定键,开关[第一天NPC位置移动2](需ON),,
+115,地图事件,Map067.json MAP067,21,EV021,"(34,9)",1/2,按确定键,开关[第一天NPC位置移动2](需ON),,
+117,地图事件,Map067.json MAP067,20,EV020,"(32,9)",1/2,按确定键,开关[第一天NPC位置移动2](需ON),,
+118,地图事件,Map073.json MAP073,10,EV010,"(5,0)",1/4,事件接触,开关[第一天NPC位置移动2](需ON),,
+119,地图事件,Map073.json MAP073,13,EV013,"(5,6)",1/2,按确定键,开关[橘花猫boss战胜利](需ON),,
+120,地图事件,Map073.json MAP073,14,EV014,"(3,0)",1/2,自动执行,开关[橘花猫boss战胜利](需ON),,
+126,地图事件,Map006.json MAP006,17,EV017,"(34,9)",1/1,按确定键,无条件,,
+126,地图事件,Map006.json MAP006,17,EV017,"(34,9)",1/1,按确定键,无条件,,
+126,地图事件,Map006.json MAP006,18,EV018,"(15,9)",1/1,按确定键,无条件,,
+126,地图事件,Map006.json MAP006,18,EV018,"(15,9)",1/1,按确定键,无条件,,
+126,地图事件,Map006.json MAP006,18,EV018,"(15,9)",1/1,按确定键,无条件,,
+126,地图事件,Map006.json MAP006,19,EV019,"(18,9)",1/3,按确定键,无条件,,
+126,地图事件,Map006.json MAP006,19,EV019,"(18,9)",2/3,按确定键,开关[天空区域开启](需ON),,
+126,地图事件,Map006.json MAP006,19,EV019,"(18,9)",3/3,按确定键,开关[第三天开启](需ON),,
+126,地图事件,Map007.json MAP007,4,EV004,"(16,9)",1/2,按确定键,无条件,,
+126,地图事件,Map007.json MAP007,4,EV004,"(16,9)",1/2,按确定键,无条件,,
+126,地图事件,Map007.json MAP007,4,EV004,"(16,9)",2/2,按确定键,开关[第三天夜晚](需ON),,
+126,地图事件,Map039.json MAP039,8,EV008,"(41,29)",1/1,按确定键,无条件,,
+126,地图事件,Map170.json MAP170,5,EV005,"(21,4)",1/5,按确定键,无条件,,
+126,地图事件,Map170.json MAP170,5,EV005,"(21,4)",4/5,按确定键,开关[第三天夜晚](需ON),,
+126,地图事件,Map170.json MAP170,5,EV005,"(21,4)",5/5,按确定键,开关[新后日谈第二天夜晚](需ON),,
+126,地图事件,Map189.json MAP189,4,EV004,"(9,7)",1/3,按确定键,无条件,,
+126,地图事件,Map189.json MAP189,4,EV004,"(9,7)",1/3,按确定键,无条件,,
+126,地图事件,Map189.json MAP189,4,EV004,"(9,7)",1/3,按确定键,无条件,,
+126,地图事件,Map189.json MAP189,4,EV004,"(9,7)",1/3,按确定键,无条件,,
+126,地图事件,Map189.json MAP189,5,EV005,"(29,7)",1/3,按确定键,无条件,,
+126,地图事件,Map189.json MAP189,5,EV005,"(29,7)",1/3,按确定键,无条件,,
+126,地图事件,Map189.json MAP189,5,EV005,"(29,7)",1/3,按确定键,无条件,,
+126,地图事件,Map189.json MAP189,5,EV005,"(29,7)",1/3,按确定键,无条件,,
+126,地图事件,Map296.json MAP296,1,EV001,"(28,14)",1/1,按确定键,无条件,,
+126,地图事件,Map296.json MAP296,1,EV001,"(28,14)",1/1,按确定键,无条件,,
+126,地图事件,Map301.json MAP301,5,EV005,"(11,13)",1/1,按确定键,无条件,,
+126,地图事件,Map381.json MAP381,4,EV004,"(16,12)",4/5,按确定键,开关[莉伊进门缝眼睛](需ON),,
+130,JS数组(浮动消息),,,,,,,,js/nya/nya_ai.js:655,
+130,JS数组(浮动消息),,,,,,,,js/nya/nya_ai.js:659,
+130,JS数组(浮动消息),,,,,,,,js/nya/nya_ai.js:663,
+130,JS数组(浮动消息),,,,,,,,js/nya/nya_ai.js:667,
+131,JS数组(浮动消息),,,,,,,,js/nya/nya_ai.js:655,
+131,JS数组(浮动消息),,,,,,,,js/nya/nya_ai.js:659,
+131,JS数组(浮动消息),,,,,,,,js/nya/nya_ai.js:663,
+131,JS数组(浮动消息),,,,,,,,js/nya/nya_ai.js:667,
+132,JS数组(浮动消息),,,,,,,,js/nya/nya_ai.js:655,
+132,JS数组(浮动消息),,,,,,,,js/nya/nya_ai.js:659,
+132,JS数组(浮动消息),,,,,,,,js/nya/nya_ai.js:663,
+132,JS数组(浮动消息),,,,,,,,js/nya/nya_ai.js:667,
+133,JS数组(浮动消息),,,,,,,,js/nya/nya_ai.js:667,
+134,JS数组(浮动消息),,,,,,,,js/nya/nya_ai.js:667,
+135,JS数组(浮动消息),,,,,,,,js/nya/nya_ai.js:667,
+138,JS:requestUniqueFloatMessage(),,,,,,,,js/nya/nya_ai.js:852,
+139,JS数组(浮动消息),,,,,,,,js/nya/nya_ai.js:659,
+140,JS数组(浮动消息),,,,,,,,js/nya/nya_ai.js:659,
+141,JS数组(浮动消息),,,,,,,,js/nya/nya_ai.js:659,
+143,JS:requestUniqueFloatMessage(),,,,,,,,js/nya/nya_ai.js:791,
+145,JS数组(浮动消息),,,,,,,,js/nya/nya_ai.js:663,
+146,JS数组(浮动消息),,,,,,,,js/nya/nya_ai.js:663,
+147,JS数组(浮动消息),,,,,,,,js/nya/nya_ai.js:663,
+150,地图事件,Map170.json MAP170,5,EV005,"(21,4)",1/5,按确定键,无条件,,
+151,地图事件,Map039.json MAP039,15,EV015,"(33,25)",1/2,事件接触,开关[第一天夜晚门锁](需ON) 且 开关[从天台进入学校外景](需ON),,
+152,JS数组(浮动消息),,,,,,,,js/nya/nya_ai.js:655,
+153,JS数组(浮动消息),,,,,,,,js/nya/nya_ai.js:655,
+154,JS数组(浮动消息),,,,,,,,js/nya/nya_ai.js:655,
+155,公共事件,,95,第一天晚上回家,,,公共事件#95,开关[开场白完成],,
+156,地图事件,Map004.json MAP004,8,EV008,"(2,0)",1/8,自动执行,开关[第一天夜晚莉莉亚回家](需ON),,
+157,地图事件,Map004.json MAP004,7,EV007,"(5,6)",1/4,按确定键,开关[第一天夜晚莉莉亚回家](需ON),,
+158,公共事件,,16,选择简单难度2,,,公共事件#16,开关[开场白完成],,
+158,公共事件,,17,选择普通难度2,,,公共事件#17,开关[开场白完成],,
+158,公共事件,,18,选择困难难度2,,,公共事件#18,开关[开场白完成],,
+162,地图事件,Map004.json MAP004,5,EV005,"(1,4)",3/6,按确定键,开关[第一天夜晚门锁](需ON),,
+163,地图事件,Map004.json MAP004,8,EV008,"(2,0)",3/8,自动执行,开关[第二天开启](需ON),,
+164,地图事件,Map012.json MAP012,6,EV006,"(1,0)",1/8,自动执行,开关[第一天夜晚莉莉亚回家](需ON),,
+165,地图事件,Map012.json MAP012,6,EV006,"(1,0)",1/8,自动执行,开关[第一天夜晚莉莉亚回家](需ON),,
+166,地图事件,Map012.json MAP012,6,EV006,"(1,0)",1/8,自动执行,开关[第一天夜晚莉莉亚回家](需ON),,
+167,地图事件,Map012.json MAP012,5,EV005,"(13,6)",1/2,按确定键,开关[第一天夜晚莉莉亚回家](需ON),,
+168,公共事件,,22,山洞校长主线,,,公共事件#22,开关[开场白完成],,
+169,地图事件,Map007.json MAP007,3,EV003,"(7,9)",1/4,按确定键,无条件,,
+170,地图事件,Map004.json MAP004,4,EV004,"(3,4)",1/4,按确定键,无条件,,
+171,地图事件,Map047.json MAP047,3,EV003,"(5,4)",1/2,按确定键,开关[第一天夜晚](需ON),,
+172,地图事件,Map047.json MAP047,3,EV003,"(5,4)",1/2,按确定键,开关[第一天夜晚](需ON),,
+172,地图事件,Map047.json MAP047,3,EV003,"(5,4)",1/2,按确定键,开关[第一天夜晚](需ON),,
+173,地图事件,Map047.json MAP047,3,EV003,"(5,4)",1/2,按确定键,开关[第一天夜晚](需ON),,
+176,地图事件,Map107.json MAP107,4,EV004,"(4,0)",1/3,事件接触,开关[第二天开启](需ON),,
+177,地图事件,Map107.json MAP107,5,EV005,"(1,0)",2/3,自动执行,开关[水底莉莉亚胜利](需ON),,
+178,地图事件,Map107.json MAP107,6,EV006,"(6,7)",1/2,按确定键,开关[水底莉莉亚胜利](需ON),,
+179,地图事件,Map107.json MAP107,6,EV006,"(6,7)",1/2,按确定键,开关[水底莉莉亚胜利](需ON),,
+181,地图事件,Map108.json MAP108,6,EV006,"(1,0)",1/2,自动执行,开关[水底莉莉亚胜利](需ON),,
+182,地图事件,Map049.json MAP049,3,EV003,"(8,7)",1/2,按确定键,开关[前往天空区域](需ON),,
+183,地图事件,Map049.json MAP049,3,EV003,"(8,7)",1/2,按确定键,开关[前往天空区域](需ON),,
+183,地图事件,Map049.json MAP049,3,EV003,"(8,7)",1/2,按确定键,开关[前往天空区域](需ON),,
+184,地图事件,Map049.json MAP049,3,EV003,"(8,7)",1/2,按确定键,开关[前往天空区域](需ON),,
+185,地图事件,Map076.json MAP076,3,EV003,"(5,10)",1/2,按确定键,开关[第二天开启](需ON),,
+186,地图事件,Map097.json MAP097,16,EV016,"(11,9)",1/2,按确定键,开关[第二天开启](需ON),,
+187,地图事件,Map097.json MAP097,16,EV016,"(11,9)",1/2,按确定键,开关[第二天开启](需ON),,
+187,地图事件,Map126.json MAP126,6,EV006,"(16,6)",1/2,按确定键,开关[天空区域开启](需ON),,
+188,地图事件,Map037.json MAP037,15,EV015,"(47,0)",1/2,事件接触,开关[前往天空区域](需ON),,
+194,地图事件,Map034.json MAP034,14,EV014,"(32,6)",3/4,按确定键,开关[第一天夜晚](需ON),,
+194,公共事件,,26,传送门公共事件,,,公共事件#26,开关[开场白完成],,
+195,地图事件,Map110.json MAP110,2,EV002,"(0,0)",1/2,自动执行,开关[大蘑菇出现](需ON),,
+197,公共事件,,32,水底依铃抓鱼任务,,,公共事件#32,开关[开场白完成],,
+198,公共事件,,28,水底依铃支线分支判断,,,公共事件#28,开关[开场白完成],,
+199,公共事件,,28,水底依铃支线分支判断,,,公共事件#28,开关[开场白完成],,
+200,地图事件,Map102.json MAP102,9,EV009,"(13,6)",1/3,按确定键,开关[第二天开启](需ON),,
+201,地图事件,Map102.json MAP102,10,EV010,"(15,6)",1/3,按确定键,开关[第二天开启](需ON),,
+204,地图事件,Map093.json MAP093,61,EV061,"(11,28)",1/2,按确定键,开关[第二天开启](需ON),,
+205,地图事件,Map093.json MAP093,61,EV061,"(11,28)",1/2,按确定键,开关[第二天开启](需ON),,
+206,地图事件,Map093.json MAP093,61,EV061,"(11,28)",1/2,按确定键,开关[第二天开启](需ON),,
+209,公共事件,,34,水底露茜亚梅卡闲聊,,,公共事件#34,开关[开场白完成],,
+210,公共事件,,34,水底露茜亚梅卡闲聊,,,公共事件#34,开关[开场白完成],,
+211,地图事件,Map094.json MAP094,53,EV053,"(13,30)",1/2,按确定键,开关[第二天开启](需ON),,
+212,地图事件,Map094.json MAP094,54,EV054,"(15,30)",1/2,按确定键,开关[第二天开启](需ON),,
+217,地图事件,Map093.json MAP093,63,EV063,"(9,28)",1/2,按确定键,无条件,,
+217,地图事件,Map093.json MAP093,63,EV063,"(9,28)",2/2,按确定键,开关[水底关密码锁打开](需ON),,
+218,地图事件,Map093.json MAP093,63,EV063,"(9,28)",1/2,按确定键,无条件,,
+219,地图事件,Map093.json MAP093,63,EV063,"(9,28)",1/2,按确定键,无条件,,
+220,地图事件,Map089.json MAP089,7,EV007,"(0,0)",1/2,自动执行,开关[水底关密码锁打开](需ON),,
+221,地图事件,Map089.json MAP089,7,EV007,"(0,0)",1/2,自动执行,开关[水底关密码锁打开](需ON),,
+222,地图事件,Map006.json MAP006,33,EV033,"(13,4)",1/2,按确定键,开关[第二天开启](需ON),,
+226,地图事件,Map009.json MAP009,3,EV003,"(4,9)",3/16,按确定键,开关[前往天空区域](需ON),,
+227,地图事件,Map004.json MAP004,4,EV004,"(3,4)",2/4,按确定键,开关[前往天空区域](需ON),,
+228,地图事件,Map004.json MAP004,5,EV005,"(1,4)",4/6,按确定键,开关[前往天空区域](需ON),,
+229,地图事件,Map012.json MAP012,3,EV003,"(13,5)",2/3,按确定键,开关[前往天空区域](需ON),,
+230,JS数组(浮动消息),,,,,,,,js/nya/nya_ai.js:680,
+231,JS数组(浮动消息),,,,,,,,js/nya/nya_ai.js:680,
+232,JS数组(浮动消息),,,,,,,,js/nya/nya_ai.js:680,
+233,JS数组(浮动消息),,,,,,,,js/nya/nya_ai.js:680,
+234,JS数组(浮动消息),,,,,,,,js/nya/nya_ai.js:680,
+235,JS:triggerUniqueFloatMessage(),,,,,,,,js/nya/nya_statem_ext.js:2234,
+237,JS数组(浮动消息),,,,,,,,js/nya/nya_game_system.js:9736,
+238,地图事件,Map004.json MAP004,5,EV005,"(1,4)",4/6,按确定键,开关[前往天空区域](需ON),,
+238,地图事件,Map004.json MAP004,5,EV005,"(1,4)",4/6,按确定键,开关[前往天空区域](需ON),,
+238,地图事件,Map004.json MAP004,5,EV005,"(1,4)",6/6,按确定键,开关[第三天开启](需ON),,
+239,地图事件,Map013.json MAP013,8,EV008,"(6,4)",1/2,按确定键,开关[依铃蕾铃从水底回家](需ON),,
+240,地图事件,Map013.json MAP013,7,EV007,"(3,8)",1/2,按确定键,开关[依铃蕾铃从水底回家](需ON),,
+241,地图事件,Map113.json MAP113,7,EV007,"(0,0)",1/2,自动执行,开关[天空区域开启](需ON),,
+242,地图事件,Map152.json MAP152,5,EV005,"(0,0)",1/3,自动执行,开关[天空区域开启](需ON),,
+243,地图事件,Map006.json MAP006,19,EV019,"(18,9)",2/3,按确定键,开关[天空区域开启](需ON),,
+244,地图事件,Map066.json MAP066,9,EV009,"(11,6)",1/2,按确定键,开关[天空区域开启](需ON),,
+245,公共事件,,39,梅卡喂猫牛对话判断,,,公共事件#39,开关[开场白完成],,
+246,公共事件,,39,梅卡喂猫牛对话判断,,,公共事件#39,开关[开场白完成],,
+251,地图事件,Map171.json MAP171,4,EV004,"(13,16)",1/2,按确定键,开关[老板娘战后对话完](需ON),,
+252,地图事件,Map113.json MAP113,14,EV014,"(2,34)",1/3,按确定键,开关[天空区域开启](需ON),,
+253,地图事件,Map113.json MAP113,14,EV014,"(2,34)",1/3,按确定键,开关[天空区域开启](需ON),,
+254,地图事件,Map126.json MAP126,6,EV006,"(16,6)",1/2,按确定键,开关[天空区域开启](需ON),,
+255,地图事件,Map010.json MAP010,5,EV005,"(8,6)",1/2,按确定键,开关[天空区域开启](需ON),,
+256,地图事件,Map039.json MAP039,17,EV017,"(2,0)",1/2,自动执行,开关[老板娘BOSS战胜利](需ON),,
+257,地图事件,Map039.json MAP039,18,EV018,"(70,49)",1/2,按确定键,开关[老板娘BOSS战胜利](需ON),,
+258,地图事件,Map170.json MAP170,5,EV005,"(21,4)",1/5,按确定键,无条件,,
+259,地图事件,Map171.json MAP171,3,EV003,"(1,0)",1/2,自动执行,开关[老板娘战后对话完](需ON),,
+260,公共事件,,42,烤鱼派对小演出,,,公共事件#42,开关[开场白完成],,
+261,公共事件,,42,烤鱼派对小演出,,,公共事件#42,开关[开场白完成],,
+262,公共事件,,42,烤鱼派对小演出,,,公共事件#42,开关[开场白完成],,
+263,地图事件,Map007.json MAP007,3,EV003,"(7,9)",1/4,按确定键,无条件,,
+263,地图事件,Map007.json MAP007,4,EV004,"(16,9)",1/2,按确定键,无条件,,
+264,地图事件,Map004.json MAP004,8,EV008,"(2,0)",5/8,自动执行,开关[老板娘战后对话完](需ON),,
+265,地图事件,Map039.json MAP039,33,EV033,"(52,28)",1/2,事件接触,开关[烤鱼派对结束](需ON),,
+266,地图事件,Map039.json MAP039,31,EV031,"(52,29)",2/4,按确定键,开关[烤鱼派对结束](需ON),,
+267,地图事件,Map171.json MAP171,6,EV006,"(14,16)",1/1,按确定键,无条件,,
+268,地图事件,Map171.json MAP171,8,EV008,"(5,12)",1/1,按确定键,无条件,,
+269,地图事件,Map171.json MAP171,10,EV010,"(11,8)",1/1,按确定键,无条件,,
+270,地图事件,Map171.json MAP171,11,EV011,"(13,13)",1/1,按确定键,无条件,,
+271,地图事件,Map171.json MAP171,9,EV009,"(3,17)",1/1,按确定键,无条件,,
+272,地图事件,Map171.json MAP171,7,EV007,"(16,7)",1/2,按确定键,开关[老板娘战后对话完](需ON),,
+273,地图事件,Map171.json MAP171,7,EV007,"(16,7)",1/2,按确定键,开关[老板娘战后对话完](需ON),,
+274,地图事件,Map007.json MAP007,4,EV004,"(16,9)",1/2,按确定键,无条件,,
+275,地图事件,Map155.json MAP155,3,EV003,"(0,0)",1/2,自动执行,开关[结界入口开启](需ON),,
+276,JS数组(浮动消息),,,,,,,,js/nya/nya_ai.js:695,
+277,JS:triggerUniqueFloatMessage(),,,,,,,,js/nya/nya_statem_ext.js:4537,
+278,JS数组(浮动消息),,,,,,,,js/nya/nya_ai.js:695,
+279,JS数组(浮动消息),,,,,,,,js/nya/nya_ai.js:695,
+280,JS数组(浮动消息),,,,,,,,js/nya/nya_ai.js:695,
+281,地图事件,Map039.json MAP039,34,EV034,"(52,27)",1/2,按确定键,开关[结界入口开启](需ON),,
+281,地图事件,Map217.json MAP217,7,EV007,"(11,8)",1/2,按确定键,开关[德古拉准备开启结界](需ON),,
+282,JS:lockTextBalloon(),,,,,,,,js/nya/nya_game.js:3829,
+283,JS:lockTextBalloon(),,,,,,,,js/nya/nya_game.js:3839,
+284,地图事件,Map039.json MAP039,31,EV031,"(52,29)",2/4,按确定键,开关[烤鱼派对结束](需ON),,
+285,地图事件,Map169.json MAP169,26,EV026,"(2,0)",2/4,自动执行,独立开关A=ON,,
+286,地图事件,Map169.json MAP169,24,EV024,"(53,12)",3/4,自动执行,开关[恶魔墙胜利](需ON),,
+287,地图事件,Map172.json MAP172,10,EV010,"(0,0)",1/2,自动执行,开关[恶魔墙胜利](需ON),,
+288,地图事件,Map173.json MAP173,11,EV011,"(2,22)",1/2,按确定键,开关[老板娘战后对话完](需ON),,
+289,地图事件,Map039.json MAP039,31,EV031,"(52,29)",2/4,按确定键,开关[烤鱼派对结束](需ON),,
+290,地图事件,Map173.json MAP173,11,EV011,"(2,22)",1/2,按确定键,开关[老板娘战后对话完](需ON),,
+290,地图事件,Map248.json MAP248,33,EV033,"(5,56)",1/2,按确定键,开关[德古拉准备开启结界](需ON),,
+290,地图事件,Map282.json MAP282,21,EV021,"(65,7)",1/2,按确定键,开关[第四天开启](需ON),,
+291,地图事件,Map181.json MAP181,19,EV019,"(5,0)",1/2,自动执行,开关[恶魔墙胜利](需ON),,
+292,地图事件,Map181.json MAP181,2,EV002,"(20,20)",1/5,按确定键,开关[恶魔墙胜利](需ON),,
+293,地图事件,Map173.json MAP173,12,EV012,"(1,0)",1/2,自动执行,开关[恶魔墙胜利](需ON),,
+294,地图事件,Map171.json MAP171,13,EV013,"(2,0)",1/3,自动执行,开关[结界入口开启](需ON),,
+295,地图事件,Map171.json MAP171,4,EV004,"(13,16)",1/2,按确定键,开关[老板娘战后对话完](需ON),,
+296,地图事件,Map171.json MAP171,7,EV007,"(16,7)",1/2,按确定键,开关[老板娘战后对话完](需ON),,
+297,地图事件,Map171.json MAP171,7,EV007,"(16,7)",1/2,按确定键,开关[老板娘战后对话完](需ON),,
+298,地图事件,Map182.json MAP182,3,EV003,"(0,0)",1/6,自动执行,开关[喵朵露BOSS战胜利](需ON),,
+299,地图事件,Map182.json MAP182,3,EV003,"(0,0)",1/6,自动执行,开关[喵朵露BOSS战胜利](需ON),,
+300,地图事件,Map174.json MAP174,217,EV217,"(26,46)",1/2,按确定键,开关[莉莉亚走丢](需ON),,
+301,地图事件,Map039.json MAP039,31,EV031,"(52,29)",3/4,事件接触,开关[封印结界调查完毕](需ON),,
+302,地图事件,Map007.json MAP007,3,EV003,"(7,9)",1/4,按确定键,无条件,,
+303,地图事件,Map004.json MAP004,8,EV008,"(2,0)",7/8,自动执行,开关[封印结界调查完毕](需ON),,
+304,地图事件,Map004.json MAP004,5,EV005,"(1,4)",5/6,按确定键,开关[封印结界调查完毕](需ON),,
+305,地图事件,Map182.json MAP182,3,EV003,"(0,0)",1/6,自动执行,开关[喵朵露BOSS战胜利](需ON),,
+308,地图事件,Map182.json MAP182,4,EV004,"(12,11)",1/6,按确定键,开关[恶魔墙胜利](需ON),,
+308,地图事件,Map182.json MAP182,4,EV004,"(12,11)",2/6,按确定键,开关[恶魔墙胜利](需ON) 且 独立开关A=ON,,
+308,地图事件,Map182.json MAP182,4,EV004,"(12,11)",3/6,按确定键,开关[恶魔墙胜利](需ON) 且 独立开关B=ON,,
+309,地图事件,Map182.json MAP182,4,EV004,"(12,11)",1/6,按确定键,开关[恶魔墙胜利](需ON),,
+310,地图事件,Map182.json MAP182,4,EV004,"(12,11)",2/6,按确定键,开关[恶魔墙胜利](需ON) 且 独立开关A=ON,,
+311,地图事件,Map182.json MAP182,4,EV004,"(12,11)",3/6,按确定键,开关[恶魔墙胜利](需ON) 且 独立开关B=ON,,
+312,地图事件,Map182.json MAP182,4,EV004,"(12,11)",4/6,按确定键,开关[恶魔墙胜利](需ON) 且 独立开关C=ON,,
+313,地图事件,Map006.json MAP006,24,EV024,"(4,9)",1/2,按确定键,开关[第三天开启](需ON),,
+313,地图事件,Map006.json MAP006,25,EV025,"(6,9)",1/2,按确定键,开关[第三天开启](需ON),,
+314,地图事件,Map006.json MAP006,25,EV025,"(6,9)",1/2,按确定键,开关[第三天开启](需ON),,
+315,地图事件,Map006.json MAP006,24,EV024,"(4,9)",1/2,按确定键,开关[第三天开启](需ON),,
+320,地图事件,Map004.json MAP004,7,EV007,"(5,6)",3/4,按确定键,开关[封印结界调查完毕](需ON),,
+322,地图事件,Map007.json MAP007,3,EV003,"(7,9)",2/4,按确定键,开关[第三天开启](需ON),,
+323,地图事件,Map007.json MAP007,3,EV003,"(7,9)",2/4,按确定键,开关[第三天开启](需ON),,
+324,地图事件,Map135.json MAP135,5,EV005,"(25,6)",1/2,按确定键,开关[第三天开启](需ON),,
+324,地图事件,Map135.json MAP135,6,EV006,"(26,6)",1/2,按确定键,开关[第三天开启](需ON),,
+325,地图事件,Map135.json MAP135,6,EV006,"(26,6)",1/2,按确定键,开关[第三天开启](需ON),,
+326,地图事件,Map135.json MAP135,5,EV005,"(25,6)",1/2,按确定键,开关[第三天开启](需ON),,
+327,地图事件,Map009.json MAP009,3,EV003,"(4,9)",5/16,按确定键,开关[第三天开启](需ON),,
+328,地图事件,Map039.json MAP039,35,EV035,"(32,15)",1/3,按确定键,开关[第三天开启](需ON),,
+329,地图事件,Map039.json MAP039,35,EV035,"(32,15)",1/3,按确定键,开关[第三天开启](需ON),,
+330,地图事件,Map192.json MAP192,10,EV010,"(1,0)",1/2,自动执行,开关[莲boss战准备](需ON),,
+331,地图事件,Map192.json MAP192,20,EV020,"(18,7)",1/2,自动执行,开关[莲boss战胜利](需ON),,莲BOSS战胜利后的NPC事件
+332,地图事件,Map191.json MAP191,3,EV003,"(4,5)",1/3,按确定键,开关[莲boss战胜利](需ON),,
+334,地图事件,Map191.json MAP191,3,EV003,"(4,5)",1/3,按确定键,开关[莲boss战胜利](需ON),,
+335,地图事件,Map191.json MAP191,3,EV003,"(4,5)",1/3,按确定键,开关[莲boss战胜利](需ON),,
+336,地图事件,Map191.json MAP191,3,EV003,"(4,5)",1/3,按确定键,开关[莲boss战胜利](需ON),,
+337,地图事件,Map191.json MAP191,3,EV003,"(4,5)",1/3,按确定键,开关[莲boss战胜利](需ON),,
+338,地图事件,Map191.json MAP191,3,EV003,"(4,5)",1/3,按确定键,开关[莲boss战胜利](需ON),,
+339,地图事件,Map191.json MAP191,3,EV003,"(4,5)",1/3,按确定键,开关[莲boss战胜利](需ON),,
+340,地图事件,Map184.json MAP184,498,EV498,"(32,142)",1/2,按确定键,开关[第三天开启](需ON),,
+341,地图事件,Map184.json MAP184,498,EV498,"(32,142)",1/2,按确定键,开关[第三天开启](需ON),,
+342,地图事件,Map184.json MAP184,497,EV497,"(64,0)",1/3,自动执行,变量[莲对话进度]>=1,,
+343,地图事件,Map006.json MAP006,38,EV038,"(32,9)",1/3,按确定键,开关[恶魔城莉莉亚入队](需ON),,
+344,地图事件,Map006.json MAP006,38,EV038,"(32,9)",1/3,按确定键,开关[恶魔城莉莉亚入队](需ON),,
+345,地图事件,Map007.json MAP007,3,EV003,"(7,9)",3/4,按确定键,变量[莲对话进度]>=1,,
+346,地图事件,Map012.json MAP012,6,EV006,"(1,0)",4/8,自动执行,变量[莲对话进度]>=1,,
+347,公共事件,,26,传送门公共事件,,,公共事件#26,开关[开场白完成],,
+347,公共事件,,26,传送门公共事件,,,公共事件#26,开关[开场白完成],,
+347,公共事件,,26,传送门公共事件,,,公共事件#26,开关[开场白完成],,
+348,地图事件,Map098.json MAP098,159,EV159,"(4,6)",1/3,按确定键,变量[莲对话进度]>=1,,
+349,地图事件,Map098.json MAP098,161,EV161,"(7,6)",1/3,按确定键,变量[莲对话进度]>=1,,
+350,地图事件,Map039.json MAP039,50,EV050,"(74,49)",1/3,按确定键,变量[莲对话进度]>=1,,
+350,地图事件,Map039.json MAP039,51,EV051,"(76,51)",1/3,按确定键,变量[莲对话进度]>=1,,
+351,地图事件,Map039.json MAP039,50,EV050,"(74,49)",1/3,按确定键,变量[莲对话进度]>=1,,
+352,地图事件,Map039.json MAP039,51,EV051,"(76,51)",1/3,按确定键,变量[莲对话进度]>=1,,
+353,地图事件,Map045.json MAP045,5,EV005,"(6,5)",1/3,按确定键,变量[莲对话进度]>=1,,
+354,地图事件,Map045.json MAP045,5,EV005,"(6,5)",1/3,按确定键,变量[莲对话进度]>=1,,
+355,地图事件,Map045.json MAP045,5,EV005,"(6,5)",1/3,按确定键,变量[莲对话进度]>=1,,
+356,地图事件,Map191.json MAP191,3,EV003,"(4,5)",1/3,按确定键,开关[莲boss战胜利](需ON),,
+357,地图事件,Map191.json MAP191,3,EV003,"(4,5)",1/3,按确定键,开关[莲boss战胜利](需ON),,
+358,地图事件,Map191.json MAP191,3,EV003,"(4,5)",1/3,按确定键,开关[莲boss战胜利](需ON),,
+359,地图事件,Map214.json MAP214,1,EV001,"(41,25)",1/2,事件接触,开关[恶魔城莉莉亚入队](需ON),,
+359,地图事件,Map223.json MAP223,3,EV003,"(0,0)",1/2,自动执行,开关[恶魔城莉莉亚入队](需ON),,
+360,地图事件,Map224.json MAP224,5,EV005,"(9,0)",1/3,事件接触,开关[恶魔城莉莉亚离队](需ON),,
+361,地图事件,Map223.json MAP223,5,EV005,"(4,6)",1/2,按确定键,开关[恶魔城莉莉亚入队](需ON),,
+362,地图事件,Map223.json MAP223,5,EV005,"(4,6)",1/2,按确定键,开关[恶魔城莉莉亚入队](需ON),,
+363,地图事件,Map196.json MAP196,3,EV003,"(0,0)",1/2,自动执行,开关[德古拉胜利](需ON),,
+364,地图事件,Map196.json MAP196,3,EV003,"(0,0)",1/2,自动执行,开关[德古拉胜利](需ON),,
+365,地图事件,Map196.json MAP196,2,EV002,"(4,7)",1/3,按确定键,开关[德古拉胜利](需ON),,
+366,地图事件,Map224.json MAP224,10,EV010,"(7,8)",1/2,按确定键,开关[管家来到大厅](需ON),,
+367,地图事件,Map004.json MAP004,11,EV011,"(5,0)",1/2,自动执行,开关[德古拉交谈完](需ON),,
+367,地图事件,Map006.json MAP006,43,EV043,"(4,0)",1/2,自动执行,开关[德古拉交谈完](需ON),,
+367,地图事件,Map223.json MAP223,6,EV006,"(1,0)",1/2,自动执行,开关[德古拉交谈完](需ON),,
+368,地图事件,Map196.json MAP196,2,EV002,"(4,7)",2/3,按确定键,开关[喵露露折返](需ON),,
+369,地图事件,Map196.json MAP196,2,EV002,"(4,7)",2/3,按确定键,开关[喵露露折返](需ON),,
+370,地图事件,Map217.json MAP217,7,EV007,"(11,8)",1/2,按确定键,开关[德古拉准备开启结界](需ON),,
+372,地图事件,Map170.json MAP170,5,EV005,"(21,4)",2/5,按确定键,开关[封印结界调查完毕](需ON),,
+374,公共事件,,20,喵露露还钱,,,公共事件#20,开关[开场白完成],,
+375,公共事件,,20,喵露露还钱,,,公共事件#20,开关[开场白完成],,
+376,地图事件,Map188.json MAP188,7,EV007,"(21,8)",1/3,按确定键,开关[天空莉莉亚分头行动](需ON),,
+377,地图事件,Map188.json MAP188,7,EV007,"(21,8)",1/3,按确定键,开关[天空莉莉亚分头行动](需ON),,
+378,地图事件,Map192.json MAP192,23,EV023,"(5,7)",1/2,按确定键,开关[图书馆莉莉亚被推下去](需ON),,
+379,地图事件,Map007.json MAP007,15,EV015,"(30,9)",1/3,按确定键,开关[老板娘战后对话完](需ON),,
+380,地图事件,Map170.json MAP170,6,EV006,"(14,4)",1/1,按确定键,无条件,,
+381,地图事件,Map007.json MAP007,3,EV003,"(7,9)",1/4,按确定键,无条件,,
+382,地图事件,Map215.json MAP215,2,EV002,"(7,29)",1/2,按确定键,开关[恶魔城莉莉亚离队](需ON),,
+383,地图事件,Map215.json MAP215,2,EV002,"(7,29)",1/2,按确定键,开关[恶魔城莉莉亚离队](需ON),,
+384,地图事件,Map239.json MAP239,11,EV011,"(0,0)",1/2,自动执行,开关[德古拉准备开启结界](需ON),,
+385,地图事件,Map264.json MAP264,14,EV014,"(4,0)",1/3,事件接触,开关[初遇结界2](需ON),,
+386,地图事件,Map264.json MAP264,18,EV018,"(3,9)",2/3,按确定键,开关[心魔莉莉亚胜利](需ON),,
+387,地图事件,Map182.json MAP182,3,EV003,"(0,0)",3/6,自动执行,开关[心魔莉莉亚胜利](需ON),,
+388,地图事件,Map004.json MAP004,10,EV010,"(4,0)",3/4,自动执行,开关[第二次离开结界](需ON),,
+389,地图事件,Map004.json MAP004,10,EV010,"(4,0)",3/4,自动执行,开关[第二次离开结界](需ON),,
+390,地图事件,Map039.json MAP039,52,EV052,"(45,41)",1/2,按确定键,开关[第四天开启](需ON),,
+391,地图事件,Map284.json MAP284,16,EV016,"(15,20)",1/2,事件接触,开关[雪原任务开启](需ON),,
+392,地图事件,Map295.json MAP295,2,EV002,"(4,0)",1/3,事件接触,开关[雪原任务开启](需ON),,
+393,地图事件,Map296.json MAP296,9,EV009,"(13,18)",1/2,按确定键,开关[心渊BOSS战胜利](需ON),,
+394,地图事件,Map296.json MAP296,9,EV009,"(13,18)",1/2,按确定键,开关[心渊BOSS战胜利](需ON),,
+395,地图事件,Map007.json MAP007,3,EV003,"(7,9)",4/4,按确定键,开关[恶魔城莉莉亚入队](需ON),,
+396,地图事件,Map009.json MAP009,3,EV003,"(4,9)",7/16,按确定键,开关[第三天夜晚](需ON),,
+397,地图事件,Map009.json MAP009,3,EV003,"(4,9)",8/16,按确定键,开关[第四天开启](需ON),,
+398,地图事件,Map248.json MAP248,33,EV033,"(5,56)",1/2,按确定键,开关[德古拉准备开启结界](需ON),,
+399,地图事件,Map282.json MAP282,21,EV021,"(65,7)",1/2,按确定键,开关[第四天开启](需ON),,
+400,地图事件,Map039.json MAP039,4,EV004,"(54,29)",1/4,按确定键,开关[前往天空区域](需ON),,
+401,地图事件,Map039.json MAP039,31,EV031,"(52,29)",1/4,按确定键,开关[烤鱼派对开始](需ON),,
+402,地图事件,Map170.json MAP170,7,EV007,"(17,4)",1/2,按确定键,开关[第二天开启](需ON),,
+403,地图事件,Map170.json MAP170,5,EV005,"(21,4)",1/5,按确定键,无条件,,
+404,地图事件,Map009.json MAP009,7,EV007,"(8,10)",1/2,按确定键,开关[第四天开启](需ON),,
+404,地图事件,Map009.json MAP009,7,EV007,"(8,10)",1/2,按确定键,开关[第四天开启](需ON),,
+405,地图事件,Map009.json MAP009,7,EV007,"(8,10)",1/2,按确定键,开关[第四天开启](需ON),,
+405,地图事件,Map009.json MAP009,7,EV007,"(8,10)",1/2,按确定键,开关[第四天开启](需ON),,
+406,地图事件,Map171.json MAP171,15,EV015,"(6,11)",1/2,按确定键,开关[雪原任务开启](需ON),,
+407,地图事件,Map186.json MAP186,2,EV002,"(8,7)",1/2,按确定键,开关[第四天开启](需ON),,
+408,地图事件,Map186.json MAP186,2,EV002,"(8,7)",1/2,按确定键,开关[第四天开启](需ON),,
+409,地图事件,Map186.json MAP186,2,EV002,"(8,7)",1/2,按确定键,开关[第四天开启](需ON),,
+410,地图事件,Map252.json MAP252,2,EV002,"(15,7)",1/1,按确定键,无条件,,
+411,地图事件,Map253.json MAP253,2,EV002,"(16,7)",1/2,按确定键,无条件,,
+412,地图事件,Map187.json MAP187,3,EV003,"(9,6)",1/2,按确定键,开关[第三天夜晚](需ON),,
+413,地图事件,Map213.json MAP213,7,EV007,"(10,7)",1/2,按确定键,开关[第二天开启](需ON),,
+414,地图事件,Map282.json MAP282,22,EV022,"(130,9)",1/2,按确定键,开关[前往天空区域](需ON),,
+414,地图事件,Map282.json MAP282,22,EV022,"(130,9)",1/2,按确定键,开关[前往天空区域](需ON),,
+415,地图事件,Map282.json MAP282,22,EV022,"(130,9)",1/2,按确定键,开关[前往天空区域](需ON),,
+415,地图事件,Map282.json MAP282,22,EV022,"(130,9)",1/2,按确定键,开关[前往天空区域](需ON),,
+416,地图事件,Map171.json MAP171,14,EV014,"(16,12)",1/2,按确定键,开关[第三天开启](需ON),,
+417,地图事件,Map223.json MAP223,1,EV001,"(6,6)",1/2,按确定键,开关[第四天开启](需ON),,
+418,地图事件,Map223.json MAP223,1,EV001,"(6,6)",1/2,按确定键,开关[第四天开启](需ON),,
+419,地图事件,Map223.json MAP223,2,EV002,"(14,10)",1/2,按确定键,开关[第四天开启](需ON),,
+419,地图事件,Map223.json MAP223,2,EV002,"(14,10)",1/2,按确定键,开关[第四天开启](需ON),,
+420,地图事件,Map045.json MAP045,6,EV006,"(10,5)",1/4,自动执行,开关[开场白完成](需ON),,
+421,地图事件,Map005.json MAP005,14,EV014,"(18,8)",1/2,按确定键,开关[第四天开启](需ON),,
+424,地图事件,Map005.json MAP005,15,EV015,"(22,8)",1/2,按确定键,开关[第四天开启](需ON),,
+425,地图事件,Map005.json MAP005,16,EV016,"(24,8)",1/2,按确定键,开关[第四天开启](需ON),,
+428,地图事件,Map005.json MAP005,5,EV005,"(17,8)",1/2,按确定键,开关[第四天开启](需ON),,
+428,地图事件,Map007.json MAP007,30,EV030,"(41,9)",1/2,按确定键,开关[首次进入星之海](需ON),,
+429,地图事件,Map005.json MAP005,5,EV005,"(17,8)",1/2,按确定键,开关[第四天开启](需ON),,
+429,地图事件,Map007.json MAP007,30,EV030,"(41,9)",1/2,按确定键,开关[首次进入星之海](需ON),,
+432,地图事件,Map050.json MAP050,11,EV011,"(10,5)",1/2,按确定键,开关[第四天开启](需ON),,
+433,地图事件,Map050.json MAP050,10,EV010,"(8,5)",1/2,按确定键,开关[第四天开启](需ON),,
+434,地图事件,Map050.json MAP050,10,EV010,"(8,5)",1/2,按确定键,开关[第四天开启](需ON),,
+435,地图事件,Map050.json MAP050,10,EV010,"(8,5)",1/2,按确定键,开关[第四天开启](需ON),,
+436,地图事件,Map009.json MAP009,3,EV003,"(4,9)",8/16,按确定键,开关[第四天开启](需ON),,
+437,地图事件,Map009.json MAP009,7,EV007,"(8,10)",1/2,按确定键,开关[第四天开启](需ON),,
+438,地图事件,Map186.json MAP186,2,EV002,"(8,7)",1/2,按确定键,开关[第四天开启](需ON),,
+439,地图事件,Map282.json MAP282,22,EV022,"(130,9)",1/2,按确定键,开关[前往天空区域](需ON),,
+440,地图事件,Map223.json MAP223,1,EV001,"(6,6)",1/2,按确定键,开关[第四天开启](需ON),,
+441,地图事件,Map223.json MAP223,2,EV002,"(14,10)",1/2,按确定键,开关[第四天开启](需ON),,
+442,地图事件,Map223.json MAP223,2,EV002,"(14,10)",1/2,按确定键,开关[第四天开启](需ON),,
+442,地图事件,Map223.json MAP223,2,EV002,"(14,10)",1/2,按确定键,开关[第四天开启](需ON),,
+443,地图事件,Map009.json MAP009,8,EV008,"(14,10)",1/1,按确定键,开关[恶魔城莉莉亚入队](需ON),,
+446,地图事件,Map009.json MAP009,8,EV008,"(14,10)",1/1,按确定键,开关[恶魔城莉莉亚入队](需ON),,
+446,地图事件,Map296.json MAP296,33,EV033,"(15,18)",1/3,按确定键,开关[缇亚本体BOSS胜利](需ON),,
+446,地图事件,Map349.json MAP349,3,EV003,"(14,12)",1/2,按确定键,开关[传送魔法失效](需ON),,
+449,地图事件,Map009.json MAP009,10,EV010,"(0,0)",6/10,自动执行,开关[莉莉亚小游戏胜利](需ON) 且 开关[br是否带莉莉亚进入](需ON) 且 变量[小游戏类型]>=1,,
+449,地图事件,Map296.json MAP296,34,EV034,"(4,0)",6/11,自动执行,开关[莉莉亚小游戏胜利](需ON) 且 开关[br是否带莉莉亚进入](需ON) 且 变量[小游戏类型]>=1,,
+450,地图事件,Map009.json MAP009,10,EV010,"(0,0)",5/10,自动执行,开关[喵露露小游戏胜利](需ON) 且 开关[br是否带莉莉亚进入](需ON) 且 变量[小游戏类型]>=1,,
+450,地图事件,Map296.json MAP296,34,EV034,"(4,0)",5/11,自动执行,开关[喵露露小游戏胜利](需ON) 且 开关[br是否带莉莉亚进入](需ON) 且 变量[小游戏类型]>=1,,
+451,地图事件,Map009.json MAP009,10,EV010,"(0,0)",3/10,自动执行,开关[喵露露小游戏胜利](需ON) 且 开关[br是否带莉莉亚进入](需ON),,
+451,地图事件,Map009.json MAP009,10,EV010,"(0,0)",5/10,自动执行,开关[喵露露小游戏胜利](需ON) 且 开关[br是否带莉莉亚进入](需ON) 且 变量[小游戏类型]>=1,,
+451,地图事件,Map296.json MAP296,34,EV034,"(4,0)",3/11,自动执行,开关[喵露露小游戏胜利](需ON) 且 开关[br是否带莉莉亚进入](需ON),,
+451,地图事件,Map296.json MAP296,34,EV034,"(4,0)",5/11,自动执行,开关[喵露露小游戏胜利](需ON) 且 开关[br是否带莉莉亚进入](需ON) 且 变量[小游戏类型]>=1,,
+452,地图事件,Map009.json MAP009,10,EV010,"(0,0)",4/10,自动执行,开关[莉莉亚小游戏胜利](需ON) 且 开关[br是否带莉莉亚进入](需ON),,
+452,地图事件,Map296.json MAP296,34,EV034,"(4,0)",4/11,自动执行,开关[莉莉亚小游戏胜利](需ON) 且 开关[br是否带莉莉亚进入](需ON),,
+453,地图事件,Map009.json MAP009,10,EV010,"(0,0)",3/10,自动执行,开关[喵露露小游戏胜利](需ON) 且 开关[br是否带莉莉亚进入](需ON),,
+453,地图事件,Map296.json MAP296,34,EV034,"(4,0)",3/11,自动执行,开关[喵露露小游戏胜利](需ON) 且 开关[br是否带莉莉亚进入](需ON),,
+454,JS数组(浮动消息),,,,,,,,js/nya/nya_ai.js:710,
+455,JS数组(浮动消息),,,,,,,,js/nya/nya_ai.js:710,
+456,JS数组(浮动消息),,,,,,,,js/nya/nya_ai.js:710,
+457,JS数组(浮动消息),,,,,,,,js/nya/nya_ai.js:710,
+458,JS数组(浮动消息),,,,,,,,js/nya/nya_ai.js:725,
+459,JS数组(浮动消息),,,,,,,,js/nya/nya_ai.js:725,
+460,JS数组(浮动消息),,,,,,,,js/nya/nya_ai.js:725,
+461,JS数组(浮动消息),,,,,,,,js/nya/nya_ai.js:725,
+462,地图事件,Map007.json MAP007,24,EV024,"(38,9)",4/4,按确定键,无条件,,
+462,地图事件,Map296.json MAP296,12,EV012,"(18,16)",4/4,按确定键,无条件,,
+462,地图事件,Map349.json MAP349,10,EV010,"(1,12)",4/4,按确定键,无条件,,
+466,地图事件,Map005.json MAP005,4,EV004,"(0,8)",1/4,按确定键,无条件,,
+466,地图事件,Map034.json MAP034,14,EV014,"(32,6)",1/4,按确定键,无条件,,
+467,地图事件,Map254.json MAP254,2,EV002,"(0,0)",1/3,自动执行,无条件,,
+468,地图事件,Map181.json MAP181,21,EV021,"(0,20)",2/3,按确定键,开关[喵朵露BOSS战胜利](需ON),,
+472,地图事件,Map009.json MAP009,9,EV009,"(8,6)",1/4,按确定键,开关[心渊BOSS战胜利](需ON),,
+473,公共事件,,73,手办屋前置处理,,,公共事件#73,开关[开场白完成],,
+478,地图事件,Map168.json MAP168,3,EV003,"(0,0)",1/2,自动执行,开关[恶魔墙喵露露逃走](需ON),,
+479,地图事件,Map169.json MAP169,1,EV001,"(54,11)",2/3,按确定键,开关[已触发喵露露逃走](需ON),,
+480,地图事件,Map299.json MAP299,1,EV001,"(0,0)",1/2,自动执行,无条件,,
+481,公共事件,,80,游戏机前置条件检查,,,公共事件#80,开关[开场白完成],,
+482,地图事件,Map009.json MAP009,10,EV010,"(0,0)",3/10,自动执行,开关[喵露露小游戏胜利](需ON) 且 开关[br是否带莉莉亚进入](需ON),,
+482,地图事件,Map009.json MAP009,10,EV010,"(0,0)",4/10,自动执行,开关[莉莉亚小游戏胜利](需ON) 且 开关[br是否带莉莉亚进入](需ON),,
+482,地图事件,Map009.json MAP009,10,EV010,"(0,0)",5/10,自动执行,开关[喵露露小游戏胜利](需ON) 且 开关[br是否带莉莉亚进入](需ON) 且 变量[小游戏类型]>=1,,
+482,地图事件,Map009.json MAP009,10,EV010,"(0,0)",6/10,自动执行,开关[莉莉亚小游戏胜利](需ON) 且 开关[br是否带莉莉亚进入](需ON) 且 变量[小游戏类型]>=1,,
+482,地图事件,Map009.json MAP009,10,EV010,"(0,0)",7/10,自动执行,开关[喵露露小游戏胜利](需ON) 且 开关[br是否带莉莉亚进入](需ON) 且 变量[小游戏类型]>=2,,
+482,地图事件,Map009.json MAP009,10,EV010,"(0,0)",8/10,自动执行,开关[莉莉亚小游戏胜利](需ON) 且 开关[br是否带莉莉亚进入](需ON) 且 变量[小游戏类型]>=2,,
+482,地图事件,Map009.json MAP009,10,EV010,"(0,0)",9/10,自动执行,开关[喵露露小游戏胜利](需ON) 且 开关[br是否带莉莉亚进入](需ON) 且 变量[小游戏类型]>=3,,
+482,地图事件,Map009.json MAP009,10,EV010,"(0,0)",10/10,自动执行,开关[莉莉亚小游戏胜利](需ON) 且 开关[br是否带莉莉亚进入](需ON) 且 变量[小游戏类型]>=3,,
+482,地图事件,Map296.json MAP296,34,EV034,"(4,0)",3/11,自动执行,开关[喵露露小游戏胜利](需ON) 且 开关[br是否带莉莉亚进入](需ON),,
+482,地图事件,Map296.json MAP296,34,EV034,"(4,0)",4/11,自动执行,开关[莉莉亚小游戏胜利](需ON) 且 开关[br是否带莉莉亚进入](需ON),,
+482,地图事件,Map296.json MAP296,34,EV034,"(4,0)",5/11,自动执行,开关[喵露露小游戏胜利](需ON) 且 开关[br是否带莉莉亚进入](需ON) 且 变量[小游戏类型]>=1,,
+482,地图事件,Map296.json MAP296,34,EV034,"(4,0)",6/11,自动执行,开关[莉莉亚小游戏胜利](需ON) 且 开关[br是否带莉莉亚进入](需ON) 且 变量[小游戏类型]>=1,,
+482,地图事件,Map296.json MAP296,34,EV034,"(4,0)",7/11,自动执行,开关[喵露露小游戏胜利](需ON) 且 开关[br是否带莉莉亚进入](需ON) 且 变量[小游戏类型]>=2,,
+482,地图事件,Map296.json MAP296,34,EV034,"(4,0)",8/11,自动执行,开关[莉莉亚小游戏胜利](需ON) 且 开关[br是否带莉莉亚进入](需ON) 且 变量[小游戏类型]>=2,,
+482,地图事件,Map296.json MAP296,34,EV034,"(4,0)",9/11,自动执行,开关[喵露露小游戏胜利](需ON) 且 开关[br是否带莉莉亚进入](需ON) 且 变量[小游戏类型]>=3,,
+482,地图事件,Map296.json MAP296,34,EV034,"(4,0)",10/11,自动执行,开关[莉莉亚小游戏胜利](需ON) 且 开关[br是否带莉莉亚进入](需ON) 且 变量[小游戏类型]>=3,,
+483,地图事件,Map300.json MAP300,19,EV019,"(0,0)",1/2,自动执行,无条件,,
+486,JS:lockTextBalloon(),,,,,,,,js/nya/nya_game.js:3841,
+487,JS:lockTextBalloon(),,,,,,,,js/nya/nya_game.js:3831,
+489,地图事件,Map301.json MAP301,2,EV002,"(0,0)",1/2,自动执行,开关[心渊BOSS战胜利](需ON),,
+490,地图事件,Map296.json MAP296,15,EV015,"(37,13)",1/5,按确定键,开关[心渊家剧情转场](需ON),,
+491,地图事件,Map296.json MAP296,15,EV015,"(37,13)",1/5,按确定键,开关[心渊家剧情转场](需ON),,
+492,公共事件,,90,传送点1000猫币判断,,,公共事件#90,开关[开场白完成],,
+493,公共事件,,90,传送点1000猫币判断,,,公共事件#90,开关[开场白完成],,
+494,地图事件,Map301.json MAP301,3,EV003,"(10,9)",1/4,按确定键,开关[心渊家剧情转场](需ON),,
+494,地图事件,Map301.json MAP301,3,EV003,"(10,9)",1/4,按确定键,开关[心渊家剧情转场](需ON),,
+495,地图事件,Map301.json MAP301,3,EV003,"(10,9)",1/4,按确定键,开关[心渊家剧情转场](需ON),,
+496,地图事件,Map301.json MAP301,3,EV003,"(10,9)",1/4,按确定键,开关[心渊家剧情转场](需ON),,
+497,地图事件,Map301.json MAP301,3,EV003,"(10,9)",1/4,按确定键,开关[心渊家剧情转场](需ON),,
+498,地图事件,Map301.json MAP301,3,EV003,"(10,9)",1/4,按确定键,开关[心渊家剧情转场](需ON),,
+499,地图事件,Map301.json MAP301,2,EV002,"(0,0)",1/2,自动执行,开关[心渊BOSS战胜利](需ON),,
+500,地图事件,Map296.json MAP296,16,EV016,"(1,0)",1/2,自动执行,开关[心渊家剧情转场](需ON),,
+501,地图事件,Map296.json MAP296,15,EV015,"(37,13)",1/5,按确定键,开关[心渊家剧情转场](需ON),,
+503,公共事件,,90,传送点1000猫币判断,,,公共事件#90,开关[开场白完成],,
+504,地图事件,Map006.json MAP006,30,EV030,"(29,0)",1/2,事件接触,开关[第一天夜晚](需ON),,
+504,地图事件,Map006.json MAP006,31,EV031,"(11,0)",1/2,事件接触,开关[第一天夜晚](需ON),,
+504,地图事件,Map007.json MAP007,20,EV020,"(32,0)",1/2,事件接触,开关[第一天夜晚](需ON),,
+505,地图事件,Map301.json MAP301,3,EV003,"(10,9)",1/4,按确定键,开关[心渊家剧情转场](需ON),,
+505,地图事件,Map301.json MAP301,3,EV003,"(10,9)",1/4,按确定键,开关[心渊家剧情转场](需ON),,
+506,地图事件,Map301.json MAP301,3,EV003,"(10,9)",1/4,按确定键,开关[心渊家剧情转场](需ON),,
+507,地图事件,Map301.json MAP301,7,EV007,"(17,9)",1/1,按确定键,无条件,,
+508,地图事件,Map301.json MAP301,8,EV008,"(9,4)",1/2,按确定键,无条件,,
+509,地图事件,Map296.json MAP296,2,EV002,"(40,11)",1/5,按确定键,无条件,,
+509,地图事件,Map296.json MAP296,2,EV002,"(40,11)",4/5,按确定键,开关[后日谈](需ON) 且 开关[新结局后日谈](需ON),,
+510,地图事件,Map296.json MAP296,15,EV015,"(37,13)",2/5,按确定键,开关[首次进入星之海](需ON),,
+511,地图事件,Map296.json MAP296,17,EV017,"(42,13)",1/2,按确定键,开关[校长出现在雪原](需ON),,
+512,地图事件,Map296.json MAP296,17,EV017,"(42,13)",1/2,按确定键,开关[校长出现在雪原](需ON),,
+513,地图事件,Map303.json MAP303,1,EV001,"(0,0)",1/3,自动执行,开关[首次进入星之海](需ON),,
+514,地图事件,Map296.json MAP296,15,EV015,"(37,13)",2/5,按确定键,开关[首次进入星之海](需ON),,
+514,地图事件,Map296.json MAP296,17,EV017,"(42,13)",1/2,按确定键,开关[校长出现在雪原](需ON),,
+515,地图事件,Map007.json MAP007,21,EV021,"(25,4)",1/4,按确定键,开关[首次进入星之海](需ON),,
+516,地图事件,Map007.json MAP007,21,EV021,"(25,4)",1/4,按确定键,开关[首次进入星之海](需ON),,
+517,地图事件,Map007.json MAP007,21,EV021,"(25,4)",1/4,按确定键,开关[首次进入星之海](需ON),,
+518,地图事件,Map039.json MAP039,53,EV053,"(71,49)",1/2,按确定键,开关[首次进入星之海](需ON),,
+519,地图事件,Map039.json MAP039,53,EV053,"(71,49)",1/2,按确定键,开关[首次进入星之海](需ON),,
+520,地图事件,Map196.json MAP196,1,EV001,"(15,5)",1/2,按确定键,开关[首次进入星之海](需ON),,
+521,地图事件,Map196.json MAP196,1,EV001,"(15,5)",1/2,按确定键,开关[首次进入星之海](需ON),,
+522,地图事件,Map191.json MAP191,4,EV004,"(11,6)",1/2,按确定键,开关[首次进入星之海](需ON),,
+523,地图事件,Map191.json MAP191,4,EV004,"(11,6)",1/2,按确定键,开关[首次进入星之海](需ON),,
+524,地图事件,Map189.json MAP189,4,EV004,"(9,7)",2/3,按确定键,开关[首次进入星之海](需ON),,
+524,地图事件,Map189.json MAP189,5,EV005,"(29,7)",2/3,按确定键,开关[首次进入星之海](需ON),,
+525,地图事件,Map006.json MAP006,44,EV044,"(35,3)",1/2,按确定键,开关[首次进入星之海](需ON),,
+526,地图事件,Map007.json MAP007,27,EV027,"(31,6)",1/2,按确定键,开关[首次进入星之海](需ON),,
+527,地图事件,Map007.json MAP007,26,EV026,"(33,6)",1/2,按确定键,开关[首次进入星之海](需ON),,
+528,地图事件,Map046.json MAP046,8,EV008,"(4,6)",1/2,按确定键,开关[首次进入星之海](需ON),,
+529,地图事件,Map046.json MAP046,8,EV008,"(4,6)",1/2,按确定键,开关[首次进入星之海](需ON),,
+530,地图事件,Map046.json MAP046,9,EV009,"(7,6)",1/2,按确定键,开关[首次进入星之海](需ON),,
+531,地图事件,Map168.json MAP168,4,EV004,"(6,0)",1/3,事件接触,开关[结界入口开启](需ON),,
+532,地图事件,Map217.json MAP217,7,EV007,"(11,8)",1/2,按确定键,开关[德古拉准备开启结界](需ON),,
+534,地图事件,Map304.json MAP304,2,EV002,"(4,0)",1/3,事件接触,开关[首次进入星之海](需ON),,
+535,地图事件,Map304.json MAP304,3,EV003,"(0,0)",2/3,自动执行,开关[缇娅幻影胜利](需ON),,
+538,地图事件,Map325.json MAP325,22,EV022,"(0,0)",1/1,自动执行,无条件,,
+539,地图事件,Map326.json MAP326,3,EV003,"(2,0)",1/1,自动执行,无条件,,
+540,地图事件,Map009.json MAP009,10,EV010,"(0,0)",7/10,自动执行,开关[喵露露小游戏胜利](需ON) 且 开关[br是否带莉莉亚进入](需ON) 且 变量[小游戏类型]>=2,,
+540,地图事件,Map296.json MAP296,34,EV034,"(4,0)",7/11,自动执行,开关[喵露露小游戏胜利](需ON) 且 开关[br是否带莉莉亚进入](需ON) 且 变量[小游戏类型]>=2,,
+541,地图事件,Map009.json MAP009,10,EV010,"(0,0)",8/10,自动执行,开关[莉莉亚小游戏胜利](需ON) 且 开关[br是否带莉莉亚进入](需ON) 且 变量[小游戏类型]>=2,,
+541,地图事件,Map296.json MAP296,34,EV034,"(4,0)",8/11,自动执行,开关[莉莉亚小游戏胜利](需ON) 且 开关[br是否带莉莉亚进入](需ON) 且 变量[小游戏类型]>=2,,
+542,地图事件,Map009.json MAP009,10,EV010,"(0,0)",8/10,自动执行,开关[莉莉亚小游戏胜利](需ON) 且 开关[br是否带莉莉亚进入](需ON) 且 变量[小游戏类型]>=2,,
+542,地图事件,Map296.json MAP296,34,EV034,"(4,0)",8/11,自动执行,开关[莉莉亚小游戏胜利](需ON) 且 开关[br是否带莉莉亚进入](需ON) 且 变量[小游戏类型]>=2,,
+543,地图事件,Map009.json MAP009,10,EV010,"(0,0)",9/10,自动执行,开关[喵露露小游戏胜利](需ON) 且 开关[br是否带莉莉亚进入](需ON) 且 变量[小游戏类型]>=3,,
+543,地图事件,Map296.json MAP296,34,EV034,"(4,0)",9/11,自动执行,开关[喵露露小游戏胜利](需ON) 且 开关[br是否带莉莉亚进入](需ON) 且 变量[小游戏类型]>=3,,
+544,地图事件,Map009.json MAP009,10,EV010,"(0,0)",10/10,自动执行,开关[莉莉亚小游戏胜利](需ON) 且 开关[br是否带莉莉亚进入](需ON) 且 变量[小游戏类型]>=3,,
+544,地图事件,Map296.json MAP296,34,EV034,"(4,0)",10/11,自动执行,开关[莉莉亚小游戏胜利](需ON) 且 开关[br是否带莉莉亚进入](需ON) 且 变量[小游戏类型]>=3,,
+545,地图事件,Map009.json MAP009,10,EV010,"(0,0)",10/10,自动执行,开关[莉莉亚小游戏胜利](需ON) 且 开关[br是否带莉莉亚进入](需ON) 且 变量[小游戏类型]>=3,,
+545,地图事件,Map296.json MAP296,34,EV034,"(4,0)",10/11,自动执行,开关[莉莉亚小游戏胜利](需ON) 且 开关[br是否带莉莉亚进入](需ON) 且 变量[小游戏类型]>=3,,
+546,地图事件,Map296.json MAP296,33,EV033,"(15,18)",1/3,按确定键,开关[缇亚本体BOSS胜利](需ON),,
+546,地图事件,Map349.json MAP349,3,EV003,"(14,12)",1/2,按确定键,开关[传送魔法失效](需ON),,
+547,地图事件,Map296.json MAP296,33,EV033,"(15,18)",1/3,按确定键,开关[缇亚本体BOSS胜利](需ON),,
+547,地图事件,Map349.json MAP349,3,EV003,"(14,12)",1/2,按确定键,开关[传送魔法失效](需ON),,
+548,地图事件,Map327.json MAP327,18,EV018,"(0,0)",1/4,自动执行,开关[缇娅幻影胜利](需ON),,
+549,地图事件,Map327.json MAP327,18,EV018,"(0,0)",3/4,自动执行,开关[缇亚本体BOSS胜利](需ON),,
+550,地图事件,Map327.json MAP327,21,EV021,"(9,8)",2/3,按确定键,开关[缇娅BOSS胜利对话完](需ON),,
+556,地图事件,Map327.json MAP327,21,EV021,"(9,8)",2/3,按确定键,开关[缇娅BOSS胜利对话完](需ON),,
+557,地图事件,Map327.json MAP327,21,EV021,"(9,8)",2/3,按确定键,开关[缇娅BOSS胜利对话完](需ON),,
+558,地图事件,Map327.json MAP327,21,EV021,"(9,8)",2/3,按确定键,开关[缇娅BOSS胜利对话完](需ON),,
+559,地图事件,Map327.json MAP327,21,EV021,"(9,8)",2/3,按确定键,开关[缇娅BOSS胜利对话完](需ON),,
+562,地图事件,Map324.json MAP324,7,EV007,"(14,3)",1/2,按确定键,开关[缇娅幻影胜利](需ON),,
+564,地图事件,Map005.json MAP005,23,EV023,"(3,0)",1/2,自动执行,开关[缇亚本体BOSS胜利](需ON),,
+565,地图事件,Map004.json MAP004,13,EV013,"(3,3)",1/3,自动执行,开关[缇亚本体BOSS胜利](需ON),,
+566,地图事件,Map004.json MAP004,13,EV013,"(3,3)",2/3,自动执行,独立开关A=ON,,
+566,地图事件,Map004.json MAP004,13,EV013,"(3,3)",2/3,自动执行,独立开关A=ON,,
+567,地图事件,Map296.json MAP296,21,EV021,"(12,18)",1/3,按确定键,开关[缇亚本体BOSS胜利](需ON),,
+568,地图事件,Map296.json MAP296,21,EV021,"(12,18)",1/3,按确定键,开关[缇亚本体BOSS胜利](需ON),,
+569,地图事件,Map296.json MAP296,23,EV023,"(21,13)",1/2,按确定键,开关[缇亚本体BOSS胜利](需ON),,
+570,地图事件,Map296.json MAP296,23,EV023,"(21,13)",1/2,按确定键,开关[缇亚本体BOSS胜利](需ON),,
+571,地图事件,Map296.json MAP296,22,EV022,"(22,12)",1/2,按确定键,开关[缇亚本体BOSS胜利](需ON),,
+572,地图事件,Map296.json MAP296,22,EV022,"(22,12)",1/2,按确定键,开关[缇亚本体BOSS胜利](需ON),,
+573,地图事件,Map296.json MAP296,24,EV024,"(24,13)",1/3,按确定键,开关[缇亚本体BOSS胜利](需ON),,
+574,地图事件,Map296.json MAP296,24,EV024,"(24,13)",1/3,按确定键,开关[缇亚本体BOSS胜利](需ON),,
+575,地图事件,Map296.json MAP296,25,EV025,"(34,13)",1/3,按确定键,开关[缇亚本体BOSS胜利](需ON),,
+576,地图事件,Map296.json MAP296,25,EV025,"(34,13)",1/3,按确定键,开关[缇亚本体BOSS胜利](需ON),,
+577,地图事件,Map296.json MAP296,26,EV026,"(38,13)",1/3,按确定键,开关[缇亚本体BOSS胜利](需ON),,
+578,地图事件,Map296.json MAP296,26,EV026,"(38,13)",1/3,按确定键,开关[缇亚本体BOSS胜利](需ON),,
+579,地图事件,Map301.json MAP301,11,EV011,"(4,14)",1/5,按确定键,开关[缇亚本体BOSS胜利](需ON),,
+580,地图事件,Map301.json MAP301,11,EV011,"(4,14)",3/5,按确定键,开关[黑雾区心渊等猫粮](需ON),,
+583,公共事件,,114,喵露露有猫粮判断,,,公共事件#114,开关[开场白完成],,
+584,公共事件,,114,喵露露有猫粮判断,,,公共事件#114,开关[开场白完成],,
+586,地图事件,Map301.json MAP301,11,EV011,"(4,14)",1/5,按确定键,开关[缇亚本体BOSS胜利](需ON),,
+587,地图事件,Map301.json MAP301,11,EV011,"(4,14)",2/5,按确定键,开关[黑雾区心渊对话完](需ON),,
+588,地图事件,Map301.json MAP301,13,EV013,"(15,9)",1/3,按确定键,开关[缇亚本体BOSS胜利](需ON),,
+589,地图事件,Map301.json MAP301,13,EV013,"(15,9)",1/3,按确定键,开关[缇亚本体BOSS胜利](需ON),,
+590,地图事件,Map301.json MAP301,12,EV012,"(14,9)",1/3,按确定键,开关[缇亚本体BOSS胜利](需ON),,
+591,地图事件,Map301.json MAP301,12,EV012,"(14,9)",1/3,按确定键,开关[缇亚本体BOSS胜利](需ON),,
+592,地图事件,Map296.json MAP296,32,EV032,"(32,13)",1/3,按确定键,开关[缇亚本体BOSS胜利](需ON),,
+593,地图事件,Map296.json MAP296,32,EV032,"(32,13)",1/3,按确定键,开关[缇亚本体BOSS胜利](需ON),,
+594,地图事件,Map296.json MAP296,31,EV031,"(31,13)",1/3,按确定键,开关[缇亚本体BOSS胜利](需ON),,
+595,地图事件,Map296.json MAP296,31,EV031,"(31,13)",1/3,按确定键,开关[缇亚本体BOSS胜利](需ON),,
+596,地图事件,Map296.json MAP296,29,EV029,"(44,13)",1/3,按确定键,开关[缇亚本体BOSS胜利](需ON),,
+597,地图事件,Map296.json MAP296,29,EV029,"(44,13)",1/3,按确定键,开关[缇亚本体BOSS胜利](需ON),,
+598,地图事件,Map296.json MAP296,30,EV030,"(43,13)",1/3,按确定键,开关[缇亚本体BOSS胜利](需ON),,
+599,地图事件,Map296.json MAP296,30,EV030,"(43,13)",1/3,按确定键,开关[缇亚本体BOSS胜利](需ON),,
+600,公共事件,,26,传送门公共事件,,,公共事件#26,开关[开场白完成],,
+601,地图事件,Map324.json MAP324,8,EV008,"(1,0)",1/3,自动执行,开关[缇娅BOSS胜利对话完](需ON),,
+602,地图事件,Map329.json MAP329,8,EV008,"(0,0)",1/2,自动执行,开关[缇亚本体BOSS胜利](需ON),,
+603,地图事件,Map337.json MAP337,16,EV016,"(1,0)",1/2,自动执行,开关[缇亚本体BOSS胜利](需ON),,
+604,地图事件,Map339.json MAP339,8,EV008,"(32,0)",1/2,事件接触,开关[小眼被打败](需ON) 且 开关[大眼被打败](需ON),,
+605,地图事件,Map339.json MAP339,12,EV012,"(33,14)",1/1,按确定键,开关[莉莉亚负伤传送回家](需ON),,
+606,公共事件,,117,剧情回城禁用时触发,,,公共事件#117,开关[开场白完成],,
+607,地图事件,Map342.json MAP342,3,EV003,"(8,0)",1/2,事件接触,开关[大眼被打败](需ON) 且 开关[小眼被打败](需ON),,
+608,公共事件,,117,剧情回城禁用时触发,,,公共事件#117,开关[开场白完成],,
+609,地图事件,Map349.json MAP349,8,EV008,"(3,12)",1/2,按确定键,开关[传送魔法失效](需ON),,
+610,地图事件,Map181.json MAP181,2,EV002,"(20,20)",4/5,按确定键,开关[触发莉莉亚隐藏-弃用](需ON),,
+613,地图事件,Map182.json MAP182,5,EV005,"(6,11)",1/3,自动执行,开关[触发莉莉亚隐藏-弃用](需ON),,
+614,地图事件,Map005.json MAP005,25,EV025,"(4,0)",1/3,自动执行,开关[后日谈](需ON),,
+615,地图事件,Map351.json MAP351,1,EV001,"(9,8)",1/1,按确定键,无条件,,
+616,图片名引用,Map348.json,,,,,,,/events/pages/list/parameters 中的 #616#28#ffffff#4#000000,
+617,图片名引用,Map348.json,,,,,,,/events/pages/list/parameters 中的 #617#28#ffffff#4#000000,
+618,图片名引用,Map359.json,,,,,,,/events/pages/list/parameters 中的 #618#28#ffffff#4#000000,
+619,图片名引用,Map360.json,,,,,,,/events/pages/list/parameters 中的 #619#28#ffffff#4#000000,
+620,图片名引用,Map361.json,,,,,,,/events/pages/list/parameters 中的 #620#28#ffffff#4#000000,
+621,地图事件,Map359.json MAP359,1,EV001,"(9,8)",1/1,按确定键,无条件,,
+622,地图事件,Map360.json MAP360,1,EV001,"(9,5)",1/1,按确定键,无条件,,
+623,地图事件,Map361.json MAP361,1,EV001,"(30,6)",1/1,按确定键,无条件,,
+624,地图事件,Map358.json MAP358,4,EV004,"(1,0)",1/3,自动执行,开关[喵可莉最终对白演出](需ON),,
+625,地图事件,Map191.json MAP191,5,EV005,"(30,6)",1/2,按确定键,无条件,,
+626,地图事件,Map348.json MAP348,11,EV011,"(3,0)",2/2,自动执行,开关[莉伊被喵露露消灭](需ON),,
+628,地图事件,Map348.json MAP348,12,EV012,"(15,10)",1/1,按确定键,开关[莉伊被喵露露消灭](需ON),,
+629,地图事件,Map350.json MAP350,4,EV004,"(2,7)",1/3,事件接触,开关[传送魔法失效](需ON),,
+630,地图事件,Map349.json MAP349,9,EV009,"(29,12)",1/2,按确定键,无条件,,
+632,图片名引用,Map350.json,,,,,,,/events/pages/list/parameters 中的 #632#28#ffffff#4#000000,
+632,图片名引用,Map350.json,,,,,,,/events/pages/list/parameters 中的 #632#28#ffffff#4#000000,
+633,地图事件,Map350.json MAP350,12,EV012,"(7,7)",1/9,自动执行,开关[魔女被击败](需ON),,魔女被击败后,喵露露强杀魔女路线和莉伊喵可莉路线都在这里事件触发
+634,地图事件,Map350.json MAP350,12,EV012,"(7,7)",5/9,自动执行,开关[演出莉伊阻止校长](需ON),,请到事件14查看,魔女被击败后,莉伊喵可莉路线首先在14事件触发 / 在喵可莉结局中,14事件执行完毕才会到本事件执行 / 但本事件的附加特效在14事件执行过程中就有了 / 250开关预先会打开 / CG
+635,图片名引用,Map350.json,,,,,,,/events/pages/list/parameters 中的 #635#28#ffffff#4#000000,
+636,图片名引用,Map350.json,,,,,,,/events/pages/list/parameters 中的 #636#28#ffffff#4#000000,
+637,地图事件,Map348.json MAP348,5,EV005,"(2,0)",2/3,自动执行,独立开关A=ON,,
+638,地图事件,Map350.json MAP350,12,EV012,"(7,7)",4/9,自动执行,开关[魔女被击败](需ON) 且 开关[莉伊被喵露露消灭](需ON),,魔女被击败后,强杀莉伊再强杀魔女路线首先在这里事件触发
+641,地图事件,Map350.json MAP350,12,EV012,"(7,7)",7/9,自动执行,开关[容器重建完毕](需ON) 且 开关[莉伊被喵露露消灭](需ON),,
+644,地图事件,Map350.json MAP350,11,EV011,"(9,10)",1/6,按确定键,开关[传送魔法失效](需ON),,
+647,公共事件,,146,结局1莉莉亚登场对白,,,公共事件#146,开关[开场白完成],,
+648,JS:requestFloatMessage(),,,,,,,,js/nya/nya_statem_ext.js:14959,
+660,地图事件,Map004.json MAP004,14,EV014,"(5,4)",1/2,按确定键,开关[后日谈](需ON),,
+661,地图事件,Map004.json MAP004,14,EV014,"(5,4)",1/2,按确定键,开关[后日谈](需ON),,
+662,地图事件,Map004.json MAP004,14,EV014,"(5,4)",1/2,按确定键,开关[后日谈](需ON),,
+663,地图事件,Map004.json MAP004,14,EV014,"(5,4)",1/2,按确定键,开关[后日谈](需ON),,
+663,地图事件,Map004.json MAP004,14,EV014,"(5,4)",1/2,按确定键,开关[后日谈](需ON),,
+664,地图事件,Map191.json MAP191,6,EV006,"(23,6)",1/2,按确定键,开关[后日谈](需ON),,
+665,地图事件,Map191.json MAP191,6,EV006,"(23,6)",1/2,按确定键,开关[后日谈](需ON),,
+666,地图事件,Map009.json MAP009,3,EV003,"(4,9)",10/16,按确定键,开关[后日谈](需ON),,
+667,地图事件,Map009.json MAP009,3,EV003,"(4,9)",10/16,按确定键,开关[后日谈](需ON),,
+668,地图事件,Map009.json MAP009,3,EV003,"(4,9)",10/16,按确定键,开关[后日谈](需ON),,
+669,地图事件,Map196.json MAP196,5,EV005,"(9,7)",1/2,按确定键,开关[后日谈](需ON),,
+670,地图事件,Map196.json MAP196,5,EV005,"(9,7)",1/2,按确定键,开关[后日谈](需ON),,
+671,地图事件,Map196.json MAP196,6,EV006,"(7,6)",1/2,按确定键,开关[后日谈](需ON),,
+672,地图事件,Map196.json MAP196,6,EV006,"(7,6)",1/2,按确定键,开关[后日谈](需ON),,
+673,地图事件,Map327.json MAP327,22,EV022,"(14,8)",1/2,按确定键,开关[后日谈](需ON),,
+674,地图事件,Map327.json MAP327,22,EV022,"(14,8)",1/2,按确定键,开关[后日谈](需ON),,
+675,地图事件,Map301.json MAP301,14,EV014,"(10,4)",1/2,按确定键,开关[后日谈](需ON),,
+676,地图事件,Map301.json MAP301,14,EV014,"(10,4)",1/2,按确定键,开关[后日谈](需ON),,
+677,地图事件,Map075.json MAP075,1,EV001,"(11,8)",1/2,按确定键,开关[后日谈](需ON),,
+678,地图事件,Map075.json MAP075,1,EV001,"(11,8)",1/2,按确定键,开关[后日谈](需ON),,
+679,公共事件,,142,夏洛特米娅后日谈对话,,,公共事件#142,开关[开场白完成],,
+680,公共事件,,142,夏洛特米娅后日谈对话,,,公共事件#142,开关[开场白完成],,
+681,公共事件,,140,依玲和蕾铃后日谈对话,,,公共事件#140,开关[开场白完成],,
+682,公共事件,,140,依玲和蕾铃后日谈对话,,,公共事件#140,开关[开场白完成],,
+683,公共事件,,141,露茜亚梅卡后日谈对话,,,公共事件#141,开关[开场白完成],,
+684,公共事件,,141,露茜亚梅卡后日谈对话,,,公共事件#141,开关[开场白完成],,
+685,地图事件,Map046.json MAP046,10,EV010,"(22,6)",1/2,按确定键,开关[后日谈](需ON),,
+686,地图事件,Map046.json MAP046,10,EV010,"(22,6)",1/2,按确定键,开关[后日谈](需ON),,
+687,地图事件,Map012.json MAP012,6,EV006,"(1,0)",6/8,自动执行,开关[后日谈](需ON),,
+688,地图事件,Map196.json MAP196,9,EV009,"(17,7)",1/1,自动执行,开关[莉莉亚后日谈](需ON),,
+689,地图事件,Map007.json MAP007,24,EV024,"(38,9)",4/4,按确定键,无条件,,
+689,地图事件,Map296.json MAP296,12,EV012,"(18,16)",4/4,按确定键,无条件,,
+689,地图事件,Map349.json MAP349,10,EV010,"(1,12)",4/4,按确定键,无条件,,
+691,地图事件,Map182.json MAP182,7,EV007,"(4,11)",1/1,按确定键,开关[缇亚本体BOSS胜利](需ON),,
+693,地图事件,Map350.json MAP350,19,EV019,"(1,10)",1/2,按确定键,开关[后日谈](需ON),,
+694,地图事件,Map171.json MAP171,16,EV016,"(5,6)",1/3,按确定键,无条件,,
+695,地图事件,Map171.json MAP171,16,EV016,"(5,6)",2/3,按确定键,开关[获得樱的钥匙](需ON),,
+696,地图事件,Map171.json MAP171,16,EV016,"(5,6)",3/3,按确定键,开关[获得樱的钥匙](需ON) 且 独立开关A=ON,,
+697,地图事件,Map353.json MAP353,6,EV006,"(4,4)",1/1,事件接触,开关[喵可莉摸猫](需ON),,
+698,地图事件,Map353.json MAP353,6,EV006,"(4,4)",1/1,事件接触,开关[喵可莉摸猫](需ON),,
+699,地图事件,Map004.json MAP004,14,EV014,"(5,4)",1/2,按确定键,开关[后日谈](需ON),,
+700,地图事件,Map039.json MAP039,61,EV061,"(51,45)",1/2,按确定键,开关[第二天开启](需ON),,
+701,地图事件,Map171.json MAP171,17,EV017,"(6,16)",1/2,按确定键,开关[第三天开启](需ON),,
+702,地图事件,Map369.json MAP369,2,EV002,"(15,13)",1/1,自动执行,无条件,,
+703,地图事件,Map370.json MAP370,2,EV002,"(10,5)",1/1,事件接触,无条件,,
+704,地图事件,Map371.json MAP371,2,EV002,"(14,5)",1/1,事件接触,无条件,,
+705,公共事件,,80,游戏机前置条件检查,,,公共事件#80,开关[开场白完成],,
+708,地图事件,Map009.json MAP009,12,EV012,"(2,6)",1/2,按确定键,开关[4399看广告拿东西](需ON),,
+711,地图事件,Map009.json MAP009,13,EV013,"(4,6)",1/2,按确定键,开关[4399看广告拿东西](需ON),,
+714,地图事件,Map009.json MAP009,14,EV014,"(6,6)",1/2,按确定键,开关[4399看广告拿东西](需ON),,
+718,地图事件,Map296.json MAP296,15,EV015,"(37,13)",3/5,按确定键,开关[缇娅幻影胜利](需ON),,
+719,地图事件,Map296.json MAP296,15,EV015,"(37,13)",3/5,按确定键,开关[缇娅幻影胜利](需ON),,
+720,地图事件,Map378.json MAP378,9,EV009,"(21,9)",1/3,事件接触,开关[缇娅幻影胜利](需ON),,
+721,地图事件,Map383.json MAP383,3,EV003,"(12,5)",1/2,自动执行,开关[触发校长在璃音门口](需ON),,
+722,地图事件,Map381.json MAP381,7,EV007,"(0,0)",2/3,自动执行,开关[校长BOSS胜利](需ON),,
+723,地图事件,Map379.json MAP379,2,EV002,"(0,0)",1/3,自动执行,无条件,,
+724,地图事件,Map379.json MAP379,3,EV003,"(6,6)",1/3,按确定键,无条件,,
+725,地图事件,Map379.json MAP379,4,EV004,"(5,6)",1/1,按确定键,无条件,,
+726,地图事件,Map379.json MAP379,5,EV005,"(1,3)",1/1,按确定键,无条件,,
+727,地图事件,Map379.json MAP379,6,EV006,"(9,2)",1/2,按确定键,无条件,,
+728,地图事件,Map379.json MAP379,6,EV006,"(9,2)",2/2,按确定键,开关[夜晚开关](需ON),,
+730,地图事件,Map383.json MAP383,2,EV002,"(11,5)",1/2,按确定键,无条件,,
+730,地图事件,Map383.json MAP383,2,EV002,"(11,5)",1/2,按确定键,无条件,,
+731,地图事件,Map383.json MAP383,2,EV002,"(11,5)",1/2,按确定键,无条件,,
+732,地图事件,Map383.json MAP383,4,EV004,"(0,0)",1/3,自动执行,开关[前往天空区域](需ON),,
+733,地图事件,Map383.json MAP383,5,EV005,"(9,5)",1/2,按确定键,开关[前往天空区域](需ON),,
+734,地图事件,Map378.json MAP378,10,EV010,"(33,9)",1/3,按确定键,开关[老板娘战后对话完](需ON),,
+735,地图事件,Map378.json MAP378,10,EV010,"(33,9)",1/3,按确定键,开关[老板娘战后对话完](需ON),,
+736,地图事件,Map378.json MAP378,11,EV011,"(32,8)",1/3,并行处理,开关[老板娘战后对话完](需ON),,
+736,地图事件,Map378.json MAP378,12,EV012,"(0,0)",1/3,自动执行,开关[触发喵露露推涅普](需ON),,
+737,地图事件,Map378.json MAP378,13,EV013,"(48,10)",1/3,事件接触,开关[第一天夜晚](需ON),,
+738,地图事件,Map378.json MAP378,13,EV013,"(48,10)",1/3,事件接触,开关[第一天夜晚](需ON),,
+739,地图事件,Map295.json MAP295,2,EV002,"(4,0)",1/3,事件接触,开关[雪原任务开启](需ON),,
+740,地图事件,Map295.json MAP295,2,EV002,"(4,0)",1/3,事件接触,开关[雪原任务开启](需ON),,
+741,地图事件,Map383.json MAP383,6,EV006,"(5,5)",1/3,按确定键,开关[第三天开启](需ON),,
+742,地图事件,Map383.json MAP383,6,EV006,"(5,5)",1/3,按确定键,开关[第三天开启](需ON),,
+743,地图事件,Map381.json MAP381,10,EV010,"(13,8)",1/4,按确定键,开关[第四天开启](需ON),,
+745,地图事件,Map381.json MAP381,10,EV010,"(13,8)",2/4,按确定键,开关[莉伊被叫醒](需ON),,
+746,地图事件,Map382.json MAP382,2,EV002,"(17,12)",1/2,按确定键,开关[莉伊走进树林中](需ON),,
+747,地图事件,Map382.json MAP382,2,EV002,"(17,12)",1/2,按确定键,开关[莉伊走进树林中](需ON),,
+749,地图事件,Map327.json MAP327,18,EV018,"(0,0)",3/4,自动执行,开关[缇亚本体BOSS胜利](需ON),,
+750,地图事件,Map324.json MAP324,8,EV008,"(1,0)",2/3,自动执行,开关[校长BOSS胜利后传送走](需ON) 且 开关[缇娅BOSS胜利对话完](需ON),,
+751,地图事件,Map327.json MAP327,21,EV021,"(9,8)",2/3,按确定键,开关[缇娅BOSS胜利对话完](需ON),,
+757,地图事件,Map381.json MAP381,11,EV011,"(6,12)",1/3,按确定键,开关[校长BOSS后猫头出现](需ON),,
+758,地图事件,Map004.json MAP004,13,EV013,"(3,3)",1/3,自动执行,开关[缇亚本体BOSS胜利](需ON),,
+759,地图事件,Map004.json MAP004,13,EV013,"(3,3)",2/3,自动执行,独立开关A=ON,,
+760,地图事件,Map349.json MAP349,8,EV008,"(3,12)",1/2,按确定键,开关[传送魔法失效](需ON),,
+761,地图事件,Map004.json MAP004,15,EV015,"(6,0)",1/2,自动执行,开关[新结局后日谈](需ON) 且 开关[后日谈](需ON),,
+762,地图事件,Map004.json MAP004,16,EV016,"(2,6)",1/2,按确定键,开关[新后日谈橘花猫出现](需ON),,
+763,地图事件,Map384.json MAP384,1,EV001,"(7,11)",1/3,事件接触,开关[新后日谈橘花猫出现](需ON),,
+764,地图事件,Map384.json MAP384,1,EV001,"(7,11)",1/3,事件接触,开关[新后日谈橘花猫出现](需ON),,
+765,地图事件,Map384.json MAP384,1,EV001,"(7,11)",1/3,事件接触,开关[新后日谈橘花猫出现](需ON),,
+766,地图事件,Map191.json MAP191,7,EV007,"(13,6)",1/4,按确定键,开关[获得金丝草](需ON),,
+767,地图事件,Map191.json MAP191,7,EV007,"(13,6)",3/4,按确定键,开关[获得金丝草](需ON) 且 独立开关B=ON,,
+768,地图事件,Map191.json MAP191,7,EV007,"(13,6)",2/4,按确定键,开关[获得金丝草](需ON) 且 独立开关A=ON,,
+769,地图事件,Map009.json MAP009,3,EV003,"(4,9)",12/16,按确定键,开关[新结局后日谈](需ON) 且 开关[新后日谈与莲对话完](需ON),,
+770,地图事件,Map009.json MAP009,3,EV003,"(4,9)",12/16,按确定键,开关[新结局后日谈](需ON) 且 开关[新后日谈与莲对话完](需ON),,
+771,地图事件,Map009.json MAP009,3,EV003,"(4,9)",12/16,按确定键,开关[新结局后日谈](需ON) 且 开关[新后日谈与莲对话完](需ON),,
+772,地图事件,Map296.json MAP296,27,EV027,"(40,13)",3/4,按确定键,开关[新结局后日谈](需ON) 且 开关[新后日谈开传送点](需ON),,
+773,地图事件,Map327.json MAP327,26,EV026,"(12,8)",1/6,按确定键,开关[重新开启星之海传送](需ON),,
+774,地图事件,Map327.json MAP327,26,EV026,"(12,8)",2/6,按确定键,开关[新后日谈缇娅对话完成](需ON),,
+775,地图事件,Map377.json MAP377,4,EV004,"(3,12)",1/2,按确定键,开关[新后日谈缇娅对话完成](需ON),,
+776,地图事件,Map377.json MAP377,4,EV004,"(3,12)",1/2,按确定键,开关[新后日谈缇娅对话完成](需ON),,
+777,地图事件,Map377.json MAP377,4,EV004,"(3,12)",1/2,按确定键,开关[新后日谈缇娅对话完成](需ON),,
+778,地图事件,Map387.json MAP387,1,EV001,"(9,9)",1/5,事件接触,开关[和校长对话完修熊](需ON),,
+780,公共事件,,200,新后日谈转到第二天,,,公共事件#200,开关[开场白完成],,
+781,地图事件,Map327.json MAP327,26,EV026,"(12,8)",3/6,按确定键,开关[新后日谈第二天](需ON),,
+782,地图事件,Map327.json MAP327,26,EV026,"(12,8)",3/6,按确定键,开关[新后日谈第二天](需ON),,
+783,地图事件,Map012.json MAP012,10,EV010,"(9,6)",1/2,按确定键,开关[新后日谈第二天](需ON),,
+783,地图事件,Map012.json MAP012,11,EV011,"(10,6)",1/2,按确定键,开关[新后日谈第二天](需ON),,
+784,地图事件,Map012.json MAP012,11,EV011,"(10,6)",1/2,按确定键,开关[新后日谈第二天](需ON),,
+785,地图事件,Map012.json MAP012,10,EV010,"(9,6)",1/2,按确定键,开关[新后日谈第二天](需ON),,
+786,地图事件,Map012.json MAP012,10,EV010,"(9,6)",1/2,按确定键,开关[新后日谈第二天](需ON),,
+787,地图事件,Map196.json MAP196,11,EV011,"(8,7)",1/2,按确定键,开关[新后日谈和小心心对话](需ON),,
+788,地图事件,Map196.json MAP196,11,EV011,"(8,7)",1/2,按确定键,开关[新后日谈和小心心对话](需ON),,
+789,地图事件,Map378.json MAP378,14,EV014,"(41,3)",1/2,事件接触,开关[新后日谈和德古拉对话](需ON),,
+791,地图事件,Map387.json MAP387,1,EV001,"(9,9)",3/5,事件接触,开关[新后日谈第二天夜晚](需ON),,
+794,地图事件,Map387.json MAP387,1,EV001,"(9,9)",3/5,事件接触,开关[新后日谈第二天夜晚](需ON),,
+795,地图事件,Map327.json MAP327,26,EV026,"(12,8)",4/6,自动执行,开关[新后日谈第二天夜晚](需ON),,
+796,地图事件,Map389.json MAP389,1,EV001,"(4,5)",1/3,按确定键,开关[新后日谈第二天夜晚](需ON),,
+797,地图事件,Map390.json MAP390,1,EV001,"(7,6)",1/3,按确定键,开关[看完喵可莉质问缇娅](需ON),,
+798,地图事件,Map390.json MAP390,1,EV001,"(7,6)",2/3,按确定键,开关[喵可莉准备变猫](需ON),,
+800,地图事件,Map379.json MAP379,8,EV008,"(7,6)",1/2,按确定键,开关[新结局后日谈](需ON),,
+801,地图事件,Map379.json MAP379,7,EV007,"(4,3)",2/3,按确定键,开关[新结局后日谈](需ON),,
+802,地图事件,Map379.json MAP379,7,EV007,"(4,3)",2/3,按确定键,开关[新结局后日谈](需ON),,
+803,地图事件,Map009.json MAP009,3,EV003,"(4,9)",11/16,按确定键,开关[新结局后日谈](需ON),,
+804,地图事件,Map171.json MAP171,18,EV018,"(11,12)",1/2,按确定键,开关[新后日谈橘花猫出现](需ON),,
+804,地图事件,Map171.json MAP171,19,EV019,"(12,12)",1/2,按确定键,开关[新后日谈橘花猫出现](需ON),,
+805,地图事件,Map171.json MAP171,18,EV018,"(11,12)",1/2,按确定键,开关[新后日谈橘花猫出现](需ON),,
+806,地图事件,Map171.json MAP171,19,EV019,"(12,12)",1/2,按确定键,开关[新后日谈橘花猫出现](需ON),,
+807,地图事件,Map381.json MAP381,18,EV018,"(14,12)",1/2,按确定键,开关[获得金丝草](需ON),,
+808,地图事件,Map381.json MAP381,18,EV018,"(14,12)",1/2,按确定键,开关[获得金丝草](需ON),,
+809,地图事件,Map383.json MAP383,2,EV002,"(11,5)",2/2,按确定键,开关[莉伊进门缝眼睛](需ON),,
+810,地图事件,Map379.json MAP379,9,EV009,"(3,6)",1/2,按确定键,开关[新后日谈第二天](需ON),,
+811,地图事件,Map379.json MAP379,9,EV009,"(3,6)",1/2,按确定键,开关[新后日谈第二天](需ON),,
+812,地图事件,Map388.json MAP388,11,EV011,"(4,9)",1/4,事件接触,开关[新后日谈第二天](需ON),,
+813,地图事件,Map388.json MAP388,10,EV010,"(4,10)",1/3,按确定键,开关[新后日谈第二天](需ON),,
+814,地图事件,Map378.json MAP378,22,EV022,"(31,9)",1/2,按确定键,开关[新结局后日谈](需ON),,
+815,地图事件,Map378.json MAP378,22,EV022,"(31,9)",1/2,按确定键,开关[新结局后日谈](需ON),,
+816,地图事件,Map009.json MAP009,3,EV003,"(4,9)",14/16,按确定键,开关[新结局后日谈](需ON) 且 开关[新后日谈第二天](需ON),,
+817,地图事件,Map196.json MAP196,4,EV004,"(5,5)",3/5,按确定键,开关[新后日谈第二天](需ON),,
+818,地图事件,Map196.json MAP196,4,EV004,"(5,5)",3/5,按确定键,开关[新后日谈第二天](需ON),,
+819,地图事件,Map381.json MAP381,20,EV020,"(11,12)",1/2,按确定键,开关[新后日谈第二天](需ON),,
+820,地图事件,Map381.json MAP381,20,EV020,"(11,12)",1/2,按确定键,开关[新后日谈第二天](需ON),,
+822,地图事件,Map009.json MAP009,9,EV009,"(8,6)",3/4,按确定键,开关[新后日谈第二天](需ON),,
+823,地图事件,Map389.json MAP389,1,EV001,"(4,5)",1/3,按确定键,开关[新后日谈第二天夜晚](需ON),,
+824,地图事件,Map389.json MAP389,1,EV001,"(4,5)",1/3,按确定键,开关[新后日谈第二天夜晚](需ON),,
+825,地图事件,Map196.json MAP196,12,EV012,"(12,7)",2/3,按确定键,开关[新后日谈第二天夜晚](需ON),,
+826,地图事件,Map382.json MAP382,3,EV003,"(15,12)",1/2,按确定键,开关[新后日谈第二天夜晚](需ON),,
+826,地图事件,Map382.json MAP382,4,EV004,"(14,12)",1/2,按确定键,开关[新后日谈第二天夜晚](需ON),,
+827,地图事件,Map382.json MAP382,3,EV003,"(15,12)",1/2,按确定键,开关[新后日谈第二天夜晚](需ON),,
+827,地图事件,Map382.json MAP382,4,EV004,"(14,12)",1/2,按确定键,开关[新后日谈第二天夜晚](需ON),,
+828,地图事件,Map009.json MAP009,9,EV009,"(8,6)",3/4,按确定键,开关[新后日谈第二天](需ON),,
+845,地图事件,Map350.json MAP350,12,EV012,"(7,7)",1/9,自动执行,开关[魔女被击败](需ON),,魔女被击败后,喵露露强杀魔女路线和莉伊喵可莉路线都在这里事件触发
+846,地图事件,Map349.json MAP349,13,EV013,"(18,13)",1/2,事件接触,开关[喵露露带莉莉亚回去](需ON),,
+847,地图事件,Map350.json MAP350,24,EV024,"(3,0)",1/3,自动执行,开关[喵露露带莉莉亚折返](需ON),,
+848,地图事件,Map349.json MAP349,9,EV009,"(29,12)",1/2,按确定键,无条件,,
+849,地图事件,Map350.json MAP350,26,EV026,"(4,0)",2/12,自动执行,开关[二战魔女一阶段结束演出](需ON),,
+850,地图事件,Map350.json MAP350,26,EV026,"(4,0)",2/12,自动执行,开关[二战魔女一阶段结束演出](需ON),,
+851,地图事件,Map350.json MAP350,26,EV026,"(4,0)",2/12,自动执行,开关[二战魔女一阶段结束演出](需ON),,
+852,地图事件,Map350.json MAP350,26,EV026,"(4,0)",5/12,自动执行,开关[二战魔女二阶前半](需ON),,
+853,地图事件,Map350.json MAP350,26,EV026,"(4,0)",6/12,自动执行,开关[二战魔女二阶后半](需ON),,
+854,地图事件,Map350.json MAP350,26,EV026,"(4,0)",6/12,自动执行,开关[二战魔女二阶后半](需ON),,
+855,地图事件,Map350.json MAP350,26,EV026,"(4,0)",8/12,自动执行,开关[二战魔女轰击演出](需ON),,
+856,地图事件,Map350.json MAP350,26,EV026,"(4,0)",8/12,自动执行,开关[二战魔女轰击演出](需ON),,
+857,公共事件,,219,德古拉瞬移到12,18,,,公共事件#219,开关[开场白完成],,
+860,地图事件,Map350.json MAP350,26,EV026,"(4,0)",10/12,自动执行,开关[二战魔女三阶演出](需ON),,
+875,地图事件,Map380.json MAP380,9,EV009,"(5,9)",1/2,按确定键,开关[魔女被推下湖底](需ON),,
+876,地图事件,Map012.json MAP012,12,EV012,"(2,0)",1/2,自动执行,开关[新后日谈喵可莉变猫](需ON),,
+877,地图事件,Map007.json MAP007,28,EV028,"(3,0)",1/2,自动执行,开关[新后日谈剧情完结](需ON),,
+878,地图事件,Map350.json MAP350,26,EV026,"(4,0)",6/12,自动执行,开关[二战魔女二阶后半](需ON),,
+879,地图事件,Map324.json MAP324,7,EV007,"(14,3)",1/2,按确定键,开关[缇娅幻影胜利](需ON),,
+880,地图事件,Map381.json MAP381,21,EV021,"(1,0)",1/2,自动执行,开关[樱校长战斗中剧情对白](需ON),,
+881,地图事件,Map381.json MAP381,21,EV021,"(1,0)",1/2,自动执行,开关[樱校长战斗中剧情对白](需ON),,
+882,地图事件,Map381.json MAP381,22,EV022,"(2,0)",1/2,自动执行,开关[樱校长战斗中剧情对白2](需ON),,
+883,公共事件,,233,魔女幻觉-喵可莉(废弃),,,公共事件#233,开关[开场白完成],,
+884,公共事件,,233,魔女幻觉-喵可莉(废弃),,,公共事件#233,开关[开场白完成],,
+885,公共事件,,233,魔女幻觉-喵可莉(废弃),,,公共事件#233,开关[开场白完成],,
+886,公共事件,,233,魔女幻觉-喵可莉(废弃),,,公共事件#233,开关[开场白完成],,
+887,公共事件,,233,魔女幻觉-喵可莉(废弃),,,公共事件#233,开关[开场白完成],,
+888,公共事件,,233,魔女幻觉-喵可莉(废弃),,,公共事件#233,开关[开场白完成],,
+889,公共事件,,233,魔女幻觉-喵可莉(废弃),,,公共事件#233,开关[开场白完成],,
+890,公共事件,,233,魔女幻觉-喵可莉(废弃),,,公共事件#233,开关[开场白完成],,
+891,公共事件,,233,魔女幻觉-喵可莉(废弃),,,公共事件#233,开关[开场白完成],,
+891,图片名引用,Map350.json,,,,,,,/events/pages/list/parameters 中的 #891#28#ff2020#4#000000,
+892,图片名引用,Map350.json,,,,,,,/events/pages/list/parameters 中的 #892#28#ff2020#4#000000,
+893,图片名引用,Map350.json,,,,,,,/events/pages/list/parameters 中的 #893#28#ff2020#4#000000,
+894,图片名引用,Map350.json,,,,,,,/events/pages/list/parameters 中的 #894#28#ff2020#4#000000,
+895,图片名引用,Map350.json,,,,,,,/events/pages/list/parameters 中的 #895#28#ff2020#4#000000,
+896,图片名引用,Map350.json,,,,,,,/events/pages/list/parameters 中的 #896#28#ff2020#4#000000,
+897,图片名引用,Map350.json,,,,,,,/events/pages/list/parameters 中的 #897#28#ff2020#4#000000,
+898,图片名引用,Map350.json,,,,,,,/events/pages/list/parameters 中的 #898#28#ff2020#4#000000,
+899,图片名引用,Map350.json,,,,,,,/events/pages/list/parameters 中的 #899#28#ff2020#4#000000,
+900,JS:requestFloatMessage(),,,,,,,,js/nya/nya_statem_ext.js:16831,
+900,JS:requestFloatMessage(),,,,,,,,js/nya/nya_statem_ext.js:16853,
+901,地图事件,Map342.json MAP342,3,EV003,"(8,0)",1/2,事件接触,开关[大眼被打败](需ON) 且 开关[小眼被打败](需ON),,
+902,地图事件,Map350.json MAP350,26,EV026,"(4,0)",4/12,自动执行,开关[二战魔女特殊击杀分支](需ON),,
+903,JS:requestFloatMessage(),,,,,,,,js/nya/nya_statem_ext.js:18582,
+906,地图事件,Map350.json MAP350,26,EV026,"(4,0)",6/12,自动执行,开关[二战魔女二阶后半](需ON),,
+907,地图事件,Map350.json MAP350,26,EV026,"(4,0)",10/12,自动执行,开关[二战魔女三阶演出](需ON),,
+908,地图事件,Map350.json MAP350,26,EV026,"(4,0)",10/12,自动执行,开关[二战魔女三阶演出](需ON),,
+909,地图事件,Map350.json MAP350,26,EV026,"(4,0)",10/12,自动执行,开关[二战魔女三阶演出](需ON),,
+910,地图事件,Map350.json MAP350,26,EV026,"(4,0)",10/12,自动执行,开关[二战魔女三阶演出](需ON),,
+911,地图事件,Map350.json MAP350,26,EV026,"(4,0)",10/12,自动执行,开关[二战魔女三阶演出](需ON),,
+913,地图事件,Map350.json MAP350,30,EV030,"(6,0)",4/7,自动执行,独立开关A=ON,,
+914,地图事件,Map350.json MAP350,30,EV030,"(6,0)",6/7,自动执行,开关[璃音最终登场](需ON),,
+915,地图事件,Map350.json MAP350,30,EV030,"(6,0)",6/7,自动执行,开关[璃音最终登场](需ON),,
+916,地图事件,Map350.json MAP350,26,EV026,"(4,0)",10/12,自动执行,开关[二战魔女三阶演出](需ON),,
+917,JS:requestFloatMessage(),,,,,,,,js/nya/nya_statem_ext.js:18625,
+918,JS:requestFloatMessage(),,,,,,,,js/nya/nya_statem_ext.js:18628,
+919,JS:requestFloatMessage(),,,,,,,,js/nya/nya_statem_ext.js:18631,
+920,JS:requestFloatMessage(),,,,,,,,js/nya/nya_statem_ext.js:18634,
+921,地图事件,Map327.json MAP327,18,EV018,"(0,0)",1/4,自动执行,开关[缇娅幻影胜利](需ON),,
+922,JS:requestFloatMessage(),,,,,,,,js/nya/nya_statem_ext.js:18751,
+923,JS:requestFloatMessage(),,,,,,,,js/nya/nya_statem_ext.js:18235,
+924,JS:requestFloatMessage(),,,,,,,,js/nya/nya_statem_ext.js:18788,
+925,JS:requestFloatMessage(),,,,,,,,js/nya/nya_statem_ext.js:18852,
+926,JS:requestFloatMessage(),,,,,,,,js/nya/nya_statem_ext.js:18289,
+927,JS:requestFloatMessage(),,,,,,,,js/nya/nya_statem_ext.js:18953,
+928,JS:requestFloatMessage(),,,,,,,,js/nya/nya_statem_ext.js:18305,
+929,JS:requestFloatMessage(),,,,,,,,js/nya/nya_statem_ext.js:18317,
+931,JS:requestFloatMessage(),,,,,,,,js/nya/nya_statem_ext.js:18329,
+932,JS:requestFloatMessage(),,,,,,,,js/nya/nya_statem_ext.js:18341,
+933,JS:requestFloatMessage(),,,,,,,,js/nya/nya_statem_ext.js:18353,
+934,地图事件,Map350.json MAP350,26,EV026,"(4,0)",10/12,自动执行,开关[二战魔女三阶演出](需ON),,
+935,地图事件,Map350.json MAP350,4,EV004,"(2,7)",1/3,事件接触,开关[传送魔法失效](需ON),,
+936,地图事件,Map045.json MAP045,10,EV010,"(4,5)",1/1,按确定键,开关[新后日谈剧情完结](需ON),,
+937,地图事件,Map045.json MAP045,10,EV010,"(4,5)",1/1,按确定键,开关[新后日谈剧情完结](需ON),,
+938,地图事件,Map046.json MAP046,11,EV011,"(19,6)",1/1,按确定键,开关[新后日谈剧情完结](需ON),,
+939,地图事件,Map046.json MAP046,11,EV011,"(19,6)",1/1,按确定键,开关[新后日谈剧情完结](需ON),,
+940,地图事件,Map045.json MAP045,7,EV007,"(12,5)",1/1,按确定键,开关[新后日谈剧情完结](需ON),,
+941,地图事件,Map045.json MAP045,7,EV007,"(12,5)",1/1,按确定键,开关[新后日谈剧情完结](需ON),,
+942,地图事件,Map044.json MAP044,14,EV014,"(12,5)",1/1,按确定键,开关[新后日谈剧情完结](需ON),,
+943,地图事件,Map044.json MAP044,14,EV014,"(12,5)",1/1,按确定键,开关[新后日谈剧情完结](需ON),,
+944,地图事件,Map044.json MAP044,15,EV015,"(11,4)",1/1,按确定键,开关[新后日谈剧情完结](需ON),,
+945,地图事件,Map044.json MAP044,15,EV015,"(11,4)",1/1,按确定键,开关[新后日谈剧情完结](需ON),,
+946,地图事件,Map044.json MAP044,16,EV016,"(7,5)",1/1,按确定键,开关[新后日谈剧情完结](需ON),,
+947,地图事件,Map044.json MAP044,16,EV016,"(7,5)",1/1,按确定键,开关[新后日谈剧情完结](需ON),,
+948,地图事件,Map045.json MAP045,8,EV008,"(3,5)",1/1,按确定键,开关[新后日谈剧情完结](需ON),,
+949,地图事件,Map045.json MAP045,8,EV008,"(3,5)",1/1,按确定键,开关[新后日谈剧情完结](需ON),,
+950,地图事件,Map045.json MAP045,9,EV009,"(11,5)",1/1,按确定键,开关[新后日谈剧情完结](需ON),,
+951,地图事件,Map045.json MAP045,9,EV009,"(11,5)",1/1,按确定键,开关[新后日谈剧情完结](需ON),,
+952,地图事件,Map009.json MAP009,3,EV003,"(4,9)",16/16,按确定键,开关[新结局后日谈](需ON) 且 开关[新后日谈剧情完结](需ON),,
+953,地图事件,Map009.json MAP009,3,EV003,"(4,9)",16/16,按确定键,开关[新结局后日谈](需ON) 且 开关[新后日谈剧情完结](需ON),,
+954,地图事件,Map048.json MAP048,9,EV009,"(10,6)",1/1,按确定键,开关[新后日谈剧情完结](需ON),,
+955,地图事件,Map048.json MAP048,9,EV009,"(10,6)",1/1,按确定键,开关[新后日谈剧情完结](需ON),,
+956,地图事件,Map048.json MAP048,7,EV007,"(12,6)",1/1,按确定键,开关[新后日谈剧情完结](需ON),,
+957,地图事件,Map048.json MAP048,7,EV007,"(12,6)",1/1,按确定键,开关[新后日谈剧情完结](需ON),,
+958,地图事件,Map048.json MAP048,8,EV008,"(13,6)",1/1,按确定键,开关[新后日谈剧情完结](需ON),,
+959,地图事件,Map048.json MAP048,8,EV008,"(13,6)",1/1,按确定键,开关[新后日谈剧情完结](需ON),,
+960,地图事件,Map039.json MAP039,62,EV062,"(33,42)",1/1,按确定键,开关[新后日谈剧情完结](需ON),,
+960,地图事件,Map039.json MAP039,63,EV063,"(36,44)",1/1,按确定键,开关[新后日谈剧情完结](需ON),,
+961,地图事件,Map007.json MAP007,29,EV029,"(12,9)",1/1,按确定键,开关[新后日谈剧情完结](需ON),,
+961,地图事件,Map007.json MAP007,33,EV033,"(13,9)",1/1,按确定键,开关[新后日谈剧情完结](需ON),,
+962,地图事件,Map006.json MAP006,47,EV047,"(28,6)",1/1,按确定键,开关[新后日谈剧情完结](需ON),,
+962,地图事件,Map006.json MAP006,48,EV048,"(30,6)",1/1,按确定键,开关[新后日谈剧情完结](需ON),,
diff --git a/tool/README.md b/tool/README.md
new file mode 100644
index 0000000..2050f68
--- /dev/null
+++ b/tool/README.md
@@ -0,0 +1,26 @@
+# 咸鱼喵喵截图辅助工具:简单使用方法
+
+一个在浏览器里操作的本地辅助工具,支持剧情全文搜索、传送、播放对白、角色摆拍,以及角色数值和背包调整,方便回看剧情和截图。
+
+需要 Windows、已安装的《咸鱼喵喵》、[Node.js 22 或更新版本](https://nodejs.org/),以及 [NW.js SDK 0.64.1(Windows x64)](https://dl.nwjs.io/v0.64.1/nwjs-sdk-v0.64.1-win-x64.zip)。发布包不含游戏本体、存档、完整剧本和上述运行环境;无需 npm install。
+
+1. 将包内的 `aitemp截图工具` 文件夹放进游戏安装目录,与游戏的 `package.json` 同级。可从 Steam 的“管理 → 浏览本地文件”找到游戏目录。
+2. 解压 NW.js SDK,把 `nwjs-sdk-v0.64.1-win-x64` 文件夹放进 `aitemp截图工具`。确认路径如下,别多套一层文件夹:
+
+ ```text
+ 游戏目录/
+ package.json
+ aitemp截图工具/
+ tool/启动.bat
+ nwjs-sdk-v0.64.1-win-x64/nw.exe
+ out/triggers.csv
+ unpacked/data/...
+ ```
+
+3. 先在原游戏中正常保存一次并退出。双击 `tool/启动.bat`,首次选择 **1:只启动面板**,在“存档与沙盒”页创建游戏副本。副本需要额外磁盘空间,默认放在游戏所在盘的 `Nyaruru_shots` 文件夹。
+4. 副本创建完成后,再次双击 `启动.bat`,工具会备份存档并启动副本游戏。在游戏里正常读档进入地图,确认面板顶部连接的是沙盒。
+5. 在面板搜索台词或故事编号,按需要选择“前往”“运行过场”或“只播台词”;摆拍后可点“撤销场景还原”。全文台词在连接游戏后自动读取,断开连接后清空。
+
+面板默认地址为 `http://127.0.0.1:8787`。用完双击 `tool/停止面板.bat`,游戏窗口另行关闭。升级前先退出旧面板和游戏。
+
+建议始终在沙盒里使用:剧情操作可能改变进度,自动存档和手动保存照常生效。“全部还原”会解除临时效果和锁定,但不会回退已经改过的数值、背包或剧情进度;摆拍状态也不保证能通过读档完整恢复。详细说明见包内 `tool/使用须知.md`。
diff --git a/tool/launcher.js b/tool/launcher.js
new file mode 100644
index 0000000..119d4ab
--- /dev/null
+++ b/tool/launcher.js
@@ -0,0 +1,106 @@
+const fs = require('fs');
+const path = require('path');
+const net = require('net');
+const { spawn } = require('child_process');
+const readline = require('readline/promises');
+const { readConfig, samePath } = require('./lib/safety');
+const FILES = require('./lib/files');
+const { CdpClient } = require('./lib/cdp');
+const { OPS } = require('./lib/ops');
+const TOOL = __dirname, AITEMP = path.dirname(TOOL), GAME = path.dirname(AITEMP);
+const sleep = ms => new Promise(r => setTimeout(r, ms));
+async function listening(port) {
+ return new Promise(resolve => {
+ const s = net.connect({ host: '127.0.0.1', port });
+ const finish = yes => { s.destroy(); resolve(yes); };
+ s.once('connect', () => finish(true)); s.once('error', () => finish(false)); s.setTimeout(1000, () => finish(false));
+ });
+}
+async function panel(config, matchConfig = true) {
+ const r = await fetch(`http://127.0.0.1:${config.webPort}/api/health`, { signal: AbortSignal.timeout(1500) });
+ const data = await r.json();
+ if (!r.ok || data.version !== 2 || !data.toolDir || !samePath(data.toolDir, TOOL) || (matchConfig && JSON.stringify(data.config) !== JSON.stringify(config))) {
+ throw new Error('面板端口运行的是旧服务、其他版本或旧配置。请先关闭那个面板服务,再重新启动');
+ }
+ return data;
+}
+async function main() {
+ if (Number(process.versions.node.split('.')[0]) < 22) throw new Error('需要 Node.js 22 或更新版本');
+ const config = readConfig(path.join(TOOL, 'config.json'), { cdpPort: 9223, sandboxDir: path.join(path.parse(GAME).root, 'Nyaruru_shots'), webPort: 8787 }, GAME);
+ if (process.argv.includes('--stop')) {
+ const runtimeFile = path.join(TOOL, '.server-runtime.json');
+ const port = fs.existsSync(runtimeFile) ? JSON.parse(fs.readFileSync(runtimeFile, 'utf8')).port : config.webPort;
+ await panel({ ...config, webPort: port }, false);
+ await fetch(`http://127.0.0.1:${port}/api/shutdown`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' });
+ console.log('控制面板服务已停止,游戏窗口保留'); return;
+ }
+ const runtimeFile = path.join(TOOL, '.server-runtime.json');
+ if (fs.existsSync(runtimeFile)) {
+ const runtime = JSON.parse(fs.readFileSync(runtimeFile, 'utf8'));
+ if (runtime.port !== config.webPort && await listening(runtime.port)) {
+ await panel({ ...config, webPort: runtime.port }, false);
+ throw new Error('旧面板仍在原端口运行,请先双击“停止面板.bat”,再重新启动');
+ }
+ }
+ console.log('咸鱼喵喵 · 截图辅助工具\n调试端口 ' + config.cdpPort + ' / 面板端口 ' + config.webPort);
+ const panelRunning = await listening(config.webPort);
+ if (panelRunning) await panel(config);
+ const status = FILES.sandboxStatus(GAME, config.sandboxDir, AITEMP);
+ let target = status.ready ? config.sandboxDir : null;
+ if (!status.ready) {
+ console.log('沙盒尚不可用:' + status.reason + '\n[1] 只启动面板,创建沙盒\n[2] 启动原版游戏\n[3] 退出');
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
+ let choice; try { choice = (await rl.question('请选择(默认 1):')).trim(); } finally { rl.close(); }
+ if (choice === '3') { console.log('已退出'); return; }
+ if (choice === '2') target = GAME;
+ else if (choice && choice !== '1') throw new Error('无效选择,已退出');
+ }
+ if (target) {
+ const occupied = await listening(config.cdpPort);
+ if (occupied) {
+ const client = new CdpClient(config.cdpPort);
+ try {
+ const info = await client.evalJson(OPS.selfPath());
+ if (!info.ok || !info.gameDir || !samePath(info.gameDir, target) || !info.saveDir || !samePath(info.saveDir, path.join(target, 'save'))) {
+ throw new Error('调试端口上的游戏与目标不符。期望:' + target + ';实际:' + (info.gameDir || '未知'));
+ }
+ } catch (e) { throw new Error('未启动面板:不能确认已有游戏实例。' + e.message); }
+ finally { client.close(); }
+ }
+ const backup = FILES.backupSaves(path.join(AITEMP, 'backups'), target, samePath(target, GAME) ? 'original' : 'sandbox');
+ console.log('存档已备份并校验:' + backup.name);
+ if (!occupied) {
+ const nw = path.join(AITEMP, 'nwjs-sdk-v0.64.1-win-x64', 'nw.exe');
+ if (!fs.existsSync(nw)) throw new Error('缺少 SDK 版 NW.js 0.64.1:' + nw + '\n下载:https://dl.nwjs.io/v0.64.1/nwjs-sdk-v0.64.1-win-x64.zip');
+ const child = spawn(nw, [target, '--remote-debugging-port=' + config.cdpPort], { cwd: path.dirname(nw), detached: true, stdio: 'ignore', windowsHide: true });
+ await new Promise((resolve, reject) => { child.once('spawn', resolve); child.once('error', reject); }); child.unref();
+ const client = new CdpClient(config.cdpPort);
+ let ready = false;
+ try {
+ for (let i = 0; i < 20; i++) {
+ try {
+ const info = await client.evalJson(OPS.selfPath());
+ if (!info.ok || !info.gameDir || !samePath(info.gameDir, target) || !info.saveDir || !samePath(info.saveDir, path.join(target, 'save'))) throw new Error('实际游戏路径不符');
+ ready = true; break;
+ } catch { client.close(); await sleep(500); }
+ }
+ } finally { client.close(); }
+ if (!ready) throw new Error('游戏未能就绪或实例身份不符,未启动面板。请检查游戏窗口');
+ }
+ console.log('当前游戏:' + target);
+ }
+ if (!panelRunning) {
+ const log = fs.openSync(path.join(TOOL, 'server.log'), 'a');
+ const child = spawn(process.execPath, [path.join(TOOL, 'server.js')], { cwd: TOOL, detached: true, stdio: ['ignore', log, log], windowsHide: true });
+ await new Promise((resolve, reject) => { child.once('spawn', resolve); child.once('error', reject); }); child.unref(); fs.closeSync(log);
+ let ready = false;
+ for (let i = 0; i < 20; i++) { try { await panel(config); ready = true; break; } catch { await sleep(300); } }
+ if (!ready) throw new Error('面板启动失败,请查看 tool/server.log');
+ }
+ const url = `http://127.0.0.1:${config.webPort}`;
+ const browser = spawn('rundll32.exe', ['url.dll,FileProtocolHandler', url], { detached: true, stdio: 'ignore', windowsHide: true });
+ browser.on('error', () => console.log('请手动打开:' + url)); browser.unref();
+ console.log('面板已就绪:' + url + (target ? '' : '\n本次没有启动游戏。创建沙盒后再次运行启动.bat'));
+}
+if (require.main === module) main().catch(e => { console.error('\n启动停止:' + e.message); process.exitCode = 1; });
+module.exports = { main, listening, panel };
diff --git a/tool/lib/cdp.js b/tool/lib/cdp.js
new file mode 100644
index 0000000..3652cfd
--- /dev/null
+++ b/tool/lib/cdp.js
@@ -0,0 +1,97 @@
+// 单连接 CDP 客户端,要求 Node.js 22+(内置 fetch / WebSocket)。
+const RECONNECT_DELAY_MS = 3000;
+class CdpClient {
+ constructor(port = 9223) {
+ this.port = port; this.ws = null; this.sessionId = null;
+ this._id = 0; this._pending = new Map(); this._connecting = null; this.generation = 0;
+ }
+ get base() { return `http://127.0.0.1:${this.port}`; }
+ async isAlive() {
+ try {
+ const r = await fetch(`${this.base}/json/version`, { signal: AbortSignal.timeout(1500) });
+ return r.ok && !!(await r.json()).webSocketDebuggerUrl;
+ } catch { return false; }
+ }
+ connect() {
+ if (this.ws?.readyState === 1 && this.sessionId) return Promise.resolve();
+ if (this._connecting) return this._connecting;
+ const generation = ++this.generation;
+ const attempt = this._connect(generation);
+ this._connecting = attempt;
+ attempt.finally(() => { if (this._connecting === attempt) this._connecting = null; }).catch(() => {});
+ return attempt;
+ }
+ async _connect(generation) {
+ let ws;
+ try {
+ const r = await fetch(`${this.base}/json/version`, { signal: AbortSignal.timeout(3000) });
+ if (!r.ok) throw new Error('调试端口没有返回 CDP 信息');
+ const v = await r.json();
+ if (this.generation !== generation) throw new Error('连接已取消');
+ ws = new WebSocket(v.webSocketDebuggerUrl);
+ this.ws = ws;
+ await new Promise((resolve, reject) => {
+ const timer = setTimeout(() => { reject(new Error('连接游戏超时')); try { ws.close(); } catch {} }, 4000);
+ const fail = () => { clearTimeout(timer); reject(new Error('无法建立游戏调试连接')); };
+ ws.onopen = () => { clearTimeout(timer); resolve(); };
+ ws.onerror = fail; ws.onclose = fail;
+ });
+ if (this.generation !== generation) throw new Error('连接已取消');
+ ws.onmessage = ev => {
+ let msg; try { msg = JSON.parse(ev.data); } catch { return; }
+ const p = this._pending.get(msg.id);
+ if (p && p.ws === ws) {
+ this._pending.delete(msg.id); clearTimeout(p.timer);
+ if (msg.error) p.reject(new Error(JSON.stringify(msg.error))); else p.resolve(msg.result);
+ }
+ if (msg.method === 'Target.detachedFromTarget' && msg.params?.sessionId === this.sessionId && this.ws === ws) this.close();
+ };
+ ws.onclose = () => this._drop(ws, new Error('游戏连接已断开'));
+ ws.onerror = () => { this._drop(ws, new Error('游戏调试连接出错')); try { ws.close(); } catch {} };
+ const { targetInfos } = await this._send('Target.getTargets', {}, false);
+ const pages = targetInfos.filter(t => t.type === 'page' && /\/index\.html?(?:[?#]|$)/i.test(t.url || ''));
+ if (pages.length !== 1) throw new Error('无法唯一识别游戏主页面,请关闭多余游戏窗口');
+ const { sessionId } = await this._send('Target.attachToTarget', { targetId: pages[0].targetId, flatten: true }, false);
+ if (this.ws !== ws || this.generation !== generation) throw new Error('连接已更换');
+ this.sessionId = sessionId;
+ } catch (e) {
+ if (ws) { this._drop(ws, e); try { ws.close(); } catch {} }
+ throw e;
+ }
+ }
+ _drop(ws, error) {
+ for (const [id, p] of this._pending) if (p.ws === ws) {
+ clearTimeout(p.timer); p.reject(error); this._pending.delete(id);
+ }
+ if (this.ws === ws) { this.ws = null; this.sessionId = null; }
+ }
+ _send(method, params = {}, session = true) {
+ const ws = this.ws;
+ if (!ws || ws.readyState !== 1) return Promise.reject(new Error('游戏连接已断开'));
+ const id = ++this._id, payload = { id, method, params };
+ if (session && this.sessionId) payload.sessionId = this.sessionId;
+ return new Promise((resolve, reject) => {
+ const timer = setTimeout(() => { this._pending.delete(id); reject(new Error(`CDP 调用超时: ${method};不要立即重复操作,请先检查游戏状态`)); }, 20000);
+ this._pending.set(id, { resolve, reject, timer, ws });
+ try { ws.send(JSON.stringify(payload)); }
+ catch (e) { clearTimeout(timer); this._pending.delete(id); reject(e); }
+ });
+ }
+ async eval(expression) {
+ await this.connect();
+ const res = await this._send('Runtime.evaluate', { expression, returnByValue: true, awaitPromise: true });
+ if (res.exceptionDetails) throw new Error(res.exceptionDetails.exception?.description || res.exceptionDetails.text);
+ return res.result?.value;
+ }
+ async evalJson(expression) {
+ const value = await this.eval(expression);
+ return typeof value === 'string' ? JSON.parse(value) : value;
+ }
+ async screenshot() { await this.connect(); return (await this._send('Page.captureScreenshot', { format: 'png' })).data; }
+ close() {
+ ++this.generation; this._connecting = null;
+ const ws = this.ws;
+ if (ws) { this._drop(ws, new Error('游戏连接已关闭或切换')); try { ws.close(); } catch {} }
+ }
+}
+module.exports = { CdpClient, RECONNECT_DELAY_MS };
diff --git a/tool/lib/data.js b/tool/lib/data.js
new file mode 100644
index 0000000..ad6e491
--- /dev/null
+++ b/tool/lib/data.js
@@ -0,0 +1,216 @@
+// 静态数据加载
+//
+// 数据来源全部是**之前分析阶段已产出的本地文件**(aitemp/out 和 aietmp/unpacked),
+// 工具运行时只读它们,不碰游戏目录里的任何文件。
+//
+// unpacked/data/System.json → 360 个开关的中文名
+// unpacked/data/MapInfos.json → 393 张地图
+// out/triggers.csv → 897 条剧情触发点(其中 731 条有坐标)
+//
+// ⚠️ **台词不在这里。** 这是"C 方案"(见开发文档 12.5)的核心约定:
+// 随包分发的只有**结构索引**(哪里有个触发点),
+// 一个字创作内容都不带。台词在连上游戏后从 `$dataStory` 实时读,
+// 只存在于内存,**绝不落盘**(读取逻辑在 lib/ops.js 的 readStoryIndex)。
+//
+// 历史:早期版本这里有个 loadStoryIndex(),启动时读 ../out/story.json
+// (1.9 MB,全部 870 条剧情的完整台词)。那是作者的原创剧本,
+// 随包外传等于分发游戏内容,发布版必须去掉。
+
+const fs = require('fs');
+const path = require('path');
+
+/** 极简 CSV 解析(RFC4180:支持引号包裹、字段内逗号、双引号转义) */
+function parseCsv(text) {
+ const rows = [];
+ let row = [], field = '', inQuotes = false;
+ // 去掉 BOM
+ if (text.charCodeAt(0) === 0xFEFF) text = text.slice(1);
+ for (let i = 0; i < text.length; i++) {
+ const c = text[i];
+ if (inQuotes) {
+ if (c === '"') {
+ if (text[i + 1] === '"') { field += '"'; i++; }
+ else inQuotes = false;
+ } else field += c;
+ } else if (c === '"') {
+ inQuotes = true;
+ } else if (c === ',') {
+ row.push(field); field = '';
+ } else if (c === '\n') {
+ row.push(field); rows.push(row); row = []; field = '';
+ } else if (c !== '\r') {
+ field += c;
+ }
+ }
+ if (field !== '' || row.length) { row.push(field); rows.push(row); }
+ return rows;
+}
+
+function readJson(file) {
+ return JSON.parse(fs.readFileSync(file, 'utf8'));
+}
+
+/**
+ * 载入每张地图的 BGM 名,用来做**粗略的区域提示**。
+ *
+ * 游戏的地图在 MapInfos.json 里只有 "MAP170" 这种编号,没有地点名
+ * (Map*.json 里的 displayName 和 meta 都是空的)。
+ * 但 BGM 文件名有区分度:cave(24张) / black_forest(26张) / lake(23张)…
+ * 所以拿它当"这片区域大概是什么"的线索,聊胜于无。
+ */
+const AREA_NAMES = {
+ cave: '洞窟', black_forest: '黑森林', lake: '湖畔',
+ town: '城镇', boss: 'Boss 战', title: '标题',
+};
+
+function loadMapAreas(dataDir) {
+ const areas = new Map();
+ let files;
+ try { files = fs.readdirSync(dataDir); } catch { return areas; }
+
+ for (const f of files) {
+ const m = /^Map(\d+)\.json$/.exec(f);
+ if (!m) continue;
+ try {
+ const d = readJson(path.join(dataDir, f));
+ const bgm = (d.bgm && d.bgm.name) || '';
+ if (bgm) areas.set(Number(m[1]), AREA_NAMES[bgm] || bgm);
+ } catch { /* 个别地图文件损坏不影响整体 */ }
+ }
+ return areas;
+}
+
+/**
+ * 载入全部静态数据。
+ * @param {string} baseDir aitemp 目录
+ */
+function loadAll(baseDir) {
+ const outDir = path.join(baseDir, 'out');
+ const dataDir = path.join(baseDir, 'unpacked', 'data');
+
+ // ---- 开关 ----
+ const sys = readJson(path.join(dataDir, 'System.json'));
+ const switches = [];
+ const switchNameToId = new Map();
+ for (let i = 1; i < sys.switches.length; i++) {
+ const name = sys.switches[i];
+ if (name === undefined || name === null) continue;
+ switches.push({ id: i, name: String(name) });
+ // 同名开关极少见,出现时保留首个
+ if (!switchNameToId.has(String(name))) switchNameToId.set(String(name), i);
+ }
+
+ // ---- 变量 ----
+ const variables = [];
+ for (let i = 1; i < sys.variables.length; i++) {
+ const name = sys.variables[i];
+ if (name === undefined || name === null) continue;
+ variables.push({ id: i, name: String(name) });
+ }
+
+ // ---- 地图 ----
+ const mapInfos = readJson(path.join(dataDir, 'MapInfos.json'));
+ const maps = mapInfos
+ .filter(m => m && m.id)
+ .map(m => ({ id: m.id, name: String(m.name || ''), parentId: m.parentId || 0 }));
+
+ // ---- 区域名 ----
+ // 台词**不在这里**:它由 server.js 在连上游戏后从 $dataStory 实时补进内存
+ const mapAreas = loadMapAreas(dataDir);
+
+ // ---- 剧情触发点 ----
+ const csvPath = path.join(outDir, 'triggers.csv');
+ const raw = parseCsv(fs.readFileSync(csvPath, 'utf8'));
+ const header = raw[0];
+ const col = (row, key) => row[header.indexOf(key)] ?? '';
+
+ const triggers = [];
+ for (let i = 1; i < raw.length; i++) {
+ const r = raw[i];
+ if (!r || r.length < 2) continue;
+
+ // 地图字段形如 "Map004.json MAP004",从中取出数字 ID
+ const mapField = col(r, '地图');
+ const mapMatch = /Map0*(\d+)\.json/i.exec(mapField);
+ const mapId = mapMatch ? Number(mapMatch[1]) : null;
+
+ // 坐标字段形如 "(0,0)"
+ const coordField = col(r, '坐标');
+ const coordMatch = /\(\s*(\d+)\s*,\s*(\d+)\s*\)/.exec(coordField);
+ const x = coordMatch ? Number(coordMatch[1]) : null;
+ const y = coordMatch ? Number(coordMatch[2]) : null;
+
+ const condition = col(r, '条件').trim();
+
+ // 条件里引用的开关名(形如 开关[第一天夜晚莉莉亚回家](需ON))
+ const needSwitchNames = [];
+ for (const m of condition.matchAll(/开关\[([^\]]+)\]/g)) needSwitchNames.push(m[1]);
+ const needVarNames = [];
+ for (const m of condition.matchAll(/变量\[([^\]]+)\]/g)) needVarNames.push(m[1]);
+
+ const storyId = Number(col(r, '故事序号')) || 0;
+
+ triggers.push({
+ storyId,
+ source: col(r, '来源'),
+ mapId,
+ mapArea: mapId !== null ? (mapAreas.get(mapId) || '') : '',
+ eventId: col(r, '事件ID'),
+ eventName: col(r, '事件名'),
+ x, y,
+ page: col(r, '页'),
+ pageIndex: /^\d+\//.test(col(r, '页')) ? Number(col(r, '页').split('/')[0]) - 1 : null,
+ triggerType: col(r, '触发方式'),
+ condition,
+ needSwitchNames,
+ needVarNames,
+ // ⚠️ 这里**故意没有** line / speaker / lineCount —— 只有 storyId。
+ // UI 上那句台词预览由 server.js 在拼响应时从内存里的台词表补上
+ // (见 /api/triggers),没连游戏时就是空的,前端退回显示地图+坐标。
+ // 有地图和坐标的才能"一键前往"
+ actionable: mapId !== null && x !== null && y !== null,
+ });
+ }
+
+ // 仅合并完全相同的候选;不同事件、页和条件必须保留。
+ const seen = new Set();
+ const uniqueTriggers = [];
+ for (const t of triggers) {
+ const k = `${t.storyId}|${t.mapId}|${t.eventId}|${t.page}|${t.x}|${t.y}|${t.condition}`;
+ if (seen.has(k)) continue;
+ seen.add(k);
+ uniqueTriggers.push(t);
+ }
+
+ // ---- 常用开关:被触发点条件引用到的那些 ----
+ // 这是"截图工作真正会用到的开关"的精确集合,用来做开关面板的默认视图
+ const commonSwitchIds = [];
+ for (const name of new Set(uniqueTriggers.flatMap(t => t.needSwitchNames))) {
+ const id = switchNameToId.get(name);
+ if (id !== undefined) commonSwitchIds.push(id);
+ }
+ commonSwitchIds.sort((a, b) => a - b);
+
+ return {
+ switches,
+ variables,
+ maps,
+ triggers: uniqueTriggers,
+ commonSwitchIds,
+ switchNameToId,
+ stats: {
+ switches: switches.length,
+ variables: variables.length,
+ maps: maps.length,
+ triggersTotal: triggers.length,
+ triggersUnique: uniqueTriggers.length,
+ triggersActionable: uniqueTriggers.filter(t => t.actionable).length,
+ commonSwitches: commonSwitchIds.length,
+ mapAreas: mapAreas.size,
+ // 注意:storyLoaded / triggersWithLine **不在这里** ——
+ // 台词要连上游戏才知道,它们是运行时值,由 server.js 的 /api/status 现算。
+ },
+ };
+}
+
+module.exports = { loadAll, parseCsv };
diff --git a/tool/lib/files.js b/tool/lib/files.js
new file mode 100644
index 0000000..0b6e0c0
--- /dev/null
+++ b/tool/lib/files.js
@@ -0,0 +1,145 @@
+const fs = require('fs');
+const path = require('path');
+const crypto = require('crypto');
+const { execFile } = require('child_process');
+const { canonical, samePath, contains } = require('./safety');
+const MANIFEST = 'backup-manifest.json';
+const COMPLETE = '.nyaruru-sandbox-complete.json';
+const INCOMPLETE = '.nyaruru-sandbox-incomplete';
+const hash = file => crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
+const saveNames = dir => fs.existsSync(dir) ? fs.readdirSync(dir).filter(n => /\.rmmzsave$/i.test(n)).sort() : [];
+function safeFile(dir, name) {
+ if (path.basename(name) !== name || !/\.rmmzsave$/i.test(name)) throw new Error('非法存档文件名');
+ const file = path.join(dir, name), st = fs.lstatSync(file);
+ if (!st.isFile() || st.isSymbolicLink()) throw new Error('存档必须是普通文件:' + name);
+ return file;
+}
+function backupSaves(root, sourceDir, label = 'auto') {
+ const saveDir = path.join(canonical(sourceDir), 'save');
+ if (!fs.existsSync(saveDir) || !samePath(saveDir, path.join(sourceDir, 'save')) || fs.lstatSync(saveDir).isSymbolicLink()) throw new Error('存档目录不存在或是目录链接');
+ const names = saveNames(saveDir);
+ if (!names.length) throw new Error('没有找到 .rmmzsave 存档,未创建空备份');
+ fs.mkdirSync(root, { recursive: true });
+ const dest = fs.mkdtempSync(path.join(root, `${label.replace(/[^a-z0-9_-]/gi, '')}_${new Date().toISOString().replace(/[:.]/g, '-')}_`));
+ // 未完成目录没有 manifest,不会被列为可恢复备份。
+ const files = names.map(name => ({ name, sha256: hash(safeFile(saveDir, name)) }));
+ for (const f of files) fs.copyFileSync(safeFile(saveDir, f.name), path.join(dest, f.name), fs.constants.COPYFILE_EXCL);
+ if (JSON.stringify(names) !== JSON.stringify(saveNames(saveDir)) || files.some(f => hash(safeFile(saveDir, f.name)) !== f.sha256 || hash(path.join(dest, f.name)) !== f.sha256)) {
+ throw new Error('备份期间存档发生变化或复制校验失败,请停止存档操作后重试。未完成目录:' + dest);
+ }
+ const manifest = { version: 1, sourceDir: canonical(sourceDir), createdAt: new Date().toISOString(), files };
+ fs.writeFileSync(path.join(dest, MANIFEST), JSON.stringify(manifest, null, 2), { flag: 'wx' });
+ return { dir: dest, name: path.basename(dest), files: files.length, from: saveDir };
+}
+function inspectBackup(root, name) {
+ if (typeof name !== 'string' || name === '.' || name === '..' || /[\\/:]/.test(name)) throw new Error('非法备份名');
+ const dir = path.join(root, name);
+ if (!fs.existsSync(dir) || fs.lstatSync(dir).isSymbolicLink() || !fs.statSync(dir).isDirectory()) throw new Error('备份不存在或是目录链接');
+ let manifest = null;
+ const meta = path.join(dir, MANIFEST);
+ if (fs.existsSync(meta)) {
+ manifest = JSON.parse(fs.readFileSync(meta, 'utf8'));
+ if (!Array.isArray(manifest.files) || !manifest.files.length) throw new Error('备份清单无效');
+ } else if (!/^\d{8}_\d{6}$|^[a-z][a-z0-9_-]*_\d{8}_\d{6}$/i.test(name)) {
+ throw new Error('备份没有完成标记');
+ }
+ const files = manifest ? manifest.files : saveNames(dir).map(name => ({ name }));
+ if (!files.length || new Set(files.map(f => f.name)).size !== files.length) throw new Error('备份为空或文件重复');
+ for (const f of files) {
+ const digest = hash(safeFile(dir, f.name));
+ if (f.sha256 && digest !== f.sha256) throw new Error('备份内容校验失败:' + f.name);
+ f.sha256 = digest;
+ }
+ return { dir, manifest, files };
+}
+function listBackups(root) {
+ if (!fs.existsSync(root)) return [];
+ return fs.readdirSync(root).flatMap(name => {
+ try {
+ const b = inspectBackup(root, name);
+ return [{ name, files: b.files.length, sourceDir: b.manifest?.sourceDir || null, legacy: !b.manifest, mtime: fs.statSync(b.dir).mtimeMs }];
+ } catch { return []; }
+ }).sort((a, b) => b.mtime - a.mtime);
+}
+function restoreBackup(root, name, targetDir) {
+ const b = inspectBackup(root, name);
+ const target = canonical(targetDir), saveDir = path.join(target, 'save');
+ if (fs.existsSync(saveDir) && fs.lstatSync(saveDir).isSymbolicLink()) throw new Error('不能还原到存档目录链接');
+ const safety = saveNames(saveDir).length ? backupSaves(root, target, 'prerestore') : null;
+ const stage = fs.mkdtempSync(path.join(target, '.save-restore-'));
+ // 保留云配置等非存档文件;存档集合严格来自选中的备份。
+ if (fs.existsSync(saveDir)) {
+ for (const entry of fs.readdirSync(saveDir, { withFileTypes: true })) {
+ if (entry.isSymbolicLink() || entry.isDirectory()) throw new Error('存档目录含链接或子目录,停止还原');
+ if (!/\.rmmzsave$/i.test(entry.name)) fs.copyFileSync(path.join(saveDir, entry.name), path.join(stage, entry.name));
+ }
+ }
+ for (const f of b.files) {
+ fs.copyFileSync(path.join(b.dir, f.name), path.join(stage, f.name));
+ if (hash(path.join(stage, f.name)) !== f.sha256) throw new Error('还原暂存校验失败');
+ }
+ const old = path.join(target, `.save-before-restore-${crypto.randomUUID()}`);
+ const hadSave = fs.existsSync(saveDir);
+ if (hadSave) fs.renameSync(saveDir, old);
+ try { fs.renameSync(stage, saveDir); }
+ catch (e) { if (hadSave) fs.renameSync(old, saveDir); throw e; }
+ return { restored: b.files.length, target: saveDir, safetyBackup: safety?.name || null, previousDirectory: hadSave ? old : null };
+}
+function excludedDirs(gameDir, toolDir) {
+ return fs.readdirSync(gameDir, { withFileTypes: true }).filter(e => e.isDirectory() && (
+ /^aitemp/i.test(e.name) || samePath(path.join(gameDir, e.name), toolDir) || fs.existsSync(path.join(gameDir, e.name, 'tool', 'server.js'))
+ )).map(e => path.join(gameDir, e.name));
+}
+function gameFiles(gameDir, excluded) {
+ const result = [];
+ function walk(dir, prefix = '') {
+ for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
+ const file = path.join(dir, e.name), rel = path.join(prefix, e.name);
+ if (!prefix && (['save', 'error', 'debug.log'].includes(e.name.toLowerCase()) || /^aitemp/i.test(e.name) || e.name.startsWith('.save-') || excluded.some(d => samePath(d, file)))) continue;
+ if (e.isSymbolicLink()) throw new Error('游戏目录含链接,无法验证完整副本:' + rel);
+ if (e.isDirectory()) walk(file, rel);
+ else if (e.isFile()) result.push({ name: rel, size: fs.statSync(file).size });
+ }
+ }
+ walk(gameDir); return result;
+}
+function sandboxStatus(gameDir, sandboxDir, toolDir, checkingCopy = false) {
+ try {
+ if (contains(gameDir, sandboxDir) || contains(sandboxDir, gameDir)) return { ready: false, reason: '沙盒路径与原版重叠' };
+ if (!fs.existsSync(sandboxDir)) return { ready: false, reason: '尚未创建' };
+ if (!checkingCopy && fs.existsSync(path.join(sandboxDir, INCOMPLETE))) return { ready: false, reason: '上次复制尚未完成,请另选空目录重新创建' };
+ const required = gameFiles(gameDir, excludedDirs(gameDir, toolDir));
+ if (!required.some(f => f.name === 'package.json') || !required.some(f => f.name === 'index.html')) throw new Error('原版游戏目录不完整');
+ const bad = required.find(f => {
+ const dest = path.join(sandboxDir, f.name);
+ return !fs.existsSync(dest) || !fs.statSync(dest).isFile() || fs.statSync(dest).size !== f.size || !contains(sandboxDir, canonical(dest));
+ });
+ if (bad) return { ready: false, reason: '副本缺失或大小不符:' + bad.name };
+ return { ready: true, legacy: !fs.existsSync(path.join(sandboxDir, COMPLETE)), files: required.length };
+ } catch (e) { return { ready: false, reason: e.message }; }
+}
+function copySandbox(gameDir, sandboxDir, toolDir) {
+ if (contains(gameDir, sandboxDir) || contains(sandboxDir, gameDir)) throw new Error('沙盒路径与原版重叠');
+ if (fs.existsSync(sandboxDir) && fs.readdirSync(sandboxDir).length) throw new Error('目标目录非空,不能覆盖;请选择新的空目录');
+ const excluded = excludedDirs(gameDir, toolDir);
+ const originalSave = path.join(gameDir, 'save');
+ const saves = saveNames(originalSave).map(name => ({ name, sha256: hash(safeFile(originalSave, name)) }));
+ if (!saves.length) throw new Error('原版没有存档,请先正常开始游戏并保存');
+ fs.mkdirSync(sandboxDir, { recursive: true });
+ fs.writeFileSync(path.join(sandboxDir, INCOMPLETE), new Date().toISOString(), { flag: 'wx' });
+ return new Promise((resolve, reject) => {
+ execFile('robocopy', [gameDir, sandboxDir, '/E', '/XJ', '/NFL', '/NDL', '/NJH', '/NJS', '/NP', '/R:1', '/W:1', '/XF', 'aitemp*', 'debug.log', '/XD', ...excluded, 'error', '.save-*'],
+ { windowsHide: true, maxBuffer: 32 * 1024 * 1024 }, (err, out, stderr) => {
+ try {
+ if (err && !(Number.isInteger(err.code) && err.code <= 7 && err.code >= 0)) throw new Error(stderr || err.message);
+ const status = sandboxStatus(gameDir, sandboxDir, toolDir, true);
+ if (!status.ready) throw new Error(status.reason);
+ if (JSON.stringify(saveNames(originalSave)) !== JSON.stringify(saves.map(f => f.name)) || saves.some(f => hash(safeFile(originalSave, f.name)) !== f.sha256 || hash(safeFile(path.join(sandboxDir, 'save'), f.name)) !== f.sha256)) throw new Error('复制期间存档发生变化或副本校验失败,请退出游戏后重建');
+ fs.writeFileSync(path.join(sandboxDir, COMPLETE), JSON.stringify({ createdAt: new Date().toISOString(), files: status.files }));
+ fs.unlinkSync(path.join(sandboxDir, INCOMPLETE));
+ resolve(status);
+ } catch (e) { reject(e); }
+ });
+ });
+}
+module.exports = { backupSaves, listBackups, restoreBackup, inspectBackup, sandboxStatus, copySandbox, excludedDirs };
diff --git a/tool/lib/ops.js b/tool/lib/ops.js
new file mode 100644
index 0000000..c0ff91a
--- /dev/null
+++ b/tool/lib/ops.js
@@ -0,0 +1,1335 @@
+const { installPhotoTools } = require('./photo-state');
+const { chooseStoryPosition } = require('./story-position');
+const { installTrainer } = require('./trainer');
+const PHOTO = `const photo = (${installPhotoTools.toString()})();`;
+// 游戏操作脚本库
+//
+// 这里每个函数都返回一段「将被送进游戏页面执行的 JavaScript 源码」。
+// 之所以存成字符串而非直接执行,是因为这些代码必须在游戏进程里跑,
+// 才能访问 $gamePlayer / $gameMap 这些游戏全局对象。
+//
+// ⚠️ 关键设计约束(来自 2026-10-02 实机探查,详见 aitemp/探查结果.md):
+// 1. 传送必须用 $gamePlayer.locate(x, y),不能手写 _x/_y/_realX/_realY
+// —— 后者会漏掉物理引擎的 px/py,导致"画面过去了、碰撞体还在原地"
+// 2. 游戏有 4 套坐标系,locate() 内部会一次同步全部
+// 3. 无敌用 lockInvincible()(常驻)而非 makeInvincible()(会自己失效)
+// 4. 工具不负责读档 —— CDP 读档会崩(物理网格无法反序列化)
+
+/**
+ * 包一层 IIFE,统一处理:游戏未进地图的守卫、异常捕获、JSON 返回格式。
+ *
+ * **用 async IIFE**,因为跨地图传送需要 `await` 等地图加载完
+ * (`reserveTransfer` 是异步的,下一个 update 才真正切图)。
+ * CDP 那边开了 `awaitPromise: true`,所以返回 Promise 会被自动等待。
+ * 同步的操作写成 async 也无妨,只是多包一层 Promise。
+ */
+function wrap(body, { needMap = true } = {}) {
+ 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: '游戏还没进入地图。请先在游戏里读档或开始游戏。' });
+ }
+ ` : '';
+ 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 = `
+ // 清残留碰撞 + 重置落地锚点,否则 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);
+ try { c.suspendGravity(); c.suspendPhysicsHitTest(); } catch (e) {}
+ };
+ // 莉莉娅在不在(她被移除时不该动她)
+ 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 });
+ },
+
+ trainer(body = null) {
+ return wrap(`const trainer = (${installTrainer.toString()})();
+ return JSON.stringify(${body ? `trainer.change(${JSON.stringify(body)})` : 'trainer.read()'});`);
+ },
+ /** 读取当前游戏状态(供控制面板顶栏显示) */
+ snapshot() {
+ return wrap(`
+ 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._x,
+ y: p._y,
+ realX: p._realX,
+ realY: p._realY,
+ hp: bo ? bo.hp : null,
+ gravitySuspended: !!p.isSuspendGravity(),
+ 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; })(),
+ });
+ `);
+ },
+
+ /** 实机 NW.js 用 chrome-extension URL;身份以 Node 入口和实际存档目录为准。 */
+ selfPath() {
+ return wrap(`
+ 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' });
+ `, { needMap: false });
+ },
+
+ /**
+ * 传送到指定格。
+ *
+ * ⚠️ 这个函数处理了一个**实测踩出来的坑**,改动前务必先读 `探查结果.md` 与开发文档第 6 节。
+ *
+ * 游戏的"坠落复位"机制(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 } = {}) {
+ 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}
+ 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 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)})));
+
+ 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() + ')。'
+ + '常见原因是场景被占用(对话窗口、菜单、演出),先在游戏里结束当前操作再试。',
+ });
+ }
+ // 换图后坐标可能被目标地图的出生点逻辑改过,再定一次位
+ 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);
+ } else {
+ p.locate(tx, ty);
+ }
+
+ 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}
+ floatChar(p);
+ const companion = lilyHere();
+ if (!sameMap && storyPlacement?.ok && storyPlacement.controller && companion?.locate) companion.locate(p._x, p._y);
+ 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: storyPlacement?.positionNote,
+ 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])。
+ */
+ 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: '当前位置下方没有可站立的地面(可能是无底洞)。横向挪几格再试。' });
+ }
+
+ // 恢复碰撞;锚点设到落点,确保万一出岔子也落在附近而不是旧地图的位置
+ 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) { 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 });
+ `);
+ },
+
+ /** 设置单个开关 */
+ 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) {
+ const on = suspended ? 'true' : 'false';
+ return wrap(`
+ ${SAFE_CHAR}
+ const p = $gamePlayer;
+ const lh = lilyAny();
+ if (${on}) {
+ floatChar(p);
+ if (lh) floatChar(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;
+ for (const c of [p, lilyAny()]) {
+ if (!c) continue;
+ safeChar(c);
+ if (${on ? 'true' : 'false'}) c.suspendPhysicsHitTest();
+ else c.resumePhysicsHitTest();
+ }
+ return JSON.stringify({ok:true,noclip:!!p._suspendPhysicsHitTest});
+ `);
+ },
+
+ /**
+ * 一键还原所有作弊状态 —— 断开连接前、或工具出错后用它兜底。
+ * 同样要清残留碰撞,理由见 setNoclip 的注释。
+ */
+ restoreAll() {
+ return wrap(`
+ ${SAFE_CHAR}
+ const p = $gamePlayer;
+ const lh = lilyAny();
+ if (window.__nyaruruPhoto) window.__nyaruruPhoto.unpinAll();
+ if (window.__nyaruruTrainer) window.__nyaruruTrainer.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 };
diff --git a/tool/lib/photo-state.js b/tool/lib/photo-state.js
new file mode 100644
index 0000000..59c47ae
--- /dev/null
+++ b/tool/lib/photo-state.js
@@ -0,0 +1,120 @@
+// 此函数在游戏上下文中运行。记录留在 window 的非枚举属性中,不进入存档。
+function installPhotoTools() {
+ if (window.__nyaruruPhoto) { window.__nyaruruPhoto.touch?.(); return window.__nyaruruPhoto; }
+ const records = new Map();
+ let lastTouch = Date.now();
+ const refreshSprite = ev => {
+ const walk = node => {
+ if (!node) return;
+ if (node._character === ev && node.setCharacterBitmap) {
+ node._characterName = ev.bitmapName ? ev.bitmapName() : ev.characterName();
+ node._characterIndex = ev.characterIndex();
+ node._tileId = ev.tileId();
+ if (node._tileId) node.setTileBitmap(); else node.setCharacterBitmap();
+ // 换位图会重置为整张图;角色帧号即使没变,也必须重新裁切。
+ // 图片异步加载完成时还会重置一次,因此在加载回调中刷新游戏自己的帧逻辑。
+ const bitmap = node._contentSprite ? node._contentSprite.bitmap : node.bitmap;
+ const refreshFrame = () => {
+ const current = node._contentSprite ? node._contentSprite.bitmap : node.bitmap;
+ if (node._character !== ev || current !== bitmap) return;
+ node._framePos = -1;
+ if (node.updateFrame) node.updateFrame();
+ };
+ node._framePos = -1;
+ if (bitmap && bitmap.addLoadListener) bitmap.addLoadListener(refreshFrame);
+ else refreshFrame();
+ }
+ for (const child of node.children || []) walk(child);
+ };
+ try { walk(SceneManager._scene); } catch { /* 精灵尚未建立时由下一帧刷新 */ }
+ };
+ const settle = (ev, page) => {
+ const ai = ev.event?.().pages?.[page]?.list?.find(c => c.code === 357 && c.parameters?.[1] === 'ai')?.parameters?.[3];
+ // 只有原页明确要求贴地的角色参与;飞行角色、固定摆件及无近处地面保持原位。
+ if (String(ai?.smoothfloor) !== 'true' || String(ai?.skipSmoothfloor) === 'true' || String(ai?.fix) === 'true' || !ev.hitPreview || !ev.locate) return;
+ const th = $gameMap.tileHeight(), tw = $gameMap.tileWidth();
+ for (let dy = 0; dy <= th; dy++) {
+ const hits = (ev.hitPreview(0, dy) || []).filter(o => o?.terrain === true);
+ if (!hits.length) continue;
+ if (hits.some(o => o.type === 2)) return;
+ const drop = Math.max(0, dy - 1); // 停在地形外侧,不能把碰撞体塞进地板。
+ if (!drop) return;
+ const x = ev.tileXToCenterX ? (ev.px - ev.tileXToCenterX(0)) / tw : (ev._realX ?? ev._x);
+ const y = ev.tileYToCenterY ? (ev.py + drop - ev.tileYToCenterY(0)) / th : (ev._realY ?? ev._y) + drop / th;
+ ev.locate(x, y);
+ ev.clearImpact?.(); ev.clearGrounding?.();
+ ev._groundingX = ev._terrainGroundingX = ev.px;
+ ev._groundingY = ev._terrainGroundingY = ev.py;
+ return;
+ }
+ };
+ const unpin = ev => {
+ const record = records.get(ev);
+ if (!record) return;
+ // 还原被我们覆盖的自有字段和方法,包括原始 _erased、解释器及物理状态。
+ for (const key of Reflect.ownKeys(ev)) if (!Object.hasOwn(record, key)) delete ev[key];
+ Object.defineProperties(ev, record);
+ records.delete(ev);
+ refreshSprite(ev);
+ };
+ const api = {
+ touch: () => { lastTouch = Date.now(); },
+ count: () => records.size,
+ facePlayer(ev) {
+ if (!records.has(ev) || typeof $gamePlayer === 'undefined' || !ev.setDirection) return;
+ const dx = $gamePlayer.px - ev.px;
+ if (!Number.isFinite(dx) || Math.abs(dx) < 1) return;
+ const fixed = ev._directionFix;
+ try { ev._directionFix = false; ev.setDirection(dx < 0 ? 4 : 6); }
+ finally { ev._directionFix = fixed; }
+ },
+ unpinAll() {
+ const n = records.size;
+ for (const ev of [...records.keys()]) unpin(ev);
+ if ($gameMap?.requestRefresh) $gameMap.requestRefresh();
+ return n;
+ },
+ pin(ev, page) {
+ if (records.has(ev)) unpin(ev);
+ const record = Object.getOwnPropertyDescriptors(ev);
+ records.set(ev, record);
+ try {
+ // setupPageSettings 会写 statemMembers,隔离这层避免污染撤销快照。
+ if (ev._statemMembers) ev._statemMembers = { ...ev._statemMembers };
+ ev._erased = false;
+ ev._pageIndex = page;
+ ev.setupPageSettings();
+ settle(ev, page);
+ for (const name of ['start', 'update', 'updateParallel', 'checkEventTriggerAuto', 'checkEventTriggerTouch', 'physicsTriggerTouch', 'refresh']) {
+ Object.defineProperty(ev, name, { value() {}, configurable: true, writable: true });
+ }
+ // 只更新图片帧,不调用 Game_Event.update(其中包含 AI、移动和事件触发)。
+ Object.defineProperty(ev, 'update', { configurable: true, writable: true, value() {
+ if (typeof ImageFrame !== 'undefined' && this.updateFrameAnimation) {
+ this.updateFrameAnimation(ImageFrame.info(this.characterName()));
+ }
+ } });
+ Object.defineProperty(ev, 'findProperPageIndex', { value: () => page, configurable: true, writable: true });
+ ev._starting = false; ev._trigger = null; ev._interpreter = null; ev._moveType = 0;
+ ev.physics = false; ev.fix = true;
+ ev.vx = ev.vy = ev._vx = ev._vy = 0;
+ api.facePlayer(ev);
+ refreshSprite(ev);
+ } catch (e) { unpin(ev); throw e; }
+ },
+ };
+ Object.defineProperty(window, '__nyaruruPhoto', { value: api, configurable: true });
+ // 面板关闭或断线后两分钟撤销摆拍,恢复事件原有行为。
+ if (typeof setInterval === 'function') setInterval(() => {
+ if (records.size && Date.now() - lastTouch > 120000) api.unpinAll();
+ }, 10000);
+ // 不拦截游戏的手动或自动存档;摆拍跨读档的限制由面板提示。
+ // 正常切图或读档会结束摆拍,清除旧地图的记录。
+ for (const [object, name] of [[Game_Map.prototype, 'setup'], [DataManager, 'extractSaveContents']]) {
+ const original = object[name];
+ if (typeof original !== 'function') continue;
+ object[name] = function(...args) { api.unpinAll(); return original.apply(this, args); };
+ }
+ return api;
+}
+module.exports = { installPhotoTools };
diff --git a/tool/lib/probe-game.js b/tool/lib/probe-game.js
new file mode 100644
index 0000000..b916177
--- /dev/null
+++ b/tool/lib/probe-game.js
@@ -0,0 +1,11 @@
+// 与启动器、服务端一致:读取实际游戏及存档目录,不解析页面 URL。
+const { CdpClient } = require('./cdp');
+const { OPS } = require('./ops');
+async function probe(port) {
+ if (!Number.isInteger(port) || port < 1024 || port > 65535) throw new Error('端口无效');
+ const c = new CdpClient(port);
+ try { const r = await c.evalJson(OPS.selfPath()); if (!r.ok || !r.gameDir) throw new Error(r.error || '无法识别游戏'); return r; }
+ finally { c.close(); }
+}
+if (require.main === module) probe(Number(process.argv[2])).then(r => console.log(r.gameDir)).catch(e => { console.error(e.message); process.exitCode = 1; });
+module.exports = { probe };
diff --git a/tool/lib/processes.js b/tool/lib/processes.js
new file mode 100644
index 0000000..1455e6d
--- /dev/null
+++ b/tool/lib/processes.js
@@ -0,0 +1,9 @@
+const { execFile } = require('child_process');
+async function assertGamesStopped() {
+ if (process.platform !== 'win32') throw new Error('当前平台无法核实游戏进程,停止还原');
+ const script = '[Console]::OutputEncoding=[System.Text.Encoding]::UTF8; @(Get-CimInstance Win32_Process -ErrorAction Stop | Where-Object { $_.Name -match "^(nw|Nyaruru).*\\.exe$" } | Select-Object ProcessId,Name) | ConvertTo-Json -Compress';
+ const text = await new Promise((resolve, reject) => execFile('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', script], { windowsHide: true, timeout: 6000, encoding: 'utf8' }, (e, out) => e ? reject(new Error('无法检查游戏进程,停止还原:' + e.message)) : resolve(out)));
+ const rows = text.trim() ? JSON.parse(text) : [];
+ if ((Array.isArray(rows) ? rows : [rows]).length) throw new Error('请先退出游戏及 NW.js 游戏窗口,再还原存档,避免运行中的游戏把旧状态写回');
+}
+module.exports = { assertGamesStopped };
diff --git a/tool/lib/safety.js b/tool/lib/safety.js
new file mode 100644
index 0000000..d13dfd4
--- /dev/null
+++ b/tool/lib/safety.js
@@ -0,0 +1,43 @@
+// 启动器与服务端共用的路径、配置校验。
+const fs = require('fs');
+const path = require('path');
+
+function canonical(input) {
+ if (typeof input !== 'string' || !path.isAbsolute(input)) throw new Error('必须填写绝对路径');
+ let p = path.resolve(input);
+ const tail = [];
+ while (!fs.existsSync(p)) {
+ const parent = path.dirname(p);
+ if (parent === p) throw new Error('目录所在磁盘不存在');
+ tail.unshift(path.basename(p)); p = parent;
+ }
+ return path.join(fs.realpathSync.native(p), ...tail);
+}
+function samePath(a, b) { return canonical(a).toLowerCase() === canonical(b).toLowerCase(); }
+function contains(a, b) {
+ const rel = path.relative(canonical(a).toLowerCase(), canonical(b).toLowerCase());
+ return rel === '' || (!rel.startsWith('..' + path.sep) && rel !== '..' && !path.isAbsolute(rel));
+}
+function validateConfig(config, gameDir) {
+ const next = { ...config };
+ for (const key of ['cdpPort', 'webPort']) {
+ const n = Number(next[key]);
+ if (!Number.isInteger(n) || n < 1024 || n > 65535) throw new Error(`${key} 必须是 1024~65535 的整数`);
+ next[key] = n;
+ }
+ if (next.cdpPort === next.webPort) throw new Error('游戏调试端口和控制面板端口不能相同');
+ next.sandboxDir = canonical(String(next.sandboxDir || '').trim());
+ if (contains(gameDir, next.sandboxDir) || contains(next.sandboxDir, gameDir)) {
+ throw new Error('沙盒与原版目录必须完全分开,不能相同,也不能互相包含');
+ }
+ return next;
+}
+function readConfig(file, defaults, gameDir) {
+ let patch = {};
+ if (fs.existsSync(file)) {
+ try { patch = JSON.parse(fs.readFileSync(file, 'utf8')); }
+ catch { throw new Error('config.json 损坏,请修正配置后重试;不会静默换用其他目录'); }
+ }
+ return validateConfig({ ...defaults, ...patch }, gameDir);
+}
+module.exports = { canonical, samePath, contains, validateConfig, readConfig };
diff --git a/tool/lib/story-cache.js b/tool/lib/story-cache.js
new file mode 100644
index 0000000..dd3aaba
--- /dev/null
+++ b/tool/lib/story-cache.js
@@ -0,0 +1,78 @@
+// 发布版专用。全文索引只存在于内存,不读写台词文件。
+class StoryCache {
+ constructor(cdp, ops, triggers, { retryMs = 3000, maxAttempts = 4, now = Date.now } = {}) {
+ Object.assign(this, { cdp, ops, triggers, retryMs, maxAttempts, now });
+ this.revision = 0;
+ this.reset();
+ }
+ key() {
+ return this.cdp.ws?.readyState === 1 && this.cdp.sessionId
+ ? `${this.cdp.port}:${this.cdp.generation}:${this.cdp.sessionId}` : null;
+ }
+ reset(key = null) {
+ this.connection = key;
+ this.index = null;
+ this.pending = null;
+ this.attempts = 0;
+ this.nextTryAt = 0;
+ this.error = '';
+ this.at = 0;
+ ++this.revision;
+ }
+ sync() {
+ const key = this.key();
+ if (key !== this.connection) this.reset(key);
+ return key;
+ }
+ status() {
+ const key = this.sync();
+ const loaded = !!this.index;
+ return {
+ state: !key ? 'disconnected' : loaded ? 'ready' : this.pending ? 'loading'
+ : this.error ? (this.attempts >= this.maxAttempts ? 'error' : 'retrying') : 'waiting',
+ loaded, revision: this.revision, connection: key, error: this.error,
+ attempts: this.attempts, at: this.at,
+ count: loaded ? Object.keys(this.index).length : 0,
+ triggersWithLine: loaded ? this.triggers.filter(t => this.index[String(t.storyId)]).length : 0,
+ };
+ }
+ get(id) { this.sync(); return this.index?.[String(id)] || null; }
+ async ensure({ force = false } = {}) {
+ let key = this.sync();
+ if (!key) {
+ if (!(await this.cdp.isAlive())) return null;
+ try { await this.cdp.connect(); } catch { this.sync(); return null; }
+ key = this.sync();
+ }
+ if (!key) return null;
+ if (this.pending) return this.pending;
+ if (force) { this.reset(key); }
+ else if (this.index || this.attempts >= this.maxAttempts || this.now() < this.nextTryAt) return this.index;
+ const revision = this.revision;
+ ++this.attempts;
+ const task = (async () => {
+ try {
+ const result = await this.cdp.evalJson(this.ops.readStoryIndex());
+ if (this.key() !== key || this.revision !== revision) return null;
+ if (!result?.ok || !result.index || !Object.keys(result.index).length) {
+ throw new Error(result?.error || '故事表尚未就绪或没有可读台词');
+ }
+ this.index = result.index;
+ this.error = '';
+ this.at = this.now();
+ ++this.revision;
+ return this.index;
+ } catch (e) {
+ if (this.key() !== key || this.revision !== revision) return null;
+ this.error = String(e.message || e);
+ this.nextTryAt = this.now() + this.retryMs;
+ ++this.revision;
+ return null;
+ }
+ })();
+ this.pending = task;
+ try { return await task; }
+ finally { if (this.pending === task) this.pending = null; }
+ }
+}
+module.exports = { StoryCache };
diff --git a/tool/lib/story-position.js b/tool/lib/story-position.js
new file mode 100644
index 0000000..83fb0d8
--- /dev/null
+++ b/tool/lib/story-position.js
@@ -0,0 +1,82 @@
+// 已核对的剧情落脚点。控制事件的位置不一定是角色演出的位置。
+// Map181 EV002 第 5 页将玩家送到 Map182 (11,11),随后由 EV003 自动执行
+// 298/299 -> 305。EV003 本身放在 (0,0),仅用于控制剧情。
+// 此表只有结构坐标,不含台词;发布版可直接共用,无需本地解包数据。
+function storyPosition(trigger) {
+ const { x, y } = trigger;
+ const entries = [
+ { map: 182, event: 3, page: 0, stories: [298, 299, 305], from: [0, 0], to: [11, 11] },
+ // 原游戏 Map006 EV017 第 1 页的入场位置。
+ { map: 9, event: 10, page: 5, stories: [449], from: [0, 0], to: [11, 9] },
+ // 原游戏 Map301 EV005 第 1 页的村落入口。
+ { map: 296, event: 34, page: 5, stories: [449], from: [4, 0], to: [28, 13] },
+ // 原游戏 Map004 EV005 第 5 页的梦境入场位置。
+ { map: 254, event: 2, page: 0, stories: [467], from: [0, 0], to: [6, 5] },
+ ];
+ const entry = entries.find(e => Number(trigger.mapId) === e.map && Number(trigger.eventId) === e.event &&
+ trigger.pageIndex !== null && trigger.pageIndex !== undefined && Number(trigger.pageIndex) === e.page &&
+ e.stories.includes(Number(trigger.storyId)) &&
+ x !== null && y !== null && x !== '' && y !== '' && Number(x) === e.from[0] && Number(y) === e.from[1]);
+ if (entry) {
+ return { x: entry.to[0], y: entry.to[1], positionNote: `使用原游戏入场位置;剧情控制事件位于 (${entry.from.join(',')})` };
+ }
+ return { x, y };
+}
+// 在游戏运行时调用;只读取当前地图与碰撞,不加载/导出台词或解包文件。
+function chooseStoryPosition({ map, player, data, eventId, pageIndex, storyId, sameMap, previous, preferred, requested }) {
+ const event = data.events?.[eventId];
+ const containsStory = page => page.list?.some(c => c.code === 357 && c.parameters?.[1] === 'call_story' && Number(c.parameters[3]?.id) === Number(storyId));
+ const page = event?.pages?.[pageIndex] || event?.pages?.find(containsStory);
+ const hasBody = pg => !!(pg?.image?.characterName || pg?.image?.tileId || pg?.list?.some(c => c.code === 357 && c.parameters?.[1] === 'ai' && Number(c.parameters[3]?.battleObject) > 0));
+ if (!page || ![3, 4].includes(page.trigger) || hasBody(page)) return { ok: true, ...requested, controller: false };
+ if (!preferred && (requested.x !== event.x || requested.y !== event.y)) preferred = requested;
+
+ const tw = map.tileWidth(), th = map.tileHeight(), step = Math.max(8, Math.floor(th / 4));
+ let probes = 0;
+ const floor = (x, y, depth) => {
+ x = Math.round(x); y = Math.round(y);
+ if (!Number.isFinite(x) || !Number.isFinite(y) || x < 0 || y < 0 || x >= map.width() || y >= map.height()) return null;
+ for (let dy = 0; dy <= Math.min(depth * th, (map.height() - y) * th); dy += step) {
+ if (++probes > 800) return null;
+ const hits = (player.hitPreview((x - player._x) * tw, (y - player._y) * th + dy) || []).filter(o => o?.terrain === true);
+ if (!hits.length) continue;
+ if (hits.some(o => o.type === 2)) return null;
+ return { x, y: y + Math.max(0, Math.floor((dy - step) / th)) };
+ }
+ return null;
+ };
+ // 同图已站在正常场景里时,不因点击控制事件而把人重新送到角落。
+ let pick = sameMap && previous ? floor(previous.x, previous.y, 1) : null;
+ let reason = pick ? '保留当前可落地位置' : '';
+ if (!pick && preferred) { pick = floor(preferred.x, preferred.y, 2); if (pick) reason = '使用已核实的入场位置'; }
+ const events = (map.events() || []).filter(e => e && e.eventId() !== Number(eventId));
+ const refs = new Set((page.list || []).filter(c => [203, 205, 213].includes(c.code)).map(c => Number(c.parameters?.[0])).filter(id => id > 0));
+ const ordered = [
+ ...events.filter(e => refs.has(e.eventId())),
+ ...events.filter(e => e.characterName?.()),
+ ...events.filter(e => e.event()?.pages?.some(hasBody)),
+ ];
+ const seen = new Set();
+ for (const ev of ordered) {
+ if (pick || probes >= 600) break;
+ if (seen.has(ev)) continue;
+ seen.add(ev);
+ for (const dx of [-1, 1, -2, 2]) {
+ pick = floor(ev._x + dx, Math.max(0, ev._y - 1), 4);
+ if (pick) { reason = '按场景人物附近的安全地面定位'; break; }
+ }
+ }
+ // 场景人物不可用时,仅在地图内部做有上限的地形扫描,不猜测原作站位。
+ if (!pick) {
+ const xs = Array.from({ length: Math.min(map.width(), 31) }, (_, i) => Math.round((i + 0.5) * map.width() / Math.min(map.width(), 31)))
+ .sort((a, b) => Math.abs(a - map.width() / 2) - Math.abs(b - map.width() / 2));
+ for (const x of xs) {
+ if (probes >= 800) break;
+ pick = floor(x, 0, map.height());
+ if (pick) { reason = '按当前地图的安全地面定位'; break; }
+ }
+ }
+ if (!pick) return { ok: false, controller: true, error: '这是无形剧情控制事件,未找到可确认的安全落脚点;已保留悬浮,请手动定位后再运行过场' };
+ return { ok: true, ...pick, controller: true, positionNote: reason };
+}
+module.exports = { storyPosition, chooseStoryPosition };
diff --git a/tool/lib/trainer.js b/tool/lib/trainer.js
new file mode 100644
index 0000000..4ca1e62
--- /dev/null
+++ b/tool/lib/trainer.js
@@ -0,0 +1,148 @@
+// Injected into the game. Uses NYA's actor inventory/battle values, not RPG Maker HP/gold.
+function installTrainer() {
+ if (window.__nyaruruTrainer) return window.__nyaruruTrainer;
+ const fields = { coin: '_coin', hp: '_hp', sp: 'sp', zp: 'zp' };
+ const locks = new Map();
+ const ids = new WeakMap();
+ let serial = 0, touched = Date.now(), actors = $gameActors, party = $gameParty;
+ const id = o => { if (!ids.has(o)) ids.set(o, ++serial); return ids.get(o); };
+ const active = () => {
+ const actor = $gamePlayer.actor(), battle = $gamePlayer.battleObject();
+ if (!actor || !battle) throw new Error('当前角色数据尚未就绪');
+ return { actor, battle, subject: `${id(actor)}:${id(battle)}:${!!$gamePlayer.isLilyMode()}` };
+ };
+ const maximum = (field, b) => field === 'coin' ? 99999 : Math.max(0, Math.floor(b['m' + field]));
+ const integer = (value, max) => {
+ if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 0 || value > max) {
+ throw new Error(`请输入 0~${max} 的整数`);
+ }
+ return value;
+ };
+ // Accessor intercepts the actual storage slot, so even lethal damage cannot reach zero
+ // before a later polling refill. Descriptor flags and the current value survive unlock.
+ function detach(lock) {
+ if (!lock.object) return;
+ const value = lock.value();
+ Object.defineProperty(lock.object, fields[lock.field], { ...lock.descriptor, value });
+ lock.object = null;
+ }
+ function clear() {
+ for (const lock of locks.values()) detach(lock);
+ locks.clear();
+ }
+ function sync() {
+ if (actors !== $gameActors || party !== $gameParty || Date.now() - touched > 120000) {
+ clear(); actors = $gameActors; party = $gameParty;
+ }
+ if (!locks.size) return;
+ const { actor, battle } = active();
+ for (const lock of locks.values()) {
+ const object = lock.field === 'coin' ? actor : battle;
+ if (lock.object === object) continue;
+ detach(lock);
+ const key = fields[lock.field], descriptor = Object.getOwnPropertyDescriptor(object, key);
+ if (!descriptor?.configurable || !descriptor.writable || !('value' in descriptor)) {
+ throw new Error('当前游戏的数值结构不支持锁定');
+ }
+ lock.object = object; lock.descriptor = descriptor;
+ lock.value = () => Math.min(lock.target, maximum(lock.field, battle));
+ Object.defineProperty(object, key, {
+ configurable: true, enumerable: descriptor.enumerable,
+ get: () => lock.value(), set: () => {},
+ });
+ }
+ }
+ function touch() { sync(); touched = Date.now(); }
+ // Only one timer per game page; neither hooks nor lock settings enter save data.
+ setInterval(() => { try { sync(); } catch { clear(); } }, 100);
+
+ const categories = ['consumables', 'weapons', 'stones', 'ornaments'];
+ const specials = {
+ joycoin: ['joyCoinData', 'joyCoinNum', '_joyCoin'],
+ hpst: ['hpStrengthData', 'hpStrengthNum', '_hpStrengthItem'],
+ skillst: ['skillStrengthData', 'skillStrengthNum', '_skillStrengthItem'],
+ };
+ const localized = key => String(TextManager[key] ?? key ?? '');
+ function read() {
+ touch();
+ const { actor, battle, subject } = active();
+ const stats = Object.fromEntries(Object.keys(fields).map(field => [field, {
+ value: field === 'coin' ? actor.coin() : battle[field],
+ max: maximum(field, battle), locked: locks.has(field), target: locks.get(field)?.target ?? null,
+ }]));
+ const items = [];
+ for (const type of categories) {
+ const container = actor['_' + type];
+ for (const [key, data] of Object.entries(container.data())) {
+ const itemId = Number(key);
+ if (!Number.isSafeInteger(itemId) || itemId < 0 || !data) continue;
+ items.push({ type, id: itemId, name: localized(data.name), desc: localized(data.desc),
+ count: type === 'consumables' ? container.itemNum(itemId) : Number(container.isUnlock(itemId)),
+ max: type === 'consumables' ? container.maxNum(itemId) : 1,
+ equipped: type === 'weapons' ? container.currentWeapon() === itemId : !!container.isEquip?.(itemId),
+ });
+ }
+ }
+ for (const [type, [dataMethod, countMethod]] of Object.entries(specials)) {
+ const data = actor[dataMethod]();
+ items.push({ type, id: 0, name: localized(data.name), desc: localized(data.desc), count: actor[countMethod](), max: 99999, equipped: false });
+ }
+ return { ok: true, subject, role: $gamePlayer.isLilyMode() ? '莉莉娅' : actor.name(), stats, items };
+ }
+ function change(body) {
+ touch();
+ const { actor, battle, subject } = active();
+ if (body.subject !== subject) throw new Error('操作角色已变化,请刷新角色与背包后重试');
+ if (body.action === 'stat') {
+ const field = body.field;
+ if (!Object.prototype.hasOwnProperty.call(fields, field)) throw new Error('未知数值');
+ if (body.locked !== undefined && typeof body.locked !== 'boolean') throw new Error('锁定参数无效');
+ if (body.locked === false) {
+ const old = locks.get(field);
+ if (old) { detach(old); locks.delete(field); }
+ return read();
+ }
+ const value = integer(body.value, maximum(field, battle));
+ if (field === 'hp' && battle.hp === 0 && value > 0) throw new Error('角色已倒下,请先在游戏里复活或读档,再修改血量');
+ const old = locks.get(field), keepLocked = body.locked === true || !!old;
+ if (old) { detach(old); locks.delete(field); }
+ if (field === 'coin') {
+ const delta = value - actor.coin();
+ if (delta >= 0) actor.addCoin(delta); else actor.deductCoin(-delta);
+ } else battle['add' + field[0].toUpperCase() + field.slice(1)](value - battle[field]);
+ if (keepLocked) {
+ locks.set(field, { field, target: value, object: null });
+ try { sync(); } catch (e) { clear(); throw e; }
+ }
+ } else if (body.action === 'item') {
+ const { type } = body;
+ const itemId = integer(body.id, 99999);
+ if (Object.prototype.hasOwnProperty.call(specials, type)) {
+ if (itemId !== 0) throw new Error('物品编号无效');
+ actor[specials[type][2]] = integer(body.count, 99999);
+ } else {
+ if (!categories.includes(type)) throw new Error('物品分类无效');
+ const container = actor['_' + type], data = container.data();
+ if (!Object.prototype.hasOwnProperty.call(data, itemId) || !data[itemId]) throw new Error('物品不存在');
+ const max = type === 'consumables' ? (body.overCap === true ? 99999 : container.maxNum(itemId)) : 1;
+ const count = integer(body.count, max);
+ if (type === 'consumables') container.add(itemId, count - container.itemNum(itemId), body.overCap === true);
+ else if (count) container.unlock(itemId);
+ else if (type === 'weapons') {
+ const remaining = container._unlock.filter(n => n !== itemId);
+ if (!remaining.length) throw new Error('至少需要保留一件武器,避免游戏攻击时找不到武器');
+ if (container.currentWeapon() === itemId) { actor.changeWeapon(remaining[0]); $gamePlayer.setWeaponChange(); }
+ container._unlock = remaining;
+ } else if (type === 'ornaments') container.remove(itemId);
+ else {
+ if (container.isEquip(itemId)) container.equip(itemId);
+ container._unlock = container._unlock.filter(n => n !== itemId);
+ }
+ }
+ } else throw new Error('未知修改操作');
+ return read();
+ }
+ return window.__nyaruruTrainer = { read, change, touch, clear };
+}
+
+module.exports = { installTrainer };
diff --git a/tool/server.js b/tool/server.js
new file mode 100644
index 0000000..2784cec
--- /dev/null
+++ b/tool/server.js
@@ -0,0 +1,497 @@
+// 咸鱼喵喵 · 截图辅助工具 —— 本地服务
+//
+// 职责:
+// 1. 给浏览器提供一个控制面板页面
+// 2. 通过 CDP 把面板上的操作转成游戏里的 JavaScript
+// 3. 管存档备份 / 恢复、沙盒副本
+//
+// 安全:
+// - 只监听 127.0.0.1,同一个 WiFi 下的别人连不上
+// - 不改程序和素材;明确选择后可保存/还原存档,复制沙盒。
+
+const http = require('http');
+const fs = require('fs');
+const path = require('path');
+
+const { CdpClient } = require('./lib/cdp.js');
+const { OPS } = require('./lib/ops.js');
+const { storyPosition } = require('./lib/story-position');
+const { loadAll } = require('./lib/data.js');
+const { StoryCache } = require('./lib/story-cache');
+const SAFETY = require('./lib/safety');
+const FILES = require('./lib/files');
+const { assertGamesStopped } = require('./lib/processes');
+
+// ---------- 路径与配置 ----------
+const TOOL_DIR = __dirname;
+const AITEMP_DIR = path.resolve(TOOL_DIR, '..'); // aitemp/
+const GAME_DIR = path.resolve(AITEMP_DIR, '..'); // 游戏根目录
+const WEB_DIR = path.join(TOOL_DIR, 'web');
+const BACKUP_DIR = path.join(AITEMP_DIR, 'backups');
+const CONFIG_FILE = path.join(TOOL_DIR, 'config.json');
+
+// 沙盒默认位置:和游戏同一个盘的根目录下。
+// ⚠️ 不要写死具体盘符 —— 别人的游戏不一定装在 L 盘。
+// 放在盘根是为了**避开 Steam 库**(放库里 Steam 会扫到,云同步也可能够得着)。
+const DEFAULT_SANDBOX = path.join(path.parse(GAME_DIR).root, 'Nyaruru_shots');
+
+const DEFAULTS = {
+ cdpPort: 9223, // 沙盒实例的调试端口
+ sandboxDir: DEFAULT_SANDBOX,
+ webPort: 8787,
+};
+
+function loadConfig() { return SAFETY.readConfig(CONFIG_FILE, DEFAULTS, GAME_DIR); }
+const CONFIG = loadConfig();
+function classifyGameDir(dir) {
+ if (!dir) return 'unknown';
+ try {
+ if (SAFETY.samePath(dir, GAME_DIR)) return 'original';
+ if (SAFETY.samePath(dir, CONFIG.sandboxDir)) return 'sandbox';
+ } catch { return 'unknown'; }
+ return 'other';
+}
+/** 校验并写回 config.json。只接受已知字段,避免前端传进来的杂项污染配置 */
+function saveConfig(patch) {
+ if (sandboxJob.running) throw new Error('沙盒复制期间不能修改配置');
+ const allowed = Object.fromEntries(Object.entries(patch).filter(([k]) => ['cdpPort', 'webPort', 'sandboxDir'].includes(k)));
+ const next = SAFETY.validateConfig({ ...CONFIG, ...allowed }, GAME_DIR);
+ // 面板端口修改要重启,因此也不能让 CDP 占用当前正在监听的旧端口。
+ if (server?.listening && next.cdpPort === server.address().port) throw new Error('调试端口与当前正在使用的面板端口冲突,请先重启面板');
+ const temp = CONFIG_FILE + '.tmp';
+ fs.writeFileSync(temp, JSON.stringify(next, null, 2), 'utf8');
+ fs.renameSync(temp, CONFIG_FILE);
+ const portChanged = next.cdpPort !== CONFIG.cdpPort;
+ const webPortChanged = next.webPort !== CONFIG.webPort;
+ Object.assign(CONFIG, next);
+ if (portChanged) { cdp.close(); cdp.port = CONFIG.cdpPort; erasedCache.clear(); storyCache.reset(); }
+ return { config: CONFIG, cdpReconnected: portChanged, webPortNeedsRestart: webPortChanged };
+}
+
+// ---------- 静态数据(启动时载入一次)----------
+let DATA;
+try {
+ DATA = loadAll(AITEMP_DIR);
+ console.log(`[数据] 开关 ${DATA.stats.switches} · 变量 ${DATA.stats.variables} · 地图 ${DATA.stats.maps}`);
+ console.log(`[数据] 触发点 ${DATA.stats.triggersUnique} 条(可跳转 ${DATA.stats.triggersActionable} 条)· 常用开关 ${DATA.stats.commonSwitches} 个`);
+} catch (e) {
+ console.error('[数据] 载入失败:', e.message);
+ console.error(' 请确认 aitemp/out/triggers.csv 与 aitemp/unpacked/data/ 存在。');
+ process.exit(1);
+}
+
+const cdp = new CdpClient(CONFIG.cdpPort);
+const storyCache = new StoryCache(cdp, OPS, DATA.triggers);
+
+// "mapId:eventId" → 该事件是否被游戏自己抹除(_erased)。
+// 每次只保留当前地图扫描结果,避免切换存档后沿用旧标记。
+const erasedCache = new Map();
+
+// ---------- 存档备份 ----------
+const backupSaves = (dir, label) => FILES.backupSaves(BACKUP_DIR, dir, label);
+const listBackups = () => FILES.listBackups(BACKUP_DIR);
+let sandboxJob = { running: false, done: false, error: null, message: '', startedAt: 0 };
+function sandboxExists() { return FILES.sandboxStatus(GAME_DIR, CONFIG.sandboxDir, AITEMP_DIR).ready; }
+function createSandbox() {
+ if (sandboxJob.running) throw new Error('已有拷贝任务在进行中');
+ const dest = CONFIG.sandboxDir;
+ const promise = FILES.copySandbox(GAME_DIR, dest, AITEMP_DIR);
+ sandboxJob = { running: true, done: false, error: null, message: '正在拷贝并验证…', startedAt: Date.now(), dest };
+ promise.then(() => { sandboxJob = { ...sandboxJob, running: false, done: true, message: '拷贝及验证完成' }; }, e => {
+ sandboxJob = { ...sandboxJob, running: false, done: false, error: e.message, message: '拷贝失败,不能启动该副本' };
+ });
+ return { started: true, dest };
+}
+function targetDir(body) {
+ if (!['original', 'sandbox'].includes(body.target)) throw new Error('必须明确选择原版或沙盒目标');
+ return body.target === 'sandbox' ? CONFIG.sandboxDir : GAME_DIR;
+}
+let operationTail = Promise.resolve();
+const offlineActions = new Set(['/api/config', '/api/backup', '/api/restore-backup', '/api/sandbox', '/api/shutdown', '/api/story-text']);
+const getRoutes = new Set(['/api/trainer', '/api/status', '/api/snapshot', '/api/maps', '/api/switches', '/api/config', '/api/ground', '/api/events/lost', '/api/events', '/api/triggers', '/api/backups', '/api/sandbox/status', '/api/health', '/api/story-text']);
+async function identity() {
+ const r = await cdp.evalJson(OPS.selfPath());
+ if (!r?.ok) throw new Error(r?.error || '无法识别游戏实例');
+ return { ...r, dir: r.gameDir, kind: classifyGameDir(r.gameDir), context: `${cdp.generation}:${r.mapToken}` };
+}
+function guarded(script, token) {
+ return `(async()=>{ const i=JSON.parse(await ${OPS.selfPath()}); if(i.mapToken!==${JSON.stringify(token)}) return JSON.stringify({ok:false,error:'地图或存档已经变化,请刷新后重试'}); return await ${script}; })()`;
+}
+
+// ---------- 工具函数 ----------
+function readBody(req) {
+ return new Promise((resolve, reject) => {
+ let buf = '';
+ req.on('data', c => { buf += c; if (buf.length > 1e6) req.destroy(); });
+ req.on('end', () => { try { resolve(buf ? JSON.parse(buf) : {}); } catch { reject(new Error('请求 JSON 格式错误')); } });
+ req.on('error', reject);
+ req.on('aborted', () => reject(new Error('请求已取消')));
+ });
+}
+
+function sendJson(res, obj, code = 200) {
+ const body = JSON.stringify(obj);
+ res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' });
+ res.end(body);
+}
+
+const MIME = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8', '.png': 'image/png', '.ico': 'image/x-icon' };
+
+function serveStatic(res, urlPath) {
+ const rel = urlPath === '/' ? 'index.html' : urlPath.replace(/^\/+/, '');
+ const file = path.join(WEB_DIR, rel);
+ // 防止 ../ 穿越到 web 目录之外
+ if (!SAFETY.contains(WEB_DIR, file)) { res.writeHead(403); res.end('forbidden'); return; }
+ if (!fs.existsSync(file) || !fs.statSync(file).isFile()) { res.writeHead(404); res.end('not found'); return; }
+ res.writeHead(200, { 'Content-Type': MIME[path.extname(file)] || 'application/octet-stream', 'Cache-Control': 'no-store' });
+ fs.createReadStream(file).pipe(res);
+}
+
+// ---------- 路由 ----------
+async function handleApi(req, res, urlPath, query) {
+ if (req.method !== 'GET' && req.method !== 'POST') return sendJson(res, { ok: false, error: '不支持的请求方法' }, 405);
+ if (req.method === 'GET' && !getRoutes.has(urlPath)) return sendJson(res, { ok: false, error: '修改操作必须使用 POST' }, 405);
+ if (req.method === 'POST' && req.headers.origin && req.headers.origin !== `http://${req.headers.host}`) return sendJson(res, { ok: false, error: '拒绝跨站修改请求' }, 403);
+ const body = req.method === 'POST' ? await readBody(req) : {};
+ if (req.method === 'POST') {
+ const task = operationTail.then(() => executeApi(req, res, urlPath, body));
+ operationTail = task.catch(() => {});
+ return task;
+ }
+ return executeApi(req, res, urlPath, body);
+}
+async function executeApi(req, res, urlPath, body) {
+ let current = null;
+ if (req.method === 'POST' && !offlineActions.has(urlPath)) {
+ current = await identity();
+ if (!['sandbox', 'original'].includes(current.kind)) throw new Error('无法确认游戏身份,停止修改操作');
+ if (!body.context || body.context !== current.context) throw new Error('连接、地图或存档已变化,请刷新后重试');
+ }
+ const game = async script => {
+ if (current && current.context.split(':')[0] !== String(cdp.generation)) throw new Error('连接已变化,操作已取消');
+ return cdp.evalJson(current ? guarded(script, current.mapToken) : script);
+ };
+ switch (urlPath) {
+ case '/api/shutdown': {
+ if (sandboxJob.running) throw new Error('沙盒仍在复制,请等待任务结束再停止面板');
+ sendJson(res, { ok: true });
+ setTimeout(() => { cdp.close(); server.close(() => { if (require.main === module) process.exit(0); }); }, 100);
+ return;
+ }
+ case '/api/health': return sendJson(res, { ok: true, toolDir: TOOL_DIR, version: 2, config: CONFIG });
+ // --- 连接状态 ---
+ case '/api/status': {
+ const alive = await cdp.isAlive();
+ // 检测到调试端口就顺手建立会话(首次之后会复用,开销很小),
+ // 这样面板一打开就能显示"已连接",而不是等到第一次操作
+ let connected = false;
+ if (alive) {
+ try { await cdp.connect(); connected = !!cdp.sessionId; } catch { connected = false; }
+ }
+
+ // 顺手问一句:游戏窗口自己是从哪个目录加载的?
+ // 面板据此显示"当前连接:沙盒 / 原版",把"以为在沙盒、其实改了原版"这种
+ // 误用摆在明面上(见 复查与隐患.md 坑 5)。
+ let gameSelf = null;
+ if (connected) { try { gameSelf = await identity(); } catch { connected = false; } }
+ if (!connected) {
+ erasedCache.clear(); cdp.close(); storyCache.reset();
+ } else { void storyCache.ensure().catch(() => {}); }
+ const storyText = storyCache.status();
+ return sendJson(res, {
+ ok: true,
+ gameRunning: alive,
+ connected,
+ cdpPort: CONFIG.cdpPort,
+ gameDir: GAME_DIR,
+ sandboxDir: CONFIG.sandboxDir,
+ sandboxExists: sandboxExists(),
+ sandboxJob,
+ gameSelf,
+ stats: { ...DATA.stats, storyLoaded: storyText.count, triggersWithLine: storyText.triggersWithLine },
+ dataMode: 'runtime', storyText,
+ context: gameSelf?.context || null,
+ });
+ }
+
+ // --- 游戏状态快照 ---
+ case '/api/trainer': {
+ if (req.method === 'GET') current = await identity();
+ const r = await game(OPS.trainer(req.method === 'POST' ? body : null));
+ return sendJson(res, { ...r, context: current.context });
+ }
+ case '/api/snapshot': {
+ const snap = await game(OPS.snapshot());
+ return sendJson(res, snap);
+ }
+
+ // --- 地图列表 ---
+ case '/api/maps':
+ return sendJson(res, { ok: true, maps: DATA.maps });
+
+ // --- 开关 ---
+ case '/api/switches': {
+ let values = {};
+ try { values = (await game(OPS.readSwitches())).values || {}; } catch { /* 没连上就只给名字 */ }
+ const common = new Set(DATA.commonSwitchIds);
+ return sendJson(res, {
+ ok: true,
+ switches: DATA.switches.map(s => ({ ...s, common: common.has(s.id) })),
+ values,
+ connected: Object.keys(values).length > 0,
+ });
+ }
+ case '/api/switch': {
+ const r = await game(OPS.setSwitch(body.id, body.value));
+ return sendJson(res, r);
+ }
+ // 批量设置开关("一键前往"用:一次把该对话需要的条件全部打开)
+ case '/api/switches/set': {
+ const pairs = Array.isArray(body.pairs) ? body.pairs : [];
+ if (!pairs.length) return sendJson(res, { ok: true, applied: 0 });
+ return sendJson(res, await game(OPS.setSwitches(pairs)));
+ }
+
+ // --- 传送 ---
+ // body.float 默认 true:传送后进入"悬浮安全模式"(暂停重力 + 穿墙),
+ // 避免落进坑里触发游戏的坠落复位。详见 ops.js 里 teleport 的注释。
+ case '/api/teleport': {
+ const r = await game(OPS.teleport(body.x, body.y, {
+ float: body.float !== false,
+ // mapId 不传或与当前地图相同 → 地图内定位;
+ // 不同 → 走 reserveTransfer 跨地图(见 ops.js 的 teleport 注释)
+ mapId: body.mapId ?? null,
+ }));
+ return sendJson(res, r);
+ }
+
+ // --- 直接播放一段剧情序列 ---
+ // 绕开地图事件与开关条件,用于通关存档上复现"错过就没了"的对话
+ case '/api/story/start': {
+ const position = storyPosition(body);
+ const tp = await game(OPS.teleport(position.x, position.y, { mapId: body.mapId, story: body }));
+ if (!tp.ok) return sendJson(res, tp);
+ const next = await identity();
+ if (next.context.split(':')[0] !== current.context.split(':')[0] || !SAFETY.samePath(next.dir, current.dir)) throw new Error('传送期间连接已变化');
+ current = next;
+ const r = await game(body.mode === 'scene' ? OPS.runStoryScene(body.storyId, body.eventId, body.pageIndex) : OPS.playStory(body.storyId));
+ r.positionNote = tp.positionNote;
+ // 传送及补角色会临时开启悬浮;过场准备完成后恢复安全落地。
+ // 放在 runStoryScene 之后,避免其避让角色逻辑再次开启悬浮。
+ if (r.ok && body.mode === 'scene') {
+ r.landing = await game(OPS.land());
+ if (!r.landing.ok) r.landingWarning = '当前落点未能自动落地:' + r.landing.error;
+ else if (r.landing.warning) r.landingWarning = r.landing.warning;
+ }
+ return sendJson(res, r);
+ }
+ case '/api/story/goto': {
+ // 验证所有参数后才修改开关,并把开关及传送放在同一排队任务中。
+ const position = storyPosition(body);
+ const teleport = OPS.teleport(position.x, position.y, { mapId: body.mapId, story: body });
+ const pairs = Array.isArray(body.pairs) ? body.pairs : [];
+ if (pairs.some(p => !Array.isArray(p) || !DATA.switches.some(s => s.id === p[0]) || typeof p[1] !== 'boolean')) throw new Error('开关参数无效');
+ if (pairs.length) {
+ const r = await game(OPS.setSwitches(pairs));
+ if (!r.ok) return sendJson(res, r);
+ }
+ return sendJson(res, await game(teleport));
+ }
+ case '/api/story/play':
+ return sendJson(res, await game(OPS.playStory(body.storyId)));
+
+ // --- 运行整段过场(含 NPC 站位与演出),而不是只念台词 ---
+ case '/api/story/scene':
+ return sendJson(res, await game(OPS.runStoryScene(body.storyId)));
+
+ // --- 关掉当前的消息窗口(截完图收尾用)---
+ case '/api/story/close': return sendJson(res, await game(OPS.closeMessage()));
+
+ // --- 莉莉娅显示 / 隐藏 ---
+ case '/api/lily': return sendJson(res, await game(OPS.setLily(body.show !== false)));
+
+ // --- 拍照辅助:隐藏 / 显示 对话框、立绘、HUD ---
+ // 只藏不关,随时能显示回来(要"真的关掉对话"请用 /api/story/close)
+ case '/api/ui-hidden':
+ return sendJson(res, await game(OPS.setUiHidden({
+ message: typeof body.message === 'boolean' ? body.message : null,
+ artwork: typeof body.artwork === 'boolean' ? body.artwork : null,
+ hud: typeof body.hud === 'boolean' ? body.hud : null,
+ })));
+
+ // --- 落地:从悬浮状态站到实地上(会先探测脚下是否安全)---
+ case '/api/land': return sendJson(res, await game(OPS.land()));
+
+ // --- 探测脚下地形("落地"按钮的预检,也可单独用)---
+ case '/api/ground': return sendJson(res, await game(OPS.probeGround()));
+
+ // --- 配置 ---
+ case '/api/config': {
+ if (req.method === 'POST') {
+ try {
+ const r = saveConfig(body);
+ return sendJson(res, { ok: true, ...r, defaults: DEFAULTS });
+ } catch (e) {
+ return sendJson(res, { ok: false, error: String(e.message) }, 400);
+ }
+ }
+ return sendJson(res, { ok: true, config: CONFIG, defaults: DEFAULTS, configFile: CONFIG_FILE });
+ }
+
+ // --- 作弊开关 ---
+ case '/api/gravity': return sendJson(res, await game(OPS.setGravity(!!body.on)));
+ case '/api/invincible': return sendJson(res, await game(OPS.setInvincible(!!body.on)));
+ case '/api/noclip': return sendJson(res, await game(OPS.setNoclip(!!body.on)));
+ case '/api/restore': return sendJson(res, await game(OPS.restoreAll()));
+
+ // --- 场景还原(摆拍):扫描本图丢失的角色 → 强行切页让它们出现 ---
+ // 静态摆拍与撤销由 photo-state 管理;自动和手动存档照常,仅提示风险。
+ case '/api/events/lost': {
+ const before = await identity();
+ const r = await cdp.evalJson(guarded(OPS.scanLostEvents(), before.mapToken));
+ return sendJson(res, { ...r, context: before.context });
+ }
+ case '/api/events/force-page':
+ return sendJson(res, await game(OPS.forcePage(body.eventId, body.page)));
+ // 取消本图所有"钉住"的页号,恢复游戏自己的选页
+ case '/api/events/unpin':
+ return sendJson(res, await game(OPS.unpinPages()));
+
+ case '/api/events/restore-cast':
+ // includeHidden=false(默认)只恢复"被抹除"的,那些是明确的该在不在;
+ // true 才连"游戏有意隐藏"的一起拉出来 —— 会凭空多出人物,界面上要警告
+ return sendJson(res, await game(OPS.restoreLostCast({
+ includeHidden: body.includeHidden === true,
+ })));
+
+ // --- 存"拍照专用档" ---
+ // ⚠️ 会覆盖该槽位原有进度,所以这里先自动备份整个存档目录再存
+ case '/api/save-photo': {
+ // 先在服务端做个粗校验,避免参数写错时白备份一次存档目录
+ // (真正的合法性由游戏侧按 DataManager.maxSavefiles() 判断)
+ const slot = Number(body.slot);
+ if (!Number.isInteger(slot) || slot < 1 || slot > 23) {
+ return sendJson(res, { ok: false, error: `槽位必须是 1~23 之间的整数(0 是自动存档槽,不能存)` }, 400);
+ }
+ const target = targetDir(body);
+ if (current.kind !== body.target || !SAFETY.samePath(current.dir, target) || !current.saveDir || !SAFETY.samePath(current.saveDir, path.join(target, 'save'))) throw new Error('实际连接的游戏或存档目录与所选目标不一致,未保存');
+ const backup = backupSaves(target, 'presave');
+ const r = await game(OPS.savePhotoSlot(slot));
+ return sendJson(res, { ...r, target: current.saveDir, safetyBackup: backup.name });
+ }
+
+ // --- 事件列表(当前地图)---
+ // 把事件页里 call_story 的故事序号翻译成台词,这样列表上显示的是
+ // 「台词」— 说话人 而不是 EV058 这种内部编号(见开发文档第 6 节)
+ case '/api/events': {
+ await storyCache.ensure();
+ const before = await identity();
+ const r = await cdp.evalJson(guarded(OPS.listEvents(), before.mapToken));
+ r.context = before.context;
+ if (r && r.ok) {
+ for (const ev of r.events) {
+ const lines = (ev.storyIds || [])
+ .map(id => ({ id, ...(storyCache.get(id) || {}) }))
+ .filter(s => s.first);
+ ev.lines = lines;
+ // 有剧情的事件优先展示,UI 也据此排序
+ ev.hasStory = (ev.storyIds || []).length > 0;
+ }
+ }
+ return sendJson(res, r);
+ }
+
+ // --- 剧情触发点 ---
+ // 顺便抓一次**当前地图**的"抹除状态"并缓存(开发文档第 6 节):
+ // 被游戏自己抹除的事件页号钉死在 -1,走过去也触发不了,
+ // 只标记当前地图的本次扫描,不跨地图或存档保留旧标记。
+ case '/api/story-text': {
+ await storyCache.ensure({ force: body.refresh === true });
+ return sendJson(res, { ok: true, ...storyCache.status() });
+ }
+ case '/api/triggers': {
+ await storyCache.ensure();
+ erasedCache.clear();
+ let scannedMap = null;
+ if (await cdp.isAlive()) {
+ try {
+ const ev = await game(OPS.listEvents());
+ if (ev && ev.ok && ev.mapId) {
+ scannedMap = ev.mapId;
+ for (const e of ev.events) {
+ erasedCache.set(`${ev.mapId}:${e.id}`, !!e.erased);
+ }
+ }
+ } catch { /* 没连上就只给静态数据 */ }
+ }
+ const triggers = DATA.triggers.map(t => {
+ const key = (t.mapId !== null && t.eventId !== '') ? `${t.mapId}:${t.eventId}` : null;
+ const known = key !== null && erasedCache.has(key);
+ const line = storyCache.get(t.storyId);
+ return { ...t, triggerX: t.x, triggerY: t.y, ...storyPosition(t), line: line?.first || '', speaker: line?.speaker || '',
+ lineCount: line?.count || 0, lines: line?.lines || [], searchText: line?.searchText || '',
+ erased: known ? erasedCache.get(key) : null };
+ });
+ return sendJson(res, { ok: true, triggers, scannedMap, knownErased: erasedCache.size, storyText: storyCache.status() });
+ }
+
+ // --- 存档备份 ---
+ case '/api/backups':
+ return sendJson(res, { ok: true, backups: listBackups(), backupDir: BACKUP_DIR });
+ case '/api/backup': {
+ const target = targetDir(body);
+ const label = body.target;
+ return sendJson(res, { ok: true, ...backupSaves(target, label) });
+ }
+ case '/api/restore-backup': {
+ if (!body.confirm) return sendJson(res, { ok: false, error: '需要确认(会覆盖当前存档)' }, 400);
+ const target = targetDir(body);
+ await assertGamesStopped();
+ return sendJson(res, { ok: true, ...FILES.restoreBackup(BACKUP_DIR, body.name, target) });
+ }
+
+ // --- 沙盒 ---
+ case '/api/sandbox': return sendJson(res, { ok: true, ...createSandbox() });
+ case '/api/sandbox/status': return sendJson(res, { ok: true, ...sandboxJob, exists: sandboxExists() });
+
+ default:
+ return sendJson(res, { ok: false, error: '未知接口: ' + urlPath }, 404);
+ }
+}
+
+const server = http.createServer(async (req, res) => {
+ try {
+ const u = new URL(req.url, `http://127.0.0.1:${CONFIG.webPort}`);
+ if (u.pathname.startsWith('/api/')) await handleApi(req, res, u.pathname, u.searchParams);
+ else serveStatic(res, u.pathname);
+ } catch (e) {
+ sendJson(res, { ok: false, error: String(e && e.message || e) }, 500);
+ }
+});
+
+// 只监听回环地址 —— 局域网内的其他人访问不到
+if (require.main === module) server.listen(CONFIG.webPort, '127.0.0.1', async () => {
+ fs.writeFileSync(path.join(TOOL_DIR, '.server-runtime.json'), JSON.stringify({ port: server.address().port, pid: process.pid }));
+ console.log('');
+ console.log(' 咸鱼喵喵 · 截图辅助工具');
+ console.log(' ────────────────────────────────────────');
+ console.log(` 控制面板: http://127.0.0.1:${CONFIG.webPort}`);
+ console.log(` 游戏目录: ${GAME_DIR}`);
+ console.log(` 沙盒目录: ${CONFIG.sandboxDir} ${sandboxExists() ? '(已存在)' : '(未创建)'}`);
+ console.log(` 调试端口: ${CONFIG.cdpPort}`);
+ const alive = await cdp.isAlive();
+ console.log(` 游戏连接: ${alive ? '已检测到调试端口' : '未检测到(请先启动游戏)'}`);
+ console.log(' ────────────────────────────────────────');
+ console.log(' 按 Ctrl+C 退出');
+ console.log('');
+});
+
+// 退出时主动断开 CDP,不给游戏留悬挂连接
+if (require.main === module) process.on('SIGINT', () => {
+ console.log('\n正在退出…');
+ cdp.close();
+ server.close(() => process.exit(0));
+ setTimeout(() => process.exit(0), 1000);
+});
+
+module.exports = { server, cdp, CONFIG, saveConfig, handleApi, executeApi, identity, storyCache };
diff --git a/tool/web/app.js b/tool/web/app.js
new file mode 100644
index 0000000..4583b09
--- /dev/null
+++ b/tool/web/app.js
@@ -0,0 +1,1046 @@
+// 咸鱼喵喵 · 截图辅助工具 —— 控制面板逻辑
+
+const $ = (sel) => document.querySelector(sel);
+const $$ = (sel) => [...document.querySelectorAll(sel)];
+
+let SWITCHES = []; // [{id, name, common}]
+let SWITCH_BY_NAME = new Map(); // 开关名 → id
+let TRIGGERS = [];
+let STORY_TEXT_STATE = { state: 'disconnected', loaded: false, revision: -1, connection: null };
+let TRIGGER_REQUEST = 0;
+function clearRuntimeText() {
+ ++TRIGGER_REQUEST;
+ TRIGGERS = TRIGGERS.map(t => ({ ...t, line: '', speaker: '', lineCount: 0, lines: [], searchText: '', erased: null }));
+ EVENTS = []; EVENTS_CONTEXT = null;
+ renderEvents(); renderTriggers();
+}
+function applyStoryState(state) {
+ const previous = STORY_TEXT_STATE;
+ if ((!state.loaded && previous.loaded) || state.connection !== previous.connection) clearRuntimeText();
+ STORY_TEXT_STATE = state;
+ renderStoryTextHint();
+ if (state.loaded && (state.revision !== previous.revision || state.connection !== previous.connection)) loadTriggers();
+}
+function renderStoryTextHint() {
+ const el = $('#story-text-hint');
+ el.hidden = STORY_TEXT_STATE.loaded;
+ const messages = {
+ disconnected: '连接游戏后会自动读取台词。现在可按地图、坐标或开关名搜索。',
+ waiting: '已连接,等待游戏的故事表就绪…', loading: '正在读取台词…',
+ retrying: '故事表尚未就绪,稍后自动重试。',
+ error: '台词读取失败,自动重试已停止。可点“重新读取台词”再试。',
+ };
+ el.textContent = (messages[STORY_TEXT_STATE.state] || '') + (STORY_TEXT_STATE.error ? ' 原因:' + STORY_TEXT_STATE.error : '');
+}
+
+let SHOW_ALL_SWITCHES = false;
+let CURRENT_CONTEXT = null;
+let CURRENT_GAME = null;
+let EVENTS_CONTEXT = null;
+let LOST_CONTEXT = null;
+let STATUS_BUSY = false;
+let LAST_MAP = null; // 上一次看到的地图号,用来检测换图
+
+let TRAINER = null, TRAINER_READING = false, TRAINER_WRITING = false, TRAINER_REVISION = 0;
+
+// ---------- 基础工具 ----------
+async function api(path, body) {
+ if (body) body = { context: CURRENT_CONTEXT, ...body };
+ const opt = body
+ ? { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }
+ : {};
+ const r = await fetch('/api' + path, opt);
+ const j = await r.json();
+ if (!j.ok) throw new Error(j.error || '操作失败');
+ return j;
+}
+
+let toastTimer = null;
+function toast(msg, kind = '') {
+ const t = $('#toast');
+ t.textContent = msg;
+ t.className = 'toast show ' + kind;
+ clearTimeout(toastTimer);
+ toastTimer = setTimeout(() => { t.className = 'toast ' + kind; }, 3600);
+}
+
+function esc(s) {
+ return String(s ?? '').replace(/[&<>"']/g, c =>
+ ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
+}
+
+// ---------- 角色与背包 ----------
+const STAT_NAMES = { coin: '金币', hp: '生命 HP', sp: '体力 SP', zp: '能量 ZP' };
+const ITEM_TYPES = { consumables: '消耗品', weapons: '武器', stones: '宝石', ornaments: '饰品', joycoin: '特殊材料', hpst: '特殊材料', skillst: '特殊材料' };
+function invalidateTrainer(message = '请先连接游戏并进入地图') {
+ TRAINER = null; TRAINER_REVISION++;
+ $('#trainer-role').textContent = message;
+ $('#trainer-stats').replaceChildren();
+ $('#trainer-items').innerHTML = `
${esc(message)}
`;
+}
+function renderTrainer(resetInputs = false) {
+ if (!TRAINER) return;
+ $('#trainer-role').textContent = `当前操作角色:${TRAINER.role}`;
+ for (const [field, label] of Object.entries(STAT_NAMES)) {
+ const stat = TRAINER.stats[field];
+ let row = $('#trainer-stat-' + field);
+ if (!row) {
+ row = document.createElement('div'); row.className = 'trainer-stat'; row.id = 'trainer-stat-' + field;
+ row.innerHTML = `${label} `;
+ $('#trainer-stats').appendChild(row);
+ row.querySelector('[data-set]').onclick = () => changeTrainer({ action: 'stat', field, value: numericInput(row.querySelector('input')) });
+ row.querySelector('[data-lock]').onclick = () => changeTrainer({ action: 'stat', field,
+ locked: !TRAINER.stats[field].locked, value: numericInput(row.querySelector('input')) });
+ resetInputs = true;
+ }
+ const shown = Math.round(stat.value * 100) / 100;
+ row.querySelector('[data-current]').textContent = `${shown} / ${stat.max}${stat.locked ? ' · 已锁定' : ''}`;
+ const input = row.querySelector('input'); input.max = stat.max;
+ if (resetInputs) input.value = stat.locked ? Math.min(stat.target, stat.max) : Math.floor(stat.value);
+ row.querySelector('[data-lock]').textContent = stat.locked ? '解除锁定' : '设值并锁定';
+ }
+ if (!$('#trainer-items').contains(document.activeElement) || resetInputs) renderTrainerItems();
+ trainerDisabled();
+}
+function numericInput(input) { return input.value.trim() === '' ? null : Number(input.value); }
+function trainerDisabled() {
+ $$('#trainer-stats button, #trainer-stats input, #trainer-items button, #trainer-items input').forEach(el => { el.disabled = TRAINER_WRITING || !TRAINER; });
+ $('#trainer-refresh').disabled = TRAINER_WRITING || TRAINER_READING;
+}
+function renderTrainerItems() {
+ if (!TRAINER) return;
+ const query = $('#trainer-search').value.trim().toLowerCase(), category = $('#trainer-category').value;
+ const items = TRAINER.items.filter(item =>
+ (!category || category === item.type || (category === 'special' && ITEM_TYPES[item.type] === '特殊材料')) &&
+ (!$('#trainer-owned').checked || item.count > 0) && `${item.name} ${item.desc} ${item.id}`.toLowerCase().includes(query));
+ $('#trainer-items').innerHTML = items.map(item => {
+ const counted = item.type === 'consumables' || ITEM_TYPES[item.type] === '特殊材料';
+ const max = item.type === 'consumables' && $('#trainer-overcap').checked ? 99999 : item.max;
+ return `${esc(item.name)} ${item.equipped ? '已装备' : ''}
${ITEM_TYPES[item.type]} #${item.id} · 已有 ${item.count}${counted ? ` · 上限 ${item.max}` : ''}
${esc(item.desc)}
${counted ? `
` : `
`}
`;
+ }).join('') || '没有匹配物品
';
+ trainerDisabled();
+}
+async function loadTrainer(resetInputs = false) {
+ if (TRAINER_READING || TRAINER_WRITING || !CURRENT_CONTEXT) return;
+ TRAINER_READING = true; trainerDisabled();
+ const revision = TRAINER_REVISION, context = CURRENT_CONTEXT;
+ try {
+ const result = await api('/trainer');
+ if (revision !== TRAINER_REVISION || context !== CURRENT_CONTEXT || result.context !== context) return;
+ const changed = TRAINER?.subject !== result.subject;
+ TRAINER = result; renderTrainer(resetInputs || changed);
+ } catch (e) {
+ if (revision === TRAINER_REVISION) invalidateTrainer('读取失败:' + e.message);
+ } finally { TRAINER_READING = false; trainerDisabled(); }
+}
+async function changeTrainer(body) {
+ if (!TRAINER || TRAINER_WRITING) return;
+ const context = TRAINER.context, subject = TRAINER.subject, revision = ++TRAINER_REVISION;
+ TRAINER_WRITING = true; trainerDisabled();
+ try {
+ const result = await api('/trainer', { ...body, context, subject });
+ if (revision !== TRAINER_REVISION || CURRENT_CONTEXT !== context) return;
+ TRAINER = result; renderTrainer(true);
+ toast(body.locked === false ? '已解除锁定' : '已修改', 'ok');
+ } catch (e) { toast(e.message, 'err'); }
+ finally { TRAINER_WRITING = false; trainerDisabled(); }
+}
+$('#trainer-refresh').onclick = () => loadTrainer(true);
+$('#trainer-search').oninput = renderTrainerItems;
+['trainer-category', 'trainer-owned', 'trainer-overcap'].forEach(id => { $('#' + id).onchange = renderTrainerItems; });
+$('#trainer-items').onclick = ev => {
+ const button = ev.target.closest('button');
+ if (!button) return;
+ const row = button.closest('[data-type]');
+ changeTrainer({ action: 'item', type: row.dataset.type, id: Number(row.dataset.id),
+ count: button.hasAttribute('data-count') ? numericInput(row.querySelector('input')) : Number(button.dataset.owned), overCap: $('#trainer-overcap').checked });
+};
+
+// ---------- 标签页 ----------
+$$('.tab').forEach(tab => {
+ tab.onclick = () => {
+ $$('.tab').forEach(t => t.classList.toggle('active', t === tab));
+ $$('.panel').forEach(p => p.classList.toggle('active', p.id === 'panel-' + tab.dataset.panel));
+ if (tab.dataset.panel === 'trainer') loadTrainer();
+ };
+});
+
+// ---------- 状态轮询 ----------
+
+/**
+ * 渲染「当前连接」这一格 —— 让用户一眼看出面板连的是沙盒还是原版。
+ *
+ * 背景:沙盒和原版默认共用同一个调试端口,面板光看"已连接"分不出对面是谁。
+ * 用户以为在沙盒里操作、实际改的是原版存档,是这套工具最危险的误用方式
+ * (见 复查与隐患.md 坑 5)。这一格是最后一道兜底。
+ *
+ * 判断由服务端做(它有 gameDir 和 sandboxDir 两个路径可以比对),
+ * 前端只负责显示,免得两边各写一套判断逻辑。
+ */
+function renderGameSrc(s) {
+ const wrap = $('#st-src-wrap'), el = $('#st-src');
+ if (!wrap || !el) return;
+
+ const gs = s.gameSelf;
+ let cls = 'stat game-src';
+ let text = '—';
+
+ if (!s.gameRunning) {
+ text = '未启动游戏';
+ } else if (!gs || !gs.dir) {
+ // 连上了却读不到目录:可能地址形态和预期不一致。不硬猜,如实说读不到。
+ text = '读不到游戏目录';
+ cls += ' other';
+ } else if (gs.kind === 'sandbox') {
+ text = `${gs.dir}(沙盒)`;
+ cls += ' sandbox';
+ } else if (gs.kind === 'original') {
+ text = `${gs.dir} ⚠️ 这是原版!任何操作都在动正式存档`;
+ cls += ' original';
+ } else {
+ text = `${gs.dir}(不在原版 / 沙盒路径上)`;
+ cls += ' other';
+ }
+
+ wrap.className = cls;
+ el.textContent = text;
+}
+
+async function refreshStatus() {
+ if (STATUS_BUSY) return;
+ STATUS_BUSY = true;
+ try {
+ const s = await api('/status');
+ $('#connDot').className = 'dot ' + (s.connected ? 'on' : 'off');
+ $('#connText').textContent = s.connected ? (s.gameSelf?.inMap ? `已连接游戏(端口 ${s.cdpPort})` : '已连接,请在游戏里读档进入地图') : (s.gameRunning ? '调试端口可达,但连接未建立' : '未检测到游戏 — 请先启动游戏');
+ renderGameSrc(s);
+ applyStoryState(s.storyText || { state: 'disconnected', loaded: false, connection: null });
+ const nextContext = s.connected && s.gameSelf?.inMap ? s.context : null;
+ CURRENT_GAME = s.gameSelf;
+ if (CURRENT_CONTEXT !== nextContext) {
+ CURRENT_CONTEXT = nextContext;
+ EVENTS_CONTEXT = LOST_CONTEXT = null;
+ invalidateTrainer(nextContext ? '正在读取角色与背包…' : '请先连接游戏并进入地图');
+ EVENTS = []; LOST = []; VISIBLE = [];
+ renderEvents(); renderLost();
+ if (TRIGGERS.length) loadTriggers();
+ }
+ const where = $('#photo-target');
+ if (where) where.textContent = s.gameSelf?.saveDir || '未连接游戏,不能保存';
+
+ if (s.connected && s.gameSelf?.inMap) {
+ if ($('#panel-trainer').classList.contains('active')) loadTrainer();
+ try {
+ const snap = await api('/snapshot');
+ $('#st-map').textContent = `${snap.mapId} ${snap.mapName}`.trim();
+ $('#st-pos').textContent = `(${snap.x}, ${snap.y})`;
+ $('#st-hp').textContent = snap.hp ?? '—';
+ $('#st-sw').textContent = snap.switchesOn ?? '—';
+ $('#st-grav').textContent = snap.gravitySuspended ? '已关' : '正常';
+ $('#st-inv').textContent = snap.invincible ? '已开' : '关';
+
+ // 换地图了 → 重新拉一次跳转点,好把新地图的"已抹除"状态标出来
+ if (LAST_MAP !== null && LAST_MAP !== snap.mapId) loadTriggers();
+ LAST_MAP = snap.mapId;
+ for (const [key, id] of [['message','q-msg'],['artwork','q-art'],['hud','q-hud']]) {
+ $('#' + id).classList.toggle('on', !snap.uiHidden?.[key]);
+ }
+ $('#photo-state').textContent = snap.stagedActors ? `摆拍角色 ${snap.stagedActors} 个:存档照常;不推荐此时保存,读档后摆拍状态可能无法完整恢复` : '';
+ // 顶部常驻开关:高亮 = 这个开关是"开着/显示着"的
+ $('#q-gravity').classList.toggle('on', !snap.gravitySuspended); // 高亮=重力正常
+ $('#q-invincible').classList.toggle('on', !!snap.invincible);
+ $('#q-noclip').classList.toggle('on', !!snap.noclip);
+ if (snap.lilyVisible !== null && snap.lilyVisible !== undefined) {
+ $('#q-lily').classList.toggle('on', !!snap.lilyVisible);
+ }
+
+ // 传送面板右上角显示当前是"悬浮"还是"站立"
+ const floating = snap.gravitySuspended || snap.noclip;
+ $('#tp-state').textContent = floating ? '悬浮中' : '站立';
+ } catch (e) {
+ $('#st-map').textContent = '读取失败';
+ }
+ } else {
+ $('#photo-state').textContent = '';
+ ['st-map', 'st-pos', 'st-hp', 'st-sw', 'st-grav', 'st-inv'].forEach(id => $('#' + id).textContent = '—');
+ $('#tp-state').textContent = '—';
+ }
+ } catch (e) {
+ $('#connDot').className = 'dot off';
+ $('#connText').textContent = '无法连接本地服务';
+ applyStoryState({ state: 'disconnected', loaded: false, connection: null });
+ CURRENT_CONTEXT = null; CURRENT_GAME = null; EVENTS_CONTEXT = LOST_CONTEXT = null;
+ invalidateTrainer('连接已断开,请重连后刷新');
+ renderGameSrc({gameRunning:false});
+ $('#photo-target').textContent = '未连接游戏,不能保存';
+ $('#photo-state').textContent = '';
+ ['st-map','st-pos','st-hp','st-sw','st-grav','st-inv'].forEach(id => $('#' + id).textContent = '—');
+ EVENTS = []; LOST = []; VISIBLE = []; renderEvents(); renderLost();
+ } finally { STATUS_BUSY = false; }
+}
+
+// ---------- 传送 ----------
+// 传送默认走"悬浮安全模式"(后端 teleport 里 float 默认为 true),
+// 目的是绕开游戏的坠落复位机制,详见 ops.js 里 teleport 的注释。
+$('#tp-go').onclick = async () => {
+ const x = Number($('#tp-x').value), y = Number($('#tp-y').value);
+ try {
+ const r = await api('/teleport', { x, y });
+ if (r.busy) {
+ // 演出中的角色位置由事件系统接管,会被立刻覆盖 —— 说清楚,别让人以为工具坏了
+ toast(`传送到 (${r.x}, ${r.y}),但游戏正在播放剧情,位置可能被演出覆盖。等剧情结束再传。`, 'err');
+ } else if (r.float) {
+ toast(`已传送到 (${r.x}, ${r.y}),悬浮中。想走动请点「落地」。`, 'ok');
+ } else {
+ toast(`已传送到 (${r.x}, ${r.y})`, 'ok');
+ }
+ refreshStatus();
+ } catch (e) { toast(e.message, 'err'); }
+};
+
+$('#tp-here').onclick = async () => {
+ try {
+ const s = await api('/snapshot');
+ $('#tp-x').value = s.x; $('#tp-y').value = s.y;
+ toast(`已填入当前坐标 (${s.x}, ${s.y})`);
+ } catch (e) { toast(e.message, 'err'); }
+};
+
+// 落地:后端会先探测脚下是不是坑,是坑就拒绝执行
+$('#tp-land').onclick = async () => {
+ const btn = $('#tp-land');
+ btn.disabled = true;
+ try {
+ const r = await api('/land', {});
+ toast(r.warning || `已落地(下坠 ${r.dropDistance} 像素)`, r.warning ? 'err' : 'ok');
+ refreshStatus();
+ } catch (e) {
+ toast(e.message, 'err');
+ }
+ btn.disabled = false;
+};
+
+$$('[data-nudge]').forEach(b => {
+ b.onclick = () => {
+ const [dx, dy] = b.dataset.nudge.split(',').map(Number);
+ $('#tp-x').value = Number($('#tp-x').value || 0) + dx;
+ $('#tp-y').value = Number($('#tp-y').value || 0) + dy;
+ $('#tp-go').click();
+ };
+});
+
+// ---------- 本图事件 ----------
+let EVENTS = [];
+
+/**
+ * 立绘名 → 人话。
+ *
+ * 事件的立绘名(img/characters/ 下的图名)本身有描述性,实测常见的有
+ * ;cat_coin_copper(铜币) / ;moving_platform(移动平台) / ;minecar_wheel(矿车轮) 等。
+ * 这里只翻译**能确定的**,不确定的一律原样显示英文名 ——
+ * 猜一个错的译名比显示英文原名更糟。
+ */
+const SPRITE_NAMES = [
+ [/^cat_coin_copper/, '铜币'],
+ [/^cat_coin_silver/, '银币'],
+ [/^cat_coin_gold/, '金币'],
+ [/^cat_coin_diamond/, '钻石币'],
+ [/^moving_platform/, '移动平台'],
+ [/^minecar_wheel/, '矿车轮'],
+ [/^minecar/, '矿车'],
+ [/^icon_item_/, '道具图标'],
+ [/^tower_box/, '宝箱'],
+ [/^difficulty_switch/, '难度开关'],
+ [/^iron_string/, '铁索'],
+ [/^frame$/, '边框装饰'],
+ [/_fake\d*$/, '装饰地形'],
+];
+
+function spriteLabel(sprite) {
+ if (!sprite) return '';
+ for (const [re, name] of SPRITE_NAMES) if (re.test(sprite)) return name;
+ return ''; // 不认识就返回空,调用方会退回显示英文原名
+}
+
+async function loadEvents() {
+ const btn = $('#ev-load');
+ btn.disabled = true;
+ try {
+ const r = await api('/events');
+ if (r.context !== CURRENT_CONTEXT) throw new Error('地图已变化,请刷新本图事件');
+ EVENTS_CONTEXT = r.context;
+ EVENTS = r.events || [];
+ const withStory = EVENTS.filter(e => e.hasStory).length;
+ renderEvents();
+ toast(`本图 ${EVENTS.length} 个事件,其中 ${withStory} 个带剧情`, 'ok');
+ } catch (e) { toast(e.message, 'err'); }
+ btn.disabled = false;
+}
+
+function renderEvents() {
+ const q = $('#ev-filter').value.trim().toLowerCase();
+ const showAll = $('#ev-all').checked;
+
+ let list = showAll ? EVENTS : EVENTS.filter(e => e.hasStory);
+ if (q) {
+ list = list.filter(e => {
+ const hay = [e.name, e.sprite, e.trigger,
+ ...(e.lines || []).map(l => l.searchText || (l.first + ' ' + l.speaker))]
+ .join(' ').toLowerCase();
+ return hay.includes(q) || String(e.id).includes(q);
+ });
+ }
+ // 带剧情的排前面
+ list = [...list].sort((a, b) => (b.hasStory ? 1 : 0) - (a.hasStory ? 1 : 0));
+
+ const box = $('#ev-list');
+ if (!list.length) {
+ box.innerHTML = `${showAll ? '没有匹配的事件' : '本图没有带剧情的事件(可勾选「显示全部事件」)'}
`;
+ return;
+ }
+
+ box.innerHTML = list.map(e => {
+ const first = (q && (e.lines || []).find(l => (l.searchText || '').toLowerCase().includes(q))) || (e.lines && e.lines[0]) || null;
+ const match = q && (first?.lines || []).find(l => (l.text + ' ' + l.speaker).toLowerCase().includes(q));
+ // 有剧情就用台词当标题,否则退回"立绘译名 → 英文原名 → 事件触发方式"
+ const headline = first
+ ? `「${esc(match?.text || first.first)}」${(match?.speaker || first.speaker) ? ' — ' + esc(match?.speaker || first.speaker) : ''}`
+ : (spriteLabel(e.sprite) || e.sprite || `${e.trigger}的事件`);
+ const raw = (first ? '' : (e.sprite ? ` · ${esc(e.sprite)}` : ''));
+ const more = (e.lines && e.lines.length > 1) ? ` · 共 ${e.lines.length} 段剧情` : '';
+
+ return `
+
+ #${e.id}
+
+ ${headline}
+ (${e.x}, ${e.y}) · ${esc(e.trigger)}${more}${raw}
+
+ ${e.hasStory ? '剧情' : ''}
+
+
`;
+ }).join('');
+
+ box.querySelectorAll('button[data-go-x]').forEach(b => {
+ b.onclick = async () => {
+ try {
+ const r = await api('/teleport', { x: Number(b.dataset.goX), y: Number(b.dataset.goY), context: EVENTS_CONTEXT });
+ toast(r.busy
+ ? `已传送到 (${r.x}, ${r.y}),但游戏正在播放剧情,位置可能被覆盖。`
+ : `已传送到 (${r.x}, ${r.y}),悬浮中。`, r.busy ? 'err' : 'ok');
+ refreshStatus();
+ } catch (e) { toast(e.message, 'err'); }
+ };
+ });
+}
+$('#ev-load').onclick = loadEvents;
+$('#ev-filter').oninput = renderEvents;
+$('#ev-all').onchange = renderEvents;
+
+// ---------- 场景还原(摆拍)----------
+// 静态摆拍由 photo-state 管理。撤销恢复原状态;摆拍期间仅提示保存风险。
+let LOST = []; // 所有"有可能拉出来"的事件
+let VISIBLE = []; // 现在已经看得见的
+
+async function scanLost() {
+ const btn = $('#lost-scan');
+ btn.disabled = true;
+ try {
+ const r = await api('/events/lost');
+ if (r.context !== CURRENT_CONTEXT) throw new Error('地图已变化,请重新扫描');
+ LOST_CONTEXT = r.context;
+ LOST = r.lost || [];
+ VISIBLE = r.visible || [];
+ renderLost();
+ const erased = LOST.filter(e => e.erased).length;
+ toast(`本图现在可见 ${VISIBLE.length} 个角色;另有 ${LOST.length} 个可以拉出来`
+ + (erased ? `(其中 ${erased} 个是被抹除的)` : ''), 'ok');
+ } catch (e) { toast(e.message, 'err'); }
+ btn.disabled = false;
+}
+
+function renderLost() {
+ const box = $('#lost-list');
+ if (!LOST.length && !VISIBLE.length) {
+ box.innerHTML = '本图没有可显示的角色(或还没扫描)
';
+ return;
+ }
+
+ let html = '';
+
+ // 已经看得见的:告诉你"这些本来就在场",免得以为多了
+ if (VISIBLE.length) {
+ html += `已在场(${VISIBLE.length} 个)—— 这些本来就有,不是多出来的
`;
+ html += VISIBLE.map(v => `
+
+ #${v.id}
+
+ ${esc(String(v.sprite).replace(/^;/, ''))}
+ (${v.x}, ${v.y})
+
+ 在场
+
`).join('');
+ }
+
+ // 可以拉出来的:分成"被抹除"(该在不在)和"游戏隐藏"(可能是有意的)
+ const erased = LOST.filter(e => e.erased);
+ const hidden = LOST.filter(e => !e.erased);
+
+ if (erased.length) {
+ html += `被游戏抹除(${erased.length} 个)—— 曾被事件脚本抹除;是否符合目标剧情需自行核对
`;
+ html += erased.map(e => lostRow(e)).join('');
+ }
+ if (hidden.length) {
+ html += `游戏有意隐藏(${hidden.length} 个)—— 可能是剧情推进后本来就该退场,拉出来未必符合原作
`;
+ html += hidden.map(e => lostRow(e)).join('');
+ }
+
+ box.innerHTML = html;
+ box.querySelectorAll('button[data-lost]').forEach(b => {
+ b.onclick = () => forcePageOf(LOST[Number(b.dataset.lost)]);
+ });
+
+ const hint = $('#lost-hint');
+ if (hint) {
+ hint.textContent = LOST.length
+ ? `共 ${LOST.length} 个可拉出(抹除 ${erased.length} / 游戏隐藏 ${hidden.length})`
+ : '本图没有被隐藏的角色';
+ }
+}
+
+function lostRow(e) {
+ const i = LOST.indexOf(e);
+ return `
+
+ #${e.id}
+
+ ${esc(String(e.suggestSprite).replace(/^;/, ''))}
+ (${e.x}, ${e.y}) · 切到第 ${e.suggestPage + 1} 页
+
+ ${e.erased ? '已抹除' : '游戏隐藏'}
+
+
`;
+}
+
+async function forcePageOf(e) {
+ try {
+ const r = await api('/events/force-page', { eventId: e.id, page: e.suggestPage, context: LOST_CONTEXT });
+ if (!r.ok) throw new Error(r.error || '切页失败');
+ if (!r.visible) {
+ toast(`事件 #${e.id} 切到第 ${r.page + 1} 页,但这一页没有立绘`, 'err');
+ } else {
+ toast(`已拉出 ${r.sprite.replace(/^;/, '')}(第 ${r.page + 1} 页)。可截图,完成后点“撤销场景还原”。`, 'ok');
+ }
+ } catch (err) { toast(err.message, 'err'); }
+}
+
+$('#lost-scan').onclick = scanLost;
+
+// 取消所有"钉住"的页号:强切页现在是靠覆盖 findProperPageIndex 实现的,
+// 会一直生效(这是为了扛住游戏的重算),想恢复原状就点这个。
+$('#lost-unpin').onclick = async () => {
+ try {
+ const r = await api('/events/unpin', {});
+ toast(r.unpinned ? `已撤销 ${r.unpinned} 个角色的摆拍状态` : '本图没有被人为固定的角色');
+ scanLost();
+ } catch (e) { toast(e.message, 'err'); }
+};
+
+// 默认只恢复"被抹除"的 —— 那些是明确的"该在不在"。
+// 连"游戏有意隐藏"的一起拉,会凭空多出人物,所以单独一个按钮 + 明确警告。
+$('#lost-restore-all').onclick = () => restoreAll(false);
+$('#lost-restore-hidden').onclick = () => restoreAll(true);
+
+async function restoreAll(includeHidden) {
+ if (includeHidden && !confirm(
+ '这会把「游戏有意隐藏」的角色也一起拉出来。\n\n' +
+ '那些角色可能是剧情推进后本来就该退场的,拉出来会让画面多出不该在场的人物,\n' +
+ '和原作不符。\n\n确定继续吗?')) return;
+
+ const btn = includeHidden ? $('#lost-restore-hidden') : $('#lost-restore-all');
+ btn.disabled = true;
+ try {
+ const r = await api('/events/restore-cast', { includeHidden });
+ if (!r.restored) {
+ toast(r.skippedCount
+ ? `没有"被抹除"的角色可恢复(另有 ${r.skippedCount} 个是游戏有意隐藏的,要用旁边那个按钮)`
+ : '没有可拉出来的角色(先点「扫描」确认本图有没有)');
+ } else if (r.duplicates && r.duplicates.length) {
+ // 同一角色在数据里有好几份事件,全拉出来会变成好几个一样的人
+ const d = r.duplicates.map(x => `${x.sprite.replace(/^;/, '')} ×${x.count}`).join('、');
+ toast(`拉出了 ${r.restored} 个,但其中有重复:${d}。`
+ + `同一个角色在数据里有好几份事件,全拉出来会变成好几个一模一样的人。`
+ + `建议只保留需要的那一个(先「全部还原」再单独点某个「拉出来」)。`, 'err');
+ } else {
+ toast(`拉出了 ${r.restored} 个角色`
+ + (r.erasedCount ? `(其中 ${r.erasedCount} 个是被抹除的,静态摆拍角色)` : '')
+ + (r.skippedCount ? `。另有 ${r.skippedCount} 个是游戏有意隐藏的,没动` : '')
+ + '。完成后点“撤销场景还原”。', 'ok');
+ }
+ scanLost();
+ } catch (e) { toast(e.message, 'err'); }
+ btn.disabled = false;
+}
+
+// ---------- 存拍照专用档 ----------
+$('#photo-save').onclick = async () => {
+ const slot = Number($('#photo-slot').value);
+ const target = CURRENT_GAME?.kind;
+ if (!CURRENT_CONTEXT || !['sandbox','original'].includes(target)) return toast('无法确认当前游戏和存档目录', 'err');
+ const where = (target === 'sandbox' ? '沙盒' : '原版') + ':' + CURRENT_GAME.saveDir;
+ if (!confirm(`确定要把当前场景存进【${where}】的存档槽 ${slot} 吗?\n\n这会覆盖该槽位原有的进度。\n若正在摆拍,不推荐此时保存:读档后角色、UI 或对白可能无法完整恢复。仍可确定继续保存。\n(工具会先自动备份整个存档目录)`)) return;
+ try {
+ const r = await api('/save-photo', { slot, target });
+ if (!r.ok) throw new Error(r.error || '存档失败');
+ toast(`已存到槽 ${r.slot}(地图 ${r.mapId} 坐标 ${r.x},${r.y})。存档前状态已备份为 ${r.safetyBackup}`, 'ok');
+ loadBackups();
+ } catch (e) { toast(e.message, 'err'); }
+};
+
+// ---------- 对话跳转点 ----------
+async function loadTriggers() {
+ const request = ++TRIGGER_REQUEST;
+ try {
+ const r = await api('/triggers');
+ if (request !== TRIGGER_REQUEST) return;
+ STORY_TEXT_STATE = r.storyText; renderStoryTextHint();
+ TRIGGERS = r.triggers || [];
+ $('#story-count').textContent = `${TRIGGERS.length} 条(其中 ${TRIGGERS.filter(t => t.actionable).length} 条可一键跳转)`;
+ renderTriggers();
+ } catch (e) {
+ if (request === TRIGGER_REQUEST) $('#story-list').innerHTML = `载入失败:${esc(e.message)}
`;
+ }
+}
+
+function renderTriggers() {
+ const q = $('#story-filter').value.trim().toLowerCase();
+ const onlyActionable = $('#story-only-actionable').checked;
+ const onlySwitch = $('#story-need-switch').checked;
+
+ const list = TRIGGERS.filter(t => {
+ if (onlyActionable && !t.actionable) return false;
+ if (onlySwitch && !t.needSwitchNames.length) return false;
+ if (!q) return true;
+ const hay = [t.storyId, t.mapId, t.mapArea, t.searchText, t.line, t.speaker, t.eventName,
+ t.triggerType, t.x, t.y, t.condition, ...t.needSwitchNames].join(' ').toLowerCase();
+ return hay.includes(q);
+ });
+
+ const box = $('#story-list');
+ if (!list.length) { box.innerHTML = '没有匹配的跳转点
'; return; }
+
+ box.innerHTML = list.map((t, i) => {
+ // 台词是主要标识 —— "地图170" 谁也看不懂,"「好烦啊!」— 喵露露" 一眼就认出来
+ const match = q && (t.lines || []).find(l => (l.text + ' ' + l.speaker).toLowerCase().includes(q));
+ const preview = match?.text || t.line, speaker = match?.speaker || t.speaker;
+ const headline = preview
+ ? `「${esc(preview)}」${speaker ? ' — ' + esc(speaker) : ''}`
+ : `${esc(t.actionable ? `地图${t.mapId} (${t.x}, ${t.y})` : `故事 #${t.storyId}`)} · ${esc(t.triggerType)}`;
+ const where = [
+ t.mapArea ? esc(t.mapArea) : '',
+ t.actionable ? `地图${t.mapId} (${t.x}, ${t.y})` : '无坐标',
+ t.triggerType || '',
+ t.positionNote ? esc(t.positionNote) : '',
+ t.lineCount > 1 ? `共 ${t.lineCount} 句` : '',
+ ].filter(Boolean).join(' · ');
+
+ return `
+
+ #${t.storyId}
+
+ ${headline}
+ ${where}
+
+ ${t.needSwitchNames.length ? `需开关 ${t.needSwitchNames.length}` : ''}
+ ${t.erased === true ? '已抹除' : ''}
+ ${t.actionable ? `
+
+
+ ` : ''}
+
`;
+ }).join('');
+
+ // --- 运行过场:执行含这段对话的整个事件页 —— NPC 站位、演出、对白全按原样走 ---
+ box.querySelectorAll('button[data-scene]').forEach(b => {
+ b.onclick = async () => {
+ const t = list[Number(b.dataset.scene)];
+ b.disabled = true;
+ try {
+ const r = await api('/story/start', { mode: 'scene', x: t.x, y: t.y, mapId: t.mapId, storyId: t.storyId, eventId: Number(t.eventId) || null, pageIndex: t.pageIndex });
+ if (!r.ok) throw new Error(r.error || '运行失败');
+
+ const sb = r.speakerBody || {};
+ const bodyNote = sb.forced
+ ? `说话者已就位(${String(sb.sprite || '').replace(/^;/, '')})`
+ : (sb.reason ? `说话者没身体:${sb.reason}` : '');
+
+ if (!r.started) {
+ toast('过场已开始,但还没出现对话窗口。前面可能有一段移动/演出,稍等一下。', 'ok');
+ } else if (r.matched === false) {
+ toast(`播出的是故事 ${r.playedStoryId},不是你要的 #${t.storyId}。请把这条反馈给开发者。`, 'err');
+ } else if (r.insideBranch) {
+ toast(`已播 #${t.storyId}。这段对话在【条件分支】里,所以直接从该分支开始播,`
+ + `跳过了前面 ${r.startAt} 条摆位指令。${bodyNote}`, 'ok');
+ } else {
+ toast(`过场执行中(事件 ${r.eventName},共 ${r.commands} 条指令)。${bodyNote}。可以一路截图。`, 'ok');
+ }
+ if (r.landingWarning) toast(r.landingWarning, 'err');
+ else if (r.positionNote) toast(r.positionNote, 'ok');
+ refreshStatus();
+ } catch (e) { toast(e.message, 'err'); }
+ b.disabled = false;
+ };
+ });
+
+ // --- 只播台词:从指令层直接调用 call_story,跳过节目前的演出 ---
+ // 适合只想快速看一眼这段对话;缺点是场景里可能没有对应的 NPC(见开发文档第 6 节)
+ box.querySelectorAll('button[data-play]').forEach(b => {
+ b.onclick = async () => {
+ const t = list[Number(b.dataset.play)];
+ b.disabled = true;
+ try {
+ const r = await api('/story/start', { mode: 'play', x: t.x, y: t.y, mapId: t.mapId, storyId: t.storyId, eventId: Number(t.eventId) || null, pageIndex: t.pageIndex });
+ if (!r.ok) throw new Error(r.error || '播放失败');
+ if (!r.started) {
+ toast('剧情已下发,但没等到对话窗口出现。可能这段剧情需要特定场景。', 'err');
+ } else if (r.matched === false) {
+ toast(`播放的可能不是这段——画面显示「${r.firstText.slice(0, 16)}」,期望「${(r.expected || '').slice(0, 16)}」。先点「关闭对话窗口」再重试。`, 'err');
+ } else {
+ toast(`正在播放 #${t.storyId}(共 ${r.lines} 句)${r.speaker ? ',' + r.speaker + ':' : ':'}${r.firstText.slice(0, 20)}`, 'ok');
+ }
+ refreshStatus();
+ } catch (e) { toast(e.message, 'err'); }
+ b.disabled = false;
+ };
+ });
+
+ // --- 前往:打开开关 + 传送,之后自己走过去按确定键 ---
+ box.querySelectorAll('button[data-goto]').forEach(b => {
+ b.onclick = async () => {
+ const t = list[Number(b.dataset.goto)];
+ b.disabled = true;
+ try {
+ const pairs = [];
+ const missing = [];
+ for (const name of t.needSwitchNames) {
+ const id = SWITCH_BY_NAME.get(name);
+ if (id === undefined) missing.push(name);
+ else pairs.push([id, true]);
+ }
+ const tp = await api('/story/goto', { pairs, x: t.x, y: t.y, mapId: t.mapId, storyId: t.storyId, eventId: Number(t.eventId) || null, pageIndex: t.pageIndex });
+ if (!tp.ok) throw new Error(tp.error || '传送失败');
+
+ const where = tp.changedMap ? `已切换到地图 ${tp.mapId}` : `已传送到 (${tp.x}, ${tp.y})`;
+ if (missing.length) {
+ toast(`${where},但有 ${missing.length} 个开关没找到:${missing.join('、')}`, 'err');
+ } else {
+ toast(`${where},打开了 ${pairs.length} 个普通开关。` + ((t.needVarNames.length || /独立开关/.test(t.condition)) ? '仍需手动核对条件:' + t.condition : '点「落地」站到地上,再尝试触发。'), (t.needVarNames.length || /独立开关/.test(t.condition)) ? 'err' : 'ok');
+ }
+ refreshStatus();
+ } catch (e) { toast(e.message, 'err'); }
+ b.disabled = false;
+ };
+ });
+}
+
+$('#story-filter').oninput = renderTriggers;
+$('#story-only-actionable').onchange = renderTriggers;
+$('#story-need-switch').onchange = renderTriggers;
+
+$('#story-load-text').onclick = async () => {
+ const button = $('#story-load-text'); button.disabled = true;
+ try {
+ const state = await api('/story-text', { refresh: true });
+ applyStoryState(state);
+ await loadTriggers();
+ toast(state.loaded ? `已读取 ${state.count} 条剧情,可搜索任意一句台词` : (state.error || '游戏未连接或故事表尚未就绪'), state.loaded ? 'ok' : 'err');
+ } catch (e) { toast(e.message, 'err'); }
+ finally { button.disabled = false; }
+};
+
+$('#story-close-msg').onclick = async () => {
+ try {
+ await api('/story/close', {});
+ toast('已关闭对话窗口');
+ } catch (e) { toast(e.message, 'err'); }
+};
+
+// ---------- 开关 ----------
+let SWITCH_VALUES = {};
+async function loadSwitches() {
+ try {
+ const r = await api('/switches');
+ SWITCHES = r.switches || [];
+ SWITCH_VALUES = r.values || {};
+ SWITCH_BY_NAME = new Map(SWITCHES.map(s => [s.name, s.id]));
+ renderSwitches();
+ if (!r.connected) toast('开关当前值读取失败(游戏未连接),仅显示名称', 'err');
+ } catch (e) {
+ $('#sw-list').innerHTML = `载入失败:${esc(e.message)}
`;
+ }
+}
+
+function renderSwitches() {
+ const q = $('#sw-filter').value.trim().toLowerCase();
+ let list = SWITCHES;
+ if (!SHOW_ALL_SWITCHES) list = list.filter(s => s.common);
+ if (q) list = list.filter(s => s.name.toLowerCase().includes(q) || String(s.id) === q);
+ list = list.slice(0, 500);
+
+ $('#sw-mode').textContent = SHOW_ALL_SWITCHES
+ ? `当前:全部开关(${SWITCHES.length} 个)`
+ : `当前:常用开关(${SWITCHES.filter(s => s.common).length} 个)`;
+ $('#sw-toggle-all').textContent = SHOW_ALL_SWITCHES
+ ? '只看常用开关'
+ : `展开全部 ${SWITCHES.length} 个`;
+
+ const box = $('#sw-list');
+ if (!list.length) { box.innerHTML = '没有匹配的开关
'; return; }
+
+ box.innerHTML = list.map(s => {
+ const on = SWITCH_VALUES[s.id] === 1;
+ const known = s.id in SWITCH_VALUES;
+ return `
+
+ #${s.id}
+
+ ${esc(s.name)}
+ ${s.common ? '常用' : ''}
+
+ ${known ? (on ? 'ON' : 'OFF') : '—'}
+
+
+
`;
+ }).join('');
+
+ box.querySelectorAll('button[data-sw]').forEach(b => {
+ b.onclick = async () => {
+ const id = Number(b.dataset.sw), val = b.dataset.val === '1';
+ try {
+ await api('/switch', { id, value: val });
+ SWITCH_VALUES[id] = val ? 1 : 0;
+ renderSwitches();
+ refreshStatus();
+ } catch (e) { toast(e.message, 'err'); }
+ };
+ });
+}
+
+$('#sw-filter').oninput = renderSwitches;
+$('#sw-reload').onclick = loadSwitches;
+$('#sw-toggle-all').onclick = () => { SHOW_ALL_SWITCHES = !SHOW_ALL_SWITCHES; renderSwitches(); };
+
+// ---------- 顶部常驻开关 ----------
+// 高亮 = 这个开关是"开着/显示着"的。语义统一,见 index.html 里的 title 提示。
+const chipOn = (id) => $('#' + id).classList.contains('on');
+
+async function flipChip(id, fn) {
+ const el = $('#' + id);
+ el.disabled = true;
+ try {
+ await fn(!chipOn(id));
+ refreshStatus();
+ } catch (e) {
+ toast(e.message, 'err');
+ refreshStatus();
+ }
+ el.disabled = false;
+}
+
+// 重力:高亮 = 正常。点一下切换成"关掉重力"(角色悬空)
+$('#q-gravity').onclick = () => flipChip('q-gravity', on => api('/gravity', { on: !on }));
+// 无敌 / 穿墙:高亮 = 开启
+$('#q-invincible').onclick = () => flipChip('q-invincible', on => api('/invincible', { on }));
+$('#q-noclip').onclick = () => flipChip('q-noclip', on => api('/noclip', { on }));
+// 莉莉娅:高亮 = 显示中
+// ⚠️ flipChip 传进来的是**按钮的新状态**(true = 希望莉莉娅显示),直接当 show 用。
+// 之前写成 show: !on,等于每次都"再显示一遍",点了毫无反应(用户报过)。
+$('#q-lily').onclick = () => flipChip('q-lily', async wantVisible => {
+ await api('/lily', { show: wantVisible });
+ toast(wantVisible ? '莉莉娅已显示' : '莉莉娅已隐藏(有些剧情里她本来就在场,注意别藏错)', 'ok');
+});
+
+// 拍照辅助:高亮 = 显示中;点一下藏起来
+// 注意这是"藏起来"不是"关掉",随时能显示回来。
+// 要真的关掉对话请用「对话剧情」页的「关闭对话窗口」。
+//
+// ⚠️ 注意 `/api/ui-hidden` 的语义是**反向**的:字段传 true 表示"藏起来"。
+// 而 flipChip 传进来的是"按钮的新状态"(true = 希望它显示着)。
+// 所以要取反:setUi({ message: !wantVisible })。
+// (之前直接传 wantVisible,等于每次都在"显示",点了没反应。
+// 莉莉娅那个按钮也是同一个毛病,一起修了。)
+$('#q-msg').onclick = () => flipChip('q-msg', async wantVisible => {
+ await setUi({ message: !wantVisible });
+ toast(wantVisible ? '对话框已显示' : '对话框已隐藏(只是藏起来,随时能显示回来)');
+});
+$('#q-art').onclick = () => flipChip('q-art', wantVisible => setUi({ artwork: !wantVisible }));
+$('#q-hud').onclick = () => flipChip('q-hud', wantVisible => setUi({ hud: !wantVisible }));
+
+async function setUi(state) {
+ const r = await api('/ui-hidden', state);
+ if (!r.ok) return;
+ // 用返回值回写按钮状态,别靠本地猜测
+ if (r.state.message !== undefined) $('#q-msg').classList.toggle('on', !r.state.message);
+ if (r.state.artwork !== undefined) $('#q-art').classList.toggle('on', !r.state.artwork);
+ if (r.state.hud !== undefined) $('#q-hud').classList.toggle('on', !r.state.hud);
+}
+
+$('#q-restore').onclick = async () => {
+ try {
+ await api('/restore', {});
+ toast('已还原重力、碰撞、无敌,撤销摆拍并解除数值锁定', 'ok');
+ loadTrainer(true);
+ refreshStatus();
+ } catch (e) { toast(e.message, 'err'); }
+};
+
+// ---------- 存档与沙盒 ----------
+async function loadBackups() {
+ try {
+ const r = await api('/backups');
+ const box = $('#bk-list');
+ if (!r.backups.length) { box.innerHTML = '还没有备份
'; return; }
+ box.innerHTML = r.backups.map(b => `
+
+
+ ${esc(b.name)}
+ ${b.files} 个文件 · 来源:${esc(b.sourceDir || '旧备份,来源未知')} · ${new Date(b.mtime).toLocaleString('zh-CN')}
+
+
+
`).join('');
+ box.querySelectorAll('button[data-restore]').forEach(btn => {
+ btn.onclick = async () => {
+ const name = btn.dataset.restore;
+ const target = $('#bk-target').value;
+ if (!target) return toast('请先明确选择还原目标', 'err');
+ let cfg; try { cfg = await api('/status'); } catch (e) { return toast(e.message, 'err'); }
+ const dir = target === 'sandbox' ? cfg.sandboxDir : cfg.gameDir;
+ if (!confirm(`将「${name}」还原到【${target === 'sandbox' ? '沙盒' : '原版'}】\n${dir}\n\n请先退出游戏。目标现有存档会先备份;备份中没有的槽位也会被移出当前存档集合。`)) return;
+ try {
+ const r = await api('/restore-backup', { name, confirm: true, target });
+ toast(`已还原 ${r.restored} 个文件(还原前的状态已存为 ${r.safetyBackup})`, 'ok');
+ loadBackups();
+ } catch (e) { toast(e.message, 'err'); }
+ };
+ });
+ } catch (e) {
+ $('#bk-list').innerHTML = `读取失败:${esc(e.message)}
`;
+ }
+}
+
+async function doBackup(sandbox) {
+ try {
+ const r = await api('/backup', { target: sandbox ? 'sandbox' : 'original' });
+ toast(`已备份 ${r.files} 个文件 → ${r.name}`, 'ok');
+ loadBackups();
+ } catch (e) { toast(e.message, 'err'); }
+}
+$('#bk-original').onclick = () => doBackup(false);
+$('#bk-sandbox').onclick = () => doBackup(true);
+$('#bk-reload').onclick = loadBackups;
+
+async function refreshSandbox() {
+ try {
+ const s = await api('/status');
+ const info = $('#sb-info');
+ if (s.sandboxJob && s.sandboxJob.running) {
+ info.innerHTML = `⏳ 正在拷贝到 ${esc(s.sandboxDir)}…(请稍候,复制后会校验文件)`;
+ setTimeout(refreshSandbox, 2500);
+ } else if (s.sandboxJob && s.sandboxJob.error) {
+ info.innerHTML = `❌ 拷贝失败:${esc(s.sandboxJob.error)}`;
+ } else if (s.sandboxExists) {
+ info.innerHTML = `✅ 沙盒已就绪:${esc(s.sandboxDir)}
用 SDK 版 NW.js 启动它即可(调试端口 ${s.cdpPort})。`;
+ } else {
+ info.innerHTML = `尚未创建。创建后会拷贝到 ${esc(s.sandboxDir)}。`;
+ }
+ } catch (e) { /* 忽略 */ }
+}
+
+$('#sb-create').onclick = async () => {
+ if (!confirm('将复制游戏和存档,排除工具及发布版目录,完成后校验副本。\n\n继续吗?')) return;
+ try {
+ await api('/sandbox', {});
+ toast('已开始拷贝,请稍候…');
+ refreshSandbox();
+ } catch (e) { toast(e.message, 'err'); }
+};
+$('#sb-status').onclick = refreshSandbox;
+
+// ---------- 设置 ----------
+let CONFIG = {};
+
+async function loadConfig() {
+ try {
+ const r = await api('/config');
+ CONFIG = r.config;
+ $('#set-cdpPort').value = r.config.cdpPort;
+ $('#set-webPort').value = r.config.webPort;
+ $('#set-sandboxDir').value = r.config.sandboxDir;
+ $('#set-path').textContent = 'tool/config.json';
+ } catch (e) { toast('读取配置失败:' + e.message, 'err'); }
+}
+
+$('#set-save').onclick = async () => {
+ try {
+ const r = await api('/config', {
+ cdpPort: Number($('#set-cdpPort').value),
+ webPort: Number($('#set-webPort').value),
+ sandboxDir: $('#set-sandboxDir').value,
+ });
+ CONFIG = r.config;
+ let msg = '设置已保存';
+ if (r.cdpReconnected) msg += '(调试端口已切换)';
+ if (r.webPortNeedsRestart) msg += '。控制面板端口要重启工具才生效';
+ toast(msg, 'ok');
+ refreshStatus();
+ refreshSettingsInfo();
+ } catch (e) { toast(e.message, 'err'); }
+};
+
+$('#set-reset').onclick = async () => {
+ if (!confirm('把端口和路径恢复成默认值?')) return;
+ try {
+ // 用后端的 DEFAULTS 回填,避免前端写死一份会过期的默认值
+ const r = await api('/config');
+ await api('/config', {
+ cdpPort: r.defaults.cdpPort,
+ webPort: r.defaults.webPort,
+ sandboxDir: r.defaults.sandboxDir,
+ });
+ await loadConfig();
+ toast('已恢复默认值', 'ok');
+ refreshStatus();
+ refreshSettingsInfo();
+ } catch (e) { toast(e.message, 'err'); }
+};
+
+async function refreshSettingsInfo() {
+ try {
+ const s = await api('/status');
+ const rows = [
+ ['游戏目录', s.gameDir],
+ ['沙盒目录', s.sandboxDir + (s.sandboxExists ? ' ✓ 已就绪' : ' ✗ 未创建')],
+ ['调试端口', s.cdpPort + (s.gameRunning ? ' ✓ 已检测到游戏' : ' ✗ 未检测到')],
+ ['连接状态', s.connected ? '✓ 已连接游戏' : '✗ 未连接'],
+ ['数据', `${s.stats.switches} 个开关 · ${s.stats.triggersUnique} 条触发点(可跳转 ${s.stats.triggersActionable})`],
+ ['台词索引', `${s.stats.storyLoaded} 条剧情 · ${s.stats.triggersWithLine} 条触发点有台词`],
+ ];
+ $('#set-info').innerHTML = rows.map(([k, v]) =>
+ `${esc(k)}
${esc(v)}
`
+ ).join('');
+ } catch (e) {
+ $('#set-info').innerHTML = `${esc(e.message)}
`;
+ }
+}
+
+// ---------- 启动 ----------
+(async function init() {
+ await refreshStatus();
+ await loadSwitches(); // 先拿开关名表,跳转点要用它把开关名换成编号
+ await loadTriggers();
+ await loadBackups();
+ await loadConfig();
+ refreshSandbox();
+ refreshSettingsInfo();
+ setInterval(refreshStatus, 3000);
+})();
diff --git a/tool/web/index.html b/tool/web/index.html
new file mode 100644
index 0000000..581ce5c
--- /dev/null
+++ b/tool/web/index.html
@@ -0,0 +1,335 @@
+
+
+
+
+
+咸鱼喵喵 · 截图辅助工具
+
+
+
+
+
+
+
+
+
+ —
+ —
+ —
+ —
+ —
+ —
+
+ —
+
+
+
+
+ 状态
+
+
+
+
+
+
+ 拍照
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
金币、生命与能量
+
请先连接游戏并进入地图
+
作用于当前操作的角色,喵露露/莉莉娅切换形态后仍有效。输入数值后点「设值」或「设值并锁定」;锁定后不随消耗或拾取变化。修改当前值,不改变最大值。
+
+
数值锁定只在本次游戏中有效;读档、新游戏或面板失联超过 2 分钟自动解除。「全部还原」也会解除锁定,已修改的数值与物品不回退。
+
+
+
+
+
+
+
+
+
坐标传送
+
+ 输入目标格子的横纵坐标。坐标超出地图范围会被自动夹到边界。
+
+
+
+ 传送默认进入「悬浮」状态(暂停重力 + 穿墙)。
+ 这是为了避开游戏的坠落复位机制 —— 直接落到坑洞地形上,游戏会判定你掉下去了,
+ 播掉落音效并把你弹回"上次站立的位置",如果这个记录还是上一张地图的,人就会直接消失。
+ 想站到地上走动时,点下面的「落地」(会先探测脚下安不安全)。
+
+
+ ⚠️ 悬浮时别按跳跃键 —— 跳跃给了向上的速度,而重力是关掉的,
+ 没有任何力把它拉回来,人会一直往上飘。
+ 想恢复正常走动:点 「落地」,或顶部 「全部还原」。
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ —
+
+
+
+
+
本图事件
+
+ 默认只列出带剧情的事件(显示台词),也就是截图真正要用的那些。
+ 金币、平台、装饰这类走「显示全部事件」。
+
+
+
+
+
+
+
+
+
+
+
场景还原(摆拍)
+
+ 找出本图本该可见却没出现的角色,强行让它们出现。
+ 用于「对话框在说话、画面里却没人」的情况。
+
+
+ ⚠️ 这是摆拍,不是把游戏改回当时的样子。
+ · 被游戏标记为「已抹除」的事件强行拉出来,它不会执行任何指令,
+ 只是一个站着的立绘;站位也不一定和原作一致。
+ · 摆拍会固定角色,自动和手动存档照常。不推荐摆拍时保存:读档后状态可能无法完整恢复。完成后可点「撤销场景还原」恢复原来的事件状态。
+ 切图、读档或面板失联两分钟也会撤销摆拍。
+ · 如果这条对话有「运行过场」可用,优先用那个——那个是真的在重现演出。
+
+
+
+
+
+
+
+
+ 还没扫描
+
+
+
+
+
+
+
+
+
对话剧情
+
+ 连接后自动读取台词,支持全文搜索。
从游戏数据里解析出的 — 条剧情触发点。
+
+
+ 三种复现方式,优先试「运行过场」
+ · 运行过场 —— 执行含这段对话的整个事件页。
+ NPC 站位、演出、对白全按原样走,画面最接近原作。通关存档也能用。
+ 注意:一整段过场会从头播,可能包含好几段对话,一路截图即可。
+ · 只播台词 —— 只调那一段对白,跳过节目前的演出。快,但
+ 场景里的 NPC 可能不在场(那些角色是靠前面的指令摆出来的)。
+ · 前往 —— 打开所需开关 + 传送,你自己走过去。存档没通关时场景最自然。
+
+
+
+
+
+
+
+
+
+
+ 截完图用它收尾
+
+
+
+
+
+
+
+
+
开关
+
+ 默认只显示常用开关——即"隐藏对话触发条件里真正会用到的那 114 个",
+ 比翻全部 360 个高效得多。需要时可以展开全部。
+
+
+
+
+
+
+
+ 当前:常用开关
+
+
+
+
+
+
+
+
+
⚠️ 用之前先读这段
+
+ - 本工具会改游戏运行时状态(坐标、开关、物理),不改游戏程序和素材;保存、还原会写入明确选定的存档目录。
+ - 作弊状态下游戏可能自动存盘,落到
file0(自动存档槽)。
+ - 本游戏启用了 Steam 云存档。直接在原版游戏上折腾,被污染的存档可能被同步上去且追不回来。
+ - 所以推荐用沙盒:把游戏整体拷一份,在副本里折腾。副本不在 Steam 库里,云同步够不着。
+
+
+
+
+
沙盒副本
+
复制游戏及存档(排除工具和发布版目录),所有操作都在副本里进行。
+
+
+
+
+
—
+
+
+
+
存档备份
+
把 save/ 整个目录复制一份存档。原版和沙盒可以分别备份。
+
+
+
+
+
+
+
+
+
+
+
存「拍照专用档」
+
+ 把当前游戏状态存进一个存档槽。摆拍时也可保存,但不保证读档后完整恢复角色、UI 或对白。
+ 工具会先自动备份整个存档目录,再让游戏自己存。
+
+
+ ⚠️ 会覆盖该槽位原有的进度。
+ 你的 24 个槽位(0 是自动存档,1~23 是手动)目前全部占用,
+ 没有空槽可用——所以请自己挑一个不在乎的槽。
+
+
+
+ 实际保存目录:未连接
+
+
+
+
+
+
关于这个工具
+
+ 粉丝向的截图辅助工具,用于个人复现「错过就没了」的剧情并截图。
+ 不包含、不打包任何游戏素材;不提供成就解锁功能;不修改游戏任何文件。
+ 按作者 readme 的要求,请勿用本工具提取或传播游戏素材。
+
+
+
+
+
+
+
+
+
+
怎么启动这个工具
+
+ - 双击
tool\启动.bat
+ - 它会自动:检查 SDK 版 NW.js → 备份存档 → 启动游戏 → 启动本服务 → 打开浏览器
+ - 在游戏里正常读档进入地图(工具不能替你读档,见开发文档第 6 节)
+
+
+ 只想启动某一部分?
+ · 只起控制面板(游戏已经在跑):在这个目录执行 node server.js
+ · 只启动游戏:nw.exe "沙盒目录" --remote-debugging-port=9223
+ · 改了控制面板端口后,要关掉旧的服务窗口再重新 node server.js
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tool/web/style.css b/tool/web/style.css
new file mode 100644
index 0000000..1beaf09
--- /dev/null
+++ b/tool/web/style.css
@@ -0,0 +1,308 @@
+/* 咸鱼喵喵 · 截图辅助工具 —— 控制面板样式 */
+
+:root {
+ --bg: #14121a;
+ --bg-card: #1e1b26;
+ --bg-input: #2a2634;
+ --bg-hover: #332e40;
+ --border: #35303f;
+ --text: #e9e6f0;
+ --text-dim: #9d97ac;
+ --accent: #ff8ab8;
+ --accent-dk: #d96a96;
+ --ok: #5fd39a;
+ --warn: #f0b849;
+ --danger: #f07070;
+ --radius: 10px;
+}
+
+* { box-sizing: border-box; }
+
+body {
+ margin: 0;
+ background: var(--bg);
+ color: var(--text);
+ font: 14px/1.6 "Microsoft YaHei", "PingFang SC", system-ui, sans-serif;
+ padding-bottom: 48px;
+}
+
+/* ---------- 顶栏 ---------- */
+/* 顶部三件套(标题栏 + 状态条 + 快捷开关)整体吸顶 */
+.topsticky {
+ position: sticky; top: 0; z-index: 50;
+ box-shadow: 0 4px 14px rgba(0,0,0,.35);
+}
+
+.topbar {
+ display: flex; align-items: center; justify-content: space-between;
+ flex-wrap: wrap; gap: 12px;
+ padding: 14px 22px;
+ background: linear-gradient(180deg, #241f2e, #1a1722);
+ border-bottom: 1px solid var(--border);
+}
+.brand-name { font-size: 17px; font-weight: 700; color: var(--accent); }
+.brand-sub { display: block; font-size: 12px; color: var(--text-dim); font-weight: 400; }
+.status-wrap { display: flex; align-items: center; gap: 8px; font-size: 13px; }
+
+.dot {
+ width: 9px; height: 9px; border-radius: 50%;
+ background: var(--text-dim); display: inline-block;
+ box-shadow: 0 0 0 0 rgba(95,211,154,.6);
+}
+.dot.on { background: var(--ok); animation: pulse 2.4s infinite; }
+.dot.off { background: var(--danger); }
+@keyframes pulse {
+ 0% { box-shadow: 0 0 0 0 rgba(95,211,154,.5); }
+ 70% { box-shadow: 0 0 0 8px rgba(95,211,154,0); }
+ 100% { box-shadow: 0 0 0 0 rgba(95,211,154,0); }
+}
+
+/* ---------- 状态条 ---------- */
+.statusbar {
+ display: flex; flex-wrap: wrap; gap: 10px;
+ padding: 12px 22px;
+ background: #191623;
+ border-bottom: 1px solid var(--border);
+}
+.stat {
+ display: flex; align-items: baseline; gap: 7px;
+ background: var(--bg-card); border: 1px solid var(--border);
+ border-radius: 8px; padding: 5px 12px;
+}
+.stat label { font-size: 12px; color: var(--text-dim); }
+.stat b { font-size: 14px; font-variant-numeric: tabular-nums; }
+
+/* 「当前连接」——整行独占,连到原版时整块变红 */
+.stat.game-src { flex: 1 1 100%; }
+.stat.game-src b {
+ font-family: Consolas, monospace; font-size: 12px;
+ word-break: break-all;
+}
+.stat.game-src.sandbox b { color: var(--ok); }
+.stat.game-src.original {
+ background: #3a2222; border-color: var(--danger);
+}
+.stat.game-src.original b { color: #ff9b9b; font-weight: 700; }
+.stat.game-src.other b { color: var(--warn); }
+
+/* ---------- 常驻快捷开关条 ---------- */
+.quickbar {
+ display: flex; align-items: center; flex-wrap: wrap; gap: 6px;
+ padding: 9px 22px;
+ background: #1d1a27;
+ border-bottom: 1px solid var(--border);
+}
+.qb-label {
+ font-size: 12px; color: var(--text-dim);
+ margin-right: 2px; user-select: none;
+}
+.qb-sep {
+ width: 1px; height: 18px; background: var(--border);
+ margin: 0 8px;
+}
+
+/* 胶囊开关:高亮 = 这个开关是"开着/显示着"的 */
+.chip {
+ padding: 5px 14px; font-size: 13px;
+ border-radius: 999px;
+ background: #262231; border: 1px solid var(--border);
+ color: var(--text-dim);
+ transition: background .12s, color .12s, border-color .12s;
+}
+.chip:hover:not(:disabled) { background: var(--bg-hover); border-color: var(--accent); color: var(--text); }
+.chip.on {
+ background: #4a2a3c; border-color: var(--accent);
+ color: #ffd7e8; font-weight: 700;
+}
+.chip.on::before {
+ content: '●'; font-size: 9px; margin-right: 5px;
+ color: var(--accent); vertical-align: 1px;
+}
+.chip.danger { border-color: #7a3a3a; color: #ffc9c9; }
+.chip.danger:hover { background: #6a3232; border-color: var(--danger); }
+
+/* ---------- 标签页 ---------- */
+.tabs {
+ display: flex; gap: 4px; flex-wrap: wrap;
+ padding: 14px 22px 0;
+}
+.tab {
+ background: transparent; color: var(--text-dim);
+ border: 1px solid transparent; border-bottom: none;
+ border-radius: 8px 8px 0 0;
+ padding: 9px 18px; cursor: pointer; font-size: 14px;
+ font-family: inherit;
+}
+.tab:hover { background: var(--bg-hover); color: var(--text); }
+.tab.active {
+ background: var(--bg-card); color: var(--accent);
+ border-color: var(--border); font-weight: 700;
+}
+
+/* ---------- 面板 ---------- */
+main { padding: 0 22px; }
+.panel { display: none; }
+.panel.active { display: block; }
+
+.card {
+ background: var(--bg-card);
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ padding: 18px 20px;
+ margin: 16px 0;
+}
+.card h2 {
+ margin: 0 0 6px; font-size: 15px; color: var(--accent);
+ font-weight: 700;
+}
+.hint { margin: 0 0 14px; font-size: 13px; color: var(--text-dim); }
+.hint b { color: var(--text); }
+.note {
+ margin-top: 14px; padding: 10px 14px;
+ background: #232030; border-left: 3px solid var(--accent);
+ border-radius: 6px; font-size: 13px; color: var(--text-dim);
+}
+.note b { color: var(--text); }
+
+/* 警告性质的提示条 */
+.note.warn-note {
+ border-left-color: var(--warn);
+ background: #2b2620;
+ color: #e0d5c2;
+}
+.note.warn-note b { color: var(--warn); }
+
+.warn-card { border-color: #5a4630; background: #241f1a; }
+.warn-card h2 { color: var(--warn); }
+.warn-list { margin: 0; padding-left: 20px; font-size: 13px; color: #d8cfc0; }
+.warn-list li { margin: 6px 0; }
+.warn-list b { color: var(--warn); }
+code {
+ background: #2a2634; padding: 1px 6px; border-radius: 4px;
+ font-family: Consolas, monospace; font-size: 12px; color: var(--accent);
+}
+
+/* ---------- 控件 ---------- */
+.row { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
+.row.mt { margin-top: 12px; }
+.row label { display: flex; align-items: center; gap: 6px; font-size: 13px; color: var(--text-dim); }
+
+input[type="number"], input[type="search"], input[type="text"] {
+ background: var(--bg-input); color: var(--text);
+ border: 1px solid var(--border); border-radius: 7px;
+ padding: 8px 11px; font: inherit; font-size: 13px;
+}
+input[type="number"] { width: 92px; }
+input[type="search"] { flex: 1; min-width: 200px; }
+input:focus { outline: none; border-color: var(--accent); }
+
+button {
+ background: var(--bg-input); color: var(--text);
+ border: 1px solid var(--border); border-radius: 7px;
+ padding: 8px 15px; cursor: pointer; font: inherit; font-size: 13px;
+ transition: background .12s, border-color .12s;
+}
+button:hover:not(:disabled) { background: var(--bg-hover); border-color: var(--accent); }
+button:disabled { opacity: .45; cursor: not-allowed; }
+button.primary { background: var(--accent-dk); border-color: var(--accent); color: #fff; font-weight: 700; }
+button.primary:hover:not(:disabled) { background: var(--accent); }
+button.danger { background: #4a2626; border-color: #7a3a3a; color: #ffc9c9; }
+button.danger:hover:not(:disabled) { background: #6a3232; }
+
+.chk { display: flex; align-items: center; gap: 6px; font-size: 13px; color: var(--text-dim); cursor: pointer; }
+input[type="checkbox"] { accent-color: var(--accent); width: 15px; height: 15px; cursor: pointer; }
+
+.toggles { display: flex; flex-direction: column; gap: 10px; }
+.toggle {
+ display: flex; align-items: center; gap: 10px;
+ background: var(--bg-input); border: 1px solid var(--border);
+ border-radius: 8px; padding: 11px 15px; cursor: pointer;
+ font-size: 14px; transition: border-color .12s;
+}
+.toggle:hover { border-color: var(--accent); }
+.toggle input { width: 17px; height: 17px; }
+
+.pill {
+ display: inline-block; background: var(--bg-input);
+ border: 1px solid var(--border); border-radius: 999px;
+ padding: 3px 12px; font-size: 12px; color: var(--text-dim);
+}
+
+/* ---------- 设置表单 ---------- */
+.form { display: flex; flex-direction: column; gap: 16px; }
+.field { display: grid; grid-template-columns: 160px 1fr; gap: 6px 14px; align-items: center; }
+.field > span { font-size: 13px; color: var(--text); }
+.field > em {
+ grid-column: 2; font-style: normal;
+ font-size: 12px; color: var(--text-dim); line-height: 1.5;
+}
+.field > em b { color: var(--warn); }
+.field input { width: 100%; max-width: 420px; }
+
+.steps { margin: 0; padding-left: 22px; font-size: 13px; color: var(--text-dim); }
+.steps li { margin: 7px 0; }
+.steps b { color: var(--text); }
+
+/* ---------- 列表 ---------- */
+.list {
+ margin-top: 12px; max-height: 320px; overflow-y: auto;
+ border: 1px solid var(--border); border-radius: 8px;
+ background: #191623;
+}
+.list.tall { max-height: 560px; }
+.empty { padding: 26px; text-align: center; color: var(--text-dim); font-size: 13px; }
+
+.item {
+ display: flex; align-items: center; gap: 12px;
+ padding: 9px 14px; border-bottom: 1px solid #241f2e;
+ font-size: 13px;
+}
+.item:last-child { border-bottom: none; }
+.item:hover { background: var(--bg-hover); }
+.item .grow { flex: 1; min-width: 0; }
+.item .name { color: var(--text); }
+.item .meta { font-size: 12px; color: var(--text-dim); }
+.item .num {
+ font-family: Consolas, monospace; font-size: 12px;
+ color: var(--text-dim); min-width: 44px;
+}
+.item .tag {
+ font-size: 11px; padding: 1px 7px; border-radius: 4px;
+ background: #2f2a3c; color: var(--text-dim); white-space: nowrap;
+}
+.item .tag.common { background: #3a2a38; color: var(--accent); }
+.item .tag.ok { background: #1f3a2c; color: var(--ok); }
+.item .tag.danger { background: #4a2626; color: #ff9b9b; }
+
+/* 分类小标题行 */
+.item.cat-head {
+ background: #221e2c; color: var(--text-dim);
+ font-size: 12px; font-weight: 700;
+ padding: 7px 14px;
+ position: sticky; top: 0; z-index: 2;
+}
+.item.cat-head.warn-head { background: #2b2420; color: var(--warn); }
+.item button { padding: 4px 11px; font-size: 12px; }
+
+/* 开关行 */
+.switch-on { color: var(--ok); font-weight: 700; }
+.switch-off { color: var(--text-dim); }
+
+/* ---------- 提示条 ---------- */
+.toast {
+ position: fixed; left: 50%; bottom: 28px; transform: translateX(-50%) translateY(20px);
+ background: #2a2634; border: 1px solid var(--accent);
+ color: var(--text); padding: 11px 22px; border-radius: 9px;
+ font-size: 13px; opacity: 0; pointer-events: none;
+ transition: opacity .2s, transform .2s; z-index: 999;
+ max-width: 80vw;
+}
+.toast.show { opacity: 1; transform: translateX(-50%) translateY(0); }
+.toast.err { border-color: var(--danger); background: #3a2222; }
+.toast.ok { border-color: var(--ok); background: #1f3329; }
+
+.trainer-stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 12px; margin-top: 16px; }
+.trainer-stat { padding: 14px; border: 1px solid var(--border); border-radius: 8px; }
+.trainer-stat .row { margin-top: 12px; flex-wrap: wrap; }
+.trainer-stat input, #trainer-items input[type=number] { width: 95px; }
+#trainer-items .item { flex-wrap: wrap; }
diff --git a/tool/使用须知.md b/tool/使用须知.md
new file mode 100644
index 0000000..7fc4155
--- /dev/null
+++ b/tool/使用须知.md
@@ -0,0 +1,76 @@
+# 使用须知(发布版,2026-10-03 修订)
+
+## 确认当前操作哪份游戏
+
+看顶部“当前连接”和存档页“实际保存目录”。身份从游戏的 Node 入口及 StorageManager 存档路径读取,不依赖网页 URL。实机网页 URL 是 chrome-extension 地址。
+
+调试端口可达不等于已连接,更不等于已进入地图。标题画面请正常读档;不要从外部直接调用 DataManager.loadGame 绕过游戏的 completeLoad 流程。
+
+## 常见提示
+
+| 提示或现象 | 处理 |
+|---|---|
+| 调试端口和面板端口相同 | 设置两个不同的端口,再启动 |
+| 端口上的游戏与目标不符/无法识别 | 关闭占用该端口的游戏,或为各实例配置不同端口;不会自动放行 |
+| 旧服务或旧配置占用面板端口 | 停止旧面板后再启动;本次升级前的旧服务需关闭原来的命令行窗口 |
+| 副本缺失/复制未完成 | 不启动残缺副本;选择新的空目录重建。不会覆盖已有目录 |
+| 地图、存档或连接已变化 | 刷新状态并重新读取本图事件/扫描角色,旧列表不能用于新地图 |
+| 莉莉娅脚下不安全 | 她保持悬浮;移动到安全地面再点“落地” |
+| 跨地图超时 | 工具会取消尚未完成的传送请求;确认游戏是否被菜单或演出占用,再重试 |
+| 备份失败 | 不会继续启动需要备份的游戏或覆盖槽位。检查目录、磁盘空间及是否仍在写档 |
+| 当前有摆拍角色,提示不推荐保存 | 读档后摆拍状态可能无法完整恢复;可自行决定继续保存,不要求先撤销 |
+
+悬浮时按跳跃仍可能一直向上运动;关重力没有取消跳跃输入。落地会检查地形,“全部还原”会直接恢复正常物理状态,使用时应处于安全位置。
+
+## 搜索和剧情
+
+发布版从游戏运行时读取全文台词,全部在内存中使用,不写剧本或缓存文件。先开面板再开游戏也会自动读取;故事表未就绪时最多尝试四次、间隔至少三秒,仍失败会显示原因,可手动重新读取。断线、换实例或重连都会使旧台词失效,重连成功后自动补齐。
+
+搜索覆盖整段台词和说话人,并显示命中句;未连接时仍可搜索地图、坐标、事件和条件。
+
+“运行过场”包含剧情指令,可能改变游戏开关和进度。分支剧情可能跳过先前的演出;前往只自动设置普通开关,不承诺还原完整历史进度。没有任何一页提供角色形象的事件,也不能凭空生成合适 NPC。
+
+## 场景还原与撤销
+
+恢复的角色是固定的摆拍对象,不再自动执行原事件、移动或参与物理碰撞。清除“已抹除”状态只是临时操作;撤销会恢复修改前的字段、原有抹除状态及方法。
+
+“被抹除”不证明这个人应该出现在当前剧情。选择哪些角色和站位仍需核对画面;不要把全部恢复后的画面当成原作的准确复现。
+
+摆拍期间自动和手动存档照常,工具不拦截游戏自身的保存。顶部提示不推荐摆拍时保存;面板保存的原有确认框会说明风险,仍可确定继续。完成后可撤销;切图或正常读档自动撤销。面板关闭或失联超过两分钟也会撤销,恢复事件原有行为。
+
+这次没有实现“保存并读回完整摆拍布局”。临时函数、UI 显示状态、剧情解释器不承诺跨存档复现。
+
+## 备份与还原
+
+- 新备份记录来源,使用唯一目录并校验每个 .rmmzsave 文件。未完成或损坏备份不列为可恢复项。
+- 旧时间戳备份仍可读取,但显示来源未知。还原时必须明确选目标,不能凭备份名称猜。
+- 还原前先退出游戏和 NW.js 游戏窗口,防止运行中的游戏把旧状态写回。
+- 还原得到所选备份中的完整存档集合;备份没有的槽位不会继续留在当前 save 目录。
+- 当前存档会先备份;原 save 目录保留为游戏目录下的 `.save-before-restore-…`,不会直接删除。
+- steam_autocloud.vdf 等非存档文件保留目标目录自己的版本,不从另一份游戏的备份覆盖。
+
+## 已知画面限制
+
+先前记录的“切换剧情分支时角色短暂抽动”,没有足够实机证据保证所有地图均已解决;本轮不把它标成已修。相同立绘的多个事件、剧情自身的移动与刷新仍可能影响演出效果。
+
+并非全部剧情均已验收。遇到问题请记录故事编号、地图、事件页、存档进度和使用的按钮。
+
+
+## 本轮剧情显示与定位修复
+
+#370 横排重复角色的原因是动画图整张显示,现已同步单帧裁切修复。升级后需重启面板和游戏,再刷新网页,才会加载新的游戏内 helper。
+
+运行无形自动/并行控制事件时,会优先保留同图的安全站位,或在目标地图寻找可落地位置。找不到安全地面会停止启动剧情并提示手动定位。运行过场在准备完成后尝试落地;普通手动传送仍默认悬浮。自动选点不等于还原原作站位。
+
+#305 的“运行过场”仍可能先播同页的 #298/#299;直接查看 #305 请用“只播台词”。
+
+
+## 角色与背包、锁定
+
+“角色与背包”页可设置并分别锁定金币、HP、SP、ZP,以及搜索、添加、移除背包物品。作用于当前操作角色,包含切换后的莉莉娅形态,不修改独立跟随的莉莉娅。金币范围 0~99999,其余不能超过当前最大值;不会改变上限。已倒下时请先在游戏里复活或读档。
+
+消耗品默认遵循携带上限,可勾选超上限(最多 99999)。移除装备会同步卸下,移除当前武器会换到另一件已有武器,至少保留一件。铃铛容量、任务及收集进度不在修改范围内。
+
+锁定立即阻止该数值的消耗和增加。读档、新游戏、面板失联超过两分钟或点“全部还原”会解除锁定;已修改的数值和背包不会回退,正常保存会把当前数值和物品写进存档。
+
+本轮同时更新了摆拍人物贴地、原图待机动画和面向主角。仅对原页要求贴地的人物小范围探地,飞行角色和固定摆件不强制落地;撤销恢复原始位置和朝向。首次升级这批摆拍修复,需要重启面板和游戏后刷新网页。
diff --git a/tool/停止面板.bat b/tool/停止面板.bat
new file mode 100644
index 0000000..bf1bd91
--- /dev/null
+++ b/tool/停止面板.bat
@@ -0,0 +1,5 @@
+@echo off
+chcp 65001 >nul
+setlocal DisableDelayedExpansion
+node "%~dp0launcher.js" --stop
+if errorlevel 1 pause
diff --git a/tool/发布清单.json b/tool/发布清单.json
new file mode 100644
index 0000000..aa26e26
--- /dev/null
+++ b/tool/发布清单.json
@@ -0,0 +1,33 @@
+{
+ "version": 1,
+ "files": [
+ "tool/启动.bat",
+ "tool/停止面板.bat",
+ "tool/launcher.js",
+ "tool/server.js",
+ "tool/lib/safety.js",
+ "tool/lib/files.js",
+ "tool/lib/processes.js",
+ "tool/lib/cdp.js",
+ "tool/lib/probe-game.js",
+ "tool/lib/ops.js",
+ "tool/lib/photo-state.js",
+ "tool/lib/trainer.js",
+ "tool/lib/story-position.js",
+ "tool/lib/data.js",
+ "tool/lib/story-cache.js",
+ "tool/web/app.js",
+ "tool/web/index.html",
+ "tool/web/style.css",
+ "tool/README.md",
+ "tool/使用须知.md",
+ "tool/发布清单.json",
+ "out/triggers.csv",
+ "unpacked/data/System.json",
+ "unpacked/data/MapInfos.json",
+ "README.md",
+ ".gitignore"
+ ],
+ "optionalRuntime": "nwjs-sdk-v0.64.1-win-x64/",
+ "note": "本包仅含工具及必要结构索引;台词从游戏运行时读取。Node.js 和 NW.js SDK 需另行安装。"
+}
diff --git a/tool/启动.bat b/tool/启动.bat
new file mode 100644
index 0000000..f21f6fb
--- /dev/null
+++ b/tool/启动.bat
@@ -0,0 +1,15 @@
+@echo off
+chcp 65001 >nul
+setlocal DisableDelayedExpansion
+where node >nul 2>&1
+if errorlevel 1 (
+ echo 找不到 Node.js。请安装 Node.js 22 或更新版本后重试。
+ pause
+ exit /b 1
+)
+node "%~dp0launcher.js"
+if errorlevel 1 (
+ pause
+ exit /b 1
+)
+exit /b 0
diff --git a/unpacked/data/MapInfos.json b/unpacked/data/MapInfos.json
new file mode 100644
index 0000000..dec44f4
--- /dev/null
+++ b/unpacked/data/MapInfos.json
@@ -0,0 +1 @@
+[null,{"id":1,"name":"MAP001","parentId":0},{"id":2,"name":"MAP002","parentId":0},{"id":3,"name":"MAP003","parentId":0},{"id":4,"name":"MAP004","parentId":0},{"id":5,"name":"MAP005","parentId":0},{"id":6,"name":"MAP006","parentId":0},{"id":7,"name":"MAP007","parentId":0},{"id":8,"name":"MAP008","parentId":0},{"id":9,"name":"MAP009","parentId":0},{"id":10,"name":"MAP010","parentId":0},{"id":11,"name":"MAP011","parentId":0},{"id":12,"name":"MAP012","parentId":0},{"id":13,"name":"MAP013","parentId":0},{"id":14,"name":"MAP014","parentId":0},{"id":15,"name":"MAP015","parentId":0},{"id":16,"name":"MAP016","parentId":0},{"id":17,"name":"MAP017","parentId":0},{"id":18,"name":"MAP018","parentId":0},{"id":19,"name":"MAP019","parentId":0},{"id":20,"name":"MAP020","parentId":0},{"id":21,"name":"MAP021","parentId":0},{"id":22,"name":"MAP022","parentId":0},{"id":23,"name":"MAP023","parentId":0},{"id":24,"name":"MAP024","parentId":0},{"id":25,"name":"MAP025","parentId":0},{"id":26,"name":"MAP026","parentId":0},{"id":27,"name":"MAP027","parentId":0},{"id":28,"name":"MAP028","parentId":0},{"id":29,"name":"MAP029","parentId":0},{"id":30,"name":"MAP030","parentId":0},{"id":31,"name":"MAP031","parentId":0},{"id":32,"name":"MAP032","parentId":0},{"id":33,"name":"MAP033","parentId":0},{"id":34,"name":"MAP034","parentId":0},{"id":35,"name":"MAP035","parentId":0},{"id":36,"name":"MAP036","parentId":0},{"id":37,"name":"MAP037","parentId":0},{"id":38,"name":"MAP038","parentId":0},{"id":39,"name":"MAP039","parentId":0},{"id":40,"name":"MAP040","parentId":0},{"id":41,"name":"MAP041","parentId":0},{"id":42,"name":"MAP042","parentId":0},{"id":43,"name":"MAP043","parentId":0},{"id":44,"name":"MAP044","parentId":0},{"id":45,"name":"MAP045","parentId":0},{"id":46,"name":"MAP046","parentId":0},{"id":47,"name":"MAP047","parentId":0},{"id":48,"name":"MAP048","parentId":0},{"id":49,"name":"MAP049","parentId":0},{"id":50,"name":"MAP050","parentId":0},{"id":51,"name":"MAP051","parentId":0},{"id":52,"name":"MAP052","parentId":0},{"id":53,"name":"MAP053","parentId":0},{"id":54,"name":"MAP054","parentId":0},{"id":55,"name":"MAP055","parentId":0},{"id":56,"name":"MAP056","parentId":0},{"id":57,"name":"MAP057","parentId":0},{"id":58,"name":"MAP058","parentId":0},{"id":59,"name":"MAP059","parentId":0},{"id":60,"name":"MAP060","parentId":0},{"id":61,"name":"MAP061","parentId":0},{"id":62,"name":"MAP062","parentId":0},{"id":63,"name":"MAP063","parentId":0},{"id":64,"name":"MAP064","parentId":0},{"id":65,"name":"MAP065","parentId":0},{"id":66,"name":"MAP066","parentId":0},{"id":67,"name":"MAP067","parentId":0},{"id":68,"name":"MAP068","parentId":0},{"id":69,"name":"MAP069","parentId":0},{"id":70,"name":"MAP070","parentId":0},{"id":71,"name":"MAP071","parentId":0},{"id":72,"name":"MAP072","parentId":0},{"id":73,"name":"MAP073","parentId":0},{"id":74,"name":"MAP074","parentId":0},{"id":75,"name":"MAP075","parentId":0},{"id":76,"name":"MAP076","parentId":0},{"id":77,"name":"MAP077","parentId":0},{"id":78,"name":"MAP078","parentId":0},{"id":79,"name":"MAP079","parentId":0},{"id":80,"name":"MAP080","parentId":0},{"id":81,"name":"MAP081","parentId":0},{"id":82,"name":"MAP082","parentId":0},{"id":83,"name":"MAP083","parentId":0},{"id":84,"name":"MAP084","parentId":0},{"id":85,"name":"MAP085","parentId":0},{"id":86,"name":"MAP086","parentId":0},{"id":87,"name":"MAP087","parentId":0},{"id":88,"name":"MAP088","parentId":0},{"id":89,"name":"MAP089","parentId":0},{"id":90,"name":"MAP090","parentId":0},{"id":91,"name":"MAP091","parentId":0},{"id":92,"name":"MAP092","parentId":0},{"id":93,"name":"MAP093","parentId":0},{"id":94,"name":"MAP094","parentId":0},{"id":95,"name":"MAP095","parentId":0},{"id":96,"name":"MAP096","parentId":0},{"id":97,"name":"MAP097","parentId":0},{"id":98,"name":"MAP098","parentId":0},{"id":99,"name":"MAP099","parentId":0},{"id":100,"name":"MAP100","parentId":0},{"id":101,"name":"MAP101","parentId":0},{"id":102,"name":"MAP102","parentId":0},{"id":103,"name":"MAP103","parentId":0},{"id":104,"name":"MAP104","parentId":0},{"id":105,"name":"MAP105","parentId":0},{"id":106,"name":"MAP106","parentId":0},{"id":107,"name":"MAP107","parentId":0},{"id":108,"name":"MAP108","parentId":0},{"id":109,"name":"MAP109","parentId":0},{"id":110,"name":"MAP110","parentId":0},{"id":111,"name":"MAP111","parentId":0},{"id":112,"name":"MAP112","parentId":0},{"id":113,"name":"MAP113","parentId":0},{"id":114,"name":"MAP114","parentId":0},{"id":115,"name":"MAP115","parentId":0},{"id":116,"name":"MAP116","parentId":0},{"id":117,"name":"MAP117","parentId":0},{"id":118,"name":"MAP118","parentId":0},{"id":119,"name":"MAP119","parentId":0},{"id":120,"name":"MAP120","parentId":0},{"id":121,"name":"MAP121","parentId":0},{"id":122,"name":"MAP122","parentId":0},{"id":123,"name":"MAP123","parentId":0},{"id":124,"name":"MAP124","parentId":0},{"id":125,"name":"MAP125","parentId":0},{"id":126,"name":"MAP126","parentId":0},{"id":127,"name":"MAP127","parentId":0},{"id":128,"name":"MAP128","parentId":0},{"id":129,"name":"MAP129","parentId":0},{"id":130,"name":"MAP130","parentId":0},{"id":131,"name":"MAP131","parentId":0},{"id":132,"name":"MAP132","parentId":0},{"id":133,"name":"MAP133","parentId":0},{"id":134,"name":"MAP134","parentId":0},{"id":135,"name":"MAP135","parentId":0},{"id":136,"name":"MAP136","parentId":0},{"id":137,"name":"MAP137","parentId":0},{"id":138,"name":"MAP138","parentId":0},{"id":139,"name":"MAP139","parentId":0},{"id":140,"name":"MAP140","parentId":0},{"id":141,"name":"MAP141","parentId":0},{"id":142,"name":"MAP142","parentId":0},{"id":143,"name":"MAP143","parentId":0},{"id":144,"name":"MAP144","parentId":0},{"id":145,"name":"MAP145","parentId":0},{"id":146,"name":"MAP146","parentId":0},{"id":147,"name":"MAP147","parentId":0},{"id":148,"name":"MAP148","parentId":0},{"id":149,"name":"MAP149","parentId":0},{"id":150,"name":"MAP150","parentId":0},{"id":151,"name":"MAP151","parentId":0},{"id":152,"name":"MAP152","parentId":0},{"id":153,"name":"MAP153","parentId":0},{"id":154,"name":"MAP154","parentId":0},{"id":155,"name":"MAP155","parentId":0},{"id":156,"name":"MAP156","parentId":0},{"id":157,"name":"MAP157","parentId":0},{"id":158,"name":"MAP158","parentId":0},{"id":159,"name":"MAP159","parentId":0},{"id":160,"name":"MAP160","parentId":0},{"id":161,"name":"MAP161","parentId":0},{"id":162,"name":"MAP162","parentId":0},{"id":163,"name":"MAP163","parentId":0},{"id":164,"name":"MAP164","parentId":0},{"id":165,"name":"MAP165","parentId":0},{"id":166,"name":"MAP166","parentId":0},{"id":167,"name":"MAP167","parentId":0},{"id":168,"name":"MAP168","parentId":0},{"id":169,"name":"MAP169","parentId":0},{"id":170,"name":"MAP170","parentId":0},{"id":171,"name":"MAP171","parentId":0},{"id":172,"name":"MAP172","parentId":0},{"id":173,"name":"MAP173","parentId":0},{"id":174,"name":"MAP174","parentId":0},{"id":175,"name":"MAP175","parentId":0},{"id":176,"name":"MAP176","parentId":0},{"id":177,"name":"MAP177","parentId":0},{"id":178,"name":"MAP178","parentId":0},{"id":179,"name":"MAP179","parentId":0},{"id":180,"name":"MAP180","parentId":0},{"id":181,"name":"MAP181","parentId":0},{"id":182,"name":"MAP182","parentId":0},{"id":183,"name":"MAP183","parentId":0},{"id":184,"name":"MAP184","parentId":0},{"id":185,"name":"MAP185","parentId":0},{"id":186,"name":"MAP186","parentId":0},{"id":187,"name":"MAP187","parentId":0},{"id":188,"name":"MAP188","parentId":0},{"id":189,"name":"MAP189","parentId":0},{"id":190,"name":"MAP190","parentId":0},{"id":191,"name":"MAP191","parentId":0},{"id":192,"name":"MAP192","parentId":0},{"id":193,"name":"MAP193","parentId":0},{"id":194,"name":"MAP194","parentId":0},{"id":195,"name":"MAP195","parentId":0},{"id":196,"name":"MAP196","parentId":0},{"id":197,"name":"MAP197","parentId":0},{"id":198,"name":"MAP198","parentId":0},{"id":199,"name":"MAP199","parentId":0},{"id":200,"name":"MAP200","parentId":0},{"id":201,"name":"MAP201","parentId":0},{"id":202,"name":"MAP202","parentId":0},{"id":203,"name":"MAP203","parentId":0},{"id":204,"name":"MAP204","parentId":0},{"id":205,"name":"MAP205","parentId":0},{"id":206,"name":"MAP206","parentId":0},{"id":207,"name":"MAP207","parentId":0},{"id":208,"name":"MAP208","parentId":0},{"id":209,"name":"MAP209","parentId":0},{"id":210,"name":"MAP210","parentId":0},{"id":211,"name":"MAP211","parentId":0},{"id":212,"name":"MAP212","parentId":0},{"id":213,"name":"MAP213","parentId":0},{"id":214,"name":"MAP214","parentId":0},{"id":215,"name":"MAP215","parentId":0},{"id":216,"name":"MAP216","parentId":0},{"id":217,"name":"MAP217","parentId":0},{"id":218,"name":"MAP218","parentId":0},{"id":219,"name":"MAP219","parentId":0},{"id":220,"name":"MAP220","parentId":0},{"id":221,"name":"MAP221","parentId":0},{"id":222,"name":"MAP222","parentId":0},{"id":223,"name":"MAP223","parentId":0},{"id":224,"name":"MAP224","parentId":0},{"id":225,"name":"MAP225","parentId":0},{"id":226,"name":"MAP226","parentId":0},{"id":227,"name":"MAP227","parentId":0},{"id":228,"name":"MAP228","parentId":0},{"id":229,"name":"MAP229","parentId":0},{"id":230,"name":"MAP230","parentId":0},{"id":231,"name":"MAP231","parentId":0},{"id":232,"name":"MAP232","parentId":0},{"id":233,"name":"MAP233","parentId":0},{"id":234,"name":"MAP234","parentId":0},{"id":235,"name":"MAP235","parentId":0},{"id":236,"name":"MAP236","parentId":0},{"id":237,"name":"MAP237","parentId":0},{"id":238,"name":"MAP238","parentId":0},{"id":239,"name":"MAP239","parentId":0},{"id":240,"name":"MAP240","parentId":0},{"id":241,"name":"MAP241","parentId":0},{"id":242,"name":"MAP242","parentId":0},{"id":243,"name":"MAP243","parentId":0},{"id":244,"name":"MAP244","parentId":0},{"id":245,"name":"MAP245","parentId":0},{"id":246,"name":"MAP246","parentId":0},{"id":247,"name":"MAP247","parentId":0},{"id":248,"name":"MAP248","parentId":0},{"id":249,"name":"MAP249","parentId":0},{"id":250,"name":"MAP250","parentId":0},{"id":251,"name":"MAP251","parentId":0},{"id":252,"name":"MAP252","parentId":0},{"id":253,"name":"MAP253","parentId":0},{"id":254,"name":"MAP254","parentId":0},{"id":255,"name":"MAP255","parentId":0},{"id":256,"name":"MAP256","parentId":0},{"id":257,"name":"MAP257","parentId":0},{"id":258,"name":"MAP258","parentId":0},{"id":259,"name":"MAP259","parentId":0},{"id":260,"name":"MAP260","parentId":0},{"id":261,"name":"MAP261","parentId":0},{"id":262,"name":"MAP262","parentId":0},{"id":263,"name":"MAP263","parentId":0},{"id":264,"name":"MAP264","parentId":0},{"id":265,"name":"MAP265","parentId":0},{"id":266,"name":"MAP266","parentId":0},{"id":267,"name":"MAP267","parentId":0},{"id":268,"name":"MAP268","parentId":0},{"id":269,"name":"MAP269","parentId":0},{"id":270,"name":"MAP270","parentId":0},{"id":271,"name":"MAP271","parentId":0},{"id":272,"name":"MAP272","parentId":0},{"id":273,"name":"MAP273","parentId":0},{"id":274,"name":"MAP274","parentId":0},{"id":275,"name":"MAP275","parentId":0},{"id":276,"name":"MAP276","parentId":0},{"id":277,"name":"MAP277","parentId":0},{"id":278,"name":"MAP278","parentId":0},{"id":279,"name":"MAP279","parentId":0},{"id":280,"name":"MAP280","parentId":0},{"id":281,"name":"MAP281","parentId":0},{"id":282,"name":"MAP282","parentId":0},{"id":283,"name":"MAP283","parentId":0},{"id":284,"name":"MAP284","parentId":0},{"id":285,"name":"MAP285","parentId":0},{"id":286,"name":"MAP286","parentId":0},{"id":287,"name":"MAP287","parentId":0},{"id":288,"name":"MAP288","parentId":0},{"id":289,"name":"MAP289","parentId":0},{"id":290,"name":"MAP290","parentId":0},{"id":291,"name":"MAP291","parentId":0},{"id":292,"name":"MAP292","parentId":0},{"id":293,"name":"MAP293","parentId":0},{"id":294,"name":"MAP294","parentId":0},{"id":295,"name":"MAP295","parentId":0},{"id":296,"name":"MAP296","parentId":0},{"id":297,"name":"MAP297","parentId":0},{"id":298,"name":"MAP298","parentId":0},{"id":299,"name":"MAP299","parentId":0},{"id":300,"name":"MAP300","parentId":0},{"id":301,"name":"MAP301","parentId":0},{"id":302,"name":"MAP302","parentId":0},{"id":303,"name":"MAP303","parentId":0},{"id":304,"name":"MAP304","parentId":0},{"id":305,"name":"MAP305","parentId":0},{"id":306,"name":"MAP306","parentId":0},{"id":307,"name":"MAP307","parentId":0},{"id":308,"name":"MAP308","parentId":0},{"id":309,"name":"MAP309","parentId":0},{"id":310,"name":"MAP310","parentId":0},{"id":311,"name":"MAP311","parentId":0},{"id":312,"name":"MAP312","parentId":0},{"id":313,"name":"MAP313","parentId":0},{"id":314,"name":"MAP314","parentId":0},{"id":315,"name":"MAP315","parentId":0},{"id":316,"name":"MAP316","parentId":0},{"id":317,"name":"MAP317","parentId":0},{"id":318,"name":"MAP318","parentId":0},{"id":319,"name":"MAP319","parentId":0},{"id":320,"name":"MAP320","parentId":0},{"id":321,"name":"MAP321","parentId":0},{"id":322,"name":"MAP322","parentId":0},{"id":323,"name":"MAP323","parentId":0},{"id":324,"name":"MAP324","parentId":0},{"id":325,"name":"MAP325","parentId":0},{"id":326,"name":"MAP326","parentId":0},{"id":327,"name":"MAP327","parentId":0},{"id":328,"name":"MAP328","parentId":0},{"id":329,"name":"MAP329","parentId":0},{"id":330,"name":"MAP330","parentId":0},{"id":331,"name":"MAP331","parentId":0},{"id":332,"name":"MAP332","parentId":0},{"id":333,"name":"MAP333","parentId":0},{"id":334,"name":"MAP334","parentId":0},{"id":335,"name":"MAP335","parentId":0},{"id":336,"name":"MAP336","parentId":0},{"id":337,"name":"MAP337","parentId":0},{"id":338,"name":"MAP338","parentId":0},{"id":339,"name":"MAP339","parentId":0},{"id":340,"name":"MAP340","parentId":0},{"id":341,"name":"MAP341","parentId":0},{"id":342,"name":"MAP342","parentId":0},{"id":343,"name":"MAP343","parentId":0},{"id":344,"name":"MAP344","parentId":0},{"id":345,"name":"MAP345","parentId":0},{"id":346,"name":"MAP346","parentId":0},{"id":347,"name":"MAP347","parentId":0},{"id":348,"name":"MAP348","parentId":0},{"id":349,"name":"MAP349","parentId":0},{"id":350,"name":"MAP350","parentId":0},{"id":351,"name":"MAP351","parentId":0},{"id":352,"name":"MAP352","parentId":0},{"id":353,"name":"MAP353","parentId":0},{"id":354,"name":"MAP354","parentId":0},{"id":355,"name":"MAP355","parentId":0},{"id":356,"name":"MAP356","parentId":0},{"id":357,"name":"MAP357","parentId":0},{"id":358,"name":"MAP358","parentId":0},{"id":359,"name":"MAP359","parentId":0},{"id":360,"name":"MAP360","parentId":0},{"id":361,"name":"MAP361","parentId":0},{"id":362,"name":"MAP362","parentId":0},{"id":363,"name":"MAP363","parentId":0},{"id":364,"name":"MAP364","parentId":0},{"id":365,"name":"MAP365","parentId":0},{"id":366,"name":"MAP366","parentId":0},{"id":367,"name":"MAP367","parentId":0},{"id":368,"name":"MAP368","parentId":0},{"id":369,"name":"MAP369","parentId":0},{"id":370,"name":"MAP370","parentId":0},{"id":371,"name":"MAP371","parentId":0},{"id":372,"name":"MAP372","parentId":0},{"id":373,"name":"MAP373","parentId":0},{"id":374,"name":"MAP374","parentId":0},{"id":375,"name":"MAP375","parentId":0},{"id":376,"name":"MAP376","parentId":0},{"id":377,"name":"MAP377","parentId":0},{"id":378,"name":"MAP378","parentId":0},{"id":379,"name":"MAP379","parentId":0},{"id":380,"name":"MAP380","parentId":0},{"id":381,"name":"MAP381","parentId":0},{"id":382,"name":"MAP382","parentId":0},{"id":383,"name":"MAP383","parentId":0},{"id":384,"name":"MAP384","parentId":0},{"id":385,"name":"MAP385","parentId":0},{"id":386,"name":"MAP386","parentId":0},{"id":387,"name":"MAP387","parentId":0},{"id":388,"name":"MAP388","parentId":0},{"id":389,"name":"MAP389","parentId":0},{"id":390,"name":"MAP390","parentId":0},{"id":391,"name":"MAP391","parentId":0},{"id":392,"name":"MAP392","parentId":0},{"id":393,"name":"MAP393","parentId":0}]
diff --git a/unpacked/data/System.json b/unpacked/data/System.json
new file mode 100644
index 0000000..cac59b1
--- /dev/null
+++ b/unpacked/data/System.json
@@ -0,0 +1 @@
+{"switches":["","开场白完成","小镇居民B已对话","小镇开场完成","小镇居民气球已启用","夏洛特对话气球暂停","梅卡对话气球暂停","露茜亚对话气球暂停","小镇居民DE对话气球暂停","小镇居民DE已对白","米娅对话气球暂停","老板娘初始已对话","知情魔物出现","初遇幽暗森林","系统插件判断","带任务进莉莉亚家","已吐槽喵露露没钱","尝试偷老板娘的钱","老板娘不在商店","大炮操作引导","第一天NPC位置移动2","矿洞已与作者交谈","困难开关","山洞防玩家走岔路提示","玩家镜头释放检测","猫牛剪毛任务完成","山洞猫牛已对白","橘花猫boss战开始","橘花猫boss战胜利","山洞任务开启","第一天夜晚","作者气泡气球暂停","已与作者主动交谈","已把作者推下悬崖1","已把作者推下悬崖2","第一天夜晚门锁","夜晚开关","第一天夜晚莉莉亚回家","喵露露是否偷莉莉亚钱","从天台进入学校外景","第二天开启","橘花猫对话气球暂停","菜单引导(已废弃)","简单开关","作者位置移动","24地图作者消失","8地图作者消失","第一天NPC位置变化1","森林依铃已对话","依铃回到小镇","森林夏洛克已对话","夏洛克对话后回到小镇","P1-米娅已交谈","P1-露茜亚已交谈","P1-梅卡已交谈","P1-夏洛克吐槽近路","莉莉亚boss战-开始","莉莉亚boss战-胜利","莉莉亚boss战-提示","老板娘在校长室","老板娘气球对话暂停","水底莉莉亚胜利","水底莉莉亚开启","前往天空区域","遗物石头光线","水底剧情莉莉亚出现","莉莉亚气球对话暂停","天空区域开启","大蘑菇出现","水底抓鱼已对白","抓鱼任务莉莉亚在场","依铃蕾铃从水底回家","水底关密码锁打开","水底露茜亚已闲聊","前往天空与菓对话","和莉莉亚调查家熊","一起调查莉莉亚家熊","天空莉莉亚分头行动","老板娘BOSS战准备","玩家经过天空存档点","第二天夜晚","大招引导","二段跳引导完成","冲刺引导完成","宣传片导播录制","老板娘BOSS战开始","老板娘BOSS战胜利","老板娘战后对话完","烤鱼派对开始","烤鱼派对结束","结界入口开启","恶魔墙胜利","恶魔墙倒塌演出","隐藏门","莉莉亚走丢","喵朵露出现","熊兔变成普通事件","喵朵露BOSS战胜利","喵朵露boss并行开始","莉珑撸猫完成","触发莉珑撸猫","莉珑撸猫","第三天开启","封印结界调查完毕","第二天校长对话完","结界入口关闭","第三天夏洛特米娅对话","第三天莉莉亚家敲门","第三天露茜亚梅卡对话","第三天与菓对话","莲boss战准备","莲boss战胜利","恶魔城莉莉亚入队","莲后露茜亚梅卡已对话","猫牛放风","视角引导","恶魔城莉莉亚离队","德古拉开战","德古拉胜利","第三天夜晚","德古拉交谈完","管家来到大厅","管家去时计塔","喵露露折返","德古拉准备开启结界","德古拉第二次交谈完","显示br难度选择","br老板娘地形","检测第一天NPC位置是否移动","检测第一天NPC位置是否移动2","图书馆莉莉亚被推下去","烤鱼结束敲莉莉亚家门","第四天开启","第三天夜晚和老板娘对白","老板娘br是否是晚上","下砸获得","下砸引导完成","猫翻滚获得","猫翻滚引导完成","爬墙获得","滑板获得","二连击已获得","二连击显示引导","二连击已引导","蓄力斩已获得","蓄力斩已引导","物理引导完成","初见天空区域","护符装备是否已引导","喵可莉梦境剧情","喵露露往右走N帧","缓降已获得","缓降引导完成","初遇结界2","第二次结界入口关闭","心魔莉莉亚开战","心魔莉莉亚胜利","结界12连接门打开","头槌获得","头槌引导","第二次离开结界","暂时不要播放家里的BGM","老板娘封存游戏机图层","第四天白天和老板娘对白","雪原莉莉亚入队","雪原任务开启","第四天老板娘莉莉亚闲聊","小鱼干仇恨是否是喵露露","心渊BOSS战开始","心渊BOSS战胜利","出295地图隐藏莉莉亚","雪原莉莉亚救出","莉莉亚对话气球暂停","BOSS单个挑战模式","BOSS单个挑战模式选择取消","br是否带莉莉亚进入","恶魔墙喵露露逃走","已触发喵露露逃走","喵露露小游戏胜利","莉莉亚小游戏胜利","是否符合小游戏条件","小游戏类型1失败已对白","小游戏类型1胜利对白A","小游戏类型1胜利对白B","心渊家左侧壁炉点火","心渊家剧情转场","心渊家剧情完毕","Noise滤镜动态","占卜球特效","莉莉亚是否已占卜","校长出现在雪原","首次进入星之海","喵露露知情莉莉亚逃走","宇宙传送点激活","蓄力斩石板引导","自动存档点暂时消失","倒立点暂时消失","缇娅幻影出现","缇娅幻影胜利","小地图引导","萝卜引导已触发","小游戏2失败已对白1","捉老鼠是否是掉坑失败","小游戏2失败已对白2","小游戏2胜利已对白","方块是否是堆满失败","小游戏3失败已对白1","小游戏3失败已对白2","小游戏3胜利已对白","捉老鼠规则介绍完","方块规则介绍完","莉莉亚出结界入口演出","缇亚本体BOSS胜利","兔玩偶拿走","缇娅BOSS莉莉亚出现","缇娅BOSS胜利对话完","缇娅选项对白A","缇娅选项对白B","缇娅选项对白C","缇娅选项对白D","缇娅选项对白完成","黑雾区心渊对话完","黑雾区心渊等猫粮","大眼被打败","小眼被打败","莉莉亚负伤传送回家","传送魔法失效","莉莉亚的猫币","偷钱还了","触发莉莉亚隐藏-弃用","屏幕淡出","莉伊触发二阶段","莉伊房间地形隐藏","莉伊拿兔子并行","兔玩偶半眼","兔玩偶全眼","熊玩偶无眼","莉伊BOSS战开始","大兔子玩偶阴影","喵可莉结局演出完","兔玩偶锁链全部破坏","喵可莉三场景演出完","出流星雨","莉伊被喵露露消灭","莉伊CG粒子特效演出","魔女BOSS战开始","喵可莉最终对白演出","魔女被击败","魔女被击败后樱出现","容器重建完毕","演出莉伊阻止校长","喵露露被封印","莉伊房间黑化","结局2喵可莉出现","后日谈","莉莉亚爬去魔女","依玲和蕾铃后日谈对话","露茜亚梅卡后日谈对话","夏洛特米娅后日谈对话","花屏特效","结局演出莉伊消除","喵露露向右移动并行","莉莉亚尸体清除","莉莉亚后日谈","结局1缇娅出现","莉伊唤醒","场景黑化笑脸演出1","场景黑化笑脸演出2","场景黑化笑脸演出3","莉莉亚尸体","DEMO版本","获得樱的钥匙","非中文版本翻译暂缺","喵可莉摸猫","莉莉亚出现在缇娅门口","4399看广告拿东西","莉莉亚已经出现在缇娅门口","触发校长在璃音门口","触发校长在裁缝门口","校长移动到裁缝店外","校长BOSS胜利","校长BOSS胜利后传送走","新结局后日谈","触发喵露露推涅普","触发偷看心渊洗澡","偷看心渊洗澡时莉莉亚在","莉伊走进树林中","莉伊被叫醒","莉伊没被叫醒","莲和缇娅对话","莲和缇娅对话完","校长BOSS后猫头出现","樱和璃音回忆演出","校长BOSS后菓酱出现","校长BOSS后缇娅出现","新后日谈橘花猫出现","新后日谈莉莉亚睡着","获得金丝草","新后日谈与莲对话完","新后日谈开传送点","重新开启星之海传送","莲和校长对话已对话","新后日谈缇娅对话完成","和校长对话完修熊","新后日谈夜晚","莉伊进门缝眼睛","新后日谈第二天","新后日谈和小心心对话","新后日谈和德古拉对话","正在泡温泉","新后日谈第二天夜晚","新后日谈二晚樱对话","二战魔女开始","喵露露带莉莉亚回去","喵露露带莉莉亚折返","二战魔女登场","二战魔女二阶段","二战魔女三阶段","二战魔女滤镜特效","二战魔女一阶段结束演出","莉莉亚倒地","二战魔女二阶前半","二战魔女莉伊出现","二战魔女轰击演出","猫头并行走路","猫头出现魔女房","魔女幻觉花屏特效","猫头遇害","二战魔女三阶演出","新结局喵可莉出现","二战魔女璃音回忆演出","喵可莉幻象","喵可莉幻象-璃音","璃音最终登场","缇娅靠近璃音并行","魔女被推下湖底","新后日谈喵可莉变猫","看完喵可莉质问缇娅","喵可莉准备变猫","新后日谈剧情完结","璃音帮助莉伊走出禁锢","樱校长战斗中剧情对白","樱校长战斗中剧情对白2","二战魔女特殊击杀分支","二战魔女二阶后半","二战魔女莉莉亚阶段","二战魔女心渊菓酱打斗","二阶魔女莉莉亚进锁链","判断日本语(1.10d版本)","显示传送回家的引导","","","","","","","","","","",""],"variables":["","玩家X坐标","玩家Y坐标","随机数","最后完成的主线任务ID","临时变量","莉莉亚气球对话期数","传送地图ID","传送X坐标","传送Y坐标","屏幕中心X","屏幕中心Y","当前商店角色","cg移动目标X","cg移动目标Y","br当前bossId","地图ID","cg初始X","cg初始Y","莲对话进度","玩家zp","小游戏类型","心渊家对话","喵露露往前走帧数","缇娅对话进度","随机数X","随机数Y","结局类型","后日谈菓酱对话","临时变量2","看广告给硬币计数","看广告给猫币计数","看广告给猫棒计数","看广告给猫板计数","新后日谈菓酱对话","新后日谈菓酱对话2","新后日谈菓酱对话3","莉莉亚-魔女大招阶段","临时变量3","临时变量4",""]}