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

This commit is contained in:
Kitasami_Nya
2026-10-24 01:37:15 +08:00
parent 8f6ac4ff04
commit 3823e38b07
39 changed files with 2918 additions and 194 deletions
+3
View File
@@ -35,3 +35,6 @@
Thumbs.db Thumbs.db
desktop.ini desktop.ini
.DS_Store .DS_Store
# 可重建的发布产物。
/dist/
+16
View File
@@ -0,0 +1,16 @@
# 变更记录
## 2026-10-24 · 发布版功能同步
- 悬停 v5:支持空中连续点跳、按下下降、技能主动位移;保留地形碰撞和原版出口换图,支持 1/4/16 像素长按微调。
- 背包:完整分类、筛选批量操作、装备/卸下、铃铛容量调整;图标在游戏运行时内存生成,批量失败回退并保留至少一件武器。
- 战斗:玩家与莉莉娅的输出倍率、受击倍率和有效命中秒杀,保留原作免疫与治疗规则。
- 百科:物品、来源、商店和收集状态分开显示;分块传送先检查角色身体与地形,目标受阻时寻找邻近分块/同图备用位置。
- 地图:原版分块布局、区域/页选择、缩放拖动、状态筛选、三态图标、明确按钮传送;金币使用双圈猫爪矢量图标。
- 按发布范围移除快照与 TAS 整页、专属接口、模块及打包依赖;保留顶部实时状态、战斗倍率及常规存档备份/还原。发布版不提供战斗中途记录与回放。
- 保留发布版运行时全文台词 C 方案:内存缓存、限次重试、断线失效和重连读取。未引入本地剧本、原始地图、素材或私人配置。
- 增加独立目录配置模板、自动测试、白名单打包脚本和干净发布目录启动测试。旧依赖原始游戏文件的测试改为公开结构索引与人工运行时夹具。
当前游戏版本的既有只读验证统计为:48 项背包物品,百科含金币共 49 项、426 条来源、24 处商店,覆盖 411 个官方收集点;地图包含 16 区域、12 页、996 分块。不同游戏版本或运行时尚未就绪时,以面板实际读取结果为准;这些数字不代表发布包附带游戏数据。
本轮完整自动测试 72 项通过,另有 JavaScript 语法检查和干净发布目录启动验证。独立面板连接测试游戏,只读确认运行时台词已加载、背包 48 项、地图 996 格及官方收集点 411/411;独立浏览器验证地图缩放/拖动、金币图标、菜单拦截和窄窗口显示。浏览器传送请求全部拦截,没有移动角色、读档或重放真实游戏。既有剧情摆拍并非全部地图均已验收;动态掉落和部分分支奖励不在确定收集索引内。
+91 -27
View File
@@ -1,38 +1,102 @@
# 咸鱼喵喵截图&修改辅助工具:简单使用方法 # 咸鱼喵喵全能助手
一个在浏览器里操作的本地辅助工具,支持剧情全文搜索、传送、播放对白、角色摆拍,以及角色数值和背包调整,方便回看剧情和截图。 **修改器 · 全收集地图 · 物品百科 · 剧情回看 · 截图摆拍**
需要 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. 将下载解压后、包含 `tool`、`out`、`unpacked` 的整个工具文件夹放进游戏安装目录,与游戏的 `package.json` 同级。外层文件夹名称不限,保留下载后的名字即可,无需另建 `aitemp` 文件夹。可从 Steam 的“管理 → 浏览本地文件”找到游戏目录。 支持 Windows 版《咸鱼喵喵》(Nyaruru)。推荐先用面板创建一份独立游戏副本,在副本里截图和尝试各种玩法。
2. 解压 NW.js SDK,把 `nwjs-sdk-v0.64.1-win-x64` 文件夹放进上述工具文件夹。保留内部目录结构,别多套一层文件夹:
## 看地图,补收集
世界地图按原作的房间和通道排列,能看见完整区域布局,也能定位自己现在在哪。拖动平移、滚轮缩放,放大后直接看每个分块里的收集物。
- **找漏项**:未收集显示彩色图标,拿过的变灰并带 ✓,暂时无法判断的显示 ?;可以只看未收集,或按物品类型筛选。
- **查位置**:点一个格子或图标,右侧显示该分块的物品、来源和获取条件。
- **去附近**:选好分块,再点「传送到此分块」。工具会找附近能容纳角色的位置,目标被挡住时改用备用位置,并显示到达提示。
当前版本整理了 **16 个区域、12 个地图页、996 个分块**。地图是房间布局示意图,查看完整地图不会改变游戏自己的探索记录。
![世界地图:区域布局、收集图标与分块详情](docs/images/world-map.png)
收集百科适合反过来查:先搜物品,再看用途、获取位置和收集进度。例如,想找某件配饰,就搜名字、展开来源,再前往对应分块附近。
百科整理了 **49 种物品、426 条地图来源和 24 处商店入口**,覆盖当前版本 **411 个地图收集点**。它会分别显示「背包现在有多少」与「地图上是否拿过」,吃掉的食物不会因此变回未收集。
![收集百科:物品用途、持有数量与获取地点](docs/images/encyclopedia.png)
## 改背包,试搭配
「角色与背包」页列出 **48 项物品,包括 26 种配饰**,带游戏图标和效果说明。可以按名称搜索、按类别筛选,逐项添加、删除、装备或卸下,也可以批量获得或清除筛选结果。
想快速试一套配饰,就筛选「配饰」,找到想要的几件并装备;想补给,则筛选食物后批量获得。配饰容量跟随铃铛数量,面板会显示当前容量。
同一页还能调整:
- **金币、生命(HP)、体力(SP)、能量(ZP)**:直接设值,或分别锁定。
- **伤害与受击倍率**:1 倍为正常,可分别调整。
- **一击秒杀**:有效攻击命中时生效,剧情里的无敌阶段仍按游戏规则处理。
顶部还有常驻的「无敌」快捷开关,切换页面时也能随手开启或关闭。
![角色与背包:物品图标、效果说明与装备操作](docs/images/inventory.png)
## 回看剧情,慢慢取景
「对话剧情」支持按台词、说话人、地图或故事编号搜索。连接游戏后会自动读取全文台词,断开连接后清空,再次连接时重新读取。
找到想看的片段后,可以选择:
| 操作 | 适合什么时候用 |
| --- | --- |
| 前往 | 去剧情发生的位置,自己走过去触发 |
| 只播台词 | 想快速重看某一段对白或截对话框 |
| 运行过场 | 想连同人物站位和动作一起回看;会从所在事件页开头播放 |
| 临时摆拍 | 场景里缺了角色时,让角色临时出现,拍完点「撤销场景还原」 |
取景时不用一直和跳跃时机较劲:
- **悬停**:停在空中,每次点跳都能继续上升;按住游戏「下」键下降,松开停住。保留技能位移、地形碰撞和正常出口换图。
- **穿墙**:需要穿过障碍时单独开启;回到空处后关闭,再选择落地。
- **像素微调**:按 1、4 或 16 像素调整位置,方向按钮支持长按。
- **隐藏画面元素**:随时切换对话框、立绘、游戏界面和莉莉娅的显示,让构图更干净。
## 第一次使用
准备好已安装的游戏,再安装或下载这两个运行环境:
- [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. **找到游戏目录。** 在 Steam 中右键游戏 → 管理 → 浏览本地文件。
2. **放入工具和 SDK。** 将下载解压后包含 `tool`、`out`、`unpacked` 的整个文件夹放进游戏目录,外层名字随意;再把完整的 `nwjs-sdk-v0.64.1-win-x64` 文件夹放到工具文件夹里。目录像这样:
```text ```text
Nyaruru(游戏目录) 游戏目录/
├─ package.json ├─ package.json
└─Nyaruru_modifier(名字不限) └─ Nyaruru_modifier/ ← 工具文件夹,名字不限
├─ README.md ├─ README.md
├─.gitignore ├─ nwjs-sdk-v0.64.1-win-x64/
├─nwjs-sdk-v0.64.1-win-x64 ← 另行下载并解压 │ ├─ nw.exe
│ └─nw.exe │ └─ …… ← SDK 的其他文件也要保留
├─out ├─ out/
│ └─triggers.csv ├─ unpacked/
├─tool └─ tool/
│ ├─启动.bat ├─ 启动.bat
│ ├─停止面板.bat └─ 停止面板.bat
│ ├─lib
│ └─web
└─unpacked
└─data
├─System.json
└─MapInfos.json
``` ```
(仅列出主要文件;NW.js SDK 文件夹需完整保留。)
3. 先在原游戏中正常保存一次并退出。双击 `tool/启动.bat`,首次选择 **1:只启动面板**,在“存档与沙盒”页创建游戏副本。副本需要额外磁盘空间,默认放在游戏所在盘的 `Nyaruru_shots` 文件夹。 3. **先创建游戏副本。** 在原游戏中正常保存并退出。双击 `tool/启动.bat`,首次选择 **1:只启动面板**,到「存档与沙盒」页创建副本。这里的「沙盒」就是一份单独运行的游戏副本,会额外占用磁盘空间。
4. 副本创建完成后,再次双击 `启动.bat`,工具会备份存档并启动副本游戏。在游戏里正常读档进入地图,确认面板顶部连接的是沙盒。 4. **启动副本并读档。** 创建完成后再次双击 `启动.bat`,工具会备份存档并启动副本游戏。在游戏里正常读档进入地图,查看面板顶部的「当前连接」,确认连的是沙盒副本。
5. 在面板搜索台词或故事编号,按需要选择“前往”“运行过场”或“只播台词”;摆拍后可点“撤销场景还原”。全文台词在连接游戏后自动读取,断开连接后清空。 5. **打开想用的页面。** 查漏项用「地图」或「收集百科」,改物品用「角色与背包」,找台词用「对话剧情」。
面板默认地址为 `http://127.0.0.1:8787`。用完双击 `tool/停止面板.bat`,游戏窗口另行关闭。升级前先退出旧面板和游戏。 面板默认地址:**http://127.0.0.1:8787**。用完双击 `tool/停止面板.bat`,游戏窗口另外关闭。升级时先退出旧面板和游戏,再替换工具文件。
建议始终在沙盒里使用:剧情操作可能改变进度,自动存档和手动保存照常生效。“全部还原”会解除临时效果和锁定,但不会回退已经改过的数值、背包或剧情进度;摆拍状态也不保证能通过读档完整恢复。详细说明见包内 `tool/使用须知.md`。 ## 用之前记住这几件事
- **在副本里试更省心。** 改过的数值、物品和剧情进度可能随正常存档保留;「全部还原」会解除辅助和锁定,已经改过的内容要靠存档备份恢复。备份和还原都在「存档与沙盒」页。
- **传送是帮你到附近。** 物品仍需自己取得,剧情条件和商店库存仍跟随游戏进度;地图传送前先退出游戏菜单,回到地图画面。
- **过场可能推动剧情。** 只想看对白就用「只播台词」;临时摆拍结束后先撤销,再继续游玩或保存。
遇到具体操作问题,可以查看 [使用须知](tool/使用须知.md);想了解配置和源码,可看 [开发文档](tool/开发文档.md) 与 [更新记录](CHANGELOG.md)。上面的数量对应当前整理的游戏版本,其他版本以面板实际读取为准。
Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

+66 -2
View File
@@ -1,6 +1,8 @@
# 咸鱼喵喵截图辅助工具:简单使用方法 # 咸鱼喵喵全能助手 · 使用说明
一个在浏览器里操作的本地辅助工具,支持剧情全文搜索、传送、播放对白、角色摆拍,以及角色数值和背包调整,方便回看剧情和截图。 一个在浏览器里操作的本地辅助工具,支持剧情全文搜索、传送、摆拍、悬停、角色与背包管理、战斗倍率、收集百科和世界地图,方便回看剧情、截图和探索。
发布版更新:2026-10-24。
需要 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。 需要 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。
@@ -36,3 +38,65 @@
面板默认地址为 `http://127.0.0.1:8787`。用完双击 `tool/停止面板.bat`,游戏窗口另行关闭。升级前先退出旧面板和游戏。 面板默认地址为 `http://127.0.0.1:8787`。用完双击 `tool/停止面板.bat`,游戏窗口另行关闭。升级前先退出旧面板和游戏。
建议始终在沙盒里使用:剧情操作可能改变进度,自动存档和手动保存照常生效。“全部还原”会解除临时效果和锁定,但不会回退已经改过的数值、背包或剧情进度;摆拍状态也不保证能通过读档完整恢复。详细说明见包内 `tool/使用须知.md`。 建议始终在沙盒里使用:剧情操作可能改变进度,自动存档和手动保存照常生效。“全部还原”会解除临时效果和锁定,但不会回退已经改过的数值、背包或剧情进度;摆拍状态也不保证能通过读档完整恢复。详细说明见包内 `tool/使用须知.md`。
## 独立目录安装与配置
默认按上面的步骤放进游戏目录,无需手工配置。希望工具放在其他目录时,将 tool/config.example.json 复制为 tool/config.json,再把 gameDir 和 sandboxDir 改成实际路径。示例中的 C:/Games/Nyaruru 只是占位路径,不能直接用于启动。
原版和副本路径必须完全分开,不能互相包含;两个端口也不能相同。JSON 路径可以使用正斜杠(例如 C:/Games/Nyaruru);修改游戏目录或面板端口后重启工具。不要公开自己的 config.json、存档、日志或 backups。
## 悬停与取景
「悬停」保持高度,空中每次点跳都能继续上升,不受二段跳次数限制;上升仍会减速到顶。按住游戏的「下」键下降,松开停住。原有技能触发条件不变,下冲等技能的主动位移保留,技能结束后恢复悬停。
悬停状态独立于原版技能的临时重力标记,技能或受击结束时不会把它关闭;面板刷新读取实际辅助开关,不把技能自带的短暂浮空显示成悬停。
普通悬停保留原版碰撞,下降遇到实心地面会停住;穿墙开启后才忽略四个方向的碰撞。原作允许下穿的平台仍遵循原版规则。穿墙自动启用悬停;关闭穿墙会检查地形重叠,已经卡在地里时先开穿墙飞到空处,再关闭。
悬停支持通过原版地图出口换图,并保留悬停和穿墙设置;只有没有可用出口的边界会挡住。穿墙也不会凭空建立地图连接。读档仍会解除旧辅助。点击「落地」会检查脚下安全性,再恢复重力和碰撞。
方向按钮从角色真实位置按 1/4/16 像素微调,支持长按。「全部还原」解除物理辅助、摆拍、数值锁定和战斗倍率,不回退已经修改的背包、数值或剧情进度。
## 角色、背包与战斗
背包列出全部 48 项,包括 26 种配饰、食物、咸鱼武器、鱼眼宝石、材料和铃铛。图标由当前游戏的图标组件在内存中生成,不导出素材文件;效果文字为 16px。
支持单项添加、删除、装备、卸下,以及对筛选结果批量获得或清除。批量操作先验证全部参数,执行失败会回退;至少保留一件武器。铃铛决定配饰容量,减少容量会卸下多出的配饰。默认保留地图铃铛;勾选允许减少后可清零,对应收集点按地图编号从后往前重置。其他背包操作不会改变地图收集记录。
金币、HP、SP、ZP 支持设值和独立锁定。伤害与受击倍率范围为 0~1000,1 为正常;一击秒杀在玩家或莉莉娅的有效攻击命中敌人时生效,不突破剧情无敌,不把治疗变成伤害。受击 0 倍仍可能受到击退。
数值锁定和战斗设置只在本次游戏中生效,读档会清理。面板失联两分钟也会解除这些辅助。
## 收集百科与剧情
百科显示 49 种物品、426 条地图来源、24 处商店入口,覆盖当前版本全部 411 个地图收集点(含 32 个猫币堆)。按区域和状态筛选,可前往来源分块附近。当前持有与地图收集分别显示,吃掉食物不等于没拿过。
「前往分块边缘」根据事件所属的原版小地图格子定位,优先找分块边缘附近、角色身体不碰墙且脚下没有伤害地形的落脚空处;没有地面时可使用经过检查的空中位置。这个分块被堵住时继续找邻近分块,再找同一张地图的其他空处,并在提示中说明改了位置。到达后保持悬停、恢复碰撞,不自动开穿墙,也不直接传到物品旁。跨图载入后再次检查;只有整图都找不到可容纳角色的落点时才失败,必要时尝试返回原地图与原位置。
事件坐标可能是发奖控制点,不一定就是道具贴图的位置。工具不主动更改获取条件或收集开关,到达分块后自行前往获取。地点按钮使用固定编号,刷新百科不会再因对象引用变化而失效。
商店为候选入口,库存受任务进度影响。普通敌人掉落与脚本动态奖励尚未纳入来源索引;无法判断的分支奖励标为未知。拾取、读档或修改背包后刷新百科。
对话剧情保留前往、只播台词、运行过场和临时摆拍。分支剧情可能跳过先前摆位;前往只处理普通开关。发布版连接游戏后自动从运行时读取全文台词,全部只保存在内存,断线清空、重连补齐;未连接时仍能搜索结构索引。读取失败可点「重新读取台词」。
## 世界地图
面板「地图」页按游戏原版房间与通道布局绘制示意图,支持选择大区域(例如喵喵小镇、封印结界)和地图页。当前读取 16 个区域、12 个地图页、996 个分块;未探索和隐藏分块也会显示,但查看本页不会解锁游戏里的探索记录。
地图标出 426 条已知来源,覆盖 411/411 个官方收集点。未收集物显示正常图标,已收集变灰并标 ✓,无法判定的标 ?;可以筛选类型或只看未收集。鼠标悬停查看名称、状态和效果,点击方格或图标在右侧查看该分块全部物品、条件和来源。额外脚本点与未知分支沿用百科的标记规则。
金币使用带双层币边和猫爪纹样的专用图标,币面扩大至接近图标框大小,放大后仍清晰;地图与右侧详情保持一致。
拖动平移、滚轮或加减按钮缩放;「适应地图」显示当前选择的完整布局,「定位当前」切回角色所在区域。放大后更容易看清图标,自动刷新会保留缩放、平移与选中项。
点击分块只选中;右侧「传送到此分块」才移动角色,空分块也支持。落点使用上面的分块边缘与备用位置逻辑:目标受阻时继续找邻近分块、同图其他空处,到达后悬停并恢复碰撞。先关闭游戏菜单回到地图再传送;地图、存档或连接变化后须等待本页刷新。传送不替你领取物品或满足剧情条件。
发布版不提供快照与 TAS(逐帧/输入录制)功能;常规存档备份与还原保留。
## 文档与验证
- [使用须知](使用须知.md):常见提示与保存边界。
- [开发文档](开发文档.md):配置、运行时数据、测试与打包。
- [变更记录](../CHANGELOG.md):本次功能、验证范围和已知限制。
上面的物品、地图和收集点数量来自当前游戏版本的既有只读验证,面板实际数量随游戏版本而变化。发布包不附带剧本、原始地图或贴图;百科、地图与图标均在连接用户游戏后读取。源码仓库可在根目录运行 node --test tool/tests/*.test.js;玩家发布包不含测试,也无需安装 npm 依赖。
+6
View File
@@ -0,0 +1,6 @@
{
"gameDir": "C:\\Games\\Nyaruru",
"cdpPort": 9223,
"sandboxDir": "C:\\Nyaruru_shots",
"webPort": 8787
}
+3 -2
View File
@@ -7,7 +7,7 @@ const { readConfig, samePath } = require('./lib/safety');
const FILES = require('./lib/files'); const FILES = require('./lib/files');
const { CdpClient } = require('./lib/cdp'); const { CdpClient } = require('./lib/cdp');
const { OPS } = require('./lib/ops'); const { OPS } = require('./lib/ops');
const TOOL = __dirname, AITEMP = path.dirname(TOOL), GAME = path.dirname(AITEMP); const TOOL = __dirname, AITEMP = path.dirname(TOOL), LEGACY_GAME = path.dirname(AITEMP);
const sleep = ms => new Promise(r => setTimeout(r, ms)); const sleep = ms => new Promise(r => setTimeout(r, ms));
async function listening(port) { async function listening(port) {
return new Promise(resolve => { return new Promise(resolve => {
@@ -26,7 +26,8 @@ async function panel(config, matchConfig = true) {
} }
async function main() { async function main() {
if (Number(process.versions.node.split('.')[0]) < 22) throw new Error('需要 Node.js 22 或更新版本'); 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); const config = readConfig(path.join(TOOL, 'config.json'), { cdpPort: 9223, sandboxDir: path.join(path.parse(LEGACY_GAME).root, 'Nyaruru_shots'), webPort: 8787 }, LEGACY_GAME);
const GAME = config.gameDir || LEGACY_GAME;
if (process.argv.includes('--stop')) { if (process.argv.includes('--stop')) {
const runtimeFile = path.join(TOOL, '.server-runtime.json'); const runtimeFile = path.join(TOOL, '.server-runtime.json');
const port = fs.existsSync(runtimeFile) ? JSON.parse(fs.readFileSync(runtimeFile, 'utf8')).port : config.webPort; const port = fs.existsSync(runtimeFile) ? JSON.parse(fs.readFileSync(runtimeFile, 'utf8')).port : config.webPort;
+108
View File
@@ -0,0 +1,108 @@
// Pure geometry helpers plus an adapter evaluated inside the game.
function catalogCellBounds(data, thumbnail, cell) {
if (!data || !thumbnail || !cell || !thumbnail.data?.some(p=>p.x===cell.x&&p.y===cell.y)) throw Error('这个地点缺少可核实的小地图分块信息,未执行传送');
const columns=thumbnail.maxX-thumbnail.minX+1,rows=thumbnail.maxY-thumbnail.minY+1;
const ox=Number(data.meta?.cellOx)||0,oy=Number(data.meta?.cellOy)||0;
const width=data.width/columns,height=data.height/rows;
const left=Math.max(0,(cell.x-thumbnail.minX)*width-ox),top=Math.max(0,(cell.y-thumbnail.minY)*height-oy);
const right=Math.min(data.width,(cell.x-thumbnail.minX+1)*width-ox),bottom=Math.min(data.height,(cell.y-thumbnail.minY+1)*height-oy);
if(![left,top,right,bottom].every(Number.isFinite)||right<=left||bottom<=top)throw Error('小地图分块范围无效,未执行传送');
return{left,top,right,bottom,cellX:cell.x,cellY:cell.y};
}
function findCatalogLanding({bounds,shape,tileWidth:tw,tileHeight:th,hitsAt,maxProbes=14000}) {
const minX=Math.ceil(bounds.left*tw+shape.left+8),maxX=Math.floor(bounds.right*tw-shape.right-8);
const minY=Math.ceil(bounds.top*th+shape.top+8),maxY=Math.floor(bounds.bottom*th-shape.bottom-8);
if(minX>maxX||minY>maxY)return null;
const axis=(min,max,step)=>{const out=[min];for(let v=min+step;v<max;v+=step)out.push(v);if(max!==min)out.push(max);return out;};
const candidates=[];
for(const px of axis(minX,maxX,tw/2))for(const py of axis(minY,maxY,th/2))candidates.push({px,py,edge:Math.min((px-minX)/tw,(maxX-px)/tw,(py-minY)/th,(maxY-py)/th)});
candidates.sort((a,b)=>a.edge-b.edge||b.py-a.py||a.px-b.px);
let probes=0,floating=null;
const budgetError=Error('分块落点检查达到上限');
const hit=(px,py)=>{if(++probes>maxProbes)throw budgetError;return hitsAt(px,py)||[];};
const hazard=o=>o.type===2||!!o.triggerDamage||Number(o.upElastic)>0;
try{
for(const candidate of candidates){
const{px,py}=candidate;
if(hit(px,py).length)continue;
let supported=null,danger=false;
for(let d=1;d<=Math.min(2*th,maxY-py);d+=Math.max(1,th/8)){
const hits=hit(px,py+d);
if(!hits.length)continue;
danger=hits.some(hazard)||hits.some(o=>!o.terrain&&!o.fix);
if(!danger&&hits.some(o=>o.terrain||o.fix)){
let low=0,high=d;
while(high-low>1){const mid=Math.floor((low+high)/2);if(hit(px,py+mid).length)high=mid;else low=mid;}
supported={px,py:py+low,grounded:true};
}
break;
}
if(supported)return{...supported,probes};
if(!danger&&!floating)floating={px,py,grounded:false};
}
}catch(error){if(error!==budgetError||!floating)throw error;}
return floating?{...floating,probes}:null;
}
function planCatalogLanding(mapId,target,live=false) {
const data=DataManager.getCachedData(mapId);
const thumbnail=DataManager.getThumbnailMapInfo(mapId);
let cell;
if(target && typeof target==='object') {
if(![target.cellX,target.cellY,target.page].every(Number.isInteger))throw Error('地图分块坐标无效');
cell=Object.values(DataManager._thumbnailMapCell||{}).find(c=>c.mapId===mapId&&c.x===target.cellX&&c.y===target.cellY&&c.page===target.page);
if(!cell)throw Error('目标分块不属于指定地图或页面,未执行传送');
catalogCellBounds(data,thumbnail,cell);
} else {
const event=data?.events?.[target];
if(!event)throw Error('收集来源事件不存在,请刷新百科');
cell=DataManager.getThumbnailCell(event.x,event.y,mapId);
}
let targetBounds=null;
if(cell?.mapId===mapId)try{targetBounds=catalogCellBounds(data,thumbnail,cell);}catch{}
const current=mapId===$gameMap.mapId();
if(live&&!current)throw Error('目标地图尚未载入');
let terrain;
if(current)terrain=$gameMap._terrain;
else{
const preview=Object.create(Game_Map.prototype);
Object.assign(preview,{_mapId:mapId,_tmxExternalVertices:{},_terrain:new PhysicsGrid()});
// No setup(), events, scene loading, or swaps of the real $gameMap/$dataMap.
preview.tilemapTerrain();preview._terrain.rebuild();terrain=preview._terrain;
}
if(!terrain)throw Error('目标地图碰撞资料尚未就绪,未执行传送');
const tw=$gameMap.tileWidth(),th=$gameMap.tileHeight(),characters=[$gamePlayer];
if(typeof $gameLily!=='undefined'&&$gameLily&&!$gameLily.isRemove())characters.push($gameLily);
const shape={left:Math.max(...characters.map(p=>p.hitOffsetL()))+4,right:Math.max(...characters.map(p=>p.hitOffsetR()))+4,
top:Math.max(th*0.8,...characters.map(p=>p.hitOffsetT()))+4,bottom:Math.max(...characters.map(p=>p.hitOffsetB()))+4};
const probe=new Game_CharacterBase();
Object.assign(probe,{_hitOffsetL:shape.left,_hitOffsetR:shape.right,_hitOffsetT:shape.top,_hitOffsetB:shape.bottom,
_direction:6,_invertMode:false,_physicsFlag:0,_isFastRect:true,vertices:null});
probe.hitTest=function(other){return !characters.includes(other)&&this.baseHitTest(other);};
const grids=[terrain];
if(current)grids.push($gameMap._physicsFixGrid,$gameMap._physicsDynamicGrid);
const hitsAt=(px,py)=>{
probe.px=probe.rx=px;probe.py=probe.ry=py;
const hits=[];for(const grid of grids)grid?.getObjGrid(probe,hits);return hits;
};
const areas=targetBounds?[targetBounds]:[];
const otherCells=[...(thumbnail?.data||[])].filter(p=>!cell||p.x!==cell.x||p.y!==cell.y)
.sort((a,b)=>Math.abs(a.x-(cell?.x||0))+Math.abs(a.y-(cell?.y||0))-Math.abs(b.x-(cell?.x||0))-Math.abs(b.y-(cell?.y||0)));
for(const p of otherCells)try{areas.push(catalogCellBounds(data,thumbnail,p));}catch{}
areas.push({left:0,top:0,right:data.width,bottom:data.height,cellX:null,cellY:null});
for(const bounds of areas){
let result;
try{result=findCatalogLanding({bounds,shape,tileWidth:tw,tileHeight:th,hitsAt});}
catch(error){if(!error.message.includes('检查达到上限'))throw error;}
if(!result)continue;
const alternate=bounds!==targetBounds;
return{...result,bounds,targetBounds,alternate,x:(result.px-$gamePlayer.tileXToCenterX(0))/tw,y:(result.py-$gamePlayer.tileYToCenterY(0))/th,
positionNote:alternate?'目标分块受阻,已改到同图其他可用位置':result.grounded?'目标分块边缘附近的可落脚空处':'目标分块内的空处,保持悬停'};
}
// Only an entirely blocked/unavailable map fails; a blocked target cell alone
// always expands the search instead of cancelling the user's journey.
throw Error('已检查目标分块、邻近分块和整张地图,仍未找到能容纳角色的空处');
}
module.exports={catalogCellBounds,findCatalogLanding,planCatalogLanding};
+43
View File
@@ -0,0 +1,43 @@
function installCombat() {
if (window.__nyaruruCombat) return window.__nyaruruCombat;
let settings={outgoing:1,incoming:1,oneHit:false}, attacker=null, touched=Date.now(), actors=$gameActors;
const prototype=Game_CharacterBase.prototype;
const hit=prototype.onAttackHit, direct=prototype.requestDamage, hooked=new WeakMap();
const playerSide=c=>c===$gamePlayer || (typeof $gameLily!=='undefined' && c===$gameLily);
function reset(){settings={outgoing:1,incoming:1,oneHit:false};}
function touch(){if(actors!==$gameActors){reset();actors=$gameActors;}touched=Date.now();}
function scale(character,value){
if(!(value>0))return value;
if(playerSide(character))return value*settings.incoming;
const target=character.battleObject?.(), source=attacker?.battleObject?.();
if(playerSide(attacker) && target && source && target.team!==source.team){
const scaled=value*settings.outgoing;
return settings.oneHit && !target.unlimitHp ? Math.max(scaled,target.hp+1) : scaled;
}
return value;
}
prototype.onAttackHit=function(info,character,...args){
const previous=attacker;attacker=info?.bulletOwner||this;
const already=character&&hooked.has(character);
if(character&&!already){
const descriptor=Object.getOwnPropertyDescriptor(character,'overrideBeAttackDamage'),original=character.overrideBeAttackDamage;
hooked.set(character,{descriptor});
Object.defineProperty(character,'overrideBeAttackDamage',{configurable:true,writable:true,enumerable:false,value(...values){return scale(this,original.apply(this,values));}});
}
try{return hit.call(this,info,character,...args);}finally{
attacker=previous;
if(character&&!already){const {descriptor}=hooked.get(character);if(descriptor)Object.defineProperty(character,'overrideBeAttackDamage',descriptor);else delete character.overrideBeAttackDamage;hooked.delete(character);}
}
};
prototype.requestDamage=function(value,...args){return direct.call(this,playerSide(this)&&value>0?value*settings.incoming:value,...args);};
const api={touch,clear:reset,read(){touch();return{ok:true,...settings};},change(patch){
touch();const next={...settings};
for(const key of ['outgoing','incoming'])if(patch[key]!==undefined){if(typeof patch[key]!=='number'||!Number.isFinite(patch[key])||patch[key]<0||patch[key]>1000)throw Error('倍率必须为 0~1000 的数字');next[key]=patch[key];}
if(patch.oneHit!==undefined){if(typeof patch.oneHit!=='boolean')throw Error('秒杀开关无效');next.oneHit=patch.oneHit;}
settings=next;return api.read();
}};
Object.defineProperty(window,'__nyaruruCombat',{value:api,configurable:true});
setInterval(()=>{if(Date.now()-touched>120000||actors!==$gameActors){reset();actors=$gameActors;}},1000);
return api;
}
module.exports={installCombat};
+1 -1
View File
@@ -7,7 +7,7 @@
// unpacked/data/MapInfos.json → 393 张地图 // unpacked/data/MapInfos.json → 393 张地图
// out/triggers.csv → 897 条剧情触发点(其中 731 条有坐标) // out/triggers.csv → 897 条剧情触发点(其中 731 条有坐标)
// //
// ⚠️ **台词不在这里。** 这是"C 方案"(见开发文档 12.5)的核心约定: // ⚠️ **台词不在这里。** 这是"C 方案"(见 tool/开发文档.md 的运行时数据约定)的核心约定:
// 随包分发的只有**结构索引**(哪里有个触发点), // 随包分发的只有**结构索引**(哪里有个触发点),
// 一个字创作内容都不带。台词在连上游戏后从 `$dataStory` 实时读, // 一个字创作内容都不带。台词在连上游戏后从 `$dataStory` 实时读,
// 只存在于内存,**绝不落盘**(读取逻辑在 lib/ops.js 的 readStoryIndex)。 // 只存在于内存,**绝不落盘**(读取逻辑在 lib/ops.js 的 readStoryIndex)。
+94
View File
@@ -0,0 +1,94 @@
// Read-only inventory/location join, evaluated in the game. No progress is modified.
function readEncyclopedia(inventory) {
const maps = DataManager._cacheMapData;
if (!maps || !Object.keys(maps).length) throw new Error('地图资料尚未加载,请先正常读档');
const items = inventory.items.map(item => ({ ...item, locations: [] }));
items.push({ type: 'coin', id: 0, name: '金币', desc: '可用于商店购买物品。', count: inventory.stats.coin.value, max: 99999, locations: [] });
const byKey = new Map(items.map(item => [item.type + ':' + item.id, item]));
const shops = [], locations = [], seen = new Set();
const collectedKeys = new Set();
for (const region of Object.values(typeof $dataRegion === 'undefined' ? {} : $dataRegion)) {
for (const [mapId, eventId] of region?.exploreItem || []) collectedKeys.add(mapId + ':' + eventId);
}
const conditions = cond => {
const result = [];
for (const n of [1, 2]) if (cond?.['switch' + n + 'Valid']) {
const id = cond['switch' + n + 'Id'];
result.push({ label: '开关 ' + ($dataSystem.switches[id] || id), met: !!$gameSwitches.value(id) });
}
if (cond?.variableValid) result.push({ label: '变量 ' + ($dataSystem.variables[cond.variableId] || cond.variableId) + ' ≥ ' + cond.variableValue,
met: $gameVariables.value(cond.variableId) >= cond.variableValue });
if (cond?.actorValid) result.push({ label: '队伍角色 ' + cond.actorId, met: null });
if (cond?.itemValid) result.push({ label: '持有道具 ' + cond.itemId, met: null });
return result;
};
for (const [mapKey, map] of Object.entries(maps)) {
const mapId = Number(mapKey);
if (!map || !Number.isInteger(mapId)) continue;
const regionId = DataManager.mapRegion?.(mapId) || 0;
const region = typeof $dataRegion !== 'undefined' && $dataRegion[regionId];
const area = region?.name || '未分类区域';
for (const event of map.events || []) {
if (!event) continue;
for (const [pageIndex, page] of (event.pages || []).entries()) {
const list = page.list || [];
for (const [commandIndex, command] of list.entries()) {
if (command.code !== 357) continue;
const [, name, , args = {}] = command.parameters || [];
const coinPile = name === 'ai' && Number(args.type) === 60;
if (!coinPile && !['addItem', 'add_coin', 'shop'].includes(name)) continue;
if (name === 'add_coin' && !(Number(args.num) > 0)) continue;
const base = { mapId, mapName: $dataMapInfos?.[mapId]?.name || '地图 ' + mapId, area, regionId,
eventId: event.id, eventName: event.name || '', x: event.x, y: event.y, pageIndex,
conditions: conditions(page.conditions), branch: Number(command.indent) > 0 };
if (page.conditions?.selfSwitchValid) base.conditions.push({ label: '独立开关 ' + page.conditions.selfSwitchCh,
met: !!$gameSelfSwitches.value([mapId, event.id, page.conditions.selfSwitchCh]) });
if (name === 'shop') { shops.push({ ...base, kind: 'shop' }); continue; }
const type = name === 'add_coin' || coinPile ? 'coin' : args.type;
const id = name === 'add_coin' || coinPile ? 0 : Number(args.idx);
const item = byKey.get(type + ':' + id);
if (!item) continue;
const key = `${type}:${id}:${mapId}:${event.id}:${pageIndex}:${commandIndex}`;
if (seen.has(key)) continue;
seen.add(key);
const official = collectedKeys.has(mapId + ':' + event.id);
// A later marker is reliable only for a straight-line pickup. Branch
// rewards may reuse A for another path, so those remain unknown.
const marker = list.slice(commandIndex + 1).find(c => c.code === 123 && Number(c.indent || 0) === Number(command.indent || 0) && c.parameters?.[1] === 0);
const nextPageHides = (event.pages || []).some(pg => pg.conditions?.selfSwitchValid && pg.conditions.selfSwitchCh === marker?.parameters?.[0]);
const reliable = official || (!base.branch && marker && nextPageHides);
const status = reliable ? !!$gameSelfSwitches.value([mapId, event.id, official ? 'A' : marker.parameters[0]]) : null;
const location = { ...base, key, type, id, kind: coinPile ? 'coinpile' : 'pickup', collected: status, official,
sourceNote: coinPile ? '猫币堆:击打 8 次耗尽,掉落数量受配饰影响' : '',
amount: coinPile ? null : name === 'add_coin' ? Number(args.num) : 1, antiCollection: args.antiCollection === 'true' };
item.locations.push(location); locations.push(location);
}
}
}
}
const actor = $gamePlayer.actor();
for (const item of items) {
let definition = null;
if (['consumables','weapons','stones','ornaments'].includes(item.type)) definition = actor['_' + item.type].data()[item.id];
else if (item.type === 'bell') definition = actor._ornaments.bellData();
else if (item.type === 'hpst') definition = actor.hpStrengthData();
else if (item.type === 'skillst') definition = actor.skillStrengthData();
else if (item.type === 'joycoin') definition = actor.joyCoinData();
item.price = definition?.price ?? null;
item.shopCandidate = Number(definition?.price) > 0;
item.openTask = definition?.openTask ?? null;
}
const counted = new Map();
for (const location of locations) if (location.official) counted.set(location.mapId + ':' + location.eventId, location);
const summaries = new Map();
for (const location of counted.values()) {
if (!summaries.has(location.area)) summaries.set(location.area, { area: location.area, total: 0, collected: 0 });
const s = summaries.get(location.area); s.total++; if (location.collected) s.collected++;
}
const regions = [...summaries.values()];
return { ok: true, items, locations, shops, regions, subject: inventory.subject,
stats: { items: items.length, locations: locations.length, shops: shops.length,
total: counted.size, collected: [...counted.values()].filter(l => l.collected).length },
coverage: '来源为当前游戏已加载的地图物品指令、猫币堆、收集点和商店入口。商店候选需结合任务进度在店内确认;脚本动态奖励及普通敌人掉落不在地图索引中。未知状态不会按未收集处理。' };
}
module.exports = { readEncyclopedia };
+31
View File
@@ -0,0 +1,31 @@
// Renders only inventory thumbnails through the game's own UI icon mapping.
// Data URLs stay in memory; no game textures are exported to the filesystem.
async function readItemIcons(items) {
if (typeof Sprite_ItemIcon === 'undefined') throw new Error('游戏图标组件尚未就绪');
const bitmaps = new Map();
for (const item of items) {
if (!bitmaps.has(item.type)) bitmaps.set(item.type,Sprite_ItemIcon.prototype.itemBitmap(item.type));
}
await Promise.all([...new Set(bitmaps.values())].map(bitmap=>new Promise((resolve,reject)=>{
if (bitmap.isReady()) return resolve();
if (bitmap.isError?.()) return reject(new Error('物品图标加载失败'));
const timer=setTimeout(()=>reject(new Error('物品图标加载超时,请重试')),8000);
bitmap.addLoadListener(()=>{ clearTimeout(timer); resolve(); });
})));
const icons = {};
for (const item of items) {
const bitmap = bitmaps.get(item.type);
let frame;
const proxy = { _type:item.type, bitmap, setFrame(x,y,width,height) { frame={x,y,width,height}; } };
Sprite_ItemIcon.prototype.refreshIdx.call(proxy,item.id);
const {x,y,width,height} = frame;
if (![x,y,width,height].every(Number.isFinite) || width <= 0 || height <= 0 || width > 512 || height > 512 ||
x < 0 || y < 0 || x+width > bitmap.width || y+height > bitmap.height) throw new Error('物品图标裁切范围无效');
const canvas=document.createElement('canvas'); canvas.width=width; canvas.height=height;
const context=canvas.getContext('2d'); context.imageSmoothingEnabled=false;
context.drawImage(bitmap.canvas,x,y,width,height,0,0,width,height);
icons[item.type+':'+item.id]=canvas.toDataURL('image/png');
}
return {ok:true,icons};
}
module.exports = { readItemIcons };
+168
View File
@@ -0,0 +1,168 @@
// Runs inside the game. Hooks live outside saved actors and can be fully removed.
function installMotion() {
if (window.__nyaruruMotion?.version === 5) { window.__nyaruruMotion.touch(); return window.__nyaruruMotion; }
// Preserve an active hover when upgrading the in-game helper after a service restart.
const previous = window.__nyaruruMotion;
const resume = [$gamePlayer, typeof $gameLily !== 'undefined' ? $gameLily : null]
.filter(c => c && previous?.has(c)).map(c => ({ character:c, noclip:!!c._suspendPhysicsHitTest, vx:c._vx, vy:c._vy }));
if (previous?.dispose) previous.dispose(); else previous?.clear();
const records = new Map();
const actorSet = () => typeof $gameActors !== 'undefined' ? $gameActors : null;
let touched = Date.now(), player = $gamePlayer, data = $dataMap, map = $gameMap, actors = actorSet();
const reset = c => {
c.vx = c.vy = c._vx = c._vy = c.dotX = c.dotY = 0;
c.clearImpact?.(); c._impact = null; c.clearGrounding?.();
c._groundingX = c._terrainGroundingX = c.px;
c._groundingY = c._terrainGroundingY = c.py;
};
function disable(c) {
const r = records.get(c);
if (!r) return;
if (r.gravity) Object.defineProperty(c, 'doGravity', r.gravity); else delete c.doGravity;
if (r.transfer) Object.defineProperty(c, 'updateMapBoundTransfer', r.transfer); else delete c.updateMapBoundTransfer;
if (r.cache) Object.defineProperty(c, 'cacheDeltaTimeSpeed', r.cache); else delete c.cacheDeltaTimeSpeed;
if (r.suspendCheck) Object.defineProperty(c, 'isSuspendGravity', r.suspendCheck); else delete c.isSuspendGravity;
for (const [name, descriptor] of Object.entries(r.controls)) {
if (descriptor) Object.defineProperty(c,name,descriptor); else delete c[name];
}
for (const [key, descriptor] of Object.entries(r.flags)) {
if (descriptor) Object.defineProperty(c,key,{...descriptor,value:c[key]});
else delete c[key];
}
records.delete(c); reset(c);
}
function clear() {
for (const c of [...records.keys()]) {
disable(c); c.resumeGravity?.(); c.resumePhysicsHitTest?.();
}
}
function sync() {
// Normal exits reuse these objects; loading a save replaces them.
if (player !== $gamePlayer || map !== $gameMap || actors !== actorSet()) {
clear(); player = $gamePlayer; map = $gameMap; actors = actorSet(); data = $dataMap;
} else if (data !== $dataMap && $dataMap && $gameMap.mapId() > 0 &&
SceneManager._scene?.constructor.name === 'Scene_Map' && !SceneManager.isSceneChanging?.()) {
data = $dataMap;
for (const [c, r] of records) {
reset(c); c.suspendGravity();
if (r.noclip) c.suspendPhysicsHitTest();
}
}
}
function touch() { sync(); touched = Date.now(); }
function keepInMap(c) {
if (!c.tileXToCenterX || !c.tileYToCenterY || !c.unflush) return;
const tw=$gameMap.tileWidth(), th=$gameMap.tileHeight();
const left=c.hitOffsetL?.() ?? c.tileXToCenterX(0), top=c.hitOffsetT?.() ?? c.tileYToCenterY(0);
const right=$gameMap.width()*tw-(c.hitOffsetR?.() ?? tw-c.tileXToCenterX(0));
const bottom=$gameMap.height()*th-(c.hitOffsetB?.() ?? th-c.tileYToCenterY(0));
const px=Math.max(left,Math.min(right,c.px)),py=Math.max(top,Math.min(bottom,c.py));
if(px===c.px && py===c.py)return;
const blockedX=px!==c.px,blockedY=py!==c.py;
const x=(px-c.tileXToCenterX(0))/tw,y=(py-c.tileYToCenterY(0))/th;
const locate=typeof Game_CharacterBase!=='undefined' ? Game_CharacterBase.prototype.locate : c.locate;
locate.call(c,x,y);c.unflush(x,y);
if(blockedX){c.vx=0;c.dotX=0;}if(blockedY){c.vy=0;c.dotY=0;}
}
function enable(c, noclip = false) {
if (!c) return;
touch();
if (!records.has(c)) {
const gravity = Object.getOwnPropertyDescriptor(c, 'doGravity');
const transfer = Object.getOwnPropertyDescriptor(c, 'updateMapBoundTransfer'), originalTransfer = c.updateMapBoundTransfer;
const cache = Object.getOwnPropertyDescriptor(c, 'cacheDeltaTimeSpeed'), originalCache = c.cacheDeltaTimeSpeed;
const originalGravity = c.doGravity;
const suspendCheck = Object.getOwnPropertyDescriptor(c, 'isSuspendGravity');
const flags = Object.fromEntries(['_suspendGravity','_suspendPhysicsHitTest'].map(key=>[key,Object.getOwnPropertyDescriptor(c,key)]));
const controls = Object.fromEntries(['controlJump','controlDoubleJump'].map(key=>[key,Object.getOwnPropertyDescriptor(c,key)]));
const record = { gravity, transfer, cache, suspendCheck, flags, controls, handledJump:false, noclip:!!(noclip || c._suspendPhysicsHitTest) };
records.set(c, record);
// Native skill/stagger cleanup calls resumeGravity(). Keep the tool's
// hover independent, without blocking those native state transitions.
Object.defineProperty(c, 'isSuspendGravity', { configurable:true, writable:true, enumerable:false, value() { return true; } });
const skillActive = () => !!c._bodyAttacking;
const descending = () => c === $gamePlayer && !c.isOutControl?.() && !!c.isPressDown?.();
const extraJump = actor => {
if (c !== $gamePlayer || c.isFlexibleGrounding?.() || !c.isTriggeredJump?.() || c.isOutControl?.()) return false;
const sign = c._invertMode ? -1 : 1;
const native = c.jumpStrength?.(actor);
const strength = Number.isFinite(native) && native !== 0 ? Math.abs(native) : Math.abs(actor?.doubleJumpStrength?.() || 10);
c.vy = -sign * strength;
c.frameJump = true; c._nextFrameImmueElasticCollision = true; c._impact = null;
if (typeof AudioManager !== 'undefined') AudioManager.playNormalSe?.('mtv_jump2');
return true;
};
for (const name of ['controlJump','controlDoubleJump']) {
const original = c[name];
if (typeof original !== 'function') continue;
Object.defineProperty(c,name,{configurable:true,writable:true,enumerable:false,value(...args) {
if (name === 'controlJump') record.handledJump = false;
else if (record.handledJump) return true;
if (extraJump(args[0])) { record.handledJump = true; return true; }
return original.apply(this,args);
}});
}
Object.defineProperty(c, 'doGravity', { configurable: true, writable: true, enumerable: false, value() {
sync();
if (!records.has(c)) return originalGravity.apply(this,arguments);
// Positive Y is down; reversed-gravity scenes use the other sign.
const sign = this._invertMode ? -1 : 1;
const vy = Number.isFinite(this._vy) ? this._vy : Number(this.vy) || 0;
const g = Math.max(0.1, Math.abs(Number($gameMap.g?.() || 0) + Number(this.g?.() || 0)) || 0.35);
if (!skillActive()) {
if (descending()) this.vy = sign * 4;
else this.vy = vy * sign < 0 ? sign * Math.min(0, vy * sign + g * Math.max(0.1, Math.min(2, this.deltaTime?.() || 1))) : 0;
}
this.setTempGravity?.(0); this.setTempMGravity?.(0);
} });
// Apply intent once BEFORE time scaling and collision resolution.
// makeMomentum is also called AFTER collisions by endTestV; changing
// velocity there overwrites the resolved floor contact and tunnels down.
if (typeof originalCache === 'function') Object.defineProperty(c, 'cacheDeltaTimeSpeed', {
configurable:true, writable:true, enumerable:false, value(...args) {
sync();
if (records.has(c) && !this._testV && !skillActive()) {
if (descending()) this.vy = (this._invertMode ? -1 : 1) * 4;
else if (this._vy * (this._invertMode ? -1 : 1) > 0) this.vy = 0;
}
return originalCache.apply(this,args);
}
});
if(c === $gamePlayer && typeof originalTransfer === 'function') Object.defineProperty(c,'updateMapBoundTransfer',{
configurable:true,writable:true,enumerable:false,value(...args){
sync();
const result=originalTransfer.apply(this,args);
if(!records.has(c) || this.isTransferring?.() || $gameMap.isEventRunning?.())return result;
// Side walls sit outside the map, and noclip has no impact.
// Resolve real exits before our tighter fallback boundary.
const left=this.px-(this.hitOffsetL?.()||0)<0;
const right=this.px+(this.hitOffsetR?.()||0)>$gameMap.width()*$gameMap.tileWidth();
const down=this.py+(this.hitOffsetB?.()||0)>$gameMap.height()*$gameMap.tileHeight();
if((left && this.autoTransfer?.(3)) || (right && this.autoTransfer?.(4)) || (down && this.autoTransfer?.(2)))return result;
if(!this.isTransferring?.())keepInMap(this);
return result;
}
});
}
records.get(c).noclip=!!(noclip || c._suspendPhysicsHitTest);
reset(c); c.suspendGravity();
if (noclip) c.suspendPhysicsHitTest();
// These are temporary tool states. Omitting them from saves avoids loading
// suspended gravity without the per-session controller that stops a jump.
for (const key of ['_suspendGravity','_suspendPhysicsHitTest']) {
const descriptor=Object.getOwnPropertyDescriptor(c,key);
if (descriptor?.configurable && 'value' in descriptor) Object.defineProperty(c,key,{...descriptor,enumerable:false});
}
}
let timer;
const api = { version:5, touch, enable, disable, clear, has: c => records.has(c), reset,
setNoclip(c,on) { if(on)enable(c,true);else {const r=records.get(c);if(r)r.noclip=false;c.resumePhysicsHitTest();} },
dispose() { clear(); if (typeof clearInterval === 'function') clearInterval(timer); } };
Object.defineProperty(window, '__nyaruruMotion', { value: api, configurable: true });
if (typeof setInterval === 'function') timer = setInterval(() => {
if (Date.now() - touched > 120000) clear(); else sync();
}, 1000);
for (const r of resume) { enable(r.character,r.noclip); r.character._vx=r.vx; r.character._vy=r.vy; }
return api;
}
module.exports = { installMotion };
+121 -30
View File
@@ -1,6 +1,13 @@
const { installPhotoTools } = require('./photo-state'); const { installPhotoTools } = require('./photo-state');
const { chooseStoryPosition } = require('./story-position'); const { chooseStoryPosition } = require('./story-position');
const { installTrainer } = require('./trainer'); const { installTrainer } = require('./trainer');
const { installMotion } = require('./motion');
const { readEncyclopedia } = require('./encyclopedia');
const { readWorldMap } = require('./world-map');
const { readItemIcons } = require('./item-icons');
const { installCombat } = require('./combat');
const { catalogCellBounds,findCatalogLanding,planCatalogLanding } = require('./catalog-position');
const CATALOG = `const catalogCellBounds=(${catalogCellBounds.toString()}),findCatalogLanding=(${findCatalogLanding.toString()}),planCatalogLanding=(${planCatalogLanding.toString()});`;
const PHOTO = `const photo = (${installPhotoTools.toString()})();`; const PHOTO = `const photo = (${installPhotoTools.toString()})();`;
// 游戏操作脚本库 // 游戏操作脚本库
// //
@@ -8,12 +15,12 @@ const PHOTO = `const photo = (${installPhotoTools.toString()})();`;
// 之所以存成字符串而非直接执行,是因为这些代码必须在游戏进程里跑, // 之所以存成字符串而非直接执行,是因为这些代码必须在游戏进程里跑,
// 才能访问 $gamePlayer / $gameMap 这些游戏全局对象。 // 才能访问 $gamePlayer / $gameMap 这些游戏全局对象。
// //
// ⚠️ 关键设计约束(来自 2026-10-02 实机探查,详见 aitemp/探查结果.md): // ⚠️ 关键设计约束(当前规则见 tool/开发文档.md):
// 1. 传送必须用 $gamePlayer.locate(x, y),不能手写 _x/_y/_realX/_realY // 1. 传送必须用 $gamePlayer.locate(x, y),不能手写 _x/_y/_realX/_realY
// —— 后者会漏掉物理引擎的 px/py,导致"画面过去了、碰撞体还在原地" // —— 后者会漏掉物理引擎的 px/py,导致"画面过去了、碰撞体还在原地"
// 2. 游戏有 4 套坐标系,locate() 内部会一次同步全部 // 2. 游戏有 4 套坐标系,locate() 内部会一次同步全部
// 3. 无敌用 lockInvincible()(常驻)而非 makeInvincible()(会自己失效) // 3. 无敌用 lockInvincible()(常驻)而非 makeInvincible()(会自己失效)
// 4. 工具不负责读档 —— CDP 读档会崩(物理网格无法反序列化) // 4. 读档由用户在游戏正常菜单完成,不能裸调 loadGame(还需重建物理网格)
/** /**
* 包一层 IIFE,统一处理:游戏未进地图的守卫、异常捕获、JSON 返回格式。 * 包一层 IIFE,统一处理:游戏未进地图的守卫、异常捕获、JSON 返回格式。
@@ -23,12 +30,14 @@ const PHOTO = `const photo = (${installPhotoTools.toString()})();`;
* CDP 那边开了 `awaitPromise: true`,所以返回 Promise 会被自动等待。 * CDP 那边开了 `awaitPromise: true`,所以返回 Promise 会被自动等待。
* 同步的操作写成 async 也无妨,只是多包一层 Promise。 * 同步的操作写成 async 也无妨,只是多包一层 Promise。
*/ */
function wrap(body, { needMap = true } = {}) { function wrap(body, { needMap = true, allowMenu = false } = {}) {
const guard = needMap ? ` const guard = needMap ? `
if (typeof $gamePlayer === 'undefined' || !$gamePlayer || if (typeof $gamePlayer === 'undefined' || !$gamePlayer ||
typeof $gameMap === 'undefined' || !$gameMap || $gameMap.mapId() === 0 || typeof $gameMap === 'undefined' || !$gameMap || $gameMap.mapId() === 0 ||
(typeof SceneManager !== 'undefined' && SceneManager._scene?.constructor.name !== 'Scene_Map')) { (typeof SceneManager !== 'undefined' && !${JSON.stringify(allowMenu ? ['Scene_Map', 'Scene_Menu', 'Scene_Shop'] : ['Scene_Map'])}.includes(SceneManager._scene?.constructor.name))) {
return JSON.stringify({ ok: false, error: '游戏还没进入地图。请先在游戏里读档或开始游戏。' }); return JSON.stringify({ ok: false, error: typeof SceneManager !== 'undefined' && ['Scene_Menu','Scene_Shop'].includes(SceneManager._scene?.constructor.name)
? '请先关闭游戏菜单,回到地图后再执行移动或剧情操作。'
: '游戏还没进入地图。请先在游戏里读档或开始游戏。' });
} }
` : ''; ` : '';
return `(async function(){ try { ${guard} ${body} } catch (e) { return JSON.stringify({ ok:false, error: String(e && e.message || e) }); } })()`; return `(async function(){ try { ${guard} ${body} } catch (e) { return JSON.stringify({ ok:false, error: String(e && e.message || e) }); } })()`;
@@ -46,6 +55,7 @@ const SLEEP = `const sleep = (ms) => new Promise(r => setTimeout(r, ms));`;
* 机制见开发文档第 6 节。 * 机制见开发文档第 6 节。
*/ */
const SAFE_CHAR = ` const SAFE_CHAR = `
const motion = (${installMotion.toString()})();
// 清残留碰撞 + 重置落地锚点,否则 isFallDown() 会一直为真 // 清残留碰撞 + 重置落地锚点,否则 isFallDown() 会一直为真
const safeChar = (c) => { const safeChar = (c) => {
if (!c) return; if (!c) return;
@@ -63,7 +73,7 @@ const SAFE_CHAR = `
const floatChar = (c) => { const floatChar = (c) => {
if (!c) return; if (!c) return;
safeChar(c); safeChar(c);
try { c.suspendGravity(); c.suspendPhysicsHitTest(); } catch (e) {} motion.enable(c, true);
}; };
// 莉莉娅在不在(她被移除时不该动她) // 莉莉娅在不在(她被移除时不该动她)
const lilyAny = () => typeof $gameLily !== 'undefined' ? $gameLily : null; const lilyAny = () => typeof $gameLily !== 'undefined' ? $gameLily : null;
@@ -110,13 +120,38 @@ const OPS = {
`, { needMap: false }); `, { needMap: false });
}, },
worldMapTravel(mapId,cellX,cellY,page){
if(!Number.isInteger(mapId)||mapId<1||![cellX,cellY,page].every(Number.isInteger))throw Error('地图分块坐标无效');
return this.teleport(0,0,{mapId,catalog:{cellX,cellY,page}});
},
worldMap(){
return wrap(`const trainer=(${installTrainer.toString()})();
const encyclopedia=(${readEncyclopedia.toString()})(trainer.read());
return JSON.stringify((${readWorldMap.toString()})(encyclopedia));`,{allowMenu:true});
},
catalogTravel(mapId,eventId){
if(!Number.isInteger(mapId)||mapId<1||!Number.isInteger(eventId)||eventId<1)throw Error('收集地点无效');
return this.teleport(0,0,{mapId,catalog:{eventId}});
},
combat(body=null){return wrap(`const api=(${installCombat.toString()})();return JSON.stringify(${body?`api.change(${JSON.stringify(body)})`:'api.read()'});`,{allowMenu:true});},
itemIcons() {
return wrap(`const trainer = (${installTrainer.toString()})();
return JSON.stringify(await (${readItemIcons.toString()})(trainer.read().items));`, { allowMenu: true });
},
encyclopedia() {
return wrap(`const trainer = (${installTrainer.toString()})();
return JSON.stringify((${readEncyclopedia.toString()})(trainer.read()));`, { allowMenu: true });
},
trainer(body = null) { trainer(body = null) {
return wrap(`const trainer = (${installTrainer.toString()})(); return wrap(`const trainer = (${installTrainer.toString()})();
return JSON.stringify(${body ? `trainer.change(${JSON.stringify(body)})` : 'trainer.read()'});`); return JSON.stringify(${body ? `trainer.change(${JSON.stringify(body)})` : 'trainer.read()'});`, { allowMenu: true });
}, },
/** 读取当前游戏状态(供控制面板顶栏显示) */ /** 读取当前游戏状态(供控制面板顶栏显示) */
snapshot() { snapshot() {
return wrap(` return wrap(`
if (window.__nyaruruMotion && window.__nyaruruMotion.version !== 5) (${installMotion.toString()})();
window.__nyaruruMotion?.touch();
window.__nyaruruCombat?.touch();
if (window.__nyaruruPhoto?.touch) window.__nyaruruPhoto.touch(); if (window.__nyaruruPhoto?.touch) window.__nyaruruPhoto.touch();
if (window.__nyaruruTrainer) window.__nyaruruTrainer.touch(); if (window.__nyaruruTrainer) window.__nyaruruTrainer.touch();
const p = $gamePlayer, m = $gameMap; const p = $gamePlayer, m = $gameMap;
@@ -129,12 +164,12 @@ const OPS = {
mapId: m.mapId(), mapId: m.mapId(),
mapName: mi ? mi.name : ('地图 ' + m.mapId()), mapName: mi ? mi.name : ('地图 ' + m.mapId()),
mapSize: [m.width(), m.height()], mapSize: [m.width(), m.height()],
x: p._x, x: p.tileXToCenterX ? Math.round((p.px - p.tileXToCenterX(0)) / m.tileWidth() * 10000) / 10000 : p._x,
y: p._y, y: p.tileYToCenterY ? Math.round((p.py - p.tileYToCenterY(0)) / m.tileHeight() * 10000) / 10000 : p._y,
realX: p._realX, realX: p._realX,
realY: p._realY, realY: p._realY,
hp: bo ? bo.hp : null, hp: bo ? bo.hp : null,
gravitySuspended: !!p.isSuspendGravity(), gravitySuspended: !!window.__nyaruruMotion?.has(p),
invincible: !!p.isInvincible(), invincible: !!p.isInvincible(),
noclip: !!p._suspendPhysicsHitTest, noclip: !!p._suspendPhysicsHitTest,
// 莉莉娅在不在画面里(通关后她会一直跟着,可以用开关藏起来) // 莉莉娅在不在画面里(通关后她会一直跟着,可以用开关藏起来)
@@ -147,12 +182,14 @@ const OPS = {
uiHidden: SceneManager._scene.__nyaruruUiHidden || {message:false, artwork:false, hud:false}, 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; })(), switchesOn: (function(){ let n=0; for (let i=1;i<$dataSystem.switches.length;i++) if ($gameSwitches.value(i)) n++; return n; })(),
}); });
`); `, { allowMenu: true });
}, },
/** 实机 NW.js 用 chrome-extension URL;身份以 Node 入口和实际存档目录为准。 */ /** 实机 NW.js 用 chrome-extension URL;身份以 Node 入口和实际存档目录为准。 */
selfPath() { selfPath() {
return wrap(` return wrap(`
window.__nyaruruMotion?.touch();
window.__nyaruruCombat?.touch();
if (window.__nyaruruTrainer) window.__nyaruruTrainer.touch(); if (window.__nyaruruTrainer) window.__nyaruruTrainer.touch();
if (!window.__nyaruruIdentity) Object.defineProperty(window, '__nyaruruIdentity', { if (!window.__nyaruruIdentity) Object.defineProperty(window, '__nyaruruIdentity', {
value: { maps: new WeakMap(), next: 0, epoch: Date.now().toString(36) + Math.random().toString(36).slice(2) } value: { maps: new WeakMap(), next: 0, epoch: Date.now().toString(36) + Math.random().toString(36).slice(2) }
@@ -167,14 +204,15 @@ const OPS = {
const saveDir = typeof StorageManager !== 'undefined' ? StorageManager.fileDirectoryPath() : ''; const saveDir = typeof StorageManager !== 'undefined' ? StorageManager.fileDirectoryPath() : '';
return JSON.stringify({ ok: true, gameDir, saveDir, href: String(location.href), pathname: String(location.pathname), 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), mapToken: state.epoch + ':' + (player ? state.maps.get(player) : 0) + ':' + mapId + ':' + (mapData ? state.maps.get(mapData) : 0),
mapId, inMap: typeof SceneManager !== 'undefined' && SceneManager._scene?.constructor.name === 'Scene_Map' }); mapId, inMap: typeof SceneManager !== 'undefined' && SceneManager._scene?.constructor.name === 'Scene_Map',
inMenu: mapId > 0 && ['Scene_Menu','Scene_Shop'].includes(SceneManager._scene?.constructor.name) });
`, { needMap: false }); `, { needMap: false });
}, },
/** /**
* 传送到指定格。 * 传送到指定格。
* *
* ⚠️ 这个函数处理了一个**实测踩出来的坑**,改动前务必先读 `探查结果.md` 与开发文档第 6 节。 * ⚠️ 这个函数处理了游戏物理坐标同步问题,改动前先读 `tool/开发文档.md` 的「功能模块与边界」。
* *
* 游戏的"坠落复位"机制(nya_statem.js:5986): * 游戏的"坠落复位"机制(nya_statem.js:5986):
* isFallDown() 在玩家碰到 type===2 的地形(坑、虚空)时返回 true, * isFallDown() 在玩家碰到 type===2 的地形(坑、虚空)时返回 true,
@@ -194,19 +232,23 @@ const OPS = {
* *
* 用户想真正站到地上走动时,调用 land()。 * 用户想真正站到地上走动时,调用 land()。
*/ */
teleport(x, y, { float = true, mapId = null, story = null } = {}) { teleport(x, y, { float = true, mapId = null, story = null, catalog = null } = {}) {
if (x === null || y === null || x === '' || y === '' || !Number.isFinite(Number(x)) || !Number.isFinite(Number(y))) throw new Error('坐标必须是有限数值'); if (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('地图编号无效'); if (mapId !== null && (!Number.isInteger(Number(mapId)) || Number(mapId) < 1)) throw new Error('地图编号无效');
return wrap(` return wrap(`
${SLEEP} ${SLEEP}
${catalog ? CATALOG : ''}
const p = $gamePlayer, m = $gameMap; const p = $gamePlayer, m = $gameMap;
const targetMap = ${mapId !== null && mapId !== undefined ? Number(mapId) : 'null'}; const targetMap = ${mapId !== null && mapId !== undefined ? Number(mapId) : 'null'};
const sameMap = (targetMap === null || targetMap === m.mapId()); const sameMap = (targetMap === null || targetMap === m.mapId());
const fromMap = m.mapId(); const fromMap = m.mapId();
const previous = { x: p._x, y: p._y }; const previous = { x: p._x, y: p._y };
const priorPhysical={px:p.px,py:p.py};
let catalogPlacement = ${catalog ? `planCatalogLanding(targetMap,${JSON.stringify(catalog.eventId ?? catalog)})` : 'null'};
const destination=catalogPlacement||{x:${Number(x)},y:${Number(y)}};
const tx = Math.max(0, Math.min(m.width() - 1, Math.round(${Number(x)}))); const tx = catalogPlacement?destination.x:Math.max(0, Math.min(m.width() - 1, Math.round(destination.x)));
const ty = Math.max(0, Math.min(m.height() - 1, Math.round(${Number(y)}))); const ty = catalogPlacement?destination.y:Math.max(0, Math.min(m.height() - 1, Math.round(destination.y)));
if (!sameMap) { if (!sameMap) {
if (typeof $dataMapInfos === 'undefined' || !$dataMapInfos[targetMap]) return JSON.stringify({ok:false,error:'目标地图不存在'}); if (typeof $dataMapInfos === 'undefined' || !$dataMapInfos[targetMap]) return JSON.stringify({ok:false,error:'目标地图不存在'});
@@ -242,12 +284,33 @@ const OPS = {
}); });
} }
// 换图后坐标可能被目标地图的出生点逻辑改过,再定一次位 // 换图后坐标可能被目标地图的出生点逻辑改过,再定一次位
const nx = Math.max(0, Math.min(m.width() - 1, Math.round(${Number(x)}))); if(!catalogPlacement){
const ny = Math.max(0, Math.min(m.height() - 1, Math.round(${Number(y)}))); const nx = Math.max(0, Math.min(m.width() - 1, Math.round(destination.x)));
const ny = Math.max(0, Math.min(m.height() - 1, Math.round(destination.y)));
p.locate(nx, ny); p.locate(nx, ny);
} else {
p.locate(tx, ty);
} }
} else {
if(!catalogPlacement)p.locate(tx, ty);
}
${catalog ? `
try{catalogPlacement=planCatalogLanding(targetMap,${JSON.stringify(catalog.eventId ?? catalog)},true);}
catch(error){
// A live gate/actor can differ from the cached terrain preview.
// Return to the starting map/position instead of forcing a landing.
if(!sameMap){
p.reserveTransfer(fromMap,previous.x,previous.y,2,0);
const rollbackStart=Date.now();
while(Date.now()-rollbackStart<5000){await sleep(100);if($gameMap!==m||$gamePlayer!==p)break;if(m.mapId()===fromMap&&!p.isTransferring()&&SceneManager._scene?.constructor.name==='Scene_Map')break;}
if($gameMap!==m||$gamePlayer!==p||m.mapId()!==fromMap||p.isTransferring())throw Error('目标分块未找到落点,返回原地图超时或游戏状态已变化,请检查游戏状态');
const bx=(priorPhysical.px-p.tileXToCenterX(0))/m.tileWidth(),by=(priorPhysical.py-p.tileYToCenterY(0))/m.tileHeight();
Game_CharacterBase.prototype.locate.call(p,bx,by);p.unflush(bx,by);
}
throw Error('目标分块暂时被地形或角色占用,已保留或返回原位置:'+error.message);
}
Game_CharacterBase.prototype.locate.call(p,catalogPlacement.x,catalogPlacement.y);
p.unflush(catalogPlacement.x,catalogPlacement.y);
` : ''}
let storyPlacement = null; 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)}; const storyRequest = ${JSON.stringify(story ? { eventId: Number(story.eventId), pageIndex: story.pageIndex == null ? null : Number(story.pageIndex), storyId: Number(story.storyId) } : null)};
@@ -279,10 +342,14 @@ const OPS = {
const floatMode = ${float ? 'true' : 'false'}; const floatMode = ${float ? 'true' : 'false'};
if (floatMode) { if (floatMode) {
${SAFE_CHAR} ${SAFE_CHAR}
floatChar(p); ${catalog ? 'motion.enable(p);motion.setNoclip(p,false);' : 'floatChar(p);'}
const companion = lilyHere(); const companion = lilyHere();
if (!sameMap && storyPlacement?.ok && storyPlacement.controller && companion?.locate) companion.locate(p._x, p._y); if (!sameMap && storyPlacement?.ok && storyPlacement.controller && companion?.locate) companion.locate(p._x, p._y);
floatChar(companion); ${catalog ? `if(companion){
const lx=(catalogPlacement.px-companion.tileXToCenterX(0))/m.tileWidth(),ly=(catalogPlacement.py-companion.tileYToCenterY(0))/m.tileHeight();
Game_CharacterBase.prototype.locate.call(companion,lx,ly);companion.unflush(lx,ly);
motion.enable(companion);motion.setNoclip(companion,false);
}` : 'floatChar(companion);'}
} }
// 检测是否正在播放剧情 / 有事件在跑。 // 检测是否正在播放剧情 / 有事件在跑。
@@ -299,7 +366,8 @@ const OPS = {
return JSON.stringify({ return JSON.stringify({
ok: storyPlacement?.ok !== false, error: storyPlacement?.error, ok: storyPlacement?.ok !== false, error: storyPlacement?.error,
positionNote: storyPlacement?.positionNote, positionNote: catalogPlacement?.positionNote||storyPlacement?.positionNote,
catalogCell:catalogPlacement?.bounds,
x: p._x, y: p._y, px: p.px, py: p.py, x: p._x, y: p._y, px: p.px, py: p.py,
mapId: m.mapId(), mapId: m.mapId(),
changedMap: fromMap !== m.mapId(), changedMap: fromMap !== m.mapId(),
@@ -325,6 +393,22 @@ const OPS = {
* 插件指令参数格式实测为 [插件名, 命令名, 显示文本, 参数对象] —— 4 个元素, * 插件指令参数格式实测为 [插件名, 命令名, 显示文本, 参数对象] —— 4 个元素,
* 参数在**第 3 位**(rmmz_objects.js:11315 的 command357 取的就是 params[3])。 * 参数在**第 3 位**(rmmz_objects.js:11315 的 command357 取的就是 params[3])。
*/ */
nudge(dx, dy, pixels = 4) {
if (![dx, dy].every(Number.isInteger) || Math.abs(dx) + Math.abs(dy) !== 1 || !Number.isFinite(pixels) || pixels < 1 || pixels > 96) throw new Error('微调方向或像素步长无效');
return wrap(`
${SAFE_CHAR}
const p = $gamePlayer, m = $gameMap;
const x = Math.max(0, Math.min(m.width() - 1, (p.px - p.tileXToCenterX(0) + ${dx * pixels}) / m.tileWidth()));
const y = Math.max(0, Math.min(m.height() - 1, (p.py - p.tileYToCenterY(0) + ${dy * pixels}) / m.tileHeight()));
// Base locate synchronizes physical coordinates without player locate's
// automatic floor search or companion transfer, which defeats tiny steps.
if (typeof Game_CharacterBase !== 'undefined') Game_CharacterBase.prototype.locate.call(p, x, y);
else p.locate(x, y);
p.unflush(x, y); floatChar(p);
const lily = lilyHere(); if (lily) floatChar(lily);
return JSON.stringify({ok:true,x,y,px:p.px,py:p.py});
`);
},
playStory(storyId) { playStory(storyId) {
return wrap(` return wrap(`
${SLEEP} ${SLEEP}
@@ -1116,6 +1200,7 @@ const OPS = {
} }
// 恢复碰撞;锚点设到落点,确保万一出岔子也落在附近而不是旧地图的位置 // 恢复碰撞;锚点设到落点,确保万一出岔子也落在附近而不是旧地图的位置
window.__nyaruruMotion?.disable(p);
p.resumePhysicsHitTest(); p.resumePhysicsHitTest();
p.clearImpact && p.clearImpact(); p.clearImpact && p.clearImpact();
p._impact = null; p._impact = null;
@@ -1132,7 +1217,7 @@ const OPS = {
const hits = (lily.hitPreview(0, dy) || []).filter(o => o && o.terrain === true); const hits = (lily.hitPreview(0, dy) || []).filter(o => o && o.terrain === true);
if (hits.length) { safe = !hits.some(o => o.type === 2); break; } if (hits.length) { safe = !hits.some(o => o.type === 2); break; }
} }
if (safe) { safeChar(lily); lily.resumeGravity(); lily.resumePhysicsHitTest(); lilyLanded = true; } if (safe) { motion.disable(lily); safeChar(lily); lily.resumeGravity(); lily.resumePhysicsHitTest(); lilyLanded = true; }
else { floatChar(lily); warning = '主角已落地;莉莉娅脚下不安全,仍保持悬浮。请移动到安全地面再点落地'; } else { floatChar(lily); warning = '主角已落地;莉莉娅脚下不安全,仍保持悬浮。请移动到安全地面再点落地'; }
} }
return JSON.stringify({ ok: true, dropDistance: found, lilyLanded, warning }); return JSON.stringify({ ok: true, dropDistance: found, lilyLanded, warning });
@@ -1147,7 +1232,7 @@ const OPS = {
out[i] = $gameSwitches.value(i) ? 1 : 0; out[i] = $gameSwitches.value(i) ? 1 : 0;
} }
return JSON.stringify({ ok: true, values: out }); return JSON.stringify({ ok: true, values: out });
`); `, { allowMenu: true });
}, },
/** 设置单个开关 */ /** 设置单个开关 */
@@ -1178,14 +1263,15 @@ const OPS = {
* 并触发坠落复位循环(用户实测反馈过)。 * 并触发坠落复位循环(用户实测反馈过)。
*/ */
setGravity(suspended) { setGravity(suspended) {
if (!suspended) return this.land();
const on = suspended ? 'true' : 'false'; const on = suspended ? 'true' : 'false';
return wrap(` return wrap(`
${SAFE_CHAR} ${SAFE_CHAR}
const p = $gamePlayer; const p = $gamePlayer;
const lh = lilyAny(); const lh = lilyAny();
if (${on}) { if (${on}) {
floatChar(p); safeChar(p); motion.enable(p);
if (lh) floatChar(lh); if (lh) { safeChar(lh); motion.enable(lh); }
} else { } else {
p.resumeGravity(); p.resumeGravity();
p.resumePhysicsHitTest(); p.resumePhysicsHitTest();
@@ -1221,13 +1307,16 @@ const OPS = {
return wrap(` return wrap(`
${SAFE_CHAR} ${SAFE_CHAR}
const p = $gamePlayer; const p = $gamePlayer;
if (!${on ? 'true' : 'false'} && (p.hitPreview(0, 0) || []).some(o => o?.terrain === true)) return JSON.stringify({ok:false,error:'当前与地形重叠,请先微调到空处再关闭穿墙'});
let warning = '';
for (const c of [p, lilyAny()]) { for (const c of [p, lilyAny()]) {
if (!c) continue; if (!c) continue;
safeChar(c); safeChar(c);
if (${on ? 'true' : 'false'}) c.suspendPhysicsHitTest(); if (${on ? 'true' : 'false'}) motion.enable(c, true);
else c.resumePhysicsHitTest(); else if (c !== p && (c.hitPreview(0,0) || []).some(o => o?.terrain === true)) warning = '莉莉娅与地形重叠,仍保持穿墙悬停';
else motion.setNoclip(c,false);
} }
return JSON.stringify({ok:true,noclip:!!p._suspendPhysicsHitTest}); return JSON.stringify({ok:true,noclip:!!p._suspendPhysicsHitTest,warning});
`); `);
}, },
@@ -1242,6 +1331,8 @@ const OPS = {
const lh = lilyAny(); const lh = lilyAny();
if (window.__nyaruruPhoto) window.__nyaruruPhoto.unpinAll(); if (window.__nyaruruPhoto) window.__nyaruruPhoto.unpinAll();
if (window.__nyaruruTrainer) window.__nyaruruTrainer.clear(); if (window.__nyaruruTrainer) window.__nyaruruTrainer.clear();
window.__nyaruruCombat?.clear();
motion.clear();
// 玩家和莉莉娅都要清 —— 只清玩家的话,同伴会继续坠落复位循环 // 玩家和莉莉娅都要清 —— 只清玩家的话,同伴会继续坠落复位循环
for (const c of [p, lh]) { for (const c of [p, lh]) {
if (!c) continue; if (!c) continue;
+9
View File
@@ -20,6 +20,15 @@ function contains(a, b) {
} }
function validateConfig(config, gameDir) { function validateConfig(config, gameDir) {
const next = { ...config }; const next = { ...config };
// 独立工作区可显式指定游戏;旧版放在游戏/aitemp 下的布局仍然可用。
if (next.gameDir !== undefined) {
next.gameDir = canonical(String(next.gameDir).trim());
for (const name of ['package.json', 'index.html']) {
const file = path.join(next.gameDir, name);
if (!fs.existsSync(file) || !fs.statSync(file).isFile()) throw new Error('游戏目录不完整,缺少:' + file);
}
gameDir = next.gameDir;
}
for (const key of ['cdpPort', 'webPort']) { for (const key of ['cdpPort', 'webPort']) {
const n = Number(next[key]); const n = Number(next[key]);
if (!Number.isInteger(n) || n < 1024 || n > 65535) throw new Error(`${key} 必须是 1024~65535 的整数`); if (!Number.isInteger(n) || n < 1024 || n > 65535) throw new Error(`${key} 必须是 1024~65535 的整数`);
+109 -27
View File
@@ -1,6 +1,8 @@
// Injected into the game. Uses NYA's actor inventory/battle values, not RPG Maker HP/gold. // Injected into the game. Uses NYA's actor inventory/battle values, not RPG Maker HP/gold.
function installTrainer() { function installTrainer() {
if (window.__nyaruruTrainer) return window.__nyaruruTrainer; if (window.__nyaruruTrainer?.version === 3) return window.__nyaruruTrainer;
if (window.__nyaruruTrainer?.dispose) window.__nyaruruTrainer.dispose();
else window.__nyaruruTrainer?.clear();
const fields = { coin: '_coin', hp: '_hp', sp: 'sp', zp: 'zp' }; const fields = { coin: '_coin', hp: '_hp', sp: 'sp', zp: 'zp' };
const locks = new Map(); const locks = new Map();
const ids = new WeakMap(); const ids = new WeakMap();
@@ -54,7 +56,7 @@ function installTrainer() {
} }
function touch() { sync(); touched = Date.now(); } function touch() { sync(); touched = Date.now(); }
// Only one timer per game page; neither hooks nor lock settings enter save data. // Only one timer per game page; neither hooks nor lock settings enter save data.
setInterval(() => { try { sync(); } catch { clear(); } }, 100); const timer = setInterval(() => { try { sync(); } catch { clear(); } }, 100);
const categories = ['consumables', 'weapons', 'stones', 'ornaments']; const categories = ['consumables', 'weapons', 'stones', 'ornaments'];
const specials = { const specials = {
@@ -87,7 +89,67 @@ function installTrainer() {
const data = actor[dataMethod](); const data = actor[dataMethod]();
items.push({ type, id: 0, name: localized(data.name), desc: localized(data.desc), count: actor[countMethod](), max: 99999, equipped: false }); 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 }; if (actor._ornaments.bellData) {
const data = actor._ornaments.bellData(), fromMap = actor._ornaments.eventBellGainNum();
items.push({ type: 'bell', id: 0, name: localized(data.name), desc: localized(data.desc) + ' 默认保留地图铃铛;允许重置收集点时可减少到 0。',
count: actor.bellNum(), min: fromMap, max: 25, equipped: false });
}
const capacity = actor._ornaments.maxEquip ? { used: actor._ornaments.hasEquipNum(), max: actor._ornaments.maxEquip(),
mapBells: actor._ornaments.eventBellGainNum(), extraBells: actor._ornaments._hasBuyBellNum } : null;
return { ok: true, subject, role: $gamePlayer.isLilyMode() ? '莉莉娅' : actor.name(), stats, items, capacity };
}
function validateItem(actor, body) {
const { type } = body, itemId = integer(body.id, 99999);
if (type === 'bell') {
if (itemId !== 0 || !actor._ornaments.bellData) throw new Error('铃铛数据不可用');
const count = integer(body.count, 25), minimum = actor._ornaments.eventBellGainNum();
if (count < minimum) {
if (body.adjustMapBells !== true) throw new Error(`已从地图取得 ${minimum} 个铃铛;勾选允许重置地图铃铛后可继续减少`);
const keys = typeof DataManager !== 'undefined' && DataManager._cacheBell;
if (!Array.isArray(keys) || keys.filter(key => $gameSelfSwitches.value(key)).length !== minimum) throw new Error('地图铃铛记录不完整,不能减少');
}
return;
}
if (Object.prototype.hasOwnProperty.call(specials, type)) {
if (itemId !== 0) throw new Error('物品编号无效');
integer(body.count, 99999); return;
}
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('物品不存在');
integer(body.count, type === 'consumables' ? (body.overCap === true ? 99999 : container.maxNum(itemId)) : 1);
}
function applyItem(actor, body) {
const { type, id: itemId, count } = body;
if (type === 'bell') {
const o = actor._ornaments;
if (count < o.eventBellGainNum()) {
const acquired = DataManager._cacheBell.filter(key => $gameSelfSwitches.value(key));
// Remove the highest map/event keys first for deterministic results.
const ordered = [...acquired].sort((a,b)=>a[0]-b[0] || a[1]-b[1]);
for (const key of ordered.slice(count)) $gameSelfSwitches.setValue(key, false);
}
o._hasBuyBellNum = count - o.eventBellGainNum();
while (o.hasEquipNum() > o.maxEquip()) {
const removable = [...o._equip].reverse().find(id => id !== 20);
if (removable === undefined) break;
o.unequip(removable);
}
} else if (Object.prototype.hasOwnProperty.call(specials, type)) actor[specials[type][2]] = count;
else {
const container = actor['_' + type];
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 (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);
}
}
} }
function change(body) { function change(body) {
touch(); touch();
@@ -114,35 +176,55 @@ function installTrainer() {
locks.set(field, { field, target: value, object: null }); locks.set(field, { field, target: value, object: null });
try { sync(); } catch (e) { clear(); throw e; } try { sync(); } catch (e) { clear(); throw e; }
} }
} else if (body.action === 'item') { } else if (body.action === 'equip') {
const { type } = body; const { type, id: itemId } = body;
const itemId = integer(body.id, 99999); if (!['weapons','stones','ornaments'].includes(type) || typeof body.equipped !== 'boolean') throw new Error('装备参数无效');
if (Object.prototype.hasOwnProperty.call(specials, type)) { integer(itemId, 99999);
if (itemId !== 0) throw new Error('物品编号无效'); const container = actor['_' + type];
actor[specials[type][2]] = integer(body.count, 99999); if (!Object.hasOwn(container.data(), itemId) || !container.isUnlock(itemId)) throw new Error('需要先获得该物品');
} else { if (type === 'weapons') { if (!body.equipped) throw new Error('当前武器不能卸为空'); actor.changeWeapon(itemId); $gamePlayer.setWeaponChange(); }
if (!categories.includes(type)) throw new Error('物品分类无效'); else if (type === 'stones') { if (container.isEquip(itemId) !== body.equipped) container.equip(itemId); }
const container = actor['_' + type], data = container.data(); else if (!body.equipped) container.unequip(itemId);
if (!Object.prototype.hasOwnProperty.call(data, itemId) || !data[itemId]) throw new Error('物品不存在'); else if (!container.isEquip(itemId) && !container.equip(itemId)) throw new Error('配饰栏位不足,请增加铃铛或先卸下其他配饰');
const max = type === 'consumables' ? (body.overCap === true ? 99999 : container.maxNum(itemId)) : 1; } else if (body.action === 'batch' || body.action === 'item') {
const count = integer(body.count, max); const changes = body.action === 'item' ? [body] : body.changes;
if (type === 'consumables') container.add(itemId, count - container.itemNum(itemId), body.overCap === true); if (!Array.isArray(changes) || !changes.length || changes.length > 100) throw new Error('批量物品列表无效');
else if (count) container.unlock(itemId); const seen = new Set();
else if (type === 'weapons') { for (const item of changes) {
const remaining = container._unlock.filter(n => n !== itemId); if (!item || typeof item !== 'object') throw new Error('物品参数无效');
if (!remaining.length) throw new Error('至少需要保留一件武器,避免游戏攻击时找不到武器'); validateItem(actor, item);
if (container.currentWeapon() === itemId) { actor.changeWeapon(remaining[0]); $gamePlayer.setWeaponChange(); } const key = item.type + ':' + item.id;
container._unlock = remaining; if (seen.has(key)) throw new Error('批量列表含重复物品');
} else if (type === 'ornaments') container.remove(itemId); seen.add(key);
else {
if (container.isEquip(itemId)) container.equip(itemId);
container._unlock = container._unlock.filter(n => n !== itemId);
} }
const weapons = new Set(actor._weapons._unlock);
for (const item of changes) if (item.type === 'weapons') { if (item.count) weapons.add(item.id); else weapons.delete(item.id); }
if (!weapons.size) throw new Error('至少需要保留一件武器,避免游戏攻击时找不到武器');
const objects = categories.map(type => actor['_' + type]);
const snapshots = objects.map(object => Object.fromEntries(Reflect.ownKeys(object).map(key => {
const d = Object.getOwnPropertyDescriptor(object, key);
if ('value' in d && d.value && typeof d.value === 'object') d.value = Array.isArray(d.value) ? [...d.value] : { ...d.value };
return [key, d];
})));
const materialKeys = Object.values(specials).map(v => v[2]);
const materials = materialKeys.map(key => actor[key]);
const bellFlags = changes.some(i=>i.type==='bell' && i.adjustMapBells === true) ? DataManager._cacheBell.map(key=>[key,$gameSelfSwitches.value(key)]) : [];
try {
const ordered = [...changes].sort((a,b) => Number(b.type === 'weapons' && b.count === 1) - Number(a.type === 'weapons' && a.count === 1));
for (const item of ordered) applyItem(actor, item);
} catch (e) {
for (let i = 0; i < objects.length; i++) {
for (const key of Reflect.ownKeys(objects[i])) if (!Object.hasOwn(snapshots[i], key)) delete objects[i][key];
Object.defineProperties(objects[i], snapshots[i]);
}
materialKeys.forEach((key, i) => { actor[key] = materials[i]; });
for (const [key,value] of bellFlags) $gameSelfSwitches.setValue(key,value);
throw e;
} }
} else throw new Error('未知修改操作'); } else throw new Error('未知修改操作');
return read(); return read();
} }
return window.__nyaruruTrainer = { read, change, touch, clear }; return window.__nyaruruTrainer = { version: 3, read, change, touch, clear, dispose() { clear(); if (typeof clearInterval === 'function') clearInterval(timer); } };
} }
module.exports = { installTrainer }; module.exports = { installTrainer };
+45
View File
@@ -0,0 +1,45 @@
// Evaluated in the game: read original schematic cells, never activate exploration.
function readWorldMap(encyclopedia) {
const source = Object.values(DataManager._thumbnailMapCell || {});
if (!source.length) throw Error('世界地图资料尚未加载,请先正常读档');
const keyOf = c => `${c.page}:${c.x}:${c.y}`;
const cells = source.map(c => ({ key: keyOf(c), page: c.page, regionId: c.regionId, mapId: c.mapId,
x: c.x, y: c.y, hidden: !!c.hidden,
mapName: $dataMapInfos?.[c.mapId]?.name || '地图 ' + c.mapId,
explored: !!$gameSystem.isThumbnailActive(DataManager.makePosKey(c.x,c.y)),
walls: { left:!!c.l, top:!!c.t, right:!!c.r, bottom:!!c.b },
exits: { left:!!c.pl, top:!!c.pt, right:!!c.pr, bottom:!!c.pb } }));
const byCell = new Map(cells.map(c => [c.key,c]));
const definitions = new Map(encyclopedia.items.map(i => [i.type + ':' + i.id,i]));
const markers = [], unmapped = [];
for (const location of encyclopedia.locations) {
const c = DataManager.getThumbnailCell(location.x,location.y,location.mapId);
const cell = c && byCell.get(keyOf(c));
if (!cell || cell.mapId !== location.mapId) { unmapped.push(location.key); continue; }
const iconKey = location.type + ':' + location.id, item = definitions.get(iconKey);
markers.push({ ...location, cellKey:cell.key, page:cell.page, regionId:cell.regionId,
name:item?.name || iconKey, desc:item?.desc || '', iconKey });
}
const official = new Set();
for (const region of Object.values($dataRegion || {}))
for (const [mapId,eventId] of region?.exploreItem || []) official.add(mapId + ':' + eventId);
const mapped = new Set(markers.filter(m=>m.official).map(m=>m.mapId + ':' + m.eventId));
const regions = [...new Set(cells.map(c=>c.regionId))].sort((a,b)=>a-b).map(id=>({id,
name:$dataRegion?.[id]?.name || '区域 ' + id,
color:'#' + (DataManager.regionColor?.(id) || 0).toString(16).padStart(6,'0')}));
const pages = [...new Set(cells.map(c=>c.page))].sort((a,b)=>a-b).map(id=>{
const group=cells.filter(c=>c.page===id);
return {id,label:'页面 ' + id,regionIds:[...new Set(group.map(c=>c.regionId))],
minX:Math.min(...group.map(c=>c.x)),maxX:Math.max(...group.map(c=>c.x)),
minY:Math.min(...group.map(c=>c.y)),maxY:Math.max(...group.map(c=>c.y))};
});
const current=DataManager.getCurrentThumbnailCell();
const playerCell=current && byCell.get(keyOf(current));
const player=playerCell ? {cellKey:playerCell.key,page:playerCell.page,regionId:playerCell.regionId,
mapId:$gameMap.mapId(),x:$gamePlayer._x,y:$gamePlayer._y} : {cellKey:null,mapId:$gameMap.mapId()};
const missing=[...official].filter(k=>!mapped.has(k)), mappedOfficial=official.size-missing.length;
return {ok:true,regions,pages,cells,markers,player,coverage:{cells:cells.length,markers:markers.length,
officialTotal:official.size,mappedOfficial,officialMapped:mappedOfficial,missing,missingOfficial:missing,unmapped,
message:`原版收集点已映射 ${mappedOfficial}/${official.size};地图 ${cells.length} 格`}};
}
module.exports={readWorldMap};
+41
View File
@@ -0,0 +1,41 @@
// Explicit allow-list packaging. Never copy the working tree recursively.
const fs = require('node:fs');
const path = require('node:path');
const crypto = require('node:crypto');
const ROOT = path.resolve(__dirname, '..');
function manifestFiles(root = ROOT) {
const manifest = JSON.parse(fs.readFileSync(path.join(root, 'tool/发布清单.json'), 'utf8'));
const files = manifest.files;
if (!Array.isArray(files) || new Set(files).size !== files.length) throw Error('发布清单无效或包含重复文件');
for (const file of files) {
if (typeof file !== 'string' || file.includes('\\') || path.posix.isAbsolute(file) || file.split('/').some(p => !p || p === '.' || p === '..')) throw Error('发布路径无效:' + file);
if (/^(?:out|unpacked)\//.test(file) && !['out/triggers.csv', 'unpacked/data/System.json', 'unpacked/data/MapInfos.json'].includes(file)) throw Error('原始游戏数据不能打包:' + file);
if (/(?:^|\/)(?:\.git|config\.json|\.server-runtime\.json|backups|save|probe|docs\/历史|nwjs-sdk[^/]*)(?:\/|$)|\.(?:rmmzsave|bin|log|zip)$/i.test(file)) throw Error('私人或运行时文件不能打包:' + file);
let current = root;
for (const part of file.split('/')) { current = path.join(current, part); if (fs.lstatSync(current).isSymbolicLink()) throw Error('不能打包链接:' + file); }
if (!fs.statSync(current).isFile()) throw Error('发布项不是文件:' + file);
}
return files;
}
function buildRelease(destination = path.join(ROOT, 'dist', 'Nyaruru_modifier'), root = ROOT) {
destination = path.resolve(destination);
const files = manifestFiles(root);
if (fs.existsSync(destination)) throw Error('输出目录已存在,请使用新的空路径:' + destination);
fs.mkdirSync(destination, { recursive: true });
const hashes = [];
for (const file of files) {
const target = path.join(destination, file), data = fs.readFileSync(path.join(root, file));
fs.mkdirSync(path.dirname(target), { recursive: true });
fs.writeFileSync(target, data, { flag: 'wx' });
hashes.push({ file, bytes: data.length, sha256: crypto.createHash('sha256').update(data).digest('hex') });
}
return { destination, files: hashes };
}
if (require.main === module) {
try { const result = buildRelease(process.argv[2]); console.log(JSON.stringify(result, null, 2)); }
catch (error) { console.error(error.message); process.exitCode = 1; }
}
module.exports = { buildRelease, manifestFiles };
+32 -6
View File
@@ -25,7 +25,7 @@ const { assertGamesStopped } = require('./lib/processes');
// ---------- 路径与配置 ---------- // ---------- 路径与配置 ----------
const TOOL_DIR = __dirname; const TOOL_DIR = __dirname;
const AITEMP_DIR = path.resolve(TOOL_DIR, '..'); // aitemp/ const AITEMP_DIR = path.resolve(TOOL_DIR, '..'); // aitemp/
const GAME_DIR = path.resolve(AITEMP_DIR, '..'); // 游戏根目录 const LEGACY_GAME_DIR = path.resolve(AITEMP_DIR, '..'); // 旧布局的游戏根目录
const WEB_DIR = path.join(TOOL_DIR, 'web'); const WEB_DIR = path.join(TOOL_DIR, 'web');
const BACKUP_DIR = path.join(AITEMP_DIR, 'backups'); const BACKUP_DIR = path.join(AITEMP_DIR, 'backups');
const CONFIG_FILE = path.join(TOOL_DIR, 'config.json'); const CONFIG_FILE = path.join(TOOL_DIR, 'config.json');
@@ -33,7 +33,7 @@ const CONFIG_FILE = path.join(TOOL_DIR, 'config.json');
// 沙盒默认位置:和游戏同一个盘的根目录下。 // 沙盒默认位置:和游戏同一个盘的根目录下。
// ⚠️ 不要写死具体盘符 —— 别人的游戏不一定装在 L 盘。 // ⚠️ 不要写死具体盘符 —— 别人的游戏不一定装在 L 盘。
// 放在盘根是为了**避开 Steam 库**(放库里 Steam 会扫到,云同步也可能够得着)。 // 放在盘根是为了**避开 Steam 库**(放库里 Steam 会扫到,云同步也可能够得着)。
const DEFAULT_SANDBOX = path.join(path.parse(GAME_DIR).root, 'Nyaruru_shots'); const DEFAULT_SANDBOX = path.join(path.parse(LEGACY_GAME_DIR).root, 'Nyaruru_shots');
const DEFAULTS = { const DEFAULTS = {
cdpPort: 9223, // 沙盒实例的调试端口 cdpPort: 9223, // 沙盒实例的调试端口
@@ -41,8 +41,9 @@ const DEFAULTS = {
webPort: 8787, webPort: 8787,
}; };
function loadConfig() { return SAFETY.readConfig(CONFIG_FILE, DEFAULTS, GAME_DIR); } function loadConfig() { return SAFETY.readConfig(CONFIG_FILE, DEFAULTS, LEGACY_GAME_DIR); }
const CONFIG = loadConfig(); const CONFIG = loadConfig();
const GAME_DIR = CONFIG.gameDir || LEGACY_GAME_DIR;
function classifyGameDir(dir) { function classifyGameDir(dir) {
if (!dir) return 'unknown'; if (!dir) return 'unknown';
try { try {
@@ -108,7 +109,7 @@ function targetDir(body) {
} }
let operationTail = Promise.resolve(); let operationTail = Promise.resolve();
const offlineActions = new Set(['/api/config', '/api/backup', '/api/restore-backup', '/api/sandbox', '/api/shutdown', '/api/story-text']); 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']); const getRoutes = new Set(['/api/status', '/api/snapshot', '/api/trainer', '/api/item-icons', '/api/encyclopedia', '/api/world-map', '/api/combat', '/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() { async function identity() {
const r = await cdp.evalJson(OPS.selfPath()); const r = await cdp.evalJson(OPS.selfPath());
if (!r?.ok) throw new Error(r?.error || '无法识别游戏实例'); if (!r?.ok) throw new Error(r?.error || '无法识别游戏实例');
@@ -172,6 +173,12 @@ async function executeApi(req, res, urlPath, body) {
return cdp.evalJson(current ? guarded(script, current.mapToken) : script); return cdp.evalJson(current ? guarded(script, current.mapToken) : script);
}; };
switch (urlPath) { switch (urlPath) {
case '/api/encyclopedia/travel':return sendJson(res,await game(OPS.catalogTravel(body.mapId,body.eventId)));
case '/api/world-map/travel':return sendJson(res,await game(OPS.worldMapTravel(body.mapId,body.cellX,body.cellY,body.page)));
case '/api/combat': {
if(req.method==='GET')current=await identity();
const r=await game(OPS.combat(req.method==='POST'?body:null));return sendJson(res,{...r,context:current.context});
}
case '/api/shutdown': { case '/api/shutdown': {
if (sandboxJob.running) throw new Error('沙盒仍在复制,请等待任务结束再停止面板'); if (sandboxJob.running) throw new Error('沙盒仍在复制,请等待任务结束再停止面板');
sendJson(res, { ok: true }); sendJson(res, { ok: true });
@@ -191,7 +198,7 @@ async function executeApi(req, res, urlPath, body) {
// 顺手问一句:游戏窗口自己是从哪个目录加载的? // 顺手问一句:游戏窗口自己是从哪个目录加载的?
// 面板据此显示"当前连接:沙盒 / 原版",把"以为在沙盒、其实改了原版"这种 // 面板据此显示"当前连接:沙盒 / 原版",把"以为在沙盒、其实改了原版"这种
// 误用摆在明面上(见 复查与隐患.md 坑 5)。 // 将实际实例身份显示给用户;约定见 tool/开发文档.md。
let gameSelf = null; let gameSelf = null;
if (connected) { try { gameSelf = await identity(); } catch { connected = false; } } if (connected) { try { gameSelf = await identity(); } catch { connected = false; } }
if (!connected) { if (!connected) {
@@ -215,6 +222,24 @@ async function executeApi(req, res, urlPath, body) {
} }
// --- 游戏状态快照 --- // --- 游戏状态快照 ---
case '/api/world-map': {
if (req.method !== 'GET') throw new Error('世界地图为只读功能');
const before = await identity();
const r = await cdp.evalJson(guarded(OPS.worldMap(), before.mapToken));
return sendJson(res, { ...r, context: before.context });
}
case '/api/encyclopedia': {
if (req.method !== 'GET') throw new Error('百科为只读功能');
const before = await identity();
const r = await cdp.evalJson(guarded(OPS.encyclopedia(), before.mapToken));
return sendJson(res, { ...r, context: before.context });
}
case '/api/item-icons': {
if (req.method !== 'GET') throw new Error('图标为只读功能');
const before = await identity();
const r = await cdp.evalJson(guarded(OPS.itemIcons(), before.mapToken));
return sendJson(res, { ...r, context:before.context });
}
case '/api/trainer': { case '/api/trainer': {
if (req.method === 'GET') current = await identity(); if (req.method === 'GET') current = await identity();
const r = await game(OPS.trainer(req.method === 'POST' ? body : null)); const r = await game(OPS.trainer(req.method === 'POST' ? body : null));
@@ -264,6 +289,7 @@ async function executeApi(req, res, urlPath, body) {
})); }));
return sendJson(res, r); return sendJson(res, r);
} }
case '/api/nudge': return sendJson(res, await game(OPS.nudge(body.dx, body.dy, body.pixels)));
// --- 直接播放一段剧情序列 --- // --- 直接播放一段剧情序列 ---
// 绕开地图事件与开关条件,用于通关存档上复现"错过就没了"的对话 // 绕开地图事件与开关条件,用于通关存档上复现"错过就没了"的对话
@@ -345,7 +371,7 @@ async function executeApi(req, res, urlPath, body) {
case '/api/restore': return sendJson(res, await game(OPS.restoreAll())); case '/api/restore': return sendJson(res, await game(OPS.restoreAll()));
// --- 场景还原(摆拍):扫描本图丢失的角色 → 强行切页让它们出现 --- // --- 场景还原(摆拍):扫描本图丢失的角色 → 强行切页让它们出现 ---
// 静态摆拍与撤销由 photo-state 管理;自动和手动存档照常,仅提示风险。 // 静态摆拍与撤销由 photo-state 管理,自动和手动存档照常,仅提示风险。
case '/api/events/lost': { case '/api/events/lost': {
const before = await identity(); const before = await identity();
const r = await cdp.evalJson(guarded(OPS.scanLostEvents(), before.mapToken)); const r = await cdp.evalJson(guarded(OPS.scanLostEvents(), before.mapToken));
+103
View File
@@ -0,0 +1,103 @@
const test=require('node:test');
const assert=require('node:assert/strict');
const vm=require('node:vm');
const {catalogCellBounds,findCatalogLanding}=require('../lib/catalog-position');
const {OPS}=require('../lib/ops');
test('catalog cell bounds use the actual submap cell and coordinate offsets',()=>{
const thumbnail={minX:35,maxX:35,minY:32,maxY:35,data:[{x:35,y:32},{x:35,y:33},{x:35,y:34},{x:35,y:35}]};
assert.deepEqual(catalogCellBounds({width:20,height:64,meta:{}},thumbnail,{x:35,y:34}),{left:0,top:32,right:20,bottom:48,cellX:35,cellY:34});
assert.deepEqual(catalogCellBounds({width:20,height:64,meta:{cellOx:2,cellOy:3}},thumbnail,{x:35,y:34}),{left:0,top:29,right:18,bottom:45,cellX:35,cellY:34});
assert.throws(()=>catalogCellBounds({},thumbnail,{x:99,y:99}),/分块信息/);
});
test('catalog landing checks the whole body, avoids walls and refines standing height above thin ground',()=>{
const bounds={left:0,top:12,right:18,bottom:24},shape={left:20,right:20,top:80,bottom:20};
const floorY=23*96+7;
const hitsAt=(px,py)=>px-shape.left<96?[{terrain:true,type:0}]:py+shape.bottom>=floorY?[{terrain:true,type:0}]:[];
const pick=findCatalogLanding({bounds,shape,tileWidth:96,tileHeight:96,hitsAt});
assert.equal(pick.grounded,true);assert.equal(hitsAt(pick.px,pick.py).length,0);
assert.ok(pick.py+shape.bottom<floorY);assert.ok(floorY-(pick.py+shape.bottom)<=1);
assert.ok(pick.px-shape.left>=96);assert.ok(pick.py-shape.top>=bounds.top*96);
assert.ok(pick.py+shape.bottom<=bounds.bottom*96);
});
test('catalog landing refuses blocked or undersized cells and bounds its search',()=>{
const args={bounds:{left:0,top:0,right:4,bottom:4},shape:{left:20,right:20,top:80,bottom:20},tileWidth:96,tileHeight:96};
assert.equal(findCatalogLanding({...args,hitsAt:()=>[{terrain:true,type:0}]}),null);
assert.equal(findCatalogLanding({...args,bounds:{left:0,top:0,right:1,bottom:0.5},hitsAt:()=>[]}),null);
const limited=findCatalogLanding({...args,maxProbes:2,hitsAt:()=>[]});assert.equal(limited.grounded,false);assert.ok(limited.probes<=3);
const floating=findCatalogLanding({...args,hitsAt:()=>[]});assert.equal(floating.grounded,false);
const hazardous=findCatalogLanding({...args,hitsAt:()=>[{terrain:true,type:2}]});assert.equal(hazardous,null);
});
function runtime({blocked=false,blockAfterTransfer=false,blockedTargetCell=false}={}){
class Character{
constructor(){Object.assign(this,{px:500,py:500,_vx:0,_vy:0,dotX:0,dotY:0,_direction:6});}
hitOffsetL(){return this._hitOffsetL??24;}hitOffsetR(){return this._hitOffsetR??24;}
hitOffsetT(){return this._hitOffsetT??72;}hitOffsetB(){return this._hitOffsetB??16;}
tileXToCenterX(x){return x*96+48;}tileYToCenterY(y){return y*96+80;}
locate(x,y){this._x=x;this._y=y;this.unflush(x,y);}unflush(x,y){this.px=this.tileXToCenterX(x);this.py=this.tileYToCenterY(y);}
suspendGravity(){this._suspendGravity=true;}resumeGravity(){this._suspendGravity=false;}
suspendPhysicsHitTest(){this._suspendPhysicsHitTest=true;}resumePhysicsHitTest(){this._suspendPhysicsHitTest=false;}
isSuspendGravity(){return !!this._suspendGravity;}clearImpact(){}clearGrounding(){}
doGravity(){}makeMomentum(){}cacheDeltaTimeSpeed(){}isRemove(){return false;}
}
class Grid{
rebuild(){}
getObjGrid(p,hits){if(this.blocked||(this.blockedTargetCell&&p.px<10*96)||p.py+p.hitOffsetB()>=14*96)hits.push({terrain:true,type:0});}
}
class Map{
mapId(){return this._mapId;}tileWidth(){return 96;}tileHeight(){return 96;}width(){return 20;}height(){return 16;}
tilemapTerrain(){}isAnyEventStarting(){return false;}
}
const p=new Character(),map=new Map(),terrain=new Grid();map._mapId=1;map._terrain=terrain;terrain.blocked=blocked;terrain.blockedTargetCell=blockedTargetCell;
const data={width:20,height:16,meta:{},events:[null,{id:1,x:10,y:10}]};
const context=vm.createContext({window:{},Game_CharacterBase:Character,Game_Map:Map,PhysicsGrid:Grid,
$gamePlayer:p,$gameLily:null,$gameActors:{},$gameMap:map,$dataMap:data,$dataMapInfos:[null,{},{}],
SceneManager:{_scene:{constructor:{name:'Scene_Map'}}},setTimeout:fn=>fn(),
DataManager:{getCachedData:()=>data,getThumbnailCell:(x,y,id)=>({x:0,y:0,mapId:id}),
getThumbnailMapInfo:()=>({minX:0,maxX:blockedTargetCell?1:0,minY:0,maxY:0,data:blockedTargetCell?[{x:0,y:0},{x:1,y:0}]:[{x:0,y:0}]})}});
const transfers=[];
p.reserveTransfer=(id,x,y)=>{transfers.push(id);map._mapId=id;map._terrain=new Grid();map._terrain.blocked=id===2&&blockAfterTransfer;context.$dataMap=data;p._x=x;p._y=y;};
p.isTransferring=()=>false;
p.locate=()=>{throw Error('Catalog must use base locate, without the native player grounding adjustment');};
return{context,p,map,transfers};
}
test('catalog travel validates before moving and arrives with hover and collision',async()=>{
const f=runtime();const r=JSON.parse(await vm.runInContext(OPS.catalogTravel(1,1),f.context));
assert.equal(r.ok,true);assert.equal(r.gravitySuspended,true);assert.equal(r.noclip,false);
assert.equal(r.catalogCell.cellX,0);assert.ok(f.p.py+f.p.hitOffsetB()<14*96);
const bad=runtime({blocked:true}),before=[bad.p.px,bad.p.py];
const refused=JSON.parse(await vm.runInContext(OPS.catalogTravel(1,1),bad.context));
assert.equal(refused.ok,false);assert.deepEqual([bad.p.px,bad.p.py],before);assert.deepEqual(bad.transfers,[]);
});
test('a blocked target cell expands to another cell in the same map and still teleports',async()=>{
const f=runtime({blockedTargetCell:true});
const r=JSON.parse(await vm.runInContext(OPS.catalogTravel(1,1),f.context));
assert.equal(r.ok,true);assert.equal(r.catalogCell.cellX,1);assert.ok(f.p.px>10*96);
assert.match(r.positionNote,/同图其他可用位置/);assert.equal(r.noclip,false);
});
test('catalog travel rechecks the loaded destination and returns on newly blocked geometry',async()=>{
const f=runtime({blockAfterTransfer:true}),before=[f.p.px,f.p.py];
const r=JSON.parse(await vm.runInContext(OPS.catalogTravel(2,1),f.context));
assert.equal(r.ok,false);assert.match(r.error,/返回原位置/);
assert.deepEqual(f.transfers,[2,1]);assert.equal(f.map.mapId(),1);assert.deepEqual([f.p.px,f.p.py],before);
});
test('world cell travel validates map and page before moving, and supports empty cells with safe fallback',async()=>{
assert.throws(()=>OPS.worldMapTravel(1,0,0,'1'),/坐标无效/);
const f=runtime({blockedTargetCell:true});
f.context.DataManager._thumbnailMapCell={a:{mapId:1,x:0,y:0,page:3},b:{mapId:1,x:1,y:0,page:3}};
const before=[f.p.px,f.p.py];
for(const args of [[1,0,0,4],[2,0,0,3],[1,99,0,3]]){
const r=JSON.parse(await vm.runInContext(OPS.worldMapTravel(...args),f.context));
assert.equal(r.ok,false);assert.match(r.error,/不属于/);assert.deepEqual([f.p.px,f.p.py],before);
}
assert.deepEqual(f.transfers,[]);
const r=JSON.parse(await vm.runInContext(OPS.worldMapTravel(1,0,0,3),f.context));
assert.equal(r.ok,true);assert.equal(r.catalogCell.cellX,1);assert.equal(r.noclip,false);assert.equal(r.gravitySuspended,true);
});
+22
View File
@@ -0,0 +1,22 @@
const test=require('node:test'),assert=require('node:assert/strict'),vm=require('node:vm');
const {installCombat}=require('../lib/combat');
test('combat scales player attacks and incoming damage, supports bullets and respects healing and scripted immunity',()=>{
function Character(team){this.battle={team,hp:100,unlimitHp:false};}
Character.prototype.battleObject=function(){return this.battle;};
Character.prototype.overrideBeAttackDamage=function(d){return d;};
Character.prototype.onAttackHit=function(info,target){const d=target.overrideBeAttackDamage(info.damage);if(!target.battle.unlimitHp)target.battle.hp-=d;return d;};
Character.prototype.requestDamage=function(d){this.battle.hp-=d;};
const p=new Character(0),lily=new Character(0),enemy=new Character(1),other=new Character(1),timers=[];
const c=vm.createContext({window:{},Game_CharacterBase:Character,$gamePlayer:p,$gameLily:lily,$gameActors:{},setInterval:fn=>timers.push(fn)});
const api=vm.runInContext(`(${installCombat.toString()})()`,c);
// The real Player and Event classes override the base method independently.
enemy.overrideBeAttackDamage=function(d){return d;};p.overrideBeAttackDamage=function(d){return d;};
api.change({outgoing:3,incoming:0.5});assert.equal(p.onAttackHit({damage:10},enemy),30);
assert.equal(enemy.onAttackHit({damage:10},p),5);p.requestDamage(10);assert.equal(p.battle.hp,90);
assert.equal(other.onAttackHit({damage:10,bulletOwner:p},enemy),30);
assert.equal(enemy.onAttackHit({damage:-10},p),-10);
api.change({oneHit:true});p.onAttackHit({damage:1},enemy);assert.ok(enemy.battle.hp<=0);
enemy.battle.hp=100;enemy.battle.unlimitHp=true;p.onAttackHit({damage:1},enemy);assert.equal(enemy.battle.hp,100);
assert.throws(()=>api.change({outgoing:Infinity}));assert.throws(()=>api.change({incoming:-1}));
c.$gameActors={};api.read();assert.equal(api.read().outgoing,1);assert.equal(api.read().oneHit,false);
});
+49
View File
@@ -0,0 +1,49 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
function fixture() {
const source=fs.readFileSync(path.join(__dirname,'../web/app.js'),'utf8');
const start=source.indexOf('// ---------- 收集百科 ----------'),end=source.indexOf('// ---------- 标签页 ----------',start);
assert.ok(start>=0&&end>start);
const a={key:'ornaments:0:14:1:0:9',type:'ornaments',id:0,mapId:14,mapName:'MAP014',area:'森林',
eventId:1,pageIndex:0,x:9,y:22,collected:false,conditions:[]};
const b={...a,key:'ornaments:0:88:2:0:9',mapId:88,mapName:'MAP088',area:'城堡',eventId:2,x:0,y:0};
// HTTP JSON duplicates shared objects; item.locations and locations no longer
// contain identical object references even though their keys still match.
const encyclopedia=JSON.parse(JSON.stringify({context:'current',locations:[a,b],shops:[{...a,x:5,y:6}],
items:[{type:'ornaments',id:0,name:'挂件',desc:'效果',count:0,shopCandidate:true,price:100,locations:[a,b]}]}));
const elements=new Map(),calls=[],messages=[];let refreshed=0;
const $=key=>{if(!elements.has(key))elements.set(key,{value:'',innerHTML:'',replaceChildren(){}});return elements.get(key);};
const context=vm.createContext({$,ENCYCLOPEDIA:encyclopedia,ENCYCLOPEDIA_BUSY:false,ENCYCLOPEDIA_REVISION:0,
CURRENT_CONTEXT:'current',CURRENT_GAME:{inMap:true},ITEM_TYPES:{ornaments:'配饰'},esc:v=>String(v??''),
api:async(route,body)=>{calls.push({route,body:JSON.parse(JSON.stringify(body))});return{ok:true,positionNote:'目标分块边缘附近的可落脚空处'};},
toast:(message,kind)=>messages.push({message,kind}),refreshStatus:()=>refreshed++});
vm.runInContext(source.slice(start,end),context);
const click=(attribute,value)=>$('#encyclopedia-items').onclick({target:{closest:()=>({
hasAttribute:key=>key===attribute,dataset:{location:value,shop:value}})}});
return{context,$,calls,messages,encyclopedia,click,refreshed:()=>refreshed};
}
test('encyclopedia buttons resolve JSON-loaded and filtered locations by stable key',async()=>{
const f=fixture();assert.equal(f.encyclopedia.locations.indexOf(f.encyclopedia.items[0].locations[0]),-1);
f.$('#encyclopedia-area').value='城堡';vm.runInContext('renderEncyclopedia()',f.context);
const html=f.$('#encyclopedia-items').innerHTML;
assert.match(html,/data-location="ornaments:0:88:2:0:9"/);assert.doesNotMatch(html,/data-location="-1"|data-location="ornaments:0:14/);
await f.click('data-location','ornaments:0:88:2:0:9');
assert.deepEqual(f.calls,[{route:'/encyclopedia/travel',body:{mapId:88,eventId:2}}]);
assert.match(f.messages[0].message,/MAP088.*分块边缘.*碰撞已恢复/);assert.equal(f.refreshed(),1);
await f.click('data-location','ornaments:0:14:1:0:9');
assert.deepEqual(f.calls[1],{route:'/encyclopedia/travel',body:{mapId:14,eventId:1}});
});
test('encyclopedia still rejects stale locations and menu movement; shop travel keeps working',async()=>{
const f=fixture();f.context.CURRENT_CONTEXT='another-map';
await f.click('data-location','ornaments:0:14:1:0:9');assert.equal(f.calls.length,0);assert.match(f.messages.at(-1).message,/刷新百科/);
f.context.CURRENT_CONTEXT='current';f.context.CURRENT_GAME.inMap=false;
await f.click('data-location','ornaments:0:14:1:0:9');assert.equal(f.calls.length,0);assert.match(f.messages.at(-1).message,/关闭游戏菜单/);
f.context.CURRENT_GAME.inMap=true;await f.click('data-location','missing');assert.equal(f.calls.length,0);
await f.click('data-shop','0');assert.deepEqual(f.calls,[{route:'/encyclopedia/travel',body:{mapId:14,eventId:1}}]);
});
+30
View File
@@ -0,0 +1,30 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const vm = require('node:vm');
const { readEncyclopedia } = require('../lib/encyclopedia');
test('encyclopedia separates ownership from collected flags and never guesses branch rewards', () => {
const flags = {'14,1,A':true};
const add=(type,idx,indent=0)=>({code:357,indent,parameters:['NYA.Utils','addItem','',{type,idx:String(idx),antiCollection:'false'}]});
const event=(id,list,conditions={})=>({id,name:'pickup',x:id+1,y:4,pages:[{conditions,list}]});
const map={events:[null,event(1,[add('hpst',0)]),event(2,[add('hpst',0)]),event(3,[add('ornaments',0,1),{code:123,indent:1,parameters:['A',0]}]),
event(4,[{code:357,parameters:['NYA.Utils','shop','',{}]}]),event(5,[{code:357,parameters:['NYA.Utils','add_coin','',{num:'-20'}]}]),
event(6,[{code:357,parameters:['NYA.EVENT.AI_Adapter','ai','',{type:'60',battleObject:'104'}]}]) ]};
const inventory={subject:'actor',stats:{coin:{value:100}},items:[{type:'hpst',id:0,name:'逗猫棒',count:0},{type:'ornaments',id:0,name:'挂件',count:1}]};
const actor={_ornaments:{data:()=>({0:{price:900,openTask:0}})},hpStrengthData:()=>({price:500})};
const context=vm.createContext({inventory,$gamePlayer:{actor:()=>actor},DataManager:{_cacheMapData:{14:map},mapRegion:()=>2},
$dataRegion:{2:{name:'幽暗之森',exploreItem:[[14,1],[14,2],[14,6]]}},$dataMapInfos:{14:{name:'MAP014'}},$dataSystem:{switches:[],variables:[]},
$gameSwitches:{value:()=>false},$gameVariables:{value:()=>0},$gameSelfSwitches:{value:key=>!!flags[key.join(',')]}});
const result=JSON.parse(vm.runInContext(`JSON.stringify((${readEncyclopedia.toString()})(inventory))`,context));
assert.equal(result.items[0].count,0); assert.equal(result.items[0].locations[0].collected,true); assert.equal(result.items[0].locations[1].collected,false);
assert.equal(result.items[1].locations[0].collected,null); assert.equal(result.items[1].shopCandidate,true);
assert.equal(result.locations.length,4); assert.equal(result.shops.length,1);
assert.equal(result.items[2].locations[0].kind,'coinpile'); assert.equal(result.items[2].locations[0].collected,false);
assert.deepEqual(result.stats,{items:3,locations:4,shops:1,total:3,collected:1});
assert.deepEqual(flags,{'14,1,A':true}); assert.equal(inventory.items[0].locations,undefined);
});
test('encyclopedia reports missing runtime map data instead of empty success', () => {
const context=vm.createContext({DataManager:{_cacheMapData:{}},inventory:{}});
assert.throws(()=>vm.runInContext(`(${readEncyclopedia.toString()})(inventory)`,context),/尚未加载/);
});
+15
View File
@@ -0,0 +1,15 @@
const test=require('node:test');
const assert=require('node:assert/strict');
const vm=require('node:vm');
const {readItemIcons}=require('../lib/item-icons');
test('item thumbnails use the native icon frames without creating sprites or writing files',async()=>{
const draws=[];
const bitmap={width:384,height:192,isReady:()=>true,canvas:{source:true}};
const c=vm.createContext({items:[{type:'consumables',id:0},{type:'ornaments',id:25}],setTimeout,clearTimeout,
Sprite_ItemIcon:{prototype:{itemBitmap:()=>bitmap,refreshIdx(id){this.setFrame(id===25?32:0,id===25?160:32,32,32);}}},
document:{createElement:()=>({getContext:()=>({drawImage(...args){draws.push(args.slice(1));}}),toDataURL:()=> 'data:image/png;base64,dGVzdA=='})}});
const result=JSON.parse(await vm.runInContext(`(${readItemIcons.toString()})(items).then(JSON.stringify)`,c));
assert.equal(result.ok,true);assert.equal(Object.keys(result.icons).length,2);
assert.deepEqual(draws[0],[0,32,32,32,0,0,32,32]);assert.deepEqual(draws[1],[32,160,32,32,0,0,32,32]);
});
+187
View File
@@ -0,0 +1,187 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const vm = require('node:vm');
const { installMotion } = require('../lib/motion');
const { OPS } = require('../lib/ops');
function fixture() {
let now = 0;
const timers = [];
const character = () => ({ _x: 3, _y: 4, px: 3 * 96 + 40, py: 4 * 96 + 80, _vx: 0, _vy: 12,
get vx() { return this._vx; }, set vx(v) { this._vx = v; },
get vy() { return Math.round(this._vy); }, set vy(v) { this._vy = v; },
g: () => 0, deltaTime: () => 1, doGravity() { this._vy += 0.5; }, updateMapBoundTransfer() { this.transfer = true; },
makeMomentum() { this.rx = this.px + this._vx; this.ry = this.py + this._vy; },
cacheDeltaTimeSpeed() { this._vx *= this.deltaTime(); this._vy *= this.deltaTime(); },
startTestV() { if(!this._testV){this._testV=true;this._tvx=this._vx;this._tvy=this._vy;} },
testV(v) {this.vx=v.x;this.vy=v.y;this.makeMomentum();},
endTestV() {if(this._testV){this.vx=this._tvx;this.vy=this._tvy;this._testV=false;this.makeMomentum();}},
tileXToCenterX: x => x * 96 + 40, tileYToCenterY: y => y * 96 + 80,
locate(x,y) { this._x = Math.round(x); this._y = Math.round(y); this.unflush(x,y); },
unflush(x,y) { this.px = Math.round(x * 96 + 40); this.py = Math.round(y * 96 + 80); },
clearImpact() {}, clearGrounding() {}, suspendGravity() { this._suspendGravity = true; }, resumeGravity() { this._suspendGravity = false; },
suspendPhysicsHitTest() { this._suspendPhysicsHitTest = true; }, resumePhysicsHitTest() { this._suspendPhysicsHitTest = false; },
isSuspendGravity() { return !!this._suspendGravity; }, isRemove() { return false; },
hitPreview() { return []; },
isFlexibleGrounding() { return !!this.grounded; }, isTriggeredJump() { return !!this.jumpPressed; },
isPressDown() { return !!this.downPressed; }, isOutControl() { return !!this.blocked; }, jumpStrength() { return -8; },
controlJump() { if(this.grounded && this.jumpPressed)this.vy=-8; },
controlDoubleJump() { if(!this.grounded && !this.useDoubleJump && this.jumpPressed) { this.vy=-6; this.useDoubleJump=true; } },
});
const p = character(), lily = character();
const context = vm.createContext({ window: {}, $gamePlayer:p, $gameLily:lily, $dataMap: {},
$gameMap: { g: () => 0.5, width: () => 20, height: () => 12, tileWidth: () => 96, tileHeight: () => 96, mapId: () => 1 },
SceneManager: { _scene: { constructor: { name:'Scene_Map' } } },
Date: { now: () => now }, setInterval: fn => timers.push(fn),
});
const api = vm.runInContext(`(${installMotion.toString()})()`, context);
return { context, api, p, lily, advance(ms) { now += ms; timers.forEach(fn=>fn()); } };
}
test('hover stops falling, slows upward jump to zero, supports inverted gravity and restores hooks', () => {
const f=fixture(), original=f.p.doGravity, transfer=f.p.updateMapBoundTransfer;
f.api.enable(f.p); assert.equal(f.p._vy,0); assert.equal(f.p._suspendGravity,true);
const saved=JSON.parse(JSON.stringify(f.p));
assert.equal(Object.hasOwn(saved,'_suspendGravity'),false);
assert.equal(Object.hasOwn(saved,'_suspendPhysicsHitTest'),false);
f.p._vy=-8; const speeds=[];
for(let i=0;i<40;i++) { f.p.doGravity(); speeds.push(f.p._vy); }
assert.equal(speeds[0],-7.5); assert.equal(speeds.at(-1),0); assert.ok(speeds.every(v=>v<=0));
f.p._vy=20; f.p.doGravity(); assert.equal(f.p._vy,0);
f.p._vy=20; f.p.cacheDeltaTimeSpeed(); f.p.makeMomentum(); assert.equal(f.p.ry,f.p.py);
f.p._testV=true; f.p._vy=5; f.p.makeMomentum(); assert.equal(f.p.ry,f.p.py+5); f.p._testV=false;
f.p._invertMode=true; f.p._vy=8;
for(let i=0;i<40;i++) f.p.doGravity();
assert.equal(f.p._vy,0);
f.p.updateMapBoundTransfer(); assert.equal(f.p.transfer,true);
assert.equal(Object.keys(f.p).includes('doGravity'),false);
f.api.clear(); assert.equal(f.p.doGravity,original); assert.equal(f.p.updateMapBoundTransfer,transfer);
assert.equal(f.p._suspendGravity,false); assert.equal(f.p._suspendPhysicsHitTest,false);
});
test('noclip enables hover for both characters and refuses to restore collision inside terrain', async () => {
const f=fixture();
const run=async code=>JSON.parse(await vm.runInContext(code,f.context));
assert.equal((await run(OPS.setNoclip(true))).ok,true);
for(const c of [f.p,f.lily]) { assert.equal(c._suspendGravity,true); assert.equal(c._suspendPhysicsHitTest,true); c._vy=99; c.doGravity(); assert.equal(c._vy,0); }
f.p.hitPreview=()=>[{terrain:true,type:0}];
assert.equal((await run(OPS.setNoclip(false))).ok,false); assert.equal(f.p._suspendPhysicsHitTest,true);
f.p.hitPreview=()=>[];
assert.equal((await run(OPS.setNoclip(false))).ok,true); assert.equal(f.p._suspendPhysicsHitTest,false); assert.equal(f.p._suspendGravity,true);
});
test('pixel nudge uses physical position, accumulates exact small steps and respects map bounds', async () => {
const f=fixture(), run=async code=>JSON.parse(await vm.runInContext(code,f.context));
f.p.px=500; f.p.py=600;
const first=await run(OPS.nudge(1,0,4)); assert.equal(first.px,504); assert.equal(first.py,600);
const next=await run(OPS.nudge(1,0,4)); assert.equal(next.px,508);
f.p.px=40; assert.equal((await run(OPS.nudge(-1,0,16))).px,40);
f.p.py=80; assert.equal((await run(OPS.nudge(0,-1,1))).py,80);
f.p.px=99999; f.p.py=99999; f.p.updateMapBoundTransfer(); assert.equal(f.p.px,19*96+40); assert.equal(f.p.py,11*96+80);
assert.throws(()=>OPS.nudge(1,1,4)); assert.throws(()=>OPS.nudge(1,0,Infinity));
});
test('hover survives normal map changes but releases on save load and heartbeat expiry', () => {
const f=fixture(); f.api.enable(f.p,true); f.advance(120001);
assert.equal(f.api.has(f.p),false); assert.equal(f.p._suspendGravity,false);
f.api.enable(f.p,true); f.context.$dataMap={}; f.p.resumeGravity();f.p.resumePhysicsHitTest();f.advance(1);
assert.equal(f.api.has(f.p),true);assert.equal(f.p.isSuspendGravity(),true);assert.equal(f.p._suspendPhysicsHitTest,true);
f.context.$gameMap={...f.context.$gameMap};f.advance(1);
assert.equal(f.api.has(f.p),false);
});
test('hover allows repeated airborne jumps, manual descent, and preserves downward skill movement', () => {
const f=fixture(), jump=f.p.controlJump, double=f.p.controlDoubleJump;
f.p.useDoubleJump=true; f.api.enable(f.p); f.p.jumpPressed=true;
for(let i=0;i<5;i++) { f.p._vy=0; f.p.controlJump({}); f.p.controlDoubleJump({}); assert.equal(f.p._vy,-8); }
assert.equal(f.p.useDoubleJump,true);
f.p.jumpPressed=false; f.p.downPressed=true; f.p.doGravity(); f.p.makeMomentum();
assert.equal(f.p.ry-f.p.py,4);
f.p.downPressed=false; f.p.doGravity(); f.p.makeMomentum(); assert.equal(f.p.ry,f.p.py);
f.p._bodyAttacking='toru_down_dash_attack'; f.p._vy=22; f.p.doGravity(); f.p.makeMomentum(); assert.equal(f.p.ry-f.p.py,22);
f.p.downPressed=true; f.p._vy=25; f.p.doGravity(); f.p.makeMomentum(); assert.equal(f.p.ry-f.p.py,25);
f.p._bodyAttacking=''; f.p.downPressed=false; f.p.doGravity(); f.p.makeMomentum(); assert.equal(f.p.ry,f.p.py);
f.p.blocked=true; f.p.downPressed=true; f.p.doGravity(); assert.equal(f.p._vy,0);
f.api.clear(); assert.equal(f.p.controlJump,jump); assert.equal(f.p.controlDoubleJump,double);
});
test('native skill and stagger cleanup cannot cancel tool hover; disabling restores the native query', async () => {
const f=fixture(), originalQuery=f.p.isSuspendGravity;
f.api.enable(f.p);
// The original game does this when a skill or stagger ends.
f.p.resumeGravity();
assert.equal(f.p._suspendGravity,false);
assert.equal(f.api.has(f.p),true);
assert.equal(f.p.isSuspendGravity(),true);
f.p._vy=20; f.p.doGravity(); f.p.makeMomentum();
assert.equal(f.p.ry,f.p.py);
f.advance(15000);
f.p.isInvincible=()=>false;
Object.assign(f.context,{$dataMapInfos:[], $dataSystem:{switches:[]}, $gameSwitches:{value:()=>false}, Graphics:{}});
const snap=JSON.parse(await vm.runInContext(OPS.snapshot(),f.context));
assert.equal(snap.ok,true);
assert.equal(snap.gravitySuspended,true);
assert.equal(f.p.isSuspendGravity(),true);
assert.equal(Object.keys(f.p).includes('isSuspendGravity'),false);
f.api.disable(f.p);
assert.equal(f.p.isSuspendGravity,originalQuery);
assert.equal(f.p.isSuspendGravity(),false);
// Native skill suspension must not turn the panel's tool switch back on.
f.p.suspendGravity();
assert.equal(JSON.parse(await vm.runInContext(OPS.snapshot(),f.context)).gravitySuspended,false);
});
test('hot upgrading an active v4 helper preserves hover, noclip and velocity', () => {
const f=fixture(), nativeQuery=f.p.isSuspendGravity;
f.api.enable(f.p,true); f.p._vx=3; f.p._vy=-7;
f.api.version=4;
const next=vm.runInContext(`(${installMotion.toString()})()`,f.context);
assert.equal(next.version,5); assert.equal(next.has(f.p),true);
assert.equal(f.p._suspendPhysicsHitTest,true);
assert.equal(f.p._vx,3); assert.equal(f.p._vy,-7);
f.p.resumeGravity(); assert.equal(f.p.isSuspendGravity(),true);
next.clear(); assert.equal(f.p.isSuspendGravity,nativeQuery);
assert.equal(f.p.isSuspendGravity(),false);
});
test('collision-resolved motion is preserved through grounding probes while holding down', () => {
const f=fixture(), momentum=f.p.makeMomentum,cache=f.p.cacheDeltaTimeSpeed;
f.api.enable(f.p); f.p.downPressed=true;
assert.equal(f.p.makeMomentum,momentum);
for(let i=0;i<30;i++){
f.p.doGravity();f.p.cacheDeltaTimeSpeed();f.p.makeMomentum();
assert.equal(f.p._vy,4);
// Native solid-floor collision resolves to no vertical displacement.
f.p._vy=0;f.p.makeMomentum();
// generateObjectPhysicsInfo probes below, then restores resolved velocity.
f.p.startTestV();f.p.testV({x:0,y:1});f.p.endTestV();
assert.equal(f.p._vy,0);assert.equal(f.p.ry,f.p.py);
}
f.p.deltaTime=()=>0.5;f.p.cacheDeltaTimeSpeed();assert.equal(f.p._vy,2);
f.api.clear();assert.equal(f.p.cacheDeltaTimeSpeed,cache);
});
test('native map exits run before the safety clamp; noclip side exits use native destinations', () => {
const f=fixture();let nativeCalls=0,exitType=null,transferring=false;
f.p.hitOffsetL=f.p.hitOffsetR=()=>12;f.p.hitOffsetT=()=>64;f.p.hitOffsetB=()=>16;
f.p.updateMapBoundTransfer=()=>{nativeCalls++;};f.p.isTransferring=()=>transferring;
f.p.autoTransfer=type=>{exitType=type;transferring=true;return true;};
f.api.enable(f.p,true);f.p.px=-5;f.p.py=600;f.p.updateMapBoundTransfer();
assert.equal(nativeCalls,1);assert.equal(exitType,3);assert.equal(f.p.px,-5);
transferring=false;f.p.autoTransfer=()=>false;f.p.updateMapBoundTransfer();assert.equal(f.p.px,12);
f.p.updateMapBoundTransfer();assert.equal(nativeCalls,3);
f.api.setNoclip(f.p,false);f.context.$dataMap={};f.api.touch();assert.equal(f.p._suspendPhysicsHitTest,false);
assert.equal(f.api.has(f.p),true);
});
test('normal map transitions retain helpers; transient loading and replaced actors do not revive old helpers', () => {
const f=fixture();f.context.$gameActors={};f.api.touch();f.api.enable(f.p,true);
f.context.$dataMap=null;f.context.SceneManager.isSceneChanging=()=>true;
f.api.touch();assert.equal(f.api.has(f.p),true);
f.context.$dataMap={};f.context.SceneManager.isSceneChanging=()=>false;
f.p.resumePhysicsHitTest();f.p._vy=40;f.api.touch();
assert.equal(f.p._suspendPhysicsHitTest,true);assert.equal(f.p._vy,0);
f.context.$gameActors={};f.api.touch();
assert.equal(f.api.has(f.p),false);assert.equal(f.p.isSuspendGravity(),false);
f.context.$dataMap={};f.api.touch();assert.equal(f.api.has(f.p),false);
});
+498
View File
@@ -0,0 +1,498 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const path = require('path');
const os = require('os');
const vm = require('vm');
const { EventEmitter } = require('events');
const SAFETY = require('../lib/safety');
const FILES = require('../lib/files');
const { OPS } = require('../lib/ops');
const { loadAll } = require('../lib/data');
const { storyPosition, chooseStoryPosition } = require('../lib/story-position');
function fixture(t) {
const root = fs.realpathSync.native(fs.mkdtempSync(path.join(os.tmpdir(), 'nyaruru-regression-')));
t.after(() => {
const resolved = path.resolve(root);
assert.equal(path.dirname(resolved), fs.realpathSync.native(os.tmpdir()));
assert.match(path.basename(resolved), /^nyaruru-regression-/);
fs.rmSync(resolved, { recursive: true, force: true });
});
const game = path.join(root, 'game'), sandbox = path.join(root, 'sandbox'), backup = path.join(root, 'backups');
fs.mkdirSync(path.join(game, 'save'), { recursive: true });
fs.mkdirSync(path.join(game, 'aitemp', 'tool'), { recursive: true });
fs.writeFileSync(path.join(game, 'save', 'file1.rmmzsave'), 'first');
fs.writeFileSync(path.join(game, 'save', 'global.rmmzsave'), 'global');
fs.writeFileSync(path.join(game, 'save', 'steam_autocloud.vdf'), 'cloud');
fs.writeFileSync(path.join(game, 'package.json'), '{}');
fs.writeFileSync(path.join(game, 'index.html'), 'game');
return { root, game, sandbox, backup, tool: path.join(game, 'aitemp', 'tool') };
}
test('reject overlapping paths, relative paths and identical ports at startup and save', t => {
const f = fixture(t), defaults = { cdpPort: 9223, webPort: 8787, sandboxDir: f.sandbox };
assert.equal(SAFETY.validateConfig(defaults, f.game).sandboxDir, f.sandbox);
for (const patch of [{ cdpPort: 8787 }, { sandboxDir: f.game }, { sandboxDir: f.root }, { sandboxDir: path.join(f.game, 'nested') }, { sandboxDir: 'relative' }]) {
assert.throws(() => SAFETY.validateConfig({ ...defaults, ...patch }, f.game));
}
const file = path.join(f.root, 'config.json');
fs.writeFileSync(file, '{bad'); assert.throws(() => SAFETY.readConfig(file, defaults, f.game), /损坏/);
fs.writeFileSync(file, JSON.stringify({ webPort: 9223 })); assert.throws(() => SAFETY.readConfig(file, defaults, f.game), /不能相同/);
});
test('standalone layout uses configured game directory and rejects incomplete or overlapping targets', t => {
const f = fixture(t);
const defaults = { cdpPort: 9224, webPort: 8788, sandboxDir: f.sandbox, gameDir: f.game };
const inferred = path.join(f.root, 'unrelated-workspace');
assert.equal(SAFETY.validateConfig(defaults, inferred).gameDir, f.game);
assert.throws(() => SAFETY.validateConfig({ ...defaults, sandboxDir: path.join(f.game, 'copy') }, inferred), /完全分开/);
assert.throws(() => SAFETY.validateConfig({ ...defaults, gameDir: f.root }, inferred), /缺少/);
assert.throws(() => SAFETY.validateConfig({ ...defaults, gameDir: 'relative' }, inferred), /绝对路径/);
const configFile = path.join(f.tool, 'config.json');
fs.writeFileSync(configFile, JSON.stringify(defaults));
const config = SAFETY.readConfig(configFile, {}, inferred);
assert.equal(config.gameDir, f.game);
assert.equal(FILES.sandboxStatus(config.gameDir, config.sandboxDir, path.dirname(f.tool)).ready, false);
});
test('backup is unique, verifies content, records source and rejects traversal/corruption', t => {
const f = fixture(t);
const a = FILES.backupSaves(f.backup, f.game, 'original');
fs.writeFileSync(path.join(f.game, 'save', 'file1.rmmzsave'), 'second');
const b = FILES.backupSaves(f.backup, f.game, 'original');
assert.notEqual(a.name, b.name);
assert.equal(fs.readFileSync(path.join(a.dir, 'file1.rmmzsave'), 'utf8'), 'first');
assert.equal(FILES.listBackups(f.backup)[0].sourceDir, f.game);
assert.throws(() => FILES.inspectBackup(f.backup, '../outside'), /非法/);
fs.writeFileSync(path.join(a.dir, 'file1.rmmzsave'), 'corrupt');
assert.throws(() => FILES.inspectBackup(f.backup, a.name), /校验失败/);
assert.equal(FILES.listBackups(f.backup).length, 1);
});
test('restore produces exact save set, preserves cloud settings, keeps previous directory', t => {
const f = fixture(t), b = FILES.backupSaves(f.backup, f.game, 'original');
fs.writeFileSync(path.join(f.game, 'save', 'file1.rmmzsave'), 'changed');
fs.writeFileSync(path.join(f.game, 'save', 'file9.rmmzsave'), 'extra');
const r = FILES.restoreBackup(f.backup, b.name, f.game);
assert.equal(fs.readFileSync(path.join(f.game, 'save', 'file1.rmmzsave'), 'utf8'), 'first');
assert.equal(fs.existsSync(path.join(f.game, 'save', 'file9.rmmzsave')), false);
assert.equal(fs.readFileSync(path.join(f.game, 'save', 'steam_autocloud.vdf'), 'utf8'), 'cloud');
assert.equal(fs.readFileSync(path.join(r.previousDirectory, 'file9.rmmzsave'), 'utf8'), 'extra');
assert.ok(r.safetyBackup);
});
test('empty backup is rejected and legacy backups remain readable', t => {
const f = fixture(t);
const empty = path.join(f.root, 'empty'); fs.mkdirSync(path.join(empty, 'save'), { recursive: true });
assert.throws(() => FILES.backupSaves(f.backup, empty), /没有找到/);
const legacy = path.join(f.backup, '20261002_123456'); fs.mkdirSync(legacy, { recursive: true });
fs.writeFileSync(path.join(legacy, 'file1.rmmzsave'), 'old');
assert.equal(FILES.listBackups(f.backup)[0].legacy, true);
});
test('sandbox rejects partial copy; actual robocopy excludes both tool versions', { skip: process.platform !== 'win32' }, async t => {
const f = fixture(t);
fs.mkdirSync(f.sandbox); fs.writeFileSync(path.join(f.sandbox, 'package.json'), '{}');
assert.equal(FILES.sandboxStatus(f.game, f.sandbox, path.dirname(f.tool)).ready, false);
fs.mkdirSync(path.join(f.game, 'aitemp发布版')); fs.writeFileSync(path.join(f.game, 'aitemp发布版', 'private.txt'), 'no copy');
const target = path.join(f.root, 'complete');
await FILES.copySandbox(f.game, target, path.dirname(f.tool));
assert.equal(FILES.sandboxStatus(f.game, target, path.dirname(f.tool)).ready, true);
assert.equal(fs.existsSync(path.join(target, 'aitemp')), false);
assert.equal(fs.existsSync(path.join(target, 'aitemp发布版')), false);
assert.equal(fs.readFileSync(path.join(target, 'save', 'file1.rmmzsave'), 'utf8'), 'first');
fs.writeFileSync(path.join(target, '.nyaruru-sandbox-incomplete'), 'interrupted');
assert.equal(FILES.sandboxStatus(f.game, target, path.dirname(f.tool)).ready, false);
});
function character() {
return { _x: 1, _y: 1, px: 48, py: 48, _suspendPhysicsHitTest: true, gravity: true,
locate(x, y) { this._x = x; this._y = y; },
clearImpact() {}, clearGrounding() {}, resumeFriction() {}, unlockInvincible() {},
suspendGravity() { this.gravity = true; }, resumeGravity() { this.gravity = false; },
suspendPhysicsHitTest() { this._suspendPhysicsHitTest = true; }, resumePhysicsHitTest() { this._suspendPhysicsHitTest = false; },
isSuspendGravity() { return this.gravity; }, isInvincible() { return false; },
isRemove() { return !!this.removed; }, hitPreview() { return [{ terrain: true, type: 0 }]; },
};
}
function gameContext() {
const c = { window: {}, $gamePlayer: character(), $gameLily: character(), $dataMapInfos: [null, {}, {}], $dataMap: {},
SceneManager: { _scene: { constructor: { name: 'Scene_Map' } } },
$gameMap: { mapId: () => 1, width: () => 20, height: () => 20, tileHeight: () => 48 },
};
vm.createContext(c); return c;
}
async function run(c, script) { return JSON.parse(await vm.runInContext(script, c)); }
test('land restores both companions, preserves unsafe lily and restore handles hidden lily', async () => {
const c = gameContext(); assert.equal((await run(c, OPS.land())).ok, true);
assert.equal(c.$gamePlayer.gravity, false); assert.equal(c.$gameLily.gravity, false);
assert.equal(c.$gameLily._suspendPhysicsHitTest, false);
c.$gameLily.hitPreview = () => [{ terrain: true, type: 2 }];
const unsafe = await run(c, OPS.land()); assert.match(unsafe.warning, /不安全/); assert.equal(c.$gameLily.gravity, true);
c.$gameLily.removed = true; await run(c, OPS.restoreAll());
assert.equal(c.$gameLily.gravity, false); assert.equal(c.$gameLily._suspendPhysicsHitTest, false);
});
test('invalid coordinates cannot reach locate; unfinished transfer fails and cancels reservation', async () => {
assert.throws(() => OPS.teleport(NaN, 2)); assert.throws(() => OPS.teleport('', 2)); assert.throws(() => OPS.teleport(1, 2, { mapId: -1 }));
const c = gameContext(); let id = 1, now = 0, cancelled = false;
c.$gameMap.mapId = () => id; c.$gamePlayer.reserveTransfer = () => { id = 2; };
c.$gamePlayer.isTransferring = () => true; c.$gamePlayer.clearTransferInfo = () => { cancelled = true; };
c.Date = { now: () => now }; c.setTimeout = (fn, ms) => { now += ms; fn(); };
const result = await run(c, OPS.teleport(3, 4, { mapId: 2 }));
assert.equal(result.ok, false); assert.equal(cancelled, true); assert.equal(now, 12000);
});
test('identity is based on runtime storage path and changes for reload and revisiting a map', async () => {
const c = gameContext();
c.require = require; c.process = { mainModule: { filename: path.join(os.tmpdir(), 'game', 'index.html') } };
c.StorageManager = { fileDirectoryPath: () => path.join(os.tmpdir(), 'game', 'save') };
c.location = { href: 'chrome-extension://id/index.html', pathname: '/index.html' };
const a = await run(c, OPS.selfPath()); c.$dataMap = {};
const b = await run(c, OPS.selfPath()); assert.notEqual(a.mapToken, b.mapToken);
c.$gamePlayer = character(); const d = await run(c, OPS.selfPath()); assert.notEqual(b.mapToken, d.mapToken);
assert.equal(a.gameDir, path.join(os.tmpdir(), 'game'));
});
test('scene pin is inert and reversible; staging permits all game saves and panel saves', async () => {
const c = gameContext(); let started = 0, saved = 0;
let now = 0, watchdog;
c.Date = { now: () => now }; c.setInterval = fn => { watchdog = fn; };
c.Game_Map = function() {}; c.Game_Map.prototype.setup = function() {};
c.DataManager = { maxSavefiles: () => 23, extractSaveContents() {} };
const saveMethods = ['saveGame', 'saveGameHidden', 'autoSaveGame', 'autoSaveGameInternal'];
const originals = {};
for (const name of saveMethods) {
originals[name] = c.DataManager[name] = function(slot) {
assert.equal(this, c.DataManager); assert.equal(slot, 23);
saved++; return Promise.resolve(name);
};
}
c.$gameSystem = { onBeforeSave() {} }; c.SceneManager.snapForBackground = () => {};
c.StorageManager = { saveZip() {}, saveObject() {} };
const ev = { _erased: true, _pageIndex: -1, physics: true, _statemMembers: { name: 'old' },
eventId: () => 1, event: () => ({ name: 'event', pages: [{ image: { characterName: 'body' } }] }),
findProperPageIndex: () => 0, setupPageSettings() { this._trigger = 4; this._interpreter = {}; this._statemMembers.name = 'new'; },
characterName: () => 'body', start() { started++; }, update() { this.start(); },
};
const originalUpdate = ev.update; c.$gameMap.events = () => [ev]; c.$gameMap.requestRefresh = () => {};
assert.equal((await run(c, OPS.forcePage(1, 0))).ok, true);
ev.update(); ev.start(); assert.equal(started, 0); assert.equal(ev._interpreter, null); assert.equal(ev.physics, false);
for (const name of saveMethods) {
assert.equal(c.DataManager[name], originals[name]);
assert.equal(await c.DataManager[name](23), name);
}
assert.equal(saved, 4);
assert.equal((await run(c, OPS.savePhotoSlot(23))).ok, true); assert.equal(saved, 5);
assert.equal(c.window.__nyaruruPhoto.count(), 1); assert.equal(ev.physics, false);
await run(c, OPS.unpinPages()); assert.equal(ev._erased, true); assert.equal(ev._pageIndex, -1);
assert.equal(ev.update, originalUpdate); assert.equal(ev._statemMembers.name, 'old');
await c.DataManager.saveGame(23); assert.equal(saved, 6);
await run(c, OPS.forcePage(1, 0)); now = 119000; watchdog(); assert.equal(c.window.__nyaruruPhoto.count(), 1);
c.window.__nyaruruPhoto.touch(); now += 119000; watchdog(); assert.equal(c.window.__nyaruruPhoto.count(), 1);
now += 2000; watchdog(); assert.equal(c.window.__nyaruruPhoto.count(), 0); assert.equal(ev._erased, true);
});
test('staged actors settle on terrain, animate without AI and face the player; undo restores pose', async () => {
const c = gameContext(); let aiCalls = 0;
c.Game_Map = function() {}; c.Game_Map.prototype.setup = function() {};
c.DataManager = { extractSaveContents() {} };
c.$gameMap.tileWidth = c.$gameMap.tileHeight = () => 96;
c.$gameMap.requestRefresh = () => {};
c.ImageFrame = { info: () => ({ width: 3, height: 1 }) };
const ai = { smoothfloor: 'true', skipSmoothfloor: 'false', fix: 'false' };
const ev = { _x: 16, _y: 12, _realX: 16, _realY: 12, px: 1584, py: 1248, rx: 1584, ry: 1248,
_direction: 6, _directionFix: true, _framePos: 0, physics: true, fix: false,
eventId: () => 1, event: () => ({ pages: [{ list: [{ code: 357, parameters: ['ai-plugin', 'ai', '', ai] }] }] }),
setupPageSettings() {}, characterName: () => 'actor',
tileXToCenterX: x => (x + 0.5) * 96, tileYToCenterY: y => (y + 1) * 96,
locate(x, y) { this._realX = x; this._realY = y; this._x = Math.round(x); this._y = Math.round(y); this.px = this.rx = (x + 0.5) * 96; this.py = this.ry = (y + 1) * 96; },
setDirection(d) { if (!this._directionFix) this._direction = d; },
update() { aiCalls++; }, start() { aiCalls++; },
updateFrameAnimation(info) { this._framePos = (this._framePos + 1) % info.width; },
hitPreview(dx, dy) { return [{ terrain: false, type: 0 }, ...(dy >= 47 ? [{ terrain: true, type: 0 }] : [])]; },
};
c.$gameMap.events = () => [ev]; c.$gamePlayer.px = 1488;
const originalUpdate = ev.update;
await run(c, OPS.forcePage(1, 0));
assert.equal(ev.py, 1294); assert.equal(ev.ry, 1294); assert.equal(ev.physics, false);
assert.equal(ev._direction, 4); assert.equal(ev._directionFix, true);
ev.update(); assert.equal(ev._framePos, 1);
ev.update(); ev.update(); assert.equal(ev._framePos, 0); ev.start(); assert.equal(aiCalls, 0);
c.$gamePlayer.px = 1680; c.window.__nyaruruPhoto.facePlayer(ev); assert.equal(ev._direction, 6);
await run(c, OPS.unpinPages());
assert.equal(ev.py, 1248); assert.equal(ev.ry, 1248); assert.equal(ev.physics, true); assert.equal(ev.update, originalUpdate);
const originalProbe = ev.hitPreview;
for (const mode of ['flying', 'hazard', 'no-ground']) {
ai.smoothfloor = mode === 'flying' ? 'false' : 'true';
ev.hitPreview = mode === 'hazard' ? () => [{ terrain: true, type: 2 }] : mode === 'no-ground' ? () => [] : originalProbe;
await run(c, OPS.forcePage(1, 0)); assert.equal(ev.py, 1248);
await run(c, OPS.unpinPages());
}
});
for (const delayed of [false, true]) test(`staging crops the sprite sheet on pin and undo (${delayed ? 'delayed' : 'cached'} bitmap)`, async () => {
const c = gameContext();
c.Game_Map = function() {}; c.Game_Map.prototype.setup = function() {};
c.DataManager = { extractSaveContents() {} };
const ev = {
_characterName: 'original', _pageIndex: 0, _framePos: 0,
eventId: () => 1, event: () => ({ pages: [{}, {}] }),
characterName() { return this._characterName; },
bitmapName() { return this._characterName + '_atlas'; },
characterIndex: () => 0, tileId: () => 0,
setupPageSettings() { this._characterName = 'dracula'; },
};
c.$gameMap.events = () => [ev]; c.$gameMap.requestRefresh = () => {};
const images = {}, sprite = {
_character: ev, _framePos: 0, children: [], _contentSprite: {}, updates: 0,
setCharacterBitmap() {
const name = this._characterName;
const bitmap = images[name] ||= {
width: name === 'dracula_atlas' ? 558 : 240, height: 73,
columns: name === 'dracula_atlas' ? 6 : 3, ready: !delayed, listeners: [],
addLoadListener(fn) { if (this.ready) fn(); else this.listeners.push(fn); },
load() { this.ready = true; for (const fn of this.listeners.splice(0)) fn(); },
};
this._contentSprite.bitmap = bitmap;
bitmap.addLoadListener(() => {
if (this._contentSprite.bitmap === bitmap) this._contentSprite.frame = { width: bitmap.width, height: bitmap.height };
});
},
updateFrame() {
this.updates++;
if (this._framePos === ev._framePos) return;
this._framePos = ev._framePos;
const bitmap = this._contentSprite.bitmap;
this._contentSprite.frame = { width: bitmap.width / bitmap.columns, height: bitmap.height };
},
};
c.SceneManager._scene.children = [sprite];
assert.equal((await run(c, OPS.forcePage(1, 1))).ok, true);
if (delayed) images.dracula_atlas.load();
assert.equal(sprite._characterName, 'dracula_atlas');
assert.deepEqual(sprite._contentSprite.frame, { width: 93, height: 73 });
await run(c, OPS.unpinPages());
if (delayed) images.original_atlas.load();
assert.equal(ev.characterName(), 'original');
assert.deepEqual(sprite._contentSprite.frame, { width: 80, height: 73 });
if (delayed) {
images.dracula_atlas.ready = false;
await run(c, OPS.forcePage(1, 1));
await run(c, OPS.unpinPages());
const updates = sprite.updates;
images.dracula_atlas.load(); // 旧图迟到,不能改动已撤销的显示状态。
assert.equal(sprite.updates, updates);
assert.deepEqual(sprite._contentSprite.frame, { width: 80, height: 73 });
}
});
test('save awaits disk rejection instead of reporting success', async () => {
const c = gameContext(); c.setTimeout = setTimeout;
c.$gameSystem = { onBeforeSave() {} }; c.SceneManager.snapForBackground = () => {};
const originalZip = () => new Promise((resolve, reject) => setTimeout(() => reject(new Error('disk full')), 5));
c.StorageManager = { saveZip: originalZip, saveObject() { return Promise.resolve(); } };
c.DataManager = { maxSavefiles: () => 23, saveGame() { c.StorageManager.saveZip(); return Promise.resolve(); } };
const r = await run(c, OPS.savePhotoSlot(23)); assert.equal(r.ok, false); assert.match(r.error, /disk full/);
assert.equal(c.StorageManager.saveZip, originalZip);
});
test('CDP shares concurrent connect, rejects pending on disconnect, ignores old socket close', async () => {
const sockets = [];
class WS {
constructor() { this.readyState = 1; sockets.push(this); queueMicrotask(() => this.onopen()); }
send(raw) {
const p = JSON.parse(raw);
if (p.method === 'Runtime.evaluate') return;
const result = p.method === 'Target.getTargets' ? { targetInfos: [{ type: 'page', targetId: 'game', url: 'chrome-extension://id/index.html' }] } : { sessionId: 'session' };
queueMicrotask(() => this.onmessage({ data: JSON.stringify({ id: p.id, result }) }));
}
close() {}
}
const c = { module: { exports: {} }, WebSocket: WS, fetch: async () => ({ ok: true, json: async () => ({ webSocketDebuggerUrl: 'ws://test' }) }), AbortSignal, setTimeout, clearTimeout };
vm.runInNewContext(fs.readFileSync(path.join(__dirname, '../lib/cdp.js'), 'utf8'), c);
const client = new c.module.exports.CdpClient();
await Promise.all([client.connect(), client.connect()]); assert.equal(sockets.length, 1);
const old = sockets[0]; client.close(); await client.connect(); old.onclose(); assert.equal(client.ws, sockets[1]);
const pending = client.eval('test'); await new Promise(r => setImmediate(r));
const rejection = assert.rejects(pending, /断开/); sockets[1].onclose(); await rejection;
assert.equal(client._pending.size, 0); client.close();
});
function serverHarness(t, ops = OPS) {
const f = fixture(t), calls = [];
const fake = { generation: 1, port: 9223, close() {}, async isAlive() { return false; },
async evalJson(script) { calls.push(script); return { ok: true, gameDir: f.game, saveDir: path.join(f.game, 'save'), mapToken: 'mock:1', mapId: 1 }; } };
const c = { __dirname: f.tool, module: { exports: {} }, process, console: { log() {}, error() {} }, URL, Buffer, setTimeout,
require(n) {
if (n === './lib/cdp.js') return { CdpClient: function() { return fake; } };
if (n === './lib/ops.js') return { OPS: ops };
if (n === './lib/story-position') return { storyPosition };
if (n === './lib/data.js') return { loadAll: () => ({ stats: {}, triggers: [] }) };
if (n === './lib/safety') return SAFETY;
if (n === './lib/files') return FILES;
if (n === './lib/processes') return { assertGamesStopped: async () => {} };
if (n === './lib/story-cache') return require('../lib/story-cache');
return require(n);
},
};
vm.runInNewContext(fs.readFileSync(path.join(__dirname, '../server.js'), 'utf8'), c);
c.module.exports.CONFIG.sandboxDir = f.sandbox;
return { ...f, api: c.module.exports, fake, calls };
}
async function request(h, url, body, method = 'POST') {
const req = new EventEmitter(); req.method = method; req.headers = {};
let result, code;
const res = { writeHead(c) { code = c; }, end(s) { result = JSON.parse(s); } };
const task = h.api.handleApi(req, res, url);
if (method === 'POST') { req.emit('data', JSON.stringify(body)); req.emit('end'); }
await task; return { result, code };
}
test('controller stories use the verified scene entry without changing other or explicit positions', () => {
const data = loadAll(path.resolve(__dirname, '../..'));
for (const [story, map, expected] of [[449, 9, [11, 9]], [449, 296, [28, 13]], [467, 254, [6, 5]]]) {
const trigger = data.triggers.find(t => t.storyId === story && t.mapId === map);
assert.ok(trigger);
const p = storyPosition(trigger); assert.deepEqual([p.x, p.y], expected);
}
// Published tests validate the structural trigger index; original map scripts are not distributed.
for (const storyId of [298, 299, 305]) {
const trigger = data.triggers.find(t => t.storyId === storyId && t.mapId === 182 && Number(t.eventId) === 3 && t.pageIndex === 0);
assert.ok(trigger); assert.equal(trigger.x, 0); assert.equal(trigger.y, 0);
const p = storyPosition(trigger);
assert.equal(p.x, 11); assert.equal(p.y, 11); assert.ok(p.positionNote);
for (const patch of [{ mapId: 183 }, { eventId: 4 }, { storyId: 370 }, { pageIndex: 1 }, { pageIndex: null }, { x: 7, y: 8 }, { x: null }]) {
const input = { ...trigger, ...patch };
assert.deepEqual(storyPosition(input), { x: input.x, y: input.y });
}
}
});
test('unknown controller scenes preserve safe positions or find floor without story-number rules', () => {
const actor = { _x: 6, _y: 7, eventId: () => 2, characterName: () => 'actor', event: () => ({ pages: [] }) };
const page = { trigger: 3, image: { characterName: '', tileId: 0 }, list: [] };
let probes = 0, hazard = false, noFloor = false;
const player = { _x: 0, _y: 0, hitPreview(dx, dy) {
probes++; if (noFloor || dy < 8 * 96) return [];
return [{ terrain: true, type: hazard && dx === 5 * 96 ? 2 : 0 }];
} };
const input = { map: { tileWidth: () => 96, tileHeight: () => 96, width: () => 20, height: () => 15, events: () => [actor] },
player, data: { events: [null, { x: 0, y: 0, pages: [page] }] }, eventId: 1, pageIndex: 0,
storyId: 987654, sameMap: true, previous: { x: 10, y: 7 }, requested: { x: 0, y: 0 } };
let r = chooseStoryPosition(input); assert.equal(r.x, 10); assert.equal(r.y, 7);
input.sameMap = false; hazard = true;
r = chooseStoryPosition(input); assert.equal(r.x, 7); assert.equal(r.y, 7); // 左侧陷阱被拒绝,选右侧。
page.trigger = 4; r = chooseStoryPosition(input); assert.equal(r.controller, true);
noFloor = true; probes = 0; r = chooseStoryPosition(input);
assert.equal(r.ok, false); assert.match(r.error, /未找到/); assert.ok(probes <= 800);
page.trigger = 0; probes = 0; r = chooseStoryPosition(input);
assert.equal(r.controller, false); assert.equal(r.x, 0); assert.equal(probes, 0);
page.trigger = 3; page.image.characterName = 'visible';
assert.equal(chooseStoryPosition(input).controller, false); // 有形事件不能当成控制事件。
});
test('scene, dialogue and goto APIs correct stale controller coordinates before teleport', async t => {
const targets = [], order = [];
const h = serverHarness(t, { ...OPS,
teleport(x, y, options) { targets.push({ x, y, map: options.mapId }); return OPS.teleport(x, y, options); },
runStoryScene(...args) { order.push('scene'); return OPS.runStoryScene(...args); },
land() { order.push('land'); return OPS.land(); },
});
const body = { context: '1:mock:1', mapId: 182, eventId: 3, pageIndex: 0, storyId: 305, x: 0, y: 0 };
await request(h, '/api/story/start', { ...body, mode: 'scene' });
await request(h, '/api/story/start', { ...body, mode: 'play' });
await request(h, '/api/story/goto', { ...body, pairs: [] });
assert.deepEqual(order, ['scene', 'land']); // 只对运行过场自动落地,且在补角色之后。
assert.deepEqual(targets, Array.from({ length: 3 }, () => ({ x: 11, y: 11, map: 182 })));
await request(h, '/api/story/start', { ...body, mode: 'play', x: 7, y: 8 });
assert.deepEqual(targets[3], { x: 7, y: 8, map: 182 });
});
test('restore never defaults to original; GET cannot mutate', async t => {
const h = serverHarness(t);
await assert.rejects(request(h, '/api/restore-backup', { name: 'any', confirm: true }), /明确/);
assert.equal((await request(h, '/api/restore', null, 'GET')).code, 405);
assert.equal(h.calls.length, 0);
});
test('photo save checks target and blocks both stale context and backup failure', async t => {
const h = serverHarness(t);
await assert.rejects(request(h, '/api/save-photo', { context: '1:mock:1', slot: 23, target: 'sandbox' }), /不一致/);
await assert.rejects(request(h, '/api/save-photo', { context: '1:old', slot: 23, target: 'original' }), /已变化/);
fs.unlinkSync(path.join(h.game, 'save', 'file1.rmmzsave')); fs.unlinkSync(path.join(h.game, 'save', 'global.rmmzsave'));
await assert.rejects(request(h, '/api/save-photo', { context: '1:mock:1', slot: 23, target: 'original' }), /没有找到/);
assert.equal(h.calls.some(s => s.includes('await DataManager.saveGame(s)')), false);
});
test('mutations are serialized and stale map-local actions are rejected', async t => {
const h = serverHarness(t); let active = 0, max = 0;
h.fake.evalJson = async s => {
if (!s.includes('const i=JSON.parse')) return { ok: true, gameDir: h.game, saveDir: path.join(h.game, 'save'), mapToken: 'mock:1' };
active++; max = Math.max(max, active); await new Promise(r => setTimeout(r, 10)); active--; return { ok: true };
};
await Promise.all([request(h, '/api/gravity', { context: '1:mock:1', on: true }), request(h, '/api/gravity', { context: '1:mock:1', on: false })]);
assert.equal(max, 1);
await assert.rejects(request(h, '/api/events/force-page', { context: '1:old-map', eventId: 1, page: 0 }), /已变化/);
});
test('public structural index excludes text and preserves distinct event/page alternatives', () => {
const data = loadAll(path.resolve(__dirname, '../..'));
assert.ok(data.triggers.length > 0);
assert.ok(data.triggers.every(t => !('lines' in t) && !('line' in t) && !('searchText' in t)));
assert.equal(data.storyIndex, undefined);
const story11 = data.triggers.filter(t => t.storyId === 11);
assert.ok(new Set(story11.map(t => t.page)).size > 1);
});
function launcherHarness(t, { ready = false, choice = '3', occupied = false, identityError = false, backupError = false } = {}) {
const f = fixture(t), actions = [];
fs.writeFileSync(path.join(f.tool, 'config.json'), JSON.stringify({ sandboxDir: f.sandbox }));
const c = { __dirname: f.tool, module: { exports: {} }, console: { log() {} }, setTimeout,
process: { versions: { node: '24.0.0' }, argv: [], stdin: {}, stdout: {}, execPath: process.execPath },
require(n) {
if (n === './lib/safety') return SAFETY;
if (n === './lib/files') return { sandboxStatus: () => ({ ready, reason: 'mock' }), backupSaves: () => { actions.push('backup'); if (backupError) throw Error('backup failed'); return { name: 'mock' }; } };
if (n === './lib/cdp') return { CdpClient: function() { return { close() {}, async evalJson() { if (identityError) throw Error('unknown'); return { ok: true, gameDir: f.game, saveDir: path.join(f.game, 'save') }; } }; } };
if (n === './lib/ops') return { OPS };
if (n === 'readline/promises') return { createInterface: () => ({ question: async () => choice, close() {} }) };
if (n === 'net') return { connect: ({ port }) => { const s = new EventEmitter(); s.destroy = () => {}; s.setTimeout = () => {}; queueMicrotask(() => s.emit(occupied && port === 9223 ? 'connect' : 'error')); return s; } };
if (n === 'child_process') return { spawn: () => { actions.push('spawn'); throw Error('unexpected spawn'); } };
return require(n);
},
};
vm.runInNewContext(fs.readFileSync(path.join(__dirname, '../launcher.js'), 'utf8'), c);
return { main: c.module.exports.main, actions };
}
test('launcher exit stops completely, wrong and unknown instances stop before backup/panel', async t => {
const exit = launcherHarness(t); await exit.main(); assert.deepEqual(exit.actions, []);
for (const identityError of [false, true]) {
const h = launcherHarness(t, { ready: true, occupied: true, identityError });
await assert.rejects(h.main(), /不能确认/); assert.deepEqual(h.actions, []);
}
});
test('launcher backup failure prevents starting either game or panel', async t => {
const h = launcherHarness(t, { ready: true, backupError: true });
await assert.rejects(h.main(), /backup failed/); assert.deepEqual(h.actions, ['backup']);
});
test('panel save warning lets the user continue or cancel using the existing confirmation', async () => {
const source = fs.readFileSync(path.join(__dirname, '../web/app.js'), 'utf8');
const start = source.indexOf("$('#photo-save').onclick");
const end = source.indexOf('// ---------- 对话跳转点', start);
for (const proceed of [false, true]) {
const button = {}, calls = []; let confirmation;
vm.runInNewContext(source.slice(start, end), {
$: key => key === '#photo-save' ? button : { value: '23' },
CURRENT_CONTEXT: 'current', CURRENT_GAME: { kind: 'sandbox', saveDir: 'sandbox/save' },
confirm: text => { confirmation = text; return proceed; },
api: async (route, body) => { calls.push({ route, body }); return { ok: true, slot: 23 }; },
toast() {}, loadBackups() {},
});
await button.onclick();
assert.match(confirmation, /不推荐.*保存/); assert.match(confirmation, /仍可确定继续保存/);
assert.equal(calls.length, proceed ? 1 : 0);
if (proceed) { assert.equal(calls[0].route, '/save-photo'); assert.equal(calls[0].body.slot, 23); assert.equal(calls[0].body.target, 'sandbox'); }
}
});
test('map event search matches later lines and displays the matched text', () => {
const source = fs.readFileSync(path.join(__dirname, '../web/app.js'), 'utf8');
const start = source.indexOf('function renderEvents()');
const end = source.indexOf("$('#ev-load').onclick", start);
const list = { innerHTML: '', querySelectorAll: () => [] };
const elements = { '#ev-filter': { value: 'later sentence' }, '#ev-all': { checked: false }, '#ev-list': list };
vm.runInNewContext(source.slice(start, end) + ';renderEvents();', {
$: key => elements[key], esc: value => String(value), spriteLabel: () => '',
EVENTS: [{ id: 1, hasStory: true, name: 'event', sprite: '', trigger: 'test', x: 0, y: 0,
lines: [{ first: 'first sentence', speaker: 'actor', searchText: 'first sentence\nlater sentence', lines: [{ text: 'first sentence', speaker: 'actor' }, { text: 'later sentence', speaker: 'actor' }] }] }],
});
assert.match(list.innerHTML, /later sentence/); assert.doesNotMatch(list.innerHTML, /没有匹配|没有带剧情/);
});
+59
View File
@@ -0,0 +1,59 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const os = require('node:os');
const { buildRelease, manifestFiles } = require('../package-release');
function tempRoot(t) {
const parent = fs.realpathSync.native(os.tmpdir());
const root = fs.mkdtempSync(path.join(parent, 'nyaruru-public-release-'));
t.after(() => {
assert.equal(path.dirname(path.resolve(root)), parent);
assert.match(path.basename(root), /^nyaruru-public-release-/);
fs.rmSync(root, { recursive: true, force: true });
});
return root;
}
function walk(root, prefix = '') {
return fs.readdirSync(path.join(root, prefix), { withFileTypes: true }).flatMap(entry => {
const relative = prefix ? prefix + '/' + entry.name : entry.name;
return entry.isDirectory() ? walk(root, relative) : [relative];
});
}
test('clean release contains only allow-listed files and serves its page without game access', async t => {
const root = tempRoot(t), destination = path.join(root, 'Nyaruru_modifier');
const result = buildRelease(destination);
assert.deepEqual(walk(destination).sort(), manifestFiles().sort());
assert.equal(result.files.length, manifestFiles().length);
assert.throws(() => buildRelease(destination), /已存在/);
const mod = require(path.join(destination, 'tool/server.js'));
let gameCalls = 0;
for (const method of ['connect', 'evalJson', 'isAlive']) mod.cdp[method] = async () => { gameCalls++; throw Error('No live game access in release tests'); };
await new Promise(resolve => mod.server.listen(0, '127.0.0.1', resolve));
t.after(() => new Promise(resolve => { mod.server.closeAllConnections(); mod.server.close(resolve); }));
const origin = 'http://127.0.0.1:' + mod.server.address().port;
const health = await fetch(origin + '/api/health').then(r => r.json());
assert.equal(health.ok, true); assert.equal(health.config.webPort, 8787);
const html = await fetch(origin).then(r => r.text());
for (const id of ['story-text-hint', 'story-load-text', 'panel-world-map', 'panel-encyclopedia']) assert.ok(html.includes('id="' + id + '"'));
assert.equal((await fetch(origin + '/app.js')).status, 200);
assert.equal(gameCalls, 0);
assert.doesNotMatch(html, /panel-workspace|data-panel="workspace"|快照与 TAS/);
for (const route of ['/api/checkpoints','/api/tas']) assert.equal((await fetch(origin + route)).status, 405);
const app = await fetch(origin + '/app.js').then(r => r.text());
assert.doesNotMatch(app, /loadWorkspace|WORK_BUSY|panel-workspace|checkpoints|\/tas/);
const { OPS } = require(path.join(destination, 'tool/lib/ops.js'));
for (const name of ['tas','captureCheckpoint','loadCheckpoint','restoreCheckpointTools']) assert.equal(OPS[name], undefined);
assert.equal(typeof OPS.snapshot, 'function'); assert.equal(typeof OPS.combat, 'function');
assert.deepEqual(walk(destination).sort(), manifestFiles().sort());
});
test('release manifest rejects raw maps, runtime configuration and path traversal', t => {
const root = tempRoot(t); fs.mkdirSync(path.join(root, 'tool'));
for (const file of ['unpacked/data/Map181.json', 'out/story.json', 'tool/config.json', '../outside', 'backups/states.bin']) {
fs.writeFileSync(path.join(root, 'tool/发布清单.json'), JSON.stringify({ files: [file] }));
assert.throws(() => manifestFiles(root));
}
});
+57
View File
@@ -0,0 +1,57 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const vm = require('node:vm');
const { StoryCache } = require('../lib/story-cache');
const { OPS } = require('../lib/ops');
function fixture(options = {}) {
let calls = 0;
const cdp = { port: 9223, generation: 1, sessionId: 'session-1', ws: { readyState: 1 },
async isAlive() { return true; }, async connect() { this.ws = { readyState: 1 }; },
async evalJson() { calls++; return { ok: true, index: { 1: { first: 'fixture', lines: [{ text: 'fixture' }] } } }; } };
const cache = new StoryCache(cdp, OPS, [{ storyId: 1 }, { storyId: 2 }], options);
return { cdp, cache, calls: () => calls };
}
test('runtime story index searches later synthetic lines and excludes commands and comments', async () => {
const context = vm.createContext({ $dataActors: { 2: { name: '模拟角色' } }, $dataStory: {
1: [{ name: 'story', contents: '模拟首句', speaker: 2 }, { name: 'story', contents: '模拟后续检索句' },
{ name: 'story', contents: '#注释' }, { name: 'story', contents: '///备注' }, { name: 'other', contents: '不属于台词' }],
2: [{ name: 'story', contents: ' ' }], invalid: [{ name: 'story', contents: '忽略' }],
} });
const result = JSON.parse(await vm.runInContext(OPS.readStoryIndex(), context));
assert.equal(result.ok, true); assert.equal(result.count, 1);
assert.equal(result.index[1].count, 2); assert.equal(result.index[1].speaker, '模拟角色');
assert.match(result.index[1].searchText, /模拟后续检索句/);
assert.doesNotMatch(result.index[1].searchText, /注释|备注|不属于台词/);
});
test('story cache shares reads, clears disconnected text and reloads a new connection', async () => {
const f = fixture();
await Promise.all([f.cache.ensure(), f.cache.ensure()]);
assert.equal(f.calls(), 1); assert.equal(f.cache.status().triggersWithLine, 1);
f.cdp.ws = null; assert.equal(f.cache.get(1), null); assert.equal(f.cache.status().state, 'disconnected');
f.cdp.generation++; f.cdp.sessionId = 'session-2'; await f.cache.ensure();
assert.equal(f.calls(), 2); assert.equal(f.cache.status().loaded, true);
});
test('story cache rate limits four retries and permits explicit retry after failure', async () => {
let now = 0; const f = fixture({ now: () => now });
f.cdp.evalJson = async () => ({ ok: false, error: 'fixture not ready' });
await f.cache.ensure(); await f.cache.ensure(); assert.equal(f.cache.status().attempts, 1);
for (let i = 0; i < 3; i++) { now += 3000; await f.cache.ensure(); }
now += 3000; await f.cache.ensure();
assert.equal(f.cache.status().attempts, 4); assert.equal(f.cache.status().state, 'error');
f.cdp.evalJson = async () => ({ ok: true, index: { 1: { first: 'ready' } } });
await f.cache.ensure({ force: true }); assert.equal(f.cache.get(1).first, 'ready');
});
test('late story response from an old connection cannot overwrite new text', async () => {
const f = fixture(); let resolve;
f.cdp.evalJson = () => new Promise(r => { resolve = r; });
const old = f.cache.ensure();
f.cdp.generation++; f.cdp.sessionId = 'session-2'; f.cache.status();
f.cdp.evalJson = async () => ({ ok: true, index: { 1: { first: 'new connection' } } });
await f.cache.ensure(); resolve({ ok: true, index: { 1: { first: 'stale connection' } } }); await old;
assert.equal(f.cache.get(1).first, 'new connection');
});
+186
View File
@@ -0,0 +1,186 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const vm = require('node:vm');
const { installTrainer } = require('../lib/trainer');
const { OPS } = require('../lib/ops');
function fixture() {
let now = 0;
const timers = [];
function makeActor() {
const battle = { _hp: 80, mhp: 100, sp: 30, msp: 100, zp: 60, mzp: 200,
get hp() { return this._hp | 0; },
set hp(value) { if (this._hp !== 0) this._hp = Math.max(0, value); },
addHp(delta) { this.hp = Math.min(this.mhp, Math.max(0, this._hp + delta)); },
addSp(delta) { this.sp = Math.min(this.msp, Math.max(0, this.sp + delta)); },
addZp(delta) { this.zp = Math.min(this.mzp, Math.max(0, this.zp + delta)); },
};
const definitions = { 0: { name: 'item0', desc: 'desc0', maxNum: 6 }, 1: { name: 'item1', maxNum: 2 } };
function equipment(type) {
return { _unlock: [0, 1], _equip: [0], _currentWeapon: 0,
data: () => definitions, isUnlock(id) { return this._unlock.includes(id); },
isEquip(id) { return this._equip.includes(id); }, currentWeapon() { return this._currentWeapon; },
unlock(id) { if (!this.isUnlock(id)) { this._unlock.push(id); if (type === 'stones') this._equip.push(id); } },
equip(id) { if (type === 'weapons') this._currentWeapon = id; else this._equip = this.isEquip(id) ? this._equip.filter(n => n !== id) : [...this._equip, id]; },
remove(id) { this._equip = this._equip.filter(n => n !== id); this._unlock = this._unlock.filter(n => n !== id); },
};
}
return { battle, hp: 1, _coin: 123, name: () => '喵露露', coin() { return this._coin; },
addCoin(n) { this._coin = Math.min(99999, this._coin + n); },
deductCoin(n) { if (this._coin < n) return false; this._coin -= n; return true; },
_consumables: { _data: { 0: 3 }, data: () => definitions, itemNum(id) { return this._data[id] || 0; }, maxNum: id => definitions[id].maxNum,
add(id, n, over) { this._data[id] = Math.max(0, Math.min(over ? Infinity : definitions[id].maxNum, this.itemNum(id) + n)); } },
_weapons: equipment('weapons'), _stones: equipment('stones'), _ornaments: equipment('ornaments'),
changeWeapon(id) { this._weapons.equip(id); },
_joyCoin: 2, _hpStrengthItem: 1, _skillStrengthItem: 3,
joyCoinData: () => definitions[0], hpStrengthData: () => definitions[0], skillStrengthData: () => definitions[0],
joyCoinNum() { return this._joyCoin; }, hpStrengthNum() { return this._hpStrengthItem; }, skillStrengthNum() { return this._skillStrengthItem; },
};
}
const actor = makeActor(), companion = makeActor();
const player = { current: actor, lily: false, actor() { return this.current; }, battleObject() { return this.current.battle; }, isLilyMode() { return this.lily; }, setWeaponChange() { this.weaponChanged = true; } };
const context = vm.createContext({ window: {}, $gamePlayer: player, $gameLily: { actor: () => companion }, $gameParty: {}, $gameActors: {},
TextManager: { item0: '卷心菜', item1: '物品一', desc0: '恢复生命' },
Date: { now: () => now }, setInterval: fn => { timers.push(fn); },
$gameMap: { mapId: () => 1 }, SceneManager: { _scene: { constructor: { name: 'Scene_Map' } } },
});
const api = vm.runInContext(`(${installTrainer.toString()})()`, context);
const change = body => api.change({ subject: api.read().subject, ...body });
return { api, change, context, actor, companion, player, timers, makeActor, advance(ms) { now += ms; timers.forEach(fn => fn()); } };
}
test('trainer edits actual NYA values, supports Lily mode and rejects stale actor requests', () => {
const f = fixture();
f.change({ action: 'stat', field: 'coin', value: 9000 });
f.change({ action: 'stat', field: 'hp', value: 95 });
f.change({ action: 'stat', field: 'sp', value: 40 });
f.change({ action: 'stat', field: 'zp', value: 150 });
assert.equal(f.actor.hp, 1); assert.equal(f.actor.battle.hp, 95); assert.equal(f.actor.coin(), 9000);
const old = f.api.read().subject;
f.player.lily = true;
assert.equal(f.api.read().role, '莉莉娅');
assert.throws(() => f.api.change({ subject: old, action: 'stat', field: 'hp', value: 20 }), /角色已变化/);
f.change({ action: 'stat', field: 'hp', value: 50 });
assert.equal(f.actor.battle.hp, 50); assert.equal(f.companion.battle.hp, 80);
for (const value of [null, '20', -1, 101, NaN, Infinity, 1.5]) {
assert.throws(() => f.change({ action: 'stat', field: 'hp', value }), /整数/);
}
assert.equal(f.actor.battle.hp, 50);
f.change({ action: 'stat', field: 'hp', value: 0 });
assert.throws(() => f.change({ action: 'stat', field: 'hp', value: 50 }), /已倒下/);
});
test('locks stop lethal damage and spending immediately, survive form changes and restore descriptors', () => {
const f = fixture(), b = f.actor.battle;
for (const [field, value] of Object.entries({ hp: 70, sp: 45, zp: 90, coin: 1000 })) f.change({ action: 'stat', field, value, locked: true });
b.hp = 0; b.sp -= 50; b.zp += 80;
assert.equal(f.actor.deductCoin(999), true); f.actor.addCoin(500);
assert.equal(b.hp, 70); assert.equal(b.sp, 45); assert.equal(b.zp, 90); assert.equal(f.actor.coin(), 1000);
f.player.lily = true; b.hp = 0; assert.equal(b.hp, 70);
b.mhp = 40; assert.equal(b.hp, 40);
f.change({ action: 'stat', field: 'coin', value: 2000 });
assert.equal(f.api.read().stats.coin.locked, true); assert.equal(f.actor.coin(), 2000);
const saved = JSON.parse(JSON.stringify(f.actor));
assert.equal(saved.battle._hp, 40); assert.equal(saved._coin, 2000);
assert.equal(Object.keys(saved).some(k => /lock|trainer/i.test(k)), false);
f.change({ action: 'stat', field: 'hp', locked: false });
const desc = Object.getOwnPropertyDescriptor(b, '_hp');
assert.equal(desc.value, 40); assert.equal(desc.writable, true); assert.equal(desc.enumerable, true);
b.hp = 0; assert.equal(b.hp, 0);
f.api.clear(); assert.equal(f.actor.deductCoin(500), true); assert.equal(f.actor.coin(), 1500);
});
test('locks follow current player objects but clear on load and heartbeat expiry; install is idempotent', () => {
const f = fixture();
f.change({ action: 'stat', field: 'sp', value: 55, locked: true });
const other = f.makeActor(); f.player.current = other; f.advance(100);
other.battle.sp = 0; assert.equal(other.battle.sp, 55);
f.actor.battle.sp = 10; assert.equal(f.actor.battle.sp, 10);
vm.runInContext(`(${installTrainer.toString()})()`, f.context); assert.equal(f.timers.length, 1);
f.context.$gameActors = {}; f.context.$gameParty = {}; f.player.current = f.makeActor(); f.advance(100);
assert.equal(f.api.read().stats.sp.locked, false); assert.equal(f.player.current.battle.sp, 30);
f.change({ action: 'stat', field: 'coin', value: 500, locked: true });
f.advance(120001); assert.equal(f.api.read().stats.coin.locked, false);
f.player.current.addCoin(1); assert.equal(f.player.current.coin(), 501);
});
test('inventory accepts item zero, respects optional consumable cap and keeps equipment references valid', () => {
const f = fixture();
assert.equal(f.api.read().items[0].name, '卷心菜');
const item = (type, id, count, extra = {}) => f.change({ action: 'item', type, id, count, ...extra });
item('consumables', 0, 6); assert.equal(f.actor._consumables.itemNum(0), 6);
assert.throws(() => item('consumables', 0, 7), /整数/);
item('consumables', 0, 99, { overCap: true }); assert.equal(f.actor._consumables.itemNum(0), 99);
item('consumables', 0, 0); assert.equal(f.actor._consumables.itemNum(0), 0);
for (const type of ['stones', 'ornaments']) {
item(type, 0, 0); assert.equal(f.actor['_' + type].isEquip(0), false); assert.equal(f.actor['_' + type].isUnlock(0), false);
item(type, 0, 1); item(type, 0, 1); assert.equal(f.actor['_' + type]._unlock.filter(n => n === 0).length, 1);
}
item('weapons', 0, 0); assert.equal(f.actor._weapons.currentWeapon(), 1); assert.equal(f.player.weaponChanged, true);
assert.throws(() => item('weapons', 1, 0), /保留一件/); assert.equal(f.actor._weapons.isUnlock(1), true);
item('joycoin', 0, 500); assert.equal(f.actor.joyCoinNum(), 500);
assert.throws(() => item('__proto__', 0, 1), /分类/);
assert.throws(() => item('consumables', 999, 1), /不存在/);
assert.throws(() => item('hpst', 1, 1), /编号/);
});
test('OPS wraps trainer scripts with map guard and returns structured validation failures', async () => {
const f = fixture();
const result = JSON.parse(await vm.runInContext(OPS.trainer(), f.context));
assert.equal(result.ok, true); assert.equal(result.stats.hp.value, 80);
const bad = JSON.parse(await vm.runInContext(OPS.trainer({ action: 'stat', field: 'hp', value: 999, subject: result.subject }), f.context));
assert.equal(bad.ok, false); assert.match(bad.error, /整数/);
f.context.SceneManager._scene.constructor.name = 'Scene_Title';
const menu = JSON.parse(await vm.runInContext(OPS.trainer(), f.context));
assert.equal(menu.ok, false); assert.match(menu.error, /进入地图/);
});
test('batch changes validate every entry before modifying and roll back failed game methods', () => {
const f=fixture();
assert.throws(()=>f.change({action:'batch',changes:[{type:'joycoin',id:0,count:9},{type:'ornaments',id:999,count:1}]}),/不存在/);
assert.equal(f.actor.joyCoinNum(),2);
assert.throws(()=>f.change({action:'batch',changes:[{type:'weapons',id:0,count:0},{type:'weapons',id:1,count:0}]}),/保留一件/);
f.change({action:'batch',changes:[{type:'consumables',id:0,count:6},{type:'ornaments',id:1,count:0},{type:'joycoin',id:0,count:9}]});
assert.equal(f.actor._consumables.itemNum(0),6); assert.equal(f.actor._ornaments.isUnlock(1),false); assert.equal(f.actor.joyCoinNum(),9);
const original=f.actor._ornaments.unlock;
f.actor._ornaments.unlock=()=>{throw Error('simulated failure');};
assert.throws(()=>f.change({action:'batch',changes:[{type:'joycoin',id:0,count:20},{type:'consumables',id:0,count:1},{type:'ornaments',id:1,count:1}]}),/simulated/);
assert.equal(f.actor.joyCoinNum(),9); assert.equal(f.actor._consumables.itemNum(0),6); assert.equal(f.actor._ornaments.isUnlock(1),false);
f.actor._ornaments.unlock=original;
});
test('accessories equip and unequip; bells control extra capacity without altering map collection', () => {
const f=fixture(), o=f.actor._ornaments;
o._equip=[]; o._hasBuyBellNum=1;
o.bellData=()=>({name:'bell',desc:'capacity'}); o.eventBellGainNum=()=>1;
o.hasEquipNum=()=>o._equip.filter(id=>id!==20).length; o.maxEquip=()=>1+o._hasBuyBellNum+o.eventBellGainNum();
o.unequip=id=>{o._equip=o._equip.filter(n=>n!==id);};
o.equip=id=>{if(o.hasEquipNum()>=o.maxEquip())return false;if(!o.isEquip(id))o._equip.push(id);return true;};
f.actor.bellNum=()=>o._hasBuyBellNum+o.eventBellGainNum();
f.change({action:'equip',type:'ornaments',id:0,equipped:true}); assert.equal(o.isEquip(0),true);
f.change({action:'equip',type:'ornaments',id:0,equipped:false}); assert.equal(o.isEquip(0),false);
assert.throws(()=>f.change({action:'item',type:'bell',id:0,count:0}),/地图取得/);
f.change({action:'item',type:'bell',id:0,count:8}); assert.equal(o.maxEquip(),9);
o._equip=[0,1,2,3]; f.change({action:'item',type:'bell',id:0,count:1});
assert.equal(o.hasEquipNum(),2); assert.equal(o.eventBellGainNum(),1); assert.equal(o._hasBuyBellNum,0);
assert.equal(f.api.read().items.find(i=>i.type==='bell').min,1);
const flags={'174,229,A':true,'222,6,A':false};
f.context.DataManager={_cacheBell:[[174,229,'A'],[222,6,'A']]};
f.context.$gameSelfSwitches={value:key=>!!flags[key.join(',')],setValue:(key,value)=>{flags[key.join(',')]=value;}};
o.eventBellGainNum=()=>Number(flags['174,229,A'])+Number(flags['222,6,A']);
f.change({action:'item',type:'bell',id:0,count:0,adjustMapBells:true});
assert.equal(flags['174,229,A'],false); assert.equal(f.actor.bellNum(),0); assert.equal(o.maxEquip(),1);
flags['174,229,A']=true;
o.unlock=()=>{throw Error('bell rollback');};
assert.throws(()=>f.change({action:'batch',changes:[{type:'bell',id:0,count:0,adjustMapBells:true},{type:'ornaments',id:0,count:1}]}),/bell rollback/);
assert.equal(flags['174,229,A'],true); assert.equal(o._hasBuyBellNum,0);
});
test('trainer is usable in backpack menu and rejects title screen', async () => {
const f=fixture(); f.context.SceneManager._scene.constructor.name='Scene_Menu';
const read=JSON.parse(await vm.runInContext(OPS.trainer(),f.context)); assert.equal(read.ok,true);
const changed=JSON.parse(await vm.runInContext(OPS.trainer({action:'item',type:'ornaments',id:0,count:0,subject:read.subject}),f.context));
assert.equal(changed.ok,true); assert.equal(f.actor._ornaments.isUnlock(0),false);
f.context.SceneManager._scene.constructor.name='Scene_Title';
assert.equal(JSON.parse(await vm.runInContext(OPS.trainer(),f.context)).ok,false);
});
+96
View File
@@ -0,0 +1,96 @@
const test=require('node:test');
const assert=require('node:assert/strict');
const fs=require('node:fs');
const path=require('node:path');
const vm=require('node:vm');
function fixture() {
const source=fs.readFileSync(path.join(__dirname,'../web/app.js'),'utf8');
const start=source.indexOf('// ---------- 世界地图 ----------'),end=source.indexOf('// ---------- 收集百科 ----------');
const elements=new Map(),calls=[],messages=[];
const $=key=>{
if(!elements.has(key))elements.set(key,{value:'',checked:false,innerHTML:'',clientWidth:800,clientHeight:560,
setAttribute(){},addEventListener(){},setPointerCapture(){}});
return elements.get(key);
};
const data=JSON.parse(JSON.stringify({context:'current',regions:[{id:2,name:'小镇'}],pages:[{id:1},{id:2}],
player:{cellKey:'1:3:4',page:1,regionId:2},coverage:{message:'映射 3/3'},
cells:[{key:'1:3:4',page:1,mapId:14,regionId:2,x:3,y:4,explored:true,walls:{}},
{key:'2:3:4',page:2,mapId:99,regionId:2,x:3,y:4,walls:{}}],
markers:[false,true,null].map((collected,i)=>({key:'marker-'+i,cellKey:'1:3:4',type:'bell',id:0,collected,name:'铃铛',desc:'增加容量',official:true}))}));
const context=vm.createContext({$,CURRENT_CONTEXT:'current',CURRENT_GAME:{inMap:true},ITEM_ICONS:{},ITEM_TYPES:{bell:'铃铛'},
esc:v=>String(v??''),itemArt:()=>'',loadItemIcons(){},refreshStatus:async()=>{},
api:async(route,body)=>{calls.push({route,body});return route==='/world-map'?data:{ok:true};},toast:message=>messages.push(message)});
// Use the actual shared SVG definition, including its encoding, for coin markers.
vm.runInContext(source.slice(source.indexOf('const COIN_ICON ='),source.indexOf('function invalidateTrainer')),context);
vm.runInContext(source.slice(start,end),context);
const run=code=>vm.runInContext(code,context);
context.fixtureData=data;run('WORLD_MAP=fixtureData');$('#world-map-page').value='1';
return {$,data,calls,messages,context,run};
}
test('map JSON marker keys associate correctly; overlapping page coordinates stay separated',()=>{
const f=fixture();f.run('renderWorldMap()');
assert.match(f.$('#world-map-drawing').innerHTML,/data-world-cell="1:3:4"/);
assert.doesNotMatch(f.$('#world-map-drawing').innerHTML,/data-world-cell="2:3:4"/);
assert.equal((f.$('#world-map-drawing').innerHTML.match(/data-world-marker=/g)||[]).length,3);
f.run("WORLD_MAP_SELECTED='1:3:4';renderWorldMapDetail()");
assert.match(f.$('#world-map-detail').innerHTML,/未收集 1 · 已收集 1 · 未知 1/);
});
test('mixed collected state never marks a group fully collected; unknown is not uncollected',()=>{
const f=fixture(),state=f.run('worldMapState(WORLD_MAP.markers)');
assert.equal(state.allCollected,false);assert.equal(state.missing,1);assert.equal(state.unknown,1);
f.$('#world-map-missing').checked=true;assert.equal(f.run("worldMapMarkers('1:3:4').length"),1);
f.run('renderWorldMap()');assert.doesNotMatch(f.$('#world-map-drawing').innerHTML,/data-world-marker="marker-[12]"/);
});
test('coin map markers render the shared double-rim paw SVG at every collection state',()=>{
const f=fixture();
for(const marker of f.data.markers)Object.assign(marker,{type:'coin',name:'金币',iconKey:'coin:0'});
f.run('renderWorldMap()');
const html=f.$('#world-map-drawing').innerHTML;
assert.equal((html.match(/data:image\/svg\+xml/g)||[]).length,3);
const svg=decodeURIComponent(f.run('COIN_ICON').split(',')[1]);
assert.match(svg,/viewBox="0 0 32 32"/);
assert.ok((svg.match(/<circle/g)||[]).length>=2);
});
test('map details explain conditions and branch sources without exposing JSON',()=>{
const f=fixture();Object.assign(f.data.markers[0],{conditions:[{label:'已打开大门',met:true},{label:'击败守卫',met:false},{label:'剧情条件',met:null}],branch:true,sourceNote:'击打 8 次耗尽'});
f.run("WORLD_MAP_SELECTED='1:3:4';renderWorldMapDetail()");
const html=f.$('#world-map-detail').innerHTML;
assert.match(html,/✓ 已打开大门;✕ 击败守卫;\? 剧情条件;位于条件分支,需满足剧情条件;击打 8 次耗尽/);
assert.doesNotMatch(html,/"label"|"met"/);
});
test('travel uses cell coordinates and captured context, not marker event coordinates',async()=>{
const f=fixture();await f.run("travelWorldMap('1:3:4')");
assert.deepEqual(JSON.parse(JSON.stringify(f.calls)),[{route:'/world-map/travel',body:{context:'current',mapId:14,cellX:3,cellY:4,page:1}}]);
});
test('stale contexts and game menus reject travel before any request',async()=>{
const f=fixture();f.context.CURRENT_CONTEXT='changed';await f.run("travelWorldMap('1:3:4')");
assert.equal(f.calls.length,0);assert.match(f.messages.at(-1),/刷新地图/);
f.context.CURRENT_CONTEXT='current';f.context.CURRENT_GAME.inMap=false;await f.run("travelWorldMap('1:3:4')");
assert.equal(f.calls.length,0);assert.match(f.messages.at(-1),/关闭游戏菜单/);
});
test('drag does not select or travel, but a tap selects by stable cell key',()=>{
const f=fixture(),viewport=f.$('#world-map-viewport');
const target={closest:selector=>selector==='[data-world-cell]'?{dataset:{worldCell:'1:3:4'}}:null};
const down={button:0,pointerId:1,clientX:10,clientY:20,target,currentTarget:viewport};
viewport.onpointerdown(down);viewport.onpointermove({...down,clientX:40});viewport.onpointerup(down);
assert.equal(f.run('WORLD_MAP_SELECTED'),null);assert.equal(f.calls.length,0);
viewport.onpointerdown(down);viewport.onpointerup(down);
assert.equal(f.run('WORLD_MAP_SELECTED'),'1:3:4');assert.equal(f.calls.length,0);
});
test('poll refresh preserves selection and zoom; late response cannot restore invalidated data',async()=>{
const f=fixture();f.run("WORLD_MAP_FITTED=true;WORLD_MAP_VIEW={x:123,y:234,scale:1.75};WORLD_MAP_SELECTED='1:3:4'");
await f.run('loadWorldMap()');assert.equal(f.run('WORLD_MAP_VIEW.scale'),1.75);assert.equal(f.run('WORLD_MAP_VIEW.x'),123);
assert.equal(f.run('WORLD_MAP_SELECTED'),'1:3:4');
let finish;f.context.api=()=>new Promise(resolve=>{finish=resolve;});
const pending=f.run('loadWorldMap()');f.context.CURRENT_CONTEXT='new-save';f.run('invalidateWorldMap()');finish(f.data);await pending;
assert.equal(f.run('WORLD_MAP'),null);assert.equal(f.run('WORLD_MAP_SELECTED'),null);
});
+27
View File
@@ -0,0 +1,27 @@
const test=require('node:test');
const assert=require('node:assert/strict');
const vm=require('node:vm');
const {readWorldMap}=require('../lib/world-map');
test('world schematic preserves page boundaries, per-item collection and unexplored cells without writes',()=>{
const cells=[{x:1,y:2,page:1,mapId:7,regionId:2,l:true,pt:true},
{x:1,y:2,page:2,mapId:8,regionId:3,hidden:true}];
const locations=[{key:'a',type:'hpst',id:0,mapId:7,eventId:1,x:4,y:4,collected:true,official:true},
{key:'b',type:'hpst',id:0,mapId:7,eventId:2,x:4,y:4,collected:false,official:true},
{key:'c',type:'hpst',id:0,mapId:8,eventId:3,x:4,y:4,collected:null,official:false}];
const encyclopedia={items:[{type:'hpst',id:0,name:'强化',desc:'效果'}],locations};
const before=JSON.stringify({cells,encyclopedia});
const context=vm.createContext({encyclopedia,DataManager:{_thumbnailMapCell:{a:cells[0],b:cells[1]},
makePosKey:(x,y)=>x+':'+y,getThumbnailCell:(x,y,id)=>cells.find(c=>c.mapId===id),getCurrentThumbnailCell:()=>cells[1]},
$gameSystem:{isThumbnailActive:()=>false},$dataMapInfos:{},$dataRegion:{2:{name:'森林',exploreItem:[[7,1],[7,2],[7,9]]},3:{name:'里'}},
$gameMap:{mapId:()=>8},$gamePlayer:{_x:5,_y:6}});
const r=JSON.parse(vm.runInContext(`JSON.stringify((${readWorldMap.toString()})(encyclopedia))`,context));
assert.equal(r.cells.length,2);assert.equal(r.pages.length,2);
assert.deepEqual(r.markers.map(m=>m.collected),[true,false,null]);
assert.deepEqual(r.markers.map(m=>m.cellKey),['1:1:2','1:1:2','2:1:2']);
assert.equal(r.cells[0].walls.left,true);assert.equal(r.cells[0].exits.top,true);
assert.equal(r.cells[1].explored,false);assert.equal(r.cells[1].hidden,true);
assert.equal(r.player.cellKey,'2:1:2');assert.equal(r.markers[0].name,'强化');
assert.equal(r.coverage.mappedOfficial,2);assert.equal(r.coverage.officialTotal,3);assert.deepEqual(r.coverage.missing,['7:9']);
assert.equal(JSON.stringify({cells,encyclopedia}),before);
});
+366 -27
View File
@@ -40,8 +40,10 @@ let EVENTS_CONTEXT = null;
let LOST_CONTEXT = null; let LOST_CONTEXT = null;
let STATUS_BUSY = false; let STATUS_BUSY = false;
let LAST_MAP = null; // 上一次看到的地图号,用来检测换图 let LAST_MAP = null; // 上一次看到的地图号,用来检测换图
let TRAINER = null, TRAINER_READING = false, TRAINER_WRITING = false, TRAINER_REVISION = 0; let TRAINER = null, TRAINER_READING = false, TRAINER_WRITING = false, TRAINER_REVISION = 0;
let ENCYCLOPEDIA = null, ENCYCLOPEDIA_BUSY = false, ENCYCLOPEDIA_REVISION = 0;
let ITEM_ICONS = {}, ICON_CONTEXT = null, ICON_REQUEST = null;
let COMBAT_CONTEXT=null;
// ---------- 基础工具 ---------- // ---------- 基础工具 ----------
async function api(path, body) { async function api(path, body) {
@@ -71,16 +73,36 @@ function esc(s) {
// ---------- 角色与背包 ---------- // ---------- 角色与背包 ----------
const STAT_NAMES = { coin: '金币', hp: '生命 HP', sp: '体力 SP', zp: '能量 ZP' }; const STAT_NAMES = { coin: '金币', hp: '生命 HP', sp: '体力 SP', zp: '能量 ZP' };
const ITEM_TYPES = { consumables: '消耗品', weapons: '武器', stones: '宝石', ornaments: '饰品', joycoin: '特殊材料', hpst: '特殊材料', skillst: '特殊材料' }; const ITEM_TYPES = { consumables: '食物', weapons: '咸鱼(武器)', stones: '鱼眼宝石', ornaments: '配饰', joycoin: '材料与铃铛', hpst: '材料与铃铛', skillst: '材料与铃铛', bell: '材料与铃铛', coin: '金币' };
// Gold has no inventory thumbnail; use a vector coin that stays sharp at map zoom levels.
const COIN_ICON = 'data:image/svg+xml,' + encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<circle cx="16" cy="17" r="14.2" fill="#92521b"/>
<circle cx="16" cy="15.5" r="14.2" fill="#e9a830" stroke="#794416" stroke-width="1.2"/>
<circle cx="16" cy="15.5" r="11.8" fill="#ffda69" stroke="#fff1a6" stroke-width="1.3"/>
<circle cx="16" cy="15.5" r="9.8" fill="none" stroke="#cc8b26" stroke-width=".7"/>
<g fill="#9b581b" stroke="#ffe994" stroke-width=".45">
<ellipse cx="10.2" cy="13.8" rx="1.5" ry="2.1" transform="rotate(-30 10.2 13.8)"/>
<ellipse cx="13.8" cy="10.9" rx="1.6" ry="2.2" transform="rotate(-12 13.8 10.9)"/>
<ellipse cx="18.2" cy="10.9" rx="1.6" ry="2.2" transform="rotate(12 18.2 10.9)"/>
<ellipse cx="21.8" cy="13.8" rx="1.5" ry="2.1" transform="rotate(30 21.8 13.8)"/>
<path d="M12.3 17.4C13.7 15.1 14.6 14.6 16 14.6S18.3 15.1 19.7 17.4C22.6 20.6 20.1 23 17.8 21.7Q16 20.8 14.2 21.7C11.9 23 9.4 20.6 12.3 17.4Z"/>
</g>
</svg>`);
function invalidateTrainer(message = '请先连接游戏并进入地图') { function invalidateTrainer(message = '请先连接游戏并进入地图') {
TRAINER = null; TRAINER_REVISION++; TRAINER = null; TRAINER_REVISION++;
ITEM_ICONS = {}; ICON_CONTEXT = null;
$('#trainer-role').textContent = message; $('#trainer-role').textContent = message;
$('#trainer-stats').replaceChildren(); $('#trainer-stats').replaceChildren();
$('#trainer-capacity').textContent = '';
$('#trainer-item-summary').textContent = '';
$('#trainer-items').innerHTML = `<div class="empty">${esc(message)}</div>`; $('#trainer-items').innerHTML = `<div class="empty">${esc(message)}</div>`;
trainerDisabled();
} }
function renderTrainer(resetInputs = false) { function renderTrainer(resetInputs = false) {
if (!TRAINER) return; if (!TRAINER) return;
$('#trainer-role').textContent = `当前操作角色:${TRAINER.role}`; $('#trainer-role').textContent = `当前操作角色:${TRAINER.role}`;
const capacity = TRAINER.capacity;
$('#trainer-capacity').textContent = capacity ? `配饰栏位:已用 ${capacity.used} / ${capacity.max};地图铃铛 ${capacity.mapBells},额外铃铛 ${capacity.extraBells}。调低容量会自动卸下超出的配饰。` : '';
for (const [field, label] of Object.entries(STAT_NAMES)) { for (const [field, label] of Object.entries(STAT_NAMES)) {
const stat = TRAINER.stats[field]; const stat = TRAINER.stats[field];
let row = $('#trainer-stat-' + field); let row = $('#trainer-stat-' + field);
@@ -104,19 +126,45 @@ function renderTrainer(resetInputs = false) {
} }
function numericInput(input) { return input.value.trim() === '' ? null : Number(input.value); } function numericInput(input) { return input.value.trim() === '' ? null : Number(input.value); }
function trainerDisabled() { function trainerDisabled() {
$$('#trainer-stats button, #trainer-stats input, #trainer-items button, #trainer-items input').forEach(el => { el.disabled = TRAINER_WRITING || !TRAINER; }); $$('#trainer-stats button, #trainer-stats input, #trainer-items button, #trainer-items input, #trainer-give-filtered, #trainer-clear-filtered').forEach(el => { el.disabled = TRAINER_WRITING || !TRAINER; });
$('#trainer-refresh').disabled = TRAINER_WRITING || TRAINER_READING; $('#trainer-refresh').disabled = TRAINER_WRITING || TRAINER_READING;
} }
function filteredTrainerItems() {
if (!TRAINER) return [];
const query = $('#trainer-search').value.trim().toLowerCase(), category = $('#trainer-category').value;
return 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));
}
function itemArt(item) {
const key=item.type+':'+item.id, src=item.type==='coin'?COIN_ICON:ITEM_ICONS[key];
return `<span class="item-art" data-item-icon="${esc(key)}" role="img" aria-label="${esc(item.name)}图标">${src ? `<img src="${esc(src)}" alt="" width="64" height="64">` : '<span class="icon-placeholder" aria-hidden="true">·</span>'}</span>`;
}
async function loadItemIcons(force = false) {
const context = CURRENT_CONTEXT;
if (!context || (!force && ICON_CONTEXT === context) || ICON_REQUEST?.context === context) return;
const request={context}; ICON_REQUEST=request;
try {
const result=await api('/item-icons');
if (CURRENT_CONTEXT !== context || result.context !== context || ICON_REQUEST !== request) return;
ITEM_ICONS=Object.fromEntries(Object.entries(result.icons).filter(([,src])=>typeof src==='string' && src.startsWith('data:image/png;base64,')));
ICON_CONTEXT=context;
// Update thumbnails in place so a late image response cannot replace typed quantities.
$$('[data-item-icon]').forEach(el=>{const src=ITEM_ICONS[el.dataset.itemIcon]; if(src) el.innerHTML=`<img src="${esc(src)}" alt="" width="64" height="64">`;});
if (WORLD_MAP) renderWorldMap();
} catch(e) { if (CURRENT_CONTEXT===context) toast('物品图标暂时未加载:'+e.message,'err'); }
finally { if (ICON_REQUEST===request) ICON_REQUEST=null; }
}
function renderTrainerItems() { function renderTrainerItems() {
if (!TRAINER) return; if (!TRAINER) return;
const query = $('#trainer-search').value.trim().toLowerCase(), category = $('#trainer-category').value; const items = filteredTrainerItems();
const items = TRAINER.items.filter(item => const adjustMapBells = $('#trainer-map-bells').checked;
(!category || category === item.type || (category === 'special' && ITEM_TYPES[item.type] === '特殊材料')) && $('#trainer-item-summary').textContent = `显示 ${items.length} / ${TRAINER.items.length} 项(含未拥有物品)`;
(!$('#trainer-owned').checked || item.count > 0) && `${item.name} ${item.desc} ${item.id}`.toLowerCase().includes(query));
$('#trainer-items').innerHTML = items.map(item => { $('#trainer-items').innerHTML = items.map(item => {
const counted = item.type === 'consumables' || ITEM_TYPES[item.type] === '特殊材料'; const counted = item.type === 'consumables' || ITEM_TYPES[item.type] === '材料与铃铛';
const max = item.type === 'consumables' && $('#trainer-overcap').checked ? 99999 : item.max; const max = item.type === 'consumables' && $('#trainer-overcap').checked ? 99999 : item.max;
return `<div class="item" data-type="${item.type}" data-id="${item.id}"><div class="grow"><div class="name">${esc(item.name)} ${item.equipped ? '<span class="tag ok">已装备</span>' : ''}</div><div class="meta">${ITEM_TYPES[item.type]} #${item.id} · 已有 ${item.count}${counted ? ` · 上限 ${item.max}` : ''}</div><div class="meta">${esc(item.desc)}</div></div>${counted ? `<input type="number" min="0" max="${max}" step="1" value="${item.count}" aria-label="${esc(item.name)}数量"><button data-count>设置数量</button>` : `<button data-owned="${item.count ? 0 : 1}">${item.count ? '移除' : '添加'}</button>`}</div>`; const equip = !counted && item.count && !(item.type === 'weapons' && item.equipped) ? `<button data-equip="${item.equipped ? '0' : '1'}">${item.equipped ? '卸下' : '装备'}</button>` : '';
return `<div class="item inventory-row ${item.count ? 'owned' : 'missing'}" data-type="${item.type}" data-id="${item.id}"><div class="inventory-main">${itemArt(item)}<div class="grow"><div class="name">${esc(item.name)} <span class="tag ${item.count ? 'ok' : ''}">${item.count ? '已拥有' : '未拥有'}</span> ${item.equipped ? '<span class="tag ok">已装备</span>' : ''}</div><div class="meta">${ITEM_TYPES[item.type]} #${item.id} · 已有 ${item.count}${counted ? ` · 上限 ${item.max}` : ''}</div><div class="meta item-description">${esc(item.desc)}</div></div></div><div class="item-actions">${counted ? `<input type="number" min="${item.type==='bell'&&adjustMapBells ? 0 : item.min || 0}" max="${max}" step="1" value="${item.count}" aria-label="${esc(item.name)}数量"><button data-count>设置数量</button><button data-add>加 1</button><button data-remove>${item.type === 'bell' && !adjustMapBells ? '清除额外铃铛' : '清空'}</button>` : `<button data-owned="${item.count ? 0 : 1}">${item.count ? '移除' : '添加'}</button>${equip}`}</div></div>`;
}).join('') || '<div class="empty">没有匹配物品</div>'; }).join('') || '<div class="empty">没有匹配物品</div>';
trainerDisabled(); trainerDisabled();
} }
@@ -129,6 +177,7 @@ async function loadTrainer(resetInputs = false) {
if (revision !== TRAINER_REVISION || context !== CURRENT_CONTEXT || result.context !== context) return; if (revision !== TRAINER_REVISION || context !== CURRENT_CONTEXT || result.context !== context) return;
const changed = TRAINER?.subject !== result.subject; const changed = TRAINER?.subject !== result.subject;
TRAINER = result; renderTrainer(resetInputs || changed); TRAINER = result; renderTrainer(resetInputs || changed);
loadItemIcons();
} catch (e) { } catch (e) {
if (revision === TRAINER_REVISION) invalidateTrainer('读取失败:' + e.message); if (revision === TRAINER_REVISION) invalidateTrainer('读取失败:' + e.message);
} finally { TRAINER_READING = false; trainerDisabled(); } } finally { TRAINER_READING = false; trainerDisabled(); }
@@ -141,19 +190,283 @@ async function changeTrainer(body) {
const result = await api('/trainer', { ...body, context, subject }); const result = await api('/trainer', { ...body, context, subject });
if (revision !== TRAINER_REVISION || CURRENT_CONTEXT !== context) return; if (revision !== TRAINER_REVISION || CURRENT_CONTEXT !== context) return;
TRAINER = result; renderTrainer(true); TRAINER = result; renderTrainer(true);
invalidateEncyclopedia('背包已修改,请刷新百科以更新数量');
toast(body.locked === false ? '已解除锁定' : '已修改', 'ok'); toast(body.locked === false ? '已解除锁定' : '已修改', 'ok');
} catch (e) { toast(e.message, 'err'); } } catch (e) { toast(e.message, 'err'); }
finally { TRAINER_WRITING = false; trainerDisabled(); } finally { TRAINER_WRITING = false; trainerDisabled(); }
} }
$('#trainer-refresh').onclick = () => loadTrainer(true); $('#trainer-refresh').onclick = () => { loadTrainer(true); loadItemIcons(true); };
$('#trainer-search').oninput = renderTrainerItems; $('#trainer-search').oninput = renderTrainerItems;
['trainer-category', 'trainer-owned', 'trainer-overcap'].forEach(id => { $('#' + id).onchange = renderTrainerItems; }); ['trainer-category', 'trainer-owned', 'trainer-overcap', 'trainer-map-bells'].forEach(id => { $('#' + id).onchange = renderTrainerItems; });
$('#trainer-items').onclick = ev => { $('#trainer-items').onclick = ev => {
const button = ev.target.closest('button'); const button = ev.target.closest('button');
if (!button) return; if (!button) return;
const row = button.closest('[data-type]'); const row = button.closest('[data-type]');
const item = TRAINER?.items.find(i => i.type === row.dataset.type && i.id === Number(row.dataset.id));
if (!item) return;
if (button.hasAttribute('data-equip')) return changeTrainer({action:'equip',type:item.type,id:item.id,equipped:button.dataset.equip === '1'});
const max = item.type === 'consumables' && $('#trainer-overcap').checked ? 99999 : item.max;
const adjustMapBells = $('#trainer-map-bells').checked;
changeTrainer({ action: 'item', type: row.dataset.type, id: Number(row.dataset.id), 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 }); count: button.hasAttribute('data-count') ? numericInput(row.querySelector('input')) : button.hasAttribute('data-add') ? Math.min(max, item.count + 1) : button.hasAttribute('data-remove') ? (item.type==='bell'&&adjustMapBells ? 0 : item.min || 0) : Number(button.dataset.owned), overCap: $('#trainer-overcap').checked, adjustMapBells });
};
async function batchTrainer(remove) {
if (!TRAINER || TRAINER_WRITING) return;
const adjustMapBells = $('#trainer-map-bells').checked;
const changes = filteredTrainerItems().filter(i => !(remove && i.type === 'weapons' && i.equipped)).map(i => ({ type:i.type, id:i.id,
count: remove ? (i.type==='bell'&&adjustMapBells ? 0 : i.min || 0) : i.type === 'consumables' ? Math.max(i.count, i.max) : ['weapons','stones','ornaments'].includes(i.type) ? 1 : Math.min(i.max, i.count + 1), overCap: i.count > i.max, adjustMapBells }));
if (!changes.length) return toast('没有可修改的筛选结果');
if (!confirm(`${remove ? '清除' : '获得'}当前筛选的 ${changes.length} 项物品?${remove ? '保留当前武器,移除的装备会卸下。' + (adjustMapBells ? '已允许重置地图铃铛收集点。' : '保留地图铃铛记录。') : '食物补满,装备添加,各材料与铃铛加 1。'}游戏存档会保留修改结果。`)) return;
await changeTrainer({action:'batch',changes});
}
$('#trainer-give-filtered').onclick = () => batchTrainer(false);
$('#trainer-clear-filtered').onclick = () => batchTrainer(true);
// ---------- 战斗 ----------
async function loadCombat(reset=false){
if(!CURRENT_CONTEXT)return;const context=CURRENT_CONTEXT;
try{const r=await api('/combat');if(context!==CURRENT_CONTEXT||r.context!==context)return;
if(reset||COMBAT_CONTEXT!==context){$('#combat-outgoing').value=r.outgoing;$('#combat-incoming').value=r.incoming;COMBAT_CONTEXT=context;}
$('#combat-onehit').checked=r.oneHit;$('#combat-status').textContent=`当前:伤害 ${r.outgoing} 倍 · 受击 ${r.incoming} 倍 · ${r.oneHit?'一击秒杀开启':'秒杀关闭'}。作用于玩家与莉莉娅;不突破剧情无敌,受击 0 倍仍可能有击退。`;
}catch(e){$('#combat-status').textContent=e.message;}
}
async function changeCombat(body){try{await api('/combat',body);await loadCombat(true);toast('战斗设置已更新','ok');}catch(e){toast(e.message,'err');}}
$('#combat-apply').onclick=()=>changeCombat({outgoing:numericInput($('#combat-outgoing')),incoming:numericInput($('#combat-incoming'))});
$('#combat-onehit').onchange=()=>changeCombat({oneHit:$('#combat-onehit').checked});
$('#combat-reset').onclick=()=>changeCombat({outgoing:1,incoming:1,oneHit:false});
// ---------- 世界地图 ----------
let WORLD_MAP = null, WORLD_MAP_REQUEST = null, WORLD_MAP_REVISION = 0, WORLD_MAP_TRAVELLING = false;
let WORLD_MAP_SELECTED = null, WORLD_MAP_MARKER = null, WORLD_MAP_VIEW = {x:0,y:0,scale:1}, WORLD_MAP_FITTED = false;
const WORLD_MAP_SIZE = 52;
function worldMapState(markers) {
const missing=markers.filter(m=>m.collected===false).length, collected=markers.filter(m=>m.collected===true).length;
return {missing,collected,unknown:markers.length-missing-collected,allCollected:markers.length>0&&collected===markers.length};
}
function worldMapStatus(marker) { return marker.collected===true?'已收集':marker.collected===false?'未收集':'状态未知(脚本条件)'; }
function worldMapConditions(marker) {
return (marker.conditions||[]).map(c=>typeof c==='string'?c:`${c.met===true?'✓':c.met===false?'✕':'?'} ${c.label||'未说明的条件'}`)
.concat(marker.branch?['位于条件分支,需满足剧情条件']:[])
.concat(marker.sourceNote?[marker.sourceNote]:[]).join(';');
}
function worldMapHTML(selector,html) {
const node=$(selector); if(node._worldHTML===html)return;
const scroll=node.scrollTop; node.innerHTML=html; node._worldHTML=html; node.scrollTop=scroll;
}
function invalidateWorldMap(message='地图或存档变化,正在更新地图…') {
WORLD_MAP=null; WORLD_MAP_REVISION++; WORLD_MAP_REQUEST=null; WORLD_MAP_SELECTED=null; WORLD_MAP_MARKER=null;
worldMapHTML('#world-map-drawing',''); $('#world-map-summary').textContent=message;
$('#world-map-empty').textContent=message; $('#world-map-empty').hidden=false;
worldMapHTML('#world-map-detail','<p class="hint">点击地图上的方格或收集图标查看详情。</p>');
}
async function loadWorldMap() {
if (!CURRENT_CONTEXT || WORLD_MAP_REQUEST) return;
const request={context:CURRENT_CONTEXT,revision:WORLD_MAP_REVISION}; WORLD_MAP_REQUEST=request;
try {
const result=await api('/world-map');
if(WORLD_MAP_REQUEST!==request || request.context!==CURRENT_CONTEXT || request.revision!==WORLD_MAP_REVISION || result.context!==CURRENT_CONTEXT)return;
WORLD_MAP=result;
const region=$('#world-map-region').value, type=$('#world-map-type').value;
$('#world-map-region').innerHTML='<option value="">全部区域</option>'+result.regions.map(r=>`<option value="${esc(r.id)}">${esc(r.name)}</option>`).join('');
$('#world-map-region').value=result.regions.some(r=>String(r.id)===region)?region:'';
const types=[...new Set(result.markers.map(m=>m.type))];
$('#world-map-type').innerHTML='<option value="">全部类型</option>'+types.map(t=>`<option value="${esc(t)}">${esc(ITEM_TYPES[t]||t)}</option>`).join('');
$('#world-map-type').value=types.includes(type)?type:'';
worldMapPages(); renderWorldMap(); if(!WORLD_MAP_FITTED)fitWorldMap(); loadItemIcons();
} catch(e) { if(WORLD_MAP_REQUEST===request){$('#world-map-summary').textContent='地图读取失败:'+e.message;} }
finally { if(WORLD_MAP_REQUEST===request)WORLD_MAP_REQUEST=null; }
}
function worldMapPages() {
if(!WORLD_MAP)return;
const region=$('#world-map-region').value, old=$('#world-map-page').value;
const pages=WORLD_MAP.pages.filter(p=>WORLD_MAP.cells.some(c=>String(c.page)===String(p.id)&&(!region||String(c.regionId)===region)));
$('#world-map-page').innerHTML=pages.map(p=>`<option value="${esc(p.id)}">${esc(p.label||p.name||('地图页 '+p.id))}</option>`).join('');
const player=String(WORLD_MAP.player?.page);
$('#world-map-page').value=pages.some(p=>String(p.id)===old)?old:pages.some(p=>String(p.id)===player)?player:String(pages[0]?.id??'');
}
function worldMapCells() {
if(!WORLD_MAP)return[];
const page=$('#world-map-page').value, region=$('#world-map-region').value;
return WORLD_MAP.cells.filter(c=>String(c.page)===page&&(!region||String(c.regionId)===region));
}
function worldMapMarkers(cellKey) {
const type=$('#world-map-type').value, missing=$('#world-map-missing').checked;
return (WORLD_MAP?.markers||[]).filter(m=>m.cellKey===cellKey&&(!type||m.type===type)&&(!missing||m.collected===false));
}
function renderWorldMap() {
if(!WORLD_MAP)return;
const cells=worldMapCells(), size=WORLD_MAP_SIZE;
const byPosition=new Map(cells.map(c=>[`${c.x}:${c.y}`,c]));
let shown=0;
worldMapHTML('#world-map-drawing',cells.map(c=>{
const markers=worldMapMarkers(c.key); shown+=markers.length;
const x=c.x*size,y=c.y*size;
const title=`${c.mapName||'房间 '+c.mapId} · 分块 (${c.x}, ${c.y}) · ${c.explored?'已探索':'未探索'}${c.hidden?' · 隐藏区域':''}`;
const edges=[['left',-1,0,0,0,0,size],['top',0,-1,0,0,size,0],['right',1,0,size,0,size,size],['bottom',0,1,0,size,size,size]];
const lines=edges.map(([side,dx,dy,x1,y1,x2,y2])=>{
const neighbor=byPosition.get(`${c.x+dx}:${c.y+dy}`);
if(neighbor&&neighbor.mapId===c.mapId)return '';
const open=c.walls?.[side]===false||!!c.exits?.[side];
// Door gaps preserve the original room outline without joining unrelated pages.
if(open) {
const ax=x1+(x2-x1)*.3,ay=y1+(y2-y1)*.3,bx=x1+(x2-x1)*.7,by=y1+(y2-y1)*.7;
return `<path class="world-wall" d="M${x1},${y1} L${ax},${ay} M${bx},${by} L${x2},${y2}"/>`;
}
return `<path class="world-wall" d="M${x1},${y1} L${x2},${y2}"/>`;
}).join('');
const art=markers.map((m,i)=>{
const mx=markers.length===1?14:5+(i%2)*23,my=markers.length<=2?14:5+Math.floor(i/2)*23;
const src=m.type==='coin'?COIN_ICON:ITEM_ICONS[m.iconKey||m.type+':'+m.id];
return `<g data-world-marker="${esc(m.key)}" class="world-marker ${m.collected===true?'collected':''}" transform="translate(${mx},${my})"><title>${esc(m.name)} · ${worldMapStatus(m)}\n${esc(m.desc)}</title><rect width="22" height="22" rx="4"/>${src?`<image href="${esc(src)}" width="22" height="22"/>`:`<text x="11" y="16">${esc((ITEM_TYPES[m.type]||m.type||'物').slice(0,1))}</text>`}${m.collected!==false?`<text class="world-marker-state" x="19" y="8">${m.collected===true?'✓':'?'}</text>`:''}</g>`;
}).join('');
return `<g data-world-cell="${esc(c.key)}" transform="translate(${x},${y})" class="world-cell ${c.explored?'':'unexplored'} ${c.key===WORLD_MAP.player?.cellKey?'current':''} ${c.key===WORLD_MAP_SELECTED?'selected':''}"><title>${esc(title)}</title><rect class="world-floor" width="${size}" height="${size}"/>${lines}<rect class="world-cell-outline" x="2" y="2" width="48" height="48"/>${art}${c.key===WORLD_MAP.player?.cellKey?'<circle class="world-player" cx="44" cy="44" r="5"/>':''}</g>`;
}).join(''));
$('#world-map-empty').hidden=!!cells.length; $('#world-map-empty').textContent='该区域在本地图页没有分块';
$('#world-map-summary').textContent=`${cells.length} 个分块 · ${shown} 个收集点 · ${Math.round(WORLD_MAP_VIEW.scale*100)}%`;
const coverage=WORLD_MAP.coverage;
$('#world-map-coverage').textContent=typeof coverage==='string'?coverage:coverage?.message||'包含未探索分块;未知收集状态请查看详情条件。';
applyWorldMapView(); renderWorldMapDetail();
}
function applyWorldMapView() {
const v=WORLD_MAP_VIEW;
$('#world-map-drawing').setAttribute('transform',`translate(${v.x},${v.y}) scale(${v.scale})`);
}
function fitWorldMap() {
const cells=worldMapCells(); if(!cells.length)return;
const box=$('#world-map-viewport'), minX=Math.min(...cells.map(c=>c.x))*WORLD_MAP_SIZE,minY=Math.min(...cells.map(c=>c.y))*WORLD_MAP_SIZE;
const width=(Math.max(...cells.map(c=>c.x))*WORLD_MAP_SIZE-minX+WORLD_MAP_SIZE),height=(Math.max(...cells.map(c=>c.y))*WORLD_MAP_SIZE-minY+WORLD_MAP_SIZE);
const scale=Math.max(.12,Math.min(2,(box.clientWidth-36)/width,(box.clientHeight-36)/height));
WORLD_MAP_VIEW={scale,x:(box.clientWidth-width*scale)/2-minX*scale,y:(box.clientHeight-height*scale)/2-minY*scale};
WORLD_MAP_FITTED=true; renderWorldMap();
}
function zoomWorldMap(factor,x,y) {
const box=$('#world-map-viewport'),v=WORLD_MAP_VIEW;
x??=box.clientWidth/2; y??=box.clientHeight/2;
const scale=Math.min(4,Math.max(.12,v.scale*factor)),ratio=scale/v.scale;
WORLD_MAP_VIEW={scale,x:x-(x-v.x)*ratio,y:y-(y-v.y)*ratio};renderWorldMap();
}
function renderWorldMapDetail() {
const cell=WORLD_MAP?.cells.find(c=>c.key===WORLD_MAP_SELECTED);
if(!cell){worldMapHTML('#world-map-detail','<p class="hint">点击地图上的方格或收集图标查看详情。</p>');return;}
const markers=WORLD_MAP.markers.filter(m=>m.cellKey===cell.key), state=worldMapState(markers);
worldMapHTML('#world-map-detail',`<h3>${esc(cell.mapName||'房间 '+cell.mapId)}</h3><p class="meta">地图 #${cell.mapId} · 分块 (${cell.x}, ${cell.y})<br>${cell.explored?'已探索':'未探索'}${cell.hidden?' · 隐藏区域':''}</p><button id="world-map-travel" class="primary" ${WORLD_MAP_TRAVELLING?'disabled':''}>${WORLD_MAP_TRAVELLING?'传送中…':'传送到此分块'}</button><p class="hint">目标分块附近选择空处,抵达后悬停并开启碰撞。请先关闭游戏菜单回到地图。</p><p class="meta">未收集 ${state.missing} · 已收集 ${state.collected} · 未知 ${state.unknown}</p>${markers.map(m=>`<article class="world-detail-item ${m.key===WORLD_MAP_MARKER?'focused':''}"><div class="inventory-main">${itemArt(m)}<b>${esc(m.name||ITEM_TYPES[m.type]||m.type)}</b></div><span class="tag ${m.collected===true?'':'common'}">${worldMapStatus(m)}</span>${!m.official?'<span class="tag">额外脚本点</span>':''}<p>${esc(m.desc||'暂无用途说明')}</p>${worldMapConditions(m)?`<p class="meta">条件与来源:${esc(worldMapConditions(m))}</p>`:''}</article>`).join('')||'<p class="hint">此分块没有已知收集点。</p>'}`);
$('#world-map-travel').onclick=()=>travelWorldMap(cell.key);
}
async function travelWorldMap(key) {
if(!WORLD_MAP||WORLD_MAP_TRAVELLING)return;
if(WORLD_MAP.context!==CURRENT_CONTEXT)return toast('地图或存档已变化,请刷新地图','err');
if(!CURRENT_GAME?.inMap)return toast('请先关闭游戏菜单回到地图,再传送','err');
const cell=WORLD_MAP.cells.find(c=>c.key===key); if(!cell)return;
const context=WORLD_MAP.context; WORLD_MAP_TRAVELLING=true; renderWorldMapDetail();
try {const result=await api('/world-map/travel',{context,mapId:cell.mapId,cellX:cell.x,cellY:cell.y,page:cell.page});toast(result.positionNote||'已传送到目标分块附近,悬停中,碰撞已恢复','ok');await refreshStatus();}
catch(e){toast(e.message,'err');}finally{WORLD_MAP_TRAVELLING=false;renderWorldMapDetail();}
}
$('#world-map-refresh').onclick=()=>loadWorldMap();
$('#world-map-region').onchange=()=>{WORLD_MAP_SELECTED=null;WORLD_MAP_MARKER=null;worldMapPages();fitWorldMap();};
$('#world-map-page').onchange=()=>{WORLD_MAP_SELECTED=null;WORLD_MAP_MARKER=null;fitWorldMap();};
$('#world-map-type').onchange=renderWorldMap; $('#world-map-missing').onchange=renderWorldMap;
$('#world-map-fit').onclick=fitWorldMap;
$('#world-map-plus').onclick=()=>zoomWorldMap(1.25); $('#world-map-minus').onclick=()=>zoomWorldMap(.8);
$('#world-map-locate').onclick=()=>{
if(!WORLD_MAP?.player?.cellKey)return toast('当前房间不在原版地图布局中');
const cell=WORLD_MAP.cells.find(c=>c.key===WORLD_MAP.player.cellKey); if(!cell)return;
$('#world-map-region').value=String(cell.regionId);worldMapPages();$('#world-map-page').value=String(cell.page);
WORLD_MAP_SELECTED=cell.key;WORLD_MAP_MARKER=null;fitWorldMap();
};
let WORLD_MAP_DRAG=null;
$('#world-map-viewport').onpointerdown=e=>{
if(e.button!==0)return;
WORLD_MAP_DRAG={id:e.pointerId,x:e.clientX,y:e.clientY,vx:WORLD_MAP_VIEW.x,vy:WORLD_MAP_VIEW.y,moved:false,target:e.target};
e.currentTarget.setPointerCapture(e.pointerId);
};
$('#world-map-viewport').onpointermove=e=>{
const drag=WORLD_MAP_DRAG;if(!drag||drag.id!==e.pointerId)return;
const dx=e.clientX-drag.x,dy=e.clientY-drag.y;if(Math.hypot(dx,dy)>5)drag.moved=true;
if(drag.moved){WORLD_MAP_VIEW.x=drag.vx+dx;WORLD_MAP_VIEW.y=drag.vy+dy;applyWorldMapView();}
};
$('#world-map-viewport').onpointerup=e=>{
const drag=WORLD_MAP_DRAG;WORLD_MAP_DRAG=null;if(!drag||drag.moved)return;
const cell=drag.target.closest('[data-world-cell]'); if(!cell)return;
WORLD_MAP_SELECTED=cell.dataset.worldCell;WORLD_MAP_MARKER=drag.target.closest('[data-world-marker]')?.dataset.worldMarker||null;renderWorldMap();
};
$('#world-map-viewport').onpointercancel=()=>{WORLD_MAP_DRAG=null;};
$('#world-map-viewport').addEventListener('wheel',e=>{e.preventDefault();const r=e.currentTarget.getBoundingClientRect();zoomWorldMap(e.deltaY<0?1.15:1/1.15,e.clientX-r.left,e.clientY-r.top);},{passive:false});
$('#world-map-viewport').onkeydown=e=>{
if(e.key==='+'||e.key==='='){e.preventDefault();zoomWorldMap(1.25);}else if(e.key==='-'){e.preventDefault();zoomWorldMap(.8);}
else if(['ArrowLeft','ArrowRight','ArrowUp','ArrowDown'].includes(e.key)){e.preventDefault();WORLD_MAP_VIEW.x+=e.key==='ArrowLeft'?40:e.key==='ArrowRight'?-40:0;WORLD_MAP_VIEW.y+=e.key==='ArrowUp'?40:e.key==='ArrowDown'?-40:0;applyWorldMapView();}
};
// ---------- 收集百科 ----------
function invalidateEncyclopedia(message = '请刷新百科') {
ENCYCLOPEDIA = null; ENCYCLOPEDIA_REVISION++;
$('#encyclopedia-summary').textContent = message;
$('#encyclopedia-regions').replaceChildren();
$('#encyclopedia-items').innerHTML = `<div class="empty">${esc(message)}</div>`;
$('#encyclopedia-coverage').textContent = '';
}
async function loadEncyclopedia() {
if (ENCYCLOPEDIA_BUSY || !CURRENT_CONTEXT) return;
const context = CURRENT_CONTEXT, revision = ENCYCLOPEDIA_REVISION;
ENCYCLOPEDIA_BUSY = true; $('#encyclopedia-refresh').disabled = true;
$('#encyclopedia-summary').textContent = '正在读取物品与地图收集记录…';
try {
const result = await api('/encyclopedia');
if (revision !== ENCYCLOPEDIA_REVISION || context !== CURRENT_CONTEXT || result.context !== context) return;
ENCYCLOPEDIA = result;
const area = $('#encyclopedia-area'), previous = area.value;
const areas = [...new Set(result.locations.map(l => l.area).concat(result.shops.map(l => l.area)))].sort((a,b) => a.localeCompare(b,'zh-CN'));
area.innerHTML = '<option value="">全部区域</option>' + areas.map(a => `<option value="${esc(a)}">${esc(a)}</option>`).join('');
if (areas.includes(previous)) area.value = previous;
$('#encyclopedia-summary').textContent = `${result.stats.items} 种物品 · ${result.stats.locations} 条地图来源 · 地图收集 ${result.stats.collected} / ${result.stats.total} · ${result.stats.shops} 处商店入口`;
$('#encyclopedia-coverage').textContent = result.coverage;
$('#encyclopedia-regions').innerHTML = result.regions.map(r => `<button class="collection-region" data-area="${esc(r.area)}"><b>${esc(r.area)}</b><span>已收集 ${r.collected} / ${r.total} · 剩余 ${r.total-r.collected}</span><progress max="${r.total}" value="${r.collected}"></progress></button>`).join('');
renderEncyclopedia();
} catch(e) {
if (revision === ENCYCLOPEDIA_REVISION) invalidateEncyclopedia('读取失败:' + e.message);
} finally { ENCYCLOPEDIA_BUSY = false; $('#encyclopedia-refresh').disabled = false; }
}
function renderEncyclopedia() {
if (!ENCYCLOPEDIA) return;
const query = $('#encyclopedia-search').value.trim().toLowerCase(), type = $('#encyclopedia-category').value;
const area = $('#encyclopedia-area').value, status = $('#encyclopedia-status').value;
const matchStatus = l => status === 'uncollected' ? l.collected === false : status === 'collected' ? l.collected === true : status === 'unknown' ? l.collected === null : true;
const rows = [];
for (const item of ENCYCLOPEDIA.items) {
if (type && type !== item.type && !(type === 'special' && ITEM_TYPES[item.type] === '材料与铃铛')) continue;
if ((status === 'owned' && item.count <= 0) || (status === 'missing' && item.count > 0)) continue;
const textMatch = `${item.name} ${item.desc} ${item.id}`.toLowerCase().includes(query);
const locations = item.locations.filter(l => (!area || l.area === area) && matchStatus(l) && (textMatch || `${l.area} ${l.mapName} ${l.mapId} ${l.eventName}`.toLowerCase().includes(query)));
if (['uncollected','collected','unknown'].includes(status) && !locations.length) continue;
const shopMatches = item.shopCandidate ? ENCYCLOPEDIA.shops.filter(l => (!area || l.area === area) && (textMatch || `${l.area} ${l.mapName} ${l.mapId}`.toLowerCase().includes(query))) : [];
if (area && !locations.length && !shopMatches.length) continue;
if (!textMatch && !locations.length && !shopMatches.length) continue;
const got = item.locations.filter(l => l.collected === true).length, missed = item.locations.filter(l => l.collected === false).length;
const locationHTML = locations.map(l => {
const label = l.collected === true ? '地图已收集' : l.collected === false ? '地图未收集' : '收集状态未知';
const conditions = l.conditions.map(c => `${c.met === true ? '✓' : c.met === false ? '✕' : '?'} ${c.label}`).concat(l.branch ? ['位于条件分支,需满足剧情条件'] : []).concat(l.sourceNote ? [l.sourceNote] : []);
return `<div class="catalog-location"><div class="grow"><span class="tag ${l.collected === true ? 'ok' : l.collected === false ? 'warn' : ''}">${label}</span> <b>${esc(l.area)}</b> · ${esc(l.mapName)} (#${l.mapId})<div class="meta">事件 ${l.eventId} · 第 ${l.pageIndex+1} 页 · 坐标 (${l.x}, ${l.y})${l.amount > 1 ? ` · 数量 ${l.amount}` : ''}${l.official ? ' · 计入地图收集率' : ''}</div>${conditions.length ? `<div class="meta">${esc(conditions.join(';'))}</div>` : ''}</div><button data-location="${esc(l.key)}" title="前往目标分块边缘附近的空处,保持悬停和碰撞">前往分块边缘</button></div>`;
}).join('');
const shopHTML = item.shopCandidate && !['uncollected','collected','unknown'].includes(status) ? `<details class="shop-sources"><summary>商店候选 · 基础价格 ${item.price} 金币 · ${shopMatches.length} 处入口</summary><p class="hint">是否售卖受任务及购买进度影响,请到店确认。此处列的是商店入口,不代表每家店当前都有库存。</p>${shopMatches.map(l => `<div class="catalog-location"><span class="grow">${esc(l.area)} · ${esc(l.mapName)} (#${l.mapId}) · (${l.x}, ${l.y})</span><button data-shop="${ENCYCLOPEDIA.shops.indexOf(l)}">前往商店附近</button></div>`).join('')}</details>` : '';
rows.push(`<details class="catalog-item" ${query || status === 'uncollected' || status === 'unknown' ? 'open' : ''}><summary><span class="catalog-title">${esc(item.name)} <span class="tag">${ITEM_TYPES[item.type]}</span></span><span class="meta">当前 ${item.count > 0 ? `拥有 ${item.count}` : '未拥有'} · 地图已收集 ${got} / 未收集 ${missed} · ${item.locations.length} 条来源</span></summary><div class="catalog-content"><p>${esc(item.desc)}</p>${locationHTML || '<p class="hint">当前筛选下没有已定位的地图来源。</p>'}${shopHTML}</div></details>`);
}
$('#encyclopedia-items').innerHTML = rows.join('') || '<div class="empty">没有符合筛选条件的物品或地点。</div>';
}
async function goCatalogLocation(location) {
if (!location || !ENCYCLOPEDIA || ENCYCLOPEDIA.context !== CURRENT_CONTEXT) return toast('请刷新百科后重试', 'err');
if (!CURRENT_GAME?.inMap) return toast('请先关闭游戏菜单,回到地图后再前往', 'err');
try {
const result = await api('/encyclopedia/travel', {mapId:location.mapId,eventId:location.eventId});
toast(`已到 ${location.mapName} (#${location.mapId}) 的${result.positionNote||'目标分块空处'},保持悬停,碰撞已恢复;物品请自行前往获取。`, 'ok');
refreshStatus();
} catch(e) { toast(e.message, 'err'); }
}
$('#encyclopedia-refresh').onclick = loadEncyclopedia;
$('#encyclopedia-search').oninput = renderEncyclopedia;
['encyclopedia-category','encyclopedia-area','encyclopedia-status'].forEach(id => { $('#' + id).onchange = renderEncyclopedia; });
$('#encyclopedia-regions').onclick = ev => { const button=ev.target.closest('[data-area]'); if (button) { $('#encyclopedia-area').value=button.dataset.area; $('#encyclopedia-status').value='uncollected'; renderEncyclopedia(); } };
$('#encyclopedia-items').onclick = ev => {
const button=ev.target.closest('button'); if (!button || !ENCYCLOPEDIA) return;
if (button.hasAttribute('data-location')) return goCatalogLocation(ENCYCLOPEDIA.locations.find(l => l.key === button.dataset.location));
else if (button.hasAttribute('data-shop')) return goCatalogLocation(ENCYCLOPEDIA.shops[Number(button.dataset.shop)]);
}; };
// ---------- 标签页 ---------- // ---------- 标签页 ----------
@@ -162,6 +475,9 @@ $$('.tab').forEach(tab => {
$$('.tab').forEach(t => t.classList.toggle('active', t === tab)); $$('.tab').forEach(t => t.classList.toggle('active', t === tab));
$$('.panel').forEach(p => p.classList.toggle('active', p.id === 'panel-' + tab.dataset.panel)); $$('.panel').forEach(p => p.classList.toggle('active', p.id === 'panel-' + tab.dataset.panel));
if (tab.dataset.panel === 'trainer') loadTrainer(); if (tab.dataset.panel === 'trainer') loadTrainer();
if (tab.dataset.panel === 'trainer') loadCombat();
if (tab.dataset.panel === 'encyclopedia' && !ENCYCLOPEDIA) loadEncyclopedia();
if (tab.dataset.panel === 'world-map') loadWorldMap();
}; };
}); });
@@ -172,7 +488,7 @@ $$('.tab').forEach(tab => {
* *
* 背景:沙盒和原版默认共用同一个调试端口,面板光看"已连接"分不出对面是谁。 * 背景:沙盒和原版默认共用同一个调试端口,面板光看"已连接"分不出对面是谁。
* 用户以为在沙盒里操作、实际改的是原版存档,是这套工具最危险的误用方式 * 用户以为在沙盒里操作、实际改的是原版存档,是这套工具最危险的误用方式
* (见 复查与隐患.md 坑 5)。这一格是最后一道兜底。 * (见 tool/开发文档.md 的运行结构与配置)。这一格用于核对实际实例。
* *
* 判断由服务端做(它有 gameDir 和 sandboxDir 两个路径可以比对), * 判断由服务端做(它有 gameDir 和 sandboxDir 两个路径可以比对),
* 前端只负责显示,免得两边各写一套判断逻辑。 * 前端只负责显示,免得两边各写一套判断逻辑。
@@ -215,12 +531,15 @@ async function refreshStatus() {
$('#connText').textContent = s.connected ? (s.gameSelf?.inMap ? `已连接游戏(端口 ${s.cdpPort})` : '已连接,请在游戏里读档进入地图') : (s.gameRunning ? '调试端口可达,但连接未建立' : '未检测到游戏 — 请先启动游戏'); $('#connText').textContent = s.connected ? (s.gameSelf?.inMap ? `已连接游戏(端口 ${s.cdpPort})` : '已连接,请在游戏里读档进入地图') : (s.gameRunning ? '调试端口可达,但连接未建立' : '未检测到游戏 — 请先启动游戏');
renderGameSrc(s); renderGameSrc(s);
applyStoryState(s.storyText || { state: 'disconnected', loaded: false, connection: null }); applyStoryState(s.storyText || { state: 'disconnected', loaded: false, connection: null });
const nextContext = s.connected && s.gameSelf?.inMap ? s.context : null; const nextContext = s.connected && (s.gameSelf?.inMap || s.gameSelf?.inMenu) ? s.context : null;
CURRENT_GAME = s.gameSelf; CURRENT_GAME = s.gameSelf;
if (CURRENT_CONTEXT !== nextContext) { if (CURRENT_CONTEXT !== nextContext) {
CURRENT_CONTEXT = nextContext; CURRENT_CONTEXT = nextContext;
EVENTS_CONTEXT = LOST_CONTEXT = null; EVENTS_CONTEXT = LOST_CONTEXT = null;
invalidateTrainer(nextContext ? '正在读取角色与背包…' : '请先连接游戏并进入地图'); invalidateTrainer(nextContext ? '正在读取角色与背包…' : '请先连接游戏并进入地图');
COMBAT_CONTEXT=null;
invalidateEncyclopedia(nextContext ? '地图或存档变化,请刷新百科' : '请先连接游戏并读档');
invalidateWorldMap(nextContext ? '地图或存档变化,正在更新地图…' : '请先连接游戏并读档');
EVENTS = []; LOST = []; VISIBLE = []; EVENTS = []; LOST = []; VISIBLE = [];
renderEvents(); renderLost(); renderEvents(); renderLost();
if (TRIGGERS.length) loadTriggers(); if (TRIGGERS.length) loadTriggers();
@@ -228,15 +547,19 @@ async function refreshStatus() {
const where = $('#photo-target'); const where = $('#photo-target');
if (where) where.textContent = s.gameSelf?.saveDir || '未连接游戏,不能保存'; if (where) where.textContent = s.gameSelf?.saveDir || '未连接游戏,不能保存';
if (s.connected && s.gameSelf?.inMap) { if (s.connected && (s.gameSelf?.inMap || s.gameSelf?.inMenu)) {
if ($('#panel-trainer').classList.contains('active')) loadTrainer(); if ($('#panel-trainer').classList.contains('active')) loadTrainer();
if ($('#panel-trainer').classList.contains('active')) loadCombat();
if ($('#panel-encyclopedia').classList.contains('active') && !ENCYCLOPEDIA) loadEncyclopedia();
if ($('#panel-world-map').classList.contains('active')) loadWorldMap();
if (!s.gameSelf.inMap) $('#connText').textContent = '已连接(游戏菜单中,可管理背包及查看百科)';
try { try {
const snap = await api('/snapshot'); const snap = await api('/snapshot');
$('#st-map').textContent = `${snap.mapId} ${snap.mapName}`.trim(); $('#st-map').textContent = `${snap.mapId} ${snap.mapName}`.trim();
$('#st-pos').textContent = `(${snap.x}, ${snap.y})`; $('#st-pos').textContent = `(${snap.x}, ${snap.y})`;
$('#st-hp').textContent = snap.hp ?? '—'; $('#st-hp').textContent = snap.hp ?? '—';
$('#st-sw').textContent = snap.switchesOn ?? '—'; $('#st-sw').textContent = snap.switchesOn ?? '—';
$('#st-grav').textContent = snap.gravitySuspended ? '已关' : '正常'; $('#st-grav').textContent = snap.gravitySuspended ? '悬停' : '正常';
$('#st-inv').textContent = snap.invincible ? '已开' : '关'; $('#st-inv').textContent = snap.invincible ? '已开' : '关';
// 换地图了 → 重新拉一次跳转点,好把新地图的"已抹除"状态标出来 // 换地图了 → 重新拉一次跳转点,好把新地图的"已抹除"状态标出来
@@ -247,7 +570,7 @@ async function refreshStatus() {
} }
$('#photo-state').textContent = snap.stagedActors ? `摆拍角色 ${snap.stagedActors} 个:存档照常;不推荐此时保存,读档后摆拍状态可能无法完整恢复` : ''; $('#photo-state').textContent = snap.stagedActors ? `摆拍角色 ${snap.stagedActors} 个:存档照常;不推荐此时保存,读档后摆拍状态可能无法完整恢复` : '';
// 顶部常驻开关:高亮 = 这个开关是"开着/显示着"的 // 顶部常驻开关:高亮 = 这个开关是"开着/显示着"的
$('#q-gravity').classList.toggle('on', !snap.gravitySuspended); // 高亮=重力正常 $('#q-gravity').classList.toggle('on', !!snap.gravitySuspended);
$('#q-invincible').classList.toggle('on', !!snap.invincible); $('#q-invincible').classList.toggle('on', !!snap.invincible);
$('#q-noclip').classList.toggle('on', !!snap.noclip); $('#q-noclip').classList.toggle('on', !!snap.noclip);
if (snap.lilyVisible !== null && snap.lilyVisible !== undefined) { if (snap.lilyVisible !== null && snap.lilyVisible !== undefined) {
@@ -271,6 +594,8 @@ async function refreshStatus() {
applyStoryState({ state: 'disconnected', loaded: false, connection: null }); applyStoryState({ state: 'disconnected', loaded: false, connection: null });
CURRENT_CONTEXT = null; CURRENT_GAME = null; EVENTS_CONTEXT = LOST_CONTEXT = null; CURRENT_CONTEXT = null; CURRENT_GAME = null; EVENTS_CONTEXT = LOST_CONTEXT = null;
invalidateTrainer('连接已断开,请重连后刷新'); invalidateTrainer('连接已断开,请重连后刷新');
invalidateEncyclopedia('连接已断开,请重连后刷新');
invalidateWorldMap('连接已断开,请重连后刷新');
renderGameSrc({gameRunning:false}); renderGameSrc({gameRunning:false});
$('#photo-target').textContent = '未连接游戏,不能保存'; $('#photo-target').textContent = '未连接游戏,不能保存';
$('#photo-state').textContent = ''; $('#photo-state').textContent = '';
@@ -320,14 +645,28 @@ $('#tp-land').onclick = async () => {
btn.disabled = false; btn.disabled = false;
}; };
$$('[data-nudge]').forEach(b => { let nudgeTimer = null, nudgeHeld = null, nudgeBusy = false;
b.onclick = () => { function stopNudge() { nudgeHeld = null; clearTimeout(nudgeTimer); }
const [dx, dy] = b.dataset.nudge.split(',').map(Number); async function nudge(button) {
$('#tp-x').value = Number($('#tp-x').value || 0) + dx; if (nudgeBusy) return;
$('#tp-y').value = Number($('#tp-y').value || 0) + dy; nudgeBusy = true;
$('#tp-go').click(); const context = CURRENT_CONTEXT;
}; try {
const [dx, dy] = button.dataset.nudge.split(',').map(Number);
const r = await api('/nudge', {dx,dy,pixels:Number($('#tp-step').value)});
if (context !== CURRENT_CONTEXT) return stopNudge();
$('#tp-x').value = Math.round(r.x * 10000) / 10000; $('#tp-y').value = Math.round(r.y * 10000) / 10000;
$('#tp-state').textContent = '悬停中';
} catch(e) { stopNudge(); toast(e.message, 'err'); }
finally { nudgeBusy = false; if (nudgeHeld === button) nudgeTimer = setTimeout(() => nudge(button), 100); }
}
$$('[data-nudge]').forEach(button => {
button.onpointerdown = ev => { if (ev.button !== 0) return; ev.preventDefault(); stopNudge(); nudgeHeld = button; button.setPointerCapture?.(ev.pointerId); nudge(button); };
button.onpointerup = button.onpointercancel = button.onlostpointercapture = stopNudge;
button.onclick = ev => { if (ev.detail === 0) nudge(button); };
}); });
window.addEventListener('blur', stopNudge);
document.addEventListener('visibilitychange', () => { if (document.hidden) stopNudge(); });
// ---------- 本图事件 ---------- // ---------- 本图事件 ----------
let EVENTS = []; let EVENTS = [];
@@ -847,11 +1186,11 @@ async function flipChip(id, fn) {
el.disabled = false; el.disabled = false;
} }
// 重力:高亮 = 正常。点一下切换成"关掉重力"(角色悬空) // 悬停:高亮 = 保持高度。关闭时走安全落地检查。
$('#q-gravity').onclick = () => flipChip('q-gravity', on => api('/gravity', { on: !on })); $('#q-gravity').onclick = () => flipChip('q-gravity', on => api('/gravity', { on }));
// 无敌 / 穿墙:高亮 = 开启 // 无敌 / 穿墙:高亮 = 开启
$('#q-invincible').onclick = () => flipChip('q-invincible', on => api('/invincible', { on })); $('#q-invincible').onclick = () => flipChip('q-invincible', on => api('/invincible', { on }));
$('#q-noclip').onclick = () => flipChip('q-noclip', on => api('/noclip', { on })); $('#q-noclip').onclick = () => flipChip('q-noclip', async on => { const r = await api('/noclip', { on }); if (r.warning) toast(r.warning, 'err'); });
// 莉莉娅:高亮 = 显示中 // 莉莉娅:高亮 = 显示中
// ⚠️ flipChip 传进来的是**按钮的新状态**(true = 希望莉莉娅显示),直接当 show 用。 // ⚠️ flipChip 传进来的是**按钮的新状态**(true = 希望莉莉娅显示),直接当 show 用。
// 之前写成 show: !on,等于每次都"再显示一遍",点了毫无反应(用户报过)。 // 之前写成 show: !on,等于每次都"再显示一遍",点了毫无反应(用户报过)。
+55 -9
View File
@@ -35,7 +35,7 @@
<!-- 常驻快捷开关:不放在二级页面里,任何标签页下都能直接点 --> <!-- 常驻快捷开关:不放在二级页面里,任何标签页下都能直接点 -->
<section class="quickbar"> <section class="quickbar">
<span class="qb-label">状态</span> <span class="qb-label">状态</span>
<button class="chip" id="q-gravity" title="高亮 = 重力正常;点一下关掉重力,角色会悬空">重力</button> <button class="chip" id="q-gravity" title="高亮 = 悬停;空中可连续点跳、按下下降,保留技能位移;再点安全落地">悬停</button>
<button class="chip" id="q-invincible" title="高亮 = 无敌中(不会掉血)">无敌</button> <button class="chip" id="q-invincible" title="高亮 = 无敌中(不会掉血)">无敌</button>
<button class="chip" id="q-noclip" title="高亮 = 穿墙中(无视碰撞体)">穿墙</button> <button class="chip" id="q-noclip" title="高亮 = 穿墙中(无视碰撞体)">穿墙</button>
<button class="chip on" id="q-lily" title="高亮 = 莉莉娅在画面里;点一下把她藏起来">莉莉娅</button> <button class="chip on" id="q-lily" title="高亮 = 莉莉娅在画面里;点一下把她藏起来">莉莉娅</button>
@@ -54,15 +54,43 @@
<div class="note" id="photo-state" role="status"></div> <div class="note" id="photo-state" role="status"></div>
<nav class="tabs"> <nav class="tabs">
<button class="tab active" data-panel="teleport">传送</button> <button class="tab active" data-panel="teleport">传送</button>
<button class="tab" data-panel="world-map">地图</button>
<button class="tab" data-panel="story">对话剧情</button> <button class="tab" data-panel="story">对话剧情</button>
<button class="tab" data-panel="trainer">角色与背包</button> <button class="tab" data-panel="trainer">角色与背包</button>
<button class="tab" data-panel="encyclopedia">收集百科</button>
<button class="tab" data-panel="switches">开关</button> <button class="tab" data-panel="switches">开关</button>
<button class="tab" data-panel="saves">存档与沙盒</button> <button class="tab" data-panel="saves">存档与沙盒</button>
<button class="tab" data-panel="settings">设置</button> <button class="tab" data-panel="settings">设置</button>
</nav> </nav>
<main> <main>
<section class="panel" id="panel-world-map">
<div class="card">
<h2>世界地图 <span class="tag">原版房间布局</span></h2>
<div class="row world-map-controls">
<label>大区域 <select id="world-map-region"><option value="">全部区域</option></select></label>
<label>地图页 <select id="world-map-page" aria-label="地图页"></select></label>
<label>收集物 <select id="world-map-type"><option value="">全部类型</option></select></label>
<label><input type="checkbox" id="world-map-missing">只看未收集</label>
</div>
<div class="row mt"><button id="world-map-locate">定位当前</button><button id="world-map-fit">适应地图</button><button id="world-map-minus" aria-label="缩小地图">−</button><button id="world-map-plus" aria-label="放大地图">+</button><button id="world-map-refresh">刷新</button><span id="world-map-summary" class="meta" role="status">连接游戏后可查看</span></div>
<p class="hint">拖动平移,滚轮缩放;点击方格或图标查看详情。彩色:未收集 · 灰色 ✓:已收集 · ?:状态未知 · 虚线:未探索 · 绿色框:当前位置。查看完整布局不会更改探索记录。</p>
<div class="world-map-layout">
<div id="world-map-viewport" tabindex="0" aria-label="世界地图:方向键平移,加减键缩放">
<svg id="world-map-svg" role="img" aria-label="原版房间与收集物地图"><g id="world-map-drawing"></g></svg>
<div id="world-map-empty" class="empty">连接游戏后打开地图</div>
</div>
<aside id="world-map-detail" class="world-map-detail" aria-label="所选分块详情"><p class="hint">点击地图上的方格或收集图标查看详情。</p></aside>
</div>
<p class="hint" id="world-map-coverage"></p>
</div>
</section>
<section class="panel" id="panel-trainer"> <section class="panel" id="panel-trainer">
<div class="card">
<h2>伤害与受击倍率</h2>
<div class="row"><label>伤害倍率 <input id="combat-outgoing" type="number" min="0" max="1000" step="0.1" value="1"></label><label>受击倍率 <input id="combat-incoming" type="number" min="0" max="1000" step="0.1" value="1"></label><button id="combat-apply" class="primary">应用倍率</button><label><input type="checkbox" id="combat-onehit">一击秒杀</label><button id="combat-reset">恢复正常战斗</button></div>
<p id="combat-status" class="hint">1 倍为正常;0 倍不扣血。秒杀在玩家或莉莉娅的有效攻击命中敌人时生效,不突破剧情无敌。倍率不改变基础攻击力。</p>
</div>
<div class="card"> <div class="card">
<h2>金币、生命与能量</h2> <h2>金币、生命与能量</h2>
<div class="row"><span id="trainer-role">请先连接游戏并进入地图</span><button id="trainer-refresh">刷新数值与背包</button></div> <div class="row"><span id="trainer-role">请先连接游戏并进入地图</span><button id="trainer-refresh">刷新数值与背包</button></div>
@@ -71,19 +99,22 @@
<p class="hint">数值锁定只在本次游戏中有效;读档、新游戏或面板失联超过 2 分钟自动解除。「全部还原」也会解除锁定,已修改的数值与物品不回退。</p> <p class="hint">数值锁定只在本次游戏中有效;读档、新游戏或面板失联超过 2 分钟自动解除。「全部还原」也会解除锁定,已修改的数值与物品不回退。</p>
</div> </div>
<div class="card"> <div class="card">
<h2>背包物品</h2> <h2>全部背包物品</h2>
<p id="trainer-capacity" class="note"></p>
<p class="hint">直接修改当前背包,游戏正常存档时会保存。移除已装备的饰品/宝石会同时卸下;移除当前武器会换到另一件已有武器。新增宝石按游戏规则自动启用。</p> <p class="hint">直接修改当前背包,游戏正常存档时会保存。移除已装备的饰品/宝石会同时卸下;移除当前武器会换到另一件已有武器。新增宝石按游戏规则自动启用。</p>
<div class="row"> <div class="row">
<input id="trainer-search" type="search" placeholder="搜索物品名称、描述或编号" aria-label="搜索背包物品"> <input id="trainer-search" type="search" placeholder="搜索物品名称、描述或编号" aria-label="搜索背包物品">
<select id="trainer-category" aria-label="物品分类"><option value="">全部分类</option><option value="consumables">消耗品</option><option value="weapons">武器</option><option value="stones">宝石</option><option value="ornaments">饰品</option><option value="special">特殊材料</option></select> <select id="trainer-category" aria-label="物品分类"><option value="">全部分类</option><option value="consumables">食物</option><option value="weapons">咸鱼(武器)</option><option value="stones">鱼眼宝石</option><option value="ornaments">配饰</option><option value="special">材料与铃铛</option></select>
<label><input id="trainer-owned" type="checkbox">只看已拥有</label> <label><input id="trainer-owned" type="checkbox">只看已拥有</label>
<label><input id="trainer-overcap" type="checkbox">允许超过消耗品携带上限</label> <label><input id="trainer-overcap" type="checkbox">允许超过消耗品携带上限</label>
<label><input id="trainer-map-bells" type="checkbox">允许减少地图铃铛(会重置相应收集点)</label>
</div> </div>
<div class="row mt"><button id="trainer-give-filtered">获得筛选结果</button><button class="danger" id="trainer-clear-filtered">清除筛选结果</button><span id="trainer-item-summary" class="meta"></span></div>
<p class="hint">物品逐项添加/移除,配饰与宝石也可装备/卸下。批量获得会补满食物、添加装备及各 1 个材料/铃铛;批量清除保留当前武器。默认保留地图铃铛;勾选允许减少后,按地图编号从后往前重置对应铃铛收集点。其他物品的背包修改不影响地图收集记录。</p>
<div id="trainer-items" class="list tall"><div class="empty">连接后读取背包</div></div> <div id="trainer-items" class="list tall"><div class="empty">连接后读取背包</div></div>
</div> </div>
</section> </section>
<!-- ========== 传送 ========== --> <!-- ========== 传送 ========== -->
<section class="panel active" id="panel-teleport"> <section class="panel active" id="panel-teleport">
<div class="card"> <div class="card">
@@ -98,11 +129,7 @@
播掉落音效并把你弹回"上次站立的位置",如果这个记录还是上一张地图的,人就会直接消失。<br> 播掉落音效并把你弹回"上次站立的位置",如果这个记录还是上一张地图的,人就会直接消失。<br>
想站到地上走动时,点下面的<b>「落地」</b>(会先探测脚下安不安全)。 想站到地上走动时,点下面的<b>「落地」</b>(会先探测脚下安不安全)。
</div> </div>
<div class="note warn-note"> <div class="note">悬停时每次点跳都能继续上升;按住游戏「下」键下降,松开停住,遇到实心地面会停下。下冲等技能保留原有位移。开「穿墙」后四向均可穿过障碍;已卡进地里时先开穿墙飞到空处再关闭。支持原版出口换图并保留设置,无出口的边界仍会阻挡。方向微调点按小步移动,按住连续移动。</div>
⚠️ <b>悬浮时别按跳跃键</b> —— 跳跃给了向上的速度,而重力是关掉的,
<b>没有任何力把它拉回来,人会一直往上飘</b>。<br>
想恢复正常走动:点 <b>「落地」</b>,或顶部 <b>「全部还原」</b>。
</div>
<div class="row mt"> <div class="row mt">
<label>X <input type="number" id="tp-x" min="0" step="1" value="0"></label> <label>X <input type="number" id="tp-x" min="0" step="1" value="0"></label>
@@ -113,6 +140,7 @@
</div> </div>
<div class="row mt"> <div class="row mt">
<label>微调</label> <label>微调</label>
<select id="tp-step" aria-label="微调像素步长"><option value="1">精细 · 1 像素</option><option value="4" selected>普通 · 4 像素</option><option value="16">快速 · 16 像素</option></select>
<button data-nudge="-1,0">←</button> <button data-nudge="-1,0">←</button>
<button data-nudge="1,0">→</button> <button data-nudge="1,0">→</button>
<button data-nudge="0,-1">↑</button> <button data-nudge="0,-1">↑</button>
@@ -162,6 +190,24 @@
</div> </div>
</section> </section>
<section class="panel" id="panel-encyclopedia">
<div class="card">
<h2>收集百科</h2>
<p class="hint">查看物品用途、获取位置和当前存档的收集状态。背包为空不代表没拿过;地图记录与当前持有数量独立显示。打开游戏背包时也能查百科。</p>
<p class="hint">「前往分块边缘」优先选择物品所属分块边缘附近、不碰墙且脚下无伤害地形的落点;这个分块被堵住时,会改找邻近分块或同图其他空处,继续传送。到达后保持悬停并恢复碰撞,不会自动穿墙。物品位置和获取条件照常,请自行走过去获取。</p>
<div class="row"><button id="encyclopedia-refresh" class="primary">刷新百科与收集进度</button><span id="encyclopedia-summary" role="status">连接后读取游戏资料</span></div>
<div id="encyclopedia-regions" class="collection-regions"></div>
<div class="row mt">
<input id="encyclopedia-search" type="search" placeholder="搜索物品、用途、区域或地图编号" aria-label="搜索百科">
<select id="encyclopedia-category" aria-label="百科物品分类"><option value="">全部分类</option><option value="consumables">食物</option><option value="weapons">咸鱼(武器)</option><option value="stones">鱼眼宝石</option><option value="ornaments">配饰</option><option value="special">材料与铃铛</option><option value="coin">金币</option></select>
<select id="encyclopedia-area" aria-label="百科区域"><option value="">全部区域</option></select>
<select id="encyclopedia-status" aria-label="百科状态"><option value="all">全部物品与地点</option><option value="uncollected">有明确未收集地点</option><option value="collected">有已收集地点</option><option value="unknown">有状态未知地点</option><option value="missing">背包未拥有</option><option value="owned">背包已拥有</option></select>
</div>
<p id="encyclopedia-coverage" class="hint"></p>
<div id="encyclopedia-items" class="catalog-list"><div class="empty">请先读档,再刷新百科。</div></div>
</div>
</section>
<!-- ========== 对话跳转点 ========== --> <!-- ========== 对话跳转点 ========== -->
<section class="panel" id="panel-story"> <section class="panel" id="panel-story">
<div class="card"> <div class="card">
+70
View File
@@ -1,5 +1,41 @@
/* 咸鱼喵喵 · 截图辅助工具 —— 控制面板样式 */ /* 咸鱼喵喵 · 截图辅助工具 —— 控制面板样式 */
.world-map-layout { display:grid; grid-template-columns:minmax(0,1fr) 280px; gap:16px; margin-top:12px; }
#world-map-viewport { height:560px; min-width:0; position:relative; overflow:hidden; border:1px solid var(--border); border-radius:10px; background:#121019; touch-action:none; cursor:grab; }
#world-map-viewport:active { cursor:grabbing; }
#world-map-viewport:focus-visible { outline:2px solid var(--accent); }
#world-map-svg { width:100%; height:100%; display:block; user-select:none; }
#world-map-empty { position:absolute; inset:0; pointer-events:none; padding:70px 20px; }
#world-map-empty[hidden] { display:none; }
.world-floor { fill:#635375; stroke:#706480; stroke-width:.4; }
.world-cell.unexplored .world-floor { fill:#302b3d; }
.world-wall { fill:none; stroke:#d3c7e2; stroke-width:2; }
.world-cell.unexplored .world-wall { stroke:#8e829f; stroke-dasharray:4 3; }
.world-cell-outline { fill:none; stroke:transparent; stroke-width:2; pointer-events:none; }
.world-cell:hover .world-floor { fill:#847098; }
.world-cell.selected .world-cell-outline { stroke:#ff8ab8; stroke-width:3; }
.world-cell.current .world-cell-outline { stroke:#5fd39a; stroke-width:3; }
.world-player { fill:#5fd39a; stroke:#12271d; stroke-width:2; pointer-events:none; }
.world-marker { cursor:pointer; }
.world-marker rect { fill:#21152c; stroke:#bd92cb; stroke-width:.7; }
.world-marker text { fill:#ffd46b; font-size:13px; text-anchor:middle; font-weight:bold; }
.world-marker.collected { filter:grayscale(1); opacity:.65; }
.world-marker .world-marker-state { fill:white; stroke:#15101e; stroke-width:2; paint-order:stroke; font-size:11px; }
.world-map-detail { min-width:0; max-height:560px; overflow:auto; padding:14px; border:1px solid var(--border); border-radius:10px; overflow-wrap:anywhere; }
.world-map-detail h3 { margin-top:0; }
.world-detail-item { padding:12px 0; border-top:1px solid var(--border); }
.world-detail-item.focused { border-left:3px solid var(--accent); padding-left:9px; }
.world-detail-item p { margin:6px 0; }
.world-detail-item .item-art, .world-detail-item .item-art img { width:36px; height:36px; flex-basis:36px; }
.world-map-controls select { max-width:100%; }
#panel-world-map { min-width:0; }
@media(max-width:760px) {
.world-map-layout { grid-template-columns:minmax(0,1fr); }
#world-map-viewport { height:440px; }
.world-map-detail { max-height:360px; }
.world-map-controls label { max-width:100%; }
}
:root { :root {
--bg: #14121a; --bg: #14121a;
--bg-card: #1e1b26; --bg-card: #1e1b26;
@@ -306,3 +342,37 @@ input[type="checkbox"] { accent-color: var(--accent); width: 15px; height: 15px;
.trainer-stat .row { margin-top: 12px; flex-wrap: wrap; } .trainer-stat .row { margin-top: 12px; flex-wrap: wrap; }
.trainer-stat input, #trainer-items input[type=number] { width: 95px; } .trainer-stat input, #trainer-items input[type=number] { width: 95px; }
#trainer-items .item { flex-wrap: wrap; } #trainer-items .item { flex-wrap: wrap; }
/* Inventory and collection encyclopedia */
select { background:var(--bg-input); color:var(--text); border:1px solid var(--border); border-radius:6px; padding:8px 10px; font:inherit; max-width:100%; }
#photo-state:empty { display:none; }
.inventory-row { align-items: flex-start; }
#combat-outgoing,#combat-incoming { width:100px; }
.inventory-main { display:flex; gap:16px; flex:1 1 430px; min-width:0; }
.item-art { display:flex; align-items:center; justify-content:center; flex:0 0 76px; width:76px; height:76px; border:1px solid var(--border); border-radius:12px; background:rgba(255,255,255,.035); }
.item-art img { display:block; width:64px; height:64px; object-fit:contain; image-rendering:pixelated; }
.icon-placeholder { color:var(--text-dim); font-size:28px; }
#trainer-items .inventory-row { padding:16px; gap:16px; }
#trainer-items .inventory-row .name { font-size:18px; line-height:1.5; margin-bottom:4px; }
#trainer-items .inventory-row .meta { font-size:13px; }
#trainer-items .inventory-row .item-description { font-size:16px; line-height:1.75; color:#d6d1df; margin-top:6px; }
.inventory-row.missing { border-left: 3px solid #b9bcc8; }
.inventory-row.owned { border-left: 3px solid #5ea988; }
.item-actions { display:flex; flex-wrap:wrap; gap:6px; align-items:center; justify-content:flex-end; }
.item-actions input { width:90px; }
.collection-regions { display:flex; flex-wrap:wrap; gap:10px; margin:16px 0; }
.collection-region { display:grid; gap:5px; text-align:left; min-width:155px; }
.collection-region span { font-size:12px; opacity:.8; }
.collection-region progress { width:100%; height:7px; accent-color:#6f8e72; }
.catalog-list { display:grid; gap:10px; margin-top:16px; }
.catalog-item { border:1px solid var(--border, #d9dce5); border-radius:10px; overflow:hidden; }
.catalog-item > summary { display:flex; flex-wrap:wrap; justify-content:space-between; align-items:center; gap:8px; padding:16px; cursor:pointer; }
.catalog-item > summary::before { content:'▸'; opacity:.6; }
.catalog-item[open] > summary::before { content:'▾'; }
.catalog-title { flex:1; font-weight:600; }
.catalog-content { padding:0 16px 16px; }
.catalog-location { display:flex; align-items:center; gap:12px; border-top:1px solid var(--border, #d9dce5); padding:12px 0; }
.shop-sources { margin-top:14px; }
.shop-sources summary { cursor:pointer; }
.tag.warn { background:#fff0d1; color:#865313; }
[data-nudge] { touch-action:none; user-select:none; min-width:44px; }
@media(max-width:700px) { .inventory-row { flex-wrap:wrap; } .item-actions { width:100%; justify-content:flex-start; } .catalog-item > summary { align-items:flex-start; } .catalog-location { flex-wrap:wrap; } }
+34 -58
View File
@@ -1,76 +1,52 @@
# 使用须知(发布版,2026-10-03 修订) # 使用须知(发布版)
## 确认当前操作哪份游戏 更新:2026-10-24。完整功能说明见 [README](README.md)。
看顶部“当前连接”和存档页“实际保存目录”。身份从游戏的 Node 入口及 StorageManager 存档路径读取,不依赖网页 URL。实机网页 URL 是 chrome-extension 地址。 ## 先确认实例
调试端口可达不等于已连接,更不等于已进入地图。标题画面请正常读档;不要从外部直接调用 DataManager.loadGame 绕过游戏的 completeLoad 流程。 看面板「当前连接」,建议确认为已创建的沙盒副本。端口可达不等于已读档;标题界面请从游戏正常菜单进入。移动/剧情需回地图,背包、百科和战斗支持菜单画面。
## 常见提示 不要自行调用裸 DataManager.loadGame:本游戏读档还要重建物理网格。请通过游戏菜单正常读档。
## 常见操作
| 提示或现象 | 处理 | | 提示或现象 | 处理 |
|---|---| |---|---|
| 调试端口和面板端口相同 | 设置两个不同的端口,再启动 | | 端口上的游戏与目标不符 | 关闭占用实例或改端口,不会自动放行 |
| 端口上的游戏与目标不符/无法识别 | 关闭占用该端口的游戏,或为各实例配置不同端口;不会自动放行 | | 地图、存档或连接已变化 | 刷新并重读旧列表 |
| 旧服务或旧配置占用面板端口 | 停止旧面板后再启动;本次升级前的旧服务需关闭原来的命令行窗口 | | 百科“前往分块边缘” | 优先目标分块边缘,堵住就改到邻近分块/同图其他空处,保持悬停并恢复碰撞,再自行前往获取 |
| 副本缺失/复制未完成 | 不启动残缺副本;选择新的空目录重建。不会覆盖已有目录 | | 地图怎么传送 | 点击方格或图标选中,再点右侧「传送到此分块」;游戏菜单中可看图,需回地图后传送 |
| 地图、存档或连接已变化 | 刷新状态并重新读取本图事件/扫描角色,旧列表不能用于新地图 | | 地图太小或图标看不清 | 选择大区域后点「适应地图」,用滚轮/加号放大、拖动查看 |
| 莉莉娅脚下不安全 | 她保持悬浮;移动到安全地面再点“落地” | | 地图灰色图标和问号 | 灰色 ✓ 为已收集,? 为条件无法判定;「只看未收集」不包括未知点 |
| 跨地图超时 | 工具会取消尚未完成的传送请求;确认游戏是否被菜单或演出占用,再重试 | | 图标尚未加载 | 等待片刻,或点刷新数值与背包;不影响数量操作 |
| 备份失败 | 不会继续启动需要备份的游戏或覆盖槽位。检查目录、磁盘空间及是否仍在写档 | | 关闭穿墙被拒绝 | 先微调到空处;开着悬停不代表已离开墙体 |
| 当前有摆拍角色,提示不推荐保存 | 读档后摆拍状态可能无法完整恢复;可自行决定继续保存,不要求先撤销 | | 脚下没有安全地面 | 继续悬停,移动到安全处再落地 |
| 空中跳跃/下降 | 悬停中可连续点跳,按住游戏下键下降;技能移动保留 |
| 悬停与穿墙的区别 | 悬停仍受实心地形阻挡;开穿墙后四向均可穿过 |
| 已经卡进地里 | 先开穿墙,跳到空处再关穿墙;脚下安全后可落地 |
| 悬停换地图 | 通过原版出口换图,保留悬停和穿墙;无出口的边界仍会阻挡 |
| 配饰栏位不足 | 增加铃铛或卸下其他配饰 |
| 无法减少地图铃铛 | 勾选对应选项;此举会重置部分地图收集点 |
| 秒杀没有生效 | 需有效命中敌人;不会突破剧情无敌或无限生命对象 |
悬浮时按跳跃仍可能一直向上运动;关重力没有取消跳跃输入。落地会检查地形,“全部还原”会直接恢复正常物理状态,使用时应处于安全位置。 ## 保存与恢复的边界
## 搜索和剧情 普通手动和自动存档照常,数值和背包修改可能随游戏保存。全部还原只解除辅助,不回退物品、数值及剧情变化。
发布版从游戏运行时读取全文台词,全部在内存中使用,不写剧本或缓存文件。先开面板再开游戏也会自动读取;故事表未就绪时最多尝试四次、间隔至少三秒,仍失败会显示原因,可手动重新读取。断线、换实例或重连都会使旧台词失效,重连成功后自动补齐。 发布版不提供快照、逐帧或输入录制(TAS)。常规游戏存档备份与还原继续可用。
搜索覆盖整段台词和说话人,并显示命中句;未连接时仍可搜索地图、坐标、事件和条件。 ## 数据来源与收集状态
“运行过场”包含剧情指令,可能改变游戏开关和进度。分支剧情可能跳过先前的演出;前往只自动设置普通开关,不承诺还原完整历史进度。没有任何一页提供角色形象的事件,也不能凭空生成合适 NPC。 百科里的「背包未拥有」和「地图未收集」不是同一回事。食物吃掉或配饰删除后,原来的拾取记录仍在。商店只列候选入口,实际库存由进度决定;未知奖励不会被判成未收集。
## 场景还原与撤销 面板「地图」显示原版分块示意布局,包含未探索和隐藏区域;查看不会修改游戏探索记录。它与百科共用来源和状态,当前覆盖全部 411 个官方收集点,但未索引的动态掉落也不会凭空出现在图上。分块传送复用百科备用落点规则,目标受阻时可能抵达同图的邻近分块或其他空处,请留意到达提示。
恢复的角色是固定的摆拍对象,不再自动执行原事件、移动或参与物理碰撞。清除“已抹除”状态只是临时操作;撤销会恢复修改前的字段、原有抹除状态及方法。 发布版从当前游戏运行时读取全文台词,仅保存在内存中。自动读取最多尝试四次、间隔至少三秒;失败后可手动重新读取。断线、换实例和重连会清除旧内容;未连接时仍可搜索地图、坐标、事件和条件。百科、世界地图与物品图标同样从运行时读取,无需提供完整提取资料。
“被抹除”不证明这个人应该出现在当前剧情。选择哪些角色和站位仍需核对画面;不要把全部恢复后的画面当成原作的准确复现。 ## 剧情、摆拍与备份
摆拍期间自动和手动存档照常,工具不拦截游戏自身的保存。顶部提示不推荐摆拍时保存;面板保存的原有确认框会说明风险,仍可确定继续。完成后可撤销;切图或正常读档自动撤销。面板关闭或失联超过两分钟也会撤销,恢复事件原有行为。 运行过场会执行整个事件页,可能改变开关和剧情;只播台词不等于完整还原演出。#305 的整页过场可能先播放 #298/#299,直接回看请用只播台词。临时摆拍不保证跨读档恢复;切图、正常读档或面板失联两分钟会撤销,普通存档照常生效。升级前关闭旧面板和游戏,再启动新版本并刷新页面。
这次没有实现“保存并读回完整摆拍布局”。临时函数、UI 显示状态、剧情解释器不承诺跨存档复现。 还原备份前先关闭游戏。工具校验存档文件与来源,保留还原前的 save 目录和额外安全备份;目标自己的 Steam 云配置保留。备份不存在的槽位不会混入恢复结果。任何恢复操作都应明确选择原版或沙盒目标。
## 备份与还原 详细安装与默认端口见 README;独立目录可使用 config.example.json,但必须先修改示例路径。
- 新备份记录来源,使用唯一目录并校验每个 .rmmzsave 文件。未完成或损坏备份不列为可恢复项。
- 旧时间戳备份仍可读取,但显示来源未知。还原时必须明确选目标,不能凭备份名称猜。
- 还原前先退出游戏和 NW.js 游戏窗口,防止运行中的游戏把旧状态写回。
- 还原得到所选备份中的完整存档集合;备份没有的槽位不会继续留在当前 save 目录。
- 当前存档会先备份;原 save 目录保留为游戏目录下的 `.save-before-restore-…`,不会直接删除。
- steam_autocloud.vdf 等非存档文件保留目标目录自己的版本,不从另一份游戏的备份覆盖。
## 已知画面限制
先前记录的“切换剧情分支时角色短暂抽动”,没有足够实机证据保证所有地图均已解决;本轮不把它标成已修。相同立绘的多个事件、剧情自身的移动与刷新仍可能影响演出效果。
并非全部剧情均已验收。遇到问题请记录故事编号、地图、事件页、存档进度和使用的按钮。
## 本轮剧情显示与定位修复
#370 横排重复角色的原因是动画图整张显示,现已同步单帧裁切修复。升级后需重启面板和游戏,再刷新网页,才会加载新的游戏内 helper。
运行无形自动/并行控制事件时,会优先保留同图的安全站位,或在目标地图寻找可落地位置。找不到安全地面会停止启动剧情并提示手动定位。运行过场在准备完成后尝试落地;普通手动传送仍默认悬浮。自动选点不等于还原原作站位。
#305 的“运行过场”仍可能先播同页的 #298/#299;直接查看 #305 请用“只播台词”。
## 角色与背包、锁定
“角色与背包”页可设置并分别锁定金币、HP、SP、ZP,以及搜索、添加、移除背包物品。作用于当前操作角色,包含切换后的莉莉娅形态,不修改独立跟随的莉莉娅。金币范围 0~99999,其余不能超过当前最大值;不会改变上限。已倒下时请先在游戏里复活或读档。
消耗品默认遵循携带上限,可勾选超上限(最多 99999)。移除装备会同步卸下,移除当前武器会换到另一件已有武器,至少保留一件。铃铛容量、任务及收集进度不在修改范围内。
锁定立即阻止该数值的消耗和增加。读档、新游戏、面板失联超过两分钟或点“全部还原”会解除锁定;已修改的数值和背包不会回退,正常保存会把当前数值和物品写进存档。
本轮同时更新了摆拍人物贴地、原图待机动画和面向主角。仅对原页要求贴地的人物小范围探地,飞行角色和固定摆件不强制落地;撤销恢复原始位置和朝向。首次升级这批摆拍修复,需要重启面板和游戏后刷新网页。
+17 -3
View File
@@ -1,5 +1,5 @@
{ {
"version": 1, "version": 2,
"files": [ "files": [
"tool/启动.bat", "tool/启动.bat",
"tool/停止面板.bat", "tool/停止面板.bat",
@@ -26,8 +26,22 @@
"unpacked/data/System.json", "unpacked/data/System.json",
"unpacked/data/MapInfos.json", "unpacked/data/MapInfos.json",
"README.md", "README.md",
".gitignore" ".gitignore",
"tool/lib/catalog-position.js",
"tool/lib/combat.js",
"tool/lib/encyclopedia.js",
"tool/lib/item-icons.js",
"tool/lib/motion.js",
"tool/lib/world-map.js",
"tool/开发文档.md",
"CHANGELOG.md",
"tool/package-release.js",
"tool/config.example.json",
"docs/images/world-map.png",
"docs/images/inventory.png",
"docs/images/encyclopedia.png"
], ],
"optionalRuntime": "nwjs-sdk-v0.64.1-win-x64/", "optionalRuntime": "nwjs-sdk-v0.64.1-win-x64/",
"note": "本包仅含工具及必要结构索引;台词从游戏运行时读取。Node.js 和 NW.js SDK 需另行安装。" "note": "本包仅含工具及必要结构索引;台词从游戏运行时读取。Node.js 和 NW.js SDK 需另行安装。",
"release": "2026-10-24"
} }
+58
View File
@@ -0,0 +1,58 @@
# 发布版开发文档
更新:2026-10-24。运行要求为 Windows、Node.js 22+ 和用户另行安装的 NW.js SDK 0.64.1,无 npm 依赖。
## 运行结构与配置
`launcher.js` 校验目录、端口和实例身份,备份存档,再按用户选择启动游戏和面板。`server.js` 仅监听本机回环地址;`lib/cdp.js` 通过 Chrome DevTools Protocol(浏览器调试协议)执行 `lib/ops.js` 生成的游戏脚本。静态网页位于 `web`。
默认将整个工具目录放在游戏目录内;原版目录为工具目录的上级,调试端口 9223、面板端口 8787,副本位于同盘根目录 `Nyaruru_shots`。配置缺省时无需创建 config.json。独立布局请复制 `config.example.json` 为同目录 `config.json`,修改示例中的 gameDir 和 sandboxDir 为实际绝对路径。gameDir 需含 package.json 和 index.html;原版和副本目录不能相同或互相包含。JSON 中 Windows 路径的反斜杠需写成双反斜杠,也可使用正斜杠。
`gameDir` 在服务启动时确定;修改它后重启面板。调试端口变更会关闭旧连接并清空地图/台词缓存。面板端口变更需重启。不要提交 config.json 或 `.server-runtime.json`。
## 运行时数据约定(C 方案)
随包只含既有 `out/triggers.csv`、`unpacked/data/System.json` 和 `MapInfos.json` 结构索引。`lib/data.js` 不读取完整剧本,不把台词写入触发点。
`OPS.readStoryIndex()` 从当前游戏 `$dataStory` 和 `$dataActors` 生成首句、说话人、全文及检索文本,忽略指令和备注。`StoryCache` 仅持有内存对象,以端口、连接代次和会话标识区分实例;并发读取复用同一个请求。最多自动尝试四次,间隔至少三秒;手动重新读取可重置重试次数。断线、端口变化、换实例和重连使旧结果失效;旧请求迟到不能覆盖新连接结果。
服务端 `/api/status` 发起自动读取;`/api/story-text` 提供状态及主动重读;`/api/events` 和 `/api/triggers` 合并当前连接的内存台词。前端显示载入/错误提示,断线清空旧台词和事件列表,并用请求编号拒绝过期返回。不得改成读取本机 `story.json` 或把响应保存到仓库。
百科读取 `DataManager._cacheMapData`,地图读取 `_thumbnailMapCell`,图标调用原游戏图标组件并在内存 canvas 裁切为 Data URL。三个模块均从当前用户游戏读取,不要求随包放置原始地图或贴图。金币使用工具自身的 SVG(可缩放矢量图)图标。
## 功能模块与边界
| 模块 | 职责与关键约定 |
|---|---|
| motion | 悬停 v5、连续点跳、下降、技能位移、碰撞及原版出口;读档和失联清理,普通换图保留 |
| trainer / item-icons | 当前操作角色的数值、锁定、背包、装备、铃铛和缩略图;批量先校验,失败回退 |
| combat | 在有效攻击命中链路应用倍率/秒杀,保留免疫与治疗;不永久修改基础攻击力 |
| encyclopedia / world-map | 收集来源与世界分块,只读探索记录;拥有、已收集、未知分别处理 |
| catalog-position | 检查角色整个身体与危险地形,优先分块边缘,受阻时扩展同图搜索;载入后复查 |
| photo-state / story-position | 临时摆拍、单帧裁切、角色贴地/面向、剧情站位;撤销恢复原字段及方法 |
| files / safety / processes | 路径隔离、备份校验、原存档目录保留、实例/进程检查 |
读写请求携带连接与地图上下文;服务端串行执行修改操作,在实际调用前重新核对身份。菜单中可管理背包、查看百科/地图和调整战斗倍率;移动和剧情操作必须回地图。
传送同步游戏逻辑和物理坐标,不直接乱改 `_x/_y`。百科/地图到达后悬停并恢复碰撞,不主动领取物品。跨图失败尝试返回原位置,但仍需核对提示和实际画面。
## 发布功能范围
发布版不提供快照与 TAS:不含对应页面、事件绑定、轮询、API、模块或打包依赖。常规存档备份/还原保留;`/api/snapshot` 只是顶部实时状态读取,与已移除的多槽记录功能无关。不要把它一并删除。
## 验证与发布
在仓库根目录使用 PowerShell 7:
```powershell
node --test tool/tests/*.test.js
Get-ChildItem -LiteralPath tool -Recurse -Filter *.js | ForEach-Object { node --check $_.FullName; if ($LASTEXITCODE -ne 0) { throw "语法检查失败" } }
node tool/package-release.js
Compress-Archive -LiteralPath dist/Nyaruru_modifier -DestinationPath dist/Nyaruru_modifier-public-2026-10-24.zip
```
打包脚本只复制 `发布清单.json` 的显式文件,并返回每项大小和 SHA-256。默认输出目录为 `dist/Nyaruru_modifier`;存在时拒绝覆盖,可传入新输出路径。清单排除 config.json、存档、快照、日志、SDK、完整剧本和原始地图。不要以递归复制工作目录替代该脚本。
源码仓库包含测试;面向玩家的包仅含运行文件、说明与打包清单,省略测试。`release.test.js` 检查白名单、拒绝敏感条目,并在临时干净目录启动 HTTP 页面,不访问真实游戏。功能测试使用人工对象、公开结构索引及临时存档夹具;不依赖私人原始资料或预先安装 SDK。
测试通过不替代所有地图/剧情的实机验收。真实游戏核对需单独说明范围;用户正在游玩时不为测试移动角色、读档或回放。