0.2.3: prepare follow-up fixes and client features (#407, #408, #410, #413, #431, #435, #436, #440, #444)
Add special-target activation without bypassing attack cadence, cooperative card preferences and emotes, skill sound banks, per-operator voice choices, text sizing, install prompts, closed-tab recovery and unanimous setup rerolls. Retain late deployment elapsed time and resolve echo/counter pulses without recursive hook overflow. Independent final review remains pending. Golden: roster-016, roster-035, roster-036, roster-045; boss-boss_1-pair, boss-boss_3-pair, boss-boss_5-pair, boss-boss_7-pair, hidden-boss_9-pair; solo-HARD-9-boosted: Lemuen attack cadence. diy-132: Necras extra activation targets. coop2-NORMAL-14-standins: cooperative purchase preference and simulation fixes. hidden-boss_10-solo: echo pulse FIFO resolution. See DESIGN 28.13-28.24 for assumptions, sources and contributor credit.
This commit is contained in:
1 parent
314564c139
commit
f3658a0dd4
104 files changed
+3674
-741
No files matched your search
@@ -2,6 +2,8 @@
|
||||
|
||||
## 0.2.3-dev — 开发中,尚未发布
|
||||
|
||||
- 修复“余音”与刺玫、泡泡反击在高频次生命下递归超过事件上限的问题。
|
||||
|
||||
- 自选编队新增六星克莱门莎(三个技能、两项天赋、潜能与练度);黍新增「好时节」GUA-Y,乌尔比安新增「前路」CRU-Y。数值来自国服 2.7.81 官方表,机制对照 PRTS;新干员尚无来源确认的碰撞边界、攻击溅射和炮击节拍已标在 [设计记录 §28](docs/history/0.2.3.md),后续依据资料校正。联动干员不在本次新增范围。
|
||||
- 蕾缪安可以用远处的通缉目标触发技能,修正通缉首次计时、多人来源和撤退清理(#428,感谢 @CurlyZhu)。
|
||||
- 塞雷娅 / 黍一技能在没有敌人时也按攻击间隔治疗范围内半血及以下的友方;凯尔希·思衡托二技能可以由扩大范围内的可选敌人触发,仍能由伤员触发(#406,感谢 @ChengCC0101)。
|
||||
@@ -10,6 +12,14 @@
|
||||
- 修复弹窗内复制诊断信息的旧浏览器回退,复制后恢复焦点(#433,感谢 @RushFTK)。
|
||||
- 素材下载会报告因磁盘缺失而略去的清单条目,新增可选严格模式(PR #421,感谢 @LimitlessHPPK);润色韩语文本(PR #414,感谢 @D-Roote),补中文 PR 模板(PR #432,感谢 @Xinglan233)。
|
||||
|
||||
- 死芒一、二技能可由自身召唤物阻挡的范围外目标触发;与蕾缪安通缉目标一样等待正常攻击检查,不逐帧提前开技(#440、#438、#439)。
|
||||
- 设置新增四档文字大小,支持长译文换行;干员调配可逐干员选择中文、日语或跟随全局语音(#435、#436,PR #446,感谢 @7eLaryn)。
|
||||
- 补齐司霆惊蛰、赤刃明霄陈等干员技能期间的攻击与命中音,保留各技能音效自己的音量与播放概率(PR #410,感谢 @Convey123)。
|
||||
- AI 购买高阶卡时适当降低与队友开局盟约抢卡的倾向,并在混合对局中发送有限的表情回应(PR #407、#444,感谢 @IceCodeNew、@DDDarkstar;可用 `SP_BOT_EMOTES=0` 关闭表情)。
|
||||
- 增加本机对局恢复入口,避免同一浏览器的闲置窗口妨碍恢复已关闭窗口的席位(#431)。浏览器支持时可添加到桌面,精简包也自带安装图标(PR #413,感谢 @Cromemadnd)。
|
||||
- 确认页可由房主发起全体真人一致同意的开局重刷,保留房间与个人配置;取消恢复剩余确认时间,成功后重新确认(PR #408,感谢 @Siornya)。这是复刻版便捷功能。
|
||||
- 修复模型加载较慢时丢失入场动画,按经过时间接续,攻击或死亡后不再补播过期入场(PR #416 提示的加载问题,感谢 @Cromemadnd)。
|
||||
|
||||
## 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));有意和官方不同的写在「有意与官方不同的调整」里。
|
||||
|
||||
@@ -121,6 +121,7 @@ npm start # 启动服务器:http://localhost:3000
|
||||
| `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`。
|
||||
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
{"version":1,"hash":"997b60350583","generator":"tools/fetch-assets.mjs","stats":{"files":10688,"bytes":578013623,"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
|
||||
{"version":1,"hash":"cf275d509d29","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
|
||||
+11
-1
@@ -221,7 +221,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? } } } }
|
||||
}
|
||||
},
|
||||
@@ -452,3 +453,12 @@ 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.
|
||||
@@ -898,3 +898,15 @@ 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.
|
||||
@@ -194,3 +194,17 @@
|
||||
**什么算一局**:打到结算页的对局,以及中途退出(「放弃模拟」)前至少清完 1 个回合的对局,计入以上所有统计——和官方一样,中途退出按淘汰处理,通过的回合数就是退出时已经通过的回合。**进去就退、没清完第一个回合就退的对局不计入统计**(不算一局,不影响胜率和策略通过率),但会留在「对局记录」里,标着「中途退出」和「不计入统计」。已经被淘汰后再退出的对局算(你输了这一局)。下面这些不会记录:观战;被服务器结束的对局(服务器重启、同盟解散);关掉页面后没有再回来的对局。
|
||||
|
||||
**存储与隐私**:最多保留最近 1000 局,所有记录加起来不超过约 0.8 MB(超出时最旧的先丢)。只有最新的 30 局保留完整的阵容、盟约和队友信息,更早的压缩成你自己的一行汇总。同盟对局的记录里有**队友的代号**,它们保存在本机,也会出现在你导出的文件里;不会发给任何人。想删掉就点「清空」(会先确认),或清除这个网站的数据。读不出来的存档(损坏的数据)会另存为备份并重新开始记录,不会让页面出错;由更新版本导出的文件会被拒绝导入,也不会被旧版本覆盖。
|
||||
|
||||
### 0.2.3 的便捷设置
|
||||
|
||||
设置中的「文字大小」有小、中、大、特大四档,默认小;棋盘尺寸不随文字放大。干员调配详情和已选自选干员卡可
|
||||
单独选择中文、日语或跟随全局语音,只保存在当前浏览器,日语缺失时沿用中文回退。
|
||||
|
||||
同一浏览器的对局窗口意外关闭后,可以从标题或大厅的「恢复本机对局」选择保存的席位。仍被其他窗口使用的席位
|
||||
不会被接管;服务器重启、对局过期或浏览器无法检查其他窗口时不能恢复。
|
||||
|
||||
浏览器提供安装条件时会显示「添加到桌面」。安装后仍需连接游戏服务器,不会把服务器或整套素材离线保存。
|
||||
不支持页面内安装按钮的浏览器可使用其自己的菜单(是否可用由浏览器决定)。
|
||||
|
||||
确认开局信息时,房主可发起「重刷本局」。全体真人同意后重抽开局,保留房间和个人配置;投票暂停确认倒计时,
|
||||
拒绝、取消、断线或退出则保留原开局和剩余时间。只有一名真人时立即刷新。随机结果可能重复。
|
||||
+8
-3
@@ -947,13 +947,13 @@ flag alters movement or 失衡.
|
||||
**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`). 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`,
|
||||
@@ -1011,7 +1011,7 @@ flag alters movement or 失衡.
|
||||
`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).
|
||||
@@ -1547,3 +1547,8 @@ window — `push` / `pull`; a wall-shortened slide keeps its row time, a wall at
|
||||
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.
|
||||
@@ -89,3 +89,35 @@ are verified, and cleans up / restores focus on both success and failure. Failur
|
||||
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 adopting the selected token, then reloads. It does not identify players by
|
||||
name or IP or displace a live tab. 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; its original fortress favicon is packaged as actual 192/512 px icons in
|
||||
`public/icons/`, including lite packages. 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.
|
||||
@@ -189,3 +189,16 @@ and marks. Leaving clears that source's state without clearing another still-dep
|
||||
|
||||
凯尔希·思衡托 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.
|
||||
|
||||
|
||||
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.
|
||||
@@ -117,3 +117,25 @@ Official rule (bwiki 盟约, 更新公告 5114): before a match the player canno
|
||||
**自选编队 (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.
|
||||
+11
-1
@@ -15,7 +15,7 @@ Platform additions (implemented): `welcome.{resumed, version}`, `welcome.diyKitt
|
||||
**Spectator seats** (a remake feature — the official room has none; community report #26, owner's decision 2026-10-04; the spectator-seat subsection of §23): `room.spectate {code}` takes one of a co-op room's `MAX_SPECTATORS` (2) spectator seats, in its lobby or during its match; `room.removeSpectator {playerId}` (host, at any time — the room screen's strip and, once the match runs, the game screen's 观战席 capsule in the top bar, GitHub #120) frees one (`room.closed {reason:'kicked'}` to it, no match frame after it); `room.state.spectators: [{playerId, name, connected}]` (still pushed while the match runs). A spectator is never a player (not in `seats`, not counted for the start, never host, keeps no room alive — `room.closed {empty}` when the last human leaves), receives the room's broadcasts and watches like an eliminated player (`Match.addSpectator`: `b.start` watch / `m.field`, never `m.private`); it may only `g.watch`, leave, and store a `room.loadout` — anything else answers `error SPECTATOR`. Its seat is kept and given back on reconnect like a player's.
|
||||
|
||||
### 8.2 Match
|
||||
C→S (all carry `rid`): `g.infoReady` · `g.band {bandId}` · `g.bandSkip` · `g.bandFocus {bandId?}` (the strategy highlighted in the draft screen — what a timed-out turn takes; absent / null clears it; `WRONG_PHASE` outside BAND_DRAFT, `ALREADY` after the pick, `BAD_TARGET` for a band not allowed; §18.2) · `g.buy {slot}` · `g.refresh` · `g.freeze` · `g.levelUp` · `g.sell {uid}` · `g.move {uid, to:{area:'board', row, col}|{area:'hand', idx}, dir?}` · `g.equip {itemUid, targetUid, replaceUid?}` (`replaceUid`: which equipped item a third one replaces, §6.2) · `g.art {itemUid, row, col, dir?}` (`dir` ∈ UP|RIGHT|DOWN|LEFT from the deploy wheel, §3; absent ⇒ `to.dir`, then RIGHT; `g.move` onto the piece's own tile re-orients it; board pieces in `m.private` / `m.field {prep:true}` carry `dir`) · `g.destroy {uid}` (hand / temp items only; equipped items are locked, §6.2) · `g.reward {idx}` · `g.choice {idx, 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`
|
||||
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...], 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).
|
||||
@@ -117,3 +117,13 @@ Piece = { uid, kind: 'chess'|'item'|'token', id, golden: bool, tier, items: [ {
|
||||
- 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.
|
||||
@@ -187,3 +187,160 @@ assertion is corrected to require actual healing, and development builds retain
|
||||
新增真实子进程继承 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
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
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 existing original fortress favicon supplies correctly sized 192/512 px icons, shipped outside optional game art.
|
||||
No service worker or offline cache is installed; the server is still required.
|
||||
|
||||
Tests: offer lifecycle/disposal, actual PNG dimensions, 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.
|
||||
|
||||
### 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 and reloading. 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. No server API changes; normal automatic startup selection is preserved.
|
||||
|
||||
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
|
||||
|
||||
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.
|
||||
+41
-41
@@ -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,16 @@
|
||||
}
|
||||
.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); }
|
||||
.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; }
|
||||
.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; }
|
||||
|
||||
.crash { align-items: center; justify-content: center; }
|
||||
.crash__box {
|
||||
@@ -1051,4 +1051,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); }
|
||||
@@ -624,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; }
|
||||
@@ -635,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; }
|
||||
@@ -649,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); }
|
||||
@@ -679,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); }
|
||||
+30
-24
@@ -43,8 +43,8 @@
|
||||
.spec__row.is-offline .spec__name { color: var(--text-dim); }
|
||||
.spec__ico { width: .18rem; height: .18rem; flex: none; color: var(--mint-500); }
|
||||
.spec__row.is-offline .spec__ico { color: var(--text-dim); }
|
||||
.spec__name { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: .19rem; color: var(--text-hi); }
|
||||
.spec__hint { margin: .04rem 0 0; font-size: .15rem; line-height: 1.45; }
|
||||
.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);
|
||||
@@ -521,45 +521,51 @@
|
||||
.settings-btn .btn__label { display: inline-flex; align-items: center; gap: .1rem; }
|
||||
.settings-btn .btn__icon { color: var(--mint-500); }
|
||||
.set-list { display: flex; flex-direction: column; gap: .16rem; padding-top: .06rem; }
|
||||
.set-row { display: grid; grid-template-columns: 3.3rem 1fr auto; align-items: center; gap: .18rem; min-height: .44rem; }
|
||||
.set-row__label { display: flex; align-items: center; gap: .1rem; font-size: .18rem; color: var(--text-hi); white-space: nowrap; }
|
||||
.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
|
||||
@@ -567,10 +573,10 @@
|
||||
clipboard refused it, styled like 干员调配's 导出 box (screens/loadout.css .lo-io__text) */
|
||||
.set-diag { display: flex; flex-direction: column; gap: .08rem; padding-top: .1rem; border-top: 1px solid var(--line); }
|
||||
.set-diag__meta { display: flex; flex-wrap: wrap; align-items: baseline; justify-content: space-between; gap: .04rem .2rem; }
|
||||
.set-diag__stat { display: inline-flex; align-items: baseline; gap: .1rem; font-size: .14rem; color: var(--text-lo); }
|
||||
.set-diag__count { font-family: var(--font-num); font-size: .2rem; line-height: 1; color: var(--mint-400); }
|
||||
.set-diag__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: .13rem; color: var(--text-dim); overflow-wrap: anywhere; -webkit-user-select: all; user-select: all; }
|
||||
.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;
|
||||
@@ -589,11 +595,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; }
|
||||
@@ -703,7 +709,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 */
|
||||
|
||||
@@ -634,3 +634,8 @@
|
||||
.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%; }
|
||||
@@ -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); }
|
||||
@@ -11,14 +11,14 @@
|
||||
.result__main { position: relative; z-index: 1; flex: 1; min-height: 0; display: grid; grid-template-columns: 5.6rem 1fr; gap: .5rem; padding: .36rem .5rem .3rem .6rem; }
|
||||
.result__hero { display: flex; flex-direction: column; align-items: flex-start; gap: .16rem; padding-top: .3rem; min-height: 0; }
|
||||
.result__logoimg { height: 1.1rem; width: auto; opacity: .92; }
|
||||
.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 +27,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 +45,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 .btn { min-width: 3.4rem; }
|
||||
+23
-23
@@ -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,19 +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: .14rem; }
|
||||
.ailast__opt { --d-color: var(--mint-400); height: .36rem; padding: 0 .14rem; border: 1px solid var(--line-2); background: rgba(0, 0, 0, .3); font-size: .15rem; letter-spacing: .04em; }
|
||||
.ailast { 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)); }
|
||||
|
||||
@@ -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,21 +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); }
|
||||
.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 */
|
||||
/* 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;
|
||||
@@ -263,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 */
|
||||
|
||||
+18
-2
@@ -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;
|
||||
|
||||
+25
-1
@@ -1183,5 +1183,29 @@
|
||||
"精英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."
|
||||
"默认满潜能、精英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"
|
||||
}
|
||||
+25
-1
@@ -1189,5 +1189,29 @@
|
||||
"精英2": "昇進2",
|
||||
"精英2 Lv.60": "昇進2 Lv60",
|
||||
"开始模拟前可调整干员携带的技能与模组,以及潜能与练度;干员的局内等级不可调整": "シミュレーション開始前に、オペレーターのスキルとモジュール、潜在と育成度を調整できます(局内のオペレーターのレベルは変更不可)",
|
||||
"默认满潜能、精英2 Lv.60(满加成)。官方的潜能取你自己的潜能,练度是自持有加成(已持有的同名干员的养成,攻击 / 防御 / 生命单独乘算);未持有的特许干员在官方按潜能1、没有加成,要照官方打请手动设为潜能1、未精英化。": "初期値は潜在最大・昇進2 Lv60(ボーナス最大)。公式では潜在はあなた自身の潜在、育成度は所持オペレーターボーナス(同名オペレーターの育成度による攻撃・防御・HPの独立した乗算)です。未所持のプリセットオペレーターは公式では潜在1・ボーナスなしのため、公式どおりにするには潜在1・未昇進に設定してください。"
|
||||
"默认满潜能、精英2 Lv.60(满加成)。官方的潜能取你自己的潜能,练度是自持有加成(已持有的同名干员的养成,攻击 / 防御 / 生命单独乘算);未持有的特许干员在官方按潜能1、没有加成,要照官方打请手动设为潜能1、未精英化。": "初期値は潜在最大・昇進2 Lv60(ボーナス最大)。公式では潜在はあなた自身の潜在、育成度は所持オペレーターボーナス(同名オペレーターの育成度による攻撃・防御・HPの独立した乗算)です。未所持のプリセットオペレーターは公式では潜在1・ボーナスなしのため、公式どおりにするには潜在1・未昇進に設定してください。",
|
||||
"文字大小": "文字サイズ",
|
||||
"小": "小",
|
||||
"大": "大",
|
||||
"特大": "特大",
|
||||
"调整界面文字大小,棋盘保持原比例。": "画面の文字サイズを変更します。盤面の縮尺は変わりません。",
|
||||
"此干员语音": "このオペレーターの音声",
|
||||
"跟随全局": "全体設定に従う",
|
||||
"仅保存在此浏览器,缺失的日语语音会回退到中文。": "このブラウザーのみに保存されます。日本語音声がない場合は中国語に切り替わります。",
|
||||
"添加到桌面": "ホーム画面に追加",
|
||||
"恢复本机对局": "この端末の対戦を復帰",
|
||||
"此对局仍在其他窗口中,或当前浏览器无法安全恢复。请关闭原窗口后重试。": "別のウィンドウで対戦中か、このブラウザーでは安全に復帰できません。元のウィンドウを閉じて再試行してください。",
|
||||
"选择已关闭窗口的对局。服务器重启或对局过期后无法恢复。": "閉じたウィンドウの対戦を選択してください。サーバー再起動後や期限切れの場合は復帰できません。",
|
||||
"{name} · 同盟 {code}": "{name} · 同盟 {code}",
|
||||
"重刷本局": "開始条件を再抽選",
|
||||
"重刷本局投票:{agreed}/{total}": "再抽選の投票:{agreed}/{total}",
|
||||
"全员同意后重新随机战场、领袖、特训敌人和禁用;确认倒计时已暂停。": "全員の同意で戦場、ボス、特訓の敵と禁止対象を再抽選します。確認の制限時間は一時停止中です。",
|
||||
"保留房间和个人配置,重新随机全部开局信息。": "ルームと個人設定を維持して、開始条件をすべて再抽選します。",
|
||||
"已同意": "同意済み",
|
||||
"待同意": "同意待ち",
|
||||
"全体真人玩家一致同意后刷新,随机结果可能重复。": "人間プレイヤー全員の同意が必要です。同じ結果になる場合があります。",
|
||||
"请等待所有玩家连接后再发起投票": "全プレイヤーの接続を待ってから投票を開始してください。",
|
||||
"同意重刷": "再抽選に同意",
|
||||
"拒绝重刷": "再抽選を拒否",
|
||||
"取消投票": "投票を取り消す"
|
||||
}
|
||||
+25
-1
@@ -1185,5 +1185,29 @@
|
||||
"精英2": "정예 2",
|
||||
"精英2 Lv.60": "정예 2 Lv.60",
|
||||
"开始模拟前可调整干员携带的技能与模组,以及潜能与练度;干员的局内等级不可调整": "시뮬레이션 시작 전에 오퍼레이터의 스킬과 모듈, 잠재와 육성도를 조정할 수 있습니다. 게임 내 오퍼레이터 레벨은 조정할 수 없습니다.",
|
||||
"默认满潜能、精英2 Lv.60(满加成)。官方的潜能取你自己的潜能,练度是自持有加成(已持有的同名干员的养成,攻击 / 防御 / 生命单独乘算);未持有的特许干员在官方按潜能1、没有加成,要照官方打请手动设为潜能1、未精英化。": "기본값은 최대 잠재 · 2차 정예화 Lv.60(최대 보너스)입니다. 공식 규칙에서 잠재는 자신의 잠재, 육성도는 보유 오퍼레이터 보너스(같은 오퍼레이터의 육성도에 따른 공격 / 방어 / HP 별도 배율)입니다. 보유하지 않은 프리셋 오퍼레이터는 공식적으로 잠재 1·보너스 없음이므로, 공식대로 하려면 잠재 1, 비정예화로 직접 설정하세요."
|
||||
"默认满潜能、精英2 Lv.60(满加成)。官方的潜能取你自己的潜能,练度是自持有加成(已持有的同名干员的养成,攻击 / 防御 / 生命单独乘算);未持有的特许干员在官方按潜能1、没有加成,要照官方打请手动设为潜能1、未精英化。": "기본값은 최대 잠재 · 2차 정예화 Lv.60(최대 보너스)입니다. 공식 규칙에서 잠재는 자신의 잠재, 육성도는 보유 오퍼레이터 보너스(같은 오퍼레이터의 육성도에 따른 공격 / 방어 / HP 별도 배율)입니다. 보유하지 않은 프리셋 오퍼레이터는 공식적으로 잠재 1·보너스 없음이므로, 공식대로 하려면 잠재 1, 비정예화로 직접 설정하세요.",
|
||||
"文字大小": "글자 크기",
|
||||
"小": "작게",
|
||||
"大": "크게",
|
||||
"特大": "아주 크게",
|
||||
"调整界面文字大小,棋盘保持原比例。": "화면의 글자 크기를 조절합니다. 보드 배율은 유지됩니다.",
|
||||
"此干员语音": "오퍼레이터 음성",
|
||||
"跟随全局": "전체 설정 따르기",
|
||||
"仅保存在此浏览器,缺失的日语语音会回退到中文。": "이 브라우저에만 저장됩니다. 일본어 음성이 없으면 중국어로 재생합니다.",
|
||||
"添加到桌面": "홈 화면에 추가",
|
||||
"恢复本机对局": "이 기기의 게임 복귀",
|
||||
"此对局仍在其他窗口中,或当前浏览器无法安全恢复。请关闭原窗口后重试。": "다른 창에서 진행 중이거나 이 브라우저에서 안전하게 복귀할 수 없습니다. 원래 창을 닫고 다시 시도하세요.",
|
||||
"选择已关闭窗口的对局。服务器重启或对局过期后无法恢复。": "닫힌 창의 게임을 선택하세요. 서버 재시작 또는 게임 만료 후에는 복귀할 수 없습니다.",
|
||||
"{name} · 同盟 {code}": "{name} · 동맹 {code}",
|
||||
"重刷本局": "시작 조건 다시 추첨",
|
||||
"重刷本局投票:{agreed}/{total}": "다시 추첨 투표: {agreed}/{total}",
|
||||
"全员同意后重新随机战场、领袖、特训敌人和禁用;确认倒计时已暂停。": "전원 동의 시 전장, 보스, 특훈 적과 금지 대상을 다시 추첨합니다. 확인 제한 시간이 일시 정지됩니다.",
|
||||
"保留房间和个人配置,重新随机全部开局信息。": "방과 개인 설정을 유지하고 시작 조건 전체를 다시 추첨합니다.",
|
||||
"已同意": "동의함",
|
||||
"待同意": "동의 대기",
|
||||
"全体真人玩家一致同意后刷新,随机结果可能重复。": "모든 실제 플레이어의 동의가 필요합니다. 같은 결과가 나올 수 있습니다.",
|
||||
"请等待所有玩家连接后再发起投票": "모든 플레이어가 접속한 후 투표를 시작하세요.",
|
||||
"同意重刷": "다시 추첨에 동의",
|
||||
"拒绝重刷": "다시 추첨 거부",
|
||||
"取消投票": "투표 취소"
|
||||
}
|
||||
+25
-1
@@ -1187,5 +1187,29 @@
|
||||
"精英2": "精英2",
|
||||
"精英2 Lv.60": "精英2 Lv.60",
|
||||
"开始模拟前可调整干员携带的技能与模组,以及潜能与练度;干员的局内等级不可调整": "開始模擬前可調整幹員攜帶的技能與模組,以及潛能與練度;幹員的局內等級不可調整",
|
||||
"默认满潜能、精英2 Lv.60(满加成)。官方的潜能取你自己的潜能,练度是自持有加成(已持有的同名干员的养成,攻击 / 防御 / 生命单独乘算);未持有的特许干员在官方按潜能1、没有加成,要照官方打请手动设为潜能1、未精英化。": "預設滿潛能、精英2 Lv.60(滿加成)。官方的潛能取你自己的潛能,練度是自持有加成(已持有的同名幹員的養成,攻擊 / 防禦 / 生命單獨乘算);未持有的特許幹員在官方按潛能1、沒有加成,要照官方打請手動設為潛能1、未精英化。"
|
||||
"默认满潜能、精英2 Lv.60(满加成)。官方的潜能取你自己的潜能,练度是自持有加成(已持有的同名干员的养成,攻击 / 防御 / 生命单独乘算);未持有的特许干员在官方按潜能1、没有加成,要照官方打请手动设为潜能1、未精英化。": "預設滿潛能、精英2 Lv.60(滿加成)。官方的潛能取你自己的潛能,練度是自持有加成(已持有的同名幹員的養成,攻擊 / 防禦 / 生命單獨乘算);未持有的特許幹員在官方按潛能1、沒有加成,要照官方打請手動設為潛能1、未精英化。",
|
||||
"文字大小": "文字大小",
|
||||
"小": "小",
|
||||
"大": "大",
|
||||
"特大": "特大",
|
||||
"调整界面文字大小,棋盘保持原比例。": "調整介面文字大小,棋盤保持原比例。",
|
||||
"此干员语音": "此幹員語音",
|
||||
"跟随全局": "跟隨全域",
|
||||
"仅保存在此浏览器,缺失的日语语音会回退到中文。": "僅儲存在此瀏覽器,缺失的日語語音會回退到中文。",
|
||||
"添加到桌面": "加入主畫面",
|
||||
"恢复本机对局": "恢復本機對局",
|
||||
"此对局仍在其他窗口中,或当前浏览器无法安全恢复。请关闭原窗口后重试。": "此對局仍在其他視窗中,或目前瀏覽器無法安全恢復。請關閉原視窗後重試。",
|
||||
"选择已关闭窗口的对局。服务器重启或对局过期后无法恢复。": "選擇已關閉視窗的對局。伺服器重啟或對局過期後無法恢復。",
|
||||
"{name} · 同盟 {code}": "{name} · 同盟 {code}",
|
||||
"重刷本局": "重刷本局",
|
||||
"重刷本局投票:{agreed}/{total}": "重刷本局投票:{agreed}/{total}",
|
||||
"全员同意后重新随机战场、领袖、特训敌人和禁用;确认倒计时已暂停。": "全員同意後重新隨機戰場、領袖、特訓敵人和禁用;確認倒數已暫停。",
|
||||
"保留房间和个人配置,重新随机全部开局信息。": "保留房間和個人配置,重新隨機全部開局資訊。",
|
||||
"已同意": "已同意",
|
||||
"待同意": "待同意",
|
||||
"全体真人玩家一致同意后刷新,随机结果可能重复。": "全體真人玩家一致同意後刷新,隨機結果可能重複。",
|
||||
"请等待所有玩家连接后再发起投票": "請等待所有玩家連線後再發起投票",
|
||||
"同意重刷": "同意重刷",
|
||||
"拒绝重刷": "拒絕重刷",
|
||||
"取消投票": "取消投票"
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 886 B |
Binary file not shown.
|
After Width: | Height: | Size: 2.5 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><rect width="24" height="24" fill="#0c0f0e"/><path fill="#4ed8af" d="M5 3h3v2h2V3h4v2h2V3h3v5l-2 2v7l2 2v2H5v-2l2-2v-7L5 8z"/></svg>
|
||||
|
After Width: | Height: | Size: 193 B |
@@ -15,6 +15,7 @@
|
||||
<meta name="apple-mobile-web-app-title" content="卫戍协议" />
|
||||
<meta name="description" content="卫戍协议:盟约 · 网页联机复刻(非官方同人作品)" />
|
||||
<title>卫戍协议:盟约 · STRONGHOLD PROTOCOL</title>
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
<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" />
|
||||
|
||||
<!-- Web fonts: never render-blocking (LAN/offline play must still start); system fallbacks in theme.css. -->
|
||||
@@ -69,6 +70,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>
|
||||
|
||||
|
||||
+10
-1
@@ -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]);
|
||||
}
|
||||
|
||||
|
||||
+44
-13
@@ -43,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
|
||||
@@ -55,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';
|
||||
|
||||
@@ -514,6 +525,7 @@ 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;
|
||||
@@ -522,7 +534,8 @@ export class AudioManager {
|
||||
this.bgmToken = 0;
|
||||
this.units = new Map(); // battle unit id → defId
|
||||
this.pendingSkill = new Map(); // unit id → the 'skill' tuple that arrived before the unit was known (see _track)
|
||||
this.lastAttacker = new Map(); // target id → { def, at } of the hostile attack last aimed at it (its impact sound)
|
||||
this.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);
|
||||
@@ -660,8 +673,9 @@ export class AudioManager {
|
||||
* line on air finishes in its own dub; the next one follows the setting.
|
||||
* @param {string} lang
|
||||
*/
|
||||
setVoiceLang(lang) {
|
||||
setVoiceLang(lang, overrides = this.voiceOverrides) {
|
||||
this.voiceLang = lang === 'jp' ? 'jp' : 'cn';
|
||||
this.voiceOverrides = sanitizeVoiceOverrides(overrides);
|
||||
}
|
||||
|
||||
_applyVolumes() {
|
||||
@@ -870,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;
|
||||
@@ -907,7 +930,7 @@ export class AudioManager {
|
||||
if (!this.ctx || !this.voiceGain || this.volumes.muted || this.volumes.voice <= 0) return false;
|
||||
if (typeof charId !== 'string' || typeof slot !== 'string') return false;
|
||||
// the chosen dub's line (settings 语音语言), with the Chinese file of the same name as its fallback (voiceLine)
|
||||
const line = voiceLine(this.getManifest()?.audio, charId, slot, this.voiceLang);
|
||||
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);
|
||||
@@ -1005,9 +1028,9 @@ 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) {
|
||||
@@ -1040,18 +1063,20 @@ 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') {
|
||||
@@ -1066,6 +1091,12 @@ export class AudioManager {
|
||||
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]);
|
||||
@@ -1140,7 +1171,7 @@ export function installAudio(deps) {
|
||||
try {
|
||||
manifestGetter = typeof deps?.getManifest === 'function' ? deps.getManifest : manifestGetter;
|
||||
audio.install();
|
||||
if (deps?.settings) { audio.setVolumes(deps.settings); audio.setVoiceLang(deps.settings.voiceLang); }
|
||||
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 {
|
||||
|
||||
@@ -136,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();
|
||||
@@ -145,6 +146,7 @@ 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;
|
||||
@@ -188,6 +190,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();
|
||||
}
|
||||
|
||||
@@ -660,6 +660,7 @@ export class Net {
|
||||
const K_NAME = 'sp.name';
|
||||
const K_TOKEN = 'sp.token'; // sessionStorage: this tab's 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
|
||||
@@ -725,6 +726,7 @@ export function createIdentity(deps = {}) {
|
||||
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; // { hashes: Set<hash>, taken: Set<hash> } while init() waits for claims
|
||||
|
||||
@@ -738,6 +740,25 @@ 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 */ }
|
||||
};
|
||||
@@ -773,6 +794,10 @@ export function createIdentity(deps = {}) {
|
||||
// Without a channel we cannot tell whether a shared token is in use: never adopt one.
|
||||
const candidates = [...new Set([ownOk, ...(channel ? readRecent() : [])].filter(isToken))];
|
||||
if (!channel || candidates.length === 0) return ownOk;
|
||||
return queryCandidates(candidates);
|
||||
}
|
||||
|
||||
async function queryCandidates(candidates) {
|
||||
resolving = { hashes: new Set(candidates.map(tokenHash)), taken: new Set() };
|
||||
post({ type: 'who', hashes: [...resolving.hashes] });
|
||||
await new Promise((r) => setTimer(r, queryMs));
|
||||
@@ -802,6 +827,32 @@ export function createIdentity(deps = {}) {
|
||||
}
|
||||
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;
|
||||
sset(session, K_TOKEN, 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 */
|
||||
@@ -816,12 +867,14 @@ export function createIdentity(deps = {}) {
|
||||
/** @param {string} token from `welcome` */
|
||||
saveToken(token) {
|
||||
if (!isToken(token)) return;
|
||||
if (current !== token) rememberMatch(null);
|
||||
current = token;
|
||||
sset(session, K_TOKEN, token);
|
||||
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;
|
||||
sdel(session, K_TOKEN);
|
||||
|
||||
@@ -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);
|
||||
@@ -530,6 +530,7 @@ export class UnitView {
|
||||
this.actor = null;
|
||||
this.spineReady = false;
|
||||
this._modelDirty = false; // died / stood up since the last frame: update() checks Front ⇄ Back (_syncModel)
|
||||
this._pendingDeployElapsed = null; // elapsed game seconds until a late model can continue Start
|
||||
this._spineBusy = false; // a Spine load of this view is in flight
|
||||
this._spineTries = 0; // failed loads since the last model (SPINE_RETRY_MS)
|
||||
this._retryAt = 0; // when the next retry is due (ms, performance clock; 0 = none)
|
||||
@@ -650,7 +651,8 @@ export class UnitView {
|
||||
// no fallback diamond in between; a deploy clip it was playing goes on on the new model (an operator facing UP
|
||||
// redeployed: its Back model takes over from the Front model it lay down with)
|
||||
const swap = !!this.actor;
|
||||
const deployed = swap && this.alive ? this.actor.deployElapsed() : null;
|
||||
const deployed = swap && this.alive ? this.actor.deployElapsed() : this._pendingDeployElapsed;
|
||||
this._pendingDeployElapsed = null;
|
||||
if (swap) this._dropActor();
|
||||
this.actor = actor;
|
||||
this._actorEntry = entry;
|
||||
@@ -961,6 +963,7 @@ export class UnitView {
|
||||
/** An attack was made (b.ev 'atk'). `target` = view or null. */
|
||||
onAttack(target, now, kind) {
|
||||
if (!this.alive) return;
|
||||
this._pendingDeployElapsed = null;
|
||||
// a one-off cast (PROJ[kind].once: 暴鸰's bomb drop) is no attack rhythm: its clip plays once at its own speed
|
||||
const once = !!PROJ[kind]?.once;
|
||||
if (!once) {
|
||||
@@ -1018,6 +1021,7 @@ export class UnitView {
|
||||
onDeploy() {
|
||||
this.fadeIn = 0;
|
||||
if (!this.alive) this.revive();
|
||||
this._pendingDeployElapsed = this.actor ? null : 0;
|
||||
if (this.actor) this.actor.deploy();
|
||||
}
|
||||
|
||||
@@ -1038,6 +1042,7 @@ export class UnitView {
|
||||
*/
|
||||
die(instant = false) {
|
||||
if (!this.alive) return;
|
||||
this._pendingDeployElapsed = null;
|
||||
this.alive = false;
|
||||
this._modelDirty = true;
|
||||
this._dieForm = this._formSpec();
|
||||
@@ -1105,6 +1110,7 @@ export class UnitView {
|
||||
/** @param {number} dt real seconds @param {any} cam camera @param {number} t real clock */
|
||||
update(dt, cam, t) {
|
||||
if (this.destroyed) return;
|
||||
if (this._pendingDeployElapsed != null) this._pendingDeployElapsed += dt * (this.ctx.animRate?.() || 1);
|
||||
// a displacement's slide (slideTo): held where it stood until the snapshot with the destination is shown, then
|
||||
// eased into the sampled position (sync keeps `lx, ly` current) under a constant deceleration
|
||||
if (this.slide) {
|
||||
|
||||
@@ -11,6 +11,7 @@ import { useGameData, Img, RichText } from '../ui/gameComponents.js';
|
||||
import { StepHeader, ExitModal } from '../ui/matchChrome.js';
|
||||
import { MatchInfo, matchInfoModel } from '../ui/matchInfo.js';
|
||||
import { LoadoutButton } from './loadout.js';
|
||||
import { SetupReroll } from '../ui/setupReroll.js';
|
||||
import { actions } from '../ui/gameActions.js';
|
||||
import { factionTypes, sortedPlayers, phaseTotalSeconds } from '../ui/gameLogic.js';
|
||||
import { enemyIconUrl, factionIconUrl } from '../ui/assetUrls.js';
|
||||
@@ -50,7 +51,7 @@ export function BriefingScreen() {
|
||||
const ready = async () => {
|
||||
if (busy || me?.ready) return;
|
||||
setBusy(true);
|
||||
await actions.infoReady();
|
||||
await actions.infoReady(pub.setupRevision ?? 0);
|
||||
setBusy(false);
|
||||
};
|
||||
|
||||
@@ -91,6 +92,7 @@ export function BriefingScreen() {
|
||||
<${MatchInfo} model=${info} />
|
||||
</section>
|
||||
</main>
|
||||
<${SetupReroll} pub=${pub} />
|
||||
<footer class="brief__foot">
|
||||
<${LoadoutButton} from="briefing" size="lg" class="brief-loadout" />
|
||||
<div class="brief-ready">
|
||||
@@ -98,7 +100,7 @@ export function BriefingScreen() {
|
||||
<span class="brief-ready__pips">${players.map((p) => html`<i key=${p.playerId} class=${cx(p.ready && 'on', p.playerId === myId && 'me')} title=${p.name}><${Icon} name="user" /></i>`)}</span>
|
||||
</div>
|
||||
<${Button} variant="primary" size="xl" icon=${me?.ready ? 'check' : me ? 'play' : 'eye'} active=${!!me?.ready} loading=${busy}
|
||||
disabled=${!!me?.ready || !me} onClick=${ready}>${me?.ready ? t('已就绪') : me ? t('准备就绪') : t('观战中')}<//>
|
||||
disabled=${!!me?.ready || !me || !!pub.rerollVote} onClick=${ready}>${me?.ready ? t('已就绪') : me ? t('准备就绪') : t('观战中')}<//>
|
||||
</footer>
|
||||
<${ExitModal} open=${exit} onClose=${() => setExit(false)} solo=${solo} />
|
||||
</div>`;
|
||||
|
||||
@@ -16,6 +16,7 @@ import { chessAvatarUrl, chessPortraitUrl, profIconUrl, skillRecordIconUrl, modu
|
||||
import { data } from '../data.js';
|
||||
import { PROF_NAME, skillLabel, moduleBadge, fullTraitText } from '../ui/loadoutModel.js';
|
||||
import { diySlotList, pickChoices, pickOptions, slotRecord, defaultPick } from '../ui/diyModel.js';
|
||||
import { OperatorVoice } from '../ui/operatorVoice.js';
|
||||
import { CultivationSelects } from './cultivation.js';
|
||||
import { t } from '../../../shared/i18n.js';
|
||||
|
||||
@@ -77,6 +78,7 @@ function SlotCard({ m, slot, pick, illegal, onOpen, onClear, ops = {}, onOps = n
|
||||
</span>
|
||||
${proto ? html`<small class="diy-slot__cult t-dim">${t('原型干员没有潜能与练度')}</small>`
|
||||
: onOps ? html`<span class="diy-slot__cult"><${CultivationSelects} charId=${rec.charId} ops=${ops} onSet=${onOps} /></span>` : null}
|
||||
<${OperatorVoice} charId=${rec.charId} />
|
||||
${illegal ? html`<p class="diy-slot__bad"><${Icon} name="warn" />${t('这项自选在当前版本不可用,开局时会被移除')}</p>` : null}
|
||||
</div>
|
||||
<div class="diy-slot__acts">
|
||||
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
opsOf, setOps, resetOps, moduleRecord,
|
||||
} from '../ui/loadoutModel.js';
|
||||
import { loadoutStore, openLoadout, closeLoadout, setEntries, setOpsMap, applyLoadoutEntries, setNotOwned, applyOwnershipImport, setDiyPicks, applyDiyImport } from '../ui/loadoutSync.js';
|
||||
import { OperatorVoice } from '../ui/operatorVoice.js';
|
||||
import { CultivationSelects, CultivationSection } from './cultivation.js';
|
||||
import { cultivationCharIds } from '../../../shared/protocol.js';
|
||||
import { atPotential } from '../../../shared/potential.js';
|
||||
@@ -387,6 +388,7 @@ function Detail({ m, chess, golden, entries, ops = {}, onChange, onOps, onReset,
|
||||
<${Button} variant="ghost" size="sm" icon="refresh" class="lo-dhead__reset" disabled=${!changed} onClick=${onReset}>${t('恢复默认')}<//>
|
||||
</div>
|
||||
<div class="lo-detail__body" ref=${bodyRef}>
|
||||
<${OperatorVoice} charId=${chess.charId} />
|
||||
<${CultivationSection} charId=${chess.charId} ops=${ops} onSet=${onOps} standIn=${notOwned} />
|
||||
<${LoadoutGarrisons} chess=${level === 'elite' && golden ? golden : chess} m=${m} />
|
||||
<section class="lo-sec">
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
// Texts go through t() (docs/I18N.md); the module-level tables hold msgids (N_) translated where they are shown, the
|
||||
// config.json mode texts come localized from data.js.
|
||||
|
||||
import { ResumeMatchButton } from '../ui/resumeMatch.js';
|
||||
import { useEffect, useRef, useState } from '../../vendor/hooks.module.js';
|
||||
import { DIFFICULTIES, DIFFICULTY_NAMES, DIFFICULTY_COLORS, ROOM_CODE_LEN, MAX_SEATS, MAX_SPECTATORS, modeIdFor, ERR } from '../../../shared/constants.js';
|
||||
import { html, Button, Icon, MicroLabel, Panel, TextField, PingPill, AvatarFrame, Tooltip, Spinner, DifficultyIcon, doctorNo } from '../ui/components.js';
|
||||
@@ -18,6 +19,7 @@ import { toast, toastError } from '../ui/toasts.js';
|
||||
import { GuideButton } from '../ui/guide.js';
|
||||
import { openStats } from './stats.js';
|
||||
import { SettingsButton } from '../ui/settings.js';
|
||||
import { PwaInstallButton } from '../ui/device.js';
|
||||
import { LoadoutButton } from './loadout.js';
|
||||
import { net, identity } from '../net.js';
|
||||
import { store, useStore, shallowEqual, loadPref, savePref } from '../store.js';
|
||||
@@ -315,6 +317,8 @@ export function LobbyScreen() {
|
||||
<div class="topbar__right">
|
||||
<${Button} variant="secondary" size="sm" icon="chart" class="stats-entry" onClick=${openStats} title=${t('统计数据')} aria-label=${t('统计数据')}>${t('统计')}<//>
|
||||
<${SettingsButton} class="lobby-settings" variant="secondary" label=${t('设置')} />
|
||||
<${ResumeMatchButton} />
|
||||
<${PwaInstallButton} class="lobby-pwa" />
|
||||
<${GuideButton} class="lobby-guide" variant="secondary" label=${t('玩法说明')} />
|
||||
<${LoadoutButton} from="lobby" size="sm" class="lobby-loadout" label=${t('干员调配')} />
|
||||
<div class="me-chip">
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
// entry/loading illustration names) it is layered under the CSS art; otherwise the screen is
|
||||
// pure CSS/SVG (radar, ridgelines, glow), so it never issues a request that can 404.
|
||||
|
||||
import { ResumeMatchButton } from '../ui/resumeMatch.js';
|
||||
import { useMemo, useState } from '../../vendor/hooks.module.js';
|
||||
import { NAME_MAX_LEN, APP_VERSION, DEV_BUILD } from '../../../shared/constants.js';
|
||||
import { html, Button, Icon, MicroLabel, TextField, PingPill } from '../ui/components.js';
|
||||
@@ -19,7 +20,7 @@ import { toast } from '../ui/toasts.js';
|
||||
import { net, identity } from '../net.js';
|
||||
import { store, useStore, shallowEqual } from '../store.js';
|
||||
import { data, useData } from '../data.js';
|
||||
import { FullscreenButton, detectFeatures } from '../ui/device.js';
|
||||
import { FullscreenButton, PwaInstallButton, detectFeatures } from '../ui/device.js';
|
||||
import { LangToggle, useLang } from '../ui/lang.js';
|
||||
import { t, N_ } from '../../../shared/i18n.js';
|
||||
import { scriptOf } from '../../../shared/i18nPacks.js';
|
||||
@@ -287,6 +288,8 @@ export function TitleScreen() {
|
||||
<${SettingsModal} open=${settingsOpen} onClose=${() => setSettingsOpen(false)} />
|
||||
|
||||
<footer class="title-foot">
|
||||
<${ResumeMatchButton} />
|
||||
<${PwaInstallButton} />
|
||||
<span>${t('非官方同人复刻 · 游戏素材版权归 上海鹰角网络 / Yostar 所有')}</span>
|
||||
<${MicroLabel}>v${APP_VERSION} · WEB SIMULATION<//>
|
||||
${DEV_BUILD ? html`<span class="title-dev" role="note">${t('开发版 · 不稳定,请勿用于公开服务器')}</span>` : null}
|
||||
|
||||
+23
-1
@@ -20,7 +20,8 @@
|
||||
// CSS counterpart: public/css/devices.css (safe-area insets, touch-action, overscroll, tap-target expansion).
|
||||
|
||||
import { useEffect, useState } from '../../vendor/hooks.module.js';
|
||||
import { html, Icon } from './components.js';
|
||||
import { pwaInstall } from '../pwa.js';
|
||||
import { html, Icon, Button } from './components.js';
|
||||
import { t } from '../../../shared/i18n.js';
|
||||
|
||||
/** A touch held this long without moving opens the detail (contextmenu) on DOM controls. */
|
||||
@@ -162,6 +163,20 @@ export function FullscreenButton({ class: cls = '' }) {
|
||||
</button>`;
|
||||
}
|
||||
|
||||
/** Only a browser-issued install offer shows a button; installed and unsupported environments stay quiet. */
|
||||
export function PwaInstallButton({ class: cls = '', size = 'sm' }) {
|
||||
const [available, setAvailable] = useState(pwaInstall.available);
|
||||
useEffect(() => pwaInstall.subscribe(setAvailable), []);
|
||||
if (!available) return null;
|
||||
return html`<${Button} variant="secondary" size=${size} icon="download" class=${cls}
|
||||
data-testid="pwa-install" onClick=${() => pwaInstall.request()}>${t('添加到桌面')}<//>`;
|
||||
}
|
||||
|
||||
export function updateRotateHintPwaI18n(doc = globalThis.document) {
|
||||
const btn = doc?.getElementById?.('rotate-hint-pwa');
|
||||
if (btn) btn.textContent = t('添加到桌面');
|
||||
}
|
||||
|
||||
// ---- boot-time installation ----------------------------------------------------------------------------------------
|
||||
|
||||
let installed = null;
|
||||
@@ -253,6 +268,13 @@ export function installDeviceSupport(win = globalThis) {
|
||||
if (lp.swallowUntil && Date.now() < lp.swallowUntil) { lp.swallowUntil = 0; e.preventDefault(); e.stopPropagation(); }
|
||||
}, { capture: true });
|
||||
|
||||
const pwaBtn = doc?.getElementById?.('rotate-hint-pwa');
|
||||
if (pwaBtn) {
|
||||
updateRotateHintPwaI18n(doc);
|
||||
offs.push(pwaInstall.subscribe((available) => { pwaBtn.hidden = !available; }));
|
||||
on(pwaBtn, 'click', () => { pwaInstall.request(); });
|
||||
}
|
||||
|
||||
installed = () => { for (const off of offs.splice(0)) { try { off(); } catch { /* ignore */ } } clearTimeout(rotTimer); installed = null; };
|
||||
return installed;
|
||||
}
|
||||
@@ -48,7 +48,10 @@ export async function act(t, fields = {}, opts = {}) {
|
||||
}
|
||||
|
||||
export const actions = {
|
||||
infoReady: () => act('g.infoReady'),
|
||||
infoReady: (setupRevision = 0) => act('g.infoReady', { setupRevision }),
|
||||
rerollSetup: (setupRevision) => act('room.rerollSetup', { setupRevision }, { sfx: 'confirm' }),
|
||||
rerollVote: (voteId, agree) => act('g.rerollVote', { voteId, agree }, { sfx: agree ? 'confirm' : 'back' }),
|
||||
cancelReroll: (voteId) => act('room.cancelReroll', { voteId }, { sfx: 'back' }),
|
||||
band: (bandId) => act('g.band', { bandId }),
|
||||
bandSkip: () => act('g.bandSkip'),
|
||||
buy: (slot) => act('g.buy', { slot }),
|
||||
|
||||
@@ -40,7 +40,7 @@ export { normalizeDraft, normalizeSp, normalizePersonalChoice } from './gameLogi
|
||||
export { groupEnemies, PEN, penZoneTiles, penPlacement, previewEnemyKey, factionTypes } from './gameLogic/enemies.js';
|
||||
export { snapHud, bossFrac, bossPctText, hasFlag, UF, attackInterval, fmtNum, rangeGridBox } from './gameLogic/format.js';
|
||||
export { shortcutFor, closesOnFieldPress, shortcutBlocked, HOTKEY_ACTIONS, DEFAULT_HOTKEYS, isBindableCode, hotkeyLabel, sanitizeHotkeys, rebindHotkey, isDefaultHotkeys, hotkeyOf, actionForKey, captureHotkey, facingSwallows, facingEnter } from './gameLogic/shortcuts.js';
|
||||
export { DEFAULT_SETTINGS, VOICE_LANGS, sanitizeSettings } from './gameLogic/settings.js';
|
||||
export { DEFAULT_SETTINGS, VOICE_LANGS, TEXT_SIZES, sanitizeSettings } from './gameLogic/settings.js';
|
||||
export { normalizeResult } from './gameLogic/result.js';
|
||||
export { chessLoadout, unitLoadout, unitCultivation } from './gameLogic/loadout.js';
|
||||
export { standInIds, fieldsStandIn, standInOf, ownStandIn, cardStandIn, memberStandIn, standInGetter, standInLoadout, deployedRecord, deployedModuleId, standInLabel, standInForText, standInTip } from './gameLogic/standIn.js';
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
// ui/gameLogic/settings.js — settings defaults and sanitising. Re-exported from ../gameLogic.js.
|
||||
|
||||
import { VOICE_LANGS, sanitizeVoiceOverrides } from '../../voicePrefs.js';
|
||||
export { VOICE_LANGS } from '../../voicePrefs.js';
|
||||
|
||||
import { clamp, isObj } from './shared.js';
|
||||
import { DEFAULT_HOTKEYS, sanitizeHotkeys } from './shortcuts.js';
|
||||
|
||||
@@ -10,17 +13,25 @@ import { DEFAULT_HOTKEYS, sanitizeHotkeys } from './shortcuts.js';
|
||||
* The operator voice dubs (settings 语音语言): 'cn' 中文 (`audio.voice`, the default) and 'jp' 日本語 (`audio.voiceJp`,
|
||||
* falling back to the Chinese line it lacks — public/js/audio.js voiceLine). Not tied to the interface language.
|
||||
*/
|
||||
export const VOICE_LANGS = Object.freeze(['cn', 'jp']);
|
||||
|
||||
/** keys: the in-match shortcuts' key map (ui/gameLogic/shortcuts.js; settings → 快捷键). voiceLang: VOICE_LANGS. */
|
||||
export const DEFAULT_SETTINGS = Object.freeze({ bgm: 0.6, sfx: 0.8, voice: 0.8, voiceLang: 'cn', muted: false, damageNumbers: true, quality: 'high', keys: DEFAULT_HOTKEYS });
|
||||
|
||||
/**
|
||||
* The steps of 设置 →「文字大小」 (textSize): the interface text root `--t` of css/theme.css — 'sm' is the design's own
|
||||
* sizes (`--t: 1rem`), the others lift the phone's 40 px root by a floor and grow the desktop gently. Text only: the
|
||||
* layout root `1rem` (and with it the field camera, the detail card's side and the DOM fallback board) never moves.
|
||||
*/
|
||||
export const TEXT_SIZES = Object.freeze(['sm', 'md', 'lg', 'xl']);
|
||||
|
||||
/** keys: the in-match shortcuts' key map (ui/gameLogic/shortcuts.js; settings → 快捷键). voiceLang: VOICE_LANGS.
|
||||
* textSize: TEXT_SIZES (css/theme.css `--t`, applied by ui/settings.js applyTextSize). */
|
||||
export const DEFAULT_SETTINGS = Object.freeze({ bgm: 0.6, sfx: 0.8, voice: 0.8, voiceLang: 'cn', voiceOverrides: Object.freeze({}), muted: false, damageNumbers: true, quality: 'high', textSize: 'sm', keys: DEFAULT_HOTKEYS });
|
||||
const QUALITIES = ['high', 'medium', 'low'];
|
||||
|
||||
/**
|
||||
* Sanitize persisted settings.
|
||||
* @param {any} raw
|
||||
* @returns {{ bgm: number, sfx: number, voice: number, voiceLang: 'cn'|'jp', muted: boolean, damageNumbers: boolean, quality: 'high'|'medium'|'low',
|
||||
* keys: Record<'refresh'|'freeze'|'levelUp'|'retreat'|'sell'|'ready', string> }}
|
||||
* @returns {{ bgm: number, sfx: number, voice: number, voiceLang: 'cn'|'jp', voiceOverrides: Record<string, string>, muted: boolean, damageNumbers: boolean, quality: 'high'|'medium'|'low',
|
||||
* textSize: 'sm'|'md'|'lg'|'xl', keys: Record<'refresh'|'freeze'|'levelUp'|'retreat'|'sell'|'ready', string> }}
|
||||
*/
|
||||
export function sanitizeSettings(raw) {
|
||||
const r = isObj(raw) ? raw : {};
|
||||
@@ -30,9 +41,11 @@ export function sanitizeSettings(raw) {
|
||||
sfx: vol(r.sfx, DEFAULT_SETTINGS.sfx),
|
||||
voice: vol(r.voice, DEFAULT_SETTINGS.voice),
|
||||
voiceLang: VOICE_LANGS.includes(r.voiceLang) ? r.voiceLang : DEFAULT_SETTINGS.voiceLang,
|
||||
voiceOverrides: sanitizeVoiceOverrides(r.voiceOverrides),
|
||||
muted: typeof r.muted === 'boolean' ? r.muted : DEFAULT_SETTINGS.muted,
|
||||
damageNumbers: typeof r.damageNumbers === 'boolean' ? r.damageNumbers : DEFAULT_SETTINGS.damageNumbers,
|
||||
quality: QUALITIES.includes(r.quality) ? r.quality : DEFAULT_SETTINGS.quality,
|
||||
textSize: TEXT_SIZES.includes(r.textSize) ? r.textSize : DEFAULT_SETTINGS.textSize,
|
||||
keys: sanitizeHotkeys(r.keys),
|
||||
};
|
||||
}
|
||||
@@ -23,6 +23,7 @@ import { PACKS_URL, PACK_INDEX_FILE, readPackIndex, langMetaOf } from '../../../
|
||||
import { DEV_BUILD } from '../../../shared/constants.js';
|
||||
import { loadPref, savePref } from '../store.js';
|
||||
import { data } from '../data.js';
|
||||
import { updateRotateHintPwaI18n } from './device.js';
|
||||
import { html } from './components.js';
|
||||
|
||||
/** The switch's own label, in both languages (whoever opens it may not read the current one). */
|
||||
@@ -160,6 +161,7 @@ function applyDocument(lang) {
|
||||
doc.documentElement.dataset.lang = lang;
|
||||
doc.documentElement.dataset.script = scriptOf(t('卫戍协议'));
|
||||
doc.title = t('卫戍协议:盟约 · STRONGHOLD PROTOCOL');
|
||||
updateRotateHintPwaI18n(doc);
|
||||
}
|
||||
|
||||
// `{ dn }` params and tName(): Chinese game-data names → the current language (data/i18n/<lang>.json names)
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
// Local listening preference, shared by roster and DIY details; deliberately absent from room.loadout.
|
||||
import { html } from './components.js';
|
||||
import { useSettings, updateSettings } from './settings.js';
|
||||
import { t } from '../../../shared/i18n.js';
|
||||
|
||||
const VOICE_LANG_NAMES = { cn: '中文', jp: '日本語' }; // i18n-ignore
|
||||
|
||||
export function OperatorVoice({ charId }) {
|
||||
const settings = useSettings();
|
||||
if (!charId) return null;
|
||||
const value = settings.voiceOverrides[charId] || '';
|
||||
const change = (lang) => {
|
||||
const voiceOverrides = { ...settings.voiceOverrides };
|
||||
if (lang) voiceOverrides[charId] = lang;
|
||||
else delete voiceOverrides[charId];
|
||||
updateSettings({ voiceOverrides });
|
||||
};
|
||||
return html`<label class="lo-voice" data-voice-char=${charId}>
|
||||
<span>${t('此干员语音')}</span>
|
||||
<span class="lo-select"><select aria-label=${t('此干员语音')} value=${value} onChange=${(e) => change(e.currentTarget.value)}>
|
||||
<option value="">${t('跟随全局')}</option><option value="cn">${VOICE_LANG_NAMES.cn}</option><option value="jp">${VOICE_LANG_NAMES.jp}</option>
|
||||
</select></span>
|
||||
<small>${t('仅保存在此浏览器,缺失的日语语音会回退到中文。')}</small>
|
||||
</label>`;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
// Recover an explicitly selected local seat; nickname and IP never identify a player.
|
||||
import { useEffect, useState } from '../../vendor/hooks.module.js';
|
||||
import { html, Button, Modal } from './components.js';
|
||||
import { identity } from '../net.js';
|
||||
import { t } from '../../../shared/i18n.js';
|
||||
import { toast } from './toasts.js';
|
||||
|
||||
export function ResumeMatchButton() {
|
||||
const [items, setItems] = useState(() => identity.recoverable());
|
||||
const [open, setOpen] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
useEffect(() => {
|
||||
const refresh = () => setItems(identity.recoverable());
|
||||
window.addEventListener('storage', refresh);
|
||||
window.addEventListener('focus', refresh);
|
||||
refresh();
|
||||
return () => { window.removeEventListener('storage', refresh); window.removeEventListener('focus', refresh); };
|
||||
}, []);
|
||||
if (!items.length) return null;
|
||||
const resume = async (item) => {
|
||||
if (busy) return;
|
||||
setBusy(true);
|
||||
if (await identity.resume(item.id)) {
|
||||
identity.saveName(item.name);
|
||||
identity.setEntered(true);
|
||||
location.reload();
|
||||
} else {
|
||||
setBusy(false);
|
||||
toast(t('此对局仍在其他窗口中,或当前浏览器无法安全恢复。请关闭原窗口后重试。'), 'warn');
|
||||
}
|
||||
};
|
||||
return html`<${Button} variant="secondary" size="sm" data-testid="resume-local-match" onClick=${() => setOpen(true)}>${t('恢复本机对局')}<//>
|
||||
<${Modal} open=${open} onClose=${() => !busy && setOpen(false)} title=${t('恢复本机对局')}>
|
||||
<p>${t('选择已关闭窗口的对局。服务器重启或对局过期后无法恢复。')}</p>
|
||||
${items.map((item) => html`<${Button} key=${item.id} variant="secondary" block=${true} disabled=${busy} onClick=${() => resume(item)}>
|
||||
${t('{name} · 同盟 {code}', { name: item.name || t('博士'), code: item.code || '—' })}<//>`)}
|
||||
<//>`;
|
||||
}
|
||||
+50
-16
@@ -1,18 +1,24 @@
|
||||
// Player settings (BGM/SFX/voice volume, the voice dub 语音语言, mute, damage numbers, render quality, the shortcut keys): a
|
||||
// tiny observable store persisted in localStorage (`sp.pref.settings`), applied to the audio manager on every change, plus
|
||||
// the settings modal — which also holds the language switch (ui/lang.js; kept apart in `sp.pref.lang`; under it a note
|
||||
// while the current language's pack is a machine translation, `_meta.machineTranslated`) and the 快捷键
|
||||
// section that rebinds the in-match shortcuts (the key map: ui/gameLogic/shortcuts.js; the community request
|
||||
// 「快捷键可不可以自己设置」, the owner's decision of 2026-10-07) and 问题反馈, which copies the diagnostics of this page
|
||||
// for a bug report (diag.js: the error log, this browser, optionally the battle on screen; nothing is uploaded). The
|
||||
// lobby and the room open it from a 设置 button next to 玩法说明 (SettingsButton, GitHub #238); the title screen and the
|
||||
// match have their own gear.
|
||||
// Player settings (BGM/SFX/voice volume, the voice dub 语音语言, mute, damage numbers, render quality, 文字大小, the
|
||||
// shortcut keys): a tiny observable store persisted in localStorage (`sp.pref.settings`), applied to the audio manager
|
||||
// and to the document on every change, plus the settings modal — which also holds the language switch (ui/lang.js; kept
|
||||
// apart in `sp.pref.lang`; under it a note while the current language's pack is a machine translation,
|
||||
// `_meta.machineTranslated`) and the 快捷键 section that rebinds the in-match shortcuts (the key map:
|
||||
// ui/gameLogic/shortcuts.js; the community request 「快捷键可不可以自己设置」, the owner's decision of 2026-10-07) and
|
||||
// 问题反馈, which copies the diagnostics of this page for a bug report (diag.js: the error log, this browser, optionally
|
||||
// the battle on screen; nothing is uploaded). The lobby and the room open it from a 设置 button next to 玩法说明
|
||||
// (SettingsButton, GitHub #238); the title screen and the match have their own gear.
|
||||
//
|
||||
// 文字大小 (textSize, applied by applyTextSize): the interface text root `--t` of css/theme.css — a phone clamps the
|
||||
// layout root `1rem` at 40 px (theme.css), which left the .18rem body text at 7.2 CSS px while the browser's own font
|
||||
// settings only inflate the glyphs inside fixed boxes (and page zoom is off: index.html's viewport, ui/device.js).
|
||||
// Only font-size declarations read `--t`, so the board, the HUD bands the prep camera keeps clear and the detail
|
||||
// card's side do not move — the field is sized from the host element's clientWidth (render/app.js).
|
||||
|
||||
import { useLayoutEffect, useRef, useState } from '../../vendor/hooks.module.js';
|
||||
import { html, Modal, Button, Icon, MicroLabel } from './components.js';
|
||||
import { GIcon } from './gameComponents.js';
|
||||
import { createStore, useStore, loadPref, savePref, store } from '../store.js';
|
||||
import { sanitizeSettings, HOTKEY_ACTIONS, DEFAULT_HOTKEYS, hotkeyLabel, rebindHotkey, isDefaultHotkeys, captureHotkey, VOICE_LANGS } from './gameLogic.js';
|
||||
import { sanitizeSettings, HOTKEY_ACTIONS, DEFAULT_HOTKEYS, hotkeyLabel, rebindHotkey, isDefaultHotkeys, captureHotkey, VOICE_LANGS, TEXT_SIZES } from './gameLogic.js';
|
||||
import { audio } from '../audio.js';
|
||||
import { openGuide } from './guide.js';
|
||||
import { detectFeatures } from './device.js';
|
||||
@@ -23,16 +29,31 @@ import { copyText } from './clipboard.js';
|
||||
|
||||
const cx = (...p) => p.flat().filter(Boolean).join(' ');
|
||||
|
||||
/** Settings store: { bgm, sfx, voice, voiceLang, muted, damageNumbers, quality, keys }. */
|
||||
/** Settings store: { bgm, sfx, voice, voiceLang, voiceOverrides, muted, damageNumbers, quality, textSize, keys }. */
|
||||
export const settingsStore = createStore(sanitizeSettings(loadPref('settings', null)));
|
||||
|
||||
/**
|
||||
* 设置 →「文字大小」: put the step on <html data-text> — css/theme.css turns it into the text root `--t`
|
||||
* (`:root[data-text="md"] { --t: … }`), which every readable font-size reads; no layout value does. The attribute
|
||||
* (not an inline style) keeps the default in the stylesheet: without it — no JavaScript, an old saved profile, a
|
||||
* value a future version dropped — the page is the design's own sizes. Values outside TEXT_SIZES fall back to 小.
|
||||
* @param {'sm'|'md'|'lg'|'xl'} v
|
||||
*/
|
||||
export function applyTextSize(v) {
|
||||
const el = globalThis.document?.documentElement;
|
||||
if (el) el.dataset.text = TEXT_SIZES.includes(v) ? v : 'sm';
|
||||
}
|
||||
|
||||
settingsStore.subscribe((s) => {
|
||||
savePref('settings', sanitizeSettings(s));
|
||||
audio.setVolumes(s);
|
||||
audio.setVoiceLang(s.voiceLang);
|
||||
audio.setVoiceLang(s.voiceLang, s.voiceOverrides);
|
||||
applyTextSize(s.textSize);
|
||||
});
|
||||
audio.setVolumes(settingsStore.get());
|
||||
audio.setVoiceLang(settingsStore.get().voiceLang);
|
||||
audio.setVoiceLang(settingsStore.get().voiceLang, settingsStore.get().voiceOverrides);
|
||||
// before the first render (main.js boot renders after its imports ran): the stored step is on screen without a flash
|
||||
applyTextSize(settingsStore.get().textSize);
|
||||
|
||||
/** @param {Partial<ReturnType<typeof sanitizeSettings>>} patch */
|
||||
export function updateSettings(patch) {
|
||||
@@ -68,6 +89,11 @@ function Toggle({ label, micro, value, onChange }) {
|
||||
}
|
||||
|
||||
const QUALITY = [['high', N_('高')], ['medium', N_('中')], ['low', N_('低')]];
|
||||
/**
|
||||
* 文字大小: one step of TEXT_SIZES (ui/gameLogic/settings.js) with its label — 小 is the design's own sizes, the others
|
||||
* raise the text root `--t` (css/theme.css) and with it every readable font-size; the board and the HUD's boxes stay.
|
||||
*/
|
||||
const TEXT_SCALES = [['sm', N_('小')], ['md', N_('中')], ['lg', N_('大')], ['xl', N_('特大')]];
|
||||
/**
|
||||
* 语音语言: each dub named in its own language, like the interface language switch (ui/lang.js) — the owner's
|
||||
* 「中文 / 日本語」 (2026-10-08); VOICE_LANGS order.
|
||||
@@ -216,8 +242,8 @@ export function SettingsModal({ open, onClose }) {
|
||||
const [touchUi] = useState(() => detectFeatures().coarse && !detectFeatures().fine);
|
||||
const mtNote = machineTranslationNote(); // a pack marked as machine translation says so under the switch
|
||||
return html`<${Modal} open=${open} onClose=${onClose} title=${t('设置')} micro="SETTINGS" width="7.4rem"
|
||||
actions=${html`<${Button} variant="secondary" icon="book" class="set-guide" onClick=${() => openGuide(0)}>${t('玩法说明')}<//>
|
||||
<${Button} variant="primary" icon="check" onClick=${onClose}>${t('完成')}<//>`}>
|
||||
actions=${html`<${Button} variant="secondary" icon="book" class="set-guide" onClick=${() => openGuide(0)}>${t('玩法说明')}<//>
|
||||
<${Button} variant="primary" icon="check" onClick=${onClose}>${t('完成')}<//>`}>
|
||||
<div class="set-list">
|
||||
<div class="set-row">
|
||||
<span class="set-row__label">${t('语言')}<${MicroLabel}>LANGUAGE<//></span>
|
||||
@@ -234,7 +260,7 @@ export function SettingsModal({ open, onClose }) {
|
||||
</div>
|
||||
</div>
|
||||
<${Slider} label=${t('音效')} micro="SFX" icon="signal" value=${s.sfx}
|
||||
onInput=${(v) => { updateSettings({ sfx: v }); if (!tested) { setTested(true); setTimeout(() => setTested(false), 400); audio.sfx('click'); } }} />
|
||||
onInput=${(v) => { updateSettings({ sfx: v }); if (!tested) { setTested(true); setTimeout(() => setTested(false), 400); audio.sfx('click'); } }} />
|
||||
<${Toggle} label=${t('静音')} micro="MUTE" value=${s.muted} onChange=${(v) => updateSettings({ muted: v })} />
|
||||
<${Toggle} label=${t('显示伤害数字')} micro="DAMAGE NUMBERS" value=${s.damageNumbers} onChange=${(v) => updateSettings({ damageNumbers: v })} />
|
||||
<div class="set-row">
|
||||
@@ -244,6 +270,14 @@ export function SettingsModal({ open, onClose }) {
|
||||
class=${s.quality === id ? 'is-on' : ''} onClick=${() => updateSettings({ quality: id })}>${t(label)}</button>`)}
|
||||
</div>
|
||||
</div>
|
||||
<div class="set-row">
|
||||
<span class="set-row__label">${t('文字大小')}<${MicroLabel}>TEXT SIZE<//></span>
|
||||
<div class="set-seg set-textsize" role="radiogroup" aria-label=${t('文字大小')} data-testid="text-size">
|
||||
${TEXT_SCALES.map(([id, label]) => html`<button key=${id} type="button" role="radio" aria-checked=${s.textSize === id ? 'true' : 'false'}
|
||||
class=${s.textSize === id ? 'is-on' : ''} onClick=${() => updateSettings({ textSize: id })}>${t(label)}</button>`)}
|
||||
</div>
|
||||
</div>
|
||||
<p class="set-hint set-textsize-note">${t('调整界面文字大小,棋盘保持原比例。')}</p>
|
||||
<${HotkeySection} keys=${s.keys} touchUi=${touchUi} />
|
||||
<${DiagSection} />
|
||||
<p class="set-hint">${touchUi ? t('触屏操作:点击单位选中(撤退 / 出售)· 长按单位或卡牌查看详情 · 拖动部署后滑动选择朝向') : t('右键查看详情')}</p>
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
// A server-owned unanimous vote. Reconnects render the current vote from m.public.
|
||||
import { useRef, useState } from '../../vendor/hooks.module.js';
|
||||
import { html, Button } from './components.js';
|
||||
import { actions } from './gameActions.js';
|
||||
import { useStore } from '../store.js';
|
||||
import { t } from '../../../shared/i18n.js';
|
||||
|
||||
export function SetupReroll({ pub }) {
|
||||
const myId = useStore((s) => s.me.playerId);
|
||||
const hostId = useStore((s) => s.room?.hostId);
|
||||
const online = useStore((s) => s.connection.status === 'online');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const pending = useRef(false);
|
||||
const vote = pub.rerollVote;
|
||||
const host = myId === hostId;
|
||||
const players = pub.players || [];
|
||||
const me = players.find((p) => p.playerId === myId && p.status !== 'left');
|
||||
const allConnected = players.every((p) => p.isBot || p.status === 'left' || p.connected);
|
||||
const voter = !!vote?.voters.includes(myId);
|
||||
const agreed = !!vote?.agreed.includes(myId);
|
||||
const send = async (fn) => {
|
||||
if (pending.current) return;
|
||||
pending.current = true;
|
||||
setBusy(true);
|
||||
try { await fn(); } finally { pending.current = false; setBusy(false); }
|
||||
};
|
||||
if (!vote && (!host || !me)) return null;
|
||||
return html`<section class="brief-reroll" aria-label=${t('重刷本局')} aria-live="polite">
|
||||
<div class="brief-reroll__text">
|
||||
<strong>${vote ? t('重刷本局投票:{agreed}/{total}', { agreed: vote.agreed.length, total: vote.voters.length }) : t('重刷本局')}</strong>
|
||||
<span>${vote ? t('全员同意后重新随机战场、领袖、特训敌人和禁用;确认倒计时已暂停。') : t('保留房间和个人配置,重新随机全部开局信息。')}</span>
|
||||
${vote ? html`<div class="brief-reroll__players">${vote.voters.map((id) => {
|
||||
const p = players.find((p) => p.playerId === id);
|
||||
return html`<span key=${id} class=${vote.agreed.includes(id) ? 'is-agreed' : ''}>${p?.name || id} · ${vote.agreed.includes(id) ? t('已同意') : t('待同意')}</span>`;
|
||||
})}</div>` : null}
|
||||
</div>
|
||||
<div class="brief-reroll__actions">
|
||||
${!vote ? html`<${Button} icon="refresh" size="lg" loading=${busy} disabled=${!online || !allConnected}
|
||||
title=${allConnected ? t('全体真人玩家一致同意后刷新,随机结果可能重复。') : t('请等待所有玩家连接后再发起投票')}
|
||||
onClick=${() => send(() => actions.rerollSetup(pub.setupRevision ?? 0))}>${t('重刷本局')}<//>` : null}
|
||||
${vote && voter ? html`<${Button} variant="primary" loading=${busy} disabled=${!online || agreed}
|
||||
onClick=${() => send(() => actions.rerollVote(vote.id, true))}>${agreed ? t('已同意') : t('同意重刷')}<//>
|
||||
<${Button} variant="danger" disabled=${busy || !online}
|
||||
onClick=${() => send(() => actions.rerollVote(vote.id, false))}>${t('拒绝重刷')}<//>` : null}
|
||||
${vote && host && me ? html`<${Button} disabled=${busy || !online}
|
||||
onClick=${() => send(() => actions.cancelReroll(vote.id))}>${t('取消投票')}<//>` : null}
|
||||
</div>
|
||||
</section>`;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
// Browser-local dub preferences. Every variant of an operator shares its charId; absent entries follow the global dub.
|
||||
export const VOICE_LANGS = Object.freeze(['cn', 'jp']);
|
||||
export function sanitizeVoiceOverrides(raw) {
|
||||
const out = {};
|
||||
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return out;
|
||||
for (const [id, lang] of Object.entries(raw).slice(0, 512)) {
|
||||
if (/^char_[0-9]+_[a-z0-9]+$/.test(id) && id.length <= 80 && VOICE_LANGS.includes(lang)) out[id] = lang;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
export function voiceLangFor(charId, globalLang, overrides) {
|
||||
const own = Object.hasOwn(overrides || {}, charId) ? overrides[charId] : null;
|
||||
return VOICE_LANGS.includes(own) ? own : globalLang === 'jp' ? 'jp' : 'cn';
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"id": "/",
|
||||
"name": "卫戍协议:盟约",
|
||||
"short_name": "卫戍协议",
|
||||
"description": "明日方舟「卫戍协议:盟约」非官方同人复刻",
|
||||
"start_url": "/",
|
||||
"scope": "/",
|
||||
"display": "fullscreen",
|
||||
"orientation": "landscape-primary",
|
||||
"background_color": "#0c0f0e",
|
||||
"theme_color": "#0c0f0e",
|
||||
"icons": [
|
||||
{ "src": "/icons/app-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },
|
||||
{ "src": "/icons/app-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" }
|
||||
]
|
||||
}
|
||||
@@ -343,6 +343,8 @@ export class Lobby {
|
||||
case 'room.removeBot': return this.removeBot(session, msg);
|
||||
case 'room.kick': return this.kick(session, msg);
|
||||
case 'room.start': return this.start(session);
|
||||
case 'room.rerollSetup': return this.rerollSetup(session, msg);
|
||||
case 'room.cancelReroll': return this.rerollSetup(session, msg, true);
|
||||
case 'room.loadout': return this.loadout(session, msg);
|
||||
case 'room.ownership': return this.ownership(session, msg);
|
||||
case 'room.diy': return this.diy(session, msg);
|
||||
@@ -619,6 +621,17 @@ export class Lobby {
|
||||
return this.startMatch(room, key);
|
||||
}
|
||||
|
||||
/** Host authorization stays in the lobby; the match owns the vote and setup. */
|
||||
rerollSetup(session, msg, cancel = false) {
|
||||
const room = this.roomOf(session);
|
||||
if (!room) return fail(ERR.NOT_IN_ROOM);
|
||||
if (room.spectatorOf(session.playerId)) return fail(ERR.SPECTATOR);
|
||||
if (room.hostId !== session.playerId) return fail(ERR.NOT_HOST);
|
||||
const method = cancel ? 'cancelSetupReroll' : 'requestSetupReroll';
|
||||
if (!room.match || typeof room.match[method] !== 'function') return fail(ERR.WRONG_PHASE);
|
||||
return this.callMatch(room, method, session.playerId, cancel ? msg.voteId : msg.setupRevision) || fail(ERR.INTERNAL);
|
||||
}
|
||||
|
||||
/**
|
||||
* room.loadout (DESIGN §16): check the operator loadout — and its per-operator 潜能 / 练度 `ops` (0.2.2) — against the
|
||||
* game data, store both on the session and the seat, and — while a match runs — hand them to the match (accepted only
|
||||
|
||||
@@ -191,6 +191,7 @@ import { MatchWatch } from './match/watch.js';
|
||||
import { MatchIntents } from './match/intents.js';
|
||||
import { MatchPause } from './match/pause.js';
|
||||
import { MatchPhases } from './match/phases.js';
|
||||
import { MatchSetupVote } from './match/setupVote.js';
|
||||
import { MatchSpDraft } from './match/spDraft.js';
|
||||
import { MatchPrep } from './match/prep.js';
|
||||
import { MatchCombat } from './match/combat.js';
|
||||
@@ -348,6 +349,11 @@ export class Match {
|
||||
/** @type {Set<any>} */
|
||||
this._timers = new Set();
|
||||
this._phaseTimer = null;
|
||||
this._infoAdvanceTimer = null;
|
||||
this.setupRevision = 0;
|
||||
this.setupVote = null;
|
||||
this._setupVoteSeq = 0;
|
||||
this._lastSetupVoteAt = -Infinity;
|
||||
this._turnTimer = null;
|
||||
this._pubDirty = false;
|
||||
this._pubTimer = null;
|
||||
@@ -394,7 +400,7 @@ export class Match {
|
||||
}
|
||||
|
||||
// the method modules, in this order (a name defined twice is an error, never a silent override)
|
||||
for (const part of [MatchPlatform, MatchInfra, MatchMessaging, MatchViews, MatchWatch, MatchIntents, MatchPause, MatchPhases, MatchSpDraft, MatchPrep, MatchCombat, MatchClientCombat, MatchReports, MatchUnite, MatchBoss, MatchSettle]) {
|
||||
for (const part of [MatchPlatform, MatchInfra, MatchMessaging, MatchViews, MatchWatch, MatchIntents, MatchPause, MatchPhases, MatchSetupVote, MatchSpDraft, MatchPrep, MatchCombat, MatchClientCombat, MatchReports, MatchUnite, MatchBoss, MatchSettle]) {
|
||||
for (const key of Reflect.ownKeys(part.prototype)) {
|
||||
if (key === 'constructor') continue;
|
||||
if (Object.prototype.hasOwnProperty.call(Match.prototype, key)) throw new Error(`Match.${String(key)} is defined twice`);
|
||||
|
||||
@@ -603,6 +603,11 @@ function buyScore(m, ps, id, ctx) {
|
||||
if (ctx.model && !isHealer(c)) s += ARMOR_WEIGHT * 0.6 * (armorFit(ctx.model, c) - 0.6);
|
||||
// the player's own 自选 piece (a slotted slot: its composed record carries diyFor) — AI 托管 of a human with picks
|
||||
if (c.diyFor) s += DIY_PIECE_BONUS;
|
||||
// [ASSUMED] A soft 18-point cooperation weight (PR #407), not an official rule or a reserved card.
|
||||
// Leave high-tier shared chess to living teammates whose opening strategy needs its faction.
|
||||
// Humans and bots count equally; a single soft penalty still allows valuable own merges.
|
||||
if (c.tier >= 5 && !c.isGolden && !c.diyFor && m.pool.has(base) && m.alivePlayers().some((mate) =>
|
||||
mate !== ps && gd.bandBondIds(mate.bandId).some((b) => !gd.modeInactiveBonds.has(b) && c.bonds.includes(b)))) s -= 18;
|
||||
return s;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
// AI teammate reactions, adapted from PR #444 by @DDDarkstar.
|
||||
// [ASSUMED] Reaction choices are social heuristics, not official gameplay rules.
|
||||
// Only mixed human/AI matches; no RNG, decisions, timer scheduling or battle changes.
|
||||
// Enabled by default; SP_BOT_EMOTES=0 disables every entry point.
|
||||
import { EMOTES, EMOTE_COOLDOWN_MS } from '../../shared/constants.js';
|
||||
export const BOT_EMOTES_ON = process.env.SP_BOT_EMOTES !== '0';
|
||||
const E = (name) => `autochess_battle_${name}`;
|
||||
export function hasHumans(m) {
|
||||
return !!m?.order?.some((p) => p && !p.isBot) && !!m?.order?.some((p) => p?.isBot);
|
||||
}
|
||||
export function sendEmote(m, p, id) {
|
||||
if (!BOT_EMOTES_ON || !hasHumans(m) || !p?.isBot || !p.alive || !EMOTES.includes(id)) return false;
|
||||
const now = m.sched.now();
|
||||
if (typeof p.lastEmoteAt === 'number' && now - p.lastEmoteAt < EMOTE_COOLDOWN_MS) return false;
|
||||
p.lastEmoteAt = now;
|
||||
m.broadcast({ t: 'm.emote', playerId: p.playerId, id });
|
||||
return true;
|
||||
}
|
||||
export function onHumanEmote(m, senderId, id) {
|
||||
if (!BOT_EMOTES_ON || !hasHumans(m) || !m.order.some((p) => p.playerId === senderId && !p.isBot)) return false;
|
||||
const replies = { [E('thanks')]: E('noproblem'), [E('sorry')]: E('noproblem'),
|
||||
[E('nice_cooperate')]: E('thanks'), [E('happy')]: E('happy'), [E('call')]: E('playingcool'),
|
||||
[E('playingcool')]: E('happy'), [E('sad')]: E('sad') };
|
||||
const reply = replies[id];
|
||||
if (!reply) return false;
|
||||
return m.order.some((p) => p.isBot && sendEmote(m, p, reply)); // at most one reply, skipping cooling bots
|
||||
}
|
||||
export function onPickCard(m, p, card) {
|
||||
return card?.kind === 'bounty' && sendEmote(m, p, E('thinking'));
|
||||
}
|
||||
export function onStartUnite(m, plan) {
|
||||
return !!plan?.helpers?.some((p) => p.isBot && sendEmote(m, p, E('nice_cooperate')));
|
||||
}
|
||||
export function pickSettleEmote(m, p) {
|
||||
const r = m.lastResults.get(p.playerId);
|
||||
if (!r) return null;
|
||||
const ranked = m.alivePlayers().filter((q) => m.lastResults.has(q.playerId)).sort((a, b) => {
|
||||
const ar = m.lastResults.get(a.playerId), br = m.lastResults.get(b.playerId);
|
||||
return (br.killed || 0) - (ar.killed || 0) || (br.damageDealt || 0) - (ar.damageDealt || 0);
|
||||
});
|
||||
if (ranked.length > 1 && ranked[0] === p && r.killed > 0) return E('call');
|
||||
const leaks = (r.leaked || []).filter((l) => l && l.counted !== false).length;
|
||||
if (leaks > 0) return E('scared');
|
||||
if (r.perfect !== false) return E('playingcool');
|
||||
if (r.coins > 0) return E('thanks');
|
||||
return null;
|
||||
}
|
||||
export function onSettle(m) {
|
||||
if (!BOT_EMOTES_ON || !hasHumans(m) || m._botEmotesSettledRound === m.round) return false;
|
||||
m._botEmotesSettledRound = m.round;
|
||||
let sent = false;
|
||||
for (const p of m.alivePlayers()) if (p.isBot) {
|
||||
if (sendEmote(m, p, pickSettleEmote(m, p))) sent = true;
|
||||
}
|
||||
return sent;
|
||||
}
|
||||
export function onMerge(m, p, info) {
|
||||
if (!BOT_EMOTES_ON || !hasHumans(m) || !p?.isBot || !p.alive || info?.kind !== 'chess') return false;
|
||||
const counts = m._botEmotesMerges ??= new Map();
|
||||
const n = (counts.get(p.playerId) || 0) + 1;
|
||||
counts.set(p.playerId, n);
|
||||
return n === 3 && sendEmote(m, p, E('scared'));
|
||||
}
|
||||
export function onGiftTicker(m, recipient, fromPlayerId) {
|
||||
// Display names are not unique identities; use the trusted source player id.
|
||||
return !!m.order.some((p) => p.playerId === fromPlayerId && !p.isBot && p !== recipient)
|
||||
&& sendEmote(m, recipient, E('thanks'));
|
||||
}
|
||||
export function resetRoundCounters(m) { m._botEmotesMerges?.clear(); }
|
||||
@@ -282,7 +282,7 @@ const EFFECT_HANDLERS = {
|
||||
if (!to) { ctx.removeEffect(ref.id); return; }
|
||||
if (!to.grantChess(p.chessId)) return;
|
||||
ctx.removeEffect(ref.id);
|
||||
to.giftTicker(ctx.name, p.chessId);
|
||||
to.giftTicker(ctx.name, p.chessId, ctx.playerId);
|
||||
},
|
||||
},
|
||||
// 整备: the next purchased item becomes advanced (golden)
|
||||
|
||||
@@ -44,6 +44,7 @@ import { pieceBonds as bondsOfPiece } from './bondsMeta.js';
|
||||
import { registerAllMeta } from '../sim/content/index.js';
|
||||
import { registerBuiltins } from './builtinMeta.js';
|
||||
import { msg, dn } from '../../shared/i18n.js';
|
||||
import { onGiftTicker } from './botEmotes.js';
|
||||
|
||||
export const HOOKS = Object.freeze([
|
||||
'onRoundStart', 'onIncome', 'onPrepStart', 'onPrepEnd', 'onGain', 'onSold', 'onRefresh', 'onPrice', 'onBuy',
|
||||
@@ -684,10 +685,12 @@ export function makeCtx(m, ps, source, hook, ev = null) {
|
||||
* CHAR_GIFT broadcast to this player: "{0}博士给你赠送了{1}" — named as this player sees the gift (a chess it fields as
|
||||
* its 补位 stand-in by the stand-in's name: 0.2.0, the owner's recall of the official mode, 2026-10-06).
|
||||
*/
|
||||
giftTicker: (fromName, chessId) => {
|
||||
giftTicker: (fromName, chessId, fromPlayerId = null) => {
|
||||
const c = gd.chess(chessId);
|
||||
const shown = c && typeof ps.fieldRecord === 'function' ? ps.fieldRecord(c) || c : c;
|
||||
m.tickerFor('CHAR_GIFT', [String(fromName), shown ? shown.name : String(chessId)], { to: ps.playerId });
|
||||
// the recipient bot thanks the sender when the gift was a real player's CHAR_GIFT (enabled by default; SP_BOT_EMOTES=0 silences it)
|
||||
onGiftTicker(m, ps, fromPlayerId);
|
||||
},
|
||||
|
||||
// ---- team
|
||||
|
||||
@@ -8,14 +8,18 @@ import { unitStatsEntry } from '../../../shared/protocol.js';
|
||||
import { PHASE, ERR, EMOTES, EMOTE_COOLDOWN_MS, GEO } from '../../../shared/constants.js';
|
||||
import { deriveSeed } from '../../sim/rng.js';
|
||||
import { OK, fail } from './common.js';
|
||||
import { onHumanEmote } from '../botEmotes.js';
|
||||
|
||||
export class MatchIntents {
|
||||
_handle(ps, msg) {
|
||||
switch (msg.t) {
|
||||
case 'g.infoReady':
|
||||
if (this.phase !== PHASE.INFO_CHECK) return fail(ERR.WRONG_PHASE);
|
||||
if (this.setupVote) return fail(ERR.WRONG_PHASE, 'setup reroll vote in progress');
|
||||
if ((msg.setupRevision ?? 0) !== this.setupRevision) return fail(ERR.BAD_TARGET, 'setup changed; confirm the current revision');
|
||||
if (!ps.infoReady) { ps.infoReady = true; this.markPublic(); this.maybeEndInfo(); }
|
||||
return OK;
|
||||
case 'g.rerollVote': return this.voteSetupReroll(ps, msg.voteId, msg.agree);
|
||||
case 'g.band': return this.pickBand(ps, msg.bandId);
|
||||
case 'g.bandSkip': return this.skipBand(ps);
|
||||
// the strategy highlighted in the draft screen (what a timed-out turn takes, timeoutBand)
|
||||
@@ -55,6 +59,8 @@ export class MatchIntents {
|
||||
if (now - ps.lastEmoteAt < EMOTE_COOLDOWN_MS) return fail(ERR.RATE);
|
||||
ps.lastEmoteAt = now;
|
||||
this.broadcast({ t: 'm.emote', playerId: ps.playerId, id });
|
||||
// an AI teammate can react to a human's emote (enabled by default: server/match/botEmotes.js, SP_BOT_EMOTES=0 silences it)
|
||||
onHumanEmote(this, ps.playerId, id);
|
||||
return OK;
|
||||
}
|
||||
|
||||
|
||||
@@ -23,10 +23,13 @@ export class MatchPhases {
|
||||
}
|
||||
|
||||
maybeEndInfo() {
|
||||
if (this.phase !== PHASE.INFO_CHECK) return;
|
||||
if (this.phase !== PHASE.INFO_CHECK || this.setupVote || this._infoAdvanceTimer) return;
|
||||
if (this.order.every((p) => p.isBot || p.left || p.infoReady)) {
|
||||
this.setDeadline(0);
|
||||
this.later(0, () => { if (this.phase === PHASE.INFO_CHECK) this.enterBandDraft(); });
|
||||
this._infoAdvanceTimer = this.later(0, () => {
|
||||
this._infoAdvanceTimer = null;
|
||||
if (this.phase === PHASE.INFO_CHECK && !this.setupVote) this.enterBandDraft();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -82,6 +82,8 @@ export class MatchPlatform {
|
||||
if (!ps || ps.isBot || this.disposed) return;
|
||||
this.guard(() => {
|
||||
ps.connected = false;
|
||||
// A disconnected human's silence is never consent to a new setup.
|
||||
this.cancelSetupVote();
|
||||
// a paused solo battle resumes (the server takes the field over; nobody is left to resume it)
|
||||
this._resume();
|
||||
if (this.clientCombat) this._authorityLost(ps, 'disconnect');
|
||||
@@ -130,6 +132,7 @@ export class MatchPlatform {
|
||||
if (!ps || ps.isBot || ps.left || this.disposed) return;
|
||||
this.guard(() => {
|
||||
ps.left = true;
|
||||
this.cancelSetupVote();
|
||||
ps.connected = false;
|
||||
ps.autoplay = false;
|
||||
this.watchers.delete(playerId);
|
||||
|
||||
@@ -8,6 +8,7 @@ import { uniteSurvivors } from '../unite.js';
|
||||
import { buildResult } from '../results.js';
|
||||
import { FLOW_TICKER_PRIORITY, DELAYS } from './common.js';
|
||||
import { msg } from '../../../shared/i18n.js';
|
||||
import { onSettle, resetRoundCounters } from '../botEmotes.js';
|
||||
|
||||
export class MatchSettle {
|
||||
settle(plan, uniteResult) {
|
||||
@@ -83,6 +84,7 @@ export class MatchSettle {
|
||||
this.fields = [];
|
||||
this.watchers.clear();
|
||||
this.markPublic();
|
||||
onSettle(this); // one emote per alive AI per round (enabled by default; SP_BOT_EMOTES=0 silences it)
|
||||
this.setDeadline(DELAYS.SETTLE / 1000, () => this.afterSettle(), { silent: this.soloUntimed });
|
||||
}
|
||||
|
||||
@@ -111,6 +113,7 @@ export class MatchSettle {
|
||||
|
||||
afterSettle() {
|
||||
if (!this.alivePlayers().length) { this.finish({ victory: false, reason: 'eliminated' }); return; }
|
||||
resetRoundCounters(this); // AI bot merge-counter resets each round (server/match/botEmotes.js)
|
||||
this.startRound(this.round + 1);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
// INFO_CHECK full setup reroll: unanimous human consent, keeping the room and player settings.
|
||||
// [ASSUMED] Remake convenience: unanimous human vote, pause/resume timer, independent reroll stream and
|
||||
// a 300 ms request guard. These are not official mode rules; DESIGN §28.21.
|
||||
// Installed on Match.prototype like the other method containers. The lobby authorizes host-only actions.
|
||||
import { PHASE, ERR } from '../../../shared/constants.js';
|
||||
import { createRng, deriveSeed } from '../../sim/rng.js';
|
||||
import { setupMatchWaves } from '../waves.js';
|
||||
import { drawDisabledBonds, SharedPool } from '../pool.js';
|
||||
import { OK, fail } from './common.js';
|
||||
|
||||
export class MatchSetupVote {
|
||||
requestSetupReroll(playerId, revision) {
|
||||
if (this.disposed || this.ended || this.phase !== PHASE.INFO_CHECK) return fail(ERR.WRONG_PHASE);
|
||||
const ps = this.players.get(playerId);
|
||||
if (!ps || ps.isBot || ps.left || !ps.connected) return fail(ERR.NOT_IN_ROOM);
|
||||
if (revision !== this.setupRevision) return fail(ERR.BAD_TARGET, 'setup revision changed');
|
||||
if (this.setupVote) return fail(ERR.WRONG_PHASE, 'setup reroll vote in progress');
|
||||
const humans = this.humans();
|
||||
if (humans.some((p) => !p.connected)) return fail(ERR.NOT_READY, 'all humans must be connected to vote');
|
||||
const now = this.sched.now();
|
||||
if (now - this._lastSetupVoteAt < 300) return fail(ERR.RATE);
|
||||
this._lastSetupVoteAt = now;
|
||||
this.setupVote = {
|
||||
id: ++this._setupVoteSeq,
|
||||
proposerId: playerId,
|
||||
voters: humans.map((p) => p.playerId),
|
||||
agreed: new Set([playerId]),
|
||||
remainingMs: this.deadline > 0 ? Math.max(1, this.deadline - now) : 0,
|
||||
};
|
||||
this.cancel(this._infoAdvanceTimer);
|
||||
this._infoAdvanceTimer = null;
|
||||
this.setDeadline(0);
|
||||
if (humans.length === 1) return this.completeSetupReroll();
|
||||
this.markPublic();
|
||||
this.flush(true);
|
||||
return OK;
|
||||
}
|
||||
|
||||
voteSetupReroll(ps, voteId, agree) {
|
||||
if (this.phase !== PHASE.INFO_CHECK || !this.setupVote) return fail(ERR.WRONG_PHASE);
|
||||
const vote = this.setupVote;
|
||||
if (vote.id !== voteId) return fail(ERR.BAD_TARGET, 'setup vote changed');
|
||||
if (!ps.connected || !vote.voters.includes(ps.playerId)) return fail(ERR.NOT_IN_ROOM);
|
||||
if (!agree) { this.cancelSetupVote(); return OK; }
|
||||
vote.agreed.add(ps.playerId);
|
||||
if (vote.voters.every((id) => vote.agreed.has(id))) return this.completeSetupReroll();
|
||||
this.markPublic();
|
||||
this.flush(true);
|
||||
return OK;
|
||||
}
|
||||
|
||||
cancelSetupReroll(playerId, voteId) {
|
||||
if (this.disposed || this.ended || this.phase !== PHASE.INFO_CHECK || !this.setupVote) return fail(ERR.WRONG_PHASE);
|
||||
if (this.setupVote.id !== voteId) return fail(ERR.BAD_TARGET, 'setup vote changed');
|
||||
const ps = this.players.get(playerId);
|
||||
if (!ps || ps.left || !ps.connected) return fail(ERR.NOT_IN_ROOM);
|
||||
this.cancelSetupVote();
|
||||
return OK;
|
||||
}
|
||||
|
||||
cancelSetupVote() {
|
||||
const vote = this.setupVote;
|
||||
if (!vote) return;
|
||||
this.setupVote = null;
|
||||
if (this.phase === PHASE.INFO_CHECK && !this.disposed && !this.ended) {
|
||||
if (vote.remainingMs > 0) {
|
||||
this.deadline = this.sched.now() + vote.remainingMs;
|
||||
this._phaseTimer = this.later(vote.remainingMs, () => {
|
||||
this._phaseTimer = null;
|
||||
this.enterBandDraft();
|
||||
});
|
||||
}
|
||||
this.maybeEndInfo();
|
||||
this.markPublic();
|
||||
this.flush(true);
|
||||
}
|
||||
}
|
||||
|
||||
completeSetupReroll() {
|
||||
let prepared;
|
||||
try {
|
||||
const rng = createRng(deriveSeed(this.seed, `reroll-setup:${this.setupRevision + 1}`));
|
||||
const setup = setupMatchWaves(this.gd, rng);
|
||||
const stage = setup.stageId ? this.gd.stage(setup.stageId) : null;
|
||||
const bans = drawDisabledBonds(this.gd, rng);
|
||||
const pool = new SharedPool(this.gd, { banned: bans.banned });
|
||||
if (!pool.entries.size) throw new Error('rerolled chess pool is empty');
|
||||
const off = new Set([...bans.drawn, ...bans.staticOff]);
|
||||
const stocks = this.order.map((ps) => ps.buildDiyStock(off));
|
||||
prepared = { setup, stage, bans, pool, stocks };
|
||||
} catch (e) {
|
||||
this.reportError('reroll setup', e);
|
||||
this.cancelSetupVote();
|
||||
return fail(ERR.INTERNAL);
|
||||
}
|
||||
// All data-dependent work succeeds before any opening or player state changes.
|
||||
const { setup, stage, bans, pool, stocks } = prepared;
|
||||
this.stageId = setup.stageId;
|
||||
this.stage = stage;
|
||||
this.factions = setup.factions;
|
||||
this.bossId = setup.bossId;
|
||||
this.hiddenBossId = setup.hiddenBossId;
|
||||
this.disabledBonds = bans.drawn;
|
||||
this.staticInactiveBonds = bans.staticOff;
|
||||
this.bannedChess = bans.banned;
|
||||
this.pool = pool;
|
||||
this._botPath = null;
|
||||
for (const [i, ps] of this.order.entries()) {
|
||||
ps.diyStock = stocks[i].stock;
|
||||
ps.diyBanned = stocks[i].banned;
|
||||
ps.invalidateDeployMap();
|
||||
ps.infoReady = ps.botControlled;
|
||||
}
|
||||
this.setupRevision++;
|
||||
this.setupVote = null;
|
||||
this.enterInfoCheck();
|
||||
return OK;
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
import { PHASE, ERR } from '../../../shared/constants.js';
|
||||
import { generateDraft, applyCard, bountyBattles, isMultiRoundBounty, bountyCard } from '../choices.js';
|
||||
import { onPickCard } from '../botEmotes.js';
|
||||
import { weightedPick } from '../waves.js';
|
||||
import { botPickCard } from '../bot.js';
|
||||
import { OK, fail, DELAYS } from './common.js';
|
||||
@@ -102,6 +103,7 @@ export class MatchSpDraft {
|
||||
try { applyCard(this, ps, card); } catch (e) { this.reportError(`applyCard ${card.id}`, e); }
|
||||
this.markPrivate(ps);
|
||||
this.markPublic();
|
||||
onPickCard(this, ps, card); // AI bots say "思考" when they take a bounty (enabled by default; SP_BOT_EMOTES=0 silences it)
|
||||
this.startSpTurn();
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import { FieldRunner, timelineAt, uniteBillBounds } from '../fields.js';
|
||||
import { uniteLeft } from '../../sim/spec.js';
|
||||
import { FLOW_TICKER_PRIORITY, DELAYS } from './common.js';
|
||||
import { msg } from '../../../shared/i18n.js';
|
||||
import { onStartUnite } from '../botEmotes.js';
|
||||
|
||||
export class MatchUnite {
|
||||
startUnite(plan) {
|
||||
@@ -23,6 +24,7 @@ export class MatchUnite {
|
||||
this._defaultWatch();
|
||||
this.markPublic();
|
||||
this.tickerText(msg('联防阶段:{names} 迎战突破防线的敌人', { names: plan.helpers.map((p) => p.name) }), FLOW_TICKER_PRIORITY);
|
||||
onStartUnite(this, plan); // an AI helper says "合作愉快" once on the open of a 联防 (enabled by default; SP_BOT_EMOTES=0 silences it)
|
||||
this._uniteLeftKey = null;
|
||||
this.runner = new FieldRunner(this, this.fields, {
|
||||
onTick: (runner) => this._uniteTick(runner),
|
||||
|
||||
@@ -71,6 +71,13 @@ export class MatchViews {
|
||||
modeId: this.modeId,
|
||||
difficulty: this.difficulty,
|
||||
stageId: this.stageId,
|
||||
setupRevision: this.setupRevision,
|
||||
rerollVote: this.setupVote ? {
|
||||
id: this.setupVote.id,
|
||||
proposerId: this.setupVote.proposerId,
|
||||
voters: this.setupVote.voters.slice(),
|
||||
agreed: [...this.setupVote.agreed],
|
||||
} : null,
|
||||
factions: this.factions.slice(),
|
||||
disabledBonds: [...new Set([...this.disabledBonds, ...this.staticInactiveBonds])].sort(),
|
||||
drawnDisabledBonds: this.disabledBonds.slice(),
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
|
||||
import { pieceDir, parseDir, mergeTile } from '../board.js';
|
||||
import { MAX_OFFER_SLOTS } from './common.js';
|
||||
import { onMerge } from '../botEmotes.js';
|
||||
|
||||
export class PlayerAcquire {
|
||||
/** Normal copies of a base chess currently owned (board/hand/temp). */
|
||||
@@ -134,6 +135,7 @@ export class PlayerAcquire {
|
||||
const shown = rec ? this.fieldRecord(rec) || rec : null;
|
||||
this.m.tickerFor('GOLDEN_CHAR', [this.name, shown ? shown.name : goldenId], { playerId: this.playerId });
|
||||
this.m.dispatch(this, 'onMerge', { kind: 'chess', piece: elite, baseId, consumed: consumed.map((l) => l.piece.uid), area: where });
|
||||
onMerge(this.m, this, { kind: 'chess' }); // AI bot says "害怕" on its 3rd chess merge of a round (enabled by default; SP_BOT_EMOTES=0 silences it)
|
||||
return elite;
|
||||
}
|
||||
|
||||
|
||||
@@ -177,7 +177,14 @@ export class PlayerDiy {
|
||||
* @param {Set<string>} off
|
||||
*/
|
||||
initDiyStock(off = new Set()) {
|
||||
this.diyStock = new DiyStock();
|
||||
const { stock, banned } = this.buildDiyStock(off);
|
||||
this.diyStock = stock;
|
||||
this.diyBanned = banned;
|
||||
}
|
||||
|
||||
/** Prepare stock without mutating this player, so setup rerolls can commit all players together. */
|
||||
buildDiyStock(off = new Set()) {
|
||||
const stock = new DiyStock();
|
||||
const banned = [];
|
||||
const gd = this.m.gd;
|
||||
for (const slotId of Object.keys(this.diy || {})) {
|
||||
@@ -188,9 +195,9 @@ export class PlayerDiy {
|
||||
if (bonds.length > 0 && bonds.every((b) => off.has(b))) { banned.push(slotId); continue; }
|
||||
const cap = gd.poolCopies(slotId);
|
||||
if (!(cap > 0)) continue;
|
||||
this.diyStock.entries.set(slotId, { cap, left: cap, tier: gd.tierOf(slotId), shopLevel: slot.shopLevel });
|
||||
stock.entries.set(slotId, { cap, left: cap, tier: gd.tierOf(slotId), shopLevel: slot.shopLevel });
|
||||
}
|
||||
this.diyBanned = Object.freeze(banned);
|
||||
return { stock, banned: Object.freeze(banned) };
|
||||
}
|
||||
|
||||
/** The copy accounting of base chess `baseId` for this player: its own stock for a slotted DIY slot, else the shared pool. */
|
||||
|
||||
@@ -774,15 +774,25 @@ export function setEchoForm(b, echo, form) {
|
||||
export function echoHit(b, echo) {
|
||||
const ab = echo.mem.ab;
|
||||
if (!ab || !echo.alive) return;
|
||||
const atk = echo.s.atk;
|
||||
b.fx('explode', { x: echo.x, y: echo.y, r: ECHO_PULSE_RADIUS, kind: 'echoPulse' });
|
||||
for (const u of areaAllies(b, echo, echo.x, echo.y, ECHO_PULSE_RADIUS)) {
|
||||
hurt(b, echo, u, atk * (T(ab, '3.atk_scale') ?? 0), 'arts');
|
||||
elem(b, echo, u, 'apoptosis', atk * (T(ab, '3.ep_damage_ratio') ?? 0));
|
||||
}
|
||||
const need = ab.form === 'gold' ? T(ab, '1.hit_times_to_switch') : T(ab, '2.hit_times_to_switch');
|
||||
ab.strikes = (ab.strikes ?? 0) + 1;
|
||||
if (need > 0 && ab.strikes >= need) setEchoForm(b, echo, ab.form === 'gold' ? 'dark' : 'gold');
|
||||
// [ASSUMED] Resolve recursively earned pulses FIFO after the current pulse. A counter can remove dozens of
|
||||
// hit-count HP here; synchronous damaged → counter → pulse nesting used to trip the engine's 32-hook guard.
|
||||
// Keep the pulse earned by the lethal hit, too. No timer, extra RNG or skipped damage (DESIGN §28.24).
|
||||
(ab.pulses ||= []).push(echo.s.atk);
|
||||
if (ab.pulsing) return;
|
||||
ab.pulsing = true;
|
||||
try {
|
||||
for (let i = 0; i < ab.pulses.length; i++) {
|
||||
const atk = ab.pulses[i];
|
||||
b.fx('explode', { x: echo.x, y: echo.y, r: ECHO_PULSE_RADIUS, kind: 'echoPulse' });
|
||||
for (const u of areaAllies(b, echo, echo.x, echo.y, ECHO_PULSE_RADIUS)) {
|
||||
hurt(b, echo, u, atk * (T(ab, '3.atk_scale') ?? 0), 'arts');
|
||||
elem(b, echo, u, 'apoptosis', atk * (T(ab, '3.ep_damage_ratio') ?? 0));
|
||||
}
|
||||
const need = ab.form === 'gold' ? T(ab, '1.hit_times_to_switch') : T(ab, '2.hit_times_to_switch');
|
||||
ab.strikes = (ab.strikes ?? 0) + 1;
|
||||
if (echo.alive && need > 0 && ab.strikes >= need) setEchoForm(b, echo, ab.form === 'gold' ? 'dark' : 'gold');
|
||||
}
|
||||
} finally { ab.pulses.length = 0; ab.pulsing = false; }
|
||||
}
|
||||
|
||||
function kitEcho(ab, e) {
|
||||
|
||||
@@ -232,7 +232,7 @@ function lemuen(bb, chess, def) {
|
||||
return keys;
|
||||
};
|
||||
// #428: extra attack range also opens her skill under the existing content-trigger contract.
|
||||
unit.skill?.addTriggerRange(() => [{ keys: wantedKeys(), profile: unit.profile }]);
|
||||
unit.skill?.addTriggerRange(() => [{ keys: wantedKeys(), profile: unit.profile }], { attackOnly: true });
|
||||
let sig = null;
|
||||
const refresh = () => { // update immediately when the last source leaves, before another attack can run
|
||||
const keys = live(unit) ? wantedKeys() : [];
|
||||
|
||||
@@ -380,12 +380,21 @@ export default {
|
||||
} },
|
||||
],
|
||||
install(battle, unit) {
|
||||
// PRTS 卫戍协议/帮助: special targeting also supplies the DEFAULT attack opportunity (#440).
|
||||
// Query live owned blockers for both paths; S2's links still use its own effect range, S3 keeps SKILL_RANGE.
|
||||
const blockedKeys = () => {
|
||||
if (!up(unit)) return [];
|
||||
const keys = [];
|
||||
for (const s of skelsUp(unit)) for (const e of s.blocking) if (e.alive && e.blockedBy === s) keys.push(tileKey(Math.round(e.y), Math.round(e.x)));
|
||||
return [...new Set(keys)].sort((a, b) => a - b);
|
||||
};
|
||||
if (unit.skill?.id === S1 || unit.skill?.id === S2) {
|
||||
unit.skill.addTriggerRange(() => [{ keys: blockedKeys(), profile: unit.profile }], { attackOnly: true });
|
||||
}
|
||||
// trait: the enemies her summons block are her targets too
|
||||
battle.on('tick', () => {
|
||||
if (!up(unit)) return;
|
||||
const keys = [];
|
||||
for (const s of skelsUp(unit)) for (const e of s.blocking) if (e.alive && e.blockedBy === s) keys.push(tileKey(Math.round(e.y), Math.round(e.x)));
|
||||
const uniq = [...new Set(keys)].sort((a, b) => a - b);
|
||||
const uniq = blockedKeys();
|
||||
const cur = unit.extraRangeKeys ?? [];
|
||||
if (uniq.length === cur.length && uniq.every((k, i) => k === cur[i])) return;
|
||||
battle.setExtraRange(unit, uniq.length ? uniq : null);
|
||||
|
||||
+10
-6
@@ -125,6 +125,7 @@ export class SkillRuntime {
|
||||
this._trigKeys = null;
|
||||
this._trigSet = null;
|
||||
this.triggerRanges = []; // content trigger ranges (addTriggerRange)
|
||||
this.attackTriggerRanges = new Set(); // special targeting checked only at the next attack
|
||||
this.noSkill = !spec; // unit without any skill spec
|
||||
}
|
||||
|
||||
@@ -147,20 +148,23 @@ export class SkillRuntime {
|
||||
* keys, or `{ keys, profile }` — tile keys with the enemy profile the effect selects by (`canHitFly` false: ground
|
||||
* enemies only — the owner's larger-range rule through a summon's area, kits/shared/summoner.js summonTriggerArea;
|
||||
* 0.2.0 WV, additive). A targetable enemy (flyers included unless the entry's profile says otherwise) on those tiles
|
||||
* satisfies the DEFAULT rule (and unknown DEFAULT-like rules); it is checked every tick, since the unit itself may
|
||||
* have nothing to attack. Returns an unregister fn.
|
||||
* satisfies the DEFAULT rule (and unknown DEFAULT-like rules). Summon effect areas are checked every tick;
|
||||
* { attackOnly: true } is for special targeting that must wait for the next attack (蕾缪安, 死芒).
|
||||
* Returns an unregister fn.
|
||||
*/
|
||||
addTriggerRange(fn) {
|
||||
addTriggerRange(fn, { attackOnly = false } = {}) {
|
||||
if (typeof fn !== 'function') return () => {};
|
||||
this.triggerRanges.push(fn);
|
||||
return () => { const i = this.triggerRanges.indexOf(fn); if (i >= 0) this.triggerRanges.splice(i, 1); };
|
||||
if (attackOnly) this.attackTriggerRanges.add(fn);
|
||||
return () => { this.attackTriggerRanges.delete(fn); const i = this.triggerRanges.indexOf(fn); if (i >= 0) this.triggerRanges.splice(i, 1); };
|
||||
}
|
||||
|
||||
/** An enemy inside one of the content trigger ranges. */
|
||||
_extraTriggerSatisfied() {
|
||||
_extraTriggerSatisfied(allowAttackOnly = true) {
|
||||
const b = this.battle;
|
||||
const u = this.unit;
|
||||
for (const fn of this.triggerRanges) {
|
||||
if (!allowAttackOnly && this.attackTriggerRanges.has(fn)) continue;
|
||||
const list = b._safe(() => fn(b, u), 'skill.triggerRange', u);
|
||||
if (!list || typeof list[Symbol.iterator] !== 'function') continue;
|
||||
for (const x of list) {
|
||||
@@ -405,7 +409,7 @@ export class SkillRuntime {
|
||||
const prof = u.profile;
|
||||
if (prof && (prof.noAttack || (prof.noAttackUnlessSkill && !this.active))) {
|
||||
if (this._defaultCondition()) this.activate('DEFAULT');
|
||||
} else if (this.triggerRanges.length && !this.healSkill && this._extraTriggerSatisfied()) this.activate('DEFAULT');
|
||||
} else if (this.triggerRanges.length && !this.healSkill && this._extraTriggerSatisfied(false)) this.activate('DEFAULT');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+4
-1
@@ -394,6 +394,8 @@ export const C2S = {
|
||||
// host confirmed — a seat that changed hands meanwhile is refused
|
||||
'room.kick': { seat: (v) => isInt(v, 0, MAX_SEATS - 1), playerId: isId },
|
||||
'room.start': {},
|
||||
'room.rerollSetup': { setupRevision: (v) => isInt(v, 0, 2 ** 31) },
|
||||
'room.cancelReroll': { voteId: (v) => isInt(v, 1, 2 ** 31) },
|
||||
// operator loadout (DESIGN §16): stored per session/seat; accepted until the match leaves INFO_CHECK — `ops` (0.2.2):
|
||||
// the per-operator 潜能 / 练度 (absent = none set: every operator at 潜能 6, 精英2 Lv.60)
|
||||
'room.loadout': { entries: isLoadoutEntries, ops: isLoadoutOps, $optional: ['ops'] },
|
||||
@@ -410,7 +412,8 @@ export const C2S = {
|
||||
'room.removeSpectator': { playerId: isId },
|
||||
|
||||
// match
|
||||
'g.infoReady': {},
|
||||
'g.infoReady': { setupRevision: (v) => isInt(v, 0, 2 ** 31), $optional: ['setupRevision'] },
|
||||
'g.rerollVote': { voteId: (v) => isInt(v, 1, 2 ** 31), agree: isBool },
|
||||
'g.band': { bandId: isId },
|
||||
'g.bandSkip': {},
|
||||
// the strategy highlighted in the draft screen (user playtest #4 item 4): a turn that runs out takes it while it is
|
||||
|
||||
@@ -1308,3 +1308,49 @@ describe('multi-device & browser compatibility (static)', () => {
|
||||
assert.match(src, /--tap-min: 44px/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('explicit local match recovery (#431)', () => {
|
||||
test('an idle tab can reclaim a closed match tab without taking a live one', async () => {
|
||||
const { createIdentity } = await mod('net.js');
|
||||
const local = memStorage(), hub = channelHub(), ca = hub.create(), cb = hub.create();
|
||||
const a = createIdentity({ local, session: memStorage(), tabId: 'a', channel: ca, queryMs: 5 });
|
||||
const b = createIdentity({ local, session: memStorage(), tabId: 'b', channel: cb, queryMs: 5 });
|
||||
await a.init(); a.saveToken('idle-token');
|
||||
await b.init(); b.saveToken('match-token'); b.rememberMatch({ name: 'Doctor B', code: 'ABC123' });
|
||||
const [item] = a.recoverable();
|
||||
assert.equal(item.name, 'Doctor B');
|
||||
assert.equal(JSON.stringify(item).includes('match-token'), false);
|
||||
assert.equal(await a.resume(item.id), false, 'live original keeps its seat');
|
||||
assert.equal(a.getToken(), 'idle-token');
|
||||
cb.close();
|
||||
assert.equal(await a.resume(item.id), true);
|
||||
assert.equal(a.getToken(), 'match-token');
|
||||
a.rememberMatch(null);
|
||||
assert.deepEqual(JSON.parse(local.getItem('sp.matches')), {});
|
||||
ca.close();
|
||||
});
|
||||
test('simultaneous recoveries pick one winner; absent channels and unknown ids cannot recover', async () => {
|
||||
const { createIdentity } = await mod('net.js');
|
||||
const local = memStorage(), hub = channelHub();
|
||||
local.setItem('sp.tokens', JSON.stringify(['idle-b', 'idle-a']));
|
||||
const sa = memStorage(), sb = memStorage(); sa.setItem('sp.token', 'idle-a'); sb.setItem('sp.token', 'idle-b');
|
||||
const a = createIdentity({ local, session: sa, tabId: 'a', channel: hub.create(), queryMs: 5 });
|
||||
const b = createIdentity({ local, session: sb, tabId: 'b', channel: hub.create(), queryMs: 5 });
|
||||
await Promise.all([a.init(), b.init()]);
|
||||
const closed = createIdentity({ local, session: memStorage(), channel: null });
|
||||
closed.saveToken('closed-token'); closed.rememberMatch({ name: 'C', code: 'XYZ' });
|
||||
const id = a.recoverable()[0].id;
|
||||
assert.deepEqual(await Promise.all([a.resume(id), b.resume(id)]), [true, false]);
|
||||
assert.equal(await b.resume('unknown'), false);
|
||||
assert.equal(await closed.resume(id), false);
|
||||
});
|
||||
test('server replacement clears expired metadata and malformed storage remains safe', async () => {
|
||||
const { createIdentity } = await mod('net.js');
|
||||
const local = memStorage(); const a = createIdentity({ local, session: memStorage(), channel: null });
|
||||
a.saveToken('old'); a.rememberMatch({ name: 'D', code: 'AAA' }); a.saveToken('new');
|
||||
assert.deepEqual(a.recoverable(), []);
|
||||
for (const bad of ['null', '[]', '{', '"hello"']) {
|
||||
local.setItem('sp.matches', bad); assert.deepEqual(a.recoverable(), []);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
// Real reflected hits against a scaled hit-count enemy must finish without recursive hook overflow.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { makeBattle, checkInvariants } from '../helpers/battleHarness.js';
|
||||
const ECHO = 'enemy_9023_acdums';
|
||||
for (const chessId of ['chess_char_1_06_b', 'chess_char_2_08_a']) {
|
||||
test(`${chessId}: a 67-hit echo resolves all counter pulses including its lethal hit`, () => {
|
||||
const h = makeBattle({ units: [{ chessId, row: 10, col: 4 }], autoFinish: false, captureNoisy: true, hooks: ['damaged'] });
|
||||
h.step();
|
||||
const u = h.unit(chessId);
|
||||
h.b.addBuff(u, { key: 'test:survive', persist: true, mods: { hpFlat: 1e8 } });
|
||||
u.hp = u.s.maxHp;
|
||||
u.skill.sp = u.skill.spCost; u.skill.charges = 1;
|
||||
assert.ok(u.skill.activate('manual'));
|
||||
const echo = h.spawn(ECHO, { pos: [10, 5], mods: { hpMul: 6.7, speedMul: 0 } });
|
||||
assert.equal(echo.s.maxHp, 67);
|
||||
const t = h.eventsOf('fx').length;
|
||||
h.b.dealDamage(u, echo, { amount: 1, type: 'arts', isAttack: true });
|
||||
assert.equal(echo.alive, false);
|
||||
assert.equal(h.b.errors.length, 0);
|
||||
assert.equal(h.eventsOf('fx').slice(t).filter((f) => f[1] === 'explode' && f[4]?.kind === 'echoPulse').length, 67);
|
||||
assert.equal(echo.mem.ab.pulsing, false);
|
||||
assert.deepEqual(echo.mem.ab.pulses, []);
|
||||
checkInvariants(h.b);
|
||||
});
|
||||
}
|
||||
@@ -59,3 +59,11 @@ test('immediate redeployment cannot attack through a cleared wanted range', () =
|
||||
h.step();assert.equal(u.stats.attacks, before, 'the allies phase sees the cleared range immediately');
|
||||
done(h);
|
||||
});
|
||||
|
||||
for (const skillIndex of [0, 1, 2]) test(`wanted S${skillIndex + 1} waits for the next attack, not the next tick`, () => {
|
||||
const { h, u, e } = field({ skillIndex });
|
||||
u.skill.sp = 0;h.run(8.5);assert.ok(e.findBuff('lemuen:wanted'));
|
||||
u.atkCd = 0.7;u.skill.gainSp(999);
|
||||
h.run(0.3);assert.equal(u.skill.activations, 0);
|
||||
h.run(0.6);assert.equal(u.skill.activations, 1);done(h);
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
// #440: special targeting supplies the DEFAULT attack opportunity, without extending S2 links.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { makeBattle, enemyRec, checkInvariants } from '../helpers/battleHarness.js';
|
||||
import { COLS } from '../../server/sim/constants.js';
|
||||
|
||||
function field(skillIndex, kind = 'unite') {
|
||||
const h = makeBattle({ kind, timeLimit: 90, autoFinish: false, seed: 5,
|
||||
flags: { dpPerSec: 0, dpMax: 999 }, hooks: ['skillStart', 'damaged'], captureNoisy: true,
|
||||
defs: { enemies: { weak: enemyRec({ key: 'weak', hp: 100, speed: 0, mass: 0 }),
|
||||
walker: enemyRec({ key: 'walker', hp: 1e9, speed: 0.5, mass: 0 }) } },
|
||||
units: [{ uid: 1, elite: true, row: 10, col: 3,
|
||||
diy: { slot: 'chess_char_6_diy1_a', charId: 'char_450_necras', skillIndex, uniEquipId: null } }],
|
||||
});
|
||||
h.step();
|
||||
const u = h.unit(1);u.skill.sp = 0;
|
||||
const weak = h.spawn('weak', { pos: [10, 6] });h.step();h.b.kill(weak, null);h.run(0.2);
|
||||
const summon = u.mem.necras.skels.find((s) => s.alive);assert.ok(summon);
|
||||
const e = h.spawn('walker', { pos: [10, 9] });
|
||||
assert.ok(h.runUntil(() => e.blockedBy === summon, 20));h.step(3);
|
||||
const key = Math.round(e.y) * COLS + Math.round(e.x);
|
||||
assert.ok(!u.baseRangeKeys.includes(key) && u.extraRangeKeys.includes(key));
|
||||
return { h, u, e, summon };
|
||||
}
|
||||
function done(h) { checkInvariants(h.b);assert.deepEqual(h.b.errors, []); }
|
||||
for (const kind of ['normal', 'unite']) for (const skillIndex of [0, 1]) {
|
||||
test(`Necras S${skillIndex + 1} starts on its next attack against a summon-blocked remote target (${kind})`, () => {
|
||||
const { h, u, e } = field(skillIndex, kind);
|
||||
u.skill.gainSp(999);u.atkCd = 0.7;
|
||||
h.run(0.3);assert.equal(u.skill.activations, 0, 'retain attack cadence');
|
||||
h.run(0.6);assert.equal(u.skill.activations, 1);
|
||||
if (skillIndex === 1) assert.ok(!u.mem.necrasLinks?.some((l) => l.e === e), 'remote trigger does not extend S2 effect range');
|
||||
done(h);
|
||||
});
|
||||
}
|
||||
test('remote Necras trigger needs a selectable enemy and the owning summon', () => {
|
||||
const { h, u, e, summon } = field(1);
|
||||
e.base.moveSpeed = 0;e.markDirty();
|
||||
h.b.applyStatus(e, 'sleep', { duration: 5 });u.skill.gainSp(999);u.atkCd = 0;
|
||||
h.run(1);assert.equal(u.skill.activations, 0, 'sleep cannot open DEFAULT');
|
||||
h.b.retreat(summon, { reason: 'test' });
|
||||
h.run(5);assert.equal(u.skill.activations, 0, 'expired block does not retain an extra trigger');
|
||||
done(h);
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
// test/feedback6-leizi2-attack-sfx.test.js — user report 「司霆惊蛰三技能攻击没有音效」 (2026-10-08).
|
||||
//
|
||||
// 司霆惊蛰 (char_1043_leizi2, 自选 6★ 解放者) attacks only while a skill runs (the trait: 通常不攻击), and the official
|
||||
// client has no normal-mode bank for her at all: every bank of hers is a numbered ABILITY variant whose file names the
|
||||
// skill mode — excel/audio_data.json:
|
||||
//
|
||||
// battle.ON_ABILITY_ON.char_1043_leizi2.attack.1.2.1 → p_atk_lzxqlkl_d1 技能1
|
||||
// battle.ON_ABILITY_START.char_1043_leizi2.attack.4 → p_atk_lzxqlkl_h2 技能2 (ON also carries _h)
|
||||
// battle.ON_ABILITY_START.char_1043_leizi2.attack.7 → p_atk_lzxqlkl_s 技能3
|
||||
//
|
||||
// `pickUnitSfx` refuses skill-mode files for an operator's NORMAL attack (community report #54, 银灰 — the file of a
|
||||
// skill mode must not ring on every normal attack), so she had no `attack` sound at all and her attacks were silent.
|
||||
// The fix keeps that rule and adds the mode's own file per skill index: tools/assets/audio.mjs pickModeAttacks() reads
|
||||
// the mode letter of each bank's file (_d / _h / _s = 技能1 / 2 / 3, the same letter an operator's activation sounds
|
||||
// use), plan.mjs writes it as sfx.units[id].attacks = { '<skillIndex>': url }, and public/js/audio.js plays it while
|
||||
// that skill is active (the ['skill', id, 1] / 0 events) — never outside it.
|
||||
//
|
||||
// Run: node --test test/feedback6-leizi2-attack-sfx.test.js
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { pickModeAttacks, skillModeLetter, SLOT_MODE_LETTER } from '../tools/assets/audio.mjs';
|
||||
|
||||
const P = (f) => `player/${f.startsWith('p_imp') ? 'p_imp' : 'p_atk'}/${f}.mp3`;
|
||||
const LEIZI2 = 'char_1043_leizi2';
|
||||
const manifest = JSON.parse(readFileSync(new URL('../data/assets.json', import.meta.url), 'utf8'));
|
||||
|
||||
/** 司霆惊蛰's own banks, exactly as excel/audio_data.json lists them. */
|
||||
const LEIZI2_BANKS = new Map([
|
||||
['ON_UNIT_BORN', [P('b_char_lzxqlkl')]],
|
||||
['ON_ABILITY_ON.attack.1.2.1', [P('p_atk_lzxqlkl_d1')]],
|
||||
['ON_ABILITY_HIT.attack.1.2.1', [P('p_imp_lzxqlkl_n')]],
|
||||
['ON_ABILITY_HIT.attack.1.2.2', [P('p_imp_lzxqlkl_n')]],
|
||||
['ON_ABILITY_HIT.attack.1.2.3', [P('p_imp_lzxqlkl_n')]],
|
||||
['ON_ABILITY_START.attack.4', [P('p_atk_lzxqlkl_h2')]],
|
||||
['ON_ABILITY_ON.attack.4', [P('p_atk_lzxqlkl_h')]],
|
||||
['ON_ABILITY_HIT.attack.4', [P('p_imp_lzxqlkl_h')]],
|
||||
['ON_ABILITY_START.attack.7', [P('p_atk_lzxqlkl_s')]],
|
||||
['ON_UNIT_DEAD', [P('b_char_dead')]],
|
||||
]);
|
||||
|
||||
test('pickModeAttacks: 司霆惊蛰 gets one attack sound per skill mode (d / h / s), the START bank first', () => {
|
||||
assert.deepEqual(pickModeAttacks(LEIZI2_BANKS), {
|
||||
d: [P('p_atk_lzxqlkl_d1')],
|
||||
h: [P('p_atk_lzxqlkl_h2')], // ON_ABILITY_START.attack.4, not the ON_ABILITY_ON.attack.4 (_h) variant
|
||||
s: [P('p_atk_lzxqlkl_s')],
|
||||
});
|
||||
// nothing else is taken from her banks: the _n impacts and the born / dead sounds are pickUnitSfx's business
|
||||
assert.deepEqual(Object.keys(pickModeAttacks(LEIZI2_BANKS)).sort(), ['d', 'h', 's']);
|
||||
});
|
||||
|
||||
test('pickModeAttacks: only ability banks, only skill-mode files, nothing for a normal-mode unit', () => {
|
||||
// a normal attack bank (_n) is never a mode: it stays the operator's normal attack (pickUnitSfx)
|
||||
assert.deepEqual(pickModeAttacks(new Map([['ON_ABILITY_START.attack', [P('p_atk_sword_n')]]])), {});
|
||||
// the unsuffixed default mode of an operator whose library is normal-mode only
|
||||
assert.deepEqual(pickModeAttacks(new Map([['ON_ABILITY_HIT.combat', [P('p_imp_spear_n')]]])), {});
|
||||
// a mode file under a non-attack ability (a talent, a mode switch) is not an attack sound
|
||||
assert.deepEqual(pickModeAttacks(new Map([['ON_ABILITY_START.T.1', [P('p_atk_x_s')]]])), {});
|
||||
// empty / absent banks
|
||||
assert.deepEqual(pickModeAttacks(new Map()), {});
|
||||
assert.deepEqual(pickModeAttacks(undefined), {});
|
||||
// ON_ABILITY_ON only (no START bank): still read
|
||||
assert.deepEqual(pickModeAttacks(new Map([['ON_ABILITY_ON.attack.2', [P('p_atk_firesword_h')]]])), { h: [P('p_atk_firesword_h')] });
|
||||
});
|
||||
|
||||
test('skillModeLetter / SLOT_MODE_LETTER: 技能1 / 2 / 3 = _d / _h / _s (+ digits), _n is the normal mode', () => {
|
||||
assert.equal(skillModeLetter(P('p_atk_lzxqlkl_s')), 's');
|
||||
assert.equal(skillModeLetter(P('p_atk_lzxqlkl_d1')), 'd');
|
||||
assert.equal(skillModeLetter('/assets/audio/sfx/player/p_skill/p_skill_khlrftcrdfr_h.mp3'), 'h');
|
||||
assert.equal(skillModeLetter(P('p_atk_sword_n')), null);
|
||||
assert.equal(skillModeLetter(P('p_atk_snwlprdg_n1')), null);
|
||||
assert.equal(skillModeLetter(null), null);
|
||||
assert.deepEqual(SLOT_MODE_LETTER, { 0: 'd', 1: 'h', 2: 's' });
|
||||
});
|
||||
|
||||
test('the committed manifest carries her three mode attacks under the skill indexes', () => {
|
||||
const u = manifest.audio.sfx.units[LEIZI2];
|
||||
assert.equal(u.attack, undefined, 'she has no normal-mode attack bank — that was the silent attack');
|
||||
assert.deepEqual(u.attacks, {
|
||||
0: '/assets/audio/sfx/player/p_atk/p_atk_lzxqlkl_d1.mp3',
|
||||
1: '/assets/audio/sfx/player/p_atk/p_atk_lzxqlkl_h2.mp3',
|
||||
2: '/assets/audio/sfx/player/p_atk/p_atk_lzxqlkl_s.mp3',
|
||||
});
|
||||
// her activation sounds (the other half of the chain) name the same modes: _d / _h / _s for 技能1 / 2 / 3
|
||||
assert.deepEqual(u.skills, {
|
||||
0: '/assets/audio/sfx/player/p_skill/p_skill_lzxqlkl_d.mp3',
|
||||
1: '/assets/audio/sfx/player/p_skill/p_skill_lzxqlkl_h.mp3',
|
||||
2: '/assets/audio/sfx/player/p_skill/p_skill_lzxqlkl_s.mp3',
|
||||
});
|
||||
assert.equal(u.hit, '/assets/audio/sfx/player/p_imp/p_imp_lzxqlkl_n.mp3', 'her normal impact is untouched');
|
||||
});
|
||||
|
||||
test('every manifest unit with both tables agrees with the mode convention (d / h / s = 技能1 / 2 / 3)', () => {
|
||||
const units = manifest.audio.sfx.units;
|
||||
let both = 0;
|
||||
for (const [id, u] of Object.entries(units)) {
|
||||
if (!u?.attacks || !u?.skills) continue;
|
||||
both++;
|
||||
for (const [i, url] of Object.entries(u.attacks)) {
|
||||
const a = skillModeLetter(url);
|
||||
assert.ok(a, `${id}: attacks[${i}] is a skill-mode file (${url})`);
|
||||
assert.equal(a, SLOT_MODE_LETTER[i], `${id}: attacks[${i}] is 技能${Number(i) + 1} (the _${a} mode)`);
|
||||
}
|
||||
}
|
||||
assert.ok(both >= 5, `some operators carry both tables (found ${both})`);
|
||||
});
|
||||
|
||||
test('no operator takes a skill-mode file as its normal attack or impact (community report #54 stays fixed)', () => {
|
||||
const units = manifest.audio.sfx.units;
|
||||
for (const [id, u] of Object.entries(units)) {
|
||||
if (!id.startsWith('char_')) continue;
|
||||
for (const kind of ['attack', 'hit']) {
|
||||
const url = u?.[kind];
|
||||
if (typeof url !== 'string') continue;
|
||||
assert.equal(skillModeLetter(url), null, `${id}.${kind} = ${url} is a skill mode's file`);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
// test/feedback7-chen3-s3-sfx.test.js — user report 「赤刃明霄陈开启三技能后斩击音效应该和普通攻击不一样」 (2026-10-08).
|
||||
//
|
||||
// 赤刃明霄陈 (char_1050_chen3) has both kinds of bank, and BOTH of her S3's sounds differ from her normal attack —
|
||||
// excel/audio_data.json, her ability banks (`attack.<n>` = the mode; the mode is in the file name):
|
||||
//
|
||||
// battle.ON_ABILITY_START.char_1050_chen3.attack.0 → p_atk_hljdswd_n 普通攻击(默认形态)
|
||||
// battle.ON_ABILITY_HIT.char_1050_chen3.attack.0 → p_imp_hljdswd_n 普通攻击的命中
|
||||
// battle.ON_ABILITY_START.char_1050_chen3.attack.1 → p_atk_hljdfswd_d 技能1
|
||||
// battle.ON_ABILITY_HIT.char_1050_chen3.attack.1.0 → p_imp_hljdrfswd_d 技能1 的命中
|
||||
// battle.ON_ABILITY_HIT.char_1050_chen3.attack.1.1 → p_imp_hljdylfswd_d 技能1 的命中(第二个变体)
|
||||
// battle.ON_ABILITY_START.char_1050_chen3.attack.3 → p_atk_hljdswd_s 技能3
|
||||
// battle.ON_ABILITY_HIT.char_1050_chen3.attack.3 → p_imp_hljdswd_s 技能3 的命中 ← 这就是「斩击」
|
||||
//
|
||||
// `pickUnitSfx` refuses skill-mode files for the NORMAL attack / impact (社区反馈 #54, 银灰) and therefore keeps the `_n`
|
||||
// pair — correct. The fix of 司霆惊蛰 (test/feedback6-…) already plays the mode's own swing while the skill runs; this
|
||||
// report is the other half: without a per-mode IMPACT the S3 slash still landed with p_imp_hljdswd_n, i.e. it sounded
|
||||
// like her normal attack. tools/assets/audio.mjs pickModeHits() now reads `ON_ABILITY_HIT` per mode (same mode letters,
|
||||
// same slot rule), plan.mjs writes it as sfx.units[id].hits = { '<skillIndex>': url }, and public/js/audio.js plays it for
|
||||
// an attack that was aimed while that skill ran (the attacker's state travels with its impact bookkeeping).
|
||||
//
|
||||
// Run: node --test test/feedback7-chen3-s3-sfx.test.js
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { pickModeAttacks, pickModeHits, skillModeLetter, SLOT_MODE_LETTER } from '../tools/assets/audio.mjs';
|
||||
|
||||
const P = (f) => `player/${f.startsWith('p_imp') ? 'p_imp' : 'p_atk'}/${f}.mp3`;
|
||||
const CHEN3 = 'char_1050_chen3';
|
||||
const manifest = JSON.parse(readFileSync(new URL('../data/assets.json', import.meta.url), 'utf8'));
|
||||
|
||||
/** 赤刃明霄陈's own banks, exactly as excel/audio_data.json lists them. */
|
||||
const CHEN3_BANKS = new Map([
|
||||
['ON_ABILITY_START.attack.0', [P('p_atk_hljdswd_n')]],
|
||||
['ON_ABILITY_HIT.attack.0', [P('p_imp_hljdswd_n')]],
|
||||
['ON_ABILITY_START.attack.1', [P('p_atk_hljdfswd_d')]],
|
||||
['ON_ABILITY_HIT.attack.1.0', [P('p_imp_hljdrfswd_d')]],
|
||||
['ON_ABILITY_HIT.attack.1.1', [P('p_imp_hljdylfswd_d')]],
|
||||
['ON_ABILITY_START.attack.3', [P('p_atk_hljdswd_s')]],
|
||||
['ON_ABILITY_HIT.attack.3', [P('p_imp_hljdswd_s')]],
|
||||
]);
|
||||
|
||||
test('pickModeAttacks / pickModeHits: 赤刃明霄陈 S3 的挥砍与命中都和普通攻击不同(技能1 两个命中变体取第一个)', () => {
|
||||
assert.deepEqual(pickModeAttacks(CHEN3_BANKS), { d: [P('p_atk_hljdfswd_d')], s: [P('p_atk_hljdswd_s')] });
|
||||
assert.deepEqual(pickModeHits(CHEN3_BANKS), { d: [P('p_imp_hljdrfswd_d')], s: [P('p_imp_hljdswd_s')] });
|
||||
// the normal pair (`attack.0`, files ending in `_n`) belongs to pickUnitSfx, never to a mode
|
||||
assert.equal(skillModeLetter(P('p_atk_hljdswd_n')), null);
|
||||
assert.equal(skillModeLetter(P('p_imp_hljdswd_n')), null);
|
||||
});
|
||||
|
||||
test('pickModeHits: only ON_ABILITY_HIT banks, and a mode without one keeps the unit\'s normal impact', () => {
|
||||
// a START bank alone is a swing, not an impact
|
||||
assert.deepEqual(pickModeHits(new Map([['ON_ABILITY_START.attack.4', [P('p_atk_lzxqlkl_h2')]]])), {});
|
||||
// 司霆惊蛰: 技能2 swings and lands `_h`, 技能3 has no HIT bank at all (its slash keeps the normal impact)
|
||||
const leizi = new Map([
|
||||
['ON_ABILITY_ON.attack.1.2.1', [P('p_atk_lzxqlkl_d1')]],
|
||||
['ON_ABILITY_HIT.attack.4', [P('p_imp_lzxqlkl_h')]],
|
||||
['ON_ABILITY_START.attack.7', [P('p_atk_lzxqlkl_s')]],
|
||||
]);
|
||||
assert.deepEqual(pickModeHits(leizi), { h: [P('p_imp_lzxqlkl_h')] });
|
||||
assert.deepEqual(pickModeAttacks(leizi), { d: [P('p_atk_lzxqlkl_d1')], s: [P('p_atk_lzxqlkl_s')] });
|
||||
assert.deepEqual(pickModeHits(new Map()), {});
|
||||
assert.deepEqual(pickModeHits(undefined), {});
|
||||
});
|
||||
|
||||
test('the committed manifest: her S3 swing and impact are the `_s` pair, her normal pair is untouched', () => {
|
||||
const u = manifest.audio.sfx.units[CHEN3];
|
||||
assert.equal(u.attack, '/assets/audio/sfx/player/p_atk/p_atk_hljdswd_n.mp3', '普通攻击的挥砍');
|
||||
assert.equal(u.hit, '/assets/audio/sfx/player/p_imp/p_imp_hljdswd_n.mp3', '普通攻击的命中');
|
||||
assert.equal(u.attacks?.['2'], '/assets/audio/sfx/player/p_atk/p_atk_hljdswd_s.mp3', 'S3 的挥砍');
|
||||
assert.equal(u.hits?.['2'], '/assets/audio/sfx/player/p_imp/p_imp_hljdswd_s.mp3', 'S3 的斩击(命中)');
|
||||
assert.equal(u.attacks?.['0'], '/assets/audio/sfx/player/p_atk/p_atk_hljdfswd_d.mp3', '技能1 的挥砍');
|
||||
assert.equal(u.hits?.['0'], '/assets/audio/sfx/player/p_imp/p_imp_hljdrfswd_d.mp3', '技能1 的命中');
|
||||
});
|
||||
|
||||
test('every manifest unit with a hits table agrees with the mode convention (d / h / s = 技能1 / 2 / 3)', () => {
|
||||
const units = manifest.audio.sfx.units;
|
||||
let n = 0;
|
||||
for (const [id, u] of Object.entries(units)) {
|
||||
for (const [i, url] of Object.entries(u?.hits || {})) {
|
||||
n++;
|
||||
const letter = skillModeLetter(url);
|
||||
assert.ok(letter, `${id}: hits[${i}] is a skill-mode file (${url})`);
|
||||
assert.equal(letter, SLOT_MODE_LETTER[i], `${id}: hits[${i}] is 技能${Number(i) + 1} (the _${letter} mode)`);
|
||||
}
|
||||
}
|
||||
assert.ok(n >= 10, `some operators carry mode impacts (found ${n})`);
|
||||
});
|
||||
|
||||
test('no operator takes a skill-mode file as its normal attack or impact (community report #54 stays fixed)', () => {
|
||||
const units = manifest.audio.sfx.units;
|
||||
let n = 0;
|
||||
for (const [id, u] of Object.entries(units)) {
|
||||
if (!id.startsWith('char_')) continue;
|
||||
for (const kind of ['attack', 'hit']) {
|
||||
const url = u?.[kind];
|
||||
if (typeof url !== 'string') continue;
|
||||
n++;
|
||||
assert.equal(skillModeLetter(url), null, `${id}.${kind} = ${url} is a skill mode's file`);
|
||||
}
|
||||
}
|
||||
assert.ok(n > 300, `${n} normal attack / impact entries checked`);
|
||||
});
|
||||
+36
-36
@@ -5697,68 +5697,68 @@
|
||||
"round": 13,
|
||||
"seed": 326579774,
|
||||
"waveId": "act1autochess_h06",
|
||||
"time": 196.33,
|
||||
"ticks": 5890,
|
||||
"time": 197.77,
|
||||
"ticks": 5933,
|
||||
"reason": "cleared",
|
||||
"killed": 123,
|
||||
"total": 123,
|
||||
"unspawned": 0,
|
||||
"bossHpLeft": null,
|
||||
"rngDraws": 23,
|
||||
"rngDraws": 25,
|
||||
"errors": {"count":0,"kinds":[]},
|
||||
"players": {"p1":{"killed":123,"total":123,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":1103139,"bossDmg":0,"heal":29758,"deaths":1,"layerGains":{}}},
|
||||
"players": {"p1":{"killed":123,"total":123,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":1103139,"bossDmg":0,"heal":43932,"deaths":0,"layerGains":{}}},
|
||||
"units": [
|
||||
["p1",1,"op","chess_char_5_diy1_b",0,"uniequip_002_necras",2070.2,1,3,0,0,0,0,0,0],
|
||||
["p1",2,"op","chess_char_5_diy2_b",1,"uniequip_002_necras",2070.2,1,4,1,18891,0,0,2,1],
|
||||
["p1",3,"op","chess_char_5_diy1_b",2,"uniequip_002_necras",2070.2,1,11,0,35621,0,0,33,5],
|
||||
["p1",4,"op","chess_char_6_diy1_a",0,null,1103.11,1,13,2,20136,484,0,38,3],
|
||||
["p1",3,"op","chess_char_5_diy1_b",2,"uniequip_002_necras",2070.2,1,11,2,28423,0,0,32,5],
|
||||
["p1",4,"op","chess_char_6_diy1_a",0,null,1103.11,1,13,2,17971,484,0,35,3],
|
||||
["p1",5,"op","chess_char_6_diy2_a",1,null,1587.3,1,5,1,5091,0,0,2,1],
|
||||
["p1",6,"op","chess_char_6_diy1_a",2,null,1587.3,1,8,8,73989,0,0,46,7],
|
||||
["p1",7,"op","chess_char_6_diy2_b",0,"none",1938.2,1,12,3,24655,0,0,37,3],
|
||||
["p1",8,"op","chess_char_6_diy1_b",1,"none",1938.2,1,167,9,89706,1938,0,60,4],
|
||||
["p1",9,"op","chess_char_6_diy2_b",2,"none",1938.2,1,9,20,183323,0,0,82,7],
|
||||
["p1",6,"op","chess_char_6_diy1_a",2,null,1587.3,1,8,6,59953,0,0,37,6],
|
||||
["p1",7,"op","chess_char_6_diy2_b",0,"none",1938.2,1,12,3,40643,0,0,34,6],
|
||||
["p1",8,"op","chess_char_6_diy1_b",1,"none",133.43,1,14,17,152041,1805,0,86,7],
|
||||
["p1",9,"op","chess_char_6_diy2_b",2,"none",1938.2,1,9,12,135375,0,0,57,6],
|
||||
["p1",10,"op","chess_char_6_diy1_b",0,"uniequip_002_necras",2158.2,1,6,0,869,0,0,2,1],
|
||||
["p1",11,"op","chess_char_6_diy2_b",1,"uniequip_002_necras",2158.2,1,7,4,26920,0,0,1,3],
|
||||
["p1",12,"op","chess_char_6_diy1_b",2,"uniequip_002_necras",1932.6,1,10,9,35710,226,0,36,5],
|
||||
["p1",11,"op","chess_char_6_diy2_b",1,"uniequip_002_necras",2158.2,1,7,3,26240,0,0,1,2],
|
||||
["p1",12,"op","chess_char_6_diy1_b",2,"uniequip_002_necras",1932.6,1,10,6,30800,226,0,33,5],
|
||||
["-","~1","device","trap_1105_accrate",null,null,100,1,1,0,0,0,0,0,null],
|
||||
["-","~2","device","trap_1105_accrate",null,null,100,1,2,0,0,0,0,0,null],
|
||||
["p1","~1","token","token_10043_necras_skeltn",null,null,141.28,0,24,0,4555,5621,2399,9,null],
|
||||
["p1","~1","token","token_10043_necras_skeltn",null,null,1814.64,0,24,0,2543,3948,2399,6,null],
|
||||
["p1","~2","token","token_10043_necras_skeltn",null,null,3488,1,27,0,1341,2008,2008,2,null],
|
||||
["p1","~3","token","token_10043_necras_skeltn",null,null,0,0,31,1,3094,3488,0,7,null],
|
||||
["p1","~4","token","token_10043_necras_skeltn",null,null,0,0,32,1,6630,5232,0,4,1],
|
||||
["p1","~5","token","token_10043_necras_skeltn",null,null,0,0,33,0,2913,6512,3024,8,null],
|
||||
["p1","~6","token","token_10043_necras_skeltn",null,null,1374.21,1,35,38,346689,10935,426,48,8],
|
||||
["p1","~6","token","token_10043_necras_skeltn",null,null,4026.44,1,35,43,391096,8283,426,48,8],
|
||||
["p1","~7","token","token_10043_necras_skeltn",null,null,3488,1,40,0,0,0,0,0,null],
|
||||
["p1","~8","token","token_10043_necras_skeltn",null,null,1744,0,41,0,0,0,0,0,null],
|
||||
["p1","~9","token","token_10043_necras_skeltn",null,null,0,0,47,0,202,1535,0,1,0],
|
||||
["p1","~10","token","token_10043_necras_skeltn",null,null,0,0,48,13,104269,9857,2930,12,2],
|
||||
["p1","~11","token","token_10043_necras_skeltn",null,null,0,0,49,2,14104,7574,4334,30,null],
|
||||
["p1","~12","token","token_10043_necras_skeltn",null,null,643.36,0,51,0,231,2845,0,1,0],
|
||||
["p1","~13","token","token_10043_necras_skeltn",null,null,0,0,52,0,5636,3488,0,11,null],
|
||||
["p1","~9","token","token_10043_necras_skeltn",null,null,3431.01,0,45,1,924,57,0,3,null],
|
||||
["p1","~10","token","token_10043_necras_skeltn",null,null,0,0,48,0,202,1535,0,1,0],
|
||||
["p1","~11","token","token_10043_necras_skeltn",null,null,0,0,49,13,104269,9857,2930,12,2],
|
||||
["p1","~12","token","token_10043_necras_skeltn",null,null,0,0,50,2,14104,7574,4334,30,null],
|
||||
["p1","~13","token","token_10043_necras_skeltn",null,null,643.36,0,52,0,231,2845,0,1,0],
|
||||
["p1","~14","token","token_10043_necras_skeltn",null,null,1312.64,0,54,0,1462,1757,0,3,0],
|
||||
["p1","~15","token","token_10043_necras_skeltn",null,null,1535,1,55,0,0,0,0,0,null],
|
||||
["p1","~16","token","token_10043_necras_skeltn",null,null,3070,1,56,0,0,0,0,0,null],
|
||||
["p1","~17","token","token_10043_necras_skeltn",null,null,3488,1,57,0,0,0,0,0,null],
|
||||
["p1","~18","token","token_10043_necras_skeltn",null,null,1535,0,60,0,0,0,0,0,0],
|
||||
["p1","~19","token","token_10043_necras_skeltn",null,null,3070,0,61,0,0,0,0,0,0],
|
||||
["p1","~20","token","token_10043_necras_skeltn",null,null,3070,0,82,0,0,0,0,0,0],
|
||||
["p1","~21","token","token_10043_necras_skeltn",null,null,3488,0,89,0,0,0,0,0,0],
|
||||
["p1","~22","token","token_10043_necras_skeltn",null,null,0,0,96,0,2647,3488,0,6,null],
|
||||
["p1","~23","token","token_10043_necras_skeltn",null,null,3280.31,0,102,0,7450,5000,5413,17,null],
|
||||
["p1","~24","token","token_10043_necras_skeltn",null,null,13603.2,1,103,6,58696,20,26,7,3],
|
||||
["p1","~25","token","token_10043_necras_skeltn",null,null,340.04,0,106,1,6379,3520,745,14,null],
|
||||
["p1","~26","token","token_10043_necras_skeltn",null,null,264.69,1,124,1,3626,3223,0,8,null],
|
||||
["p1","~27","token","token_10043_necras_skeltn",null,null,1672.16,0,130,1,5196,6256,4565,11,0],
|
||||
["p1","~28","token","token_10043_necras_skeltn",null,null,643.36,0,146,0,231,2873,28,1,0],
|
||||
["p1","~29","token","token_10043_necras_skeltn",null,null,1228.28,0,147,1,6396,5161,2902,12,0],
|
||||
["p1","~30","token","token_10043_necras_skeltn",null,null,3337.3,1,157,1,5095,804,653,11,0],
|
||||
["p1","~31","token","token_10043_necras_skeltn",null,null,3459.5,1,168,0,1387,210,305,4,null]
|
||||
["p1","~20","token","token_10043_necras_skeltn",null,null,1072.87,0,77,1,4090,2415,0,9,null],
|
||||
["p1","~21","token","token_10043_necras_skeltn",null,null,3070,0,83,0,0,0,0,0,0],
|
||||
["p1","~22","token","token_10043_necras_skeltn",null,null,3488,0,90,0,0,0,0,0,0],
|
||||
["p1","~23","token","token_10043_necras_skeltn",null,null,0,0,97,0,2647,3488,0,6,null],
|
||||
["p1","~24","token","token_10043_necras_skeltn",null,null,2594.33,0,98,0,798,894,0,2,null],
|
||||
["p1","~25","token","token_10043_necras_skeltn",null,null,3488,1,104,3,27488,17532,18152,57,null],
|
||||
["p1","~26","token","token_10043_necras_skeltn",null,null,5232,1,105,0,0,0,0,0,0],
|
||||
["p1","~27","token","token_10043_necras_skeltn",null,null,0,0,108,1,6842,3860,745,15,null],
|
||||
["p1","~28","token","token_10043_necras_skeltn",null,null,1072.87,0,126,1,4090,2415,0,9,null],
|
||||
["p1","~29","token","token_10043_necras_skeltn",null,null,0,0,130,1,3566,4074,586,8,null],
|
||||
["p1","~30","token","token_10043_necras_skeltn",null,null,3488,1,145,2,8178,9328,9328,16,null],
|
||||
["p1","~31","token","token_10043_necras_skeltn",null,null,2651.32,1,146,0,335,837,0,1,null]
|
||||
],
|
||||
"enemies": {"enemy_1019_jshoot_2":[57,57,0,9221,255302],"enemy_1299_ymkilr_2":[27,27,0,81708,492687],"enemy_1422_lrsldr_2":[39,39,0,3129,355830]},
|
||||
"hooks": [1,169,5890,915,915,1387,1387,138,132,145,54,52,0,36568,14,133,123,0,1,132,0,0,0],
|
||||
"events": {"atk":915,"deploy":44,"die":145,"dmg":1387,"engage":33,"fx":349,"heal":52,"skill":106,"spawn":168,"status":14},
|
||||
"fx": {"aoe":166,"grow":138,"sleep":14,"summon":31},
|
||||
"snaps": "90c70f41 c302dd2b 54cd7edf e58852e9 bff57786 f6e87429 f72cca13 5c878308 5dae900e 9943c059 9d89ea84 f721c49b ce40836f b3189627 f100922e 9c82ebda 12c800d9 bd8c14ef 4b6a90f7 fe7535da"
|
||||
"enemies": {"enemy_1019_jshoot_2":[57,57,0,8725,255302],"enemy_1299_ymkilr_2":[27,27,0,87984,492687],"enemy_1422_lrsldr_2":[39,39,0,3746,355830]},
|
||||
"hooks": [1,168,5933,918,918,1441,1441,159,132,144,54,53,0,36962,19,139,123,0,1,132,0,0,0],
|
||||
"events": {"atk":918,"deploy":43,"die":144,"dmg":1441,"engage":32,"fx":355,"heal":74,"skill":107,"spawn":168,"status":19},
|
||||
"fx": {"aoe":146,"grow":159,"sleep":19,"summon":31},
|
||||
"snaps": "90c70f41 c302dd2b ce97444e f56da20e 7867fc43 c6389923 0e8c4561 4f741322 44a83b86 92652efc ba0316f9 978ef38d 12494606 71523afe 0ac4155c 4b9bbd7b 25d1835c 9345e5a3 94e6cb6a 4af2c8cc"
|
||||
},
|
||||
"diy-133": {
|
||||
"about": "5_diy1_a=kalts2/S1 5_diy2_a=kalts2/S2 5_diy1_a=kalts2/S3 5_diy2_b=kalts2/S1 5_diy1_b=kalts2/S2 5_diy2_b=kalts2/S3 6_diy1_a=kalts2/S1 6_diy2_a=kalts2/S2 6_diy1_a=kalts2/S3 6_diy2_b=kalts2/S1 6_diy1_b=kalts2/S2 6_diy2_b=kalts2/S3",
|
||||
|
||||
+258
-256
@@ -17,42 +17,42 @@
|
||||
"round": 14,
|
||||
"seed": 1379500996,
|
||||
"waveId": "act1autochess_h07_01",
|
||||
"time": 198.03,
|
||||
"ticks": 5941,
|
||||
"reason": "cleared",
|
||||
"time": 200,
|
||||
"ticks": 6000,
|
||||
"reason": "forced",
|
||||
"killed": 20,
|
||||
"total": 16,
|
||||
"unspawned": 0,
|
||||
"bossHpLeft": 0,
|
||||
"rngDraws": 624,
|
||||
"bossHpLeft": 26388,
|
||||
"rngDraws": 616,
|
||||
"errors": {"count":0,"kinds":[]},
|
||||
"players": {
|
||||
"p1": {"killed":20,"total":16,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":2580943,"bossDmg":2141675,"heal":19370,"deaths":1,"layerGains":{}},
|
||||
"p2": {"killed":0,"total":0,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":1723678,"bossDmg":1458325,"heal":61060,"deaths":0,"layerGains":{}}
|
||||
"p1": {"killed":20,"total":16,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":2492630,"bossDmg":2081416,"heal":24007,"deaths":0,"layerGains":{}},
|
||||
"p2": {"killed":0,"total":0,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":1785615,"bossDmg":1492196,"heal":63058,"deaths":0,"layerGains":{}}
|
||||
},
|
||||
"units": [
|
||||
["p1",1,"op","chess_char_6_03_b",2,"uniequip_002_yu",2060.79,1,13,0,0,12547,10824,0,0],
|
||||
["p1",2,"op","chess_char_6_06_b",2,"uniequip_002_pepe",1839.57,1,15,0,0,4688,524,0,0],
|
||||
["p1",3,"op","chess_char_6_07_b",2,"uniequip_002_siege2",2662.34,1,17,0,0,5403,1853,0,0],
|
||||
["p1",4,"op","chess_char_6_01_b",2,"uniequip_002_lemuen",709.43,1,7,8,1345935,1252,0,63,8],
|
||||
["p1",5,"op","chess_char_6_02_b",2,"uniequip_002_sbell2",2126.3,1,9,0,0,1417,0,0,0],
|
||||
["p1",6,"op","chess_char_6_04_b",2,"uniequip_002_skadi2",0,0,19,0,71666,12281,3597,0,4],
|
||||
["p1",7,"op","chess_char_6_05_b",2,"uniequip_002_pasngr",1586.2,1,24,3,640231,1558,444,98,10],
|
||||
["p1",8,"op","chess_char_6_08_b",2,"uniequip_002_reed2",1834.8,1,11,0,0,593,0,0,0],
|
||||
["p1",9,"op","chess_char_6_09_b",0,"uniequip_002_cello",1454.2,1,26,3,308334,2483,51,51,51],
|
||||
["p1",10,"op","chess_char_6_11_b",2,"uniequip_002_mlyss",1015.63,1,21,0,133231,1763,39,214,5],
|
||||
["p1",1,"op","chess_char_6_03_b",2,"uniequip_002_yu",2665.18,1,13,0,0,12709,11440,0,0],
|
||||
["p1",2,"op","chess_char_6_06_b",2,"uniequip_002_pepe",3227.66,1,15,0,0,3107,2118,0,0],
|
||||
["p1",3,"op","chess_char_6_07_b",2,"uniequip_002_siege2",3015.02,1,17,0,0,6076,2611,0,0],
|
||||
["p1",4,"op","chess_char_6_01_b",2,"uniequip_002_lemuen",709.43,1,7,10,1231170,1252,0,61,8],
|
||||
["p1",5,"op","chess_char_6_02_b",2,"uniequip_002_sbell2",2126.3,1,9,0,0,2382,0,0,0],
|
||||
["p1",6,"op","chess_char_6_04_b",2,"uniequip_002_skadi2",1341.02,1,19,0,77075,11593,3192,0,5],
|
||||
["p1",7,"op","chess_char_6_05_b",2,"uniequip_002_pasngr",1586.2,1,24,5,645358,853,853,99,10],
|
||||
["p1",8,"op","chess_char_6_08_b",2,"uniequip_002_reed2",1092.93,1,11,0,0,2188,0,0,0],
|
||||
["p1",9,"op","chess_char_6_09_b",0,"uniequip_002_cello",1454.2,1,26,1,317350,3825,709,51,51],
|
||||
["p1",10,"op","chess_char_6_11_b",2,"uniequip_002_mlyss",2082.3,1,21,0,136761,974,599,216,5],
|
||||
["p1",11,"token","token_10017_skadi2_dedant",null,null,138,0,28,0,0,0,0,0,null],
|
||||
["p1",12,"token","token_10030_mlyss_wtrman",null,null,0,0,22,0,22803,4807,1334,27,1],
|
||||
["p2",13,"op","chess_char_6_15_b",2,"uniequip_002_qiubai",4435.2,1,33,10,787333,16947,0,190,3],
|
||||
["p2",14,"op","chess_char_6_19_b",2,"uniequip_002_blkkgt",3043.7,1,16,1,75440,6403,0,0,1],
|
||||
["p1",12,"token","token_10030_mlyss_wtrman",null,null,0,0,22,0,24621,3767,1334,28,1],
|
||||
["p2",13,"op","chess_char_6_15_b",2,"uniequip_002_qiubai",4435.2,1,33,5,811980,16947,0,192,3],
|
||||
["p2",14,"op","chess_char_6_19_b",2,"uniequip_002_blkkgt",3043.7,1,16,1,75440,2090,0,0,1],
|
||||
["p2",15,"op","chess_char_5_01_b",1,"uniequip_002_excu2",2545.4,1,18,0,0,0,0,0,0],
|
||||
["p2",16,"op","chess_char_6_17_b",2,"uniequip_002_nearl2",3952.3,1,23,1,218717,3293,0,133,4],
|
||||
["p2",17,"op","chess_char_6_12_b",1,"uniequip_002_rosmon",2545.15,1,8,0,0,4493,0,0,0],
|
||||
["p2",18,"op","chess_char_6_13_b",1,"uniequip_002_angel2",2119.76,1,10,0,48487,5750,2454,0,0],
|
||||
["p2",19,"op","chess_char_6_14_b",2,"uniequip_002_lumen",3028.91,1,20,0,0,1127,12010,16,1],
|
||||
["p2",20,"op","chess_char_6_16_b",2,"uniequip_002_halo2",1741.19,1,25,0,235654,1496,0,202,4],
|
||||
["p2",21,"op","chess_char_6_18_b",2,"uniequip_002_whitw2",1695.1,1,12,1,343425,890,0,0,3],
|
||||
["p2",22,"op","chess_char_6_20_b",2,"uniequip_002_agoat2",1828.31,1,27,0,0,0,46597,45,2],
|
||||
["p2",16,"op","chess_char_6_17_b",2,"uniequip_002_nearl2",3952.3,1,23,0,229606,2042,0,134,4],
|
||||
["p2",17,"op","chess_char_6_12_b",1,"uniequip_002_rosmon",3175.48,1,8,0,0,1853,0,0,0],
|
||||
["p2",18,"op","chess_char_6_13_b",1,"uniequip_002_angel2",2412.14,1,10,0,64538,9447,3757,0,0],
|
||||
["p2",19,"op","chess_char_6_14_b",2,"uniequip_002_lumen",3028.91,1,20,0,0,0,6776,9,1],
|
||||
["p2",20,"op","chess_char_6_16_b",2,"uniequip_002_halo2",1741.19,1,25,0,242658,2644,0,204,4],
|
||||
["p2",21,"op","chess_char_6_18_b",2,"uniequip_002_whitw2",804.61,1,12,1,346767,2486,0,0,3],
|
||||
["p2",22,"op","chess_char_6_20_b",2,"uniequip_002_agoat2",1828.31,1,27,0,0,4344,52525,46,2],
|
||||
["-","~1","device","trap_1105_accrate",null,null,100,1,1,0,0,0,0,0,null],
|
||||
["-","~2","device","trap_1105_accrate",null,null,100,1,2,0,0,0,0,0,null],
|
||||
["-","~3","device","trap_1105_accrate",null,null,100,1,3,0,0,0,0,0,null],
|
||||
@@ -60,30 +60,31 @@
|
||||
["-","~5","device","trap_1105_accrate",null,null,100,1,5,0,0,0,0,0,null],
|
||||
["-","~6","device","trap_1105_accrate",null,null,100,1,6,0,0,0,0,0,null],
|
||||
["p2","~1","token","token_10019_nearl2_sword",null,null,8280,0,38,0,3661,0,0,25,null],
|
||||
["p1","~1","token","token_10030_mlyss_wtrman",null,null,875.29,0,39,1,6710,1115,0,12,null],
|
||||
["p1","~1","token","token_10030_mlyss_wtrman",null,null,1989.96,0,39,1,6710,0,0,12,null],
|
||||
["p1","~2","token","token_10030_mlyss_wtrman",null,null,303.83,0,43,0,8824,1571,0,12,null],
|
||||
["p1","~1","token","token_10017_skadi2_dedant",null,null,100,0,53,0,0,0,0,0,null],
|
||||
["p2","~2","token","token_10019_nearl2_sword",null,null,6600,0,55,0,3661,0,0,25,null],
|
||||
["p1","~3","token","token_10030_mlyss_wtrman",null,null,1442,1,58,0,28966,705,705,52,1],
|
||||
["p1","~4","token","token_10030_mlyss_wtrman",null,null,1989.96,0,61,0,6040,0,0,12,null],
|
||||
["p1","~3","token","token_10030_mlyss_wtrman",null,null,1442,1,58,2,31187,1151,1151,53,1],
|
||||
["p1","~4","token","token_10030_mlyss_wtrman",null,null,1989.96,0,61,0,5369,0,0,12,null],
|
||||
["p1","~2","token","token_10017_skadi2_dedant",null,null,138,0,64,0,0,0,0,0,null],
|
||||
["p2","~3","token","token_10019_nearl2_sword",null,null,6600,0,65,0,3654,0,0,25,null],
|
||||
["p1","~5","token","token_10030_mlyss_wtrman",null,null,1442,0,67,0,2686,0,0,12,null],
|
||||
["p2","~3","token","token_10019_nearl2_sword",null,null,6600,0,65,0,3657,0,0,25,null],
|
||||
["p1","~5","token","token_10030_mlyss_wtrman",null,null,1442,0,67,1,3021,0,0,12,null],
|
||||
["p1","~6","token","token_10030_mlyss_wtrman",null,null,1442,0,70,0,4846,0,0,12,null],
|
||||
["p1","~7","token","token_10030_mlyss_wtrman",null,null,1442,1,71,0,672,0,0,3,null],
|
||||
["p2","~4","token","token_10019_nearl2_sword",null,null,6600,1,72,0,3647,0,0,4,null]
|
||||
["p1","~7","token","token_10030_mlyss_wtrman",null,null,1442,1,71,0,337,0,0,4,null],
|
||||
["p2","~4","token","token_10019_nearl2_sword",null,null,6600,1,72,0,3648,0,0,6,null],
|
||||
["p1","~3","token","token_10017_skadi2_dedant",null,null,100,1,74,0,0,0,0,0,null]
|
||||
],
|
||||
"enemies": {
|
||||
"enemy_1005_yokai": [4,4,0,0,13759],
|
||||
"enemy_1006_shield_2": [14,14,0,19099,601974],
|
||||
"enemy_1061_zomshd": [2,2,0,1052,88636],
|
||||
"enemy_9013_acstmk": [1,1,0,46552,3600000],
|
||||
"enemy_9016_acstmr": [8,7,0,0,251]
|
||||
"enemy_9013_acstmk": [1,0,0,46337,3573612],
|
||||
"enemy_9016_acstmr": [8,7,0,0,263]
|
||||
},
|
||||
"hooks": [1,71,5941,1319,1319,3122,3493,3694,30,42,98,94,69,41511,2716,10,29,0,1,29,0,21,0],
|
||||
"events": {"atk":1625,"deploy":36,"die":42,"dmg":3192,"engage":19,"fx":854,"heal":336,"skill":192,"spawn":70,"status":607},
|
||||
"fx": {"airstrike":26,"beam":54,"blink":1,"bombard":69,"bombardShell":69,"burst":21,"copy":2,"dp":5,"drone":328,"droneLock":18,"drones":3,"finale":1,"garrisonGrant":1,"lightning":129,"link":2,"lock":69,"phase":1,"radiantSword":4,"shell":8,"slash":1,"snow":5,"split":6,"storm":10,"summon":8,"talent":4,"tide":4,"wanted":5},
|
||||
"snaps": "d0c7ebf7 c2a47f5c 6df72cfc f267f95e d216374a ed68fbff db7fd85b 64c3b233 8d07119c 37a755de af8e384d 37e6901f f432740d 5617ea7a bd1267d1 796410c7 47858302 d2d260f8 5c90313f 65608f5f"
|
||||
"hooks": [1,72,6000,1324,1324,3177,3555,4943,28,40,99,94,69,42113,2727,11,29,0,1,28,0,21,0],
|
||||
"events": {"atk":1635,"deploy":37,"die":40,"dmg":3251,"engage":19,"fx":848,"heal":347,"skill":193,"spawn":71,"status":629},
|
||||
"fx": {"airstrike":27,"beam":54,"blink":1,"bombard":64,"bombardShell":65,"burst":21,"copy":2,"dp":5,"drone":334,"droneLock":17,"drones":3,"finale":1,"garrisonGrant":1,"lightning":125,"link":2,"lock":69,"phase":1,"radiantSword":4,"shell":8,"slash":1,"snow":5,"split":6,"storm":10,"summon":8,"talent":4,"tide":5,"wanted":5},
|
||||
"snaps": "d0c7ebf7 c2a47f5c d51bb8c3 2d417d2a e0e24d2b 03e06f98 c1c51721 923667aa 93a574cd 45824693 fddb0f84 fda2294a 9028c7a3 85522803 4853f594 0a4870e7 bbefbbd9 c61aa475 3ca5a376 929494fd"
|
||||
},
|
||||
"boss-boss_1-solo": {
|
||||
"about": "假想敌:胄 solo pool 1800000: chess_char_5_08_b chess_char_5_11_b chess_char_5_05_b chess_char_5_06_b chess_char_5_13_b chess_char_5_07_b chess_char_5_02_b chess_char_5_03_b chess_char_5_10_b chess_char_5_12_b",
|
||||
@@ -226,110 +227,112 @@
|
||||
"round": 14,
|
||||
"seed": 1428555614,
|
||||
"waveId": "act1autochess_h07_03",
|
||||
"time": 165.03,
|
||||
"ticks": 4951,
|
||||
"time": 193.03,
|
||||
"ticks": 5791,
|
||||
"reason": "cleared",
|
||||
"killed": 19,
|
||||
"total": 6,
|
||||
"unspawned": 0,
|
||||
"bossHpLeft": 0,
|
||||
"rngDraws": 1278,
|
||||
"rngDraws": 1399,
|
||||
"errors": {"count":0,"kinds":[]},
|
||||
"players": {
|
||||
"p1": {"killed":19,"total":6,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":1570654,"bossDmg":1472280,"heal":5235,"deaths":2,"layerGains":{}},
|
||||
"p2": {"killed":0,"total":0,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":2613753,"bossDmg":2527720,"heal":37017,"deaths":3,"layerGains":{}}
|
||||
"p1": {"killed":19,"total":6,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":1311182,"bossDmg":1211754,"heal":15185,"deaths":2,"layerGains":{}},
|
||||
"p2": {"killed":0,"total":0,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":2873270,"bossDmg":2788246,"heal":46782,"deaths":3,"layerGains":{}}
|
||||
},
|
||||
"units": [
|
||||
["p1",1,"op","chess_char_6_03_b",2,"uniequip_002_yu",4070,1,7,0,0,0,3196,0,1],
|
||||
["p1",2,"op","chess_char_6_06_b",2,"uniequip_002_pepe",3232.9,1,11,0,0,0,0,0,0],
|
||||
["p1",3,"op","chess_char_6_07_b",2,"uniequip_002_siege2",3411.1,1,9,1,44,1804,324,32,1],
|
||||
["p1",4,"op","chess_char_6_01_b",2,"uniequip_002_lemuen",2344.65,1,13,4,902572,2208,317,53,7],
|
||||
["p1",5,"op","chess_char_6_02_b",2,"uniequip_002_sbell2",2126.3,1,17,3,323222,5411,272,52,3],
|
||||
["p1",6,"op","chess_char_6_04_b",2,"uniequip_002_skadi2",532.64,1,63,1,27583,12835,687,0,3],
|
||||
["p1",7,"op","chess_char_6_05_b",2,"uniequip_002_pasngr",823.13,1,67,1,178714,7514,404,45,3],
|
||||
["p1",8,"op","chess_char_6_08_b",2,"uniequip_002_reed2",1834.8,1,5,0,59,0,36,25,2],
|
||||
["p1",9,"op","chess_char_6_09_b",0,"uniequip_002_cello",1454.2,1,15,1,59,0,0,15,15],
|
||||
["p1",10,"op","chess_char_6_11_b",2,"uniequip_002_mlyss",2082.3,1,23,0,101077,0,0,170,4],
|
||||
["p1",1,"op","chess_char_6_03_b",2,"uniequip_002_yu",4070,1,7,0,0,963,7372,0,1],
|
||||
["p1",2,"op","chess_char_6_06_b",2,"uniequip_002_pepe",3232.9,1,11,0,0,22,0,0,0],
|
||||
["p1",3,"op","chess_char_6_07_b",2,"uniequip_002_siege2",3296.85,1,9,1,54,7553,554,47,2],
|
||||
["p1",4,"op","chess_char_6_01_b",2,"uniequip_002_lemuen",1668.07,1,66,3,560206,5126,838,41,5],
|
||||
["p1",5,"op","chess_char_6_02_b",2,"uniequip_002_sbell2",2084.44,1,17,1,258588,5844,732,41,2],
|
||||
["p1",6,"op","chess_char_6_04_b",2,"uniequip_002_skadi2",757.15,1,59,1,26013,13391,2675,0,3],
|
||||
["p1",7,"op","chess_char_6_05_b",2,"uniequip_002_pasngr",2068.72,1,21,1,300324,8951,2948,93,7],
|
||||
["p1",8,"op","chess_char_6_08_b",2,"uniequip_002_reed2",1834.8,1,5,1,87,2172,67,30,2],
|
||||
["p1",9,"op","chess_char_6_09_b",0,"uniequip_002_cello",1454.2,1,15,2,78,2307,0,17,17],
|
||||
["p1",10,"op","chess_char_6_11_b",2,"uniequip_002_mlyss",2082.3,1,23,0,121760,0,0,203,5],
|
||||
["p1",11,"token","token_10017_skadi2_dedant",null,null,138,0,26,0,0,0,0,0,null],
|
||||
["p2",12,"op","chess_char_6_15_b",2,"uniequip_002_qiubai",4181.76,1,31,0,465270,0,0,170,3],
|
||||
["p2",13,"op","chess_char_6_19_b",2,"uniequip_002_blkkgt",3043.7,1,60,1,29,3044,0,9,1],
|
||||
["p2",14,"op","chess_char_5_01_b",1,"uniequip_002_excu2",2545.4,1,58,0,16,3865,960,16,1],
|
||||
["p2",15,"op","chess_char_6_17_b",2,"uniequip_002_nearl2",6916.53,1,32,0,293853,2679,0,72,3],
|
||||
["p2",16,"op","chess_char_6_12_b",1,"uniequip_002_rosmon",3493.03,1,16,6,216145,3498,625,60,3],
|
||||
["p2",17,"op","chess_char_6_13_b",1,"uniequip_002_angel2",2680.15,1,20,2,1433169,5683,1699,302,5],
|
||||
["p2",18,"op","chess_char_6_14_b",2,"uniequip_002_lumen",2297.79,1,22,0,0,3550,4812,50,1],
|
||||
["p2",19,"op","chess_char_6_16_b",2,"uniequip_002_halo2",1741.19,1,25,0,67561,1414,0,113,3],
|
||||
["p2",20,"op","chess_char_6_18_b",2,"uniequip_002_whitw2",1695.1,1,59,0,105769,1695,0,21,2],
|
||||
["p2",21,"op","chess_char_6_20_b",2,"uniequip_002_agoat2",1828.31,1,18,0,0,1833,28921,49,2],
|
||||
["p2",12,"op","chess_char_6_15_b",2,"uniequip_002_qiubai",4181.76,1,31,1,525926,1350,73,197,3],
|
||||
["p2",13,"op","chess_char_6_19_b",2,"uniequip_002_blkkgt",3043.7,1,61,0,27,3044,0,8,1],
|
||||
["p2",14,"op","chess_char_5_01_b",1,"uniequip_002_excu2",2545.4,1,57,0,15,3807,900,15,1],
|
||||
["p2",15,"op","chess_char_6_17_b",2,"uniequip_002_nearl2",6916.53,1,32,0,361017,2679,0,91,3],
|
||||
["p2",16,"op","chess_char_6_12_b",1,"uniequip_002_rosmon",3493.03,1,16,6,251273,3571,545,71,4],
|
||||
["p2",17,"op","chess_char_6_13_b",1,"uniequip_002_angel2",2680.15,1,20,2,1500382,6607,1632,344,6],
|
||||
["p2",18,"op","chess_char_6_14_b",2,"uniequip_002_lumen",2297.79,1,22,0,0,3550,6766,52,1],
|
||||
["p2",19,"op","chess_char_6_16_b",2,"uniequip_002_halo2",1741.19,1,25,0,85000,1414,0,136,4],
|
||||
["p2",20,"op","chess_char_6_18_b",2,"uniequip_002_whitw2",1695.1,1,58,1,117690,1695,0,22,2],
|
||||
["p2",21,"op","chess_char_6_20_b",2,"uniequip_002_agoat2",1828.31,1,18,0,0,1717,36867,52,2],
|
||||
["-","~1","device","trap_1105_accrate",null,null,100,1,1,0,0,0,0,0,null],
|
||||
["-","~2","device","trap_1105_accrate",null,null,100,1,2,0,0,0,0,0,null],
|
||||
["-","~3","device","trap_1105_accrate",null,null,100,1,3,0,0,0,0,0,null],
|
||||
["-","~4","device","trap_1105_accrate",null,null,100,1,4,0,0,0,0,0,null],
|
||||
["p1","~1","token","token_10030_mlyss_wtrman",null,null,1586.2,1,24,0,37309,0,0,74,1],
|
||||
["p1","~1","token","token_10030_mlyss_wtrman",null,null,1586.2,1,24,0,44067,0,0,87,1],
|
||||
["p2","~1","token","token_10019_nearl2_sword",null,null,8280,0,41,0,10647,0,0,0,null],
|
||||
["p1","~2","token","token_10030_mlyss_wtrman",null,null,1989.96,0,42,0,0,0,0,0,null],
|
||||
["p1","~2","token","token_10030_mlyss_wtrman",null,null,1989.96,0,42,0,1,0,0,1,null],
|
||||
["p1","~1","token","token_10017_skadi2_dedant",null,null,100,0,49,0,0,0,0,0,null],
|
||||
["p1","~3","token","token_10030_mlyss_wtrman",null,null,1586.2,0,50,0,3,0,0,3,null],
|
||||
["p2","~2","token","token_10019_nearl2_sword",null,null,6600,0,52,0,10647,0,0,0,null],
|
||||
["p1","~4","token","token_10030_mlyss_wtrman",null,null,1989.96,0,54,0,0,0,0,0,null],
|
||||
["p1","~1","token","token_10040_siege2_vlion",null,null,2804.82,0,55,0,10,2715,0,10,null],
|
||||
["p1","~2","token","token_10040_siege2_vlion",null,null,5520,0,56,0,2,0,0,2,null],
|
||||
["p1","~1","token","token_10040_siege2_vlion",null,null,4000,0,53,0,0,0,0,0,null],
|
||||
["p1","~4","token","token_10030_mlyss_wtrman",null,null,1989.96,0,55,0,0,0,0,0,null],
|
||||
["p1","~2","token","token_10017_skadi2_dedant",null,null,100,0,60,0,0,0,0,0,null],
|
||||
["p2","~3","token","token_10019_nearl2_sword",null,null,6600,0,62,0,10647,0,0,0,null],
|
||||
["p1","~2","token","token_10017_skadi2_dedant",null,null,100,1,64,0,0,0,0,0,null],
|
||||
["p1","~5","token","token_10030_mlyss_wtrman",null,null,1586.2,1,65,0,0,0,0,0,null]
|
||||
["p1","~5","token","token_10030_mlyss_wtrman",null,null,1586.2,0,64,0,0,0,0,0,null],
|
||||
["p1","~3","token","token_10017_skadi2_dedant",null,null,100,1,68,0,0,0,0,0,null],
|
||||
["p1","~2","token","token_10040_siege2_vlion",null,null,4000,1,69,0,0,0,0,0,null]
|
||||
],
|
||||
"enemies": {
|
||||
"enemy_1197_sfshu_2": [2,2,0,0,28809],
|
||||
"enemy_1198_msfshu_2": [6,6,0,0,102],
|
||||
"enemy_1209_sfden_2": [2,2,0,0,154793],
|
||||
"enemy_1209_sfden_2": [2,2,0,1350,154793],
|
||||
"enemy_1210_msfden_2": [2,2,0,0,386],
|
||||
"enemy_9021_acduml": [1,1,0,0,4000000],
|
||||
"enemy_9023_acdums": [8,7,0,49006,317]
|
||||
"enemy_9023_acdums": [9,7,0,62774,362]
|
||||
},
|
||||
"hooks": [1,65,4951,1349,1349,3657,4441,2191,25,37,64,57,319,35751,1888,4,21,0,1,24,0,12,1],
|
||||
"events": {"atk":1613,"deploy":40,"die":37,"dmg":4177,"engage":19,"fx":1131,"heal":431,"skill":121,"spawn":58,"status":789},
|
||||
"hooks": [1,67,5791,1558,1558,4205,5122,4446,25,38,72,66,329,44235,2200,3,22,0,1,24,0,17,1],
|
||||
"events": {"atk":1800,"deploy":41,"die":38,"dmg":4833,"engage":18,"fx":1197,"heal":476,"skill":138,"spawn":60,"status":867},
|
||||
"fx": {
|
||||
"airstrike": 115,
|
||||
"airstrike": 116,
|
||||
"appear": 1,
|
||||
"beam": 10,
|
||||
"beam": 11,
|
||||
"blink": 2,
|
||||
"bombard": 60,
|
||||
"bombardShell": 60,
|
||||
"bombard": 39,
|
||||
"bombardShell": 39,
|
||||
"buff": 3,
|
||||
"burst": 12,
|
||||
"burst": 17,
|
||||
"camouflage": 1,
|
||||
"copy": 1,
|
||||
"disappear": 1,
|
||||
"displace": 1,
|
||||
"dodge": 1,
|
||||
"dp": 4,
|
||||
"drone": 199,
|
||||
"droneLock": 21,
|
||||
"dp": 5,
|
||||
"drone": 208,
|
||||
"droneLock": 18,
|
||||
"drones": 2,
|
||||
"explode": 392,
|
||||
"extraAttack": 5,
|
||||
"explode": 449,
|
||||
"extraAttack": 4,
|
||||
"finale": 1,
|
||||
"firewall": 1,
|
||||
"garrisonGrant": 1,
|
||||
"lightning": 37,
|
||||
"link": 7,
|
||||
"lock": 60,
|
||||
"lure": 3,
|
||||
"phase": 38,
|
||||
"lightning": 79,
|
||||
"link": 8,
|
||||
"lock": 39,
|
||||
"lure": 2,
|
||||
"phase": 44,
|
||||
"radiantSword": 3,
|
||||
"scorchBurst": 2,
|
||||
"scorchBurst": 4,
|
||||
"slash": 11,
|
||||
"snow": 37,
|
||||
"snow": 36,
|
||||
"split": 4,
|
||||
"steal": 5,
|
||||
"storm": 3,
|
||||
"summon": 15,
|
||||
"steal": 6,
|
||||
"storm": 7,
|
||||
"summon": 16,
|
||||
"sunBurst": 1,
|
||||
"talent": 6,
|
||||
"talent": 8,
|
||||
"telegraph": 2,
|
||||
"tide": 3,
|
||||
"wanted": 1
|
||||
"wanted": 2
|
||||
},
|
||||
"snaps": "68fffef1 494368df d4448d17 4e91833a 23e11a5a 8752e6c6 6f9be873 fa966775 dfb5037e cefeae7b 8ab8ba7b e65d3634 fa7102a8 076e446e 4f16e0ab 97fb5ca2 77c75b43"
|
||||
"snaps": "68fffef1 494368df 4e38f532 f87d9068 690476ee 03b01e26 0bed04bd 2fd59226 69665385 05fe5c2d 998b698f 48323861 b99302e0 9efaad8f a695d151 50c5bc45 dd3e6215 601aae77 2a462704 b1188c19"
|
||||
},
|
||||
"boss-boss_3-solo": {
|
||||
"about": "假想敌:管 solo pool 2000000: chess_char_5_08_b chess_char_5_11_b chess_char_5_05_b chess_char_5_06_b chess_char_5_13_b chess_char_5_07_b chess_char_5_02_b chess_char_5_03_b chess_char_5_10_b chess_char_5_12_b",
|
||||
@@ -518,99 +521,98 @@
|
||||
"round": 14,
|
||||
"seed": 2302764647,
|
||||
"waveId": "act2autochess_h07_05",
|
||||
"time": 103.97,
|
||||
"ticks": 3119,
|
||||
"time": 102.27,
|
||||
"ticks": 3068,
|
||||
"reason": "cleared",
|
||||
"killed": 18,
|
||||
"killed": 16,
|
||||
"total": 8,
|
||||
"unspawned": 0,
|
||||
"bossHpLeft": 0,
|
||||
"rngDraws": 867,
|
||||
"rngDraws": 874,
|
||||
"errors": {"count":0,"kinds":[]},
|
||||
"players": {
|
||||
"p1": {"killed":18,"total":8,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":1249795,"bossDmg":929511,"heal":26462,"deaths":1,"layerGains":{}},
|
||||
"p2": {"killed":0,"total":0,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":1109971,"bossDmg":630489,"heal":42330,"deaths":1,"layerGains":{}}
|
||||
"p1": {"killed":16,"total":8,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":1231000,"bossDmg":925772,"heal":23117,"deaths":2,"layerGains":{}},
|
||||
"p2": {"killed":0,"total":0,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":1042770,"bossDmg":634228,"heal":41304,"deaths":1,"layerGains":{}}
|
||||
},
|
||||
"units": [
|
||||
["p1",1,"op","chess_char_6_03_b",2,"uniequip_002_yu",4070,1,7,0,42174,5551,11075,18,1],
|
||||
["p1",2,"op","chess_char_6_06_b",2,"uniequip_002_pepe",3232.9,1,12,1,64021,2256,958,12,1],
|
||||
["p1",3,"op","chess_char_6_07_b",2,"uniequip_002_siege2",3411.1,1,14,0,76000,4697,2195,34,1],
|
||||
["p1",4,"op","chess_char_6_01_b",2,"uniequip_002_lemuen",1617,1,1,2,587890,2311,1987,32,4],
|
||||
["p1",5,"op","chess_char_6_02_b",2,"uniequip_002_sbell2",2126.3,1,16,0,54457,230,21,11,2],
|
||||
["p1",6,"op","chess_char_6_04_b",2,"uniequip_002_skadi2",1694.16,1,3,0,6859,6498,1793,0,3],
|
||||
["p1",7,"op","chess_char_6_05_b",2,"uniequip_002_pasngr",1586.2,1,18,0,143648,2793,596,18,4],
|
||||
["p1",8,"op","chess_char_6_08_b",2,"uniequip_002_reed2",1834.8,1,5,1,49062,868,4571,26,2],
|
||||
["p1",9,"op","chess_char_6_09_b",0,"uniequip_002_cello",0,0,20,2,143676,6495,603,20,20],
|
||||
["p1",10,"op","chess_char_6_11_b",2,"uniequip_002_mlyss",2082.3,1,9,1,53802,2964,1391,64,3],
|
||||
["p1",1,"op","chess_char_6_03_b",2,"uniequip_002_yu",4070,1,7,1,8305,1825,6494,6,1],
|
||||
["p1",2,"op","chess_char_6_06_b",2,"uniequip_002_pepe",0,0,12,0,25770,5629,769,5,1],
|
||||
["p1",3,"op","chess_char_6_07_b",2,"uniequip_002_siege2",3411.1,1,14,0,76065,2161,834,30,1],
|
||||
["p1",4,"op","chess_char_6_01_b",2,"uniequip_002_lemuen",1381.55,1,1,4,511267,991,755,31,4],
|
||||
["p1",5,"op","chess_char_6_02_b",2,"uniequip_002_sbell2",2126.3,1,16,1,128892,1418,956,23,2],
|
||||
["p1",6,"op","chess_char_6_04_b",2,"uniequip_002_skadi2",1757.8,1,3,0,4001,5151,305,0,2],
|
||||
["p1",7,"op","chess_char_6_05_b",2,"uniequip_002_pasngr",1586.2,1,18,0,143584,3329,1053,18,5],
|
||||
["p1",8,"op","chess_char_6_08_b",2,"uniequip_002_reed2",1834.8,1,5,0,74636,868,8481,45,2],
|
||||
["p1",9,"op","chess_char_6_09_b",0,"uniequip_002_cello",1454.2,1,20,4,160370,6470,1752,23,23],
|
||||
["p1",10,"op","chess_char_6_11_b",2,"uniequip_002_mlyss",0,0,9,0,50925,6392,964,63,2],
|
||||
["p1",11,"token","token_10017_skadi2_dedant",null,null,138,0,22,0,0,0,0,0,null],
|
||||
["p1",12,"token","token_10030_mlyss_wtrman",null,null,0,0,10,0,18615,2939,1271,35,1],
|
||||
["p2",13,"op","chess_char_6_15_b",2,"uniequip_002_qiubai",3508.08,1,56,5,369668,10048,0,93,4],
|
||||
["p2",14,"op","chess_char_6_19_b",2,"uniequip_002_blkkgt",2043.7,1,13,1,152329,5717,0,19,3],
|
||||
["p2",15,"op","chess_char_5_01_b",1,"uniequip_002_excu2",2081.5,1,15,0,12767,1079,540,9,1],
|
||||
["p2",16,"op","chess_char_6_17_b",2,"uniequip_002_nearl2",6504.15,1,58,2,186781,6913,0,46,2],
|
||||
["p2",17,"op","chess_char_6_12_b",1,"uniequip_002_rosmon",5344.3,1,2,2,194696,4516,2646,20,2],
|
||||
["p2",18,"op","chess_char_6_13_b",1,"uniequip_002_angel2",3562.16,1,17,2,73558,4871,2224,8,1],
|
||||
["p2",19,"op","chess_char_6_14_b",2,"uniequip_002_lumen",1336.23,1,4,0,0,2258,9039,16,1],
|
||||
["p2",20,"op","chess_char_6_16_b",2,"uniequip_002_halo2",1741.19,1,19,0,22189,174,0,27,2],
|
||||
["p2",21,"op","chess_char_6_18_b",2,"uniequip_002_whitw2",0,0,6,0,88775,1695,0,12,1],
|
||||
["p2",22,"op","chess_char_6_20_b",2,"uniequip_002_agoat2",1828.31,1,21,0,0,2787,27882,28,1],
|
||||
["p1",12,"token","token_10030_mlyss_wtrman",null,null,607.48,1,10,0,35076,1816,755,52,1],
|
||||
["p2",13,"op","chess_char_6_15_b",2,"uniequip_002_qiubai",4181.76,1,55,4,380444,9705,860,99,3],
|
||||
["p2",14,"op","chess_char_6_19_b",2,"uniequip_002_blkkgt",3043.7,1,13,1,126431,2339,0,21,2],
|
||||
["p2",15,"op","chess_char_5_01_b",1,"uniequip_002_excu2",1908.49,1,15,1,32435,2117,1225,22,1],
|
||||
["p2",16,"op","chess_char_6_17_b",2,"uniequip_002_nearl2",6521.3,1,53,1,166515,10334,0,40,2],
|
||||
["p2",17,"op","chess_char_6_12_b",1,"uniequip_002_rosmon",5557.09,1,2,0,150406,888,888,17,1],
|
||||
["p2",18,"op","chess_char_6_13_b",1,"uniequip_002_angel2",3437.9,1,17,0,76446,5687,2518,8,1],
|
||||
["p2",19,"op","chess_char_6_14_b",2,"uniequip_002_lumen",2088.9,1,4,0,0,2258,6052,8,1],
|
||||
["p2",20,"op","chess_char_6_16_b",2,"uniequip_002_halo2",1741.19,1,19,0,17095,1031,0,18,2],
|
||||
["p2",21,"op","chess_char_6_18_b",2,"uniequip_002_whitw2",0,0,6,0,83803,1695,0,12,1],
|
||||
["p2",22,"op","chess_char_6_20_b",2,"uniequip_002_agoat2",1828.31,1,21,0,0,2305,29761,25,1],
|
||||
["p2","~1","token","token_10019_nearl2_sword",null,null,1907.57,0,32,0,9195,6372,0,12,null],
|
||||
["p1","~1","token","token_10030_mlyss_wtrman",null,null,2301.84,0,36,0,4738,0,0,12,null],
|
||||
["p1","~1","token","token_10040_siege2_vlion",null,null,1631.32,0,39,0,2850,3136,0,7,null],
|
||||
["p2","~2","token","token_10019_nearl2_sword",null,null,2413.5,0,45,0,13,3587,0,23,null],
|
||||
["p1","~1","token","token_10030_mlyss_wtrman",null,null,2301.84,0,36,0,5259,0,0,12,null],
|
||||
["p1","~1","token","token_10040_siege2_vlion",null,null,136.97,0,39,0,4275,4590,0,10,null],
|
||||
["p1","~2","token","token_10030_mlyss_wtrman",null,null,0,0,45,0,2575,1668,0,7,null],
|
||||
["p2","~2","token","token_10019_nearl2_sword",null,null,5742.56,0,46,0,0,857,0,0,null],
|
||||
["p1","~1","token","token_10017_skadi2_dedant",null,null,100,0,48,0,0,0,0,0,null],
|
||||
["p1","~2","token","token_10030_mlyss_wtrman",null,null,0,0,52,0,2003,1668,0,8,null]
|
||||
["p1","~3","token","token_10030_mlyss_wtrman",null,null,1668,1,56,0,0,0,0,0,null]
|
||||
],
|
||||
"enemies": {
|
||||
"enemy_1010_demon_2": [2,2,0,1457,128995],
|
||||
"enemy_2009_csaudc": [4,4,0,1050,137594],
|
||||
"enemy_2010_csdcr": [2,2,0,6581,103196],
|
||||
"enemy_2016_csphtm": [2,2,0,70829,1560000],
|
||||
"enemy_2017_csphts": [10,10,0,3407,429982]
|
||||
"enemy_2009_csaudc": [4,4,0,2694,137594],
|
||||
"enemy_2010_csdcr": [2,2,0,5885,103196],
|
||||
"enemy_2016_csphtm": [2,2,0,67665,1560000],
|
||||
"enemy_2017_csphts": [8,8,0,1223,343985]
|
||||
},
|
||||
"hooks": [1,56,3119,685,685,1889,2222,4724,24,38,60,51,43,26422,1127,30,20,0,1,23,0,17,64],
|
||||
"events": {"atk":852,"deploy":36,"die":38,"dmg":2064,"engage":23,"fx":534,"heal":240,"skill":111,"spawn":48,"status":580},
|
||||
"hooks": [1,54,3068,668,668,1754,2039,3872,22,35,59,52,50,26377,1286,31,18,0,1,20,0,17,81],
|
||||
"events": {"atk":821,"deploy":36,"die":35,"dmg":1888,"engage":22,"fx":526,"heal":252,"skill":111,"spawn":47,"status":628},
|
||||
"fx": {
|
||||
"airstrike": 14,
|
||||
"blink": 18,
|
||||
"airstrike": 15,
|
||||
"blink": 15,
|
||||
"bombard": 33,
|
||||
"bombardShell": 33,
|
||||
"burst": 17,
|
||||
"cleanse": 2,
|
||||
"copy": 1,
|
||||
"dodge": 64,
|
||||
"dp": 3,
|
||||
"displace": 4,
|
||||
"dodge": 81,
|
||||
"dp": 2,
|
||||
"drone": 102,
|
||||
"droneLock": 3,
|
||||
"drones": 1,
|
||||
"emergency": 2,
|
||||
"explode": 29,
|
||||
"extraAttack": 3,
|
||||
"finale": 3,
|
||||
"explode": 24,
|
||||
"extraAttack": 7,
|
||||
"finale": 2,
|
||||
"firewall": 1,
|
||||
"garrisonGrant": 1,
|
||||
"lightning": 47,
|
||||
"link": 4,
|
||||
"lightning": 43,
|
||||
"link": 3,
|
||||
"lock": 33,
|
||||
"lure": 2,
|
||||
"palsy": 1,
|
||||
"radiantSword": 2,
|
||||
"scorchBurst": 1,
|
||||
"shield": 13,
|
||||
"slash": 33,
|
||||
"snow": 21,
|
||||
"split": 2,
|
||||
"shield": 8,
|
||||
"slash": 22,
|
||||
"snow": 24,
|
||||
"split": 3,
|
||||
"steal": 1,
|
||||
"storm": 4,
|
||||
"storm": 5,
|
||||
"strike": 3,
|
||||
"summon": 13,
|
||||
"summon": 11,
|
||||
"sunBurst": 4,
|
||||
"talent": 4,
|
||||
"telegraph": 6,
|
||||
"tide": 3,
|
||||
"wanted": 7
|
||||
"telegraph": 2,
|
||||
"tide": 2,
|
||||
"wanted": 11
|
||||
},
|
||||
"snaps": "d3cd88f1 0b40f2d2 1bc3ac15 f797dbfa 67a1d811 c33ee6e2 949c4a4b a8645ba6 e4cccf0c f1968adc 303367ba"
|
||||
"snaps": "d3cd88f1 0b40f2d2 3fada850 9d166f76 eb1c5713 8daf7ec1 58aeec40 fd26682e 39226066 3ce9d7e2 8cf2e7c7"
|
||||
},
|
||||
"boss-boss_5-solo": {
|
||||
"about": "卢西恩,“猩红血钻” solo pool 780000: chess_char_5_08_b chess_char_5_11_b chess_char_5_05_b chess_char_5_06_b chess_char_5_13_b chess_char_5_07_b chess_char_5_02_b chess_char_5_03_b chess_char_5_10_b chess_char_5_12_b",
|
||||
@@ -811,56 +813,56 @@
|
||||
"round": 14,
|
||||
"seed": 120974514,
|
||||
"waveId": "act1autochess_h07_07",
|
||||
"time": 171.13,
|
||||
"ticks": 5134,
|
||||
"time": 173.63,
|
||||
"ticks": 5209,
|
||||
"reason": "cleared",
|
||||
"killed": 16,
|
||||
"total": 16,
|
||||
"unspawned": 0,
|
||||
"bossHpLeft": 0,
|
||||
"rngDraws": 910,
|
||||
"rngDraws": 918,
|
||||
"errors": {"count":0,"kinds":[]},
|
||||
"players": {
|
||||
"p1": {"killed":16,"total":16,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":1769900,"bossDmg":1675565,"heal":25043,"deaths":0,"layerGains":{}},
|
||||
"p2": {"killed":0,"total":0,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":2703080,"bossDmg":2324435,"heal":40114,"deaths":0,"layerGains":{}}
|
||||
"p1": {"killed":16,"total":16,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":1730751,"bossDmg":1610239,"heal":24718,"deaths":0,"layerGains":{}},
|
||||
"p2": {"killed":0,"total":0,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":2742228,"bossDmg":2389761,"heal":40810,"deaths":0,"layerGains":{}}
|
||||
},
|
||||
"units": [
|
||||
["p1",1,"op","chess_char_6_03_b",2,"uniequip_002_yu",4070,1,5,0,0,0,0,0,0],
|
||||
["p1",2,"op","chess_char_6_06_b",2,"uniequip_002_pepe",3232.9,1,7,0,0,0,0,0,0],
|
||||
["p1",3,"op","chess_char_6_07_b",2,"uniequip_002_siege2",3411.1,1,11,0,0,0,0,0,0],
|
||||
["p1",4,"op","chess_char_6_01_b",2,"uniequip_002_lemuen",1617,1,1,2,1131550,0,0,82,8],
|
||||
["p1",4,"op","chess_char_6_01_b",2,"uniequip_002_lemuen",1617,1,1,2,1075028,0,0,78,8],
|
||||
["p1",5,"op","chess_char_6_02_b",2,"uniequip_002_sbell2",2126.3,1,9,0,0,0,0,0,0],
|
||||
["p1",6,"op","chess_char_6_04_b",2,"uniequip_002_skadi2",1669.91,1,17,0,41867,19445,1580,0,5],
|
||||
["p1",6,"op","chess_char_6_04_b",2,"uniequip_002_skadi2",1669.91,1,17,0,43766,18917,1278,0,5],
|
||||
["p1",7,"op","chess_char_6_05_b",2,"uniequip_002_pasngr",1586.2,1,3,0,0,0,0,0,0],
|
||||
["p1",8,"op","chess_char_6_08_b",2,"uniequip_002_reed2",1834.8,1,13,0,161084,0,19850,120,3],
|
||||
["p1",9,"op","chess_char_6_09_b",0,"uniequip_002_cello",1454.2,1,15,1,254912,0,0,44,44],
|
||||
["p1",10,"op","chess_char_6_11_b",2,"uniequip_002_mlyss",2082.3,1,19,0,140358,12849,1604,170,4],
|
||||
["p1",8,"op","chess_char_6_08_b",2,"uniequip_002_reed2",1834.8,1,13,0,165894,0,20171,122,3],
|
||||
["p1",9,"op","chess_char_6_09_b",0,"uniequip_002_cello",1454.2,1,15,1,261942,1172,0,45,45],
|
||||
["p1",10,"op","chess_char_6_11_b",2,"uniequip_002_mlyss",2082.3,1,19,1,142272,12632,1260,172,4],
|
||||
["p1",11,"token","token_10017_skadi2_dedant",null,null,138,0,22,0,0,0,0,0,null],
|
||||
["p1",12,"token","token_10030_mlyss_wtrman",null,null,0,0,20,0,7684,2977,0,17,1],
|
||||
["p2",13,"op","chess_char_6_15_b",2,"uniequip_002_qiubai",4435.2,1,26,2,403485,0,0,134,3],
|
||||
["p1",12,"token","token_10030_mlyss_wtrman",null,null,0,0,20,0,8204,2977,0,18,1],
|
||||
["p2",13,"op","chess_char_6_15_b",2,"uniequip_002_qiubai",4435.2,1,26,3,408287,0,0,133,3],
|
||||
["p2",14,"op","chess_char_6_19_b",2,"uniequip_002_blkkgt",3043.7,1,2,0,0,0,0,0,0],
|
||||
["p2",15,"op","chess_char_5_01_b",1,"uniequip_002_excu2",2545.4,1,6,0,0,0,0,0,0],
|
||||
["p2",16,"op","chess_char_6_17_b",2,"uniequip_002_nearl2",6844.55,1,31,2,137844,15939,0,41,3],
|
||||
["p2",17,"op","chess_char_6_12_b",1,"uniequip_002_rosmon",2480.38,1,16,2,379931,10260,0,64,3],
|
||||
["p2",18,"op","chess_char_6_13_b",1,"uniequip_002_angel2",2653.35,1,21,6,1358147,5265,2873,294,6],
|
||||
["p2",16,"op","chess_char_6_17_b",2,"uniequip_002_nearl2",6916.53,1,31,1,136739,14292,0,40,3],
|
||||
["p2",17,"op","chess_char_6_12_b",1,"uniequip_002_rosmon",2540.38,1,16,4,396919,10380,0,65,3],
|
||||
["p2",18,"op","chess_char_6_13_b",1,"uniequip_002_angel2",2680.15,1,21,4,1362550,5319,2968,297,6],
|
||||
["p2",19,"op","chess_char_6_14_b",2,"uniequip_002_lumen",2088.9,1,8,0,0,0,0,0,0],
|
||||
["p2",20,"op","chess_char_6_16_b",2,"uniequip_002_halo2",1582.9,1,12,1,140177,0,0,100,4],
|
||||
["p2",21,"op","chess_char_6_18_b",2,"uniequip_002_whitw2",1695.1,1,14,1,283496,0,0,77,3],
|
||||
["p2",22,"op","chess_char_6_20_b",2,"uniequip_002_agoat2",1828.31,1,18,0,0,0,37241,59,2],
|
||||
["p1","~1","token","token_10030_mlyss_wtrman",null,null,0,0,34,0,10512,2454,630,12,null],
|
||||
["p1","~2","token","token_10030_mlyss_wtrman",null,null,0,0,41,0,5418,1593,0,9,1],
|
||||
["p2",20,"op","chess_char_6_16_b",2,"uniequip_002_halo2",1582.9,1,12,0,145556,0,0,103,4],
|
||||
["p2",21,"op","chess_char_6_18_b",2,"uniequip_002_whitw2",1695.1,1,14,1,292178,0,0,77,3],
|
||||
["p2",22,"op","chess_char_6_20_b",2,"uniequip_002_agoat2",1828.31,1,18,0,0,1103,37842,58,2],
|
||||
["p1","~1","token","token_10030_mlyss_wtrman",null,null,0,0,34,0,10960,2454,630,13,null],
|
||||
["p1","~2","token","token_10030_mlyss_wtrman",null,null,0,0,41,0,5575,1593,0,9,1],
|
||||
["p1","~1","token","token_10017_skadi2_dedant",null,null,100,0,42,0,0,0,0,0,null],
|
||||
["p1","~3","token","token_10030_mlyss_wtrman",null,null,0,0,46,0,2005,1322,0,6,1],
|
||||
["p1","~3","token","token_10030_mlyss_wtrman",null,null,0,0,46,0,2108,1322,0,6,1],
|
||||
["p1","~2","token","token_10017_skadi2_dedant",null,null,138,0,48,0,0,0,0,0,null],
|
||||
["p1","~4","token","token_10030_mlyss_wtrman",null,null,122.63,1,49,0,10116,2003,0,28,1],
|
||||
["p1","~5","token","token_10030_mlyss_wtrman",null,null,0,0,50,0,4393,3044,1379,12,null],
|
||||
["p1","~4","token","token_10030_mlyss_wtrman",null,null,122.63,1,49,0,10458,2003,0,29,1],
|
||||
["p1","~5","token","token_10030_mlyss_wtrman",null,null,0,0,50,0,4545,2968,1379,12,null],
|
||||
["p1","~3","token","token_10017_skadi2_dedant",null,null,100,1,51,0,0,0,0,0,null]
|
||||
],
|
||||
"enemies": {"enemy_1067_snslime":[8,8,0,8997,111795],"enemy_1081_sotisd":[4,4,0,0,257989],"enemy_1249_lysdb_2":[4,4,0,0,103196],"enemy_9033_acdeer":[1,1,0,43366,4000000]},
|
||||
"hooks": [1,49,5134,1270,1270,3029,3331,3007,22,27,92,86,300,37421,1393,1,17,0,1,21,0,11,0],
|
||||
"events": {"atk":1436,"deploy":32,"die":27,"dmg":3070,"engage":16,"fx":669,"heal":444,"skill":178,"spawn":47,"status":497},
|
||||
"fx": {"airstrike":100,"beam":5,"blink":2,"bombard":69,"bombardShell":69,"burst":11,"column":45,"copy":4,"dp":4,"drone":234,"droneLock":11,"drones":3,"explode":6,"garrisonGrant":1,"link":1,"lock":69,"phase":1,"scorchBurst":2,"shieldBreak":4,"snow":5,"split":2,"steal":6,"sunBurst":4,"talent":4,"tide":5,"wanted":2},
|
||||
"snaps": "e8f77b71 c1cf2cef 36ab0e73 ccc2551f 2b2e3882 dd02a2c0 0af53ff0 4dd61228 eb6652cc d394b2db 63e0edd0 aa384869 69ec5174 2cf805c7 d39727e2 8d04b1b9 23c424be cf78b290"
|
||||
"enemies": {"enemy_1067_snslime":[8,8,0,11760,111795],"enemy_1081_sotisd":[4,4,0,0,257989],"enemy_1249_lysdb_2":[4,4,0,0,103196],"enemy_9033_acdeer":[1,1,0,40235,4000000]},
|
||||
"hooks": [1,49,5209,1281,1281,3051,3357,2765,22,27,93,88,305,37705,1418,3,17,0,1,21,0,10,0],
|
||||
"events": {"atk":1445,"deploy":32,"die":27,"dmg":3088,"engage":16,"fx":675,"heal":436,"skill":181,"spawn":47,"status":511},
|
||||
"fx": {"airstrike":104,"beam":5,"blink":2,"bombard":63,"bombardShell":64,"burst":10,"column":45,"copy":4,"dp":4,"drone":245,"droneLock":15,"drones":3,"explode":6,"garrisonGrant":1,"link":1,"lock":69,"phase":1,"scorchBurst":2,"shieldBreak":4,"snow":5,"split":2,"steal":6,"sunBurst":4,"talent":4,"tide":5,"wanted":1},
|
||||
"snaps": "e8f77b71 c1cf2cef a6688abd b9a6fe99 07c13245 40140044 ddfccb97 273b0a5a 3f6c7803 621a62a4 561c43b2 75e4ac28 41579a2d 3b4b24fa 4aa3b38d e05ffd35 6d9a4f36 45587dcb"
|
||||
},
|
||||
"boss-boss_7-solo": {
|
||||
"about": "“萨米的意志” solo pool 2000000: chess_char_5_08_b chess_char_5_11_b chess_char_5_05_b chess_char_5_06_b chess_char_5_13_b chess_char_5_07_b chess_char_5_02_b chess_char_5_03_b chess_char_5_10_b chess_char_5_12_b",
|
||||
@@ -1074,72 +1076,72 @@
|
||||
"killed": 8,
|
||||
"total": 8,
|
||||
"unspawned": 0,
|
||||
"bossHpLeft": 2705390,
|
||||
"rngDraws": 2092,
|
||||
"bossHpLeft": 2992115,
|
||||
"rngDraws": 1828,
|
||||
"errors": {"count":0,"kinds":[]},
|
||||
"players": {
|
||||
"p1": {"killed":8,"total":8,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":2847989,"bossDmg":1144813,"heal":26393,"deaths":3,"layerGains":{}},
|
||||
"p2": {"killed":0,"total":0,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":4559292,"bossDmg":1700319,"heal":131365,"deaths":10,"layerGains":{}}
|
||||
"p1": {"killed":8,"total":8,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":2840858,"bossDmg":1130306,"heal":26454,"deaths":3,"layerGains":{}},
|
||||
"p2": {"killed":0,"total":0,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":3891982,"bossDmg":1427731,"heal":133966,"deaths":7,"layerGains":{}}
|
||||
},
|
||||
"units": [
|
||||
["p1",1,"op","chess_char_6_03_b",2,"uniequip_002_yu",7326,1,15,0,3237,9562,15614,31,3],
|
||||
["p1",1,"op","chess_char_6_03_b",2,"uniequip_002_yu",7326,1,15,0,3237,9163,15665,31,3],
|
||||
["p1",2,"op","chess_char_6_06_b",2,"uniequip_002_pepe",3232.9,1,11,0,22947,281,0,23,1],
|
||||
["p1",3,"op","chess_char_6_07_b",2,"uniequip_002_siege2",2302.22,1,17,0,49362,1605,0,26,2],
|
||||
["p1",4,"op","chess_char_6_01_b",2,"uniequip_002_lemuen",528.27,1,7,1,2380803,4432,0,60,9],
|
||||
["p1",5,"op","chess_char_6_02_b",2,"uniequip_002_sbell2",871.14,1,9,0,37700,13770,0,46,2],
|
||||
["p1",6,"op","chess_char_6_04_b",2,"uniequip_002_skadi2",719.06,1,54,0,30456,11524,527,0,4],
|
||||
["p1",7,"op","chess_char_6_05_b",2,"uniequip_002_pasngr",2696.54,1,13,1,70578,0,0,0,11],
|
||||
["p1",8,"op","chess_char_6_08_b",2,"uniequip_002_reed2",1834.8,1,19,0,54992,0,9335,73,2],
|
||||
["p1",9,"op","chess_char_6_09_b",0,"uniequip_002_cello",0,0,21,1,160062,13974,0,27,27],
|
||||
["p1",10,"op","chess_char_6_11_b",2,"uniequip_002_mlyss",2082.3,1,59,0,29453,10289,306,74,2],
|
||||
["p1",3,"op","chess_char_6_07_b",2,"uniequip_002_siege2",2291.42,1,17,0,49362,1605,0,26,2],
|
||||
["p1",4,"op","chess_char_6_01_b",2,"uniequip_002_lemuen",913.1,1,7,1,2371886,3937,0,57,9],
|
||||
["p1",5,"op","chess_char_6_02_b",2,"uniequip_002_sbell2",871.14,1,9,0,38138,13770,0,46,2],
|
||||
["p1",6,"op","chess_char_6_04_b",2,"uniequip_002_skadi2",719.06,1,54,0,30456,11499,452,0,4],
|
||||
["p1",7,"op","chess_char_6_05_b",2,"uniequip_002_pasngr",2696.54,1,13,1,71156,0,0,0,11],
|
||||
["p1",8,"op","chess_char_6_08_b",2,"uniequip_002_reed2",1834.8,1,19,0,55032,0,9419,73,2],
|
||||
["p1",9,"op","chess_char_6_09_b",0,"uniequip_002_cello",0,0,21,1,160062,14044,0,27,27],
|
||||
["p1",10,"op","chess_char_6_11_b",2,"uniequip_002_mlyss",2082.3,1,59,0,30214,11261,306,74,2],
|
||||
["p1",11,"token","token_10017_skadi2_dedant",null,null,138,0,28,0,0,0,0,0,null],
|
||||
["p1",12,"token","token_10030_mlyss_wtrman",null,null,0,0,24,0,0,1670,348,4,1],
|
||||
["p2",13,"op","chess_char_6_15_b",2,"uniequip_002_qiubai",0,0,51,4,1181207,26626,0,330,3],
|
||||
["p2",14,"op","chess_char_6_19_b",2,"uniequip_002_blkkgt",0,0,56,0,619703,17069,0,79,5],
|
||||
["p2",15,"op","chess_char_5_01_b",1,"uniequip_002_excu2",0,0,12,0,19399,8026,5640,64,2],
|
||||
["p2",16,"op","chess_char_6_17_b",2,"uniequip_002_nearl2",0,0,64,1,633128,29140,0,72,3],
|
||||
["p2",17,"op","chess_char_6_12_b",1,"uniequip_002_rosmon",0,0,8,0,905789,18226,0,72,3],
|
||||
["p2",18,"op","chess_char_6_13_b",1,"uniequip_002_angel2",2314.38,1,14,0,663280,10817,6140,266,5],
|
||||
["p2",19,"op","chess_char_6_14_b",2,"uniequip_002_lumen",0,0,20,0,0,3342,19329,77,1],
|
||||
["p2",20,"op","chess_char_6_16_b",2,"uniequip_002_halo2",0,0,10,0,102836,1583,0,121,3],
|
||||
["p2",21,"op","chess_char_6_18_b",2,"uniequip_002_whitw2",1844.61,1,27,0,352326,5115,0,25,3],
|
||||
["p2",22,"op","chess_char_6_20_b",2,"uniequip_002_agoat2",1828.31,1,25,0,0,15360,100256,80,2],
|
||||
["p2",13,"op","chess_char_6_15_b",2,"uniequip_002_qiubai",903.1,1,68,4,817795,32754,0,248,4],
|
||||
["p2",14,"op","chess_char_6_19_b",2,"uniequip_002_blkkgt",0,0,64,0,355848,15836,0,48,4],
|
||||
["p2",15,"op","chess_char_5_01_b",1,"uniequip_002_excu2",1415.78,1,66,0,123216,11064,6422,44,2],
|
||||
["p2",16,"op","chess_char_6_17_b",2,"uniequip_002_nearl2",1602.9,1,65,1,499184,28500,0,76,3],
|
||||
["p2",17,"op","chess_char_6_12_b",1,"uniequip_002_rosmon",1,1,8,0,1061491,19179,0,73,3],
|
||||
["p2",18,"op","chess_char_6_13_b",1,"uniequip_002_angel2",2436.5,1,14,0,526339,8805,4488,259,5],
|
||||
["p2",19,"op","chess_char_6_14_b",2,"uniequip_002_lumen",3342.24,1,20,0,0,0,19549,78,1],
|
||||
["p2",20,"op","chess_char_6_16_b",2,"uniequip_002_halo2",1582.9,1,10,0,103786,0,0,127,3],
|
||||
["p2",21,"op","chess_char_6_18_b",2,"uniequip_002_whitw2",1859.61,1,27,0,346326,5155,0,25,3],
|
||||
["p2",22,"op","chess_char_6_20_b",2,"uniequip_002_agoat2",1758.31,1,25,0,0,14000,103507,78,2],
|
||||
["-","~1","device","trap_1105_accrate",null,null,100,1,1,0,0,0,0,0,null],
|
||||
["-","~2","device","trap_1105_accrate",null,null,100,1,2,0,0,0,0,0,null],
|
||||
["-","~3","device","trap_1105_accrate",null,null,100,1,3,0,0,0,0,0,null],
|
||||
["-","~4","device","trap_1105_accrate",null,null,100,1,4,0,0,0,0,0,null],
|
||||
["-","~5","device","trap_1105_accrate",null,null,100,1,5,0,0,0,0,0,null],
|
||||
["-","~6","device","trap_1105_accrate",null,null,100,1,6,0,0,0,0,0,null],
|
||||
["p1","~1","token","token_10030_mlyss_wtrman",null,null,0,0,45,0,2647,1882,0,21,1],
|
||||
["p1","~1","token","token_10030_mlyss_wtrman",null,null,0,0,45,0,2612,1882,0,21,1],
|
||||
["p2","~1","token","token_10019_nearl2_sword",null,null,1739.66,0,47,0,7453,6540,0,21,null],
|
||||
["p1","~2","token","token_10030_mlyss_wtrman",null,null,0,0,48,0,390,3127,0,9,null],
|
||||
["p1","~3","token","token_10030_mlyss_wtrman",null,null,0,0,49,0,613,1322,0,10,null],
|
||||
["p1","~2","token","token_10030_mlyss_wtrman",null,null,0,0,48,0,363,3127,0,9,null],
|
||||
["p1","~3","token","token_10030_mlyss_wtrman",null,null,0,0,49,0,645,1322,0,10,null],
|
||||
["p1","~1","token","token_10040_siege2_vlion",null,null,3730,0,52,0,4750,270,0,10,null],
|
||||
["p1","~1","token","token_10017_skadi2_dedant",null,null,138,0,55,0,0,0,0,0,null],
|
||||
["p2","~2","token","token_10019_nearl2_sword",null,null,0,0,57,0,50543,6744,0,0,null],
|
||||
["p1","~1","token","token_10017_skadi2_dedant",null,null,138,0,56,0,0,0,0,0,null],
|
||||
["p2","~2","token","token_10019_nearl2_sword",null,null,6876.71,0,57,0,25272,1338,0,0,null],
|
||||
["p1","~4","token","token_10030_mlyss_wtrman",null,null,1322,1,60,0,0,310,263,0,1],
|
||||
["p1","~2","token","token_10017_skadi2_dedant",null,null,100,0,61,0,0,0,0,0,null],
|
||||
["p1","~2","token","token_10040_siege2_vlion",null,null,3913.91,0,62,0,0,119,0,0,null],
|
||||
["p2","~3","token","token_10019_nearl2_sword",null,null,205.57,0,63,0,23627,7004,0,0,null],
|
||||
["p1","~3","token","token_10017_skadi2_dedant",null,null,100,1,65,0,0,0,0,0,null]
|
||||
["p1","~2","token","token_10040_siege2_vlion",null,null,3916.52,0,62,0,0,115,0,0,null],
|
||||
["p2","~3","token","token_10019_nearl2_sword",null,null,3182.15,0,63,0,25272,4024,0,0,null],
|
||||
["p1","~3","token","token_10017_skadi2_dedant",null,null,100,1,67,0,0,0,0,0,null]
|
||||
],
|
||||
"enemies": {
|
||||
"enemy_1267_nhpbr_2": [6,6,0,1900,141894],
|
||||
"enemy_9007_acelem": [2,2,0,13271,309587],
|
||||
"enemy_9017_achunt_2": [2,0,0,58763,2894610],
|
||||
"enemy_9018_actrpa": [1,0,0,61321,1190947],
|
||||
"enemy_9019_actrpb": [1,0,0,28448,1109516],
|
||||
"enemy_9020_actrpc": [1,0,0,21621,1863552]
|
||||
"enemy_9007_acelem": [2,2,0,14390,309587],
|
||||
"enemy_9017_achunt_2": [2,0,0,57663,2607885],
|
||||
"enemy_9018_actrpa": [1,0,0,53759,1092472],
|
||||
"enemy_9019_actrpb": [1,0,0,24773,1015854],
|
||||
"enemy_9020_actrpc": [1,0,0,22601,1668902]
|
||||
},
|
||||
"hooks": [1,63,6000,1773,1773,9719,19351,3054,26,38,96,92,314,48736,3494,21,13,0,1,33,0,18,0],
|
||||
"events": {"atk":2403,"deploy":44,"die":38,"dmg":18745,"engage":22,"fx":2239,"heal":960,"skill":188,"spawn":53,"status":946},
|
||||
"hooks": [1,66,6000,1649,1649,8552,17196,2968,22,35,96,88,305,47649,3685,17,13,0,1,31,0,19,0],
|
||||
"events": {"atk":2230,"deploy":47,"die":35,"dmg":16662,"engage":22,"fx":2182,"heal":905,"skill":184,"spawn":53,"status":898},
|
||||
"fx": {
|
||||
"airstrike": 113,
|
||||
"airstrike": 93,
|
||||
"beam": 26,
|
||||
"blink": 5,
|
||||
"bombard": 78,
|
||||
"bombardShell": 78,
|
||||
"burst": 18,
|
||||
"bombard": 69,
|
||||
"bombardShell": 70,
|
||||
"burst": 19,
|
||||
"camouflage": 1,
|
||||
"copy": 3,
|
||||
"dash": 24,
|
||||
@@ -1148,8 +1150,8 @@
|
||||
"drone": 334,
|
||||
"droneLock": 11,
|
||||
"drones": 3,
|
||||
"extraAttack": 23,
|
||||
"finale": 5,
|
||||
"extraAttack": 15,
|
||||
"finale": 4,
|
||||
"firewall": 3,
|
||||
"garrisonGrant": 1,
|
||||
"lightning": 118,
|
||||
@@ -1160,20 +1162,20 @@
|
||||
"radiantSword": 3,
|
||||
"revive": 3,
|
||||
"shieldBreak": 9,
|
||||
"slash": 55,
|
||||
"slash": 44,
|
||||
"snow": 26,
|
||||
"split": 2,
|
||||
"steal": 5,
|
||||
"storm": 11,
|
||||
"summon": 5,
|
||||
"sunBurst": 4,
|
||||
"sunBurst": 3,
|
||||
"talent": 4,
|
||||
"tide": 4,
|
||||
"undying": 1,
|
||||
"wanted": 6,
|
||||
"zone": 6
|
||||
},
|
||||
"snaps": "8b102142 7109c20e 6cb62905 cd421024 07699f86 52b1f74f 9eb6286f 13679ac3 4a76f2fe f3f7a2a9 bfaa974c 4043041c 78cb5e5f e4afd571 ee6c6d9a fd48d22e 44c582cd 445a604a 58683b3b b2f7ef86"
|
||||
"snaps": "8b102142 7109c20e 6cb62905 16f4d84e db38e8d1 fb736fc0 d644e65e bf490768 454f169f 723043e7 883b6557 609aa7bb bfa05477 7d38be83 76b95dc1 8ac15f69 7c25d190 03595be2 40606ec4 e87eb589"
|
||||
},
|
||||
"hidden-boss_9-solo": {
|
||||
"about": "假想敌:铳 solo pool 2800000: chess_char_5_08_b chess_char_5_11_b chess_char_5_05_b chess_char_5_06_b chess_char_5_13_b chess_char_5_07_b chess_char_5_02_b chess_char_5_03_b chess_char_5_10_b chess_char_5_12_b",
|
||||
@@ -1332,36 +1334,36 @@
|
||||
"time": 200,
|
||||
"ticks": 6000,
|
||||
"reason": "forced",
|
||||
"killed": 12,
|
||||
"killed": 11,
|
||||
"total": 5,
|
||||
"unspawned": 0,
|
||||
"bossHpLeft": 7226192,
|
||||
"rngDraws": 103,
|
||||
"bossHpLeft": 7221428,
|
||||
"rngDraws": 99,
|
||||
"errors": {"count":0,"kinds":[]},
|
||||
"players": {"p1":{"killed":12,"total":5,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":591594,"bossDmg":373808,"heal":141080,"deaths":9,"layerGains":{}}},
|
||||
"players": {"p1":{"killed":11,"total":5,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":572445,"bossDmg":378572,"heal":140471,"deaths":8,"layerGains":{}}},
|
||||
"units": [
|
||||
["p1",1,"op","chess_char_4_17_b",1,"uniequip_002_hsguma",3864.3,1,29,2,51,3864,0,14,0],
|
||||
["p1",2,"op","chess_char_4_18_b",1,"uniequip_002_mudrok",5783.26,1,25,0,17,18719,11206,15,2],
|
||||
["p1",3,"op","chess_char_4_23_b",2,"uniequip_002_gvial2",2579.06,1,26,2,230272,25481,4759,191,4],
|
||||
["p1",4,"op","chess_char_4_19_b",2,"uniequip_002_flamtl",3960.66,1,33,0,16,16650,0,15,0],
|
||||
["p1",5,"op","chess_char_4_22_b",2,"uniequip_002_svrash",2563.92,1,22,5,198993,51878,7500,123,2],
|
||||
["p1",6,"op","chess_char_4_20_b",2,"uniequip_002_fartth",1560.9,1,30,0,115397,1561,0,56,6],
|
||||
["p1",7,"op","chess_char_4_21_b",1,"uniequip_002_plosis",1428.79,1,23,0,0,15153,80115,84,2],
|
||||
["p1",8,"op","chess_char_4_24_b",1,"uniequip_002_billro",2395.8,1,34,3,46848,3732,0,52,3],
|
||||
["p1",9,"op","chess_char_4_25_b",2,"uniequip_002_cetsyr",1404.57,1,24,0,0,31236,5310,0,2],
|
||||
["p1",10,"op","chess_char_4_26_b",0,"uniequip_002_bldsk",1674.2,1,3,0,0,3378,32189,47,8]
|
||||
["p1",1,"op","chess_char_4_17_b",1,"uniequip_002_hsguma",3864.3,1,29,3,56,3864,0,14,0],
|
||||
["p1",2,"op","chess_char_4_18_b",1,"uniequip_002_mudrok",4803.42,1,25,0,16,18391,9981,15,2],
|
||||
["p1",3,"op","chess_char_4_23_b",2,"uniequip_002_gvial2",3075.04,1,26,2,206116,24874,5090,191,4],
|
||||
["p1",4,"op","chess_char_4_19_b",2,"uniequip_002_flamtl",3960.66,1,5,0,15,16060,0,14,0],
|
||||
["p1",5,"op","chess_char_4_22_b",2,"uniequip_002_svrash",0,0,22,2,199997,48631,7490,117,2],
|
||||
["p1",6,"op","chess_char_4_20_b",2,"uniequip_002_fartth",1560.9,1,30,0,119396,1561,0,57,6],
|
||||
["p1",7,"op","chess_char_4_21_b",1,"uniequip_002_plosis",1052.5,1,23,0,0,16534,81043,85,2],
|
||||
["p1",8,"op","chess_char_4_24_b",1,"uniequip_002_billro",2395.8,1,2,4,46849,3478,0,54,4],
|
||||
["p1",9,"op","chess_char_4_25_b",2,"uniequip_002_cetsyr",1076.51,1,24,0,0,32684,5419,0,2],
|
||||
["p1",10,"op","chess_char_4_26_b",0,"uniequip_002_bldsk",1674.2,1,3,0,0,2208,31448,46,8]
|
||||
],
|
||||
"enemies": {
|
||||
"enemy_1005_yokai_3": [1,1,0,17270,107495],
|
||||
"enemy_9010_acpupp": [2,1,0,19915,109930],
|
||||
"enemy_9021_acduml_2": [1,0,0,4507,373808],
|
||||
"enemy_9022_acdumm": [1,0,0,3098,0],
|
||||
"enemy_9023_acdums": [10,10,0,124838,360]
|
||||
"enemy_1005_yokai_3": [1,1,0,16542,107495],
|
||||
"enemy_9010_acpupp": [2,2,0,18441,86026],
|
||||
"enemy_9021_acduml_2": [1,0,0,4507,378572],
|
||||
"enemy_9022_acdumm": [1,0,0,3215,0],
|
||||
"enemy_9023_acdums": [10,8,0,123292,352]
|
||||
},
|
||||
"hooks": [1,34,6000,686,686,2437,2957,6444,21,21,29,28,0,9973,109,18,15,0,1,51,0,24,2],
|
||||
"events": {"atk":1008,"deploy":19,"die":21,"dmg":2942,"engage":7,"fx":874,"heal":228,"skill":60,"spawn":25,"status":330},
|
||||
"fx": {"appear":3,"bandage":8,"beam":21,"block":15,"burst":24,"disappear":3,"dodge":2,"ember":2,"explode":510,"featherArrow":6,"garrisonGrant":1,"healField":2,"jungleSoul":4,"mote":53,"phase":56,"redistribute":30,"revive":1,"reweave":2,"riposte":1,"rockslide":2,"sandChains":1,"sandChainsCharged":2,"shieldBreak":22,"slash":40,"spGift":7,"summon":8,"telegraph":2,"thorns":43,"truesilver":2,"undying":1},
|
||||
"snaps": "66102eb2 711577a7 5b34ba48 b88d4bb5 a7c05e6b bfd5e766 4b333a83 ed69496e 06b28ff3 a3aff9fb 53f4bd37 c96d8bc9 870f8b31 27aa3bc7 c1567cdf b461354b 0d40f693 b5835929 ced76f1b fe45cd95"
|
||||
"hooks": [1,32,6000,682,682,2353,2829,6095,19,19,30,29,0,10668,109,17,15,0,1,40,0,22,3],
|
||||
"events": {"atk":996,"deploy":17,"die":19,"dmg":2814,"engage":7,"fx":832,"heal":226,"skill":62,"spawn":25,"status":320},
|
||||
"fx": {"appear":1,"bandage":8,"beam":21,"block":11,"burst":22,"disappear":1,"dodge":3,"ember":2,"explode":484,"featherArrow":6,"garrisonGrant":1,"healField":2,"jungleSoul":4,"mote":52,"phase":54,"redistribute":30,"reweave":2,"riposte":1,"rockslide":2,"sandChains":2,"sandChainsCharged":2,"shieldBreak":22,"slash":40,"spGift":4,"summon":8,"telegraph":2,"thorns":42,"truesilver":2,"undying":1},
|
||||
"snaps": "66102eb2 711577a7 5b34ba48 b88d4bb5 a7c05e6b bfd5e766 4b333a83 ed69496e 09c18528 619b7ea1 9d890c8e 85de072b 0f2ae8c9 af506320 0f8016e6 188a65ff fa7de72b e09b1905 868c7205 6205ddcc"
|
||||
},
|
||||
"unite-1": {
|
||||
"about": "联防 1 helper(s), 11 leaked: chess_char_6_03_b chess_char_6_06_b chess_char_6_07_b chess_char_6_01_b chess_char_6_02_b chess_char_6_04_b chess_char_6_05_b chess_char_6_08_b token_10017_skadi2_dedant",
|
||||
|
||||
+29
-29
@@ -1322,7 +1322,7 @@
|
||||
"combat": "client",
|
||||
"boost": {"lp":400,"layers":300},
|
||||
"setup": {"stageId":"act1autochess_m02","bossId":"boss_5","hiddenBossId":"boss_10","factions":["TIMES","ELEMENT","DOT"]},
|
||||
"end": {"victory":true,"roundsPassed":15,"hiddenReached":true,"hiddenCleared":true,"reason":"victory","teamLp":397,"bossPoolLeft":0,"virtualMs":113700},
|
||||
"end": {"victory":true,"roundsPassed":15,"hiddenReached":true,"hiddenCleared":true,"reason":"victory","teamLp":399,"bossPoolLeft":0,"virtualMs":113700},
|
||||
"rng": {"setup":25,"shop":346,"waves":0,"draft":42,"bots":10350,"meta":3},
|
||||
"errors": {"engine":0,"logged":0,"sim":0,"dispatcher":0},
|
||||
"rounds": [
|
||||
@@ -1385,12 +1385,12 @@
|
||||
"victory": true,
|
||||
"roundsPassed": 15,
|
||||
"eliminatedRound": null,
|
||||
"lp": 397,
|
||||
"lp": 399,
|
||||
"bandId": "band_paganini",
|
||||
"title": {"id":"comment_1","name":"卫戍之星","picId":"comment_icon_1","text":"对敌方领袖造成伤害最高(仅胜利时)"},
|
||||
"trophies": 0,
|
||||
"reward": 425,
|
||||
"stats": {"activatedLayers":2279,"bossDamage":4580000,"dmgDealt":6596436,"gold":175,"itemsEquipped":25,"kills":376,"leaks":1,"lpLost":1,"merges":5,"perfectRounds":12,"refreshes":34},
|
||||
"stats": {"activatedLayers":2279,"bossDamage":4580000,"dmgDealt":6596430,"gold":175,"itemsEquipped":25,"kills":375,"leaks":1,"lpLost":1,"merges":5,"perfectRounds":12,"refreshes":34},
|
||||
"lineup": ["4_02_b@12,7[3_08_e_a+1_02_e_b]","6_01_a@12,8[3_03_e_a+3_02_e_a]","6_16_a@11,5","3_18_a@11,8[4_11_e_a+4_05_e_b]","4_10_a@10,4[1_05_e_b]","6_05_a@10,5","4_01_b@10,6[4_06_e_a+4_05_e_a]","1_08_b@9,9[4_10_e_a]"],
|
||||
"bonds": ["arcaneShip 2/389","deputShip 0/300","egirShip 0/300","emptyShip 0/300","indomShip 0/308","investShip 0/300","kazimierzShip 0/308","kjeragShip 0/300","lateranoShip 1/320","maniShip 0/300","miraShip 0/300","preciShip 1/302","raidShip 0/300","sargonShip 0/300","siracusaShip 1/345","skillfulShip 0/335","soloShip 1/307","steadShip 0/300","suntShip 1/300","swiftShip 1/316","victoriaShip 0/300","visiShip 0/300","yanShip 0/300"]
|
||||
}
|
||||
@@ -1517,8 +1517,8 @@
|
||||
"combat": "client",
|
||||
"boost": null,
|
||||
"setup": {"stageId":"act1autochess_m01","bossId":"boss_5","hiddenBossId":"boss_10","factions":["DOT","INVISIBLE","REFLECTION"]},
|
||||
"end": {"victory":true,"roundsPassed":14,"hiddenReached":false,"hiddenCleared":false,"reason":"victory","teamLp":40,"bossPoolLeft":0,"virtualMs":116900},
|
||||
"rng": {"setup":25,"shop":489,"waves":0,"draft":34,"bots":18039,"meta":11},
|
||||
"end": {"victory":true,"roundsPassed":14,"hiddenReached":false,"hiddenCleared":false,"reason":"victory","teamLp":30,"bossPoolLeft":0,"virtualMs":119900},
|
||||
"rng": {"setup":25,"shop":428,"waves":0,"draft":34,"bots":18039,"meta":10},
|
||||
"errors": {"engine":0,"logged":0,"sim":0,"dispatcher":0},
|
||||
"rounds": [
|
||||
{
|
||||
@@ -1563,28 +1563,28 @@
|
||||
},
|
||||
{
|
||||
"round": 9,
|
||||
"h_0": [1,28,14,0,0,4,8,"6e857f7b",102,"arcaneShip 1/11, kazimierzShip 1/20, preciShip 0/8, raidShip 1/60, skillfulShip 0/3, soloShip 1/0, suntShip 1/0, swiftShip 1/11, yanShip 0/1",23,23,0,78],
|
||||
"ai_0": [1,23,12,0,2,4,8,"8aab1ba8",138,"arcaneShip 0/2, deputShip 1/16, kazimierzShip 0/8, lateranoShip 1/35, preciShip 1/17, sargonShip 0/2, skillfulShip 1/9, soloShip 0/5, suntShip 1/14, swiftShip 1/47",23,23,0,73]
|
||||
"h_0": [1,28,14,0,0,4,8,"256af2e6",37,"arcaneShip 1/11, kazimierzShip 1/12, miraShip 0/3, preciShip 0/8, raidShip 0/48, skillfulShip 1/3, soloShip 1/0, suntShip 1/0, swiftShip 1/11, yanShip 0/7",22,23,1,78],
|
||||
"ai_0": [1,23,12,0,0,4,8,"10cd80b7",133,"arcaneShip 0/2, deputShip 0/14, kazimierzShip 0/8, lateranoShip 1/36, preciShip 1/23, sargonShip 0/2, skillfulShip 1/7, soloShip 1/5, suntShip 1/12, swiftShip 1/50",23,23,0,72]
|
||||
},
|
||||
{
|
||||
"round": 10,
|
||||
"h_0": [1,28,12,0,0,5,8,"00fe855c",129,"arcaneShip 1/14, kazimierzShip 1/32, preciShip 0/8, raidShip 1/72, skillfulShip 0/3, soloShip 1/0, suntShip 1/0, swiftShip 1/11, yanShip 0/1",31,31,0,88],
|
||||
"ai_0": [1,23,14,0,0,5,8,"b70033a0",160,"arcaneShip 0/2, deputShip 0/16, kazimierzShip 0/8, lateranoShip 1/38, preciShip 2/27, sargonShip 0/2, skillfulShip 1/14, soloShip 1/5, suntShip 1/14, swiftShip 1/62",31,31,0,88]
|
||||
"h_0": [1,28,12,0,0,5,8,"904868f5",47,"arcaneShip 1/14, kazimierzShip 1/16, miraShip 0/3, preciShip 0/8, raidShip 0/48, skillfulShip 1/6, soloShip 1/0, suntShip 1/0, swiftShip 1/11, yanShip 0/7",31,31,0,88],
|
||||
"ai_0": [1,23,12,0,0,5,8,"fc0a1877",163,"arcaneShip 0/2, deputShip 0/14, kazimierzShip 0/8, lateranoShip 1/39, preciShip 1/33, sargonShip 0/2, skillfulShip 1/12, soloShip 1/5, suntShip 1/12, swiftShip 1/62",31,31,0,87]
|
||||
},
|
||||
{
|
||||
"round": 11,
|
||||
"h_0": [1,28,12,0,0,5,8,"b2b9f9bd",153,"arcaneShip 1/14, kazimierzShip 1/40, miraShip 1/4, preciShip 0/8, raidShip 1/80, skillfulShip 0/3, soloShip 1/0, suntShip 1/0, swiftShip 1/15, yanShip 0/1",25,25,0,100],
|
||||
"ai_0": [1,23,12,0,0,5,8,"6ec28fe0",193,"arcaneShip 0/2, deputShip 0/16, kazimierzShip 0/8, lateranoShip 1/41, preciShip 2/40, sargonShip 0/2, skillfulShip 1/19, soloShip 1/5, suntShip 1/14, swiftShip 1/74",25,25,0,101]
|
||||
"h_0": [1,27,12,0,0,5,8,"0d65116f",125,"arcaneShip 1/17, kazimierzShip 1/31, miraShip 1/3, preciShip 0/8, raidShip 1/60, skillfulShip 0/9, soloShip 1/3, suntShip 1/0, swiftShip 1/11, yanShip 0/7",22,25,3,99],
|
||||
"ai_0": [1,23,12,0,0,5,8,"6cafa713",213,"arcaneShip 0/2, deputShip 0/14, kazimierzShip 0/8, lateranoShip 1/49, preciShip 2/53, sargonShip 0/2, skillfulShip 1/17, soloShip 1/5, suntShip 1/12, swiftShip 1/77",25,25,0,100]
|
||||
},
|
||||
{
|
||||
"round": 12,
|
||||
"h_0": [1,28,12,0,0,6,8,"c8e0476d",188,"arcaneShip 1/17, kazimierzShip 1/52, miraShip 1/8, preciShip 0/8, raidShip 1/92, skillfulShip 0/3, soloShip 1/0, suntShip 1/0, swiftShip 1/19, yanShip 0/1",25,31,6,112],
|
||||
"ai_0": [1,23,12,0,0,6,8,"54a25cd7",229,"arcaneShip 0/2, deputShip 0/16, kazimierzShip 0/8, lateranoShip 1/47, preciShip 2/50, sargonShip 0/2, skillfulShip 1/24, soloShip 1/5, suntShip 1/14, swiftShip 1/89",31,31,0,112]
|
||||
"h_0": [1,27,12,0,0,6,8,"42ad03f7",80,"arcaneShip 1/20, emptyShip 1/0, kazimierzShip 1/35, miraShip 1/3, preciShip 1/8, raidShip 0/60, skillfulShip 0/9, soloShip 1/3, suntShip 1/0, swiftShip 1/11, yanShip 0/7",22,31,9,111],
|
||||
"ai_0": [1,23,12,0,0,6,8,"189b8c70",273,"arcaneShip 0/2, deputShip 0/14, kazimierzShip 0/8, lateranoShip 1/67, preciShip 2/75, sargonShip 0/2, skillfulShip 1/25, soloShip 1/5, suntShip 1/12, swiftShip 1/89",31,31,0,111]
|
||||
},
|
||||
{
|
||||
"round": 13,
|
||||
"h_0": [1,18,12,0,0,6,8,"205ea1e7",227,"arcaneShip 1/20, kazimierzShip 1/64, miraShip 1/12, preciShip 0/8, raidShip 1/108, skillfulShip 0/3, soloShip 1/0, suntShip 1/0, swiftShip 1/23, yanShip 0/1",23,57,34,124],
|
||||
"ai_0": [1,22,12,0,0,6,8,"dc15adde",271,"arcaneShip 0/2, deputShip 0/16, kazimierzShip 0/8, lateranoShip 1/53, preciShip 2/63, sargonShip 0/2, skillfulShip 1/29, soloShip 1/5, suntShip 1/14, swiftShip 1/107, visiShip 1/0",56,57,1,126]
|
||||
"h_0": [1,17,12,0,0,6,8,"d8bc61ba",191,"arcaneShip 1/20, emptyShip 1/0, kazimierzShip 1/62, miraShip 0/3, preciShip 1/8, raidShip 1/87, skillfulShip 0/9, soloShip 1/3, suntShip 1/0, swiftShip 1/11, yanShip 0/7",28,57,29,123],
|
||||
"ai_0": [1,13,12,0,0,6,8,"09231657",325,"arcaneShip 0/2, deputShip 0/14, kazimierzShip 0/8, lateranoShip 1/81, preciShip 2/93, sargonShip 0/2, skillfulShip 1/33, soloShip 1/5, suntShip 2/12, swiftShip 1/101",27,57,30,122]
|
||||
}
|
||||
],
|
||||
"players": {
|
||||
@@ -1593,28 +1593,28 @@
|
||||
"victory": true,
|
||||
"roundsPassed": 14,
|
||||
"eliminatedRound": null,
|
||||
"lp": 18,
|
||||
"lp": 17,
|
||||
"bandId": "band_amedic",
|
||||
"title": {"id":"comment_4","name":"精英云集","picId":"comment_icon_4","text":"晋升精锐次数最多"},
|
||||
"title": {"id":"comment_5","name":"万事俱备","picId":"comment_icon_5","text":"装备配发数量最多"},
|
||||
"trophies": 4,
|
||||
"reward": 400,
|
||||
"stats": {"activatedLayers":220,"bossDamage":344186,"dmgDealt":3000543,"gold":134,"itemsEquipped":22,"kills":272,"leaks":40,"lpLost":10,"merges":3,"perfectRounds":11,"refreshes":21},
|
||||
"lineup": ["6_18_a@12,3[4_11_e_a]","4_10_a@12,4","1_17_b@11,3[4_03_e_a+1_01_e_a]","5_20_a@11,8[6_05_e_a+3_06_e_a]","3_17_b@10,3","3_12_a@10,4[1_02_e_a+3_11_e_a]","5_07_a@10,7[4_02_e_b+2_01_e_b]","1_19_b@10,8[2_01_e_b+4_04_e_b]"],
|
||||
"bonds": ["arcaneShip 2/24","kazimierzShip 1/64","miraShip 1/12","preciShip 0/8","raidShip 1/108","siracusaShip 1/12","skillfulShip 0/3","soloShip 1/0","suntShip 1/0","swiftShip 0/23","yanShip 0/1"]
|
||||
"stats": {"activatedLayers":194,"bossDamage":206158,"dmgDealt":2761977,"gold":135,"itemsEquipped":20,"kills":267,"leaks":42,"lpLost":11,"merges":4,"perfectRounds":9,"refreshes":19},
|
||||
"lineup": ["3_10_a@12,3","6_12_a@11,3[1_02_e_a+2_01_e_a]","3_09_a@11,8[1_01_e_a+6_05_e_a]","3_17_a@10,3[1_05_e_a+3_08_e_a]","3_12_b@10,4[3_04_e_a+4_04_e_a]","5_07_a@10,7","1_19_b@10,8[4_05_e_a+2_01_e_b]","1_17_b@9,8[4_03_e_a+4_02_e_b]"],
|
||||
"bonds": ["arcaneShip 2/20","emptyShip 1/0","kazimierzShip 1/62","miraShip 1/3","preciShip 1/8","raidShip 1/87","skillfulShip 0/9","soloShip 1/3","suntShip 1/0","swiftShip 1/11","yanShip 0/7"]
|
||||
},
|
||||
"ai_0": {
|
||||
"alive": true,
|
||||
"victory": true,
|
||||
"roundsPassed": 14,
|
||||
"eliminatedRound": null,
|
||||
"lp": 22,
|
||||
"lp": 13,
|
||||
"bandId": "band_chiave",
|
||||
"title": {"id":"comment_1","name":"卫戍之星","picId":"comment_icon_1","text":"对敌方领袖造成伤害最高(仅胜利时)"},
|
||||
"trophies": 4,
|
||||
"reward": 400,
|
||||
"stats": {"activatedLayers":302,"bossDamage":435814,"dmgDealt":3507011,"gold":140,"itemsEquipped":17,"kills":299,"leaks":1,"lpLost":1,"merges":3,"perfectRounds":12,"refreshes":22},
|
||||
"lineup": ["3_21_a@12,3[4_08_e_a+6_02_e_a]","4_25_a@12,4","2_01_a@12,5[3_01_e_a]","6_12_a@11,3[1_05_e_a+3_11_e_a]","4_14_b@11,4[1_05_e_b+4_07_e_a]","1_01_b@10,3[3_08_e_a]","5_01_a@10,4[4_05_e_a]","2_13_a@9,4[4_02_e_b]"],
|
||||
"bonds": ["arcaneShip 0/2","deputShip 0/16","emptyShip 1/0","kazimierzShip 0/8","lateranoShip 1/56","preciShip 2/76","sargonShip 0/2","skillfulShip 1/34","soloShip 0/5","suntShip 1/14","swiftShip 1/119","visiShip 1/3"]
|
||||
"stats": {"activatedLayers":383,"bossDamage":573842,"dmgDealt":3560504,"gold":134,"itemsEquipped":20,"kills":276,"leaks":30,"lpLost":10,"merges":5,"perfectRounds":12,"refreshes":14},
|
||||
"lineup": ["2_01_a@12,3[3_01_e_a+3_03_e_a]","4_14_b@12,7[4_08_e_a+3_04_e_b]","1_01_b@11,3[2_01_e_a]","4_09_a@11,8","3_21_b@10,3[6_02_e_a+5_01_e_a]","2_13_b@10,7[4_04_e_a+4_02_e_a]","6_17_a@10,8","6_01_a@9,8[3_07_e_a]"],
|
||||
"bonds": ["arcaneShip 0/2","deputShip 0/14","kazimierzShip 0/8","lateranoShip 1/93","miraShip 1/0","preciShip 2/109","sargonShip 0/2","siracusaShip 0/6","skillfulShip 1/43","soloShip 1/5","suntShip 1/12","swiftShip 1/121"]
|
||||
}
|
||||
},
|
||||
"standIns": {
|
||||
@@ -1628,11 +1628,11 @@
|
||||
[6,"3_12_a→char_608_acpion"],
|
||||
[7,"3_12_a→char_608_acpion"],
|
||||
[8,"3_12_a→char_608_acpion"],
|
||||
[9,"3_12_a→char_608_acpion 5_20_a→char_614_acsupo"],
|
||||
[10,"3_12_a→char_608_acpion 5_20_a→char_614_acsupo"],
|
||||
[11,"3_12_a→char_608_acpion 5_20_a→char_614_acsupo"],
|
||||
[12,"3_12_a→char_608_acpion 5_20_a→char_614_acsupo"],
|
||||
[13,"3_12_a→char_608_acpion 5_20_a→char_614_acsupo"]
|
||||
[9,"3_12_a→char_608_acpion"],
|
||||
[10,"3_12_a→char_608_acpion"],
|
||||
[11,"3_12_a→char_608_acpion 4_16_a→char_615_acspec"],
|
||||
[12,"3_12_a→char_608_acpion"],
|
||||
[13,"3_12_b→char_608_acpion 4_16_a→char_615_acspec"]
|
||||
]
|
||||
}
|
||||
},
|
||||
|
||||
+54
-54
@@ -1010,71 +1010,71 @@
|
||||
"round": 12,
|
||||
"seed": 3545615126,
|
||||
"waveId": "act1autochess_h05",
|
||||
"time": 226.4,
|
||||
"ticks": 6792,
|
||||
"reason": "cleared",
|
||||
"killed": 104,
|
||||
"time": 230,
|
||||
"ticks": 6900,
|
||||
"reason": "timeout",
|
||||
"killed": 105,
|
||||
"total": 126,
|
||||
"unspawned": 0,
|
||||
"bossHpLeft": null,
|
||||
"rngDraws": 605,
|
||||
"rngDraws": 633,
|
||||
"errors": {"count":0,"kinds":[]},
|
||||
"players": {
|
||||
"p1": {
|
||||
"killed": 104,
|
||||
"killed": 105,
|
||||
"total": 126,
|
||||
"perfect": false,
|
||||
"leaks": 23,
|
||||
"leaks": 22,
|
||||
"leaksUncounted": 0,
|
||||
"coins": 10,
|
||||
"dmg": 4885870,
|
||||
"dmg": 5000855,
|
||||
"bossDmg": 0,
|
||||
"heal": 80735,
|
||||
"deaths": 7,
|
||||
"heal": 92236,
|
||||
"deaths": 4,
|
||||
"layerGains": {"preciShip":4}
|
||||
}
|
||||
},
|
||||
"units": [
|
||||
["p1",1,"op","chess_char_5_19_b",2,"uniequip_002_mlynar",4297.7,1,130,8,555859,4578,280,79,4],
|
||||
["p1",2,"op","chess_char_6_03_a",2,null,2973.3,1,122,1,90690,18321,9211,67,2],
|
||||
["p1",3,"op","chess_char_6_06_a",2,null,2445.3,1,135,8,553051,18507,837,91,2],
|
||||
["p1",4,"op","chess_char_5_17_b",1,"uniequip_002_f12yin",3011.8,1,8,8,248468,28911,11557,331,1],
|
||||
["p1",5,"op","chess_char_5_20_b",2,"uniequip_002_aglina",1520.2,1,10,2,160362,0,0,48,5],
|
||||
["p1",6,"op","chess_char_5_21_b",1,"uniequip_002_sntlla",1791.9,1,85,1,369795,3362,889,125,3],
|
||||
["p1",7,"op","chess_char_5_22_b",1,"uniequip_002_nymph",1772.1,1,128,1,4914,2027,255,5,3],
|
||||
["p1",8,"op","chess_char_5_23_b",1,"uniequip_002_reckpr",1771,1,127,0,0,4860,52445,66,4],
|
||||
["p1",9,"op","chess_char_6_01_a",2,null,1273.8,1,5,43,1148844,1462,452,98,5],
|
||||
["p1",10,"op","chess_char_6_02_a",2,null,1855.7,1,7,27,1342901,1863,429,73,3],
|
||||
["p1",11,"op","chess_char_6_04_a",2,null,1322.2,1,9,0,96272,9110,2272,0,4],
|
||||
["p1",12,"op","chess_char_6_05_a",2,null,1336.5,1,59,3,314713,3845,2107,69,5],
|
||||
["p1",1,"op","chess_char_5_19_b",2,"uniequip_002_mlynar",4297.7,1,2,18,602485,3572,935,94,5],
|
||||
["p1",2,"op","chess_char_6_03_a",2,null,4459.95,1,4,1,125610,20086,15136,82,3],
|
||||
["p1",3,"op","chess_char_6_06_a",2,null,2445.3,1,139,7,554612,19329,583,71,2],
|
||||
["p1",4,"op","chess_char_5_17_b",1,"uniequip_002_f12yin",3011.8,1,8,9,204346,33430,14620,337,1],
|
||||
["p1",5,"op","chess_char_5_20_b",2,"uniequip_002_aglina",1520.2,1,10,2,144300,0,0,44,5],
|
||||
["p1",6,"op","chess_char_5_21_b",1,"uniequip_002_sntlla",1791.9,1,85,1,319504,3362,889,122,3],
|
||||
["p1",7,"op","chess_char_5_22_b",1,"uniequip_002_nymph",1772.1,1,1,1,13078,1242,569,7,3],
|
||||
["p1",8,"op","chess_char_5_23_b",1,"uniequip_002_reckpr",1771,1,3,0,0,1352,54601,78,4],
|
||||
["p1",9,"op","chess_char_6_01_a",2,null,1273.8,1,5,38,1233629,1462,645,97,5],
|
||||
["p1",10,"op","chess_char_6_02_a",2,null,1855.7,1,7,23,1379878,2121,288,76,3],
|
||||
["p1",11,"op","chess_char_6_04_a",2,null,1322.2,1,9,3,102446,10462,1863,0,4],
|
||||
["p1",12,"op","chess_char_6_05_a",2,null,1336.5,1,59,2,320966,3845,2107,69,5],
|
||||
["p1",13,"token","token_10017_skadi2_dedant",null,null,100,0,13,0,0,0,0,0,null],
|
||||
["p1","~1","token","token_10017_skadi2_dedant",null,null,100,0,60,0,0,0,0,0,null],
|
||||
["p1","~2","token","token_10017_skadi2_dedant",null,null,100,0,101,0,0,0,0,0,null],
|
||||
["p1","~3","token","token_10017_skadi2_dedant",null,null,100,0,144,0,0,0,0,0,null],
|
||||
["p1","~4","token","token_10017_skadi2_dedant",null,null,100,1,152,0,0,0,0,0,null]
|
||||
["p1","~3","token","token_10017_skadi2_dedant",null,null,100,0,141,0,0,0,0,0,null],
|
||||
["p1","~4","token","token_10017_skadi2_dedant",null,null,100,1,149,0,0,0,0,0,null]
|
||||
],
|
||||
"enemies": {
|
||||
"enemy_10045_parrot": [1,0,1,0,59936],
|
||||
"enemy_10098_crhro": [1,1,0,8773,185752],
|
||||
"enemy_10124_uashld_2": [12,9,3,44779,1527741],
|
||||
"enemy_10045_parrot": [1,0,1,0,53667],
|
||||
"enemy_10098_crhro": [1,0,1,6535,161143],
|
||||
"enemy_10124_uashld_2": [12,8,3,46823,1543522],
|
||||
"enemy_1019_jshoot_2": [1,1,0,1629,16718],
|
||||
"enemy_1043_zomsbr": [72,61,11,66,1082509],
|
||||
"enemy_1061_zomshd": [30,25,5,9861,1798745],
|
||||
"enemy_1043_zomsbr": [72,62,10,101,1105813],
|
||||
"enemy_1061_zomshd": [30,26,4,12386,1814925],
|
||||
"enemy_1072_dlancer": [1,1,0,271,37150],
|
||||
"enemy_1165_duhond": [1,1,0,0,17337],
|
||||
"enemy_1165_duhond": [1,1,0,86,17337],
|
||||
"enemy_1196_msfyin_2": [1,1,0,0,9],
|
||||
"enemy_1209_sfden_2": [1,1,0,2344,111451],
|
||||
"enemy_1210_msfden_2": [1,0,1,0,13],
|
||||
"enemy_1273_stmgun_2": [1,0,1,23833,36689],
|
||||
"enemy_1209_sfden_2": [1,1,0,781,111451],
|
||||
"enemy_1210_msfden_2": [1,0,1,0,36],
|
||||
"enemy_1273_stmgun_2": [1,1,0,26361,111451],
|
||||
"enemy_1355_mrfly": [1,1,0,0,37150],
|
||||
"enemy_1433_dsbasi_2": [1,1,0,0,35293],
|
||||
"enemy_2001_duckmi_2": [1,1,0,0,37150],
|
||||
"enemy_9006_actoxi": [1,0,1,0,5530]
|
||||
"enemy_9006_actoxi": [1,0,1,0,25174]
|
||||
},
|
||||
"hooks": [1,151,6792,1051,1131,7506,7729,62877,111,138,41,38,29,39389,1162,40,127,23,1,120,2,9,8],
|
||||
"events": {"atk":2382,"bounty":5,"deploy":24,"die":115,"dmg":7649,"engage":10,"fx":578,"heal":102,"layer":2,"leak":23,"skill":79,"spawn":144,"status":2786},
|
||||
"fx": {"aoe":7,"bombard":29,"bombardShell":29,"burst":9,"crit":56,"dodge":8,"explode":29,"firewall":2,"garrison":2,"iceSpike":99,"lightning":127,"lock":29,"lure":3,"revive":1,"snow":75,"sp":2,"storm":5,"summon":1,"talent":1,"telegraph":8,"tide":4,"wanted":47,"weightless":5},
|
||||
"snaps": "fe3def4e d483c136 ee2f3c0d 07a1e547 9d76e528 885dce23 cc4899ef 0f9faea8 eff043fe 32300c6f 2224c6d6 e3df2d18 9d122a08 27bee743 0d07fc4f f4421c12 c5ec3c4a fe540660 e802505f c9d74e16 6b47afbe e6a0a4fc 8246b490"
|
||||
"hooks": [1,148,6900,1084,1158,7800,8054,66853,109,134,43,38,29,40854,1160,49,127,21,1,125,2,12,7],
|
||||
"events": {"atk":2485,"bounty":5,"deploy":21,"die":113,"dmg":7974,"engage":10,"fx":604,"heal":118,"layer":2,"leak":21,"skill":81,"spawn":144,"status":2763},
|
||||
"fx": {"aoe":8,"bombard":29,"bombardShell":29,"burst":12,"crit":66,"dodge":7,"explode":30,"firewall":3,"garrison":2,"iceSpike":99,"lightning":139,"lock":29,"lure":3,"revive":1,"snow":74,"soul":1,"sp":1,"storm":5,"summon":1,"talent":1,"telegraph":7,"tide":4,"wanted":48,"weightless":5},
|
||||
"snaps": "fe3def4e 60ce0e98 68e4eff1 0ab18079 e3445176 db32929f bf23ff70 1142d3b6 bc663bbd dde23a5f 8df8a873 85d69b81 c70cdc4f 94132718 683de6ff 1eccc1af f1c61b7b 2bf8e444 50898a85 e25629e3 913e8ab6 2f3a3189 fa6f8200"
|
||||
},
|
||||
"roster-017": {
|
||||
"about": "pass 0 band 6: chess_char_6_15_a chess_char_6_19_a chess_char_6_07_a chess_char_6_17_a chess_char_6_08_a chess_char_6_09_a chess_char_6_11_a chess_char_6_12_a chess_char_6_13_a chess_char_6_14_a chess_char_6_16_a chess_char_6_18_a",
|
||||
@@ -2195,7 +2195,7 @@
|
||||
"hooks": [1,142,5560,344,344,550,908,981,125,128,57,54,6,14497,442,7,125,0,1,126,2,25,0],
|
||||
"events": {"atk":520,"bounty":4,"deploy":17,"die":128,"dmg":908,"engage":6,"fx":229,"heal":14,"layer":2,"skill":111,"spawn":142,"status":726},
|
||||
"fx": {"bombard":2,"bombardShell":2,"burst":25,"copy":1,"expose":118,"frostNova":1,"garrison":2,"heal":3,"phase":1,"pulse":2,"shieldBreak":1,"snow":14,"soul":2,"sp":39,"talent":1,"wanted":15},
|
||||
"snaps": "1fd81580 e8b29c39 8fe3be2e 22869cd5 83901ef4 36107212 007246d4 0fbda2dd d9aefee2 c69d102c cac8d601 30f7a8ad 606c1bdc d241a31d d8ce3067 1c139be5 14565097 4a7b95ab ecf99e3e"
|
||||
"snaps": "1fd81580 e8b29c39 8fe3be2e 22869cd5 83901ef4 e9e63622 d75e0fd3 81f144f2 93d80511 36f4b653 603069de 9337b18e 35d5c75b f92e33e2 ae44a508 733b9e86 63b04f00 a1c2df3c f8192841"
|
||||
},
|
||||
"roster-036": {
|
||||
"about": "pass 1 band 7: chess_char_6_03_b chess_char_6_15_a chess_char_6_19_a chess_char_6_17_a chess_char_6_12_a chess_char_6_13_a chess_char_6_14_a chess_char_6_16_a chess_char_6_18_a chess_char_6_20_a chess_char_6_01_b chess_char_6_02_b",
|
||||
@@ -2262,7 +2262,7 @@
|
||||
"hooks": [1,157,6221,639,638,1374,1624,8342,92,147,56,54,59,23902,3684,26,130,55,1,92,2,4,0],
|
||||
"events": {"atk":680,"bounty":2,"deploy":24,"die":92,"dmg":1417,"engage":9,"fx":644,"heal":524,"layer":2,"leak":55,"skill":110,"spawn":145,"status":871},
|
||||
"fx": {"airstrike":15,"aoe":3,"burst":4,"displace":2,"explode":16,"expose":460,"frostNova":17,"garrison":2,"healField":15,"link":1,"phase":1,"snow":37,"sunBurst":2,"talent":7,"telegraph":2,"wanted":60},
|
||||
"snaps": "352a6117 5197ba48 10f9a2ae 33f679df 3665a8ee 49550c0d 3f360174 ce1f9cce 89c5196c 39a34bf7 31facd72 20cbae52 17caf576 03e5ee75 5a29cf38 cd4bf8d3 6a8928c3 7ccff12b 527fbb07 bf017b13 a9045a61"
|
||||
"snaps": "352a6117 5197ba48 22ff25c2 16105f43 67bd4112 04c5a2b1 e4e63a95 9087d64d 41e697b3 8e3b24fc fa002205 4a75e62c 71798688 e3c0247f 3b14b2c2 04aa6c52 2d90b642 4764ab7a 541b8b1e 598e0a94 ca28421b"
|
||||
},
|
||||
"roster-037": {
|
||||
"about": "pass 1 band 7: chess_char_6_06_b chess_char_6_15_b chess_char_6_07_b chess_char_6_04_b chess_char_6_05_b chess_char_6_08_b chess_char_6_09_b chess_char_6_11_b chess_char_6_12_b chess_char_6_13_b chess_char_6_14_b chess_char_6_16_b token_10017_skadi2_dedant token_10030_mlyss_wtrman",
|
||||
@@ -2790,9 +2790,9 @@
|
||||
"leaks": 0,
|
||||
"leaksUncounted": 0,
|
||||
"coins": 8,
|
||||
"dmg": 1238376,
|
||||
"dmg": 1238096,
|
||||
"bossDmg": 0,
|
||||
"heal": 9730,
|
||||
"heal": 9576,
|
||||
"deaths": 0,
|
||||
"layerGains": {"lateranoShip":12,"preciShip":8}
|
||||
}
|
||||
@@ -2801,15 +2801,15 @@
|
||||
["p1",1,"op","chess_char_6_03_a",1,null,5054.61,1,7,0,0,0,0,0,0],
|
||||
["p1",2,"op","chess_char_6_06_a",1,null,2445.3,1,9,1,1,0,0,1,1],
|
||||
["p1",3,"op","chess_char_6_07_a",1,null,2420,1,10,0,0,0,0,0,0],
|
||||
["p1",4,"op","chess_char_6_01_a",1,null,1273.8,1,4,26,302286,0,0,36,3],
|
||||
["p1",4,"op","chess_char_6_01_a",1,null,1273.8,1,4,31,302479,0,0,39,3],
|
||||
["p1",5,"op","chess_char_6_02_a",1,null,1855.7,1,5,0,0,0,0,0,0],
|
||||
["p1",6,"op","chess_char_6_04_a",1,null,1322.2,1,11,0,0,0,0,0,1],
|
||||
["p1",7,"op","chess_char_6_05_a",1,null,1336.5,1,13,5,85739,0,0,19,2],
|
||||
["p1",7,"op","chess_char_6_05_a",1,null,1336.5,1,13,5,87114,0,0,19,2],
|
||||
["p1",8,"op","chess_char_6_08_a",1,null,1497.1,1,6,0,0,0,0,0,0],
|
||||
["p1",9,"op","chess_char_6_09_a",2,null,1029.6,1,14,11,378876,864,864,111,2],
|
||||
["p1",10,"op","chess_char_6_11_a",1,null,1634.6,1,12,6,173885,0,0,95,3],
|
||||
["p1",11,"op","chess_char_6_12_a",2,null,4367.19,1,15,18,228756,5384,5403,45,2],
|
||||
["p1",12,"op","chess_char_6_13_a",2,null,1892,1,8,1,68833,3462,3462,0,0],
|
||||
["p1",9,"op","chess_char_6_09_a",2,null,1029.6,1,14,11,371732,711,711,110,2],
|
||||
["p1",10,"op","chess_char_6_11_a",1,null,1634.6,1,12,7,173044,0,0,95,3],
|
||||
["p1",11,"op","chess_char_6_12_a",2,null,4367.19,1,15,13,223002,5384,5403,46,2],
|
||||
["p1",12,"op","chess_char_6_13_a",2,null,1892,1,8,0,80724,3462,3462,0,0],
|
||||
["p1",13,"token","token_10017_skadi2_dedant",null,null,100,0,16,0,0,0,0,0,null],
|
||||
["-","~1","device","trap_1105_accrate",null,null,100,1,1,0,0,0,0,0,null],
|
||||
["-","~2","device","trap_1105_accrate",null,null,100,1,2,0,0,0,0,0,null],
|
||||
@@ -2825,18 +2825,18 @@
|
||||
"enemy_10073_mpcar": [1,1,0,0,3110],
|
||||
"enemy_1023_jmage": [1,1,0,1159,3421],
|
||||
"enemy_1045_hammer_2": [12,12,0,6631,373248],
|
||||
"enemy_1061_zomshd": [48,48,0,940,788876],
|
||||
"enemy_1158_divman": [1,1,0,285,5132],
|
||||
"enemy_1061_zomshd": [48,48,0,940,788596],
|
||||
"enemy_1158_divman": [1,1,0,131,5132],
|
||||
"enemy_1199_sfjin": [1,1,0,0,10109],
|
||||
"enemy_1200_msfjin": [1,1,0,0,47],
|
||||
"enemy_1270_nhstlk_2": [1,1,0,0,24883],
|
||||
"enemy_1402_tgshd_2": [1,1,0,54,20218],
|
||||
"enemy_2001_duckmi_2": [1,1,0,0,9331]
|
||||
},
|
||||
"hooks": [1,90,5499,356,338,701,1128,8573,68,74,14,12,41,25781,515,20,68,0,1,68,10,51,0],
|
||||
"events": {"atk":361,"bounty":4,"deploy":19,"die":74,"dmg":945,"engage":6,"fx":193,"layer":10,"skill":26,"spawn":90,"status":1102},
|
||||
"fx": {"airstrike":12,"burst":51,"crit":28,"dp":3,"expose":6,"garrison":6,"link":1,"lock":28,"snow":5,"summon":3,"summonStun":3,"talent":1,"wanted":46},
|
||||
"snaps": "a85a8369 0a33b2f2 724f8cee 96cdeaea 2a60b953 251ce27e ef31d396 4fd327c7 e1fcf4f3 2413fe96 23f1c1b9 e60d68c2 414b5196 26676467 18daab40 3c5e3c0d 5fcd0834 02715784 017438eb"
|
||||
"hooks": [1,90,5499,357,339,694,1119,8499,68,74,14,12,41,25781,509,19,68,0,1,68,10,50,0],
|
||||
"events": {"atk":362,"bounty":4,"deploy":19,"die":74,"dmg":936,"engage":6,"fx":192,"layer":10,"skill":26,"spawn":90,"status":1088},
|
||||
"fx": {"airstrike":12,"burst":50,"crit":28,"dp":3,"expose":6,"garrison":6,"link":1,"lock":28,"snow":5,"summon":3,"summonStun":3,"talent":1,"wanted":46},
|
||||
"snaps": "a85a8369 6b6410a1 1af1f862 32a99902 bdbc1150 56475174 2f96ad8a 1db64d03 21d24dd9 2db9fdb3 1c302c59 053458d5 efcdeffa 11f04efa c2d50d10 498289e0 092a22da 8661ea58 38a6f6ff"
|
||||
},
|
||||
"roster-046": {
|
||||
"about": "pass 2 band 7: chess_char_6_03_b chess_char_6_15_a chess_char_6_19_a chess_char_6_17_a chess_char_6_14_a chess_char_6_16_a chess_char_6_18_a chess_char_6_20_a chess_char_6_01_b chess_char_6_02_b chess_char_6_04_b chess_char_6_05_b token_10017_skadi2_dedant",
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import * as BE from '../../server/match/botEmotes.js';
|
||||
import { EMOTE_COOLDOWN_MS } from '../../shared/constants.js';
|
||||
import { makeMatch, give, giveItem, DATA } from './harness.js';
|
||||
const E = (name) => `autochess_battle_${name}`;
|
||||
function fixture() {
|
||||
const human = { playerId: 'human', isBot: false, alive: true };
|
||||
const ai = { playerId: 'ai', isBot: true, alive: true, lastEmoteAt: -Infinity };
|
||||
const other = { playerId: 'other', isBot: true, alive: true, lastEmoteAt: -Infinity };
|
||||
const messages = [];let now = 10000;
|
||||
const m = { round: 1, order: [human, ai, other], lastResults: new Map(), sched: { now: () => now },
|
||||
alivePlayers: () => m.order.filter((p) => p.alive), broadcast: (x) => messages.push(x) };
|
||||
return { m, human, ai, other, messages, advance: () => { now += EMOTE_COOLDOWN_MS + 1; } };
|
||||
}
|
||||
test('AI emotes use the existing whitelist and cooldown, never impersonate a human', () => {
|
||||
const { m, ai, human, messages, advance } = fixture();
|
||||
assert.equal(BE.sendEmote(m, human, E('thanks')), false);
|
||||
assert.equal(BE.sendEmote(m, ai, 'bad'), false);
|
||||
assert.equal(BE.sendEmote(m, ai, E('thanks')), true);
|
||||
assert.equal(BE.sendEmote(m, ai, E('thanks')), false);
|
||||
advance();assert.equal(BE.sendEmote(m, ai, E('thanks')), true);
|
||||
ai.alive = false;advance();assert.equal(BE.sendEmote(m, ai, E('thanks')), false);
|
||||
assert.equal(messages.length, 2);
|
||||
});
|
||||
test('one eligible AI answers a human, cooling seats are skipped; AI messages do not recurse', () => {
|
||||
const { m, human, ai, other, messages } = fixture();ai.lastEmoteAt = m.sched.now();
|
||||
assert.equal(BE.onHumanEmote(m, human.playerId, E('thanks')), true);
|
||||
assert.deepEqual(messages.map((x) => x.playerId), [other.playerId]);
|
||||
assert.equal(BE.onHumanEmote(m, ai.playerId, E('thanks')), false);
|
||||
assert.equal(BE.onHumanEmote(m, 'unknown', E('thanks')), false);
|
||||
});
|
||||
test('pure AI and solo games stay silent at every entry point', () => {
|
||||
for (const pureAI of [true, false]) {
|
||||
const { m, human, ai, messages } = fixture();m.order = pureAI ? [ai] : [human];
|
||||
BE.sendEmote(m, ai, E('thanks'));BE.onHumanEmote(m, human.playerId, E('thanks'));
|
||||
BE.onSettle(m);BE.onPickCard(m, ai, { kind: 'bounty' });BE.onStartUnite(m, { helpers: [ai] });
|
||||
BE.onGiftTicker(m, ai, human.playerId);for (let n=0;n<3;n++) BE.onMerge(m, ai, { kind: 'chess' });
|
||||
assert.deepEqual(messages, []);
|
||||
}
|
||||
});
|
||||
test('settlement uses this round, breaks kill ties by damage and cannot repeat after cooldown', () => {
|
||||
const { m, ai, other, messages, advance } = fixture();
|
||||
ai.stats = { kills: 999 };other.stats = { kills: 0 };
|
||||
m.lastResults.set(ai.playerId, { killed: 2, damageDealt: 10, leaked: [{ counted: true }], perfect: false });
|
||||
m.lastResults.set(other.playerId, { killed: 2, damageDealt: 20, leaked: [], perfect: true });
|
||||
assert.equal(BE.pickSettleEmote(m, ai), E('scared'));assert.equal(BE.pickSettleEmote(m, other), E('call'));
|
||||
assert.equal(BE.onSettle(m), true);advance();assert.equal(BE.onSettle(m), false);
|
||||
assert.equal(messages.length, 2);m.round++;assert.equal(BE.onSettle(m), true);
|
||||
});
|
||||
test('third chess merge reacts once per round, not to items or every later merge', () => {
|
||||
const { m, ai, messages, advance } = fixture();
|
||||
BE.onMerge(m, ai, { kind: 'item' });
|
||||
for(let n=0;n<8;n++) { BE.onMerge(m, ai, { kind: 'chess' });advance(); }
|
||||
assert.equal(messages.length, 1);BE.resetRoundCounters(m);
|
||||
for(let n=0;n<3;n++) { BE.onMerge(m, ai, { kind: 'chess' });advance(); }
|
||||
assert.equal(messages.length, 2);
|
||||
});
|
||||
test('gift reactions identify the source player rather than an operator display name', () => {
|
||||
const { m, ai, human, other, messages } = fixture();
|
||||
assert.equal(BE.onGiftTicker(m, ai, '琳琅诗怀雅'), false);
|
||||
assert.equal(BE.onGiftTicker(m, ai, other.playerId), false);
|
||||
assert.equal(BE.onGiftTicker(m, ai, human.playerId), true);
|
||||
assert.equal(messages.length, 1);
|
||||
});
|
||||
test('environment switch suppresses every emote entry point', () => {
|
||||
const r = spawnSync(process.execPath, ['--input-type=module', '-e', `
|
||||
import {sendEmote} from './server/match/botEmotes.js';
|
||||
const ai={playerId:'ai',isBot:true,alive:true};
|
||||
if(sendEmote({order:[ai,{isBot:false}]},ai,'autochess_battle_thanks')) process.exit(2);
|
||||
`], { env: { ...process.env, SP_BOT_EMOTES: '0' }, encoding: 'utf8' });
|
||||
assert.equal(r.status, 0, r.stderr);
|
||||
});
|
||||
test('actual match emote and merge hooks broadcast without errors', (t) => {
|
||||
const h = makeMatch({ mode: 'coop', humans: 1, bots: 1, seed: 1, fake: true }).start().toPrep();t.after(() => h.m.dispose());
|
||||
const [human, ai] = h.m.order;human.lastEmoteAt = ai.lastEmoteAt = -Infinity;
|
||||
assert.equal(h.m.emote(human, E('thanks')).ok, true);
|
||||
const c = Object.values(DATA.chess).find((c) => c.visible && !c.isGolden && c.tier === 1);
|
||||
for (let n=0;n<3;n++) ai.acquireChess(c.chessId);
|
||||
assert.equal(h.m._botEmotesMerges.get(ai.playerId), 1);
|
||||
});
|
||||
|
||||
test('real beacon delivery from a human makes the receiving bot thank the correct player', (t) => {
|
||||
const h = makeMatch({ mode: 'coop', humans: 2, seed: 31, fake: true }).start().toPrep();
|
||||
t.after(() => h.m.dispose());
|
||||
const [human, ai] = h.m.order;
|
||||
ai.isBot = true; ai.lastEmoteAt = -Infinity;
|
||||
const c = Object.values(DATA.chess).find((c) => c.visible && !c.isGolden && c.tier === 2 && c.bonds.length);
|
||||
const carrier = give(h.m, human, c.chessId, 'hand');
|
||||
const item = giveItem(h.m, human, 'chess_item_5_04_e_a');
|
||||
assert.equal(h.m.handle(human.playerId, { t: 'g.equip', itemUid: item.uid, targetUid: carrier.uid }).ok, true);
|
||||
h.m.round = 2;
|
||||
h.m.dispatch(human, 'onRoundStart', { round: 2 });
|
||||
assert.ok(h.bc.filter((e) => e.t === 'm.emote').some((e) => e.playerId === ai.playerId && e.id === E('thanks')));
|
||||
assert.equal(human.effects.filter((e) => e.key === 'effect:builtin_gift').length, 0);
|
||||
assert.equal(h.m.errorCount, 0);
|
||||
});
|
||||
@@ -7,12 +7,74 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { PHASE } from '../../shared/constants.js';
|
||||
import { botPickCard, bountyKillChance, itemTarget, arrange, botPrepBegin, botPrepEnd, bondPlan, rangeTiles, effDps, rangeRec } from '../../server/match/bot.js';
|
||||
import { botPickCard, bountyKillChance, itemTarget, arrange, botPrepBegin, botPrepEnd, bondPlan, rangeTiles, effDps, rangeRec, buyScoreOf } from '../../server/match/bot.js';
|
||||
import { parseKey, tileKey } from '../../server/match/board.js';
|
||||
import { makeMatch, checkInvariants, give, giveItem, legalTileFor, DATA } from './harness.js';
|
||||
|
||||
const soloBot = (o = {}) => makeMatch({ mode: 'solo', difficulty: 'NORMAL', seats: [{ seat: 0, playerId: 'ai_0', name: 'AI', isBot: true, connected: true }], ...o });
|
||||
|
||||
test('user: Given a faction strategy teammate, When scoring shared high-tier chess, Then humans and bots receive the same preference even with full stock', () => {
|
||||
const { m } = makeMatch({ humans: 3, seed: 11 }).start().toPrep();
|
||||
const [ps, mate, other] = m.order;
|
||||
try {
|
||||
mate.bandId = other.bandId = null;
|
||||
ps.shop.level = 6;
|
||||
// 贾维 names 叙拉古; 铃兰 (V) and 荒芜拉普兰德 (VI) both belong to it.
|
||||
for (const id of ['chess_char_5_10_a', 'chess_char_6_18_a']) {
|
||||
mate.isBot = false;
|
||||
const ordinary = buyScoreOf(m, ps, id);
|
||||
mate.bandId = 'band_chiave';
|
||||
const cooperative = buyScoreOf(m, ps, id);
|
||||
assert.ok(cooperative < ordinary, `${m.gd.chess(id).name}: leave faction stock for the strategy`);
|
||||
mate.isBot = true;
|
||||
assert.equal(buyScoreOf(m, ps, id), cooperative, 'bot teammates count equally');
|
||||
other.bandId = 'band_chiave';
|
||||
assert.equal(buyScoreOf(m, ps, id), cooperative, 'multiple teammates do not multiply the penalty');
|
||||
mate.alive = other.alive = false;
|
||||
assert.equal(buyScoreOf(m, ps, id), ordinary, 'eliminated players no longer need cards');
|
||||
mate.alive = other.alive = true;
|
||||
other.bandId = null;
|
||||
m.gd.modeInactiveBonds.add('siracusaShip');
|
||||
const inactive = buyScoreOf(m, ps, id);
|
||||
mate.bandId = null;
|
||||
assert.equal(buyScoreOf(m, ps, id), inactive, 'inactive faction does not trigger yielding');
|
||||
m.gd.modeInactiveBonds.delete('siracusaShip');
|
||||
}
|
||||
const unaffected = Object.values(DATA.chess).filter((c) => c.visible && !c.isGolden &&
|
||||
(c.tier < 5 || !c.bonds.includes('siracusaShip')));
|
||||
for (const c of unaffected) {
|
||||
mate.bandId = null;
|
||||
const ordinary = buyScoreOf(m, ps, c.chessId);
|
||||
mate.bandId = 'band_chiave';
|
||||
assert.equal(buyScoreOf(m, ps, c.chessId), ordinary, c.name);
|
||||
}
|
||||
} finally { m.dispose(); }
|
||||
});
|
||||
|
||||
test('user: Given a teammate faction strategy, When buying or taking a reward, Then prefer an alternative but retain an immediate own merge', () => {
|
||||
const yu = 'chess_char_6_03_a';
|
||||
const nearl = 'chess_char_6_17_a';
|
||||
for (const { reward = false, ownPair = false, band = 'band_duyaoy', expected } of [
|
||||
{ expected: nearl },
|
||||
{ reward: true, expected: nearl },
|
||||
{ ownPair: true, expected: yu },
|
||||
{ band: null, expected: yu },
|
||||
]) {
|
||||
const { m } = makeMatch({ humans: 2, seed: 11 }).start().toPrep();
|
||||
const [ps, mate] = m.order;
|
||||
try {
|
||||
mate.bandId = band;
|
||||
if (ownPair) for (let i = 0; i < 2; i++) give(m, ps, yu, 'hand');
|
||||
ps.shop.level = 6;
|
||||
ps.funds = reward ? 0 : 5;
|
||||
ps.shop.slots = reward ? [] : [yu, nearl].map((id) => ({ kind: 'chess', id, basePrice: 5, sold: false }));
|
||||
if (reward) ps.pushRewardOffer('merge', { tier: 6, ids: [yu, nearl] });
|
||||
botPrepBegin(m, ps);
|
||||
assert.equal(reward ? ps.allChess()[0]?.id : ps.shop.slots.find((s) => s.sold)?.id, expected);
|
||||
} finally { m.dispose(); }
|
||||
}
|
||||
});
|
||||
|
||||
/** A 悬赏决策 card as the draft builds it (choices.js buildCards), by its enemy's name. */
|
||||
function bountyCard(enemyName) {
|
||||
const c = DATA.choices.cards.bounty.find((x) => x.draft && DATA.enemies[x.enemyKey] && DATA.enemies[x.enemyKey].name === enemyName);
|
||||
|
||||
@@ -24,6 +24,8 @@ function randomIntent(rng, m, ps) {
|
||||
const uid = () => (pieces.length && rng() < 0.8 ? rng.pick(pieces) : 1 + rng.int(5000));
|
||||
const to = () => (rng() < 0.6 ? { area: 'board', row: rng() < 0.8 ? 9 + rng.int(4) : rng.int(19), col: rng() < 0.8 ? 2 + rng.int(9) : rng.int(21) } : { area: 'hand', idx: rng.int(10) });
|
||||
switch (t) {
|
||||
case 'g.infoReady': return { t, setupRevision: m.setupRevision ?? 0 };
|
||||
case 'g.rerollVote': return { t, voteId: m.setupVote?.id ?? 1 + rng.int(10), agree: rng() < 0.5 };
|
||||
case 'g.band': return { t, bandId: rng() < 0.8 ? rng.pick(BANDS) : rng.pick(['constructor', '__proto__', 'band_x', 'a'.repeat(64)]) };
|
||||
case 'g.buy': return { t, slot: rng.int(16) };
|
||||
case 'g.sell': case 'g.destroy': return { t, uid: uid() };
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { startServer } from '../../server/index.js';
|
||||
import { TestClient } from '../helpers/wsClient.js';
|
||||
import { ERR } from '../../shared/constants.js';
|
||||
|
||||
const ok = async (c, msg) => {
|
||||
const r = await c.request(msg);
|
||||
assert.equal(r.t, 'ok', JSON.stringify(r));
|
||||
};
|
||||
const err = async (c, msg, code) => {
|
||||
const r = await c.request(msg);
|
||||
assert.equal(r.t, 'error', JSON.stringify(r));
|
||||
assert.equal(r.code, code, JSON.stringify(r));
|
||||
};
|
||||
const setupOf = (pub) => ({ stageId: pub.stageId, bossId: pub.bossId, hiddenBossId: pub.hiddenBossId,
|
||||
factions: pub.factions, disabledBonds: pub.disabledBonds, bannedChess: pub.bannedChess, setupRevision: pub.setupRevision });
|
||||
|
||||
test('real four-player sockets: host-only proposal, unanimous sync, reconnect and stale requests', { timeout: 15000 }, async (t) => {
|
||||
const errors = [];
|
||||
const srv = await startServer({ port: 0, host: '127.0.0.1', seedFn: () => 123,
|
||||
log: { info() {}, warn() {}, debug() {}, error: (...args) => errors.push(args.map(String).join(' ')) } });
|
||||
const clients = [];
|
||||
t.after(async () => {
|
||||
await Promise.all(clients.map((c) => c.terminate()));
|
||||
await srv.close();
|
||||
assert.deepEqual(errors, []);
|
||||
});
|
||||
const player = async (name, token) => {
|
||||
const c = await TestClient.connect(`ws://127.0.0.1:${srv.port}/ws`);
|
||||
clients.push(c);
|
||||
const w = await c.hello(name, token);
|
||||
c.id = w.playerId;
|
||||
c.token = w.token;
|
||||
return c;
|
||||
};
|
||||
const host = await player('Host');
|
||||
await ok(host, { t: 'room.create', mode: 'coop', difficulty: 'HARD' });
|
||||
const room = await host.waitFor('room.state');
|
||||
const guests = [];
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const c = await player(`Guest${i}`);
|
||||
guests.push(c);
|
||||
await ok(c, { t: 'room.join', code: room.code });
|
||||
await ok(c, { t: 'room.ready', ready: true });
|
||||
}
|
||||
const spectator = await player('Viewer');
|
||||
await ok(spectator, { t: 'room.spectate', code: room.code });
|
||||
await ok(host, { t: 'room.start' });
|
||||
const initial = await host.waitFor('m.public', (p) => p.phase === 'INFO_CHECK');
|
||||
const m = srv.lobby.rooms.get(room.code).match;
|
||||
await err(guests[0], { t: 'room.rerollSetup', setupRevision: 0 }, ERR.NOT_HOST);
|
||||
await err(spectator, { t: 'room.rerollSetup', setupRevision: 0 }, ERR.SPECTATOR);
|
||||
await ok(guests[0], { t: 'g.infoReady', setupRevision: 0 });
|
||||
await ok(host, { t: 'room.rerollSetup', setupRevision: 0 });
|
||||
const voting = await host.waitFor('m.public', (p) => !!p.rerollVote);
|
||||
assert.equal(voting.deadline, 0);
|
||||
assert.equal(voting.rerollVote.voters.length, 4);
|
||||
assert.deepEqual(voting.rerollVote.agreed, [host.id]);
|
||||
const voteId = voting.rerollVote.id;
|
||||
await err(spectator, { t: 'g.rerollVote', voteId, agree: true }, ERR.SPECTATOR);
|
||||
await err(guests[0], { t: 'room.cancelReroll', voteId }, ERR.NOT_HOST);
|
||||
await ok(guests[0], { t: 'g.rerollVote', voteId, agree: true });
|
||||
await ok(guests[1], { t: 'g.rerollVote', voteId, agree: true });
|
||||
assert.equal(m.setupRevision, 0, '3/4 agrees never refreshes');
|
||||
await ok(guests[2], { t: 'g.rerollVote', voteId, agree: true });
|
||||
const all = [host, ...guests, spectator];
|
||||
const fresh = await Promise.all(all.map((c) => c.waitFor('m.public', (p) => p.setupRevision === 1)));
|
||||
for (const p of fresh) {
|
||||
assert.deepEqual(setupOf(p), setupOf(fresh[0]));
|
||||
assert.equal(p.deadline, fresh[0].deadline, 'all players share the same new deadline');
|
||||
assert.equal(Math.ceil((p.deadline - p.serverNow) / 1000), 25, 'each player sees a fresh 25-second countdown');
|
||||
assert.equal(p.rerollVote, null);
|
||||
assert.ok(p.players.every((ps) => !ps.ready));
|
||||
}
|
||||
assert.notDeepEqual(setupOf(fresh[0]), { ...setupOf(initial), setupRevision: 1 });
|
||||
assert.equal(srv.lobby.rooms.get(room.code).match, m, 'room/match instance and sockets retained');
|
||||
assert.ok(all.every((c) => c.isOpen));
|
||||
await err(guests[0], { t: 'g.infoReady', setupRevision: 0 }, ERR.BAD_TARGET);
|
||||
await err(guests[0], { t: 'g.infoReady' }, ERR.BAD_TARGET);
|
||||
await err(host, { t: 'room.rerollSetup', setupRevision: 0 }, ERR.BAD_TARGET);
|
||||
// Disconnect/reconnect retains this room and returns the current full setup.
|
||||
const guest = guests[2];
|
||||
await guest.terminate();
|
||||
await host.waitFor('m.public', (p) => p.setupRevision === 1 && p.players.some((ps) => ps.playerId === guest.id && !ps.connected));
|
||||
const resumed = await player('Guest2', guest.token);
|
||||
assert.equal(resumed.id, guest.id);
|
||||
assert.deepEqual(setupOf(await resumed.waitFor('m.public', (p) => p.setupRevision === 1)), setupOf(fresh[0]));
|
||||
const restoredRoom = await resumed.waitFor('room.state');
|
||||
assert.equal(restoredRoom.code, room.code);
|
||||
for (const c of [host, guests[0], guests[1], resumed]) await ok(c, { t: 'g.infoReady', setupRevision: 1 });
|
||||
await host.waitFor('m.public', (p) => p.phase === 'BAND_DRAFT');
|
||||
await err(host, { t: 'room.rerollSetup', setupRevision: 1 }, ERR.WRONG_PHASE);
|
||||
});
|
||||
|
||||
test('real sockets: reject/cancel/disconnect keep the setup and resume the countdown', { timeout: 15000 }, async (t) => {
|
||||
const srv = await startServer({ port: 0, host: '127.0.0.1', quiet: true });
|
||||
const clients = [];
|
||||
t.after(async () => { await Promise.all(clients.map((c) => c.terminate())); await srv.close(); });
|
||||
for (const name of ['Host', 'Guest']) {
|
||||
const c = await TestClient.connect(`ws://127.0.0.1:${srv.port}/ws`);
|
||||
clients.push(c);
|
||||
await c.hello(name);
|
||||
}
|
||||
const [host, guest] = clients;
|
||||
await ok(host, { t: 'room.create', mode: 'coop', difficulty: 'NORMAL' });
|
||||
const room = await host.waitFor('room.state');
|
||||
await ok(guest, { t: 'room.join', code: room.code });
|
||||
await ok(guest, { t: 'room.ready', ready: true });
|
||||
await ok(host, { t: 'room.start' });
|
||||
const initial = await host.waitFor('m.public', (p) => p.phase === 'INFO_CHECK');
|
||||
const m = srv.lobby.rooms.get(room.code).match;
|
||||
for (const finish of ['reject', 'cancel', 'disconnect']) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 310));
|
||||
await ok(host, { t: 'room.rerollSetup', setupRevision: 0 });
|
||||
const voting = await host.waitFor('m.public', (p) => !!p.rerollVote);
|
||||
if (finish === 'reject') await ok(guest, { t: 'g.rerollVote', voteId: voting.rerollVote.id, agree: false });
|
||||
if (finish === 'cancel') await ok(host, { t: 'room.cancelReroll', voteId: voting.rerollVote.id });
|
||||
if (finish === 'disconnect') await guest.terminate();
|
||||
await host.waitFor('m.public', (p) => !p.rerollVote && p.deadline > 0);
|
||||
assert.equal(m.setupRevision, 0);
|
||||
assert.deepEqual(setupOf(m.publicView()), setupOf(initial));
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,233 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { makeMatch } from './harness.js';
|
||||
import { ERR, PHASE } from '../../shared/constants.js';
|
||||
import { validateC2S } from '../../shared/protocol.js';
|
||||
import { createRng, deriveSeed } from '../../server/sim/rng.js';
|
||||
import { setupMatchWaves } from '../../server/match/waves.js';
|
||||
import { drawDisabledBonds } from '../../server/match/pool.js';
|
||||
|
||||
const snapshot = (m) => ({ stageId: m.stageId, bossId: m.bossId, hiddenBossId: m.hiddenBossId,
|
||||
factions: [...m.factions], schedule: m.factions.schedule, bans: [...m.bannedChess], drawn: [...m.disabledBonds] });
|
||||
const approve = (m, id, voteId = m.setupVote.id) => m.handle(id, { t: 'g.rerollVote', voteId, agree: true });
|
||||
|
||||
test('reroll with one second left gives everyone a fresh full countdown; old timeout cannot advance it', (t) => {
|
||||
const h = makeMatch({ humans: 2 }).start();
|
||||
t.after(() => h.m.dispose());
|
||||
const m = h.m;
|
||||
const fullMs = m.scaled(m.gd.timer('infoCheck') * 1000);
|
||||
h.sched.advance(fullMs - 1000);
|
||||
assert.equal(m.deadline - h.sched.now(), 1000);
|
||||
assert.deepEqual(m.requestSetupReroll('p_0', 0), { ok: true });
|
||||
h.sched.advance(5000);
|
||||
assert.deepEqual(approve(m, 'p_1'), { ok: true });
|
||||
const fresh = h.lastBc('m.public');
|
||||
assert.equal(fresh.deadline - fresh.serverNow, fullMs);
|
||||
assert.ok(fresh.players.every((p) => !p.ready));
|
||||
h.sched.advance(fullMs - 1);
|
||||
assert.equal(m.phase, PHASE.INFO_CHECK);
|
||||
h.sched.advance(1);
|
||||
assert.equal(m.phase, PHASE.BAND_DRAFT);
|
||||
});
|
||||
|
||||
test('protocol requires setup revisions and vote identities; old initial ready remains supported', () => {
|
||||
assert.equal(validateC2S({ t: 'room.rerollSetup', setupRevision: 0 }), null);
|
||||
assert.equal(validateC2S({ t: 'room.cancelReroll', voteId: 1 }), null);
|
||||
assert.equal(validateC2S({ t: 'g.rerollVote', voteId: 1, agree: false }), null);
|
||||
assert.equal(validateC2S({ t: 'g.infoReady' }), null);
|
||||
assert.equal(validateC2S({ t: 'g.infoReady', setupRevision: 2 }), null);
|
||||
for (const msg of [{ t: 'room.rerollSetup' }, { t: 'room.rerollSetup', setupRevision: -1 },
|
||||
{ t: 'room.rerollSetup', setupRevision: 0.5 }, { t: 'g.rerollVote', voteId: 0, agree: true },
|
||||
{ t: 'g.rerollVote', voteId: 1 }, { t: 'g.rerollVote', voteId: 1, agree: 'yes' },
|
||||
{ t: 'room.cancelReroll' }, { t: 'g.infoReady', setupRevision: -1 }]) {
|
||||
assert.notEqual(validateC2S(msg), null, JSON.stringify(msg));
|
||||
}
|
||||
});
|
||||
|
||||
test('four humans must agree; rerolls the entire official setup and stock, preserves seats/settings/RNG', (t) => {
|
||||
const h = makeMatch({ humans: 4, difficulty: 'HARD', seed: 123, spectators: ['viewer'] }).start();
|
||||
t.after(() => h.m.dispose());
|
||||
const m = h.m;
|
||||
const before = snapshot(m);
|
||||
const pool = m.pool;
|
||||
const players = [...m.order];
|
||||
const rngs = [m.rngSetup, m.rngShop, m.rngDraft, m.rngWaves, m.rngBots, m.rngMeta].map((r) => [r, r.state()]);
|
||||
const p0 = h.ps('p_0');
|
||||
assert.equal(p0.setDiy({ chess_char_5_diy1_a: { charId: 'char_017_huang', skillIndex: 0 } }), true);
|
||||
p0.initDiyStock(new Set([...m.disabledBonds, ...m.staticInactiveBonds]));
|
||||
const settings = [p0.loadout, p0.standIns, p0.diy];
|
||||
const stock = p0.diyStock;
|
||||
m.handle('p_1', { t: 'g.infoReady' });
|
||||
h.sched.advance(500);
|
||||
assert.deepEqual(m.requestSetupReroll('p_0', 0), { ok: true });
|
||||
assert.deepEqual(m.setupVote.voters, ['p_0', 'p_1', 'p_2', 'p_3']);
|
||||
assert.equal(m.deadline, 0);
|
||||
assert.deepEqual(approve(m, 'p_1'), { ok: true });
|
||||
assert.deepEqual(approve(m, 'p_2'), { ok: true });
|
||||
h.sched.advance(30000);
|
||||
assert.equal(m.phase, PHASE.INFO_CHECK);
|
||||
assert.deepEqual(snapshot(m), before, 'nothing rerolled until everyone agrees');
|
||||
assert.equal(m.handle('viewer', { t: 'g.rerollVote', voteId: m.setupVote.id, agree: true }).error, ERR.SPECTATOR);
|
||||
assert.deepEqual(approve(m, 'p_3'), { ok: true });
|
||||
assert.equal(m.setupRevision, 1);
|
||||
assert.equal(m.setupVote, null);
|
||||
const rng = createRng(deriveSeed(m.seed, 'reroll-setup:1'));
|
||||
const setup = setupMatchWaves(m.gd, rng);
|
||||
const bans = drawDisabledBonds(m.gd, rng);
|
||||
assert.deepEqual(snapshot(m), { stageId: setup.stageId, bossId: setup.bossId, hiddenBossId: setup.hiddenBossId,
|
||||
factions: [...setup.factions], schedule: setup.factions.schedule, bans: bans.banned, drawn: bans.drawn });
|
||||
assert.notDeepEqual(snapshot(m), before);
|
||||
assert.equal(m.stage, m.gd.stage(m.stageId));
|
||||
assert.notEqual(m.pool, pool);
|
||||
assert.deepEqual(m.order, players);
|
||||
assert.deepEqual([p0.loadout, p0.standIns, p0.diy], settings);
|
||||
assert.equal(p0.diy, settings[2]);
|
||||
assert.notEqual(p0.diyStock, stock);
|
||||
const off = new Set([...bans.drawn, ...bans.staticOff]);
|
||||
const diy = p0.gd.chess('chess_char_5_diy1_a');
|
||||
assert.equal(p0.diyBanned.includes(diy.chessId), diy.bonds.every((b) => off.has(b)));
|
||||
assert.equal(p0.diyStock.has(diy.chessId), !p0.diyBanned.includes(diy.chessId));
|
||||
for (const [r, state] of rngs) assert.equal(r.state(), state);
|
||||
for (const id of m.gd.visibleChess) assert.equal(m.pool.has(id), !bans.banned.includes(id));
|
||||
assert.ok(m.order.every((p) => !p.infoReady));
|
||||
assert.equal(m.deadline, h.sched.now() + m.scaled(m.gd.timer('infoCheck') * 1000));
|
||||
assert.equal(h.lastBc('m.public').setupRevision, 1);
|
||||
h.invariants();
|
||||
for (const p of m.order) assert.deepEqual(m.handle(p.playerId, { t: 'g.infoReady', setupRevision: 1 }), { ok: true });
|
||||
h.sched.advance(0);
|
||||
assert.equal(m.phase, PHASE.BAND_DRAFT);
|
||||
});
|
||||
|
||||
test('reject/cancel restores exact remaining countdown and keeps setup and ready flags', (t) => {
|
||||
const h = makeMatch({ humans: 2, timerScale: 0.5 }).start();
|
||||
t.after(() => h.m.dispose());
|
||||
const m = h.m;
|
||||
const before = snapshot(m);
|
||||
m.handle('p_1', { t: 'g.infoReady' });
|
||||
h.sched.advance(1000);
|
||||
const remaining = m.deadline - h.sched.now();
|
||||
m.requestSetupReroll('p_0', 0);
|
||||
h.sched.advance(10000);
|
||||
assert.deepEqual(m.handle('p_1', { t: 'g.rerollVote', voteId: m.setupVote.id, agree: false }), { ok: true });
|
||||
assert.deepEqual(snapshot(m), before);
|
||||
assert.equal(m.setupRevision, 0);
|
||||
assert.equal(h.ps('p_1').infoReady, true);
|
||||
assert.equal(m.deadline, h.sched.now() + remaining, 'real remaining ms, no extra timer scaling');
|
||||
m.requestSetupReroll('p_0', 0);
|
||||
assert.deepEqual(m.cancelSetupReroll('p_0', m.setupVote.id), { ok: true });
|
||||
assert.equal(m.deadline, h.sched.now() + remaining);
|
||||
h.sched.advance(remaining - 1);
|
||||
assert.equal(m.phase, PHASE.INFO_CHECK);
|
||||
h.sched.advance(1);
|
||||
assert.equal(m.phase, PHASE.BAND_DRAFT);
|
||||
});
|
||||
|
||||
test('reroll cancels queued all-ready transition; stale ready and previous vote cannot affect a new setup', (t) => {
|
||||
const h = makeMatch({ humans: 2 }).start();
|
||||
t.after(() => h.m.dispose());
|
||||
const m = h.m;
|
||||
for (const p of m.order) m.handle(p.playerId, { t: 'g.infoReady' });
|
||||
assert.ok(m._infoAdvanceTimer);
|
||||
m.requestSetupReroll('p_0', 0);
|
||||
const oldVote = m.setupVote.id;
|
||||
assert.equal(m.handle('p_1', { t: 'g.infoReady' }).error, ERR.WRONG_PHASE);
|
||||
approve(m, 'p_1');
|
||||
h.sched.advance(0);
|
||||
assert.equal(m.phase, PHASE.INFO_CHECK);
|
||||
for (const msg of [{ t: 'g.infoReady' }, { t: 'g.infoReady', setupRevision: 0 }]) {
|
||||
assert.equal(m.handle('p_1', msg).error, ERR.BAD_TARGET);
|
||||
}
|
||||
assert.equal(m.requestSetupReroll('p_0', 0).error, ERR.BAD_TARGET);
|
||||
assert.equal(m.requestSetupReroll('p_0', 1).error, ERR.RATE);
|
||||
h.sched.advance(300);
|
||||
assert.deepEqual(m.requestSetupReroll('p_0', 1), { ok: true });
|
||||
assert.equal(approve(m, 'p_1', oldVote).error, ERR.BAD_TARGET);
|
||||
assert.equal(m.cancelSetupReroll('p_0', oldVote).error, ERR.BAD_TARGET);
|
||||
assert.equal(m.setupVote.agreed.size, 1);
|
||||
});
|
||||
|
||||
test('solo and one human with bots refresh immediately without a forced timer; fixed rules remain fixed', (t) => {
|
||||
for (const mode of ['solo', 'coop']) {
|
||||
const h = makeMatch({ mode, humans: 1, bots: 2, difficulty: 'FUNNY' }).start();
|
||||
t.after(() => h.m.dispose());
|
||||
const m = h.m;
|
||||
const stage = m.stageId;
|
||||
const staticOff = [...m.staticInactiveBonds];
|
||||
assert.deepEqual(m.requestSetupReroll('p_0', 0), { ok: true });
|
||||
assert.equal(m.stageId, stage);
|
||||
assert.deepEqual(m.staticInactiveBonds, staticOff);
|
||||
assert.equal(m.deadline, 0);
|
||||
assert.equal(m.setupRevision, 1);
|
||||
assert.equal(m.setupVote, null);
|
||||
assert.equal(h.ps('p_0').infoReady, false);
|
||||
assert.ok(m.order.filter((p) => p.isBot).every((p) => p.infoReady));
|
||||
m.handle('p_0', { t: 'g.infoReady', setupRevision: 1 });
|
||||
h.sched.advance(0);
|
||||
assert.equal(m.phase, PHASE.BAND_DRAFT);
|
||||
assert.equal(m.requestSetupReroll('p_0', 1).error, ERR.WRONG_PHASE);
|
||||
}
|
||||
});
|
||||
|
||||
test('disconnect and leave cancel rather than count as consent; reconnect sees current vote/setup', (t) => {
|
||||
const h = makeMatch({ humans: 3 }).start();
|
||||
t.after(() => h.m.dispose());
|
||||
const m = h.m;
|
||||
const before = snapshot(m);
|
||||
m.requestSetupReroll('p_0', 0);
|
||||
const id = m.setupVote.id;
|
||||
m.onReconnect('p_1');
|
||||
assert.equal(h.lastTo('p_1', 'm.public').rerollVote.id, id);
|
||||
m.onDisconnect('p_1');
|
||||
assert.equal(m.setupVote, null);
|
||||
assert.deepEqual(snapshot(m), before);
|
||||
h.sched.advance(300);
|
||||
assert.equal(m.requestSetupReroll('p_0', 0).error, ERR.NOT_READY);
|
||||
m.onReconnect('p_1');
|
||||
assert.equal(h.lastTo('p_1', 'm.public').rerollVote, null);
|
||||
m.requestSetupReroll('p_0', 0);
|
||||
m.onLeave('p_1');
|
||||
assert.equal(m.setupVote, null);
|
||||
assert.equal(m.setupRevision, 0);
|
||||
h.sched.advance(300);
|
||||
m.requestSetupReroll('p_0', 0);
|
||||
assert.deepEqual(m.setupVote.voters, ['p_0', 'p_2']);
|
||||
approve(m, 'p_2');
|
||||
assert.equal(m.setupRevision, 1);
|
||||
});
|
||||
|
||||
test('autoplay humans still explicitly consent; dispose stops a paused vote and invalid actors are refused', (t) => {
|
||||
const h = makeMatch({ humans: 2, bots: 1 }).start();
|
||||
t.after(() => h.m.dispose());
|
||||
const m = h.m;
|
||||
m.handle('p_1', { t: 'g.autoplay', on: true });
|
||||
assert.equal(m.requestSetupReroll('ai_0', 0).error, ERR.NOT_IN_ROOM);
|
||||
assert.equal(m.requestSetupReroll('stranger', 0).error, ERR.NOT_IN_ROOM);
|
||||
m.requestSetupReroll('p_0', 0);
|
||||
assert.equal(m.setupVote.agreed.size, 1);
|
||||
h.sched.advance(30000);
|
||||
assert.equal(m.setupRevision, 0);
|
||||
assert.equal(m.phase, PHASE.INFO_CHECK);
|
||||
m.dispose();
|
||||
assert.equal(m.requestSetupReroll('p_0', 0).error, ERR.WRONG_PHASE);
|
||||
h.sched.advance(30000);
|
||||
assert.equal(m.setupRevision, 0);
|
||||
});
|
||||
|
||||
test('failed DIY stock preparation leaves every opening field and player stock intact', (t) => {
|
||||
const h = makeMatch({ humans: 2, difficulty: 'HARD' }).start();
|
||||
t.after(() => h.m.dispose());
|
||||
const m = h.m, before = snapshot(m), pool = m.pool, stage = m.stage;
|
||||
const stocks = m.order.map((ps) => ps.diyStock), banned = m.order.map((ps) => ps.diyBanned);
|
||||
h.sched.advance(1000);
|
||||
const remaining = m.deadline - h.sched.now();
|
||||
m.requestSetupReroll('p_0', 0);
|
||||
const original = h.ps('p_1').buildDiyStock;
|
||||
h.ps('p_1').buildDiyStock = () => { throw Error('stock preparation failed'); };
|
||||
assert.equal(approve(m, 'p_1').error, ERR.INTERNAL);
|
||||
h.ps('p_1').buildDiyStock = original;
|
||||
assert.deepEqual(snapshot(m), before); assert.equal(m.pool, pool); assert.equal(m.stage, stage);
|
||||
for (const [i, ps] of m.order.entries()) { assert.equal(ps.diyStock, stocks[i]); assert.equal(ps.diyBanned, banned[i]); }
|
||||
assert.equal(m.setupRevision, 0); assert.equal(m.setupVote, null);
|
||||
assert.equal(m.deadline - h.sched.now(), remaining);
|
||||
assert.equal(h.logs.error.length, 1, 'failure is reported once');
|
||||
});
|
||||
@@ -50,7 +50,7 @@ const M = {
|
||||
audio: {
|
||||
bgm: { prep: { loop: '/bgm/prep.mp3' }, lobby: { intro: '/bgm/i.mp3', loop: '/bgm/l.mp3' } },
|
||||
bossBgm: { boss_1: { loop: '/bgm/b1.mp3' } },
|
||||
sfx: { ui: { buy: '/sfx/buy.mp3' }, battle: { deploy: '/sfx/dep.mp3' }, units: { char_010_chen: { attack: '/sfx/c_atk.mp3', skills: { 2: '/sfx/c_s3.mp3' }, skill: '/sfx/c_s.mp3' } } },
|
||||
sfx: { ui: { buy: '/sfx/buy.mp3' }, battle: { deploy: '/sfx/dep.mp3' }, units: { char_010_chen: { attack: '/sfx/c_atk.mp3', hit: '/sfx/c_hit.mp3', skills: { 2: '/sfx/c_s3.mp3' }, skill: '/sfx/c_s.mp3', attacks: { 2: '/sfx/c_atk_s3.mp3' }, hits: { 2: '/sfx/c_hit_s3.mp3' } } } },
|
||||
},
|
||||
};
|
||||
|
||||
@@ -134,6 +134,12 @@ describe('URL helpers (synthetic manifest)', () => {
|
||||
assert.equal(unitSfxUrl(M, 'char_010_chen', 'skill', 2), '/sfx/c_s3.mp3');
|
||||
assert.equal(unitSfxUrl(M, 'char_010_chen', 'skill', 0), '/sfx/c_s.mp3');
|
||||
assert.equal(unitSfxUrl(M, 'char_010_chen_2', 'attack'), '/sfx/c_atk.mp3');
|
||||
// the running skill's own attack sound (sfx.units[id].attacks, tools/assets/audio.mjs pickModeAttacks)
|
||||
assert.equal(unitSfxUrl(M, 'char_010_chen', 'attack', 2), '/sfx/c_atk_s3.mp3');
|
||||
assert.equal(unitSfxUrl(M, 'char_010_chen', 'attack', 0), '/sfx/c_atk.mp3', 'no attacks[0] ⇒ the normal bank');
|
||||
assert.equal(unitSfxUrl(M, 'char_010_chen', 'hit', 2), '/sfx/c_hit_s3.mp3', 'the running skill\'s own impact');
|
||||
assert.equal(unitSfxUrl(M, 'char_010_chen', 'hit', 1), '/sfx/c_hit.mp3', 'no hits[1] ⇒ the normal impact');
|
||||
assert.equal(unitSfxUrl(M, 'char_010_chen', 'die', 2), null, 'only an attack / an impact has a per-skill file');
|
||||
assert.equal(unitSfxUrl(M, 'nobody', 'attack'), null);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { test, before, after } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { installFakePixi, fakeViewCtx } from './fakepixi.js';
|
||||
import { presetCamera } from '../../public/js/render/projection.js';
|
||||
let fake, UnitView;
|
||||
before(async () => { fake = installFakePixi(); ({ UnitView } = await import('../../public/js/render/units.js')); });
|
||||
after(() => fake.restore());
|
||||
function rig(rate = 1) {
|
||||
let resolve;
|
||||
const entry = { skel: '/op.skel', atlas: '/op.atlas', textures: ['/op.png'], anims: { idle: 'Idle', deploy: 'Start', die: 'Die', attack: { loop: 'Attack' } }, animations: { Idle: 1, Start: 1, Die: 1, Attack: 1 } };
|
||||
const assets = { spineEntry: () => entry, hasBack: () => false, spine: { acquire: () => new Promise((r) => { resolve = r; }), release() {} } };
|
||||
const cam = presetCamera('prep', { width: 1280, height: 720 });
|
||||
const ctx = fakeViewCtx(fake.P, { assets, cam: () => cam }); ctx.animRate = () => rate;
|
||||
const v = new UnitView(ctx, { id: 1, side: 'ally', kind: 'chess', defId: 'char_op', x: 5, y: 12, maxHp: 1000, dir: 'RIGHT' }, { prep: true });
|
||||
return { v, advance: (dt) => v.update(dt, cam, dt), ready: async () => { await new Promise((r) => setImmediate(r)); resolve({ animations: [{ name: 'Idle' }, { name: 'Start' }, { name: 'Die' }, { name: 'Attack' }] }); await new Promise((r) => setImmediate(r)); } };
|
||||
}
|
||||
test('late model continues the current deployment at elapsed game time', async () => {
|
||||
const h = rig(2); h.v.onDeploy(); h.advance(0.1); await h.ready();
|
||||
assert.equal(h.v.actor.mode, 'deploy'); assert.ok(Math.abs(h.v.actor.deployElapsed() - 0.2) < 1e-6); h.v.destroy();
|
||||
});
|
||||
test('model arriving after Start duration does not replay the old entrance', async () => {
|
||||
const h = rig(); h.v.onDeploy(); h.advance(2); await h.ready();
|
||||
assert.equal(h.v.actor.mode, 'base'); assert.equal(h.v._pendingDeployElapsed, null); h.v.destroy();
|
||||
});
|
||||
test('an attack or death supersedes the pending entrance', async () => {
|
||||
for (const action of ['attack', 'die']) {
|
||||
const h = rig(); h.v.onDeploy();
|
||||
if (action === 'attack') h.v.onAttack(null, 1, 'none'); else h.v.die();
|
||||
await h.ready(); assert.notEqual(h.v.actor.mode, 'deploy'); assert.equal(h.v._pendingDeployElapsed, null); h.v.destroy();
|
||||
}
|
||||
});
|
||||
test('a destroyed view never consumes a late model as a fresh entrance', async () => {
|
||||
const h = rig(); h.v.onDeploy(); h.v.destroy(); await h.ready(); assert.equal(h.v.actor, null);
|
||||
});
|
||||
+143
-1
@@ -367,7 +367,7 @@ describe('operator battle voice', () => {
|
||||
assert.equal(a.voiceLang, 'cn', 'no other dub: anything but jp is 中文');
|
||||
// the settings store hands the choice over (installAudio and ui/settings.js)
|
||||
const settings = readFileSync(path.join(ROOT, 'public/js/ui/settings.js'), 'utf8');
|
||||
assert.match(settings, /audio\.setVoiceLang\(s\.voiceLang\)/);
|
||||
assert.match(settings, /audio\.setVoiceLang\(s\.voiceLang, s\.voiceOverrides\)/);
|
||||
assert.match(settings, /t\('语音语言'\)/);
|
||||
} finally {
|
||||
globalThis.fetch = origFetch;
|
||||
@@ -870,6 +870,148 @@ describe('impact sounds (user playtest #4 item 6)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// 司霆惊蛰's S3 attack had no sound: she is 解放者 (she only attacks while a skill runs) and the official client has no
|
||||
// normal-mode bank of hers at all — every attack of hers is a skill mode's (`attacks`, tools/assets/audio.mjs
|
||||
// pickModeAttacks). 赤刃明霄陈's S3 slashes are the other half of the same idea: the mode's IMPACT differs too (`hits`,
|
||||
// pickModeHits) — without it her S3 still landed with p_imp_hljdswd_n, i.e. it sounded like her normal attack. Both
|
||||
// tables play only while that skill is active (the ['skill', id, 1] / 0 events).
|
||||
describe('the running skill\'s own attack / impact sound (sfx.units[id].attacks / hits)', () => {
|
||||
const LEIZI2 = 'char_1043_leizi2';
|
||||
const CHEN3 = 'char_1050_chen3';
|
||||
const enemyId = () => Object.keys(manifest.audio.sfx.units).find((k) => k.startsWith('enemy_') && manifest.audio.sfx.units[k].attack);
|
||||
async function rig(units) {
|
||||
const fw = fakeWindow();
|
||||
const urls = [];
|
||||
const origFetch = globalThis.fetch;
|
||||
globalThis.fetch = async (u) => { urls.push(u); return { ok: true, arrayBuffer: async () => new ArrayBuffer(8) }; };
|
||||
const a = new AudioManager({ win: fw.win, getManifest: () => manifest });
|
||||
a.install();
|
||||
fw.fire('pointerdown');
|
||||
a.setFieldUnits(units);
|
||||
const settle = () => new Promise((r) => setTimeout(r, 5));
|
||||
return { a, urls, settle, restore: () => { globalThis.fetch = origFetch; } };
|
||||
}
|
||||
const clear = (a) => { a.limiter.lastByUnit.clear(); a.limiter.lastByUrl.clear(); };
|
||||
// …and forget the decoded buffers too: these assertions count REQUESTS, and a sound the manager already holds is
|
||||
// played from the cache without a second fetch (a real battle hears it either way).
|
||||
const fresh = (a) => { clear(a); a.buffers.clear(); };
|
||||
/** One attack plus the impact that belongs to it (the dmg inside IMPACT_WINDOW_MS). */
|
||||
const swingAndHit = (a) => a.handleBattleEvents([['atk', 1, 2, 'none'], ['dmg', 2, 120, 'phys']]);
|
||||
|
||||
|
||||
test('司霆惊蛰 S3: her attack rings the S3 mode\'s file while the skill runs, and only then', async () => {
|
||||
const u = manifest.audio.sfx.units[LEIZI2];
|
||||
assert.equal(u.attack, undefined, '前提:她没有普通形态的攻击音(这正是那次「没有音效」)');
|
||||
assert.ok(u.attacks['2'] && /_s\.mp3$/.test(u.attacks['2']), '前提:清单带 S3 形态的攻击音');
|
||||
const { a, urls, settle, restore } = await rig([
|
||||
{ id: 1, side: 'ally', kind: 'chess', spine: LEIZI2, skillIndex: 2 }, { id: 2, side: 'enemy', kind: 'enemy', spine: enemyId() },
|
||||
]);
|
||||
try {
|
||||
// 技能没开(解放者本就不攻击,模拟里也不会发 atk;真发了也仍然是静的)
|
||||
a.handleBattleEvents([['atk', 1, 2, 'none']]);
|
||||
await settle();
|
||||
assert.equal(askedCount(urls, u.attacks['2']), 0, '没有技能就没有她的攻击音');
|
||||
// S3 开启 → ['skill', id, 1]
|
||||
a.handleBattleEvents([['skill', 1, 1]]);
|
||||
await settle();
|
||||
const before = askedCount(urls, u.attacks['2']);
|
||||
a.handleBattleEvents([['atk', 1, 2, 'none']]);
|
||||
await settle();
|
||||
assert.equal(askedCount(urls, u.attacks['2']), before + 1, 'S3 期间她的攻击响 S3 的形态音');
|
||||
assert.ok(!asked(urls, u.skill), '技能发动音是另一条事件(这次没发),别混进攻击音');
|
||||
// 重复的 atk 会被同键冷却挡(既有口径,这里确认它没被新分支绕过)
|
||||
a.handleBattleEvents([['atk', 1, 2, 'none']]);
|
||||
await settle();
|
||||
assert.equal(askedCount(urls, u.attacks['2']), before + 1, '同一单位 160 ms 内只响一次');
|
||||
// S3 结束 → ['skill', id, 0]
|
||||
clear(a);
|
||||
a.handleBattleEvents([['skill', 1, 0]]);
|
||||
a.handleBattleEvents([['atk', 1, 2, 'none']]);
|
||||
await settle();
|
||||
assert.equal(askedCount(urls, u.attacks['2']), before + 1, '技能结束后又静音');
|
||||
} finally { restore(); }
|
||||
});
|
||||
|
||||
test('a unit with both banks: the mode file inside the skill, its normal attack outside it', async () => {
|
||||
const id = Object.keys(manifest.audio.sfx.units).find((k) => k.startsWith('char_') && manifest.audio.sfx.units[k].attack && manifest.audio.sfx.units[k].attacks?.['2']);
|
||||
const u = manifest.audio.sfx.units[id];
|
||||
const { a, urls, settle, restore } = await rig([
|
||||
{ id: 1, side: 'ally', kind: 'chess', spine: id, skillIndex: 2 }, { id: 2, side: 'enemy', kind: 'enemy', spine: enemyId() },
|
||||
]);
|
||||
try {
|
||||
a.handleBattleEvents([['atk', 1, 2, 'none']]);
|
||||
await settle();
|
||||
assert.equal(askedCount(urls, u.attack), 1, `${id}: 技能外是普通攻击音`);
|
||||
assert.equal(askedCount(urls, u.attacks['2']), 0, `${id}: 技能外不响形态音`);
|
||||
clear(a);
|
||||
a.handleBattleEvents([['skill', 1, 1]]);
|
||||
a.handleBattleEvents([['atk', 1, 2, 'none']]);
|
||||
await settle();
|
||||
assert.equal(askedCount(urls, u.attacks['2']), 1, `${id}: 技能期间响形态音`);
|
||||
assert.equal(askedCount(urls, u.attack), 1, `${id}: 形态音取代、而不是叠加普通攻击音`);
|
||||
} finally { restore(); }
|
||||
});
|
||||
|
||||
test('a unit whose manifest has no attacks table for that slot keeps its normal bank (and no crash)', async () => {
|
||||
const id = Object.keys(manifest.audio.sfx.units).find((k) => k.startsWith('char_') && manifest.audio.sfx.units[k].attack && !manifest.audio.sfx.units[k].attacks?.['2']);
|
||||
const u = manifest.audio.sfx.units[id];
|
||||
const { a, urls, settle, restore } = await rig([
|
||||
{ id: 1, side: 'ally', kind: 'chess', spine: id, skillIndex: 2 }, { id: 2, side: 'enemy', kind: 'enemy', spine: enemyId() },
|
||||
]);
|
||||
try {
|
||||
a.handleBattleEvents([['skill', 1, 1]]);
|
||||
a.handleBattleEvents([['atk', 1, 2, 'none']]);
|
||||
await settle();
|
||||
assert.equal(askedCount(urls, u.attack), 1, `${id}: 没有形态音就用普通攻击音`);
|
||||
} finally { restore(); }
|
||||
});
|
||||
|
||||
test('赤刃明霄陈 S3: 挥砍与斩击都用 S3 的 `_s` 对,技能外回到普通 `_n` 对', async () => {
|
||||
const u = manifest.audio.sfx.units[CHEN3];
|
||||
assert.ok(u.attacks['2'] && u.hits['2'] && /_s\.mp3$/.test(u.attacks['2']) && /_s\.mp3$/.test(u.hits['2']), '前提:清单带 S3 的挥砍与斩击');
|
||||
const { a, urls, settle, restore } = await rig([
|
||||
{ id: 1, side: 'ally', kind: 'chess', spine: CHEN3, skillIndex: 2 }, { id: 2, side: 'enemy', kind: 'enemy', spine: enemyId() },
|
||||
]);
|
||||
try {
|
||||
fresh(a);
|
||||
swingAndHit(a);
|
||||
await settle();
|
||||
assert.equal(askedCount(urls, u.attack), 1, '技能外:普通挥砍');
|
||||
assert.equal(askedCount(urls, u.hit), 1, '技能外:普通命中');
|
||||
fresh(a);
|
||||
a.handleBattleEvents([['skill', 1, 1]]); // S3 开启
|
||||
swingAndHit(a);
|
||||
await settle();
|
||||
assert.equal(askedCount(urls, u.attacks['2']), 1, 'S3:挥砍换成 S3 的');
|
||||
assert.equal(askedCount(urls, u.hits['2']), 1, 'S3:斩击(命中)也换成 S3 的');
|
||||
assert.equal(askedCount(urls, u.attack), 1, 'S3:没有叠加普通挥砍');
|
||||
assert.equal(askedCount(urls, u.hit), 1, 'S3:没有叠加普通命中');
|
||||
fresh(a);
|
||||
a.handleBattleEvents([['skill', 1, 0]]); // S3 结束
|
||||
swingAndHit(a);
|
||||
await settle();
|
||||
assert.equal(askedCount(urls, u.attack), 2, '技能结束:普通挥砍回来了');
|
||||
assert.equal(askedCount(urls, u.hit), 2, '技能结束:普通命中回来了');
|
||||
assert.equal(askedCount(urls, u.hits['2']), 1, '技能结束:不再用 S3 的斩击');
|
||||
} finally { restore(); }
|
||||
});
|
||||
|
||||
test('a mode without a hits entry keeps the unit\'s normal impact while the skill runs', async () => {
|
||||
const u = manifest.audio.sfx.units[LEIZI2];
|
||||
assert.ok(u.attacks['2'] && !u.hits?.['2'], '前提:她的 S3 有挥砍、没有命中 bank(官方就没有)');
|
||||
const { a, urls, settle, restore } = await rig([
|
||||
{ id: 1, side: 'ally', kind: 'chess', spine: LEIZI2, skillIndex: 2 }, { id: 2, side: 'enemy', kind: 'enemy', spine: enemyId() },
|
||||
]);
|
||||
try {
|
||||
a.handleBattleEvents([['skill', 1, 1]]);
|
||||
swingAndHit(a);
|
||||
await settle();
|
||||
assert.equal(askedCount(urls, u.attacks['2']), 1, 'S3 的挥砍');
|
||||
assert.equal(askedCount(urls, u.hit), 1, '命中仍是普通的那条');
|
||||
} finally { restore(); }
|
||||
});
|
||||
});
|
||||
|
||||
// =====================================================================================================================
|
||||
// 漏怪 sound (user request "接下来加漏怪的音效", then "应该是原版明日方舟关卡中的怪进蓝门的音效"). The sim emits
|
||||
// `['leak', id]` when an enemy reaches its goal (Battle.leak) — NOT a `die` — so until now an escape was completely
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
// Real browser / server checks for local identity recovery, per-operator voice, install offers and large text.
|
||||
import { test, describe, before, after } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { existsSync } from 'node:fs';
|
||||
const CHROME = process.env.CHROME_PATH || '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
|
||||
const ENABLED = process.env.SP_E2E === '1' && existsSync(CHROME);
|
||||
describe('0.2.3 follow-up client features', { skip: !ENABLED }, () => {
|
||||
let server, browser, base;
|
||||
before(async () => {
|
||||
const { startServer } = await import('../../server/index.js');
|
||||
const puppeteer = (await import('puppeteer-core')).default;
|
||||
server = await startServer({ port: 0, host: '127.0.0.1', quiet: true });
|
||||
base = `http://127.0.0.1:${server.port}`;
|
||||
browser = await puppeteer.launch({ executablePath: CHROME, headless: true, args: ['--no-sandbox'] });
|
||||
});
|
||||
after(async () => { await browser?.close(); await server?.close(); });
|
||||
async function pageIn(ctx, phone = false) {
|
||||
const page = await ctx.newPage();
|
||||
await page.setViewport({ width: phone ? 844 : 1440, height: phone ? 390 : 900 });
|
||||
await page.evaluateOnNewDocument(() => { localStorage.setItem('sp.name', 'Recovery Test'); sessionStorage.setItem('sp.entered', '1'); });
|
||||
await page.goto(base, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForFunction(() => globalThis.__SP__?.store.get().connection.status === 'online' && document.querySelector('.lobby-screen'));
|
||||
return page;
|
||||
}
|
||||
test('closed second window resumes the same server player and match through the recovery UI', async () => {
|
||||
const ctx = await browser.createBrowserContext();
|
||||
try {
|
||||
const a = await pageIn(ctx), b = await pageIn(ctx);
|
||||
const before = await b.evaluate(async () => {
|
||||
const { net, store } = globalThis.__SP__;
|
||||
await net.request('room.create', { mode: 'solo', difficulty: 'NORMAL' });
|
||||
await net.request('room.start', {});
|
||||
return { id: store.get().me.playerId, code: store.get().room.code };
|
||||
});
|
||||
await b.waitForFunction(() => globalThis.__SP__.store.get().match.public?.phase === 'INFO_CHECK');
|
||||
await a.waitForSelector('[data-testid="resume-local-match"]');
|
||||
const refused = await a.evaluate(async () => { const { identity } = await import('/js/net.js'); return identity.resume(identity.recoverable()[0].id); });
|
||||
assert.equal(refused, false, 'live window is not displaced');
|
||||
assert.equal(await b.evaluate(() => globalThis.__SP__.store.get().connection.status), 'online');
|
||||
await b.close();
|
||||
await a.click('[data-testid="resume-local-match"]');
|
||||
await a.waitForSelector('.modal .btn--block');
|
||||
await Promise.all([a.waitForNavigation({ waitUntil: 'domcontentloaded' }), a.click('.modal .btn--block')]);
|
||||
await a.waitForFunction(() => globalThis.__SP__?.store.get().match.public?.phase === 'INFO_CHECK');
|
||||
const after = await a.evaluate(() => { const s = globalThis.__SP__.store.get(); return { id: s.me.playerId, code: s.room.code }; });
|
||||
assert.deepEqual(after, before);
|
||||
} finally { await ctx.close(); }
|
||||
});
|
||||
test('co-op opening reroll shows a unanimous vote, clears stale readiness and can be rejected', { timeout: 45000 }, async () => {
|
||||
const ctx = await browser.createBrowserContext();
|
||||
try {
|
||||
const host = await pageIn(ctx), guest = await pageIn(ctx);
|
||||
const code = await host.evaluate(async () => {
|
||||
const { net, store } = globalThis.__SP__;
|
||||
await net.request('room.create', { mode: 'coop', difficulty: 'NORMAL' });
|
||||
return store.get().room.code;
|
||||
});
|
||||
await guest.evaluate(async (code) => {
|
||||
const { net } = globalThis.__SP__;
|
||||
await net.request('room.join', { code }); await net.request('room.ready', { ready: true });
|
||||
}, code);
|
||||
await host.evaluate(() => globalThis.__SP__.net.request('room.start', {}));
|
||||
for (const p of [host, guest]) await p.waitForFunction(() => globalThis.__SP__.store.get().match.public?.phase === 'INFO_CHECK');
|
||||
await guest.evaluate(() => globalThis.__SP__.net.request('g.infoReady', { setupRevision: 0 }));
|
||||
await host.waitForSelector('.brief-reroll button');
|
||||
await host.bringToFront();
|
||||
await host.click('.brief-reroll button');
|
||||
await guest.waitForSelector('.brief-reroll .btn--primary');
|
||||
assert.equal(await guest.evaluate(() => globalThis.__SP__.store.get().match.public.deadline), 0);
|
||||
await guest.bringToFront();
|
||||
await guest.click('.brief-reroll .btn--primary');
|
||||
for (const p of [host, guest]) await p.waitForFunction(() => globalThis.__SP__.store.get().match.public?.setupRevision === 1);
|
||||
const state = await guest.evaluate(() => globalThis.__SP__.store.get().match.public);
|
||||
assert.equal(state.rerollVote, null);
|
||||
assert.ok(state.players.filter((p) => !p.isBot).every((p) => !p.ready));
|
||||
await host.waitForFunction(() => !!document.querySelector('.brief-reroll button:not(:disabled)'));
|
||||
// Wait out the documented server anti-burst interval before a second request.
|
||||
await new Promise((resolve) => setTimeout(resolve, 350));
|
||||
await host.bringToFront();
|
||||
await host.click('.brief-reroll button');
|
||||
await guest.waitForSelector('.brief-reroll .btn--danger');
|
||||
await guest.bringToFront();
|
||||
await guest.click('.brief-reroll .btn--danger');
|
||||
await host.waitForFunction(() => !globalThis.__SP__.store.get().match.public.rerollVote);
|
||||
assert.equal(await host.evaluate(() => globalThis.__SP__.store.get().match.public.setupRevision), 1);
|
||||
} finally { await ctx.close(); }
|
||||
});
|
||||
test('operator voice selection persists across reload and follows global after reset', async () => {
|
||||
const ctx = await browser.createBrowserContext();
|
||||
try {
|
||||
const page = await pageIn(ctx, true);
|
||||
const open = async () => { await page.evaluate(async () => (await import('/js/ui/loadoutSync.js')).openLoadout()); await page.waitForSelector('.lo-card__pick'); await page.click('.lo-card__pick'); await page.waitForSelector('[data-voice-char] select', { visible: true }); };
|
||||
await open();
|
||||
const id = await page.$eval('[data-voice-char]', (e) => e.dataset.voiceChar);
|
||||
await page.select('[data-voice-char] select', 'jp');
|
||||
assert.equal(await page.evaluate(async (id) => (await import('/js/audio.js')).audio.voiceOverrides[id], id), 'jp');
|
||||
await page.reload({ waitUntil: 'domcontentloaded' });
|
||||
await page.waitForFunction(() => globalThis.__SP__?.store.get().connection.status === 'online');
|
||||
await open();
|
||||
assert.equal(await page.$eval('[data-voice-char] select', (e) => e.value), 'jp');
|
||||
await page.select('[data-voice-char] select', '');
|
||||
assert.equal(await page.evaluate((id) => JSON.parse(localStorage.getItem('sp.pref.settings')).voiceOverrides[id], id), undefined);
|
||||
} finally { await ctx.close(); }
|
||||
});
|
||||
test('install manifest is eligible and a dismissed offer is consumed through the actual button', async () => {
|
||||
const ctx = browser.defaultBrowserContext();
|
||||
let page;
|
||||
try {
|
||||
page = await pageIn(ctx);
|
||||
const client = await page.createCDPSession();
|
||||
const manifest = await client.send('Page.getAppManifest');
|
||||
assert.deepEqual(manifest.errors, []);
|
||||
const install = await client.send('Page.getInstallabilityErrors');
|
||||
assert.deepEqual(install.installabilityErrors, []);
|
||||
await page.evaluate(() => {
|
||||
const e = new Event('beforeinstallprompt', { cancelable: true });
|
||||
window.promptCalls = 0;
|
||||
e.prompt = async () => { window.promptCalls++; return { outcome: 'dismissed' }; };
|
||||
window.dispatchEvent(e);
|
||||
});
|
||||
await page.waitForSelector('[data-testid="pwa-install"]');
|
||||
await page.click('[data-testid="pwa-install"]');
|
||||
await page.waitForFunction(() => !document.querySelector('[data-testid="pwa-install"]'));
|
||||
assert.equal(await page.evaluate(() => window.promptCalls), 1);
|
||||
} finally { await page?.close(); }
|
||||
});
|
||||
test('extra-large settings stay within a landscape phone in every shipped interface language', async () => {
|
||||
const ctx = await browser.createBrowserContext();
|
||||
try {
|
||||
const page = await pageIn(ctx, true);
|
||||
for (const lang of ['zh-CN', 'en', 'ja', 'ko', 'zh-TW']) {
|
||||
await page.evaluate(async (lang) => {
|
||||
await (await import('/js/ui/lang.js')).switchLang(lang);
|
||||
(await import('/js/ui/settings.js')).updateSettings({ textSize: 'xl' });
|
||||
}, lang);
|
||||
await page.click('[data-testid="settings-btn"]');
|
||||
await page.waitForSelector('.modal .set-list');
|
||||
const overflow = await page.$eval('.modal__body', (e) => e.scrollWidth - e.clientWidth);
|
||||
const excess = await page.$eval('.modal__body', (e) => { const right = e.getBoundingClientRect().right; return [...e.querySelectorAll('*')].filter((x) => x.getBoundingClientRect().right > right + 1).map((x) => [x.className, x.textContent.slice(0, 50), Math.round(x.getBoundingClientRect().width)]); });
|
||||
assert.ok(overflow <= 1, `${lang}: settings overflow ${overflow} ${JSON.stringify(excess)}`);
|
||||
await page.keyboard.press('Escape');
|
||||
await page.waitForFunction(() => !document.querySelector('.modal .set-list'));
|
||||
}
|
||||
} finally { await ctx.close(); }
|
||||
});
|
||||
});
|
||||
@@ -723,7 +723,7 @@ describe('keyboard & settings', () => {
|
||||
test('sanitizeSettings', () => {
|
||||
assert.deepEqual(sanitizeSettings(null), { ...DEFAULT_SETTINGS });
|
||||
assert.deepEqual(sanitizeSettings({ bgm: 3, sfx: -1, voice: 2, muted: 'yes', damageNumbers: false, quality: 'ultra' }),
|
||||
{ bgm: 1, sfx: 0, voice: 1, voiceLang: 'cn', muted: false, damageNumbers: false, quality: 'high', keys: { ...DEFAULT_SETTINGS.keys } },
|
||||
{ bgm: 1, sfx: 0, voice: 1, voiceLang: 'cn', voiceOverrides: {}, muted: false, damageNumbers: false, quality: 'high', textSize: 'sm', keys: { ...DEFAULT_SETTINGS.keys } },
|
||||
'a saved profile without `keys` (before 0.2.0) gets the default key map (test/ui/feedback5-hotkeys.test.js)');
|
||||
assert.equal(sanitizeSettings({ bgm: 0.5 }).voice, DEFAULT_SETTINGS.voice, 'a saved profile without `voice` gets the default');
|
||||
// 语音语言 (0.2.2): 中文 by default — a profile saved before it, or any other value, plays the Chinese dub
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { createPwaInstall } from '../../public/js/pwa.js';
|
||||
function rig(standalone = false) {
|
||||
const win = new EventTarget(), display = new EventTarget();
|
||||
display.matches = standalone;
|
||||
win.matchMedia = () => display;
|
||||
const pwa = createPwaInstall(win);
|
||||
const offer = (outcome = 'dismissed', fail = false) => {
|
||||
let calls = 0;
|
||||
const e = new Event('beforeinstallprompt', { cancelable: true });
|
||||
e.prompt = async () => { calls++; if (fail) throw Error('unavailable'); return { outcome }; };
|
||||
win.dispatchEvent(e);
|
||||
return { calls: () => calls, e };
|
||||
};
|
||||
return { pwa, win, display, offer };
|
||||
}
|
||||
test('install offer is consumed even on dismissal, duplicate clicks, or rejected prompt', async () => {
|
||||
const { pwa, offer } = rig();
|
||||
const states = []; const off = pwa.subscribe((x) => states.push(x));
|
||||
assert.equal(pwa.available(), false);
|
||||
const first = offer();
|
||||
assert.equal(first.e.defaultPrevented, true);
|
||||
assert.equal(pwa.available(), true);
|
||||
const a = pwa.request(), b = pwa.request();
|
||||
assert.equal(await a, false); assert.equal(await b, false); assert.equal(first.calls(), 1);
|
||||
const broken = offer('accepted', true);
|
||||
assert.equal(await pwa.request(), false); assert.equal(await pwa.request(), false); assert.equal(broken.calls(), 1);
|
||||
const next = offer('accepted');
|
||||
assert.equal(await pwa.request(), true); assert.equal(next.calls(), 1);
|
||||
assert.deepEqual(states, [false, true, false, true, false, true, false]);
|
||||
off(); pwa.dispose();
|
||||
});
|
||||
test('installed / standalone hides stale offers; disposing removes listeners', async () => {
|
||||
const { pwa, win, display, offer } = rig();
|
||||
offer(); display.matches = true; display.dispatchEvent(new Event('change'));
|
||||
assert.equal(pwa.available(), false); assert.equal(await pwa.request(), false);
|
||||
display.matches = false; win.dispatchEvent(new Event('appinstalled')); offer();
|
||||
assert.equal(pwa.available(), false);
|
||||
pwa.dispose();
|
||||
assert.equal(offer().e.defaultPrevented, false);
|
||||
const native = rig(true); native.offer(); assert.equal(native.pwa.available(), false); native.pwa.dispose();
|
||||
});
|
||||
test('manifest uses actual 192/512 icons shipped outside optional game assets', () => {
|
||||
const m = JSON.parse(readFileSync(new URL('../../public/manifest.json', import.meta.url)));
|
||||
assert.equal(m.start_url, '/'); assert.equal(m.scope, '/');
|
||||
for (const icon of m.icons) {
|
||||
assert.ok(icon.src.startsWith('/icons/'));
|
||||
const bytes = readFileSync(new URL('../../public' + icon.src, import.meta.url));
|
||||
const size = Number(icon.sizes.split('x')[0]);
|
||||
assert.equal(bytes.readUInt32BE(16), size); assert.equal(bytes.readUInt32BE(20), size);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { AudioManager } from '../../public/js/audio.js';
|
||||
import { indexAudio, pickModeAttacks } from '../../tools/assets/audio.mjs';
|
||||
import { buildPlan } from '../../tools/assets/plan.mjs';
|
||||
|
||||
const id = 'char_1043_leizi2';
|
||||
function rig() {
|
||||
const entry = { attack: '/normal_n.mp3', hit: '/normal_hit_n.mp3', attacks: { 2: '/mode_s.mp3' }, hits: { 2: '/impact_s.mp3' }, mix: { attack: { p: 0 }, hit: { vol: 0.1 } }, attackMix: { 2: { vol: 0.6 } } };
|
||||
const a = new AudioManager({ getManifest: () => ({ audio: { sfx: { units: { [id]: entry } } } }), random: () => 0.5 });
|
||||
a.ctx = {};
|
||||
const played = [];
|
||||
a._play = (url, opts) => played.push({ url, ...opts });
|
||||
const units = [{ id: 1, spine: id, side: 'ally', kind: 'chess', skillIndex: 2 }, { id: 2, spine: 'enemy_1_test', side: 'enemy' }];
|
||||
a.setFieldUnits(units);
|
||||
return { a, entry, played, units };
|
||||
}
|
||||
|
||||
test('mode uses its own chance and volume, never the normal bank mix', () => {
|
||||
const { a, entry, played } = rig();
|
||||
a.unit(id, 'attack', 1, 2, true);
|
||||
a.unit(id, 'hit', 1, 2, true);
|
||||
assert.deepEqual(played.map((p) => [p.url, p.volume]), [['/mode_s.mp3', 0.33], ['/impact_s.mp3', 0.55]]);
|
||||
entry.attackMix[2].p = 0;
|
||||
a.unit(id, 'attack', 1, 2, true);
|
||||
assert.equal(played.length, 2, 'silent mode does not fall back to normal');
|
||||
});
|
||||
|
||||
test('an in-flight impact remembers the mode at attack time after skill end', () => {
|
||||
const { a, played } = rig();
|
||||
a.handleBattleEvents([['skill', 1, 1], ['atk', 1, 2, 'none'], ['skill', 1, 0], ['dmg', 2, 10, 'phys']]);
|
||||
assert.deepEqual(played.map((p) => p.url), ['/mode_s.mp3', '/impact_s.mp3']);
|
||||
assert.equal(a.units.get(1).skillActive, false);
|
||||
});
|
||||
|
||||
test('end before spawn cancels a queued start; field changes discard stale modes', () => {
|
||||
const { a, played, units } = rig();
|
||||
a.setFieldUnits([]);
|
||||
a.handleBattleEvents([['skill', 1, 1], ['skill', 1, 0], ['spawn', units[0]], ['atk', 1, 2, 'none']]);
|
||||
assert.equal(a.pendingSkill.size, 0);
|
||||
assert.equal(a.units.get(1).skillActive, false);
|
||||
assert.equal(played.length, 0);
|
||||
a.setFieldUnits([{ ...units[0], skillActive: true }, units[1]]);
|
||||
a.handleBattleEvents([['atk', 1, 2, 'none']]);
|
||||
assert.equal(played.at(-1).url, '/mode_s.mp3', 'late join snapshot restores active mode');
|
||||
a.setFieldUnits(units);
|
||||
assert.equal(a.lastAttacker.size, 0);
|
||||
assert.equal(a.units.get(1).skillActive, false);
|
||||
});
|
||||
|
||||
test('mixed mode banks are not guessed; the selected official bank retains its mix in the plan', () => {
|
||||
assert.deepEqual(pickModeAttacks(new Map([['ON_ABILITY_START.attack', ['/a_s.mp3', '/b_h.mp3']]])), {});
|
||||
const audio = indexAudio({ soundFXBanks: [{ name: `battle.ON_ABILITY_START.${id}.attack.7`, sounds: [
|
||||
{ asset: 'Audio/Sound_Beta_2/Player/p_atk/test_s', weight: 1, minVolume: 0.6, maxVolume: 0.6 },
|
||||
{ asset: '', weight: 1 },
|
||||
] }] });
|
||||
const { template } = buildPlan({ assets07: {}, ops03: {}, extraOperators: { [id]: { skills: [{ index: 2, skillId: 'test' }] } }, enemies05: {}, maps05: {}, audio, modelsData: {} });
|
||||
assert.deepEqual(template.audio.sfx.units[id].attackMix, { 2: { p: 0.5, vol: 0.6 } });
|
||||
});
|
||||
@@ -0,0 +1,172 @@
|
||||
// Browser check of 设置 →「文字大小」 (opt-in, needs Chrome, ~30 s):
|
||||
// SP_E2E=1 CHROME_PATH=… node --test test/ui/text-scale.e2e.test.js
|
||||
//
|
||||
// A 844×390 landscape phone: 1rem is clamped at 40 px, so the .18rem body text is 7.2 CSS px. The setting steps the text
|
||||
// root `--t` (css/theme.css) instead of the layout root: the readable text grows, while `documentElement`'s font-size,
|
||||
// the modal's rem-sized box and — through the dev mock's 休整期 screen — the field host, the canvas and the shop bar stay
|
||||
// exactly where they were. Static counterpart: test/ui/text-scale.test.js.
|
||||
|
||||
import { test, describe, before, after } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { existsSync } from 'node:fs';
|
||||
|
||||
const CHROME = process.env.CHROME_PATH || '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
|
||||
const ENABLED = process.env.SP_E2E === '1' && existsSync(CHROME);
|
||||
const PHONE = { width: 844, height: 390 };
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
/** The settings the mock harness reads: `sp.pref.settings` with one text step (the argument is passed into the page —
|
||||
* evaluateOnNewDocument cannot see a closure). */
|
||||
const seedSettings = (page, textSize) => page.evaluateOnNewDocument((ts) => {
|
||||
try { localStorage.setItem('sp.pref.settings', JSON.stringify({ textSize: ts })); } catch { /* ignore */ }
|
||||
}, textSize);
|
||||
|
||||
describe('文字大小 on a phone (headless Chrome)', { skip: !ENABLED && 'set SP_E2E=1 (and have Chrome) to run' }, () => {
|
||||
let srv;
|
||||
let browser;
|
||||
let base;
|
||||
|
||||
before(async () => {
|
||||
const { startServer } = await import('../../server/index.js');
|
||||
const puppeteer = (await import('puppeteer-core')).default;
|
||||
srv = await startServer({ port: 0, host: '127.0.0.1', quiet: true });
|
||||
base = `http://127.0.0.1:${srv.port}`;
|
||||
browser = await puppeteer.launch({ executablePath: CHROME, headless: true, args: ['--no-sandbox', '--force-device-scale-factor=1'] });
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await browser?.close();
|
||||
await srv?.close();
|
||||
});
|
||||
|
||||
/** A page watching for console / page errors, with `sp.pref.settings` seeded before the first script runs. */
|
||||
async function open(url, textSize, { seed = true } = {}) {
|
||||
const page = await browser.newPage();
|
||||
await page.setViewport(PHONE);
|
||||
const problems = [];
|
||||
page.on('console', (m) => { if (m.type() === 'error') problems.push(`console: ${m.text()}`); });
|
||||
page.on('pageerror', (e) => problems.push(`pageerror: ${e.message}`));
|
||||
if (seed && textSize) await seedSettings(page, textSize);
|
||||
await page.goto(url, { waitUntil: 'networkidle0', timeout: 60000 });
|
||||
return { page, problems };
|
||||
}
|
||||
|
||||
/** What the settings modal shows at this step: the roots, a readable label, its Latin micro label, the box. */
|
||||
const modalMetrics = (page) => page.evaluate(() => {
|
||||
const px = (sel, prop = 'fontSize') => {
|
||||
const el = document.querySelector(sel);
|
||||
return el ? parseFloat(getComputedStyle(el)[prop]) : null;
|
||||
};
|
||||
const box = document.querySelector('.modal__box')?.getBoundingClientRect();
|
||||
const body = document.querySelector('.modal__body');
|
||||
const on = document.querySelector('[data-testid="text-size"] button.is-on');
|
||||
// nothing may stick out of the modal's box (the label wraps and the four steps fit its row)
|
||||
let outRight = 0;
|
||||
for (const el of document.querySelectorAll('.modal__box *')) {
|
||||
const r = el.getBoundingClientRect();
|
||||
if (box && r.width > 0) outRight = Math.max(outRight, r.right - box.right);
|
||||
}
|
||||
return {
|
||||
root: parseFloat(getComputedStyle(document.documentElement).fontSize),
|
||||
label: px('.set-row__label'),
|
||||
micro: px('.set-row__label .micro'),
|
||||
modalWidth: box ? Math.round(box.width) : null,
|
||||
bodyOverflowX: body ? body.scrollWidth - body.clientWidth : null,
|
||||
outRight: Math.round(outRight),
|
||||
step: on?.textContent ?? null,
|
||||
hint: document.querySelector('.set-textsize-note')?.textContent ?? null,
|
||||
};
|
||||
});
|
||||
|
||||
test('the steps grow the readable text; the layout root and the modal box do not move', async () => {
|
||||
const { page, problems } = await open(`${base}/`, null, { seed: false });
|
||||
await page.waitForSelector('input', { timeout: 15000 });
|
||||
await page.type('input', '文字大小');
|
||||
await page.evaluate(() => [...document.querySelectorAll('button')].find((b) => /开始/.test(b.textContent))?.click());
|
||||
await page.waitForSelector('.lobby-screen', { timeout: 15000 });
|
||||
await page.click('.lobby-screen [data-testid="settings-btn"]');
|
||||
await page.waitForSelector('.modal .set-list', { timeout: 5000 });
|
||||
await sleep(200);
|
||||
|
||||
const small = await modalMetrics(page);
|
||||
assert.equal(small.step, '小', 'the default is the design\'s own sizes');
|
||||
assert.equal(small.root, 40, 'a landscape phone clamps the layout root at 40 px');
|
||||
assert.ok(Math.abs(small.label - 0.18 * 40) < 0.1, `the body text starts at the reported 7.2 px (${small.label})`);
|
||||
assert.ok(small.hint && /棋盘/.test(small.hint), 'the row says the layout is not touched');
|
||||
|
||||
const step = async (name) => {
|
||||
await page.evaluate((n) => {
|
||||
const btn = [...document.querySelectorAll('[data-testid="text-size"] button')].find((b) => b.textContent.trim() === n);
|
||||
btn?.click();
|
||||
}, name);
|
||||
await sleep(200);
|
||||
return modalMetrics(page);
|
||||
};
|
||||
|
||||
const md = await step('中');
|
||||
const lg = await step('大');
|
||||
const xl = await step('特大');
|
||||
assert.equal(md.step, '中');
|
||||
assert.equal(xl.step, '特大', 'the pick sticks');
|
||||
assert.ok(md.label > small.label * 1.5 && lg.label > md.label && xl.label > lg.label,
|
||||
`the readable text grows step by step (${[small.label, md.label, lg.label, xl.label].join(' → ')})`);
|
||||
assert.ok(Math.abs(xl.label - 0.18 * 88) < 0.2, `特大 reaches the 88 px text root (${xl.label})`);
|
||||
assert.ok(xl.micro > small.micro * 1.9, `the 4.4 px micro labels follow (${small.micro} → ${xl.micro})`);
|
||||
for (const m of [md, lg, xl]) {
|
||||
assert.equal(m.root, 40, 'the layout root never moves: the field camera and the DOM board read 1rem');
|
||||
assert.equal(m.modalWidth, small.modalWidth, 'the modal\'s box is sized in rem and does not change');
|
||||
assert.ok(m.bodyOverflowX <= 1, `the rows do not overflow the modal body (${m.bodyOverflowX} px)`);
|
||||
assert.ok(m.outRight <= 1, `nothing sticks out of the modal's box (${m.outRight} px): the label wraps and the four steps fit`);
|
||||
}
|
||||
|
||||
// saved with the settings and restored on the next visit
|
||||
assert.deepEqual(await page.evaluate(() => JSON.parse(localStorage.getItem('sp.pref.settings')).textSize), 'xl');
|
||||
await page.reload({ waitUntil: 'networkidle0' });
|
||||
await page.waitForSelector('.lobby-screen', { timeout: 15000 });
|
||||
await page.click('.lobby-screen [data-testid="settings-btn"]');
|
||||
await page.waitForSelector('.modal .set-list', { timeout: 5000 });
|
||||
const again = await modalMetrics(page);
|
||||
assert.equal(again.step, '特大', 'the choice survives a reload');
|
||||
assert.equal(again.root, 40);
|
||||
assert.deepEqual(problems, []);
|
||||
await page.close();
|
||||
});
|
||||
|
||||
test('the in-match field, canvas and shop bar do not move; the settings dialog in a match grows', async () => {
|
||||
const measure = async (textSize) => {
|
||||
const { page, problems } = await open(`${base}/dev/game-mock.html?shot=1&phase=PREP&variant=settings`, textSize);
|
||||
await page.waitForFunction(() => !!document.querySelector('.screen:not(.gload)'), { timeout: 15000 });
|
||||
await sleep(900);
|
||||
const m = await page.evaluate(() => {
|
||||
const box = (sel) => {
|
||||
const el = document.querySelector(sel);
|
||||
if (!el) return null;
|
||||
const r = el.getBoundingClientRect();
|
||||
return [Math.round(r.x), Math.round(r.y), Math.round(r.width), Math.round(r.height)];
|
||||
};
|
||||
return {
|
||||
root: parseFloat(getComputedStyle(document.documentElement).fontSize),
|
||||
field: box('.gm__field'),
|
||||
canvas: box('.gm__field canvas'),
|
||||
shopbar: box('.gm__hud .shopbar'),
|
||||
label: (() => { const el = document.querySelector('.set-row__label'); return el ? parseFloat(getComputedStyle(el).fontSize) : null; })(),
|
||||
};
|
||||
});
|
||||
return { ...m, problems, page };
|
||||
};
|
||||
|
||||
const small = await measure('sm');
|
||||
const big = await measure('xl');
|
||||
assert.ok(small.field && small.shopbar, 'the prep screen with its HUD is on screen');
|
||||
assert.equal(small.root, 40);
|
||||
assert.equal(big.root, 40, 'the text step never reaches the root');
|
||||
assert.deepEqual(big.field, small.field, 'the field host (host.clientWidth / clientHeight) is unchanged');
|
||||
assert.deepEqual(big.canvas, small.canvas, 'and so is the canvas the renderer resizes to it');
|
||||
assert.deepEqual(big.shopbar, small.shopbar, 'the shop bar is a fixed HUD box: still the design\'s size');
|
||||
assert.ok(big.label > small.label * 1.9, `while the dialog's readable text grows (${small.label} → ${big.label})`);
|
||||
assert.deepEqual(small.problems, []);
|
||||
assert.deepEqual(big.problems, []);
|
||||
await small.page.close();
|
||||
await big.page.close();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,240 @@
|
||||
// 设置 →「文字大小」 (the owner's decision of 2026-10-09): the interface text root `--t` of css/theme.css, stepped by
|
||||
// the settings store (`sp.pref.settings` textSize), applied to <html data-text> by ui/settings.js applyTextSize.
|
||||
// A phone clamps the LAYOUT root `1rem` at 40 px (theme.css), which left the .18rem body text at 7.2 CSS px, while the
|
||||
// browser's own font settings only inflate glyphs inside fixed boxes and page zoom is off (index.html's viewport,
|
||||
// ui/device.js). Only font-size declarations read `--t`: the five modules that turn `1rem` into pixels — ui/fieldHost.js
|
||||
// hudPadding / hudBands (the prep camera's HUD clearance), ui/gameLogic/panel.js panelSlots (the detail card's side),
|
||||
// ui/underframe.js (the tile labels' budget) and ui/fallbackField.js (the DOM board) — must never move with it, and the
|
||||
// field itself is sized from the host element's clientWidth (render/app.js).
|
||||
//
|
||||
// What this file guards: the default is the design's own sizes; a bad or missing value falls back to 小; the step lands
|
||||
// on <html data-text>; the layout root's clamp is untouched; and every font-size that stayed on a plain `rem` is one of
|
||||
// the deliberate exclusions (the in-match HUD's fixed boxes, the board-pinned DOM text, geometry-derived sizes, the
|
||||
// ≥16 px iOS-zoom guards) — a new readable size that forgets `--t` fails here, the way `tools/i18n.mjs extract 0` fails
|
||||
// on an unwrapped string. Browser counterpart: test/ui/text-scale.e2e.test.js (SP_E2E=1).
|
||||
|
||||
import { describe, test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync, readdirSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { DEFAULT_SETTINGS, TEXT_SIZES, sanitizeSettings } from '../../public/js/ui/gameLogic.js';
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
|
||||
const read = (p) => readFileSync(path.join(ROOT, p), 'utf8');
|
||||
const CSS = 'public/css';
|
||||
|
||||
/** Every stylesheet under public/css (index.html loads all of them, devices.css last), as { rel, src }. */
|
||||
function stylesheets() {
|
||||
const out = [];
|
||||
const visit = (rel) => {
|
||||
for (const e of readdirSync(path.join(ROOT, rel), { withFileTypes: true })) {
|
||||
const r = `${rel}/${e.name}`;
|
||||
if (e.isDirectory()) visit(r);
|
||||
else if (r.endsWith('.css')) out.push({ rel: r, src: read(r).replace(/\/\*[\s\S]*?\*\//g, '') });
|
||||
}
|
||||
};
|
||||
visit(CSS);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Rules as { selector, body } (flat; @media blocks are walked into, as test/client-static.test.js does). */
|
||||
function cssRules(src) {
|
||||
const out = [];
|
||||
const re = /([^{}]+)\{([^{}]*)\}/g;
|
||||
let m;
|
||||
while ((m = re.exec(src))) out.push({ selector: m[1].trim(), body: m[2] });
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* The font sizes that deliberately stay on a plain `rem` (or a bare px): the in-match HUD chrome in fixed boxes, the
|
||||
* DOM text pinned onto board tiles, the geometry-derived sizes and the ≥16 px iOS-zoom guards. Listed by selector, the
|
||||
* same list the codemod used — a rule that stops being an exclusion (or a new one that should have used `--t`) shows up
|
||||
* as a failure with its file and selector.
|
||||
*/
|
||||
const NOT_SCALED = [
|
||||
// pinned onto board tiles by JS-computed pixels (ui/standInTags.js, ui/underframe.js)
|
||||
/\.sitag/, /\.tempnote/, /\.uframe/, /\.ff-/,
|
||||
// in-match HUD chrome whose box the design fixed (a later pass: shop cards, top bar, bond strip, corner buttons)
|
||||
/\.gm__/, /\.roundbox/, /\.capsule/, /\.lp\b/, /\.lp--/, /\.misstag/, /\.bossbar/, /\.readywrap/,
|
||||
/\.bstrip/, /\.bslot/, /\.effect/, /\.efftip/, /\.ebubble/, /\.ticker/, /\.dpbox/, /\.vswitch/,
|
||||
/\.specpill/, /\.otwarn/, /\.team/, /\.enemybtn/, /\.readybtn/, /\.ewheel/, /\.fwheel/, /\.chud/, /\.toolbtn/,
|
||||
/\.shopbar/, /\.scard/, /\.scr\b/,
|
||||
// text inside a fixed-height bar (the HP bar of the detail card, the boss bar's fill)
|
||||
/\.dhp\b/,
|
||||
// the 机变 card: its grid fits 7 lines of the effect text (game-panels.css), so a bigger font would clamp text away
|
||||
/\.spcard/,
|
||||
// decorative wordmarks and boot/rotate overlays whose size is the composition
|
||||
/\.title-cn/, /\.title-en/, /\.title-bg__target/, /\.brief-boss__q/, /\.boot__/, /\.rotate-hint/,
|
||||
// the join code's letter-spaced field, the px-floored 本局信息 dialog, the 16 px diagnostics box (iOS zoom guard)
|
||||
/\.field--code/, /\.minfo-dlg/, /\.set-diag__text/,
|
||||
// the keyboard hints of the 快捷键 list (a shared rule with the HUD's .toolbtn kbd)
|
||||
/\.set-hint kbd/,
|
||||
];
|
||||
|
||||
const notScaled = (selector) => NOT_SCALED.some((re) => re.test(selector));
|
||||
|
||||
/**
|
||||
* Whole stylesheets a later pass owns: the shop bar's fixed cards (their height feeds the prep camera's HUD band,
|
||||
* ui/fieldHost.js HUD_REM), and the 干员调配 / 统计 screens, whose text is already px-floored (`max(.15rem, 11px)`) and
|
||||
* therefore readable on a phone as it is. Scaling them is step 2/3 of the change, not a forgotten line.
|
||||
*/
|
||||
const NOT_SCALED_FILES = [`${CSS}/screens/game-shop.css`, `${CSS}/screens/loadout.css`, `${CSS}/screens/stats.css`];
|
||||
|
||||
/** A font-size value that is neither scaled nor an exclusion: a plain `rem`, or `max(<rem>, …)`. */
|
||||
function plainRem(value) {
|
||||
if (/var\(/.test(value)) return false; // --t, or a geometry-derived size
|
||||
if (/(^|[^r])em\b|%/.test(value.replace(/rem\b/g, ''))) return false;
|
||||
return /^\s*(max\(\s*)?\d*\.?\d+rem\b/.test(value);
|
||||
}
|
||||
|
||||
describe('文字大小: the setting', () => {
|
||||
test('the default is 小 (the design\'s own sizes) and a saved profile without it keeps it', () => {
|
||||
assert.deepEqual([...TEXT_SIZES], ['sm', 'md', 'lg', 'xl'], 'the steps, in the settings order');
|
||||
assert.equal(DEFAULT_SETTINGS.textSize, 'sm');
|
||||
assert.equal(sanitizeSettings(null).textSize, 'sm');
|
||||
assert.equal(sanitizeSettings({ bgm: 0.4 }).textSize, 'sm', 'a profile saved before the setting existed');
|
||||
});
|
||||
|
||||
test('every step survives sanitising; anything else falls back to 小', () => {
|
||||
for (const v of TEXT_SIZES) assert.equal(sanitizeSettings({ textSize: v }).textSize, v);
|
||||
for (const bad of ['huge', 'SM', 'xl2', 'md ', 1, true, null, undefined, {}, []]) {
|
||||
assert.equal(sanitizeSettings({ textSize: bad }).textSize, 'sm', String(bad));
|
||||
}
|
||||
assert.equal(sanitizeSettings({ textSize: 'xl', quality: 'low' }).quality, 'low', 'the other settings are untouched');
|
||||
});
|
||||
|
||||
test('applyTextSize writes the step to <html data-text>, and 小 when it is not a step', async () => {
|
||||
const { applyTextSize } = await import('../../public/js/ui/settings.js');
|
||||
const el = { dataset: {} };
|
||||
const prev = globalThis.document;
|
||||
globalThis.document = { documentElement: el };
|
||||
try {
|
||||
for (const v of TEXT_SIZES) {
|
||||
applyTextSize(v);
|
||||
assert.equal(el.dataset.text, v);
|
||||
}
|
||||
for (const bad of ['', 'big', null, undefined, 42]) {
|
||||
applyTextSize(bad);
|
||||
assert.equal(el.dataset.text, 'sm', String(bad));
|
||||
}
|
||||
applyTextSize(undefined);
|
||||
assert.equal(el.dataset.text, 'sm', 'the attribute is always written, never left stale');
|
||||
} finally {
|
||||
if (prev === undefined) delete globalThis.document; else globalThis.document = prev;
|
||||
}
|
||||
});
|
||||
|
||||
test('the store applies the step on every change, and at boot before the first render', async () => {
|
||||
const { settingsStore, updateSettings } = await import('../../public/js/ui/settings.js');
|
||||
const el = { dataset: {} };
|
||||
const prev = globalThis.document;
|
||||
globalThis.document = { documentElement: el };
|
||||
try {
|
||||
updateSettings({ textSize: 'lg' });
|
||||
assert.equal(el.dataset.text, 'lg', 'picking a step re-renders the text immediately');
|
||||
assert.equal(settingsStore.get().textSize, 'lg', 'and it is saved with the settings (sp.pref.settings)');
|
||||
updateSettings({ textSize: 'sm' });
|
||||
assert.equal(el.dataset.text, 'sm');
|
||||
} finally {
|
||||
updateSettings({ textSize: DEFAULT_SETTINGS.textSize });
|
||||
if (prev === undefined) delete globalThis.document; else globalThis.document = prev;
|
||||
}
|
||||
});
|
||||
|
||||
test('the settings modal offers the four steps (data-testid text-size), below 画面质量', () => {
|
||||
const src = read('public/js/ui/settings.js');
|
||||
assert.match(src, /data-testid="text-size"/);
|
||||
assert.match(src, /const TEXT_SCALES = \[\[?.*'sm'.*'md'.*'lg'.*'xl'/s, 'the steps with their labels');
|
||||
assert.match(src, /applyTextSize\(settingsStore\.get\(\)\.textSize\)/, 'applied at module load (before main.js renders)');
|
||||
assert.ok(src.indexOf("t('文字大小')") > src.indexOf("t('画面质量')"), 'the row is below the quality row');
|
||||
assert.match(read('public/css/screens/game.css'), /\.set-seg\.set-textsize button \{ min-width: \.62rem; \}/,
|
||||
'four steps fit the row on a phone (1rem = 40 px)');
|
||||
});
|
||||
});
|
||||
|
||||
describe('文字大小: the CSS text root', () => {
|
||||
const sheets = stylesheets();
|
||||
const theme = sheets.find((s) => s.rel === `${CSS}/theme.css`).src;
|
||||
|
||||
test('theme.css defines --t for 小 and every step, and nothing else defines it', () => {
|
||||
assert.match(theme, /:root \{ --t: 1rem; \}/, '小 = the design\'s own sizes');
|
||||
for (const [attr, value] of [['md', /--t: max\(1rem, 64px\)/], ['lg', /--t: max\(calc\(1rem \* 1\.15\), 76px\)/], ['xl', /--t: max\(calc\(1rem \* 1\.3\), 88px\)/]]) {
|
||||
assert.match(theme, new RegExp(`:root\\[data-text="${attr}"\\] \\{ ${value.source}; \\}`), `the ${attr} step`);
|
||||
}
|
||||
for (const { rel, src } of sheets) {
|
||||
if (rel === `${CSS}/theme.css`) continue;
|
||||
assert.doesNotMatch(src, /--t\s*:/, `${rel}: --t is defined in theme.css only`);
|
||||
}
|
||||
});
|
||||
|
||||
test('the layout root 1rem is untouched — the five rem→px readers must not move', () => {
|
||||
assert.match(theme, /html \{\n {2}font-size: clamp\(40px, min\(calc\(100vw \/ 19\.2\), calc\(100vh \/ 10\.8\)\), 240px\);/,
|
||||
'the desktop clamp');
|
||||
assert.match(theme, /font-size: clamp\(40px, min\(calc\(100vw \/ 19\.2\), calc\(100svh \/ 10\.8\)\), 240px\);/,
|
||||
'the svh clamp (mobile toolbars)');
|
||||
assert.doesNotMatch(theme, /font-size: clamp\([^)]*var\(--t\)/, 'the root scale never reads the text root');
|
||||
// and the modules that convert it keep their own floor: a text step must not reach them
|
||||
for (const f of ['public/js/ui/fieldHost.js', 'public/js/ui/gameLogic/panel.js', 'public/js/ui/underframe.js', 'public/js/ui/fallbackField.js']) {
|
||||
const src = read(f);
|
||||
assert.doesNotMatch(src, /--t\b|data-text/, `${f}: the layout must not read the text root`);
|
||||
assert.match(src, /getComputedStyle\(document\.documentElement\)\.fontSize|rem\b/, `${f}: still converts 1rem`);
|
||||
}
|
||||
});
|
||||
|
||||
test('readable text reads --t (body, .micro, buttons, modals, panels, settings)', () => {
|
||||
const want = [
|
||||
[theme, /body \{[^}]*font-size: calc\(\.18 \* var\(--t\)\)/s, 'theme.css body (the 7.2 px text)'],
|
||||
[theme, /\.micro \{[^}]*font-size: calc\(\.11 \* var\(--t\)\)/s, 'theme.css .micro (92 render sites)'],
|
||||
[theme, /:root\[data-text="md"\]/, 'the steps'],
|
||||
];
|
||||
for (const [src, re, what] of want) assert.match(src, re, what);
|
||||
const byRel = new Map(sheets.map((s) => [s.rel, s.src]));
|
||||
const samples = [
|
||||
['components.css', /\.btn \{[^}]*font-size: calc\(\.18 \* var\(--t\)\)/s],
|
||||
['screens/game.css', /\.set-row__label \{[^}]*font-size: calc\(\.18 \* var\(--t\)\)/s],
|
||||
['screens/game-panels.css', /\.dtext \{ font-size: calc\(\.15 \* var\(--t\)\); line-height: 1\.65;/],
|
||||
['screens/guide.css', /\.guide__tips li \{ font-size: calc\(\.18 \* var\(--t\)\)/],
|
||||
['screens/lobby.css', /\.mode-card__desc/, ],
|
||||
];
|
||||
for (const [file, re] of samples) assert.match(byRel.get(`${CSS}/${file}`), re, file);
|
||||
});
|
||||
|
||||
test('a newer reader would be caught: every plain rem font-size is a documented exclusion', () => {
|
||||
const bad = [];
|
||||
let scaled = 0;
|
||||
for (const { rel, src } of sheets) {
|
||||
if (NOT_SCALED_FILES.includes(rel)) continue;
|
||||
for (const { selector, body } of cssRules(src)) {
|
||||
for (const m of body.matchAll(/font-size:([^;}]*)/g)) {
|
||||
const value = m[1];
|
||||
if (/var\(--t\)/.test(value)) { scaled++; continue; }
|
||||
if (!plainRem(value)) continue;
|
||||
if (notScaled(selector)) continue;
|
||||
bad.push(`${rel}: ${selector} { font-size:${value.trim()} }`);
|
||||
}
|
||||
}
|
||||
}
|
||||
assert.deepEqual(bad, [], `not on --t and not a documented exclusion (${scaled} declarations do read --t):`);
|
||||
assert.ok(scaled >= 250, `${scaled} declarations read --t — a mass revert would fail here`);
|
||||
});
|
||||
|
||||
test('the in-match HUD, the board-pinned text and the iOS zoom guards stay as they were', () => {
|
||||
const byRel = new Map(sheets.map((s) => [s.rel, s.src]));
|
||||
const plain = [
|
||||
['screens/game-panels.css', /\.sitag \{[^}]*font-size: max\(\.12rem, 9px\)/, '.sitag'],
|
||||
['screens/game-panels.css', /\.tempnote__title \{[^}]*font-size: \.15rem/, '.tempnote__title'],
|
||||
['screens/game-panels.css', /\.uframe__label \{[^}]*font-size: \.14rem/, '.uframe__label'],
|
||||
['screens/game-panels.css', /\.spcard__desc \{ flex: none; font-size: \.2rem/, '.spcard__desc (7 clamped lines)'],
|
||||
['screens/game-panels.css', /\.dhp span \{[^}]*font-size: \.12rem/, '.dhp span (a fixed-height HP bar)'],
|
||||
['screens/game-shop.css', /\.scard__name \{[^}]*font-size: \.19rem/, 'the shop card'],
|
||||
['screens/game.css', /\.bslot \.bond__name \{[^}]*font-size: \.14rem/, 'the bond strip'],
|
||||
['screens/game.css', /\.bossbar__txt \{[^}]*font-size: \.14rem/, 'the boss bar'],
|
||||
['screens/game.css', /\.lp--md \{ font-size: \.24rem; \}/, 'the top bar LP'],
|
||||
['screens/game.css', /\.sp-coarse \.set-diag__text \{ font-size: 16px; \}/, 'the 16 px iOS-zoom guard'],
|
||||
['theme.css', /\.rotate-hint \{[\s\S]*?font-size: 16px/, 'the rotate hint'],
|
||||
];
|
||||
for (const [file, re, what] of plain) assert.match(byRel.get(`${CSS}/${file}`), re, what);
|
||||
});
|
||||
});
|
||||
Loaded 100 of 104 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user