Merge branch 'master' into fix/spine-start-anim
No files matched your search
@@ -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://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
|
||||
@@ -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://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
|
||||
@@ -0,0 +1,55 @@
|
||||
> [!IMPORTANT]
|
||||
> 选择一种改动类型,并填写以下内容。没有相关内容填“无”,不适用填“不适用”。
|
||||
|
||||
## 改动类型
|
||||
|
||||
- [ ] fix: 修复
|
||||
- [ ] feature: 功能
|
||||
- [ ] docs: 文档
|
||||
- [ ] refactor: 重构
|
||||
- [ ] test: 测试
|
||||
- [ ] chore: 维护
|
||||
|
||||
## 改动说明
|
||||
|
||||
> [!NOTE]
|
||||
> 写清问题、原因和范围。
|
||||
|
||||
- 改动说明:
|
||||
- 期望效果:
|
||||
- 关联 Issue / PR:
|
||||
|
||||
## 依据与假设
|
||||
|
||||
> [!NOTE]
|
||||
> 涉及规则时附官方数据字段、PRTS 或实测;未确认细节及处理方式写入 [ASSUMED],代码中同步标注。
|
||||
|
||||
- 来源依据:
|
||||
- [ASSUMED]:
|
||||
|
||||
## 验证结果
|
||||
|
||||
> [!NOTE]
|
||||
> 运行相关测试、npm run ci 和 npm run golden,如实记录结果及未运行或跳过原因。Golden 变化说明场景及原因;重构不得改变结果,预期结果有意变化时运行 npm run golden:update 并同提交。界面改动附截图。
|
||||
|
||||
- 验证方法:
|
||||
- 相关测试:
|
||||
- npm run ci:
|
||||
- npm run golden:
|
||||
- Golden 变化:
|
||||
- 截图/录屏:
|
||||
|
||||
## 适用性说明
|
||||
|
||||
- 不适用项:
|
||||
- 原因:
|
||||
|
||||
## 检查清单
|
||||
|
||||
> [!IMPORTANT]
|
||||
> 最后一步:回顾回答,仅勾选已完成的项目。
|
||||
|
||||
- [ ] 本 PR 只处理一个主题
|
||||
- [ ] 已运行相关测试、npm run ci 和 npm run golden,并如实填写结果
|
||||
- [ ] 涉及规则时,已同步文档并运行文档检查
|
||||
- [ ] 涉及干员 kit 时,仅一名干员,并附上逐项勾选的 Fidelity checklist
|
||||
@@ -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`.
|
||||
@@ -1,5 +1,170 @@
|
||||
# 更新记录
|
||||
|
||||
## 0.2.4 — 2026-10-11
|
||||
|
||||
### 规则(按官方)
|
||||
- 共享卡池和装备库存按官方规则补齐(依据路标档案馆的实测视频和公开资料):单人和同盟的干员份数不同(同盟为 2 倍,单人部分低阶干员例外);装备也有全队共享的库存,部分特殊装备例外;普通 / 进阶装备分别占 1 / 2 份,商店展示不占份数,购买时再检查一次,机变卡用尽时不能再选,消耗、销毁、出售和淘汰都会归还份数。商业包装方案是同时最多持有 2 份,不是整局限购 2 次;拟态物质按符合条件的干员身份抽取,整备也不会超过库存。
|
||||
- 突袭成员复活后若没有阻挡敌人,会按新的优先级重新选一次落点(嘲讽优先,其次离蓝门最近);脚下有敌人时留在原地,普通的轮询和再部署仍按原规则。联机首领 / 隐秘首领战中,突袭可以跳到空半场的正常部署格;手牌区、临时区、单人模式和单人联防的空半场仍不行(#475)。
|
||||
- 浊心斯卡蒂一技能和坚守同时生效时,伤害不再互相反弹导致战斗卡住,两边分走的伤害也与原伤害守恒(PR #464,感谢 @HappyTanuki)。
|
||||
- 哈洛德二技能在友方只有元素损伤时也能开启(PR #459);涤火杰西卡的盾牌只能放在相邻四格(PR #460);家族徽章在解除隐匿后的第一笔伤害触发(PR #461);松果二技能每次重新部署都重新成长(PR #462)。感谢 @Xinglan233。
|
||||
- 克莱门莎二技能溅射按更新后的资料修正(半径 1.1、100% 攻击力),扩展范围内只有可选飞行目标时也会自动开启(#467);白面鸮复制歌蕾蒂娅 / 余时,准备期赠送不再要求三人条件(#473,按实机视频,只限这两个特例)。
|
||||
- 琳琅诗怀雅的香槟炸弹不会再被友方的光环、鼓舞或治疗选中(PR #468 / #476,感谢 @Siornya)。
|
||||
- 首领战超时扣血的额外宽限只留给有 AI 队友的队伍。
|
||||
|
||||
### 界面
|
||||
- 琳琅诗怀雅技能自带的金币显示在血条下方,显示余额和上限(PR #450,感谢 @IceCodeNew);外勤医疗策略在准备阶段显示预备医疗(PR #451,感谢 @YinqiMargatroid);香槟炸弹可使用原皮模型。
|
||||
- 大厅标题栏重新设计(PR #471,感谢 @YuriRestia);日语界面改用官方日服用语(PR #448,感谢 @scutkane);大字号下结算页的返回按钮不再被挤出屏幕。
|
||||
- 自选编队的详情与干员调配共用同一套数值、技能和潜能练度显示;自选 / 补位干员的伤害公告显示正确名称。
|
||||
- 渲染资源加载失败后会自动重试并可恢复 3D 画面,不会重开正在进行的战斗;主动选择的平面模式会保留。战斗中重新进入同一场战斗时,箱子等装置仍可以点开说明。
|
||||
|
||||
## 0.2.3 — 2026-10-10
|
||||
|
||||
### 新内容
|
||||
- 自选编队新增六星克莱门莎(三个技能、两项天赋、潜能与练度);黍新增模组「好时节」GUA-Y,乌尔比安新增「前路」CRU-Y。数值来自国服 2.7.81 官方表,机制对照 PRTS;资料尚未确认的碰撞边界、溅射和炮击节拍按推断实现并写在[设计记录 §28](docs/history/0.2.3.md),以后按资料校正。
|
||||
|
||||
### 新功能
|
||||
- 设置新增四档文字大小,较长的译文和按钮会自动换行,棋盘大小不变(#435、PR #446,感谢 @7eLaryn)。
|
||||
- 干员调配里可以逐个干员选择中文、日语或跟随全局语音,偏好只存在本机(#436)。
|
||||
- 浏览器支持时可以把游戏添加到桌面,图标沿用薄荷绿城堡并精修;不做离线缓存(PR #413,感谢 @Cromemadnd)。
|
||||
- 开局确认页可以由房主发起重刷,全体在线真人一致同意后重新随机开局,保留房间与个人配置;这是复刻版的便捷功能(#77、PR #408,感谢 @Siornya)。
|
||||
- 标题页和大厅新增「恢复本机对局」:关掉的窗口可以回到原来的身份和房间,正在使用、刷新或启动中的窗口不会被另一个窗口抢走席位(#431)。
|
||||
|
||||
### 规则(按官方)
|
||||
- 蕾缪安可以用范围外的通缉目标触发技能,并修正通缉的首次计时、多人来源和撤退清理(#428,感谢 @CurlyZhu);死芒一、二技能可以由自身召唤物阻挡的范围外敌人触发,两者都等到正常攻击时点再开技(#440、#438、#439)。
|
||||
- 塞雷娅、黍的一技能在没有敌人时也按攻击间隔治疗半血及以下的友方;凯尔希·思衡托二技能可以由扩大范围内的敌人触发(#406,感谢 @ChengCC0101、@shadiaojumao-hyb)。
|
||||
- 赤刃明霄陈三技能在攻击范围内只有飞行敌人时也会自动开启。
|
||||
- 远牙授予的特质在傀儡师切换本体 / 替身时也会叠层,与部署共用原上限(#415、PR #429,感谢 @rosexxxp、@CXUtk)。
|
||||
- 致死的锤击 / 水手击晕不会波及不屈等同一击里重新部署的干员;吟游者和浊心斯卡蒂被晕眩时持续回复不中断;崇高牺牲在盟约未激活时不再给阿戈尔加层;假想敌「铳」的减伤条件把另一把铳也算作同伴;黄沙罗盘返还的技力不受阻回限制。
|
||||
- 合作模式的开局策略每人选择 50 秒(含跳过),单人和只有一名真人的对局仍不计时。
|
||||
- 克莱门莎二技能运送途中,死亡、隐匿、不可运送或无路可走的敌人会立刻让出舱位和重量。
|
||||
|
||||
### 界面
|
||||
- 被推开、拉拽的敌人在滑行中保留原朝向(#418、PR #427,感谢 @YinqiMargatroid);倒地、冻结的静止模型不再重复重画,再部署倒计时共用数字纹理(PR #434,感谢 @Cloudnyco)。
|
||||
- 补齐司霆惊蛰、赤刃明霄陈等干员技能期间的攻击与命中音,各技能音效保留自己的音量和播放概率(PR #410,感谢 @Convey123)。
|
||||
- 模型加载较慢时不再丢失入场动画,按经过的时间接续(PR #416 提示的问题,感谢 @Cromemadnd)。
|
||||
- 弹窗里复制诊断信息在旧浏览器上也能复制,复制后焦点回到原处(#433,感谢 @RushFTK);渲染器启动失败时的提示改为说明原因并提供重新加载。
|
||||
|
||||
### 人机
|
||||
- AI 队友购买高阶卡时减少与队友开局盟约抢卡(PR #407,感谢 @IceCodeNew);有真人参与时会发送有限的表情回应,可用 `SP_BOT_EMOTES=0` 关闭(PR #444,感谢 @DDDarkstar)。
|
||||
- 修复「余音」与刺玫、泡泡的反击在高频次生命下递归超过上限的问题。
|
||||
|
||||
### 工具与开发
|
||||
- 素材下载会报告略去的清单条目,并新增可选的严格模式(PR #421,感谢 @LimitlessHPPK);润色韩语文本(PR #414,感谢 @D-Roote);补充中文 PR 模板(PR #432,感谢 @Xinglan233)。
|
||||
|
||||
## 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))。
|
||||
|
||||
@@ -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).
|
||||
@@ -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.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
《明日方舟》季节性自走棋塔防玩法「卫戍协议:盟约」的**非官方同人复刻**:浏览器即开即玩,单人或 1–4 人联机合作。
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
@@ -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.4:按官方补齐共享卡池与装备库存,修正突袭复活选敌、克莱门莎、浊心斯卡蒂与坚守等规则,联机首领战突袭可以跳到空半场,并修复了 0.2.3 发布后玩家和 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,12 +113,13 @@ 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` 从不 |
|
||||
| `DEBUG` | 空 | 设为任意值输出详细日志 |
|
||||
| `SP_NO_BROWSER` | 空 | 设为 `1` 时启动脚本不自动打开浏览器 |
|
||||
| `SP_BOT_EMOTES` | 空 | AI 队友的对局表情回应(战斗、整备、合成、联防、收礼时即刻的 `m.emote` 广播);设为 `0` 时整体关闭 |
|
||||
|
||||
设置方式:macOS / Linux `PORT=8080 npm start`;PowerShell `$env:PORT=8080; npm start`;cmd `set "PORT=8080" && npm start`。健康检查:`GET /healthz`。
|
||||
|
||||
@@ -153,7 +154,7 @@ npm start # 启动服务器:http://localhost:3000
|
||||
| 操作 | 方法 |
|
||||
|---|---|
|
||||
| 购买 / 升级调度中心 / 机变选卡 | 点一次选中,再点一次确认(`D` 升级) |
|
||||
| 部署 / 移动干员 | 从整备区拖到棋盘格 → 出现方向轮盘 → 往上 / 右 / 下 / 左滑动选择朝向后松手;松在中心或点「✕ 点击取消」取消。拖动时模型在指针 / 手指下,指针所在的格子就是落点 |
|
||||
| 部署 / 移动干员 | 从整备区拖到棋盘格 → 出现方向轮盘 → 往上 / 右 / 下 / 左滑动选择朝向后松手;松在中心或点「✕ 点击取消」取消。拖动时指针 / 手指所在的格子就是落点,能放下时模型直接站在这一格上(不能放下时跟在指针下) |
|
||||
| 调整朝向 | 把干员拖回它自己的格子,再选方向 |
|
||||
| 出售 / 撤退 / 销毁装备 | 点击单位所在的格子 → 底部按钮「出售 +1」「撤退」;也可以把棋盘上的干员拖回整备区撤退。整备区里的装备与法术只能「销毁」,已配发的装备锁定在干员身上(干员出售或合成精锐时退回整备区) |
|
||||
| 装备 | 把装备拖到干员所在的格子上(每人 2 件;满了会弹出替换窗口,被替换的一件会被销毁);法术拖到地块上并选方向 |
|
||||
@@ -163,6 +164,7 @@ npm start # 启动服务器:http://localhost:3000
|
||||
| 暂停(独立模拟) | 作战中(含最终攻势 / 隐秘核心)点顶栏的「暂停」或按 `Space`,再点「继续作战」(或 `Space`)继续;同盟模拟的作战不能暂停 |
|
||||
| 表情 | 左下角「交流」,左右滑动(或方向键)换主题,冷却 1 秒 |
|
||||
| 观战 | 自己的作战结束后(或休整期)点左侧队友头像 →「前往查看」;不参战的朋友可以在大厅输入同盟密钥点「观战」(每个同盟最多 2 名观战者,本作新增) |
|
||||
| 统计(本机) | 标题画面右上角、大厅、等待室的「统计」:胜率、策略通过率、称号、战斗累计和对局记录(点一行回看结算页);只存在本机浏览器里,可以导出 / 导入,没清完第一个回合就退出的对局不计入统计([玩法指南 §12](docs/PLAYING.md#13-统计本机)) |
|
||||
|
||||
完整的规则、数值和小技巧见 **[docs/PLAYING.md](docs/PLAYING.md)**(游戏内左下角也有「玩法说明」)。
|
||||
|
||||
@@ -194,9 +196,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 +250,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 @@
|
||||
{"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":"2424fdc0a6b5","generator":"tools/fetch-assets.mjs","stats":{"files":10956,"bytes":581063469,"chars":210,"charsWithBack":207,"enemies":252,"enemiesWithSpine":251,"tokens":58,"tokensWithSpine":13,"spineModels":673,"bonds":23,"items":59,"bands":40,"skills":525,"modules":131,"ui":602,"sfxUnits":484,"voiceChars":192,"voiceJpChars":192},"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 @@
|
||||
{"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 @@
|
||||
{"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 @@
|
||||
{"season":"act2autochess","seasonName":"卫戍协议:盟约","modes":{"mode_training_1":{"modeId":"mode_training_1","name":"入门协议","code":"AC-TR-1","sortId":1,"type":"LOCAL","difficulty":"TRAINING","inScope":false,"color":"#4ed8af","iconId":"mode_training_icon","backgroundId":"background_training","desc":"用以熟悉协议内容的模式","effectDescList":["·仅可使用预置的调配干员","·无奖励","·无法用于解锁策略"],"unlockText":null,"specialPhaseTime":150,"activeBondIds":["yanShip","sargonShip","victoriaShip","kjeragShip","lateranoShip","egirShip","siracusaShip","kazimierzShip","preciShip","swiftShip","skillfulShip","arcaneShip","steadShip","deputShip","visiShip","miraShip","investShip","raidShip","indomShip","maniShip","emptyShip","soloShip","suntShip"],"inactiveBondIds":[],"inactiveEnemyKeys":[],"lastRound":4,"bossRound":4,"hiddenRound":null,"rounds":{"1":{"template":"act1autochess_tr01","bossTemplates":null,"combatTimeLimit":40,"levelMaxPlayTime":40,"prepTime":999,"prepTimeData":999,"isSpPrepare":false,"isBoss":false,"isHidden":false,"bossOvertimeAfter":null},"2":{"template":"act1autochess_tr02","bossTemplates":null,"combatTimeLimit":45,"levelMaxPlayTime":45,"prepTime":999,"prepTimeData":999,"isSpPrepare":false,"isBoss":false,"isHidden":false,"bossOvertimeAfter":null},"3":{"template":"act1autochess_tr02","bossTemplates":null,"combatTimeLimit":45,"levelMaxPlayTime":45,"prepTime":999,"prepTimeData":999,"isSpPrepare":true,"isBoss":false,"isHidden":false,"bossOvertimeAfter":null},"4":{"template":null,"bossTemplates":{"boss_7":"act1autochess_tr04"},"combatTimeLimit":null,"levelMaxPlayTime":180,"prepTime":999,"prepTimeData":999,"isSpPrepare":false,"isBoss":true,"isHidden":false,"bossOvertimeAfter":150}},"spRounds":[3],"combatTimeLimit":{"1":40,"2":45,"3":45,"4":null},"enemyScale":{"1":{"atk":1,"hp":1,"speed":1,"assumed":true},"2":{"atk":1,"hp":1,"speed":1,"assumed":true},"3":{"atk":1,"hp":1,"speed":1,"assumed":true},"4":{"atk":1,"hp":1,"speed":1,"assumed":true}},"bossHpScale":{"bloodPointKey":null,"unaffectedByEnemyScale":true},"upgradePrices":[4,4,6,9,12],"maxShopLevel":6,"shopSlots":{"1":{"chess":3,"item":0},"2":{"chess":3,"item":0},"3":{"chess":3,"item":0},"4":{"chess":3,"item":0},"5":{"chess":3,"item":1},"6":{"chess":3,"item":1}},"levelTagColors":{"1":"#434343","2":"#626654","3":"#445760","4":"#615B74","5":"#6C5E41","6":"#5d341e"},"stages":["act1autochess_m01"],"bossWeights":{"boss_7":5},"hiddenBossWeights":{}},"mode_single_funny":{"modeId":"mode_single_funny","name":"标准模拟","code":"AC-1","sortId":2,"type":"SINGLE","difficulty":"FUNNY","inScope":true,"color":"#f6a329","iconId":"mode_funny_icon","backgroundId":"background_funny","desc":"时长较短的模拟训练","effectDescList":["·可以快速完成作战","·常规奖励"],"unlockText":"通关【入门协议】后解锁","specialPhaseTime":150,"activeBondIds":["yanShip","sargonShip","victoriaShip","kjeragShip","siracusaShip","preciShip","swiftShip","steadShip","deputShip","visiShip","indomShip","maniShip","emptyShip"],"inactiveBondIds":["lateranoShip","egirShip","kazimierzShip","skillfulShip","arcaneShip","miraShip","investShip","raidShip","soloShip","suntShip"],"inactiveEnemyKeys":["enemy_1112_emppnt","enemy_1207_sfji","enemy_1275_dwlock_2","enemy_1234_dsubrl_2","enemy_10122_uacann_2","enemy_1272_nhtank_2","enemy_9008_acbunn","enemy_1174_duholy_2","enemy_1355_mrfly_2","enemy_1197_sfshu_2","enemy_10067_ftsjc","enemy_1270_nhstlk_2","enemy_1283_sgkill","enemy_1175_dushdo_2","enemy_1254_lypa_2","enemy_1425_lrcmra_2","enemy_1329_cbshld_2","enemy_1072_dlancer"],"lastRound":9,"bossRound":9,"hiddenRound":null,"rounds":{"1":{"template":"act1autochess_01","bossTemplates":null,"combatTimeLimit":45,"levelMaxPlayTime":45,"prepTime":null,"prepTimeData":300,"isSpPrepare":false,"isBoss":false,"isHidden":false,"bossOvertimeAfter":null},"2":{"template":"act1autochess_02","bossTemplates":null,"combatTimeLimit":45,"levelMaxPlayTime":45,"prepTime":null,"prepTimeData":300,"isSpPrepare":false,"isBoss":false,"isHidden":false,"bossOvertimeAfter":null},"3":{"template":"act1autochess_03","bossTemplates":null,"combatTimeLimit":55,"levelMaxPlayTime":55,"prepTime":null,"prepTimeData":300,"isSpPrepare":false,"isBoss":false,"isHidden":false,"bossOvertimeAfter":null},"4":{"template":"act1autochess_04","bossTemplates":null,"combatTimeLimit":55,"levelMaxPlayTime":55,"prepTime":null,"prepTimeData":300,"isSpPrepare":false,"isBoss":false,"isHidden":false,"bossOvertimeAfter":null},"5":{"template":"act1autochess_05","bossTemplates":null,"combatTimeLimit":55,"levelMaxPlayTime":55,"prepTime":null,"prepTimeData":300,"isSpPrepare":false,"isBoss":false,"isHidden":false,"bossOvertimeAfter":null},"6":{"template":"act1autochess_06","bossTemplates":null,"combatTimeLimit":70,"levelMaxPlayTime":70,"prepTime":null,"prepTimeData":300,"isSpPrepare":false,"isBoss":false,"isHidden":false,"bossOvertimeAfter":null},"7":{"template":"act1autochess_07","bossTemplates":nuLine truncated
|
||||
{"season":"act2autochess","seasonName":"卫戍协议:盟约","modes":{"mode_training_1":{"modeId":"mode_training_1","name":"入门协议","code":"AC-TR-1","sortId":1,"type":"LOCAL","difficulty":"TRAINING","inScope":false,"color":"#4ed8af","iconId":"mode_training_icon","backgroundId":"background_training","desc":"用以熟悉协议内容的模式","effectDescList":["·仅可使用预置的调配干员","·无奖励","·无法用于解锁策略"],"unlockText":null,"specialPhaseTime":150,"activeBondIds":["yanShip","sargonShip","victoriaShip","kjeragShip","lateranoShip","egirShip","siracusaShip","kazimierzShip","preciShip","swiftShip","skillfulShip","arcaneShip","steadShip","deputShip","visiShip","miraShip","investShip","raidShip","indomShip","maniShip","emptyShip","soloShip","suntShip"],"inactiveBondIds":[],"inactiveEnemyKeys":[],"lastRound":4,"bossRound":4,"hiddenRound":null,"rounds":{"1":{"template":"act1autochess_tr01","bossTemplates":null,"combatTimeLimit":40,"levelMaxPlayTime":40,"prepTime":999,"prepTimeData":999,"isSpPrepare":false,"isBoss":false,"isHidden":false,"bossOvertimeAfter":null},"2":{"template":"act1autochess_tr02","bossTemplates":null,"combatTimeLimit":45,"levelMaxPlayTime":45,"prepTime":999,"prepTimeData":999,"isSpPrepare":false,"isBoss":false,"isHidden":false,"bossOvertimeAfter":null},"3":{"template":"act1autochess_tr02","bossTemplates":null,"combatTimeLimit":45,"levelMaxPlayTime":45,"prepTime":999,"prepTimeData":999,"isSpPrepare":true,"isBoss":false,"isHidden":false,"bossOvertimeAfter":null},"4":{"template":null,"bossTemplates":{"boss_7":"act1autochess_tr04"},"combatTimeLimit":null,"levelMaxPlayTime":180,"prepTime":999,"prepTimeData":999,"isSpPrepare":false,"isBoss":true,"isHidden":false,"bossOvertimeAfter":150}},"spRounds":[3],"combatTimeLimit":{"1":40,"2":45,"3":45,"4":null},"enemyScale":{"1":{"atk":1,"hp":1,"speed":1,"assumed":true},"2":{"atk":1,"hp":1,"speed":1,"assumed":true},"3":{"atk":1,"hp":1,"speed":1,"assumed":true},"4":{"atk":1,"hp":1,"speed":1,"assumed":true}},"bossHpScale":{"bloodPointKey":null,"unaffectedByEnemyScale":true},"upgradePrices":[4,4,6,9,12],"maxShopLevel":6,"shopSlots":{"1":{"chess":3,"item":0},"2":{"chess":3,"item":0},"3":{"chess":3,"item":0},"4":{"chess":3,"item":0},"5":{"chess":3,"item":1},"6":{"chess":3,"item":1}},"levelTagColors":{"1":"#434343","2":"#626654","3":"#445760","4":"#615B74","5":"#6C5E41","6":"#5d341e"},"stages":["act1autochess_m01"],"bossWeights":{"boss_7":5},"hiddenBossWeights":{}},"mode_single_funny":{"modeId":"mode_single_funny","name":"标准模拟","code":"AC-1","sortId":2,"type":"SINGLE","difficulty":"FUNNY","inScope":true,"color":"#f6a329","iconId":"mode_funny_icon","backgroundId":"background_funny","desc":"时长较短的模拟训练","effectDescList":["·可以快速完成作战","·常规奖励"],"unlockText":"通关【入门协议】后解锁","specialPhaseTime":150,"activeBondIds":["yanShip","sargonShip","victoriaShip","kjeragShip","siracusaShip","preciShip","swiftShip","steadShip","deputShip","visiShip","indomShip","maniShip","emptyShip"],"inactiveBondIds":["lateranoShip","egirShip","kazimierzShip","skillfulShip","arcaneShip","miraShip","investShip","raidShip","soloShip","suntShip"],"inactiveEnemyKeys":["enemy_1112_emppnt","enemy_1207_sfji","enemy_1275_dwlock_2","enemy_1234_dsubrl_2","enemy_10122_uacann_2","enemy_1272_nhtank_2","enemy_9008_acbunn","enemy_1174_duholy_2","enemy_1355_mrfly_2","enemy_1197_sfshu_2","enemy_10067_ftsjc","enemy_1270_nhstlk_2","enemy_1283_sgkill","enemy_1175_dushdo_2","enemy_1254_lypa_2","enemy_1425_lrcmra_2","enemy_1329_cbshld_2","enemy_1072_dlancer"],"lastRound":9,"bossRound":9,"hiddenRound":null,"rounds":{"1":{"template":"act1autochess_01","bossTemplates":null,"combatTimeLimit":45,"levelMaxPlayTime":45,"prepTime":null,"prepTimeData":300,"isSpPrepare":false,"isBoss":false,"isHidden":false,"bossOvertimeAfter":null},"2":{"template":"act1autochess_02","bossTemplates":null,"combatTimeLimit":45,"levelMaxPlayTime":45,"prepTime":null,"prepTimeData":300,"isSpPrepare":false,"isBoss":false,"isHidden":false,"bossOvertimeAfter":null},"3":{"template":"act1autochess_03","bossTemplates":null,"combatTimeLimit":55,"levelMaxPlayTime":55,"prepTime":null,"prepTimeData":300,"isSpPrepare":false,"isBoss":false,"isHidden":false,"bossOvertimeAfter":null},"4":{"template":"act1autochess_04","bossTemplates":null,"combatTimeLimit":55,"levelMaxPlayTime":55,"prepTime":null,"prepTimeData":300,"isSpPrepare":false,"isBoss":false,"isHidden":false,"bossOvertimeAfter":null},"5":{"template":"act1autochess_05","bossTemplates":null,"combatTimeLimit":55,"levelMaxPlayTime":55,"prepTime":null,"prepTimeData":300,"isSpPrepare":false,"isBoss":false,"isHidden":false,"bossOvertimeAfter":null},"6":{"template":"act1autochess_06","bossTemplates":null,"combatTimeLimit":70,"levelMaxPlayTime":70,"prepTime":null,"prepTimeData":300,"isSpPrepare":false,"isBoss":false,"isHidden":false,"bossOvertimeAfter":null},"7":{"template":"act1autochess_07","bossTemplates":nuLine truncated
|
||||
@@ -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":2498,"translated":2449,"official":2431,"pct":98},"operators":{"texts":876,"translated":854,"official":846,"pct":97.5},"skills":{"texts":5673,"translated":5439,"official":5439,"pct":95.9},"stages":{"texts":1124,"translated":1124,"official":1122,"pct":100},"talents":{"texts":5996,"translated":5688,"official":5680,"pct":94.9},"tokens":{"texts":458,"translated":458,"official":457,"pct":100},"traits":{"texts":1876,"translated":1860,"official":1860,"pct":99.1}},"totals":{"texts":23881,"translated":23252,"official":23158,"pct":97.4}},"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","冰爆源石虫":"ILine truncated
|
||||
@@ -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":2498,"translated":2431,"official":2431,"pct":97.3},"operators":{"texts":876,"translated":846,"official":846,"pct":96.6},"skills":{"texts":5673,"translated":5439,"official":5439,"pct":95.9},"stages":{"texts":1124,"translated":1111,"official":1111,"pct":98.8},"talents":{"texts":5996,"translated":5676,"official":5676,"pct":94.7},"tokens":{"texts":458,"translated":457,"official":457,"pct":99.8},"traits":{"texts":1876,"translated":1860,"official":1860,"pct":99.1}},"totals":{"texts":23881,"translated":23181,"official":23143,"pct":97.1}},"names":{"\"炎佑\"":"「炎祐」","Dor-1号失败品":"Dor-1号失敗作","Dor-α号失败品":"Dor-α号失敗作","Dor-β号复制体":"Dor-β号複製体","M3茧甲":"M3コクーンアーマー","W·悬赏":"懸賞・W","“变形者集群”":"「変形者」","“墓碑”":"「墓碑」","“墓碑”·悬赏":"懸賞・「墓碑」","“复仇者”":"Vendetta","“复仇者”·悬赏":"懸賞・「ヴェンデッタ」","“小自在”":"「小自在」","“巨大的丑东西”":"「ビッグアグリー」","“巨大的丑东西”·悬赏":"懸賞・「ビッグアグリー」","“帝国的甲胄”":"「帝国の甲冑」","“庞贝”":"「ポンペイ」","“庞贝”·悬赏":"懸賞・Pompeii","“斩胄之剑”":"「甲冑断つ剣」","“灵幛”":"「霊骸布」","“独轮车玩具”":"「一輪車のおもちゃ」","“破胄之锤”":"「甲冑割る槌」","“碎铳之簧”":"「機銃砕く輪」","“神秘顾客”":"「謎の客人」","“耀阳”":"「ブレイジング・サン」","“自在”":"「ジザイ」","“自在”·悬赏":"懸賞・「ジザイ」","“萨科塔之眼”":"「サンクタの眼」","“萨科塔之眼”·悬赏":"懸賞・「サンクタの眼」","“萨科塔之翼”":"「サンクタの翼」","“萨科塔昂首”":"「サンクタの仰け反り」","“萨米的意志”":"「サーミの意志」","“裂管之音”/“断弦之音”":"「断絃の音」/「裂管の音」","“越长尘”":"「長塵」","“遗弃者”":"「見棄てし者」","“遗弃者”·悬赏":"懸賞・「見棄てし者」","“邪魔的利刃”":"「悪魔の利刃」","“邪魔的利刃”·悬赏":"懸賞・「悪魔の利刃」","“鼠王”·悬赏":"懸賞・「鼠王」","不屈":"不屈","不屈弹射器":"不屈の射出装置","不祥幻影":"不祥の幻影","不稳定要素":"不安定要素","业余竞演者":"一般参加の競演者","业务指标":"業務ノルマ","临时收音师":"臨時録音技師","主角阵营角色":"主役側の脇役","乌尔比安":"ウルピアヌス","乌顶巨角卢鲁":"黒き大角のルル","九流之缘":"人たらし","人事部文档":"人事部の書類","人才盲盒":"人材抽選","仇白":"チューバイ","从不混淆的方向":"混同せぬ方向","令":"リィン","以己之长":"持ち味を活かして","伊内丝":"イネス","伊利昂的木驮兽":"イリオスの木駄獣","伊奥莱塔·罗素":"イオレッタ・ラッセル","伊芙利特":"イフリータ","休眠子裔":"眠れる眷属","休谟斯":"ヒューマス","休露丝":"スキウース","众志合一":"一致団結","优等生":"優等生","传奇重犯":"伝説の凶悪犯","伪装服":"迷彩ヘルメット","伺夜":"ヴィジェル","余":"ユー","佩佩":"ペペ","俗心":"俗心","保护目标(冻结状态)":"防衛ライン(凍結状態)","信仰搅拌机":"サンクタ・ミキサー","信标":"ビーコン","假想敌:再生":"仮想敵:再生","假想敌:弦":"仮想敵:絃","假想敌:淤困":"仮想敵:泥濘","假想敌:管":"仮想敵:管","假想敌:胄":"仮想敵:冑","假想敌:蚀裂":"仮想敵:蝕裂","假想敌:铳":"仮想敵:銃","假想敌:镜膜":"仮想敵:鏡膜","假想敌:骨刺":"仮想敵:骨棘","假想敌:黑云":"仮想敵:黒雲","假日威龙陈":"遊龍チェン","傀影":"Line truncated
|
||||
@@ -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":2498,"translated":2431,"official":2431,"pct":97.3},"operators":{"texts":876,"translated":846,"official":846,"pct":96.6},"skills":{"texts":5673,"translated":5439,"official":5439,"pct":95.9},"stages":{"texts":1124,"translated":1111,"official":1111,"pct":98.8},"talents":{"texts":5996,"translated":5680,"official":5680,"pct":94.7},"tokens":{"texts":458,"translated":457,"official":457,"pct":99.8},"traits":{"texts":1876,"translated":1860,"official":1860,"pct":99.1}},"totals":{"texts":23881,"translated":23185,"official":23147,"pct":97.1}},"names":{"\"炎佑\"":"'염의 가호'","Dor-1号失败品":"Dor-1호 실패작","Dor-α号失败品":"Dor-α호 실패작","Dor-β号复制体":"Dor-β호 복제품","M3茧甲":"M3 고치 갑옷","W·悬赏":"W - 수배","“变形者集群”":"'다마즈티'","“墓碑”":"'툼스톤'","“墓碑”·悬赏":"'툼스톤' - 수배","“复仇者”":"'어벤져'","“复仇者”·悬赏":"'어벤져' - 수배","“小自在”":"'작은 즈자이'","“巨大的丑东西”":"'거대하고 못생긴 것'","“巨大的丑东西”·悬赏":"'거대하고 못생긴 것' - 수배","“帝国的甲胄”":"'제국의 갑옷'","“庞贝”":"'폼페이'","“庞贝”·悬赏":"'폼페이' - 수배","“斩胄之剑”":"'갑주를 베는 검'","“灵幛”":"'영장'","“独轮车玩具”":"'완구 외발자전거'","“破胄之锤”":"'갑주를 부수는 해머'","“碎铳之簧”":"'총을 부수는 리드'","“神秘顾客”":"'신비한 고객'","“耀阳”":"'눈 부신 태양'","“自在”":"'즈자이'","“自在”·悬赏":"'즈자이' - 수배","“萨科塔之眼”":"'산크타의 눈'","“萨科塔之眼”·悬赏":"'산크타의 눈' - 수배","“萨科塔之翼”":"'산크타의 날개'","“萨科塔昂首”":"'산크타의 긍지'","“萨米的意志”":"'사미의 의지'","“裂管之音”/“断弦之音”":"'단현지음'/'열관지음'","“越长尘”":"'월장진'","“遗弃者”":"'버림받은 자'","“遗弃者”·悬赏":"'버림받은 자' - 수배","“邪魔的利刃”":"'데몬의 칼날'","“邪魔的利刃”·悬赏":"'데몬의 칼날' - 수배","“鼠王”·悬赏":"'래트킹' - 수배","不屈":"불굴","不屈弹射器":"불굴의 사출기","不祥幻影":"불길한 환영","不稳定要素":"불안요소","业余竞演者":"아마추어 경연자","业务指标":"업무 지표","临时收音师":"임시 음향기사","主角阵营角色":"주인공 진영 캐릭터","乌尔比安":"울피아누스","乌顶巨角卢鲁":"검은 뿔의 루울","九流之缘":"구류의 인연","人事部文档":"인사부 파일","人才盲盒":"인재 랜덤박스","仇白":"치우바이","从不混淆的方向":"헷갈리지 않는 방향","令":"링","以己之长":"장점 활용","伊内丝":"이네스","伊利昂的木驮兽":"일리온의 나무 버든비스트","伊奥莱塔·罗素":"이올레타 로시","伊芙利特":"이프리트","休眠子裔":"잠자는 후예","休谟斯":"휴머스","休露丝":"시우루스","众志合一":"일치단결","优等生":"우등생","传奇重犯":"전설의 중범죄수","伪装服":"위장복","伺夜":"비질","余":"위","佩佩":"페페","俗心":"수신","保护目标(冻结状态)":"방어 포인트 (빙결 상태)","信仰搅拌机":"산크타 믹사파라토","信标":"비콘","假想敌:再生":"가상적: 재생","假想敌:弦":"가상적: 현","假想敌:淤困":"가상적: 정체","假想敌:管":"가상적: 관","假想敌:胄":"가상적: 갑주","假想敌:蚀裂":"가상적: 침식","假想敌:铳":"가상적: 총","假想敌:镜膜":"가상적: 경막","假想敌:骨刺":"가상적: 골극","假想敌:黑云":"가상적: 흑운","假日威龙陈":"첸 더 홀룽데이","傀影":"팬텀","催泪瓦斯":"최루가스","元核孽生者":"원핵의 악생자","元核孽生者·悬赏":"원핵의 악생자 - 수배","克莱Line truncated
|
||||
@@ -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":2498,"translated":2329,"official":2329,"pct":93.2},"operators":{"texts":876,"translated":590,"official":590,"pct":67.4},"skills":{"texts":5673,"translated":5131,"official":5131,"pct":90.4},"stages":{"texts":1124,"translated":901,"official":901,"pct":80.2},"talents":{"texts":5996,"translated":5410,"official":5410,"pct":90.2},"tokens":{"texts":458,"translated":435,"official":435,"pct":95},"traits":{"texts":1876,"translated":1860,"official":1860,"pct":99.1}},"totals":{"texts":23881,"translated":21838,"official":21819,"pct":91.4}},"names":{"\"炎佑\"":"“炎佑”","Dor-1号失败品":"Dor-1號失敗品","Dor-α号失败品":"Dor-α號失敗品","Dor-β号复制体":"Dor-β號複製體","M3茧甲":"M3繭甲","W·悬赏":"W·懸賞","“变形者集群”":"“變形者集群”","“墓碑”·悬赏":"“墓碑”·懸賞","“复仇者”":"“復仇者”","“复仇者”·悬赏":"“復仇者”·懸賞","“巨大的丑东西”":"“巨大的醜東西”","“巨大的丑东西”·悬赏":"“巨大的醜東西”·懸賞","“帝国的甲胄”":"“帝國的甲胄”","“庞贝”":"“龐貝”","“庞贝”·悬赏":"“龐貝”·懸賞","“斩胄之剑”":"“斬胄之劍”","“灵幛”":"“靈幛”","“独轮车玩具”":"“獨輪車玩具”","“破胄之锤”":"“破胄之錘”","“碎铳之簧”":"“碎銃之簧”","“神秘顾客”":"“神秘顧客”","“耀阳”":"“耀陽”","“自在”·悬赏":"“自在”·懸賞","“萨科塔之眼”":"“薩科塔之眼”","“萨科塔之眼”·悬赏":"“薩科塔之眼”·懸賞","“萨科塔之翼”":"“薩科塔之翼”","“萨科塔昂首”":"“薩科塔昂首”","“萨米的意志”":"“薩米的意志”","“裂管之音”/“断弦之音”":"“裂管之音”/“斷弦之音”","“越长尘”":"“越長塵”","“遗弃者”":"“遺棄者”","“遗弃者”·悬赏":"“遺棄者”·懸賞","“邪魔的利刃”·悬赏":"“邪魔的利刃”·懸賞","“鼠王”·悬赏":"“鼠王”·懸賞","不屈弹射器":"不屈彈射器","不稳定要素":"不穩定要素","业余竞演者":"業餘競演者","业务指标":"業務指標","临时收音师":"臨時收音師","主角阵营角色":"主角陣營角色","乌尔比安":"烏爾比安","乌顶巨角卢鲁":"烏頂巨角盧魯","九流之缘":"九流之緣","人事部文档":"人事部文件","从不混淆的方向":"從不混淆的方向","以己之长":"以己之長","伊内丝":"伊內絲","伊利昂的木驮兽":"伊利昂的木馱獸","伊奥莱塔·罗素":"伊奧萊塔·羅素","休谟斯":"休謨斯","休露丝":"休露絲","众志合一":"眾志合一","优等生":"優等生","传奇重犯":"傳奇重犯","伪装服":"偽裝服","余":"餘","保护目标(冻结状态)":"保護目標(凍結狀態)","信仰搅拌机":"信仰攪拌機","信标":"信標","假想敌:再生":"假想敵:再生","假想敌:弦":"假想敵:弦","假想敌:淤困":"假想敵:淤困","假想敌:管":"假想敵:管","假想敌:胄":"假想敵:胄","假想敌:蚀裂":"假想敵:蝕裂","假想敌:铳":"假想敵:銃","假想敌:镜膜":"假想敵:鏡膜","假想敌:骨刺":"假想敵:骨刺","假想敌:黑云":"假想敵:黑雲","假日威龙陈":"假日威龍陳","催泪瓦斯":"催淚瓦斯","元核孽生者·悬赏":"元核孽生者·懸賞","克莱门莎":"克萊門莎","军用猎狗":"軍用獵狗","冰爆源石虫":"冰爆源石蟲","冲击式施术单元":"衝擊式施術單元","凋零骑士":"凋零騎士","凋零骑士·悬赏":"凋零騎士·懸賞","凛御银灰":"凜御銀灰","凯尔希":"凱爾希","凯瑟琳":"凱瑟琳","列装":"列裝","利滚利":"利滾利","刺胄之弹":"刺胄之彈","加急调派":"緊急調派","加练!":"加練!","加速维式重锤":"加速維式重鎚","医疗探机":"醫療探機","升华":"昇華","华法琳":"華法琳",Line truncated
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -85,11 +89,11 @@ so old imports keep working: `public/js/ui/gameLogic.js` (`public/js/ui/gameLogi
|
||||
| `server/sim/Battle.js` | one battle field; its methods are in `server/sim/battle/` (`lifecycle.js`, `spawns.js`, `deploy.js`, `blocking.js`, `combat.js`, `status.js`, `summons.js`, `displacement.js`, `events.js` …) |
|
||||
| `server/sim/` (the rest) | `skills.js`, `ai.js`, `damage.js`, `buffs.js`, `targeting.js`, `grid.js` (pathing), `units.js`, `professions.js`, `snapshot.js`, `spec.js` (the BattleSpec), `simdata.js` (data records → engine defs), `nodeData.js` (the Node-only data loader, never served) |
|
||||
| `server/sim/content/` | everything game-specific, installed into a battle by `server/sim/content/index.js` |
|
||||
| `server/sim/content/kits/` | the operator kits: `ops/` holds one file per chess (`<chessId>-<codename>.js`, 129), per 补位 stand-in (`standin-<codename>.js`, 9) and per 自选 operator (`op-<codename>.js`, 71); `shared/` the helpers several kits use; `index.js` the registry; `README.md` the guide |
|
||||
| `server/sim/content/kits/` | the operator kits: `ops/` holds one file per chess (`<chessId>-<codename>.js`, 129), per 补位 stand-in (`standin-<codename>.js`, 9) and per 自选 operator (`op-<codename>.js`, 72); `shared/` the helpers several kits use; `index.js` the registry; `README.md` the guide |
|
||||
| `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) |
|
||||
|
||||
@@ -2,8 +2,57 @@
|
||||
|
||||
Owner: `tools/fetch-assets.mjs` and `tools/assets/*`. Research background: `docs/research/07-assets.md`.
|
||||
|
||||
All art, Spine models and audio are **downloaded at install time**. They are never committed; `public/assets/` is git-ignored.
|
||||
Everything the client needs is listed in **`data/assets.json`**. The client should only request URLs that appear in that manifest.
|
||||
All official game art, Spine models and audio are **downloaded at install time**. They are never committed; `public/assets/` is git-ignored.
|
||||
All downloaded game assets the client needs are listed in **`data/assets.json`**. Game-art requests must use that manifest.
|
||||
The original project icons below are shipped static files, not downloaded game assets.
|
||||
|
||||
## Original project application icons (#413)
|
||||
|
||||
By the maintainer's revised decision of 2026-10-10, **沿用 0.2.x 的薄荷绿城堡并精修**.
|
||||
`public/icons/app.svg` retains the existing mint rook's three battlements, inset tower and splayed base.
|
||||
The selected refinement (B) narrows the tower, balances the battlements, reduces the base thickness and adds breathing
|
||||
room. On the 512 px canvas the mark spans x=128–384 and y=88–424; the tower is 176 px wide and the base is 32 px thick.
|
||||
The original colours remain mint `#4ed8af` and dark `#0c0f0e`, with no text, gradients, gloss or texture.
|
||||
|
||||
**Provenance / originality statement (2026-10-10):** this is a proportion refinement of this project's existing
|
||||
`public/icons/app.svg` from before `ba5b998a`, expressed as basic rectangles and polygons.
|
||||
No official logo, artwork, icon or font was used, downloaded, embedded, traced or redrawn for this refinement.
|
||||
The vector and exports are project-owned work under the repository's GPL-3.0-or-later licence, separate from the
|
||||
Hypergryph / Yostar game assets below. The castle mark does not assert official branding.
|
||||
|
||||
Rebuild offline with Python 3 and Pillow (the same image library used by local-extract):
|
||||
|
||||
```bash
|
||||
python3 tools/export-app-icons.py
|
||||
# Optional review export without replacing shipped files:
|
||||
python3 tools/export-app-icons.py --out /tmp/stronghold-icons
|
||||
```
|
||||
|
||||
The script reads only the master SVG's flat rectangle / polygon geometry, renders at 8× and downsamples; unsupported
|
||||
SVG elements fail rather than silently disappearing. It needs no game art, fonts, browser or network.
|
||||
ICO frames use the same native-size renders as the PNG favicons, with explicit 24-bit DIBs and opaque 1-bit masks
|
||||
whose rows are padded to 4 bytes. This avoids malformed transparency masks in Pillow's RGB ICO export.
|
||||
|
||||
| Files in `public/icons/` | Use |
|
||||
|---|---|
|
||||
| `app-192.png`, `app-512.png` | Manifest `purpose: any`, 192 / 512 px |
|
||||
| `app-maskable-192.png`, `app-maskable-512.png` | Manifest `purpose: maskable`, opaque full-bleed background |
|
||||
| `favicon-16.png`, `favicon-32.png`, `favicon-48.png`, `favicon.ico` | Tab / legacy favicon; ICO contains 16 / 32 / 48 px frames |
|
||||
| `apple-touch-icon.png` | 180 px home-screen icon |
|
||||
| `app.svg` | Scalable favicon and editable source |
|
||||
|
||||
The maskable mark is scaled to 87.5% around the canvas centre. Its outermost vertex is about 36.1% of the canvas
|
||||
width from that centre; even antialiased edge pixels stay inside the **40%-radius safe circle** specified by
|
||||
[Web Application Manifest §2.3](https://www.w3.org/TR/appmanifest/#icon-masks).
|
||||
The background remains opaque to every edge; launcher masks may remove the background outside the safe zone.
|
||||
Normal icons and favicons retain the larger mark for readability. `public/index.html` links the SVG / ICO / PNG
|
||||
favicons and Apple icon; `public/manifest.json` lists all four install icons. All these static files must be tracked
|
||||
and included by the full and lite package allowlist; no changes to `data/assets.json` are needed.
|
||||
|
||||
`test/ui/pwa-install-023.test.js` checks decoded pixels, mask safety and references; `test/package.test.js` checks
|
||||
packaging inclusion. The local handoff `claude-review-023/icon-v2/` holds the original and A/B/C SVGs, light/dark
|
||||
256 px and 16/32/48/96 px comparisons, and phone-layout / mask previews with their render script.
|
||||
These are offline review renders, not evidence of browser or native OS installation.
|
||||
|
||||
## Running
|
||||
|
||||
@@ -20,10 +69,11 @@ 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). |
|
||||
| `--strict` | Exit 1 when a leaf was dropped for having no file on disk, instead of only reporting it (see "A dropped leaf is reported" below). The same as the environment variable `SP_ASSETS_STRICT=1` — for CI and packaging builds. |
|
||||
| `--add-only` | For a checkout whose `public/assets/` and `public/fonts/` are shared with another one (a git worktree with symlinked asset folders): download only the files missing on disk and never re-download, rewrite or delete an existing file — atlases already on disk are left as they are, the fonts are not rebuilt (the manifest keeps its current `fonts`). Not with `--prune` / `--force`. |
|
||||
| `--local-spines` | Rewrite `tools/assets/local-enemy-spines.json` and `tools/assets/local-token-spines.json` (the metadata of the enemy and token models only the local client has, see "Enemy aliases" and "Token models from the local client") from the models `tools/local-extract/extract.py` extracted to `public/assets/local/spine/enemy/` and `public/assets/local/spine/token/`. Run it after a game update changed them; without it the committed files are used and a differing extraction only gets a warning. |
|
||||
|
||||
@@ -38,6 +88,23 @@ smaller manifest is intended, for example after a mapping change. Build fields (
|
||||
plan legitimately narrows — like the 干员战斗语音 default, which no longer plans the three prep-only slots (360 entries,
|
||||
DESIGN §21.30) — reports exactly those entries and needs `--allow-shrink` once; the list it prints is the check.
|
||||
|
||||
**A dropped leaf is reported, never silent.** `resolveTemplate` leaves out every entry none of whose alternative files
|
||||
is on disk — for a leaf that is an entry the client loses outright: a unit whose `attack` sound was never downloaded has
|
||||
no `audio.sfx.units[charId].attack` at all, so the sound is simply missing (nothing falls back; there is no URL to
|
||||
retry). That is a different thing from an entry whose *fallback* was used. The run therefore prints a summary naming at most eight such leaves
|
||||
and records the complete list as `droppedLeaves` (a subset of `misses`, which also lists the
|
||||
unresolved Spine models):
|
||||
|
||||
```
|
||||
[assets] 1 leaf dropped: no alternative on disk (audio.sfx.units.some_char.attack)
|
||||
```
|
||||
|
||||
A run with nothing dropped prints `[assets] no leaf dropped: every planned leaf has a file on disk`. This is only
|
||||
observability: which files are planned, downloaded and written does not change. `--strict` (or `SP_ASSETS_STRICT=1`)
|
||||
turns those drops into a failure (exit 1, the leaves named) so a CI or packaging run cannot ship a manifest with such
|
||||
holes — the committed manifest's shrink guard cannot see them once the entry is already gone. The manifest may
|
||||
already have been written before this check returns failure; strict mode does not roll it back.
|
||||
|
||||
The script is **idempotent**. A file on disk is kept, not re-downloaded, when any one of these holds:
|
||||
- its size matches the ledger entry from a previous download (`.cache/assets-ledger.json`);
|
||||
- its size matches the byte count recorded in research;
|
||||
@@ -53,16 +120,18 @@ 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).
|
||||
- `public/assets/**`: art and audio (git-ignored).
|
||||
- `public/fonts/*`: fonts and `fonts.css`.
|
||||
- `.cache/assets-report.json`: misses, fallbacks and notes from the last run.
|
||||
- `.cache/assets-report.json`: misses, fallbacks, `droppedLeaves` (the leaves left out of the manifest for having no file
|
||||
on disk) and notes from the last run.
|
||||
- `.cache/spine-info.json`: skeleton parse cache.
|
||||
|
||||
The run exits with code 1 if any pool operator is missing its avatar, its portrait or its Front Spine.
|
||||
The run exits with code 1 if any pool operator is missing its avatar, its portrait or its Front Spine, and — with
|
||||
`--strict` / `SP_ASSETS_STRICT=1` — if any leaf was dropped for having no file on disk.
|
||||
|
||||
Upstream indexes are cached under `.cache/`. They are downloaded when missing:
|
||||
- `.cache/gamedata/excel/audio_data.json`, from `Kengxxiao/ArknightsGameData` (zh_CN).
|
||||
@@ -90,17 +159,17 @@ The research JSONs in `docs/research/` (03, 05, 07) define **which** ids are nee
|
||||
| Token Spine | fexli: the default model, or else the first skin variant (`spine/{tokenId}/{variant}/Spine/`) | `spine/token/{tokenId}/{stem}.*` |
|
||||
| Enemy Spine (PC build, premultiplied alpha) | isHarryh/Ark-Models `models_enemies/{key}/`, file names from `models_data.json` | `spine/enemy/{enemyId}/{stem}.*` |
|
||||
| Enemy Spine that no dump carries (灼热源石虫 / 炽焰源石虫) | the local client only (`tools/local-extract/extract.py ENEMY_SPINES`, optional); never downloaded and never required: an overlay of the web alias (`enemies[id].spineLocal`) | `local/spine/enemy/{enemyId}/{stem}.*` (listed in `data/local-assets.json`) |
|
||||
| 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`) |
|
||||
| Original token Spine overlays (40 summons: most 自选 summons, 凯瑟琳's 爬行号·防护单元, 凛御银灰's 风雪之眼, 香槟炸弹) | local-client extraction (`tools/local-extract/extract.py TOKEN_SPINES`, optional); the original overlay is preferred, with any listed web model retained as fallback | `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.
|
||||
|
||||
### Id scope
|
||||
|
||||
- **Operators:** all 138 pool charIds from `activity_table` (`charShopChessDatas[*].charId ∪ backupCharId`), including hidden chess and backup operators; plus every unit of `data/backups.json` research 07 does not list — the 71 自选 owned-6★ picks (`diy.ownedPool`, DATA.md §18; the collab operators are not in the data, and 焰狐龙梓兰's entries left the manifest when she left the pool in 0.2.0) — planned from 07's URL patterns (`tools/assets/plan.mjs patternOperator`, `tools/fetch-assets.mjs dataExtras`): avatar and portrait (E0–E1 and E2), the default-skin battle Spine Front / Back, the icon and skill sound of each skill, the sub-profession icon. 209 operators in all (206 with a Back model).
|
||||
- **Operators:** all 138 pool charIds from `activity_table` (`charShopChessDatas[*].charId ∪ backupCharId`), including hidden chess and backup operators; plus every unit of `data/backups.json` research 07 does not list — the 72 自选 owned-6★ picks (`diy.ownedPool`, DATA.md §18; the collab operators are not in the data, and 焰狐龙梓兰's entries left the manifest when she left the pool in 0.2.0) — planned from 07's URL patterns (`tools/assets/plan.mjs patternOperator`, `tools/fetch-assets.mjs dataExtras`): avatar and portrait (E0–E1 and E2), the default-skin battle Spine Front / Back, the icon and skill sound of each skill, the sub-profession icon. 209 operators in all (206 with a Back model).
|
||||
- **Tokens:** the 20 pool tokens, and the 38 summons of the 自选 picks (`data/backups.json tokens`) as extra tokens (below): their avatars; no dump carries the battle Spine of the 自选 summons (upstream has at most skin variants, which the default locations miss), so the web manifest has no model for them — 35 of them, and 4 pool summons, have the official model as an optional local-client overlay ("Token models from the local client"); 3 have no model in the game at all.
|
||||
- **Enemies:** 253 ids planned, 252 in the manifest (心烛 has no assets). The set is the union of:
|
||||
- the 07 enemy list;
|
||||
@@ -148,7 +217,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 +255,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,
|
||||
@@ -195,7 +270,8 @@ All paths are URL paths relative to the site root, for example `/assets/char/ava
|
||||
disconnect, settlementSucceed, settlementFail, settlementTeam, settlementBossSign,
|
||||
goodEvaluation, load, start, matchSucceed, matchFail, matchCancel, joinRoom },
|
||||
battle: { deploy, tokenDeploy, charDie, enemyDie, enemyDieHeavy, enemyHit, heal, leak, win, lose, killCoin },
|
||||
units: { [charId|tokenId|enemyId]: { attack?, hit?, skill?, skills?: {[skillIndex]: url}, die?, born?,
|
||||
units: { [charId|tokenId|enemyId]: { attack?, hit?, skill?, skills?: {[skillIndex]: url}, attacks?: {[skillIndex]: url}, hits?: {[skillIndex]: url}, die?, born?,
|
||||
attackMix?: {[skillIndex]: {p?, vol?}}, hitMix?: {[skillIndex]: {p?, vol?}},
|
||||
mix?: { [attack|hit|die|born]: { p?, vol? } } } }
|
||||
}
|
||||
},
|
||||
@@ -273,8 +349,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`);
|
||||
@@ -338,7 +418,7 @@ Other renderer rules from research 07 §5.4–5.5:
|
||||
"missing skel"), nor of 凯瑟琳's 爬行号·防护单元 and 凛御银灰's 风雪之眼, so they were drawn as the avatar diamond. The
|
||||
local client has them in its battle token packs (`pkgrps/btl_pfb_tokens_*.ab`, the Windows build carries all of them;
|
||||
the iOS build lacks `btl_pfb_tokens_0` and has ASTC pages), the same overlay as the enemies above:
|
||||
- `tools/local-extract/extract.py TOKEN_SPINES` (39 ids; `--only spine/token`) reads each token's battle prefab
|
||||
- `tools/local-extract/extract.py TOKEN_SPINES` (40 ids; `--only spine/token`) reads each token's battle prefab
|
||||
(`dyn/battle/prefabs/[uc]tokens/<id>.prefab`), takes the skeleton of its Front renderer — a directional token has
|
||||
Front / Back (/ Down) renderers, each with its own skeleton of the same name; the web tokens use Front too — and
|
||||
writes it to `public/assets/local/spine/token/{tokenId}/` like an enemy model (skeleton, sized `pma: true` atlas,
|
||||
@@ -361,7 +441,7 @@ Other renderer rules from research 07 §5.4–5.5:
|
||||
|
||||
### Other fallbacks
|
||||
|
||||
- **Emotes and 玩法说明 pages** (`public/js/data.js artUrls / nextArtUrl`, `ui/guide.js guideStage`): the local-client picture (`data/local-assets.json`) first, then the mirror copy (`ui['emoticon/…']`, `ui['guide/…']`), each tried in turn when one fails to load; when none is left — none listed, or every copy failed (for example data/assets.json lists the downloaded pages but the files are not on disk yet: a `git pull` and restart without setup) — the neutral emote glyph, and for a page the official tips text (`config.tips`). The rest of the local-client art (the 3D board, the official HUD sprites, module type icons, the two enemy models and the 39 token models above) is not downloaded: the client looks it up in `data/local-assets.json` only (most of the HUD sprites are on the mirror too, DESIGN §22.5); docs/DEPLOY.md §6 lists what falls back without it.
|
||||
- **Emotes and 玩法说明 pages** (`public/js/data.js artUrls / nextArtUrl`, `ui/guide.js guideStage`): the local-client picture (`data/local-assets.json`) first, then the mirror copy (`ui['emoticon/…']`, `ui['guide/…']`), each tried in turn when one fails to load; when none is left — none listed, or every copy failed (for example data/assets.json lists the downloaded pages but the files are not on disk yet: a `git pull` and restart without setup) — the neutral emote glyph, and for a page the official tips text (`config.tips`). The rest of the local-client art (the 3D board, the official HUD sprites, module type icons, the two enemy models and the 40 token models above) is not downloaded: the client looks it up in `data/local-assets.json` only (most of the HUD sprites are on the mirror too, DESIGN §22.5); docs/DEPLOY.md §6 lists what falls back without it.
|
||||
- **Tokens:**
|
||||
- Without an avatar, use `chars[owner].avatar` with a 召唤物 badge, or `prof.battlecard.token` — except 圣聆初雪's 保护目标(冻结状态) (PRTS 无头像; the frozen gate), drawn as a procedural ice diamond (`render/units.js ICE_TOKENS`).
|
||||
- Without a Spine (and without its local-client model, "Token models from the local client"), draw the avatar sprite with a bob tween.
|
||||
@@ -422,3 +502,14 @@ The project's code is GPL-3.0-or-later (`LICENSE`); none of the items below is c
|
||||
- **Novecento Wide:** © Jan Tonellato / Synthview. Free licence.
|
||||
- Both are mirrored from TimWangZi/The-font-of-Arknights.
|
||||
- Noto Sans SC and Noto Serif SC (SIL OFL) are loaded from Google Fonts, not self-hosted.
|
||||
|
||||
### Skill attack banks (0.2.3, PR #410)
|
||||
|
||||
`pickModeAttacks` / `pickModeHits` select a uniform d/h/s bank from the official audio table (attack/combat abilities
|
||||
only). A skill's activation sound supplies its mode letter; without one, d/h/s = slots 1/2/3 is [ASSUMED]. Competing
|
||||
banks prefer START over ON, then plain/numeric order; this is a deterministic approximation, not proof of every
|
||||
operator's runtime ability graph. Mixed-letter banks are skipped. Preserve `mixOf` for each selected bank separately;
|
||||
`attacks`/`hits` and `attackMix`/`hitMix` use matching skill keys. Regenerate the current manifest instead of importing
|
||||
an older PR's full JSON, preserving JP voices and current operators. See DESIGN §28.17.
|
||||
|
||||
The 40th local token model is the original champagne bomb (`token_10031_swire2_gdtrap`, §29.5), extracted from the official `btl_pfb_tokens_0.ab` through `TOKEN_SPINES`. Its web fallback is retained. Failed board-pack/image requests can retry after connectivity/visibility recovers; a failed three.js module gets at most two fresh-URL retries per page because browser module failures are cached. Successful resources remain cached.
|
||||
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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).
|
||||
@@ -106,7 +109,8 @@ Top level: `{ season, seasonName, modes, economy, lpCapPerRound, bossOvertimeAft
|
||||
| `itemSellable`, `itemDestroyRefund` | `false`, `0` | items can only be destroyed |
|
||||
| `benchSize`, `tempSize`, `deployCap`, `storeCntMax` | `10`, `5`, `8`, `6` | |
|
||||
| `equipPerChess`, `maxArtsPerRound` | `2`, `2` | |
|
||||
| `poolCopies[tier]` + `poolCopiesOverrides` | `{"1":12,"2":14,"3":18,"4":16,"5":8,"6":5}`, `{"chess_char_6_11_a":4}` (缪尔赛思) | shared pool copies per base chess |
|
||||
| `poolCopies[tier]` + `poolCopiesOverrides` | 12/14/18/16/8/5 plus measured low-tier identity exceptions (DESIGN §29.8) | single-player copies; `coopPoolMultiplier: 2` doubles shared operators, not private DIY stock |
|
||||
| `itemPoolCopies[tier]` + `itemPoolCopiesOverrides` | 4/6/7/8/7/3 plus eight measured exceptions (DESIGN §29.8) | shared shop-equipment ownership: normal 1, upgraded 2; non-shop effect-only equipment unlimited |
|
||||
| `goldenCopies`, `mergeCount`, `mergeCountOverrides`, `itemMergeCount` | `3`, `3`, `{"chess_char_2_11_a":2}` (风丸), `2` | |
|
||||
| `rewardOffer` | `{"count":3,"tierOffset":1,"maxTier":6,"price":0,…}` | merge reward: 3 **different** free chess of tier `min(shopLevel+1,6)` (a short tier tops up from the tier below; `PlayerState.pushRewardOffer`, user playtest #6 item 19) |
|
||||
| `handFillOrder` | `"rightToLeft"` | |
|
||||
@@ -161,7 +165,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 +173,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 +247,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 +346,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 +471,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 +523,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"}` | |
|
||||
@@ -572,9 +601,9 @@ Glyph legend (`rows`):
|
||||
|
||||
## 16. Counts (current build)
|
||||
|
||||
`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`,
|
||||
`chess 266 (112 visible; 283 selectable skills over the visible chess, 185 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: 89 units (17 stand-ins, 72 owned-6★ picks; 259 forms), 38 自选 summons, 4 DIY slots, 15 / 9 prototype picks (tier 5 / 6), 72 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,10 +652,10 @@ 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
|
||||
`units[charId]` — first the 17 stand-ins, then the 72 owned-6★ 自选 picks (`diy.ownedPool`); no unit for a PRESET or DIY
|
||||
chess:
|
||||
|
||||
| Field | Example (`char_611_acnipe`) | Meaning |
|
||||
@@ -638,7 +667,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` +
|
||||
@@ -691,7 +720,7 @@ operator (自选)").
|
||||
| `slots[slotId]` | `{"tier":5,"goldenId":"chess_char_5_diy1_b","shopLevel":5,"requirement":"TIER_6"}` | the four DIY chess `chess_char_5_diy1/2_a`, `chess_char_6_diy1/2_a` (chess.json: price 4, sell 1, `diyRequirement`, empty `bonds` / `garrisonIds`); `shopLevel` = the 调度中心 level whose `shopLevelDisplayDataDict.charChessDiySlotIdList` lists the slot |
|
||||
| `prototypes[tier]` | `{"5":[…15],"6":[…9]}` | the legal prototype picks: the nine 6★ at both tiers, at tier 5 also the six 4★ that are not 先锋 / 特种 ("第5阶可额外从6名四星原型干员(先锋、特种职业除外)中选取"; `DIY_EXTRA_PROTOTYPES`). A prototype may fill a tier-5 and a tier-6 slot ("原型干员可于5、6阶之间重复选取") |
|
||||
| `locked[tier][charId]` | `{"skillIndex":2,"uniEquipId":"uniequip_002_acguad","from":["chess_char_5_06_a","chess_char_5_13_a"]}` | the skill and module a prototype carries in a slot of that tier — "技能携带规则与系统补位时一致" (PRTS 卫戍协议), read as [ASSUMED] (the owner's decision of 2026-10-05) the selection of its 补位 rows at that tier (`from`): the eight 6★ elites S3 with their own module, 领主·Sharp S1, the reserves S3 without a module; 预备干员-医疗 has no tier-5 row: S3 by analogy (`from` `[]`, `DIY_PROTOTYPE_FALLBACK_SKILL`) |
|
||||
| `ownedPool[]` | 71 charIds | the owned 6★ a player may slot: obtainable, rarity = the requirement, and no chess names it — hidden chess included ("不可甄选加入已在名单中的固定干员"); each at most once per roster ("玩家已拥有干员不可重复选取"). Not the collab operators (`excluded`) |
|
||||
| `ownedPool[]` | 72 charIds | the owned 6★ a player may slot: obtainable, rarity = the requirement, and no chess names it — hidden chess included ("不可甄选加入已在名单中的固定干员"); each at most once per roster ("玩家已拥有干员不可重复选取"). Not the collab operators (`excluded`) |
|
||||
| `excluded[]` | `["char_456_ash","char_1029_yato2","char_1048_orchd2","char_4123_ela","char_4141_marcil","char_4182_oblvns","char_4217_makoto"]` | the 7 obtainable 6★ outside the pool that come from a 联动寻访 — a collab team in `mainPower` / `subPower` (`DIY_EXCLUDED_TEAMS`: rainbow, action4, mujica, sees, laios — 灰烬, 麒麟R夜刀, 艾拉, 玛露西尔, 丰川祥子, 结城理) or a collab series in `displayNumber` (`DIY_EXCLUDED_NUMBER_PREFIXES` MH / RS / AM / PS / DD — also 焰狐龙梓兰 MH05, whose team reserve6 names no collab): left out of the data and the pool by the owner's decision of 2026-10-05 (copyright); the excel does not exclude them |
|
||||
| `operators[charId]` | `{"name":"煌","rarity":6,"profession":"WARRIOR","subProfessionId":"centurion","obtainable":true,"powers":["rhodes","elite","yan","victoria"],"bonds":["yanShip","victoriaShip"]}` | every pick (owned pool + prototypes): `powers` = the `nationId` / `groupId` / `teamId` of `mainPower` and of every `subPower` (隐藏势力); `bonds` = the core bonds whose `powerIdList` meets them — one or several — else `economy.fallbackBondId` 协防干员 ("甄选加入的干员会根据其实际阵营所属分配核心盟约,若没有可匹配的则改为分配协防干员盟约"); every prototype gets `["emptyShip"]` |
|
||||
|
||||
@@ -705,5 +734,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`).
|
||||
@@ -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`)用它核对安装。
|
||||
|
||||
@@ -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) per-operator 潜能 / 练度, Japanese voices, the statistics page, 失衡, whole-frame attack timing and the GitHub fixes after 0.2.1 — §27. **0.2.3** (2026-10-10; `package.json`, `shared/constants.js APP_VERSION`) 克莱门莎 and new modules, community fixes, text size, per-operator voices, installation, the opening reroll and local match recovery — §28. **0.2.4** (2026-10-11) the official shared card pool and item stock, Raid revival and #475, and the community fixes after 0.2.3 — §29, 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,6 @@ 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) |
|
||||
| §28 | [history/0.2.3.md](history/0.2.3.md) | 0.2.3 — after the 0.2.2 release (2026-10-10) |
|
||||
| §29 | [history/0.2.4.md](history/0.2.4.md) | 0.2.4 — after the 0.2.3 release (2026-10-11) |
|
||||
@@ -80,12 +80,12 @@ legal from R14 into R15. The client mirrors it
|
||||
| Timer (real s, × `opts.timerScale`) | Value |
|
||||
|---|---|
|
||||
| INFO_CHECK | `config.timers.infoCheck` 25 |
|
||||
| band draft turn | `Match.BAND_TURN_SECONDS` 30 [ASSUMED] (= `timers.bandTurn`), the step's only countdown: `m.public.deadline` = the current turn's end, no step cap (`timers.bandDraft` 50 = the official whole step, informational) (co-op; solo / single human untimed) |
|
||||
| band draft turn | `Match.BAND_TURN_SECONDS` 50 (owner official-play report, 2026-10-10) (= `timers.bandTurn`), the step's only countdown: `m.public.deadline` = the current turn's end, no step cap (`timers.bandDraft` 50 = the official whole step, informational) (co-op; solo / single human untimed) |
|
||||
| BATTLE_CHECK | `battleCheck` 3 |
|
||||
| 机变 first / other pickers | `spFirst` 30 / `spTurn` 16 (co-op; solo / single human untimed) |
|
||||
| PREP | `modes[m].rounds[r].prepTime` (co-op; solo / single human untimed) |
|
||||
| COMBAT / 联防 | `modes[m].rounds[r].combatTimeLimit` (= the level's `maxPlayTime`) real seconds = 2× that in game seconds |
|
||||
| 最终攻势 / 隐秘核心 | no hard stop: countdown `rounds[r].levelMaxPlayTime` 120 real s; overtime drain from `bossOvertimeAfter` 150 real s, 1 team LP per real s (§3) |
|
||||
| 最终攻势 / 隐秘核心 | no hard stop: countdown `rounds[r].levelMaxPlayTime` 120 real s; overtime drain at 120 real s for all-human teams, configured 150 real s with a non-departed AI teammate; 1 team LP per real s (§3) |
|
||||
| ROUND_START / after combat / SETTLE | 2 / 1.5 / 3 (presentation delays, `Match.DELAYS`) |
|
||||
| bot action delay | 0.9 s (+0.35 s per seat) |
|
||||
|
||||
@@ -95,10 +95,10 @@ band draft / 机变 / PREP deadline, and BATTLE_CHECK / ROUND_START / SETTLE run
|
||||
(draft order and skip, 6 机变 cards, 联防) stay.
|
||||
`m.public.deadline` is the absolute end of the current timer (ms epoch, 0 = untimed; combat: estimated end at 2×;
|
||||
最终攻势 / 隐秘核心: the boss level's `levelMaxPlayTime` countdown, 120 real s — the battle goes on past it — with
|
||||
`m.public.overtimeAt` = when the overtime drain starts, 150 real s; both on the field clock).
|
||||
`m.public.overtimeAt` = when the overtime drain starts: 120 real s for all-human teams, 150 real s with an AI teammate at fight start; both on the field clock).
|
||||
|
||||
### 1.1 Band draft
|
||||
Co-op: random order (all seats, bots included), one pick per turn, ONE countdown: `BAND_TURN_SECONDS` 30 s per turn,
|
||||
Co-op: random order (all seats, bots included), one pick per turn, ONE countdown: `BAND_TURN_SECONDS` 50 s per turn,
|
||||
published as `m.public.deadline` (= `draft.turnDeadline`; `draft.turnSeconds` its length) — no step cap; AI seats pick
|
||||
at once. A turn that runs out takes the strategy the player highlights in the draft screen (`g.bandFocus {bandId?}`,
|
||||
`Match.timeoutBand`) while it is allowed and no teammate holds it, else `bandDraft.timeoutBandId` 华法琳, else the first
|
||||
@@ -174,19 +174,24 @@ otherwise every "之后 / 后续的每场作战" card — e.g. 教鞭's 法术
|
||||
A `choice:<effectId>` registry handler overrides the default application (§2.4).
|
||||
|
||||
### 1.3 Disconnects, AI takeover
|
||||
* Local multi-window recovery (#431, DESIGN §28.27) sends optional `hello.noReplace` for a tentative shared token,
|
||||
and `hello.claimAt` for a previously welcomed token. Before binding a different socket, the server rejects a
|
||||
connected seat when no replacement was allowed or the request has a later stamp than the attached holder
|
||||
(`SESSION_IN_USE`). Equal stamps still allow normal reload/reconnect. These hints do not authenticate players
|
||||
and do not change the bearer token or expiration rules below.
|
||||
* 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 +268,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 +281,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 +433,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 +467,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 +544,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 +655,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
|
||||
@@ -637,8 +682,9 @@ Any `choice:` handler whose EffectRef reuses its own key must guard like this (o
|
||||
which replaces the fixed pool of 「保持固定血量」 (config `bossHpScale.perPlayer: false` with `solo: 0.25` restores it;
|
||||
its optional × alive / 4, `aliveScaling`, stays off); leaders are never scaled by `enemyScale`, their parts and escorts
|
||||
keep their own HP. The merged team LP loses leaks (`lpr`), the overtime drain
|
||||
(`bossTurnHpReduceTime` 150 counts REAL seconds, like the boss level's 120 s maxPlayTime that runs out first — the
|
||||
battle goes on — so 1 LP per real second from 150 real s = 300 game s on the 2× field clock; `gd.bossOvertimeDue`)
|
||||
(the boss level's 120 real-second countdown for all-human teams; `bossTurnHpReduceTime` 150 real seconds only with
|
||||
a non-departed AI teammate at fight start. The first deduction follows one complete real second after that threshold;
|
||||
`gd.bossOvertimeDue` receives the fight's frozen threshold — DESIGN §29.1)
|
||||
and leader "扣除目标生命" effects (the sim's `lpLoss` hook: boss_7 Doom, 斥退 …); after every change it is written back
|
||||
to the alive players as shares of the LP each brought in (`lpAtFinal`, largest remainder), so `lp` in m.public /
|
||||
m.private / m.result is what is left (Σ = team LP).
|
||||
@@ -826,9 +872,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,
|
||||
@@ -856,3 +904,36 @@ round was over. The official 1 s `broadcastBeginDelay` is not modelled.
|
||||
* A merge completed after the prep (SETTLE effects) keeps its reward offer for the next prep; its elite goes where a prep
|
||||
merge's would — onto the tile of a consumed deployed copy (PRTS 卫戍协议/帮助 "若消耗已部署至作战区的干员,则发送至作战区
|
||||
对应位置"), else to the hand, overflowing into temp (temp pieces that arrive after the prep wait through the next prep).
|
||||
|
||||
## Opening rerolls and bot reactions (0.2.3)
|
||||
|
||||
`match/setupVote.js` owns the INFO_CHECK vote and transactional opening replacement; `Lobby.rerollSetup` checks host
|
||||
permission for `room.rerollSetup {setupRevision}` and `room.cancelReroll {voteId}`. Participants send
|
||||
`g.rerollVote {voteId, agree}`; `g.infoReady {setupRevision?}` accepts an omitted revision only before the first reroll.
|
||||
`m.public` includes `setupRevision` and `rerollVote` (null or `{id, proposerId, voters, agreed}`). Old vote/revision
|
||||
requests cannot affect a new opening. See DESIGN §28.21 for cancellation and timer behavior.
|
||||
|
||||
`botEmotes.js` is isolated from bot decisions and RNG. It sends the existing `m.emote` only in mixed matches, under
|
||||
normal cooldown/whitelist rules; `SP_BOT_EMOTES=0` disables it. The cooperation score in `bot.js` and these cosmetic
|
||||
choices are explicitly [ASSUMED]; DESIGN §28.15–16 records the source and validation.
|
||||
|
||||
### Community follow-up contracts (DESIGN §29)
|
||||
|
||||
- `triggerGarrisons` includes `source.copiedFrom` for copied effects. Only Ptilopsis copying Yu/Gladiia at SERVER_PREP_START skips the three-in-row prerequisite, as confirmed by the original-game recording; Saria and other copies keep existing conditions.
|
||||
- Jessica's placeable shield uses `shared/summonPlacement.js` for the four orthogonal neighbors, independent of her selected skill, facing or module. Client preview and server moves/swaps use the same grid.
|
||||
- Client result statistics resolve DIY and stand-in names from the authoritative BattleSpec. A client cannot replace those names.
|
||||
|
||||
### Shared inventory after 0.2.3 (DESIGN §29.8)
|
||||
|
||||
`SharedItemPool` derives equipment availability from current ownership across all players: ordinary 1, upgraded 2.
|
||||
Equips, merges and returns cannot lose reservations. Consumptions/destruction release ownership [ASSUMED removal
|
||||
interpretation]. Shop/reward displays reserve nothing; rejected exhausted purchases retain funds and offers.
|
||||
整备 upgrades the purchased item only when `left` still has the golden's second copy; otherwise the item stays
|
||||
normal and the charge remains.
|
||||
`m.public.sp.cards[].soldOut` disables duplicates when a preceding choice used the final copy; timer/bot choices
|
||||
use the same availability. Non-shop effect-only equipment is unlimited.
|
||||
|
||||
Operator capacities use measured single-player exceptions and twice those counts in co-op; DIY remains private.
|
||||
`take(..., { overdraw: true })` records full occupation of a granted/promoted elite or a Mimic copy beyond capacity;
|
||||
normal takes remain bounded. Negative internal balance is a deficit, never negative available stock. Existing
|
||||
item selection probabilities and explicit grant weights remain unchanged.
|
||||
@@ -6,6 +6,8 @@
|
||||
|
||||
界面语言:默认中文;标题画面右上角或「设置」里可切换 **中文 / English / 日本語 / 한국어 / 繁體中文**(会记住选择,不用刷新)。日语、韩语、繁体中文的游戏文本取自官方日服 / 韩服 / 台服数据,界面文字暂未翻译——日语和韩语显示英文界面,繁体中文显示简体中文界面,欢迎社区补全(docs/I18N.md「Adding a language」)。英文的干员、技能、盟约、装备、敌人等游戏文本取自官方英文服数据,界面文字全部有英文;切到英文后仍显示中文的只有玩家名字(人机的名字也是)、多轮悬赏改写过的「接下来两场作战」说明,以及官方英文服还没有的游戏文本(部分自选干员和较新的模组);「玩法说明」的教程图是官方的中文图片,标题有英文(docs/I18N.md)。
|
||||
|
||||
干员语音(官方台词):「设置」里的「干员语音」调音量,「语音语言」选 **中文 / 日本語**(默认中文,和界面语言无关,会记住选择;日语缺了哪一句就播那一句的中文)。点选干员时——作战区或整备区里的干员、商店和晋升奖励里的干员卡片——会说一句官方的「选中干员」语音,休整期也一样;连续点选时新的一句接替正在说的那句,但不会打断正在播放的部署、技能等作战语音。部署(每场第一个部署的干员说「行动出发」)、技能、接敌和结算语音只在作战中播放。替补干员和原型干员(预备干员、Sharp、Mechanist 等)、盟约·辅助干员和召唤物没有官方语音,点选时不出声。
|
||||
|
||||
## 1. 一局的流程
|
||||
|
||||
```
|
||||
@@ -17,11 +19,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)**和一条永久规则。同盟:随机顺序轮流选,**每人 50 秒**,右上角和「决策中」显示同一个倒计时;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 节。最终攻势和隐秘核心没有这一框(之后直接是结算称号或隐秘核心);已淘汰的玩家和观战者不弹框。 |
|
||||
|
||||
@@ -46,9 +49,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 格)。
|
||||
@@ -60,8 +63,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。
|
||||
|
||||
@@ -70,16 +74,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 的决定);高普尼克免疫晕眩、冻结和浮空。
|
||||
@@ -110,8 +115,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%」(维多利亚、谢拉格、奥术、脆弱等)这类伤害倍率之间仍然相乘;但同名效果只算一个——同一战场两名玩家都开了「奥术」时,敌人身上只挂一层奥术易伤,取两人中较高的那一个(不再相乘);两名「灵知」的坚冰、两名「莱恩哈特」/「缄默德克萨斯」的法抗削减也一样只算最强的一个。「坚守」的反伤计入被打的那名成员(个人对领袖伤害统计里能看到)。
|
||||
- 每局会随机**禁用**若干盟约(标准模拟较少),它们的干员不会出现在商店;开局的「确认本局信息」会列出,选择策略时(「查看禁用盟约与干员」)和对局中(左上角 🔍)也能再查看。
|
||||
@@ -132,8 +137,8 @@
|
||||
- 左下角「交流」打开表情面板:6 套主题 × 6 个表情,左右滑动(或方向键)换主题。表情以气泡形式显示在你头像旁 3 秒,发送冷却 1 秒。
|
||||
- 休整期、或自己的作战结束后(显示「作战结束,等待队友完成作战」),点左侧队友头像 →「前往查看」观看他的战场;自己的作战进行中不能观战。联防和最终攻势中可以用 ‹ › 切换左半 / 全景 / 右半。
|
||||
- 被淘汰后可以自由观看任何战场,而且**不用每回合重选**:你上次点「前往查看」的是谁,之后每个阶段(回合开始、机变、休整期、作战、最终攻势)都会自动跟到他的阵地或战场;没选过或他也被淘汰时,跟随座位顺序第一位还在场的博士(包括 AI 队友)。阶段中途换人立即生效,之后就跟随新选的人。
|
||||
- **观战席**(本作新增,原版没有):每个同盟房间另有 2 个观战席。在大厅输入同盟密钥后点「观战」进入(模拟进行中也可以),不占博士席位、不算参战人数,也不能购买、部署、准备或发送表情;观看方式与被淘汰的玩家相同——自动跟随你上次查看的博士(没查看过则是座位顺序第一位还在场的博士):休整期显示他的阵地,作战时观看他的战场,点左侧成员头像可切换查看任何人。断线重连会回到观战席;创建者可以在等待室用观战者名字旁的 ✕ 将其移出;观战者在等待室有空位时可以点「入座」成为博士。
|
||||
- **观看谁,盟约栏就显示谁的盟约**:查看队友的阵地或战场时(休整期、作战结束后、被淘汰后自动观战),左上的盟约栏换成**该队友的盟约、人数和层数**,前面有琥珀色的「👁 队友名」标签,点开的盟约详情也写着「队友名 的盟约」(成员一栏只标出他在场上的干员,整备区看不到);作战中层数实时增长(与他自己看到的一致)。联防和最终攻势里跟随 ‹ ›:镜头在队友的半场时显示他的盟约,在你自己的半场时显示你自己的;「全景」时,你在这个战场上作战就显示你自己的,你不在这个战场上(漏怪后观看联防、被淘汰后观战)则显示你点「前往查看」选中的那名队友,没选过就显示该战场的第一名玩家(1 号联防者 / 这一组的第一位),**不会显示你自己的**。在这样的战场上点某名队友的头像 →「前往查看」,镜头会直接移到他的半场,‹ › 上的「👁 队友名」与盟约栏的标签一致;‹ › 旁边有「返回战场」(被淘汰时没有)。盟约栏换人时,从盟约栏打开的盟约详情会自动关闭。点开场上某个干员的详情卡时,卡上的盟约(以及点它打开的盟约详情)属于**这个干员的主人**,与盟约栏当前显示谁无关——例如在「全景」点队友的干员,看到的是「队友名 的盟约」及他的人数和层数;在队友的半场点你自己的干员,看到的是你自己的。回到自己的阵地 / 战场后恢复成你自己的盟约。作战结束后观看队友时刷新页面 / 断线重连,仍会回到正在观看的队友战场,「👁 队友名」和「返回战场」都还在。已被淘汰的队友不再显示盟约(也无法被查看)。
|
||||
- **观战席**(本作新增,原版没有):每个同盟房间另有 2 个观战席。在大厅输入同盟密钥后点「观战」进入(模拟进行中也可以),不占博士席位、不算参战人数,也不能购买、部署、准备或发送表情;观看方式与被淘汰的玩家相同——自动跟随你上次查看的博士(没查看过则是座位顺序第一位还在场的博士):休整期显示他的阵地,作战时观看他的战场,点左侧成员头像可切换查看任何人。断线重连会回到观战席;创建者可以在等待室用观战者名字旁的 ✕ 将其移出;模拟开始后,顶栏延迟旁会出现一个「👁 人数」的小胶囊(没有观战者时不显示),点开是观战者名单(离线的有标记),创建者可以在名单里用 ✕ 把观战者移出,被移出的人立刻回到大厅、不再收到战场画面(观战席上限仍是 2 个,不能调整);观战者在等待室有空位时可以点「入座」成为博士。
|
||||
- **观看谁,盟约栏就显示谁的盟约**:查看队友的阵地或战场时(休整期、作战结束后、被淘汰后自动观战),左上的盟约栏换成**该队友的盟约、人数和层数**,前面有琥珀色的「👁 队友名」标签,点开的盟约详情也写着「队友名 的盟约」(成员一栏标出他在场上的干员;休整期查看时他整备区、临时整备区里的干员也会列出、标为「整备区」,和他自己看到的一样计数:投资人、远见、奇迹这类含整备区的盟约把整备区的算进成员人数,临时整备区的哪个盟约都不算;作战中只列出场上的);作战中层数实时增长(与他自己看到的一致)。联防和最终攻势里跟随 ‹ ›:镜头在队友的半场时显示他的盟约,在你自己的半场时显示你自己的;「全景」时,你在这个战场上作战就显示你自己的,你不在这个战场上(漏怪后观看联防、被淘汰后观战)则显示你点「前往查看」选中的那名队友,没选过就显示该战场的第一名玩家(1 号联防者 / 这一组的第一位),**不会显示你自己的**。在这样的战场上点某名队友的头像 →「前往查看」,镜头会直接移到他的半场,‹ › 上的「👁 队友名」与盟约栏的标签一致;‹ › 旁边有「返回战场」(被淘汰时没有)。盟约栏换人时,从盟约栏打开的盟约详情会自动关闭。点开场上某个干员的详情卡时,卡上的盟约(以及点它打开的盟约详情)属于**这个干员的主人**,与盟约栏当前显示谁无关——例如在「全景」点队友的干员,看到的是「队友名 的盟约」及他的人数和层数;在队友的半场点你自己的干员,看到的是你自己的。回到自己的阵地 / 战场后恢复成你自己的盟约。作战结束后观看队友时刷新页面 / 断线重连,仍会回到正在观看的队友战场,「👁 队友名」和「返回战场」都还在。已被淘汰的队友不再显示盟约(也无法被查看)。
|
||||
|
||||
## 9. 结算称号
|
||||
|
||||
@@ -167,6 +172,41 @@
|
||||
| `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 局保留完整的阵容、盟约和队友信息,更早的压缩成你自己的一行汇总。同盟对局的记录里有**队友的代号**,它们保存在本机,也会出现在你导出的文件里;不会发给任何人。想删掉就点「清空」(会先确认),或清除这个网站的数据。读不出来的存档(损坏的数据)会另存为备份并重新开始记录,不会让页面出错;由更新版本导出的文件会被拒绝导入,也不会被旧版本覆盖。
|
||||
|
||||
### 0.2.3 的便捷设置
|
||||
|
||||
设置中的「文字大小」有小、中、大、特大四档,默认小;棋盘尺寸不随文字放大。干员调配详情和已选自选干员卡可
|
||||
单独选择中文、日语或跟随全局语音,只保存在当前浏览器,日语缺失时沿用中文回退。
|
||||
|
||||
同一浏览器的对局窗口意外关闭后,可以从标题或大厅的「恢复本机对局」选择保存的席位。仍被其他窗口使用的席位
|
||||
(包括正在刷新、初始化的窗口)会保留给原窗口;刷新只尝试本窗口原来的身份,不会自动换成另一个近期身份。
|
||||
恢复在当前页面重新连接,服务器确认后才保存新身份;发生竞争时,较早获得服务器确认的窗口优先。
|
||||
服务器重启、对局过期或浏览器无法检查其他窗口时不能恢复。
|
||||
|
||||
浏览器提供安装条件时会显示「添加到桌面」。安装后仍需连接游戏服务器,不会把服务器或整套素材离线保存。
|
||||
不支持页面内安装按钮的浏览器可使用其自己的菜单(是否可用由浏览器决定)。
|
||||
|
||||
确认开局信息时,房主可发起「重刷本局」。全体真人同意后重抽开局,保留房间和个人配置;投票暂停确认倒计时,
|
||||
拒绝、取消、断线或退出则保留原开局和剩余时间。只有一名真人时立即刷新。随机结果可能重复。
|
||||
@@ -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,19 +432,38 @@ 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
|
||||
`sharedBoss`** ends only when the pool reaches 0 or on `forceEnd` (DESIGN §5.5) — an emptied field keeps running
|
||||
(the h07_04 pair leader has no wait step and can walk into the objective: the leak costs its `lpr`, then the field
|
||||
idles while another field may still empty the pool or the match's overtime drain — team LP, from 150 real s — ends it); without a pool
|
||||
idles while another field may still empty the pool or the match's overtime drain — team LP, from 120 real s for all-human teams / 150 with an AI teammate — ends it); without a pool
|
||||
(sandboxes, tools) it still ends `cleared` when empty. `timeout`: `time ≥ timeLimit` —
|
||||
every living non-boss enemy becomes a leak; spawns that never happened are dropped from `total` and listed in `unspawned`.
|
||||
- `leaked[].counted = false` for `notCountInTotal`/`unharmful`/boss parts (LP rules: normal rounds count only `counted`
|
||||
@@ -490,6 +535,11 @@ flag `float` (近地悬浮, PRTS 术语释义 "算作空中单位") or `levitate
|
||||
walking the ground path). `deploySeq` counts deployments (and identifies one: `seq === u.deploySeq`);
|
||||
`aggroSeq` is the aggro order (= deploySeq, except the summons of the initial deployment, §1).
|
||||
|
||||
克莱门莎 S2's kit stores its cabin in `unit.mem.clemntCabin`. Before boarding each tick, it removes passengers
|
||||
that died, became hidden / uncarryable or cannot path to the next cabin position, removes their carry buff and
|
||||
returns their current `weight` (including modifiers). The same tick's count and weight limits use that cleaned
|
||||
list and budget (DESIGN §28.27); this is local to the kit, not a change to general displacement.
|
||||
|
||||
**Hit areas (`body.js`, user playtest #5 item 10).** A regular enemy is a point: in a grid range when the tile of its
|
||||
position (`round(y)`, `round(x)`) is a range tile, in a radius when its position is (DESIGN §3). A huge enemy (巨型单位:
|
||||
`enemy.hitArea` from data/enemies.json `hitArea` — 假想敌:胄 / 管 / 盐风主教昆图斯 / 阿利斯泰尔 / “萨米的意志”, PRTS
|
||||
@@ -520,6 +570,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 +587,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 +653,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 +802,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
|
||||
@@ -781,7 +845,7 @@ registration order. `battle.off(handle)` / `battle.off(name, fn)` / `battle.offO
|
||||
| `tick` | `{ dt }` | end of every tick |
|
||||
| `beforeAttack` | `{ attacker, targets, isSkill, profile }` | allies **and** enemies; replace/filter `ctx.targets` |
|
||||
| `enemyAttackStart` | `{ enemy, targets }` | an enemy starts a normal attack (it has targets), **before** 麻痹 may interrupt it (ai.js enemyAttack; the client's ON_BEFORE_ABILITY_SPELL_ON): a burst a handler causes interrupts that very attack (酒神 堕梦, PRTS 备注 "触发的元素爆发可打断当次普攻"); a handler that kills or stuns the enemy ends it |
|
||||
| `attack` | `{ attacker, targets, isSkill }` | an attack/heal was performed (projectiles may still be in flight) |
|
||||
| `attack` | `{ attacker, targets, isSkill, targetDeployments? }` | an attack/heal was performed (projectiles may still be in flight). Normal enemy attacks provide a `Map<Unit, deploySeq>` captured before their damage; post-attack control effects must not cross a target redeployment. |
|
||||
| `hit` | `{ source, target, dmg, credit }` | before mitigation; mutate `dmg` (not fired for gauge fills — see `elementHit`). `source` may be null (terrain; 无来源 `dmg.sourceless` bursts, whose `credit` names the unit credited) |
|
||||
| `elementHit` | `{ source, target, dmg }` | before a gauge fill (`dmg.type === 'element'`); mutate `dmg.amount`/`dmg.mul`, set `dmg.cancel` |
|
||||
| `damaged` | `{ source, target, amount, type, dmg, credit }` | after application (`amount` may be 0 when shielded); element fills too (with their source); 无来源: `source` null, `credit` set |
|
||||
@@ -789,7 +853,7 @@ registration order. `battle.off(handle)` / `battle.off(name, fn)` / `battle.offO
|
||||
| `heal` | `{ source, target, amount, opts }` | mutable `amount` |
|
||||
| `fatal` | `{ unit, source, credit, dmg, amount, prevented }` | HP would reach 0 — set `prevented` (substitutes, kit savers, 不死 items; the 复活 — M3茧甲, 埃芒加德 — and 不屈 are `death` hooks). Fired by every HP loss of a unit without a boss pool — hits of any type, element bursts, 无来源 damage, `loseHp` 流失. Order: kits' own savers (10 … −60) → items' 不死 (坚固维式重锤 — once per deployment: `items/battle.js deploymentOf`, a key every deploy changes (a 复活's redeploy too); one battle-level hook holds the running windows (`holdsUndying`), so a window outlasts a lend, DESIGN §21.21 — the lock `PRIO_REVIVE` −100 after the substitutes (−100, registered first), the running windows `PRIO_UNDYING_HELD` −99 before them: a 傀儡师 holding 不死 does not switch, PRTS 分支特性信息 傀儡师 "未持有不死的情况下", DESIGN §22.11) → 淬羽赫默 S3's 不死 (−100.5: PRTS 衍生生命 "无畏者协议提供的不死效果仅在目标没有不死效果时才会触发"; its running windows held at −99 like the hammer's). The 复活 act on the knock-out after this step (PRTS "复活" acts on a knock-out, which a 不死 prevents): `death`, below |
|
||||
| `dollSwitch` | `{ unit, reason, done }` | content switches a 傀儡师 to its <替身> now (归溟幽灵鲨 S2 "技能结束后立刻切换为<替身>": no lethal HP loss); its trait does it unless it already is one or is not on the field, and sets `done` |
|
||||
| `dollSwap` | `{ unit, form }` | a 傀儡师 starts a switch — to its <替身> (`form` `'doll'`) or back to its <本体> (`null`); not when it is knocked out as the 替身 (不屈 rolls on it: "切换<替身>与<本体>时") |
|
||||
| `dollSwap` | `{ unit, form }` | a 傀儡师 starts a switch — to its <替身> (`form` `'doll'`) or back to its <本体> (`null`); not when it is knocked out as the 替身 (不屈 rolls on it: "切换<替身>与<本体>时"). 远牙's granted `garrison_108_a/b` gains on it too (player-confirmed, DESIGN §28.5), sharing its deployment cap and active-bond checks. |
|
||||
| `kill` | `{ killer, victim }` | victim HP reached 0 (a handler may revive by restoring HP) |
|
||||
| `death` | `{ unit, reason:'killed'|'leak'|'retreat'|'merchant'|'expired'|'forcedExit', killer, dying, revivedBy? }` | unit removed (`'forcedExit'`: an operator entering 联防 knocked out, §1.1; `dying`: a `retreat` that plays the death animation — 史尔特尔's 余烬, 骑士戒律 + 竞技旗). Revives of a knock-out redeploy the operator inside it, free, where it lies — PRTS "“复活”的实现方式为:受益者因移动之外的原因退场时下次部署的再部署时间和费用归零", so the 被击倒时 and 部署时 effects both run (a deploy-timed skill — `activateOnDeploy` — starts anew: a 突袭 member with one jumps at once, DESIGN §25.17.5): M3茧甲 (`PRIO_RESPAWN` 13) → 埃芒加德 (`PRIO_BAND_REVIVE` 12) — `items/battle.js reviveNow`, which sets `revivedBy` ('item' / 'band'; 阿戈尔 5 does not count such a knock-out as the member's first) — → 阿戈尔 5 (11; a knock-out by the battle-start devour waits for the last devour pass, then the knocked-out members go by board position, DESIGN §25.22.5) → 不屈 (10); until 0.2.0 the two 复活 were `fatal` savers that kept the unit standing |
|
||||
| `skillStart` / `skillEnd` | `{ unit, skill, reason }` | mutate `skill.ammoLeft` / `skill.timeLeft` in skillStart (bullets added there raise `skill.ammoMax`, the ammo bar's full mark) |
|
||||
@@ -829,7 +893,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,27 +901,35 @@ 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 }` |
|
||||
| `mapTile(ps, row, col, abs?)` / `mapDir(ps, dir, abs?)` | board → field tile / direction of a player (the FA right-side mirror) |
|
||||
| `onOwnBoard(ps, r, c)` | whether a field tile lies on the player's own board — GEO.FIELD (rows 9–12, cols 2–10) mapped onto this field: the 联防 right-hand helper's +8 columns, the boss field's rows 2–5 (never its hand / 临时整备区 rows 0–1), the mirrored right half; 乌尔比安's S3 【移动】 takes only such tiles (DESIGN §25.17.2) |
|
||||
| `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) |
|
||||
| `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/player, never a boss field's hand / 临时整备区 rows. Raid additionally admits the empty deployment half only in `mode_multi_*` boss/hidden fights (rows 2–5, cols 2–18), without changing this general method or terrain/reservation checks (DESIGN §29.13, #475) |
|
||||
| `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, keepFacing})`, `pull(enemy, force, {to, center, stop, keepFacing})`, `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 μ =Line truncated
|
||||
| `displace(enemy, {x, y}, tiles, {dur, keepFacing})` | 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?)` | |
|
||||
| `battle.grid` | `tile(r,c)` → `{glyph, key, height:'LOW'|'HIGH', build, pass:'ALL'|'FLY'|'NONE', terrain, special}`, `inRect`, `canStand(r,c,{ranged})` (every automatic placement: the 突袭 landing tile, tactical points, summon tiles — `build` is the effective deploy type, so never the 深水区 `tile_deepsea` (PRTS 深水区 地形信息 "拒绝部署(待补充)"; the 特制水上平台 of the inactive act1 m05 is not modelled, its tiles stay NONE here [ASSUMED]); no melee unit on a hard-blocked 射击台 / mound tile), `groundPassable`, `isLow`, `findPath(sr,sc,er,ec)`, `specialTiles('start'|'end'|…)`; `battle.rect`, `battle.stage` (normalised stage incl. `special` terrain params) |
|
||||
| `battle.data` | DataSource: `getChess(id, loadout?)`, `getEnemy(key)`, `getToken(id, ownerChessId, ownerLoadout?)`, `getStage(id)`, `getWave(id)` (normalised defs; raw record in `def.raw`; the per-battle loadout view, §12) |
|
||||
|
||||
`keepFacing` is presentation metadata emitted by `push` and `pull` by default: `displace` includes it in the fx so the
|
||||
client retains the enemy's pre-hit facing through its slide. Directional Wild Mane S2 (#418) and radial Mostima S3 have
|
||||
official footage directly showing unchanged facing. Mint S2 uses `push({ inward: true })`, while hook pulls such as
|
||||
Gladiia S1/S2 use `pullToFront` → `pull`; the supplied drag clips directly show unchanged facing before and after the
|
||||
shown pulls. Applying that observation to pulls against prior facing and other sources is [ASSUMED]. Either method can
|
||||
pass `keepFacing: false` for a documented exception. Raw `displace` calls still face their travel direction. No facing
|
||||
flag alters movement or 失衡.
|
||||
|
||||
---
|
||||
|
||||
## 7. Skills
|
||||
@@ -869,7 +941,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
|
||||
@@ -878,13 +952,15 @@ instance) and skip `'counter'` / `'reflect'` damage. When the guard trips, the l
|
||||
**and** about to attack/heal **and** an enemy (heal skills: an injured ally) inside the **initial** range
|
||||
(`unit.baseRangeKeys`: its own grid + its permanent rangeExtend — "攻击范围扩大" modules/talents as persist never-expiring
|
||||
`rangeExtend` buffs; no skill range, no temporary extend, no extra keys) or blocked by the melee unit — or, checked
|
||||
**every tick**, an enemy (flyers included) inside a content trigger range (`unit.skill.addTriggerRange(fn)`,
|
||||
**every tick**, an enemy (flyers included) inside a content trigger range (`unit.skill.addTriggerRange(fn, { attackOnly: false })`,
|
||||
`fn(battle, unit)` → list of ally units (their current range), tile-key arrays or `{ keys, profile }` (tile keys with
|
||||
the enemy profile the effect selects by — `canHitFly` false: ground enemies only); returns an unregister fn; not for
|
||||
heal skills: 海嗣 "攻击范围视为自身攻击范围的延伸", 流形, 谬因 S2's beam, and the areas a skill acts through around its
|
||||
owner's standing summons — 麦哲伦 S1 (her drones' ranges), 令 S3 (each summon's x-5, ground), 电弧 S2 (赛柯's range,
|
||||
ground) / S3 (桑特拉's range): the owner's larger-range rule of 2026-10-06, kits/shared/summoner.js
|
||||
`summonTriggerArea`); `SKILL_RANGE` — "不通过普通攻击/治疗触发技能,仅在技能范围内存在敌人(无视其不可选中)时释放技能": any
|
||||
`summonTriggerArea`). 赤刃明霄陈 S3 uses an air-capable content range on its expanded grid for `ACTIVE_RANGE` too
|
||||
(§28.26): the wave can hit flyers, its normal attacks remain ground-only. [ASSUMED] This activation area includes
|
||||
permanent range extension but not the wave's eventual route. With `{ attackOnly: true }`, the extra range is checked only at the normal attack opportunity, not each tick: 蕾缪安’s own wanted targets and 死芒’s own living summons’ blocked targets (S1/S2); normal selectability and the skill’s actual effect range remain intact (§28.13). `SKILL_RANGE` — "不通过普通攻击/治疗触发技能,仅在技能范围内存在敌人(无视其不可选中)时释放技能": any
|
||||
living enemy (stealthed, untargetable, flying included) on `trigger.customRangeGrid` (= the skill's rangeGrid), every
|
||||
tick, no attack needed; `trigger.allies` (+ `hpAtMost`, default 1) — set by a kit, or by the data for an ally row (黍 S3's
|
||||
official `TRY_SEARCH_ALLY_SKILL`, "技能范围内存在可治疗的我方单位时释放技能": tools/build-data.mjs `TRIGGER_ALLY_RULES`,
|
||||
@@ -897,9 +973,11 @@ instance) and skip `'counter'` / `'reflect'` damage. When the guard trips, the l
|
||||
on `trigger.customRangeGrid` (= that running range, grown by the unit's permanent rangeExtend unless the skill's
|
||||
`targeting.noRangeExtend`), every tick, no attack needed (the 外勤医疗 map character Touch's 恳切福音 too, on its 5-2:
|
||||
set by its kit, content/tokens.js `touchKit` — the map character's record keeps DEFAULT, build-data widens operators'
|
||||
skills only; GitHub #260); `DEFAULT` with `trigger.allies` (+ `hpAtMost`,
|
||||
`grid`) = the basic rule **and** such an ally on the grid: the cast replaces the attack about to be made (塞雷娅 S1 "触发
|
||||
时会替换当次攻击", ≤ half HP); a cast whose ally condition fails before that attack is withdrawn, its charge returned;
|
||||
skills only; GitHub #260). A mixed attack/heal skill may set `trigger.enemies` to also open for a selectable enemy
|
||||
on that range (凯尔希·思衡托 S2), retaining the injured-ally condition; `DEFAULT` with `trigger.allies` (+ `hpAtMost`,
|
||||
`grid`) = an attack interval ready **and** such an ally on the grid, without requiring an enemy:
|
||||
the cast replaces that attack with a heal (塞雷娅 / 黍 S1, ≤ half HP). The check follows cooldown decrement
|
||||
and control/disarm checks, preserving the same cadence with or without enemies; a cast whose ally condition fails before that attack is withdrawn, its charge returned;
|
||||
`TAKE_DAMAGE` — ready and just hit (重装: "不受技能范围影响,受到伤害时释放技能"; in the data every MANUAL 重装 skill but
|
||||
the six of DESIGN §21.29 — 深巡 / 雷蛇 S2, 号角 S2 / S3, 灰毫 S1 / S2 — which are `DEFAULT`, a deliberate deviation (深巡
|
||||
S2 then `ACTIVE_RANGE` on its 3-2), and 余 S2 厚礼上宾, `SKILL_RANGE` on its own x-1 since DESIGN §22.10);
|
||||
@@ -909,15 +987,26 @@ 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
|
||||
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).
|
||||
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), and while the unit has `noSp` (阻回; the operators' 凋亡 burst, §3), except for
|
||||
deployment initialization (`reason: 'init'`) and explicitly documented bypasses.
|
||||
`gainSp(n, reason, silent, { ignoreLock: true })` bypasses both locks while retaining the source reason, charge cap
|
||||
and `spGain` hook; used by 黄沙罗盘 (PRTS says all its SP ignores 阻回), including its caster's +3 SP.
|
||||
- Kinds: `duration` (mods for `duration` s), `ammo` (mods until `ammo` attacks were made, optional duration cap),
|
||||
`instant` (onStart + optional one-shot attack override applied to the next attack), `charges` (instant with charges),
|
||||
`passive` (always on from deployment, no SP), `toggle` (stays on until death once activated).
|
||||
@@ -931,7 +1020,7 @@ instance) and skip `'counter'` / `'reflect'` damage. When the guard trips, the l
|
||||
`skillEnd` fires. `onAttack` ctx carries `noAmmo` (set it to true: this attack spends no bullet, no `ammoUsed` — 流明's
|
||||
free heals).
|
||||
- Runtime helpers on `unit.skill`: `activate(reason, {free})`, `end(reason)`, `stop()`, `addAmmo(n)`, `extend(s)`,
|
||||
`addCharge(n)`, `gainSp(n, reason)`, `addTriggerRange(fn)`, `setTrigger(rule, grid)` (a kit's own rule change mid-battle —
|
||||
`addCharge(n)`, `gainSp(n, reason)`, `addTriggerRange(fn, { attackOnly?: boolean })`, `setTrigger(rule, grid)` (a kit's own rule change mid-battle —
|
||||
薇薇安娜 S3's ACTIVE_RANGE on 3-2 after its first cast; 0.2.0); fields `sp, spCost (= floor(base×spCostMul + spCostFlat)), spCostMul, charges,
|
||||
maxCharges, active, timeLeft, ammoLeft, ammoMax, activations, kind, rule, bb` (`ammoMax`: the most bullets the running ammo skill
|
||||
has held — set at activation, raised by skillStart additions and `addAmmo` above it; 0 when inactive).
|
||||
@@ -1018,8 +1107,8 @@ Element conventions of the kits (user playtest #5 #3; official term dictionary:
|
||||
duration, // s (duration kind; optional cap for ammo)
|
||||
ammo, // attacks (ammo kind)
|
||||
spCost, initSp, charges, spType: 'time'|'attack'|'hurt'|'none', // optional overrides of the data values
|
||||
trigger: 'DEFAULT' | { rule, grid, allies?, hpAtMost? }, // optional override (allies / hpAtMost: an injured ally condition, §7.1)
|
||||
heal: bool, // heal-type skill for the DEFAULT trigger (default: unit is a healer)
|
||||
trigger: 'DEFAULT' | { rule, grid, allies?, hpAtMost?, enemies? }, // optional override (allies / hpAtMost: an injured ally condition, §7.1)
|
||||
heal: bool, // heal-type skill for the DEFAULT trigger (default: unit is a healer); trigger.enemies also permits enemies
|
||||
mods: { …mod keys }, // buff while active (instant: only during the pending attack)
|
||||
flags: { …flags },
|
||||
targeting: { maxTargets, rangeGrid, rangeExtend, priority, allInRange, canHitFly,
|
||||
@@ -1292,7 +1381,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?, coins?, 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 +1394,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 +1422,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 +1509,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 +1528,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 +1551,21 @@ 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.
|
||||
|
||||
|
||||
“余音” owns a same-call pulse queue (DESIGN §28.24): reflected hits enqueue their earned pulses instead of recursively
|
||||
entering another pulse. This retains the lethal-hit pulse and prevents valid high hit-count chains from tripping the
|
||||
general hook guard. The queue is emptied in `finally`; it introduces no timer or simulation RNG.
|
||||
|
||||
### Community follow-up contracts (DESIGN §29)
|
||||
|
||||
- Optional snapshot `coins: [[id, balance, cap]]` comes from `snapshot.js coinView`: a live allied Swire skill's nonnegative integer balance and blackboard cap, including zero. Invalid/missing entries are omitted; interpolation clears absent values.
|
||||
- Raid's one reselect after a revive orders by taunt, then remaining route distance, then id, and jumps while the operator is not blocking (contact on the current tile is refreshed first). Ordinary polls keep remaining-distance order and the empty-range prerequisite.
|
||||
- Healing skill activation includes elemental-only injury when the operator's heal profile can cure it. Pinecone S2 growth is deployment-local. Skadi S1 and Stead preserve redirect ancestry and take both shares from the pre-redirect hit, so the total damage is conserved.
|
||||
|
||||
`champagneHold` includes the internal `isolated` ally-selection flag (§29.11): device-type champagne bombs are excluded from ordinary allied auras/inspiration in both creation paths. Other unhealable summons retain their own selection behavior.
|
||||
@@ -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; a model whose pose cannot change — `SpineActor.poseHeld()`: a knocked-out operator in the held end of its Die clip, a frozen model — is neither re-posed nor redrawn, only its clock runs, until a new clip, a revive, a wind-up, a form's closing clip, a clipping switch or a zoom past the impostor's rescale threshold; the redeploy rings draw one shared rasterised label per number, `render/textures.js downLabel`, the same pixels as a Text of their own). 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.
|
||||
|
||||
---
|
||||
|
||||
@@ -58,7 +63,7 @@ Audio: autochess BGM per phase, UI SFX (buy/sell/refresh/level/merge/ready/timer
|
||||
- `guide/autochess_{home,shop,handbook}_N.png` — the 19 official tutorial pages; stored squashed to 1024² — **display at 16:9** (the mirror copies too, §22.5).
|
||||
- `projectiles/*` — battle projectile sprites.
|
||||
- `spine/enemy/<id>/` — enemy models no web dump carries (灼热 / 炽焰源石虫, `extract.py ENEMY_SPINES`): manifest `enemies[id].spineLocal` (metadata from the committed `tools/assets/local-enemy-spines.json`); drawn instead of the tinted web alias when every file is listed (§21.14).
|
||||
- `spine/token/<id>/` — token (summon) models no web dump carries (39: most 自选 summons, 凯瑟琳's 爬行号·防护单元, 凛御银灰's 风雪之眼; `extract.py TOKEN_SPINES`, the Front renderer's skeleton of each battle token prefab): manifest `tokens[id].spineLocal` (metadata from the committed `tools/assets/local-token-spines.json`); drawn instead of the avatar diamond when every file is listed (docs/ASSETS.md "Token models from the local client").
|
||||
- `spine/token/<id>/` — token (summon) models no web dump carries (40: most 自选 summons, 凯瑟琳's 爬行号·防护单元, 凛御银灰's 风雪之眼; `extract.py TOKEN_SPINES`, the Front renderer's skeleton of each battle token prefab): manifest `tokens[id].spineLocal` (metadata from the committed `tools/assets/local-token-spines.json`); drawn instead of the avatar diamond when every file is listed (docs/ASSETS.md "Token models from the local client").
|
||||
- `module/<TYPE>` — the official module (uniequip) TYPE icons (white glyphs; keys are the client's mixed-case file names, e.g. `PRI-X`, `mar-x`, `isw-a`), matched **case-insensitively** against a ModuleRecord's `typeName` by `assetUrls.moduleTypeIconUrl(local, typeName)` (Greek type letters map to the file's Latin letter: `ISW-α` → `isw-a`) — shown on the 干员调配 module cards / module info, the detail panel's 模组 row and the shop card's module tag; lettered tiles / type text without them.
|
||||
|
||||
---
|
||||
@@ -75,3 +80,73 @@ Target: a **real 3D board layer** rendered with three.js (vendored ESM, no CDN)
|
||||
- Done: Final Assault / Hidden Core prep takes place on your own half of the boss field (research 09 §1.2): pieces shown at boss-field positions (row − 7; right-hand player mirrored col → 20 − col, RIGHT ↔ LEFT), drop targets and wheel directions mapped back to board coordinates (`prepCamera` → `bossPrep` in public/js/ui/gameLogic.js; covered by the playtest2 browser tests). A scout of such a board (`m.field` prep of a boss round) comes in boss-field coordinates with the player's `side` and is framed the same way, the leader standing at its spawn (0.2.0, community report item 55). Both players of a pair are shown together, as in the battle (0.2.0, community report item 51): the partner's board stands on the other half (`m.private.bossMate` units, already in boss-field coordinates: mirrored on the right half, RIGHT ↔ LEFT; read-only `m:<uid>` views of `render/app.js syncMates`), the whole boss field is lit, a scout carries both halves (`mate`), and the team panel frames the pair's avatars in the official green (`ui/battle bg_team_border` tinted, a plain ring without the local art; `gameLogic teamFrameIds`) from the round's start through the fight.
|
||||
|
||||
---
|
||||
|
||||
Clipboard exports (`ui/clipboard.js`, §28.3) first use the secure Clipboard API. The textarea fallback for LAN
|
||||
play stays inside the active modal/guide focus boundary; it copies only after focus and the complete selection
|
||||
are verified, and cleans up / restores focus on both success and failure. Failure exposes the existing manual-copy UI.
|
||||
|
||||
|
||||
Push/pull slides preserve the target's pre-hit facing through the waiting and moving phases (`keepFacing`, §28.9).
|
||||
Explicit false and unmarked raw displacements face the travel direction. Normal snapshot facing resumes on landing.
|
||||
[ASSUMED] unrecorded push sources and reverse-facing hook pulls use the same display rule as the cited examples.
|
||||
|
||||
### Local preferences and recovery (§28.14, §28.18–20)
|
||||
|
||||
Settings persist `textSize` (sm/md/lg/xl; default sm) and `voiceOverrides` (charId → cn/jp; default empty) alongside the
|
||||
existing global `voiceLang`. CSS text uses `--t`; the layout rem, camera and board geometry stay unchanged. Larger text
|
||||
and translated controls wrap within the settings modal. The normal roster detail and chosen DIY cards expose each
|
||||
operator's voice preference, shared across that charId's forms. An absent override follows the global preference;
|
||||
missing Japanese lines retain the existing Chinese fallback. Overrides are local listening preferences, absent from
|
||||
room loadouts and their exports.
|
||||
|
||||
The title and lobby offer explicit recovery of a previously saved local match seat. `sp.matches` stores bounded name /
|
||||
room-code metadata indexed by token hash; the existing four-entry `sp.tokens` ring remains the authority. Recovery
|
||||
rechecks BroadcastChannel claims before selecting the token in memory and reconnecting in the same document.
|
||||
Only the server's `welcome` (`saveToken`) persists that selection and its first-welcome stamp (`sp.tokenWelcome`,
|
||||
bound to the token hash). Refreshing or reconnecting the same token preserves that stamp. A tab with a session token
|
||||
initializes with that token or null, never another recent token, and answers ownership queries during initialization.
|
||||
An earlier welcome takes precedence; equal stamps prefer an already welcomed live document over a copied session,
|
||||
then tab id breaks ties. A session token without a stamp still outranks a tentative shared-token selection. A pending
|
||||
recovery yields when the original owner returns; a later welcomed holder also gives up its local claim and pending
|
||||
reconnect. `hello.noReplace` prevents tentative recovery from evicting a connected session even if its hello is already
|
||||
in flight; `hello.claimAt` preserves the earlier-welcome priority for established holders' in-flight reconnects. A stale
|
||||
welcome never reaches the store or flushes queued game actions. Nickname persistence also waits for welcome, so a
|
||||
refused recovery can return to the previous identity and nickname. It does not identify players by name or IP.
|
||||
Without BroadcastChannel it refuses shared recovery. Server restart / expiry clears
|
||||
obsolete metadata on the next welcome. A spectator never registers a recoverable player seat.
|
||||
|
||||
`manifest.json` enables browser installation. The maintainer's revised decision of 2026-10-10 is to
|
||||
**沿用 0.2.x 的薄荷绿城堡并精修**: `public/icons/app.svg` keeps the existing three-battlement rook, inset tower
|
||||
and splayed base in mint `#4ed8af` on dark `#0c0f0e`. Refinement B narrows the tower, balances the battlements,
|
||||
thins the base and increases breathing room. It has no lettering, gradients or gloss and uses no official art,
|
||||
logos, icons or fonts.
|
||||
`tools/export-app-icons.py` exports 192/512 px PNGs for `any` and separate opaque `maskable` icons: the latter scale
|
||||
only the mark to 87.5%, keeping every foreground pixel inside the central circle of radius 40% of the canvas.
|
||||
The same master supplies 16/32/48 px PNG / ICO favicons and the 180 px Apple touch icon linked from `index.html`.
|
||||
These project-owned assets live in `public/icons/` and ship in full and lite packages; ASSETS.md records their provenance.
|
||||
The install button appears only for a live browser install offer and hides
|
||||
in standalone/fullscreen windows or after installation. Every offer is consumed before awaiting the prompt, including
|
||||
cancellation or failure. No service worker, offline cache or persistent server is introduced; HTTPS or localhost and
|
||||
browser support still govern installation.
|
||||
|
||||
### Skill-mode sound and delayed models (§28.17, §28.22)
|
||||
|
||||
The audio manifest can carry `attacks` / `hits` plus independent `attackMix` / `hitMix` by skill index. Only an active
|
||||
skill uses a mode bank. Impacts remember the mode at the time of the attack; an end event cancels a queued start for
|
||||
an unknown unit, and a late-join snapshot restores the current mode. A mode without a bank falls back to its normal
|
||||
sound. A selected mode with no mix uses its own default volume/probability, not the ordinary bank's mix.
|
||||
|
||||
A model that arrives after an actual deployment continues Start at the elapsed game time; after the clip's duration it
|
||||
shows the current base pose. A subsequent attack/death supersedes that pending entrance. No extra deployment sound
|
||||
or per-phase animation schedule is added by this change.
|
||||
|
||||
The simplified-view notice describes a renderer load failure, rather than declaring the device incapable of 3D.
|
||||
On same-battle metadata refresh, remembered once-spawned device records also seed the renderer's new entry (§29.14).
|
||||
This preserves device tile picking and live-HP cards; records explicitly supplied by the refresh take precedence,
|
||||
and a different field never inherits the previous field's devices.
|
||||
The same fallback covers import failure, timeout and WebGL initialization errors; a reload can retry.
|
||||
It does not diagnose a particular user's GPU or persist a disabled renderer preference (§28.25).
|
||||
|
||||
Community UI and recovery (§29.6): DIY reuses roster stats and skill previews; Swire's coins show current/cap. The lobby player chip opens statistics and the responsive header keeps optional controls from overlapping its title. Settlement's summary scrolls separately from Return. Failed board art can retry, and failed three.js imports use two bounded fresh URLs. Failed engine initialization can remount on online/visibility, at most twice consecutively, cleaning up each previous view; explicit fallback/2D stays selected.
|
||||
|
||||
Replacing a field renderer also resets the game screen's entered-view identity. Frames are buffered while the replacement loads; after subscribing, the local runner re-shows full current metadata/snapshot, or the server resends the watched field. Real local and server-streamed battles preserve the same simulation object and clock through recovery, including enemies spawned before the new renderer existed.
|
||||
@@ -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`, attack cadence ready and a healable ally of the skill grid at or below that HP ratio suffice without an enemy — 塞雷娅 / 黍 S1 (§28.10), 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
|
||||
@@ -171,12 +171,65 @@ Profession traits (default behaviours implemented once in the engine, keyed by `
|
||||
|
||||
Each domain file `server/sim/content/{tokens,bonds,garrisons,items,bands,enemies,bosses,devices,choices}.js` exports `install(battle)` (battle side; called once per Battle by `content/index.js → installContent`) and `registerMeta(registry)` (prep side; called once at server boot by `match/effectsMeta.js`, API documented in `docs/META.md`). Kits live one per file in `content/kits/ops/` (`export default { [baseChessId]: (bb, chess, def) => Kit }`), listed in `content/kits/index.js` (guide: `content/kits/README.md`). Content must be **data-driven from blackboards** (numbers never hard-coded when a blackboard key exists). Every effect gets at least one unit test in `test/content/*.test.js` using the `test/helpers/battleHarness.js` harness (provided by sim-core).
|
||||
|
||||
- kits: all 112 visible chess + hidden ones used by effects (盟约·辅助干员), default skill + talents + trait specifics; tokens.
|
||||
- kits: all 112 visible chess + hidden ones used by effects (盟约·辅助干员), default skill + talents + trait specifics; tokens. The 72 non-collaboration 6★ DIY operators include 克莱门莎 (§28.1); her launch-day provisional mechanics are local to her kit. 黍 GUA-Y and 乌尔比安 CRU-Y follow the generated module blackboards and existing module-level rules (§28.2).
|
||||
克莱门莎 S2 removes dead, hidden, self-bound / teleport-immune and unreachable passengers before checking new
|
||||
boarding in the same tick. Each released passenger returns its current weight to the cabin's remaining budget
|
||||
and frees its slot (§28.27); neither corpses nor failed carries reserve capacity.
|
||||
- bonds: 23 (battle + meta), exact per-layer formulas from research 02.
|
||||
- garrisons: 43 effect keys (IN_BATTLE via hooks; SERVER_* via meta registry).
|
||||
远牙's granted `garrison_108_a/b` additionally gains on both directions of `dollSwap`, sharing its deployment
|
||||
cap and active-bond checks (player-confirmed, §28.5). This extra trigger is specific to that granted trait.
|
||||
- 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).
|
||||
|
||||
---
|
||||
|
||||
蕾缪安's wanted tiles extend both attacks and the existing content skill-trigger range (§28.4). New detection
|
||||
requires selectability; an attached timer needs continuous detection range, and each on-field source owns its timers
|
||||
and marks. Leaving clears that source's state without clearing another still-deployed source's marks.
|
||||
|
||||
凯尔希·思衡托 S2 uses its mixed attack/heal target condition for automatic activation: either an injured, healable ally
|
||||
or a selectable enemy in its expanded running range; operation cooldown and other controls still apply (§28.10).
|
||||
|
||||
### Additional DEFAULT trigger targets (§28.13)
|
||||
|
||||
`addTriggerRange(fn, { attackOnly: true })` extends the DEFAULT target check at the owner's next attack opportunity.
|
||||
It does not create a per-tick trigger. 蕾缪安 S1–S3 use only their own wanted targets; 死芒 S1/S2 use targets blocked by
|
||||
living summons owned by that operator. Sleeping or otherwise unselectable enemies do not count. This only changes
|
||||
the condition for activation: 死芒 S2 still links inside its own effect range. Other registered summon areas retain
|
||||
the existing per-tick behavior. The optional flag defaults to false for compatibility.
|
||||
|
||||
赤刃明霄陈 S3 also registers selectable air targets in its expanded attack grid for `ACTIVE_RANGE` automation
|
||||
(§28.26): its sword wave can hit flyers while its normal attacks remain ground-only. The existing operation cooldown,
|
||||
silence and control checks apply. [ASSUMED] The activation area is that grid plus permanent attack-range extension,
|
||||
not every tile the roaming wave might eventually reach.
|
||||
|
||||
|
||||
0.2.3 echo/counter interaction (DESIGN §28.24): recursively earned “余音” pulses are drained FIFO by that echo in the
|
||||
same call, retaining the earned attack value and the lethal-hit pulse. [ASSUMED] This local order avoids exhausting
|
||||
the hook-depth guard on scaled hit-count HP; the guard remains unchanged for other recursive handlers.
|
||||
|
||||
### Community corrections (2026-10-10, §28.25)
|
||||
|
||||
- A normal enemy attack's post-hit status belongs to the target's deployment at the strike. If lethal damage
|
||||
immediately redeploys the target (不屈), that old attack cannot stun the new deployment.
|
||||
- Bard continuous regeneration, including 浊心斯卡蒂's replacement trait, continues through stun; leaving the field
|
||||
stops refreshing its aura. Stun still prevents attacks and skill use.
|
||||
- 黄沙罗盘 SP ignores both an active skill's recovery lock and `noSp`, including the caster and other 萨尔贡 recipients
|
||||
of the 浓茶 combination. Other SP gifts retain their existing restrictions.
|
||||
- 克莱门莎's 崇高牺牲 strategy requires 阿戈尔 to be active before a knock-out grants layers.
|
||||
- 隐秘核心 铳 takes 20% physical/arts damage while another gun or spring remains. The condition is checked on each hit
|
||||
as well as by its aura tick, including the first hit and immediately after the last companion leaves.
|
||||
|
||||
Sources and the feedback items requiring no simulation change are recorded in [§28.25](../history/0.2.3.md#2825-community-feedback-of-2026-10-10).
|
||||
|
||||
Community corrections in §29 are normative: Raid's one reselect after a revive uses taunt then route distance and jumps while unblocked, after refreshing contact on the current tile; ordinary polls stay on route distance and the empty-range rule. Healing cast conditions include curable elemental injury; Pinecone S2 growth restarts per deployment; crest consumption is checked on damage. Mixed Skadi/Stead redirects preserve ancestry and both take their share from the pre-redirect hit, so the victim keeps the remainder and the total is conserved. Clementia S2 secondary splash is 100% ATK within a 1.1 collision radius (four additional ground targets), while its main hit and 1.5-radius vortex keep their own coefficients. Final-mode leaders retain their erosion immunity, explicitly stated in the PRTS mode tips. 整备 does not upgrade a purchase when the golden's second copy would exceed shared item stock, and it does not spend its charge in that case.
|
||||
|
||||
Raid alone may land on an empty deployment half in multiplayer (`mode_multi_*`) Final Assault / Hidden Core battles
|
||||
(#475, maintainer-approved, §29.13): rows 2–5, cols 2–18, subject to the existing field rect, terrain, occupied/downed
|
||||
tile and attack-reach checks. Hand/temp rows, empty halves in single-player mode or lone-helper 联防 stay excluded.
|
||||
The general `onOwnBoard`/`onFieldBoard` contracts, other movement, summons and preparation placement remain unchanged.
|
||||
|
||||
香槟炸弹 also rejects ordinary allied auras and inspiration as a device, through its shared internal selection barrier (§29.11). This does not isolate other unhealable summons or alter its detonation.
|
||||
@@ -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.
|
||||
|
||||
@@ -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` 50 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)
|
||||
@@ -27,7 +28,7 @@ LOBBY(room) → INFO_CHECK (co-op 25 s; solo and any single-human match untimed
|
||||
SETTLE (LP −min(leaks,10) per player (unite survivors to their source); eliminations; layer gains applied; coins; broadcast;
|
||||
after a 联防 the outcome and every player's charge ride the SETTLE m.public as `uniteResult`, GitHub #235)
|
||||
R14 FINAL_ASSAULT (merged team LP; pairs by seat; shared boss pool; 120 real s countdown, no hard stop; overtime −1 team LP per
|
||||
real s after 150 real s (§4, m.public.overtimeAt); no layer gains; team LP 0 ⇒ defeat at once (PRTS 直接失败),
|
||||
real s after 120 real s, or 150 with an AI teammate at fight start (§29.1, m.public.overtimeAt); no layer gains; team LP 0 ⇒ defeat at once (PRTS 直接失败),
|
||||
pool 0 ⇒ victory — the first end the server registers decides, §20.5)
|
||||
R15 HIDDEN_CORE (if difficulty ≥ NORMAL and Σ activated layers > 350 solo / 1200 co-op and team LP > 1)
|
||||
→ RESULT (per-player stats, titles, rounds passed, victory/defeat)
|
||||
@@ -44,6 +45,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 }
|
||||
@@ -58,10 +60,16 @@ Summons (§20.1): an operator on the board sends its manually deployable summons
|
||||
|
||||
Equipment (research 04 §2 + addendum, 09 §1.2): 2 slots per chess; a third item on a full carrier replaces the equipped item `replaceUid` names (the replace dialog's pick, official `UseEquipUp.unloadInstId`; absent ⇒ the oldest; a uid not equipped on the target ⇒ `BAD_TARGET`, nothing changes) and the replaced item is destroyed. A consume-on-equip item (盟约之币, 博士投影 …) on a full carrier replaces the same way before it resolves, so the carrier is left with a free slot (the normal 博士投影 takes it until the next round start); a target its effect refuses (博士投影 on an elite) keeps both items — PRTS 卫戍协议/帮助 "达到上限强行佩戴会改为替换装备" names no exception (GitHub #263; the client opens the replace dialog for it, `equipReplaces`). Equipped items are otherwise **locked**: `destroy` (销毁) accepts hand / temp items only (`BAD_TARGET 'equipped items are locked'`); equipment goes back to the hand only when its carrier merges, is sold, or is destroyed by 突变细胞's transformation (the cell itself returns too; PRTS 帮助 "在失去该干员(干员出售、销毁、合并等)…时自动卸除", §21.1) — a sale is refused with `HAND_FULL` when there is no room for it; returned equipment is stowed before the operator a transformation gains and before a merge elite's summon cards, and every path that hands the player an item — returned equipment included — ends with the auto-merge, so no player holds two identical mergeable normal items (§21.7).
|
||||
|
||||
Economy/shop/pool/merge/hand rules: exactly as research 00-INDEX §3–§4 (income `min(3+r,12)`; prices by tier; refresh 1; freeze toggles all; sell +1; items not sellable; upgrade prices per mode with −1/round; shared pool copies 12/14/18/16/8/5 (缪尔赛思 4) across all players; elite = 3 copies; odds = copy-weighted draw over unbanned visible chess with tier ≤ level; merge 3 (风丸 2) → elite onto the consumed deployed copy's tile, else to the hand (§20.11) + reward offer of 3 **different** free chess of tier `min(level+1, 6)` (copy-weighted; a tier short of different chess tops up from the tier below [ASSUMED], §20.6); full hand blocks every buy / reward pick, a merge-completing one too (PRTS 卫戍协议/帮助 §手牌区, GitHub #82); overflow to temp; a free hand slot pulls the temp pieces back in at once, right → left (`PlayerState._fillHandFromTemp`); temp blocks ready).
|
||||
Economy/shop/pool/merge/hand rules: exactly as research 00-INDEX §3–§4 (income `min(3+r,12)`; prices by tier; refresh 1; freeze toggles all; sell +1; items not sellable; upgrade prices per mode with −1/round; single-player pool copies 12/14/18/16/8/5 plus measured exceptions; co-op shared operators use twice those counts, while equipment has separate shared stock (DESIGN §29.8); elite = 3 copies; odds = copy-weighted draw over unbanned visible chess with tier ≤ level; merge 3 (风丸 2) → elite onto the consumed deployed copy's tile, else to the hand (§20.11) + reward offer of 3 **different** free chess of tier `min(level+1, 6)` (copy-weighted; a tier short of different chess tops up from the tier below [ASSUMED], §20.6); full hand blocks every buy / reward pick, a merge-completing one too (PRTS 卫戍协议/帮助 §手牌区, GitHub #82); overflow to temp; a free hand slot pulls the temp pieces back in at once, right → left (`PlayerState._fillHandFromTemp`); temp blocks ready).
|
||||
|
||||
**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 +89,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,12 +106,38 @@ 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.
|
||||
|
||||
---
|
||||
|
||||
### Cooperative buying, reactions and opening rerolls (§28.15–16, §28.21)
|
||||
|
||||
[ASSUMED] Bot purchasing applies one soft 18-point penalty to non-elite, non-DIY tier V/VI shared-pool cards whose
|
||||
active bonds overlap another living teammate's opening strategy. Human and AI teammates are treated equally; it
|
||||
neither reserves stock nor prevents a stronger purchase. Existing strategy presets and rehearsal counts are unchanged.
|
||||
|
||||
[ASSUMED] AI reactions are cosmetic social choices, enabled by default and disabled with `SP_BOT_EMOTES=0`. Only
|
||||
mixed human/AI matches emit them, through the existing whitelist and cooldown. A human emote gets at most one eligible
|
||||
bot reply. Settlement uses that round's results (kills then damage, stable seat tie-break), once per round; the third
|
||||
chess merge reacts once and resets at settlement. Gifts are classified by trusted source playerId, never display name.
|
||||
These hooks consume no gameplay RNG and schedule no game actions.
|
||||
|
||||
[ASSUMED] During INFO_CHECK the host may request an opening reroll; all connected human participants must explicitly
|
||||
agree, including autoplay humans. Bots and spectators do not vote; one human refreshes immediately. A vote pauses the
|
||||
confirmation timer and blocks Ready. Rejection, host cancellation, disconnect or leaving cancels it and restores the
|
||||
remaining time / prior Ready state. Success preserves the room, seats, settings and connections, redraws the complete
|
||||
opening, resets Ready, and starts the full original timer (single-human matches remain untimed). Fixed stage/bans
|
||||
remain fixed and random results can repeat. A separate seed-derived stream leaves shop/draft RNG untouched; requests
|
||||
have a 300 ms anti-burst guard, revision and vote identities. All new setup data and player DIY stocks are prepared
|
||||
before mutation; data preparation failure leaves the old opening intact. This is a remake convenience, not an official
|
||||
mode rule. Unrelated result-screen changes from PR #408 are not included.
|
||||
|
||||
The shared DIY detail preview (stats, range, trait, talents, potential, cultivation and matching normal/elite skill descriptions) follows roster loadout resolution. Jessica shield placement is limited to her four orthogonal neighbors. The two Ptilopsis prep-gift copy exceptions are defined in §29.3 and META; no generic change to all copied conditions is implied.
|
||||
@@ -7,26 +7,40 @@ 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}`
|
||||
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.
|
||||
|
||||
Local recovery arbitration (0.2.3, §28.27): `hello` also accepts optional `noReplace:boolean` and
|
||||
`claimAt:number` (finite, nonnegative ms). A shared-token candidate sends `noReplace:true`; a tab with a saved
|
||||
welcome stamp sends `claimAt`. When that session is already connected to a different socket, `noReplace` or a later
|
||||
stamp than the attached holder causes `SESSION_IN_USE` before the old socket is touched. A same-stamp own reconnect
|
||||
can still replace its previous socket. Omitted hints retain legacy behavior; they are not authentication. Client
|
||||
selection/stamp rules live in design/client.md. In-flight protection requires the updated server as well as client.
|
||||
|
||||
### 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 {setupRevision?}` · `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?, coins?, 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 +48,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 +85,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,9 +118,25 @@ 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.
|
||||
|
||||
---
|
||||
|
||||
### Opening reroll messages and local seat recovery
|
||||
|
||||
C→S adds host-only `room.rerollSetup {setupRevision}` / `room.cancelReroll {voteId}` and participant
|
||||
`g.rerollVote {voteId, agree}`. `m.public` carries `setupRevision` and nullable `rerollVote {id, proposerId, voters, agreed}`.
|
||||
Ready must reference the current revision; omission means revision 0 for old clients. The server blocks Ready while
|
||||
voting, refuses stale ids and cancels on disconnect/leave; DESIGN §28.21 / META.md describe the complete contract.
|
||||
|
||||
Explicit local recovery (§28.20) reuses saved hello tokens after a fresh live-tab claim check. Display names and room
|
||||
codes are UI metadata only, never authentication. No new network message or nickname/IP matching is introduced.
|
||||
|
||||
Optional `coins: [[id, balance, cap]]` is an integer skill-currency readout (§29.6), separate from ammo. It includes zero, is stepped between snapshots and clears when omitted. Result-validation names for board operators are resolved from the server's DIY/stand-in selections, never a supplied display name.
|
||||
|
||||
Preparation state carries read-only `prepMapChars` for 外勤医疗. Boss-mate and scouting metadata include them in
|
||||
displayed field coordinates. Draft cards expose optional `soldOut` when equipment stock is unavailable; the client
|
||||
disables picking and the server checks again before consumption.
|
||||
@@ -15,6 +15,10 @@ In scope:
|
||||
- Rendering with the real Spine battle chibis (PixiJS 7 + pixi-spine 4), procedural tiles, VFX, damage numbers, real BGM/SFX, emotes, broadcast ticker.
|
||||
- Reconnect, AI take-over of disconnected players, robust validation of every client intent.
|
||||
|
||||
New features require explicit maintainer approval before implementation. The 2026-10-10 review approved retaining
|
||||
the existing PWA installation, unanimous opening reroll, teammate emotes and cooperative purchasing heuristic
|
||||
(DESIGN §28.15, §28.16, §28.19, §28.21); this is not blanket approval for future additions.
|
||||
|
||||
Out of scope v1: matchmaking queue, training/tutorial, DIY (甄选) slots (the 4 DIY chess are removed from the shared pool — 0.2.0 plays them as 自选编队: the picks of the 干员调配 overlay's 自选编队 tab, each sold only in its player's shop; DATA.md §18, shared/diy.js, server/match/player/diy.js), trophies/progression persistence, reporting.
|
||||
|
||||
---
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -0,0 +1,481 @@
|
||||
# DESIGN §28 — 0.2.3
|
||||
|
||||
Part of [DESIGN.md](../DESIGN.md) (the index; section numbers are global).
|
||||
|
||||
## 28. New content and community fixes
|
||||
|
||||
This chapter describes 0.2.3, released on 2026-10-10. Numerical content is rebuilt from
|
||||
CN client 2.7.81, ArknightsGameData commit `17759cc8cdf37388414145d16262a3a06f3ab25e` (2026-10-08),
|
||||
with mechanism notes from PRTS checked on 2026-10-09. Launch-day descriptions may receive corrections.
|
||||
|
||||
### 28.1 克莱门莎 — `sim/content/kits/ops/op-clemnt.js`
|
||||
|
||||
- **Source:** [PRTS 克莱门莎](https://prts.wiki/w/克莱门莎) and the official character, skill and range tables.
|
||||
The non-collaboration 6★ roster gains Clementia (`char_4231_clemnt`), a 本源近卫: 72 DIY operators,
|
||||
89 backup units including stand-ins, 259 selectable forms. All three skills are selectable in the existing
|
||||
tier-5 / tier-6 DIY slots at their existing skill-rank rules; potential and cultivation follow the player's settings.
|
||||
She has no launch module. Her bonds follow the existing faction mapping, including 阿戈尔.
|
||||
- **Talents:** each physical hit independently rolls 生死定夺's pre-DEF ATK multiplier; an enemy with current
|
||||
DEF below its initial DEF uses the higher probability. 习得性防御 reduces physical / Arts damage from the
|
||||
forward half-plane (including its boundary) or an enemy she blocks; 海怪 uses its separate reduction.
|
||||
Both talents take their potential-sensitive values from the generated data.
|
||||
- **S1:** the charged attack applies its ATK scale and inflicts 侵蚀损伤 proportional to actual HP damage.
|
||||
**S2:** the cabin travels forwards at 2 tiles/s, up to 3 tiles, carrying up to 15 ground enemies with a total
|
||||
weight of 8. Its collision square is 1.1 × 1.1. Impassable terrain stops it; self-bound / teleport-immune
|
||||
units cannot ride. Its actual stopping position becomes a radius-1.5 vortex that can affect air, slows by 70%, and each
|
||||
second deals 200 erosion damage plus the skill's physical damage. Erosion follows the engine's normal
|
||||
resistance, burst cooldown and mode-leader immunity rules.
|
||||
Ordinary skill attacks hit their main ground target and up to four neighbours.
|
||||
- **S3:** attacks select up to three ground enemies and add erosion based on actual HP damage without spending
|
||||
ammunition. A selectable ground enemy's erosion burst anywhere in the current battle marks it for 1 s.
|
||||
The closest mark consumes one of 10 bombardments: three physical + elemental hits in the target tile's
|
||||
five-tile cross. A tile with an outstanding projectile cannot be selected again for 5 s. Values are taken
|
||||
from the skill rank, not inferred from the final rank's description.
|
||||
- **[ASSUMED], pending PRTS confirmation:** S2 ordinary splash radius 1.5 and stable spawn-order tie breaks;
|
||||
放逐 presently prevents walking, attacking and blocking while keeping the passenger damageable; cabin
|
||||
movement updates every sim tick, stops 0.4 tiles before an impassable edge, and the vortex ends with S2.
|
||||
S3's first impact is 0.5 s after launch, the others 1 s apart; launched projectiles finish after skill end.
|
||||
These are local kit assumptions, not changes to displacement or targeting rules for other operators.
|
||||
- **Tests:** `test/content/op_clemnt.test.js` covers available forms, potential, crit probabilities, directional
|
||||
defence, S1 cadence, S2 capacity / immunity / terrain / air vortex / target cap, and S3 ammo / global marks /
|
||||
mixed damage. Golden changes and the final integration verification are recorded below once complete.
|
||||
|
||||
Clementia S3 currently uses the shared blast visual as an explicit `[ASSUMED]` placeholder;
|
||||
this preserves the previous fallback appearance, not a claim to reproduce her official effect assets.
|
||||
|
||||
### 28.2 New modules — 黍「好时节」and 乌尔比安「前路」
|
||||
|
||||
- **Sources:** [PRTS 黍](https://prts.wiki/w/黍), [PRTS 乌尔比安](https://prts.wiki/w/乌尔比安), official
|
||||
`uniequip_table` and `battle_equip_table` from the same CN revision. Regenerating data adds GUA-Y
|
||||
(`uniequip_003_shu`) and CRU-Y (`uniequip_003_ulpia`) to their existing forms, including the anchor's
|
||||
module lookup. The day's new 5★ operators stay outside the established non-collaboration 6★ DIY pool.
|
||||
- **黍:** GUA-Y's trait gives 15% damage reduction. The upgraded 天有四时 takes the improved HP / ASPD /
|
||||
four-Sui squad bonuses from the module blackboard. Satisfying at least two of the three conditions grants
|
||||
the separate additional ATK bonus (3% / 5% at module levels 2 / 3); it expires when the conditions stop holding.
|
||||
- **乌尔比安:** CRU-Y increases damage to an enemy blocked by any ally by 10%. Its upgraded talent also counts another
|
||||
深海猎人's credited kills (including delayed hits after that hunter leaves) while 乌尔比安 is deployed, shares the module's specified HP / ATK proportion,
|
||||
and adds one block at 9 stacks. Potential's extra maximum stack is retained. The mode's tier-5 elite form
|
||||
still uses module level 1; support for upgraded talent blackboards does not change that established rule.
|
||||
- **Tests:** `test/content/new-modules-023.test.js`, plus existing 黍 and tier-5 kit cases. The level-3 talent
|
||||
fixture is sourced from the official module blackboard; it is not exposed as a new tier-5 loadout.
|
||||
|
||||
### Content verification checkpoint
|
||||
|
||||
The new content's targeted tests passed 16/16; existing Shu / tier-5 coverage passed 120/120. An independent
|
||||
interaction pass checked retreat cleanup, sleeping passengers, a real rebirth enemy, hit-count life,
|
||||
post-retreat bombardments and delayed hunter kill credit. It found and corrected vortex-position rounding,
|
||||
an unsupported elemental-resistance bypass and an unnecessary on-field check on the credited hunter.
|
||||
All 285 golden scenarios were regenerated: only `roster` and `diy` changed, (`roster-019`, `roster-038`, `diy-059`–`diy-140`), because the added module expands
|
||||
loadout coverage and the added DIY operator / Shu module expand and regroup the DIY scenarios. `bonds`,
|
||||
`fields`, `matches` and `standins` remained byte-identical. Full integration and browser checks follow separately.
|
||||
|
||||
### 28.3 Clipboard export inside dialogs — #433
|
||||
|
||||
Reported by @RushFTK: exporting settings over an insecure/LAN connection focused the language selector and
|
||||
reported success without copying the export. The fallback textarea was appended outside the active dialog,
|
||||
whose focus trap redirected selection before `execCommand('copy')`.
|
||||
|
||||
`ui/clipboard.js` now selects its temporary textarea inside the active dialog/guide, validates focus and the
|
||||
whole selection before copying, and always removes it and restores focus, including after exceptions.
|
||||
The secure Clipboard API remains first choice; a failed fallback returns false for the existing manual-copy UI.
|
||||
Shared invite/loadout copying uses the same helper. Device tests passed 16/16; independent interaction probes
|
||||
passed 12/12. The real nested-dialog regression is included in `test/ui/modal.e2e.test.js` for integration testing.
|
||||
|
||||
### 28.4 蕾缪安's wanted range also triggers skills — #428
|
||||
|
||||
Reported by @CurlyZhu. Marked enemies' tiles extended normal targeting, but were absent from the skill's
|
||||
initial trigger range: full SP could sit idle while she attacked a remote wanted enemy. Her kit now registers
|
||||
those tiles through the existing `addTriggerRange` contract, retaining target selectability and operation cooldown.
|
||||
[PRTS 蕾缪安](https://prts.wiki/w/蕾缪安) also clarifies that a new detection timer needs a selectable target,
|
||||
a timer already attached continues while sleeping within detection range, and the source leaving clears its
|
||||
timers and marks. Each source now tracks its own progress; one Lemuen leaving does not erase another's marks.
|
||||
The focused 12 tests cover all three skills, cross-player detection in a joint field, sleep/wake, multiple sources,
|
||||
redeployment (including immediate range cleanup before the next attack) and the existing S3 waiting/late-bombardment behavior. Independent review passed after fixing that cleanup.
|
||||
|
||||
Golden: all 285 scenarios recomputed. `roster-016/018/035/036/045`, `boss-boss_1/3/5/7-pair`,
|
||||
`hidden-boss_9-pair` and `solo-HARD-9-boosted` move because wanted marks now drive skill casts and
|
||||
source-owned detection/cleanup; allies and kill timing consequently change. Bonds, stand-ins and DIY are unchanged.
|
||||
|
||||
### 28.5 远牙's granted trait on substitute switches — #415 / PR #429
|
||||
|
||||
Ported from [PR #429](https://github.com/sganggs/Stronghold-Protocol/pull/429) by @CXUtk; reported and
|
||||
original-mode interaction confirmed by @rosexxxp. The official activity table supplies the amounts:
|
||||
`garrison_148_a/b` grants `garrison_108_a/b`, +4/+8 to active 卡西米尔 and 精准, capped at 24/48 per battle.
|
||||
Only that granted trait additionally listens to `dollSwap`, in both directions, through the existing gain function.
|
||||
Deployment and switches share the original cap, active-bond checks and grant deduplication. Other deployment
|
||||
traits keep their existing triggers. The 7 regression cases cover both real dollkeepers, natural and S2 returns,
|
||||
shared caps, duplicate grant holders, inactive bonds and unrelated timed windows.
|
||||
|
||||
### 28.6 Asset download visibility — PR #421
|
||||
|
||||
Ported from [PR #421](https://github.com/sganggs/Stronghold-Protocol/pull/421) by @LimitlessHPPK.
|
||||
The asset resolver records every planned leaf with no alternative on disk as `droppedLeaves` in its report;
|
||||
the terminal prints a bounded summary. `--strict` / `SP_ASSETS_STRICT=1` returns exit 1 for those omissions.
|
||||
The default remains a warning, and the existing manifest shrink guard remains in force. Strict mode changes
|
||||
the outcome code; it is not a transaction and does not roll back a manifest already written. Temporary test
|
||||
assets are cleaned up after each case. This is an opt-in tool check, not an added packaging requirement.
|
||||
|
||||
### 28.7 Korean wording and contribution template — PRs #414 / #432
|
||||
|
||||
[PR #414](https://github.com/sganggs/Stronghold-Protocol/pull/414) by @D-Roote improves 292 existing Korean
|
||||
interface strings. Only its message values are ported; later keys and the pack's translation metadata remain.
|
||||
[PR #432](https://github.com/sganggs/Stronghold-Protocol/pull/432) by @Xinglan233 supplies a contribution
|
||||
checklist covering the problem, source evidence, assumptions, actual checks and golden changes. The template
|
||||
is installed at `.github/pull_request_template.md`, the default template location, so GitHub can offer it
|
||||
without a named-template query parameter. No gameplay rules change.
|
||||
|
||||
### 28.8 Stop recalculating held Spine poses — PR #434
|
||||
|
||||
Ported from [PR #434](https://github.com/sganggs/Stronghold-Protocol/pull/434) by @Cloudnyco. A finished,
|
||||
non-looping, unmixed death pose or frozen model can reuse its posed skeleton and cached pixels while its
|
||||
clock advances. First poses, new clips, tint changes, mixing, multiple tracks, pending wind-ups / closing clips,
|
||||
clipping switches, dirty textures and zoom rescaling still update. Redeployment resumes normal animation.
|
||||
Redeploy countdown labels share rasterized textures keyed by text, colour and resolution; late web fonts refresh
|
||||
them in place. The 64 targeted renderer tests passed. The author's performance measurements are not reproduced
|
||||
here; real Pixi checks are part of the integration pass. No battle rule or golden result is intentionally changed.
|
||||
|
||||
Real Chrome verification additionally renders one actual Spine death against continual forced updates through
|
||||
mixing and the held final frame (40 pixel comparisons over 600 frames), checks revival and the actor clock under the held-update policy,
|
||||
and reloads Bender after a shared label was rasterised. The label updates in place and DPR variants stay separate.
|
||||
This verifies correctness on that model; it is not a new performance benchmark or a full-roster pixel claim.
|
||||
|
||||
### 28.9 Preserve facing during pushes and pulls — #418 / PR #427
|
||||
|
||||
Ported from [PR #427](https://github.com/sganggs/Stronghold-Protocol/pull/427) by @YinqiMargatroid.
|
||||
Successful pushes/pulls mark their displacement events `keepFacing` so the renderer retains pre-hit facing
|
||||
while waiting for the endpoint snapshot and throughout the slide. Raw displacement and explicit
|
||||
`keepFacing: false` retain the old directional behavior. Position, distance and unbalance time are unchanged.
|
||||
The contributor's source clips and their limitations are retained in research 13; those observations were not
|
||||
independently replayed for this port. **[ASSUMED]** other push sources and pulls against the target's old facing
|
||||
share the examples' display behavior; the drag template alone cannot establish that engine rule.
|
||||
Tests cover real push/pull skill paths, default/override metadata, snapshot reversal, and an earlier attack
|
||||
arriving after slide prestart in either field direction. Golden metadata deliberately omits the display flag.
|
||||
|
||||
### 28.10 Charged guardian heals and mixed-skill activation — #406
|
||||
|
||||
Reported by @ChengCC0101. 塞雷娅 / 黍 S1 previously required an enemy to supply the attack that their charged heal
|
||||
replaces. [PRTS 塞雷娅](https://prts.wiki/w/塞雷娅) and [黍](https://prts.wiki/w/黍) describe an eligible ally at
|
||||
half HP or below, with the normal attack cadence. `updateAlly` now checks this alternate target after its cooldown
|
||||
and control/disarm checks; an enemy is unnecessary. An invalid pending heal still returns its charge. This corrects
|
||||
the enemy requirement inferred in historical §27.31; that earlier conclusion is superseded.
|
||||
|
||||
[PRTS 凯尔希·思衡托](https://prts.wiki/w/凯尔希·思衡托) S2 can attack enemies and heal allies. Its automatic
|
||||
activation now accepts either target kind on its expanded range, under the existing player-approved range strategy
|
||||
and operation cooldown. S1/S3 keep their existing trigger rules. This addresses the healing/activation parts of #406;
|
||||
the separate fatal-hit/silence report still needs an independently justified damage-order fix.
|
||||
|
||||
Verification: 104 targeted tests pass. Independent interaction review found and verified fixes for a one-frame
|
||||
cadence delay and premature charge use while disarmed; normal/100-ASPD intervals are exactly 1.2/0.6 s.
|
||||
Sleep, silence, no-heal targets, charge refunds, cooperative healing and S2 target selectability remain covered.
|
||||
|
||||
Golden: all 285 scenarios recomputed; `diy-059`, `diy-133` change because
|
||||
charged heals now act without enemy targets and S2 can open for enemies. Other scenarios remain byte-identical.
|
||||
|
||||
### 28.11 Integration checks for the expanded roster
|
||||
|
||||
The fixed roster assertions now include 72 owned DIY operators, 259 forms, 192 voiced operators and 2688 Japanese
|
||||
lines; the official-table comparison still checks every potential, skill and module. Clementia adds one 阿戈尔 pick.
|
||||
The official EN overlay has no launch-day Clementia talents yet: overall coverage remains ≥97%, with the measured
|
||||
talent floor explicitly updated to 94.9%; untranslated text keeps its Chinese fallback. The old Saria no-enemy
|
||||
assertion is corrected to require actual healing, and development builds retain the standard top-of-README notice.
|
||||
|
||||
|
||||
## §28.12 浏览器验收退出时释放自有日志管道
|
||||
|
||||
完整浏览器验收中,真实对局与观战的断言已经全部通过,但 Chrome 的崩溃报告/更新辅助进程继承了日志管道,测试进程仍在等待读取结束。共用测试客户端现在在浏览器子进程明确退出后释放它的 stdio;关闭失败、子进程仍活着时不关闭管道,也不操作其他浏览器进程。游戏运行时代码与战斗规则不变。
|
||||
|
||||
新增真实子进程继承 stderr 的回归,以及关闭失败/信号退出的边界检查;验收要求原异常文件单独重跑并确认正常结束。首轮异常与复测结果分别保留在本轮验收记录中。
|
||||
|
||||
同一轮的版本重连测试在刻意停服时,Chrome 可能报告连接拒绝、连接重置或未连接的 socket。测试只在主动停服至恢复握手的窗口内排除这些已知传输日志;正常运行期间仍检查它们,脚本异常始终保留,所有版本指纹、重连及换局刷新断言不变。
|
||||
|
||||
### 28.13 Special targets respect DEFAULT attack timing — #438–440
|
||||
|
||||
Seen: 死芒's normal attack reaches enemies blocked by his own summons, but S1/S2 did not open for those targets.
|
||||
The earlier 蕾缪安 fix also registered a per-tick trigger, allowing an early cast before her attack countdown elapsed.
|
||||
The [PRTS mode help](https://prts.wiki/w/卫戍协议/帮助) describes DEFAULT activation at the next ordinary attack.
|
||||
`addTriggerRange` now accepts `attackOnly`; both operators use it, while existing summon-area triggers retain their
|
||||
per-tick behavior. Only live owned summons / wanted marks contribute selectable targets. S2's actual link range is
|
||||
unchanged. PR #438 by @YinqiMargatroid addresses the same wanted-target symptom; the existing registration API is
|
||||
extended instead of introducing a second targeting flag. SIM.md and design/engine.md contain the current contract.
|
||||
|
||||
Tests: real summoned-bone blocking, S1/S2 and united fields, attack countdown, sleep/ownership, wanted-target S1–S3,
|
||||
plus existing 令 / 麦哲伦 trigger regressions. The combined operator checks pass 27 tests; the additional area-trigger
|
||||
and champagne controls also pass. Golden movement is recorded below.
|
||||
|
||||
### 28.14 Interface text size — #435 / PR #446
|
||||
|
||||
Adapted from @7eLaryn: four persisted text sizes use `--t` without changing the rem layout root. All shipped language
|
||||
packs retain their current translations. Added the missing explanatory note and fixed English XL settings overflow:
|
||||
segmented controls and header actions wrap within their available width. Long briefing alliance labels size and wrap
|
||||
with their cards instead of overlapping neighboring names. Default sm preserves the previous sizes.
|
||||
|
||||
Tests: settings / text-unit checks, phone landscape growth and unchanged board/canvas/shop geometry, reload persistence,
|
||||
and XL settings in Chinese, English, Japanese, Korean and Traditional Chinese. The first multilingual check exposed
|
||||
English overflow; the corrected browser suite passes. Current behavior is in design/client.md and PLAYING.md.
|
||||
|
||||
### 28.15 Cooperative card purchasing — PR #407
|
||||
|
||||
维护者 2026-10-10 审核后同意保留。此前未获明确批准,本次审核追认仅适用于本节已有功能;以后新增功能必须先取得维护者明确同意。
|
||||
|
||||
Adapted from @IceCodeNew. [ASSUMED] A tier V/VI shared-pool card matching another living teammate's active opening
|
||||
bond receives one 18-point buying-score penalty. It is a soft preference, not reserved stock; normal/elite and DIY
|
||||
stock semantics remain unchanged. Humans and bots receive the same cooperation. This does not add persona presets.
|
||||
|
||||
Tests: 14 targeted bot tests; 40-seed solo:HARD and coop4:HARD before/after benchmark is part of integration evidence.
|
||||
The comparison includes the special-target timing changes of §28.13, so it must not be interpreted as an isolated
|
||||
measurement of the purchasing heuristic. Current rule: design/match.md §6.6 and its follow-up contract.
|
||||
|
||||
### 28.16 AI teammate emotes — PR #444
|
||||
|
||||
维护者 2026-10-10 审核后同意保留。此前未获明确批准,本次审核追认仅适用于本节已有功能;以后新增功能必须先取得维护者明确同意。
|
||||
|
||||
Event wiring from @DDDarkstar, with corrected dispatch semantics. [ASSUMED] Cosmetic reactions are enabled only in
|
||||
mixed human/AI matches, under the existing emote whitelist and cooldown; `SP_BOT_EMOTES=0` disables all hooks.
|
||||
Responses skip cooling bots and stop after one reply. MVP uses this round's kills and damage, with stable order for
|
||||
ties; settlement reacts once per round. Only the third chess merge reacts, then resets for the next round. A gift uses
|
||||
its source playerId rather than a display-name heuristic. No RNG, gameplay decisions or timers are introduced.
|
||||
|
||||
Tests cover whitelist/cooldown, no human impersonation or recursive AI reply, pure-AI silence, round statistics,
|
||||
settlement/merge limits, gift identities, the disable switch and actual emote/acquisition hooks. README, META.md and
|
||||
design/match.md document the switch and boundary.
|
||||
|
||||
### 28.17 Active skill attack and impact sounds — PR #410
|
||||
|
||||
Adapted from @Convey123's updated PR. The current official `audio_data.json` gives 司霆惊蛰 mode attack banks and
|
||||
赤刃明霄陈 S3 separate swing/impact banks. The generator adds per-slot `attacks` / `hits`, each with its own optional
|
||||
`attackMix` / `hitMix`. It preserves the selected bank's chance and volume rather than applying the normal attack's
|
||||
mix. A late impact keeps the mode at launch; an end before spawn cancels a pending start. Snapshot restoration and
|
||||
ordinary-mode fallback remain supported. JP voices and current roster assets survive regeneration.
|
||||
|
||||
[ASSUMED] Where no activation sound names the mode, slot 1/2/3 is inferred as d/h/s. Bank selection prefers START to
|
||||
ON and then plain/numeric order; this approximates an ability graph and is not independently established for every
|
||||
operator. Mixed-letter banks are rejected. Instant effects that emit no normal attack event remain outside this
|
||||
mechanism. ASSETS.md and design/client.md specify the manifest/runtime contract.
|
||||
|
||||
Tests: 85 audio, manifest, official-mix and JP-voice checks, including independent mode gain, delayed impacts,
|
||||
end-before-spawn, late join and field reset. Regeneration is additive; no existing manifest leaf was removed. Four
|
||||
unresolved upstream candidate icon/avatar leaves still make strict asset fetching report failure; they are not new
|
||||
sound failures or deletions of existing entries. Packaging must retain this limitation visibly.
|
||||
|
||||
### 28.18 Per-operator voice language — #436
|
||||
|
||||
Roster details and selected DIY cards offer 中文 / 日本語 / 跟随全局. Settings persist a sanitized charId map locally;
|
||||
normal/elite/DIY forms of that charId share the choice. Existing profiles migrate to an empty map, so global language
|
||||
continues to apply. Deleting an override restores the current global choice. Missing Japanese slots / files keep the
|
||||
existing Chinese fallback. No room protocol or exported combat-loadout data changes.
|
||||
|
||||
Tests cover malformed/inherited entries, persistence, changing the global default, deleting an override and fallback;
|
||||
a real phone-sized browser changes the control, reloads and resets it. Current contract: design/client.md / PLAYING.md.
|
||||
|
||||
### 28.19 Browser installation — PR #413
|
||||
|
||||
维护者 2026-10-10 审核后同意保留。此前未获明确批准,本次审核追认仅适用于本节已有功能;以后新增功能必须先取得维护者明确同意。
|
||||
|
||||
Based on @Cromemadnd's proposal, with a one-use install controller. Buttons appear only after a browser install offer;
|
||||
standalone/fullscreen and installed sessions hide them. Accept, dismiss, failure and duplicate clicks all consume the
|
||||
same offer once. See [the prompt API](https://developer.mozilla.org/en-US/docs/Web/API/BeforeInstallPromptEvent/prompt)
|
||||
and [installability requirements](https://developer.mozilla.org/en-US/docs/Web/Progressive_web_apps/Guides/Making_PWAs_installable).
|
||||
The maintainer revised the icon direction on 2026-10-10: **沿用 0.2.x 的薄荷绿城堡并精修**.
|
||||
The selected B refinement keeps the previous mint rook's three battlements, inset tower and splayed base,
|
||||
with a narrower tower, balanced battlements, thinner base and more breathing room. It retains mint `#4ed8af`
|
||||
on dark `#0c0f0e`, without text, gradients, gloss or official logos, artwork, icons or fonts.
|
||||
The source is the project's `public/icons/app.svg` from before `ba5b998a`; the local `icon-v2` review includes
|
||||
the original and A/B/C proposals at large and small sizes, on light/dark backgrounds and simulated home screens.
|
||||
`public/icons/app.svg` is the selected B master; `tools/export-app-icons.py` exports
|
||||
192/512 px `any` and separate opaque `maskable` PNGs (the mark stays inside the central 40%-radius safe circle),
|
||||
16/32/48 px PNG / ICO favicons and a 180 px Apple touch icon. All ship outside optional game art, including lite
|
||||
packages. See ASSETS.md for the source, reproduction command and originality statement.
|
||||
The offline revision also corrects the ICO writer's transparency masks: each 16/32/48 px frame now uses its own
|
||||
native-size render and 4-byte-aligned opaque mask rows. Decoding previously exposed transparent holes in the 48 px
|
||||
frame; the regression checks mask layout / opacity and compares every ICO colour pixel with its PNG counterpart.
|
||||
No service worker or offline cache is installed; the server is still required.
|
||||
The lobby keeps Settings immediately before How to Play even when the optional install/recovery buttons appear;
|
||||
the full browser run caught and corrected an initial insertion between that established pair.
|
||||
The settings-entry check tests the absent board-style/background-music option labels, allowing the text-size help
|
||||
to explain that the board keeps its scale.
|
||||
|
||||
Tests: offer lifecycle/disposal, decoded PNG dimensions / opacity / mint palette / maskable safe zone,
|
||||
three distinct battlements and an inset tower at 16/32/48 px, favicon references and packaging inclusion.
|
||||
The initial feature also had real Chrome manifest and installability checks, and a synthetic
|
||||
dismissed offer through the actual button. Initial incognito rejection was a test-environment limitation; the real
|
||||
installability check runs in Chrome's ordinary temporary profile. Native OS installation itself is not performed.
|
||||
The icon revision is verified offline; browser / OS validation of these replacement assets is deferred to the follow-up
|
||||
review environment (this sandbox cannot listen on a port).
|
||||
|
||||
### 28.20 Recover a closed local match window — #431
|
||||
|
||||
Reproduced with an idle window retaining its own session while a second match window closes. The title/lobby now
|
||||
lists local saved match seats using bounded metadata beside the existing recent-token ring. Explicit selection asks
|
||||
live tabs to claim the chosen token again before adopting it (same-document reconnect since §28.27). An active tab is never deliberately
|
||||
replaced; names/IP are not authentication. Missing BroadcastChannel refuses recovery. A server-generated replacement
|
||||
token or leaving clears obsolete metadata. The initial feature changed no server API; §28.27 later adds optional hello guards and keeps startup on the tab's own identity.
|
||||
|
||||
Tests: live tab protection, closed-tab recovery, simultaneous claim arbitration, no channel/unknown id, malformed
|
||||
storage and expired metadata; a real two-window browser restores the identical server playerId and room after closing
|
||||
the match tab. The existing identity/static suite also passes. Current contract: design/client.md and network.md.
|
||||
|
||||
### 28.21 Unanimous opening reroll — #77 / PR #408
|
||||
|
||||
维护者 2026-10-10 审核后同意保留。此前未获明确批准,本次审核追认仅适用于本节已有功能;以后新增功能必须先取得维护者明确同意。
|
||||
|
||||
Adapted from @Siornya, excluding the unrelated result-screen redesign. [ASSUMED] This remake feature is host-proposed,
|
||||
requires every connected human's consent, and pauses confirmation while voting. Bots/spectators do not vote; autoplay
|
||||
humans still do. Single-human matches refresh immediately. Refusal, cancellation, disconnect or leave restores the
|
||||
old setup and remaining confirmation time. Success preserves room/seats/settings, resets Ready and restarts the full
|
||||
normal timer; fixed stages/bans remain fixed. The random result may repeat.
|
||||
|
||||
A dedicated seed-derived reroll stream does not advance the original shop/draft/meta streams. A 300 ms anti-burst guard,
|
||||
setup revision and vote id reject stale actions. Opening data and every player's DIY stock are prepared before any
|
||||
mutation; a forced failure on the second player's stock leaves all previous state intact. Current contracts are in
|
||||
META.md, design/match.md, design/network.md and PLAYING.md; all four translation packs have the new messages.
|
||||
|
||||
Tests: 13 match checks including real four-player sockets, permission/spectator checks, cancellation/disconnect,
|
||||
reconnect, last-second and queued-Ready timers, outdated messages, one human with bots, fixed rules, fuzzed requests
|
||||
and transactional failure. Final browser/integration validation supplements these targeted checks.
|
||||
|
||||
### 28.22 Late-loading deployment animation — part of PR #416
|
||||
|
||||
@Cromemadnd's proposal exposed a real missing case: onDeploy arrived before the model, so Start was never played.
|
||||
The view now retains elapsed game time until the model arrives and continues the existing clip at that point. A clip
|
||||
already over is not restarted; an intervening attack/death cancels the pending entrance. Existing Front/Back transfer
|
||||
and single-source deployment audio remain unchanged. The proposed repeated phase/purchase sound sequence is not part
|
||||
of this fix: its timing and duplication need separate visual evidence.
|
||||
|
||||
Tests: delayed load, simulation rate, load after clip end, attack/death supersession, destroyed view and existing
|
||||
UnitView behavior, 48 checks passing. No simulation/golden change from this visual fix.
|
||||
|
||||
### 28.23 Follow-up golden movement
|
||||
|
||||
Recomputed all 285 scenarios from the integrated code; never copied PR baselines. The DEFAULT cadence correction moves
|
||||
`roster-016`, `roster-035`, `roster-036`, `roster-045`; paired fields `boss-boss_1-pair`, `boss-boss_3-pair`,
|
||||
`boss-boss_5-pair`, `boss-boss_7-pair`, `hidden-boss_9-pair`; and match `solo-HARD-9-boosted`. 死芒's added activation
|
||||
condition moves `diy-132`. `coop2-NORMAL-14-standins` includes both cooperative purchasing and the corrected simulation.
|
||||
The pulse queue correction of §28.24 additionally moves `hidden-boss_10-solo`. The bonds and standins families are unchanged. Cosmetic emotes, preferences, installation and opening rerolls that
|
||||
are not requested do not intentionally alter those games. The validation record lists any outcome flips and measured
|
||||
benchmark changes; the independent final review remains pending.
|
||||
|
||||
### 28.24 Echo / counter recursion found by the cooperative benchmark
|
||||
|
||||
The 40-seed benchmark exposed existing hook-depth errors in co-op seeds 3 and 29, plus seed 14 after the purchasing
|
||||
change. In all three, “余音” damage pulses and 刺玫 / 泡泡 counters synchronously called each other. A scaled echo can
|
||||
have 67 hit-count HP; the generic 32-hook guard cut off effects before the legitimate chain finished. The same chain
|
||||
could also enter another hit while zero HP was waiting for death bookkeeping.
|
||||
|
||||
[PRTS “余音”](https://prts.wiki/w/“余音”) establishes the hit-count and damage-pulse behavior. [ASSUMED] The exact nested
|
||||
ordering is not documented: each echo now queues recursively earned pulses and resolves them FIFO in the same call,
|
||||
after the current pulse finishes. Every earned pulse retains its attack value, including the lethal hit's pulse. No
|
||||
extra tick, RNG draw, global hook-depth increase or arbitrary pulse suppression is introduced. Death prevents further
|
||||
counter targeting normally. This local scheduling choice needs final fidelity review.
|
||||
|
||||
Tests use real elite 刺玫 and 泡泡 against 67-hit echoes: all 67 pulses resolve, the echo dies, no engine error is logged,
|
||||
and the queue clears. Both tests fail against the previous implementation. Together with the existing enemy/boss and
|
||||
刺玫 healing suites, 273 tests pass. The benchmark now writes actual diagnostic messages beside nonzero error counts,
|
||||
so failures cannot be mistaken for ordinary performance results. Recomputed golden and final benchmark results are
|
||||
recorded with the candidate; do not reuse the earlier, erroneous benchmark as a clean result.
|
||||
|
||||
### 28.25 Community feedback of 2026-10-10
|
||||
|
||||
The current development candidate was checked against thirteen reports, including feedback explicitly describing
|
||||
0.2.1. Sources: [PRTS mode strategies](https://prts.wiki/w/卫戍协议:盟约),
|
||||
[alliance/item notes](https://prts.wiki/w/卫戍协议:盟约_下半/PRTS盟约记录),
|
||||
[吟游者 branch notes](https://prts.wiki/w/分支特性信息/data),
|
||||
[假想敌:铳](https://prts.wiki/w/假想敌:铳), the cached official enemy and activity tables, and the owner's supplied
|
||||
克莱门莎 official showcase. The multiplayer 50-second setting is the owner's explicit official-play requirement.
|
||||
|
||||
| Report | Verified result / change |
|
||||
|---|---|
|
||||
| 1. Lethal hammer/sailor stun after 不屈 | Fixed. Enemy normal attacks snapshot target deployments before damage. The subsequent periodic stun cannot affect a replacement deployment created during that strike. A surviving target is still stunned normally. Covers both hammer variants (third strike) and both dock sailors (fourth strike). |
|
||||
| 2. Opening strategy time | Multiplayer picks now have 50 seconds each, including skips; the header and per-turn deadline remain one clock. Solo and single-human matches remain untimed. Regenerated `config.json` from the builder. |
|
||||
| 3. Fartooth + dollkeepers | Already fixed in §28.1. Existing actual Kazemaru and Specter alter swap/return tests pass, including active-bond gating, caps and repeated swaps. |
|
||||
| 4. Gavial S2 pulls final boss | Not reproduced. Both tier-4 and tier-5 S2 implementations use the shared pull API, which rejects boss-pool leaders. New actual-S2 tests hit a final boss without moving it and additionally reject exaggerated pull force. |
|
||||
| 5. Clementia strategy gains inactive Aegir | Fixed. PRTS explicitly requires activation; the former `requireActive: false` and its old test contradicted that note. An inactive bond now gains nothing; an active one gains the knocked-out member's tier, still disabled in unite/boss battles. This is the strategy, separate from the new playable operator. |
|
||||
| 6. Pith/Aegir ATK in 0.2.1 | No confirmed current defect. Devoured ATK is `atkFinal`, added after an operator's own ATK percentages, then scaled by independent multipliers; existing numeric tests check this order. The report lacks the full lineup, skill/equipment state and exact unrounded stats, so 5340 versus “about 6040” alone cannot establish a discrepancy. |
|
||||
| 7. Stunned bards stop regeneration | Fixed for the default trait and Skadi alter's custom continuous trait. Aura refresh requires being on-field, not being able to act. Stun still blocks attacks/skill use, and leaving stops the aura. |
|
||||
| 8. Stationary redeployment loses Raid | Current code grants the ATK/HP bonus to the deployment made by Raid's relocation. Ordinary redeployment clears it; the existing passive-skill revive tests do jump promptly when their conditions hold. PRTS's “期间” does not settle whether an ordinary stationary redeploy should retain it. Preserve this rule pending an official comparison of that exact case. |
|
||||
| 9. Gun boss “90% reduction absent” | The official value is damage ×0.2 (80% reduction), already implemented. Fixed an actual coverage gap: PRTS includes another gun as well as springs in the condition. Also refresh on hit so the first hit and immediate companion removal cannot see an outdated aura. |
|
||||
| 10. Compass/tea +3 bypasses SP lock | Fixed. PRTS says all 黄沙罗盘 SP bypasses 阻回. A documented `ignoreLock` option permits the first-end refund and combo gift during `noSp` or a running skill, including the caster. Ordinary SP gifts still obey both locks. |
|
||||
| 11. Dobermann whip random bounty | Already implemented as a private three-choice selection (§27). Existing choice and reconnect tests pass. A timer/bot can still resolve an unanswered choice automatically. |
|
||||
| 12. Lemuen ammo does not gain layers | Not reproduced with real S3 consumption, normal and elite. Each ten bullets grants +2/+4 to each active named bond when at least three own operators share the row. Two actual casts cross that threshold and gain the expected layers. The existing garrison tests also cover fewer than three in the row. |
|
||||
| 13. Sudden simplified view | The message is emitted for any renderer startup failure, including import timeout, not exclusively unsupported hardware. Reworded it to describe the failure and offer reload. The prior mount-cancellation/late-disposal fix remains; the report alone does not identify this user's browser failure. No forced persistent 2D preference is written. |
|
||||
|
||||
The new same-strike revival, bard and SP-lock regressions fail seven checks against the pre-change simulation and
|
||||
pass after these fixes. The additional band and boss tests exercise inactive activation and same-tick reduction.
|
||||
The validation record accompanying the candidate lists full CI, browser recovery and golden results.
|
||||
|
||||
The official showcase was reviewed in talent/S1, S2 and S3 segments. S2 shows a moving cabin then a stationary vortex,
|
||||
including the second battlefield demonstration. S3's repeated bombardments agree with the documented three-hit,
|
||||
one-second pulse interval. Crownslayer's supporting skill and smoke are visible in the later S3 segment and are not
|
||||
attributed to Clementia. Edited footage does not settle the absolute first-bomb offset, S2 splash radius, or all exile
|
||||
status rules; the existing marked assumptions remain. No visual resemblance is used as proof of extra immunity.
|
||||
|
||||
Full golden regeneration covers all 285 scenarios. Four digests intentionally move relative to the preceding candidate:
|
||||
|
||||
- `roster-008`: 崇高牺牲 no longer grants 18 阿戈尔 layers while inactive; combat damage and kills are unchanged.
|
||||
- `boss-boss_4-solo`: the bard's regeneration continues during stun, changing healing, subsequent attacks and snapshots.
|
||||
- `hidden-boss_9-pair`: 铳's reduction is checked for the first hit/current companions; boss HP left changes
|
||||
2,992,115 → 2,992,511.
|
||||
- `standin-07`: hammer stuns no longer cross the immediate revival deployment provided by M3茧甲; kills 9 → 10,
|
||||
counted leaks 71 → 70, with corresponding combat/RNG/snapshot differences.
|
||||
|
||||
No scenario's ending reason changes. The bonds, matches and DIY families are byte-identical to the preceding golden
|
||||
files. The 50-second setting and fallback-message change do not alter battle rules in those digests.
|
||||
|
||||
### 28.26 Further community reports: deployment, air targets and champagne
|
||||
|
||||
Five reports were checked against the current candidate and PRTS on 2026-10-10. Reports without an operator,
|
||||
phase or enemy identity remain bounded by the scenarios actually tested.
|
||||
|
||||
| Report | Evidence and result |
|
||||
|---|---|
|
||||
| Deployment skill counts down before combat | Not reproduced in the battle lifecycle or real match prep-stat path. Constructing a battle does not deploy units; starting it activates deployment skills. Prep previews start their own battle for initial stats, never step it, send no SP/skill timer and cannot spend the next battle's duration. Tests cover 予愿安洁莉娜 S1 and repeated prep previews with 缄默德克萨斯. This does not identify the unspecified UI/phase in the report. |
|
||||
| 赤刃明霄陈 will not open S3 against flyers | Reproduced and fixed. `ACTIVE_RANGE` checked her ground-only normal-attack profile, although the sword wave can hit air ([PRTS](https://prts.wiki/w/赤刃明霄陈)). S3 now registers air-capable targeting on its expanded attack grid. Normal swings remain ground-only; operation cooldown, stealth/sleep selectability and stun still apply. [ASSUMED] The auto-cast region follows the existing expanded-grid policy, including permanent range extension, rather than the wave's whole future route. This is an automation choice, not a claim about the official mode's automatic-cast rule. |
|
||||
| Rear player's 蕾缪安 does not open S3 on front player's targets | Already handled by §28.4/§28.13. Added normal/elite united-field cases with the second player's 蕾缪安 and the first player's distant Laterano operator: the real wanted timer completes, S3 starts automatically, locks that enemy and spends ammo. The existing all-skill tests retain attack cadence and selectability. |
|
||||
| 琳琅诗怀雅 S2 immediately creates a bottle on deployment | Expected. [PRTS](https://prts.wiki/w/琳琅诗怀雅) identifies S2 as passive, deployment with S1/S2 as skill activation, and her talent as granting a coin on activation. The first bomb spends that coin without waiting for a merchant payment. No new SP cooldown is added. Existing throw-animation timing remains an assumption. |
|
||||
| 莎草 shields a champagne bomb | Not reproduced on the current candidate, which already gives bombs 禁疗 under the maintainer's earlier decision. Both skills actually heal/shield an adjacent operator but never chain to the bomb produced by 琳琅诗怀雅. [PRTS 莎草](https://prts.wiki/w/莎草) also excludes 禁疗 from S2's main-target selection. |
|
||||
| 予愿安洁莉娜 dies to splash while airborne | All three skills avoid ordinary ground 控潮术师 splash while airborne and take it after landing. A 萨卡兹悖谬暴虐兵长 splash explicitly ignoring target selectability still damages her, as expected. 起飞's 对地规避 is a selectability effect, not immunity to every damage source ([PRTS 异常效果](https://prts.wiki/w/异常效果), [暴虐兵长](https://prts.wiki/w/萨卡兹悖谬暴虐兵长)). Without the reported enemy's identity these checks cannot settle that particular encounter. No global immunity change. |
|
||||
|
||||
All 285 golden scenarios were regenerated and verified. Only `diy-074` moves: the mixed 陈 loadout against
|
||||
flying enemies now starts four previously idle S3 activations and emits sword-wave effects. Damage rises
|
||||
142376 → 163563; changed combat/target timing moves kills 21 → 18 and leaks 96 → 99, with the same 223.83 s
|
||||
clear time and end reason. All other 284 scenarios, including the five other golden families, are unchanged.
|
||||
The focused checks cover all four tier/elite forms, selectability/control/cooldown boundaries, actual casts,
|
||||
healing chains and enemy splash. Full CI passes: 5879 tests, 5876 passed, 0 failed, 3 skipped; setup, smoke, lint,
|
||||
imports and typecheck pass. This round changes no production UI and does not rerun the opt-in browser suites.
|
||||
|
||||
|
||||
### 28.27 Cabin capacity and local recovery races — #431
|
||||
|
||||
- **Observed:** 克莱门莎 S2 removed the carry buff from dead, hidden, uncarryable or unreachable passengers but
|
||||
retained them in the passenger list and weight budget. Two weight-3 riders blocked a replacement after one died;
|
||||
a dead zero-weight rider still occupied one of the 15 slots; a dead weight-4 rider retained four budget points.
|
||||
- **Now:** after moving the cabin, release invalid passengers and return each one's current weight before either
|
||||
boarding limit is checked that tick. Remaining passengers keep their order and movement. This corrects bookkeeping
|
||||
under the existing S2 rules and official blackboard limits (sources in §28.1); no new gameplay assumption is added.
|
||||
- **Observed (#431):** a recovery attempt with a smaller tab id could claim a token while its owner was refreshing
|
||||
or still initializing. The original page then fell back to another recent identity, or erased its own token.
|
||||
- **Now:** startup with a session token chooses only that token or null and answers ownership queries immediately.
|
||||
The first server welcome for a token stamps its ownership; reload/reconnect preserves that stamp. Earlier welcome
|
||||
wins a conflict, a live document wins an equal-stamp duplicate, and tab id resolves remaining ties. Explicit recovery
|
||||
keeps its choice only in memory and reconnects in the same document; only welcome persists it. An original owner
|
||||
returning cancels the lower-priority choice, including a later welcomed holder's reconnect. Nicknames and IPs remain
|
||||
irrelevant to identity; a refused recovery retains the previous nickname until its own welcome.
|
||||
- **In-flight handshake:** a hello can reach the server after another connection's welcome. Optional `hello.noReplace`
|
||||
makes shared candidates refuse a currently connected seat; `hello.claimAt` lets the server refuse an established
|
||||
holder with a later welcome stamp. Refusal is `SESSION_IN_USE`, before rebinding or closing the incumbent socket.
|
||||
Normal same-stamp reconnects and older clients remain accepted. These are local ownership hints, not authentication;
|
||||
the bearer token remains the authority. A revoked hello's stale welcome is ignored before going online, flushing
|
||||
queued actions or notifying listeners, and retries use a new socket. Reconnect-window duration is unchanged.
|
||||
- **Regression coverage:** `op_clemnt.test.js` adds same-tick replacement for death / hidden / self-bound /
|
||||
teleport immunity / failed path, the 15-slot and weight-4 probes, and a changed current weight. The eight regressions
|
||||
fail before the fix and pass after it. `client-static.test.js` covers live / reload / mid-init, no fallback identity,
|
||||
welcome-only persistence, earlier-welcome priority and a welcome arriving during init. The real-browser recovery
|
||||
test holds a refreshing owner's initialization open while another page clicks recovery and checks both server
|
||||
identities and live sockets; closed-window recovery also checks that welcome arrives without a page reload.
|
||||
`client-identity-handshake.test.js` controls delayed hellos / welcomes through the real client and server handlers,
|
||||
including a later holder's buffered reconnect, same-stamp reconnect, stale-welcome action suppression and validation.
|
||||
- **Golden:** all 285 scenarios regenerated. Only `diy-135` moves: releasing S2 capacity permits more later carries
|
||||
(teleport events 572 → 630), changing movement, attack/heal timing and RNG consumption. Clear time 194.33 → 198.03 s;
|
||||
outcome remains `cleared`, 150/150 kills, zero leaks, one allied death and zero errors. The other 284 scenarios and
|
||||
the `roster`, `bonds`, `fields`, `matches`, `standins` families are unchanged.
|
||||
- **Contracts updated:** design/engine.md, SIM.md (cabin), design/client.md, design/network.md, META.md and PLAYING.md
|
||||
(local identity recovery and handshake).
|
||||
@@ -0,0 +1,276 @@
|
||||
# DESIGN §29 — 0.2.4
|
||||
|
||||
Part of [DESIGN.md](../DESIGN.md). This chapter records 0.2.4, released on 2026-10-11.
|
||||
|
||||
## 29. Community follow-up
|
||||
|
||||
### 29.1 Boss overtime and Raid revival
|
||||
|
||||
- The maintainer requested the extra Final Assault / Hidden Core overtime grace only for teams with an AI teammate.
|
||||
At fight start, a non-departed AI seat preserves the configured 150 real seconds; otherwise the threshold is the
|
||||
visible level countdown (120 real seconds). The decision stays fixed during that fight. The first deduction is at
|
||||
the following whole real second, at one team LP per real second. `overtimeAt` uses that same threshold.
|
||||
- Ordinary Raid polls keep the 0.2.3 order: own field first, then remaining route distance and stable id, and they
|
||||
still jump only when the skill is ready or the member has been idle long enough and the range is empty.
|
||||
The maintainer confirmed one reselect immediately after a revive. That reselect alone orders by taunt, then
|
||||
remaining route distance, then id, and it jumps while the operator is not blocking, even if another enemy is
|
||||
already in range. Standing on an enemy's tile is blocking: the 0.25 s poll runs before that tick's block pass,
|
||||
so the reselect refreshes contact before it decides to stay. A retreat, a move, a redeploy that waited out its
|
||||
timer, the initial deployment and a Raid jump do not arm it. [ASSUMED] A revive is recognised as a knock-out
|
||||
redeploy that returns before `respawnAt`; the bond text does not name that timer split.
|
||||
- Regression: `boss-ai-grace.test.js` covers both final fights with/without AI; `community-round29.test.js` covers
|
||||
Texas S2 with local enemies, remote taunt/route priority, blocked revival and no repeated self-trigger.
|
||||
|
||||
### 29.2 Operator, item and sharing corrections
|
||||
|
||||
- #462, Pinecone S2: PRTS's growth is per deployment. A deployment-local use count resets on redeploy; the runtime's
|
||||
lifetime activation sequence is unchanged. Normal and elite variants are covered.
|
||||
- #459, Harold S2: a healer that can heal elemental injury considers elemental-only injury when deciding to cast.
|
||||
Full HP plus an elemental gauge can trigger; a wholly uninjured ally cannot.
|
||||
- #461, family crest: the first damage after stealth ends consumes the accumulated bonus immediately, including
|
||||
damage before the next growth poll. A synchronous equipment combo cannot consume it repeatedly; the current hit
|
||||
and combo retain the attack value that triggered them.
|
||||
- #464, Skadi S1 / Stead sharing: redirected damage carries both existing redirect ancestry markers, so neither
|
||||
mechanism revisits its own output and the transfer tree stays bounded. Both shares are taken from the pre-redirect
|
||||
hit and the victim keeps `1 − R − S` (1000 true with the official 0.4 and 0.5 leaves the victim 100, the three
|
||||
members 400 together and Skadi 500, including the one cross-application the ancestry tags already allow). A single
|
||||
effect is unchanged: Stead alone leaves 600 + 400, Skadi alone leaves 500 + 500. [ASSUMED] The two ratios add as
|
||||
shares of the original hit; no official source says they multiply. The same split does not depend on which
|
||||
damaged handler runs first. Hook registration order remains otherwise unchanged.
|
||||
- Sources: [PRTS 松果](https://prts.wiki/w/松果), [哈洛德](https://prts.wiki/w/哈洛德), mode item descriptions and the
|
||||
existing sharing contracts. `community-round29.test.js` includes before/after reproductions.
|
||||
|
||||
### 29.3 Ptilopsis copying Yu / Gladiia (#473)
|
||||
|
||||
The [original-game recording](https://www.bilibili.com/video/BV1XJSoBzEFB/) around 2:30–2:38 shows and explains that
|
||||
Ptilopsis's copies of Yu and Gladiia bypass the three-in-row prerequisite. The maintainer approved reproducing
|
||||
**only these two exceptions**. Preparation-start gifts from these copied traits skip the row condition; original
|
||||
owners, Saria, other traits and other events retain their conditions. The dispatcher records the copied origin in
|
||||
`source.copiedFrom`; eight original/copier-row combinations are tested in `garrisons_meta.test.js`.
|
||||
|
||||
### 29.4 Clementia S2 and final-boss erosion
|
||||
|
||||
[PRTS 克莱门莎](https://prts.wiki/w/克莱门莎), checked 2026-10-10, now specifies S2 ordinary-attack splash: radius
|
||||
1.1 with collision testing, at most four extra ground targets, each taking 100% ATK physical damage. This replaces
|
||||
the launch-day assumed 1.5 centre radius and copied main-hit multiplier. The primary target retains the skill's
|
||||
own attack multiplier; the vortex remains radius 1.5. Normal/elite numeric, radius and large-body regressions live in
|
||||
`op_clemnt.test.js`. The unresolved launch-day carriage/bomb timing assumptions remain documented in §28.1.
|
||||
|
||||
The [PRTS mode record's tips](https://prts.wiki/w/卫戍协议:盟约/PRTS盟约记录) explicitly state that Final Assault
|
||||
leaders do not receive erosion injury. The existing mode-specific immunity remains. All ten final-leader definitions
|
||||
were compared against all five injury types: erosion was blocked, the other four could burst. Main-game constants
|
||||
give leaders a 2,000 erosion threshold, so main-game boss identity alone is not general erosion immunity; that does
|
||||
not override the mode tip or prove every scripted boss phase susceptible. Pith's current three named injury types
|
||||
are unchanged. No unannounced future-season behavior is inferred.
|
||||
|
||||
### 29.5 Shield placement and original champagne art
|
||||
|
||||
- #460: Jessica's placeable shield uses the four orthogonally adjacent tiles stated by her talent. Preview, server
|
||||
validation, owner movement and swaps use a shared placement grid, independent of skill, facing or module.
|
||||
`jessica-placement.test.js` covers legal/illegal moves, swaps, bots and other summons.
|
||||
- #468: the original champagne-bomb model is extracted from the local official token bundle through the existing
|
||||
extraction/build pipeline. There are now 40 local token metadata records. Only this token's `spineLocal` and the
|
||||
generated asset hash change; the web fallback remains. No game art enters Git.
|
||||
|
||||
### 29.6 UI and recovery
|
||||
|
||||
- DIY selection reuses the roster's `LoadoutStats` component, showing the chosen ordinary/elite stats, attack range,
|
||||
trait, talents, potential and cultivation. Skill descriptions/SP labels follow the same preview level. The list
|
||||
uses aligned roster-style rows; list and details scroll independently, leaving Confirm reachable.
|
||||
- #450: Swire's skill coins show as current/cap, including zero. Optional `coins: [[id, balance, cap]]` comes from
|
||||
valid live-unit state, is stepped between snapshots and clears when absent. It changes no earning/spending rule.
|
||||
- #471: lobby tools are grouped at the left; the player chip opens statistics. Responsive title allocation prevents
|
||||
overlap with long names, translated text, resume and install controls; a narrow header puts its title on a new row.
|
||||
- #458: settlement summary scrolls independently from its return button. Enlarged text no longer pushes the button
|
||||
outside the viewport.
|
||||
- Transient failed images and board packs can retry on a later useful event. Three.js failed imports use two bounded
|
||||
fresh URLs because browsers cache module failures. A failed field engine may remount on online/visibility events,
|
||||
with cleanup and at most two consecutive retries; explicit fallback/2D choices remain respected. Real Chrome
|
||||
tests abort actual resource requests and verify recovery, single canvas ownership and unmount cleanup. This does
|
||||
not establish the cause of every reported device/GPU failure.
|
||||
- #469's repeated “甄选干员干员” was a result validator replacing a DIY operator's name with its empty slot's name.
|
||||
It now resolves DIY and stand-in names from the authoritative BattleSpec; client-supplied names remain ignored.
|
||||
- #448: Japanese UI terms are corrected, including 自然回復 and 完璧な作戦, consistent with official Japanese data.
|
||||
|
||||
### 29.7 Verified reports and pending proposals
|
||||
|
||||
Muelsyse's deployed Harmony correctly adds one member to existing core bonds, without counting a benched copy or
|
||||
stacking duplicate traits. Six Kazimierz stuns nearby hovering enemies and makes them land; the radius remains 0.8.
|
||||
Targeted real-socket tests cover eliminated host/guest departures in prep/combat/unite and survivor reconnection.
|
||||
These checks did not reproduce the reported generic Harmony/room failures; no speculative rule change was made.
|
||||
|
||||
The plugin groundwork remains the existing content-pack manifest/language implementation; a runtime plugin API is
|
||||
not added. Additional dub languages remain deferred until supported resources exist. Other rule/AI proposals
|
||||
require explicit decisions and evidence; open PRs are not blanket approval for their implementations.
|
||||
|
||||
Validation and intended golden moves are recorded in §29.12.
|
||||
|
||||
### 29.8 Shared inventory (#466, #430; approved after reviewing the source video)
|
||||
|
||||
The maintainer requested the measured rules in 路标档案馆's original
|
||||
[路标月报 #2](https://www.bilibili.com/video/BV1eLXXBqEgF/) (2026-03-29): single-player operators around
|
||||
0:30–1:00, equipment around 1:30, co-op operators around 2:00, ownership/special grants around 2:30–4:00.
|
||||
Official client tables supply identities, tiers and merge recipes;
|
||||
[PRTS mode help](https://prts.wiki/w/卫戍协议/帮助) supplies equipment return/merge and hand rules.
|
||||
|
||||
| Stock | Base counts by tier I–VI | Exceptions |
|
||||
|---|---|---|
|
||||
| Single-player operators | 12 / 14 / 18 / 16 / 8 / 5 | I: 普罗旺斯、德克萨斯、跃跃、古米、格雷伊 = 10; II: 风丸 = 8, 赫默、休谟斯、砾、蒂比、调香师 = 12 |
|
||||
| Co-op shared operators | twice the single-player counts | exceptions doubled too; independent of seat count |
|
||||
| Shared shop equipment | 4 / 6 / 7 / 8 / 7 / 3 | 简易通讯机 = 5; 蜂鸣器、防暴盾、浓缩嗅盐 = 6; 寻呼模块、伪装服、拉特兰桥夹 = 7; 商业包装方案 = 2 |
|
||||
|
||||
- This explicit decision supersedes the old blanket shared counts and Muelsyse override. Private DIY stock keeps its
|
||||
per-player 8/5 capacities; the video's multiplier describes the shared pool.
|
||||
- Normal/elite operators occupy 1/3 copies by every gain route. Kazemaru still merges two normals, but the elite
|
||||
occupies three. Promotion also occupies the full three. Grants beyond capacity keep a negative internal balance:
|
||||
selling one copy cannot expose stock while ownership remains above the cap. Public availability and sampling are
|
||||
clamped at zero; `raw balance + owned copies = capacity` remains invariant.
|
||||
- Mimic samples eligible identities rather than remaining copies and can exceed stock, including the third-copy
|
||||
branch. This supersedes #207's old empty-pool failure. Other pool-weighted grants keep their selection rules.
|
||||
- Equipment across every player's hand, temporary area and equipped slots is counted live: normal 1, upgraded 2.
|
||||
Shop/choice displays reserve nothing. Exhausted purchases fail before charging or marking the offer taken; draft
|
||||
duplicates become unavailable when an earlier choice empties stock, and automatic picks skip them. Equipment
|
||||
returned from a sold/merged operator still occupies stock in the hand. 整备 upgrades the bought item only when
|
||||
another copy is still free (the purchase already took one, and a golden takes two). Otherwise the item stays
|
||||
normal and the 整备 charge is not spent. An uncapped item may still be upgraded. Held copies of every capped
|
||||
shop item stay within that cap.
|
||||
- [ASSUMED] Consumption/destruction/removal by elimination ends ownership and therefore releases equipment stock;
|
||||
the video does not separately document every removal path. A third lifetime purchase is possible after a prior
|
||||
copy leaves ownership. Non-shop effect-only equipment has no shared shop cap, consistent with unlimited special
|
||||
gifts. This never becomes a two-appearance or two-purchase limit for commercial packaging.
|
||||
- Stock counts alone do not establish probabilities. Existing shop tier shares, uniform item selection and explicit
|
||||
effect-pool weights remain, filtered for availability. No new probability curve is inferred from the table.
|
||||
- `shared-stock.test.js` covers exceptions, shared ownership, stale offers, reacquisition after destruction, upgraded
|
||||
occupancy, consumption, duplicate draft exhaustion and Mimic overdraw/release.
|
||||
|
||||
### 29.9 Ember proposal (#465) — current behavior preserved
|
||||
|
||||
The history in §22.8 and the 0.1.2 changelog records the chosen compromise for queued 深池逐火 enemies. Comparing
|
||||
the current code with the proposed phase-order swap reproduces that risk: after a blocker takes the next warrior,
|
||||
the three husk types can be finished by ranged hits in the current reveal window; the proposal hides them throughout
|
||||
that window and they stand up again. Reveal or spare blocking capacity defeats them in either version. This is not
|
||||
absolute invulnerability, but it removes the previously chosen counterplay window. The proposal is not adopted;
|
||||
the existing phase-order assumption remains pending a better official comparison.
|
||||
|
||||
### 29.10 Approved scope additions
|
||||
|
||||
- #467: Clementia S2 includes selectable airborne targets in the expanded skill attack grid when deciding to cast,
|
||||
like Chen S3. Normal attacks stay ground-only. [ASSUMED] No mode recording establishes a separate trigger shape
|
||||
for this new operator. Tier/form, range, stealth/sleep, stun and operation-cooldown regressions cover it.
|
||||
- #451: the reserve medic appears in preparation, scouting and boss-half views as a read-only map character,
|
||||
including fallback rendering. Touch replaces it when battle starts. Initial aggro order remains unchanged.
|
||||
|
||||
### 29.11 Champagne selection follow-up (#476, replaces #468)
|
||||
|
||||
[PRTS 香槟炸弹](https://prts.wiki/w/香槟炸弹) identifies it as a device, normally excluded from ordinary allied selection. Both bomb creation paths now share the internal `isolated` selection barrier: Angelina auras and Skadi inspiration no longer select it, while ordinary unhealable summons remain selectable. HP protection, no-heal behavior and detonation retain the previous rules. The new source is PR #476 by Siornya; its original-art portion was first proposed in #468. Both creation paths, ordinary-summon controls and existing aged/contact detonations are tested.
|
||||
|
||||
### 29.12 Validation and golden changes
|
||||
|
||||
Full regeneration after the post-revive Raid reselect, the shared-damage split and the 整备 stock check. The corpus is
|
||||
287 scenarios. No release scenario was removed. Two scenarios were added.
|
||||
|
||||
265 of the 285 release scenarios keep the 0.2.3 digest (`1db8e510`): roster 49/49, bonds 46/46, fields 22/22,
|
||||
stand-ins 10/10, and 138 of 140 DIY battles. The taunt order applies only to the one reselect after a revive, and
|
||||
these battle digests stay on the release result.
|
||||
|
||||
`diy-135` and `diy-136` move because their fields include Clementia. `op-clemnt.js` is the only operator kit on
|
||||
either field that differs from 0.2.3; the Mon3tr kit, and the defender, guard and sniper kits on `diy-136`, are the
|
||||
release kits. The splash and airborne-cast changes are §29.4 and §29.10.
|
||||
|
||||
- `diy-135` (Clementia S1–S3 and Mon3tr): `cleared` at 198.03 s, 150 kills, 0 leaks, perfect, 1 death becomes
|
||||
`timeout` at 230 s (5,941 → 6,900 ticks), 149 kills, 1 leak, not perfect, 0 deaths. Damage dealt 1,661,420 →
|
||||
1,648,194. The first unit row that moves is Clementia S1 (`chess_char_5_diy1_a`), kills 1 → 0.
|
||||
- `diy-136` (Clementia S1–S3 with defenders, guards and snipers): still `cleared` at 212.4 s, still not perfect.
|
||||
Kills 13 → 12, leaks 110 → 111, damage dealt 651,626 → 540,205. Clementia S2 (`chess_char_6_diy2_a`) kills
|
||||
7 → 6 and damage 383,594 → 320,372.
|
||||
|
||||
All 18 release matches move. Every one changes its shop-draw count, and the first settle row that differs changes
|
||||
the board hash (operator, tile and equipment). That is the shared-stock lineup change in §29.8. `solo-HARD-9-boosted`
|
||||
is the only match whose first differing row also changes kills and leaks; its board and bonds change on that same row.
|
||||
|
||||
The ending class changes:
|
||||
|
||||
- `solo-FUNNY-2`: defeat at round 8, team LP 0, boss HP left 17,162 becomes victory at round 9, team LP 2, boss
|
||||
cleared. Shop draws 123 → 154. The round-1 board changes and gains `yanShip 0/1`.
|
||||
- `solo-HARD-1`: victory at round 14, team LP 12, boss cleared becomes defeat at round 13, team LP 0, boss HP left
|
||||
341,377. Shop draws 201 → 215. The round-3 board changes.
|
||||
- `solo-HARD-2`: defeat at round 13, team LP 0, boss HP left 66,239 becomes victory at round 14, team LP 28, boss
|
||||
cleared. Shop draws 279 → 283. The round-1 board changes.
|
||||
- `solo-ABYSS-1`: eliminated in round 13 after 12 rounds becomes defeat at round 13, still alive, team LP 0, boss HP
|
||||
left 2,832,671, hidden boss unreached. Shop draws 138 → 215. The round-3 board changes.
|
||||
- `solo-ABYSS-2`: eliminated in round 12 after 11 rounds becomes defeat at round 13, still alive, team LP 0, boss HP
|
||||
left 2,823,834, hidden boss unreached. Shop draws 179 → 280. The round-1 board changes.
|
||||
- `coop3-ABYSS-5`: eliminated at round 11 becomes defeat at round 13, team LP 0, boss HP left 2,616,799, hidden boss
|
||||
unreached. `ai_0` stays eliminated in round 10. `ai_1` is no longer eliminated in round 12 and passes 13 rounds.
|
||||
`ai_2` is eliminated in round 11 after 10 rounds, previously round 10 after 9. Shop draws 531 → 506. The round-2
|
||||
board changes.
|
||||
- `coop4-ABYSS-7`: eliminated at round 12 becomes defeat at round 13, team LP 0, boss HP left 3,486,896, hidden boss
|
||||
unreached. `ai_0` is no longer eliminated in round 13 and passes 13 rounds. `ai_1` passes 8 → 11 rounds and is
|
||||
eliminated in round 12 instead of 9. `ai_2` passes 11 → 12 rounds and is eliminated in round 13 instead of 12.
|
||||
`ai_3` stays eliminated in round 8 after 7 rounds. Shop draws 539 → 645. Round-4 boards, bonds, pending gold and
|
||||
layer counts change.
|
||||
|
||||
Same victory and reason, team LP changes:
|
||||
|
||||
- `solo-FUNNY-1`: victory at round 9, team LP 27 → 16. Shop draws 142 → 87. The round-1 board drops `sargonShip`.
|
||||
- `solo-NORMAL-2`: victory at round 14, team LP 25 → 15. Shop draws 227 → 226. The round-1 board changes.
|
||||
- `coop2-NORMAL-3`: victory at round 14, team LP 68 → 51 (`ai_0` 41 → 34, `ai_1` 27 → 17). Shop draws 449 → 487.
|
||||
The round-3 `ai_1` board changes.
|
||||
- `coop2-NORMAL-8-serverrun`: victory at round 14, team LP 58 → 26 (`ai_0` 30 → 10, `ai_1` 28 → 16). Shop draws
|
||||
485 → 482. The round-1 `ai_1` board changes and `preciShip` goes from tier 1 to tier 0.
|
||||
- `coop2-NORMAL-14-standins`: victory at round 14, team LP 30 → 31 (human 17 → 18). Shop draws 428 → 403. The
|
||||
round-4 boards change and the human's `raidShip 1/8` is gone. Stand-ins were fielded in 10 of 13 settle rows at
|
||||
0.2.3 and in 0 of 13 now.
|
||||
- `coop2-ABYSS-10-boosted`: victory at round 15 with the hidden boss cleared, team LP 630 → 704 (`ai_0` 315 → 354,
|
||||
`ai_1` 315 → 350). Shop draws 591 → 649. The round-1 boards change.
|
||||
|
||||
Same victory, reason, rounds and team LP:
|
||||
|
||||
- `solo-NORMAL-1`: defeat at round 13, team LP 0. Boss HP left 884 → 50,897. Shop draws 207 → 159. The round-3
|
||||
board changes.
|
||||
- `solo-HARD-9-boosted`: victory at round 15, team LP 399, hidden boss cleared. Shop draws 346 → 343. Round 8
|
||||
changes the board, the bonds, gold, kills 18 → 19 and leaks 1 → 0 together; that row's LP is 399 → 400 and the
|
||||
final team LP stays 399.
|
||||
- `coop2-HARD-4`: eliminated at round 12. Shop draws 419 → 417. The round-3 boards and bonds change (`ai_0` gains
|
||||
`arcaneShip` and `preciShip`; `ai_1` loses `sargonShip`).
|
||||
- `coop2-NORMAL-70-diy`: victory at round 14, team LP 55. Shop draws 503 → 586. The round-3 boards change and the
|
||||
human's bonds become `miraShip` and `yanShip`. DIY operators were fielded in 6 of 13 settle rows at 0.2.3 and in
|
||||
0 of 13 now. Virtual duration 121,400 → 118,400 ms.
|
||||
- `coop4-FUNNY-6`: victory at round 14, team LP 130. Shop draws 832 → 952. The round-1 `ai_2` board, layers and
|
||||
bonds change. Virtual duration 130,000 → 127,000 ms.
|
||||
|
||||
Added coverage, both victories at round 14:
|
||||
|
||||
- `coop2-NORMAL-1-coverage-standins`: team LP 37 (human 11, bot 26). Stand-ins are fielded in 6 of 13 settle rows.
|
||||
- `coop2-NORMAL-5-coverage-diy`: team LP 30 (human 23, bot 7). DIY operators are fielded in 5 of 13 settle rows.
|
||||
|
||||
Seeds 14 and 70 keep their outcome rows and, under these lineups, field no stand-in and no DIY operator. The two
|
||||
added seeds carry those paths.
|
||||
|
||||
Replacing a field renderer also resets the game screen's entered-view identity. Frames are buffered while the replacement loads; after subscribing, the local runner re-shows full current metadata/snapshot, or the server resends the watched field. Real local and server-streamed battles preserve the same simulation object and clock through recovery, including enemies spawned before the new renderer existed. A refresh of the same field whose metadata lists no device keeps crates and turrets already noted from their one spawn event; metadata that names devices replaces that list. A different field does not keep the previous battle's devices.
|
||||
|
||||
### 29.13 Multiplayer boss Raid landing (#475)
|
||||
|
||||
The maintainer approved a narrow revision to §26.1 on 2026-10-10 after the report of three remaining multiplayer
|
||||
seats leaving one boss field unpaired. Only Raid in `mode_multi_*` Final Assault / Hidden Core battles may use the
|
||||
empty half's normal deployment area (rows 2–5, cols 2–18). This is an explicit maintainer decision on the community
|
||||
report, not a claim of independently verified official footage. Hand row 0, temporary row 1, single-player mode's
|
||||
empty boss half and lone-helper 联防's empty half stay closed. Terrain, field bounds, living/downed reservations,
|
||||
target ordering and attack reach retain their checks. The exception is local to Raid; general board membership,
|
||||
Ulpian S3 movement, summons and preparation placement do not expand.
|
||||
|
||||
`feedback5-raid-own-board.test.js` reproduces the eight left/right, melee/ranged, boss/hidden blocked cases before
|
||||
the fix, and checks actual solo modes, hand/temp/side margins, terrain and living/downed reservations afterward.
|
||||
All 287 golden scenarios were regenerated and verified with no additional digest changes. A fresh 80-match sample
|
||||
also had zero errors and unchanged wins, completed rounds and final LP versus the preceding candidate. Two co-op
|
||||
HARD runs changed round-14 counters: seed 33's unpaired player killed 22 enemies instead of 26; seed 39's unpaired
|
||||
player killed 5 instead of 6 and leaked 1 instead of 0. Opening more landing tiles does not guarantee better outcomes.
|
||||
|
||||
### 29.14 Device cards after field refresh
|
||||
|
||||
Re-entering the same battle with metadata that omits once-spawned devices now restores the remembered device records
|
||||
to the renderer as well as to the detail-card lookup. Otherwise an HP snapshot recreated the crate as an unknown
|
||||
enemy: picking emitted a unit click with no definition, instead of the tile click needed by the device card. The
|
||||
engine-canvas regression retains the card/100 HP assertions and also covers another field refresh, live HP loss and
|
||||
closing the card when the crate is destroyed. Device metadata supplied by the new entry still wins; changing fields
|
||||
does not carry old devices across. Gameplay and allied targeting rules are unchanged.
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
# 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 的渲染方案由 0.2.2 的 `2669b6a` 移植,并非该 PR 直接合入;当时模拟层、协议、golden 未改。
|
||||
0.2.2 的失衡状态另改了模拟层,`displace` fx 多带一个 `dur`。)
|
||||
|
||||
推击模型朝向的原版对照有两类:[野鬃 S2 原版约 1:29](https://b23.tv/jMhl7xx) 的
|
||||
[关键片段](https://github.com/user-attachments/assets/28acc81e-7ade-4245-b4d8-70be52546257) 显示方向推击时“火与钢”继续面朝原方向,
|
||||
[复刻录屏](https://github.com/user-attachments/assets/7383bdb8-3974-47f2-b5b3-303af609cd4d) 显示其滑行时翻转(#418);
|
||||
[莫斯提马 S3 原版 p2 约 34:33](https://b23.tv/EguihiI) 的用户截取片段在相邻帧中显示,持斧敌人受径向击退并向右滑时仍保持原来的模型朝向。
|
||||
PRTS「推与拉」把推力分为方向力和径向力;官方 `knockback[dir]` / `knockback[relative]` 均使用 `Knockback` 动作类。
|
||||
拖拽有不同实现:[薄绿 S2 原版约 4:26](https://b23.tv/v5QQ6uv) 对应 PRTS「推与拉」所称的反向径向推击,
|
||||
本项目用 `Battle.push({ inward: true })`;[歌蕾蒂娅 S1/S2 原版录像](https://b23.tv/YLYYSPJ) 展示钩索拖拽,
|
||||
本项目走 `Battle.pullToFront` → `Battle.pull`。用户截取的两段视频直接展示被拖拽目标在动作前后没有转身,
|
||||
作为这些实例的画面证据。歌蕾蒂娅片段的目标原本就面朝拖拽方向,因此这段画面无法单独区分
|
||||
「始终保持原朝向」和「始终面朝拖拽方向」两种全局规则;将所见外推到反向拉拽属于推断。
|
||||
官方客户端模板的 `DragTowardSource` 动作节点没有显式朝向参数,但这一点也不能排除引擎内部的转向处理。
|
||||
|
||||
因此 `Battle.push` 与 `Battle.pull` 默认在 `displace` fx 带 `keepFacing`,从等待终点快照到滑行结束保留原朝向。
|
||||
其他未录制的推击来源及朝向与拉拽方向相反的拉力来源均属 **[ASSUMED]**,技能描述中的「推开」「击退」「拖拽」本身
|
||||
没有规定模型朝向。`KnockBackWithCharacterDirection` 另有 `_dontChangeFaceByDirection` true / false,
|
||||
若当前或未来技能明确要求转向,调用方可设置 `keepFacing: false`。直接 `displace` 的朝向逻辑不变;
|
||||
位移位置与失衡时间也不变。
|
||||
|
||||
滑行途中死亡:滑行照常走完,尸体停在被推到的位置(模拟里它已经在那里;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 已说明原因)。
|
||||
@@ -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.
|
||||
@@ -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().
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "stronghold-protocol-alliance",
|
||||
"version": "0.2.1",
|
||||
"version": "0.2.4",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "stronghold-protocol-alliance",
|
||||
"version": "0.2.1",
|
||||
"version": "0.2.4",
|
||||
"hasInstallScript": true,
|
||||
"license": "GPL-3.0-or-later",
|
||||
"dependencies": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "stronghold-protocol-alliance",
|
||||
"version": "0.2.1",
|
||||
"version": "0.2.4",
|
||||
"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"
|
||||
},
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
border: 1px solid var(--btn-bd);
|
||||
background: var(--btn-bg);
|
||||
color: var(--btn-fg);
|
||||
font-size: .18rem;
|
||||
font-size: calc(.18 * var(--t));
|
||||
font-weight: 700;
|
||||
letter-spacing: .06em;
|
||||
line-height: 1;
|
||||
@@ -164,9 +164,9 @@
|
||||
.btn.is-loading { cursor: default; --btn-fg: var(--text-lo); }
|
||||
.btn--primary.is-loading { --btn-bg: var(--mint-700); --btn-fg: var(--text-on-mint); }
|
||||
|
||||
.btn--sm { height: .36rem; min-width: .36rem; padding: 0 .16rem; font-size: .15rem; gap: .07rem; }
|
||||
.btn--lg { height: .64rem; min-width: .64rem; padding: 0 .34rem; font-size: .22rem; gap: .12rem; }
|
||||
.btn--xl { height: .84rem; min-width: .84rem; padding: 0 .48rem; font-size: .28rem; letter-spacing: .24em; gap: .16rem; }
|
||||
.btn--sm { height: .36rem; min-width: .36rem; padding: 0 .16rem; font-size: calc(.15 * var(--t)); gap: .07rem; }
|
||||
.btn--lg { height: .64rem; min-width: .64rem; padding: 0 .34rem; font-size: calc(.22 * var(--t)); gap: .12rem; }
|
||||
.btn--xl { height: .84rem; min-width: .84rem; padding: 0 .48rem; font-size: calc(.28 * var(--t)); letter-spacing: .24em; gap: .16rem; }
|
||||
.btn--block { display: flex; width: 100%; }
|
||||
.btn--square { padding: 0; width: var(--sq, .5rem); }
|
||||
.btn--square.btn--sm { --sq: .36rem; }
|
||||
@@ -204,7 +204,7 @@
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.panel__titles { display: flex; flex-direction: column; gap: .03rem; min-width: 0; }
|
||||
.panel__title { font-size: .21rem; font-weight: 700; letter-spacing: .04em; }
|
||||
.panel__title { font-size: calc(.21 * var(--t)); font-weight: 700; letter-spacing: .04em; }
|
||||
.panel__actions { display: flex; gap: .08rem; align-items: center; }
|
||||
.panel__body { position: relative; padding: .22rem; min-height: 0; }
|
||||
.panel--flush .panel__body { padding: 0; }
|
||||
@@ -472,9 +472,9 @@
|
||||
opacity: .55;
|
||||
}
|
||||
.modal__head { display: flex; flex-direction: column; gap: .04rem; padding: .22rem .3rem .04rem; }
|
||||
.modal__title { font-size: .26rem; font-weight: 700; letter-spacing: .04em; }
|
||||
.modal__title { font-size: calc(.26 * var(--t)); font-weight: 700; letter-spacing: .04em; }
|
||||
.modal__body { padding: .14rem .3rem .26rem; color: var(--text-md); overflow: auto; }
|
||||
.modal__text { font-size: .18rem; line-height: 1.7; }
|
||||
.modal__text { font-size: calc(.18 * var(--t)); line-height: 1.7; }
|
||||
.modal__actions {
|
||||
flex: none;
|
||||
display: flex;
|
||||
@@ -500,7 +500,7 @@
|
||||
border-left: 2px solid var(--mint-500);
|
||||
box-shadow: 0 .06rem .2rem rgba(0, 0, 0, .55);
|
||||
color: var(--text-md);
|
||||
font-size: .15rem;
|
||||
font-size: calc(.15 * var(--t));
|
||||
line-height: 1.55;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
@@ -515,8 +515,8 @@
|
||||
|
||||
/* ---- progress bar ----------------------------------------------------------------------------------- */
|
||||
.pbar { --pc: var(--mint-500); --ph: .1rem; display: flex; flex-direction: column; gap: .05rem; min-width: .8rem; }
|
||||
.pbar__meta { display: flex; justify-content: space-between; align-items: baseline; gap: .1rem; font-size: .14rem; color: var(--text-lo); }
|
||||
.pbar__value { font-family: var(--font-num); font-weight: 700; color: var(--text-hi); font-size: .16rem; }
|
||||
.pbar__meta { display: flex; justify-content: space-between; align-items: baseline; gap: .1rem; font-size: calc(.14 * var(--t)); color: var(--text-lo); }
|
||||
.pbar__value { font-family: var(--font-num); font-weight: 700; color: var(--text-hi); font-size: calc(.16 * var(--t)); }
|
||||
.pbar__value small { color: var(--text-dim); font-size: .8em; }
|
||||
.pbar__track { position: relative; height: var(--ph); background: #080a09; border: 1px solid var(--line); overflow: hidden; }
|
||||
.pbar__fill {
|
||||
@@ -549,7 +549,7 @@
|
||||
border: 0;
|
||||
background: none;
|
||||
color: var(--text-lo);
|
||||
font-size: .17rem;
|
||||
font-size: calc(.17 * var(--t));
|
||||
font-weight: 500;
|
||||
letter-spacing: .04em;
|
||||
transition: color var(--t-fast) var(--ease-out), background-color var(--t-fast);
|
||||
@@ -570,11 +570,11 @@
|
||||
}
|
||||
.tabs__tab.is-active { color: var(--mint-400); }
|
||||
.tabs__tab.is-active::after { transform: scaleX(1); }
|
||||
.tabs__badge { font-family: var(--font-num); font-size: .13rem; padding: .01rem .06rem; background: var(--bg-3); color: var(--text-md); }
|
||||
.tabs--sm .tabs__tab { font-size: .15rem; padding: .07rem .16rem .09rem; }
|
||||
.tabs__badge { font-family: var(--font-num); font-size: calc(.13 * var(--t)); padding: .01rem .06rem; background: var(--bg-3); color: var(--text-md); }
|
||||
.tabs--sm .tabs__tab { font-size: calc(.15 * var(--t)); padding: .07rem .16rem .09rem; }
|
||||
|
||||
/* ---- spinner -------------------------------------------------------------------------------------- */
|
||||
.spinner { --sp: .36rem; --sc: var(--mint-500); display: inline-flex; align-items: center; gap: .1rem; color: var(--text-lo); font-size: .15rem; }
|
||||
.spinner { --sp: .36rem; --sc: var(--mint-500); display: inline-flex; align-items: center; gap: .1rem; color: var(--text-lo); font-size: calc(.15 * var(--t)); }
|
||||
.spinner svg { width: var(--sp); height: var(--sp); animation: spin 1.6s linear infinite; overflow: visible; }
|
||||
.spinner__track { fill: none; stroke: var(--line-2); stroke-width: 3; }
|
||||
.spinner__arc { fill: none; stroke: var(--sc); stroke-width: 3; stroke-dasharray: 34 98; animation: spinner-dash 1.1s var(--ease-in-out) infinite; filter: drop-shadow(0 0 3px var(--sc)); }
|
||||
@@ -582,7 +582,7 @@
|
||||
.spinner--lg { --sp: .56rem; }
|
||||
.spinner--gold { --sc: var(--gold); }
|
||||
.spinner--amber { --sc: var(--amber); }
|
||||
.spinner__label { font-family: var(--font-display); letter-spacing: .2em; font-size: .12rem; text-transform: uppercase; }
|
||||
.spinner__label { font-family: var(--font-display); letter-spacing: .2em; font-size: calc(.12 * var(--t)); text-transform: uppercase; }
|
||||
@keyframes spinner-dash { 0% { stroke-dashoffset: 0; } 100% { stroke-dashoffset: -132; } }
|
||||
|
||||
/* ---- avatar frame ---------------------------------------------------------------------------------- */
|
||||
@@ -710,9 +710,9 @@
|
||||
animation: pb-sweep 700ms 250ms var(--ease-out) forwards;
|
||||
opacity: 0;
|
||||
}
|
||||
.pbanner__micro { font-family: var(--font-display); font-size: .12rem; letter-spacing: .5em; color: var(--pb); padding-left: .5em; }
|
||||
.pbanner__micro { font-family: var(--font-display); font-size: calc(.12 * var(--t)); letter-spacing: .5em; color: var(--pb); padding-left: .5em; }
|
||||
.pbanner__title {
|
||||
font-size: .56rem;
|
||||
font-size: calc(.56 * var(--t));
|
||||
font-weight: 900;
|
||||
letter-spacing: .16em;
|
||||
padding-left: .16em;
|
||||
@@ -720,7 +720,7 @@
|
||||
color: #fff;
|
||||
text-shadow: 0 0 .22rem var(--pb-glow), 0 2px 0 rgba(0, 0, 0, .5);
|
||||
}
|
||||
.pbanner__sub { font-size: .16rem; color: var(--text-lo); letter-spacing: .12em; }
|
||||
.pbanner__sub { font-size: calc(.16 * var(--t)); color: var(--text-lo); letter-spacing: .12em; }
|
||||
.pbanner__chev { --ch-h: .42rem; height: .42rem; flex: none; }
|
||||
.pbanner--inline .pbanner__band { animation-duration: var(--t-slow); }
|
||||
.pbanner.is-leaving .pbanner__band { animation: pb-close 240ms var(--ease-in-out) forwards; }
|
||||
@@ -783,7 +783,7 @@
|
||||
.rdialog__tick--br { bottom: 8px; right: 8px; border-left: 0; border-top: 0; }
|
||||
.rdialog__micro {
|
||||
font-family: var(--font-display);
|
||||
font-size: .13rem;
|
||||
font-size: calc(.13 * var(--t));
|
||||
letter-spacing: .48em;
|
||||
padding-left: .48em;
|
||||
color: var(--rd);
|
||||
@@ -811,7 +811,7 @@
|
||||
animation: rd-sweep 780ms 300ms var(--ease-out) forwards;
|
||||
}
|
||||
.rdialog__title {
|
||||
font-size: .62rem;
|
||||
font-size: calc(.62 * var(--t));
|
||||
font-weight: 900;
|
||||
letter-spacing: .14em;
|
||||
padding-left: .14em;
|
||||
@@ -821,7 +821,7 @@
|
||||
}
|
||||
.rdialog__chev { --ch-h: .46rem; height: .46rem; flex: none; }
|
||||
.rdialog__sub {
|
||||
font-size: .18rem;
|
||||
font-size: calc(.18 * var(--t));
|
||||
letter-spacing: .1em;
|
||||
color: var(--text-lo);
|
||||
text-align: center;
|
||||
@@ -834,7 +834,7 @@
|
||||
@keyframes rd-sweep { 0% { left: -25%; opacity: 1; } 100% { left: 115%; opacity: 0; } }
|
||||
@media (max-width: 720px) {
|
||||
.rdialog__box { min-width: 0; width: 92vw; padding: .26rem .3rem .22rem; }
|
||||
.rdialog__title { font-size: .42rem; }
|
||||
.rdialog__title { font-size: calc(.42 * var(--t)); }
|
||||
.rdialog__chev { --ch-h: .34rem; height: .34rem; }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
@@ -854,29 +854,29 @@
|
||||
border: 1px solid var(--line);
|
||||
color: var(--pc);
|
||||
font-family: var(--font-num);
|
||||
font-size: .18rem;
|
||||
font-size: calc(.18 * var(--t));
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.ping__icon { width: .15rem; height: .15rem; margin-right: .03rem; }
|
||||
.ping__unit { font-size: .13rem; color: var(--text-lo); font-weight: 500; }
|
||||
.ping__unit { font-size: calc(.13 * var(--t)); color: var(--text-lo); font-weight: 500; }
|
||||
.ping--low { --pc: var(--mint-400); }
|
||||
.ping--medium { --pc: var(--amber); }
|
||||
.ping--high { --pc: var(--red-premium); }
|
||||
.ping--off { --pc: var(--text-dim); }
|
||||
|
||||
/* ---- difficulty tag ---------------------------------------------------------------------------------- */
|
||||
.dtag { --d-color: var(--amber); display: inline-flex; align-items: center; gap: .06rem; color: var(--d-color); font-weight: 700; font-size: .18rem; letter-spacing: .04em; white-space: nowrap; }
|
||||
.dtag { --d-color: var(--amber); display: inline-flex; align-items: center; gap: .06rem; color: var(--d-color); font-weight: 700; font-size: calc(.18 * var(--t)); letter-spacing: .04em; white-space: nowrap; }
|
||||
.dtag__icon { width: 1.15em; height: 1.15em; filter: drop-shadow(0 0 4px var(--d-color)); filter: drop-shadow(0 0 4px color-mix(in srgb, var(--d-color) 65%, transparent)); }
|
||||
.dicon { object-fit: contain; image-rendering: auto; }
|
||||
.dtag__code { font-family: var(--font-num); font-size: .72em; font-weight: 500; color: var(--text-dim); letter-spacing: .1em; margin-left: .03rem; }
|
||||
.dtag--sm { font-size: .15rem; }
|
||||
.dtag--lg { font-size: .24rem; }
|
||||
.dtag--sm { font-size: calc(.15 * var(--t)); }
|
||||
.dtag--lg { font-size: calc(.24 * var(--t)); }
|
||||
|
||||
/* ---- text field ------------------------------------------------------------------------------------ */
|
||||
.field { display: flex; flex-direction: column; gap: .07rem; min-width: 0; }
|
||||
.field__label { display: flex; align-items: baseline; gap: .1rem; font-size: .15rem; color: var(--text-lo); font-weight: 500; letter-spacing: .04em; }
|
||||
.field__label { display: flex; align-items: baseline; gap: .1rem; font-size: calc(.15 * var(--t)); color: var(--text-lo); font-weight: 500; letter-spacing: .04em; }
|
||||
.field__box {
|
||||
--bk-c: var(--line-3);
|
||||
--bk-l: .1rem;
|
||||
@@ -899,15 +899,15 @@
|
||||
border: 0;
|
||||
outline: none;
|
||||
color: var(--text-hi);
|
||||
font-size: .2rem;
|
||||
font-size: calc(.2 * var(--t));
|
||||
letter-spacing: .04em;
|
||||
}
|
||||
.field__input::placeholder { color: var(--text-dim); font-weight: 400; letter-spacing: .04em; }
|
||||
.field__icon { width: .22rem; height: .22rem; color: var(--text-dim); }
|
||||
.field__box:focus-within .field__icon { color: var(--mint-500); }
|
||||
.field__hint { font-size: .13rem; color: var(--text-dim); }
|
||||
.field__hint { font-size: calc(.13 * var(--t)); color: var(--text-dim); }
|
||||
.field--lg .field__box { height: .68rem; }
|
||||
.field--lg .field__input { font-size: .26rem; font-weight: 700; }
|
||||
.field--lg .field__input { font-size: calc(.26 * var(--t)); font-weight: 700; }
|
||||
.field--code .field__box { height: .64rem; }
|
||||
.field--code .field__input { font-family: var(--font-num); font-size: .34rem; font-weight: 700; letter-spacing: .42em; text-transform: uppercase; }
|
||||
.field--code .field__input::placeholder { font-family: var(--font-cjk); font-size: .18rem; letter-spacing: .06em; font-weight: 400; text-transform: none; }
|
||||
@@ -943,14 +943,14 @@
|
||||
border-left: 3px solid var(--tc);
|
||||
box-shadow: 0 .08rem .24rem rgba(0, 0, 0, .55), -.04rem 0 .16rem transparent; box-shadow: 0 .08rem .24rem rgba(0, 0, 0, .55), -.04rem 0 .16rem color-mix(in srgb, var(--tc) 25%, transparent);
|
||||
color: var(--text-hi);
|
||||
font-size: .17rem;
|
||||
font-size: calc(.17 * var(--t));
|
||||
line-height: 1.45;
|
||||
cursor: pointer;
|
||||
animation: toast-in var(--t-med) var(--ease-out);
|
||||
}
|
||||
.toast__icon { width: .2rem; height: .2rem; fill: var(--tc); flex: none; }
|
||||
.toast__text { flex: 1; }
|
||||
.toast__count { font-family: var(--font-num); font-size: .14rem; color: var(--text-lo); }
|
||||
.toast__count { font-family: var(--font-num); font-size: calc(.14 * var(--t)); color: var(--text-lo); }
|
||||
.toast--success { --tc: var(--mint-400); }
|
||||
.toast--warn { --tc: var(--amber); }
|
||||
.toast--error { --tc: var(--red-premium); }
|
||||
@@ -973,7 +973,7 @@
|
||||
border: 1px solid rgba(232, 90, 26, .55);
|
||||
box-shadow: 0 .08rem .3rem rgba(0, 0, 0, .6), 0 0 .2rem rgba(232, 90, 26, .15);
|
||||
color: var(--text-hi);
|
||||
font-size: .16rem;
|
||||
font-size: calc(.16 * var(--t));
|
||||
/* own keyframes: toast-in's transform would drop the translateX(-50%) centring mid-animation */
|
||||
animation: conn-in var(--t-med) var(--ease-out);
|
||||
}
|
||||
@@ -990,11 +990,11 @@
|
||||
.sp-in-match .conn-banner { top: calc(1.34rem + var(--sa-t, 0px)); bottom: auto; }
|
||||
.sp-in-match.sp-conn .toast-host { top: calc(2.0rem + var(--sa-t, 0px)); }
|
||||
.conn-banner .icon { width: .22rem; height: .22rem; color: var(--orange-hi); }
|
||||
.conn-banner__sub { color: var(--text-lo); font-family: var(--font-num); font-size: .15rem; }
|
||||
.conn-banner__sub { color: var(--text-lo); font-family: var(--font-num); font-size: calc(.15 * var(--t)); }
|
||||
|
||||
/* ---- misc shared bits ------------------------------------------------------------------------------ */
|
||||
.kv { display: flex; align-items: baseline; gap: .08rem; }
|
||||
.kv__k { color: var(--text-lo); font-size: .14rem; }
|
||||
.kv__k { color: var(--text-lo); font-size: calc(.14 * var(--t)); }
|
||||
.kv__v { font-family: var(--font-num); font-weight: 700; }
|
||||
.divider { height: 1px; background: linear-gradient(90deg, transparent, var(--line-2) 15%, var(--line-2) 85%, transparent); }
|
||||
.status-dot { width: .09rem; height: .09rem; border-radius: 50%; background: var(--text-dim); flex: none; display: inline-block; }
|
||||
@@ -1028,16 +1028,45 @@
|
||||
}
|
||||
.topbar__left { justify-self: start; display: flex; align-items: center; gap: .18rem; min-width: 0; }
|
||||
.topbar__center { display: flex; flex-direction: column; align-items: center; gap: .03rem; text-align: center; }
|
||||
.topbar__title { display: flex; align-items: center; gap: .18rem; font-size: .3rem; font-weight: 700; letter-spacing: .1em; line-height: 1.2; }
|
||||
.topbar__title { display: flex; align-items: center; gap: .18rem; font-size: calc(.3 * var(--t)); font-weight: 700; letter-spacing: .1em; line-height: 1.2; }
|
||||
.topbar__sep { width: 1px; height: .28rem; background: var(--line-2); }
|
||||
/* the lobby's ⚙ / 📖 / install squares: a size up from `sm` and spaced apart; on touch screens at least 40 px each with
|
||||
a 10 px gap, so their 44 px hit areas (css/devices.css) never overlap and a tap lands on the intended one */
|
||||
.topbar__tools { display: flex; align-items: center; gap: .14rem; }
|
||||
.topbar__tools .btn--square.btn--sm { --sq: .46rem; height: var(--sq); min-width: var(--sq); }
|
||||
.sp-coarse .topbar__tools { gap: max(.16rem, 10px); }
|
||||
.sp-coarse .topbar__tools .btn--square.btn--sm { --sq: max(.56rem, 40px); }
|
||||
.topbar__right { justify-self: end; display: flex; align-items: center; gap: .16rem; min-width: 0; }
|
||||
|
||||
.section-label { display: flex; align-items: baseline; gap: .12rem; margin: 0 0 .14rem; font-size: .2rem; font-weight: 700; letter-spacing: .08em; }
|
||||
.section-label__idx { font-size: .15rem; color: var(--mint-500); padding: 0 .06rem; border: 1px solid var(--mint-800); line-height: 1.35; letter-spacing: .06em; }
|
||||
/* Optional resume/install controls and translated XL titles must share actual space instead of overlapping the
|
||||
centered 1fr/auto/1fr title. On narrow displays the title has its own row; the controls stay in their two groups. */
|
||||
.lobby-screen .topbar { grid-template-columns: auto minmax(0, 1fr) auto; height: auto; min-height: 1.1rem; padding-block: .12rem; }
|
||||
.lobby-screen .topbar__center { min-width: 0; }
|
||||
.lobby-screen .topbar__center .micro { max-width: 100%; overflow: hidden; text-overflow: ellipsis; }
|
||||
.lobby-screen .topbar__title { justify-content: center; overflow-wrap: anywhere; }
|
||||
@media (max-width: 1000px) {
|
||||
.lobby-screen .topbar { grid-template-columns: minmax(0, 1fr) auto; gap: .1rem .2rem; }
|
||||
.lobby-screen .topbar__center { grid-column: 1 / -1; grid-row: 2; }
|
||||
.lobby-screen .topbar__center .micro { display: none; }
|
||||
.lobby-screen .topbar__right { grid-column: 2; grid-row: 1; }
|
||||
.lobby-screen .me-chip__name { max-width: 1.5rem; }
|
||||
}
|
||||
|
||||
.section-label { display: flex; align-items: baseline; gap: .12rem; margin: 0 0 .14rem; font-size: calc(.2 * var(--t)); font-weight: 700; letter-spacing: .08em; }
|
||||
.section-label__idx { font-size: calc(.15 * var(--t)); color: var(--mint-500); padding: 0 .06rem; border: 1px solid var(--mint-800); line-height: 1.35; letter-spacing: .06em; }
|
||||
|
||||
.me-chip { display: flex; align-items: center; gap: .12rem; padding: .06rem .18rem .06rem .06rem; border: 1px solid var(--line); background: rgba(0, 0, 0, .35); }
|
||||
.me-chip__text { display: flex; flex-direction: column; line-height: 1.3; min-width: 0; }
|
||||
.me-chip__name { font-weight: 700; font-size: .18rem; max-width: 2.4rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.me-chip__name { font-weight: 700; font-size: calc(.18 * var(--t)); max-width: 2.4rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
/* the lobby's chip doubles as the 统计数据 entry */
|
||||
.me-chip--btn {
|
||||
font: inherit; color: inherit; text-align: left; cursor: pointer;
|
||||
transition: border-color var(--t-fast) var(--ease-out), background-color var(--t-fast) var(--ease-out);
|
||||
}
|
||||
.me-chip--btn:hover { border-color: var(--mint-700); background: rgba(34, 42, 38, .6); }
|
||||
.me-chip--btn:active { transform: translateY(1px); }
|
||||
.me-chip__stats { flex: none; width: .2rem; height: .2rem; margin-left: .04rem; color: var(--mint-500); opacity: .7; }
|
||||
.me-chip--btn:hover .me-chip__stats { opacity: 1; }
|
||||
|
||||
.crash { align-items: center; justify-content: center; }
|
||||
.crash__box {
|
||||
@@ -1051,4 +1080,4 @@
|
||||
border: 1px solid var(--line-2);
|
||||
--bk-c: var(--red-premium);
|
||||
}
|
||||
.crash__box h2 { font-size: .3rem; }
|
||||
.crash__box h2 { font-size: calc(.3 * var(--t)); }
|
||||
@@ -146,10 +146,13 @@ html:not(.sp-in-match) .toast-host { margin-top: var(--sa-t); }
|
||||
.mode-cards { margin-bottom: .2rem; }
|
||||
.mode-card { min-height: 0; gap: .12rem; padding: .22rem .26rem .2rem; }
|
||||
.mode-card__icon { width: .66rem; height: .66rem; }
|
||||
.mode-card__name { font-size: .4rem; }
|
||||
.mode-card__name { font-size: calc(.4 * var(--t)); }
|
||||
.mode-card__points, .diff-card__effects, .tips, .create-box__hint { display: none; }
|
||||
.diff-list { gap: .1rem; }
|
||||
.diff-card { padding-top: .12rem; padding-bottom: .12rem; }
|
||||
.create-box { margin-top: .18rem; }
|
||||
.create-box .btn--xl { height: .8rem; }
|
||||
}
|
||||
|
||||
.rotate-hint__pwa { pointer-events: auto; margin-top: 6px; }
|
||||
.rotate-hint__pwa[hidden] { display: none; }
|
||||
@@ -23,18 +23,19 @@
|
||||
.stephead__left { display: flex; align-items: center; gap: .16rem; }
|
||||
.stephead__meta { display: flex; flex-direction: column; gap: .06rem; align-items: flex-start; }
|
||||
.stephead__center { display: flex; flex-direction: column; align-items: center; gap: .04rem; }
|
||||
.stephead__title { display: flex; align-items: baseline; gap: .02rem; font-size: .34rem; font-weight: 900; letter-spacing: .12em; }
|
||||
.stephead__title b { font-size: .56rem; line-height: 1; color: var(--mint-400); text-shadow: 0 0 .2rem rgba(23, 249, 183, .4); }
|
||||
.stephead__of { font-size: .26rem; color: var(--text-dim); margin-right: .18rem; }
|
||||
.stephead__title { display: flex; align-items: baseline; gap: .02rem; font-size: calc(.34 * var(--t)); font-weight: 900; letter-spacing: .12em; }
|
||||
.stephead__title b { font-size: calc(.56 * var(--t)); line-height: 1; color: var(--mint-400); text-shadow: 0 0 .2rem rgba(23, 249, 183, .4); }
|
||||
.stephead__of { font-size: calc(.26 * var(--t)); color: var(--text-dim); margin-right: .18rem; }
|
||||
.stephead__right { justify-self: end; display: flex; align-items: center; gap: .24rem; }
|
||||
.stephead__right .countdown { --cd-h: .64rem; }
|
||||
|
||||
.brief-h { display: flex; align-items: baseline; gap: .12rem; font-size: .21rem; font-weight: 700; letter-spacing: .1em; margin-bottom: .12rem; }
|
||||
.brief-h { display: flex; align-items: baseline; gap: .12rem; font-size: calc(.21 * var(--t)); font-weight: 700; letter-spacing: .1em; margin-bottom: .12rem; }
|
||||
.brief-h::before { content: ''; width: .06rem; height: .2rem; background: var(--mint-500); align-self: center; }
|
||||
|
||||
/* ---- briefing layout ---- */
|
||||
.brief__main { position: relative; z-index: 1; flex: 1; min-height: 0; display: grid; grid-template-columns: 6.2rem 1fr; gap: .36rem; padding: .3rem .5rem .1rem; }
|
||||
.brief__left { display: flex; flex-direction: column; gap: .2rem; min-height: 0; }
|
||||
.brief__left { display: flex; flex-direction: column; gap: .2rem; min-height: 0; overflow: auto; padding-right: .06rem; }
|
||||
.brief__left > * { flex-shrink: 0; }
|
||||
.brief__right { display: flex; flex-direction: column; gap: .2rem; min-height: 0; overflow: auto; padding-right: .06rem; }
|
||||
|
||||
.brief-boss {
|
||||
@@ -54,14 +55,14 @@
|
||||
background: repeating-linear-gradient(0deg, rgba(0, 0, 0, .22) 0 2px, transparent 2px 4px), radial-gradient(circle, transparent 45%, rgba(40, 8, 2, .75) 100%); }
|
||||
.brief-boss__q { font-size: 1rem; font-weight: 900; color: rgba(255, 110, 40, .6); }
|
||||
.brief-boss__text { display: flex; flex-direction: column; gap: .08rem; padding-top: .2rem; min-width: 0; }
|
||||
.brief-boss__name { font-size: .34rem; font-weight: 900; letter-spacing: .04em; line-height: 1.25; }
|
||||
.brief-boss__when { font-size: .15rem; color: var(--orange-hi); }
|
||||
.brief-boss__abil { margin: .04rem 0 0; padding-left: .18rem; display: flex; flex-direction: column; gap: .04rem; font-size: .14rem; line-height: 1.6; color: var(--text-md); }
|
||||
.brief-boss__name { font-size: calc(.34 * var(--t)); font-weight: 900; letter-spacing: .04em; line-height: 1.25; }
|
||||
.brief-boss__when { font-size: calc(.15 * var(--t)); color: var(--orange-hi); }
|
||||
.brief-boss__abil { margin: .04rem 0 0; padding-left: .18rem; display: flex; flex-direction: column; gap: .04rem; font-size: calc(.14 * var(--t)); line-height: 1.6; color: var(--text-md); }
|
||||
|
||||
.brief-stage { display: flex; align-items: center; justify-content: space-between; gap: .2rem; padding: .12rem .18rem; background: rgba(12, 15, 14, .8); border: 1px solid var(--line); }
|
||||
.brief-stage__k { display: flex; align-items: baseline; gap: .12rem; min-width: 0; }
|
||||
.brief-stage__pool { font-size: .14rem; color: var(--text-lo); white-space: nowrap; }
|
||||
.brief-stage__name { display: flex; align-items: center; gap: .08rem; font-size: .19rem; font-weight: 700; }
|
||||
.brief-stage__pool { font-size: calc(.14 * var(--t)); color: var(--text-lo); white-space: nowrap; }
|
||||
.brief-stage__name { display: flex; align-items: center; gap: .08rem; font-size: calc(.19 * var(--t)); font-weight: 700; }
|
||||
.brief-stage__name .icon { width: .2rem; height: .2rem; color: var(--mint-500); }
|
||||
|
||||
.brief-factions { display: flex; flex-direction: column; gap: .1rem; min-height: 0; }
|
||||
@@ -69,38 +70,57 @@
|
||||
.brief-faction__icon { width: .72rem; height: .72rem; flex: none; display: grid; place-items: center; background: radial-gradient(circle, rgba(246, 163, 41, .16), transparent 70%); }
|
||||
.brief-faction__icon img { width: 100%; height: 100%; object-fit: contain; }
|
||||
.brief-faction__text { display: flex; flex-direction: column; gap: .03rem; }
|
||||
.brief-faction__text b { font-size: .2rem; color: var(--amber); }
|
||||
.brief-faction__text span { font-size: .15rem; color: var(--text-lo); }
|
||||
.brief-faction__text b { font-size: calc(.2 * var(--t)); color: var(--amber); }
|
||||
.brief-faction__text span { font-size: calc(.15 * var(--t)); color: var(--text-lo); }
|
||||
|
||||
.brief-bonds { padding: .16rem .2rem .2rem; background: rgba(12, 15, 14, .8); border: 1px solid var(--line); }
|
||||
.brief-bonds__row { display: flex; flex-wrap: wrap; gap: .16rem .1rem; }
|
||||
.brief-bond { position: relative; width: 1rem; display: flex; justify-content: center; }
|
||||
.brief-bond .bond { --disc: .66rem; }
|
||||
.brief-bond .bond__name { font-size: .16rem; }
|
||||
.brief-bond__ban { position: absolute; top: -.04rem; right: .08rem; display: inline-flex; align-items: center; gap: .01rem; font-size: .12rem; font-weight: 700; padding: 0 .05rem; background: var(--red); color: #fff; line-height: 1.5; box-shadow: 0 0 0 1px #000; }
|
||||
.brief-bond { position: relative; min-width: 1rem; width: max-content; max-width: 100%; display: flex; justify-content: center; }
|
||||
.brief-bond .bond { --disc: .66rem; max-width: 100%; }
|
||||
.brief-bond .bond__name { font-size: calc(.16 * var(--t)); max-width: 100%; white-space: normal; overflow-wrap: anywhere; text-align: center; }
|
||||
.brief-bond__ban { position: absolute; top: -.04rem; right: .08rem; display: inline-flex; align-items: center; gap: .01rem; font-size: calc(.12 * var(--t)); font-weight: 700; padding: 0 .05rem; background: var(--red); color: #fff; line-height: 1.5; box-shadow: 0 0 0 1px #000; }
|
||||
.brief-bond__ban .icon { width: .12rem; height: .12rem; }
|
||||
.brief-bond.is-partial .bond__core { box-shadow: 0 0 0 2px rgba(255, 84, 84, .5); }
|
||||
.brief-legend { display: flex; align-items: center; gap: .08rem; flex-wrap: wrap; font-size: .14rem; color: var(--text-dim); margin-top: -.06rem; }
|
||||
.brief-legend { display: flex; align-items: center; gap: .08rem; flex-wrap: wrap; font-size: calc(.14 * var(--t)); color: var(--text-dim); margin-top: -.06rem; }
|
||||
.brief-legend__off { width: .16rem; height: .16rem; border-radius: 50%; background: #3a403d; border: 2px solid #555; }
|
||||
.brief-legend__ban { display: inline-grid; place-items: center; width: .2rem; height: .18rem; background: var(--red); color: #fff; }
|
||||
.brief-legend__ban .icon { width: .12rem; height: .12rem; }
|
||||
|
||||
.brief-banned { padding: .16rem .2rem .2rem; background: rgba(12, 15, 14, .8); border: 1px solid var(--line); }
|
||||
.brief-banned__n { margin-left: auto; font-size: .22rem; color: var(--red-premium); }
|
||||
.brief-banned__n { margin-left: auto; font-size: calc(.22 * var(--t)); color: var(--red-premium); }
|
||||
.brief-banned__grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(.6rem, 1fr)); gap: .08rem; }
|
||||
.brief-banned__grid .uthumb { --ut: .6rem; }
|
||||
/* 0.2.0: the player's own 自选 pieces out of the shop this match (ui/matchInfo.js DiyBannedLine; m.private.diyBanned) */
|
||||
.brief-banned__diy { margin: .1rem 0 0; font-size: .14rem; line-height: 1.5; color: var(--text-dim, #b8c2bf); }
|
||||
.diybanned__tag { display: inline-block; margin-right: .06rem; padding: .01rem .06rem; font-size: .12rem; font-weight: 700;
|
||||
.brief-banned__diy { margin: .1rem 0 0; font-size: calc(.14 * var(--t)); line-height: 1.5; color: var(--text-dim, #b8c2bf); }
|
||||
.diybanned__tag { display: inline-block; margin-right: .06rem; padding: .01rem .06rem; font-size: calc(.12 * var(--t)); font-weight: 700;
|
||||
letter-spacing: .08em; background: var(--mint-500); color: var(--text-on-mint); vertical-align: .01rem; }
|
||||
|
||||
.brief__foot { position: relative; z-index: 2; flex: none; display: flex; align-items: center; justify-content: flex-end; gap: .4rem; padding: .18rem .5rem .3rem; }
|
||||
.brief-ready { display: flex; align-items: center; gap: .16rem; }
|
||||
.brief-ready__txt { font-size: .2rem; color: var(--text-lo); }
|
||||
.brief-ready__txt b { font-size: .4rem; color: var(--mint-400); margin: 0 .02rem; }
|
||||
.brief-ready__txt span { font-size: .24rem; color: var(--text-dim); }
|
||||
.brief-ready__txt { font-size: calc(.2 * var(--t)); color: var(--text-lo); }
|
||||
.brief-ready__txt b { font-size: calc(.4 * var(--t)); color: var(--mint-400); margin: 0 .02rem; }
|
||||
.brief-ready__txt span { font-size: calc(.24 * var(--t)); color: var(--text-dim); }
|
||||
.brief-ready__pips { display: flex; gap: .06rem; }
|
||||
.brief-ready__pips i { width: .3rem; height: .3rem; display: grid; place-items: center; color: var(--text-dim); }
|
||||
.brief-ready__pips i .icon { width: 100%; height: 100%; }
|
||||
.brief-ready__pips i.on { color: var(--mint-400); filter: drop-shadow(0 0 .06rem rgba(23, 249, 183, .6)); }
|
||||
.brief-ready__pips i.me { border-bottom: 2px solid var(--mint-700); }
|
||||
|
||||
/* Full setup reroll: consent stays visible alongside the information being confirmed. */
|
||||
.brief-reroll {
|
||||
position: relative; z-index: 2; flex: none;
|
||||
display: flex; align-items: center; justify-content: space-between; gap: .2rem;
|
||||
margin: .1rem .5rem 0; padding: .14rem .2rem;
|
||||
background: rgba(12, 15, 14, .9); border: 1px solid var(--mint-700);
|
||||
}
|
||||
.brief-reroll__text { display: flex; flex-direction: column; gap: .05rem; min-width: 0; font-size: calc(.16 * var(--t)); }
|
||||
.brief-reroll__text strong { font-size: calc(.2 * var(--t)); color: var(--mint-400); }
|
||||
.brief-reroll__text > span { color: var(--text-lo); }
|
||||
.brief-reroll__players { display: flex; flex-wrap: wrap; gap: .08rem .2rem; color: var(--text-dim); }
|
||||
.brief-reroll__players .is-agreed { color: var(--mint-400); }
|
||||
.brief-reroll__actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: .1rem; flex-shrink: 0; }
|
||||
@media (max-width: 900px) {
|
||||
.brief-reroll { margin-inline: .3rem; gap: .1rem; padding: .1rem .14rem; }
|
||||
.brief-reroll__actions { flex-shrink: 1; }
|
||||
.brief-reroll__actions .btn { min-height: .44rem; padding-inline: .12rem; }
|
||||
}
|
||||
@@ -13,11 +13,11 @@
|
||||
transition: border-color var(--t-med), background var(--t-med);
|
||||
}
|
||||
.dorder .pavatar { --pa: .66rem; }
|
||||
.dorder__idx { font-size: .22rem; color: var(--text-dim); width: .26rem; text-align: center; }
|
||||
.dorder__idx { font-size: calc(.22 * var(--t)); color: var(--text-dim); width: .26rem; text-align: center; }
|
||||
.dorder__text { display: flex; flex-direction: column; gap: .02rem; min-width: 0; }
|
||||
.dorder__name { font-size: .18rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; display: flex; align-items: center; gap: .06rem; }
|
||||
.dorder__ai { font-family: var(--font-num); font-size: .11rem; padding: 0 .04rem; background: #a9c3d8; color: #0b1520; }
|
||||
.dorder__state { font-size: .14rem; }
|
||||
.dorder__name { font-size: calc(.18 * var(--t)); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; display: flex; align-items: center; gap: .06rem; }
|
||||
.dorder__ai { font-family: var(--font-num); font-size: calc(.11 * var(--t)); padding: 0 .04rem; background: #a9c3d8; color: #0b1520; }
|
||||
.dorder__state { font-size: calc(.14 * var(--t)); }
|
||||
.dorder__state span { display: inline-flex; align-items: center; gap: .04rem; }
|
||||
.dorder__state .icon { width: .16rem; height: .16rem; }
|
||||
.dorder__box { position: relative; width: .6rem; height: .6rem; display: grid; place-items: center; border: 1px dashed var(--line-2); }
|
||||
@@ -28,7 +28,7 @@
|
||||
.dorder.is-cur { border-color: var(--gold); background: linear-gradient(90deg, rgba(255, 198, 0, .12), rgba(12, 15, 14, .82)); box-shadow: 0 0 .2rem rgba(255, 198, 0, .15); }
|
||||
.dorder.is-done { border-color: var(--mint-800); }
|
||||
.dorder.is-self .dorder__name { color: var(--mint-400); }
|
||||
.draft-order__tip { margin-top: auto; font-size: .13rem; line-height: 1.6; color: var(--text-dim); }
|
||||
.draft-order__tip { margin-top: auto; font-size: calc(.13 * var(--t)); line-height: 1.6; color: var(--text-dim); }
|
||||
|
||||
.draft-grid {
|
||||
display: grid; grid-template-columns: repeat(auto-fill, minmax(1.36rem, 1fr)); grid-auto-rows: min-content; gap: .12rem;
|
||||
@@ -41,13 +41,13 @@
|
||||
}
|
||||
.dband:hover { transform: translateY(-.03rem); border-color: var(--line-3); }
|
||||
.dband .bandicon { --bi: 1.1rem; border: 1px solid var(--line-2); }
|
||||
.dband__name { font-size: .16rem; font-weight: 700; color: var(--text-hi); white-space: nowrap; }
|
||||
.dband__lp { display: inline-flex; align-items: center; gap: .04rem; font-size: .15rem; color: var(--mint-400); }
|
||||
.dband__name { font-size: calc(.16 * var(--t)); font-weight: 700; color: var(--text-hi); white-space: nowrap; }
|
||||
.dband__lp { display: inline-flex; align-items: center; gap: .04rem; font-size: calc(.15 * var(--t)); color: var(--mint-400); }
|
||||
.dband__lp i { width: .12rem; height: .14rem; background: var(--mint-500); clip-path: polygon(0 0, 30% 0, 30% 20%, 45% 20%, 45% 0, 55% 0, 55% 20%, 70% 20%, 70% 0, 100% 0, 100% 35%, 85% 50%, 85% 80%, 100% 90%, 100% 100%, 0 100%, 0 90%, 15% 80%, 15% 50%, 0 35%); }
|
||||
.dband__who { position: absolute; right: .04rem; top: .04rem; display: flex; flex-direction: column; gap: .02rem; }
|
||||
.dband__who .pavatar { --pa: .3rem; border-radius: 50%; overflow: hidden; border: 2px solid var(--mint-400); }
|
||||
.dband.is-sel { border-color: var(--mint-400); background: linear-gradient(180deg, rgba(78, 216, 175, .18), rgba(12, 15, 14, .85)); box-shadow: 0 0 0 1px var(--mint-400), 0 0 .24rem rgba(23, 249, 183, .2); }
|
||||
.dband.is-mine::after { content: '已选择'; position: absolute; left: 0; top: 0; font-size: .11rem; font-weight: 700; padding: 0 .06rem; background: var(--mint-500); color: var(--text-on-mint); }
|
||||
.dband.is-mine::after { content: '已选择'; position: absolute; left: 0; top: 0; font-size: calc(.11 * var(--t)); font-weight: 700; padding: 0 .06rem; background: var(--mint-500); color: var(--text-on-mint); }
|
||||
|
||||
.draft-detail {
|
||||
display: flex; flex-direction: column; align-items: center; gap: .12rem; padding: .26rem .26rem .22rem; min-height: 0;
|
||||
@@ -55,33 +55,33 @@
|
||||
}
|
||||
.draft-detail__art { position: relative; padding: .06rem; border: 1px solid var(--mint-800); background: radial-gradient(circle, rgba(78, 216, 175, .14), transparent 70%); }
|
||||
.draft-detail__art .bandicon { --bi: 2rem; }
|
||||
.draft-detail__hp { display: flex; align-items: center; gap: .12rem; font-size: .16rem; color: var(--text-lo); }
|
||||
.draft-detail__name { font-size: .42rem; font-weight: 900; letter-spacing: .1em; }
|
||||
.draft-detail__hp { display: flex; align-items: center; gap: .12rem; font-size: calc(.16 * var(--t)); color: var(--text-lo); }
|
||||
.draft-detail__name { font-size: calc(.42 * var(--t)); font-weight: 900; letter-spacing: .1em; }
|
||||
.draft-detail__eff { align-self: stretch; display: flex; flex-direction: column; gap: .06rem; padding: .12rem .14rem; background: rgba(0, 0, 0, .3); border-left: 3px solid var(--mint-500); flex: 1; min-height: 0; overflow: auto; }
|
||||
.draft-detail__eff b { font-size: .2rem; color: var(--mint-400); }
|
||||
.draft-detail__desc { font-size: .16rem; line-height: 1.7; color: var(--text-md); }
|
||||
.draft-detail__eff b { font-size: calc(.2 * var(--t)); color: var(--mint-400); }
|
||||
.draft-detail__desc { font-size: calc(.16 * var(--t)); line-height: 1.7; color: var(--text-md); }
|
||||
.draft-detail__actions { align-self: stretch; display: flex; flex-direction: column; gap: .1rem; }
|
||||
.draft-detail__status { display: flex; align-items: center; gap: .06rem; font-size: .15rem; color: var(--text-lo); }
|
||||
.draft-detail__status { display: flex; align-items: center; gap: .06rem; font-size: calc(.15 * var(--t)); color: var(--text-lo); }
|
||||
.draft-detail__status .icon { width: .18rem; height: .18rem; }
|
||||
.draft-detail__btns { display: flex; gap: .1rem; }
|
||||
.draft-detail__btns .btn { flex: 1; }
|
||||
.dorder__secs { margin-left: .06rem; font-size: .15rem; color: var(--gold); }
|
||||
.dorder__secs { margin-left: .06rem; font-size: calc(.15 * var(--t)); color: var(--gold); }
|
||||
/* 队友已选 (research 09 §5): a strategy a teammate picked cannot be chosen again */
|
||||
.dband.is-taken { cursor: not-allowed; border-color: rgba(246, 163, 41, .35); }
|
||||
.dband.is-taken .bandicon, .dband.is-taken .dband__name, .dband.is-taken .dband__lp { filter: grayscale(.8) brightness(.62); }
|
||||
.dband.is-taken:hover { transform: none; }
|
||||
.dband__taken {
|
||||
position: absolute; left: 50%; top: 42%; transform: translate(-50%, -50%) rotate(-8deg); white-space: nowrap; pointer-events: none;
|
||||
padding: .02rem .12rem; font-size: .15rem; font-weight: 900; letter-spacing: .16em; color: #1d1400; background: var(--amber);
|
||||
padding: .02rem .12rem; font-size: calc(.15 * var(--t)); font-weight: 900; letter-spacing: .16em; color: #1d1400; background: var(--amber);
|
||||
box-shadow: 0 .03rem .1rem rgba(0, 0, 0, .6);
|
||||
}
|
||||
.draft-detail__status--taken { color: var(--amber); }
|
||||
/* a strategy built around a bond this mode switches off (bandDraft.js BandOffTag / BandOffNote, DESIGN §21.26 — the
|
||||
§21.7 本局禁用 look: dim, dashed, the bond struck through): information only, it stays selectable. Px floors keep it
|
||||
readable on phones (1rem = 40 px). */
|
||||
.dband__off { font-size: max(.12rem, 8px); line-height: 1.5; padding: 0 .05rem; color: var(--text-dim); border: 1px dashed rgba(255, 255, 255, .28); white-space: nowrap; }
|
||||
.dband__off { font-size: max(calc(.12 * var(--t)), 8px); line-height: 1.5; padding: 0 .05rem; color: var(--text-dim); border: 1px dashed rgba(255, 255, 255, .28); white-space: nowrap; }
|
||||
.draft-detail__off { align-self: stretch; display: flex; align-items: flex-start; gap: .06rem; margin: 0; padding: .06rem .1rem;
|
||||
font-size: max(.14rem, 9px); line-height: 1.5; color: var(--text-lo); background: rgba(255, 255, 255, .03); border-left: 2px solid rgba(255, 255, 255, .28); }
|
||||
font-size: max(calc(.14 * var(--t)), 9px); line-height: 1.5; color: var(--text-lo); background: rgba(255, 255, 255, .03); border-left: 2px solid rgba(255, 255, 255, .28); }
|
||||
.draft-detail__off .icon { width: max(.16rem, 10px); height: max(.16rem, 10px); flex: none; margin-top: .02rem; }
|
||||
.draft-detail__offname { color: var(--text-dim); text-decoration: line-through; text-decoration-color: rgba(255, 84, 84, .6); }
|
||||
|
||||
@@ -92,7 +92,7 @@
|
||||
footer (the turn's countdown, 关闭) stay in view. */
|
||||
.draft-order__info {
|
||||
margin-top: .04rem; justify-content: flex-start; padding: 0 .16rem;
|
||||
height: max(.5rem, 28px); font-size: max(.18rem, 10px);
|
||||
height: max(.5rem, 28px); font-size: max(calc(.18 * var(--t)), 10px);
|
||||
--btn-bd: var(--mint-800); --btn-bg: rgba(78, 216, 175, .06); --btn-bg-hover: rgba(78, 216, 175, .14);
|
||||
}
|
||||
.draft-order__info .btn__icon { color: var(--mint-400); }
|
||||
|
||||
@@ -44,7 +44,7 @@
|
||||
|
||||
.bglyph { display: inline-grid; place-items: center; width: .24rem; height: .24rem; flex: none; }
|
||||
.bglyph img { width: 100%; height: 100%; object-fit: contain; filter: brightness(0) invert(.86) sepia(.4) hue-rotate(110deg) saturate(2.5); }
|
||||
.bglyph__txt { font-weight: 900; font-size: .15rem; color: var(--mint-400); }
|
||||
.bglyph__txt { font-weight: 900; font-size: calc(.15 * var(--t)); color: var(--mint-400); }
|
||||
.bandicon { --bi: .6rem; display: inline-grid; place-items: center; width: var(--bi); height: var(--bi); flex: none; overflow: hidden; background: #111513; }
|
||||
.bandicon img { width: 100%; height: 100%; object-fit: cover; }
|
||||
.bandicon__txt { font-weight: 900; font-size: calc(var(--bi) * .44); color: var(--mint-400); }
|
||||
@@ -83,23 +83,23 @@
|
||||
.dhead__icon--enemy.is-elite { border-color: var(--gold-2); }
|
||||
.dhead__info { display: flex; flex-direction: column; gap: .05rem; min-width: 0; padding-top: .02rem; flex: 1; }
|
||||
.dhead__chips { display: flex; align-items: center; gap: .08rem; flex-wrap: wrap; }
|
||||
.dhead__name { font-size: .32rem; font-weight: 900; letter-spacing: .04em; line-height: 1.2; }
|
||||
.dhead__en { font-family: var(--font-display); font-size: .12rem; letter-spacing: .2em; color: var(--text-dim); text-transform: uppercase; }
|
||||
.dhead__name { font-size: calc(.32 * var(--t)); font-weight: 900; letter-spacing: .04em; line-height: 1.2; }
|
||||
.dhead__en { font-family: var(--font-display); font-size: calc(.12 * var(--t)); letter-spacing: .2em; color: var(--text-dim); text-transform: uppercase; }
|
||||
/* 0.2.0 补位: the replaced operator under a stand-in's name (「银灰的替补」, ui/detailPanel.js ChessDetail) */
|
||||
.dhead__for { display: block; font-size: max(.13rem, 9px); letter-spacing: .04em; color: var(--ice); }
|
||||
.dhead__flavor { font-size: .13rem; color: var(--text-lo); font-style: italic; }
|
||||
.dhead__class { display: flex; align-items: center; gap: .06rem; flex-wrap: wrap; font-size: .15rem; color: var(--text-md); margin-top: .06rem; }
|
||||
.dhead__for { display: block; font-size: max(calc(.13 * var(--t)), 9px); letter-spacing: .04em; color: var(--ice); }
|
||||
.dhead__flavor { font-size: calc(.13 * var(--t)); color: var(--text-lo); font-style: italic; }
|
||||
.dhead__class { display: flex; align-items: center; gap: .06rem; flex-wrap: wrap; font-size: calc(.15 * var(--t)); color: var(--text-md); margin-top: .06rem; }
|
||||
.dhead__class .sep { width: 1px; height: .16rem; background: var(--line-2); }
|
||||
.dhead__prof, .dhead__sub { width: .24rem; height: .24rem; object-fit: contain; }
|
||||
.dhead__pos { font-size: .12rem; padding: 0 .06rem; border: 1px solid var(--line-2); color: var(--text-lo); }
|
||||
.dtag-elite, .dtag-token, .dtag-kind { font-size: .13rem; font-weight: 700; padding: .01rem .08rem; letter-spacing: .08em; }
|
||||
.dhead__pos { font-size: calc(.12 * var(--t)); padding: 0 .06rem; border: 1px solid var(--line-2); color: var(--text-lo); }
|
||||
.dtag-elite, .dtag-token, .dtag-kind { font-size: calc(.13 * var(--t)); font-weight: 700; padding: .01rem .08rem; letter-spacing: .08em; }
|
||||
.dtag-elite { background: var(--gold); color: #1d1400; }
|
||||
.dtag-token { background: #a9c3d8; color: #0b1520; }
|
||||
/* 0.2.0 自选编队 / 补位: the card of a 自选 piece, of a chess fielded as its stand-in (ui/detailPanel.js ChessDetail) */
|
||||
.dtag-diy { font-size: .13rem; font-weight: 700; padding: .01rem .08rem; letter-spacing: .08em; background: var(--mint-500); color: var(--text-on-mint); }
|
||||
.dtag-standin { font-size: .13rem; font-weight: 700; padding: .01rem .08rem; letter-spacing: .08em; background: var(--ice); color: var(--ice-900); }
|
||||
.dtag-diy { font-size: calc(.13 * var(--t)); font-weight: 700; padding: .01rem .08rem; letter-spacing: .08em; background: var(--mint-500); color: var(--text-on-mint); }
|
||||
.dtag-standin { font-size: calc(.13 * var(--t)); font-weight: 700; padding: .01rem .08rem; letter-spacing: .08em; background: var(--ice); color: var(--ice-900); }
|
||||
.dtag-kind { border: 1px solid var(--line-2); color: var(--text-lo); font-weight: 500; }
|
||||
.drank { font-size: .13rem; font-weight: 700; padding: .01rem .08rem; }
|
||||
.drank { font-size: calc(.13 * var(--t)); font-weight: 700; padding: .01rem .08rem; }
|
||||
.drank--normal { background: var(--bg-4); color: var(--text-md); }
|
||||
.drank--elite { background: var(--gold-2); color: #1d1400; }
|
||||
.drank--boss { background: var(--orange); color: #fff; }
|
||||
@@ -107,7 +107,7 @@
|
||||
.dhp i { position: absolute; inset: 0 auto 0 0; background: linear-gradient(90deg, #2bb65a, #56e08a); }
|
||||
.dhp--enemy i { background: linear-gradient(90deg, #b5261a, #ff5a4a); }
|
||||
.dhp span { position: absolute; inset: 0; display: grid; place-items: center; font-size: .12rem; color: #fff; text-shadow: 0 1px 2px #000; }
|
||||
.dtrait { display: flex; gap: .08rem; align-items: flex-start; font-size: .14rem; line-height: 1.6; color: var(--text-md); padding: .08rem .1rem; background: rgba(255, 255, 255, .03); border-left: 2px solid var(--line-3); }
|
||||
.dtrait { display: flex; gap: .08rem; align-items: flex-start; font-size: calc(.14 * var(--t)); line-height: 1.6; color: var(--text-md); padding: .08rem .1rem; background: rgba(255, 255, 255, .03); border-left: 2px solid var(--line-3); }
|
||||
.dtrait .icon { width: .16rem; height: .16rem; margin-top: .03rem; color: var(--text-lo); flex: none; }
|
||||
.dstats-wrap { display: flex; gap: .12rem; align-items: stretch; }
|
||||
/* compact 4 × 2 grid (label over value): the skill stays close under the 特质 block (user playtest #3 item 8) */
|
||||
@@ -115,10 +115,10 @@
|
||||
.dstats { flex: none; display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 1px; background: var(--line); border: 1px solid var(--line); }
|
||||
.dstats-wrap > .dstats { flex: 1; }
|
||||
.dstat { display: flex; flex-direction: column; justify-content: center; gap: .01rem; min-width: 0; padding: .04rem .07rem; background: #0f1311; }
|
||||
.dstat__k { font-size: .12rem; color: var(--text-lo); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.dstat__k { font-size: calc(.12 * var(--t)); color: var(--text-lo); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.dstat__row { display: flex; align-items: baseline; min-width: 0; white-space: nowrap; }
|
||||
.dstat__v { font-size: .17rem; line-height: 1.15; color: #fff; }
|
||||
.dstat small { font-size: .11rem; color: var(--text-dim); margin-left: .04rem; }
|
||||
.dstat__v { font-size: calc(.17 * var(--t)); line-height: 1.15; color: #fff; }
|
||||
.dstat small { font-size: calc(.11 * var(--t)); color: var(--text-dim); margin-left: .04rem; }
|
||||
/* live stats (user playtest #4 item 7): buffed values green, debuffed red (the official card), the difference beside
|
||||
them; a 实时 (battle) / 开战时 (prep preview) tag over the grid's right edge */
|
||||
.dstats.is-live, .dstats-wrap.is-live { margin-top: .14rem; }
|
||||
@@ -128,7 +128,7 @@
|
||||
.dstat.is-down .dstat__v { color: var(--red-premium); text-shadow: 0 0 .08rem rgba(255, 84, 84, .3); }
|
||||
.dstat.is-down small { color: #ff8a80; }
|
||||
.dstats__tag {
|
||||
position: absolute; right: -1px; bottom: 100%; padding: .01rem .08rem; font-size: .11rem; font-weight: 700; letter-spacing: .1em;
|
||||
position: absolute; right: -1px; bottom: 100%; padding: .01rem .08rem; font-size: calc(.11 * var(--t)); font-weight: 700; letter-spacing: .1em;
|
||||
color: var(--text-on-mint); background: var(--mint-500); pointer-events: auto; cursor: help;
|
||||
}
|
||||
.dstats__tag.is-battle { background: var(--orange); color: #1d0c00; }
|
||||
@@ -137,47 +137,47 @@
|
||||
.rgrid i { width: var(--rg); height: var(--rg); border: 1px solid rgba(255, 255, 255, .06); }
|
||||
.rgrid i.on { background: rgba(246, 163, 41, .25); border-color: var(--amber); }
|
||||
.rgrid i.self { background: var(--mint-500); border-color: var(--mint-400); }
|
||||
.rgrid-all { font-size: .16rem; font-weight: 700; color: var(--amber); padding: .02rem .06rem; border: 1px solid var(--amber); background: rgba(246, 163, 41, .25); }
|
||||
.rgrid-all { font-size: calc(.16 * var(--t)); font-weight: 700; color: var(--amber); padding: .02rem .06rem; border: 1px solid var(--amber); background: rgba(246, 163, 41, .25); }
|
||||
.dsec { display: flex; flex-direction: column; gap: .08rem; }
|
||||
.dsec__title { display: flex; align-items: baseline; gap: .1rem; font-size: .17rem; font-weight: 700; color: var(--mint-400); padding-bottom: .04rem; border-bottom: 1px solid var(--line); }
|
||||
.dtext { font-size: .15rem; line-height: 1.65; color: var(--text-md); }
|
||||
.dsec__title { display: flex; align-items: baseline; gap: .1rem; font-size: calc(.17 * var(--t)); font-weight: 700; color: var(--mint-400); padding-bottom: .04rem; border-bottom: 1px solid var(--line); }
|
||||
.dtext { font-size: calc(.15 * var(--t)); line-height: 1.65; color: var(--text-md); }
|
||||
.dskill { display: flex; gap: .12rem; align-items: center; }
|
||||
.dskill__icon { width: .62rem; height: .62rem; flex: none; object-fit: cover; border: 1px solid var(--line-3); background: #0a0d0c; }
|
||||
.dskill__icon--empty { display: grid; place-items: center; }
|
||||
.dskill__icon--empty b { font-family: var(--font-display); font-size: .2rem; color: var(--mint-400); letter-spacing: .02em; }
|
||||
.dskill__icon--empty b { font-family: var(--font-display); font-size: calc(.2 * var(--t)); color: var(--mint-400); letter-spacing: .02em; }
|
||||
.dskill__slot { display: inline-block; margin-right: .06rem; padding: 0 .05rem; vertical-align: .02rem; font-family: var(--font-display);
|
||||
font-size: .12rem; line-height: 1.5; color: var(--text-lo); border: 1px solid var(--line-3); }
|
||||
font-size: calc(.12 * var(--t)); line-height: 1.5; color: var(--text-lo); border: 1px solid var(--line-3); }
|
||||
.dskill__meta { display: flex; flex-direction: column; gap: .05rem; min-width: 0; }
|
||||
.dskill__name { font-size: .19rem; }
|
||||
.dskill__name { font-size: calc(.19 * var(--t)); }
|
||||
.dskill__tags { display: flex; flex-wrap: wrap; gap: .06rem; align-items: center; }
|
||||
.dsp { font-size: .12rem; padding: 0 .06rem; line-height: 1.6; font-weight: 700; }
|
||||
.dsp { font-size: calc(.12 * var(--t)); padding: 0 .06rem; line-height: 1.6; font-weight: 700; }
|
||||
.dsp--time { background: #7fd37a; color: #0b1a0a; }
|
||||
.dsp--atk { background: #ff8a3d; color: #1f0c00; }
|
||||
.dsp--def { background: #f2bd3e; color: #1f1400; }
|
||||
.dsp--trig { border: 1px solid var(--line-3); color: var(--text-lo); font-weight: 500; }
|
||||
.dsp__num { display: inline-flex; align-items: center; gap: .03rem; font-size: .13rem; color: var(--text-lo); }
|
||||
.dsp__num { display: inline-flex; align-items: center; gap: .03rem; font-size: calc(.13 * var(--t)); color: var(--text-lo); }
|
||||
.dsp__num b { color: #fff; }
|
||||
.dsp__num .icon { width: .14rem; height: .14rem; color: var(--gold); }
|
||||
.dtalent { display: flex; flex-direction: column; gap: .02rem; }
|
||||
.dtalent b { font-size: .15rem; color: var(--text-hi); }
|
||||
.dtalent b { font-size: calc(.15 * var(--t)); color: var(--text-hi); }
|
||||
/* the operator's own effect (特质, user playtest #3 item 8): compact, right under the header, gold accent */
|
||||
.dgarrison {
|
||||
display: flex; flex-direction: column; gap: .04rem; padding: .07rem .1rem .08rem; flex: none;
|
||||
background: linear-gradient(90deg, rgba(242, 189, 62, .1), rgba(242, 189, 62, .02) 70%); border-left: 3px solid var(--gold-2);
|
||||
}
|
||||
.dgarrison__head { display: flex; align-items: center; gap: .08rem; }
|
||||
.dgarrison__k { font-size: .14rem; font-weight: 900; letter-spacing: .12em; color: var(--gold-2); }
|
||||
.dgarrison__type { display: inline-flex; align-items: center; gap: .04rem; font-size: .12rem; line-height: 1.5; color: var(--gold-2); padding: 0 .07rem 0 .03rem; border: 1px solid rgba(242, 189, 62, .4); }
|
||||
.dgarrison__k { font-size: calc(.14 * var(--t)); font-weight: 900; letter-spacing: .12em; color: var(--gold-2); }
|
||||
.dgarrison__type { display: inline-flex; align-items: center; gap: .04rem; font-size: calc(.12 * var(--t)); line-height: 1.5; color: var(--gold-2); padding: 0 .07rem 0 .03rem; border: 1px solid rgba(242, 189, 62, .4); }
|
||||
.dgarrison__icon { width: .18rem; height: .18rem; object-fit: contain; }
|
||||
.dgarrison__text { font-size: .15rem; line-height: 1.55; color: var(--text-hi); }
|
||||
.dgarrison__text { font-size: calc(.15 * var(--t)); line-height: 1.55; color: var(--text-hi); }
|
||||
.dbonds { display: flex; flex-wrap: wrap; gap: .08rem; }
|
||||
.dbond { display: inline-flex; align-items: center; gap: .05rem; padding: .03rem .1rem .03rem .05rem; font-size: .15rem; background: rgba(78, 216, 175, .08); border: 1px solid var(--mint-800); color: var(--mint-400); }
|
||||
.dbond { display: inline-flex; align-items: center; gap: .05rem; padding: .03rem .1rem .03rem .05rem; font-size: calc(.15 * var(--t)); background: rgba(78, 216, 175, .08); border: 1px solid var(--mint-800); color: var(--mint-400); }
|
||||
/* the unit's bonds in the header's right column, right under name / class (user playtest #2 item 9): icon, name,
|
||||
count / next threshold, tier pips — visible as soon as the card opens */
|
||||
.dbonds--top { gap: .05rem; margin-top: .06rem; }
|
||||
.dhead__info .dbonds--top .dbond { font-size: .14rem; padding: .02rem .07rem .02rem .03rem; }
|
||||
.dhead__info .dbonds--top .dbond { font-size: calc(.14 * var(--t)); padding: .02rem .07rem .02rem .03rem; }
|
||||
.dhead__info .dbond__icon { width: .24rem; height: .24rem; }
|
||||
.dbonds--top .dbond { gap: .06rem; padding: .03rem .08rem .03rem .04rem; font: inherit; font-size: .15rem; color: var(--text-md);
|
||||
.dbonds--top .dbond { gap: .06rem; padding: .03rem .08rem .03rem .04rem; font: inherit; font-size: calc(.15 * var(--t)); color: var(--text-md);
|
||||
background: rgba(255, 255, 255, .03); border: 1px solid var(--line-2); cursor: default; }
|
||||
.dbonds--top button.dbond { cursor: pointer; }
|
||||
.dbonds--top button.dbond:hover { border-color: var(--mint-500); color: #fff; }
|
||||
@@ -185,8 +185,8 @@
|
||||
.dbonds--top .dbond.is-core .dbond__name { font-weight: 700; }
|
||||
.dbond__icon { width: .26rem; height: .26rem; }
|
||||
.dbond:not(.is-active) .dbond__icon img { filter: brightness(0) invert(.6); }
|
||||
.dbond__count { font-size: .15rem; color: #fff; }
|
||||
.dbond__count small { font-size: .11rem; color: var(--text-dim); }
|
||||
.dbond__count { font-size: calc(.15 * var(--t)); color: #fff; }
|
||||
.dbond__count small { font-size: calc(.11 * var(--t)); color: var(--text-dim); }
|
||||
.dbond__count.is-max { color: var(--gold-2); }
|
||||
.dbond__tiers { display: inline-flex; gap: 2px; margin-left: .02rem; }
|
||||
.dbond__tiers i { width: .05rem; height: .12rem; background: rgba(255, 255, 255, .16); transform: skewX(-18deg); }
|
||||
@@ -195,23 +195,23 @@
|
||||
.dbonds--top .dbond.is-off { border-style: dashed; border-color: rgba(255, 255, 255, .16); background: transparent; color: var(--text-dim); }
|
||||
.dbond.is-off .dbond__name { text-decoration: line-through; text-decoration-color: rgba(255, 84, 84, .6); }
|
||||
.dbond.is-off .dbond__icon img { filter: grayscale(1) brightness(.5); }
|
||||
.dbond__off { font-size: .12rem; color: var(--text-dim); white-space: nowrap; }
|
||||
.dtag-loadout { display: inline-block; margin-left: .08rem; vertical-align: .02rem; font-size: .11rem; font-weight: 700; letter-spacing: .06em;
|
||||
.dbond__off { font-size: calc(.12 * var(--t)); color: var(--text-dim); white-space: nowrap; }
|
||||
.dtag-loadout { display: inline-block; margin-left: .08rem; vertical-align: .02rem; font-size: calc(.11 * var(--t)); font-weight: 700; letter-spacing: .06em;
|
||||
padding: 0 .06rem; line-height: 1.6; background: var(--mint-500); color: #062019; }
|
||||
.dmodule { display: flex; align-items: center; flex-wrap: wrap; gap: .08rem; font-size: .15rem; }
|
||||
.dmodule { display: flex; align-items: center; flex-wrap: wrap; gap: .08rem; font-size: calc(.15 * var(--t)); }
|
||||
.dmodule__name { color: var(--text-hi); }
|
||||
/* official module type icon (local-client art, white glyph) on a dark tile; the type letter without it */
|
||||
.dmodule__icon { flex: none; display: inline-grid; place-items: center; width: .4rem; height: .4rem; background: linear-gradient(180deg, #2a322f, #151a18); border: 1px solid var(--gold-2); border: 1px solid color-mix(in srgb, var(--gold-2) 60%, transparent); }
|
||||
.dmodule__icon img { width: 80%; height: 80%; object-fit: contain; }
|
||||
.dmodule__icon b { font-size: .2rem; font-weight: 700; line-height: 1; color: var(--gold-2); }
|
||||
.dmodule__type { font-family: var(--font-display); font-size: .12rem; letter-spacing: .1em; padding: 0 .06rem; border: 1px solid var(--line-3); color: var(--text-lo); }
|
||||
.dmodule__icon b { font-size: calc(.2 * var(--t)); font-weight: 700; line-height: 1; color: var(--gold-2); }
|
||||
.dmodule__type { font-family: var(--font-display); font-size: calc(.12 * var(--t)); letter-spacing: .1em; padding: 0 .06rem; border: 1px solid var(--line-3); color: var(--text-lo); }
|
||||
.dmodule.is-none .dmodule__name { color: var(--text-dim); font-weight: 500; }
|
||||
.ditem { display: flex; gap: .1rem; align-items: flex-start; }
|
||||
.ditem__text { display: flex; flex-direction: column; gap: .02rem; min-width: 0; }
|
||||
.ditem__text b { font-size: .15rem; }
|
||||
.ditem__desc { font-size: .13rem; line-height: 1.55; color: var(--text-lo); }
|
||||
.dempty { font-size: .14rem; }
|
||||
.dhint { display: flex; align-items: flex-start; gap: .06rem; font-size: .14rem; color: var(--text-lo); line-height: 1.6; }
|
||||
.ditem__text b { font-size: calc(.15 * var(--t)); }
|
||||
.ditem__desc { font-size: calc(.13 * var(--t)); line-height: 1.55; color: var(--text-lo); }
|
||||
.dempty { font-size: calc(.14 * var(--t)); }
|
||||
.dhint { display: flex; align-items: flex-start; gap: .06rem; font-size: calc(.14 * var(--t)); color: var(--text-lo); line-height: 1.6; }
|
||||
.dhint .icon { width: .16rem; height: .16rem; margin-top: .03rem; flex: none; }
|
||||
/* a merge-completing shop / reward card's 可晋升 destination (the tag's colour; touch shows no title — QA 6b) */
|
||||
.dhint--merge { color: var(--gold); }
|
||||
@@ -219,7 +219,7 @@
|
||||
.dhint--rule { color: var(--text-md); }
|
||||
/* 变形同构体's 天赋栏 (detailPanel MorphPairings, DESIGN §21.26): one line per bond — 【bond】 | the items that pair with it;
|
||||
本局禁用 struck through, the pairing a wearer wears highlighted. Px floors keep it readable on phones (1rem = 40 px). */
|
||||
.dmorph { display: flex; flex-direction: column; gap: .05rem; font-size: max(.14rem, 9px); line-height: 1.5; color: var(--text-md); }
|
||||
.dmorph { display: flex; flex-direction: column; gap: .05rem; font-size: max(calc(.14 * var(--t)), 9px); line-height: 1.5; color: var(--text-md); }
|
||||
.dmorph__lead { margin: 0; color: var(--text-hi); }
|
||||
.dmorph__list { margin: 0; padding: 0; list-style: none; display: flex; flex-direction: column; gap: 1px; }
|
||||
.dmorph__row { display: grid; grid-template-columns: 6.2em minmax(0, 1fr); gap: .06rem; align-items: baseline; padding: .02rem .06rem;
|
||||
@@ -230,20 +230,20 @@
|
||||
.dmorph__row.is-off .dmorph__items { color: var(--text-dim); }
|
||||
.dmorph__row.is-worn { background: rgba(78, 216, 175, .14); border-left-color: var(--mint-400); }
|
||||
.dmorph__item.is-worn { color: #fff; font-weight: 700; }
|
||||
.dmorph__tag { display: inline-block; margin-left: .06rem; padding: 0 .05rem; font-size: max(.11rem, 8px); line-height: 1.5; font-weight: 700; white-space: nowrap; }
|
||||
.dmorph__tag { display: inline-block; margin-left: .06rem; padding: 0 .05rem; font-size: max(calc(.11 * var(--t)), 8px); line-height: 1.5; font-weight: 700; white-space: nowrap; }
|
||||
.dmorph__tag.is-on { background: var(--mint-500); color: #062019; }
|
||||
.dmorph__tag.is-off { border: 1px dashed rgba(255, 255, 255, .25); color: var(--text-dim); font-weight: 500; }
|
||||
.dmorph__none { margin: 0; color: var(--gold-2); }
|
||||
/* a bond item's line: what it gives a 变形同构体 wearer (MorphGrantLine) */
|
||||
.dhint--morph { font-size: max(.14rem, 9px); color: var(--text-md); }
|
||||
.dhint--morph { font-size: max(calc(.14 * var(--t)), 9px); color: var(--text-md); }
|
||||
.dhint--morph.is-worn { color: var(--mint-400); }
|
||||
.dhint--morph.is-off { color: var(--text-dim); }
|
||||
.dactions { display: flex; justify-content: flex-end; gap: .1rem; padding-top: .06rem; border-top: 1px solid var(--line); }
|
||||
.dsell { margin-left: .06rem; color: #1d1400; }
|
||||
.dfactions { display: flex; flex-wrap: wrap; gap: .06rem; }
|
||||
.dfaction { display: inline-flex; align-items: center; gap: .04rem; font-size: .13rem; color: var(--amber); padding: 0 .08rem 0 .02rem; border: 1px solid rgba(246, 163, 41, .45); }
|
||||
.dfaction { display: inline-flex; align-items: center; gap: .04rem; font-size: calc(.13 * var(--t)); color: var(--amber); padding: 0 .08rem 0 .02rem; border: 1px solid rgba(246, 163, 41, .45); }
|
||||
.dfaction img { width: .22rem; height: .22rem; object-fit: contain; }
|
||||
.dabil { margin: 0; padding-left: .2rem; display: flex; flex-direction: column; gap: .05rem; font-size: .15rem; line-height: 1.6; color: var(--text-md); }
|
||||
.dabil { margin: 0; padding-left: .2rem; display: flex; flex-direction: column; gap: .05rem; font-size: calc(.15 * var(--t)); line-height: 1.6; color: var(--text-md); }
|
||||
.dabil li.is-off { color: var(--text-dim); }
|
||||
.dabil li.is-off .rt-term { color: var(--text-dim); border-bottom-color: transparent; }
|
||||
|
||||
@@ -270,44 +270,44 @@
|
||||
.bpop__disc.is-active { background: var(--mint-500); border-color: var(--mint-400); box-shadow: 0 0 .24rem rgba(23, 249, 183, .45); }
|
||||
.bpop__disc.is-active .bglyph img { filter: brightness(0) opacity(.85); }
|
||||
.bpop__titles { display: flex; flex-direction: column; gap: .02rem; min-width: 0; }
|
||||
.bpop__name { font-size: .32rem; font-weight: 900; letter-spacing: .08em; }
|
||||
.bpop__owner { display: inline-flex; align-items: center; gap: .06rem; font-size: .14rem; color: var(--amber); }
|
||||
.bpop__name { font-size: calc(.32 * var(--t)); font-weight: 900; letter-spacing: .08em; }
|
||||
.bpop__owner { display: inline-flex; align-items: center; gap: .06rem; font-size: calc(.14 * var(--t)); color: var(--amber); }
|
||||
.bpop__owner b { color: #fff; font-weight: 700; max-width: 2.4rem; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
|
||||
.bpop.is-other .bpop__disc { box-shadow: 0 0 0 1px rgba(246, 163, 41, .55); }
|
||||
.bpop__facts { display: flex; gap: .16rem; font-size: .15rem; color: var(--text-lo); }
|
||||
.bpop__facts b { color: #fff; font-size: .18rem; }
|
||||
.bpop__facts small { font-size: .12rem; }
|
||||
.bpop__off { margin: .08rem 0 0; font-size: .14rem; color: var(--text-lo); border-left: 2px solid rgba(255, 255, 255, .2); padding-left: .08rem; }
|
||||
.bpop__facts { display: flex; gap: .16rem; font-size: calc(.15 * var(--t)); color: var(--text-lo); }
|
||||
.bpop__facts b { color: #fff; font-size: calc(.18 * var(--t)); }
|
||||
.bpop__facts small { font-size: calc(.12 * var(--t)); }
|
||||
.bpop__off { margin: .08rem 0 0; font-size: calc(.14 * var(--t)); color: var(--text-lo); border-left: 2px solid rgba(255, 255, 255, .2); padding-left: .08rem; }
|
||||
.bpop__tiers { display: flex; gap: .06rem; }
|
||||
.bpop__tier { display: flex; align-items: baseline; gap: .02rem; padding: .02rem .12rem; border: 1px solid var(--line-2); color: var(--text-dim); font-size: .13rem; }
|
||||
.bpop__tier b { font-size: .2rem; }
|
||||
.bpop__tier { display: flex; align-items: baseline; gap: .02rem; padding: .02rem .12rem; border: 1px solid var(--line-2); color: var(--text-dim); font-size: calc(.13 * var(--t)); }
|
||||
.bpop__tier b { font-size: calc(.2 * var(--t)); }
|
||||
.bpop__tier.is-on { background: var(--mint-500); color: var(--text-on-mint); border-color: var(--mint-400); }
|
||||
.bpop__sec { display: flex; flex-direction: column; gap: .06rem; }
|
||||
.bpop__sec h4 { font-size: .16rem; color: var(--mint-400); display: flex; align-items: baseline; gap: .08rem; }
|
||||
.bpop__sec h4 small { font-size: .13rem; color: var(--text-lo); font-weight: 400; }
|
||||
.bpop__sec h4 { font-size: calc(.16 * var(--t)); color: var(--mint-400); display: flex; align-items: baseline; gap: .08rem; }
|
||||
.bpop__sec h4 small { font-size: calc(.13 * var(--t)); color: var(--text-lo); font-weight: 400; }
|
||||
.bpop__sec--now { padding: .1rem .12rem; background: rgba(78, 216, 175, .06); border-left: 3px solid var(--mint-500); }
|
||||
.bpop__desc { font-size: .15rem; line-height: 1.7; color: var(--text-md); }
|
||||
.bpop__desc { font-size: calc(.15 * var(--t)); line-height: 1.7; color: var(--text-md); }
|
||||
.bpop__members { display: grid; grid-template-columns: repeat(5, 1fr); gap: .08rem; }
|
||||
.bpop__member { position: relative; display: flex; flex-direction: column; align-items: center; gap: .03rem; padding: .04rem 0; background: none; border: 1px solid transparent; }
|
||||
.bpop__member:hover { border-color: var(--line-2); background: rgba(255, 255, 255, .03); }
|
||||
.bpop__member.is-on .uthumb__art { border-color: var(--mint-400); box-shadow: 0 0 .1rem rgba(23, 249, 183, .45); }
|
||||
.bpop__mname { font-size: .12rem; color: var(--text-lo); max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.bpop__mname { font-size: calc(.12 * var(--t)); color: var(--text-lo); max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.bpop__member.is-on .bpop__mname { color: var(--mint-400); }
|
||||
.bpop__member.is-owned .bpop__mname { color: var(--text-hi); }
|
||||
.bpop__ban { position: absolute; top: .02rem; right: .1rem; width: .2rem; height: .2rem; display: grid; place-items: center; background: var(--red); color: #fff; border-radius: 50%; }
|
||||
.bpop__ban .icon { width: .14rem; height: .14rem; }
|
||||
/* a member through 变形同构体 (the item pairing, not the operator's own bond): a gold 同构 tag on the portrait */
|
||||
.bpop__iso { position: absolute; top: .02rem; right: .08rem; padding: 0 .04rem; font-size: .11rem; line-height: 1.45; font-weight: 700; color: #141414; background: var(--gold-2); }
|
||||
.bpop__iso { position: absolute; top: .02rem; right: .08rem; padding: 0 .04rem; font-size: calc(.11 * var(--t)); line-height: 1.45; font-weight: 700; color: #141414; background: var(--gold-2); }
|
||||
/* a 自选 pick all of whose bonds are off this match (m.private.diyBanned): its 「自选」 tag moves down to the portrait's
|
||||
bottom corner (the 替补 mark's place), clear of the red ✕ and the tier chip */
|
||||
.bpop__member.is-banned .bpop__diy { top: .42rem; }
|
||||
.dbonds--top .dbond.is-granted { border-style: dashed; }
|
||||
.dbond__iso { font-size: .11rem; font-weight: 700; color: var(--gold-2); }
|
||||
.dbond__iso { font-size: calc(.11 * var(--t)); font-weight: 700; color: var(--gold-2); }
|
||||
/* a count that holds 调和's +1 (the server's bond entry `harmony`, DESIGN §21.26): 在场 n(含调和 +1) and the 调和 row that
|
||||
heads the member list, naming the 调和 operators on the board (px floors: readable on phones) */
|
||||
.bpop__facts small.bpop__hnote { font-size: max(.12rem, 8px); color: var(--gold-2); }
|
||||
.bpop__facts small.bpop__hnote { font-size: max(calc(.12 * var(--t)), 8px); color: var(--gold-2); }
|
||||
.bpop__harmony { display: flex; align-items: center; gap: .08rem; width: 100%; margin: 0; padding: .05rem .1rem; text-align: left;
|
||||
font: inherit; font-size: max(.14rem, 9px); line-height: 1.45; color: var(--text-md); background: rgba(242, 189, 62, .08); border: 1px dashed rgba(242, 189, 62, .5); }
|
||||
font: inherit; font-size: max(calc(.14 * var(--t)), 9px); line-height: 1.45; color: var(--text-md); background: rgba(242, 189, 62, .08); border: 1px dashed rgba(242, 189, 62, .5); }
|
||||
button.bpop__harmony { cursor: pointer; }
|
||||
button.bpop__harmony:hover { border-style: solid; color: #fff; }
|
||||
.bpop__harmony b { flex: none; color: var(--gold-2); font-weight: 700; white-space: nowrap; }
|
||||
@@ -328,12 +328,12 @@ button.bpop__harmony:hover { border-style: solid; color: #fff; }
|
||||
.edrawer__close { width: .34rem; height: .34rem; margin-bottom: .06rem; display: grid; place-items: center; background: none; border: 1px solid var(--line-2); color: var(--text-lo); }
|
||||
.edrawer__close .icon { width: .18rem; height: .18rem; }
|
||||
.edrawer__body { overflow: auto; padding: .14rem .18rem .18rem; display: flex; flex-direction: column; gap: .12rem; }
|
||||
.edrawer__sum { display: flex; align-items: center; justify-content: space-between; gap: .12rem; flex-wrap: wrap; font-size: .15rem; color: var(--text-md); }
|
||||
.edrawer__sum b { font-size: .19rem; }
|
||||
.edrawer__empty { display: flex; align-items: center; gap: .08rem; justify-content: center; padding: .3rem; color: var(--text-dim); font-size: .16rem; }
|
||||
.edrawer__sum { display: flex; align-items: center; justify-content: space-between; gap: .12rem; flex-wrap: wrap; font-size: calc(.15 * var(--t)); color: var(--text-md); }
|
||||
.edrawer__sum b { font-size: calc(.19 * var(--t)); }
|
||||
.edrawer__empty { display: flex; align-items: center; gap: .08rem; justify-content: center; padding: .3rem; color: var(--text-dim); font-size: calc(.16 * var(--t)); }
|
||||
.edrawer__empty .icon { width: .24rem; height: .24rem; }
|
||||
.fchips { display: flex; gap: .06rem; flex-wrap: wrap; }
|
||||
.fchip { display: inline-flex; align-items: center; gap: .04rem; padding: 0 .1rem 0 .02rem; font-size: .13rem; color: var(--amber); border: 1px solid rgba(246, 163, 41, .45); background: rgba(246, 163, 41, .06); }
|
||||
.fchip { display: inline-flex; align-items: center; gap: .04rem; padding: 0 .1rem 0 .02rem; font-size: calc(.13 * var(--t)); color: var(--amber); border: 1px solid rgba(246, 163, 41, .45); background: rgba(246, 163, 41, .06); }
|
||||
.fchip__icon { width: .26rem; height: .26rem; object-fit: contain; }
|
||||
.elist { display: grid; grid-template-columns: 1fr 1fr; gap: .08rem; }
|
||||
.erow { display: flex; align-items: center; gap: .1rem; padding: .06rem .12rem .06rem .06rem; background: rgba(255, 255, 255, .025); border: 1px solid var(--line); text-align: left; color: inherit; transition: border-color var(--t-fast), background var(--t-fast); }
|
||||
@@ -341,10 +341,10 @@ button.bpop__harmony:hover { border-style: solid; color: #fff; }
|
||||
.erow--boss { border-color: rgba(255, 110, 40, .6); background: rgba(232, 90, 26, .1); }
|
||||
.erow--bounty { border-color: rgba(255, 198, 0, .45); }
|
||||
.erow__text { display: flex; flex-direction: column; gap: .03rem; min-width: 0; flex: 1; }
|
||||
.erow__name { font-size: .16rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.erow__name { font-size: calc(.16 * var(--t)); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.erow__tags { display: flex; gap: .04rem; flex-wrap: wrap; }
|
||||
.erow__count { font-size: .26rem; color: #fff; }
|
||||
.etag { font-size: .11rem; line-height: 1.5; padding: 0 .05rem; font-weight: 700; }
|
||||
.erow__count { font-size: calc(.26 * var(--t)); color: #fff; }
|
||||
.etag { font-size: calc(.11 * var(--t)); line-height: 1.5; padding: 0 .05rem; font-weight: 700; }
|
||||
.etag--boss { background: var(--orange); color: #fff; }
|
||||
.etag--bounty { background: var(--gold); color: #1d1400; }
|
||||
.etag--escort { background: var(--bg-4); color: var(--text-md); }
|
||||
@@ -353,29 +353,29 @@ button.bpop__harmony:hover { border-style: solid; color: #fff; }
|
||||
.etag--faction { border: 1px solid rgba(246, 163, 41, .5); color: var(--amber); font-weight: 500; }
|
||||
.eboss { display: flex; gap: .12rem; align-items: center; padding: .1rem .12rem; border: 1px solid rgba(255, 110, 40, .45); background: linear-gradient(90deg, rgba(232, 90, 26, .14), transparent); }
|
||||
.eboss > div { display: flex; flex-direction: column; gap: .02rem; }
|
||||
.eboss b { font-size: .19rem; }
|
||||
.eboss .t-lo { font-size: .13rem; }
|
||||
.eboss b { font-size: calc(.19 * var(--t)); }
|
||||
.eboss .t-lo { font-size: calc(.13 * var(--t)); }
|
||||
.iband { display: flex; gap: .12rem; align-items: flex-start; padding: .1rem; background: rgba(78, 216, 175, .05); border: 1px solid var(--mint-800); }
|
||||
.iband > div { display: flex; flex-direction: column; gap: .03rem; min-width: 0; }
|
||||
.iband b { font-size: .19rem; }
|
||||
.iband b small { font-size: .14rem; font-weight: 400; margin-left: .06rem; }
|
||||
.iband__desc { font-size: .14rem; line-height: 1.6; color: var(--text-md); }
|
||||
.istage { display: flex; align-items: center; gap: .06rem; font-size: .15rem; color: var(--text-lo); }
|
||||
.iband b { font-size: calc(.19 * var(--t)); }
|
||||
.iband b small { font-size: calc(.14 * var(--t)); font-weight: 400; margin-left: .06rem; }
|
||||
.iband__desc { font-size: calc(.14 * var(--t)); line-height: 1.6; color: var(--text-md); }
|
||||
.istage { display: flex; align-items: center; gap: .06rem; font-size: calc(.15 * var(--t)); color: var(--text-lo); }
|
||||
.istage .icon { width: .18rem; height: .18rem; color: var(--mint-500); }
|
||||
.istage b { color: var(--text-hi); }
|
||||
.ihead { font-size: .16rem; color: var(--mint-400); display: flex; gap: .08rem; align-items: baseline; }
|
||||
.ihead small { color: var(--text-lo); font-size: .14rem; }
|
||||
.ihead { font-size: calc(.16 * var(--t)); color: var(--mint-400); display: flex; gap: .08rem; align-items: baseline; }
|
||||
.ihead small { color: var(--text-lo); font-size: calc(.14 * var(--t)); }
|
||||
.ibonds { display: flex; flex-wrap: wrap; gap: .06rem; }
|
||||
.ibond { display: inline-flex; align-items: center; gap: .05rem; padding: .03rem .08rem .03rem .04rem; font-size: .14rem; border: 1px solid var(--line-2); color: var(--text-md); }
|
||||
.ibond { display: inline-flex; align-items: center; gap: .05rem; padding: .03rem .08rem .03rem .04rem; font-size: calc(.14 * var(--t)); border: 1px solid var(--line-2); color: var(--text-md); }
|
||||
.ibond.is-off { color: var(--text-dim); background: rgba(255, 255, 255, .02); text-decoration: line-through; text-decoration-color: rgba(255, 84, 84, .6); }
|
||||
.ibond.is-off .bglyph img { filter: grayscale(1) brightness(.5); }
|
||||
.ibond__ban { display: inline-flex; align-items: center; gap: .02rem; font-size: .12rem; padding: 0 .05rem; background: var(--red); color: #fff; text-decoration: none; }
|
||||
.ibond__ban { display: inline-flex; align-items: center; gap: .02rem; font-size: calc(.12 * var(--t)); padding: 0 .05rem; background: var(--red); color: #fff; text-decoration: none; }
|
||||
.ibond__ban .icon { width: .12rem; height: .12rem; }
|
||||
.ibanned { display: flex; flex-wrap: wrap; gap: .06rem; }
|
||||
.ibanned__one { padding: 0; background: none; border: 0; }
|
||||
/* 0.2.0: the player's own 自选 pieces out of the shop this match (ui/matchInfo.js DiyBannedLine) */
|
||||
.idiybanned { margin: .08rem 0 0; font-size: .13rem; line-height: 1.5; }
|
||||
.idiybanned .diybanned__tag { display: inline-block; margin-right: .05rem; padding: 0 .05rem; font-size: .11rem; font-weight: 700;
|
||||
.idiybanned { margin: .08rem 0 0; font-size: calc(.13 * var(--t)); line-height: 1.5; }
|
||||
.idiybanned .diybanned__tag { display: inline-block; margin-right: .05rem; padding: 0 .05rem; font-size: calc(.11 * var(--t)); font-weight: 700;
|
||||
background: var(--mint-500); color: var(--text-on-mint); }
|
||||
|
||||
/* ---- 机变 overlay ---- */
|
||||
@@ -385,13 +385,13 @@ button.bpop__harmony:hover { border-style: solid; color: #fff; }
|
||||
.spov__inner { position: absolute; left: .3rem; right: .5rem; top: 1.14rem; bottom: .5rem; display: flex; flex-direction: column; gap: .16rem; animation: screen-in var(--t-slow) var(--ease-out); }
|
||||
.spov__head { display: flex; align-items: flex-end; justify-content: space-between; gap: .3rem; }
|
||||
.spov__titles { display: flex; flex-direction: column; gap: .04rem; }
|
||||
.spov__title { display: flex; align-items: baseline; gap: .14rem; font-size: .5rem; font-weight: 900; letter-spacing: .08em; color: #eafff8; text-shadow: 0 0 .2rem rgba(23, 249, 183, .35); }
|
||||
.spov__title { display: flex; align-items: baseline; gap: .14rem; font-size: calc(.5 * var(--t)); font-weight: 900; letter-spacing: .08em; color: #eafff8; text-shadow: 0 0 .2rem rgba(23, 249, 183, .35); }
|
||||
.spov__title.is-special { color: var(--gold-2); text-shadow: 0 0 .2rem rgba(242, 189, 62, .35); }
|
||||
.spov__bar { color: var(--mint-700); font-weight: 400; }
|
||||
.spov__desc { font-size: .22rem; font-weight: 400; color: var(--text-lo); letter-spacing: .04em; text-shadow: none; }
|
||||
.spov__sub { font-size: .15rem; color: var(--text-dim); }
|
||||
.spov__desc { font-size: calc(.22 * var(--t)); font-weight: 400; color: var(--text-lo); letter-spacing: .04em; text-shadow: none; }
|
||||
.spov__sub { font-size: calc(.15 * var(--t)); color: var(--text-dim); }
|
||||
.spov__turn { display: flex; align-items: center; gap: .24rem; }
|
||||
.spov__turntxt { display: flex; align-items: center; gap: .08rem; font-size: .24rem; font-weight: 700; color: var(--text-md); }
|
||||
.spov__turntxt { display: flex; align-items: center; gap: .08rem; font-size: calc(.24 * var(--t)); font-weight: 700; color: var(--text-md); }
|
||||
.spov__turntxt .icon { width: .26rem; height: .26rem; }
|
||||
.spov__turntxt.is-mine { color: var(--gold); text-shadow: 0 0 .16rem rgba(255, 198, 0, .45); animation: blink 1.2s ease-in-out infinite; }
|
||||
.spov__turntxt.is-done { color: var(--mint-400); }
|
||||
@@ -400,8 +400,8 @@ button.bpop__harmony:hover { border-style: solid; color: #fff; }
|
||||
.spov__who.is-cur { border-color: var(--gold); background: rgba(255, 198, 0, .08); }
|
||||
.spov__who.is-done { border-color: var(--mint-800); }
|
||||
.spov__who.is-self .spov__wname { color: var(--mint-400); }
|
||||
.spov__idx { font-size: .16rem; color: var(--text-dim); width: .2rem; text-align: center; }
|
||||
.spov__wname { font-size: .15rem; max-width: 1.2rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.spov__idx { font-size: calc(.16 * var(--t)); color: var(--text-dim); width: .2rem; text-align: center; }
|
||||
.spov__wname { font-size: calc(.15 * var(--t)); max-width: 1.2rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.spov__wstate { display: grid; }
|
||||
.spov__wstate .icon { width: .2rem; height: .2rem; }
|
||||
.spov__who.is-done .spov__wstate { color: var(--mint-400); }
|
||||
@@ -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); } }
|
||||
@@ -622,7 +624,7 @@ button.bpop__harmony:hover { border-style: solid; color: #fff; }
|
||||
border-color: rgba(255, 84, 84, .35);
|
||||
}
|
||||
.eqr .modal__body { display: flex; flex-direction: column; align-items: stretch; gap: .1rem; padding-bottom: .16rem; }
|
||||
.eqr__lead { font-size: .17rem; line-height: 1.6; color: var(--text-md); }
|
||||
.eqr__lead { font-size: calc(.17 * var(--t)); line-height: 1.6; color: var(--text-md); }
|
||||
.eqr__lead b { color: var(--text-hi); }
|
||||
.eqr__lead .t-red { color: var(--red-premium); }
|
||||
.eqr__top { display: flex; align-items: center; justify-content: center; gap: .22rem; margin-top: .06rem; }
|
||||
@@ -633,7 +635,7 @@ button.bpop__harmony:hover { border-style: solid; color: #fff; }
|
||||
}
|
||||
.eqr__ava::after { content: ''; position: absolute; inset: .1rem; background: var(--eqr-avaf, none) center / 100% 100% no-repeat; pointer-events: none; }
|
||||
.eqr__ava .uthumb { --ut: .72rem; }
|
||||
.eqr__opname { font-size: .15rem; font-weight: 700; color: var(--text-hi); max-width: 1.1rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.eqr__opname { font-size: calc(.15 * var(--t)); font-weight: 700; color: var(--text-hi); max-width: 1.1rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.eqr__swap { display: grid; place-items: center; width: .4rem; height: .32rem; color: var(--mint-400); flex: none; }
|
||||
.eqr__swap img, .eqr__swap .icon { width: 100%; height: 100%; object-fit: contain; }
|
||||
.eqr__incoming { display: flex; flex-direction: column; gap: .04rem; min-width: 0; flex: 0 1 4.2rem; }
|
||||
@@ -647,9 +649,9 @@ button.bpop__harmony:hover { border-style: solid; color: #fff; }
|
||||
.eqr__card--new .eqr__icon .uthumb { --ut: .56rem; }
|
||||
.eqr__meta { grid-row: 1; grid-column: 2; display: flex; flex-direction: column; gap: .03rem; min-width: 0; }
|
||||
.eqr__chips { display: flex; align-items: center; gap: .06rem; min-height: .2rem; }
|
||||
.eqr__elite { font-size: .12rem; padding: 0 .06rem; background: var(--gold); color: #1b1300; font-weight: 700; }
|
||||
.eqr__name { font-size: .19rem; color: var(--text-hi); line-height: 1.2; }
|
||||
.eqr__desc { grid-row: 2; grid-column: 1 / -1; margin: 0; font-size: .145rem; line-height: 1.55; color: var(--text-md); max-height: 1.12rem; overflow: auto; }
|
||||
.eqr__elite { font-size: calc(.12 * var(--t)); padding: 0 .06rem; background: var(--gold); color: #1b1300; font-weight: 700; }
|
||||
.eqr__name { font-size: calc(.19 * var(--t)); color: var(--text-hi); line-height: 1.2; }
|
||||
.eqr__desc { grid-row: 2; grid-column: 1 / -1; margin: 0; font-size: calc(.145 * var(--t)); line-height: 1.55; color: var(--text-md); max-height: 1.12rem; overflow: auto; }
|
||||
.eqr__arrow { position: relative; height: .5rem; margin: 0 auto; width: 50%; }
|
||||
.eqr__aline { position: absolute; left: 0; right: 0; top: .2rem; height: .2rem; border: .045rem solid rgba(242, 242, 242, .82); border-bottom: 0; }
|
||||
.eqr__aline::before { content: ''; position: absolute; left: 50%; top: -.25rem; width: .045rem; height: .21rem; margin-left: -.0225rem; background: rgba(242, 242, 242, .82); }
|
||||
@@ -677,12 +679,12 @@ button.bpop__harmony:hover { border-style: solid; color: #fff; }
|
||||
}
|
||||
.eqr__flag {
|
||||
position: absolute; z-index: 2; left: 0; right: 0; bottom: 0; height: .44rem; display: flex; align-items: center; justify-content: center; gap: .08rem;
|
||||
font-size: .17rem; font-weight: 700; letter-spacing: .12em; color: #fff; text-shadow: 0 1px 2px rgba(0, 0, 0, .6);
|
||||
font-size: calc(.17 * var(--t)); font-weight: 700; letter-spacing: .12em; color: #fff; text-shadow: 0 1px 2px rgba(0, 0, 0, .6);
|
||||
background: var(--eqr-selbar, linear-gradient(90deg, #a3141a, #d42a2a)) center / 100% 100% no-repeat;
|
||||
opacity: 0; transform: translateY(100%); transition: opacity var(--t-fast), transform var(--t-med) var(--ease-out);
|
||||
}
|
||||
.eqr__flag-icon { width: .2rem; height: .2rem; }
|
||||
.eqr__opt.is-sel .eqr__flag { opacity: 1; transform: none; }
|
||||
.eqr__hint { margin: .04rem 0 0; text-align: center; font-size: .15rem; color: var(--text-lo); min-height: .24rem; }
|
||||
.eqr__hint { margin: .04rem 0 0; text-align: center; font-size: calc(.15 * var(--t)); color: var(--text-lo); min-height: .24rem; }
|
||||
.eqr__hint b { color: var(--text-hi); }
|
||||
.eqr__ok-icon { width: .2rem; height: .2rem; margin-right: .06rem; vertical-align: -.03rem; filter: brightness(0) invert(1); }
|
||||
@@ -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); }
|
||||
|
||||
@@ -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: calc(.19 * var(--t)); color: var(--text-hi); }
|
||||
.spec__hint { margin: .04rem 0 0; font-size: calc(.15 * var(--t)); line-height: 1.45; }
|
||||
.gtop__exit { box-shadow: 0 .06rem .2rem rgba(0, 0, 0, .5); }
|
||||
.gtop__center {
|
||||
--bk-c: var(--mint-700);
|
||||
@@ -501,48 +516,81 @@
|
||||
@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); }
|
||||
/* square (the lobby's gear alone): the label box drops out so the gear sizes and centres against the button, like an `icon` */
|
||||
.settings-btn.btn--square .btn__label { display: contents; }
|
||||
.settings-btn.btn--square .btn__icon { width: 62%; height: 62%; } /* the gear glyph is drawn smaller in its box than the book */
|
||||
.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; }
|
||||
.set-row { display: grid; grid-template-columns: minmax(0, 3.3rem) minmax(0, 1fr) auto; align-items: center; gap: .18rem; min-height: .44rem; }
|
||||
/* The label column may shrink (the label wraps) instead of pushing its control out of the modal: at 设置 →「文字大小」
|
||||
大 / 特大 a phone's label (a Chinese name plus its Latin micro label) is wider than 3.3rem at 1rem = 40 px. */
|
||||
.set-row__label { display: flex; flex-wrap: wrap; align-items: center; gap: .1rem; font-size: calc(.18 * var(--t)); color: var(--text-hi); }
|
||||
.set-row__label .icon { width: .2rem; height: .2rem; color: var(--mint-500); }
|
||||
.set-row__label .micro { margin-left: .04rem; }
|
||||
.set-row__val { width: .5rem; text-align: right; font-size: .2rem; color: var(--mint-400); }
|
||||
/* a floor, not a fixed width: the %. value grows with the text step (20 px on a phone, 3 digits at 特大) */
|
||||
.set-row__val { min-width: .5rem; text-align: right; font-size: calc(.2 * var(--t)); color: var(--mint-400); }
|
||||
.set-range {
|
||||
-webkit-appearance: none; appearance: none; width: 100%; height: .08rem; background: linear-gradient(90deg, var(--mint-500) var(--pct), #0a0d0c var(--pct));
|
||||
border: 1px solid var(--line-2); outline: none; cursor: pointer;
|
||||
}
|
||||
.set-range::-webkit-slider-thumb { -webkit-appearance: none; width: .2rem; height: .26rem; background: var(--mint-400); clip-path: polygon(50% 0, 100% 30%, 100% 100%, 0 100%, 0 30%); cursor: grab; }
|
||||
.set-range::-moz-range-thumb { width: .2rem; height: .26rem; background: var(--mint-400); border: 0; }
|
||||
.set-toggle { grid-column: 2 / 4; justify-self: start; display: flex; align-items: center; gap: .1rem; height: .38rem; padding: 0 .14rem 0 .06rem; background: #0a0d0c; border: 1px solid var(--line-2); color: var(--text-lo); font-size: .15rem; }
|
||||
.set-toggle { grid-column: 2 / 4; justify-self: start; display: flex; align-items: center; gap: .1rem; height: .38rem; padding: 0 .14rem 0 .06rem; background: #0a0d0c; border: 1px solid var(--line-2); color: var(--text-lo); font-size: calc(.15 * var(--t)); }
|
||||
.set-toggle i { width: .46rem; height: .24rem; background: var(--bg-3); position: relative; transition: background var(--t-fast); }
|
||||
.set-toggle i::after { content: ''; position: absolute; left: .03rem; top: .03rem; width: .18rem; height: .18rem; background: var(--text-lo); transition: transform var(--t-fast), background var(--t-fast); }
|
||||
.set-toggle.is-on { color: var(--mint-400); border-color: var(--mint-700); }
|
||||
.set-toggle.is-on i { background: var(--mint-800); }
|
||||
.set-toggle.is-on i::after { transform: translateX(.22rem); background: var(--mint-400); }
|
||||
.set-seg { grid-column: 2 / 4; justify-self: start; display: flex; border: 1px solid var(--line-2); }
|
||||
.set-seg button { min-width: .8rem; height: .38rem; background: #0a0d0c; border: 0; border-right: 1px solid var(--line-2); color: var(--text-lo); font-size: .16rem; }
|
||||
.set-seg { grid-column: 2 / 4; justify-self: start; display: flex; flex-wrap: wrap; max-width: 100%; border: 1px solid var(--line-2); }
|
||||
.set-seg button { flex: 1 1 auto; min-width: .8rem; min-height: .38rem; height: auto; white-space: normal; background: #0a0d0c; border: 0; border-right: 1px solid var(--line-2); color: var(--text-lo); font-size: calc(.16 * var(--t)); }
|
||||
/* 文字大小 (ui/settings.js): four steps, so its buttons are narrower than the two- and three-step groups' — at .8rem each
|
||||
the group would be wider than the row's 1fr column on a phone (1rem = 40 px) */
|
||||
.set-seg.set-textsize button { min-width: .62rem; }
|
||||
.set-seg button:last-child { border-right: 0; }
|
||||
.set-seg button.is-on { background: var(--mint-500); color: var(--text-on-mint); font-weight: 700; }
|
||||
.set-hint { margin-top: .06rem; font-size: .14rem; color: var(--text-dim); line-height: 1.8; }
|
||||
.set-hint { margin-top: .06rem; font-size: calc(.14 * var(--t)); color: var(--text-dim); line-height: 1.8; }
|
||||
.set-hint kbd, .lvcard__key, .toolbtn kbd { font-family: var(--font-num); font-size: .11rem; border: 1px solid var(--line-2); padding: 0 .04rem; color: var(--text-lo); background: rgba(0, 0, 0, .3); }
|
||||
/* 快捷键 (ui/settings.js HotkeySection): each shortcut with its key — click the key, press the new one; two columns,
|
||||
one under 768 px (phones in landscape) */
|
||||
.set-keys { display: flex; flex-direction: column; gap: .08rem; padding-top: .1rem; border-top: 1px solid var(--line); }
|
||||
.set-keys__head { display: flex; align-items: center; justify-content: space-between; gap: .12rem; min-height: .44rem; }
|
||||
.set-keys__head { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: .12rem; min-height: .44rem; }
|
||||
.set-keys__list { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: .06rem .14rem; margin: 0; padding: 0; list-style: none; }
|
||||
.set-keys__row { display: flex; align-items: center; justify-content: space-between; gap: .1rem; min-height: .42rem; padding: .03rem .03rem .03rem .12rem; background: rgba(0, 0, 0, .22); border: 1px solid var(--line); }
|
||||
.set-keys__name { min-width: 0; font-size: .15rem; line-height: 1.35; color: var(--text-md); overflow-wrap: anywhere; }
|
||||
.set-key { flex: none; display: inline-flex; align-items: center; justify-content: center; gap: .06rem; min-width: .9rem; height: .36rem; padding: 0 .1rem; background: #0a0d0c; border: 1px solid var(--line-2); color: var(--mint-400); font-family: var(--font-num); font-size: .15rem; cursor: pointer; }
|
||||
.set-keys__name { min-width: 0; font-size: calc(.15 * var(--t)); line-height: 1.35; color: var(--text-md); overflow-wrap: anywhere; }
|
||||
.set-key { flex: none; display: inline-flex; align-items: center; justify-content: center; gap: .06rem; min-width: .9rem; height: .36rem; padding: 0 .1rem; background: #0a0d0c; border: 1px solid var(--line-2); color: var(--mint-400); font-family: var(--font-num); font-size: calc(.15 * var(--t)); cursor: pointer; }
|
||||
.set-key kbd { font: inherit; }
|
||||
.set-key:hover, .set-key:focus-visible { border-color: var(--mint-700); }
|
||||
.set-key.is-waiting { border-color: var(--amber); color: var(--amber); font-family: inherit; font-size: .13rem; animation: set-key-wait 1s ease-in-out infinite alternate; }
|
||||
.set-key.is-waiting { border-color: var(--amber); color: var(--amber); font-family: inherit; font-size: calc(.13 * var(--t)); animation: set-key-wait 1s ease-in-out infinite alternate; }
|
||||
.set-key.is-fixed { cursor: default; color: var(--text-lo); border-style: dashed; }
|
||||
.set-key.is-fixed small { font-family: inherit; font-size: .12rem; color: var(--text-dim); }
|
||||
.set-keys__note { min-height: 1.5em; font-size: .14rem; line-height: 1.5; color: var(--mint-400); }
|
||||
.set-key.is-fixed small { font-family: inherit; font-size: calc(.12 * var(--t)); color: var(--text-dim); }
|
||||
.set-keys__note { min-height: 1.5em; font-size: calc(.14 * var(--t)); line-height: 1.5; color: var(--mint-400); }
|
||||
.set-keys__note.is-warn { color: var(--amber); }
|
||||
.set-keys__more > summary { padding: .06rem 0; font-size: .15rem; color: var(--mint-400); cursor: pointer; }
|
||||
.set-keys__more > summary { padding: .06rem 0; font-size: calc(.15 * var(--t)); 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: calc(.14 * var(--t)); color: var(--text-lo); }
|
||||
.set-diag__count { font-family: var(--font-num); font-size: calc(.2 * var(--t)); line-height: 1; color: var(--mint-400); }
|
||||
.set-diag__count.is-warn { color: var(--amber); }
|
||||
.set-diag__url { min-width: 0; font-size: calc(.13 * var(--t)); 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); }
|
||||
@@ -550,11 +598,11 @@
|
||||
}
|
||||
|
||||
/* ---- exit / away ------------------------------------------------------------------------------------------------------ */
|
||||
.exitm { display: flex; flex-direction: column; gap: .1rem; font-size: .17rem; line-height: 1.7; }
|
||||
.exitm { display: flex; flex-direction: column; gap: .1rem; font-size: calc(.17 * var(--t)); line-height: 1.7; }
|
||||
.awayov { position: fixed; inset: 0; z-index: 70; display: grid; place-items: center; background: rgba(4, 7, 6, .78); -webkit-backdrop-filter: blur(3px); backdrop-filter: blur(3px); animation: screen-in var(--t-slow) var(--ease-out); }
|
||||
.awayov__box { display: flex; flex-direction: column; align-items: center; gap: .12rem; padding: .44rem .7rem; background: rgba(12, 16, 14, .96); border: 1px solid var(--line-2); }
|
||||
.awayov__icon { width: .8rem; height: .8rem; color: var(--ice); filter: drop-shadow(0 0 .2rem rgba(159, 212, 255, .5)); }
|
||||
.awayov__box h2 { font-size: .34rem; letter-spacing: .12em; }
|
||||
.awayov__box h2 { font-size: calc(.34 * var(--t)); letter-spacing: .12em; }
|
||||
|
||||
/* ---- simplified (DOM) field view ---------------------------------------------------------------------------------------- */
|
||||
.ff { position: absolute; inset: 0; overflow: hidden; user-select: none; }
|
||||
@@ -664,7 +712,7 @@
|
||||
display: flex; flex-direction: column; align-items: center; gap: .02rem; min-width: 3.6rem; padding: .16rem .6rem .2rem;
|
||||
background: rgba(20, 22, 22, .9); background: var(--pause-plate, linear-gradient(90deg, transparent, rgba(12, 14, 14, .95) 18%, rgba(12, 14, 14, .95) 82%, transparent)) center / 100% 100% no-repeat;
|
||||
}
|
||||
.pauseov__micro { font-family: var(--font-num); font-size: .16rem; letter-spacing: .5em; color: var(--mint-400); }
|
||||
.pauseov__plate h2 { font-size: .5rem; font-weight: 900; letter-spacing: .3em; color: #fff; text-shadow: 0 0 .2rem rgba(0, 0, 0, .8); }
|
||||
.pauseov__note { font-size: .17rem; color: var(--text-md); text-shadow: 0 1px 2px #000; }
|
||||
.pauseov__micro { font-family: var(--font-num); font-size: calc(.16 * var(--t)); letter-spacing: .5em; color: var(--mint-400); }
|
||||
.pauseov__plate h2 { font-size: calc(.5 * var(--t)); font-weight: 900; letter-spacing: .3em; color: #fff; text-shadow: 0 0 .2rem rgba(0, 0, 0, .8); }
|
||||
.pauseov__note { font-size: calc(.17 * var(--t)); color: var(--text-md); text-shadow: 0 1px 2px #000; }
|
||||
.pauseov__btns { display: flex; gap: .2rem; }
|
||||
@@ -27,7 +27,7 @@
|
||||
/* header: title | chapter tabs | close */
|
||||
.guide__head { display: flex; align-items: center; gap: .3rem; min-height: .62rem; }
|
||||
.guide__titles { display: flex; flex-direction: column; gap: .02rem; min-width: 2.4rem; }
|
||||
.guide__title { margin: 0; font-size: .3rem; font-weight: 900; letter-spacing: .12em; color: var(--text-hi); line-height: 1.1; }
|
||||
.guide__title { margin: 0; font-size: calc(.3 * var(--t)); font-weight: 900; letter-spacing: .12em; color: var(--text-hi); line-height: 1.1; }
|
||||
.guide__chapters { display: flex; gap: .06rem; margin: 0 auto; }
|
||||
.guide__chapter {
|
||||
position: relative; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: .01rem;
|
||||
@@ -38,8 +38,8 @@
|
||||
}
|
||||
.guide__chapter:hover { color: var(--text-hi); background: rgba(78, 216, 175, .06); }
|
||||
.guide__chapter.is-on { color: var(--text-on-mint); background: var(--mint-500); border-color: var(--mint-500); }
|
||||
.guide__chname { font-size: .19rem; font-weight: 700; letter-spacing: .1em; }
|
||||
.guide__chmicro { font-family: var(--font-display); font-size: .1rem; letter-spacing: .24em; opacity: .75; }
|
||||
.guide__chname { font-size: calc(.19 * var(--t)); font-weight: 700; letter-spacing: .1em; }
|
||||
.guide__chmicro { font-family: var(--font-display); font-size: calc(.1 * var(--t)); letter-spacing: .24em; opacity: .75; }
|
||||
.guide__close {
|
||||
width: .56rem; height: .56rem; display: grid; place-items: center; flex: none;
|
||||
background: rgba(0, 0, 0, .35); border: 1px solid var(--line-2); color: var(--text-md);
|
||||
@@ -77,10 +77,10 @@
|
||||
.guide__foot { display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; gap: .3rem; min-height: .44rem; }
|
||||
.guide__label { display: flex; align-items: center; gap: .14rem; min-width: 0; }
|
||||
.guide__chtag {
|
||||
flex: none; padding: .02rem .12rem; font-size: .14rem; font-weight: 700; letter-spacing: .1em;
|
||||
flex: none; padding: .02rem .12rem; font-size: calc(.14 * var(--t)); font-weight: 700; letter-spacing: .1em;
|
||||
color: var(--mint-400); border: 1px solid var(--mint-800); background: var(--mint-a10);
|
||||
}
|
||||
.guide__ptitle { font-size: .22rem; font-weight: 700; letter-spacing: .06em; color: var(--text-hi); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.guide__ptitle { font-size: calc(.22 * var(--t)); font-weight: 700; letter-spacing: .06em; color: var(--text-hi); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.guide__dots { display: flex; align-items: center; gap: .05rem; }
|
||||
.guide__dot {
|
||||
width: .2rem; height: .08rem; padding: 0; border: 0; background: var(--line-2);
|
||||
@@ -89,12 +89,12 @@
|
||||
.guide__dot.is-first { margin-left: .14rem; }
|
||||
.guide__dot:hover { background: var(--line-3); }
|
||||
.guide__dot.is-on { width: .42rem; background: var(--mint-500); box-shadow: 0 0 .08rem rgba(23, 249, 183, .5); }
|
||||
.guide__count { justify-self: end; font-size: .24rem; color: var(--text-md); letter-spacing: .06em; }
|
||||
.guide__count { justify-self: end; font-size: calc(.24 * var(--t)); color: var(--text-md); letter-spacing: .06em; }
|
||||
|
||||
/* text fallback: the official tips when no tutorial page can be shown (none listed, or none of a page's copies loads) */
|
||||
.guide__stage--text { width: min(92vw, 10rem); max-height: calc(100vh - 2rem); overflow: auto; padding: .1rem .06rem; }
|
||||
.guide__tips ol { margin: .14rem 0 0; padding-left: .34rem; display: flex; flex-direction: column; gap: .08rem; }
|
||||
.guide__tips li { font-size: .18rem; line-height: 1.6; color: var(--text-md); }
|
||||
.guide__tips li { font-size: calc(.18 * var(--t)); line-height: 1.6; color: var(--text-md); }
|
||||
.guide__tips li::marker { color: var(--mint-500); font-family: var(--font-num); }
|
||||
|
||||
/* standard trigger */
|
||||
|
||||
@@ -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); }
|
||||
@@ -408,41 +455,44 @@
|
||||
padding: .1rem; font: inherit; color: var(--text-md); background: none; border: 0; cursor: pointer; touch-action: manipulation;
|
||||
}
|
||||
.diy-slot__fill:hover { background: rgba(255, 255, 255, .05); color: var(--text-hi); }
|
||||
.diy-slot__fill b { font-size: max(.16rem, 11px); }
|
||||
.diy-slot__fill small { font-size: max(.12rem, 9px); color: var(--text-lo); }
|
||||
.diy-slot__fill b { font-size: max(calc(.16 * var(--t)), 11px); }
|
||||
.diy-slot__fill small { font-size: max(calc(.12 * var(--t)), 9px); color: var(--text-lo); }
|
||||
.diy-slot__plus { display: grid; place-items: center; width: max(.4rem, 26px); height: max(.4rem, 26px); border: 1px solid var(--line-3); border-radius: 50%; }
|
||||
.diy-slot__plus .icon { width: 60%; height: 60%; }
|
||||
.diy-slot__art { display: grid; place-items: center; width: max(.9rem, 56px); height: max(1.3rem, 84px); overflow: hidden; background: #0a0d0c; border: 1px solid var(--line-2); }
|
||||
.diy-slot__art img { width: 100%; height: 100%; object-fit: cover; object-position: top; }
|
||||
.diy-slot__art b { font-size: max(.3rem, 18px); color: var(--text-lo); }
|
||||
.diy-slot__art b { font-size: max(calc(.3 * var(--t)), 18px); color: var(--text-lo); }
|
||||
.diy-slot__body { display: flex; flex-direction: column; gap: .03rem; min-width: 0; }
|
||||
.diy-slot__head { display: flex; align-items: center; gap: .05rem; }
|
||||
.diy-slot__label { font-size: max(.12rem, 9px); color: var(--text-lo); letter-spacing: .06em; }
|
||||
.diy-slot__name { font-size: max(.17rem, 12px); font-weight: 700; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.diy-slot__class { font-size: max(.12rem, 9px); color: var(--text-lo); }
|
||||
.diy-slot__kit { display: flex; align-items: center; gap: .05rem; min-width: 0; font-size: max(.13rem, 9px); color: var(--text-md); }
|
||||
.diy-slot__label { font-size: max(calc(.12 * var(--t)), 9px); color: var(--text-lo); letter-spacing: .06em; }
|
||||
.diy-slot__name { font-size: max(calc(.17 * var(--t)), 12px); font-weight: 700; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.diy-slot__class { font-size: max(calc(.12 * var(--t)), 9px); color: var(--text-lo); }
|
||||
.diy-slot__kit { display: flex; align-items: center; gap: .05rem; min-width: 0; font-size: max(calc(.13 * var(--t)), 9px); color: var(--text-md); }
|
||||
.diy-slot__kit > span { min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.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; }
|
||||
.diy-slot__bad { display: flex; align-items: center; gap: .04rem; margin: .02rem 0 0; font-size: max(.12rem, 9px); color: var(--amber); }
|
||||
/* 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(calc(.12 * var(--t)), 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(calc(.12 * var(--t)), 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; }
|
||||
.diy-tag { display: inline-flex; align-items: center; height: 1.5em; padding: 0 .4em; font-size: max(calc(.11 * var(--t)), 8px); font-weight: 700; border-radius: 1em; white-space: nowrap; }
|
||||
.diy-tag--proto { color: var(--ice-900); background: var(--ice); }
|
||||
.diy-tag--own { color: var(--text-on-mint); background: var(--mint-500); }
|
||||
.diy-bonds { display: flex; flex-wrap: wrap; gap: .03rem .06rem; }
|
||||
.diy-bond { display: inline-flex; align-items: center; gap: .03rem; font-size: max(.12rem, 9px); color: var(--text-md); }
|
||||
.diy-bond { display: inline-flex; align-items: center; gap: .03rem; font-size: max(calc(.12 * var(--t)), 9px); color: var(--text-md); }
|
||||
.diy-bond .bglyph, .diy-bond img { width: max(.16rem, 11px); height: max(.16rem, 11px); }
|
||||
/* the picker covers the tab: the list of the slot's tier on the left, the chosen operator's skill / module on the right */
|
||||
.diy-pick { position: absolute; inset: 0; z-index: 3; display: flex; flex-direction: column; gap: .08rem; padding: .12rem .3rem .14rem; background: var(--bg-1, #0d1110); }
|
||||
.diy-pick__head { flex: none; display: flex; align-items: center; gap: .08rem; font-size: max(.16rem, 11px); }
|
||||
.diy-pick__note { flex: 1; min-width: 0; font-size: max(.12rem, 9px); color: var(--text-lo); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.diy-pick__head { flex: none; display: flex; align-items: center; gap: .08rem; font-size: max(calc(.16 * var(--t)), 11px); }
|
||||
.diy-pick__note { flex: 1; min-width: 0; font-size: max(calc(.12 * var(--t)), 9px); color: var(--text-lo); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.diy-pick__x { display: grid; place-items: center; width: max(.4rem, 28px); height: max(.4rem, 28px); color: var(--text-md); background: none; border: 1px solid var(--line-2); cursor: pointer; }
|
||||
.diy-pick__bar { flex: none; display: flex; align-items: center; gap: .1rem; flex-wrap: wrap; }
|
||||
.diy-pick__search { flex: 1 1 2rem; min-width: 120px; height: max(.36rem, 26px); padding: 0 .08rem; font: inherit; font-size: max(.13rem, 10px); color: var(--text-hi); background: rgba(255, 255, 255, .04); border: 1px solid var(--line-2); }
|
||||
.diy-pick__search { flex: 1 1 2rem; min-width: 120px; height: max(.36rem, 26px); padding: 0 .08rem; font: inherit; font-size: max(calc(.13 * var(--t)), 10px); color: var(--text-hi); background: rgba(255, 255, 255, .04); border: 1px solid var(--line-2); }
|
||||
.diy-pick__main { flex: 1 1 auto; min-height: 0; display: grid; grid-template-columns: minmax(0, 1.2fr) minmax(0, 1fr); gap: .12rem; }
|
||||
.diy-pick__list, .diy-pick__detail { min-height: 0; overflow: auto; -webkit-overflow-scrolling: touch; overscroll-behavior: contain; scrollbar-width: thin; scrollbar-color: var(--line-3) transparent; }
|
||||
.diy-pick__list { display: grid; grid-template-columns: repeat(auto-fill, minmax(max(3.2rem, 200px), 1fr)); align-content: start; gap: .06rem; }
|
||||
.diy-pick__list { display: grid; grid-template-columns: 1fr; align-content: start; gap: .06rem; }
|
||||
.diy-opt {
|
||||
position: relative; display: flex; align-items: center; gap: .07rem; min-width: 0; padding: .06rem; font: inherit; color: var(--text-hi); text-align: left;
|
||||
background: rgba(255, 255, 255, .03); border: 1px solid var(--line); cursor: pointer; touch-action: manipulation;
|
||||
@@ -454,19 +504,19 @@
|
||||
.diy-opt__ava img { width: 100%; height: 100%; object-fit: cover; }
|
||||
.diy-opt__txt { display: flex; flex-direction: column; gap: .02rem; min-width: 0; }
|
||||
.diy-opt__top { display: flex; align-items: center; gap: .04rem; min-width: 0; }
|
||||
.diy-opt__name { font-size: max(.15rem, 11px); font-weight: 700; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.diy-opt__class { display: inline-flex; align-items: center; gap: .03rem; font-size: max(.11rem, 8px); color: var(--text-lo); }
|
||||
.diy-opt__name { font-size: max(calc(.15 * var(--t)), 11px); font-weight: 700; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.diy-opt__class { display: inline-flex; align-items: center; gap: .03rem; font-size: max(calc(.11 * var(--t)), 8px); color: var(--text-lo); }
|
||||
.diy-opt__prof { width: max(.14rem, 10px); height: max(.14rem, 10px); }
|
||||
.diy-opt__taken { position: absolute; right: .04rem; bottom: .03rem; font-size: max(.1rem, 8px); color: var(--amber); }
|
||||
.diy-opt__taken { position: absolute; right: .04rem; bottom: .03rem; font-size: max(calc(.1 * var(--t)), 8px); color: var(--amber); }
|
||||
.diy-pick__detail { display: flex; flex-direction: column; gap: .06rem; padding: .06rem; background: rgba(255, 255, 255, .02); border: 1px solid var(--line); }
|
||||
.diy-pick__who { display: flex; align-items: center; gap: .08rem; }
|
||||
.diy-pick__ava { display: grid; place-items: center; width: max(.6rem, 40px); height: max(.6rem, 40px); overflow: hidden; background: #0a0d0c; border: 1px solid var(--line-2); }
|
||||
.diy-pick__ava img { width: 100%; height: 100%; object-fit: cover; }
|
||||
.diy-pick__wtxt { display: flex; flex-direction: column; flex: 1; min-width: 0; }
|
||||
.diy-pick__wtxt b { font-size: max(.17rem, 12px); }
|
||||
.diy-pick__wtxt small { font-size: max(.12rem, 9px); color: var(--text-lo); }
|
||||
.diy-pick__sec { display: flex; align-items: baseline; gap: .06rem; margin: .04rem 0 0; font-size: max(.14rem, 10px); color: var(--text-md); }
|
||||
.diy-pick__sec small { font-size: max(.11rem, 8px); color: var(--text-lo); font-weight: 400; }
|
||||
.diy-pick__wtxt b { font-size: max(calc(.17 * var(--t)), 12px); }
|
||||
.diy-pick__wtxt small { font-size: max(calc(.12 * var(--t)), 9px); color: var(--text-lo); }
|
||||
.diy-pick__sec { display: flex; align-items: baseline; gap: .06rem; margin: .04rem 0 0; font-size: max(calc(.14 * var(--t)), 10px); color: var(--text-md); }
|
||||
.diy-pick__sec small { font-size: max(calc(.11 * var(--t)), 8px); color: var(--text-lo); font-weight: 400; }
|
||||
.diy-pick__choices { display: flex; flex-direction: column; gap: .04rem; }
|
||||
.diy-choice {
|
||||
display: flex; align-items: flex-start; gap: .07rem; padding: .05rem; font: inherit; color: var(--text-hi); text-align: left;
|
||||
@@ -477,8 +527,8 @@
|
||||
.diy-choice__icon { flex: none; display: grid; place-items: center; width: max(.36rem, 24px); height: max(.36rem, 24px); color: var(--text-lo); }
|
||||
.diy-choice__icon img { width: 100%; height: 100%; object-fit: contain; }
|
||||
.diy-choice__body { display: flex; flex-direction: column; gap: .02rem; min-width: 0; }
|
||||
.diy-choice__head { display: flex; align-items: center; gap: .05rem; flex-wrap: wrap; font-size: max(.14rem, 10px); }
|
||||
.diy-choice__desc { font-size: max(.12rem, 9px); line-height: 1.45; color: var(--text-md); }
|
||||
.diy-choice__head { display: flex; align-items: center; gap: .05rem; flex-wrap: wrap; font-size: max(calc(.14 * var(--t)), 10px); }
|
||||
.diy-choice__desc { font-size: max(calc(.12 * var(--t)), 9px); line-height: 1.45; color: var(--text-md); }
|
||||
.diy-pick__foot { flex: none; display: flex; justify-content: flex-end; gap: .08rem; }
|
||||
/* 0.2.0 自选编队: the slots the player filled (the entry button's chip) */
|
||||
.lo-entry__diy { color: var(--text-on-mint); background: var(--mint-500); }
|
||||
@@ -527,48 +577,55 @@
|
||||
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; }
|
||||
.lo-body.is-detail .lo-detail-wrap { display: flex; }
|
||||
.lo-detail-back {
|
||||
display: inline-flex; align-self: flex-start; align-items: center; gap: .04rem; margin-bottom: .08rem; padding: .04rem .12rem .04rem .04rem;
|
||||
font: inherit; font-size: max(.15rem, 11px); color: var(--mint-400); background: transparent; border: 1px solid var(--mint-800); cursor: pointer;
|
||||
font: inherit; font-size: max(calc(.15 * var(--t)), 11px); color: var(--mint-400); background: transparent; border: 1px solid var(--mint-800); cursor: pointer;
|
||||
}
|
||||
.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) */
|
||||
.lo .lo-back { height: max(.5rem, 32px); padding: 0 max(.2rem, 10px) 0 max(.12rem, 6px); font-size: max(.18rem, 12px); }
|
||||
.lo .lo-top__right .btn, .lo .lo-dhead__reset { height: max(.36rem, 28px); padding: 0 max(.16rem, 8px); font-size: max(.15rem, 11px); }
|
||||
.lo .lo-back { height: max(.5rem, 32px); padding: 0 max(.2rem, 10px) 0 max(.12rem, 6px); font-size: max(calc(.18 * var(--t)), 12px); }
|
||||
.lo .lo-top__right .btn, .lo .lo-dhead__reset { height: max(.36rem, 28px); padding: 0 max(.16rem, 8px); font-size: max(calc(.15 * var(--t)), 11px); }
|
||||
.lo .lo-search .field__box { height: max(.56rem, 28px); padding: 0 max(.16rem, 6px); }
|
||||
.lo .lo-search .field__input { font-size: max(.2rem, 12px); }
|
||||
.lo .lo-search .field__input { font-size: max(calc(.2 * var(--t)), 12px); }
|
||||
.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
|
||||
else, and `.lo > *` needs no exception for it. Below: the payload textarea + the hidden input behind 选择文件. */
|
||||
.lo-io__hint { margin: 0 0 max(.1rem, 8px); font-size: max(.18rem, 12px); line-height: 1.55; color: var(--text-md); }
|
||||
.lo-io__hint b { color: var(--mint-400); font-size: max(.22rem, 13px); margin: 0 .02rem; }
|
||||
.lo-io__hint { margin: 0 0 max(.1rem, 8px); font-size: max(calc(.18 * var(--t)), 12px); line-height: 1.55; color: var(--text-md); }
|
||||
.lo-io__hint b { color: var(--mint-400); font-size: max(calc(.22 * var(--t)), 13px); margin: 0 .02rem; }
|
||||
.lo-io__hint strong { color: var(--gold); }
|
||||
.lo-io__text {
|
||||
box-sizing: border-box; width: 100%; height: max(2.4rem, 160px); min-height: 120px; resize: vertical;
|
||||
padding: max(.1rem, 8px) max(.12rem, 10px);
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
/* 16 px: iOS zooms into a focused field below that (same reason as ui/clipboard.js) */
|
||||
font-size: max(.2rem, 16px); line-height: 1.45;
|
||||
font-size: max(calc(.2 * var(--t)), 16px); line-height: 1.45;
|
||||
color: var(--text-hi); background: rgba(4, 10, 8, .55);
|
||||
border: 1px solid var(--line-2); border-radius: 2px; outline: none;
|
||||
/* wrap instead of a horizontal scrollbar: phones are narrow and the payload is meant to be read/copied whole */
|
||||
@@ -577,3 +634,19 @@
|
||||
.lo-io__text:focus { border-color: var(--mint-700); }
|
||||
.lo-io__text::placeholder { color: var(--text-lo); }
|
||||
.lo-io__file { display: none; }
|
||||
|
||||
/* Per-operator voice, a browser-local preference in roster and DIY details. */
|
||||
.lo-voice { display: flex; flex-wrap: wrap; align-items: center; gap: .12rem; padding: .14rem 0; font-size: calc(.18 * var(--t)); }
|
||||
.lo-voice > small { flex-basis: 100%; color: var(--text-dim); font-size: calc(.15 * var(--t)); }
|
||||
.lo-voice .lo-select { min-width: 1.6rem; max-width: 100%; }
|
||||
|
||||
/* DIY uses the same aligned rows and detail sections as operator loadout. */
|
||||
.diy-pick__list .diy-opt { min-height: 64px; padding: .1rem; }
|
||||
.diy-pick__list .diy-opt__txt { flex: 1; display: grid; grid-template-columns: minmax(0, 1.15fr) minmax(0, 1fr) minmax(0, 1fr); align-items: center; gap: .1rem; }
|
||||
.diy-pick__list .diy-opt__top { flex-wrap: wrap; }
|
||||
.diy-pick__detail > .lo-sec { flex: none; }
|
||||
@media (max-width: 900px) {
|
||||
.diy-pick__list .diy-opt__txt { display: flex; flex-direction: column; align-items: flex-start; gap: .04rem; }
|
||||
}
|
||||
|
||||
.diy-choice__tags { display: flex; flex-wrap: wrap; gap: .08rem; color: var(--text-lo); font-size: max(calc(.12 * var(--t)), 10px); }
|
||||
@@ -71,9 +71,9 @@
|
||||
}
|
||||
.mode-card__icon .icon { width: .44rem; height: .44rem; }
|
||||
.mode-card__text { position: relative; display: flex; flex-direction: column; gap: .1rem; }
|
||||
.mode-card__name { font-size: .46rem; font-weight: 900; letter-spacing: .08em; line-height: 1.1; }
|
||||
.mode-card__desc { font-size: .17rem; color: var(--text-lo); line-height: 1.65; max-width: 3.6rem; }
|
||||
.mode-card__points { display: flex; flex-direction: column; gap: .05rem; margin-top: .06rem; font-size: .15rem; color: var(--text-md); }
|
||||
.mode-card__name { font-size: calc(.46 * var(--t)); font-weight: 900; letter-spacing: .08em; line-height: 1.1; }
|
||||
.mode-card__desc { font-size: calc(.17 * var(--t)); color: var(--text-lo); line-height: 1.65; max-width: 3.6rem; }
|
||||
.mode-card__points { display: flex; flex-direction: column; gap: .05rem; margin-top: .06rem; font-size: calc(.15 * var(--t)); color: var(--text-md); }
|
||||
.mode-card__points span::before {
|
||||
content: '';
|
||||
display: inline-block;
|
||||
@@ -94,7 +94,7 @@
|
||||
padding: .04rem .12rem .04rem .08rem;
|
||||
background: var(--mint-500);
|
||||
color: var(--text-on-mint);
|
||||
font-size: .14rem;
|
||||
font-size: calc(.14 * var(--t));
|
||||
font-weight: 700;
|
||||
opacity: 0;
|
||||
transform: translateY(-.06rem);
|
||||
@@ -118,13 +118,13 @@
|
||||
.join-row { display: flex; gap: .16rem; align-items: stretch; }
|
||||
.join-row .field { flex: 1; }
|
||||
.join-row .btn { height: .64rem; }
|
||||
.join-foot { display: flex; align-items: center; flex-wrap: wrap; gap: .1rem; min-height: .32rem; font-size: .14rem; }
|
||||
.join-foot { display: flex; align-items: center; flex-wrap: wrap; gap: .1rem; min-height: .32rem; font-size: calc(.14 * var(--t)); }
|
||||
.code-chip {
|
||||
padding: .03rem .12rem .03rem .16rem;
|
||||
border: 1px solid var(--line-2);
|
||||
background: rgba(0, 0, 0, .3);
|
||||
color: var(--amber);
|
||||
font-size: .17rem;
|
||||
font-size: calc(.17 * var(--t));
|
||||
font-weight: 700;
|
||||
letter-spacing: .2em;
|
||||
transition: border-color var(--t-fast), background-color var(--t-fast);
|
||||
@@ -139,9 +139,9 @@
|
||||
background: rgba(8, 10, 9, .55);
|
||||
border: 1px solid var(--line);
|
||||
}
|
||||
.tips__head { display: flex; align-items: center; gap: .1rem; font-size: .16rem; font-weight: 700; color: var(--text-md); }
|
||||
.tips__head { display: flex; align-items: center; gap: .1rem; font-size: calc(.16 * var(--t)); font-weight: 700; color: var(--text-md); }
|
||||
.tips__head > .icon { width: .2rem; height: .2rem; color: var(--mint-500); }
|
||||
.tips__idx { margin-left: auto; font-size: .16rem; color: var(--text-md); }
|
||||
.tips__idx { margin-left: auto; font-size: calc(.16 * var(--t)); color: var(--text-md); }
|
||||
.tips__nav {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
@@ -155,7 +155,7 @@
|
||||
}
|
||||
.tips__nav:hover { color: var(--mint-400); border-color: var(--mint-700); }
|
||||
.tips__nav .icon { width: .18rem; height: .18rem; }
|
||||
.tips__text { margin-top: .1rem; font-size: .17rem; color: var(--text-lo); line-height: 1.6; animation: tip-in var(--t-slow) var(--ease-out); }
|
||||
.tips__text { margin-top: .1rem; font-size: calc(.17 * var(--t)); color: var(--text-lo); line-height: 1.6; animation: tip-in var(--t-slow) var(--ease-out); }
|
||||
@keyframes tip-in { from { opacity: 0; transform: translateX(.1rem); } }
|
||||
|
||||
/* ---- difficulty cards -------------------------------------------------------------------------------- */
|
||||
@@ -182,20 +182,20 @@
|
||||
.diff-card__bar { position: absolute; left: 0; top: 0; bottom: 0; width: .06rem; background: var(--d-color); opacity: .45; transition: opacity var(--t-med), box-shadow var(--t-med); }
|
||||
.diff-card__head { grid-column: 1 / -1; display: flex; align-items: center; gap: .1rem; min-width: 0; }
|
||||
.diff-card__glyph { width: .28rem; height: .28rem; color: var(--d-color); filter: drop-shadow(0 0 5px var(--d-color)); filter: drop-shadow(0 0 5px color-mix(in srgb, var(--d-color) 55%, transparent)); }
|
||||
.diff-card__name { font-size: .27rem; font-weight: 900; letter-spacing: .06em; color: var(--d-color); }
|
||||
.diff-card__code { font-size: .14rem; color: var(--text-dim); letter-spacing: .1em; }
|
||||
.diff-card__meta { margin-left: auto; display: flex; align-items: center; gap: .04rem; font-size: .14rem; color: var(--text-lo); white-space: nowrap; }
|
||||
.diff-card__meta .num { color: var(--text-hi); font-weight: 700; font-size: .17rem; }
|
||||
.diff-card__name { font-size: calc(.27 * var(--t)); font-weight: 900; letter-spacing: .06em; color: var(--d-color); }
|
||||
.diff-card__code { font-size: calc(.14 * var(--t)); color: var(--text-dim); letter-spacing: .1em; }
|
||||
.diff-card__meta { margin-left: auto; display: flex; align-items: center; gap: .04rem; font-size: calc(.14 * var(--t)); color: var(--text-lo); white-space: nowrap; }
|
||||
.diff-card__meta .num { color: var(--text-hi); font-weight: 700; font-size: calc(.17 * var(--t)); }
|
||||
.diff-card__hidden {
|
||||
margin-left: .08rem;
|
||||
padding: 0 .07rem;
|
||||
border: 1px solid transparent; border: 1px solid color-mix(in srgb, var(--d-color) 45%, transparent);
|
||||
color: var(--d-color); color: color-mix(in srgb, var(--d-color) 80%, #fff);
|
||||
font-size: .12rem;
|
||||
font-size: calc(.12 * var(--t));
|
||||
line-height: 1.6;
|
||||
}
|
||||
.diff-card__desc { grid-column: 1; font-size: .16rem; color: var(--text-md); }
|
||||
.diff-card__effects { grid-column: 1; display: flex; flex-wrap: wrap; gap: .04rem .2rem; font-size: .14rem; color: var(--text-lo); }
|
||||
.diff-card__desc { grid-column: 1; font-size: calc(.16 * var(--t)); color: var(--text-md); }
|
||||
.diff-card__effects { grid-column: 1; display: flex; flex-wrap: wrap; gap: .04rem .2rem; font-size: calc(.14 * var(--t)); color: var(--text-lo); }
|
||||
.diff-card__effects span::before { content: '·'; margin-right: .05rem; color: var(--d-color); font-weight: 900; }
|
||||
/* battlefield pool note (config.json modes[].stages): 战场固定为 战场#01 / 战场随机(共N张) — the last item of the
|
||||
effects row (no extra card height: the lobby must not scroll at 1280×720 / 1920×1080) */
|
||||
@@ -210,7 +210,7 @@
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: .03rem;
|
||||
font-size: .12rem;
|
||||
font-size: calc(.12 * var(--t));
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.diff-card__check .icon {
|
||||
@@ -236,5 +236,5 @@
|
||||
|
||||
/* ---- create ------------------------------------------------------------------------------------------ */
|
||||
.create-box { margin-top: .3rem; display: flex; flex-direction: column; gap: .12rem; }
|
||||
.create-box .btn--xl { height: .92rem; font-size: .32rem; letter-spacing: .3em; }
|
||||
.create-box__hint { display: flex; align-items: center; justify-content: center; min-height: .3rem; font-size: .14rem; color: var(--text-lo); }
|
||||
.create-box .btn--xl { height: .92rem; font-size: calc(.32 * var(--t)); letter-spacing: .3em; }
|
||||
.create-box__hint { display: flex; align-items: center; justify-content: center; min-height: .3rem; font-size: calc(.14 * var(--t)); color: var(--text-lo); }
|
||||
@@ -10,15 +10,16 @@
|
||||
-webkit-mask-image: radial-gradient(70% 70% at 25% 50%, #000, transparent); mask-image: radial-gradient(70% 70% at 25% 50%, #000, transparent); }
|
||||
.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__summary { display: flex; flex-direction: column; align-items: flex-start; gap: .16rem; min-height: 0; width: 100%; overflow: auto; overscroll-behavior: contain; scrollbar-width: thin; }
|
||||
.result__logoimg { height: 1.1rem; width: auto; opacity: .92; }
|
||||
.result__headline { font-size: .96rem; font-weight: 900; letter-spacing: .12em; line-height: 1.05; color: var(--mint-400); text-shadow: 0 0 .4rem rgba(23, 249, 183, .45); animation: head-in 600ms var(--ease-out); }
|
||||
.result__headline { font-size: calc(.96 * var(--t)); font-weight: 900; letter-spacing: .12em; line-height: 1.05; color: var(--mint-400); text-shadow: 0 0 .4rem rgba(23, 249, 183, .45); animation: head-in 600ms var(--ease-out); }
|
||||
.result.is-lose .result__headline { color: var(--red-premium); text-shadow: 0 0 .4rem rgba(255, 60, 60, .4); }
|
||||
@keyframes head-in { from { opacity: 0; letter-spacing: .5em; } }
|
||||
.result__sub { font-size: .2rem; color: var(--text-lo); letter-spacing: .1em; }
|
||||
.result__sub { font-size: calc(.2 * var(--t)); color: var(--text-lo); letter-spacing: .1em; }
|
||||
.result__rounds { display: flex; align-items: baseline; gap: .12rem; margin-top: .1rem; }
|
||||
.result__rlabel { font-size: .22rem; color: var(--text-md); letter-spacing: .14em; }
|
||||
.result__rnum { font-size: 1.5rem; line-height: .9; font-weight: 700; color: #fff; }
|
||||
.result__rof { font-size: .4rem; color: var(--text-dim); }
|
||||
.result__rlabel { font-size: calc(.22 * var(--t)); color: var(--text-md); letter-spacing: .14em; }
|
||||
.result__rnum { font-size: calc(1.5 * var(--t)); line-height: .9; font-weight: 700; color: #fff; }
|
||||
.result__rof { font-size: calc(.4 * var(--t)); color: var(--text-dim); }
|
||||
.result__medals { display: flex; gap: .24rem; margin-top: .12rem; }
|
||||
.medal { position: relative; width: 1.1rem; display: flex; flex-direction: column; align-items: center; gap: .08rem; }
|
||||
.medal img { width: .96rem; height: .96rem; object-fit: cover; border-radius: 50%; border: 3px solid var(--line-3); background: #151a18; filter: grayscale(.7) brightness(.7); }
|
||||
@@ -27,8 +28,8 @@
|
||||
.medal__check { position: absolute; right: .04rem; top: .64rem; width: .32rem; height: .32rem; display: grid; place-items: center; background: var(--text-dim); color: #fff; border-radius: 50%; }
|
||||
.medal.is-done .medal__check { background: var(--gold); color: #1d1400; }
|
||||
.medal__check .icon { width: .2rem; height: .2rem; }
|
||||
.medal__label { font-size: .14rem; color: var(--text-lo); }
|
||||
.result__time { font-size: .16rem; }
|
||||
.medal__label { font-size: calc(.14 * var(--t)); color: var(--text-lo); }
|
||||
.result__time { font-size: calc(.16 * var(--t)); }
|
||||
.result__time b { color: var(--text-hi); }
|
||||
|
||||
.result__players { display: flex; flex-direction: column; gap: .1rem; min-height: 0; overflow: auto; padding-right: .06rem; }
|
||||
@@ -45,34 +46,34 @@
|
||||
.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__name { font-size: calc(.22 * var(--t)); display: flex; align-items: center; gap: .08rem; }
|
||||
.rcard__ai, .rcard__you { font-size: calc(.11 * var(--t)); 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__band { display: inline-flex; align-items: center; gap: .06rem; font-size: calc(.15 * var(--t)); color: var(--text-lo); }
|
||||
.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__round b { font-size: calc(.34 * var(--t)); line-height: 1; color: #fff; }
|
||||
.rcard__gain { display: flex; gap: .08rem; margin-top: .03rem; font-size: calc(.13 * var(--t)); 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__gain b { font-size: calc(.14 * var(--t)); 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; }
|
||||
.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__title b { font-size: calc(.19 * var(--t)); 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: .06rem; min-width: 0; }
|
||||
.rcard__lineup .uthumb { --ut: .46rem; }
|
||||
.rcard__lineup { display: flex; flex-wrap: wrap; gap: .05rem; }
|
||||
.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__noinfo { font-family: var(--font-display); font-size: calc(.14 * var(--t)); 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); }
|
||||
.rbond { display: inline-flex; align-items: center; gap: .04rem; padding: .01rem .07rem .01rem .03rem; border: 1px solid var(--line-2); font-size: calc(.13 * var(--t)); color: var(--text-lo); }
|
||||
.rbond .bglyph { width: .2rem; height: .2rem; }
|
||||
.rbond.is-on { border-color: var(--mint-800); color: var(--mint-400); }
|
||||
.rcard__stats { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 1px; }
|
||||
.rstat { display: flex; align-items: baseline; justify-content: space-between; gap: .06rem; padding: .05rem .1rem; background: #0e1210; box-shadow: 0 0 0 1px var(--line); font-size: .13rem; color: var(--text-lo); white-space: nowrap; min-width: 0; }
|
||||
.rstat b { font-size: .18rem; color: #fff; }
|
||||
.rstat { display: flex; align-items: baseline; justify-content: space-between; gap: .06rem; padding: .05rem .1rem; background: #0e1210; box-shadow: 0 0 0 1px var(--line); font-size: calc(.13 * var(--t)); color: var(--text-lo); white-space: nowrap; min-width: 0; }
|
||||
.rstat b { font-size: calc(.18 * var(--t)); color: #fff; }
|
||||
.rstat.is-best b { color: var(--gold); }
|
||||
.result__foot { margin-top: auto; padding-top: .2rem; display: flex; }
|
||||
.result__foot { flex: 0 0 auto; margin-top: auto; padding-top: .2rem; display: flex; }
|
||||
.result__foot .btn { min-width: 3.4rem; }
|
||||
@@ -17,7 +17,7 @@
|
||||
background: rgba(0, 0, 0, .38);
|
||||
border: 1px solid var(--line);
|
||||
}
|
||||
.invite__label { grid-column: 1 / -1; display: flex; align-items: center; gap: .07rem; font-size: .14rem; color: var(--text-lo); }
|
||||
.invite__label { grid-column: 1 / -1; display: flex; align-items: center; gap: .07rem; font-size: calc(.14 * var(--t)); color: var(--text-lo); }
|
||||
.invite__label .icon { width: .16rem; height: .16rem; color: var(--mint-500); }
|
||||
.invite__code { display: flex; gap: .06rem; }
|
||||
.invite__code span {
|
||||
@@ -28,13 +28,13 @@
|
||||
background: linear-gradient(180deg, rgba(78, 216, 175, .1), rgba(78, 216, 175, .02));
|
||||
border-bottom: 2px solid var(--mint-600);
|
||||
color: var(--mint-400);
|
||||
font-size: .44rem;
|
||||
font-size: calc(.44 * var(--t));
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
text-shadow: 0 0 .16rem rgba(23, 249, 183, .45);
|
||||
}
|
||||
.invite__btns { display: flex; flex-direction: column; gap: .06rem; }
|
||||
.solo-note { display: flex; flex-direction: column; align-items: flex-end; gap: .03rem; font-size: .16rem; color: var(--text-lo); }
|
||||
.solo-note { display: flex; flex-direction: column; align-items: flex-end; gap: .03rem; font-size: calc(.16 * var(--t)); color: var(--text-lo); }
|
||||
|
||||
/* ---- seats ------------------------------------------------------------------------------------------------ */
|
||||
.seats {
|
||||
@@ -82,7 +82,7 @@
|
||||
transition: background var(--t-med), box-shadow var(--t-med);
|
||||
}
|
||||
.seat__head { align-self: stretch; display: flex; align-items: center; gap: .1rem; }
|
||||
.seat__no { font-size: .22rem; font-weight: 700; color: var(--text-md); padding-left: .08rem; border-left: 3px solid var(--line-3); line-height: 1.1; }
|
||||
.seat__no { font-size: calc(.22 * var(--t)); font-weight: 700; color: var(--text-md); padding-left: .08rem; border-left: 3px solid var(--line-3); line-height: 1.1; }
|
||||
.seat__host {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
@@ -91,7 +91,7 @@
|
||||
padding: .02rem .09rem;
|
||||
background: var(--gold);
|
||||
color: #1d1400;
|
||||
font-size: .13rem;
|
||||
font-size: calc(.13 * var(--t));
|
||||
font-weight: 700;
|
||||
}
|
||||
.seat__host .icon { width: .15rem; height: .15rem; }
|
||||
@@ -131,13 +131,13 @@
|
||||
background: rgba(159, 212, 255, .1);
|
||||
border: 1px solid rgba(159, 212, 255, .35);
|
||||
color: var(--ice);
|
||||
font-size: .14rem;
|
||||
font-size: calc(.14 * var(--t));
|
||||
font-weight: 700;
|
||||
}
|
||||
.seat__bot-label .icon { width: .16rem; height: .16rem; }
|
||||
.seat__who { display: flex; align-items: center; gap: .08rem; max-width: 100%; }
|
||||
.seat__name { font-size: .26rem; font-weight: 700; max-width: 3rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.seat__you { flex: none; padding: .01rem .07rem; background: var(--mint-500); color: var(--text-on-mint); font-size: .13rem; font-weight: 700; }
|
||||
.seat__name { font-size: calc(.26 * var(--t)); font-weight: 700; max-width: 3rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.seat__you { flex: none; padding: .01rem .07rem; background: var(--mint-500); color: var(--text-on-mint); font-size: calc(.13 * var(--t)); font-weight: 700; }
|
||||
.seat__foot {
|
||||
align-self: stretch;
|
||||
display: flex;
|
||||
@@ -150,7 +150,7 @@
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
.seat__foot .tt-anchor { position: absolute; right: .14rem; bottom: .14rem; }
|
||||
.seat__state { display: flex; align-items: center; gap: .06rem; font-size: .18rem; font-weight: 700; letter-spacing: .06em; }
|
||||
.seat__state { display: flex; align-items: center; gap: .06rem; font-size: calc(.18 * var(--t)); font-weight: 700; letter-spacing: .06em; }
|
||||
.seat__state .icon { width: .22rem; height: .22rem; }
|
||||
.seat__state--ready { color: var(--mint-400); }
|
||||
.seat__state--waiting { color: var(--text-lo); }
|
||||
@@ -185,7 +185,7 @@
|
||||
animation: radar-sweep 4s linear infinite;
|
||||
}
|
||||
@keyframes radar-sweep { to { transform: rotate(360deg); } }
|
||||
.seat__wait { font-size: .2rem; color: var(--text-lo); letter-spacing: .08em; }
|
||||
.seat__wait { font-size: calc(.2 * var(--t)); color: var(--text-lo); letter-spacing: .08em; }
|
||||
.seat--empty .seat__foot { border-top-style: dashed; }
|
||||
|
||||
.solo-brief {
|
||||
@@ -202,9 +202,9 @@
|
||||
border: 1px solid var(--line);
|
||||
animation: seat-in var(--t-slow) 80ms var(--ease-out) backwards;
|
||||
}
|
||||
.solo-brief h2 { font-size: .36rem; font-weight: 900; letter-spacing: .06em; }
|
||||
.solo-brief p { font-size: .18rem; color: var(--text-md); }
|
||||
.solo-brief ul { margin: .06rem 0 0; padding: 0; list-style: none; display: flex; flex-direction: column; gap: .08rem; font-size: .16rem; color: var(--text-lo); }
|
||||
.solo-brief h2 { font-size: calc(.36 * var(--t)); font-weight: 900; letter-spacing: .06em; }
|
||||
.solo-brief p { font-size: calc(.18 * var(--t)); color: var(--text-md); }
|
||||
.solo-brief ul { margin: .06rem 0 0; padding: 0; list-style: none; display: flex; flex-direction: column; gap: .08rem; font-size: calc(.16 * var(--t)); color: var(--text-lo); }
|
||||
.solo-brief li::before { content: ''; display: inline-block; width: .06rem; height: .06rem; margin: 0 .12rem .02rem 0; background: var(--mint-600); transform: rotate(45deg); vertical-align: middle; }
|
||||
.solo-brief b { color: var(--text-hi); }
|
||||
|
||||
@@ -217,7 +217,7 @@
|
||||
justify-content: center;
|
||||
gap: .14rem;
|
||||
padding: 0 .6rem .16rem;
|
||||
font-size: .15rem;
|
||||
font-size: calc(.15 * var(--t));
|
||||
color: var(--text-lo);
|
||||
}
|
||||
.specbar__label { display: flex; align-items: baseline; gap: .06rem; font-weight: 700; color: var(--text-md); }
|
||||
@@ -260,10 +260,10 @@
|
||||
background: linear-gradient(90deg, var(--mint-700), transparent 30%, transparent 70%, var(--mint-700));
|
||||
}
|
||||
.room-bar__left { display: flex; align-items: center; gap: .22rem; min-width: 0; }
|
||||
.room-bar__label { display: flex; flex-direction: column; font-size: .17rem; font-weight: 700; color: var(--text-md); line-height: 1.35; }
|
||||
.room-bar__label { display: flex; flex-direction: column; font-size: calc(.17 * var(--t)); font-weight: 700; color: var(--text-md); line-height: 1.35; }
|
||||
.room-bar__center { display: flex; flex-direction: column; align-items: center; gap: .06rem; }
|
||||
.room-bar__right { justify-self: end; display: flex; align-items: center; gap: .16rem; }
|
||||
.room-bar__status { font-size: .15rem; }
|
||||
.room-bar__status { font-size: calc(.15 * var(--t)); }
|
||||
.room-bar__status .icon { width: .18rem; height: .18rem; margin-right: .05rem; vertical-align: -.03rem; }
|
||||
|
||||
.dpick { display: flex; border: 1px solid var(--line-2); background: rgba(0, 0, 0, .3); }
|
||||
@@ -279,7 +279,7 @@
|
||||
border-right: 1px solid var(--line);
|
||||
background: transparent;
|
||||
color: var(--text-lo);
|
||||
font-size: .18rem;
|
||||
font-size: calc(.18 * var(--t));
|
||||
font-weight: 700;
|
||||
letter-spacing: .08em;
|
||||
transition: color var(--t-fast), background-color var(--t-med), box-shadow var(--t-med);
|
||||
@@ -295,12 +295,19 @@
|
||||
box-shadow: inset 0 -3px 0 var(--d-color);
|
||||
}
|
||||
.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; }
|
||||
.dpick--ro { border: 0; background: none; gap: .18rem; align-items: center; font-size: calc(.14 * var(--t)); }
|
||||
/* 「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: calc(.14 * var(--t)); }
|
||||
.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: calc(.15 * var(--t)); 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; }
|
||||
.ready-count b { font-size: .36rem; line-height: 1; color: var(--mint-400); text-shadow: 0 0 .14rem rgba(23, 249, 183, .45); }
|
||||
.ready-count .t-dim { font-size: .22rem; }
|
||||
.ready-count { display: flex; align-items: baseline; gap: .06rem; font-size: calc(.16 * var(--t)); }
|
||||
.ready-count b { font-size: calc(.36 * var(--t)); line-height: 1; color: var(--mint-400); text-shadow: 0 0 .14rem rgba(23, 249, 183, .45); }
|
||||
.ready-count .t-dim { font-size: calc(.22 * var(--t)); }
|
||||
.ready-count__icons { display: flex; gap: .02rem; margin-left: .12rem; align-self: center; }
|
||||
.ready-count__icons .icon { width: .28rem; height: .28rem; color: var(--line-3); transition: color var(--t-med); }
|
||||
.ready-count__icons .icon.is-on { color: var(--mint-400); filter: drop-shadow(0 0 4px var(--mint-glow)); }
|
||||
|
||||
@@ -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; } }
|
||||
@@ -197,7 +197,7 @@
|
||||
.title-cn em { font-style: normal; color: var(--mint-500); text-shadow: 0 0 .26rem rgba(23, 249, 183, .45); }
|
||||
.title-tag {
|
||||
margin-top: .12rem;
|
||||
font-size: .19rem;
|
||||
font-size: calc(.19 * var(--t));
|
||||
color: var(--text-lo);
|
||||
letter-spacing: .1em;
|
||||
animation: rise-in 600ms 200ms var(--ease-out) backwards;
|
||||
@@ -238,18 +238,23 @@
|
||||
background: rgba(246, 163, 41, .08);
|
||||
border: 1px solid rgba(246, 163, 41, .35);
|
||||
color: var(--amber);
|
||||
font-size: .16rem;
|
||||
font-size: calc(.16 * var(--t));
|
||||
}
|
||||
.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); }
|
||||
/* language menu (中文 | English | …, ui/lang.js) in the top-right corner; more than four languages make it a list */
|
||||
.title-invite b { font-size: calc(.2 * var(--t)); letter-spacing: .2em; color: #ffd08a; }
|
||||
.title-conn { display: flex; align-items: center; justify-content: center; gap: .1rem; min-height: .32rem; font-size: calc(.14 * var(--t)); 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.
|
||||
The height is max(design, 1.4em) so the buttons still hold their text at 设置 →「文字大小」 大 / 特大 (on a phone
|
||||
1rem is clamped at 40 px and .3rem is only 12 px, less than the enlarged label). */
|
||||
.title-lang { display: inline-flex; margin-bottom: .1rem; }
|
||||
.title-lang button { min-width: .74rem; height: .3rem; font-size: .14rem; cursor: pointer; }
|
||||
.lang-select select { height: .38rem; min-width: 1.6rem; padding: 0 .12rem; background: #0a0d0c; border: 0; color: var(--text-hi); font-size: .16rem; cursor: pointer; }
|
||||
.title-lang.lang-select select { height: .3rem; font-size: .14rem; }
|
||||
.title-lang button { min-width: .74rem; height: max(.3rem, 1.4em); font-size: calc(.14 * var(--t)); cursor: pointer; }
|
||||
.lang-select select { height: max(.38rem, 1.4em); min-width: 1.6rem; padding: 0 .12rem; background: #0a0d0c; border: 0; color: var(--text-hi); font-size: calc(.16 * var(--t)); cursor: pointer; }
|
||||
.title-lang.lang-select select { height: max(.3rem, 1.4em); font-size: calc(.14 * var(--t)); }
|
||||
html[data-script="alphabetic"] .title-login .btn--xl { letter-spacing: .3em; }
|
||||
.title-conn .ping { height: .26rem; font-size: .15rem; }
|
||||
.title-conn .ping { height: max(.26rem, 1.6em); font-size: calc(.15 * var(--t)); }
|
||||
|
||||
.title-foot {
|
||||
position: absolute;
|
||||
@@ -260,7 +265,7 @@ html[data-script="alphabetic"] .title-login .btn--xl { letter-spacing: .3em; }
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-end;
|
||||
font-size: .13rem;
|
||||
font-size: calc(.13 * var(--t));
|
||||
color: var(--text-dim);
|
||||
}
|
||||
/* a development build (shared/constants.js DEV_BUILD): an unmissable tag in the footer */
|
||||
|
||||
@@ -125,6 +125,22 @@ html {
|
||||
background: var(--bg-0);
|
||||
}
|
||||
|
||||
/* ---- text root: 设置 →「文字大小」 ---------------------------------------------------------------
|
||||
1rem above is the LAYOUT root, and five modules convert it to pixels with getComputedStyle:
|
||||
ui/fieldHost.js hudPadding / hudBands (the prep camera's HUD clearance), ui/gameLogic/panel.js
|
||||
panelSlots (which side the detail card docks), ui/underframe.js (the tile labels' pixel budget) and
|
||||
ui/fallbackField.js (the DOM board) — raising it would move the board, not just the text.
|
||||
--t is the TEXT root: it appears only in font-size declarations, so a player can enlarge the
|
||||
interface text without touching any layout. The field itself is sized from host.clientWidth /
|
||||
clientHeight (render/app.js), which no font-size can reach.
|
||||
小 = 1rem reproduces the design's own sizes exactly, on every device. The other steps keep the
|
||||
desktop gentle and lift the phone: 1rem there is clamped at 40 px by the rule above, which is what
|
||||
made the .18rem body text 7.2 CSS px. */
|
||||
:root { --t: 1rem; }
|
||||
:root[data-text="md"] { --t: max(1rem, 64px); }
|
||||
:root[data-text="lg"] { --t: max(calc(1rem * 1.15), 76px); }
|
||||
:root[data-text="xl"] { --t: max(calc(1rem * 1.3), 88px); }
|
||||
|
||||
*, *::before, *::after { box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
@@ -134,7 +150,7 @@ body {
|
||||
background: var(--bg-0);
|
||||
color: var(--text-hi);
|
||||
font-family: var(--font-cjk);
|
||||
font-size: .18rem;
|
||||
font-size: calc(.18 * var(--t));
|
||||
line-height: 1.5;
|
||||
font-weight: 400;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
@@ -170,7 +186,7 @@ h1, h2, h3, h4, p { margin: 0; }
|
||||
.display { font-family: var(--font-display); text-transform: uppercase; letter-spacing: .12em; }
|
||||
.micro {
|
||||
font-family: var(--font-display);
|
||||
font-size: .11rem;
|
||||
font-size: calc(.11 * var(--t));
|
||||
line-height: 1.2;
|
||||
letter-spacing: .18em;
|
||||
text-transform: uppercase;
|
||||
|
||||
@@ -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>> 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>
|
||||
@@ -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}`;
|
||||
});
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
@@ -472,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 });
|
||||
@@ -489,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 }]); }
|
||||
@@ -508,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
|
||||
@@ -517,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);
|
||||
}
|
||||
|
||||
@@ -575,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 {};
|
||||
}
|
||||
@@ -586,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 {};
|
||||
}
|
||||
@@ -661,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');
|
||||
@@ -669,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');
|
||||
@@ -676,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 {};
|
||||
}
|
||||
@@ -706,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 {};
|
||||
}
|
||||
}
|
||||
@@ -780,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();
|
||||
@@ -790,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));
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
@@ -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",
|
||||
@@ -235,7 +239,7 @@
|
||||
"继续作战": "Resume Battle",
|
||||
"正在载入模拟数据…": "Loading simulation data...",
|
||||
"正在进入模拟…": "Entering simulation...",
|
||||
"当前设备无法启用 3D / WebGL 渲染,已切换为简化视图(功能不受影响)": "3D / WebGL rendering is unavailable on this device; switched to a simplified view (all features still work)",
|
||||
"渲染器暂时未能加载,已切换为简化视图(功能不受影响,可刷新重试)": "The renderer could not load. Switched to the simplified view (all features remain available; reload to retry).",
|
||||
"已配发的装备无法销毁": "Equipped Equipment cannot be destroyed",
|
||||
"销毁道具": "Destroy Item",
|
||||
"道具": "Item",
|
||||
@@ -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\".",
|
||||
@@ -1055,5 +1094,123 @@
|
||||
"选择「{name}」的朝向": "Choose {name}'s facing",
|
||||
"选择朝向": "Choose a facing",
|
||||
"朝向:{dir}": "Facing: {dir}",
|
||||
"开发版 · 不稳定,请勿用于公开服务器": "Development build · unstable, not for public servers"
|
||||
"开发版 · 不稳定,请勿用于公开服务器": "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.",
|
||||
"文字大小": "Text Size",
|
||||
"小": "Small",
|
||||
"大": "Large",
|
||||
"特大": "Extra Large",
|
||||
"调整界面文字大小,棋盘保持原比例。": "Adjust interface text size while keeping the board at its original scale.",
|
||||
"此干员语音": "Operator voice",
|
||||
"跟随全局": "Use global setting",
|
||||
"仅保存在此浏览器,缺失的日语语音会回退到中文。": "Saved in this browser only. Missing Japanese lines use Chinese.",
|
||||
"添加到桌面": "Add to Home Screen",
|
||||
"恢复本机对局": "Resume local match",
|
||||
"此对局仍在其他窗口中,或当前浏览器无法安全恢复。请关闭原窗口后重试。": "This match is still open in another window, or this browser cannot safely resume it. Close the original window and retry.",
|
||||
"选择已关闭窗口的对局。服务器重启或对局过期后无法恢复。": "Select a match from a closed window. Matches cannot be resumed after a server restart or expiry.",
|
||||
"{name} · 同盟 {code}": "{name} · Alliance {code}",
|
||||
"重刷本局": "Reroll setup",
|
||||
"重刷本局投票:{agreed}/{total}": "Reroll vote: {agreed}/{total}",
|
||||
"全员同意后重新随机战场、领袖、特训敌人和禁用;确认倒计时已暂停。": "With unanimous consent, reroll the field, bosses, training enemies and bans. The confirmation timer is paused.",
|
||||
"保留房间和个人配置,重新随机全部开局信息。": "Keep the room and player settings, and reroll the full opening setup.",
|
||||
"已同意": "Agreed",
|
||||
"待同意": "Waiting",
|
||||
"全体真人玩家一致同意后刷新,随机结果可能重复。": "All human players must agree. Random results may repeat.",
|
||||
"请等待所有玩家连接后再发起投票": "Wait for all players to connect before starting a vote.",
|
||||
"同意重刷": "Agree to reroll",
|
||||
"拒绝重刷": "Reject reroll",
|
||||
"取消投票": "Cancel vote",
|
||||
"只能部署在召唤者相邻四格": "Can only be deployed on the four tiles adjacent to the summoner",
|
||||
"{names}只能部署在召唤者相邻四格,已退回整备区": "{names} can only be deployed on the four tiles adjacent to the summoner and returned to the Bench",
|
||||
"{names}只能部署在召唤者相邻四格,整备区已满,下回合返还": "{names} can only be deployed on the four tiles adjacent to the summoner; the Bench is full, so they come back next round",
|
||||
"装备库存不足,未获得该装备": "Equipment stock is insufficient; no equipment was received",
|
||||
"库存不足": "Out of stock"
|
||||
}
|
||||
@@ -110,6 +110,9 @@
|
||||
"复制密钥": "コードをコピー",
|
||||
"复制链接": "リンクをコピー",
|
||||
"由创建者选择": "ホストが選択",
|
||||
"由创建者设置": "ホストが設定",
|
||||
"AI 队友最后选择": "AI チームメイトは最後に選択",
|
||||
"策略与机变轮选时,所有博士先于 AI 队友选择": "戦術と調整の順番選択では、すべてのドクターが AI チームメイトより先に選びます",
|
||||
"连接中断,请稍候重试": "接続が切断されました。しばらくしてから再試行してください",
|
||||
"离开同盟": "同盟から退出",
|
||||
"你是同盟的创建者,离开后创建者身份将移交或同盟解散。确定离开吗?": "あなたはこの同盟のホストです。退出するとホストが他のドクターに移るか、同盟が解散します。退出しますか?",
|
||||
@@ -154,6 +157,7 @@
|
||||
"完成": "完了",
|
||||
"背景音乐": "BGM",
|
||||
"干员语音": "オペレーターボイス",
|
||||
"语音语言": "ボイス言語",
|
||||
"音效": "効果音",
|
||||
"静音": "ミュート",
|
||||
"显示伤害数字": "ダメージ数値を表示",
|
||||
@@ -196,7 +200,7 @@
|
||||
"该队友当前没有战场": "この同盟者には現在戦場がありません",
|
||||
"队友与你在同一战场,使用 ‹ › 切换视角": "同盟者はあなたと同じ戦場にいます。‹ › で視点を切り替えます",
|
||||
"无法查看另一组队友的战场": "もう一組の同盟者の戦場は確認できません",
|
||||
"作战中无法查看队友,作战结束后可前往查看": "戦闘中は同盟者を確認できません。戦闘終了後に確認できます",
|
||||
"作战中无法查看队友,作战结束后可前往查看": "戦闘中は同盟者を確認できません。作戦終了後に確認できます",
|
||||
"无人在家": "不在",
|
||||
"你自己": "自分",
|
||||
"全景": "全体",
|
||||
@@ -241,7 +245,7 @@
|
||||
"继续作战": "戦闘を再開",
|
||||
"正在载入模拟数据…": "シミュレーションデータを読み込み中…",
|
||||
"正在进入模拟…": "シミュレーションに進入中…",
|
||||
"当前设备无法启用 3D / WebGL 渲染,已切换为简化视图(功能不受影响)": "この端末では3D / WebGLで描画できないため、簡易表示に切り替えました(機能に影響はありません)",
|
||||
"渲染器暂时未能加载,已切换为简化视图(功能不受影响,可刷新重试)": "描画エンジンを読み込めなかったため簡易表示に切り替えました(機能はそのまま利用できます。再読み込みで再試行)。",
|
||||
"已配发的装备无法销毁": "装備済みの装備品は破棄できません",
|
||||
"销毁道具": "アイテムを破棄",
|
||||
"道具": "アイテム",
|
||||
@@ -358,7 +362,7 @@
|
||||
"{owner} 的盟约": "{owner}の盟約",
|
||||
"我的盟约": "自分の盟約",
|
||||
"(含调和 +{harmony})": "(調和 +{harmony} を含む)",
|
||||
"层数叠加已禁用": "加算数は増加しません",
|
||||
"层数叠加已禁用": "盟約加算無効化中",
|
||||
"调和": "調和",
|
||||
"层数": "加算数",
|
||||
"盟约效果": "盟約効果",
|
||||
@@ -385,6 +389,12 @@
|
||||
"CONTINGENCY // 机变阶段": "CONTINGENCY // 調整段階",
|
||||
"选择一项": "1つ選択",
|
||||
"倒计时结束后仍未选定将自动分配": "カウントダウン終了までに選ばない場合、自動で割り当てられます",
|
||||
"教鞭选择": "教鞭の選択",
|
||||
"教鞭 · 战术特训": "教鞭 · 戦術訓練",
|
||||
"请选择一项战术特训": "戦術訓練を1つ選択してください",
|
||||
"请先完成教鞭选择": "先に教鞭の選択を完了してください",
|
||||
"休整期结束时未选择将自动选定": "未選択の場合、整備フェーズ終了時に自動で選択されます",
|
||||
"从候选中选择一项战术特训": "候補から戦術訓練を1つ選択",
|
||||
"选择一项(无时间限制)": "1つ選択(時間制限なし)",
|
||||
" · 点击卡牌选中,再次点击确认": " · カードをタップで選択、もう一度タップで確定",
|
||||
"已完成选择": "選択完了",
|
||||
@@ -398,8 +408,8 @@
|
||||
"再次点击": "もう一度タップ",
|
||||
"选择中": "選択中",
|
||||
"队友作战进度": "同盟者の戦闘状況",
|
||||
"作战结束": "戦闘終了",
|
||||
"作战结束,等待队友完成作战": "戦闘終了。同盟者の戦闘終了を待っています",
|
||||
"作战结束": "作戦終了",
|
||||
"作战结束,等待队友完成作战": "作戦終了。同盟者の作戦終了を待っています",
|
||||
"你已被淘汰,正在观战": "あなたは脱落しました。観戦中",
|
||||
"上一个战场": "前の戦場",
|
||||
"下一个战场": "次の戦場",
|
||||
@@ -432,7 +442,7 @@
|
||||
"辅助": "補助",
|
||||
"特种": "特殊",
|
||||
"召唤物": "召喚物",
|
||||
"自动回复": "自動回復",
|
||||
"自动回复": "自然回復",
|
||||
"攻击回复": "攻撃回復",
|
||||
"受击回复": "被撃回復",
|
||||
"被动": "パッシブ",
|
||||
@@ -508,7 +518,7 @@
|
||||
"团队增益": "連合バフ",
|
||||
"生效中的效果": "発動中の効果",
|
||||
"表情": "スタンプ",
|
||||
"交流": "通信",
|
||||
"交流": "交流",
|
||||
"上一组表情": "前のスタンプ",
|
||||
"下一组表情": "次のスタンプ",
|
||||
"表情主题": "スタンプのテーマ",
|
||||
@@ -563,7 +573,7 @@
|
||||
"被源石侵蚀的假想敌": "源石に侵食された仮想敵",
|
||||
"回合开始": "ラウンド開始",
|
||||
"回合结算": "ラウンド集計",
|
||||
"休息一下": "ひと休み",
|
||||
"休息一下": "休憩タイム",
|
||||
"找不到该单位": "ユニットが見つかりません",
|
||||
"无法放置在该位置": "この位置には置けません",
|
||||
"位置未变化": "位置が変わっていません",
|
||||
@@ -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}つをすべてクリアしますか?",
|
||||
"文件过大,请选择「导出」下载的自选编队文件": "ファイルが大きすぎます。「エクスポート」でダウンロードした自由編成ファイルを選んでください",
|
||||
@@ -1059,7 +1098,125 @@
|
||||
"导入失败:游戏数据没有载入,未做任何改动。请刷新页面;仍不行时,请检查广告拦截插件和网络": "インポート失敗:ゲームデータが読み込まれていないため、何も変更していません。ページを更新してください。それでも解決しない場合は、広告ブロッカーとネットワークを確認してください",
|
||||
"游戏数据没有载入": "ゲームデータが読み込まれていません",
|
||||
"{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・未昇進に設定してください。",
|
||||
"文字大小": "文字サイズ",
|
||||
"小": "小",
|
||||
"大": "大",
|
||||
"特大": "特大",
|
||||
"调整界面文字大小,棋盘保持原比例。": "画面の文字サイズを変更します。盤面の縮尺は変わりません。",
|
||||
"此干员语音": "このオペレーターの音声",
|
||||
"跟随全局": "全体設定に従う",
|
||||
"仅保存在此浏览器,缺失的日语语音会回退到中文。": "このブラウザーのみに保存されます。日本語音声がない場合は中国語に切り替わります。",
|
||||
"添加到桌面": "ホーム画面に追加",
|
||||
"恢复本机对局": "この端末の対戦を復帰",
|
||||
"此对局仍在其他窗口中,或当前浏览器无法安全恢复。请关闭原窗口后重试。": "別のウィンドウで対戦中か、このブラウザーでは安全に復帰できません。元のウィンドウを閉じて再試行してください。",
|
||||
"选择已关闭窗口的对局。服务器重启或对局过期后无法恢复。": "閉じたウィンドウの対戦を選択してください。サーバー再起動後や期限切れの場合は復帰できません。",
|
||||
"{name} · 同盟 {code}": "{name} · 同盟 {code}",
|
||||
"重刷本局": "開始条件を再抽選",
|
||||
"重刷本局投票:{agreed}/{total}": "再抽選の投票:{agreed}/{total}",
|
||||
"全员同意后重新随机战场、领袖、特训敌人和禁用;确认倒计时已暂停。": "全員の同意で戦場、ボス、特訓の敵と禁止対象を再抽選します。確認の制限時間は一時停止中です。",
|
||||
"保留房间和个人配置,重新随机全部开局信息。": "ルームと個人設定を維持して、開始条件をすべて再抽選します。",
|
||||
"已同意": "同意済み",
|
||||
"待同意": "同意待ち",
|
||||
"全体真人玩家一致同意后刷新,随机结果可能重复。": "人間プレイヤー全員の同意が必要です。同じ結果になる場合があります。",
|
||||
"请等待所有玩家连接后再发起投票": "全プレイヤーの接続を待ってから投票を開始してください。",
|
||||
"同意重刷": "再抽選に同意",
|
||||
"拒绝重刷": "再抽選を拒否",
|
||||
"取消投票": "投票を取り消す",
|
||||
"只能部署在召唤者相邻四格": "召喚者に隣接する上下左右の4マスにのみ配置可能",
|
||||
"{names}只能部署在召唤者相邻四格,已退回整备区": "{names}は召喚者に隣接する上下左右の4マスにのみ配置可能なため、ベンチに戻りました",
|
||||
"{names}只能部署在召唤者相邻四格,整备区已满,下回合返还": "{names}は召喚者に隣接する上下左右の4マスにのみ配置可能です。ベンチが満員のため、次のラウンドに返却されます",
|
||||
"装备库存不足,未获得该装备": "装備の在庫が不足しているため、獲得できませんでした",
|
||||
"库存不足": "在庫不足"
|
||||
}
|
||||
@@ -108,6 +108,9 @@
|
||||
"复制密钥": "複製金鑰",
|
||||
"复制链接": "複製連結",
|
||||
"由创建者选择": "由建立者選擇",
|
||||
"由创建者设置": "由建立者設定",
|
||||
"AI 队友最后选择": "AI 隊友最後選擇",
|
||||
"策略与机变轮选时,所有博士先于 AI 队友选择": "策略與機變輪選時,所有博士先於 AI 隊友選擇",
|
||||
"连接中断,请稍候重试": "連線中斷,請稍候重試",
|
||||
"离开同盟": "離開同盟",
|
||||
"你是同盟的创建者,离开后创建者身份将移交或同盟解散。确定离开吗?": "你是同盟的建立者,離開後建立者身分將移交或同盟解散。確定離開嗎?",
|
||||
@@ -152,6 +155,7 @@
|
||||
"完成": "完成",
|
||||
"背景音乐": "背景音樂",
|
||||
"干员语音": "幹員語音",
|
||||
"语音语言": "語音語言",
|
||||
"音效": "音效",
|
||||
"静音": "靜音",
|
||||
"显示伤害数字": "顯示傷害數字",
|
||||
@@ -239,7 +243,7 @@
|
||||
"继续作战": "繼續作戰",
|
||||
"正在载入模拟数据…": "正在載入模擬資料…",
|
||||
"正在进入模拟…": "正在進入模擬…",
|
||||
"当前设备无法启用 3D / WebGL 渲染,已切换为简化视图(功能不受影响)": "目前的裝置無法啟用 3D / WebGL 渲染,已切換為簡化檢視(功能不受影響)",
|
||||
"渲染器暂时未能加载,已切换为简化视图(功能不受影响,可刷新重试)": "繪圖引擎暫時未能載入,已切換為簡化檢視(功能不受影響,可重新整理再試)",
|
||||
"已配发的装备无法销毁": "已配發的裝備無法銷毀",
|
||||
"销毁道具": "銷毀道具",
|
||||
"道具": "道具",
|
||||
@@ -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,123 @@
|
||||
"游戏数据没有载入": "遊戲資料沒有載入",
|
||||
"{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、未精英化。",
|
||||
"文字大小": "文字大小",
|
||||
"小": "小",
|
||||
"大": "大",
|
||||
"特大": "特大",
|
||||
"调整界面文字大小,棋盘保持原比例。": "調整介面文字大小,棋盤保持原比例。",
|
||||
"此干员语音": "此幹員語音",
|
||||
"跟随全局": "跟隨全域",
|
||||
"仅保存在此浏览器,缺失的日语语音会回退到中文。": "僅儲存在此瀏覽器,缺失的日語語音會回退到中文。",
|
||||
"添加到桌面": "加入主畫面",
|
||||
"恢复本机对局": "恢復本機對局",
|
||||
"此对局仍在其他窗口中,或当前浏览器无法安全恢复。请关闭原窗口后重试。": "此對局仍在其他視窗中,或目前瀏覽器無法安全恢復。請關閉原視窗後重試。",
|
||||
"选择已关闭窗口的对局。服务器重启或对局过期后无法恢复。": "選擇已關閉視窗的對局。伺服器重啟或對局過期後無法恢復。",
|
||||
"{name} · 同盟 {code}": "{name} · 同盟 {code}",
|
||||
"重刷本局": "重刷本局",
|
||||
"重刷本局投票:{agreed}/{total}": "重刷本局投票:{agreed}/{total}",
|
||||
"全员同意后重新随机战场、领袖、特训敌人和禁用;确认倒计时已暂停。": "全員同意後重新隨機戰場、領袖、特訓敵人和禁用;確認倒數已暫停。",
|
||||
"保留房间和个人配置,重新随机全部开局信息。": "保留房間和個人配置,重新隨機全部開局資訊。",
|
||||
"已同意": "已同意",
|
||||
"待同意": "待同意",
|
||||
"全体真人玩家一致同意后刷新,随机结果可能重复。": "全體真人玩家一致同意後刷新,隨機結果可能重複。",
|
||||
"请等待所有玩家连接后再发起投票": "請等待所有玩家連線後再發起投票",
|
||||
"同意重刷": "同意重刷",
|
||||
"拒绝重刷": "拒絕重刷",
|
||||
"取消投票": "取消投票",
|
||||
"只能部署在召唤者相邻四格": "只能部署在召喚者相鄰四格",
|
||||
"{names}只能部署在召唤者相邻四格,已退回整备区": "{names}只能部署在召喚者相鄰四格,已退回整備區",
|
||||
"{names}只能部署在召唤者相邻四格,整备区已满,下回合返还": "{names}只能部署在召喚者相鄰四格,整備區已滿,下回合返還",
|
||||
"装备库存不足,未获得该装备": "裝備庫存不足,未獲得該裝備",
|
||||
"库存不足": "庫存不足"
|
||||
}
|
||||
|
After Width: | Height: | Size: 884 B |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
@@ -0,0 +1,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512">
|
||||
<title>Stronghold Protocol — refined mint rook</title>
|
||||
<desc>Refines the existing 0.2.x project castle: three battlements, inset tower and splayed base. Original project geometry; no official artwork, lettering, gradients or texture.</desc>
|
||||
<rect width="512" height="512" fill="#0c0f0e"/>
|
||||
<g id="mark">
|
||||
<polygon fill="#4ed8af" points="128,88 184,88 184,128 224,128 224,88 288,88 288,128 328,128 328,88 384,88 384,184 344,224 344,352 384,392 384,424 128,424 128,392 168,352 168,224 128,184"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 608 B |
|
After Width: | Height: | Size: 1021 B |
|
After Width: | Height: | Size: 275 B |
|
After Width: | Height: | Size: 356 B |
|
After Width: | Height: | Size: 422 B |
|
After Width: | Height: | Size: 11 KiB |
@@ -15,7 +15,12 @@
|
||||
<meta name="apple-mobile-web-app-title" content="卫戍协议" />
|
||||
<meta name="description" content="卫戍协议:盟约 · 网页联机复刻(非官方同人作品)" />
|
||||
<title>卫戍协议:盟约 · STRONGHOLD PROTOCOL</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="manifest" href="/manifest.json" />
|
||||
<link rel="icon" href="/icons/favicon.ico" sizes="16x16 32x32 48x48" />
|
||||
<link rel="icon" href="/icons/favicon-16.png" type="image/png" sizes="16x16" />
|
||||
<link rel="icon" href="/icons/favicon-32.png" type="image/png" sizes="32x32" />
|
||||
<link rel="icon" href="/icons/app.svg" type="image/svg+xml" sizes="any" />
|
||||
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png" sizes="180x180" />
|
||||
|
||||
<!-- Web fonts: never render-blocking (LAN/offline play must still start); system fallbacks in theme.css. -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
@@ -38,6 +43,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" />
|
||||
|
||||
@@ -68,6 +74,7 @@
|
||||
<div class="rotate-hint__phone"></div>
|
||||
<div class="rotate-hint__title">请将设备横屏</div>
|
||||
<div>卫戍协议:盟约 需要横屏显示以获得完整体验</div>
|
||||
<button type="button" hidden class="btn btn--secondary btn--sm rotate-hint__pwa" id="rotate-hint-pwa">添加到桌面</button>
|
||||
<div class="rotate-hint__micro">ROTATE DEVICE</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -226,7 +226,11 @@ export function bgmEntry(m, kind) {
|
||||
/** SFX URL: group 'ui' | 'battle', key e.g. 'buy'. */
|
||||
export const sfxUrl = (m, group, key) => str(get(get(get(get(m, 'audio'), 'sfx'), str(group) || ''), str(key) || ''));
|
||||
|
||||
/** Per-unit SFX: kind 'attack' | 'hit' | 'skill' | 'die' | 'born'; `skillIndex` picks `skills[i]` when present. */
|
||||
/**
|
||||
* Per-unit SFX: kind 'attack' | 'hit' | 'skill' | 'die' | 'born'; `skillIndex` picks `skills[i]` and — for an attack or
|
||||
* an impact — `attacks[i]` / `hits[i]` (the running skill's own files, tools/assets/audio.mjs pickModeAttacks /
|
||||
* pickModeHits) when the manifest has them.
|
||||
*/
|
||||
export function unitSfxUrl(m, id, kind, skillIndex) {
|
||||
const u = get(get(get(get(m, 'audio'), 'sfx'), 'units'), str(id) || '') || get(get(get(get(m, 'audio'), 'sfx'), 'units'), baseCharId(id) || '');
|
||||
if (!isObj(u)) return null;
|
||||
@@ -234,6 +238,11 @@ export function unitSfxUrl(m, id, kind, skillIndex) {
|
||||
const s = str(u.skills[skillIndex]);
|
||||
if (s) return s;
|
||||
}
|
||||
const per = kind === 'attack' ? u.attacks : kind === 'hit' ? u.hits : null;
|
||||
if (Number.isInteger(skillIndex) && isObj(per)) {
|
||||
const a = str(per[skillIndex]);
|
||||
if (a) return a;
|
||||
}
|
||||
return str(u[kind]);
|
||||
}
|
||||
|
||||
@@ -831,10 +840,11 @@ export function createAssets(options) {
|
||||
});
|
||||
|
||||
/** Image element (cached; failures resolve to null). */
|
||||
function image(u) {
|
||||
function image(u, { retry = false } = {}) {
|
||||
const s = str(u);
|
||||
if (!s) return Promise.resolve(null);
|
||||
let e = images.get(s);
|
||||
if (retry && e?.done && !e.value) { images.delete(s); e = null; }
|
||||
if (!e) {
|
||||
e = { value: null, done: false, promise: null };
|
||||
e.promise = Promise.resolve().then(() => loadImage(s)).then(
|
||||
|
||||
@@ -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
|
||||
@@ -34,6 +43,16 @@
|
||||
// sound of that target. An operator's attack / hit sound that is a skill-mode file of its own (official names end in
|
||||
// `_n` for the normal attack, `_d` / `_h` / `_s` for its skill modes — the manifest picked 纯烬艾雅法拉's S3 impact
|
||||
// p_imp_gtshpbrnch_s as her `hit`) never plays for a normal attack (normalAttackSfx).
|
||||
// - The attack sound of the RUNNING skill (`attacks`, 技能1 / 2 / 3 = `_d` / `_h` / `_s`): an operator whose every attack
|
||||
// belongs to a skill mode has no normal attack bank at all — 司霆惊蛰 (解放者: she only attacks while a skill runs) was
|
||||
// completely silent when she attacked (report 「三技能攻击没有音效」). The manifest then carries that mode's own file
|
||||
// per skill index (`attacks`, tools/assets/audio.mjs pickModeAttacks) and it plays while the skill is active, from the
|
||||
// `['skill', id, 1]` / `0` events; a unit with a normal bank too (能天使 S2 过载模式, 史尔特尔 S2) keeps that bank
|
||||
// outside the skill. Being the official file of that very skill, it is not the normalAttackSfx case above.
|
||||
// - …and its impact (`hits`): a mode's swing is not always the whole difference — 赤刃明霄陈 S3's slashes swing
|
||||
// p_atk_hljdswd_s AND land p_imp_hljdswd_s, while her normal attack uses p_atk_hljdswd_n / p_imp_hljdswd_n
|
||||
// (report 「开启三技能后斩击音效应该和普通攻击不一样」). Same mechanism and the same window: the `hit` of an attack
|
||||
// aimed while the skill ran — the attacker's state travels with `lastAttacker`.
|
||||
// - The official bank mix of a unit's own attack / hit / die / born sound (`mix`, tools/assets/audio.mjs bankMix; community
|
||||
// report #30): it plays with chance `p` — 猎狗pro / 深池侦察犬's attack bank is 80 % silence, so they bark on about one
|
||||
// attack in five (never replaced by the generic enemy sound) — at its base gain × `vol`, capped at 1: an official volume
|
||||
@@ -46,6 +65,7 @@
|
||||
// `bgmKeyFor(route, pub)` picks the track for the current screen/phase (main.js calls `audio.install()`,
|
||||
// which follows the store).
|
||||
|
||||
import { sanitizeVoiceOverrides, voiceLangFor } from './voicePrefs.js';
|
||||
import { PHASE } from '../../shared/constants.js';
|
||||
import { mediaUrl } from './media.js';
|
||||
|
||||
@@ -76,6 +96,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 +270,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 +281,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 +428,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 +461,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 +476,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,13 +525,17 @@ export class AudioManager {
|
||||
this.voiceGate = new VoiceGate();
|
||||
this.voiceNode = null; // { src, gain, url, token } of the line on air
|
||||
this.voiceToken = 0;
|
||||
this.voiceOverrides = {};
|
||||
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.lastAttacker = new Map(); // target id → { def, at } of the hostile attack last aimed at it (its impact sound)
|
||||
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, skillIndex, skillActive } of the hostile attack aimed at it (its impact sound,
|
||||
// including the running skill's own `hits` — 赤刃明霄陈 S3's slash)
|
||||
this.consumed = new Set(); // summons used up by their own effect (香槟炸弹 exploded): no death sound
|
||||
this.installed = false;
|
||||
this._unlock = this._unlock.bind(this);
|
||||
@@ -596,6 +668,16 @@ 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, overrides = this.voiceOverrides) {
|
||||
this.voiceLang = lang === 'jp' ? 'jp' : 'cn';
|
||||
this.voiceOverrides = sanitizeVoiceOverrides(overrides);
|
||||
}
|
||||
|
||||
_applyVolumes() {
|
||||
if (!this.ctx) return;
|
||||
try {
|
||||
@@ -802,18 +884,27 @@ export class AudioManager {
|
||||
* @param {string} defId charId/tokenId/enemyId (or chess id — mapped via its spine/char id by the caller)
|
||||
* @param {'attack'|'hit'|'skill'|'die'|'born'} kind
|
||||
* @param {number|string} unitId battle unit id (cooldown key)
|
||||
* @param {number} [skillIndex] the unit's equipped skill slot (0-based)
|
||||
* @param {boolean} [skillActive] whether that skill runs right now (`attacks` / `hits` need it, see header)
|
||||
* @returns {boolean} whether a unit-specific sound exists
|
||||
*/
|
||||
unit(defId, kind, unitId, skillIndex) {
|
||||
unit(defId, kind, unitId, skillIndex, skillActive = false) {
|
||||
try {
|
||||
const u = this.getManifest()?.audio?.sfx?.units?.[defId];
|
||||
// DESIGN §16: the equipped skill's own ON_SKILL_START sound (`skills[index]`) when the manifest has it
|
||||
const own = kind === 'skill' && Number.isInteger(skillIndex) && u?.skills ? u.skills[skillIndex] : null;
|
||||
const url = typeof own === 'string' ? own : u?.[kind];
|
||||
// the running skill's own swing / impact (header); only while it runs — otherwise 能天使 S2's file would ring on
|
||||
// every normal attack of hers (exactly what normalAttackSfx keeps out). 赤刃明霄陈 S3: `attacks[2]` = the S3 swing,
|
||||
// `hits[2]` = the S3 slash's impact.
|
||||
const table = kind === 'attack' ? u?.attacks : kind === 'hit' ? u?.hits : null;
|
||||
const mode = skillActive && Number.isInteger(skillIndex) && table ? table[skillIndex] : null;
|
||||
const url = typeof own === 'string' ? own : typeof mode === 'string' ? mode : u?.[kind];
|
||||
if (typeof url !== 'string') return false;
|
||||
if ((kind === 'attack' || kind === 'hit') && !normalAttackSfx(defId, url)) return false;
|
||||
// a skill-mode file is fine here when it IS the running skill's (mode); the gate is for normal attacks
|
||||
if ((kind === 'attack' || kind === 'hit') && typeof mode !== 'string' && !normalAttackSfx(defId, url)) return false;
|
||||
// the official bank's mix (header): a silent roll still counts as the unit's own sound (no generic fallback)
|
||||
const mix = kind === 'skill' ? null : u?.mix?.[kind];
|
||||
const mix = kind === 'skill' ? null : typeof mode === 'string'
|
||||
? (kind === 'attack' ? u?.attackMix : u?.hitMix)?.[skillIndex] : u?.mix?.[kind];
|
||||
if (!unitSoundPlays(mix, this.random())) return true;
|
||||
this._play(url, { volume: unitGain(kind === 'attack' || kind === 'hit' ? 0.55 : 0.8, mix), limited: true, unitKey: `${unitId}:${kind}` });
|
||||
return true;
|
||||
@@ -823,9 +914,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 +929,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, voiceLangFor(charId, this.voiceLang, this.voiceOverrides));
|
||||
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 +990,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 +1014,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
|
||||
@@ -923,9 +1028,15 @@ export class AudioManager {
|
||||
_track(u) {
|
||||
if (!u || typeof u !== 'object' || u.id == null) return;
|
||||
// UnitInfo.spine is the model id (charId / tokenId / enemyId) — the key of sfx.units; kind/defId pick the
|
||||
// official class sounds (operator vs summon vs device)
|
||||
// official class sounds (operator vs summon vs device). `skillActive` follows the ['skill', id, 1] / 0 events.
|
||||
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 });
|
||||
skillIndex: Number.isInteger(u.skillIndex) ? u.skillIndex : null, skillActive: !!u.skillActive });
|
||||
// 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. */
|
||||
@@ -952,25 +1063,40 @@ export class AudioManager {
|
||||
if (!src) continue;
|
||||
// only a hostile attack authors the target's next impact (a heal — an ally aiming at an ally — never does)
|
||||
const tgt = this.units.get(e[2]);
|
||||
if (tgt && tgt.side !== src.side) this.lastAttacker.set(e[2], { def: src.def, at: now });
|
||||
if (!this.unit(src.def, 'attack', e[1]) && src.side === 'enemy') this.battle('enemyHit', { unitKey: `${e[1]}:atk`, volume: 0.35 });
|
||||
if (tgt && tgt.side !== src.side) this.lastAttacker.set(e[2], { def: src.def, at: now, skillIndex: src.skillIndex ?? null, skillActive: !!src.skillActive });
|
||||
if (!this.unit(src.def, 'attack', e[1], src.skillIndex ?? undefined, src.skillActive) && src.side === 'enemy') this.battle('enemyHit', { unitKey: `${e[1]}:atk`, volume: 0.35 });
|
||||
} else if (kind === 'dmg') {
|
||||
const by = this.lastAttacker.get(e[1]);
|
||||
if (!by || !IMPACT_TYPES.has(e[3])) continue;
|
||||
this.lastAttacker.delete(e[1]); // one impact per attack
|
||||
if (now - by.at <= IMPACT_WINDOW_MS) this.unit(by.def, 'hit', `h${e[1]}`);
|
||||
if (now - by.at <= IMPACT_WINDOW_MS) this.unit(by.def, 'hit', `h${e[1]}`, by.skillIndex ?? undefined, by.skillActive);
|
||||
} else if (kind === 'heal') {
|
||||
this.battle('heal', { unitKey: `heal:${e[1]}`, volume: 0.35 });
|
||||
} else if (kind === 'skill' && e[2]) {
|
||||
const u = this.units.get(e[1]);
|
||||
if (u) {
|
||||
// the running skill's own swing / impact (`attacks` / `hits`, header) follow this flag
|
||||
u.skillActive = true;
|
||||
this.unit(u.def, 'skill', e[1], u.skillIndex ?? undefined);
|
||||
// 作战中N: the equipped skill's own slot (0-based; 作战中4 is the fallback of a 4th slot)
|
||||
if (unitSoundClass(u) === 'char') {
|
||||
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 === 'skill') {
|
||||
// ['skill', id, 0] = the skill ENDED (the sim's skills.js: `b._ev(['skill', u.id, 0])`): back to the unit's own
|
||||
// attack / impact. The end carries no sound of its own (the activation sound belongs to the start).
|
||||
const u = this.units.get(e[1]);
|
||||
if (u) u.skillActive = false;
|
||||
this.pendingSkill.delete(e[1]);
|
||||
} else if (kind === 'engage') {
|
||||
// 行动开始: the first attack a unit makes on an enemy (the sim's ENGAGE, official ENCOUNTER_ENEMY, 3 s apart)
|
||||
const u = this.units.get(e[1]);
|
||||
@@ -1039,13 +1165,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, deps.settings.voiceOverrides); }
|
||||
if (typeof deps?.subscribe === 'function' && typeof deps?.getState === 'function') {
|
||||
const sync = (s) => {
|
||||
try {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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;
|
||||
@@ -133,6 +136,7 @@ function maybeFinishRestore() {
|
||||
* follow the store themselves).
|
||||
*/
|
||||
function backToLobby() {
|
||||
identity.rememberMatch(null);
|
||||
clearTimeout(restoreTimer);
|
||||
const s = store.get();
|
||||
if (s.room || s.match.public) closeAllDialogs();
|
||||
@@ -142,9 +146,11 @@ function backToLobby() {
|
||||
|
||||
function onWelcome(msg) {
|
||||
identity.saveToken(msg.token);
|
||||
identity.rememberMatch(null);
|
||||
const prev = store.get();
|
||||
const prevId = prev.me.playerId;
|
||||
const name = typeof msg.name === 'string' && msg.name ? msg.name : prev.me.name;
|
||||
identity.saveName(name);
|
||||
store.set({ me: { playerId: msg.playerId ?? null, name, token: typeof msg.token === 'string' ? msg.token : null } });
|
||||
welcomeAt = Date.now();
|
||||
|
||||
@@ -185,6 +191,8 @@ function onRoomState(msg) {
|
||||
// A (new) match starts: forget the previous match's state so stale results never show.
|
||||
if (room.inMatch && !(prevRoom && prevRoom.inMatch && prevRoom.code === room.code)) store.set({ match: emptyMatch() });
|
||||
store.set({ room });
|
||||
identity.rememberMatch(room.inMatch && seats.some((seat) => seat?.playerId === myId)
|
||||
? { name: store.get().me.name, code: room.code || '' } : null);
|
||||
if (room.mode === 'coop' && typeof room.code === 'string') rememberRoom(room.code);
|
||||
maybeFinishRestore();
|
||||
}
|
||||
@@ -207,7 +215,10 @@ function wireNet() {
|
||||
});
|
||||
net.on('clock', (c) => store.set({ clock: { offset: c.offset, rtt: c.rtt, synced: c.synced } }));
|
||||
net.on('welcome', onWelcome);
|
||||
net.on('helloError', (err) => toastError(err));
|
||||
net.on('helloError', (err) => {
|
||||
if (err.code === 'SESSION_IN_USE') identity.rejectToken();
|
||||
toastError(err);
|
||||
});
|
||||
net.on('replaced', () => toast(t('该身份已在其他页面登录,本页已断开'), 'warn', { ttl: 6000 }));
|
||||
net.on('unhandledError', (err) => toastError(err));
|
||||
net.on('room.state', onRoomState);
|
||||
@@ -219,7 +230,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 +304,7 @@ function App() {
|
||||
<${UiHosts} />
|
||||
<${GuideHost} />
|
||||
<${LoadoutHost} />
|
||||
<${StatsHost} />
|
||||
</div>`;
|
||||
}
|
||||
|
||||
@@ -311,12 +329,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 +359,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 });
|
||||
|
||||
@@ -114,6 +114,7 @@ export class Net {
|
||||
* @param {string} [opts.url] socket URL (default: derived from location at connect time)
|
||||
* @param {any} [opts.WebSocket] WebSocket constructor (default: globalThis.WebSocket)
|
||||
* @param {() => (string|null)} [opts.getToken] reconnect-token provider for `hello`
|
||||
* @param {() => ({ noReplace: boolean, claimAt?: number })} [opts.getTokenClaim] local ownership for this hello
|
||||
* @param {() => number} [opts.now]
|
||||
* @param {() => number} [opts.random]
|
||||
* @param {{setTimeout: Function, clearTimeout: Function, setInterval: Function, clearInterval: Function}} [opts.timers]
|
||||
@@ -122,6 +123,7 @@ export class Net {
|
||||
this.url = opts.url || null;
|
||||
this.WS = opts.WebSocket || null;
|
||||
this.getToken = typeof opts.getToken === 'function' ? opts.getToken : () => null;
|
||||
this.getTokenClaim = typeof opts.getTokenClaim === 'function' ? opts.getTokenClaim : () => null;
|
||||
this.now = opts.now || (() => Date.now());
|
||||
this.random = opts.random || Math.random;
|
||||
this.timers = opts.timers || {
|
||||
@@ -157,6 +159,7 @@ export class Net {
|
||||
this._helloTimer = null;
|
||||
this._helloRid = null;
|
||||
this._helloSentName = null;
|
||||
this._helloToken = null;
|
||||
this._lastRx = 0;
|
||||
this._unansweredSince = null; // time of the oldest ping sent since the last inbound frame
|
||||
this._clockSamples = []; // [{ offset, rtt }]
|
||||
@@ -254,8 +257,9 @@ export class Net {
|
||||
}
|
||||
|
||||
/** Drop the current socket and reconnect immediately (fresh hello). */
|
||||
reconnectNow() {
|
||||
reconnectNow(name = this.name) {
|
||||
if (this._manualClose) return;
|
||||
this.name = name;
|
||||
const ws = this.ws;
|
||||
this._teardownSocket();
|
||||
try { ws?.close(4000, 'reconnect'); } catch { /* ignore */ }
|
||||
@@ -365,7 +369,13 @@ export class Net {
|
||||
const msg = { t: 'hello', rid, name: this.name, version: PROTOCOL_VERSION };
|
||||
let token = null;
|
||||
try { token = this.getToken(); } catch { token = null; }
|
||||
if (typeof token === 'string' && token.length > 0 && token.length <= 64) msg.token = token;
|
||||
if (typeof token === 'string' && token.length > 0 && token.length <= 64) {
|
||||
msg.token = token;
|
||||
const claim = this.getTokenClaim();
|
||||
if (claim?.noReplace) msg.noReplace = true;
|
||||
if (Number.isFinite(claim?.claimAt)) msg.claimAt = claim.claimAt;
|
||||
}
|
||||
this._helloToken = token;
|
||||
this._helloRid = rid;
|
||||
this._helloSentName = this.name;
|
||||
if (this.status !== 'handshaking') this._setStatus('handshaking');
|
||||
@@ -533,6 +543,9 @@ export class Net {
|
||||
const isHelloError = t === 'error' && rid != null && rid === this._helloRid;
|
||||
|
||||
if (t === 'welcome') {
|
||||
// A local ownership election can revoke a token after its hello went onto the wire.
|
||||
// Do not publish or persist that stale welcome, even if the server had already accepted it.
|
||||
if (this._helloToken !== this.getToken()) { this.reconnectNow(); return; }
|
||||
this._onWelcome(msg);
|
||||
} else if (t === 'pong') {
|
||||
this._onPong(msg);
|
||||
@@ -654,12 +667,17 @@ export class Net {
|
||||
// browsers throttle background-tab timers to ~1/min, which would make a busy background tab look
|
||||
// dead. A duplicated tab (sessionStorage is copied) therefore drops the copied token and becomes a
|
||||
// new player instead of kicking the original tab off its session (server close 4001). Two tabs
|
||||
// resolving at the same moment tie-break on tab id. Without BroadcastChannel a tab only ever uses
|
||||
// resolving shared candidates at the same moment tie-break on tab id. An existing session token
|
||||
// never falls back to another recent identity. Its welcome stamp survives reload; an earlier
|
||||
// welcome outranks a later claimant, then a live holder wins over its duplicate, then tab id.
|
||||
// Without BroadcastChannel a tab only ever uses
|
||||
// its own token (never another tab's), so it can't steal a live session either.
|
||||
|
||||
const K_NAME = 'sp.name';
|
||||
const K_TOKEN = 'sp.token'; // sessionStorage: this tab's token
|
||||
const K_WELCOME = 'sp.tokenWelcome'; // sessionStorage: { hash, at } from the first welcome for this token
|
||||
const K_RECENT = 'sp.tokens'; // localStorage: this browser's recent tokens, most recent first
|
||||
const K_MATCHES = 'sp.matches'; // local metadata for explicit recovery of another closed tab's seat
|
||||
const K_ENTERED = 'sp.entered'; // sessionStorage: this tab passed the title screen
|
||||
const RECENT_MAX = 4;
|
||||
const TOKEN_MAX_LEN = 64; // protocol limit for hello.token
|
||||
@@ -713,20 +731,31 @@ function defaultChannel() {
|
||||
* Create the identity helper (nickname, "entered" flag and reconnect-token selection; see above).
|
||||
* @param {{ local?: Storage|null, session?: Storage|null, tabId?: string,
|
||||
* channel?: { postMessage: Function, addEventListener?: Function, onmessage?: any } | null,
|
||||
* setTimeout?: (fn: Function, ms: number) => any, queryMs?: number }} [deps]
|
||||
* setTimeout?: (fn: Function, ms: number) => any, queryMs?: number, now?: () => number,
|
||||
* onYield?: () => void }} [deps]
|
||||
*/
|
||||
export function createIdentity(deps = {}) {
|
||||
const local = deps.local !== undefined ? deps.local : safeStorage('localStorage');
|
||||
const session = deps.session !== undefined ? deps.session : safeStorage('sessionStorage');
|
||||
const tabId = deps.tabId || Math.random().toString(36).slice(2) + Date.now().toString(36);
|
||||
const setTimer = deps.setTimeout || ((fn, ms) => globalThis.setTimeout(fn, ms));
|
||||
const now = deps.now || (() => Date.now());
|
||||
const queryMs = Number.isFinite(deps.queryMs) && deps.queryMs >= 0 ? deps.queryMs : CLAIM_QUERY_MS;
|
||||
|
||||
let channel = null;
|
||||
let initPromise = null;
|
||||
let initialized = false;
|
||||
let resumeBusy = 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
|
||||
let owned = sget(session, K_TOKEN);
|
||||
if (!isToken(owned)) owned = null;
|
||||
let welcomeAt = null;
|
||||
try {
|
||||
const saved = JSON.parse(sget(session, K_WELCOME) || 'null');
|
||||
if (owned && saved?.hash === tokenHash(owned) && Number.isFinite(saved.at) && saved.at >= 0) welcomeAt = saved.at;
|
||||
} catch { /* old / damaged storage: the session token still claims its seat */ }
|
||||
let welcomed = false; // this document has received welcome (a copied stamp alone is not a live holder)
|
||||
|
||||
const readRecent = () => {
|
||||
try {
|
||||
@@ -738,22 +767,75 @@ export function createIdentity(deps = {}) {
|
||||
};
|
||||
const writeRecent = (list) => sset(local, K_RECENT, JSON.stringify([...new Set(list)].slice(0, RECENT_MAX)));
|
||||
|
||||
const readMatches = () => {
|
||||
try {
|
||||
const v = JSON.parse(sget(local, K_MATCHES) || '{}');
|
||||
return v && typeof v === 'object' && !Array.isArray(v) ? v : {};
|
||||
} catch { return {}; }
|
||||
};
|
||||
const rememberMatch = (info) => {
|
||||
if (!current) return;
|
||||
const old = readMatches(), next = {};
|
||||
for (const token of readRecent()) {
|
||||
const hash = tokenHash(token);
|
||||
const v = token === current ? info : old[hash];
|
||||
if (v && typeof v.name === 'string' && typeof v.code === 'string') {
|
||||
next[hash] = { name: v.name.slice(0, 64), code: v.code.slice(0, 32) };
|
||||
}
|
||||
}
|
||||
sset(local, K_MATCHES, JSON.stringify(next));
|
||||
};
|
||||
|
||||
const post = (msg) => {
|
||||
try { channel?.postMessage({ ...msg, from: tabId }); } catch { /* channel closed */ }
|
||||
};
|
||||
|
||||
// Session ownership is already valid while init is waiting. Shared candidates, including an
|
||||
// explicit resume, remain tentative until saveToken receives the server's welcome.
|
||||
const claimFor = (hash) => {
|
||||
if (owned && tokenHash(owned) === hash) return { owned: true, at: welcomeAt, live: welcomed };
|
||||
if ((current && tokenHash(current) === hash) || resolving?.hashes.has(hash)) return { owned: false, at: null, live: false };
|
||||
return null;
|
||||
};
|
||||
const peerWins = (peer, from, ours) => {
|
||||
if (!peer || !ours) return true; // an older client answering "mine" is conservatively treated as live
|
||||
const a = Number.isFinite(peer.at) && peer.at >= 0 ? peer.at : Infinity;
|
||||
const b = Number.isFinite(ours.at) && ours.at >= 0 ? ours.at : Infinity;
|
||||
if (a !== b) return a < b;
|
||||
if (!!peer.owned !== !!ours.owned) return !!peer.owned;
|
||||
if (!!peer.live !== !!ours.live) return !!peer.live;
|
||||
return from < tabId;
|
||||
};
|
||||
const yieldCandidate = (hash) => {
|
||||
resolving?.taken.add(hash);
|
||||
// The original page can reappear after resume's query but before its hello. Keep our old
|
||||
// session in storage and give back the tentative selection immediately in that interval.
|
||||
if (current && tokenHash(current) === hash) {
|
||||
if (current === owned) {
|
||||
owned = null; welcomeAt = null; welcomed = false;
|
||||
sdel(session, K_WELCOME);
|
||||
}
|
||||
current = owned;
|
||||
if (owned) sset(session, K_TOKEN, owned);
|
||||
else sdel(session, K_TOKEN);
|
||||
if (initialized) deps.onYield?.();
|
||||
}
|
||||
};
|
||||
|
||||
function onChannelMessage(ev) {
|
||||
const m = ev && ev.data;
|
||||
if (!m || typeof m !== 'object' || m.from === tabId || typeof m.from !== 'string') return;
|
||||
if (m.type === 'who' && Array.isArray(m.hashes)) {
|
||||
if (current) {
|
||||
const h = tokenHash(current);
|
||||
if (m.hashes.includes(h)) post({ type: 'mine', hash: h, to: m.from });
|
||||
for (const h of m.hashes) {
|
||||
if (typeof h !== 'string') continue;
|
||||
const ours = claimFor(h);
|
||||
if (!ours) continue;
|
||||
const peer = m.claims?.[h] || { owned: false, at: null, live: false };
|
||||
if (peerWins(peer, m.from, ours)) yieldCandidate(h);
|
||||
else post({ type: 'mine', hash: h, claim: ours, 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);
|
||||
} else if (m.type === 'mine' && resolving && m.to === tabId && typeof m.hash === 'string') {
|
||||
resolving.taken.add(m.hash);
|
||||
} else if (m.type === 'mine' && m.to === tabId && typeof m.hash === 'string') {
|
||||
if (peerWins(m.claim, m.from, claimFor(m.hash))) yieldCandidate(m.hash);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -763,13 +845,16 @@ export function createIdentity(deps = {}) {
|
||||
if (typeof channel.addEventListener === 'function') channel.addEventListener('message', onChannelMessage);
|
||||
else channel.onmessage = onChannelMessage;
|
||||
}
|
||||
const own = sget(session, K_TOKEN);
|
||||
const ownOk = isToken(own) ? own : null;
|
||||
// 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) });
|
||||
const candidates = owned ? [owned] : channel ? [...new Set(readRecent())] : [];
|
||||
if (!channel || candidates.length === 0) return owned;
|
||||
return queryCandidates(candidates);
|
||||
}
|
||||
|
||||
async function queryCandidates(candidates) {
|
||||
resolving = { hashes: new Set(candidates.map(tokenHash)), taken: new Set() };
|
||||
post({ type: 'who', hashes: [...resolving.hashes],
|
||||
claims: Object.fromEntries([...resolving.hashes].map((h) => [h, claimFor(h)])) });
|
||||
await new Promise((r) => setTimer(r, queryMs));
|
||||
const { taken } = resolving;
|
||||
resolving = null;
|
||||
@@ -786,17 +871,46 @@ export function createIdentity(deps = {}) {
|
||||
init() {
|
||||
if (!initPromise) {
|
||||
initPromise = resolveToken().catch(() => null).then((pick) => {
|
||||
const own = sget(session, K_TOKEN);
|
||||
if (pick) sset(session, K_TOKEN, pick);
|
||||
else if (own) sdel(session, K_TOKEN); // duplicated tab: the copied token belongs to a live tab
|
||||
// A welcome may already have set a token while we were waiting: that one wins.
|
||||
if (!current) current = pick;
|
||||
if (!welcomed) {
|
||||
current = pick;
|
||||
if (pick) sset(session, K_TOKEN, pick);
|
||||
else {
|
||||
owned = null; welcomeAt = null;
|
||||
sdel(session, K_TOKEN); sdel(session, K_WELCOME);
|
||||
}
|
||||
}
|
||||
initialized = true;
|
||||
return current;
|
||||
});
|
||||
}
|
||||
return initPromise;
|
||||
},
|
||||
/** Record only display metadata; the existing recent-token ring remains the authority. */
|
||||
rememberMatch,
|
||||
recoverable() {
|
||||
const matches = readMatches();
|
||||
return readRecent().filter((token) => token !== current).flatMap((token) => {
|
||||
const hash = tokenHash(token), v = matches[hash];
|
||||
return v && typeof v.name === 'string' && typeof v.code === 'string'
|
||||
? [{ id: hash, name: v.name.slice(0, 64), code: v.code.slice(0, 32) }] : [];
|
||||
});
|
||||
},
|
||||
/** Explicit recovery only. Recheck live claims immediately before selecting a saved seat. */
|
||||
async resume(id) {
|
||||
await this.init();
|
||||
if (!channel || resumeBusy) return false;
|
||||
const token = readRecent().find((t) => t !== current && tokenHash(t) === id);
|
||||
if (!token || !this.recoverable().some((m) => m.id === id)) return false;
|
||||
resumeBusy = true;
|
||||
try {
|
||||
const pick = await queryCandidates([token]);
|
||||
if (!pick) return false;
|
||||
current = pick;
|
||||
return true;
|
||||
} catch { return false; }
|
||||
finally { resumeBusy = false; }
|
||||
},
|
||||
/** @returns {string} remembered player name ('' if none) */
|
||||
loadName: () => (sget(local, K_NAME) || '').slice(0, 64),
|
||||
/** @param {string} name */
|
||||
@@ -808,18 +922,36 @@ export function createIdentity(deps = {}) {
|
||||
const own = sget(session, K_TOKEN);
|
||||
return isToken(own) ? own : null;
|
||||
},
|
||||
/** A shared candidate must never replace a connected session, including a hello already in flight. */
|
||||
getTokenClaim() {
|
||||
return current === owned && owned
|
||||
? { noReplace: false, ...(welcomeAt != null ? { claimAt: welcomeAt } : {}) }
|
||||
: { noReplace: true };
|
||||
},
|
||||
/** Only this page gives up the rejected claim; the shared recovery list belongs to all tabs. */
|
||||
rejectToken() {
|
||||
if (current) yieldCandidate(tokenHash(current));
|
||||
},
|
||||
/** @param {string} token from `welcome` */
|
||||
saveToken(token) {
|
||||
if (!isToken(token)) return;
|
||||
if (current !== token) rememberMatch(null);
|
||||
if (owned !== token || welcomeAt == null) welcomeAt = now();
|
||||
owned = token;
|
||||
welcomed = true;
|
||||
current = token;
|
||||
sset(session, K_TOKEN, token);
|
||||
sset(session, K_WELCOME, JSON.stringify({ hash: tokenHash(token), at: welcomeAt }));
|
||||
writeRecent([token, ...readRecent().filter((t) => t !== token)]);
|
||||
},
|
||||
/** Forget this tab's token (e.g. the server said the session is invalid). */
|
||||
clearToken() {
|
||||
rememberMatch(null);
|
||||
const t = current || sget(session, K_TOKEN);
|
||||
current = null;
|
||||
owned = null; welcomeAt = null; welcomed = false;
|
||||
sdel(session, K_TOKEN);
|
||||
sdel(session, K_WELCOME);
|
||||
if (t) writeRecent(readRecent().filter((x) => x !== t));
|
||||
},
|
||||
/** Whether this tab already passed the title screen (survives reloads, not new tabs). */
|
||||
@@ -830,7 +962,7 @@ export function createIdentity(deps = {}) {
|
||||
}
|
||||
|
||||
/** Browser identity singleton (main.js awaits `identity.init()` before connecting). */
|
||||
export const identity = createIdentity();
|
||||
export const identity = createIdentity({ onYield: () => net.reconnectNow(net.helloName || net.name) });
|
||||
|
||||
/** Browser connection singleton (created lazily-safe: nothing touches the network until connect()). */
|
||||
export const net = new Net({ getToken: () => identity.getToken() });
|
||||
export const net = new Net({ getToken: () => identity.getToken(), getTokenClaim: () => identity.getTokenClaim() });
|
||||
@@ -0,0 +1,40 @@
|
||||
// Install prompts are browser-issued, one-use capabilities. No service worker or application cache is installed.
|
||||
export function createPwaInstall(win) {
|
||||
let prompt = null, installed = false;
|
||||
const listeners = new Set();
|
||||
const display = win?.matchMedia?.('(display-mode: standalone), (display-mode: fullscreen)');
|
||||
const available = () => !!prompt && !installed && !display?.matches && !win?.navigator?.standalone;
|
||||
const emit = () => { for (const fn of listeners) fn(available()); };
|
||||
const offer = (event) => {
|
||||
if (installed || display?.matches || win?.navigator?.standalone || typeof event.prompt !== 'function') return;
|
||||
event.preventDefault();
|
||||
prompt = event;
|
||||
emit();
|
||||
};
|
||||
const done = () => { installed = true; prompt = null; emit(); };
|
||||
win?.addEventListener?.('beforeinstallprompt', offer);
|
||||
win?.addEventListener?.('appinstalled', done);
|
||||
display?.addEventListener?.('change', emit);
|
||||
return {
|
||||
available,
|
||||
subscribe(fn) { listeners.add(fn); fn(available()); return () => listeners.delete(fn); },
|
||||
async request() {
|
||||
if (!available()) return false;
|
||||
const event = prompt;
|
||||
prompt = null; // Consume before awaiting: dismissal, rejection and repeated clicks cannot reuse this event.
|
||||
emit();
|
||||
try {
|
||||
const result = await event.prompt();
|
||||
const choice = event.userChoice ? await event.userChoice : result;
|
||||
return choice?.outcome === 'accepted';
|
||||
} catch { return false; }
|
||||
},
|
||||
dispose() {
|
||||
win?.removeEventListener?.('beforeinstallprompt', offer);
|
||||
win?.removeEventListener?.('appinstalled', done);
|
||||
display?.removeEventListener?.('change', emit);
|
||||
prompt = null; listeners.clear();
|
||||
},
|
||||
};
|
||||
}
|
||||
export const pwaInstall = createPwaInstall(globalThis.window);
|
||||
@@ -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,10 +118,10 @@ 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 { backdropTextures, shadowTexture, refreshTierChips, silhouetteTexture } from './textures.js';
|
||||
import { createDragController, pieceTile, dragStandTile } from './drag.js';
|
||||
import { backdropTextures, shadowTexture, refreshTierChips, refreshDownLabels, silhouetteTexture } from './textures.js';
|
||||
import { TILE_H, TIER_COLORS, COLORS } from './style.js';
|
||||
import { loadBoardArt } from './boardArt.js';
|
||||
import { ImpostorAtlas } from './impostor.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) });
|
||||
@@ -302,12 +319,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(),
|
||||
@@ -322,10 +341,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
|
||||
@@ -334,7 +355,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
|
||||
@@ -342,6 +362,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;
|
||||
@@ -411,13 +432,20 @@ 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);
|
||||
let boardRetry = null;
|
||||
function retryBoard() {
|
||||
if (!want3d || destroyed || board3d || recover.off || recover.timer || boardRetry) return;
|
||||
boardRetry = Promise.all([loadThree(), loadBoardPack(assets)])
|
||||
.then(([THREE, pack]) => enable3d(THREE, pack), () => false)
|
||||
.finally(() => { boardRetry = null; });
|
||||
}
|
||||
// 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;
|
||||
@@ -430,13 +458,17 @@ 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();
|
||||
ensureDamageFonts();
|
||||
// web fonts may land after the first chips were drawn
|
||||
if (document.fonts?.ready) document.fonts.ready.then(() => { if (!destroyed) refreshTierChips(); }).catch(() => {});
|
||||
// web fonts may land after the first chips / redeploy-ring labels were drawn
|
||||
if (document.fonts?.ready) document.fonts.ready.then(() => { if (!destroyed) { refreshTierChips(); refreshDownLabels(); } }).catch(() => {});
|
||||
|
||||
// ---- camera ---------------------------------------------------------------------------------------------
|
||||
|
||||
@@ -775,8 +807,16 @@ export async function createFieldView(host, options = {}) {
|
||||
if (e.area === 'board' && info.dir && !held.has(e.uid) && typeof v.setDir === 'function') v.setDir(info.dir);
|
||||
}
|
||||
for (const k of [...views.keys()]) if (String(k).startsWith('p:') && !seen.has(Number(String(k).slice(2)))) dropView(k);
|
||||
// the boss round's prep: the pair partner's board on its half of the boss field (item 51; display only)
|
||||
syncMates(prepXf.kind === 'bossPrep' && src.bossMate && Array.isArray(src.bossMate.units) ? src.bossMate.units : []);
|
||||
// The 外勤医疗 reserve medic is visible before battle but not a movable board piece. Map its board-space slot to
|
||||
// the displayed boss half exactly as for the player's own pieces; the partner's units already use field coordinates.
|
||||
const mapChars = Array.isArray(src.prepMapChars) ? src.prepMapChars.map((u) => {
|
||||
if (!u || !Number.isInteger(u.x) || !Number.isInteger(u.y)) return null;
|
||||
const p = prepXf.toDisp(u.y, u.x);
|
||||
const dir = prepXf.dirToDisp(u.dir);
|
||||
return { ...u, x: p.col, y: p.row, dir, facing: dir === 'LEFT' ? -1 : 1 };
|
||||
}).filter(Boolean) : [];
|
||||
const mate = prepXf.kind === 'bossPrep' && src.bossMate && Array.isArray(src.bossMate.units) ? src.bossMate.units : [];
|
||||
syncReadOnlyPrepUnits([...mapChars, ...mate]);
|
||||
prepPieces = list.filter((e) => e.key && views.has(e.key));
|
||||
if (dragState && !views.has(dragState.key)) { drag.reset(); endDragVisual(false); }
|
||||
holdScene(false); // the prep pieces reference their models now (a battle's hold ends here)
|
||||
@@ -784,11 +824,10 @@ export async function createFieldView(host, options = {}) {
|
||||
}
|
||||
|
||||
/**
|
||||
* The pair partner's pieces in the Final Assault / Hidden Core prep (m.private bossMate units: UnitInfo in boss-field
|
||||
* coordinates, facing as the battle will place them): read-only 'm:<uid>' views — not prep pieces, so they are never
|
||||
* picked, dragged or swept with the own ones; a view is rebuilt when its body, tile or facing changes.
|
||||
* The reserve medic and a boss pair's partner pieces: read-only 'm:<uid>' views. These are not prep pieces, so they
|
||||
* are never picked, dragged or swept with the own board; a view is rebuilt when its body, tile or facing changes.
|
||||
*/
|
||||
function syncMates(units) {
|
||||
function syncReadOnlyPrepUnits(units) {
|
||||
const keep = new Set();
|
||||
for (const u of units) {
|
||||
const info = u && Number.isInteger(u.uid) ? renderInfo({ ...u, id: `m:${u.uid}` }) : null;
|
||||
@@ -1062,23 +1101,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;
|
||||
@@ -1209,6 +1258,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);
|
||||
@@ -1415,6 +1475,16 @@ 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) } : {}), keepFacing: d.keepFacing === true,
|
||||
});
|
||||
}
|
||||
// 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
|
||||
@@ -1467,10 +1537,27 @@ 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]), {
|
||||
at: slideAt, ...(Number(extra.dur) > 0 ? { dur: Number(extra.dur) } : {}), keepFacing: extra.keepFacing === true,
|
||||
});
|
||||
}
|
||||
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);
|
||||
@@ -1481,9 +1568,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
|
||||
@@ -1534,6 +1619,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
|
||||
@@ -1550,8 +1639,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;
|
||||
@@ -1574,6 +1664,7 @@ export async function createFieldView(host, options = {}) {
|
||||
}
|
||||
function frameBody(now) {
|
||||
frameNo++;
|
||||
impSlot = 0;
|
||||
if (frameNo % 30 === 1) {
|
||||
impInterval = pickImpostorInterval(); clipAllowed = pickClipping();
|
||||
culledCount = 0;
|
||||
@@ -1658,6 +1749,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
|
||||
@@ -1672,6 +1764,7 @@ export async function createFieldView(host, options = {}) {
|
||||
// load it again at once (their bounded retries never run while hidden: no frames).
|
||||
function onAssets() {
|
||||
if (destroyed) return;
|
||||
retryBoard();
|
||||
loadShadow();
|
||||
loadMountains();
|
||||
if (mode === 'prep' && lastPrep) setPrep(lastPrep.ps, lastPrep.o);
|
||||
@@ -1680,18 +1773,21 @@ 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;
|
||||
retryBoard();
|
||||
if (assets.loaded === false && typeof assets.ready === 'function') assets.ready();
|
||||
for (const v of views.values()) v.retryAssets?.();
|
||||
for (const v of penViews.values()) v.retryAssets?.();
|
||||
leader?.view.retryAssets?.();
|
||||
};
|
||||
globalThis.document?.addEventListener?.('visibilitychange', onVisible);
|
||||
setupCleanup.push(() => globalThis.document?.removeEventListener?.('visibilitychange', onVisible));
|
||||
|
||||
// ---- public API ---------------------------------------------------------------------------------------------
|
||||
|
||||
const view = {
|
||||
view = {
|
||||
setStage,
|
||||
setCamera,
|
||||
setPrep,
|
||||
@@ -1830,14 +1926,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);
|
||||
@@ -1857,6 +1948,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 {
|
||||
@@ -1876,5 +1968,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;
|
||||
}
|
||||
}
|
||||
@@ -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. */
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -16,10 +16,20 @@ import { parseObj } from './obj.js';
|
||||
export const THREE_URL = '/vendor/three.module.js';
|
||||
|
||||
let threePromise = null;
|
||||
let threeFailures = 0;
|
||||
/** Dynamic import of the vendored three.js ESM build (browser only); null when unavailable. */
|
||||
export function loadThree(url = THREE_URL) {
|
||||
if (!threePromise) {
|
||||
threePromise = import(/* @vite-ignore */ url).then((m) => (m && m.WebGLRenderer ? m : null), () => null);
|
||||
// Browsers also cache failed module imports. Two bounded retry URLs permit a transient download failure to
|
||||
// recover; clearing only our promise would keep receiving that same cached rejection without another request.
|
||||
if (threeFailures >= 3) return Promise.resolve(null);
|
||||
const source = new URL(url, import.meta.url);
|
||||
if (threeFailures) source.searchParams.set('sp-retry', String(threeFailures));
|
||||
const pending = import(/* @vite-ignore */ source.href).then((m) => (m && m.WebGLRenderer ? m : null), () => null).then((m) => {
|
||||
if (!m && threePromise === pending) { threePromise = null; threeFailures++; }
|
||||
return m;
|
||||
});
|
||||
threePromise = pending;
|
||||
}
|
||||
return threePromise;
|
||||
}
|
||||
@@ -95,7 +105,7 @@ async function fetchJson(url) {
|
||||
*/
|
||||
export function loadBoardPack(assets) {
|
||||
if (cached) return cached;
|
||||
cached = (async () => {
|
||||
const pending = (async () => {
|
||||
if (!assets || typeof assets.local !== 'function' || typeof assets.localUrl !== 'function' || typeof assets.image !== 'function') return null;
|
||||
const manifest = await assets.local().catch(() => null);
|
||||
if (!isObj(manifest)) return null;
|
||||
@@ -106,7 +116,7 @@ export function loadBoardPack(assets) {
|
||||
await Promise.all(Object.entries(PACK_IMAGES).map(async ([k, [g, n]]) => {
|
||||
const u = url(g, n);
|
||||
if (!u) return;
|
||||
const img = await assets.image(u).catch(() => null);
|
||||
const img = await assets.image(u, { retry: true }).catch(() => null);
|
||||
if (img && (img.width || img.naturalWidth) > 0) images[k] = img;
|
||||
}));
|
||||
if (!images.D) return null;
|
||||
@@ -138,9 +148,15 @@ export function loadBoardPack(assets) {
|
||||
images, meshes, tiles: isObj(tiles) ? tiles : null, uv: resolveUvTable(isObj(tiles) ? tiles : null),
|
||||
materials: { theme: isObj(theme) ? theme : null, fx: isObj(fxMats) ? fxMats : null },
|
||||
};
|
||||
})().catch((err) => { console.warn('[board3d] art load failed', err); return null; });
|
||||
return cached;
|
||||
})().catch((err) => { console.warn('[board3d] art load failed', err); return null; }).then((pack) => {
|
||||
// A timeout / failed atlas is not proof the host lacks 3D art. A later mount or visibility change may retry.
|
||||
// An older attempt finishing after reset must not discard a newer cached request.
|
||||
if (!pack && cached === pending) cached = null;
|
||||
return pack;
|
||||
});
|
||||
cached = pending;
|
||||
return pending;
|
||||
}
|
||||
|
||||
/** Forget the cached pack (tests / hot reload). */
|
||||
export function resetBoardPack() { cached = null; threePromise = null; }
|
||||
/** Forget the cached pack and the three.js import attempt (tests / hot reload). */
|
||||
export function resetBoardPack() { cached = null; threePromise = null; threeFailures = 0; }
|
||||