From f6f5ed50cd510fdec30a0c03ed9a36360edd25da Mon Sep 17 00:00:00 2001 From: Cartethyia <2100928311@qq.com> Date: Tue, 6 Oct 2026 14:08:18 +0800 Subject: [PATCH] =?UTF-8?q?A=20special=20terrain=20tile=20explains=20itsel?= =?UTF-8?q?f=20on=20a=20tap,=20and=20=E6=B4=BB=E6=80=A7=E6=BA=90=E7=9F=B3?= =?UTF-8?q?=20is=20one=20material=20on=20both=20boards=20(GitHub=20#184)?= =?UTF-8?q?=20(#185)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tapping a 活性源石 / 沼泽 / 深水区 / 排气格栅 / gate / teleport tile opens a read-only card with that stage's own terrain numbers (the boss-prep board picks the board tile once, through prepXf.toBoard); the 2D atlas cell and the 3D shader now share one 活性源石 palette and recipe and draw to the tile edge. Contributed by @Convey123. --- docs/DESIGN.md | 22 +++- docs/PLAYING.md | 1 + public/js/render/app.js | 27 +++- public/js/render/board3d/materials.js | 28 +++- public/js/render/style.js | 31 +++++ public/js/render/textures.js | 122 +++++++++++++++--- public/js/render/tiles.js | 7 +- public/js/screens/game.js | 25 +++- public/js/ui/detailPanel.js | 26 +++- public/js/ui/fallbackField.js | 3 + public/js/ui/gameLogic.js | 115 ++++++++++++++++- test/render/originium-art.test.js | 79 ++++++++++++ test/render/originium-cell.e2e.test.js | 133 +++++++++++++++++++ test/ui/gameLogic.test.js | 110 +++++++++++++++- test/ui/terrain-detail.test.js | 41 ++++++ test/ui/terrain-tip.e2e.test.js | 172 +++++++++++++++++++++++++ 16 files changed, 905 insertions(+), 37 deletions(-) create mode 100644 test/render/originium-art.test.js create mode 100644 test/render/originium-cell.e2e.test.js create mode 100644 test/ui/terrain-detail.test.js create mode 100644 test/ui/terrain-tip.e2e.test.js diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 2b22969a..53b466b5 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -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. +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; a special terrain tile explains itself on a tap (GitHub #184) → §24.5; 活性源石 was two different materials on the two boards → §24.6. ### 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`) @@ -2154,3 +2154,23 @@ Reports after the 0.1.3 release. Each was checked against the official data and - **A PASSIVE skill reaches none of this through `activate()`**: `Skill.activate()` returns early for `kind === 'passive'` (`sim/skills.js`); a passive goes through `_startPassive()` (active + mods + `onStart`). Three of the 57 instant clips are passives in the data — 琳琅诗怀雅 S1 仗义疏财 / S2 “见面礼” and 凯瑟琳 S1; 凯瑟琳 S1 turned out to be `kind: instant` in the sim (so it already casts), and the two 琳琅诗怀雅 ones now fire the same window from `_startPassive` — on at the deployment, off after `SKILL_ANIM_WINDOW` (0.5 s) while the passive itself stays active. So all 57 are live. - **Deploy-time passives with a duration are left alone** (缄默德克萨斯 S1–S3 8–12 s, 野鬃 S1, 伊内丝 S3, 耀骑士临光 S2): the sim keeps a passive active until death, so how long its stance should show is a separate question. A clip that IS the normal attack clip (`anims.skill.loop === anims.attack.loop`: 缄默德克萨斯 S1, 砾, 普罗旺斯, 斯卡蒂 S2) is never played as a skill animation — the new branch requires a distinct clip, so a deploy-time passive on those shows no attack twitch. - **Tests**: `test/render/feedback4-texas-skill-clip.test.js` (德克萨斯 S2: the clip plays, holds through the same-tick off, then the idle, with no base flash in between; two more real instant entries — `char_140_whitew` S1 日晷, `char_206_gnosis` S2 零度爆发; 银灰 S3 真银斩 — the same shape but long — rests without freezing on the clip's last frame and still attacks with the loop; the deploy-time passives 琳琅诗怀雅 S1/S2 hold their 1 s clip through the sim's 0.5 s window and then idle; a clip that is the attack clip falls through to the base clip; unchanged: 折桠 S2 begin → idle, 耀骑士临光 S3 own idle, 宴 begin → loop). `test/sim/skills.test.js` covers the sim side (the passive window opens at the deployment and closes 0.5 s later, the passive staying active; a timed passive fires nothing). `test/render/feedback3-skill-idle.test.js`, `feedback3-move-deploy.test.js`, `loadout-skill.test.js` and the full suite stay green. + +### 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[]` — 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 `, 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). diff --git a/docs/PLAYING.md b/docs/PLAYING.md index ca9a8071..d93debab 100644 --- a/docs/PLAYING.md +++ b/docs/PLAYING.md @@ -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 的玩家淘汰(其干员回到公共卡池)。 | diff --git a/public/js/render/app.js b/public/js/render/app.js index 97df1c9d..83d7b249 100644 --- a/public/js/render/app.js +++ b/public/js/render/app.js @@ -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; diff --git a/public/js/render/board3d/materials.js b/public/js/render/board3d/materials.js index b862daec..d986c485 100644 --- a/public/js/render/board3d/materials.js +++ b/public/js/render/board3d/materials.js @@ -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 ', '}', diff --git a/public/js/render/style.js b/public/js/render/style.js index 5bfbdaa4..b5226c57 100644 --- a/public/js/render/style.js +++ b/public/js/render/style.js @@ -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 `, 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 + * ` 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' }, diff --git a/public/js/render/textures.js b/public/js/render/textures.js index 85290290..3182c8e7 100644 --- a/public/js/render/textures.js +++ b/public/js/render/textures.js @@ -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; diff --git a/public/js/render/tiles.js b/public/js/render/tiles.js index d2eeae93..68c2b90d 100644 --- a/public/js/render/tiles.js +++ b/public/js/render/tiles.js @@ -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; diff --git a/public/js/screens/game.js b/public/js/screens/game.js index 131f4009..936e5bac 100644 --- a/public/js/screens/game.js +++ b/public/js/screens/game.js @@ -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 @@ -885,6 +895,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]); diff --git a/public/js/ui/detailPanel.js b/public/js/ui/detailPanel.js index 5f5ecc01..901fb4b6 100644 --- a/public/js/ui/detailPanel.js +++ b/public/js/ui/detailPanel.js @@ -602,13 +602,36 @@ export function TokenDetail({ token, piece, ownerId = null, snapHp = null, live ${talents.length ? html`<${Section} title="天赋">${talents.map((t, i) => html`

${t.name} ${t.desc}

`)}` : 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` +
+
<${Icon} name="info" />
+
+
${terrain.tag}
+

${terrain.name}

+
+
+ <${Section} title="地形机制" micro="TERRAIN"> + ${terrain.lines.map((t, i) => html`

${t}

`)} + + ${Array.isArray(terrain.facts) && terrain.facts.length ? html`<${Section} title="这一格">

${terrain.facts.join(' · ')}

` : 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} 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} `; } diff --git a/public/js/ui/fallbackField.js b/public/js/ui/fallbackField.js index d0d9008f..86943acf 100644 --- a/public/js/ui/fallbackField.js +++ b/public/js/ui/fallbackField.js @@ -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`
{ 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`}>
`); } } diff --git a/public/js/ui/gameLogic.js b/public/js/ui/gameLogic.js index a2a6a0c1..186ce3dd 100644 --- a/public/js/ui/gameLogic.js +++ b/public/js/ui/gameLogic.js @@ -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[]` + * 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, 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']); @@ -1564,7 +1675,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 diff --git a/test/render/originium-art.test.js b/test/render/originium-art.test.js new file mode 100644 index 00000000..9ad1084b --- /dev/null +++ b/test/render/originium-art.test.js @@ -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\)/); +}); diff --git a/test/render/originium-cell.e2e.test.js b/test/render/originium-cell.e2e.test.js new file mode 100644 index 00000000..758ed4e7 --- /dev/null +++ b/test/render/originium-cell.e2e.test.js @@ -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)})`); + } + }); +}); diff --git a/test/ui/gameLogic.test.js b/test/ui/gameLogic.test.js index 9369d908..a8282184 100644 --- a/test/ui/gameLogic.test.js +++ b/test/ui/gameLogic.test.js @@ -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'; @@ -592,3 +592,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 层/); + }); +}); diff --git a/test/ui/terrain-detail.test.js b/test/ui/terrain-detail.test.js new file mode 100644 index 00000000..bc10ec2e --- /dev/null +++ b/test/ui/terrain-detail.test.js @@ -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); +}); diff --git a/test/ui/terrain-tip.e2e.test.js b/test/ui/terrain-tip.e2e.test.js new file mode 100644 index 00000000..548743ee --- /dev/null +++ b/test/ui/terrain-tip.e2e.test.js @@ -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(); + }); +});