feedback4: merge master — PR #186 (board textures as WebP), PR #134 (the leak alarm), PR #185 (terrain tap-to-explain, one 活性源石 material); DESIGN §24 intro lists §24.5 / §24.6 after the 0.1.4 sections; golden unchanged (client and tools only)
This commit is contained in:
commit
4f870013c7
26 files changed
+1200
-51
No files matched your search
+1
-1
@@ -1 +1 @@
|
||||
{"version":1,"hash":"9e240a72da75","generator":"tools/fetch-assets.mjs","stats":{"files":5699,"bytes":331609355,"chars":138,"charsWithBack":135,"enemies":252,"enemiesWithSpine":251,"tokens":20,"tokensWithSpine":13,"spineModels":529,"bonds":23,"items":59,"bands":40,"skills":317,"ui":601,"sfxUnits":382,"voiceChars":120},"chars":{"char_1012_skadi2":{"avatar":"/assets/char/avatar/char_1012_skadi2.png","avatarE2":"/assets/char/avatar/char_1012_skadi2_2.png","portrait":"/assets/char/portrait/char_1012_skadi2_1.png","portraitE2":"/assets/char/portrait/char_1012_skadi2_2.png","spine":{"front":{"skel":"/assets/spine/op/char_1012_skadi2/front/char_1012_skadi2.skel","atlas":"/assets/spine/op/char_1012_skadi2/front/char_1012_skadi2.atlas","textures":["/assets/spine/op/char_1012_skadi2/front/char_1012_skadi2.png"],"pma":false,"anims":{"idle":"Idle","deploy":"Start","attack":{"begin":null,"loop":"Skill_1_Loop","end":null,"via":"skill"},"attackDown":null,"skill":{"begin":"Skill_3_Begin","loop":"Skill_3_Loop","end":null,"index":2,"idle":null},"die":"Die","move":null,"stun":{"begin":"Stun_Begin","loop":"Stun","end":null},"skills":{"0":{"begin":"Skill_1_Begin","loop":"Skill_1_Loop","end":null,"index":0,"idle":null},"1":{"begin":"Skill_2_Begin","loop":"Skill_2_Loop","end":null,"index":1,"idle":null},"2":{"begin":"Skill_3_Begin","loop":"Skill_3_Loop","end":null,"index":2,"idle":null}}},"animations":{"Default":0,"Die":1,"Idle":8.767,"Skill_1_Begin":3.2,"Skill_1_Loop":8.767,"Skill_2_Begin":3.933,"Skill_2_Loop":3.333,"Skill_3_Begin":4.4,"Skill_3_Loop":8.767,"Start":1,"Stun":2,"Stun_Begin":1},"events":["OnAttack","OnStart"],"hits":{},"bounds":{"x":-190.586,"y":-25.785,"width":393.516,"height":443.426}}}},"char_1014_nearl2":{"avatar":"/assets/char/avatar/char_1014_nearl2.png","avatarE2":"/assets/char/avatar/char_1014_nearl2_2.png","portrait":"/assets/char/portrait/char_1014_nearl2_1.png","portraitE2":"/assets/char/portrait/char_1014_nearl2_2.png","spine":{"front":{"skel":"/assets/spine/op/char_1014_nearl2/front/char_1014_nearl2.skel","atlas":"/assets/spine/op/char_1014_nearl2/front/char_1014_nearl2.atlas","textures":["/assets/spine/op/char_1014_nearl2/front/char_1014_nearl2.png"],"pma":false,"anims":{"idle":"Idle","deploy":"Start","attack":{"begin":"Attack_Begin","loop":"Attack_Loop","end":"Attack_End"},"attackDown":null,"skill":{"begin":null,"loop":"Skill_3","end":null,"index":2,"idle":"Skill_3_Idle"},"die":"Die","move":null,"stun":null,"skills":{"0":{"begin":"Skill_1_Begin","loop":"Skill_1_Loop","end":null,"index":0,"idle":"Skill_1_Idle"},"1":{"begin":null,"loop":"Skill_2_Die","end":null,"index":1,"idle":null},"2":{"begin":null,"loop":"Skill_3","end":null,"index":2,"idle":"Skill_3_Idle"}}},"animations":{"Attack_Begin":0.1,"Attack_End":0.167,"Attack_Loop":1.5,"Default":0,"Die":1,"Idle":2.667,"Skill_3":1.5,"Skill_1_Begin":0.1,"Skill_1_Idle":1,"Skill_1_Loop":1.5,"Skill_1_Loop_Down":1.5,"Skill_2_Die":1.167,"Skill_3_Down":1.5,"Skill_3_Idle":1.333,"Start":1},"events":["OnAttack","OnStart"],"hits":{"Attack_Loop":[0.433],"Skill_3":[0.5],"Skill_1_Loop":[0.467],"Skill_1_Loop_Down":[0.467],"Skill_3_Down":[0.5]},"bounds":{"x":-400.77,"y":-461.007,"width":720.545,"height":872.242}},"back":{"skel":"/assets/spine/op/char_1014_nearl2/back/char_1014_nearl2.skel","atlas":"/assets/spine/op/char_1014_nearl2/back/char_1014_nearl2.atlas","textures":["/assets/spine/op/char_1014_nearl2/back/char_1014_nearl2.png"],"pma":false,"anims":{"idle":"Idle","deploy":"Start","attack":{"begin":"Attack_Begin","loop":"Attack_Loop","end":"Attack_End"},"attackDown":null,"skill":{"begin":null,"loop":"Skill_3","end":null,"index":2,"idle":"Skill_3_Idle"},"die":null,"move":null,"stun":null,"skills":{"0":{"begin":"Skill_1_Begin","loop":"Skill_1_Loop","end":null,"index":0,"idle":"Skill_1_Idle"},"1":{"begin":"Attack_Begin","loop":"Attack_Loop","end":"Attack_End","via":"attack","index":1,"idle":null},"2":{"begin":null,"loop":"Skill_3","end":null,"index":2,"idle":"Skill_3_Idle"}}},"animations":{"Attack_Begin":0.1,"Attack_End":0.167,"Attack_Loop":1.5,"Default":0,"Idle":2.667,"Skill_1_Begin":0.1,"Skill_1_Idle":1,"Skill_1_Loop":1.5,"Skill_3":1.5,"Skill_3_Idle":1.333,"Start":1},"events":["OnAttack","OnStart"],"hits":{"Attack_Loop":[0.433],"Skill_1_Loop":[0.467],"Skill_3":[0.5]},"bounds":null}}},"char_1016_agoat2":{"avatar":"/assets/char/avatar/char_1016_agoat2.png","avatarE2":"/assets/char/avatar/char_1016_agoat2_2.png","portrait":"/assets/char/portrait/char_1016_agoat2_1.png","portraitE2":"/assets/char/portrait/char_1016_agoat2_2.png","spine":{"front":{"skel":"/assets/spine/op/char_1016_agoat2/front/char_1016_agoat2.skel","atlas":"/assets/spine/op/char_1016_agoat2/front/char_1016_agoat2.atlas","textures":["/assets/spine/op/char_1016_agoat2/front/char_1016_agoat2.png"],"pma":false,"anims":{"idle":"Idle","deploy":"Start","attack":{"begin":null,"loop":"Attack","end":null},"attackDown":null,"skill":{"begin":"Skill_3_Begin","loop":"Skill_3_Loop","end":"Skill_3_End","index":2,"idle":"SLine truncated
|
||||
{"version":1,"hash":"2249e4a0918a","generator":"tools/fetch-assets.mjs","stats":{"files":5700,"bytes":331632595,"chars":138,"charsWithBack":135,"enemies":252,"enemiesWithSpine":251,"tokens":20,"tokensWithSpine":13,"spineModels":529,"bonds":23,"items":59,"bands":40,"skills":317,"ui":601,"sfxUnits":382,"voiceChars":120},"chars":{"char_1012_skadi2":{"avatar":"/assets/char/avatar/char_1012_skadi2.png","avatarE2":"/assets/char/avatar/char_1012_skadi2_2.png","portrait":"/assets/char/portrait/char_1012_skadi2_1.png","portraitE2":"/assets/char/portrait/char_1012_skadi2_2.png","spine":{"front":{"skel":"/assets/spine/op/char_1012_skadi2/front/char_1012_skadi2.skel","atlas":"/assets/spine/op/char_1012_skadi2/front/char_1012_skadi2.atlas","textures":["/assets/spine/op/char_1012_skadi2/front/char_1012_skadi2.png"],"pma":false,"anims":{"idle":"Idle","deploy":"Start","attack":{"begin":null,"loop":"Skill_1_Loop","end":null,"via":"skill"},"attackDown":null,"skill":{"begin":"Skill_3_Begin","loop":"Skill_3_Loop","end":null,"index":2,"idle":null},"die":"Die","move":null,"stun":{"begin":"Stun_Begin","loop":"Stun","end":null},"skills":{"0":{"begin":"Skill_1_Begin","loop":"Skill_1_Loop","end":null,"index":0,"idle":null},"1":{"begin":"Skill_2_Begin","loop":"Skill_2_Loop","end":null,"index":1,"idle":null},"2":{"begin":"Skill_3_Begin","loop":"Skill_3_Loop","end":null,"index":2,"idle":null}}},"animations":{"Default":0,"Die":1,"Idle":8.767,"Skill_1_Begin":3.2,"Skill_1_Loop":8.767,"Skill_2_Begin":3.933,"Skill_2_Loop":3.333,"Skill_3_Begin":4.4,"Skill_3_Loop":8.767,"Start":1,"Stun":2,"Stun_Begin":1},"events":["OnAttack","OnStart"],"hits":{},"bounds":{"x":-190.586,"y":-25.785,"width":393.516,"height":443.426}}}},"char_1014_nearl2":{"avatar":"/assets/char/avatar/char_1014_nearl2.png","avatarE2":"/assets/char/avatar/char_1014_nearl2_2.png","portrait":"/assets/char/portrait/char_1014_nearl2_1.png","portraitE2":"/assets/char/portrait/char_1014_nearl2_2.png","spine":{"front":{"skel":"/assets/spine/op/char_1014_nearl2/front/char_1014_nearl2.skel","atlas":"/assets/spine/op/char_1014_nearl2/front/char_1014_nearl2.atlas","textures":["/assets/spine/op/char_1014_nearl2/front/char_1014_nearl2.png"],"pma":false,"anims":{"idle":"Idle","deploy":"Start","attack":{"begin":"Attack_Begin","loop":"Attack_Loop","end":"Attack_End"},"attackDown":null,"skill":{"begin":null,"loop":"Skill_3","end":null,"index":2,"idle":"Skill_3_Idle"},"die":"Die","move":null,"stun":null,"skills":{"0":{"begin":"Skill_1_Begin","loop":"Skill_1_Loop","end":null,"index":0,"idle":"Skill_1_Idle"},"1":{"begin":null,"loop":"Skill_2_Die","end":null,"index":1,"idle":null},"2":{"begin":null,"loop":"Skill_3","end":null,"index":2,"idle":"Skill_3_Idle"}}},"animations":{"Attack_Begin":0.1,"Attack_End":0.167,"Attack_Loop":1.5,"Default":0,"Die":1,"Idle":2.667,"Skill_3":1.5,"Skill_1_Begin":0.1,"Skill_1_Idle":1,"Skill_1_Loop":1.5,"Skill_1_Loop_Down":1.5,"Skill_2_Die":1.167,"Skill_3_Down":1.5,"Skill_3_Idle":1.333,"Start":1},"events":["OnAttack","OnStart"],"hits":{"Attack_Loop":[0.433],"Skill_3":[0.5],"Skill_1_Loop":[0.467],"Skill_1_Loop_Down":[0.467],"Skill_3_Down":[0.5]},"bounds":{"x":-400.77,"y":-461.007,"width":720.545,"height":872.242}},"back":{"skel":"/assets/spine/op/char_1014_nearl2/back/char_1014_nearl2.skel","atlas":"/assets/spine/op/char_1014_nearl2/back/char_1014_nearl2.atlas","textures":["/assets/spine/op/char_1014_nearl2/back/char_1014_nearl2.png"],"pma":false,"anims":{"idle":"Idle","deploy":"Start","attack":{"begin":"Attack_Begin","loop":"Attack_Loop","end":"Attack_End"},"attackDown":null,"skill":{"begin":null,"loop":"Skill_3","end":null,"index":2,"idle":"Skill_3_Idle"},"die":null,"move":null,"stun":null,"skills":{"0":{"begin":"Skill_1_Begin","loop":"Skill_1_Loop","end":null,"index":0,"idle":"Skill_1_Idle"},"1":{"begin":"Attack_Begin","loop":"Attack_Loop","end":"Attack_End","via":"attack","index":1,"idle":null},"2":{"begin":null,"loop":"Skill_3","end":null,"index":2,"idle":"Skill_3_Idle"}}},"animations":{"Attack_Begin":0.1,"Attack_End":0.167,"Attack_Loop":1.5,"Default":0,"Idle":2.667,"Skill_1_Begin":0.1,"Skill_1_Idle":1,"Skill_1_Loop":1.5,"Skill_3":1.5,"Skill_3_Idle":1.333,"Start":1},"events":["OnAttack","OnStart"],"hits":{"Attack_Loop":[0.433],"Skill_1_Loop":[0.467],"Skill_3":[0.5]},"bounds":null}}},"char_1016_agoat2":{"avatar":"/assets/char/avatar/char_1016_agoat2.png","avatarE2":"/assets/char/avatar/char_1016_agoat2_2.png","portrait":"/assets/char/portrait/char_1016_agoat2_1.png","portraitE2":"/assets/char/portrait/char_1016_agoat2_2.png","spine":{"front":{"skel":"/assets/spine/op/char_1016_agoat2/front/char_1016_agoat2.skel","atlas":"/assets/spine/op/char_1016_agoat2/front/char_1016_agoat2.atlas","textures":["/assets/spine/op/char_1016_agoat2/front/char_1016_agoat2.png"],"pma":false,"anims":{"idle":"Idle","deploy":"Start","attack":{"begin":null,"loop":"Attack","end":null},"attackDown":null,"skill":{"begin":"Skill_3_Begin","loop":"Skill_3_Loop","end":"Skill_3_End","index":2,"idle":"SLine truncated
|
||||
+2
-2
@@ -129,7 +129,7 @@ The `stem` of a Spine model is the upstream file name. Two examples: `char_107_l
|
||||
- **Fonts:** OTF/TTF files are converted to WOFF2 by a built-in encoder (`tools/assets/woff2.mjs`: Brotli with null transforms).
|
||||
- Its output was verified lossless against Google's reference `woff2` decoder.
|
||||
- `fonts.css` lists WOFF2 first and falls back to the original file.
|
||||
- Images stay PNG. WebP conversion is not done: it would need a native dependency.
|
||||
- Images stay PNG. WebP conversion is not done: it would need a native dependency. The local-client board textures are the exception: `tools/local-extract/extract.py` (Python, where Pillow is already a dependency) writes WebP copies of the 12 textures the 3D board downloads (`WEBP`: colour maps lossy at quality 95 with the alpha and the RGB under transparent texels kept, normal and data maps lossless) and `data/local-assets.json` lists the copies; the PNGs stay beside them for `tools/crop-board-atlas.mjs`. `extract.py --webp` adds the copies to an existing extraction without the client.
|
||||
|
||||
## Manifest schema (`data/assets.json`)
|
||||
|
||||
@@ -182,7 +182,7 @@ All paths are URL paths relative to the site root, for example `/assets/char/ava
|
||||
defenceStart, defenceUnite, battleOverReduce, battleOverNoReduce, battleOverNormal, goFirst,
|
||||
disconnect, settlementSucceed, settlementFail, settlementTeam, settlementBossSign,
|
||||
goodEvaluation, load, start, matchSucceed, matchFail, matchCancel, joinRoom },
|
||||
battle: { deploy, tokenDeploy, charDie, enemyDie, enemyDieHeavy, enemyHit, heal, win, lose, killCoin },
|
||||
battle: { deploy, tokenDeploy, charDie, enemyDie, enemyDieHeavy, enemyHit, heal, leak, win, lose, killCoin },
|
||||
units: { [charId|tokenId|enemyId]: { attack?, hit?, skill?, skills?: {[skillIndex]: url}, die?, born?,
|
||||
mix?: { [attack|hit|die|born]: { p?, vol? } } } }
|
||||
}
|
||||
|
||||
@@ -257,3 +257,5 @@ services:
|
||||
表情(6 套 × 6 个)和「玩法说明」的 19 页教程图公开镜像也有:`node tools/setup.mjs` 会和其他素材一起下载(约 21 MB),不需要客户端;有本地素材时优先显示本地的。
|
||||
|
||||
**没有客户端的服务器**想要上表中的官方素材:从**同一版本**的完整包([Releases](https://github.com/sganggs/Stronghold-Protocol/releases))里,把 `public/assets/local/` 文件夹和 `data/local-assets.json` 复制到服务器项目目录下的相同位置。服务器每次请求都会重新读取这两处,不必重启,玩家刷新页面即可。一定要用与服务器代码相同版本的完整包:各版本提取的内容和清单可能不同(例如灼热 / 炽焰源石虫的模型是 0.1.0 之后才加入的),混用其他版本的文件会缺图或用错图。复制后 `node tools/doctor.mjs` 会显示本地素材的条目数和「3D 棋盘可用」。
|
||||
|
||||
**3D 棋盘贴图的下载量**:每位玩家进入对局时都要从开服的电脑下载 3D 棋盘的 12 张贴图。提取时会给这 12 张各写一份 WebP(颜色贴图有损、质量 95,法线和数据贴图无损),清单里列的是 WebP,同名 PNG 留在旁边给裁切工具和 setup 用。这部分下载量从约 6.7 MB 降到约 2 MB,网速慢的远程联机最明显。只有 PNG 的本地素材(例如在这一改动之前提取的)可以用提取时的 Python 环境运行 `tools/local-extract/extract.py --webp` 就地补上,只需要 Pillow,不需要客户端。
|
||||
+21
-1
@@ -2142,7 +2142,7 @@ Decisions of the owner, 2026-10-04: a 联防 devour counts members who enter alr
|
||||
|
||||
## 24. Community reports after 0.1.3
|
||||
|
||||
Reports after the 0.1.3 release. Each was checked against the official data and PRTS; anything with no source is marked [ASSUMED]. Where each is handled: a skill clip with no Begin and no own Idle plays once (德克萨斯 S2 剑雨) → §24.1; 高台 for every melee chess whose trait reads 「可以放置于远程位」 (GitHub #153, PR #69) → §24.2; the 5-阿戈尔 revives go to the first 3 members knocked out (GitHub #105, #140) → §24.3; 联防 阿戈尔 devours the operator in front whoever owns it (GitHub #140) → §24.4. Merged pull requests keep their own notes where they changed the rules: deploy-timed skills as duration skills (PR #109, §5 skill contract), the teammate's hand / temp / equipment in prep scouting (PR #129), 灵巧's knocked-out aura and the hidden-layer bonds (PR #66, research 02).
|
||||
Reports after the 0.1.3 release. Each was checked against the official data and PRTS; anything with no source is marked [ASSUMED]. Where each is handled: a skill clip with no Begin and no own Idle plays once (德克萨斯 S2 剑雨) → §24.1; 高台 for every melee chess whose trait reads 「可以放置于远程位」 (GitHub #153, PR #69) → §24.2; the 5-阿戈尔 revives go to the first 3 members knocked out (GitHub #105, #140) → §24.3; 联防 阿戈尔 devours the operator in front whoever owns it (GitHub #140) → §24.4; a special terrain tile explains itself on a tap (GitHub #184, PR #185) → §24.5; 活性源石 was two different materials on the two boards (GitHub #184, PR #185) → §24.6. Merged pull requests keep their own notes where they changed the rules: deploy-timed skills as duration skills (PR #109, §5 skill contract), the teammate's hand / temp / equipment in prep scouting (PR #129), 灵巧's knocked-out aura and the hidden-layer bonds (PR #66, research 02).
|
||||
|
||||
### 24.1 A skill clip with no Begin and no own Idle plays once — 德克萨斯 S2 剑雨 (player report) — `render/spine.js SpineActor.setSkill / update` (`SKILL_CLIP_MIN`, `skillClipOnce`, `skillEndPending`)
|
||||
|
||||
@@ -2312,3 +2312,23 @@ Reports after the 0.1.3 release. Each was checked against the official data and
|
||||
through a teammate's 幽灵鲨 equals the own chain, and her knock-out takes her owner's 5-tier slot; the own-board case
|
||||
through `uniteBattleOpts` (carry.down from a real own combat, the own combat's gains). The first three fail on the
|
||||
previous code. `feedback3-egir-down`, `bonds_core`, `unitedown`, `feedback3-unitecarry` pass unchanged.
|
||||
|
||||
### 24.5 A special terrain tile explains itself on a tap (GitHub #184, player request 「建议加入对于特殊地形的单击信息提示」) — `render/app.js` (`tileClick`, `emitTileClick`), `ui/fallbackField.js` (the board tile), `ui/screens/game.js` (`tileClick` → `terrainInfo`, `live.terrainStage` / `terrainTile`), `ui/gameLogic.js` (`terrainInfo`, `TERRAIN_TIPS`), `ui/detailPanel.js` (`TerrainDetail`, `resolveDetail`)
|
||||
|
||||
- **Asked** (GitHub #184, after 0.1.3): a tap on a special terrain tile should say what it is. The mode draws 活性源石, 沼泽, 排气格栅, 深水区, the gates and the teleports, but nothing in the client names them — the legend lived in `docs/DATA.md` and the data only.
|
||||
- **Why the ground was free**: a press on a tile that had nothing on it only closed what was open (a piece / unit card, the selection). `render/app.js` now emits `tileClick { row, col }` when neither a battle unit, a prep piece (the drag controller), a pen enemy nor the boss leader took the press, and `ui/fallbackField.js` emits the same from its own tile divs, so both field implementations behave alike.
|
||||
- **What the card says**: `gameLogic.terrainInfo(stage, row, col)` reads the glyph from `stage.rows`, its legend entry from `stage.tiles` (tileKey / height / buildable / passability) and **that stage's own terrain parameters** from `stage.special[<terrain>]` — the values `sim/content/devices.js` runs on: 活性源石 `damage 70 / atk 0.2 / attack_speed 20 / duration 300` (PRTS 特殊地形 "部署于其上的我军和经过的敌军在{duration}s内每秒受到{damage}真实伤害…"), 沼泽's stack rule (`intervalSec` 1, `aspdPerStack` −0.05, `moveMulPerStack` −0.05, `maxStacks` 10, `heavyWeight` 3, cleared on leaving — PRTS 沼泽控制), 深水区's drowning (`damage 40`, aspd −0.6, move ×0.6) plus 拒绝部署 (PRTS 深水区 地形信息; `grid.js DEPLOY_REFUSED_TILES`), 排气格栅's "operators on it cannot be targeted by enemy ranged attacks" — completed in review with the two halves that matter in play: the operator it blocks still gets attacked (the tile's buff is a 隐匿-like `stealth` flag, `devices.js enterTerrain`), and 深水区's drowning damage is sourceless damage that no 干员's 增伤 / 穿透 / 装备 applies to and is *not* 环境伤害 (`tickDeepsea`'s tags are `dot` / `periodic` / `deepsea`, deliberately not `terrain`). The gates and teleports carry no `special`, so they key on `tileKey` — 红门 (enemy gate) / 蓝门 (protection objective) / 传送入口 / 传送出口, in the words `docs/PLAYING.md` already uses. The card is a normal `ResultDialog`-less `DetailPanel` card: read-only, no protocol frame, closed by the next field press (`closesOnFieldPress` now lists `terrain`) or its own ✕.
|
||||
- **An ordinary tile still says nothing** (road / floor / wall / fence / bench → null): the press keeps its other meanings there — deselecting a piece, closing a card — and a drag still starts only on a piece.
|
||||
- **Coordinate spaces (corrected in review)**: a tap reports BOARD coordinates — `emitTileClick` picks with `pickBoardTile`, so the boss-prep transform (`prepXf.toBoard`) is applied once, in the renderer. (The first version reported the DRAWN tile from `groundTile` and the screen mapped it again, so on a Final Assault / Hidden Core prep the tap was converted twice and explained another tile.) Everywhere but that prep board the two spaces are the same: a 最终攻势 / 隐秘核心 battle renders the stage's own rows (`GEO.BOSS_RECT`), the normal and 联防 rects are stage rows. A boss PREP draws the player's half — stage rows 2–5 — as board rows 9–12, and the screen turns the board tile into the stage tile with `gameLogic.fieldTile` before the lookup (and reads the stage the board on screen is built from: the own one with the player's 机变 tile overrides, `effectiveStage`, or the plain stage while watching a teammate).
|
||||
- **[ASSUMED]**: that a player wants the official's 地形信息 here (the official shows terrain descriptions in its own level / terrain UI, not in-match — research 09 records none) and the wording of the mechanism lines: the numbers are the data's, the prose is ours (marked as ours in `TERRAIN_TIPS`). A 机变 card that switches a terrain off (模拟战场演变 · 沙尘暴停止) is not reflected — the tip explains the TILE, not the round's modifier.
|
||||
- **Tests**: `test/ui/gameLogic.test.js` (every terrain of the real stages with its own numbers: 活性源石 in 战场#04, 沼泽 in 战场#06, 排气格栅 in 战场#07, 深水区 in 战场#05; the four gate / teleport tiles; an ordinary glyph → null; out-of-range, fractional and legend-less input; the fact lines from the legend entry; a stage missing its parameters still explains the mechanism), `test/ui/terrain-detail.test.js` (the tap's target resolves into the card; a tip-less or malformed one does not), `test/ui/terrain-tip.e2e.test.js` (headless Chrome on the mock harness: a tap on 蓝门 / 红门 opens the card with its own line on BOTH field implementations — the engine canvas at the tile's own screen position and the DOM fallback's tile — while a tap on a floor tile opens nothing; and on a Final Assault PREP the tapped tile is the one the board tile maps to on the boss field, the regression the review asked for).
|
||||
|
||||
### 24.6 活性源石 is ONE material on both boards (GitHub #184, same report's second half) — `render/style.js` (`ORIGINIUM`, `linearToSrgb255` / `linearToHex`), `render/textures.js` (`originiumCanvas` / `originiumOverlay`, `MAT_DRAW.infection` / `infection2`, `PROC_LAYERS.originium` / `originium2`, `DEFAULT_ART_LAYERS.infection` / `infection2`), `render/tiles.js` (`variantMat`, the pulse tint), `render/board3d/materials.js` (`infectionMaterial`)
|
||||
|
||||
- **Reported** (GitHub #184): 「感觉源石地板的图片有点奇怪(有点简陋,另外两个源石地板的贴图不一致)」 — two originium floors that do not match. They were two different materials: with the board art installed (or `?board=2d`) the tile came from the 2D atlas cell — a beveled brick with seven randomly placed orange crystal clusters — while the 3D board drew a world-space crust with pulsing veins over the official concrete. And that 3D crust was multiplied by a per-tile fade (`edge = smoothstep(0, 0.1, min(vUv, 1 − vUv))`), so every tile of a 活性源石 area faded out at its own border and the area read as separate patches rather than one floor.
|
||||
- **One palette**: `style.js ORIGINIUM` holds the crust's dark and lit sides, the vein and the crystal grains. The values are the 3D shader's own working colours (its fragment ends in `#include <colorspace_fragment>`, so they are linear); the canvas converts them with `linearToSrgb255` — without that the 2D tile came out darker than the 3D one, which is what the first version of this change did.
|
||||
- **One recipe**: two octaves of noise make the crust mask (`smoothstep(0.38, 0.62, n·0.7 + n2·0.45)`), the ridge of the fine octave its veins (`smoothstep(0.86, 0.98, 1 − |2·n2 − 1|)`, brightened where the crust is), the rare top of a third octave the grains — the shader runs it over world-space noise, `textures.js originiumCanvas` over a *toroidal* field (the noise wraps, so a cell that is repeated over the board lines up with itself: the veins run through the tile's edge instead of stopping at it).
|
||||
- **Both reach the tile's edges**: the 3D material no longer has the per-tile fade, and the 2D cell has no frame any more (the old `bevelTile` is gone). The mire / water / smog materials keep their own fades — this section is about 活性源石, the tile that was reported.
|
||||
- **With the board art installed** the 2D tile is composed exactly like the 3D one: `DEFAULT_ART_LAYERS.infection` draws the official concrete slab (`board3d/atlas.js concrete`, the same atlas rect) and then the crust as a procedural layer; without the art the cell falls back to `MAT_DRAW.infection` (the procedural ground + the same crust). A second variant (`infection2`, the same art mirrored; `PROC_LAYERS.originium2` for the art path) is picked by tile (`tiles.js variantMat`), so a field of 活性源石 does not repeat one pattern.
|
||||
- **Not [ASSUMED]** — this is our own art: the palette and the recipe are ours, the tile it dresses and its numbers are the data's (a 机变 card can switch the terrain off, §24.5).
|
||||
- **Tests**: `test/render/originium-art.test.js` (the couplings: both renderers take the palette from `ORIGINIUM`, the canvas converts it, the recipe's thresholds agree, the 2D base slab is the same atlas rect the 3D board lays its crust on, the infection shader has no per-tile fade while the mire keeps its own, two variants are registered and picked, the pulse tint is the shared one), `test/render/originium-cell.e2e.test.js` (headless Chrome: the cell's veins hit the palette's displayed colour (`linearToHex(ORIGINIUM.vein)`), veins reach the border like the middle (no frame), the veins at opposite edges are alike (the cell joins itself), and the second variant is a different pattern).
|
||||
@@ -18,6 +18,7 @@
|
||||
| 选择策略 | 40 名策略(分队长)决定**初始目标生命值(20–45)**和一条永久规则。同盟:随机顺序轮流选,**每人 30 秒**,右上角和「决策中」显示同一个倒计时;AI 队友立即选完;队友已选的不能重复,每人可**跳过一次**(排到最后);超时自动选你**当前选中**的策略(已被队友选走则选华法琳)。独立:自由选择、不限时;同盟模拟只有你一名玩家时也不限时。选的时候可以点左侧决策顺序下方的「查看禁用盟约与干员」,再看一遍「确认本局信息」里的盟约(置灰、禁用人数)和本局禁用干员:只能查看,倒计时照常走,底部也显示轮到谁和剩余秒数;点「关闭」、点窗口外面或按 Esc 关闭,轮到下一位时会自动关闭。 |
|
||||
| 机变 | 部分回合开始前出现:同盟共 6 张卡轮流各取 1 张(首位 30 秒,其余 16 秒;只有你一名玩家时不限时),独立 3 选 1。卡片包括装备、资金、干员、盟约层数、悬赏等;**悬赏决策**为你的作战添加悬赏敌人,卡上用蓝字标出持续多久:「下场作战」/「接下来两场作战」的只出现一场或两场,之后不再出现;官方的「多轮悬赏」(原文写「之后的每场作战」)在这里也按「接下来两场作战」处理,只出现两场(如教鞭的法术大师A2·多轮战术特训)。各回合的悬赏决策按官方对局截图来出:第 3 回合是 6 张「接下来两场作战」的悬赏(3 张 1 资金、2 张 2 资金、1 张 3 资金);第 9 回合是各首领的「下场作战」悬赏和「源石虫·特训」;绝境 / 终极的第 11 回合多半是悬赏决策(也可能是机密商店或战术决策),其中的悬赏通常有 1 只特异巨兽,其余是各阵营的「下场战斗」悬赏。击倒悬赏敌人的人(联防中的队友也算)获得资金;右侧效果栏会写「还剩 N 场作战」。**机密商店**的 6 件装备都免费,和官方一样可能出现两件相同的装备(各算一张卡,可以分别被选走);第 11 回合(绝境 / 终极)的机密商店按官方:2 件 VI 阶、至少 1 件 V 阶和 1 枚盟约之币,标准 / 险境较早回合的机密商店各阶装备都可能出现。**战术决策**也和官方一样可能出现两张相同的卡(官方截图里出现过两张「补给」;各算一张卡,可以分别被选走,每张只生效一次,两人各选一张全队卡则效果叠加);第 11 回合(绝境 / 终极)的战术决策按官方截图只出盟誓、驰援、列装 / 财富 / 补给 / 整备 / 升华等增益卡,不出削弱敌人的卡和战场演变卡。每张卡左上是图标和名称,下面写着完整效果(与详情卡相同的官方描述,手机上也显示);已被选走的卡变暗,右上角显示选走它的玩家头像(悬停可看名字)。选卡要**点两次**:第一次只选中(金框 +「确认选择 · 再次点击」),再点同一张或点右上的「确认选择」才生效;点别的卡换选,按 Esc 或点空白处取消。 |
|
||||
| 休整期 | 购买、摆阵、配装备、冻结 / 刷新、升级调度中心;同盟有倒计时(65–215 秒,随回合增加);独立模拟、以及只有你一名玩家(只带 AI 队友)的同盟模拟不限时。所有人「准备就绪」即提前开战,全员就绪前可取消。 |
|
||||
- **特殊地形点一下就有说明**:战场上的**活性源石**、**沼泽**、**排气格栅**、**深水区**,以及**红门 / 蓝门 / 传送点**,点一下就会弹出它自己的说明卡——名称、机制,以及**这一关自己的数值**(比如战场#04 的活性源石:每秒 70 点真实伤害、攻击力 +20%、攻击速度 +20、持续 300 秒)。休整期和作战期都能点。普通地板 / 道路 / 高台点了不会弹东西,所以点一下仍然只是「取消选中、关掉卡片」。
|
||||
| 作战 | 每人在自己的战场各自作战(战斗在你的浏览器里模拟,2 倍速)。干员按**逐列从左到右、每列从上到下**的顺序(官方「从上到下>从左到右」:最左一列最先)自动部署并消耗部署费用(初始 10,+1/秒)。时限 45–115 秒(实际秒数,即屏幕上的倒计时),时间到仍存活的敌人算漏怪。有敌人进入蓝门时,顶栏的目标生命值会**立即**显示扣除后的数值(红字 + 「−N」,每回合至多 10 点),结算时确定;联防阶段标注「联防中」,队友拦下的部分最后不扣。左侧队伍栏同样显示队友的待扣生命值。被击倒的干员**留在倒下的格子上**(倒地姿势、略微变灰),之后也在这一格再部署(与官方一样;倒在其他干员或召唤物初始位置上的,会回到自己的初始位置躺下,召唤物已经消失也一样);因自身效果强制退场的干员(史尔特尔「余烬」结束、耀骑士临光 2 技能结束等)同样倒在原地、在原地再部署;倒地干员所在的格子**不会被占用**,突袭盟约的再部署、援军、召唤物都会避开它。头上的圆圈是再部署倒计时:绿色圆环 + 秒数;变成黄色「DP」表示部署费用不够。血条下方的一行是**元素损伤**(与原版一致:元素图标 + 白条):干员为带图标的彩色圆盘,敌人为小号纯色圆盘,右侧白条是剩余元素值,像血条一样从右往左减少,白条耗尽的那一下就是爆条(被大额元素损伤一次打掉一截时白条也会一次少一截,例如掠海漂移体每击约为 25%–50%);爆条后进入爆发冷却(灼燃 / 神经 / 侵蚀 10 秒,敌方侵蚀 8 秒,凋亡 15 秒),这时白条变成**元素颜色**(神经青绿、侵蚀蓝、灼燃橙红、凋亡灰紫)并在冷却期间慢慢回满、图标闪烁——彩色条表示已经爆条、正在承受爆发效果,不是还剩的元素值;期间所有元素都不会累积也无法被治疗,结束后清零。元素爆发造成的是**无来源伤害**,不吃干员的增伤、穿透和装备加成(例如深巡打海嗣时灼燃爆发固定 7000),击杀仍记在爆条的干员名下;元素脆弱只提高元素伤害,不加快元素条;脆弱不影响元素伤害。已被击杀的敌人不会再被附加元素损伤。 |
|
||||
| 联防 | 见第 5 节。 |
|
||||
| 结算 | 扣 LP、发放悬赏资金、结算层数;LP ≤ 0 的玩家淘汰(其干员回到公共卡池)。 |
|
||||
@@ -88,6 +89,7 @@
|
||||
- 联防中击杀悬赏敌人,资金归击杀者。
|
||||
- 联防后仍然存活的敌人,按原主人扣 LP;没人完美作战时直接扣。
|
||||
- 漏怪的玩家在顶栏中间(击倒数旁)看到橙色的「×N」标签:你漏过、仍在联防战场上的敌人数(含还没出场的,**不设上限**),队友每击倒一个就减一(会分裂或召唤的敌人被击倒时,分裂出的敌人也算你的,数字会相应增加);目标生命值同时显示为扣除 min(N, 10) 后的数值。左侧队伍栏里漏怪队友的行也显示 ×N(与战场上的击倒同步更新;鼠标悬停可看到是谁漏过的敌人)。
|
||||
- **有敌人走进蓝门就会响一声**:不等它真的扣到目标生命值——一回合里超过 10 点上限的那部分、数据里标明不计数目的敌人(BOSS 部件之类),以及联防里漏过去、随后被队友清掉的那几只,目标生命值都不会再扣,但这一声照样会响。播的是**原版关卡里怪进蓝门那一声**(官方音效表里的 `ON_ENEMY_REACHED_EXIT`,任何一关都是它)。你正在看哪个战场就听哪个战场的,所以看队友的战场、以及漏怪后观看联防时同样听得到。这一声 1.44 秒,我们按官方的「同时只允许一个」做成 **1.5 秒内共用一声**:一波打穿防线只响一遍(不会叠成一片),过一会儿再漏会再响。
|
||||
- 每个普通回合每人**最多扣 10 点 LP**。
|
||||
|
||||
## 6. 盟约
|
||||
|
||||
@@ -53,6 +53,15 @@ const MAX_VOICES = 8;
|
||||
const UNIT_COOLDOWN_MS = 160;
|
||||
const URL_GAP_MS = 45;
|
||||
const MAX_PER_URL = 2;
|
||||
/**
|
||||
* 漏怪: the original Arknights exit alarm (`sfx.battle.leak`, `battle/b_ui/b_ui_alarmenter`) runs **1.44 s**, and the
|
||||
* official bank is a one-shot: `battle.ON_ENEMY_REACHED_EXIT` carries `maxSoundAllowed: 1` with `popOldest: true` on the
|
||||
* `Battle_UI_Important` mixer, i.e. **never two at once** (a new escape replaces the one still ringing). We keep the
|
||||
* "never two at once" half and leave the rest of the cue alone: leaks closer together than the cue is long are the same
|
||||
* disaster and share one alarm, so a line that breaks costs one clear ring per 1.5 s instead of a stutter of restarts.
|
||||
* (The SFX limiter still applies on top.)
|
||||
*/
|
||||
const LEAK_SFX_GAP_MS = 1500;
|
||||
const BUFFER_CACHE = 180;
|
||||
/** Decoded-PCM budget of the buffer cache beside its entry count: a voice line decodes to 0.4–1.3 MB (see _buffer). */
|
||||
const BUFFER_BYTES = 64 * 1024 * 1024;
|
||||
@@ -977,6 +986,16 @@ export class AudioManager {
|
||||
const mix = own ? m.audio.sfx.units[u.def].mix?.die : null;
|
||||
if (!unitSoundPlays(mix, this.random())) continue;
|
||||
this._playUnitUrl(url, own ? `${e[1]}:die` : `die:${e[1]}`, own ? unitGain(0.8, mix) : 0.7);
|
||||
} else if (kind === 'leak') {
|
||||
// 漏怪: an enemy reached its goal (Battle.leak emits the sim's own EV.LEAK — it is NOT a `die`, so until now a
|
||||
// leak was completely silent, for the player's own field and for a 联防 the helpers could not hold alike).
|
||||
// The cue is the ORIGINAL Arknights stage alarm — the one an enemy entering the exit plays in any normal
|
||||
// stage (manifest `sfx.battle.leak`, bank battle.ON_ENEMY_REACHED_EXIT, file b_ui_alarmenter).
|
||||
// `LEAK_SFX_GAP_MS` keeps it to one alarm at a time (the official bank's own maxSoundAllowed 1).
|
||||
if (now - (this.lastLeakSfxAt ?? -Infinity) < LEAK_SFX_GAP_MS) continue;
|
||||
if (typeof this.getManifest()?.audio?.sfx?.battle?.leak !== 'string') continue;
|
||||
this.lastLeakSfxAt = now;
|
||||
this.battle('leak', { unitKey: 'leak', volume: 0.85 });
|
||||
} else if (kind === 'deploy') {
|
||||
const u = this.units.get(e[1]);
|
||||
if (!u || u.side === 'enemy') continue;
|
||||
|
||||
+23
-4
@@ -35,6 +35,8 @@
|
||||
// pieceDragStart { uid, piece, from } · pieceDrop { uid, piece, from, target } · pieceDragEnd {uid, dropped}
|
||||
// pieceClick { uid, piece, button, detail, clientX, clientY } (battle units: { unitId, uid, unit, … })
|
||||
// pieceDetail (right-click / long-press) · pieceHover { uid } | { uid: null } (battle: + unitId, unit)
|
||||
// tileClick { row, col, x, y } — the ground itself was tapped and nothing stands there (GitHub issue #184:
|
||||
// a special terrain tile's own tip; the screen resolves it with gameLogic.terrainInfo)
|
||||
// tileHover { row, col, area, idx } | null (while dragging: the drop target — the tile under the pointer)
|
||||
// view.pieceScreenRect(uid) → { left, top, right, bottom, width, height, x, y } (client px: the drawn body) | null
|
||||
// Picking (user playtest #4 item 1: the ground is drawn as tiles — a press on a tile is a press on the unit standing
|
||||
@@ -1338,6 +1340,21 @@ export async function createFieldView(host, options = {}) {
|
||||
return hit ? hit.ref : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The ground itself was tapped: nothing stands there, so the TILE explains itself — a special terrain tile (活性源石,
|
||||
* 沼泽, 排气格栅, 深水区, 红/蓝门, 传送) opens its own card (GitHub issue #184; screens/game.js `tileClick` →
|
||||
* gameLogic.terrainInfo, which says nothing about an ordinary floor / road / wall tile).
|
||||
* The tile is picked as a BOARD tile (`pickBoardTile`, i.e. through `prepXf.toBoard`): on a Final Assault / Hidden Core
|
||||
* PREP the board draws the boss field's own rows (stage 2–5 as board 9–12), and the screen maps board → stage once more
|
||||
* with `gameLogic.fieldTile` — reporting the DRAWN tile here would be converted twice and explain the wrong tile
|
||||
* (review on #185).
|
||||
*/
|
||||
function emitTileClick(ev, e) {
|
||||
const t = pickBoardTile(ev.x, ev.y);
|
||||
if (!t || !(t.row >= 0) || !(t.col >= 0)) return; // outside the board this field draws
|
||||
emit('tileClick', { row: t.row, col: t.col, button: e.button, clientX: e.clientX, clientY: e.clientY });
|
||||
}
|
||||
|
||||
const onPointerDown = (e) => {
|
||||
if (destroyed) return;
|
||||
const ev = evPayload(e);
|
||||
@@ -1348,18 +1365,20 @@ export async function createFieldView(host, options = {}) {
|
||||
const payload = { unitId: v.id, uid: info?.uid ?? null, unit: info, button: e.button, detail: e.button === 2, clientX: e.clientX, clientY: e.clientY };
|
||||
emit('pieceClick', payload);
|
||||
if (e.button === 2) emit('pieceDetail', payload);
|
||||
} else if (penViews.size) {
|
||||
const pv = penUnitAt(ev.x, ev.y);
|
||||
if (pv) emitPenClick(pv, e);
|
||||
return;
|
||||
}
|
||||
const pv = penViews.size ? penUnitAt(ev.x, ev.y) : null;
|
||||
if (pv) emitPenClick(pv, e);
|
||||
else emitTileClick(ev, e);
|
||||
return;
|
||||
}
|
||||
if (drag.pointerDown(ev)) { try { canvas.setPointerCapture(e.pointerId); } catch { /* ignore */ } return; }
|
||||
if (mode === 'prep') { const lv = leaderAt(ev.x, ev.y); if (lv) { emitPenClick(lv, e); return; } }
|
||||
if (penViews.size && mode === 'prep') {
|
||||
const pv = penUnitAt(ev.x, ev.y);
|
||||
if (pv) emitPenClick(pv, e);
|
||||
if (pv) { emitPenClick(pv, e); return; }
|
||||
}
|
||||
emitTileClick(ev, e);
|
||||
};
|
||||
const onPointerMove = (e) => {
|
||||
if (destroyed) return;
|
||||
|
||||
@@ -12,6 +12,8 @@
|
||||
// (a soft world-space falloff), so the active field reads like the original's lit board without re-building
|
||||
// geometry when the camera moves.
|
||||
|
||||
import { ORIGINIUM } from '../style.js';
|
||||
|
||||
/** Shared focus uniforms (one object for every lit material). */
|
||||
export function focusUniforms(THREE) {
|
||||
return {
|
||||
@@ -275,7 +277,17 @@ export function mireMaterial(THREE, tex, focus) {
|
||||
});
|
||||
}
|
||||
|
||||
/** Infection (活性源石): dark originium crust patches with glowing, pulsing orange veins over the concrete. */
|
||||
/**
|
||||
* 活性源石 (infection): dark originium crust patches with glowing, pulsing orange veins over the concrete. The palette
|
||||
* (`style.js ORIGINIUM`) and the recipe are the ones `render/textures.js originiumOverlay` draws on the 2D board —
|
||||
* two octaves of noise for the crust, the ridge of the fine one for the veins, a third for the crystal grains — so both
|
||||
* boards show ONE material (GitHub #184).
|
||||
* The noise is world-space, so the crust carries on into the neighbouring tile: there is deliberately NO per-tile edge
|
||||
* fade (the old `edge` term faded every tile out at its own border, which made a field of 活性源石 read as separate
|
||||
* patches instead of one floor).
|
||||
*/
|
||||
const V3 = (c) => `vec3(${c.map((v) => Number(v).toFixed(3)).join(', ')})`;
|
||||
|
||||
export function infectionMaterial(THREE, tex, focus) {
|
||||
const uniforms = { uTime: { value: 0 }, uNoise: { value: tex.noise || null }, ...focus };
|
||||
return new THREE.ShaderMaterial({
|
||||
@@ -288,15 +300,17 @@ export function infectionMaterial(THREE, tex, focus) {
|
||||
'void main() {',
|
||||
' float n = texture2D(uNoise, vWorld.xy * 0.55).r;',
|
||||
' float n2 = texture2D(uNoise, vWorld.xy * 1.3 + 0.37).r;',
|
||||
' float n3 = texture2D(uNoise, vWorld.xy * 3.1 + 0.11).r;',
|
||||
' float crust = smoothstep(0.38, 0.62, n * 0.7 + n2 * 0.45);',
|
||||
' float vein = smoothstep(0.86, 0.98, 1.0 - abs(n2 * 2.0 - 1.0)) * (0.35 + 0.65 * crust);',
|
||||
' float grain = smoothstep(0.93, 1.0, n3) * crust;',
|
||||
' float pulse = 0.6 + 0.4 * sin(uTime * 2.2 + n * 9.0);',
|
||||
' vec2 e = min(vUv, 1.0 - vUv);',
|
||||
' float edge = smoothstep(0.0, 0.1, min(e.x, e.y));',
|
||||
' vec3 base = mix(vec3(0.16, 0.05, 0.05), vec3(0.3, 0.1, 0.07), n2);',
|
||||
' vec3 glow = vec3(1.0, 0.46, 0.18) * (1.2 + 0.8 * pulse);',
|
||||
' vec3 c = mix(base, glow, vein) * focusMask(vWorld.xy);',
|
||||
' float a = clamp(crust * 0.72 + vein, 0.0, 1.0) * edge;',
|
||||
` vec3 base = mix(${V3(ORIGINIUM.base)}, ${V3(ORIGINIUM.crust)}, n2);`,
|
||||
` vec3 glow = ${V3(ORIGINIUM.vein)} * (1.2 + 0.8 * pulse);`,
|
||||
' vec3 c = mix(base, glow, vein);',
|
||||
` c = mix(c, ${V3(ORIGINIUM.spec)} * (1.0 + 0.6 * pulse), grain);`,
|
||||
' c *= focusMask(vWorld.xy);',
|
||||
' float a = clamp(crust * 0.72 + vein, 0.0, 1.0);',
|
||||
' gl_FragColor = vec4(c, a);',
|
||||
' #include <colorspace_fragment>',
|
||||
'}',
|
||||
|
||||
@@ -7,11 +7,29 @@
|
||||
// `map/autochess → TX_autochessi_D`; its crop table `tiles.json` sits next to it (written by
|
||||
// tools/crop-board-atlas.mjs). Every image referenced by the table is loaded through the shared image cache; a
|
||||
// missing image drops only the materials that use it. Everything is cached per page (one load for every view).
|
||||
// A table image is fetched at the URL the manifest lists for the same texture (sourceUrl), so the 3D board
|
||||
// (board3d/load.js, which reads the manifest only) and this art share one download — also when the manifest lists
|
||||
// the WebP copy tools/local-extract writes and the table still names the PNG.
|
||||
|
||||
let cached = null;
|
||||
|
||||
const isObj = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
|
||||
|
||||
/** The manifest group of the board textures (and of their crop table). */
|
||||
export const BOARD_GROUP = 'map/autochess';
|
||||
|
||||
/**
|
||||
* URL of a crop-table image: the local-art manifest's entry for the same texture (group `BOARD_GROUP`, name = the
|
||||
* file name without its extension), else the table's own path.
|
||||
* @param {any} assets store of public/js/assets.js
|
||||
* @param {string} path tiles.json `source[k].path`
|
||||
*/
|
||||
export function sourceUrl(assets, path) {
|
||||
const name = path.replace(/^.*\//, '').replace(/\.[^.]*$/, '');
|
||||
const listed = name && typeof assets?.localUrl === 'function' ? assets.localUrl(BOARD_GROUP, name) : null;
|
||||
return typeof listed === 'string' && listed ? listed : path;
|
||||
}
|
||||
|
||||
/** @param {any} assets store of public/js/assets.js (needs local(), localUrl(), image()) */
|
||||
export function loadBoardArt(assets) {
|
||||
if (cached) return cached;
|
||||
@@ -19,7 +37,7 @@ export function loadBoardArt(assets) {
|
||||
if (!assets || typeof assets.local !== 'function' || typeof assets.image !== 'function') return null;
|
||||
const manifest = await assets.local().catch(() => null);
|
||||
if (!manifest) return null;
|
||||
const atlasUrl = assets.localUrl ? assets.localUrl('map/autochess', 'TX_autochessi_D') : null;
|
||||
const atlasUrl = assets.localUrl ? assets.localUrl(BOARD_GROUP, 'TX_autochessi_D') : null;
|
||||
if (!atlasUrl) return null;
|
||||
const dir = atlasUrl.replace(/\/[^/]*$/, '');
|
||||
let tiles = null;
|
||||
@@ -30,7 +48,7 @@ export function loadBoardArt(assets) {
|
||||
if (!isObj(tiles) || !isObj(tiles.materials) || !isObj(tiles.source)) return null;
|
||||
const images = {};
|
||||
await Promise.all(Object.entries(tiles.source).map(async ([k, s]) => {
|
||||
const url = isObj(s) && typeof s.path === 'string' ? s.path : null;
|
||||
const url = isObj(s) && typeof s.path === 'string' && s.path ? sourceUrl(assets, s.path) : null;
|
||||
if (!url) return;
|
||||
const img = await assets.image(url).catch(() => null);
|
||||
if (img && img.width > 0) images[k] = img;
|
||||
|
||||
@@ -70,6 +70,37 @@ export const COLORS = Object.freeze({
|
||||
export const TIER_COLORS = Object.freeze([0x9aa5a0, 0x9aa5a0, 0x7fd37a, 0x52b6ff, 0xb98cff, 0xffc600, 0xff6b3d]);
|
||||
export const ENEMY_FRAME = Object.freeze({ normal: 0xc84a3c, elite: 0xff7a33, boss: 0xff2d55 });
|
||||
|
||||
/**
|
||||
* 活性源石 (infection) — the ONE palette of that tile, shared by the two boards (GitHub #184: the 2D atlas cell and the
|
||||
* 3D shader used to be two different materials — a beveled brick with crystal clusters against a world-space crust).
|
||||
* The values are the 3D board's own working colours (`render/board3d/materials.js` writes them into its shaders, which
|
||||
* end in `#include <colorspace_fragment>`, i.e. they are LINEAR); `textures.js` converts them for its canvas with
|
||||
* `linearToHex`, so both renderers show the same colour.
|
||||
* base — the crust's dark side, crust — its lit side, vein — the glowing veins, spec — the bright crystal grains.
|
||||
*/
|
||||
export const ORIGINIUM = Object.freeze({
|
||||
base: Object.freeze([0.16, 0.05, 0.05]),
|
||||
crust: Object.freeze([0.3, 0.1, 0.07]),
|
||||
vein: Object.freeze([1.0, 0.46, 0.18]),
|
||||
spec: Object.freeze([1.0, 0.78, 0.52]),
|
||||
glow: 0xff6a3d, // the additive pulse the 2D board tints per tile
|
||||
});
|
||||
|
||||
/**
|
||||
* One channel of a working (linear) colour as the 0–255 sRGB value a canvas stores — the shaders' `#include
|
||||
* <colorspace_fragment>` does this for the 3D board, the canvas has to do it itself.
|
||||
*/
|
||||
export function linearToSrgb255(v) {
|
||||
const k = Number(v);
|
||||
const s = k <= 0.0031308 ? k * 12.92 : 1.055 * Math.pow(Math.max(0, k), 1 / 2.4) - 0.055;
|
||||
return Math.max(0, Math.min(255, Math.round(s * 255)));
|
||||
}
|
||||
|
||||
/** A working (linear) `ORIGINIUM` colour as the `#rrggbb` sRGB string of the canvas (`textures.js originiumCanvas`). */
|
||||
export function linearToHex(rgb) {
|
||||
return `#${rgb.map((v) => linearToSrgb255(v).toString(16).padStart(2, '0')).join('')}`;
|
||||
}
|
||||
|
||||
export const DMG_STYLE = Object.freeze({
|
||||
phys: { font: 'sp-dmg-phys', fill: ['#fffbe8', '#ffb35c'], stroke: '#3b1400' },
|
||||
arts: { font: 'sp-dmg-arts', fill: ['#fbe8ff', '#c77dff'], stroke: '#2a0b45' },
|
||||
|
||||
+103
-19
@@ -11,7 +11,7 @@
|
||||
// Everything procedural is deterministic (seeded noise) and generated lazily on first use. Requires
|
||||
// globalThis.PIXI and a DOM canvas at call time (never at import time).
|
||||
|
||||
import { statusIconKey } from './style.js';
|
||||
import { statusIconKey, ORIGINIUM, linearToSrgb255 } from './style.js';
|
||||
|
||||
const PIXI = () => globalThis.PIXI;
|
||||
|
||||
@@ -50,7 +50,7 @@ const PROC_UNIT = 112;
|
||||
export const MATERIALS = [
|
||||
'road', 'road2', 'road3', 'roadN', 'roadN2', 'floor', 'floor2', 'preview', 'wall', 'wallSide', 'forbid', 'forbid2', 'forbidSide',
|
||||
'sep', 'sepSide', 'fence', 'fenceSide', 'start', 'end', 'telin', 'telout', 'hand', 'temp', 'benchSide', 'benchSideTemp', 'mire',
|
||||
'smog', 'deepsea', 'infection', 'cliff', 'blank', 'lowSide', 'margin',
|
||||
'smog', 'deepsea', 'infection', 'infection2', 'cliff', 'blank', 'lowSide', 'margin',
|
||||
// high-ground plates by connectivity (horizontal strip L/M/R, vertical strip B(near)/VM/T(far); 'wall' = single)
|
||||
'wallL', 'wallM', 'wallR', 'wallB', 'wallVM', 'wallT',
|
||||
// textured props
|
||||
@@ -62,6 +62,11 @@ export const MATERIALS = [
|
||||
/** Art layers of materials the crop table (tiles.json) may not describe yet: the board atlas' glass hatch + a glaze. */
|
||||
const DEFAULT_ART_LAYERS = Object.freeze({
|
||||
penGlass: Object.freeze([Object.freeze({ src: 'D', rect: [549, 1787, 255, 256] }), Object.freeze({ proc: 'glass' })]),
|
||||
// 活性源石 (GitHub #184): the official concrete slab the 3D board lays its crust on (`board3d/atlas.js concrete`), plus
|
||||
// the same crust as a procedural layer — so with the board art installed the 2D tile is composed exactly like the 3D
|
||||
// one, and `MAT_DRAW.infection` is only the no-art fallback.
|
||||
infection: Object.freeze([Object.freeze({ src: 'D', rect: [256, 512, 256, 256] }), Object.freeze({ proc: 'originium' })]),
|
||||
infection2: Object.freeze([Object.freeze({ src: 'D', rect: [256, 512, 256, 256] }), Object.freeze({ proc: 'originium2' })]),
|
||||
});
|
||||
|
||||
function speckle(ctx, x, y, w, h, r, n, colors, size = [0.6, 1.8]) {
|
||||
@@ -91,6 +96,97 @@ function bevelTile(ctx, x, y, s, o) {
|
||||
ctx.fillRect(x + s - m - bw, y + m, bw, s - 2 * m);
|
||||
}
|
||||
|
||||
/**
|
||||
* Toroidal value noise on an `n × n` grid: `(u, v) ∈ [0, 1)` wraps, so a cell that is repeated over the board joins
|
||||
* itself without a seam (the knobs of 活性源石's crust line up across neighbouring tiles).
|
||||
*/
|
||||
function noiseField(n, seed) {
|
||||
const r = rng(seed);
|
||||
const g = new Float32Array(n * n);
|
||||
for (let i = 0; i < g.length; i++) g[i] = r();
|
||||
const smooth = (t) => t * t * (3 - 2 * t);
|
||||
return (u, v) => {
|
||||
const x = u * n, y = v * n;
|
||||
const x0 = Math.floor(x), y0 = Math.floor(y);
|
||||
const i0 = ((x0 % n) + n) % n, j0 = ((y0 % n) + n) % n;
|
||||
const i1 = (i0 + 1) % n, j1 = (j0 + 1) % n;
|
||||
const sx = smooth(x - x0), sy = smooth(y - y0);
|
||||
const a = g[j0 * n + i0], b = g[j0 * n + i1], c = g[j1 * n + i0], d = g[j1 * n + i1];
|
||||
return (a * (1 - sx) + b * sx) * (1 - sy) + (c * (1 - sx) + d * sx) * sy;
|
||||
};
|
||||
}
|
||||
|
||||
const ORIGINIUM_RES = 256; // the crust's own resolution (scaled into the atlas cell)
|
||||
|
||||
const smoothstep = (a, b, t) => {
|
||||
const k = Math.max(0, Math.min(1, (t - a) / (b - a)));
|
||||
return k * k * (3 - 2 * k);
|
||||
};
|
||||
|
||||
/**
|
||||
* 活性源石 (infection) — the dark originium crust with thin glowing veins, drawn with the palette and the recipe the 3D
|
||||
* board's shader uses (`render/board3d/materials.js infectionMaterial`, the same octaves and thresholds over its own
|
||||
* world-space noise), so the two boards show ONE material (GitHub #184). Two octaves make the crust mask, the ridge of
|
||||
* the fine octave its veins (brightened where the crust is), the rare top of a third one the crystal grains; the crust
|
||||
* reaches the tile's edges, so a field of 活性源石 carries on into the neighbouring tile instead of ending in a frame.
|
||||
* `mirrored` draws the second variant (`infection2`; `render/tiles.js variantMat` picks between them by tile).
|
||||
*/
|
||||
const _originiumCells = new Map();
|
||||
|
||||
/**
|
||||
* 活性源石's crust, rendered once per variant into `size × size` pixels (cached: the atlas rebuilds on every board art
|
||||
* change, the pattern does not). `mirrored` is the second variant (see `originiumOverlay`).
|
||||
*/
|
||||
function originiumCanvas(size, mirrored) {
|
||||
const key = `${size}:${mirrored ? 1 : 0}`;
|
||||
const hit = _originiumCells.get(key);
|
||||
if (hit) return hit;
|
||||
const canvas = makeCanvas(size, size);
|
||||
const ctx = canvas.getContext('2d');
|
||||
const crustN = noiseField(16, 9137), fineN = noiseField(32, 4421), grainN = noiseField(64, 733);
|
||||
const img = ctx.createImageData(size, size);
|
||||
const d = img.data;
|
||||
const base = ORIGINIUM.base, crust = ORIGINIUM.crust, vein = ORIGINIUM.vein, spec = ORIGINIUM.spec;
|
||||
for (let py = 0; py < size; py++) {
|
||||
const v = (py + 0.5) / size;
|
||||
for (let px = 0; px < size; px++) {
|
||||
const u = mirrored ? 1 - (px + 0.5) / size : (px + 0.5) / size;
|
||||
const n1 = crustN(u, v), n2 = fineN(u, v), n3 = grainN(u, v);
|
||||
const crustM = smoothstep(0.38, 0.62, n1 * 0.7 + n2 * 0.45);
|
||||
const ridge = smoothstep(0.86, 0.98, 1 - Math.abs(n2 * 2 - 1)) * (0.35 + 0.65 * crustM);
|
||||
const grain = smoothstep(0.93, 1, n3) * crustM;
|
||||
const alpha = Math.min(1, crustM * 0.72 + ridge);
|
||||
if (alpha <= 0.004 && grain <= 0.01) continue;
|
||||
const i = (py * size + px) * 4;
|
||||
for (let c = 0; c < 3; c++) {
|
||||
const lit = base[c] * (1 - n2) + crust[c] * n2; // the crust's own shading
|
||||
const vc = lit * (1 - ridge) + vein[c] * ridge; // …and the vein over it
|
||||
// the mixing happens in the shaders' working (linear) space; the canvas stores sRGB (the 3D board's
|
||||
// colorspace_fragment does the same conversion at the end of its fragment shader)
|
||||
d[i + c] = linearToSrgb255(Math.max(0, Math.min(1, vc * (1 - grain) + spec[c] * grain)));
|
||||
}
|
||||
d[i + 3] = Math.round(alpha * 255);
|
||||
}
|
||||
}
|
||||
ctx.putImageData(img, 0, 0);
|
||||
_originiumCells.set(key, canvas);
|
||||
return canvas;
|
||||
}
|
||||
|
||||
/**
|
||||
* 活性源石 (infection) — the dark originium crust with thin glowing veins, drawn with the palette and the recipe the 3D
|
||||
* board's shader uses (`render/board3d/materials.js infectionMaterial`: the same octaves and thresholds over its own
|
||||
* world-space noise), so the two boards show ONE material (GitHub #184: this cell used to be a beveled brick with random
|
||||
* crystal clusters while the 3D quad carried the crust). Two octaves make the crust mask, the ridge of the fine octave
|
||||
* its veins (brightened where the crust is), the rare top of a third one the crystal grains; the crust reaches the
|
||||
* tile's edges, so a field of 活性源石 carries on into the neighbouring tile instead of ending in a frame — the noise
|
||||
* wraps, so a repeated cell joins itself. Drawn through `drawImage`, so the callers' transform (the procedura draw path
|
||||
* scales a 112 px cell up to the atlas cell) applies; `putImageData` would ignore it.
|
||||
*/
|
||||
function originiumOverlay(ctx, x, y, s, mirrored = false) {
|
||||
ctx.drawImage(originiumCanvas(ORIGINIUM_RES, mirrored), x, y, s, s);
|
||||
}
|
||||
|
||||
function cracks(ctx, x, y, s, r, n, color) {
|
||||
ctx.strokeStyle = color;
|
||||
ctx.lineWidth = 1;
|
||||
@@ -285,23 +381,8 @@ const MAT_DRAW = {
|
||||
}
|
||||
speckle(ctx, x, y, s, s, r, 60, ['rgba(200,255,255,0.12)']);
|
||||
},
|
||||
infection(ctx, x, y, s, r) {
|
||||
bevelTile(ctx, x, y, s, { light: '#3a2a33', dark: '#241a20', seamColor: '#140e11', hi: 'rgba(255,140,90,0.14)' });
|
||||
for (let i = 0; i < 7; i++) {
|
||||
const cx = x + 16 + r() * (s - 32), cy = y + 16 + r() * (s - 32), rr = 5 + r() * 10, a = r() * Math.PI;
|
||||
const g = ctx.createRadialGradient(cx, cy, 0, cx, cy, rr * 2.2);
|
||||
g.addColorStop(0, 'rgba(255,110,60,0.45)'); g.addColorStop(1, 'rgba(255,110,60,0)');
|
||||
ctx.fillStyle = g; ctx.beginPath(); ctx.arc(cx, cy, rr * 2.2, 0, Math.PI * 2); ctx.fill();
|
||||
ctx.fillStyle = i % 2 ? '#ff7a45' : '#ffb070';
|
||||
ctx.beginPath();
|
||||
for (let k = 0; k < 5; k++) {
|
||||
const aa = a + (k / 5) * Math.PI * 2, rad = k % 2 ? rr * 0.45 : rr;
|
||||
ctx.lineTo(cx + Math.cos(aa) * rad, cy + Math.sin(aa) * rad);
|
||||
}
|
||||
ctx.closePath(); ctx.fill();
|
||||
}
|
||||
cracks(ctx, x, y, s, r, 4, 'rgba(255,120,70,0.45)');
|
||||
},
|
||||
infection(ctx, x, y, s, r) { floorBase(ctx, x, y, s, r); originiumOverlay(ctx, x, y, s, false); },
|
||||
infection2(ctx, x, y, s, r) { floorBase(ctx, x, y, s, r); originiumOverlay(ctx, x, y, s, true); },
|
||||
cliff(ctx, x, y, s) {
|
||||
const g = ctx.createLinearGradient(x, y, x, y + s);
|
||||
g.addColorStop(0, 'rgba(70,80,86,1)'); g.addColorStop(0.08, 'rgba(38,45,49,1)'); g.addColorStop(1, 'rgba(10,13,14,0)');
|
||||
@@ -439,6 +520,9 @@ const _atlases = new Map(); // art key → atlas
|
||||
|
||||
/** Procedural finishing layers usable in tiles.json (`{ proc: 'rim' }`): a bevelled block edge. */
|
||||
const PROC_LAYERS = {
|
||||
/** 活性源石's crust over the official concrete (DEFAULT_ART_LAYERS.infection); the second variant is mirrored. */
|
||||
originium(ctx, x, y, s) { originiumOverlay(ctx, x, y, s, false); },
|
||||
originium2(ctx, x, y, s) { originiumOverlay(ctx, x, y, s, true); },
|
||||
/** A glossy glass pane over the inner square of a hatch: sky-lit gradient, soft cloud reflections, a highlight. */
|
||||
glass(ctx, x, y, s) {
|
||||
const i = s * 0.135, w = s - i * 2;
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
// are built; switching back (WebGL context lost, art missing) rebuilds the 2D board.
|
||||
|
||||
import { tileAtlas, fxAtlas, rng, groundTexture } from './textures.js';
|
||||
import { GLYPH, TILEKEY_GLYPH, TILE_H, COLORS } from './style.js';
|
||||
import { GLYPH, TILEKEY_GLYPH, TILE_H, COLORS, ORIGINIUM } from './style.js';
|
||||
import { parsePenRect, PEN_RECT } from './pen.js';
|
||||
|
||||
/**
|
||||
@@ -193,6 +193,9 @@ function variantMat(t) {
|
||||
case 'roadN': return h < 4 ? 'roadN' : 'roadN2';
|
||||
case 'floor': return h < 4 ? 'floor' : 'floor2';
|
||||
case 'forbid': return h < 4 ? 'forbid' : 'forbid2';
|
||||
// 活性源石: the same material twice (the second mirrored), so a field of it does not repeat one pattern
|
||||
// (textures.js originiumOverlay — the palette and recipe match the 3D board's shader, GitHub #184)
|
||||
case 'infection': return h < 4 ? 'infection' : 'infection2';
|
||||
default: return t.mat;
|
||||
}
|
||||
}
|
||||
@@ -650,7 +653,7 @@ export class TileField {
|
||||
switch (t.glyph) {
|
||||
case 'g': add('smog', 'smoke', P.BLEND_MODES.NORMAL, 0xb8c4c0, 2); break;
|
||||
case 'd': add('sea', 'soft', P.BLEND_MODES.ADD, 0x5fe0ff, 1); break;
|
||||
case 'i': add('infect', 'glow', P.BLEND_MODES.ADD, 0xff6a3d, 1); break;
|
||||
case 'i': add('infect', 'glow', P.BLEND_MODES.ADD, ORIGINIUM.glow, 1); break;
|
||||
case 'm': add('mire', 'dot', P.BLEND_MODES.ADD, 0xc8d890, 2); break;
|
||||
case 'I': case 'O': add('tel', 'ring', P.BLEND_MODES.ADD, 0xb36bff, 1); break;
|
||||
case 'S': if (r <= 12) add('gateGlow', 'glow', P.BLEND_MODES.ADD, COLORS.gateRed, 1); break;
|
||||
|
||||
@@ -94,8 +94,9 @@ import { pieceTile } from '../render/drag.js';
|
||||
import {
|
||||
phaseMode, phaseBanner, isCombatPhase, showDeadPill, isBossPhase, placementContext, canPlace, boardTargets, dropIntent,
|
||||
snapHud, activeBubbles, shortcutFor, shortcutBlocked, closesOnFieldPress, phaseTotalSeconds, homeFieldId, ownFieldId, normalizeSp, sortedPlayers,
|
||||
terrainInfo,
|
||||
countdownState, shopBlockReason, stageOverrides, effectiveStage, watchTarget, dropFailureReason,
|
||||
previewEnemyKey, prepCamera, prepCameraFor, foldCamera, deployFieldOf, panelSide, panelSlots, bondPopupPlace, chessLoadout, unitLoadout,
|
||||
previewEnemyKey, prepCamera, prepCameraFor, foldCamera, deployFieldOf, fieldTile, panelSide, panelSlots, bondPopupPlace, chessLoadout, unitLoadout,
|
||||
mergeTarget, modeOffBonds, readyFundsPrompt, ownerBandId,
|
||||
} from '../ui/gameLogic.js';
|
||||
import { toast } from '../ui/toasts.js';
|
||||
@@ -368,6 +369,15 @@ function MatchScreen() {
|
||||
const st = ownView ? ownStage : baseStage;
|
||||
if (st) view.setStage(st);
|
||||
}, [view, ownView, ownStage, baseStage]);
|
||||
// the stage behind the board ON SCREEN — the own one (机变 overrides applied) or, while watching a teammate, the plain
|
||||
// one — and how a tapped BOARD tile maps to it (GitHub issue #184: tileClick → gameLogic.terrainInfo). Everywhere but a
|
||||
// boss-prep board the two spaces are the same: a 最终攻势 / 隐秘核心 battle renders the stage's own rows (GEO.BOSS_RECT),
|
||||
// 联防 / normal rects are stage rows; the boss PREP draws the player's half (stage rows 2–5) as board rows 9–12
|
||||
// (render/prepfield.js toDisp), which is exactly gameLogic.fieldTile.
|
||||
live.current.terrainStage = ownView ? ownStage : baseStage;
|
||||
live.current.terrainTile = showPrep && (deployField === 'bossL' || deployField === 'bossR')
|
||||
? (row, col) => fieldTile(deployField, row, col)
|
||||
: (row, col) => [row, col];
|
||||
const staleFieldRef = useRef(null);
|
||||
const enteredFieldRef = useRef(null);
|
||||
const pressSel = useRef(null); // the selected piece when the current field press began
|
||||
@@ -886,6 +896,19 @@ function MatchScreen() {
|
||||
setSel(wasSel ? null : { uid: e.uid });
|
||||
if (wasSel) setDetail((d) => (d?.kind === 'piece' && d.uid === e.uid ? null : d));
|
||||
}),
|
||||
// a tap on the ground itself: a special terrain tile explains itself (GitHub issue #184 「建议加入对于特殊地形的单击
|
||||
// 信息提示」) — 活性源石 / 沼泽 / 排气格栅 / 深水区 / 红蓝门 / 传送, with the numbers of the stage behind the board.
|
||||
// An ordinary tile (road / floor / wall) says nothing, so the press keeps its other meanings (deselect, close).
|
||||
view.on('tileClick', (t) => {
|
||||
if (!t || !Number.isInteger(t.row) || !Number.isInteger(t.col)) return;
|
||||
const L = live.current;
|
||||
const [row, col] = L.terrainTile(t.row, t.col);
|
||||
const info = terrainInfo(L.terrainStage, row, col);
|
||||
if (!info) return;
|
||||
audio.sfx('click', { volume: 0.4 });
|
||||
setSel(null);
|
||||
setDetail({ kind: 'terrain', terrain: info });
|
||||
}),
|
||||
];
|
||||
return () => { moveOff?.(); for (const off of offs) { try { off?.(); } catch { /* ignore */ } } };
|
||||
}, [view]);
|
||||
|
||||
@@ -602,13 +602,36 @@ export function TokenDetail({ token, piece, ownerId = null, snapHp = null, live
|
||||
${talents.length ? html`<${Section} title="天赋">${talents.map((t, i) => html`<p class="dtext" key=${i}><b>${t.name}</b> ${t.desc}</p>`)}<//>` : null}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* A special terrain tile's tip (GitHub issue #184: 「建议加入对于特殊地形的单击信息提示」). Opened by a tap on the tile
|
||||
* itself — the game screen resolves it with `gameLogic.terrainInfo` from the stage the board on screen is built from, so
|
||||
* the mechanism lines carry that stage's own numbers (活性源石's damage / duration, 沼泽's stacks, 深水区's drowning …).
|
||||
* @param {{ name:string, tag:string, lines:string[], facts:string[], row:number, col:number }} terrain
|
||||
*/
|
||||
function TerrainDetail({ terrain }) {
|
||||
return html`
|
||||
<div class="dhead">
|
||||
<div class="dhead__icon"><${Icon} name="info" /></div>
|
||||
<div class="dhead__info">
|
||||
<div class="dhead__chips"><span class="dtag-kind">${terrain.tag}</span></div>
|
||||
<h3 class="dhead__name">${terrain.name}</h3>
|
||||
</div>
|
||||
</div>
|
||||
<${Section} title="地形机制" micro="TERRAIN">
|
||||
${terrain.lines.map((t, i) => html`<p class="dtext" key=${i}>${t}</p>`)}
|
||||
<//>
|
||||
${Array.isArray(terrain.facts) && terrain.facts.length ? html`<${Section} title="这一格"><p class="dtext">${terrain.facts.join(' · ')}</p><//>` : null}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve what a detail target shows.
|
||||
* @param {{ kind:'piece'|'chess'|'item'|'enemy'|'unit'|'token', id?:string, uid?:number, unit?:any, count?:number }} target
|
||||
* @param {{ kind:'piece'|'chess'|'item'|'enemy'|'unit'|'token'|'terrain', id?:string, uid?:number, unit?:any, count?:number }} target
|
||||
* @param {Map<number, any>} pieces indexPieces(priv)
|
||||
*/
|
||||
export function resolveDetail(target, pieces) {
|
||||
if (!target) return null;
|
||||
// a special terrain tile (issue #184): the screen resolved the stage's own numbers already (gameLogic.terrainInfo)
|
||||
if (target.kind === 'terrain') return target.terrain && typeof target.terrain === 'object' ? { type: 'terrain', terrain: target.terrain } : null;
|
||||
if (target.kind === 'piece') {
|
||||
const e = pieces?.get(target.uid);
|
||||
if (!e) return null;
|
||||
@@ -690,6 +713,7 @@ export function DetailPanel({ detail, editable, snapHp, onClose, onSell, onDestr
|
||||
${detail.type === 'item' ? html`<${ItemDetail} item=${detail.item} piece=${detail.piece} editable=${editable} onDestroy=${destroyIt} offBonds=${offBonds} />` : null}
|
||||
${detail.type === 'enemy' ? html`<${EnemyDetail} enemy=${detail.enemy} snapHp=${snapHp} count=${detail.count} live=${liveNow} />` : null}
|
||||
${detail.type === 'token' ? html`<${TokenDetail} token=${detail.token} piece=${detail.piece} ownerId=${detail.ownerId ?? null} snapHp=${snapHp} live=${liveNow} />` : null}
|
||||
${detail.type === 'terrain' ? html`<${TerrainDetail} terrain=${detail.terrain} />` : null}
|
||||
</div>
|
||||
</aside>`;
|
||||
}
|
||||
|
||||
@@ -16,6 +16,8 @@
|
||||
// Final Assault prep) keeps the own board layout here — every coordinate the UI exchanges is a board coordinate anyway —
|
||||
// but draws the tiles of the player's half of the boss field (gameLogic fieldTile, the legality the highlights use: act2
|
||||
// m01's fence tiles are floor there, not the normal field's walls; user playtest #5 item 7).
|
||||
// A tap on the ground itself emits tileClick { row, col } like render/app.js does, so a special terrain tile
|
||||
// explains itself on this board too (GitHub issue #184).
|
||||
|
||||
import { render } from '../../vendor/preact.module.js';
|
||||
import { html, TierChip } from './components.js';
|
||||
@@ -310,6 +312,7 @@ export function createFallbackView(host, opts = {}) {
|
||||
const hov = st.hoverTarget?.area === 'board' && st.hoverTarget.row === row && st.hoverTarget.col === col ? dropState(st.hoverTarget) : null;
|
||||
tiles.push(html`<div key=${k} class=${cx('ff-tile', `ff-tile--${tileClass(row, col)}`, hl && `is-${hl}`, hov && `is-hover-${hov}`)}
|
||||
data-drop="board" data-row=${row} data-col=${col}
|
||||
onPointerDown=${(e) => { if (e.button === 0) emit('tileClick', { row, col, button: 0, clientX: e.clientX, clientY: e.clientY }); }}
|
||||
style=${`transform:translate(${p.x}px,${p.y}px);width:${L.tile}px;height:${L.tile}px`}></div>`);
|
||||
}
|
||||
}
|
||||
|
||||
+114
-1
@@ -887,6 +887,117 @@ export function deploySets(stage, field = 'normal', overrides = {}) {
|
||||
return { melee, ranged };
|
||||
}
|
||||
|
||||
// ---- special terrain tips (GitHub issue #184: 特殊地形的单击信息提示) ----------------------------------------
|
||||
|
||||
/**
|
||||
* What tapping a special tile says. `lines` are functions of the stage's own terrain parameters (`stage.special[<terrain>]`
|
||||
* and the tile's `bb`, the very numbers the sim runs on — server/sim/content/devices.js), so a tip can never disagree with
|
||||
* the battle; the prose is ours (docs/PLAYING.md wording, PRTS 特殊地形 / 沼泽控制 / 深水区 地形信息).
|
||||
* `tag` is the chip above the name; `fact` needs the tile's own legend entry (see terrainInfo).
|
||||
*/
|
||||
const TERRAIN_TIPS = Object.freeze({
|
||||
infection: {
|
||||
name: '活性源石', tag: '特殊地形',
|
||||
lines: (st) => {
|
||||
const b = isObj(st?.infection?.bb) ? st.infection.bb : {};
|
||||
const dmg = param(b.damage, 0);
|
||||
const mods = [];
|
||||
if (param(b.atk, 0)) mods.push(`攻击力 +${Math.round(param(b.atk, 0) * 100)}%`);
|
||||
if (param(b.attack_speed, 0)) mods.push(`攻击速度 +${param(b.attack_speed, 0)}`);
|
||||
return [
|
||||
dmg ? `部署于其上的我方单位、经过的敌方单位,每秒受到 ${dmg} 点真实伤害(无来源)` : '在其上的我方单位与经过的敌方单位持续受到伤害',
|
||||
mods.length ? `同时获得:${mods.join('、')}` : null,
|
||||
param(b.duration, 0) ? `效果持续 ${param(b.duration, 0)} 秒;离开地块后仍然保留,再次接触会重新计时` : null,
|
||||
].filter(Boolean);
|
||||
},
|
||||
},
|
||||
mire: {
|
||||
name: '沼泽', tag: '特殊地形',
|
||||
lines: (st) => {
|
||||
const m = isObj(st?.mire) ? st.mire : {};
|
||||
const per = param(m.aspdPerStack, -0.05);
|
||||
const move = param(m.moveMulPerStack, -0.05);
|
||||
const max = param(m.maxStacks, 10);
|
||||
const heavy = param(m.heavyWeight, 3);
|
||||
return [
|
||||
`留在沼泽里的单位每 ${param(m.intervalSec, 1)} 秒获得 1 层「陷入沼泽」:攻击速度 ${pctText(per)}${move ? `,敌方单位还有移动速度 ${pctText(move)}` : ''}`,
|
||||
heavy ? `重量 ≥ ${heavy} 的敌人一次获得 2 层` : null,
|
||||
`最多 ${max} 层;离开沼泽后解除`,
|
||||
].filter(Boolean);
|
||||
},
|
||||
},
|
||||
smog: {
|
||||
name: '排气格栅', tag: '特殊地形',
|
||||
// the sim gives the tile's buff `flags: { stealth: true }` (devices.js enterTerrain): enemy ranged targeting
|
||||
// skips it like 隐匿 — and, like 隐匿, it does NOT stop the enemy it blocks from attacking it (PRTS 隐匿).
|
||||
lines: () => [
|
||||
'站在排气格栅上的干员不会被敌方的远程攻击选中(效果相当于隐匿)',
|
||||
'但挡住敌人的干员仍会被它攻击到',
|
||||
],
|
||||
},
|
||||
deepsea: {
|
||||
name: '深水区', tag: '特殊地形',
|
||||
lines: (st) => {
|
||||
const b = isObj(st?.deepsea?.bb) ? st.deepsea.bb : {};
|
||||
const dmg = param(b['sea_drown[enemy].damage'], 0);
|
||||
const aspd = param(b['sea_drown[enemy].attack_speed'], 0);
|
||||
const move = param(b['sea_drown[enemy].move_speed'], 0);
|
||||
const out = [];
|
||||
if (dmg) out.push(`敌人每秒受到 ${dmg} 点伤害`);
|
||||
const mods = [];
|
||||
if (aspd) mods.push(`攻击速度 ${pctText(aspd)}`);
|
||||
if (move && move !== 1) mods.push(`移动速度 ×${move}`);
|
||||
if (mods.length) out.push(mods.join('、'));
|
||||
// devices.js tickDeepsea: sourceless true damage tagged 'dot' / 'periodic' / 'deepsea' — deliberately NOT 'terrain'
|
||||
// (环境伤害, which is what 活性源石's tick is): it is nobody's damage, so no 干员's 增伤 / 穿透 / 装备 applies.
|
||||
out.push('溺水伤害属于无来源伤害(不吃干员的增伤、穿透与装备加成),也不归类为环境伤害');
|
||||
out.push('拒绝部署(特制水上平台可以让这一格变得可部署)');
|
||||
return out;
|
||||
},
|
||||
},
|
||||
start: { name: '红门', tag: '敌方入口', lines: () => ['敌方单位从这里出场'] },
|
||||
end: { name: '蓝门', tag: '保护目标', lines: () => ['敌人走进这里会扣你的目标生命值(LP),一回合至多 10 点'] },
|
||||
telin: { name: '传送入口', tag: '特殊地形', lines: () => ['敌人走到这里会从场上消失'] },
|
||||
telout: { name: '传送出口', tag: '特殊地形', lines: () => ['消失的敌人会从这里重新出现'] },
|
||||
});
|
||||
|
||||
/** Tile keys that carry a tip of their own although the legend gives them no `special` tag (gates, teleports). */
|
||||
const TIP_BY_TILEKEY = Object.freeze({ tile_start: 'start', tile_end: 'end', tile_telin: 'telin', tile_telout: 'telout' });
|
||||
/** 深水区's own legend entry is the one tile whose mechanism overrides the level's buildableType (grid.js DEPLOY_REFUSED_TILES). */
|
||||
const BUILDABILITY = Object.freeze({ ALL: '可部署', MELEE: '仅近战位可部署', RANGED: '仅远程位可部署', NONE: '不可部署' });
|
||||
|
||||
const param = (v, d) => (Number.isFinite(Number(v)) ? Number(v) : d);
|
||||
const pctText = (v) => `${v > 0 ? '+' : '−'}${Math.abs(Math.round(param(v, 0) * 100))}%`;
|
||||
|
||||
/**
|
||||
* The tip a tap on board tile (row, col) opens (GitHub issue #184 — "建议加入对于特殊地形的单击信息提示"), or null for an
|
||||
* ordinary tile (road / floor / wall / fence …): those say nothing, so a tap on them still just closes what is open.
|
||||
* The tile comes from the stage the board on screen is built from (`stage.rows` + `stage.tiles`, data/stages.json), and
|
||||
* the numbers from that stage's own terrain parameters — the same values the sim runs.
|
||||
* @param {{ rows?: string[], tiles?: Record<string, any>, special?: any } | null | undefined} stage the shown field's stage
|
||||
* @param {number} row board row (row 0 = the bottom row, DESIGN §1)
|
||||
* @param {number} col
|
||||
* @returns {{ key:string, name:string, tag:string, row:number, col:number, lines:string[], facts:string[] } | null}
|
||||
*/
|
||||
export function terrainInfo(stage, row, col) {
|
||||
const rows = Array.isArray(stage?.rows) ? stage.rows : null;
|
||||
const line = rows && Number.isInteger(row) && row >= 0 ? rows[row] : null;
|
||||
if (typeof line !== 'string' || !Number.isInteger(col) || col < 0 || col >= line.length) return null;
|
||||
const tiles = isObj(stage.tiles) ? stage.tiles : null;
|
||||
const tile = tiles ? tiles[line[col]] : null;
|
||||
if (!isObj(tile)) return null;
|
||||
const key = tile.special || TIP_BY_TILEKEY[tile.tileKey] || null;
|
||||
const tip = key ? TERRAIN_TIPS[key] : null;
|
||||
if (!tip) return null;
|
||||
const facts = [];
|
||||
const build = BUILDABILITY[tile.buildable];
|
||||
if (build) facts.push(build);
|
||||
if (tile.height === 'HIGH') facts.push('高台');
|
||||
if (tile.groundPassable === false) facts.push('只有空中单位能通过');
|
||||
else if (tile.groundPassable === true) facts.push('地面单位可通过');
|
||||
return { key, name: tip.name, tag: tip.tag, row, col, lines: tip.lines(stage.special).filter((s) => typeof s === 'string' && s), facts };
|
||||
}
|
||||
|
||||
// ---- per-player stage overrides (terrain 机变 cards) ------------------------------------------------------
|
||||
|
||||
const OBSTACLE_ROLES = new Set(['crate', 'mound']);
|
||||
@@ -1563,7 +1674,9 @@ export function shortcutFor(e) {
|
||||
* right-click or long press — or a battle / teammate unit). Shop, reward, bond-member and intel (enemy) cards stay.
|
||||
* @param {{ kind?: string }|null|undefined} detail
|
||||
*/
|
||||
export const closesOnFieldPress = (detail) => detail?.kind === 'piece' || detail?.kind === 'unit';
|
||||
// a card opened BY a field press (a piece, a unit, a special terrain tile: issue #184) closes on the next press of
|
||||
// the field; the ones opened from the shop / hand / HUD stay until their own close button (or the flow that opened them)
|
||||
export const closesOnFieldPress = (detail) => detail?.kind === 'piece' || detail?.kind === 'unit' || detail?.kind === 'terrain';
|
||||
|
||||
/**
|
||||
* Whether an open overlay swallows a game shortcut: a modal / the guide own the keyboard (Esc included — they close
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
// test/render/board3d-extract.test.js — the local-client exports behind the official 3D board (DESIGN §13/§15):
|
||||
// tools/local-extract/extract.py's board jobs (gate / objective effects, blower texture, water & noise maps), the
|
||||
// derived three.js maps (BC5 normal → RGB normal, Unity metallic/gloss → roughness), the prefab mesh keys, and —
|
||||
// derived three.js maps (BC5 normal → RGB normal, Unity metallic/gloss → roughness), the WebP copies of the textures
|
||||
// the renderer downloads (extract.py WEBP), the prefab mesh keys, and —
|
||||
// when a client was extracted here — the files and manifest entries the renderer loads (render/board3d/load.js).
|
||||
|
||||
import { test, describe } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync, existsSync } from 'node:fs';
|
||||
import { readFileSync, existsSync, mkdtempSync, rmSync } from 'node:fs';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { decodePng } from '../../tools/crop-board-atlas.mjs';
|
||||
@@ -18,6 +20,7 @@ const TOOL = path.join(ROOT, 'tools/local-extract');
|
||||
const ENV = { ...process.env, PYTHONDONTWRITEBYTECODE: '1' };
|
||||
const PY = ['python3', 'python'].find((bin) => spawnSync(bin, ['--version']).status === 0);
|
||||
const HAS_PIL = !!PY && spawnSync(PY, ['-c', 'import PIL'], { env: ENV }).status === 0;
|
||||
const HAS_WEBP = HAS_PIL && spawnSync(PY, ['-c', "import sys; from PIL import features; sys.exit(0 if features.check('webp') else 1)"], { env: ENV }).status === 0;
|
||||
const manifest = (() => { try { return JSON.parse(readFileSync(path.join(ROOT, 'data/local-assets.json'), 'utf8')); } catch { return null; } })();
|
||||
|
||||
function py(code) {
|
||||
@@ -62,6 +65,68 @@ print(json.dumps({'n': [list(rn.getpixel((i, 0))) for i in range(3)], 'm': [list
|
||||
assert.deepEqual(out.m, [[255, 255, 0], [255, 55, 0]]);
|
||||
});
|
||||
|
||||
test('WebP copies: exactly the board textures the renderer downloads; normal and data maps stay lossless', () => {
|
||||
const r = spawnSync(PY, [path.join(TOOL, 'extract.py'), '--print-jobs'], { encoding: 'utf8', env: ENV });
|
||||
assert.equal(r.status, 0, r.stderr);
|
||||
const { webp } = JSON.parse(r.stdout);
|
||||
assert.deepEqual(webp.map((w) => `${w.sub}/${w.name}`).sort(), Object.values(PACK_IMAGES).map(([g, n]) => `${g}/${n}`).sort());
|
||||
const mode = Object.fromEntries(webp.map((w) => [w.name, w.mode]));
|
||||
for (const n of ['TX_autochessi_N_rgb', '[ucp]TX_water_normal', 'TX_autochessi_M_rough', 'T_noise_clouds_01']) assert.equal(mode[n], 'lossless', n);
|
||||
for (const n of ['TX_autochessi_D', 'TX_autochessi_BG', 'TX_autochessi_common_D']) assert.equal(mode[n], 'lossy', n);
|
||||
});
|
||||
|
||||
test('run_webp / --webp: a copy beside the PNG that the manifest lists; normals exact, alpha and hidden RGB kept', { skip: !HAS_WEBP && 'no Pillow with WebP' }, () => {
|
||||
const dir = mkdtempSync(path.join(tmpdir(), 'sp-webp-'));
|
||||
try {
|
||||
const out = py(`
|
||||
from pathlib import Path
|
||||
from PIL import Image
|
||||
root = Path(${JSON.stringify(dir)})
|
||||
sub = root / 'map' / 'autochess'
|
||||
sub.mkdir(parents=True)
|
||||
d = Image.new('RGBA', (64, 64), (200, 40, 40, 0)) # transparent texels whose colour the opaque board material shows
|
||||
for x in range(32):
|
||||
for y in range(64): d.putpixel((x, y), (x * 8, y * 4, 90, 255))
|
||||
d.save(sub / 'TX_autochessi_D.png')
|
||||
n = Image.new('RGB', (64, 64))
|
||||
for x in range(64):
|
||||
for y in range(64): n.putpixel((x, y), ((x * 37) % 256, (y * 53) % 256, 200 + (x + y) % 56))
|
||||
n.save(sub / 'TX_autochessi_N_rgb.png')
|
||||
entry = lambda name, kind: {'path': f'/assets/local/map/autochess/{name}.png', 'w': 64, 'h': 64, 'kind': kind}
|
||||
groups = {'map/autochess': {'TX_autochessi_D': entry('TX_autochessi_D', 'Texture2D'),
|
||||
'TX_autochessi_N_rgb': entry('TX_autochessi_N_rgb', 'Derived'),
|
||||
'TX_autochessi_E': entry('TX_autochessi_E', 'Texture2D')}}
|
||||
mf = root / 'local-assets.json'
|
||||
mf.write_text(json.dumps({'version': 1, 'source': 'local-client', 'count': 3, 'groups': groups}))
|
||||
logs = []
|
||||
code = e.webp_only(root, mf, logs.append)
|
||||
doc = json.loads(mf.read_text())
|
||||
g = doc['groups']['map/autochess']
|
||||
px = lambda p: list(Image.open(p).convert('RGBA').getdata())
|
||||
a, b = px(sub / 'TX_autochessi_D.png'), px(sub / 'TX_autochessi_D.webp')
|
||||
hidden = [q for p, q in zip(a, b) if p[3] == 0]
|
||||
print(json.dumps({'code': code, 'paths': {k: v['path'] for k, v in g.items()}, 'modes': {k: v.get('webp') for k, v in g.items()},
|
||||
'count': doc['count'], 'pngKept': (sub / 'TX_autochessi_D.png').exists(),
|
||||
'alphaSame': all(p[3] == q[3] for p, q in zip(a, b)),
|
||||
'normalSame': px(sub / 'TX_autochessi_N_rgb.png') == px(sub / 'TX_autochessi_N_rgb.webp'),
|
||||
'hidden': [sum(q[i] for q in hidden) / len(hidden) for i in range(3)],
|
||||
'again': e.run_webp(root, 'map/autochess', doc['groups'], logs.append)}))`);
|
||||
assert.equal(out.code, 0);
|
||||
const at = (n, ext) => `/assets/local/map/autochess/${n}.${ext}`;
|
||||
// TX_autochessi_E is listed without a PNG on disk: its entry stays as it was
|
||||
assert.deepEqual(out.paths, { TX_autochessi_D: at('TX_autochessi_D', 'webp'), TX_autochessi_N_rgb: at('TX_autochessi_N_rgb', 'webp'), TX_autochessi_E: at('TX_autochessi_E', 'png') });
|
||||
assert.deepEqual(out.modes, { TX_autochessi_D: 'lossy', TX_autochessi_N_rgb: 'lossless', TX_autochessi_E: null });
|
||||
assert.equal(out.count, 3);
|
||||
assert.ok(out.pngKept, 'the PNG stays beside the copy (crop tool, setup)');
|
||||
assert.ok(out.alphaSame, 'alpha lossless');
|
||||
assert.ok(out.normalSame, 'normal map lossless');
|
||||
out.hidden.forEach((v, i) => assert.ok(Math.abs(v - [200, 40, 40][i]) < 12, `RGB under alpha 0 kept: ${out.hidden}`));
|
||||
assert.equal(out.again, 2, 'a second run rewrites both copies');
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('prefab_node names the exported OBJ of a mesh whose name is shared (map/fx has two Start_back meshes)', () => {
|
||||
const out = py(`
|
||||
from types import SimpleNamespace as NS
|
||||
@@ -92,6 +157,7 @@ describe('vendored three.js', () => {
|
||||
|
||||
describe('extracted board files (when a local client was extracted)', { skip: !manifest?.groups?.['map/fx'] && 'map/fx not extracted' }, () => {
|
||||
const onDisk = (p) => path.join(ROOT, 'public', decodeURIComponent(p));
|
||||
const pngOf = (p) => p.replace(/\.webp$/, '.png'); // a WebP copy (extract.py WEBP) keeps its PNG beside it
|
||||
const hasFiles = existsSync(path.join(ROOT, 'public/assets/local/map/fx'));
|
||||
|
||||
test('every pack slot the renderer loads is in the manifest (and on disk)', () => {
|
||||
@@ -113,13 +179,24 @@ describe('extracted board files (when a local client was extracted)', { skip: !m
|
||||
|
||||
test('derived maps on disk: RGB normals point out of the surface; roughness mirrors the smoothness', { skip: !hasFiles && 'files not extracted' }, () => {
|
||||
const g = manifest.groups['map/autochess'];
|
||||
const n = decodePng(readFileSync(onDisk(g.TX_autochessi_N_rgb.path)));
|
||||
const n = decodePng(readFileSync(onDisk(pngOf(g.TX_autochessi_N_rgb.path))));
|
||||
let zSum = 0, cnt = 0;
|
||||
for (let i = 0; i < n.rgba.length; i += 4 * 97) { zSum += n.rgba[i + 2]; cnt++; }
|
||||
assert.ok(zSum / cnt > 230, `mean z ${zSum / cnt}`);
|
||||
const m = decodePng(readFileSync(onDisk(g.TX_autochessi_M.path)));
|
||||
const r = decodePng(readFileSync(onDisk(g.TX_autochessi_M_rough.path)));
|
||||
const r = decodePng(readFileSync(onDisk(pngOf(g.TX_autochessi_M_rough.path))));
|
||||
assert.deepEqual([r.w, r.h], [m.w, m.h]);
|
||||
for (let i = 0; i < m.rgba.length; i += 4 * 131) assert.equal(r.rgba[i + 1], 255 - m.rgba[i + 3]);
|
||||
});
|
||||
|
||||
test('the WebP copies the manifest lists are on disk with their PNGs beside them', { skip: !hasFiles && 'files not extracted' }, () => {
|
||||
for (const [g, entries] of Object.entries(manifest.groups)) {
|
||||
for (const [n, e] of Object.entries(entries)) {
|
||||
if (!/\.webp$/.test(e?.path || '')) continue;
|
||||
assert.ok(existsSync(onDisk(e.path)), `${g}/${n}`);
|
||||
assert.ok(existsSync(onDisk(pngOf(e.path))), `${g}/${n}: PNG`);
|
||||
assert.ok(e.webp === 'lossy' || e.webp === 'lossless', `${g}/${n}: mode ${e.webp}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -18,6 +18,7 @@ import { FX_KINDS, fxSpec, tilesAround } from '../../public/js/render/fx.js';
|
||||
import { statusIconKey } from '../../public/js/render/style.js';
|
||||
import { STATUS_KEYS } from '../../public/js/render/textures.js';
|
||||
import { createAssets, localAssetUrl } from '../../public/js/assets.js';
|
||||
import { loadBoardArt, resetBoardArt, sourceUrl } from '../../public/js/render/boardArt.js';
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
|
||||
const stages = JSON.parse(readFileSync(path.join(ROOT, 'data/stages.json'), 'utf8'));
|
||||
@@ -311,4 +312,26 @@ describe('local-art manifest helpers', () => {
|
||||
const broken = createAssets({ manifest: {}, fetch: async () => { throw new Error('offline'); } });
|
||||
assert.equal(await broken.local(), null);
|
||||
});
|
||||
|
||||
test('board art loads the crop-table textures at the URLs the manifest lists (one download with the 3D board)', async () => {
|
||||
const at = (n, ext) => `/assets/local/map/autochess/${n}.${ext}`;
|
||||
const m = { groups: { 'map/autochess': { TX_autochessi_D: { path: at('TX_autochessi_D', 'webp') }, TX_autochessi_BG: { path: at('TX_autochessi_BG', 'webp') } } } };
|
||||
const asked = [];
|
||||
const store = { local: async () => m, localUrl: (g, n) => localAssetUrl(m, g, n), image: async (u) => { asked.push(u); return { width: 4 }; } };
|
||||
assert.equal(sourceUrl(store, at('TX_autochessi_D', 'png')), at('TX_autochessi_D', 'webp'));
|
||||
assert.equal(sourceUrl(store, at('TX_autochessi_common_D', 'png')), at('TX_autochessi_common_D', 'png'), 'not in the manifest: the table path');
|
||||
assert.equal(sourceUrl({}, '/x/y.png'), '/x/y.png');
|
||||
const tiles = { version: 2, materials: {}, source: { D: { path: at('TX_autochessi_D', 'png') }, common: { path: at('TX_autochessi_common_D', 'png') }, BG: { path: at('TX_autochessi_BG', 'png') } } };
|
||||
const realFetch = globalThis.fetch;
|
||||
globalThis.fetch = async (u) => (u === '/assets/local/map/autochess/tiles.json' ? { ok: true, json: async () => tiles } : { ok: false, status: 404 });
|
||||
try {
|
||||
resetBoardArt();
|
||||
const art = await loadBoardArt(store);
|
||||
assert.ok(art?.images.D && art.images.common && art.images.BG);
|
||||
assert.deepEqual(asked.sort(), [at('TX_autochessi_BG', 'webp'), at('TX_autochessi_D', 'webp'), at('TX_autochessi_common_D', 'png')]);
|
||||
} finally {
|
||||
globalThis.fetch = realFetch;
|
||||
resetBoardArt();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
// GitHub #184 (second half): 活性源石 was drawn as two different materials — the 2D atlas cell was a beveled brick with
|
||||
// random crystal clusters, the 3D board's quad a world-space crust with pulsing veins — so the same tile read as two
|
||||
// different floors. These are the couplings that keep it ONE material: the palette of `render/style.js ORIGINIUM`, the
|
||||
// concrete slab it lies on, and no per-tile frame (which made a field of 活性源石 look like separate patches).
|
||||
// The pixels themselves are checked in test/render/originium-cell.e2e.test.js (it needs a real canvas).
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
|
||||
const read = (p) => readFileSync(path.join(ROOT, p), 'utf8');
|
||||
const style = read('public/js/render/style.js');
|
||||
const textures = read('public/js/render/textures.js');
|
||||
const materials = read('public/js/render/board3d/materials.js');
|
||||
const tiles = read('public/js/render/tiles.js');
|
||||
|
||||
/** The source of one exported function of a module (its JSDoc through the closing brace at column 0). */
|
||||
function fnSource(src, name) {
|
||||
const at = src.indexOf(`export function ${name}(`);
|
||||
assert.ok(at > 0, `${name} not found`);
|
||||
const end = src.indexOf('\n}\n', at);
|
||||
return src.slice(src.lastIndexOf('/**', at), end + 2);
|
||||
}
|
||||
|
||||
test('活性源石: one palette for both boards, and the canvas converts the working space', () => {
|
||||
assert.match(style, /export const ORIGINIUM = Object\.freeze\(\{/, 'the palette lives in style.js');
|
||||
for (const k of ['base', 'crust', 'vein', 'spec']) assert.match(style, new RegExp(`${k}: Object\\.freeze\\(\\[`), k);
|
||||
assert.match(style, /export function linearToSrgb255/, 'the sRGB conversion the canvas needs');
|
||||
// the 2D drawer takes its colours from the palette (no private copy) and converts them for the canvas
|
||||
assert.match(textures, /import \{[^}]*ORIGINIUM[^}]*\} from '\.\/style\.js'/);
|
||||
assert.match(textures, /ORIGINIUM\.vein/);
|
||||
assert.match(textures, /linearToSrgb255\(/, 'the canvas stores sRGB, the palette is the shaders\' linear space');
|
||||
// so does the 3D shader, through the vec3 helper
|
||||
assert.match(materials, /import \{ ORIGINIUM \} from '\.\.\/style\.js'/);
|
||||
assert.match(materials, /const V3 = \(c\) => `vec3\(/);
|
||||
for (const k of ['base', 'crust', 'vein', 'spec']) assert.match(materials, new RegExp(`V3\\(ORIGINIUM\\.${k}\\)`), k);
|
||||
// the identical recipe (the shader's own thresholds, mirrored in the drawer's smoothstep calls)
|
||||
assert.match(materials, /smoothstep\(0\.38, 0\.62, n \* 0\.7 \+ n2 \* 0\.45\)/);
|
||||
assert.match(textures, /smoothstep\(0\.38, 0\.62, n1 \* 0\.7 \+ n2 \* 0\.45\)/);
|
||||
assert.match(materials, /smoothstep\(0\.86, 0\.98, 1\.0 - abs\(n2 \* 2\.0 - 1\.0\)\)/);
|
||||
assert.match(textures, /smoothstep\(0\.86, 0\.98, 1 - Math\.abs\(n2 \* 2 - 1\)\)/);
|
||||
});
|
||||
|
||||
test('活性源石: the 2D tile lies on the same concrete the 3D board lays its crust on', () => {
|
||||
// board3d/atlas.js `concrete` and textures.js DEFAULT_ART_LAYERS.infection must name the same atlas rect: with the
|
||||
// board art installed the 2D tile is composed exactly like the 3D one (official slab + our crust).
|
||||
const atlas = read('public/js/render/board3d/atlas.js');
|
||||
const m2 = atlas.match(/concrete: S\('D', \[([\d, ]+)\]\)/);
|
||||
const m1 = textures.match(/infection: Object\.freeze\(\[Object\.freeze\(\{ src: 'D', rect: \[([\d, ]+)\] \}\)/);
|
||||
assert.ok(m1 && m2, 'both define the slab');
|
||||
assert.equal(m1[1].replace(/\s/g, ''), m2[1].replace(/\s/g, ''), 'the same concrete cell');
|
||||
});
|
||||
|
||||
test('活性源石: neither board fades the tile out at its own border', () => {
|
||||
// the 3D crust used to be multiplied by a per-tile `edge` fade (min(vUv, 1 − vUv)), which left a transparent band
|
||||
// between neighbouring tiles; the drawer must reach the cell's edges the same way. Its siblings (mire / water) keep
|
||||
// their own fade — this is about 活性源石 only.
|
||||
const inf = fnSource(materials, 'infectionMaterial');
|
||||
assert.ok(!/min\(e\.x, e\.y\)|float edge/.test(inf), 'no per-tile edge fade in the infection shader');
|
||||
assert.match(inf, /float a = clamp\(crust \* 0\.72 \+ vein, 0\.0, 1\.0\)/);
|
||||
const mire = fnSource(materials, 'mireMaterial');
|
||||
assert.match(mire, /float edge = smoothstep/, 'the mire keeps its own fade');
|
||||
// the 2D cell has no frame either: no bevelTile in the drawer's two variants
|
||||
const draw = textures.slice(textures.indexOf(' infection(ctx, x, y, s, r) {'), textures.indexOf(' cliff(ctx, x, y, s) {'));
|
||||
assert.ok(!/bevelTile/.test(draw), 'the old beveled brick is gone');
|
||||
assert.match(draw, /floorBase\(ctx, x, y, s, r\); originiumOverlay\(ctx, x, y, s, false\);/);
|
||||
assert.match(draw, /infection2\(ctx, x, y, s, r\) \{ floorBase\(ctx, x, y, s, r\); originiumOverlay\(ctx, x, y, s, true\); \}/);
|
||||
});
|
||||
|
||||
test('活性源石: two variants are registered, picked by tile, and the pulse uses the shared tint', () => {
|
||||
assert.match(textures, /'deepsea', 'infection', 'infection2', 'cliff'/);
|
||||
assert.match(textures, /originium2\(ctx, x, y, s\) \{ originiumOverlay\(ctx, x, y, s, true\); \}/, 'the proc layer for the art path');
|
||||
assert.match(textures, /infection2: Object\.freeze\(\[Object\.freeze\(\{ src: 'D', rect: \[256, 512, 256, 256\] \}\), Object\.freeze\(\{ proc: 'originium2' \}\)\]\)/);
|
||||
assert.match(tiles, /case 'infection': return h < 4 \? 'infection' : 'infection2';/);
|
||||
assert.match(tiles, /add\('infect', 'glow', P\.BLEND_MODES\.ADD, ORIGINIUM\.glow, 1\)/);
|
||||
});
|
||||
@@ -0,0 +1,133 @@
|
||||
// GitHub #184 (second half), the pixels: the 2D board's 活性源石 cell must be the material the 3D board's shader draws
|
||||
// (`render/board3d/materials.js infectionMaterial`) — the palette of `render/style.js ORIGINIUM`, the crust reaching the
|
||||
// tile's edges (no per-tile frame: a field of 活性源石 must read as ONE floor, not as separate patches), and the cell
|
||||
// joining itself without a seam (the noise wraps, so a repeated cell lines up).
|
||||
// SP_E2E=1 CHROME_PATH=… node --test test/render/originium-cell.e2e.test.js
|
||||
// Both configurations are checked: the atlas a deployment WITH the board art renders (the official concrete slab + our
|
||||
// crust, `DEFAULT_ART_LAYERS.infection`) and the procedural one upstream players without the art see (`MAT_DRAW.infection`).
|
||||
// The couplings between the two renderers' sources are in test/render/originium-art.test.js (no browser needed).
|
||||
|
||||
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 f3 = (v) => Math.round(v * 1000) / 1000;
|
||||
|
||||
describe('活性源石 cell (2D board)', { skip: !ENABLED && 'set SP_E2E=1 (and have Chrome) to run' }, () => {
|
||||
let srv;
|
||||
let browser;
|
||||
let base;
|
||||
let configs = []; // [{ name, art, infection, infection2, road, palette }]
|
||||
|
||||
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'] });
|
||||
const page = await browser.newPage();
|
||||
await page.goto(`${base}/dev/game-mock.html?phase=PREP&render=fallback`, { waitUntil: 'domcontentloaded' });
|
||||
await page.addScriptTag({ url: '/vendor/pixi.min.js' }); // textures.js builds its atlas with PIXI
|
||||
configs = await page.evaluate(async () => {
|
||||
const m = await import('/js/render/textures.js');
|
||||
const st = await import('/js/render/style.js');
|
||||
const { assets } = await import('/js/assets.js');
|
||||
const { loadBoardArt } = await import('/js/render/boardArt.js');
|
||||
// the atlas a deployment with the board art renders, and the procedural one (no art) upstream sees
|
||||
await assets.load?.().catch?.(() => null);
|
||||
const art = await loadBoardArt(assets).catch(() => null);
|
||||
const read = (atlas, name) => {
|
||||
const uv = atlas.uv[name];
|
||||
if (!uv) return null;
|
||||
const W = atlas.canvas.width, H = atlas.canvas.height;
|
||||
const x0 = Math.round(uv[0] * W), y0 = Math.round(uv[1] * H);
|
||||
const w = Math.round((uv[2] - uv[0]) * W), h = Math.round((uv[3] - uv[1]) * H);
|
||||
const img = atlas.canvas.getContext('2d').getImageData(x0, y0, w, h);
|
||||
const px = [];
|
||||
for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) {
|
||||
const k = (y * w + x) * 4;
|
||||
px.push({ x, y, r: img.data[k] / 255, g: img.data[k + 1] / 255, b: img.data[k + 2] / 255, a: img.data[k + 3] / 255 });
|
||||
}
|
||||
return { w, h, px, data: Array.from(img.data) };
|
||||
};
|
||||
const cells = (atlas, name) => ({ name, art: atlas.art, artCount: atlas.artCount,
|
||||
infection: read(atlas, 'infection'), infection2: read(atlas, 'infection2'), road: read(atlas, 'road') });
|
||||
// the vein's DISPLAYED colour (what a canvas stores): the palette converted out of the shaders' working space
|
||||
const hex = st.linearToHex(st.ORIGINIUM.vein);
|
||||
const rgb = [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16) / 255);
|
||||
const palette = { veinHex: hex, gr: rgb[1] / rgb[0], br: rgb[2] / rgb[0] };
|
||||
const out = [cells(m.tileAtlas(art || null), art ? 'board art' : 'no art installed')];
|
||||
if (art) out.push(cells(m.tileAtlas(null), 'procedural'));
|
||||
return out.map((c) => ({ ...c, palette }));
|
||||
});
|
||||
await page.close();
|
||||
});
|
||||
after(async () => {
|
||||
await browser?.close();
|
||||
await srv?.close();
|
||||
});
|
||||
|
||||
/** The numbers of one cell: veins, edges, ring vs middle, and the difference to another cell. */
|
||||
function stats(c) {
|
||||
const veinish = (p) => p.a > 0.6 && p.r > 0.55 && p.g > 0.2 && p.g < 0.8 && p.b < 0.5;
|
||||
const mean = (arr, k) => arr.reduce((n, p) => n + p[k], 0) / Math.max(1, arr.length);
|
||||
const hot = c.px.filter(veinish);
|
||||
const ring = c.px.filter((p) => Math.min(p.x, p.y, c.w - 1 - p.x, c.h - 1 - p.y) < c.w * 0.08);
|
||||
const lum = (arr) => arr.reduce((n, p) => n + (0.2126 * p.r + 0.7152 * p.g + 0.0722 * p.b) * p.a, 0) / Math.max(1, arr.length);
|
||||
const band = (pred) => { const a = c.px.filter(pred); return f3(a.filter(veinish).length / Math.max(1, a.length)); };
|
||||
// the vein CORES are opaque, so their colour does not depend on the slab underneath (the art config draws one):
|
||||
// the brightest few percent are exactly those, whichever base the cell has
|
||||
const core = [...c.px].sort((p, q) => (q.r + q.g + q.b) - (p.r + p.g + p.b)).slice(0, Math.max(1, Math.round(c.px.length * 0.03)));
|
||||
return {
|
||||
veinPct: f3(hot.length / c.px.length), ringVeinPct: f3(ring.filter(veinish).length / ring.length),
|
||||
hotGR: f3(mean(hot, 'g') / mean(hot, 'r')), hotBR: f3(mean(hot, 'b') / mean(hot, 'r')), borderLum: f3(lum(ring)),
|
||||
coreGR: f3(mean(core, 'g') / mean(core, 'r')), coreBR: f3(mean(core, 'b') / mean(core, 'r')),
|
||||
// our own crust at the tile's borders (the base slab may be official art with edges of its own, so the veins — the
|
||||
// layer this change drew — are what shows whether the tile joins the next one)
|
||||
veinsL: band((p) => p.x < 3), veinsR: band((p) => p.x >= c.w - 3),
|
||||
veinsT: band((p) => p.y < 3), veinsB: band((p) => p.y >= c.h - 3),
|
||||
};
|
||||
}
|
||||
const diff = (a, b) => f3(a.data.reduce((n, v, i) => n + Math.abs(v - b.data[i]), 0) / a.data.length);
|
||||
|
||||
test('the cell wears the shared palette: the veins hit the colour the 3D shader writes', () => {
|
||||
for (const cfg of configs) {
|
||||
const s = stats(cfg.infection);
|
||||
const { gr, br, veinHex } = cfg.palette;
|
||||
assert.ok(s.veinPct >= 0.05, `${cfg.name}: the crust carries veins (${s.veinPct})`);
|
||||
assert.ok(Math.abs(s.coreGR - gr) <= 0.06, `${cfg.name}: the veins wear the palette's colour (g/r ${s.coreGR} vs ${f3(gr)} for ${veinHex})`);
|
||||
assert.ok(Math.abs(s.coreBR - br) <= 0.06, `${cfg.name}: …and its blue (b/r ${s.coreBR} vs ${f3(br)})`);
|
||||
// the whole cell is that material, not the bare slab the atlas holds for an ordinary tile
|
||||
assert.ok(diff(cfg.infection, cfg.road) > 8, `${cfg.name}: the overlay really draws over the slab (${diff(cfg.infection, cfg.road)})`);
|
||||
}
|
||||
});
|
||||
|
||||
test('no per-tile frame — the crust reaches the edges, and the cell joins itself', () => {
|
||||
for (const cfg of configs) {
|
||||
const s = stats(cfg.infection);
|
||||
// veins at the border like anywhere else: the old beveled brick had a frame and none at the very edge
|
||||
assert.ok(s.ringVeinPct >= s.veinPct * 0.6, `${cfg.name}: veins reach the tile's border (${s.ringVeinPct} at the ring vs ${s.veinPct})`);
|
||||
// a repeated cell must line up: the noise wraps, so opposite edges sample the same field — the veins run through
|
||||
// the edge on both sides instead of stopping at it. Compared exactly on the cell WE compose (floor + crust); with
|
||||
// the official slab under the crust the vein/not-vein classification of the composite pixels follows the slab's
|
||||
// own shading (its edges are the official art's, on every tile of the board), so there only the ring rule above
|
||||
// applies — an edge band is 3 of 240 columns, and one slab of the noise is not the cell's average.
|
||||
if (!cfg.art) {
|
||||
for (const [a, b, where] of [[s.veinsL, s.veinsR, 'left/right'], [s.veinsT, s.veinsB, 'top/bottom']]) {
|
||||
assert.ok(Math.abs(a - b) <= 0.06, `${cfg.name}: ${where} edge veins ${a} vs ${b}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('two variants, so a field of 活性源石 does not repeat one pattern', () => {
|
||||
for (const cfg of configs) {
|
||||
assert.ok(cfg.infection2, `${cfg.name}: infection2 is an atlas cell`);
|
||||
const a = stats(cfg.infection), b = stats(cfg.infection2);
|
||||
assert.ok(Math.abs(a.veinPct - b.veinPct) <= 0.02, `${cfg.name}: the same recipe (${a.veinPct} vs ${b.veinPct})`);
|
||||
assert.ok(diff(cfg.infection, cfg.infection2) > 6, `${cfg.name}: the second variant is a different pattern (${diff(cfg.infection, cfg.infection2)})`);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -664,3 +664,62 @@ describe('impact sounds (user playtest #4 item 6)', () => {
|
||||
} finally { globalThis.performance = perf; 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
|
||||
// silent, for the player's own field and for a 联防 the helpers could not hold alike.
|
||||
//
|
||||
// The cue is the ORIGINAL Arknights stage alarm an enemy entering the exit plays in any normal stage: the manifest's
|
||||
// `sfx.battle.leak`, bank `battle.ON_ENEMY_REACHED_EXIT`, file `Battle/b_ui/b_ui_alarmenter`. (The autochess banks
|
||||
// have nothing named for an escape — all 13,948 SFX banks searched — but the stage itself does.) The official bank is
|
||||
// a one-shot: `maxSoundAllowed: 1` with `popOldest: true` on the `Battle_UI_Important` mixer.
|
||||
|
||||
describe('漏怪 sound', () => {
|
||||
async function rig() {
|
||||
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');
|
||||
const settle = () => new Promise((r) => setTimeout(r, 10));
|
||||
return { a, fw, urls, settle, restore: () => { globalThis.fetch = origFetch; } };
|
||||
}
|
||||
|
||||
test('an escaped enemy plays the original stage exit alarm — and no death sound (a leak is not a `die`)', async () => {
|
||||
const { a, urls, settle, restore } = await rig();
|
||||
try {
|
||||
const url = manifest.audio.sfx.battle.leak;
|
||||
assert.ok(url, '前提:清单里有 sfx.battle.leak');
|
||||
assert.match(url, /b_ui_alarmenter\.mp3$/, '就是原版关卡里怪进蓝门那一声');
|
||||
a.handleBattleEvents([['leak', 7]]);
|
||||
await settle();
|
||||
assert.ok(asked(urls, url), `漏怪 plays ${url}`);
|
||||
assert.equal(askedCount(urls, manifest.audio.sfx.battle.enemyDie), 0, 'a leak is not a death — no death sound');
|
||||
} finally { restore(); }
|
||||
});
|
||||
|
||||
test('leaks of one disaster are ONE alarm (the cue is 1.44 s long), a later one rings again', async () => {
|
||||
const { a, fw, urls, settle, restore } = await rig();
|
||||
try {
|
||||
const url = manifest.audio.sfx.battle.leak;
|
||||
a.handleBattleEvents([['leak', 1]]);
|
||||
await settle();
|
||||
assert.equal(askedCount(urls, url), 1, 'the first escape rings');
|
||||
// the plays themselves, not the fetches: the buffer is cached after the first one
|
||||
const before = fw.made.started;
|
||||
// six more at once — a wiped board, or a 联防 the helpers could not hold
|
||||
a.handleBattleEvents([['leak', 2], ['leak', 3], ['leak', 4], ['leak', 5], ['leak', 6], ['leak', 7]]);
|
||||
await settle();
|
||||
assert.equal(fw.made.started - before, 0, 'one disaster never stacks alarms (the official bank allows 1)');
|
||||
// a genuine later leak is a new disaster and rings again, once the cue (1.44 s) has finished
|
||||
await new Promise((r) => setTimeout(r, 1600));
|
||||
a.handleBattleEvents([['leak', 8]]);
|
||||
await settle();
|
||||
assert.equal(fw.made.started - before, 1, 'a later leak rings again');
|
||||
assert.ok(a.limiter.active <= a.limiter.maxVoices);
|
||||
} finally { restore(); }
|
||||
});
|
||||
});
|
||||
+109
-1
@@ -12,7 +12,7 @@ import {
|
||||
bondMembers, memberHeadCount, bannedPerBond, priceTone, mergeProgress, shopBlockReason, deploySets, indexPieces, placementContext, canPlace,
|
||||
boardTargets, dropIntent, normalizeDraft, normalizeSp, groupEnemies, factionTypes, snapHud, bossFrac, attackInterval, fmtNum,
|
||||
rangeGridBox, shortcutFor, sanitizeSettings, DEFAULT_SETTINGS, normalizeResult, cycleField, fieldLabel, homeFieldId,
|
||||
activeBubbles, sortedPlayers, tileKey, prepCapsuleLabel, prepCamera, dropFailureReason,
|
||||
activeBubbles, sortedPlayers, tileKey, prepCapsuleLabel, prepCamera, dropFailureReason, terrainInfo,
|
||||
} from '../../public/js/ui/gameLogic.js';
|
||||
import { pairPlayers } from '../../server/match/finalAssault.js';
|
||||
import { PHASE, GEO } from '../../shared/constants.js';
|
||||
@@ -585,3 +585,111 @@ describe('equipment dropped on a tile goes to the unit on it', () => {
|
||||
assert.deepEqual(dropIntent(ctx, art.uid, { area: 'board', row: 11, col: 3 }), { t: 'g.art', fields: { itemUid: art.uid, row: 11, col: 3 } });
|
||||
});
|
||||
});
|
||||
|
||||
// GitHub issue #184 「建议加入对于特殊地形的单击信息提示」: tapping a special terrain tile explains it — in the stage's own
|
||||
// numbers (the same bb / special parameters the sim runs on), while an ordinary tile says nothing at all.
|
||||
describe('special terrain tip', () => {
|
||||
/** The first (row, col) of `glyph` in a real stage of data/stages.json. */
|
||||
const at = (stageId, glyph) => {
|
||||
const st = stages[stageId];
|
||||
for (let row = 0; row < st.rows.length; row++) {
|
||||
const col = st.rows[row].indexOf(glyph);
|
||||
if (col >= 0) return { st, row, col };
|
||||
}
|
||||
throw new Error(`no ${glyph} in ${stageId}`);
|
||||
};
|
||||
|
||||
test('each terrain of the mode, with the numbers of the stage it stands on', () => {
|
||||
// 活性源石 (战场#04): the official tile parameters (damage 70/s, +20% ATK, +20 ASPD, 300 s)
|
||||
const inf = at('act1autochess_m04', 'i');
|
||||
const tip = terrainInfo(inf.st, inf.row, inf.col);
|
||||
assert.equal(tip.key, 'infection');
|
||||
assert.equal(tip.name, '活性源石');
|
||||
assert.equal(tip.tag, '特殊地形');
|
||||
assert.deepEqual([tip.row, tip.col], [inf.row, inf.col]);
|
||||
assert.match(tip.lines[0], /每秒受到 70 点真实伤害/, 'damage from the stage\'s own bb');
|
||||
assert.match(tip.lines[1], /攻击力 \+20%、攻击速度 \+20/);
|
||||
assert.match(tip.lines[2], /300 秒/);
|
||||
assert.deepEqual(tip.facts, ['可部署', '地面单位可通过']);
|
||||
// 沼泽 (战场#06): one 陷入沼泽 layer a second, −5% ASPD (−5% move for enemies), 2 layers at 重量 ≥ 3, 10 at most
|
||||
const mire = terrainInfo(...(() => { const g = at('act2autochess_m02', 'm'); return [g.st, g.row, g.col]; })());
|
||||
assert.equal(mire.name, '沼泽');
|
||||
assert.ok(mire.lines.some((l) => /攻击速度 −5%/.test(l) && /移动速度 −5%/.test(l)));
|
||||
assert.ok(mire.lines.some((l) => /重量 ≥ 3 的敌人一次获得 2 层/.test(l)));
|
||||
assert.ok(mire.lines.some((l) => /最多 10 层/.test(l)));
|
||||
// 排气格栅 (战场#07): the 隐匿-like rule and its limit — the enemy it blocks still hits it (review on #185)
|
||||
const smog = at('act2autochess_m03', 'g');
|
||||
const smogTip = terrainInfo(smog.st, smog.row, smog.col);
|
||||
assert.equal(smogTip.name, '排气格栅');
|
||||
assert.match(smogTip.lines[0], /不会被敌方的远程攻击选中(效果相当于隐匿)/);
|
||||
assert.match(smogTip.lines[1], /挡住敌人的干员仍会被它攻击到/);
|
||||
// 深水区 (战场#05): drowning numbers, and 拒绝部署 although the level's own buildableType is ALL (grid.js)
|
||||
const sea = at('act1autochess_m05', 'd');
|
||||
const seaTip = terrainInfo(sea.st, sea.row, sea.col);
|
||||
assert.equal(seaTip.name, '深水区');
|
||||
assert.ok(seaTip.lines.some((l) => /每秒受到 40 点伤害/.test(l)));
|
||||
assert.ok(seaTip.lines.some((l) => /攻击速度 −60%/.test(l) && /移动速度 ×0.6/.test(l)));
|
||||
// …and what that damage IS: sourceless, and not 环境伤害 (devices.js tickDeepsea; review on #185)
|
||||
assert.ok(seaTip.lines.some((l) => /无来源伤害/.test(l) && /不归类为环境伤害/.test(l)));
|
||||
assert.ok(seaTip.lines.some((l) => /拒绝部署/.test(l)));
|
||||
assert.ok(seaTip.facts.includes('不可部署'));
|
||||
// the gates and teleports every stage carries (tile_start / tile_end / tile_telin / tile_telout)
|
||||
for (const [glyph, name] of [['S', '红门'], ['E', '蓝门'], ['I', '传送入口'], ['O', '传送出口']]) {
|
||||
const g = at('act1autochess_m04', glyph);
|
||||
const t = terrainInfo(g.st, g.row, g.col);
|
||||
assert.equal(t.name, name, glyph);
|
||||
assert.ok(t.lines.length >= 1);
|
||||
}
|
||||
});
|
||||
|
||||
test('an ordinary tile says nothing (the press keeps its other meanings); nonsense input is safe', () => {
|
||||
const st = stages['act1autochess_m04'];
|
||||
const inf = at('act1autochess_m04', 'i');
|
||||
assert.ok(terrainInfo(inf.st, inf.row, inf.col), 'the stage does carry one tile that answers');
|
||||
// every non-special glyph the stage uses: the bench, the blocked rows, road / floor, the fence, the separator…
|
||||
for (const glyph of ['a', 'A', '#', 'X', 'r', 'f', 'b', 'h', 'p']) {
|
||||
let found = null;
|
||||
for (let row = 0; row < st.rows.length && !found; row++) {
|
||||
const col = st.rows[row].indexOf(glyph);
|
||||
if (col >= 0) found = { row, col };
|
||||
}
|
||||
if (!found) continue; // a glyph this stage does not use says nothing to test
|
||||
assert.equal(terrainInfo(st, found.row, found.col), null, glyph);
|
||||
}
|
||||
assert.equal(terrainInfo(inf.st, -1, inf.col), null);
|
||||
assert.equal(terrainInfo(inf.st, inf.st.rows.length, 0), null);
|
||||
assert.equal(terrainInfo(inf.st, inf.row, 999), null);
|
||||
assert.equal(terrainInfo(inf.st, 1.5, 2), null);
|
||||
assert.equal(terrainInfo(inf.st, '3', 2), null);
|
||||
assert.equal(terrainInfo(null, 0, 0), null);
|
||||
assert.equal(terrainInfo(undefined, 0, 0), null);
|
||||
assert.equal(terrainInfo({}, 0, 0), null);
|
||||
// a stage without the tile legend (or without that glyph) explains nothing rather than guessing
|
||||
assert.equal(terrainInfo({ rows: ['i'] }, 0, 0), null);
|
||||
assert.equal(terrainInfo({ rows: ['i'], tiles: {} }, 0, 0), null);
|
||||
assert.equal(terrainInfo({ rows: ['i'], tiles: { i: null } }, 0, 0), null);
|
||||
// …and a legend entry the mode never gave a tip (its own ordinary floor) is not a tip either
|
||||
assert.equal(terrainInfo({ rows: ['z'], tiles: { z: { tileKey: 'tile_floor', special: null } } }, 0, 0), null);
|
||||
});
|
||||
|
||||
test('the facts come from the tile\'s own legend entry; missing terrain parameters never crash', () => {
|
||||
const stage = {
|
||||
rows: ['ih'],
|
||||
tiles: {
|
||||
i: { tileKey: 'tile_infection', height: 'LOW', buildable: 'RANGED', groundPassable: false, special: 'infection' },
|
||||
h: { tileKey: 'tile_smog', height: 'HIGH', buildable: 'NONE', groundPassable: false, special: 'smog' },
|
||||
},
|
||||
special: {},
|
||||
};
|
||||
assert.deepEqual(terrainInfo(stage, 0, 0).facts, ['仅远程位可部署', '只有空中单位能通过']);
|
||||
assert.deepEqual(terrainInfo(stage, 0, 1).facts, ['不可部署', '高台', '只有空中单位能通过']);
|
||||
// a stage whose `special` is missing: the mechanism is still explained, just without the stage's numbers
|
||||
const bare = { rows: ['ih'], tiles: stage.tiles };
|
||||
assert.deepEqual(terrainInfo(bare, 0, 0).lines, ['在其上的我方单位与经过的敌方单位持续受到伤害']);
|
||||
assert.deepEqual(terrainInfo(bare, 0, 1).lines, ['站在排气格栅上的干员不会被敌方的远程攻击选中(效果相当于隐匿)', '但挡住敌人的干员仍会被它攻击到']);
|
||||
// 沼泽 without its parameters falls back to the official template numbers
|
||||
const mire = { rows: ['m'], tiles: { m: { tileKey: 'tile_mire', height: 'LOW', buildable: 'ALL', groundPassable: true, special: 'mire' } } };
|
||||
assert.match(terrainInfo(mire, 0, 0).lines[0], /每 1 秒获得 1 层/);
|
||||
assert.match(terrainInfo(mire, 0, 0).lines[2], /最多 10 层/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
// GitHub issue #184 「建议加入对于特殊地形的单击信息提示」: the tap's target (`screens/game.js` tileClick hands over
|
||||
// `{ kind: 'terrain', terrain }`, resolved from the stage the board on screen is built from) turns into the panel's
|
||||
// terrain card — and a target that carries no tip resolves to nothing rather than opening an empty card.
|
||||
// The words / numbers themselves are covered in test/ui/gameLogic.test.js (terrainInfo) and the tap itself reaches them
|
||||
// in the browser (test/ui/terrain-tip.e2e.test.js); this is the link between the two.
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
|
||||
const { resolveDetail } = await import('../../public/js/ui/detailPanel.js');
|
||||
const { terrainInfo } = await import('../../public/js/ui/gameLogic.js');
|
||||
|
||||
/** The first (row, col) of `glyph` in a stage of data/stages.json. */
|
||||
function at(stage, glyph) {
|
||||
for (let row = 0; row < stage.rows.length; row++) {
|
||||
const col = stage.rows[row].indexOf(glyph);
|
||||
if (col >= 0) return { row, col };
|
||||
}
|
||||
throw new Error(`no ${glyph} in ${stage.id}`);
|
||||
}
|
||||
|
||||
test('a terrain target resolves into the panel card; a tip-less target resolves to nothing', () => {
|
||||
const stages = JSON.parse(readFileSync(path.join(ROOT, 'data', 'stages.json'), 'utf8'));
|
||||
const st = stages['act1autochess_m04'];
|
||||
const p = at(st, 'i');
|
||||
const info = terrainInfo(st, p.row, p.col);
|
||||
assert.equal(info.name, '活性源石');
|
||||
assert.deepEqual(resolveDetail({ kind: 'terrain', terrain: info }, new Map()), { type: 'terrain', terrain: info });
|
||||
// the screen never hands a tip-less tile over (terrainInfo returns null there), but a stray target must not throw or
|
||||
// open an empty card
|
||||
assert.equal(resolveDetail({ kind: 'terrain' }, new Map()), null);
|
||||
assert.equal(resolveDetail({ kind: 'terrain', terrain: null }, new Map()), null);
|
||||
assert.equal(resolveDetail({ kind: 'terrain', terrain: '活性源石' }, new Map()), null);
|
||||
assert.deepEqual(resolveDetail({ kind: 'terrain', terrain: {} }, new Map()), { type: 'terrain', terrain: {} },
|
||||
'a well-formed but empty tip still resolves (the screen filtered it before)');
|
||||
assert.equal(resolveDetail(null, new Map()), null);
|
||||
});
|
||||
@@ -0,0 +1,172 @@
|
||||
// Browser checks of the special-terrain tip (GitHub issue #184 「建议加入对于特殊地形的单击信息提示」) on the in-match mock
|
||||
// harness (public/dev/game-mock.html):
|
||||
// SP_E2E=1 CHROME_PATH=… node --test test/ui/terrain-tip.e2e.test.js
|
||||
//
|
||||
// A tap on the GROUND — nothing stands there — makes the tile explain itself, on both field implementations: the engine
|
||||
// canvas (render/app.js `tileClick`, reached by clicking the tile's own screen position) and the DOM fallback
|
||||
// (ui/fallbackField.js, the tile div). Asserted: the mock's own stage (act2autochess_m01) carries gates, a tap on 蓝门 /
|
||||
// 红门 opens the card with its mechanism line, a tap on an ordinary floor tile opens nothing (and closes the card that was
|
||||
// open), and no scenario logs a console error. The words and the numbers are unit-tested in test/ui/gameLogic.test.js
|
||||
// (terrainInfo, against every terrain of the real stages) — this file is about the tap reaching them.
|
||||
|
||||
import { test, describe, before, after } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { existsSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
|
||||
const CHROME = process.env.CHROME_PATH || '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
|
||||
const ENABLED = process.env.SP_E2E === '1' && existsSync(CHROME);
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
/** Tiles of the mock's stage (act2autochess_m01) the tip must answer for, and one ordinary tile it must not. */
|
||||
const GATE = { row: 9, col: 10, name: '红门' };
|
||||
const GOAL = { row: 9, col: 2, name: '蓝门' };
|
||||
const FLOOR = { row: 10, col: 5, name: null };
|
||||
|
||||
describe('special terrain tip in the browser', { 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();
|
||||
});
|
||||
|
||||
async function open(url, { w = 1600, h = 900, waitUntil = 'networkidle0' } = {}) {
|
||||
const page = await browser.newPage();
|
||||
await page.setViewport({ width: w, height: h });
|
||||
const problems = [];
|
||||
page.on('console', (m) => { if (m.type() === 'error') problems.push(`console: ${m.text()}`); });
|
||||
page.on('pageerror', (e) => problems.push(`pageerror: ${e.message}`));
|
||||
page.on('requestfailed', (r) => { if (r.failure()?.errorText !== 'net::ERR_ABORTED') problems.push(`requestfailed: ${r.url()} ${r.failure()?.errorText}`); });
|
||||
// the engine keeps fetching art (and SwiftShader is slow to build the board), so it waits on __SP_VIEW__ instead
|
||||
await page.goto(`${base}${url}`, { waitUntil, timeout: 60000 });
|
||||
return { page, problems };
|
||||
}
|
||||
|
||||
/** The open detail card's text (null while none is open). */
|
||||
const card = (page) => page.evaluate(() => {
|
||||
const el = document.querySelector('.dpanel');
|
||||
return el ? el.textContent.replace(/\s+/g, ' ').trim() : null;
|
||||
});
|
||||
|
||||
test('the fallback (DOM) board: a tap on 蓝门 / 红门 opens the card, an ordinary tile opens nothing', async () => {
|
||||
const { page, problems } = await open('/dev/game-mock.html?phase=PREP&render=fallback');
|
||||
await page.waitForSelector('.ff-tile', { timeout: 15000 });
|
||||
await sleep(400);
|
||||
const tap = (row, col) => page.evaluate(([r, c]) => {
|
||||
const el = document.querySelector(`.ff-tile[data-row="${r}"][data-col="${c}"]`);
|
||||
if (!el) return false;
|
||||
const b = el.getBoundingClientRect();
|
||||
el.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, cancelable: true, button: 0, pointerId: 1, pointerType: 'mouse', clientX: b.left + b.width / 2, clientY: b.top + b.height / 2 }));
|
||||
return true;
|
||||
}, [row, col]);
|
||||
|
||||
assert.ok(await tap(GOAL.row, GOAL.col), 'the mock board draws the 蓝门 tile');
|
||||
await page.waitForSelector('.dpanel', { timeout: 5000 });
|
||||
let text = await card(page);
|
||||
assert.match(text, /蓝门/, text);
|
||||
assert.match(text, /目标生命值/, text);
|
||||
assert.match(text, /保护目标/, text);
|
||||
|
||||
// the 红门 (the enemies' own entrance) says its own thing
|
||||
assert.ok(await tap(GATE.row, GATE.col));
|
||||
await sleep(200);
|
||||
text = await card(page);
|
||||
assert.match(text, /红门/, text);
|
||||
assert.match(text, /从这里出场/, text);
|
||||
|
||||
// an ordinary floor tile: nothing to explain, and the card that was open closes with the press
|
||||
assert.ok(await tap(FLOOR.row, FLOOR.col));
|
||||
await sleep(200);
|
||||
assert.equal(await card(page), null, 'an ordinary tile opens nothing');
|
||||
assert.deepEqual(problems, []);
|
||||
await page.close();
|
||||
});
|
||||
|
||||
test('the engine canvas: the same tap on a tile\'s own screen position opens the card', async () => {
|
||||
const { page, problems } = await open('/dev/game-mock.html?phase=PREP&render=engine', { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForFunction(() => !!globalThis.__SP_VIEW__?.tileScreen, { timeout: 30000 });
|
||||
await sleep(1200); // models / camera
|
||||
const at = (row, col) => page.evaluate(([r, c]) => {
|
||||
const t = globalThis.__SP_VIEW__.tileScreen(r, c);
|
||||
return t && { x: t.x, y: t.y };
|
||||
}, [row, col]);
|
||||
const pt = await at(GOAL.row, GOAL.col);
|
||||
assert.ok(pt, 'the 蓝门 tile has a screen position');
|
||||
await page.mouse.click(pt.x, pt.y);
|
||||
await page.waitForSelector('.dpanel', { timeout: 5000 });
|
||||
let text = await card(page);
|
||||
assert.match(text, /蓝门/, text);
|
||||
assert.match(text, /目标生命值/, text);
|
||||
|
||||
const floor = await at(FLOOR.row, FLOOR.col);
|
||||
await page.mouse.click(floor.x, floor.y);
|
||||
await sleep(250);
|
||||
assert.equal(await card(page), null, 'the floor tile closes it again');
|
||||
assert.deepEqual(problems, []);
|
||||
await page.close();
|
||||
});
|
||||
|
||||
test('Final Assault prep: the tap reports the BOARD tile, so the boss field explains ITS tile (review on #185)', async () => {
|
||||
const { page, problems } = await open('/dev/game-mock.html?phase=PREP&variant=boss&render=engine', { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForFunction(() => !!globalThis.__SP_VIEW__?.tileScreen, { timeout: 30000 });
|
||||
await page.waitForFunction(() => globalThis.__SP_VIEW__.raw.prepField?.().kind === 'bossPrep', { timeout: 15000 });
|
||||
await sleep(1200); // camera / models (a deployment with the local board art also builds the 3D board here)
|
||||
// A board tile of the player's half + the stage tile it draws (stage 2–5 shown as board 9–12): the card must be about
|
||||
// the stage tile. Reporting the drawn tile instead (the old groundTile) made the screen convert a second time, which
|
||||
// on this board explains a DIFFERENT tile — or none at all.
|
||||
const target = await page.evaluate(async () => {
|
||||
const { data } = await import('/js/data.js');
|
||||
const gl = await import('/js/ui/gameLogic.js');
|
||||
const S = globalThis.__MOCK__.S();
|
||||
const stage = data.lookup('stages', S.pub.stageId);
|
||||
for (let row = 9; row <= 12; row++) {
|
||||
for (let col = 0; col <= 10; col++) {
|
||||
const [sr, sc] = gl.fieldTile('bossL', row, col);
|
||||
const info = gl.terrainInfo(stage, sr, sc);
|
||||
if (!info) continue;
|
||||
const t = globalThis.__SP_VIEW__.tileScreen(row, col);
|
||||
if (t) return { row, col, sr, sc, name: info.name, x: t.x, y: t.y };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
});
|
||||
assert.ok(target, 'the boss field has a special tile on the player half');
|
||||
// The boss-prep camera is still settling when the board appears (and building the 3D board shifts a machine's timing),
|
||||
// so wait for the tile's screen position to stop moving, and re-read it before each try.
|
||||
const at = () => page.evaluate(([r, c]) => {
|
||||
const t = globalThis.__SP_VIEW__.tileScreen(r, c);
|
||||
return t && { x: t.x, y: t.y };
|
||||
}, [target.row, target.col]);
|
||||
let settled = null;
|
||||
for (let i = 0; i < 40; i++) {
|
||||
const p = await at();
|
||||
if (p && settled && Math.abs(p.x - settled.x) < 1 && Math.abs(p.y - settled.y) < 1) break;
|
||||
settled = p;
|
||||
await sleep(150);
|
||||
}
|
||||
assert.ok(settled, 'the boss-field tile still has a screen position');
|
||||
let text = null;
|
||||
for (let i = 0; i < 3 && text === null; i++) {
|
||||
const p = (await at()) || settled;
|
||||
await page.mouse.click(p.x, p.y);
|
||||
try { await page.waitForSelector('.dpanel', { timeout: 3000 }); } catch { continue; } // a tap that missed: retry
|
||||
text = await card(page);
|
||||
}
|
||||
assert.ok(text !== null, 'the tap opened the terrain card');
|
||||
assert.match(text, new RegExp(target.name), `board (${target.row},${target.col}) → stage (${target.sr},${target.sc}) is ${target.name}, card: ${text}`);
|
||||
assert.deepEqual(problems, []);
|
||||
await page.close();
|
||||
});
|
||||
});
|
||||
@@ -252,6 +252,11 @@ export const BATTLE_SFX = Object.freeze({
|
||||
enemyDieHeavy: { path: 'battle/b_enemy/b_enemy_dead_h.mp3' },
|
||||
enemyHit: { path: 'enemy/e_imp/e_imp_general_w.mp3' },
|
||||
heal: { bank: 'battle.ON_MODIFIER_HEAL' },
|
||||
// 漏怪: an enemy reached the exit. This is the ORIGINAL Arknights stage cue (`battle.ON_ENEMY_REACHED_EXIT` ->
|
||||
// `Battle/b_ui/b_ui_alarmenter`), not an autochess one — the mode itself has no bank named for an escape (all 13,948
|
||||
// SFX banks of audio_data.json searched). The official treats it as a one-shot alarm: `maxSoundAllowed: 1` with
|
||||
// `popOldest: true` on the `Battle_UI_Important` mixer, so a new escape replaces the one still playing.
|
||||
leak: { bank: 'battle.ON_ENEMY_REACHED_EXIT' },
|
||||
win: { path: 'battle/b_ui/b_ui_win.mp3' },
|
||||
lose: { path: 'battle/b_ui/b_ui_lose.mp3' },
|
||||
killCoin: { bank: 'battle.ON_CUSTOM_TRIGGER.autochess_kill_gain_coin' },
|
||||
|
||||
@@ -26,15 +26,19 @@ aklz4.py registers a decoder for it. This script pulls the art the web sources l
|
||||
- derived PBR maps for three.js (DERIVED): Unity stores the theme's normal map as two channels (BC5: RG = XY, B = 0)
|
||||
and metallic/gloss with the smoothness in A; the board renderer needs an RGB normal map (Z rebuilt) and a
|
||||
roughness map in G (1 − smoothness; metalness B = 0), written next to the source as <name>_rgb.png / _rough.png
|
||||
- WebP copies of the board textures every player downloads with the 3D board (WEBP), written next to their PNGs;
|
||||
the manifest lists the copy
|
||||
|
||||
Usage:
|
||||
python3 -m venv .venv && .venv/bin/pip install -r tools/local-extract/requirements.txt
|
||||
.venv/bin/python tools/local-extract/extract.py [--game <AB root>] [--out public/assets/local] [--only <subdir prefix>]
|
||||
python3 tools/local-extract/extract.py --print-jobs (the job table as JSON; needs no dependencies)
|
||||
python3 tools/local-extract/extract.py --webp (only the WebP copies, from the PNGs already extracted —
|
||||
e.g. the local art copied from a release bundle; needs Pillow)
|
||||
|
||||
Writes <out>/**.png|.skel|.atlas and data/local-assets.json (manifest of what was extracted). With --only, just the
|
||||
jobs whose output subdir starts with one of the prefixes run, and their groups replace those of the existing manifest
|
||||
(every other group is kept as is).
|
||||
Writes <out>/**.png|.webp|.skel|.atlas and data/local-assets.json (manifest of what was extracted). With --only, just
|
||||
the jobs whose output subdir starts with one of the prefixes run, and their groups replace those of the existing
|
||||
manifest (every other group is kept as is).
|
||||
Everything is (c) Hypergryph; for private, non-commercial fan use only.
|
||||
"""
|
||||
import argparse
|
||||
@@ -118,6 +122,32 @@ DERIVED = [
|
||||
('map/autochess', 'TX_autochessi_M', 'rough_from_gloss', 'TX_autochessi_M_rough'),
|
||||
]
|
||||
|
||||
# The board textures every player downloads when a match shows the 3D board (public/js/render/board3d/load.js
|
||||
# PACK_IMAGES; D / common_D / BG also feed the 2D board art, render/boardArt.js): (output subdir, name, mode). A WebP
|
||||
# copy is written next to the PNG and the manifest lists the copy instead (≈ 6.7 MB → 2.0 MB per cold start); the PNG
|
||||
# stays for tools/crop-board-atlas.mjs and setup's check. 'lossy' = colour maps at quality 95 with the alpha lossless
|
||||
# and the RGB under transparent texels kept (`exact`: the board material is opaque and samples it); 'lossless' =
|
||||
# normal and data maps, whose channels hold independent values that lossy WebP's chroma subsampling would mix (a
|
||||
# normal map ends up tens of degrees off). A Pillow without WebP support keeps the PNG.
|
||||
WEBP = [
|
||||
('map/autochess', 'TX_autochessi_D', 'lossy'),
|
||||
('map/autochess', 'TX_autochessi_BG', 'lossy'),
|
||||
('map/autochess', 'TX_autochessi_common_D', 'lossy'),
|
||||
('map/autochess', 'TX_autochessi_N_rgb', 'lossless'),
|
||||
('map/autochess', 'TX_autochessi_M_rough', 'lossless'),
|
||||
('map/autochess', 'TX_autochessi_E', 'lossless'),
|
||||
('map/autochess', 'TX_autochessi_common_E', 'lossless'),
|
||||
('map/common', 'TX_wind_device', 'lossy'),
|
||||
('map/fx', '[opt]merged_textures', 'lossless'),
|
||||
('map/water', '[ucp]TX_water_normal', 'lossless'),
|
||||
('map/water', 'TX_Caustics256', 'lossy'),
|
||||
('map/water', 'T_noise_clouds_01', 'lossless'),
|
||||
]
|
||||
WEBP_OPTIONS = {
|
||||
'lossy': {'quality': 95, 'method': 6, 'alpha_quality': 100, 'exact': True},
|
||||
'lossless': {'lossless': True, 'quality': 100, 'method': 6, 'exact': True},
|
||||
}
|
||||
|
||||
# Shader bundles loaded beside every job that exports Materials, only so that the materials' shader references
|
||||
# (external CABs) resolve to a name in materials.json (e.g. Torappu/Scene/StandardDirectional); nothing is exported
|
||||
# from them.
|
||||
@@ -314,6 +344,44 @@ def run_derived(out_root, sub, manifest, log):
|
||||
return n
|
||||
|
||||
|
||||
def run_webp(out_root, sub, manifest, log):
|
||||
"""Write the WEBP copies of output subdir `sub` from its PNGs and point their manifest entries at them."""
|
||||
n = 0
|
||||
for wsub, name, mode in WEBP:
|
||||
entry = manifest.get(sub, {}).get(name) if wsub == sub else None
|
||||
png = Path(out_root) / sub / f'{name}.png'
|
||||
if not entry or not png.exists():
|
||||
continue
|
||||
dst = png.with_name(f'{name}.webp')
|
||||
try:
|
||||
from PIL import Image
|
||||
with Image.open(png) as img:
|
||||
img.save(dst, 'WEBP', **WEBP_OPTIONS[mode])
|
||||
except Exception as e: # no WebP support in this Pillow, or a broken PNG: the entry keeps the PNG
|
||||
log(f' warn webp {name}: {e}')
|
||||
continue
|
||||
entry.update(path=f'/assets/local/{sub}/{dst.name}', webp=mode)
|
||||
n += 1
|
||||
return n
|
||||
|
||||
|
||||
def webp_only(out_root, manifest_path, log):
|
||||
"""--webp: the WEBP copies of an existing extraction (e.g. the local art copied from a release bundle)."""
|
||||
try:
|
||||
doc = json.loads(Path(manifest_path).read_text(encoding='utf-8'))
|
||||
except (OSError, ValueError) as e:
|
||||
print(f'No readable manifest at {manifest_path} ({e}). Extract the local art first.', file=sys.stderr)
|
||||
return 2
|
||||
groups = doc.get('groups') if isinstance(doc, dict) else None
|
||||
if not isinstance(groups, dict):
|
||||
print(f'{manifest_path} has no groups. Extract the local art first.', file=sys.stderr)
|
||||
return 2
|
||||
n = sum(run_webp(out_root, sub, groups, log) for sub in dict.fromkeys(s for s, _, _ in WEBP))
|
||||
Path(manifest_path).write_text(json.dumps(doc, ensure_ascii=False, separators=(',', ':')), encoding='utf-8')
|
||||
log(f'done: {n} WebP copies, manifest {manifest_path}')
|
||||
return 0 if n else 1
|
||||
|
||||
|
||||
def merge_manifest(old_groups, new_groups, ran_subs):
|
||||
"""Groups of the previous manifest minus the subdirs that were re-extracted, plus the new ones (sorted keys)."""
|
||||
out = {g: v for g, v in (old_groups or {}).items() if g not in ran_subs}
|
||||
@@ -415,6 +483,9 @@ def export_bundle(ab_root, job, out_root, manifest, log):
|
||||
n += 1
|
||||
n += run_derived(out_root, sub, manifest, log)
|
||||
log(f'{rel}: {n} files -> {sub}')
|
||||
k = run_webp(out_root, sub, manifest, log)
|
||||
if k:
|
||||
log(f' {k} WebP copies -> {sub}')
|
||||
return n
|
||||
|
||||
|
||||
@@ -555,16 +626,22 @@ def main():
|
||||
ap.add_argument('--only', action='append', default=[], metavar='SUBDIR',
|
||||
help='only run the jobs whose output subdir starts with this prefix (repeatable), e.g. emoticon')
|
||||
ap.add_argument('--print-jobs', action='store_true', help='print the job table as JSON and exit')
|
||||
ap.add_argument('--webp', action='store_true',
|
||||
help='only write the WebP copies of the board textures from the PNGs already under --out and list '
|
||||
'them in the manifest (needs Pillow, not the client)')
|
||||
args = ap.parse_args()
|
||||
|
||||
if args.print_jobs:
|
||||
jobs = [{'bundle': rel, 'sub': sub, 'kinds': sorted(kinds), 'keep': keep.pattern if keep else None}
|
||||
for rel, sub, kinds, keep in map(job_parts, JOBS)]
|
||||
derived = [{'sub': sub, 'from': src, 'derive': kind, 'name': name} for sub, src, kind, name in DERIVED]
|
||||
webp = [{'sub': sub, 'name': name, 'mode': mode} for sub, name, mode in WEBP]
|
||||
print(json.dumps({'emoteThemes': [{'themeId': t, 'dir': d} for t, d in EMOTE_THEMES], 'jobs': jobs,
|
||||
'derived': derived, 'enemySpines': {'bundles': ENEMY_ART, 'sub': ENEMY_SPINE_SUB,
|
||||
'ids': ENEMY_SPINES}}, ensure_ascii=False))
|
||||
'derived': derived, 'webp': webp, 'enemySpines': {'bundles': ENEMY_ART, 'sub': ENEMY_SPINE_SUB,
|
||||
'ids': ENEMY_SPINES}}, ensure_ascii=False))
|
||||
return 0
|
||||
if args.webp:
|
||||
return webp_only(Path(args.out), Path(args.manifest), print)
|
||||
|
||||
ab_root = Path(args.game) if args.game else next((p for p in CANDIDATES if p.exists()), None)
|
||||
if not ab_root or not ab_root.exists():
|
||||
|
||||
Reference in new issue
Block a user