feedback5: merge feedback4 (3af5b84) — PRs #186 / #134 / #185 / #24 / #183 ported to the split layout (terrainInfo / TERRAIN_TIPS into a new ui/gameLogic/terrain.js re-exported by the gameLogic.js barrel, closesOnFieldPress into ui/gameLogic/shortcuts.js, the rest in place; 42 strings through t(), #183's 设置 too); golden unchanged

Ported by hand:
- public/js/ui/gameLogic.js is a re-export shim on feedback5: #185's TERRAIN_TIPS / terrainInfo went into the new
  public/js/ui/gameLogic/terrain.js (the barrel gains one re-export line); closesOnFieldPress 'terrain' into
  ui/gameLogic/shortcuts.js.
- i18n (docs/I18N.md): every new user-visible string goes through t() with an English entry in public/i18n/en.json
  (42, after the 补位 strings): the terrain names / tags / fact table are N_() and translated where terrainInfo hands
  them out, the mechanism lines are msgids with their numbers as params (a line with an optional half is two whole
  msgids, the list joins use an array param), the card's 地形机制 / 这一格 titles in ui/detailPanel.js. Official EN
  terms where the EN client has them (stage_table tileInfo: Active Originium, Mire, Exhaust Grille, Deep Water Zone,
  Incursion Point, Protection Objective, Tunnel Entry / Exit, High Ground; Camouflage). The Chinese output is
  byte-identical to feedback4's terrainInfo on all 5187 tiles of the 13 stages and on synthetic stages.
