Files
ark-stronghold-protocol/public/js/ui/gameLogic/terrain.js
T
sganggs 6bfc08270c 0.2.4-dev: round-29 community fixes and the official shared card pool
- Shared card pool and item stock as in the official mode (the owner's request, from the 路标档案馆 video and public notes): solo low-tier exceptions, co-op operators x2, shared item stock with special-item exceptions, normal / advanced occupancy 1 / 2, re-check on buy, 机变 exhaustion, release on consume / destroy / elimination; 整备 never exceeds stock; invariant held <= cap.
- 突袭: one taunt-first reselect right after a revive (the owner's rule); ordinary polls and redeploys keep the 0.2.3 rule; a revive on an enemy's tile stays. #475: online boss / hidden-boss Raid may land on the empty half (owner-limited scope).
- 浊心斯卡蒂 S1 x 坚守 no longer bounce redirected damage, and the two shares conserve the hit (idea of PR #464 by @HappyTanuki).
- Operator fixes adapted from PRs by @Xinglan233 (#459 哈洛德 S2 elemental trigger, #460 涤火杰西卡 shield on the four adjacent tiles, #461 家族徽章 first hit after stealth, #462 松果 S2 regrow per deploy); 克莱门莎 S2 splash 1.1 / 100% ATK and air auto-trigger (#467); 白面 copying 歌蕾蒂娅 / 余 skips the three-member condition (#473, the owner's video).
- 香槟炸弹 is not chosen by friendly auras / heals (PR #468 / #476 by @Siornya); 琳琅诗怀雅 coin balance (PR #450 by @IceCodeNew); Touch prep display (PR #451 by @YinqiMargatroid); lobby header (PR #471 by @YuriRestia); Japanese UI terms (PR #448 by @scutkane).
- AI-only boss countdown grace, render-failure cache / recovery without restarting a battle, device units kept on a same-field re-entry, DIY detail / skill previews, large-text result buttons.
- golden: battle families equal 0.2.3 except diy-135 / diy-136 (克莱门莎); all 18 release matches move through the shared pool, every flip named in docs/history/0.2.4.md §29.12; two coverage scenarios added.
2026-10-11 01:58:52 +08:00

312 lines
19 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ui/gameLogic/terrain.js — the tips of a tap on the ground: special terrain tiles (GitHub issue #184: 特殊地形的单击信息
// 提示) and, in the same place, stage devices (阻隔工事 / “双眼皮” / 射击台 / 气流: GitHub #228, PR #229 by @rickylxw —
// deviceInfo / deviceTipAt below). Re-exported from ../gameLogic.js.
//
// The words go through t() (docs/I18N.md: the Chinese text is the msgid, public/i18n/en.json the English): the name /
// tag / fact tables are marked N_() and translated where terrainInfo hands them out, the mechanism lines are built
// with their numbers as params. A device's NAME is a data text (data/stages.json `devices[].name`, localized by the
// data overlay: "Barricade", "'Double Lids'" …), not a msgid.
import { isObj } from './shared.js';
import { t, N_ } from '../../../../shared/i18n.js';
// ---- 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: N_('活性源石'), tag: N_('特殊地形'),
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(t('攻击力 +{atk}%', { atk: Math.round(param(b.atk, 0) * 100) }));
if (param(b.attack_speed, 0)) mods.push(t('攻击速度 +{aspd}', { aspd: param(b.attack_speed, 0) }));
return [
dmg ? t('部署于其上的我方单位、经过的敌方单位,每秒受到 {dmg} 点真实伤害(无来源)', { dmg }) : t('在其上的我方单位与经过的敌方单位持续受到伤害'),
mods.length ? t('同时获得:{mods}', { mods }) : null,
param(b.duration, 0) ? t('效果持续 {sec} 秒;离开地块后仍然保留,再次接触会重新计时', { sec: param(b.duration, 0) }) : null,
].filter(Boolean);
},
},
mire: {
name: N_('沼泽'), tag: N_('特殊地形'),
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);
const sec = param(m.intervalSec, 1);
return [
move
? t('留在沼泽里的单位每 {sec} 秒获得 1 层「陷入沼泽」:攻击速度 {aspd},敌方单位还有移动速度 {move}', { sec, aspd: pctText(per), move: pctText(move) })
: t('留在沼泽里的单位每 {sec} 秒获得 1 层「陷入沼泽」:攻击速度 {aspd}', { sec, aspd: pctText(per) }),
heavy ? t('重量 ≥ {heavy} 的敌人一次获得 2 层', { heavy }) : null,
t('最多 {max} 层;离开沼泽后解除', { max }),
].filter(Boolean);
},
},
smog: {
name: N_('排气格栅'), tag: N_('特殊地形'),
// 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: () => [
t('站在排气格栅上的干员不会被敌方的远程攻击选中(效果相当于隐匿)'),
t('但挡住敌人的干员仍会被它攻击到'),
],
},
deepsea: {
name: N_('深水区'), tag: N_('特殊地形'),
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(t('敌人每秒受到 {dmg} 点伤害', { dmg }));
const slowed = move && move !== 1;
if (aspd && slowed) out.push(t('攻击速度 {aspd}、移动速度 ×{move}', { aspd: pctText(aspd), move }));
else if (aspd) out.push(t('攻击速度 {aspd}', { aspd: pctText(aspd) }));
else if (slowed) out.push(t('移动速度 ×{move}', { move }));
// 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(t('溺水伤害属于无来源伤害(不吃干员的增伤、穿透与装备加成),也不归类为环境伤害'));
out.push(t('拒绝部署(特制水上平台可以让这一格变得可部署)'));
return out;
},
},
start: { name: N_('红门'), tag: N_('敌方入口'), lines: () => [t('敌方单位从这里出场')] },
end: { name: N_('蓝门'), tag: N_('保护目标'), lines: () => [t('敌人走进这里会扣你的目标生命值(LP),一回合至多 10 点')] },
telin: { name: N_('传送入口'), tag: N_('特殊地形'), lines: () => [t('敌人走到这里会从场上消失')] },
telout: { name: N_('传送出口'), tag: N_('特殊地形'), lines: () => [t('消失的敌人会从这里重新出现')] },
});
/** 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: N_('可部署'), MELEE: N_('仅近战位可部署'), RANGED: N_('仅远程位可部署'), NONE: N_('不可部署') });
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. It reads the stage only, so a
* 补位 / 自选 board (whose pieces are other records) explains its tiles the same way. The text is in the current
* language (t()).
* @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(t(build));
if (tile.height === 'HIGH') facts.push(t('高台'));
if (tile.groundPassable === false) facts.push(t('只有空中单位能通过'));
else if (tile.groundPassable === true) facts.push(t('地面单位可通过'));
return { key, name: t(tip.name), tag: t(tip.tag), row, col, lines: tip.lines(stage.special).filter((s) => typeof s === 'string' && s), facts };
}
// ---- stage device tips (GitHub #228, PR #229 by @rickylxw: the stage DEVICES, not only the tiles) ---------------------
/** The device roles a tap explains; the rest is invisible (盟约寒风, the 沼泽 / 涨潮 controllers) or not fielded this season. */
const DEVICE_TIP_ROLES = Object.freeze(['crate', 'turret', 'platform', 'blower']);
/** Roles the battle spawns as device UNITS (Battle._spawnStageDevices, devices.js spawnTurret): in a battle a tap finds them by unit. */
const DEVICE_UNIT_ROLES = Object.freeze(['crate', 'turret']);
const TIP_TAG = N_('场地装置');
const DIR_NAME = Object.freeze({ UP: N_('上'), DOWN: N_('下'), LEFT: N_('左'), RIGHT: N_('右') });
const round1 = (v) => Math.round(v * 10) / 10;
/**
* What tapping a stage device says — the same contract as TERRAIN_TIPS, but `lines` read the device's own stage entry
* (data/stages.json `stage.devices[]`: stats / skill blackboard / dir / rangeTiles), the very numbers the sim runs
* (`sim/content/devices.js`, `Battle._spawnStageDevices`), so a tip can never disagree with the battle. The prose is ours
* (docs/PLAYING.md wording; PRTS 阻隔工事 / “双眼皮” / 射击台 / 源石流发生装置 and the sim's behaviour). `stats` is the
* stat grid under the lines (the turret's), `facts` what the tile allows.
*/
const DEVICE_TIPS = Object.freeze({
crate: {
lines: (st, d) => [
t('挡在地面上的工事:地面敌人会绕开它走;只有无路可走时才会撞上来,把它摧毁后继续前进'),
t('生命 {hp} 点,被摧毁后从场上消失', { hp: param(d.stats?.maxHp, 100) }),
],
facts: () => [t(BUILDABILITY.NONE)], // board.js buildDeployMap: an active crate takes its tile off the deploy map
},
turret: {
lines: (st, d) => {
const bb = isObj(d.skill?.bb) ? d.skill.bb : {};
// devices.js spawnTurret: the same blackboard keys and the same `持续N秒` read from the skill text
const dur = /持续(\d+(?:\.\d+)?)秒/.exec(String(d.skill?.desc ?? ''));
const aspdMax = param(bb.max_attack_speed, Infinity);
const fragMax = param(bb.max_damage_scale, Infinity);
return [
t('自动攻击射程内的一名敌人,造成法术伤害,命中的敌人附加脆弱'),
t('按当前层数最高的盟约计算:每 1 层,攻击速度 +{aspd}(至多 +{aspdMax}),附加的脆弱 +{frag}%(至多 +{fragMax}%)', {
aspd: param(bb.attack_speed_per_stack, 1),
aspdMax: Number.isFinite(aspdMax) ? aspdMax : '∞',
frag: round1(param(bb.damage_scale_per_stack, 0.001) * 100),
fragMax: Number.isFinite(fragMax) ? round1((fragMax - 1) * 100) : '∞',
}),
dur ? t('脆弱持续 {sec} 秒', { sec: Number(dur[1]) }) : null,
];
},
stats: (st, d) => {
const s = isObj(d.stats) ? d.stats : {};
const out = [{ k: t('生命上限'), v: param(s.maxHp, 100) }];
if (param(s.atk, 0)) out.push({ k: t('攻击'), v: param(s.atk, 0) });
if (param(s.def, 0)) out.push({ k: t('防御'), v: param(s.def, 0) });
const sec = param(s.bat, 0) > 0 ? param(s.bat, 0) * 100 / (param(s.aspd, 100) > 0 ? param(s.aspd, 100) : 100) : 0;
if (sec) out.push({ k: t('攻击间隔'), v: `${round1(sec)}s` });
return out;
},
facts: () => [t(BUILDABILITY.NONE)],
},
platform: {
lines: () => [
t('高台位装置:地面敌人不能走上这一格'),
t('远程位干员可以部署在其上;站上去的干员在高位,不阻挡敌人'),
],
facts: () => [t(BUILDABILITY.RANGED)], // board.js: an active platform makes its tile a 'ranged' deploy tile
},
blower: {
lines: (st, d) => {
// devices.js buildTerrain: the device's own skill blackboard, else stage.special.blower.bb; rangeTiles = its own tile + the lane
const bb = isObj(d.skill?.bb) ? d.skill.bb : (isObj(st?.blower?.bb) ? st.blower.bb : {});
const dir = DIR_NAME[String(d.dir || 'UP').toUpperCase()] || DIR_NAME.UP;
const lane = Array.isArray(d.rangeTiles) && d.rangeTiles.length > 1 ? d.rangeTiles.length - 1 : 3;
const out = [t('向前方 {n} 格吹出气流(这一台朝{dir})', { n: lane, dir: t(dir) })];
const eq = param(bb['blower_s_character[equal].atk'], 0);
const op = param(bb['blower_s_character[opposite].atk'], 0);
const mods = [];
if (eq) mods.push(t('朝向与风向相同的攻击力 {v}', { v: pctText(eq) }));
if (op) mods.push(t('相反的 {v}', { v: pctText(op) }));
if (mods.length) out.push(t('部署在气流里的干员:{mods}', { mods }));
const eqM = param(bb['blower_s_enemy[equal].move_speed'], 0);
const opM = param(bb['blower_s_enemy[opposite].move_speed'], 0);
const em = [];
if (eqM) em.push(t('顺风移动速度 ×{v}', { v: round2(1 + eqM) }));
if (opM) em.push(t('逆风 ×{v}', { v: round2(1 + opM) }));
if (em.length) out.push(t('在气流里移动的敌人:{mods}', { mods: em }));
return out;
},
facts: () => [t(BUILDABILITY.NONE)],
},
});
const round2 = (v) => Math.round(v * 100) / 100;
/** A stage device entry is drawn: `active` when it carries it, else not `hidden` (render/tiles.js _stageDevices, board3d/layout.js stageDevices). */
const deviceDrawn = (d) => (typeof d.active === 'boolean' ? d.active : !d.hidden);
/** The card of stage device `d` standing at (row, col) — `tip` is DEVICE_TIPS[d.role]. */
function deviceCard(stage, d, row, col) {
const tip = DEVICE_TIPS[d.role];
const lines = tip.lines(stage.special, d).filter((s) => typeof s === 'string' && s);
const stats = typeof tip.stats === 'function' ? tip.stats(stage.special, d) : null;
const name = typeof d.name === 'string' && d.name ? d.name : String(d.role);
return {
key: d.role, name, tag: t(TIP_TAG), row, col, lines, facts: tip.facts(stage.special, d).filter(Boolean),
...(stats && stats.length ? { stats } : {}),
};
}
/**
* The tip a tap on the tile (row, col) opens when a stage DEVICE stands on it (GitHub #228, PR #229), or null: an ordinary
* tile — or a device this client does not draw (`active` false: a turret that stays dormant until 机械援助 / a 机变 card
* switches it on, a crate such a card removed) — falls through to `terrainInfo`. Visibility mirrors what the renderers draw
* (`render/tiles.js _stageDevices`, `board3d/layout.js stageDevices`): `active` when set, else not `hidden`. The stage is
* the shown field's (already the player's own `effectiveStage`, so a card-removed crate is not in the way). The device's
* name is its data text (localized by the data overlay); the lines are in the current language (t()).
* @param {{ devices?: any[], special?: any, rows?: string[], tiles?: Record<string, any> } | null | undefined} stage
* @param {number} row board row (row 0 = the bottom row, DESIGN §1)
* @param {number} col
* @param {{ roles?: readonly string[] }} [opts] `roles`: only these roles answer (default: the four of DEVICE_TIP_ROLES)
* @returns {{ key:string, name:string, tag:string, row:number, col:number, lines:string[], facts:string[], stats?:{k:string,v:string|number}[] } | null}
*/
export function deviceInfo(stage, row, col, { roles = DEVICE_TIP_ROLES } = {}) {
const list = Array.isArray(stage?.devices) ? stage.devices : null;
if (!list || !Number.isInteger(row) || !Number.isInteger(col)) return null;
for (const d of list) {
if (!isObj(d) || !Array.isArray(d.pos) || d.pos[0] !== row || d.pos[1] !== col) continue;
if (!roles.includes(d.role) || !DEVICE_TIPS[d.role] || !deviceDrawn(d)) continue;
return deviceCard(stage, d, row, col);
}
return null;
}
/**
* The tip of a tap on (row, col) while a BATTLE shows the field — the live counterpart of `deviceInfo`: crates and “双眼皮”
* turrets are device UNITS there (kind 'device', one per drawn stage device, `defId` = the device key), so they are
* found by the unit standing on the tile — a turret outside the battle's rect stands on another tile than its stage `pos`
* (devices.js deviceTile) — and what the card says comes from the stage entry of that key. The platforms and blowers are no
* units: they answer by their stage `pos`, as in the prep.
* A destroyed device (HP gone, or no longer in the snapshots once its DIE animation is over) is not there any more: the
* tap belongs to the tile under the wreck (null here, so it falls through to `terrainInfo`). Before the first snapshot of the
* battle has arrived (`snap` empty) a unit counts as standing.
* The result is the same card as `deviceInfo`'s, with `unitId` (the unit, for its live HP: the screen reads `b.snap`
* through it, like a unit card's `snapHp`).
* @param {object|null|undefined} stage the shown field's stage
* @param {number} row @param {number} col
* @param {{ units?: any[]|null, snap?: Map<number, any[]>|null }} battle the field's unit infos (m.field.units) and the
* latest snapshot tuples by unit id ([id, x, y, hp, maxHp, …])
*/
export function deviceTipAt(stage, row, col, { units = null, snap = null } = {}) {
if (!Number.isInteger(row) || !Number.isInteger(col)) return null;
const u = (Array.isArray(units) ? units : []).find((x) => isObj(x) && x.kind === 'device' && Math.round(Number(x.x)) === col && Math.round(Number(x.y)) === row);
if (!u) return deviceInfo(stage, row, col, { roles: DEVICE_TIP_ROLES.filter((r) => !DEVICE_UNIT_ROLES.includes(r)) });
if (snap && snap.size > 0) {
const tuple = snap.get(u.id);
if (!Array.isArray(tuple) || !(tuple[3] > 0)) return null;
}
const list = Array.isArray(stage?.devices) ? stage.devices : [];
const same = list.filter((d) => isObj(d) && d.key === u.defId && DEVICE_UNIT_ROLES.includes(d.role));
const d = same.find((x) => Array.isArray(x.pos) && x.pos[0] === row && x.pos[1] === col) || same[0];
return d ? { ...deviceCard(stage, d, row, col), unitId: u.id } : null;
}
/**
* Fold the device units a battle shows into `map` (UnitInfo by unit id): the field meta's `units` (what stands when the field
* is entered) and the 'spawn' event tuples `['spawn', UnitInfo]` after it — the stage crates and the “双眼皮” turrets come
* into being with the battle's first step (`battleStart`), after the meta of a battle shown from its beginning was taken.
* Only kind 'device' is kept; `deviceTipAt` reads the map's values.
* @param {Map<number, any>} map
* @param {{ units?: any[]|null, events?: any[]|null }} [from]
*/
export function noteDeviceUnits(map, { units = null, events = null } = {}) {
for (const u of Array.isArray(units) ? units : []) if (isObj(u) && u.kind === 'device' && u.id != null) map.set(u.id, u);
for (const e of Array.isArray(events) ? events : []) if (Array.isArray(e) && e[0] === 'spawn' && isObj(e[1]) && e[1].kind === 'device' && e[1].id != null) map.set(e[1].id, e[1]);
return map;
}
/**
* The device-unit map after entering a field. A refresh of the same field whose new meta names no device
* (the start-of-battle list: crates and turrets arrive as `spawn` and are not repeated) keeps the units
* already noted. A meta that names devices replaces the map. A different field does not keep the previous one.
* @param {Map<number, any>|null|undefined} previous
* @param {Map<number, any>} noted
* @param {boolean} sameField
*/
export function deviceUnitsForEntry(previous, noted, sameField) {
if (!(noted instanceof Map) || noted.size > 0 || !sameField) return noted instanceof Map ? noted : new Map();
return previous instanceof Map && previous.size > 0 ? previous : noted;
}