A special terrain tile explains itself on a tap, and 活性源石 is one material on both boards (GitHub #184) (#185)
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.
This commit is contained in:
1 parent
d582925816
commit
f6f5ed50cd
16 files changed
+905
-37
No files matched your search
+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.
|
||||
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[<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 的玩家淘汰(其干员回到公共卡池)。 |
|
||||
|
||||
+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>',
|
||||
'}',
|
||||
|
||||
@@ -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
|
||||
@@ -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]);
|
||||
|
||||
@@ -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']);
|
||||
@@ -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
|
||||
|
||||
@@ -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)})`);
|
||||
}
|
||||
});
|
||||
});
|
||||
+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';
|
||||
@@ -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 层/);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user