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:
Cartethyia authored and GitHub committed 2026-10-06 14:08:18 +08:00
1 parent d582925816
commit f6f5ed50cd
16 files changed
+905 -37

No files matched your search

+25 -1
View File
@@ -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>`;
}
+3
View File
@@ -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
View File
@@ -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