- public/js/screens/game.js (MatchScreen still lives there): the import keeps feedback5's names plus terrainInfo /
  fieldTile; the terrain stage is the one view.setStage gets, so it includes feedback5's fieldStage — a 联防 field is
  played on its own map (GitHub #41), and a tap there explains that map's tile, not the round stage's. The tileClick
  handler and render/app.js emitTileClick / ui/fallbackField.js auto-merged in place. terrainInfo reads the stage only,
  so a 补位 / 自选 board explains its tiles the same way (their pieces only change a piece's art and name).
- public/js/ui/detailPanel.js: the terrain branch of resolveDetail before feedback5's stand-in / 自选 helpers.
- public/js/screens/title.js (#183): feedback5's LangToggle / t() imports plus GIcon / SettingsModal; the gear
  button's aria-label / title are t('设置') (en.json already has "Settings").
- tools/fetch-assets.mjs / tools/assets/downloader.mjs: --add-only (keepExisting, the font skip) beside #24's
  --asset-source / SP_ASSET_SOURCE and the shared MirrorPolicy; feedback5's extraOperators / moduleTypes and the
  voice plan unchanged.
- data/assets.json: feedback5's manifest plus #134's audio.sfx.battle.leak (after heal, the plan order); stats files
  6993 → 6994 and bytes +23 240 as measured on feedback4; the hash recomputed with the generator's contentHash
  (c01aa739a2d3 → 6a547fc5c937). A field-wise JSON diff against feedback5 shows only those four fields.
- docs: DESIGN §24 intro and §24.5 / §24.6 as on feedback4 (the rest of DESIGN.md is feedback5's); ASSETS.md,
  DEPLOY.md, PLAYING.md auto-merged.
- Auto-merged and checked: audio.js (the sim still emits ['leak', id], server/sim/battle/deploy.js), render/style.js,
  textures.js, tiles.js, board3d/materials.js, boardArt.js, tools/assets/*, tools/setup.mjs, extract.py, the tests.

Checks: the PRs' unit tests (audio 22, gameLogic 44, terrain-detail 1, originium-art 4, boardart 15,
board3d-extract 9, asset-network 21, fetch-assets-shrink 4), i18n 9 / i18n-data 8 / i18n-server 4, client-static 313,
docs-consistency 24, kits_layout 6 pass; lint 0 errors (no new warnings), check:imports, typecheck; golden: all 283
scenarios match, nothing regenerated.
This commit is contained in:
sganggs committed 2026-10-06 14:41:40 +08:00
commit 7059528c47
40 files changed
+1944 -97

No files matched your search

+1 -1
View File
@@ -103,7 +103,7 @@ html:not(.sp-in-match) .toast-host { margin-top: var(--sa-t); }
.fsbtn { display: grid; place-items: center; padding: 0; background: rgba(8, 11, 10, .85); border: 1px solid var(--line-2); color: var(--text-lo); transition: color var(--t-fast), border-color var(--t-fast); }
.fsbtn .icon { width: .26rem; height: .26rem; }
.fsbtn:hover, .fsbtn[aria-pressed="true"] { color: var(--mint-400); border-color: var(--mint-700); }
.title-fs { width: .4rem; height: .4rem; margin-left: .06rem; }
.title-fs, .title-settings { width: .4rem; height: .4rem; margin-left: .06rem; }
/* standalone / fullscreen: the browser bars are gone, nothing to reserve */
.sp-standalone .rotate-hint__micro::after { content: ' · PWA'; }
+42
View File
@@ -880,6 +880,48 @@
"{name}的替补": "Stand-in for {name}",
"未持有{name}:由替补干员 {standIn} 上场(盟约、特质、阶级与价格不变)": "{name} not owned: the stand-in {standIn} takes the field (Alliances, Attribute, tier and price unchanged)",
"未持有的干员由官方指定的替补干员上场:商店、整备区、作战区与结算都显示替补干员(模型、技能、天赋、攻击范围),盟约、特质、阶级、价格与合成仍按原干员。预设干员无论是否持有都由本人上场,不在此列。": "An Operator you do not own takes the field as its official stand-in: the shop, the Bench, the battlefield and the results show the stand-in (model, skills, talents, attack range), while Alliances, Attribute, tier, price and promotion follow the original Operator. Preset Operators always take the field themselves, owned or not, and are not listed.",
"活性源石": "Active Originium",
"特殊地形": "Special Terrain",
"攻击力 +{atk}%": "ATK +{atk}%",
"攻击速度 +{aspd}": "ASPD +{aspd}",
"部署于其上的我方单位、经过的敌方单位,每秒受到 {dmg} 点真实伤害(无来源)": "Friendly units deployed on it and enemies passing over it take {dmg} True damage per second (sourceless)",
"在其上的我方单位与经过的敌方单位持续受到伤害": "Friendly units on it and enemies passing over it take damage over time",
"同时获得:{mods}": "They also gain: {mods}",
"效果持续 {sec} 秒;离开地块后仍然保留,再次接触会重新计时": "The effect lasts {sec} {sec|second|seconds}; it stays after leaving the tile, and touching it again restarts the timer",
"沼泽": "Mire",
"留在沼泽里的单位每 {sec} 秒获得 1 层「陷入沼泽」:攻击速度 {aspd},敌方单位还有移动速度 {move}": "Units staying in the Mire gain 1 stack of Mired every {sec} {sec|second|seconds}: ASPD {aspd}, and for enemies also Movement Speed {move}",
"留在沼泽里的单位每 {sec} 秒获得 1 层「陷入沼泽」:攻击速度 {aspd}": "Units staying in the Mire gain 1 stack of Mired every {sec} {sec|second|seconds}: ASPD {aspd}",
"重量 ≥ {heavy} 的敌人一次获得 2 层": "Enemies with weight ≥ {heavy} gain 2 stacks at a time",
"最多 {max} 层;离开沼泽后解除": "Up to {max} {max|stack|stacks}; removed on leaving the Mire",
"排气格栅": "Exhaust Grille",
"站在排气格栅上的干员不会被敌方的远程攻击选中(效果相当于隐匿)": "An Operator on an Exhaust Grille is not targeted by enemy ranged attacks (the same effect as Camouflage)",
"但挡住敌人的干员仍会被它攻击到": "An Operator blocking an enemy can still be attacked by that enemy",
"深水区": "Deep Water Zone",
"敌人每秒受到 {dmg} 点伤害": "Enemies take {dmg} damage per second",
"攻击速度 {aspd}、移动速度 ×{move}": "ASPD {aspd}, Movement Speed ×{move}",
"攻击速度 {aspd}": "ASPD {aspd}",
"移动速度 ×{move}": "Movement Speed ×{move}",
"溺水伤害属于无来源伤害(不吃干员的增伤、穿透与装备加成),也不归类为环境伤害": "Drowning damage is sourceless (no Operator damage bonus, penetration or Equipment bonus applies to it) and does not count as environmental damage",
"拒绝部署(特制水上平台可以让这一格变得可部署)": "Refuses deployment (a special water platform makes this tile deployable)",
"红门": "Red Gate",
"敌方入口": "Incursion Point",
"敌方单位从这里出场": "Enemy units enter the battlefield here",
"蓝门": "Blue Gate",
"保护目标": "Protection Objective",
"敌人走进这里会扣你的目标生命值(LP),一回合至多 10 点": "An enemy entering here costs you LP, at most 10 per round",
"传送入口": "Tunnel Entry",
"敌人走到这里会从场上消失": "Enemies reaching this tile vanish from the battlefield",
"传送出口": "Tunnel Exit",
"消失的敌人会从这里重新出现": "Vanished enemies reappear here",
"可部署": "Deployable",
"仅近战位可部署": "Melee deployment only",
"仅远程位可部署": "Ranged deployment only",
"不可部署": "Not deployable",
"高台": "High Ground",
"只有空中单位能通过": "Only aerial units can pass",
"地面单位可通过": "Ground units can pass",
"地形机制": "Terrain Mechanics",
"这一格": "This Tile",
"全部清空": "Clear All",
"清空全部 {n} 个自选名额?": "Clear all {n} Custom Squad {n|slot|slots}?",
"文件过大,请选择「导出」下载的自选编队文件": "The file is too large. Choose a Custom Squad file downloaded with \"Export\".",
+19
View File
@@ -53,6 +53,15 @@ const MAX_VOICES = 8;
const UNIT_COOLDOWN_MS = 160;
const URL_GAP_MS = 45;
const MAX_PER_URL = 2;
/**
* 漏怪: the original Arknights exit alarm (`sfx.battle.leak`, `battle/b_ui/b_ui_alarmenter`) runs **1.44 s**, and the
* official bank is a one-shot: `battle.ON_ENEMY_REACHED_EXIT` carries `maxSoundAllowed: 1` with `popOldest: true` on the
* `Battle_UI_Important` mixer, i.e. **never two at once** (a new escape replaces the one still ringing). We keep the
* "never two at once" half and leave the rest of the cue alone: leaks closer together than the cue is long are the same
* disaster and share one alarm, so a line that breaks costs one clear ring per 1.5 s instead of a stutter of restarts.
* (The SFX limiter still applies on top.)
*/
const LEAK_SFX_GAP_MS = 1500;
const BUFFER_CACHE = 180;
/** Decoded-PCM budget of the buffer cache beside its entry count: a voice line decodes to 0.4–1.3 MB (see _buffer). */
const BUFFER_BYTES = 64 * 1024 * 1024;
@@ -977,6 +986,16 @@ export class AudioManager {
const mix = own ? m.audio.sfx.units[u.def].mix?.die : null;
if (!unitSoundPlays(mix, this.random())) continue;
this._playUnitUrl(url, own ? `${e[1]}:die` : `die:${e[1]}`, own ? unitGain(0.8, mix) : 0.7);
} else if (kind === 'leak') {
// 漏怪: an enemy reached its goal (Battle.leak emits the sim's own EV.LEAK — it is NOT a `die`, so until now a
// leak was completely silent, for the player's own field and for a 联防 the helpers could not hold alike).
// The cue is the ORIGINAL Arknights stage alarm — the one an enemy entering the exit plays in any normal
// stage (manifest `sfx.battle.leak`, bank battle.ON_ENEMY_REACHED_EXIT, file b_ui_alarmenter).
// `LEAK_SFX_GAP_MS` keeps it to one alarm at a time (the official bank's own maxSoundAllowed 1).
if (now - (this.lastLeakSfxAt ?? -Infinity) < LEAK_SFX_GAP_MS) continue;
if (typeof this.getManifest()?.audio?.sfx?.battle?.leak !== 'string') continue;
this.lastLeakSfxAt = now;
this.battle('leak', { unitKey: 'leak', volume: 0.85 });
} else if (kind === 'deploy') {
const u = this.units.get(e[1]);
if (!u || u.side === 'enemy') continue;
+23 -4
View File
@@ -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
@@ -1099,6 +1101,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);
@@ -1109,18 +1126,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;
+21 -7
View File
@@ -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>',
'}',
+20 -2
View File
@@ -7,11 +7,29 @@
// `map/autochess → TX_autochessi_D`; its crop table `tiles.json` sits next to it (written by
// tools/crop-board-atlas.mjs). Every image referenced by the table is loaded through the shared image cache; a
// missing image drops only the materials that use it. Everything is cached per page (one load for every view).
// A table image is fetched at the URL the manifest lists for the same texture (sourceUrl), so the 3D board
// (board3d/load.js, which reads the manifest only) and this art share one download — also when the manifest lists
// the WebP copy tools/local-extract writes and the table still names the PNG.
let cached = null;
const isObj = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
/** The manifest group of the board textures (and of their crop table). */
export const BOARD_GROUP = 'map/autochess';
/**
* URL of a crop-table image: the local-art manifest's entry for the same texture (group `BOARD_GROUP`, name = the
* file name without its extension), else the table's own path.
* @param {any} assets store of public/js/assets.js
* @param {string} path tiles.json `source[k].path`
*/
export function sourceUrl(assets, path) {
const name = path.replace(/^.*\//, '').replace(/\.[^.]*$/, '');
const listed = name && typeof assets?.localUrl === 'function' ? assets.localUrl(BOARD_GROUP, name) : null;
return typeof listed === 'string' && listed ? listed : path;
}
/** @param {any} assets store of public/js/assets.js (needs local(), localUrl(), image()) */
export function loadBoardArt(assets) {
if (cached) return cached;
@@ -19,7 +37,7 @@ export function loadBoardArt(assets) {
if (!assets || typeof assets.local !== 'function' || typeof assets.image !== 'function') return null;
const manifest = await assets.local().catch(() => null);
if (!manifest) return null;
const atlasUrl = assets.localUrl ? assets.localUrl('map/autochess', 'TX_autochessi_D') : null;
const atlasUrl = assets.localUrl ? assets.localUrl(BOARD_GROUP, 'TX_autochessi_D') : null;
if (!atlasUrl) return null;
const dir = atlasUrl.replace(/\/[^/]*$/, '');
let tiles = null;
@@ -30,7 +48,7 @@ export function loadBoardArt(assets) {
if (!isObj(tiles) || !isObj(tiles.materials) || !isObj(tiles.source)) return null;
const images = {};
await Promise.all(Object.entries(tiles.source).map(async ([k, s]) => {
const url = isObj(s) && typeof s.path === 'string' ? s.path : null;
const url = isObj(s) && typeof s.path === 'string' && s.path ? sourceUrl(assets, s.path) : null;
if (!url) return;
const img = await assets.image(url).catch(() => null);
if (img && img.width > 0) images[k] = img;
+31
View File
@@ -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
View File
@@ -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;
+5 -2
View File
@@ -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;
+25 -1
View File
@@ -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, unitLoadout, deployedRecord,
previewEnemyKey, prepCamera, prepCameraFor, foldCamera, deployFieldOf, fieldTile, panelSide, panelSlots, bondPopupPlace, unitLoadout, deployedRecord,
mergeTarget, modeOffBonds, readyFundsPrompt, ownerBandId, ownDiyRecord, ownStandIn,
} from '../ui/gameLogic.js';
import { toast } from '../ui/toasts.js';
@@ -341,6 +342,16 @@ function MatchScreen() {
const st = fieldStage || (ownView ? ownStage : baseStage);
if (st) view.setStage(st);
}, [view, ownView, ownStage, baseStage, fieldStage]);
// the stage behind the board ON SCREEN — the one view.setStage was just given: a field's own map (联防, GitHub #41),
// else 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 = fieldStage || (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
@@ -860,6 +871,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]);
+7
View File
@@ -20,6 +20,8 @@ import { data, useData } from '../data.js';
import { FullscreenButton, detectFeatures } from '../ui/device.js';
import { LangToggle, useLang } from '../ui/lang.js';
import { t, N_ } from '../../../shared/i18n.js';
import { GIcon } from '../ui/gameComponents.js';
import { SettingsModal } from '../ui/settings.js';
// Same character classes as server/net.js sanitizeName (control, zero-width, bidi, BOM), so a name
// the client accepts is never rejected by the server's hello validation.
@@ -187,6 +189,7 @@ export function TitleScreen() {
const pendingJoin = useStore((s) => s.ui.pendingJoin);
const lang = useLang();
const [name, setName] = useState(() => store.get().me.name || identity.loadName() || '');
const [settingsOpen, setSettingsOpen] = useState(false);
const assetsSettled = useData('assets');
const assets = data.get('assets');
const backdrop = findUiAsset(assets, BACKDROP_KEYS);
@@ -265,11 +268,15 @@ export function TitleScreen() {
<span>${STATUS_TEXT[conn.status] ? t(STATUS_TEXT[conn.status]) : conn.status}</span>
${conn.status === 'online' ? html`<${PingPill} ms=${conn.ping} />` : null}
<${GuideButton} class="title-guide" label=${t('玩法说明')} />
<button type="button" class="title-settings fsbtn tapx" aria-label=${t('设置')} title=${t('设置')}
onClick=${() => setSettingsOpen(true)}><${GIcon} name="gear" /></button>
<${FullscreenButton} class="title-fs" />
</div>
</div>
</main>
<${SettingsModal} open=${settingsOpen} onClose=${() => setSettingsOpen(false)} />
<footer class="title-foot">
<span>${t('非官方同人复刻 · 游戏素材版权归 上海鹰角网络 / Yostar 所有')}</span>
<${MicroLabel}>v${APP_VERSION} · WEB SIMULATION<//>
+25 -1
View File
@@ -615,9 +615,30 @@ 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=${t('地形机制')} micro="TERRAIN">
${terrain.lines.map((line, i) => html`<p class="dtext" key=${i}>${line}</p>`)}
<//>
${Array.isArray(terrain.facts) && terrain.facts.length ? html`<${Section} title=${t('这一格')}><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)
* @param {{ priv?: any, backups?: any }} [opts] 0.2.0 补位: the player's own pieces and cards of a chess in
* m.private.standIns — a unit carrying `standInFor`, and a teammate's bond popup row that says it (`target.standInFor`)
@@ -627,6 +648,8 @@ export function TokenDetail({ token, piece, ownerId = null, snapHp = null, live
*/
export function resolveDetail(target, pieces, { priv = null, backups = data.get('backups') } = {}) {
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;
const ownSi = (c) => ownStandIn(c, priv, backups);
const dd = { chess: data.get('chess'), backups };
/** the own card of chess `c`: its 自选 record and pick when the player filled that DIY slot */
@@ -737,6 +760,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';
@@ -320,6 +322,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>`);
}
}
+1
View File
@@ -34,6 +34,7 @@ export { ownFieldId, homeFieldId, cycleField, watchTarget, switcherLabel, fieldL
export { sortBonds, bondTier, nextThreshold, grantedBonds, pieceBondIds, morphPairings, HARMONY_BOND, harmonyMembers, bondMembers, memberHeadCount, bannedPerBond, disabledBondSets, briefingBondTip, modeOffBonds, bandOffBonds, bandOffLine } from './gameLogic/bonds.js';
export { priceTone, mergeProgress, mergeTarget, handFull, completesMerge, offerHeader, shopBlockReason, readyFundsPrompt } from './gameLogic/shop.js';
export { deploySets, stageOverrides, deployMap, effectiveStage, indexPieces, placementContext, piecePosition, tileAllows, summonRange, canPlace, itemAttaches, equipMerges, boardTargets, dropIntent, dropFailureReason } from './gameLogic/placement.js';
export { terrainInfo } from './gameLogic/terrain.js';
export { normalizeDraft, normalizeSp } from './gameLogic/draft.js';
export { groupEnemies, PEN, penZoneTiles, penPlacement, previewEnemyKey, factionTypes } from './gameLogic/enemies.js';
export { snapHud, bossFrac, bossPctText, hasFlag, UF, attackInterval, fmtNum, rangeGridBox } from './gameLogic/format.js';
+3 -1
View File
@@ -33,7 +33,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
+125
View File
@@ -0,0 +1,125 @@
// ui/gameLogic/terrain.js — special terrain tips (GitHub issue #184: 特殊地形的单击信息提示). 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.
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 };
}