Merge upstream 0.2.2 and resolve reroll conflicts (#408)

This commit is contained in:
Siornya committed 2026-10-10 00:46:46 +08:00
commit be095d8638
447 files changed
+33376 -8843

No files matched your search

+79
View File
@@ -0,0 +1,79 @@
name: Bug 反馈
description: 在使用的过程中遇到了 Bug。
title: "[Bug]:"
labels: ["bug"]
body:
- type: markdown
attributes:
value: |
感谢您进行 Bug 反馈。请在上面的文本框⬆️起一个能够清晰描述您的问题的标题,便于开发者解决您的问题。
> [!important]
> 重要:如果要提出**多个 Bug**,请为每一个 Bug 开一个单独的 issue。
>
> 如果您不知道如何有效、精准地表述,我们建议您先阅读《提问的智慧》([链接](https://github.com/ryanhanwu/How-To-Ask-Questions-The-Smart-Way/blob/main/README-zh_CN.md))。
- type: checkboxes
id: checklist
attributes:
label: 检查清单
description: 在开始反馈这个问题之前,请先检查:
options:
- label: 我已更新到最新版 ![最新的版本号](https://img.shields.io/github/v/release/sganggs/Stronghold-Protocol?include_prereleases&style=flat-square&label=) 或 ![最新的版本号](https://img.shields.io/github/v/release/sganggs/Stronghold-Protocol?style=flat-square&label=) 测试,并看过 [最新提交](https://github.com/sganggs/Stronghold-Protocol/commits/master/),确认这一 Bug 还没有修复。我也已在 [Issues](https://github.com/sganggs/Stronghold-Protocol/issues) 中检索,确认这一 Bug 未被提交过。
required: true
- label: 我已知晓并同意,此处仅用于汇报程序中存在的问题(关于其他非程序本身的问题应当在 Discussion 板块提出)。
required: true
- type: markdown
attributes:
value: |
### Bug 信息
描述您遇到的 Bug。您可以附上截图、录屏、堆栈跟踪、日志等材料,便于开发者追踪问题。
> [!note]
> 上传附件时**请优先使用 GitHub 的附件系统上传附件**,将需要上传的附件粘贴或拖动到撰写区域即可上传。多个文件可打包为 zip 格式后上传。
- type: textarea
id: expected
attributes:
label: 期望的行为
description: 详细的描述你期望发生的行为,突出与目前(可能不正确的)行为的不同。
validations:
required: true
- type: textarea
id: what-happened
attributes:
label: 实际结果
description: 实际发生的行为。
validations:
required: true
- type: textarea
id: reproduce-steps
attributes:
label: 重现步骤
description: |
详细描述要怎么操作才能再次触发这个 Bug。
placeholder: |
1. 首先……
2. 然后……
3. ……
validations:
required: true
- type: textarea
id: screenshots
attributes:
label: 截图/录屏(可选,建议)
description: |
如果可能,请提供可以用来进一步描述您的问题的截图或录屏。
- type: textarea
id: stacktrace
attributes:
label: 堆栈跟踪(可选)
description: 如果在遇到这个 Bug 时发生了崩溃(弹出崩溃提示),或者产生了错误日志,请将产生的堆栈跟踪信息粘贴到此处,便于开发者定位 Bug。
render: shell
- type: checkboxes
id: revision
attributes:
label: 最后一步
description: 回顾您的回答
options:
- label: 我认为上述的描述已经足以详细,以允许开发人员能复现该问题。如果我的 issue 没有按照上述的要求填写,可能会被无条件关闭。
required: true
+49
View File
@@ -0,0 +1,49 @@
name: 功能请求
description: 提出一项新的功能。
title: "[Feature]:"
labels: ["enhancement"]
body:
- type: markdown
attributes:
value: |
感谢您贡献您的想法。
请使用简洁明确的标题描述您希望增加或改进的功能。便于开发者了解您的想法。
> [!important]
> 重要:如果您想要提出 **多项功能**,请为每一个功能开一个单独的 issue。
- type: checkboxes
id: checklist
attributes:
label: 检查清单
description: 在开始反馈这个问题之前,请先检查:
options:
- label: 我已将本应用更新到最新测试版 ![最新的版本号](https://img.shields.io/github/v/release/sganggs/Stronghold-Protocol?include_prereleases&style=flat-square&label=),并看过 [最新提交](https://github.com/sganggs/Stronghold-Protocol/commits/master/),确认我想要的功能还没有实现。我也已在 [Issues](https://github.com/sganggs/Stronghold-Protocol/issues?q=-label%3Abug)中检索,确认我想要的功能未被提交过。
required: true
- type: markdown
attributes:
value: |
> [!note]
> 上传附件时**请优先使用 GitHub 的附件系统上传附件**,将需要上传的附件粘贴或拖动到撰写区域即可上传。多个文件可打包为 zip 格式后上传。
- type: textarea
id: requested-feature
attributes:
label: 想要实现或优化的功能
description: 详细地描述一下你想要的功能,联系到哪些已经存在的 BUG 或者功能(如有),描述的越具体,采纳的可能性越高。
validations:
required: true
- type: textarea
id: expected-effect
attributes:
label: 期望效果
placeholder: |
请详细描述这个功能实现的效果。
validations:
required: true
- type: checkboxes
id: revision
attributes:
label: 最后一步
description: 回顾您的回答
options:
- label: 我认为上述的描述已经足以详细,以便开发人员理解您想要的功能。如果我的 issue 没有按照上述的要求填写,可能会被无条件关闭。
required: true
+56
View File
@@ -0,0 +1,56 @@
# AGENTS.md
Entry point for AI coding assistants (Codex, Claude Code, Cursor, Copilot …) working on this repository. It only
indexes the existing documents and their hard rules; when this file and a linked document disagree, the document wins.
Human contributors: [CONTRIBUTING.md](CONTRIBUTING.md) is the same material in full.
## What this is
A non-commercial fan remake of Arknights「卫戍协议:盟约」 that runs in the browser: a Node.js server (economy, rounds,
rooms) and a deterministic battle sim shared by the server and the browser. It aims to be faithful to the official mode.
## Read first
1. [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) — the code map: where things live, data flow, golden results,
import boundaries, where common changes start.
2. [docs/DESIGN.md](docs/DESIGN.md) — the index of the rules: current rules in `docs/design/`, each release's
revisions and their evidence in `docs/history/`.
3. The reference for the part you touch: [docs/SIM.md](docs/SIM.md) (battle engine API, hooks, test harness),
[docs/META.md](docs/META.md) (match flow, shop, protocol), [docs/DATA.md](docs/DATA.md) (generated data),
[docs/I18N.md](docs/I18N.md) (interface strings), `server/sim/content/kits/README.md` (operator kits).
## Hard rules
- **Official first.** Rules come from the official data tables (`.cache/gamedata/excel/` after setup) and
[PRTS](https://prts.wiki/). A detail no source settles is implemented the simplest way and marked `[ASSUMED]` in the
code and the PR. Deliberate deviations from the official mode are the maintainer's decision only.
- **Determinism.** `server/sim/` is plain ESM that runs in Node and in browsers: no Node APIs, no `Math.random`, no
clocks, and no engine-approximated Math functions (use `server/sim/detmath.js`; ESLint and
`test/sim/detmath.test.js` enforce it).
- **Golden results.** A refactor must not change `test/golden/*.json`. An intended gameplay change runs
`npm run golden:update` in the same commit, and the commit / PR names every moved scenario and why
([test/golden/README.md](test/golden/README.md)).
- **Generated data.** Never hand-edit `data/*.json`: change `tools/build-data.mjs` and regenerate.
- **No game art in git.** `public/assets/` is ignored; never commit extracted or downloaded game files.
- **Interface strings** go through `t('…')`, with entries in every pack under `public/i18n/`
(`node tools/i18n.mjs check --all --strict`).
- **Docs follow the code.** A rule change updates `docs/design/`, the current `docs/history/` file and the reference
docs; run `node --test test/docs-consistency.test.js test/docs-paths.test.js`.
- **Commits and PRs**: one topic per PR; a one-line message of what changed (Chinese or English) with the issue number;
no attribution trailers (`Co-Authored-By:`, `Generated with …`). The PR description states what, why, the sources,
the `[ASSUMED]` points, the tests you ran (paste the summary lines) and the golden moves. No ads, payments or other
monetisation — the project stays non-commercial.
## Verify
Run the tests of what you touched first, then everything before opening the PR:
```bash
node --test test/<area>/<file>.test.js # targeted
npm run ci # the CI checks locally (tools/ci.mjs): node --test, smoke, lint, imports, types
npm run golden # golden results
```
Browser suites are off by default and need Chrome: `SP_E2E=1` (UI), `SP_REAL_E2E=1` (real match, needs assets),
`RENDER_E2E=1`, `SIM_E2E=1` — see CONTRIBUTING.md §2. Battle tests are easiest with the helpers described in
docs/SIM.md (the test harness section) and the patterns in `test/content/op_siege.test.js`.
+114
View File
@@ -1,5 +1,119 @@
# 更新记录
## 0.2.2 — 2026-10-09
0.2.1 之后的版本:干员的潜能和自持有加成改为在「干员调配」里按干员自己设置(默认满潜、精英2 60 级);新增日文语音、点选干员的语音和本机统计数据页;按官方数据和 PRTS 修正了一大批规则(被推拉的敌人进入失衡、首领场传送门、杜宾「教鞭」三选一、突袭会跳到隐匿敌人身边等),并修复了社区和 GitHub 上反馈的问题、移植了多位贡献者的 PR。战斗模拟改用与浏览器无关的数学函数,各浏览器和服务器的战斗结果逐位一致。规则对照官方数据和 PRTS 核对,详见 DESIGN §27([docs/history/0.2.2.md](docs/history/0.2.2.md));有意和官方不同的写在「有意与官方不同的调整」里。
### 新功能
- 潜能和自持有加成按官方改成每名干员自己设置:「干员调配」里每名干员(普通、精锐和自选共用)可以设潜能 1–6 和练度(未精英化 / 精英阶段1 / 精英阶段2 / 精英阶段2-60级),默认潜能 6、精英阶段2-60级(维护者 2026-10-08 的决定:默认满潜满加成)。潜能和官方一样改变部署费用、再部署时间、属性和天赋(召唤物的天赋按召唤者的潜能);练度是调度手册「加成情况」里的自持有加成,攻击、防御、生命上限单独乘算,不和其他加成相加(精英1 攻击、防御 ×1.05;精英2 攻击 ×1.1、防御和生命 ×1.05;精英2 60 级三项 ×1.1)。官方中未持有的特许干员按潜能 1、没有加成,想照官方打可以把它设成潜能 1、未精英化;补位的替补干员和自选的原型干员两样都没有,AI 队友按默认。因为默认带满加成,持有的干员的攻击、防御和生命上限都比 0.2.1 高 10%(0.2.1 是全体满潜、没有自持有加成)。设置随调配同步,服务器会校验;旧的调配存档照常可用,没设的按默认;商店卡片、详情卡、「局内数值」和队友的干员都按各自主人的设置显示(GitHub #252)。
- 战场上的场地装置也能点一下看说明了:阻隔工事、“双眼皮”炮台、射击台、源石流发生装置,说明卡的样子和点特殊地形一样;数值取自所在关卡的装置数据(炮台还列出生命、攻击、防御、攻击间隔,吹风装置列出吹风方向和对干员攻击力、敌人移动速度的加减),装置名称按当前语言显示官方译名;作战中阻隔工事和炮台的说明卡带实时血量,被摧毁后点不出来,还没启用的炮台和被机变卡移除的箱子也不显示(GitHub #228,PR #229,感谢 @rickylxw)。
- 大厅和房间的顶栏里,「玩法说明」左边多了一个「设置」按钮,在等待时也能调语言、音量和快捷键等,打开的就是标题画面和对局里的同一个设置窗口。后台播放音乐和棋盘风格选项暂不做(GitHub #238,感谢 @RushFTK)。
- 点选干员时会说官方的「选中干员」语音了:休整期在作战区、整备区点干员,或点商店、晋升奖励里的干员卡片都会说(此前只有作战中才说);连续点选时新的一句接替正在说的那句,作战中也不会打断部署、技能等更重要的语音。部署、技能、接敌和结算语音仍只在作战中播放;替补干员、原型干员、盟约·辅助干员和召唤物没有官方语音(维护者 2026-10-08 的要求)。
- 新增日文干员语音:「设置」里多了「语音语言」,可选 中文 / 日本語(默认中文,与界面语言无关)。全部 191 名有官方语音的干员都有日文的作战语音(和中文同样的 14 句:行动出发、行动开始、选中、部署、作战中 1–4、结算),个别缺失的句子播中文。完整包附带中文、日文两套语音(因此比 0.2.1 大约 76 MB,约 505 MB);精简包和源码由 setup 一并下载(日文约 85 MB,素材合计约 550 MB)(维护者 2026-10-08 的要求;GitHub #256 提出的多语种语音先做日文,感谢 @xcdoge)。
- 新增「统计数据」页:标题画面右上角、大厅和等待室顶栏的「统计」打开这台设备上的对局记录——总览(胜场、胜率、到达 / 通关隐秘核心、最远回合、累计时长、各难度、六种称号各拿过几次)、策略(每个策略用了几局、通过几局、通过率)、战斗累计、对局记录(每局一行,点一行回看那一局的结算页,最新的 30 局可以回看)。记录只存在本机浏览器里、不上传,服务器仍然不保存任何历史;最多保留最近 1000 局(约 0.8 MB),可以「导出」「导入」换设备,同盟对局里队友的代号也会存在本机(页面和玩法指南 §12 都写明了);读不出来的存档会另存为备份后重新开始。打到结算的对局,以及「放弃模拟」前至少清完 1 个回合的对局计入统计;进去就退、没清完第一个回合的对局不计入,但会留在对局记录里并标着「中途退出」和「不计入统计」(维护者 2026-10-08 的要求;GitHub PR #323,感谢 @2321Robin)。
- 「干员调配」的干员列表改成一张表:每名干员一行,技能、模组(精锐)、潜能 · 练度各占一列、上下对齐,直接点行里的技能或模组图标就能换(「不装备」在最前),不用先打开详情;点头像或名字在右边打开详情,最上面是潜能与练度,接着是这名干员的特质(与局内详情卡同一块,跟着普通 / 精锐切换)。宽度 1000 像素以下(如横屏手机)详情盖住列表,点「干员列表」返回;自选编队里持有的干员在名额卡片上设潜能与练度。快捷选择和特质预览来自 PR #301,布局改成一张表,不用三列竖排的卡片(维护者觉得三列竖排有点乱)(PR #301,感谢 @farridge)。
- 点开场上干员的详情卡时,棋盘上会画出它当前的攻击范围:休整期是自己作战区的干员(按携带的技能、模组或替补和朝向);作战中和查看队友阵地时,我方干员和召唤物(包括队友的)按此刻的位置、朝向和技能画出实际攻击范围(例如开着扩大攻击范围的技能时就是扩大后的范围);敌人、倒下的干员和整备区里的单位不画,拖动、选朝向时也不画。只显示实际的攻击范围,不做技能范围预测(GitHub PR #281,感谢 @IceCodeNew;范围显示的思路来自 @BBleae)。
- 合作房间新增「AI 队友最后选择」(默认关闭;关闭时选顺与随机不变)。(PR #368,感谢 @anAtheist987)
- 休整期双击队友头像即可查看其战场,「前往查看」仍在;没有战场时不强制打开,正在查看时单击不再重新打开。(PR #374,感谢 @shadiaojumao-hyb)
- 设置新增「问题反馈」,在本地复制诊断信息(可附上本场战斗);玩家名只替换完整名字和完全相同的 JSON 字符串。(PR #321,感谢 @Cloudnyco)
### 有意与官方不同的调整
- 琳琅诗怀雅二技能的香槟炸弹不再掉血(站在活性源石上、受到各种伤害都不会)、不会被治疗,也不会因为生命值归零而消失,只在「见面礼」炸到碰到它的第一个地面敌人后消失。官方的香槟炸弹会掉血(PRTS:即使自身生命值未满,模型下方也不会显示生命值槽),这是维护者 2026-10-08 的决定。
### 规则(按官方)
- 「同名效果取最高」的效果(减防、脆弱、虚弱、庇护等)有三个或更多同时叠在一起时,最强的结束后按强度依次接上还没到期的那一个,不再跳过中间那一档:例如先后挂上 30%、25%、20% 减防(各 5 秒),30% 结束时接上 25%,再接 20%(此前直接跳到 20%)(GitHub #342,PR #348,感谢 @siruimei07)。
- 圣约送葬人、隐德来希的模组(REA-Y)「攻击范围内存在2名及以上敌人时攻击速度+12」改由战斗引擎统一处理:范围内敌人达到 2 名的那一刻生效、少于 2 名的那一刻消失(此前最多晚约 0.2 秒生效、多留约 0.3 秒);对全部棋子、补位和自选组合的扫描确认,其他干员不会因此多出攻速(PR #293,感谢 @LimitlessHPPK)。
- 异客三技能的雷暴区域改为以目标所在格为中心的十三格菱形(官方 x-1 范围;PRTS 备注),此前是以目标位置为圆心、半径 1.5 格的圆:目标所在格上下左右第二格的敌人现在也会被雷暴劈中,斜向第二格之外的则不会(GitHub #322,PR #329,感谢 @IceCodeNew)。
- 歌蕾蒂娅三技能结束时的「捕网」只拖拽龙卷风中心 1 格内的敌人(PRTS 备注:捕网半径 1),此前沿用龙卷风的 1.5 格;龙卷风的减速和拖拽仍是 1.5 格(GitHub #324,PR #329,感谢 @IceCodeNew)。
- 信仰搅拌机一技能的补弹对象改为周围 8 格内最后部署的那名开着弹药类技能的拉特兰干员(PRTS 备注;重新部署也算最后部署),此前选离他最近的(GitHub #325,PR #329,感谢 @IceCodeNew)。
- 最终攻势和隐秘核心的领袖战场,传送门按官方会传送了:敌人沿路线走进场边角落的传送门入口时,不再当场算漏怪、扣目标生命值,而是在传送门里消失 3 秒(与数据里写明的传送一样)后从场地中央的传送出口出来,再走向入口这一侧的蓝门,走进蓝门才算漏怪;另有 4 条官方路线漏写了「出现」,敌人穿过传送门后会在出口现身,不再隐身走完剩下的路。被传回来的敌人会再经过一次防线,领袖战的战况随之变化(GitHub #336,PR #337,感谢 @2321Robin)。
- 隐秘核心「假想敌:铳」的【末日布道】:碎铳之簧追到铳身边后不再马上结束追逐,而是在 5 秒增益期间一直跟着铳移动(期间无敌、不攻击),与 PRTS 一致;场上已经没有铳时原地等增益结束。双人战场的两把铳仍各按数据的冷却施放(出场 20 秒后一次,之后每 45 秒),后喊的那把会把所有碎铳之簧叫到自己身边(PR #347,感谢 @CXUtk)。
- 策略「大帝」的加急调派不再有 20 层上限:干员每次部署后再部署时间减少 50%,第 21 次及以后的部署仍会继续减半(PRTS:该策略效果可无限叠加)(GitHub #328,PR #329,感谢 @IceCodeNew)。
- 假想敌「骨刺」按 PRTS 天赋索敌:普通攻击索敌不受阻挡影响(被阻挡后按仇恨优先级选目标,不再先打阻挡者)、不会攻击飞行单位,隐匿期间同时攻击的 3 个目标从普通攻击的范围(含干员碰撞体积)里选(GitHub #296,PR #365,感谢 @Sukvii)。
- 顶栏中间的胶囊按官方口径计数:显示「已解决 / 本回合敌人数」,打死一个、漏掉一个都算已解决(开局 0/3,漏一个 1/3,打死一个 2/3,打死会分裂的那只 3/3);分裂出来的小怪、召唤物(包括领袖召唤的敌人)不进分子也不进分母,所以会召唤的领袖关胶囊也能填满,不会再因为小怪越分越多而「永远差一截」;它们漏掉时仍然各自扣目标生命值、破坏完美作战,击杀统计和悬赏也照旧(4 只磨砻被打死、分裂出的 8 只全漏:胶囊 4/4,扣 8 点)。队友进度和观战里的胶囊同样按这个口径(PR #308,即 PR #157 的胶囊部分,感谢 @xcdoge)。
- 白铁的「铁钳号·原型机」在干员的攻击范围或技能范围内时,能作用到它的技能会像遇到敌人一样自动开启并消耗弹药:例如范围内只有铁钳号时,圣约送葬人二技能会开启、子弹打在铁钳号上,「每消耗7发子弹」的拉特兰、远见特质照常叠层(此前技能从不开启,子弹和层数都不动);蕾缪安三技能会锁定它并打完子弹,艾丽妮三技能、陈二技能会斩它,引星棘刺二技能的炼金单元会落在它身上。效果只会挑选敌人、打不到它的技能(如德克萨斯「剑雨」)和治疗技能不会为它开启,留给真正的敌人;白铁™多功能平台仍不会被攻击(社区反馈;维护者 2026-10-08 按官方体验确认)。
- 伊内丝三技能「独影归途」在突袭再部署落地后重新发动(攻击力提升、收回影哨),不再在跳跃后被吞掉;有技力消耗的技能仍按「保留技力」保留原有技力(社区反馈)。
- 引星棘刺一、二技能的炼金单元给友军的生命恢复改为提高「生命回复速度」,与锡人「大拉里」相同(PRTS 锡人备注:不受治疗加成和禁疗影响):不屈者(如折桠)和处于禁疗的干员现在也能回血,治疗量加成不再放大它(社区反馈)。
- 炎佑是飞行单位,标注「不可对空」的敌人效果不再打到它:高能源石虫、冰爆源石虫、卷心籽的死亡爆炸,水遁忍者的漩涡,鼎沸的范围伤害,“萨科塔昂首”的祈祷邀约(攻击速度 −30),以及“斩胄之剑”“破胄之锤”的攻击(社区反馈)。
- 望放在棋盘上的棋子在开战部署时就由第一天赋「铸子」在相邻格额外部署一枚棋子(跟子),可以落在不可部署的地面上,位置按 PRTS 的顺序(有敌人的格子优先;不可部署地块 > 可部署地面 > 可部署高台;同级按棋子的上 > 右 > 下 > 左);之后有单位部署到跟子所在的格子时跟子消失;三技能开启时溢出的跟子也按同样的顺序落位。此前开战时一枚跟子都没有,单独一枚棋子永远不会触发(社区反馈)。
- 升级调度中心时,新增的商品栏位立刻出现但保持空着,不再立即刷出一名干员(官方录屏:升级只开位,新位在下一次刷新——手动刷新或回合开始的自动刷新——时才会出现干员),已有的卡片不变;商店里的空栏位只画一个空框,不再写成「已招募」。0.2.0 起「升级立即补一张新卡」的做法撤回(GitHub #332,PR #333,感谢 @2321Robin)。
- 耀骑士临光的特质给自己和身前一格干员的「【卡西米尔】每叠加 3 层,再部署时间 -1.5%(精锐 -3%)」不再在基础值的 5% 处封顶:此前卡西米尔层数很高时(普通 192 层、精锐 96 层起)再部署时间停在 5%,现在按数据一直减到 0 秒为止(GitHub #370,感谢 @FrogThai)。
- 战场#08(下半) 涨潮控制的领袖战:巡逻的「假想敌:铳」和卢西恩从场地中央走向蓝门一侧时走上方的路(与官方寻路一致),不再从下方穿过两格深水区。敌人「优先走能被阻挡的路」的寻路调整现在只在不会多踩深水区时生效,其他战场已有的路线不变(GitHub #375,感谢 @FrogThai)。
- 立即生效、本身不是一次攻击的技能(如引星棘刺「度算浪波」、先锋的回费技能)两次释放之间至少隔一个攻击间隔(释放本身不占用普通攻击):攻速越快放得越勤,迅捷返还的技力再多也不会每一帧都放(此前迅捷叠高后度算浪波每帧都放,攻速不起作用)(GitHub #298,感谢 @FrogThai)。
- 琳琅诗怀雅二技能的香槟炸弹不会再被没有破隐的隐匿敌人触发:隐匿的敌人被反隐或被阻挡后才会引爆它,沉睡中的敌人醒来后才会引爆它(PRTS:隐匿使单位无法被任何敌方能力选中;此前隐匿、沉睡的敌人走上去照样引爆,沉睡时炸弹白白浪费)(社区反馈)。
- 帝国炮火先兆者、帝国炮火中枢先兆者的炮弹:干员的碰撞体积(半径 0.25)碰到半径 1.2 的落点圆就会受到伤害,也就是落点格和周围一圈共 3×3 格,此前只打中十字形的 5 格(PRTS:碰撞判定;GitHub #364,感谢 @FrogThai)。
- 速射手(特性「优先攻击空中单位」)站在近战位阻挡地面敌人时,攻击范围内有飞行敌人会先打飞行敌人;其他干员仍先打自己阻挡的敌人(维护者 2026-10-08 的决定;GitHub #220,PR #220 的思路,感谢 @TsangAsuna)。
- 策略「余」的【文火慢炖】不再给不显示层数的盟约(独行、绝技、调和、协防干员)加层:第 8 回合只激活了其中一个时什么也不加,同时激活了别的盟约时只给有层数的那些加(维护者 2026-10-08 的决定,与 PRTS「这些盟约照样吃叠层效果」的说法不同;协防、华法琳的叠层不变)。
- 杜宾的法术「教鞭」改回官方的三选一:使用后销毁,休整期里弹出 3 张战术特训悬赏(只有自己看得到,刷新页面或断线重连后还是这 3 张;绝境、终极里从 20 张里抽,标准、险境里从 15 张里抽),点一张再点一次确认,选中的那张成为接下来的悬赏;选定之前不能点「准备就绪」,同盟模式的休整期倒计时结束时还没选就随机选一张(独立模拟和只有一名玩家时休整期不限时,不会自动选);AI 队友和 AI 托管的座位按悬赏评分自己选。此前(0.1.0 起)是服务器随机抽 3 张再随机拿 1 张,文档里写作有意的简化;“神秘顾客”仍是随机悬赏(PR #353,感谢 @Sukvii;维护者 2026-10-08 决定按官方)。
- 战术特训悬赏的赏金在最终攻势和隐秘核心里也发了:本侧没有漏怪、全队获胜时,和击杀赏金一起在下一回合的资金里入账,每名玩家每张卡只算一次;此前这两场只发击杀赏金,战术特训的敌人出场了、用掉了一场,赏金却从不发。这两场的战况由浏览器上报、服务器不重新模拟,所以赏金还要求结果与浏览器战斗中自己上报的数据一致:上报时把队伍生命的损失分到了每名玩家名下(新增 b.progress 的 leaksBy,其余是领袖自身效果扣的),结果里这名玩家的漏怪列表不能比上报的少,上报了生命损失却没有分账的结果一律不发,漏怪既不能藏起来也不能推给队友(PR #353,感谢 @Sukvii)。
- 突袭盟约的干员也会再部署到隐匿(未被反隐)的地面敌人周围,范围内有隐匿的地面敌人时也算「范围内有敌人」、不会跳走;沉睡、不可选中的敌人和飞行敌人仍不算(GitHub #316,感谢 @warpgenius;维护者 2026-10-08 按官方实测确认)。
- 「画卷」复制的干员凑成三合一晋升时,目标身上的装备也会照常复制了:此前晋升先把装备收回整备区,画卷随后读到空的装备栏,复制的装备就丢了(例如两把普通维式重锤本应合成一把攻击力 +25% 的进阶锤,结果只剩原来那把 +15%)。现在复制前先记下装备:复制出的装备和收回的原装备一样放进整备区(PRTS:获得的装备为未装备状态),同名普通装备照常自动合成;晋升后的精锐干员留在原格、朝向不变、身上不带装备,已经是进阶的装备也不会再被穿回精锐身上(GitHub #389,PR #390,感谢 @Xinglan233)。
- 引星棘刺一技能「度算浪波」的防御力加成可以叠加了:两个炼金单元罩住同一名干员时防御力 +60 再 +60(精锐 +75),此前只算一个(治疗本来就会叠加);同一名引星棘刺前后两次释放、两个炼金单元重叠的那几秒也一样叠加(PRTS 备注:效果均可叠加;GitHub #388,感谢 @ChengCC0101)。
- 被推开、拉动的敌人会进入官方的「失衡」状态:这段时间里不走路、也不发动普通攻击。推开按「力度减重量」的档位持续 0.2 秒到约 1.17 秒(PRTS 推力-位移近似对应表的位移时间,例如中等力度推重量 1 的敌人 0.8 秒),拉拽持续 1 秒(力度比重量低 2 档时 0.5 秒,拉到身前后也保持到结束);失衡期间也不会放技能、闪现或执行脚本移动,弧光锋卫在失衡期间每 0.066 秒受到 400 点真实伤害(此前按推开的格数一次性扣血);推不动的空中单位(静态刚体)被推时停顿 0.1 秒、被拉时停顿整个拉力作用时间;浮空、重生和路线上的瞬移出现都会立刻解除失衡。画面上的推拉滑行也改用同样的时间(默认 2 倍速下现实时间减半)(PR #392 的思路,感谢 @xcdoge)。
- 「画卷」复制出的装备一律以未装备状态放进整备区(整备区满时进临时整备区,两处都满时和其他获得的装备一样销毁并提示),不再穿在复制出的干员身上——没有触发晋升时也一样(PRTS 画卷 备注:使用后销毁,获得的装备为未装备状态)。
- 引星棘刺一技能「度算浪波」的炼金单元在几名干员生命比例相同时投给最晚部署的那名(重新部署的按新的部署算),此前投给阻挡数多、离她近的那名(PRTS 备注:优先选择生命比例最低>最晚部署的我方单位)。
- 攻击间隔和技力消耗恰好是整数帧时不再多等 1 帧:此前 1 秒的攻击间隔实际每 31 帧一次(官方 30 帧),3 秒 91 帧、4 秒 121 帧,从 0 开始、每秒回复 1 点的 10 点技力要 301 帧才充满;敌人恰好整数帧的攻击前摇也准时了(如 0.4 秒前摇 12 帧,此前 13 帧),“双眼皮”炮台和寒檀二技能的冰凌同样准时。干员和敌人都受影响,整数秒攻击间隔的敌人更多,整体略难一点。不是整数帧的间隔仍按越过 0 的那一帧计算(向上取整,例如 20.34 帧算 21 帧),官方是四舍五入(20 帧),这一点这次不改(PRTS 作战机制「帧对齐」:一帧 1/30 秒)(PR #402,感谢 @Cloudnyco)。
### 界面
- 休整期点「准备就绪」后商店自动收起、棋盘放大到全场,取消准备时商店再展开,不用再手动点「收起」(收起和展开仍可随时手动切换)。拖动干员身体这一条还没做(GitHub #138,感谢 @blackyang40-cmd)。
- 血条下方的三项读数(只是显示,不改变战斗):弹药类技能开启时(如隐现二技能,共 14 发),技力条换成分格的黄色弹药条,一格一发、打一发少一格、补弹时回来,开启期间脚下不再有持续的金色光环;伺夜的狼群在血条下方有一排菱形,亮的是剩下的狼影(最多 3 个),进入战术点形态时消失;斩业星熊进入「我执」后绿色血条停在 1 点,超出的伤害涨成血条上的红条(PR #303、#378,感谢 @IceCodeNew;PR #383,感谢 @Convey123)。
- 梓兰「猎头顾问」每次主动刷新冻结的那张同名干员现在会显示冰霜:此前服务器已经冻结了这张卡并留到下一回合,但商店栏只看上方的冻结按钮,玩家看不出是哪一张;规则不变,再手动刷新会连它一起重抽(GitHub #354,感谢 @DuolaCCCCat)。
- 渲染器初始化超过 12 秒而改用简化视图后,不会再迟到地把画布装到简化视图上、截走点击:超时、失败或离开页面时会中止这次初始化并销毁迟到的视图,也不再清空整个宿主元素(此前这还可能把后装的新视图一起删掉)(PR #302,感谢 @siruimei07)。
- 单位部署当刻就发动的技能(如宴),第一次发动的音效此前会丢失(技能事件比单位的出现事件先到),现在等单位出现后补播一次;中途进入战场时仍只回放「谁在场」,不会把之前的死亡、部署和技能音一次放出来(PR #292,感谢 @LimitlessHPPK)。
- 战场上单位很多时,缓存图集的刷新按本帧的更新顺序错开,每帧刷新的单位数几乎相同,不再因随机相位让个别帧特别忙而出现周期性的帧耗时尖峰;不改画面和战斗(GitHub #327 第一步,PR #334,感谢 @Cloudnyco)。
- 自选干员赤刃明霄陈三技能「赤霄·天喟」的剑气看得见了:剑气在战场上前进、拐弯时一路画出剑光拖尾和朝向前进方向的月牙(此前模拟里剑气照常移动、造成伤害,画面上只有开技能时的一下和命中时的闪光);剑光是程序绘制的近似效果,不是官方的粒子特效(维护者反馈;PR #382,感谢 @Convey123)。
- 敌人攻击后摇期间(攻击动作播完、重新迈步之前的那段)画面上也原地站着,不再在两次画面更新之间往前滑;被推开、恐惧、晕眩、隐身或死亡打断后摇时照常移动。只是显示,不改变战斗(PR #381,感谢 @ZiyueW1116;取代 #307、#314)。
- 被推开、拉拽的敌人会减速滑到模拟算出的位置,不再一下子出现在路径终点(此前推开约 1.7 格只用一两帧走完,更远的拉拽直接瞬移);滑行时长是本作拟定的(官方是冲量加摩擦,没有公开的时长),推开后被打倒的敌人尸体留在被推到的位置。飞行敌人死亡时在死亡动画播完前保持在空中,淡出时才落下;死亡动画最长 1.6 秒的限制放宽到 8 秒,盐风主教昆图斯等 25 个敌人的死亡动画不再被截短。只是显示,不改变战斗(PR #380,感谢 @xcdoge)。
- 琳琅诗怀雅二技能的香槟炸弹不再显示血条(PRTS:即使自身生命值未满,模型下方也不会显示生命值槽);它的生命值、受到的伤害和能否被治疗都不变(维护者反馈)。
- 佩佩、仇白、隐德来希、琳琅诗怀雅等干员闭眼(眨眼、倒地)时眼球不再从眼皮下露出来:场上带眼部遮罩的干员不止一名、或画质不是「高」时,遮罩会关掉以节省性能,现在改为在闭眼的那几帧把眼球藏起来(GitHub #177;PR #384 的思路,感谢 @Convey123)。
- 敌人的攻击来不及提前起手时(例如画面落后后补上的攻击),只要攻击间隔放得下整段攻击动作,就从头完整播一遍:重犯的铁球会先举起再砸下,不再只剩砸下的后半段;攻击比动作快的仍从命中那一帧播起(#246,感谢 @TsangAsuna)。
- 假想敌「骨刺」被阻挡或被反隐时立刻换成另一形态(B 组模型),恢复隐匿后变回三头蛇;攻击、晕眩、冻结中切换也会立即换模型(GitHub #296,感谢 @FrogThai 反馈;PR #365,感谢 @Sukvii)。
- 联防(两位队友一起守)和双人首领场里,敌人从一位队友的半场进来、在另一位队友的半场漏掉时,战斗结束后队友界面里该战场的进度与战斗中的胶囊一致(例如 1/1),不会再跳回 0(顶栏胶囊 PR #308 的补充:战斗结束时按整个战场的数字发布,不再把两位玩家各自被截到 0 的数字相加)
- 观战队友时点开盟约详情,成员人数和高亮与外圈的数字一致了:此前休整期观战会把队友整备区、临时整备区里的干员也标成「在场」。现在只有作战区的干员标为在场;整备区的干员标为「整备区」,投资人、远见、奇迹这类含整备区的盟约照常把他们算进成员人数;临时整备区的干员哪个盟约都不算——和自己的盟约详情一样(GitHub #385,PR #387,感谢 @zasx77771)。
- 选择朝向时按 Enter 不会再误触发部署:选朝向期间按 Tab 先到轮盘上的「点击取消」,在它上面按 Enter 是取消;Tab 到准备、设置、放弃模拟等其他按钮上按 Enter 没有反应;焦点不在按钮上时 Enter 照常确认朝向。此前不管焦点在哪个按钮上,只要选了方向,Enter 都会直接部署(GitHub #394,PR #395,感谢 @Xinglan233)。
- 谢拉格盟约的「盟约寒风」改为暴风雪从战场下方往上刮(维护者 2026-10-08 确认官方就是从下往上):雪粒从屏幕底部被吹起、带着侧风翻滚,几道风痕一起刮过,寒风来时的全屏冷光也更明显;灵知三技能、麦哲伦一技能(每 3 秒一次)自身的寒气保持原来较轻的飘雪。只是显示,不改变战斗(GitHub PR #386,感谢 @leiming2333)。
- 「干员调配」里选中技能的蓝色边框恢复为正方形、完整包住技能图标:此前全局的图片宽度限制让边框比图标窄、比图标高并向左偏,两侧线条被裁细,图标的一边露在框外(GitHub #398,PR #399 的定位,感谢 @shadiaojumao-hyb)。
- 圣约送葬人装备 Y 模组(REA-Y)时,干员调配、局内数值和详情卡上的特性写回「每攻击到一个敌人回复自身50生命」,与战斗中一致;此前把模组追加那一行「攻击速度+12」的 12 误填进了回血量。只改显示,战斗一直按 50 回复(GitHub #400,感谢 @PCL1234)。
- 「干员调配 → 自选编队」里再次点中已经选中的干员,技能和模组的草稿不再被打回已保存的选择或默认值(PR #306,感谢 @Xinglan233)。
- 出售召唤者时,其召唤物栈即将空出的整备区或临时整备区格子算进容量:每个自己的栈算一格,不管叠了几只;场上的召唤物和别人的召唤物不算(PR #309,感谢 @siruimei07)。
- 导入配置等共用弹窗把 Tab 焦点圈在弹窗内,隐藏的文件选择框不再接到焦点;攻略层打开时弹窗让出键盘,Shift+Tab 不再跑到背景按钮上(PR #341,感谢 @Xinglan233)。
- 观战休整期时,机变选完后效果和下一波敌人预览会刷新给队友和观战席,不再只在棋子移动时才推送(PR #349,感谢 @siruimei07)。
- 按 D 升级调度中心之后,升级按钮上的二次确认会清掉,下一次点击只是选中新的等级,不会再升一级(PR #356,感谢 @Xinglan233)。
- 替换装备窗口里,焦点在「取消」或装备选项上时按回车走按钮自己的动作,取消不再拆掉选中的装备(PR #358,感谢 @Xinglan233)。
- 整备区已满、只剩自己的召唤物栈时,可以把干员撤退到这一格;临时整备区、场上的召唤物和别人的召唤物仍然不算空位(PR #361,感谢 @Xinglan233)。
- 「干员调配 → 自选编队」的选择列表刚打开时立刻按 Esc 也能关上:此前列表出现后一瞬间按下的 Esc 会被吞掉,列表留在屏幕上。
- 拟态物质、简易通讯机、寻呼模块拿不到干员时会提示原因:卡池里这名干员已经全部被持有(例如 1 金 2 白溯光星源已占满Ⅵ阶的 5 份),或者没有可获得的同盟约干员;道具照官方说明仍会销毁(#401)。
- 拖动干员到可以放下的格子(棋盘或整备区)上时,模型直接站在那一格上(与官方部署一致,手指在格子里移动时模型不动),不再悬在手指下方、看起来落在下一格;落点仍是指针 / 手指所在的格子,不在可放下的格子上时(例如商店栏上)模型照旧跟在指针下(感谢 @bubububaoshe 在 PR #403 的反馈)。
### 联机与大厅
- 对局开始后,顶栏延迟旁会出现观战席的小胶囊(眼睛图标 + 观战人数,没有人观战时不显示),点开是观战者名单:创建者可以在名单里用 ✕ 把观战者移出,被移出的人回到大厅、不再收到战场画面;其他玩家和观战者也能看到名单。此前对局开始后看不到谁在观战,也没法移出(房间界面的名单在对局中不显示)。观战席上限仍是 2 个,不能调整(GitHub PR #120,感谢 @salt-fishes)。
- 两个标签页错开启动时,不再把同一个重连令牌各认领一次。(PR #299,感谢 @siruimei07)
- 较慢返回的旧语言包不再盖掉更新的语言选择。(PR #304,感谢 @siruimei07)
- 未设置 HOST 时默认监听 `::`,同一端口同时接受 IPv6 和 IPv4,启动横幅列出可分享的 IPv6 地址。只移植了双栈监听,没有带上授权页、设置迁移和防火墙脚本,Docker 镜像仍绑定 `0.0.0.0`。(PR #291,感谢 @LimitlessHPPK)
- 设置了 HTTP(S)_PROXY 时,素材下载会在进程启动时打开 NODE_USE_ENV_PROXY 再走代理(Node 22.21+ / 24);更旧的 Node 会提示无法走代理,然后照旧直连下载。(PR #289,感谢 @weinibuliu)
### 人机
- 人机(和 AI 托管)摆放「没有攻击距离」的重装干员(攻击范围只有自己脚下一格:角峰、古米、泡泡、折桠、菲莱、蛇屠箱、塞雷娅、余)时,会优先把它们放在敌人必经的路径格上:它们只有站在路径上才挡得到、打得到敌人,此前阵容很满时偶尔会被随机放到路边、白占一个部署名额;路径格不够用时仍退到其他合法位置(PR #339,感谢 @IceCodeNew;该 PR 里改购买评分、人事部文档估值的部分没有采用)。
### 工具与开发
- 素材清单 `data/assets.json` 多了 `audio.voiceJp`(日文语音:ArknightsAssets2 `voice/` 目录,和中文同样的文件名,放在 `public/assets/audio/voice/jp/`);`fetch-assets` / `setup` 两套都下载。`tools/package.mjs` 顶部的开关 `FULL_ZIP_JP_VOICE` 改成 `false` 时完整包(及其更新包)不带日文语音:玩家首次启动时由 setup 下载,更新包也不会删除玩家已有的日文语音。
- `--refresh` 下载失败时直接报错,不再退回旧缓存。(PR #350,感谢 @siruimei07)
- 启动时的构建标记现在包含模拟、shared 和数据,旧页面能发现新版本(刷新仍等本局结束)。(PR #351,感谢 @siruimei07)
- 校验重放按现有的 8 毫秒预算分片,不再卡住事件循环。(PR #352,感谢 @siruimei07)
- 本地一条命令按 GitHub CI 的顺序跑检查(不含 npm ci)。(PR #367,感谢 @anAtheist987)
- 增加战斗性能基准(public/dev,默认不打进发布包)。(PR #198,感谢 @Cloudnyco)
- 增加 GitHub 议题模板(功能请求的「期望效果」补了 id,去掉不用勾的检查项,标签统一为小写 bug)。(PR #300,感谢 @Griseo-bh3rd)
- 战斗模拟改用与浏览器引擎无关的数学函数:Chrome、Firefox、Safari 和服务器算出的战斗逐位一致(sin / cos / atan2 用和 Node 相同的 fdlibm 算法,hypot 和整数次幂自己实现),模拟里禁止再用会因引擎而异的 Math 函数(PR #373,感谢 @Cloudnyco)。
- Windows 上打包、读取 zip 时中文文件名不再出错(Windows 的 tar 改为写 UTF-8 文件名,读取端也认 Info-ZIP 的 Unicode Path 字段),GitHub Actions 的 Windows 检查恢复正常;发布包本身不受影响(PR #311,感谢 @Cloudnyco)。
- 新增 AGENTS.md(CLAUDE.md 指向它):给 AI 编程助手的入口,只列出该先读的文档和仓库的硬规矩,完整说明仍在 CONTRIBUTING.md(GitHub #379,感谢 @anAtheist987)。
## 0.2.1 — 2026-10-07
0.2.0 之后的快速更新:联防改回在本回合的战场上进行(0.2.0 的联防地图没有任何地形,影响游玩),突袭在战场两边都有人时可以跳到队友的半场,干员按满潜能计算,并修复了 GitHub 上反馈的问题、移植了多位贡献者的 PR。从这个版本起,Releases 里除了完整包和精简包,还有只含改动文件的更新包,可以直接覆盖到用 0.2.0 整合包装好的文件夹上。规则对照官方数据和 PRTS 核对,详见 DESIGN §26([docs/history/0.2.1.md](docs/history/0.2.1.md))。
+3
View File
@@ -0,0 +1,3 @@
# CLAUDE.md
Read [AGENTS.md](AGENTS.md) — the entry point for AI coding assistants (what to read first, the hard rules, how to verify).
+8 -1
View File
@@ -31,6 +31,11 @@ npm run golden:update # 有意改变
npm run lint && npm run check:imports && npm run typecheck # 代码检查、导入边界、类型
```
提交 PR 前可以一条命令跑一遍 GitHub CI(`.github/workflows/ci.yml`)的全部检查:`npm run ci`(或 `node tools/ci.mjs`)。
依次是 setup 检查、`node --test`、服务器冒烟测试(在空闲端口启动、请求 `/healthz` 和首页、运行 doctor,然后关掉)、lint、导入边界、
类型;第一步失败就停下并打印每步结果和耗时。`--keep-going` 失败后继续,`--only lint,test` 只跑指定步骤,`--list` 列出步骤。
不包含 `npm ci`(依赖请自己装),也不跑 Node 版本 × 系统的矩阵。
浏览器测试默认不跑,需要本机 Chrome(路径不标准时设置 `CHROME_PATH`),用环境变量打开:
```bash
@@ -99,7 +104,9 @@ SIM_E2E=1 node --test 'test/sim/*.browser.test.js' # 浏览器里
- **Setup**: Node.js 22 or 24; `npm install`, `npm run setup` (art and audio — optional for tests), `npm run dev`.
The code map is [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md).
- **Tests**: run the targeted files (`node --test <file>`, `--test-name-pattern`), then `node --test` and
`npm run golden` before a pull request; `npm run lint`, `npm run check:imports`, `npm run typecheck`. Browser suites
`npm run golden` before a pull request; `npm run lint`, `npm run check:imports`, `npm run typecheck`.
`npm run ci` (or `node tools/ci.mjs`) runs the checks of `.github/workflows/ci.yml` in one go and stops at the first
failure (`--keep-going`, `--only <steps>`, `--list`); it does not run `npm ci`. Browser suites
are opt-in: `SP_E2E=1`, `SP_REAL_E2E=1` (needs downloaded art), `RENDER_E2E=1`, `SIM_E2E=1` (a local Chrome,
`CHROME_PATH`). A refactor never changes `test/golden/*.json`; an intended gameplay change runs
`npm run golden:update` and commits the digests with the change, naming the scenarios that moved.
+12 -9
View File
@@ -2,7 +2,7 @@
《明日方舟》季节性自走棋塔防玩法「卫戍协议:盟约」的**非官方同人复刻**:浏览器即开即玩,单人或 1–4 人联机合作。
![version](https://img.shields.io/badge/version-0.2.1-2ea44f)
![version](https://img.shields.io/badge/version-0.2.2-2ea44f)
![license](https://img.shields.io/badge/code%20license-GPL--3.0--or--later-blue)
![node](https://img.shields.io/badge/node-22%20%7C%2024-339933)
@@ -37,7 +37,7 @@ English summary: [below](#english).
- **独立模拟**(单人)与**同盟模拟**(1–4 人**合作**,没有 PvP;空位可以加 AI 队友)。
- 服务器是一个 Node.js 程序,**战斗在各玩家的浏览器里模拟**(和官方一样),服务器只管经济与回合,一台低功耗小主机就能开服。
- 当前版本 0.2.1:联防改回在本回合的战场上进行,干员按满潜能计算,并修复了 0.2.0 发布后玩家和 GitHub 上反馈的问题;从这个版本起提供只含改动文件的更新包,详见 [CHANGELOG.md](CHANGELOG.md)。仍有少数规则按推断实现,与官方不一致的地方欢迎在 Issue 里反馈。
- 当前版本 0.2.2:干员可以单独设置潜能和练度(默认满潜、精英2 Lv.60),新增日文语音和统计数据页,按官方补上失衡、整数秒攻击间隔 30 帧等规则,并修复了 0.2.1 发布后玩家和 GitHub 上反馈的问题,详见 [CHANGELOG.md](CHANGELOG.md)。仍有少数规则按推断实现,与官方不一致的地方欢迎在 Issue 里反馈。
## 功能一览
@@ -64,8 +64,8 @@ English summary: [below](#english).
[Releases](../../releases/latest) 里有两种整合包,代码和运行依赖完全相同,二选一;已经装好 0.2.x 的,升级时只下载更新包即可:
- **完整包** `Stronghold-Protocol-v<版本>.zip`(约 430 MB,解压后约 625 MB):附带全部美术 / 音频(含官方 3D 棋盘贴图),解压就能玩,不需要再下载任何东西。**推荐。**
- **精简包** `Stronghold-Protocol-v<版本>-lite.zip`(约 22 MB):不带素材,第一次启动时自动从公开镜像下载美术、Spine 模型、音频、字体、表情和「玩法说明」教程图(约 460 MB,可中断,再次启动会续传);官方 3D 棋盘等本地客户端素材不在其中(见下面的「本地客户端素材」)。适合下载大文件不方便的情况。
- **完整包** `Stronghold-Protocol-v<版本>.zip`(约 505 MB,解压后约 710 MB):附带全部美术 / 音频(含中文、日文两套干员语音和官方 3D 棋盘贴图),解压就能玩,不需要再下载任何东西。**推荐。**
- **精简包** `Stronghold-Protocol-v<版本>-lite.zip`(约 22 MB):不带素材,第一次启动时自动从公开镜像下载美术、Spine 模型、音频、字体、表情和「玩法说明」教程图(约 550 MB,可中断,再次启动会续传);官方 3D 棋盘等本地客户端素材不在其中(见下面的「本地客户端素材」)。适合下载大文件不方便的情况。
- **更新包** `Stronghold-Protocol-v<版本>-update.zip`(0.2.1 起提供,大小看改动多少,通常只有几 MB):只含比之前的 0.2.x 版本改动过的文件,用来把已经装好的 0.2.x(完整包或精简包装的都行)升级到新版本,不用重新下载整个包。用法:先停止服务器(关掉窗口;装了开机自启的运行 `scripts\install-service-windows.ps1 -Stop`),把 zip 里 `Stronghold-Protocol` 文件夹的全部内容合并到安装文件夹、覆盖同名文件(Windows 资源管理器里复制粘贴即可;macOS 不要用访达拖放,它会整个替换文件夹,请用 `unzip -o`,见 [docs/DEPLOY.md](docs/DEPLOY.md) 第 1.5 节),再照常启动。启动时会先核对全部程序文件、删除新版本不再用的旧文件,然后正常运行;如果这个文件夹不是更新包对应的版本(例如 0.1.x,或者程序文件被改过),会提示下载完整包,服务器不启动。全新安装请用完整包或精简包。
两种包都只含运行和部署需要的文件(服务器、客户端、数据、启动脚本、setup / doctor / 素材下载工具、许可证与说明、[docs/PLAYING.md](docs/PLAYING.md) 和 [docs/DEPLOY.md](docs/DEPLOY.md));测试、开发工具和设计文档只在源码仓库里。
@@ -86,7 +86,7 @@ English summary: [below](#english).
git clone https://github.com/sganggs/Stronghold-Protocol.git
cd Stronghold-Protocol
npm install # 安装依赖(postinstall 会把 pixi / preact / three 复制到 public/vendor)
npm run setup # 检查环境,并从公开镜像下载约 460 MB 美术 / 音频(可中断,再次运行会续传)
npm run setup # 检查环境,并从公开镜像下载约 550 MB 美术 / 音频(可中断,再次运行会续传)
npm start # 启动服务器:http://localhost:3000
```
@@ -100,7 +100,7 @@ npm start # 启动服务器:http://localhost:3000
| 项目 | 要求 |
|---|---|
| 开服的电脑 | Windows / macOS / Linux,Node.js 22 或 24(LTS);磁盘约 600–750 MB(素材、依赖与本地提取贴图:完整包解压后约 625 MB);内存空闲约 100 MB,每局再加几 MB |
| 开服的电脑 | Windows / macOS / Linux,Node.js 22 或 24(LTS);磁盘约 700–850 MB(素材、依赖与本地提取贴图:完整包解压后约 710 MB);内存空闲约 100 MB,每局再加几 MB |
| 玩家 | 支持 WebGL 的现代浏览器(Chrome / Edge / Firefox / Safari 最新版),电脑、手机或平板(横屏) |
| 网络 | 首次进入游戏时,每位玩家要从开服的电脑下载几十 MB 素材(之后走浏览器缓存);对局中流量很小 |
@@ -113,7 +113,7 @@ npm start # 启动服务器:http://localhost:3000
| 环境变量 | 默认 | 说明 |
|---|---|---|
| `PORT` | `3000` | 监听端口 |
| `HOST` | `0.0.0.0` | 监听地址(`127.0.0.1` = 只允许本机,放在反向代理后面时使用) |
| `HOST` | `::` | 监听地址。默认 `::` 是双栈:同一个端口同时接受 IPv6 和 IPv4;`0.0.0.0` = 只 IPv4;`127.0.0.1` = 只允许本机,放在反向代理后面时使用 |
| `SP_COMBAT` | `client` | `client`:各玩家浏览器模拟自己的战斗(服务器负载极低);`server`:由服务器模拟并推流 |
| `SP_VERIFY` | `off` | 服务器复算客户端上报的战斗结果:`off` / `sample`(约 1/8 抽查)/ `all`(全部复算,更耗 CPU) |
| `TRUST_PROXY` | `auto` | 是否信任 `X-Forwarded-For` 等转发头:`auto` 只信任来自本机 / 内网的代理;`1` 总是;`0` 从不 |
@@ -153,7 +153,7 @@ npm start # 启动服务器:http://localhost:3000
| 操作 | 方法 |
|---|---|
| 购买 / 升级调度中心 / 机变选卡 | 点一次选中,再点一次确认(`D` 升级) |
| 部署 / 移动干员 | 从整备区拖到棋盘格 → 出现方向轮盘 → 往上 / 右 / 下 / 左滑动选择朝向后松手;松在中心或点「✕ 点击取消」取消。拖动时模型在指针 / 手指下,指针所在的格子就是落点 |
| 部署 / 移动干员 | 从整备区拖到棋盘格 → 出现方向轮盘 → 往上 / 右 / 下 / 左滑动选择朝向后松手;松在中心或点「✕ 点击取消」取消。拖动时指针 / 手指所在的格子就是落点,能放下时模型直接站在这一格上(不能放下时跟在指针下) |
| 调整朝向 | 把干员拖回它自己的格子,再选方向 |
| 出售 / 撤退 / 销毁装备 | 点击单位所在的格子 → 底部按钮「出售 +1」「撤退」;也可以把棋盘上的干员拖回整备区撤退。整备区里的装备与法术只能「销毁」,已配发的装备锁定在干员身上(干员出售或合成精锐时退回整备区) |
| 装备 | 把装备拖到干员所在的格子上(每人 2 件;满了会弹出替换窗口,被替换的一件会被销毁);法术拖到地块上并选方向 |
@@ -163,6 +163,7 @@ npm start # 启动服务器:http://localhost:3000
| 暂停(独立模拟) | 作战中(含最终攻势 / 隐秘核心)点顶栏的「暂停」或按 `Space`,再点「继续作战」(或 `Space`)继续;同盟模拟的作战不能暂停 |
| 表情 | 左下角「交流」,左右滑动(或方向键)换主题,冷却 1 秒 |
| 观战 | 自己的作战结束后(或休整期)点左侧队友头像 →「前往查看」;不参战的朋友可以在大厅输入同盟密钥点「观战」(每个同盟最多 2 名观战者,本作新增) |
| 统计(本机) | 标题画面右上角、大厅、等待室的「统计」:胜率、策略通过率、称号、战斗累计和对局记录(点一行回看结算页);只存在本机浏览器里,可以导出 / 导入,没清完第一个回合就退出的对局不计入统计([玩法指南 §12](docs/PLAYING.md#13-统计本机)) |
完整的规则、数值和小技巧见 **[docs/PLAYING.md](docs/PLAYING.md)**(游戏内左下角也有「玩法说明」)。
@@ -194,9 +195,11 @@ SP_E2E=1 node --test test/ui/mock.e2e.test.js # 浏览器端到端测试
SP_REAL_E2E=1 node --test test/ui/real.e2e.test.js # 需要 Chrome + 已下载的素材
RENDER_E2E=1 node --test 'test/render/*.browser.test.js' # 渲染测试,部分需要本地提取的棋盘贴图
GOLDEN_FULL=1 node --test test/golden.test.js # 黄金结果:固定种子的整套战斗与人机对局摘要(默认只跑快速子集)
node tools/perfbench.mjs --cpu 1,4,6 --profile # 真实战斗的帧耗时基准(Chrome 降速 CPU 近似中低端手机),需要 Chrome + 已下载的素材
```
- 游戏数据由 `npm run build-data`(`tools/build-data.mjs`)从官方数据表生成,不要手工修改 `data/*.json`。
- 性能测试:`/dev/battle-perf.html` 在浏览器里跑一场真实战斗(对局用的战斗运行器、模拟和渲染),实时显示帧率与逐帧耗时;在手机上打开后点击「开始测量(10 秒)」,生成的报告可以复制后附在反馈中。用到的战斗来自 `node tools/capture-specs.mjs` 从固定种子的机器人对局里截取的数据(`public/dev/perf/`)。
- 只重构、不改玩法的提交不能改变 `test/golden/*.json`;有意改变玩法时运行 `npm run golden:update`,检查差异后随改动一起提交(见 [test/golden/README.md](test/golden/README.md))。
- GitHub Actions([.github/workflows/ci.yml](.github/workflows/ci.yml))在 Ubuntu 与 Windows、Node 22 / 24 上运行 `npm ci`、`node --test` 和服务器冒烟测试。
- 代码怎么分层、改某个规则该从哪个文件入手,见 [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md)。
@@ -246,7 +249,7 @@ GOLDEN_FULL=1 node --test test/golden.test.js # 黄金结果:固定
An **unofficial, non-commercial fan remake** of Arknights' seasonal auto-chess tower-defense mode *Stronghold Protocol: Alliance*, played in the browser: solo, or 1–4 player co-op (AI teammates can fill seats). Combat is simulated in each player's browser, so a low-power PC can host.
- **Run:** download the full bundle `Stronghold-Protocol-v<version>.zip` (~430 MB, all the art inside) from [Releases](../../releases/latest) — or the lite one, `…-lite.zip` (~22 MB), which downloads the art (~460 MB) on its first start; from 0.2.1 on, `…-update.zip` holds only the files changed since the earlier 0.2.x releases: stop the server, extract it over an existing 0.2.x folder and start again (the first start deletes the files the new version dropped and verifies the install) — install Node.js 22 or 24, then double-click `scripts\start-windows.bat` (Windows) or run `./scripts/start.sh` (macOS / Linux) and open <http://localhost:3000>. From source: `npm install && npm run setup && npm start` (setup downloads ~460 MB of art from public mirrors, the emotes and the how-to-play pages included; the official 3D board, some official HUD icons, two enemy models and 39 summon models are extracted from a local Arknights client — without one the game uses the 2D board and look-alike stand-ins (the summons show their avatars), and a server can copy `public/assets/local/` and `data/local-assets.json` from the full bundle of the same version).
- **Run:** download the full bundle `Stronghold-Protocol-v<version>.zip` (~505 MB, all the art inside, the Chinese and Japanese operator voices included) from [Releases](../../releases/latest) — or the lite one, `…-lite.zip` (~22 MB), which downloads the art (~550 MB) on its first start; from 0.2.1 on, `…-update.zip` holds only the files changed since the earlier 0.2.x releases: stop the server, extract it over an existing 0.2.x folder and start again (the first start deletes the files the new version dropped and verifies the install) — install Node.js 22 or 24, then double-click `scripts\start-windows.bat` (Windows) or run `./scripts/start.sh` (macOS / Linux) and open <http://localhost:3000>. From source: `npm install && npm run setup && npm start` (setup downloads ~550 MB of art from public mirrors, the emotes and the how-to-play pages included; the official 3D board, some official HUD icons, two enemy models and 39 summon models are extracted from a local Arknights client — without one the game uses the 2D board and look-alike stand-ins (the summons show their avatars), and a server can copy `public/assets/local/` and `data/local-assets.json` from the full bundle of the same version).
- **Languages:** Chinese (the default), English, 日本語, 한국어 and 繁體中文 — switch on the title screen or in Settings. Game texts come from the official clients; the Japanese, Korean and Traditional Chinese interface strings are machine translations (corrections welcome: [docs/I18N.md](docs/I18N.md)).
- **Play with friends:** create a co-op room and share the 4-letter key or the `?room=KEY` link. On a LAN, use the address printed at start; otherwise use a virtual-LAN tool, a tunnel or a VPS — see [docs/DEPLOY.md](docs/DEPLOY.md).
- **Disclaimer:** not affiliated with or endorsed by Hypergryph or Yostar. All Arknights names, art, audio, text and data are © their respective owners and are **not** covered by this project's GPL licence. For study and personal non-commercial use only — no selling, paid distribution, paid servers or monetisation of any kind. Content will be removed on request of the rights holders. Provided "as is", without warranty.
+1 -1
View File
@@ -1 +1 @@
{"version":1,"hash":"7ae1d03466cb","generator":"tools/fetch-assets.mjs","stats":{"files":7969,"bytes":485061859,"chars":209,"charsWithBack":206,"enemies":252,"enemiesWithSpine":251,"tokens":58,"tokensWithSpine":13,"spineModels":671,"bonds":23,"items":59,"bands":40,"skills":522,"modules":131,"ui":602,"sfxUnits":483,"voiceChars":191},"chars":{"char_003_kalts":{"avatar":"/assets/char/avatar/char_003_kalts.png","avatarE2":"/assets/char/avatar/char_003_kalts_2.png","portrait":"/assets/char/portrait/char_003_kalts_1.png","portraitE2":"/assets/char/portrait/char_003_kalts_2.png","spine":{"front":{"skel":"/assets/spine/op/char_003_kalts/front/char_003_kalts.skel","atlas":"/assets/spine/op/char_003_kalts/front/char_003_kalts.atlas","textures":["/assets/spine/op/char_003_kalts/front/char_003_kalts.png"],"pma":false,"anims":{"idle":"Idle","deploy":"Start","attack":{"begin":null,"loop":"Attack","end":null},"attackDown":null,"skill":{"begin":null,"loop":"Attack","end":null,"via":"attack","index":0,"idle":null},"die":"Die","move":null,"stun":null,"skills":{"0":{"begin":null,"loop":"Attack","end":null,"via":"attack","index":0,"idle":null},"1":{"begin":null,"loop":"Attack","end":null,"via":"attack","index":1,"idle":null},"2":{"begin":null,"loop":"Attack","end":null,"via":"attack","index":2,"idle":null}}},"animations":{"Attack":1,"Default":0,"Die":1,"Idle":9,"Start":1},"events":["OnAttack","OnStart"],"hits":{"Attack":[0.433]},"bounds":{"x":-227.579,"y":-138.244,"width":532.04,"height":551.479}},"back":{"skel":"/assets/spine/op/char_003_kalts/back/char_003_kalts.skel","atlas":"/assets/spine/op/char_003_kalts/back/char_003_kalts.atlas","textures":["/assets/spine/op/char_003_kalts/back/char_003_kalts.png"],"pma":false,"anims":{"idle":"Idle","deploy":"Start","attack":{"begin":null,"loop":"Attack","end":null},"attackDown":null,"skill":{"begin":null,"loop":"Attack","end":null,"via":"attack","index":0,"idle":null},"die":null,"move":null,"stun":null,"skills":{"0":{"begin":null,"loop":"Attack","end":null,"via":"attack","index":0,"idle":null},"1":{"begin":null,"loop":"Attack","end":null,"via":"attack","index":1,"idle":null},"2":{"begin":null,"loop":"Attack","end":null,"via":"attack","index":2,"idle":null}}},"animations":{"Attack":1,"Default":0,"Idle":9,"Start":1},"events":["OnAttack","OnStart"],"hits":{"Attack":[0.433]},"bounds":{"x":410.785,"y":-2.26,"width":531.785,"height":416.492}}}},"char_010_chen":{"avatar":"/assets/char/avatar/char_010_chen.png","avatarE2":"/assets/char/avatar/char_010_chen_2.png","portrait":"/assets/char/portrait/char_010_chen_1.png","portraitE2":"/assets/char/portrait/char_010_chen_2.png","spine":{"front":{"skel":"/assets/spine/op/char_010_chen/front/char_010_chen.skel","atlas":"/assets/spine/op/char_010_chen/front/char_010_chen.atlas","textures":["/assets/spine/op/char_010_chen/front/char_010_chen.png"],"pma":false,"anims":{"idle":"Idle","deploy":"Start","attack":{"begin":null,"loop":"Attack","end":null},"attackDown":null,"skill":{"begin":null,"loop":"Skill","end":"Skill_End","index":0,"idle":null},"die":"Die","move":null,"stun":null,"skills":{"0":{"begin":null,"loop":"Skill","end":"Skill_End","index":0,"idle":null},"1":{"begin":null,"loop":"Skill_2","end":null,"index":1,"idle":null},"2":{"begin":null,"loop":"Skill_3","end":null,"index":2,"idle":null}}},"animations":{"Attack":1.5,"Attack_End":0.6,"Attack_Pre":0.6,"Default":0,"Die":1,"Idle":2,"Skill":1,"Skill_2":1.167,"Skill_3":2.933,"Skill_End":0.6,"Skill_End_2":0.6,"Skill_End_3":0.3,"Start":0.833},"events":["OnAttack","OnStart"],"hits":{"Attack":[0.433,1],"Skill":[0.467],"Skill_2":[0.467],"Skill_3":[0.6,0.8,1,1.2,1.4,1.6,1.8,2,2.2,2.833,2.933]},"bounds":{"x":-322.335,"y":-109.274,"width":663.549,"height":524.806}},"back":{"skel":"/assets/spine/op/char_010_chen/back/char_010_chen.skel","atlas":"/assets/spine/op/char_010_chen/back/char_010_chen.atlas","textures":["/assets/spine/op/char_010_chen/back/char_010_chen.png"],"pma":false,"anims":{"idle":"Idle","deploy":"Start","attack":{"begin":null,"loop":"Attack","end":null},"attackDown":null,"skill":{"begin":null,"loop":"Skill","end":"Skill_End","index":0,"idle":null},"die":null,"move":null,"stun":null,"skills":{"0":{"begin":null,"loop":"Skill","end":"Skill_End","index":0,"idle":null},"1":{"begin":null,"loop":"Skill","end":"Skill_End","index":1,"idle":null},"2":{"begin":null,"loop":"Skill","end":"Skill_End","index":2,"idle":null}}},"animations":{"Attack":1.5,"Attack_End":0.6,"Attack_Pre":0.6,"Default":0,"Idle":2,"Skill":1,"Skill_End":0.6,"Start":0.833},"events":["OnAttack","OnStart"],"hits":{"Attack":[0.433,1],"Skill":[0.467]},"bounds":{"x":-173.082,"y":-13.829,"width":315.887,"height":404.623}}}},"char_017_huang":{"avatar":"/assets/char/avatar/char_017_huang.png","avatarE2":"/assets/char/avatar/char_017_huang_2.png","portrait":"/assets/char/portrait/char_017_huang_1.png","portraitE2":"/assets/char/portrait/char_017_huang_2.png","spine":{"front":{"skel":"/assets/spine/op/char_017_huang/front/char_017_huang.skelLine truncated
{"version":1,"hash":"bfdfd5e7efd9","generator":"tools/fetch-assets.mjs","stats":{"files":10643,"bytes":574449900,"chars":209,"charsWithBack":206,"enemies":252,"enemiesWithSpine":251,"tokens":58,"tokensWithSpine":13,"spineModels":671,"bonds":23,"items":59,"bands":40,"skills":522,"modules":131,"ui":602,"sfxUnits":483,"voiceChars":191,"voiceJpChars":191},"chars":{"char_003_kalts":{"avatar":"/assets/char/avatar/char_003_kalts.png","avatarE2":"/assets/char/avatar/char_003_kalts_2.png","portrait":"/assets/char/portrait/char_003_kalts_1.png","portraitE2":"/assets/char/portrait/char_003_kalts_2.png","spine":{"front":{"skel":"/assets/spine/op/char_003_kalts/front/char_003_kalts.skel","atlas":"/assets/spine/op/char_003_kalts/front/char_003_kalts.atlas","textures":["/assets/spine/op/char_003_kalts/front/char_003_kalts.png"],"pma":false,"anims":{"idle":"Idle","deploy":"Start","attack":{"begin":null,"loop":"Attack","end":null},"attackDown":null,"skill":{"begin":null,"loop":"Attack","end":null,"via":"attack","index":0,"idle":null},"die":"Die","move":null,"stun":null,"skills":{"0":{"begin":null,"loop":"Attack","end":null,"via":"attack","index":0,"idle":null},"1":{"begin":null,"loop":"Attack","end":null,"via":"attack","index":1,"idle":null},"2":{"begin":null,"loop":"Attack","end":null,"via":"attack","index":2,"idle":null}}},"animations":{"Attack":1,"Default":0,"Die":1,"Idle":9,"Start":1},"events":["OnAttack","OnStart"],"hits":{"Attack":[0.433]},"bounds":{"x":-227.579,"y":-138.244,"width":532.04,"height":551.479}},"back":{"skel":"/assets/spine/op/char_003_kalts/back/char_003_kalts.skel","atlas":"/assets/spine/op/char_003_kalts/back/char_003_kalts.atlas","textures":["/assets/spine/op/char_003_kalts/back/char_003_kalts.png"],"pma":false,"anims":{"idle":"Idle","deploy":"Start","attack":{"begin":null,"loop":"Attack","end":null},"attackDown":null,"skill":{"begin":null,"loop":"Attack","end":null,"via":"attack","index":0,"idle":null},"die":null,"move":null,"stun":null,"skills":{"0":{"begin":null,"loop":"Attack","end":null,"via":"attack","index":0,"idle":null},"1":{"begin":null,"loop":"Attack","end":null,"via":"attack","index":1,"idle":null},"2":{"begin":null,"loop":"Attack","end":null,"via":"attack","index":2,"idle":null}}},"animations":{"Attack":1,"Default":0,"Idle":9,"Start":1},"events":["OnAttack","OnStart"],"hits":{"Attack":[0.433]},"bounds":{"x":410.785,"y":-2.26,"width":531.785,"height":416.492}}}},"char_010_chen":{"avatar":"/assets/char/avatar/char_010_chen.png","avatarE2":"/assets/char/avatar/char_010_chen_2.png","portrait":"/assets/char/portrait/char_010_chen_1.png","portraitE2":"/assets/char/portrait/char_010_chen_2.png","spine":{"front":{"skel":"/assets/spine/op/char_010_chen/front/char_010_chen.skel","atlas":"/assets/spine/op/char_010_chen/front/char_010_chen.atlas","textures":["/assets/spine/op/char_010_chen/front/char_010_chen.png"],"pma":false,"anims":{"idle":"Idle","deploy":"Start","attack":{"begin":null,"loop":"Attack","end":null},"attackDown":null,"skill":{"begin":null,"loop":"Skill","end":"Skill_End","index":0,"idle":null},"die":"Die","move":null,"stun":null,"skills":{"0":{"begin":null,"loop":"Skill","end":"Skill_End","index":0,"idle":null},"1":{"begin":null,"loop":"Skill_2","end":null,"index":1,"idle":null},"2":{"begin":null,"loop":"Skill_3","end":null,"index":2,"idle":null}}},"animations":{"Attack":1.5,"Attack_End":0.6,"Attack_Pre":0.6,"Default":0,"Die":1,"Idle":2,"Skill":1,"Skill_2":1.167,"Skill_3":2.933,"Skill_End":0.6,"Skill_End_2":0.6,"Skill_End_3":0.3,"Start":0.833},"events":["OnAttack","OnStart"],"hits":{"Attack":[0.433,1],"Skill":[0.467],"Skill_2":[0.467],"Skill_3":[0.6,0.8,1,1.2,1.4,1.6,1.8,2,2.2,2.833,2.933]},"bounds":{"x":-322.335,"y":-109.274,"width":663.549,"height":524.806}},"back":{"skel":"/assets/spine/op/char_010_chen/back/char_010_chen.skel","atlas":"/assets/spine/op/char_010_chen/back/char_010_chen.atlas","textures":["/assets/spine/op/char_010_chen/back/char_010_chen.png"],"pma":false,"anims":{"idle":"Idle","deploy":"Start","attack":{"begin":null,"loop":"Attack","end":null},"attackDown":null,"skill":{"begin":null,"loop":"Skill","end":"Skill_End","index":0,"idle":null},"die":null,"move":null,"stun":null,"skills":{"0":{"begin":null,"loop":"Skill","end":"Skill_End","index":0,"idle":null},"1":{"begin":null,"loop":"Skill","end":"Skill_End","index":1,"idle":null},"2":{"begin":null,"loop":"Skill","end":"Skill_End","index":2,"idle":null}}},"animations":{"Attack":1.5,"Attack_End":0.6,"Attack_Pre":0.6,"Default":0,"Idle":2,"Skill":1,"Skill_End":0.6,"Start":0.833},"events":["OnAttack","OnStart"],"hits":{"Attack":[0.433,1],"Skill":[0.467]},"bounds":{"x":-173.082,"y":-13.829,"width":315.887,"height":404.623}}}},"char_017_huang":{"avatar":"/assets/char/avatar/char_017_huang.png","avatarE2":"/assets/char/avatar/char_017_huang_2.png","portrait":"/assets/char/portrait/char_017_huang_1.png","portraitE2":"/assets/char/portrait/char_017_huang_2.png","spine":{"front":{"skel":"/assets/spine/op/char_017_huang/frontLine truncated
+1 -1
View File
@@ -1 +1 @@
{"units":{"char_600_cpione":{"charId":"char_600_cpione","name":"预备干员-先锋","appellation":"Reserve Operator - Vanguard","rarity":4,"profession":"PIONEER","subProfessionId":"pioneer","subProfessionName":"尖兵","position":"MELEE","nationId":null,"isNotObtainable":true,"assets":{"avatar":"char_600_cpione","avatarGolden":"char_600_cpione","portrait":"char_600_cpione_1","portraitGolden":"char_600_cpione_1","spine":"char_600_cpione","subProfIcon":"sub_pioneer_icon"},"moduleNames":{},"standsIn":["chess_char_5_01_a"],"forms":{"2/1/4/0":{"status":{"phase":2,"level":1,"skillLevel":4,"equipLevel":0},"stats":{"maxHp":1225,"atk":320,"def":272,"res":0,"cost":11,"blockCnt":2,"bat":1.05,"aspd":100,"respawnTime":70,"spRecovery":1,"hpRecoveryPerSec":0,"moveSpeed":1,"tauntLevel":0,"massLevel":0,"deployLimit":1,"deckStack":0},"immunities":{"stun":false,"silence":false,"sleep":false,"frozen":false,"levitate":false},"rangeId":"1-1","rangeGrid":[[0,0],[0,1]],"dmgType":"phys","attackKind":"melee","projectile":"none","canHitFly":false,"targetPriority":null,"trait":{"desc":"能够阻挡两个敌人","descRaw":"能够阻挡两个敌人","bb":{},"bbStr":{},"rangeGrid":null},"skills":[{"skillId":"skcom_charge_cost[1]","iconId":"skcom_charge_cost[1]","name":"冲锋号令·α型","level":4,"desc":"立即获得6点部署费用","descRaw":"立即获得<@ba.vup>6</>点部署费用","skillType":"AUTO","durationType":"NONE","duration":0,"spType":"INCREASE_WITH_TIME","spCost":27,"initSp":3,"maxChargeTime":1,"increment":1,"bb":{"cost":6},"bbStr":{},"rangeId":null,"rangeGrid":null,"prefabId":"skcom_charge_cost","trigger":{"rule":"DEFAULT","rawRule":"DEFAULT","customRangeGrid":null},"index":0,"overrideTokenKey":null},{"skillId":"skcom_charge_cost[2]","iconId":"skcom_charge_cost[2]","name":"冲锋号令·β型","level":4,"desc":"立即获得9点部署费用","descRaw":"立即获得<@ba.vup>9</>点部署费用","skillType":"AUTO","durationType":"NONE","duration":0,"spType":"INCREASE_WITH_TIME","spCost":36,"initSp":9,"maxChargeTime":1,"increment":1,"bb":{"cost":9},"bbStr":{},"rangeId":null,"rangeGrid":null,"prefabId":"skcom_charge_cost","trigger":{"rule":"DEFAULT","rawRule":"DEFAULT","customRangeGrid":null},"index":1,"overrideTokenKey":null},{"skillId":"skcom_charge_cost[3]","iconId":"skcom_charge_cost[3]","name":"冲锋号令·γ型","level":4,"desc":"立即获得12点部署费用","descRaw":"立即获得<@ba.vup>12</>点部署费用","skillType":"AUTO","durationType":"NONE","duration":0,"spType":"INCREASE_WITH_TIME","spCost":41,"initSp":12,"maxChargeTime":1,"increment":1,"bb":{"cost":12},"bbStr":{},"rangeId":null,"rangeGrid":null,"prefabId":"skcom_charge_cost","trigger":{"rule":"DEFAULT","rawRule":"DEFAULT","customRangeGrid":null},"index":2,"overrideTokenKey":null}],"talents":[{"index":0,"name":"攻击提升","desc":"攻击力+8%","descRaw":"攻击力+8%","bb":{"atk":0.08},"bbStr":{},"rangeGrid":null,"tokenKey":null,"hidden":false,"fromModule":false}],"displayTokens":[],"tokens":[]},"2/60/7/1":{"status":{"phase":2,"level":60,"skillLevel":7,"equipLevel":1},"stats":{"maxHp":1674,"atk":380,"def":322,"res":0,"cost":11,"blockCnt":2,"bat":1.05,"aspd":100,"respawnTime":70,"spRecovery":1,"hpRecoveryPerSec":0,"moveSpeed":1,"tauntLevel":0,"massLevel":0,"deployLimit":1,"deckStack":0},"immunities":{"stun":false,"silence":false,"sleep":false,"frozen":false,"levitate":false},"rangeId":"1-1","rangeGrid":[[0,0],[0,1]],"dmgType":"phys","attackKind":"melee","projectile":"none","canHitFly":false,"targetPriority":null,"trait":{"desc":"能够阻挡两个敌人","descRaw":"能够阻挡两个敌人","bb":{},"bbStr":{},"rangeGrid":null},"skills":[{"skillId":"skcom_charge_cost[1]","iconId":"skcom_charge_cost[1]","name":"冲锋号令·α型","level":7,"desc":"立即获得6点部署费用","descRaw":"立即获得<@ba.vup>6</>点部署费用","skillType":"AUTO","durationType":"NONE","duration":0,"spType":"INCREASE_WITH_TIME","spCost":25,"initSp":6,"maxChargeTime":1,"increment":1,"bb":{"cost":6},"bbStr":{},"rangeId":null,"rangeGrid":null,"prefabId":"skcom_charge_cost","trigger":{"rule":"DEFAULT","rawRule":"DEFAULT","customRangeGrid":null},"index":0,"overrideTokenKey":null},{"skillId":"skcom_charge_cost[2]","iconId":"skcom_charge_cost[2]","name":"冲锋号令·β型","level":7,"desc":"立即获得9点部署费用","descRaw":"立即获得<@ba.vup>9</>点部署费用","skillType":"AUTO","durationType":"NONE","duration":0,"spType":"INCREASE_WITH_TIME","spCost":33,"initSp":10,"maxChargeTime":1,"increment":1,"bb":{"cost":9},"bbStr":{},"rangeId":null,"rangeGrid":null,"prefabId":"skcom_charge_cost","trigger":{"rule":"DEFAULT","rawRule":"DEFAULT","customRangeGrid":null},"index":1,"overrideTokenKey":null},{"skillId":"skcom_charge_cost[3]","iconId":"skcom_charge_cost[3]","name":"冲锋号令·γ型","level":7,"desc":"立即获得12点部署费用","descRaw":"立即获得<@ba.vup>12</>点部署费用","skillType":"AUTO","durationType":"NONE","duration":0,"spType":"INCREASE_WILine truncated
{"units":{"char_600_cpione":{"charId":"char_600_cpione","name":"预备干员-先锋","appellation":"Reserve Operator - Vanguard","rarity":4,"profession":"PIONEER","subProfessionId":"pioneer","subProfessionName":"尖兵","position":"MELEE","nationId":null,"isNotObtainable":true,"assets":{"avatar":"char_600_cpione","avatarGolden":"char_600_cpione","portrait":"char_600_cpione_1","portraitGolden":"char_600_cpione_1","spine":"char_600_cpione","subProfIcon":"sub_pioneer_icon"},"moduleNames":{},"standsIn":["chess_char_5_01_a"],"forms":{"2/1/4/0":{"status":{"phase":2,"level":1,"skillLevel":4,"equipLevel":0},"stats":{"maxHp":1225,"atk":320,"def":272,"res":0,"cost":11,"blockCnt":2,"bat":1.05,"aspd":100,"respawnTime":70,"spRecovery":1,"hpRecoveryPerSec":0,"moveSpeed":1,"tauntLevel":0,"massLevel":0,"deployLimit":1,"deckStack":0},"immunities":{"stun":false,"silence":false,"sleep":false,"frozen":false,"levitate":false},"rangeId":"1-1","rangeGrid":[[0,0],[0,1]],"dmgType":"phys","attackKind":"melee","projectile":"none","canHitFly":false,"targetPriority":null,"trait":{"desc":"能够阻挡两个敌人","descRaw":"能够阻挡两个敌人","bb":{},"bbStr":{},"rangeGrid":null},"skills":[{"skillId":"skcom_charge_cost[1]","iconId":"skcom_charge_cost[1]","name":"冲锋号令·α型","level":4,"desc":"立即获得6点部署费用","descRaw":"立即获得<@ba.vup>6</>点部署费用","skillType":"AUTO","durationType":"NONE","duration":0,"spType":"INCREASE_WITH_TIME","spCost":27,"initSp":3,"maxChargeTime":1,"increment":1,"bb":{"cost":6},"bbStr":{},"rangeId":null,"rangeGrid":null,"prefabId":"skcom_charge_cost","trigger":{"rule":"DEFAULT","rawRule":"DEFAULT","customRangeGrid":null},"index":0,"overrideTokenKey":null},{"skillId":"skcom_charge_cost[2]","iconId":"skcom_charge_cost[2]","name":"冲锋号令·β型","level":4,"desc":"立即获得9点部署费用","descRaw":"立即获得<@ba.vup>9</>点部署费用","skillType":"AUTO","durationType":"NONE","duration":0,"spType":"INCREASE_WITH_TIME","spCost":36,"initSp":9,"maxChargeTime":1,"increment":1,"bb":{"cost":9},"bbStr":{},"rangeId":null,"rangeGrid":null,"prefabId":"skcom_charge_cost","trigger":{"rule":"DEFAULT","rawRule":"DEFAULT","customRangeGrid":null},"index":1,"overrideTokenKey":null},{"skillId":"skcom_charge_cost[3]","iconId":"skcom_charge_cost[3]","name":"冲锋号令·γ型","level":4,"desc":"立即获得12点部署费用","descRaw":"立即获得<@ba.vup>12</>点部署费用","skillType":"AUTO","durationType":"NONE","duration":0,"spType":"INCREASE_WITH_TIME","spCost":41,"initSp":12,"maxChargeTime":1,"increment":1,"bb":{"cost":12},"bbStr":{},"rangeId":null,"rangeGrid":null,"prefabId":"skcom_charge_cost","trigger":{"rule":"DEFAULT","rawRule":"DEFAULT","customRangeGrid":null},"index":2,"overrideTokenKey":null}],"talents":[{"index":0,"name":"攻击提升","desc":"攻击力+8%","descRaw":"攻击力+8%","bb":{"atk":0.08},"bbStr":{},"rangeGrid":null,"tokenKey":null,"hidden":false,"fromModule":false}],"displayTokens":[],"tokens":[]},"2/60/7/1":{"status":{"phase":2,"level":60,"skillLevel":7,"equipLevel":1},"stats":{"maxHp":1674,"atk":380,"def":322,"res":0,"cost":11,"blockCnt":2,"bat":1.05,"aspd":100,"respawnTime":70,"spRecovery":1,"hpRecoveryPerSec":0,"moveSpeed":1,"tauntLevel":0,"massLevel":0,"deployLimit":1,"deckStack":0},"immunities":{"stun":false,"silence":false,"sleep":false,"frozen":false,"levitate":false},"rangeId":"1-1","rangeGrid":[[0,0],[0,1]],"dmgType":"phys","attackKind":"melee","projectile":"none","canHitFly":false,"targetPriority":null,"trait":{"desc":"能够阻挡两个敌人","descRaw":"能够阻挡两个敌人","bb":{},"bbStr":{},"rangeGrid":null},"skills":[{"skillId":"skcom_charge_cost[1]","iconId":"skcom_charge_cost[1]","name":"冲锋号令·α型","level":7,"desc":"立即获得6点部署费用","descRaw":"立即获得<@ba.vup>6</>点部署费用","skillType":"AUTO","durationType":"NONE","duration":0,"spType":"INCREASE_WITH_TIME","spCost":25,"initSp":6,"maxChargeTime":1,"increment":1,"bb":{"cost":6},"bbStr":{},"rangeId":null,"rangeGrid":null,"prefabId":"skcom_charge_cost","trigger":{"rule":"DEFAULT","rawRule":"DEFAULT","customRangeGrid":null},"index":0,"overrideTokenKey":null},{"skillId":"skcom_charge_cost[2]","iconId":"skcom_charge_cost[2]","name":"冲锋号令·β型","level":7,"desc":"立即获得9点部署费用","descRaw":"立即获得<@ba.vup>9</>点部署费用","skillType":"AUTO","durationType":"NONE","duration":0,"spType":"INCREASE_WITH_TIME","spCost":33,"initSp":10,"maxChargeTime":1,"increment":1,"bb":{"cost":9},"bbStr":{},"rangeId":null,"rangeGrid":null,"prefabId":"skcom_charge_cost","trigger":{"rule":"DEFAULT","rawRule":"DEFAULT","customRangeGrid":null},"index":1,"overrideTokenKey":null},{"skillId":"skcom_charge_cost[3]","iconId":"skcom_charge_cost[3]","name":"冲锋号令·γ型","level":7,"desc":"立即获得12点部署费用","descRaw":"立即获得<@ba.vup>12</>点部署费用","skillType":"AUTO","durationType":"NONE","duration":0,"spType":"INCREASE_WILine truncated
+1 -1
View File
@@ -1 +1 @@
{"chess_char_1_01_a":{"chessId":"chess_char_1_01_a","baseId":"chess_char_1_01_a","goldenId":"chess_char_1_01_b","isGolden":false,"tier":1,"identifier":0,"isHidden":false,"isDiy":false,"visible":true,"chessType":"PRESET","backup":{"charId":"char_498_inside","tmplId":null,"skillIndex":1,"uniEquipId":"uniequip_002_inside","potRank":0},"shopSortId":1,"charId":"char_498_inside","name":"隐现","appellation":"Insider","rarity":5,"profession":"SNIPER","subProfessionId":"fastshot","subProfessionName":"速射手","position":"RANGED","nationId":"laterano","bonds":["lateranoShip","swiftShip"],"garrisonIds":["garrison_16_a"],"price":2,"sellPrice":1,"upgradeNum":3,"upgradeChessId":"chess_char_1_01_b","status":{"phase":1,"level":55,"skillLevel":4,"equipLevel":0},"stats":{"maxHp":1123,"atk":422,"def":140,"res":0,"cost":12,"blockCnt":1,"bat":1,"aspd":100,"respawnTime":70,"spRecovery":1,"hpRecoveryPerSec":0,"moveSpeed":1,"tauntLevel":0,"massLevel":0,"deployLimit":1,"deckStack":0},"immunities":{"stun":false,"silence":false,"sleep":false,"frozen":false,"levitate":false},"rangeId":"3-3","rangeGrid":[[1,0],[1,1],[1,2],[1,3],[0,0],[0,1],[0,2],[0,3],[-1,0],[-1,1],[-1,2],[-1,3]],"dmgType":"phys","attackKind":"ranged","projectile":"arrow","canHitFly":true,"targetPriority":"fly","trait":{"desc":"优先攻击空中单位","descRaw":"优先攻击空中单位","bb":{},"bbStr":{},"rangeGrid":null},"skill":{"skillId":"skchr_inside_2","iconId":"skchr_inside_2","name":"“解决麻烦”","level":4,"desc":"攻击力+80%,攻击间隔略微缩短,优先攻击使用远程武器的敌人并且不容易成为敌人的攻击目标\n攻击装有14发弹药,打完后结束(可随时停止技能)","descRaw":"攻击力<@ba.vup>+80%</>,攻击间隔<@ba.vup>略微缩短</>,优先攻击使用远程武器的敌人并且不容易成为敌人的攻击目标\n<@ba.rem>攻击装有14发弹药,打完后结束(可随时停止技能)</>","skillType":"MANUAL","durationType":"AMMO","duration":0,"spType":"INCREASE_WITH_TIME","spCost":24,"initSp":8,"maxChargeTime":1,"increment":1,"bb":{"atk":0.8,"base_attack_time":-0.3,"attack@trigger_time":14},"bbStr":{},"rangeId":null,"rangeGrid":null,"prefabId":"skchr_inside_2","trigger":{"rule":"DEFAULT","rawRule":"DEFAULT","customRangeGrid":null},"index":1,"overrideTokenKey":null},"talents":[{"index":0,"name":"火力支援","desc":"在场停留20秒后,自身弹药类技能弹药上限+2","descRaw":"在场停留20秒后,自身弹药类技能弹药上限+2","bb":{"self_ammo":2,"duration":20},"bbStr":{},"rangeGrid":null,"tokenKey":null,"hidden":false,"fromModule":false}],"tokens":[],"module":{"id":"uniequip_002_inside","name":"“最初的惊喜”","type":"MAR-X","level":0,"active":false},"assets":{"avatar":"char_498_inside","portrait":"char_498_inside_1","spine":"char_498_inside","skillIcon":"skchr_inside_2","subProfIcon":"sub_fastshot_icon"},"skills":[{"skillId":"skchr_inside_1","iconId":"skchr_inside_1","name":"“不惹麻烦”","level":4,"desc":"每次攻击造成相当于攻击力185%的物理伤害\n攻击装有4发弹药,打完后结束(可随时停止技能)","descRaw":"每次攻击造成相当于攻击力<@ba.vup>185%</>的物理伤害\n<@ba.rem>攻击装有4发弹药,打完后结束(可随时停止技能)</>","skillType":"AUTO","durationType":"AMMO","duration":0,"spType":"INCREASE_WHEN_ATTACK","spCost":14,"initSp":0,"maxChargeTime":1,"increment":1,"bb":{"attack@atk_scale":1.85,"attack@trigger_time":4},"bbStr":{},"rangeId":null,"rangeGrid":null,"prefabId":"skchr_inside_1","trigger":{"rule":"DEFAULT","rawRule":"DEFAULT","customRangeGrid":null},"index":0,"overrideTokenKey":null,"isDefault":false},{"skillId":"skchr_inside_2","iconId":"skchr_inside_2","name":"“解决麻烦”","level":4,"desc":"攻击力+80%,攻击间隔略微缩短,优先攻击使用远程武器的敌人并且不容易成为敌人的攻击目标\n攻击装有14发弹药,打完后结束(可随时停止技能)","descRaw":"攻击力<@ba.vup>+80%</>,攻击间隔<@ba.vup>略微缩短</>,优先攻击使用远程武器的敌人并且不容易成为敌人的攻击目标\n<@ba.rem>攻击装有14发弹药,打完后结束(可随时停止技能)</>","skillType":"MANUAL","durationType":"AMMO","duration":0,"spType":"INCREASE_WITH_TIME","spCost":24,"initSp":8,"maxChargeTime":1,"increment":1,"bb":{"atk":0.8,"base_attack_time":-0.3,"attack@trigger_time":14},"bbStr":{},"rangeId":null,"rangeGrid":null,"prefabId":"skchr_inside_2","trigger":{"rule":"DEFAULT","rawRule":"DEFAULT","customRangeGrid":null},"index":1,"overrideTokenKey":null,"isDefault":true}]},"chess_char_1_01_b":{"chessId":"chess_char_1_01_b","baseId":"chess_char_1_01_a","goldenId":"chess_char_1_01_b","isGolden":true,"tier":1,"identifier":133,"isHidden":false,"isDiy":false,"visible":true,"chessType":"PRESET","backup":{"charId":"char_498_inside","tmplId":null,"skillIndex":1,"uniEquipId":"uniequip_002_inside","potRank":0},"shopSortId":1,"charId":"char_498Line truncated
{"chess_char_1_01_a":{"chessId":"chess_char_1_01_a","baseId":"chess_char_1_01_a","goldenId":"chess_char_1_01_b","isGolden":false,"tier":1,"identifier":0,"isHidden":false,"isDiy":false,"visible":true,"chessType":"PRESET","backup":{"charId":"char_498_inside","tmplId":null,"skillIndex":1,"uniEquipId":"uniequip_002_inside","potRank":0},"shopSortId":1,"charId":"char_498_inside","name":"隐现","appellation":"Insider","rarity":5,"profession":"SNIPER","subProfessionId":"fastshot","subProfessionName":"速射手","position":"RANGED","nationId":"laterano","bonds":["lateranoShip","swiftShip"],"garrisonIds":["garrison_16_a"],"price":2,"sellPrice":1,"upgradeNum":3,"upgradeChessId":"chess_char_1_01_b","status":{"phase":1,"level":55,"skillLevel":4,"equipLevel":0},"stats":{"maxHp":1123,"atk":422,"def":140,"res":0,"cost":12,"blockCnt":1,"bat":1,"aspd":100,"respawnTime":70,"spRecovery":1,"hpRecoveryPerSec":0,"moveSpeed":1,"tauntLevel":0,"massLevel":0,"deployLimit":1,"deckStack":0},"immunities":{"stun":false,"silence":false,"sleep":false,"frozen":false,"levitate":false},"rangeId":"3-3","rangeGrid":[[1,0],[1,1],[1,2],[1,3],[0,0],[0,1],[0,2],[0,3],[-1,0],[-1,1],[-1,2],[-1,3]],"dmgType":"phys","attackKind":"ranged","projectile":"arrow","canHitFly":true,"targetPriority":"fly","trait":{"desc":"优先攻击空中单位","descRaw":"优先攻击空中单位","bb":{},"bbStr":{},"rangeGrid":null},"skill":{"skillId":"skchr_inside_2","iconId":"skchr_inside_2","name":"“解决麻烦”","level":4,"desc":"攻击力+80%,攻击间隔略微缩短,优先攻击使用远程武器的敌人并且不容易成为敌人的攻击目标\n攻击装有14发弹药,打完后结束(可随时停止技能)","descRaw":"攻击力<@ba.vup>+80%</>,攻击间隔<@ba.vup>略微缩短</>,优先攻击使用远程武器的敌人并且不容易成为敌人的攻击目标\n<@ba.rem>攻击装有14发弹药,打完后结束(可随时停止技能)</>","skillType":"MANUAL","durationType":"AMMO","duration":0,"spType":"INCREASE_WITH_TIME","spCost":24,"initSp":8,"maxChargeTime":1,"increment":1,"bb":{"atk":0.8,"base_attack_time":-0.3,"attack@trigger_time":14},"bbStr":{},"rangeId":null,"rangeGrid":null,"prefabId":"skchr_inside_2","trigger":{"rule":"DEFAULT","rawRule":"DEFAULT","customRangeGrid":null},"index":1,"overrideTokenKey":null},"talents":[{"index":0,"name":"火力支援","desc":"在场停留20秒后,自身弹药类技能弹药上限+2","descRaw":"在场停留20秒后,自身弹药类技能弹药上限+2","bb":{"self_ammo":2,"duration":20},"bbStr":{},"rangeGrid":null,"tokenKey":null,"hidden":false,"fromModule":false}],"tokens":[],"module":{"id":"uniequip_002_inside","name":"“最初的惊喜”","type":"MAR-X","level":0,"active":false},"assets":{"avatar":"char_498_inside","portrait":"char_498_inside_1","spine":"char_498_inside","skillIcon":"skchr_inside_2","subProfIcon":"sub_fastshot_icon"},"skills":[{"skillId":"skchr_inside_1","iconId":"skchr_inside_1","name":"“不惹麻烦”","level":4,"desc":"每次攻击造成相当于攻击力185%的物理伤害\n攻击装有4发弹药,打完后结束(可随时停止技能)","descRaw":"每次攻击造成相当于攻击力<@ba.vup>185%</>的物理伤害\n<@ba.rem>攻击装有4发弹药,打完后结束(可随时停止技能)</>","skillType":"AUTO","durationType":"AMMO","duration":0,"spType":"INCREASE_WHEN_ATTACK","spCost":14,"initSp":0,"maxChargeTime":1,"increment":1,"bb":{"attack@atk_scale":1.85,"attack@trigger_time":4},"bbStr":{},"rangeId":null,"rangeGrid":null,"prefabId":"skchr_inside_1","trigger":{"rule":"DEFAULT","rawRule":"DEFAULT","customRangeGrid":null},"index":0,"overrideTokenKey":null,"isDefault":false},{"skillId":"skchr_inside_2","iconId":"skchr_inside_2","name":"“解决麻烦”","level":4,"desc":"攻击力+80%,攻击间隔略微缩短,优先攻击使用远程武器的敌人并且不容易成为敌人的攻击目标\n攻击装有14发弹药,打完后结束(可随时停止技能)","descRaw":"攻击力<@ba.vup>+80%</>,攻击间隔<@ba.vup>略微缩短</>,优先攻击使用远程武器的敌人并且不容易成为敌人的攻击目标\n<@ba.rem>攻击装有14发弹药,打完后结束(可随时停止技能)</>","skillType":"MANUAL","durationType":"AMMO","duration":0,"spType":"INCREASE_WITH_TIME","spCost":24,"initSp":8,"maxChargeTime":1,"increment":1,"bb":{"atk":0.8,"base_attack_time":-0.3,"attack@trigger_time":14},"bbStr":{},"rangeId":null,"rangeGrid":null,"prefabId":"skchr_inside_2","trigger":{"rule":"DEFAULT","rawRule":"DEFAULT","customRangeGrid":null},"index":1,"overrideTokenKey":null,"isDefault":true}],"potDown":{"0":{"stats.cost":15},"1":{"stats.respawnTime":80},"2":{"stats.cost":14},"3":{"stats.atk":399},"4":{"stats.cost":13}}},"chess_char_1_01_b":{"chessId":"chess_char_1_01_b","baseId":"chess_char_1_01_a","goldenId":"chess_char_1_01_b","isGolden":true,"tier":1,"identifier":133,"isHidden":false,"isDiy":false,"visible":true,"chessType":"PRESET","backup":{"charId"Line truncated
+1 -1
View File
@@ -1 +1 @@
{"version":1,"lang":"en","meta":{"generator":"tools/build-i18n.mjs","source":{"id":"assets","label":"ArknightsAssets/ArknightsGamedata (en)","home":"https://github.com/ArknightsAssets/ArknightsGamedata","season":"act2autochess"},"fallback":"tools/i18n/fallback-remake.json (the remake), tools/i18n/fallback-pr70.json (PR #70 by @YuriRestia), public/i18n/en.json","coverage":{"bands":{"texts":186,"translated":186,"official":186,"pct":100},"bonds":{"texts":138,"translated":138,"official":138,"pct":100},"bosses":{"texts":66,"translated":66,"official":66,"pct":100},"choices":{"texts":679,"translated":679,"official":679,"pct":100},"config":{"texts":110,"translated":110,"official":103,"pct":100},"effects":{"texts":1083,"translated":1083,"official":1083,"pct":100},"emotes":{"texts":41,"translated":41,"official":3,"pct":100},"enemies":{"texts":1734,"translated":1734,"official":1734,"pct":100},"factions":{"texts":14,"translated":14,"official":14,"pct":100},"garrisons":{"texts":747,"translated":747,"official":747,"pct":100},"items":{"texts":582,"translated":582,"official":570,"pct":100},"modules":{"texts":2482,"translated":2437,"official":2419,"pct":98.2},"operators":{"texts":873,"translated":851,"official":843,"pct":97.5},"skills":{"texts":5646,"translated":5439,"official":5439,"pct":96.3},"stages":{"texts":1124,"translated":1124,"official":1122,"pct":100},"talents":{"texts":4297,"translated":4099,"official":4099,"pct":95.4},"tokens":{"texts":442,"translated":442,"official":441,"pct":100},"traits":{"texts":1870,"translated":1854,"official":1854,"pct":99.1}},"totals":{"texts":22114,"translated":21626,"official":21540,"pct":97.8}},"names":{"\"炎佑\"":"Yan's Protection","Dor-1号失败品":"Dor-1 Failure","Dor-α号失败品":"Dor-α Failure","Dor-β号复制体":"Dor-β Replica","M3茧甲":"M3 Cocoon Shell","W·悬赏":"W/Bounty","“变形者集群”":"'Damazti Cluster'","“墓碑”":"'Gravestone'","“墓碑”·悬赏":"'Gravestone'/Bounty","“复仇者”":"'Hateful Avenger'","“复仇者”·悬赏":"'Hateful Avenger'/Bounty","“小自在”":"'Freeling'","“巨大的丑东西”":"'The Big Ugly Thing'","“巨大的丑东西”·悬赏":"'The Big Ugly Thing'/Bounty","“帝国的甲胄”":"'Armor of the Empire'","“庞贝”":"'Pompeii'","“庞贝”·悬赏":"'Pompeii'/Bounty","“斩胄之剑”":"'Armor Cleaver'","“灵幛”":"Sudaram","“独轮车玩具”":"'Unicycle Toy'","“破胄之锤”":"'Armor Crusher'","“碎铳之簧”":"'Gunshatter'","“神秘顾客”":"'Mystery Customer'","“耀阳”":"'Blazing Sun'","“自在”":"'Free'","“自在”·悬赏":"'Free'/Bounty","“萨科塔之眼”":"'Eyes of the Sankta'","“萨科塔之眼”·悬赏":"'Eyes of the Sankta'/Bounty","“萨科塔之翼”":"'Wings of the Sankta'","“萨科塔昂首”":"'Pride of the Sankta'","“萨米的意志”":"'Samivilinn,' the Resolution of the Very North","“裂管之音”/“断弦之音”":"'Echoes of Cracked Pipe'/'Echoes of Broken Strings'","“越长尘”":"'Sand Voyager'","“遗弃者”":"'Forsaken One'","“遗弃者”·悬赏":"'Forsaken One'/Bounty","“邪魔的利刃”":"'Demon's Blade'","“邪魔的利刃”·悬赏":"'Demon's Blade'/Bounty","“鼠王”·悬赏":"'Rat King'/Bounty","不屈":"Resilient","不屈弹射器":"Tough Launcher","不祥幻影":"Ominous Apparition","不稳定要素":"Unstable Element","业余竞演者":"Amateur Performer","业务指标":"Work Quota","临时收音师":"Audio Technician","主角阵营角色":"Hero's Sidekick","乌尔比安":"Ulpianus","乌顶巨角卢鲁":"Blackhorn Rhuul","九流之缘":"Streetwise Ties","人事部文档":"HR File","人才盲盒":"Talent Blind Box","仇白":"Qiubai","从不混淆的方向":"Undeviating Course","令":"Ling","以己之长":"Personal Power","伊内丝":"Ines","伊利昂的木驮兽":"Wooden Burdenbeast of Ilion","伊奥莱塔·罗素":"Ioleta Russell","伊芙利特":"Ifrit","休眠子裔":"Dormant Heir","休谟斯":"Humus","休露丝":"Sciurus","众志合一":"Wills As One","优等生":"Top Student","传奇重犯":"Infamous Recidivist","伪装服":"Disguise Outfit","伺夜":"Vigil","余":"Yu","佩佩":"Pepe","俗心":"Vernacularism","保护目标(冻结状态)":"Protection Objective (Frozen)","信仰搅拌机":"Sankta Miksaparato","信标":"Transmitter","假想敌:再生":"OpFor: Rebirth","假想敌:弦":"OpFor: String","假想敌:淤困":"OpFor: Quagmire","假想敌:管":"OpFor: Pipe","假想敌:胄":"OpFor: Armor","假想敌:蚀裂":"OpFor: Fissure","假想敌:铳":"OpFor: Gun","假想敌:镜膜":"OpFor: Mirror","假想敌:骨刺":"OpFor: Bonespur","假想敌:黑云":"OpFor: BlackCloud","假日威龙陈":"Ch'en the Holungday","傀影":"Phantom","催泪瓦斯":"Teargas Can","元核孽生者":"Nucleic Maleficent","元核孽生者·悬赏":"Nucleic Maleficient/Bounty","克莱门莎":"Clementia","冒失的小弟":"Careless Underling","军用猎狗":"Military Hound","冰爆源石虫":Line truncated
{"version":1,"lang":"en","meta":{"generator":"tools/build-i18n.mjs","source":{"id":"assets","label":"ArknightsAssets/ArknightsGamedata (en)","home":"https://github.com/ArknightsAssets/ArknightsGamedata","season":"act2autochess"},"fallback":"tools/i18n/fallback-remake.json (the remake), tools/i18n/fallback-pr70.json (PR #70 by @YuriRestia), public/i18n/en.json","coverage":{"bands":{"texts":186,"translated":186,"official":186,"pct":100},"bonds":{"texts":138,"translated":138,"official":138,"pct":100},"bosses":{"texts":66,"translated":66,"official":66,"pct":100},"choices":{"texts":679,"translated":679,"official":679,"pct":100},"config":{"texts":110,"translated":110,"official":103,"pct":100},"effects":{"texts":1083,"translated":1083,"official":1083,"pct":100},"emotes":{"texts":41,"translated":41,"official":3,"pct":100},"enemies":{"texts":1734,"translated":1734,"official":1734,"pct":100},"factions":{"texts":14,"translated":14,"official":14,"pct":100},"garrisons":{"texts":747,"translated":747,"official":747,"pct":100},"items":{"texts":582,"translated":582,"official":570,"pct":100},"modules":{"texts":2482,"translated":2437,"official":2419,"pct":98.2},"operators":{"texts":873,"translated":851,"official":843,"pct":97.5},"skills":{"texts":5646,"translated":5439,"official":5439,"pct":96.3},"stages":{"texts":1124,"translated":1124,"official":1122,"pct":100},"talents":{"texts":5963,"translated":5687,"official":5679,"pct":95.4},"tokens":{"texts":456,"translated":456,"official":455,"pct":100},"traits":{"texts":1870,"translated":1854,"official":1854,"pct":99.1}},"totals":{"texts":23794,"translated":23228,"official":23134,"pct":97.6}},"names":{"\"炎佑\"":"Yan's Protection","Dor-1号失败品":"Dor-1 Failure","Dor-α号失败品":"Dor-α Failure","Dor-β号复制体":"Dor-β Replica","M3茧甲":"M3 Cocoon Shell","W·悬赏":"W/Bounty","“变形者集群”":"'Damazti Cluster'","“墓碑”":"'Gravestone'","“墓碑”·悬赏":"'Gravestone'/Bounty","“复仇者”":"'Hateful Avenger'","“复仇者”·悬赏":"'Hateful Avenger'/Bounty","“小自在”":"'Freeling'","“巨大的丑东西”":"'The Big Ugly Thing'","“巨大的丑东西”·悬赏":"'The Big Ugly Thing'/Bounty","“帝国的甲胄”":"'Armor of the Empire'","“庞贝”":"'Pompeii'","“庞贝”·悬赏":"'Pompeii'/Bounty","“斩胄之剑”":"'Armor Cleaver'","“灵幛”":"Sudaram","“独轮车玩具”":"'Unicycle Toy'","“破胄之锤”":"'Armor Crusher'","“碎铳之簧”":"'Gunshatter'","“神秘顾客”":"'Mystery Customer'","“耀阳”":"'Blazing Sun'","“自在”":"'Free'","“自在”·悬赏":"'Free'/Bounty","“萨科塔之眼”":"'Eyes of the Sankta'","“萨科塔之眼”·悬赏":"'Eyes of the Sankta'/Bounty","“萨科塔之翼”":"'Wings of the Sankta'","“萨科塔昂首”":"'Pride of the Sankta'","“萨米的意志”":"'Samivilinn,' the Resolution of the Very North","“裂管之音”/“断弦之音”":"'Echoes of Cracked Pipe'/'Echoes of Broken Strings'","“越长尘”":"'Sand Voyager'","“遗弃者”":"'Forsaken One'","“遗弃者”·悬赏":"'Forsaken One'/Bounty","“邪魔的利刃”":"'Demon's Blade'","“邪魔的利刃”·悬赏":"'Demon's Blade'/Bounty","“鼠王”·悬赏":"'Rat King'/Bounty","不屈":"Resilient","不屈弹射器":"Tough Launcher","不祥幻影":"Ominous Apparition","不稳定要素":"Unstable Element","业余竞演者":"Amateur Performer","业务指标":"Work Quota","临时收音师":"Audio Technician","主角阵营角色":"Hero's Sidekick","乌尔比安":"Ulpianus","乌顶巨角卢鲁":"Blackhorn Rhuul","九流之缘":"Streetwise Ties","人事部文档":"HR File","人才盲盒":"Talent Blind Box","仇白":"Qiubai","从不混淆的方向":"Undeviating Course","令":"Ling","以己之长":"Personal Power","伊内丝":"Ines","伊利昂的木驮兽":"Wooden Burdenbeast of Ilion","伊奥莱塔·罗素":"Ioleta Russell","伊芙利特":"Ifrit","休眠子裔":"Dormant Heir","休谟斯":"Humus","休露丝":"Sciurus","众志合一":"Wills As One","优等生":"Top Student","传奇重犯":"Infamous Recidivist","伪装服":"Disguise Outfit","伺夜":"Vigil","余":"Yu","佩佩":"Pepe","俗心":"Vernacularism","保护目标(冻结状态)":"Protection Objective (Frozen)","信仰搅拌机":"Sankta Miksaparato","信标":"Transmitter","假想敌:再生":"OpFor: Rebirth","假想敌:弦":"OpFor: String","假想敌:淤困":"OpFor: Quagmire","假想敌:管":"OpFor: Pipe","假想敌:胄":"OpFor: Armor","假想敌:蚀裂":"OpFor: Fissure","假想敌:铳":"OpFor: Gun","假想敌:镜膜":"OpFor: Mirror","假想敌:骨刺":"OpFor: Bonespur","假想敌:黑云":"OpFor: BlackCloud","假日威龙陈":"Ch'en the Holungday","傀影":"Phantom","催泪瓦斯":"Teargas Can","元核孽生者":"Nucleic Maleficent","元核孽生者·悬赏":"Nucleic Maleficient/Bounty","克莱门莎":"Clementia","冒失的小弟":"Careless Underling","军用猎狗":"Military Hound","冰爆源石虫":Line truncated
+1 -1
View File
@@ -1 +1 @@
{"version":1,"lang":"ja","meta":{"generator":"tools/build-i18n.mjs","source":{"id":"assets","label":"ArknightsAssets/ArknightsGamedata (jp)","home":"https://github.com/ArknightsAssets/ArknightsGamedata","season":"act2autochess"},"fallback":"public/i18n/ja.json (the pack's UI strings)","coverage":{"bands":{"texts":186,"translated":186,"official":186,"pct":100},"bonds":{"texts":138,"translated":138,"official":138,"pct":100},"bosses":{"texts":66,"translated":66,"official":66,"pct":100},"choices":{"texts":679,"translated":679,"official":679,"pct":100},"config":{"texts":110,"translated":103,"official":103,"pct":93.6},"effects":{"texts":1083,"translated":1083,"official":1083,"pct":100},"emotes":{"texts":41,"translated":41,"official":3,"pct":100},"enemies":{"texts":1734,"translated":1734,"official":1734,"pct":100},"factions":{"texts":14,"translated":14,"official":14,"pct":100},"garrisons":{"texts":747,"translated":747,"official":747,"pct":100},"items":{"texts":582,"translated":570,"official":570,"pct":97.9},"modules":{"texts":2482,"translated":2419,"official":2419,"pct":97.5},"operators":{"texts":873,"translated":843,"official":843,"pct":96.6},"skills":{"texts":5646,"translated":5439,"official":5439,"pct":96.3},"stages":{"texts":1124,"translated":1111,"official":1111,"pct":98.8},"talents":{"texts":4297,"translated":4095,"official":4095,"pct":95.3},"tokens":{"texts":442,"translated":441,"official":441,"pct":99.8},"traits":{"texts":1870,"translated":1854,"official":1854,"pct":99.1}},"totals":{"texts":22114,"translated":21563,"official":21525,"pct":97.5}},"names":{"\"炎佑\"":"「炎祐」","Dor-1号失败品":"Dor-1号失敗作","Dor-α号失败品":"Dor-α号失敗作","Dor-β号复制体":"Dor-β号複製体","M3茧甲":"M3コクーンアーマー","W·悬赏":"懸賞・W","“变形者集群”":"「変形者」","“墓碑”":"「墓碑」","“墓碑”·悬赏":"懸賞・「墓碑」","“复仇者”":"Vendetta","“复仇者”·悬赏":"懸賞・「ヴェンデッタ」","“小自在”":"「小自在」","“巨大的丑东西”":"「ビッグアグリー」","“巨大的丑东西”·悬赏":"懸賞・「ビッグアグリー」","“帝国的甲胄”":"「帝国の甲冑」","“庞贝”":"「ポンペイ」","“庞贝”·悬赏":"懸賞・Pompeii","“斩胄之剑”":"「甲冑断つ剣」","“灵幛”":"「霊骸布」","“独轮车玩具”":"「一輪車のおもちゃ」","“破胄之锤”":"「甲冑割る槌」","“碎铳之簧”":"「機銃砕く輪」","“神秘顾客”":"「謎の客人」","“耀阳”":"「ブレイジング・サン」","“自在”":"「ジザイ」","“自在”·悬赏":"懸賞・「ジザイ」","“萨科塔之眼”":"「サンクタの眼」","“萨科塔之眼”·悬赏":"懸賞・「サンクタの眼」","“萨科塔之翼”":"「サンクタの翼」","“萨科塔昂首”":"「サンクタの仰け反り」","“萨米的意志”":"「サーミの意志」","“裂管之音”/“断弦之音”":"「断絃の音」/「裂管の音」","“越长尘”":"「長塵」","“遗弃者”":"「見棄てし者」","“遗弃者”·悬赏":"懸賞・「見棄てし者」","“邪魔的利刃”":"「悪魔の利刃」","“邪魔的利刃”·悬赏":"懸賞・「悪魔の利刃」","“鼠王”·悬赏":"懸賞・「鼠王」","不屈":"不屈","不屈弹射器":"不屈の射出装置","不祥幻影":"不祥の幻影","不稳定要素":"不安定要素","业余竞演者":"一般参加の競演者","业务指标":"業務ノルマ","临时收音师":"臨時録音技師","主角阵营角色":"主役側の脇役","乌尔比安":"ウルピアヌス","乌顶巨角卢鲁":"黒き大角のルル","九流之缘":"人たらし","人事部文档":"人事部の書類","人才盲盒":"人材抽選","仇白":"チューバイ","从不混淆的方向":"混同せぬ方向","令":"リィン","以己之长":"持ち味を活かして","伊内丝":"イネス","伊利昂的木驮兽":"イリオスの木駄獣","伊奥莱塔·罗素":"イオレッタ・ラッセル","伊芙利特":"イフリータ","休眠子裔":"眠れる眷属","休谟斯":"ヒューマス","休露丝":"スキウース","众志合一":"一致団結","优等生":"優等生","传奇重犯":"伝説の凶悪犯","伪装服":"迷彩ヘルメット","伺夜":"ヴィジェル","余":"ユー","佩佩":"ペペ","俗心":"俗心","保护目标(冻结状态)":"防衛ライン(凍結状態)","信仰搅拌机":"サンクタ・ミキサー","信标":"ビーコン","假想敌:再生":"仮想敵:再生","假想敌:弦":"仮想敵:絃","假想敌:淤困":"仮想敵:泥濘","假想敌:管":"仮想敵:管","假想敌:胄":"仮想敵:冑","假想敌:蚀裂":"仮想敵:蝕裂","假想敌:铳":"仮想敵:銃","假想敌:镜膜":"仮想敵:鏡膜","假想敌:骨刺":"仮想敵:骨棘","假想敌:黑云":"仮想敵:黒雲","假日威龙陈":"遊龍チェン","傀影":"Line truncated
{"version":1,"lang":"ja","meta":{"generator":"tools/build-i18n.mjs","source":{"id":"assets","label":"ArknightsAssets/ArknightsGamedata (jp)","home":"https://github.com/ArknightsAssets/ArknightsGamedata","season":"act2autochess"},"fallback":"public/i18n/ja.json (the pack's UI strings)","coverage":{"bands":{"texts":186,"translated":186,"official":186,"pct":100},"bonds":{"texts":138,"translated":138,"official":138,"pct":100},"bosses":{"texts":66,"translated":66,"official":66,"pct":100},"choices":{"texts":679,"translated":679,"official":679,"pct":100},"config":{"texts":110,"translated":103,"official":103,"pct":93.6},"effects":{"texts":1083,"translated":1083,"official":1083,"pct":100},"emotes":{"texts":41,"translated":41,"official":3,"pct":100},"enemies":{"texts":1734,"translated":1734,"official":1734,"pct":100},"factions":{"texts":14,"translated":14,"official":14,"pct":100},"garrisons":{"texts":747,"translated":747,"official":747,"pct":100},"items":{"texts":582,"translated":570,"official":570,"pct":97.9},"modules":{"texts":2482,"translated":2419,"official":2419,"pct":97.5},"operators":{"texts":873,"translated":843,"official":843,"pct":96.6},"skills":{"texts":5646,"translated":5439,"official":5439,"pct":96.3},"stages":{"texts":1124,"translated":1111,"official":1111,"pct":98.8},"talents":{"texts":5963,"translated":5675,"official":5675,"pct":95.2},"tokens":{"texts":456,"translated":455,"official":455,"pct":99.8},"traits":{"texts":1870,"translated":1854,"official":1854,"pct":99.1}},"totals":{"texts":23794,"translated":23157,"official":23119,"pct":97.3}},"names":{"\"炎佑\"":"「炎祐」","Dor-1号失败品":"Dor-1号失敗作","Dor-α号失败品":"Dor-α号失敗作","Dor-β号复制体":"Dor-β号複製体","M3茧甲":"M3コクーンアーマー","W·悬赏":"懸賞・W","“变形者集群”":"「変形者」","“墓碑”":"「墓碑」","“墓碑”·悬赏":"懸賞・「墓碑」","“复仇者”":"Vendetta","“复仇者”·悬赏":"懸賞・「ヴェンデッタ」","“小自在”":"「小自在」","“巨大的丑东西”":"「ビッグアグリー」","“巨大的丑东西”·悬赏":"懸賞・「ビッグアグリー」","“帝国的甲胄”":"「帝国の甲冑」","“庞贝”":"「ポンペイ」","“庞贝”·悬赏":"懸賞・Pompeii","“斩胄之剑”":"「甲冑断つ剣」","“灵幛”":"「霊骸布」","“独轮车玩具”":"「一輪車のおもちゃ」","“破胄之锤”":"「甲冑割る槌」","“碎铳之簧”":"「機銃砕く輪」","“神秘顾客”":"「謎の客人」","“耀阳”":"「ブレイジング・サン」","“自在”":"「ジザイ」","“自在”·悬赏":"懸賞・「ジザイ」","“萨科塔之眼”":"「サンクタの眼」","“萨科塔之眼”·悬赏":"懸賞・「サンクタの眼」","“萨科塔之翼”":"「サンクタの翼」","“萨科塔昂首”":"「サンクタの仰け反り」","“萨米的意志”":"「サーミの意志」","“裂管之音”/“断弦之音”":"「断絃の音」/「裂管の音」","“越长尘”":"「長塵」","“遗弃者”":"「見棄てし者」","“遗弃者”·悬赏":"懸賞・「見棄てし者」","“邪魔的利刃”":"「悪魔の利刃」","“邪魔的利刃”·悬赏":"懸賞・「悪魔の利刃」","“鼠王”·悬赏":"懸賞・「鼠王」","不屈":"不屈","不屈弹射器":"不屈の射出装置","不祥幻影":"不祥の幻影","不稳定要素":"不安定要素","业余竞演者":"一般参加の競演者","业务指标":"業務ノルマ","临时收音师":"臨時録音技師","主角阵营角色":"主役側の脇役","乌尔比安":"ウルピアヌス","乌顶巨角卢鲁":"黒き大角のルル","九流之缘":"人たらし","人事部文档":"人事部の書類","人才盲盒":"人材抽選","仇白":"チューバイ","从不混淆的方向":"混同せぬ方向","令":"リィン","以己之长":"持ち味を活かして","伊内丝":"イネス","伊利昂的木驮兽":"イリオスの木駄獣","伊奥莱塔·罗素":"イオレッタ・ラッセル","伊芙利特":"イフリータ","休眠子裔":"眠れる眷属","休谟斯":"ヒューマス","休露丝":"スキウース","众志合一":"一致団結","优等生":"優等生","传奇重犯":"伝説の凶悪犯","伪装服":"迷彩ヘルメット","伺夜":"ヴィジェル","余":"ユー","佩佩":"ペペ","俗心":"俗心","保护目标(冻结状态)":"防衛ライン(凍結状態)","信仰搅拌机":"サンクタ・ミキサー","信标":"ビーコン","假想敌:再生":"仮想敵:再生","假想敌:弦":"仮想敵:絃","假想敌:淤困":"仮想敵:泥濘","假想敌:管":"仮想敵:管","假想敌:胄":"仮想敵:冑","假想敌:蚀裂":"仮想敵:蝕裂","假想敌:铳":"仮想敵:銃","假想敌:镜膜":"仮想敵:鏡膜","假想敌:骨刺":"仮想敵:骨棘","假想敌:黑云":"仮想敵:黒雲","假日威龙陈":"遊龍チェン","傀影":"Line truncated
+1 -1
View File
@@ -1 +1 @@
{"version":1,"lang":"ko","meta":{"generator":"tools/build-i18n.mjs","source":{"id":"assets","label":"ArknightsAssets/ArknightsGamedata (kr)","home":"https://github.com/ArknightsAssets/ArknightsGamedata","season":"act2autochess"},"fallback":"public/i18n/ko.json (the pack's UI strings)","coverage":{"bands":{"texts":186,"translated":186,"official":186,"pct":100},"bonds":{"texts":138,"translated":138,"official":138,"pct":100},"bosses":{"texts":66,"translated":66,"official":66,"pct":100},"choices":{"texts":679,"translated":679,"official":679,"pct":100},"config":{"texts":110,"translated":103,"official":103,"pct":93.6},"effects":{"texts":1083,"translated":1083,"official":1083,"pct":100},"emotes":{"texts":41,"translated":41,"official":3,"pct":100},"enemies":{"texts":1734,"translated":1734,"official":1734,"pct":100},"factions":{"texts":14,"translated":14,"official":14,"pct":100},"garrisons":{"texts":747,"translated":747,"official":747,"pct":100},"items":{"texts":582,"translated":570,"official":570,"pct":97.9},"modules":{"texts":2482,"translated":2419,"official":2419,"pct":97.5},"operators":{"texts":873,"translated":843,"official":843,"pct":96.6},"skills":{"texts":5646,"translated":5439,"official":5439,"pct":96.3},"stages":{"texts":1124,"translated":1111,"official":1111,"pct":98.8},"talents":{"texts":4297,"translated":4099,"official":4099,"pct":95.4},"tokens":{"texts":442,"translated":441,"official":441,"pct":99.8},"traits":{"texts":1870,"translated":1854,"official":1854,"pct":99.1}},"totals":{"texts":22114,"translated":21567,"official":21529,"pct":97.5}},"names":{"\"炎佑\"":"'염의 가호'","Dor-1号失败品":"Dor-1호 실패작","Dor-α号失败品":"Dor-α호 실패작","Dor-β号复制体":"Dor-β호 복제품","M3茧甲":"M3 고치 갑옷","W·悬赏":"W - 수배","“变形者集群”":"'다마즈티'","“墓碑”":"'툼스톤'","“墓碑”·悬赏":"'툼스톤' - 수배","“复仇者”":"'어벤져'","“复仇者”·悬赏":"'어벤져' - 수배","“小自在”":"'작은 즈자이'","“巨大的丑东西”":"'거대하고 못생긴 것'","“巨大的丑东西”·悬赏":"'거대하고 못생긴 것' - 수배","“帝国的甲胄”":"'제국의 갑옷'","“庞贝”":"'폼페이'","“庞贝”·悬赏":"'폼페이' - 수배","“斩胄之剑”":"'갑주를 베는 검'","“灵幛”":"'영장'","“独轮车玩具”":"'완구 외발자전거'","“破胄之锤”":"'갑주를 부수는 해머'","“碎铳之簧”":"'총을 부수는 리드'","“神秘顾客”":"'신비한 고객'","“耀阳”":"'눈 부신 태양'","“自在”":"'즈자이'","“自在”·悬赏":"'즈자이' - 수배","“萨科塔之眼”":"'산크타의 눈'","“萨科塔之眼”·悬赏":"'산크타의 눈' - 수배","“萨科塔之翼”":"'산크타의 날개'","“萨科塔昂首”":"'산크타의 긍지'","“萨米的意志”":"'사미의 의지'","“裂管之音”/“断弦之音”":"'단현지음'/'열관지음'","“越长尘”":"'월장진'","“遗弃者”":"'버림받은 자'","“遗弃者”·悬赏":"'버림받은 자' - 수배","“邪魔的利刃”":"'데몬의 칼날'","“邪魔的利刃”·悬赏":"'데몬의 칼날' - 수배","“鼠王”·悬赏":"'래트킹' - 수배","不屈":"불굴","不屈弹射器":"불굴의 사출기","不祥幻影":"불길한 환영","不稳定要素":"불안요소","业余竞演者":"아마추어 경연자","业务指标":"업무 지표","临时收音师":"임시 음향기사","主角阵营角色":"주인공 진영 캐릭터","乌尔比安":"울피아누스","乌顶巨角卢鲁":"검은 뿔의 루울","九流之缘":"구류의 인연","人事部文档":"인사부 파일","人才盲盒":"인재 랜덤박스","仇白":"치우바이","从不混淆的方向":"헷갈리지 않는 방향","令":"링","以己之长":"장점 활용","伊内丝":"이네스","伊利昂的木驮兽":"일리온의 나무 버든비스트","伊奥莱塔·罗素":"이올레타 로시","伊芙利特":"이프리트","休眠子裔":"잠자는 후예","休谟斯":"휴머스","休露丝":"시우루스","众志合一":"일치단결","优等生":"우등생","传奇重犯":"전설의 중범죄수","伪装服":"위장복","伺夜":"비질","余":"위","佩佩":"페페","俗心":"수신","保护目标(冻结状态)":"방어 포인트 (빙결 상태)","信仰搅拌机":"산크타 믹사파라토","信标":"비콘","假想敌:再生":"가상적: 재생","假想敌:弦":"가상적: 현","假想敌:淤困":"가상적: 정체","假想敌:管":"가상적: 관","假想敌:胄":"가상적: 갑주","假想敌:蚀裂":"가상적: 침식","假想敌:铳":"가상적: 총","假想敌:镜膜":"가상적: 경막","假想敌:骨刺":"가상적: 골극","假想敌:黑云":"가상적: 흑운","假日威龙陈":"첸 더 홀룽데이","傀影":"팬텀","催泪瓦斯":"최루가스","元核孽生者":"원핵의 악생자","元核孽生者·悬赏":"원핵의 악생자 - 수배","克莱Line truncated
{"version":1,"lang":"ko","meta":{"generator":"tools/build-i18n.mjs","source":{"id":"assets","label":"ArknightsAssets/ArknightsGamedata (kr)","home":"https://github.com/ArknightsAssets/ArknightsGamedata","season":"act2autochess"},"fallback":"public/i18n/ko.json (the pack's UI strings)","coverage":{"bands":{"texts":186,"translated":186,"official":186,"pct":100},"bonds":{"texts":138,"translated":138,"official":138,"pct":100},"bosses":{"texts":66,"translated":66,"official":66,"pct":100},"choices":{"texts":679,"translated":679,"official":679,"pct":100},"config":{"texts":110,"translated":103,"official":103,"pct":93.6},"effects":{"texts":1083,"translated":1083,"official":1083,"pct":100},"emotes":{"texts":41,"translated":41,"official":3,"pct":100},"enemies":{"texts":1734,"translated":1734,"official":1734,"pct":100},"factions":{"texts":14,"translated":14,"official":14,"pct":100},"garrisons":{"texts":747,"translated":747,"official":747,"pct":100},"items":{"texts":582,"translated":570,"official":570,"pct":97.9},"modules":{"texts":2482,"translated":2419,"official":2419,"pct":97.5},"operators":{"texts":873,"translated":843,"official":843,"pct":96.6},"skills":{"texts":5646,"translated":5439,"official":5439,"pct":96.3},"stages":{"texts":1124,"translated":1111,"official":1111,"pct":98.8},"talents":{"texts":5963,"translated":5679,"official":5679,"pct":95.2},"tokens":{"texts":456,"translated":455,"official":455,"pct":99.8},"traits":{"texts":1870,"translated":1854,"official":1854,"pct":99.1}},"totals":{"texts":23794,"translated":23161,"official":23123,"pct":97.3}},"names":{"\"炎佑\"":"'염의 가호'","Dor-1号失败品":"Dor-1호 실패작","Dor-α号失败品":"Dor-α호 실패작","Dor-β号复制体":"Dor-β호 복제품","M3茧甲":"M3 고치 갑옷","W·悬赏":"W - 수배","“变形者集群”":"'다마즈티'","“墓碑”":"'툼스톤'","“墓碑”·悬赏":"'툼스톤' - 수배","“复仇者”":"'어벤져'","“复仇者”·悬赏":"'어벤져' - 수배","“小自在”":"'작은 즈자이'","“巨大的丑东西”":"'거대하고 못생긴 것'","“巨大的丑东西”·悬赏":"'거대하고 못생긴 것' - 수배","“帝国的甲胄”":"'제국의 갑옷'","“庞贝”":"'폼페이'","“庞贝”·悬赏":"'폼페이' - 수배","“斩胄之剑”":"'갑주를 베는 검'","“灵幛”":"'영장'","“独轮车玩具”":"'완구 외발자전거'","“破胄之锤”":"'갑주를 부수는 해머'","“碎铳之簧”":"'총을 부수는 리드'","“神秘顾客”":"'신비한 고객'","“耀阳”":"'눈 부신 태양'","“自在”":"'즈자이'","“自在”·悬赏":"'즈자이' - 수배","“萨科塔之眼”":"'산크타의 눈'","“萨科塔之眼”·悬赏":"'산크타의 눈' - 수배","“萨科塔之翼”":"'산크타의 날개'","“萨科塔昂首”":"'산크타의 긍지'","“萨米的意志”":"'사미의 의지'","“裂管之音”/“断弦之音”":"'단현지음'/'열관지음'","“越长尘”":"'월장진'","“遗弃者”":"'버림받은 자'","“遗弃者”·悬赏":"'버림받은 자' - 수배","“邪魔的利刃”":"'데몬의 칼날'","“邪魔的利刃”·悬赏":"'데몬의 칼날' - 수배","“鼠王”·悬赏":"'래트킹' - 수배","不屈":"불굴","不屈弹射器":"불굴의 사출기","不祥幻影":"불길한 환영","不稳定要素":"불안요소","业余竞演者":"아마추어 경연자","业务指标":"업무 지표","临时收音师":"임시 음향기사","主角阵营角色":"주인공 진영 캐릭터","乌尔比安":"울피아누스","乌顶巨角卢鲁":"검은 뿔의 루울","九流之缘":"구류의 인연","人事部文档":"인사부 파일","人才盲盒":"인재 랜덤박스","仇白":"치우바이","从不混淆的方向":"헷갈리지 않는 방향","令":"링","以己之长":"장점 활용","伊内丝":"이네스","伊利昂的木驮兽":"일리온의 나무 버든비스트","伊奥莱塔·罗素":"이올레타 로시","伊芙利特":"이프리트","休眠子裔":"잠자는 후예","休谟斯":"휴머스","休露丝":"시우루스","众志合一":"일치단결","优等生":"우등생","传奇重犯":"전설의 중범죄수","伪装服":"위장복","伺夜":"비질","余":"위","佩佩":"페페","俗心":"수신","保护目标(冻结状态)":"방어 포인트 (빙결 상태)","信仰搅拌机":"산크타 믹사파라토","信标":"비콘","假想敌:再生":"가상적: 재생","假想敌:弦":"가상적: 현","假想敌:淤困":"가상적: 정체","假想敌:管":"가상적: 관","假想敌:胄":"가상적: 갑주","假想敌:蚀裂":"가상적: 침식","假想敌:铳":"가상적: 총","假想敌:镜膜":"가상적: 경막","假想敌:骨刺":"가상적: 골극","假想敌:黑云":"가상적: 흑운","假日威龙陈":"첸 더 홀룽데이","傀影":"팬텀","催泪瓦斯":"최루가스","元核孽生者":"원핵의 악생자","元核孽生者·悬赏":"원핵의 악생자 - 수배","克莱Line truncated
+1 -1
View File
@@ -1 +1 @@
{"version":1,"lang":"zh-TW","meta":{"generator":"tools/build-i18n.mjs","source":{"id":"assets","label":"ArknightsAssets/ArknightsGamedata (tw)","home":"https://github.com/ArknightsAssets/ArknightsGamedata","season":"act2autochess"},"fallback":"public/i18n/zh-TW.json (the pack's UI strings)","coverage":{"bands":{"texts":186,"translated":170,"official":170,"pct":91.4},"bonds":{"texts":138,"translated":130,"official":130,"pct":94.2},"bosses":{"texts":66,"translated":66,"official":66,"pct":100},"choices":{"texts":679,"translated":674,"official":674,"pct":99.3},"config":{"texts":110,"translated":103,"official":103,"pct":93.6},"effects":{"texts":1083,"translated":1053,"official":1053,"pct":97.2},"emotes":{"texts":41,"translated":22,"official":3,"pct":53.7},"enemies":{"texts":1734,"translated":1659,"official":1659,"pct":95.7},"factions":{"texts":14,"translated":14,"official":14,"pct":100},"garrisons":{"texts":747,"translated":747,"official":747,"pct":100},"items":{"texts":582,"translated":544,"official":544,"pct":93.5},"modules":{"texts":2482,"translated":2302,"official":2302,"pct":92.7},"operators":{"texts":873,"translated":585,"official":585,"pct":67},"skills":{"texts":5646,"translated":5110,"official":5110,"pct":90.5},"stages":{"texts":1124,"translated":901,"official":901,"pct":80.2},"talents":{"texts":4297,"translated":3806,"official":3806,"pct":88.6},"tokens":{"texts":442,"translated":419,"official":419,"pct":94.8},"traits":{"texts":1870,"translated":1854,"official":1854,"pct":99.1}},"totals":{"texts":22114,"translated":20159,"official":20140,"pct":91.2}},"names":{"\"炎佑\"":"“炎佑”","Dor-1号失败品":"Dor-1號失敗品","Dor-α号失败品":"Dor-α號失敗品","Dor-β号复制体":"Dor-β號複製體","M3茧甲":"M3繭甲","W·悬赏":"W·懸賞","“变形者集群”":"“變形者集群”","“墓碑”·悬赏":"“墓碑”·懸賞","“复仇者”":"“復仇者”","“复仇者”·悬赏":"“復仇者”·懸賞","“巨大的丑东西”":"“巨大的醜東西”","“巨大的丑东西”·悬赏":"“巨大的醜東西”·懸賞","“帝国的甲胄”":"“帝國的甲胄”","“庞贝”":"“龐貝”","“庞贝”·悬赏":"“龐貝”·懸賞","“斩胄之剑”":"“斬胄之劍”","“灵幛”":"“靈幛”","“独轮车玩具”":"“獨輪車玩具”","“破胄之锤”":"“破胄之錘”","“碎铳之簧”":"“碎銃之簧”","“神秘顾客”":"“神秘顧客”","“耀阳”":"“耀陽”","“自在”·悬赏":"“自在”·懸賞","“萨科塔之眼”":"“薩科塔之眼”","“萨科塔之眼”·悬赏":"“薩科塔之眼”·懸賞","“萨科塔之翼”":"“薩科塔之翼”","“萨科塔昂首”":"“薩科塔昂首”","“萨米的意志”":"“薩米的意志”","“裂管之音”/“断弦之音”":"“裂管之音”/“斷弦之音”","“越长尘”":"“越長塵”","“遗弃者”":"“遺棄者”","“遗弃者”·悬赏":"“遺棄者”·懸賞","“邪魔的利刃”·悬赏":"“邪魔的利刃”·懸賞","“鼠王”·悬赏":"“鼠王”·懸賞","不屈弹射器":"不屈彈射器","不稳定要素":"不穩定要素","业余竞演者":"業餘競演者","业务指标":"業務指標","临时收音师":"臨時收音師","主角阵营角色":"主角陣營角色","乌尔比安":"烏爾比安","乌顶巨角卢鲁":"烏頂巨角盧魯","九流之缘":"九流之緣","人事部文档":"人事部文件","从不混淆的方向":"從不混淆的方向","以己之长":"以己之長","伊内丝":"伊內絲","伊利昂的木驮兽":"伊利昂的木馱獸","伊奥莱塔·罗素":"伊奧萊塔·羅素","休谟斯":"休謨斯","休露丝":"休露絲","众志合一":"眾志合一","优等生":"優等生","传奇重犯":"傳奇重犯","伪装服":"偽裝服","余":"餘","保护目标(冻结状态)":"保護目標(凍結狀態)","信仰搅拌机":"信仰攪拌機","信标":"信標","假想敌:再生":"假想敵:再生","假想敌:弦":"假想敵:弦","假想敌:淤困":"假想敵:淤困","假想敌:管":"假想敵:管","假想敌:胄":"假想敵:胄","假想敌:蚀裂":"假想敵:蝕裂","假想敌:铳":"假想敵:銃","假想敌:镜膜":"假想敵:鏡膜","假想敌:骨刺":"假想敵:骨刺","假想敌:黑云":"假想敵:黑雲","假日威龙陈":"假日威龍陳","催泪瓦斯":"催淚瓦斯","元核孽生者·悬赏":"元核孽生者·懸賞","克莱门莎":"克萊門莎","军用猎狗":"軍用獵狗","冰爆源石虫":"冰爆源石蟲","冲击式施术单元":"衝擊式施術單元","凋零骑士":"凋零騎士","凋零骑士·悬赏":"凋零騎士·懸賞","凛御银灰":"凜御銀灰","凯尔希":"凱爾希","凯瑟琳":"凱瑟琳","列装":"列裝","利滚利":"利滾利","刺胄之弹":"刺胄之彈","加急调派":"緊急調派","加练!":"加練!","加速维式重锤":"加速維式重鎚","医疗探机":"醫療探機","升华":"昇華","华法琳":"華法琳",Line truncated
{"version":1,"lang":"zh-TW","meta":{"generator":"tools/build-i18n.mjs","source":{"id":"assets","label":"ArknightsAssets/ArknightsGamedata (tw)","home":"https://github.com/ArknightsAssets/ArknightsGamedata","season":"act2autochess"},"fallback":"public/i18n/zh-TW.json (the pack's UI strings)","coverage":{"bands":{"texts":186,"translated":170,"official":170,"pct":91.4},"bonds":{"texts":138,"translated":130,"official":130,"pct":94.2},"bosses":{"texts":66,"translated":66,"official":66,"pct":100},"choices":{"texts":679,"translated":674,"official":674,"pct":99.3},"config":{"texts":110,"translated":103,"official":103,"pct":93.6},"effects":{"texts":1083,"translated":1053,"official":1053,"pct":97.2},"emotes":{"texts":41,"translated":22,"official":3,"pct":53.7},"enemies":{"texts":1734,"translated":1659,"official":1659,"pct":95.7},"factions":{"texts":14,"translated":14,"official":14,"pct":100},"garrisons":{"texts":747,"translated":747,"official":747,"pct":100},"items":{"texts":582,"translated":544,"official":544,"pct":93.5},"modules":{"texts":2482,"translated":2302,"official":2302,"pct":92.7},"operators":{"texts":873,"translated":585,"official":585,"pct":67},"skills":{"texts":5646,"translated":5110,"official":5110,"pct":90.5},"stages":{"texts":1124,"translated":901,"official":901,"pct":80.2},"talents":{"texts":5963,"translated":5368,"official":5368,"pct":90},"tokens":{"texts":456,"translated":433,"official":433,"pct":95},"traits":{"texts":1870,"translated":1854,"official":1854,"pct":99.1}},"totals":{"texts":23794,"translated":21735,"official":21716,"pct":91.3}},"names":{"\"炎佑\"":"“炎佑”","Dor-1号失败品":"Dor-1號失敗品","Dor-α号失败品":"Dor-α號失敗品","Dor-β号复制体":"Dor-β號複製體","M3茧甲":"M3繭甲","W·悬赏":"W·懸賞","“变形者集群”":"“變形者集群”","“墓碑”·悬赏":"“墓碑”·懸賞","“复仇者”":"“復仇者”","“复仇者”·悬赏":"“復仇者”·懸賞","“巨大的丑东西”":"“巨大的醜東西”","“巨大的丑东西”·悬赏":"“巨大的醜東西”·懸賞","“帝国的甲胄”":"“帝國的甲胄”","“庞贝”":"“龐貝”","“庞贝”·悬赏":"“龐貝”·懸賞","“斩胄之剑”":"“斬胄之劍”","“灵幛”":"“靈幛”","“独轮车玩具”":"“獨輪車玩具”","“破胄之锤”":"“破胄之錘”","“碎铳之簧”":"“碎銃之簧”","“神秘顾客”":"“神秘顧客”","“耀阳”":"“耀陽”","“自在”·悬赏":"“自在”·懸賞","“萨科塔之眼”":"“薩科塔之眼”","“萨科塔之眼”·悬赏":"“薩科塔之眼”·懸賞","“萨科塔之翼”":"“薩科塔之翼”","“萨科塔昂首”":"“薩科塔昂首”","“萨米的意志”":"“薩米的意志”","“裂管之音”/“断弦之音”":"“裂管之音”/“斷弦之音”","“越长尘”":"“越長塵”","“遗弃者”":"“遺棄者”","“遗弃者”·悬赏":"“遺棄者”·懸賞","“邪魔的利刃”·悬赏":"“邪魔的利刃”·懸賞","“鼠王”·悬赏":"“鼠王”·懸賞","不屈弹射器":"不屈彈射器","不稳定要素":"不穩定要素","业余竞演者":"業餘競演者","业务指标":"業務指標","临时收音师":"臨時收音師","主角阵营角色":"主角陣營角色","乌尔比安":"烏爾比安","乌顶巨角卢鲁":"烏頂巨角盧魯","九流之缘":"九流之緣","人事部文档":"人事部文件","从不混淆的方向":"從不混淆的方向","以己之长":"以己之長","伊内丝":"伊內絲","伊利昂的木驮兽":"伊利昂的木馱獸","伊奥莱塔·罗素":"伊奧萊塔·羅素","休谟斯":"休謨斯","休露丝":"休露絲","众志合一":"眾志合一","优等生":"優等生","传奇重犯":"傳奇重犯","伪装服":"偽裝服","余":"餘","保护目标(冻结状态)":"保護目標(凍結狀態)","信仰搅拌机":"信仰攪拌機","信标":"信標","假想敌:再生":"假想敵:再生","假想敌:弦":"假想敵:弦","假想敌:淤困":"假想敵:淤困","假想敌:管":"假想敵:管","假想敌:胄":"假想敵:胄","假想敌:蚀裂":"假想敵:蝕裂","假想敌:铳":"假想敵:銃","假想敌:镜膜":"假想敵:鏡膜","假想敌:骨刺":"假想敵:骨刺","假想敌:黑云":"假想敵:黑雲","假日威龙陈":"假日威龍陳","催泪瓦斯":"催淚瓦斯","元核孽生者·悬赏":"元核孽生者·懸賞","克莱门莎":"克萊門莎","军用猎狗":"軍用獵狗","冰爆源石虫":"冰爆源石蟲","冲击式施术单元":"衝擊式施術單元","凋零骑士":"凋零騎士","凋零骑士·悬赏":"凋零騎士·懸賞","凛御银灰":"凜御銀灰","凯尔希":"凱爾希","凯瑟琳":"凱瑟琳","列装":"列裝","利滚利":"利滾利","刺胄之弹":"刺胄之彈","加急调派":"緊急調派","加练!":"加練!","加速维式重锤":"加速維式重鎚","医疗探机":"醫療探機","升华":"昇華","华法琳":"華法琳","协Line truncated
+1 -1
View File
@@ -1 +1 @@
{"token_10000_silent_healrb":{"tokenId":"token_10000_silent_healrb","kind":"summon","name":"医疗探机","appellation":"Medical Drone","desc":"不会受到攻击,恢复周围友方单位的生命","descRaw":"不会受到攻击,恢复周围友方单位的生命","profession":"TOKEN","subProfessionId":"notchar1","position":"ALL","displayType":"DEFAULT","placeable":true,"ownerRange":false,"owners":["chess_char_2_02_a","chess_char_2_02_b"],"stats":{"maxHp":1000,"atk":85,"def":0,"res":0,"cost":5,"blockCnt":0,"bat":0.5,"aspd":100,"respawnTime":5,"spRecovery":1,"hpRecoveryPerSec":0,"moveSpeed":1,"tauntLevel":0,"massLevel":0,"deployLimit":1,"deckStack":0},"rangeGrid":[[1,-1],[1,0],[1,1],[0,-1],[0,0],[0,1],[-1,-1],[-1,0],[-1,1]],"dmgType":"heal","attackKind":"heal","projectile":"orb","canHitFly":false,"skill":{"skillId":"skcom_withdraw","bb":{}},"deployLimit":1,"count":1,"abnormal":[],"variants":{"chess_char_2_02_a":{"phase":1,"level":60,"stats":{"maxHp":1000,"atk":85,"def":0,"res":0,"cost":5,"blockCnt":0,"bat":0.5,"aspd":100,"respawnTime":5,"spRecovery":1,"hpRecoveryPerSec":0,"moveSpeed":1,"tauntLevel":0,"massLevel":0,"deployLimit":1,"deckStack":0},"immunities":{"stun":false,"silence":false,"sleep":false,"frozen":false,"levitate":false},"rangeGrid":[[1,-1],[1,0],[1,1],[0,-1],[0,0],[0,1],[-1,-1],[-1,0],[-1,1]],"trait":{"desc":"不会受到攻击,恢复周围友方单位的生命","descRaw":"不会受到攻击,恢复周围友方单位的生命","bb":{},"bbStr":{}},"dmgType":"heal","attackKind":"heal","projectile":"orb","canHitFly":false,"targetPriority":null,"skill":{"skillId":"skcom_withdraw","iconId":"skcom_withdraw","name":"自我销毁","level":1,"desc":"10秒后自动销毁","descRaw":"<@ba.vup>10</>秒后自动销毁","skillType":"PASSIVE","durationType":"NONE","duration":10,"spType":"ON_DEPLOY","spCost":0,"initSp":0,"maxChargeTime":0,"increment":0,"bb":{},"bbStr":{},"rangeId":null,"rangeGrid":null,"prefabId":"skcom_withdraw","trigger":{"rule":"DEFAULT","rawRule":"DEFAULT","customRangeGrid":null},"index":1},"talents":[],"count":1,"sources":["skill","display"],"bySkill":{"0":{"skill":{"skillId":"skcom_withdraw","iconId":"skcom_withdraw","name":"自我销毁","level":1,"desc":"10秒后自动销毁","descRaw":"<@ba.vup>10</>秒后自动销毁","skillType":"PASSIVE","durationType":"NONE","duration":10,"spType":"ON_DEPLOY","spCost":0,"initSp":0,"maxChargeTime":0,"increment":0,"bb":{},"bbStr":{},"rangeId":null,"rangeGrid":null,"prefabId":"skcom_withdraw","trigger":{"rule":"DEFAULT","rawRule":"DEFAULT","customRangeGrid":null},"index":0},"count":null,"sources":["display"]}}},"chess_char_2_02_b":{"phase":2,"level":55,"stats":{"maxHp":1000,"atk":114,"def":0,"res":0,"cost":5,"blockCnt":0,"bat":0.5,"aspd":100,"respawnTime":5,"spRecovery":1,"hpRecoveryPerSec":0,"moveSpeed":1,"tauntLevel":0,"massLevel":0,"deployLimit":1,"deckStack":0},"immunities":{"stun":false,"silence":false,"sleep":false,"frozen":false,"levitate":false},"rangeGrid":[[1,-1],[1,0],[1,1],[0,-1],[0,0],[0,1],[-1,-1],[-1,0],[-1,1]],"trait":{"desc":"不会受到攻击,恢复周围友方单位的生命","descRaw":"不会受到攻击,恢复周围友方单位的生命","bb":{},"bbStr":{}},"dmgType":"heal","attackKind":"heal","projectile":"orb","canHitFly":false,"targetPriority":null,"skill":{"skillId":"skcom_withdraw","iconId":"skcom_withdraw","name":"自我销毁","level":1,"desc":"10秒后自动销毁","descRaw":"<@ba.vup>10</>秒后自动销毁","skillType":"PASSIVE","durationType":"NONE","duration":10,"spType":"ON_DEPLOY","spCost":0,"initSp":0,"maxChargeTime":0,"increment":0,"bb":{},"bbStr":{},"rangeId":null,"rangeGrid":null,"prefabId":"skcom_withdraw","trigger":{"rule":"DEFAULT","rawRule":"DEFAULT","customRangeGrid":null},"index":1},"talents":[],"count":1,"sources":["skill","display"],"bySkill":{"0":{"skill":{"skillId":"skcom_withdraw","iconId":"skcom_withdraw","name":"自我销毁","level":1,"desc":"10秒后自动销毁","descRaw":"<@ba.vup>10</>秒后自动销毁","skillType":"PASSIVE","durationType":"NONE","duration":10,"spType":"ON_DEPLOY","spCost":0,"initSp":0,"maxChargeTime":0,"increment":0,"bb":{},"bbStr":{},"rangeId":null,"rangeGrid":null,"prefabId":"skcom_withdraw","trigger":{"rule":"DEFAULT","rawRule":"DEFAULT","customRangeGrid":null},"index":0},"count":null,"sources":["display"]}},"byModule":{"none":{"stats":{"maxHp":1000,"atk":114,"def":0,"res":0,"cost":5,"blockCnt":0,"bat":0.5,"aspd":100,"respawnTime":5,"spRecovery":1,"hpRecoveryPerSec":0,"moveSpeed":1,"tauntLevel":0,"massLevel":0,"deployLimit":1,"deckStack":0},"immunities":{"stun":false,"silence":false,"sleep":false,"frozen":false,"levitate":false},"trait":{"desc":"不会受到攻击,恢复周围友方单位的生命","descRaw":"不会受到攻击,恢复周围友方单位的生命","bb":{},"bbStr":{}},"talents":[]}}}},"assets":{"avatar":"token_10000_silent_healrb","spine":"token_10000_silent_healrb"}},"token_10006_vodfox_doll":{"tokenId":"token_10006_vodfox_doll","kind":"summon"Line truncated
{"token_10000_silent_healrb":{"tokenId":"token_10000_silent_healrb","kind":"summon","name":"医疗探机","appellation":"Medical Drone","desc":"不会受到攻击,恢复周围友方单位的生命","descRaw":"不会受到攻击,恢复周围友方单位的生命","profession":"TOKEN","subProfessionId":"notchar1","position":"ALL","displayType":"DEFAULT","placeable":true,"ownerRange":false,"owners":["chess_char_2_02_a","chess_char_2_02_b"],"stats":{"maxHp":1000,"atk":85,"def":0,"res":0,"cost":5,"blockCnt":0,"bat":0.5,"aspd":100,"respawnTime":5,"spRecovery":1,"hpRecoveryPerSec":0,"moveSpeed":1,"tauntLevel":0,"massLevel":0,"deployLimit":1,"deckStack":0},"rangeGrid":[[1,-1],[1,0],[1,1],[0,-1],[0,0],[0,1],[-1,-1],[-1,0],[-1,1]],"dmgType":"heal","attackKind":"heal","projectile":"orb","canHitFly":false,"skill":{"skillId":"skcom_withdraw","bb":{}},"deployLimit":1,"count":1,"abnormal":[],"variants":{"chess_char_2_02_a":{"phase":1,"level":60,"stats":{"maxHp":1000,"atk":85,"def":0,"res":0,"cost":5,"blockCnt":0,"bat":0.5,"aspd":100,"respawnTime":5,"spRecovery":1,"hpRecoveryPerSec":0,"moveSpeed":1,"tauntLevel":0,"massLevel":0,"deployLimit":1,"deckStack":0},"immunities":{"stun":false,"silence":false,"sleep":false,"frozen":false,"levitate":false},"rangeGrid":[[1,-1],[1,0],[1,1],[0,-1],[0,0],[0,1],[-1,-1],[-1,0],[-1,1]],"trait":{"desc":"不会受到攻击,恢复周围友方单位的生命","descRaw":"不会受到攻击,恢复周围友方单位的生命","bb":{},"bbStr":{}},"dmgType":"heal","attackKind":"heal","projectile":"orb","canHitFly":false,"targetPriority":null,"skill":{"skillId":"skcom_withdraw","iconId":"skcom_withdraw","name":"自我销毁","level":1,"desc":"10秒后自动销毁","descRaw":"<@ba.vup>10</>秒后自动销毁","skillType":"PASSIVE","durationType":"NONE","duration":10,"spType":"ON_DEPLOY","spCost":0,"initSp":0,"maxChargeTime":0,"increment":0,"bb":{},"bbStr":{},"rangeId":null,"rangeGrid":null,"prefabId":"skcom_withdraw","trigger":{"rule":"DEFAULT","rawRule":"DEFAULT","customRangeGrid":null},"index":1},"talents":[],"count":1,"sources":["skill","display"],"bySkill":{"0":{"skill":{"skillId":"skcom_withdraw","iconId":"skcom_withdraw","name":"自我销毁","level":1,"desc":"10秒后自动销毁","descRaw":"<@ba.vup>10</>秒后自动销毁","skillType":"PASSIVE","durationType":"NONE","duration":10,"spType":"ON_DEPLOY","spCost":0,"initSp":0,"maxChargeTime":0,"increment":0,"bb":{},"bbStr":{},"rangeId":null,"rangeGrid":null,"prefabId":"skcom_withdraw","trigger":{"rule":"DEFAULT","rawRule":"DEFAULT","customRangeGrid":null},"index":0},"count":null,"sources":["display"]}}},"chess_char_2_02_b":{"phase":2,"level":55,"stats":{"maxHp":1000,"atk":114,"def":0,"res":0,"cost":5,"blockCnt":0,"bat":0.5,"aspd":100,"respawnTime":5,"spRecovery":1,"hpRecoveryPerSec":0,"moveSpeed":1,"tauntLevel":0,"massLevel":0,"deployLimit":1,"deckStack":0},"immunities":{"stun":false,"silence":false,"sleep":false,"frozen":false,"levitate":false},"rangeGrid":[[1,-1],[1,0],[1,1],[0,-1],[0,0],[0,1],[-1,-1],[-1,0],[-1,1]],"trait":{"desc":"不会受到攻击,恢复周围友方单位的生命","descRaw":"不会受到攻击,恢复周围友方单位的生命","bb":{},"bbStr":{}},"dmgType":"heal","attackKind":"heal","projectile":"orb","canHitFly":false,"targetPriority":null,"skill":{"skillId":"skcom_withdraw","iconId":"skcom_withdraw","name":"自我销毁","level":1,"desc":"10秒后自动销毁","descRaw":"<@ba.vup>10</>秒后自动销毁","skillType":"PASSIVE","durationType":"NONE","duration":10,"spType":"ON_DEPLOY","spCost":0,"initSp":0,"maxChargeTime":0,"increment":0,"bb":{},"bbStr":{},"rangeId":null,"rangeGrid":null,"prefabId":"skcom_withdraw","trigger":{"rule":"DEFAULT","rawRule":"DEFAULT","customRangeGrid":null},"index":1},"talents":[],"count":1,"sources":["skill","display"],"bySkill":{"0":{"skill":{"skillId":"skcom_withdraw","iconId":"skcom_withdraw","name":"自我销毁","level":1,"desc":"10秒后自动销毁","descRaw":"<@ba.vup>10</>秒后自动销毁","skillType":"PASSIVE","durationType":"NONE","duration":10,"spType":"ON_DEPLOY","spCost":0,"initSp":0,"maxChargeTime":0,"increment":0,"bb":{},"bbStr":{},"rangeId":null,"rangeGrid":null,"prefabId":"skcom_withdraw","trigger":{"rule":"DEFAULT","rawRule":"DEFAULT","customRangeGrid":null},"index":0},"count":null,"sources":["display"]}},"byModule":{"none":{"stats":{"maxHp":1000,"atk":114,"def":0,"res":0,"cost":5,"blockCnt":0,"bat":0.5,"aspd":100,"respawnTime":5,"spRecovery":1,"hpRecoveryPerSec":0,"moveSpeed":1,"tauntLevel":0,"massLevel":0,"deployLimit":1,"deckStack":0},"immunities":{"stun":false,"silence":false,"sleep":false,"frozen":false,"levitate":false},"trait":{"desc":"不会受到攻击,恢复周围友方单位的生命","descRaw":"不会受到攻击,恢复周围友方单位的生命","bb":{},"bbStr":{}},"talents":[]}}}},"assets":{"avatar":"token_10000_silent_healrb","spine":"token_10000_silent_healrb"}},"token_10006_vodfox_doll":{"tokenId":"token_10006_vodfox_doll","kind":"summon"Line truncated
+8 -4
View File
@@ -34,8 +34,12 @@ effects), [DATA.md](DATA.md) (generated data), [ASSETS.md](ASSETS.md) (art and a
against the spec (`server/match/fields.js` `validateClientResult`), runs the battles no connected human owns (bots, a
dropped player, an authority that missed its deadline) and can re-simulate results (`SP_VERIFY`). `SP_COMBAT=server`
is the older mode in which the server runs every battle and streams snapshots. DESIGN §14.
- **Determinism** is what makes this work: fixed 1/30 s ticks, a seeded PRNG (`server/sim/rng.js`), no wall clock and
no `Math.random` in the sim, so the same spec and data give the same battle on every machine, bit for bit. The golden
- **Determinism** is what makes this work: fixed 1/30 s ticks, a seeded PRNG (`server/sim/rng.js`), no wall clock,
no `Math.random` and only correctly rounded arithmetic in the sim, so the same spec and data give the same battle on
every machine, bit for bit. ECMA-262 leaves `Math.hypot`, `sin`, `cos`, `atan2`, `pow` (and `**`) implementation-
approximated and the engines differ in their last bits (V8, SpiderMonkey and JavaScriptCore each compute `hypot`
differently; Chrome's and Node's `sin` differ), so the sim takes them from `server/sim/detmath.js`, built only from
+ − × ÷ and `Math.sqrt`; ESLint and `test/sim/detmath.test.js` refuse the Math ones in `server/sim/`. The golden
results (§5) rely on it.
## 2. The WebSocket protocol
@@ -89,7 +93,7 @@ so old imports keep working: `public/js/ui/gameLogic.js` (`public/js/ui/gameLogi
| `server/sim/content/enemies/` | the enemy kits by special type (`invisible.js`, `times.js`, `element.js`, `dot.js`, `reflection.js`, `fly.js`, `special.js`) and `leaders.js`; `server/sim/content/enemies.js` dispatches them, `server/sim/content/bosses.js` scripts the leaders |
| `server/sim/content/garrisons/`, `items/`, `bands/` | 特质, equipment and strategies: `battle.js` is the battle side, `meta.js` the prep side (`registerMeta`, META §2) |
| `server/sim/content/bonds/` | the 23 bonds: `core.js` the 8 core bonds (both sides), `server/sim/content/bonds/addon/` the 15 add-on bonds (`battle.js`, `meta.js`) |
| `server/sim/content/` (the rest) | `tokens.js` (summons), `devices.js` (terrain and stage devices), `generic.js` (the kit built from a skill's data when a chess has none), `choices.js` (机变 cards in battle) |
| `server/sim/content/` (the rest) | `tokens.js` (summons), `devices.js` (terrain and stage devices), `generic.js` (the kit built from a skill's data when a chess has none), `choices.js` (机变 cards in battle), `traitMods.js` (the module trait line the engine applies to every operator: 「攻击范围内存在N名及以上敌人时攻击速度+X」) |
| `shared/` | imported by the server and the browser: `protocol.js`, `constants.js`, `i18n.js`, `i18nData.js` and `i18nPacks.js` (languages), `packs.js` (the content-pack format), `standIn.js` (补位), `diy.js` (自选编队), `highGround.js`, `loadoutRecord.js` |
### Client (`public/`)
@@ -99,7 +103,7 @@ so old imports keep working: `public/js/ui/gameLogic.js` (`public/js/ui/gameLogi
| `public/index.html`, `public/js/main.js` | the page and its entry: boot, the router (title → lobby → room → game) |
| `public/js/net.js`, `public/js/store.js`, `public/js/data.js` | the socket client, the observable store, the data loader (`/data/*.json`, with the English overlay) |
| `public/js/battle/` | `runner.js` (the local battle: loads `/sim/`, steps it, reports), `observe.js` (who may watch which field) |
| `public/js/screens/` | `title.js`, `lobby.js`, `room.js`, `loadout.js` (干员调配), `ownership.js` (干员持有), `diy.js` (自选编队), `briefing.js`, `bandDraft.js`, `game.js` with `public/js/screens/game/`, `result.js` |
| `public/js/screens/` | `title.js`, `lobby.js`, `room.js`, `loadout.js` (干员调配), `cultivation.js` (its 潜能 / 练度 controls), `ownership.js` (干员持有), `diy.js` (自选编队), `briefing.js`, `bandDraft.js`, `game.js` with `public/js/screens/game/`, `result.js` |
| `public/js/ui/` | the HUD components (`hud.js`, `shopBar.js`, `detailPanel.js`, `bondStrip.js`, `teamPanel.js` …); `public/js/ui/gameLogic/` the pure in-match logic, unit-tested in Node |
| `public/js/render/` | the field view: `app.js` with `public/js/render/app/`, `units.js` and `spine.js` (models), `tiles.js`, `projection.js`, `interp.js`, `pick.js`, `drag.js`, `public/js/render/fx/` (effects; `kinds.js` maps the fx kinds), `public/js/render/board3d/` (the official 3D board) |
| `public/css/`, `public/i18n/<code>.json` | the styles; the UI strings of each language pack (English ships) |
+15 -5
View File
@@ -20,7 +20,7 @@ npm run assets # = node tools/vendor.mjs && node tools/fetch-assets.mjs
| `--offline` | No network. Re-runs post-processing (atlas fixes, skeleton parsing, WOFF2) on what is already on disk, then rebuilds `data/assets.json`. |
| `--dry-run` | Print the plan (file and model counts, alias notes) and exit. |
| `--refresh-index` | Re-download the upstream indexes: `audio_data.json`, `charword_table.json` (the 干员战斗语音 slots) and `models_data.json`. |
| `--voice-lang=cn` | 干员战斗语音 language: `cn` (default) | `jp` | `en` | `kr` — the same file names under `voice_cn/`, `voice/`, `voice_en/`, `voice_kr/`. |
| `--voice-lang=cn` | The dub of `audio.voice` (the 中文 voice setting): `cn` (default) | `jp` | `en` | `kr` — the same file names under `voice_cn/`, `voice/`, `voice_en/`, `voice_kr/`. `audio.voiceJp` (the 日本語 setting) is always the JP dub: every run plans both trees. |
| `--voice-all` | Plan every official voice slot, including the prep-only lines no battle plays (干员报到 / 编入队伍 / 任命队长 — 360 files, one per operator and slot). Off by default: nothing requests them, so planning them only makes every run download more. |
| `--prune` | Delete files under `public/assets/` that the manifest no longer references, for example after a mapping change. Without this flag they are only listed in the report. `public/assets/local/` (written by `tools/local-extract`) is never pruned. Implies `--allow-shrink`. |
| `--allow-shrink` | Write `data/assets.json` even when it loses entries the current one has (see "The manifest never shrinks by accident" below). |
@@ -53,7 +53,7 @@ How downloads are fetched:
- A manifest entry with fallbacks (for example an enemy icon that falls back to its base enemy's icon) only moves on to the next alternative after a **definitive 404**. When the primary fails transiently (network error, 5xx or an invalid payload after all retries), no fallback is fetched. The path is listed under `downloadErrors` in the report, and the next run retries the primary.
- A skeleton that fails to parse is deleted and removed from the ledger, so the next online run downloads it again.
The first run downloads about **309 MiB in about 5,690 files** (it took 134 s on a ~3 MB/s link before the 55 emote and 玩法说明 files, 21.3 MiB, and the 1,680 干员战斗语音 files, 40.1 MiB, were added). A re-run takes about 1 s. The voice count is the twelve slots a battle plays; the three prep-only slots the official client uses elsewhere (干员报到 / 编入队伍 / 任命队长, 360 more files, 18.4 MiB) are left out unless `--voice-all` is passed.
The first run downloads about **548 MiB in 10,643 files** (`stats` of `data/assets.json` in 0.2.2: 574,449,900 bytes). The two 干员战斗语音 dubs are 2,674 files each, the twelve slots a battle plays for 191 operators: the Chinese one 65.6 MiB, the Japanese one 85.2 MiB (89,388,041 bytes; adding it took 107 s at about 1 MB/s from raw.githubusercontent.com). A re-run takes about 1 s. The three prep-only slots the official client uses elsewhere (干员报到 / 编入队伍 / 任命队长, 360 more files per dub, 18.4 MiB of them Chinese) are left out unless `--voice-all` is passed.
Outputs:
- `data/assets.json`: the manifest (committed).
@@ -93,7 +93,7 @@ The research JSONs in `docs/research/` (03, 05, 07) define **which** ids are nee
| Token Spine that no dump carries (39 summons: most 自选 summons, 凯瑟琳's 爬行号·防护单元, 凛御银灰's 风雪之眼) | the local client only (`tools/local-extract/extract.py TOKEN_SPINES`, optional); never downloaded and never required: an overlay (`tokens[id].spineLocal`; without it the avatar diamond) | `local/spine/token/{tokenId}/{stem}.*` (listed in `data/local-assets.json`) |
| BGM | AA2 `voice` branch `audio/sound_beta_2/music/**` (大厅/休整期 `act1autochess`, 开战 `act13side/m_bat_kazimierz2_{1,2}` — 骑士之日 / 无畏者; the 开战 track follows the round: `_2` 无畏者 rounds 1–7, `_1` 骑士之日 from round 8) | `audio/bgm/{file}.mp3` |
| SFX (UI, battle, per unit) | AA2 `voice` `audio/sound_beta_2/**`, mapped from `audio_data.json` banks | `audio/sfx/{same sub-path}.mp3` |
| 干员战斗语音 | AA2 `voice` `audio/sound_beta_2/voice_cn/{charId}/cn_nn.mp3` — the lines `charword_table.json` lists (`placeType` = when the game plays one, `voiceAsset` = the path); `--voice-lang=jp|en|kr` takes the same file names from `voice/`, `voice_en/`, `voice_kr/` | `audio/voice/{lang}/{charId}/{cn_nn}.mp3` |
| 干员战斗语音 | AA2 `voice` `audio/sound_beta_2/voice_cn/{charId}/cn_nn.mp3` (Chinese, `audio.voice`) and `voice/{charId}/cn_nn.mp3` (Japanese, `audio.voiceJp`: the same file names) — the lines `charword_table.json` lists (`placeType` = when the game plays one, `voiceAsset` = the path); `--voice-lang=jp|en|kr` fills `audio.voice` from `voice/`, `voice_en/`, `voice_kr/` instead | `audio/voice/cn/{charId}/{cn_nn}.mp3`, `audio/voice/jp/{charId}/{cn_nn}.mp3` (`audio/voice/{lang}/…` for another `--voice-lang`) |
| Fonts: Bender Regular and Light, Novecento Wide | TimWangZi/The-font-of-Arknights | `public/fonts/*.{otf,ttf,woff2}`, `public/fonts/fonts.css` |
The `stem` of a Spine model is the upstream file name. Two examples: `char_107_liskam` has the stem `char_107_liskarm`, and `enemy_9032_aclionk` uses `enemy_1559_vtlionk`. The skel and atlas of a model always share one stem. pixi-spine locates the atlas by swapping the extension, so this matters.
@@ -148,7 +148,7 @@ All paths are URL paths relative to the site root, for example `/assets/char/ava
hash: 'a1b2c3d4e5f6', // content hash (cache busting)
generator: 'tools/fetch-assets.mjs',
stats: { files, bytes, chars, charsWithBack, enemies, enemiesWithSpine, tokens, tokensWithSpine,
spineModels, bonds, items, bands, skills, modules, ui, sfxUnits, voiceChars },
spineModels, bonds, items, bands, skills, modules, ui, sfxUnits, voiceChars, voiceJpChars },
chars: { [charId]: { avatar, avatarE2?, portrait, portraitE2?, spine: { front: Spine, back?: Spine } } },
enemies: { [enemyId]: { icon, spine?: Spine, spineAliasOf?: enemyId,
spineLocal?: { group, skel, atlas, textures, …Spine } } },
@@ -186,6 +186,12 @@ All paths are URL paths relative to the site root, for example `/assets/char/ava
// prep-only slots 干员报到 / 编入队伍 / 任命队长 are left out of the plan by default —
// nothing requests them and they cost 360 files (19.3 MB) per run — and `--voice-all` adds
// them (audio.mjs VOICE_PREP_SLOTS) for the complete official set
voiceJp: { [charId]: { …the slots of `voice` } },
// the Japanese dub (0.2.2, the owner's request 「全套的日配语音」): the same slots, lines and file
// names as `voice`, under /assets/audio/voice/jp/ (AA2 `voice/`). The client plays it when the
// player picks 日本語 (settings 语音语言, not tied to the interface language) and falls back to
// `voice` for a slot it lacks or a file the host does not have (public/js/audio.js voiceLine);
// the full zip ships it unless tools/package.mjs FULL_ZIP_JP_VOICE is off
sfx: {
ui: { click, back, confirm, tab, pick, drop, error, buy, sell, income, refresh, freeze, levelup,
merge, equip, itemMerge, bondUp, artPlace, ready, timer, draft, yourTurn, yourTurnCircle,
@@ -273,8 +279,12 @@ catch-up frames and hidden tabs (`keepsState`), the game screen's pre-entry buff
event queue (`render/interp.js isCosmeticEvent`) too — or, for a view built mid-battle, UnitInfo `form`, which `render/app/info.js renderInfo` passes to the view; a
`change` clip plays once first, an `end` clip is timed from the fx's `dur` to finish as that state ends, keeping the
current form's death clip until the next form's fx). A blocked or revealed 隐匿 enemy is drawn solid: the sim sends the
stealth bit only while its 隐匿 is on:
stealth bit only while its 隐匿 is on — and 假想敌:骨刺 changes model with that bit, not with an fx (`STEALTH_FORMS`, GitHub
#296, PR #365): the manifest's `*_A` (the three-headed snake) while the bit is on, its `*_B` set (form `revealed`) the
moment it is blocked or revealed, `*_A` again when its 隐匿 is back; no change clip, a running attack, stun or freeze
pose carried over (`render/spine.js syncFormPose`). The forms:
- 掠海漂移体's crawl (`Change`, then `*_02`);
- 假想敌:骨刺 (`*_A` / `*_B`, above);
- 转译基底·α's three forms (`A_Die_B` / `_C` / `_D`, 2 s each, then `B_*` 寻仇者, `C_*` 幽灵, `D_*` 特战术师);
- the 深池逐火 embers (`Die`, then `Idle_2` / `Move_2` / `Die_2`; `Revive` ends as it stands up) and 假想敌:再生's puppet
(`A_Die`, then `B_*`; `B_Revive`);
+3 -2
View File
@@ -393,12 +393,13 @@ heuristics CPU 36 / 88 → 35 / 86 ms and 16 / 40 → 16 / 40 ms.
* Bounties with battles left (multi-round cards — "之后的每场作战" — last two battles since playtest #6, META §1.2)
also spawn in the Final Assault / Hidden Core, on the owner's half (route to its goal), are shown in the boss-round
preview, and the boss battle uses up one of their battles (kill coins → pending funds for the Hidden Core prep).
preview, and the boss battle uses up one of their battles (kill coins, and a perfect-payout 战术特训 card's coins when the player's own field is perfect and the team wins → pending funds for the Hidden Core prep).
* A 驰援 card is never offered when its bond has no chess left in the match's pool (every member banned).
* docs/META.md §2.6 example guards `ctx.source.kind === 'choice'` (its EffectRef reuses the handler's key).
* 坚若磐石 = least LP lost (`titles.comment_3 { stat: 'lpLost', rule: 'min' }` in tuning.json; results.js supports
`rule: 'min'` among the players still alive).
* 教鞭 / “神秘顾客” stay a random bounty, documented in docs/META.md §2.5.
* 教鞭 offers a personal choice of three 战术特训 cards (a bot picks by the bounty scorer, the prep's deadline at random);
“神秘顾客” stays a random bounty — docs/META.md §2.5.
---
+44 -15
View File
@@ -12,7 +12,7 @@ downloads failed on this machine unless `--allow-shrink` (or `--prune`) is passe
```
node tools/build-data.mjs # build (downloads missing official files into .cache/gamedata/)
node tools/build-data.mjs --offline # never download; fail if a cached file is missing
node tools/build-data.mjs --refresh # re-download every official file
node tools/build-data.mjs --refresh # re-download every official file; fail if any download fails
node tools/build-data.mjs --no-research --out /tmp/x # research-free build (fallback defaults), other dir
node tools/build-data.mjs --cache <dir> --report <file> # other cache dir / report file (tests, CI)
node tools/build-data.mjs --force # write data/ even when integrity checks fail (debugging only)
@@ -23,8 +23,11 @@ Unknown options or a missing option value are errors (exit code 2); `--refresh`
- **Cache.** Official files live under `.cache/gamedata/<repo path>` (`excel/activity_table.json`,
`levels/enemydata/enemy_database.json`, `levels/activities/act1autochess/level_*.json`, …) and are fetched from
`https://raw.githubusercontent.com/Kengxxiao/ArknightsGameData/master/zh_CN/gamedata/<path>` when missing.
Downloads are validated (must parse as JSON) and written atomically.
- **Report.** Every run writes `.cache/build-data-report.json` (counts, byte sizes, warnings (each once), errors, `written`).
Downloads are validated (must parse as JSON) and written atomically. With `--refresh`, a download that still fails
after four attempts aborts the build (exit code 1), even if an older cached file exists. Generated files and the
previous report remain untouched, including with `--force`; successfully downloaded cache entries are not rolled
back. Retry `--refresh` to complete the refresh; cached files are not verified to share an upstream revision.
- **Report.** A run that reaches integrity validation writes `.cache/build-data-report.json` (counts, byte sizes, warnings (each once), errors, `written`).
Integrity errors (see §17) make the exit code 1 **and leave the previous output untouched** (unless `--force`);
warnings never do. Each output file is written atomically (temp file + rename).
- **Determinism.** Same inputs ⇒ byte-identical outputs (stable key order, no timestamps, no randomness).
@@ -161,7 +164,7 @@ Top level: `{ season, seasonName, modes, economy, lpCapPerRound, bossOvertimeAft
| `price`, `sellPrice` | `2`, `1` | |
| `upgradeNum`, `upgradeChessId` | `3`, `"chess_char_1_01_b"` | copies needed to merge (风丸 2; golden 0) |
| `status` | `{"phase":2,"level":50,"skillLevel":7,"equipLevel":1}` | training status used for all numbers |
| `stats` | `{"maxHp":1421,"atk":531,"def":167,"res":0,"cost":12,"blockCnt":1,"bat":1,"aspd":100,"respawnTime":70,"spRecovery":1,"hpRecoveryPerSec":0,"moveSpeed":1,"tauntLevel":0,"massLevel":0,"deployLimit":1,"deckStack":0}` | keyframes linearly interpolated at `status` **plus every potential attribute modifier** (full potential, 潜能 6: character_table `potentialRanks` 1–5 — 「攻击力+N」, 「部署费用-1」, 「再部署时间-N秒」, 生命 / 防御 / 法抗 / 攻速 — added to the raw values; the owner's decision of 2026-10-07, GitHub #252 / PR #255: the official records carry no potential, a tournament video shows 刺玫 at ATK 435 / cost 15 = her E1 Lv55 413 / 17 + 攻击力+22 and two 部署费用-1; `tools/build-data.mjs OPERATOR_POTENTIAL`), hp/atk/def/cost/block/respawn rounded; **golden includes the module attribute bonus**. `aspd` 100 = base (module attack_speed added). Talent stat boosts are NOT folded in. |
| `stats` | `{"maxHp":1421,"atk":531,"def":167,"res":0,"cost":12,"blockCnt":1,"bat":1,"aspd":100,"respawnTime":70,"spRecovery":1,"hpRecoveryPerSec":0,"moveSpeed":1,"tauntLevel":0,"massLevel":0,"deployLimit":1,"deckStack":0}` | keyframes linearly interpolated at `status` **plus every potential attribute modifier** (full potential, 潜能 6 — the default; character_table `potentialRanks` 1–5 — 「攻击力+N」, 「部署费用-1」, 「再部署时间-N秒」, 生命 / 防御 / 法抗 / 攻速 — added to the raw values: the official records carry no potential, a tournament video shows 刺玫 at ATK 435 / cost 15 = her E1 Lv55 413 / 17 + 攻击力+22 and two 部署费用-1; a lower potential — the player's 干员调配 setting since 0.2.2 — reads `potDown`, §2.3), hp/atk/def/cost/block/respawn rounded; **golden includes the module attribute bonus**. `aspd` 100 = base (module attack_speed added). Talent stat boosts are NOT folded in. |
| `immunities` | `{"stun":false,"silence":false,"sleep":false,"frozen":false,"levitate":false}` | |
| `rangeId`, `rangeGrid` | `"3-3"`, `[[1,0],[1,1],…]` | attack range at `status` |
| `dmgType` | `"phys"` | `phys` / `arts` / `heal` (derived, see §2.1) |
@@ -169,15 +172,16 @@ Top level: `{ season, seasonName, modes, economy, lpCapPerRound, bossOvertimeAft
| `projectile` | `"arrow"` | `arrow` (phys ranged) / `bolt` (arts ranged) / `orb` (heal) / `none` |
| `canHitFly` | `true` | |
| `targetPriority` | `"fly"` | from trait text: `fly` (优先攻击空中单位), `lowestDef` (防御力最低), else `null` |
| `trait` | `{"desc":"优先攻击空中单位","descRaw":"…","bb":{"atk_scale":1.1},"bbStr":{},"rangeGrid":null,"moduleDesc":"攻击空中单位时攻击力提升至110%","moduleDescRaw":"…"}` | profession trait (+ golden module trait upgrade merged into `bb`; `rangeGrid` = trait-effect area, e.g. 散射手 front row — **not** the attack range). `desc` = the class trait, or the module's rewrite of it (official `overrideDescripton`); `moduleDesc` = the module's added line (official `additionalDescription`, PRTS 「特性追加」), which the client shows after `desc`, never alone (0.2.0, community report item 16.2) |
| `trait` | `{"desc":"优先攻击空中单位","descRaw":"…","bb":{"atk_scale":1.1},"bbStr":{},"rangeGrid":null,"moduleDesc":"攻击空中单位时攻击力提升至110%","moduleDescRaw":"…"}` | profession trait (+ golden module trait upgrade merged into `bb`; `rangeGrid` = trait-effect area, e.g. 散射手 front row — **not** the attack range). `desc` = the class trait, or the module's rewrite of it (official `overrideDescripton`); `moduleDesc` = the module's added line (official `additionalDescription`, PRTS 「特性追加」), which the client shows after `desc`, never alone (0.2.0, community report item 16.2). A module part that only adds a display line (official target `DISPLAY`, no rewrite) fills that line with its blackboard and leaves `desc` at the class trait's numbers — 圣约送葬人 REA-Y: `desc` heals 50, `moduleDesc` 攻击速度+12 (0.2.2, GitHub #400); `bb` still merges every part (what the kits read) |
| `skill` | see below | default skill at `status.skillLevel` (normal 4, golden 7) |
| `skills[]` | `[{…skill record…, "index":0, "isDefault":false}, {…, "index":1, "isDefault":true}]` | **loadout choices** (DESIGN §16): every skill unlocked at `status` (E1 ⇒ S1–S2, E2 ⇒ S1–S3; the default is always listed), same shape as `skill` + `isDefault`, at the chess skill level, `trigger` resolved **for that skill index** (§2.2). The `isDefault` entry equals `skill` |
| `modules[]`, `statsBase`, `traitBase`, `talentsBase` | see §2.2 | golden chess with `equipLevel > 0` only: selectable modules + the no-module base they apply to |
| `talents[]` | `{"index":0,"name":"火力支援","desc":"…","descRaw":"…","bb":{"self_ammo":3,"duration":20,"ally_ammo":1},"bbStr":{},"rangeGrid":null,"tokenKey":null,"hidden":false,"fromModule":false}` | best unlocked candidate at `status` at full potential (candidates up to `requiredPotentialRank` 5: the 「天赋效果增强」 steps; module talent candidates the same); golden: module talent upgrades applied (`fromModule`); module data-only talents have `name:null, hidden:true`. A module upgrade of an existing talent **merges** blackboards (module keys win, base keys it does not restate are kept — e.g. 宴 keeps `min_attack_speed`; 仇白's upgrade adds `atk_scale_t` next to the old `atk_scale`: prefer the key the text uses). Module parts flagged `isToken` are **not** applied to the operator; they upgrade its summons (tokens.json variants). `containerTokenKey`: the official talent token id when it is a container missing from character_table (凛御银灰), `tokenKey` then holds the default skill's token |
| `talents[]` | `{"index":0,"name":"火力支援","desc":"…","descRaw":"…","bb":{"self_ammo":3,"duration":20,"ally_ammo":1},"bbStr":{},"rangeGrid":null,"tokenKey":null,"hidden":false,"fromModule":false}` | best unlocked candidate at `status` at full potential (candidates up to `requiredPotentialRank` 5: the 「天赋效果增强」 steps; module talent candidates the same — an entry a lower potential changes chains its lower candidates, `potMin` / `potBelow`, §2.3); golden: module talent upgrades applied (`fromModule`); module data-only talents have `name:null, hidden:true`. A module upgrade of an existing talent **merges** blackboards (module keys win, base keys it does not restate are kept — e.g. 宴 keeps `min_attack_speed`; 仇白's upgrade adds `atk_scale_t` next to the old `atk_scale`: prefer the key the text uses). Module parts flagged `isToken` are **not** applied to the operator; they upgrade its summons (tokens.json variants). `containerTokenKey`: the official talent token id when it is a container missing from character_table (凛御银灰), `tokenKey` then holds the default skill's token |
| `tokens[]` | `["token_10028_vigil_wolf"]` | summons (→ `tokens.json`): displayTokenDict + default-skill `overrideTokenKey` + talent `tokenKey`; `tokens.json → variants[chessId].sources` tells which (a `display`-only token is not produced by this chess's default skill or talents) |
| `module` | `{"id":"uniequip_002_inside","name":"“最初的惊喜”","type":"MAR-X","level":1,"active":true}` | active only on golden chess |
| `assets` | `{"avatar":"char_498_inside_2","portrait":"char_498_inside_2","spine":"char_498_inside","skillIcon":"skchr_inside_2","subProfIcon":"sub_fastshot_icon"}` | asset **ids** (URLs in `data/assets.json`); golden uses the E2 art when it exists |
| `diyRequirement` | `"TIER_6"` | DIY only |
| `potDown` | `{"0":{"stats.cost":17},"1":{"stats.respawnTime":70},"2":{"stats.atk":413},"4":{"stats.cost":16}}` (`chess_char_1_06_a`, 刺玫) | what a lower potential changes (§2.3; absent when nothing does: the DIY slots, 盟约·辅助干员) |
`skill`:
@@ -242,6 +246,30 @@ everything needed to resolve a unit for `(chessId, skillIndex, moduleId)` (`simd
variants[owner].byModule` (§14). Module choices never change the combat classification (`dmgType`…; the build warns
if one ever would).
### 2.3 Potential below full potential: `potDown` and chained talents (0.2.2)
Potential (潜能 1–6) is the player's per-operator setting (干员调配, the owner's decision of 2026-10-08 — default 潜能 6;
official: 调度手册 「卫戍协议中干员潜能由自身已持有干员潜能决定」). Every record is built at **full potential** and carries
what each lower one changes; `shared/potential.js atPotential(rec, potential)` composes the record at a potential
(data rank r = potential − 1, character_table's `potentialRanks[r]` being the step from rank r to r + 1). The same
annotation is on chess records, `backups.json` unit forms (§18) and token variants (`tokens.json` and `backups.json`
`tokens` variants, §14 — the owner's potential; a token record's top-level defaults stay full):
- `potDown`: `{ "<rank>": { "<dotted.path>": value } }` — for each rank 0–4, the exact value at that rank of every leaf
that differs from the rank above (`stats.*` / `statsBase.*` of a record, `stats.*` of a form; a variant's
`stats.deployLimit`, `stats.deckStack`, `count`, `bySkill.<i>.count`, `byModule.<id>.stats.*`). Applied from rank 4
down to the wanted rank, each value overwriting, so every leaf ends at its value for that rank.
- a talent entry (`talents`, `talentsBase`, `modules[].talentChanges`; a variant's `talents` and `byModule[*].talents`)
that a lower rank changes: `potMin` = the lowest rank it holds for, `potBelow` = the fields the entry below it
changes (`desc`, `descRaw`, `bb` …), itself chained the same way — the candidates by `requiredPotentialRank`
(刺玫's 土壤基肥改良 `potMin` 4: 11% → 8%; Mon3tr's 不毁重构 below its owner's rank 4: 3 s / 1200).
`tools/build-data.mjs` runs the chess / backups / tokens builders at every rank (`ctx.potRank`) and annotates the
full-rank build from the others; the build fails unless `atRank` rebuilds every rank exactly, and the unit-form parity
(§18) holds at every rank. Nothing else depends on potential (no skill, trait or list length). The data translations
(`data/i18n/*.json`) translate the chained texts too. 练度 (the 自持有 bonus) is no record field: it is the
`char_attribute_mul` of the effects.json CHAR_MAP records `aceffect_char_1…4` (§7), applied to the unit.
---
## 3. `bonds.json` — `{ [bondId]: Bond }` (23 bonds, 8 core)
@@ -317,7 +345,7 @@ everything needed to resolve a unit for `(chessId, skillIndex, moduleId)` (`simd
## 7. `effects.json` — `{ [effectId]: Effect }` (361: every effect of the season)
Types: `BAND_INITIAL` 41, `BOND` 23, `EQUIP` 115, `ENEMY_GAIN` 129 (悬赏), `BUFF_GAIN` 43 (战术决策), `ENEMY` 6 (`aceffect_enemy_*` global modifiers), `CHAR_MAP` 4.
Types: `BAND_INITIAL` 41, `BOND` 23, `EQUIP` 115, `ENEMY_GAIN` 129 (悬赏), `BUFF_GAIN` 43 (战术决策), `ENEMY` 6 (`aceffect_enemy_*` global modifiers), `CHAR_MAP` 4 (角色练度映射 — the 自持有 练度 tiers `aceffect_char_1…4`, 未精英化 / 精英阶段1 / 精英阶段2 / 精英阶段2-60级, in `autoChessData.cultivateEffectList` order: their `char_attribute_mul` buff — `atk` / `def` / `max_hp` 1, 1.05, 1.1 — is the unit's 练度 multiplier, `shared/potential.js cultivateMul`, 0.2.2).
| Field | Example | Meaning |
|---|---|---|
@@ -442,7 +470,7 @@ as official level data, and sim tests use them as a plain two-halves road.
| `special` | `{"mire":{"intervalSec":1,"aspdPerStack":-0.05,"moveMulPerStack":-0.05,"maxStacks":10,"heavyWeight":3,…}}`, `{"infection":{"bb":{"damage":70,"atk":0.2,"attack_speed":20,"duration":300}}}`, `{"deepsea":{"skillId":"sktok_tidectrl_3","bb":{…}}}`, `{"smog":{"rule":"operatorsNotTargetableByEnemyRanged"}}`, `{"blower":{…}}` | special-terrain parameters |
| `runes[]`, `globalBuffs[]` | `{"key":"map_tile_blackb_assign","bb":{…},"bbStr":{…}}` | level runes (act2 m03 reed rune has no reed tiles — leftover) |
| `deployTiles` | `{"normal":{"melee":[[9,3],…],"rangedOnly":[[10,4],…],"changedByDevices":[…]},"bossLeft":{…},"bossRight":{…}}` | deployable tiles at match start: `melee` = LOW (effective) buildable ALL/MELEE (ranged may use them too) — and a 深水区 under an active 特制水上平台 (`waterPlatform`, "在水上建立可以部署任意单位的平台"; act1 m05 only — the prep's deploy map only: the sim does not model the canoes, so its Grid keeps those tiles NONE, automatic placements avoid them and the path tie-break counts them as non-blockable [ASSUMED, weight 0 this season]) —, `rangedOnly` = high ground / active platforms; tiles under active crates/mounds and the 深水区 excluded. `bossLeft` / `bossRight` (field coordinates, boss rows 1–5) are what a player may deploy on in a boss round's prep: board (r, c) = boss (r − 7, c) / the right half mirrored (r − 7, 20 − c) (server/match/board.js `field`, user playtest #5 item 7) |
| `groundPaths` / `groundPathsWithDevices` | `{"12,10->9,2":[[12,10],[12,9],…,[12,3],[11,3],[10,3],[9,3],[9,2]]}` | the ground route of the sim's own `server/sim/grid.js` flow field (research 08 §3.4: 4-direction SPFA from the goal, crates cost 1000, Bresenham line-of-sight smoothing — official route lengths — kept unless the remake's (0.1.0's) road-over-floor preference route crosses strictly fewer non-blockable floor / gate / 深水区 tiles — the fewest among equal-length chains, smoothing that never cuts across floor its grid route does not walk; a corner touch does not count as a crossing — e.g. 战场#01's lower gate takes the col-8 road, not the col-9 floor lane, while 战场#04's keeps the official diagonal from row 9 into row 10; DESIGN §14 corrections, test/sim/pathing-official.test.js) as the tiles an enemy crosses, over the whole map, without / with the match-start devices (crates cost 1000, platforms / mounds blocking [ASSUMED]); the battle re-reads its flow fields live |
| `groundPaths` / `groundPathsWithDevices` | `{"12,10->9,2":[[12,10],[12,9],…,[12,3],[11,3],[10,3],[9,3],[9,2]]}` | the ground route of the sim's own `server/sim/grid.js` flow field (research 08 §3.4: 4-direction SPFA from the goal, crates cost 1000, Bresenham line-of-sight smoothing — official route lengths — kept unless the remake's (0.1.0's) road-over-floor preference route crosses strictly fewer non-blockable floor / gate / 深水区 tiles and no more 深水区 (GitHub #375) — the fewest among equal-length chains, smoothing that never cuts across floor its grid route does not walk; a corner touch does not count as a crossing — e.g. 战场#01's lower gate takes the col-8 road, not the col-9 floor lane, while 战场#04's keeps the official diagonal from row 9 into row 10; DESIGN §14 corrections, test/sim/pathing-official.test.js) as the tiles an enemy crosses, over the whole map, without / with the match-start devices (crates cost 1000, platforms / mounds blocking [ASSUMED]); the battle re-reads its flow fields live |
| `options`, `config` | `{"characterLimit":8,"moveMultiplier":0.5}`, `{…configBlackBoard strings…}` | |
Glyph legend (`rows`):
@@ -494,7 +522,7 @@ Glyph legend (`rows`):
| `skill` | `{"skillId":"sktok_vigil_wolf_3","bb":{…}}` | default token skill (same slot as the owner's skill) |
| `deployLimit`, `count` | `1`, `1` | `deployLimit` = the first owner's `stats.deployLimit`. `count` = copies sent to the hand / spawned (talent/skill `cnt`); `null` ⇒ use `deployLimit` |
| `abnormal[]` | `["healFree"]` | abnormal effects the summon holds from the start, no official table carries them — `tools/build-data.mjs TOKEN_ABNORMAL` from the PRTS summon pages (user playtest #6 item 18): `healFree` = 禁疗 (“小自在”, “耀阳”, 斯卡蒂的海嗣, 沙之碑, 流形, 狼群, 迷迭香的战术装备, 黄金盟誓, 保护目标(冻结状态)), `isolated` = 孤立 "无法被同阵营选中" (“炎佑”, 从不混淆的方向); `[]` otherwise. The sim sets `noHeal` / `isolated` (docs/SIM.md §3) |
| `variants[ownerChessId]` | `{"phase":2,"level":1,"stats":{…},"immunities":{…},"rangeGrid":…,"trait":{…},"dmgType":…,"skill":{full skill record},"talents":[…],"count":1,"sources":["talent","display"]}` | stats at the owner's phase/level (clamped to the token's max level) — **none of the owner's potential attribute modifiers** (a token has no potential ranks) — + golden module `tokenAttributeBlackboard` + the token's own talent additions to `deployLimit` / `deckStack` (blackboard `max_deploy_count` / `max_deck_stack_cnt` — the hidden "TOKEN数" talent of 麦哲伦's / 令's summons and 白铁's devices, 夜莺's 幻影; a module token part of the same talent replaces it: `tools/build-data.mjs tokenTalentDeckBonus`, 0.2.0 — no tokens.json summon has one; PRTS 幻影 备注 "最大可部署数量为3", the owners' "最多同时部署3个"); its `talents` / `trait` candidates are picked at the **owner's potential** (full: their `requiredPotentialRank` mirrors the owner's 「天赋效果增强」 — 夕's “小自在” 18 层, 凯尔希's Mon3tr, 望's 棋子 +1 持有 / 部署; `tools/build-data.mjs OPERATOR_POTENTIAL`); the owner's module parts flagged `isToken` upgrade the variant's `trait` (+`moduleDesc`) and `talents` (伺夜's wolves, 缪尔赛思's 流形 `scale` 1, 浊心斯卡蒂's 海嗣 30 s, “耀阳” `atk_scale` 1.15). `sources` ⊆ `talent`/`skill`/`display`: how the owner produces it (`display` only = listed on the character but unused by its default skill/talents, e.g. 迷迭香 S2, 凛御银灰 eagle1/3). `count` = copies from a talent `cnt` or the default skill's `cnt` when that skill overrides this token; `null` ⇒ use `deployLimit` |
| `variants[ownerChessId]` | `{"phase":2,"level":1,"stats":{…},"immunities":{…},"rangeGrid":…,"trait":{…},"dmgType":…,"skill":{full skill record},"talents":[…],"count":1,"sources":["talent","display"]}` | stats at the owner's phase/level (clamped to the token's max level) — **none of the owner's potential attribute modifiers** (a token has no potential ranks) — + golden module `tokenAttributeBlackboard` + the token's own talent additions to `deployLimit` / `deckStack` (blackboard `max_deploy_count` / `max_deck_stack_cnt` — the hidden "TOKEN数" talent of 麦哲伦's / 令's summons and 白铁's devices, 夜莺's 幻影; a module token part of the same talent replaces it: `tools/build-data.mjs tokenTalentDeckBonus`, 0.2.0 — no tokens.json summon has one; PRTS 幻影 备注 "最大可部署数量为3", the owners' "最多同时部署3个"); its `talents` / `trait` candidates are picked at the **owner's potential** (full; their `requiredPotentialRank` mirrors the owner's 「天赋效果增强」 — 夕's “小自在” 18 层, 凯尔希's Mon3tr, 望's 棋子 +1 持有 / 部署 — and the variant chains the lower ones, §2.3: `atPotential(variant, ownerPotential)`); the owner's module parts flagged `isToken` upgrade the variant's `trait` (+`moduleDesc`) and `talents` (伺夜's wolves, 缪尔赛思's 流形 `scale` 1, 浊心斯卡蒂's 海嗣 30 s, “耀阳” `atk_scale` 1.15). `sources` ⊆ `talent`/`skill`/`display`: how the owner produces it (`display` only = listed on the character but unused by its default skill/talents, e.g. 迷迭香 S2, 凛御银灰 eagle1/3). `count` = copies from a talent `cnt` or the default skill's `cnt` when that skill overrides this token; `null` ⇒ use `deployLimit` |
| `variants[o].bySkill[i]` | `{"skill":{…},"count":1,"sources":["talent","display"]}` | owner loadout with the non-default skill index `i` (one entry per other selectable owner skill): the token skill of that slot (伺夜's wolves, 缪尔赛思's 流形, 凛御银灰's eagles…), the count and how the chess then produces it (`sources` may be `[]`: 风丸 S1 makes no 纸偶; 赫默 / 巫恋 S1 only `display` ⇒ no hand piece). The sim resolves them for an owner loadout: `simdata getToken(id, ownerChessId, loadout)` → `def.sources` / `def.count` |
| `variants[o].byModule[m]` | `{"stats":{…},"immunities":{…},"trait":{…},"talents":[…]}` | golden owner with another module `m` or `'none'`: the token as that module makes it (module `tokenAttributeBlackboard`, `isToken` trait/talent parts) |
| `assets` | `{"avatar":"token_10028_vigil_wolf","spine":"token_10028_vigil_wolf"}` | |
@@ -574,7 +602,7 @@ Glyph legend (`rows`):
`chess 266 (112 visible; 283 selectable skills over the visible chess, 184 module choices over 129 goldens; 74 PRESET / 55 NORMAL / 4 DIY base chess)`, `bonds 23`, `garrisons 249 (43 effect keys)`, `items 115`, `bands 40`, `effects 361`,
`enemies 249`, `factions 67 entries`, `waves 38`, `stages 11 (8 active)`, `bosses 10`, `tokens 22`, `choice events 109`,
`bounty cards 129`, `tactic cards 43`, `backups: 88 units (17 stand-ins, 71 owned-6★ picks; 256 forms), 38 自选 summons, 4 DIY slots, 15 / 9 prototype picks (tier 5 / 6), 71 owned-6★ picks (7 collab operators excluded)`.
`bounty cards 129`, `tactic cards 43`, `backups: 88 units (17 stand-ins, 71 owned-6★ picks; 256 forms), 38 自选 summons, 4 DIY slots, 15 / 9 prototype picks (tier 5 / 6), 71 owned-6★ picks (7 collab operators excluded)`, potential (§2.3): `256 chess records, 219 forms / variants with potDown; 442 + 401 + 19 chained talent entries (chess, backups, tokens)`.
## 17. Integrity guarantees (checked by the builder and `test/data.test.js`)
@@ -623,8 +651,8 @@ server/match/player/diy.js, docs/META.md §3). The rules in the data (activity_t
- A stand-in keeps the chess's bonds, 特质, tier, price, merge and status (the elite form uses the same backup at the
elite row) and fights as `backup.charId` at that status with skill `backup.skillIndex` (fixed by the chess: the same
character takes S2 on one chess and S3 on another), module `backup.uniEquipId` (null at tiers 3–4 and for every 4★, so
even their elite form has none) and the row's potential (`potRank` 0 on all 55) — moot: every unit form is built at
full potential like a chess (the owner's decision of 2026-10-07), and the 17 原型干员 have no potential ranks.
even their elite form has none) and the row's potential (`potRank` 0 on all 55) — moot: the 17 原型干员 have no
potential ranks, and a stand-in takes neither the player's potential nor the 自持有 练度 (0.2.2).
`units[charId]` — first the 17 stand-ins, then the 71 owned-6★ 自选 picks (`diy.ownedPool`); no unit for a PRESET or DIY
chess:
@@ -638,7 +666,7 @@ chess:
| `forms[statusKey]` | keys `"2/1/4/0"`, `"2/60/7/1"`, `"2/60/7/3"` | the character at every status it fights at — of the chess it stands in for and of the DIY slots it may fill (`statusKey(status)` = `phase/level/skillLevel/equipLevel`): 3 forms per 6★ (an owned pick: exactly the three DIY slot statuses — E2 Lv1 skill rank 4 without a module, E2 Lv60 rank 7 with every module at stage 1 and at stage 3), 2 per 4★ |
A **form** holds the operator fields of a chess record with **nothing selected**: `status`; `stats`, `trait`, `talents`
**without** a module, at full potential as on a chess; `immunities`, `rangeId`, `rangeGrid`, `dmgType`, `attackKind`, `projectile`, `canHitFly`,
**without** a module, at full potential as on a chess (`potDown` / chained talents for the lower ones, §2.3); `immunities`, `rangeId`, `rangeGrid`, `dmgType`, `attackKind`, `projectile`, `canHitFly`,
`targetPriority`; `skills[]` — every skill unlocked at the status, at its skill level, `trigger` resolved per skill, no
`isDefault`; `displayTokens` / `tokens` (summons — none of the 17 stand-ins has one; 27 of the owned picks do, their
records in `tokens` below); at `equipLevel > 0` `modules[]` (§2.2 shape without `isDefault`). `buildUnitForm` uses buildChess's helpers and rules, and the build fails when `buildUnitForm` +
@@ -705,5 +733,6 @@ loadout. No id is also a tokens.json record.
Not in this data: a player's ownership roster and 自选 picks (browser settings sent with `room.ownership` / `room.diy`),
the DIY stock (per player in the match, `PlayerState.diyStock`: `config.economy.poolCopies` of the slot's tier — no excel
field), 助战 borrows (`borrowCount` 20), the owned-operator training bonus (`prepareStateDict`), a per-player potential (every
form is built at full potential, as every chess: the owner's decision of 2026-10-07).
field), 助战 borrows (`borrowCount` 20), and a player's potential / 练度 per operator (the 干员调配 settings sent with
`room.loadout`; the forms are built at full potential and carry the lower ranks, §2.3; 练度 = effects.json
`aceffect_char_1…4`).
+8 -8
View File
@@ -10,7 +10,7 @@
| 服务器 CPU | 战斗在各玩家浏览器里模拟(DESIGN §14),服务器只负责回合、经济和校验:**每个房间每个作战回合约 1 ms CPU**。AI 队友 / 掉线玩家的战场由服务器模拟:作战开始时 3 个 AI 战场在开发机上约 0.2–0.5 s CPU,小主机上可能要几秒(分成 8 ms 小片执行,不会卡住其他房间)。`SP_VERIFY=all` 会复算每个真人战场,CPU 明显增加,小主机建议保持 `off` 或 `sample`。 |
| 服务器内存 | 空闲约 100 MB,每个进行中的对局再增加几 MB。 |
| 网络 | 4 人对局中服务器每回合下行约 0.25 MB(DESIGN §14 实测)。首次进入游戏时浏览器要从主机下载所需的图片 / Spine 模型 / 音频(按需加载,之后走浏览器缓存),公网隧道带宽小时第一次会慢一些。 |
| 磁盘 | 素材约 460 MB(`public/assets`)+ 依赖约 125 MB(`node_modules`;整合包只带运行依赖,约 65 MB);可选的本地提取约 40 MB(`.venv-extract`)+ 70 MB 贴图(见第 6 节)。完整包解压后约 625 MB。 |
| 磁盘 | 素材约 550 MB(`public/assets`,含中文约 65 MB、日文约 85 MB 两套干员语音)+ 依赖约 125 MB(`node_modules`;整合包只带运行依赖,约 65 MB);可选的本地提取约 40 MB(`.venv-extract`)+ 70 MB 贴图(见第 6 节)。完整包解压后约 710 MB。 |
| 玩家设备 | 支持 WebGL 的现代浏览器(Chrome / Edge / Firefox / Safari 最新版),电脑或手机平板(横屏)。老旧设备可在设置里调低画质或访问 `/?board=2d`。 |
服务器**无状态**:房间和对局只存在内存里,没有数据库和存档,**不需要备份**。重启服务器会结束正在进行的对局(包括断线后本可在 24 小时内回来继续的独立模拟)。
@@ -26,13 +26,13 @@
```
装完**关闭并重新打开**终端,`node -v` 应显示 v22 或更高(winget 的 LTS 目前是 v24.x,同样可用)。没有 winget 时从 <https://nodejs.org/zh-cn/download> 和 <https://git-scm.com/download/win> 下载安装。
2. 下载,三选一。建议放在一个固定、短、**不在 OneDrive 同步范围内**的目录,例如 `C:\Stronghold-Protocol`:
- **完整包(推荐)**:在仓库的 [Releases](https://github.com/sganggs/Stronghold-Protocol/releases) 页面下载最新版本的 `Stronghold-Protocol-v<版本>.zip`(约 430 MB,解压后约 625 MB;已含运行依赖、前端库和全部素材,包括官方 3D 棋盘等本地客户端素材),解压后把里面的 `Stronghold-Protocol` 文件夹放到上述位置。不需要 Git,首次启动也不用再下载素材。素材版权归上海鹰角网络 / Yostar,仅限非商业使用,见 [NOTICE.md](../NOTICE.md)。
- **精简包**:同一页面的 `Stronghold-Protocol-v<版本>-lite.zip`(约 22 MB)。代码、运行依赖和前端库与完整包相同,但不带素材:美术、Spine 模型、音频、字体、表情和「玩法说明」教程图在首次启动时由 setup 从公开镜像下载(约 460 MB,显示进度,可中断续传;镜像设置见下面的「国内镜像下载」)。官方 3D 棋盘等本地客户端素材需要用本机客户端提取,或从同一版本的完整包复制(第 6 节)。适合下载大文件不方便、或想先下一个小包的情况;放置方式同完整包。
- **完整包(推荐)**:在仓库的 [Releases](https://github.com/sganggs/Stronghold-Protocol/releases) 页面下载最新版本的 `Stronghold-Protocol-v<版本>.zip`(约 505 MB,解压后约 710 MB;已含运行依赖、前端库和全部素材,包括中文、日文两套干员语音和官方 3D 棋盘等本地客户端素材),解压后把里面的 `Stronghold-Protocol` 文件夹放到上述位置。不需要 Git,首次启动也不用再下载素材。素材版权归上海鹰角网络 / Yostar,仅限非商业使用,见 [NOTICE.md](../NOTICE.md)。
- **精简包**:同一页面的 `Stronghold-Protocol-v<版本>-lite.zip`(约 22 MB)。代码、运行依赖和前端库与完整包相同,但不带素材:美术、Spine 模型、音频(含两套干员语音)、字体、表情和「玩法说明」教程图在首次启动时由 setup 从公开镜像下载(约 550 MB,显示进度,可中断续传;镜像设置见下面的「国内镜像下载」)。官方 3D 棋盘等本地客户端素材需要用本机客户端提取,或从同一版本的完整包复制(第 6 节)。适合下载大文件不方便、或想先下一个小包的情况;放置方式同完整包。
- **源码**:
```powershell
git clone https://github.com/sganggs/Stronghold-Protocol.git C:\Stronghold-Protocol
```
3. 双击 `C:\Stronghold-Protocol\scripts\start-windows.bat`。首次会:安装依赖(`npm ci`;整合包已含,跳过)→ 复制前端库(整合包已含,跳过)→ 下载约 460 MB 素材(完整包已含,跳过;精简包和源码在这一步下载,显示进度,中断后再次启动会续传)→ 若检测到本机的明日方舟客户端,询问是否提取官方贴图(可跳过)→ 启动服务器并打开浏览器。
3. 双击 `C:\Stronghold-Protocol\scripts\start-windows.bat`。首次会:安装依赖(`npm ci`;整合包已含,跳过)→ 复制前端库(整合包已含,跳过)→ 下载约 550 MB 素材(完整包已含,跳过;精简包和源码在这一步下载,显示进度,中断后再次启动会续传)→ 若检测到本机的明日方舟客户端,询问是否提取官方贴图(可跳过)→ 启动服务器并打开浏览器。
4. 窗口里会打印朋友可用的地址,例如 `http://192.168.1.23:3000`。用另一台设备打开它确认能进入。关闭窗口即停止服务器。
等价的手动命令:`npm ci`、`node tools/setup.mjs`、`npm start`。
@@ -106,7 +106,7 @@ powershell -ExecutionPolicy Bypass -File scripts\install-service-windows.ps1
winget install NSSM.NSSM # 或从 https://nssm.cc 下载
nssm install StrongholdProtocol "C:\Program Files\nodejs\node.exe" server\index.js
nssm set StrongholdProtocol AppDirectory C:\Stronghold-Protocol
nssm set StrongholdProtocol AppEnvironmentExtra PORT=3000 HOST=0.0.0.0
nssm set StrongholdProtocol AppEnvironmentExtra PORT=3000 HOST=::
nssm set StrongholdProtocol AppStdout C:\Stronghold-Protocol\logs\server.log
nssm set StrongholdProtocol AppStderr C:\Stronghold-Protocol\logs\server.log
nssm start StrongholdProtocol
@@ -226,7 +226,7 @@ https / wss 说明:页面通过 https 打开时客户端自动连接 `wss://
## 3. Docker
```bash
# A) 构建时下载素材(需要联网,约 460 MB)
# A) 构建时下载素材(需要联网,约 550 MB)
docker build -t stronghold-protocol --build-arg FETCH_ASSETS=1 .
docker run -d --name stronghold -p 3000:3000 --restart unless-stopped stronghold-protocol
@@ -266,7 +266,7 @@ services:
[Service]
WorkingDirectory=/opt/Stronghold-Protocol
ExecStart=/usr/bin/node server/index.js
Environment=PORT=3000 HOST=0.0.0.0
Environment=PORT=3000 HOST=::
Restart=always
RestartSec=5
User=stronghold
@@ -323,7 +323,7 @@ npm run package:lite -- --out <目录> # 精简包 Stronghold-Protocol-v<版
npm run package -- --update --from <旧版本的完整包>[,<…>] --out <目录> # 更新包 Stronghold-Protocol-v<版本>-update.zip
```
- **打进去的**:`git ls-files` 里的 `server/`、`shared/`、`data/`、`public/`(不含 `public/dev/`)、`packs/`(随仓库提交的内容包;只在本机安装、没提交的不打进去)、启动脚本、玩家会运行的工具(setup、vendor、fetch-assets 与 `tools/assets/`、doctor,以及 setup 调用的 `tools/local-extract/` 和 `crop-board-atlas.mjs`)、服务器和 fetch-assets 读取的 4 张研究数据表(`docs/research/` 的 `03-operators`、`05-enemies`、`05-maps`、`07-assets` 四个 JSON)、`package.json` / `package-lock.json`、许可证与说明(`LICENSE`、`NOTICE.md`、`THIRD-PARTY-NOTICES.md`、`README.md`、`CHANGELOG.md`)、`docs/PLAYING.md` 和本文;然后在临时目录里生成 `packs/index.json`(打进去的语言包和内容包的列表,供纯静态托管使用;服务器自己会实时列出,见 [PACKS.md](PACKS.md)),再 `npm ci --omit=dev` 装上运行依赖和 `public/vendor`。完整包再加上 `data/assets.json` 列出的素材、`public/fonts`,以及本地提取的 `public/assets/local/` 和 `data/local-assets.json`。磁盘上有、清单却没列出的文件不打进去(例如 0.2.0 移出自选的焰狐龙梓兰的旧素材)。
- **打进去的**:`git ls-files` 里的 `server/`、`shared/`、`data/`、`public/`(不含 `public/dev/`)、`packs/`(随仓库提交的内容包;只在本机安装、没提交的不打进去)、启动脚本、玩家会运行的工具(setup、vendor、fetch-assets 与 `tools/assets/`、doctor,以及 setup 调用的 `tools/local-extract/` 和 `crop-board-atlas.mjs`)、服务器和 fetch-assets 读取的 4 张研究数据表(`docs/research/` 的 `03-operators`、`05-enemies`、`05-maps`、`07-assets` 四个 JSON)、`package.json` / `package-lock.json`、许可证与说明(`LICENSE`、`NOTICE.md`、`THIRD-PARTY-NOTICES.md`、`README.md`、`CHANGELOG.md`)、`docs/PLAYING.md` 和本文;然后在临时目录里生成 `packs/index.json`(打进去的语言包和内容包的列表,供纯静态托管使用;服务器自己会实时列出,见 [PACKS.md](PACKS.md)),再 `npm ci --omit=dev` 装上运行依赖和 `public/vendor`。完整包再加上 `data/assets.json` 列出的素材、`public/fonts`,以及本地提取的 `public/assets/local/` 和 `data/local-assets.json`。磁盘上有、清单却没列出的文件不打进去(例如 0.2.0 移出自选的焰狐龙梓兰的旧素材)。日文干员语音(`audio.voiceJp`,`public/assets/audio/voice/jp/` 的 2674 个文件,约 85 MB,zip 后约 76 MB)默认也打进完整包;`tools/package.mjs` 里的开关 `FULL_ZIP_JP_VOICE` 改成 `false` 时,完整包(以及由它比较出的更新包)不带这些文件:玩家首次启动时 setup 会像精简包那样下载它们,下载完成前选「日本語」会播中文语音,更新包也不会删除玩家已有的日文语音。
- **不打进去的**:`test/`、维护用的工具(数据构建、golden、botbench、i18n、导入检查、本工具等)、`scripts/make-windows-bundle.mjs`(Windows 便携包,见 [WINDOWS.md](WINDOWS.md))、其他文档、研究笔记和 `docs/img/`、`handoff/`、`.github/`、`types/`、lint / 编辑器 / Docker 配置。和 0.1.x 的整树打包(全部跟踪文件加上 `public/assets` 的全部内容)相比,0.2.0 的完整包少了约 640 个文件、解压后小约 26 MB,zip 小约 8 MB。
- **打包前的检查**(`--dry-run` 也全部做一遍):拒绝名单(`pv`、`review`、`.cache`、`.claude`、`.git`、`logs`、`.env`、`scripts/service.env.cmd`、`handoff`、`test` 等);每个打进去的模块的相对导入、玩家用的 npm 脚本(start / setup / doctor / launch / postinstall / vendor / assets)都指向包里的文件;完整包里 `data/assets.json` 和 `data/local-assets.json` 列出的文件都在(缺了先运行 `node tools/fetch-assets.mjs`);没有只差大小写的两个路径;包里的文件(二进制素材也查)不含个人目录路径(`/Users/…`、`C:\Users\…`、`/home/…`)或本机的账户名(运行时从系统读取;`SP_PACKAGE_SCAN_NAMES=a,b` 可以再加名字);打进去的已跟踪文件没有未提交的改动(重新生成的 `data/assets.json` 要先提交)。有任何问题都会列出原因、以非零状态结束,不写 zip;正式打包时还会核对临时目录里的文件和计划完全一致。
- **MANIFEST.json**:三种包的根目录都有(`npm ci` 之后写入):除素材(`public/assets/`、`public/fonts/`、`data/assets.json`、`data/local-assets.json`,归 setup 管)以外每个文件的大小和 sha256,同一版本的三种包内容相同。`npm run doctor` 和更新包的启动检查(`server/update.js`)用它核对安装。
+2 -1
View File
@@ -4,7 +4,7 @@ This is the **single source of truth** for every implementer. Research lives in
Language: player-facing text is **Simplified Chinese** by default, with an English switch since 0.2.0 (§25.2, docs/I18N.md: UI strings through `t('中文')`, game texts from the official Chinese data or the official EN client's). Code, comments and identifiers are English.
Versions: the first public release was **0.1.0** (2026-10-02, the state of §0–§20.15); the releases after it add the player feedback and GitHub reports — 0.1.1 §21, 0.1.2 §22, 0.1.3 §23, 0.1.4 §24 — and **0.2.0** (2026-10-07; `CHANGELOG.md`) the maintainability refactor, two languages, 补位, 自选编队 and their fidelity work — §25 — and **0.2.1** (2026-10-07; `package.json`, `shared/constants.js APP_VERSION`) the 联防 battlefield restored, full potential and the GitHub fixes after it — §26 — and is the state described by this document. The labels v1 / v2 / v2.1–v2.5.2 in §0, §14–§20 and in the BALANCE / SIM comparisons name the design generations and the private playtest builds that came before it; they are kept as history.
Versions: the first public release was **0.1.0** (2026-10-02, the state of §0–§20.15); the releases after it add the player feedback and GitHub reports — 0.1.1 §21, 0.1.2 §22, 0.1.3 §23, 0.1.4 §24 — and **0.2.0** (2026-10-07; `CHANGELOG.md`) the maintainability refactor, two languages, 补位, 自选编队 and their fidelity work — §25 — **0.2.1** (2026-10-07) the 联防 battlefield restored, full potential and the GitHub fixes after it — §26 — and **0.2.2** (2026-10-09; `package.json`, `shared/constants.js APP_VERSION`) per-operator 潜能 / 练度, Japanese voices, the statistics page, 失衡, whole-frame attack timing and the GitHub fixes after 0.2.1 — §27, the state described by this document. The labels v1 / v2 / v2.1–v2.5.2 in §0, §14–§20 and in the BALANCE / SIM comparisons name the design generations and the private playtest builds that came before it; they are kept as history.
---
@@ -33,3 +33,4 @@ normative lines it rewrote).
| §24 | [history/0.1.4.md](history/0.1.4.md) | 0.1.4 — community reports after 0.1.3 |
| §25 | [history/0.2.0.md](history/0.2.0.md) | 0.2.0 |
| §26 | [history/0.2.1.md](history/0.2.1.md) | 0.2.1 — after the 0.2.0 release |
| §27 | [history/0.2.2.md](history/0.2.2.md) | 0.2.2 — after the 0.2.1 release (2026-10-09) |
+73 -23
View File
@@ -41,6 +41,14 @@ server/match/
## 1. Match flow
The INFO_CHECK page supports a host-proposed setup reroll (remake convenience proposal, GitHub #77 / PR #408).
Every human must agree; AI and spectators do not vote, and one human rerolls immediately. Voting pauses the
confirmation deadline and queued all-ready transition. Refusal, host cancellation, disconnect or departure restores
the previous remaining time and ready states. Success redraws every random setup field and rebuilds shared / DIY
stock, resets confirmation and its full deadline, and preserves the room, personal settings (including 潜能 / 练度)
and the AI-picks-last option. Single-human confirmation remains untimed. `match/setupVote.js` owns the state;
`Lobby.rerollSetup` checks host permission. The wire fields and stale-request checks are in DESIGN §8.1.
```
LOBBY → INFO_CHECK (co-op 25 s; solo and single-human matches untimed; all humans confirmed ⇒ next) → BAND_DRAFT → BATTLE_CHECK (3 s)
→ for r = 1..lastRound (+ hidden):
@@ -175,18 +183,18 @@ A `choice:<effectId>` registry handler overrides the default application (§2.4)
### 1.3 Disconnects, AI takeover
* Disconnected human: the seat keeps playing its last lineup; drafts auto-resolve at their deadlines, prep auto-readies at
the deadline (the temp pieces due at that prep sold/destroyed). Nothing is bought for them. A battle the human was authority of goes to the server
the deadline (an open 教鞭 choice is picked at random first, §2.5; then the temp pieces due at that prep are sold/destroyed). Nothing is bought for them. A battle the human was authority of goes to the server
(normal / 联防: re-simulated from t = 0) or, on a boss field, to the partner's replica (DESIGN §14). The session stays
resumable for 10 min (net.js `reconnectWindowMs`); a **solo** run's for the official `constants.singleReconnectTime`
(86 400 s = 24 h, lobby.js `soloResumeWindowMs`) — nothing in a solo run is timed, so it simply waits. `onReconnect`
resends `m.public`, `m.private` and the `b.start` of the field the player is on / watching.
* `g.autoplay { on }` ("AI 托管"): the bot plays the seat (drafts, buying, placement, ready) until turned off.
* `g.autoplay { on }` ("AI 托管"): the bot plays the seat (drafts, buying, placement, an open 教鞭 choice, ready) until turned off.
* `onLeave` (quit / reconnect window expired): 中途退出 counts as elimination (research 00-INDEX §3, 01 §9, 06 §7 /
§10.3): every copy the seat holds returns to the shared pool at once; the seat leaves the round loop and the Final
Assault pairing (re-planned when it quits before the boss fight) and the boss pool (bloodPoint per player alive when
the fight starts — a departed seat no longer counts, DESIGN §25.13.4); its
running normal battle is force-ended; a pending band pick
becomes the default band and a 机变 turn passes on. Status `left`, LP 0, rounds passed = the rounds it had survived.
becomes the default band, a 机变 turn passes on and an open 教鞭 choice goes with the seat (`eliminate`). Status `left`, LP 0, rounds passed = the rounds it had survived.
When no human is left at all the match ends immediately (`reason: 'abandoned'`); when only eliminated spectators are
left it ends as `'eliminated'`.
@@ -263,7 +271,8 @@ like any owned unit — AI 托管 too; the buy loop's bench shed, which sells by
never sells a piece gained since the bot's last prep ended, `rememberOwned`), 身份牌 / 通讯机 / 寻呼模块 on a focus
member; 画卷 copies the most
valuable deployed operator; 教鞭 / “神秘顾客” are used after a perfect battle and otherwise kept in the hand — a bot drops
a kept one only when it needs the hand slot (“神秘顾客” then pays its fund), a human's seat under AI 托管 never. 机变: a
a kept one only when it needs the hand slot (“神秘顾客” then pays its fund), a human's seat under AI 托管 never; 教鞭's three
cards are scored like a 机变 bounty draft (§2.5). 机变: a
bounty card is scored by its expected payout minus its expected leaks × the value of an LP (2 + 20 / LP), the kill
chance from the exposure model below for that one enemy against the own board as it stands (its HP × the round's
multiplier, DEF / RES, speed, route kind) — an expected-value comparison: it prefers the card with the best payout
@@ -275,7 +284,9 @@ its enemies; an exposure model (time on each tile × DPS against the round's arm
unit's cover is the range it is deployed with under the player's loadout, `rangeRec` = `attackRangeGrid`, the grid the
server's `summonRange` and the card use —, blocker hold time, anti-air only on flying routes) is maximized greedily
(blockers, then damage dealers, then healers) over the server's deploy map (no 深水区, PlayerState.deployMap); a
tactician's 援军 goes on a tile of the tactician's attack range (`PlayerState.summonRange`, the server's own rule; player
blocker whose range is its own tile only (range 0-1, the one grid (0,0): 角峰, 古米, 泡泡, 折桠, 菲莱, 蛇屠箱, 塞雷娅, 余) takes a free
tile of the enemy road first — it blocks and hits nothing off it — and only when the road has none free any legal tile (0.2.2, PR #339 by
@IceCodeNew); a tactician's 援军 goes on a tile of the tactician's attack range (`PlayerState.summonRange`, the server's own rule; player
report #9 after 0.1.0), and a tile the server's `g.move` refuses is skipped for the next best one (the planner reads
`board.js legalTiles` on the deploy map; `g.move` is the judge). Boss
rounds: the player's boss-field template (`Match.bossWaves`) is mapped onto the own board (rows −7, the right player
@@ -425,6 +436,7 @@ Writes (all validated, never throw on bad input, never make funds / pools negati
| `setDeviceActive(alias, on)` / `setTileOverride(r, c, 'melee'\|'ranged'\|'none')` | terrain changes of this player's board (legality + battle input `deviceOverrides`) |
| `addEffect(ref)` / `removeEffect(id)` / `setEffectCounter(id, v)` | EffectRefs `{ id, key?, name, desc, iconKind, iconId, counter?, battle=true, params?, data?, hidden? }` — shown in `m.private.effects`, passed to battles as `playerEffects` when `battle` |
| `addBounty(card)` | a bounty (choices.json cards.bounty shape) on the next battles |
| `offerBountyChoice(cards, sourceItemId)` | 教鞭 only: open a PERSONAL choice of up to three of `cards` (cards.bounty entries) for this player — shuffled once with the meta rng, kept in `m.private.personalChoice` until the owner confirms one (`g.choice { idx, choiceId }`) or the prep's deadline / an AI seat picks (§2.5); returns the intent result (`{ ok }`, or `{ error, detail }` for a phase that is not PREP, a Ready player, a choice already open or no card — before any rng draw or id, so the Art stays in the hand) |
| `toast(text, kind)` / `ticker(text)` / `giftTicker(fromName, chessId)` | messages |
| `teammates()` / `player(playerId)` | ctx objects of other alive players (team effects) |
@@ -458,20 +470,47 @@ bilibili BV1vzyVBuEN9 ≈ 8:24 / BV1Qkw1zMEoR ≈ 7:25, pointed out in PR #2 —
is not consumed: PRTS 下半 记录 备注 "生效时,原干员销毁,获得一名高一阶的随机初始干员(最高六阶)", PRTS 卫戍协议/帮助 "佩戴的
装备无法手动卸除,在失去该干员(干员出售、销毁、合并等)…时自动卸除", players re-inject it every round; player feedback after
0.1.0), 画卷 (copy the operator in range with its
items), 教鞭 / “神秘顾客” (a random bounty is added).
items — the copied items arrive unequipped in the hand, PRTS 画卷 备注 "获得的装备为未装备状态"), 教鞭 (a personal choice of three 战术特训 cards), “神秘顾客” (a random bounty is added).
**教鞭 / “神秘顾客” stay a random bounty (deliberate).** The official Arts open a personal 悬赏 choice
("选择一项(特殊)悬赏任务进行挑战", `choice_event hunter_band_1`). The server applies a random bounty instead: content
(server/sim/content/items/meta.js) draws 3 cards and takes one at random — 教鞭 from the 20 战术特训 cards (payout
`perfect`: extra enemies, the card's coins when the own phase is perfect; PRTS 下半 记录 §法术 教鞭 "于3个战术特训的悬赏
任务中选择一项", §机变阶段 "※以下悬赏任务仅由法术教鞭生成", 杜宾 加练! "若自身战斗完美作战可获得资金"; user playtest #6
item 4 review), “神秘顾客” (granted by nothing in act2) from the band-bounty family `enemyeffect_b_*` [ASSUMED] — each
limited to enemies the mode can field (the built-in fallback: a random tier ≤ II bounty of `cards.bounty`), and adds it
with `ctx.addBounty`. A personal choice overlay would need its own phase / protocol message outside SP_DRAFT (a
second, simultaneous draft in co-op, with timers and AI takeover) for a rarely used Art, while the random pick keeps the
risk / reward the item is about. The
bounty then behaves like any other (next battles, 联防 payouts, Final Assault spawns). 神秘顾客's destroy clause (+1
fund, the Art passes to the next alive player) is content too (`onDestroy`).
**教鞭 offers a personal choice of three 战术特训 cards (0.2.2); “神秘顾客” stays a random bounty.** The official Arts open a
personal 悬赏 choice ("选择一项(特殊)悬赏任务进行挑战", `choice_event hunter_band_1`, choiceType `PERSONAL_CHOOSE`). 教鞭
(PRTS 下半 记录 §法术 教鞭 "使用后销毁,于3个战术特训的悬赏任务中选择一项"; §机变阶段 "※以下悬赏任务仅由法术教鞭生成"; 杜宾
加练! "<教鞭>:使用后为下场战斗添加额外敌人,若自身战斗完美作战可获得资金"; user playtest #6 item 4 review) offers three of
the 20 战术特训 cards (payout `perfect`: extra enemies, the card's coins when the own phase is perfect), each limited to
enemies the mode can field. Until 0.2.1 the server took one of the three at random — a deliberate simplification: a
personal choice looked like it needed a phase of its own, a protocol message outside SP_DRAFT, a timer and an AI
takeover for a rarely used Art. The owner decided on 2026-10-08 to follow the official choice (PR #353 by @Sukvii), and
it fits in the existing parts:
* **Offer.** Content (server/sim/content/items/meta.js) calls `ctx.offerBountyChoice(cards, itemId)` (`Match.offerBountyChoice`,
match/spDraft.js): the eligible cards are shuffled once with the meta rng and up to three different ones are kept in
`ps.personalChoice { id, round, sourceItemId, cards }`. The Art is used up (and counts for the two per round) when the
offer is made; nothing is applied yet. Refused — the Art stays in the hand, no rng draw, no id — outside the player's
PREP, for a Ready player, while an offer is already open and when no card is left to offer.
* **Private.** Only the owner sees it: `m.private.personalChoice` (re-sent on a reconnect or a refresh, so the same cards
and id come back); never `m.public`, a scout's view or a spectator. Players have offers of their own, at the same time.
* **Confirm.** `g.choice { idx, choiceId }` (`Match.pickPersonalChoice`) validates the owner, the id, the round and the
index, then adds the card with `addBounty` and closes the offer; a request without a `choiceId` is the public 机变 draft
as ever, and a stale, foreign or repeated id never falls through to it (`BAD_TARGET`). The client shows the overlay of
the 机变 draft (`ChoiceOverlay`, `personal`: the same two taps, no order, no turns) over a locked prep.
* **No phase, no timer.** The offer lives inside PREP. Ready is refused while it is open (`setReady`: `BAD_TARGET`;
`m.private.canReady` false), and the prep's own deadline resolves it: in a timed co-op prep `prepDeadline` takes one of
the cards at random (the meta rng, like a 机变 turn that runs out) before the temp pieces and Ready; a solo or
single-human prep is untimed and has no timer for it, and a disconnected player waits for the deadline like for any
other prep action. A seat the engine plays — an AI teammate or a human under AI 托管 — picks like the bots do; a seat that
left is eliminated (`onLeave` → `eliminate`), which drops its open offer: nobody picks for it.
* **The bots.** `bot.js` scores the three cards like a 机变 bounty draft (`botPickCard` → `bountyScore`: the expected
payout minus the expected leaks × the value of an LP, against the own board; the bots' seeded rng only breaks ties), so
the choice is deterministic for a seed: at the start of the bot's prep (an offer left by a human who turned 托管 on),
right after it cast the Art, and before Ready. When a step of the bot's prep fails, the scheduler's fallback readies the
seat with the random pick instead — no seat is ever left un-ready by an offer.
* The chosen bounty is a bounty like any other: next battles (a multi-round card lasts two, §1.2), 联防 payouts, the Final
Assault / Hidden Core spawns — and in the boss rounds its perfect-battle coins (§3 Waves).
“神秘顾客” (granted by nothing in act2, no PRTS list of its cards) remains random: three cards are drawn from the band-bounty
family `enemyeffect_b_*` [ASSUMED], limited to enemies the mode can field, and one is added with `ctx.addBounty` (the
built-in fallback: a random tier ≤ II bounty of `cards.bounty`). Its destroy clause (+1 fund, the Art passes to the next
alive player) is content too (`onDestroy`).
EffectRefs: `effect:builtin_round_coin`, `effect:builtin_gift`, `effect:builtin_next_buy_golden_item` (整备),
`effect:builtin_next_buy_elite` (升华). An eliminated player gets no dispatch, except an EffectRef whose handler sets
`afterElimination: true` (its `onRoundStart` still runs: `EffectDispatcher.dispatchEliminated`, called by
@@ -508,7 +547,7 @@ Any `choice:` handler whose EffectRef reuses its own key must guard like this (o
* **Shop**: `shopSlots[level]` chess slots + item slot(s), copy-weighted rolls over remaining pool copies of unbanned,
visible chess with tier ≤ level; item slot: tier by the same shares, uniform item within the tier. Level-up price =
base per mode, −1 each round start (floor 0), reset to the next base after upgrading; an upgrade opens the new level's
extra slots at once with new cards (the cards shown stay); `MAX_LEVEL` at 6. One freeze
extra slots at once, empty (the cards shown stay; the empty slots fill on the next roll); `MAX_LEVEL` at 6. One freeze
toggle freezes every unsold slot until the next round start; a manual refresh rerolls everything (new slots stay
frozen). Unfrozen slots are cleared at combat start. Slot positions are stable (frozen slots keep their index).
* **Pool**: copies 12/14/18/16/8/5 (缪尔赛思 4); a normal piece holds 1 copy, an elite 3; displays never reserve copies;
@@ -619,7 +658,16 @@ Any `choice:` handler whose EffectRef reuses its own key must guard like this (o
`enemyScale[r]` × the tuning layer §3.1, bounties, boss templates, 联防 routing). Bounties with battles left (every one
that lasts more than one battle; an official multi-round card lasts two, §1.2) also spawn in the Final Assault /
Hidden Core, on the owner's half of the boss field (a route ending at its goal); the boss battle then uses up one of
the bounty's battles and its kill coins go to pending funds.
the bounty's battles and its kill coins go to pending funds — with, for every perfect-payout card (战术特训), its coins
as after a normal battle ("若各自行动阶段就达成完美作战,获得N资金"): once per player, when the team won (the first end the
server registered: the leader's pool empty), the result is a real one (not the stand-in of a field that never
reported) and the player's own field let no counted enemy through [ASSUMED: the text has no boss-battle exception, and
the card's enemies do come in the boss battle] — and, for a field a client reported (the boss path never re-simulates
it), the result agrees with what its authority reported while it fought: LP it asked the team to pay must have been
split in `b.progress.leaksBy` (per player, the enemy leaks; the rest is the leader's own "扣除目标生命" effects), and a
player's leaked list must show at least its reported leak LP (`Match._bossLeaksAgree`), so a result cannot hide a charged
leak or hand it to the other seat; a server-run field is the truth itself. They are spent in the Hidden Core's prep like
the kill coins.
* **Combat time limit**: data `combatTimeLimit` (the level's `maxPlayTime`) counts REAL seconds of the forced 2×
battle; the Battle / 联防 limit is `gd.combatTimeLimit(r)` = 2 × that in game seconds (`config.combatTimeScale`,
default 2). Read as game seconds the rounds' own spawn schedules would not fit (R2's last flyer spawns at 43 s of
@@ -826,9 +874,11 @@ round was over. The official 1 s `broadcastBeginDelay` is not modelled.
units > active bond > Σ active layers > standing > seat; the ranks marked 存疑 in PRTS). Unite enemies re-enter with
their original stats on the official 联防 spawn timing (waves.js `buildUniteWave`, DATA.md §15 #22).
* A manual refresh while frozen keeps the new slots frozen until the next round start.
* A level-up opens the new level's extra slots at once, each with a new card drawn at the new level, and keeps the
cards shown (0.2.0, community report item 19; the official tutorial's 「升级后将出现更多的商品栏位」); the new card
follows the freeze toggle like a manual refresh's cards.
* A level-up opens the new level's extra slots at once — empty, with no card drawn into them (official footage, GitHub
#332: bilibili BV1AXwuzdEys 1:39, a 1→2 upgrade with the new slot visible and empty; the official texts only ever say
「升级后将出现更多的商品栏位」/「增加刷新栏位」) — and keeps the cards shown. The empty slots fill on the next roll (a manual
refresh or the round start), like every unfrozen slot; the shop draws an empty slot as a bare frame, never 已招募. 0.2.0
drew a card into each new slot (community report item 19, one uncorroborated remark): reverted in 0.2.2.
* Buying a second copy of an equipped normal item merges into the golden item in the hand (not equipped).
* Promotions by effects (升华, 博士投影) keep the equipment; merges return it; 突变细胞's transformation returns it (the
cell included) before its new operator is gained into the 整备区 — the carrier's tile is left empty (official footage,
+46 -22
View File
@@ -6,6 +6,8 @@
界面语言:默认中文;标题画面右上角或「设置」里可切换 **中文 / English / 日本語 / 한국어 / 繁體中文**(会记住选择,不用刷新)。日语、韩语、繁体中文的游戏文本取自官方日服 / 韩服 / 台服数据,界面文字暂未翻译——日语和韩语显示英文界面,繁体中文显示简体中文界面,欢迎社区补全(docs/I18N.md「Adding a language」)。英文的干员、技能、盟约、装备、敌人等游戏文本取自官方英文服数据,界面文字全部有英文;切到英文后仍显示中文的只有玩家名字(人机的名字也是)、多轮悬赏改写过的「接下来两场作战」说明,以及官方英文服还没有的游戏文本(部分自选干员和较新的模组);「玩法说明」的教程图是官方的中文图片,标题有英文(docs/I18N.md)。
干员语音(官方台词):「设置」里的「干员语音」调音量,「语音语言」选 **中文 / 日本語**(默认中文,和界面语言无关,会记住选择;日语缺了哪一句就播那一句的中文)。点选干员时——作战区或整备区里的干员、商店和晋升奖励里的干员卡片——会说一句官方的「选中干员」语音,休整期也一样;连续点选时新的一句接替正在说的那句,但不会打断正在播放的部署、技能等作战语音。部署(每场第一个部署的干员说「行动出发」)、技能、接敌和结算语音只在作战中播放。替补干员和原型干员(预备干员、Sharp、Mechanist 等)、盟约·辅助干员和召唤物没有官方语音,点选时不出声。
## 1. 一局的流程
在「确认本局信息」页,房主可以点击「重刷本局」。所有真人玩家(包括房主;AI 与观战者不参与)一致同意后,重新随机战场、敌方领袖、隐秘核心、特训敌人及完整回合安排、禁用盟约与干员,所有玩家重新确认。房间号、连接、干员调配、干员持有和自选编队配置保留;固定战场与模式固定禁用遵循原规则,随机结果可能重复。投票期间暂停确认倒计时与阶段跳转;有人拒绝、断线、退出,或房主取消时,保留原本信息并恢复剩余确认时间。只有一名真人玩家时,点击即可刷新。
@@ -19,11 +21,12 @@
| 阶段 | 说明 |
|---|---|
| 确认本局信息 | 本局地图(标准模拟固定为「战场#01」;险境从 8 张地图中随机,绝境 / 终极从不含战场#01 的 7 张中随机)、敌方派系(三种特训敌人类型,如折射、飞行:和官方一样,每种占全局 15 个回合位中的 3 个,第 14 回合领袖战和第 15 回合隐秘核心也各占一个位,所以险境 / 绝境 / 终极第 1–13 回合中有 7–9 波是这三种类型、每种最多 3 波,其余各波是「特异」类型;每波只有一种类型)、**被禁用的盟约及其干员**、领袖预告。所有人确认后继续;同盟模拟最多 25 秒;只有你一名玩家时(独立模拟,或同盟模拟只带 AI 队友)不限时。 |
| 选择策略 | 40 名策略(分队长)决定**初始目标生命值(20–45)**和一条永久规则。同盟:随机顺序轮流选,**每人 30 秒**,右上角和「决策中」显示同一个倒计时;AI 队友立即选完;队友已选的不能重复,每人可**跳过一次**(排到最后);超时自动选你**当前选中**的策略(已被队友选走则选华法琳)。独立:自由选择、不限时;同盟模拟只有你一名玩家时也不限时。选的时候可以点左侧决策顺序下方的「查看禁用盟约与干员」,再看一遍「确认本局信息」里的盟约(置灰、禁用人数)和本局禁用干员:只能查看,倒计时照常走,底部也显示轮到谁和剩余秒数;点「关闭」、点窗口外面或按 Esc 关闭,轮到下一位时会自动关闭。 |
| 机变 | 部分回合开始前出现:同盟共 6 张卡轮流各取 1 张(首位 30 秒,其余 16 秒;只有你一名玩家时不限时),独立 3 选 1。卡片包括装备、资金、干员、盟约层数、悬赏等;**悬赏决策**为你的作战添加悬赏敌人,卡上用蓝字标出持续多久:「下场作战」/「接下来两场作战」的只出现一场或两场,之后不再出现;官方的「多轮悬赏」(原文写「之后的每场作战」)在这里也按「接下来两场作战」处理,只出现两场(如教鞭的法术大师A2·多轮战术特训)。各回合的悬赏决策按官方对局截图来出:第 3 回合是 6 张「接下来两场作战」的悬赏(3 张 1 资金、2 张 2 资金、1 张 3 资金);第 9 回合是各首领的「下场作战」悬赏和「源石虫·特训」;绝境 / 终极的第 11 回合多半是悬赏决策(也可能是机密商店或战术决策),其中的悬赏通常有 1 只特异巨兽,其余是各阵营的「下场战斗」悬赏。击倒悬赏敌人的人(联防中的队友也算)获得资金;右侧效果栏会写「还剩 N 场作战」。**机密商店**的 6 件装备都免费,和官方一样可能出现两件相同的装备(各算一张卡,可以分别被选走);第 11 回合(绝境 / 终极)的机密商店按官方:2 件 VI 阶、至少 1 件 V 阶和 1 枚盟约之币,标准 / 险境较早回合的机密商店各阶装备都可能出现。**战术决策**也和官方一样可能出现两张相同的卡(官方截图里出现过两张「补给」;各算一张卡,可以分别被选走,每张只生效一次,两人各选一张全队卡则效果叠加);第 11 回合(绝境 / 终极)的战术决策按官方截图只出盟誓、驰援、列装 / 财富 / 补给 / 整备 / 升华等增益卡,不出削弱敌人的卡和战场演变卡。每张卡左上是图标和名称,下面写着完整效果(与详情卡相同的官方描述,手机上也显示);已被选走的卡变暗,右上角显示选走它的玩家头像(悬停可看名字)。选卡要**点两次**:第一次只选中(金框 +「确认选择 · 再次点击」),再点同一张或点右上的「确认选择」才生效;点别的卡换选,按 Esc 或点空白处取消。 |
| 休整期 | 购买、摆阵、配装备、冻结 / 刷新、升级调度中心;同盟有倒计时(65–215 秒,随回合增加);独立模拟、以及只有你一名玩家(只带 AI 队友)的同盟模拟不限时。所有人「准备就绪」即提前开战,全员就绪前可取消。 |
| 选择策略 | 40 名策略(分队长)决定**初始目标生命值(20–45)**和一条永久规则。同盟:随机顺序轮流选,**每人 30 秒**,右上角和「决策中」显示同一个倒计时;AI 队友立即选完;队友已选的不能重复,每人可**跳过一次**(排到最后);创建者可以在同盟等待室打开「**AI 队友最后选择**」(默认关闭):选择策略和机变轮选时所有博士都排在 AI 队友前面(博士之间、AI 之间仍是随机顺序,AI 托管或掉线的博士也算博士),跳过时排到其他还没选的博士之后、AI 之前(没有其他博士时排到最后);超时自动选你**当前选中**的策略(已被队友选走则选华法琳)。独立:自由选择、不限时;同盟模拟只有你一名玩家时也不限时。选的时候可以点左侧决策顺序下方的「查看禁用盟约与干员」,再看一遍「确认本局信息」里的盟约(置灰、禁用人数)和本局禁用干员:只能查看,倒计时照常走,底部也显示轮到谁和剩余秒数;点「关闭」、点窗口外面或按 Esc 关闭,轮到下一位时会自动关闭。 |
| 机变 | 部分回合开始前出现:同盟共 6 张卡轮流各取 1 张(随机顺序,开启「AI 队友最后选择」时博士先选;首位 30 秒,其余 16 秒;只有你一名玩家时不限时),独立 3 选 1。卡片包括装备、资金、干员、盟约层数、悬赏等;**悬赏决策**为你的作战添加悬赏敌人,卡上用蓝字标出持续多久:「下场作战」/「接下来两场作战」的只出现一场或两场,之后不再出现;官方的「多轮悬赏」(原文写「之后的每场作战」)在这里也按「接下来两场作战」处理,只出现两场(如教鞭的法术大师A2·多轮战术特训)。各回合的悬赏决策按官方对局截图来出:第 3 回合是 6 张「接下来两场作战」的悬赏(3 张 1 资金、2 张 2 资金、1 张 3 资金);第 9 回合是各首领的「下场作战」悬赏和「源石虫·特训」;绝境 / 终极的第 11 回合多半是悬赏决策(也可能是机密商店或战术决策),其中的悬赏通常有 1 只特异巨兽,其余是各阵营的「下场战斗」悬赏。击倒悬赏敌人的人(联防中的队友也算)获得资金;右侧效果栏会写「还剩 N 场作战」。**机密商店**的 6 件装备都免费,和官方一样可能出现两件相同的装备(各算一张卡,可以分别被选走);第 11 回合(绝境 / 终极)的机密商店按官方:2 件 VI 阶、至少 1 件 V 阶和 1 枚盟约之币,标准 / 险境较早回合的机密商店各阶装备都可能出现。**战术决策**也和官方一样可能出现两张相同的卡(官方截图里出现过两张「补给」;各算一张卡,可以分别被选走,每张只生效一次,两人各选一张全队卡则效果叠加);第 11 回合(绝境 / 终极)的战术决策按官方截图只出盟誓、驰援、列装 / 财富 / 补给 / 整备 / 升华等增益卡,不出削弱敌人的卡和战场演变卡。每张卡左上是图标和名称,下面写着完整效果(与详情卡相同的官方描述,手机上也显示);已被选走的卡变暗,右上角显示选走它的玩家头像(悬停可看名字)。选卡要**点两次**:第一次只选中(金框 +「确认选择 · 再次点击」),再点同一张或点右上的「确认选择」才生效;点别的卡换选,按 Esc 或点空白处取消。 |
| 休整期 | 购买、摆阵、配装备、冻结 / 刷新、升级调度中心;同盟有倒计时(65–215 秒,随回合增加);独立模拟、以及只有你一名玩家(只带 AI 队友)的同盟模拟不限时。所有人「准备就绪」即提前开战,全员就绪前可取消;点「准备就绪」时商店自动收起、棋盘放大到全场,取消准备时商店再展开(资金旁的「收起」和商店标签「展开商店」随时可以手动切换)。 |
- **特殊地形点一下就有说明**:战场上的**活性源石**、**沼泽**、**排气格栅**、**深水区**,以及**红门 / 蓝门 / 传送点**,点一下就会弹出它自己的说明卡——名称、机制,以及**这一关自己的数值**(比如战场#04 的活性源石:每秒 70 点真实伤害、攻击力 +20%、攻击速度 +20、持续 300 秒)。休整期和作战期都能点。普通地板 / 道路 / 高台点了不会弹东西,所以点一下仍然只是「取消选中、关掉卡片」。
| 作战 | 每人在自己的战场各自作战(战斗在你的浏览器里模拟,2 倍速)。干员按**逐列从左到右、每列从上到下**的顺序(官方「从上到下>从左到右」:最左一列最先)自动部署并消耗部署费用(初始 10,+1/秒)。时限 45–115 秒(实际秒数,即屏幕上的倒计时),时间到仍存活的敌人算漏怪。有敌人进入蓝门时,顶栏的目标生命值会**立即**显示扣除后的数值(红字 + 「−N」,每回合至多 10 点),结算时确定;联防阶段标注「联防中」,队友拦下的部分最后不扣。左侧队伍栏同样显示队友的待扣生命值。被击倒的干员**留在倒下的格子上**(倒地姿势、略微变灰),之后也在这一格再部署(与官方一样;倒在其他干员或召唤物初始位置上的,会回到自己的初始位置躺下,召唤物已经消失也一样;唯一的例外是乌尔比安:三技能把他移到船锚处期间被击倒时,他回到自己的部署位躺下、再部署——这是项目维护者 2026-10-07 应社区反馈有意做出的改动,与官方「原地倒下」不同);因自身效果强制退场的干员(史尔特尔「余烬」结束、耀骑士临光 2 技能结束等)同样倒在原地、在原地再部署;倒地干员所在的格子**不会被占用**,突袭盟约的再部署、援军、召唤物都会避开它。头上的圆圈是再部署倒计时:绿色圆环 + 秒数;变成黄色「DP」表示部署费用不够。血条下方的一行是**元素损伤**(与原版一致:元素图标 + 白条):干员为带图标的彩色圆盘,敌人为小号纯色圆盘,右侧白条是剩余元素值,像血条一样从右往左减少,白条耗尽的那一下就是爆条(被大额元素损伤一次打掉一截时白条也会一次少一截,例如掠海漂移体每击约为 25%–50%);爆条后进入爆发冷却(灼燃 / 神经 / 侵蚀 10 秒,敌方侵蚀 8 秒,凋亡 15 秒),这时白条变成**元素颜色**(神经青绿、侵蚀蓝、灼燃橙红、凋亡灰紫)并在冷却期间慢慢回满、图标闪烁——彩色条表示已经爆条、正在承受爆发效果,不是还剩的元素值;期间所有元素都不会累积也无法被治疗,结束后清零。元素爆发造成的是**无来源伤害**,不吃干员的增伤、穿透和装备加成(例如深巡打海嗣时灼燃爆发固定 7000),击杀仍记在爆条的干员名下;元素脆弱只提高元素伤害,不加快元素条;脆弱不影响元素伤害。已被击杀的敌人不会再被附加元素损伤。 |
- **场地装置点一下也有说明**:**阻隔工事**(挡路:地面敌人绕着走,无路可走才撞碎它;作战中说明卡上有它的实时血量,被摧毁后点不出来)、**“双眼皮”炮台**(自动法术攻击、命中附加脆弱,按当前层数最高的盟约强化;还没被机械援助或机变卡启用的炮台不显示、点不出来)、**射击台**(远程位可部署的高台位)和**源石流发生装置**(吹风方向,以及对干员攻击力、敌人移动速度的加减)点一下同样弹出说明卡,数值都取自所在关卡的装置数据,装置名称按当前语言显示官方译名;被机变卡移除的箱子同样不显示。休整期和作战期都能点(GitHub #228,PR #229,感谢 @rickylxw)。
| 作战 | 每人在自己的战场各自作战(战斗在你的浏览器里模拟,2 倍速)。干员按**逐列从左到右、每列从上到下**的顺序(官方「从上到下>从左到右」:最左一列最先)自动部署并消耗部署费用(初始 10,+1/秒)。时限 45–115 秒(实际秒数,即屏幕上的倒计时),时间到仍存活的敌人算漏怪。顶栏中间的胶囊是官方的「已解决 / 本回合敌人数」:打死一个、漏掉一个都算已解决(开局 0/3,漏一个 1/3,打死一个 2/3,打死会分裂的那只 3/3);分裂出来的小怪、召唤物和领袖召唤的敌人不进分子也不进分母,但它们漏掉时仍然各自扣目标生命值、也会破坏完美作战(4 只磨砻全被打死、分裂出的 8 只全漏进蓝门:胶囊 4/4,扣 8 点),所以会召唤的领袖关胶囊也能填满。有敌人进入蓝门时,顶栏的目标生命值会**立即**显示扣除后的数值(红字 + 「−N」,每回合至多 10 点),结算时确定;联防阶段标注「联防中」,队友拦下的部分最后不扣。左侧队伍栏同样显示队友的待扣生命值。被击倒的干员**留在倒下的格子上**(倒地姿势、略微变灰),之后也在这一格再部署(与官方一样;倒在其他干员或召唤物初始位置上的,会回到自己的初始位置躺下,召唤物已经消失也一样;唯一的例外是乌尔比安:三技能把他移到船锚处期间被击倒时,他回到自己的部署位躺下、再部署——这是项目维护者 2026-10-07 应社区反馈有意做出的改动,与官方「原地倒下」不同);因自身效果强制退场的干员(史尔特尔「余烬」结束、耀骑士临光 2 技能结束等)同样倒在原地、在原地再部署;倒地干员所在的格子**不会被占用**,突袭盟约的再部署、援军、召唤物都会避开它。头上的圆圈是再部署倒计时:绿色圆环 + 秒数;变成黄色「DP」表示部署费用不够。血条下方的一行是**元素损伤**(与原版一致:元素图标 + 白条):干员为带图标的彩色圆盘,敌人为小号纯色圆盘,右侧白条是剩余元素值,像血条一样从右往左减少,白条耗尽的那一下就是爆条(被大额元素损伤一次打掉一截时白条也会一次少一截,例如掠海漂移体每击约为 25%–50%);爆条后进入爆发冷却(灼燃 / 神经 / 侵蚀 10 秒,敌方侵蚀 8 秒,凋亡 15 秒),这时白条变成**元素颜色**(神经青绿、侵蚀蓝、灼燃橙红、凋亡灰紫)并在冷却期间慢慢回满、图标闪烁——彩色条表示已经爆条、正在承受爆发效果,不是还剩的元素值;期间所有元素都不会累积也无法被治疗,结束后清零。元素爆发造成的是**无来源伤害**,不吃干员的增伤、穿透和装备加成(例如深巡打海嗣时灼燃爆发固定 7000),击杀仍记在爆条的干员名下;元素脆弱只提高元素伤害,不加快元素条;脆弱不影响元素伤害。已被击杀的敌人不会再被附加元素损伤。 |
| 联防 | 见第 5 节。 |
| 结算 | 扣 LP、发放悬赏资金、结算层数;LP ≤ 0 的玩家淘汰(其干员回到公共卡池)。结算开始时屏幕正中跳出官方的「**作战结束**」框(约 3 秒后自己消失,不挡操作),下面一行只写**你自己**这一回合被扣的目标生命值:没扣是「**全员无伤!**」,扣了是「**生命值减少 −N**」;同时播官方的战斗结束音效(扣了、联防里没扣、普通回合没扣各一种)。联防回合见第 5 节。最终攻势和隐秘核心没有这一框(之后直接是结算称号或隐秘核心);已淘汰的玩家和观战者不弹框。 |
@@ -48,9 +51,9 @@
| 招募价格(按阶级 I–VI) | 2 / 3 / 3 / 3 / 4 / 4 |
| 刷新 | 1 资金 |
| 出售干员 | 任何干员(含精锐)都只返还 **1** 资金 |
| 装备 / 法术 | 调度中心出售除特殊装备外的所有装备:4 把特殊维式重锤(战栗 / 坚固 / 加速 / 灼燃)只来自维多利亚盟约(每 25 层)和洛洛的定制品,突变细胞只来自昆图斯的策略。装备不能出售,整备区里的只能「销毁」(不返还)。已配发的装备锁定在干员身上:只能被第三件装备替换(弹出替换窗口,选择要替换的一件,它会被销毁;盟约之币、博士投影等装备即销毁的道具也一样,先销毁选中的那件再生效,干员留下一个空栏位,精锐干员不能使用博士投影,此时什么都不销毁),或在干员出售 / 合成精锐时退回整备区。**突变细胞**不会用完:战斗结束后装备者被销毁(部署在场上的话,原来的格子空出来,剩余可放置角色加 1),突变细胞和它的其他装备先退回整备区,可以再次配发给任意干员;随后获得一名高一阶(最高 VI)的随机普通干员(装备者是精锐也一样),和其他新获得的干员一样进入整备区(满了进入临时整备区),需要自己重新部署;如果它凑齐了合成所需的同名干员,照常合成精锐。**坚固维式重锤**首次受到致命伤害时锁血 8 秒,**每次部署一次**:被击倒再部署(或突袭跳跃再部署、被 M3茧甲 / 埃芒加德复活)后又能锁一次,娜仁图亚策略借来的锤子也一样;借用到期时正在锁血的会锁满 8 秒;站在阿戈尔干员身前被开场吞噬、自带源石溶剂掉血时也会用掉这一次 |
| 冻结 | 免费,保留当前所有未买的格子到下一回合;冻结状态下仍可主动刷新 |
| 升级调度中心(1→2 … 5→6) | 险境及以上 5 / 8 / 11 / 12 / 13;同盟标准 5 / 8 / 10 / 11 / 11;独立标准 1 / 1 / 5 / 8 / 10。**每过一回合价格 −1**(最低 0),升级后按下一级的价格重新计算。升级到干员栏位更多的等级(险境及以上 2 级 4 个、4 级 5 个;标准 2 级 4 个、6 级 5 个)时,**新栏位立刻刷出一名新干员**,已有的卡片不变 |
| 装备 / 法术 | 调度中心出售除特殊装备外的所有装备:4 把特殊维式重锤(战栗 / 坚固 / 加速 / 灼燃)只来自维多利亚盟约(每 25 层)和洛洛的定制品,突变细胞只来自昆图斯的策略。装备不能出售,整备区里的只能「销毁」(不返还)。已配发的装备锁定在干员身上:只能被第三件装备替换(弹出替换窗口,选择要替换的一件,它会被销毁;盟约之币、博士投影等装备即销毁的道具也一样,先销毁选中的那件再生效,干员留下一个空栏位,精锐干员不能使用博士投影,此时什么都不销毁),或在干员出售 / 合成精锐时退回整备区。**突变细胞**不会用完:战斗结束后装备者被销毁(部署在场上的话,原来的格子空出来,剩余可放置角色加 1),突变细胞和它的其他装备先退回整备区,可以再次配发给任意干员;随后获得一名高一阶(最高 VI)的随机普通干员(装备者是精锐也一样),和其他新获得的干员一样进入整备区(满了进入临时整备区),需要自己重新部署;如果它凑齐了合成所需的同名干员,照常合成精锐。**坚固维式重锤**首次受到致命伤害时锁血 8 秒,**每次部署一次**:被击倒再部署(或突袭跳跃再部署、被 M3茧甲 / 埃芒加德复活)后又能锁一次,娜仁图亚策略借来的锤子也一样;借用到期时正在锁血的会锁满 8 秒;站在阿戈尔干员身前被开场吞噬、自带源石溶剂掉血时也会用掉这一次。**教鞭**(杜宾的策略每 2 个回合给一枚)拖到场上使用后即销毁,休整期里弹出 3 张战术特训悬赏(只有你自己看得到,刷新页面或断线重连后还是这 3 张),点一张再点一次确认,选中的那张成为接下来的悬赏;选定之前不能点「准备就绪」。同盟模式的休整期倒计时结束时还没选,会随机选一张;独立模拟和只有一名玩家时休整期不限时,所以不会自动选。AI 队友和 AI 托管的座位按悬赏评分自己选。战术特训的赏金要「各自行动阶段就达成完美作战」才给(最终攻势、隐秘核心里是你这一侧没有漏怪且全队获胜);“神秘顾客”仍然随机给一个悬赏 |
| 冻结 | 免费,保留当前所有未买的格子到下一回合;冻结状态下仍可主动刷新。策略「梓兰」的每次主动刷新会单独冻结送来的那张同名干员:只有这一张卡显示冰霜(上方的冻结按钮不亮),没有再刷新就留到下一回合,再手动刷新会连它一起重抽 |
| 升级调度中心(1→2 … 5→6) | 险境及以上 5 / 8 / 11 / 12 / 13;同盟标准 5 / 8 / 10 / 11 / 11;独立标准 1 / 1 / 5 / 8 / 10。**每过一回合价格 −1**(最低 0),升级后按下一级的价格重新计算。升级到干员栏位更多的等级(险境及以上 2 级 4 个、4 级 5 个;标准 2 级 4 个、6 级 5 个)时,**新栏位立刻出现,但空着不补干员**(官方如此:升级只开位,新位在下一次刷新——手动刷新或回合开始的自动刷新——时才会出现干员;空栏位只画一个空框,不写「已招募」),已有的卡片不变 |
| 其他收入 | 悬赏敌人、盟约「远见」「奇迹」、装备「骑士储蓄罐」、部分策略和特质 |
调度中心等级决定商店能出现的**最高阶级**和格子数(险境及以上:干员 3 / 4 / 4 / 5 / 5 / 5 格 + 装备 1 格)。
@@ -62,8 +65,9 @@
- **位置上限**:棋盘最多部署 **8** 名;整备区 **10** 格。整备区满时**不能购买或领取**任何干员、装备(包括能凑齐 3 名合成精锐的那一张,和官方一样),先出售、部署或配发腾出格子;新获得的单位(合成的精锐、奖励、退回的装备等)进入**临时整备区**(整备区前面的 5 格),整备区一空出格子,临时整备区的单位就**自动补进去**(从右往左)。临时整备区有单位时**不能准备就绪**,棋盘上会用红色虚线框标出这一排并说明规则:把它们放进整备区或战场、配发或使用,或者出售 / 销毁。休整期中进入临时整备区的单位,若到**本休整期结束**时仍留在那里就会被自动处理(干员返还卡池、装备销毁);作战、结算期间或准备就绪之后才进入的(例如战斗奖励、结算时合成的精锐),会保留到**下一个休整期**结束,你一定有机会处理它们。
- **晋升精锐**:3 名同名干员(无论是否部署)自动合成**精锐**(等级、技能、模组大幅提升;风丸只需 2 名),精锐不会再合成。如果被合成的干员里有**已经部署在战场上**的,精锐会直接出现在**那名干员的位置**(保持原来的朝向,不多占部署位;有两三名都在场上时,取最先部署的那名——最左一列优先,同一列从上到下);否则精锐进入整备区(满了进入临时整备区)。原先配发的装备都退回整备区,需要重新配发;被合成干员的召唤物随之移除,场上的精锐会把自己的召唤物送进整备区。点选一张能合成的商店卡片时,精锐将出现的那一格会亮起金色边框,同时打开的详情卡里也会写明去向(「可晋升:精锐干员将出现在作战区原位置 / 将进入整备区」;详情卡会自动停靠在不遮挡这一格的一侧)。在回合开始或结算时因策略、奖励等完成的合成,精锐出现时同样会播放金色的晋升特效。
- **晋级奖励**:每次晋升后获得一次特殊招募:从 3 名 **高一阶**(最高 VI)、**互不相同**的随机干员中**免费**挑 1 名(该阶剩余干员不足 3 名时由低一阶补足)。它不能刷新、不能冻结、本回合结束失效。
- **干员持有(补位)**:大厅、房间和「确认本局信息」里的「干员调配」按钮打开的界面有两个标签页:「干员调配」选择每名干员携带的技能和精锐的模组;「**干员持有**」可以把你没有的干员**下掉**(点一下卡片切换「持有 / 未持有」)。和官方一样只有 55 张「普通」棋子能下掉(界面按阶级列出本赛季商店里的 53 名,每张写着由谁替补、用几技能);74 张「预设」棋子(多是 1–2 阶的常见干员)无论是否持有都由本人上场,不在其中。默认全部持有。下掉的干员和官方一样**显示为替补干员**:商店和晋升奖励的卡片、整备区和临时整备区里的单位、作战区、结算界面的阵容,都是替补干员的头像、名字和模型(例如银灰显示为 Sharp),带一个小小的「替补」标记;详情卡显示替补干员的立绘、名字、技能、天赋和模组,名字下面写着「银灰的替补」。**盟约、特质、阶级、价格、合成仍按原干员**(卡片和详情卡上的盟约、特质就是原干员的),作战时的属性、技能、天赋、模组、攻击范围、近战位 / 远程位都是替补干员的(例如银灰 → Sharp 的 2 技能「亮剑」)。同一名替补干员可能替补好几名干员(Sharp 替补忍冬、银灰、隐德来希等),它们按各自的原干员分别合成:看阶级和盟约区分,点开详情卡能看到替补的是谁。同盟模拟中队友查看你的整备区和作战区、你的盟约成员时,看到的也是替补干员。替补的技能和模组由棋子固定,「干员调配」对它不生效;替补干员没有召唤物(下掉缪尔赛思就没有流形)。这是**局外设置**:下一局开始时生效,对局中修改只保存到下一局;同盟模拟中只影响你自己的棋子,队友的棋子按队友的设置。没有官方的好友借用:想用本人上场,改回「持有」即可。「全部持有」一键恢复,「导出 / 导入」可以把设置带到别的浏览器;大厅的「干员调配」按钮上会显示「替补 N」提醒你有几名干员被下掉。
- **自选编队**:同一个界面的第三个标签页「**自选编队**」有 4 个名额:5 阶两个、6 阶两个。点名额打开选择列表,每个名额可以放一名**你持有的 6★ 干员**(棋子池里没有的那些,标「已持有」:3 个技能任选其一,精锐的模组任选其一或不装备;只在集成战略或生息演算里生效的模组不能选:ISW-A、SO-A / SO-B、RA-A)或一名**原型干员**(标「原型」:Sharp、Mechanist 等 9 名 6★,5 阶另有 6 名 4★ 预备干员;技能和模组与补位时相同,不能更改)。同一名原型干员可以同时放进一个 5 阶和一个 6 阶名额;你持有的干员只能放一个名额;同一阶的两个名额不能放同一人。列表只列出已经做好战斗逻辑的干员(原型干员全部可选,持有的 6★ 随战斗逻辑完成陆续加入)。对局中,自选干员**只出现在你自己的商店里**:调度中心升到 5 级后才会刷出 5 阶名额的干员,6 级后才会刷出 6 阶的;刷新概率和同阶干员一样,每个名额有自己的库存(5 阶 8 张、6 阶 5 张,不与队友共用),价格与同阶干员相同,晋升奖励里也可能出现;效果、奖励或机变卡「获得一名随机干员」时,也可能抽到你自己的自选干员(按那个效果自己的阶级规则,不看调度中心等级)。三张合成精锐,精锐时所选模组生效(5 阶 1 级、6 阶 3 级)。自选干员**没有特质**,盟约按干员的实际阵营分配(推进之王是维多利亚,煌同时是炎和维多利亚,没有对应阵营的是协防干员),和普通棋子一样计入盟约人数;本局禁用了它全部的盟约时,它不会出现在商店里(协防不会被禁用),开局的「确认本局信息」、选策略时的「本局信息」和对局中的「本局信息」都会在「本局禁用干员」下写明是哪一名。商店卡片、整备区、作战区和详情卡上都标着「自选」,详情卡显示所选的技能和模组。这同样是**局外设置**:下一局开始时生效,对局中修改只保存到下一局;AI 队友不使用自选;你开启 AI 托管时,AI 会优先买你的自选干员,并在快能刷出自选名额时提早一回合升级调度中心;信标不会把自选干员送给队友。「导出 / 导入」「全部清空」可用,大厅的「干员调配」按钮上会显示「自选 N」。
- **干员调配(技能、模组、潜能与练度)**:大厅、房间和「确认本局信息」里的「干员调配」按钮打开的界面有三个标签页:「干员调配」「干员持有」「自选编队」。「干员调配」把干员列成一张表,每名干员一行,各列是**技能**、精锐的**模组**和**潜能 · 练度**:直接点行里的技能图标、模组图标(「不装备」在最前,模组只在精锐时生效)或改两个下拉框就设好了;点头像或名字在右边打开详情,依次是潜能与练度、特质、技能(普通 Lv.4 / 精锐 Lv.7 两档数值可切换)、局内数值和模组(窄屏——宽度 1000 像素以下,例如横屏的手机——详情盖住列表,点「干员列表」返回)。**潜能**(1–6)和**练度**(未精英化 / 精英阶段1 / 精英阶段2 / 精英阶段2-60级)按干员设置,同一名干员的普通、精锐和自选共用,默认潜能 6、精英阶段2-60级(维护者 2026-10-08 的决定:默认满潜、满加成)。官方规则:潜能取你自己持有的这名干员的潜能;练度是调度手册「加成情况」里的**自持有加成**——未精英化 +0%,精英1 攻击、防御 +5%,精英2 攻击 +10%、防御和生命 +5%,精英2 60 级三项 +10%,单独乘算,不和其他「攻击力 +x%」相加;未持有的特许干员在官方按潜能 1、没有加成,想照官方打就把它设成潜能 1、未精英化。补位的替补干员和自选的原型干员没有潜能和练度,设置对它们不生效。商店卡片、详情卡和「局内数值」都按你的设置显示;同盟模拟中队友的干员按队友的设置。和技能、模组一样是**局外设置**:「确认本局信息」结束前修改对本局生效,之后只保存到下一局。「恢复默认」「全部恢复默认」同时重置潜能与练度,「导出 / 导入」会带上它们,「干员调配」按钮上的数字也算上改过潜能或练度的干员。
- **干员持有(补位)**:同一个界面的第二个标签页「**干员持有**」可以把你没有的干员**下掉**(点一下卡片切换「持有 / 未持有」)。和官方一样只有 55 张「普通」棋子能下掉(界面按阶级列出本赛季商店里的 53 名,每张写着由谁替补、用几技能);74 张「预设」棋子(多是 1–2 阶的常见干员)无论是否持有都由本人上场,不在其中。默认全部持有。下掉的干员和官方一样**显示为替补干员**:商店和晋升奖励的卡片、整备区和临时整备区里的单位、作战区、结算界面的阵容,都是替补干员的头像、名字和模型(例如银灰显示为 Sharp),带一个小小的「替补」标记;详情卡显示替补干员的立绘、名字、技能、天赋和模组,名字下面写着「银灰的替补」。**盟约、特质、阶级、价格、合成仍按原干员**(卡片和详情卡上的盟约、特质就是原干员的),作战时的属性、技能、天赋、模组、攻击范围、近战位 / 远程位都是替补干员的(例如银灰 → Sharp 的 2 技能「亮剑」)。同一名替补干员可能替补好几名干员(Sharp 替补忍冬、银灰、隐德来希等),它们按各自的原干员分别合成:看阶级和盟约区分,点开详情卡能看到替补的是谁。同盟模拟中队友查看你的整备区和作战区、你的盟约成员时,看到的也是替补干员。替补的技能和模组由棋子固定,「干员调配」对它不生效;替补干员没有召唤物(下掉缪尔赛思就没有流形)。这是**局外设置**:下一局开始时生效,对局中修改只保存到下一局;同盟模拟中只影响你自己的棋子,队友的棋子按队友的设置。没有官方的好友借用:想用本人上场,改回「持有」即可。「全部持有」一键恢复,「导出 / 导入」可以把设置带到别的浏览器;大厅的「干员调配」按钮上会显示「替补 N」提醒你有几名干员被下掉。
- **自选编队**:同一个界面的第三个标签页「**自选编队**」有 4 个名额:5 阶两个、6 阶两个。点名额打开选择列表,每个名额可以放一名**你持有的 6★ 干员**(棋子池里没有的那些,标「已持有」:3 个技能任选其一,精锐的模组任选其一或不装备;只在集成战略或生息演算里生效的模组不能选:ISW-A、SO-A / SO-B、RA-A)或一名**原型干员**(标「原型」:Sharp、Mechanist 等 9 名 6★,5 阶另有 6 名 4★ 预备干员;技能和模组与补位时相同,不能更改)。同一名原型干员可以同时放进一个 5 阶和一个 6 阶名额;你持有的干员只能放一个名额;同一阶的两个名额不能放同一人。列表只列出已经做好战斗逻辑的干员(原型干员全部可选,持有的 6★ 随战斗逻辑完成陆续加入)。对局中,自选干员**只出现在你自己的商店里**:调度中心升到 5 级后才会刷出 5 阶名额的干员,6 级后才会刷出 6 阶的;刷新概率和同阶干员一样,每个名额有自己的库存(5 阶 8 张、6 阶 5 张,不与队友共用),价格与同阶干员相同,晋升奖励里也可能出现;效果、奖励或机变卡「获得一名随机干员」时,也可能抽到你自己的自选干员(按那个效果自己的阶级规则,不看调度中心等级)。三张合成精锐,精锐时所选模组生效(5 阶 1 级、6 阶 3 级)。你持有的干员的潜能与练度在名额卡片上设置(默认潜能 6、精英阶段2-60级,规则同「干员调配」);原型干员没有潜能和练度。自选干员**没有特质**,盟约按干员的实际阵营分配(推进之王是维多利亚,煌同时是炎和维多利亚,没有对应阵营的是协防干员),和普通棋子一样计入盟约人数;本局禁用了它全部的盟约时,它不会出现在商店里(协防不会被禁用),开局的「确认本局信息」、选策略时的「本局信息」和对局中的「本局信息」都会在「本局禁用干员」下写明是哪一名。商店卡片、整备区、作战区和详情卡上都标着「自选」,详情卡显示所选的技能和模组。这同样是**局外设置**:下一局开始时生效,对局中修改只保存到下一局;AI 队友不使用自选;你开启 AI 托管时,AI 会优先买你的自选干员,并在快能刷出自选名额时提早一回合升级调度中心;信标不会把自选干员送给队友。「导出 / 导入」「全部清空」可用,大厅的「干员调配」按钮上会显示「自选 N」。
- 每回合商店会自动刷新未冻结的格子。
- **「本回合首次主动刷新」按每名干员各自计算**(拉普兰德):她在本回合遇到的第一次主动刷新生效,在整备区里也算——先刷新、再买下拉普兰德,下一次刷新她仍会叠层;本回合新合成的精锐算作一名新的拉普兰德——即使合成用的几名本回合已经生效,精锐在本回合的下一次主动刷新仍会 +8。
@@ -72,16 +76,17 @@
- 从整备区把干员拖到棋盘:近战位 / 远程位高亮为可放置格。**特性写着「可以放置于远程位」的近战干员(钩索师、推击手:歌蕾蒂娅、崖心、见行者,以及自选编队的温蒂)也能放到高台上**,普通和精锐都可以,装什么模组或者不装模组都一样;其他近战干员只能放在地面。站在高台上时照常攻击,但不阻挡敌人。**水里不能部署**:战场#08(涨潮控制)中间的那条水池(深水区)和官方一样拒绝部署,任何干员和召唤物都放不上去;突袭盟约的干员再部署、战术家的援军自动选战术点时也只会落在岸上。松手后出现**方向轮盘**,向上 / 右 / 下 / 左滑动,棋盘上会用橙色条纹预览旋转后的攻击范围,松手确认;松在中心或点「✕ 点击取消」取消。
- 想只改朝向:把干员拖回它自己所在的格子再选方向。朝向会一直保留到下一回合。
- **点选按地上的方格**:点哪个方格就选中站在那个方格上的单位,点空方格不选任何单位(点到后一格干员画出来的头部,算作点了后面那一格)。作战中友方同样按方格;敌人在格子之间走动,点它的身体或脚下附近(0.6 格内)即可,飞行敌人点它的身体。
- 拖动时模型一直在指针 / 手指下(脚在指针下方约半格,鼠标和触屏一样),**指针所在的格子**就是落点并高亮;装备拖到哪个格子,就配发给那个格子上的干员;在商店栏上松手则放回原位。
- 点击干员显示攻击范围和底部按钮(「撤退」回到整备区、「出售 +1」);右键 / 长按查看详细信息:头像旁是盟约,下面第一项就是干员的**特质**(如「休整期结束时 …」),之后是特性、属性、攻击范围、技能、模组、装备、天赋。属性是**实时数值**:作战中每秒刷新 4 次(当前生命、攻击、防御、法抗、攻击间隔、阻挡),休整期显示棋盘上的干员**开战时**的数值(已算入装备、盟约层数、特质、策略与机变效果);高于基础值为绿色、低于为红色并标出差值(攻击间隔变短算增益),鼠标悬停可看基础值。基础值是官方数据在该阶级的等级、技能等级(和精锐的模组)下、**满潜能**、0 信赖的数值:所有干员(精锐、补位、自选也一样)都带上全部潜能加成——部署费用、再部署时间、攻击力 / 生命 / 防御 / 法抗 / 攻速,以及「天赋效果增强」,召唤物的天赋也按召唤者的满潜能(维护者 2026-10-07 的决定,GitHub #252:官方赛事视频里的刺玫正是满潜能的攻击 435、费用 15)。
- 拖动时**指针 / 手指所在的格子**就是落点并高亮。这一格可以放下(棋盘或整备区)时,干员模型直接站在这一格上,和官方部署一样:手指在格子里移动时模型不动,移到下一个可放下的格子就站到那一格;指针不在可放下的格子上时(例如在商店栏上),模型跟在指针 / 手指下(脚在指针下方约半格)。鼠标和触屏一样。装备拖到哪个格子,就配发给那个格子上的干员;在商店栏上松手则放回原位。
- 点击干员显示攻击范围和底部按钮(「撤退」回到整备区、「出售 +1」);右键 / 长按查看详细信息。作战中、查看队友的阵地时,点开场上我方干员或召唤物(包括队友的)的详情卡,棋盘上也会画出它**当前的攻击范围**(按它现在的位置、朝向和技能,例如开着扩大攻击范围的技能时就是扩大后的范围),关掉详情卡、拖动或选方向时消失;敌人、整备区和临时整备区里的单位不画。详情卡里:头像旁是盟约,下面第一项就是干员的**特质**(如「休整期结束时 …」),之后是特性、属性、攻击范围、技能、模组、装备、天赋。属性是**实时数值**:作战中每秒刷新 4 次(当前生命、攻击、防御、法抗、攻击间隔、阻挡),休整期显示棋盘上的干员**开战时**的数值(已算入装备、盟约层数、特质、策略与机变效果);高于基础值为绿色、低于为红色并标出差值(攻击间隔变短算增益),鼠标悬停可看基础值。基础值是官方数据在该阶级的等级、技能等级(和精锐的模组)下、按你在「干员调配」里给这名干员设的**潜能和练度**(默认潜能 6、精英阶段2-60级)、0 信赖的数值:潜能加成包括部署费用、再部署时间、攻击力 / 生命 / 防御 / 法抗 / 攻速,以及「天赋效果增强」,召唤物的天赋按召唤者的潜能;练度把攻击、防御、生命上限单独乘上自持有加成(精英阶段2-60级 ×1.1;召唤物不乘)。补位的替补干员和原型干员没有潜能档,也没有自持有加成(0.2.2;0.2.1 是所有干员一律满潜能、没有自持有加成,GitHub #252)。
- **召唤物手动摆放**:把带可部署召唤物的干员放上棋盘后,它的召唤物会进入整备区(赫默的医疗探机、巫恋的诅咒娃娃、凯瑟琳的 2 个支援装置、浊心斯卡蒂的海嗣、伺夜的狼群、缪尔赛思的流形;同一干员的召唤物叠在一格,不占部署位),像干员一样拖到棋盘上并选方向。凯瑟琳的支援装置给**它面朝的那一格**上的干员加屏障,所以要放在干员旁边、朝向干员。**战术家的援军**(伺夜的狼群、缪尔赛思的流形,也就是选战术点)和官方一样**只能放在召唤者的攻击范围内**:拖动时只有这些格子会亮起;之后在原地给战术家换朝向时,不在新攻击范围里的援军会退回整备区(还在范围里的保持不动;整备区和临时整备区都满、援军无处可退时,这次换朝向不生效并提示「整备区已满」)。撤退或出售干员时它的召唤物一起移除;**移动干员到别的格子时(包括把召唤物拖到干员身上交换位置),它已摆放的召唤物会退回整备区**,需要重新摆放。整备区满时退回的召唤物进入临时整备区,休整期结束还没处理的会在下一回合重新回到整备区。
- 作战中:海嗣、狼群、流形、支援装置开战时就在摆放的位置部署;医疗探机、诅咒娃娃开战时也在摆放的位置免费部署一次(持续 10 / 15 秒,和官方一样不需要先开技能),之后所属干员每次发动技能时再次在原位出现;所属干员被击倒、再部署回场时,技能给的那一个如果还没出现,会立刻出现。巫恋退场时诅咒娃娃随之撤退,赫默退场时已出现的医疗探机不受影响。医疗探机、诅咒娃娃、海嗣(以及自选干员的工程蓄水炮、沙地兽、打字机、本能的召唤)这类不会受到攻击、到时间就离场的召唤物不受任何伤害(活性源石也一样)、也不会被治疗,它们的血条就是倒计时:随时间减少,空了就离场。医疗探机、诅咒娃娃、支援装置、海嗣没有摆放就不会出现;狼群、流形(战术家的援军)没有摆放时会在战术点出现。狼群最后一只狼影被击倒后进入战术点形态:暂时离开战斗(不阻挡、不攻击、不会被攻击,格子仍然占着),经过狼影的恢复时间(25 秒)后在原地满血、带着 1 只狼影回来,之后重新开始每 25 秒加 1 只狼影;伺夜退场时狼群随之撤退,伺夜再部署回场时狼群重新带着 2 只狼影出现。
- **阻挡**看接触:干员阻挡中心距离约 0.7 格以内、与它接触的敌人(地面干员挡不住飞行和近地悬浮的敌人)。敌人卡在两名干员之间时,阻挡它的干员倒下、撤退或被晕眩后,另一名仍与它接触、还有空余阻挡数的干员会**立刻接替阻挡**;都没有空位时敌人才会走掉。被冻结或晕眩的敌人同样会被接触到的干员挡住:干员再部署到它身边就会阻挡它,隐匿的敌人也随之破隐(沉睡、浮空、恐惧的敌人仍然挡不住)。迎面走来的敌人会停在干员格子前面。**阻挡了就一定能打到**:任何干员(包括站在近战位的远程干员和召唤物)总能攻击自己阻挡的敌人,不管它在不在攻击范围内、在干员哪一侧,并且优先攻击它(医疗干员除外:咒愈师以外的医疗挡住敌人时照常治疗队友,不会攻击它;「停止攻击」类技能期间也除外;技能原文另有限制的也按原文:普罗旺斯二技能开启时不以生命值高于 80% 的敌人为目标,寒檀二技能的冰凌随机落在攻击范围内的地块上)。
- 作战中:海嗣、狼群、流形、支援装置开战时就在摆放的位置部署;医疗探机、诅咒娃娃开战时也在摆放的位置免费部署一次(持续 10 / 15 秒,和官方一样不需要先开技能),之后所属干员每次发动技能时再次在原位出现;所属干员被击倒、再部署回场时,技能给的那一个如果还没出现,会立刻出现。巫恋退场时诅咒娃娃随之撤退,赫默退场时已出现的医疗探机不受影响。医疗探机、诅咒娃娃、海嗣(以及自选干员的工程蓄水炮、沙地兽、打字机、本能的召唤)这类不会受到攻击、到时间就离场的召唤物不受任何伤害(活性源石也一样)、也不会被治疗,它们的血条就是倒计时:随时间减少,空了就离场。琳琅诗怀雅二技能的香槟炸弹也不会掉血(活性源石和各种伤害都不会)、不会被治疗,不会因为生命值而消失,只在「见面礼」炸到碰到它的第一个地面敌人后消失(维护者 2026-10-08 的决定);隐匿(没有被反隐、也没有被阻挡)或沉睡的敌人选不中,不会触发它。医疗探机、诅咒娃娃、支援装置、海嗣没有摆放就不会出现;狼群、流形(战术家的援军)没有摆放时会在战术点出现。狼群最后一只狼影被击倒后进入战术点形态:暂时离开战斗(不阻挡、不攻击、不会被攻击,格子仍然占着),经过狼影的恢复时间(25 秒)后在原地满血、带着 1 只狼影回来,之后重新开始每 25 秒加 1 只狼影;伺夜退场时狼群随之撤退,伺夜再部署回场时狼群重新带着 2 只狼影出现。
- **阻挡**看接触:干员阻挡中心距离约 0.7 格以内、与它接触的敌人(地面干员挡不住飞行和近地悬浮的敌人)。敌人卡在两名干员之间时,阻挡它的干员倒下、撤退或被晕眩后,另一名仍与它接触、还有空余阻挡数的干员会**立刻接替阻挡**;都没有空位时敌人才会走掉。被冻结或晕眩的敌人同样会被接触到的干员挡住:干员再部署到它身边就会阻挡它,隐匿的敌人也随之破隐(沉睡、浮空、恐惧的敌人仍然挡不住)。迎面走来的敌人会停在干员格子前面。**阻挡了就一定能打到**:任何干员(包括站在近战位的远程干员和召唤物)总能攻击自己阻挡的敌人,不管它在不在攻击范围内、在干员哪一侧,并且优先攻击它——只有速射手例外:攻击范围内有飞行敌人时先打飞行敌人(特性「优先攻击空中单位」,维护者 2026-10-08 的决定,GitHub #220)(医疗干员除外:咒愈师以外的医疗挡住敌人时照常治疗队友,不会攻击它;「停止攻击」类技能期间也除外;技能原文另有限制的也按原文:普罗旺斯二技能开启时不以生命值高于 80% 的敌人为目标,寒檀二技能的冰凌随机落在攻击范围内的地块上)。
- 开战时干员按**逐列从左到右、每列从上到下**的顺序部署(最左一列最先,与阿戈尔吞噬「更靠左和靠上」的顺序相同),召唤物在所有干员之后。被阻挡的敌人先打阻挡它的干员;没被阻挡的远程敌人先打嘲讽等级高的,同级时打最后部署的(召唤物、最右一列靠下的干员和刚再部署的干员更容易挨打)。隐匿的干员只会被它阻挡的敌人攻击,没被它挡住的敌人的溅射、爆炸、范围技能、留下的伤害区域(包括假想敌:蚀裂被击倒后喷出的毒雾)以及深池伙友卫队、扎罗的减攻速光环也落不到它身上(和官方一样;写明「无视无法选择」的效果如污染秽蚀照常能打到,寒霜的减攻速光环和“墓碑”这类地图效果照常生效);**迷彩**(如忍冬三技能期间击倒敌人后、寒芒克洛丝一技能)同样只会被它阻挡的敌人攻击,也显示为半透明,但躲不开溅射、爆炸和范围伤害(同时打范围内所有单位的普通攻击——如“斩胄之剑”“破胄之锤”“灵幛”——打不到它),也不算隐匿(不触发叙拉古等「隐匿时」效果)。叙拉古的隐匿按时(32 秒 + 每层 0.4 秒)结束;忍冬的迷彩按官方持续到她下一次开技能。**蒂比起飞**期间只挡飞行敌人,地面敌人不会选她为目标(普通攻击、溅射、技能范围、燃烧区域和要选中目标的减益都落不到她身上);飞行和悬浮敌人、写明「无视无法选择」的效果(如污染秽蚀)照常能打到她;地面敌人的光环和全场效果(如“墓碑”让全场攻击力、防御力减半)、她攻击敌人时招来的反制(如重装侦察兵的暴露)照常生效,起飞前已挂上的持续伤害也照常跳。
- **技能自动释放**按官方「技能策略」:一般的手动技能在技能就绪、即将攻击 / 治疗且初始攻击范围内有敌人(治疗技能:有受伤的队友)时释放;**带独立技能范围**的技能(如德克萨斯「剑雨」、凛御银灰「御敌的锋锐」、锏「归于宁静」,不含「攻击范围扩大」类)在**技能范围内出现敌人**时就释放,不必先攻击;**开启后攻击范围比平时大**的手动技能(如莫斯提马「序时之匙」、银灰「真银斩」、史尔特尔「黄昏」、白面鸮「脑啡肽」)在**开启后的攻击范围内出现敌人**(治疗技能:有受伤的队友)时就释放,不必等敌人走进平时的攻击范围,这是本作按玩家反馈有意与官方不同的地方;**重装干员**的手动技能在受到伤害时释放(例外:深巡、雷蛇的二技能,号角的二、三技能,灰毫的一、二技能按玩家反馈改为攻击范围内有敌人时就释放(深巡二技能按开启后更远的攻击范围判断),余的二技能「厚礼上宾」改为周围两格(技能范围)内有敌人时就释放,这是本作有意与官方不同的地方;深巡一技能仍要受到伤害);执旗手 / 战术家 / 吟游者技能就绪即释放,解放者 / 阵法术师在初始攻击范围内有敌人时释放(开启后攻击范围扩大的技能——薄绿「风语」、蜜蜡「沙暴扩散」、卡涅利安「食噬之印」、玛恩纳「未宽解的悲哀」,以及安洁莉娜「秘杖·反重力模式」——同样按开启后的攻击范围判断)。**自动触发**的技能按其本身规则:只作用于队友的(如引星棘刺「度算浪波」、浊心斯卡蒂「同葬无光之愿」、纯烬艾雅法拉「无声润物」)技力满就释放,不用等敌人或伤员;伺夜「领袖的呼唤」「领袖的馈赠」只在场上有狼群时释放:狼群处于战术点形态时,「领袖的呼唤」也会释放并让它立刻带着 1 只狼影回到场上(狼影未满时改为加 1 只狼影,已满时把狼群的生命值回满),「领袖的馈赠」也会释放(立即获得费用,强化留给回来的狼群的下一次攻击);又如古米「备用军粮」在身边(技能范围内)有受伤的队友时就会发动,下一次攻击改为治疗该队友(不用先接敌;她的二技能「食粮烹制」是手动技能,按重装规则在受到伤害时释放);塞雷娅「急救」在她即将攻击时、身边有生命不高于一半的队友才发动,这次攻击直接改为治疗;新约能天使二技能的护盾按官方给攻击范围内**仇恨最高**的队友(嘲讽等级高的优先,同级时给最后部署的),不一定是站在最前面挨打的那个。手动技能的自动释放有 3 秒冷却:开战部署后先等 3 秒,之后同一干员两次自动释放至少间隔 3 秒(可充能的技能不会同时放完)。
- **推开 / 拉拽**按官方「力度 − 重量」决定距离:例如野鬃二技能(中等力度)把重量 0 / 1 / 2 / 3 的敌人推开约 2.1 / 1.7 / 0.4 / 0.1 格,重量 4 以上推不动——轻型敌人反而推得更远。野鬃二技能沿她的朝向推开:正前方 45° 以内按上面的距离,身后、侧面或贴身的敌人只被轻推一下(约 0.4 格以内);琳琅诗怀雅三技能的金币则从她身边向外推开,落在她前方 2–4 格的范围(官方「范围2-4」)内的地面敌人和她阻挡的敌人身上。攻击速度最低为 20(沼泽、寒霜等减速叠满时也不会更慢)。
- **生命回复不是治疗**(与官方一致):吟游者的特性(魔王、浊心斯卡蒂及其海嗣)、调香师的天赋、瑕光二技能、铃兰三技能、锡人炼金单元给友军的「每秒恢复生命」是提高对方的**生命回复速度**,不是治疗:收割者 / 不屈者 / 武者(如隐德来希、圣约送葬人、折桠)这类「无法被友方治疗」的干员和处于禁疗的干员也能吃到,治疗量加成(如「受到的治疗效果提升」)也不会放大它;铃兰三技能开启后的第一秒没有回复。直接治疗(医疗干员的治疗、技能的「恢复…生命」)仍然对这些干员无效。
- **技能自动释放**按官方「技能策略」:一般的手动技能在技能就绪、即将攻击 / 治疗且初始攻击范围内有敌人(治疗技能:有受伤的队友)时释放;**带独立技能范围**的技能(如德克萨斯「剑雨」、凛御银灰「御敌的锋锐」、锏「归于宁静」,不含「攻击范围扩大」类)在**技能范围内出现敌人**时就释放,不必先攻击;**开启后攻击范围比平时大**的手动技能(如莫斯提马「序时之匙」、银灰「真银斩」、史尔特尔「黄昏」、白面鸮「脑啡肽」)在**开启后的攻击范围内出现敌人**(治疗技能:有受伤的队友)时就释放,不必等敌人走进平时的攻击范围,这是本作按玩家反馈有意与官方不同的地方;**重装干员**的手动技能在受到伤害时释放(例外:深巡、雷蛇的二技能,号角的二、三技能,灰毫的一、二技能按玩家反馈改为攻击范围内有敌人时就释放(深巡二技能按开启后更远的攻击范围判断),余的二技能「厚礼上宾」改为周围两格(技能范围)内有敌人时就释放,这是本作有意与官方不同的地方;深巡一技能仍要受到伤害);执旗手 / 战术家 / 吟游者技能就绪即释放,解放者 / 阵法术师在初始攻击范围内有敌人时释放(开启后攻击范围扩大的技能——薄绿「风语」、蜜蜡「沙暴扩散」、卡涅利安「食噬之印」、玛恩纳「未宽解的悲哀」,以及安洁莉娜「秘杖·反重力模式」——同样按开启后的攻击范围判断)。**自动触发**的技能按其本身规则:只作用于队友的(如引星棘刺「度算浪波」、浊心斯卡蒂「同葬无光之愿」、纯烬艾雅法拉「无声润物」)技力满就释放,不用等敌人或伤员;立即生效、本身不是一次攻击的技能(如度算浪波、先锋的回费技能)两次释放之间至少隔一个攻击间隔(释放本身不占用普通攻击),攻速越快放得越勤,迅捷返还的技力再多也不会每一帧都放(GitHub #298);伺夜「领袖的呼唤」「领袖的馈赠」只在场上有狼群时释放:狼群处于战术点形态时,「领袖的呼唤」也会释放并让它立刻带着 1 只狼影回到场上(狼影未满时改为加 1 只狼影,已满时把狼群的生命值回满),「领袖的馈赠」也会释放(立即获得费用,强化留给回来的狼群的下一次攻击);又如古米「备用军粮」在身边(技能范围内)有受伤的队友时就会发动,下一次攻击改为治疗该队友(不用先接敌;她的二技能「食粮烹制」是手动技能,按重装规则在受到伤害时释放);塞雷娅「急救」在她即将攻击时、身边有生命不高于一半的队友才发动,这次攻击直接改为治疗;新约能天使二技能的护盾按官方给攻击范围内**仇恨最高**的队友(嘲讽等级高的优先,同级时给最后部署的),不一定是站在最前面挨打的那个。手动技能的自动释放有 3 秒冷却:开战部署后先等 3 秒,之后同一干员两次自动释放至少间隔 3 秒(可充能的技能不会同时放完)。白铁的「铁钳号·原型机」能被我方干员攻击(不受伤害):它在攻击范围 / 技能范围内时,能作用到它的技能会像遇到敌人一样自动开启并消耗弹药(项目维护者 2026-10-08 按官方体验确认;白铁™多功能平台不会被攻击);技能效果只会挑选敌人、打不到它的技能(如德克萨斯「剑雨」)不会为它开启,留给真正的敌人。
- **推开 / 拉拽**按官方「力度 − 重量」决定距离:例如野鬃二技能(中等力度)把重量 0 / 1 / 2 / 3 的敌人推开约 2.1 / 1.7 / 0.4 / 0.1 格,重量 4 以上推不动——轻型敌人反而推得更远。野鬃二技能沿她的朝向推开:正前方 45° 以内按上面的距离,身后、侧面或贴身的敌人只被轻推一下(约 0.4 格以内);琳琅诗怀雅三技能的金币则从她身边向外推开,落在她前方 2–4 格的范围(官方「范围2-4」)内的地面敌人和她阻挡的敌人身上。被推开、拉动的敌人会进入官方的**失衡**状态,这段时间里不走路、也不发动普通攻击:推开按力度减重量的档位持续 0.2–约 1.17 秒(中等力度推重量 1 的敌人为 0.8 秒),拉拽持续 1 秒(力度比重量低 2 档时 0.5 秒,拉到身前后也一样),失衡期间也不会放技能(例如弑君者的闪现)或执行脚本移动(例如枯朽之种俯冲),弧光锋卫则在失衡期间每 0.066 秒受到 400 点真实伤害;推不动的空中单位(静态刚体)被推时停顿 0.1 秒、被拉时停顿整个拉力作用时间;浮空、重生和路线上的瞬移出现都会立刻解除失衡。画面上的滑行也用同样的时间(默认 2 倍速下现实时间减半)。攻击速度最低为 20(沼泽、寒霜等减速叠满时也不会更慢)。
- **生命回复不是治疗**(与官方一致):吟游者的特性(魔王、浊心斯卡蒂及其海嗣)、调香师的天赋、瑕光二技能、铃兰三技能、锡人和引星棘刺(一、二技能)的炼金单元给友军的「每秒恢复生命」是提高对方的**生命回复速度**,不是治疗:收割者 / 不屈者 / 武者(如隐德来希、圣约送葬人、折桠)这类「无法被友方治疗」的干员和处于禁疗的干员也能吃到,治疗量加成(如「受到的治疗效果提升」)也不会放大它;铃兰三技能开启后的第一秒没有回复。直接治疗(医疗干员的治疗、技能的「恢复…生命」)仍然对这些干员无效。
- **元素损伤条**在单位自己的血条正下方:左边是元素图标,白条是剩余的元素值,只有真正爆发时才会耗尽;爆发后条变成元素的颜色、图标闪烁,在冷却期间慢慢回满(这段时间不会再积累元素损伤)。
- **弹药、狼影和「我执」**也画在血条上(0.2.2):弹药类技能(如隐现的二技能,14 发)开启时,血条下方的技力条换成**分格的黄色弹药条**——一格一发,打一发少一格,补弹时回来;这类技能开启期间脚下不再有持续的金色光环。伺夜的狼群在血条下方有一排**菱形**,亮的是剩下的狼影,暗的是已用掉的空位(最多 3 个),进入战术点形态时消失。斩业星熊进入「我执」后,绿色血条停在 1 点,超出的伤害涨成血条上的**红条**,红条满了才会倒下。这些只是显示,不改变任何战斗数值。
- **敌人**:远程敌人的射程按官方从干员的碰撞体积(半径 0.25 格)算起,比数据上的攻击范围半径远 0.25 格——例如萨卡兹枯朽战车(射程 2.2)能打到中心 2.45 格以内、站在低地的干员。没被阻挡的远程敌人每次攻击都会站定、播完攻击动作,两次攻击之间才继续前进。它的第一下攻击就是「秽蚀轰击」(在目标处留下半径 1.7、持续 10 秒的污染区:低地每秒 50、高台 25 点真实伤害,同名不叠加),被阻挡时普通攻击伤害翻倍。“萨科塔之翼”等血量第一次低于一半时会在原地小范围乱飞 5 秒(官方叫「恐惧」);叙拉古、妮芙等干员造成的恐惧会让敌人朝远离干员的方向逃跑。隐匿的敌人只有被阻挡(或被揭示)时才能被选为攻击目标,溅射、技能的圆形范围伤害也打不到没被挡住的隐匿敌人(和官方一样);阻挡解除后 3 秒内它仍然可以被选中(官方页面注明 0 秒或 1 秒的敌人按页面),之后才重新隐匿:深池逐火战士每次被击倒后先重生 1 秒,然后变成继续往前走的隐匿「余烬」(需要 5 次伤害击倒,护卫的「火灰」10 次),一般要用干员挡住它才能选中(挡住时它不再半透明;被阻挡时被击倒的,重生结束后约 2 秒内也能用远程或范围伤害补刀),10 秒内没打掉就满血站起来;敌人重生时身上的减速、恐惧等状态会被清空、技能冷却从头开始,被恐惧的逐火战士变成余烬后也能被挡住;转译基底·α 原始形态受到的伤害全部无效,受到第 4 次物理伤害、第 4 次法术伤害或被阻挡时花 2 秒变成寻仇者、特战术师或幽灵,之后才能被击倒。
- **复活**(策略「埃芒加德」的前 3 次、M3茧甲)与官方一致:算作一次被击倒,紧接着在倒下的位置免费立即再部署——干员「被击倒时」和「部署时」的效果都会触发(例如砾的不屈 / 卡西米尔层数、瑕光的层数、卡西米尔盟约的部署攻击力),技力和身上的增益像普通再部署一样重置。
- **策略「鸭爵」(“神秘顾客”)**:从第 5 回合起,你和队友的每场作战(**最终攻势和隐秘核心也算**,联防不另外替换)有 0–2 名地面敌人(不含领袖及其部件、悬赏敌人)被替换为特殊版本的鸭爵、高普尼克、流泪小子或圆仔,出现在被替换敌人的时间和路线上(两人同场的领袖战里,各自只替换走向自己保护目标点的敌人);击倒者获得 1 资金(联防中的队友也算),漏进保护目标点照常扣 1 点目标生命值。鸭爵和圆仔**不攻击、无法被阻挡**:鸭爵受到伤害后移动速度 +300%;圆仔始终面向我方干员较多的一侧,来自正面的物理 / 法术伤害 −80%。流泪小子隐匿(被阻挡时才能被选中),攻击使目标晕眩 3.5 秒,被晕眩的干员挡不住它(官方规则);它被放开后要过 1 秒才重新隐匿(官方是 0 秒,本模式有意放宽,维护者 2026-10-07 的决定);高普尼克免疫晕眩、冻结和浮空。
@@ -112,8 +117,8 @@
- **附加盟约**(精准、迅捷、灵巧、奥术、坚守、助力、远见、奇迹、投资人、突袭、不屈、调和、协防干员、独行、绝技):多为 2 / 3 名激活;「独行」需要场上**恰好 1 名**,「绝技」按场上的精锐数量计算,没有势力盟约的干员算「协防干员」。
- **本局禁用**:「标准模拟」与官方相同,关闭拉特兰、阿戈尔、卡西米尔、灵巧、奥术、奇迹、投资人、突袭、独行、绝技这 10 个盟约,它们不会激活。你有这些盟约的成员时(奇迹、投资人也算整备区里的成员),盟约栏末尾显示**灰色带 ✕ 的盟约图标和「本局禁用」**,干员卡、盟约标签和盟约详情也这样标注。「远见」「奇迹」「投资人」计入整备区,盟约详情的「成员」人数也一样计入整备区里的成员(临时整备区不算)。
- **调和**:激活时,场上已有成员的核心盟约人数各 +1(只加 1 人,不论有几名调和干员)。盟约详情会写「含调和 +1」,成员列表上方列出带来这 1 人的调和干员(如缪尔赛思)。
- **炎佑**:场上 6 名以上「炎」干员时召唤炎佑(9 名时两只)。它追踪全场仇恨最高的敌人并悬停在其上方,技能「祛恶之焰」锁定 1 个目标**持续喷火**(最长 20 秒,每秒对目标周围的敌人造成法术伤害并附带灼燃,喷火时不普攻,目标死亡或被沉默时结束);场上暂时没有敌人时停在原地等待。它持有官方的「孤立」:医疗干员不会治疗它,友方的增益、光环和天赋也不会选中它(敌人照常可以攻击它)。
- **层数**:盟约通过各种效果叠加层数,层数越高效果越强(例如「炎」攻击力随层数提升、「远见」每 10 层给资金)。**层数整局保留,但只有盟约激活时才生效**,多数情况下也只有激活的盟约能叠层。每个盟约最多 **999 层**(与官方相同,取自官方客户端的常量):到 999 后再获得的层数直接作废,按层数发放的奖励(如「远见」的资金)也随之停止。作战中获得的层数在盟约栏里**实时**显示,作战结束后一直保留到结算(联防期间也不会退回去)。「调和」「协防干员」「独行」「绝技」与官方一样**不显示层数**(盟约栏、详情和结算卡片都不显示),但叠层的特质 / 策略 / 装备照样给它们加层(PRTS 盟约记录:「部分盟约不会显示叠加层数,但是叠加层数的特质/策略/装备等效果仍然对其生效」),这些层数也计入激活层数总和;它们的效果本身与层数无关。
- **炎佑**:场上 6 名以上「炎」干员时召唤炎佑(9 名时两只)。它追踪全场仇恨最高的敌人并悬停在其上方,技能「祛恶之焰」锁定 1 个目标**持续喷火**(最长 20 秒,每秒对目标周围的敌人造成法术伤害并附带灼燃,喷火时不普攻,目标死亡或被沉默时结束);场上暂时没有敌人时停在原地等待。它持有官方的「孤立」:医疗干员不会治疗它,友方的增益、光环和天赋也不会选中它(敌人照常可以攻击它)。它是飞行单位:标注「不可对空」的敌人效果(高能源石虫 / 冰爆源石虫 / 卷心籽的死亡爆炸、水遁忍者的漩涡、鼎沸、“萨科塔昂首”的祈祷邀约、“斩胄之剑” / “破胄之锤”的攻击等)不会波及它。
- **层数**:盟约通过各种效果叠加层数,层数越高效果越强(例如「炎」攻击力随层数提升、「远见」每 10 层给资金)。**层数整局保留,但只有盟约激活时才生效**,多数情况下也只有激活的盟约能叠层。每个盟约最多 **999 层**(与官方相同,取自官方客户端的常量):到 999 后再获得的层数直接作废,按层数发放的奖励(如「远见」的资金)也随之停止。作战中获得的层数在盟约栏里**实时**显示,作战结束后一直保留到结算(联防期间也不会退回去)。「调和」「协防干员」「独行」「绝技」与官方一样**不显示层数**(盟约栏、详情和结算卡片都不显示),但叠层的特质 / 策略 / 装备照样给它们加层(PRTS 盟约记录:「部分盟约不会显示叠加层数,但是叠加层数的特质/策略/装备等效果仍然对其生效」),这些层数也计入激活层数总和——只有策略「余」的【文火慢炖】不给这四个盟约加层(第 8 回合只激活了其中一个时什么也不加;维护者 2026-10-08 的决定);它们的效果本身与层数无关。
- **特质在哪里生效**:「进入休整期时」「休整期结束时」「刷新时」的特质(详情卡的第一项)只在干员部署在作战区时生效;文字里写着「此干员在整备区时也有效」的拉普兰德、调香师、波登可,以及激活【炎】/【投资人】时的琳琅诗怀雅,在整备区里也生效(临时整备区不算),与官方数据一致;「获得时」「售出时」的特质在获得、售出的那一刻生效,不论干员在哪里。「远见」「奇迹」「投资人」的「整备区的…干员也可用于激活盟约」只是把整备区的成员算进人数,不会让赫默等干员的特质在整备区生效。写着「使已激活的…」的特质要求盟约当时已经激活:例如「叙拉古」只数作战区里不同的叙拉古干员,整备区里的拉普兰德不算在这 3 名里;拉普兰德每回合只在她经历的第一次主动刷新时叠层(免费刷新也算),那次刷新时叙拉古没有激活,这一回合就不会再叠。
- **加成怎么叠**:盟约、策略、装备、机变卡和特质给的「攻击力 / 防御力 / 生命值 +X%」与技能的「攻击力 +X%」**相加**后再乘到属性上(官方:直接乘算),例如精准 +130%、独行 +60%、一件 +40% 的装备合计 +230%,而不是 ×2.3×1.6×1.4。「伤害提升至 X%」「受到的伤害 +X%」(维多利亚、谢拉格、奥术、脆弱等)这类伤害倍率之间仍然相乘;但同名效果只算一个——同一战场两名玩家都开了「奥术」时,敌人身上只挂一层奥术易伤,取两人中较高的那一个(不再相乘);两名「灵知」的坚冰、两名「莱恩哈特」/「缄默德克萨斯」的法抗削减也一样只算最强的一个。「坚守」的反伤计入被打的那名成员(个人对领袖伤害统计里能看到)。
- 每局会随机**禁用**若干盟约(标准模拟较少),它们的干员不会出现在商店;开局的「确认本局信息」会列出,选择策略时(「查看禁用盟约与干员」)和对局中(左上角 🔍)也能再查看。
@@ -134,8 +139,8 @@
- 左下角「交流」打开表情面板:6 套主题 × 6 个表情,左右滑动(或方向键)换主题。表情以气泡形式显示在你头像旁 3 秒,发送冷却 1 秒。
- 休整期、或自己的作战结束后(显示「作战结束,等待队友完成作战」),点左侧队友头像 →「前往查看」观看他的战场;自己的作战进行中不能观战。联防和最终攻势中可以用 ‹ › 切换左半 / 全景 / 右半。
- 被淘汰后可以自由观看任何战场,而且**不用每回合重选**:你上次点「前往查看」的是谁,之后每个阶段(回合开始、机变、休整期、作战、最终攻势)都会自动跟到他的阵地或战场;没选过或他也被淘汰时,跟随座位顺序第一位还在场的博士(包括 AI 队友)。阶段中途换人立即生效,之后就跟随新选的人。
- **观战席**(本作新增,原版没有):每个同盟房间另有 2 个观战席。在大厅输入同盟密钥后点「观战」进入(模拟进行中也可以),不占博士席位、不算参战人数,也不能购买、部署、准备或发送表情;观看方式与被淘汰的玩家相同——自动跟随你上次查看的博士(没查看过则是座位顺序第一位还在场的博士):休整期显示他的阵地,作战时观看他的战场,点左侧成员头像可切换查看任何人。断线重连会回到观战席;创建者可以在等待室用观战者名字旁的 ✕ 将其移出;观战者在等待室有空位时可以点「入座」成为博士。
- **观看谁,盟约栏就显示谁的盟约**:查看队友的阵地或战场时(休整期、作战结束后、被淘汰后自动观战),左上的盟约栏换成**该队友的盟约、人数和层数**,前面有琥珀色的「👁 队友名」标签,点开的盟约详情也写着「队友名 的盟约」(成员一栏只标出他在场上的干员,整备区看不到);作战中层数实时增长(与他自己看到的一致)。联防和最终攻势里跟随 ‹ ›:镜头在队友的半场时显示他的盟约,在你自己的半场时显示你自己的;「全景」时,你在这个战场上作战就显示你自己的,你不在这个战场上(漏怪后观看联防、被淘汰后观战)则显示你点「前往查看」选中的那名队友,没选过就显示该战场的第一名玩家(1 号联防者 / 这一组的第一位),**不会显示你自己的**。在这样的战场上点某名队友的头像 →「前往查看」,镜头会直接移到他的半场,‹ › 上的「👁 队友名」与盟约栏的标签一致;‹ › 旁边有「返回战场」(被淘汰时没有)。盟约栏换人时,从盟约栏打开的盟约详情会自动关闭。点开场上某个干员的详情卡时,卡上的盟约(以及点它打开的盟约详情)属于**这个干员的主人**,与盟约栏当前显示谁无关——例如在「全景」点队友的干员,看到的是「队友名 的盟约」及他的人数和层数;在队友的半场点你自己的干员,看到的是你自己的。回到自己的阵地 / 战场后恢复成你自己的盟约。作战结束后观看队友时刷新页面 / 断线重连,仍会回到正在观看的队友战场,「👁 队友名」和「返回战场」都还在。已被淘汰的队友不再显示盟约(也无法被查看)。
- **观战席**(本作新增,原版没有):每个同盟房间另有 2 个观战席。在大厅输入同盟密钥后点「观战」进入(模拟进行中也可以),不占博士席位、不算参战人数,也不能购买、部署、准备或发送表情;观看方式与被淘汰的玩家相同——自动跟随你上次查看的博士(没查看过则是座位顺序第一位还在场的博士):休整期显示他的阵地,作战时观看他的战场,点左侧成员头像可切换查看任何人。断线重连会回到观战席;创建者可以在等待室用观战者名字旁的 ✕ 将其移出;模拟开始后,顶栏延迟旁会出现一个「👁 人数」的小胶囊(没有观战者时不显示),点开是观战者名单(离线的有标记),创建者可以在名单里用 ✕ 把观战者移出,被移出的人立刻回到大厅、不再收到战场画面(观战席上限仍是 2 个,不能调整);观战者在等待室有空位时可以点「入座」成为博士。
- **观看谁,盟约栏就显示谁的盟约**:查看队友的阵地或战场时(休整期、作战结束后、被淘汰后自动观战),左上的盟约栏换成**该队友的盟约、人数和层数**,前面有琥珀色的「👁 队友名」标签,点开的盟约详情也写着「队友名 的盟约」(成员一栏标出他在场上的干员;休整期查看时他整备区、临时整备区里的干员也会列出、标为「整备区」,和他自己看到的一样计数:投资人、远见、奇迹这类含整备区的盟约把整备区的算进成员人数,临时整备区的哪个盟约都不算;作战中只列出场上的);作战中层数实时增长(与他自己看到的一致)。联防和最终攻势里跟随 ‹ ›:镜头在队友的半场时显示他的盟约,在你自己的半场时显示你自己的;「全景」时,你在这个战场上作战就显示你自己的,你不在这个战场上(漏怪后观看联防、被淘汰后观战)则显示你点「前往查看」选中的那名队友,没选过就显示该战场的第一名玩家(1 号联防者 / 这一组的第一位),**不会显示你自己的**。在这样的战场上点某名队友的头像 →「前往查看」,镜头会直接移到他的半场,‹ › 上的「👁 队友名」与盟约栏的标签一致;‹ › 旁边有「返回战场」(被淘汰时没有)。盟约栏换人时,从盟约栏打开的盟约详情会自动关闭。点开场上某个干员的详情卡时,卡上的盟约(以及点它打开的盟约详情)属于**这个干员的主人**,与盟约栏当前显示谁无关——例如在「全景」点队友的干员,看到的是「队友名 的盟约」及他的人数和层数;在队友的半场点你自己的干员,看到的是你自己的。回到自己的阵地 / 战场后恢复成你自己的盟约。作战结束后观看队友时刷新页面 / 断线重连,仍会回到正在观看的队友战场,「👁 队友名」和「返回战场」都还在。已被淘汰的队友不再显示盟约(也无法被查看)。
## 9. 结算称号
@@ -169,6 +174,25 @@
| `Space` | 准备就绪 / 取消准备;独立模拟的作战中暂停 / 继续 |
| `Esc` | 取消 / 关闭:依次关闭表情面板、敌人预览、盟约浮层、详情卡、本局信息 / 敌方情报(固定,不能修改) |
刷新、冻结、升级、撤退、出售和准备只在休整期有效,撤退 / 出售要先点选一名干员。方向轮盘(方向键预览、`Enter` 确认、`Esc` 取消)、「玩法说明」和「干员调配」的翻页键是这些界面自己的按键,不能修改。
刷新、冻结、升级、撤退、出售和准备只在休整期有效,撤退 / 出售要先点选一名干员。方向轮盘(方向键预览、`Enter` 确认、`Esc` 取消;按 Tab 先到轮盘上的「点击取消」,在它上面按 `Enter` 是取消,Tab 到其他按钮上按 `Enter` 没有反应)、「玩法说明」和「干员调配」的翻页键是这些界面自己的按键,不能修改。
**修改快捷键**:标题画面右上角或对局左下角的「设置」→「快捷键」。点一下要改的按键(只用键盘时按 Tab 移到它上面再按 Enter),出现「按下新按键…」后按新的键;按 `Esc` 取消,设置窗口不会关闭。可以用字母、数字、空格、标点符号键和 Delete / Insert / Home / End / PageUp / PageDown;`Esc`、`Tab`、`Enter`、方向键和 F1–F12 留给界面使用,也不能搭配 Ctrl / Alt / ⌘。新按键已被另一个快捷键使用时两者互换,列表下方会写明(例如把「撤退选中干员」改成 `X`,「出售选中干员」就换成原来的 `Q`)。「恢复默认」改回上表的按键。修改立即生效,商店栏、准备按钮和单位底部「撤退 / 出售」按钮上显示的按键也跟着变;设置和音量等一起保存在当前浏览器里(换浏览器或清除网站数据后回到默认)。按键按键帽上印的字符识别(AZERTY 等键盘按印着的字母),俄文等非拉丁布局按键位识别。手机等纯触屏设备要连接键盘才能用快捷键,设置里的快捷键列表默认折叠。
**修改快捷键**:标题画面的「设置」按钮、大厅和房间顶栏的「设置」(玩法说明左边)或对局左下角的「设置」→「快捷键」。点一下要改的按键(只用键盘时按 Tab 移到它上面再按 Enter),出现「按下新按键…」后按新的键;按 `Esc` 取消,设置窗口不会关闭。可以用字母、数字、空格、标点符号键和 Delete / Insert / Home / End / PageUp / PageDown;`Esc`、`Tab`、`Enter`、方向键和 F1–F12 留给界面使用,也不能搭配 Ctrl / Alt / ⌘。新按键已被另一个快捷键使用时两者互换,列表下方会写明(例如把「撤退选中干员」改成 `X`,「出售选中干员」就换成原来的 `Q`)。「恢复默认」改回上表的按键。修改立即生效,商店栏、准备按钮和单位底部「撤退 / 出售」按钮上显示的按键也跟着变;设置和音量等一起保存在当前浏览器里(换浏览器或清除网站数据后回到默认)。按键按键帽上印的字符识别(AZERTY 等键盘按印着的字母),俄文等非拉丁布局按键位识别。手机等纯触屏设备要连接键盘才能用快捷键,设置里的快捷键列表默认折叠。
## 12. 反馈问题
遇到报错、卡住或结果不对时,打开「设置」→「问题反馈」,点「复制诊断信息」,再到 [GitHub issue](https://github.com/sganggs/Stronghold-Protocol/issues) 里描述发生了什么并粘贴进去。诊断信息包括游戏版本、浏览器和设备、当前所在的阶段与回合,以及打开页面以来记录到的错误(最多保留最近 40 条)。战斗中复制时,默认附上这场战斗的数据,开发者可以用它重现这场战斗;不想附上可以关掉「附上本场战斗」。诊断信息只在你的浏览器里生成,不会自动上传;你和其他玩家的名字、房间号会被替换成 `<me>`、`<player2>`、`<room>` 这样的占位符。剪贴板不可用时(部分 App 内置浏览器),内容会显示在下方的文本框里,手动全选复制即可。
## 13. 统计(本机)
标题画面右上角、大厅和等待室顶栏的「统计」打开**统计数据**页:这台设备上你参与过的对局的记录。记录只存在这个浏览器里(`localStorage`),不会上传到服务器,服务器也不保存任何对局历史;换浏览器、换设备或清除网站数据后,记录不会跟过去。要搬家,用页面顶部的「导出」(一个 JSON 文件)和另一台设备上的「导入」(按记录合并,重复的跳过)。
| 页签 | 内容 |
|---|---|
| 总览 | 总局数、胜场、胜率、到达 / 通关隐秘核心的次数、最远回合、累计时长;各难度一行;六种结算称号各拿过几次 |
| 策略 | 每个策略用了几局、通过几局(你自己撑到最后、队伍获胜才算通过)和通过率 |
| 战斗累计 | 击倒敌人、领袖伤害、造成伤害等结算页上的数字,在打到结算的对局里累加 |
| 对局记录 | 每局一行,最新的在上面(一次显示 50 行,「显示更多」继续);点一行回看那一局的结算页(最新的 30 局可以回看,更早的只留汇总,点不开) |
**什么算一局**:打到结算页的对局,以及中途退出(「放弃模拟」)前至少清完 1 个回合的对局,计入以上所有统计——和官方一样,中途退出按淘汰处理,通过的回合数就是退出时已经通过的回合。**进去就退、没清完第一个回合就退的对局不计入统计**(不算一局,不影响胜率和策略通过率),但会留在「对局记录」里,标着「中途退出」和「不计入统计」。已经被淘汰后再退出的对局算(你输了这一局)。下面这些不会记录:观战;被服务器结束的对局(服务器重启、同盟解散);关掉页面后没有再回来的对局。
**存储与隐私**:最多保留最近 1000 局,所有记录加起来不超过约 0.8 MB(超出时最旧的先丢)。只有最新的 30 局保留完整的阵容、盟约和队友信息,更早的压缩成你自己的一行汇总。同盟对局的记录里有**队友的代号**,它们保存在本机,也会出现在你导出的文件里;不会发给任何人。想删掉就点「清空」(会先确认),或清除这个网站的数据。读不出来的存档(损坏的数据)会另存为备份并重新开始记录,不会让页面出错;由更新版本导出的文件会被拒绝导入,也不会被旧版本覆盖。
+133 -28
View File
@@ -3,7 +3,9 @@
Audience: **content authors** (kits, bonds, garrisons, items, bands, enemies, bosses, devices, choices) and the
**match owner** who drives `Battle`. The normative contract is DESIGN.md §5 ([design/engine.md](design/engine.md));
this file documents the concrete implementation, every hook and helper, the SkillSpec schema with worked examples,
the profession defaults and the test harness. Everything here is deterministic: the only randomness is `battle.rng()`.
the profession defaults and the test harness. Everything here is deterministic: the only randomness is `battle.rng()`,
and the only floating-point functions are correctly rounded ones (`detmath.js` below), so a battle gives the same bits in
every browser and on the server.
```
server/sim/
@@ -13,6 +15,8 @@ server/sim/
tiles (relocation, bodies, tactical points), displacement, economy (DP, layers, coins), events
constants.js TICK, MOVE_SCALE, ATTACK_PAUSE, element numbers, tuning knobs
rng.js mulberry32 PRNG (+ int/range/chance/pick/shuffle/weighted)
detmath.js hypot / powi / sin / cos / atan2 with the same bits on every engine — the sim never calls the
implementation-approximated Math functions (hypot, sin, cos, atan2, pow, ** …; lint + test enforce it)
grid.js stage grid, tile semantics, 8-dir A* (no corner cutting), obstacles
units.js Unit model + stat aggregation
buffs.js Buff model, mod keys, status catalogue
@@ -180,10 +184,20 @@ by a `battleStart` handler covers it like a later knock-out (机变 征召 −50
`SpawnSpec = { time, enemyKey, routeIndex, count=1, interval=0, mods:{hpMul,atkMul,defMul,resMul,speedMul}, sourcePlayerId?,
bounty?:{coins, ownerPlayerId}, tag?:'boss'|'part'|'escort'|'bounty', ownerPlayerId?, pos?:[r,c], route?:RouteSpec, countInTotal? }`.
A spec entry the battle queues (`_queueSpawn`) is `inTotal` when it counts (`countInTotal`, `tag`, data `notCountInTotal`):
that is the HUD capsule's own set (§1.3); everything content spawns at runtime (a split, a summon — a boss's included —,
a part) is not, even when it counts for LP (`counted`).
`RouteSpec` accepts data/waves.json routes (`{motion, start, end, checkpoints:[[r,c]…], steps:[{t:'move',p},{t:'wait',s},{t:'disappear'},{t:'appear',p}]}`)
and research routes (`{m, s, e, cp:[['MOVE',r,c]…]}`). `spawnsFromTemplate(waveEntry, {mods})` (simdata.js) converts a
template into `{ routes, spawns, maxPlayTime, overrides, extraRoutes }` (non-spawn `action` entries are skipped; `unharmful`
and `tag:'part'` spawns don't count in `total`).
and `tag:'part'` spawns don't count in `total`). The boss templates' 传送门 reads as "reappear on the far side" (GitHub
#336, PR #337): four official routes write the crossing as `disappear → wait → move` to the exit with no `appear` (their
twins spell the `appear` out), so normalizeRoute re-inserts the exit `appear` ahead of that move — otherwise the enemy
walks the rest of its route hidden and leaks unseen (test/sim/portal-appear.test.js). A route that ENDS on an entrance
(`tile_telin`; the boss / Hidden Core circuits end on [1,3] / [1,17]) does not leak there: the enemy vanishes, comes
out of the far exit (`tile_telout`; both entrances feed [5,10], the pairing of every explicit `disappear`/`appear` pair
of the data) after 3 s hidden inside — the wait of the explicit crossings (85 of 95) — and walks on to the blue door
on the entrance's side, where it leaks (ai.js portalPickup; [ASSUMED] the pairing and the 3 s).
A `bounty` pays `coins` once, when the enemy really dies (not a knock-out it survives; a leak pays nothing), to
`Battle._bountyPayee`: the player of the operator or summon that dealt the blow, if that player is in the battle; any
@@ -194,13 +208,19 @@ Leader parts (`tag:'part'`) pass damage to their leader with `loseHp(leader, sha
(无来源, credited to the attacker's `bossDamage`): `PART_TRANSFER` 1 for 斩胄之剑 / 破胄之锤 (`BLADE_TRANSFER`, the same
constant) and 碎铳之簧 (PRTS "受到伤害时令假想敌:胄/铳受到等量的无来源生命流失"; DESIGN §20.10, §20.13). Content may replace a part mid-battle: every 剑/锤 sortie (content/bosses.js `kitBlade`)
ends by spawning a new 初始模式 copy on its level branch route (`left_hand_origin` / `right_hand_origin`) with the old
HP, then `kill(old, null)` — uncounted, no bounty; the client sees a `die` and a `spawn`.
HP, then `kill(old, null)` — uncounted, no bounty; the client sees a `die` and a `spawn`. The Hidden Core 铳's 【末日布道】
puts every 碎铳之簧 into 追逐模式 for its whole 5 s gain (`dog_duration`; invulnerable, no ordinary attacks): the spring
keeps following the casting gun after reaching it (PR #347; until 0.2.1 arrival or 1 tile ended the chase), and each
gun casts on its own data timer (20 s after it spawns, then every 45 s), so on a pair field the later call retargets
every spring to that gun for a new 5 s (PRTS: 「持续召唤场上所有“碎铳之簧”向自身移动」).
WALK legs pathfind on the stage grid inside the rect with the official flow field (grid.js: 4-direction SPFA from the
destination, crates cost 1000, then Bresenham line-of-sight smoothing — research 08 §3.4). The official route stays
unless 0.1.0's road-over-floor preference route (the fewest non-blockable tiles among equal-length chains, a line of
sight that never covers floor — diagonal-step corners included — its grid route does not walk; user playtest #2 item 2)
crosses strictly fewer non-blockable tiles (floor / gate lanes); "crosses" = passes through the tile's interior, a
crosses strictly fewer non-blockable tiles (floor / gate lanes) and no more 深水区 (GitHub #375: on 战场#08(下半) the
patrolling 铳 / 卢西恩 waded through two water tiles to dodge one floor tile; the official upper road crosses one);
"crosses" = passes through the tile's interior, a
corner touch does not count (`grid.js` `segmentTiles`; community report D5 after 0.1.0: 战场#04's lower-gate enemies cut
diagonally from row 9 into row 10 as officially), and on equal counts the official route stays.
test/sim/pathing-official.test.js lists the 21 of 154 stage routes that still differ from the official ones (战场#01's
@@ -257,7 +277,9 @@ included), summons (流形's melee copy) and a ranged operator standing on a mel
enemies it blocks, in range or not, whatever its facing, and targets them first ("可以选择且优先选择阻挡单位", PRTS 选择器;
the user's rule after playtest #6, "阻挡了就一定要能打到": officially the collision pushes a blocked enemy to its blocker's
front — `Battle.blockedTargets`, used by `ai.js acquireTargets` and the skills' DEFAULT trigger, and
`sortEnemyTargets`; "自身这格内" rules name the blocked enemies separately — 瑕光 S2, PRTS 备注). A kit that picks its own
`sortEnemyTargets`; one exception, the owner's decision of 2026-10-08 (GitHub #220): the 速射手 air priority —
priority 'fly', trait 优先攻击空中单位 — comes before its own blocked enemy, so a 速狙 on a melee tile shoots a flyer in its
range first, PRTS 索敌的概念 「阻挡(近战限定)→特殊优先级→…」; every other priority keeps the blocked enemy first; "自身这格内" rules name the blocked enemies separately — 瑕光 S2, PRTS 备注). A kit that picks its own
targets from the range (`beforeAttack`) adds `battle.blockedTargets` to its candidates (深靛, 迷迭香 S3, 荒芜拉普兰德 S2,
佩佩 S2, 灵知 S3, 远牙 S3); only skill texts that exclude targets keep their rule (普罗旺斯 S2: none above 80 % HP; 寒檀
S2: icicles on random tiles of her range). This replaces the
@@ -358,6 +380,10 @@ one; the sourceless 毒雾 of 假想敌:蚀裂 skips a 隐匿 one but still re
范围内的所有单位持续视为受到源石污染区影响,技力自然回复速度倍率-80%,每0.5秒受到50真实持续伤害(同类效果取最高)") is the same
kind of damage — 无来源 true (like the terrain it stands for [ASSUMED]), the chimera credited — not a 流失 (player report
D1 audit): radius 1.2, a tick every 0.5 s, one tick per unit per 0.5 s however many chimeras reach it (`mem.chimeraAt`).
Every enemy effect PRTS marks "不可对空" skips a flying ally (the 炎佑 dragon, `isFlying`): the 死亡爆炸 of 高能源石虫 / 冰爆源石虫 /
卷心籽 (archetypes.js `deathBoom`, `noAir` by default), 水遁忍者's 漩涡形态, 鼎沸's pulses, “萨科塔昂首”'s 祈祷邀约, the attack of
“斩胄之剑” / “破胄之锤”, the splash and blast of 烹泉 / 沏虹, 庞贝's blast and 集团军重型火炮's shell and 燃烧区域 (the first six
reached it until 0.2.1 — a community report).
**Knock-outs that are not deaths** (`content/enemies/archetypes.js`; player reports after 0.1.0): a `killed` ability that keeps the
enemy alive hides the knock-out from every later `kill` handler, the kill count, kill credit and the bounty — they all
@@ -406,13 +432,32 @@ recorded for the player whose half contains the goal it reached, with `sourcePla
### 1.3 BattleResult
```js
{ time, reason: 'cleared'|'timeout'|'forced', killed, total, errors, unspawned?: [{enemyKey,time,tag}], bossHpLeft?,
perPlayer: { [playerId]: { killed, total, perfect,
{ time, reason: 'cleared'|'timeout'|'forced', killed, total, resolved, errors, unspawned?: [{enemyKey,time,tag}], bossHpLeft?,
perPlayer: { [playerId]: { killed, total, resolved, killedInTotal, leakedInTotal, perfect,
leaked: [{ enemyKey, mods, lpr, sourcePlayerId, tag, counted, boss?, spawned }],
layerGains: {bondId: n}, coins, damageDealt, bossDamage, healingDone, deaths,
unitsEnd: [{ uid, id, defId, hpPct, sp, skillActive, alive }], // operators + board summon pieces; sp = skill.spTotal
unitStats: [{ id, uid, defId, name, kind, dmg, kills, heal, taken, attacks }] } } }
```
- **Two sets of counters, and they never cross.** `counted` drives the official LP / 完美作战 / `killed` reading (master's,
untouched): every counted enemy — a runtime split child, a summon, a boss's summon included — is in it. `inTotal` (the
new flag, `spawns.js`) is the HUD capsule's own set (DESIGN §14 顶栏胶囊): only the enemies the field itself scheduled
(`_queueSpawn`, and only those that `counted` — `countInTotal: false`, `tag: 'boss'` / `'part'`, data `notCountInTotal`
stay out) are `inTotal`. Everything a runtime `spawnEnemy` makes defaults to `inTotal: false` (an explicit
`inTotal: true` is the content back door); a 变身 / 重生 (`setForm`, a revive) is the same enemy and keeps its flag.
- `total` counts the `inTotal` enemies (the capsule's denominator), `killedInTotal` / `leakedInTotal` (battle level and per
player) count those that were knocked down or leaked, and `resolved = min(total, killedInTotal + leakedInTotal)` is the
capsule's numerator (official: 开局 0/3 → 漏一个 1/3 → 打死一个 2/3 → 打死会分裂的 3/3). A leaked split child or summon
moves none of them, while `leakedCount` / `leaked[]` still charge its LP and break 完美作战. The per-player numbers are
attributed like their `counted` twins — a knock-out to the enemy's owner (as `total`), a leak to the player whose half it
reached (`_recordLeak`) — so in a two-half field (the 联防 lane, col 18 → col 2; the boss pair's crossing routes) an enemy
that spawns on one half and leaks on the other is in one player's `total` and in the other's `leakedInTotal`, and each
player's own `resolved` clamps it away (0 and 0 for an enemy the field resolved). Only the battle-level numbers are exact:
the match never sums the per-player `resolved` — `validateClientResult` keeps the result's own and `m.public` publishes
it (design/network.md 顶栏胶囊).
- `killed` therefore may **exceed** `total` (it counts runtime children, they do not): 4 磨砻 killed plus one of their 8
木制瑞印 reads `killed` 5 with `total` 4 and a capsule of 4/4. `spec.js compactResult` and the match's validation bound
`killed` by the spec's content-spawn allowance (`4·spawns + 100`) instead of by `total`.
- `cleared`: no spawns pending and no enemy alive (or the shared boss pool reached 0 — a pool never holds less than
`BOSS_POOL_MIN_HP` (1): the hit that would leave less takes the rest and the browser's `LocalBossPool` reads less as
0, so float dust of the pool sync can never keep a leader alive; user playtest #6). A boss/hidden battle **with a
@@ -520,6 +565,16 @@ Aggregation: `ATK/DEF/maxHp = (base + Σflat) × (1 + Σpct) × Πmul`, for ATK
percentages: `ATK = ((base + ΣatkFlat) × (1 + ΣatkPct) + ΣatkFinal) × ΠatkMul`; `res = clamp((base + ΣresFlat) × ΠresMul, 0, 100)`;
`aspd = clamp(base + Σaspd, 20, 600)` (floor 20: PRTS 数值范围 ATTACK_SPEED 默认下限; user playtest #6); `interval = bat × (1 + ΣbatPct) × 100 / aspd`; `moveSpeed = (base + ΣmoveFlat) × ΠmoveMul`;
tiles/s = `moveSpeed × MOVE_SCALE (0.5)`. A maxHp change keeps the HP ratio. Elite stats (module included) come from data.
The attack cooldown `atkCd` starts at `interval` and counts down by `TICK`; what is left within 1e-9 of 0 is 0
(`ai.js attackCountdown`, the tolerance of skill `timeLeft`, buff intervals and `every()`; PR #402 by @Cloudnyco, 0.2.2),
so an interval of a whole number of ticks takes exactly that many: 1 s = 30 ticks, 3 s = 90 (floating point alone left
2.1e-16 after thirty steps of 1/30, and every such attack came a tick late — 31, 91). The same countdown runs the 双眼皮
turrets (`content/devices.js`) and 寒檀's S2 icicles; the drones of 澄闪 / 荒芜拉普兰德 and 电弧's 赛柯 volleys compared
with 1e-9 already. Content clocks that count up and restart from 0 (the boss self-timers of `content/bosses.js`, kit auras)
are not covered. Official (PRTS 作战机制/sandbox 「帧对齐机制对攻速的影响」): the time unit is one frame, 1/30 s, and since
the 2019-12-24 update a non-integer frame count is rounded (四舍五入; before, 进一取整); an exact one stays. Here a
non-integer count still ends on the tick that crosses 0 — ⌈30 × interval⌉ ticks (1.2 s = 36, 1.25 s = 38; 能天使's
0.678 s is 21 here, 20 frames officially): an older difference from the official rounding, not changed by this rule.
**Which bucket (PRTS 游戏数据基础 属性基本公式 / 作战机制, DESIGN §20.10).** `Σpct` is the official **直接乘算** class — its
values are summed (`A = (A₀ + D_p)(1 + D_t)`, D_t = t₁ + … + tₙ): a skill's or talent's "攻击力+X%" **and** every "+X%"
@@ -527,8 +582,9 @@ ATK / DEF / max HP bonus of the 卫戍 systems — 盟约, 策略 (bands), 装
卫戍协议:盟约 下半/PRTS盟约记录 "盟约效果,策略效果,装备效果提供的属性加成均为直接乘算"). Content builds those with
`content/support directMods({ atk, def, hp })` (constants.js `DIRECT_BONUS_STACKING` 'add'; 'multiply' = the v2.5
per-source ×(1 + x), which compounded with layers: user report after playtest #6). `Πmul` is for 最终乘算 / "提升至X%"
effects (炎佑 ×1.5 at 9 炎, 虚弱, 停顿 …) and the char_attribute_mul 特质 ("攻击力和生命值+20%", a rune on the base
attributes). `atkFinal` is the **最终加算** (PRTS `A_f = F_t[(A + D_p)(1 + D_t) + F_p]`: added after the 直接乘算, inside
effects (炎佑 ×1.5 at 9 炎, 虚弱, 停顿 …), the char_attribute_mul 特质 ("攻击力和生命值+20%", a rune on the base
attributes) and the unit's 练度 `cultMul` (自持有, the same char_attribute_mul key, 0.2.2: ATK / DEF / max HP of the owned
operator's tier, §12). `atkFinal` is the **最终加算** (PRTS `A_f = F_t[(A + D_p)(1 + D_t) + F_p]`: added after the 直接乘算, inside
the 最终乘算): 阿戈尔's devoured base ATK ("基础攻击力(最终加算)", DESIGN §24.7) — a skill's ATK +% does not scale it.
Damage multipliers (`dmgDealtMul`, "伤害提升至X%"; `*TakenMul`, 脆弱 / "受到的伤害+X%") multiply each other
(PRTS 游戏数据基础 "同种倍率间叠乘"), same-named statuses keep the strongest — catalogue statuses (§3) and the content
@@ -592,7 +648,9 @@ and the 浮空 weight rule, then `statusApplied { source, target, status, durati
= the target carried no buff of that status before (a refresh / a weaker "取最高" application is not an entry: "进入…时"). Effects follow the official term table
(`gamedata_const.termDescriptionDict`, `ba.*`). Same-key statuses refresh to the longer duration, except the
"同名效果取最高" ones marked *strongest* below: the strongest value wins, a weaker application never overrides it and,
if it outlasts it, resumes when the strong one expires (pass `refresh` to opt out); `stackAs` = the strength such an
if it outlasts it, resumes when the strong one expires (pass `refresh` to opt out) — every such waiting application keeps
its own end, and they resume strongest first (`buff.data.tail` is a chain ordered by strength: of 30 % / 25 % / 20 %
applied one after another, the 25 % follows the 30 %, then the 20 % — GitHub #342, PR #348); `stackAs` = the strength such an
application competes with instead of its `value`, which stays its effect (Raidian S3's 虚弱: PRTS 备注 "在叠加时视为90%…的
虚弱(仅影响叠加优先级,不影响实际效果)").
`battle.applyStrongest(target, key, { duration, value, mods: (v) => mods, source })` gives a content effect that is not a
@@ -739,7 +797,8 @@ selection (`injuredAlliesInKeys`) takes it — 凯尔希 on her Mon3tr (PRTS "
describes as raising the target's 生命回复速度 are hpRegen buffs, never `heal` calls, so 禁疗 and 无法被友方治疗 (`noHeal`:
收割者 / 不屈者 / 武者) do not stop them: the 吟游者 trait (professions.js `bardRegen`, 分支特性信息 吟游者; 魔王's 微尘 ×1.5
through its `bardRegen` hook; 浊心斯卡蒂 and her 海嗣), 调香师's 熏衣草, 瑕光 S2, 铃兰 S3 (none in its first second, refreshed
every second), 锡人's 炼金单元 (GitHub #96 / #137); `overheal`
every second), 锡人's 炼金单元 (GitHub #96 / #137) and 引星棘刺's S1 / S2 炼金单元 (the same wording and blackboard key as
锡人 S2; a community report, 0.2.2); `overheal`
turns the excess into an `overheal` shield. `battle.loseHp(target, amount, { source, from, tags, silent, sourceless })` = HP
loss ignoring DEF/RES/shields/dodge (流失); `sourceless: true` makes it 无来源 ("受到等量的无来源生命流失": hooks see no source,
`source` keeps the credit — stats and the per-player shared-pool tally), as does a 无来源 `from`. A 流失 skips the damage
@@ -829,7 +888,7 @@ instance) and skip `'counter'` / `'reflect'` damage. When the guard trips, the l
| `addBuff(unit, buff)`, `removeBuff(unit, key)` | §3 |
| `spawnToken(ownerUnit | playerId, tokenId, row, col, { def, stats, hp, duration, untargetable, dir, kit, force, anySource })` | field tiles; def from data/tokens.json `variants[ownerChessId]` for the owner unit's selected skill / module (`tokenDef`); `dir` defaults to the owner unit's (else the player's: RIGHT, mirrored side LEFT; a legacy `facing` ±1 is still read); returns the token or null (tile busy; or the owner runs a **non-default** skill that does not produce the token — `producesToken` — unless `anySource`: kit install hooks written for the default skill run under every skill). `spawnDevice(key, row, col, { …, dir })` likewise |
| `tokenDef(tokenId, ownerUnit | chessId)`, `producesToken(ownerUnit, tokenId)` | the token def a summon of that owner gets — `getToken(id, owner.defId, owner.def.loadout)`, exact even when two players of one field give the same chess different loadouts (prefer it over an id-only `battle.data.getToken(id, unit.defId)` for summon stats / blackboards); whether the owner's loadout makes the token (DATA.md §14 `sources` has 'skill' or 'talent'; true when the data does not tell: no own variant, player-owned summons) |
| `spawnEnemy(enemyKey, { routeIndex, route, pos, mods, tag, sourcePlayerId, ownerPlayerId, bounty, countInTotal, def })` | returns the enemy, or null past `MAX_ALIVE_ENEMIES`; `def` = inline record (enemies.json shape or normalised) for keys missing from data |
| `spawnEnemy(enemyKey, { routeIndex, route, pos, mods, tag, sourcePlayerId, ownerPlayerId, bounty, countInTotal, inTotal, def })` | returns the enemy, or null past `MAX_ALIVE_ENEMIES`; `def` = inline record (enemies.json shape or normalised) for keys missing from data. A runtime spawn is never `inTotal` (§1.3 — the HUD capsule's own set) unless content passes `inTotal: true` explicitly |
| `spawnDevice(key, row, col, { hp, obstacle, blockCnt, name, def, res, atk, bat, aspd })`, `setObstacle(r, c, on)` | obstacles re-path enemies; spawnDevice returns null outside the rect, on a living unit or on a knocked-out operator's tile |
| `isReservedTile(r, c)` | true when a living unit stands there or it is the rest tile (`restTile`) of an ally piece that has not deployed yet / waits to redeploy — the tile a knocked-out operator lies on, else the home tile — every automatic picker (the 突袭 landing tile, tactical points, summon / device tiles) must skip these (`findTacticalPoint` does) |
| `downOn(r, c, except?)`, `restTile(u)` | the down operator (`isDown`: knocked out or forced out) lying on a tile (no ally deploys / moves there: `_deploy`, `spawnDevice`, `relocate`); the tile a withdrawn ally comes back on — its `body` tile when down, else its home (§1) |
@@ -837,7 +896,7 @@ instance) and skip `'counter'` / `'reflect'` damage. When the guard trips, the l
| `allySelectable(ally, by)`, `alliesFor(by, ownerId?)` | may an ability of ally `by` select `ally` — never a 孤立 unit (炎佑, 从不混淆的方向) but `by` itself (PRTS 选择器: "若掩码中孤立为1,且选择器的阵营与目标为友好关系,则不可选中"); `allies()` without the 孤立 ones — content picks ally targets (buffs, auras, 全场 talents, heal picks) through these, `alliesInGrid` too |
| `unitsInGrid(unit, grid, {side, extend})`, `alliesInGrid(unit)`, `enemiesInRadius(x, y, r, centre?)`, `foesInRadius(x, y, r, centre?)`, `alliesInRadius(x, y, r, ownerId?)` | grid offsets are relative to facing RIGHT, rotated by `unit.dir`; enemies by their body (§2 hit areas: a huge enemy on every tile it occupies / within `r` of its rectangle; `centre` = splash around a target, a 中点判定 by position; `foesInRadius` = the enemies an operator's area effect can select — no untargetable enemy, none whose 隐匿 is on (`targeting.js enemyStealthed`: not blocked, not revealed, not within 3 s of its last block), PRTS 作战机制 §AOE伤害判定 — while enemy-side auras / heals and collisions keep `enemiesInRadius`) |
| `enemiesInKeys(keys, attacker, profile)`, `blockedTargets(unit, profile)` | targetable enemies whose body is on the tiles (a huge one listed once); the enemies a unit blocks — always selectable by it, a ranged operator on a melee tile included (§1.2 Blocking) |
| `setAllyTarget(unit, on)`, `allyTargetsInKeys(keys, attacker)`, `isAllyTarget(unit)` | an **ally target**: an ally unit our attacks select like an enemy — 白铁's 铁钳号·原型机, a summon of the enemy camp ("可被我方干员攻击但不受伤害", PRTS; kits/ops/op-ironmn.js). The attack loop (ai.js `acquireTargets`) appends the registered ones standing on the attacker's range after every enemy (their 嘲讽等级 −2 puts them last: one target ⇒ an enemy first; all in range / more targets ⇒ them too) and ranged attacks fly to them; heals, skills that pick their own victims and enemies never select them (the kit gives it `untargetable` against enemies and cancels the damage it takes in its `hit` hook). None registered ⇒ the loop is unchanged |
| `setAllyTarget(unit, on)`, `allyTargetsInKeys(keys, attacker)`, `isAllyTarget(unit)` | an **ally target**: an ally unit our attacks select like an enemy — 白铁's 铁钳号·原型机, a summon of the enemy camp ("可被我方干员攻击但不受伤害", PRTS; kits/ops/op-ironmn.js). The attack loop (ai.js `acquireTargets`) appends the registered ones standing on the attacker's range after every enemy (their 嘲讽等级 −2 puts them last: one target ⇒ an enemy first; all in range / more targets ⇒ them too) and ranged attacks fly to them; heals, skills that pick their own victims and enemies never select them (the kit gives it `untargetable` against enemies and cancels the damage it takes in its `hit` hook). A skill that acts on them counts the registered ones like enemies for its automatic start (skills.js `_allyTargetIn` / `allyTargetsOk`, every enemy condition of the trigger rules — the owner's rule of 2026-10-08): one that acts through the unit's own attacks, or whose kit flags `allyTargets: true` because its selectors take them too (`allyTargetsInKeys` / `allyTargetsInRadius`: 蕾缪安 S3's locks, 艾丽妮 S3, 陈 S2, 引星棘刺 S2); [ASSUMED] a skill that picks its victims among enemies only (or whose kit says `allyTargets: false`) is not opened by them and waits for an enemy. None registered ⇒ the loop is unchanged |
| `allies(ownerId?)`, `aliveEnemies()`, `unitAt(r, c)`, `unitById(id)`, `tileInfo(r, c)`, `lowestHpAllyInRange(unit)` | |
| `addLayers(playerId, bondId, n, reason, {source})`, `addCoins(playerId, n)` | layers are a no-op when `flags.layerGainsEnabled` is false (unite/boss); a gain adds at most the room left under `BOND_LAYER_CAP` (999, shared/constants.js `layerGainRoom`: the client's `AddBondCount` min(L + n, 999)) on the live copy — or, without one, on the battle's own gains — and returns what it added (0 at the cap: no hook, no event) |
| `getPlayer(playerId)` | `{ playerId, seat, side, colOffset, mirror, dir (default unit direction: RIGHT, mirrored side LEFT), facing (its sign), bonds (live copy, layers updated by addLayers), bandId, playerEffects, lpForBoss, dp, units }` |
@@ -846,12 +905,12 @@ instance) and skip `'counter'` / `'reflect'` damage. When the guard trips, the l
| `onFieldBoard(r, c)` | whether a field tile lies on the board of a player of this field — `onOwnBoard` of any of `players` (the players whose units the battle holds; an eliminated or absent teammate is not one): both halves of the two-helper 联防 field and of a Final Assault / Hidden Core pair field, the own half only for a lone 联防 helper and on a solo boss field, never a boss field's hand / 临时整备区 rows; the 突袭 landing takes only such tiles (DESIGN §25.18, §26.1) |
| `addDp(playerId, n)`, `retreat(unit, {reason, permanent, dying})`, `relocate(unit, r, c)` | `relocate` only changes the tile (state kept, no event) |
| `moveRedeploy(unit, r, c, { clearSp })` | a 【移动】 (PRTS 术语释义: "不退场,以当前血量在目标位置部署", a special retreat + redeploy): `relocate`'s checks (false = refused, nothing changed), then a new deployment (`deploySeq` / `aggroSeq` / `deployedAt`) and `deploy {initial:false, move:true}` (deploy effects fire again); no `die` / `death`, timer or cost (不屈 / 阿戈尔's revive never see it); HP, buffs and a running skill are kept (owner's deviation, DESIGN §22.3); `clearSp` empties the SP before the deploy handlers run — 乌尔比安 S3's move (kits/ops/chess_char_5_05-ulpia.js) and 【返回】 (`clearSp`; also the 从不混淆的方向 fallback, content/tokens.js) |
| `redeploy(unit, { free=true, tile, keepSp })` | immediate (re)deployment of a dead/retreated ally (full HP, `deploy {initial:false}`); `free: false` pays `base.cost` DP (refused without it); without `tile` it lands on the unit's rest tile (`restTile`: where a knocked-out operator lies, else home); `tile: [r, c]` lands on that tile once (home unchanged; refused when off-rect, occupied or a knocked-out operator's tile, no fallback); `keepSp` keeps SP/charges (保留技力), restored before `deploy` fires — 突袭 raids, 阿戈尔 / 不屈 revives where the unit lies |
| `redeploy(unit, { free=true, tile, keepSp })` | immediate (re)deployment of a dead/retreated ally (full HP, `deploy {initial:false}`); `free: false` pays `base.cost` DP (refused without it); without `tile` it lands on the unit's rest tile (`restTile`: where a knocked-out operator lies, else home); `tile: [r, c]` lands on that tile once (home unchanged; refused when off-rect, occupied or a knocked-out operator's tile, no fallback); `keepSp` keeps SP/charges (保留技力), restored before `deploy` fires — but not for a skill that costs no SP, which the reset re-arms as at every deployment (伊内丝 S3 casts again on a 突袭 landing) — 突袭 raids, 阿戈尔 / 不屈 revives where the unit lies |
| `refreshRange(unit)`, `setExtraRange(unit, keys)`, `rangeChanged(unit)` | rebuild the ranges after changing `unit.rangeGrid` (流形 copies); extra targetable tiles (absolute keys; merged into every later rebuild until set again; `null` clears; never in `baseRangeKeys`): 蕾缪安 wanted, 维娜 S3 |
| `push(enemy, force, {from, dir, fixed, fixedAngle, inward, effect})`, `pull(enemy, force, {to, center, stop})`, `pullToFront(enemy, unit, force)`, `forceLevel(enemy, force)`, `pushDistance(enemy, force, {effect})` | the official 位移 (PRTS 游戏数据基础 §重量公式 / 推与拉; user playtest #6 item 14): 受力等级 = 力度 (微小力 −1, 小力 0, 中力 1, 较大力 2, 大力 3 …) − current 重量等级 (massLevel, 失重 counts). Push distance per level (`constants.js PUSH_TILES`, PRTS 推与拉's 弹道 column): ≤ −3 → 0, −2 → 0.12, −1 → 0.44, 0 → 1.7, 1 → 2.14, 2 → 2.96, ≥ 3 → 3.53 tiles; `effect` = a 特效 push (`PUSH_TILES_EFFECT`: −2 → 0.085, −1 → 0.374, 0 → 1.562, 1 → 1.987, 2 → 2.773, ≥ 3 → 3.331 — 见行者 S1 / S2, `PUSH_EFFECT_SKILLS`; every other pusher uses the 弹道 column [ASSUMED]); radial (away from `from`; the client's buff template `knockback[relative]`: 莫斯提马 S3, 山 S3, 琳琅诗怀雅 S3 — also PRTS 备注 "推开效果为径向推动") unless `dir` (directional; template `knockback[dir]` = Knockback {`_useSourceDirection` true, `_decreaseForceLevelWhenNotInDirection` 2}: 推击手, 野鬃 S2 "往攻击方向" (charpack char_496_wildmn: buff `wildmn_s_2[force]`) — > 45° off or < 0.25 tile ⇒ radial and level −2; `fixed` waives both (圣聆初雪 S1 朝部署方向, `KnockBackWithCharacterDirection`), `fixedAngle` only the angle (见行者 S2)); `inward` = a push towards `from` (薄绿 S2), stopping at the 急停 radius. Pull: level ≥ 0 → to `to` / the 急停 radius around `center`, −1 → 35 % of the way, −2 → 0.03, ≤ −3 → 0; `pullToFront` aims at the 拉力起点 0.5 tile ahead of the unit with the 急停 radius 0.6708 around it (never moves an enemy the unit itself blocks — nor does a pull towards an ally's centre, e.g. the 流形 S3 pulse) A 静态刚体 (data `staticBody`: every air unit of the mode except “炎佑”, plus 昆图斯 — PRTS 特殊机制 "可以进入失衡状态…但物理层面上无法产生任何速度或移动", "与单位的行动方式无关") moves 0 from every source while the skills that reach it still hit it (薄绿 S2, 锏 S3, the 钩索师 …; player report after 0.1.0) |
| `displace(enemy, {x, y}, tiles)` | the raw mover behind push / pull: along passable tiles (by `motion`: FLY in the rect, else ground-passable), no weight rule; nothing for 失衡免疫 (`noDisplace`: 近地悬浮 incl. 喷气人's 飞行模式, 浮空, the 胄 parts, 守墓石像's statue and flight), 静态刚体 (`def.staticBody`) and leaders (`_displaceable`); unblocks + re-paths |
| `push(enemy, force, {from, dir, fixed, fixedAngle, inward, effect})`, `pull(enemy, force, {to, center, stop})`, `pullToFront(enemy, unit, force)`, `forceLevel(enemy, force)`, `pushDistance(enemy, force, {effect})` | the official 位移 (PRTS 游戏数据基础 §重量公式 / 推与拉; user playtest #6 item 14): 受力等级 = 力度 (微小力 −1, 小力 0, 中力 1, 较大力 2, 大力 3 …) − current 重量等级 (massLevel, 失重 counts). Push distance per level (`constants.js PUSH_TILES`, PRTS 推与拉's 弹道 column): ≤ −3 → 0, −2 → 0.12, −1 → 0.44, 0 → 1.7, 1 → 2.14, 2 → 2.96, ≥ 3 → 3.53 tiles; `effect` = a 特效 push (`PUSH_TILES_EFFECT`: −2 → 0.085, −1 → 0.374, 0 → 1.562, 1 → 1.987, 2 → 2.773, ≥ 3 → 3.331 — 见行者 S1 / S2, `PUSH_EFFECT_SKILLS`; every other pusher uses the 弹道 column [ASSUMED]); radial (away from `from`; the client's buff template `knockback[relative]`: 莫斯提马 S3, 山 S3, 琳琅诗怀雅 S3 — also PRTS 备注 "推开效果为径向推动") unless `dir` (directional; template `knockback[dir]` = Knockback {`_useSourceDirection` true, `_decreaseForceLevelWhenNotInDirection` 2}: 推击手, 野鬃 S2 "往攻击方向" (charpack char_496_wildmn: buff `wildmn_s_2[force]`) — > 45° off or < 0.25 tile ⇒ radial and level −2; `fixed` waives both (圣聆初雪 S1 朝部署方向, `KnockBackWithCharacterDirection`), `fixedAngle` only the angle (见行者 S2)); `inward` = a push towards `from` (薄绿 S2), stopping at the 急停 radius. Pull: level ≥ 0 → to `to` / the 急停 radius around `center`, −1 → 35 % of the way, −2 → 0.03, ≤ −3 → 0; `pullToFront` aims at the 拉力起点 0.5 tile ahead of the unit with the 急停 radius 0.6708 around it (never moves an enemy the unit itself blocks — nor does a pull towards an ally's centre, e.g. the 流形 S3 pulse) A 静态刚体 (data `staticBody`: every air unit of the mode except “炎佑”, plus 昆图斯 — PRTS 特殊机制 "可以进入失衡状态…但物理层面上无法产生任何速度或移动", "与单位的行动方式无关") moves 0 from every source while the skills that reach it still hit it (薄绿 S2, 锏 S3, the 钩索师 …; player report after 0.1.0). **失衡 (UNBALANCE)** (PR #392's idea, its timing re-done from the sources): the move stays instant, then the enemy is held in the 失衡 state — a state machine, not a status (PRTS 失衡位移机制; 异常效果 「失衡…不属于异常效果」: no stun, no immunity, SP untouched), `enemy.unbalanceUntil`, read by `ai.js updateEnemy`: no walking (恐惧 / 诱导 included — no fear exemption) and no normal attack started, a swing short of its frame cut, the attack clip's stand ended; and no skill (失衡免疫 「失衡期间无法自主移动、发动攻击、使用技能」): content skills wait (`enemies/helpers.js canCast(e, sil, battle)`, `unbalancedNow`), so do blinks (`blinkForward`) and scripted moves (枯朽之种's dive, the 碎铳之簧 / 铳 charges), and 乌顶巨角卢鲁's clash waits for its end. Left running on purpose: checks the state does not stop (异常效果: 水遁忍者's damage — 「仍然能正常造成伤害」), passive auras and terrain / periodic effects (no skills), death / damage-taken triggers, skill cooldowns (only the cast waits) — and 弧光锋卫's bleed, which is the state's own (天赋 「处于失衡状态时,每0.066s受到400点无来源真实持续伤害」: every 0.066 s for as long as it lasts — until 0.2.2 one hit per tile moved [ASSUMED]). Length, game seconds: a push — the 位移时间 of its 受力等级, PRTS 游戏数据基础 推力-位移近似对应表 (`constants.js PUSH_UNBALANCE`: −2 → 0.2, −1 → 0.4, 0 → 0.8, 1 → 0.9, 2 → 32/30, ≥ 3 → 35/30), the level push() computes (the −2 correction included), kept when the 特效 column or a wall shortens the slide ([ASSUMED] for the wall; a wall at the first step → the 0.1 s floor [ASSUMED]); a pull — its force window, 推与拉 「作用时间默认为 1 s;若本次受力等级 < −1,作用时间改为 0.5 s」 (`PULL_UNBALANCE` / `_WEAK`), to its end after the 急停 too (「目标将仍保持失衡状态至拉力作用时间结束为止」), none at ≤ −3; a 静态刚体 hit by a force > 0 — no movement, the state while the force acts (特殊机制 静态刚体: 「在失去施力后立刻」 out, 「0.1 秒保底」): a push 0.1 s (`UNBALANCE_MIN`; a push's force is instant), a pull its window; 失衡免疫 and leaders never. 浮空 ends it (术语释义 浮空), so do a 重生 (重生 / 逐火's 余烬) and a route APPEAR relocation — forced state switches (失衡位移机制). The end compares with 1e-9 so a float-summed 35/30 s keeps its 35 frames. The `displace` fx carries the length (`dur`): the client's slide takes as long (render/units.js slideTo). PR #392's 0.6387·√tiles (from the page's μ = 0.5 单位假设) and iLine truncated
| `displace(enemy, {x, y}, tiles, {dur})` | the raw mover behind push / pull: along passable tiles (by `motion`: FLY in the rect, else ground-passable), no weight rule, no 失衡 state of its own (push / pull add it; `dur` only rides on its `displace` fx); nothing for 失衡免疫 (`noDisplace`: 近地悬浮 incl. 喷气人's 飞行模式, 浮空, the 胄 parts, 守墓石像's statue and flight), 静态刚体 (`def.staticBody`) and leaders (`_displaceable`); unblocks + re-paths |
| `after(seconds, fn, {owner})`, `every(seconds, fn, {owner, immediate})` | return `{cancel()}`; fn(battle, sched) |
| `fx(kind, params)`, `rng()` (+ `rng.int/range/chance/pick/shuffle/weighted`) | never use Math.random |
| `fx(kind, params)`, `rng()` (+ `rng.int/range/chance/pick/shuffle/weighted`) | never use Math.random; for distances, angles and powers use `hypot`, `sin`, `cos`, `atan2`, `powi` from `server/sim/detmath.js`, never `Math.hypot` / `sin` / `cos` / `atan2` / `pow` / `**` (their last bits differ between browsers) |
| `forceAttack(unit, targets?, { noAmmo })` | an immediate attack with the current profile (hooks, attack SP, a running ammo skill's bullet); `noAmmo: true` = spends no bullet and emits no `ammoUsed` (圣约送葬人 extra attack). Returns true when it attacked |
| `findTacticalPoint(unit)`, `groundPathTiles()` | tactical point (战术点, tactician 援军 / talent tokens): a free (`isReservedTile`) walkable, standable (`canStand`: never the 深水区) tile of the initial range **on an enemy ground path first** (`groundPathTiles`: grid paths of every non-FLY route, cached per grid version), then nearest (Chebyshev, rows break ties), then the lowest tile key |
| `effectiveProfile(unit)`, `reduceElement(unit, amount, el?)` | |
@@ -869,7 +928,9 @@ instance) and skip `'counter'` / `'reflect'` damage. When the guard trips, the l
PRTS 异常效果 晕眩 names no SP effect — community report #18). Attack-type SP: attacks made
by the skill (the pending "next attack" of an instant/charge skill, every shot of a timed skill including the one that
ends it) recover nothing, so a cost-N skill fires every **N+1** attacks (AK). Charges (`maxChargeTime > 1`):
SP fills to cost → +1 charge (SP restarts) until charges are full (then SP stays full).
SP fills to cost → +1 charge (SP restarts) until charges are full (then SP stays full). SP within 1e-9 of the cost is
the cost (`gainSp`, `_normalize`; a tiny negative remainder is 0): the float sum of the 1/30-SP time gains can stop
2.5e-14 short of it — a 10-SP skill at 1 SP/s took 301 ticks, now 300 (PR #402, 0.2.2).
- Triggers (`skill.trigger.rule` in data — the official 技能策略, PRTS 卫戍协议/帮助 §作战阶段 技能操作, resolved by
`tools/build-data.mjs resolveTrigger`: charId rows by skill index; the class rows (重装 / 执旗手 / 战术家 / 吟游者 / 解放者 /
阵法术师) for **every MANUAL skill** of the class; SKILL_RANGE for a MANUAL skill with a 技能范围 of its own; ACTIVE_RANGE
@@ -909,13 +970,22 @@ instance) and skip `'counter'` / `'reflect'` damage. When the guard trips, the l
`GDGLOW_SKILL_2` — "全场存在可选目标时释放技能": a targetable enemy anywhere (a heal skill: an ally that needs healing),
every tick; `NEVER` (alias `MANUAL`) — never auto-casts: the kit calls `unit.skill.activate(reason)` itself;
`MLYSS_WTRMAN` and any unknown rule behave like DEFAULT. Units that never attack (bard, phalanx, librator) check
DEFAULT every tick. Silence blocks activation. The automatic operations cool down (`constants.js AUTO_OP_COOLDOWN` 3 s,
DEFAULT every tick. In every enemy condition above a registered ally target (`setAllyTarget`: 白铁's 铁钳号·原型机, an
enemy-camp summon) counts like an enemy for a skill that acts on it (`_allyTargetIn`, `allyTargetsOk` — the owner's rule
of 2026-10-08: with the device alone in range such a skill opens and spends itself, its bullets included, on it): one
acting through the unit's own attacks, or flagged `allyTargets` by its kit; [ASSUMED] one that picks enemies only waits.
Silence blocks activation. The automatic operations cool down (`constants.js AUTO_OP_COOLDOWN` 3 s,
"自动操作具有3s冷却,在完成一次操作或作战开始时部署的单位将进入冷却"): a MANUAL skill is auto-cast no sooner than 3 s
after its previous cast (a charged skill spends its charges 3 s apart) or after its battle-start deployment
(`Battle._deploy` initial; `battle.flags.startOpCooldown`, default 3 — the test harness sets 0 unless told otherwise);
AUTO skills are exempt; kits with their own automatic cast of a MANUAL skill check `skill.opCooling` (波登可 S1, 雪猎
special bullets, 流形 copy). While a cast "next attack" (instant / charges with an attack override) waits for its
attack, no further charge is cast. `gainSp` is ignored while a duration/ammo/toggle skill
attack, no further charge is cast. An instant / charges skill with no attack override of its own (a throw, a heal, a
buff, a DP gain …) is cast from the tick (the rules above, the DEFAULT of units that never attack, content trigger
ranges) at most once per attack interval of its unit — `skill.nextCastAt`, set by such a cast, reset on deployment —:
attack speed paces it, and an SP refund that refills the bar at once (迅捷) no longer recasts it every tick (GitHub #298,
引星棘刺 S1; [ASSUMED] one attack interval: PRTS prints no 前后摇 for such casts). The cast is no attack: the unit's
attacks and heals keep their own rhythm, and its first cast comes as soon as it is ready (#124). `gainSp` is ignored while a duration/ammo/toggle skill
runs (its bar shows the skill), whatever the reason, and — any reason but `'init'` — while the unit has the `noSp`
flag (阻回: "停止并阻止任意形式的技力回复"; the operators' 凋亡 burst, §3).
- Kinds: `duration` (mods for `duration` s), `ammo` (mods until `ammo` attacks were made, optional duration cap),
@@ -1292,7 +1362,9 @@ Unknown subprofessions fall back to the profession default (test `professions.te
## 9. Wire format (snapshot.js, DESIGN §8.2)
- `snapshot()` → `{ fieldId, t, units: [[id, x, y, hp, maxHp, sp, spMax, flags, anim]], dp, killed, total, dps?, boss?, down?, elem? }`.
- `snapshot()` → `{ fieldId, t, units: [[id, x, y, hp, maxHp, sp, spMax, flags, anim]], dp, killed, total, resolved, dps?, boss?, down?, elem?, ammo?, wolves?, neg?, stand?, standCut? }`.
`killed` counts every `counted` knock-out (§1.3) and `total` only the enemies this field scheduled (`inTotal`), so it may
exceed it; `resolved = min(total, killedInTotal + leakedInTotal)` is the HUD capsule's numerator (DESIGN §14 顶栏胶囊).
`sp/spMax` show remaining duration/ammo as a draining bar while a timed skill is active (ammo: `ammoLeft / ammoMax`, the
activation's real total — 拉特兰's and 逃犯引渡手续's extra bullets included, community report #35). Units in DIE state stay 0.8 s.
Active finite zero-SP duration skills display `timeLeft/duration` using `duration` as `spMax`; after end they show
@@ -1303,8 +1375,25 @@ Unknown subprofessions fall back to the profession default (test `professions.te
respawn timer; `state` = constants.js
`DOWN_STATE`: 0 counting, 1 timer done / DP short, 2 timer done / its tile taken — a safeguard: no ally deploys on a
knocked-out operator's tile, §1; `row, col` = the tile it lies on and comes back on, `unit.body`); `elem: [[id, element, fill,
cooldownEnd, cooldown]]` (only when non-empty) = `elementView` of every unit with a gauge or a running 爆发冷却 (§3).
cooldownEnd, cooldown]]` (only when non-empty) = `elementView` of every unit with a gauge or a running 爆发冷却 (§3);
`ammo: [[id, left, magazine]]`, `wolves: [[id, left, max]]` and `neg: [[id, fill]]` (each only when non-empty; display only) = the HP-bar
readouts of `snapshot.js` `ammoView` (an ally's running ammo skill: whole rounds, the magazine = `ammoMax`), `wolfView` (伺夜's 狼群:
`mem.shadows` of `mem.wolfCapacity`) and `negView` (`unit.negFill()`, set by 斩业星熊's T1 业火: the 我执 pool as a share of its cap);
UnitInfo `ammoSkill` marks an ally whose skill is an ammo magazine (DESIGN §8.2).
`fieldMeta()` lists the knocked-out operators too (a client joining mid-battle shows them; DESIGN §18.3).
`stand: [[id, until]]` (only when non-empty) = the game time each enemy's attack recovery ends (`atkStandUntil`: it
stands for the rest of its attack clip, §1.2 `attackStand`) — enemies alive, deployed, visible, neither feared nor stunned;
display metadata the battle never reads (PR #381). When `until` falls between two snapshots the renderer holds the older
position until then and interpolates the rest of the interval (HP / SP keep the whole interval); a newer snapshot that
moved before `until` is interpolated linearly; while the newest snapshot still holds a stand nothing is extrapolated
(the next one confirms the move). A snapshot without the field interpolates over the whole interval, a teleport or a
redeploy still snaps first, and nothing is inferred from the attack animation. `standCut: [[id, at]]` (only when
non-empty) = the latest game time each enemy's recovery was cut or ignored (a displacement — 失衡 —, fear, a stun, hiding,
leaving the field), one per enemy: an interval whose newer snapshot carries a cut inside it is interpolated linearly even
when the older stand ended inside it too, and an older cut leaves a later stand alone. It covers the enemies listed in
`units` (the 0.8 s death window included), never a hidden or unlisted one. The newest snapshot showing a death, a stun
(freeze, sleep, 浮空 included) or a block is not extrapolated until a later snapshot shows it moving again; the
extrapolated velocity uses the same moving span as the interpolation (the stand is not in its denominator).
- `drainEvents()` tuples: `['spawn', UnitInfo]` (first appearance), `['deploy', id]` (every (re)deploy and 【移动】 — the client plays the deploy and its interpolation snaps instead of sliding), `['atk', src, tgt, projKind]`
(`none|arrow|bolt|bomb|lob|orb|drone|enemy|boomerang|droneBomb|chain|chainHeal`; a boomerang's way back has no event — the
renderer flies it back to the thrower at `BOOMERANG_RETURN_SPEED`; an enemy's `profile.shot` may name another kind,
@@ -1314,7 +1403,7 @@ Unknown subprofessions fall back to the profession default (test `professions.te
`['fx', kind, x, y, extra]` (`hitCap` `{ id, n }`: a leader's hit cancelled by 限伤 — the renderer draws nothing;
`extra.form` = the unit's model form from then on — an enemy's `content/enemies/helpers.js setForm`, a 傀儡师's 替身 — `shared/protocol.js fxForm`),
`['layer', playerId, bondId, n]` (n = the layers actually added, capped at 999), `['bounty', playerId, coins]`.
- `UnitInfo = { id, kind, side, ownerId, defId, name, tier, golden, spine, avatar, x, y, facing, dir, maxHp, motion?, boss?, uid?, form?, skillIndex?, moduleId?, items?, standInFor?, diy? }` (`standInFor`: a 补位 stand-in's replaced operator charId — `spine` / `avatar` / `name` are the stand-in's, DATA.md §18; the prep scouting units of `Match.prepFieldMeta` carry it too, board and held pieces alike with the stand-in's art (the owner's recall of the official mode, 2026-10-06), and so do the m.result lineups; `diy`: a 自选 piece's pick `{ charId, skillIndex, uniEquipId }` — `defId` is its slot, `spine` / `avatar` / `name` the operator's; the prep scouting units of `Match.prepFieldMeta` and the m.result lineups carry it too (server/match/player/diy.js, the client composes the operator's card from it); `skillIndex`: an ally's equipped skill, DESIGN §16; `form`: the unit's current model form — `content/enemies/helpers.js setForm`: 掠海漂移体 `'crawl'`, 暴鸰 `'bombed'` after its drop, 转译基底·α's forms …; a 傀儡师 fighting as its 替身 `'doll'` — so a view built mid-battle from `fieldMeta()` starts on that clip set; `items`: an ally operator's equipped item ids — a 变形同构体 wearer is a member of the bond it grants on the client too, the bond popup and the detail card's chips)
- `UnitInfo = { id, kind, side, ownerId, defId, name, tier, golden, spine, avatar, x, y, facing, dir, maxHp, motion?, boss?, uid?, form?, skillIndex?, moduleId?, items?, standInFor?, diy?, potential?, cultivate? }` (`potential` / `cultivate`: an ally operator's potential below 6 and its 练度 tier, 0.2.2 — a teammate's card composes its numbers from them) (`standInFor`: a 补位 stand-in's replaced operator charId — `spine` / `avatar` / `name` are the stand-in's, DATA.md §18; the prep scouting units of `Match.prepFieldMeta` carry it too, board and held pieces alike with the stand-in's art (the owner's recall of the official mode, 2026-10-06), and so do the m.result lineups; `diy`: a 自选 piece's pick `{ charId, skillIndex, uniEquipId }` — `defId` is its slot, `spine` / `avatar` / `name` the operator's; the prep scouting units of `Match.prepFieldMeta` and the m.result lineups carry it too (server/match/player/diy.js, the client composes the operator's card from it); `skillIndex`: an ally's equipped skill, DESIGN §16; `form`: the unit's current model form — `content/enemies/helpers.js setForm`: 掠海漂移体 `'crawl'`, 暴鸰 `'bombed'` after its drop, 转译基底·α's forms …; a 傀儡师 fighting as its 替身 `'doll'` — so a view built mid-battle from `fieldMeta()` starts on that clip set; `items`: an ally operator's equipped item ids — a 变形同构体 wearer is a member of the bond it grants on the client too, the bond popup and the detail card's chips)
(`dir` = the unit direction, allies meaningful, enemies 'RIGHT'; `facing` = its horizontal sign for sprite flipping)
(`spine`/`avatar` are asset ids from data).
- flags: UF bits (blocked 1, stunned 2, frozen 4, stealth 8 — 隐匿 (an enemy's only while not blocked / revealed and not within 3 s of a block's end) or an ally's 迷彩 — skill 16, shield 32, invuln 64, cold 128, sleep 256, flying 512);
@@ -1401,8 +1490,14 @@ never displaced, §6), tags, abilities, skills, talent, raw`. Tokens use `varian
`skcom_withdraw` token skills are ignored.
Operator loadouts (DESIGN §16, DATA.md §2.2 / §14): `getChess(id, { skillIndex, moduleId })` = the def with the selected
skill (`def.skill`, its bb / SP / trigger) and, for elites, the module's stats / trait / talents; `def.loadout` = the
resolved loadout `{ skillIndex, moduleId, skillIsDefault, moduleIsDefault, isDefault }` (illegal choices fall back to the
default; cached per loadout; the default loadout is the same object as `getChess(id)`). `getToken(id, ownerChessId,
resolved loadout `{ skillIndex, moduleId, potential, skillIsDefault, moduleIsDefault, potentialIsDefault, isDefault }`
(illegal choices fall back to the default; cached per loadout; the default loadout is the same object as `getChess(id)`).
Potential (0.2.2, DATA.md §2.3, shared/potential.js): a loadout's `potential` (潜能 1–6; default 6 = the data's full
potential) composes the record at it first (`loadoutRecord` → `atPotential`: stats, statsBase, talents, module talent
changes); `getToken` composes the owner variant at the owner's `loadout.potential` (talents; a 自选 summon's deploy
limit / holding / count) and `getDiy(slotId, pick, potential)` an owned pick's form (a prototype has none: `diyProto`);
the kits that read a summon variant raw (content/tokens.js `ownVariant`, `kits/shared/summoner.js tokenStat`, 多萝西,
麦克尼斯特) compose it the same way. `getToken(id, ownerChessId,
ownerLoadout)` = the summon for that owner loadout (`bySkill` / `byModule` merged; `def.sources` ⊆ ['talent', 'skill',
'display'] and `def.count` of that loadout — `[]` / `['display']`: the owner does not make it). Every Battle gets a
per-battle view (`withUnitLoadouts`: id-only `getChess(id)` / `getToken(id, owner)` use the loadout the inputs give that
@@ -1414,7 +1509,17 @@ the source's `backups` (data/backups.json; `rawBackups()` follows the fallback c
the replaced operator's, `loadout` the backup selection (`standIn: true`); its skill / module ignore the loadout's
("对于补位干员其技能不可更改", shared/standIn.js). A PRESET / DIY chess or a source without backups (a browser that did not
fetch backups.json) gives the chess's own def. A PlayerBattleInput entry carries it as `standIn: true` (spec.js keeps
nothing else); the per-battle view maps id-only lookups of that chess id to the stand-in.
nothing else — no `potential` / `cultivate`: a stand-in has neither); the per-battle view maps id-only lookups of that
chess id to the stand-in.
潜能 / 练度 in a PlayerBattleInput operator entry (0.2.2; the owner's decision of 2026-10-08 — the player's 干员调配 settings,
PlayerState.battleInput states both for every owned operator, bots the defaults 6 / 3): `potential` (1–6) → the def of
`getChess(chessId, { …, potential })`; `cultivate` (0–3) → `unit.cultivate` and `unit.cultMul` = `data.cultivateMul(n)`,
the effects.json CHAR_MAP `aceffect_char_<n+1>` `char_attribute_mul` (×ATK / ×DEF / ×max HP: 1 / 1.05·1.05·1 / 1.1·1.05·1.05
/ 1.1 each), a multiplier of its own in the Πmul of §2 (never summed with the 直接乘算 percentages: PRTS 盟约记录
「…与自持有干员的属性加成独立」); a stand-in or a prototype 自选 def never takes it, summons neither [ASSUMED: the official
buff names the operator]. spec.js keeps each only when well-formed (never on a stand-in or a summon); absent = full
potential and no 练度 (a raw unit: tests, tools). `unitStatsEntry` counts the 练度 in the unit's own numbers (`base`), as
the record cards do; UnitInfo carries `potential` (below 6) and `cultivate`.
自选 (DATA.md §18): `getChess(slotId, { diy })` = `getDiy(slotId, pick)` — shared/diy.js `diyRecordOf` of the DIY slot
record (`_a` normal, `_b` elite) with the pick over this source, normalised: `id` / `baseId` / `golden` / `tier` of the
slot, `bonds` the pick's derived ones, `raw.garrisonIds` `[]` (no 特质), every combat field of the operator at the slot's
@@ -1427,8 +1532,8 @@ id, `loadout` the selection with `diy` = the checked pick `{ charId, skillIndex,
`diy` (spec.js keeps `charId` / `skillIndex` / `uniEquipId` when well-formed); Battle fields a DIY slot only with a legal
pick (none ⇒ no unit), and each unit resolves its own pick — two players filling one slot id differently are exact.
Assumptions taken here (documented choices): element burst numbers for necrosis/apoptosis; unspawned enemies at timeout
are dropped (not leaks); geek drain is non-lethal; tactician reinforcement stats; displacement is instantaneous (the official 失衡 slide over ≈ 0.4–1.2 s is not
modelled — only its distance, `push` / `pull`); pushes PRTS does not classify use the 弹道 distance column (`PUSH_TILES`);
are dropped (not leaks); geek drain is non-lethal; tactician reinforcement stats; displacement is instantaneous, followed by the 失衡 hold (push: the 位移时间 table; pull: the force
window — `push` / `pull`; a wall-shortened slide keeps its row time, a wall at the first step gets the 0.1 s floor); pushes PRTS does not classify use the 弹道 distance column (`PUSH_TILES`);
platforms/mounds are ground obstacles that elevate operators; the generic kit maps 凋亡 to `apoptosis` (侵蚀 has no element key
yet — enemy content must pick one); 抵抗 covers the control statuses of `RESIST_STATUSES` (the official term lists 晕眩/寒冷/
冻结/恐惧/诱导…, the rest follow the operator kits that grant it) and caps at 0.95; tactical points prefer enemy path tiles.
+10 -5
View File
@@ -22,14 +22,19 @@ view.on('pieceDragStart'|'pieceDrop'|'pieceDragEnd'|'pieceClick'|'pieceHover'|'t
// area 'outside' lets the DOM UI decide: released off the grid or over DOM covering the canvas (the shop bar), mouse
// and touch alike — there is no drag-to-sell, the piece goes back
// the drop target, its highlight and tileHover while dragging = the tile under the POINTER; an item dropped on a
// tile equips the unit standing on it (§18.1)
// tile equips the unit standing on it (§18.1); a dragged unit is drawn standing on that tile while it is a legal
// target — a board tile or a bench slot (render/drag.js dragStandTile), the official deploy drag — and held under
// the pointer elsewhere (DRAG_HOLD_TILES), an item plate on the pointer (§27.62)
view.pieceScreenRect(uid) // the drawn body's rect { left, top, right, bottom, width, height, x, y } (tooltips / overlays)
view.tileScreen(row, col) // { x, y, s, poly } client px of a tile top (direction wheel geometry)
view.holdPiece(uid, {row,col}|null) // keep a dropped piece on a tile while its direction is chosen
view.setPieceDir(uid, dir) // show a prep piece facing UP|RIGHT|DOWN|LEFT (wheel preview / stored dir)
view.resize() ; view.destroy()
```
Visuals: oblique perspective board (tilted ~30°, trapezoid tiles), raised highland blocks with side faces, road/floor/forbidden styles per research 07 §7, pulsing red gates, blue objective, dashed hand pads. Units: Spine (Front/Back rule, feet anchored — flying units hover `FLY_HOVER` 1.3 tiles up, the client's 0.35 in character space, research 12, §25.13.5 — an enemy flyer from the road whatever tile it crosses (GitHub #277) —, shadow sprite on the ground), elite gold outline glow, tier chip, HP bar (green ally / red enemy), SP bar (skill ready glow), status icons, the element gauge row under the bars — icon + white bar of the remaining 元素值, element-coloured refill during a 爆发冷却 (§18.3, §20.8); enemies drawn × their official prefab factor (enemies.json `modelScale`, §20.4); an enemy's mode — the `form` of a sim fx (`shared/protocol.js fxForm`), or UnitInfo `form` for a view built later through `render/app/info.js renderInfo` — switches clip sets (`render/units.js FORMS`: 掠海漂移体 crawl, 暴鸰 bombed, 转译基底's forms, the 逐火 embers, the leaders' 重生, 守墓石像, the 孤岛风云 prisoners' warning and 解放; closing clips timed from the fx `dur`), a one-off cast (`PROJ[kind].once`) plays once, and a local-only model's web alias is tinted (`ALIAS_TINT`: 灼热 / 炽焰源石虫, §21.14); damage numbers (phys orange-white, arts purple, true white, heal green); projectiles drawn per kind, hit sparks and melee slashes, skill activation bursts and auras (§17.3); death fade for enemies, summons and devices — a knocked-out operator stays on its tile in its held Die pose under a redeploy ring (§18.3); leak flash at the objective. Fallback when Spine missing/failed: avatar in a rarity-coloured diamond with bob/lunge tweens — a failed or timed-out model is loaded again (bounded), and a view re-resolves its model and avatar when the asset manifest arrives late or the tab is shown again (§21.27). 60 fps target with ≤ 120 units. Picking (which unit a press / hover / dropped item is on) and the drag target follow one rule, `render/pick.js` — the tile under the pointer (§18.1); the Spine store never hands out a skeleton whose unload is in flight (§17.1).
`createFieldView(host, { …, signal })` takes an `AbortSignal` (0.2.2, GitHub PR #302 by @siruimei07): an abort during the build — the Pixi load, the manifest, the fonts, the board art and the 3D board waits — destroys what the attempt made (the canvas, the ticker, the listeners, the observers) and rejects, a failure while it is built releases the same partial resources, and `view.destroy()` is idempotent. `ui/fieldHost.js mountFieldView(host, { signal })` aborts that signal when the engine fails, times out (12 s) or the owner unmounts, disposes a view a factory returns after that, and never clears the other children of `host` — the simplified view and a newer mount share it.
Visuals: oblique perspective board (tilted ~30°, trapezoid tiles), raised highland blocks with side faces, road/floor/forbidden styles per research 07 §7, pulsing red gates, blue objective, dashed hand pads. Units: Spine (Front/Back rule, feet anchored — flying units hover `FLY_HOVER` 1.3 tiles up, the client's 0.35 in character space, research 12, §25.13.5 — an enemy flyer from the road whatever tile it crosses (GitHub #277) —, shadow sprite on the ground), elite gold outline glow, tier chip, HP bar (green ally / red enemy), SP bar (skill ready glow; while an ammo skill runs it is the magazine instead — a yellow cell per round, emptying from the right, `b.snap ammo`, and the skill draws no standing aura, UnitInfo `ammoSkill`), 斩业星熊's 我执 pool as a red bar over her drained HP bar (`b.snap neg`), 伺夜's 狼群 count as a row of diamonds under the bars, the lit ones the 狼影 left (`b.snap wolves`; 0.2.2, GitHub PR #303 / #378 / #383), status icons, the element gauge row under the bars — icon + white bar of the remaining 元素值, element-coloured refill during a 爆发冷却 (§18.3, §20.8); enemies drawn × their official prefab factor (enemies.json `modelScale`, §20.4); an enemy's mode — the `form` of a sim fx (`shared/protocol.js fxForm`), or UnitInfo `form` for a view built later through `render/app/info.js renderInfo` — switches clip sets (`render/units.js FORMS`: 掠海漂移体 crawl, 暴鸰 bombed, 转译基底's forms, the 逐火 embers, the leaders' 重生, 守墓石像, the 孤岛风云 prisoners' warning and 解放; closing clips timed from the fx `dur`), a one-off cast (`PROJ[kind].once`) plays once, and a local-only model's web alias is tinted (`ALIAS_TINT`: 灼热 / 炽焰源石虫, §21.14); damage numbers (phys orange-white, arts purple, true white, heal green); projectiles drawn per kind, hit sparks and melee slashes, skill activation bursts and auras (§17.3); death fade for enemies, summons and devices — a knocked-out operator stays on its tile in its held Die pose under a redeploy ring (§18.3); leak flash at the objective. Fallback when Spine missing/failed: avatar in a rarity-coloured diamond with bob/lunge tweens — a failed or timed-out model is loaded again (bounded), and a view re-resolves its model and avatar when the asset manifest arrives late or the tab is shown again (§21.27). 60 fps target with ≤ 120 units (a crowd draws its Spine units through the cached atlas, `render/impostor.js`, each refreshed every k-th frame: the refresh turns follow this frame's update order — `ctx.impostorSlot()`, a counter reset at the start of every frame — so a frame refreshes ⌊n/k⌋ or ⌈n/k⌉ of n units instead of whatever random per-unit phases gave, and a unit not refreshed for 2k frames is refreshed regardless; GitHub PR #334 by @Cloudnyco, issue #327 step 1 — its step 2, a time budget by priority, is not done). Picking (which unit a press / hover / dropped item is on) and the drag target follow one rule, `render/pick.js` — the tile under the pointer (§18.1); the Spine store never hands out a skeleton whose unload is in flight (§17.1).
---
@@ -37,15 +42,15 @@ Visuals: oblique perspective board (tilted ~30°, trapezoid tiles), raised highl
Look & feel: research 06 §11–§12 (near-black green-grey panels, mint `#4ed8af` accents, gold prices, bracket frames, hexagon badges, bond discs, 7-segment countdown). Fonts: Noto Sans SC (Google Fonts with local fallback), Bender/Novecento self-hosted for numerals and Latin micro-labels.
Screens: **Title** (season art backdrop, 开始 → nickname), **Lobby** (独立模拟 / 同盟模拟 → create or join; recent rooms), **Room** (4 seats, invite code + copy link, difficulty picker (host), add/remove AI, ready, start), **Briefing** (INFO_CHECK: stage, factions, disabled bonds, banned chess, boss silhouette, ready x/4), **Band draft** (grid of 40 bands with icon/name/LP/effect; turn order list; skip; 查看禁用盟约与干员 under the order list opens the briefing's bond rows, legend and banned operators again in a read-only dialog — one component, `ui/matchInfo.js`, §21.28), **Game** (Pixi field + DOM HUD: top bar with round/phase capsule/LP (live during the own battle, §17.5)/timer/ready; bond strip + popups; left team panel with LP/status/emote bubbles, click to watch (a boss round frames the viewer's pair in green, §15); bottom shop bar (level card, 3–5 chess cards, item card, funds, refresh, freeze, collapse); detail panel on click/hover (header with bonds, then 特质 — the operator's own effect — right under it, 特性, stats (live: the battle's own numbers / the start-of-battle ones in prep, §18.5), skill, module, equipped items, talents: §17.5); enemy preview drawer (next round enemies with counts, factions; its 本局信息 tab lists the briefing's disabled bonds and banned operators, §21.28); 机变 overlay (the official card: framed icon + name, the effect text — data `descRaw` — under it, the taker's round avatar at the top-right; two taps, §18.2, §20.7); merge reward overlay (the promotion reward, or a special offer — 凯瑟琳's 定向投放 … — as item / chess cards under its source's label, with the count of offers queued behind it, §21.2); phase banners; the round's result box at SETTLE (the official 「作战结束」 + 「全员无伤!」 / 「生命值减少 −N」 for the viewer's own LP — after a 联防 the server's charge, `m.public.uniteResult.losses` — with the matching BATTLEOVER sound; none in the boss rounds or for a viewer not in the round; GitHub #235, PR #112); ticker (by broadcast priority, highest first; a leader-damage line plays only in its round and a player's newer one replaces the older, §21.10); toasts; emote wheel; settings (volume, quality, show damage numbers)), **Result** (victory/defeat, rounds, per-player lineup, stats, titles, back to room).
Screens: **Title** (season art backdrop, 开始 → nickname), **Lobby** (独立模拟 / 同盟模拟 → create or join; recent rooms; a 设置 button left of 玩法说明 opens the title screen's settings dialog, GitHub #238), **Room** (4 seats, invite code + copy link, difficulty picker (host), the co-op 「AI 队友最后选择」 switch (host, off by default; read-only for the others), add/remove AI, ready, start; the same 设置 button), **Briefing** (INFO_CHECK: stage, factions, disabled bonds, banned chess, boss silhouette, ready x/4), **Band draft** (grid of 40 bands with icon/name/LP/effect; turn order list; skip; 查看禁用盟约与干员 under the order list opens the briefing's bond rows, legend and banned operators again in a read-only dialog — one component, `ui/matchInfo.js`, §21.28), **Game** (Pixi field + DOM HUD: top bar with round/phase capsule (the kill counter reads 已解决 / the round's own enemies: a leak counts, a split child or summon is in neither number, §14)/LP (live during the own battle, §17.5)/timer/ready, and beside the latency a 观战席 capsule (eye + count, a roster with the host's ✕) while the room has spectators (GitHub #120); bond strip + popups; left team panel with LP/status/emote bubbles, click to watch (a boss round frames the viewer's pair in green, §15); bottom shop bar (level card, 3–5 chess cards — a slot the upgrade just opened is a bare frame until the next refresh, GitHub #332 —, item card, funds, refresh, freeze, collapse; 准备就绪 folds it and cancelling unfolds it, GitHub #138); detail panel on click/hover (header with bonds, then 特质 — the operator's own effect — right under it, 特性, stats (live: the battle's own numbers / the start-of-battle ones in prep, §18.5), skill, module, equipped items, talents: §17.5); enemy preview drawer (next round enemies with counts, factions; its 本局信息 tab lists the briefing's disabled bonds and banned operators, §21.28); 机变 overlay (the official card: framed icon + name, the effect text — data `descRaw` — under it, the taker's round avatar at the top-right; two taps, §18.2, §20.7); merge reward overlay (the promotion reward, or a special offer — 凯瑟琳's 定向投放 … — as item / chess cards under its source's label, with the count of offers queued behind it, §21.2); phase banners; the round's result box at SETTLE (the official 「作战结束」 + 「全员无伤!」 / 「生命值减少 −N」 for the viewer's own LP — after a 联防 the server's charge, `m.public.uniteResult.losses` — with the matching BATTLEOVER sound; none in the boss rounds or for a viewer not in the round; GitHub #235, PR #112); ticker (by broadcast priority, highest first; a leader-damage line plays only in its round and a player's newer one replaces the older, §21.10); toasts; emote wheel; settings (volume, quality, show damage numbers)), **Result** (victory/defeat, rounds, per-player lineup, stats, titles, back to room).
盟约栏支持「收起/展开盟约」(GitHub #142):收起时隐藏完整列表并关闭由列表打开的浮层,让原图标区域的指针事件到达棋盘,只保留至少 44×44 CSS px 的恢复按钮。折叠状态在本局阶段变化、观看队友时保留;展开后仍按当前观看对象显示最新盟约。按钮支持 Enter / Space,聚焦按钮时 Space 不触发准备或暂停。
A bond the mode never activates (标准: official `inactiveBondIdList`) reads 本局禁用 on shop / reward cards (struck through), detail chips and the popup — no 在场 count, no 当前效果 (§21.7); a strategy built around one carries a 本局禁用 tag and note in the strategy draft and stays selectable (bands.json `bondIds`, §21.26).
Interactions (research 09 §1.2 / §5, which supersede the first draft): shop card, upgrade and 机变 card are two-tap (select → confirm, §18.2) — a tap anywhere on the card, its confirm strip included, is the card's tap (shop cards have no ⓘ corner; the armed bar sits above the detail card; a shop card that cannot be bought right now opens its detail on tap, §20.7); a press / drop goes by the tile under the pointer (§18.1); drag bench↔board — a legal board drop (also onto the piece's own tile) opens the 4-direction deploy wheel (swipe UP/RIGHT/DOWN/LEFT with the rotated range striped orange; release in the centre, ✕ 点击取消 or Esc cancels); tap a unit's tile ⇒ underframe with **出售 +N** (and **撤退** on the board; items/Arts in the hand / temp: **销毁** — equipped items are locked, §6.2) plus its rotated range; no drag-to-sell; drag an item onto a unit's tile ⇒ equip that unit (both slots used ⇒ the replace dialog `ui/equipReplace.js` shows the incoming and the two equipped items; the player picks the one to replace — it is destroyed — `g.equip replaceUid`; cancel changes nothing); Arts are dropped on a tile and get the direction step; right-click or long-press ⇒ detail (or a tap on a shop card while the bar is locked); keyboard shortcuts — the defaults, which each player rebinds in 设置 → 快捷键 (`ui/gameLogic/shortcuts.js`, saved with the settings; §25.15) — `R` refresh, `F` freeze, `D` level-up, `Q` retreat / `X` sell the selected operator, `Space` ready (in a solo battle: pause / resume, §14 Solo pause); `Esc` closes (fixed); none acts while a text field has the focus or a dialog is open. All actions optimistic-free: UI waits for server state (latency is small).
Interactions (research 09 §1.2 / §5, which supersede the first draft): shop card, upgrade and 机变 card are two-tap (select → confirm, §18.2) — a tap anywhere on the card, its confirm strip included, is the card's tap (shop cards have no ⓘ corner; the armed bar sits above the detail card; a shop card that cannot be bought right now opens its detail on tap, §20.7); a press / drop goes by the tile under the pointer (§18.1); drag bench↔board — over a legal tile the dragged unit stands on it, as in the official deploy drag, held under the pointer elsewhere (§27.62); a legal board drop (also onto the piece's own tile) opens the 4-direction deploy wheel (swipe UP/RIGHT/DOWN/LEFT with the rotated range striped orange; release in the centre, ✕ 点击取消 or Esc cancels); tap a unit's tile ⇒ underframe with **出售 +N** (and **撤退** on the board; items/Arts in the hand / temp: **销毁** — equipped items are locked, §6.2) plus its rotated range — the range follows the open detail card (`screens/game/range.js inspectRange`, GitHub PR #281): an own board piece in prep, and in battle or on a scouted board any ally operator / summon on the field with its current range (the live stats' `range` and `dir`, the snapshot's tile), never an enemy or a bench unit (UnitInfo `area` hand / temp); drag, the wheel and the pen hide it; no drag-to-sell; drag an item onto a unit's tile ⇒ equip that unit (both slots used ⇒ the replace dialog `ui/equipReplace.js` shows the incoming and the two equipped items; the player picks the one to replace — it is destroyed — `g.equip replaceUid`; cancel changes nothing); Arts are dropped on a tile and get the direction step; right-click or long-press ⇒ detail (or a tap on a shop card while the bar is locked); keyboard shortcuts — the defaults, which each player rebinds in 设置 → 快捷键 (`ui/gameLogic/shortcuts.js`, saved with the settings; §25.15) — `R` refresh, `F` freeze, `D` level-up, `Q` retreat / `X` sell the selected operator, `Space` ready (in a solo battle: pause / resume, §14 Solo pause); `Esc` closes (fixed); none acts while a text field has the focus or a dialog is open. All actions optimistic-free: UI waits for server state (latency is small).
Audio: autochess BGM per phase, UI SFX (buy/sell/refresh/level/merge/ready/timer), per-unit attack/hit SFX (throttled; an impact plays for a hostile attack's phys / arts / true damage only, never an operator's skill-mode file for a normal attack, ≤ 2 copies of one battle sound — §18.4), boss music in R14/15.
Audio: autochess BGM per phase, UI SFX (buy/sell/refresh/level/merge/ready/timer), per-unit attack/hit SFX (throttled; an impact plays for a hostile attack's phys / arts / true damage only, never an operator's skill-mode file for a normal attack, ≤ 2 copies of one battle sound — §18.4; a skill cue that reaches the client before its unit — the sim emits a deploy-tick cast ahead of the unit's `['spawn']` — is held and sounds once when the unit is tracked, `AudioManager.pendingSkill`, GitHub PR #292 by @LimitlessHPPK; entering a field late hands the sound the buffered `spawn` tuples only, `screens/game/early.js audioEarly`, so no old death, deploy or cast replays), boss music in R14/15.
---
+5 -5
View File
@@ -113,16 +113,16 @@ Base stats (from data): `maxHp, atk, def, res` (0–100), `aspd` (100 base), `ba
DamageInfo: `{ amount (pre-mitigation), type: 'phys'|'arts'|'true'|'element', element?: 'burn'|'necrosis'|'neural'|'apoptosis', atkScale, defIgnoreFlat, defIgnorePct, resIgnoreFlat, resIgnorePct, mul (multiplier), canDodge, isSkill, isSplash, ignoreSelect (no selection — a 无视无法选择 ability, a direct pick, a debuff's tick: it reaches an airborne 起飞 ally, §21.22), tags[] }`.
Engine helpers (content must use these, never touch internals): `battle.dealDamage(source, target, dmgInfo)`, `battle.heal(source, target, amount, {overheal=false})`, `battle.applyStatus(target, key, { duration, source, value })`, `battle.addBuff(unit, buff)`, `battle.removeBuff(unit, key)`, `battle.spawnToken(ownerUnit|playerId, tokenId, row, col, opts)`, `battle.spawnEnemy(enemyKey, { routeIndex, pos, mods, tag })`, `battle.addProjectile({ from, to|target, speed, onHit, visual })`, `battle.push(e, force, { from, dir, fixed, fixedAngle, inward, effect })` / `battle.pull(e, force, { to, center, stop })` / `battle.pullToFront(e, unit, force)` / `battle.forceLevel(e, force)` / `battle.pushDistance(e, force, { effect })` (the official 力度 − 重量 tables, §20.3; a 静态刚体 — data `staticBody` — is never moved, §21.9; `battle.displace(e, dir, distance)` is the raw mover), `battle.allySelectable(a, by)` / `battle.alliesFor(by, ownerId)` (friendly selection, skipping 孤立 units, §20.3), `battle.unitsInGrid(unit, grid)`, `battle.enemiesInRadius(x, y, r)` (every enemy: enemy-side auras / heals, collisions), `battle.foesInRadius(x, y, r)` (the enemies an operator's area effect can select — no untargetable, no unblocked unrevealed 隐匿 enemy; PRTS 作战机制 §AOE伤害判定, §21.19), `battle.alliesInRadius(x, y, r, ownerId?)`, `battle.addLayers(playerId, bondId, n, reason)` (no-op when gains disabled; at most the room left under `BOND_LAYER_CAP` 999 — `shared/constants.js layerGainRoom`, §20.12; records into result), `battle.loseHp(target, amount, { source, from, sourceless })` (流失 — it skips the damage events: no 受击回复, TAKE_DAMAGE, shield or counter, `damage.js isHpLoss`; only for what the official calls 流失 — "N 真实伤害 per second" effects such as 源石溶剂 are `periodicDamage` damage, §21.12; a 物理流失 is first reduced by the target's DEF, 阿戈尔's devour, §21.19; `sourceless` = 无来源, `source` keeps the credit — leader parts pass damage on 1:1 with it, §20.10), `battle.addCoins(playerId, n)`, `battle.fx(kind, params)` (client VFX event), `battle.rng()` (never `Math.random`), `battle.getPlayer(playerId)` (bonds/band/effects view), `battle.redeploy(unit)` (on its rest tile: where a knocked-out operator lies, else its home; `battle.isReservedTile(r, c)` = a tile no automatic placement takes, `battle.downOn(r, c)` = the knocked-out operator lying there, whose tile no ally deploys or moves onto — §21.24), `battle.after(seconds, fn)` (scheduled callbacks), `battle.every(seconds, fn)`.
Engine helpers (content must use these, never touch internals): `battle.dealDamage(source, target, dmgInfo)`, `battle.heal(source, target, amount, {overheal=false})`, `battle.applyStatus(target, key, { duration, source, value })`, `battle.addBuff(unit, buff)`, `battle.removeBuff(unit, key)`, `battle.spawnToken(ownerUnit|playerId, tokenId, row, col, opts)`, `battle.spawnEnemy(enemyKey, { routeIndex, pos, mods, tag })`, `battle.addProjectile({ from, to|target, speed, onHit, visual })`, `battle.push(e, force, { from, dir, fixed, fixedAngle, inward, effect })` / `battle.pull(e, force, { to, center, stop })` / `battle.pullToFront(e, unit, force)` / `battle.forceLevel(e, force)` / `battle.pushDistance(e, force, { effect })` (the official 力度 − 重量 tables, §20.3; a 静态刚体 — data `staticBody` — is never moved, §21.9; a push / pull then holds the enemy in its 失衡 state — no walking, normal attack or skill — for the PRTS 位移时间 / force window, 0.2.2, SIM.md §6; `battle.displace(e, dir, distance)` is the raw mover), `battle.allySelectable(a, by)` / `battle.alliesFor(by, ownerId)` (friendly selection, skipping 孤立 units, §20.3), `battle.unitsInGrid(unit, grid)`, `battle.enemiesInRadius(x, y, r)` (every enemy: enemy-side auras / heals, collisions), `battle.foesInRadius(x, y, r)` (the enemies an operator's area effect can select — no untargetable, no unblocked unrevealed 隐匿 enemy; PRTS 作战机制 §AOE伤害判定, §21.19), `battle.alliesInRadius(x, y, r, ownerId?)`, `battle.addLayers(playerId, bondId, n, reason)` (no-op when gains disabled; at most the room left under `BOND_LAYER_CAP` 999 — `shared/constants.js layerGainRoom`, §20.12; records into result), `battle.loseHp(target, amount, { source, from, sourceless })` (流失 — it skips the damage events: no 受击回复, TAKE_DAMAGE, shield or counter, `damage.js isHpLoss`; only for what the official calls 流失 — "N 真实伤害 per second" effects such as 源石溶剂 are `periodicDamage` damage, §21.12; a 物理流失 is first reduced by the target's DEF, 阿戈尔's devour, §21.19; `sourceless` = 无来源, `source` keeps the credit — leader parts pass damage on 1:1 with it, §20.10), `battle.addCoins(playerId, n)`, `battle.fx(kind, params)` (client VFX event), `battle.rng()` (never `Math.random`), `battle.getPlayer(playerId)` (bonds/band/effects view), `battle.redeploy(unit)` (on its rest tile: where a knocked-out operator lies, else its home; `battle.isReservedTile(r, c)` = a tile no automatic placement takes, `battle.downOn(r, c)` = the knocked-out operator lying there, whose tile no ally deploys or moves onto — §21.24), `battle.after(seconds, fn)` (scheduled callbacks), `battle.every(seconds, fn)`.
### 5.5 Combat rules (engine)
- **Initial deployment** at t=0, free: operators by column — the left column first, top to bottom within a column (right-side boss player: from its own left, the highest field column) — then the summons (PRTS 卫戍协议/帮助 "按从上到下>从左到右的顺序部署。优先部署干员,随后为召唤物", read as a scan: down each column, the columns left to right — the order PRTS gives the 阿戈尔 devour, "更靠左和靠上"; row-major until 0.1.3, §23.23; `aggroSeq` ranks every start-of-battle summon after all operators) — a skill's placed summon (赫默 医疗探机, 巫恋 诅咒娃娃) included, once and free ("所有手动部署的召唤物,无视所属干员的持有状态…作战开始时立即部署一次"; afterwards it re-appears on its tile each time the owner's skill gives one, §20.1); on a shared field (联防, boss) the players' i-th operators deploy together [ASSUMED]. Deploy effects fire (`deploy` with `initial: true`). Then operators carried into 联防 knocked out (`carryState.down`) are forced out (`FORCED_EXIT`, HP 0, §19.3). Then `battleStart`, after which the forced-out operators' timers are re-read (redeploy-time effects that start with the battle, e.g. 机变 征召, cover them too).
- **DP**: per player, start 10, +1/s, cap 99; used only for auto-redeploy (dead op: after `respawnTime`, if the tile it lies on is free and DP ≥ cost ⇒ redeploy there with full HP, SP = initSp). A knocked-out operator (and, since 0.1.2, one forced out by its own effects, §22.15) stays **down** on the tile it fell on — its own home when it fell on another board piece's home (that piece on the field or not) and its home is free, and 乌尔比安 knocked out while his S3 has moved him always on his deployment tile when it is free (the owner's deviation of 2026-10-07, `Unit.downAtHome`, §25.17.3) — until it redeploys there, and no ally deploys or moves onto that tile meanwhile (PRTS 卫戍协议/帮助 §作战阶段 单位部署; `Battle.downOn` / `restTile` / `_layBody`, §21.24; b.snap `down`, §18.3: counting / waiting for DP / its tile taken, a safeguard) — also one that entered 联防 knocked out (`FORCED_EXIT`, its full redeploy timer — confirmed by the user — with the battle-start redeploy effects, §19.3); summons, devices and enemies leave at once.
- **Blocking** (contact radius, PRTS 游戏数据基础 §阻挡半径 / 作战机制 "中点判定…案例:阻挡"; `constants.js BLOCK_RADIUS`, `Battle._checkBlock`, §19.2): an operator with free capacity blocks an unblocked, blockable enemy whose position lies within its block radius of its centre — ground 0.7071 (squared 0.49999037), 0.8944 × the blocker's 阻挡半径倍率 for blockFly units vs flyers (起飞; mod `blockRadiusScale` = 倍率 − 1 — PRTS 游戏数据基础 "飞行阻挡半径 = 0.8944 × 阻挡半径倍率", 凯尔希·思衡托 +0.23), devices 0.4472 (阻隔工事 / 障碍物); air units (FLY, 近地悬浮, 浮空) only by blockFly units. A ground enemy is never blocked by a unit standing on a tile ground units cannot pass (围墙 / 围栏: PRTS 地形机制 "部署在其中的单位,若当前阻挡类型为'地面阻挡'则无法阻挡敌人" — an enemy pushed or pulled against the fence walks on; air blocking there stays [ASSUMED], §21.23) nor by an airborne (起飞) unit, which blocks flyers only (§21.22). An enemy holding 不可阻挡 (PRTS 异常效果 BLOCK_FREE 「无法阻挡/被阻挡,自动解除阻挡」) — `unblockable` (恐惧, 诱导, enemy abilities), 浮空, and 沉睡 (SLEEPING = 无法行动+无敌+不可阻挡) — is never blocked and is released at once: a sleeping enemy frees its blocker's slot, stays where it is and, once awake, is blocked again only by a blocker with room (§24.9). The nearest such operator wins (ties: scan order [ASSUMED]). It is re-checked every tick, moving or not, so another operator in contact with room takes the enemy over when its blocker dies, is withdrawn or is stunned, and an operator whose capacity frees up grabs an overlapping enemy; one that finds no room walks on. A stunned or frozen enemy is checked too — 晕眩 / 冻结 hold no 不可阻挡 (PRTS 异常效果: the STUN / FROZEN state machines stop its moves and attacks) —, so an operator redeployed beside a held enemy blocks it where it stands and lifts its 隐匿; one held short of contact is blocked once it walks in after the status (GitHub #232; until 0.2.0 the check waited for the status to end). A head-on enemy stops at contact (≈ 0.71 tile), outside the blocker's tile. Enemy uses `blockCnt` capacity (default 1). Blocked enemies stop moving and attack their blocker if melee (or anyone in range if ranged, blocker first). **Every blocker** — melee units (重装要塞 / 领主 / 哨戒铁卫 included), summons and a ranged operator on a melee tile alike — whose attack hits enemies may always target the enemies it blocks, in range or not, whatever its facing, and targets them first ("可以选择且优先选择阻挡单位", PRTS 选择器; the user's rule after playtest #6, "阻挡了就一定要能打到": officially the collision pushes a blocked enemy to its blocker's front; `Battle.blockedTargets`, `targeting.js sortEnemyTargets`; §20.3 — it replaces the playtest #5 QA's melee-only restriction of §19.2). A healer (dmgType `heal`, or a skill attack turned into a heal) selects injured allies only and keeps healing while it blocks (PRTS 卫戍协议/帮助 "对于医疗干员(咒愈师分支除外),攻击目标为需要治疗的单位"; §21.17); 咒愈师 follow the blocker rule. The huge leaders are 自缚 + unblockable (§7).
- **Blocking** (contact radius, PRTS 游戏数据基础 §阻挡半径 / 作战机制 "中点判定…案例:阻挡"; `constants.js BLOCK_RADIUS`, `Battle._checkBlock`, §19.2): an operator with free capacity blocks an unblocked, blockable enemy whose position lies within its block radius of its centre — ground 0.7071 (squared 0.49999037), 0.8944 × the blocker's 阻挡半径倍率 for blockFly units vs flyers (起飞; mod `blockRadiusScale` = 倍率 − 1 — PRTS 游戏数据基础 "飞行阻挡半径 = 0.8944 × 阻挡半径倍率", 凯尔希·思衡托 +0.23), devices 0.4472 (阻隔工事 / 障碍物); air units (FLY, 近地悬浮, 浮空) only by blockFly units. A ground enemy is never blocked by a unit standing on a tile ground units cannot pass (围墙 / 围栏: PRTS 地形机制 "部署在其中的单位,若当前阻挡类型为'地面阻挡'则无法阻挡敌人" — an enemy pushed or pulled against the fence walks on; air blocking there stays [ASSUMED], §21.23) nor by an airborne (起飞) unit, which blocks flyers only (§21.22). An enemy holding 不可阻挡 (PRTS 异常效果 BLOCK_FREE 「无法阻挡/被阻挡,自动解除阻挡」) — `unblockable` (恐惧, 诱导, enemy abilities), 浮空, and 沉睡 (SLEEPING = 无法行动+无敌+不可阻挡) — is never blocked and is released at once: a sleeping enemy frees its blocker's slot, stays where it is and, once awake, is blocked again only by a blocker with room (§24.9). The nearest such operator wins (ties: scan order [ASSUMED]). It is re-checked every tick, moving or not, so another operator in contact with room takes the enemy over when its blocker dies, is withdrawn or is stunned, and an operator whose capacity frees up grabs an overlapping enemy; one that finds no room walks on. A stunned or frozen enemy is checked too — 晕眩 / 冻结 hold no 不可阻挡 (PRTS 异常效果: the STUN / FROZEN state machines stop its moves and attacks) —, so an operator redeployed beside a held enemy blocks it where it stands and lifts its 隐匿; one held short of contact is blocked once it walks in after the status (GitHub #232; until 0.2.0 the check waited for the status to end). A head-on enemy stops at contact (≈ 0.71 tile), outside the blocker's tile. Enemy uses `blockCnt` capacity (default 1). Blocked enemies stop moving and attack their blocker if melee (or anyone in range if ranged, blocker first). **Every blocker** — melee units (重装要塞 / 领主 / 哨戒铁卫 included), summons and a ranged operator on a melee tile alike — whose attack hits enemies may always target the enemies it blocks, in range or not, whatever its facing, and targets them first — a 速射手's air priority (优先攻击空中单位) excepted, which comes before the enemy it blocks (the owner's decision of 2026-10-08, GitHub #220) — ("可以选择且优先选择阻挡单位", PRTS 选择器; the user's rule after playtest #6, "阻挡了就一定要能打到": officially the collision pushes a blocked enemy to its blocker's front; `Battle.blockedTargets`, `targeting.js sortEnemyTargets`; §20.3 — it replaces the playtest #5 QA's melee-only restriction of §19.2). A healer (dmgType `heal`, or a skill attack turned into a heal) selects injured allies only and keeps healing while it blocks (PRTS 卫戍协议/帮助 "对于医疗干员(咒愈师分支除外),攻击目标为需要治疗的单位"; §21.17); 咒愈师 follow the blocker rule. The huge leaders are 自缚 + unblockable (§7).
- **Operator attacks**: every `interval` s if a valid target exists; targets = up to `maxTargets` (default 1) from `unitsInGrid(rangeGrid)` (a `rangeAoe` profile — 阵法术师, 轰击术师 — takes every selectable enemy in range, §21.18; 「同时攻击阻挡的所有敌人」 — 强攻手 / 重剑手 / 推击手 and the skills worded so, `hitAllBlocked` — up to the block count, never fewer than 1, since 0.2.0: PRTS 分支特性信息 "普通攻击最大目标数等于阻挡数(不会低于1)"), priority: (1) the enemies it blocks (every blocker, in range or not, §20.3), (2) profession/trait priorities (e.g. fly-first snipers, `trait` text parsed by build-data into `targetPriority`), (3) enemy with the **least remaining path distance** to its goal, ties → earliest spawned. Melee (no ranged trait) and ground-only attacks cannot hit air units (`Unit.isFlying`: FLY, or a 近地悬浮 / 浮空 enemy — PRTS "算作空中单位", §19.1). Stealthed enemies can't be targeted unless blocked or revealed — or within 3 s of a block's end (§22.8). Ranged ops fire projectiles (speed ~12 tiles/s, instant for melee/arts-beam classes as data indicates); damage applies on impact; a 锁定攻击范围 AoE (`rangeAoe`) strikes every selectable enemy on its range at once with instant hits (§21.18).
- **Healers** (`dmgType heal`): target the ally in range with the lowest HP% below 100%, never a 禁疗 / 孤立 unit (tokens.json `abnormal`, `noHeal`; §20.3; 史尔特尔 during 余烬, §22.7), and never the enemies they block — also while blocking (§21.17); do nothing otherwise (SP still regenerates).
- **Enemy AI**: follow the route (checkpoints; WALK paths recomputed on the grid when devices change); when blocked stop & fight (`dmgType 'none'` enemies never attack unless content arms them through `e.profile` — 转译基底's armed forms, §21.4); ranged enemies (`rangeRadius > 0`) attack ops whose 0.25 collider touches their radius (centre distance ≤ `rangeRadius` + 0.25, §3, §20.4): the enemy's own target rule filters the candidates first (`profile.canTarget`: 只攻击地面单位 …), then priority blocker → special priority → highest taunt → **latest deployed** (`aggroSeq`) — PRTS 索敌 "阻挡→特殊优先级→仇恨值→最早出现"; a stealthed ally is attacked only by the enemy it blocks (§19.1) — and not even by that one when its 索敌不受阻挡影响 (自制投石机, `profile.blockFree`: it selects as if unblocked, §25.18) —, and an enemy's area effects — splash, blasts, area skills, zones, jumps — and buff auras skip it unless it blocks that enemy, while 迷彩 does not protect from splash and the other area effects (`targeting.js areaSelectable` / `auraSelectable`, §22.12); an airborne (起飞) ally is never a ground enemy's selection — picks, area damage, zones and statuses skip it, while 无视无法选择 abilities, direct picks, debuff ticks and flying units still reach it (§21.22); unblocked, they stand for each attack's clip (enemies.json `attackAnim`: its wind-up before the strike and the rest of the clip after it, shortened when the attacks come quicker than the clip; `ATTACK_PAUSE = 0.35 s` after the strike when no clip is known; a 「不停止移动」 attacker never stops; §22.14); any enemy whose block ends after its strike (its blocker stunned by it, knocked out, retreated) stands for the rest of that clip too — PRTS 状态机: the enemy's COMBAT state ends with its attack — and a displacement ends a stand (0.2.0). Every enemy attack strikes at its clip's damage frame after its swing starts — the first one of a contact too —, and a stun / freeze / sleep / 浮空, hiding, 缴械, 恐惧 or 战栗 (blocked) before that frame cuts the swing: no strike, the next swing starts from its wind-up (0.2.0, `ai.js attackWindup`; PRTS 状态机 / 异常效果 STUNNED; no clip known: no wind-up). FLY enemies ignore ground obstacles (only blockFly units block them) and follow their checkpoint route. A feared enemy runs between random 恐惧可达地块 away from the source (PRTS 诱发移动; `fear.js`); 恐惧 outranks 诱导; the 恐惧 / 诱导 walks re-plan from where the enemy stands after a push or an obstacle change, and the route re-plans when they end (§20.4).
- **Enemy AI**: follow the route (checkpoints; WALK paths recomputed on the grid when devices change); when blocked stop & fight (`dmgType 'none'` enemies never attack unless content arms them through `e.profile` — 转译基底's armed forms, §21.4); ranged enemies (`rangeRadius > 0`) attack ops whose 0.25 collider touches their radius (centre distance ≤ `rangeRadius` + 0.25, §3, §20.4): the enemy's own target rule filters the candidates first (`profile.canTarget`: 只攻击地面单位 …), then priority blocker → special priority → highest taunt → **latest deployed** (`aggroSeq`) — PRTS 索敌 "阻挡→特殊优先级→仇恨值→最早出现"; a stealthed ally is attacked only by the enemy it blocks (§19.1) — and not even by that one when its 索敌不受阻挡影响 (自制投石机, `profile.blockFree`: it selects as if unblocked, §25.18) —, and an enemy's area effects — splash, blasts, area skills, zones, jumps — and buff auras skip it unless it blocks that enemy, while 迷彩 does not protect from splash and the other area effects (`targeting.js areaSelectable` / `auraSelectable`, §22.12); an airborne (起飞) ally is never a ground enemy's selection — picks, area damage, zones and statuses skip it, while 无视无法选择 abilities, direct picks, debuff ticks and flying units still reach it (§21.22); unblocked, they stand for each attack's clip (enemies.json `attackAnim`: its wind-up before the strike and the rest of the clip after it, shortened when the attacks come quicker than the clip; `ATTACK_PAUSE = 0.35 s` after the strike when no clip is known; a 「不停止移动」 attacker never stops; §22.14); any enemy whose block ends after its strike (its blocker stunned by it, knocked out, retreated) stands for the rest of that clip too — PRTS 状态机: the enemy's COMBAT state ends with its attack — and a displacement ends a stand (0.2.0) and holds the enemy in its 失衡 state (0.2.2: no walking, no attack started, no skill — PRTS 失衡位移机制). Every enemy attack strikes at its clip's damage frame after its swing starts — the first one of a contact too —, and a stun / freeze / sleep / 浮空, hiding, 缴械, 恐惧 or 战栗 (blocked) before that frame cuts the swing: no strike, the next swing starts from its wind-up (0.2.0, `ai.js attackWindup`; PRTS 状态机 / 异常效果 STUNNED; no clip known: no wind-up). FLY enemies ignore ground obstacles (only blockFly units block them) and follow their checkpoint route. A feared enemy runs between random 恐惧可达地块 away from the source (PRTS 诱发移动; `fear.js`); 恐惧 outranks 诱导; the 恐惧 / 诱导 walks re-plan from where the enemy stands after a push or an obstacle change, and the route re-plans when they end (§20.4).
- **Damage**: phys `max(A − max(0, D×(1−defIgnorePct) − defIgnoreFlat), 0.05·A)`; arts `max(A × (1 − R'/100), 0.05·A)` with `R' = max(0, R×(1−resIgnorePct) − resIgnoreFlat)`; true = A; 元素伤害 ('elemental') `max(A × (1 − 元素抗性/100), 0.05·A)`. Then × `dmgDealtMul` (source) × `dmgTakenMul` & type-specific taken multipliers (target) × `dmg.mul` — 元素伤害 takes 元素脆弱 (`elementalTakenMul`) alone, not `dmgTakenMul` (脆弱 = "受到的物理、法术、真实伤害提升"). Then **限伤**: on a leader (`isBoss` — the 10 bossInfoDict enemyIds and their mirrored copies; never parts, drones or escorts) in a `'boss'` / `'hidden'` battle a hit with `ceil(final) ≥ 300000` is cancelled — no HP or pool loss, no credit, no damage number, no `damaged` / kill (`shared/constants.js BOSS_HIT_LIMIT`, `damage.js leaderHitCancelled`, after every multiplier and the hit-step blocks, before HP shields; `loseHp` too — a part's 传递, but not 胄's drone link, a share of the pool that is no hit (`noHitLimit`, §25.13.4); the client's `MAX_BATTLE_DAMAGE`, §20.12). Then shields absorb; dodge checks for phys/arts. Element damage fills a gauge (1000; enemy leaders 2000) × `elemTakenMul` × max(0.05, 1 − 损伤抵抗/100), 损伤抵抗 = data `epResistance` (PRTS 元素; the 5 % floor of DMG_e, PRTS 游戏数据基础). An element fill on a target with no HP left is refused (damage.js `hasHp`: no burst on a corpse). A full gauge bursts with the side-specific official effects, as **无来源** damage (no damage-dealt multiplier or penetration of the unit that filled the gauge, which keeps the kill and the stats; §19.5) (sim/constants.js `ELEMENT`; operators: 灼燃 1200 arts + RES −20, 神经 stun 10 s then 1000 true, 凋亡 15 s of 阻回 (`noSp`: no SP gain of any kind) + 静默 with −1 SP/s and 100 arts/s, 侵蚀 DEF −100 then 800 phys; enemies: the "·我方" terms). Every burst starts a **爆发冷却** = its duration (灼燃 / 神经 / 侵蚀 10 s — enemies' 侵蚀 8 s — 凋亡 15 s) during which NO element of the unit fills or can be recovered; when it ends every gauge of the unit resets (§18.3).
- **Leak**: an enemy reaching its goal is removed, counted as leaked for its field owner (`sourcePlayerId` for unite). LP accounting is done by the match, not the battle.
- **Results**: `damageDealt` / a unit's `dmg` count only HP removed from the other side — a unit's own drain or 流失 and friendly damage are the target's `taken` only (§21.12).
@@ -139,7 +139,7 @@ Duration end cleans the window and emits `skillEnd`; it leaves zero charges and
Redeployment opens a new full window. Permanent passives retain `passive`; first-kill recasts of 缄默德克萨斯 explicitly
restart a full window. Special timed kits and their effect rules are described in SIM §7.2.
Auto-trigger rules (`skillTrigger.rule` in data — the official 技能策略, PRTS 卫戍协议/帮助 §作战阶段 技能操作; §20.2): `DEFAULT` (SP ready AND about to attack/heal AND an enemy/injured ally inside the **initial** range, or an enemy the unit blocks; with `trigger.allies` + `hpAtMost` an ally of the skill grid at or below that HP ratio — 塞雷娅 S1 急救, whose cast replaces that attack and is withdrawn with its charge if the ally recovers first), `SKILL_RANGE` (a MANUAL skill with a 技能范围 of its own, not an attack-range change: any living enemy, stealthed ones included, on the skill range, checked every tick — "仅在技能范围内存在敌人(无视其不可选中)时释放技能"; 13 skills / 26 records by the official rule, 14 / 28 in the data with 余 S2's deviation, §22.10), `ACTIVE_RANGE` (the owner's deliberate deviation of 2026-10-05, 0.2.0: a MANUAL skill on the basic strategy — or, by the owner's further decisions of the same day, on the `SEARCH` row, and 深巡 S2 on top of its §21.29 DEFAULT — whose attack range while it runs strictly contains the operator's own range — its rangeId grid, or the own range grown by `ability_range_forward_extend` — checks the DEFAULT condition on that running range every tick, no attack needed; `customRangeGrid` = the running range, `rawRule` the official row (DEFAULT, SEARCH, 深巡's TAKE_DAMAGE); 39 skills / 78 records, of them 薄绿 S1, 蜜蜡 S1, 卡涅利安 S3, 玛恩纳 S2 and 安洁莉娜 S3 from SEARCH; 烛煌 S2 / S3, whose ranges do not contain her 3-1, keep DEFAULT), `TAKE_DAMAGE` (on taking damage — any damage, 无来源 included, never a 流失; every MANUAL 重装 skill, §21.12 — but the six of the deliberate deviation, §21.29: 深巡 / 雷蛇 S2, 号角 S2 / S3 and 灰毫 S1 / S2 are `DEFAULT`, `rawRule` keeping the official TAKE_DAMAGE — 深巡 S2 then `ACTIVE_RANGE` on its 3-2; and 余 S2, `SKILL_RANGE` on its x-1 (§22.10); the 重装 of data/backups.json — the 补位 stand-ins 预备干员-重装 / Mechanist and every 自选 重装 — cast with an enemy in range, `DEFAULT` (or `ACTIVE_RANGE` when the skill's running range is larger), by the owner's decisions of 2026-10-05), `SP_FULL` (immediately — 执旗手 / 战术家 / 吟游者 MANUAL skills, AUTO DP skills such as 焰尾 S1, 凛御银灰 S1 (伺夜 S1 / S2 too, but only while his pack is on the field — PRTS 备注 「仅场上存在狼群时可触发技能」, the kit's own cast; the owner's decision of 2026-10-05 for S1; S1 also while the pack sits in its 战术点形态, which the cast ends — S2 too since 0.2.0: its DP at once, its gift kept for the returning pack [ASSUMED: S2's 备注 uses S1's words]; 黑键 S2 荒芜回响 likewise only while one placeable tile of his range is free, no enemy needed — PRTS 备注 「攻击范围内不存在可部署位时,技能不会被触发」, its kit's cast, 0.2.0), and, by their kits, AUTO skills whose effects act on allies only: 引星棘刺 S1, 浊心斯卡蒂 S2, 纯烬艾雅法拉 S1 — the owner's decision of 2026-10-05 for the last), `CUSTOM_RANGE` (enemy inside the custom grid), `SEARCH` (an enemy inside the initial range, checked every tick — no attack needed; 解放者 / 阵法术师 MANUAL skills and 安洁莉娜 S2, those without a larger running range), `GDGLOW_SKILL_2` (a valid target anywhere on the field: 荒芜拉普兰德, 纯烬艾雅法拉 S3), special ids. The class rows cover **every MANUAL skill** of the class and never an AUTO skill (AUTO skills keep their own rule — an inference: PRTS names whole classes; the data's `skillIndex 0` / `-1` sentinel argues the other way, §20.2). **Operation cooldown**: the engine auto-casts a MANUAL skill no sooner than `AUTO_OP_COOLDOWN` (3 s) after its previous cast and after the unit's battle-start deployment ("自动操作具有3s冷却,在完成一次操作或作战开始时部署的单位将进入冷却"; `Battle._deploy` initial, `battle.flags.startOpCooldown`); kits with an automatic cast of their own check `skill.opCooling`; a pending "next attack" never takes a second charge. Permanent passive skills are always on; finite deployment effects use the duration lifecycle above.
Auto-trigger rules (`skillTrigger.rule` in data — the official 技能策略, PRTS 卫戍协议/帮助 §作战阶段 技能操作; §20.2): `DEFAULT` (SP ready AND about to attack/heal AND an enemy/injured ally inside the **initial** range, or an enemy the unit blocks; with `trigger.allies` + `hpAtMost` an ally of the skill grid at or below that HP ratio — 塞雷娅 S1 急救, whose cast replaces that attack and is withdrawn with its charge if the ally recovers first), `SKILL_RANGE` (a MANUAL skill with a 技能范围 of its own, not an attack-range change: any living enemy, stealthed ones included, on the skill range, checked every tick — "仅在技能范围内存在敌人(无视其不可选中)时释放技能"; 13 skills / 26 records by the official rule, 14 / 28 in the data with 余 S2's deviation, §22.10), `ACTIVE_RANGE` (the owner's deliberate deviation of 2026-10-05, 0.2.0: a MANUAL skill on the basic strategy — or, by the owner's further decisions of the same day, on the `SEARCH` row, and 深巡 S2 on top of its §21.29 DEFAULT — whose attack range while it runs strictly contains the operator's own range — its rangeId grid, or the own range grown by `ability_range_forward_extend` — checks the DEFAULT condition on that running range every tick, no attack needed; `customRangeGrid` = the running range, `rawRule` the official row (DEFAULT, SEARCH, 深巡's TAKE_DAMAGE); 39 skills / 78 records, of them 薄绿 S1, 蜜蜡 S1, 卡涅利安 S3, 玛恩纳 S2 and 安洁莉娜 S3 from SEARCH; 烛煌 S2 / S3, whose ranges do not contain her 3-1, keep DEFAULT), `TAKE_DAMAGE` (on taking damage — any damage, 无来源 included, never a 流失; every MANUAL 重装 skill, §21.12 — but the six of the deliberate deviation, §21.29: 深巡 / 雷蛇 S2, 号角 S2 / S3 and 灰毫 S1 / S2 are `DEFAULT`, `rawRule` keeping the official TAKE_DAMAGE — 深巡 S2 then `ACTIVE_RANGE` on its 3-2; and 余 S2, `SKILL_RANGE` on its x-1 (§22.10); the 重装 of data/backups.json — the 补位 stand-ins 预备干员-重装 / Mechanist and every 自选 重装 — cast with an enemy in range, `DEFAULT` (or `ACTIVE_RANGE` when the skill's running range is larger), by the owner's decisions of 2026-10-05), `SP_FULL` (immediately — 执旗手 / 战术家 / 吟游者 MANUAL skills, AUTO DP skills such as 焰尾 S1, 凛御银灰 S1 (伺夜 S1 / S2 too, but only while his pack is on the field — PRTS 备注 「仅场上存在狼群时可触发技能」, the kit's own cast; the owner's decision of 2026-10-05 for S1; S1 also while the pack sits in its 战术点形态, which the cast ends — S2 too since 0.2.0: its DP at once, its gift kept for the returning pack [ASSUMED: S2's 备注 uses S1's words]; 黑键 S2 荒芜回响 likewise only while one placeable tile of his range is free, no enemy needed — PRTS 备注 「攻击范围内不存在可部署位时,技能不会被触发」, its kit's cast, 0.2.0), and, by their kits, AUTO skills whose effects act on allies only: 引星棘刺 S1, 浊心斯卡蒂 S2, 纯烬艾雅法拉 S1 — the owner's decision of 2026-10-05 for the last), `CUSTOM_RANGE` (enemy inside the custom grid), `SEARCH` (an enemy inside the initial range, checked every tick — no attack needed; 解放者 / 阵法术师 MANUAL skills and 安洁莉娜 S2, those without a larger running range), `GDGLOW_SKILL_2` (a valid target anywhere on the field: 荒芜拉普兰德, 纯烬艾雅法拉 S3), special ids. In every enemy condition a registered ally target (白铁's 铁钳号·原型机, an enemy-camp summon our attacks select) counts like an enemy for a skill that acts on it (the owner's rule of 2026-10-08): one acting through the unit's own attacks, or whose kit flags `allyTargets` (its selectors take the device too — 蕾缪安 S3, 艾丽妮 S3, 陈 S2, 引星棘刺 S2); [ASSUMED] a skill that picks enemies only is not opened by it. The class rows cover **every MANUAL skill** of the class and never an AUTO skill (AUTO skills keep their own rule — an inference: PRTS names whole classes; the data's `skillIndex 0` / `-1` sentinel argues the other way, §20.2). **Operation cooldown**: the engine auto-casts a MANUAL skill no sooner than `AUTO_OP_COOLDOWN` (3 s) after its previous cast and after the unit's battle-start deployment ("自动操作具有3s冷却,在完成一次操作或作战开始时部署的单位将进入冷却"; `Battle._deploy` initial, `battle.flags.startOpCooldown`); kits with an automatic cast of their own check `skill.opCooling`; a pending "next attack" never takes a second charge. Permanent passive skills are always on; finite deployment effects use the duration lifecycle above.
**SkillSpec** (authored per chess in `sim/content/kits/tierN.js`; values come from the chess's skill blackboard at its level — normal Lv4, elite Lv7 — so a spec is a *function of the blackboard*):
```js
@@ -177,6 +177,6 @@ Each domain file `server/sim/content/{tokens,bonds,garrisons,items,bands,enemies
- items: 56 equipment (+golden) + 3 Arts; 变形同构体 bond grant; VI-tier set/combos.
- bands: 40 strategies (meta + battle). 鸭爵 “神秘顾客” swaps 0–2 ground enemies (not the leader, its parts or bounties; on a pair boss field only the player's half — the boss / hidden `onBattleStart` event carries `routes` and `side`) in every teammate's normal / Final Assault / Hidden Core battle from R5 into the act2 鸭爵 / 高普尼克 / 流泪小子 / 圆仔 (lpr 1, 1 fund to the killer, 联防 included) (§21.5); the swaps carry the spawn tag `'duck'`, and the swapped 流泪小子 hides again 1 s after a block ends, not the official 0 s (the owner's decision of 2026-10-07, §25.22.6).
- enemies: special-type behaviours (FLY, TIMES, ELEMENT, DOT, INVISIBLE, REFLECTION, SPECIAL) and notable individual abilities from `data/enemies.json`; bosses 1–10 scripted — the huge leaders (假想敌:胄 ×2, 假想敌:管 ×2, 盐风主教昆图斯, 阿利斯泰尔, “萨米的意志”) are 自缚 + 无法被阻挡 (PRTS 天赋; `content/bosses.js SELF_BOUND`: a persistent noMove + unblockable buff from spawn) with their hit area (§19.4); 圆仔 and 鸭爵 are 无法被阻挡 (PRTS 天赋, §21.5); the knock-out forms follow PRTS — 深池逐火 余烬 (5 / 10 hits, 隐匿, blockable, back after 10 s), 转译基底·α (damage cancelled until its 2 s change into 寻仇者 / 特战术师 / 幽灵), the leaders' 重生, 守墓石像's statue and flight — and every 重生 clears what operators put on the enemy and restarts its skills from their initial cooldown (§21.4, §21.19); the mode's air units are 静态刚体, never moved (§21.9); leader parts (斩胄之剑 / 破胄之锤 / 碎铳之簧) pass every damage they take to their leader 1:1 as 无来源 HP loss (`PART_TRANSFER` 1, PRTS "等量"), a 假想敌:胄 drone that dies costs it 2 % of the pool (`DRONE_LINK_BASE 'pool'`) (§20.10, §20.13); devices/terrain.
- choices: 机变 families (悬赏决策 bounties, 道具补给, 机密商店, 战术决策 team/personal buffs). 悬赏决策 = the official structure of its round (choices.json `bountyDrafts`, 86 drafted cards: R3 a fixed set of six 两场 cards, R9 6 of a boss group's list, R11 6 of a seen 7-card list; §21.2); 机密商店 (R11 the official composition) and 战术决策 can offer the same card twice (`shopDraft` / `tacticDraft`); bounty cards carry `descRaw`; no draft offers a multi-round card, and 教鞭's lasts two battles (`choices.js MULTI_ROUND_BOUNTY_BATTLES`, §20.6). 教鞭 = a random 战术特训 card (3 drawn, 1 taken).
- choices: 机变 families (悬赏决策 bounties, 道具补给, 机密商店, 战术决策 team/personal buffs). 悬赏决策 = the official structure of its round (choices.json `bountyDrafts`, 86 drafted cards: R3 a fixed set of six 两场 cards, R9 6 of a boss group's list, R11 6 of a seen 7-card list; §21.2); 机密商店 (R11 the official composition) and 战术决策 can offer the same card twice (`shopDraft` / `tacticDraft`); bounty cards carry `descRaw`; no draft offers a multi-round card, and 教鞭's lasts two battles (`choices.js MULTI_ROUND_BOUNTY_BATTLES`, §20.6). Pointing Stick (教鞭) offers up to three private Tactical Training cards during PREP; the owner confirms one through `g.choice` with its `choiceId`, with Ready blocked until confirmation or the existing prep deadline's auto-pick. “神秘顾客” remains random (META §2.5).
---
+7
View File
@@ -22,6 +22,13 @@ Part of [DESIGN.md](../DESIGN.md) (the index; section numbers are global).
## 4. Time model
- Simulation time is **game seconds**. Fixed step `TICK = 1/30 s`.
- Timers count in ticks and take what is within 1e-9 as reached — attack cooldowns and enemy wind-ups (`ai.js
attackCountdown`), time SP against its cost (`skills.js gainSp`), skill durations, buff intervals, `every()` /
`after()`, enemy abilities, the 失衡 end —, so a whole number of ticks takes exactly that many: 1 s = 30 ticks (PRTS
作战机制/sandbox 「帧对齐机制对攻速的影响」: one frame = 1/30 s). Floating point alone held 1 s attacks and 10-SP charges a
tick longer until 0.2.2 (PR #402, §27.61). A non-integer count ends on the tick that crosses the line, ⌈30 × t⌉ (1.25 s
= 38); the official game rounds it (四舍五入 since 2019-12-24: 能天使's 0.678 s is 20 frames there, 21 here) — an older
difference, not changed.
- Combat runs at **2× real time** (forced, like the original): 60 ticks per real second — in the browser's runner (§14) or, for server-run fields, the server's accumulator (2 ticks every real 1/30 s, never more than 8 ticks per interval to avoid spirals).
- Combat time limits come from `data/config.json → modes[m].rounds[r].combatTimeLimit` (= the level's `maxPlayTime`; also `modes[m].combatTimeLimit[r]`) and are **real seconds of the forced-2× battle** (verified by the balance pass: spawn schedules exceed the limit when read as game seconds). The server passes `2 × value` game seconds to the Battle (`server/match/gamedata.js → combatTimeLimit`; `config.combatTimeScale`, default 2); the countdown players see equals the data value. When time runs out, living non-boss enemies count as leaked. 联防 uses the round's limit.
- Final Assault / Hidden Core have **no hard stop** and their clocks are real seconds too: the boss level's `levelMaxPlayTime` (120 real s) is only the HUD countdown (`m.public.deadline`; the battle goes on past it), and the overtime drain (`bossOvertimeAfter` 150 / `bossOvertimeDrainPerSec` 1 = official `bossTurnHpReduceTime`) takes 1 team LP per whole **real** second from 150 real s (= 300 game s on the 2× field clock; first point at 151 s — `gamedata.js bossOvertimeDue`). `m.public.overtimeAt` = the ms epoch when the drain starts; both are placed on the field clock.
+31 -6
View File
@@ -7,14 +7,15 @@ Part of [DESIGN.md](../DESIGN.md) (the index; section numbers are global).
### 6.1 State machine (Match.js)
```
LOBBY(room) → INFO_CHECK (co-op 25 s; solo and any single-human match untimed (§18.2); all ready ⇒ skip; the operator loadout (§16) locks when it ends) → BAND_DRAFT (co-op: random order, ONE countdown — `BAND_TURN_SECONDS` 30 s per turn = m.public.deadline, AI seats pick at once, 1 skip each, a strategy a teammate already took is refused (队友已选), timeout ⇒ the strategy the player highlights (`g.bandFocus`) while free, else band_bldsk, else the first free one; solo: free pick, no timer, no skip; a single human: untimed) → BATTLE_CHECK (3 s)
LOBBY(room) → INFO_CHECK (co-op 25 s; solo and any single-human match untimed (§18.2); all ready ⇒ skip; the operator loadout (§16) locks when it ends) → BAND_DRAFT (co-op: random order — the room option 「AI 队友最后选择」 (host, off by default; `room.setAiPicksLast`) puts every human seat before every AI seat, each group keeping the drawn order, and draws no extra random number; a skipping human goes behind the other humans still to pick and ahead of the AI seats, or to the very end when no other human remains [ASSUMED] —, ONE countdown — `BAND_TURN_SECONDS` 30 s per turn = m.public.deadline, AI seats pick at once, 1 skip each, a strategy a teammate already took is refused (队友已选), timeout ⇒ the strategy the player highlights (`g.bandFocus`) while free, else band_bldsk, else the first free one; solo: free pick, no timer, no skip; a single human: untimed) → BATTLE_CHECK (3 s)
→ loop r = 1..lastRound:
ROUND_START (income, upgrade price −1 (floor 0), temp NOT wiped — what overflowed after the last prep's deadline is shown in this
prep (§6.2 temp overflow), unfrozen shop slots rerolled, frozen kept, <进入休整期时> effects)
[SP_DRAFT] (if r ∈ spRounds: 机变 draft; co-op 30 s first picker / 16 s others; solo 3 cards; solo / single human untimed;
[SP_DRAFT] (if r ∈ spRounds: 机变 draft; co-op: random order (「AI 队友最后选择」, off by default: humans first, the same partition as the strategy draft), 30 s first picker / 16 s others; solo 3 cards; solo / single human untimed;
a card is picked with two taps, §18.2)
PREP (co-op: timer from config, ends when all alive humans+bots ready; solo / single human: untimed, ends on ready)
PREP_END (<休整期结束时> effects; unfrozen shop cleared; temp overflow: Ready is blocked while temp is non-empty; at the deadline
PREP_END (<休整期结束时> effects; unfrozen shop cleared; Ready is blocked while a personal choice or temp overflow is pending;
at the deadline personal choices are auto-picked first, then
the temp pieces due at this prep are resolved (chess sold back, items destroyed, summon stacks removed — they come back at the next round start, §20.1) — pieces that arrived
after Ready or during the <休整期结束时> effects wait for the next prep (§6.2 tempDue))
COMBAT (one Battle per alive player, all in parallel, 2×; watchers get snapshots; solo: pausable, §14 Solo pause)
@@ -34,6 +35,18 @@ LOBBY(room) → INFO_CHECK (co-op 25 s; solo and any single-human match untimed
```
Solo FUNNY has 9 rounds (boss at R9); everything is read from `data/config.json → modes[modeId]`.
**Confirm-page setup reroll (remake convenience proposal, GitHub #77 / PR #408).** During INFO_CHECK the host may
request a reroll; every non-departed human explicitly consents, including a human on AI control. AI and spectator
seats do not vote, and one human rerolls immediately. All humans must be connected to start a vote; a refusal, host
cancellation, disconnect or departure cancels it. While voting, the confirmation deadline and any queued all-ready
transition are suspended. Cancellation restores the previous remaining time and ready states; success clears human
ready states (bot-controlled seats keep auto-ready) and restarts the full confirmation deadline, still untimed for a
single human. Success redraws the stage, leader, hidden leader, special factions and their full wave schedule, and
bans, rebuilding shared and per-player DIY stock. Mode-fixed rules still apply and draws may repeat. The room,
connections, loadout, ownership, DIY picks, potential / cultivation and AI-picks-last option remain unchanged. A
separate RNG stream leaves existing shop / draft streams untouched. Setup revisions and vote IDs reject stale requests;
the minimum interval between proposals is 300 ms. `match/setupVote.js` owns this flow; the lobby authorizes the host.
### 6.2 Player state (PlayerState.js) — authoritative per player
```js
@@ -44,6 +57,7 @@ Solo FUNNY has 9 rounds (boss at R9); everything is read from `data/config.json
board: Map<'r,c', Piece>, // chess & token pieces on the normal field
bonds: { [bondId]: { count, active, tier, layers } }, // layers persist all match
effects: [ EffectRef ], // band/choice/team/garrison persistent effects & counters
personalChoice: null | { id, round, sourceItemId, cards: [ BountyCard ] }, // private PREP offer; raw cards on the server
counters: { refreshes, buys, sells, … },
stats: { … for results } }
Slot = { kind:'chess'|'item', id, price, sold:false, frozen }
@@ -62,6 +76,12 @@ Economy/shop/pool/merge/hand rules: exactly as research 00-INDEX §3–§4 (inco
**Temp overflow (临时整备区, row 8; user playtest #3 item 3).** Passive gains that find the hand full (merge elites, grants — the operator a 突变细胞 transformation gains included, §21.1 —, battle-result rewards, equipment returned by a merged / sold / transformed carrier) overflow into the 5 temp slots, and Ready is refused while any is occupied (official "直到溢出情况排除才可开始进行作战"). A temp piece is resolved — a chess sold back to the pool with its summons removed, items destroyed, a summon stack removed (it comes back at the next round start, PRTS "干员所属召唤物会于下一回合返还", §20.1) — only at the deadline of the **first prep in which its player could act on it** (`PlayerState.tempDue(piece)` vs `prepsEnded`; every write into a temp slot goes through `_putTemp`, which records it): a piece that arrived during a prep before Ready is due at that prep's end; one that arrived after Ready or during the `<休整期结束时>` effects, or outside PREP (COMBAT, 联防, SETTLE — battle-result grants, a SETTLE merge's elite, returned equipment —, the next ROUND_START, 机变) is kept, visible and usable (move to a free hand slot, place, equip, use, destroy, sell) through the **next** prep. Un-readying (`setReady(false)`) makes whatever arrived while ready due at the current prep. ROUND_START no longer wipes temp (it used to destroy what SETTLE had put there before the player ever saw it). A merge's elite whose merge consumed a deployed copy takes that copy's tile and facing (§20.11); otherwise it goes to the hand, overflowing into temp (also outside PREP). `server/match/audit.js` (`tools/matchrun.mjs --check`) tracks the arrivals itself and flags any temp piece that outlives its due prep. The UI frames the row on the board with the rule (§17.5).
Pointing Stick creates at most one pending offer per alive, non-Ready player in PREP. The owner confirms it with
`g.choice { idx, choiceId }`; confirmation validates its ID, round and index, adds one bounty and clears the offer.
Ready requires no pending offer as well as an empty temp area. The co-op prep deadline resolves the offer randomly
before resolving temp and setting Ready; untimed solo / single-human prep does not schedule a separate choice timer.
The offer is recipient-private, including after reconnect, and never enters `m.public.sp` or a teammate's scout view.
### 6.3 Bonds & layers (bondsMeta.js)
- `count` = number of **distinct base chess** on the board carrying the bond (`BOARD`); `BOARD_AND_DECK` bonds also count the hand; 绝技 counts elites on board; 调和 adds +1 to each core bond count that has a real member on the board (the views mark such an entry `harmony: 1`, §21.26), etc. (research 02).
@@ -81,7 +101,10 @@ Per match: pick stage (weighted among the mode's `config.modes[m].stages`, weigh
### 6.6 AI players (bot.js)
Deterministic-ish heuristic player used for (a) AI teammates, (b) auto-play for disconnected humans (only auto-ready + auto-picks, not shopping, unless "AI 托管" is toggled), (c) tests. Strategy (§21.6): pick a band (weighted by starting LP; never one built around a bond the mode switches off — `gd.bandBondIds`, §21.26); each prep: take offers, sell dead bench weight, fill the board, complete affordable merges, level on the curve, then buy only merge progress / lineup upgrades worth more than the refreshes their price would buy (shop odds of completing held pairs), freeze for an unaffordable third copy (坎诺特 keeps 5 funds back); build around a committed focus core bond (a teammate's main bond on its strip counts against it; bonds the mode switches off never) + a second bond; value dealers by DPS vs the round's DEF / RES (an attack on every enemy in range counts double — 阵法术师 / 轰击术师), with the range the unit is deployed with; place by the exposure model + rehearsal on the server's deploy map (no 深水区; a tactician's 援军 within `PlayerState.summonRange`; a melee chess whose trait reads 「可以放置于远程位」 (钩索师 / 推击手) on a 高台 whose range covers the enemy road, else on the ground, counted as a blocker in the lineup either way — §3; `g.move` judges legality); items by effect (META §1.5: 突变细胞 on the least valuable normal single below 6阶, a cell left in temp gets a hand slot made for it, the operator its transformation gains is deployed by the next prep's lineup step like any owned unit — AI 托管 too; the buy loop's bench shed never sells a piece gained since the bot's last prep, §21.1); 机变 bounties by expected payout − expected LP loss over the offered cards; resolve temp — on a human seat under AI 托管 never by destroying an item while a bench operator can be sold for the slot —; ready. A bot's whole prep — shop decisions and layout planning (`botPrepBeginSteps`, 25–70 ms late in a 4-bot match), the layout rehearsal and `botPrepEndSteps` — runs in wall-clock slices of `botSliceMs` (8 ms) per scheduler callback on a real host (step generators that yield between whole actions, never with a transient board; the same actions in the same order, so decisions are unchanged; at once in virtual time). Under AI 托管 a human's own 自选 pieces score `DIY_PIECE_BONUS` (12) more in the shop / reward choice and the 调度中心 is levelled one round earlier for the step that opens a slotted tier (`levelUp`, `diyOpensAt`; 0.2.0 — bots field no 自选 piece).
Pending Pointing Stick offers use the existing bounty scorer at bot prep entry, immediately after a successful cast,
and before Ready. The prep's deadline — and the scheduler's fallback when a step of a bot's prep fails — randomly resolves any remaining offer before temp and Ready; a seat that left is eliminated and its offer dropped.
Deterministic-ish heuristic player used for (a) AI teammates, (b) auto-play for disconnected humans (only auto-ready + auto-picks, not shopping, unless "AI 托管" is toggled), (c) tests. Strategy (§21.6): pick a band (weighted by starting LP; never one built around a bond the mode switches off — `gd.bandBondIds`, §21.26); each prep: take offers, sell dead bench weight, fill the board, complete affordable merges, level on the curve, then buy only merge progress / lineup upgrades worth more than the refreshes their price would buy (shop odds of completing held pairs), freeze for an unaffordable third copy (坎诺特 keeps 5 funds back); build around a committed focus core bond (a teammate's main bond on its strip counts against it; bonds the mode switches off never) + a second bond; value dealers by DPS vs the round's DEF / RES (an attack on every enemy in range counts double — 阵法术师 / 轰击术师), with the range the unit is deployed with; place by the exposure model + rehearsal on the server's deploy map (no 深水区; a blocker whose range is its own tile only — range 0-1 — takes a free tile of the enemy road first; a tactician's 援军 within `PlayerState.summonRange`; a melee chess whose trait reads 「可以放置于远程位」 (钩索师 / 推击手) on a 高台 whose range covers the enemy road, else on the ground, counted as a blocker in the lineup either way — §3; `g.move` judges legality); items by effect (META §1.5: 突变细胞 on the least valuable normal single below 6阶, a cell left in temp gets a hand slot made for it, the operator its transformation gains is deployed by the next prep's lineup step like any owned unit — AI 托管 too; the buy loop's bench shed never sells a piece gained since the bot's last prep, §21.1); 机变 bounties by expected payout − expected LP loss over the offered cards; resolve temp — on a human seat under AI 托管 never by destroying an item while a bench operator can be sold for the slot —; ready. A bot's whole prep — shop decisions and layout planning (`botPrepBeginSteps`, 25–70 ms late in a 4-bot match), the layout rehearsal and `botPrepEndSteps` — runs in wall-clock slices of `botSliceMs` (8 ms) per scheduler callback on a real host (step generators that yield between whole actions, never with a transient board; the same actions in the same order, so decisions are unchanged; at once in virtual time). Under AI 托管 a human's own 自选 pieces score `DIY_PIECE_BONUS` (12) more in the shop / reward choice and the 调度中心 is levelled one round earlier for the step that opens a slotted tier (`levelUp`, `diyOpensAt`; 0.2.0 — bots field no 自选 piece).
---
@@ -95,11 +118,13 @@ Official rule (bwiki 盟约, 更新公告 5114): before a match the player canno
**Loadout wire & state:** the client keeps a per-browser loadout `{ [baseChessId]: { skill: index, module: uniEquipId | 'none' } }` (localStorage) and sends it with C2S `room.loadout { entries }` (validated: known visible chess, legal skill index for that chess's normal AND elite status, legal module id). The lobby stores it on the seat; at match start `Match` receives `seats[].loadout`; `PlayerState` keeps it immutable for the match; board pieces are resolved to `{chessId, skillIndex, moduleId}` when building `PlayerBattleInput.units[]` (fields `skillIndex`, `moduleId`; BattleSpec carries them). Bots use defaults. m.private exposes the effective loadout so the UI (shop cards, detail panel) shows the chosen skill/module.
**潜能 / 练度 (0.2.2, the owner's decision of 2026-10-08: 「调配干员里自己设置吧,默认满潜满加成」):** official — an operator fights at the player's own potential (调度手册 「卫戍协议中干员潜能由自身已持有干员潜能决定」; an unowned 特许 operator at potential 1) and an owned operator of the same name gets the 自持有 练度 bonus (activity_table `cultivateEffectList`: 未精英化 +0 %, 精英1 ATK / DEF +5 %, 精英2 ATK +10 % DEF / HP +5 %, 精英2 Lv.60 +10 % each — a multiplier of its own, never summed with the 直接乘算 bonuses). The 干员调配 screen sets both per operator (by charId: initial, elite and 自选 forms share them; default 潜能 6 and 精英2 Lv.60); they travel as `room.loadout.ops` (`{ [charId]: { potential, cultivate } }`, defaults dropped; shared/protocol.js `checkLoadoutOps`, strict like the entries), `seats[].ops` → `PlayerState.ops` (re-checked; bots none = the defaults), and every operator the player owns states them in its `PlayerBattleInput` entry (`potential`, `cultivate`; shared/potential.js `cultivationOf`) — never a 补位 stand-in (a not-owned NORMAL chess) or a prototype 自选 pick. The sim composes the def at the potential (data/*.json carry every potential, DATA.md §2.3; the summons' talents at the owner's) and multiplies ATK / DEF / max HP by the tier's `char_attribute_mul` (effects.json `aceffect_char_1…4`, SIM.md §12). The cards (局内数值, shop and detail cards, a teammate's unit from UnitInfo `potential` / `cultivate`) show the numbers at the settings.
**Sim:** `simdata` resolves a unit def for `(chessId, skillIndex, moduleId)` (cached): `skill` = the selected SkillRecord; stats = `statsBase` + module attr (elite) ; trait/talents = base with the selected module's overrides. Kit contract (backward compatible): a kit function `(bb, chess) => { skill?, skills?: { [skillId]: SkillSpec }, talents, trait }` receives `chess.skill` = the **selected** skill and `bb` = its blackboard; the loader uses `skills[selectedSkillId]` if present, else `skill` only when the selected skill is the chess's default, else the **generic** skill spec — talents/trait from the kit always apply. Every visible chess must get hand-authored specs for all of its selectable skills (tracked by a coverage test).
**UI:** a 干员调配 screen reachable from the lobby, room and briefing (INFO_CHECK): filter by tier/bond/class, per chess choose skill (icon, name, SP info, desc at Lv4 and Lv7) and elite module (official type icon from the local-client art when extracted (§13), else a lettered tile; name/type, stat bonus, trait/talent changes), 恢复默认, persisted; a 局内数值 section between the skills and the modules (普通 / 精锐 toggle, 精锐 first) draws the detail card's own stats block, 特性 and 天赋 for the chosen skill / module (§22.16); the chosen skill/module shown on shop cards and in the detail panel (a teammate's unit — 前往查看 in prep, 联防 / 最终攻势 fields, an observed battle — shows **its owner's** skill/module from its UnitInfo `skillIndex` / `moduleId`, `gameLogic.js unitLoadout`) — which also shows the stats, 特性, talents and attack range the unit fights with (`shared/loadoutRecord.js` `loadoutRecord` / `attackRangeGrid`, the same composition the sim's `getChess(id, loadout)` uses; the deploy wheel's range preview too) — the range it is deployed with: a selected passive range skill (引星棘刺 S3 3-9) and the 特性's permanent 攻击距离 (信仰搅拌机 SPT-Y +1) included, over all 1284 loadouts equal to the battle's range at deployment; in battle and prep the card draws the live `range` (§21.16). In battle the renderer plays the equipped skill's own Spine clip and ON_SKILL_START sound (`UnitInfo.skillIndex`; `data/assets.json` lists icons, clips and sounds for every skill index).
**UI:** a 干员调配 screen reachable from the lobby, room and briefing (INFO_CHECK): the roster is one list, a row per operator with aligned columns under a head row — portrait / name / bonds (opens the detail), the skills and the elite's modules to tap (不装备 first; the quick choices of PR #301 by @farridge, laid out as one list instead of its three columns of cards, the owner's call of 2026-10-08), the 潜能 / 练度 selects (`screens/loadout.js RosterRow`, `screens/cultivation.js`); the detail beside it (below 1000 px wide it slides over the list, a quick choice keeps the list) opens with the 潜能与练度 section (潜能 1–6, the four official 练度 tiers with their effect texts); the detail then shows the operator's 特质 — the in-match `GarrisonBlock` / RichText of the form its 普通 / 精锐 skill toggle shows, triggers such as 「<进入休整期时>」 kept (0.2.2, PR #301 by @farridge); filter by tier/bond/class, per chess choose skill (icon, name, SP info, desc at Lv4 and Lv7) and elite module (official type icon from the local-client art when extracted (§13), else a lettered tile; name/type, stat bonus, trait/talent changes), 恢复默认, persisted; a 局内数值 section between the skills and the modules (普通 / 精锐 toggle, 精锐 first) draws the detail card's own stats block, 特性 and 天赋 for the chosen skill / module (§22.16); the chosen skill/module shown on shop cards and in the detail panel (a teammate's unit — 前往查看 in prep, 联防 / 最终攻势 fields, an observed battle — shows **its owner's** skill/module from its UnitInfo `skillIndex` / `moduleId`, `gameLogic.js unitLoadout`) — which also shows the stats, 特性, talents and attack range the unit fights with (`shared/loadoutRecord.js` `loadoutRecord` / `attackRangeGrid`, the same composition the sim's `getChess(id, loadout)` uses; the deploy wheel's range preview too) — the range it is deployed with: a selected passive range skill (引星棘刺 S3 3-9) and the 特性's permanent 攻击距离 (信仰搅拌机 SPT-Y +1) included, over all 1284 loadouts equal to the battle's range at deployment; in battle and prep the card draws the live `range` (§21.16). In battle the renderer plays the equipped skill's own Spine clip and ON_SKILL_START sound (`UnitInfo.skillIndex`; `data/assets.json` lists icons, clips and sounds for every skill index).
**导出 / 导入 a loadout:** 导出 hands the current loadout out as a versioned envelope `{ kind: 'stronghold.loadout', v, exportedAt, count, entries }` — copied through `ui/clipboard.js copyText` or downloaded as a file. 导入 accepts that envelope, the stored `{ v, entries }` form and a bare `{ [chessId]: { skill, module } }` map (serialised text too), and refuses another tool's `kind`, junk, an empty result and a **newer** `v` (`parseImport` — a newer payload is never mis-read). Both go through `loadoutSync.applyLoadoutEntries` → `sanitizeEntries`, so a preset from another build never sends the server an entry it would refuse, an import that keeps nothing changes nothing, and the result is reported as N imported / M not (`ui/loadoutModel.js`, `screens/loadout.js`).
**导出 / 导入 a loadout:** 导出 hands the current loadout out as a versioned envelope `{ kind: 'stronghold.loadout', v, exportedAt, count, entries, ops }` (`ops`: the 潜能 / 练度 settings, 0.2.2 — an import without them leaves the current settings alone) — copied through `ui/clipboard.js copyText` or downloaded as a file. 导入 accepts that envelope, the stored `{ v, entries }` form and a bare `{ [chessId]: { skill, module } }` map (serialised text too), and refuses another tool's `kind`, junk, an empty result and a **newer** `v` (`parseImport` — a newer payload is never mis-read). Both go through `loadoutSync.applyLoadoutEntries` → `sanitizeEntries`, so a preset from another build never sends the server an entry it would refuse, an import that keeps nothing changes nothing, and the result is reported as N imported / M not (`ui/loadoutModel.js`, `screens/loadout.js`).
**干员持有 (0.2.0 补位):** the overlay's second tab marks operators as not owned (the 55 NORMAL chess; per-browser `sp.pref.ownership` = `{ v, notOwned }`, synced as `room.ownership { notOwned }` by the same sync engine as the loadout, `ui/loadoutSync.js installPrefSync`; 导出 / 导入 as `{ kind: 'stronghold.ownership', v, exportedAt, count, notOwned }`). A not-owned chess fights as its official stand-in with the backup skill / module — the loadout never applies to it ("对于补位干员其技能不可更改") — and is shown as the stand-in wherever the player's copy appears (shop / reward cards, hand and 临时整备区, board, detail card, result lineup, a teammate's scouting and bond popups: portrait, name, model, with a small 「替补」 mark; the owner's recall of the official mode, 2026-10-06), while its bonds, 特质, tier, price and merge stay the chess's; the 0.2.0 补位 design subsections have the rules and sources.
**自选编队 (0.2.0):** the overlay's third tab fills the four DIY slots (5阶 ×2, 6阶 ×2) with an owned 6★ outside the chess pool (any of its three skills, any module of its elite form but a 集成战略 one) or a prototype (its locked skill / module); only operators with a kit are offered (`welcome.diyKitted`). Per-browser `sp.pref.diy` = `{ v, picks }`, synced as `room.diy { picks }` by the same sync engine; 导出 / 导入 as `{ kind: 'stronghold.diy', v, exportedAt, count, picks }` (`ui/diyModel.js`, `screens/diy.js`). In the match a slotted piece is the operator for its player (name, art, class, bonds from its factions, no 特质, the pick's skill and module; the slot's tier, price and merge), sold only in that player's shop from the slot's 调度中心 level with its own stock; the shop / reward / detail cards and the own pieces carry a 「自选」 badge (`ui/gameLogic/diy.js`); the 0.2.0 自选编队 design subsection has the rules and sources.
+38 -14
View File
@@ -7,26 +7,41 @@ Part of [DESIGN.md](../DESIGN.md) (the index; section numbers are global).
Transport: one WebSocket per tab at `/ws`, JSON text frames `{ t: '<type>', ...payload }`. Client requests may carry `rid` (request id); the server's direct reply echoes `rid` (`ok` or `error`). Server pushes have no `rid`. Max frame 64 KB inbound; rate limit 40 msgs/s per connection (excess dropped with `error RATE`).
### 8.1 Session & lobby
C→S: `hello {name, token?, version}` · `room.create {mode:'solo'|'coop', difficulty}` · `room.join {code}` · `room.leave` · `room.ready {ready}` · `room.setDifficulty {difficulty}` (host) · `room.addBot` / `room.removeBot {seat}` (host) · `room.kick {seat, playerId}` (host, before the match: another human leaves the room and gets `room.closed {reason:'kicked'}`; may join again; `playerId` = the player the host confirmed, a seat that changed hands meanwhile → `BAD_TARGET`) · `room.start` (host) · `room.loadout {entries}` (§16: any time in the lobby / room and during INFO_CHECK, then `WRONG_PHASE`; heavy rate-limit bucket; the match exposes it as `m.private.loadout`) · `room.ownership {notOwned}` (0.2.0 补位, the 干员持有 tab: the base chess ids marked as not owned — ≤ 160 ids, kept leniently: anything but a droppable NORMAL chess is dropped; stored on the session and the seat, a match takes the seat's list when it starts and never changes it — during a match `ROOM_STARTED` 'stored for the next match'; heavy bucket; the match exposes it as `m.private.standIns`) · `room.diy {picks}` (0.2.0 自选编队: `{ [slotBaseId]: { charId, skillIndex?, uniEquipId? } | null }` for the four DIY slots — ≤ 8 entries, kept leniently: shared/protocol.js `checkDiyPicks` drops an illegal pick (an operator without a kit, another tier's prototype, a prototype off its locked skill, a module of another game mode (集成战略 / 生息演算), an owned operator's second slot, the same operator twice in a tier, an unknown slot), only malformed picks are `BAD_MSG`; stored on the session and the seat like the not-owned list, a match takes the seat's picks when it starts — during a match `ROOM_STARTED` 'stored for the next match'; heavy bucket; the match exposes them as `m.private.diy`) · `ping {c}`
S→C: `welcome {playerId, token, name, serverNow}` · `room.state {code, hostId, mode, difficulty, seats:[{seat, playerId, name, isBot, ready, connected}|null ×4]}` · `room.closed {reason}` · `pong {c, s}` · `error {rid?, code, msg}` · `ok {rid}`
Confirm-page reroll (GitHub #77 / PR #408): `room.rerollSetup {setupRevision}` and
`room.cancelReroll {voteId}` are host-only, while `g.rerollVote {voteId, agree}` requires a participating human.
`g.infoReady {setupRevision?}` confirms the setup the player saw; an omitted revision means 0, so it is accepted only
before the first successful reroll. `m.public.setupRevision` and
`m.public.rerollVote = null | {id, proposerId, voters:[playerId], agreed:[playerId]}` synchronize every client and resync.
These requests only apply in INFO_CHECK. A mismatched setup revision / vote ID is `BAD_TARGET`; spectators cannot act.
C→S: `hello {name, token?, version}` · `room.create {mode:'solo'|'coop', difficulty}` · `room.join {code}` · `room.leave` · `room.ready {ready}` · `room.setDifficulty {difficulty}` (host) · `room.setAiPicksLast {on}` (host, co-op, before the match — `ROOM_STARTED` during it, `BAD_TARGET` in a solo room; un-readies the other humans like a difficulty change; the match takes it as `opts.aiPicksLast`: humans draft before AI seats, off by default, no extra random draw) · `room.addBot` / `room.removeBot {seat}` (host) · `room.kick {seat, playerId}` (host, before the match: another human leaves the room and gets `room.closed {reason:'kicked'}`; may join again; `playerId` = the player the host confirmed, a seat that changed hands meanwhile → `BAD_TARGET`) · `room.start` (host) · `room.loadout {entries, ops?}` (§16: any time in the lobby / room and during INFO_CHECK, then `WRONG_PHASE`; heavy rate-limit bucket; the match exposes it as `m.private.loadout`; `ops` — 0.2.2 — the per-operator 潜能 / 练度 `{ [charId]: { potential?: 1–6, cultivate?: 0–3 } }` of the 干员调配 roster and the 自选 owned pool, ≤ 256, checked strictly with the entries, `m.private.ops`; absent = none set: every operator 潜能 6, 精英2 Lv.60) · `room.ownership {notOwned}` (0.2.0 补位, the 干员持有 tab: the base chess ids marked as not owned — ≤ 160 ids, kept leniently: anything but a droppable NORMAL chess is dropped; stored on the session and the seat, a match takes the seat's list when it starts and never changes it — during a match `ROOM_STARTED` 'stored for the next match'; heavy bucket; the match exposes it as `m.private.standIns`) · `room.diy {picks}` (0.2.0 自选编队: `{ [slotBaseId]: { charId, skillIndex?, uniEquipId? } | null }` for the four DIY slots — ≤ 8 entries, kept leniently: shared/protocol.js `checkDiyPicks` drops an illegal pick (an operator without a kit, another tier's prototype, a prototype off its locked skill, a module of another game mode (集成战略 / 生息演算), an owned operator's second slot, the same operator twice in a tier, an unknown slot), only malformed picks are `BAD_MSG`; stored on the session and the seat like the not-owned list, a match takes the seat's picks when it starts — during a match `ROOM_STARTED` 'stored for the next match'; heavy bucket; the match exposes them as `m.private.diy`) · `ping {c}`
S→C: `welcome {playerId, token, name, serverNow}` · `room.state {code, hostId, mode, difficulty, aiPicksLast, seats:[{seat, playerId, name, isBot, ready, connected}|null ×4]}` · `room.closed {reason}` · `pong {c, s}` · `error {rid?, code, msg}` · `ok {rid}`
Platform additions (implemented): `welcome.{resumed, version}`, `welcome.diyKitted` (0.2.0: the operators a 自选 slot may field — server/sim/content/kits/index.js `KITTED_CHARS`, the picker's list), `room.state.inMatch`, `error.detail`; `room.closed.reason ∈ 'timeout'|'kicked'|'empty'|'shutdown'` (`kicked`: room.kick / room.removeSpectator; `empty`: a spectator whose room lost its last player); WebSocket close codes 4001 (session taken over by another socket — client must not auto-reconnect), 4002 (no hello in time), 1008 (flooding), 1001 (shutdown). The host's `room.start` counts as the host being ready. After any (re)`hello` the server pushes `room.state` and, if a match runs, the match resends `m.public`/`m.private` (+`m.field`, or the field's `b.start` under client-side combat). **Reconnect window:** a dropped session stays resumable for 10 min (`net.js NET_DEFAULTS.reconnectWindowMs`; co-op seats keep playing their last lineup meanwhile, §6.6); a session that drops while its **solo** room's match runs stays resumable for the official `config.constants.singleReconnectTime` (86 400 s = 24 h; lobby option `soloReconnectWindowMs`; research 01 §1 "24小时内随时返回") — the untimed solo run simply waits. Only when the window expires does the drop become `match.onLeave` (co-op: 中途退出 = elimination; solo: the run ends `'abandoned'`). The client keeps its token in sessionStorage plus a recent-token list in localStorage, so reopening the tab resumes. Everything lives in server memory: a server restart ends every room and run. The lobby⇄match interface (constructor opts, `start/handle/onDisconnect/onReconnect/onLeave/dispose`) is documented verbatim at the top of `server/match/Match.js` and is normative. `server/data.js` provides `getData()` + index getters (`getChess`, `getBond`, `getGarrison`, `getItem`, `getBand`, `getEffect`, `getEnemy`, `getWave`, `getStage`, `getBoss`, `getToken`, `getConfig`, `getMode`). Note: the skill trigger rule lives at `chess.skill.trigger.rule` in data.
**Spectator seats** (a remake feature — the official room has none; community report #26, owner's decision 2026-10-04; the spectator-seat subsection of §23): `room.spectate {code}` takes one of a co-op room's `MAX_SPECTATORS` (2) spectator seats, in its lobby or during its match; `room.removeSpectator {playerId}` (host) frees one (`room.closed {reason:'kicked'}` to it); `room.state.spectators: [{playerId, name, connected}]`. A spectator is never a player (not in `seats`, not counted for the start, never host, keeps no room alive — `room.closed {empty}` when the last human leaves), receives the room's broadcasts and watches like an eliminated player (`Match.addSpectator`: `b.start` watch / `m.field`, never `m.private`); it may only `g.watch`, leave, and store a `room.loadout` — anything else answers `error SPECTATOR`. Its seat is kept and given back on reconnect like a player's.
**Spectator seats** (a remake feature — the official room has none; community report #26, owner's decision 2026-10-04; the spectator-seat subsection of §23): `room.spectate {code}` takes one of a co-op room's `MAX_SPECTATORS` (2) spectator seats, in its lobby or during its match; `room.removeSpectator {playerId}` (host, at any time — the room screen's strip and, once the match runs, the game screen's 观战席 capsule in the top bar, GitHub #120) frees one (`room.closed {reason:'kicked'}` to it, no match frame after it); `room.state.spectators: [{playerId, name, connected}]` (still pushed while the match runs). A spectator is never a player (not in `seats`, not counted for the start, never host, keeps no room alive — `room.closed {empty}` when the last human leaves), receives the room's broadcasts and watches like an eliminated player (`Match.addSpectator`: `b.start` watch / `m.field`, never `m.private`); it may only `g.watch`, leave, and store a `room.loadout` — anything else answers `error SPECTATOR`. Its seat is kept and given back on reconnect like a player's.
### 8.2 Match
C→S (all carry `rid`): `g.infoReady` · `g.band {bandId}` · `g.bandSkip` · `g.bandFocus {bandId?}` (the strategy highlighted in the draft screen — what a timed-out turn takes; absent / null clears it; `WRONG_PHASE` outside BAND_DRAFT, `ALREADY` after the pick, `BAD_TARGET` for a band not allowed; §18.2) · `g.buy {slot}` · `g.refresh` · `g.freeze` · `g.levelUp` · `g.sell {uid}` · `g.move {uid, to:{area:'board', row, col}|{area:'hand', idx}, dir?}` · `g.equip {itemUid, targetUid, replaceUid?}` (`replaceUid`: which equipped item a third one replaces, §6.2) · `g.art {itemUid, row, col, dir?}` (`dir` ∈ UP|RIGHT|DOWN|LEFT from the deploy wheel, §3; absent ⇒ `to.dir`, then RIGHT; `g.move` onto the piece's own tile re-orients it; board pieces in `m.private` / `m.field {prep:true}` carry `dir`) · `g.destroy {uid}` (hand / temp items only; equipped items are locked, §6.2) · `g.reward {idx}` · `g.choice {idx}` · `g.ready {ready}` · `g.emote {id}` · `g.watch {fieldId, playerId?}` (`playerId`: the player tapped — a shared 联防 / boss pair field shows two; what an eliminated viewer follows, §25) · `g.autoplay {on}` · `g.pause {on}` (solo battles only, §14 Solo pause) · `g.unitStats {seq?}` (ROUND_START / SP_DRAFT / PREP, else `WRONG_PHASE`; eliminated: `ELIMINATED`) → push `m.unitStats` (§18.5) · `g.leave`
C→S (all carry `rid`): `g.infoReady` · `g.band {bandId}` · `g.bandSkip` · `g.bandFocus {bandId?}` (the strategy highlighted in the draft screen — what a timed-out turn takes; absent / null clears it; `WRONG_PHASE` outside BAND_DRAFT, `ALREADY` after the pick, `BAD_TARGET` for a band not allowed; §18.2) · `g.buy {slot}` · `g.refresh` · `g.freeze` · `g.levelUp` · `g.sell {uid}` · `g.move {uid, to:{area:'board', row, col}|{area:'hand', idx}, dir?}` · `g.equip {itemUid, targetUid, replaceUid?}` (`replaceUid`: which equipped item a third one replaces, §6.2) · `g.art {itemUid, row, col, dir?}` (`dir` ∈ UP|RIGHT|DOWN|LEFT from the deploy wheel, §3; absent ⇒ `to.dir`, then RIGHT; `g.move` onto the piece's own tile re-orients it; board pieces in `m.private` / `m.field {prep:true}` carry `dir`) · `g.destroy {uid}` (hand / temp items only; equipped items are locked, §6.2) · `g.reward {idx}` · `g.choice {idx, choiceId?}` (with an ID: the recipient's PREP personal choice; without one: the public SP_DRAFT; stale or wrong IDs never fall through to the public draft) · `g.ready {ready}` · `g.emote {id}` · `g.watch {fieldId, playerId?}` (`playerId`: the player tapped — a shared 联防 / boss pair field shows two; what an eliminated viewer follows, §25) · `g.autoplay {on}` · `g.pause {on}` (solo battles only, §14 Solo pause) · `g.unitStats {seq?}` (ROUND_START / SP_DRAFT / PREP, else `WRONG_PHASE`; eliminated: `ELIMINATED`) → push `m.unitStats` (§18.5) · `g.leave`
S→C:
- `m.public` — full public state on every change (throttled ≤ 10/s): `{ phase, round, lastRound, deadline, serverNow, modeId, difficulty, stageId, factions, disabledBonds, bannedChess, bossId, hiddenBossId?, teamLp?, bossHp?:{hp,max}, overtimeAt? /* FA / HC: ms epoch the overtime drain starts, §4 */, paused /* solo pause, §14 */, draft?:{order,turn,picks,skipsLeft,turnDeadline,turnSeconds /* one turn's length, 0 untimed; deadline = turnDeadline (§18.2) */,untimed}, sp?:{family,cards /* bounty cards carry descRaw?, §20.6 */,turn,picks}, players:[{playerId, seat, name, isBot, connected, alive, lp, bandId, shopLevel, boardCount, ready, bonds:[{bondId,count,active,tier,layers,harmony? /* 调和's +1 is in count, §21.26 */}], fieldId, status, pendingLp? /* COMBAT / 联防 of a normal round: the LP the player's own battle will cost at settlement so far, min(lpCapPerRound, counted leaks) — in 联防 min(lpCapPerRound, uniteLeft) — omitted when 0 and in every other phase (§17.5 live LP) */, uniteLeft? /* 联防: the leaker's enemies still standing — not spawned yet + alive + counted leaks, uncapped, live (§20.6) */}] , fields:[{fieldId, kind, players:[playerId...]}] }` · `m.public.uniteResult` — SETTLE after a 联防 only, its outcome as data: `{ through /* the leakers' enemies that still got through, uncapped */, helpers:[playerId...], leakers:[playerId...], losses:{playerId: the LP settlement charged this round — every alive player} }`; absent when no 联防 resolved (the client's result box then reads the round's own battle; GitHub #235, PR #112)
- `m.public` — full public state on every change (throttled ≤ 10/s): `{ phase, round, lastRound, deadline, serverNow, modeId, difficulty, stageId, factions, disabledBonds, bannedChess, bossId, hiddenBossId?, teamLp?, bossHp?:{hp,max}, overtimeAt? /* FA / HC: ms epoch the overtime drain starts, §4 */, paused /* solo pause, §14 */, draft?:{order,turn,picks,skipsLeft,turnDeadline,turnSeconds /* one turn's length, 0 untimed; deadline = turnDeadline (§18.2) */,untimed}, sp?:{family,cards /* bounty cards carry descRaw?, §20.6 */,turn,picks}, players:[{playerId, seat, name, isBot, connected, alive, lp, bandId, shopLevel, boardCount, ready, bonds:[{bondId,count,active,tier,layers,harmony? /* 调和's +1 is in count, §21.26 */}], fieldId, status, pendingLp? /* COMBAT / 联防 of a normal round: the LP the player's own battle will cost at settlement so far, min(lpCapPerRound, counted leaks) — in 联防 min(lpCapPerRound, uniteLeft) — omitted when 0 and in every other phase (§17.5 live LP) */, uniteLeft? /* 联防: the leaker's enemies still standing — not spawned yet + alive + counted leaks, uncapped, live (§20.6) */}] , fields:[{fieldId, kind, players:[playerId...], progress?:{killed, resolved, total, done} /* §14 顶栏胶囊: resolved = the field's own scheduled enemies knocked down or leaked, null while unknown */}] }` · `m.public.uniteResult` — SETTLE after a 联防 only, its outcome as data: `{ through /* the leakers' enemies that still got through, uncapped */, helpers:[playerId...], leakers:[playerId...], losses:{playerId: the LP settlement charged this round — every alive player} }`; absent when no 联防 resolved (the client's result box then reads the round's own battle; GitHub #235, PR #112)
- `m.private` — the recipient's full PlayerState view (§6.2 minus server-only data) on every change; in a boss round's prep also `bossMate { playerId, side, units }` — the pair partner's board on its half of the boss field (UnitInfo, mirrored on the right half), re-sent when the partner moves (0.2.0, community report item 51).
- `m.field` — field meta when watching starts: `{ fieldId, kind, rect, stageId, units: [UnitInfo], effects? }` (the units on the field, knocked-out operators waiting to redeploy included — `Battle.fieldMeta`, §18.3). The prep-scouting field (`Match.prepFieldMeta`) puts the scouted player's **hand (整备区) and 临时整备区 into `units` as well**: held pieces as units on the hand row (row 7, col = hand slot) and temp pieces on the temp row (row 8, cols 4..8 = the temp slots; PRTS 帮助 counts the temp area with the hand; a held item is a `kind: 'item'` unit the client draws as its floating plate) — the scout renders exactly like the own prep bench, for every watcher alike, the spectator seat's copy equal to a teammate's (§23.19's "never a private view" stays the no-`m.private` rule); the scout signature `_prepScoutSig` carries the hand / temp slots, so a piece moved to another slot is a change — and carries `effects`: the scouted player's effects view (策略 / 机变 / 悬赏 …), the watched player's effects column ready-made
- NOTE: game time in `b.snap`/`b.ev` is carried as **`gt`** (the frame's `t` is the message type).
- Active finite zero-SP timed skills display `sp=timeLeft`, `spMax=duration` and `UF.SKILL`; after end they display
`0/0`. Snapshot display capacity does not change runtime SP/cost.
- `b.snap` — `{ fieldId, gt, units: [[id, x, y, hp, maxHp, sp, spMax, flags, anim]] , dp, killed, total, down?, elem? }` 20 Hz (a countdown summon's `hp` is maxHp × the share of its life left, §25.16.5). Optional, only when non-empty (§18.3): `down: [[id, respawnAt (game s), respawnTime (s), state, row, col]]` — knocked-out operators waiting to redeploy, `state` 0 counting | 1 timer done, waiting for DP | 2 timer done, its tile taken — a safeguard, no ally deploys there (sim/constants.js `DOWN_STATE`); `row, col` = the tile it lies on and comes back on (§21.24); `elem: [[id, element, fill, cooldownEnd (game s), cooldown (s)]]` — the one gauge a unit shows: the fullest (ties by the official element id 神经 < 侵蚀 < 灼燃 < 凋亡), fill with 2 decimals rounded DOWN and kept at 0.01–0.99 outside a burst (§20.8); during a 爆发冷却 the bursting element with fill 1 and the cooldown numbers (else 0, 0)
- `b.snap` — `{ fieldId, gt, units: [[id, x, y, hp, maxHp, sp, spMax, flags, anim]] , dp, killed, total, resolved, down?, elem?, ammo?, wolves?, neg?, stand?, standCut? }` 20 Hz (a countdown summon's `hp` is maxHp × the share of its life left, §25.16.5). Optional, only when non-empty (§18.3): `down: [[id, respawnAt (game s), respawnTime (s), state, row, col]]` — knocked-out operators waiting to redeploy, `state` 0 counting | 1 timer done, waiting for DP | 2 timer done, its tile taken — a safeguard, no ally deploys there (sim/constants.js `DOWN_STATE`); `row, col` = the tile it lies on and comes back on (§21.24); `elem: [[id, element, fill, cooldownEnd (game s), cooldown (s)]]` — the one gauge a unit shows: the fullest (ties by the official element id 神经 < 侵蚀 < 灼燃 < 凋亡), fill with 2 decimals rounded DOWN and kept at 0.01–0.99 outside a burst (§20.8); during a 爆发冷却 the bursting element with fill 1 and the cooldown numbers (else 0, 0); the three HP-bar readouts (0.2.2, GitHub PR #303 / #378 / #383, display only — no sim state reads them, the nine-field tuples and the SP fraction an ammo skill still puts in them are unchanged; `render/interp.js` keeps them per snapshot and `sample()` hands them out from the older snapshot like `flags`, so they step with the skill flag; a malformed entry or an id the snapshot does not list is dropped, a snapshot without the list clears the readout): `ammo: [[id, rounds left, rounds in the magazine]]` — an ally's running ammo skill, whole rounds, the magazine being the activation's real total (extra bullets and refills included; 隐现's S2 holds 14, skill_table `attack@trigger_time`) — the client draws yellow cells in place of the SP bar (the count, not a pixel copy of the official magazine widget — its art is in no game data we have), and UnitInfo `ammoSkill: true` (an ally whose skill is an ammo magazine, known from its first appearance) tells it to draw no standing skill aura; `wolves: [[id, 狼影 left, the talent's maximum]]` — 伺夜's 狼群 (`mem.shadows` / `mem.wolfCapacity`, both the placed and the automatic pack), one diamond per slot under the HP bar (plain diamonds: no official counter art is known, the portrait of PR #378 was not taken), nothing in the 战术点形态; `neg: [[id, fill]]` — the share (0.01–1) of its cap a negative-HP pool holds (斩业星熊's 我执, `unit.negFill`), drawn as the red bar over her drained HP bar. These show only for a living, deployed, visible unit and only when it has one
Optional, only when non-empty (PR #381; display metadata, SIM §9): `stand: [[id, until]]` — the game time an enemy's
attack recovery ends (`atkStandUntil`; enemies alive, deployed, visible, neither feared nor stunned): when it falls
between two snapshots the renderer holds the position until then and interpolates the rest (HP / SP unchanged), and
extrapolates nothing while the newest snapshot still holds a stand; `standCut: [[id, at]]` — the latest time each
listed enemy's recovery was cut or ignored (失衡, fear, stun, hiding, leaving): an interval with a cut inside it stays
linear. A newest snapshot showing a death, a stun or a block is not extrapolated. Old snapshots without them
interpolate linearly; nothing is inferred from the attack animation.
- `b.ev` — `{ fieldId, ev: [ ... ] }` event tuples: `['spawn', UnitInfo]`, `['atk', srcId, tgtId, projKind]`, `['dmg', tgtId, amount, type]`, `['heal', tgtId, amount]`, `['skill', id, on:0|1]`, `['die', id, reason]` (reason `'killed'`, `'retreat'`, `'expired'` …; only `'killed'` plays an operator's knock-down sound), `['status', id, key, on]`, `['fx', kind, x, y, extra]` (`'hitCap' {id, n}` = a cancelled 限伤 hit, drawn as nothing, §20.12; `extra.form` = the unit's model form from then on (an enemy's mode, a 傀儡师's 替身 §22.11) — `shared/protocol.js fxForm`, state like a spawn: never dropped by the client, §21.4), `['layer', playerId, bondId, n]`, `['bounty', playerId, coins]`, `['deploy', id]`
- `m.toast {kind, text}` · `m.ticker {text, id, type, priority, playerId}` (the strip plays the highest priority first, §21.10) · `m.emote {playerId, id}` · `m.result {...}`
- `m.unitStats {seq, round, units: [unitStatsEntry]}` — the answer to `g.unitStats` (`seq` echoed): every own board operator / summon by uid with `{id, uid, defId, hp, alive, maxHp, atk, def, res, interval, blockCnt, moveSpeed, range?, base: {…same}}` (shared/protocol.js `unitStatsEntry`; `range` = an ally's live range grid, facing RIGHT, never a target-selection grid, §21.16) as its next battle starts (§18.5)
- `m.unitStats {seq, round, units: [unitStatsEntry]}` — the answer to `g.unitStats` (`seq` echoed): every own board operator / summon by uid with `{id, uid, defId, hp, alive, maxHp, atk, def, res, interval, blockCnt, moveSpeed, range?, base: {…same}}` and `dir?` (shared/protocol.js `unitStatsEntry`; `range` = an ally's live range grid, facing RIGHT, never a target-selection grid, §21.16; `dir` = the unit's facing now, UP|RIGHT|DOWN|LEFT — the snapshot tuples carry none and UnitInfo `dir` is the facing at send time — which the detail card's range overlay rotates by, GitHub PR #281) as its next battle starts (§18.5)
`UnitInfo = { id, kind, side, ownerId, defId, name, tier, golden, spine, avatar, x, y, facing, dir, maxHp, skillIndex?, moduleId?, items?, form? }` (`dir` = the ally's deploy direction; the renderer picks the model (Back for UP, mirrored for LEFT) and the ground wedge from it; `skillIndex` = the ally's equipped skill (§16): the renderer plays that skill's Spine clip (`anims.skills[i]`) and the audio its own ON_SKILL_START sound; `moduleId` = an elite ally's equipped module (uniEquipId | 'none'); `items` = an ally operator's equipped item ids (absent without any; never on enemies or summons): the bond popup lists a 变形同构体 wearer from them and the detail card shows its 同构 chip and 装备 (§21.11); all three also on the prep-scouting `m.field` units (`Match.prepFieldMeta`, the held pieces included), so a teammate's unit shows its owner's loadout in the detail card and its equipped items as item pips on the unit (prep surfaces; user playtest #2 item 3); `form` = the unit's current model form (an enemy's mode §21.4, a 傀儡师's 替身 §22.11; a view built mid-battle starts in it)).
@@ -34,9 +49,10 @@ S→C:
`m.private`:
```js
{ playerId, seat, alive, lp, funds, bandId, ready, canReady /* false while temp non-empty */,
{ playerId, seat, alive, lp, funds, bandId, ready, canReady /* false while a personal choice or temp overflow is pending */,
personalChoice: null | { id, round, sourceItemId, cards: [ { kind: 'bounty', id, name, desc, descRaw, ... } ] }, // recipient only, including reconnect; absent from public / scout views
shop: { level, maxLevel: 6, upgradePrice, refreshPrice /* effective (0 when a free refresh is available) */, freeRefreshes,
frozen, slots: [ { kind: 'chess'|'item', id, price, basePrice, sold } | null ],
frozen, slots: [ { kind: 'chess'|'item', id, price, basePrice, sold, frozen } | null ], // slot.frozen: the 冻结 toggle copies onto every unsold slot, and 梓兰's 猎头顾问 freezes ONE card of an active refresh with the toggle off — every card draws its own flag (GitHub #354)
rewardOffer: null | { tier, source: 'merge'|'special', label, queued, slots: [ { kind: 'chess'|'item', id, price: 0, sold } ] } }, // §21.2
hand: [ Piece | null ], // length 10, index = hand slot
temp: [ Piece | null ], // length 5
@@ -70,20 +86,28 @@ Piece = { uid, kind: 'chess'|'item'|'token', id, golden: bool, tier, items: [ {
**Authority per field.**
- Normal field: the owning human's client is **authoritative** (if connected).
- Unite field (联防) and boss/hidden pair fields: the lowest-seat **connected human** in that field is authoritative; every other human in (or watching) that field receives the same spec and simulates it locally **for display only**.
- Bots, departed/disconnected players, fields with no connected human: the **server** simulates. Normal and 联防 fields headlessly (`fields.js HeadlessJob`, with a `[gt, killed, total(, left)]` progress timeline — `left`: the 联防 leakers' enemies still standing, §20.6) in wall-clock slices of `headlessSliceMs` (8 ms) per callback on a real host — never one blocking run per field (3 bot fields ≈ 0.2–0.5 s of CPU at COMBAT start here, seconds on a mini PC); at once in virtual time; the result is released at the battle's natural end on the field clock (so round pacing and the teammates' progress look live). Boss fields in real time without snapshots (`HeadlessPacer`); when no field of a boss round has a connected human the whole round runs server-side as before (FieldRunner with streaming off).
- Bots, departed/disconnected players, fields with no connected human: the **server** simulates. Normal and 联防 fields headlessly (`fields.js HeadlessJob`, with a `[gt, killed, total, resolved(, left)]` progress timeline — `resolved`: the capsule's numerator on the field clock (the maintainer's review of PR #157: a bot field has no authority reporting `b.progress`), `left`: the 联防 leakers' enemies still standing, §20.6) in wall-clock slices of `headlessSliceMs` (8 ms) per callback on a real host — never one blocking run per field (3 bot fields ≈ 0.2–0.5 s of CPU at COMBAT start here, seconds on a mini PC); at once in virtual time; the result is released at the battle's natural end on the field clock (so round pacing and the teammates' progress look live). Boss fields in real time without snapshots (`HeadlessPacer`); when no field of a boss round has a connected human the whole round runs server-side as before (FieldRunner with streaming off).
- Takeover: an authority that disconnects or leaves, or misses its deadline (`timeLimit / speed` real s + 15 s grace, normal/联防) loses the field: normal/联防 → the server re-simulates the spec from t = 0 (deterministic: the same result); boss → the partner's replica is promoted (`b.start authoritative: true`), else the server runs it, fast-forwarded to the field clock through a `CreditPool` that only credits damage beyond what the client already reported (the fast-forward is spread over the pacing intervals, ≤ 240 extra ticks each). A boss authority silent for 12 s is handed over the same way. The former authority gets `b.end { reason: 'takeover' }` and keeps displaying.
**Protocol (additions; `shared/protocol.js` is normative).**
- `battleId` = `<prefix>.<round>.<seq>.<fieldId>` (`Match._ccField`; `<prefix>` = the match seed in base 36 plus `-<matchNo>`, the room's match number the lobby passes as `opts.matchNo`; `<seq>` counts every battle of the match; the informational `.<fieldId>` is dropped when the id would exceed the protocol's 64 chars): unique for the whole match although field ids repeat (`n:<pid>` every round, `u`, `b1`/`b2` in both boss rounds), and across the matches of one room. The client runner keys its local battles by it (the same id again = the same battle, no rebuild) and the server matches reports by it, so a late `b.progress` / `b.result` of a finished battle — or one that crossed into the room's next match on the same socket — is ignored instead of being credited to another battle.
- S→C `b.start { battleId, fieldId, kind, spec, authoritative, startAt, serverNow, elapsed, speed, watch, done }` — sent at COMBAT/UNITE/FINAL_ASSAULT/HIDDEN start to every human in the field (联防: to every human; COMBAT and boss rounds: eliminated humans and spectator seats get the field of the player they follow as a display replica — the one they last watched with `g.watch`, else the first player still in; research 09 "keep-watching auto-observes the first available field", 0.2.0 item 56), on `g.watch`, and on reconnect/resync. `elapsed` = game seconds on the field clock (a finished field: its end time); the client fast-forwards to it. `speed` = game seconds per real second (2; tests/tools may speed up).
- C→S `b.progress { battleId, gt, killed, total, leaks?, left?, bossDmg?, by?, done? }` — authoritative clients, ~1 Hz (boss fields 4 Hz). Normal/联防: `leaks` = counted leaks so far; 联防: `left` = per leaker its enemies still standing (`spec.js uniteLeft`, clamped by the server to `fields.js uniteBillBounds`; §20.6). Boss: `bossDmg` = cumulative damage of this field to the shared pool, `by` = the same per player, `leaks` = the field's cumulative LP cost (leaks × `lpr` + leader "扣除目标生命" effects; `spec.js attachLpMeter`). The server applies deltas (never twice). Stale reports (unknown battle, not the authority, after the match ended) are ignored, never answered with an error; the platform never answers a rid-less `b.progress` with an error frame (it would surface as a toast).
- C→S `b.progress { battleId, gt, killed, total, resolved?, leaks?, left?, bossDmg?, by?, leaksBy?, done? }` — authoritative clients, ~1 Hz (boss fields 4 Hz). `resolved` = the HUD capsule's numerator of this field (below; omitted by a client that cannot count it — the teammate UI then falls back to `killed`); `killed` / `total` are the field's own counters: `killed` counts every `counted` knock-out (a runtime split / summon included) and may exceed `total`, which counts only the enemies the round scheduled. Normal/联防: `leaks` = counted leaks so far; 联防: `left` = per leaker its enemies still standing (`spec.js uniteLeft`, clamped by the server to `fields.js uniteBillBounds`; §20.6). Boss: `bossDmg` = cumulative damage of this field to the shared pool, `by` = the same per player, `leaks` = the field's cumulative LP cost (leaks × `lpr` + leader "扣除目标生命" effects; `spec.js attachLpMeter`), `leaksBy` = its split per player (the LP the enemies that reached that player's goal cost: every leak entry the result will show × its `lpr`; `leaks` minus its sum is the leader's effects; `spec.js battleProgress`) — the server keeps the highest value per player and, before a perfect-payout bounty pays in a client-reported field, requires the result's leaked list of that player to show at least that much and the LP asked for to have been split at all (`Match._bossLeaksAgree`, the boss path does not re-simulate). The server applies deltas (never twice). Stale reports (unknown battle, not the authority, after the match ended) are ignored, never answered with an error; the platform never answers a rid-less `b.progress` with an error frame (it would surface as a toast).
- C→S `b.result { battleId, result }` — authoritative client at battle end; `result` = `compactResult(BattleResult)` (the fields the match consumes; ≤ 4 players, ≤ 400 leaks, ≤ 64 unitsEnd, ≤ 160 unitStats, ≤ 40 layer gains; `fitResult` keeps the frame ≤ 60 KB: unit statistics, then leak mods, then — boss only — leak entries go first). Structural checks in `isBattleResult`; `server/net.js` routes `b.*` frames to the match (lobby `routeGame`).
- S→C `b.pool { hp, max, teamLp, acked: { [fieldId]: cumulative damage counted } }` (boss rounds, ≤ 4 Hz; exact numbers, never rounded) — clients reconcile their `LocalBossPool`: displayed pool = server hp − (local cumulative damage − acked). Pools never hold less than 1 HP (`BOSS_POOL_MIN_HP`: `SharedBossPool` and `LocalBossPool`; a browser pool reads any finite rest below 1 as 0 — §20.5); the 'cleared'-result dust rule (a boss field whose client result is `'cleared'` while < 1 HP is left on the server empties the pool) only catches noise at that boundary.
- S→C `b.end { battleId, fieldId, reason }` — `'cleared'` (pool 0) / `'forced'` (team LP 0): the client calls `battle.forceEnd` and reports; `'takeover'`: stop reporting, keep displaying. Both also reach a battle the client is still preparing (the sim loading, the silent catch-up): Client runner below.
- `m.public.fields[]` gains `progress: { killed, total, done }`; `m.public.combatMode`; `m.public.paused: boolean` (always present; solo pause below).
- `m.public.fields[]` gains `progress: { killed, resolved, total, done }`; `m.public.combatMode`; `m.public.paused: boolean` (always present; solo pause below).
- `b.snap` / `b.ev` are **not sent for combat** in client mode. `g.watch` rules: see Spectating.
**Result validation (server, `fields.js validateClientResult`).** A client result is accepted only if structurally valid and plausible for its spec: every spec player reported and nobody else; finite numbers; `killed ≤ total ≤ 4·spawns + 100`; every leaked `enemyKey` is in the spec's spawns (multiset bound) or a key the spawned enemies' data mentions (content summons/splits, bounded by the total) — boss fields excepted (their leaks cost team LP through `b.progress`); `counted: false` only for enemies that never count (data `notCountInTotal`, schedule `countInTotal: false`, boss/part entries, content spawns); 联防: leaks + `unspawned` per (enemy, leaker) ≤ what that leaker sent in (nobody's survivors are billed to a teammate); per-bond `layerGains ≤ min(60 + 4·round, 999 − the bond's starting layers)` (§20.12) — the flat part raised by what the player's layer 特质 can add to that bond (their per-battle caps; an uncapped one leaves only the 999 room, §21.26) —, none when the spec disables gains, bonds must exist and be named by the player's bond snapshot, band, effects, units or items; `coins ≤ Σ bounty coins of the spawns`; `perfect` consistent with the counted leaks; unit states only for the player's own board uids with hpPct ∈ [0, 1]. The accepted result is **rebuilt** from whitelisted fields (leak mods/tags/sources from the spec — a content-spawned enemy (split, summon) keeps the mods of the schedule entry they equal, i.e. its parent's round multipliers and bounty id, so its 联防 re-entry and bounty match the server run; unit names from server data). Otherwise (or on any exception) the server re-simulates (normal/联防); a boss field is **handed over** instead (below). `SP_VERIFY=all|sample|off` (default `off`): `all` re-simulates every accepted normal/联防 result before accepting it (the server result wins on a digest mismatch); `sample` checks ~1 battle in 8 in a later callback and logs mismatches. `Match.verifyStats = { checked, mismatches, rejected, takeovers }`. Desync between display-only clients and the authority is tolerated: the authoritative outcome is what counts and is broadcast at settlement.
**顶栏胶囊 (the top bar's 击倒 / 漏掉 counter; PR #157, official reading).** The capsule shows `resolved / total`:
- `total` = the enemies **this field itself scheduled** (`Battle` `total`: the `spawns` the match built, `inTotal`) that count — `countInTotal: false`, `tag: 'boss'` / `'part'` and data `notCountInTotal` entries stay out, like the 领袖 of a boss field.
- `resolved` = `min(total, killedInTotal + leakedInTotal)` (`Battle.resolved`): that same set's enemies that are 已解决 — knocked down (`killedInTotal`) **or** leaked (`leakedInTotal`). Official: 开局 0/3 → 漏一个 1/3 → 打死一个 2/3 → 打死会分裂的 3/3.
- Everything spawned while the battle runs is in **neither** part: a split child (磨砻's 木制瑞印), a summon (枯朽萃聚使徒's 枯朽之种, a boss's 余音 / 妖怪 / 不祥幻影 / 王权号令 equipment), a 变身 copy, a part. They still count for LP, 完美作战 and `killed` exactly as master did (`counted`): a leaked split child costs its `lpr` and breaks 完美作战, it just moves no capsule number.
- `killed` (`Battle.killed`, per player too) keeps the official `counted` reading — a runtime split / summon knocked down counts — so it may **exceed** `total` (4 磨砻 killed plus one of their 8 children reads `killed` 5 with `total` 4 while the capsule reads 4/4). Nothing clamps it: the match's stats and the bot's scoring read those knock-outs.
- Carried by `b.snap`, `BattleResult` (battle level and per player, the per-player `resolved` clamped to the player's `total`), `b.progress` and `m.public.fields[].progress`. **A finished field publishes the field's own number, never the sum of the players':** in a two-half field (the 联防 lane, the boss pair's crossing routes) an enemy that spawns on one half and leaks on the other is billed to one player's `total` and to the other's `leakedInTotal`, so each player's own `min(total, …)` reads 0 for an enemy the field resolved (capsule 1/1, players 0 and 0). `validateClientResult` therefore keeps the result's field-level `resolved` (the client's `Battle.resolved`, clamped to the field's `total`) and `_fieldProgress` publishes it — for a server-run field too, whose `Battle.result()` carries the same number; the per-player numbers are only the fallback of a result that has none, and with none at all `progress.resolved` is `null`. A bot / server-run field has no authority reporting `b.progress`, so its capsule reads the battle's own counters through the `HeadlessJob` timeline sample (`[gt, killed, total, resolved(, left)]`), never the report-driven `progress.leaks` (which stays 0 there); `progress.resolved` starts at `null` and is only adopted from a real reported integer — a reported `0` is a value, "not reported" is not.
- The HUD reads it as `resolved ?? killed` (`ui/hud.js` two capsules, `ui/combatHud.js` teammates' rows, `ui/gameLogic/format.js snapHud`, `battle/observe.js teammateProgress`), so a server that does not send `resolved` displays master's numbers unchanged.
**Result validation (server, `fields.js validateClientResult`).** A client result is accepted only if structurally valid and plausible for its spec: every spec player reported and nobody else; finite numbers; `killed ≤ maxTotal` and `total ≤ maxTotal` (`4·spawns + 100`, the content-spawn allowance — `killed ≤ total` no longer holds, see the capsule above) and the field-level `resolved ≤ total`; every leaked `enemyKey` is in the spec's spawns (multiset bound) or a key the spawned enemies' data mentions (content summons/splits, bounded by the total) — boss fields excepted (their leaks cost team LP through `b.progress`); `counted: false` only for enemies that never count (data `notCountInTotal`, schedule `countInTotal: false`, boss/part entries, content spawns); 联防: leaks + `unspawned` per (enemy, leaker) ≤ what that leaker sent in (nobody's survivors are billed to a teammate); per-bond `layerGains ≤ min(60 + 4·round, 999 − the bond's starting layers)` (§20.12) — the flat part raised by what the player's layer 特质 can add to that bond (their per-battle caps; an uncapped one leaves only the 999 room, §21.26) —, none when the spec disables gains, bonds must exist and be named by the player's bond snapshot, band, effects, units or items; `coins ≤ Σ bounty coins of the spawns`; `perfect` consistent with the counted leaks; unit states only for the player's own board uids with hpPct ∈ [0, 1]. The accepted result is **rebuilt** from whitelisted fields (leak mods/tags/sources from the spec — a content-spawned enemy (split, summon) keeps the mods of the schedule entry they equal, i.e. its parent's round multipliers and bounty id, so its 联防 re-entry and bounty match the server run; unit names from server data). Otherwise (or on any exception) the server re-simulates (normal/联防); a boss field is **handed over** instead (below). `SP_VERIFY=all|sample|off` (default `off`): `all` re-simulates every accepted normal/联防 result before accepting it (the server result wins on a digest mismatch); `sample` checks ~1 battle in 8 in a later callback and logs mismatches. `Match.verifyStats = { checked, mismatches, rejected, takeovers }`. Desync between display-only clients and the authority is tolerated: the authoritative outcome is what counts and is broadcast at settlement.
**Boss rounds.** The shared pool (`SharedBossPool`, size = `bloodPoint[difficulty]` × the players alive when the fight starts, solo × 1 — the owner's decision of 2026-10-06 adopting PR #209, which replaces the fixed pool of 「保持固定血量」, §25.13.4) lives on the server and is fed by the authorities' `b.progress` damage deltas (attributed per player through `by`; BOSS_HIT tickers from each player's damage to that round's pool, §21.10) and by server-run fields; a server clock (250 ms) applies the overtime drain (−`bossOvertimeDrainPerSec` team LP per whole **real** second past `bossOvertimeAfter` real s, measured on the field clock — `gamedata.js bossOvertimeDue`, §4), the silence watchdog and the end checks: pool 0 → `b.end cleared` (victory), team LP 0 → `b.end forced` (defeat) — the first one the server registers decides the verdict (`Match._finalEnding`) and after a forced end no report (an in-flight `b.progress`, the final `b.result`) credits the pool (§20.5); results are awaited ≤ 6 s (× timerScale) after a forced end. `b.pool` is the live channel for the pool and the team LP: a boss `b.progress` (4 Hz per field) and the team-LP losses never re-mark the whole public state — `m.public` (`bossHp`, `teamLp`, `fields[].progress`) and the players' merged-LP shares (`_syncTeamLp`) refresh at ~1 Hz (`Match._bossPublic`, `BOSS_PUBLIC_MS`), and at once when the round ends. A boss field's `b.result` ends it only when the shared pool is empty or after that `b.end` — except a `'cleared'` result whose cumulative report covers what the pool still holds (within its 1 HP floor) while the plausibility budget (one field credits ≤ the pool per `BOSS_MIN_CLEAR_GS` 5 game s) has not caught up: it is **held** (`f.heldResult`; 999-layer boards kill a leader in 2–4 game s, §20.14) — the boss clock keeps crediting the report at the budget's rate, the silence watchdog and handovers skip the field, a re-sent copy is ignored, and the pool's end (`_endFinal`) completes the field with it; a forged report gains nothing a `b.progress` could not already get. Any other result — `'forced'` / `'timeout'` at t = 0, `'cleared'` while the pool still holds more than the report covers — and any result failing validation would stop the pair's fight (and, with every field done, end the Final Assault as a defeat), so the field is **handed over** (`Match._bossHandover`) to the partner's display replica (promoted with `b.start authoritative: true`) or, without one, to the server (fast-forwarded to the field clock); the sender gets `b.end takeover` and is never that field's authority again (`f.demoted`). There is no synthetic zero result for a running boss field: a stats-only stand-in (`fields.js syntheticResult`, carrying the damage already credited, never charging LP) only fills a result that cannot be had otherwise — the Final Assault already ending (no result, or an invalid one, within the grace) or a server-run battle whose `result()` threw.
@@ -95,7 +119,7 @@ Piece = { uid, kind: 'chess'|'item'|'token', id, golden: bool, tier, items: [ {
**Corrections from research 08/09 (normative):**
- Waves: official generator (research 08 §2): 3 of 6 types, each owns exactly 3 of the 15 round slots, SPECIAL the rest (shuffled per match); per round one type + one weighted special entry (half by round); each template SPAWN **action** becomes `clamp(roundHalfEven(n·P(t)/P(new)), 1, 5)` units over the same window; placeholder actions of the other movement class are skipped. Stage per match from the mode's list (标准 = 战场#01 only, §6.5). No custom balance multipliers (`data/tuning.json` keeps only titles); co-op boss pool = `bloodPoint[difficulty]` unscaled.
- Ground pathing: official SPFA-style 4-direction search from the goal with crates as cost-1000 tiles, then straight-line smoothing (research 08 §3.4). act1 m02 crates #001–#004 inactive in 下半. Remake refinement (user playtest #2, narrowed by §21.15): route lengths and routes stay the official ones unless 0.1.0's road-over-floor preference route crosses strictly fewer non-blockable (floor / gate / 深水区) tiles — a corner touch is not a crossing; ties keep the official route. E.g. 战场#01's lower-gate enemies take the col-8 road, while 战场#04's keep the official diagonal from row 9 into row 10 (server/sim/grid.js header; test/sim/pathing-official.test.js, pathing-blockable.test.js).
- Ground pathing: official SPFA-style 4-direction search from the goal with crates as cost-1000 tiles, then straight-line smoothing (research 08 §3.4). act1 m02 crates #001–#004 inactive in 下半. Remake refinement (user playtest #2, narrowed by §21.15): route lengths and routes stay the official ones unless 0.1.0's road-over-floor preference route crosses strictly fewer non-blockable (floor / gate / 深水区) tiles and no more 深水区 (GitHub #375) — a corner touch is not a crossing; ties keep the official route. E.g. 战场#01's lower-gate enemies take the col-8 road, while 战场#04's keep the official diagonal from row 9 into row 10 (server/sim/grid.js header; test/sim/pathing-official.test.js, pathing-blockable.test.js).
- Observing: no watching while your own normal battle runs; after it ends (and in prep) tap a teammate → 前往查看 (local replica, fast-forwarded). ‹ › only inside 联防/FA fields; FA: own pair only; eliminated: free.
- Prep interactions: tap a unit → underframe with 出售 +1 (and 撤退 on board); items/Arts → 销毁; shop and upgrade are two-tap (select → confirm; the remake's 机变 cards too, §18.2); strategy draft forbids a strategy already taken by a teammate (队友已选).
- Emotes: 6 themes × 6 battle emotes (research 09 §4.2), image-only bubbles beside the sender avatar, 1 s cooldown, 3 s bubble; `g.emote {id}` with the official emoji id.
+2
View File
@@ -350,6 +350,8 @@ Two public GitHub issues after 0.1.0, both client-only. Normative lines rewritte
User: 「给每个卫戍干员都加语音」, then, after the first build, 「整备阶段不需要干员语音,战斗开始后才需要语音,也就是只需要作战中语音」.
**Changed in 0.2.2** (§27, the owner's request of 2026-10-08 「添加一下干员点击上去的语气一样的语音」): 选中干员 is no longer battle-only — the detail panel opening on an operator the player taps says it in every phase, the prep, the shop and the hand included (`DetailPanel voice` is true on the game screen), with the official FOCUS_CHAR timing: no cooldown and no global gap on an idle channel, a newer tap replaces the 选中 line on air, a higher-priority line is never interrupted, and a tap starts no gap of its own for the battle lines (`VOICE_TAP_SLOTS`); every shop / reward card opened counts as a tap, so two cards of one operator each speak (`selectVoiceKey`). Every other line stays battle-only. 0.2.2 also adds the Japanese dub (「全套的日配语音」) as a second tree beside the Chinese one, `audio.voiceJp` — the same slots and file names from the dump's `voice/` folder — which the client plays when the player picks 日本語 under 设置 → 语音语言 (default 中文, not tied to the interface language), falling back to the Chinese line it lacks. The paragraphs below describe 0.1.1.
**Where the lines come from.** Every operator the mode can field already has official battle lines in the same voice dump the BGM and SFX come from — `voice_cn/{charId}/cn_nn.mp3` (JP/EN/KR: `voice/`, `voice_en/`, `voice_kr/`, the same file names, `tools/fetch-assets.mjs --voice-lang=jp|en|kr`). `charword_table.json`'s `placeType` says when each line is used, and `indexVoice()` turns it into the manifest's `audio.voice[charId]`: 行动出发 `start` (BATTLE_START), 行动开始 `faceEnemy` (BATTLE_FACE_ENEMY), 选中干员 `select`, 部署 `place`, 作战中1-4 `skill1..skill4` (BATTLE_SKILL_N), 编入队伍 `squad`, 任命队长 `squadFirst`, 结算 `resultFour/Three/Two/Lose` (完成高难行动 / 3星结束行动 / 非3星结束行动 / 行动失败) and 干员报到 `gacha`. A slot with several lines stays an array and the client draws one. Only the base word key is planned (`wordKey === charId`): the dump has no folder for a skin variant's word key (`char_x_ita`, `char_x_epoque#28`), so those lines 404. **Only the slots a battle can play are planned** (`audio.mjs VOICE_BATTLE_SLOTS`): 干员报到 / 编入队伍 / 任命队长 are lines of the official client's own 养成 / 编队 UI, nothing in this mode ever requests them (`test/docs-consistency.test.js` checks the client's `voice(...)` call sites), and downloading them cost 360 files (19.3 MB) on every run — `--voice-all` adds them back for the complete official set. Measured: 120 of the 121 in-mode charIds have a full set — the 17 预备干员 and the mode's own 盟约·辅助干员 have no battle voice at all, so they simply get no entry — 1680 lines of the 2040 upstream, in a manifest of 5694 files / 324.2 MB (files and `stats.voiceChars` in `data/assets.json`).
**Where each line plays — battle only.** Every line comes from a running battle's own event stream or from its settlement, so no hook needs a phase check and the 休整期 stays silent: the shop does not say 干员报到 (a merge-completing buy neither says 任命队长), a retreat does not say 编入队伍 and dragging a piece onto the board in prep says nothing. In battle: 开战 — the first operator deployed says 行动出发 and the others 部署 (the sim's `deploy` events; `setFieldUnits` resets the flag per field — in this mode every piece enters at once, which is why the gate below exists, and a knocked-out operator's redeploy says 部署 again); 接敌 — the sim's new `['engage', id]` (a unit's first attack that hits an enemy, official ENCOUNTER_ENEMY); 技能 — `skillN` from the unit's `skillIndex` (作战中1-4); 选中 — the detail panel opening on an operator **while a battle runs** (`DetailPanel voice` = the game screen's `combat`); 结算 — `resultVoiceSlot()`: 完美作战 ⇒ 3星结束行动 (绝境 / 终极 ⇒ 完成高难行动), 有漏怪 ⇒ 非3星结束行动, 一个没杀 ⇒ 行动失败, spoken by a drawn operator **of that battle's own field** (`resultSpeaker`, from the result's `perPlayer.unitsEnd`: the operators of that field, survivors first, a summon piece never), driven by the runner's new `emit('result', …)` — never the field on screen, so watching a teammate cannot put their operator's voice on the viewer's settlement line (review on #73). `unitsEnd` names each unit by its chess id (`chess_char_*`), so `onResult` passes the chess record's `charId` as `resultSpeaker`'s `charOf`; as merged, the speaker looked for `char_*` ids only and no battle ever said its settlement line (found while porting #73 to 0.2.0; `test/ui/audio.test.js` runs a real battle result through it).
File diff suppressed because it is too large. Load diff
+2
View File
@@ -103,6 +103,8 @@ Eight workstreams were merged into `playtest6` (WH → WG → WD → WC → WB
- **#19**: PRTS 帮助 "临时刷新3名高于调度中心等级1阶(最多6阶)的干员"; the official 玩法介绍 image shows three different operators at price 0. v2.4.1 rolled three times independently (14–21 % of offers had a duplicate). **Now** three different chess, copy-weighted; a tier short of different chess tops up from the tier below [ASSUMED]; the offer reserves no copies. The integration QA found the same repeat in the other free pick-one chess offers — 信标 (same tier), 寻呼模块 (same bond ≤ shop level, content/items/meta.js) and the engine default `builtinMeta use_equip_reward_special_goods_char_chess` — they now draw different operators too (信标 tops up from the tier below, 寻呼模块 shows fewer cards when the pool has no other one), and `pushRewardOffer` drops a repeated id from any list it is given (`test/content/items.test.js`).
- Tests: `test/match/playtest6-matchflow.test.js` (the draft, two battles for a multi-round card end to end, 联防 counts, rewards), `test/match/followups.test.js`, `test/ui/playtest6-unite.test.js`, `test/ui/playtest6-bounty.test.js`, `test/content/items.test.js` (教鞭).
Superseded in 0.2.2: 教鞭 no longer takes one of its three 战术特训 cards at random — its owner chooses (docs/META.md §2.5).
### 20.7 机变 card text; the shop's confirming tap on phones (#6, #10) — `ui/choiceOverlay.js`, `css/screens/game-panels.css`, `ui/shopBar.js`, `css/screens/game-shop.css`, `css/devices.css`
- **#6 seen** (screenshot 2, Android 756×366 CSS px, 终极 co-op): the 道具补给 cards showed only icon, name and tier. **Cause**: the card was a centred column sized for 16:9; with the root font clamped at 40 px a 3×2 card was 105 px tall and showed 0.2 lines of text (217 of 223 texts cut), in all four families; at 1920×1080 nine long texts were cut at 4 lines. **Official** (research 06 §11.5; screenshots Mb2mtd9 / Wrkhtxv / SEbySVl): a framed icon at the top-left with the bold name beside it, the description below, numbers in blue, the tier chip on the icon's corner, the taker's round avatar alone at the top-right. **Now**: that layout; the text from `cardText` (the card's own `descRaw`, else the data `descRaw` while its plain form equals the server's `desc`, else the server text), clamped to 7 lines (8 at line height 1.3 on phones under 700 px wide: with Noto Sans SC — the page's web font, metrics of Android's CJK font — 海沟实验体 wraps to 8 lines at 640×360, residual QA); short screens get a tighter overlay header (no CONTINGENCY micro line) and .19rem text; the taker badge is the avatar alone (name in its tooltip and the card's aria-label) and the taken card's head reserves its width. All 223 texts (51 items, 129 bounty, 43 tactic) fit in full, taken or not, from 640×360 to 2560×1080, measured with Noto Sans SC.
+2
View File
@@ -19,6 +19,8 @@ Tags: **[DATA]** client game data · **[VERIFIED]** official or wiki rule text,
| `07-assets.md` / `07-assets.json` | Verified URL patterns and resolved URLs for avatars, portraits, skills, bond/item/band icons, UI, Spine (ops + enemies), BGM/SFX, fonts; build plan. Addendum: spot check 40/40 OK. | `operators[*]`, `enemies[*]`, `bonds`, `items`, `bands`, `autochessUi`, `audio` |
| `11-limits-official.md` | Official limits read from the client (2.7.71 il2cpp) and the community: bond layers cap at **999** per bond (`MAX_GARRISON_STACK`, `AddBondCount` = min(L + n, 999)); in boss battles outside training a single hit of **≥ 300000** on a leader (`IsBossEnemy` = an `autoChessData.bossInfoDict` enemyId) is **cancelled** ("限伤", `MAX_BATTLE_DAMAGE`); implemented in DESIGN §20.12. | `shared/constants.js BOND_LAYER_CAP / BOSS_HIT_LIMIT` |
| `12-flying-visuals-official.md` | Official flying visuals and model placement, read from the client (PR #211 by @xcdoge; ported into 0.2.0 by the owner's decision of 2026-10-06): the fly offset is the single model-independent constant **0.35** (`CharacterAnimator..ctor` stores `Vector3(0,0.35,0)` at field +0x114; `_SetFlyMountPointOffset` / `_SetFlyHitOffset` add it while flying, negate it on landing) — in the client's *character* space, whose unit is the standard prefab scale 0.27, so **0.35 / 0.27 ≈ 1.3 tiles**, confirmed by pixel-measuring an official screenshot (1.2–1.4). `FLY_HOVER` 0.32 → **1.3**, flat (no per-model term). The same sweep found two model quirks: `modelScaleY` 1.263 (帝国炮火先兆者 pair, vertically stretched) and `mirrorX` (木制瑞印, mirrored prefab). | `public/js/render/units.js FLY_HOVER / enemyModelScaleY`, `data/enemies.json`, `tools/local-extract/enemy_model_offsets.py` |
| `13-knockback-official.md` | Push / pull (推拉) in the official client (PR #380 by @xcdoge; written in Chinese): the knockback abilities (`Knockback`, `KnockBackWithDirection`, `KnockBackWithCharacterDirection`, `DragTowardSource`) carry an impulse (`m_force`) and a velocity decaying under friction (`m_friction*`, the tile's `additionalFriction` — `GrasslandType` NORMAL_LAND / ICE_LAND / SLIME_LAND); the data has no `move_duration` / `move_distance` key (distances are emergent; 力度 comes from `skill_table` / `enemy_database`); every tile of this mode's 11 maps is normal land. Also noted, not modelled: wall bounces (`_bounceTimesAsDamageTimes`) and `unbalanceProtectDueTime`. The client's slide (`v0 = 2D/T`, `a = v0/T`, T = 0.14·√D in 0.12–0.45 s) is ours [ASSUMED: no source gives the official timing]. | `render/units.js DISPLACE_SLIDE / slideTo`, `render/app.js` `displace` fx |
| `14-death-animations.md` | Enemy death animations (PR #380 by @xcdoge): the art never drops the body (the PR author's 234-clip sweep — no Die/Idle/Move/Attack clip translates the root), the 1.6 s death cap cut 25 clips short (盐风主教昆图斯 7.97 s) so it is 8 s, and a flying unit keeps its lift for the whole Die clip, dropping only inside the fade tail (the client removes its fly offset in `CharacterAnimator.OnFinish`). | `render/units.js DIE_CLIP_MAX / DIE_FADE_TAIL` |
## 2. Match structure
+1 -1
View File
@@ -922,7 +922,7 @@ Members (5 chess, 4 in current shop pool; by tier in shop: {'2': 1, '3': 1, '5':
**Implementable spec**
- **[2 distinct]** If a 突袭 member has not attacked for 10 s, or its skill is ready, and no enemy is in its range: it is immediately redeployed (keeping SP) onto a tile next to a ground enemy; while in that redeployed state its ATK and maxHP x(1 + 0.25 + 0.01*L). A passive skill that is on counts as ready (GitHub #49: the reporter's footage shows 缄默德克萨斯 jumping within her passive's 10 s; the engine keeps a passive on for the whole deployment [ASSUMED]), and so does a deploy-timed skill while its window runs (#109 made 宴 S2, 斯卡蒂 S2, 伊内丝 S3, 缄默德克萨斯 S1–S3 and 耀骑士临光 S2 duration skills that start at each deployment; once the window has ended only the idle trigger is left); the landing tile must have the target in range (GitHub #51 [ASSUMED]).
- **[2 distinct]** If a 突袭 member has not attacked for 10 s, or its skill is ready, and no enemy is in its range: it is immediately redeployed (keeping SP) onto a tile next to a ground enemy; while in that redeployed state its ATK and maxHP x(1 + 0.25 + 0.01*L). A passive skill that is on counts as ready (GitHub #49: the reporter's footage shows 缄默德克萨斯 jumping within her passive's 10 s; the engine keeps a passive on for the whole deployment [ASSUMED]), and so does a deploy-timed skill while its window runs (#109 made 宴 S2, 斯卡蒂 S2, 伊内丝 S3, 缄默德克萨斯 S1–S3 and 耀骑士临光 S2 duration skills that start at each deployment; once the window has ended only the idle trigger is left); the landing tile must have the target in range (GitHub #51 [ASSUMED]). A 隐匿 (unrevealed) ground enemy is a target too, and one inside the member's range counts as an enemy in range (no jump) — the owner's decision of 2026-10-08 from the official game (GitHub #316); asleep, untargetable and flying enemies are not.
- **[L >= 50]** ALL operators ASPD +50.
- Formulas: `atkHpMultiplier = 1.25 + 0.01*L`; `idleSec = 10`; `aspdAll(L>=50) = 50`
- How layers are gained: 史尔特尔 部署时 +8 (<=50/battle); 休谟斯 each 2 kills +1; 瑕光 +4/deploy; 斯卡蒂; 伊内丝 +5; 机变 "斯卡蒂的盟誓" +8, "德克萨斯的盟誓" +10.
+2 -2
View File
@@ -18,7 +18,7 @@ Rich-text tags (`<@ba.vup>…</>`, `<$ba.inspire>…</>`) are stripped in this d
| 6 | E2 Lv1 / S-Lv4 / mod 0 | E2 Lv60 / S-Lv7 / mod 3 | 4 (golden entry also 4) | 1 (golden 1) | char_elite_base_2 / char_elite_gold_2 |
- Every chess has `upgradeNum = 3`: 3 copies of the same normal chess -> 1 golden (精锐) chess (`upgradeChessId` = `goldenChessId`). Golden chess have `upgradeChessId = null` (no further upgrade). Exception via 特质: 风丸 `chess_char_2_11` (garrison_93: "只需2个相同的本干员即可合成精锐") needs only 2 copies.
- favorPoint = 0 in every status -> **trust bonus 0**. The status has no potential field (the tables here are at potential 0); the remake fights at **full potential** (潜能 6 — the owner's decision of 2026-10-07, GitHub #252: a tournament video shows 刺玫 at ATK 435 / cost 15 = her E1 Lv55 413 / 17 + 攻击力+22 and two 部署费用-1; data/chess.json, build-data `OPERATOR_POTENTIAL`). Module (模组) is **inactive on normal chess** (equipLevel 0) and **active on golden chess** at stage 1 (tiers 1–5) or stage 3 (tier 6). The module used is always `defaultUniEquipId` (always the operator's `uniequip_002_*` = first advanced module, X or Y type depending on operator; 凛御银灰 `char_1045_svash2` has none).
- favorPoint = 0 in every status -> **trust bonus 0**. The status has no potential field (the tables here are at potential 0); the remake fights at the **player's potential** (潜能 1–6 per operator in 干员调配, default full potential 潜能 6 — the owner's decisions of 2026-10-07, GitHub #252: a tournament video shows 刺玫 at ATK 435 / cost 15 = her E1 Lv55 413 / 17 + 攻击力+22 and two 部署费用-1, and of 2026-10-08: the 调度手册 「卫戍协议中干员潜能由自身已持有干员潜能决定」; data/chess.json is built at full potential and carries the lower ranks, DATA.md §2.3). Module (模组) is **inactive on normal chess** (equipLevel 0) and **active on golden chess** at stage 1 (tiers 1–5) or stage 3 (tier 6). The module used is always `defaultUniEquipId` (always the operator's `uniequip_002_*` = first advanced module, X or Y type depending on operator; 凛御银灰 `char_1045_svash2` has none).
- Golden stat jump is large: tier 1 goes E1 Lv55 -> E2 Lv50 (so skill 3 exists but chess keep their `defaultSkillIndex`; the skill index does NOT change on upgrade, only the level 4 -> 7).
- Prices: see 1.1 table; purchasePrice is per tier (T1=2, T2=3, T3=3, T4=3, T5=4, T6=4 coins); selling any chess returns 1 (data `chessSoldPrice`=1 for all; the shop/economy doc should confirm whether golden sells for more via other rules). Note: garrison `SERVER_PRICE` effects override the purchase price for specific chess (e.g. "购买价格为1").
- Board/bench limits (constData): maxBattleChessCnt = **8** on field, maxDeckChessCnt = **10** on bench (整备区), storeCntMax = 6, shopRefreshPrice = 1, fallbackBondId = `emptyShip` (协防干员), borrowCount = 20 (max friend-support borrows).
@@ -35,7 +35,7 @@ Rich-text tags (`<@ba.vup>…</>`, `<$ba.inspire>…</>`) are stripped in this d
- `stat(level) = kf0 + (kf1 - kf0) * (level - kf0.level) / (kf1.level - kf0.level)` using `character_table[charId].phases[evolvePhase].attributesKeyFrames` (two keyframes: Lv1 and max level). maxHp/atk/def/cost/blockCnt/respawnTime rounded half-up to int; others kept as float.
- Golden: + module `battle_equip_table[defaultUniEquipId].phases[equipLevel-1].attributeBlackboard` (max_hp, atk, def, attack_speed, magic_resistance, …). Module trait additions are listed as `module.traitEffect` / `module.hiddenTalentEffects` in JSON.
- No trust. No potential in these tables — data/chess.json adds every `potentialRanks` attribute step and takes the talent candidates up to `requiredPotentialRank` 5 (full potential, the owner's decision of 2026-10-07). Talent-driven permanent stat increases (e.g. "攻击力+8%") are NOT folded into the numbers; apply them in the sim from `talents[]`.
- No trust. No potential in these tables — data/chess.json adds every `potentialRanks` attribute step and takes the talent candidates up to `requiredPotentialRank` 5 (full potential, the default) and carries what each lower potential changes (`potDown`, chained talents — DATA.md §2.3: the player's 干员调配 setting since 0.2.2). Talent-driven permanent stat increases (e.g. "攻击力+8%") are NOT folded into the numbers; apply them in the sim from `talents[]`.
- `cost` (DP) and `respawnTime` are used by automatic redeployment in battle (see 1.4).
- Attack interval = `baseAttackTime * 100 / attackSpeed` seconds.
+1 -1
View File
@@ -352,7 +352,7 @@ Combo check: `equip_chess_id` in blackboard is the partner's id WITHOUT the `_a/
- Buff keys: 画卷 `trap_copy_front_char{}`; 教鞭 `trap_create_self_choice{choice_event=hunter_band_1}`; 神秘顾客 additionally `trap_disney_special{count=1}`.
- `hunter_band_1` (choiceType PERSONAL_CHOOSE, "悬赏决策") pool is server-side. [ASSUMED] offer 3 random `enemyeffect_b_*` bounties (e.g. 碎骨·悬赏 +1, 萨卡兹百夫长·悬赏 +2, 泥岩·悬赏 +4, 锏·悬赏 +5, “复仇者”·悬赏 +6 funds; buff `add_enemy_kill_gain_coin{count,enemy_id,coin,round=1}`): adds 1 enemy to your next battle, killer gets `coin` funds.
- 画卷 copy: duplicates the operator (same chess id incl. elite `_b`) AND clones its equipped items into the copy [DATA desc "包括精锐状态和装备"]; copy goes to hand [ASSUMED]; if hand full -> temporary hand.
- 画卷 copy: duplicates the operator (same chess id incl. elite `_b`) AND its equipped items [DATA desc "包括精锐状态和装备"]; the copied items arrive unequipped (PRTS 画卷 备注 "使用后销毁,获得的装备为未装备状态"), gained like any item — the hand, overflow temp (destroyed with the usual toast when both are full); copy goes to hand [ASSUMED]; if hand full -> temporary hand.
## 8. Item sources other than the shop [DATA]
+3 -2
View File
@@ -352,8 +352,9 @@ reported the 战场#01 lower-gate enemies walking up the col-9 floor lane, where
`server/sim/grid.js` keeps every official route LENGTH and the official route itself unless the 2026-09-29
road-over-floor preference route (equal-length ties to the chain with the fewest non-blockable floor / gate tiles,
smoothing that never cuts across floor its grid route does not walk, the corner tiles of a diagonal step included)
crosses strictly fewer non-blockable tiles; in that comparison a segment that only touches a floor tile's corner does
not cross it. Used alone (0.1.0) the preference also bent official diagonals into L shapes — the D5 report after
crosses strictly fewer non-blockable tiles and no more 深水区 (GitHub #375, 0.2.2: the 深水区 is non-blockable too, and
on 战场#08(下半) the patrolling leaders had waded through two water tiles to dodge one floor tile); in that comparison a
segment that only touches a floor tile's corner does not cross it. Used alone (0.1.0) the preference also bent official diagonals into L shapes — the D5 report after
0.1.0: on 战场#04 the lower-gate enemies walked (9,10) → (9,8) → (10,8) instead of the official diagonal (9,10) →
(10,7), which only brushes the corner of the floor (10,9). Lanes that still differ from the table above (21 of the 154
stage × gate × field routes, test/sim/pathing-official.test.js):
+138
View File
@@ -0,0 +1,138 @@
# 13 · 推拉(击退 / 拖拽)的官方实现:冲量 + 摩擦
这份文档回答两个问题:**官方推拉到底怎么动**,以及**摩擦是谁的参数**。结论全部来自官方客户端本体的
`global-metadata.dat`(il2cpp 元数据)、官方 gamedata(社区镜像 Kengxxiao/ArknightsGameData)与官方 AB 资源包,
并附上每一步的验证方式,便于复核。
## 0. 结论速览
| 问题 | 答案 |
|---|---|
| 官方怎么动 | **冲量 + 摩擦**:给被推者一个力(`m_force` / `m_forceVectorX/Y`),速度**逐帧被摩擦衰减**直到停下 |
| 有固定时长/距离吗 | **没有**:数据里不存在 `move_duration` / `move_distance` / `move_tiles` 键(`moveDuration`、`moveTiles` 属于 UI 类)⇒ 位移量是**涌现**的,这也是社区只能给出"**推力-位移近似对应表**"的原因 |
| "力度"从哪来 | **官方数据**:`skill_table.json` 的 blackboard `force`(41 个技能带它),敌人侧在 `enemy_database.json`(8 条) |
| 摩擦是谁的参数 | **地板(地形)+ 单位质量**,**不是**技能参数:`GrasslandType { NORMAL_LAND, ICE_LAND, SLIME_LAND }`,每格是一条 `GrasslandData { modeIdx, grasslandType, additionalFriction }`,运行时还会被 `UpdateFrictionFactor` / `RestoreFrictionFactor` 临时改写 |
| 卫戍协议里有几种地板 | **实际只有普通地面**:11 张地图共 18 种格子字符,无冰面/黏液 ⇒ 本模式下摩擦恒定,滑行时长**不必按地板分档** |
| 飞行目标怎么算 | **没有独立的空中摩擦**(`boatAirFactor` / `m_airBase` 属于"船"玩法):飞行用的是**同一套**公式(单位摩擦 × 所在格子的 `additionalFriction`);差别只在**运动模式**(`ENEMY_MOTION_TYPE_FLY`、`HEIGHT_CHANGED_TO_FLY_BLOCK_MOTION_MODE` ⇒ 忽略地面阻挡),以及**失衡免疫 / 静态刚体**的空中单位根本不可位移 |
| 曲线形状 | 恒定减速 ⇒ `d/D = 2k − k²`(ease-out quad) |
| 推多久 / 拉多久 | **失衡(UNBALANCE)状态机**的时长(0.2.2,PR #392 的思路、按来源重做):推 = PRTS 游戏数据基础「推力-位移近似对应表」的**位移时间**(受力等级 −2/−1/0/1/2/≥3 → 0.2 / 0.4 / 0.8 / 0.9 / 32⁄30 / 35⁄30 游戏秒,正好是 30 fps 的整帧);拉 = 推与拉的**拉力作用时间**(1 s,受力等级 < −1 时 0.5 s,急停后仍保持到结束);静态刚体受力 > 0 时 0.1 s 保底。客户端滑行用同一段游戏时间(默认 2 倍速 ⇒ 现实时间减半);`T = 0.14·√D` 只留作旧录像(fx 不带 `dur`)的回退 [ASSUMED] |
## 1. 官方类与字段(`global-metadata.dat`)
推拉能力的类簇(与 PRTS 的"推与拉"术语逐字对应):
```
Knockback · KnockBackWithDirection · KnockBackWithCharacterDirection · KnockBackTargetsByRootTile · DragTowardSource
_decreaseForceLevelWhenNotInDirection(PRTS"特殊修正"−2 力度)· _dontChangeFaceByDirection · _GetPushDir
TOO_CLOSE_TOLERANCE_SQR(PRTS"< 0.25 格转径向")
```
运动学字段(同一批类里):
```
m_force · m_forceVectorX/Y · forceScale · m_mass · m_velocity · get_mass · get_rigidbody2D
m_friction · m_frictionBase · m_frictionFactor · get_frictionFactor · m_frictionFactorAdditional · get_additionalFriction
UpdateFrictionFactor · RestoreFrictionFactor · _UpdateFrictionFactorAdditional · RestoreCached…
_ApplyForce · _UpdateVelocityAndPos · _UpdateAllUnitDeltaPos · _GetForceByDirectionAndDamage
m_bounceFrictionFactor · bounce · bounceease · _bounceTimesAsDamageTimes · _bounceBBKey · _bounceConditionValidator
unbalanceProtectDueTime
```
⇒ `m_force*`(冲量)+ `m_velocity` / `_UpdateVelocityAndPos`(逐帧速度积分)+ `m_friction*`(衰减)就是"冲量 + 摩擦";
`UpdateFrictionFactor` / `RestoreFrictionFactor`(改完还要还原)说明摩擦**由外部临时提供**,不是能力自带。
## 2. 地板(地形)摩擦
地板系统就在**卫戍协议自己的格子类**里(同簇可见 `TileState { SHARED, MY_SIDE, MATE_SIDE }`、
`CooperateStartTile` / `CooperateEndTile` / `CoopFootballTile`、`get_modeIndex` / `get_tilePlayerSide` /
`_OnPlayerDying` / `_OnPlayerRevive`):
```
_grasslandDatas · GrasslandType { NORMAL_LAND, ICE_LAND, SLIME_LAND } · GrasslandData { modeIdx, grasslandType, additionalFriction }
DynamicBuffQuickSandTile(流沙)· DynamicBuffTile · DirectionTile(带方向的格子)
```
⇒ 每格可带 `additionalFriction`,即"**地板提供摩擦**"。另有冰面相关因子(`iceFactor`、`iceTile`、`OnIce`、`Frozen`)
与"船/水/空"那套(`m_boatFrictionFactor`、`m_boatAirFactor`、`m_airBase`、`m_waterFlowForce*` —— 属于多索雷斯船玩法,
**不是**飞行单位)。
### 卫戍协议实际用到哪些地板
`docs/research/05-maps.json`(11 张地图)里出现过的全部格子字符:
```
# ×1776(墙/虚空) r ×580(道路) X ×477(分区隔断) f ×448(地面) a ×330 b ×250 A ×165
p ×77(预览栏) d ×76(装置) S ×66(起点) I / E ×33(路线起/终) h ×24(手牌栏) O ×22 m ×12 i ×8 g ×8 R ×4
```
**没有冰面或黏液** ⇒ 本模式下所有可站地板摩擦一致。
## 3. 力度来自官方数据(不是我们拟定的)
`excel/skill_table.json` 的 blackboard 里 **`force` 出现 357 次、41 个技能**,例如:
| 技能 | 名称 | 力度(按等级) |
|---|---|---|
| `skchr_weedy_1` / `_3` | 温蒂 炮管敲击 / 液氮大炮 | 0,1,2 / 1,2,3 |
| `skchr_forcer_1` / `_2` | 见行者 护身射击 / 惊爆射击 | 0,1,2 / 1,2,3 |
| `skchr_panda_1` / `_2` | 食铁兽 铁意六合 / 崩拳式 | 0,1,2 / 1,2,3 |
| `skchr_rope_1` / `_2` | 崖心 勾爪发射 / 复式勾爪 | 0,1,2 / 0,1,2 |
| `skchr_glady_1` / `_3` | 歌蕾蒂娅 缺水的大洋裂断 / 缺水的碎漩狂舞 | 0,1,2 / 0,1,2 |
| `skchr_sqrrel_1` / `_2` | 阿消 水蒸气泵 / 高压水炮 | 0,1,2 / 0,1,2 |
| `skchr_moeshd_2` | 可颂 磁爆锤 | 1,2,3 |
| `sktok_archook` | 钩索 token 发射 | **11** |
同一张表里 **没有** `friction` / `mass` / `knockback` 键 ⇒ **力度是数据(可查表),摩擦与质量在引擎里**。
### 靠"职业"就能推拉的干员
`character_table.json` 的 `subProfessionId` 有两个专门职业,**普通攻击**即推/拉,力不在技能 blackboard 里:
- **`pusher`(推击手)**:阿消 · 见行者 · 食铁兽 · 温蒂
- **`hookmaster`(钩索师)**:暗索 · 歌蕾蒂娅 · 杏仁 · 雪雉 · 崖心
其余靠技能/天赋推拉的还有:Misery、灰烬、黑键、燧石、溯光星源、可颂、焰狐龙梓兰、傀影、莱伊、罗宾、圣聆初雪、
琳琅诗怀雅、乌尔比安,以及 `勾爪` / `暴风雪` / `喷拒器` / `猎潮的骑士` / `流形` / `weedy_token` 等 token 与装置。
## 4. 顺带查清的两条官方机制(尚未建模)
- **撞墙弹跳**:`bounce` / `bounceease` / `m_bounceFrictionFactor` / `_bounceBBKey`,并且 **`_bounceTimesAsDamageTimes`
—— 弹跳次数计入伤害次数**(温蒂 / 推击手那类撞墙伤害的官方口径)。
- **失衡保护**:`unbalanceProtectDueTime` —— 连续推拉之间有保护窗口。
## 5. 客户端的做法(本次实现)
`render/units.js`:模拟位移是**瞬时**的(`sim/battle/displacement.js` 在一次调用里走完 0.1 格步进),随后敌人处于
**失衡状态**(0.2.2:不走路、不发动普通攻击,时长见 §0「推多久 / 拉多久」),快照只带终点,
所以客户端补上中间帧——`UnitView.slideTo(x, y, { dur, friction })` 建立一次滑行,`update()` **逐帧做速度积分**
(`v ← max(0, v − a·dt)`、`d ← d + v·dt`),终点由模拟给出(**权威**),`v0 = 2D/T`、`a = v0/T` 保证
`T` 秒内正好走完 `D` 且速度归零 —— 即官方"冲量 + 摩擦"的等效形式;`T` = `displace` fx 带来的失衡时长 `dur`(游戏秒)÷
播放倍率(`ctx.animRate`,默认 2 ⇒ 受力等级 0 的推 0.8 游戏秒 = 0.4 现实秒),滑行结束时模拟的失衡也正好结束;fx 不带
`dur`(0.2.2 以前的录像)时才回退到 `T = 0.14·√D`(夹 0.12–0.45 s)[ASSUMED]。`friction` 是**地板因子**(本模式恒为 1;将来有冰面就乘
< 1 ⇒ 滑得更久)。PR #392 的 `0.6387·√格` / `0.3194` 是用 PRTS 页面的「μ = 0.5 单位假设」推出来的,页面没有写出、也对不上
位移时间表(每档高约一帧),未采用。`render/app.js` 在插值器开始显示「以带终点的快照结尾」的那一段时就用 `displace` fx 启动滑行:
到达那张快照之前画面停在原处,之后从原处减速滑入模拟的实时位置(终点,以及敌人随后继续走的路),落地不跳。
(PR #380 合入时模拟层、协议、golden 未改;0.2.2 的失衡状态改了模拟层,`displace` fx 多带一个 `dur`。)
滑行途中死亡:滑行照常走完,尸体停在被推到的位置(模拟里它已经在那里;0.2.2 合入时改掉了原 PR 的「回到出发格」);
倒地的单位交给 `setDown` 放到它的格子上。
## 6. 复核方式
1. 元数据:`node` 读 `global-metadata.dat`,搜上列标识符(本文件所有类名/字段名都是这样得到的)。
2. 力度表:`zh_CN/gamedata/excel/skill_table.json` → 各技能的 `levels[].blackboard` 里 `key === 'force'`;
敌人侧 `levels/enemydata/enemy_database.json`。
3. 职业:`excel/character_table.json` 的 `subProfessionId ∈ {pusher, hookmaster}`。
4. 地板:`docs/research/05-maps.json` 的格子字符统计(本文件 §2)。
## 7. 附:官方 AB 资源包的读取要点(踩过的坑)
供以后要读官方战斗配置时参考(本次为定位"摩擦是不是数据"而打通):
- `Flags` 里的 `0x200`(`UsesAssetBundleEncryption`)在本客户端上是**虚设**:blocksInfo 既没有 70 字节加密前导,
也不需要任何 16 字节 key(暴力扫 `global-metadata.dat` 144 万候选、`GameAssembly.dll` 5 万候选均无命中即因此)。
- blocksInfo 位置 = `align16(headerEnd)`;**块数据是连续存储**的(不要逐块对齐)。
- 压缩:`comp 3` = 普通 LZ4 ✓;`comp 4 / 5` = Arknights 自定义 LZ4AK(nibble 交换后按 LZ4 解,
终止条件是 `op === uncompressedSize`,**严格相等**)。
- 包内官方配置是**带 `$type` 的 JSON**(如 `Torappu.Battle.Action.Nodes+SetBodyDirection`),
内嵌类型树 ⇒ 字段名可直接读;但**摩擦不在其中**(本文档 §2 已说明原因)。
+109
View File
@@ -0,0 +1,109 @@
# 14 · Enemy death animations: the art never drops the body, and 25 clips were being cut short
A player report — "a dying 帝国炮火先兆者 seems to drop at the very end", then "what about the other enemies'
animations?" — answered by reading the client's own data and its code.
## 0. TL;DR
- **No enemy animation moves the whole body.** Playing all 251 enemies' clips and sampling every bone's world position,
the **root bone's vertical displacement is 0.000 tiles in every Die (234), Idle (235), Move (234) and Attack (215)
clip**. Limbs move (up to 2.16 tiles for “帝国的甲胄”'s arms); the body never does. Every vertical placement is the
renderer's: the fly lift, the model's own art hang (`.skel bounds.y`) and nothing else.
- **A dying flyer dropped at the wrong moment.** The client removes its fly offset in `CharacterAnimator.OnFinish` —
when the finish state ends, not when the unit dies — so a drone stays aloft through the death animation and drops as
it goes. The remake dropped it the instant `alive` went false, so the death clip played on the ground. Fixed: the lift
is held for the Die clip and released inside the fade tail (§4).
- **25 death clips were cut short by a 1.6 s cap** — 盐风主教昆图斯's 7.97 s clip played 20 % of itself, 鼠王 37 %,
杰斯顿·威廉姆斯 40 %, and 22 more (§3). The cap is now 8 s, above every clip in the data.
- 17 manifest entries have no Die clip at all; they are the ground items/collectibles (§5).
## 1. How the clips were read [DATA]
A sweep script of the PR author (PR #380, not in this repository) reads `data/assets.json` + the extracted `.skel` files
and **plays** each clip: `Skeleton` + `AnimationState`, stepped at 30 Hz, `state.apply(skeleton)` and
`skeleton.updateWorldTransform()` after every step, recording the range of `skeleton.y` (the root) and of every
`bone.worldY`. Ranges are converted to tiles with the model's `modelScale` (320 skeleton units = one tile at scale 1 —
the conversion the flying-visuals write-up uses for the same models).
Do **not** try to read the timelines directly: in this build of `@pixi-spine/runtime-3.8` they hold packed numeric
arrays (`timeline.frames[0]` is a number, not `{time, x, y}`), so a keyframe walk silently reports zero motion — the
first attempt here did, and 270 Attack clips "had no rotation". Sampling the played skeleton is layout-agnostic and
matches what the client draws.
## 2. Nothing translates the body [DATA]
| clip | read | root displacement > 0.01 tiles |
|---|---|---|
| Die | 234 | **0** |
| Idle / Default | 235 | **0** |
| Move / Walk | 234 | **0** |
| Attack | 215 | **0** |
The largest single-bone travel inside a Die clip is 2.16 tiles (“帝国的甲胄”, a flailing limb), then 1.40 (节日气球),
1.29 (“独轮车玩具”), 1.18 (业余竞演者) — normal animation, not a fall. A "the body sinks" effect can therefore only come
from the code paths that place the model (`FLY_HOVER`, the `zTarget` ease, `lift`, and the artist's `bounds.y`).
## 3. Death clip durations, and the 1.6 s cap that cut them [DATA]
234 Death clips: shortest **0.50 s**, median **1.00 s**, longest **7.97 s**. `render/units.js` used
`clamp(d / rate, 0.35, 1.6)`, so **25 clips were trimmed** (11 %), every one of them a boss or elite:
| enemy | Die clip | played by the old cap |
|---|---|---|
| 盐风主教昆图斯 | 7.97 s | 1.60 s (20 %) |
| 鼠王 | 4.33 s | 37 % |
| 杰斯顿·威廉姆斯 | 4.00 s | 40 % |
| W / 泥岩 | 3.33 s | 48 % |
| 假想敌:胄 ×2 | 3.10 s | 52 % |
| “巨大的丑东西” | 2.83 s | 56 % |
| 腐败骑士 | 2.67 s | 60 % |
| 高普尼克 ×2 / 卢西恩,“猩红血钻” / 假想敌:管 ×2 | 2.33 s | 69 % |
| 假想敌:弦 | 2.30 s | 70 % |
| 雪孩子 / 伊利昂的木驮兽 / 鸭爵 ×2 / 纠缠藤蔓 / 凋零骑士 | 2.00 s | 80 % |
| 陷落雪祀 | 1.80 s | 89 % |
| “帝国的甲胄” / 底海滑动者 / 富营养的滑动者 | 1.67 s | 96 % |
The lower bound (0.35 s) trims nothing: the shortest real clip is 0.50 s. The client plays its clip out — its `OnFinish`
fires when the finish state ends — so the cap only ever made the remake's deaths shorter than the official's.
## 4. A dying flyer drops at the end of the clip, like the client [DATA]
The client's fly offset is one constant (`Vector3(0, 0.35f, 0)` in `Torappu.Battle.CharacterAnimator`, applied by
`_SetFlyMountPointOffset` / `_SetFlyHitOffset` while a unit flies and negated when it lands; the remake draws 0.35 in
character space ≈ **1.3 tiles** as `FLY_HOVER` — the flying-visuals write-up has the full derivation). Its only direct
caller is `OnFinish` (#62924, call site
0x1805feb87), disassembled as `xor edx, edx; mov rcx, rbx; call _SetFlyMountPointOffset` — **arg 0**, the negation
branch — followed by clearing `[this+0x128]` and a tail call to another cleanup. `OnFinish` has no direct `call` site of
its own: it is a virtual lifecycle method (there is a `<>xLuaBaseProxy_OnFinish`), so the battle framework calls it
through the vtable when the character's **finish** state ends.
Since the Die clips carry no vertical motion (§2):
- **Client**: the drone stays aloft for the whole death animation and drops as the finish state ends.
- **Remake before**: `hoverTo = this.flying && this.alive ? FLY_HOVER : 0` — `die()` clears `alive` *before* the clip
plays, so every flyer sank to the ground inside ≈0.42 s (lerp τ = 1/6 s) and played its death clip **on the ground**.
- **Remake now**: the hover target is `this.alive || (this.dying > DIE_FADE_TAIL && !this.down)` — the flyer holds
`FLY_HOVER` for the whole clip (the view fades over the last `DIE_FADE_TAIL` = 0.55 s of `dying`) and drops inside
that fade, which is the client's `OnFinish` moment. A knocked-down (`down`) flyer still lies on the ground.
All 27 flying enemies of the data have a Die clip of 0.67–1.00 s, well inside the cap, so the cap change never touches
them; 帝国炮火先兆者 and 帝国炮火中枢先兆者 are unexceptional (Die 1.00 s, root displacement 0.000).
## 5. The 17 entries with no Die clip [DATA]
未装配刀片 · 防护背心 · 冲击式施术单元 (deployable equipment) and 木制瑞印 · 红木瑞印 · 小说卷轴 · 诗画卷轴 · 青铜镜 ·
黄铜镜 · 木制镇纸 · 红木镇纸 · 青瓷茶器 · 彩瓷茶器 · 铜矛头 · 铁矛头 · 铜灯盘 · 铁灯盘 (the collectible/relic props). They take
the documented frozen-first-idle-frame path (see the `die()` comment in `render/spine.js`), which is right for an item
that is picked up rather than killed.
## 6. Implementation (2026-10-06, client-only)
- `public/js/render/units.js`: new `DIE_FADE_TAIL = 0.55` (the fade tail `die()` already appended as a literal) and
`DIE_CLIP_MAX = 8`; `dying = clamp(d / rate, 0.35, DIE_CLIP_MAX) + DIE_FADE_TAIL`; the hover target as in §4; the three
`0.55` literals in `die()` / the knockdown branch / the fade now use `DIE_FADE_TAIL`.
- `test/render/unitview.test.js`: a dying flyer keeps `FLY_HOVER` through the Die clip and is under a quarter of it
inside the fade (a walker stays at 0); a data guard — no enemy Die clip of `data/assets.json` exceeds `DIE_CLIP_MAX`
(234 clips, the longest 7.97 s: the §3 durations re-checked from the manifest when this was merged in 0.2.2) — and
the 7.97 s clip is scheduled whole, the view still on screen 5 s in.
- The root-displacement sweep of §2 is the PR author's measurement (their script is not in the repository).
- Client-only: the server serves `units.js` per request, so a hard refresh (Ctrl+F5) applies it — no restart.
+18
View File
@@ -39,6 +39,10 @@ const rules = {
'preserve-caught-error': 'warn',
};
// ECMA-262 §21.3.2: the Math functions whose results are "implementation-approximated".
const APPROXIMATED_MATH = ['acos', 'acosh', 'asin', 'asinh', 'atan', 'atanh', 'atan2', 'cbrt', 'cos', 'cosh', 'exp',
'expm1', 'hypot', 'log', 'log1p', 'log10', 'log2', 'pow', 'sin', 'sinh', 'tan', 'tanh'];
const nodeFiles = [
'server/**/*.js',
'shared/**/*.js',
@@ -79,6 +83,20 @@ export default [
},
rules,
},
{
// The sim runs in every player's browser and on the server, and must give the same bits everywhere. ECMA-262
// leaves these Math functions (and **) implementation-approximated: engines differ in the last bits.
files: ['server/sim/**/*.js'],
rules: {
'no-restricted-properties': ['error', ...APPROXIMATED_MATH.map((property) => ({
object: 'Math', property, message: 'implementation-approximated (engines differ in the last bits): use server/sim/detmath.js',
}))],
'no-restricted-syntax': ['error',
{ selector: "BinaryExpression[operator='**']", message: 'implementation-approximated: use powi from server/sim/detmath.js' },
{ selector: "AssignmentExpression[operator='**=']", message: 'implementation-approximated: use powi from server/sim/detmath.js' },
],
},
},
{
// Node test runner, plus browser tests whose page.evaluate callbacks use DOM globals.
// PIXI is the page global those render tests read inside evaluate().
+1
View File
@@ -18,6 +18,7 @@
"include": [
"shared/**/*.js",
"server/sim/rng.js",
"server/sim/detmath.js",
"server/sim/dir.js",
"server/sim/constants.js",
"types/**/*.js"
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "stronghold-protocol-alliance",
"version": "0.2.1",
"version": "0.2.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "stronghold-protocol-alliance",
"version": "0.2.1",
"version": "0.2.2",
"hasInstallScript": true,
"license": "GPL-3.0-or-later",
"dependencies": {
+2 -1
View File
@@ -1,6 +1,6 @@
{
"name": "stronghold-protocol-alliance",
"version": "0.2.1",
"version": "0.2.2",
"private": true,
"description": "明日方舟「卫戍协议:盟约」非官方同人联机网页复刻(1–4 人合作,Node.js 服务器 + 浏览器客户端)",
"license": "GPL-3.0-or-later",
@@ -38,6 +38,7 @@
"lint": "eslint .",
"typecheck": "tsc --noEmit --checkJs -p jsconfig.json",
"check:imports": "node tools/check-imports.mjs",
"ci": "node tools/ci.mjs",
"package": "node tools/package.mjs",
"package:lite": "node tools/package.mjs --lite"
},
+2
View File
@@ -515,6 +515,8 @@ button.bpop__harmony:hover { border-style: solid; color: #fff; }
/* ---- direction wheel (ui/facingWheel.js, research 09 §1.2): white diamond, 4 chevrons, "✕ 点击取消", orange range ---- */
.fwheel { position: fixed; inset: 0; z-index: 6; touch-action: none; user-select: none; -webkit-user-select: none; cursor: crosshair; }
/* the wheel takes the keyboard focus when it opens (Enter confirms there, GitHub #394): no ring around the whole screen */
.fwheel:focus-visible { outline: none; }
.fwheel__stripes { position: absolute; inset: 0; width: 100%; height: 100%; overflow: visible; pointer-events: none; }
.fwheel__dia { position: absolute; transform: translate(-50%, -50%); pointer-events: none; animation: fwheel-in 160ms var(--ease-out); }
@keyframes fwheel-in { from { opacity: 0; transform: translate(-50%, -50%) scale(.82); } }
+3
View File
@@ -156,6 +156,9 @@
.scard__idesc { font-size: .12rem; line-height: 1.45; color: var(--text-lo); display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }
.scard--sold { background: repeating-linear-gradient(135deg, rgba(255, 255, 255, .02) 0 .08rem, transparent .08rem .16rem), rgba(8, 11, 10, .75); border: 1px dashed var(--line-2); justify-content: center; align-items: center; }
.scard__soldtxt { display: flex; flex-direction: column; align-items: center; gap: .02rem; font-size: .16rem; color: var(--text-dim); }
/* a slot the 调度中心's upgrade opened and nothing filled yet (ui/shopBar.js EmptyCard, also .scard--sold): the bare frame — no hatching,
no SOLD OUT text */
.scard--sold.scard--empty { background: rgba(8, 11, 10, .4); border-color: var(--line); }
/* ---- funds card ---- */
.funds { width: 1.18rem; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: .08rem; padding: .06rem; border-left: 1px solid var(--line); }
+39
View File
@@ -30,6 +30,21 @@
.gtop > * { pointer-events: auto; }
.gtop__left { display: flex; align-items: center; gap: .16rem; }
.gtop__meta { display: flex; flex-direction: column; align-items: flex-start; gap: .06rem; }
/* the latency pill and, beside it, the 观战席 capsule (ui/hud.js SpectatorPill: an eye + how many are watching; opens the roster) */
.gtop__net { display: flex; align-items: center; gap: .08rem; }
.specpill { --btn-bg: rgba(0, 0, 0, .5); --btn-bg-hover: rgba(0, 0, 0, .66); --btn-bd: var(--line); height: .32rem; min-width: 0; padding: 0 .12rem; gap: .05rem; border-radius: 99px; }
.specpill .btn__icon { width: .18rem; height: .18rem; color: var(--mint-500); }
.specpill__num { font-size: .18rem; font-weight: 700; color: var(--mint-400); }
.specpill.is-me { --btn-bd: var(--mint-600); }
/* the roster inside its modal */
.spec__roster { display: flex; flex-direction: column; gap: .06rem; margin: 0 0 .1rem; padding: 0; list-style: none; }
.spec__row { display: flex; align-items: center; gap: .1rem; min-height: .44rem; padding: .04rem .1rem; background: rgba(255, 255, 255, .03); border: 1px solid transparent; }
.spec__row.is-me { border-color: var(--mint-700); background: rgba(45, 212, 191, .08); }
.spec__row.is-offline .spec__name { color: var(--text-dim); }
.spec__ico { width: .18rem; height: .18rem; flex: none; color: var(--mint-500); }
.spec__row.is-offline .spec__ico { color: var(--text-dim); }
.spec__name { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: .19rem; color: var(--text-hi); }
.spec__hint { margin: .04rem 0 0; font-size: .15rem; line-height: 1.45; }
.gtop__exit { box-shadow: 0 .06rem .2rem rgba(0, 0, 0, .5); }
.gtop__center {
--bk-c: var(--mint-700);
@@ -501,6 +516,10 @@
@keyframes spin { to { transform: rotate(360deg); } }
/* ---- settings modal ------------------------------------------------------------------------------------------------ */
/* the 设置 trigger of the lobby and the room (ui/settings.js SettingsButton, GitHub #238): the 玩法说明 button's twin; its gear sits
inside the label, so the label lays glyph and text out like the button itself */
.settings-btn .btn__label { display: inline-flex; align-items: center; gap: .1rem; }
.settings-btn .btn__icon { color: var(--mint-500); }
.set-list { display: flex; flex-direction: column; gap: .16rem; padding-top: .06rem; }
.set-row { display: grid; grid-template-columns: 3.3rem 1fr auto; align-items: center; gap: .18rem; min-height: .44rem; }
.set-row__label { display: flex; align-items: center; gap: .1rem; font-size: .18rem; color: var(--text-hi); white-space: nowrap; }
@@ -543,6 +562,26 @@
.set-keys__more > summary { padding: .06rem 0; font-size: .15rem; color: var(--mint-400); cursor: pointer; }
.set-keys__more[open] > summary { margin-bottom: .08rem; }
@keyframes set-key-wait { to { box-shadow: 0 0 .12rem rgba(246, 163, 41, .45); } }
/* 问题反馈 (ui/settings.js DiagSection): laid out like 快捷键 — a head with its button, a hint, the result line; the
recorded-error count in the figure face of the slider values (amber once there is one); the report box only when the
clipboard refused it, styled like 干员调配's 导出 box (screens/loadout.css .lo-io__text) */
.set-diag { display: flex; flex-direction: column; gap: .08rem; padding-top: .1rem; border-top: 1px solid var(--line); }
.set-diag__meta { display: flex; flex-wrap: wrap; align-items: baseline; justify-content: space-between; gap: .04rem .2rem; }
.set-diag__stat { display: inline-flex; align-items: baseline; gap: .1rem; font-size: .14rem; color: var(--text-lo); }
.set-diag__count { font-family: var(--font-num); font-size: .2rem; line-height: 1; color: var(--mint-400); }
.set-diag__count.is-warn { color: var(--amber); }
.set-diag__url { min-width: 0; font-size: .13rem; color: var(--text-dim); overflow-wrap: anywhere; -webkit-user-select: all; user-select: all; }
.set-diag__battle-hint { margin-top: -.06rem; }
.set-diag__text {
box-sizing: border-box; width: 100%; height: max(2rem, 140px); min-height: 120px; resize: vertical;
padding: max(.1rem, 8px) max(.12rem, 10px);
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: max(.13rem, 11px); line-height: 1.5;
color: var(--text-hi); background: rgba(4, 10, 8, .55); border: 1px solid var(--line-2); border-radius: 2px; outline: none;
white-space: pre-wrap; overflow-wrap: anywhere; overflow-y: auto;
}
.set-diag__text:focus { border-color: var(--mint-700); }
/* touch: 16 px, below which iOS zooms into the focused box (as 干员调配's 导出 box and ui/clipboard.js) */
.sp-coarse .set-diag__text { font-size: 16px; }
@media (max-width: 767px) { .set-keys__list { grid-template-columns: minmax(0, 1fr); } }
@media (max-height: 600px) and (pointer: coarse), (max-height: 600px) and (any-pointer: coarse) {
.set-key { height: max(.38rem, 30px); }
+102 -45
View File
@@ -138,56 +138,93 @@
.lo-toggle.is-on .lo-toggle__box { background: var(--mint-500); border-color: var(--mint-400); }
.lo-toggle.is-on .lo-toggle__box .icon { opacity: 1; }
/* ---- roster grid ------------------------------------------------------------------------------------------------------------ */
.lo-grid {
/* ---- roster: one list, one row per operator (0.2.2) ------------------------------------------------------------------------ */
/* The quick choices of PR #301 (by @farridge) as ONE list instead of three columns of cards (the owner's decision of
2026-10-08: 「三个竖排有点乱影响观感」): every row has the same four columns — the operator (portrait, name, bonds), the
skills, the elite's modules, 潜能 / 练度 — aligned under a sticky head row, so the eye runs down a column. Sizes: --q a
quick button (a skill / module), --art the portrait; a coarse pointer gets 40 px targets. */
.lo-list {
--q: max(.4rem, 32px); --qg: max(.05rem, 4px); --art: max(.52rem, 40px);
--cols: minmax(max(2.2rem, 110px), max(3.2rem, 260px)) calc(3 * var(--q) + 2 * var(--qg)) calc(4 * var(--q) + 3 * var(--qg)) max(2.2rem, 160px);
flex: 1 1 auto; min-height: 0; overflow: auto; -webkit-overflow-scrolling: touch; overscroll-behavior: contain;
display: grid; grid-template-columns: repeat(auto-fill, minmax(max(1.16rem, 64px), 1fr)); grid-auto-rows: max-content;
align-content: start; gap: .1rem; padding: .06rem .08rem .2rem .02rem;
scrollbar-width: thin; scrollbar-color: var(--line-3) transparent;
padding: 0 .06rem .2rem 0; scrollbar-width: thin; scrollbar-color: var(--line-3) transparent;
}
.lo-empty { grid-column: 1 / -1; padding: .4rem; text-align: center; font-size: max(.16rem, 11px); }
.sp-coarse .lo-list { --q: max(.44rem, 40px); }
.lo-list__head, .lo .lo-card { display: grid; grid-template-columns: var(--cols); justify-content: start; align-items: center; column-gap: max(.12rem, 8px); }
/* (the head has the rows' box — the same borders, transparent, and padding — so its columns are the rows' to the pixel) */
.lo-list__head {
position: sticky; top: 0; z-index: 2; padding: .05rem .1rem .05rem .07rem; border: 1px solid transparent; border-left-width: 3px;
font-size: max(.12rem, 9px); font-weight: 700; letter-spacing: .12em; color: var(--text-dim);
background: linear-gradient(180deg, #0d1110 70%, rgba(13, 17, 16, .85)); box-shadow: inset 0 -1px 0 var(--line);
}
.lo-list__h small { margin-left: .05rem; font-weight: 400; letter-spacing: .04em; color: var(--gold-2); opacity: .8; }
.lo-list__rows { display: flex; flex-direction: column; gap: max(.04rem, 3px); padding-top: max(.05rem, 4px); }
.lo-empty { padding: .4rem; text-align: center; font-size: max(.16rem, 11px); }
.lo-card {
.lo .lo-card {
--tc: var(--tier-1);
position: relative; display: flex; flex-direction: column; align-items: stretch; min-width: 0;
padding: 0; font: inherit; color: var(--text-hi); text-align: left; cursor: pointer;
background: linear-gradient(180deg, rgba(34, 41, 38, .95), rgba(14, 17, 16, .98));
border: 1px solid var(--line); box-shadow: inset 0 -2px 0 var(--tc);
transition: border-color var(--t-fast), transform var(--t-fast), box-shadow var(--t-fast);
touch-action: manipulation;
position: relative; min-width: 0; padding: max(.05rem, 4px) .1rem max(.05rem, 4px) .07rem;
background: rgba(255, 255, 255, .025); border: 1px solid var(--line); border-left: 3px solid var(--tc);
transition: background var(--t-fast), border-color var(--t-fast);
}
.lo-card--t1 { --tc: var(--tier-1); } .lo-card--t2 { --tc: var(--tier-2); } .lo-card--t3 { --tc: var(--tier-3); }
.lo-card--t4 { --tc: var(--tier-4); } .lo-card--t5 { --tc: var(--tier-5); } .lo-card--t6 { --tc: var(--tier-6); }
.lo-card:hover { border-color: var(--line-3); transform: translateY(-1px); }
.lo-card.is-sel { border-color: var(--mint-400); box-shadow: inset 0 -2px 0 var(--tc), 0 0 0 1px var(--mint-500), 0 0 .16rem rgba(23, 249, 183, .35); }
.lo-card:focus-visible { outline: 2px solid var(--mint-400); outline-offset: 1px; }
.lo-card__art { position: relative; display: block; aspect-ratio: 1; overflow: hidden; background: #1b201e; }
.lo-card__art img { width: 100%; height: 100%; object-fit: cover; display: block; }
.lo-card__art::after { content: ''; position: absolute; inset: 55% 0 0; background: linear-gradient(180deg, transparent, rgba(8, 10, 9, .85)); }
.lo-card__glyph { display: grid; place-items: center; width: 100%; height: 100%; font-size: .44rem; font-weight: 900; color: var(--text-dim); }
.lo-card__tier { position: absolute; left: .03rem; top: .03rem; z-index: 1; }
.lo-card__flag {
position: absolute; right: 0; top: 0; z-index: 1; width: .22rem; height: .22rem;
background: linear-gradient(225deg, var(--mint-400) 50%, transparent 50%);
}
/* 0.2.0 补位: a chess marked 未持有 on the 干员持有 tab (its stand-in fights with a fixed skill) */
.lo-card__sub { position: absolute; right: .03rem; top: .26rem; z-index: 1; padding: 0 .04rem; font-size: max(.11rem, 8px); font-weight: 700; line-height: 1.4; color: var(--ice-900); background: var(--ice); }
.lo .lo-card:hover { background: rgba(255, 255, 255, .045); border-color: var(--line-3); border-left-color: var(--tc); }
.lo .lo-card.is-sel { background: linear-gradient(90deg, rgba(78, 216, 175, .1), rgba(78, 216, 175, .02) 60%); border-color: var(--mint-700); border-left-color: var(--tc); box-shadow: 0 0 0 1px var(--mint-500) inset; }
.lo-card button:focus-visible, .lo-card select:focus-visible { outline: 2px solid var(--mint-400); outline-offset: 1px; }
.lo-card__pick { display: flex; align-items: center; gap: max(.1rem, 6px); min-width: 0; padding: 0; font: inherit; color: inherit; text-align: left; background: none; border: 0; cursor: pointer; touch-action: manipulation; }
.lo-card__art { position: relative; flex: none; width: var(--art); height: var(--art); overflow: hidden; background: #1b201e; border: 1px solid var(--line-2); }
.lo-card__art > img { width: 100%; height: 100%; object-fit: cover; display: block; }
.lo-card__glyph { display: grid; place-items: center; width: 100%; height: 100%; font-size: max(.24rem, 16px); font-weight: 900; color: var(--text-dim); }
.lo-card__tier { position: absolute; left: 1px; top: 1px; z-index: 1; transform-origin: 0 0; transform: scale(.8); }
.lo-card__id { display: flex; flex-direction: column; gap: max(.03rem, 2px); min-width: 0; }
.lo-card__name { font-size: max(.17rem, 12px); font-weight: 700; line-height: 1.2; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.lo-card__bonds { display: flex; flex-wrap: wrap; gap: max(.04rem, 3px); min-height: max(.16rem, 12px); }
.lo-card__bond, .lo-card__bonds .lo-bond__dot { width: max(.18rem, 13px); height: max(.18rem, 13px); object-fit: contain; }
.lo-card__flag { position: absolute; right: 0; top: 0; z-index: 1; width: max(.16rem, 10px); height: max(.16rem, 10px); background: linear-gradient(225deg, var(--mint-400) 50%, transparent 50%); pointer-events: none; }
/* 0.2.0 补位: a chess marked 未持有 on the 干员持有 tab — its stand-in fights with a fixed skill and no 潜能 / 练度; the choices
here apply once it is owned again */
.lo-card__sub { align-self: flex-start; padding: 0 .04rem; font-size: max(.11rem, 8px); font-weight: 700; line-height: 1.4; color: var(--ice-900); background: var(--ice); }
.lo .lo-card.is-standin .lo-card__skills, .lo .lo-card.is-standin .lo-card__mods, .lo .lo-card.is-standin .lo-cult { opacity: .55; }
.lo-locknote--standin { color: var(--ice); }
.lo-card__name {
position: absolute; left: .05rem; right: .05rem; top: calc(100% - max(.64rem, 34px)); z-index: 1;
font-size: max(.15rem, 10px); font-weight: 700; line-height: 1.2; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
text-shadow: 0 1px 2px #000;
.lo-card__skills, .lo-card__mods { display: flex; align-items: center; gap: var(--qg); min-width: 0; }
.lo-card__mods { overflow-x: auto; overscroll-behavior-x: contain; scrollbar-width: none; }
.lo-q {
position: relative; flex: none; display: grid; place-items: center; width: var(--q); height: var(--q); padding: 0;
font: inherit; color: var(--text-lo); background: none; border: 0; cursor: pointer; touch-action: manipulation;
}
.lo-card__kit { display: flex; align-items: center; justify-content: space-between; gap: .04rem; height: max(.3rem, 18px); padding: 0 .04rem; background: rgba(0, 0, 0, .4); }
.lo-card__sk { display: inline-flex; align-items: center; gap: .04rem; font-size: max(.13rem, 9px); color: var(--text-lo); }
.lo-card__sk b { font-family: var(--font-num); letter-spacing: .04em; }
.lo-card__sk.is-alt { color: var(--mint-400); }
.lo-card__mod {
display: inline-grid; place-items: center; min-width: max(.24rem, 15px); height: max(.2rem, 13px); padding: 0 .03rem;
font-family: var(--font-num); font-size: max(.13rem, 9px); font-weight: 700; color: var(--text-lo);
border: 1px solid var(--line-2); background: rgba(255, 255, 255, .04);
}
.lo-card__mod.is-alt { color: var(--text-on-mint); background: var(--gold-2); border-color: var(--gold); }
.lo-q--empty { border: 1px dashed rgba(255, 255, 255, .07); cursor: default; }
.lo-q:not(.is-on):not(.lo-q--empty) { opacity: .62; }
.lo-q:not(.is-on):not(.lo-q--empty):hover { opacity: 1; }
.lo-q__type { position: absolute; left: 0; bottom: 0; padding: 0 2px; font-size: max(.11rem, 8px); line-height: 1.3; color: var(--text-hi); background: rgba(0, 0, 0, .7); pointer-events: none; }
.lo-q--mod.is-on .lo-mglyph { border-color: var(--gold-2); box-shadow: 0 0 .08rem rgba(255, 198, 0, .35); }
/* 潜能 / 练度 (screens/cultivation.js): two compact selects side by side in the row's last column; at the defaults (潜能 6,
精英2 Lv.60 — the same on every row) they stay quiet, a changed one is mint */
.lo-cult { display: flex; align-items: center; gap: max(.04rem, 3px); min-width: 0; }
.lo .lo-cult .lo-select { min-height: 0; height: max(.32rem, 22px); padding-left: 0; border-color: transparent; background: rgba(255, 255, 255, .03); }
.lo .lo-cult .lo-select:hover, .lo .lo-cult .lo-select:focus-within { border-color: var(--line-3); }
.lo .lo-cult .lo-select select { width: 100%; min-width: 0; padding: 0 max(.2rem, 14px) 0 max(.06rem, 4px); font-size: max(.14rem, 10px); color: var(--text-lo); }
.lo-cult__pot { flex: 0 0 auto; }
.lo-cult__tier { flex: 1 1 auto; min-width: 0; }
.lo .lo-cult .lo-select.is-off { border-color: var(--mint-700); background: rgba(78, 216, 175, .08); }
.lo .lo-cult .lo-select.is-off select { color: var(--mint-400); font-weight: 700; }
.sp-coarse .lo .lo-cult .lo-select { height: max(.32rem, 28px); }
/* the detail's 潜能 / 练度 section */
.lo-sec--cult { padding: .08rem .1rem .1rem; margin-bottom: .14rem; background: rgba(0, 0, 0, .22); border: 1px solid var(--line); border-left: 3px solid var(--mint-500); }
.lo-sec--cult .lo-sec__head .lo-badge { margin-left: auto; }
.lo-cult__row { display: grid; grid-template-columns: max(.5rem, 30px) minmax(0, 1fr); align-items: center; gap: .08rem; }
.lo-cult__k { font-size: max(.13rem, 10px); color: var(--text-dim); letter-spacing: .08em; }
.lo .lo-cult__row .lo-seg { margin-left: 0; flex-wrap: wrap; justify-self: start; }
.lo-seg--pot button { min-width: max(.36rem, 26px); font-family: var(--font-num); }
.lo-seg--tier button { padding: 0 .1rem; }
.lo-cult__eff { margin: .02rem 0 0; font-size: max(.14rem, 10px); line-height: 1.5; color: var(--text-md); }
.lo-cult__eff b { color: var(--mint-400); margin-right: .04rem; }
.lo-cult__note { margin: 0; font-size: max(.12rem, 9px); line-height: 1.5; color: var(--text-dim); }
.lo-sec--cult.is-moot .lo-cult__row, .lo-sec--cult.is-moot .lo-cult__eff { opacity: .55; }
.lo-sec--cult.is-moot .lo-cult__note { color: var(--ice); }
/* ---- skill icon / module glyph ---------------------------------------------------------------------------------------------- */
.lo-sicon { position: relative; flex: none; display: inline-grid; place-items: center; width: .72rem; height: .72rem; background: #151a18; border: 1px solid var(--line-2); }
@@ -195,9 +232,13 @@
.lo-sicon--xs { width: max(.2rem, 13px); height: max(.2rem, 13px); border: 0; background: transparent; }
.lo-sicon--xs .lo-sicon__glyph { display: none; }
.lo-sicon--md { width: max(.72rem, 38px); height: max(.72rem, 38px); }
.lo-sicon--q { width: 86%; height: 86%; }
.lo-sicon__glyph { font-family: var(--font-num); font-size: max(.2rem, 12px); font-weight: 700; color: var(--text-lo); letter-spacing: .04em; }
.lo-sicon.is-on { border-color: #1ea0ff; }
.lo-sicon__outline { position: absolute; inset: -.05rem; width: calc(100% + .1rem) !important; height: calc(100% + .1rem) !important; object-fit: fill; pointer-events: none; }
/* the selected skill's frame (a 30 × 30 sprite, a 3 px ring to its edges) drawn .05rem outside the icon on every side, square:
theme.css `img { max-width: 100% }` clamped its width to the icon's while its height grew by .1rem, and `.lo-sicon img`
(more specific) made it `cover` — a tall, left-shifted frame with clipped side lines (GitHub #398, PR #399) */
.lo-sicon .lo-sicon__outline { position: absolute; inset: -.05rem; width: calc(100% + .1rem) !important; height: calc(100% + .1rem) !important; max-width: none; object-fit: fill; pointer-events: none; }
.lo-sicon__ring { position: absolute; inset: -.05rem; border: 2px solid #1ea0ff; pointer-events: none; }
.lo-mglyph { position: relative; flex: none; display: inline-grid; place-items: center; width: max(.56rem, 30px); height: max(.56rem, 30px);
@@ -205,6 +246,7 @@
.lo-mglyph img { width: 78%; height: 78%; object-fit: contain; }
.lo-mglyph__t { font-family: var(--font-num); font-size: max(.26rem, 15px); font-weight: 700; color: var(--gold-2); line-height: 1; }
.lo-mglyph--none { background: #121615; border-style: dashed; }
.lo-mglyph--q { width: 86%; height: 86%; }
.lo-mglyph__slash { position: absolute; inset: 22%; border-radius: 50%; border: 2px solid var(--text-dim); }
.lo-mglyph__slash::after { content: ''; position: absolute; left: 50%; top: -10%; bottom: -10%; width: 2px; background: var(--text-dim); transform: translateX(-50%) rotate(45deg); }
@@ -236,6 +278,11 @@
.lo-dhead__reset { position: absolute; right: .12rem; top: .12rem; }
.lo-detail__body { flex: 1 1 auto; min-height: 0; overflow: auto; -webkit-overflow-scrolling: touch; overscroll-behavior: contain; padding: .12rem .18rem .2rem; scrollbar-width: thin; scrollbar-color: var(--line-3) transparent; }
.lo-garrisons { margin-bottom: .18rem; }
.lo-garrisons .dgarrison__k { font-size: max(.14rem, 11px); }
.lo-garrisons .dgarrison__type { font-size: max(.12rem, 10px); }
.lo-garrisons .dgarrison__icon { width: max(.18rem, 14px); height: max(.18rem, 14px); }
.lo-garrisons .dgarrison__text { font-size: max(.15rem, 11px); }
.lo-sec { display: flex; flex-direction: column; gap: .08rem; }
.lo-sec + .lo-sec { margin-top: .18rem; }
.lo-sec__head { display: flex; align-items: center; gap: .12rem; min-height: max(.36rem, 22px); }
@@ -425,6 +472,9 @@
.diy-slot__sicon, .diy-slot__micon { flex: none; display: inline-grid; place-items: center; width: max(.24rem, 16px); height: max(.24rem, 16px); object-fit: contain; font-size: .8em; color: var(--text-lo); }
.diy-slot__micon--none { border: 1px solid var(--line-2); }
.diy-slot__acts { display: flex; flex-direction: column; gap: .04rem; }
/* 0.2.2: an owned pick's 潜能 / 练度 (screens/cultivation.js) — a prototype has neither (a dim line instead) */
.diy-slot__cult { display: block; margin-top: .03rem; font-size: max(.12rem, 9px); }
.diy-slot__cult .lo-cult { max-width: max(2.6rem, 200px); }
.diy-slot__bad { display: flex; align-items: center; gap: .04rem; margin: .02rem 0 0; font-size: max(.12rem, 9px); color: var(--amber); }
.diy-slot.is-bad { border-color: rgba(255, 196, 0, .45); }
.diy-tag { display: inline-flex; align-items: center; height: 1.5em; padding: 0 .4em; font-size: max(.11rem, 8px); font-weight: 700; border-radius: 1em; white-space: nowrap; }
@@ -527,9 +577,13 @@
position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0;
}
}
/* phones (the root scale is clamped at 40 px: less than ~14 rem of width): the detail slides over the roster */
/* phones and small tablets (up to 1000 px wide; 0.2.2 — 760 px before the roster became one list with quick choices): the
list takes the whole width and the detail slides over it — a row's quick choice keeps the list, its portrait / name
opens the detail */
@media (max-width: 760px) {
.lo-top { padding: 0 .16rem; }
}
@media (max-width: 1000px) {
.lo-body { grid-template-columns: minmax(0, 1fr); }
.lo-detail-wrap { display: none; }
.lo-body.is-detail .lo-roster { display: none; }
@@ -540,11 +594,14 @@
}
.lo-detail-back .icon { width: .2rem; height: .2rem; }
}
/* narrow phones: the row's bonds give their line to the name, the columns keep their place */
@media (max-width: 700px) {
.lo-card__bonds { display: none; }
}
@media (max-height: 520px) {
.lo-note { display: none; }
.lo-dhead { grid-template-columns: max(1rem, 48px) minmax(0, 1fr); padding: .1rem .14rem; }
.lo-dhead__en { display: none; }
.lo-grid { grid-template-columns: repeat(auto-fill, minmax(max(1rem, 58px), 1fr)); }
}
/* the shared Button / TextField scale with the root (40 px/rem on phones ⇒ 6–7 px text, 14–20 px tall): the overlay's
own controls (返回, 全部恢复默认, 恢复默认, the search field) keep a readable size and a tappable height (review fix) */
@@ -555,7 +612,7 @@
.lo .lo-search .field__icon { width: max(.22rem, 14px); height: max(.22rem, 14px); }
.sp-coarse .lo-chip, .sp-coarse .lo-toggle, .sp-coarse .lo-select { min-height: 30px; }
.sp-coarse .lo-seg button { min-height: 28px; }
@media (prefers-reduced-motion: reduce) { .lo, .lo-card { animation: none; transition: none; } }
@media (prefers-reduced-motion: reduce) { .lo, .lo .lo-card { animation: none; transition: none; } }
/* 导出 / 导入 (干员调配 presets): the dialog is the shared Modal, rendered NEXT TO `.lo` (a sibling) rather than inside
it — so components.css `.modal { position: fixed }` and its z-index above the overlay apply as they do everywhere
+7 -69
View File
@@ -8,8 +8,6 @@
.result__grid { position: absolute; inset: 0; z-index: 0; pointer-events: none;
background: linear-gradient(rgba(78, 216, 175, .04) 1px, transparent 1px) 0 0 / .6rem .6rem, linear-gradient(90deg, rgba(78, 216, 175, .04) 1px, transparent 1px) 0 0 / .6rem .6rem;
-webkit-mask-image: radial-gradient(70% 70% at 25% 50%, #000, transparent); mask-image: radial-gradient(70% 70% at 25% 50%, #000, transparent); }
/* The settlement can scroll in portrait; the game-wide rotate overlay must not cover it. */
.sp-rotatable.sp-result .rotate-hint { display: none; }
.result__main { position: relative; z-index: 1; flex: 1; min-height: 0; display: grid; grid-template-columns: 5.6rem 1fr; gap: .5rem; padding: .36rem .5rem .3rem .6rem; }
.result__hero { display: flex; flex-direction: column; align-items: flex-start; gap: .16rem; padding-top: .3rem; min-height: 0; }
.result__logoimg { height: 1.1rem; width: auto; opacity: .92; }
@@ -44,57 +42,29 @@
@keyframes card-in { from { opacity: 0; transform: translateX(.3rem); } }
.rcard.is-self { border-left-color: var(--mint-500); background: linear-gradient(90deg, rgba(78, 216, 175, .08), rgba(11, 15, 13, .9) 40%); }
.rcard.is-dead { opacity: .8; }
.rcard__head {
display: grid; grid-template-columns: auto minmax(0, 1fr) auto 2.3rem;
grid-template-areas: 'avatar who rounds title' 'lineup lineup lineup lineup'; gap: .12rem .18rem; align-items: center;
}
.rcard__head .pavatar { --pa: .6rem; grid-area: avatar; }
.rcard__who { display: flex; flex-direction: column; gap: .03rem; min-width: 0; grid-area: who; }
.rcard__head { display: grid; grid-template-columns: auto minmax(1.5rem, 2.1rem) minmax(0, 1fr) auto 2.3rem; gap: .18rem; align-items: center; }
.rcard__head .pavatar { --pa: .6rem; }
.rcard__who { display: flex; flex-direction: column; gap: .03rem; min-width: 0; }
.rcard__name { font-size: .22rem; display: flex; align-items: center; gap: .08rem; }
.rcard__ai, .rcard__you { font-size: .11rem; padding: 0 .05rem; font-weight: 700; }
.rcard__ai { background: #a9c3d8; color: #0b1520; }
.rcard__you { background: var(--mint-500); color: var(--text-on-mint); }
.rcard__band { display: inline-flex; align-items: center; gap: .06rem; font-size: .15rem; color: var(--text-lo); }
.rcard__round { display: flex; flex-direction: column; align-items: center; grid-area: rounds; }
.rcard__round { display: flex; flex-direction: column; align-items: center; }
.rcard__round b { font-size: .34rem; line-height: 1; color: #fff; }
.rcard__gain { display: flex; gap: .08rem; margin-top: .03rem; font-size: .13rem; color: var(--gold); }
.rcard__gain span { display: inline-flex; align-items: center; gap: .02rem; }
.rcard__gain .icon { width: .14rem; height: .14rem; }
.rcard__gain b { font-size: .14rem; color: var(--gold); }
.rcard__title { display: flex; align-items: center; gap: .08rem; padding: .06rem .14rem .06rem .06rem; border: 1px solid rgba(255, 198, 0, .4); background: rgba(255, 198, 0, .06); min-width: 0; grid-area: title; }
.rcard__title { display: flex; align-items: center; gap: .08rem; padding: .06rem .14rem .06rem .06rem; border: 1px solid rgba(255, 198, 0, .4); background: rgba(255, 198, 0, .06); min-width: 0; }
.rcard__title--none { border-style: dashed; border-color: var(--line); background: none; height: .64rem; }
.rcard__title > span { display: flex; flex-direction: column; }
.rcard__title b { font-size: .19rem; color: var(--gold); }
.rcard__ticon { width: .5rem; height: .5rem; object-fit: contain; }
.rcard__title .icon { width: .3rem; height: .3rem; color: var(--gold); }
.rcard__mid { display: flex; flex-direction: column; justify-content: center; gap: .08rem; min-width: 0; grid-area: lineup; }
.rcard__mid { display: flex; flex-direction: column; justify-content: center; gap: .06rem; min-width: 0; }
.rcard__lineup .uthumb { --ut: .46rem; }
.rcard__lineup { display: flex; flex-wrap: wrap; gap: .05rem; }
.runit { width: max(.9rem, 56px); min-width: 0; }
.runit__open {
display: flex; flex-direction: column; align-items: center; gap: .04rem; width: 100%; padding: .04rem;
color: var(--text-md); background: #101613; border: 1px solid var(--line-2); cursor: pointer;
}
.runit__open:hover, .runit__open:focus-visible { border-color: var(--mint-400); background: #17231d; }
.runit__open:focus-visible { outline: 2px solid var(--mint-400); outline-offset: 2px; }
.runit__open.is-golden { border-color: var(--gold); }
.runit__art { position: relative; display: grid; place-items: center; width: 100%; height: max(1.4rem, 80px); overflow: hidden; background: radial-gradient(ellipse at bottom, #2a3d32, #111713); }
.runit__portrait { width: 100%; height: 100%; object-fit: contain; object-position: bottom; }
.runit__art > .uthumb { --ut: .56rem; }
.runit__tier { position: absolute; left: .02rem; top: .02rem; }
.runit__standin { position: absolute; bottom: 0; right: 0; padding: .02rem .04rem; background: #101613; font-size: max(.1rem, 10px); }
.runit__items { display: flex; gap: .04rem; }
.runit__items .uthumb, .runit__empty { --ut: max(.28rem, 22px); width: max(.28rem, 22px); height: max(.28rem, 22px); }
.runit__empty { display: grid; place-items: center; border: 1px dashed var(--line-2); font-size: .11rem; color: var(--text-dim); }
.result-unit__body { display: grid; grid-template-columns: 1.5rem minmax(0, 1fr); gap: .24rem; }
.result-unit__portrait { display: flex; flex-direction: column; align-items: center; gap: .1rem; }
.result-unit__portrait > img { width: 100%; max-height: 3rem; object-fit: contain; }
.result-unit__elite { display: inline-block; margin-left: .06rem; padding: .02rem .06rem; font-size: .12rem; color: var(--gold); border: 1px solid var(--gold); }
.result-unit__equipment h3 { display: flex; justify-content: space-between; gap: .1rem; margin-bottom: .12rem; font-size: max(.18rem, 14px); }
.result-unit__item { display: flex; gap: .12rem; padding: .14rem 0; border-top: 1px solid var(--line-2); }
.result-unit__item > .uthumb { flex-shrink: 0; }
.result-unit__item > div { min-width: 0; }
.result-unit__item h4 { margin-bottom: .06rem; color: var(--text-hi); font-size: max(.16rem, 13px); }
.result-unit__item p { font-size: max(.14rem, 12px); line-height: 1.6; overflow-wrap: anywhere; }
.rcard__noinfo { font-family: var(--font-display); font-size: .14rem; letter-spacing: .3em; color: var(--text-dim); padding: .14rem .2rem; border: 1px dashed var(--line-2); }
.rcard__bonds { display: flex; gap: .05rem; flex-wrap: wrap; }
.rbond { display: inline-flex; align-items: center; gap: .04rem; padding: .01rem .07rem .01rem .03rem; border: 1px solid var(--line-2); font-size: .13rem; color: var(--text-lo); }
@@ -106,35 +76,3 @@
.rstat.is-best b { color: var(--gold); }
.result__foot { margin-top: auto; padding-top: .2rem; display: flex; }
.result__foot .btn { min-width: 3.4rem; }
@media (max-width: 1000px) {
.result__main { grid-template-columns: 3.4rem minmax(0, 1fr); gap: .2rem; padding: .2rem; }
.result__hero { padding-top: 0; }
.result__headline { font-size: .6rem; }
.result__sub { font-size: max(.16rem, 10px); letter-spacing: 0; }
.result__logoimg { height: .65rem; }
.result__rnum { font-size: 1rem; }
.result__foot .btn { min-width: 0; }
.rcard__name { font-size: max(.22rem, 13px); }
.rcard__band, .rcard__title b { font-size: max(.15rem, 11px); }
.rcard__stats { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.rstat { font-size: max(.13rem, 10px); }
.rstat b { font-size: max(.18rem, 12px); }
.result-unit { width: min(6.8rem, 94vw); }
.result-unit .modal__title { font-size: 18px; }
.result-unit .modal__actions .btn { min-width: 64px; min-height: 44px; font-size: 14px; }
.result-unit__elite { font-size: max(.12rem, 10px); }
.result-unit__item > .uthumb { --ut: max(.5rem, 36px); }
}
@media (max-width: 640px) {
.result__main { display: flex; flex-direction: column; overflow: auto; }
.result__hero { flex-shrink: 0; display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: .12rem; align-items: center; }
.result__logo, .result__sub, .result__medals, .result__time { display: none; }
.result__rounds { grid-column: 1; }
.result__foot { grid-column: 2; grid-row: 2; margin: 0; padding: 0; }
.result__players { flex-shrink: 0; overflow: visible; }
.rcard__head { grid-template-columns: auto minmax(0, 1fr) auto; grid-template-areas: 'avatar who rounds' 'title title title' 'lineup lineup lineup'; }
.rcard__title--none { display: none; }
.result-unit { width: 94vw !important; }
.result-unit__body { grid-template-columns: 1rem minmax(0, 1fr); gap: .12rem; }
}
+7
View File
@@ -296,6 +296,13 @@
}
.dpick__opt.is-active .icon { opacity: 1; filter: drop-shadow(0 0 5px var(--d-color)); }
.dpick--ro { border: 0; background: none; gap: .18rem; align-items: center; font-size: .14rem; }
/* 「AI 队友最后选择」 (GitHub #338, co-op): one switch styled like a difficulty option, under the difficulty picker */
.room-bar__opts { display: flex; flex-direction: column; align-items: flex-start; gap: .08rem; min-width: 0; }
.ailast { display: flex; align-items: center; gap: .12rem; font-size: .14rem; }
.ailast__opt { --d-color: var(--mint-400); height: .36rem; padding: 0 .14rem; border: 1px solid var(--line-2); background: rgba(0, 0, 0, .3); font-size: .15rem; letter-spacing: .04em; }
.ailast__opt:last-child { border-right: 1px solid var(--line-2); }
.ailast__opt .icon { opacity: .25; }
.ailast__opt .icon.is-on { opacity: 1; }
.ready-count[hidden] { display: none; }
.ready-count { display: flex; align-items: baseline; gap: .06rem; font-size: .16rem; }
+193
View File
@@ -0,0 +1,193 @@
/* ==========================================================================================
screens/stats.css — 统计数据 (js/screens/stats.js): the local match record as a full-screen overlay in the grammar of
干员调配 (screens/loadout.css) — a top bar with the back button, four tabs (总览 / 策略 / 战斗累计 / 对局记录) and
导出 / 导入 / 清空, a note line, a scrolling body of number cards, tables, the six 评语 and the history list; a stored
match re-viewed as its settlement fills the overlay (.st--replay).
Sizes are design px ÷ 100 (theme.css root scale); text that must stay readable on phones uses max(.xxrem, N px)
(the root scale is clamped at 40 px there, so a bare .15rem would be 6 px). Ported from PR #323 by @2321Robin.
========================================================================================== */
.st {
position: fixed; inset: 0; z-index: 55; /* like .lo: above every screen (1) and the in-match banners (40), below the connection
banner (60), modals / confirm dialogs (80), toasts and tooltips */
display: flex; flex-direction: column;
padding: var(--sa-t, 0px) var(--sa-r, 0px) var(--sa-b, 0px) var(--sa-l, 0px);
background: var(--bg-0);
color: var(--text-hi);
outline: none;
animation: st-in 200ms var(--ease-out) backwards;
overscroll-behavior: contain;
}
@keyframes st-in { from { opacity: 0; } }
.st__bg {
position: absolute; inset: 0; pointer-events: none;
background:
radial-gradient(70% 90% at 18% 10%, rgba(78, 216, 175, .07), transparent 60%),
radial-gradient(60% 70% at 100% 100%, rgba(246, 163, 41, .05), transparent 60%),
linear-gradient(rgba(78, 216, 175, .035) 1px, transparent 1px) 0 0 / .6rem .6rem,
linear-gradient(90deg, rgba(78, 216, 175, .035) 1px, transparent 1px) 0 0 / .6rem .6rem,
linear-gradient(180deg, #0f1312, #090b0a);
}
.st-top, .st-note, .st-body { position: relative; }
/* the app screens behind the overlay do not need to animate */
.sp-stats-open .app-root > .screen { visibility: hidden; }
/* a stored match re-viewed as its settlement (the result screen fills the overlay) */
.st--replay { background: var(--bg-0); }
/* ---- top bar ----------------------------------------------------------------------------------------------------- */
.st-top {
flex: none; display: grid; grid-template-columns: minmax(max-content, 1fr) minmax(0, auto) minmax(max-content, 1fr); align-items: center; gap: .2rem;
height: max(1rem, 48px); padding: 0 .36rem;
background: linear-gradient(180deg, rgba(8, 10, 9, .95), rgba(10, 13, 12, .6));
border-bottom: 1px solid var(--line);
}
.st-top::after {
content: ''; position: absolute; left: .36rem; right: .36rem; bottom: -1px; height: 1px;
background: linear-gradient(90deg, var(--mint-700), transparent 26%, transparent 74%, var(--mint-700));
}
.st-top__left { justify-self: start; display: flex; align-items: center; gap: .14rem; }
.st-top__center { display: flex; flex-direction: column; align-items: center; gap: .02rem; min-width: 0; }
.st-top__right { justify-self: end; display: flex; align-items: center; gap: .16rem; min-width: 0; }
.st-tabs { display: flex; align-items: stretch; gap: .04rem; max-width: 100%; overflow-x: auto; scrollbar-width: none; }
.st-tab {
display: inline-flex; align-items: center; padding: .02rem .16rem; font: inherit; font-size: max(.24rem, 13px); font-weight: 900;
letter-spacing: .12em; line-height: 1.2; color: var(--text-lo); background: transparent; border: 0; border-bottom: 2px solid transparent; cursor: pointer;
touch-action: manipulation; white-space: nowrap;
}
.st-tab:hover { color: var(--text-hi); }
.st-tab.is-on { color: var(--text-hi); border-bottom-color: var(--mint-500); }
/* the note lines under the bar: where the data lives, what went wrong with the stored value */
.st-note {
flex: none; display: flex; align-items: center; gap: .08rem; margin: 0; padding: .07rem .36rem;
font-size: max(.15rem, 10px); color: var(--text-lo);
background: linear-gradient(90deg, rgba(78, 216, 175, .08), transparent 70%);
border-bottom: 1px solid rgba(47, 58, 53, .6);
}
.st-note .icon { width: max(.16rem, 11px); height: max(.16rem, 11px); color: var(--mint-500); flex: none; }
.st-note.is-warn { color: var(--amber); background: linear-gradient(90deg, rgba(246, 163, 41, .12), transparent 70%); }
.st-note.is-warn .icon { color: var(--amber); }
/* ---- body ------------------------------------------------------------------------------------------------------------ */
.st-body {
flex: 1 1 auto; min-height: 0; overflow-y: auto; -webkit-overflow-scrolling: touch; overscroll-behavior: contain;
padding: .2rem .36rem .32rem; scrollbar-width: thin; scrollbar-color: var(--line-3) transparent;
}
.st-body > * { max-width: max(12.4rem, 760px); margin-left: auto; margin-right: auto; }
.st-loading { flex: 1; display: grid; place-items: center; min-height: 2rem; }
.st-empty { margin: .6rem auto; text-align: center; font-size: max(.2rem, 13px); color: var(--text-lo); letter-spacing: .06em; }
.st-sec__h { display: flex; align-items: baseline; gap: .12rem; margin: 0 0 .1rem; font-size: max(.22rem, 14px); font-weight: 800; letter-spacing: .1em; color: var(--text-hi); }
.st-sub { margin: .22rem 0 .08rem; font-size: max(.17rem, 12px); font-weight: 700; letter-spacing: .08em; color: var(--text-md); }
/* number cards */
.st-cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(max(1.5rem, 96px), 1fr)); gap: .08rem; }
.st-card {
display: flex; flex-direction: column; gap: .02rem; min-width: 0; padding: .08rem .12rem .07rem;
background: rgba(255, 255, 255, .03); border: 1px solid var(--line);
}
.st-card--mint { border-color: var(--mint-800); background: var(--mint-a10); }
.st-card--gold { border-color: rgba(246, 163, 41, .45); background: rgba(246, 163, 41, .07); }
.st-card__num { font-size: max(.3rem, 17px); font-weight: 900; line-height: 1.15; color: var(--text-hi); overflow-wrap: anywhere; }
.st-card--mint .st-card__num { color: var(--mint-400); }
.st-card--gold .st-card__num { color: var(--amber); }
.st-card__label { font-size: max(.14rem, 10px); color: var(--text-md); letter-spacing: .06em; }
.st-split { display: flex; gap: .26rem; margin: .1rem 0 .14rem; font-size: max(.15rem, 11px); color: var(--text-md); }
.st-split b { margin-left: .06rem; font-size: max(.18rem, 12px); color: var(--text-hi); }
/* tables (难度 / 策略 / 对局记录) */
.st-tablewrap { overflow-x: auto; }
.st-table { width: 100%; border-collapse: collapse; font-size: max(.17rem, 11px); }
.st-table th {
padding: .05rem .1rem; text-align: left; font-size: max(.14rem, 10px); font-weight: 600; letter-spacing: .08em;
color: var(--text-lo); border-bottom: 1px solid var(--line-2); white-space: nowrap;
}
.st-table td { padding: .06rem .1rem; color: var(--text-md); border-bottom: 1px solid var(--line); vertical-align: middle; }
/* the per-difficulty and per-strategy tables stay narrow enough to read across; the history takes the width */
.st-table:not(.st-table--history) { max-width: max(9.4rem, 560px); }
.st-table th.num, .st-table td.num { text-align: right; }
.st-table tbody tr:hover td { background: rgba(78, 216, 175, .05); }
.st-table td.st-diff { color: var(--d-color, var(--text-md)); font-weight: 700; white-space: nowrap; }
.st-band { display: inline-flex; align-items: center; gap: .06rem; color: var(--text-hi); white-space: nowrap; vertical-align: middle; }
.st-band .bandicon { flex: none; }
.st-t { font-family: var(--font-num); font-size: max(.15rem, 10px); color: var(--text-lo); white-space: nowrap; }
.st-rate { white-space: nowrap; }
.st-rate b { color: var(--text-hi); font-weight: 700; }
.st-bar { display: inline-block; width: max(.8rem, 48px); height: max(.07rem, 4px); margin-right: .1rem; vertical-align: middle; background: var(--line); }
.st-bar i { display: block; height: 100%; background: var(--mint-500); }
/* 对局记录: result colours, the 不计入统计 chip, clickable rows */
.st-res { white-space: nowrap; }
.t-win { color: var(--mint-400); font-weight: 800; }
.t-lose { color: var(--red-premium); font-weight: 800; }
.t-quit { color: var(--amber); font-weight: 800; }
.st-chip {
display: inline-block; margin-left: .08rem; padding: 0 .06rem; font-style: normal; font-size: max(.12rem, 9px); letter-spacing: .04em;
color: var(--text-lo); border: 1px solid var(--line-2); white-space: nowrap;
}
.st-table tr.is-excluded td { opacity: .6; }
.st-table tr.is-clickable { cursor: pointer; }
.st-table tr.is-clickable:hover td, .st-table tr.is-clickable:focus-visible td { background: rgba(78, 216, 175, .1); color: var(--text-hi); }
.st-table tr.is-clickable:focus-visible { outline: 2px solid var(--mint-400); outline-offset: -2px; }
.st-go { width: .24rem; color: var(--text-lo); }
.st-go .icon { width: max(.18rem, 12px); height: max(.18rem, 12px); }
.st-table tr.is-clickable:hover .st-go { color: var(--mint-400); }
.st-more { display: flex; justify-content: center; margin: .14rem 0 0; }
.st-hint { margin: .1rem 0 0; font-size: max(.13rem, 10px); line-height: 1.6; color: var(--text-lo); letter-spacing: .04em; }
/* the six 评语 with their counts */
.st-titles { display: grid; grid-template-columns: repeat(auto-fill, minmax(max(1.9rem, 120px), 1fr)); gap: .08rem; }
.st-title { display: flex; align-items: center; gap: .08rem; min-width: 0; padding: .06rem .1rem; background: rgba(255, 255, 255, .03); border: 1px solid var(--line); }
.st-title.is-none { opacity: .45; }
.st-title__icon { width: max(.38rem, 24px); height: max(.38rem, 24px); flex: none; object-fit: contain; }
.st-title__text { display: flex; flex-direction: column; min-width: 0; }
.st-title__text span { font-size: max(.15rem, 10px); color: var(--text-md); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.st-title__text b { font-size: max(.22rem, 14px); font-weight: 900; color: var(--amber); line-height: 1.1; }
/* what counts (the rule of ui/stats.js countsTowardStats) */
.st-rule { display: flex; flex-wrap: wrap; align-items: center; gap: .06rem; margin: .24rem 0 0; font-size: max(.14rem, 10px); line-height: 1.6; color: var(--text-lo); }
.st-rule .icon { width: max(.16rem, 11px); height: max(.16rem, 11px); flex: none; color: var(--mint-500); }
.st-rule b { color: var(--amber); font-weight: 700; }
/* 战斗累计 */
.st-sums { display: grid; grid-template-columns: repeat(auto-fill, minmax(max(2.4rem, 150px), 1fr)); gap: .06rem; }
.st-sum { display: flex; align-items: baseline; justify-content: space-between; gap: .08rem; padding: .08rem .12rem; background: rgba(255, 255, 255, .03); border: 1px solid var(--line); }
.st-sum span { font-size: max(.15rem, 10px); color: var(--text-md); }
.st-sum b { font-size: max(.22rem, 14px); font-weight: 800; color: var(--text-hi); }
/* the shared Button scales with the root (40 px/rem on phones ⇒ 6–7 px text, 14–20 px tall): the overlay's own controls keep a
readable size and a tappable height */
.st .st-back { height: max(.5rem, 32px); padding: 0 max(.2rem, 10px) 0 max(.12rem, 6px); font-size: max(.18rem, 12px); }
.st .st-top__right .btn, .st .st-more .btn { height: max(.36rem, 28px); padding: 0 max(.16rem, 8px); font-size: max(.15rem, 11px); }
/* the entry buttons of the lobby / room top bars: on the narrowest phones (≤ 700 px) the icon alone — the room's top bar has
no room for another label beside 玩法说明 (its title ran under the label at 640 px); the name stays aria-label / title */
@media (max-width: 700px) {
.stats-entry .btn__icon + .btn__label {
position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0;
}
}
/* ---- narrow / short viewports ------------------------------------------------------------------------------------------ */
@media (max-width: 1200px) {
.st-tab { padding: .02rem .08rem; letter-spacing: .04em; }
}
/* phones in landscape (up to 932 px wide): 返回 shows its icon only (the text stays its name) */
@media (max-width: 1000px) {
.st-top .btn__icon + .btn__label {
position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0;
}
}
@media (max-width: 760px) {
.st-top { padding: 0 .16rem; gap: .1rem; }
.st-top::after { left: .16rem; right: .16rem; }
.st-note { padding: .07rem .16rem; }
.st-body { padding: .12rem .16rem .2rem; }
}
@media (max-height: 520px) {
.st-note { display: none; }
.st-body { padding-top: .12rem; }
.st-split { margin: .06rem 0 .08rem; }
}
.sp-coarse .st-tab, .sp-coarse .st-table tr.is-clickable td { min-height: 30px; }
@media (prefers-reduced-motion: reduce) { .st { animation: none; } }
+3
View File
@@ -243,6 +243,9 @@
.title-invite .icon { width: .2rem; height: .2rem; }
.title-invite b { font-size: .2rem; letter-spacing: .2em; color: #ffd08a; }
.title-conn { display: flex; align-items: center; justify-content: center; gap: .1rem; min-height: .32rem; font-size: .14rem; color: var(--text-lo); }
/* 统计 (the local stats page) beside the language menu in the top-right corner */
.title-corner__tools { display: flex; align-items: center; justify-content: flex-end; gap: .1rem; margin-bottom: .1rem; }
.title-corner__tools .title-lang { margin-bottom: 0; }
/* language menu (中文 | English | …, ui/lang.js) in the top-right corner; more than four languages make it a list */
.title-lang { display: inline-flex; margin-bottom: .1rem; }
.title-lang button { min-width: .74rem; height: .3rem; font-size: .14rem; cursor: pointer; }
+128
View File
@@ -0,0 +1,128 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="color-scheme" content="dark" />
<title>Battle perf · 卫戍协议:盟约</title>
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Crect width='24' height='24' fill='%230c0f0e'/%3E%3Cpath fill='%234ed8af' d='M5 3h3v2h2V3h4v2h2V3h3v5l-2 2v7l2 2v2H5v-2l2-2v-7L5 8z'/%3E%3C/svg%3E" />
<link rel="stylesheet" href="/fonts/fonts.css" />
<link rel="stylesheet" href="/css/theme.css" />
<script type="importmap">
{ "imports": { "preact": "/vendor/preact.module.js", "preact/hooks": "/vendor/hooks.module.js", "htm": "/vendor/htm.module.js" } }
</script>
<!-- PixiJS 7 + pixi-spine as classic scripts (global PIXI / PIXI.spine), like the game page -->
<script src="/vendor/pixi.min.js"></script>
<script src="/vendor/pixi-spine.js"></script>
<style>
/* Battle perf panel: the game's own tokens (css/theme.css — green-grey glass, mint, Bender figures, bracket corners)
in px, not the game's viewport-scaled rem. The one loud element is the frame-time strip. */
html, body { margin: 0; height: 100%; background: var(--bg-0); overflow: hidden; }
#field { position: fixed; inset: 0; }
.bp { position: fixed; top: 10px; right: 10px; z-index: 5; width: 320px; max-width: calc(100vw - 20px); max-height: calc(100vh - 20px);
overflow: auto; background: var(--bg-glass); border: 1px solid var(--line); color: var(--text-md);
font: 13px/1.45 var(--font-cjk); -webkit-backdrop-filter: blur(6px); backdrop-filter: blur(6px); --bk-l: 10px; }
.bp.is-hidden, .bp [hidden] { display: none; }
.bp-body { display: grid; gap: 10px; padding: 10px 12px 12px; }
.bp-head { display: flex; align-items: center; gap: 8px; }
.bp-head h1 { margin: 0; font-size: 14px; font-weight: 700; color: var(--text-hi); flex: 1; }
.bp button, .bp select { font: 13px/1.2 var(--font-cjk); color: var(--text-hi); background: var(--bg-2); border: 1px solid var(--line-2);
padding: 5px 8px; cursor: pointer; min-height: 30px; }
.bp button:hover, .bp select:hover { border-color: var(--line-3); }
.bp button:focus-visible, .bp select:focus-visible, .bp summary:focus-visible { outline: 2px solid var(--mint-500); outline-offset: 1px; }
.bp button:disabled { opacity: .5; cursor: default; }
.bp-ctl { display: grid; grid-template-columns: 1fr auto; gap: 6px; }
.bp-ctl select { min-width: 0; }
.bp-ctl #spec { grid-column: 1 / -1; }
.bp-live { display: grid; grid-template-columns: auto 1fr; gap: 4px 14px; align-items: end; }
.bp-fps { font: 600 44px/0.95 var(--font-num); color: var(--text-hi); font-variant-numeric: tabular-nums; white-space: nowrap; }
.bp-fps small { font: 400 13px var(--font-cjk); color: var(--text-lo); margin-left: 4px; }
.bp-figs { display: grid; grid-template-columns: auto auto; gap: 1px 10px; justify-content: end; font-size: 12px; color: var(--text-lo); }
/* decimals: Bender's point is nearly invisible at small sizes (4.2 reads as 42), so small figures use the CJK face */
.bp-figs b { font: 600 13px var(--font-cjk); color: var(--text-hi); text-align: right; font-variant-numeric: tabular-nums; }
.bp-meta { grid-column: 1 / -1; font-size: 12px; color: var(--text-lo); }
.bp-strip { position: relative; height: 70px; background: var(--bg-0); border: 1px solid var(--line); }
.bp-strip canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
.bp-freeze { position: absolute; inset: 0; display: grid; place-items: center; text-align: center; padding: 0 12px;
font-size: 12px; color: var(--text-md); background: rgba(12, 15, 14, .72); }
.bp-key { display: flex; flex-wrap: wrap; gap: 2px 12px; font-size: 11.5px; color: var(--text-lo); }
.bp-key i { display: inline-block; width: 8px; height: 8px; margin-right: 4px; }
.bp .bp-measure { position: relative; overflow: hidden; width: 100%; min-height: 36px; font-weight: 700;
color: var(--text-on-mint); background: var(--mint-500); border-color: var(--mint-500); }
.bp .bp-measure:hover { background: var(--mint-400); border-color: var(--mint-400); }
.bp-fill { position: absolute; inset: 0; background: var(--mint-800); transform-origin: left; transform: scaleX(0); }
.bp .bp-measure.is-running { color: var(--text-hi); background: var(--mint-900); border-color: var(--mint-700); cursor: default; opacity: 1; }
.bp-measure.is-running .bp-fill { animation: bp-fill 10s linear forwards; }
.bp-measure span { position: relative; }
@keyframes bp-fill { to { transform: scaleX(1); } }
.bp-result { display: grid; gap: 8px; border-top: 1px solid var(--line); padding-top: 10px; }
.bp-verdict { display: flex; align-items: baseline; flex-wrap: wrap; gap: 2px 8px; color: var(--text-hi); font-weight: 700; }
.bp-verdict::before { content: ''; width: 10px; height: 10px; flex: none; align-self: center; background: var(--v, var(--mint-500)); }
.bp-verdict small { font-weight: 400; color: var(--text-lo); font-size: 12px; }
.bp-sum { display: grid; grid-template-columns: repeat(3, 1fr); gap: 6px; }
.bp-sum div { display: grid; background: var(--bg-1); border: 1px solid var(--line); padding: 5px 7px; min-width: 0; }
.bp-sum span { font-size: 11.5px; color: var(--text-lo); }
.bp-sum b { font: 700 16px/1.3 var(--font-cjk); color: var(--text-hi); font-variant-numeric: tabular-nums; }
.bp-result details { font-size: 12px; }
.bp-result summary { cursor: pointer; color: var(--text-md); }
.bp-report { margin: 6px 0 0; font: 12px/1.6 var(--font-cjk); color: var(--text-hi); white-space: pre-wrap; word-break: break-word;
background: var(--bg-0); border: 1px solid var(--line); padding: 6px 8px; max-height: 30vh; overflow: auto; user-select: text; }
/* collapsed: a chip with the live figures and a mini strip */
.bp .bp-chip { display: none; align-items: center; gap: 8px; width: 100%; padding: 4px 8px 4px 10px; background: none; border: 0; }
.bp-chip b { font: 600 20px/1 var(--font-num); color: var(--text-hi); font-variant-numeric: tabular-nums; }
.bp-chip span { font-size: 12px; color: var(--text-lo); white-space: nowrap; }
.bp-chip canvas { width: 72px; height: 22px; display: block; margin-left: 4px; }
.bp.is-collapsed { width: auto; }
.bp.is-collapsed .bp-body { display: none; }
.bp.is-collapsed .bp-chip { display: flex; }
@media (prefers-reduced-motion: reduce) { .bp-measure.is-running .bp-fill { animation: none; transform: scaleX(1); } }
</style>
</head>
<body>
<div id="field"></div>
<section class="bp brackets" id="panel" aria-label="战斗性能测试">
<button class="bp-chip" id="expand" type="button" aria-expanded="false" aria-controls="bp-body" title="展开面板">
<b id="chip-fps">–</b><span id="chip-ms">FPS</span><canvas id="chip-strip" aria-hidden="true"></canvas>
</button>
<div class="bp-body" id="bp-body">
<div class="bp-head"><h1>战斗性能测试</h1><button id="collapse" type="button" aria-expanded="true" aria-controls="bp-body">收起</button></div>
<div class="bp-ctl">
<select id="spec" aria-label="战斗场景"></select>
<select id="quality" aria-label="画质"><option value="high">高画质</option><option value="medium">中画质</option><option value="low">低画质</option></select>
<button id="restart" type="button">重新开始</button>
</div>
<div class="bp-live">
<div class="bp-fps"><span id="fps">–</span><small>FPS</small></div>
<div class="bp-figs">
<span>帧耗时</span><b id="f-frame">–</b>
<span>渲染 CPU</span><b id="f-view">–</b>
<span>模拟单步</span><b id="f-sim">–</b>
</div>
<div class="bp-meta" id="meta">正在加载战斗…</div>
</div>
<div class="bp-strip" title="近 10 秒逐帧耗时">
<canvas id="strip" role="img" aria-label="近 10 秒逐帧耗时"></canvas>
<div class="bp-freeze" id="freeze" hidden>测量进行中,图表暂停刷新以避免干扰结果</div>
</div>
<div class="bp-key"><span><i style="background:var(--mint-500)"></i>≤ 16.7 ms(≥ 60 FPS)</span><span><i style="background:var(--amber)"></i>16.7–33.3 ms</span><span><i style="background:var(--red)"></i>&gt; 33.3 ms(卡顿帧)</span></div>
<button class="bp-measure" id="measure" type="button" disabled><i class="bp-fill" aria-hidden="true"></i><span id="measure-label">开始测量(10 秒)</span></button>
<div class="bp-result" id="result" hidden aria-live="polite">
<div class="bp-verdict" id="verdict"></div>
<div class="bp-sum">
<div><span>平均帧率</span><b id="r-fps">–</b></div>
<div><span>P95 帧耗时</span><b id="r-p95">–</b></div>
<div><span>卡顿帧占比</span><b id="r-over">–</b></div>
</div>
<details><summary>完整报告(提交反馈时请附上)</summary><pre class="bp-report" id="out"></pre></details>
<button id="copy" type="button">复制报告</button>
</div>
</div>
</section>
<script type="module" src="/dev/battle-perf.js"></script>
</body>
</html>
+331
View File
@@ -0,0 +1,331 @@
// Battle perf page (dev): one real battle in the browser, run the way the match screen runs it — the client-side battle
// runner (js/battle/runner.js: the shared sim at the battle speed) feeding the field view (js/render/app.js) — from a
// BattleSpec captured off a bot match (tools/capture-specs.mjs → /dev/perf/<name>.json). Players can open it on a
// phone and report the numbers (「开始测量」→ a verdict and a copyable report); tools/perfbench.mjs drives it headlessly.
//
// Query: ?spec=<name> (default: the first of /dev/perf/index.json) &quality=high|medium|low &panel=0 (hide the panel)
// &board=2d|3d (render/app.js reads it)
// Puppeteer hook: window.__perf = { ready, error, view, runner, sample(ms) → figures over `ms` of animation frames }
//
// The panel must not disturb what it measures: frame times go into a ring buffer (one store per frame), the strip is
// redrawn 4× a second from it, binned to pixel columns, and both the strip and the live figures stand still during a
// measurement (only a CSS progress animation runs).
import { createFieldView } from '../js/render/app.js';
import { data } from '../js/data.js';
import { assets } from '../js/assets.js';
import { createBattleRunner } from '../js/battle/runner.js';
import { round, frameFigures } from './frame-stats.js';
const $ = (id) => document.getElementById(id);
const q = new URLSearchParams(location.search);
const perf = { ready: false, error: null };
window.__perf = perf;
const longTasks = [];
try {
new PerformanceObserver((list) => { for (const e of list.getEntries()) longTasks.push(e.duration); }).observe({ type: 'longtask', buffered: true });
} catch { /* not supported */ }
// Frame-time budgets (ms) shared by the verdict, the strip colours and the stutter share: 16.7 / 33.3 ms are 60 / 30 FPS.
// The 60 FPS budget allows 5% for vsync jitter (a 60 Hz display measures frames of 16.6–17.4 ms); a frame over the 30 FPS
// budget counts as a stutter frame.
export const FRAME_60_MS = 17.5;
export const FRAME_30_MS = 33.4;
const QUALITY_NAME = { high: '高', medium: '中', low: '低' };
/**
* Frame figures over `ms` of animation frames: average fps, frame-time percentiles, the share of frames over 33.4 ms
* (below 30 fps), long tasks, the sim's ticks and their cost (runner.stats), and the view's own CPU / render times.
* The first frame time is partial (from the call to the first frame) and dropped, so the sample runs for at least two
* frames whatever `ms` is: a sample that saw a single frame (a short `ms`, a frame longer than `ms`) had no frame time
* left and divided by zero.
*/
perf.sample = (ms = 10000) => new Promise((resolve) => {
const deltas = [];
let last = performance.now();
const t0 = last;
const r0 = { ...perf.runner.stats() };
const lt0 = longTasks.length;
const step = (t) => {
deltas.push(t - last);
last = t;
if (t - t0 < ms || deltas.length < 2) { requestAnimationFrame(step); return; }
deltas.shift();
const r1 = perf.runner.stats();
const v = perf.view.stats();
const lt = longTasks.slice(lt0);
const ticks = r1.ticks - r0.ticks;
resolve({
...frameFigures(deltas, FRAME_30_MS),
longTasks: lt.length, longTaskMs: Math.round(lt.reduce((a, b) => a + b, 0)),
ticks, simMs: round(r1.stepMs - r0.stepMs), simMsPerTick: ticks > 0 ? round((r1.stepMs - r0.stepMs) / ticks, 3) : null,
viewCpuMs: v.cpuMs, renderMs: v.renderMs, units: v.units, lod: v.lod, particles: v.particles,
board: v.board3d?.on ? '3d' : '2d', done: !!perf.runner.state()?.done,
});
};
requestAnimationFrame(step);
});
/** The device line of a report: renderer, screen, cores (what a phone report needs to be comparable). */
function deviceInfo() {
let gpu = '?';
try {
const gl = document.createElement('canvas').getContext('webgl2') || document.createElement('canvas').getContext('webgl');
const ext = gl && gl.getExtension('WEBGL_debug_renderer_info');
gpu = ext ? gl.getParameter(ext.UNMASKED_RENDERER_WEBGL) : (gl ? gl.getParameter(gl.RENDERER) : 'no WebGL');
} catch { /* ignore */ }
return {
ua: navigator.userAgent, gpu, cores: navigator.hardwareConcurrency || null, memoryGB: navigator.deviceMemory || null,
viewport: `${innerWidth}×${innerHeight}`, dpr: devicePixelRatio,
};
}
/** The copyable report: one item per line, the panel's terms and units (pasted into feedback as is). */
function report(spec, title, quality, s) {
const d = deviceInfo();
const lines = [
'战斗性能测试报告(/dev/battle-perf.html)',
`场景:${title}(${spec})`,
`画质:${QUALITY_NAME[quality] || quality};棋盘:${s.board === '3d' ? '3D' : '2D'}`,
`帧率:${s.fps} FPS`,
`帧耗时:P50 ${s.p50} ms,P95 ${s.p95} ms,P99 ${s.p99} ms`,
`卡顿帧占比(单帧 > 33.3 ms):${s.over33}%`,
`模拟单步:${s.simMsPerTick} ms(共 ${s.ticks} 步)`,
`渲染 CPU:${s.viewCpuMs} ms;渲染提交:${s.renderMs} ms`,
`场上单位:${s.units};自适应负载档位:${s.lod}`,
`长任务:${s.longTasks} 次,共 ${s.longTaskMs} ms`,
`GPU:${d.gpu}`,
`设备:${d.cores ?? '未知'} 个 CPU 线程${d.memoryGB ? `,内存约 ${d.memoryGB} GB` : ''};视口 ${d.viewport},像素比 ${d.dpr}`,
`浏览器:${d.ua}`,
];
if (s.done) lines.splice(10, 0, '注意:战斗在测量期间结束,结果仅供参考');
return lines.join('\n');
}
/** The verdict a player reads first, on the frame-time budgets of the strip (60 / 30 FPS). */
export function verdictOf(s) {
if (s.p95 <= FRAME_60_MS && s.over33 < 1) return { label: '流畅', note: `P95 帧耗时 ${s.p95} ms,达到 60 FPS`, color: '--mint-500' };
if (s.p95 <= FRAME_30_MS && s.over33 < 1) return { label: '基本流畅', note: `P95 帧耗时 ${s.p95} ms,介于 30–60 FPS`, color: '--amber' };
return { label: '卡顿', note: `卡顿帧占比 ${s.over33}%,P95 帧耗时 ${s.p95} ms`, color: '--red' };
}
// ---- frame ring buffer + strip ------------------------------------------------------------------------------------
const RING = 4096;
const ringT = new Float64Array(RING); // frame end (performance.now)
const ringD = new Float32Array(RING); // frame time (ms)
let ringN = 0;
let lastT = 0;
function recordFrames(t) {
if (lastT) { const i = ringN % RING; ringT[i] = t; ringD[i] = t - lastT; ringN++; }
lastT = t;
requestAnimationFrame(recordFrames);
}
const SPAN_MS = 10000; // the strip shows the last 10 s
const TOP_MS = 50; // frame times above clip to the top (a red spike)
let palette = null;
function colors() {
if (!palette) {
const cs = getComputedStyle(document.documentElement);
const v = (n) => cs.getPropertyValue(n).trim();
palette = { good: v('--mint-500'), warn: v('--amber'), bad: v('--red'), line: v('--line-2'), text: v('--text-lo') };
}
return palette;
}
const band = (ms) => (ms <= FRAME_60_MS ? 'good' : ms <= FRAME_30_MS ? 'warn' : 'bad');
/** Draw the last SPAN_MS of frame times into `cv`: one column per pixel (its slowest frame), budget lines at 60 / 30 fps. */
function drawStrip(cv, { labels = true } = {}) {
const w = cv.clientWidth, h = cv.clientHeight;
if (!w || !h) return;
const dpr = Math.min(2, devicePixelRatio || 1);
if (cv.width !== Math.round(w * dpr) || cv.height !== Math.round(h * dpr)) { cv.width = Math.round(w * dpr); cv.height = Math.round(h * dpr); }
const g = cv.getContext('2d');
g.setTransform(dpr, 0, 0, dpr, 0, 0);
g.clearRect(0, 0, w, h);
const c = colors();
const pad = labels ? 4 : 1;
const y = (ms) => h - pad - (Math.min(ms, TOP_MS) / TOP_MS) * (h - pad * 2);
g.fillStyle = c.line;
for (const ms of [16.7, 33.3]) g.fillRect(0, Math.round(y(ms)), w, 1);
if (labels) {
g.fillStyle = c.text;
g.font = '10px "Noto Sans SC", sans-serif';
g.textAlign = 'right';
g.fillText('60 FPS', w - 3, y(16.7) - 3);
g.fillText('30 FPS', w - 3, y(33.3) - 3);
}
const now = performance.now();
const cols = new Float32Array(Math.ceil(w));
for (let k = 1; k <= Math.min(ringN, RING); k++) {
const i = (ringN - k) % RING;
const age = now - ringT[i];
if (age > SPAN_MS) break;
const x = Math.floor(w - 1 - (age / SPAN_MS) * w);
if (x >= 0 && ringD[i] > cols[x]) cols[x] = ringD[i];
}
const barW = labels ? 1 : 1.5;
for (let x = 0; x < cols.length; x++) {
const ms = cols[x];
if (!ms) continue;
g.fillStyle = c[band(ms)];
const top = y(ms);
g.fillRect(x, top, barW, h - pad - top);
}
}
// ---- panel ----------------------------------------------------------------------------------------------------------
const PANEL_KEY = 'sp.dev.battlePerfPanel';
function loadPanelPref() {
try { return localStorage.getItem(PANEL_KEY); } catch { return null; }
}
function savePanelPref(v) {
try { localStorage.setItem(PANEL_KEY, v); } catch { /* private mode */ }
}
function setupPanel() {
const panel = $('panel');
const set = (collapsed, save = true) => {
panel.classList.toggle('is-collapsed', collapsed);
$('expand').setAttribute('aria-expanded', String(!collapsed));
$('collapse').setAttribute('aria-expanded', String(!collapsed));
if (save) savePanelPref(collapsed ? 'collapsed' : 'open');
};
const pref = loadPanelPref();
// phones (a landscape phone is short): start folded, the battle is what is being measured
set(pref ? pref === 'collapsed' : (innerWidth < 900 || innerHeight < 500), false);
$('collapse').onclick = () => { set(true); $('expand').focus(); };
$('expand').onclick = () => { set(false); $('collapse').focus(); };
}
async function main() {
if (q.get('panel') === '0') $('panel').classList.add('is-hidden');
setupPanel();
const index = await fetch('/dev/perf/index.json').then((r) => (r.ok ? r.json() : []), () => []);
const specName = q.get('spec') || index[0]?.name;
if (!specName) throw new Error('未找到战斗数据。请先运行 node tools/capture-specs.mjs 生成。');
const quality = ['high', 'medium', 'low'].includes(q.get('quality')) ? q.get('quality') : 'high';
for (const s of index) $('spec').append(new Option(s.title || s.name, s.name));
$('spec').value = specName;
$('quality').value = quality;
const reload = () => {
const p = new URLSearchParams(location.search);
p.set('spec', $('spec').value);
p.set('quality', $('quality').value);
location.search = p.toString();
};
$('restart').onclick = reload;
$('spec').onchange = reload;
$('quality').onchange = reload;
await data.loadAll('chess', 'tokens', 'items', 'enemies', 'stages', 'bonds', 'config');
await assets.ready();
const msg = await fetch(`/dev/perf/${encodeURIComponent(specName)}.json`).then((r) => {
if (!r.ok) throw new Error(`战斗数据 ${specName} 加载失败(HTTP ${r.status})。请确认该文件位于 public/dev/perf/。`);
return r.json();
});
const view = await createFieldView($('field'), { data, assets, settings: { quality, damageNumbers: true } });
view.setStage(data.lookup('stages', msg.spec.stageId));
// the runner needs no server here: reports go nowhere, the battle runs from its start
const net = { on: () => () => {}, send() {}, request: async () => ({ ok: true }) };
const runner = createBattleRunner({ net, store: null });
const camKind = msg.kind === 'hidden' ? 'boss' : msg.kind;
runner.on('field', (field) => { view.enterBattle(field); view.setCamera(camKind, { rect: field.rect, side: 'L', instant: true }); });
runner.on('snap', (snap) => view.pushSnapshot(snap));
runner.on('ev', (ev) => view.pushEvents(ev));
perf.view = view;
perf.runner = runner;
await runner.onStart({ ...msg, elapsed: 0, authoritative: true, watch: false, done: false });
perf.ready = true;
// frame times from here on: the loading hitches before the battle are not the battle's
requestAnimationFrame(recordFrames);
let measuring = false;
let prev = { ...runner.stats() };
const live = () => {
if (measuring) return;
const v = view.stats();
const r = runner.stats();
const ticks = r.ticks - prev.ticks;
const simMs = ticks > 0 ? (r.stepMs - prev.stepMs) / ticks : 0;
prev = { ...r };
const fps = Math.round(v.fps);
$('fps').textContent = fps;
$('f-frame').textContent = `${v.frameMs.toFixed(1)} ms`;
$('f-view').textContent = `${v.cpuMs.toFixed(1)} ms`;
$('f-sim').textContent = `${simMs.toFixed(2)} ms`;
$('chip-fps').textContent = fps;
$('chip-ms').textContent = `FPS / ${v.frameMs.toFixed(1)} ms`;
const st = runner.state();
$('meta').textContent = st?.done
? '战斗已结束。如需继续测量,请点击「重新开始」。'
: `场上单位 ${v.units} 个,自适应负载 ${v.lod} 档,${v.board3d?.on ? '3D' : '2D'} 棋盘`;
};
setInterval(live, 500);
setInterval(() => {
if (measuring) return;
if ($('panel').classList.contains('is-collapsed')) drawStrip($('chip-strip'), { labels: false });
else drawStrip($('strip'));
}, 250);
const btn = $('measure');
btn.disabled = false;
btn.onclick = async () => {
if (measuring) return;
measuring = true;
btn.classList.add('is-running');
btn.setAttribute('aria-disabled', 'true');
$('freeze').hidden = false;
const t0 = Date.now();
const tick = setInterval(() => { $('measure-label').textContent = `测量中(剩余 ${Math.max(1, 10 - Math.floor((Date.now() - t0) / 1000))} 秒)`; }, 1000);
$('measure-label').textContent = '测量中(剩余 10 秒)';
const s = await perf.sample(10000);
clearInterval(tick);
measuring = false;
btn.classList.remove('is-running');
btn.removeAttribute('aria-disabled');
$('freeze').hidden = true;
$('measure-label').textContent = '重新测量(10 秒)';
const v = verdictOf(s);
const verdict = $('verdict');
verdict.style.setProperty('--v', `var(${v.color})`);
verdict.replaceChildren(document.createTextNode(v.label));
const note = document.createElement('small');
note.textContent = v.note;
verdict.append(note);
$('r-fps').textContent = s.fps;
$('r-p95').textContent = `${s.p95} ms`;
$('r-over').textContent = `${s.over33}%`;
const text = report(specName, index.find((x) => x.name === specName)?.title || specName, quality, s);
$('out').textContent = text;
$('result').hidden = false;
const copy = $('copy');
copy.textContent = '复制报告';
copy.onclick = () => {
const done = () => { copy.textContent = '已复制到剪贴板'; };
const fallback = () => {
const d = $('result').querySelector('details');
d.open = true;
const range = document.createRange();
range.selectNodeContents($('out'));
const sel = getSelection();
sel.removeAllRanges();
sel.addRange(range);
copy.textContent = '无法写入剪贴板,已选中报告文本,请手动复制';
};
if (navigator.clipboard?.writeText) navigator.clipboard.writeText(text).then(done, fallback);
else fallback();
};
};
}
main().catch((err) => {
perf.error = String(err?.stack || err);
console.error(err);
const meta = document.getElementById('meta');
if (meta) meta.textContent = `加载失败:${err?.message || err}`;
});
+22
View File
@@ -0,0 +1,22 @@
// public/dev/frame-stats.js — the frame figures of the battle perf page (battle-perf.js), free of the DOM so the Node
// tests can import them (test/perfbench.test.js).
/** `v` rounded to `d` decimals; null for NaN / ±Infinity (a figure the report shows as missing, not as 0). */
export const round = (v, d = 1) => (Number.isFinite(v) ? Math.round(v * 10 ** d) / 10 ** d : null);
/**
* Average fps, frame-time percentiles (ms) and the share (%) of frames over `stutterMs`, from a sample's frame times.
* Every figure is null without a frame time (perf.sample() always records one, but an empty run must not divide by 0).
* @param {number[]} deltas frame times in ms (sorted in place)
* @param {number} stutterMs a frame over this counts as a stutter frame
*/
export function frameFigures(deltas, stutterMs) {
if (!deltas.length) return { fps: null, p50: null, p95: null, p99: null, over33: null };
deltas.sort((a, b) => a - b);
const pick = (p) => deltas[Math.min(deltas.length - 1, Math.floor(deltas.length * p))];
const avg = deltas.reduce((a, b) => a + b, 0) / deltas.length;
return {
fps: avg > 0 ? round(1000 / avg) : null, p50: round(pick(0.5)), p95: round(pick(0.95)), p99: round(pick(0.99)),
over33: round((deltas.filter((x) => x > stutterMs).length / deltas.length) * 100),
};
}
-1
View File
@@ -10,7 +10,6 @@
|overtime|drain (boss rounds: level time over / overtime drain) |paused (solo battles)
|funny (标准: 本局禁用 bonds) |morph (变形同构体 in the hand / shop, a wearer on the board) |harmony (缪尔赛思 on the board)
&shot=1 (hide the switcher) &render=engine|fallback (default: the Pixi engine, DOM fallback if it fails)
&variant=equipped (RESULT: zero, one or two carried equipment items, including upgraded ones)
Keyboard: ` toggles the switcher.
-->
<html lang="zh-CN">
+63 -13
View File
@@ -23,6 +23,9 @@ import { PHASE, GEO } from '/shared/constants.js';
const params = new URLSearchParams(location.search);
const SHOT = params.get('shot') === '1';
const VARIANTS = new Set((params.get('variant') || '').split(',').filter(Boolean));
// the match's stage (data/stages.json id): ?stage=act1autochess_m01 boards a stage whose own devices (crates / turrets)
// stand on the prep field — the default act2autochess_m01 keeps its blowers and turrets off it (rows 6 / 8 / 13)
const STAGE_ID = params.get('stage') || 'act2autochess_m01';
// ---- deterministic rng ----------------------------------------------------------------------------------
let seed = 20260927;
@@ -101,7 +104,7 @@ function refreshPrivate() {
const p = S.priv;
p.deployCount = p.board.filter((x) => x.kind === 'chess').length;
p.bonds = computeBonds(p);
p.canReady = !p.temp.some(Boolean);
p.canReady = !p.personalChoice && !p.temp.some(Boolean);
const upg = [5, 8, 11, 12, 13];
p.shop.maxLevel = 6;
if (p.shop.upgradePrice == null) p.shop.upgradePrice = upg[p.shop.level - 1] ?? 0;
@@ -119,7 +122,7 @@ function pushPublic() {
function buildState() {
uidSeq = 100;
seed = 20260927;
const stage = data.lookup('stages', 'act2autochess_m01');
const stage = data.lookup('stages', STAGE_ID) || data.lookup('stages', 'act2autochess_m01');
const melee = stage.deployTiles.normal.melee.slice();
const ranged = stage.deployTiles.normal.rangedOnly.slice();
const bonds = data.list('bonds');
@@ -178,7 +181,7 @@ function buildState() {
].sort((a, b) => a.t - b.t);
const priv = {
playerId: ME, seat: 0, alive: !VARIANTS.has('dead'), lp: 24, funds: 13, bandId: 'band_bldsk', ready: false, canReady: true,
playerId: ME, seat: 0, alive: !VARIANTS.has('dead'), lp: 24, funds: 13, bandId: 'band_bldsk', ready: false, canReady: true, personalChoice: null,
shop: { level, maxLevel: 6, upgradePrice: 9, refreshPrice: 1, freeRefreshes: 0, frozen: VARIANTS.has('frozen'), slots, rewardOffer: null },
hand, temp, board, deployCap: 8, deployCount: 0, bonds: [],
effects: [
@@ -206,7 +209,7 @@ function buildState() {
const pub = {
phase: PHASE.PREP, round: 6, lastRound: 14, bossRound: 14, hiddenRound: 15, deadline: Date.now() + 74000, serverNow: Date.now(), modeId: 'mode_multi_hard', difficulty: 'HARD',
stageId: 'act2autochess_m01', factions: ['FLY', 'TIMES', 'SPECIAL'], disabledBonds: [...disabled], bannedChess: banned, bossId: 'boss_5',
stageId: STAGE_ID, factions: ['FLY', 'TIMES', 'SPECIAL'], disabledBonds: [...disabled], bannedChess: banned, bossId: 'boss_5',
hiddenBossId: 'boss_8', teamLp: null, bossHp: null, draft: null, sp: null, players, fields: [],
};
if (VARIANTS.has('boss') || VARIANTS.has('bossR')) pub.round = pub.bossRound;
@@ -353,9 +356,7 @@ function buildResult(victory) {
players: S.pub.players.map((p, i) => ({
playerId: p.playerId, seat: p.seat, name: p.name, isBot: p.isBot, left: false, alive: i !== 3, victory, lp: [12, 12, 12, 0][i], bandId: p.bandId,
roundsPassed: i === 3 ? 9 : victory ? 14 : 11, eliminatedRound: i === 3 ? 10 : null, title: pickTitle(i),
lineup: shuffle(S.pool).slice(0, 8 + (i % 2)).map((c, k) => ({ id: k < 3 ? c.goldenId : c.chessId, golden: k < 3, tier: c.tier, row: 9 + (k % 4), col: 2 + k,
items: VARIANTS.has('equipped') ? data.list('items').filter((it) => it.itemType === 'EQUIP' && !!it.isGolden === (k % 2 === 0)).slice(0, k % 3).map((it) => ({ id: it.id })) : [],
})),
lineup: shuffle(S.pool).slice(0, 8 + (i % 2)).map((c, k) => ({ id: k < 3 ? c.goldenId : c.chessId, golden: k < 3, tier: c.tier, row: 9 + (k % 4), col: 2 + k, items: [] })),
bonds: shuffle(data.list('bonds')).slice(0, 4).map((b, k) => ({ bondId: b.bondId, count: 3 - (k % 3), layers: [359, 136, 34, 12][k], active: k < 3, tier: k < 3 ? 1 : 0 })),
stats: {
dmgDealt: [2310000, 1720000, 980000, 402000][i], kills: [412, 388, 301, 150][i], leaks: [3, 6, 9, 31][i], gold: [188, 164, 231, 90][i],
@@ -474,6 +475,13 @@ function startCombat(phase) {
const e = data.lookup('enemies', bossRec.enemyKey);
enemies.push({ id: id++, kind: 'enemy', side: 'enemy', ownerId: ME, defId: bossRec.enemyKey, name: bossRec.name, spine: bossRec.enemyKey, avatar: bossRec.enemyKey, x: 10, y: 3, facing: -1, maxHp: e?.stats?.maxHp || 1e5, hp: (e?.stats?.maxHp || 1e5) * 0.62, spawnAt: 0, dir: 0, boss: true, dead: false });
}
// ?variant=devices — the stage's crates inside the rect are device UNITS (kind 'device', defId = the device key), as in the
// real sim: absent from the field meta (a battle shown from its start), they come in with 'spawn' events once it steps;
// __MOCK__.breakDevice(i) destroys one (hp 0 for a second in the snapshots, then gone)
const deviceUnits = !VARIANTS.has('devices') ? [] : (S.stage.devices || [])
.filter((d) => d.role === 'crate' && (typeof d.active === 'boolean' ? d.active : !d.hidden)
&& d.pos[0] >= rect.r0 && d.pos[0] <= rect.r1 && d.pos[1] >= rect.c0 && d.pos[1] <= rect.c1)
.map((d) => ({ id: id++, kind: 'device', side: 'ally', ownerId: null, defId: d.key, name: d.name, x: d.pos[1], y: d.pos[0], facing: 1, maxHp: 100, hp: 100, deadAt: null }));
// every field — 联防 too — is fought on the round's battlefield (server unite.js; 0.2.0's escaped-level map withdrawn)
const field = { fieldId, kind, rect, stageId: pub.stageId, units: units.map((u) => ({ ...u })) };
store.patch('match', { field });
@@ -491,6 +499,7 @@ function startCombat(phase) {
if (pausedSince != null) { pausedFor += performance.now() - pausedSince; pausedSince = null; }
const t = (performance.now() - t0 - pausedFor) / 1000 + 4;
const ev = [];
for (const d of deviceUnits) if (!spawned.has(d.id)) { spawned.add(d.id); ev.push(['spawn', { ...d }]); }
for (const e of enemies) {
if (e.dead || t < e.spawnAt) continue;
if (!spawned.has(e.id)) { spawned.add(e.id); ev.push(['spawn', { ...e }]); }
@@ -510,6 +519,7 @@ function startCombat(phase) {
if (rnd() < 0.15) { const a = pick(allyState); a.hp = Math.min(a.maxHp, a.hp + 180); ev.push(['heal', a.id, 180]); }
const snapUnits = [
...allyState.map((a) => [a.id, a.x, a.y, a.hp, a.maxHp, a.sp, a.spMax, a.sp > 15 ? 16 : 0, 2]),
...deviceUnits.filter((d) => d.deadAt == null || t - d.deadAt < 1).map((d) => [d.id, d.x, d.y, Math.max(0, d.hp), d.maxHp, 0, 0, 0, 2]),
...enemies.filter((e) => !e.dead && t >= e.spawnAt).map((e) => [e.id, e.x, e.y, Math.max(1, e.hp), e.maxHp, 0, 0, e.y === 5 ? 512 : 0, e.dir ? 1 : 2]),
];
// wire frames exactly like server/match/fields.js: `t` is the frame type, game time travels as `gt`; b.ev first
@@ -519,7 +529,13 @@ function startCombat(phase) {
net._emit('b.snap', snap);
};
const e2boss = (e) => !!e.boss;
S.battle = { timer: setInterval(tick, 50), fieldRef };
S.battle = { timer: setInterval(tick, 50), fieldRef, devices: deviceUnits, hurtDevice: (i = 0, dmg = 100) => {
const d = deviceUnits[i];
if (!d || d.deadAt != null) return false;
d.hp = Math.max(0, d.hp - dmg);
if (d.hp <= 0) { d.deadAt = (performance.now() - t0 - pausedFor) / 1000 + 4; net._emit('b.ev', { t: 'b.ev', fieldId: fieldRef.id, gt: d.deadAt, ev: [['die', d.id]] }); }
return true;
} };
setTimeout(tick, 60);
}
@@ -577,7 +593,7 @@ async function mockRequest(t, f = {}) {
prepOnly();
if (p.shop.refreshPrice > p.funds) fail('NO_FUNDS');
if (p.shop.freeRefreshes > 0) p.shop.freeRefreshes--; else p.funds -= 1;
const n = p.shop.slots.filter((s) => s.kind !== 'item').length;
const n = p.shop.slots.filter((s) => !s || s.kind !== 'item').length;
p.shop.slots = [...Array.from({ length: n }, () => makeSlot(p.shop.level)), makeItemSlot()];
refreshPrivate(); return {};
}
@@ -588,7 +604,8 @@ async function mockRequest(t, f = {}) {
if (p.shop.upgradePrice > p.funds) fail('NO_FUNDS');
p.funds -= p.shop.upgradePrice; p.shop.level++; p.shop.upgradePrice = [5, 8, 11, 12, 13][p.shop.level - 1] ?? 0;
const n = p.shop.level >= 4 ? 5 : 4;
while (p.shop.slots.filter((s) => s.kind !== 'item').length < n) p.shop.slots.splice(p.shop.slots.length - 1, 0, makeSlot(p.shop.level));
// the upgrade opens the new slot EMPTY (server/match/player/economy.js _openLevelSlots: a null slot; a refresh fills it)
while (p.shop.slots.filter((s) => !s || s.kind !== 'item').length < n) p.shop.slots.splice(p.shop.slots.length - 1, 0, null);
toast(`调度中心等级提升至 ${p.shop.level}`, 'success');
refreshPrivate(); return {};
}
@@ -663,7 +680,16 @@ async function mockRequest(t, f = {}) {
}
pushPublic(); return {};
}
case 'g.art': { prepOnly(); const it = findPiece(f.itemUid); if (!it) fail('BAD_TARGET'); removeAt(it); toast('奇术已生效', 'success'); refreshPrivate(); return {}; }
case 'g.art': {
prepOnly(); const it = findPiece(f.itemUid); if (!it) fail('BAD_TARGET');
if (it.piece.id === 'chess_item_6_03_m') {
if (p.personalChoice) fail('BAD_TARGET');
const cards = data.get('choices').cards.bounty.filter((c) => c.payout === 'perfect' && c.rounds < 90).slice(0, 3)
.map((c) => ({ ...c, kind: 'bounty', id: c.effectId, descRaw: data.lookup('effects', c.effectId)?.descRaw }));
p.personalChoice = { id: `mock.choice.${nextUid()}`, round: pub.round, sourceItemId: it.piece.id, cards };
}
removeAt(it); toast('奇术已生效', 'success'); refreshPrivate(); return {};
}
case 'g.reward': {
if (!p.shop.rewardOffer) fail('WRONG_PHASE');
const s = p.shop.rewardOffer.slots[f.idx]; if (!s) fail('BAD_TARGET');
@@ -671,6 +697,15 @@ async function mockRequest(t, f = {}) {
p.hand[idx] = chessPiece(data.lookup('chess', s.id)); p.shop.rewardOffer = null; refreshPrivate(); return {};
}
case 'g.choice': {
if (f.choiceId !== undefined) {
prepOnly();
const choice = p.personalChoice;
if (!choice || choice.id !== f.choiceId || choice.round !== pub.round || !choice.cards[f.idx]) fail('BAD_TARGET');
const card = choice.cards[f.idx];
p.effects.push({ id: choice.id, name: card.name, desc: card.descRaw || card.desc, iconKind: 'choice', iconId: card.id });
p.personalChoice = null;
refreshPrivate(); return {};
}
if (!pub.sp) fail('WRONG_PHASE');
const taken = Object.values(pub.sp.picks).includes(f.idx);
if (taken) fail('BAD_TARGET');
@@ -678,6 +713,7 @@ async function mockRequest(t, f = {}) {
}
case 'g.ready': {
if (pub.phase !== PHASE.PREP) fail('WRONG_PHASE');
if (f.ready && p.personalChoice) fail('BAD_TARGET');
if (f.ready && !p.canReady) fail('TEMP_NOT_EMPTY');
p.ready = !!f.ready; refreshPrivate(); return {};
}
@@ -708,6 +744,15 @@ async function mockRequest(t, f = {}) {
return {};
}
case 'g.autoplay': case 'g.leave': case 'room.leave': return {};
// the host frees a spectator seat (server/lobby.js removeSpectator; ?variant=spectators seats two, ?variant=notHost makes
// another player the host)
case 'room.removeSpectator': {
const room = store.get().room;
if (room?.hostId !== ME) fail('NOT_HOST');
if (!(room.spectators || []).some((x) => x.playerId === f.playerId)) fail('BAD_TARGET');
store.set({ room: { ...room, spectators: room.spectators.filter((x) => x.playerId !== f.playerId) } });
return {};
}
default: return {};
}
}
@@ -782,7 +827,11 @@ async function boot() {
me: { playerId: ME, name: '凯尔希', token: null },
connection: { status: 'online', ping: 42, attempt: 0, retryAt: 0, lastError: null, everOnline: true },
clock: { offset: 0, rtt: 20, synced: true },
room: { code: 'MOCK', hostId: ME, mode: 'coop', difficulty: 'HARD', inMatch: true, seats: PLAYERS.map((p) => ({ seat: p.seat, playerId: p.playerId, name: p.name, isBot: p.isBot, ready: true, connected: true })) },
room: {
code: 'MOCK', hostId: VARIANTS.has('notHost') ? 'ai_2' : ME, mode: 'coop', difficulty: 'HARD', inMatch: true,
seats: PLAYERS.map((p) => ({ seat: p.seat, playerId: p.playerId, name: p.name, isBot: p.isBot, ready: true, connected: true })),
...(VARIANTS.has('spectators') ? { spectators: [{ playerId: 'sp_1', name: '观战者甲', connected: true }, { playerId: 'sp_2', name: '观战者乙', connected: false }] } : {}),
},
});
await data.loadAll(GAME_FILES);
installDeviceSupport();
@@ -792,6 +841,7 @@ async function boot() {
render(html`<div class="app-root"><div class="app-bg" aria-hidden="true"></div><${GameScreen} /><${ConnectionBanner} /><${ToastHost} /><${UiHosts} /><${GuideHost} /></div>`, document.getElementById('app'));
renderBar();
store.subscribe(() => renderBar());
globalThis.__MOCK__ = { store, S: () => S, setPhase, mutate: (fn) => { fn(S); refreshPrivate(); }, pushPublic: () => pushPublic() };
globalThis.__MOCK__ = { store, S: () => S, setPhase, mutate: (fn) => { fn(S); refreshPrivate(); }, pushPublic: () => pushPublic(),
hurtDevice: (i, dmg) => S?.battle?.hurtDevice?.(i, dmg) ?? false };
}
boot().catch((err) => console.error('[mock] boot failed', err));
+1
View File
@@ -0,0 +1 @@
{"t":"b.start","battleId":"7.14.63.b1","fieldId":"b1","kind":"boss","spec":{"v":1,"battleId":"7.14.63.b1","fieldId":"b1","kind":"boss","seed":3380376830,"modeId":"mode_multi_hard","round":14,"stageId":"act2autochess_m01","rect":{"r0":0,"r1":5,"c0":0,"c1":20},"timeLimit":null,"players":[{"playerId":"p_0","seat":0,"side":"L","colOffset":0,"units":[{"uid":123,"kind":"chess","chessId":"chess_char_4_02_b","row":12,"col":8,"dir":"DOWN","items":[],"skillIndex":2,"moduleId":"uniequip_002_mostma"},{"uid":125,"kind":"chess","chessId":"chess_char_4_20_a","row":11,"col":4,"dir":"RIGHT","items":["chess_item_1_05_e_a","chess_item_4_03_e_a"],"skillIndex":2},{"uid":208,"kind":"chess","chessId":"chess_char_3_17_b","row":11,"col":8,"dir":"RIGHT","items":["chess_item_3_01_e_a","chess_item_2_01_e_a"],"skillIndex":1,"moduleId":"uniequip_002_shotst"},{"uid":41,"kind":"chess","chessId":"chess_char_1_17_b","row":10,"col":4,"dir":"RIGHT","items":[],"skillIndex":1,"moduleId":"uniequip_002_indigo"},{"uid":169,"kind":"chess","chessId":"chess_char_4_10_a","row":10,"col":5,"dir":"RIGHT","items":[],"skillIndex":1},{"uid":96,"kind":"chess","chessId":"chess_char_1_19_b","row":10,"col":8,"dir":"RIGHT","items":["chess_item_1_01_e_a","chess_item_4_04_e_b"],"skillIndex":1,"moduleId":"uniequip_002_wildmn"},{"uid":168,"kind":"chess","chessId":"chess_char_4_14_b","row":9,"col":8,"dir":"RIGHT","items":["chess_item_3_04_e_a","chess_item_5_02_e_b"],"skillIndex":1,"moduleId":"uniequip_002_lionhd"},{"uid":209,"kind":"chess","chessId":"chess_char_6_19_a","row":9,"col":9,"dir":"UP","items":["chess_item_3_05_e_a","chess_item_4_05_e_a"],"skillIndex":2}],"bonds":{"yanShip":{"count":0,"active":false,"tier":0,"layers":0},"sargonShip":{"count":0,"active":false,"tier":0,"layers":0},"victoriaShip":{"count":0,"active":false,"tier":0,"layers":0},"kjeragShip":{"count":1,"active":false,"tier":0,"layers":8},"lateranoShip":{"count":1,"active":false,"tier":0,"layers":0},"egirShip":{"count":0,"active":false,"tier":0,"layers":0},"siracusaShip":{"count":1,"active":false,"tier":0,"layers":0},"kazimierzShip":{"count":4,"active":true,"tier":1,"layers":74},"preciShip":{"count":3,"active":true,"tier":2,"layers":98},"swiftShip":{"count":3,"active":true,"tier":1,"layers":82},"skillfulShip":{"count":0,"active":false,"tier":0,"layers":0},"arcaneShip":{"count":3,"active":true,"tier":2,"layers":26},"steadShip":{"count":0,"active":false,"tier":0,"layers":0},"deputShip":{"count":0,"active":false,"tier":0,"layers":0},"visiShip":{"count":0,"active":false,"tier":0,"layers":0},"miraShip":{"count":0,"active":false,"tier":0,"layers":0},"investShip":{"count":0,"active":false,"tier":0,"layers":0},"raidShip":{"count":0,"active":false,"tier":0,"layers":0},"indomShip":{"count":0,"active":false,"tier":0,"layers":4},"maniShip":{"count":0,"active":false,"tier":0,"layers":0},"emptyShip":{"count":0,"active":false,"tier":0,"layers":0},"soloShip":{"count":1,"active":true,"tier":1,"layers":0},"suntShip":{"count":5,"active":true,"tier":2,"layers":0}},"bandId":"band_sarkazb","playerEffects":[],"deviceOverrides":{},"lpForBoss":1572,"contentInfo":{"round":14,"kind":"boss","shopLevel":6,"funds":0,"handUnits":4,"handChess":4,"roundStats":{"refreshes":3,"buys":4,"sells":0,"spent":10,"gainedChess":0,"arts":0},"matchBands":["band_sarkazb","band_kirara","band_bldsk","band_cathy"],"teammates":[{"playerId":"p_1","bandId":"band_kirara"},{"playerId":"ai_2","bandId":"band_bldsk"},{"playerId":"ai_3","bandId":"band_cathy"}]}},{"playerId":"p_1","seat":1,"side":"R","colOffset":8,"units":[{"uid":243,"kind":"chess","chessId":"chess_char_4_20_b","row":12,"col":8,"dir":"DOWN","items":["chess_item_4_12_e_a","chess_item_3_08_e_b"],"skillIndex":2,"moduleId":"uniequip_002_fartth"},{"uid":240,"kind":"chess","chessId":"chess_char_6_16_a","row":11,"col":4,"dir":"DOWN","items":[],"skillIndex":2},{"uid":73,"kind":"chess","chessId":"chess_char_1_01_b","row":11,"col":8,"dir":"RIGHT","items":["chess_item_1_01_e_a"],"skillIndex":1,"moduleId":"uniequip_002_inside"},{"uid":246,"kind":"chess","chessId":"chess_char_6_05_a","row":10,"col":4,"dir":"RIGHT","items":["chess_item_4_02_e_a","chess_item_4_08_e_a"],"skillIndex":2},{"uid":175,"kind":"chess","chessId":"chess_char_2_18_b","row":10,"col":5,"dir":"RIGHT","items":["chess_item_4_10_e_b","chess_item_2_03_e_a"],"skillIndex":0,"moduleId":"uniequip_002_ashlok"},{"uid":130,"kind":"chess","chessId":"chess_char_4_01_a","row":10,"col":7,"dir":"RIGHT","items":["chess_item_4_04_e_a","chess_item_3_05_e_a"],"skillIndex":2},{"uid":177,"kind":"chess","chessId":"chess_char_6_19_a","row":10,"col":8,"dir":"RIGHT","items":["chess_item_2_01_e_a","chess_item_4_06_e_a"],"skillIndex":2},{"uid":100,"kind":"chess","chessId":"chess_char_4_02_a","row":9,"col":9,"dir":"UP","items":["chess_item_3_07_e_a","chess_item_3_06_e_b"],"skillIndex":2}],"bonds":{"yanShip":{"count":0,"active":false,"tier":0,"layers":11},"sargonShip":{"count":1,"active":false,"tier":0,"layers":4},"victoriaShip":{"count":0,"active":false,"tier":0Line truncated
+1
View File
@@ -0,0 +1 @@
{"t":"b.start","battleId":"7.15.65.b1","fieldId":"b1","kind":"hidden","spec":{"v":1,"battleId":"7.15.65.b1","fieldId":"b1","kind":"hidden","seed":725852008,"modeId":"mode_multi_hard","round":15,"stageId":"act2autochess_m01","rect":{"r0":0,"r1":5,"c0":0,"c1":20},"timeLimit":null,"players":[{"playerId":"p_0","seat":0,"side":"L","colOffset":0,"units":[{"uid":123,"kind":"chess","chessId":"chess_char_4_02_b","row":12,"col":8,"dir":"DOWN","items":["chess_item_3_03_e_a"],"skillIndex":2,"moduleId":"uniequip_002_mostma"},{"uid":125,"kind":"chess","chessId":"chess_char_4_20_a","row":11,"col":4,"dir":"RIGHT","items":["chess_item_1_05_e_a","chess_item_4_03_e_a"],"skillIndex":2},{"uid":208,"kind":"chess","chessId":"chess_char_3_17_b","row":11,"col":8,"dir":"RIGHT","items":["chess_item_3_01_e_a","chess_item_2_01_e_a"],"skillIndex":1,"moduleId":"uniequip_002_shotst"},{"uid":41,"kind":"chess","chessId":"chess_char_1_17_b","row":10,"col":4,"dir":"RIGHT","items":[],"skillIndex":1,"moduleId":"uniequip_002_indigo"},{"uid":169,"kind":"chess","chessId":"chess_char_4_10_a","row":10,"col":5,"dir":"RIGHT","items":[],"skillIndex":1},{"uid":96,"kind":"chess","chessId":"chess_char_1_19_b","row":10,"col":8,"dir":"RIGHT","items":["chess_item_1_01_e_a","chess_item_4_04_e_b"],"skillIndex":1,"moduleId":"uniequip_002_wildmn"},{"uid":168,"kind":"chess","chessId":"chess_char_4_14_b","row":9,"col":8,"dir":"RIGHT","items":["chess_item_3_04_e_a","chess_item_5_02_e_b"],"skillIndex":1,"moduleId":"uniequip_002_lionhd"},{"uid":209,"kind":"chess","chessId":"chess_char_6_19_a","row":9,"col":9,"dir":"UP","items":["chess_item_4_05_e_a","chess_item_4_02_e_a"],"skillIndex":2}],"bonds":{"yanShip":{"count":0,"active":false,"tier":0,"layers":0},"sargonShip":{"count":0,"active":false,"tier":0,"layers":0},"victoriaShip":{"count":0,"active":false,"tier":0,"layers":0},"kjeragShip":{"count":1,"active":false,"tier":0,"layers":8},"lateranoShip":{"count":1,"active":false,"tier":0,"layers":0},"egirShip":{"count":0,"active":false,"tier":0,"layers":0},"siracusaShip":{"count":1,"active":false,"tier":0,"layers":0},"kazimierzShip":{"count":4,"active":true,"tier":1,"layers":74},"preciShip":{"count":3,"active":true,"tier":2,"layers":108},"swiftShip":{"count":3,"active":true,"tier":1,"layers":92},"skillfulShip":{"count":0,"active":false,"tier":0,"layers":0},"arcaneShip":{"count":3,"active":true,"tier":2,"layers":32},"steadShip":{"count":0,"active":false,"tier":0,"layers":0},"deputShip":{"count":0,"active":false,"tier":0,"layers":0},"visiShip":{"count":0,"active":false,"tier":0,"layers":0},"miraShip":{"count":0,"active":false,"tier":0,"layers":0},"investShip":{"count":0,"active":false,"tier":0,"layers":0},"raidShip":{"count":0,"active":false,"tier":0,"layers":0},"indomShip":{"count":0,"active":false,"tier":0,"layers":4},"maniShip":{"count":0,"active":false,"tier":0,"layers":0},"emptyShip":{"count":0,"active":false,"tier":0,"layers":0},"soloShip":{"count":1,"active":true,"tier":1,"layers":0},"suntShip":{"count":5,"active":true,"tier":2,"layers":0}},"bandId":"band_sarkazb","playerEffects":[],"deviceOverrides":{},"lpForBoss":1205,"contentInfo":{"round":15,"kind":"hidden","shopLevel":6,"funds":0,"handUnits":6,"handChess":6,"roundStats":{"refreshes":3,"buys":4,"sells":0,"spent":11,"gainedChess":2,"arts":0},"matchBands":["band_sarkazb","band_kirara","band_bldsk","band_cathy"],"teammates":[{"playerId":"p_1","bandId":"band_kirara"},{"playerId":"ai_2","bandId":"band_bldsk"},{"playerId":"ai_3","bandId":"band_cathy"}]}},{"playerId":"p_1","seat":1,"side":"R","colOffset":8,"units":[{"uid":246,"kind":"chess","chessId":"chess_char_6_05_a","row":12,"col":8,"dir":"DOWN","items":["chess_item_4_02_e_a","chess_item_4_08_e_a"],"skillIndex":2},{"uid":243,"kind":"chess","chessId":"chess_char_4_20_b","row":11,"col":8,"dir":"DOWN","items":["chess_item_4_12_e_a","chess_item_3_08_e_b"],"skillIndex":2,"moduleId":"uniequip_002_fartth"},{"uid":240,"kind":"chess","chessId":"chess_char_6_16_a","row":10,"col":4,"dir":"RIGHT","items":[],"skillIndex":2},{"uid":175,"kind":"chess","chessId":"chess_char_2_18_b","row":10,"col":5,"dir":"RIGHT","items":["chess_item_4_10_e_b","chess_item_2_03_e_a"],"skillIndex":0,"moduleId":"uniequip_002_ashlok"},{"uid":274,"kind":"chess","chessId":"chess_char_2_17_b","row":10,"col":7,"dir":"RIGHT","items":[],"skillIndex":1,"moduleId":"uniequip_002_branch"},{"uid":177,"kind":"chess","chessId":"chess_char_6_19_a","row":10,"col":8,"dir":"RIGHT","items":["chess_item_2_01_e_a","chess_item_4_06_e_b"],"skillIndex":2},{"uid":100,"kind":"chess","chessId":"chess_char_4_02_a","row":9,"col":8,"dir":"RIGHT","items":["chess_item_3_07_e_a","chess_item_3_06_e_b"],"skillIndex":2},{"uid":130,"kind":"chess","chessId":"chess_char_4_01_a","row":9,"col":9,"dir":"UP","items":["chess_item_4_04_e_a","chess_item_3_05_e_a"],"skillIndex":2}],"bonds":{"yanShip":{"count":0,"active":false,"tier":0,"layers":11},"sargonShip":{"count":1,"active":false,"tier":0,"layers":4},"victoriaShip":{"count":0,"active":false,"tLine truncated
+1
View File
@@ -0,0 +1 @@
{"t":"b.start","battleId":"7.13.58.n:p_0","fieldId":"n:p_0","kind":"normal","spec":{"v":1,"battleId":"7.13.58.n:p_0","fieldId":"n:p_0","kind":"normal","seed":720961930,"modeId":"mode_multi_hard","round":13,"stageId":"act2autochess_m01","rect":{"r0":9,"r1":12,"c0":0,"c1":10},"timeLimit":230,"players":[{"playerId":"p_0","seat":0,"side":"L","colOffset":0,"units":[{"uid":169,"kind":"chess","chessId":"chess_char_4_10_a","row":12,"col":4,"dir":"RIGHT","items":[],"skillIndex":1},{"uid":41,"kind":"chess","chessId":"chess_char_1_17_b","row":12,"col":5,"dir":"DOWN","items":[],"skillIndex":1,"moduleId":"uniequip_002_indigo"},{"uid":208,"kind":"chess","chessId":"chess_char_3_17_b","row":11,"col":4,"dir":"RIGHT","items":["chess_item_3_01_e_a","chess_item_2_01_e_a"],"skillIndex":1,"moduleId":"uniequip_002_shotst"},{"uid":96,"kind":"chess","chessId":"chess_char_1_19_b","row":11,"col":5,"dir":"DOWN","items":["chess_item_4_04_e_a","chess_item_1_01_e_a"],"skillIndex":1,"moduleId":"uniequip_002_wildmn"},{"uid":168,"kind":"chess","chessId":"chess_char_4_14_b","row":11,"col":7,"dir":"LEFT","items":[],"skillIndex":1,"moduleId":"uniequip_002_lionhd"},{"uid":123,"kind":"chess","chessId":"chess_char_4_02_b","row":10,"col":4,"dir":"RIGHT","items":[],"skillIndex":2,"moduleId":"uniequip_002_mostma"},{"uid":209,"kind":"chess","chessId":"chess_char_6_19_a","row":10,"col":5,"dir":"DOWN","items":["chess_item_3_05_e_a","chess_item_4_05_e_a"],"skillIndex":2},{"uid":125,"kind":"chess","chessId":"chess_char_4_20_a","row":9,"col":7,"dir":"UP","items":["chess_item_1_05_e_a","chess_item_4_03_e_a"],"skillIndex":2}],"bonds":{"yanShip":{"count":0,"active":false,"tier":0,"layers":0},"sargonShip":{"count":0,"active":false,"tier":0,"layers":0},"victoriaShip":{"count":0,"active":false,"tier":0,"layers":0},"kjeragShip":{"count":1,"active":false,"tier":0,"layers":8},"lateranoShip":{"count":1,"active":false,"tier":0,"layers":0},"egirShip":{"count":0,"active":false,"tier":0,"layers":0},"siracusaShip":{"count":1,"active":false,"tier":0,"layers":0},"kazimierzShip":{"count":4,"active":true,"tier":1,"layers":58},"preciShip":{"count":3,"active":true,"tier":2,"layers":80},"swiftShip":{"count":3,"active":true,"tier":1,"layers":64},"skillfulShip":{"count":0,"active":false,"tier":0,"layers":0},"arcaneShip":{"count":3,"active":true,"tier":2,"layers":20},"steadShip":{"count":0,"active":false,"tier":0,"layers":0},"deputShip":{"count":0,"active":false,"tier":0,"layers":0},"visiShip":{"count":0,"active":false,"tier":0,"layers":0},"miraShip":{"count":0,"active":false,"tier":0,"layers":0},"investShip":{"count":0,"active":false,"tier":0,"layers":0},"raidShip":{"count":0,"active":false,"tier":0,"layers":0},"indomShip":{"count":0,"active":false,"tier":0,"layers":4},"maniShip":{"count":0,"active":false,"tier":0,"layers":0},"emptyShip":{"count":0,"active":false,"tier":0,"layers":0},"soloShip":{"count":1,"active":true,"tier":1,"layers":0},"suntShip":{"count":5,"active":true,"tier":2,"layers":0}},"bandId":"band_sarkazb","playerEffects":[],"deviceOverrides":{},"contentInfo":{"round":13,"kind":"normal","shopLevel":6,"funds":0,"handUnits":4,"handChess":4,"roundStats":{"refreshes":4,"buys":5,"sells":2,"spent":14,"gainedChess":2,"arts":0},"matchBands":["band_sarkazb","band_kirara","band_bldsk","band_cathy"],"teammates":[{"playerId":"p_1","bandId":"band_kirara"},{"playerId":"ai_2","bandId":"band_bldsk"},{"playerId":"ai_3","bandId":"band_cathy"}]}}],"spawns":[{"time":7,"enemyKey":"enemy_1166_dusbr_2","routeIndex":0,"count":3,"interval":3,"mods":{"hpMul":3.583181,"atkMul":1.948717,"speedMul":1,"slot":"N"},"actionIndex":0,"preview":{"gate":"lower","start":[9,10],"fly":false,"elite":false,"boss":false},"ownerPlayerId":"p_0"},{"time":10,"enemyKey":"enemy_1166_dusbr_2","routeIndex":1,"count":3,"interval":5,"mods":{"hpMul":3.583181,"atkMul":1.948717,"speedMul":1,"slot":"N"},"actionIndex":1,"preview":{"gate":"lower","start":[9,10],"fly":false,"elite":false,"boss":false},"ownerPlayerId":"p_0"},{"time":39,"enemyKey":"enemy_1166_dusbr_2","routeIndex":2,"count":3,"interval":5,"mods":{"hpMul":3.583181,"atkMul":1.948717,"speedMul":1,"slot":"N"},"actionIndex":2,"preview":{"gate":"lower","start":[9,10],"fly":false,"elite":false,"boss":false},"ownerPlayerId":"p_0"},{"time":62,"enemyKey":"enemy_1166_dusbr_2","routeIndex":3,"count":3,"interval":4,"mods":{"hpMul":3.583181,"atkMul":1.948717,"speedMul":1,"slot":"N"},"actionIndex":3,"preview":{"gate":"lower","start":[9,10],"fly":false,"elite":false,"boss":false},"ownerPlayerId":"p_0"},{"time":11,"enemyKey":"enemy_1169_duphlx_2","routeIndex":4,"count":5,"interval":5.4,"mods":{"hpMul":3.583181,"atkMul":1.948717,"speedMul":1,"slot":"E"},"actionIndex":4,"preview":{"gate":"lower","start":[9,10],"fly":false,"elite":false,"boss":false},"ownerPlayerId":"p_0"},{"time":37,"enemyKey":"enemy_1169_duphlx_2","routeIndex":5,"count":5,"interval":7.2,"mods":{"hpMul":3.583181,"atkMul":1.948717,"speedMul":1,"slot":"E"},"actionIndex":5,"preview":{"gate":"lower","start":[9,10]Line truncated
+1
View File
@@ -0,0 +1 @@
{"t":"b.start","battleId":"7.13.62.u","fieldId":"u","kind":"unite","spec":{"v":1,"battleId":"7.13.62.u","fieldId":"u","kind":"unite","seed":3495193848,"modeId":"mode_multi_hard","round":13,"stageId":"act2autochess_m01","rect":{"r0":9,"r1":12,"c0":0,"c1":20},"timeLimit":230,"players":[{"playerId":"p_0","seat":0,"side":"L","colOffset":8,"units":[{"uid":169,"kind":"chess","chessId":"chess_char_4_10_a","row":12,"col":4,"dir":"RIGHT","items":[],"skillIndex":1,"carryState":{"hpPct":0.3342892236024844,"sp":0}},{"uid":41,"kind":"chess","chessId":"chess_char_1_17_b","row":12,"col":5,"dir":"DOWN","items":[],"skillIndex":1,"moduleId":"uniequip_002_indigo","carryState":{"down":true}},{"uid":208,"kind":"chess","chessId":"chess_char_3_17_b","row":11,"col":4,"dir":"RIGHT","items":["chess_item_3_01_e_a","chess_item_2_01_e_a"],"skillIndex":1,"moduleId":"uniequip_002_shotst","carryState":{"hpPct":1,"sp":8}},{"uid":96,"kind":"chess","chessId":"chess_char_1_19_b","row":11,"col":5,"dir":"DOWN","items":["chess_item_4_04_e_a","chess_item_1_01_e_a"],"skillIndex":1,"moduleId":"uniequip_002_wildmn","carryState":{"down":true}},{"uid":168,"kind":"chess","chessId":"chess_char_4_14_b","row":11,"col":7,"dir":"LEFT","items":[],"skillIndex":1,"moduleId":"uniequip_002_lionhd","carryState":{"down":true}},{"uid":123,"kind":"chess","chessId":"chess_char_4_02_b","row":10,"col":4,"dir":"RIGHT","items":[],"skillIndex":2,"moduleId":"uniequip_002_mostma","carryState":{"hpPct":1,"sp":0}},{"uid":209,"kind":"chess","chessId":"chess_char_6_19_a","row":10,"col":5,"dir":"DOWN","items":["chess_item_3_05_e_a","chess_item_4_05_e_a"],"skillIndex":2,"carryState":{"hpPct":0.7218991466789668,"sp":0}},{"uid":125,"kind":"chess","chessId":"chess_char_4_20_a","row":9,"col":7,"dir":"UP","items":["chess_item_1_05_e_a","chess_item_4_03_e_a"],"skillIndex":2,"carryState":{"down":true}}],"bonds":{"yanShip":{"count":0,"active":false,"tier":0,"layers":0},"sargonShip":{"count":0,"active":false,"tier":0,"layers":0},"victoriaShip":{"count":0,"active":false,"tier":0,"layers":0},"kjeragShip":{"count":1,"active":false,"tier":0,"layers":8},"lateranoShip":{"count":1,"active":false,"tier":0,"layers":0},"egirShip":{"count":0,"active":false,"tier":0,"layers":0},"siracusaShip":{"count":1,"active":false,"tier":0,"layers":0},"kazimierzShip":{"count":4,"active":true,"tier":1,"layers":74},"preciShip":{"count":3,"active":true,"tier":2,"layers":88},"swiftShip":{"count":3,"active":true,"tier":1,"layers":72},"skillfulShip":{"count":0,"active":false,"tier":0,"layers":0},"arcaneShip":{"count":3,"active":true,"tier":2,"layers":20},"steadShip":{"count":0,"active":false,"tier":0,"layers":0},"deputShip":{"count":0,"active":false,"tier":0,"layers":0},"visiShip":{"count":0,"active":false,"tier":0,"layers":0},"miraShip":{"count":0,"active":false,"tier":0,"layers":0},"investShip":{"count":0,"active":false,"tier":0,"layers":0},"raidShip":{"count":0,"active":false,"tier":0,"layers":0},"indomShip":{"count":0,"active":false,"tier":0,"layers":4},"maniShip":{"count":0,"active":false,"tier":0,"layers":0},"emptyShip":{"count":0,"active":false,"tier":0,"layers":0},"soloShip":{"count":1,"active":true,"tier":1,"layers":0},"suntShip":{"count":5,"active":true,"tier":2,"layers":0}},"bandId":"band_sarkazb","playerEffects":[],"deviceOverrides":{},"contentInfo":{"round":13,"kind":"unite","shopLevel":6,"funds":0,"handUnits":4,"handChess":4,"roundStats":{"refreshes":4,"buys":5,"sells":2,"spent":14,"gainedChess":2,"arts":0},"matchBands":["band_sarkazb","band_kirara","band_bldsk","band_cathy"],"teammates":[{"playerId":"p_1","bandId":"band_kirara"},{"playerId":"ai_2","bandId":"band_bldsk"},{"playerId":"ai_3","bandId":"band_cathy"}]}},{"playerId":"ai_3","seat":3,"side":"L","colOffset":0,"units":[{"uid":80,"kind":"chess","chessId":"chess_char_3_11_a","row":12,"col":4,"dir":"RIGHT","items":["chess_item_1_02_e_b","chess_item_6_04_e_a"],"skillIndex":1,"carryState":{"hpPct":1,"sp":13.13}},{"uid":143,"kind":"chess","chessId":"chess_char_4_22_a","row":12,"col":5,"dir":"DOWN","items":["chess_item_3_02_e_a","chess_item_6_09_e_a"],"skillIndex":2,"carryState":{"hpPct":1,"sp":0.17}},{"uid":158,"kind":"chess","chessId":"chess_char_5_11_a","row":12,"col":6,"dir":"RIGHT","items":["chess_item_4_06_e_a","chess_item_4_04_e_b"],"skillIndex":1,"carryState":{"hpPct":0.9422580123247037,"sp":4.57}},{"uid":112,"kind":"chess","chessId":"chess_char_4_13_a","row":11,"col":4,"dir":"RIGHT","items":["chess_item_3_10_e_a"],"skillIndex":1,"carryState":{"hpPct":1,"sp":1.85}},{"uid":59,"kind":"chess","chessId":"chess_char_3_20_a","row":11,"col":5,"dir":"RIGHT","items":["chess_item_6_06_e_a","chess_item_6_07_e_a"],"skillIndex":1,"carryState":{"hpPct":1,"sp":0}},{"uid":146,"kind":"chess","chessId":"chess_char_4_21_a","row":11,"col":7,"dir":"LEFT","items":[],"skillIndex":1,"carryState":{"down":true}},{"uid":81,"kind":"chess","chessId":"chess_char_3_14_a","row":10,"col":4,"dir":"RIGHT","items":["chess_item_6_01_e_a","chess_item_3_11_e_b"],"skillIndex":1,"carrySLine truncated
+38
View File
@@ -0,0 +1,38 @@
[
{
"name": "hard-normal-r13",
"title": "普通战 · 第 13 回合",
"kind": "normal",
"round": 13,
"stageId": "act2autochess_m01",
"players": 1,
"units": 8
},
{
"name": "hard-unite-r13",
"title": "联防 · 第 13 回合",
"kind": "unite",
"round": 13,
"stageId": "act2autochess_m01",
"players": 2,
"units": 16
},
{
"name": "hard-boss-r14",
"title": "最终攻势 · 第 14 回合",
"kind": "boss",
"round": 14,
"stageId": "act2autochess_m01",
"players": 2,
"units": 16
},
{
"name": "hard-hidden-r15",
"title": "隐秘核心 · 第 15 回合",
"kind": "hidden",
"round": 15,
"stageId": "act2autochess_m01",
"players": 2,
"units": 16
}
]
+128
View File
@@ -104,6 +104,9 @@
"复制密钥": "Copy Key",
"复制链接": "Copy Link",
"由创建者选择": "Chosen by the Host",
"由创建者设置": "Set by the Host",
"AI 队友最后选择": "AI Teammates Pick Last",
"策略与机变轮选时,所有博士先于 AI 队友选择": "In the Strategy and Improv drafts, every Doctor picks before the AI teammates",
"连接中断,请稍候重试": "Connection lost, please try again shortly",
"离开同盟": "Leave Alliance",
"你是同盟的创建者,离开后创建者身份将移交或同盟解散。确定离开吗?": "You are the Host of this Alliance. If you leave, host duties will be handed over or the Alliance will disband. Leave anyway?",
@@ -148,6 +151,7 @@
"完成": "Done",
"背景音乐": "Music",
"干员语音": "Operator Voices",
"语音语言": "Voice Language",
"音效": "Sound Effects",
"静音": "Mute",
"显示伤害数字": "Damage Numbers",
@@ -382,6 +386,12 @@
"CONTINGENCY // 机变阶段": "CONTINGENCY // Improv Phase",
"选择一项": "Choose one",
"倒计时结束后仍未选定将自动分配": "If no choice is made before the countdown ends, one will be assigned automatically",
"教鞭选择": "Pointing Stick Choice",
"教鞭 · 战术特训": "Pointing Stick · Tactical Training",
"请选择一项战术特训": "Choose a Tactical Training bounty",
"请先完成教鞭选择": "Complete the Pointing Stick choice first",
"休整期结束时未选择将自动选定": "An unselected bounty will be picked automatically when the Rest Phase ends",
"从候选中选择一项战术特训": "Choose one Tactical Training bounty from the offered cards",
"选择一项(无时间限制)": "Choose one (no time limit)",
" · 点击卡牌选中,再次点击确认": " · Tap a card to select, tap again to confirm",
"已完成选择": "Choice made",
@@ -681,6 +691,7 @@
" · 未装备模组": " · No Module equipped",
"已购买": "Purchased",
"已招募": "Recruited",
"空栏位:刷新或下回合开始时补满": "Empty slot: filled by the next refresh or at the start of the next round",
"再次点击确认升级({price} 资金)": "Tap again to confirm upgrade ({price} Funds)",
"升级调度中心({price} 资金) · {key}": "Upgrade Dispatch Center ({price} Funds) · {key}",
"已满级": "Max Level",
@@ -777,6 +788,7 @@
"观战席": "Spectators",
"移出观战者 {name}": "Remove spectator {name}",
"移出该观战者": "Remove this spectator",
"观战者不占博士席位,只能观看;创建者可以把观战者移出,被移出的人不再收到战场画面。": "Spectators take no Doctor seat and can only watch; the Host can remove a spectator, who then no longer receives the battlefield.",
"入座": "Take a Seat",
"移出同盟": "Remove from Alliance",
"确定将「{name}」移出同盟吗?对方可以凭同盟密钥重新加入。": "Remove \"{name}\" from the Alliance? They can rejoin with the Alliance Key.",
@@ -808,6 +820,15 @@
"已恢复默认快捷键": "Default hotkeys restored",
"快捷键需要实体键盘;触屏设备连接键盘后可用": "Hotkeys need a physical keyboard; connect one to use them on a touch device",
"查看 / 更改快捷键": "Show / change hotkeys",
"问题反馈": "Report a problem",
"复制诊断信息": "Copy diagnostics",
"遇到问题时,复制诊断信息并粘贴到 GitHub issue 中,开发者就能看到出错时的情况。诊断信息只在本机生成,不会自动上传;玩家名和房间号会被替换。": "If something goes wrong, copy the diagnostics and paste them into a GitHub issue so the developers can see what happened. They are generated on this device and never uploaded; player names and the room code are replaced.",
"已记录的错误": "Errors recorded",
"附上本场战斗": "Attach this battle",
"开发者可以用附上的战斗数据重现这场战斗。": "Developers can replay this battle from the attached data.",
"已复制诊断信息,可以粘贴到 GitHub issue 中": "Diagnostics copied. Paste them into a GitHub issue.",
"无法写入剪贴板,请手动复制下面的内容": "Couldn't write to the clipboard. Copy the text below by hand.",
"诊断信息": "Diagnostics",
"观战中无法进行该操作": "Spectators cannot do that",
"标准": "Standard",
"险境": "Perilous",
@@ -815,6 +836,8 @@
"终极": "Ultimate",
"rounds::回合": "Rounds",
"{who}:获得{name}": "{who}: obtained {name}",
"{who}:卡池中已没有{name}": "{who}: no {name} left in the shared pool",
"{who}:没有可获得的同盟约干员": "{who}: no Operator of the same Alliance can be obtained",
"获得{name}": "Obtained {name}",
"联防阶段:{names} 迎战突破防线的敌人": "Unite Phase: {names} take on the enemies that broke through",
"地形变化:{names}无法停留在原位置,已撤回整备区": "Terrain change: {names} can no longer stay in place and returned to the Bench",
@@ -903,6 +926,22 @@
"地面单位可通过": "Ground units can pass",
"地形机制": "Terrain Mechanics",
"这一格": "This Tile",
"场地装置": "Stage Device",
"装置机制": "Device Mechanics",
"挡在地面上的工事:地面敌人会绕开它走;只有无路可走时才会撞上来,把它摧毁后继续前进": "An obstacle on the ground: ground enemies walk around it, and only run into it when there is no other way through, destroying it before moving on",
"生命 {hp} 点,被摧毁后从场上消失": "{hp} HP; it disappears from the battlefield once destroyed",
"自动攻击射程内的一名敌人,造成法术伤害,命中的敌人附加脆弱": "Automatically attacks one enemy in range for Arts damage; the enemy hit is inflicted with Fragile",
"按当前层数最高的盟约计算:每 1 层,攻击速度 +{aspd}(至多 +{aspdMax}),附加的脆弱 +{frag}%(至多 +{fragMax}%)": "Scales with the Alliance that currently has the most stacks: per stack, ASPD +{aspd} (up to +{aspdMax}) and the Fragile it inflicts +{frag}% (up to +{fragMax}%)",
"脆弱持续 {sec} 秒": "The Fragile lasts {sec} {sec|second|seconds}",
"高台位装置:地面敌人不能走上这一格": "A high-ground device: ground enemies cannot walk onto this tile",
"远程位干员可以部署在其上;站上去的干员在高位,不阻挡敌人": "Ranged-position Operators can be deployed on it; an Operator standing on it is on high ground and does not block enemies",
"向前方 {n} 格吹出气流(这一台朝{dir})": "Blows an airflow {n} {n|tile|tiles} ahead (this one faces {dir})",
"朝向与风向相同的攻击力 {v}": "ATK {v} when facing the same way as the airflow",
"相反的 {v}": "{v} when facing the opposite way",
"部署在气流里的干员:{mods}": "Operators deployed in the airflow: {mods}",
"顺风移动速度 ×{v}": "Movement Speed ×{v} moving with the airflow",
"逆风 ×{v}": "×{v} moving against it",
"在气流里移动的敌人:{mods}": "Enemies moving in the airflow: {mods}",
"全部清空": "Clear All",
"清空全部 {n} 个自选名额?": "Clear all {n} Custom Squad {n|slot|slots}?",
"文件过大,请选择「导出」下载的自选编队文件": "The file is too large. Choose a Custom Squad file downloaded with \"Export\".",
@@ -1056,6 +1095,95 @@
"选择朝向": "Choose a facing",
"朝向:{dir}": "Facing: {dir}",
"开发版 · 不稳定,请勿用于公开服务器": "Development build · unstable, not for public servers",
"统计数据": "Statistics",
"统计": "Stats",
"总览": "Overview",
"总局数": "Games",
"胜场": "Wins",
"胜率": "Win rate",
"到达隐秘核心": "Hidden Core reached",
"通关隐秘核心": "Hidden Core cleared",
"最远回合": "Best round",
"累计时长": "Time played",
"{n} 分": "{n} min",
"难度": "Difficulty",
"局数": "Games",
"隐秘核心通关": "Hidden Core clears",
"使用局数": "Used",
"通过局数": "Cleared",
"通过率": "Clear rate",
"称号": "Titles",
"战斗累计": "Combat totals",
"时间": "Time",
"模式": "Mode",
"结果": "Result",
"点击查看该局结算": "View this match's settlement",
"单人": "Solo",
"同盟": "Co-op",
"胜": "Win",
"负": "Loss",
"已导出 {n} 条对局记录": "Exported {n} match {n|record|records}",
"已导入 {added} 条记录{tail}": "Imported {added} {added|record|records}{tail}",
"(跳过重复 {skipped} 条)": " ({skipped} {skipped|duplicate|duplicates} skipped)",
"没有新记录(全部与现有数据重复)": "Nothing new (everything already exists)",
"导入失败:不是有效的统计导出文件": "Import failed: not a valid stats export file",
"已清空本机统计数据": "Local stats cleared",
"本机数据 · 仅记录本浏览器中你参与的对局": "Local data · only matches you took part in, in this browser",
"检测到更新版本的统计数据,请先升级客户端再查看,以免覆盖数据。": "Stats data from a newer version detected — upgrade the client before viewing, or the data could be overwritten.",
"还没有记录 —— 完成一局对局后,这里会开始积累。": "No records yet — finish a match and the page starts filling in.",
"未知": "Unknown",
"对局记录": "Match history",
"{n} 小时": "{n} h",
"计入统计:打到结算的对局,以及中途退出前至少清完 1 个回合的对局;更早退出的对局只留在对局记录里。": "Counted: matches played to the settlement, and matches given up after clearing at least 1 round. Matches given up earlier stay in the match history only.",
"另有 {n} 局未计入统计": "{n} more {n|match|matches} not counted",
"没有清完第一个回合就退出,不计入统计": "Left before clearing the first round — not counted",
"中途退出的对局没有结算可看": "A match given up has no settlement to view",
"较早的对局只保留汇总,不能回看结算": "Older matches keep only their summary — no settlement to view",
"中途退出": "Left early",
"不计入统计": "Not counted",
"显示更多(还有 {n} 局)": "Show more ({n} left)",
"点击一行,回看该局结算;中途退出的对局只留记录。": "Click a row to re-view that match's settlement; a match given up only leaves its entry.",
"最多保留最近 {max} 局,只有最新的 {full} 局能回看结算。": "Only the latest {max} matches are kept, and only the newest {full} can show their settlement again.",
"返回统计": "Back to Stats",
"文件过大,无法导入": "File too large to import",
"无法写入本机存储,可能已满": "Could not write to local storage — it may be full",
"({n} 条无法读取)": " ({n} unreadable)",
"导入失败:这份文件由更新版本的客户端导出,请先升级客户端": "Import failed: this file was exported by a newer client — upgrade the client first",
"清空统计数据": "Clear Statistics",
"将删除本机保存的 {n} 条对局记录,无法恢复。": "This deletes the {n} match {n|record|records} stored on this device. It cannot be undone.",
"导出为文件(可在别的设备上导入)": "Export to a file (import it on another device)",
"从导出的文件导入(与现有记录合并)": "Import from an exported file (merged with the current records)",
"清空本机的统计数据": "Clear the statistics stored on this device",
"本机数据:只保存在这个浏览器里,不会上传到服务器;同盟对局里队友的代号也会存在本机(导出文件里也有)。": "Local data: kept only in this browser, never uploaded to the server. Teammates' names from Team Simulations are stored here too (and appear in an export file).",
"本机保存的统计数据无法读取,已另存为备份,从现在起重新记录。": "The stats stored on this device could not be read. They were set aside as a backup and recording starts afresh.",
"有 {n} 条记录已损坏,已忽略。": "{n} damaged {n|record was|records were} ignored.",
"攻回": "Offensive",
"受回": "Defensive",
"自回": "Auto",
"常驻": "Always",
"瞬发": "Instant",
"部署触发": "On deployment",
"未精英化": "Non-Elite",
"精英阶段1": "Elite 1",
"精英阶段2": "Elite 2",
"精英阶段2-60级": "Elite 2 Lv.60",
"潜能": "Potential",
"潜能 {n}": "Potential {n}",
"练度": "Development",
"潜能与练度": "Potential & development",
"满潜能 · 精英2 Lv.60": "Max potential · Elite 2 Lv.60",
"干员持有中标记为未持有:替补干员没有潜能与练度;这里的设置在改回「持有」后生效": "Marked as not owned on Operator Roster: the stand-in has no potential or development; these settings apply once it is owned again",
"原型干员没有潜能与练度": "A prototype Operator has no potential or development",
"模组仅在精锐形态生效": "Modules apply only to the Elite form",
"未持有(干员持有):由替补干员上场,替补干员没有潜能与练度": "Not owned (Operator Roster): its stand-in fights, with no potential or development",
"含潜能与练度;": "Potential and development included; ",
"将 {nChanged} 名干员的技能、模组、潜能与练度恢复为默认配置?": "Reset the skills, modules, potential and development of {nChanged} Operators to their defaults?",
"已导入 {n} 名干员的潜能与练度": "Imported the potential and development of {n} Operators",
"精英1": "E1",
"精英2": "E2",
"精英2 Lv.60": "E2 Lv.60",
"开始模拟前可调整干员携带的技能与模组,以及潜能与练度;干员的局内等级不可调整": "Before the simulation starts you can change the Skills and Modules your Operators carry, and their potential and development; Operator levels in the match cannot be changed",
"默认满潜能、精英2 Lv.60(满加成)。官方的潜能取你自己的潜能,练度是自持有加成(已持有的同名干员的养成,攻击 / 防御 / 生命单独乘算);未持有的特许干员在官方按潜能1、没有加成,要照官方打请手动设为潜能1、未精英化。": "Default: max Potential, Elite 2 Lv.60 (the full bonus). Officially, Potential is your own and development is the owned-operator bonus (your training of the same Operator: ATK / DEF / HP, a separate multiplier); a licensed Operator you do not own fights at Potential 1 with no bonus — to play it that way, set Potential 1 and Non-Elite here.",
"重刷本局": "Reroll Setup",
"重刷本局投票:{agreed}/{total}": "Setup Reroll Vote: {agreed}/{total}",
"全员同意后重新随机战场、领袖、特训敌人和禁用;确认倒计时已暂停。": "Unanimous consent rerolls the battlefield, leader, special enemies and bans. The briefing countdown is paused.",
+140 -1
View File
@@ -110,6 +110,9 @@
"复制密钥": "コードをコピー",
"复制链接": "リンクをコピー",
"由创建者选择": "ホストが選択",
"由创建者设置": "ホストが設定",
"AI 队友最后选择": "AI チームメイトは最後に選択",
"策略与机变轮选时,所有博士先于 AI 队友选择": "戦術と調整の順番選択では、すべてのドクターが AI チームメイトより先に選びます",
"连接中断,请稍候重试": "接続が切断されました。しばらくしてから再試行してください",
"离开同盟": "同盟から退出",
"你是同盟的创建者,离开后创建者身份将移交或同盟解散。确定离开吗?": "あなたはこの同盟のホストです。退出するとホストが他のドクターに移るか、同盟が解散します。退出しますか?",
@@ -154,6 +157,7 @@
"完成": "完了",
"背景音乐": "BGM",
"干员语音": "オペレーターボイス",
"语音语言": "ボイス言語",
"音效": "効果音",
"静音": "ミュート",
"显示伤害数字": "ダメージ数値を表示",
@@ -385,6 +389,12 @@
"CONTINGENCY // 机变阶段": "CONTINGENCY // 調整段階",
"选择一项": "1つ選択",
"倒计时结束后仍未选定将自动分配": "カウントダウン終了までに選ばない場合、自動で割り当てられます",
"教鞭选择": "教鞭の選択",
"教鞭 · 战术特训": "教鞭 · 戦術訓練",
"请选择一项战术特训": "戦術訓練を1つ選択してください",
"请先完成教鞭选择": "先に教鞭の選択を完了してください",
"休整期结束时未选择将自动选定": "未選択の場合、整備フェーズ終了時に自動で選択されます",
"从候选中选择一项战术特训": "候補から戦術訓練を1つ選択",
"选择一项(无时间限制)": "1つ選択(時間制限なし)",
" · 点击卡牌选中,再次点击确认": " · カードをタップで選択、もう一度タップで確定",
"已完成选择": "選択完了",
@@ -682,6 +692,7 @@
" · 未装备模组": " · モジュール未装備",
"已购买": "購入済み",
"已招募": "獲得済み",
"空栏位:刷新或下回合开始时补满": "空きスロット:更新するか次のラウンド開始時に補充されます",
"再次点击确认升级({price} 资金)": "もう一度タップでレベルアップ(資金{price})",
"升级调度中心({price} 资金) · {key}": "管理センターをレベルアップ(資金{price}) · {key}",
"已满级": "最大レベル",
@@ -778,6 +789,7 @@
"观战席": "観戦席",
"移出观战者 {name}": "観戦者{name}を除外",
"移出该观战者": "この観戦者を除外",
"观战者不占博士席位,只能观看;创建者可以把观战者移出,被移出的人不再收到战场画面。": "観戦者はドクター枠を使わず、観戦のみ可能です。ホストは観戦者を除外でき、除外された人には戦場の映像が届かなくなります。",
"入座": "着席",
"移出同盟": "同盟から除外",
"确定将「{name}」移出同盟吗?对方可以凭同盟密钥重新加入。": "「{name}」を同盟から除外しますか?相手は同盟コードで再参加できます。",
@@ -808,6 +820,15 @@
"已恢复默认快捷键": "ショートカットキーをデフォルトに戻しました",
"快捷键需要实体键盘;触屏设备连接键盘后可用": "ショートカットキーには物理キーボードが必要です。タッチ端末でもキーボードを接続すれば使えます",
"查看 / 更改快捷键": "ショートカットキーの確認 / 変更",
"问题反馈": "問題の報告",
"复制诊断信息": "診断情報をコピー",
"遇到问题时,复制诊断信息并粘贴到 GitHub issue 中,开发者就能看到出错时的情况。诊断信息只在本机生成,不会自动上传;玩家名和房间号会被替换。": "問題が起きたときは、診断情報をコピーして GitHub の issue に貼り付けてください。開発者がエラー発生時の状況を確認できます。診断情報はこの端末内で作成され、自動でアップロードされることはありません。プレイヤー名とルーム番号は置き換えられます。",
"已记录的错误": "記録されたエラー",
"附上本场战斗": "この戦闘を添付",
"开发者可以用附上的战斗数据重现这场战斗。": "添付した戦闘データから、開発者がこの戦闘を再現できます。",
"已复制诊断信息,可以粘贴到 GitHub issue 中": "診断情報をコピーしました。GitHub の issue に貼り付けてください",
"无法写入剪贴板,请手动复制下面的内容": "クリップボードに書き込めませんでした。下の内容を手動でコピーしてください",
"诊断信息": "診断情報",
"观战中无法进行该操作": "観戦中はこの操作はできません",
"标准": "標準",
"险境": "逆境",
@@ -815,6 +836,8 @@
"终极": "究極",
"rounds::回合": "ラウンド",
"{who}:获得{name}": "{who}:{name}を獲得",
"{who}:卡池中已没有{name}": "{who}:共有プールに{name}が残っていません",
"{who}:没有可获得的同盟约干员": "{who}:入手できる同じ盟約のオペレーターがいません",
"获得{name}": "{name}を獲得",
"联防阶段:{names} 迎战突破防线的敌人": "防衛支援段階:{names}が防衛線を突破した敵を迎え撃つ",
"地形变化:{names}无法停留在原位置,已撤回整备区": "地形変化:{names}は元の位置に留まれないため、整備エリアに戻りました",
@@ -903,6 +926,22 @@
"地面单位可通过": "地上ユニットも通過可能",
"地形机制": "地形の仕組み",
"这一格": "このマス",
"场地装置": "ステージ装置",
"装置机制": "装置の仕組み",
"挡在地面上的工事:地面敌人会绕开它走;只有无路可走时才会撞上来,把它摧毁后继续前进": "地面に置かれた障害物:地上の敵は迂回して進み、他に道がないときだけぶつかって破壊し、そのまま進む",
"生命 {hp} 点,被摧毁后从场上消失": "HP {hp}。破壊されると戦場から消える",
"自动攻击射程内的一名敌人,造成法术伤害,命中的敌人附加脆弱": "射程内の敵1体を自動で攻撃して術ダメージを与え、命中した敵に脆弱を付与する",
"按当前层数最高的盟约计算:每 1 层,攻击速度 +{aspd}(至多 +{aspdMax}),附加的脆弱 +{frag}%(至多 +{fragMax}%)": "加算数が最も多い盟約に応じて強化:加算数1ごとに攻撃速度+{aspd}(最大+{aspdMax})、付与する脆弱+{frag}%(最大+{fragMax}%)",
"脆弱持续 {sec} 秒": "脆弱は{sec}秒持続する",
"高台位装置:地面敌人不能走上这一格": "高台の装置:地上の敵はこのマスに入れない",
"远程位干员可以部署在其上;站上去的干员在高位,不阻挡敌人": "遠距離位置のオペレーターを配置できる。上に立つオペレーターは高所にいるため、敵をブロックしない",
"向前方 {n} 格吹出气流(这一台朝{dir})": "前方{n}マスに気流を吹き出す(この装置は{dir}向き)",
"朝向与风向相同的攻击力 {v}": "気流と同じ向きの攻撃力{v}",
"相反的 {v}": "逆向きは{v}",
"部署在气流里的干员:{mods}": "気流内に配置されたオペレーター:{mods}",
"顺风移动速度 ×{v}": "気流と同じ向きに移動すると移動速度×{v}",
"逆风 ×{v}": "逆向きは×{v}",
"在气流里移动的敌人:{mods}": "気流内を移動する敵:{mods}",
"全部清空": "すべてクリア",
"清空全部 {n} 个自选名额?": "自由編成枠{n}つをすべてクリアしますか?",
"文件过大,请选择「导出」下载的自选编队文件": "ファイルが大きすぎます。「エクスポート」でダウンロードした自由編成ファイルを選んでください",
@@ -1061,5 +1100,105 @@
"{files} 没有下载成功,这一页无法显示;已保存的设置不受影响。请刷新页面;仍不行时,请检查广告拦截插件和网络。": "{files}をダウンロードできなかったため、このページを表示できません。保存済みの設定に影響はありません。ページを更新してください。それでも解決しない場合は、広告ブロッカーとネットワークを確認してください。",
"全员无伤!": "全員無傷!",
"生命值减少 −{n}": "耐久値減少 −{n}",
"当前语言的界面文字为机器翻译,可能不够准确,欢迎在 GitHub 上指正。": "この言語の画面表示は機械翻訳のため、不正確な場合があります。GitHubでの修正提案を歓迎します。"
"当前语言的界面文字为机器翻译,可能不够准确,欢迎在 GitHub 上指正。": "この言語の画面表示は機械翻訳のため、不正確な場合があります。GitHubでの修正提案を歓迎します。",
"统计数据": "統計データ",
"统计": "統計",
"总览": "概要",
"总局数": "対戦数",
"胜场": "勝利数",
"胜率": "勝率",
"到达隐秘核心": "シークレットコア到達",
"通关隐秘核心": "シークレットコアクリア",
"最远回合": "最高ラウンド",
"累计时长": "累計プレイ時間",
"{n} 分": "{n}分",
"难度": "難易度",
"局数": "対戦数",
"隐秘核心通关": "シークレットコアクリア",
"使用局数": "使用回数",
"通过局数": "クリア回数",
"通过率": "クリア率",
"称号": "称号",
"战斗累计": "戦闘累計",
"时间": "日時",
"模式": "モード",
"结果": "結果",
"点击查看该局结算": "この対戦の結果を見る",
"单人": "単独",
"同盟": "連合",
"胜": "勝",
"负": "敗",
"已导出 {n} 条对局记录": "対戦記録を{n}件エクスポートしました",
"已导入 {added} 条记录{tail}": "{added}件の記録をインポートしました{tail}",
"(跳过重复 {skipped} 条)": "(重複{skipped}件をスキップ)",
"没有新记录(全部与现有数据重复)": "新しい記録はありません(すべて既存のデータと重複しています)",
"导入失败:不是有效的统计导出文件": "インポートに失敗しました:有効な統計のエクスポートファイルではありません",
"已清空本机统计数据": "この端末の統計データを消去しました",
"本机数据 · 仅记录本浏览器中你参与的对局": "この端末のデータ · このブラウザで参加した対戦のみ記録されます",
"检测到更新版本的统计数据,请先升级客户端再查看,以免覆盖数据。": "より新しいバージョンの統計データが見つかりました。上書きを避けるため、クライアントを更新してから表示してください。",
"还没有记录 —— 完成一局对局后,这里会开始积累。": "記録はまだありません — 対戦を1つ終えると、ここに蓄積されていきます。",
"未知": "不明",
"对局记录": "対戦履歴",
"{n} 小时": "{n}時間",
"计入统计:打到结算的对局,以及中途退出前至少清完 1 个回合的对局;更早退出的对局只留在对局记录里。": "集計対象:結果画面まで進んだ対戦と、1ラウンド以上クリアしてから途中退出した対戦。それより早く退出した対戦は対戦履歴にのみ残ります。",
"另有 {n} 局未计入统计": "他に{n}件の対戦が集計対象外です",
"没有清完第一个回合就退出,不计入统计": "最初のラウンドをクリアする前に退出したため、集計対象外です",
"中途退出的对局没有结算可看": "途中退出した対戦には表示できる結果がありません",
"较早的对局只保留汇总,不能回看结算": "古い対戦は要約のみ保存されているため、結果は表示できません",
"中途退出": "途中退出",
"不计入统计": "集計対象外",
"显示更多(还有 {n} 局)": "さらに表示(残り{n}件)",
"点击一行,回看该局结算;中途退出的对局只留记录。": "行をクリックすると、その対戦の結果をもう一度見られます。途中退出した対戦は記録のみ残ります。",
"最多保留最近 {max} 局,只有最新的 {full} 局能回看结算。": "保存されるのは直近{max}件のみで、結果を再表示できるのは最新の{full}件だけです。",
"返回统计": "統計に戻る",
"文件过大,无法导入": "ファイルが大きすぎるためインポートできません",
"无法写入本机存储,可能已满": "ローカルストレージに書き込めません。容量がいっぱいの可能性があります",
"({n} 条无法读取)": "(読み取れない記録{n}件)",
"导入失败:这份文件由更新版本的客户端导出,请先升级客户端": "インポートに失敗しました:このファイルはより新しいバージョンのクライアントでエクスポートされています。先にクライアントを更新してください",
"清空统计数据": "統計データを消去",
"将删除本机保存的 {n} 条对局记录,无法恢复。": "この端末に保存されている対戦記録{n}件を削除します。元に戻せません。",
"导出为文件(可在别的设备上导入)": "ファイルにエクスポート(別の端末でインポートできます)",
"从导出的文件导入(与现有记录合并)": "エクスポートしたファイルからインポート(既存の記録と統合)",
"清空本机的统计数据": "この端末の統計データを消去",
"本机数据:只保存在这个浏览器里,不会上传到服务器;同盟对局里队友的代号也会存在本机(导出文件里也有)。": "この端末のデータ:このブラウザにのみ保存され、サーバーには送信されません。連合演算の仲間の名前もこの端末に保存されます(エクスポートファイルにも含まれます)。",
"本机保存的统计数据无法读取,已另存为备份,从现在起重新记录。": "この端末に保存された統計データを読み取れませんでした。バックアップとして退避し、記録を最初からやり直します。",
"有 {n} 条记录已损坏,已忽略。": "破損した記録{n}件を無視しました。",
"攻回": "攻撃",
"受回": "被撃",
"自回": "自動",
"常驻": "常時",
"瞬发": "即時",
"部署触发": "配置時",
"未精英化": "未昇進",
"精英阶段1": "昇進段階1",
"精英阶段2": "昇進段階2",
"精英阶段2-60级": "昇進段階2-Lv60",
"潜能": "潜在",
"潜能 {n}": "潜在{n}",
"练度": "育成度",
"潜能与练度": "潜在と育成度",
"满潜能 · 精英2 Lv.60": "潜在最大・昇進2 Lv60",
"干员持有中标记为未持有:替补干员没有潜能与练度;这里的设置在改回「持有」后生效": "オペレーター所持で未所持に設定中:代役オペレーターには潜在も育成度もありません。ここでの設定は「所持」に戻すと有効になります",
"原型干员没有潜能与练度": "モデルオペレーターには潜在も育成度もありません",
"模组仅在精锐形态生效": "モジュールは精鋭のみ有効です",
"未持有(干员持有):由替补干员上场,替补干员没有潜能与练度": "未所持(オペレーター所持):代役オペレーターが出撃し、潜在も育成度もありません",
"含潜能与练度;": "潜在と育成度を含む。",
"将 {nChanged} 名干员的技能、模组、潜能与练度恢复为默认配置?": "{nChanged}名のオペレーターのスキル・モジュール・潜在・育成度を初期設定に戻しますか?",
"已导入 {n} 名干员的潜能与练度": "{n}名のオペレーターの潜在と育成度をインポートしました",
"精英1": "昇進1",
"精英2": "昇進2",
"精英2 Lv.60": "昇進2 Lv60",
"开始模拟前可调整干员携带的技能与模组,以及潜能与练度;干员的局内等级不可调整": "シミュレーション開始前に、オペレーターのスキルとモジュール、潜在と育成度を調整できます(局内のオペレーターのレベルは変更不可)",
"默认满潜能、精英2 Lv.60(满加成)。官方的潜能取你自己的潜能,练度是自持有加成(已持有的同名干员的养成,攻击 / 防御 / 生命单独乘算);未持有的特许干员在官方按潜能1、没有加成,要照官方打请手动设为潜能1、未精英化。": "初期値は潜在最大・昇進2 Lv60(ボーナス最大)。公式では潜在はあなた自身の潜在、育成度は所持オペレーターボーナス(同名オペレーターの育成度による攻撃・防御・HPの独立した乗算)です。未所持のプリセットオペレーターは公式では潜在1・ボーナスなしのため、公式どおりにするには潜在1・未昇進に設定してください。",
"重刷本局": "開始条件を再抽選",
"重刷本局投票:{agreed}/{total}": "再抽選の投票:{agreed}/{total}",
"全员同意后重新随机战场、领袖、特训敌人和禁用;确认倒计时已暂停。": "全員の同意後に戦場、敵ボス、訓練用仮想敵、禁止対象を再抽選します。確認のカウントダウンは一時停止中です。",
"保留房间和个人配置,重新随机全部开局信息。": "同盟と個人設定を維持して、開始条件をすべて再抽選します。",
"已同意": "同意済み",
"待同意": "同意待ち",
"全体真人玩家一致同意后刷新,随机结果可能重复。": "全プレイヤーの同意後に再抽選します。同じ結果になる場合もあります。",
"请等待所有玩家连接后再发起投票": "全プレイヤーの接続を待ってから投票を開始してください",
"同意重刷": "再抽選に同意",
"拒绝重刷": "再抽選を拒否",
"取消投票": "投票を中止"
}
+140 -1
View File
@@ -106,6 +106,9 @@
"复制密钥": "코드 복사",
"复制链接": "링크 복사",
"由创建者选择": "방장이 선택",
"由创建者设置": "방장이 설정",
"AI 队友最后选择": "AI 팀원은 마지막에 선택",
"策略与机变轮选时,所有博士先于 AI 队友选择": "전략과 기회 순서 선택에서 모든 박사가 AI 팀원보다 먼저 선택합니다",
"连接中断,请稍候重试": "연결이 끊어졌습니다. 잠시 후 다시 시도해 주세요.",
"离开同盟": "동맹 나가기",
"你是同盟的创建者,离开后创建者身份将移交或同盟解散。确定离开吗?": "이 동맹의 방장입니다. 나가면 방장 권한이 이양되거나 동맹이 해산됩니다. 정말 나가시겠습니까?",
@@ -150,6 +153,7 @@
"完成": "완료",
"背景音乐": "배경 음악",
"干员语音": "오퍼레이터 음성",
"语音语言": "음성 언어",
"音效": "효과음",
"静音": "음소거",
"显示伤害数字": "대미지 수치 표시",
@@ -381,6 +385,12 @@
"CONTINGENCY // 机变阶段": "CONTINGENCY // 기회 단계",
"选择一项": "하나를 선택하세요",
"倒计时结束后仍未选定将自动分配": "카운트다운이 끝날 때까지 선택하지 않으면 자동으로 지정됩니다",
"教鞭选择": "교편 선택",
"教鞭 · 战术特训": "교편 · 전술 특훈",
"请选择一项战术特训": "전술 특훈 1개를 선택해 주세요",
"请先完成教鞭选择": "먼저 교편 선택을 완료해 주세요",
"休整期结束时未选择将自动选定": "선택하지 않으면 휴식 기간 종료 시 자동으로 선택됩니다",
"从候选中选择一项战术特训": "후보 중 전술 특훈 1개 선택",
"选择一项(无时间限制)": "하나를 선택하세요 (시간 제한 없음)",
" · 点击卡牌选中,再次点击确认": " · 카드를 탭해 선택하고 한 번 더 탭해 확인",
"已完成选择": "선택 완료",
@@ -678,6 +688,7 @@
" · 未装备模组": " · 모듈 미장착",
"已购买": "구매 완료",
"已招募": "구매 완료",
"空栏位:刷新或下回合开始时补满": "빈 슬롯: 갱신하거나 다음 라운드가 시작되면 채워집니다",
"再次点击确认升级({price} 资金)": "한 번 더 탭해 승급 확인 (자금 {price})",
"升级调度中心({price} 资金) · {key}": "보급센터 승급 (자금 {price}) · {key}",
"已满级": "최대 레벨",
@@ -774,6 +785,7 @@
"观战席": "관전석",
"移出观战者 {name}": "관전자 {name} 내보내기",
"移出该观战者": "이 관전자 내보내기",
"观战者不占博士席位,只能观看;创建者可以把观战者移出,被移出的人不再收到战场画面。": "관전자는 박사 자리를 차지하지 않고 관전만 할 수 있으며, 방장은 관전자를 내보낼 수 있습니다. 내보내진 사람은 더 이상 전장 화면을 받지 않습니다.",
"入座": "착석",
"移出同盟": "동맹에서 내보내기",
"确定将「{name}」移出同盟吗?对方可以凭同盟密钥重新加入。": "{name} 님을 동맹에서 내보내시겠습니까? 동맹 코드로 다시 참가할 수 있습니다.",
@@ -804,6 +816,15 @@
"已恢复默认快捷键": "기본 단축키를 복원했습니다",
"快捷键需要实体键盘;触屏设备连接键盘后可用": "단축키는 실제 키보드가 필요합니다. 터치 기기에서는 키보드를 연결하면 사용할 수 있습니다.",
"查看 / 更改快捷键": "단축키 보기 / 변경",
"问题反馈": "문제 신고",
"复制诊断信息": "진단 정보 복사",
"遇到问题时,复制诊断信息并粘贴到 GitHub issue 中,开发者就能看到出错时的情况。诊断信息只在本机生成,不会自动上传;玩家名和房间号会被替换。": "문제가 생기면 진단 정보를 복사해 GitHub issue에 붙여 넣어 주세요. 개발자가 오류가 났을 때의 상황을 확인할 수 있습니다. 진단 정보는 이 기기에서만 만들어지며 자동으로 업로드되지 않습니다. 플레이어 이름과 방 번호는 다른 값으로 바뀝니다.",
"已记录的错误": "기록된 오류",
"附上本场战斗": "이 전투 첨부",
"开发者可以用附上的战斗数据重现这场战斗。": "첨부한 전투 데이터로 개발자가 이 전투를 재현할 수 있습니다.",
"已复制诊断信息,可以粘贴到 GitHub issue 中": "진단 정보를 복사했습니다. GitHub issue에 붙여 넣어 주세요",
"无法写入剪贴板,请手动复制下面的内容": "클립보드에 쓸 수 없습니다. 아래 내용을 직접 복사해 주세요",
"诊断信息": "진단 정보",
"观战中无法进行该操作": "관전 중에는 할 수 없습니다",
"标准": "표준",
"险境": "험지",
@@ -811,6 +832,8 @@
"终极": "초월",
"rounds::回合": "라운드",
"{who}:获得{name}": "{who}: {name} 획득",
"{who}:卡池中已没有{name}": "{who}: 공유 풀에 {name}이(가) 남아 있지 않습니다",
"{who}:没有可获得的同盟约干员": "{who}: 획득할 수 있는 같은 맹약의 오퍼레이터가 없습니다",
"获得{name}": "{name} 획득",
"联防阶段:{names} 迎战突破防线的敌人": "연합 방어 단계: {names} 님이 방어선을 돌파한 적을 맞아 싸웁니다",
"地形变化:{names}无法停留在原位置,已撤回整备区": "지형 변화: {names} 오퍼레이터가 원래 위치에 머무를 수 없어 정비 구역으로 돌아갔습니다",
@@ -899,6 +922,22 @@
"地面单位可通过": "지상 유닛 통과 가능",
"地形机制": "지형 메커니즘",
"这一格": "이 칸",
"场地装置": "스테이지 장치",
"装置机制": "장치 메커니즘",
"挡在地面上的工事:地面敌人会绕开它走;只有无路可走时才会撞上来,把它摧毁后继续前进": "지면에 놓인 구조물: 지상 적은 우회해서 이동하며, 다른 길이 없을 때만 부딪혀 파괴하고 계속 전진함",
"生命 {hp} 点,被摧毁后从场上消失": "HP {hp}, 파괴되면 전장에서 사라짐",
"自动攻击射程内的一名敌人,造成法术伤害,命中的敌人附加脆弱": "사거리 내 적 1명을 자동으로 공격해 마법 대미지를 입히고, 명중한 적에게 취약을 부여함",
"按当前层数最高的盟约计算:每 1 层,攻击速度 +{aspd}(至多 +{aspdMax}),附加的脆弱 +{frag}%(至多 +{fragMax}%)": "중첩 수가 가장 높은 맹약 기준: 중첩 1회당 공격 속도 +{aspd}(최대 +{aspdMax}), 부여하는 취약 +{frag}%(최대 +{fragMax}%)",
"脆弱持续 {sec} 秒": "취약은 {sec}초간 지속됨",
"高台位装置:地面敌人不能走上这一格": "고지 장치: 지상 적은 이 칸에 올라갈 수 없음",
"远程位干员可以部署在其上;站上去的干员在高位,不阻挡敌人": "원거리 위치 오퍼레이터를 배치할 수 있으며, 위에 선 오퍼레이터는 고지에 있어 적을 저지하지 않음",
"向前方 {n} 格吹出气流(这一台朝{dir})": "전방 {n}칸으로 기류를 내뿜음(이 장치는 {dir} 방향)",
"朝向与风向相同的攻击力 {v}": "기류와 같은 방향을 향한 공격력 {v}",
"相反的 {v}": "반대 방향은 {v}",
"部署在气流里的干员:{mods}": "기류 속에 배치된 오퍼레이터: {mods}",
"顺风移动速度 ×{v}": "기류 방향으로 이동 시 이동 속도 ×{v}",
"逆风 ×{v}": "역방향은 ×{v}",
"在气流里移动的敌人:{mods}": "기류 속을 이동하는 적: {mods}",
"全部清空": "모두 비우기",
"清空全部 {n} 个自选名额?": "자유 선택 슬롯 {n}개를 모두 비우시겠습니까?",
"文件过大,请选择「导出」下载的自选编队文件": "파일이 너무 큽니다. [내보내기]로 다운로드한 자유 편성 파일을 선택해 주세요.",
@@ -1057,5 +1096,105 @@
"{files} 没有下载成功,这一页无法显示;已保存的设置不受影响。请刷新页面;仍不行时,请检查广告拦截插件和网络。": "{files} 다운로드에 실패하여 이 페이지를 표시할 수 없습니다. 저장된 설정에는 영향이 없습니다. 페이지를 새로고침해 주세요. 그래도 안 되면 광고 차단 확장 프로그램과 네트워크를 확인해 주세요.",
"全员无伤!": "전원 무사!",
"生命值减少 −{n}": "목표 HP 감소 −{n}",
"当前语言的界面文字为机器翻译,可能不够准确,欢迎在 GitHub 上指正。": "현재 언어의 인터페이스 텍스트는 기계 번역이므로 정확하지 않을 수 있습니다. GitHub에서 수정 제안을 환영합니다."
"当前语言的界面文字为机器翻译,可能不够准确,欢迎在 GitHub 上指正。": "현재 언어의 인터페이스 텍스트는 기계 번역이므로 정확하지 않을 수 있습니다. GitHub에서 수정 제안을 환영합니다.",
"统计数据": "통계",
"统计": "통계",
"总览": "개요",
"总局数": "총 판수",
"胜场": "승리",
"胜率": "승률",
"到达隐秘核心": "숨겨진 핵심 도달",
"通关隐秘核心": "숨겨진 핵심 클리어",
"最远回合": "최고 라운드",
"累计时长": "누적 플레이 시간",
"{n} 分": "{n}분",
"难度": "난이도",
"局数": "판수",
"隐秘核心通关": "숨겨진 핵심 클리어",
"使用局数": "사용 판수",
"通过局数": "클리어 판수",
"通过率": "클리어율",
"称号": "평가",
"战斗累计": "전투 누적",
"时间": "시간",
"模式": "모드",
"结果": "결과",
"点击查看该局结算": "이 판의 정산 보기",
"单人": "독립",
"同盟": "동맹",
"胜": "승",
"负": "패",
"已导出 {n} 条对局记录": "기록 {n}건을 내보냈습니다",
"已导入 {added} 条记录{tail}": "기록 {added}건을 가져왔습니다{tail}",
"(跳过重复 {skipped} 条)": " (중복 {skipped}건 건너뜀)",
"没有新记录(全部与现有数据重复)": "새 기록이 없습니다(모두 기존 데이터와 중복)",
"导入失败:不是有效的统计导出文件": "가져오기 실패: 올바른 통계 내보내기 파일이 아닙니다",
"已清空本机统计数据": "이 기기의 통계 데이터를 삭제했습니다",
"本机数据 · 仅记录本浏览器中你参与的对局": "이 기기의 데이터 · 이 브라우저에서 참여한 판만 기록됩니다",
"检测到更新版本的统计数据,请先升级客户端再查看,以免覆盖数据。": "더 최신 버전의 통계 데이터가 감지되었습니다. 데이터를 덮어쓰지 않도록 클라이언트를 업데이트한 뒤 확인하세요.",
"还没有记录 —— 完成一局对局后,这里会开始积累。": "아직 기록이 없습니다 — 한 판을 마치면 여기에 쌓이기 시작합니다.",
"未知": "알 수 없음",
"对局记录": "판 기록",
"{n} 小时": "{n}시간",
"计入统计:打到结算的对局,以及中途退出前至少清完 1 个回合的对局;更早退出的对局只留在对局记录里。": "집계 대상: 정산까지 진행한 판, 그리고 1라운드 이상 클리어한 뒤 중도 포기한 판. 그보다 일찍 나간 판은 판 기록에만 남습니다.",
"另有 {n} 局未计入统计": "집계에 포함되지 않은 판이 {n}건 더 있습니다",
"没有清完第一个回合就退出,不计入统计": "첫 라운드를 클리어하기 전에 나가서 집계에 포함되지 않습니다",
"中途退出的对局没有结算可看": "중도 포기한 판은 볼 수 있는 정산이 없습니다",
"较早的对局只保留汇总,不能回看结算": "오래된 판은 요약만 저장되어 정산을 볼 수 없습니다",
"中途退出": "중도 퇴장",
"不计入统计": "집계 제외",
"显示更多(还有 {n} 局)": "더 보기 (남은 {n}건)",
"点击一行,回看该局结算;中途退出的对局只留记录。": "행을 누르면 해당 판의 정산을 다시 볼 수 있습니다. 중도 포기한 판은 기록만 남습니다.",
"最多保留最近 {max} 局,只有最新的 {full} 局能回看结算。": "최근 {max}판까지만 보관하며, 정산을 다시 볼 수 있는 것은 최신 {full}판뿐입니다.",
"返回统计": "통계로 돌아가기",
"文件过大,无法导入": "파일이 너무 커서 가져올 수 없습니다",
"无法写入本机存储,可能已满": "로컬 저장소에 쓰지 못했습니다. 공간이 가득 찼을 수 있습니다",
"({n} 条无法读取)": " (읽을 수 없는 기록 {n}건)",
"导入失败:这份文件由更新版本的客户端导出,请先升级客户端": "가져오기 실패: 더 최신 버전 클라이언트에서 내보낸 파일입니다. 먼저 클라이언트를 업데이트하세요",
"清空统计数据": "통계 데이터 비우기",
"将删除本机保存的 {n} 条对局记录,无法恢复。": "이 기기에 저장된 기록 {n}건을 삭제합니다. 되돌릴 수 없습니다.",
"导出为文件(可在别的设备上导入)": "파일로 내보내기 (다른 기기에서 가져올 수 있습니다)",
"从导出的文件导入(与现有记录合并)": "내보낸 파일에서 가져오기 (기존 기록과 병합)",
"清空本机的统计数据": "이 기기의 통계 데이터 비우기",
"本机数据:只保存在这个浏览器里,不会上传到服务器;同盟对局里队友的代号也会存在本机(导出文件里也有)。": "이 기기의 데이터: 이 브라우저에만 저장되며 서버로 전송되지 않습니다. 동맹 시뮬레이션에서 함께한 동료의 이름도 이 기기에 저장됩니다(내보낸 파일에도 포함).",
"本机保存的统计数据无法读取,已另存为备份,从现在起重新记录。": "이 기기에 저장된 통계 데이터를 읽을 수 없습니다. 백업으로 따로 보관했으며 지금부터 새로 기록합니다.",
"有 {n} 条记录已损坏,已忽略。": "손상된 기록 {n}건을 무시했습니다.",
"攻回": "공격",
"受回": "피격",
"自回": "자동",
"常驻": "상시",
"瞬发": "즉시",
"部署触发": "배치 시",
"未精英化": "비정예화",
"精英阶段1": "1차 정예화",
"精英阶段2": "2차 정예화",
"精英阶段2-60级": "2차 정예화 Lv.60",
"潜能": "잠재",
"潜能 {n}": "잠재 {n}",
"练度": "육성도",
"潜能与练度": "잠재와 육성도",
"满潜能 · 精英2 Lv.60": "최대 잠재 · 2차 정예화 Lv.60",
"干员持有中标记为未持有:替补干员没有潜能与练度;这里的设置在改回「持有」后生效": "오퍼레이터 보유에서 미보유로 표시됨: 대체 오퍼레이터는 잠재와 육성도가 없습니다. 이 설정은 다시 '보유'로 바꾸면 적용됩니다",
"原型干员没有潜能与练度": "프로토타입 오퍼레이터는 잠재와 육성도가 없습니다",
"模组仅在精锐形态生效": "모듈은 정예화 형태에서만 적용됩니다",
"未持有(干员持有):由替补干员上场,替补干员没有潜能与练度": "미보유(오퍼레이터 보유): 대체 오퍼레이터가 출전하며 잠재와 육성도가 없습니다",
"含潜能与练度;": "잠재와 육성도 포함. ",
"将 {nChanged} 名干员的技能、模组、潜能与练度恢复为默认配置?": "오퍼레이터 {nChanged}명의 스킬, 모듈, 잠재, 육성도를 기본 설정으로 되돌릴까요?",
"已导入 {n} 名干员的潜能与练度": "오퍼레이터 {n}명의 잠재와 육성도를 가져왔습니다",
"精英1": "정예 1",
"精英2": "정예 2",
"精英2 Lv.60": "정예 2 Lv.60",
"开始模拟前可调整干员携带的技能与模组,以及潜能与练度;干员的局内等级不可调整": "시뮬레이션 시작 전에 오퍼레이터의 스킬과 모듈, 잠재와 육성도를 조정할 수 있습니다. 게임 내 오퍼레이터 레벨은 조정할 수 없습니다.",
"默认满潜能、精英2 Lv.60(满加成)。官方的潜能取你自己的潜能,练度是自持有加成(已持有的同名干员的养成,攻击 / 防御 / 生命单独乘算);未持有的特许干员在官方按潜能1、没有加成,要照官方打请手动设为潜能1、未精英化。": "기본값은 최대 잠재 · 2차 정예화 Lv.60(최대 보너스)입니다. 공식 규칙에서 잠재는 자신의 잠재, 육성도는 보유 오퍼레이터 보너스(같은 오퍼레이터의 육성도에 따른 공격 / 방어 / HP 별도 배율)입니다. 보유하지 않은 프리셋 오퍼레이터는 공식적으로 잠재 1·보너스 없음이므로, 공식대로 하려면 잠재 1, 비정예화로 직접 설정하세요.",
"重刷本局": "시작 정보 다시 추첨",
"重刷本局投票:{agreed}/{total}": "재추첨 투표: {agreed}/{total}",
"全员同意后重新随机战场、领袖、特训敌人和禁用;确认倒计时已暂停。": "모두 동의하면 전장, 리더 적, 특훈 적과 금지 대상을 다시 추첨합니다. 확인 카운트다운은 일시 정지되었습니다.",
"保留房间和个人配置,重新随机全部开局信息。": "방과 개인 설정을 유지하고 모든 시작 정보를 다시 추첨합니다.",
"已同意": "동의함",
"待同意": "동의 대기",
"全体真人玩家一致同意后刷新,随机结果可能重复。": "모든 플레이어가 동의하면 다시 추첨합니다. 같은 결과가 나올 수도 있습니다.",
"请等待所有玩家连接后再发起投票": "모든 플레이어가 연결된 후 투표를 시작해 주세요",
"同意重刷": "재추첨 동의",
"拒绝重刷": "재추첨 거절",
"取消投票": "투표 취소"
}
+140 -1
View File
@@ -108,6 +108,9 @@
"复制密钥": "複製金鑰",
"复制链接": "複製連結",
"由创建者选择": "由建立者選擇",
"由创建者设置": "由建立者設定",
"AI 队友最后选择": "AI 隊友最後選擇",
"策略与机变轮选时,所有博士先于 AI 队友选择": "策略與機變輪選時,所有博士先於 AI 隊友選擇",
"连接中断,请稍候重试": "連線中斷,請稍候重試",
"离开同盟": "離開同盟",
"你是同盟的创建者,离开后创建者身份将移交或同盟解散。确定离开吗?": "你是同盟的建立者,離開後建立者身分將移交或同盟解散。確定離開嗎?",
@@ -152,6 +155,7 @@
"完成": "完成",
"背景音乐": "背景音樂",
"干员语音": "幹員語音",
"语音语言": "語音語言",
"音效": "音效",
"静音": "靜音",
"显示伤害数字": "顯示傷害數字",
@@ -383,6 +387,12 @@
"CONTINGENCY // 机变阶段": "CONTINGENCY // 機變階段",
"选择一项": "選擇一項",
"倒计时结束后仍未选定将自动分配": "倒計時結束後仍未選定將自動分配",
"教鞭选择": "教鞭選擇",
"教鞭 · 战术特训": "教鞭 · 戰術特訓",
"请选择一项战术特训": "請選擇一項戰術特訓",
"请先完成教鞭选择": "請先完成教鞭選擇",
"休整期结束时未选择将自动选定": "休整期結束時未選擇將自動選定",
"从候选中选择一项战术特训": "從候選中選擇一項戰術特訓",
"选择一项(无时间限制)": "選擇一項(無時間限制)",
" · 点击卡牌选中,再次点击确认": " · 點擊卡牌選中,再次點擊確認",
"已完成选择": "已完成選擇",
@@ -680,6 +690,7 @@
" · 未装备模组": " · 未裝備模組",
"已购买": "已購買",
"已招募": "已招募",
"空栏位:刷新或下回合开始时补满": "空欄位:更新或下回合開始時補滿",
"再次点击确认升级({price} 资金)": "再次點擊確認升級({price} 資金)",
"升级调度中心({price} 资金) · {key}": "升級調度中心({price} 資金) · {key}",
"已满级": "已滿級",
@@ -776,6 +787,7 @@
"观战席": "觀戰席",
"移出观战者 {name}": "移出觀戰者 {name}",
"移出该观战者": "移出該觀戰者",
"观战者不占博士席位,只能观看;创建者可以把观战者移出,被移出的人不再收到战场画面。": "觀戰者不佔博士席位,只能觀看;建立者可以把觀戰者移出,被移出的人不再收到戰場畫面。",
"入座": "入座",
"移出同盟": "移出同盟",
"确定将「{name}」移出同盟吗?对方可以凭同盟密钥重新加入。": "確定將「{name}」移出同盟嗎?對方可以憑同盟金鑰重新加入。",
@@ -807,6 +819,15 @@
"已恢复默认快捷键": "已恢復預設快捷鍵",
"快捷键需要实体键盘;触屏设备连接键盘后可用": "快捷鍵需要實體鍵盤;觸控裝置連接鍵盤後可用",
"查看 / 更改快捷键": "查看 / 更改快捷鍵",
"问题反馈": "問題回報",
"复制诊断信息": "複製診斷資訊",
"遇到问题时,复制诊断信息并粘贴到 GitHub issue 中,开发者就能看到出错时的情况。诊断信息只在本机生成,不会自动上传;玩家名和房间号会被替换。": "遇到問題時,複製診斷資訊並貼到 GitHub issue 中,開發者就能看到出錯時的情況。診斷資訊只在本機產生,不會自動上傳;玩家名稱和房間號碼會被替換。",
"已记录的错误": "已記錄的錯誤",
"附上本场战斗": "附上本場戰鬥",
"开发者可以用附上的战斗数据重现这场战斗。": "開發者可以用附上的戰鬥資料重現這場戰鬥。",
"已复制诊断信息,可以粘贴到 GitHub issue 中": "已複製診斷資訊,可以貼到 GitHub issue 中",
"无法写入剪贴板,请手动复制下面的内容": "無法寫入剪貼簿,請手動複製下面的內容",
"诊断信息": "診斷資訊",
"观战中无法进行该操作": "觀戰中無法進行該操作",
"标准": "標準",
"险境": "險境",
@@ -814,6 +835,8 @@
"终极": "終極",
"rounds::回合": "回合",
"{who}:获得{name}": "{who}:獲得{name}",
"{who}:卡池中已没有{name}": "{who}:卡池中已沒有{name}",
"{who}:没有可获得的同盟约干员": "{who}:沒有可獲得的同盟約幹員",
"获得{name}": "獲得{name}",
"联防阶段:{names} 迎战突破防线的敌人": "聯防階段:{names} 迎戰突破防線的敵人",
"地形变化:{names}无法停留在原位置,已撤回整备区": "地形變化:{names}無法停留在原位置,已撤回整備區",
@@ -902,6 +925,22 @@
"地面单位可通过": "地面單位可通過",
"地形机制": "地形機制",
"这一格": "這一格",
"场地装置": "場地裝置",
"装置机制": "裝置機制",
"挡在地面上的工事:地面敌人会绕开它走;只有无路可走时才会撞上来,把它摧毁后继续前进": "擋在地面上的工事:地面敵人會繞開它走;只有無路可走時才會撞上來,把它摧毀後繼續前進",
"生命 {hp} 点,被摧毁后从场上消失": "生命 {hp} 點,被摧毀後從場上消失",
"自动攻击射程内的一名敌人,造成法术伤害,命中的敌人附加脆弱": "自動攻擊射程內的一名敵人,造成法術傷害,命中的敵人附加脆弱",
"按当前层数最高的盟约计算:每 1 层,攻击速度 +{aspd}(至多 +{aspdMax}),附加的脆弱 +{frag}%(至多 +{fragMax}%)": "按目前層數最高的盟約計算:每 1 層,攻擊速度 +{aspd}(最多 +{aspdMax}),附加的脆弱 +{frag}%(最多 +{fragMax}%)",
"脆弱持续 {sec} 秒": "脆弱持續 {sec} 秒",
"高台位装置:地面敌人不能走上这一格": "高台位裝置:地面敵人不能走上這一格",
"远程位干员可以部署在其上;站上去的干员在高位,不阻挡敌人": "遠程位幹員可以部署在其上;站上去的幹員在高位,不阻擋敵人",
"向前方 {n} 格吹出气流(这一台朝{dir})": "向前方 {n} 格吹出氣流(這一台朝{dir})",
"朝向与风向相同的攻击力 {v}": "朝向與風向相同的攻擊力 {v}",
"相反的 {v}": "相反的 {v}",
"部署在气流里的干员:{mods}": "部署在氣流裡的幹員:{mods}",
"顺风移动速度 ×{v}": "順風移動速度 ×{v}",
"逆风 ×{v}": "逆風 ×{v}",
"在气流里移动的敌人:{mods}": "在氣流裡移動的敵人:{mods}",
"全部清空": "全部清空",
"清空全部 {n} 个自选名额?": "清空全部 {n} 個自選名額?",
"文件过大,请选择「导出」下载的自选编队文件": "檔案過大,請選擇「匯出」下載的自選編隊檔案",
@@ -1059,5 +1098,105 @@
"游戏数据没有载入": "遊戲資料沒有載入",
"{files} 没有下载成功,这一页无法显示;已保存的设置不受影响。请刷新页面;仍不行时,请检查广告拦截插件和网络。": "{files} 沒有下載成功,這一頁無法顯示;已儲存的設定不受影響。請重新整理頁面;仍不行時,請檢查廣告攔截擴充功能和網路。",
"全员无伤!": "全員無傷!",
"生命值减少 −{n}": "生命值減少 −{n}"
"生命值减少 −{n}": "生命值減少 −{n}",
"统计数据": "統計資料",
"统计": "統計",
"总览": "總覽",
"总局数": "總局數",
"胜场": "勝場",
"胜率": "勝率",
"到达隐秘核心": "到達隱秘核心",
"通关隐秘核心": "通關隱秘核心",
"最远回合": "最遠回合",
"累计时长": "累計時長",
"{n} 分": "{n} 分",
"难度": "難度",
"局数": "局數",
"隐秘核心通关": "隱秘核心通關",
"使用局数": "使用局數",
"通过局数": "通過局數",
"通过率": "通過率",
"称号": "稱號",
"战斗累计": "戰鬥累計",
"时间": "時間",
"模式": "模式",
"结果": "結果",
"点击查看该局结算": "點擊查看該局結算",
"单人": "單人",
"同盟": "同盟",
"胜": "勝",
"负": "負",
"已导出 {n} 条对局记录": "已匯出 {n} 筆對局記錄",
"已导入 {added} 条记录{tail}": "已匯入 {added} 筆記錄{tail}",
"(跳过重复 {skipped} 条)": "(略過重複 {skipped} 筆)",
"没有新记录(全部与现有数据重复)": "沒有新記錄(全部與現有資料重複)",
"导入失败:不是有效的统计导出文件": "匯入失敗:不是有效的統計匯出檔案",
"已清空本机统计数据": "已清空本機統計資料",
"本机数据 · 仅记录本浏览器中你参与的对局": "本機資料 · 僅記錄本瀏覽器中你參與的對局",
"检测到更新版本的统计数据,请先升级客户端再查看,以免覆盖数据。": "偵測到更新版本的統計資料,請先升級用戶端再查看,以免覆蓋資料。",
"还没有记录 —— 完成一局对局后,这里会开始积累。": "還沒有記錄 —— 完成一局對局後,這裡會開始累積。",
"未知": "未知",
"对局记录": "對局記錄",
"{n} 小时": "{n} 小時",
"计入统计:打到结算的对局,以及中途退出前至少清完 1 个回合的对局;更早退出的对局只留在对局记录里。": "計入統計:打到結算的對局,以及中途退出前至少清完 1 個回合的對局;更早退出的對局只留在對局記錄裡。",
"另有 {n} 局未计入统计": "另有 {n} 局未計入統計",
"没有清完第一个回合就退出,不计入统计": "沒有清完第一個回合就退出,不計入統計",
"中途退出的对局没有结算可看": "中途退出的對局沒有結算可看",
"较早的对局只保留汇总,不能回看结算": "較早的對局只保留彙總,不能回看結算",
"中途退出": "中途退出",
"不计入统计": "不計入統計",
"显示更多(还有 {n} 局)": "顯示更多(還有 {n} 局)",
"点击一行,回看该局结算;中途退出的对局只留记录。": "點擊一行,回看該局結算;中途退出的對局只留記錄。",
"最多保留最近 {max} 局,只有最新的 {full} 局能回看结算。": "最多保留最近 {max} 局,只有最新的 {full} 局能回看結算。",
"返回统计": "返回統計",
"文件过大,无法导入": "檔案過大,無法匯入",
"无法写入本机存储,可能已满": "無法寫入本機儲存空間,可能已滿",
"({n} 条无法读取)": "({n} 筆無法讀取)",
"导入失败:这份文件由更新版本的客户端导出,请先升级客户端": "匯入失敗:這份檔案由更新版本的用戶端匯出,請先升級用戶端",
"清空统计数据": "清空統計資料",
"将删除本机保存的 {n} 条对局记录,无法恢复。": "將刪除本機保存的 {n} 筆對局記錄,無法復原。",
"导出为文件(可在别的设备上导入)": "匯出為檔案(可在別的裝置上匯入)",
"从导出的文件导入(与现有记录合并)": "從匯出的檔案匯入(與現有記錄合併)",
"清空本机的统计数据": "清空本機的統計資料",
"本机数据:只保存在这个浏览器里,不会上传到服务器;同盟对局里队友的代号也会存在本机(导出文件里也有)。": "本機資料:只保存在這個瀏覽器裡,不會上傳到伺服器;同盟對局裡隊友的代號也會存在本機(匯出檔案裡也有)。",
"本机保存的统计数据无法读取,已另存为备份,从现在起重新记录。": "本機保存的統計資料無法讀取,已另存為備份,從現在起重新記錄。",
"有 {n} 条记录已损坏,已忽略。": "有 {n} 筆記錄已損毀,已略過。",
"攻回": "攻回",
"受回": "受回",
"自回": "自回",
"常驻": "常駐",
"瞬发": "瞬發",
"部署触发": "部署觸發",
"未精英化": "未精英化",
"精英阶段1": "精英階段1",
"精英阶段2": "精英階段2",
"精英阶段2-60级": "精英階段2-60級",
"潜能": "潛能",
"潜能 {n}": "潛能 {n}",
"练度": "練度",
"潜能与练度": "潛能與練度",
"满潜能 · 精英2 Lv.60": "滿潛能 · 精英2 Lv.60",
"干员持有中标记为未持有:替补干员没有潜能与练度;这里的设置在改回「持有」后生效": "幹員持有中標記為未持有:替補幹員沒有潛能與練度;這裡的設定在改回「持有」後生效",
"原型干员没有潜能与练度": "原型幹員沒有潛能與練度",
"模组仅在精锐形态生效": "模組僅在精銳形態生效",
"未持有(干员持有):由替补干员上场,替补干员没有潜能与练度": "未持有(幹員持有):由替補幹員上場,替補幹員沒有潛能與練度",
"含潜能与练度;": "含潛能與練度;",
"将 {nChanged} 名干员的技能、模组、潜能与练度恢复为默认配置?": "將 {nChanged} 名幹員的技能、模組、潛能與練度恢復為預設配置?",
"已导入 {n} 名干员的潜能与练度": "已匯入 {n} 名幹員的潛能與練度",
"精英1": "精英1",
"精英2": "精英2",
"精英2 Lv.60": "精英2 Lv.60",
"开始模拟前可调整干员携带的技能与模组,以及潜能与练度;干员的局内等级不可调整": "開始模擬前可調整幹員攜帶的技能與模組,以及潛能與練度;幹員的局內等級不可調整",
"默认满潜能、精英2 Lv.60(满加成)。官方的潜能取你自己的潜能,练度是自持有加成(已持有的同名干员的养成,攻击 / 防御 / 生命单独乘算);未持有的特许干员在官方按潜能1、没有加成,要照官方打请手动设为潜能1、未精英化。": "預設滿潛能、精英2 Lv.60(滿加成)。官方的潛能取你自己的潛能,練度是自持有加成(已持有的同名幹員的養成,攻擊 / 防禦 / 生命單獨乘算);未持有的特許幹員在官方按潛能1、沒有加成,要照官方打請手動設為潛能1、未精英化。",
"重刷本局": "重刷本局",
"重刷本局投票:{agreed}/{total}": "重刷本局投票:{agreed}/{total}",
"全员同意后重新随机战场、领袖、特训敌人和禁用;确认倒计时已暂停。": "全員同意後重新隨機戰場、領袖、特訓敵人和禁用;確認倒數計時已暫停。",
"保留房间和个人配置,重新随机全部开局信息。": "保留房間和個人設定,重新隨機全部開局資訊。",
"已同意": "已同意",
"待同意": "待同意",
"全体真人玩家一致同意后刷新,随机结果可能重复。": "全體真人玩家一致同意後刷新,隨機結果可能重複。",
"请等待所有玩家连接后再发起投票": "請等待所有玩家連線後再發起投票",
"同意重刷": "同意重刷",
"拒绝重刷": "拒絕重刷",
"取消投票": "取消投票"
}
+1
View File
@@ -38,6 +38,7 @@
<link rel="stylesheet" href="/css/screens/result.css" />
<link rel="stylesheet" href="/css/screens/guide.css" />
<link rel="stylesheet" href="/css/screens/loadout.css" />
<link rel="stylesheet" href="/css/screens/stats.css" />
<link rel="stylesheet" href="/css/emotes.css" />
<link rel="stylesheet" href="/css/devices.css" />
+119 -24
View File
@@ -4,6 +4,7 @@
// bgm { lobby, prep, combat, combatAlts?: [ {intro?, loop}, … ], boss: { intro?, loop } },
// bossBgm { [bossId]: { intro?, loop } },
// voice { [charId]: { start, faceEnemy, select, place, skill1…skill4, squad, squadFirst, result*, gacha } },
// voiceJp { [charId]: { …the same slots } } (the Japanese dub, settings 语音语言 日本語; see voiceLine),
// sfx.ui { click, buy, sell, refresh, freeze, levelup, merge, equip, ready, timer, yourTurn, … },
// sfx.battle { deploy, tokenDeploy, charDie, tokenDie?, enemyDie, enemyHit, heal, killCoin, … },
// sfx.units { [charId|tokenId|enemyId]: { attack?, hit?, skill?, die?, born?, mix?: { [role]: { p?, vol? } } } }.
@@ -18,14 +19,22 @@
// schedule), so every client of a match hears the same one, a fight never switches track halfway through and the
// 联防 that follows a 作战 keeps its round's track.
// - 干员战斗语音 (`audio.voice`, user request): an operator says the official line of the moment it is in —
// 行动出发 start, 行动开始 faceEnemy, 选中干员 select, 部署 place, 作战中1-4 skillN and the settlement's
// 结算 result* (all battle-only; the 休整期 is silent). Voices are seconds long and a battle deploys eight
// 行动出发 start, 行动开始 faceEnemy, 部署 place, 作战中1-4 skillN and the settlement's 结算 result* (battle-only),
// and 选中干员 select whenever the player taps an operator — in the prep, the shop and the hand too (the owner's
// request of 2026-10-08 「添加一下干员点击上去的语气一样的语音」, which lifts 2026-10-03's 「整备阶段不需要干员语音」 for
// this line only). Voices are seconds long and a battle deploys eight
// operators, then fires dozens of skills, so they run on their own channel (own gain, settings 干员语音) through
// VoiceGate: one line at a time, a global gap, a per-unit per-slot cooldown, and a higher-priority line taking the
// channel over — the official scheduling of `audio_data.json battleVoice.voiceTypeOptions`.
// channel over — the official scheduling of `audio_data.json battleVoice.voiceTypeOptions` (a tap's 选中 line skips
// the gap: VOICE_TAP_SLOTS). Two dubs: 中文 `audio.voice` (default) and 日本語 `audio.voiceJp` (settings 语音语言, not
// tied to the interface language; the owner's request of 2026-10-08 「全套的日配语音」) — a JP line the manifest or the
// host lacks falls back to the Chinese one (voiceLine).
// - Battle SFX from `b.ev` tuples (`handleBattleEvents`): at most MAX_VOICES concurrent unit sounds, at most
// MAX_PER_URL overlapping copies of one sound (the official banks' maxSoundAllowed 2), a per-unit cooldown and a
// per-URL minimum gap (SfxLimiter), so a 60-unit fight stays listenable.
// per-URL minimum gap (SfxLimiter), so a 60-unit fight stays listenable. A `['skill', id, 1]` that arrives before its
// unit is known is held and played once the unit is tracked (`pendingSkill`): a unit that casts inside its own deploy
// tick emits that first cue before its `['spawn', unitInfo]`, and dropping it left the one cast silent while every
// later one played (GitHub PR #292 by @LimitlessHPPK).
// - Impact sounds (user playtest #4 item 6): a 'dmg' plays the `hit` sound of the unit whose hostile attack ('atk' on a
// unit of the other side) aimed at the target — once, within IMPACT_WINDOW_MS, and only for phys / arts / true damage.
// A heal "attack" ('atk' of a healer on an ally, chain heals) never makes the healer the author of the next damage
@@ -76,6 +85,8 @@ const CHAIN_KINDS = new Set(['chain', 'chainHeal']);
const IMPACT_TYPES = new Set(['phys', 'arts', 'true']);
/** A 'dmg' later than this (real ms) after the attack aimed at the target is not that attack's impact. */
const IMPACT_WINDOW_MS = 2500;
/** Casts held for a unit that is not tracked yet (`pendingSkill`): a hold per unit of a field, never more than this. */
const PENDING_SKILL_MAX = 64;
/** Official operator sound files of a skill mode: `…_d` / `…_h` / `…_s` (+ digits) — the normal attack's end in `_n`. */
const SKILL_MODE_FILE = /_(d|h|s)\d*\.mp3$/i;
@@ -248,9 +259,9 @@ export function unitSoundPlays(mix, roll) {
/**
* Voice priorities — the official battle voice types (`audio_data.json battleVoice.voiceTypeOptions`) mapped onto the
* manifest's slots: BATTLE_START 100, BATTLE_FACE_ENEMY 90, SKILL_ACTIVE 70, PASSIVE_IMP 60, PASSIVE_NOR 50,
* PLACE_CHAR 20, FOCUS_CHAR 10. The settlement lines are no battle voice of the official scheduler: they sit at 85,
* above 作战中 (70) but below 接敌 (90), so a battle's last word is never cut off by an ordinary line. The four prep
* slots (部署 / 编入队伍 / 任命队长 / 干员报到) keep their levels although the 休整期 is silent (see the header).
* PLACE_CHAR 20, FOCUS_CHAR 10 (选中干员). The settlement lines are no battle voice of the official scheduler: they sit at
* 85, above 作战中 (70) but below 接敌 (90), so a battle's last word is never cut off by an ordinary line. The three
* prep-only slots (编入队伍 / 任命队长 / 干员报到) keep their levels although nothing plays them (see the header).
*/
export const VOICE_PRIORITY = Object.freeze({
start: 100, faceEnemy: 90,
@@ -259,14 +270,55 @@ export const VOICE_PRIORITY = Object.freeze({
gacha: 60, squadFirst: 45, squad: 30, place: 20, select: 10,
});
/** Per-unit per-slot cooldowns (ms): the official 10 s of the 作战中 (passive skill) lines, 3 s between 接敌 lines. */
/**
* Per-unit per-slot cooldowns (ms): the official 10 s of the 作战中 (passive skill) lines, 3 s between 接敌 lines. 选中干员
* has none — official FOCUS_CHAR `cooldown: 0` (it was 1.5 s until 0.2.2, when a tap in the prep made it audible).
*/
export const VOICE_COOLDOWN_MS = Object.freeze({
start: 0, faceEnemy: 3000,
skill1: 10000, skill2: 10000, skill3: 10000, skill4: 10000,
resultFour: 0, resultThree: 0, resultTwo: 0, resultLose: 0,
gacha: 0, squadFirst: 0, squad: 0, place: 0, select: 1500,
gacha: 0, squadFirst: 0, squad: 0, place: 0, select: 0,
});
/**
* The slot a player's tap asks for: 选中干员 (official FOCUS_CHAR — priority 10, cooldown 0, `overlapIfSamePriority:
* true`). The battle lines are timed by the gate's global gap; a tap is the player's own request and answers at once:
* on an idle channel it always plays (no gap, no cooldown), a newer tap replaces the 选中 line still on air (the same
* priority, overlapIfSamePriority), and it still never interrupts a higher-priority line (部署, 作战中, 开战 …). Nor does
* it start a gap of its own: the battle lines keep the gap of the last battle line (a tap restarting it dropped a 部署 /
* 技能 / 接敌, which the battle asks for once, after a 选中 line shorter than the gap had already ended — many are).
*/
export const VOICE_TAP_SLOTS = Object.freeze(['select']);
/**
* The line an operator says for a slot, in the chosen dub (settings 语音语言, ui/gameLogic/settings.js VOICE_LANGS):
* { url, fallback }, or null for an operator no dub voices (the 预备干员 / 原型干员 stand-ins, 盟约·辅助干员, summons)
* or a slot it lacks. 'cn' draws from `audio.voice`; 'jp' from
* `audio.voiceJp`, the same slots and file names in the Japanese dub, and falls back to the Chinese line twice over: per
* slot when the JP tree lacks it (a file the fetch could not get is left out of the manifest), and per line at play time
* — `fallback` is the Chinese file of the same name, played when the host does not have the JP one (a full zip built
* without the JP dub, before setup downloaded it). A slot with several lines draws one (`random` ∈ [0, 1)).
* @param {any} audio the manifest's `audio`
* @param {string} charId
* @param {string} slot
* @param {'cn'|'jp'|string} [lang]
* @param {() => number} [random]
* @returns {{ url: string, fallback: string|null } | null}
*/
export function voiceLine(audio, charId, slot, lang = 'cn', random = Math.random) {
const lines = (line) => (Array.isArray(line) ? line : [line]).filter((u) => typeof u === 'string' && u);
const draw = (list) => (list.length ? list[Math.min(list.length - 1, Math.floor(random() * list.length))] : null);
const cn = lines(audio?.voice?.[charId]?.[slot]);
const jp = lang === 'jp' ? draw(lines(audio?.voiceJp?.[charId]?.[slot])) : null;
if (jp) {
const file = (u) => u.slice(u.lastIndexOf('/') + 1);
return { url: jp, fallback: cn.find((u) => file(u) === file(jp)) ?? draw(cn) };
}
const url = draw(cn);
return url ? { url, fallback: null } : null;
}
/**
* The settlement slot of a finished 作战: 完美作战 ⇒ 3星结束行动 (绝境 / 终极 ⇒ 完成高难行动 instead), a leaked enemy
* ⇒ 非3星结束行动, nothing killed at all ⇒ 行动失败.
@@ -365,7 +417,9 @@ export class SfxLimiter {
/**
* Voice gate: one line at a time, a global gap between two lines, a per-unit per-slot cooldown, and takeover by a
* clearly more important line (the caller fades the playing one out first). Pure — the clock is passed in.
* clearly more important line (the caller fades the playing one out first). A tap's 选中干员 skips the gap and the
* cooldown on an idle channel, replaces a 选中 line on air and starts no gap itself (VOICE_TAP_SLOTS). Pure — the clock
* is passed in.
*/
export class VoiceGate {
/** @param {{ gapMs?: number, preemptMargin?: number, maxUnits?: number }} [o] */
@@ -396,10 +450,13 @@ export class VoiceGate {
*/
request(slot, unitKey, now) {
const pri = VOICE_PRIORITY[slot] ?? 0;
const tap = VOICE_TAP_SLOTS.includes(slot);
if (this.playing) {
if (tap && this.playing.slot === slot) return 'preempt'; // a newer tap replaces the 选中 line on air
if (pri < this.playing.pri + this.preemptMargin) return 'drop';
return 'preempt'; // a clearly more important line takes the channel
}
if (tap) return 'play'; // a tap on an idle channel always answers (FOCUS_CHAR cooldown 0, no gap)
if (now - this.lastAt < this.gapMs) return 'drop';
if (unitKey != null && now < (this.unitUntil.get(`${unitKey}:${slot}`) || 0)) return 'drop';
return 'play';
@@ -408,7 +465,7 @@ export class VoiceGate {
/** Record a line that started (call right after request() answered play / preempt). */
start(slot, unitKey, now) {
this.playing = { slot, pri: VOICE_PRIORITY[slot] ?? 0 };
this.lastAt = now;
if (!VOICE_TAP_SLOTS.includes(slot)) this.lastAt = now; // the gap is between battle lines: a tap starts none
const cd = VOICE_COOLDOWN_MS[slot] ?? 0;
if (unitKey != null && cd > 0) {
if (this.unitUntil.size > this.maxUnits) this.unitUntil.clear();
@@ -457,12 +514,14 @@ export class AudioManager {
this.voiceGate = new VoiceGate();
this.voiceNode = null; // { src, gain, url, token } of the line on air
this.voiceToken = 0;
this.voiceLang = 'cn'; // settings 语音语言: 'cn' (audio.voice) | 'jp' (audio.voiceJp, falling back to audio.voice)
this.startVoiceDone = false; // 行动出发 of this field (the first operator deployed says it)
this.uiVoices = 0;
this.wantBgm = null; // desired key (kept while locked)
this.bgm = null; // { key, loopUrl, nodes: [{src, gain}], gain }
this.bgmToken = 0;
this.units = new Map(); // battle unit id → defId
this.pendingSkill = new Map(); // unit id → the 'skill' tuple that arrived before the unit was known (see _track)
this.lastAttacker = new Map(); // target id → { def, at } of the hostile attack last aimed at it (its impact sound)
this.consumed = new Set(); // summons used up by their own effect (香槟炸弹 exploded): no death sound
this.installed = false;
@@ -596,6 +655,15 @@ export class AudioManager {
this._applyVolumes();
}
/**
* The voice dub (settings 语音语言): 'jp' plays `audio.voiceJp`, anything else `audio.voice` (中文, the default). The
* line on air finishes in its own dub; the next one follows the setting.
* @param {string} lang
*/
setVoiceLang(lang) {
this.voiceLang = lang === 'jp' ? 'jp' : 'cn';
}
_applyVolumes() {
if (!this.ctx) return;
try {
@@ -823,9 +891,11 @@ export class AudioManager {
// ---- operator battle voice ----------------------------------------------------------------------------------
/**
* Play an operator's battle line (`audio.voice[charId][slot]`; a slot with several lines draws one at random).
* Only in battle: every caller is a running battle's own event stream or its settlement (user request — the 休整期
* is silent). The line must pass VoiceGate: one at a time, a global gap, a per-unit cooldown, higher priority wins.
* Play an operator's battle line (`audio.voice[charId][slot]`, or `audio.voiceJp` when the 语音语言 setting is 日本語 —
* voiceLine; a slot with several lines draws one at random).
* Every caller is a running battle's own event stream or its settlement, except 选中干员: the detail panel opening on
* an operator the player tapped, in every phase (ui/detailPanel.js `voice`; the owner's request of 2026-10-08). The
* line must pass VoiceGate: one at a time, a global gap, a per-unit cooldown, higher priority wins.
* @param {string} charId e.g. 'char_263_skadi'
* @param {'start'|'faceEnemy'|'select'|'place'|'skill1'|'skill2'|'skill3'|'skill4'|'squad'|'squadFirst'
* |'resultFour'|'resultThree'|'resultTwo'|'resultLose'|'gacha'} slot
@@ -836,31 +906,40 @@ export class AudioManager {
try {
if (!this.ctx || !this.voiceGain || this.volumes.muted || this.volumes.voice <= 0) return false;
if (typeof charId !== 'string' || typeof slot !== 'string') return false;
const line = this.getManifest()?.audio?.voice?.[charId]?.[slot];
const url = Array.isArray(line) ? line[Math.floor(Math.random() * line.length)] : line;
if (typeof url !== 'string' || !url) return false;
// the chosen dub's line (settings 语音语言), with the Chinese file of the same name as its fallback (voiceLine)
const line = voiceLine(this.getManifest()?.audio, charId, slot, this.voiceLang);
if (!line) return false;
const now = typeof performance !== 'undefined' ? performance.now() : Date.now();
const verdict = this.voiceGate.request(slot, o.unitKey ?? null, now);
if (verdict === 'drop') return false;
if (verdict === 'preempt') this._stopVoice();
this.voiceGate.start(slot, o.unitKey ?? null, now);
const token = ++this.voiceToken;
this._playVoice(url, token, o.volume);
this._playVoice(line.url, token, o.volume, line.fallback);
return true;
} catch (err) { this._warn('voice', err); return false; }
}
/** Fetch/decode and start one voice line through the voice channel. */
_playVoice(url, token, volume) {
/**
* Fetch/decode and start one voice line through the voice channel. `fallback`: the line to play instead when this one
* cannot be loaded (a JP file the host lacks → the Chinese file of the same name; voiceLine) — same token, same gate.
*/
_playVoice(url, token, volume, fallback = null) {
// `token` is the line's own `voiceToken`. Every deferred step below — the decode, a failed fetch, `onended` and the
// safety timer — can land AFTER this line was taken over or stopped: `voiceToken` has moved on and the channel then
// belongs to the line that replaced it. So each step re-checks its token and, when it is stale, touches NOTHING:
// `_stopVoice` (takeover / stop) and `setFieldUnits` released the gate themselves. An unconditional release here let
// a stale callback free the channel the NEW line had just taken, and the next line walked in on top of it (review
// on #73).
const failed = () => {
if (token !== this.voiceToken) return; // taken over / stopped meanwhile: not ours to release
if (fallback && fallback !== url && this.ctx && this.voiceGain) this._playVoice(fallback, token, volume);
else this.voiceGate.release();
};
this._buffer(url).then((buf) => {
if (token !== this.voiceToken) return; // taken over / stopped while it decoded: not ours to release
if (!buf || !this.ctx || !this.voiceGain) { this.voiceGate.release(); return; }
if (!buf) { failed(); return; }
if (!this.ctx || !this.voiceGain) { this.voiceGate.release(); return; }
try {
const src = this.ctx.createBufferSource();
src.buffer = buf;
@@ -888,7 +967,7 @@ export class AudioManager {
this._warn('voice-play', err);
if (token === this.voiceToken) this.voiceGate.release();
}
}, () => { if (token === this.voiceToken) this.voiceGate.release(); });
}, failed);
}
/** Fade the line on air out (a higher priority line is taking the channel over). */
@@ -912,6 +991,9 @@ export class AudioManager {
/** Reset the unit map for a new field (m.field.units = UnitInfo[]). */
setFieldUnits(units) {
this.units.clear();
// unit ids belong to one battle: a cast still held for a unit of the field left must not sound for a unit of the new
// one that happens to share its id
this.pendingSkill.clear();
this.lastAttacker.clear();
this.consumed.clear();
// a new field is a new battle: the first operator deployed says 行动出发 again and no cooldown carries over
@@ -926,6 +1008,12 @@ export class AudioManager {
// official class sounds (operator vs summon vs device)
this.units.set(u.id, { def: u.spine || u.defId, defId: u.defId ?? null, kind: u.kind ?? null, side: u.side, boss: !!u.boss,
skillIndex: Number.isInteger(u.skillIndex) ? u.skillIndex : null });
// a cast that arrived before this unit was known (handleBattleEvents): it sounds now, once
const held = this.pendingSkill.get(u.id);
if (held) {
this.pendingSkill.delete(u.id);
this.handleBattleEvents([held]);
}
}
/** Play a resolved battle sound for a unit event, limited like unit sounds. */
@@ -970,6 +1058,13 @@ export class AudioManager {
const n = Number.isInteger(u.skillIndex) ? Math.min(4, u.skillIndex + 1) : 1;
this.voice(u.def, `skill${n}`, { unitKey: e[1] });
}
} else {
// The unit is not tracked yet. The sim emits the cast of a deployment that fires inside its own first tick
// (`initSp` already at `spCost`, 宴's deploy-timed skill) BEFORE the unit's `['spawn', unitInfo]` — the same
// batch or the next one — so the cue waits here and `_track` plays it when the spawn arrives. Only a skill
// turning ON is a cast; a hold for a unit that never appears goes with the field (`setFieldUnits`).
if (this.pendingSkill.size >= PENDING_SKILL_MAX) this.pendingSkill.delete(this.pendingSkill.keys().next().value);
this.pendingSkill.set(e[1], e);
}
} else if (kind === 'engage') {
// 行动开始: the first attack a unit makes on an enemy (the sim's ENGAGE, official ENCOUNTER_ENEMY, 3 s apart)
@@ -1039,13 +1134,13 @@ export const audio = new AudioManager({ getManifest: () => manifestGetter() });
/**
* Wire the singleton to the app (called once by main.js): manifest source, settings and store-driven BGM.
* @param {{ getManifest: () => any, subscribe: (fn: (s:any, prev:any) => void) => () => void, getState: () => any,
* selectRoute: (s:any) => string, settings?: { bgm:number, sfx:number, voice:number, muted:boolean } }} deps
* selectRoute: (s:any) => string, settings?: { bgm:number, sfx:number, voice:number, muted:boolean, voiceLang?:string } }} deps
*/
export function installAudio(deps) {
try {
manifestGetter = typeof deps?.getManifest === 'function' ? deps.getManifest : manifestGetter;
audio.install();
if (deps?.settings) audio.setVolumes(deps.settings);
if (deps?.settings) { audio.setVolumes(deps.settings); audio.setVoiceLang(deps.settings.voiceLang); }
if (typeof deps?.subscribe === 'function' && typeof deps?.getState === 'function') {
const sync = (s) => {
try {
+4 -2
View File
@@ -100,7 +100,7 @@ export function followedScout({ field = null, watching = null, alive = true, spe
return fid;
}
/** Teammates' progress for the waiting pill: [{ playerId, name, killed, total, done, isBot }]. */
/** Teammates' progress for the waiting pill: [{ playerId, name, killed, resolved, total, done, isBot }]. */
export function teammateProgress(pub, myId) {
const out = [];
for (const f of fields(pub)) {
@@ -111,7 +111,9 @@ export function teammateProgress(pub, myId) {
const pr = isObj(f.progress) ? f.progress : null;
out.push({
playerId: pid, name: p?.name || t('队友'), isBot: !!p?.isBot,
killed: Number.isFinite(pr?.killed) ? pr.killed : null, total: Number.isFinite(pr?.total) ? pr.total : null,
killed: Number.isFinite(pr?.killed) ? pr.killed : null,
resolved: Number.isFinite(pr?.resolved) ? pr.resolved : (Number.isFinite(pr?.killed) ? pr.killed : null),
total: Number.isFinite(pr?.total) ? pr.total : null,
done: f.live === false || !!pr?.done,
});
}
+39 -5
View File
@@ -72,6 +72,7 @@ import { net as appNet } from '../net.js';
import { store as appStore } from '../store.js';
import { unitStatsEntry, fxForm } from '../../../shared/protocol.js';
import { spectateEffects } from './observe.js';
import { recordError, setBattleSource } from '../diag.js';
const TICK = 1 / 30;
/** Fast-forward budget per frame (ticks) when far behind. */
@@ -119,6 +120,14 @@ export const LOST_RESULT_CODES = Object.freeze(['DISCONNECTED', 'OFFLINE', 'TIME
/** Ticks per frame at a speed (same cap as the server pacing: server/match/fields.js maxTicksPerInterval). */
export const ticksPerFrameCap = (speed) => Math.max(8, Math.ceil((Number(speed) || 2) * 4));
/**
* What the runner compares between two looks at a 联防 battle (noteUniteLeft) before it reads the leakers' enemies still
* standing again: a knock-out, a leak, a spawn, the end. `total` stands for the spawns no longer — a split child or a summon
* is outside the capsule's denominator (DESIGN §14 顶栏胶囊) — so the length of the enemy list does.
* @param {any} b the battle
*/
export const uniteLeftMark = (b) => `${Number(b.killed) || 0}:${Number(b.leakedCount) || 0}:${Number(b.total) || 0}:${Array.isArray(b.enemies) ? b.enemies.length : 0}:${b.finished ? 1 : 0}`;
function deepFreeze(root) {
const stack = [root];
while (stack.length) {
@@ -156,9 +165,12 @@ export async function loadBrowserSim({ base = '/sim/', dataBase = '/data/', fetc
return { spec, ds: new simdata.DataSource(raw, null) };
}
/** Battle logger: content errors are isolated by the sim; report them as warnings (the server logs its own). */
/**
* Battle logger: content errors are isolated by the sim; report them as warnings (the server logs its own) and keep
* them for the diagnostics a player copies (diag.js).
*/
const SIM_LOGGER = Object.freeze({
error: (...a) => console.warn('[sim]', ...a),
error: (...a) => { console.warn('[sim]', ...a); recordError('sim', a.length === 1 ? a[0] : a.map(String).join(' ')); },
warn: (...a) => console.warn('[sim]', ...a),
info() {},
debug() {},
@@ -288,7 +300,7 @@ export function createBattleRunner(deps) {
function noteUniteLeft(e) {
const b = e.battle;
const mark = `${Number(b.killed) || 0}:${Number(b.leakedCount) || 0}:${Number(b.total) || 0}:${b.finished ? 1 : 0}`;
const mark = uniteLeftMark(b);
if (mark === e.leakMark) return;
e.leakMark = mark;
let left = e.left;
@@ -351,6 +363,7 @@ export function createBattleRunner(deps) {
} catch (err) {
stats.errors++;
console.warn('[runner] battle step failed', err);
recordError('runner', err, 'battle step failed');
try { b.forceEnd('timeout'); } catch { /* ignore */ }
}
const dt = now() - t;
@@ -393,7 +406,7 @@ export function createBattleRunner(deps) {
const list = catchingUp ? s.ev.filter(keepsState) : s.ev;
if (list.length) emit('ev', { t: 'b.ev', fieldId: e.fieldId, gt: s.gt, ev: list });
}
try { emit('snap', frameOf(e)); } catch (err) { console.warn('[runner] snapshot failed', err); }
try { emit('snap', frameOf(e)); } catch (err) { console.warn('[runner] snapshot failed', err); recordError('runner', err, 'snapshot failed'); }
}
/**
@@ -428,9 +441,16 @@ export function createBattleRunner(deps) {
e.lastProgressAt = t;
const p = e.sim.spec.battleProgress(e.battle);
const msg = { battleId: e.battleId, gt: Math.min(1e5, p.gt), killed: Math.min(p.killed, p.total), total: Math.min(1e5, p.total), done: !!p.done };
// the HUD capsule's numerator of this field (shared/protocol.js b.progress `resolved`): the field's own scheduled
// enemies knocked out or leaked. Sent only when the battle reports one (Battle.resolved) — an absent field leaves
// the teammate UI on its `resolved ?? killed` fallback
if (Number.isFinite(p.resolved)) msg.resolved = Math.max(0, Math.min(msg.total, p.resolved));
if (bossLike(e)) {
const pool = e.battle.sharedBoss;
msg.leaks = Math.min(1e6, e.meter.lp);
// the split of that LP between the players' own leaks (the rest is the leader's effects): the server holds the result's
// leaked lists to it before a perfect-payout bounty pays (Match._bossLeaksAgree)
if (p.leaksBy) msg.leaksBy = p.leaksBy;
msg.bossDmg = pool && Number.isFinite(pool.cum) ? pool.cum : 0;
if (pool && pool.byPlayer) {
const by = {};
@@ -457,7 +477,7 @@ export function createBattleRunner(deps) {
result = e.sim.spec.compactResult(e.battle.result());
// an oversized frame would close the socket at the very end of the battle (64 KB inbound limit)
if (typeof e.sim.spec.fitResult === 'function') result = e.sim.spec.fitResult(result, { bossLike: bossLike(e), battleId: e.battleId });
} catch (err) { console.warn('[runner] result failed', err); }
} catch (err) { console.warn('[runner] result failed', err); recordError('runner', err, 'result failed'); }
if (result) {
e.result = result;
// the view answers with the settlement voice of this battle (screens/game.js → audio.voice result*): the
@@ -491,6 +511,7 @@ export function createBattleRunner(deps) {
} else {
e.delivery = 'delivered';
console.warn('[runner] b.result refused', code);
recordError('runner', code, 'b.result refused');
}
return null;
});
@@ -670,6 +691,7 @@ export function createBattleRunner(deps) {
let sim;
try { sim = await ensureSim(); } catch (err) {
console.warn('[runner] simulation unavailable', err);
recordError('runner', err, 'simulation unavailable');
return;
}
if (!wanted()) return;
@@ -678,6 +700,7 @@ export function createBattleRunner(deps) {
battle = sim.spec.createBattleFromSpec(e.spec, sim.ds, { logger });
} catch (err) {
console.warn('[runner] battle construction failed', err);
recordError('runner', err, 'battle construction failed');
return;
}
stats.battles++;
@@ -806,6 +829,16 @@ export function createBattleRunner(deps) {
return () => listeners.get(type)?.delete(fn);
},
state,
/**
* The battle on screen as a report attaches it (diag.js): its b.start fields and its game time; null without one.
* @returns {{ battleId: string, fieldId: string, kind: string, spec: object, time: number|null }|null}
*/
currentBattle() {
const e = cur;
if (!e || !e.spec) return null;
const time = e.battle && Number.isFinite(e.battle.time) ? e.battle.time : null;
return { battleId: e.battleId, fieldId: e.fieldId, kind: e.kind, spec: e.spec, time };
},
stats() {
return { ...stats, avgTickMs: stats.ticks ? stats.stepMs / stats.ticks : 0, entries: entries.size, loadingSim: !!simP };
},
@@ -869,3 +902,4 @@ export const battleRunner = typeof window !== 'undefined' && typeof document !==
? createBattleRunner({ net: appNet, store: appStore })
: null;
if (battleRunner) globalThis.__SP_RUNNER__ = battleRunner; // dev / E2E introspection
if (battleRunner) setBattleSource(() => battleRunner.currentBattle());
+269
View File
@@ -0,0 +1,269 @@
// public/js/diag.js — the client's error log and the diagnostics a player copies into a bug report (设置 → 问题反馈).
//
// Nothing leaves the browser. The log is kept in memory (the last LOG_MAX entries, an identical error in a row counted
// on its entry); the report is plain text the player copies and pastes into a GitHub issue, with the player names,
// the room code and the reconnect token replaced and the page's host cut from every URL. A secret is a whole token in
// the prose and an exact string value in the attached battle JSON, so a name never rewrites a longer id. The battle
// on screen can be attached as its BattleSpec (the b.start a browser receives: player ids, no names), which the dev
// tools replay.
//
// recordError(source, err, detail?) source: 'error' (uncaught) | 'rejection' (unhandled promise) | 'sim' (a content
// error the battle isolated) | 'runner' (the local battle runner) | 'request'
// (a failed request or error push the player was shown)
// errorLog() / errorCount() / clearErrorLog()
// setBattleSource(fn) the battle runner registers how to read the battle on screen
// buildDiagnostics(input) the report text (pure: the state, the environment and the battle are passed in)
// diagnosticsText({ state, settings, attachBattle }) the report of this page (this browser, the battle on screen)
//
// Report keys are English on purpose: the text is read by developers and searched in issues, whatever the UI language.
import { APP_VERSION } from '../../shared/constants.js';
export const LOG_MAX = 40;
export const STACK_LINES = 6;
const MESSAGE_MAX = 300;
/** @type {{ source: string, message: string, stack: string|null, detail: string|null, at: number, lastAt: number, count: number }[]} */
const log = [];
let total = 0;
let dropped = 0;
const pageStart = Date.now();
/** `name: message (code)` of an Error, a NetError, a {code, msg} push, a string or anything else. */
export function messageOf(err) {
let s;
if (err == null) s = String(err);
else if (typeof err === 'string') s = err;
else if (typeof err === 'object') {
const name = typeof err.name === 'string' && err.name && err.name !== 'Error' ? `${err.name}: ` : '';
const text = typeof err.message === 'string' && err.message ? err.message : typeof err.msg === 'string' ? err.msg : '';
const code = typeof err.code === 'string' || typeof err.code === 'number' ? String(err.code) : '';
if (text) s = `${name}${text}${code && !text.includes(code) ? ` (${code})` : ''}`;
else if (code) s = `${name}${code}`;
else { try { s = JSON.stringify(err); } catch { s = Object.prototype.toString.call(err); } }
} else s = String(err);
s = s.replace(/\s+/g, ' ').trim();
return s.length > MESSAGE_MAX ? `${s.slice(0, MESSAGE_MAX - 1)}…` : s;
}
/** The first STACK_LINES frames of `err.stack`, each without the page's scheme and host (`/js/ui/x.js:12:5`). */
export function stackOf(err) {
const raw = err && typeof err === 'object' && typeof err.stack === 'string' ? err.stack : '';
if (!raw) return null;
const lines = raw.split('\n').map((l) => l.trim()).filter(Boolean);
// V8 starts with "Name: message"; Firefox / Safari list frames only
const frames = lines.filter((l, i) => !(i === 0 && !/^at |@|:\d+:\d+\)?$/.test(l)));
const out = frames.slice(0, STACK_LINES).map(stripHosts);
return out.length ? out.join('\n') : null;
}
/** Cut `scheme://host[:port]` from every URL (a LAN server's address, a domain): the path is what locates the code. */
export const stripHosts = (s) => String(s).replace(/\b[a-z][a-z0-9+.-]*:\/\/[^/\s)]+/gi, '');
/**
* Record an error. An entry identical to the newest one (same source, message and stack) raises its count instead.
* @param {string} source @param {any} err @param {string|null} [detail] @param {number} [at] epoch ms
*/
export function recordError(source, err, detail = null, at = Date.now()) {
const message = stripHosts(messageOf(err));
const stack = stackOf(err);
const det = detail == null ? null : stripHosts(String(detail)).slice(0, MESSAGE_MAX);
total++;
const last = log[log.length - 1];
if (last && last.source === source && last.message === message && last.stack === stack && last.detail === det) {
last.count++;
last.lastAt = at;
return last;
}
const e = { source: String(source), message, stack, detail: det, at, lastAt: at, count: 1 };
log.push(e);
while (log.length > LOG_MAX) { log.shift(); dropped++; }
return e;
}
/** The log, oldest first (copies). */
export const errorLog = () => log.map((e) => ({ ...e }));
/** Errors recorded on this page (repeats included). */
export const errorCount = () => total;
export function clearErrorLog() { log.length = 0; total = 0; dropped = 0; }
let battleSource = null;
/** @param {() => ({ battleId: string, fieldId?: string, kind?: string, spec: object, time?: number }|null)} fn */
export function setBattleSource(fn) { battleSource = typeof fn === 'function' ? fn : null; }
/** The battle on screen for a report, or null. */
export function currentBattle() {
try { return battleSource ? battleSource() || null : null; } catch { return null; }
}
/** Secrets worth replacing: non-empty strings, later pair winning when the same value is listed twice. */
function secretMap(pairs) {
const map = new Map();
for (const [v, ph] of pairs) {
if (typeof v === 'string' && v.trim()) map.set(v, ph);
}
return map;
}
/**
* Replace each secret only as a whole token, longest first, in one pass (a placeholder is never scanned again).
* A token is bounded by the start or end of the text, or by anything that is not a letter, a digit or `_`, so
* "char" does not eat `chess_char_…`, "player" does not eat "players", and "W" does not eat "Windows". "W-W" is
* two tokens. The left boundary is a consumed character (or the start): a lookbehind is a SyntaxError in Safari
* before 16.4.
* @param {string} text @param {[string, string][]} pairs [value, placeholder]
*/
export function redact(text, pairs) {
const map = secretMap(pairs);
const list = [...map.keys()].sort((a, b) => b.length - a.length);
if (!list.length) return String(text);
const esc = (v) => v.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const word = '[\\p{L}\\p{N}_]';
const re = new RegExp(`(?<pre>^|(?!${word})[\\s\\S])(?<hit>${list.map(esc).join('|')})(?!${word})`, 'gu');
return String(text).replace(re, (...args) => {
const g = args[args.length - 1];
return `${g.pre}${map.get(g.hit)}`;
});
}
/**
* Copy `value`, replacing a string only when it is exactly a secret (object keys included). A longer string that
* merely contains the secret, such as a chess id or a note, is kept. This is what the attached battle JSON uses;
* token replacement would still rewrite `"charge the char"` when the name is `char`.
* @param {any} value @param {[string, string][]} pairs
*/
export function redactValues(value, pairs) {
const map = secretMap(pairs);
const walk = (v) => {
if (typeof v === 'string') return map.has(v) ? map.get(v) : v;
if (Array.isArray(v)) return v.map(walk);
if (v && typeof v === 'object') {
const out = {};
for (const [k, val] of Object.entries(v)) out[map.has(k) ? map.get(k) : k] = walk(val);
return out;
}
return v;
};
return walk(value);
}
/** [value, placeholder] pairs of what a report must not show: player names, the room code, the reconnect token. */
export function secretsOf(state) {
const pairs = [];
const names = new Map();
const addName = (n) => {
if (typeof n !== 'string' || !n.trim() || names.has(n)) return;
names.set(n, n === state?.me?.name ? '<me>' : `<player${names.size + 1}>`);
};
addName(state?.me?.name);
for (const s of state?.room?.seats || []) addName(s?.name);
for (const s of state?.room?.spectators || []) addName(s?.name);
for (const p of state?.match?.public?.players || []) addName(p?.name);
for (const [n, ph] of names) pairs.push([n, ph]);
if (state?.room?.code) pairs.push([String(state.room.code), '<room>']);
if (state?.me?.token) pairs.push([String(state.me.token), '<token>']);
return pairs;
}
const pad2 = (n) => String(n).padStart(2, '0');
const clockOf = (ms) => { const d = new Date(ms); return `${pad2(d.getUTCHours())}:${pad2(d.getUTCMinutes())}:${pad2(d.getUTCSeconds())}`; };
const spanOf = (ms) => {
const s = Math.max(0, Math.round(ms / 1000));
return s >= 3600 ? `${Math.floor(s / 3600)}h ${pad2(Math.floor(s / 60) % 60)}m` : `${Math.floor(s / 60)}m ${pad2(s % 60)}s`;
};
const join = (...parts) => parts.filter((p) => p != null && p !== '' && p !== false).join(' | ');
/**
* The report text: a ```text block (version, time, browser, screen, connection, where the player is, settings, the
* error log newest first) and, with `battle`, a ```json block of its spec. Secrets of `state` are whole tokens in the
* prose and exact string values in the JSON.
* @param {{ state: any, env?: any, settings?: any, battle?: any, entries?: any[], dropped?: number, total?: number,
* now?: number, pageStart?: number, version?: string }} input
*/
export function buildDiagnostics(input) {
const { state = {}, env = {}, settings = null, battle = null } = input;
const entries = input.entries ?? errorLog();
const now = input.now ?? Date.now();
const lines = [];
lines.push('Stronghold-Protocol diagnostics');
lines.push(`version: ${input.version ?? APP_VERSION}`);
lines.push(`time: ${new Date(now).toISOString()} | page open ${spanOf(now - (input.pageStart ?? pageStart))}`);
if (env.ua) lines.push(`browser: ${env.ua}`);
if (env.gpu) lines.push(`gpu: ${env.gpu}`);
lines.push(`device: ${join(env.viewport, env.dpr ? `@${env.dpr}x` : null, env.touch ? 'touch' : null, env.cores ? `${env.cores} threads` : null,
env.memoryGB ? `${env.memoryGB} GB` : null, env.lang ? `lang ${env.lang}` : null, env.uiLang ? `ui ${env.uiLang}` : null) || 'unknown'}`);
const c = state.connection || {};
lines.push(`connection: ${join(c.status || 'unknown', Number.isFinite(c.ping) ? `ping ${Math.round(c.ping)} ms` : null,
c.attempt ? `retry ${c.attempt}` : null, c.lastError ? `last error ${messageOf(c.lastError)}` : null)}`);
const pub = state.match?.public || null;
const room = state.room || null;
lines.push(`where: ${join(pub?.phase ? `phase ${pub.phase}` : room ? 'room' : state.session?.entered ? 'lobby' : 'title',
Number.isFinite(pub?.round) ? `round ${pub.round}` : null, room?.mode ? `mode ${room.mode}` : null,
room?.difficulty ? `difficulty ${room.difficulty}` : null,
Array.isArray(pub?.players) ? `players ${pub.players.length}` : Array.isArray(room?.seats) ? `seats ${room.seats.filter(Boolean).length}` : null)}`);
const b = state.match?.battle || null;
if (b) {
lines.push(`battle: ${join(b.kind, b.fieldId ? `field ${b.fieldId}` : null, b.authoritative ? 'authoritative' : b.watch ? 'watching' : null,
b.done ? 'done' : b.loading ? 'loading' : 'running', b.paused ? 'paused' : null, Number.isFinite(b.speed) ? `${b.speed}x` : null)}`);
}
if (settings) {
lines.push(`settings: ${join(`quality ${settings.quality}`, settings.damageNumbers ? 'damage numbers' : null, settings.muted ? 'muted' : null)}`);
}
const shown = entries.slice().reverse();
const tot = input.total ?? total;
const drop = input.dropped ?? dropped;
lines.push('');
lines.push(`errors: ${tot ? `${tot} recorded${drop ? `, ${drop} oldest entries dropped` : ''}` : 'none recorded'}`);
shown.forEach((e, i) => {
lines.push(`#${i + 1} ${clockOf(e.at)}${e.count > 1 ? ` (x${e.count}, last ${clockOf(e.lastAt)})` : ''} ${e.source}: ${e.message}`);
if (e.detail) lines.push(` ${e.detail}`);
if (e.stack) for (const l of e.stack.split('\n')) lines.push(` ${l}`);
});
const pairs = secretsOf(state);
let text = redact(`\`\`\`text\n${lines.join('\n')}\n\`\`\``, pairs);
if (battle && battle.spec) {
const head = join(`battle ${battle.battleId}`, battle.kind, Number.isFinite(battle.spec.round) ? `round ${battle.spec.round}` : null,
Number.isFinite(battle.time) ? `copied at ${battle.time.toFixed(2)} s` : null);
const payload = redactValues({
t: 'b.start', battleId: battle.battleId, fieldId: battle.fieldId, kind: battle.kind, spec: battle.spec,
}, pairs);
text += `\n\n${redact(head, pairs)}\n\`\`\`json\n${JSON.stringify(payload)}\n\`\`\``;
}
return text;
}
let gpuName;
/** What the report says about this browser and device (null fields stay out). */
export function browserEnv() {
const g = globalThis;
if (gpuName === undefined) {
gpuName = null;
try {
const cv = g.document?.createElement('canvas');
const gl = cv && (cv.getContext('webgl2') || cv.getContext('webgl'));
const ext = gl && gl.getExtension('WEBGL_debug_renderer_info');
gpuName = gl ? String(ext ? gl.getParameter(ext.UNMASKED_RENDERER_WEBGL) : gl.getParameter(gl.RENDERER)) : 'no WebGL';
} catch { gpuName = null; }
}
let uiLang = null;
try { uiLang = g.document?.documentElement?.lang || null; } catch { /* ignore */ }
return {
ua: g.navigator?.userAgent ?? null,
gpu: gpuName,
viewport: Number.isFinite(g.innerWidth) ? `${g.innerWidth}x${g.innerHeight}` : null,
dpr: g.devicePixelRatio ?? null,
touch: !!(g.matchMedia && g.matchMedia('(pointer: coarse)').matches),
cores: g.navigator?.hardwareConcurrency ?? null,
memoryGB: g.navigator?.deviceMemory ?? null,
lang: g.navigator?.language ?? null,
uiLang,
};
}
/**
* The report of this page: the given store state and settings, this browser, the log and, with `attachBattle`, the
* battle on screen.
* @param {{ state: any, settings?: any, attachBattle?: boolean }} opts
*/
export function diagnosticsText({ state, settings = null, attachBattle = false }) {
return buildDiagnostics({ state, settings, env: browserEnv(), battle: attachBattle ? currentBattle() : null });
}
+14 -1
View File
@@ -49,10 +49,13 @@ import { settingsStore } from './ui/settings.js';
import { GuideHost } from './ui/guide.js';
import { installDeviceSupport } from './ui/device.js';
import { LoadoutHost } from './screens/loadout.js';
import { StatsHost } from './screens/stats.js';
import { recordResult, installStatsRecorder } from './ui/stats.js';
import { installLoadoutSync, installOwnershipSync, installDiySync } from './ui/loadoutSync.js';
import { startBuildGuard } from './ui/buildGuard.js';
import { initLang, useLang, tickerText } from './ui/lang.js';
import { t, N_, translateWire } from '../../shared/i18n.js';
import { recordError } from './diag.js';
const RESTORE_GRACE_MS = 1500;
const JOIN_DELAY_MS = 350;
@@ -219,7 +222,13 @@ function wireNet() {
net.on('m.public', (msg) => { matchAt = Date.now(); store.patch('match', { public: payload(msg) }); maybeFinishRestore(); });
net.on('m.private', (msg) => { matchAt = Date.now(); store.patch('match', { private: payload(msg) }); });
net.on('m.field', (msg) => store.patch('match', { field: payload(msg) }));
net.on('m.result', (msg) => store.patch('match', { result: payload(msg) }));
net.on('m.result', (msg) => {
const res = payload(msg);
store.patch('match', { result: res });
// 本机统计 (PR #323): every arrival, including the lobby's result replay after a reconnect / reload —
// replays dedupe by content id inside recordResult (spectator seats' copies build no record at all)
recordResult(res, { myId: store.get().me.playerId, roomMode: store.get().room?.mode ?? null, now: Date.now() });
});
net.on('m.toast', (msg) => {
const kind = ['info', 'success', 'warn', 'error'].includes(msg.kind) ? msg.kind : 'info';
// msgid + params (server ≥ 0.2.0) or the text itself as a msgid, in the current language
@@ -287,6 +296,7 @@ function App() {
<${UiHosts} />
<${GuideHost} />
<${LoadoutHost} />
<${StatsHost} />
</div>`;
}
@@ -311,12 +321,14 @@ function installGlobalErrorHandlers() {
// expected browser behaviour, not app errors: log quietly, never toast.
if (err && (err.name === 'NotAllowedError' || err.name === 'AbortError')) { console.warn('[app] ignored rejection', err.name); return; }
console.error('[app] unhandled rejection', err);
recordError('rejection', err);
if (err instanceof NetError) toastError(err);
else toast(t('发生意外错误:{error}', { error: describeError(err) }).slice(0, 120), 'error');
});
window.addEventListener('error', (ev) => {
if (!(ev instanceof ErrorEvent)) return; // resource load errors are not script errors
console.error('[app] uncaught error', ev.error || ev.message);
recordError('error', ev.error || ev.message, ev.error ? null : `${ev.filename || '?'}:${ev.lineno || 0}:${ev.colno || 0}`);
});
}
@@ -339,6 +351,7 @@ async function boot() {
}));
wireNet();
installStatsRecorder(store); // follows the match on screen, so a 放弃模拟 can be recorded (ui/stats.js)
installLoadoutSync({ net });
installOwnershipSync({ net });
installDiySync({ net });
+9 -4
View File
@@ -726,7 +726,7 @@ export function createIdentity(deps = {}) {
let initPromise = null;
let initialized = false;
let current = null; // token this tab uses (null = let the server create a session)
let resolving = null; // { taken: Set<hash> } while init() waits for claims
let resolving = null; // { hashes: Set<hash>, taken: Set<hash> } while init() waits for claims
const readRecent = () => {
try {
@@ -751,7 +751,12 @@ export function createIdentity(deps = {}) {
if (m.hashes.includes(h)) post({ type: 'mine', hash: h, to: m.from });
}
// Another tab is choosing at the same time: the smaller tab id has precedence.
if (resolving && m.from < tabId) for (const h of m.hashes) if (typeof h === 'string') resolving.taken.add(h);
// Answer for pending candidates too: a later channel may have missed our initial query.
if (resolving) for (const h of m.hashes) {
if (typeof h !== 'string') continue;
if (m.from < tabId) resolving.taken.add(h);
else if (resolving.hashes.has(h)) post({ type: 'mine', hash: h, to: m.from });
}
} else if (m.type === 'mine' && resolving && m.to === tabId && typeof m.hash === 'string') {
resolving.taken.add(m.hash);
}
@@ -768,8 +773,8 @@ export function createIdentity(deps = {}) {
// Without a channel we cannot tell whether a shared token is in use: never adopt one.
const candidates = [...new Set([ownOk, ...(channel ? readRecent() : [])].filter(isToken))];
if (!channel || candidates.length === 0) return ownOk;
resolving = { taken: new Set() };
post({ type: 'who', hashes: candidates.map(tokenHash) });
resolving = { hashes: new Set(candidates.map(tokenHash)), taken: new Set() };
post({ type: 'who', hashes: [...resolving.hashes] });
await new Promise((r) => setTimer(r, queryMs));
const { taken } = resolving;
resolving = null;
+136 -47
View File
@@ -44,9 +44,10 @@
// clicks and hover (battleUnitAt), the pen (penUnitAt) — is render/pick.js over the tile under the pointer (groundTile:
// raised tops first); battle enemies, which walk between tiles, by their ground position (flying ones by their drawn
// body; a huge boss also anywhere on its hit area — data/enemies.json `hitArea`, user playtest #5). A dragged piece
// is held under the pointer — a unit with its drawn feet DRAG_HOLD_TILES below it (the pointer on its body), an item
// plate centred on it — and drops on the tile under the pointer (render/drag.js); an item dropped on a unit's tile
// equips that unit.
// drops on the tile under the pointer (render/drag.js). A dragged unit stands on that tile while it is a legal target
// (a board tile or a bench slot: drag.js dragStandTile; the official deploy drag, the owner's recording of 2026-10-09,
// PR #403's report) and is held under the pointer elsewhere — its drawn feet DRAG_HOLD_TILES below it, the pointer on
// its body; an item plate stays centred on the pointer. An item dropped on a unit's tile equips that unit.
// Direction step (ui/facingWheel.js, research 09 §1.2):
// view.tileScreen(row, col) → { x, y, s, poly: [[x,y]×4] } (client px: tile-top centre, px per tile, corners) | null
// view.holdPiece(uid, {row,col} | null) keep a dropped prep piece standing on a tile while its direction is
@@ -117,9 +118,9 @@ import { fxForm } from '../../../shared/protocol.js';
import { Camera, presetCamera, lerpCamera, easeInOutCubic, pickTile, normRect } from './projection.js';
import { SnapshotBuffer, frameTime } from './interp.js';
import { TileField } from './tiles.js';
import { UnitView, ItemView, DeviceView, FORMS } from './units.js';
import { UnitView, ItemView, DeviceView, FORMS, syncView } from './units.js';
import { FxSystem, ensureDamageFonts } from './fx.js';
import { createDragController, pieceTile } from './drag.js';
import { createDragController, pieceTile, dragStandTile } from './drag.js';
import { backdropTextures, shadowTexture, refreshTierChips, silhouetteTexture } from './textures.js';
import { TILE_H, TIER_COLORS, COLORS } from './style.js';
import { loadBoardArt } from './boardArt.js';
@@ -148,14 +149,17 @@ export { releaseGl } from './app/host.js';
/**
* Create the battlefield view inside `host` (an element sized by CSS; the canvas fills it).
* @param {HTMLElement} host
* @param {{ data?: any, assets?: any, audio?: any, settings?: { damageNumbers?: boolean, quality?: string },
* @param {{ data?: any, assets?: any, audio?: any, settings?: { damageNumbers?: boolean, quality?: string }, signal?: AbortSignal,
* padding?: object|((kind:string, size:{width:number,height:number}) => object),
* hud?: {top:number,bottom:number}|((kind:'prep'|'bossPrep', size:{width:number,height:number}, o:{shop:boolean}) => {top:number,bottom:number}|null) }} [opts]
*/
export async function createFieldView(host, options = {}) {
if (!host || typeof host.appendChild !== 'function') throw new TypeError('createFieldView: host element required');
const opts = options && typeof options === 'object' ? options : {};
const P = await ensurePixi();
const signal = opts.signal;
signal?.throwIfAborted();
const P = await withTimeout(ensurePixi(), null, signal);
signal?.throwIfAborted();
const assets = resolveAssets(opts.assets);
const data = makeData(opts.data);
const settings = { damageNumbers: true, quality: 'high', ...(opts.settings || {}) };
@@ -164,19 +168,28 @@ export async function createFieldView(host, options = {}) {
const want3d = boardPref !== '2d' && webgl2Available(boardPref === '3d');
// three.js (~2 MB) is fetched only when the local-art manifest lists the board atlas (in parallel with the art)
const artListed = want3d ? boardArtListed(assets).catch(() => false) : Promise.resolve(false);
const threePromise = artListed.then((ok) => (ok ? loadThree() : null));
const packPromise = artListed.then((ok) => (ok ? Promise.resolve(assets.ready ? assets.ready() : null).catch(() => null).then(() => loadBoardPack(assets)) : null));
const threePromise = artListed.then((ok) => (ok ? loadThree() : null)).catch(() => null);
const packPromise = artListed.then((ok) => (ok ? Promise.resolve(assets.ready ? assets.ready() : null).catch(() => null).then(() => loadBoardPack(assets)) : null)).catch(() => null);
// the manifest, and the optional local-art manifest in parallel: unit views pick an enemy's local-client model by it
// (assets.js spineEntry, DESIGN §13 — 灼热源石虫 / 炽焰源石虫); absent or slow, they draw the web models
await withTimeout(Promise.all([assets.ready ? assets.ready() : null, assets.local ? assets.local() : null]
.map((p) => Promise.resolve(p).catch(() => {}))), 4000);
.map((p) => Promise.resolve(p).catch(() => {}))), 4000, signal);
signal?.throwIfAborted();
// web fonts for the bitmap damage numbers / tier chips (never block long)
try { if (document.fonts?.load) await withTimeout(Promise.all([document.fonts.load('700 40px Bender'), document.fonts.load('700 40px Oxanium')]), 1500); } catch { /* ignore */ }
try { if (document.fonts?.load) await withTimeout(Promise.all([document.fonts.load('700 40px Bender'), document.fonts.load('700 40px Oxanium')]), 1500, signal); } catch { /* optional fonts */ }
signal?.throwIfAborted();
const size = () => ({ width: Math.max(1, host.clientWidth || 1), height: Math.max(1, host.clientHeight || 1) });
const dpr = () => Math.min(globalThis.devicePixelRatio || 1, QUALITY_RES[settings.quality] || 2);
const boardDpr = () => Math.min(globalThis.devicePixelRatio || 1, BOARD_RES[settings.quality] || 2);
const s0 = size();
// Construction is synchronous until the complete view can own cancellation. If setup throws,
// dispose only the resources acquired by this attempt, never another view's host contents.
const setupCleanup = [];
let destroyed = false;
let view = null;
let onAbort;
try {
const app = new P.Application({
// MSAA only where it pays: dense (DPR ≥ 1.5) screens are sharp enough without it and it would cost 4× the fill
// transparent: the 3D board canvas shows through (the 2D board paints an opaque backdrop itself)
@@ -184,6 +197,11 @@ export async function createFieldView(host, options = {}) {
resolution: dpr(), autoDensity: true, powerPreference: 'high-performance',
});
const canvas = app.view;
setupCleanup.push(() => {
releaseGl(app.renderer);
try { app.destroy(true, { children: true, texture: false, baseTexture: false }); }
finally { canvas.remove(); }
});
canvas.style.display = 'block';
canvas.style.width = '100%';
canvas.style.height = '100%';
@@ -240,7 +258,6 @@ export async function createFieldView(host, options = {}) {
for (const fn of [...set]) { try { fn(payload); } catch (err) { console.error(`[render] ${name} listener failed`, err); } }
};
let destroyed = false;
let mode = 'idle'; // 'idle' | 'prep' | 'battle'
let stageRec = null;
let cam = presetCamera('prep', { width: s0.width, height: s0.height, padding: defaultPadding('prep', s0) }, { hud: hudBands('prep', s0) });
@@ -300,12 +317,14 @@ export async function createFieldView(host, options = {}) {
renderer: app.renderer,
frameNo: () => frameNo,
impostorInterval: () => impInterval,
impostorSlot: () => impSlot++,
clipAllowed: () => clipAllowed,
viewport: () => vp,
loadLevel: () => loadLevel,
surfaceLayer: (row) => tiles.surfaceLayer(row),
};
const tiles = new TileField({ ground: layers.ground, overlay: layers.overlay, props: layers.units, anim: layers.anim });
setupCleanup.push(() => tiles.destroy());
const fx = new FxSystem({
P, layers, cam: () => cam, settings, assets, heightAt, redVignette: bg.red, surfaceLayer: ctx.surfaceLayer,
timeScale: () => ctx.timeScale(),
@@ -320,10 +339,12 @@ export async function createFieldView(host, options = {}) {
return Math.max(80, Math.min(size().height * 0.4, p.y));
},
});
setupCleanup.push(() => fx.destroy());
ctx.fx = fx;
// battle devices (crates / turrets as sim units): the official crate mesh in the 3D scene, else a Pixi box
ctx.createBox = () => switchableBox({ board: () => board3d, pixi: () => tiles.createBox() });
const impostors = new ImpostorAtlas(app.renderer);
setupCleanup.push(() => impostors.destroy());
ctx.impostors = impostors;
tiles.setView(bandFor('prep'), camRect(), fieldRows('prep'));
// the real board art of the local client (optional): wait briefly so the first frame already uses it; a late
@@ -332,7 +353,6 @@ export async function createFieldView(host, options = {}) {
if (art && !destroyed) { tiles.setArt(art); tiles.project(cam, true); }
return art;
}, () => null);
await withTimeout(artPromise, 2500);
// ---- 3D board layer (render/board3d, DESIGN §15) -----------------------------------------------------------
let board3d = null; // BoardScene while the 3D board is on
@@ -340,6 +360,7 @@ export async function createFieldView(host, options = {}) {
let board3dError = null;
// lost-context recovery: the THREE module + art pack of the last 3D board, the pending retry, failures so far
const recover = { THREE: null, pack: null, timer: 0, tries: 0, since: 0, off: false, count: 0 };
setupCleanup.push(() => { clearTimeout(recover.timer); disable3d(); });
function enable3d(THREE, pack) {
if (destroyed || board3d || !THREE || !pack) return false;
recover.THREE = THREE; recover.pack = pack;
@@ -409,13 +430,13 @@ export async function createFieldView(host, options = {}) {
if (!enable3d(recover.THREE, recover.pack)) scheduleRecover();
}, delay);
}
if (want3d) {
const ready = Promise.all([threePromise, packPromise]).then(([THREE, pack]) => (THREE && pack ? enable3d(THREE, pack) : false), () => false);
await withTimeout(ready, 6000);
}
// Attach the board only after synchronous setup completes; enable3d also guards late arrivals.
const boardReady = Promise.all([threePromise, packPromise]).then(([THREE, pack]) => (THREE && pack ? enable3d(THREE, pack) : false), () => false);
// the official soft shadow sprite replaces the procedural one once loaded (may already be cached; asked again when the
// manifest arrives late)
let shadowAsked = false;
let offShadow = null;
setupCleanup.push(() => offShadow?.());
function loadShadow() {
const shadowUrl = !shadowAsked && assets.ui ? assets.ui('battle/sprite_shadow') : null;
if (!shadowUrl) return;
@@ -428,7 +449,11 @@ export async function createFieldView(host, options = {}) {
for (const v of views.values()) if (v.shadow && !v.destroyed) v.shadow.texture = t;
for (const v of penViews.values()) if (v.shadow && !v.destroyed) v.shadow.texture = t;
};
if (t.baseTexture.valid) use(); else t.baseTexture.once('loaded', use);
if (t.baseTexture.valid) use();
else {
t.baseTexture.once('loaded', use);
offShadow = () => t.baseTexture.off('loaded', use);
}
} catch { /* optional */ }
}
loadShadow();
@@ -1059,23 +1084,33 @@ export async function createFieldView(host, options = {}) {
function moveDragVisual(p) {
if (!dragState) return;
const v = dragState.view;
// held under the pointer: a unit with its drawn (lifted) feet DRAG_HOLD_TILES of its own px per tile below it (the
// scale at the pointer's ground, refined once at the feet), an item plate centred on it; the drop target
// (p.target, highlighted below) is the tile under the pointer
const item = v instanceof ItemView;
const g = cam.unproject(p.x, p.y, 0);
const hold = item ? 0 : DRAG_HOLD_TILES;
const s0 = g ? cam.scaleAt(g.x, g.y, 0) : cam.scale;
const t = pickTile(cam, p.x, p.y + hold * s0, heightAt, tiles.levels);
const z = t ? heightAt(t.row, t.col) : 0;
const up = item ? z : z + (v.lift || 0) + (v.hover || 0);
let w = cam.unproject(p.x, p.y + hold * s0, up);
if (w && hold) w = cam.unproject(p.x, p.y + hold * cam.scaleAt(w.x, w.y, up), up) || w;
if (w) {
v.x = Math.max(-1, Math.min(21, w.x));
v.y = Math.max(-1, Math.min(19, w.y));
v.z = z;
// a unit over a legal drop target — the tile under the pointer, on the board or the bench (render/drag.js
// dragStandTile) — stands on it, lifted, as in the official deploy drag (the owner's recording of 2026-10-09): the
// world point the drop tween goes to (boardWorld, the prep field transform applied), set at once, no tween
const stand = item ? null : dragStandTile(p);
if (stand) {
const w = boardWorld(stand.row, stand.col, stand.bench);
v.x = w.x; v.y = w.y; v.z = w.z;
} else {
// otherwise held under the pointer (the player still sees what they carry): a unit with its drawn (lifted) feet
// DRAG_HOLD_TILES of its own px per tile below it (the scale at the pointer's ground, refined once at the feet), an
// item plate centred on it
const g = cam.unproject(p.x, p.y, 0);
const hold = item ? 0 : DRAG_HOLD_TILES;
const s0 = g ? cam.scaleAt(g.x, g.y, 0) : cam.scale;
const t = pickTile(cam, p.x, p.y + hold * s0, heightAt, tiles.levels);
const z = t ? heightAt(t.row, t.col) : 0;
const up = item ? z : z + (v.lift || 0) + (v.hover || 0);
let w = cam.unproject(p.x, p.y + hold * s0, up);
if (w && hold) w = cam.unproject(p.x, p.y + hold * cam.scaleAt(w.x, w.y, up), up) || w;
if (w) {
v.x = Math.max(-1, Math.min(21, w.x));
v.y = Math.max(-1, Math.min(19, w.y));
v.z = z;
}
}
// the drop target (p.target, highlighted) is the tile under the pointer
if (p.target && p.target.area !== 'temp') {
const r = p.target.area === 'board' ? p.target.row : GEO.HAND_ROW;
const c = p.target.area === 'board' ? p.target.col : p.target.idx;
@@ -1206,6 +1241,17 @@ export async function createFieldView(host, options = {}) {
// the 收起 toggle of PR #149 moved the discs one button to the right) opened the bond popup instead of selecting the
// unit. As a click target that holds the finger's point the canvas wins: a tap on the board stays on the tile under it.
const onTapTarget = () => {};
const removeInput = () => {
canvas.removeEventListener('pointerdown', onPointerDown);
canvas.removeEventListener('pointermove', onPointerMove);
canvas.removeEventListener('pointerup', onPointerUp);
canvas.removeEventListener('pointercancel', onPointerCancel);
canvas.removeEventListener('pointerleave', onPointerLeave);
canvas.removeEventListener('contextmenu', onContext);
canvas.removeEventListener('touchend', onTouchEnd);
canvas.removeEventListener('click', onTapTarget);
};
setupCleanup.push(removeInput);
canvas.addEventListener('pointerdown', onPointerDown);
canvas.addEventListener('pointermove', onPointerMove);
canvas.addEventListener('pointerup', onPointerUp);
@@ -1412,6 +1458,14 @@ export async function createFieldView(host, options = {}) {
consumedIds.add(e[4].id);
if (consumedIds.size > 200) consumedIds.delete(consumedIds.values().next().value);
}
// 推拉: a push / pull (battle/displacement.js) moves the enemy inside one call; its `displace` fx carries the
// destination ['fx', 'displace', x, y, { id }] and the view slides there (UnitView.slideTo, PR #380) — normally
// started already by syncBattle's look-ahead, which this repeats harmlessly
if (e[1] === 'displace') {
const d = e[4] && typeof e[4] === 'object' ? e[4] : null;
const v = d && d.id != null ? views.get(d.id) : null;
if (v && v.slideTo) v.slideTo(Number(e[2]), Number(e[3]), Number(d.dur) > 0 ? { dur: Number(d.dur) } : {});
}
// an enemy's mode change — the `form` of a sim setForm fx (shared/protocol.js fxForm: 掠海漂移体 → 爬行模式, user
// playtest #5 item 1; 转译基底's forms, a 逐火 ember and its revival, the leaders' 重生, 守墓石像 — user report after
// 0.1.0) — switches the view's clip set (UnitView.setForm; a kind without a clip set of that skeleton changes
@@ -1464,10 +1518,25 @@ export async function createFieldView(host, options = {}) {
if (best) { tiles.flashObjective(best[0], best[1]); board3d?.flashObjective(best[0], best[1]); }
}
/** syncBattle's look-ahead: a queued `displace` fx ['fx', 'displace', x, y, { id }] starts that view's slide. */
let slideAt = 0;
function startSlide(e) {
if (!Array.isArray(e) || e[0] !== 'fx' || e[1] !== 'displace') return;
const extra = e[4] && typeof e[4] === 'object' ? e[4] : null;
const v = extra && extra.id != null ? views.get(extra.id) : null;
// `dur`: the push / pull's 失衡 time in game seconds (battle/displacement.js) — the slide's length
if (v && v.slideTo) v.slideTo(Number(e[2]), Number(e[3]), Number(extra.dur) > 0 ? { at: slideAt, dur: Number(extra.dur) } : { at: slideAt });
}
let renderT0Battle = null; // game time of the first rendered battle frame (spawn puffs skip the initial wave)
let downSeq = 0; // syncBattle pass counter: a view still marked down after a pass left the `down` list
function syncBattle(renderT) {
if (renderT0Battle == null) renderT0Battle = renderT;
// A push / pull's slide starts as soon as the interval sampled below ends on the snapshot that carries the
// destination: its `displace` fx is due only when renderT reaches that snapshot, and by then sample() has already
// lerped (≤ teleport) or snapped the enemy most of the way — no frames in between (PR #380). The fx are still queued
// here (processEvents took only those ≤ renderT); the view holds until `at`, then eases into the sim's position.
slideAt = interp.nextSnapT(renderT);
if (slideAt > renderT) interp.forEachUpcoming(renderT, slideAt, startSlide);
interp.sample(renderT, sample);
for (const [id, s] of sample) {
let v = views.get(id);
@@ -1478,9 +1547,7 @@ export async function createFieldView(host, options = {}) {
if (!v) v = battleView(id) || createUnknown(id, s);
if (!v) continue;
if (!v._seen) { v._seen = true; v.fadeIn = 0; }
if (v.alive || v.info?.kind === 'device') v.sync(s, renderT);
else if (v.dying > 0) { v.x = s.x; v.y = s.y; }
else if (s.anim !== ANIM.DIE && s.hp > 0) { v.revive?.(); v.sync(s, renderT); }
syncView(v, s, renderT);
}
// knocked-out operators waiting to redeploy (b.snap `down`, user playtest #4 item 9): their view stays on the
// field knocked down under a redeploy ring (UnitView.setDown) — made on the spot for one already down when this
@@ -1531,6 +1598,10 @@ export async function createFieldView(host, options = {}) {
let cpuMs = 0;
let frameNo = 0;
let impInterval = 0;
// Each frame the impostor units take slots 0, 1, 2 … in update order (units.js _updateImpostor): a unit refreshes when
// (frame + slot) % interval === 0, so a frame refreshes ⌊n/k⌋ or ⌈n/k⌉ of n units. Random per-unit phases left the
// busiest frame 20–66% above that (16–48 units, intervals 2–6) and set the frame-time peaks of a crowded battle.
let impSlot = 0;
let vp = { width: s0.width, height: s0.height }; // viewport (CSS px) of this frame: unit culling
let culledCount = 0;
// Adaptive load level 0–3: a device that cannot hold the frame rate with the current work switches crowds to
@@ -1547,8 +1618,9 @@ export async function createFieldView(host, options = {}) {
// Crowded fields render skeletons through staggered RenderTexture impostors (units.js): the interval grows with
// the number of Spine units so the per-frame vertex work stays roughly constant (hysteresis: re-evaluated
// every 30 frames). Prep and ordinary fields keep full-rate direct rendering.
// Spine clipping masks (only eyeball clips on the current roster, invisible at chibi scale) each cost a stencil
// render-pass break (~2–5 ms of GPU on tiled GPUs): kept only for a lone clipped skeleton at high quality
// Spine clipping masks (the eyelids on the current roster) each cost a stencil render-pass break (~2–5 ms of GPU on
// tiled GPUs): kept only for a lone clipped skeleton at high quality; without them SpineActor hides the eyeball slots a
// closed lid would cut (GitHub #177, spine.js _eyeMaskFallback)
let clipAllowed = true;
function pickClipping() {
let n = 0;
@@ -1571,6 +1643,7 @@ export async function createFieldView(host, options = {}) {
}
function frameBody(now) {
frameNo++;
impSlot = 0;
if (frameNo % 30 === 1) {
impInterval = pickImpostorInterval(); clipAllowed = pickClipping();
culledCount = 0;
@@ -1655,6 +1728,7 @@ export async function createFieldView(host, options = {}) {
let ro = null;
if (typeof ResizeObserver === 'function') {
ro = new ResizeObserver(() => resize());
setupCleanup.push(() => ro.disconnect());
ro.observe(host);
}
// a DPR change without a size change (window dragged to another monitor, zoom) must re-rasterise too; polled
@@ -1677,6 +1751,7 @@ export async function createFieldView(host, options = {}) {
leader?.view.retryAssets?.();
}
const offAssets = typeof assets.onChange === 'function' ? assets.onChange(onAssets) : null;
setupCleanup.push(() => offAssets?.());
const onVisible = () => {
if (destroyed || globalThis.document?.visibilityState !== 'visible') return;
if (assets.loaded === false && typeof assets.ready === 'function') assets.ready();
@@ -1685,10 +1760,11 @@ export async function createFieldView(host, options = {}) {
leader?.view.retryAssets?.();
};
globalThis.document?.addEventListener?.('visibilitychange', onVisible);
setupCleanup.push(() => globalThis.document?.removeEventListener?.('visibilitychange', onVisible));
// ---- public API ---------------------------------------------------------------------------------------------
const view = {
view = {
setStage,
setCamera,
setPrep,
@@ -1804,14 +1880,9 @@ export async function createFieldView(host, options = {}) {
try { ro?.disconnect(); } catch { /* ignore */ }
try { offAssets?.(); } catch { /* ignore */ }
globalThis.document?.removeEventListener?.('visibilitychange', onVisible);
canvas.removeEventListener('pointerdown', onPointerDown);
canvas.removeEventListener('pointermove', onPointerMove);
canvas.removeEventListener('pointerup', onPointerUp);
canvas.removeEventListener('pointercancel', onPointerCancel);
canvas.removeEventListener('pointerleave', onPointerLeave);
canvas.removeEventListener('contextmenu', onContext);
canvas.removeEventListener('touchend', onTouchEnd);
canvas.removeEventListener('click', onTapTarget);
removeInput();
offShadow?.();
signal?.removeEventListener('abort', onAbort);
app.ticker.remove(frame);
app.ticker.remove(preRender);
app.ticker.remove(postRender);
@@ -1829,6 +1900,7 @@ export async function createFieldView(host, options = {}) {
try { board3dCanvas?.remove(); } catch { /* ignore */ }
releaseGl(app.renderer);
try { app.destroy(true, { children: true, texture: false, baseTexture: false }); } catch { /* ignore */ }
canvas.remove();
},
stats() {
return {
@@ -1848,5 +1920,22 @@ export async function createFieldView(host, options = {}) {
pick: { pieceAt, battleUnitAt, penUnitAt, groundTile },
},
};
// All teardown dependencies now exist. Aborting during either asset wait immediately releases
// the renderer, ticker and listeners; late shared asset results see destroyed and do nothing.
setupCleanup.length = 0;
onAbort = () => view.destroy();
signal?.addEventListener('abort', onAbort, { once: true });
signal?.throwIfAborted();
await withTimeout(artPromise, 2500, signal);
if (want3d) await withTimeout(boardReady, 6000, signal);
signal?.throwIfAborted();
return view;
} catch (err) {
if (view) view.destroy();
else {
destroyed = true;
for (const dispose of setupCleanup.reverse()) { try { dispose(); } catch { /* continue cleanup */ } }
}
throw err;
}
}
+16 -2
View File
@@ -4,8 +4,22 @@ import { createAssets, assets as defaultAssets } from '../../assets.js';
import { standInRecord } from '../../../../shared/standIn.js';
import { diyRecordOf } from '../../../../shared/diy.js';
function withTimeout(p, ms) {
return Promise.race([p, new Promise((resolve) => setTimeout(resolve, ms))]);
/** Wait for optional assets, or stop waiting when this view is cancelled. Shared loads keep running. */
function withTimeout(p, ms, signal) {
let timer;
let onAbort;
const stopped = new Promise((resolve, reject) => {
if (ms != null) timer = setTimeout(resolve, ms);
if (signal) {
onAbort = () => reject(signal.reason);
signal.addEventListener('abort', onAbort, { once: true });
if (signal.aborted) onAbort();
}
});
return Promise.race([p, stopped]).finally(() => {
clearTimeout(timer);
if (onAbort) signal.removeEventListener('abort', onAbort);
});
}
/** Wrap whatever the caller passed as `assets` into the store API of public/js/assets.js. */
+5
View File
@@ -37,7 +37,12 @@ export function renderInfo(u) {
// DESIGN §16 loadout of an ally (UnitInfo.skillIndex / moduleId): the Spine actor plays that skill's clip, and a
// tap hands them to the detail card (a teammate's unit shows its owner's skill / module)
skillIndex: Number.isInteger(u.skillIndex) ? u.skillIndex : undefined,
// an ally whose skill is an ammo magazine (UnitInfo.ammoSkill): its rounds are the cells under the HP bar, so no sustained
// skill aura is drawn for it (render/fx/rings.js _aura)
ammoSkill: u.ammoSkill === true ? true : undefined,
moduleId: typeof u.moduleId === 'string' ? u.moduleId : undefined,
// Scouted hand and temp units have details but no field attack range.
area: typeof u.area === 'string' ? u.area : undefined,
// the ally's equipped item ids (UnitInfo.items, DESIGN §16 / §21.11): the detail card needs them for a teammate's
// unit (resolveDetail `unitItems` → the read-only 装备 section and the 变形同构体 pairing chips); the owner's own
// unit takes its items from the piece instead, so only other players' boards ever read this field
+3 -2
View File
@@ -26,8 +26,9 @@ const CHAIN_KINDS = new Set(['chain', 'chainHeal']);
const DROP_PENDING_MS = 1300;
/**
* A dragged unit is held with its drawn feet this many tiles below the pointer — the pointer on its body, the model
* under the finger / mouse (user playtest #4 item 1: as in v2.1; mouse and touch alike). An item plate is centred on it.
* A dragged unit over no legal target is held with its drawn feet this many tiles below the pointer — the pointer on its
* body, the model under the finger / mouse (user playtest #4 item 1: as in v2.1; mouse and touch alike); over a legal
* target it stands on that tile (render/drag.js dragStandTile). An item plate is centred on the pointer.
*/
export const DRAG_HOLD_TILES = 0.45;
+19 -2
View File
@@ -11,8 +11,10 @@
// direction wheel to re-orient it in place (research 09 §1.2 — "drag the unit onto its own tile and swipe").
// Everything is judged at the pointer (user playtest #4 item 1: the ground is drawn as tiles): a press / hover is on the
// piece standing on the tile under it (`hitPiece`, render/pick.js), and while dragging the target, `tileHover` and the
// legality are that tile — an item dropped on a unit's tile goes to that unit; app.js holds the dragged ghost under
// the pointer. A release over DOM UI covering the canvas (the shop bar) is 'outside' (mouse and touch alike).
// legality are that tile — an item dropped on a unit's tile goes to that unit. app.js draws a dragged unit standing on
// that tile while it is a legal target (`dragStandTile`, the official deploy drag) and held under the pointer
// otherwise; an item plate stays on the pointer. A release over DOM UI covering the canvas (the shop bar) is 'outside'
// (mouse and touch alike).
//
// Events emitted through `emit(name, payload)`:
// pieceHover { uid, piece, clientX, clientY } | { uid: null } (mouse/pen hover changes)
@@ -80,6 +82,21 @@ export function resolveDrop(p) {
return { kind: 'drop', target: slot };
}
/**
* The tile a dragged UNIT is drawn standing on, from a `pieceDragMove` payload: the drop target under the pointer while
* it is legal — a board tile, or a bench slot (row HAND_ROW) — as `{ row, col, bench }` in board space; null otherwise
* (no target, an illegal tile, a temp slot, the pointer over DOM UI such as the shop bar): the unit is then held under
* the pointer. As in the official deploy drag (the owner's recording of 2026-10-09) the model stands on the tile the
* finger is on, and moving within that tile leaves it where it is. app.js never snaps an item plate.
*/
export function dragStandTile(move, geo = GEO) {
const t = move && move.legal === true ? move.target : null;
if (!t) return null;
if (t.area === 'board' && Number.isInteger(t.row) && Number.isInteger(t.col)) return { row: t.row, col: t.col, bench: false };
if (t.area === 'hand' && Number.isInteger(t.idx)) return { row: geo.HAND_ROW, col: t.idx, bench: true };
return null;
}
/** Legality via the UI callback (board → (piece,row,col); hand → (piece, HAND_ROW, idx)). Throwing ⇒ illegal. */
export function isLegal(canPlace, piece, slot) {
if (!slot || slot.area === 'temp' || slot.area === 'outside') return false;
+3
View File
@@ -15,6 +15,9 @@ export const FX_KINDS = Object.freeze({
frostNova: { a: 'blast', c: 0x9fe6ff, smoke: 0x1c2630 }, sunBurst: { a: 'blast', c: 0xffe28a }, meltdown: { a: 'blast', c: 0xff5a2a, r: 1.5, heavy: true },
iceSpike: { a: 'blast', c: 0xbfeeff, smoke: 0x1c2630 }, rockfall: { a: 'blast', c: 0xc8a878, smoke: 0x4a3f33 }, rockslide: { a: 'blast', c: 0xc8a878, smoke: 0x4a3f33 },
finale: { a: 'blast', c: 0xffd45a, r: 1.5 }, swordStorm: { a: 'blast', c: 0xdfe8ff }, swordRain: { a: 'blast', c: 0xdfe8ff }, liberate: { a: 'blast', c: 0xffffff },
// 赤刃明霄陈 S3 赤霄·天喟 的龙剑气: a travelling blade, drawn from the sim's per-interval `chen3Wave` events (its own
// position + the previous point + direction); `pt` because it is not at the caster (it roams and passes by her tile)
chen3Wave: { a: 'qi', c: 0xdfe8ff, pt: true },
quadShot: { a: 'volley', c: 0xfff2d0 }, featherArrow: { a: 'counter', c: 0xfff2d0 },
burst: { a: 'element', c: 0xd0a0ff },
// areas
+6 -1
View File
@@ -74,8 +74,13 @@ export class FxRings {
this._aura(view, true);
}
/** Active-skill aura: a soft gold glow and a slowly turning hex on the ground under the unit (fading in / out). */
/**
* Active-skill aura: a soft gold glow and a slowly turning hex on the ground under the unit (fading in / out). An ammo
* skill (UnitInfo.ammoSkill) has none: its magazine is the yellow cells under the HP bar, which shrink with every round
* — a standing glow would read as a buff that never changes. The activation burst (skill()) is the same for all.
*/
_aura(view, on) {
if (on && view.info?.ammoSkill) return;
const P = this.P;
let a = this.auras.get(view.id);
if (on) {
+30 -3
View File
@@ -47,10 +47,11 @@ export class FxSim {
/**
* b.ev 'fx': every kind the sim / content emits has a visual (FX_KINDS archetypes: blast, shell, zone, telegraph,
* heal, sp, shield, shatter, summon, vanish, blink, move, wave, mark, reticle, buff, lift, sleep, crit, dodge,
* counter, dp, coin, crate, down, beam, bolt, strike, volley, pillar, lp, chill, element, flame), except kinds whose
* counter, dp, coin, crate, down, beam, bolt, strike, volley, pillar, lp, chill, element, flame, qi), except kinds whose
* archetype is 'none' (hitCap: a leader hit cancelled by 限伤 draws nothing); unknown kinds get a generic sparkle. `extra` keys used: id (anchor unit — or the shooter of a `pt` kind), r | radius, dur | duration,
* t (shell flight, game s), src / from / to / target / targets (unit ids), fx, fy / fromX, fromY / tx, ty (positions),
* element, n, scale, kind, tiles, hold ('lock': the reticle waits while the shooter's skill runs).
* element, n, scale, kind, tiles, hold ('lock': the reticle waits while the shooter's skill runs), dr / dc (a
* direction in tiles: 'qi' points its blade that way).
*/
simFx(kind, x, y, extra) {
const ex = extra && typeof extra === 'object' ? extra : {};
@@ -100,7 +101,12 @@ export class FxSim {
else this.zone(at.x, at.y, at.z, r, col, d, 'ring', true);
break;
}
case 'chill': this.flashScreen(col, 0.35, 0.8); this.snowfall(col); break;
// 盟约寒风 (谢拉格's field-wide wind — the device emits it with no anchor unit): a blizzard up the field and a
// stronger cold flash (PR #386); an operator's own cold (灵知 S3's start, 麦哲伦 S1's pulse every 3 s: `id` = the
// unit) keeps the light flurry and the softer flash — a full-screen blizzard every pulse would bury the field
case 'chill':
if (ex.id == null) { this.flashScreen(col, 0.45, 1); this.snowfall(col, true); } else { this.flashScreen(col, 0.35, 0.8); this.snowfall(col); }
break;
case 'heal': this.heal(at.v || { x: at.x, y: at.y, z: at.z }, 0); break;
case 'healAoe': {
this.ring(at.x, at.y, at.z, 0.2, r, col, 0.6);
@@ -168,6 +174,27 @@ export class FxSim {
}
break;
}
// a travelling blade (赤刃明霄陈 S3's 龙剑气): a short trail from where it was to where it is, plus a crescent head
// turned the way it moves — the sim sends one of these every 0.12 s, and the trail outlives that gap so the
// events join into one sweeping wave. A procedural stand-in (the official effect is a particle prefab this client
// does not extract). Trail, head and heading share one height; the head is projected after streak(), which
// reuses this._p / this._q (projected before it, the head was drawn at the trail's start, one event behind)
case 'qi': {
const z = at.z + 0.35;
const fx0 = num(ex.fromX, NaN), fy0 = num(ex.fromY, NaN);
if (Number.isFinite(fx0) && Number.isFinite(fy0)) this.streak(fx0, fy0, at.x, at.y, z, col, 0.34);
const h = cam.project(at.x, at.y, z, this._p), hx = h.x, hy = h.y, hs = h.s;
const dr = num(ex.dr, NaN), dc = num(ex.dc, NaN);
let rot = 0;
if (Number.isFinite(dr) && Number.isFinite(dc) && (dr || dc)) {
const q = cam.project(at.x + dc * 0.5, at.y + dr * 0.5, z, this._q);
// the `slash` sprite's crescent bulges along its local −y, so this turns that edge onto the travel direction
rot = Math.atan2(q.x - hx, -(q.y - hy));
}
this.particle('slash', hx, hy, { tint: col, life: 0.3, s0: hs / 128 * 1.3, s1: hs / 128 * 2.0, a0: 0.9, a1: 0, rot });
this.particle('glow', hx, hy, { tint: col, life: 0.26, s0: hs / 128 * 0.8, s1: hs / 128 * 1.5, a0: 0.55, a1: 0 });
break;
}
case 'wave': {
const k = clamp(num(ex.scale, 1), 0.5, 4);
const rr = Math.max(0.8, r * (ex.scale ? Math.min(2, k / 2) : 1));
+25 -4
View File
@@ -180,11 +180,32 @@ export class FxZones {
this.tintSprite.tint = tint;
}
/** A short flurry of snow over the field (cold wind). */
snowfall(tint) {
/**
* Snow over the field (cold wind). `gust` — 盟约寒风, 谢拉格's field-wide wind: a blizzard sweeping the screen from the
* bottom up, the official direction (the owner's confirmation of 2026-10-08; PR #386 by @leiming2333 after the
* community report 「谢拉格的效果特性不明显,暴风雪应该从下向上刮」): snowflakes rise off the bottom edge on a crosswind
* with gust streaks racing along, so it reads as wind driving up the field, not quiet snowfall (flake / streak counts,
* speeds and lives are a visual choice [ASSUMED]; screen y grows downward, so a negative vy rises). Without `gust` (an
* operator's own cold: 灵知 S3, 麦哲伦 S1's pulses) a short light flurry drifting down, as before.
*/
snowfall(tint, gust = false) {
const size = this.ctx.screenSize();
for (let i = 0; i < (this.quality === 'low' ? 10 : 26); i++) {
this.particle('dot', Math.random() * size.width, Math.random() * size.height * 0.7, { tint, vx: 30 + Math.random() * 40, vy: 60 + Math.random() * 60, life: 1 + Math.random() * 0.6, s0: 0.25 + Math.random() * 0.3, s1: 0.1, a0: 0.8, a1: 0, fadeIn: 0.2 });
const W = size.width, H = size.height, low = this.quality === 'low';
if (!gust) {
for (let i = 0; i < (low ? 10 : 26); i++) {
this.particle('dot', Math.random() * W, Math.random() * H * 0.7, { tint, vx: 30 + Math.random() * 40, vy: 60 + Math.random() * 60, life: 1 + Math.random() * 0.6, s0: 0.25 + Math.random() * 0.3, s1: 0.1, a0: 0.8, a1: 0, fadeIn: 0.2 });
}
return;
}
// snowflakes: lifted off the bottom edge (a share starts below it), blown up-right and tumbled by the gust
for (let i = 0; i < (low ? 22 : 48); i++) {
const vy = -H * (0.55 + Math.random() * 0.5), vx = W * (0.04 + Math.random() * 0.1);
this.particle('dot', Math.random() * W, H * (0.55 + Math.random() * 0.55), { tint, vx, vy, life: 1.4 + Math.random() * 0.9, s0: 0.22 + Math.random() * 0.34, s1: 0.08, a0: 0.9, a1: 0, fadeIn: 0.12, spin: (Math.random() - 0.5) * 6 });
}
// gust streaks: thin wind lines racing up through the field along their velocity
for (let i = 0; i < (low ? 4 : 10); i++) {
const vy = -H * (0.9 + Math.random() * 0.6), vx = W * (0.1 + Math.random() * 0.12);
this.particle('streak', Math.random() * W, H * (0.6 + Math.random() * 0.5), { tint, vx, vy, life: 0.7 + Math.random() * 0.4, s0: 0.04 + Math.random() * 0.03, s1: 0.02, sx: 6 + Math.random() * 6, a0: 0.55, a1: 0, fadeIn: 0.08, rot: Math.atan2(vy, vx) });
}
}
+106 -20
View File
@@ -8,7 +8,8 @@
// advancing at `rate` and gently steered back when network jitter pushes it off target (hard snap when it
// is more than `snapAfter` real seconds off),
// * extrapolation guard: renderT never runs more than `maxExtrapolate` real seconds past the newest snapshot;
// positions are extrapolated along the last velocity for at most that long, then freeze,
// positions are extrapolated along the last velocity for at most that long, then freeze — never for a unit the
// newest snapshot shows dead, stunned (frozen, asleep) or blocked, nor for an enemy it shows standing (`stand`),
// * sample(): per unit, lerps x/y/hp/sp between the two snapshots bracketing renderT; flags/anim come from
// the older one. A unit missing from the newer snapshot (died/left mid-buffer) holds its last position
// until renderT reaches the newer snapshot; a unit that only exists in the newer one (spawned mid-buffer)
@@ -20,18 +21,31 @@
// game s behind) are dropped by the caller's choice (`isCosmeticEvent`); state events — an enemy's form fx
// included — are always delivered, and a full queue sheds only cosmetic ones.
//
// Snapshot tuple layout (DESIGN §8.2): [id, x, y, hp, maxHp, sp, spMax, flags, anim]. Two optional lists ride along
// (server/sim/battle/events.js snapshot, user playtest #4 items 8 / 9):
// Snapshot tuple layout (DESIGN §8.2): [id, x, y, hp, maxHp, sp, spMax, flags, anim]. Optional lists ride along
// (server/sim/battle/events.js snapshot; the first two from user playtest #4 items 8 / 9, `stand` / `standCut` from PR #381):
// * `elem` [[id, element, fill, cooldownEnd, cooldown]] — the element gauge a unit shows: appended to that unit's
// normalised tuple (EL…EL_DUR) and handed out by sample() as `el`, `elFill`, `elUntil`, `elDur` (from the older
// snapshot, like flags);
// * `down` [[id, respawnAt, respawnTime, state, row?, col?]] — knocked-out operators waiting to redeploy (they are no
// longer in `units`) and the tile they lie on (where they fell, or their home — sim Battle._layBody; kept only when
// both are integers): downAt(time) returns the list of the snapshot at `time`.
// Game times in both (`cooldownEnd`, `respawnAt`) are on the snapshots' clock, so a view compares them with renderT.
// both are integers): downAt(time) returns the list of the snapshot at `time`;
// * three HP-bar readouts, kept per snapshot beside the tuples (the tuple layout is unchanged) and handed out by sample()
// from the older snapshot like flags, so they step with the skill flag instead of sliding:
// `ammo` [[id, rounds left, rounds in the magazine]] → sample().ammo [left, magazine] | null (whole numbers only)
// `wolves` [[id, 狼影 left, the talent's maximum]] → sample().wolves [left, maximum] | null
// `neg` [[id, fill]] (0.01–1: the negative-HP pool's share of its cap) → sample().neg number (0: none)
// An entry that is malformed, or names a unit the snapshot does not list, is dropped; a snapshot without the list
// clears the readout (render/units.js: the segmented ammo bar, the wolf pips, the red bar of 斩业星熊's 我执).
// * `stand` [[id, until]] — the game time an enemy's attack recovery ends (sim atkStandUntil, PR #381): the position
// holds until then and interpolates the rest of the interval; nothing is inferred from the attack animation, and a
// snapshot without it interpolates linearly;
// * `standCut` [[id, at]] — the latest time that enemy's recovery was cut or ignored: an interval with a cut inside
// it stays linear.
// Game times in all of them (`cooldownEnd`, `respawnAt`, `until`, `at`) are on the snapshots' clock, so a view compares
// them with renderT.
import { fxForm } from '../../../shared/protocol.js';
import { ANIM } from '../../../shared/constants.js';
import { ANIM, UF } from '../../../shared/constants.js';
export const TUPLE = Object.freeze({ ID: 0, X: 1, Y: 2, HP: 3, MAXHP: 4, SP: 5, SPMAX: 6, FLAGS: 7, ANIM: 8, EL: 9, EL_FILL: 10, EL_UNTIL: 11, EL_DUR: 12 });
/** The unit was (re)deployed between tuples `a` and the newer `b`: `b` plays the deploy animation, `a` did not (sim snapshot animOf). */
@@ -43,6 +57,15 @@ const MAX_EVENTS = 6000;
const finite = (v, d = 0) => (typeof v === 'number' && Number.isFinite(v) ? v : d);
const clamp = (v, lo, hi) => (v < lo ? lo : v > hi ? hi : v);
/**
* Game time unit `id` starts moving between snapshot `a` and the newer `b` (interpolation and extrapolation share it):
* the end of `a`'s stand when it falls inside the interval and `b` records no cut there, else `a.t`.
*/
function movementStart(a, b, id) {
const until = a.stand?.get(id), cutAt = b.standCut?.get(id);
return until > a.t && until <= b.t && !(cutAt >= a.t && cutAt <= b.t) ? until : a.t;
}
/** Cosmetic event kinds that may be dropped when far behind (never state-changing). */
export const COSMETIC_EVENTS = new Set(['atk', 'dmg', 'heal', 'fx', 'layer', 'bounty']);
/**
@@ -66,8 +89,11 @@ export function frameTime(msg) {
/**
* Validate & normalise a b.snap payload. Returns `{ t, units: Map<id, tuple>, down: [[id, respawnAt, respawnTime,
* state, row?, col?]] | null, raw }` or null when unusable. Tuples with a non-finite id/x/y are skipped; other numbers default
* to 0; a unit's `elem` entry (see header) is appended to its tuple; malformed `elem` / `down` entries are dropped.
* state, row?, col?]] | null, ammo, wolves: Map id → [left, max] | null, neg: Map id → fill | null, stand: Map<id, until> | null,
* standCut: Map<id, at> | null, raw }` or null when unusable. Tuples with a non-finite id/x/y are skipped; other numbers
* default to 0; a unit's `elem` entry (see header) is appended to its tuple; malformed `elem` / `down` / `ammo` / `wolves` /
* `neg` entries are dropped. `stand` keeps finite end times after `t` of units in the snapshot; `standCut` keeps finite
* times in [0, t] of units in it.
*/
export function normalizeSnapshot(snap) {
if (!snap || typeof snap !== 'object') return null;
@@ -90,6 +116,14 @@ export function normalizeSnapshot(snap) {
if (tu && tu.length === 9) tu.push(e[1], clamp(finite(e[2]), 0, 1), finite(e[3]), Math.max(0, finite(e[4])));
}
}
const ammo = countList(snap.ammo, units), wolves = countList(snap.wolves, units);
let neg = null;
if (Array.isArray(snap.neg)) {
for (const e of snap.neg) {
if (!Array.isArray(e) || !units.has(e[0]) || !(typeof e[1] === 'number' && e[1] > 0)) continue;
(neg || (neg = new Map())).set(e[0], Math.min(1, e[1]));
}
}
let down = null;
if (Array.isArray(snap.down)) {
for (const d of snap.down) {
@@ -99,7 +133,36 @@ export function normalizeSnapshot(snap) {
(down || (down = [])).push(e);
}
}
return { t, units, down, raw: snap };
let stand = null;
if (Array.isArray(snap.stand)) {
for (const e of snap.stand) {
if (!Array.isArray(e) || !units.has(e[0]) || !Number.isFinite(e[1]) || e[1] <= t) continue;
(stand || (stand = new Map())).set(e[0], e[1]);
}
}
let standCut = null;
if (Array.isArray(snap.standCut)) {
for (const e of snap.standCut) {
if (!Array.isArray(e) || !units.has(e[0]) || !Number.isFinite(e[1]) || e[1] < 0 || e[1] > t) continue;
(standCut || (standCut = new Map())).set(e[0], e[1]);
}
}
return { t, units, down, ammo, wolves, neg, stand, standCut, raw: snap };
}
/**
* `[[id, left, max]]` → Map id → [left, max] for the ids in `units`: whole numbers with 0 ≤ left ≤ max and max ≥ 1 (the
* `ammo` and `wolves` lists); null without a valid entry.
*/
function countList(list, units) {
if (!Array.isArray(list)) return null;
let m = null;
for (const e of list) {
if (!Array.isArray(e) || !units.has(e[0]) || !Number.isSafeInteger(e[1]) || !Number.isSafeInteger(e[2])
|| e[1] < 0 || e[2] < 1 || e[1] > e[2]) continue;
(m || (m = new Map())).set(e[0], [e[1], e[2]]);
}
return m;
}
export class SnapshotBuffer {
@@ -252,6 +315,16 @@ export class SnapshotBuffer {
return ans;
}
/**
* Game time of the first snapshot after `time` (default renderT) — the newer end of the interval sample() interpolates
* — or NaN when there is none (extrapolating). render/app.js starts a push's slide while that interval is shown.
*/
nextSnapT(time = this.renderT) {
const s = this.snaps;
const i = this._indexAt(time) + 1;
return Number.isFinite(time) && i < s.length ? s[i].t : NaN;
}
/**
* The `down` list (knocked-out operators waiting to redeploy, see header) of the snapshot shown at `time` (default
* renderT) — the same snapshot sample() reads flags from — or null.
@@ -265,8 +338,9 @@ export class SnapshotBuffer {
/**
* Interpolated state at `time` (default renderT). Fills and returns `out` (a Map id → sample object reused
* across calls: `{ id, x, y, hp, maxHp, sp, spMax, flags, anim, vx, vy, seen, el, elFill, elUntil, elDur }` —
* `el` = the shown element gauge (null: none), see header). Samples of units no longer present are deleted from `out`.
* across calls: `{ id, x, y, hp, maxHp, sp, spMax, flags, anim, vx, vy, seen, el, elFill, elUntil, elDur, ammo, wolves,
* neg }` — `el` = the shown element gauge (null: none), `ammo` / `wolves` / `neg` the HP-bar readouts (null / null / 0:
* none), see header). Samples of units no longer present are deleted from `out`.
*/
sample(time = this.renderT, out = new Map()) {
const s = this.snaps;
@@ -282,28 +356,36 @@ export class SnapshotBuffer {
const stamp = A.t;
for (const [id, a] of A.units) {
let o = out.get(id);
if (!o) { o = { id, x: 0, y: 0, hp: 0, maxHp: 0, sp: 0, spMax: 0, flags: 0, anim: 0, vx: 0, vy: 0, seen: 0, el: null, elFill: 0, elUntil: 0, elDur: 0 }; out.set(id, o); }
if (!o) { o = { id, x: 0, y: 0, hp: 0, maxHp: 0, sp: 0, spMax: 0, flags: 0, anim: 0, vx: 0, vy: 0, seen: 0, el: null, elFill: 0, elUntil: 0, elDur: 0, ammo: null, wolves: null, neg: 0 }; out.set(id, o); }
const b = B ? B.units.get(id) : null;
if (b) {
const dx = b[1] - a[1], dy = b[2] - a[2];
// (a deployment in between — the newer snapshot starts its deploy animation — lands on its tile, no slide)
const tele = dx * dx + dy * dy > this.teleport * this.teleport || redeployed(a, b);
o.x = tele ? (alpha < 1 ? a[1] : b[1]) : a[1] + dx * alpha;
o.y = tele ? (alpha < 1 ? a[2] : b[2]) : a[2] + dy * alpha;
o.vx = !tele && span > 0 ? dx / span : 0;
o.vy = !tele && span > 0 ? dy / span : 0;
const start = movementStart(A, B, id);
const moveSpan = B.t - start;
const moveAlpha = moveSpan > 0 ? clamp((time - start) / moveSpan, 0, 1) : 0;
o.x = tele ? (alpha < 1 ? a[1] : b[1]) : a[1] + dx * moveAlpha;
o.y = tele ? (alpha < 1 ? a[2] : b[2]) : a[2] + dy * moveAlpha;
o.vx = !tele && moveSpan > 0 && (start === A.t || time >= start) ? dx / moveSpan : 0;
o.vy = !tele && moveSpan > 0 && (start === A.t || time >= start) ? dy / moveSpan : 0;
o.hp = a[3] + (b[3] - a[3]) * alpha;
o.maxHp = b[4] || a[4];
o.sp = a[5] + (b[5] - a[5]) * alpha;
o.spMax = b[6] || a[6];
} else {
let vx = 0, vy = 0;
if (P && ext > 0) {
// a stand in the newest snapshot: the old velocity cannot predict the walk after it, even once the clock passes
// `until` — the next snapshot confirms the position
if (P && ext > 0 && a[8] !== ANIM.DIE && a[8] !== ANIM.STUN && !(a[7] & UF.BLOCKED) && !A.stand?.has(id)) {
const p = P.units.get(id);
const dtp = A.t - P.t;
if (p && dtp > 0) {
vx = (a[1] - p[1]) / dtp; vy = (a[2] - p[2]) / dtp;
if (vx * vx + vy * vy > (this.teleport / dtp) ** 2 || redeployed(p, a)) { vx = 0; vy = 0; }
const moveSpan = A.t - movementStart(P, A, id);
if (p && dtp > 0 && moveSpan > 0) {
const dx = a[1] - p[1], dy = a[2] - p[2];
if (dx * dx + dy * dy <= this.teleport * this.teleport && !redeployed(p, a)) {
vx = dx / moveSpan; vy = dy / moveSpan;
}
}
}
o.x = a[1] + vx * ext;
@@ -317,6 +399,10 @@ export class SnapshotBuffer {
o.flags = a[7];
o.anim = a[8];
if (a.length > 9) { o.el = a[9]; o.elFill = a[10]; o.elUntil = a[11]; o.elDur = a[12]; } else if (o.el !== null) { o.el = null; o.elFill = 0; o.elUntil = 0; o.elDur = 0; }
// the HP-bar readouts come with the older snapshot, like flags (whole rounds step with the skill flag)
o.ammo = A.ammo ? A.ammo.get(id) || null : null;
o.wolves = A.wolves ? A.wolves.get(id) || null : null;
o.neg = A.neg ? A.neg.get(id) || 0 : 0;
o.seen = stamp;
}
for (const id of out.keys()) if (!A.units.has(id)) out.delete(id);
+134 -10
View File
@@ -62,20 +62,53 @@ export function windUpPlan(loopDur, hit, interval, lead) {
return { ts, tsWind, start };
}
/** Whether any skin of the skeleton data has a clipping attachment (pixi-spine AttachmentType.Clipping = 6). */
/** A clipping attachment (pixi-spine AttachmentType.Clipping = 6). */
const isClip = (a) => !!a && (a.type === 6 || a.constructor?.name === 'ClippingAttachment' || ('endSlot' in a && 'vertices' in a && !('uvs' in a)));
/** Whether any skin of the skeleton data has a clipping attachment. */
export function hasClipping(data) {
try {
for (const skin of data?.skins || []) {
const list = typeof skin.getAttachments === 'function' ? skin.getAttachments() : [];
for (const e of list) {
const a = e && e.attachment;
if (a && (a.type === 6 || a.constructor?.name === 'ClippingAttachment' || ('endSlot' in a && 'vertices' in a && !('uvs' in a)))) return true;
}
for (const e of list) if (isClip(e && e.attachment)) return true;
}
} catch { /* unknown runtime shape: assume none */ }
return false;
}
/**
* Is (x, y) inside the polygon of `n` points `pts` (flat [x0, y0, x1, y1, …])? Ray casting (PR #384 by @Convey123).
*/
export function pointInPolygon(x, y, pts, n) {
let inside = false;
for (let i = 0, j = n - 1; i < n; j = i++) {
const xi = pts[i * 2], yi = pts[i * 2 + 1], xj = pts[j * 2], yj = pts[j * 2 + 1];
if ((yi > y) !== (yj > y) && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi) inside = !inside;
}
return inside;
}
/**
* World centre of what a slot draws, into `out` ([x, y]); false when it draws nothing measurable. A mesh: the mean of its
* world vertices (`buf` grows to fit); a region: its quad's centre — the attachment's (x, y) through the bone (the four
* corners sit symmetrically around it; RegionAttachment.computeWorldVertices takes a bone in runtime 3.8, a slot in 4.1).
*/
function slotCentre(slot, att, buf, out) {
const n = att.worldVerticesLength | 0;
if (n >= 2 && typeof att.computeWorldVertices === 'function') {
if (buf.v.length < n) buf.v = new Float32Array(n);
const v = buf.v;
att.computeWorldVertices(slot, 0, n, v, 0, 2);
let cx = 0, cy = 0;
for (let k = 0; k < n; k += 2) { cx += v[k]; cy += v[k + 1]; }
out[0] = cx / (n >> 1); out[1] = cy / (n >> 1);
} else if (att.region !== undefined && Number.isFinite(att.x) && Number.isFinite(att.y) && slot.bone) {
const b = slot.bone;
out[0] = b.a * att.x + b.b * att.y + b.worldX; out[1] = b.c * att.x + b.d * att.y + b.worldY;
} else return false;
return Number.isFinite(out[0]) && Number.isFinite(out[1]);
}
export class SpineActor {
/**
* @param {any} spineData PIXI.spine skeleton data
@@ -91,11 +124,12 @@ export class SpineActor {
this.names = new Set((spineData.animations || []).map((a) => a.name));
/**
* Clipping attachments render as stencil masks (≈1.5 ms of GPU each per frame on tiled GPUs): such skeletons
* are drawn through the impostor atlas while clipping is on, and clipping is switched off (unclipped slots,
* visually negligible on battle chibis) when too many of them share a field (app.js budget).
* are drawn through the impostor atlas while clipping is on, and clipping is switched off when too many of them
* share a field (app.js budget) — then _eyeMaskFallback hides what a closed eyelid would cut (GitHub #177).
*/
this.clipped = hasClipping(spineData);
this.clipOn = true;
this._clipHidden = new Set(); // slot indexes the eyelid fallback hid in its last pass (_eyeMaskFallback)
if (this.clipped) {
const sp = this.spine;
const orig = typeof sp.createGraphics === 'function' ? sp.createGraphics.bind(sp) : null;
@@ -134,6 +168,7 @@ export class SpineActor {
on = !!on;
if (!this.clipped || on === this.clipOn) return;
this.clipOn = on;
if (on) this._showClipHidden(); // the stencil takes over from the eyelid fallback
for (const slot of this.spine?.skeleton?.slots || []) {
if (!slot.clippingContainer) continue;
slot.clippingContainer.mask = on ? slot.currentGraphics || null : null;
@@ -200,6 +235,41 @@ export class SpineActor {
else if (this.mode === 'stun' && this.has(this.roles.stun?.loop)) this._play(this.roles.stun.loop, true);
}
/**
* Re-pose on the clip set in force right after an immediate form switch (render/units.js STEALTH_FORMS: 假想敌:骨刺's
* stealth bit; PR #365): a running attack goes on in the new set's attack clip at the same point relative to its
* strike frame (a pending wind-up still strikes at windUntil), a stun / the resting state restarts on the new set's
* clip without a crossfade, and the skeleton is posed at once — also while a freeze holds it (`frozen`).
*/
syncFormPose() {
if (this.dead) return;
if (this.mode === 'attack') {
const track = this.spine.state.tracks[0];
const clip = this._attackClip();
if (!track || !clip) return;
const dur = this.dur(clip.loop);
const oldHit = this._hitTime(this.current, this.dur(this.current));
const hit = this._hitTime(clip.loop, dur);
const start = clampN(track.trackTime + hit - oldHit, 0, dur);
const lead = this.wound && this.windUntil != null
? Math.max(0, this.windUntil - this.clock) : 0;
const ts = lead > 0 ? Math.max(0, hit - start) / lead : track.timeScale;
const tailTs = lead > 0 ? this.windTs : ts;
this._play(clip.loop, track.loop, { start, timeScale: ts, mix: 0 });
this.attackUntil = this.clock + lead +
Math.max(0, dur - (lead > 0 ? hit : start)) / tailTs;
} else if (this.mode === 'stun') {
const name = this.has(this.roles.stun?.loop) ? this.roles.stun.loop : this._baseName();
this._play(name, true, { mix: 0 });
} else if (this.mode === 'base') {
this._play(this._baseName(), true, { mix: 0 });
} else {
return;
}
// Apply the new attachments even when normal updates are frozen.
this.spine.update(0);
}
/** Play a form's transition clip once; attacks and the resting state wait for it (mode 'change'). */
_change(clip) {
if (this.mode !== 'change') this.stunWanted = this.mode === 'stun';
@@ -332,9 +402,13 @@ export class SpineActor {
this.windUntil = null;
this.wound = false;
if (!wasAttacking) {
// not wound up (no look-ahead, e.g. a batch that arrived late): the sim already resolved the hit, so show
// the strike frame now
this._play(clip.loop, !single, { timeScale: ts, start: hit, mix: 0.06 });
// not wound up (no look-ahead, e.g. a batch that arrived late): the sim already resolved the hit, so show the
// strike frame now — unless a clip-per-attack actor's rhythm leaves room for the whole clip (interval ≥ clip):
// then the clip plays complete from its wind-up at its own speed, the strike a little late (重犯's iron ball
// lifts before the slam; #246 by @TsangAsuna, accepted by the owner on 2026-10-07)
const whole = per && this.interval >= loopDur;
if (whole) this.attackUntil = this.clock + loopDur / ts;
this._play(clip.loop, !single, { timeScale: ts, start: whole ? 0 : hit, mix: 0.06 });
} else if (single) {
const e = this.spine.state.tracks[0];
if (e) e.timeScale = ts;
@@ -546,6 +620,56 @@ export class SpineActor {
if (!this.frozen) {
try { this.spine.update(dt); } catch { /* a broken skeleton must not stop the frame */ }
}
if (this.clipped && !this.clipOn) this._eyeMaskFallback();
}
/**
* The eyelids without the stencil masks (GitHub #177; PR #384 by @Convey123, re-implemented). On this roster the
* clipping attachments are eyelids: the polygon is the eye's opening and the eyeball / eye-white meshes drawn after
* it are clipped to it, so a closing eye (a blink, a knock-down) is hidden by the mask, not by the art. The masks
* are off on a crowded field or below high quality (render/app.js pickClipping), and the eyeballs showed through
* the closed lids (佩佩, 仇白, 隐德来希, 琳琅诗怀雅 …). While they are off, every slot a clip covers — walked in draw
* order from the clipping slot to its endSlot, with the attachments in force (any skin), as pixi-spine does — is
* hidden for the frame when the centre of what it draws lies outside the clip polygon [ASSUMED: all or nothing per
* slot, an approximation of the mask's cut]. pixi-spine shows every attached slot container again on each update.
*/
_eyeMaskFallback() {
const sp = this.spine, skel = sp && sp.skeleton, boxes = sp && sp.slotContainers;
if (!skel || !Array.isArray(skel.drawOrder) || !boxes) return;
this._showClipHidden(); // undo the last pass (a pixi-spine update re-shows them anyway; a frozen one does not)
try {
const buf = this._clipBuf || (this._clipBuf = { v: new Float32Array(64), poly: new Float32Array(16), c: [0, 0] });
let pn = -1, end = null; // pn: points of the clip in force (−1: none; < 3: degenerate, hides nothing)
for (const slot of skel.drawOrder) {
const att = typeof slot.getAttachment === 'function' ? slot.getAttachment() : slot.attachment;
if (isClip(att)) {
const n = att.worldVerticesLength | 0;
if (buf.poly.length < n) buf.poly = new Float32Array(n);
if (n >= 6) att.computeWorldVertices(slot, 0, n, buf.poly, 0, 2);
pn = n >> 1;
end = att.endSlot && att.endSlot !== slot.data ? att.endSlot : null;
continue;
}
if (pn < 0) continue;
const box = boxes[slot.data.index];
if (pn >= 3 && att && box && slotCentre(slot, att, buf, buf.c) && !pointInPolygon(buf.c[0], buf.c[1], buf.poly, pn)) {
box.visible = false;
this._clipHidden.add(slot.data.index);
}
if (end && slot.data === end) { pn = -1; end = null; }
}
} catch { /* an unknown runtime shape must never stop the frame */ }
}
/** Show the slot containers the fallback hid (those still drawing an attachment). */
_showClipHidden() {
if (!this._clipHidden.size) return;
const slots = this.spine?.skeleton?.slots, boxes = this.spine?.slotContainers;
for (const i of this._clipHidden) {
const slot = slots && slots[i];
if (boxes && boxes[i] && slot && (typeof slot.getAttachment === 'function' ? slot.getAttachment() : slot.attachment)) boxes[i].visible = true;
}
this._clipHidden.clear();
}
/** Model height in skeleton units (setup-pose bounds, else a chibi default). */
+5
View File
@@ -60,9 +60,14 @@ export const COLORS = Object.freeze({
hpBoss: 0xff2d55,
hpGhost: 0xfff0c8,
hpBack: 0x0c0f0e,
// the red bar of a negative-HP pool (斩业星熊's 我执, b.snap `neg`) — the same red as an enemy's HP
hpNeg: 0xff4b3e,
sp: 0x6fd3ff,
spReady: 0xffe066,
spActive: 0xffb347,
// the ammo bar's yellow cells (b.snap `ammo`) and 伺夜's 狼影 pips (b.snap `wolves`)
ammo: 0xffd04a,
wolf: 0xe8f0ff,
shield: 0xdfe8ff,
});
+256 -18
View File
@@ -110,6 +110,16 @@ export function dieClipDur(entry) {
const d = Object.hasOwn(durs, name) ? Number(durs[name]) : NaN;
return Number.isFinite(d) && d > 0 ? d : 0;
}
/**
* render/app.js syncBattle's step for one view and its interpolated sample `s` at render time `t`: a living view (or a
* device) takes the whole sample; a dying one only the position — its DIE window in the snapshots; one shown alive
* again (redeployed without an event) stands up first.
*/
export function syncView(v, s, t) {
if (v.alive || v.info?.kind === 'device') v.sync(s, t);
else if (v.dying > 0) { if (typeof v.followSample === 'function') v.followSample(s, t); else { v.x = s.x; v.y = s.y; } }
else if (s.anim !== ANIM.DIE && s.hp > 0) { v.revive?.(); v.sync(s, t); }
}
/** World step (x = col, y = row) of a direction. */
export const DIR_STEP = Object.freeze({ UP: [0, 1], RIGHT: [1, 0], DOWN: [0, -1], LEFT: [-1, 0] });
const nowMs = () => (globalThis.performance ? globalThis.performance.now() : Date.now());
@@ -117,6 +127,12 @@ const nowMs = () => (globalThis.performance ? globalThis.performance.now() : Dat
* 无头像; data/assets.json has no avatar or model for it, so the token fallback showed 圣聆初雪's own face) — the marker of a
* frozen gate. */
const ICE_TOKENS = new Set(['token_10058_sbell2_icetgt']);
/**
* Units that never show an HP bar: 琳琅诗怀雅's 香槟炸弹 — PRTS 香槟炸弹 备注 "即使自身生命值未满,模型下方也不会显示生命值槽"
* (the owner's report of 2026-10-08, 「香槟会掉血,会被治疗」: the bar showed every change). Only the bar: its HP, the
* damage it takes (活性源石 hurts what stands on it) and healing are unchanged — no source makes it invulnerable.
*/
const NO_HP_BAR = new Set(['token_10031_swire2_gdtrap']);
const ICE_FRAME = 0x9fe6ff;
/** How long a view waits for its avatar before showing the image-less placeholder diamond. */
const PIC_WAIT_MS = 400;
@@ -161,6 +177,34 @@ const RAISED_Z = 0.12;
* ride the body (`hover`); range highlights are tiles.
*/
export const FLY_HOVER = 1.3;
/**
* A push / pull slide (推拉, PR #380 by @xcdoge). The sim displaces at once — server/sim/battle/displacement.js walks its
* 0.1-tile steps inside one call — and then holds the enemy in its 失衡 (UNBALANCE) state (PR #392's idea): a push for the
* 位移时间 of its 受力等级 (PRTS 游戏数据基础 推力-位移近似对应表: 0.2 … 35/30 game s), a pull for its force window (推与拉:
* 0.5 / 1 s). The snapshots only ever show the destination, so the view slides there under a constant deceleration
* (UnitView.slideTo) for that same span: the `displace` fx carries it (`dur`, game seconds) and the view divides it by the
* playback rate (ctx.animRate — 2 game s per real second by default: a 0.8 s push slides 0.4 real s) — it lands as the
* sim's state ends. v0 = 2D/T, a = v0/T then run the distance in exactly T. DISPLACE_SLIDE (real s per √tile, kept in
* 0.12–0.45 s) is only the fallback for an fx without `dur` (a recording made before 0.2.2) [ASSUMED].
*/
export const DISPLACE_SLIDE = 0.14;
/**
* Terrain friction factor of a slide: 1 = normal land, < 1 = slippery (ICE_LAND) slides longer. Every tile of this mode
* is normal land (its 11 maps have no ice or slime), so it is always 1.
*/
export const SLIDE_FRICTION = 1;
/**
* Seconds of the death fade that follows the Die clip in `dying` (die(): the clip, then this tail). A flying unit keeps
* its lift for the whole clip and drops only inside this tail (PR #380: the official client removes its fly offset in
* `CharacterAnimator.OnFinish`, when the finish state ends — docs/research/14-death-animations.md).
*/
const DIE_FADE_TAIL = 0.55;
/**
* Longest Die clip a death plays out (s): the longest enemy Die clip of data/assets.json is 盐风主教昆图斯's 7.97 s (234
* enemy Die clips; the old 1.6 s cap cut 25 of them short — the bosses' most), and the official client plays its clip
* out (PR #380). It only bounds a view whose data lies.
*/
export const DIE_CLIP_MAX = 8;
/** b.snap `down` entry states (server/sim/constants.js DOWN_STATE). */
export const DOWN_STATE = Object.freeze({ COUNTING: 0, WAIT_DP: 1, WAIT_TILE: 2 });
@@ -184,12 +228,26 @@ export const ALIAS_TINT = Object.freeze({ enemy_1305_mhslim: 0xffc48a, enemy_130
* between `pulse` and 1 at `pulseHz` (real time).
*/
export const EL_BAR = Object.freeze({ icon: 0.15, min: 8, max: 15, enemy: 0.8, gap: 1, pulse: 0.45, pulseHz: 1.5 });
/**
* The HP-bar readouts under the bars (b.snap `ammo`, `wolves`, `neg`; render/interp.js header):
* - AMMO_BAR — the ammo skill's segmented bar replaces the SP bar: a cell per round, `height` of the HP bar's thickness
* (the SP bar's is 0.6), cells drawn only while they are at least `minCell` px wide;
* - WOLF_PIPS — 伺夜's 狼影 count as `size` (tiles) wide diamonds, clamped to `min`…`max` px, `gap` px apart, `pad` px
* below the rows above; a lit diamond is a 狼影 left, a dim one a spent slot (`dim` alpha).
* [ASSUMED] the look: the count is data (skill_table `attack@trigger_time` 14 for 隐现's S2; the talent text 「至多3只」), the
* official HUD art of neither is in the game data.
*/
export const AMMO_BAR = Object.freeze({ height: 0.85, minCell: 2 });
export const WOLF_PIPS = Object.freeze({ size: 0.1, min: 5, max: 9, gap: 2, pad: 1.5, dim: 0.25 });
/**
* Enemy modes drawn with another clip set of the same skeleton (the `form` of a sim fx — shared/protocol.js fxForm —
* → UnitView.setForm), per Spine id; the mode's roles override the manifest's (data/assets.json anims), `change` plays
* once first:
* - 掠海漂移体 (PRTS: 受晕眩/沉睡/冻结影响后进入爬行模式 — for good) crawls on its *_02 clips after 'Change';
* - 假想敌:骨刺 (GitHub #296, PR #365): not an fx — its form follows the snapshot's stealth bit (STEALTH_FORMS): the
* manifest's *_A (the three-headed snake) while its 隐匿 is on, 'revealed' *_B the moment it is blocked or revealed, *_A
* again when the 隐匿 is back; no change clip, a running attack / stun pose carried over (SpineActor.syncFormPose);
* - 暴鸰 flies on its bomb-less *_2 clips once its one bomb left (the official prefab's mode S1: Move→Move_2, Idle→Idle_2,
* Die→Die_2; user feedback after 0.1.0, D4: the bomb used to stay under the drone — no change clip, the drop is its
* Attack clip);
@@ -224,6 +282,12 @@ export const EL_BAR = Object.freeze({ icon: 0.15, min: 8, max: 15, enemy: 0.8, g
* built for the knock-out — and one rebuilt while it is down — the 替身's death clip; it stands up as the 本体.
* A kind without a clip set of this skeleton (barriers, charges, …) changes nothing. 吉兆飞鳞's 晕眩模式 is its Stun clip.
*/
/**
* Enemy forms that follow the snapshot's stealth bit (UF.STEALTH: the sim sends it only while the enemy's 隐匿 is on —
* not blocked, not revealed) instead of a sim fx, per Spine id: the FORMS kind drawn while the bit is OFF; the manifest's
* clips while it is on. Switched at once (UnitView.sync, and for a model loaded later the load callback).
*/
export const STEALTH_FORMS = Object.freeze({ enemy_9008_acbunn: 'revealed' });
const loop = (name, via = null) => Object.freeze(via ? { begin: null, loop: name, end: null, via } : { begin: null, loop: name, end: null });
const clipSet = (idle, move, die, attack = null) => Object.freeze({
idle, deploy: idle, die, move: loop(move),
@@ -274,6 +338,9 @@ export const FORMS = Object.freeze({
enemy_2025_syufo: Object.freeze({
crawl: Object.freeze({ change: 'Change', roles: clipSet('Idle_02', 'Move_02', 'Die_02', 'Attack_02') }),
}),
enemy_9008_acbunn: Object.freeze({
revealed: Object.freeze({ change: null, roles: clipSet('Idle_B', 'Move_B', 'Die_B', 'Attack_B') }),
}),
enemy_10081_mpplai: Object.freeze({
translator_fuchou: Object.freeze({ change: 'A_Die_B', roles: clipSet('B_Idle', 'B_Move', 'B_Die', 'B_Attack') }),
translator_youling: Object.freeze({ change: 'A_Die_C', roles: clipSet('C_Idle', 'C_Move', 'C_Die') }),
@@ -393,6 +460,7 @@ export class UnitView {
this.tier = clamp(Number(info.tier) || 1, 1, 6);
this.x = Number(info.x) || 0; this.y = Number(info.y) || 0; this.z = 0;
this.zTarget = null; // battle: standing height the feet ease towards (tile top under the unit; 0 for enemies)
this.slide = null; // a push / pull slide (slideTo): held, then easing into the sim's position (update)
/** @type {number|null} */
this.shadowZ = null; // an enemy flyer's shadow height (the tile top under it), else null: the shadow is at z
/** @type {number|null} */
@@ -407,6 +475,9 @@ export class UnitView {
this.visFacing = this.isEnemy ? -1 : this.facing;
this.hp = Number(info.maxHp) || 1; this.maxHp = Number(info.maxHp) || 1; this.ghostHp = this.hp;
this.sp = 0; this.spMax = 0;
this.ammo = null; // [rounds left, rounds in the magazine] while an ammo skill runs (sync; b.snap `ammo`)
this.wolves = null; // [狼影 left, the talent's maximum] of a 狼群 (sync; b.snap `wolves`)
this.neg = 0; // the share of its cap a negative-HP pool holds, 0 = none (sync; b.snap `neg`, 斩业星熊's 我执)
this.flags = 0; this.anim = ANIM.IDLE;
this.statuses = new Set();
this.alive = true;
@@ -417,6 +488,8 @@ export class UnitView {
this.gameT = 0; // battle game time of the frame (render clock), for the redeploy ring's countdown and the element bar's refill
this.el = null; this.elFill = 0; this.elUntil = 0; this.elDur = 0; // shown element gauge (sync)
this._elBar = null; // { root, disc, bg, fill } sprites of the element gauge row, built on first use
this._wolfPips = null; // { root, back[], lit[] } diamonds of the 狼影 row, built on first use
this.ammoCuts = []; // the thin separators between the ammo bar's cells (_updateAmmoCuts)
this._downRing = null; // { disc, track, arc, text } sprites, built on first use
this.alpha = 1; this.fadeIn = this.prep ? 1 : 0;
this.lunge = 0; this.lungeDir = { x: 1, y: 0 };
@@ -600,6 +673,8 @@ export class UnitView {
if (this.flags & UF.SKILL) this.actor.setSkill(true);
this.actor.setBase(this._baseFromAnim());
if (deployed != null) { this.actor.deploy(); if (deployed > 0) this.actor.update(deployed); }
// a stealth-driven form (STEALTH_FORMS): the pose of the clip set in force now, even under a frozen stun
if (STEALTH_FORMS[id]) this.actor.syncFormPose();
}
}, () => {
if (req === this._spineReq) { this._spineBusy = false; this._stuckAt = 0; }
@@ -649,6 +724,8 @@ export class UnitView {
this.hpBg = bar(P, h, COLORS.hpBack, 0.85);
this.hpGhost = bar(P, h, COLORS.hpGhost, 0.9);
this.hpFill = bar(P, h, this.isEnemy ? (this.isBoss ? COLORS.hpBoss : COLORS.hpEnemy) : COLORS.hpAlly);
// a negative-HP pool (斩业星熊's 我执), drawn over the drained HP bar as the red bar
this.negFill = bar(P, h, COLORS.hpNeg);
this.shieldBar = bar(P, h, COLORS.shield, 0.95);
this.spBg = bar(P, h, COLORS.hpBack, 0.85);
this.spFill = bar(P, h, COLORS.sp);
@@ -658,7 +735,7 @@ export class UnitView {
this.spGlow.blendMode = P.BLEND_MODES.ADD;
h.addChild(this.spGlow);
// nothing shows until the first HUD update decides (a culled or prep view never draws bars)
for (const b of [this.hpBg, this.hpGhost, this.hpFill, this.shieldBar, this.spBg, this.spFill, this.spGlow]) b.visible = false;
for (const b of [this.hpBg, this.hpGhost, this.hpFill, this.negFill, this.shieldBar, this.spBg, this.spFill, this.spGlow]) b.visible = false;
this.chip = null;
// operators only: summon tokens have no tier (hand and field alike)
if (!this.isEnemy && !this.isToken && this.info.kind !== 'device') {
@@ -726,13 +803,14 @@ export class UnitView {
// the element gauge shown (b.snap `elem`): element, fill 0..1, cooldown end (game s) and length
this.el = typeof s.el === 'string' ? s.el : null;
this.elFill = this.el ? s.elFill || 0 : 0; this.elUntil = this.el ? s.elUntil || 0 : 0; this.elDur = this.el ? s.elDur || 0 : 0;
this.x = s.x; this.y = s.y;
this.followSample(s, t);
this.flying = !!(s.flags & UF.FLYING) || this.info.motion === 'FLY';
// enemies keep to the road plane: ground enemies only ever walk low tiles (a rounding step onto a block edge must not
// pop them up), and a flyer hovers FLY_HOVER above the road whatever tile it crosses — the official lift is one
// constant over the route (docs/research/12), so a block under it is no step (GitHub #277: a flyer passing over one
// high-ground / forbidden block rose and dropped like stairs). Its shadow still lies on the tile top under it.
const floor = groundZ(this.ctx, s.x, s.y);
// While sliding, the floor is read under the view's own (mid-flight) position, not the destination's.
const floor = this.slide ? groundZ(this.ctx, this.x, this.y) : groundZ(this.ctx, s.x, s.y);
const gz = this.isEnemy ? 0 : floor;
if (this.zTarget == null) this.z = gz;
this.zTarget = gz;
@@ -744,9 +822,21 @@ export class UnitView {
if (hp < this.hp - 0.5 && this.isBoss) this.shake = 0.25;
this.hp = hp;
this.sp = s.sp; this.spMax = s.spMax;
this.ammo = s.ammo || null; this.wolves = s.wolves || null; this.neg = s.neg > 0 ? Math.min(1, s.neg) : 0;
const prevFlags = this.flags;
this.flags = s.flags | 0;
this.anim = s.anim | 0;
// a stealth-driven form (STEALTH_FORMS: 假想敌:骨刺) follows this snapshot's stealth bit at once — before a DIE in the
// same snapshot, so it dies in the form it had
const stealthForm = this.isEnemy && this.alive ? STEALTH_FORMS[this.info.spine || this.info.defId] : null;
if (stealthForm) {
const form = this.flags & UF.STEALTH ? null : stealthForm;
if (form !== this.form) {
this.setForm(form);
this.actor?.syncFormPose();
if (this.imp) this.imp.dirty = true;
}
}
if (this.isEnemy && Math.abs(s.vx) > 0.08) this.visFacing = s.vx < 0 ? -1 : 1;
if ((prevFlags ^ this.flags) & UF.SKILL) this.setSkill(!!(this.flags & UF.SKILL));
if (this.anim === ANIM.DIE && this.alive) this.die();
@@ -755,6 +845,53 @@ export class UnitView {
setWorld(x, y, z = 0) { this.x = x; this.y = y; this.z = z; }
/**
* The sampled position: the view's — unless a push / pull slide owns it (slideTo), which only anchors where it eases
* to on it, from the snapshot that carries the destination on (`at`; before that the sample is still sliding or
* snapping there). sync() starts with it; a dying view gets nothing else (syncView: the DIE window of the snapshots),
* so a unit killed on the step of its push still lands where the sim put the body (Grok's review of fb7-render).
*/
followSample(s, t) {
const sl = this.slide;
if (!sl) { this.x = s.x; this.y = s.y; }
else if (!(t < sl.at)) { sl.lx = s.x; sl.ly = s.y; sl.live = true; }
}
/**
* Slide to a displacement's destination (推拉, the `displace` fx at (x, y)) instead of appearing there: `opts.dur` = the
* fx's 失衡 time in game seconds (real seconds = dur / ctx.animRate), else the DISPLACE_SLIDE·√D fallback.
* `at` (game s): the time of the snapshot that carries the destination — render/app.js starts the slide as soon as the
* interpolator shows the interval before it, and until the render clock reaches `at` the view holds where it stood
* (the sample in between is a lerp or a snap towards the destination). From then on it eases from there into the
* sampled position — the destination, and the walk the enemy resumes from it —, so it lands without a jump. Without
* `at` the destination counts as shown. A unit that dies on the way finishes the slide: its body lies where the push
* put it (the sim's position); a dead one is placed there, a knocked-down one is left to setDown.
*/
slideTo(x, y, opts = {}) {
if (!Number.isFinite(x) || !Number.isFinite(y)) return;
// already on the way there: app.js starts the slide ahead of the event, then the event itself asks again
if (this.slide && this.slide.x1 === x && this.slide.y1 === y) return;
if (this.down) { this.slide = null; return; }
const dx = x - this.x, dy = y - this.y;
const D = Math.hypot(dx, dy);
if (!this.alive || !(D > 0.05)) { this.x = x; this.y = y; this.slide = null; return; }
const f = Number.isFinite(opts.friction) && opts.friction > 0 ? opts.friction : SLIDE_FRICTION;
// the sim's 失衡 time (game s) at the playback rate; a slippery tile (f < 1) slides longer
const rate = this.ctx.animRate?.() || 1;
const T = Math.max(0.05, (opts.dur > 0 ? opts.dur / rate : clamp(DISPLACE_SLIDE * Math.sqrt(D), 0.12, 0.45)) / f);
// v0 = 2D/T and a = v0/T run the distance in exactly T with the velocity reaching 0 there, integrated in update().
// The official turns a displaced unit towards the force (its _dontChangeFaceByDirection is an opt-in flag), while
// our facing comes from the snapshot's vx — which points back down the route the enemy resumes after the
// displacement, i.e. against the push. The slide therefore owns the facing until it lands.
if (this.isEnemy) this.visFacing = dx < 0 ? -1 : 1;
const v0 = 2 * D / T;
const at = Number.isFinite(opts.at) ? opts.at : -Infinity;
this.slide = {
x1: x, y1: y, sx: this.x, sy: this.y, ux: dx / D, uy: dy / D, D, done: 0, v: v0, a: v0 / T, t: 0, dur: T,
at, live: at === -Infinity, lx: x, ly: y, ox: 0, oy: 0, run: false, wait: 0,
};
}
setFacing(f) {
if (this.isEnemy) { this.facing = f === -1 ? -1 : 1; return; }
// the legacy ±1 only speaks about left / right: an UP / DOWN operator keeps its direction
@@ -909,7 +1046,7 @@ export class UnitView {
if (this.actor && !d) d = this._fallDur();
const rate = this.ctx.animRate?.() || 1;
this.dieT = 0;
this.dying = clamp(d / rate, 0.35, 1.6) + 0.55;
this.dying = clamp(d / rate, 0.35, DIE_CLIP_MAX) + DIE_FADE_TAIL;
this.dieDur = this.dying;
if (instant) { this.dieT = 30; if (this.actor) this.actor.update(30); }
}
@@ -948,7 +1085,7 @@ export class UnitView {
if (!d) {
if (!this.down) return;
this.down = null;
if (!this.alive) { this.dying = 0.55; this.dieDur = 0.55; }
if (!this.alive) { this.dying = DIE_FADE_TAIL; this.dieDur = DIE_FADE_TAIL; }
return;
}
if (this.alive) this.die(instant);
@@ -969,6 +1106,32 @@ export class UnitView {
/** @param {number} dt real seconds @param {any} cam camera @param {number} t real clock */
update(dt, cam, t) {
if (this.destroyed) return;
// a displacement's slide (slideTo): held where it stood until the snapshot with the destination is shown, then
// eased into the sampled position (sync keeps `lx, ly` current) under a constant deceleration
if (this.slide) {
const sl = this.slide;
if (this.down) this.slide = null; // a knocked-down view lies on its tile (setDown)
else if (!sl.live) {
this.x = sl.sx; this.y = sl.sy;
// the destination's snapshot never shown (the unit left the snapshots, a stalled stream): it slides to the
// destination the fx named
if ((sl.wait += dt) > 0.5) sl.live = true;
} else {
if (!sl.run) { sl.run = true; sl.ox = sl.sx - sl.lx; sl.oy = sl.sy - sl.ly; }
const step = Math.min(dt, 0.1); // a hidden tab's catch-up frame must not overshoot
const v0 = sl.v;
sl.v = Math.max(0, sl.v - sl.a * step); // friction: the velocity decays every frame
// the step's mean velocity — exact under a constant deceleration (the end-of-step speed fell ~D·dt/T short:
// 9 % of a 0.18 s slide at 60 fps, then a jump onto the end)
sl.done = Math.min(sl.D, sl.done + (v0 + sl.v) / 2 * step);
sl.t += step;
if (this.isEnemy) this.visFacing = sl.ux < 0 ? -1 : 1;
const k = 1 - sl.done / sl.D; // the share of the way still to go
this.x = sl.lx + sl.ox * k; this.y = sl.ly + sl.oy * k;
// the clock or the spent velocity lands it on the sampled position
if (sl.t >= sl.dur - 1e-9 || sl.done >= sl.D - 1e-9 || sl.v <= 1e-9) { this.x = sl.lx; this.y = sl.ly; this.slide = null; }
}
}
const P = this.P;
// the model for the state the frame's events and snapshot left (Front ⇄ Back when it went down / stood up: die, revive)
if (this._modelDirty) { this._modelDirty = false; this._syncModel(); }
@@ -987,7 +1150,9 @@ export class UnitView {
const d = this.shadowZTarget - this.shadowZ;
this.shadowZ = Math.abs(d) < 1e-3 ? this.shadowZTarget : this.shadowZ + d * Math.min(1, dt * 12);
}
const hoverTo = this.flying && this.alive ? FLY_HOVER : 0;
// a flyer keeps its lift through the Die clip and drops only as it fades out: the client removes the fly offset in
// CharacterAnimator.OnFinish, when the finish state ends; a knocked-down flyer lies on the ground
const hoverTo = this.flying && (this.alive || (this.dying > DIE_FADE_TAIL && !this.down)) ? FLY_HOVER : 0;
if (this.hover !== hoverTo) this.hover = Math.abs(hoverTo - this.hover) < 1e-3 ? hoverTo : this.hover + (hoverTo - this.hover) * Math.min(1, dt * 6);
const p = cam.project(this.x, this.y, this.z + this.hover + this.lift, this.screen);
const s = p.s;
@@ -1001,7 +1166,7 @@ export class UnitView {
} else if (this.dying > 0) {
this.dieT += dt;
this.dying -= dt;
const tail = 0.55;
const tail = DIE_FADE_TAIL;
if (this.dying < tail) alpha *= Math.max(0, this.dying / tail);
if (this.dying <= 0) { this.dying = 0; this.remove = true; alpha = 0; }
}
@@ -1153,7 +1318,7 @@ export class UnitView {
const prep = this.prep;
const showBars = !prep && this.alive && this.info.kind !== 'item';
const damaged = this.hp < this.maxHp - 0.5;
const showHp = showBars && (!this.isEnemy || damaged || this.isBoss);
const showHp = showBars && (!this.isEnemy || damaged || this.isBoss) && !NO_HP_BAR.has(this.info.defId);
const bw = clamp(s * (this.isBoss ? UNIT.bossBarWidth : UNIT.barWidth), 24, this.isBoss ? 260 : 96);
const bh = clamp(s * (this.isBoss ? 0.12 : 0.075), 3, this.isBoss ? 12 : 7);
// a knocked-down operator's HUD is its redeploy ring alone, drawn at full strength over the greyed model
@@ -1174,24 +1339,32 @@ export class UnitView {
this.hpFill.position.set(x0, cy); this.hpFill.width = bw * k; this.hpFill.height = bh;
if (!this.isEnemy) this.hpFill.tint = k < 0.3 ? COLORS.hpAllyLow : COLORS.hpAlly;
}
// 业火 我执: her HP sits on the 1-HP floor and the damage past it fills a pool — the red bar over the drained green one
const neg = showHp ? this.neg : 0;
this.negFill.visible = neg > 0;
if (neg > 0) { this.negFill.position.set(x0, cy); this.negFill.width = bw * neg; this.negFill.height = bh; }
const shielded = showHp && (this.flags & UF.SHIELD);
this.shieldBar.visible = !!shielded;
if (shielded) { this.shieldBar.position.set(x0, cy - bh / 2 - 1); this.shieldBar.width = bw; this.shieldBar.height = Math.max(1.5, bh * 0.35); }
// SP
const showSp = showBars && !this.isEnemy && this.spMax > 0;
// SP — or, while an ammo skill runs, its magazine: yellow cells, one per round, emptying from the right (b.snap `ammo`;
// the SP bar's own fraction, ammoLeft / ammoMax, is the same width). Without an `ammo` row the bar is the plain SP bar.
const ammo = !this.isEnemy ? this.ammo : null;
const showSp = showBars && !this.isEnemy && (this.spMax > 0 || !!ammo);
this.spBg.visible = this.spFill.visible = showSp;
const spH = Math.max(2, bh * 0.6);
const spH = Math.max(2, bh * (ammo ? AMMO_BAR.height : 0.6));
let ready = false;
let sy = 0;
if (showSp) {
const active = !!(this.flags & UF.SKILL);
const k = clamp(this.sp / this.spMax, 0, 1);
const k = ammo ? ammo[0] / ammo[1] : clamp(this.sp / this.spMax, 0, 1);
ready = !active && k >= 0.999;
const sy = cy + bh / 2 + spH / 2 + 1.5;
sy = cy + bh / 2 + spH / 2 + 1.5;
this.spBg.position.set(x0 - 1, sy); this.spBg.width = bw + 2; this.spBg.height = spH + 2;
this.spFill.position.set(x0, sy); this.spFill.width = bw * k; this.spFill.height = spH;
this.spFill.tint = active ? COLORS.spActive : ready ? COLORS.spReady : COLORS.sp;
this.spFill.tint = ammo ? COLORS.ammo : active ? COLORS.spActive : ready ? COLORS.spReady : COLORS.sp;
this._spY = sy;
}
this._updateAmmoCuts(showSp && !!ammo, ammo, x0, sy, bw, spH);
this.spGlow.visible = ready;
if (ready) {
const pulse = 0.55 + 0.35 * Math.sin(t * 6);
@@ -1222,8 +1395,11 @@ export class UnitView {
ic.width = ic.height = isz;
ic.position.set(x - ((icons.length - 1) * (isz + 2)) / 2 + i * (isz + 2), iy);
}
// element gauge row under the bars (b.snap `elem`); redeploy ring above a knocked-down operator (b.snap `down`)
this._updateElementBar(showBars && !!this.el, x0, bw, cy + bh / 2 + (showSp ? spH + 1.5 : 0) + 1, spH, s, t);
// 狼影 pips under the bars (b.snap `wolves`), then the element gauge row under them (b.snap `elem`); redeploy ring above a
// knocked-down operator (b.snap `down`)
const rowsTop = cy + bh / 2 + (showSp ? spH + 1.5 : 0);
const wolfH = this._updateWolfPips(showBars && !this.isEnemy && !!this.wolves, x0, rowsTop, s);
this._updateElementBar(showBars && !!this.el, x0, bw, rowsTop + wolfH + 1, spH, s, t);
this._updateDownRing(!prep && !!this.down && !this.alive, x, this.screen.y - DOWN_LOOK.height * s, s, t);
// blocked marker at the feet (enemies held by a blocker)
const blocked = !prep && this.alive && this.isEnemy && (this.flags & UF.BLOCKED);
@@ -1248,6 +1424,62 @@ export class UnitView {
}
}
/**
* The thin dark separators between the ammo bar's cells (see _updateHud): one at every round boundary of the `n`-round
* magazine across the bar `x0` … `x0 + bw` at height `y`, `h` tall; none while a cell would be under AMMO_BAR.minCell px
* (the fill keeps its exact width). Sprites are built as the magazines need them and hidden when not in use.
*/
_updateAmmoCuts(show, ammo, x0, y, bw, h) {
const cuts = this.ammoCuts;
const n = show ? ammo[1] : 0;
const step = n > 1 ? bw / n : 0;
const want = step >= AMMO_BAR.minCell ? n - 1 : 0;
while (cuts.length < want) cuts.push(bar(this.P, this.hud, COLORS.hpBack, 0.9));
for (let i = 0; i < cuts.length; i++) {
const c = cuts[i];
c.visible = i < want;
if (c.visible) { c.position.set(x0 + (i + 1) * step - 0.5, y); c.width = 1; c.height = h; }
}
}
/**
* 伺夜's 狼群 count (b.snap `wolves`, [left, maximum]): a row of diamonds from `x0`, its top at `top`, one per 狼影 the
* talent allows, the first `left` of them lit. Built on the first count, hidden without one. Returns the height the row
* takes (0 while hidden) so the element gauge row can sit below it.
*/
_updateWolfPips(show, x0, top, s) {
let r = this._wolfPips;
if (!show) { if (r) r.root.visible = false; return 0; }
const P = this.P;
const [left, max] = this.wolves;
if (!r) {
r = this._wolfPips = { root: new P.Container(), back: [], lit: [] };
this.hud.addChild(r.root);
}
const mk = (list, color, alpha) => {
const d = new P.Sprite(white());
d.anchor.set(0.5); d.rotation = Math.PI / 4; d.tint = color; d.alpha = alpha;
r.root.addChild(d);
list.push(d);
};
while (r.lit.length < max) { mk(r.back, COLORS.hpBack, 0.85); mk(r.lit, COLORS.wolf, 1); }
const d = clamp(s * WOLF_PIPS.size, WOLF_PIPS.min, WOLF_PIPS.max); // a diamond's diagonal
const side = d / Math.SQRT2;
const cy = top + WOLF_PIPS.pad + d / 2 + 1;
for (let i = 0; i < r.lit.length; i++) {
const on = i < max;
const b = r.back[i], l = r.lit[i];
b.visible = l.visible = on;
if (!on) continue;
const cx = x0 + d / 2 + 1 + i * (d + 2 + WOLF_PIPS.gap);
b.position.set(cx, cy); b.width = b.height = side + 2;
l.position.set(cx, cy); l.width = l.height = side;
l.alpha = i < left ? 1 : WOLF_PIPS.dim;
}
r.root.visible = true;
return d + 2 + WOLF_PIPS.pad;
}
/** True while the shown gauge is in its 爆发冷却 (b.snap `elem` carries the cooldown's end and length). */
elementCooling() {
return !!this.el && this.elDur > 0 && this.elUntil > 0;
@@ -1371,16 +1603,22 @@ export class UnitView {
if (this.actor.spine.parent) this.actor.spine.parent.removeChild(this.actor.spine);
if (atlas) atlas.park(this.actor.spine);
this.body.addChild(sprite);
this.imp = { sprite, slot: null, rt: null, sc: 0, acc: 0, phase: (Math.random() * 64) | 0, dirty: true };
this.imp = { sprite, slot: null, rt: null, sc: 0, acc: 0, phase: (Math.random() * 64) | 0, dirty: true, last: 0 };
}
const imp = this.imp;
imp.acc += animDt;
const frame = this.ctx.frameNo ? this.ctx.frameNo() : 0;
const due = imp.dirty || interval <= 1 || (frame + imp.phase) % interval === 0 || Math.abs(sc - imp.sc) > imp.sc * 0.12;
// the view's slot in this frame's update order (app.js impostorSlot) spreads the refreshes evenly over the interval;
// a context without slots keeps the random phase. A unit whose slot keeps moving with the frame (the units before it
// culled on and off in step) is still refreshed after 2 intervals at the latest.
const turn = this.ctx.impostorSlot ? this.ctx.impostorSlot() : imp.phase;
const due = imp.dirty || interval <= 1 || (frame + turn) % interval === 0 || frame - imp.last >= 2 * interval
|| Math.abs(sc - imp.sc) > imp.sc * 0.12;
if (due) {
this.actor.update(imp.acc);
imp.acc = 0;
imp.dirty = false;
imp.last = frame;
this._renderImpostor(sc, atlas);
}
const k = imp.sc > 0 ? sc / imp.sc : 1;
+94
View File
@@ -0,0 +1,94 @@
// 潜能 / 练度 controls of the 干员调配 overlay (0.2.2, the owner's decision of 2026-10-08: 「调配干员里自己设置吧,默认满潜
// 满加成」): per operator (by charId — the normal, elite and 自选 forms share them), 潜能 1–6 and the 自持有 练度 tier
// (effects.json CHAR_MAP aceffect_char_1…4: 未精英化 / 精英阶段1 / 精英阶段2 / 精英阶段2-60级 — their official names and
// texts, localized by the data overlay), default 潜能 6 / 精英阶段2-60级. Official: 调度手册 「卫戍协议中干员潜能由自身已
// 持有干员潜能决定」 and 「提升已持有的干员,将为卫戍协议中同名的干员提供加成」 (an operator the player does not own fights
// at 潜能 1 with no bonus: set by hand for a 特许 one; a NORMAL one not owned is its stand-in, which has neither).
// CultivationSelects: two compact selects (a roster row, a 自选 slot card); CultivationSection: the detail's segmented
// controls with the tier's effect. Hook-free views (the tests draw them). The settings live in ui/loadoutSync.js
// (`ops`, synced with room.loadout); the model is ui/loadoutModel.js opsOf / setOps.
import { html, MicroLabel } from '../ui/components.js';
import { data } from '../data.js';
import { opsOf } from '../ui/loadoutModel.js';
import { CULTIVATE_EFFECTS, POTENTIAL_MIN, POTENTIAL_MAX } from '../../../shared/potential.js';
import { t, N_ } from '../../../shared/i18n.js';
const cx = (...p) => p.flat().filter(Boolean).join(' ');
const POTENTIALS = Array.from({ length: POTENTIAL_MAX - POTENTIAL_MIN + 1 }, (_, i) => POTENTIAL_MIN + i);
const TIERS = CULTIVATE_EFFECTS.map((_, i) => i);
/** Names while effects.json is not loaded (msgids; the data's own names win). */
const FALLBACK_NAMES = Object.freeze([N_('未精英化'), N_('精英阶段1'), N_('精英阶段2'), N_('精英阶段2-60级')]);
/** The short names a row's select shows (the full official name is its title and the detail's). */
const SHORT_NAMES = Object.freeze([N_('未精英化'), N_('精英1'), N_('精英2'), N_('精英2 Lv.60')]);
/** The official name of 练度 tier `n` (effects.json `name`, localized), else its fallback. */
export function cultivateName(n) {
return data.lookup('effects', CULTIVATE_EFFECTS[n])?.name || t(FALLBACK_NAMES[n] || '');
}
/** The official effect text of 练度 tier `n` (effects.json `desc`: 「攻击力、防御力和最大生命值+10%」 …), or ''. */
export function cultivateDesc(n) {
return data.lookup('effects', CULTIVATE_EFFECTS[n])?.desc || '';
}
/**
* Two compact selects — 潜能 and 练度 — of operator `charId` under the stored settings `ops` (a roster row, a 自选 slot).
* @param {{ charId: string, ops: Record<string, any>, onSet: (charId: string, patch: { potential?: number, cultivate?: number }) => void,
* note?: string|null }} props `note`: why the settings do not apply now (a 补位 stand-in), shown as the tooltip
*/
export function CultivationSelects({ charId, ops, onSet, note = null }) {
if (!charId) return null;
const cur = opsOf(ops, charId);
const set = (patch) => onSet(charId, patch);
// a setting off the default stands out (mint); the default stays quiet — it is the same on every row
return html`<span class=${cx('lo-cult', cur.changed && 'is-changed', note && 'is-moot')} data-char=${charId} title=${note || undefined}>
<span class=${cx('lo-select', 'lo-cult__sel', 'lo-cult__pot', cur.potential !== 6 && 'is-off')}>
<select value=${String(cur.potential)} aria-label=${t('潜能')} data-cult="potential"
onChange=${(e) => set({ potential: Number(e.currentTarget.value) })}>
${POTENTIALS.map((n) => html`<option key=${n} value=${String(n)}>${t('潜能 {n}', { n })}</option>`)}
</select>
</span>
<span class=${cx('lo-select', 'lo-cult__sel', 'lo-cult__tier', cur.cultivate !== TIERS.length - 1 && 'is-off')} title=${note ? undefined : cultivateName(cur.cultivate)}>
<select value=${String(cur.cultivate)} aria-label=${t('练度')} data-cult="cultivate"
onChange=${(e) => set({ cultivate: Number(e.currentTarget.value) })}>
${TIERS.map((n) => html`<option key=${n} value=${String(n)}>${t(SHORT_NAMES[n])}</option>`)}
</select>
</span>
</span>`;
}
/**
* The detail's 潜能 / 练度 section: 潜能 1–6 and the four 练度 tiers as segmented buttons, the tier's official effect,
* the rule in one line, and — for a chess the player marked not owned — that its stand-in has neither.
* @param {{ charId: string, ops: Record<string, any>, onSet: (charId: string, patch: object) => void, standIn?: boolean }} props
*/
export function CultivationSection({ charId, ops, onSet, standIn = false }) {
if (!charId) return null;
const cur = opsOf(ops, charId);
const set = (patch) => onSet(charId, patch);
return html`<section class=${cx('lo-sec', 'lo-sec--cult', standIn && 'is-moot')} aria-label=${t('潜能与练度')} data-char=${charId}>
<header class="lo-sec__head">
<h3>${t('潜能与练度')}<${MicroLabel}>POTENTIAL<//></h3>
${cur.changed ? html`<span class="lo-badge lo-badge--changed">${t('已调整')}</span>` : html`<span class="lo-badge lo-badge--plain">${t('满潜能 · 精英2 Lv.60')}</span>`}
</header>
<div class="lo-cult__row">
<span class="lo-cult__k">${t('潜能')}</span>
<div class="lo-seg lo-seg--pot" role="radiogroup" aria-label=${t('潜能')}>
${POTENTIALS.map((n) => html`<button key=${n} type="button" role="radio" aria-checked=${cur.potential === n ? 'true' : 'false'}
class=${cx(cur.potential === n && 'is-on')} data-potential=${n} onClick=${() => set({ potential: n })}><span class="num">${n}</span></button>`)}
</div>
</div>
<div class="lo-cult__row">
<span class="lo-cult__k">${t('练度')}</span>
<div class="lo-seg lo-seg--tier" role="radiogroup" aria-label=${t('练度')}>
${TIERS.map((n) => html`<button key=${n} type="button" role="radio" aria-checked=${cur.cultivate === n ? 'true' : 'false'}
class=${cx(cur.cultivate === n && 'is-on')} data-cultivate=${n} onClick=${() => set({ cultivate: n })}>${cultivateName(n)}</button>`)}
</div>
</div>
<p class="lo-cult__eff" data-cultivate=${cur.cultivate}><b>${cultivateName(cur.cultivate)}</b> <span>${cultivateDesc(cur.cultivate)}</span></p>
<p class="lo-cult__note">${standIn
? t('干员持有中标记为未持有:替补干员没有潜能与练度;这里的设置在改回「持有」后生效')
: t('默认满潜能、精英2 Lv.60(满加成)。官方的潜能取你自己的潜能,练度是自持有加成(已持有的同名干员的养成,攻击 / 防御 / 生命单独乘算);未持有的特许干员在官方按潜能1、没有加成,要照官方打请手动设为潜能1、未精英化。')}</p>
</section>`;
}
+21 -10
View File
@@ -6,15 +6,17 @@
// 原型 / 已持有 tag —, an operator held by another slot greyed out with where it is. In the match the operator is sold
// only in this player's shop, from 调度中心 level 5 (tier 5) / 6 (tier 6), and fights with the pick (server/match/player
// /diy.js). Out of match: the next match takes the picks. The picks live in ui/loadoutSync.js (localStorage + room.diy);
// the model is ui/diyModel.js. Styles: css/screens/loadout.css (diy-*).
// the model is ui/diyModel.js. Styles: css/screens/loadout.css (diy-*). An owned pick's card carries its operator's 潜能 /
// 练度 selects (0.2.2, screens/cultivation.js — the same per-operator settings as 干员调配, `ops`); a prototype has neither.
import { useEffect, useState } from '../../vendor/hooks.module.js';
import { useLayoutEffect, useState } from '../../vendor/hooks.module.js';
import { html, Icon, Button, TierChip } from '../ui/components.js';
import { Img, RichText, BondGlyph } from '../ui/gameComponents.js';
import { chessAvatarUrl, chessPortraitUrl, profIconUrl, skillRecordIconUrl, moduleTypeIconUrl } from '../ui/assetUrls.js';
import { data } from '../data.js';
import { PROF_NAME, skillLabel, moduleBadge, fullTraitText } from '../ui/loadoutModel.js';
import { diySlotList, pickChoices, pickOptions, slotRecord, defaultPick } from '../ui/diyModel.js';
import { CultivationSelects } from './cultivation.js';
import { t } from '../../../shared/i18n.js';
const cx = (...p) => p.flat().filter(Boolean).join(' ');
@@ -38,8 +40,8 @@ function Bonds({ bonds }) {
return html`<span class="diy-bonds">${(bonds || []).map((b) => html`<span key=${b} class="diy-bond"><${BondGlyph} bondId=${b} /><span>${bondName(b)}</span></span>`)}</span>`;
}
/** One slot: the pick (operator, class, bonds, skill, module) or an empty slot to fill. */
function SlotCard({ m, slot, pick, illegal, onOpen, onClear }) {
/** One slot: the pick (operator, class, bonds, skill, module, an owned pick's 潜能 / 练度) or an empty slot to fill. */
function SlotCard({ m, slot, pick, illegal, onOpen, onClear, ops = {}, onOps = null }) {
const D = diyData();
const rec = pick ? slotRecord(slot.slotId, pick, D) : null;
const elite = pick ? slotRecord(slot.slotId, pick, D, { elite: true }) : null;
@@ -73,6 +75,8 @@ function SlotCard({ m, slot, pick, illegal, onOpen, onClear }) {
${mod ? html`<${Img} src=${moduleTypeIconUrl(data.get('local'), mod.type)} class="diy-slot__micon" fallback=${html`<b class="num">${moduleBadge({ typeName: mod.type })}</b>`} />` : html`<span class="diy-slot__micon diy-slot__micon--none">—</span>`}
<span>${mod ? html`<b class="num">${mod.type || ''}</b> ${mod.name || ''}` : t('不装备模组')} <small class="t-dim">${t('(精锐时生效)')}</small></span>
</span>
${proto ? html`<small class="diy-slot__cult t-dim">${t('原型干员没有潜能与练度')}</small>`
: onOps ? html`<span class="diy-slot__cult"><${CultivationSelects} charId=${rec.charId} ops=${ops} onSet=${onOps} /></span>` : null}
${illegal ? html`<p class="diy-slot__bad"><${Icon} name="warn" />${t('这项自选在当前版本不可用,开局时会被移除')}</p>` : null}
</div>
<div class="diy-slot__acts">
@@ -142,8 +146,10 @@ export function DiyPicker(props) {
const [query, setQuery] = useState('');
const [draft, setDraft] = useState(cur ? { ...cur } : null);
// Esc cancels only the picker, like its 取消 (GitHub #284, idea from PR #286); the 干员调配 overlay's own Esc skips
// while a picker is open, and a dialog over the picker (导入) still takes Esc first
useEffect(() => {
// while a picker is open, and a dialog over the picker (导入) still takes Esc first. A layout effect: the listener is
// attached in the same commit as the picker (a plain effect waits for the next frame, and an Esc pressed in between
// was swallowed — the overlay skipped it, the picker did not hear it yet; the 0.2.2 full browser pass)
useLayoutEffect(() => {
const onKey = (e) => {
if (e.key !== 'Escape' || e.ctrlKey || e.metaKey || e.altKey || document.querySelector('.modal')) return;
e.preventDefault();
@@ -168,7 +174,10 @@ export function DiyPickerView({ m, slot, picks, kitted, onDone, onClose, filter,
const list = options.filter((o) => (filter === 'all' || (filter === 'proto') === o.proto)
&& (!q || [o.unit?.name, o.unit?.appellation, o.unit?.subProfessionName, t(PROF_NAME[o.unit?.profession] || ''), ...o.bonds.map(bondName)].some((x) => typeof x === 'string' && x.toLowerCase().includes(q))));
const ch = draft ? pickChoices(draft.charId, slot.slotId, D) : null;
const sel = (charId) => setDraft(cur && cur.charId === charId ? { ...cur } : defaultPick(charId, slot.slotId, D));
const sel = (charId) => {
if (draft?.charId === charId) return;
setDraft(cur && cur.charId === charId ? { ...cur } : defaultPick(charId, slot.slotId, D));
};
const lk = ch?.locked || null;
const skillOn = ch ? (ch.proto ? lk?.skillIndex : draft.skillIndex) : null;
const modOn = ch ? (ch.proto ? lk?.uniEquipId ?? null : draft.uniEquipId ?? null) : null;
@@ -226,7 +235,9 @@ export function DiyPickerView({ m, slot, picks, kitted, onDone, onClose, filter,
/**
* The tab's body: the four slots by tier, and the picker of the slot being filled (`picking`, kept here).
* @param {{ m: any, picks: Record<string, any>, legal: Record<string, any>, kitted: string[]|null,
* onSet: (slotId: string, pick: any) => void }} props
* onSet: (slotId: string, pick: any) => void, ops?: Record<string, any>,
* onOps?: ((charId: string, patch: { potential?: number, cultivate?: number }) => void) | null }} props
* `ops` / `onOps`: the stored 潜能 / 练度 and their setter (0.2.2 — an owned pick's card shows its selects)
*/
export function DiyPanel(props) {
const [picking, setPicking] = useState(null);
@@ -234,7 +245,7 @@ export function DiyPanel(props) {
}
/** The tab's view (no hooks: the tests draw it): `picking` = the slot being filled, or null. */
export function DiyPanelView({ m, picks, legal, kitted, onSet, picking, onPicking: setPicking }) {
export function DiyPanelView({ m, picks, legal, kitted, onSet, picking, onPicking: setPicking, ops = {}, onOps = null }) {
const slots = diySlotList(diyData());
const slot = picking ? slots.find((s) => s.slotId === picking) : null;
const tiers = [...new Set(slots.map((s) => s.tier))];
@@ -247,7 +258,7 @@ export function DiyPanelView({ m, picks, legal, kitted, onSet, picking, onPickin
<h3 class="own-tier__head"><${TierChip} tier=${tier} size="sm" /><span class="num">${ROMAN[tier]}</span><span>${t('阶')}</span></h3>
<div class="diy-grid">
${slots.filter((s) => s.tier === tier).map((s) => html`<${SlotCard} key=${s.slotId} m=${m} slot=${s} pick=${picks[s.slotId] || null}
illegal=${!!picks[s.slotId] && !legal[s.slotId]} onOpen=${setPicking} onClear=${(id) => onSet(id, null)} />`)}
illegal=${!!picks[s.slotId] && !legal[s.slotId]} onOpen=${setPicking} onClear=${(id) => onSet(id, null)} ops=${ops} onOps=${onOps} />`)}
</div>
</section>`)}
</div>
+122 -29
View File
@@ -95,11 +95,11 @@ import { pieceTile } from '../render/drag.js';
import {
phaseMode, phaseBanner, isCombatPhase, showDeadPill, isBossPhase, placementContext, canPlace, boardTargets, dropIntent,
battleOverSfx, uniteResultBox, battleResultBox,
snapHud, activeBubbles, shortcutFor, shortcutBlocked, closesOnFieldPress, phaseTotalSeconds, homeFieldId, ownFieldId, normalizeSp, sortedPlayers,
terrainInfo,
snapHud, activeBubbles, shortcutFor, shortcutBlocked, closesOnFieldPress, phaseTotalSeconds, homeFieldId, ownFieldId, normalizeSp, normalizePersonalChoice, sortedPlayers,
terrainInfo, deviceInfo, deviceTipAt, noteDeviceUnits,
countdownState, shopBlockReason, stageOverrides, effectiveStage, watchTarget, dropFailureReason,
previewEnemyKey, prepCamera, prepCameraFor, foldCamera, deployFieldOf, fieldTile, panelSide, panelSlots, bondPopupPlace, unitLoadout, deployedRecord,
mergeTarget, modeOffBonds, readyFundsPrompt, ownerBandId, ownDiyRecord, ownStandIn,
mergeTarget, modeOffBonds, readyFundsPrompt, readyShopFold, ownerBandId, ownDiyRecord, ownStandIn,
} from '../ui/gameLogic.js';
import { toast } from '../ui/toasts.js';
import { BriefingScreen } from './briefing.js';
@@ -115,8 +115,9 @@ import { audio, resultSpeaker, resultVoiceSlot } from '../audio.js';
import { useDocClass, FullscreenButton } from '../ui/device.js';
// MatchEnded, PausedOverlay, the highlight styles and keepEarly live in ./game/*.js.
import { HUD_HZ_MS, MERGE_HL, SEL_RANGE, cx } from './game/marks.js';
import { keepEarly } from './game/early.js';
import { keepEarly, audioEarly } from './game/early.js';
import { MatchEnded, PausedOverlay } from './game/overlays.js';
import { inspectRange } from './game/range.js';
import { t, tParts } from '../../../shared/i18n.js';
/** Router for the in-match screens. */
@@ -146,6 +147,9 @@ export function GameScreen() {
// ---- match screen ----------------------------------------------------------------------------------------
/** A card of a unit of the battle on screen: a unit's, or a crate's / turret's (a device card with its `unitId`, #228). */
const inBattleCard = (d) => d?.kind === 'unit' || (d?.kind === 'device' && d.device?.unitId != null);
function MatchScreen() {
useDocClass('sp-in-match');
const pub = useStore((s) => s.match.public);
@@ -156,6 +160,12 @@ function MatchScreen() {
const emotes = useStore((s) => s.emotes);
const roomSolo = useStore((s) => s.room?.mode === 'solo');
const spectator = useStore((s) => isSpectating(s.room, s.me.playerId));
// the room's spectator seats for the top bar's 观战席 capsule (ui/hud.js SpectatorPill; GitHub #120): room.state keeps coming
// during the match, the list is replaced only when a seat changes — shallowEqual keeps an unrelated push from re-rendering
const specFacts = useStore((s) => ({
list: Array.isArray(s.room?.spectators) ? s.room.spectators : null,
isHost: !!s.room && s.room.hostId === s.me.playerId,
}), shallowEqual);
const gd = useGameData();
const hostRef = useRef(null);
@@ -169,6 +179,9 @@ function MatchScreen() {
const [bondOpen, setBondOpen] = useState(null); // { id, ownerId, from }: the bond popup and whose bond it shows
const [bondsCollapsed, setBondsCollapsed] = useState(false);
const [detail, setDetail] = useState(null); // detail target
// every shop / reward card opened by a tap is a new tap — `tap` in the target: two cards of one operator (the pool
// deals duplicates) carry the same chess id and no piece, and each must say its 选中干员 (detailPanel selectVoiceKey)
const cardTap = useRef(0);
const [collapsed, setCollapsed] = useState(false);
const [rewardMin, setRewardMin] = useState(false);
const [emoteOpen, setEmoteOpen] = useState(false);
@@ -184,6 +197,7 @@ function MatchScreen() {
const [resultBox, setResultBox] = useState(null); // the round's result box (ResultDialog), shown at SETTLE
const [readyBusy, setReadyBusy] = useState(false);
const [spBusy, setSpBusy] = useState(null);
const [personalBusy, setPersonalBusy] = useState(null); // { choiceId, idx }: an older request cannot clear a newer pick
const [layer, setLayer] = useState('ALL'); // 联防 / 最终攻势 camera: 'L' | 'ALL' | 'R'
const [pen, setPen] = useState(false); // the camera shows the enemy preview pen (research 09 §2)
const [camKind, setCamKind] = useState('prep'); // kind of the last camera request (data-camera)
@@ -207,7 +221,9 @@ function MatchScreen() {
const scoutPid = watchingOther && field?.prep && typeof field.fieldId === 'string' && field.fieldId.startsWith('n:') ? field.fieldId.slice(2) : null;
const scoutBandId = scoutPid ? ownerBandId(pub, scoutPid) : null;
const scoutBandOwner = scoutPid ? (players.find((p) => p.playerId === scoutPid)?.name || null) : null;
const editable = phase === PHASE.PREP && !!priv && alive && !priv.ready && !watchingOther;
const personalChoice = !spectator && alive ? normalizePersonalChoice(pub, priv, myId) : null;
const hasPersonalChoice = !!personalChoice;
const editable = phase === PHASE.PREP && !!priv && alive && !priv.ready && !watchingOther && !hasPersonalChoice;
const showShop = !!priv && alive && (phase === PHASE.PREP || phase === PHASE.SP_DRAFT || phase === PHASE.ROUND_START) && !watchingOther;
const layersDisabled = phase === PHASE.UNITE || isBossPhase(phase);
const sp = phase === PHASE.SP_DRAFT ? normalizeSp(pub?.sp, players) : null;
@@ -271,7 +287,7 @@ function MatchScreen() {
getChess: (id) => { const c = gd.chess(id); return ownDiyRecord(c, priv, { chess: data.get('chess'), backups: data.get('backups') }) || c; },
getToken: gd.token, getItem: gd.item, getEffect: gd.effect, backups: gd.backups,
}), [priv, pub?.stageId, editable, gd.ready, deployField]);
live.current = { pub, priv, field, editable, placeCtx, watching, watchWho, home, myId, alive, spectator, detail, drawer, bondOpen, emoteOpen, settingsOpen, exitOpen, drag, facing, sel, selBusy, pen, collapsedNow: collapsed, localDone: false, canPause: false, paused };
live.current = { pub, priv, field, editable, hasPersonalChoice, placeCtx, watching, watchWho, home, myId, alive, spectator, detail, drawer, bondOpen, emoteOpen, settingsOpen, exitOpen, drag, facing, sel, selBusy, pen, collapsedNow: collapsed, localDone: false, canPause: false, paused };
// ---- camera: every request goes through setCam, which remembers it for the pen's way back -----------------------
// the own prep board: the normal board, or — in the prep of a boss round — the player's half of the boss field
@@ -286,6 +302,18 @@ function MatchScreen() {
// the shop bar is shown folded (收起; the pen folds it for itself: the player's own state is the one it returns to) —
// the own prep board then takes the official shop-collapsed camera (public issue #5, gameLogic prepCameraFor)
const shopFolded = showShop && (pen ? penRef.current.collapsed : collapsed);
// 准备就绪 folds the shop bar, cancelling it unfolds the bar again (GitHub #138; gameLogic readyShopFold): the board is set,
// the fight is what to look at — the board camera follows the fold like a hand-made one (the effect below). Inside the pen
// the bar is folded for itself: the state it returns to is the one that changes.
const readySeen = useRef(null);
useEffect(() => {
const cur = phase === PHASE.PREP && priv && alive && !watchingOther ? { round: pub?.round, ready: !!priv.ready } : null;
const change = readyShopFold(readySeen.current, cur);
readySeen.current = cur;
if (!change) return;
if (penRef.current.on) penRef.current.collapsed = change === 'fold';
else setCollapsed(change === 'fold');
}, [phase, pub?.round, priv?.ready, !!priv, alive, watchingOther]);
const cancelFacingRef = useRef(() => {});
const setCam = useCallback((kind, opts) => {
camRef.current = { kind, opts: opts || {} };
@@ -335,6 +363,9 @@ function MatchScreen() {
const lastFieldRef = useRef(null);
const viewModeRef = useRef(null);
const snapUnitsRef = useRef(new Map());
// the shown battle's device units (crates / “双眼皮” turrets: UnitInfo by id) — the meta's `units` plus the 'spawn' events,
// for the tap on a device (gameLogic.deviceTipAt); the renderer's pick skips devices, a tap on one lands in tileClick
const deviceUnitsRef = useRef(new Map());
const hudRef = useRef(null);
// b.ev / b.snap that arrive before the UI entered their field (the battle's first ticks race the m.public /
// m.field re-render): kept per fieldId and replayed on enter so no unit's 'spawn' UnitInfo is ever lost.
@@ -372,6 +403,9 @@ function MatchScreen() {
live.current.terrainTile = showPrep && (deployField === 'bossL' || deployField === 'bossR')
? (row, col) => fieldTile(deployField, row, col)
: (row, col) => [row, col];
// a battle on screen (not the prep board, not a scouted prep board): its crates and turrets are device UNITS, found by
// unit; everything else answers by the stage's own device entries (gameLogic.deviceTipAt / deviceInfo, #228)
live.current.deviceBattle = !!shownField && !shownField.prep;
const staleFieldRef = useRef(null);
const enteredFieldRef = useRef(null);
const pressSel = useRef(null); // the selected piece when the current field press began
@@ -402,7 +436,7 @@ function MatchScreen() {
const wanted = combat || mode === 'settle' || watchingOther;
if (!wanted || !field || !field.fieldId || field === staleFieldRef.current || field === enteredFieldRef.current) return;
if (watchingOther && !combat && field.fieldId !== watching) return; // an older push while switching
if (lastFieldRef.current && lastFieldRef.current !== field.fieldId) setDetail((d) => (d?.kind === 'unit' ? null : d));
if (lastFieldRef.current && lastFieldRef.current !== field.fieldId) setDetail((d) => (inBattleCard(d) ? null : d));
enteredFieldRef.current = field;
lastFieldRef.current = field.fieldId;
reentryRef.current = null;
@@ -411,6 +445,7 @@ function MatchScreen() {
view.enterBattle(field);
const early = evBufRef.current.get(field.fieldId);
evBufRef.current.delete(field.fieldId);
deviceUnitsRef.current = noteDeviceUnits(new Map(), { units: field.units, events: early });
const earlySnap = snapBufRef.current.get(field.fieldId);
snapBufRef.current.delete(field.fieldId);
// a scouted prep board frames like the own prep with the shop folded (the bench row included, app.js camRect);
@@ -434,7 +469,7 @@ function MatchScreen() {
if (early && early.length) {
// replay state-bearing events only (a burst of stale hit sparks / damage numbers would look wrong)
view.pushEvents(early);
audio.handleBattleEvents(early.filter((e) => e[0] === 'spawn'));
audio.handleBattleEvents(audioEarly(early)); // (the 'spawn' tuples only: the sound would replay every old death, deploy and cast)
}
if (!earlySnap && (field.prep || !combat) && Array.isArray(field.units)) {
// prep scouting: no battle snapshots follow. A later m.field for this board (the teammate moved) re-enters
@@ -446,6 +481,9 @@ function MatchScreen() {
}
if (earlySnap) {
view.pushSnapshot(earlySnap);
if (Array.isArray(earlySnap.units)) {
snapUnitsRef.current = new Map(earlySnap.units.filter(Array.isArray).map((t) => [t[0], t]));
}
hudRef.current = snapHud(earlySnap);
setHud(hudRef.current);
}
@@ -498,6 +536,7 @@ function MatchScreen() {
return;
}
view?.pushEvents(msg);
noteDeviceUnits(deviceUnitsRef.current, { events: msg.ev });
audio.handleBattleEvents(msg.ev);
};
// 干员语音 (结算): the own battle's result just came in — the operator's line depends on how it went
@@ -630,13 +669,24 @@ function MatchScreen() {
// the pen is a 休整期 view: leaving prep returns the camera (the next setCam would, too)
if (phase !== PHASE.PREP && penRef.current.on) togglePenRef.current(false);
// a battle unit's panel (live HP of a unit of the fight that just ended) never outlives its battle
if (!isCombatPhase(phase) && phase !== PHASE.SETTLE) setDetail((d) => (d?.kind === 'unit' ? null : d));
if (!isCombatPhase(phase) && phase !== PHASE.SETTLE) setDetail((d) => (inBattleCard(d) ? null : d));
if (isCombatPhase(phase)) { setCollapsed(false); setDrag(null); view?.highlightTiles(null, null); }
setSel(null);
if (phase !== PHASE.PREP) setRewardMin(false);
setSpBusy(null);
}, [phaseKey]);
useEffect(() => {
setPersonalBusy((b) => b?.choiceId === personalChoice?.id ? b : null);
if (!personalChoice) return;
setSel(null);
setDetail(null);
setArmedCard(null);
setDrag(null);
view?.highlightTiles(null, null);
cancelFacingRef.current();
}, [personalChoice?.id, view]);
// a reload / reconnect while watching a teammate's battle after the own one (client-side combat): the server resends
// the watched field, the fresh screen adopts it as watched once per battle — the observing pill, 返回战场 and the own
// row work again, and the bond strip's "👁 name" matches the HUD (battle/observe.js resumedWatch)
@@ -694,6 +744,7 @@ function MatchScreen() {
// ---- actions ----------------------------------------------------------------------------------------------
const buy = useCallback((i) => actions.buy(i), []);
const removeSpectator = useCallback((playerId) => actions.removeSpectator(playerId), []);
// 准备 with funds left asks first: the prep's end wipes them (community report #4; not 坎诺特, not at 0 funds, not
// under AI 托管 — gameLogic.readyFundsPrompt). The button and Space both come here.
const askingReady = useRef(false);
@@ -928,13 +979,24 @@ function MatchScreen() {
setSel(wasSel ? null : { uid: e.uid });
if (wasSel) setDetail((d) => (d?.kind === 'piece' && d.uid === e.uid ? null : d));
}),
// a tap on the ground itself: a special terrain tile explains itself (GitHub issue #184 「建议加入对于特殊地形的单击
// 信息提示」) — 活性源石 / 沼泽 / 排气格栅 / 深水区 / 红蓝门 / 传送, with the numbers of the stage behind the board.
// An ordinary tile (road / floor / wall) says nothing, so the press keeps its other meanings (deselect, close).
// a tap on the ground itself: a stage device on the tile (阻隔工事 / “双眼皮” / 射击台 / 源石流发生装置: GitHub #228, PR
// #229) explains itself first — the renderer's pick skips devices, so the tap lands here —, then a special terrain
// tile (GitHub issue #184 「建议加入对于特殊地形的单击信息提示」) — 活性源石 / 沼泽 / 排气格栅 / 深水区 / 红蓝门 /
// 传送, with the numbers of the stage behind the board. An ordinary tile (road / floor / wall) says nothing, so the
// press keeps its other meanings (deselect, close).
view.on('tileClick', (t) => {
if (!t || !Number.isInteger(t.row) || !Number.isInteger(t.col)) return;
const L = live.current;
const [row, col] = L.terrainTile(t.row, t.col);
const device = L.deviceBattle
? deviceTipAt(L.terrainStage, row, col, { units: [...deviceUnitsRef.current.values()], snap: snapUnitsRef.current })
: deviceInfo(L.terrainStage, row, col);
if (device) {
audio.sfx('click', { volume: 0.4 });
setSel(null);
setDetail({ kind: 'device', device });
return;
}
const info = terrainInfo(L.terrainStage, row, col);
if (!info) return;
audio.sfx('click', { volume: 0.4 });
@@ -1001,10 +1063,6 @@ function MatchScreen() {
}, []);
// ---- direction step (research 09 §1.2) and the selected piece's underframe ------------------------------------
// DESIGN §16: previews show the range the unit fights with under the player's loadout (an elite's module grid); a
// chess the player does not own the range of its stand-in (0.2.0 补位, gameLogic deployedRecord)
const lookups = useMemo(() => ({ getChess: ownChess, getToken: gd.token, getItem: gd.item,
chessRecord: (rec) => deployedRecord(rec, live.current.priv, ownChess, gd.backups) }), [gd.ready]);
const heldRef = useRef(new Map()); // uid → { row, col, t } committed placements awaiting m.private
const releaseHold = useCallback((uid) => {
heldRef.current.delete(uid);
@@ -1056,16 +1114,10 @@ function MatchScreen() {
useEffect(() => {
if (facing && (!editable || !placeCtx.pieces.get(facing.uid))) cancelFacing();
}, [editable, placeCtx, facing]);
// the selected piece: gone / not editable → deselect; on the board its range tiles show (rotated to its facing)
// the selected piece: gone / not editable → deselect (its range tiles: the detail card's, cardRange below)
const selEntry = sel ? placeCtx.pieces.get(sel.uid) || null : null;
live.current.showPrep = showPrep;
useEffect(() => { if (sel && (!selEntry || !editable || !showPrep)) setSel(null); }, [sel, selEntry, editable, showPrep]);
const selRangeKey = selEntry && selEntry.area === 'board' ? `${selEntry.piece.uid}:${selEntry.row},${selEntry.col}:${pieceDir(selEntry.piece)}` : '';
useEffect(() => {
if (!view || !selRangeKey) return undefined;
showRange(view, previewGrid(lookups, selEntry.piece), selEntry.row, selEntry.col, pieceDir(selEntry.piece), SEL_RANGE);
return () => showRange(view, null, 0, 0, null, SEL_RANGE);
}, [view, selRangeKey]);
// the selected piece's underframe on screen: the detail card docks on the side away from it (user playtest #2
// item 8 — at some aspect ratios a bench unit's 出售 sat under the left card); the underframe is drawn above every
// panel anyway (css z-index), this keeps it visible too
@@ -1107,6 +1159,13 @@ function MatchScreen() {
const t = id != null ? snapUnitsRef.current.get(id) : null;
return t ? { hp: t[3], max: t[4] } : null;
})();
// a crate's / turret's card closes with it: destroyed (HP gone, or its tuple left the snapshots once the die animation
// is over — before the first snapshot of a battle nothing is judged)
useEffect(() => {
const id = detail?.kind === 'device' ? detail.device?.unitId : null;
const snap = snapUnitsRef.current;
if (id != null && snap.size > 0 && !(snap.get(id)?.[3] > 0)) setDetail(null);
}, [hud, detail]);
// ---- live stats of the detail card (user playtest #4 item 7) ------------------------------------------------------
// battle: the local sim's unit (battle/runner.js unitStats — a getter the panel re-reads 4× a second; any unit of the
@@ -1154,6 +1213,14 @@ function MatchScreen() {
const L = live.current;
// dialogs / the guide own the keyboard; behind the 本局信息 / 敌方情报 drawer only Esc (closing it) acts
if (shortcutBlocked(act, { modal: !!document.querySelector('.modal, .guide'), drawer: !!L.drawer })) return;
if (L.hasPersonalChoice) {
if (act !== 'escape') e.preventDefault();
if (act === 'ready') {
audio.sfx('error', { volume: 0.5 });
toast(shopBlockReason('ready', { priv: L.priv, editable: true }), 'warn');
}
return; // ChoiceOverlay handles Escape without cancelling the server's pending choice.
}
if (act === 'escape') {
if (L.emoteOpen) setEmoteOpen(false);
else if (L.pen && !L.detail) togglePenRef.current(false);
@@ -1245,7 +1312,7 @@ function MatchScreen() {
}, [strip.ownerId]);
// the popup shows the player it was opened for (ui/watchBonds.js popupView): their entry + live layers, their name,
// and as members your pieces or their operators on the field on screen — under client-side combat the battle's own
// (the runner's field meta is taken before they deploy); their hand is never sent
// (the runner's field meta is taken before they deploy); a battle sends no bench (a prep scout does: ownerBoard)
const popOps = cc && battleRunner && field?.local && bondOpen && bondOpen.ownerId !== myId ? battleRunner.ownerOps(bondOpen.ownerId, field.fieldId) : null;
const bondPop = popupView({ open: bondOpen, pub, priv, myId, field, units: popOps, live: liveLayers });
const openBond = (id, ownerId, from) => { setBondOpen((b) => toggleBond(b, id, ownerId, from)); audio.sfx('click', { volume: 0.4 }); };
@@ -1256,7 +1323,7 @@ function MatchScreen() {
const temp = tempInfo(priv);
const tempNotice = temp.count > 0 && !!view && viewKind !== 'loading' && showPrep && alive && !pen && !sp;
// the ready button shows why it is refused under it (ui/hud.js ReadyToggle): the effects column moves down a line
const readyWhy = phase === PHASE.PREP && alive && !priv?.ready && temp.count > 0;
const readyWhy = phase === PHASE.PREP && alive && !priv?.ready && (hasPersonalChoice || temp.count > 0);
// the frame the panels are laid out in: the HUD layer (client px; inside the safe-area insets of a notched phone),
// and the root font size (1rem)
const panelFrame = () => {
@@ -1299,6 +1366,23 @@ function MatchScreen() {
}
return priv?.loadout ?? null; // own pieces, shop / reward / bond-member cards
})();
// the open detail card's unit draws its attack range on the field (screens/game/range.js inspectRange, GitHub PR #281):
// an own board piece in prep (its loadout / stand-in record, its facing); in battle or on a scouted board an ally
// operator / summon on the field — the range it fights with now (the live stats' `range` and `dir`, the snapshot's
// tile) — never an enemy, a dead unit or a bench unit (UnitInfo `area` hand / temp); drag, the wheel and the pen
// draw their own
const cardRange = !drag && !facing && !pen ? inspectRange({
target: detailTarget, detail: resolved, pieces: placeCtx.pieces, showPrep, field,
snapshot: snapUnitsRef.current.get(resolved?.unitId),
live: typeof liveStats === 'function' ? liveStats() : liveStats,
loadout: detailLoadout, getChess: gd.chess, backups: gd.backups,
}) : null;
const cardRangeKey = cardRange ? JSON.stringify(cardRange) : '';
useEffect(() => {
if (!view) return undefined;
showRange(view, cardRange?.grid, cardRange?.row, cardRange?.col, cardRange?.dir, SEL_RANGE);
return () => showRange(view, null, 0, 0, null, SEL_RANGE);
}, [view, cardRangeKey, showPrep, field]);
// the card's bond chips — and the popup a chip opens: a battle / scouted unit's OWNER's bonds (yours, or that
// teammate's — m.public + live layers), your own piece's yours, a popup member card the popup's player; shop / reward
// cards read the strip's (ui/watchBonds.js detailBondOwner)
@@ -1307,7 +1391,7 @@ function MatchScreen() {
// bonds this mode never activates (标准: 10 of 23, 奥术 among them) — shown 本局禁用 on cards, chips and the popup
const offBonds = modeOffBonds(getMode(pub?.modeId));
return html`<div class=${cx('screen', 'gm', `gm--${mode}`, drag && 'is-dragging', collapsed && 'is-collapsed', sp && 'has-sp', pen && 'is-pen', readyWhy && 'has-readywhy')}
return html`<div class=${cx('screen', 'gm', `gm--${mode}`, drag && 'is-dragging', collapsed && 'is-collapsed', (sp || hasPersonalChoice) && 'has-sp', pen && 'is-pen', readyWhy && 'has-readywhy')}
data-camera=${pen ? 'pen' : camKind}>
<div class="gm__field" ref=${hostRef} onContextMenu=${(e) => e.preventDefault()}></div>
${viewKind === 'loading' ? html`<div class="gm__loading"><${Spinner} label="LOADING FIELD" /></div>` : null}
@@ -1322,7 +1406,8 @@ function MatchScreen() {
readyBusy=${readyBusy} readyCount=${readyCount} playerCount=${solo ? 1 : aliveCount}
pen=${pen} penAvail=${penAvail} onPen=${togglePen} config=${gd.config} frozenAt=${frozenAt}
pause=${canPause || paused ? { show: canPause, paused, busy: pauseBusy, onToggle: () => togglePause(!paused) } : null}
live=${liveLpNow} spectator=${spectator} />
live=${liveLpNow} spectator=${spectator}
spectators=${specFacts.list} myId=${myId} isHost=${specFacts.isHost} onRemoveSpectator=${removeSpectator} />
<div class="gm__bonds">
<button type="button" class="bonds-toggle" aria-expanded=${!bondsCollapsed} aria-controls="match-bond-strip"
@@ -1355,7 +1440,7 @@ function MatchScreen() {
${showShop ? html`<${ShopBar} priv=${priv} editable=${editable} collapsed=${collapsed} onCollapse=${setCollapsed}
barRef=${barRef} offBonds=${offBonds}
onBuy=${buy} onLevel=${() => actions.levelUp()} onRefresh=${() => actions.refresh()} onFreeze=${() => actions.freeze()}
onDetail=${(id, kind, hint) => setDetail({ kind: kind === 'item' ? 'item' : 'chess', id, hint: hint || null })}
onDetail=${(id, kind, hint) => setDetail({ kind: kind === 'item' ? 'item' : 'chess', id, hint: hint || null, tap: ++cardTap.current })}
onDetailClose=${() => setDetail((d) => (d?.kind === 'chess' || d?.kind === 'item' ? null : d))}
onRefuse=${(reason) => { toast(reason, 'warn'); audio.sfx('error', { volume: 0.5 }); }}
reward=${phase === PHASE.PREP && !rewardMin ? priv?.shop?.rewardOffer || null : null}
@@ -1391,7 +1476,7 @@ function MatchScreen() {
onClose=${() => setBondOpen(null)} onMember=${(id, items, standInFor, diy) => setDetail({ kind: 'chess', id, owner: bondPop.ownerId, items: items || null, standInFor: standInFor || null, diy: diy || null })} />` : null}
${resolved ? html`<${DetailPanel} detail=${resolved} snapHp=${snapHp} onClose=${() => { setDetail(null); setSel(null); }}
bonds=${detailBonds} offBonds=${offBonds} loadout=${detailLoadout} side=${dSide} shopOpen=${shopOpen} live=${liveStats} voice=${combat}
bonds=${detailBonds} offBonds=${offBonds} loadout=${detailLoadout} ops=${priv?.ops ?? null} side=${dSide} shopOpen=${shopOpen} live=${liveStats} voice=${true}
onBond=${(id) => openBond(id, detailOwner, 'detail')} />` : null}
${selEntry && editable && !facing && !drag && showPrep ? html`<${Underframe} key=${sel.uid} view=${view} uid=${sel.uid}
@@ -1402,6 +1487,14 @@ function MatchScreen() {
${sp ? html`<${ChoiceOverlay} pub=${pub} sp=${sp} myId=${myId} solo=${solo} busyIdx=${spBusy} total=${total}
onPick=${async (i) => { setSpBusy(i); await actions.choice(i); setSpBusy(null); }} />` : null}
${personalChoice ? html`<${ChoiceOverlay} key=${personalChoice.id} pub=${pub} sp=${personalChoice} myId=${myId} solo=${solo} personal=${true}
busyIdx=${personalBusy?.choiceId === personalChoice.id ? personalBusy.idx : null} total=${total}
onPick=${async (i) => {
const choiceId = personalChoice.id;
setPersonalBusy({ choiceId, idx: i });
await actions.choice(i, choiceId);
setPersonalBusy((b) => b?.choiceId === choiceId ? null : b);
}} />` : null}
${banner ? html`<${PhaseBanner} key=${banner.key} mode="overlay" title=${banner.title} sub=${banner.sub} micro=${banner.micro}
tone=${banner.tone} duration=${banner.duration || 1500} onDone=${() => setBanner(null)} />` : null}
+9
View File
@@ -7,3 +7,12 @@ export const STATE_EV = new Set(['spawn', 'die', 'deploy', 'status', 'skill']);
/** Event tuples replayed when a field is entered late: the state-bearing kinds and the fx that change an enemy's model
* form (shared/protocol.js fxForm — the field meta's UnitInfo `form` predates them). */
export const keepEarly = (e) => Array.isArray(e) && (STATE_EV.has(e[0]) || fxForm(e) !== undefined);
/**
* What the SOUND gets of that replay: the 'spawn' tuples alone, which teach the audio who is on the field. The buffered
* list is every spawn, die, deploy, status and skill since the field's m.field — up to 1500 of them, while the view was not
* showing it — so handing it to the audio would play the old deaths, deploy lines and skill cues all at once on entering
* (GitHub PR #292 by @LimitlessHPPK handed over the whole list; its hold of a cast that arrives before its unit,
* audio.js `pendingSkill`, is what the live path needs, and `onEv` already forwards the full list).
*/
export const audioEarly = (early) => (Array.isArray(early) ? early.filter((e) => Array.isArray(e) && e[0] === 'spawn') : []);
+32
View File
@@ -0,0 +1,32 @@
// screens/game/range.js — the attack range the open detail card's unit draws on the field (GitHub PR #281 by
// @IceCodeNew; the field-detail range idea after @BBleae's fork): see game.js cardRange.
import { attackRangeGrid } from '../../../../shared/loadoutRecord.js';
import { chessLoadout, standInLoadout } from '../../ui/gameLogic.js';
import { tokenVariantFor } from '../../ui/gameLogic/loadout.js';
import { normDir } from '../../ui/facing.js';
/** Resolve the inspected field unit without granting placement or sale actions. */
export function inspectRange({ target, detail, pieces, showPrep = false, field, snapshot, live, loadout, getChess, backups }) {
if (!detail || (detail.type !== 'chess' && detail.type !== 'token')) return null;
let row, col, dir;
if (target?.kind === 'piece' && showPrep) {
const e = pieces?.get(target.uid);
if (!e || e.area !== 'board') return null;
row = e.row; col = e.col; dir = normDir(e.piece.dir);
} else if (target?.kind === 'unit' && !showPrep) {
const unit = target.unit;
if (!unit || unit.side !== 'ally' || (unit.kind !== 'op' && unit.kind !== 'token')) return null;
if (unit.area != null && unit.area !== 'board') return null;
// Snapshots exclude undeployed, hidden and removed units. Dead units remain briefly for their death animation.
if (!field?.prep && (!snapshot || snapshot[3] <= 0)) return null;
row = Math.round(snapshot ? snapshot[2] : unit.y);
col = Math.round(snapshot ? snapshot[1] : unit.x);
dir = normDir(live?.dir, null) || normDir(unit.dir, null) || (unit.facing === -1 ? 'LEFT' : 'RIGHT');
} else return null;
if (!Number.isFinite(row) || !Number.isFinite(col)) return null;
const record = detail.type === 'token' ? { ...detail.token, ...tokenVariantFor(detail.token, detail.ownerId) }
: detail.standIn ? standInLoadout(detail.standIn, getChess, backups)?.record || detail.standIn
: chessLoadout(detail.chess, detail.diy ? null : loadout, getChess)?.record || detail.chess;
const grid = Array.isArray(live?.range) ? live.range : attackRangeGrid(record);
return grid?.length ? { grid, row, col, dir } : null;
}
+182 -68
View File
@@ -5,12 +5,16 @@
// present: operator_preset, image_skill_select_outline, skill_select_deco, icon_equip_non; the module type icons of
// groups.module — lettered tiles without them).
//
// Left: roster of the 112 visible chess (tier / class / bond filters, search, 仅看已调整) — each card shows the equipped
// skill (S1–S3) and, when changed, the elite's module badge. Right: the selected chess — skills (icon, name, 默认,
// Left: the 112 visible chess as one list (tier / class / bond filters, search, 仅看已调整) — a row per operator, its columns
// aligned (0.2.2; the quick choices of PR #301 by @farridge, laid out as one list instead of three columns of cards — the
// owner's decision of 2026-10-08): portrait, name and bonds (a click opens the detail), the skills and the elite's modules
// to tap (不装备 first), the 潜能 and 练度 selects (screens/cultivation.js). Right: the selected chess — its 潜能 / 练度,
// its 特质 (the in-match card's block, PR #301), skills (icon, name, 默认,
// SP recovery, 初始 / 消耗 SP, duration, description at 普通 Lv.4 or 精锐 Lv.7), 局内数值 (the stats, 攻击范围, 特性 and
// 天赋 the chosen variant — 精锐 first, 普通 on the toggle — fights with under the chosen skill and module: the detail
// card's own block and pure functions, GitHub issue #64) and the elite's modules (不装备 / X / Y … with the stat bonus,
// the trait upgrade and the talent changes), 恢复默认; 全部恢复默认 in the top bar.
// the trait upgrade and the talent changes), 恢复默认; 全部恢复默认 in the top bar. The detail's 普通 / 精锐 skill toggle is one
// preview for the skills, the 特质 and the rows' skill details (display only: never stored).
// The loadout lives in ui/loadoutSync.js (localStorage + room.loadout); the model is ui/loadoutModel.js.
// The second tab, 干员持有 (0.2.0 补位, screens/ownership.js): which NORMAL chess the player owns — a chess marked 未持有 is
// deployed as its official stand-in (room.ownership; ui/ownershipModel.js) — with its own 导出 / 导入 / 全部持有.
@@ -22,16 +26,20 @@ import { useEffect, useMemo, useRef, useState } from '../../vendor/hooks.module.
import { html, Icon, MicroLabel, Button, TierChip, TextField, Countdown, Spinner, confirmDialog, hasDeadline, Modal, Fragment } from '../ui/components.js';
import { Img, RichText, UnitThumb } from '../ui/gameComponents.js';
import { chessAvatarUrl, chessPortraitUrl, subProfIconUrl, bondIconUrl, moduleTypeIconUrl } from '../ui/assetUrls.js';
import { chessStatsBlock, traitText, chessTalents } from '../ui/detailPanel.js';
import { chessStatsBlock, traitText, chessTalents, GarrisonBlock } from '../ui/detailPanel.js';
import { chessLoadout } from '../ui/gameLogic.js';
import { data, useData, localAsset, DATA_FILES } from '../data.js';
import { useStore } from '../store.js';
import { PHASE } from '../../../shared/constants.js';
import {
MODULE_NONE, PROF_ORDER, PROF_NAME, rosterOf, filterRoster, recordsOf, chessOptions, effectiveChoice, setChoice, resetChoice,
changedCount, skillLabel, moduleBadge, attrRows, skillTags, traitLines, serializeExport, parseImport, LOADOUT_IMPORT_MAX_BYTES,
changedCount, skillLabel, moduleBadge, attrRows, skillTags, quickSkillTags, traitLines, serializeExport, parseImport, LOADOUT_IMPORT_MAX_BYTES,
opsOf, setOps, resetOps, moduleRecord,
} from '../ui/loadoutModel.js';
import { loadoutStore, openLoadout, closeLoadout, setEntries, applyLoadoutEntries, setNotOwned, applyOwnershipImport, setDiyPicks, applyDiyImport } from '../ui/loadoutSync.js';
import { loadoutStore, openLoadout, closeLoadout, setEntries, setOpsMap, applyLoadoutEntries, setNotOwned, applyOwnershipImport, setDiyPicks, applyDiyImport } from '../ui/loadoutSync.js';
import { CultivationSelects, CultivationSection } from './cultivation.js';
import { cultivationCharIds } from '../../../shared/protocol.js';
import { atPotential } from '../../../shared/potential.js';
import { setOwned, notOwnedCount, serializeOwnership, parseOwnershipImport, OWNERSHIP_IMPORT_MAX_BYTES } from '../ui/ownershipModel.js';
import { OwnershipPanel, useOwnershipRoster } from './ownership.js';
import { DiyPanel, diyData } from './diy.js';
@@ -140,38 +148,96 @@ export function ModuleGlyph({ m, rec, id, size = 'md' }) {
</span>`;
}
// ---- roster card ---------------------------------------------------------------------------------------------------
// ---- roster rows -----------------------------------------------------------------------------------------------------
//
// One list, one row per operator, its columns aligned (the head row names them): the quick choices of PR #301 (by
// @farridge) without its three columns of cards. Every row is the same few columns — tap a skill, a module, a 潜能 / 练度 —
// so the eye runs down a column; the portrait and name open the detail (on a phone it slides over; a quick choice keeps
// the list). Hook-free (the tests draw it).
function RosterCard({ m, chess, golden, entries, selected, onPick, notOwned = false }) {
const choice = effectiveChoice(entries, chess, golden);
const opt = chessOptions(chess, golden);
const skillRec = opt.skillOptions.find((s) => s.index === choice.skill)?.normal || chess.skill;
const modRec = golden && choice.module !== MODULE_NONE ? opt.moduleOptions.find((x) => x.id === choice.module)?.rec || null : null;
const modChanged = golden && choice.module !== opt.defaultModule;
return html`<button type="button" role="option" aria-selected=${selected ? 'true' : 'false'} data-chess=${chess.chessId}
class=${cx('lo-card', `lo-card--t${chess.tier}`, selected && 'is-sel', choice.changed && 'is-changed')} onClick=${() => onPick(chess.chessId)}
title=${`${chess.name} · ${skillRec?.name || ''}`}>
<span class="lo-card__art">
<${Img} src=${chessAvatarUrl(m, chess)} fallback=${html`<span class="lo-card__glyph">${[...(chess.name || '?')][0]}</span>`} />
</span>
<${TierChip} tier=${chess.tier} size="sm" class="lo-card__tier" />
${choice.changed ? html`<span class="lo-card__flag" aria-label=${t('已调整')}></span>` : null}
${notOwned ? html`<span class="lo-card__sub" title=${t('未持有(干员持有):由替补干员上场')} aria-label=${t('未持有')}>${t('替补')}</span>` : null}
<span class="lo-card__name">${chess.name}</span>
<span class="lo-card__kit">
<span class=${cx('lo-card__sk', choice.skill !== opt.defaultSkill && 'is-alt')}>
<${SkillIcon} m=${m} rec=${skillRec} index=${choice.skill} size="xs" />
<b class="num">${skillLabel(choice.skill)}</b>
</span>
${golden ? html`<span class=${cx('lo-card__mod', modChanged && 'is-alt')} title=${modRec ? `${modRec.typeName} ${modRec.name}` : t('不装备模组')}>
${choice.module === MODULE_NONE ? '—' : moduleBadge(modRec)}
</span>` : null}
</span>
/** One skill to tap: its icon, the equipped one ringed; the label / tooltip says slot, name, SP and duration (quickSkillTags). */
function QuickSkill({ m, opt, on, elite, onPick }) {
const rec = elite ? opt.elite || opt.normal : opt.normal || opt.elite;
const tags = quickSkillTags(rec);
const label = `${skillLabel(opt.index)} · ${rec?.name || t('未知技能')} · ${tags.sp} · ${t('初始')} ${tags.init ?? '—'} / ${t('消耗')} ${tags.cost ?? '—'} · ${t('持续')} ${tags.duration}`;
return html`<button type="button" class=${cx('lo-q', 'lo-q--skill', on && 'is-on')} data-skill=${opt.index} aria-pressed=${on ? 'true' : 'false'}
aria-label=${label} title=${label} onClick=${() => onPick(opt.index)}>
<${SkillIcon} m=${m} rec=${rec} index=${opt.index} on=${on} size="q" />
</button>`;
}
/** One module to tap (不装备 first): the type icon with its letter in the corner; the elite's equipped one ringed. */
function QuickModule({ m, opt, on, onPick }) {
const label = opt.id === MODULE_NONE ? t('不装备模组') : `${opt.rec?.typeName || ''} · ${opt.rec?.name || opt.id}`;
return html`<button type="button" class=${cx('lo-q', 'lo-q--mod', on && 'is-on')} data-module=${opt.id} aria-pressed=${on ? 'true' : 'false'}
aria-label=${label} title=${label} onClick=${() => onPick(opt.id)}>
<${ModuleGlyph} m=${m} rec=${opt.rec} id=${opt.id} size="q" />
${opt.id !== MODULE_NONE ? html`<b class="lo-q__type num" aria-hidden="true">${moduleBadge(opt.rec, opt.id)}</b>` : null}
</button>`;
}
/** The list's column heads (the rows share its grid). */
export function RosterHead() {
return html`<div class="lo-list__head" aria-hidden="true">
<span class="lo-list__h lo-list__h--op">${t('干员')}</span>
<span class="lo-list__h lo-list__h--skills">${t('技能')}</span>
<span class="lo-list__h lo-list__h--mods" title=${t('模组仅在精锐形态生效')}>${t('模组')}<small>${t('精锐')}</small></span>
<span class="lo-list__h lo-list__h--cult">${t('潜能')} · ${t('练度')}</span>
</div>`;
}
/**
* One operator's row (`.lo-card`, like the cards before it): portrait + name + bonds (`.lo-card__pick`: selects it, opens
* the detail), three skill slots (an empty one when the chess has two skills), the elite's modules, the 潜能 / 练度 selects.
* `level` 'elite': the skills' details at 精锐 Lv.7 (the shared preview). A chess marked not owned (干员持有) shows the
* 「替补」 tag: its stand-in fights with a fixed skill and no 潜能 / 练度, the choices here apply once it is owned again.
*/
export function RosterRow({ m, chess, golden, entries, ops = {}, selected, onPick, onChange, onOps, level = 'normal', notOwned = false }) {
const choice = effectiveChoice(entries, chess, golden);
const opt = chessOptions(chess, golden);
const cv = opsOf(ops, chess.charId);
const changed = choice.changed || cv.changed;
const modules = [...opt.moduleOptions].sort((a, b) => Number(b.id === MODULE_NONE) - Number(a.id === MODULE_NONE));
const elite = level === 'elite' && !!golden;
const slots = [0, 1, 2].map((i) => opt.skillOptions[i] || null);
const standInNote = notOwned ? t('未持有(干员持有):由替补干员上场,替补干员没有潜能与练度') : null;
return html`<div role="listitem" data-chess=${chess.chessId} data-variant=${elite ? 'elite' : 'normal'}
class=${cx('lo-card', `lo-card--t${chess.tier}`, selected && 'is-sel', changed && 'is-changed', notOwned && 'is-standin')}>
<button type="button" class="lo-card__pick" aria-pressed=${selected ? 'true' : 'false'} title=${chess.name} onClick=${() => onPick(chess.chessId)}>
<span class="lo-card__art">
<${Img} src=${chessAvatarUrl(m, chess)} fallback=${html`<span class="lo-card__glyph">${[...(chess.name || '?')][0]}</span>`} />
<${TierChip} tier=${chess.tier} size="sm" class="lo-card__tier" />
</span>
<span class="lo-card__id">
<span class="lo-card__name">${chess.name}</span>
<span class="lo-card__bonds">${(chess.bonds || []).map((b) => html`<${Img} key=${b} src=${bondIconUrl(m, b)} class="lo-card__bond"
alt=${data.lookup('bonds', b)?.name || b} fallback=${html`<i class="lo-bond__dot" title=${data.lookup('bonds', b)?.name || b}></i>`} />`)}</span>
${notOwned ? html`<span class="lo-card__sub" title=${t('未持有(干员持有):由替补干员上场')}>${t('替补')}</span>` : null}
</span>
</button>
${changed ? html`<span class="lo-card__flag" aria-label=${t('已调整')}></span>` : null}
<div class="lo-card__skills lo-quick" role="group" aria-label=${t('选择技能')}>
${slots.map((sk, i) => (sk ? html`<${QuickSkill} key=${sk.index} m=${m} opt=${sk} elite=${elite} on=${sk.index === choice.skill}
onPick=${(skill) => onChange(chess.chessId, { skill })} />` : html`<span key=${`e${i}`} class="lo-q lo-q--empty" aria-hidden="true"></span>`))}
</div>
<div class="lo-card__mods lo-quick" role="group" aria-label=${t('选择模组')} title=${t('模组仅在精锐形态生效')}>
${golden ? modules.map((mo) => html`<${QuickModule} key=${mo.id} m=${m} opt=${mo} on=${mo.id === choice.module}
onPick=${(module) => onChange(chess.chessId, { module })} />`) : null}
</div>
<${CultivationSelects} charId=${chess.charId} ops=${ops} onSet=${onOps} note=${standInNote} />
</div>`;
}
// ---- detail --------------------------------------------------------------------------------------------------------------
/** The same attribute records and rich-text renderer as the in-match card, including literal trigger markers. */
export function LoadoutGarrisons({ chess, m }) {
const records = (chess?.garrisonIds || []).map((id) => data.lookup('garrisons', id)).filter(Boolean);
return records.length ? html`<section class="lo-garrisons" data-variant=${chess.isGolden ? 'elite' : 'normal'}>
${records.map((garrison) => html`<${GarrisonBlock} key=${garrison.garrisonId} garrison=${garrison} m=${m} />`)}
</section>` : null;
}
function SkillOption({ m, opt, on, level, onPick }) {
const rec = level === 'elite' ? opt.elite || opt.normal : opt.normal || opt.elite;
const tags = skillTags(rec);
@@ -198,9 +264,16 @@ function SkillOption({ m, opt, on, level, onPick }) {
</button>`;
}
export function ModuleInfo({ m, golden, opt }) {
/**
* The chosen module's card: its stat bonus, the 特性 it fights with, its talent changes — read at the operator's 潜能
* (`potential`, 0.2.2: the elite composed at it first, shared/potential.js atPotential, as 局内数值 and the battle do; the
* data record is the full-potential one — 余's 闲云隐市 「生命上限2%(+0.5%)」 is 「1.5%」 at 潜能1). null = full potential.
* @param {{ m: any, golden: any, opt: { id: string, rec: any, isDefault?: boolean }, potential?: number|null }} props
*/
export function ModuleInfo({ m, golden, opt, potential = null }) {
if (!golden) return null;
const rec = opt.id === MODULE_NONE ? null : opt.rec;
const at = atPotential(golden, potential);
const rec = opt.id === MODULE_NONE ? null : at === golden ? opt.rec : moduleRecord(at, opt.id) || opt.rec;
if (!rec) {
const traitBase = golden.traitBase || null;
return html`<div class="lo-minfo lo-minfo--none">
@@ -240,13 +313,15 @@ const standInName = (chess) => data.get('backups')?.units?.[chess?.backup?.charI
* @param {any} base normal chess record @param {any} golden its elite record or null
* @param {Record<string, any>} entries the stored loadout @param {'normal'|'elite'} level
* @param {(id: string) => any} getChess
* @param {Record<string, any>|null} [ops] the stored 潜能 / 练度 (0.2.2: the numbers at the operator's settings — the
* 练度 multiplier needs effects.json; none set = 潜能 6, 精英2 Lv.60)
* @returns {{ elite: boolean, chess: any, lo: any, record: any, trait: string, talents: any[] } | null} null without a record
*/
export function statsPreview(base, golden, entries, level, getChess) {
export function statsPreview(base, golden, entries, level, getChess, ops = null) {
const elite = level === 'elite' && !!golden;
const chess = elite ? golden : base;
if (!chess) return null;
const lo = chessLoadout(chess, entries, getChess);
const lo = chessLoadout(chess, entries, getChess, { ops, effects: data.get('effects') });
const record = lo?.record || chess;
// (the card's own rule: the 特性 line exists when the chess has one; its text follows the chosen module)
return { elite, chess, lo, record, trait: chess.trait?.desc ? traitText(chess, !!chess.isGolden, lo) || '' : '', talents: chessTalents(record) };
@@ -259,9 +334,10 @@ export function statsPreview(base, golden, entries, level, getChess) {
* toggle picks the 普通 or the 精锐 record; 精锐 is the default because the module only exists there.
* @param {{ base: any, golden: any, entries: Record<string, any>, level: 'normal'|'elite', onLevel: (l: 'normal'|'elite') => void, getChess?: (id: string) => any }} props
*/
export function LoadoutStats({ base, golden, entries, level, onLevel, getChess = getChessRec }) {
const pv = statsPreview(base, golden, entries, level, getChess);
export function LoadoutStats({ base, golden, entries, level, onLevel, getChess = getChessRec, ops = null }) {
const pv = statsPreview(base, golden, entries, level, getChess, ops);
if (!pv) return null;
// (the caption names 潜能 / 练度 when the numbers carry both — the 练度 multiplier needs effects.json)
return html`<section class="lo-sec lo-sec--stats" aria-label=${t('局内数值')} data-variant=${pv.elite ? 'elite' : 'normal'}>
<header class="lo-sec__head">
<h3>${t('局内数值')}<${MicroLabel}>STATS<//></h3>
@@ -276,18 +352,19 @@ export function LoadoutStats({ base, golden, entries, level, onLevel, getChess =
${pv.talents.map((tal, i) => html`<div key=${i} class="lo-minfo__row"><span class="lo-minfo__k">${t('天赋')}</span>
<span class="lo-minfo__v"><b class="lo-minfo__tname">${tal.name}</b><${RichText} text=${tal.descRaw || tal.desc || ''} /></span></div>`)}
</div>` : null}
<p class="lo-stats__cap">${pv.elite ? t('数值含所选模组;') : golden ? t('普通干员没有模组,所选模组在「精锐」中生效;') : ''}${t('不含技能发动、装备、盟约等局内加成')}</p>
<p class="lo-stats__cap">${pv.elite ? t('数值含所选模组;') : golden ? t('普通干员没有模组,所选模组在「精锐」中生效;') : ''}${pv.lo?.cultivation && data.get('effects') ? t('含潜能与练度;') : ''}${t('不含技能发动、装备、盟约等局内加成')}</p>
</section>`;
}
function Detail({ m, chess, golden, entries, onChange, onReset, locked, notOwned = false }) {
const [level, setLevel] = useState('normal');
function Detail({ m, chess, golden, entries, ops = {}, onChange, onOps, onReset, locked, level = 'normal', onLevel, notOwned = false }) {
const [statLevel, setStatLevel] = useState('elite'); // 局内数值: the 精锐 shows the chosen module's effect
const setLevel = onLevel;
const bodyRef = useRef(null);
useEffect(() => { if (bodyRef.current) bodyRef.current.scrollTop = 0; }, [chess?.chessId]);
if (!chess) return html`<aside class="lo-detail lo-detail--empty"><p class="t-dim">${t('没有符合条件的干员')}</p></aside>`;
const opt = chessOptions(chess, golden);
const choice = effectiveChoice(entries, chess, golden);
const changed = choice.changed || opsOf(ops, chess.charId).changed;
const modOpt = opt.moduleOptions.find((x) => x.id === choice.module) || null;
const lv = (c) => c?.status?.skillLevel ?? '—';
return html`<aside class="lo-detail" aria-label=${t('{name} 调配', { name: chess.name })}>
@@ -297,7 +374,7 @@ function Detail({ m, chess, golden, entries, onChange, onReset, locked, notOwned
</div>
<div class="lo-dhead__info">
<div class="lo-dhead__chips"><${TierChip} tier=${chess.tier} size="md" />
${choice.changed ? html`<span class="lo-badge lo-badge--changed">${t('已调整')}</span>` : html`<span class="lo-badge lo-badge--plain">${t('默认配置')}</span>`}</div>
${changed ? html`<span class="lo-badge lo-badge--changed">${t('已调整')}</span>` : html`<span class="lo-badge lo-badge--plain">${t('默认配置')}</span>`}</div>
<h2 class="lo-dhead__name">${chess.name}</h2>
<span class="lo-dhead__en">${chess.appellation || ''}</span>
<span class="lo-dhead__class">
@@ -307,9 +384,11 @@ function Detail({ m, chess, golden, entries, onChange, onReset, locked, notOwned
<span class="lo-dhead__bonds">${(chess.bonds || []).map((b) => html`<span key=${b} class="lo-bond">
<${Img} src=${bondIconUrl(m, b)} class="lo-bond__icon" fallback=${html`<i class="lo-bond__dot"></i>`} />${data.lookup('bonds', b)?.name || b}</span>`)}</span>
</div>
<${Button} variant="ghost" size="sm" icon="refresh" class="lo-dhead__reset" disabled=${!choice.changed} onClick=${onReset}>${t('恢复默认')}<//>
<${Button} variant="ghost" size="sm" icon="refresh" class="lo-dhead__reset" disabled=${!changed} onClick=${onReset}>${t('恢复默认')}<//>
</div>
<div class="lo-detail__body" ref=${bodyRef}>
<${CultivationSection} charId=${chess.charId} ops=${ops} onSet=${onOps} standIn=${notOwned} />
<${LoadoutGarrisons} chess=${level === 'elite' && golden ? golden : chess} m=${m} />
<section class="lo-sec">
<header class="lo-sec__head">
<h3>${t('技能')}<${MicroLabel}>SKILL<//></h3>
@@ -323,7 +402,7 @@ function Detail({ m, chess, golden, entries, onChange, onReset, locked, notOwned
onPick=${(i) => onChange({ skill: i })} />`)}
</div>
</section>
<${LoadoutStats} base=${chess} golden=${golden} entries=${entries} level=${statLevel} onLevel=${setStatLevel} />
<${LoadoutStats} base=${chess} golden=${golden} entries=${entries} ops=${ops} level=${statLevel} onLevel=${setStatLevel} />
${golden ? html`<section class="lo-sec lo-sec--mod">
<header class="lo-sec__head">
<h3>${t('模组')}<${MicroLabel}>MODULE<//></h3>
@@ -341,7 +420,7 @@ function Detail({ m, chess, golden, entries, onChange, onReset, locked, notOwned
${mo.isDefault ? html`<span class="lo-badge lo-badge--def lo-mod__def">${t('默认')}</span>` : null}
</button>`)}
</div>
${modOpt ? html`<${ModuleInfo} m=${m} golden=${golden} opt=${modOpt} />` : null}
${modOpt ? html`<${ModuleInfo} m=${m} golden=${golden} opt=${modOpt} potential=${opsOf(ops, chess.charId).potential} />` : null}
</section>` : null}
${notOwned ? html`<p class="lo-locknote lo-locknote--standin" data-testid="loadout-standin-note"><${Icon} name="info" />${standInName(chess) ? t('干员持有中标记为未持有:此棋子由替补干员 {name} 上场,技能与模组固定(补位干员技能不可更改);这里的调配在改回「持有」后生效', { name: standInName(chess) })
: t('干员持有中标记为未持有:此棋子由替补干员上场,技能与模组固定(补位干员技能不可更改);这里的调配在改回「持有」后生效')}</p>` : null}
@@ -433,7 +512,7 @@ export function DataMissing({ files }) {
/** The overlay screen. */
function LoadoutScreen({ st }) {
const ready = useData('chess', 'bonds', 'assets', 'local', 'backups');
const ready = useData('chess', 'bonds', 'assets', 'local', 'backups', 'garrisons', 'effects');
const phase = useStore((s) => s.match?.public?.phase || null);
const inMatch = useStore((s) => !!s.room?.inMatch);
// co-op briefing (INFO_CHECK, 25 s): the overlay covers the briefing's own countdown, so it shows the time left — the
@@ -448,14 +527,16 @@ function LoadoutScreen({ st }) {
return (data.list('bonds') || []).filter((b) => b && used.has(b.bondId))
.sort((a, b) => (b.isCore ? 1 : 0) - (a.isCore ? 1 : 0) || (a.bondOrder ?? 0) - (b.bondOrder ?? 0) || String(a.name).localeCompare(String(b.name), 'zh'));
}, [ready, roster]);
const list = filterRoster(roster, st.filters, st.entries, getChess, getBond);
const list = filterRoster(roster, st.filters, st.entries, getChess, getBond, st.ops);
const selId = st.sel && roster.some((c) => c.chessId === st.sel) ? st.sel : list[0]?.chessId || roster[0]?.chessId || null;
const { base, golden } = selId ? recordsOf(selId, getChess) : { base: null, golden: null };
const nChanged = changedCount(st.entries, getChess);
const nChanged = changedCount(st.entries, getChess, st.ops, roster);
const locked = (inMatch && phase && phase !== PHASE.INFO_CHECK && phase !== PHASE.LOBBY) || st.sync === 'locked';
const gridRef = useRef(null);
const fileRef = useRef(null); // hidden <input type=file> of the 导入 dialog
const [narrowDetail, setNarrowDetail] = useState(false); // phones: the detail slides over the roster
// the detail's 普通 / 精锐 skill toggle: one preview for its skills, its 特质 and the rows' skill details (display only)
const [previewLevel, setPreviewLevel] = useState('normal');
const [io, setIo] = useState(null); // 导出 / 导入 dialog: { mode, text } | null
const tab = st.tab === 'ownership' || st.tab === 'diy' ? st.tab : 'loadout';
@@ -479,12 +560,30 @@ function LoadoutScreen({ st }) {
if (ok) setNotOwned([]);
};
const pick = (id) => { loadoutStore.set({ sel: id }); setNarrowDetail(true); };
// a row's quick choice (PR #301): the same setChoice / setEntries path; the row becomes the selection, a phone stays on the list
const quickChange = (id, patch) => {
const recs = recordsOf(id, getChess);
setEntries(setChoice(loadoutStore.get().entries, recs.base, recs.golden, patch));
loadoutStore.set({ sel: id });
};
const change = (patch) => { if (base) setEntries(setChoice(loadoutStore.get().entries, base, golden, patch)); };
const resetOne = () => { if (base) setEntries(resetChoice(loadoutStore.get().entries, base.chessId)); };
// 0.2.2: an operator's 潜能 / 练度 (by charId: its normal, elite and 自选 forms share them)
const setOpsOf = (charId, patch) => setOpsMap(setOps(loadoutStore.get().ops, charId, patch));
const resetOne = () => {
if (!base) return;
setOpsMap(resetOps(loadoutStore.get().ops, base.charId));
setEntries(resetChoice(loadoutStore.get().entries, base.chessId));
};
const resetAll = async () => {
if (!nChanged) return;
const ok = await confirmDialog({ title: t('全部恢复默认'), text: t('将 {nChanged} 名干员的技能与模组恢复为默认配置?', { nChanged }), okText: t('恢复默认'), danger: true });
if (ok) setEntries({});
const ok = await confirmDialog({ title: t('全部恢复默认'), text: t('将 {nChanged} 名干员的技能、模组、潜能与练度恢复为默认配置?', { nChanged }), okText: t('恢复默认'), danger: true });
if (!ok) return;
// the roster's operators only: a 自选 pick's settings belong to the 自选编队 tab
const rosterChars = new Set(roster.map((c) => c.charId));
const keep = {};
for (const [id, e] of Object.entries(loadoutStore.get().ops || {})) if (!rosterChars.has(id)) keep[id] = e;
setOpsMap(keep);
setEntries({});
};
// 导出 / 导入 the loadout (or, on the 干员持有 tab, the not-owned list) as the versioned payload (a downloaded file,
@@ -495,7 +594,7 @@ function LoadoutScreen({ st }) {
const openExport = () => setIo(tab === 'ownership'
? { mode: 'export', kind: 'ownership', text: serializeOwnership(loadoutStore.get().notOwned) }
: tab === 'diy' ? { mode: 'export', kind: 'diy', text: serializeDiy(loadoutStore.get().diy) }
: { mode: 'export', kind: 'loadout', text: serializeExport(loadoutStore.get().entries) });
: { mode: 'export', kind: 'loadout', text: serializeExport(loadoutStore.get().entries, { ops: loadoutStore.get().ops }) });
const openImport = () => setIo({ mode: 'import', kind: tab, text: '' });
const ioCopy = async () => {
const ok = await copyText(ioText);
@@ -536,13 +635,17 @@ function LoadoutScreen({ st }) {
}
const res = parseImport(ioText);
if (!res.ok) { toast(t('导入失败:{error}', { error: res.error }), 'error'); return; }
const { applied, dropped } = applyLoadoutEntries(res.entries, getChess);
// 0.2.2: a payload with `ops` replaces the 潜能 / 练度 too (an older export leaves them alone)
const opIds = res.ops ? cultivationCharIds(data.get('chess'), data.get('backups')) : null;
const r = applyLoadoutEntries(res.entries, getChess, res.ops ? { ops: res.ops, isOperator: (id) => opIds.has(id) } : {});
const { applied, dropped } = r;
const nOps = r.ops ?? 0;
// nothing survived sanitising (unknown chess, or every choice already the default): keep the current loadout
if (!applied) { toast(t('导入失败:这份数据在当前版本没有可用的调配,未做任何改动'), 'error'); return; }
if (!applied && !nOps) { toast(t('导入失败:这份数据在当前版本没有可用的调配,未做任何改动'), 'error'); return; }
setIo(null);
toast(dropped
? t('已导入 {applied} 名干员(另有 {dropped} 项未导入)', { applied, dropped })
: t('已导入 {applied} 名干员的调配', { applied }), dropped ? 'warn' : 'success');
toast(!applied ? t('已导入 {n} 名干员的潜能与练度', { n: nOps })
: dropped ? t('已导入 {applied} 名干员(另有 {dropped} 项未导入)', { applied, dropped })
: t('已导入 {applied} 名干员的调配', { applied }), dropped ? 'warn' : 'success');
};
// Esc closes; ←/→ browse the filtered roster (not while typing in the search field)
@@ -554,8 +657,10 @@ function LoadoutScreen({ st }) {
const typing = e.target && /^(INPUT|SELECT|TEXTAREA)$/.test(e.target.tagName);
if (e.key === 'Escape') { e.preventDefault(); e.stopImmediatePropagation(); closeLoadout(); return; }
if (typing) return;
// (a row's quick choices: ←/→ stay with the focused group, PR #301)
if (e.target?.closest?.('.lo-quick') && (e.key === 'ArrowRight' || e.key === 'ArrowLeft')) return;
if ((e.key === 'ArrowRight' || e.key === 'ArrowLeft') && loadoutStore.get().tab !== 'ownership' && loadoutStore.get().tab !== 'diy') {
const ids = filterRoster(rosterOf(data.list('chess')), loadoutStore.get().filters, loadoutStore.get().entries, getChess, getBond).map((c) => c.chessId);
const ids = filterRoster(rosterOf(data.list('chess')), loadoutStore.get().filters, loadoutStore.get().entries, getChess, getBond, loadoutStore.get().ops).map((c) => c.chessId);
if (!ids.length) return;
const cur = Math.max(0, ids.indexOf(loadoutStore.get().sel));
const next = ids[(cur + (e.key === 'ArrowRight' ? 1 : -1) + ids.length) % ids.length];
@@ -577,7 +682,7 @@ function LoadoutScreen({ st }) {
const [syncText, syncCls] = tab === 'ownership' ? OWN_SYNC_TEXT[st.ownSync] || OWN_SYNC_TEXT.idle
: tab === 'diy' ? DIY_SYNC_TEXT[st.diySync] || DIY_SYNC_TEXT.idle : SYNC_TEXT[st.sync] || SYNC_TEXT.idle;
const fromText = st.from === 'briefing' ? t('确认本局信息阶段结束前可调整本局配置') : t('开始模拟前可调整干员携带的技能与模组,干员等级不可调整');
const fromText = st.from === 'briefing' ? t('确认本局信息阶段结束前可调整本局配置') : t('开始模拟前可调整干员携带的技能与模组,以及潜能与练度;干员的局内等级不可调整');
// 干员持有 is out of match: a running match keeps the list its seat had at its start
const ownLocked = inMatch && !!phase && phase !== PHASE.LOBBY;
const ownText = ownLocked ? t('干员持有是局外设置:本局按开局时的设置进行,修改将在下一局生效')
@@ -631,20 +736,25 @@ function LoadoutScreen({ st }) {
: html`<p class=${cx('lo-note', locked && 'is-locked')}><${Icon} name="info" />${locked ? t('本局的调配已锁定(确认本局信息后无法修改),修改将在下一局生效') : fromText}</p>`}
${!ready ? html`<div class="lo-loading"><${Spinner} size="sm" />${t('正在载入干员数据(打开页面后仅载入一次)…')}</div>`
: lost.length ? html`<${DataMissing} files=${lost} />` : tab === 'diy'
? html`<${DiyPanel} m=${m} picks=${st.diy || {}} legal=${diyLegal} kitted=${st.diyKitted} onSet=${setDiySlot} />`
? html`<${DiyPanel} m=${m} picks=${st.diy || {}} legal=${diyLegal} kitted=${st.diyKitted} onSet=${setDiySlot} ops=${st.ops} onOps=${setOpsOf} />`
: tab === 'ownership'
? html`<${OwnershipPanel} m=${m} roster=${ownRoster} notOwned=${st.notOwned} onToggle=${toggleOwned} />`
: html`<main class=${cx('lo-body', narrowDetail && 'is-detail')}>
<section class="lo-roster">
<${Filters} m=${m} filters=${st.filters} bonds=${bonds} onFilters=${(filters) => loadoutStore.set({ filters })} />
<div class="lo-grid" role="listbox" aria-label=${t('干员列表')} ref=${gridRef}>
${list.length ? list.map((c) => html`<${RosterCard} key=${c.chessId} m=${m} chess=${c} golden=${c.goldenId ? getChess(c.goldenId) : null}
entries=${st.entries} selected=${c.chessId === selId} onPick=${pick} notOwned=${(st.notOwned || []).includes(c.chessId)} />`) : html`<p class="lo-empty t-dim">${t('没有符合条件的干员')}</p>`}
<div class="lo-list" ref=${gridRef}>
<${RosterHead} />
<div class="lo-list__rows" role="list" aria-label=${t('干员列表')}>
${list.length ? list.map((c) => html`<${RosterRow} key=${c.chessId} m=${m} chess=${c} golden=${c.goldenId ? getChess(c.goldenId) : null}
entries=${st.entries} ops=${st.ops} selected=${c.chessId === selId} onPick=${pick} onChange=${quickChange} onOps=${setOpsOf}
level=${previewLevel} notOwned=${(st.notOwned || []).includes(c.chessId)} />`) : html`<p class="lo-empty t-dim">${t('没有符合条件的干员')}</p>`}
</div>
</div>
</section>
<div class="lo-detail-wrap">
<button type="button" class="lo-detail-back tapx" onClick=${() => setNarrowDetail(false)}><${Icon} name="chevronLeft" />${t('干员列表')}</button>
<${Detail} m=${m} chess=${base} golden=${golden} entries=${st.entries} onChange=${change} onReset=${resetOne} locked=${locked}
<${Detail} m=${m} chess=${base} golden=${golden} entries=${st.entries} ops=${st.ops} onChange=${change} onOps=${setOpsOf} onReset=${resetOne} locked=${locked}
level=${golden ? previewLevel : 'normal'} onLevel=${setPreviewLevel}
notOwned=${!!base && (st.notOwned || []).includes(base.chessId)} />
</div>
</main>`}
@@ -711,11 +821,13 @@ export function LoadoutHost() {
/**
* The entry button's badge: the same number as the screen's 已调整 N (a stored entry of a chess the data no longer offers
* never applies) once chess.json is loaded; before that the stored entries (the badge alone must not trigger the 1.6 MB
* download). Review fix: it counted stale entries the screen does not.
* download). Review fix: it counted stale entries the screen does not. With `ops` (0.2.2) the operators whose 潜能 / 练度
* differ count too (before chess.json: the stored entries plus the stored settings, an operator possibly twice).
* @param {Record<string, any>} entries @param {((id: string) => any) | null} getChess null while chess.json is not loaded
* @param {Record<string, any>|null} [ops] @param {any[]|null} [roster] rosterOf(…) of the loaded data
*/
export function badgeCount(entries, getChess) {
return getChess ? changedCount(entries, getChess) : Object.keys(entries || {}).length;
export function badgeCount(entries, getChess, ops = null, roster = null) {
return getChess ? changedCount(entries, getChess, ops, roster) : Object.keys(entries || {}).length + Object.keys(ops || {}).length;
}
/**
@@ -725,9 +837,11 @@ export function badgeCount(entries, getChess) {
export function LoadoutButton({ from, size = 'md', variant = 'secondary', class: cls, label = t('干员调配') }) {
useData('local'); // the official preset icon (re-render once the local-art manifest arrives)
const entries = useStore((s) => s.entries, Object.is, loadoutStore);
const ops = useStore((s) => s.ops, Object.is, loadoutStore);
const notOwned = useStore((s) => s.notOwned, Object.is, loadoutStore);
const diy = useStore((s) => s.diy, Object.is, loadoutStore);
const n = badgeCount(entries, data.status('chess') === 'ready' ? (id) => data.lookup('chess', id) : null);
const chessReady = data.status('chess') === 'ready';
const n = badgeCount(entries, chessReady ? (id) => data.lookup('chess', id) : null, ops, chessReady ? rosterOf(data.list('chess')) : null);
// 0.2.0 补位: how many operators the player marked as not owned (the 干员持有 tab) — easy to forget between sessions
const off = Array.isArray(notOwned) ? notOwned.length : 0;
// 0.2.0 自选编队: how many DIY slots the player filled
+4
View File
@@ -16,6 +16,8 @@ import { DIFFICULTIES, DIFFICULTY_NAMES, DIFFICULTY_COLORS, ROOM_CODE_LEN, MAX_S
import { html, Button, Icon, MicroLabel, Panel, TextField, PingPill, AvatarFrame, Tooltip, Spinner, DifficultyIcon, doctorNo } from '../ui/components.js';
import { toast, toastError } from '../ui/toasts.js';
import { GuideButton } from '../ui/guide.js';
import { openStats } from './stats.js';
import { SettingsButton } from '../ui/settings.js';
import { LoadoutButton } from './loadout.js';
import { net, identity } from '../net.js';
import { store, useStore, shallowEqual, loadPref, savePref } from '../store.js';
@@ -311,6 +313,8 @@ export function LobbyScreen() {
<h1 class="topbar__title">${t('选择模拟协议')}</h1>
</div>
<div class="topbar__right">
<${Button} variant="secondary" size="sm" icon="chart" class="stats-entry" onClick=${openStats} title=${t('统计数据')} aria-label=${t('统计数据')}>${t('统计')}<//>
<${SettingsButton} class="lobby-settings" variant="secondary" label=${t('设置')} />
<${GuideButton} class="lobby-guide" variant="secondary" label=${t('玩法说明')} />
<${LoadoutButton} from="lobby" size="sm" class="lobby-loadout" label=${t('干员调配')} />
<div class="me-chip">
+23 -54
View File
@@ -15,16 +15,15 @@
// stats: { dmgDealt, kills, leaks, gold /* funds SPENT */, refreshes, merges, bossDamage?, itemsEquipped?,
// activatedLayers?, lpLost?, perfectRounds? } }] }
import { useEffect, useState } from '../../vendor/hooks.module.js';
import { html, Button, Icon, MicroLabel, DifficultyTag, Modal, TierChip } from '../ui/components.js';
import { useGameData, Img, UnitThumb, BandIcon, PlayerAvatar, BondGlyph, LpTower, Sprite, RichText } from '../ui/gameComponents.js';
import { useEffect } from '../../vendor/hooks.module.js';
import { html, Button, Icon, MicroLabel, DifficultyTag } from '../ui/components.js';
import { useGameData, Img, UnitThumb, BandIcon, PlayerAvatar, BondGlyph, LpTower, Sprite } from '../ui/gameComponents.js';
import { normalizeResult, fmtNum, diyRecordFor, cardStandIn, standInForText } from '../ui/gameLogic.js';
import { data } from '../data.js';
import { enemyIconUrl, titleIconUrl, uiUrl, chessPortraitUrl } from '../ui/assetUrls.js';
import { enemyIconUrl, titleIconUrl, uiUrl } from '../ui/assetUrls.js';
import { store, useStore, emptyMatch } from '../store.js';
import { audio } from '../audio.js';
import { sentText } from '../ui/lang.js';
import { useDocClass } from '../ui/device.js';
import { t, tParts, N_ } from '../../../shared/i18n.js';
const cx = (...p) => p.flat().filter(Boolean).join(' ');
@@ -57,50 +56,11 @@ export function resultBonds(bonds, bondRec = () => null) {
* 「替补」 mark — the title names the chess it fielded for.
*/
export function LineupThumb({ u, gd }) {
const [open, setOpen] = useState(false);
const token = u.kind === 'token';
const chess = token ? null : gd.chess(u.id);
const dr = u.diy && chess ? diyRecordFor(chess, u.diy, { chess: data.get('chess'), backups: data.get('backups') }) : null;
const si = !dr && chess ? cardStandIn(chess, { unit: u, backups: gd.backups }) : null;
const rec = dr || si || chess;
const name = rec?.name || (token ? gd.token(u.id)?.name : null) || u.id;
const title = si ? t('{name}({note})', { name: si.name, note: standInForText(chess.name) }) : name;
const items = (Array.isArray(u.items) ? u.items : []).map((it) => typeof it === 'string' ? it : it?.id)
.filter((id) => typeof id === 'string' && id).slice(0, 2);
const slots = [0, 1];
return html`<div class="runit">
<button type="button" class=${cx('runit__open', u.golden && 'is-golden')} title=${title} aria-label=${`${title} · ${t('装备')} ${items.length}/2`}
aria-haspopup="dialog" aria-expanded=${open} onClick=${() => setOpen(true)}>
<span class="runit__art"><${Img} src=${chessPortraitUrl(gd.m, rec)} alt=${name} class="runit__portrait"
fallback=${html`<${UnitThumb} kind=${token ? 'token' : 'chess'} id=${u.id} golden=${!!u.golden} tier=${u.tier} size="sm" showTier=${false} rec=${dr || si} title=${title} />`} />
${!token && (u.tier || rec?.tier) ? html`<${TierChip} tier=${u.tier || rec.tier} golden=${!!u.golden} size="sm" class="runit__tier" />` : null}
${si ? html`<span class="runit__standin">${t('替补')}</span>` : null}
</span>
${!token ? html`<span class="runit__items">${slots.map((slot) => items[slot]
? html`<${UnitThumb} key=${slot} kind="item" id=${items[slot]} size="xs" showTier=${false} title=${gd.item(items[slot])?.name || items[slot]} />`
: html`<span key=${slot} class="runit__empty" aria-hidden="true">—</span>`)}</span>` : null}
</button>
${open ? html`<${Modal} open=${true} title=${title} micro=${t('装备')} class="result-unit" width="min(6.8rem, 94vw)"
onClose=${() => setOpen(false)} actions=${html`<${Button} onClick=${() => setOpen(false)}>${t('关闭')}<//>`}>
<div class="result-unit__body">
<div class="result-unit__portrait"><${Img} src=${chessPortraitUrl(gd.m, rec)}
fallback=${html`<${UnitThumb} kind=${token ? 'token' : 'chess'} id=${u.id} golden=${!!u.golden} rec=${dr || si} size="lg" />`} />
${u.golden ? html`<span class="result-unit__elite">${t('精锐')}</span>` : null}</div>
<section class="result-unit__equipment" aria-label=${t('装备')}>
<h3>${t('装备')} <span class="num">${items.length}/2</span></h3>
${items.length ? items.map((id, i) => {
const item = gd.item(id);
return html`<div key=${i} class="result-unit__item">
<${UnitThumb} kind="item" id=${id} size="sm" />
<div><h4>${item?.name || id}${item?.isGolden ? html`<span class="result-unit__elite">${t('进阶')}</span>` : null}</h4>
<${RichText} as="p" text=${item?.descRaw || item?.desc || ''} />
</div>
</div>`;
}) : html`<p class="t-dim">—</p>`}
</section>
</div>
<//>` : null}
</div>`;
const chess = u.kind === 'token' ? null : gd.chess(u.id);
const dr = u.diy && chess ? diyRecordFor(chess, u.diy, { chess: data.get('chess'), backups: data.get('backups') }) : null;
const si = !dr && chess ? cardStandIn(chess, { unit: u, backups: gd.backups }) : null;
return html`<${UnitThumb} kind=${u.kind === 'token' ? 'token' : 'chess'} id=${u.id} golden=${!!u.golden} tier=${u.tier} size="sm" rec=${dr || si}
title=${si ? t('{name}({note})', { name: si.name, note: standInForText(chess.name) }) : undefined} />`;
}
function PlayerCard({ p, myId, titles, best, solo = false }) {
@@ -143,13 +103,22 @@ function PlayerCard({ p, myId, titles, best, solo = false }) {
</article>`;
}
/** RESULT screen. */
/** RESULT screen of the match on screen: reads the store; 返回同盟 / 返回大厅 drops the match. */
export function ResultScreen() {
useDocClass('sp-result');
const res = useStore((s) => s.match.result);
const pub = useStore((s) => s.match.public);
const myId = useStore((s) => s.me.playerId);
const hasRoom = useStore((s) => !!s.room);
const back = () => store.set({ match: emptyMatch() });
return html`<${ResultView} res=${res} pub=${pub} myId=${myId} backLabel=${hasRoom ? t('返回同盟') : t('返回大厅')} onBack=${back} />`;
}
/**
* The settlement view of one m.result payload — the live screen above, and the stats page's re-view of a stored match
* (screens/stats.js, which hands it the payload recordToResult rebuilt; `quiet`: no settlement jingle).
* @param {{ res: any, pub?: any, myId?: string|null, backLabel: string, onBack: () => void, quiet?: boolean }} props
*/
export function ResultView({ res, pub = null, myId = null, backLabel, onBack, quiet = false }) {
const gd = useGameData();
const r = normalizeResult(res, pub);
const titles = Array.isArray(gd.config?.titles) ? gd.config.titles : [];
@@ -159,8 +128,7 @@ export function ResultScreen() {
for (const p of r.players) if (Number.isFinite(p.stats[k]) && p.stats[k] > 0 && (top == null || p.stats[k] > top.v)) top = { v: p.stats[k], id: p.playerId };
if (top && r.players.length > 1) best[k] = top.id;
}
useEffect(() => { audio.sfx(r.victory ? 'settlementSucceed' : 'settlementFail'); }, []);
const back = () => store.set({ match: emptyMatch() });
useEffect(() => { if (!quiet) audio.sfx(r.victory ? 'settlementSucceed' : 'settlementFail'); }, []);
const boss = r.bossId ? gd.boss(r.bossId) : null;
// the Hidden Core medal (and its corrupted leader) only once R15 was actually fought
const hidden = r.hiddenBossId && r.hiddenReached ? gd.boss(r.hiddenBossId) : null;
@@ -191,7 +159,7 @@ export function ResultScreen() {
</div>
${mins ? html`<p class="result__time t-lo">${tParts('本局耗时 {n} 分钟', { n: html`<b class="num">${mins}</b>`, mins })}</p>` : null}
<footer class="result__foot">
<${Button} variant="primary" size="xl" icon="chevronLeft" onClick=${back}>${hasRoom ? t('返回同盟') : t('返回大厅')}<//>
<${Button} variant="primary" size="xl" icon="chevronLeft" onClick=${onBack}>${backLabel}<//>
</footer>
</section>
<section class="result__players">
@@ -202,3 +170,4 @@ export function ResultScreen() {
</main>
</div>`;
}
+39 -3
View File
@@ -1,6 +1,6 @@
// Room screen (同盟等待室): 4 seat cards (avatar frame, name, ready state, AI badge, host crown),
// host controls (difficulty picker, add/remove AI in co-op, start), invite code with copy code /
// copy link, ready toggle and leave.
// host controls (difficulty picker, add/remove AI and the 「AI 队友最后选择」 switch in co-op, start), invite code with
// copy code / copy link, ready toggle and leave.
//
// Start rule (server/lobby.js): room.start needs every *other* human connected and ready; the
// host's start counts as the host's ready. So 开始模拟 is enabled exactly then and sends room.start
@@ -19,6 +19,8 @@ import {
import { toast, toastError } from '../ui/toasts.js';
import { copyText } from '../ui/clipboard.js';
import { GuideButton } from '../ui/guide.js';
import { openStats } from './stats.js';
import { SettingsButton } from '../ui/settings.js';
import { LoadoutButton } from './loadout.js';
import { net } from '../net.js';
import { store, useStore, shallowEqual, emptyMatch, isSpectating } from '../store.js';
@@ -69,6 +71,19 @@ export function roomFacts(room, myId) {
};
}
/**
* The co-op room option 「AI 队友最后选择」 (GitHub #338; room.setAiPicksLast, room.state.aiPicksLast): in the strategy and
* 机变 drafts every human picks before the AI teammates. null in a solo room (no AI teammates); otherwise its state (off
* when the server sends none) and whether this player may switch it — the host only, the others see it read-only.
* @param {any} room room.state payload
* @param {any} myId
* @returns {{ on: boolean, editable: boolean } | null}
*/
export function aiLastOption(room, myId) {
if (!room || room.mode === 'solo') return null;
return { on: room.aiPicksLast === true, editable: room.hostId != null && room.hostId === myId };
}
/** Invite link for a room code (current page URL with ?room=CODE). */
export function inviteLink(code) {
const loc = globalThis.location;
@@ -189,6 +204,21 @@ function DifficultyPicker({ room, isHost, busy, onPick }) {
</div>`;
}
/** The 「AI 队友最后选择」 switch (co-op): the host toggles it, everybody else sees its state. */
function AiLastToggle({ option, busy, onToggle }) {
if (!option) return null;
return html`<div class="ailast">
<${Tooltip} text=${t('策略与机变轮选时,所有博士先于 AI 队友选择')}>
<button type="button" role="switch" aria-checked=${option.on ? 'true' : 'false'}
class=${`dpick__opt ailast__opt${option.on ? ' is-active' : ''}`} disabled=${!option.editable || !!busy}
onClick=${() => option.editable && onToggle(!option.on)}>
<${Icon} name="check" class=${option.on ? 'is-on' : ''} />${t('AI 队友最后选择')}
</button>
<//>
${option.editable ? null : html`<span class="t-dim">${t('由创建者设置')}</span>`}
</div>`;
}
/** Room screen component. */
export function RoomScreen() {
const room = useStore((s) => s.room);
@@ -229,6 +259,7 @@ export function RoomScreen() {
if (ok) run(`kick${seat}`, () => net.request('room.kick', { seat, playerId }));
};
const setDifficulty = (difficulty) => run('diff', () => net.request('room.setDifficulty', { difficulty }));
const setAiLast = (on) => run('ailast', () => net.request('room.setAiPicksLast', { on }));
// spectator seats: the host frees one; a spectator takes a free player seat with room.join of this room
const removeSpectator = (playerId) => run(`rs${playerId}`, () => net.request('room.removeSpectator', { playerId }));
const sit = () => run('sit', () => net.request('room.join', { code: room.code }));
@@ -277,6 +308,8 @@ export function RoomScreen() {
<${PingPill} ms=${conn.ping} online=${online} />
<${MicroLabel}>${t('当前延迟')}<//>
</div>
<${Button} variant="secondary" size="sm" icon="chart" class="stats-entry" onClick=${openStats} title=${t('统计数据')} aria-label=${t('统计数据')}>${t('统计')}<//>
<${SettingsButton} class="room-settings" variant="secondary" label=${t('设置')} />
<${GuideButton} class="room-guide" variant="secondary" label=${t('玩法说明')} />
</div>
<div class="topbar__center">
@@ -307,7 +340,10 @@ export function RoomScreen() {
<footer class="room-bar">
<div class="room-bar__left">
<span class="room-bar__label">${t('模拟难度')}<${MicroLabel}>DIFFICULTY<//></span>
<${DifficultyPicker} room=${room} isHost=${facts.isHost} busy=${busy} onPick=${setDifficulty} />
<div class="room-bar__opts">
<${DifficultyPicker} room=${room} isHost=${facts.isHost} busy=${busy} onPick=${setDifficulty} />
<${AiLastToggle} option=${aiLastOption(room, me.playerId)} busy=${busy} onToggle=${setAiLast} />
</div>
</div>
<div class="room-bar__center">
<div class="ready-count" hidden=${!coop}>
+360
View File
@@ -0,0 +1,360 @@
// The local stats viewer (统计数据): this browser's record of the matches the player took part in — see ui/stats.js for
// what is recorded, what counts and how the storage is bounded. A full-screen overlay opened imperatively from the
// title screen / lobby / room (`openStats()`); <StatsHost/> is mounted once by main.js. Ported from PR #323 by
// @2321Robin; reworked for 0.2.2 in the grammar of 干员调配 (top bar with tabs, a note line, a scrolling body).
//
// 总览 – games, wins, win rate, hidden-core reaches / clears, best round, time played; the difficulties; the six
// 评语 (titles) with their counts; what counts and what was left out
// 策略 – per strategy (band): games used / passed (self alive on a winning team) / pass rate
// 战斗累计 – the player's summed combat bookkeeping of the settled matches
// 对局记录 – every stored match, newest first. A settled row re-views that match's settlement screen (the record is
// inverted into an m.result payload for <ResultView/>); a match given up early is marked 中途退出 and,
// when it did not count, 不计入统计
// Everything is 本机数据 (localStorage, `sp.pref.stats`) and the page says so, teammates' names included. 导出 / 导入 (a
// JSON file, merged by record id on the way in) moves it between devices; 清空 resets it after a confirm dialog.
// The host renders nothing until opened and the game data files the replay needs load only then (the lobby alone never
// downloads them): the overview reads config / bands / assets only.
import { useEffect, useMemo, useRef, useState } from '../../vendor/hooks.module.js';
import { DIFFICULTIES, DIFFICULTY_NAMES, DIFFICULTY_COLORS } from '../../../shared/constants.js';
import { html, Icon, MicroLabel, Spinner, Button, confirmDialog } from '../ui/components.js';
import { useGameData, makeLookups, BandIcon, Img } from '../ui/gameComponents.js';
import { data, useData } from '../data.js';
import { fmtNum } from '../ui/gameLogic.js';
import { titleIconUrl } from '../ui/assetUrls.js';
import { useDocClass } from '../ui/device.js';
import { createStore, useStore, selectRoute } from '../store.js';
import { ResultView } from './result.js';
import { toast, toastError } from '../ui/toasts.js';
import {
loadStats, saveStats, emptyStats, importStats, exportStats, aggregateStats, countsTowardStats, selfRowOf, roundsOf,
recordToResult, MAX_RECORDS, FULL_KEEP, IMPORT_MAX_CHARS,
} from '../ui/stats.js';
import { t, N_ } from '../../../shared/i18n.js';
const cx = (...p) => p.flat().filter(Boolean).join(' ');
/** Rows of 对局记录 shown at first and added by each 显示更多. */
export const HISTORY_PAGE = 50;
const statsStore = createStore({ open: false, tab: 'overview' });
/** Open the stats viewer (title screen / lobby / room entry points). */
export function openStats() {
data.load('config');
data.load('bands');
data.load('assets');
statsStore.set({ open: true, tab: 'overview' });
}
export const closeStats = () => statsStore.set({ open: false });
const TABS = [
{ id: 'overview', name: N_('总览'), micro: 'OVERVIEW' },
{ id: 'bands', name: N_('策略'), micro: 'STRATEGIES' },
{ id: 'totals', name: N_('战斗累计'), micro: 'COMBAT TOTALS' },
{ id: 'history', name: N_('对局记录'), micro: 'HISTORY' },
];
const pct = (n, d) => (d > 0 ? `${Math.round((n / d) * 100)}%` : '—');
const dateTime = (ms) => {
if (!ms) return '—';
const d = new Date(ms);
const p = (n) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
};
/** Time played: minutes up to 99, then hours with one decimal. */
function playedText(ms) {
const mins = Math.round((ms || 0) / 60000);
return mins < 100 ? t('{n} 分', { n: fmtNum(mins) }) : t('{n} 小时', { n: (mins / 60).toFixed(1) });
}
const difficultyName = (d) => (DIFFICULTY_NAMES[d] ? t(DIFFICULTY_NAMES[d]) : t('未知'));
/** Save `text` as a download. Silent no-op when the browser refuses downloads. */
function downloadText(filename, text) {
try {
const url = URL.createObjectURL(new Blob([text], { type: 'application/json' }));
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(() => URL.revokeObjectURL(url), 4000);
} catch { /* ignore */ }
}
/** Read a picked file as text (`File.text()`, with a FileReader fallback for older Safari). */
function readFileText(file) {
if (typeof file?.text === 'function') return file.text();
return new Promise((resolve, reject) => {
const fr = new FileReader();
fr.onload = () => resolve(String(fr.result ?? ''));
fr.onerror = () => reject(fr.error || new Error('read failed'));
fr.readAsText(file);
});
}
/** `stronghold-stats-20261008-2130.json` */
function exportFilename(now = new Date()) {
const p = (n) => String(n).padStart(2, '0');
return `stronghold-stats-${now.getFullYear()}${p(now.getMonth() + 1)}${p(now.getDate())}-${p(now.getHours())}${p(now.getMinutes())}.json`;
}
/** One labelled number card. */
function StatCard({ label, micro, value, tone }) {
return html`<div class=${cx('st-card', tone && `st-card--${tone}`)}>
<${MicroLabel} tone=${tone === 'gold' ? 'gold' : 'mint'}>${micro}<//>
<b class="st-card__num num">${value}</b>
<span class="st-card__label">${label}</span>
</div>`;
}
function Section({ title, micro, children, class: cls }) {
return html`<section class=${cx('st-sec', cls)}>
<h3 class="st-sec__h"><span>${title}</span><${MicroLabel}>${micro}<//></h3>
${children}
</section>`;
}
/** 总览: cards, the per-difficulty table, the six titles, and the line about what counts. */
function Overview({ agg, gd }) {
const rows = DIFFICULTIES.filter((d) => agg.byDifficulty[d]).concat(Object.keys(agg.byDifficulty).filter((d) => !DIFFICULTIES.includes(d)));
const titles = (Array.isArray(gd.config?.titles) ? gd.config.titles : []).map((x) => ({ ...x, count: agg.titles[x.id]?.count || 0 }));
return html`<${Section} title=${t('总览')} micro="OVERVIEW">
<div class="st-cards">
<${StatCard} micro="GAMES" value=${fmtNum(agg.count)} label=${t('总局数')} />
<${StatCard} micro="WINS" value=${fmtNum(agg.wins)} label=${t('胜场')} tone="mint" />
<${StatCard} micro="WIN RATE" value=${pct(agg.wins, agg.count)} label=${t('胜率')} tone="gold" />
<${StatCard} micro="HIDDEN CORE" value=${fmtNum(agg.hidden.reached)} label=${t('到达隐秘核心')} />
<${StatCard} micro="HIDDEN CLEAR" value=${fmtNum(agg.hidden.cleared)} label=${t('通关隐秘核心')} />
<${StatCard} micro="BEST ROUND" value=${fmtNum(agg.rounds.max)} label=${t('最远回合')} />
<${StatCard} micro="TIME PLAYED" value=${playedText(agg.duration.totalMs)} label=${t('累计时长')} />
</div>
<p class="st-split"><span>${t('单人')} <b class="num">${fmtNum(agg.byMode.solo)}</b></span><span>${t('同盟')} <b class="num">${fmtNum(agg.byMode.coop)}</b></span></p>
${rows.length ? html`<div class="st-tablewrap"><table class="st-table">
<thead><tr><th>${t('难度')}</th><th class="num">${t('局数')}</th><th class="num">${t('胜场')}</th><th class="num">${t('胜率')}</th><th class="num">${t('隐秘核心通关')}</th></tr></thead>
<tbody>
${rows.map((d) => {
const v = agg.byDifficulty[d];
return html`<tr key=${d}>
<td class="st-diff" style=${`--d-color:${DIFFICULTY_COLORS[d] || 'var(--text-lo)'}`}>${difficultyName(d)}</td>
<td class="num">${fmtNum(v.games)}</td>
<td class="num">${fmtNum(v.wins)}</td>
<td class="num">${pct(v.wins, v.games)}</td>
<td class="num">${fmtNum(v.hiddenCleared)}</td>
</tr>`;
})}
</tbody>
</table></div>` : null}
${titles.length ? html`<h4 class="st-sub">${t('称号')}</h4>
<div class="st-titles">
${titles.map((x) => html`<div key=${x.id} class=${cx('st-title', !x.count && 'is-none')} title=${x.text || ''}>
<${Img} src=${titleIconUrl(gd.m, x.picId)} class="st-title__icon" fallback=${html`<${Icon} name="crown" />`} />
<div class="st-title__text"><span>${x.name}</span><b class="num">${fmtNum(x.count)}</b></div>
</div>`)}
</div>` : null}
<p class="st-rule"><${Icon} name="info" />${t('计入统计:打到结算的对局,以及中途退出前至少清完 1 个回合的对局;更早退出的对局只留在对局记录里。')}${agg.excluded
? html` <b>${t('另有 {n} 局未计入统计', { n: agg.excluded })}</b>` : null}</p>
<//>`;
}
/** 策略: per-band used / passed / pass rate, most used first (the user's headline ask). */
function Bands({ agg, gd }) {
const rows = Object.entries(agg.bands).sort((a, b) => (b[1].games - a[1].games) || (b[1].wins - a[1].wins));
return html`<${Section} title=${t('策略')} micro="STRATEGIES">
${rows.length ? html`<div class="st-tablewrap"><table class="st-table">
<thead><tr><th>${t('策略')}</th><th class="num">${t('使用局数')}</th><th class="num">${t('通过局数')}</th><th class="num">${t('通过率')}</th></tr></thead>
<tbody>
${rows.map(([bandId, v]) => {
const band = gd.band(bandId);
const rate = v.games > 0 ? Math.round((v.wins / v.games) * 100) : 0;
return html`<tr key=${bandId}>
<td><span class="st-band">${band ? html`<${BandIcon} bandId=${band.bandId} size="xs" />${band.name}` : bandId}</span></td>
<td class="num">${fmtNum(v.games)}</td>
<td class="num">${fmtNum(v.wins)}</td>
<td class="num st-rate"><span class="st-bar" aria-hidden="true"><i style=${`width:${rate}%`}></i></span><b>${pct(v.wins, v.games)}</b></td>
</tr>`;
})}
</tbody>
</table></div>` : html`<p class="st-empty">${t('还没有记录 —— 完成一局对局后,这里会开始积累。')}</p>`}
<//>`;
}
const SUM_ROWS = [
['kills', N_('击倒敌人')], ['bossDamage', N_('领袖伤害')], ['dmgDealt', N_('造成伤害')], ['activatedLayers', N_('盟约层数')],
['merges', N_('晋升次数')], ['itemsEquipped', N_('配发装备')], ['gold', N_('消耗资金')], ['perfectRounds', N_('完美作战')],
['refreshes', N_('刷新次数')], ['leaks', N_('未击倒')], ['lpLost', N_('损失生命')],
];
/** 战斗累计: the self player's sums over the settled matches. */
function CombatSums({ agg }) {
const rows = SUM_ROWS.filter(([k]) => Number.isFinite(agg.sums[k]));
return html`<${Section} title=${t('战斗累计')} micro="COMBAT TOTALS">
${rows.length ? html`<div class="st-sums">
${rows.map(([k, label]) => html`<div key=${k} class="st-sum"><span>${t(label)}</span><b class="num">${fmtNum(agg.sums[k])}</b></div>`)}
</div>` : html`<p class="st-empty">${t('还没有记录 —— 完成一局对局后,这里会开始积累。')}</p>`}
<//>`;
}
/** One 对局记录 row: what it was, who won, whether it counted and whether the settlement can be re-viewed. */
function HistoryRow({ rec, gd, onOpen }) {
const self = selfRowOf(rec);
const quit = rec.end === 'quit';
const counted = countsTowardStats(rec);
const won = self ? !!self.victory : !!rec.victory;
const band = self?.bandId ? gd.band(self.bandId) : null;
const rounds = roundsOf(rec);
const known = Number.isFinite(self?.roundsPassed) || Number.isFinite(rec.roundsPassed);
const view = !quit && !rec.compact; // a quit has nothing settled; a compacted record no rows left
const open = () => onOpen(rec);
return html`<tr class=${cx(quit ? 'is-quit' : won ? 'is-win' : 'is-lose', !counted && 'is-excluded', view && 'is-clickable')}
title=${view ? t('点击查看该局结算') : !counted ? t('没有清完第一个回合就退出,不计入统计') : quit ? t('中途退出的对局没有结算可看') : t('较早的对局只保留汇总,不能回看结算')}
tabIndex=${view ? 0 : undefined}
onClick=${view ? open : undefined} onKeyDown=${view ? (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); open(); } } : undefined}>
<td class="st-t">${dateTime(rec.t)}</td>
<td>${difficultyName(rec.difficulty)}</td>
<td>${rec.roomMode === 'solo' ? t('单人') : rec.roomMode === 'coop' ? t('同盟') : '—'}</td>
<td><span class="st-band">${band ? html`<${BandIcon} bandId=${band.bandId} size="xs" />${band.name}` : self?.bandId || '—'}</span></td>
<td class="st-res">${quit ? html`<span class="t-quit">${t('中途退出')}</span>` : html`<span class=${won ? 't-win' : 't-lose'}>${won ? t('胜') : t('负')}</span>`}
${!counted ? html`<em class="st-chip">${t('不计入统计')}</em>` : null}</td>
<td class="num">${known ? fmtNum(rounds) : '—'}</td>
<td class="st-go" aria-hidden="true">${view ? html`<${Icon} name="chevronRight" />` : null}</td>
</tr>`;
}
/** 对局记录: every stored match, newest first, HISTORY_PAGE rows at a time. */
function History({ records, gd, onOpen }) {
const [shown, setShown] = useState(HISTORY_PAGE);
const rows = records.slice(0, shown);
return html`<${Section} title=${t('对局记录')} micro=${`HISTORY · ${records.length}`}>
${rows.length ? html`<div class="st-tablewrap"><table class="st-table st-table--history">
<thead><tr><th>${t('时间')}</th><th>${t('难度')}</th><th>${t('模式')}</th><th>${t('策略')}</th><th>${t('结果')}</th><th class="num">${t('回合')}</th><th aria-hidden="true"></th></tr></thead>
<tbody>${rows.map((r) => html`<${HistoryRow} key=${r.id} rec=${r} gd=${gd} onOpen=${onOpen} />`)}</tbody>
</table></div>
${records.length > shown ? html`<div class="st-more"><${Button} size="sm" variant="secondary" onClick=${() => setShown(shown + HISTORY_PAGE)}>${t('显示更多(还有 {n} 局)', { n: records.length - shown })}<//></div>` : null}
<p class="st-hint">${t('点击一行,回看该局结算;中途退出的对局只留记录。')} ${t('最多保留最近 {max} 局,只有最新的 {full} 局能回看结算。', { max: MAX_RECORDS, full: FULL_KEEP })}</p>`
: html`<p class="st-empty">${t('还没有记录 —— 完成一局对局后,这里会开始积累。')}</p>`}
<//>`;
}
/** A stored match re-viewed as its settlement screen: the full game data is loaded now (the lobby alone never loads it). */
function Replay({ rec, onBack }) {
const gd = useGameData();
const res = useMemo(() => recordToResult(rec), [rec]);
if (!gd.ready) return html`<div class="st-loading"><${Spinner} size="md" /></div>`;
return html`<${ResultView} res=${res} pub=${null} myId=${rec.selfId} backLabel=${t('返回统计')} onBack=${onBack} quiet=${true} />`;
}
/** The overlay, rendered while open (its data hooks run only then). */
function StatsScreen({ tab }) {
const ready = useData('config', 'bands', 'assets');
const gd = useMemo(() => makeLookups(ready), [ready, data.locale()]);
const [stats, setStats] = useState(() => loadStats());
const [replay, setReplay] = useState(null);
const boxRef = useRef(null);
const fileRef = useRef(null);
const replayRef = useRef(null);
replayRef.current = replay;
const inMatch = useStore((s) => selectRoute(s) === 'game');
useDocClass('sp-stats-open');
// a match starting (the host pressed 开始) takes the player to its briefing: the overlay must not sit on top of it
useEffect(() => { if (inMatch) closeStats(); }, [inMatch]);
useEffect(() => {
const onKey = (e) => {
if (e.key !== 'Escape' || document.querySelector('.modal')) return; // a confirm dialog on top takes the Esc
e.preventDefault(); e.stopPropagation(); e.stopImmediatePropagation();
if (replayRef.current) setReplay(null); else closeStats();
};
window.addEventListener('keydown', onKey, true);
const timer = setTimeout(() => boxRef.current?.focus?.(), 30);
return () => { window.removeEventListener('keydown', onKey, true); clearTimeout(timer); };
}, []);
const agg = useMemo(() => aggregateStats(stats.records), [stats]);
const reload = () => setStats(loadStats());
const doExport = () => {
try {
const payload = exportStats(stats);
downloadText(exportFilename(), JSON.stringify(payload));
toast(t('已导出 {n} 条对局记录', { n: payload.records.length }), 'success');
} catch (err) {
toastError(err);
}
};
const doFile = async (e) => {
const file = e.currentTarget.files?.[0];
e.currentTarget.value = ''; // allow re-picking the same file
if (!file) return;
if (file.size > IMPORT_MAX_CHARS) { toast(t('文件过大,无法导入'), 'error'); return; }
try {
const raw = JSON.parse(await readFileText(file));
const { stats: merged, added, skipped, dropped } = importStats(raw, loadStats());
if (added && !saveStats(merged)) { toast(t('无法写入本机存储,可能已满'), 'error'); return; }
reload();
const tail = (skipped ? t('(跳过重复 {skipped} 条)', { skipped }) : '') + (dropped ? t('({n} 条无法读取)', { n: dropped }) : '');
toast(added ? t('已导入 {added} 条记录{tail}', { added, tail }) : t('没有新记录(全部与现有数据重复)'), 'success');
} catch (err) {
toast(err?.code === 'stats-newer-version' ? t('导入失败:这份文件由更新版本的客户端导出,请先升级客户端') : t('导入失败:不是有效的统计导出文件'), 'error');
}
};
const doClear = async () => {
const ok = await confirmDialog({
title: t('清空统计数据'), micro: 'CLEAR STATISTICS', danger: true, okText: t('清空'),
text: t('将删除本机保存的 {n} 条对局记录,无法恢复。', { n: stats.records.length }),
});
if (!ok) return;
if (!saveStats(emptyStats())) { try { globalThis.localStorage?.removeItem('sp.pref.stats'); } catch { /* ignore */ } }
reload();
toast(t('已清空本机统计数据'), 'success');
};
if (replay) return html`<div class="st st--replay" ref=${boxRef} tabindex="-1" role="dialog" aria-modal="true" aria-label=${t('统计数据')}>
<${Replay} rec=${replay} onBack=${() => setReplay(null)} />
</div>`;
const tabInfo = TABS.find((x) => x.id === tab) || TABS[0];
const empty = !stats.records.length;
return html`<div class="st" role="dialog" aria-modal="true" aria-label=${t('统计数据')} tabindex="-1" ref=${boxRef}>
<div class="st__bg" aria-hidden="true"></div>
<header class="st-top">
<div class="st-top__left">
<${Button} variant="ghost" size="md" icon="chevronLeft" class="st-back" onClick=${closeStats} aria-label=${t('返回')} title=${t('返回 (Esc)')}>${t('返回')}<//>
</div>
<div class="st-top__center">
<${MicroLabel} tone="mint">STATISTICS // ${tabInfo.micro}<//>
<div class="st-tabs" role="tablist" aria-label=${t('统计数据')}>
${TABS.map((x) => html`<button key=${x.id} type="button" role="tab" aria-selected=${x.id === tab ? 'true' : 'false'} data-tab=${x.id}
class=${cx('st-tab', x.id === tab && 'is-on')} onClick=${() => statsStore.set({ tab: x.id })}>${t(x.name)}</button>`)}
</div>
</div>
<div class="st-top__right">
<${Button} variant="ghost" size="sm" data-testid="stats-export" disabled=${empty || stats.newer} onClick=${doExport} title=${t('导出为文件(可在别的设备上导入)')}>${t('导出')}<//>
<${Button} variant="ghost" size="sm" data-testid="stats-import" disabled=${stats.newer} onClick=${() => fileRef.current?.click()} title=${t('从导出的文件导入(与现有记录合并)')}>${t('导入')}<//>
<input ref=${fileRef} type="file" accept="application/json,.json" hidden onChange=${doFile} />
<${Button} variant="secondary" size="sm" data-testid="stats-clear" disabled=${empty || stats.newer} onClick=${doClear} title=${t('清空本机的统计数据')}>${t('清空')}<//>
</div>
</header>
<p class="st-note"><${Icon} name="info" />${t('本机数据:只保存在这个浏览器里,不会上传到服务器;同盟对局里队友的代号也会存在本机(导出文件里也有)。')}</p>
${stats.damaged ? html`<p class="st-note is-warn"><${Icon} name="warn" />${t('本机保存的统计数据无法读取,已另存为备份,从现在起重新记录。')}</p>` : null}
${stats.dropped ? html`<p class="st-note is-warn"><${Icon} name="warn" />${t('有 {n} 条记录已损坏,已忽略。', { n: stats.dropped })}</p>` : null}
<main class="st-body" data-tab=${tab}>
${!ready ? html`<div class="st-loading"><${Spinner} size="md" /></div>`
: stats.newer ? html`<p class="st-empty">${t('检测到更新版本的统计数据,请先升级客户端再查看,以免覆盖数据。')}</p>`
: empty ? html`<p class="st-empty">${t('还没有记录 —— 完成一局对局后,这里会开始积累。')}</p>`
: tab === 'bands' ? html`<${Bands} agg=${agg} gd=${gd} />`
: tab === 'totals' ? html`<${CombatSums} agg=${agg} />`
: tab === 'history' ? html`<${History} records=${stats.records} gd=${gd} onOpen=${setReplay} />`
: html`<${Overview} agg=${agg} gd=${gd} />`}
</main>
</div>`;
}
/** Global stats viewer host — mounted once by main.js; renders nothing (and loads nothing) until opened. */
export function StatsHost() {
const { open, tab } = useStore((s) => s, Object.is, statsStore);
if (!open) return null;
return html`<${StatsScreen} tab=${tab} />`;
}
+5 -1
View File
@@ -14,6 +14,7 @@ import { useMemo, useState } from '../../vendor/hooks.module.js';
import { NAME_MAX_LEN, APP_VERSION, DEV_BUILD } from '../../../shared/constants.js';
import { html, Button, Icon, MicroLabel, TextField, PingPill } from '../ui/components.js';
import { GuideButton } from '../ui/guide.js';
import { openStats } from './stats.js';
import { toast } from '../ui/toasts.js';
import { net, identity } from '../net.js';
import { store, useStore, shallowEqual } from '../store.js';
@@ -245,7 +246,10 @@ export function TitleScreen() {
</div>
<div class="title-corner title-corner--tr">
<div>
<${LangToggle} class="title-lang" />
<div class="title-corner__tools">
<${Button} variant="ghost" size="sm" icon="chart" class="title-stats" onClick=${openStats} title=${t('统计数据')}>${t('统计')}<//>
<${LangToggle} class="title-lang" />
</div>
<${MicroLabel} tone="hi">TARGET POINT<//><br /><${MicroLabel}>STRONGHOLD PROTOCOL<//>
</div>
</div>
+3 -1
View File
@@ -17,7 +17,9 @@
// already did.
// Watching a teammate (DESIGN §20.15, ui/watchBonds.js) the strip and the popup show THAT player's bonds and layers:
// the strip carries an amber "👁 name" tag (`owner`, the observing pill's spelling, research 09 §3.1) and amber rings,
// the popup a "👁 name 的盟约" line; its member list reads the teammate's operators on the field (no hand: never sent).
// the popup a "👁 name 的盟约" line; its member list reads the teammate's operators on the field — in prep their bench
// too, counted like their own popup counts it (ui/watchBonds.js ownerBoard: hand / temp by the scout's `area`, GitHub
// #385); a battle sends no bench.
// 0.2.0 补位: a member the player fields as its stand-in (the own m.private.standIns; a teammate's unit that says so —
// UnitInfo `standInFor`) is drawn as the stand-in — avatar, name, a small 「替补」 mark on the thumbnail — and its row
// opens the stand-in's card (gameLogic memberStandIn; the owner's recall of the official mode, 2026-10-06).
+10 -9
View File
@@ -127,7 +127,7 @@ export function armedCard(armed, sp, o) {
/**
* The overlay: keeps the two-tap selection and renders ChoiceView. `onPick(idx)` sends the confirmed card (g.choice).
* @param {{ pub:any, sp:any, myId:string, solo:boolean, onPick:(idx:number)=>void, busyIdx?:number|null, total?:number|null }} props
* @param {{ pub:any, sp:any, myId:string, solo:boolean, personal?:boolean, onPick:(idx:number)=>void, busyIdx?:number|null, total?:number|null }} props
*/
export function ChoiceOverlay(props) {
const { sp, myId, solo, busyIdx = null, onPick } = props;
@@ -154,10 +154,10 @@ export function ChoiceOverlay(props) {
/**
* The overlay's view (pure: no hooks — test/ui renders it as a function).
* @param {{ pub:any, sp:any, myId:string, solo:boolean, busyIdx?:number|null, total?:number|null, armed?:number|null,
* @param {{ pub:any, sp:any, myId:string, solo:boolean, personal?:boolean, busyIdx?:number|null, total?:number|null, armed?:number|null,
* onTap?:(idx:number)=>void, onConfirm?:()=>void, onDisarm?:()=>void }} props
*/
export function ChoiceView({ pub, sp, myId, solo, busyIdx = null, total = null, armed = null, onTap = () => {}, onConfirm = () => {}, onDisarm = () => {} }) {
export function ChoiceView({ pub, sp, myId, solo, personal = false, busyIdx = null, total = null, armed = null, onTap = () => {}, onConfirm = () => {}, onDisarm = () => {} }) {
if (!sp) return null;
const fam = data.get('choices')?.families?.[sp.family] || null;
const rawFam = data.getRaw('choices')?.families?.[sp.family] || null;
@@ -166,8 +166,8 @@ export function ChoiceView({ pub, sp, myId, solo, busyIdx = null, total = null,
const mine = sp.pickOf.get(myId);
const turnName = players.get(sp.turnPid)?.name || t('队友');
const special = /_s$/.test(String(sp.family || ''));
const order = solo ? [] : sp.order;
const timed = !solo && !sp.untimed;
const order = personal || solo ? [] : sp.order;
const timed = personal ? pub?.deadline > 0 : !solo && !sp.untimed;
const armedCardRec = armed != null ? sp.cards.find((c) => c && c.idx === armed) : null;
const armedName = armedCardRec ? resolveSpCard(armedCardRec, sp.family).name : null;
// a press anywhere but a card or the confirm button drops the selection
@@ -177,17 +177,18 @@ export function ChoiceView({ pub, sp, myId, solo, busyIdx = null, total = null,
if (t && typeof t.closest === 'function' && t.closest('.spcard, .spov__confirm')) return;
onDisarm();
};
return html`<div class=${cx('spov', armed != null && 'has-armed')} role="dialog" aria-label=${t('机变阶段')} onPointerDown=${onDown}>
return html`<div class=${cx('spov', armed != null && 'has-armed')} role="dialog" aria-label=${personal ? t('教鞭选择') : t('机变阶段')} onPointerDown=${onDown}>
<div class="spov__veil" aria-hidden="true"></div>
<div class="spov__inner">
<header class="spov__head">
<div class="spov__titles">
<${MicroLabel} tone="mint">${t('CONTINGENCY // 机变阶段')}</${MicroLabel}>
<${MicroLabel} tone="mint">${personal ? t('教鞭选择') : t('CONTINGENCY // 机变阶段')}</${MicroLabel}>
<h2 class=${cx('spov__title', special && 'is-special')}>${sentText(sp.name, rawFam?.name, fam?.name) || fam?.name || t('机变')}<span class="spov__bar">|</span><span class="spov__desc"><${RichText} text=${sentText(sp.desc, rawFam?.desc, fam?.desc) || fam?.desc || t('选择一项')} /></span></h2>
<p class="spov__sub">${timed ? t('倒计时结束后仍未选定将自动分配') : t('选择一项(无时间限制)')}${mine == null && myTurn ? t(' · 点击卡牌选中,再次点击确认') : ''}</p>
<p class="spov__sub">${timed ? (personal ? t('休整期结束时未选择将自动选定') : t('倒计时结束后仍未选定将自动分配')) : t('选择一项(无时间限制)')}${mine == null && myTurn ? t(' · 点击卡牌选中,再次点击确认') : ''}</p>
</div>
<div class="spov__turn">
${mine != null ? html`<span class="spov__turntxt is-done"><${Icon} name="check" />${t('已完成选择')}</span>`
${personal ? html`<span class="spov__turntxt is-mine">${t('从候选中选择一项战术特训')}</span>`
: mine != null ? html`<span class="spov__turntxt is-done"><${Icon} name="check" />${t('已完成选择')}</span>`
: myTurn ? html`<span class="spov__turntxt is-mine">${t('当前轮到你决策')}</span>`
: html`<span class="spov__turntxt">${t('{turnName} 正在决策…', { turnName })}<${Icon} name="hourglass" /></span>`}
${armed != null ? html`<${Button} variant="primary" size="lg" icon="check" class="spov__confirm" data-testid="sp-confirm"
+1 -1
View File
@@ -25,7 +25,7 @@ function ProgressList({ list }) {
<b>${p.name}</b>
${p.done
? html`<span class="chud__prog__ok" aria-label=${t('作战结束')}>✓</span>`
: html`<span class="num">${p.killed != null && p.total != null ? `${p.killed}/${p.total}` : '•••'}</span>`}
: html`<span class="num">${(p.resolved ?? p.killed) != null && p.total != null ? `${p.resolved ?? p.killed}/${p.total}` : '•••'}</span>`}
</span>`)}
</div>`;
}
+35 -11
View File
@@ -55,6 +55,8 @@ export const ICONS = {
users: { d: 'M9 4a3.5 3.5 0 1 1 0 7 3.5 3.5 0 0 1 0-7zM2 20a7 6.5 0 0 1 14 0zM16.5 5a3 3 0 1 1 0 6 3 3 0 0 1 0-6zm.9 8.1A6.5 6 0 0 1 22.5 20H18a8.6 8 0 0 0-2.4-6.1 6 6 0 0 1 1.8-.8z' },
rook: { d: 'M5 3h3v2h2V3h4v2h2V3h3v5l-2 2v7l2 2v2H5v-2l2-2v-7L5 8z' },
signal: { d: 'M2 17h3v4H2zm6-4h3v8H8zm6-4h3v12h-3zm6-4h3v16h-3z' },
// 统计 (the stats page's entry): three columns on a baseline — apart from `signal`, the ping bars
chart: { d: 'M4 12h4v7H4zm6-6h4v13h-4zm6 3h4v10h-4zM3 20h18v2H3z' },
refresh: { d: 'M12 4a8 8 0 0 1 7.4 5H17v2h6V5h-2v2.3A10 10 0 0 0 2 12h2a8 8 0 0 1 8-8zm0 16a8 8 0 0 1-7.4-5H7v-2H1v6h2v-2.3A10 10 0 0 0 22 12h-2a8 8 0 0 1-8 8z' },
snow: { d: 'M11 2h2v4.2l2.3-2.3 1.4 1.4-3.7 3.7v2h2l3.7-3.7 1.4 1.4-2.3 2.3H22v2h-4.2l2.3 2.3-1.4 1.4-3.7-3.7h-2v2l3.7 3.7-1.4 1.4-2.3-2.3V22h-2v-4.2l-2.3 2.3-1.4-1.4 3.7-3.7v-2H9l-3.7 3.7-1.4-1.4L6.2 13H2v-2h4.2L3.9 8.7l1.4-1.4L9 11h2V9L7.3 5.3l1.4-1.4L11 6.2z' },
chevronRight: { d: 'M8.6 5 7.2 6.4 12.8 12l-5.6 5.6L8.6 19l7-7z' },
@@ -386,7 +388,7 @@ export function Countdown({ deadline, seconds, total, warnAt = 10, label = 'COUN
// ---- Modal & dialogs ---------------------------------------------------------------------------
const modalStack = []; // open modals, topmost last: only the topmost reacts to Escape
const modalStack = []; // open modals, topmost last: only the topmost handles keyboard focus / Escape
/**
* Modal dialog (declarative). Esc / backdrop click call onClose.
@@ -397,24 +399,46 @@ export function Modal({ open, title, micro, tone = 'mint', onClose, actions, wid
const boxRef = useRef(null);
const closeRef = useRef(onClose);
closeRef.current = onClose;
useEffect(() => {
useLayoutEffect(() => {
if (!open) return undefined;
const token = {};
modalStack.push(token);
const prevFocus = typeof document !== 'undefined' ? document.activeElement : null;
const box = boxRef.current;
// The independent guide can open above SettingsModal (its z-index is higher than --z-modal).
const topmost = () => modalStack[modalStack.length - 1] === token && !document.querySelector('.guide');
const available = (el) => !el.matches(':disabled') && !el.closest('[inert]') && el.getClientRects().length > 0
&& getComputedStyle(el).visibility !== 'hidden';
const tabbable = () => [...box.querySelectorAll('button, [href], input, select, textarea, [tabindex], summary')]
.filter((el) => el.tabIndex >= 0 && available(el));
const focusFirst = () => (tabbable()[0] || box).focus();
const onKey = (e) => {
if (e.key !== 'Escape' || modalStack[modalStack.length - 1] !== token || !closeRef.current) return;
e.stopPropagation();
closeRef.current();
if (!topmost()) return;
if (e.key === 'Escape' && closeRef.current) {
e.stopPropagation();
closeRef.current();
} else if (e.key === 'Tab') {
const els = tabbable();
const first = els[0] || box, last = els[els.length - 1] || box;
if (!els.includes(document.activeElement) || document.activeElement === (e.shiftKey ? first : last)) {
e.preventDefault();
(e.shiftKey ? last : first).focus();
}
}
};
const onFocus = (e) => {
if (topmost() && !box.contains(e.target)) focusFirst();
};
window.addEventListener('keydown', onKey);
const t = setTimeout(() => {
const el = boxRef.current?.querySelector('[data-autofocus]') || boxRef.current?.querySelector('button, input');
el?.focus?.();
}, 30);
window.addEventListener('focusin', onFocus);
const autofocus = box.querySelector('[data-autofocus]');
if (topmost()) {
if (autofocus && available(autofocus)) autofocus.focus();
else focusFirst();
}
return () => {
window.removeEventListener('keydown', onKey);
clearTimeout(t);
window.removeEventListener('focusin', onFocus);
const i = modalStack.indexOf(token);
if (i >= 0) modalStack.splice(i, 1);
prevFocus?.focus?.();
@@ -423,7 +447,7 @@ export function Modal({ open, title, micro, tone = 'mint', onClose, actions, wid
if (!open) return null;
return html`<div class="modal" role="presentation"
onMouseDown=${(e) => { if (closeOnBackdrop && e.target === e.currentTarget && onClose) onClose(); }}>
<div ref=${boxRef} class=${cx('modal__box', 'brackets', `modal__box--${tone}`, cls)} role="dialog" aria-modal="true"
<div ref=${boxRef} class=${cx('modal__box', 'brackets', `modal__box--${tone}`, cls)} role="dialog" aria-modal="true" tabindex="-1"
style=${width ? `width:${width}` : undefined}>
<div class="modal__stripe" aria-hidden="true"></div>
${title || micro ? html`<header class="modal__head">
Loaded 100 of 447 files, more files were not shown because too many files have changed in this diff. Show more