From eb04f5dfa4f396b17e987b61a47fbcdc2b8b2fdf Mon Sep 17 00:00:00 2001 From: sganggs Date: Mon, 5 Oct 2026 17:06:45 +0800 Subject: [PATCH 1/5] feedback5 R5: split the in-match UI logic into modules public/js/ui/gameLogic.js becomes a re-export barrel. The bodies move verbatim into public/js/ui/gameLogic/*.js (shared, phases, camera, watch, bonds, shop, placement, draft, enemies, format, shortcuts, settings, result, loadout, panel). The barrel re-exports the same 96 names in the same order. isObj and int gain export on shared.js so sibling modules can import them; they stay off the barrel. No importer changes. --- public/js/ui/gameLogic.js | 1800 +-------------------------- public/js/ui/gameLogic/bonds.js | 301 +++++ public/js/ui/gameLogic/camera.js | 91 ++ public/js/ui/gameLogic/draft.js | 75 ++ public/js/ui/gameLogic/enemies.js | 96 ++ public/js/ui/gameLogic/format.js | 98 ++ public/js/ui/gameLogic/loadout.js | 70 ++ public/js/ui/gameLogic/panel.js | 84 ++ public/js/ui/gameLogic/phases.js | 122 ++ public/js/ui/gameLogic/placement.js | 457 +++++++ public/js/ui/gameLogic/result.js | 58 + public/js/ui/gameLogic/settings.js | 26 + public/js/ui/gameLogic/shared.js | 16 + public/js/ui/gameLogic/shop.js | 181 +++ public/js/ui/gameLogic/shortcuts.js | 49 + public/js/ui/gameLogic/watch.js | 118 ++ 16 files changed, 1864 insertions(+), 1778 deletions(-) create mode 100644 public/js/ui/gameLogic/bonds.js create mode 100644 public/js/ui/gameLogic/camera.js create mode 100644 public/js/ui/gameLogic/draft.js create mode 100644 public/js/ui/gameLogic/enemies.js create mode 100644 public/js/ui/gameLogic/format.js create mode 100644 public/js/ui/gameLogic/loadout.js create mode 100644 public/js/ui/gameLogic/panel.js create mode 100644 public/js/ui/gameLogic/phases.js create mode 100644 public/js/ui/gameLogic/placement.js create mode 100644 public/js/ui/gameLogic/result.js create mode 100644 public/js/ui/gameLogic/settings.js create mode 100644 public/js/ui/gameLogic/shared.js create mode 100644 public/js/ui/gameLogic/shop.js create mode 100644 public/js/ui/gameLogic/shortcuts.js create mode 100644 public/js/ui/gameLogic/watch.js diff --git a/public/js/ui/gameLogic.js b/public/js/ui/gameLogic.js index c4316474..aeac5ce1 100644 --- a/public/js/ui/gameLogic.js +++ b/public/js/ui/gameLogic.js @@ -20,1781 +20,25 @@ // (summons don't use a slot, research 00 §8 #11). Only PREP, alive and not-ready players may edit. // A board piece dropped on its own tile is legal ('orient'): the direction wheel re-orients it in place // (research 09 §1.2); board drops of units go through the wheel before g.move {uid, to, dir} (ui/facing.js). - -import { GEO, PHASE, UF } from '../../../shared/constants.js'; -import { resolveLoadout, loadoutOptions, MODULE_NONE } from '../../../shared/protocol.js'; -import { resolveRecordLoadout, loadoutRecord, attackRangeGrid } from '../../../shared/loadoutRecord.js'; -import { meleeOnHighGround } from '../../../shared/highGround.js'; -import { rangeTiles, pieceDir } from './facing.js'; -import { layoutPen } from '../render/pen.js'; -import { BOSS_ROW_SHIFT, MAX_COL } from '../render/prepfield.js'; -import { bossLevelSeconds } from './matchStatus.js'; - -// ---- small helpers ------------------------------------------------------------------------------- - -const isObj = (v) => !!v && typeof v === 'object'; -const int = (v, d = 0) => (Number.isInteger(v) ? v : d); -export const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v)); - -/** Stable key of a board tile. */ -export const tileKey = (row, col) => `${row},${col}`; - -// ---- phases ---------------------------------------------------------------------------------------- - -const COMBAT_PHASES = new Set([PHASE.COMBAT, PHASE.UNITE, PHASE.FINAL_ASSAULT, PHASE.HIDDEN_CORE]); -const PREP_PHASES = new Set([PHASE.PREP, PHASE.SP_DRAFT, PHASE.ROUND_START]); - -/** - * Which screen family a phase belongs to. - * @param {string} phase - * @returns {'loading'|'briefing'|'draft'|'boot'|'prep'|'combat'|'settle'|'result'} - */ -export function phaseMode(phase) { - if (!phase) return 'loading'; - if (phase === PHASE.INFO_CHECK) return 'briefing'; - if (phase === PHASE.BAND_DRAFT) return 'draft'; - if (phase === PHASE.BATTLE_CHECK) return 'boot'; - if (phase === PHASE.RESULT) return 'result'; - if (phase === PHASE.SETTLE) return 'settle'; - if (COMBAT_PHASES.has(phase)) return 'combat'; - if (PREP_PHASES.has(phase)) return 'prep'; - return 'prep'; -} - -export const isCombatPhase = (phase) => COMBAT_PHASES.has(phase); -/** - * The HUD's "你已被淘汰 · 可继续观战队友" pill of an eliminated player: outside combat only — in combat and in the SETTLE - * after it the combat HUD (screens/game.js CombatHud, rendered for mode 'settle' too) already says it, and two - * elimination banners never show at once. - * @param {boolean} alive - * @param {string|null|undefined} phase - */ -export const showDeadPill = (alive, phase) => !alive && !COMBAT_PHASES.has(phase) && phase !== PHASE.SETTLE; -export const isBossPhase = (phase) => phase === PHASE.FINAL_ASSAULT || phase === PHASE.HIDDEN_CORE; - -/** - * Camera of the own prep board (research 09 §1.2 "Final Assault, right side", DESIGN §15): in the prep of a boss round - * (最终攻势 / 隐秘核心: `pub.round` is `bossRound` or `hiddenRound`) the pieces stand on the player's half of the shared - * boss field — `{ kind: 'bossPrep', opts: { side } }`; the side follows the server's pairing (server/match/ - * finalAssault.js pairPlayers: the alive players in seat order, two by two; the second of a pair is on the mirrored - * right half 'R', a lone player on 'L'). Any other prep: the own board `{ kind: 'prep', opts: { rect, side: 'L' } }`. - * @param {any} pub m.public - * @param {string|null} myId - * @returns {{ kind: 'prep'|'bossPrep', opts: { rect?: { r0: number, r1: number, c0: number, c1: number }, side: 'L'|'R' } }} - */ -export function prepCamera(pub, myId) { - const normal = { kind: 'prep', opts: { rect: { ...GEO.NORMAL_RECT }, side: 'L' } }; - const r = pub?.round; - if (!Number.isInteger(r) || r <= 0 || !(r === pub.bossRound || r === pub.hiddenRound)) return normal; - const alive = (Array.isArray(pub.players) ? pub.players : []).filter((p) => isObj(p) && p.alive !== false) - .sort((a, b) => int(a.seat, 99) - int(b.seat, 99)); - const idx = alive.findIndex((p) => p.playerId === myId); - if (idx < 0) return normal; // eliminated / unknown: no board of its own on the boss field - return { kind: 'bossPrep', opts: { side: idx % 2 === 1 ? 'R' : 'L' } }; -} - -/** - * The own prep board's camera request with the shop bar's state (public issue #5 "准备阶段,收起商店界面时,界面并不会进行 - * 缩放"): `prepCamera` plus `shop: !folded` — a folded shop (收起) takes the official shop-collapsed camera - * (configBlackBoard left_prepare_camera_param / *_boss_prepare_*, render/projection.js presetCamera `shop: false`) and - * the folded shop's HUD band (ui/fieldHost.js hudBands), an open one the shop camera. `folded` = the shop bar is shown - * and folded (an eliminated player's board, which shows no bar, keeps the shop camera). - * @returns {{ kind: 'prep'|'bossPrep', opts: { rect?: object, side: 'L'|'R', shop: boolean } }} - */ -export function prepCameraFor(pub, myId, folded = false) { - const c = prepCamera(pub, myId); - return { kind: c.kind, opts: { ...c.opts, shop: !folded } }; -} - -/** - * The camera request a fold / unfold of the shop bar needs right now (public issue #5), or null: only while the own prep - * board is on screen (`ownPrep`: prep, not a teammate's scouted board, not a battle), never over the enemy pen (`pen`: it - * folds the shop itself and returns to the camera it left), deferred while a piece is dragged or its direction is chosen - * (`busy`: the drop target and the wheel sit on tiles of the camera in use — the caller asks again once that ends), and - * nothing when the current request (`current` { kind, opts }) already has that shop state. - * @param {{ pub: any, myId: string|null, ownPrep: boolean, folded: boolean, pen?: boolean, busy?: boolean, - * current?: { kind: string, opts?: { shop?: boolean } }|null }} s - * @returns {{ kind: 'prep'|'bossPrep', opts: object }|null} - */ -export function foldCamera(s) { - if (!s || !s.ownPrep || s.pen || s.busy) return null; - const want = prepCameraFor(s.pub, s.myId, !!s.folded); - const cur = s.current; - if (cur && cur.kind === want.kind && (cur.opts?.shop !== false) === want.opts.shop) return null; - return want; -} - -/** - * The field the own pieces are deployed on (server/match/Match.js deployFieldOf): 'bossL' / 'bossR' in the prep of a - * boss round (the same pairing as `prepCamera`), else 'normal'. Placement legality (`placementContext` → `deployMap` - * with `field`) reads that field's tiles. - * @returns {'normal'|'bossL'|'bossR'} - */ -export function deployFieldOf(pub, myId) { - const cam = prepCamera(pub, myId); - return cam.kind === 'bossPrep' ? (cam.opts.side === 'R' ? 'bossR' : 'bossL') : 'normal'; -} - -/** - * The stage tile [row, col] of board tile (r, c) on deploy field `field` (server/match/board.js fieldTile): the boss - * prep's display transform (render/prepfield.js bossPrepField: row − 7, the right half mirrored col c → 20 − c). - */ -export function fieldTile(field, r, c) { - if (field !== 'bossL' && field !== 'bossR') return [r, c]; - return [r + BOSS_ROW_SHIFT, field === 'bossR' ? MAX_COL - c : c]; -} - -/** The board tile of stage tile (r, c) on deploy field `field` (server/match/board.js boardTileOf). */ -export function boardTileOf(field, r, c) { - if (field !== 'bossL' && field !== 'bossR') return [r, c]; - return [r - BOSS_ROW_SHIFT, field === 'bossR' ? MAX_COL - c : c]; -} - -/** The band (策略) a player picked, from m.public.players[].bandId (Match.js marksPublic) — the detail card shows it - * on a teammate's unit (user playtest #2 item 2: watching a teammate revealed nothing about their 策略). */ -export function ownerBandId(pub, ownerId) { - const p = Array.isArray(pub?.players) ? pub.players.find((x) => x && x.playerId === ownerId) : null; - return typeof p?.bandId === 'string' && p.bandId ? p.bandId : null; -} - -/** Banner shown when a phase starts: { title, sub?, tone } or null. */ -export function phaseBanner(phase, pub) { - const r = int(pub?.round, 0); - switch (phase) { - case PHASE.BATTLE_CHECK: return { title: '协议启动', micro: 'PROTOCOL START', tone: 'mint', sub: '模拟即将开始', duration: 2600 }; - case PHASE.ROUND_START: return { title: `第 ${r} 回合`, micro: `ROUND ${String(r).padStart(2, '0')}`, tone: 'mint', sub: '资金已到账' }; - case PHASE.SP_DRAFT: return { title: '机变阶段', micro: 'CONTINGENCY', tone: 'gold', sub: '依次选择机变' }; - case PHASE.PREP: return { title: '休整期', micro: `ROUND ${String(r).padStart(2, '0')} // REST`, tone: 'mint', sub: '部署干员,准备迎敌' }; - case PHASE.COMBAT: return { title: '作战开始', micro: 'COMBAT', tone: 'orange', sub: '各自行动阶段' }; - case PHASE.UNITE: { - const names = new Map(sortedPlayers(pub).map((p) => [p.playerId, p.name || '博士'])); - const helpers = Array.isArray(pub?.unite?.helpers) ? pub.unite.helpers.map((id) => names.get(id)).filter(Boolean) : []; - return { title: '联防阶段', micro: 'JOINT DEFENSE', tone: 'orange', sub: helpers.length ? `联防:${helpers.join('、')}` : '完美作战的博士迎战突破防线的敌人' }; - } - case PHASE.FINAL_ASSAULT: return { title: '最终攻势', micro: 'FINAL ASSAULT', tone: 'red', sub: '击败敌方领袖' }; - case PHASE.HIDDEN_CORE: return { title: '隐秘核心', micro: 'HIDDEN CORE', tone: 'red', sub: '被源石侵蚀的假想敌' }; - case PHASE.SETTLE: return null; - default: return null; - } -} - -/** Label of the prep capsule ("休息一下" in the original). */ -export function prepCapsuleLabel(phase) { - if (phase === PHASE.SP_DRAFT) return '机变阶段'; - if (phase === PHASE.ROUND_START) return '回合开始'; - if (phase === PHASE.BATTLE_CHECK) return '协议启动'; - if (phase === PHASE.SETTLE) return '回合结算'; - return '休息一下'; -} - -// ---- countdown ------------------------------------------------------------------------------------- - -/** - * Countdown display state. - * @param {number|null|undefined} deadline server epoch ms (0/null = untimed) - * @param {number} now server-corrected epoch ms - * @param {number} [total] total seconds of the phase (for the 5-bar gauge) - * @param {number} [warnAt] seconds at/below which the digits turn orange - * @returns {{ remain: number|null, warn: boolean, bars: number, text: string, frac: number }} - */ -export function countdownState(deadline, now, total, warnAt = 10) { - if (!(Number.isFinite(deadline) && deadline > 0) || !Number.isFinite(now)) { - return { remain: null, warn: false, bars: 0, text: '--', frac: 0 }; - } - const remain = Math.max(0, Math.ceil((deadline - now) / 1000)); - const t = Number.isFinite(total) && total > 0 ? total : null; - const frac = t ? clamp(remain / t, 0, 1) : 1; - const bars = remain === 0 ? 0 : t ? clamp(Math.ceil(frac * 5), 0, 5) : 5; - return { remain, warn: remain <= warnAt, bars, text: String(Math.min(999, remain)).padStart(2, '0'), frac }; -} - -/** - * Nominal length (s) of the current timed phase from data/config.json, or null when unknown/untimed. - * @param {any} pub m.public - * @param {any} config data/config.json - * @param {string|null} [myId] - */ -export function phaseTotalSeconds(pub, config, myId = null) { - if (!isObj(pub)) return null; - const timers = isObj(config?.timers) ? config.timers : {}; - const mode = isObj(config?.modes) ? config.modes[pub.modeId] : null; - const num = (v) => (Number.isFinite(v) && v > 0 ? v : null); - switch (pub.phase) { - case PHASE.INFO_CHECK: return num(timers.infoCheck) ?? 25; - // one countdown: the current turn's (m.public.draft.turnSeconds = Match.BAND_TURN_SECONDS; user playtest #4 item 4) - case PHASE.BAND_DRAFT: return num(pub.draft?.turnSeconds) ?? num(timers.bandTurn) ?? 30; - case PHASE.BATTLE_CHECK: return num(timers.battleCheck) ?? 3; - case PHASE.SP_DRAFT: { - const sp = normalizeSp(pub.sp, pub.players); - const first = !sp || sp.pickedCount === 0; - return first ? (num(timers.spFirst) ?? 30) : (num(timers.spTurn) ?? 16); - } - case PHASE.PREP: return num(mode?.rounds?.[String(pub.round)]?.prepTime); - case PHASE.COMBAT: - case PHASE.UNITE: return num(mode?.rounds?.[String(pub.round)]?.combatTimeLimit); - // 最终攻势 / 隐秘核心: m.public.deadline is the level's 120 s countdown (maxPlayTime; the battle goes on past it) - case PHASE.FINAL_ASSAULT: - case PHASE.HIDDEN_CORE: return bossLevelSeconds(pub, config); - default: return null; - } -} - -// ---- players, statuses, fields ------------------------------------------------------------------------ - -/** Status → glyph + text (research 06 §11.1). */ -export const STATUS_META = Object.freeze({ - acting: { glyph: 'dots', text: '行动中', tone: 'lo' }, - ready: { glyph: 'check', text: '已就绪', tone: 'mint' }, - deciding: { glyph: 'hourglass', text: '决策中', tone: 'gold' }, - combat: { glyph: 'sword', text: '作战中', tone: 'orange' }, - done: { glyph: 'check', text: '作战结束', tone: 'mint' }, - helping: { glyph: 'shield', text: '联防中', tone: 'orange' }, - left: { glyph: 'exit', text: '已离开', tone: 'red' }, - dead: { glyph: 'close', text: '已淘汰', tone: 'red' }, -}); - -/** Players sorted by seat (nulls dropped). */ -export function sortedPlayers(pub) { - const list = Array.isArray(pub?.players) ? pub.players.filter(isObj) : []; - return [...list].sort((a, b) => int(a.seat, 99) - int(b.seat, 99)); -} - -/** Own normal field id (DESIGN §8.3). */ -export const ownFieldId = (playerId) => `n:${playerId}`; - -/** - * Field the player sits in right now (own normal field, or the unite/boss field that lists them). - * @param {any} pub - * @param {string} playerId - */ -export function homeFieldId(pub, playerId) { - const fields = Array.isArray(pub?.fields) ? pub.fields : []; - const special = fields.find((f) => isObj(f) && f.kind !== 'normal' && Array.isArray(f.players) && f.players.includes(playerId) && f.live !== false); - if (special) return special.fieldId; - const me = sortedPlayers(pub).find((p) => p.playerId === playerId); - if (me && typeof me.fieldId === 'string' && me.fieldId) return me.fieldId; - return ownFieldId(playerId); -} - -/** - * Next/previous watchable field for the ‹ › view switcher. - * @param {Array<{fieldId:string, live?:boolean}>} fields - * @param {string|null} current - * @param {1|-1} dir - * @returns {string|null} - */ -export function cycleField(fields, current, dir = 1) { - const list = (Array.isArray(fields) ? fields : []).filter((f) => isObj(f) && typeof f.fieldId === 'string' && f.live !== false); - if (list.length === 0) return null; - const i = list.findIndex((f) => f.fieldId === current); - if (i < 0) return list[0].fieldId; - const n = list.length; - return list[(((i + dir) % n) + n) % n].fieldId; -} - -/** - * What clicking a teammate row asks the server to watch, or why it can't (mirror of server Match.watch): an - * eliminated player has no board to watch, and during 最终攻势 / 隐秘核心 a fighting player can't see the other - * pair's boss field ("无法查看另一组队友的战场情况"). - * @param {any} p m.public player row - * @param {any} pub - * @param {string} myId - * @returns {{ fieldId: string } | { reason: string }} - */ -export function watchTarget(p, pub, myId) { - if (!isObj(p)) return { reason: '无效的目标' }; - if (p.alive === false) return { reason: '该队友已被淘汰,无法查看其阵地' }; - const combat = isCombatPhase(pub?.phase); - const fieldId = (combat && typeof p.fieldId === 'string' && p.fieldId) || ownFieldId(p.playerId); - if (combat) { - const fields = Array.isArray(pub?.fields) ? pub.fields.filter(isObj) : []; - const f = fields.find((x) => x.fieldId === fieldId); - const me = sortedPlayers(pub).find((x) => x.playerId === myId); - const mine = fields.find((x) => Array.isArray(x.players) && x.players.includes(myId)); - if (f && (f.kind === 'boss' || f.kind === 'hidden') && me?.alive !== false && mine && mine.fieldId !== f.fieldId) { - return { reason: '无法查看另一组队友的战场' }; - } - } - return { fieldId }; -} - -/** - * Label of the ‹ › view switcher: the watched field (also one whose battle already ended), else the own field — - * or, for an eliminated spectator (no own field), the watched player / 观战. - * @param {any} pub - * @param {string|null} watching fieldId on screen - * @param {string} myId - * @param {boolean} [spectating] - */ -export function switcherLabel(pub, watching, myId, spectating = false) { - const fields = (Array.isArray(pub?.fields) ? pub.fields : []).filter(isObj); - const cur = fields.find((f) => f.fieldId === watching); - if (cur) return fieldLabel(cur, pub, myId); - if (typeof watching === 'string' && watching.startsWith('n:') && watching !== ownFieldId(myId)) { - return sortedPlayers(pub).find((p) => p.playerId === watching.slice(2))?.name || '队友'; - } - return spectating ? '观战' : '自己'; -} - -/** - * Human label of a field for the view switcher. - * @param {any} field { fieldId, kind, players } - * @param {any} pub - * @param {string} myId - */ -export function fieldLabel(field, pub, myId) { - if (!isObj(field)) return '—'; - const names = new Map(sortedPlayers(pub).map((p) => [p.playerId, p.name || '博士'])); - const ps = Array.isArray(field.players) ? field.players : []; - if (field.kind === 'unite') return ps.includes(myId) ? '联防(自己)' : '联防阵地'; - if (field.kind === 'boss' || field.kind === 'hidden') { - if (ps.includes(myId)) return ps.length > 1 ? '全景' : '自己'; - return ps.map((id) => names.get(id) || '博士').join(' · ') || '领袖战场'; - } - if (ps.includes(myId) || field.fieldId === ownFieldId(myId)) return '自己'; - const id = ps[0] ?? String(field.fieldId || '').replace(/^n:/, ''); - return names.get(id) || '队友'; -} - -/** - * Active emote bubbles: playerId → { id, seq, at } for emotes younger than ttl. - * @param {Array<{seq:number, playerId:string, id:string, at:number}>} emotes - * @param {number} now - * @param {number} [ttl] - */ -export function activeBubbles(emotes, now, ttl = 3000) { - const out = new Map(); - for (const e of Array.isArray(emotes) ? emotes : []) { - if (!isObj(e) || !(now - e.at < ttl) || e.at > now + 1000) continue; - const cur = out.get(e.playerId); - if (!cur || cur.seq < e.seq) out.set(e.playerId, { id: e.id, seq: e.seq, at: e.at }); - } - return out; -} - -// ---- bonds ------------------------------------------------------------------------------------------- - -/** - * Sort bonds for the strip: active first, then layers desc, count desc, tier desc, core first, id; the mode-off bonds - * (`off`: the server's entries for the bonds this mode never activates that the player has members of — - * server/match/bondsMeta.js offBondCounts) after all the others. - * @template {{bondId:string, active?:boolean, layers?:number, count?:number, tier?:number, off?:boolean}} B - * @param {B[]} bonds - * @param {(id:string)=>any} [getBond] - * @returns {B[]} - */ -export function sortBonds(bonds, getBond = () => null) { - const list = (Array.isArray(bonds) ? bonds : []).filter((b) => isObj(b) && typeof b.bondId === 'string'); - const n = (v) => (Number.isFinite(v) ? v : 0); - return [...list].sort((a, b) => ( - (a.off ? 1 : 0) - (b.off ? 1 : 0) - || (b.active ? 1 : 0) - (a.active ? 1 : 0) - || n(b.layers) - n(a.layers) - || n(b.count) - n(a.count) - || n(b.tier) - n(a.tier) - || (getBond(b.bondId)?.isCore ? 1 : 0) - (getBond(a.bondId)?.isCore ? 1 : 0) - || (a.bondId < b.bondId ? -1 : a.bondId > b.bondId ? 1 : 0) - )); -} - -/** - * Tier reached for a member count against ascending thresholds (downward bonds: active while count ≤ max). - * @param {number} count - * @param {number[]} thresholds - * @param {number|null} [maxCount] - */ -export function bondTier(count, thresholds, maxCount = null) { - const c = Number.isFinite(count) ? count : 0; - if (Number.isFinite(maxCount) && c > maxCount) return 0; - let t = 0; - for (const th of Array.isArray(thresholds) ? thresholds : []) if (c >= th) t += 1; - return t; -} - -/** Next threshold above the count, or null when maxed. */ -export function nextThreshold(count, thresholds) { - for (const th of Array.isArray(thresholds) ? thresholds : []) if (count < th) return th; - return null; -} - -/** - * Bonds granted by 变形同构体: an item with `canGiveBond` worn together with an item that has a `giveBondId` makes the - * wearer a member of that other item's bond (band 变形者集群 "与特定装备一同装备时装备者将视为特定盟约成员"; the item's - * talent, character_table trap_1073_acarm073, lists the 14 pairings). The same rule as the server's count - * (server/match/bondsMeta.js pieceBonds) and the battle's (server/sim/content/support unitBonds). - * @param {Array|null|undefined} items the carried items (ids or { id } pieces) - * @param {(id:string)=>any} [getItem] items.json lookup - * @returns {string[]} - */ -export function grantedBonds(items, getItem = () => null) { - const list = Array.isArray(items) ? items : []; - if (list.length < 2) return []; - const recs = list.map((it) => getItem(typeof it === 'string' ? it : it?.id)).filter(isObj); - if (!recs.some((r) => r.canGiveBond)) return []; - const out = []; - for (const r of recs) if (!r.canGiveBond && typeof r.giveBondId === 'string' && r.giveBondId && !out.includes(r.giveBondId)) out.push(r.giveBondId); - return out; -} - -/** - * A chess piece's bonds: its record's + those its 变形同构体 pairing grants (grantedBonds) — the detail card's chips. - * @param {any} chess chess.json record @param {Array|null|undefined} items @param {(id:string)=>any} [getItem] - * @returns {string[]} - */ -export function pieceBondIds(chess, items, getItem = () => null) { - const out = (Array.isArray(chess?.bonds) ? chess.bonds : []).filter((b) => typeof b === 'string'); - for (const b of grantedBonds(items, getItem)) if (!out.includes(b)) out.push(b); - return out; -} - -/** - * The 变形同构体 pairings — the list its 天赋栏 shows (the item text: "具体对应关系可在模拟中查看本装备天赋栏"; character_table - * trap_1073_acarm073 "“炎国短刀”→【炎】盟约 …", 14 bonds; GitHub issue #1: without it the item read as doing nothing). - * Built from the same `giveBondId` the server counts with (server/match/bondsMeta.js pieceBonds, grantedBonds here): every - * bond an item grants, in bonds.json order, with the items that grant it (one entry per item family — the normal and 进阶 - * copies share a name —, by tier, then data order). `off`: the mode never activates the bond (modeOffBonds: 本局禁用 — 标准 - * leaves 5 of the 14 off). `worn`: the wearer's items (`carried`, the card shows the 变形同构体 on an operator) pair it with - * one of the bond's items — the pairing in effect on that operator (that item `worn` too). - * @param {Iterable} itemRecs items.json records (data.list('items')) - * @param {Iterable} bondRecs bonds.json records in data order (data.list('bonds')) - * @param {{ off?: Set|null, carried?: Array|null }} [opts] - * @returns {Array<{ bondId: string, name: string, off: boolean, worn: boolean, items: Array<{ id: string, name: string, worn: boolean }> }>} - */ -export function morphPairings(itemRecs, bondRecs, { off = null, carried = null } = {}) { - const recs = [...(itemRecs || [])].filter(isObj); - const byId = new Map(recs.map((r) => [r.id, r])); - const getItem = (id) => byId.get(id) || null; - const worn = new Set(grantedBonds(carried, getItem)); - const carriedIds = new Set((Array.isArray(carried) ? carried : []).map((it) => (typeof it === 'string' ? it : it?.id)).filter((x) => typeof x === 'string')); - const famOf = (r) => (typeof r.baseId === 'string' && r.baseId ? r.baseId : r.id); - /** bondId → family id → { id, name, tier, order, ids } */ - const groups = new Map(); - recs.forEach((r, order) => { - if (r.canGiveBond || typeof r.giveBondId !== 'string' || !r.giveBondId) return; - let g = groups.get(r.giveBondId); - if (!g) groups.set(r.giveBondId, (g = new Map())); - const fam = famOf(r); - const e = g.get(fam); - if (e) { e.ids.push(r.id); return; } - g.set(fam, { id: fam, name: typeof r.name === 'string' && r.name ? r.name : fam, tier: int(r.tier, 99), order, ids: [r.id] }); - }); - const out = []; - for (const b of bondRecs || []) { - const g = isObj(b) ? groups.get(b.bondId) : null; - if (!g) continue; - const bondWorn = worn.has(b.bondId); - const items = [...g.values()].sort((x, y) => (x.tier - y.tier) || (x.order - y.order)) - .map((e) => ({ id: e.id, name: e.name, worn: bondWorn && e.ids.some((id) => carriedIds.has(id)) })); - out.push({ bondId: b.bondId, name: typeof b.name === 'string' && b.name ? b.name : b.bondId, off: !!(off && typeof off.has === 'function' && off.has(b.bondId)), worn: bondWorn, items }); - } - return out; -} - -/** - * Bond id of 调和 (bonds.json maniShip, "激活时使场上核心盟约的激活人数+1") — server/match/bondsMeta.js HARMONY_BOND; a test - * pins the two. Only to name the 调和 operators: whether a bond's count holds the +1 is the server's word (the view - * entry's `harmony`, DESIGN §21.26). - */ -export const HARMONY_BOND = 'maniShip'; - -/** - * The 调和 operators on a board, distinct (normal and elite copies of one operator once): who activates the +1 a bond - * entry's `harmony` reports (the bond popup's 调和 row names them — 缪尔赛思, the Pith strategy's 盟约·辅助干员). - * @param {any} priv m.private — or a teammate's field operators (ui/watchBonds.js ownerBoard) - * @param {(id:string)=>any} [getChess] - * @returns {Array<{ id: string, name: string }>} base chess ids - */ -export function harmonyMembers(priv, getChess = () => null) { - const out = []; - const seen = new Set(); - for (const p of Array.isArray(priv?.board) ? priv.board : []) { - if (p?.kind !== 'chess') continue; - const c = getChess(p.id); - if (!Array.isArray(c?.bonds) || !c.bonds.includes(HARMONY_BOND)) continue; - const base = c.baseId || (typeof p.id === 'string' ? p.id.replace(/_b$/, '_a') : p.id); - if (seen.has(base)) continue; - seen.add(base); - out.push({ id: base, name: getChess(base)?.name || c.name || base }); - } - return out; -} - -/** - * Member rows of a bond popup: every visible member with owned / on-board / in-hand / banned state, plus the player's - * operators that are members through 变形同构体 (grantedBonds; `granted: true` and `items`: the item ids of the copy that - * wears the pair — the card the row opens shows them; one row per operator — normal and elite copies are one member, - * like the count's distinct members), so "成员 x/y" agrees with the count the server reports (在场; memberHeadCount). - * `inHand`: in the 整备区 (hand), not the 5 temporary slots — what BOARD_AND_DECK bonds (投资人 远见 奇迹) count. - * @param {any} bond bonds.json record - * @param {any} priv m.private (hand/board/temp) — or a teammate's field operators (ui/watchBonds.js ownerBoard) - * @param {Set|string[]} [banned] banned base chess ids - * @param {(id:string)=>any} [getChess] - * @param {(id:string)=>any} [getItem] items.json lookup (the 变形同构体 pairings) - */ -export function bondMembers(bond, priv, banned = [], getChess = () => null, getItem = () => null) { - const bannedSet = banned instanceof Set ? banned : new Set(Array.isArray(banned) ? banned : []); - const members = Array.isArray(bond?.visibleMembers) && bond.visibleMembers.length ? bond.visibleMembers : (Array.isArray(bond?.members) ? bond.members : []); - const baseOf = (id) => getChess(id)?.baseId || (typeof id === 'string' ? id.replace(/_b$/, '_a') : id); - const onBoard = new Set(); - const owned = new Set(); - const inHand = new Set(); - const memberSet = new Set(members); - /** base id → { on: on the board?, hand: in the hand?, items: the wearer's item ids } — operators of the player that join this bond through 变形同构体 */ - const granted = new Map(); - const grants = (p) => typeof bond?.bondId === 'string' && grantedBonds(p.items, getItem).includes(bond.bondId); - const itemIds = (p) => p.items.map((it) => (typeof it === 'string' ? it : it?.id)).filter((x) => typeof x === 'string'); - for (const p of Array.isArray(priv?.board) ? priv.board : []) { - if (p?.kind !== 'chess') continue; - const base = baseOf(p.id); - onBoard.add(base); owned.add(base); - if (!memberSet.has(base) && !granted.has(base) && grants(p)) granted.set(base, { on: true, hand: false, items: itemIds(p) }); - } - for (const [list, hand] of [[priv?.hand, true], [priv?.temp, false]]) { - for (const p of Array.isArray(list) ? list : []) { - if (p?.kind !== 'chess') continue; - const base = baseOf(p.id); - owned.add(base); - if (hand) inHand.add(base); - if (memberSet.has(base) || !grants(p)) continue; - const g = granted.get(base); - if (!g) granted.set(base, { on: false, hand, items: itemIds(p) }); - else if (hand) g.hand = true; - } - } - const rows = members.map((id) => { - const c = getChess(id); - return { id, tier: c?.tier ?? 0, name: c?.name ?? id, onBoard: onBoard.has(id), owned: owned.has(id), inHand: inHand.has(id), banned: bannedSet.has(id) }; - }); - for (const [id, g] of granted) { - const c = getChess(id); - rows.push({ id, tier: c?.tier ?? 0, name: c?.name ?? id, onBoard: g.on, owned: true, inHand: g.hand, banned: false, granted: true, items: g.items }); - } - return rows.sort((a, b) => (b.onBoard - a.onBoard) || (b.owned - a.owned) || (a.tier - b.tier) || (a.id < b.id ? -1 : 1)); -} - -/** - * The member count of a bond popup's 成员 header: the rows on the board — and, for a bond that counts the hand - * (`countsHand`: BOARD_AND_DECK — 投资人 远见 奇迹; the server's 在场 says (含整备区)), those in the hand too (the 5 - * temporary slots never count), so the header agrees with 在场 (community report 「投资人等在休整区就能生效的盟约不生效」: - * the header said 0/n with the members in the hand while 在场 said 3/3). - * @param {Array<{ onBoard?: boolean, inHand?: boolean }>} rows bondMembers rows @param {boolean} [countsHand] - * @returns {number} - */ -export function memberHeadCount(rows, countsHand = false) { - let n = 0; - for (const r of Array.isArray(rows) ? rows : []) if (r && (r.onBoard || (countsHand && r.inHand))) n++; - return n; -} - -/** - * Banned-member count per bond (briefing / info drawer red badge). - * @param {any[]} bonds bonds.json records - * @param {string[]} bannedChess - * @returns {Map} - */ -export function bannedPerBond(bonds, bannedChess) { - const banned = new Set(Array.isArray(bannedChess) ? bannedChess : []); - const out = new Map(); - for (const b of Array.isArray(bonds) ? bonds : []) { - if (!isObj(b)) continue; - const members = Array.isArray(b.visibleMembers) ? b.visibleMembers : []; - out.set(b.bondId, members.filter((id) => banned.has(id)).length); - } - return out; -} - -/** - * The two kinds of greyed bonds of a match (research 01 A2, 06 addendum D): - * drawn — the per-match drawn set D (m.public drawnDisabledBonds): only operators whose EVERY bond is in D leave - * the pool, so these bonds stay activatable (multi-bond members, 变形同构体, 调和) — "部分盟约所含干员阵容不完整"; - * off — the mode's static inactive list (FUNNY): never activates this match — "本局禁用". - * Older payloads without drawnDisabledBonds: disabledBonds minus the static list. - * @param {any} pub m.public - * @param {string[]} [staticInactive] config.modes[modeId].inactiveBondIds - * @returns {{ drawn: Set, off: Set }} - */ -export function disabledBondSets(pub, staticInactive = []) { - const off = new Set(Array.isArray(staticInactive) ? staticInactive : []); - const src = Array.isArray(pub?.drawnDisabledBonds) ? pub.drawnDisabledBonds : Array.isArray(pub?.disabledBonds) ? pub.disabledBonds : []; - const drawn = new Set(src.filter((b) => typeof b === 'string' && !off.has(b))); - return { drawn, off }; -} - -/** - * Briefing tooltip of a bond disc. - * @param {string} name - * @param {'off'|'drawn'|null} state from disabledBondSets - * @param {number} bannedN banned operators of the bond - */ -export function briefingBondTip(name, state, bannedN = 0) { - if (state === 'off') return `${name}:本局禁用(该盟约不会激活)`; - if (state === 'drawn' || bannedN > 0) return `${name}:部分盟约所含干员阵容不完整${bannedN ? `(${bannedN} 名干员无法出现)` : ''}`; - return name; -} - -/** - * The bonds a mode never activates (config.json modes[modeId].inactiveBondIds = the official modeDataDict - * inactiveBondIdList: 标准模拟 leaves 拉特兰 阿戈尔 卡西米尔 灵巧 奥术 奇迹 投资人 突袭 独行 绝技 off). Operators that also carry an - * enabled bond stay in the pool (server pool.js drawDisabledBonds) — 标准's 深靛 洛洛 阿罗玛 夕 圣聆初雪 still show 奥术 — and - * the server left such a bond out of m.private.bonds, so without a mark a card read "奥术 0/2 未激活" with three 奥术 - * operators deployed (player report after 0.1.0: "奥术盟约不生效"). The shop / reward cards, the detail card's bond chips - * and the bond popup mark these 本局禁用 (briefingBondTip 'off'); since 0.1.3 the server also lists such a bond the player - * has members of (`off: true`, server/match/bondsMeta.js offBondCounts) and the strip shows it as a grey 本局禁用 disc. - * @param {any} mode config.json modes[modeId] (data.js getMode), or null - * @returns {Set} - */ -export function modeOffBonds(mode) { - const list = isObj(mode) && Array.isArray(mode.inactiveBondIds) ? mode.inactiveBondIds : []; - return new Set(list.filter((b) => typeof b === 'string')); -} - -/** - * The bonds a strategy is built around that this mode switches off: bands.json `bondIds` (shared/bandBonds.js at build - * time — the field the server's bot reads too, GameData.bandBondIds) ∩ `off` (modeOffBonds). 标准: 潘格尼尼 → 拉特兰, - * 克莱门莎 → 阿戈尔, 玛恩纳 → 卡西米尔; the strategy draft marks such a band 本局禁用 (still selectable, DESIGN §21.26). - * @param {any} band bands.json record @param {Set|null} off - * @returns {string[]} - */ -export function bandOffBonds(band, off) { - if (!(off instanceof Set) || !off.size || !Array.isArray(band?.bondIds)) return []; - return band.bondIds.filter((b) => typeof b === 'string' && off.has(b)); -} - -/** - * The strategy draft's note for such a band: "本局禁用【拉特兰】盟约,此策略效果可能无法发挥". - * @param {string[]} names the switched-off bonds' names - */ -export function bandOffLine(names) { - const list = (Array.isArray(names) ? names : []).filter((n) => typeof n === 'string' && n); - return list.length ? `本局禁用${list.map((n) => `【${n}】`).join('')}盟约,此策略效果可能无法发挥` : ''; -} - -// ---- shop --------------------------------------------------------------------------------------------- - -/** - * Price tone of a shop slot: discounted (mint), marked up (red) or normal (gold). - * @param {{price?:number, basePrice?:number}|null} slot - * @returns {'discount'|'premium'|'gold'} - */ -export function priceTone(slot) { - if (!isObj(slot)) return 'gold'; - const p = Number(slot.price); - const b = Number(slot.basePrice); - if (Number.isFinite(p) && p === 0) return 'discount'; - if (!Number.isFinite(p) || !Number.isFinite(b)) return 'gold'; - if (p < b) return 'discount'; - if (p > b) return 'premium'; - return 'gold'; -} - -/** - * Owned copies of a base chess (normal pieces on board/hand/temp) — shop cards show merge progress. An elite card - * never merges (server PlayerState.completesChessMerge refuses isGolden), so it reports 0 copies: no pips, no 可晋升 - * tag and no mergeTarget tile. - * @param {any} priv - * @param {string} chessId - * @param {(id:string)=>any} [getChess] - * @returns {{ copies: number, need: number }} - */ -export function mergeProgress(priv, chessId, getChess = () => null) { - const c = getChess(chessId); - const base = c?.baseId || chessId; - const need = Number.isInteger(c?.upgradeNum) && c.upgradeNum > 0 ? c.upgradeNum : 3; - if (c?.isGolden) return { copies: 0, need }; - let copies = 0; - const pieces = [...(Array.isArray(priv?.board) ? priv.board : []), ...(Array.isArray(priv?.hand) ? priv.hand : []), ...(Array.isArray(priv?.temp) ? priv.temp : [])]; - for (const p of pieces) if (p?.kind === 'chess' && !p.golden && (p.id === base || getChess(p.id)?.baseId === base) && !String(p.id).endsWith('_b')) copies += 1; - return { copies, need }; -} - -/** - * Where the elite appears when gaining one more normal copy of `chessId` completes a merge now (PRTS 卫戍协议/帮助 - * "若消耗已部署至作战区的干员,则发送至作战区对应位置"; mirror of server board.js mergeTile / PlayerState._mergeChess): the - * board tile of the deployed copy that deploys first (col asc, then row desc: by column from the left, top to bottom — - * Battle.start) — `{ row, col, dir }` — or null (no - * merge — an elite card never merges, see mergeProgress — or no copy is deployed: the elite goes to the hand). The - * copies stand on legal tiles, and the elite is the same operator, so the tile needs no legality check here. - * @param {any} priv - * @param {string} chessId - * @param {(id:string)=>any} [getChess] - * @returns {{ row: number, col: number, dir: string } | null} - */ -export function mergeTarget(priv, chessId, getChess = () => null) { - const { copies, need } = mergeProgress(priv, chessId, getChess); - if (!(copies > 0 && copies + 1 >= need)) return null; - const c = getChess(chessId); - const base = c?.baseId || chessId; - const board = (Array.isArray(priv?.board) ? priv.board : []).filter((p) => p?.kind === 'chess' && !p.golden && Number.isInteger(p.row) && Number.isInteger(p.col) - && !String(p.id).endsWith('_b') && (p.id === base || getChess(p.id)?.baseId === base)); - if (!board.length) return null; - board.sort((a, b) => a.col - b.col || b.row - a.row); - return { row: board[0].row, col: board[0].col, dir: board[0].dir || 'RIGHT' }; -} - -/** Whether every hand slot is taken (the server refuses a purchase / reward that needs a slot: HAND_FULL). */ -export function handFull(priv) { - const hand = Array.isArray(priv?.hand) ? priv.hand : []; - return hand.length >= GEO.HAND_SIZE && hand.every((p) => p != null); -} - -/** - * Would gaining one more `slot` (shop / reward card: { kind: 'chess'|'item', id }) complete a merge right away, so it - * needs no hand slot? Mirror of server PlayerState completesChessMerge / completesItemMerge. - * @param {any} priv - * @param {{kind?:string, id:string}} slot - * @param {{ getChess?:(id:string)=>any, getItem?:(id:string)=>any, itemMergeCount?: number }} [o] - */ -export function completesMerge(priv, slot, { getChess = () => null, getItem = () => null, itemMergeCount = 2 } = {}) { - if (!isObj(slot) || typeof slot.id !== 'string') return false; - if (slot.kind === 'item') { - const rec = getItem(slot.id); - if (!rec || rec.isGolden || rec.itemType !== 'EQUIP' || !rec.mergeable) return false; - const n = Number.isInteger(rec.upgradeNum) ? rec.upgradeNum : itemMergeCount; - if (!(n > 1 && n < 100) || !getItem(rec.upgradeChessId || rec.goldenId)) return false; - let have = 0; - const holders = [...(Array.isArray(priv?.board) ? priv.board : []), ...(Array.isArray(priv?.hand) ? priv.hand : []), ...(Array.isArray(priv?.temp) ? priv.temp : [])]; - for (const p of holders) { - if (!isObj(p)) continue; - if (p.kind === 'item' && p.id === slot.id) have += 1; - if (p.kind === 'chess') for (const it of Array.isArray(p.items) ? p.items : []) if (it?.id === slot.id) have += 1; - } - return have + 1 >= n; - } - const rec = getChess(slot.id); - if (!rec || rec.isGolden) return false; - const base = getChess(rec.baseId) || rec; - const golden = (base.goldenId && getChess(base.goldenId)) || getChess(String(base.chessId || slot.id).replace(/_a$/, '_b')); - if (!golden) return false; - const { copies, need } = mergeProgress(priv, slot.id, getChess); - return need > 1 && copies + 1 >= need; -} - -/** - * How the shop bar names a free pick-one offer (m.private shop.rewardOffer): the promotion reward (`source` 'merge') - * reads 晋升奖励 / PROMOTION; any other offer — a strategy's special refresh (凯瑟琳 定向投放, 娜仁图亚 见者有份), an item - * (寻呼模块, 信标) or a 特质 (松果) — reads its `label` with the refresh icon (player report #6 after 0.1.0: 凯瑟琳's three - * items showed up as nameless operator cards under 晋升奖励). `items` = the offer holds items (cards drawn as item cards). - * `queued` = offers waiting behind this one (shop.rewardOffer.queued, e.g. a 定向投放 behind a promotion reward put - * off): `more` (header) and the pill's "+N" say so, null / no suffix when none. - * @param {any} offer - * @returns {{ title: string, micro: string, sub: string, icon: string, items: boolean, pill: string, queued: number, more: string|null }} - */ -export function offerHeader(offer) { - const slots = isObj(offer) && Array.isArray(offer.slots) ? offer.slots : []; - const items = slots.some((s) => isObj(s) && s.kind === 'item'); - const label = isObj(offer) && typeof offer.label === 'string' && offer.label ? offer.label : null; - const queued = isObj(offer) && Number.isInteger(offer.queued) && offer.queued > 0 ? offer.queued : 0; - const tail = { queued, more: queued ? `之后还有 ${queued} 项` : null }; - if (!items && (!isObj(offer) || offer.source === 'merge' || offer.source == null) && !label) { - return { title: '晋升奖励', micro: 'PROMOTION', sub: '免费选择 1 名', icon: 'crown', items: false, pill: '晋升奖励待选择', ...tail }; - } - const title = label || (items ? '装备补给' : '特殊招募'); - return { title, micro: 'SPECIAL', sub: items ? '免费选择 1 件' : '免费选择 1 名', icon: 'refresh', items, pill: `${title}待选择`, ...tail }; -} - -/** - * Why a shop action is unavailable (null when available). - * @param {'buy'|'reward'|'refresh'|'freeze'|'levelUp'|'ready'} kind - * @param {{ priv:any, editable:boolean, slot?:any, getChess?:(id:string)=>any, getItem?:(id:string)=>any }} ctx - * @returns {string|null} Chinese reason - */ -export function shopBlockReason(kind, { priv, editable, slot, getChess, getItem } = {}) { - if (!priv) return '尚未就绪'; - if (priv.alive === false) return '你已被淘汰'; - if (kind === 'ready') return priv.canReady === false ? '临时整备区不为空,请先处理溢出的资源' : null; - if (!editable) { - if (kind === 'reward') return '当前无法选择'; - return priv.ready ? '已准备就绪,取消准备后才能操作' : '当前阶段无法进行该操作'; - } - const funds = Number(priv.funds) || 0; - const shop = priv.shop || {}; - if (kind === 'buy' || kind === 'reward') { - if (!isObj(slot) || slot.sold) return kind === 'reward' ? '已选择' : '已售出'; - if ((Number(slot.price) || 0) > funds) return '资金不足'; - if (handFull(priv) && !completesMerge(priv, slot, { getChess, getItem })) return '整备区已满'; - return null; - } - if (kind === 'refresh') return (Number(shop.refreshPrice) || 0) > funds ? '资金不足' : null; - if (kind === 'levelUp') { - if ((Number(shop.level) || 1) >= (Number(shop.maxLevel) || 6)) return '调度中心已达最高等级'; - return (Number(shop.upgradePrice) || 0) > funds ? '资金不足' : null; - } - return null; -} - -/** - * The 准备 confirmation (community report #4 after 0.1.2): readying with funds left asks first — the prep's end wipes - * them (PRTS 卫戍协议/帮助 「本回合的剩余资金将清零」; the server still does: PlayerState.endPrep). act2autochess constData - * `noMoneyTipsBand` — data/config.json economy.leftoverFundsKeptByBands, ["band_cannot"] — names the strategies whose - * funds carry over and that get no such tip (坎诺特 利滚利). No prompt for an un-ready, with 0 funds, when already ready - * or out, or under AI 托管 (the server plays the seat). The dialog's wording is the remake's own [ASSUMED]: neither the - * tables nor PRTS hold the official one. - * @param {any} priv m.private - * @param {{ ready?: boolean, keptBands?: string[]|null, autoplay?: boolean }} [opts] `ready`: the state asked for - * @returns {{ title: string, text: string, okText: string, cancelText: string, micro: string } | null} - */ -export function readyFundsPrompt(priv, { ready = true, keptBands = null, autoplay = false } = {}) { - if (!ready || autoplay || !isObj(priv) || priv.alive === false || priv.ready) return null; - const funds = Math.trunc(Number(priv.funds) || 0); - if (!(funds > 0)) return null; - const kept = Array.isArray(keptBands) ? keptBands : ['band_cannot']; - if (typeof priv.bandId === 'string' && kept.includes(priv.bandId)) return null; - return { - title: '剩余资金', micro: 'FUNDS LEFT', okText: '准备就绪', cancelText: '继续整备', - text: `还有 ${funds} 资金未使用。休整期结束时,本回合的剩余资金将清零。确定准备就绪吗?`, - }; -} - -// ---- placement (canPlace mirror) ------------------------------------------------------------------------ - -/** - * Deployable tiles of the own board for a stage record, in board coordinates: the normal field (default), or — `field` - * 'bossL' / 'bossR' — the player's half of the boss field (always derived from the legend + active devices, like the - * server's deploy map on that field). - * @param {any} stage stages.json record - * @param {'normal'|'bossL'|'bossR'} [field] - * @param {{ deviceOverrides?: Record, tileOverrides?: Record }} [overrides] boss field - * only (the normal field takes them folded into the stage: `effectiveStage`) - * @returns {{ melee: Set, ranged: Set }} ranged = melee ∪ rangedOnly - */ -export function deploySets(stage, field = 'normal', overrides = {}) { - const melee = new Set(); - const ranged = new Set(); - if (field === 'bossL' || field === 'bossR') { - for (const [k, cls] of deployMap(stage, { ...(isObj(overrides) ? overrides : {}), field })) { ranged.add(k); if (cls === 'melee') melee.add(k); } - return { melee, ranged }; - } - const inField = (r, c) => r >= GEO.FIELD.r0 && r <= GEO.FIELD.r1 && c >= GEO.FIELD.c0 && c <= GEO.FIELD.c1; - const dt = stage?.deployTiles?.normal; - if (isObj(dt) && Array.isArray(dt.melee)) { - for (const t of dt.melee) if (Array.isArray(t) && inField(t[0], t[1])) { melee.add(tileKey(t[0], t[1])); ranged.add(tileKey(t[0], t[1])); } - for (const t of Array.isArray(dt.rangedOnly) ? dt.rangedOnly : []) if (Array.isArray(t) && inField(t[0], t[1])) ranged.add(tileKey(t[0], t[1])); - return { melee, ranged }; - } - // derive from the tile legend - const rows = Array.isArray(stage?.rows) ? stage.rows : []; - const tiles = isObj(stage?.tiles) ? stage.tiles : {}; - for (let r = GEO.FIELD.r0; r <= GEO.FIELD.r1; r++) { - const line = typeof rows[r] === 'string' ? rows[r] : ''; - for (let c = GEO.FIELD.c0; c <= GEO.FIELD.c1; c++) { - const t = tiles[line[c]]; - if (!isObj(t)) continue; - const k = tileKey(r, c); - if (t.height === 'LOW' && (t.buildable === 'ALL' || t.buildable === 'MELEE')) { melee.add(k); ranged.add(k); } - else if (t.buildable === 'ALL' || t.buildable === 'RANGED') ranged.add(k); - } - } - return { melee, ranged }; -} - -// ---- per-player stage overrides (terrain 机变 cards) ------------------------------------------------------ - -const OBSTACLE_ROLES = new Set(['crate', 'mound']); -const PLATFORM_ROLES = new Set(['platform']); -/** 特制水上平台 (act1 m05, weight 0 this season): its 深水区 tile takes any unit ("在水上建立可以部署任意单位的平台"). */ -const WATER_PLATFORM_ROLES = new Set(['waterPlatform']); -const hasKeys = (o) => isObj(o) && Object.keys(o).length > 0; - -/** - * The own board's device / tile overrides the server applies to placement (server/match/board.js buildDeployMap - * `deviceOverrides` / `tileOverrides`, set by 模拟战场演变 cards). m.private may carry them directly - * (`deviceOverrides` / `tileOverrides`); otherwise they are derived from the owned 机变 effect entries - * (`choice:#` / `#`) whose data/effects.json record has `auto_chess_change_map` - * buffs — the blackboard's `trap_…#nnn` aliases, applied in pick order like the server does. - * @param {any} priv m.private - * @param {(id:string)=>any} [getEffect] effects.json lookup - * @returns {{ deviceOverrides: Record, tileOverrides: Record }} - */ -export function stageOverrides(priv, getEffect = () => null) { - const deviceOverrides = {}; - const tileOverrides = {}; - if (isObj(priv?.deviceOverrides) || isObj(priv?.tileOverrides)) { - for (const [k, v] of Object.entries(isObj(priv.deviceOverrides) ? priv.deviceOverrides : {})) deviceOverrides[k] = !!v; - for (const [k, v] of Object.entries(isObj(priv.tileOverrides) ? priv.tileOverrides : {})) if (typeof v === 'string') tileOverrides[k] = v; - return { deviceOverrides, tileOverrides }; - } - for (const e of Array.isArray(priv?.effects) ? priv.effects : []) { - // picked 机变 cards: 'choice:#' (sim/content/choices.js refIdFor) or '#' (choices.js - // addRef); band / builtin entries (no '#', other prefixes) are not picks - const m = typeof e?.id === 'string' ? /^(?:choice:)?([^:#]+)#\d+$/.exec(e.id) : null; - if (!m) continue; - const eff = getEffect(m[1]); - for (const b of Array.isArray(eff?.buffs) ? eff.buffs : []) { - if (b?.key !== 'auto_chess_change_map' || !isObj(b.bb)) continue; - for (const [alias, v] of Object.entries(b.bb)) if (alias.includes('#')) deviceOverrides[alias] = Number(v) !== 0; - } - } - return { deviceOverrides, tileOverrides }; -} - -/** - * Deploy classes of the own board — mirror of server/match/board.js buildDeployMap (legend + active devices + - * overrides, on deploy field `field`): 'melee' (melee and ranged) or 'ranged' (ranged only) per board 'r,c'. - * @param {any} stage - * @param {{ deviceOverrides?: Record, tileOverrides?: Record, field?: 'normal'|'bossL'|'bossR' }} [o] - * @returns {Map} - */ -export function deployMap(stage, { deviceOverrides = {}, tileOverrides = {}, field = 'normal' } = {}) { - const F = GEO.FIELD; - const inField = (r, c) => Number.isInteger(r) && Number.isInteger(c) && r >= F.r0 && r <= F.r1 && c >= F.c0 && c <= F.c1; - const map = new Map(); - const rows = Array.isArray(stage?.rows) ? stage.rows : null; - const legend = isObj(stage?.tiles) ? stage.tiles : {}; - for (let r = F.r0; r <= F.r1; r++) { - for (let c = F.c0; c <= F.c1; c++) { - const [sr, sc] = fieldTile(field, r, c); - const line = rows && typeof rows[sr] === 'string' ? rows[sr] : null; - let cls = null; - if (line) { - const g = line[sc]; - const t = g != null && Object.hasOwn(legend, g) ? legend[g] : null; - if (t) { - const b = t.buildable; - if (t.height === 'LOW' && (b === 'ALL' || b === 'MELEE')) cls = 'melee'; - else if ((t.height === 'HIGH' && (b === 'ALL' || b === 'RANGED')) || (t.height === 'LOW' && b === 'RANGED')) cls = 'ranged'; - } - } else if (!stage) cls = c === 9 ? null : 'melee'; - if (cls) map.set(tileKey(r, c), cls); - } - } - for (const d of Array.isArray(stage?.devices) ? stage.devices : []) { - if (!isObj(d) || !Array.isArray(d.pos)) continue; - const [r, c] = boardTileOf(field, d.pos[0], d.pos[1]); - if (!inField(r, c)) continue; - let active; - if (d.alias != null && isObj(deviceOverrides) && Object.hasOwn(deviceOverrides, d.alias)) active = !!deviceOverrides[d.alias]; - else if (typeof d.active === 'boolean') active = d.active; - else active = !d.hidden; - if (!active) continue; - if (OBSTACLE_ROLES.has(d.role)) map.delete(tileKey(r, c)); - else if (PLATFORM_ROLES.has(d.role)) map.set(tileKey(r, c), 'ranged'); - else if (WATER_PLATFORM_ROLES.has(d.role)) map.set(tileKey(r, c), 'melee'); - } - for (const [k, v] of Object.entries(isObj(tileOverrides) ? tileOverrides : {})) { - const [r, c] = String(k).split(',').map(Number); - if (!inField(r, c)) continue; - if (v === 'melee' || v === 'ranged') map.set(tileKey(r, c), v); - else if (v === 'none') map.delete(tileKey(r, c)); - } - return map; -} - -/** - * The stage as this player's board looks with its overrides: devices switched by `deviceOverrides` carry the new - * `active` flag (the renderer draws / hides crates and platforms from it) and `deployTiles.normal` is rebuilt - * from the deploy map. Returns the stage itself when nothing is overridden. - * @param {any} stage stages.json record - * @param {{ deviceOverrides?: Record, tileOverrides?: Record }} [overrides] - */ -export function effectiveStage(stage, overrides = {}) { - const dev = isObj(overrides?.deviceOverrides) ? overrides.deviceOverrides : {}; - const tiles = isObj(overrides?.tileOverrides) ? overrides.tileOverrides : {}; - if (!isObj(stage) || (!hasKeys(dev) && !hasKeys(tiles))) return stage; - const devices = (Array.isArray(stage.devices) ? stage.devices : []).map((d) => ( - isObj(d) && d.alias != null && Object.hasOwn(dev, d.alias) ? { ...d, active: !!dev[d.alias] } : d)); - const map = deployMap(stage, { deviceOverrides: dev, tileOverrides: tiles }); - const melee = []; - const rangedOnly = []; - for (const [k, cls] of map) { - const [r, c] = k.split(',').map(Number); - (cls === 'melee' ? melee : rangedOnly).push([r, c]); - } - const deployTiles = { ...(isObj(stage.deployTiles) ? stage.deployTiles : {}), normal: { melee, rangedOnly } }; - return { ...stage, devices, deployTiles }; -} - -/** - * Index every own piece by uid. - * @param {any} priv m.private - * @returns {Map} - */ -export function indexPieces(priv) { - const map = new Map(); - (Array.isArray(priv?.board) ? priv.board : []).forEach((p) => { - if (isObj(p) && Number.isInteger(p.uid)) map.set(p.uid, { piece: p, area: 'board', row: p.row, col: p.col }); - }); - (Array.isArray(priv?.hand) ? priv.hand : []).forEach((p, idx) => { - if (isObj(p) && Number.isInteger(p.uid)) map.set(p.uid, { piece: p, area: 'hand', idx }); - }); - (Array.isArray(priv?.temp) ? priv.temp : []).forEach((p, idx) => { - if (isObj(p) && Number.isInteger(p.uid)) map.set(p.uid, { piece: p, area: 'temp', idx }); - }); - return map; -} - -/** - * Build the placement context for `canPlace`. The deploy tiles include the player's stage overrides (terrain 机变 - * cards, `stageOverrides`), like the server's per-player deploy map, on the field the pieces are deployed on - * (`field` = `deployFieldOf(pub, myId)`: the own board, or the player's half of the boss field in a boss round). - * @param {{ priv:any, stage:any, editable:boolean, field?:'normal'|'bossL'|'bossR', getChess?:(id:string)=>any, - * getToken?:(id:string)=>any, getItem?:(id:string)=>any, getEffect?:(id:string)=>any }} o - */ -export function placementContext({ priv, stage, editable, field = 'normal', getChess = () => null, getToken = () => null, getItem = () => null, getEffect = () => null }) { - const pieces = indexPieces(priv); - const boardAt = new Map(); - for (const e of pieces.values()) if (e.area === 'board') boardAt.set(tileKey(e.row, e.col), e); - const handAt = new Map(); - for (const e of pieces.values()) if (e.area === 'hand') handAt.set(e.idx, e); - const cap = Number.isInteger(priv?.deployCap) ? priv.deployCap : 8; - let deployed = 0; - for (const e of pieces.values()) if (e.area === 'board' && e.piece.kind === 'chess') deployed += 1; - const count = Number.isInteger(priv?.deployCount) ? priv.deployCount : deployed; - const ov = stageOverrides(priv, getEffect); - // the own normal board keeps the data's deploy tiles (with the terrain overrides folded in); the boss field of a - // boss round's prep is read from the legend + its devices under the same overrides (server deploy map, field) - const deploy = field === 'bossL' || field === 'bossR' - ? deploySets(stage, field, ov) - : deploySets(effectiveStage(stage, ov)); - return { priv, pieces, boardAt, handAt, deploy, cap, count, field, editable: !!editable, getChess, getToken, getItem }; -} - -/** - * Deploy position ('MELEE'|'RANGED'|'ALL') of a chess/token piece, or null for items. Elite 歌蕾蒂娅 carrying HOK-Y - * (the viewer's loadout, shared/highGround.js) is 'ALL': any deployable tile, the 高台 included - * (server/match/board.js placeClass; owner's decision 2026-10-04). Every other MELEE chess is ground-only. - */ -export function piecePosition(ctx, piece) { - if (!isObj(piece)) return null; - if (piece.kind === 'chess') { - const rec = ctx.getChess(piece.id); - let moduleId = null; - try { moduleId = resolveLoadout(ctx.priv?.loadout ?? null, rec, ctx.getChess)?.moduleId ?? null; } catch { moduleId = null; } - if (meleeOnHighGround(rec, moduleId)) return 'ALL'; - return rec?.position === 'MELEE' ? 'MELEE' : 'RANGED'; - } - // tokens: MELEE → ground only; RANGED / ALL → any deployable tile - if (piece.kind === 'token') return ctx.getToken(piece.id)?.position === 'MELEE' ? 'MELEE' : 'RANGED'; - return null; -} - -/** Whether a unit piece (chess/token) may stand on a board tile. */ -export function tileAllows(ctx, piece, row, col) { - const pos = piecePosition(ctx, piece); - if (!pos) return false; - const k = tileKey(row, col); - return pos === 'MELEE' ? ctx.deploy.melee.has(k) : ctx.deploy.ranged.has(k); -} - -/** - * Board tiles ('r,c') where a summon whose text reads "只能部署在召唤者攻击范围内" may stand (tokens.json `ownerRange`: - * 伺夜's 狼群, 缪尔赛思's 流形 — the tactical point; player report #9 after 0.1.0): its owner's attack range (the - * loadout's grid, shared/loadoutRecord.js attackRangeGrid, rotated by the owner's facing around its tile). Null when - * the piece is not range-bound or its owner is not on the board. `owner` ({ row, col, piece }) replaces the owner's - * current position (a summon swapped with its own owner). Mirror of server/match/PlayerState.js summonRange. - * @param {ReturnType} ctx - * @returns {Set | null} - */ -export function summonRange(ctx, piece, owner = null) { - if (!isObj(piece) || piece.kind !== 'token' || ctx?.getToken?.(piece.id)?.ownerRange !== true) return null; - let at = owner; - if (!at) for (const e of ctx.boardAt.values()) if (e.piece.uid === piece.ownerUid && e.piece.kind === 'chess') { at = e; break; } - const rec = at && ctx.getChess(at.piece.id); - if (!rec) return null; - let grid = null; - try { grid = attackRangeGrid(chessLoadout(rec, ctx.priv?.loadout ?? null, ctx.getChess)?.record || rec); } catch { /* the data grid */ } - return new Set(rangeTiles(grid || rec.rangeGrid, at.row, at.col, pieceDir(at.piece)).map(([r, c]) => tileKey(r, c))); -} - -/** tileAllows plus the owner-range rule of a range-bound summon (summonRange). */ -function unitAllowed(ctx, piece, row, col, owner = null) { - if (!tileAllows(ctx, piece, row, col)) return false; - const range = summonRange(ctx, piece, owner); - return !range || range.has(tileKey(row, col)); -} - -/** - * Placement legality of dropping piece `uid` on `target` (mirror of server/match/PlayerState.js move / equip / - * useArt and server/match/board.js canPlace): - * board ← chess: legal tile for its position; empty tile or a chess occupant (swap; from the board the occupant - * must be legal on the source tile); from hand/temp the deploy cap applies unless a chess occupant swaps out. - * board ← token: legal tile; from the hand the tile must be empty and its summoner deployed; board ↔ board swaps. - * hand ← chess from the board: empty slot (withdraw), chess occupant (swap, legal on the source tile), otherwise - * it goes to any free slot (HAND_FULL when none). hand ← token from the board: always (back onto its stack). - * hand ← hand/temp piece: move / swap. hand ← EQUIP item onto a chess: equip; onto a non-chess: swap. - * board ← EQUIP item: equip the chess on that tile. board ← MAGIC (Arts): used on that tile. - * @param {ReturnType} ctx - * @param {number} uid - * @param {{area:'board',row:number,col:number}|{area:'hand',idx:number}|{area:'outside'}} target - * @returns {{ ok: boolean, action?: 'move'|'swap'|'orient'|'equip'|'art', reason?: string, code?: string }} - */ -export function canPlace(ctx, uid, target) { - const no = (code, reason) => ({ ok: false, code, reason }); - if (!ctx || !ctx.editable) return no('WRONG_PHASE', '当前阶段无法进行该操作'); - const src = ctx.pieces.get(uid); - if (!src) return no('BAD_TARGET', '找不到该单位'); - if (!isObj(target)) return no('BAD_TILE', '无法部署在该位置'); - const piece = src.piece; - const isMagic = piece.kind === 'item' && ctx.getItem(piece.id)?.itemType === 'MAGIC'; - - if (target.area === 'hand') { - const idx = target.idx; - if (!Number.isInteger(idx) || idx < 0 || idx >= GEO.HAND_SIZE) return no('BAD_TILE', '无法放置在该位置'); - if (src.area === 'hand' && src.idx === idx) return no('ALREADY', '位置未变化'); - const occ = ctx.handAt.get(idx); - if (!occ) return { ok: true, action: 'move' }; - if (piece.kind === 'item') { - if (occ.piece.kind === 'chess') return isMagic ? no('BAD_TARGET', '该道具需要放置在战场上使用') : equipCheck(ctx, piece, occ.piece); - return { ok: true, action: 'swap' }; - } - if (src.area === 'board') { - if (piece.kind === 'token') return { ok: true, action: 'move' }; // back onto its stack - if (occ.piece.kind === 'chess') { - if (!tileAllows(ctx, occ.piece, src.row, src.col)) return no('BAD_TILE', '交换后的单位无法部署在原位置'); - return { ok: true, action: 'swap' }; - } - // the withdrawn operator goes to another free slot - const free = [...Array(GEO.HAND_SIZE).keys()].some((i) => !ctx.handAt.has(i)); - return free ? { ok: true, action: 'move' } : no('HAND_FULL', '整备区已满'); - } - return { ok: true, action: 'swap' }; - } - - if (target.area === 'board') { - const { row, col } = target; - if (!Number.isInteger(row) || !Number.isInteger(col)) return no('BAD_TILE', '无法部署在该位置'); - const inField = row >= GEO.FIELD.r0 && row <= GEO.FIELD.r1 && col >= GEO.FIELD.c0 && col <= GEO.FIELD.c1; - if (!inField) return no('BAD_TILE', '无法部署在该位置'); - const occ = ctx.boardAt.get(tileKey(row, col)); - if (piece.kind === 'item') { - if (isMagic) return { ok: true, action: 'art' }; - if (!occ) return no('BAD_TARGET', '请将装备拖拽至干员身上'); - return equipCheck(ctx, piece, occ.piece); - } - // its own tile: re-orient in place through the direction wheel (research 09 §1.2) - if (src.area === 'board' && src.row === row && src.col === col) return { ok: true, action: 'orient' }; - if (!tileAllows(ctx, piece, row, col)) { - const deployable = ctx.deploy.ranged.has(tileKey(row, col)); - return no('BAD_TILE', deployable && piecePosition(ctx, piece) === 'MELEE' ? '近战单位只能部署在地面' : '无法部署在该位置'); - } - // a range-bound summon (战术点): inside its owner's attack range — seen from the summon's old tile when it is - // dropped onto its own owner (the two swap) - const ownerSwap = src.area === 'board' && occ && occ.piece.uid === piece.ownerUid ? { row: src.row, col: src.col, piece: occ.piece } : null; - const range = summonRange(ctx, piece, ownerSwap); - if (range && !range.has(tileKey(row, col))) return no('BAD_TILE', '只能部署在召唤者攻击范围内'); - if (src.area === 'board') { - // board → board: move or swap (the occupant must be legal on the source tile — the mover's own summon excepted: - // a moved operator's summons go back to the hand anyway) - const ownSummon = occ && occ.piece.kind === 'token' && occ.piece.ownerUid === piece.uid; - if (occ && !ownSummon && !unitAllowed(ctx, occ.piece, src.row, src.col)) return no('BAD_TILE', '交换后的单位无法部署在原位置'); - return { ok: true, action: occ ? 'swap' : 'move' }; - } - if (piece.kind === 'token') { - if (occ) return no('BAD_TILE', '该位置已有单位'); - const ownerDeployed = [...ctx.boardAt.values()].some((e) => e.piece.uid === piece.ownerUid); - if (Number.isInteger(piece.ownerUid) && !ownerDeployed) return no('BAD_TARGET', '召唤者尚未部署'); - return { ok: true, action: 'move' }; - } - if ((!occ || occ.piece.kind !== 'chess') && ctx.count >= ctx.cap) return no('BOARD_FULL', '已达到部署上限'); - return { ok: true, action: occ ? 'swap' : 'move' }; - } - return no('BAD_TILE', '无法放置在该位置'); -} - -function equipCheck(ctx, itemPiece, targetPiece) { - const item = ctx.getItem(itemPiece.id); - if (item?.itemType === 'MAGIC') return { ok: false, code: 'BAD_TARGET', reason: '该道具需要放置在战场上使用' }; - if (!isObj(targetPiece) || targetPiece.kind !== 'chess') return { ok: false, code: 'BAD_TARGET', reason: '装备只能配发给干员' }; - return { ok: true, action: 'equip' }; -} - -/** Whether equipping `itemId` attaches it (true) or consumes it on equip (false: no slot is used / replaced). */ -export function itemAttaches(item) { - return !(typeof item?.kind === 'string' && item.kind.startsWith('consume_on_equip')); -} - -/** - * Whether equipping item `uid` completes an item merge (an identical normal copy is owned elsewhere — hand, temp or - * equipped): the server then merges the pair into the golden item instead of equipping (server PlayerState.equip → - * completesItemMerge), so a full carrier loses nothing and no replace dialog is needed. - * @param {ReturnType} ctx - * @param {number} uid - * @param {number} [itemMergeCount] default copies per merge (config) - */ -export function equipMerges(ctx, uid, itemMergeCount = 2) { - const piece = ctx?.pieces?.get(uid)?.piece; - if (!isObj(piece) || piece.kind !== 'item') return false; - const rec = ctx.getItem?.(piece.id); - if (!rec || rec.isGolden || rec.itemType !== 'EQUIP' || !rec.mergeable) return false; - const n = Number.isInteger(rec.upgradeNum) ? rec.upgradeNum : itemMergeCount; - if (!(n > 1 && n < 100) || !ctx.getItem(rec.upgradeChessId || rec.goldenId)) return false; - let have = 0; - for (const e of ctx.pieces.values()) { - const p = e.piece; - if (p.uid !== uid && p.kind === 'item' && p.id === piece.id) have += 1; - if (p.kind === 'chess') for (const it of Array.isArray(p.items) ? p.items : []) if (it?.id === piece.id && it.uid !== uid) have += 1; - } - return have + 1 >= n; -} - -/** - * All legal board tiles for dragging `uid` (for view.highlightTiles). - * @param {ReturnType} ctx - * @param {number} uid - * @returns {{ legal: Array<[number, number]>, illegal: Array<[number, number]> }} - */ -export function boardTargets(ctx, uid) { - const legal = []; - const illegal = []; - if (!ctx?.pieces?.get(uid)) return { legal, illegal }; - for (let r = GEO.FIELD.r0; r <= GEO.FIELD.r1; r++) { - for (let c = GEO.FIELD.c0; c <= GEO.FIELD.c1; c++) { - const k = tileKey(r, c); - if (!ctx.deploy.ranged.has(k) && !ctx.boardAt.has(k)) continue; // never-deployable scenery - (canPlace(ctx, uid, { area: 'board', row: r, col: c }).ok ? legal : illegal).push([r, c]); - } - } - return { legal, illegal }; -} - -/** - * The `g.*` intent for a drop, or null (no-op / illegal / UI-handled). - * @param {ReturnType} ctx - * @param {number} uid - * @param {any} target - * @returns {{ t: string, fields: object, confirmReplace?: boolean } | null} - */ -export function dropIntent(ctx, uid, target) { - const res = canPlace(ctx, uid, target); - if (!res.ok) return null; - if (res.action === 'art') return { t: 'g.art', fields: { itemUid: uid, row: target.row, col: target.col } }; - if (res.action === 'equip') { - const occ = target.area === 'hand' ? ctx.handAt.get(target.idx) : ctx.boardAt.get(tileKey(target.row, target.col)); - // both slots used: the replace dialog picks the equipped item to destroy (g.equip replaceUid) — unless the item is - // consumed on equip, or it completes an item merge (the server merges it instead of equipping: nothing replaced) - const full = Array.isArray(occ?.piece?.items) && occ.piece.items.length >= 2 - && itemAttaches(ctx.getItem(ctx.pieces.get(uid)?.piece?.id)) && !equipMerges(ctx, uid); - return { t: 'g.equip', fields: { itemUid: uid, targetUid: occ.piece.uid }, confirmReplace: full }; - } - const to = target.area === 'hand' ? { area: 'hand', idx: target.idx } : { area: 'board', row: target.row, col: target.col }; - return { t: 'g.move', fields: { uid, to } }; -} - -/** - * Why a drag released over the field was refused (null when there is nothing to say): the canPlace reason for a - * board tile / hand slot, a generic line for other tiles of the own board rows (lanes, blocked tiles) and the - * temporary bench. Releasing on the piece's own slot or far from the board says nothing. - * @param {ReturnType} ctx - * @param {number} uid - * @param {{row:number, col:number, area?:string|null, idx?:number}|null} tile last hovered tile (render tileHover) - * @returns {string|null} - */ -export function dropFailureReason(ctx, uid, tile) { - if (!ctx?.editable || !ctx.pieces?.get(uid) || !isObj(tile)) return null; - let target = null; - if (tile.area === 'board') target = { area: 'board', row: tile.row, col: tile.col }; - else if (tile.area === 'hand') target = { area: 'hand', idx: Number.isInteger(tile.idx) ? tile.idx : tile.col }; - else if (tile.area === 'temp') return ctx.pieces.get(uid).area === 'temp' ? null : '临时整备区无法放入单位'; - else if (Number.isInteger(tile.row) && tile.row >= GEO.FIELD.r0 && tile.row <= GEO.FIELD.r1) return '无法部署在该位置'; - else return null; - const res = canPlace(ctx, uid, target); - if (res.ok || res.code === 'ALREADY') return null; - return res.reason || '无法放置在该位置'; -} - -// ---- 机变 / band draft normalisation ---------------------------------------------------------------------- - -/** - * Normalise m.public.draft ({order, turn, picks, skips?}) — tolerant of index/playerId turns and - * object/array picks. - * @param {any} draft - * @param {any[]} [players] - * @returns {{ order: string[], turnPid: string|null, picks: Map, skipsLeft: Map, done: boolean }} - */ -export function normalizeDraft(draft, players = []) { - const ids = (Array.isArray(players) ? players : []).filter(isObj).map((p) => p.playerId); - const order = Array.isArray(draft?.order) && draft.order.length ? draft.order.filter((x) => typeof x === 'string') : ids; - let turnPid = null; - if (typeof draft?.turn === 'string') turnPid = draft.turn; - else if (Number.isInteger(draft?.turn) && draft.turn >= 0 && draft.turn < order.length) turnPid = order[draft.turn]; - const picks = new Map(); - const src = draft?.picks; - if (Array.isArray(src)) { - src.forEach((v, i) => { - if (typeof v === 'string' && order[i]) picks.set(order[i], v); - else if (isObj(v) && typeof v.playerId === 'string' && typeof v.bandId === 'string') picks.set(v.playerId, v.bandId); - }); - } else if (isObj(src)) { - for (const [k, v] of Object.entries(src)) if (typeof v === 'string' && v) picks.set(k, v); - } - const skipsLeft = new Map(); - const sk = draft?.skipsLeft ?? draft?.skips; - if (isObj(sk)) for (const [k, v] of Object.entries(sk)) if (Number.isFinite(v)) skipsLeft.set(k, v); - const done = order.length > 0 && order.every((pid) => picks.has(pid)); - return { order, turnPid, picks, skipsLeft, done }; -} - -/** - * Normalise m.public.sp ({family, cards, turn, picks, order?}). - * Cards: string ids or objects; picks: {playerId: cardIdx} | [{playerId, idx}] | card.takenBy. - * @param {any} sp - * @param {any[]} [players] - */ -export function normalizeSp(sp, players = []) { - if (!isObj(sp)) return null; - const ids = (Array.isArray(players) ? players : []).filter(isObj).map((p) => p.playerId); - const order = Array.isArray(sp.order) && sp.order.length ? sp.order.filter((x) => typeof x === 'string') : ids; - const cards = (Array.isArray(sp.cards) ? sp.cards : []).slice(0, 6).map((c, idx) => { - const card = typeof c === 'string' ? { id: c } : isObj(c) ? { ...c } : {}; - return { ...card, idx, takenBy: typeof card.takenBy === 'string' ? card.takenBy : null }; - }); - const takenBy = new Map(); // idx → playerId - const pickOf = new Map(); // playerId → idx - const src = sp.picks; - const add = (pid, idx) => { - if (typeof pid !== 'string' || !Number.isInteger(idx) || idx < 0 || idx >= cards.length) return; - takenBy.set(idx, pid); - pickOf.set(pid, idx); - }; - if (Array.isArray(src)) src.forEach((v, i) => { if (isObj(v)) add(v.playerId, v.idx); else if (Number.isInteger(v) && order[i]) add(order[i], v); }); - else if (isObj(src)) for (const [k, v] of Object.entries(src)) add(k, v); - if (isObj(sp.taken)) for (const [k, v] of Object.entries(sp.taken)) add(v, Number(k)); - for (const c of cards) if (c.takenBy) add(c.takenBy, c.idx); - for (const c of cards) c.takenBy = takenBy.get(c.idx) ?? null; - let turnPid = null; - if (typeof sp.turn === 'string') turnPid = sp.turn; - else if (Number.isInteger(sp.turn) && sp.turn >= 0 && sp.turn < order.length) turnPid = order[sp.turn]; - return { - family: typeof sp.family === 'string' ? sp.family : null, - name: typeof sp.name === 'string' && sp.name ? sp.name : null, - desc: typeof sp.desc === 'string' && sp.desc ? sp.desc : null, - untimed: !!sp.untimed, - cards, order, turnPid, pickOf, takenBy, pickedCount: pickOf.size, - }; -} - -// ---- enemies ------------------------------------------------------------------------------------------------ - -const TAG_ORDER = { boss: 0, bounty: 1, escort: 2 }; -const RANK_ORDER = { BOSS: 0, ELITE: 1, NORMAL: 2 }; - -/** - * Next-round enemy preview rows: merged by (enemyKey, tag), sorted boss → bounty → elite → normal → count. - * @param {Array<{enemyKey:string, count?:number, tag?:string}>} list - * @param {(key:string)=>any} [getEnemy] - */ -export function groupEnemies(list, getEnemy = () => null) { - const map = new Map(); - for (const e of Array.isArray(list) ? list : []) { - if (!isObj(e) || typeof e.enemyKey !== 'string') continue; - const tag = typeof e.tag === 'string' ? e.tag : null; - const k = `${e.enemyKey}|${tag}`; - const cur = map.get(k) || { enemyKey: e.enemyKey, tag, count: 0 }; - cur.count += Number.isFinite(e.count) && e.count > 0 ? e.count : 1; - map.set(k, cur); - } - return [...map.values()].map((row) => { - const en = getEnemy(row.enemyKey); - return { ...row, name: en?.name || row.enemyKey, rank: en?.rank || 'NORMAL', acTypes: Array.isArray(en?.acTypes) ? en.acTypes : (en?.acType ? [en.acType] : []), fly: en?.stats?.motion === 'FLY' || !!en?.isFlyEnemy }; - }).sort((a, b) => (TAG_ORDER[a.tag] ?? 9) - (TAG_ORDER[b.tag] ?? 9) - || (RANK_ORDER[a.rank] ?? 9) - (RANK_ORDER[b.rank] ?? 9) - || b.count - a.count || (a.enemyKey < b.enemyKey ? -1 : 1)); -} - -// ---- enemy preview pen (research 09 §2, research 08 §4.2) --------------------------------------------------- - -/** Pen rect and zones: rows 14–18 × cols 7–13, row 16 unused; lower-gate zone rows 14–15, upper-gate zone rows 17–18. */ -export const PEN = Object.freeze({ r0: 14, r1: 18, c0: 7, c1: 13, emptyRow: 16, cap: 50, anchors: Object.freeze({ lower: [15, 7], upper: [18, 7] }) }); - -/** Zone tiles in row-major order (low row first, col 7→13), without the zone's anchor (tile_start) — 13 per zone. */ -export function penZoneTiles(gate) { - const rows = gate === 'upper' ? [17, 18] : [14, 15]; - const [ar, ac] = PEN.anchors[gate === 'upper' ? 'upper' : 'lower']; - const out = []; - for (const r of rows) for (let c = PEN.c0; c <= PEN.c1; c++) if (!(r === ar && c === ac)) out.push([r, c]); - return out; -} - -/** - * Placement of the preview models in the pen for the DOM fallback view — the SAME layout as the render engine - * (render/pen.js layoutPen: research 08 §4.2, client AutoChessEnemyPreviewManager — spawn-time order; > 50 enemies - * thinned per entry to max(1, round(count·50/total)), elites / leaders always all of theirs; each gate's models in its - * zone, the k-th model of a zone at zoneTiles[round(k/totalShowCnt·len) − jitter]; ≤ 3 per tile; seeded, so a re-sent - * m.private never reshuffles it). Both views show one teammate's pen identically (review regression: the fallback used - * its own variant — global k, no per-tile cap — and placed the same enemies elsewhere). `slot` numbers the models - * sharing a tile. - * @param {Array<{ enemyKey: string, count?: number, gate?: string, t?: number, fly?: boolean, elite?: boolean, boss?: boolean }>} entries m.private.nextEnemies - * @param {{ cap?: number, stage?: any }} [opts] cap = MAX_PREVIEW_CNT (50); stage = the stage (its pen rect / tiles) - * @returns {Array<{ enemyKey: string, gate: 'upper'|'lower', row: number, col: number, slot: number, fly: boolean, elite: boolean, boss: boolean, k: number }>} - */ -export function penPlacement(entries, opts = {}) { - const max = Number.isInteger(opts.cap) && opts.cap > 0 ? opts.cap : PEN.cap; - const { figures } = layoutPen(Array.isArray(entries) ? entries : [], { stage: opts.stage || null, max }); - const perTile = new Map(); - return figures.map((f, k) => { - const key = `${f.row},${f.col}`; - const slot = perTile.get(key) || 0; - perTile.set(key, slot + 1); - return { enemyKey: f.enemyKey, gate: f.zone === 'upper' ? 'upper' : 'lower', row: f.row, col: f.col, slot, fly: !!f.fly, elite: !!f.elite, boss: !!f.boss, k }; - }); -} - -/** - * The enemy a view `pieceClick` points at in the preview pen, or null: `{ enemyKey }` or a `preview` enemy unit - * (`{ preview: true, unit: { side: 'enemy', defId } }`). Battle units (live fights) are not pen enemies. - * @param {any} e - */ -export function previewEnemyKey(e) { - if (!isObj(e)) return null; - if (typeof e.enemyKey === 'string' && e.enemyKey) return e.enemyKey; - const u = isObj(e.unit) ? e.unit : null; - if (!u || !(e.preview || u.preview)) return null; - if (u.side !== 'enemy' && u.kind !== 'enemy') return null; - const k = typeof u.enemyKey === 'string' && u.enemyKey ? u.enemyKey : u.defId; - return typeof k === 'string' && k ? k : null; -} - -/** Normalise m.public.factions (type strings or objects) to type ids. */ -export function factionTypes(factions) { - const out = []; - for (const f of Array.isArray(factions) ? factions : []) { - const t = typeof f === 'string' ? f : isObj(f) ? (f.type || f.id || f.key) : null; - if (typeof t === 'string' && t && !out.includes(t)) out.push(t); - } - return out; -} - -// ---- snapshots / battle HUD --------------------------------------------------------------------------------- - -/** - * HUD numbers from a b.snap: { killed, total, dp, boss } (boss: { hp, max } when present). - * @param {any} snap - */ -export function snapHud(snap) { - if (!isObj(snap)) return null; - const n = (v) => (Number.isFinite(v) ? v : null); - let boss = null; - if (isObj(snap.boss) && Number.isFinite(snap.boss.hp)) boss = { hp: snap.boss.hp, max: n(snap.boss.max) ?? n(snap.boss.maxHp) }; - return { killed: n(snap.killed), total: n(snap.total), dp: n(snap.dp), boss }; -} - -/** Boss HP fraction 0..1 (null when unknown). */ -export function bossFrac(bossHp) { - if (!isObj(bossHp)) return null; - const hp = Number(bossHp.hp); - const max = Number(bossHp.max ?? bossHp.maxHp); - if (!Number.isFinite(hp) || !Number.isFinite(max) || max <= 0) return null; - return clamp(hp / max, 0, 1); -} - -/** - * The boss bar's percentage text for a fraction (bossFrac): whole percents from 10 %, one decimal below, and never - * "0.0%" while the leader still has HP — a sliver reads "<0.1%" (user playtest #6 item 5: a bar at 0.0 % with the - * leader still fighting read as a leader that could not die). null for an unknown fraction. - * @param {number|null} frac - */ -export function bossPctText(frac) { - if (frac == null || !Number.isFinite(frac)) return null; - const pct = clamp(frac, 0, 1) * 100; - if (pct >= 10) return `${pct.toFixed(0)}%`; - if (pct > 0 && pct < 0.05) return '<0.1%'; - return `${pct.toFixed(1)}%`; -} - -/** Whether a snapshot unit tuple has a flag. */ -export const hasFlag = (flags, bit) => (Number(flags) & bit) !== 0; -export { UF }; - -// ---- stats & range -------------------------------------------------------------------------------------------- - -/** Attack interval in seconds (bat × 100 / aspd). */ -export function attackInterval(bat, aspd = 100) { - const b = Number(bat); - const a = Number(aspd) > 0 ? Number(aspd) : 100; - if (!Number.isFinite(b) || b <= 0) return null; - return b * 100 / a; -} - -/** Compact number: 12345 → '12,345'; 1.5e6 → '150万'. */ -export function fmtNum(v) { - const n = Number(v); - if (!Number.isFinite(n)) return '—'; - if (Math.abs(n) >= 1e8) return `${(n / 1e8).toFixed(n >= 1e9 ? 0 : 1)}亿`; - if (Math.abs(n) >= 1e5) return `${(n / 1e4).toFixed(n >= 1e6 ? 0 : 1)}万`; - return Math.round(n).toLocaleString('en-US'); -} - -/** - * Bounding box + cell set of a range grid (always including the own tile [0,0]); `mirror` flips columns. - * @param {Array<[number, number]>} grid [[dRow, dCol], …] - * @param {boolean} [mirror] - * @returns {{ rows: number, cols: number, r0: number, c0: number, cells: Set, self: [number, number] }} - */ -export function rangeGridBox(grid, mirror = false) { - const cells = new Set(); - let minR = 0; let maxR = 0; let minC = 0; let maxC = 0; - for (const g of Array.isArray(grid) ? grid : []) { - if (!Array.isArray(g) || !Number.isFinite(g[0]) || !Number.isFinite(g[1])) continue; - const r = g[0]; - const c = mirror ? -g[1] : g[1]; - cells.add(tileKey(r, c)); - minR = Math.min(minR, r); maxR = Math.max(maxR, r); minC = Math.min(minC, c); maxC = Math.max(maxC, c); - } - return { rows: maxR - minR + 1, cols: maxC - minC + 1, r0: maxR, c0: minC, cells, self: [0, 0] }; -} - -// ---- keyboard --------------------------------------------------------------------------------------------------- - -/** - * Map a keydown to a game shortcut (R refresh, F freeze, D level-up, Q retreat, X sell, Space ready, Esc close). - * Space means ready even while a HUD button has focus (a mouse click leaves the shop card / 刷新 focused, and - * Space must not re-trigger it); the caller prevents the button's own activation. Enter still activates buttons. - * @param {{ key?: string, code?: string, ctrlKey?: boolean, metaKey?: boolean, altKey?: boolean, repeat?: boolean, target?: any }} e - * @returns {'refresh'|'freeze'|'levelUp'|'retreat'|'sell'|'ready'|'escape'|null} - */ -export function shortcutFor(e) { - if (!e || e.ctrlKey || e.metaKey || e.altKey) return null; - const t = e.target; - const tag = t && typeof t.tagName === 'string' ? t.tagName.toUpperCase() : ''; - if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || t?.isContentEditable) return null; - if (e.key === 'Escape') return 'escape'; - if (e.repeat) return null; - const code = e.code || ''; - const key = typeof e.key === 'string' ? e.key.toLowerCase() : ''; - if (code === 'KeyR' || key === 'r') return 'refresh'; - if (code === 'KeyF' || key === 'f') return 'freeze'; - if (code === 'KeyD' || key === 'd') return 'levelUp'; - if (code === 'KeyQ' || key === 'q') return 'retreat'; - if (code === 'KeyX' || key === 'x') return 'sell'; - if (code === 'Space' || key === ' ') return 'ready'; - return null; -} - -/** - * Whether a press on the field closes the open detail card: a card opened from the field itself (an own piece — tap, - * 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'; - -/** - * Whether an open overlay swallows a game shortcut: a modal / the guide own the keyboard (Esc included — they close - * themselves); the 本局信息 / 敌方情报 drawer is a dialog too — only Esc (it closes the drawer) passes, R / F / D / Space - * never act behind it. - * @param {'refresh'|'freeze'|'levelUp'|'retreat'|'sell'|'ready'|'escape'|null} act shortcutFor - * @param {{ modal?: boolean, drawer?: boolean }} open - */ -export function shortcutBlocked(act, { modal = false, drawer = false } = {}) { - if (!act) return true; - if (modal) return true; - return !!drawer && act !== 'escape'; -} - -// ---- settings ------------------------------------------------------------------------------------------------------ - -export const DEFAULT_SETTINGS = Object.freeze({ bgm: 0.6, sfx: 0.8, muted: false, damageNumbers: true, quality: 'high' }); -const QUALITIES = ['high', 'medium', 'low']; - -/** - * Sanitize persisted settings. - * @param {any} raw - * @returns {{ bgm: number, sfx: number, muted: boolean, damageNumbers: boolean, quality: 'high'|'medium'|'low' }} - */ -export function sanitizeSettings(raw) { - const r = isObj(raw) ? raw : {}; - const vol = (v, d) => (Number.isFinite(v) ? clamp(Math.round(v * 100) / 100, 0, 1) : d); - return { - bgm: vol(r.bgm, DEFAULT_SETTINGS.bgm), - sfx: vol(r.sfx, DEFAULT_SETTINGS.sfx), - muted: typeof r.muted === 'boolean' ? r.muted : DEFAULT_SETTINGS.muted, - damageNumbers: typeof r.damageNumbers === 'boolean' ? r.damageNumbers : DEFAULT_SETTINGS.damageNumbers, - quality: QUALITIES.includes(r.quality) ? r.quality : DEFAULT_SETTINGS.quality, - }; -} - -// ---- result ----------------------------------------------------------------------------------------------------------- - -/** - * Normalise an m.result payload (free-form; see screens/result.js header for the expected shape). - * @param {any} res - * @param {any} pub last m.public (fallbacks) - */ -export function normalizeResult(res, pub) { - const r = isObj(res) ? res : {}; - const pubPlayers = new Map(sortedPlayers(pub).map((p) => [p.playerId, p])); - // From the 最终攻势 on, every survivor's LP is one merged pool (m.public teamLp) and leaks / overtime only drain - // that pool: a survivor's remaining LP is the pool, not its own LP from before the Final Assault. - const teamLp = Number.isFinite(r.teamLp) ? r.teamLp : Number.isFinite(pub?.teamLp) ? pub.teamLp : null; - const players = (Array.isArray(r.players) ? r.players : []).filter(isObj).map((p) => { - const pp = pubPlayers.get(p.playerId) || {}; - const title = typeof p.title === 'string' ? { id: p.title } : isObj(p.title) ? p.title : null; - const alive = p.alive ?? pp.alive ?? true; - const ownLp = Number.isFinite(p.lp) ? p.lp : (Number.isFinite(pp.lp) ? pp.lp : null); - return { - playerId: p.playerId, - seat: int(p.seat, int(pp.seat, 0)), - name: p.name || pp.name || '博士', - isBot: !!(p.isBot ?? pp.isBot), - alive, - lp: teamLp != null ? (alive === false ? 0 : Math.max(0, teamLp)) : ownLp, - lpShared: teamLp != null, - bandId: p.bandId ?? pp.bandId ?? null, - roundsPassed: Number.isFinite(p.roundsPassed) ? p.roundsPassed : (Number.isFinite(r.roundsPassed) ? r.roundsPassed : 0), - title, - lineup: (Array.isArray(p.lineup) ? p.lineup : Array.isArray(p.board) ? p.board : []).filter(isObj).slice(0, 12), - bonds: (Array.isArray(p.bonds) ? p.bonds : Array.isArray(pp.bonds) ? pp.bonds : []).filter(isObj), - stats: isObj(p.stats) ? p.stats : {}, - trophies: Number.isFinite(p.trophies) ? p.trophies : 0, - reward: Number.isFinite(p.reward) ? p.reward : 0, - left: !!p.left, - }; - }).sort((a, b) => a.seat - b.seat); - return { - victory: !!r.victory, - roundsPassed: Number.isFinite(r.roundsPassed) ? r.roundsPassed : Math.max(0, ...players.map((p) => p.roundsPassed), 0), - lastRound: Number.isFinite(r.lastRound) ? r.lastRound : (Number.isFinite(pub?.lastRound) ? pub.lastRound : 14), - hiddenCleared: !!r.hiddenCleared, - // the Hidden Core (R15) was fought: only then does the result show its boss medal (m.result.hiddenReached; - // older payloads without it: a clear implies it was reached) - hiddenReached: typeof r.hiddenReached === 'boolean' ? (r.hiddenReached || !!r.hiddenCleared) : !!r.hiddenCleared, - difficulty: r.difficulty || pub?.difficulty || null, - modeId: r.modeId || pub?.modeId || null, - bossId: r.bossId ?? pub?.bossId ?? null, - hiddenBossId: r.hiddenBossId ?? pub?.hiddenBossId ?? null, - durationMs: Number.isFinite(r.durationMs) ? r.durationMs : null, - players, - }; -} - -// ---- operator loadout (DESIGN §16) ---------------------------------------------------------------------------------- - -/** - * The skill / module a chess fights with under the player's loadout (m.private.loadout, DESIGN §16) — what the shop - * card and the detail panel show. Works with data that already lists every selectable skill (`skills[]`, elite - * `modules[]`) and with older data (only the default `skill` / `module`). - * @param {any} chess the chess record (normal or elite) - * @param {any} loadout m.private.loadout `{ [baseChessId]: { skill, module } }` (entries equal to the defaults may be - * missing); null → defaults - * @param {(id: string) => any} [getChess] - * @returns {{ skill: any, skillIndex: number|null, defaultSkill: boolean, module: { id: string, name: string, typeName: string, none: boolean }|null, - * defaultModule: boolean, changed: boolean, choices: number } | null} - */ -export function chessLoadout(chess, loadout, getChess = () => null) { - if (!isObj(chess)) return null; - const lo = isObj(loadout) && !Array.isArray(loadout) ? loadout : null; - let r = { skillIndex: null, moduleId: null }; - try { r = resolveLoadout(lo, chess, getChess); } catch { /* defaults */ } - const skills = Array.isArray(chess.skills) && chess.skills.length ? chess.skills.filter(isObj) : (isObj(chess.skill) ? [chess.skill] : []); - const skill = skills.find((s) => s.index === r.skillIndex) || (isObj(chess.skill) ? chess.skill : null) || skills[0] || null; - const base = chess.isGolden ? (getChess(chess.baseId) || chess) : chess; - let opt = { defaultSkill: null, defaultModule: null, skills: [] }; - try { opt = loadoutOptions(base, chess.isGolden ? chess : null); } catch { /* defaults */ } - const defIdx = opt.defaultSkill ?? chess.skill?.index ?? null; - const defaultSkill = !skill || skill.index == null || defIdx == null || skill.index === defIdx; - let module = null; - let defaultModule = true; - if (chess.isGolden) { - const id = r.moduleId ?? (chess.module?.active ? chess.module.id : MODULE_NONE); - if (id === MODULE_NONE) module = { id: MODULE_NONE, name: '未装备模组', typeName: '', none: true }; - else { - const rec = (Array.isArray(chess.modules) ? chess.modules : []).find((m) => isObj(m) && m.uniEquipId === id) - || (isObj(chess.module) && chess.module.id === id ? { uniEquipId: id, name: chess.module.name, typeName: chess.module.type } : null); - module = { id, name: rec?.name || id, typeName: rec?.typeName || rec?.type || '', none: false }; - } - defaultModule = opt.defaultModule == null || id === opt.defaultModule; - } - // the record the unit fights with (stats / 特性 / talents of the chosen module or none — the battle's own composition, - // shared/loadoutRecord.js): the detail card must show what the sim runs - let record = chess; - try { record = loadoutRecord(chess, resolveRecordLoadout(chess, { skillIndex: r.skillIndex, moduleId: r.moduleId })) || chess; } catch { /* the record as is */ } - return { skill, skillIndex: skill?.index ?? null, defaultSkill, module, defaultModule, changed: !defaultSkill || !defaultModule, - choices: Math.max(skills.length, opt.skills?.length || 0), record }; -} - -/** - * The loadout (m.private.loadout shape) a battle / scouting unit fights with, from the unit itself — a teammate's - * operator shows ITS owner's skill / module (DESIGN §16), not the viewer's nor the defaults: UnitInfo `skillIndex` - * (sim units, prep scouting m.field units) and `moduleId` (elite only), keyed by the chess's base id for chessLoadout. - * Null when the unit carries neither (the defaults). - * @param {any} chess the unit's chess record (normal or elite) - * @param {any} unit UnitInfo - * @returns {Record | null} - */ -export function unitLoadout(chess, unit) { - if (!isObj(chess) || !isObj(unit)) return null; - const baseId = chess.baseId || chess.chessId; - if (typeof baseId !== 'string' || !baseId) return null; - const e = {}; - if (Number.isInteger(unit.skillIndex) && unit.skillIndex >= 0) e.skill = unit.skillIndex; - if (chess.isGolden && typeof unit.moduleId === 'string' && unit.moduleId) e.module = unit.moduleId; - return Object.keys(e).length ? { [baseId]: e } : null; -} - -// ---- detail panel placement (user playtest #2 item 8) --------------------------------------------------------------- - -/** - * Which side the detail panel goes to so it never covers the selected unit's underframe: the default 'left' panel - * unless the underframe (buttons included) overlaps it and the 'right' slot is clear of it (else 'left' — the - * underframe is drawn above the panel anyway, css z-index). - * @param {{ left: number, right: number, top: number, bottom: number }|null} uf underframe rect (client px) - * @param {{ left: {left:number,right:number,top:number,bottom:number}, right: {left:number,right:number,top:number,bottom:number} }} slots - * @returns {'left'|'right'} - */ -export function panelSide(uf, slots) { - if (!isObj(uf) || !isObj(slots)) return 'left'; - const hit = (a, b) => !!a && !!b && a.left < b.right && a.right > b.left && a.top < b.bottom && a.bottom > b.top; - if (!hit(uf, slots.left)) return 'left'; - return hit(uf, slots.right) ? 'left' : 'right'; -} - -/** - * Client-px rects of the two detail panel slots (css .dpanel / .dpanel--right in rem): left at 2.3rem, right against - * the right edge above the shop bar when it is open. - * @param {{ width: number, height: number, left?: number, top?: number }} vp the HUD layer's box (.gm__hud): its size, - * and its client offset — the HUD sits inside the safe-area insets (css/devices.css), so on a notched phone the - * panels start `left` / `top` px further in than the viewport edge (default 0: the whole viewport) - * @param {number} rem root font size (px) - * @param {{ shopOpen?: boolean }} [o] - */ -export function panelSlots(vp, rem, { shopOpen = false } = {}) { - const W = Number(vp?.width) || 1920; - const H = Number(vp?.height) || 1080; - const X = Number.isFinite(vp?.left) ? vp.left : 0; - const Y = Number.isFinite(vp?.top) ? vp.top : 0; - const R = rem > 0 ? rem : 100; - const top = R * 2.1; - const leftH = Math.min(R * 8.4, H - top - R * 1.02); - const rightBottom = H - R * (shopOpen ? PANEL_RIGHT_BOTTOM_SHOP : PANEL_RIGHT_BOTTOM); - return { - left: { left: X + R * 2.3, right: X + R * (2.3 + 4.9), top: Y + top, bottom: Y + top + leftH }, - right: { left: X + W - R * (PANEL_RIGHT_GAP + 4.9), right: X + W - R * PANEL_RIGHT_GAP, top: Y + top, bottom: Y + Math.max(top + R, rightBottom) }, - }; -} -/** Right detail panel slot insets (rem; keep in sync with css/screens/game-panels.css .dpanel--right). */ -export const PANEL_RIGHT_GAP = 0.9; -export const PANEL_RIGHT_BOTTOM = 1.02; -export const PANEL_RIGHT_BOTTOM_SHOP = 3.78; - -/** Bond popup geometry (rem; keep in sync with css/screens/game-panels.css .bpop / .bpop--*). */ -export const BPOP = Object.freeze({ width: 5.4, top: 2.1, bottomGap: 0.3, left: 2.3, beside: 7.32, besideR: 5.92, right: 0.9 }); - -/** - * Where the bond popup opens (css .bpop--) so it neither hides the open detail card nor covers the selected - * unit's underframe (user playtest #2 items 8 / 9): next to the card first ('beside' the left card, 'besideR' left of - * the right card), else the free side of the screen ('left' at 2.3rem / 'right' against the right edge), else over the - * card it was opened from; without a card the popup's usual place is 'left'. When every candidate covers the - * underframe the first one is kept (the underframe is drawn above the popup, css z-index). - * @param {{ left: number, right: number, top: number, bottom: number }|null} uf underframe rect (client px) or null - * @param {{ width: number, height: number, left?: number, top?: number }} vp the HUD layer's box (see panelSlots) - * @param {number} rem root font size (px) - * @param {'left'|'right'|null} card side of the open detail card (null: none) - * @returns {'left'|'beside'|'besideR'|'right'} - */ -export function bondPopupPlace(uf, vp, rem, card = null) { - const W = Number(vp?.width) || 1920; - const H = Number(vp?.height) || 1080; - const X = Number.isFinite(vp?.left) ? vp.left : 0; - const Y = Number.isFinite(vp?.top) ? vp.top : 0; - const R = rem > 0 ? rem : 100; - const top = Y + R * BPOP.top; - const bottom = Y + Math.max(R * BPOP.top + R, H - R * BPOP.bottomGap); - const rectOf = (p) => { - const left = X + (p === 'left' ? R * BPOP.left : p === 'beside' ? R * BPOP.beside - : p === 'besideR' ? W - R * (BPOP.besideR + BPOP.width) : W - R * (BPOP.right + BPOP.width)); - return { left, right: left + R * BPOP.width, top, bottom }; - }; - // last resort: over the card it was opened from (closing the popup shows the card again) - const order = card === 'left' ? ['beside', 'right', 'left'] : card === 'right' ? ['besideR', 'left', 'right'] : ['left', 'right']; - if (!isObj(uf)) return order[0]; - const hit = (a, b) => a.left < b.right && a.right > b.left && a.top < b.bottom && a.bottom > b.top; - return order.find((p) => !hit(uf, rectOf(p))) || order[0]; -} +// +// The bodies live in public/js/ui/gameLogic/*.js. Every name this file exported before the split is +// re-exported below, so existing imports keep working. + +export { clamp, tileKey } from './gameLogic/shared.js'; +export { phaseMode, isCombatPhase, showDeadPill, isBossPhase } from './gameLogic/phases.js'; +export { prepCamera, prepCameraFor, foldCamera, deployFieldOf, fieldTile, boardTileOf, ownerBandId } from './gameLogic/camera.js'; +export { phaseBanner, prepCapsuleLabel, countdownState, phaseTotalSeconds } from './gameLogic/phases.js'; +export { STATUS_META } from './gameLogic/format.js'; +export { sortedPlayers } from './gameLogic/shared.js'; +export { ownFieldId, homeFieldId, cycleField, watchTarget, switcherLabel, fieldLabel, activeBubbles } from './gameLogic/watch.js'; +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 { 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'; +export { shortcutFor, closesOnFieldPress, shortcutBlocked } from './gameLogic/shortcuts.js'; +export { DEFAULT_SETTINGS, sanitizeSettings } from './gameLogic/settings.js'; +export { normalizeResult } from './gameLogic/result.js'; +export { chessLoadout, unitLoadout } from './gameLogic/loadout.js'; +export { panelSide, panelSlots, PANEL_RIGHT_GAP, PANEL_RIGHT_BOTTOM, PANEL_RIGHT_BOTTOM_SHOP, BPOP, bondPopupPlace } from './gameLogic/panel.js'; diff --git a/public/js/ui/gameLogic/bonds.js b/public/js/ui/gameLogic/bonds.js new file mode 100644 index 00000000..c8e281fe --- /dev/null +++ b/public/js/ui/gameLogic/bonds.js @@ -0,0 +1,301 @@ +// ui/gameLogic/bonds.js — bond counts, members, harmony, 本局禁用. Re-exported from ../gameLogic.js. + +import { int, isObj } from './shared.js'; + + +// ---- bonds ------------------------------------------------------------------------------------------- + +/** + * Sort bonds for the strip: active first, then layers desc, count desc, tier desc, core first, id; the mode-off bonds + * (`off`: the server's entries for the bonds this mode never activates that the player has members of — + * server/match/bondsMeta.js offBondCounts) after all the others. + * @template {{bondId:string, active?:boolean, layers?:number, count?:number, tier?:number, off?:boolean}} B + * @param {B[]} bonds + * @param {(id:string)=>any} [getBond] + * @returns {B[]} + */ +export function sortBonds(bonds, getBond = () => null) { + const list = (Array.isArray(bonds) ? bonds : []).filter((b) => isObj(b) && typeof b.bondId === 'string'); + const n = (v) => (Number.isFinite(v) ? v : 0); + return [...list].sort((a, b) => ( + (a.off ? 1 : 0) - (b.off ? 1 : 0) + || (b.active ? 1 : 0) - (a.active ? 1 : 0) + || n(b.layers) - n(a.layers) + || n(b.count) - n(a.count) + || n(b.tier) - n(a.tier) + || (getBond(b.bondId)?.isCore ? 1 : 0) - (getBond(a.bondId)?.isCore ? 1 : 0) + || (a.bondId < b.bondId ? -1 : a.bondId > b.bondId ? 1 : 0) + )); +} + +/** + * Tier reached for a member count against ascending thresholds (downward bonds: active while count ≤ max). + * @param {number} count + * @param {number[]} thresholds + * @param {number|null} [maxCount] + */ +export function bondTier(count, thresholds, maxCount = null) { + const c = Number.isFinite(count) ? count : 0; + if (Number.isFinite(maxCount) && c > maxCount) return 0; + let t = 0; + for (const th of Array.isArray(thresholds) ? thresholds : []) if (c >= th) t += 1; + return t; +} + +/** Next threshold above the count, or null when maxed. */ +export function nextThreshold(count, thresholds) { + for (const th of Array.isArray(thresholds) ? thresholds : []) if (count < th) return th; + return null; +} + +/** + * Bonds granted by 变形同构体: an item with `canGiveBond` worn together with an item that has a `giveBondId` makes the + * wearer a member of that other item's bond (band 变形者集群 "与特定装备一同装备时装备者将视为特定盟约成员"; the item's + * talent, character_table trap_1073_acarm073, lists the 14 pairings). The same rule as the server's count + * (server/match/bondsMeta.js pieceBonds) and the battle's (server/sim/content/support unitBonds). + * @param {Array|null|undefined} items the carried items (ids or { id } pieces) + * @param {(id:string)=>any} [getItem] items.json lookup + * @returns {string[]} + */ +export function grantedBonds(items, getItem = () => null) { + const list = Array.isArray(items) ? items : []; + if (list.length < 2) return []; + const recs = list.map((it) => getItem(typeof it === 'string' ? it : it?.id)).filter(isObj); + if (!recs.some((r) => r.canGiveBond)) return []; + const out = []; + for (const r of recs) if (!r.canGiveBond && typeof r.giveBondId === 'string' && r.giveBondId && !out.includes(r.giveBondId)) out.push(r.giveBondId); + return out; +} + +/** + * A chess piece's bonds: its record's + those its 变形同构体 pairing grants (grantedBonds) — the detail card's chips. + * @param {any} chess chess.json record @param {Array|null|undefined} items @param {(id:string)=>any} [getItem] + * @returns {string[]} + */ +export function pieceBondIds(chess, items, getItem = () => null) { + const out = (Array.isArray(chess?.bonds) ? chess.bonds : []).filter((b) => typeof b === 'string'); + for (const b of grantedBonds(items, getItem)) if (!out.includes(b)) out.push(b); + return out; +} + +/** + * The 变形同构体 pairings — the list its 天赋栏 shows (the item text: "具体对应关系可在模拟中查看本装备天赋栏"; character_table + * trap_1073_acarm073 "“炎国短刀”→【炎】盟约 …", 14 bonds; GitHub issue #1: without it the item read as doing nothing). + * Built from the same `giveBondId` the server counts with (server/match/bondsMeta.js pieceBonds, grantedBonds here): every + * bond an item grants, in bonds.json order, with the items that grant it (one entry per item family — the normal and 进阶 + * copies share a name —, by tier, then data order). `off`: the mode never activates the bond (modeOffBonds: 本局禁用 — 标准 + * leaves 5 of the 14 off). `worn`: the wearer's items (`carried`, the card shows the 变形同构体 on an operator) pair it with + * one of the bond's items — the pairing in effect on that operator (that item `worn` too). + * @param {Iterable} itemRecs items.json records (data.list('items')) + * @param {Iterable} bondRecs bonds.json records in data order (data.list('bonds')) + * @param {{ off?: Set|null, carried?: Array|null }} [opts] + * @returns {Array<{ bondId: string, name: string, off: boolean, worn: boolean, items: Array<{ id: string, name: string, worn: boolean }> }>} + */ +export function morphPairings(itemRecs, bondRecs, { off = null, carried = null } = {}) { + const recs = [...(itemRecs || [])].filter(isObj); + const byId = new Map(recs.map((r) => [r.id, r])); + const getItem = (id) => byId.get(id) || null; + const worn = new Set(grantedBonds(carried, getItem)); + const carriedIds = new Set((Array.isArray(carried) ? carried : []).map((it) => (typeof it === 'string' ? it : it?.id)).filter((x) => typeof x === 'string')); + const famOf = (r) => (typeof r.baseId === 'string' && r.baseId ? r.baseId : r.id); + /** bondId → family id → { id, name, tier, order, ids } */ + const groups = new Map(); + recs.forEach((r, order) => { + if (r.canGiveBond || typeof r.giveBondId !== 'string' || !r.giveBondId) return; + let g = groups.get(r.giveBondId); + if (!g) groups.set(r.giveBondId, (g = new Map())); + const fam = famOf(r); + const e = g.get(fam); + if (e) { e.ids.push(r.id); return; } + g.set(fam, { id: fam, name: typeof r.name === 'string' && r.name ? r.name : fam, tier: int(r.tier, 99), order, ids: [r.id] }); + }); + const out = []; + for (const b of bondRecs || []) { + const g = isObj(b) ? groups.get(b.bondId) : null; + if (!g) continue; + const bondWorn = worn.has(b.bondId); + const items = [...g.values()].sort((x, y) => (x.tier - y.tier) || (x.order - y.order)) + .map((e) => ({ id: e.id, name: e.name, worn: bondWorn && e.ids.some((id) => carriedIds.has(id)) })); + out.push({ bondId: b.bondId, name: typeof b.name === 'string' && b.name ? b.name : b.bondId, off: !!(off && typeof off.has === 'function' && off.has(b.bondId)), worn: bondWorn, items }); + } + return out; +} + +/** + * Bond id of 调和 (bonds.json maniShip, "激活时使场上核心盟约的激活人数+1") — server/match/bondsMeta.js HARMONY_BOND; a test + * pins the two. Only to name the 调和 operators: whether a bond's count holds the +1 is the server's word (the view + * entry's `harmony`, DESIGN §21.26). + */ +export const HARMONY_BOND = 'maniShip'; + +/** + * The 调和 operators on a board, distinct (normal and elite copies of one operator once): who activates the +1 a bond + * entry's `harmony` reports (the bond popup's 调和 row names them — 缪尔赛思, the Pith strategy's 盟约·辅助干员). + * @param {any} priv m.private — or a teammate's field operators (ui/watchBonds.js ownerBoard) + * @param {(id:string)=>any} [getChess] + * @returns {Array<{ id: string, name: string }>} base chess ids + */ +export function harmonyMembers(priv, getChess = () => null) { + const out = []; + const seen = new Set(); + for (const p of Array.isArray(priv?.board) ? priv.board : []) { + if (p?.kind !== 'chess') continue; + const c = getChess(p.id); + if (!Array.isArray(c?.bonds) || !c.bonds.includes(HARMONY_BOND)) continue; + const base = c.baseId || (typeof p.id === 'string' ? p.id.replace(/_b$/, '_a') : p.id); + if (seen.has(base)) continue; + seen.add(base); + out.push({ id: base, name: getChess(base)?.name || c.name || base }); + } + return out; +} + +/** + * Member rows of a bond popup: every visible member with owned / on-board / in-hand / banned state, plus the player's + * operators that are members through 变形同构体 (grantedBonds; `granted: true` and `items`: the item ids of the copy that + * wears the pair — the card the row opens shows them; one row per operator — normal and elite copies are one member, + * like the count's distinct members), so "成员 x/y" agrees with the count the server reports (在场; memberHeadCount). + * `inHand`: in the 整备区 (hand), not the 5 temporary slots — what BOARD_AND_DECK bonds (投资人 远见 奇迹) count. + * @param {any} bond bonds.json record + * @param {any} priv m.private (hand/board/temp) — or a teammate's field operators (ui/watchBonds.js ownerBoard) + * @param {Set|string[]} [banned] banned base chess ids + * @param {(id:string)=>any} [getChess] + * @param {(id:string)=>any} [getItem] items.json lookup (the 变形同构体 pairings) + */ +export function bondMembers(bond, priv, banned = [], getChess = () => null, getItem = () => null) { + const bannedSet = banned instanceof Set ? banned : new Set(Array.isArray(banned) ? banned : []); + const members = Array.isArray(bond?.visibleMembers) && bond.visibleMembers.length ? bond.visibleMembers : (Array.isArray(bond?.members) ? bond.members : []); + const baseOf = (id) => getChess(id)?.baseId || (typeof id === 'string' ? id.replace(/_b$/, '_a') : id); + const onBoard = new Set(); + const owned = new Set(); + const inHand = new Set(); + const memberSet = new Set(members); + /** base id → { on: on the board?, hand: in the hand?, items: the wearer's item ids } — operators of the player that join this bond through 变形同构体 */ + const granted = new Map(); + const grants = (p) => typeof bond?.bondId === 'string' && grantedBonds(p.items, getItem).includes(bond.bondId); + const itemIds = (p) => p.items.map((it) => (typeof it === 'string' ? it : it?.id)).filter((x) => typeof x === 'string'); + for (const p of Array.isArray(priv?.board) ? priv.board : []) { + if (p?.kind !== 'chess') continue; + const base = baseOf(p.id); + onBoard.add(base); owned.add(base); + if (!memberSet.has(base) && !granted.has(base) && grants(p)) granted.set(base, { on: true, hand: false, items: itemIds(p) }); + } + for (const [list, hand] of [[priv?.hand, true], [priv?.temp, false]]) { + for (const p of Array.isArray(list) ? list : []) { + if (p?.kind !== 'chess') continue; + const base = baseOf(p.id); + owned.add(base); + if (hand) inHand.add(base); + if (memberSet.has(base) || !grants(p)) continue; + const g = granted.get(base); + if (!g) granted.set(base, { on: false, hand, items: itemIds(p) }); + else if (hand) g.hand = true; + } + } + const rows = members.map((id) => { + const c = getChess(id); + return { id, tier: c?.tier ?? 0, name: c?.name ?? id, onBoard: onBoard.has(id), owned: owned.has(id), inHand: inHand.has(id), banned: bannedSet.has(id) }; + }); + for (const [id, g] of granted) { + const c = getChess(id); + rows.push({ id, tier: c?.tier ?? 0, name: c?.name ?? id, onBoard: g.on, owned: true, inHand: g.hand, banned: false, granted: true, items: g.items }); + } + return rows.sort((a, b) => (b.onBoard - a.onBoard) || (b.owned - a.owned) || (a.tier - b.tier) || (a.id < b.id ? -1 : 1)); +} + +/** + * The member count of a bond popup's 成员 header: the rows on the board — and, for a bond that counts the hand + * (`countsHand`: BOARD_AND_DECK — 投资人 远见 奇迹; the server's 在场 says (含整备区)), those in the hand too (the 5 + * temporary slots never count), so the header agrees with 在场 (community report 「投资人等在休整区就能生效的盟约不生效」: + * the header said 0/n with the members in the hand while 在场 said 3/3). + * @param {Array<{ onBoard?: boolean, inHand?: boolean }>} rows bondMembers rows @param {boolean} [countsHand] + * @returns {number} + */ +export function memberHeadCount(rows, countsHand = false) { + let n = 0; + for (const r of Array.isArray(rows) ? rows : []) if (r && (r.onBoard || (countsHand && r.inHand))) n++; + return n; +} + +/** + * Banned-member count per bond (briefing / info drawer red badge). + * @param {any[]} bonds bonds.json records + * @param {string[]} bannedChess + * @returns {Map} + */ +export function bannedPerBond(bonds, bannedChess) { + const banned = new Set(Array.isArray(bannedChess) ? bannedChess : []); + const out = new Map(); + for (const b of Array.isArray(bonds) ? bonds : []) { + if (!isObj(b)) continue; + const members = Array.isArray(b.visibleMembers) ? b.visibleMembers : []; + out.set(b.bondId, members.filter((id) => banned.has(id)).length); + } + return out; +} + +/** + * The two kinds of greyed bonds of a match (research 01 A2, 06 addendum D): + * drawn — the per-match drawn set D (m.public drawnDisabledBonds): only operators whose EVERY bond is in D leave + * the pool, so these bonds stay activatable (multi-bond members, 变形同构体, 调和) — "部分盟约所含干员阵容不完整"; + * off — the mode's static inactive list (FUNNY): never activates this match — "本局禁用". + * Older payloads without drawnDisabledBonds: disabledBonds minus the static list. + * @param {any} pub m.public + * @param {string[]} [staticInactive] config.modes[modeId].inactiveBondIds + * @returns {{ drawn: Set, off: Set }} + */ +export function disabledBondSets(pub, staticInactive = []) { + const off = new Set(Array.isArray(staticInactive) ? staticInactive : []); + const src = Array.isArray(pub?.drawnDisabledBonds) ? pub.drawnDisabledBonds : Array.isArray(pub?.disabledBonds) ? pub.disabledBonds : []; + const drawn = new Set(src.filter((b) => typeof b === 'string' && !off.has(b))); + return { drawn, off }; +} + +/** + * Briefing tooltip of a bond disc. + * @param {string} name + * @param {'off'|'drawn'|null} state from disabledBondSets + * @param {number} bannedN banned operators of the bond + */ +export function briefingBondTip(name, state, bannedN = 0) { + if (state === 'off') return `${name}:本局禁用(该盟约不会激活)`; + if (state === 'drawn' || bannedN > 0) return `${name}:部分盟约所含干员阵容不完整${bannedN ? `(${bannedN} 名干员无法出现)` : ''}`; + return name; +} + +/** + * The bonds a mode never activates (config.json modes[modeId].inactiveBondIds = the official modeDataDict + * inactiveBondIdList: 标准模拟 leaves 拉特兰 阿戈尔 卡西米尔 灵巧 奥术 奇迹 投资人 突袭 独行 绝技 off). Operators that also carry an + * enabled bond stay in the pool (server pool.js drawDisabledBonds) — 标准's 深靛 洛洛 阿罗玛 夕 圣聆初雪 still show 奥术 — and + * the server left such a bond out of m.private.bonds, so without a mark a card read "奥术 0/2 未激活" with three 奥术 + * operators deployed (player report after 0.1.0: "奥术盟约不生效"). The shop / reward cards, the detail card's bond chips + * and the bond popup mark these 本局禁用 (briefingBondTip 'off'); since 0.1.3 the server also lists such a bond the player + * has members of (`off: true`, server/match/bondsMeta.js offBondCounts) and the strip shows it as a grey 本局禁用 disc. + * @param {any} mode config.json modes[modeId] (data.js getMode), or null + * @returns {Set} + */ +export function modeOffBonds(mode) { + const list = isObj(mode) && Array.isArray(mode.inactiveBondIds) ? mode.inactiveBondIds : []; + return new Set(list.filter((b) => typeof b === 'string')); +} + +/** + * The bonds a strategy is built around that this mode switches off: bands.json `bondIds` (shared/bandBonds.js at build + * time — the field the server's bot reads too, GameData.bandBondIds) ∩ `off` (modeOffBonds). 标准: 潘格尼尼 → 拉特兰, + * 克莱门莎 → 阿戈尔, 玛恩纳 → 卡西米尔; the strategy draft marks such a band 本局禁用 (still selectable, DESIGN §21.26). + * @param {any} band bands.json record @param {Set|null} off + * @returns {string[]} + */ +export function bandOffBonds(band, off) { + if (!(off instanceof Set) || !off.size || !Array.isArray(band?.bondIds)) return []; + return band.bondIds.filter((b) => typeof b === 'string' && off.has(b)); +} + +/** + * The strategy draft's note for such a band: "本局禁用【拉特兰】盟约,此策略效果可能无法发挥". + * @param {string[]} names the switched-off bonds' names + */ +export function bandOffLine(names) { + const list = (Array.isArray(names) ? names : []).filter((n) => typeof n === 'string' && n); + return list.length ? `本局禁用${list.map((n) => `【${n}】`).join('')}盟约,此策略效果可能无法发挥` : ''; +} diff --git a/public/js/ui/gameLogic/camera.js b/public/js/ui/gameLogic/camera.js new file mode 100644 index 00000000..1d0d7f11 --- /dev/null +++ b/public/js/ui/gameLogic/camera.js @@ -0,0 +1,91 @@ +// ui/gameLogic/camera.js — prep camera, boss-field tile mapping, owner band. Re-exported from ../gameLogic.js. + +import { GEO } from '../../../../shared/constants.js'; +import { BOSS_ROW_SHIFT, MAX_COL } from '../../render/prepfield.js'; +import { int, isObj } from './shared.js'; + + +/** + * Camera of the own prep board (research 09 §1.2 "Final Assault, right side", DESIGN §15): in the prep of a boss round + * (最终攻势 / 隐秘核心: `pub.round` is `bossRound` or `hiddenRound`) the pieces stand on the player's half of the shared + * boss field — `{ kind: 'bossPrep', opts: { side } }`; the side follows the server's pairing (server/match/ + * finalAssault.js pairPlayers: the alive players in seat order, two by two; the second of a pair is on the mirrored + * right half 'R', a lone player on 'L'). Any other prep: the own board `{ kind: 'prep', opts: { rect, side: 'L' } }`. + * @param {any} pub m.public + * @param {string|null} myId + * @returns {{ kind: 'prep'|'bossPrep', opts: { rect?: { r0: number, r1: number, c0: number, c1: number }, side: 'L'|'R' } }} + */ +export function prepCamera(pub, myId) { + const normal = { kind: 'prep', opts: { rect: { ...GEO.NORMAL_RECT }, side: 'L' } }; + const r = pub?.round; + if (!Number.isInteger(r) || r <= 0 || !(r === pub.bossRound || r === pub.hiddenRound)) return normal; + const alive = (Array.isArray(pub.players) ? pub.players : []).filter((p) => isObj(p) && p.alive !== false) + .sort((a, b) => int(a.seat, 99) - int(b.seat, 99)); + const idx = alive.findIndex((p) => p.playerId === myId); + if (idx < 0) return normal; // eliminated / unknown: no board of its own on the boss field + return { kind: 'bossPrep', opts: { side: idx % 2 === 1 ? 'R' : 'L' } }; +} + +/** + * The own prep board's camera request with the shop bar's state (public issue #5 "准备阶段,收起商店界面时,界面并不会进行 + * 缩放"): `prepCamera` plus `shop: !folded` — a folded shop (收起) takes the official shop-collapsed camera + * (configBlackBoard left_prepare_camera_param / *_boss_prepare_*, render/projection.js presetCamera `shop: false`) and + * the folded shop's HUD band (ui/fieldHost.js hudBands), an open one the shop camera. `folded` = the shop bar is shown + * and folded (an eliminated player's board, which shows no bar, keeps the shop camera). + * @returns {{ kind: 'prep'|'bossPrep', opts: { rect?: object, side: 'L'|'R', shop: boolean } }} + */ +export function prepCameraFor(pub, myId, folded = false) { + const c = prepCamera(pub, myId); + return { kind: c.kind, opts: { ...c.opts, shop: !folded } }; +} + +/** + * The camera request a fold / unfold of the shop bar needs right now (public issue #5), or null: only while the own prep + * board is on screen (`ownPrep`: prep, not a teammate's scouted board, not a battle), never over the enemy pen (`pen`: it + * folds the shop itself and returns to the camera it left), deferred while a piece is dragged or its direction is chosen + * (`busy`: the drop target and the wheel sit on tiles of the camera in use — the caller asks again once that ends), and + * nothing when the current request (`current` { kind, opts }) already has that shop state. + * @param {{ pub: any, myId: string|null, ownPrep: boolean, folded: boolean, pen?: boolean, busy?: boolean, + * current?: { kind: string, opts?: { shop?: boolean } }|null }} s + * @returns {{ kind: 'prep'|'bossPrep', opts: object }|null} + */ +export function foldCamera(s) { + if (!s || !s.ownPrep || s.pen || s.busy) return null; + const want = prepCameraFor(s.pub, s.myId, !!s.folded); + const cur = s.current; + if (cur && cur.kind === want.kind && (cur.opts?.shop !== false) === want.opts.shop) return null; + return want; +} + +/** + * The field the own pieces are deployed on (server/match/Match.js deployFieldOf): 'bossL' / 'bossR' in the prep of a + * boss round (the same pairing as `prepCamera`), else 'normal'. Placement legality (`placementContext` → `deployMap` + * with `field`) reads that field's tiles. + * @returns {'normal'|'bossL'|'bossR'} + */ +export function deployFieldOf(pub, myId) { + const cam = prepCamera(pub, myId); + return cam.kind === 'bossPrep' ? (cam.opts.side === 'R' ? 'bossR' : 'bossL') : 'normal'; +} + +/** + * The stage tile [row, col] of board tile (r, c) on deploy field `field` (server/match/board.js fieldTile): the boss + * prep's display transform (render/prepfield.js bossPrepField: row − 7, the right half mirrored col c → 20 − c). + */ +export function fieldTile(field, r, c) { + if (field !== 'bossL' && field !== 'bossR') return [r, c]; + return [r + BOSS_ROW_SHIFT, field === 'bossR' ? MAX_COL - c : c]; +} + +/** The board tile of stage tile (r, c) on deploy field `field` (server/match/board.js boardTileOf). */ +export function boardTileOf(field, r, c) { + if (field !== 'bossL' && field !== 'bossR') return [r, c]; + return [r - BOSS_ROW_SHIFT, field === 'bossR' ? MAX_COL - c : c]; +} + +/** The band (策略) a player picked, from m.public.players[].bandId (Match.js marksPublic) — the detail card shows it + * on a teammate's unit (user playtest #2 item 2: watching a teammate revealed nothing about their 策略). */ +export function ownerBandId(pub, ownerId) { + const p = Array.isArray(pub?.players) ? pub.players.find((x) => x && x.playerId === ownerId) : null; + return typeof p?.bandId === 'string' && p.bandId ? p.bandId : null; +} diff --git a/public/js/ui/gameLogic/draft.js b/public/js/ui/gameLogic/draft.js new file mode 100644 index 00000000..eefadd04 --- /dev/null +++ b/public/js/ui/gameLogic/draft.js @@ -0,0 +1,75 @@ +// ui/gameLogic/draft.js — 机变 and band-draft normalisation. Re-exported from ../gameLogic.js. + +import { isObj } from './shared.js'; + + +// ---- 机变 / band draft normalisation ---------------------------------------------------------------------- + +/** + * Normalise m.public.draft ({order, turn, picks, skips?}) — tolerant of index/playerId turns and + * object/array picks. + * @param {any} draft + * @param {any[]} [players] + * @returns {{ order: string[], turnPid: string|null, picks: Map, skipsLeft: Map, done: boolean }} + */ +export function normalizeDraft(draft, players = []) { + const ids = (Array.isArray(players) ? players : []).filter(isObj).map((p) => p.playerId); + const order = Array.isArray(draft?.order) && draft.order.length ? draft.order.filter((x) => typeof x === 'string') : ids; + let turnPid = null; + if (typeof draft?.turn === 'string') turnPid = draft.turn; + else if (Number.isInteger(draft?.turn) && draft.turn >= 0 && draft.turn < order.length) turnPid = order[draft.turn]; + const picks = new Map(); + const src = draft?.picks; + if (Array.isArray(src)) { + src.forEach((v, i) => { + if (typeof v === 'string' && order[i]) picks.set(order[i], v); + else if (isObj(v) && typeof v.playerId === 'string' && typeof v.bandId === 'string') picks.set(v.playerId, v.bandId); + }); + } else if (isObj(src)) { + for (const [k, v] of Object.entries(src)) if (typeof v === 'string' && v) picks.set(k, v); + } + const skipsLeft = new Map(); + const sk = draft?.skipsLeft ?? draft?.skips; + if (isObj(sk)) for (const [k, v] of Object.entries(sk)) if (Number.isFinite(v)) skipsLeft.set(k, v); + const done = order.length > 0 && order.every((pid) => picks.has(pid)); + return { order, turnPid, picks, skipsLeft, done }; +} + +/** + * Normalise m.public.sp ({family, cards, turn, picks, order?}). + * Cards: string ids or objects; picks: {playerId: cardIdx} | [{playerId, idx}] | card.takenBy. + * @param {any} sp + * @param {any[]} [players] + */ +export function normalizeSp(sp, players = []) { + if (!isObj(sp)) return null; + const ids = (Array.isArray(players) ? players : []).filter(isObj).map((p) => p.playerId); + const order = Array.isArray(sp.order) && sp.order.length ? sp.order.filter((x) => typeof x === 'string') : ids; + const cards = (Array.isArray(sp.cards) ? sp.cards : []).slice(0, 6).map((c, idx) => { + const card = typeof c === 'string' ? { id: c } : isObj(c) ? { ...c } : {}; + return { ...card, idx, takenBy: typeof card.takenBy === 'string' ? card.takenBy : null }; + }); + const takenBy = new Map(); // idx → playerId + const pickOf = new Map(); // playerId → idx + const src = sp.picks; + const add = (pid, idx) => { + if (typeof pid !== 'string' || !Number.isInteger(idx) || idx < 0 || idx >= cards.length) return; + takenBy.set(idx, pid); + pickOf.set(pid, idx); + }; + if (Array.isArray(src)) src.forEach((v, i) => { if (isObj(v)) add(v.playerId, v.idx); else if (Number.isInteger(v) && order[i]) add(order[i], v); }); + else if (isObj(src)) for (const [k, v] of Object.entries(src)) add(k, v); + if (isObj(sp.taken)) for (const [k, v] of Object.entries(sp.taken)) add(v, Number(k)); + for (const c of cards) if (c.takenBy) add(c.takenBy, c.idx); + for (const c of cards) c.takenBy = takenBy.get(c.idx) ?? null; + let turnPid = null; + if (typeof sp.turn === 'string') turnPid = sp.turn; + else if (Number.isInteger(sp.turn) && sp.turn >= 0 && sp.turn < order.length) turnPid = order[sp.turn]; + return { + family: typeof sp.family === 'string' ? sp.family : null, + name: typeof sp.name === 'string' && sp.name ? sp.name : null, + desc: typeof sp.desc === 'string' && sp.desc ? sp.desc : null, + untimed: !!sp.untimed, + cards, order, turnPid, pickOf, takenBy, pickedCount: pickOf.size, + }; +} diff --git a/public/js/ui/gameLogic/enemies.js b/public/js/ui/gameLogic/enemies.js new file mode 100644 index 00000000..69ab72e4 --- /dev/null +++ b/public/js/ui/gameLogic/enemies.js @@ -0,0 +1,96 @@ +// ui/gameLogic/enemies.js — enemy groups, factions, the preview pen. Re-exported from ../gameLogic.js. + +import { layoutPen } from '../../render/pen.js'; +import { isObj } from './shared.js'; + + +// ---- enemies ------------------------------------------------------------------------------------------------ + +const TAG_ORDER = { boss: 0, bounty: 1, escort: 2 }; +const RANK_ORDER = { BOSS: 0, ELITE: 1, NORMAL: 2 }; + +/** + * Next-round enemy preview rows: merged by (enemyKey, tag), sorted boss → bounty → elite → normal → count. + * @param {Array<{enemyKey:string, count?:number, tag?:string}>} list + * @param {(key:string)=>any} [getEnemy] + */ +export function groupEnemies(list, getEnemy = () => null) { + const map = new Map(); + for (const e of Array.isArray(list) ? list : []) { + if (!isObj(e) || typeof e.enemyKey !== 'string') continue; + const tag = typeof e.tag === 'string' ? e.tag : null; + const k = `${e.enemyKey}|${tag}`; + const cur = map.get(k) || { enemyKey: e.enemyKey, tag, count: 0 }; + cur.count += Number.isFinite(e.count) && e.count > 0 ? e.count : 1; + map.set(k, cur); + } + return [...map.values()].map((row) => { + const en = getEnemy(row.enemyKey); + return { ...row, name: en?.name || row.enemyKey, rank: en?.rank || 'NORMAL', acTypes: Array.isArray(en?.acTypes) ? en.acTypes : (en?.acType ? [en.acType] : []), fly: en?.stats?.motion === 'FLY' || !!en?.isFlyEnemy }; + }).sort((a, b) => (TAG_ORDER[a.tag] ?? 9) - (TAG_ORDER[b.tag] ?? 9) + || (RANK_ORDER[a.rank] ?? 9) - (RANK_ORDER[b.rank] ?? 9) + || b.count - a.count || (a.enemyKey < b.enemyKey ? -1 : 1)); +} + +// ---- enemy preview pen (research 09 §2, research 08 §4.2) --------------------------------------------------- + +/** Pen rect and zones: rows 14–18 × cols 7–13, row 16 unused; lower-gate zone rows 14–15, upper-gate zone rows 17–18. */ +export const PEN = Object.freeze({ r0: 14, r1: 18, c0: 7, c1: 13, emptyRow: 16, cap: 50, anchors: Object.freeze({ lower: [15, 7], upper: [18, 7] }) }); + +/** Zone tiles in row-major order (low row first, col 7→13), without the zone's anchor (tile_start) — 13 per zone. */ +export function penZoneTiles(gate) { + const rows = gate === 'upper' ? [17, 18] : [14, 15]; + const [ar, ac] = PEN.anchors[gate === 'upper' ? 'upper' : 'lower']; + const out = []; + for (const r of rows) for (let c = PEN.c0; c <= PEN.c1; c++) if (!(r === ar && c === ac)) out.push([r, c]); + return out; +} + +/** + * Placement of the preview models in the pen for the DOM fallback view — the SAME layout as the render engine + * (render/pen.js layoutPen: research 08 §4.2, client AutoChessEnemyPreviewManager — spawn-time order; > 50 enemies + * thinned per entry to max(1, round(count·50/total)), elites / leaders always all of theirs; each gate's models in its + * zone, the k-th model of a zone at zoneTiles[round(k/totalShowCnt·len) − jitter]; ≤ 3 per tile; seeded, so a re-sent + * m.private never reshuffles it). Both views show one teammate's pen identically (review regression: the fallback used + * its own variant — global k, no per-tile cap — and placed the same enemies elsewhere). `slot` numbers the models + * sharing a tile. + * @param {Array<{ enemyKey: string, count?: number, gate?: string, t?: number, fly?: boolean, elite?: boolean, boss?: boolean }>} entries m.private.nextEnemies + * @param {{ cap?: number, stage?: any }} [opts] cap = MAX_PREVIEW_CNT (50); stage = the stage (its pen rect / tiles) + * @returns {Array<{ enemyKey: string, gate: 'upper'|'lower', row: number, col: number, slot: number, fly: boolean, elite: boolean, boss: boolean, k: number }>} + */ +export function penPlacement(entries, opts = {}) { + const max = Number.isInteger(opts.cap) && opts.cap > 0 ? opts.cap : PEN.cap; + const { figures } = layoutPen(Array.isArray(entries) ? entries : [], { stage: opts.stage || null, max }); + const perTile = new Map(); + return figures.map((f, k) => { + const key = `${f.row},${f.col}`; + const slot = perTile.get(key) || 0; + perTile.set(key, slot + 1); + return { enemyKey: f.enemyKey, gate: f.zone === 'upper' ? 'upper' : 'lower', row: f.row, col: f.col, slot, fly: !!f.fly, elite: !!f.elite, boss: !!f.boss, k }; + }); +} + +/** + * The enemy a view `pieceClick` points at in the preview pen, or null: `{ enemyKey }` or a `preview` enemy unit + * (`{ preview: true, unit: { side: 'enemy', defId } }`). Battle units (live fights) are not pen enemies. + * @param {any} e + */ +export function previewEnemyKey(e) { + if (!isObj(e)) return null; + if (typeof e.enemyKey === 'string' && e.enemyKey) return e.enemyKey; + const u = isObj(e.unit) ? e.unit : null; + if (!u || !(e.preview || u.preview)) return null; + if (u.side !== 'enemy' && u.kind !== 'enemy') return null; + const k = typeof u.enemyKey === 'string' && u.enemyKey ? u.enemyKey : u.defId; + return typeof k === 'string' && k ? k : null; +} + +/** Normalise m.public.factions (type strings or objects) to type ids. */ +export function factionTypes(factions) { + const out = []; + for (const f of Array.isArray(factions) ? factions : []) { + const t = typeof f === 'string' ? f : isObj(f) ? (f.type || f.id || f.key) : null; + if (typeof t === 'string' && t && !out.includes(t)) out.push(t); + } + return out; +} diff --git a/public/js/ui/gameLogic/format.js b/public/js/ui/gameLogic/format.js new file mode 100644 index 00000000..3cb778e4 --- /dev/null +++ b/public/js/ui/gameLogic/format.js @@ -0,0 +1,98 @@ +// ui/gameLogic/format.js — HUD numbers, status glyphs, range boxes, unit flags. Re-exported from ../gameLogic.js. + +import { UF } from '../../../../shared/constants.js'; +import { clamp, isObj, tileKey } from './shared.js'; + + +// ---- players, statuses, fields ------------------------------------------------------------------------ + +/** Status → glyph + text (research 06 §11.1). */ +export const STATUS_META = Object.freeze({ + acting: { glyph: 'dots', text: '行动中', tone: 'lo' }, + ready: { glyph: 'check', text: '已就绪', tone: 'mint' }, + deciding: { glyph: 'hourglass', text: '决策中', tone: 'gold' }, + combat: { glyph: 'sword', text: '作战中', tone: 'orange' }, + done: { glyph: 'check', text: '作战结束', tone: 'mint' }, + helping: { glyph: 'shield', text: '联防中', tone: 'orange' }, + left: { glyph: 'exit', text: '已离开', tone: 'red' }, + dead: { glyph: 'close', text: '已淘汰', tone: 'red' }, +}); + +// ---- snapshots / battle HUD --------------------------------------------------------------------------------- + +/** + * HUD numbers from a b.snap: { killed, total, dp, boss } (boss: { hp, max } when present). + * @param {any} snap + */ +export function snapHud(snap) { + if (!isObj(snap)) return null; + const n = (v) => (Number.isFinite(v) ? v : null); + let boss = null; + if (isObj(snap.boss) && Number.isFinite(snap.boss.hp)) boss = { hp: snap.boss.hp, max: n(snap.boss.max) ?? n(snap.boss.maxHp) }; + return { killed: n(snap.killed), total: n(snap.total), dp: n(snap.dp), boss }; +} + +/** Boss HP fraction 0..1 (null when unknown). */ +export function bossFrac(bossHp) { + if (!isObj(bossHp)) return null; + const hp = Number(bossHp.hp); + const max = Number(bossHp.max ?? bossHp.maxHp); + if (!Number.isFinite(hp) || !Number.isFinite(max) || max <= 0) return null; + return clamp(hp / max, 0, 1); +} + +/** + * The boss bar's percentage text for a fraction (bossFrac): whole percents from 10 %, one decimal below, and never + * "0.0%" while the leader still has HP — a sliver reads "<0.1%" (user playtest #6 item 5: a bar at 0.0 % with the + * leader still fighting read as a leader that could not die). null for an unknown fraction. + * @param {number|null} frac + */ +export function bossPctText(frac) { + if (frac == null || !Number.isFinite(frac)) return null; + const pct = clamp(frac, 0, 1) * 100; + if (pct >= 10) return `${pct.toFixed(0)}%`; + if (pct > 0 && pct < 0.05) return '<0.1%'; + return `${pct.toFixed(1)}%`; +} + +/** Whether a snapshot unit tuple has a flag. */ +export const hasFlag = (flags, bit) => (Number(flags) & bit) !== 0; +export { UF }; + +// ---- stats & range -------------------------------------------------------------------------------------------- + +/** Attack interval in seconds (bat × 100 / aspd). */ +export function attackInterval(bat, aspd = 100) { + const b = Number(bat); + const a = Number(aspd) > 0 ? Number(aspd) : 100; + if (!Number.isFinite(b) || b <= 0) return null; + return b * 100 / a; +} + +/** Compact number: 12345 → '12,345'; 1.5e6 → '150万'. */ +export function fmtNum(v) { + const n = Number(v); + if (!Number.isFinite(n)) return '—'; + if (Math.abs(n) >= 1e8) return `${(n / 1e8).toFixed(n >= 1e9 ? 0 : 1)}亿`; + if (Math.abs(n) >= 1e5) return `${(n / 1e4).toFixed(n >= 1e6 ? 0 : 1)}万`; + return Math.round(n).toLocaleString('en-US'); +} + +/** + * Bounding box + cell set of a range grid (always including the own tile [0,0]); `mirror` flips columns. + * @param {Array<[number, number]>} grid [[dRow, dCol], …] + * @param {boolean} [mirror] + * @returns {{ rows: number, cols: number, r0: number, c0: number, cells: Set, self: [number, number] }} + */ +export function rangeGridBox(grid, mirror = false) { + const cells = new Set(); + let minR = 0; let maxR = 0; let minC = 0; let maxC = 0; + for (const g of Array.isArray(grid) ? grid : []) { + if (!Array.isArray(g) || !Number.isFinite(g[0]) || !Number.isFinite(g[1])) continue; + const r = g[0]; + const c = mirror ? -g[1] : g[1]; + cells.add(tileKey(r, c)); + minR = Math.min(minR, r); maxR = Math.max(maxR, r); minC = Math.min(minC, c); maxC = Math.max(maxC, c); + } + return { rows: maxR - minR + 1, cols: maxC - minC + 1, r0: maxR, c0: minC, cells, self: [0, 0] }; +} diff --git a/public/js/ui/gameLogic/loadout.js b/public/js/ui/gameLogic/loadout.js new file mode 100644 index 00000000..a0c30f3a --- /dev/null +++ b/public/js/ui/gameLogic/loadout.js @@ -0,0 +1,70 @@ +// ui/gameLogic/loadout.js — skill / module loadout shown on cards. Re-exported from ../gameLogic.js. + +import { MODULE_NONE, loadoutOptions, resolveLoadout } from '../../../../shared/protocol.js'; +import { loadoutRecord, resolveRecordLoadout } from '../../../../shared/loadoutRecord.js'; +import { isObj } from './shared.js'; + + +// ---- operator loadout (DESIGN §16) ---------------------------------------------------------------------------------- + +/** + * The skill / module a chess fights with under the player's loadout (m.private.loadout, DESIGN §16) — what the shop + * card and the detail panel show. Works with data that already lists every selectable skill (`skills[]`, elite + * `modules[]`) and with older data (only the default `skill` / `module`). + * @param {any} chess the chess record (normal or elite) + * @param {any} loadout m.private.loadout `{ [baseChessId]: { skill, module } }` (entries equal to the defaults may be + * missing); null → defaults + * @param {(id: string) => any} [getChess] + * @returns {{ skill: any, skillIndex: number|null, defaultSkill: boolean, module: { id: string, name: string, typeName: string, none: boolean }|null, + * defaultModule: boolean, changed: boolean, choices: number } | null} + */ +export function chessLoadout(chess, loadout, getChess = () => null) { + if (!isObj(chess)) return null; + const lo = isObj(loadout) && !Array.isArray(loadout) ? loadout : null; + let r = { skillIndex: null, moduleId: null }; + try { r = resolveLoadout(lo, chess, getChess); } catch { /* defaults */ } + const skills = Array.isArray(chess.skills) && chess.skills.length ? chess.skills.filter(isObj) : (isObj(chess.skill) ? [chess.skill] : []); + const skill = skills.find((s) => s.index === r.skillIndex) || (isObj(chess.skill) ? chess.skill : null) || skills[0] || null; + const base = chess.isGolden ? (getChess(chess.baseId) || chess) : chess; + let opt = { defaultSkill: null, defaultModule: null, skills: [] }; + try { opt = loadoutOptions(base, chess.isGolden ? chess : null); } catch { /* defaults */ } + const defIdx = opt.defaultSkill ?? chess.skill?.index ?? null; + const defaultSkill = !skill || skill.index == null || defIdx == null || skill.index === defIdx; + let module = null; + let defaultModule = true; + if (chess.isGolden) { + const id = r.moduleId ?? (chess.module?.active ? chess.module.id : MODULE_NONE); + if (id === MODULE_NONE) module = { id: MODULE_NONE, name: '未装备模组', typeName: '', none: true }; + else { + const rec = (Array.isArray(chess.modules) ? chess.modules : []).find((m) => isObj(m) && m.uniEquipId === id) + || (isObj(chess.module) && chess.module.id === id ? { uniEquipId: id, name: chess.module.name, typeName: chess.module.type } : null); + module = { id, name: rec?.name || id, typeName: rec?.typeName || rec?.type || '', none: false }; + } + defaultModule = opt.defaultModule == null || id === opt.defaultModule; + } + // the record the unit fights with (stats / 特性 / talents of the chosen module or none — the battle's own composition, + // shared/loadoutRecord.js): the detail card must show what the sim runs + let record = chess; + try { record = loadoutRecord(chess, resolveRecordLoadout(chess, { skillIndex: r.skillIndex, moduleId: r.moduleId })) || chess; } catch { /* the record as is */ } + return { skill, skillIndex: skill?.index ?? null, defaultSkill, module, defaultModule, changed: !defaultSkill || !defaultModule, + choices: Math.max(skills.length, opt.skills?.length || 0), record }; +} + +/** + * The loadout (m.private.loadout shape) a battle / scouting unit fights with, from the unit itself — a teammate's + * operator shows ITS owner's skill / module (DESIGN §16), not the viewer's nor the defaults: UnitInfo `skillIndex` + * (sim units, prep scouting m.field units) and `moduleId` (elite only), keyed by the chess's base id for chessLoadout. + * Null when the unit carries neither (the defaults). + * @param {any} chess the unit's chess record (normal or elite) + * @param {any} unit UnitInfo + * @returns {Record | null} + */ +export function unitLoadout(chess, unit) { + if (!isObj(chess) || !isObj(unit)) return null; + const baseId = chess.baseId || chess.chessId; + if (typeof baseId !== 'string' || !baseId) return null; + const e = {}; + if (Number.isInteger(unit.skillIndex) && unit.skillIndex >= 0) e.skill = unit.skillIndex; + if (chess.isGolden && typeof unit.moduleId === 'string' && unit.moduleId) e.module = unit.moduleId; + return Object.keys(e).length ? { [baseId]: e } : null; +} diff --git a/public/js/ui/gameLogic/panel.js b/public/js/ui/gameLogic/panel.js new file mode 100644 index 00000000..de71f305 --- /dev/null +++ b/public/js/ui/gameLogic/panel.js @@ -0,0 +1,84 @@ +// ui/gameLogic/panel.js — detail card and bond popup placement. Re-exported from ../gameLogic.js. + +import { isObj } from './shared.js'; + + +// ---- detail panel placement (user playtest #2 item 8) --------------------------------------------------------------- + +/** + * Which side the detail panel goes to so it never covers the selected unit's underframe: the default 'left' panel + * unless the underframe (buttons included) overlaps it and the 'right' slot is clear of it (else 'left' — the + * underframe is drawn above the panel anyway, css z-index). + * @param {{ left: number, right: number, top: number, bottom: number }|null} uf underframe rect (client px) + * @param {{ left: {left:number,right:number,top:number,bottom:number}, right: {left:number,right:number,top:number,bottom:number} }} slots + * @returns {'left'|'right'} + */ +export function panelSide(uf, slots) { + if (!isObj(uf) || !isObj(slots)) return 'left'; + const hit = (a, b) => !!a && !!b && a.left < b.right && a.right > b.left && a.top < b.bottom && a.bottom > b.top; + if (!hit(uf, slots.left)) return 'left'; + return hit(uf, slots.right) ? 'left' : 'right'; +} + +/** + * Client-px rects of the two detail panel slots (css .dpanel / .dpanel--right in rem): left at 2.3rem, right against + * the right edge above the shop bar when it is open. + * @param {{ width: number, height: number, left?: number, top?: number }} vp the HUD layer's box (.gm__hud): its size, + * and its client offset — the HUD sits inside the safe-area insets (css/devices.css), so on a notched phone the + * panels start `left` / `top` px further in than the viewport edge (default 0: the whole viewport) + * @param {number} rem root font size (px) + * @param {{ shopOpen?: boolean }} [o] + */ +export function panelSlots(vp, rem, { shopOpen = false } = {}) { + const W = Number(vp?.width) || 1920; + const H = Number(vp?.height) || 1080; + const X = Number.isFinite(vp?.left) ? vp.left : 0; + const Y = Number.isFinite(vp?.top) ? vp.top : 0; + const R = rem > 0 ? rem : 100; + const top = R * 2.1; + const leftH = Math.min(R * 8.4, H - top - R * 1.02); + const rightBottom = H - R * (shopOpen ? PANEL_RIGHT_BOTTOM_SHOP : PANEL_RIGHT_BOTTOM); + return { + left: { left: X + R * 2.3, right: X + R * (2.3 + 4.9), top: Y + top, bottom: Y + top + leftH }, + right: { left: X + W - R * (PANEL_RIGHT_GAP + 4.9), right: X + W - R * PANEL_RIGHT_GAP, top: Y + top, bottom: Y + Math.max(top + R, rightBottom) }, + }; +} +/** Right detail panel slot insets (rem; keep in sync with css/screens/game-panels.css .dpanel--right). */ +export const PANEL_RIGHT_GAP = 0.9; +export const PANEL_RIGHT_BOTTOM = 1.02; +export const PANEL_RIGHT_BOTTOM_SHOP = 3.78; + +/** Bond popup geometry (rem; keep in sync with css/screens/game-panels.css .bpop / .bpop--*). */ +export const BPOP = Object.freeze({ width: 5.4, top: 2.1, bottomGap: 0.3, left: 2.3, beside: 7.32, besideR: 5.92, right: 0.9 }); + +/** + * Where the bond popup opens (css .bpop--) so it neither hides the open detail card nor covers the selected + * unit's underframe (user playtest #2 items 8 / 9): next to the card first ('beside' the left card, 'besideR' left of + * the right card), else the free side of the screen ('left' at 2.3rem / 'right' against the right edge), else over the + * card it was opened from; without a card the popup's usual place is 'left'. When every candidate covers the + * underframe the first one is kept (the underframe is drawn above the popup, css z-index). + * @param {{ left: number, right: number, top: number, bottom: number }|null} uf underframe rect (client px) or null + * @param {{ width: number, height: number, left?: number, top?: number }} vp the HUD layer's box (see panelSlots) + * @param {number} rem root font size (px) + * @param {'left'|'right'|null} card side of the open detail card (null: none) + * @returns {'left'|'beside'|'besideR'|'right'} + */ +export function bondPopupPlace(uf, vp, rem, card = null) { + const W = Number(vp?.width) || 1920; + const H = Number(vp?.height) || 1080; + const X = Number.isFinite(vp?.left) ? vp.left : 0; + const Y = Number.isFinite(vp?.top) ? vp.top : 0; + const R = rem > 0 ? rem : 100; + const top = Y + R * BPOP.top; + const bottom = Y + Math.max(R * BPOP.top + R, H - R * BPOP.bottomGap); + const rectOf = (p) => { + const left = X + (p === 'left' ? R * BPOP.left : p === 'beside' ? R * BPOP.beside + : p === 'besideR' ? W - R * (BPOP.besideR + BPOP.width) : W - R * (BPOP.right + BPOP.width)); + return { left, right: left + R * BPOP.width, top, bottom }; + }; + // last resort: over the card it was opened from (closing the popup shows the card again) + const order = card === 'left' ? ['beside', 'right', 'left'] : card === 'right' ? ['besideR', 'left', 'right'] : ['left', 'right']; + if (!isObj(uf)) return order[0]; + const hit = (a, b) => a.left < b.right && a.right > b.left && a.top < b.bottom && a.bottom > b.top; + return order.find((p) => !hit(uf, rectOf(p))) || order[0]; +} diff --git a/public/js/ui/gameLogic/phases.js b/public/js/ui/gameLogic/phases.js new file mode 100644 index 00000000..8a1bc02f --- /dev/null +++ b/public/js/ui/gameLogic/phases.js @@ -0,0 +1,122 @@ +// ui/gameLogic/phases.js — phase families, banners and countdowns. Re-exported from ../gameLogic.js. + +import { PHASE } from '../../../../shared/constants.js'; +import { bossLevelSeconds } from '../matchStatus.js'; +import { clamp, int, isObj, sortedPlayers } from './shared.js'; +import { normalizeSp } from './draft.js'; + + +// ---- phases ---------------------------------------------------------------------------------------- + +const COMBAT_PHASES = new Set([PHASE.COMBAT, PHASE.UNITE, PHASE.FINAL_ASSAULT, PHASE.HIDDEN_CORE]); +const PREP_PHASES = new Set([PHASE.PREP, PHASE.SP_DRAFT, PHASE.ROUND_START]); + +/** + * Which screen family a phase belongs to. + * @param {string} phase + * @returns {'loading'|'briefing'|'draft'|'boot'|'prep'|'combat'|'settle'|'result'} + */ +export function phaseMode(phase) { + if (!phase) return 'loading'; + if (phase === PHASE.INFO_CHECK) return 'briefing'; + if (phase === PHASE.BAND_DRAFT) return 'draft'; + if (phase === PHASE.BATTLE_CHECK) return 'boot'; + if (phase === PHASE.RESULT) return 'result'; + if (phase === PHASE.SETTLE) return 'settle'; + if (COMBAT_PHASES.has(phase)) return 'combat'; + if (PREP_PHASES.has(phase)) return 'prep'; + return 'prep'; +} + +export const isCombatPhase = (phase) => COMBAT_PHASES.has(phase); +/** + * The HUD's "你已被淘汰 · 可继续观战队友" pill of an eliminated player: outside combat only — in combat and in the SETTLE + * after it the combat HUD (screens/game.js CombatHud, rendered for mode 'settle' too) already says it, and two + * elimination banners never show at once. + * @param {boolean} alive + * @param {string|null|undefined} phase + */ +export const showDeadPill = (alive, phase) => !alive && !COMBAT_PHASES.has(phase) && phase !== PHASE.SETTLE; +export const isBossPhase = (phase) => phase === PHASE.FINAL_ASSAULT || phase === PHASE.HIDDEN_CORE; + +/** Banner shown when a phase starts: { title, sub?, tone } or null. */ +export function phaseBanner(phase, pub) { + const r = int(pub?.round, 0); + switch (phase) { + case PHASE.BATTLE_CHECK: return { title: '协议启动', micro: 'PROTOCOL START', tone: 'mint', sub: '模拟即将开始', duration: 2600 }; + case PHASE.ROUND_START: return { title: `第 ${r} 回合`, micro: `ROUND ${String(r).padStart(2, '0')}`, tone: 'mint', sub: '资金已到账' }; + case PHASE.SP_DRAFT: return { title: '机变阶段', micro: 'CONTINGENCY', tone: 'gold', sub: '依次选择机变' }; + case PHASE.PREP: return { title: '休整期', micro: `ROUND ${String(r).padStart(2, '0')} // REST`, tone: 'mint', sub: '部署干员,准备迎敌' }; + case PHASE.COMBAT: return { title: '作战开始', micro: 'COMBAT', tone: 'orange', sub: '各自行动阶段' }; + case PHASE.UNITE: { + const names = new Map(sortedPlayers(pub).map((p) => [p.playerId, p.name || '博士'])); + const helpers = Array.isArray(pub?.unite?.helpers) ? pub.unite.helpers.map((id) => names.get(id)).filter(Boolean) : []; + return { title: '联防阶段', micro: 'JOINT DEFENSE', tone: 'orange', sub: helpers.length ? `联防:${helpers.join('、')}` : '完美作战的博士迎战突破防线的敌人' }; + } + case PHASE.FINAL_ASSAULT: return { title: '最终攻势', micro: 'FINAL ASSAULT', tone: 'red', sub: '击败敌方领袖' }; + case PHASE.HIDDEN_CORE: return { title: '隐秘核心', micro: 'HIDDEN CORE', tone: 'red', sub: '被源石侵蚀的假想敌' }; + case PHASE.SETTLE: return null; + default: return null; + } +} + +/** Label of the prep capsule ("休息一下" in the original). */ +export function prepCapsuleLabel(phase) { + if (phase === PHASE.SP_DRAFT) return '机变阶段'; + if (phase === PHASE.ROUND_START) return '回合开始'; + if (phase === PHASE.BATTLE_CHECK) return '协议启动'; + if (phase === PHASE.SETTLE) return '回合结算'; + return '休息一下'; +} + +// ---- countdown ------------------------------------------------------------------------------------- + +/** + * Countdown display state. + * @param {number|null|undefined} deadline server epoch ms (0/null = untimed) + * @param {number} now server-corrected epoch ms + * @param {number} [total] total seconds of the phase (for the 5-bar gauge) + * @param {number} [warnAt] seconds at/below which the digits turn orange + * @returns {{ remain: number|null, warn: boolean, bars: number, text: string, frac: number }} + */ +export function countdownState(deadline, now, total, warnAt = 10) { + if (!(Number.isFinite(deadline) && deadline > 0) || !Number.isFinite(now)) { + return { remain: null, warn: false, bars: 0, text: '--', frac: 0 }; + } + const remain = Math.max(0, Math.ceil((deadline - now) / 1000)); + const t = Number.isFinite(total) && total > 0 ? total : null; + const frac = t ? clamp(remain / t, 0, 1) : 1; + const bars = remain === 0 ? 0 : t ? clamp(Math.ceil(frac * 5), 0, 5) : 5; + return { remain, warn: remain <= warnAt, bars, text: String(Math.min(999, remain)).padStart(2, '0'), frac }; +} + +/** + * Nominal length (s) of the current timed phase from data/config.json, or null when unknown/untimed. + * @param {any} pub m.public + * @param {any} config data/config.json + * @param {string|null} [myId] + */ +export function phaseTotalSeconds(pub, config, myId = null) { + if (!isObj(pub)) return null; + const timers = isObj(config?.timers) ? config.timers : {}; + const mode = isObj(config?.modes) ? config.modes[pub.modeId] : null; + const num = (v) => (Number.isFinite(v) && v > 0 ? v : null); + switch (pub.phase) { + case PHASE.INFO_CHECK: return num(timers.infoCheck) ?? 25; + // one countdown: the current turn's (m.public.draft.turnSeconds = Match.BAND_TURN_SECONDS; user playtest #4 item 4) + case PHASE.BAND_DRAFT: return num(pub.draft?.turnSeconds) ?? num(timers.bandTurn) ?? 30; + case PHASE.BATTLE_CHECK: return num(timers.battleCheck) ?? 3; + case PHASE.SP_DRAFT: { + const sp = normalizeSp(pub.sp, pub.players); + const first = !sp || sp.pickedCount === 0; + return first ? (num(timers.spFirst) ?? 30) : (num(timers.spTurn) ?? 16); + } + case PHASE.PREP: return num(mode?.rounds?.[String(pub.round)]?.prepTime); + case PHASE.COMBAT: + case PHASE.UNITE: return num(mode?.rounds?.[String(pub.round)]?.combatTimeLimit); + // 最终攻势 / 隐秘核心: m.public.deadline is the level's 120 s countdown (maxPlayTime; the battle goes on past it) + case PHASE.FINAL_ASSAULT: + case PHASE.HIDDEN_CORE: return bossLevelSeconds(pub, config); + default: return null; + } +} diff --git a/public/js/ui/gameLogic/placement.js b/public/js/ui/gameLogic/placement.js new file mode 100644 index 00000000..1329b409 --- /dev/null +++ b/public/js/ui/gameLogic/placement.js @@ -0,0 +1,457 @@ +// ui/gameLogic/placement.js — deploy maps and the canPlace mirror of the server rules. Re-exported from ../gameLogic.js. + +import { GEO } from '../../../../shared/constants.js'; +import { resolveLoadout } from '../../../../shared/protocol.js'; +import { attackRangeGrid } from '../../../../shared/loadoutRecord.js'; +import { meleeOnHighGround } from '../../../../shared/highGround.js'; +import { pieceDir, rangeTiles } from '../facing.js'; +import { isObj, tileKey } from './shared.js'; +import { boardTileOf, fieldTile } from './camera.js'; +import { chessLoadout } from './loadout.js'; + + +// ---- placement (canPlace mirror) ------------------------------------------------------------------------ + +/** + * Deployable tiles of the own board for a stage record, in board coordinates: the normal field (default), or — `field` + * 'bossL' / 'bossR' — the player's half of the boss field (always derived from the legend + active devices, like the + * server's deploy map on that field). + * @param {any} stage stages.json record + * @param {'normal'|'bossL'|'bossR'} [field] + * @param {{ deviceOverrides?: Record, tileOverrides?: Record }} [overrides] boss field + * only (the normal field takes them folded into the stage: `effectiveStage`) + * @returns {{ melee: Set, ranged: Set }} ranged = melee ∪ rangedOnly + */ +export function deploySets(stage, field = 'normal', overrides = {}) { + const melee = new Set(); + const ranged = new Set(); + if (field === 'bossL' || field === 'bossR') { + for (const [k, cls] of deployMap(stage, { ...(isObj(overrides) ? overrides : {}), field })) { ranged.add(k); if (cls === 'melee') melee.add(k); } + return { melee, ranged }; + } + const inField = (r, c) => r >= GEO.FIELD.r0 && r <= GEO.FIELD.r1 && c >= GEO.FIELD.c0 && c <= GEO.FIELD.c1; + const dt = stage?.deployTiles?.normal; + if (isObj(dt) && Array.isArray(dt.melee)) { + for (const t of dt.melee) if (Array.isArray(t) && inField(t[0], t[1])) { melee.add(tileKey(t[0], t[1])); ranged.add(tileKey(t[0], t[1])); } + for (const t of Array.isArray(dt.rangedOnly) ? dt.rangedOnly : []) if (Array.isArray(t) && inField(t[0], t[1])) ranged.add(tileKey(t[0], t[1])); + return { melee, ranged }; + } + // derive from the tile legend + const rows = Array.isArray(stage?.rows) ? stage.rows : []; + const tiles = isObj(stage?.tiles) ? stage.tiles : {}; + for (let r = GEO.FIELD.r0; r <= GEO.FIELD.r1; r++) { + const line = typeof rows[r] === 'string' ? rows[r] : ''; + for (let c = GEO.FIELD.c0; c <= GEO.FIELD.c1; c++) { + const t = tiles[line[c]]; + if (!isObj(t)) continue; + const k = tileKey(r, c); + if (t.height === 'LOW' && (t.buildable === 'ALL' || t.buildable === 'MELEE')) { melee.add(k); ranged.add(k); } + else if (t.buildable === 'ALL' || t.buildable === 'RANGED') ranged.add(k); + } + } + return { melee, ranged }; +} + +// ---- per-player stage overrides (terrain 机变 cards) ------------------------------------------------------ + +const OBSTACLE_ROLES = new Set(['crate', 'mound']); +const PLATFORM_ROLES = new Set(['platform']); +/** 特制水上平台 (act1 m05, weight 0 this season): its 深水区 tile takes any unit ("在水上建立可以部署任意单位的平台"). */ +const WATER_PLATFORM_ROLES = new Set(['waterPlatform']); +const hasKeys = (o) => isObj(o) && Object.keys(o).length > 0; + +/** + * The own board's device / tile overrides the server applies to placement (server/match/board.js buildDeployMap + * `deviceOverrides` / `tileOverrides`, set by 模拟战场演变 cards). m.private may carry them directly + * (`deviceOverrides` / `tileOverrides`); otherwise they are derived from the owned 机变 effect entries + * (`choice:#` / `#`) whose data/effects.json record has `auto_chess_change_map` + * buffs — the blackboard's `trap_…#nnn` aliases, applied in pick order like the server does. + * @param {any} priv m.private + * @param {(id:string)=>any} [getEffect] effects.json lookup + * @returns {{ deviceOverrides: Record, tileOverrides: Record }} + */ +export function stageOverrides(priv, getEffect = () => null) { + const deviceOverrides = {}; + const tileOverrides = {}; + if (isObj(priv?.deviceOverrides) || isObj(priv?.tileOverrides)) { + for (const [k, v] of Object.entries(isObj(priv.deviceOverrides) ? priv.deviceOverrides : {})) deviceOverrides[k] = !!v; + for (const [k, v] of Object.entries(isObj(priv.tileOverrides) ? priv.tileOverrides : {})) if (typeof v === 'string') tileOverrides[k] = v; + return { deviceOverrides, tileOverrides }; + } + for (const e of Array.isArray(priv?.effects) ? priv.effects : []) { + // picked 机变 cards: 'choice:#' (sim/content/choices.js refIdFor) or '#' (choices.js + // addRef); band / builtin entries (no '#', other prefixes) are not picks + const m = typeof e?.id === 'string' ? /^(?:choice:)?([^:#]+)#\d+$/.exec(e.id) : null; + if (!m) continue; + const eff = getEffect(m[1]); + for (const b of Array.isArray(eff?.buffs) ? eff.buffs : []) { + if (b?.key !== 'auto_chess_change_map' || !isObj(b.bb)) continue; + for (const [alias, v] of Object.entries(b.bb)) if (alias.includes('#')) deviceOverrides[alias] = Number(v) !== 0; + } + } + return { deviceOverrides, tileOverrides }; +} + +/** + * Deploy classes of the own board — mirror of server/match/board.js buildDeployMap (legend + active devices + + * overrides, on deploy field `field`): 'melee' (melee and ranged) or 'ranged' (ranged only) per board 'r,c'. + * @param {any} stage + * @param {{ deviceOverrides?: Record, tileOverrides?: Record, field?: 'normal'|'bossL'|'bossR' }} [o] + * @returns {Map} + */ +export function deployMap(stage, { deviceOverrides = {}, tileOverrides = {}, field = 'normal' } = {}) { + const F = GEO.FIELD; + const inField = (r, c) => Number.isInteger(r) && Number.isInteger(c) && r >= F.r0 && r <= F.r1 && c >= F.c0 && c <= F.c1; + const map = new Map(); + const rows = Array.isArray(stage?.rows) ? stage.rows : null; + const legend = isObj(stage?.tiles) ? stage.tiles : {}; + for (let r = F.r0; r <= F.r1; r++) { + for (let c = F.c0; c <= F.c1; c++) { + const [sr, sc] = fieldTile(field, r, c); + const line = rows && typeof rows[sr] === 'string' ? rows[sr] : null; + let cls = null; + if (line) { + const g = line[sc]; + const t = g != null && Object.hasOwn(legend, g) ? legend[g] : null; + if (t) { + const b = t.buildable; + if (t.height === 'LOW' && (b === 'ALL' || b === 'MELEE')) cls = 'melee'; + else if ((t.height === 'HIGH' && (b === 'ALL' || b === 'RANGED')) || (t.height === 'LOW' && b === 'RANGED')) cls = 'ranged'; + } + } else if (!stage) cls = c === 9 ? null : 'melee'; + if (cls) map.set(tileKey(r, c), cls); + } + } + for (const d of Array.isArray(stage?.devices) ? stage.devices : []) { + if (!isObj(d) || !Array.isArray(d.pos)) continue; + const [r, c] = boardTileOf(field, d.pos[0], d.pos[1]); + if (!inField(r, c)) continue; + let active; + if (d.alias != null && isObj(deviceOverrides) && Object.hasOwn(deviceOverrides, d.alias)) active = !!deviceOverrides[d.alias]; + else if (typeof d.active === 'boolean') active = d.active; + else active = !d.hidden; + if (!active) continue; + if (OBSTACLE_ROLES.has(d.role)) map.delete(tileKey(r, c)); + else if (PLATFORM_ROLES.has(d.role)) map.set(tileKey(r, c), 'ranged'); + else if (WATER_PLATFORM_ROLES.has(d.role)) map.set(tileKey(r, c), 'melee'); + } + for (const [k, v] of Object.entries(isObj(tileOverrides) ? tileOverrides : {})) { + const [r, c] = String(k).split(',').map(Number); + if (!inField(r, c)) continue; + if (v === 'melee' || v === 'ranged') map.set(tileKey(r, c), v); + else if (v === 'none') map.delete(tileKey(r, c)); + } + return map; +} + +/** + * The stage as this player's board looks with its overrides: devices switched by `deviceOverrides` carry the new + * `active` flag (the renderer draws / hides crates and platforms from it) and `deployTiles.normal` is rebuilt + * from the deploy map. Returns the stage itself when nothing is overridden. + * @param {any} stage stages.json record + * @param {{ deviceOverrides?: Record, tileOverrides?: Record }} [overrides] + */ +export function effectiveStage(stage, overrides = {}) { + const dev = isObj(overrides?.deviceOverrides) ? overrides.deviceOverrides : {}; + const tiles = isObj(overrides?.tileOverrides) ? overrides.tileOverrides : {}; + if (!isObj(stage) || (!hasKeys(dev) && !hasKeys(tiles))) return stage; + const devices = (Array.isArray(stage.devices) ? stage.devices : []).map((d) => ( + isObj(d) && d.alias != null && Object.hasOwn(dev, d.alias) ? { ...d, active: !!dev[d.alias] } : d)); + const map = deployMap(stage, { deviceOverrides: dev, tileOverrides: tiles }); + const melee = []; + const rangedOnly = []; + for (const [k, cls] of map) { + const [r, c] = k.split(',').map(Number); + (cls === 'melee' ? melee : rangedOnly).push([r, c]); + } + const deployTiles = { ...(isObj(stage.deployTiles) ? stage.deployTiles : {}), normal: { melee, rangedOnly } }; + return { ...stage, devices, deployTiles }; +} + +/** + * Index every own piece by uid. + * @param {any} priv m.private + * @returns {Map} + */ +export function indexPieces(priv) { + const map = new Map(); + (Array.isArray(priv?.board) ? priv.board : []).forEach((p) => { + if (isObj(p) && Number.isInteger(p.uid)) map.set(p.uid, { piece: p, area: 'board', row: p.row, col: p.col }); + }); + (Array.isArray(priv?.hand) ? priv.hand : []).forEach((p, idx) => { + if (isObj(p) && Number.isInteger(p.uid)) map.set(p.uid, { piece: p, area: 'hand', idx }); + }); + (Array.isArray(priv?.temp) ? priv.temp : []).forEach((p, idx) => { + if (isObj(p) && Number.isInteger(p.uid)) map.set(p.uid, { piece: p, area: 'temp', idx }); + }); + return map; +} + +/** + * Build the placement context for `canPlace`. The deploy tiles include the player's stage overrides (terrain 机变 + * cards, `stageOverrides`), like the server's per-player deploy map, on the field the pieces are deployed on + * (`field` = `deployFieldOf(pub, myId)`: the own board, or the player's half of the boss field in a boss round). + * @param {{ priv:any, stage:any, editable:boolean, field?:'normal'|'bossL'|'bossR', getChess?:(id:string)=>any, + * getToken?:(id:string)=>any, getItem?:(id:string)=>any, getEffect?:(id:string)=>any }} o + */ +export function placementContext({ priv, stage, editable, field = 'normal', getChess = () => null, getToken = () => null, getItem = () => null, getEffect = () => null }) { + const pieces = indexPieces(priv); + const boardAt = new Map(); + for (const e of pieces.values()) if (e.area === 'board') boardAt.set(tileKey(e.row, e.col), e); + const handAt = new Map(); + for (const e of pieces.values()) if (e.area === 'hand') handAt.set(e.idx, e); + const cap = Number.isInteger(priv?.deployCap) ? priv.deployCap : 8; + let deployed = 0; + for (const e of pieces.values()) if (e.area === 'board' && e.piece.kind === 'chess') deployed += 1; + const count = Number.isInteger(priv?.deployCount) ? priv.deployCount : deployed; + const ov = stageOverrides(priv, getEffect); + // the own normal board keeps the data's deploy tiles (with the terrain overrides folded in); the boss field of a + // boss round's prep is read from the legend + its devices under the same overrides (server deploy map, field) + const deploy = field === 'bossL' || field === 'bossR' + ? deploySets(stage, field, ov) + : deploySets(effectiveStage(stage, ov)); + return { priv, pieces, boardAt, handAt, deploy, cap, count, field, editable: !!editable, getChess, getToken, getItem }; +} + +/** + * Deploy position ('MELEE'|'RANGED'|'ALL') of a chess/token piece, or null for items. Elite 歌蕾蒂娅 carrying HOK-Y + * (the viewer's loadout, shared/highGround.js) is 'ALL': any deployable tile, the 高台 included + * (server/match/board.js placeClass; owner's decision 2026-10-04). Every other MELEE chess is ground-only. + */ +export function piecePosition(ctx, piece) { + if (!isObj(piece)) return null; + if (piece.kind === 'chess') { + const rec = ctx.getChess(piece.id); + let moduleId = null; + try { moduleId = resolveLoadout(ctx.priv?.loadout ?? null, rec, ctx.getChess)?.moduleId ?? null; } catch { moduleId = null; } + if (meleeOnHighGround(rec, moduleId)) return 'ALL'; + return rec?.position === 'MELEE' ? 'MELEE' : 'RANGED'; + } + // tokens: MELEE → ground only; RANGED / ALL → any deployable tile + if (piece.kind === 'token') return ctx.getToken(piece.id)?.position === 'MELEE' ? 'MELEE' : 'RANGED'; + return null; +} + +/** Whether a unit piece (chess/token) may stand on a board tile. */ +export function tileAllows(ctx, piece, row, col) { + const pos = piecePosition(ctx, piece); + if (!pos) return false; + const k = tileKey(row, col); + return pos === 'MELEE' ? ctx.deploy.melee.has(k) : ctx.deploy.ranged.has(k); +} + +/** + * Board tiles ('r,c') where a summon whose text reads "只能部署在召唤者攻击范围内" may stand (tokens.json `ownerRange`: + * 伺夜's 狼群, 缪尔赛思's 流形 — the tactical point; player report #9 after 0.1.0): its owner's attack range (the + * loadout's grid, shared/loadoutRecord.js attackRangeGrid, rotated by the owner's facing around its tile). Null when + * the piece is not range-bound or its owner is not on the board. `owner` ({ row, col, piece }) replaces the owner's + * current position (a summon swapped with its own owner). Mirror of server/match/PlayerState.js summonRange. + * @param {ReturnType} ctx + * @returns {Set | null} + */ +export function summonRange(ctx, piece, owner = null) { + if (!isObj(piece) || piece.kind !== 'token' || ctx?.getToken?.(piece.id)?.ownerRange !== true) return null; + let at = owner; + if (!at) for (const e of ctx.boardAt.values()) if (e.piece.uid === piece.ownerUid && e.piece.kind === 'chess') { at = e; break; } + const rec = at && ctx.getChess(at.piece.id); + if (!rec) return null; + let grid = null; + try { grid = attackRangeGrid(chessLoadout(rec, ctx.priv?.loadout ?? null, ctx.getChess)?.record || rec); } catch { /* the data grid */ } + return new Set(rangeTiles(grid || rec.rangeGrid, at.row, at.col, pieceDir(at.piece)).map(([r, c]) => tileKey(r, c))); +} + +/** tileAllows plus the owner-range rule of a range-bound summon (summonRange). */ +function unitAllowed(ctx, piece, row, col, owner = null) { + if (!tileAllows(ctx, piece, row, col)) return false; + const range = summonRange(ctx, piece, owner); + return !range || range.has(tileKey(row, col)); +} + +/** + * Placement legality of dropping piece `uid` on `target` (mirror of server/match/PlayerState.js move / equip / + * useArt and server/match/board.js canPlace): + * board ← chess: legal tile for its position; empty tile or a chess occupant (swap; from the board the occupant + * must be legal on the source tile); from hand/temp the deploy cap applies unless a chess occupant swaps out. + * board ← token: legal tile; from the hand the tile must be empty and its summoner deployed; board ↔ board swaps. + * hand ← chess from the board: empty slot (withdraw), chess occupant (swap, legal on the source tile), otherwise + * it goes to any free slot (HAND_FULL when none). hand ← token from the board: always (back onto its stack). + * hand ← hand/temp piece: move / swap. hand ← EQUIP item onto a chess: equip; onto a non-chess: swap. + * board ← EQUIP item: equip the chess on that tile. board ← MAGIC (Arts): used on that tile. + * @param {ReturnType} ctx + * @param {number} uid + * @param {{area:'board',row:number,col:number}|{area:'hand',idx:number}|{area:'outside'}} target + * @returns {{ ok: boolean, action?: 'move'|'swap'|'orient'|'equip'|'art', reason?: string, code?: string }} + */ +export function canPlace(ctx, uid, target) { + const no = (code, reason) => ({ ok: false, code, reason }); + if (!ctx || !ctx.editable) return no('WRONG_PHASE', '当前阶段无法进行该操作'); + const src = ctx.pieces.get(uid); + if (!src) return no('BAD_TARGET', '找不到该单位'); + if (!isObj(target)) return no('BAD_TILE', '无法部署在该位置'); + const piece = src.piece; + const isMagic = piece.kind === 'item' && ctx.getItem(piece.id)?.itemType === 'MAGIC'; + + if (target.area === 'hand') { + const idx = target.idx; + if (!Number.isInteger(idx) || idx < 0 || idx >= GEO.HAND_SIZE) return no('BAD_TILE', '无法放置在该位置'); + if (src.area === 'hand' && src.idx === idx) return no('ALREADY', '位置未变化'); + const occ = ctx.handAt.get(idx); + if (!occ) return { ok: true, action: 'move' }; + if (piece.kind === 'item') { + if (occ.piece.kind === 'chess') return isMagic ? no('BAD_TARGET', '该道具需要放置在战场上使用') : equipCheck(ctx, piece, occ.piece); + return { ok: true, action: 'swap' }; + } + if (src.area === 'board') { + if (piece.kind === 'token') return { ok: true, action: 'move' }; // back onto its stack + if (occ.piece.kind === 'chess') { + if (!tileAllows(ctx, occ.piece, src.row, src.col)) return no('BAD_TILE', '交换后的单位无法部署在原位置'); + return { ok: true, action: 'swap' }; + } + // the withdrawn operator goes to another free slot + const free = [...Array(GEO.HAND_SIZE).keys()].some((i) => !ctx.handAt.has(i)); + return free ? { ok: true, action: 'move' } : no('HAND_FULL', '整备区已满'); + } + return { ok: true, action: 'swap' }; + } + + if (target.area === 'board') { + const { row, col } = target; + if (!Number.isInteger(row) || !Number.isInteger(col)) return no('BAD_TILE', '无法部署在该位置'); + const inField = row >= GEO.FIELD.r0 && row <= GEO.FIELD.r1 && col >= GEO.FIELD.c0 && col <= GEO.FIELD.c1; + if (!inField) return no('BAD_TILE', '无法部署在该位置'); + const occ = ctx.boardAt.get(tileKey(row, col)); + if (piece.kind === 'item') { + if (isMagic) return { ok: true, action: 'art' }; + if (!occ) return no('BAD_TARGET', '请将装备拖拽至干员身上'); + return equipCheck(ctx, piece, occ.piece); + } + // its own tile: re-orient in place through the direction wheel (research 09 §1.2) + if (src.area === 'board' && src.row === row && src.col === col) return { ok: true, action: 'orient' }; + if (!tileAllows(ctx, piece, row, col)) { + const deployable = ctx.deploy.ranged.has(tileKey(row, col)); + return no('BAD_TILE', deployable && piecePosition(ctx, piece) === 'MELEE' ? '近战单位只能部署在地面' : '无法部署在该位置'); + } + // a range-bound summon (战术点): inside its owner's attack range — seen from the summon's old tile when it is + // dropped onto its own owner (the two swap) + const ownerSwap = src.area === 'board' && occ && occ.piece.uid === piece.ownerUid ? { row: src.row, col: src.col, piece: occ.piece } : null; + const range = summonRange(ctx, piece, ownerSwap); + if (range && !range.has(tileKey(row, col))) return no('BAD_TILE', '只能部署在召唤者攻击范围内'); + if (src.area === 'board') { + // board → board: move or swap (the occupant must be legal on the source tile — the mover's own summon excepted: + // a moved operator's summons go back to the hand anyway) + const ownSummon = occ && occ.piece.kind === 'token' && occ.piece.ownerUid === piece.uid; + if (occ && !ownSummon && !unitAllowed(ctx, occ.piece, src.row, src.col)) return no('BAD_TILE', '交换后的单位无法部署在原位置'); + return { ok: true, action: occ ? 'swap' : 'move' }; + } + if (piece.kind === 'token') { + if (occ) return no('BAD_TILE', '该位置已有单位'); + const ownerDeployed = [...ctx.boardAt.values()].some((e) => e.piece.uid === piece.ownerUid); + if (Number.isInteger(piece.ownerUid) && !ownerDeployed) return no('BAD_TARGET', '召唤者尚未部署'); + return { ok: true, action: 'move' }; + } + if ((!occ || occ.piece.kind !== 'chess') && ctx.count >= ctx.cap) return no('BOARD_FULL', '已达到部署上限'); + return { ok: true, action: occ ? 'swap' : 'move' }; + } + return no('BAD_TILE', '无法放置在该位置'); +} + +function equipCheck(ctx, itemPiece, targetPiece) { + const item = ctx.getItem(itemPiece.id); + if (item?.itemType === 'MAGIC') return { ok: false, code: 'BAD_TARGET', reason: '该道具需要放置在战场上使用' }; + if (!isObj(targetPiece) || targetPiece.kind !== 'chess') return { ok: false, code: 'BAD_TARGET', reason: '装备只能配发给干员' }; + return { ok: true, action: 'equip' }; +} + +/** Whether equipping `itemId` attaches it (true) or consumes it on equip (false: no slot is used / replaced). */ +export function itemAttaches(item) { + return !(typeof item?.kind === 'string' && item.kind.startsWith('consume_on_equip')); +} + +/** + * Whether equipping item `uid` completes an item merge (an identical normal copy is owned elsewhere — hand, temp or + * equipped): the server then merges the pair into the golden item instead of equipping (server PlayerState.equip → + * completesItemMerge), so a full carrier loses nothing and no replace dialog is needed. + * @param {ReturnType} ctx + * @param {number} uid + * @param {number} [itemMergeCount] default copies per merge (config) + */ +export function equipMerges(ctx, uid, itemMergeCount = 2) { + const piece = ctx?.pieces?.get(uid)?.piece; + if (!isObj(piece) || piece.kind !== 'item') return false; + const rec = ctx.getItem?.(piece.id); + if (!rec || rec.isGolden || rec.itemType !== 'EQUIP' || !rec.mergeable) return false; + const n = Number.isInteger(rec.upgradeNum) ? rec.upgradeNum : itemMergeCount; + if (!(n > 1 && n < 100) || !ctx.getItem(rec.upgradeChessId || rec.goldenId)) return false; + let have = 0; + for (const e of ctx.pieces.values()) { + const p = e.piece; + if (p.uid !== uid && p.kind === 'item' && p.id === piece.id) have += 1; + if (p.kind === 'chess') for (const it of Array.isArray(p.items) ? p.items : []) if (it?.id === piece.id && it.uid !== uid) have += 1; + } + return have + 1 >= n; +} + +/** + * All legal board tiles for dragging `uid` (for view.highlightTiles). + * @param {ReturnType} ctx + * @param {number} uid + * @returns {{ legal: Array<[number, number]>, illegal: Array<[number, number]> }} + */ +export function boardTargets(ctx, uid) { + const legal = []; + const illegal = []; + if (!ctx?.pieces?.get(uid)) return { legal, illegal }; + for (let r = GEO.FIELD.r0; r <= GEO.FIELD.r1; r++) { + for (let c = GEO.FIELD.c0; c <= GEO.FIELD.c1; c++) { + const k = tileKey(r, c); + if (!ctx.deploy.ranged.has(k) && !ctx.boardAt.has(k)) continue; // never-deployable scenery + (canPlace(ctx, uid, { area: 'board', row: r, col: c }).ok ? legal : illegal).push([r, c]); + } + } + return { legal, illegal }; +} + +/** + * The `g.*` intent for a drop, or null (no-op / illegal / UI-handled). + * @param {ReturnType} ctx + * @param {number} uid + * @param {any} target + * @returns {{ t: string, fields: object, confirmReplace?: boolean } | null} + */ +export function dropIntent(ctx, uid, target) { + const res = canPlace(ctx, uid, target); + if (!res.ok) return null; + if (res.action === 'art') return { t: 'g.art', fields: { itemUid: uid, row: target.row, col: target.col } }; + if (res.action === 'equip') { + const occ = target.area === 'hand' ? ctx.handAt.get(target.idx) : ctx.boardAt.get(tileKey(target.row, target.col)); + // both slots used: the replace dialog picks the equipped item to destroy (g.equip replaceUid) — unless the item is + // consumed on equip, or it completes an item merge (the server merges it instead of equipping: nothing replaced) + const full = Array.isArray(occ?.piece?.items) && occ.piece.items.length >= 2 + && itemAttaches(ctx.getItem(ctx.pieces.get(uid)?.piece?.id)) && !equipMerges(ctx, uid); + return { t: 'g.equip', fields: { itemUid: uid, targetUid: occ.piece.uid }, confirmReplace: full }; + } + const to = target.area === 'hand' ? { area: 'hand', idx: target.idx } : { area: 'board', row: target.row, col: target.col }; + return { t: 'g.move', fields: { uid, to } }; +} + +/** + * Why a drag released over the field was refused (null when there is nothing to say): the canPlace reason for a + * board tile / hand slot, a generic line for other tiles of the own board rows (lanes, blocked tiles) and the + * temporary bench. Releasing on the piece's own slot or far from the board says nothing. + * @param {ReturnType} ctx + * @param {number} uid + * @param {{row:number, col:number, area?:string|null, idx?:number}|null} tile last hovered tile (render tileHover) + * @returns {string|null} + */ +export function dropFailureReason(ctx, uid, tile) { + if (!ctx?.editable || !ctx.pieces?.get(uid) || !isObj(tile)) return null; + let target = null; + if (tile.area === 'board') target = { area: 'board', row: tile.row, col: tile.col }; + else if (tile.area === 'hand') target = { area: 'hand', idx: Number.isInteger(tile.idx) ? tile.idx : tile.col }; + else if (tile.area === 'temp') return ctx.pieces.get(uid).area === 'temp' ? null : '临时整备区无法放入单位'; + else if (Number.isInteger(tile.row) && tile.row >= GEO.FIELD.r0 && tile.row <= GEO.FIELD.r1) return '无法部署在该位置'; + else return null; + const res = canPlace(ctx, uid, target); + if (res.ok || res.code === 'ALREADY') return null; + return res.reason || '无法放置在该位置'; +} diff --git a/public/js/ui/gameLogic/result.js b/public/js/ui/gameLogic/result.js new file mode 100644 index 00000000..851bb0e2 --- /dev/null +++ b/public/js/ui/gameLogic/result.js @@ -0,0 +1,58 @@ +// ui/gameLogic/result.js — end-of-match result normalisation. Re-exported from ../gameLogic.js. + +import { int, isObj, sortedPlayers } from './shared.js'; + + +// ---- result ----------------------------------------------------------------------------------------------------------- + +/** + * Normalise an m.result payload (free-form; see screens/result.js header for the expected shape). + * @param {any} res + * @param {any} pub last m.public (fallbacks) + */ +export function normalizeResult(res, pub) { + const r = isObj(res) ? res : {}; + const pubPlayers = new Map(sortedPlayers(pub).map((p) => [p.playerId, p])); + // From the 最终攻势 on, every survivor's LP is one merged pool (m.public teamLp) and leaks / overtime only drain + // that pool: a survivor's remaining LP is the pool, not its own LP from before the Final Assault. + const teamLp = Number.isFinite(r.teamLp) ? r.teamLp : Number.isFinite(pub?.teamLp) ? pub.teamLp : null; + const players = (Array.isArray(r.players) ? r.players : []).filter(isObj).map((p) => { + const pp = pubPlayers.get(p.playerId) || {}; + const title = typeof p.title === 'string' ? { id: p.title } : isObj(p.title) ? p.title : null; + const alive = p.alive ?? pp.alive ?? true; + const ownLp = Number.isFinite(p.lp) ? p.lp : (Number.isFinite(pp.lp) ? pp.lp : null); + return { + playerId: p.playerId, + seat: int(p.seat, int(pp.seat, 0)), + name: p.name || pp.name || '博士', + isBot: !!(p.isBot ?? pp.isBot), + alive, + lp: teamLp != null ? (alive === false ? 0 : Math.max(0, teamLp)) : ownLp, + lpShared: teamLp != null, + bandId: p.bandId ?? pp.bandId ?? null, + roundsPassed: Number.isFinite(p.roundsPassed) ? p.roundsPassed : (Number.isFinite(r.roundsPassed) ? r.roundsPassed : 0), + title, + lineup: (Array.isArray(p.lineup) ? p.lineup : Array.isArray(p.board) ? p.board : []).filter(isObj).slice(0, 12), + bonds: (Array.isArray(p.bonds) ? p.bonds : Array.isArray(pp.bonds) ? pp.bonds : []).filter(isObj), + stats: isObj(p.stats) ? p.stats : {}, + trophies: Number.isFinite(p.trophies) ? p.trophies : 0, + reward: Number.isFinite(p.reward) ? p.reward : 0, + left: !!p.left, + }; + }).sort((a, b) => a.seat - b.seat); + return { + victory: !!r.victory, + roundsPassed: Number.isFinite(r.roundsPassed) ? r.roundsPassed : Math.max(0, ...players.map((p) => p.roundsPassed), 0), + lastRound: Number.isFinite(r.lastRound) ? r.lastRound : (Number.isFinite(pub?.lastRound) ? pub.lastRound : 14), + hiddenCleared: !!r.hiddenCleared, + // the Hidden Core (R15) was fought: only then does the result show its boss medal (m.result.hiddenReached; + // older payloads without it: a clear implies it was reached) + hiddenReached: typeof r.hiddenReached === 'boolean' ? (r.hiddenReached || !!r.hiddenCleared) : !!r.hiddenCleared, + difficulty: r.difficulty || pub?.difficulty || null, + modeId: r.modeId || pub?.modeId || null, + bossId: r.bossId ?? pub?.bossId ?? null, + hiddenBossId: r.hiddenBossId ?? pub?.hiddenBossId ?? null, + durationMs: Number.isFinite(r.durationMs) ? r.durationMs : null, + players, + }; +} diff --git a/public/js/ui/gameLogic/settings.js b/public/js/ui/gameLogic/settings.js new file mode 100644 index 00000000..cd101bf0 --- /dev/null +++ b/public/js/ui/gameLogic/settings.js @@ -0,0 +1,26 @@ +// ui/gameLogic/settings.js — settings defaults and sanitising. Re-exported from ../gameLogic.js. + +import { clamp, isObj } from './shared.js'; + + +// ---- settings ------------------------------------------------------------------------------------------------------ + +export const DEFAULT_SETTINGS = Object.freeze({ bgm: 0.6, sfx: 0.8, muted: false, damageNumbers: true, quality: 'high' }); +const QUALITIES = ['high', 'medium', 'low']; + +/** + * Sanitize persisted settings. + * @param {any} raw + * @returns {{ bgm: number, sfx: number, muted: boolean, damageNumbers: boolean, quality: 'high'|'medium'|'low' }} + */ +export function sanitizeSettings(raw) { + const r = isObj(raw) ? raw : {}; + const vol = (v, d) => (Number.isFinite(v) ? clamp(Math.round(v * 100) / 100, 0, 1) : d); + return { + bgm: vol(r.bgm, DEFAULT_SETTINGS.bgm), + sfx: vol(r.sfx, DEFAULT_SETTINGS.sfx), + muted: typeof r.muted === 'boolean' ? r.muted : DEFAULT_SETTINGS.muted, + damageNumbers: typeof r.damageNumbers === 'boolean' ? r.damageNumbers : DEFAULT_SETTINGS.damageNumbers, + quality: QUALITIES.includes(r.quality) ? r.quality : DEFAULT_SETTINGS.quality, + }; +} diff --git a/public/js/ui/gameLogic/shared.js b/public/js/ui/gameLogic/shared.js new file mode 100644 index 00000000..52b8e0d8 --- /dev/null +++ b/public/js/ui/gameLogic/shared.js @@ -0,0 +1,16 @@ +// ui/gameLogic/shared.js — small helpers and seat order. Public names are re-exported from ../gameLogic.js. + +// ---- small helpers ------------------------------------------------------------------------------- + +export const isObj = (v) => !!v && typeof v === 'object'; +export const int = (v, d = 0) => (Number.isInteger(v) ? v : d); +export const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v)); + +/** Stable key of a board tile. */ +export const tileKey = (row, col) => `${row},${col}`; + +/** Players sorted by seat (nulls dropped). */ +export function sortedPlayers(pub) { + const list = Array.isArray(pub?.players) ? pub.players.filter(isObj) : []; + return [...list].sort((a, b) => int(a.seat, 99) - int(b.seat, 99)); +} diff --git a/public/js/ui/gameLogic/shop.js b/public/js/ui/gameLogic/shop.js new file mode 100644 index 00000000..3bc96b33 --- /dev/null +++ b/public/js/ui/gameLogic/shop.js @@ -0,0 +1,181 @@ +// ui/gameLogic/shop.js — shop prices, merges, offers, the ready-funds prompt. Re-exported from ../gameLogic.js. + +import { GEO } from '../../../../shared/constants.js'; +import { isObj } from './shared.js'; + + +// ---- shop --------------------------------------------------------------------------------------------- + +/** + * Price tone of a shop slot: discounted (mint), marked up (red) or normal (gold). + * @param {{price?:number, basePrice?:number}|null} slot + * @returns {'discount'|'premium'|'gold'} + */ +export function priceTone(slot) { + if (!isObj(slot)) return 'gold'; + const p = Number(slot.price); + const b = Number(slot.basePrice); + if (Number.isFinite(p) && p === 0) return 'discount'; + if (!Number.isFinite(p) || !Number.isFinite(b)) return 'gold'; + if (p < b) return 'discount'; + if (p > b) return 'premium'; + return 'gold'; +} + +/** + * Owned copies of a base chess (normal pieces on board/hand/temp) — shop cards show merge progress. An elite card + * never merges (server PlayerState.completesChessMerge refuses isGolden), so it reports 0 copies: no pips, no 可晋升 + * tag and no mergeTarget tile. + * @param {any} priv + * @param {string} chessId + * @param {(id:string)=>any} [getChess] + * @returns {{ copies: number, need: number }} + */ +export function mergeProgress(priv, chessId, getChess = () => null) { + const c = getChess(chessId); + const base = c?.baseId || chessId; + const need = Number.isInteger(c?.upgradeNum) && c.upgradeNum > 0 ? c.upgradeNum : 3; + if (c?.isGolden) return { copies: 0, need }; + let copies = 0; + const pieces = [...(Array.isArray(priv?.board) ? priv.board : []), ...(Array.isArray(priv?.hand) ? priv.hand : []), ...(Array.isArray(priv?.temp) ? priv.temp : [])]; + for (const p of pieces) if (p?.kind === 'chess' && !p.golden && (p.id === base || getChess(p.id)?.baseId === base) && !String(p.id).endsWith('_b')) copies += 1; + return { copies, need }; +} + +/** + * Where the elite appears when gaining one more normal copy of `chessId` completes a merge now (PRTS 卫戍协议/帮助 + * "若消耗已部署至作战区的干员,则发送至作战区对应位置"; mirror of server board.js mergeTile / PlayerState._mergeChess): the + * board tile of the deployed copy that deploys first (col asc, then row desc: by column from the left, top to bottom — + * Battle.start) — `{ row, col, dir }` — or null (no + * merge — an elite card never merges, see mergeProgress — or no copy is deployed: the elite goes to the hand). The + * copies stand on legal tiles, and the elite is the same operator, so the tile needs no legality check here. + * @param {any} priv + * @param {string} chessId + * @param {(id:string)=>any} [getChess] + * @returns {{ row: number, col: number, dir: string } | null} + */ +export function mergeTarget(priv, chessId, getChess = () => null) { + const { copies, need } = mergeProgress(priv, chessId, getChess); + if (!(copies > 0 && copies + 1 >= need)) return null; + const c = getChess(chessId); + const base = c?.baseId || chessId; + const board = (Array.isArray(priv?.board) ? priv.board : []).filter((p) => p?.kind === 'chess' && !p.golden && Number.isInteger(p.row) && Number.isInteger(p.col) + && !String(p.id).endsWith('_b') && (p.id === base || getChess(p.id)?.baseId === base)); + if (!board.length) return null; + board.sort((a, b) => a.col - b.col || b.row - a.row); + return { row: board[0].row, col: board[0].col, dir: board[0].dir || 'RIGHT' }; +} + +/** Whether every hand slot is taken (the server refuses a purchase / reward that needs a slot: HAND_FULL). */ +export function handFull(priv) { + const hand = Array.isArray(priv?.hand) ? priv.hand : []; + return hand.length >= GEO.HAND_SIZE && hand.every((p) => p != null); +} + +/** + * Would gaining one more `slot` (shop / reward card: { kind: 'chess'|'item', id }) complete a merge right away, so it + * needs no hand slot? Mirror of server PlayerState completesChessMerge / completesItemMerge. + * @param {any} priv + * @param {{kind?:string, id:string}} slot + * @param {{ getChess?:(id:string)=>any, getItem?:(id:string)=>any, itemMergeCount?: number }} [o] + */ +export function completesMerge(priv, slot, { getChess = () => null, getItem = () => null, itemMergeCount = 2 } = {}) { + if (!isObj(slot) || typeof slot.id !== 'string') return false; + if (slot.kind === 'item') { + const rec = getItem(slot.id); + if (!rec || rec.isGolden || rec.itemType !== 'EQUIP' || !rec.mergeable) return false; + const n = Number.isInteger(rec.upgradeNum) ? rec.upgradeNum : itemMergeCount; + if (!(n > 1 && n < 100) || !getItem(rec.upgradeChessId || rec.goldenId)) return false; + let have = 0; + const holders = [...(Array.isArray(priv?.board) ? priv.board : []), ...(Array.isArray(priv?.hand) ? priv.hand : []), ...(Array.isArray(priv?.temp) ? priv.temp : [])]; + for (const p of holders) { + if (!isObj(p)) continue; + if (p.kind === 'item' && p.id === slot.id) have += 1; + if (p.kind === 'chess') for (const it of Array.isArray(p.items) ? p.items : []) if (it?.id === slot.id) have += 1; + } + return have + 1 >= n; + } + const rec = getChess(slot.id); + if (!rec || rec.isGolden) return false; + const base = getChess(rec.baseId) || rec; + const golden = (base.goldenId && getChess(base.goldenId)) || getChess(String(base.chessId || slot.id).replace(/_a$/, '_b')); + if (!golden) return false; + const { copies, need } = mergeProgress(priv, slot.id, getChess); + return need > 1 && copies + 1 >= need; +} + +/** + * How the shop bar names a free pick-one offer (m.private shop.rewardOffer): the promotion reward (`source` 'merge') + * reads 晋升奖励 / PROMOTION; any other offer — a strategy's special refresh (凯瑟琳 定向投放, 娜仁图亚 见者有份), an item + * (寻呼模块, 信标) or a 特质 (松果) — reads its `label` with the refresh icon (player report #6 after 0.1.0: 凯瑟琳's three + * items showed up as nameless operator cards under 晋升奖励). `items` = the offer holds items (cards drawn as item cards). + * `queued` = offers waiting behind this one (shop.rewardOffer.queued, e.g. a 定向投放 behind a promotion reward put + * off): `more` (header) and the pill's "+N" say so, null / no suffix when none. + * @param {any} offer + * @returns {{ title: string, micro: string, sub: string, icon: string, items: boolean, pill: string, queued: number, more: string|null }} + */ +export function offerHeader(offer) { + const slots = isObj(offer) && Array.isArray(offer.slots) ? offer.slots : []; + const items = slots.some((s) => isObj(s) && s.kind === 'item'); + const label = isObj(offer) && typeof offer.label === 'string' && offer.label ? offer.label : null; + const queued = isObj(offer) && Number.isInteger(offer.queued) && offer.queued > 0 ? offer.queued : 0; + const tail = { queued, more: queued ? `之后还有 ${queued} 项` : null }; + if (!items && (!isObj(offer) || offer.source === 'merge' || offer.source == null) && !label) { + return { title: '晋升奖励', micro: 'PROMOTION', sub: '免费选择 1 名', icon: 'crown', items: false, pill: '晋升奖励待选择', ...tail }; + } + const title = label || (items ? '装备补给' : '特殊招募'); + return { title, micro: 'SPECIAL', sub: items ? '免费选择 1 件' : '免费选择 1 名', icon: 'refresh', items, pill: `${title}待选择`, ...tail }; +} + +/** + * Why a shop action is unavailable (null when available). + * @param {'buy'|'reward'|'refresh'|'freeze'|'levelUp'|'ready'} kind + * @param {{ priv:any, editable:boolean, slot?:any, getChess?:(id:string)=>any, getItem?:(id:string)=>any }} ctx + * @returns {string|null} Chinese reason + */ +export function shopBlockReason(kind, { priv, editable, slot, getChess, getItem } = {}) { + if (!priv) return '尚未就绪'; + if (priv.alive === false) return '你已被淘汰'; + if (kind === 'ready') return priv.canReady === false ? '临时整备区不为空,请先处理溢出的资源' : null; + if (!editable) { + if (kind === 'reward') return '当前无法选择'; + return priv.ready ? '已准备就绪,取消准备后才能操作' : '当前阶段无法进行该操作'; + } + const funds = Number(priv.funds) || 0; + const shop = priv.shop || {}; + if (kind === 'buy' || kind === 'reward') { + if (!isObj(slot) || slot.sold) return kind === 'reward' ? '已选择' : '已售出'; + if ((Number(slot.price) || 0) > funds) return '资金不足'; + if (handFull(priv) && !completesMerge(priv, slot, { getChess, getItem })) return '整备区已满'; + return null; + } + if (kind === 'refresh') return (Number(shop.refreshPrice) || 0) > funds ? '资金不足' : null; + if (kind === 'levelUp') { + if ((Number(shop.level) || 1) >= (Number(shop.maxLevel) || 6)) return '调度中心已达最高等级'; + return (Number(shop.upgradePrice) || 0) > funds ? '资金不足' : null; + } + return null; +} + +/** + * The 准备 confirmation (community report #4 after 0.1.2): readying with funds left asks first — the prep's end wipes + * them (PRTS 卫戍协议/帮助 「本回合的剩余资金将清零」; the server still does: PlayerState.endPrep). act2autochess constData + * `noMoneyTipsBand` — data/config.json economy.leftoverFundsKeptByBands, ["band_cannot"] — names the strategies whose + * funds carry over and that get no such tip (坎诺特 利滚利). No prompt for an un-ready, with 0 funds, when already ready + * or out, or under AI 托管 (the server plays the seat). The dialog's wording is the remake's own [ASSUMED]: neither the + * tables nor PRTS hold the official one. + * @param {any} priv m.private + * @param {{ ready?: boolean, keptBands?: string[]|null, autoplay?: boolean }} [opts] `ready`: the state asked for + * @returns {{ title: string, text: string, okText: string, cancelText: string, micro: string } | null} + */ +export function readyFundsPrompt(priv, { ready = true, keptBands = null, autoplay = false } = {}) { + if (!ready || autoplay || !isObj(priv) || priv.alive === false || priv.ready) return null; + const funds = Math.trunc(Number(priv.funds) || 0); + if (!(funds > 0)) return null; + const kept = Array.isArray(keptBands) ? keptBands : ['band_cannot']; + if (typeof priv.bandId === 'string' && kept.includes(priv.bandId)) return null; + return { + title: '剩余资金', micro: 'FUNDS LEFT', okText: '准备就绪', cancelText: '继续整备', + text: `还有 ${funds} 资金未使用。休整期结束时,本回合的剩余资金将清零。确定准备就绪吗?`, + }; +} diff --git a/public/js/ui/gameLogic/shortcuts.js b/public/js/ui/gameLogic/shortcuts.js new file mode 100644 index 00000000..cbb172c6 --- /dev/null +++ b/public/js/ui/gameLogic/shortcuts.js @@ -0,0 +1,49 @@ +// ui/gameLogic/shortcuts.js — in-match keyboard shortcuts. Re-exported from ../gameLogic.js. + + +// ---- keyboard --------------------------------------------------------------------------------------------------- + +/** + * Map a keydown to a game shortcut (R refresh, F freeze, D level-up, Q retreat, X sell, Space ready, Esc close). + * Space means ready even while a HUD button has focus (a mouse click leaves the shop card / 刷新 focused, and + * Space must not re-trigger it); the caller prevents the button's own activation. Enter still activates buttons. + * @param {{ key?: string, code?: string, ctrlKey?: boolean, metaKey?: boolean, altKey?: boolean, repeat?: boolean, target?: any }} e + * @returns {'refresh'|'freeze'|'levelUp'|'retreat'|'sell'|'ready'|'escape'|null} + */ +export function shortcutFor(e) { + if (!e || e.ctrlKey || e.metaKey || e.altKey) return null; + const t = e.target; + const tag = t && typeof t.tagName === 'string' ? t.tagName.toUpperCase() : ''; + if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || t?.isContentEditable) return null; + if (e.key === 'Escape') return 'escape'; + if (e.repeat) return null; + const code = e.code || ''; + const key = typeof e.key === 'string' ? e.key.toLowerCase() : ''; + if (code === 'KeyR' || key === 'r') return 'refresh'; + if (code === 'KeyF' || key === 'f') return 'freeze'; + if (code === 'KeyD' || key === 'd') return 'levelUp'; + if (code === 'KeyQ' || key === 'q') return 'retreat'; + if (code === 'KeyX' || key === 'x') return 'sell'; + if (code === 'Space' || key === ' ') return 'ready'; + return null; +} + +/** + * Whether a press on the field closes the open detail card: a card opened from the field itself (an own piece — tap, + * 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'; + +/** + * Whether an open overlay swallows a game shortcut: a modal / the guide own the keyboard (Esc included — they close + * themselves); the 本局信息 / 敌方情报 drawer is a dialog too — only Esc (it closes the drawer) passes, R / F / D / Space + * never act behind it. + * @param {'refresh'|'freeze'|'levelUp'|'retreat'|'sell'|'ready'|'escape'|null} act shortcutFor + * @param {{ modal?: boolean, drawer?: boolean }} open + */ +export function shortcutBlocked(act, { modal = false, drawer = false } = {}) { + if (!act) return true; + if (modal) return true; + return !!drawer && act !== 'escape'; +} diff --git a/public/js/ui/gameLogic/watch.js b/public/js/ui/gameLogic/watch.js new file mode 100644 index 00000000..a8b537a7 --- /dev/null +++ b/public/js/ui/gameLogic/watch.js @@ -0,0 +1,118 @@ +// ui/gameLogic/watch.js — field ids, the watch switcher, emote bubbles. Re-exported from ../gameLogic.js. + +import { isObj, sortedPlayers } from './shared.js'; +import { isCombatPhase } from './phases.js'; + + +/** Own normal field id (DESIGN §8.3). */ +export const ownFieldId = (playerId) => `n:${playerId}`; + +/** + * Field the player sits in right now (own normal field, or the unite/boss field that lists them). + * @param {any} pub + * @param {string} playerId + */ +export function homeFieldId(pub, playerId) { + const fields = Array.isArray(pub?.fields) ? pub.fields : []; + const special = fields.find((f) => isObj(f) && f.kind !== 'normal' && Array.isArray(f.players) && f.players.includes(playerId) && f.live !== false); + if (special) return special.fieldId; + const me = sortedPlayers(pub).find((p) => p.playerId === playerId); + if (me && typeof me.fieldId === 'string' && me.fieldId) return me.fieldId; + return ownFieldId(playerId); +} + +/** + * Next/previous watchable field for the ‹ › view switcher. + * @param {Array<{fieldId:string, live?:boolean}>} fields + * @param {string|null} current + * @param {1|-1} dir + * @returns {string|null} + */ +export function cycleField(fields, current, dir = 1) { + const list = (Array.isArray(fields) ? fields : []).filter((f) => isObj(f) && typeof f.fieldId === 'string' && f.live !== false); + if (list.length === 0) return null; + const i = list.findIndex((f) => f.fieldId === current); + if (i < 0) return list[0].fieldId; + const n = list.length; + return list[(((i + dir) % n) + n) % n].fieldId; +} + +/** + * What clicking a teammate row asks the server to watch, or why it can't (mirror of server Match.watch): an + * eliminated player has no board to watch, and during 最终攻势 / 隐秘核心 a fighting player can't see the other + * pair's boss field ("无法查看另一组队友的战场情况"). + * @param {any} p m.public player row + * @param {any} pub + * @param {string} myId + * @returns {{ fieldId: string } | { reason: string }} + */ +export function watchTarget(p, pub, myId) { + if (!isObj(p)) return { reason: '无效的目标' }; + if (p.alive === false) return { reason: '该队友已被淘汰,无法查看其阵地' }; + const combat = isCombatPhase(pub?.phase); + const fieldId = (combat && typeof p.fieldId === 'string' && p.fieldId) || ownFieldId(p.playerId); + if (combat) { + const fields = Array.isArray(pub?.fields) ? pub.fields.filter(isObj) : []; + const f = fields.find((x) => x.fieldId === fieldId); + const me = sortedPlayers(pub).find((x) => x.playerId === myId); + const mine = fields.find((x) => Array.isArray(x.players) && x.players.includes(myId)); + if (f && (f.kind === 'boss' || f.kind === 'hidden') && me?.alive !== false && mine && mine.fieldId !== f.fieldId) { + return { reason: '无法查看另一组队友的战场' }; + } + } + return { fieldId }; +} + +/** + * Label of the ‹ › view switcher: the watched field (also one whose battle already ended), else the own field — + * or, for an eliminated spectator (no own field), the watched player / 观战. + * @param {any} pub + * @param {string|null} watching fieldId on screen + * @param {string} myId + * @param {boolean} [spectating] + */ +export function switcherLabel(pub, watching, myId, spectating = false) { + const fields = (Array.isArray(pub?.fields) ? pub.fields : []).filter(isObj); + const cur = fields.find((f) => f.fieldId === watching); + if (cur) return fieldLabel(cur, pub, myId); + if (typeof watching === 'string' && watching.startsWith('n:') && watching !== ownFieldId(myId)) { + return sortedPlayers(pub).find((p) => p.playerId === watching.slice(2))?.name || '队友'; + } + return spectating ? '观战' : '自己'; +} + +/** + * Human label of a field for the view switcher. + * @param {any} field { fieldId, kind, players } + * @param {any} pub + * @param {string} myId + */ +export function fieldLabel(field, pub, myId) { + if (!isObj(field)) return '—'; + const names = new Map(sortedPlayers(pub).map((p) => [p.playerId, p.name || '博士'])); + const ps = Array.isArray(field.players) ? field.players : []; + if (field.kind === 'unite') return ps.includes(myId) ? '联防(自己)' : '联防阵地'; + if (field.kind === 'boss' || field.kind === 'hidden') { + if (ps.includes(myId)) return ps.length > 1 ? '全景' : '自己'; + return ps.map((id) => names.get(id) || '博士').join(' · ') || '领袖战场'; + } + if (ps.includes(myId) || field.fieldId === ownFieldId(myId)) return '自己'; + const id = ps[0] ?? String(field.fieldId || '').replace(/^n:/, ''); + return names.get(id) || '队友'; +} + +/** + * Active emote bubbles: playerId → { id, seq, at } for emotes younger than ttl. + * @param {Array<{seq:number, playerId:string, id:string, at:number}>} emotes + * @param {number} now + * @param {number} [ttl] + */ +export function activeBubbles(emotes, now, ttl = 3000) { + const out = new Map(); + for (const e of Array.isArray(emotes) ? emotes : []) { + if (!isObj(e) || !(now - e.at < ttl) || e.at > now + 1000) continue; + const cur = out.get(e.playerId); + if (!cur || cur.seq < e.seq) out.set(e.playerId, { id: e.id, seq: e.seq, at: e.at }); + } + return out; +} From c1b8292f92f5f07581342babe84ba0ac8a1dcf82 Mon Sep 17 00:00:00 2001 From: sganggs Date: Mon, 5 Oct 2026 17:13:07 +0800 Subject: [PATCH 2/5] feedback5 R5: split the battle effect system into modules public/js/render/fx.js becomes a re-export barrel. Speeds, camera heights, fonts, the kind table and the shared tunables move verbatim into public/js/render/fx/*.js. FxSystem keeps its constructor, clear, update, counts and destroy; the other methods move verbatim into container classes installed on FxSystem.prototype in source order. simSpeeds and fontsReady each stay one instance. The dynamic import of /sim/constants.js stays next to setSimProjectileSpeeds. --- public/js/render/fx.js | 2234 +--------------------------- public/js/render/fx/arrivals.js | 120 ++ public/js/render/fx/beams.js | 172 +++ public/js/render/fx/camera.js | 28 + public/js/render/fx/fonts.js | 21 + public/js/render/fx/kinds.js | 145 ++ public/js/render/fx/limits.js | 13 + public/js/render/fx/locks.js | 104 ++ public/js/render/fx/numbers.js | 368 +++++ public/js/render/fx/particles.js | 139 ++ public/js/render/fx/projectiles.js | 464 ++++++ public/js/render/fx/rings.js | 136 ++ public/js/render/fx/simfx.js | 298 ++++ public/js/render/fx/speeds.js | 38 + public/js/render/fx/system.js | 174 +++ public/js/render/fx/zones.js | 155 ++ 16 files changed, 2385 insertions(+), 2224 deletions(-) create mode 100644 public/js/render/fx/arrivals.js create mode 100644 public/js/render/fx/beams.js create mode 100644 public/js/render/fx/camera.js create mode 100644 public/js/render/fx/fonts.js create mode 100644 public/js/render/fx/kinds.js create mode 100644 public/js/render/fx/limits.js create mode 100644 public/js/render/fx/locks.js create mode 100644 public/js/render/fx/numbers.js create mode 100644 public/js/render/fx/particles.js create mode 100644 public/js/render/fx/projectiles.js create mode 100644 public/js/render/fx/rings.js create mode 100644 public/js/render/fx/simfx.js create mode 100644 public/js/render/fx/speeds.js create mode 100644 public/js/render/fx/system.js create mode 100644 public/js/render/fx/zones.js diff --git a/public/js/render/fx.js b/public/js/render/fx.js index 14034cb4..9e0c9650 100644 --- a/public/js/render/fx.js +++ b/public/js/render/fx.js @@ -33,2227 +33,13 @@ // flashes, afterimages, debris, scorch marks, aura motes — are skipped at quality 'low' and under heavy load // (adaptive load level ≥ 2), and trails yield to bursts near the particle cap (SOFT_CAP). -import { fxAtlas } from './textures.js'; -import { DMG_STYLE, dmgStyleKey, HIT_TINT, PROJ, COLORS } from './style.js'; - -/** - * The sim's projectile speeds (server/sim/constants.js PROJECTILE_SPEEDS — pure data, served read-only at - * /sim/constants.js with the rest of the client-side sim, DESIGN §14): a shot's visual flight ends when the sim lands it, - * i.e. with its damage number. Loaded once in a browser (the game already has the module from its battle runner); - * until it arrives — and in Node, where '/sim/' is no module path — style.js PROJ speeds (a copy the unit tests keep - * equal to the sim's) stand in. - */ -let simSpeeds = null; -/** - * Use the sim's speeds (tiles per game second): `table` = PROJECTILE_SPEEDS by kind, `boomerangReturn` = - * BOOMERANG_RETURN_SPEED (the sim's visual kind of a boomerang flying back); null = style.js PROJ only. - */ -export function setSimProjectileSpeeds(table, boomerangReturn = null) { - if (!table || typeof table !== 'object') { simSpeeds = null; return; } - simSpeeds = Object.assign({}, table); - if (Number(boomerangReturn) > 0) simSpeeds.boomerangReturn = Number(boomerangReturn); -} -if (typeof window !== 'undefined' && typeof window.location?.origin === 'string') { - import('/sim/constants.js').then((m) => { if (m?.PROJECTILE_SPEEDS) setSimProjectileSpeeds(m.PROJECTILE_SPEEDS, m.BOOMERANG_RETURN_SPEED); }, () => {}); -} -/** - * Tiles per game second of a projectile kind ('boomerangReturn': a boomerang's way back): the sim's value, else - * style.js PROJ (`speed`; the boomerang's `back`), else 15 for a boomerang (the sim's outbound speed) / 12. - */ -export function projSpeed(kind) { - const v = simSpeeds ? Number(simSpeeds[kind]) : NaN; - if (v > 0) return v; - if (kind === 'boomerangReturn') return PROJ.boomerang?.back > 0 ? PROJ.boomerang.back : projSpeed('boomerang'); - const f = PROJ[kind]?.speed; - return f > 0 ? f : kind === 'boomerang' ? 15 : 12; -} - -const MAX_PARTICLES = { high: 1400, medium: 800, low: 360 }; -const MAX_NUMBERS = 90; -/** Particle cap multiplier per adaptive load level (render/app.js loadLevel). */ -const LOAD_PARTICLES = Object.freeze([1, 0.75, 0.55, 0.4]); -const NUM_RISE = 0.45; // tiles a damage number rises over NUM_RISE_T (easeOut), then it stays -const NUM_RISE_T = 0.9; // seconds of the rise -const NUM_LIFE = 0.9; // seconds a number lives (a merged running total lives longer, ≤ NUM_MAX_LIFE) -const NUM_MAX_LIFE = 1.8; -const NUM_FADE_T = 0.27; // fade-out at the end of its life -const NUM_LINE_EM = 0.95; // vertical room of one line of digits, in em of the 24 px bitmap font -const NUM_MAX_LINES = 5; // a number starts at most this many lines above the head -const NUM_DIGIT_EM = 0.66; // advance of one digit of the damage font, in em (Bender 700: 0.56–0.66, the widest kept) -const NUM_POP = 1.35; // birth / merge pop scale (the layout reserves the popped size) -const NUM_GAP_PX = 7; // horizontal gap between two numbers side by side (never read as one number) -const NUM_MERGE_GAP = 0.3; // s: same-style hits on one unit closer than this join its running total -const NUM_PER_TARGET = 4; // live numbers per unit (then hits join / the oldest fades) -const NUM_CROWD = 6; // more live numbers than this around a spot → shorter lives there -const NUM_LANES = Object.freeze([0, -1, 1, -2, 2]); // lane order (lane widths from the unit's head) -/** Screen scale of a number's text (24 px font) at `s` px per tile. */ -const numScale = (s, big, style) => clamp(s / 115, 0.42, 1.05) * (big ? 1.35 : 1) * (style === 'heal' ? 0.9 : 1); -const MAX_PROJ = 260; -/** Trail particles (and boomerang afterimages) per real second of one flying projectile (cosmetic). */ -const TRAIL_HZ = 36; -/** Cosmetic particles stop at this share of the particle cap: the headroom stays for hits and bursts. */ -const SOFT_CAP = 0.75; -/** Real seconds a shot lingers after arriving: the tracer shrinks into the target, the head fades (short shots read). */ -const SHOT_FADE = 0.08; -/** Boomerang: spin (rad per real s), sideways bow of each leg (tiles, at ≥ 2 tiles range), safety lifetime (real s). */ -const BOOM_SPIN = 22, BOOM_BOW = 0.35, BOOM_MAX_T = 6; -/** - * Lock reticle: how long (game s) a lock outlives the last sign of its shooter's S3 going on — its latest lock, shell or - * bombard (a lock whose shell / shot never comes) — its fade-out (real s), and the most reticles kept at once (a long - * S3: ammo grants / reloads lock far more than the 5 base shots, ≤ 33 shells). - */ -const LOCK_T = 5, LOCK_FADE = 0.2, MAX_LOCKS = 48; -// 炎佑 fire jet: real s a jet burns past its tick's `dur` (the next 1 game s tick re-aims and extends it before it fades: -// one continuous stream), its fade-out, the most jets at once, the fire colours -const FLAME_TAIL = 0.2, FLAME_FADE = 0.15, MAX_FLAMES = 8; -const FLAME_TINTS = Object.freeze([0xffd27a, 0xffa94d, 0xff8a3d, 0xff5a2a]); -/** Bombard shell: share of its flight spent rising from the shooter, and the height it climbs / falls from (tiles). */ -const SHELL_RISE = 0.34, SHELL_UP = 5.5; -/** 蕾缪安 S3 shell (fx 'bombardShell'); `look` 'mortar' is its own flight (_stepMortar). */ -const BOMBARD_SHELL = Object.freeze({ look: 'mortar', tint: 0xfff2d8, glow: 0xff9c33, trail: 0xffb35c, smoke: 0x3a3430, len: 1.3, width: 0.3, head: 0.56 }); -/** - * An fx anchored on a unit (extra.id) is drawn at that unit's rendered position while the event's own (x, y) is within - * this many tiles of it (it happens on the unit); farther away the fx happens at (x, y) — the sim puts the caster in - * `id` of many area / target effects (an 'aoe' ahead of the caster, a 'crit' on the victim, 蕾缪安's 'bombard'). - */ -const ANCHOR_SNAP = 0.75; -const SKILL_GOLD = 0xffd45a; -const NO_OPTS = Object.freeze({}); -const clamp = (v, lo, hi) => (v < lo ? lo : v > hi ? hi : v); -/** - * Shot heights (GitHub #61): where a shot or beam leaves a unit (`launch`, ≈ its hands) and where shots, beams and lock - * marks meet a unit (`aim`, ≈ its chest), as shares of the drawn model's height above the feet (`_headTiles`). The - * models are upright screen billboards, so these are heights on screen, solved into world heights through the camera - * (projection.js liftFor, `bodyZ`): 0.45 × the head height taken as a WORLD height drew at ≈ 18 % of the model under the - * 30° pitch — shots left the hips and aimed at the targets' hips. [ASSUMED] the shares (a battle chibi's hands / chest; - * the data has no official muzzle points). - */ -export const SHOT_HEIGHT = Object.freeze({ launch: 0.45, aim: 0.5 }); -/** World height of the point `frac` of a unit's drawn model height above its feet (SHOT_HEIGHT), seen through `cam`. */ -export function bodyZ(cam, v, frac) { - const z0 = (v.z || 0) + (v.hover || 0); - const h = (v._headTiles || 1.2) * frac; - if (!cam || typeof cam.liftFor !== 'function') return z0 + h; - const dz = cam.liftFor(v.x, v.y, z0, h * cam.scaleAt(v.x, v.y, z0)); - return Number.isFinite(dz) ? z0 + dz : z0 + h; -} -/** World height just above a unit's feet (where shells land). */ -const feetZ = (v) => (v.z || 0) + (v.hover || 0) + 0.2; -/** Cheap fingerprint of a camera's framing (the damage-number layout cache is reused only while it is unchanged). */ -const camKey = (c) => (c ? c.tx + c.ty * 1e3 + c.tz * 1e6 + c.tilt * 7.13 + c.dist * 1e4 + c.scale * 3.7e-2 + c.cx * 1.1e-5 + c.cy * 1.3e-8 : 0); -/** Characters of a damage number as drawn (heals get a '+'). */ -const numChars = (v, style) => String(Math.round(v)).length + (style === 'heal' ? 1 : 0); - -/** Sub-professions whose attacks splash around the struck target (the 阵法术师 / 轰击术师 strike every enemy in range). */ -const SPLASH_SUBS = new Set(['aoesniper', 'splashcaster', 'bombarder', 'fortress', 'hammer']); - -let fontsReady = false; -/** Generate the damage-number bitmap fonts (after web fonts loaded, if possible). */ -export function ensureDamageFonts() { - if (fontsReady) return; - const P = globalThis.PIXI; - for (const st of Object.values(DMG_STYLE)) { - try { - P.BitmapFont.from(st.font, { - fontFamily: ['Bender', 'Oxanium', 'Rajdhani', 'Arial Black', 'sans-serif'], fontSize: 44, fontWeight: '700', - fill: st.fill, fillGradientStops: [0.25, 1], stroke: st.stroke, strokeThickness: 7, - dropShadow: true, dropShadowColor: '#000000', dropShadowAlpha: 0.45, dropShadowDistance: 2, dropShadowBlur: 2, - }, { chars: [['0', '9'], '+-×!'], resolution: 2, padding: 6 }); - } catch (err) { console.warn('[fx] bitmap font', st.font, err?.message || err); } - } - fontsReady = true; -} - -const num = (v, d) => { const n = typeof v === 'number' ? v : typeof v === 'string' && v.trim() !== '' ? Number(v) : NaN; return Number.isFinite(n) ? n : d; }; - -/** - * Sim fx kind → visual archetype (`a`), colour (`c`) and defaults (`r` radius, `dur` game s, `tex`, `smoke`). - * Covers every kind emitted by server/sim (Battle, kits, tokens, enemies, bosses, devices, bonds, items); unknown - * kinds fall back to a keyword guess, then to a generic sparkle (fxSpec). - */ -export const FX_KINDS = Object.freeze({ - // blasts - aoe: { a: 'blast', c: 0xffb35c }, explode: { a: 'blast', c: 0xff7a33 }, explosion: { a: 'blast', c: 0xff7a33 }, - // `pt`: always at the event's (x, y) (its `id` is the shooter); `heavy`: debris + scorch - bombard: { a: 'blast', c: 0xffa04a, r: 1.5, pt: true, heavy: true }, bombardShell: { a: 'shell', c: 0xff5a3a, r: 1.5, pt: true }, - airstrike: { a: 'blast', c: 0xff8a3d, r: 1.5, heavy: true }, splash: { a: 'blast', c: 0xffc27a }, - scorchBurst: { a: 'blast', c: 0xff6a2a }, champagneBomb: { a: 'blast', c: 0xffd27a }, shockBlast: { a: 'blast', c: 0x9fd4ff, smoke: 0x1c2630 }, - frostNova: { a: 'blast', c: 0x9fe6ff, smoke: 0x1c2630 }, sunBurst: { a: 'blast', c: 0xffe28a }, meltdown: { a: 'blast', c: 0xff5a2a, r: 1.5, heavy: true }, - iceSpike: { a: 'blast', c: 0xbfeeff, smoke: 0x1c2630 }, rockfall: { a: 'blast', c: 0xc8a878, smoke: 0x4a3f33 }, rockslide: { a: 'blast', c: 0xc8a878, smoke: 0x4a3f33 }, - finale: { a: 'blast', c: 0xffd45a, r: 1.5 }, swordStorm: { a: 'blast', c: 0xdfe8ff }, swordRain: { a: 'blast', c: 0xdfe8ff }, liberate: { a: 'blast', c: 0xffffff }, - quadShot: { a: 'volley', c: 0xfff2d0 }, featherArrow: { a: 'counter', c: 0xfff2d0 }, - burst: { a: 'element', c: 0xd0a0ff }, - // areas - zone: { a: 'zone', c: 0xffb35c, dur: 3 }, healField: { a: 'zone', c: 0x62f08a, dur: 4 }, firewall: { a: 'wall', c: 0xff6a2a, dur: 4 }, - firewallBlock: { a: 'counter', c: 0xff6a2a }, tide: { a: 'zone', c: 0x5fe0ff, dur: 3, r: 2 }, storm: { a: 'zone', c: 0xd8c8a0, dur: 3 }, - tornado: { a: 'zone', c: 0xd8e8ff, dur: 3 }, snow: { a: 'zone', c: 0xe8f6ff, dur: 3 }, telegraph: { a: 'telegraph', c: 0xff3b30, dur: 1.2 }, - coldWind: { a: 'chill', c: 0x9fd4ff }, - // support - heal: { a: 'heal', c: 0x62f08a }, healAoe: { a: 'healAoe', c: 0x62f08a, r: 1.5 }, bandage: { a: 'heal', c: 0x62f08a }, blessing: { a: 'healAoe', c: 0xfff0a8 }, - cleanse: { a: 'healAoe', c: 0xdfffff, r: 0.9 }, revive: { a: 'summon', c: 0xfff0a8 }, reborn: { a: 'summon', c: 0xffd45a }, hpShare: { a: 'heal', c: 0xff9aa6 }, - spGift: { a: 'sp', c: 0x6fd3ff }, spGain: { a: 'sp', c: 0x6fd3ff }, reload: { a: 'sp', c: 0xffe066 }, - shield: { a: 'shield', c: 0xdfe8ff }, catShield: { a: 'shield', c: 0xffe0a8 }, saltWard: { a: 'shield', c: 0xbfeeff }, shell: { a: 'shield', c: 0xc8b890 }, - vest: { a: 'shield', c: 0xdfe8ff }, sleepGuard: { a: 'shield', c: 0xa8b6ff }, truesilver: { a: 'shield', c: 0xfff3b8 }, shieldBreak: { a: 'shatter', c: 0xdfe8ff }, - // arrivals / departures - summon: { a: 'summon', c: 0x9ff0dc }, drones: { a: 'summon', c: 0x8fe6ff }, drone: { a: 'summon', c: 0x8fe6ff }, sentry: { a: 'summon', c: 0x9ff0dc }, - turretOnline: { a: 'summon', c: 0xff8a6a }, yanyouSummon: { a: 'summon', c: 0xffb347 }, device: { a: 'summon', c: 0xc0c8cc }, - appear: { a: 'summon', c: 0xb36bff }, copy: { a: 'summon', c: 0xd8b0ff }, manifoldCopy: { a: 'summon', c: 0xd8b0ff }, split: { a: 'summon', c: 0xff9a6a }, - disappear: { a: 'vanish', c: 0xb36bff }, stealth: { a: 'vanish', c: 0x8fa0b0 }, camouflage: { a: 'vanish', c: 0x8fb08f }, phase: { a: 'vanish', c: 0xb36bff }, - substitute: { a: 'vanish', c: 0xd8b0ff }, swap: { a: 'blink', c: 0xd8b0ff }, blink: { a: 'blink', c: 0xb36bff }, teleport: { a: 'blink', c: 0xb36bff }, - // a dollkeeper knocked out as its <替身> (sim professions.js): only its model form goes back to the 本体 for the - // redeploy — the 替身's death clip already shows the knock-out, nothing is drawn - dollEnd: { a: 'none', c: 0xd8b0ff }, - ulpiaReturn: { a: 'blink', c: 0x9ff0dc }, manifoldSplit: { a: 'blink', c: 0xd8b0ff }, - // displacement - pull: { a: 'move', c: 0x9fd4ff }, push: { a: 'move', c: 0xffd9a0 }, displace: { a: 'move', c: 0xd0c0a0 }, lure: { a: 'move', c: 0xffb3ec }, - charge: { a: 'move', c: 0xff9c33 }, dash: { a: 'move', c: 0xffd9a0 }, slippery: { a: 'move', c: 0x9fe6ff }, - // pulses - sonic: { a: 'wave', c: 0xc9a2ff, r: 1.5 }, pulse: { a: 'wave', c: 0x9ff0dc }, sermon: { a: 'wave', c: 0xffe28a, r: 1.5 }, ripple: { a: 'wave', c: 0x5fe0ff }, - tornadoPulse: { a: 'wave', c: 0xd8e8ff }, wake: { a: 'wave', c: 0x5fe0ff }, wolfShadow: { a: 'wave', c: 0x8fa0b0 }, wolfShadowLost: { a: 'vanish', c: 0x8fa0b0 }, - redistribute: { a: 'wave', c: 0x62f08a }, dilemma: { a: 'wave', c: 0xc9a2ff }, - // marks - taunt: { a: 'mark', c: 0xff9c33 }, palsy: { a: 'mark', c: 0xc77dff }, emergency: { a: 'mark', c: 0xff5a4a }, - lock: { a: 'reticle', c: 0xff5a4a }, droneLock: { a: 'reticle', c: 0x8fe6ff }, wanted: { a: 'reticle', c: 0xffc600 }, expose: { a: 'reticle', c: 0xff7b8a }, - reveal: { a: 'reticle', c: 0x9fd4ff }, anchor: { a: 'reticle', c: 0x9fd4ff }, - // buffs - buff: { a: 'buff', c: 0xffd45a }, overload: { a: 'buff', c: 0xff7a33 }, overclock: { a: 'buff', c: 0xff9c33 }, talent: { a: 'buff', c: 0xffd45a }, - knack: { a: 'buff', c: 0xffd45a }, bloodBattle: { a: 'buff', c: 0xff4b3e }, sword: { a: 'buff', c: 0xdfe8ff }, equip: { a: 'buff', c: 0x4ed8af }, - garrisonGrant: { a: 'buff', c: 0x4ed8af }, extraAttack: { a: 'buff', c: 0xffe066 }, soul: { a: 'buff', c: 0xb36bff }, jungleSoul: { a: 'buff', c: 0x7fd37a }, - candle: { a: 'buff', c: 0xffb347 }, mote: { a: 'buff', c: 0xfff0a8 }, ember: { a: 'buff', c: 0xff7a33 }, ignite: { a: 'buff', c: 0xff6a2a }, - flame: { a: 'buff', c: 0xff6a2a }, grow: { a: 'buff', c: 0x7fd37a }, weightlessBuff: { a: 'buff', c: 0xcfe0ff }, - // 炎佑 祛恶之焰: a continuous jet from the dragon (`id`) onto its locked target (`target`) + a burning disc (_flame) - yanyouFlame: { a: 'flame', c: 0xff8a3d, r: 1 }, - // states - takeoff: { a: 'lift', c: 0xcfe0ff }, levitate: { a: 'lift', c: 0xcfe0ff }, weightless: { a: 'lift', c: 0xcfe0ff }, - sleep: { a: 'sleep', c: 0xa8b6ff }, crit: { a: 'crit', c: 0xffe066 }, - dodge: { a: 'dodge', c: 0xffffff }, riposte: { a: 'counter', c: 0xffd9a0 }, counter: { a: 'counter', c: 0xffd9a0 }, block: { a: 'counter', c: 0xdfe8ff }, - thorns: { a: 'counter', c: 0xff9aa6 }, downed: { a: 'down', c: 0xbfeee2 }, stone: { a: 'down', c: 0xc0b8a8, smoke: 0x5a5448 }, - dp: { a: 'dp', c: 0x9fd4ff }, coin: { a: 'coin', c: 0xffc600 }, steal: { a: 'coin', c: 0xffc600 }, crateBreak: { a: 'crate', c: 0xc89a5a }, - lpLoss: { a: 'lp', c: 0xff3b30 }, - // 限伤 (sim/damage.js leaderHitCancelled): a leader's hit of ≥ 300000 dealt nothing — the official shows no number and - // no effect [ASSUMED], so nothing is drawn (`a: 'none'`) - hitCap: { a: 'none', c: 0xffffff }, - // beams - beam: { a: 'beam', c: 0xff7a5a }, link: { a: 'beam', c: 0x9ff0dc }, lightning: { a: 'bolt', c: 0xc9a2ff }, tentacle: { a: 'beam', c: 0x5fe0ff }, - sandChains: { a: 'beam', c: 0xd8c8a0 }, sandChainsCharged: { a: 'beam', c: 0xffd45a }, - strike: { a: 'strike', c: 0xffe6a8 }, volley: { a: 'volley', c: 0xfff2d0 }, column: { a: 'pillar', c: 0x9ff0dc }, obelisk: { a: 'pillar', c: 0xc9a2ff }, - duskDragon: { a: 'blast', c: 0x9dff6a, r: 1.5 }, shadowWeave: { a: 'vanish', c: 0x8f7bff }, reweave: { a: 'summon', c: 0x8f7bff }, - slash: { a: 'counter', c: 0xfff0d0 }, devour: { a: 'vanish', c: 0xff4b3e }, undying: { a: 'shield', c: 0xffd45a }, - // summon arrival bursts (content/tokens.js burst(): 沙之碑 default, 迷迭香 gear stun, 耀阳 sword, 纸偶) - summonBurst: { a: 'blast', c: 0xe8c878, smoke: 0x4a3f33 }, summonStun: { a: 'blast', c: 0xcfe0ff, smoke: 0x2a2e36 }, - radiantSword: { a: 'pillar', c: 0xffe8a0 }, paperDoll: { a: 'blast', c: 0xc9a2ff, smoke: 0x2c2436 }, - // bonds / garrisons (support/index.js fxOn) - bondMilestone: { a: 'buff', c: 0xffc600 }, bondProc: { a: 'buff', c: 0x4ed8af }, bondShare: { a: 'wave', c: 0x4ed8af }, - garrison: { a: 'buff', c: 0x4ed8af }, layer: { a: 'buff', c: 0xffe066 }, sp: { a: 'sp', c: 0x6fd3ff }, -}); - -/** Keyword guesses for kinds added later (checked in order), before the generic sparkle. */ -const FX_GUESS = [ - [/heal|cure|mend|regen/i, 'heal'], [/shield|ward|barrier|guard/i, 'shield'], [/summon|spawn|call|deploy/i, 'summon'], - [/blast|bomb|explo|burst|boom|nova|strike/i, 'blast'], [/zone|field|area|wall|mist|fog|pool/i, 'zone'], - [/buff|boost|power|rage|charge|up$/i, 'buff'], [/mark|lock|target|wanted|expose/i, 'reticle'], [/teleport|blink|warp|swap/i, 'blink'], - [/stealth|hide|vanish|cloak/i, 'vanish'], [/pull|push|knock|dash|leap/i, 'move'], [/pulse|wave|ring|sonic/i, 'wave'], -]; - -/** - * 'phase' (an enemy's mode change, render/units.js FORMS) kinds the model shows on its own: 暴鸰 'bombed' — its bomb is - * the 'droneBomb' projectile of the same moment, a puff on the drone would read as something else (feedback D4). - */ -const SILENT_PHASES = new Set(['bombed']); - -/** Visual spec of an fx kind (see FX_KINDS); `extra.kind` / `extra.element` may pick a better colour. */ -export function fxSpec(kind, extra = {}) { - const k = typeof kind === 'string' ? kind : ''; - if (k === 'phase' && SILENT_PHASES.has(extra && extra.kind)) return { a: 'none', c: 0xffffff }; - let spec = FX_KINDS[k]; - if (!spec) { - const g = FX_GUESS.find(([re]) => re.test(k)); - spec = g ? { ...FX_KINDS[{ heal: 'heal', shield: 'shield', summon: 'summon', blast: 'aoe', zone: 'zone', buff: 'buff', reticle: 'lock', blink: 'blink', vanish: 'disappear', move: 'displace', wave: 'pulse' }[g[1]]] } : { a: 'generic', c: 0xfff2d0 }; - } - const el = extra && extra.element; - if (el && (spec.a === 'blast' || spec.a === 'zone' || spec.a === 'element')) { - const c = el === 'burn' ? 0xff7a33 : el === 'neural' ? 0xff5ad0 : el === 'necrosis' || el === 'apoptosis' ? 0x9dff6a : el === 'erosion' ? 0x6fe0ff : null; - if (c) spec = { ...spec, c }; - } - if (extra && (extra.dmgType === 'arts' || /arts|magic/i.test(String(extra.kind || ''))) && spec.a === 'blast') spec = { ...spec, c: 0xc77dff }; - return spec; -} - -/** Tiles covered by a blast / telegraph: `tiles` = [[r,c]…] or 'box' (Chebyshev ⌊r⌋ around the centre) or 'disc'. */ -export function tilesAround(x, y, r, tiles) { - if (Array.isArray(tiles)) return tiles.filter((t) => Array.isArray(t) && Number.isInteger(t[0]) && Number.isInteger(t[1])).slice(0, 80); - const cr = Math.round(y), cc = Math.round(x), R = Math.max(0, Math.min(6, Math.floor(r))); - const out = []; - for (let dr = -R; dr <= R; dr++) for (let dc = -R; dc <= R; dc++) { - if (tiles === 'box' || dr * dr + dc * dc <= r * r + 0.25) out.push([cr + dr, cc + dc]); - } - return out; -} - -/** - * Tiles of a straight wall through tile (round(y), round(x)): `axis` 'col' = that column, 'row' = that row (余 S3 - * fire wall: perpendicular to his facing, sim/content/kits/ops/chess_char_6_03-yu.js). Clipped to the field `rect` (inclusive - * { r0, r1, c0, c1 }); without one ±4 tiles. - */ -export function wallTiles(x, y, axis, rect) { - const R = Math.round(Number(y)), C = Math.round(Number(x)); - if (!Number.isFinite(R) || !Number.isFinite(C)) return []; - const ok = rect && [rect.r0, rect.r1, rect.c0, rect.c1].every(Number.isFinite); - const out = []; - if (axis === 'row') { - const [a, b] = ok ? [rect.c0, rect.c1] : [C - 4, C + 4]; - for (let c = a; c <= b; c++) out.push([R, c]); - } else { - const [a, b] = ok ? [rect.r0, rect.r1] : [R - 4, R + 4]; - for (let r = a; r <= b; r++) out.push([r, C]); - } - return out.filter(([r, c]) => r >= 0 && r <= 18 && c >= 0 && c <= 20); -} - -export class FxSystem { - /** - * @param {{ P, layers: { groundFx, fxAdd, fxNormal, text, screen }, cam: () => any, view: (id) => any, - * heightAt: (r,c)=>number, settings: object, assets: any, timeScale: () => number, - * subProfOf: (defId) => string|null, screenSize: () => {width,height}, fieldTop: () => number }} ctx - */ - constructor(ctx) { - const P = ctx.P; - this.ctx = ctx; - this.P = P; - this.atlas = fxAtlas(); - this.tex = this.atlas.tex; - ensureDamageFonts(); - const props = { vertices: true, position: true, rotation: true, uvs: true, tint: true }; - this.addPc = new P.ParticleContainer(MAX_PARTICLES.high, props, 512, true); - this.addPc.blendMode = P.BLEND_MODES.ADD; - this.normPc = new P.ParticleContainer(MAX_PARTICLES.high, props, 512, true); - // ground shadows of flying shells / boomerangs: normal blend, under the units and the smoke - this.shadowLayer = new P.Container(); - ctx.layers.fxNormal.addChild(this.shadowLayer, this.normPc); - ctx.layers.fxAdd.addChild(this.addPc); - this.parts = []; // active particle records { sp, add, x, y, vx, … } - this.freeAdd = []; this.freeNorm = []; // pooled particle records (their sprites stay in the containers) - this.projs = []; - this.projFree = []; - this.projLayer = new P.Container(); - ctx.layers.fxAdd.addChild(this.projLayer); - this.locks = []; // lock-on reticles { view, id, src, x, y, z, t, idle, max, out, ring, core, shell } - this.lockFree = []; - this._po = {}; // options of the per-frame emitters (see _o) - this.beams = new P.Graphics(); - this.beams.blendMode = P.BLEND_MODES.ADD; - ctx.layers.fxAdd.addChild(this.beams); - this.beamList = []; - this.flames = []; // fire jets (炎佑 祛恶之焰) { src, tgt, x, y, z, r, col, t, end, disc, edge, … } - this.nums = []; - this._numPools = new Map(); // bitmap font → free number records - this.auras = new Map(); // unit id → { sprite, ring, t } - this.rings = []; // ground rings { sprite, x, y, z, t, dur, r0, r1, tint } - this.ringFree = []; - this.pops = []; // screen-space pops (bond glyphs, coins) - this.zones = []; // persistent ground areas (zone / telegraph fx) - this.labels = []; // floating '!' / '+n' labels - this.tileFlashes = []; // flashing tile sets (telegraphed boxes) - this.tileGfx = new P.Graphics(); - this.tileGfx.blendMode = P.BLEND_MODES.ADD; - ctx.layers.groundFx.addChild(this.tileGfx); - this.tintSprite = new P.Sprite(P.Texture.WHITE); - this.tintSprite.alpha = 0; - this.tintSprite.blendMode = P.BLEND_MODES.ADD; - ctx.layers.screen.addChild(this.tintSprite); - this.tintT = 0; this.tintDur = 1; this.tintA = 0; - this.vignette = new P.Sprite(ctx.redVignette || P.Texture.EMPTY); - this.vignette.alpha = 0; - ctx.layers.screen.addChild(this.vignette); - this.vigT = 0; - this.time = 0; - this._p = { x: 0, y: 0, s: 0, depth: 0 }; - this._q = { x: 0, y: 0, s: 0, depth: 0 }; - this._g = { x: 0, y: 0, s: 0, depth: 0 }; - } - - get quality() { return this.ctx.settings?.quality || 'high'; } - /** The view's adaptive load level (0–3, render/app.js): a struggling device gets fewer particles / numbers. */ - get load() { return this.ctx.loadLevel ? this.ctx.loadLevel() | 0 : 0; } - get maxParticles() { return Math.round((MAX_PARTICLES[this.quality] || MAX_PARTICLES.high) * LOAD_PARTICLES[Math.min(3, this.load)]); } - /** Cosmetic extras on (trails, muzzle flashes, afterimages, debris, scorch, motes): not at 'low', not under heavy load. */ - get rich() { return this.quality !== 'low' && this.load < 2; } - /** Room for one more cosmetic particle (SOFT_CAP keeps the rest of the cap for hits and bursts). */ - _room() { return this.parts.length < this.maxParticles * SOFT_CAP; } - /** Battle clock rate (game s per real s, ≈ 2 in combat). */ - _ts() { const r = this.ctx.timeScale ? Number(this.ctx.timeScale()) : 2; return r > 0.25 ? r : 0.25; } - /** Ground height of the tile under (x, y). */ - _groundZ(x, y) { return this.ctx.heightAt ? (this.ctx.heightAt(Math.round(y), Math.round(x)) || 0) : 0; } - - // ---- particles ------------------------------------------------------------------------------------------ - - /** - * Spawn a screen-space particle; returns its record (set `sx` on it to mirror). `o` is only read (the per-frame - * emitters pass the shared _o() object). Records are pooled with their sprite; at the cap the oldest is recycled. - */ - particle(tex, x, y, o = NO_OPTS) { - if (this.parts.length >= this.maxParticles) this._freeParticle(this.parts.shift()); - const add = o.add !== false; - const t = this.tex[tex] || this.tex.dot; - let p = (add ? this.freeAdd : this.freeNorm).pop(); - if (!p) { - const sp = new this.P.Sprite(t); - (add ? this.addPc : this.normPc).addChild(sp); - p = { sp, add }; - } - const sp = p.sp; - sp.texture = t; - sp.anchor.set(o.anchorX ?? 0.5, o.ay ?? 0.5); - sp.visible = true; - sp.position.set(x, y); - sp.tint = o.tint ?? 0xffffff; - sp.rotation = o.rot ?? 0; - p.x = x; p.y = y; p.vx = o.vx || 0; p.vy = o.vy || 0; p.g = o.g || 0; p.drag = o.drag ?? 0; p.life = 0; p.max = o.life || 0.5; - p.s0 = o.s0 ?? 1; p.s1 = o.s1 ?? o.s0 ?? 1; p.sx = o.sx ?? 1; p.a0 = o.a0 ?? 1; p.a1 = o.a1 ?? 0; p.spin = o.spin || 0; p.fadeIn = o.fadeIn || 0; - sp.scale.set(p.s0 * p.sx, p.s0); - sp.alpha = p.fadeIn > 0 ? 0 : p.a0; - this.parts.push(p); - return p; - } - - /** The shared options object of the per-frame emitters, reset to the defaults (particle() never keeps it). */ - _o() { - const o = this._po; - o.add = true; o.tint = 0xffffff; o.vx = 0; o.vy = 0; o.g = 0; o.drag = 0; o.life = 0.5; o.s0 = 1; o.s1 = 1; o.sx = 1; - o.a0 = 1; o.a1 = 0; o.spin = 0; o.fadeIn = 0; o.rot = 0; o.anchorX = 0.5; o.ay = 0.5; - return o; - } - - _freeParticle(p) { - // Pixi's ParticleRenderer draws every child of the container (it never reads `visible`): a freed sprite becomes a - // zero-size, fully transparent quad — no pixels, no fill cost — until the record is reused - const sp = p.sp; - sp.visible = false; - sp.alpha = 0; - sp.scale.set(0, 0); - (p.add ? this.freeAdd : this.freeNorm).push(p); - } - - _updateParticles(dt) { - const list = this.parts; - let w = 0; - for (let i = 0; i < list.length; i++) { - const p = list[i]; - p.life += dt; - if (p.life >= p.max) { this._freeParticle(p); continue; } - const k = p.life / p.max; - if (p.drag) { const d = Math.max(0, 1 - p.drag * dt); p.vx *= d; p.vy *= d; } - p.vy += p.g * dt; - p.x += p.vx * dt; p.y += p.vy * dt; - const sp = p.sp; - sp.position.set(p.x, p.y); - const s = p.s0 + (p.s1 - p.s0) * k; - sp.scale.set(s * p.sx, s); - if (p.spin) sp.rotation += p.spin * dt; - let a = p.a0 + (p.a1 - p.a0) * k; - if (p.fadeIn > 0 && p.life < p.fadeIn) a *= p.life / p.fadeIn; - sp.alpha = clamp(a, 0, 1); - list[w++] = p; - } - list.length = w; - } - - // ---- helpers ------------------------------------------------------------------------------------------ - - _proj(x, y, z, out = this._p) { return this.ctx.cam().project(x, y, z, out); } - - /** Ground decals: on a raised top they are drawn with that block row (tiles.surfaceLayer), else in groundFx. */ - _onGround(sp, y, z) { - let layer = this.ctx.layers.groundFx; - if (z > 0.12 && this.ctx.surfaceLayer) layer = this.ctx.surfaceLayer(Math.round(y)) || layer; - if (sp.parent !== layer) layer.addChild(sp); - } - - _chest(view, out = this._p) { - const z = (view.z || 0) + (view.hover || 0) + (view._headTiles ? view._headTiles * 0.45 : 0.5); - return this._proj(view.x, view.y, z, out); - } - - /** Screen point `frac` of a unit's drawn model height above its feet (SHOT_HEIGHT: beams, a mortar's muzzle). */ - _bodyPt(view, frac, out = this._p) { - const p = this._proj(view.x, view.y, (view.z || 0) + (view.hover || 0), out); - p.y -= (view._headTiles || 1.2) * frac * p.s; - return p; - } - - /** `n` sparks flying out of a screen point (halved at quality 'low'); o: speed, up, g, life, size, tex. */ - burst(x, y, s, n, tint, o = NO_OPTS) { - const q = this.quality === 'low' ? Math.ceil(n / 2) : n; - const speed = o.speed ?? 2.2, up = o.up ?? 0, g = (o.g ?? 0) * s, life = o.life ?? 0.35, size = (s / 64) * (o.size ?? 0.45), tex = o.tex || 'spark'; - for (let i = 0; i < q; i++) { - const a = Math.random() * Math.PI * 2, v = s * speed * (0.4 + Math.random() * 0.8); - const po = this._o(); - po.tint = tint; po.vx = Math.cos(a) * v; po.vy = Math.sin(a) * v * 0.7 - up * s; po.drag = 3; po.g = g; - po.life = life * (0.7 + Math.random() * 0.6); po.s0 = size; po.s1 = (s / 64) * 0.05; po.spin = (Math.random() - 0.5) * 8; - this.particle(tex, x, y, po); - } - } - - // ---- projectiles ----------------------------------------------------------------------------------------- - - /** b.ev 'atk' visual. src/tgt are views (tgt may be null). */ - attack(src, tgt, kind) { - if (!src) return; - // chain: the source is the previous target of the bounce (sim ai.js), so the arc hops unit to unit - if (kind === 'chain' || kind === 'chainHeal') { if (tgt && tgt !== src) this._beam(src, tgt, kind === 'chainHeal' ? 0x7dffa8 : 0xc9a2ff, 0.22, 1, true); return; } - if (kind === 'beam') { if (tgt && tgt !== src) this._beam(src, tgt, src.isEnemy ? 0xff7a5a : 0xffe6a8, 0.18, 0.15); return; } - const spec = PROJ[kind]; - if (!spec || !tgt) { - if (kind === 'none' || !kind) this._slashAt = src.id; - return; - } - const pr = this._takeProj(); - const cam = this.ctx.cam(); - const dx = tgt.x - src.x, dy = tgt.y - src.y; - const dist = Math.hypot(dx, dy); - const ux = dist > 1e-6 ? dx / dist : (src.facing || 1) >= 0 ? 1 : -1, uy = dist > 1e-6 ? dy / dist : 0; - const hand = Math.min(0.28, dist * 0.3); // the weapon is in front of the body - const look = spec.look; - pr.kind = kind; pr.spec = spec; pr.src = src; pr.tgt = tgt; pr.rise = 0; - pr.x0 = src.x + ux * hand; pr.y0 = src.y + uy * hand; pr.z0 = bodyZ(cam, src, SHOT_HEIGHT.launch); - pr.tx = tgt.x; pr.ty = tgt.y; pr.tz = look === 'shell' ? feetZ(tgt) : bodyZ(cam, tgt, SHOT_HEIGHT.aim); - pr.t = 0; pr.fade = 0; pr.hit = false; pr.emit = Math.random(); pr.ang = Math.atan2(-uy, ux); // ≈ on screen (rows run up) - pr.dur = clamp(dist / projSpeed(kind) / this._ts(), 0.04, 1.5); - pr.arc = spec.arc ? spec.arc * clamp(0.45 + dist * 0.18, 0.6, 1.8) : 0; - pr.glow = spec.glow; - pr.trailTint = spec.trail ?? spec.glow; - // boomerang legs: kinematic, from (bx, by, bz) at constant speed towards the target, then back to the thrower - pr.phase = 0; pr.bx = pr.x0; pr.by = pr.y0; pr.bz = pr.z0; pr.trav = 0; pr.d0 = Math.max(0.1, dist); pr.ux = ux; pr.uy = uy; - pr.spin = Math.random() * 6; - this._dressProj(pr); - this.projs.push(pr); - if (this.rich) this._muzzle(pr); - } - - /** A pooled projectile record: trail + halo + core sprites (additive, above units) and a ground shadow. */ - _takeProj() { - if (this.projs.length >= MAX_PROJ) this._releaseProj(this.projs.shift()); - let pr = this.projFree.pop(); - if (!pr) { - const P = this.P; - const add = (tex, ax) => { - const sp = new P.Sprite(this.tex[tex]); - sp.anchor.set(ax, 0.5); - sp.blendMode = P.BLEND_MODES.ADD; - sp.visible = false; - return sp; - }; - const trail = add('tracer', 1), halo = add('glow', 0.5), core = add('orb', 0.5); - this.projLayer.addChild(trail, halo, core); - const shadow = new P.Sprite(this.tex.soft); - shadow.anchor.set(0.5); - shadow.tint = 0x000000; - shadow.visible = false; - this.shadowLayer.addChild(shadow); - pr = { trail, halo, core, shadow }; - } - return pr; - } - - /** Textures / tints of a projectile's sprites for its look (shown from its first update). */ - _dressProj(pr) { - const spec = pr.spec, look = spec.look; - const thin = look === 'tracer' || look === 'dart'; - const { trail, halo, core, shadow } = pr; - trail.texture = this.tex[thin ? 'tracer' : 'streak']; - trail.tint = thin ? spec.tint : pr.trailTint; - trail.visible = look !== 'boomerang'; - halo.texture = this.tex.glow; - halo.tint = pr.glow; - halo.visible = true; - core.texture = this.tex[look === 'boomerang' ? 'boomerang' : thin ? 'dot' : 'orb']; - core.tint = spec.tint; - core.rotation = 0; - core.visible = true; - shadow.visible = look === 'shell' || look === 'boomerang'; - trail.alpha = halo.alpha = core.alpha = shadow.alpha = 0; - } - - _releaseProj(pr) { - pr.trail.visible = pr.halo.visible = pr.core.visible = pr.shadow.visible = false; - pr.src = pr.tgt = null; - this.projFree.push(pr); - } - - _updateProjs(dt) { - if (!this.projs.length) return; - const cam = this.ctx.cam(); - const rich = this.rich; - let w = 0; - for (let i = 0; i < this.projs.length; i++) { - const pr = this.projs[i]; - const look = pr.spec.look; - const live = look === 'boomerang' ? this._stepBoomerang(pr, dt, cam, rich) - : look === 'mortar' ? this._stepMortar(pr, dt, cam, rich) : this._stepShot(pr, dt, cam, rich); - if (!live) { this._releaseProj(pr); continue; } - this.projs[w++] = pr; - } - this.projs.length = w; - } - - /** Screen point of a straight / lobbed shot at flight fraction k (a parabola of height `arc` over the line). */ - _shotPoint(pr, k, cam, out) { - const x = pr.x0 + (pr.tx - pr.x0) * k, y = pr.y0 + (pr.ty - pr.y0) * k; - const z = pr.z0 + (pr.tz - pr.z0) * k + (pr.arc ? pr.arc * 4 * k * (1 - k) : 0); - return cam.project(x, y, z, out); - } - - /** - * One frame of a tracer / orb / shell / dart: placed by flight time (homing on the target), trail behind the head - * along the path (never longer than the part flown), an arrival burst, then a SHOT_FADE linger in the target. - */ - _stepShot(pr, dt, cam, rich) { - const spec = pr.spec, look = spec.look; - pr.t += dt; - const tg = pr.tgt; - if (tg && !tg.destroyed && tg.alive !== false) { pr.tx = tg.x; pr.ty = tg.y; pr.tz = look === 'shell' ? feetZ(tg) : bodyZ(cam, tg, SHOT_HEIGHT.aim); } - const k = Math.min(1, pr.t / pr.dur); - if (k >= 1 && !pr.hit) { pr.hit = true; pr.fade = 0; this._impact(pr, cam); } - let fk = 0; - if (pr.hit) { pr.fade += dt; fk = pr.fade / SHOT_FADE; if (fk >= 1) return false; } - const p = this._shotPoint(pr, k, cam, this._p); - const kb = Math.max(0, k - (pr.arc ? 0.12 : 0.25)); - const q = this._shotPoint(pr, kb, cam, this._q); - const s = p.s, px = p.x, py = p.y; - const seg = Math.hypot(px - q.x, py - q.y); - if (seg > 0.5) pr.ang = Math.atan2(py - q.y, px - q.x); - const thin = look === 'tracer' || look === 'dart'; - const flown = k > kb ? seg * (k / (k - kb)) : 0; - const L = Math.min(spec.len * s, flown) * (1 - fk); - const tr = pr.trail; - tr.position.set(px, py); - tr.rotation = pr.ang; - tr.scale.set(Math.max(0.001, L / 128), (spec.width * s) / (thin ? 16 : 20)); - tr.alpha = (thin ? 1 : 0.85) * (1 - fk); - const hs = spec.head * s; - const halo = pr.halo; - halo.position.set(px, py); - halo.scale.set((hs / 128) * (1 + 0.12 * Math.sin(pr.t * 40))); - halo.alpha = 0.85 * (1 - fk); - const core = pr.core; - core.position.set(px, py); - if (thin) core.scale.set((hs * 0.5) / 32); - else if (look === 'shell') { core.rotation = pr.ang; core.scale.set((hs * 0.85) / 64, (hs * 0.55) / 64); } // a shell along its flight - else core.scale.set((hs * 0.62) / 64); - core.alpha = 1 - fk; - if (look === 'shell') { - // its shadow on the ground under it: bigger and darker the lower it flies - const gx = pr.x0 + (pr.tx - pr.x0) * k, gy = pr.y0 + (pr.ty - pr.y0) * k; - const g = cam.project(gx, gy, this._groundZ(gx, gy) + 0.01, this._g); - const near = 1 - clamp((pr.arc * 4 * k * (1 - k)) / Math.max(0.3, pr.arc), 0, 1); - const r = g.s * (0.17 + 0.13 * near); - const sh = pr.shadow; - sh.position.set(g.x, g.y); - sh.scale.set((r * 2) / 128, (r * 0.9) / 128); - sh.alpha = (0.32 + 0.3 * near) * (1 - fk); - } - if (rich && !pr.hit && !thin) { - pr.emit += dt * TRAIL_HZ; - for (let n = 0; pr.emit >= 1 && n < 2; n++) { - pr.emit -= 1; - if (!this._room()) { pr.emit = 0; break; } - if (look === 'shell') this._puff(px, py, s, pr.trailTint, spec.smoke, n); - else this._mote(px, py, s, pr.trailTint, spec.width); - } - } - return true; - } - - /** - * 回环射手 (sim ai.js throwBoomerang): out to the (moving) target at the boomerang speed, a hit flash, then back to the - * thrower's current position at the return speed (PRTS 跃跃: 15 out, 3.75 back); gone when caught — or at once when - * the thrower's view is gone (the sim drops a boomerang whose thrower left). Each leg bows sideways (left of its own - * direction, so out and back form a loop); it spins, leaves afterimages and a shadow. - */ - _stepBoomerang(pr, dt, cam, rich) { - const spec = pr.spec; - pr.t += dt; - if (pr.t > BOOM_MAX_T) return false; - const step = projSpeed(pr.phase ? 'boomerangReturn' : pr.kind) * this._ts() * dt; - let gx, gy, gz; - if (pr.phase === 0) { - const tg = pr.tgt; - if (tg && !tg.destroyed && tg.alive !== false) { pr.tx = tg.x; pr.ty = tg.y; pr.tz = bodyZ(cam, tg, SHOT_HEIGHT.aim); } - gx = pr.tx; gy = pr.ty; gz = pr.tz; - } else { - const sv = pr.src; - if (!sv || sv.destroyed || sv.alive === false) return false; - gx = sv.x; gy = sv.y; gz = bodyZ(cam, sv, SHOT_HEIGHT.launch); - } - const dx = gx - pr.bx, dy = gy - pr.by, dz = gz - pr.bz; - const d = Math.hypot(dx, dy, dz); - if (d <= step) { - pr.bx = gx; pr.by = gy; pr.bz = gz; - if (pr.phase === 1) { this._catch(pr, cam); return false; } - this._impact(pr, cam); - // turn back: the next leg runs from here to the thrower (a thrower already gone gets nothing back) - const sv = pr.src; - if (!sv || sv.destroyed || sv.alive === false) return false; - const bx = sv.x - gx, by = sv.y - gy; - const bd = Math.hypot(bx, by); - pr.phase = 1; pr.trav = 0; pr.d0 = Math.max(0.1, bd); - if (bd > 1e-6) { pr.ux = bx / bd; pr.uy = by / bd; } else { pr.ux = -pr.ux; pr.uy = -pr.uy; } - } else { - pr.bx += (dx / d) * step; pr.by += (dy / d) * step; pr.bz += (dz / d) * step; - pr.trav += step; - } - const arcK = Math.sin(clamp(pr.trav / pr.d0, 0, 1) * Math.PI); - const bow = arcK * BOOM_BOW * Math.min(1, pr.d0 / 2); - const x = pr.bx - pr.uy * bow, y = pr.by + pr.ux * bow, z = pr.bz + arcK * 0.12; - const p = cam.project(x, y, z, this._p); - const px = p.x, py = p.y, hs = spec.head * p.s; - pr.spin += BOOM_SPIN * dt; - const core = pr.core; - core.position.set(px, py); - core.rotation = pr.spin; - core.scale.set(hs / 64); - core.alpha = 1; - const halo = pr.halo; - halo.position.set(px, py); - halo.scale.set((hs * 1.5) / 128); - halo.alpha = 0.4; - const g = cam.project(x, y, this._groundZ(x, y) + 0.01, this._g); - const sh = pr.shadow; - sh.position.set(g.x, g.y); - sh.scale.set((g.s * 0.4) / 128, (g.s * 0.18) / 128); - sh.alpha = 0.22; - if (rich) { - pr.emit += dt * TRAIL_HZ; - if (pr.emit >= 1) { - pr.emit = Math.min(1, pr.emit - 1); - if (this._room()) { - const o = this._o(); - o.tint = spec.glow; o.life = 0.14; o.s0 = hs / 64; o.s1 = (hs / 64) * 0.9; o.a0 = 0.4; o.rot = pr.spin; - this.particle('boomerang', px, py, o); - } - } - } - return true; - } - - /** - * One frame of a 蕾缪安 bombard shell: a long flight first streaks up out of the shooter (`rise` of it, following - * her); then it falls onto its spot, faster and faster, its shadow growing there. It ends at the impact — the - * explosion (and the end of its lock) is the sim's own 'bombard' fx. - */ - _stepMortar(pr, dt, cam, rich) { - const spec = pr.spec; - pr.t += dt; - const k = pr.t / pr.dur; - if (k >= 1) return false; - let x, y, z, zq; - if (k < pr.rise) { - const u = k / pr.rise, ub = Math.max(0, u - 0.25); - const sv = pr.src; - if (sv && !sv.destroyed) { pr.x0 = sv.x; pr.y0 = sv.y; } - x = pr.x0; y = pr.y0; - z = pr.z0 + SHELL_UP * (1 - (1 - u) * (1 - u)); // out of the barrel fast, slowing as it climbs - zq = pr.z0 + SHELL_UP * (1 - (1 - ub) * (1 - ub)); - } else { - const u = (k - pr.rise) / (1 - pr.rise), ub = Math.max(0, u - 0.25); - x = pr.tx; y = pr.ty; - z = pr.tz + SHELL_UP * (1 - u * u); // falling faster and faster - zq = pr.tz + SHELL_UP * (1 - ub * ub) + 0.3; - const g = cam.project(x, y, pr.tz + 0.01, this._g); - const r = g.s * (0.12 + 0.3 * u); - const sh = pr.shadow; - sh.visible = true; - sh.position.set(g.x, g.y); - sh.scale.set((r * 2) / 128, (r * 0.9) / 128); - sh.alpha = 0.12 + 0.36 * u; - } - const p = cam.project(x, y, z, this._p); - const q = cam.project(x, y, zq, this._q); - const s = p.s, px = p.x, py = p.y; - const seg = Math.hypot(px - q.x, py - q.y); - if (seg > 0.5) pr.ang = Math.atan2(py - q.y, px - q.x); - const tr = pr.trail; - tr.position.set(px, py); - tr.rotation = pr.ang; - tr.scale.set(Math.max(0.001, Math.min(spec.len * s, seg * 1.6) / 128), (spec.width * s) / 20); - tr.alpha = 0.95; - const hs = spec.head * s; - pr.halo.position.set(px, py); - pr.halo.scale.set((hs / 128) * (1 + 0.15 * Math.sin(pr.t * 50))); - pr.halo.alpha = 0.95; - pr.core.position.set(px, py); - pr.core.rotation = pr.ang; - pr.core.scale.set((hs * 0.85) / 64, (hs * 0.5) / 64); - pr.core.alpha = 1; - if (rich) { - pr.emit += dt * TRAIL_HZ; - for (let n = 0; pr.emit >= 1 && n < 2; n++) { - pr.emit -= 1; - if (!this._room()) { pr.emit = 0; break; } - this._puff(px, py, s, spec.trail, spec.smoke, n); - } - } - return true; - } - - /** Trail mote behind an orb (additive dot, drifting a little). */ - _mote(x, y, s, tint, width) { - const o = this._o(); - const j = s * width * 0.25; - o.tint = tint; o.life = 0.28; o.s0 = (s * width * 0.75) / 32; o.s1 = 0; o.a0 = 0.8; - o.vx = (Math.random() - 0.5) * s * 0.25; o.vy = (Math.random() - 0.5) * s * 0.25; - this.particle('dot', x + (Math.random() - 0.5) * j, y + (Math.random() - 0.5) * j, o); - } - - /** Trail of a shell: alternately a hot ember (additive) and a grey smoke puff (normal blend). */ - _puff(x, y, s, tint, smoke, n) { - const o = this._o(); - if ((n + (this.parts.length & 1)) & 1) { - o.tint = tint; o.life = 0.24; o.s0 = (s * 0.16) / 32; o.s1 = 0; o.a0 = 0.9; - this.particle('dot', x, y, o); - } else { - o.add = false; o.tint = smoke ?? 0x3a3430; o.life = 0.55; o.s0 = (s * 0.22) / 128; o.s1 = (s * 0.55) / 128; o.a0 = 0.45; - o.vy = -s * 0.25; o.spin = (Math.random() - 0.5) * 2; o.rot = Math.random() * 6; - this.particle('smoke', x, y, o); - } - } - - /** Flash at the shooter as a shot leaves (tracers: an oriented muzzle cone). Cosmetic. */ - _muzzle(pr) { - const spec = pr.spec, look = spec.look; - if (!spec.muzzle) return; - const cam = this.ctx.cam(); - const p = cam.project(pr.x0, pr.y0, pr.z0, this._g); - const px = p.x, py = p.y, s = p.s; - if (look === 'tracer') { - const q = cam.project(pr.tx, pr.ty, pr.tz, this._q); - this.particle('muzzle', px, py, { tint: spec.muzzle, life: 0.09, s0: (s / 64) * 0.55, s1: (s / 64) * 0.72, a0: 1, a1: 0, rot: Math.atan2(q.y - py, q.x - px), anchorX: 0.19 }); - this.particle('glow', px, py, { tint: spec.muzzle, life: 0.1, s0: (s / 128) * 0.45, s1: (s / 128) * 0.7, a0: 0.9, a1: 0 }); - } else { - this.particle('glow', px, py, { tint: pr.glow === spec.glow ? spec.muzzle : pr.glow, life: 0.16, s0: (s / 128) * 0.45, s1: (s / 128) * 0.8, a0: 0.9, a1: 0 }); - if (look === 'shell') this.smoke(px, py, s * 0.28, spec.smoke ?? 0x2a2522, 0.3); - } - } - - /** Arrival burst of a shot (PROJ `hit`): at the target, or the ground under it for shells. */ - _impact(pr, cam) { - const spec = pr.spec; - const p = cam.project(pr.tx, pr.ty, pr.tz, this._g); - const x = p.x, y = p.y, s = p.s; - const rich = this.rich; - switch (spec.hit) { - case 'arts': - this.particle('glow', x, y, { tint: pr.glow, life: 0.24, s0: (s / 128) * 0.7, s1: (s / 128) * 1.2, a0: 0.95, a1: 0 }); - this.particle('shock', x, y, { tint: pr.glow, life: 0.26, s0: (s / 128) * 0.25, s1: (s / 128) * 1.05, a0: 0.9, a1: 0 }); - if (rich) this.burst(x, y, s, 4, pr.trailTint, { speed: 2.2, life: 0.3, tex: 'dot', size: 0.35 }); - break; - case 'heal': { - this.particle('glow', x, y, { tint: spec.glow, life: 0.3, s0: (s / 128) * 0.6, s1: (s / 128) * 1.1, a0: 0.9, a1: 0 }); - this.particle('flare', x, y, { tint: 0xc8ffd8, life: 0.22, s0: (s / 128) * 0.9, s1: (s / 128) * 0.3, a0: 1, a1: 0, rot: Math.random() }); - const n = rich ? 3 : 1; - for (let i = 0; i < n; i++) { - this.particle('plus', x + (Math.random() - 0.5) * s * 0.45, y - Math.random() * s * 0.2, { tint: 0x7dffa8, vy: -s * 0.8, life: 0.6, s0: (s / 64) * 0.26, s1: (s / 64) * 0.14, a0: 0.95, a1: 0, fadeIn: 0.05 }); - } - break; - } - case 'boom': this.explosion(pr.tx, pr.ty, this._groundZ(pr.tx, pr.ty), 1, pr.glow, { smoke: spec.smoke, small: true }); break; - case 'splash': this.explosion(pr.tx, pr.ty, this._groundZ(pr.tx, pr.ty), 0.7, pr.glow, { smoke: spec.smoke, small: true }); break; - case 'zap': - this.particle('flare', x, y, { tint: spec.glow, life: 0.16, s0: (s / 128) * 0.7, s1: (s / 128) * 0.25, a0: 1, a1: 0, rot: Math.random() }); - if (rich) this.burst(x, y, s, 3, spec.glow, { speed: 2.4, life: 0.22, size: 0.35 }); - break; - case 'enemy': - this.particle('glow', x, y, { tint: spec.glow, life: 0.2, s0: (s / 128) * 0.6, s1: (s / 128) * 1.0, a0: 0.9, a1: 0 }); - this.particle('flare', x, y, { tint: 0xffb0a0, life: 0.16, s0: (s / 128) * 0.8, s1: (s / 128) * 0.3, a0: 1, a1: 0, rot: Math.random() }); - break; - default: // 'spark': bullets, boomerang hits (the hit's own sparks come with its damage number) - this.particle('flare', x, y, { tint: spec.glow, life: 0.11, s0: (s / 128) * 0.55, s1: (s / 128) * 0.2, a0: 1, a1: 0, rot: Math.random() }); - } - } - - /** The thrower caught its boomerang: a small flash in its hands. */ - _catch(pr, cam) { - const p = cam.project(pr.bx, pr.by, pr.bz, this._g); - this.particle('flare', p.x, p.y, { tint: pr.spec.glow, life: 0.12, s0: (p.s / 128) * 0.6, s1: (p.s / 128) * 0.2, a0: 0.9, a1: 0, rot: Math.random() }); - } - - /** - * 蕾缪安 S3 shell (fx 'bombardShell'): fired now by `src` (its view, or null) at the spot (x, y), where it lands after - * `flight` real seconds (_stepMortar; the sim's shells fly 0.3 game s — they drop out of the sky, a launch streak - * leaving the shooter; a flight ≥ 0.45 real s climbs out of her first). The spot gets a thin ring closing in and - * brightening plus the blast radius throbbing until the impact. The shell takes the shooter's lock on that spot: the - * lock ends with this shell's 'bombard' (_landed). - */ - mortar(src, x, y, r, flight) { - const pr = this._takeProj(); - const gz = this._groundZ(x, y); - pr.kind = 'bombardShell'; pr.spec = BOMBARD_SHELL; pr.src = src; pr.tgt = null; - pr.x0 = src ? src.x : x; pr.y0 = src ? src.y : y; pr.z0 = src ? bodyZ(this.ctx.cam(), src, SHOT_HEIGHT.launch) : gz + 0.5; - pr.tx = x; pr.ty = y; pr.tz = gz; - pr.t = 0; pr.dur = clamp(flight, 0.1, 4); pr.fade = 0; pr.hit = false; pr.emit = 0; pr.arc = 0; pr.ang = Math.PI / 2; - pr.rise = pr.dur >= 0.45 ? SHELL_RISE : 0; - pr.glow = BOMBARD_SHELL.glow; pr.trailTint = BOMBARD_SHELL.trail; - const L = this._nearestLock(src ? src.id : null, x, y, 3, true); - if (L) { L.shell = true; L.sx = x; L.sy = y; } - this._dressProj(pr); - pr.shadow.visible = false; - this.projs.push(pr); - const rr = Math.max(0.5, r); - this.ring(x, y, gz, rr, rr * 0.22, 0xff5a3a, pr.dur, 'shock', 'in'); - this.ring(x, y, gz, rr * 0.96, rr, 0xff7a4a, pr.dur, 'ring', 'pulse'); - if (src && this.rich) { - // the shot leaves her upwards: a muzzle flash and a streak climbing out of sight - const p = this._bodyPt(src, SHOT_HEIGHT.launch, this._g); - const s = p.s; - this.particle('muzzle', p.x, p.y, { tint: 0xffc27a, life: 0.1, s0: (s / 64) * 0.6, s1: (s / 64) * 0.8, a0: 1, a1: 0, rot: -Math.PI / 2, anchorX: 0.19 }); - this.particle('glow', p.x, p.y, { tint: 0xffb35c, life: 0.14, s0: (s / 128) * 0.5, s1: (s / 128) * 0.9, a0: 0.9, a1: 0 }); - this.particle('tracer', p.x, p.y, { tint: 0xffe0b0, life: 0.16, vy: -s * 16, s0: (s * 0.3) / 16, s1: (s * 0.24) / 16, sx: (1.3 * 16) / (0.3 * 128), a0: 1, a1: 0.2, rot: -Math.PI / 2, anchorX: 1 }); - } - return pr; - } - - /** - * fx 'bombard' (the impact) at (x, y) by shooter `src`: the shell falling there has landed (ended now if still in - * the air) and its lock goes — the lock that shell took, found by the landing point (the sim uses the same spot for - * both events); a sim without 'bombardShell' releases the shooter's lock nearest to the spot. - */ - _landed(src, x, y, r) { - for (const pr of this.projs) { - if (pr.spec !== BOMBARD_SHELL || (src != null && pr.src && pr.src.id !== src)) continue; - if (Math.hypot(pr.tx - x, pr.ty - y) < 0.05) pr.t = Math.max(pr.t, pr.dur); - } - let best = null, bd = Infinity; - for (const L of this.locks) { - if (L.out >= 0 || (src != null && L.src != null && L.src !== src)) continue; - const d = L.shell ? Math.hypot(L.sx - x, L.sy - y) : Math.hypot(L.x - x, L.y - y) + 0.5; - if (d < bd) { bd = d; best = L; } - } - if (best && bd <= r + 1.5) this._releaseLock(best); - } - - /** A beam from view `a` to view `b`; `chain`: a bounce, `a` is the previous target (it leaves from its chest). */ - _beam(a, b, color, dur = 0.22, jitter = 1, chain = false) { - this.beamList.push({ a, b, color, t: 0, dur, jitter, chain, seed: Math.random() * 1000 }); - if (this.beamList.length > 40) this.beamList.shift(); - const q = this._bodyPt(b, SHOT_HEIGHT.aim, this._g); - this.particle('flare', q.x, q.y, { tint: color, life: 0.16, s0: (q.s / 128) * 0.7, s1: (q.s / 128) * 0.25, a0: 1, a1: 0, rot: Math.random() }); - } - - _updateBeams(dt) { - const g = this.beams; - g.clear(); - let w = 0; - const p = this._p, q = this._q; - for (const bm of this.beamList) { - bm.t += dt; - if (bm.t >= bm.dur || !bm.a || !bm.b) continue; - // a chain bounce leaves the previous target where the shot met it (its chest), a beam leaves the shooter's hands - this._bodyPt(bm.a, bm.chain ? SHOT_HEIGHT.aim : SHOT_HEIGHT.launch, p); const px = p.x, py = p.y, s = p.s; - this._bodyPt(bm.b, SHOT_HEIGHT.aim, q); - const k = 1 - bm.t / bm.dur; - const segs = 7; - // soft glow, coloured body, white-hot core - for (let pass = 0; pass < 3; pass++) { - const wd = pass === 0 ? s * 0.16 : pass === 1 ? s * 0.065 : s * 0.026; - g.lineStyle(Math.max(1, wd), pass === 2 ? 0xffffff : bm.color, (pass === 0 ? 0.22 : pass === 1 ? 0.6 : 0.95) * k); - g.moveTo(px, py); - for (let i = 1; i < segs; i++) { - const f = i / segs; - const j = Math.sin(bm.seed + i * 12.9 + bm.t * 40) * s * 0.12 * (bm.jitter ?? 1); - g.lineTo(px + (q.x - px) * f + j, py + (q.y - py) * f - j * 0.5); - } - g.lineTo(q.x, q.y); - } - this.beamList[w++] = bm; - } - this.beamList.length = w; - } - - // ---- fire jets (炎佑 祛恶之焰) --------------------------------------------------------------------------------- - - /** - * fx 'yanyouFlame' (sim/content/tokens.js yanyouKit; user playtest #4 item 12 "一段持续时间的喷火"): the sim emits one - * event per game second of the channel; each keeps the jet of dragon `srcId` burning for `dur` real s + FLAME_TAIL - * onto its locked target `tgtId` (else the spot (x, y)), so the 1 s ticks join into ONE continuous stream, with a - * burning disc of radius `r` tiles following the target. One jet per dragon (a new tick re-aims and extends it). - */ - _flame(srcId, tgtId, x, y, r, col, dur) { - if (!Number.isFinite(x) || !Number.isFinite(y)) return; - let F = srcId != null ? this.flames.find((f) => f.src === srcId) : null; - if (!F) { - const P = this.P; - const disc = new P.Sprite(this.tex.soft); - disc.anchor.set(0.5); disc.blendMode = P.BLEND_MODES.ADD; disc.alpha = 0; - const edge = new P.Sprite(this.tex.ring); - edge.anchor.set(0.5); edge.blendMode = P.BLEND_MODES.ADD; edge.alpha = 0; - F = { src: srcId, disc, edge, t: 0, end: 0, jet: 0, fire: 0, smoke: 0, seed: Math.random() * 100 }; - this.flames.push(F); - if (this.flames.length > MAX_FLAMES) this._freeFlame(this.flames.shift()); - } - F.tgt = tgtId; F.x = x; F.y = y; F.z = this._groundZ(x, y); F.r = r; F.col = col; - F.end = Math.max(F.end, F.t + dur + FLAME_TAIL); - F.disc.tint = F.edge.tint = col; - } - - _freeFlame(F) { F.disc.destroy(); F.edge.destroy(); } - - _updateFlames(dt) { - if (!this.flames.length) return; - const cam = this.ctx.cam(); - const g = this.beams; // drawn after _updateBeams cleared it - const p = this._p, q = this._q, m = this._g; - const low = this.quality === 'low'; - let w = 0; - for (const F of this.flames) { - F.t += dt; - const sv = this._viewOf(F.src); - // the channel is over (no tick kept it going) or the dragon is gone - if (F.t >= F.end || (F.src != null && this.ctx.view && (!sv || sv.alive === false))) { this._freeFlame(F); continue; } - const tv = this._viewOf(F.tgt); - if (tv && tv.alive !== false && Number.isFinite(tv.x) && Number.isFinite(tv.y)) { F.x = tv.x; F.y = tv.y; F.z = tv.z || 0; } - const fade = Math.min(1, F.t / 0.12, (F.end - F.t) / FLAME_FADE); - const flick = 0.82 + 0.18 * Math.sin(F.t * 23 + F.seed) * Math.sin(F.t * 7.3); - // burning disc on the ground around the target - this._onGround(F.disc, F.y, F.z); this._onGround(F.edge, F.y, F.z); - cam.project(F.x, F.y, F.z + 0.02, p); - cam.project(F.x, F.y + F.r, F.z + 0.02, q); - const cx = p.x, cy = p.y, s = p.s; - const rx = s * F.r, ry = Math.max(1, cy - q.y); - F.disc.position.set(cx, cy); F.disc.scale.set((rx * 2) / 128, (ry * 2) / 128); F.disc.alpha = 0.5 * fade * flick; - F.edge.position.set(cx, cy); F.edge.scale.set((rx * 2.05) / 128, (ry * 2.05) / 128); F.edge.alpha = 0.7 * fade * flick; - // flame tongues and embers rising from the disc - F.fire += dt * (low ? 14 : 34) * fade; - while (F.fire >= 1) { - F.fire -= 1; - if (!this._room()) { F.fire = 0; break; } - const a = Math.random() * Math.PI * 2, d = Math.sqrt(Math.random()) * 0.9; - const px = cx + Math.cos(a) * rx * d, py = cy + Math.sin(a) * ry * d; - const ember = Math.random() < 0.3; - const po = this._o(); - po.tint = ember ? 0xffd27a : FLAME_TINTS[(Math.random() * FLAME_TINTS.length) | 0]; - po.vx = (Math.random() - 0.5) * s * 0.3; po.vy = -s * (ember ? 1.6 : 0.9 + Math.random() * 0.6); po.drag = ember ? 0.5 : 1.5; - po.life = ember ? 0.7 : 0.35 + Math.random() * 0.25; - po.s0 = (s / 128) * (ember ? 0.06 : 0.28 + Math.random() * 0.12); po.s1 = (s / 128) * (ember ? 0.02 : 0.08); - po.a0 = 0.9; po.a1 = 0; po.fadeIn = 0.05; - this.particle(ember ? 'dot' : 'soft', px, py, po); - } - if (this.rich) { - F.smoke += dt * 5 * fade; - while (F.smoke >= 1) { - F.smoke -= 1; - if (!this._room()) { F.smoke = 0; break; } - const po = this._o(); - po.add = false; po.tint = 0x3a3430; po.vx = (Math.random() - 0.5) * s * 0.2; po.vy = -s * 0.5; po.drag = 1; - po.life = 0.9; po.s0 = (s / 128) * 0.35; po.s1 = (s / 128) * 0.8; po.a0 = 0.32; po.a1 = 0; po.fadeIn = 0.15; - this.particle('smoke', cx + (Math.random() - 0.5) * rx, cy - s * 0.2, po); - } - } - // the jet: from the dragon's chest onto the target (its chest; the disc centre without a target view) - if (sv) { - this._chest(sv, m); - const mx = m.x, my = m.y; - if (tv && tv.alive !== false) this._chest(tv, q); else { q.x = cx; q.y = cy - s * 0.15; } - const tx = q.x, ty = q.y; - const segs = 6; - for (let pass = 0; pass < 3; pass++) { - const col = pass === 2 ? 0xfff2c0 : pass === 1 ? 0xffb347 : F.col; - const alpha = (pass === 0 ? 0.3 : pass === 1 ? 0.55 : 0.8) * fade * flick; - let x0 = mx, y0 = my; - for (let i = 1; i <= segs; i++) { - const f = i / segs; - // widens towards the target (a cone of fire), wobbling - const wd = s * (pass === 0 ? 0.1 + 0.22 * f : pass === 1 ? 0.05 + 0.1 * f : 0.02 + 0.03 * f); - const j = i < segs ? Math.sin(F.seed + i * 3.1 + F.t * 30) * s * 0.05 * f : 0; - const x1 = mx + (tx - mx) * f + j, y1 = my + (ty - my) * f - j * 0.5; - g.lineStyle(Math.max(1, wd), col, alpha); - g.moveTo(x0, y0); g.lineTo(x1, y1); - x0 = x1; y0 = y1; - } - } - // fire puffs streaming along it - F.jet += dt * (low ? 18 : 42) * fade; - const T = 0.2; - while (F.jet >= 1) { - F.jet -= 1; - if (!this._room()) { F.jet = 0; break; } - const a = Math.random() * Math.PI * 2, d = Math.random() * 0.5; - const gx = tx + Math.cos(a) * rx * d, gy = ty + Math.sin(a) * ry * d; - const po = this._o(); - po.tint = FLAME_TINTS[(Math.random() * FLAME_TINTS.length) | 0]; - po.vx = (gx - mx) / T; po.vy = (gy - my) / T; - po.life = T * (0.9 + Math.random() * 0.3); - po.s0 = (s / 128) * 0.1; po.s1 = (s / 128) * (0.38 + Math.random() * 0.2); po.a0 = 0.85; po.a1 = 0.15; - this.particle('soft', mx, my, po); - } - } - this.flames[w++] = F; - } - this.flames.length = w; - } - - // ---- lock-on reticles (蕾缪安) ------------------------------------------------------------------------------- - - /** - * fx 'lock': a reticle on the locked enemy (following its view; at the last spot once it is gone) until its shell - * lands (mortar / _landed) or the aimed shot fires ('crit' from the same shooter); at most LOCK_T game seconds after - * the shooter's last lock / shell / bombard (_touchLocks). - */ - _lock(view, src, x, y, z) { - let L = this.lockFree.pop(); - if (!L) { - const P = this.P; - const ring = new P.Sprite(this.tex.reticle); - ring.anchor.set(0.5); - ring.blendMode = P.BLEND_MODES.ADD; - const core = new P.Sprite(this.tex.glow); - core.anchor.set(0.5); - core.blendMode = P.BLEND_MODES.ADD; - this.projLayer.addChild(ring, core); - L = { ring, core }; - } - L.view = view; L.id = view ? view.id : null; L.src = src ?? null; L.x = x; L.y = y; L.z = z; - L.t = 0; L.idle = 0; L.max = LOCK_T / this._ts(); L.out = -1; L.shell = false; L.sx = x; L.sy = y; - L.ring.tint = L.core.tint = FX_KINDS.lock.c; - L.ring.alpha = L.core.alpha = 0; - L.ring.visible = L.core.visible = true; - this.locks.push(L); - if (this.locks.length > MAX_LOCKS) this._freeLock(this.locks.shift()); - return L; - } - - /** Shooter `src` is still at it (a new lock, a shell, a bombard): its live locks restart their LOCK_T wait. */ - _touchLocks(src) { - if (src == null) return; - for (const L of this.locks) if (L.src === src) L.idle = 0; - } - - /** The live lock of shooter `src` nearest to (x, y) within `within` tiles (`free`: only one no shell took yet). */ - _nearestLock(src, x, y, within, free) { - let best = null, bd = Infinity; - for (const L of this.locks) { - if (L.out >= 0 || (free && L.shell) || (src != null && L.src != null && L.src !== src)) continue; - const d = Math.hypot(L.x - x, L.y - y); - if (d < bd - 1e-9) { bd = d; best = L; } - } - return best && bd <= within ? best : null; - } - - /** Fade a lock out (it pops outwards as it goes). */ - _releaseLock(L) { if (L && L.out < 0) L.out = 0; } - - _freeLock(L) { - L.ring.visible = L.core.visible = false; - L.view = null; - this.lockFree.push(L); - } - - _updateLocks(dt) { - if (!this.locks.length) return; - const cam = this.ctx.cam(); - let w = 0; - for (const L of this.locks) { - L.t += dt; - L.idle += dt; - if (L.out < 0 && L.idle >= L.max) L.out = 0; - // the shooter knocked out / withdrawn: the sim fires no more shells — only a shell already in the air still lands - if (L.out < 0 && !L.shell && L.src != null && this.ctx.view) { - const sv = this._viewOf(L.src); - if (!sv || sv.alive === false) L.out = 0; - } - if (L.out >= 0) { L.out += dt; if (L.out >= LOCK_FADE) { this._freeLock(L); continue; } } - const v = L.view; - if (v && !v.destroyed && v.alive !== false) { L.x = v.x; L.y = v.y; L.z = bodyZ(cam, v, SHOT_HEIGHT.aim); } - const p = cam.project(L.x, L.y, L.z, this._p); - const s = p.s; - const out = L.out >= 0 ? L.out / LOCK_FADE : 0; - const pop = L.t < 0.16 ? 1.7 - 0.7 * easeOut(L.t / 0.16) : 1 + 0.05 * Math.sin(L.t * 9); - const a = (L.out >= 0 ? 1 - out : Math.min(1, L.t / 0.06)); - L.ring.position.set(p.x, p.y); - L.ring.rotation = L.t * 1.8; - L.ring.scale.set((s * 0.95 * pop * (1 + out * 0.6)) / 128); - L.ring.alpha = 0.95 * a; - L.core.position.set(p.x, p.y); - L.core.scale.set(((s * 0.4) / 128) * (1 + 0.25 * Math.sin(L.t * 14))); - L.core.alpha = 0.7 * a; - this.locks[w++] = L; - } - this.locks.length = w; - } - - // ---- hits / numbers ----------------------------------------------------------------------------------------- - - /** b.ev 'dmg' visual: glow + sparks in the hit colour; a melee blow (atk 'none' just before) adds its slash. */ - damage(view, amount, type, srcView) { - if (!view) return; - const style = dmgStyleKey(type); - const p = this._chest(view); - const px = p.x, py = p.y, s = p.s; - const tint = HIT_TINT[style] || 0xffffff; - const big = view.maxHp > 0 && amount >= view.maxHp * 0.18; - const melee = !!srcView && this._slashAt === srcView.id; - this.particle('glow', px, py, { tint, life: 0.18, s0: (s / 128) * (big ? 1.0 : 0.6), s1: (s / 128) * (big ? 1.5 : 0.9), a0: 0.9, a1: 0 }); - if (melee) { this._slashAt = null; this._slash(view, srcView, px, py, s, style, big); } - this.burst(px, py, s, big ? 8 : melee ? 6 : 4, tint, { speed: melee ? 2.8 : 2.4, size: big ? 0.6 : 0.46 }); - if (srcView && this.ctx.subProfOf && SPLASH_SUBS.has(this.ctx.subProfOf(srcView.info?.defId))) { - if (!this._lastRing || this.time - this._lastRing > 0.08) { - this._lastRing = this.time; - this.ring(view.x, view.y, view.z || 0, 0.1, 1.1, tint, 0.3); - } - } - view.onHit?.(); - if (this.ctx.settings?.damageNumbers !== false) this.number(view, amount, style, big); - } - - /** - * Melee blow on `view` by `src`: a crescent swept across the victim, bulging along the blow (attacker → victim on - * screen, a little random tilt / mirroring), in the hit colour with a white-hot inner stroke. - */ - _slash(view, src, px, py, s, style, big) { - const q = this._chest(src, this._q); - const dx = px - q.x, dy = py - q.y; - const ang = Math.abs(dx) + Math.abs(dy) > 1 ? Math.atan2(dy, dx) : (src.x > view.x ? Math.PI : 0); - const rot = ang + Math.PI / 2 + (Math.random() - 0.5) * 0.9; - const flip = Math.random() < 0.5 ? -1 : 1; - const k = big ? 1.25 : 1; - const a = this.particle('slash', px, py, { tint: style === 'phys' ? 0xffe2b0 : HIT_TINT[style] || 0xffffff, life: 0.2, s0: (s / 128) * 1.05 * k, s1: (s / 128) * 1.3 * k, a0: 1, a1: 0, rot }); - a.sx = flip; - const b = this.particle('slash', px, py, { tint: 0xffffff, life: 0.12, s0: (s / 128) * 0.8 * k, s1: (s / 128) * 1.0 * k, a0: 0.85, a1: 0, rot }); - b.sx = flip; - } - - heal(view, amount) { - if (!view) return; - const p = this._chest(view); - const s = p.s; - this.particle('glow', p.x, p.y, { tint: 0x62f08a, life: 0.3, s0: (s / 128) * 0.5, s1: (s / 128) * 0.9, a0: 0.55, a1: 0 }); - for (let i = 0; i < (this.quality === 'low' ? 1 : 3); i++) { - this.particle('plus', p.x + (Math.random() - 0.5) * s * 0.5, p.y + (Math.random() - 0.2) * s * 0.3, { - tint: 0x7dffa8, vy: -s * 0.9, life: 0.7, s0: s / 64 * 0.32, s1: s / 64 * 0.2, a0: 0.95, a1: 0, fadeIn: 0.08, - }); - } - if (this.ctx.settings?.damageNumbers !== false) this.number(view, amount, 'heal', false); - } - - /** - * A damage / heal number over `view` (see the header). Layout in screen space, against EVERY live number (the - * '41509' / '201625' overlaps were numbers of neighbouring units standing side by side): - * * merge: a same-style hit on the same unit within NUM_MERGE_GAP of that number's last hit (and while it is still - * young) adds to it — a running total that re-pops and lives a little longer; - * * cap: a unit shows at most NUM_PER_TARGET numbers; past it the hit joins the unit's youngest same-style number, - * else the unit's oldest number fades out at once; - * * lanes: the new number takes the lowest free slot of its unit's lanes (centre, then beside the head, 0 / ±1 / ±2 - * lane widths), stacking upwards at most NUM_MAX_LINES lines; a slot is free when no live number of any unit - * overlaps it now or later (a younger number rises faster: below an older one it must keep that one's rise so - * far as a margin) with a gap wide enough that two numbers never read as one; - * * crowded (> NUM_CROWD numbers near it): it lives shorter; a pair that still ends up overlapping (units walking - * into each other) resolves by fading the older one quickly (_updateNums). - */ - number(view, amount, style, big) { - const n = Math.round(amount); - if (!(n > 0) || !view) return; - const now = this.time; - const cam = this.ctx.cam(); - const base = (view.z || 0) + (view.hover || 0) + (view._headTiles || 1.2) * 0.8; - const a = cam.project(view.x, view.y, base, this._p); - const ax = a.x, ay = a.y, s = a.s > 0 ? a.s : 100; - const pxPerZ = ay - cam.project(view.x, view.y, base + 1, this._q).y; - const risePx = NUM_RISE * (pxPerZ > 1e-3 ? pxPerZ : s * 0.5); - // the live boxes only change between frames: laid out once per frame / camera, not once per hit (a heavy AoE - // lands dozens of hits in one frame — re-projecting every live number for each was O(hits × numbers)) - this._layoutNumsOnce(cam); - // 1. merge into this unit's running total of the same style - let mine = 0, youngest = null, oldest = null; - for (const t of this.nums) { - if (t.unit !== view || t.fading) continue; - mine++; - if (!oldest || t.born < oldest.born) oldest = t; - if (t.style === style && (!youngest || t.born > youngest.born)) youngest = t; - } - const merge = youngest && now - youngest.lastHit < NUM_MERGE_GAP && youngest.life < NUM_RISE_T * 0.55 ? youngest - : (mine >= NUM_PER_TARGET && youngest ? youngest : null); - if (merge && this._growFits(merge, n, big)) { - merge.value += n; - merge.text.text = (style === 'heal' ? '+' : '') + merge.value; - merge.pop = 1; - merge.big = merge.big || big; - merge.lastHit = now; - merge.end = Math.min(merge.life + NUM_LIFE * 0.75, Math.max(merge.end, merge.life + NUM_LIFE * 0.6), NUM_MAX_LIFE); - this._sizeNum(merge); - return; - } - if (mine >= NUM_PER_TARGET && oldest) this._fadeNum(oldest, 0.1); - // 2. a free slot among the unit's lanes - const sc = numScale(s, big, style); - const w = numChars(n, style) * NUM_DIGIT_EM * 24 * sc * NUM_POP + NUM_GAP_PX; - const h = 24 * NUM_LINE_EM * sc * NUM_POP; - const lane = Math.max(w, 24 * NUM_DIGIT_EM * sc * 3.2) * 0.62; - let best = null; - for (const k of NUM_LANES) { - const cx = ax + k * lane; - const cy = this._numSlotPx(cx, ay, w, h, risePx); - const lift = ay - cy; - if (!best || lift < best.lift - 0.5) best = { cx, cy, lift, k }; - if (lift <= h * 1.2) break; // low enough: keep the nearest lane - } - const cap = h * NUM_MAX_LINES; - let { cx, cy } = best; - if (ay - cy > cap) { - // over capacity (a knot of units under fire): join this unit's latest same-style total when it fits, else take - // the capped spot and drop whatever is there (crowded numbers give way at once) - let same = null; - for (const t of this.nums) if (t.unit === view && t.style === style && !t.fading && (!same || t.born > same.born)) same = t; - if (same && this._growFits(same, n, big)) { - same.value += n; - same.text.text = (style === 'heal' ? '+' : '') + same.value; - same.pop = 1; - same.big = same.big || big; - same.lastHit = now; - this._sizeNum(same); - return; - } - // (released at once, not merely ended: they would vanish before the next render anyway, and as obstacles they - // made every further hit of the same frame search a crowd that is no longer there — a knot under AoE) - cx = ax; cy = ay - cap; - let keep = 0; - for (const t of this.nums) { - if (Math.abs(t._x - cx) < (t._w + w) / 2 && t._y > cy - h && t._y - t._h < cy) { this._releaseNum(t); continue; } - this.nums[keep++] = t; - } - this.nums.length = keep; - } - // 3. crowding: numbers around this spot → a shorter life for everyone new here - let near = 0; - for (const t of this.nums) if (!t.fading && Math.abs(t._x - cx) < 150 && Math.abs(t._y - cy) < 110) near++; - const life = near >= NUM_CROWD ? NUM_LIFE * 0.62 : NUM_LIFE; - const maxNums = this.load >= 2 ? MAX_NUMBERS >> 1 : MAX_NUMBERS; - while (this.nums.length >= maxNums) this._releaseNum(this.nums.shift()); - const t = this._takeNum(style); - t.text.visible = true; - t.text.alpha = 1; - t.text.text = (style === 'heal' ? '+' : '') + n; - Object.assign(t, { - unit: view, style, value: n, life: 0, end: life, born: now, lastHit: now, pop: 1, big: !!big, fading: false, - ox: cx - ax, oy: cy - ay, x0: view.x, y0: view.y, z0: base, risePx, _x: cx, _y: cy, _w: w, _h: h, - }); - this.nums.push(t); - } - - /** Whether a merged number's grown text still fits among its neighbours (else a new number is made). */ - _growFits(t, add, big) { - const sc = numScale(t._s || 100, t.big || big, t.style); - const w = numChars(t.value + add, t.style) * NUM_DIGIT_EM * 24 * sc * NUM_POP + NUM_GAP_PX; - for (const o of this.nums) { - if (o === t || o.fading) continue; - if (Math.abs(o._x - t._x) < (o._w + w) / 2 && Math.abs(o._y - t._y) < (o._h + t._h) / 2) return false; - } - return true; - } - - /** - * Lowest (largest screen y) bottom for a number of size w×h centred at x, starting at y0, clear of every live number - * now and later: above one it must sit on its top; below one it must leave that one's rise so far as a margin - * (the new number rises faster and would catch up). - */ - _numSlotPx(x, y0, w, h, risePx) { - let y = y0; - for (let pass = 0; pass <= this.nums.length; pass++) { - let moved = false; - for (const t of this.nums) { - if (t.fading && t.text.alpha < 0.3) continue; // almost gone - if (Math.abs(t._x - x) >= (t._w + w) / 2) continue; - const top = t._y - t._h, bottom = t._y; - const risen = t.risePx * easeOut(Math.min(1, t.life / NUM_RISE_T)); - if (y <= top) continue; // entirely above it (bottom at or over its top) - if (y - h >= bottom + risen) continue; // below it, for good - y = top; - moved = true; - } - if (!moved) break; - } - return y; - } - - /** Screen boxes of the live numbers (their current anchor, rise and size) → t._x, t._y (bottom), t._w, t._h. */ - _layoutNums(cam) { - const p = this._p; - for (const t of this.nums) { - const u = t.unit; - if (u && !u.destroyed) { t.x0 = u.x; t.y0 = u.y; } - cam.project(t.x0, t.y0, t.z0, p); - t._s = p.s; - const k = Math.min(1, t.life / NUM_RISE_T); - t._x = p.x + t.ox; - t._y = p.y + t.oy - t.risePx * easeOut(k); - this._sizeNum(t); - } - this._laidAt = this.time; - this._laidCam = cam; - this._laidKey = camKey(cam); - } - - /** `_layoutNums` unless the boxes are already current: same frame (fx time) and the same camera framing. */ - _layoutNumsOnce(cam) { - if (this._laidAt === this.time && this._laidCam === cam && this._laidKey === camKey(cam)) return; - this._layoutNums(cam); - } - - /** A number's box size from its value, style, pop and the scale of its last layout (t._s px per tile). */ - _sizeNum(t) { - const sc = numScale(t._s || 100, t.big, t.style); - const pop = 1 + t.pop * (NUM_POP - 1); - t._w = numChars(t.value, t.style) * NUM_DIGIT_EM * 24 * sc * pop + NUM_GAP_PX; - t._h = 24 * NUM_LINE_EM * sc * pop; - t._sc = sc; - } - - _fadeNum(t, within) { - if (t.fading) return; - t.fading = true; - t.fadeFrom = t.text.alpha; - t.end = Math.min(t.end, t.life + within); - t.fadeT0 = t.life; - } - - _takeNum(style) { - const P = this.P; - const font = DMG_STYLE[style]?.font || DMG_STYLE.phys.font; - const pool = this._numPools || (this._numPools = new Map()); - const list = pool.get(font); - let t = list && list.length ? list.pop() : null; - if (!t) { - const text = new P.BitmapText('0', { fontName: font, fontSize: 24, align: 'center' }); - text.anchor.set(0.5, 1); - this.ctx.layers.text.addChild(text); - t = { text, font }; - } - return t; - } - - _releaseNum(t) { - t.text.visible = false; - t.unit = null; - const pool = this._numPools || (this._numPools = new Map()); - let list = pool.get(t.font); - if (!list) pool.set(t.font, list = []); - if (list.length < 30) list.push(t); else t.text.destroy(); - } - - _updateNums(dt) { - const cam = this.ctx.cam(); - let w = 0; - for (const t of this.nums) { - t.life += dt; - if (t.life >= t.end) { this._releaseNum(t); continue; } - t.pop = Math.max(0, t.pop - dt * 6); - this.nums[w++] = t; - } - this.nums.length = w; - this._layoutNums(cam); - // a pair still overlapping (their units walked into each other): the older one gives way - const L = this.nums; - for (let i = 0; i < L.length; i++) { - const a = L[i]; - if (a.fading) continue; - for (let j = i + 1; j < L.length; j++) { - const b = L[j]; - if (b.fading) continue; - if (Math.abs(a._x - b._x) < (a._w + b._w) / 2 - NUM_GAP_PX * 0.5 && Math.abs((a._y - a._h / 2) - (b._y - b._h / 2)) < (a._h + b._h) / 2 * 0.9) { - this._fadeNum(a.born <= b.born ? a : b, 0.06); - if (a.fading) break; - } - } - } - for (const t of L) { - const tx = t.text; - tx.scale.set(t._sc * (1 + t.pop * (NUM_POP - 1))); - tx.position.set(t._x, t._y); - let alpha = 1; - const left = t.end - t.life; - if (t.fading) alpha = (t.fadeFrom ?? 1) * clamp(left / Math.max(0.01, t.end - t.fadeT0), 0, 1); - else if (left < NUM_FADE_T) alpha = left / NUM_FADE_T; - tx.alpha = alpha; - } - } - - // ---- rings / auras ----------------------------------------------------------------------------------------- - - /** - * Ground ring going from r0 to r1 tiles over `dur` real seconds (easing out, fading). `mode` 'in': a warning closing - * in — linear, brightening towards its end; 'pulse': throbbing at full size until its end. - */ - ring(x, y, z, r0, r1, tint, dur = 0.4, tex = 'ring', mode = null) { - const P = this.P; - let r = this.ringFree.pop(); - if (!r) { - const sp = new P.Sprite(this.tex[tex]); - sp.anchor.set(0.5); - sp.blendMode = P.BLEND_MODES.ADD; - this.ctx.layers.groundFx.addChild(sp); - r = { sp }; - } - r.sp.texture = this.tex[tex] || this.tex.ring; - r.sp.visible = true; - r.sp.tint = tint; - r.sp.alpha = 0; - this._onGround(r.sp, y, z); - r.x = x; r.y = y; r.z = z; r.r0 = r0; r.r1 = r1; r.t = 0; r.dur = Math.max(0.01, dur); r.mode = mode; - this.rings.push(r); - if (this.rings.length > 80) { const o = this.rings.shift(); o.sp.visible = false; this.ringFree.push(o); } - } - - _updateRings(dt) { - const cam = this.ctx.cam(); - const p = this._p, q = this._q; - let w = 0; - for (const r of this.rings) { - r.t += dt; - if (r.t >= r.dur) { r.sp.visible = false; this.ringFree.push(r); continue; } - const u = r.t / r.dur; - const k = r.mode === 'in' ? u : easeOut(u); - const rad = r.r0 + (r.r1 - r.r0) * k; - cam.project(r.x, r.y, r.z + 0.01, p); - cam.project(r.x, r.y + rad, r.z + 0.01, q); - const rx = p.s * rad, ry = Math.max(1, p.y - q.y); - r.sp.position.set(p.x, p.y); - r.sp.scale.set((rx * 2) / 128, (ry * 2) / 128); - r.sp.alpha = r.mode === 'in' ? 0.35 + 0.65 * u - : r.mode === 'pulse' ? (0.35 + 0.35 * Math.abs(Math.sin(r.t * 9))) * Math.min(1, (r.dur - r.t) / 0.06, r.t / 0.06) - : 1 - u; - this.rings[w++] = r; - } - this.rings.length = w; - } - - /** - * Skill activation (on) / end (off). On: a flash at the body, a gold light pillar with a white-hot core from the - * feet, a shockwave and a hex ring on the ground, rising motes — then the active aura (_aura) until it ends. - */ - skill(view, on) { - if (!view) return; - if (!on) { this._aura(view, false); return; } - const z = (view.z || 0) + (view.hover || 0); - const g = this._proj(view.x, view.y, z, this._g); - const gx = g.x, gy = g.y, s = g.s; - const c = this._chest(view, this._q); - this.particle('pillar', gx, gy, { tint: SKILL_GOLD, life: 0.7, s0: (s / 64) * 1.05, s1: (s / 64) * 1.3, a0: 0.95, a1: 0, sx: 0.8, ay: 1 }); - this.particle('pillar', gx, gy, { tint: 0xffffff, life: 0.38, s0: (s / 64) * 0.9, s1: (s / 64) * 1.15, a0: 0.9, a1: 0, sx: 0.28, ay: 1 }); - this.particle('flare', c.x, c.y, { tint: 0xfff0b0, life: 0.3, s0: (s / 128) * 1.9, s1: (s / 128) * 0.6, a0: 1, a1: 0, rot: Math.random() }); - this.particle('glow', c.x, c.y, { tint: SKILL_GOLD, life: 0.36, s0: (s / 128) * 1.2, s1: (s / 128) * 2.4, a0: 0.9, a1: 0 }); - this.ring(view.x, view.y, z, 0.15, 1.7, 0xffe7a0, 0.45, 'shock'); - this.ring(view.x, view.y, z, 0.3, 1.25, SKILL_GOLD, 0.6, 'hex'); - this.burst(c.x, c.y, s, this.rich ? 10 : 4, 0xffe28a, { speed: 1.4, up: 1.6, life: 0.7, tex: 'dot', size: 0.34 }); - this._aura(view, true); - } - - /** Active-skill aura: a soft gold glow and a slowly turning hex on the ground under the unit (fading in / out). */ - _aura(view, on) { - const P = this.P; - let a = this.auras.get(view.id); - if (on) { - if (!a) { - // the root is squashed onto the ground; the hex turns inside it (so it turns in the ground plane) - const root = new P.Container(); - const disc = new P.Sprite(this.tex.soft); - disc.anchor.set(0.5); disc.blendMode = P.BLEND_MODES.ADD; disc.tint = SKILL_GOLD; - const hex = new P.Sprite(this.tex.hex); - hex.anchor.set(0.5); hex.blendMode = P.BLEND_MODES.ADD; hex.tint = 0xffc94a; - root.addChild(disc, hex); - root.alpha = 0; - this.ctx.layers.groundFx.addChild(root); - a = { sp: root, disc, hex, view, t: 0, mote: 0 }; - this.auras.set(view.id, a); - } - a.view = view; - a.off = false; - } else if (a) a.off = true; - } - - _updateAuras(dt) { - if (!this.auras.size) return; - const cam = this.ctx.cam(); - const p = this._p, q = this._q; - const rich = this.rich; - for (const [id, a] of this.auras) { - a.t += dt; - const v = a.view; - const ending = a.off || !v || v.destroyed || v.alive === false; - if (ending) { - a.sp.alpha -= dt * 3; - if (a.sp.alpha <= 0) { a.sp.destroy({ children: true }); this.auras.delete(id); continue; } - } else a.sp.alpha = Math.min(1, a.sp.alpha + dt * 5); - if (!v || v.destroyed) continue; - const z = (v.z || 0) + 0.01; - this._onGround(a.sp, v.y, v.z || 0); - cam.project(v.x, v.y, z, p); - cam.project(v.x, v.y + 0.55, z, q); - a.sp.position.set(p.x, p.y); - a.sp.scale.set((p.s * 0.95) / 128, (Math.max(1, p.y - q.y) * 1.72) / 128); - a.hex.rotation = a.t * 0.9; - a.hex.alpha = 0.8 + 0.2 * Math.sin(a.t * 4); - a.disc.alpha = 0.42 + 0.1 * Math.sin(a.t * 4); - // now and then a mote rises from the ring - if (rich && !ending && (a.mote += dt) >= 0.3) { - a.mote = 0; - if (this._room()) { - const ang = Math.random() * Math.PI * 2; - const m = cam.project(v.x + Math.cos(ang) * 0.38, v.y + Math.sin(ang) * 0.3, z, this._g); - const o = this._o(); - o.tint = 0xffe28a; o.vy = -m.s * 0.9; o.life = 0.75; o.s0 = (m.s / 32) * 0.14; o.s1 = 0; o.a0 = 0.9; o.fadeIn = 0.1; - this.particle('dot', m.x, m.y, o); - } - } - } - } - - deploy(view) { - if (!view) return; - const p = this._proj(view.x, view.y, view.z || 0); - const s = p.s; - const col = view.isEnemy ? 0xff6a5a : 0x9ff0dc; - this.particle('pillar', p.x, p.y, { tint: col, life: 0.45, s0: s / 64 * 0.7, s1: s / 64 * 0.2, a0: 0.9, a1: 0, sx: 1, ay: 1 }); - this.ring(view.x, view.y, view.z || 0, 0.1, 0.8, col, 0.45); - this.burst(p.x, p.y, s, 6, col, { speed: 1.6, up: 0.4, life: 0.4, tex: 'dot' }); - } - - /** - * Promotion (精锐晋升, a merge) at the elite's spot — the board tile of the consumed copy it replaced (PRTS 卫戍协议/ - * 帮助 "若消耗已部署至作战区的干员,则发送至作战区对应位置") or its bench slot: a gold pillar with a white core, a shockwave - * and a hex ring on the ground, rising motes; `from` = world points of the other consumed copies (gold streaks from - * them to the elite). Counted in `promotions` (render/app.js setPrep; tests). - * @param {any} view - * @param {Array<{ x: number, y: number, z?: number }>} [from] - */ - promote(view, from = []) { - if (!view) return; - this.promotions = (this.promotions || 0) + 1; - const z = view.z || 0; - const g = this._proj(view.x, view.y, z, this._g); - const gx = g.x, gy = g.y, s = g.s; - for (const f of from) if (f && Number.isFinite(f.x) && Number.isFinite(f.y)) this.streak(f.x, f.y, view.x, view.y, Math.max(z, f.z || 0) + 0.3, SKILL_GOLD, 0.45); - this.particle('pillar', gx, gy, { tint: SKILL_GOLD, life: 0.8, s0: (s / 64) * 1.1, s1: (s / 64) * 1.4, a0: 0.95, a1: 0, sx: 0.85, ay: 1 }); - this.particle('pillar', gx, gy, { tint: 0xffffff, life: 0.42, s0: (s / 64) * 0.9, s1: (s / 64) * 1.2, a0: 0.9, a1: 0, sx: 0.3, ay: 1 }); - this.ring(view.x, view.y, z, 0.15, 1.6, 0xffe7a0, 0.5, 'shock'); - this.ring(view.x, view.y, z, 0.3, 1.2, SKILL_GOLD, 0.7, 'hex'); - const c = this._chest(view, this._q); - this.burst(c.x, c.y, s, this.rich ? 12 : 5, 0xffe28a, { speed: 1.5, up: 1.8, life: 0.8, tex: 'dot', size: 0.36 }); - } - - death(view) { - if (!view) return; - const p = this._chest(view); - const s = p.s; - const col = view.isEnemy ? 0xff7a52 : 0xbfeee2; - const n = this.quality === 'low' ? 5 : 12; - for (let i = 0; i < n; i++) { - this.particle(i % 3 ? 'dot' : 'shard', p.x + (Math.random() - 0.5) * s * 0.5, p.y + (Math.random() - 0.3) * s * 0.6, { - tint: col, vx: (Math.random() - 0.5) * s * 0.6, vy: -s * (0.4 + Math.random() * 0.8), drag: 1.5, - life: 0.7 + Math.random() * 0.5, s0: s / 32 * 0.12, s1: 0, a0: 0.9, a1: 0, spin: (Math.random() - 0.5) * 6, - }); - } - this.particle('smoke', p.x, p.y, { add: false, tint: 0x1a1a1a, life: 0.8, s0: s / 128 * 0.6, s1: s / 128 * 1.4, a0: 0.5, a1: 0 }); - } - - crateBreak(x, y, z, tint = null) { - const p = this._proj(x, y, (z || 0) + 0.35); - const s = p.s; - for (let i = 0; i < (this.quality === 'low' ? 4 : 10); i++) { - this.particle('shard', p.x, p.y, { - add: false, tint: tint ?? (i % 2 ? 0xc89a5a : 0x9a6d38), vx: (Math.random() - 0.5) * s * 2.2, vy: -s * (0.6 + Math.random()), g: s * 4, - life: 0.6, s0: s / 32 * 0.25, s1: s / 32 * 0.15, a0: 1, a1: 0.2, spin: (Math.random() - 0.5) * 14, - }); - } - this.particle('smoke', p.x, p.y, { add: false, tint: 0x8a7a60, life: 0.7, s0: s / 128 * 0.6, s1: s / 128 * 1.5, a0: 0.45, a1: 0 }); - } - - // ---- sim fx (b.ev ['fx', kind, x, y, extra]) ------------------------------------------------------------- - - /** Unit view of an fx's `id` / `src` / … (null when unknown or gone). */ - _viewOf(id) { - if (id == null || !this.ctx.view) return null; - const v = this.ctx.view(id); - return v && !v.destroyed ? v : null; - } - - /** - * Where an fx happens: on its anchor unit `ex.id` (at the unit's rendered position, so it sticks to a moving unit) - * when the event's (x, y) is that unit's spot (within ANCHOR_SNAP tiles) or has none; otherwise at (x, y) on the ground - * — the sim often names the caster in `id` of an effect elsewhere ('aoe' at a target or ahead of the caster, 'crit' - * on the victim, 'zone' where a skill lands), which used to be drawn on the caster. - */ - _where(x, y, ex) { - const v = this._viewOf(ex.id); - if (v && Number.isFinite(v.x) && Number.isFinite(v.y)) { - const here = !Number.isFinite(x) || !Number.isFinite(y) || Math.hypot(v.x - x, v.y - y) <= ANCHOR_SNAP; - if (here) return { x: v.x, y: v.y, z: (v.z || 0) + (v.hover || 0), v }; - } - return this._point(x, y); - } - - /** An fx at the sim position (x, y), on the ground there. */ - _point(x, y) { - return { x, y, z: Number.isFinite(x) && Number.isFinite(y) ? this._groundZ(x, y) : 0, v: null }; - } - - /** - * b.ev 'fx': every kind the sim / content emits has a visual (FX_KINDS archetypes: blast, shell, zone, telegraph, - * heal, sp, shield, shatter, summon, vanish, blink, move, wave, mark, reticle, buff, lift, sleep, crit, dodge, - * counter, dp, coin, crate, down, beam, bolt, strike, volley, pillar, lp, chill, element, flame), except kinds whose - * archetype is 'none' (hitCap: a leader hit cancelled by 限伤 draws nothing); unknown kinds get a generic sparkle. `extra` keys used: id (anchor unit — or the shooter of a `pt` kind), r | radius, dur | duration, - * t (shell flight, game s), src / from / to / target / targets (unit ids), fx, fy / fromX, fromY / tx, ty (positions), - * element, n, scale, kind, tiles. - */ - simFx(kind, x, y, extra) { - const ex = extra && typeof extra === 'object' ? extra : {}; - const spec = fxSpec(kind, ex); - if (spec.a === 'none') return; // an event the screen does not show (hitCap) - const at = spec.pt ? this._point(Number(x), Number(y)) : this._where(Number(x), Number(y), ex); - if (!Number.isFinite(at.x) || !Number.isFinite(at.y)) return; - // 蕾缪安 S2: the aimed snipe ('crit' on the locked enemy, from her) ends that aim lock - if (kind === 'crit' && ex.src != null && ex.id != null) { - for (const L of this.locks) if (L.src === ex.src && L.id === ex.id) this._releaseLock(L); - } - const col = spec.c; - const r = clamp(num(ex.r ?? ex.radius, spec.r ?? 1), 0.3, 30); - const ts = this.ctx.timeScale ? Math.max(0.25, this.ctx.timeScale()) : 2; - const dur = num(ex.dur ?? ex.duration, spec.dur ?? 0) / ts; - const cam = this.ctx.cam(); - const chest = (v, out = this._p) => (v ? this._chest(v, out) : cam.project(at.x, at.y, at.z + 0.5, out)); - const p = chest(at.v); - const s = p.s; - switch (spec.a) { - case 'blast': { - if (r >= 12) { this.flashScreen(col, 0.5); break; } - if (kind === 'bombard') { this._touchLocks(ex.id ?? ex.src ?? null); this._landed(ex.id ?? ex.src ?? null, at.x, at.y, r); } - this.explosion(at.x, at.y, at.z, r, col, { smoke: spec.smoke, heavy: !!spec.heavy, tiles: ex.tiles ? tilesAround(at.x, at.y, r, ex.tiles) : null }); - break; - } - case 'shell': { - // 蕾缪安 S3: a shell fired now by `id` that lands at (x, y) after `t` game seconds (its 'bombard' explodes there) - const flight = num(ex.t ?? ex.flight ?? ex.dur ?? ex.duration, 1) / ts; - this._touchLocks(ex.id ?? ex.src ?? null); - this.mortar(this._viewOf(ex.id ?? ex.src), at.x, at.y, r, flight); - break; - } - case 'zone': this.zone(at.x, at.y, at.z, r, col, Math.max(0.6, dur || 1.5), spec.tex); break; - case 'wall': { - // a line of burning tiles through the anchor tile along `axis` ('col' | 'row', from the sim event) - const rect = this.ctx.fieldRect ? this.ctx.fieldRect() : null; - this.tileFlash(wallTiles(Number(x), Number(y), ex.axis === 'row' ? 'row' : 'col', rect), col, Math.max(0.6, dur || 1.5)); - this.zone(at.x, at.y, at.z, 0.6, col, Math.max(0.6, dur || 1.5), spec.tex); - break; - } - case 'telegraph': { - const d = Math.max(0.4, dur || 1); - if (r >= 12) { this.flashScreen(col, 0.45, d); break; } - if (ex.tiles) this.tileFlash(tilesAround(at.x, at.y, r, ex.tiles), col, d, true); - else this.zone(at.x, at.y, at.z, r, col, d, 'ring', true); - break; - } - case 'chill': this.flashScreen(col, 0.35, 0.8); this.snowfall(col); break; - case 'heal': this.heal(at.v || { x: at.x, y: at.y, z: at.z }, 0); break; - case 'healAoe': { - this.ring(at.x, at.y, at.z, 0.2, r, col, 0.6); - const n = this.quality === 'low' ? 4 : 9; - for (let i = 0; i < n; i++) { - const a = Math.random() * Math.PI * 2, d = Math.random() * r * 0.85; - const q = cam.project(at.x + Math.cos(a) * d, at.y + Math.sin(a) * d, at.z + 0.2); - this.particle('plus', q.x, q.y, { tint: col, vy: -q.s * 0.9, life: 0.8, s0: q.s / 64 * 0.3, s1: q.s / 64 * 0.18, a0: 0.9, a1: 0, fadeIn: 0.1 }); - } - break; - } - case 'sp': { - for (let i = 0; i < (this.quality === 'low' ? 3 : 7); i++) { - this.particle('dot', p.x + (Math.random() - 0.5) * s * 0.6, p.y + (Math.random() - 0.2) * s * 0.4, { tint: col, vy: -s * (0.6 + Math.random() * 0.6), life: 0.7, s0: s / 32 * 0.2, s1: 0, a0: 1, a1: 0, fadeIn: 0.05 }); - } - this.particle('glow', p.x, p.y, { tint: col, life: 0.3, s0: s / 128 * 0.6, s1: s / 128 * 1.1, a0: 0.7, a1: 0 }); - break; - } - case 'shield': { - this.particle('hex', p.x, p.y, { tint: col, life: 0.55, s0: s / 128 * 0.9, s1: s / 128 * 1.25, a0: 0.85, a1: 0, sx: 0.85 }); - this.particle('glow', p.x, p.y, { tint: col, life: 0.4, s0: s / 128 * 0.9, s1: s / 128 * 1.3, a0: 0.5, a1: 0 }); - break; - } - case 'shatter': { - this.particle('hex', p.x, p.y, { tint: col, life: 0.25, s0: s / 128 * 1.1, s1: s / 128 * 1.5, a0: 0.9, a1: 0 }); - for (let i = 0; i < (this.quality === 'low' ? 5 : 12); i++) { - const a = Math.random() * Math.PI * 2; - this.particle('shard', p.x + Math.cos(a) * s * 0.3, p.y + Math.sin(a) * s * 0.3, { tint: col, vx: Math.cos(a) * s * 1.6, vy: Math.sin(a) * s * 1.2 - s * 0.3, g: s * 3, life: 0.55, s0: s / 32 * 0.16, s1: s / 32 * 0.05, a0: 1, a1: 0, spin: (Math.random() - 0.5) * 12 }); - } - break; - } - case 'summon': { - const g = cam.project(at.x, at.y, at.z); - this.particle('pillar', g.x, g.y, { tint: col, life: 0.55, s0: g.s / 64 * 0.8, s1: g.s / 64 * 0.3, a0: 0.9, a1: 0, sx: 1, ay: 1 }); - this.ring(at.x, at.y, at.z, 0.1, 0.9, col, 0.5, 'hex'); - this.burst(g.x, g.y - g.s * 0.3, g.s, 8, col, { speed: 1.4, up: 0.8, life: 0.5, tex: 'dot' }); - break; - } - case 'vanish': { - this.particle('glow', p.x, p.y, { tint: col, life: 0.4, s0: s / 128 * 0.7, s1: s / 128 * 1.5, a0: 0.8, a1: 0 }); - this.smoke(p.x, p.y, s * 0.7, 0x2c2436, 0.5); - this.burst(p.x, p.y, s, 7, col, { speed: 1.5, tex: 'dot', life: 0.45 }); - break; - } - case 'blink': { - const fx0 = num(ex.fx ?? ex.fromX, NaN), fy0 = num(ex.fy ?? ex.fromY, NaN); - if (Number.isFinite(fx0) && Number.isFinite(fy0)) { - const q = cam.project(fx0, fy0, at.z + 0.5, this._q); - this.smoke(q.x, q.y, q.s * 0.6, 0x2c2436, 0.45); - this.particle('glow', q.x, q.y, { tint: col, life: 0.3, s0: q.s / 128 * 0.8, s1: q.s / 128 * 0.2, a0: 0.8, a1: 0 }); - this.streak(fx0, fy0, at.x, at.y, at.z + 0.5, col, 0.3); - } - this.particle('glow', p.x, p.y, { tint: col, life: 0.35, s0: s / 128 * 0.3, s1: s / 128 * 1.2, a0: 0.9, a1: 0 }); - this.burst(p.x, p.y, s, 6, col, { speed: 1.4, tex: 'dot', life: 0.4 }); - break; - } - case 'move': { - const fx0 = num(ex.fromX ?? ex.fx ?? ex.x0, NaN), fy0 = num(ex.fromY ?? ex.fy ?? ex.y0, NaN); - const tx = num(ex.tx, NaN), ty = num(ex.ty, NaN); - if (Number.isFinite(fx0) && Number.isFinite(fy0)) this.streak(fx0, fy0, at.x, at.y, at.z + 0.35, col, 0.35); - else if (Number.isFinite(tx) && Number.isFinite(ty)) this.streak(at.x, at.y, tx, ty, at.z + 0.35, col, 0.35); - const g = cam.project(at.x, at.y, at.z + 0.05); - for (let i = 0; i < (this.quality === 'low' ? 2 : 5); i++) { - this.particle('smoke', g.x + (Math.random() - 0.5) * g.s * 0.5, g.y, { add: false, tint: 0x6b6358, vx: (Math.random() - 0.5) * g.s * 0.6, vy: -g.s * 0.2, drag: 2, life: 0.5, s0: g.s / 128 * 0.25, s1: g.s / 128 * 0.55, a0: 0.45, a1: 0 }); - } - break; - } - case 'wave': { - const k = clamp(num(ex.scale, 1), 0.5, 4); - const rr = Math.max(0.8, r * (ex.scale ? Math.min(2, k / 2) : 1)); - for (let i = 0; i < 3; i++) this.ring(at.x, at.y, at.z, 0.15 + i * 0.15, rr * (0.7 + i * 0.25), col, 0.45 + i * 0.12); - this.particle('glow', p.x, p.y, { tint: col, life: 0.3, s0: s / 128 * 0.5, s1: s / 128 * 1.2, a0: 0.7, a1: 0 }); - break; - } - case 'mark': case 'reticle': { - if (kind === 'lock') { - // `id` is always the locked enemy: the reticle sticks to its view even a little off the event's spot - const lv = at.v || this._viewOf(ex.id); - this._touchLocks(ex.src ?? null); - this._lock(lv, ex.src ?? null, lv ? lv.x : at.x, lv ? lv.y : at.y, lv ? bodyZ(cam, lv, SHOT_HEIGHT.aim) : at.z + 0.55); - break; - } - const v = at.v; - const hz = v ? (v.z || 0) + (v.hover || 0) + (v._headTiles || 1.2) + 0.25 : at.z + 1.4; - const q = cam.project(at.x, at.y, hz, this._q); - if (spec.a === 'mark') { - this.particle('glow', q.x, q.y, { tint: col, life: 0.6, s0: q.s / 128 * 0.5, s1: q.s / 128 * 0.7, a0: 0.8, a1: 0 }); - this.numberAt(q.x, q.y, '!', col, 0.8); - } else { - this.particle('ring', p.x, p.y, { tint: col, life: 0.6, s0: s / 128 * 1.3, s1: s / 128 * 0.7, a0: 0.95, a1: 0, spin: 3 }); - this.particle('hex', p.x, p.y, { tint: col, life: 0.6, s0: s / 128 * 0.5, s1: s / 128 * 0.9, a0: 0.8, a1: 0, spin: -2 }); - } - break; - } - case 'buff': { - const g = cam.project(at.x, at.y, at.z + 0.05); - const n = this.quality === 'low' ? 3 : 6; - for (let i = 0; i < n; i++) { - const ox = (Math.random() - 0.5) * g.s * 0.7; - this.particle('chevron', g.x + ox, g.y - g.s * (0.1 + Math.random() * 0.5), { tint: col, vy: -g.s * (1 + Math.random() * 0.5), life: 0.65, s0: g.s / 64 * 0.22, s1: g.s / 64 * 0.12, a0: 0.95, a1: 0, rot: -Math.PI / 2, fadeIn: 0.06 }); - } - this.ring(at.x, at.y, at.z, 0.2, 0.75, col, 0.4); - this.particle('glow', p.x, p.y, { tint: col, life: 0.35, s0: s / 128 * 0.6, s1: s / 128 * 1.1, a0: 0.6, a1: 0 }); - break; - } - case 'lift': { - const g = cam.project(at.x, at.y, at.z); - this.ring(at.x, at.y, at.z, 0.2, 0.8, col, 0.5); - for (let i = 0; i < (this.quality === 'low' ? 3 : 7); i++) { - this.particle('streak', g.x + (Math.random() - 0.5) * g.s * 0.6, g.y - g.s * Math.random() * 0.4, { tint: col, vy: -g.s * 1.8, life: 0.4, s0: g.s / 128 * 0.4, s1: g.s / 128 * 0.2, a0: 0.8, a1: 0, rot: -Math.PI / 2, sx: 1 }); - } - break; - } - case 'sleep': { - const v = at.v; - const hz = v ? (v.z || 0) + (v._headTiles || 1.2) : at.z + 1.2; - const q = cam.project(at.x + 0.2, at.y, hz, this._q); - for (let i = 0; i < 3; i++) this.particle('st_sleep', q.x + i * q.s * 0.12, q.y - i * q.s * 0.12, { add: false, vy: -q.s * 0.5, vx: q.s * 0.15, life: 0.9 + i * 0.2, s0: q.s / 32 * 0.22, s1: q.s / 32 * 0.32, a0: 1, a1: 0, fadeIn: 0.1 * i }); - break; - } - case 'crit': { - this.particle('spark', p.x, p.y, { tint: col, life: 0.28, s0: s / 64 * 1.3, s1: s / 64 * 0.2, a0: 1, a1: 0, rot: Math.random() * Math.PI }); - this.particle('glow', p.x, p.y, { tint: 0xffffff, life: 0.18, s0: s / 128 * 0.9, s1: s / 128 * 1.4, a0: 0.9, a1: 0 }); - this.burst(p.x, p.y, s, 8, col, { speed: 3.2, life: 0.3, size: 0.5 }); - break; - } - case 'dodge': { - this.particle('soft', p.x, p.y, { tint: 0xffffff, life: 0.3, s0: s / 128 * 0.5, s1: s / 128 * 0.9, a0: 0.5, a1: 0 }); - this.particle('slash', p.x, p.y, { tint: col, life: 0.22, s0: s / 128 * 0.8, s1: s / 128 * 1.1, a0: 0.7, a1: 0, rot: -0.5 }); - break; - } - case 'counter': { - this.particle('spark', p.x, p.y, { tint: col, life: 0.22, s0: s / 64 * 0.9, s1: 0, a0: 1, a1: 0 }); - this.particle('slash', p.x, p.y, { tint: col, life: 0.22, s0: s / 128 * 0.9, s1: s / 128 * 1.2, a0: 0.9, a1: 0, rot: 0.6 }); - this.burst(p.x, p.y, s, 4, col, { speed: 2.4, life: 0.25 }); - break; - } - case 'dp': { - const n = Math.round(num(ex.n, 0)); - this.particle('shard', p.x, p.y, { tint: col, vy: -s * 0.6, life: 0.9, s0: s / 32 * 0.28, s1: s / 32 * 0.22, a0: 1, a1: 0 }); - if (n > 0) this.numberAt(p.x + s * 0.2, p.y, `+${n}`, col, 0.7); - break; - } - case 'coin': { - for (let i = 0; i < 4; i++) this.particle('coin', p.x + (Math.random() - 0.5) * s * 0.4, p.y, { add: false, vx: (Math.random() - 0.5) * s, vy: -s * (1.2 + Math.random() * 0.6), g: s * 4, life: 0.7, s0: s / 48 * 0.2, s1: s / 48 * 0.16, a0: 1, a1: 0.2 }); - break; - } - case 'crate': this.crateBreak(at.x, at.y, at.z); break; - case 'down': { - this.smoke(p.x, p.y, s * 0.6, spec.smoke ?? 0x1a1a1a, 0.5); - this.burst(p.x, p.y, s, 5, col, { speed: 1.2, tex: 'dot', life: 0.5, g: 2 }); - break; - } - case 'beam': case 'bolt': { - const a = this._viewOf(ex.from ?? ex.src) || (spec.a === 'bolt' ? null : at.v); - const b = this._viewOf(ex.to ?? ex.target) || (a === at.v ? null : at.v); - if (a && b && a !== b) this._beam(a, b, col, spec.a === 'bolt' ? 0.28 : 0.4, spec.a === 'bolt' ? 1 : 0.4); - else this.strike(at.x, at.y, at.z, col); - break; - } - case 'flame': { - // at the event's spot (the locked target), not snapped onto the dragon hovering 0.25 tile from it - const fx0 = Number(x), fy0 = Number(y); - this._flame(ex.id ?? ex.src ?? null, ex.target ?? ex.to ?? null, fx0, fy0, r, col, Math.max(0.2, dur || 0.5)); - break; - } - case 'strike': case 'pillar': this.strike(at.x, at.y, at.z, col, spec.a === 'pillar'); break; - case 'volley': { - const list = Array.isArray(ex.targets) ? ex.targets.slice(0, 12) : []; - const src = this._viewOf(ex.src ?? ex.id); - for (const id of list) { - const t = this._viewOf(id); - if (!t) continue; - if (src && src !== t) this.attack(src, t, 'arrow'); - else { const q = this._chest(t, this._q); this.burst(q.x, q.y, q.s, 4, col, { speed: 2, life: 0.3 }); } - } - if (!list.length) this.burst(p.x, p.y, s, 6, col, { speed: 2.4, life: 0.35 }); - break; - } - case 'lp': this.leak(); break; - case 'element': { - const el = ex.element; - const c2 = el === 'burn' ? 0xff7a33 : el === 'neural' ? 0xff5ad0 : el === 'necrosis' || el === 'apoptosis' ? 0x9dff6a : el === 'erosion' ? 0x6fe0ff : col; - this.particle('glow', p.x, p.y, { tint: c2, life: 0.45, s0: s / 128 * 1.2, s1: s / 128 * 2.6, a0: 1, a1: 0 }); - this.ring(at.x, at.y, at.z, 0.2, 1.5, c2, 0.55); - this.burst(p.x, p.y, s, 12, c2, { speed: 3, life: 0.5 }); - break; - } - default: { - this.particle('spark', p.x, p.y, { tint: col, life: 0.35, s0: s / 64 * 0.6, s1: 0, a0: 0.9, a1: 0, rot: Math.random() }); - this.particle('glow', p.x, p.y, { tint: col, life: 0.3, s0: s / 128 * 0.4, s1: s / 128 * 0.9, a0: 0.6, a1: 0 }); - this.burst(p.x, p.y, s, 4, col, { speed: 1.6, tex: 'dot', life: 0.35 }); - } - } - } - - /** - * An explosion on the ground at (x, y, z) of radius r tiles: fireball in `col`, a white flash, a shockwave and a - * coloured ring on the ground, sparks and smoke. o.heavy (bombard, airstrike …): longer, more sparks, debris flying - * and a scorch mark; o.small (shell impacts): no coloured ring, fewer sparks; o.tiles: flash those tiles too. - */ - explosion(x, y, z, r, col, o = NO_OPTS) { - const cam = this.ctx.cam(); - const g = cam.project(x, y, z + 0.3, this._g); - const gx = g.x, gy = g.y, s = g.s; - const R = Math.max(0.4, r), heavy = !!o.heavy, small = !!o.small, rich = this.rich; - this.particle('glow', gx, gy, { tint: col, life: heavy ? 0.5 : small ? 0.3 : 0.4, s0: (s / 128) * (0.7 + R * 0.7), s1: (s / 128) * (1.2 + R * 1.2), a0: 1, a1: 0 }); - this.particle('flare', gx, gy, { tint: 0xfff4e0, life: heavy ? 0.28 : 0.18, s0: (s / 128) * (0.9 + R * 0.7), s1: (s / 128) * (0.3 + R * 0.2), a0: 1, a1: 0, rot: Math.random() * 3 }); - this.ring(x, y, z, 0.1, R * 1.1, 0xfff0d8, heavy ? 0.42 : 0.3, 'shock'); - if (!small) this.ring(x, y, z, 0.15, R, col, heavy ? 0.6 : 0.45); - this.burst(gx, gy, s, Math.round((small ? 4 : 6) + R * (heavy ? 6 : 3)), col, { speed: 1.8 + R, life: heavy ? 0.6 : 0.45, up: 0.5 }); - this.smoke(gx, gy - s * 0.15, s * (0.35 + R * 0.35), o.smoke ?? 0x2a2522, heavy ? 0.5 : 0.35); - if (rich && heavy) { - // debris: dark chunks and hot embers thrown up, falling back - for (let i = 0; i < 8; i++) { - const a = -Math.PI * (0.15 + Math.random() * 0.7), v = s * (1.6 + Math.random() * 1.8); - const hot = i % 2 === 0; - this.particle(hot ? 'dot' : 'shard', gx, gy, { - add: hot, tint: hot ? col : 0x2e2620, vx: Math.cos(a) * v, vy: Math.sin(a) * v, g: s * 5, life: 0.55 + Math.random() * 0.25, - s0: (s / 32) * (hot ? 0.16 : 0.2), s1: (s / 32) * (hot ? 0.04 : 0.14), a0: 1, a1: hot ? 0 : 0.4, spin: (Math.random() - 0.5) * 14, - }); - } - // a scorch mark fading on the ground (flattened like the ground under the camera) - const c = cam.project(x, y, z + 0.01, this._p); - const cx = c.x, cy = c.y; - const flat = clamp((cy - cam.project(x, y + 1, z + 0.01, this._q).y) / Math.max(1, c.s), 0.25, 1); - this.particle('soft', cx, cy, { add: false, tint: 0x000000, life: 1.6, s0: (c.s / 128) * R * 1.5 * flat, s1: (c.s / 128) * R * 1.6 * flat, sx: 1 / flat, a0: 0.4, a1: 0 }); - } - if (o.tiles) this.tileFlash(o.tiles, col, 0.45); - } - - /** Dark (normal-blend) smoke puff. */ - smoke(x, y, size, tint, alpha = 0.45) { - this.particle('smoke', x, y, { add: false, tint, life: 0.8, s0: size / 128 * 0.9, s1: size / 128 * 2, a0: alpha, a1: 0 }); - } - - /** Motion streak between two world points at height z (dash / pull / blink trails). */ - streak(x0, y0, x1, y1, z, tint, life = 0.3) { - const cam = this.ctx.cam(); - const a = cam.project(x0, y0, z, this._p), ax = a.x, ay = a.y; - const b = cam.project(x1, y1, z, this._q); - const len = Math.hypot(b.x - ax, b.y - ay); - if (len < 2) return; - const th = Math.max(0.05, (b.s * 0.3) / 32); - this.particle('streak', b.x, b.y, { tint, life, s0: th, s1: th * 0.5, sx: len / 128 / th, a0: 0.8, a1: 0, rot: Math.atan2(b.y - ay, b.x - ax), anchorX: 1 }); - } - - /** Light strike from the sky onto a point (lightning / skill strikes / columns). */ - strike(x, y, z, tint, wide = false) { - const cam = this.ctx.cam(); - const g = cam.project(x, y, z); - this.particle('pillar', g.x, g.y, { tint, life: 0.4, s0: g.s / 64 * (wide ? 1.2 : 0.7), s1: g.s / 64 * (wide ? 0.9 : 0.2), a0: 1, a1: 0, sx: wide ? 1 : 0.5, ay: 1 }); - this.particle('glow', g.x, g.y - g.s * 0.2, { tint, life: 0.3, s0: g.s / 128 * 0.8, s1: g.s / 128 * 1.6, a0: 0.9, a1: 0 }); - this.ring(x, y, z, 0.1, wide ? 1.2 : 0.7, tint, 0.35); - this.burst(g.x, g.y - g.s * 0.2, g.s, 6, tint, { speed: 2.4, life: 0.35 }); - } - - /** Floating label ('!', '+10') at a screen point, using the damage-number pool. */ - numberAt(x, y, label, tint, life = 0.8) { - const P = this.P; - if (this.labels.length >= 24) { const o = this.labels.shift(); o.t.destroy(); } - const t = new P.BitmapText(String(label), { fontName: DMG_STYLE.true.font, fontSize: 26, align: 'center' }); - t.anchor.set(0.5, 1); - t.tint = tint; - t.position.set(x, y); - this.ctx.layers.text.addChild(t); - this.labels.push({ t, y0: y, life: 0, max: life }); - } - - _updateLabels(dt) { - let w = 0; - for (const l of this.labels) { - l.life += dt; - if (l.life >= l.max) { l.t.destroy(); continue; } - const k = l.life / l.max; - l.t.position.y = l.y0 - 22 * easeOut(k); - l.t.alpha = k > 0.6 ? 1 - (k - 0.6) / 0.4 : 1; - l.t.scale.set(k < 0.12 ? 0.6 + (k / 0.12) * 0.5 : 1.1 - Math.min(0.1, k - 0.12)); - this.labels[w++] = l; - } - this.labels.length = w; - } - - /** Persistent ground area: soft disc + pulsing edge ring for `dur` real seconds (telegraphs pulse faster). */ - zone(x, y, z, r, tint, dur, tex = 'soft', warn = false) { - const P = this.P; - const disc = new P.Sprite(this.tex[tex === 'ring' ? 'soft' : tex] || this.tex.soft); - disc.anchor.set(0.5); disc.blendMode = P.BLEND_MODES.ADD; disc.tint = tint; - const edge = new P.Sprite(this.tex.ring); - edge.anchor.set(0.5); edge.blendMode = P.BLEND_MODES.ADD; edge.tint = tint; - this._onGround(disc, y, z); this._onGround(edge, y, z); - this.zones.push({ disc, edge, x, y, z, r, t: 0, dur, warn }); - if (this.zones.length > 24) this._freeZone(this.zones.shift()); - } - - _freeZone(zn) { zn.disc.destroy(); zn.edge.destroy(); } - - _updateZones(dt) { - const cam = this.ctx.cam(); - const p = this._p, q = this._q; - let w = 0; - for (const zn of this.zones) { - zn.t += dt; - if (zn.t >= zn.dur) { this._freeZone(zn); continue; } - const k = zn.t / zn.dur; - const grow = Math.min(1, zn.t / 0.25); - const rad = zn.r * (0.35 + 0.65 * easeOut(grow)); - cam.project(zn.x, zn.y, zn.z + 0.02, p); - cam.project(zn.x, zn.y + rad, zn.z + 0.02, q); - const rx = p.s * rad, ry = Math.max(1, p.y - q.y); - const fade = k > 0.8 ? (1 - k) / 0.2 : 1; - const pulse = zn.warn ? 0.55 + 0.45 * Math.abs(Math.sin(zn.t * 7)) : 0.8 + 0.2 * Math.sin(zn.t * 3); - zn.disc.position.set(p.x, p.y); zn.disc.scale.set((rx * 2) / 128, (ry * 2) / 128); zn.disc.alpha = (zn.warn ? 0.35 : 0.28) * fade * pulse; - zn.edge.position.set(p.x, p.y); zn.edge.scale.set((rx * 2.1) / 128, (ry * 2.1) / 128); zn.edge.alpha = 0.75 * fade * pulse; - this.zones[w++] = zn; - } - this.zones.length = w; - } - - /** Flash a set of tiles ([[r,c]]) on the ground (telegraphed boxes, blast tiles). */ - tileFlash(tiles, tint, dur, warn = false) { - if (!Array.isArray(tiles) || !tiles.length) return; - this.tileFlashes.push({ tiles: tiles.slice(0, 60), tint, dur: Math.max(0.2, dur), t: 0, warn }); - if (this.tileFlashes.length > 12) this.tileFlashes.shift(); - } - - _updateTileFlashes(dt) { - const g = this.tileGfx; - g.clear(); - if (!this.tileFlashes.length) return; - const cam = this.ctx.cam(); - const p = this._p; - let w = 0; - for (const f of this.tileFlashes) { - f.t += dt; - if (f.t >= f.dur) continue; - const k = f.t / f.dur; - const a = (f.warn ? 0.35 + 0.35 * Math.abs(Math.sin(f.t * 7)) : 0.55 * (1 - k)) * (k > 0.85 ? (1 - k) / 0.15 : 1); - for (const [r, c] of f.tiles) { - const z = (this.ctx.heightAt ? this.ctx.heightAt(r, c) : 0) + 0.015; - const pts = []; - for (const [dx, dy] of [[-0.46, 0.46], [0.46, 0.46], [0.46, -0.46], [-0.46, -0.46]]) { cam.project(c + dx, r + dy, z, p); pts.push(p.x, p.y); } - g.lineStyle(Math.max(1, p.s * 0.03), f.tint, Math.min(1, a * 1.6)); - g.beginFill(f.tint, a * 0.6); - g.drawPolygon(pts); - g.endFill(); - } - this.tileFlashes[w++] = f; - } - this.tileFlashes.length = w; - } - - /** Full-screen colour pulse (field-wide telegraphs, cold wind). */ - flashScreen(tint, alpha = 0.4, dur = 0.6) { - this.tintT = dur; this.tintDur = dur; this.tintA = alpha; - this.tintSprite.tint = tint; - } - - /** A short flurry of snow over the field (cold wind). */ - snowfall(tint) { - const size = this.ctx.screenSize(); - for (let i = 0; i < (this.quality === 'low' ? 10 : 26); i++) { - this.particle('dot', Math.random() * size.width, Math.random() * size.height * 0.7, { tint, vx: 30 + Math.random() * 40, vy: 60 + Math.random() * 60, life: 1 + Math.random() * 0.6, s0: 0.25 + Math.random() * 0.3, s1: 0.1, a0: 0.8, a1: 0, fadeIn: 0.2 }); - } - } - - /** Leak: objective flash + red screen vignette pulse. */ - leak() { - this.vigT = 0.9; - } - - /** Screen-space pop (bond layer gain / bounty coins). `icon` = texture or null. */ - pop(icon, label, tint, i = 0) { - const P = this.P; - const size = this.ctx.screenSize(); - const top = this.ctx.fieldTop ? this.ctx.fieldTop() : size.height * 0.2; - const c = new P.Container(); - const x = size.width / 2 + (i % 5 - 2) * 70; - c.position.set(x, top); - if (icon) { - const glow = new P.Sprite(this.tex.glow); - glow.anchor.set(0.5); glow.tint = tint; glow.blendMode = P.BLEND_MODES.ADD; glow.scale.set(0.9); - const sp = new P.Sprite(icon); - sp.anchor.set(0.5); - sp.tint = tint; - // 46 px along its longer side. A bond icon (app.js 'layer': PIXI.Texture.from(url)) is a 1×1 placeholder until - // its image has loaded: sized from that it was drawn 46× too big, ~5000 px over the whole screen for the pop's - // 1.4 s (user playtest #4 item 13) — so it is sized once its texture is valid, and hidden until then. - const fit = () => { - if (sp.destroyed) return; - sp.scale.set(46 / Math.max(1, icon.width, icon.height)); - sp.visible = true; - }; - if (icon.valid) fit(); - else { sp.visible = false; icon.once('update', fit); } - c.addChild(glow, sp); - } - if (label) { - const t = new P.BitmapText(label, { fontName: DMG_STYLE.heal.font, fontSize: 26 }); - t.anchor.set(0, 0.5); - t.position.set(26, 0); - if (tint === COLORS.gold) t.tint = 0xffe066; - c.addChild(t); - } - this.ctx.layers.screen.addChild(c); - this.pops.push({ c, t: 0, dur: 1.4, y0: top }); - if (this.pops.length > 12) { const o = this.pops.shift(); o.c.destroy({ children: true }); } - } - - _updatePops(dt) { - let w = 0; - for (const p of this.pops) { - p.t += dt; - if (p.t >= p.dur) { p.c.destroy({ children: true }); continue; } - const k = p.t / p.dur; - p.c.position.y = p.y0 - 40 * easeOut(k); - p.c.alpha = k < 0.15 ? k / 0.15 : k > 0.7 ? 1 - (k - 0.7) / 0.3 : 1; - const s = k < 0.15 ? 0.6 + (k / 0.15) * 0.5 : 1.1 - Math.min(0.1, (k - 0.15)); - p.c.scale.set(s); - this.pops[w++] = p; - } - this.pops.length = w; - } - - /** Remove everything (battle reset). */ - clear() { - for (const p of this.parts) this._freeParticle(p); - this.parts.length = 0; - for (const pr of this.projs) this._releaseProj(pr); - this.projs.length = 0; - for (const L of this.locks) this._freeLock(L); - this.locks.length = 0; - this._slashAt = null; - for (const t of this.nums) this._releaseNum(t); - this.nums.length = 0; - for (const r of this.rings) { r.sp.visible = false; this.ringFree.push(r); } - this.rings.length = 0; - for (const a of this.auras.values()) a.sp.destroy({ children: true }); - this.auras.clear(); - for (const p of this.pops) p.c.destroy({ children: true }); - this.pops.length = 0; - this.beamList.length = 0; - this.beams.clear(); - for (const F of this.flames) this._freeFlame(F); - this.flames.length = 0; - this.vigT = 0; - this.vignette.alpha = 0; - for (const zn of this.zones) this._freeZone(zn); - this.zones.length = 0; - for (const l of this.labels) l.t.destroy(); - this.labels.length = 0; - this.tileFlashes.length = 0; - this.tileGfx.clear(); - this.tintT = 0; this.tintSprite.alpha = 0; - } - - update(dt) { - this.time += dt; - this._updateParticles(dt); - this._updateProjs(dt); - this._updateLocks(dt); - this._updateBeams(dt); - this._updateFlames(dt); - this._updateNums(dt); - this._updateRings(dt); - this._updateAuras(dt); - this._updatePops(dt); - this._updateZones(dt); - this._updateLabels(dt); - this._updateTileFlashes(dt); - if (this.tintT > 0) { - this.tintT = Math.max(0, this.tintT - dt); - const size = this.ctx.screenSize(); - this.tintSprite.width = size.width; this.tintSprite.height = size.height; - this.tintSprite.alpha = Math.sin((this.tintT / this.tintDur) * Math.PI) * this.tintA * 0.35; - } else if (this.tintSprite.alpha) this.tintSprite.alpha = 0; - if (this.vigT > 0) { - this.vigT = Math.max(0, this.vigT - dt); - const size = this.ctx.screenSize(); - this.vignette.width = size.width; this.vignette.height = size.height; - this.vignette.alpha = Math.sin((this.vigT / 0.9) * Math.PI) * 0.55; - } else if (this.vignette.alpha) this.vignette.alpha = 0; - } - - get counts() { - return { particles: this.parts.length, projectiles: this.projs.length, numbers: this.nums.length, rings: this.rings.length, auras: this.auras.size, locks: this.locks.length, flames: this.flames.length, promotions: this.promotions || 0 }; - } - - destroy() { - this.clear(); - this.addPc.destroy({ children: true }); - this.normPc.destroy({ children: true }); - this.projLayer.destroy({ children: true }); - this.shadowLayer.destroy({ children: true }); - this.lockFree.length = 0; - this.projFree.length = 0; - this.freeAdd.length = 0; this.freeNorm.length = 0; - this.beams.destroy(); - this.vignette.destroy(); - this.tileGfx.destroy(); - this.tintSprite.destroy(); - for (const list of this._numPools.values()) for (const t of list) t.text.destroy(); - this._numPools.clear(); - } -} - -const easeOut = (t) => 1 - (1 - t) * (1 - t); +// +// The bodies live in public/js/render/fx/*.js. Every name this file exported before the split is +// re-exported below, so existing imports keep working. speeds.js is imported first: its dynamic +// import('/sim/constants.js') is the module's one init side effect, as it was at the top of this file. + +export { setSimProjectileSpeeds, projSpeed } from './fx/speeds.js'; +export { SHOT_HEIGHT, bodyZ } from './fx/camera.js'; +export { ensureDamageFonts } from './fx/fonts.js'; +export { FX_KINDS, fxSpec, tilesAround, wallTiles } from './fx/kinds.js'; +export { FxSystem } from './fx/system.js'; diff --git a/public/js/render/fx/arrivals.js b/public/js/render/fx/arrivals.js new file mode 100644 index 00000000..1b1c407d --- /dev/null +++ b/public/js/render/fx/arrivals.js @@ -0,0 +1,120 @@ +// public/js/render/fx/arrivals.js — FxSystem deploy, promotion, death, crates and pops. +// Installed on FxSystem.prototype by ./system.js (a method container: never instantiated; `this` is the effect system). + +import { COLORS, DMG_STYLE } from '../style.js'; +import { SKILL_GOLD, easeOut } from './limits.js'; + +export class FxArrivals { + deploy(view) { + if (!view) return; + const p = this._proj(view.x, view.y, view.z || 0); + const s = p.s; + const col = view.isEnemy ? 0xff6a5a : 0x9ff0dc; + this.particle('pillar', p.x, p.y, { tint: col, life: 0.45, s0: s / 64 * 0.7, s1: s / 64 * 0.2, a0: 0.9, a1: 0, sx: 1, ay: 1 }); + this.ring(view.x, view.y, view.z || 0, 0.1, 0.8, col, 0.45); + this.burst(p.x, p.y, s, 6, col, { speed: 1.6, up: 0.4, life: 0.4, tex: 'dot' }); + } + + /** + * Promotion (精锐晋升, a merge) at the elite's spot — the board tile of the consumed copy it replaced (PRTS 卫戍协议/ + * 帮助 "若消耗已部署至作战区的干员,则发送至作战区对应位置") or its bench slot: a gold pillar with a white core, a shockwave + * and a hex ring on the ground, rising motes; `from` = world points of the other consumed copies (gold streaks from + * them to the elite). Counted in `promotions` (render/app.js setPrep; tests). + * @param {any} view + * @param {Array<{ x: number, y: number, z?: number }>} [from] + */ + promote(view, from = []) { + if (!view) return; + this.promotions = (this.promotions || 0) + 1; + const z = view.z || 0; + const g = this._proj(view.x, view.y, z, this._g); + const gx = g.x, gy = g.y, s = g.s; + for (const f of from) if (f && Number.isFinite(f.x) && Number.isFinite(f.y)) this.streak(f.x, f.y, view.x, view.y, Math.max(z, f.z || 0) + 0.3, SKILL_GOLD, 0.45); + this.particle('pillar', gx, gy, { tint: SKILL_GOLD, life: 0.8, s0: (s / 64) * 1.1, s1: (s / 64) * 1.4, a0: 0.95, a1: 0, sx: 0.85, ay: 1 }); + this.particle('pillar', gx, gy, { tint: 0xffffff, life: 0.42, s0: (s / 64) * 0.9, s1: (s / 64) * 1.2, a0: 0.9, a1: 0, sx: 0.3, ay: 1 }); + this.ring(view.x, view.y, z, 0.15, 1.6, 0xffe7a0, 0.5, 'shock'); + this.ring(view.x, view.y, z, 0.3, 1.2, SKILL_GOLD, 0.7, 'hex'); + const c = this._chest(view, this._q); + this.burst(c.x, c.y, s, this.rich ? 12 : 5, 0xffe28a, { speed: 1.5, up: 1.8, life: 0.8, tex: 'dot', size: 0.36 }); + } + + death(view) { + if (!view) return; + const p = this._chest(view); + const s = p.s; + const col = view.isEnemy ? 0xff7a52 : 0xbfeee2; + const n = this.quality === 'low' ? 5 : 12; + for (let i = 0; i < n; i++) { + this.particle(i % 3 ? 'dot' : 'shard', p.x + (Math.random() - 0.5) * s * 0.5, p.y + (Math.random() - 0.3) * s * 0.6, { + tint: col, vx: (Math.random() - 0.5) * s * 0.6, vy: -s * (0.4 + Math.random() * 0.8), drag: 1.5, + life: 0.7 + Math.random() * 0.5, s0: s / 32 * 0.12, s1: 0, a0: 0.9, a1: 0, spin: (Math.random() - 0.5) * 6, + }); + } + this.particle('smoke', p.x, p.y, { add: false, tint: 0x1a1a1a, life: 0.8, s0: s / 128 * 0.6, s1: s / 128 * 1.4, a0: 0.5, a1: 0 }); + } + + crateBreak(x, y, z, tint = null) { + const p = this._proj(x, y, (z || 0) + 0.35); + const s = p.s; + for (let i = 0; i < (this.quality === 'low' ? 4 : 10); i++) { + this.particle('shard', p.x, p.y, { + add: false, tint: tint ?? (i % 2 ? 0xc89a5a : 0x9a6d38), vx: (Math.random() - 0.5) * s * 2.2, vy: -s * (0.6 + Math.random()), g: s * 4, + life: 0.6, s0: s / 32 * 0.25, s1: s / 32 * 0.15, a0: 1, a1: 0.2, spin: (Math.random() - 0.5) * 14, + }); + } + this.particle('smoke', p.x, p.y, { add: false, tint: 0x8a7a60, life: 0.7, s0: s / 128 * 0.6, s1: s / 128 * 1.5, a0: 0.45, a1: 0 }); + } + + /** Screen-space pop (bond layer gain / bounty coins). `icon` = texture or null. */ + pop(icon, label, tint, i = 0) { + const P = this.P; + const size = this.ctx.screenSize(); + const top = this.ctx.fieldTop ? this.ctx.fieldTop() : size.height * 0.2; + const c = new P.Container(); + const x = size.width / 2 + (i % 5 - 2) * 70; + c.position.set(x, top); + if (icon) { + const glow = new P.Sprite(this.tex.glow); + glow.anchor.set(0.5); glow.tint = tint; glow.blendMode = P.BLEND_MODES.ADD; glow.scale.set(0.9); + const sp = new P.Sprite(icon); + sp.anchor.set(0.5); + sp.tint = tint; + // 46 px along its longer side. A bond icon (app.js 'layer': PIXI.Texture.from(url)) is a 1×1 placeholder until + // its image has loaded: sized from that it was drawn 46× too big, ~5000 px over the whole screen for the pop's + // 1.4 s (user playtest #4 item 13) — so it is sized once its texture is valid, and hidden until then. + const fit = () => { + if (sp.destroyed) return; + sp.scale.set(46 / Math.max(1, icon.width, icon.height)); + sp.visible = true; + }; + if (icon.valid) fit(); + else { sp.visible = false; icon.once('update', fit); } + c.addChild(glow, sp); + } + if (label) { + const t = new P.BitmapText(label, { fontName: DMG_STYLE.heal.font, fontSize: 26 }); + t.anchor.set(0, 0.5); + t.position.set(26, 0); + if (tint === COLORS.gold) t.tint = 0xffe066; + c.addChild(t); + } + this.ctx.layers.screen.addChild(c); + this.pops.push({ c, t: 0, dur: 1.4, y0: top }); + if (this.pops.length > 12) { const o = this.pops.shift(); o.c.destroy({ children: true }); } + } + + _updatePops(dt) { + let w = 0; + for (const p of this.pops) { + p.t += dt; + if (p.t >= p.dur) { p.c.destroy({ children: true }); continue; } + const k = p.t / p.dur; + p.c.position.y = p.y0 - 40 * easeOut(k); + p.c.alpha = k < 0.15 ? k / 0.15 : k > 0.7 ? 1 - (k - 0.7) / 0.3 : 1; + const s = k < 0.15 ? 0.6 + (k / 0.15) * 0.5 : 1.1 - Math.min(0.1, (k - 0.15)); + p.c.scale.set(s); + this.pops[w++] = p; + } + this.pops.length = w; + } +} diff --git a/public/js/render/fx/beams.js b/public/js/render/fx/beams.js new file mode 100644 index 00000000..96b1f88c --- /dev/null +++ b/public/js/render/fx/beams.js @@ -0,0 +1,172 @@ +// public/js/render/fx/beams.js — FxSystem beams and the 炎佑 fire jet. +// Installed on FxSystem.prototype by ./system.js (a method container: never instantiated; `this` is the effect system). + +import { SHOT_HEIGHT } from './camera.js'; + +// 炎佑 fire jet: real s a jet burns past its tick's `dur` (the next 1 game s tick re-aims and extends it before it fades: +// one continuous stream), its fade-out, the most jets at once, the fire colours +const FLAME_TAIL = 0.2, FLAME_FADE = 0.15, MAX_FLAMES = 8; + +const FLAME_TINTS = Object.freeze([0xffd27a, 0xffa94d, 0xff8a3d, 0xff5a2a]); + +export class FxBeams { + /** A beam from view `a` to view `b`; `chain`: a bounce, `a` is the previous target (it leaves from its chest). */ + _beam(a, b, color, dur = 0.22, jitter = 1, chain = false) { + this.beamList.push({ a, b, color, t: 0, dur, jitter, chain, seed: Math.random() * 1000 }); + if (this.beamList.length > 40) this.beamList.shift(); + const q = this._bodyPt(b, SHOT_HEIGHT.aim, this._g); + this.particle('flare', q.x, q.y, { tint: color, life: 0.16, s0: (q.s / 128) * 0.7, s1: (q.s / 128) * 0.25, a0: 1, a1: 0, rot: Math.random() }); + } + + _updateBeams(dt) { + const g = this.beams; + g.clear(); + let w = 0; + const p = this._p, q = this._q; + for (const bm of this.beamList) { + bm.t += dt; + if (bm.t >= bm.dur || !bm.a || !bm.b) continue; + // a chain bounce leaves the previous target where the shot met it (its chest), a beam leaves the shooter's hands + this._bodyPt(bm.a, bm.chain ? SHOT_HEIGHT.aim : SHOT_HEIGHT.launch, p); const px = p.x, py = p.y, s = p.s; + this._bodyPt(bm.b, SHOT_HEIGHT.aim, q); + const k = 1 - bm.t / bm.dur; + const segs = 7; + // soft glow, coloured body, white-hot core + for (let pass = 0; pass < 3; pass++) { + const wd = pass === 0 ? s * 0.16 : pass === 1 ? s * 0.065 : s * 0.026; + g.lineStyle(Math.max(1, wd), pass === 2 ? 0xffffff : bm.color, (pass === 0 ? 0.22 : pass === 1 ? 0.6 : 0.95) * k); + g.moveTo(px, py); + for (let i = 1; i < segs; i++) { + const f = i / segs; + const j = Math.sin(bm.seed + i * 12.9 + bm.t * 40) * s * 0.12 * (bm.jitter ?? 1); + g.lineTo(px + (q.x - px) * f + j, py + (q.y - py) * f - j * 0.5); + } + g.lineTo(q.x, q.y); + } + this.beamList[w++] = bm; + } + this.beamList.length = w; + } + + // ---- fire jets (炎佑 祛恶之焰) --------------------------------------------------------------------------------- + + /** + * fx 'yanyouFlame' (sim/content/tokens.js yanyouKit; user playtest #4 item 12 "一段持续时间的喷火"): the sim emits one + * event per game second of the channel; each keeps the jet of dragon `srcId` burning for `dur` real s + FLAME_TAIL + * onto its locked target `tgtId` (else the spot (x, y)), so the 1 s ticks join into ONE continuous stream, with a + * burning disc of radius `r` tiles following the target. One jet per dragon (a new tick re-aims and extends it). + */ + _flame(srcId, tgtId, x, y, r, col, dur) { + if (!Number.isFinite(x) || !Number.isFinite(y)) return; + let F = srcId != null ? this.flames.find((f) => f.src === srcId) : null; + if (!F) { + const P = this.P; + const disc = new P.Sprite(this.tex.soft); + disc.anchor.set(0.5); disc.blendMode = P.BLEND_MODES.ADD; disc.alpha = 0; + const edge = new P.Sprite(this.tex.ring); + edge.anchor.set(0.5); edge.blendMode = P.BLEND_MODES.ADD; edge.alpha = 0; + F = { src: srcId, disc, edge, t: 0, end: 0, jet: 0, fire: 0, smoke: 0, seed: Math.random() * 100 }; + this.flames.push(F); + if (this.flames.length > MAX_FLAMES) this._freeFlame(this.flames.shift()); + } + F.tgt = tgtId; F.x = x; F.y = y; F.z = this._groundZ(x, y); F.r = r; F.col = col; + F.end = Math.max(F.end, F.t + dur + FLAME_TAIL); + F.disc.tint = F.edge.tint = col; + } + + _freeFlame(F) { F.disc.destroy(); F.edge.destroy(); } + + _updateFlames(dt) { + if (!this.flames.length) return; + const cam = this.ctx.cam(); + const g = this.beams; // drawn after _updateBeams cleared it + const p = this._p, q = this._q, m = this._g; + const low = this.quality === 'low'; + let w = 0; + for (const F of this.flames) { + F.t += dt; + const sv = this._viewOf(F.src); + // the channel is over (no tick kept it going) or the dragon is gone + if (F.t >= F.end || (F.src != null && this.ctx.view && (!sv || sv.alive === false))) { this._freeFlame(F); continue; } + const tv = this._viewOf(F.tgt); + if (tv && tv.alive !== false && Number.isFinite(tv.x) && Number.isFinite(tv.y)) { F.x = tv.x; F.y = tv.y; F.z = tv.z || 0; } + const fade = Math.min(1, F.t / 0.12, (F.end - F.t) / FLAME_FADE); + const flick = 0.82 + 0.18 * Math.sin(F.t * 23 + F.seed) * Math.sin(F.t * 7.3); + // burning disc on the ground around the target + this._onGround(F.disc, F.y, F.z); this._onGround(F.edge, F.y, F.z); + cam.project(F.x, F.y, F.z + 0.02, p); + cam.project(F.x, F.y + F.r, F.z + 0.02, q); + const cx = p.x, cy = p.y, s = p.s; + const rx = s * F.r, ry = Math.max(1, cy - q.y); + F.disc.position.set(cx, cy); F.disc.scale.set((rx * 2) / 128, (ry * 2) / 128); F.disc.alpha = 0.5 * fade * flick; + F.edge.position.set(cx, cy); F.edge.scale.set((rx * 2.05) / 128, (ry * 2.05) / 128); F.edge.alpha = 0.7 * fade * flick; + // flame tongues and embers rising from the disc + F.fire += dt * (low ? 14 : 34) * fade; + while (F.fire >= 1) { + F.fire -= 1; + if (!this._room()) { F.fire = 0; break; } + const a = Math.random() * Math.PI * 2, d = Math.sqrt(Math.random()) * 0.9; + const px = cx + Math.cos(a) * rx * d, py = cy + Math.sin(a) * ry * d; + const ember = Math.random() < 0.3; + const po = this._o(); + po.tint = ember ? 0xffd27a : FLAME_TINTS[(Math.random() * FLAME_TINTS.length) | 0]; + po.vx = (Math.random() - 0.5) * s * 0.3; po.vy = -s * (ember ? 1.6 : 0.9 + Math.random() * 0.6); po.drag = ember ? 0.5 : 1.5; + po.life = ember ? 0.7 : 0.35 + Math.random() * 0.25; + po.s0 = (s / 128) * (ember ? 0.06 : 0.28 + Math.random() * 0.12); po.s1 = (s / 128) * (ember ? 0.02 : 0.08); + po.a0 = 0.9; po.a1 = 0; po.fadeIn = 0.05; + this.particle(ember ? 'dot' : 'soft', px, py, po); + } + if (this.rich) { + F.smoke += dt * 5 * fade; + while (F.smoke >= 1) { + F.smoke -= 1; + if (!this._room()) { F.smoke = 0; break; } + const po = this._o(); + po.add = false; po.tint = 0x3a3430; po.vx = (Math.random() - 0.5) * s * 0.2; po.vy = -s * 0.5; po.drag = 1; + po.life = 0.9; po.s0 = (s / 128) * 0.35; po.s1 = (s / 128) * 0.8; po.a0 = 0.32; po.a1 = 0; po.fadeIn = 0.15; + this.particle('smoke', cx + (Math.random() - 0.5) * rx, cy - s * 0.2, po); + } + } + // the jet: from the dragon's chest onto the target (its chest; the disc centre without a target view) + if (sv) { + this._chest(sv, m); + const mx = m.x, my = m.y; + if (tv && tv.alive !== false) this._chest(tv, q); else { q.x = cx; q.y = cy - s * 0.15; } + const tx = q.x, ty = q.y; + const segs = 6; + for (let pass = 0; pass < 3; pass++) { + const col = pass === 2 ? 0xfff2c0 : pass === 1 ? 0xffb347 : F.col; + const alpha = (pass === 0 ? 0.3 : pass === 1 ? 0.55 : 0.8) * fade * flick; + let x0 = mx, y0 = my; + for (let i = 1; i <= segs; i++) { + const f = i / segs; + // widens towards the target (a cone of fire), wobbling + const wd = s * (pass === 0 ? 0.1 + 0.22 * f : pass === 1 ? 0.05 + 0.1 * f : 0.02 + 0.03 * f); + const j = i < segs ? Math.sin(F.seed + i * 3.1 + F.t * 30) * s * 0.05 * f : 0; + const x1 = mx + (tx - mx) * f + j, y1 = my + (ty - my) * f - j * 0.5; + g.lineStyle(Math.max(1, wd), col, alpha); + g.moveTo(x0, y0); g.lineTo(x1, y1); + x0 = x1; y0 = y1; + } + } + // fire puffs streaming along it + F.jet += dt * (low ? 18 : 42) * fade; + const T = 0.2; + while (F.jet >= 1) { + F.jet -= 1; + if (!this._room()) { F.jet = 0; break; } + const a = Math.random() * Math.PI * 2, d = Math.random() * 0.5; + const gx = tx + Math.cos(a) * rx * d, gy = ty + Math.sin(a) * ry * d; + const po = this._o(); + po.tint = FLAME_TINTS[(Math.random() * FLAME_TINTS.length) | 0]; + po.vx = (gx - mx) / T; po.vy = (gy - my) / T; + po.life = T * (0.9 + Math.random() * 0.3); + po.s0 = (s / 128) * 0.1; po.s1 = (s / 128) * (0.38 + Math.random() * 0.2); po.a0 = 0.85; po.a1 = 0.15; + this.particle('soft', mx, my, po); + } + } + this.flames[w++] = F; + } + this.flames.length = w; + } +} diff --git a/public/js/render/fx/camera.js b/public/js/render/fx/camera.js new file mode 100644 index 00000000..e870d317 --- /dev/null +++ b/public/js/render/fx/camera.js @@ -0,0 +1,28 @@ +// public/js/render/fx/camera.js — shot heights and camera-relative world heights. + +/** + * Shot heights (GitHub #61): where a shot or beam leaves a unit (`launch`, ≈ its hands) and where shots, beams and lock + * marks meet a unit (`aim`, ≈ its chest), as shares of the drawn model's height above the feet (`_headTiles`). The + * models are upright screen billboards, so these are heights on screen, solved into world heights through the camera + * (projection.js liftFor, `bodyZ`): 0.45 × the head height taken as a WORLD height drew at ≈ 18 % of the model under the + * 30° pitch — shots left the hips and aimed at the targets' hips. [ASSUMED] the shares (a battle chibi's hands / chest; + * the data has no official muzzle points). + */ +export const SHOT_HEIGHT = Object.freeze({ launch: 0.45, aim: 0.5 }); + +/** World height of the point `frac` of a unit's drawn model height above its feet (SHOT_HEIGHT), seen through `cam`. */ +export function bodyZ(cam, v, frac) { + const z0 = (v.z || 0) + (v.hover || 0); + const h = (v._headTiles || 1.2) * frac; + if (!cam || typeof cam.liftFor !== 'function') return z0 + h; + const dz = cam.liftFor(v.x, v.y, z0, h * cam.scaleAt(v.x, v.y, z0)); + return Number.isFinite(dz) ? z0 + dz : z0 + h; +} + +/** World height just above a unit's feet (where shells land). */ +const feetZ = (v) => (v.z || 0) + (v.hover || 0) + 0.2; + +/** Cheap fingerprint of a camera's framing (the damage-number layout cache is reused only while it is unchanged). */ +const camKey = (c) => (c ? c.tx + c.ty * 1e3 + c.tz * 1e6 + c.tilt * 7.13 + c.dist * 1e4 + c.scale * 3.7e-2 + c.cx * 1.1e-5 + c.cy * 1.3e-8 : 0); + +export { feetZ, camKey }; diff --git a/public/js/render/fx/fonts.js b/public/js/render/fx/fonts.js new file mode 100644 index 00000000..fcafa0c5 --- /dev/null +++ b/public/js/render/fx/fonts.js @@ -0,0 +1,21 @@ +// public/js/render/fx/fonts.js — damage-number bitmap fonts (one module-level ready flag). + +import { DMG_STYLE } from '../style.js'; + +let fontsReady = false; + +/** Generate the damage-number bitmap fonts (after web fonts loaded, if possible). */ +export function ensureDamageFonts() { + if (fontsReady) return; + const P = globalThis.PIXI; + for (const st of Object.values(DMG_STYLE)) { + try { + P.BitmapFont.from(st.font, { + fontFamily: ['Bender', 'Oxanium', 'Rajdhani', 'Arial Black', 'sans-serif'], fontSize: 44, fontWeight: '700', + fill: st.fill, fillGradientStops: [0.25, 1], stroke: st.stroke, strokeThickness: 7, + dropShadow: true, dropShadowColor: '#000000', dropShadowAlpha: 0.45, dropShadowDistance: 2, dropShadowBlur: 2, + }, { chars: [['0', '9'], '+-×!'], resolution: 2, padding: 6 }); + } catch (err) { console.warn('[fx] bitmap font', st.font, err?.message || err); } + } + fontsReady = true; +} diff --git a/public/js/render/fx/kinds.js b/public/js/render/fx/kinds.js new file mode 100644 index 00000000..6875fc1d --- /dev/null +++ b/public/js/render/fx/kinds.js @@ -0,0 +1,145 @@ +// public/js/render/fx/kinds.js — sim fx kind table and the tile queries the effects use. + +/** + * Sim fx kind → visual archetype (`a`), colour (`c`) and defaults (`r` radius, `dur` game s, `tex`, `smoke`). + * Covers every kind emitted by server/sim (Battle, kits, tokens, enemies, bosses, devices, bonds, items); unknown + * kinds fall back to a keyword guess, then to a generic sparkle (fxSpec). + */ +export const FX_KINDS = Object.freeze({ + // blasts + aoe: { a: 'blast', c: 0xffb35c }, explode: { a: 'blast', c: 0xff7a33 }, explosion: { a: 'blast', c: 0xff7a33 }, + // `pt`: always at the event's (x, y) (its `id` is the shooter); `heavy`: debris + scorch + bombard: { a: 'blast', c: 0xffa04a, r: 1.5, pt: true, heavy: true }, bombardShell: { a: 'shell', c: 0xff5a3a, r: 1.5, pt: true }, + airstrike: { a: 'blast', c: 0xff8a3d, r: 1.5, heavy: true }, splash: { a: 'blast', c: 0xffc27a }, + scorchBurst: { a: 'blast', c: 0xff6a2a }, champagneBomb: { a: 'blast', c: 0xffd27a }, shockBlast: { a: 'blast', c: 0x9fd4ff, smoke: 0x1c2630 }, + frostNova: { a: 'blast', c: 0x9fe6ff, smoke: 0x1c2630 }, sunBurst: { a: 'blast', c: 0xffe28a }, meltdown: { a: 'blast', c: 0xff5a2a, r: 1.5, heavy: true }, + iceSpike: { a: 'blast', c: 0xbfeeff, smoke: 0x1c2630 }, rockfall: { a: 'blast', c: 0xc8a878, smoke: 0x4a3f33 }, rockslide: { a: 'blast', c: 0xc8a878, smoke: 0x4a3f33 }, + finale: { a: 'blast', c: 0xffd45a, r: 1.5 }, swordStorm: { a: 'blast', c: 0xdfe8ff }, swordRain: { a: 'blast', c: 0xdfe8ff }, liberate: { a: 'blast', c: 0xffffff }, + quadShot: { a: 'volley', c: 0xfff2d0 }, featherArrow: { a: 'counter', c: 0xfff2d0 }, + burst: { a: 'element', c: 0xd0a0ff }, + // areas + zone: { a: 'zone', c: 0xffb35c, dur: 3 }, healField: { a: 'zone', c: 0x62f08a, dur: 4 }, firewall: { a: 'wall', c: 0xff6a2a, dur: 4 }, + firewallBlock: { a: 'counter', c: 0xff6a2a }, tide: { a: 'zone', c: 0x5fe0ff, dur: 3, r: 2 }, storm: { a: 'zone', c: 0xd8c8a0, dur: 3 }, + tornado: { a: 'zone', c: 0xd8e8ff, dur: 3 }, snow: { a: 'zone', c: 0xe8f6ff, dur: 3 }, telegraph: { a: 'telegraph', c: 0xff3b30, dur: 1.2 }, + coldWind: { a: 'chill', c: 0x9fd4ff }, + // support + heal: { a: 'heal', c: 0x62f08a }, healAoe: { a: 'healAoe', c: 0x62f08a, r: 1.5 }, bandage: { a: 'heal', c: 0x62f08a }, blessing: { a: 'healAoe', c: 0xfff0a8 }, + cleanse: { a: 'healAoe', c: 0xdfffff, r: 0.9 }, revive: { a: 'summon', c: 0xfff0a8 }, reborn: { a: 'summon', c: 0xffd45a }, hpShare: { a: 'heal', c: 0xff9aa6 }, + spGift: { a: 'sp', c: 0x6fd3ff }, spGain: { a: 'sp', c: 0x6fd3ff }, reload: { a: 'sp', c: 0xffe066 }, + shield: { a: 'shield', c: 0xdfe8ff }, catShield: { a: 'shield', c: 0xffe0a8 }, saltWard: { a: 'shield', c: 0xbfeeff }, shell: { a: 'shield', c: 0xc8b890 }, + vest: { a: 'shield', c: 0xdfe8ff }, sleepGuard: { a: 'shield', c: 0xa8b6ff }, truesilver: { a: 'shield', c: 0xfff3b8 }, shieldBreak: { a: 'shatter', c: 0xdfe8ff }, + // arrivals / departures + summon: { a: 'summon', c: 0x9ff0dc }, drones: { a: 'summon', c: 0x8fe6ff }, drone: { a: 'summon', c: 0x8fe6ff }, sentry: { a: 'summon', c: 0x9ff0dc }, + turretOnline: { a: 'summon', c: 0xff8a6a }, yanyouSummon: { a: 'summon', c: 0xffb347 }, device: { a: 'summon', c: 0xc0c8cc }, + appear: { a: 'summon', c: 0xb36bff }, copy: { a: 'summon', c: 0xd8b0ff }, manifoldCopy: { a: 'summon', c: 0xd8b0ff }, split: { a: 'summon', c: 0xff9a6a }, + disappear: { a: 'vanish', c: 0xb36bff }, stealth: { a: 'vanish', c: 0x8fa0b0 }, camouflage: { a: 'vanish', c: 0x8fb08f }, phase: { a: 'vanish', c: 0xb36bff }, + substitute: { a: 'vanish', c: 0xd8b0ff }, swap: { a: 'blink', c: 0xd8b0ff }, blink: { a: 'blink', c: 0xb36bff }, teleport: { a: 'blink', c: 0xb36bff }, + // a dollkeeper knocked out as its <替身> (sim professions.js): only its model form goes back to the 本体 for the + // redeploy — the 替身's death clip already shows the knock-out, nothing is drawn + dollEnd: { a: 'none', c: 0xd8b0ff }, + ulpiaReturn: { a: 'blink', c: 0x9ff0dc }, manifoldSplit: { a: 'blink', c: 0xd8b0ff }, + // displacement + pull: { a: 'move', c: 0x9fd4ff }, push: { a: 'move', c: 0xffd9a0 }, displace: { a: 'move', c: 0xd0c0a0 }, lure: { a: 'move', c: 0xffb3ec }, + charge: { a: 'move', c: 0xff9c33 }, dash: { a: 'move', c: 0xffd9a0 }, slippery: { a: 'move', c: 0x9fe6ff }, + // pulses + sonic: { a: 'wave', c: 0xc9a2ff, r: 1.5 }, pulse: { a: 'wave', c: 0x9ff0dc }, sermon: { a: 'wave', c: 0xffe28a, r: 1.5 }, ripple: { a: 'wave', c: 0x5fe0ff }, + tornadoPulse: { a: 'wave', c: 0xd8e8ff }, wake: { a: 'wave', c: 0x5fe0ff }, wolfShadow: { a: 'wave', c: 0x8fa0b0 }, wolfShadowLost: { a: 'vanish', c: 0x8fa0b0 }, + redistribute: { a: 'wave', c: 0x62f08a }, dilemma: { a: 'wave', c: 0xc9a2ff }, + // marks + taunt: { a: 'mark', c: 0xff9c33 }, palsy: { a: 'mark', c: 0xc77dff }, emergency: { a: 'mark', c: 0xff5a4a }, + lock: { a: 'reticle', c: 0xff5a4a }, droneLock: { a: 'reticle', c: 0x8fe6ff }, wanted: { a: 'reticle', c: 0xffc600 }, expose: { a: 'reticle', c: 0xff7b8a }, + reveal: { a: 'reticle', c: 0x9fd4ff }, anchor: { a: 'reticle', c: 0x9fd4ff }, + // buffs + buff: { a: 'buff', c: 0xffd45a }, overload: { a: 'buff', c: 0xff7a33 }, overclock: { a: 'buff', c: 0xff9c33 }, talent: { a: 'buff', c: 0xffd45a }, + knack: { a: 'buff', c: 0xffd45a }, bloodBattle: { a: 'buff', c: 0xff4b3e }, sword: { a: 'buff', c: 0xdfe8ff }, equip: { a: 'buff', c: 0x4ed8af }, + garrisonGrant: { a: 'buff', c: 0x4ed8af }, extraAttack: { a: 'buff', c: 0xffe066 }, soul: { a: 'buff', c: 0xb36bff }, jungleSoul: { a: 'buff', c: 0x7fd37a }, + candle: { a: 'buff', c: 0xffb347 }, mote: { a: 'buff', c: 0xfff0a8 }, ember: { a: 'buff', c: 0xff7a33 }, ignite: { a: 'buff', c: 0xff6a2a }, + flame: { a: 'buff', c: 0xff6a2a }, grow: { a: 'buff', c: 0x7fd37a }, weightlessBuff: { a: 'buff', c: 0xcfe0ff }, + // 炎佑 祛恶之焰: a continuous jet from the dragon (`id`) onto its locked target (`target`) + a burning disc (_flame) + yanyouFlame: { a: 'flame', c: 0xff8a3d, r: 1 }, + // states + takeoff: { a: 'lift', c: 0xcfe0ff }, levitate: { a: 'lift', c: 0xcfe0ff }, weightless: { a: 'lift', c: 0xcfe0ff }, + sleep: { a: 'sleep', c: 0xa8b6ff }, crit: { a: 'crit', c: 0xffe066 }, + dodge: { a: 'dodge', c: 0xffffff }, riposte: { a: 'counter', c: 0xffd9a0 }, counter: { a: 'counter', c: 0xffd9a0 }, block: { a: 'counter', c: 0xdfe8ff }, + thorns: { a: 'counter', c: 0xff9aa6 }, downed: { a: 'down', c: 0xbfeee2 }, stone: { a: 'down', c: 0xc0b8a8, smoke: 0x5a5448 }, + dp: { a: 'dp', c: 0x9fd4ff }, coin: { a: 'coin', c: 0xffc600 }, steal: { a: 'coin', c: 0xffc600 }, crateBreak: { a: 'crate', c: 0xc89a5a }, + lpLoss: { a: 'lp', c: 0xff3b30 }, + // 限伤 (sim/damage.js leaderHitCancelled): a leader's hit of ≥ 300000 dealt nothing — the official shows no number and + // no effect [ASSUMED], so nothing is drawn (`a: 'none'`) + hitCap: { a: 'none', c: 0xffffff }, + // beams + beam: { a: 'beam', c: 0xff7a5a }, link: { a: 'beam', c: 0x9ff0dc }, lightning: { a: 'bolt', c: 0xc9a2ff }, tentacle: { a: 'beam', c: 0x5fe0ff }, + sandChains: { a: 'beam', c: 0xd8c8a0 }, sandChainsCharged: { a: 'beam', c: 0xffd45a }, + strike: { a: 'strike', c: 0xffe6a8 }, volley: { a: 'volley', c: 0xfff2d0 }, column: { a: 'pillar', c: 0x9ff0dc }, obelisk: { a: 'pillar', c: 0xc9a2ff }, + duskDragon: { a: 'blast', c: 0x9dff6a, r: 1.5 }, shadowWeave: { a: 'vanish', c: 0x8f7bff }, reweave: { a: 'summon', c: 0x8f7bff }, + slash: { a: 'counter', c: 0xfff0d0 }, devour: { a: 'vanish', c: 0xff4b3e }, undying: { a: 'shield', c: 0xffd45a }, + // summon arrival bursts (content/tokens.js burst(): 沙之碑 default, 迷迭香 gear stun, 耀阳 sword, 纸偶) + summonBurst: { a: 'blast', c: 0xe8c878, smoke: 0x4a3f33 }, summonStun: { a: 'blast', c: 0xcfe0ff, smoke: 0x2a2e36 }, + radiantSword: { a: 'pillar', c: 0xffe8a0 }, paperDoll: { a: 'blast', c: 0xc9a2ff, smoke: 0x2c2436 }, + // bonds / garrisons (support/index.js fxOn) + bondMilestone: { a: 'buff', c: 0xffc600 }, bondProc: { a: 'buff', c: 0x4ed8af }, bondShare: { a: 'wave', c: 0x4ed8af }, + garrison: { a: 'buff', c: 0x4ed8af }, layer: { a: 'buff', c: 0xffe066 }, sp: { a: 'sp', c: 0x6fd3ff }, +}); + +/** Keyword guesses for kinds added later (checked in order), before the generic sparkle. */ +const FX_GUESS = [ + [/heal|cure|mend|regen/i, 'heal'], [/shield|ward|barrier|guard/i, 'shield'], [/summon|spawn|call|deploy/i, 'summon'], + [/blast|bomb|explo|burst|boom|nova|strike/i, 'blast'], [/zone|field|area|wall|mist|fog|pool/i, 'zone'], + [/buff|boost|power|rage|charge|up$/i, 'buff'], [/mark|lock|target|wanted|expose/i, 'reticle'], [/teleport|blink|warp|swap/i, 'blink'], + [/stealth|hide|vanish|cloak/i, 'vanish'], [/pull|push|knock|dash|leap/i, 'move'], [/pulse|wave|ring|sonic/i, 'wave'], +]; + +/** + * 'phase' (an enemy's mode change, render/units.js FORMS) kinds the model shows on its own: 暴鸰 'bombed' — its bomb is + * the 'droneBomb' projectile of the same moment, a puff on the drone would read as something else (feedback D4). + */ +const SILENT_PHASES = new Set(['bombed']); + +/** Visual spec of an fx kind (see FX_KINDS); `extra.kind` / `extra.element` may pick a better colour. */ +export function fxSpec(kind, extra = {}) { + const k = typeof kind === 'string' ? kind : ''; + if (k === 'phase' && SILENT_PHASES.has(extra && extra.kind)) return { a: 'none', c: 0xffffff }; + let spec = FX_KINDS[k]; + if (!spec) { + const g = FX_GUESS.find(([re]) => re.test(k)); + spec = g ? { ...FX_KINDS[{ heal: 'heal', shield: 'shield', summon: 'summon', blast: 'aoe', zone: 'zone', buff: 'buff', reticle: 'lock', blink: 'blink', vanish: 'disappear', move: 'displace', wave: 'pulse' }[g[1]]] } : { a: 'generic', c: 0xfff2d0 }; + } + const el = extra && extra.element; + if (el && (spec.a === 'blast' || spec.a === 'zone' || spec.a === 'element')) { + const c = el === 'burn' ? 0xff7a33 : el === 'neural' ? 0xff5ad0 : el === 'necrosis' || el === 'apoptosis' ? 0x9dff6a : el === 'erosion' ? 0x6fe0ff : null; + if (c) spec = { ...spec, c }; + } + if (extra && (extra.dmgType === 'arts' || /arts|magic/i.test(String(extra.kind || ''))) && spec.a === 'blast') spec = { ...spec, c: 0xc77dff }; + return spec; +} + +/** Tiles covered by a blast / telegraph: `tiles` = [[r,c]…] or 'box' (Chebyshev ⌊r⌋ around the centre) or 'disc'. */ +export function tilesAround(x, y, r, tiles) { + if (Array.isArray(tiles)) return tiles.filter((t) => Array.isArray(t) && Number.isInteger(t[0]) && Number.isInteger(t[1])).slice(0, 80); + const cr = Math.round(y), cc = Math.round(x), R = Math.max(0, Math.min(6, Math.floor(r))); + const out = []; + for (let dr = -R; dr <= R; dr++) for (let dc = -R; dc <= R; dc++) { + if (tiles === 'box' || dr * dr + dc * dc <= r * r + 0.25) out.push([cr + dr, cc + dc]); + } + return out; +} + +/** + * Tiles of a straight wall through tile (round(y), round(x)): `axis` 'col' = that column, 'row' = that row (余 S3 + * fire wall: perpendicular to his facing, sim/content/kits/ops/chess_char_6_03-yu.js). Clipped to the field `rect` (inclusive + * { r0, r1, c0, c1 }); without one ±4 tiles. + */ +export function wallTiles(x, y, axis, rect) { + const R = Math.round(Number(y)), C = Math.round(Number(x)); + if (!Number.isFinite(R) || !Number.isFinite(C)) return []; + const ok = rect && [rect.r0, rect.r1, rect.c0, rect.c1].every(Number.isFinite); + const out = []; + if (axis === 'row') { + const [a, b] = ok ? [rect.c0, rect.c1] : [C - 4, C + 4]; + for (let c = a; c <= b; c++) out.push([R, c]); + } else { + const [a, b] = ok ? [rect.r0, rect.r1] : [R - 4, R + 4]; + for (let r = a; r <= b; r++) out.push([r, C]); + } + return out.filter(([r, c]) => r >= 0 && r <= 18 && c >= 0 && c <= 20); +} diff --git a/public/js/render/fx/limits.js b/public/js/render/fx/limits.js new file mode 100644 index 00000000..d2b0dea0 --- /dev/null +++ b/public/js/render/fx/limits.js @@ -0,0 +1,13 @@ +// public/js/render/fx/limits.js — shared fx tunables used by more than one effect module. + +const MAX_PARTICLES = { high: 1400, medium: 800, low: 360 }; + +const SKILL_GOLD = 0xffd45a; + +const NO_OPTS = Object.freeze({}); + +const clamp = (v, lo, hi) => (v < lo ? lo : v > hi ? hi : v); + +const easeOut = (t) => 1 - (1 - t) * (1 - t); + +export { MAX_PARTICLES, SKILL_GOLD, NO_OPTS, clamp, easeOut }; diff --git a/public/js/render/fx/locks.js b/public/js/render/fx/locks.js new file mode 100644 index 00000000..52dea118 --- /dev/null +++ b/public/js/render/fx/locks.js @@ -0,0 +1,104 @@ +// public/js/render/fx/locks.js — FxSystem lock reticles. +// Installed on FxSystem.prototype by ./system.js (a method container: never instantiated; `this` is the effect system). + +import { SHOT_HEIGHT, bodyZ } from './camera.js'; +import { FX_KINDS } from './kinds.js'; +import { easeOut } from './limits.js'; + +/** + * Lock reticle: how long (game s) a lock outlives the last sign of its shooter's S3 going on — its latest lock, shell or + * bombard (a lock whose shell / shot never comes) — its fade-out (real s), and the most reticles kept at once (a long + * S3: ammo grants / reloads lock far more than the 5 base shots, ≤ 33 shells). + */ +const LOCK_T = 5, LOCK_FADE = 0.2, MAX_LOCKS = 48; + +export class FxLocks { + // ---- lock-on reticles (蕾缪安) ------------------------------------------------------------------------------- + + /** + * fx 'lock': a reticle on the locked enemy (following its view; at the last spot once it is gone) until its shell + * lands (mortar / _landed) or the aimed shot fires ('crit' from the same shooter); at most LOCK_T game seconds after + * the shooter's last lock / shell / bombard (_touchLocks). + */ + _lock(view, src, x, y, z) { + let L = this.lockFree.pop(); + if (!L) { + const P = this.P; + const ring = new P.Sprite(this.tex.reticle); + ring.anchor.set(0.5); + ring.blendMode = P.BLEND_MODES.ADD; + const core = new P.Sprite(this.tex.glow); + core.anchor.set(0.5); + core.blendMode = P.BLEND_MODES.ADD; + this.projLayer.addChild(ring, core); + L = { ring, core }; + } + L.view = view; L.id = view ? view.id : null; L.src = src ?? null; L.x = x; L.y = y; L.z = z; + L.t = 0; L.idle = 0; L.max = LOCK_T / this._ts(); L.out = -1; L.shell = false; L.sx = x; L.sy = y; + L.ring.tint = L.core.tint = FX_KINDS.lock.c; + L.ring.alpha = L.core.alpha = 0; + L.ring.visible = L.core.visible = true; + this.locks.push(L); + if (this.locks.length > MAX_LOCKS) this._freeLock(this.locks.shift()); + return L; + } + + /** Shooter `src` is still at it (a new lock, a shell, a bombard): its live locks restart their LOCK_T wait. */ + _touchLocks(src) { + if (src == null) return; + for (const L of this.locks) if (L.src === src) L.idle = 0; + } + + /** The live lock of shooter `src` nearest to (x, y) within `within` tiles (`free`: only one no shell took yet). */ + _nearestLock(src, x, y, within, free) { + let best = null, bd = Infinity; + for (const L of this.locks) { + if (L.out >= 0 || (free && L.shell) || (src != null && L.src != null && L.src !== src)) continue; + const d = Math.hypot(L.x - x, L.y - y); + if (d < bd - 1e-9) { bd = d; best = L; } + } + return best && bd <= within ? best : null; + } + + /** Fade a lock out (it pops outwards as it goes). */ + _releaseLock(L) { if (L && L.out < 0) L.out = 0; } + + _freeLock(L) { + L.ring.visible = L.core.visible = false; + L.view = null; + this.lockFree.push(L); + } + + _updateLocks(dt) { + if (!this.locks.length) return; + const cam = this.ctx.cam(); + let w = 0; + for (const L of this.locks) { + L.t += dt; + L.idle += dt; + if (L.out < 0 && L.idle >= L.max) L.out = 0; + // the shooter knocked out / withdrawn: the sim fires no more shells — only a shell already in the air still lands + if (L.out < 0 && !L.shell && L.src != null && this.ctx.view) { + const sv = this._viewOf(L.src); + if (!sv || sv.alive === false) L.out = 0; + } + if (L.out >= 0) { L.out += dt; if (L.out >= LOCK_FADE) { this._freeLock(L); continue; } } + const v = L.view; + if (v && !v.destroyed && v.alive !== false) { L.x = v.x; L.y = v.y; L.z = bodyZ(cam, v, SHOT_HEIGHT.aim); } + const p = cam.project(L.x, L.y, L.z, this._p); + const s = p.s; + const out = L.out >= 0 ? L.out / LOCK_FADE : 0; + const pop = L.t < 0.16 ? 1.7 - 0.7 * easeOut(L.t / 0.16) : 1 + 0.05 * Math.sin(L.t * 9); + const a = (L.out >= 0 ? 1 - out : Math.min(1, L.t / 0.06)); + L.ring.position.set(p.x, p.y); + L.ring.rotation = L.t * 1.8; + L.ring.scale.set((s * 0.95 * pop * (1 + out * 0.6)) / 128); + L.ring.alpha = 0.95 * a; + L.core.position.set(p.x, p.y); + L.core.scale.set(((s * 0.4) / 128) * (1 + 0.25 * Math.sin(L.t * 14))); + L.core.alpha = 0.7 * a; + this.locks[w++] = L; + } + this.locks.length = w; + } +} diff --git a/public/js/render/fx/numbers.js b/public/js/render/fx/numbers.js new file mode 100644 index 00000000..54322612 --- /dev/null +++ b/public/js/render/fx/numbers.js @@ -0,0 +1,368 @@ +// public/js/render/fx/numbers.js — FxSystem damage numbers and screen-space labels. +// Installed on FxSystem.prototype by ./system.js (a method container: never instantiated; `this` is the effect system). + +import { DMG_STYLE, HIT_TINT, dmgStyleKey } from '../style.js'; +import { camKey } from './camera.js'; +import { clamp, easeOut } from './limits.js'; + +const MAX_NUMBERS = 90; + +const NUM_RISE = 0.45; // tiles a damage number rises over NUM_RISE_T (easeOut), then it stays + +const NUM_RISE_T = 0.9; // seconds of the rise + +const NUM_LIFE = 0.9; // seconds a number lives (a merged running total lives longer, ≤ NUM_MAX_LIFE) + +const NUM_MAX_LIFE = 1.8; + +const NUM_FADE_T = 0.27; // fade-out at the end of its life + +const NUM_LINE_EM = 0.95; // vertical room of one line of digits, in em of the 24 px bitmap font + +const NUM_MAX_LINES = 5; // a number starts at most this many lines above the head + +const NUM_DIGIT_EM = 0.66; // advance of one digit of the damage font, in em (Bender 700: 0.56–0.66, the widest kept) + +const NUM_POP = 1.35; // birth / merge pop scale (the layout reserves the popped size) + +const NUM_GAP_PX = 7; // horizontal gap between two numbers side by side (never read as one number) + +const NUM_MERGE_GAP = 0.3; // s: same-style hits on one unit closer than this join its running total + +const NUM_PER_TARGET = 4; // live numbers per unit (then hits join / the oldest fades) + +const NUM_CROWD = 6; // more live numbers than this around a spot → shorter lives there + +const NUM_LANES = Object.freeze([0, -1, 1, -2, 2]); // lane order (lane widths from the unit's head) + +/** Screen scale of a number's text (24 px font) at `s` px per tile. */ +const numScale = (s, big, style) => clamp(s / 115, 0.42, 1.05) * (big ? 1.35 : 1) * (style === 'heal' ? 0.9 : 1); + +/** Characters of a damage number as drawn (heals get a '+'). */ +const numChars = (v, style) => String(Math.round(v)).length + (style === 'heal' ? 1 : 0); + +/** Sub-professions whose attacks splash around the struck target (the 阵法术师 / 轰击术师 strike every enemy in range). */ +const SPLASH_SUBS = new Set(['aoesniper', 'splashcaster', 'bombarder', 'fortress', 'hammer']); + +export class FxNumbers { + // ---- hits / numbers ----------------------------------------------------------------------------------------- + + /** b.ev 'dmg' visual: glow + sparks in the hit colour; a melee blow (atk 'none' just before) adds its slash. */ + damage(view, amount, type, srcView) { + if (!view) return; + const style = dmgStyleKey(type); + const p = this._chest(view); + const px = p.x, py = p.y, s = p.s; + const tint = HIT_TINT[style] || 0xffffff; + const big = view.maxHp > 0 && amount >= view.maxHp * 0.18; + const melee = !!srcView && this._slashAt === srcView.id; + this.particle('glow', px, py, { tint, life: 0.18, s0: (s / 128) * (big ? 1.0 : 0.6), s1: (s / 128) * (big ? 1.5 : 0.9), a0: 0.9, a1: 0 }); + if (melee) { this._slashAt = null; this._slash(view, srcView, px, py, s, style, big); } + this.burst(px, py, s, big ? 8 : melee ? 6 : 4, tint, { speed: melee ? 2.8 : 2.4, size: big ? 0.6 : 0.46 }); + if (srcView && this.ctx.subProfOf && SPLASH_SUBS.has(this.ctx.subProfOf(srcView.info?.defId))) { + if (!this._lastRing || this.time - this._lastRing > 0.08) { + this._lastRing = this.time; + this.ring(view.x, view.y, view.z || 0, 0.1, 1.1, tint, 0.3); + } + } + view.onHit?.(); + if (this.ctx.settings?.damageNumbers !== false) this.number(view, amount, style, big); + } + + /** + * Melee blow on `view` by `src`: a crescent swept across the victim, bulging along the blow (attacker → victim on + * screen, a little random tilt / mirroring), in the hit colour with a white-hot inner stroke. + */ + _slash(view, src, px, py, s, style, big) { + const q = this._chest(src, this._q); + const dx = px - q.x, dy = py - q.y; + const ang = Math.abs(dx) + Math.abs(dy) > 1 ? Math.atan2(dy, dx) : (src.x > view.x ? Math.PI : 0); + const rot = ang + Math.PI / 2 + (Math.random() - 0.5) * 0.9; + const flip = Math.random() < 0.5 ? -1 : 1; + const k = big ? 1.25 : 1; + const a = this.particle('slash', px, py, { tint: style === 'phys' ? 0xffe2b0 : HIT_TINT[style] || 0xffffff, life: 0.2, s0: (s / 128) * 1.05 * k, s1: (s / 128) * 1.3 * k, a0: 1, a1: 0, rot }); + a.sx = flip; + const b = this.particle('slash', px, py, { tint: 0xffffff, life: 0.12, s0: (s / 128) * 0.8 * k, s1: (s / 128) * 1.0 * k, a0: 0.85, a1: 0, rot }); + b.sx = flip; + } + + heal(view, amount) { + if (!view) return; + const p = this._chest(view); + const s = p.s; + this.particle('glow', p.x, p.y, { tint: 0x62f08a, life: 0.3, s0: (s / 128) * 0.5, s1: (s / 128) * 0.9, a0: 0.55, a1: 0 }); + for (let i = 0; i < (this.quality === 'low' ? 1 : 3); i++) { + this.particle('plus', p.x + (Math.random() - 0.5) * s * 0.5, p.y + (Math.random() - 0.2) * s * 0.3, { + tint: 0x7dffa8, vy: -s * 0.9, life: 0.7, s0: s / 64 * 0.32, s1: s / 64 * 0.2, a0: 0.95, a1: 0, fadeIn: 0.08, + }); + } + if (this.ctx.settings?.damageNumbers !== false) this.number(view, amount, 'heal', false); + } + + /** + * A damage / heal number over `view` (see the header). Layout in screen space, against EVERY live number (the + * '41509' / '201625' overlaps were numbers of neighbouring units standing side by side): + * * merge: a same-style hit on the same unit within NUM_MERGE_GAP of that number's last hit (and while it is still + * young) adds to it — a running total that re-pops and lives a little longer; + * * cap: a unit shows at most NUM_PER_TARGET numbers; past it the hit joins the unit's youngest same-style number, + * else the unit's oldest number fades out at once; + * * lanes: the new number takes the lowest free slot of its unit's lanes (centre, then beside the head, 0 / ±1 / ±2 + * lane widths), stacking upwards at most NUM_MAX_LINES lines; a slot is free when no live number of any unit + * overlaps it now or later (a younger number rises faster: below an older one it must keep that one's rise so + * far as a margin) with a gap wide enough that two numbers never read as one; + * * crowded (> NUM_CROWD numbers near it): it lives shorter; a pair that still ends up overlapping (units walking + * into each other) resolves by fading the older one quickly (_updateNums). + */ + number(view, amount, style, big) { + const n = Math.round(amount); + if (!(n > 0) || !view) return; + const now = this.time; + const cam = this.ctx.cam(); + const base = (view.z || 0) + (view.hover || 0) + (view._headTiles || 1.2) * 0.8; + const a = cam.project(view.x, view.y, base, this._p); + const ax = a.x, ay = a.y, s = a.s > 0 ? a.s : 100; + const pxPerZ = ay - cam.project(view.x, view.y, base + 1, this._q).y; + const risePx = NUM_RISE * (pxPerZ > 1e-3 ? pxPerZ : s * 0.5); + // the live boxes only change between frames: laid out once per frame / camera, not once per hit (a heavy AoE + // lands dozens of hits in one frame — re-projecting every live number for each was O(hits × numbers)) + this._layoutNumsOnce(cam); + // 1. merge into this unit's running total of the same style + let mine = 0, youngest = null, oldest = null; + for (const t of this.nums) { + if (t.unit !== view || t.fading) continue; + mine++; + if (!oldest || t.born < oldest.born) oldest = t; + if (t.style === style && (!youngest || t.born > youngest.born)) youngest = t; + } + const merge = youngest && now - youngest.lastHit < NUM_MERGE_GAP && youngest.life < NUM_RISE_T * 0.55 ? youngest + : (mine >= NUM_PER_TARGET && youngest ? youngest : null); + if (merge && this._growFits(merge, n, big)) { + merge.value += n; + merge.text.text = (style === 'heal' ? '+' : '') + merge.value; + merge.pop = 1; + merge.big = merge.big || big; + merge.lastHit = now; + merge.end = Math.min(merge.life + NUM_LIFE * 0.75, Math.max(merge.end, merge.life + NUM_LIFE * 0.6), NUM_MAX_LIFE); + this._sizeNum(merge); + return; + } + if (mine >= NUM_PER_TARGET && oldest) this._fadeNum(oldest, 0.1); + // 2. a free slot among the unit's lanes + const sc = numScale(s, big, style); + const w = numChars(n, style) * NUM_DIGIT_EM * 24 * sc * NUM_POP + NUM_GAP_PX; + const h = 24 * NUM_LINE_EM * sc * NUM_POP; + const lane = Math.max(w, 24 * NUM_DIGIT_EM * sc * 3.2) * 0.62; + let best = null; + for (const k of NUM_LANES) { + const cx = ax + k * lane; + const cy = this._numSlotPx(cx, ay, w, h, risePx); + const lift = ay - cy; + if (!best || lift < best.lift - 0.5) best = { cx, cy, lift, k }; + if (lift <= h * 1.2) break; // low enough: keep the nearest lane + } + const cap = h * NUM_MAX_LINES; + let { cx, cy } = best; + if (ay - cy > cap) { + // over capacity (a knot of units under fire): join this unit's latest same-style total when it fits, else take + // the capped spot and drop whatever is there (crowded numbers give way at once) + let same = null; + for (const t of this.nums) if (t.unit === view && t.style === style && !t.fading && (!same || t.born > same.born)) same = t; + if (same && this._growFits(same, n, big)) { + same.value += n; + same.text.text = (style === 'heal' ? '+' : '') + same.value; + same.pop = 1; + same.big = same.big || big; + same.lastHit = now; + this._sizeNum(same); + return; + } + // (released at once, not merely ended: they would vanish before the next render anyway, and as obstacles they + // made every further hit of the same frame search a crowd that is no longer there — a knot under AoE) + cx = ax; cy = ay - cap; + let keep = 0; + for (const t of this.nums) { + if (Math.abs(t._x - cx) < (t._w + w) / 2 && t._y > cy - h && t._y - t._h < cy) { this._releaseNum(t); continue; } + this.nums[keep++] = t; + } + this.nums.length = keep; + } + // 3. crowding: numbers around this spot → a shorter life for everyone new here + let near = 0; + for (const t of this.nums) if (!t.fading && Math.abs(t._x - cx) < 150 && Math.abs(t._y - cy) < 110) near++; + const life = near >= NUM_CROWD ? NUM_LIFE * 0.62 : NUM_LIFE; + const maxNums = this.load >= 2 ? MAX_NUMBERS >> 1 : MAX_NUMBERS; + while (this.nums.length >= maxNums) this._releaseNum(this.nums.shift()); + const t = this._takeNum(style); + t.text.visible = true; + t.text.alpha = 1; + t.text.text = (style === 'heal' ? '+' : '') + n; + Object.assign(t, { + unit: view, style, value: n, life: 0, end: life, born: now, lastHit: now, pop: 1, big: !!big, fading: false, + ox: cx - ax, oy: cy - ay, x0: view.x, y0: view.y, z0: base, risePx, _x: cx, _y: cy, _w: w, _h: h, + }); + this.nums.push(t); + } + + /** Whether a merged number's grown text still fits among its neighbours (else a new number is made). */ + _growFits(t, add, big) { + const sc = numScale(t._s || 100, t.big || big, t.style); + const w = numChars(t.value + add, t.style) * NUM_DIGIT_EM * 24 * sc * NUM_POP + NUM_GAP_PX; + for (const o of this.nums) { + if (o === t || o.fading) continue; + if (Math.abs(o._x - t._x) < (o._w + w) / 2 && Math.abs(o._y - t._y) < (o._h + t._h) / 2) return false; + } + return true; + } + + /** + * Lowest (largest screen y) bottom for a number of size w×h centred at x, starting at y0, clear of every live number + * now and later: above one it must sit on its top; below one it must leave that one's rise so far as a margin + * (the new number rises faster and would catch up). + */ + _numSlotPx(x, y0, w, h, risePx) { + let y = y0; + for (let pass = 0; pass <= this.nums.length; pass++) { + let moved = false; + for (const t of this.nums) { + if (t.fading && t.text.alpha < 0.3) continue; // almost gone + if (Math.abs(t._x - x) >= (t._w + w) / 2) continue; + const top = t._y - t._h, bottom = t._y; + const risen = t.risePx * easeOut(Math.min(1, t.life / NUM_RISE_T)); + if (y <= top) continue; // entirely above it (bottom at or over its top) + if (y - h >= bottom + risen) continue; // below it, for good + y = top; + moved = true; + } + if (!moved) break; + } + return y; + } + + /** Screen boxes of the live numbers (their current anchor, rise and size) → t._x, t._y (bottom), t._w, t._h. */ + _layoutNums(cam) { + const p = this._p; + for (const t of this.nums) { + const u = t.unit; + if (u && !u.destroyed) { t.x0 = u.x; t.y0 = u.y; } + cam.project(t.x0, t.y0, t.z0, p); + t._s = p.s; + const k = Math.min(1, t.life / NUM_RISE_T); + t._x = p.x + t.ox; + t._y = p.y + t.oy - t.risePx * easeOut(k); + this._sizeNum(t); + } + this._laidAt = this.time; + this._laidCam = cam; + this._laidKey = camKey(cam); + } + + /** `_layoutNums` unless the boxes are already current: same frame (fx time) and the same camera framing. */ + _layoutNumsOnce(cam) { + if (this._laidAt === this.time && this._laidCam === cam && this._laidKey === camKey(cam)) return; + this._layoutNums(cam); + } + + /** A number's box size from its value, style, pop and the scale of its last layout (t._s px per tile). */ + _sizeNum(t) { + const sc = numScale(t._s || 100, t.big, t.style); + const pop = 1 + t.pop * (NUM_POP - 1); + t._w = numChars(t.value, t.style) * NUM_DIGIT_EM * 24 * sc * pop + NUM_GAP_PX; + t._h = 24 * NUM_LINE_EM * sc * pop; + t._sc = sc; + } + + _fadeNum(t, within) { + if (t.fading) return; + t.fading = true; + t.fadeFrom = t.text.alpha; + t.end = Math.min(t.end, t.life + within); + t.fadeT0 = t.life; + } + + _takeNum(style) { + const P = this.P; + const font = DMG_STYLE[style]?.font || DMG_STYLE.phys.font; + const pool = this._numPools || (this._numPools = new Map()); + const list = pool.get(font); + let t = list && list.length ? list.pop() : null; + if (!t) { + const text = new P.BitmapText('0', { fontName: font, fontSize: 24, align: 'center' }); + text.anchor.set(0.5, 1); + this.ctx.layers.text.addChild(text); + t = { text, font }; + } + return t; + } + + _releaseNum(t) { + t.text.visible = false; + t.unit = null; + const pool = this._numPools || (this._numPools = new Map()); + let list = pool.get(t.font); + if (!list) pool.set(t.font, list = []); + if (list.length < 30) list.push(t); else t.text.destroy(); + } + + _updateNums(dt) { + const cam = this.ctx.cam(); + let w = 0; + for (const t of this.nums) { + t.life += dt; + if (t.life >= t.end) { this._releaseNum(t); continue; } + t.pop = Math.max(0, t.pop - dt * 6); + this.nums[w++] = t; + } + this.nums.length = w; + this._layoutNums(cam); + // a pair still overlapping (their units walked into each other): the older one gives way + const L = this.nums; + for (let i = 0; i < L.length; i++) { + const a = L[i]; + if (a.fading) continue; + for (let j = i + 1; j < L.length; j++) { + const b = L[j]; + if (b.fading) continue; + if (Math.abs(a._x - b._x) < (a._w + b._w) / 2 - NUM_GAP_PX * 0.5 && Math.abs((a._y - a._h / 2) - (b._y - b._h / 2)) < (a._h + b._h) / 2 * 0.9) { + this._fadeNum(a.born <= b.born ? a : b, 0.06); + if (a.fading) break; + } + } + } + for (const t of L) { + const tx = t.text; + tx.scale.set(t._sc * (1 + t.pop * (NUM_POP - 1))); + tx.position.set(t._x, t._y); + let alpha = 1; + const left = t.end - t.life; + if (t.fading) alpha = (t.fadeFrom ?? 1) * clamp(left / Math.max(0.01, t.end - t.fadeT0), 0, 1); + else if (left < NUM_FADE_T) alpha = left / NUM_FADE_T; + tx.alpha = alpha; + } + } + + /** Floating label ('!', '+10') at a screen point, using the damage-number pool. */ + numberAt(x, y, label, tint, life = 0.8) { + const P = this.P; + if (this.labels.length >= 24) { const o = this.labels.shift(); o.t.destroy(); } + const t = new P.BitmapText(String(label), { fontName: DMG_STYLE.true.font, fontSize: 26, align: 'center' }); + t.anchor.set(0.5, 1); + t.tint = tint; + t.position.set(x, y); + this.ctx.layers.text.addChild(t); + this.labels.push({ t, y0: y, life: 0, max: life }); + } + + _updateLabels(dt) { + let w = 0; + for (const l of this.labels) { + l.life += dt; + if (l.life >= l.max) { l.t.destroy(); continue; } + const k = l.life / l.max; + l.t.position.y = l.y0 - 22 * easeOut(k); + l.t.alpha = k > 0.6 ? 1 - (k - 0.6) / 0.4 : 1; + l.t.scale.set(k < 0.12 ? 0.6 + (k / 0.12) * 0.5 : 1.1 - Math.min(0.1, k - 0.12)); + this.labels[w++] = l; + } + this.labels.length = w; + } +} diff --git a/public/js/render/fx/particles.js b/public/js/render/fx/particles.js new file mode 100644 index 00000000..2177ea84 --- /dev/null +++ b/public/js/render/fx/particles.js @@ -0,0 +1,139 @@ +// public/js/render/fx/particles.js — FxSystem particle pool, bursts, motes and puffs. +// Installed on FxSystem.prototype by ./system.js (a method container: never instantiated; `this` is the effect system). + +import { MAX_PARTICLES, NO_OPTS, clamp } from './limits.js'; + +/** Particle cap multiplier per adaptive load level (render/app.js loadLevel). */ +const LOAD_PARTICLES = Object.freeze([1, 0.75, 0.55, 0.4]); + +/** Cosmetic particles stop at this share of the particle cap: the headroom stays for hits and bursts. */ +const SOFT_CAP = 0.75; + +export class FxParticles { + get quality() { return this.ctx.settings?.quality || 'high'; } + + /** The view's adaptive load level (0–3, render/app.js): a struggling device gets fewer particles / numbers. */ + get load() { return this.ctx.loadLevel ? this.ctx.loadLevel() | 0 : 0; } + + get maxParticles() { return Math.round((MAX_PARTICLES[this.quality] || MAX_PARTICLES.high) * LOAD_PARTICLES[Math.min(3, this.load)]); } + + /** Cosmetic extras on (trails, muzzle flashes, afterimages, debris, scorch, motes): not at 'low', not under heavy load. */ + get rich() { return this.quality !== 'low' && this.load < 2; } + + /** Room for one more cosmetic particle (SOFT_CAP keeps the rest of the cap for hits and bursts). */ + _room() { return this.parts.length < this.maxParticles * SOFT_CAP; } + + /** Battle clock rate (game s per real s, ≈ 2 in combat). */ + _ts() { const r = this.ctx.timeScale ? Number(this.ctx.timeScale()) : 2; return r > 0.25 ? r : 0.25; } + + /** Ground height of the tile under (x, y). */ + _groundZ(x, y) { return this.ctx.heightAt ? (this.ctx.heightAt(Math.round(y), Math.round(x)) || 0) : 0; } + + // ---- particles ------------------------------------------------------------------------------------------ + + /** + * Spawn a screen-space particle; returns its record (set `sx` on it to mirror). `o` is only read (the per-frame + * emitters pass the shared _o() object). Records are pooled with their sprite; at the cap the oldest is recycled. + */ + particle(tex, x, y, o = NO_OPTS) { + if (this.parts.length >= this.maxParticles) this._freeParticle(this.parts.shift()); + const add = o.add !== false; + const t = this.tex[tex] || this.tex.dot; + let p = (add ? this.freeAdd : this.freeNorm).pop(); + if (!p) { + const sp = new this.P.Sprite(t); + (add ? this.addPc : this.normPc).addChild(sp); + p = { sp, add }; + } + const sp = p.sp; + sp.texture = t; + sp.anchor.set(o.anchorX ?? 0.5, o.ay ?? 0.5); + sp.visible = true; + sp.position.set(x, y); + sp.tint = o.tint ?? 0xffffff; + sp.rotation = o.rot ?? 0; + p.x = x; p.y = y; p.vx = o.vx || 0; p.vy = o.vy || 0; p.g = o.g || 0; p.drag = o.drag ?? 0; p.life = 0; p.max = o.life || 0.5; + p.s0 = o.s0 ?? 1; p.s1 = o.s1 ?? o.s0 ?? 1; p.sx = o.sx ?? 1; p.a0 = o.a0 ?? 1; p.a1 = o.a1 ?? 0; p.spin = o.spin || 0; p.fadeIn = o.fadeIn || 0; + sp.scale.set(p.s0 * p.sx, p.s0); + sp.alpha = p.fadeIn > 0 ? 0 : p.a0; + this.parts.push(p); + return p; + } + + /** The shared options object of the per-frame emitters, reset to the defaults (particle() never keeps it). */ + _o() { + const o = this._po; + o.add = true; o.tint = 0xffffff; o.vx = 0; o.vy = 0; o.g = 0; o.drag = 0; o.life = 0.5; o.s0 = 1; o.s1 = 1; o.sx = 1; + o.a0 = 1; o.a1 = 0; o.spin = 0; o.fadeIn = 0; o.rot = 0; o.anchorX = 0.5; o.ay = 0.5; + return o; + } + + _freeParticle(p) { + // Pixi's ParticleRenderer draws every child of the container (it never reads `visible`): a freed sprite becomes a + // zero-size, fully transparent quad — no pixels, no fill cost — until the record is reused + const sp = p.sp; + sp.visible = false; + sp.alpha = 0; + sp.scale.set(0, 0); + (p.add ? this.freeAdd : this.freeNorm).push(p); + } + + _updateParticles(dt) { + const list = this.parts; + let w = 0; + for (let i = 0; i < list.length; i++) { + const p = list[i]; + p.life += dt; + if (p.life >= p.max) { this._freeParticle(p); continue; } + const k = p.life / p.max; + if (p.drag) { const d = Math.max(0, 1 - p.drag * dt); p.vx *= d; p.vy *= d; } + p.vy += p.g * dt; + p.x += p.vx * dt; p.y += p.vy * dt; + const sp = p.sp; + sp.position.set(p.x, p.y); + const s = p.s0 + (p.s1 - p.s0) * k; + sp.scale.set(s * p.sx, s); + if (p.spin) sp.rotation += p.spin * dt; + let a = p.a0 + (p.a1 - p.a0) * k; + if (p.fadeIn > 0 && p.life < p.fadeIn) a *= p.life / p.fadeIn; + sp.alpha = clamp(a, 0, 1); + list[w++] = p; + } + list.length = w; + } + + /** `n` sparks flying out of a screen point (halved at quality 'low'); o: speed, up, g, life, size, tex. */ + burst(x, y, s, n, tint, o = NO_OPTS) { + const q = this.quality === 'low' ? Math.ceil(n / 2) : n; + const speed = o.speed ?? 2.2, up = o.up ?? 0, g = (o.g ?? 0) * s, life = o.life ?? 0.35, size = (s / 64) * (o.size ?? 0.45), tex = o.tex || 'spark'; + for (let i = 0; i < q; i++) { + const a = Math.random() * Math.PI * 2, v = s * speed * (0.4 + Math.random() * 0.8); + const po = this._o(); + po.tint = tint; po.vx = Math.cos(a) * v; po.vy = Math.sin(a) * v * 0.7 - up * s; po.drag = 3; po.g = g; + po.life = life * (0.7 + Math.random() * 0.6); po.s0 = size; po.s1 = (s / 64) * 0.05; po.spin = (Math.random() - 0.5) * 8; + this.particle(tex, x, y, po); + } + } + + /** Trail mote behind an orb (additive dot, drifting a little). */ + _mote(x, y, s, tint, width) { + const o = this._o(); + const j = s * width * 0.25; + o.tint = tint; o.life = 0.28; o.s0 = (s * width * 0.75) / 32; o.s1 = 0; o.a0 = 0.8; + o.vx = (Math.random() - 0.5) * s * 0.25; o.vy = (Math.random() - 0.5) * s * 0.25; + this.particle('dot', x + (Math.random() - 0.5) * j, y + (Math.random() - 0.5) * j, o); + } + + /** Trail of a shell: alternately a hot ember (additive) and a grey smoke puff (normal blend). */ + _puff(x, y, s, tint, smoke, n) { + const o = this._o(); + if ((n + (this.parts.length & 1)) & 1) { + o.tint = tint; o.life = 0.24; o.s0 = (s * 0.16) / 32; o.s1 = 0; o.a0 = 0.9; + this.particle('dot', x, y, o); + } else { + o.add = false; o.tint = smoke ?? 0x3a3430; o.life = 0.55; o.s0 = (s * 0.22) / 128; o.s1 = (s * 0.55) / 128; o.a0 = 0.45; + o.vy = -s * 0.25; o.spin = (Math.random() - 0.5) * 2; o.rot = Math.random() * 6; + this.particle('smoke', x, y, o); + } + } +} diff --git a/public/js/render/fx/projectiles.js b/public/js/render/fx/projectiles.js new file mode 100644 index 00000000..b350f3a5 --- /dev/null +++ b/public/js/render/fx/projectiles.js @@ -0,0 +1,464 @@ +// public/js/render/fx/projectiles.js — FxSystem shots, boomerangs, mortar shells and impacts. +// Installed on FxSystem.prototype by ./system.js (a method container: never instantiated; `this` is the effect system). + +import { PROJ } from '../style.js'; +import { SHOT_HEIGHT, bodyZ, feetZ } from './camera.js'; +import { clamp } from './limits.js'; +import { projSpeed } from './speeds.js'; + +const MAX_PROJ = 260; + +/** Trail particles (and boomerang afterimages) per real second of one flying projectile (cosmetic). */ +const TRAIL_HZ = 36; + +/** Real seconds a shot lingers after arriving: the tracer shrinks into the target, the head fades (short shots read). */ +const SHOT_FADE = 0.08; + +/** Boomerang: spin (rad per real s), sideways bow of each leg (tiles, at ≥ 2 tiles range), safety lifetime (real s). */ +const BOOM_SPIN = 22, BOOM_BOW = 0.35, BOOM_MAX_T = 6; + +/** Bombard shell: share of its flight spent rising from the shooter, and the height it climbs / falls from (tiles). */ +const SHELL_RISE = 0.34, SHELL_UP = 5.5; + +/** 蕾缪安 S3 shell (fx 'bombardShell'); `look` 'mortar' is its own flight (_stepMortar). */ +const BOMBARD_SHELL = Object.freeze({ look: 'mortar', tint: 0xfff2d8, glow: 0xff9c33, trail: 0xffb35c, smoke: 0x3a3430, len: 1.3, width: 0.3, head: 0.56 }); + +export class FxProjectiles { + // ---- helpers ------------------------------------------------------------------------------------------ + + _proj(x, y, z, out = this._p) { return this.ctx.cam().project(x, y, z, out); } + + /** Ground decals: on a raised top they are drawn with that block row (tiles.surfaceLayer), else in groundFx. */ + _onGround(sp, y, z) { + let layer = this.ctx.layers.groundFx; + if (z > 0.12 && this.ctx.surfaceLayer) layer = this.ctx.surfaceLayer(Math.round(y)) || layer; + if (sp.parent !== layer) layer.addChild(sp); + } + + _chest(view, out = this._p) { + const z = (view.z || 0) + (view.hover || 0) + (view._headTiles ? view._headTiles * 0.45 : 0.5); + return this._proj(view.x, view.y, z, out); + } + + /** Screen point `frac` of a unit's drawn model height above its feet (SHOT_HEIGHT: beams, a mortar's muzzle). */ + _bodyPt(view, frac, out = this._p) { + const p = this._proj(view.x, view.y, (view.z || 0) + (view.hover || 0), out); + p.y -= (view._headTiles || 1.2) * frac * p.s; + return p; + } + + // ---- projectiles ----------------------------------------------------------------------------------------- + + /** b.ev 'atk' visual. src/tgt are views (tgt may be null). */ + attack(src, tgt, kind) { + if (!src) return; + // chain: the source is the previous target of the bounce (sim ai.js), so the arc hops unit to unit + if (kind === 'chain' || kind === 'chainHeal') { if (tgt && tgt !== src) this._beam(src, tgt, kind === 'chainHeal' ? 0x7dffa8 : 0xc9a2ff, 0.22, 1, true); return; } + if (kind === 'beam') { if (tgt && tgt !== src) this._beam(src, tgt, src.isEnemy ? 0xff7a5a : 0xffe6a8, 0.18, 0.15); return; } + const spec = PROJ[kind]; + if (!spec || !tgt) { + if (kind === 'none' || !kind) this._slashAt = src.id; + return; + } + const pr = this._takeProj(); + const cam = this.ctx.cam(); + const dx = tgt.x - src.x, dy = tgt.y - src.y; + const dist = Math.hypot(dx, dy); + const ux = dist > 1e-6 ? dx / dist : (src.facing || 1) >= 0 ? 1 : -1, uy = dist > 1e-6 ? dy / dist : 0; + const hand = Math.min(0.28, dist * 0.3); // the weapon is in front of the body + const look = spec.look; + pr.kind = kind; pr.spec = spec; pr.src = src; pr.tgt = tgt; pr.rise = 0; + pr.x0 = src.x + ux * hand; pr.y0 = src.y + uy * hand; pr.z0 = bodyZ(cam, src, SHOT_HEIGHT.launch); + pr.tx = tgt.x; pr.ty = tgt.y; pr.tz = look === 'shell' ? feetZ(tgt) : bodyZ(cam, tgt, SHOT_HEIGHT.aim); + pr.t = 0; pr.fade = 0; pr.hit = false; pr.emit = Math.random(); pr.ang = Math.atan2(-uy, ux); // ≈ on screen (rows run up) + pr.dur = clamp(dist / projSpeed(kind) / this._ts(), 0.04, 1.5); + pr.arc = spec.arc ? spec.arc * clamp(0.45 + dist * 0.18, 0.6, 1.8) : 0; + pr.glow = spec.glow; + pr.trailTint = spec.trail ?? spec.glow; + // boomerang legs: kinematic, from (bx, by, bz) at constant speed towards the target, then back to the thrower + pr.phase = 0; pr.bx = pr.x0; pr.by = pr.y0; pr.bz = pr.z0; pr.trav = 0; pr.d0 = Math.max(0.1, dist); pr.ux = ux; pr.uy = uy; + pr.spin = Math.random() * 6; + this._dressProj(pr); + this.projs.push(pr); + if (this.rich) this._muzzle(pr); + } + + /** A pooled projectile record: trail + halo + core sprites (additive, above units) and a ground shadow. */ + _takeProj() { + if (this.projs.length >= MAX_PROJ) this._releaseProj(this.projs.shift()); + let pr = this.projFree.pop(); + if (!pr) { + const P = this.P; + const add = (tex, ax) => { + const sp = new P.Sprite(this.tex[tex]); + sp.anchor.set(ax, 0.5); + sp.blendMode = P.BLEND_MODES.ADD; + sp.visible = false; + return sp; + }; + const trail = add('tracer', 1), halo = add('glow', 0.5), core = add('orb', 0.5); + this.projLayer.addChild(trail, halo, core); + const shadow = new P.Sprite(this.tex.soft); + shadow.anchor.set(0.5); + shadow.tint = 0x000000; + shadow.visible = false; + this.shadowLayer.addChild(shadow); + pr = { trail, halo, core, shadow }; + } + return pr; + } + + /** Textures / tints of a projectile's sprites for its look (shown from its first update). */ + _dressProj(pr) { + const spec = pr.spec, look = spec.look; + const thin = look === 'tracer' || look === 'dart'; + const { trail, halo, core, shadow } = pr; + trail.texture = this.tex[thin ? 'tracer' : 'streak']; + trail.tint = thin ? spec.tint : pr.trailTint; + trail.visible = look !== 'boomerang'; + halo.texture = this.tex.glow; + halo.tint = pr.glow; + halo.visible = true; + core.texture = this.tex[look === 'boomerang' ? 'boomerang' : thin ? 'dot' : 'orb']; + core.tint = spec.tint; + core.rotation = 0; + core.visible = true; + shadow.visible = look === 'shell' || look === 'boomerang'; + trail.alpha = halo.alpha = core.alpha = shadow.alpha = 0; + } + + _releaseProj(pr) { + pr.trail.visible = pr.halo.visible = pr.core.visible = pr.shadow.visible = false; + pr.src = pr.tgt = null; + this.projFree.push(pr); + } + + _updateProjs(dt) { + if (!this.projs.length) return; + const cam = this.ctx.cam(); + const rich = this.rich; + let w = 0; + for (let i = 0; i < this.projs.length; i++) { + const pr = this.projs[i]; + const look = pr.spec.look; + const live = look === 'boomerang' ? this._stepBoomerang(pr, dt, cam, rich) + : look === 'mortar' ? this._stepMortar(pr, dt, cam, rich) : this._stepShot(pr, dt, cam, rich); + if (!live) { this._releaseProj(pr); continue; } + this.projs[w++] = pr; + } + this.projs.length = w; + } + + /** Screen point of a straight / lobbed shot at flight fraction k (a parabola of height `arc` over the line). */ + _shotPoint(pr, k, cam, out) { + const x = pr.x0 + (pr.tx - pr.x0) * k, y = pr.y0 + (pr.ty - pr.y0) * k; + const z = pr.z0 + (pr.tz - pr.z0) * k + (pr.arc ? pr.arc * 4 * k * (1 - k) : 0); + return cam.project(x, y, z, out); + } + + /** + * One frame of a tracer / orb / shell / dart: placed by flight time (homing on the target), trail behind the head + * along the path (never longer than the part flown), an arrival burst, then a SHOT_FADE linger in the target. + */ + _stepShot(pr, dt, cam, rich) { + const spec = pr.spec, look = spec.look; + pr.t += dt; + const tg = pr.tgt; + if (tg && !tg.destroyed && tg.alive !== false) { pr.tx = tg.x; pr.ty = tg.y; pr.tz = look === 'shell' ? feetZ(tg) : bodyZ(cam, tg, SHOT_HEIGHT.aim); } + const k = Math.min(1, pr.t / pr.dur); + if (k >= 1 && !pr.hit) { pr.hit = true; pr.fade = 0; this._impact(pr, cam); } + let fk = 0; + if (pr.hit) { pr.fade += dt; fk = pr.fade / SHOT_FADE; if (fk >= 1) return false; } + const p = this._shotPoint(pr, k, cam, this._p); + const kb = Math.max(0, k - (pr.arc ? 0.12 : 0.25)); + const q = this._shotPoint(pr, kb, cam, this._q); + const s = p.s, px = p.x, py = p.y; + const seg = Math.hypot(px - q.x, py - q.y); + if (seg > 0.5) pr.ang = Math.atan2(py - q.y, px - q.x); + const thin = look === 'tracer' || look === 'dart'; + const flown = k > kb ? seg * (k / (k - kb)) : 0; + const L = Math.min(spec.len * s, flown) * (1 - fk); + const tr = pr.trail; + tr.position.set(px, py); + tr.rotation = pr.ang; + tr.scale.set(Math.max(0.001, L / 128), (spec.width * s) / (thin ? 16 : 20)); + tr.alpha = (thin ? 1 : 0.85) * (1 - fk); + const hs = spec.head * s; + const halo = pr.halo; + halo.position.set(px, py); + halo.scale.set((hs / 128) * (1 + 0.12 * Math.sin(pr.t * 40))); + halo.alpha = 0.85 * (1 - fk); + const core = pr.core; + core.position.set(px, py); + if (thin) core.scale.set((hs * 0.5) / 32); + else if (look === 'shell') { core.rotation = pr.ang; core.scale.set((hs * 0.85) / 64, (hs * 0.55) / 64); } // a shell along its flight + else core.scale.set((hs * 0.62) / 64); + core.alpha = 1 - fk; + if (look === 'shell') { + // its shadow on the ground under it: bigger and darker the lower it flies + const gx = pr.x0 + (pr.tx - pr.x0) * k, gy = pr.y0 + (pr.ty - pr.y0) * k; + const g = cam.project(gx, gy, this._groundZ(gx, gy) + 0.01, this._g); + const near = 1 - clamp((pr.arc * 4 * k * (1 - k)) / Math.max(0.3, pr.arc), 0, 1); + const r = g.s * (0.17 + 0.13 * near); + const sh = pr.shadow; + sh.position.set(g.x, g.y); + sh.scale.set((r * 2) / 128, (r * 0.9) / 128); + sh.alpha = (0.32 + 0.3 * near) * (1 - fk); + } + if (rich && !pr.hit && !thin) { + pr.emit += dt * TRAIL_HZ; + for (let n = 0; pr.emit >= 1 && n < 2; n++) { + pr.emit -= 1; + if (!this._room()) { pr.emit = 0; break; } + if (look === 'shell') this._puff(px, py, s, pr.trailTint, spec.smoke, n); + else this._mote(px, py, s, pr.trailTint, spec.width); + } + } + return true; + } + + /** + * 回环射手 (sim ai.js throwBoomerang): out to the (moving) target at the boomerang speed, a hit flash, then back to the + * thrower's current position at the return speed (PRTS 跃跃: 15 out, 3.75 back); gone when caught — or at once when + * the thrower's view is gone (the sim drops a boomerang whose thrower left). Each leg bows sideways (left of its own + * direction, so out and back form a loop); it spins, leaves afterimages and a shadow. + */ + _stepBoomerang(pr, dt, cam, rich) { + const spec = pr.spec; + pr.t += dt; + if (pr.t > BOOM_MAX_T) return false; + const step = projSpeed(pr.phase ? 'boomerangReturn' : pr.kind) * this._ts() * dt; + let gx, gy, gz; + if (pr.phase === 0) { + const tg = pr.tgt; + if (tg && !tg.destroyed && tg.alive !== false) { pr.tx = tg.x; pr.ty = tg.y; pr.tz = bodyZ(cam, tg, SHOT_HEIGHT.aim); } + gx = pr.tx; gy = pr.ty; gz = pr.tz; + } else { + const sv = pr.src; + if (!sv || sv.destroyed || sv.alive === false) return false; + gx = sv.x; gy = sv.y; gz = bodyZ(cam, sv, SHOT_HEIGHT.launch); + } + const dx = gx - pr.bx, dy = gy - pr.by, dz = gz - pr.bz; + const d = Math.hypot(dx, dy, dz); + if (d <= step) { + pr.bx = gx; pr.by = gy; pr.bz = gz; + if (pr.phase === 1) { this._catch(pr, cam); return false; } + this._impact(pr, cam); + // turn back: the next leg runs from here to the thrower (a thrower already gone gets nothing back) + const sv = pr.src; + if (!sv || sv.destroyed || sv.alive === false) return false; + const bx = sv.x - gx, by = sv.y - gy; + const bd = Math.hypot(bx, by); + pr.phase = 1; pr.trav = 0; pr.d0 = Math.max(0.1, bd); + if (bd > 1e-6) { pr.ux = bx / bd; pr.uy = by / bd; } else { pr.ux = -pr.ux; pr.uy = -pr.uy; } + } else { + pr.bx += (dx / d) * step; pr.by += (dy / d) * step; pr.bz += (dz / d) * step; + pr.trav += step; + } + const arcK = Math.sin(clamp(pr.trav / pr.d0, 0, 1) * Math.PI); + const bow = arcK * BOOM_BOW * Math.min(1, pr.d0 / 2); + const x = pr.bx - pr.uy * bow, y = pr.by + pr.ux * bow, z = pr.bz + arcK * 0.12; + const p = cam.project(x, y, z, this._p); + const px = p.x, py = p.y, hs = spec.head * p.s; + pr.spin += BOOM_SPIN * dt; + const core = pr.core; + core.position.set(px, py); + core.rotation = pr.spin; + core.scale.set(hs / 64); + core.alpha = 1; + const halo = pr.halo; + halo.position.set(px, py); + halo.scale.set((hs * 1.5) / 128); + halo.alpha = 0.4; + const g = cam.project(x, y, this._groundZ(x, y) + 0.01, this._g); + const sh = pr.shadow; + sh.position.set(g.x, g.y); + sh.scale.set((g.s * 0.4) / 128, (g.s * 0.18) / 128); + sh.alpha = 0.22; + if (rich) { + pr.emit += dt * TRAIL_HZ; + if (pr.emit >= 1) { + pr.emit = Math.min(1, pr.emit - 1); + if (this._room()) { + const o = this._o(); + o.tint = spec.glow; o.life = 0.14; o.s0 = hs / 64; o.s1 = (hs / 64) * 0.9; o.a0 = 0.4; o.rot = pr.spin; + this.particle('boomerang', px, py, o); + } + } + } + return true; + } + + /** + * One frame of a 蕾缪安 bombard shell: a long flight first streaks up out of the shooter (`rise` of it, following + * her); then it falls onto its spot, faster and faster, its shadow growing there. It ends at the impact — the + * explosion (and the end of its lock) is the sim's own 'bombard' fx. + */ + _stepMortar(pr, dt, cam, rich) { + const spec = pr.spec; + pr.t += dt; + const k = pr.t / pr.dur; + if (k >= 1) return false; + let x, y, z, zq; + if (k < pr.rise) { + const u = k / pr.rise, ub = Math.max(0, u - 0.25); + const sv = pr.src; + if (sv && !sv.destroyed) { pr.x0 = sv.x; pr.y0 = sv.y; } + x = pr.x0; y = pr.y0; + z = pr.z0 + SHELL_UP * (1 - (1 - u) * (1 - u)); // out of the barrel fast, slowing as it climbs + zq = pr.z0 + SHELL_UP * (1 - (1 - ub) * (1 - ub)); + } else { + const u = (k - pr.rise) / (1 - pr.rise), ub = Math.max(0, u - 0.25); + x = pr.tx; y = pr.ty; + z = pr.tz + SHELL_UP * (1 - u * u); // falling faster and faster + zq = pr.tz + SHELL_UP * (1 - ub * ub) + 0.3; + const g = cam.project(x, y, pr.tz + 0.01, this._g); + const r = g.s * (0.12 + 0.3 * u); + const sh = pr.shadow; + sh.visible = true; + sh.position.set(g.x, g.y); + sh.scale.set((r * 2) / 128, (r * 0.9) / 128); + sh.alpha = 0.12 + 0.36 * u; + } + const p = cam.project(x, y, z, this._p); + const q = cam.project(x, y, zq, this._q); + const s = p.s, px = p.x, py = p.y; + const seg = Math.hypot(px - q.x, py - q.y); + if (seg > 0.5) pr.ang = Math.atan2(py - q.y, px - q.x); + const tr = pr.trail; + tr.position.set(px, py); + tr.rotation = pr.ang; + tr.scale.set(Math.max(0.001, Math.min(spec.len * s, seg * 1.6) / 128), (spec.width * s) / 20); + tr.alpha = 0.95; + const hs = spec.head * s; + pr.halo.position.set(px, py); + pr.halo.scale.set((hs / 128) * (1 + 0.15 * Math.sin(pr.t * 50))); + pr.halo.alpha = 0.95; + pr.core.position.set(px, py); + pr.core.rotation = pr.ang; + pr.core.scale.set((hs * 0.85) / 64, (hs * 0.5) / 64); + pr.core.alpha = 1; + if (rich) { + pr.emit += dt * TRAIL_HZ; + for (let n = 0; pr.emit >= 1 && n < 2; n++) { + pr.emit -= 1; + if (!this._room()) { pr.emit = 0; break; } + this._puff(px, py, s, spec.trail, spec.smoke, n); + } + } + return true; + } + + /** Flash at the shooter as a shot leaves (tracers: an oriented muzzle cone). Cosmetic. */ + _muzzle(pr) { + const spec = pr.spec, look = spec.look; + if (!spec.muzzle) return; + const cam = this.ctx.cam(); + const p = cam.project(pr.x0, pr.y0, pr.z0, this._g); + const px = p.x, py = p.y, s = p.s; + if (look === 'tracer') { + const q = cam.project(pr.tx, pr.ty, pr.tz, this._q); + this.particle('muzzle', px, py, { tint: spec.muzzle, life: 0.09, s0: (s / 64) * 0.55, s1: (s / 64) * 0.72, a0: 1, a1: 0, rot: Math.atan2(q.y - py, q.x - px), anchorX: 0.19 }); + this.particle('glow', px, py, { tint: spec.muzzle, life: 0.1, s0: (s / 128) * 0.45, s1: (s / 128) * 0.7, a0: 0.9, a1: 0 }); + } else { + this.particle('glow', px, py, { tint: pr.glow === spec.glow ? spec.muzzle : pr.glow, life: 0.16, s0: (s / 128) * 0.45, s1: (s / 128) * 0.8, a0: 0.9, a1: 0 }); + if (look === 'shell') this.smoke(px, py, s * 0.28, spec.smoke ?? 0x2a2522, 0.3); + } + } + + /** Arrival burst of a shot (PROJ `hit`): at the target, or the ground under it for shells. */ + _impact(pr, cam) { + const spec = pr.spec; + const p = cam.project(pr.tx, pr.ty, pr.tz, this._g); + const x = p.x, y = p.y, s = p.s; + const rich = this.rich; + switch (spec.hit) { + case 'arts': + this.particle('glow', x, y, { tint: pr.glow, life: 0.24, s0: (s / 128) * 0.7, s1: (s / 128) * 1.2, a0: 0.95, a1: 0 }); + this.particle('shock', x, y, { tint: pr.glow, life: 0.26, s0: (s / 128) * 0.25, s1: (s / 128) * 1.05, a0: 0.9, a1: 0 }); + if (rich) this.burst(x, y, s, 4, pr.trailTint, { speed: 2.2, life: 0.3, tex: 'dot', size: 0.35 }); + break; + case 'heal': { + this.particle('glow', x, y, { tint: spec.glow, life: 0.3, s0: (s / 128) * 0.6, s1: (s / 128) * 1.1, a0: 0.9, a1: 0 }); + this.particle('flare', x, y, { tint: 0xc8ffd8, life: 0.22, s0: (s / 128) * 0.9, s1: (s / 128) * 0.3, a0: 1, a1: 0, rot: Math.random() }); + const n = rich ? 3 : 1; + for (let i = 0; i < n; i++) { + this.particle('plus', x + (Math.random() - 0.5) * s * 0.45, y - Math.random() * s * 0.2, { tint: 0x7dffa8, vy: -s * 0.8, life: 0.6, s0: (s / 64) * 0.26, s1: (s / 64) * 0.14, a0: 0.95, a1: 0, fadeIn: 0.05 }); + } + break; + } + case 'boom': this.explosion(pr.tx, pr.ty, this._groundZ(pr.tx, pr.ty), 1, pr.glow, { smoke: spec.smoke, small: true }); break; + case 'splash': this.explosion(pr.tx, pr.ty, this._groundZ(pr.tx, pr.ty), 0.7, pr.glow, { smoke: spec.smoke, small: true }); break; + case 'zap': + this.particle('flare', x, y, { tint: spec.glow, life: 0.16, s0: (s / 128) * 0.7, s1: (s / 128) * 0.25, a0: 1, a1: 0, rot: Math.random() }); + if (rich) this.burst(x, y, s, 3, spec.glow, { speed: 2.4, life: 0.22, size: 0.35 }); + break; + case 'enemy': + this.particle('glow', x, y, { tint: spec.glow, life: 0.2, s0: (s / 128) * 0.6, s1: (s / 128) * 1.0, a0: 0.9, a1: 0 }); + this.particle('flare', x, y, { tint: 0xffb0a0, life: 0.16, s0: (s / 128) * 0.8, s1: (s / 128) * 0.3, a0: 1, a1: 0, rot: Math.random() }); + break; + default: // 'spark': bullets, boomerang hits (the hit's own sparks come with its damage number) + this.particle('flare', x, y, { tint: spec.glow, life: 0.11, s0: (s / 128) * 0.55, s1: (s / 128) * 0.2, a0: 1, a1: 0, rot: Math.random() }); + } + } + + /** The thrower caught its boomerang: a small flash in its hands. */ + _catch(pr, cam) { + const p = cam.project(pr.bx, pr.by, pr.bz, this._g); + this.particle('flare', p.x, p.y, { tint: pr.spec.glow, life: 0.12, s0: (p.s / 128) * 0.6, s1: (p.s / 128) * 0.2, a0: 0.9, a1: 0, rot: Math.random() }); + } + + /** + * 蕾缪安 S3 shell (fx 'bombardShell'): fired now by `src` (its view, or null) at the spot (x, y), where it lands after + * `flight` real seconds (_stepMortar; the sim's shells fly 0.3 game s — they drop out of the sky, a launch streak + * leaving the shooter; a flight ≥ 0.45 real s climbs out of her first). The spot gets a thin ring closing in and + * brightening plus the blast radius throbbing until the impact. The shell takes the shooter's lock on that spot: the + * lock ends with this shell's 'bombard' (_landed). + */ + mortar(src, x, y, r, flight) { + const pr = this._takeProj(); + const gz = this._groundZ(x, y); + pr.kind = 'bombardShell'; pr.spec = BOMBARD_SHELL; pr.src = src; pr.tgt = null; + pr.x0 = src ? src.x : x; pr.y0 = src ? src.y : y; pr.z0 = src ? bodyZ(this.ctx.cam(), src, SHOT_HEIGHT.launch) : gz + 0.5; + pr.tx = x; pr.ty = y; pr.tz = gz; + pr.t = 0; pr.dur = clamp(flight, 0.1, 4); pr.fade = 0; pr.hit = false; pr.emit = 0; pr.arc = 0; pr.ang = Math.PI / 2; + pr.rise = pr.dur >= 0.45 ? SHELL_RISE : 0; + pr.glow = BOMBARD_SHELL.glow; pr.trailTint = BOMBARD_SHELL.trail; + const L = this._nearestLock(src ? src.id : null, x, y, 3, true); + if (L) { L.shell = true; L.sx = x; L.sy = y; } + this._dressProj(pr); + pr.shadow.visible = false; + this.projs.push(pr); + const rr = Math.max(0.5, r); + this.ring(x, y, gz, rr, rr * 0.22, 0xff5a3a, pr.dur, 'shock', 'in'); + this.ring(x, y, gz, rr * 0.96, rr, 0xff7a4a, pr.dur, 'ring', 'pulse'); + if (src && this.rich) { + // the shot leaves her upwards: a muzzle flash and a streak climbing out of sight + const p = this._bodyPt(src, SHOT_HEIGHT.launch, this._g); + const s = p.s; + this.particle('muzzle', p.x, p.y, { tint: 0xffc27a, life: 0.1, s0: (s / 64) * 0.6, s1: (s / 64) * 0.8, a0: 1, a1: 0, rot: -Math.PI / 2, anchorX: 0.19 }); + this.particle('glow', p.x, p.y, { tint: 0xffb35c, life: 0.14, s0: (s / 128) * 0.5, s1: (s / 128) * 0.9, a0: 0.9, a1: 0 }); + this.particle('tracer', p.x, p.y, { tint: 0xffe0b0, life: 0.16, vy: -s * 16, s0: (s * 0.3) / 16, s1: (s * 0.24) / 16, sx: (1.3 * 16) / (0.3 * 128), a0: 1, a1: 0.2, rot: -Math.PI / 2, anchorX: 1 }); + } + return pr; + } + + /** + * fx 'bombard' (the impact) at (x, y) by shooter `src`: the shell falling there has landed (ended now if still in + * the air) and its lock goes — the lock that shell took, found by the landing point (the sim uses the same spot for + * both events); a sim without 'bombardShell' releases the shooter's lock nearest to the spot. + */ + _landed(src, x, y, r) { + for (const pr of this.projs) { + if (pr.spec !== BOMBARD_SHELL || (src != null && pr.src && pr.src.id !== src)) continue; + if (Math.hypot(pr.tx - x, pr.ty - y) < 0.05) pr.t = Math.max(pr.t, pr.dur); + } + let best = null, bd = Infinity; + for (const L of this.locks) { + if (L.out >= 0 || (src != null && L.src != null && L.src !== src)) continue; + const d = L.shell ? Math.hypot(L.sx - x, L.sy - y) : Math.hypot(L.x - x, L.y - y) + 0.5; + if (d < bd) { bd = d; best = L; } + } + if (best && bd <= r + 1.5) this._releaseLock(best); + } +} diff --git a/public/js/render/fx/rings.js b/public/js/render/fx/rings.js new file mode 100644 index 00000000..11f2907c --- /dev/null +++ b/public/js/render/fx/rings.js @@ -0,0 +1,136 @@ +// public/js/render/fx/rings.js — FxSystem ground rings and skill auras. +// Installed on FxSystem.prototype by ./system.js (a method container: never instantiated; `this` is the effect system). + +import { SKILL_GOLD, easeOut } from './limits.js'; + +export class FxRings { + // ---- rings / auras ----------------------------------------------------------------------------------------- + + /** + * Ground ring going from r0 to r1 tiles over `dur` real seconds (easing out, fading). `mode` 'in': a warning closing + * in — linear, brightening towards its end; 'pulse': throbbing at full size until its end. + */ + ring(x, y, z, r0, r1, tint, dur = 0.4, tex = 'ring', mode = null) { + const P = this.P; + let r = this.ringFree.pop(); + if (!r) { + const sp = new P.Sprite(this.tex[tex]); + sp.anchor.set(0.5); + sp.blendMode = P.BLEND_MODES.ADD; + this.ctx.layers.groundFx.addChild(sp); + r = { sp }; + } + r.sp.texture = this.tex[tex] || this.tex.ring; + r.sp.visible = true; + r.sp.tint = tint; + r.sp.alpha = 0; + this._onGround(r.sp, y, z); + r.x = x; r.y = y; r.z = z; r.r0 = r0; r.r1 = r1; r.t = 0; r.dur = Math.max(0.01, dur); r.mode = mode; + this.rings.push(r); + if (this.rings.length > 80) { const o = this.rings.shift(); o.sp.visible = false; this.ringFree.push(o); } + } + + _updateRings(dt) { + const cam = this.ctx.cam(); + const p = this._p, q = this._q; + let w = 0; + for (const r of this.rings) { + r.t += dt; + if (r.t >= r.dur) { r.sp.visible = false; this.ringFree.push(r); continue; } + const u = r.t / r.dur; + const k = r.mode === 'in' ? u : easeOut(u); + const rad = r.r0 + (r.r1 - r.r0) * k; + cam.project(r.x, r.y, r.z + 0.01, p); + cam.project(r.x, r.y + rad, r.z + 0.01, q); + const rx = p.s * rad, ry = Math.max(1, p.y - q.y); + r.sp.position.set(p.x, p.y); + r.sp.scale.set((rx * 2) / 128, (ry * 2) / 128); + r.sp.alpha = r.mode === 'in' ? 0.35 + 0.65 * u + : r.mode === 'pulse' ? (0.35 + 0.35 * Math.abs(Math.sin(r.t * 9))) * Math.min(1, (r.dur - r.t) / 0.06, r.t / 0.06) + : 1 - u; + this.rings[w++] = r; + } + this.rings.length = w; + } + + /** + * Skill activation (on) / end (off). On: a flash at the body, a gold light pillar with a white-hot core from the + * feet, a shockwave and a hex ring on the ground, rising motes — then the active aura (_aura) until it ends. + */ + skill(view, on) { + if (!view) return; + if (!on) { this._aura(view, false); return; } + const z = (view.z || 0) + (view.hover || 0); + const g = this._proj(view.x, view.y, z, this._g); + const gx = g.x, gy = g.y, s = g.s; + const c = this._chest(view, this._q); + this.particle('pillar', gx, gy, { tint: SKILL_GOLD, life: 0.7, s0: (s / 64) * 1.05, s1: (s / 64) * 1.3, a0: 0.95, a1: 0, sx: 0.8, ay: 1 }); + this.particle('pillar', gx, gy, { tint: 0xffffff, life: 0.38, s0: (s / 64) * 0.9, s1: (s / 64) * 1.15, a0: 0.9, a1: 0, sx: 0.28, ay: 1 }); + this.particle('flare', c.x, c.y, { tint: 0xfff0b0, life: 0.3, s0: (s / 128) * 1.9, s1: (s / 128) * 0.6, a0: 1, a1: 0, rot: Math.random() }); + this.particle('glow', c.x, c.y, { tint: SKILL_GOLD, life: 0.36, s0: (s / 128) * 1.2, s1: (s / 128) * 2.4, a0: 0.9, a1: 0 }); + this.ring(view.x, view.y, z, 0.15, 1.7, 0xffe7a0, 0.45, 'shock'); + this.ring(view.x, view.y, z, 0.3, 1.25, SKILL_GOLD, 0.6, 'hex'); + this.burst(c.x, c.y, s, this.rich ? 10 : 4, 0xffe28a, { speed: 1.4, up: 1.6, life: 0.7, tex: 'dot', size: 0.34 }); + this._aura(view, true); + } + + /** Active-skill aura: a soft gold glow and a slowly turning hex on the ground under the unit (fading in / out). */ + _aura(view, on) { + const P = this.P; + let a = this.auras.get(view.id); + if (on) { + if (!a) { + // the root is squashed onto the ground; the hex turns inside it (so it turns in the ground plane) + const root = new P.Container(); + const disc = new P.Sprite(this.tex.soft); + disc.anchor.set(0.5); disc.blendMode = P.BLEND_MODES.ADD; disc.tint = SKILL_GOLD; + const hex = new P.Sprite(this.tex.hex); + hex.anchor.set(0.5); hex.blendMode = P.BLEND_MODES.ADD; hex.tint = 0xffc94a; + root.addChild(disc, hex); + root.alpha = 0; + this.ctx.layers.groundFx.addChild(root); + a = { sp: root, disc, hex, view, t: 0, mote: 0 }; + this.auras.set(view.id, a); + } + a.view = view; + a.off = false; + } else if (a) a.off = true; + } + + _updateAuras(dt) { + if (!this.auras.size) return; + const cam = this.ctx.cam(); + const p = this._p, q = this._q; + const rich = this.rich; + for (const [id, a] of this.auras) { + a.t += dt; + const v = a.view; + const ending = a.off || !v || v.destroyed || v.alive === false; + if (ending) { + a.sp.alpha -= dt * 3; + if (a.sp.alpha <= 0) { a.sp.destroy({ children: true }); this.auras.delete(id); continue; } + } else a.sp.alpha = Math.min(1, a.sp.alpha + dt * 5); + if (!v || v.destroyed) continue; + const z = (v.z || 0) + 0.01; + this._onGround(a.sp, v.y, v.z || 0); + cam.project(v.x, v.y, z, p); + cam.project(v.x, v.y + 0.55, z, q); + a.sp.position.set(p.x, p.y); + a.sp.scale.set((p.s * 0.95) / 128, (Math.max(1, p.y - q.y) * 1.72) / 128); + a.hex.rotation = a.t * 0.9; + a.hex.alpha = 0.8 + 0.2 * Math.sin(a.t * 4); + a.disc.alpha = 0.42 + 0.1 * Math.sin(a.t * 4); + // now and then a mote rises from the ring + if (rich && !ending && (a.mote += dt) >= 0.3) { + a.mote = 0; + if (this._room()) { + const ang = Math.random() * Math.PI * 2; + const m = cam.project(v.x + Math.cos(ang) * 0.38, v.y + Math.sin(ang) * 0.3, z, this._g); + const o = this._o(); + o.tint = 0xffe28a; o.vy = -m.s * 0.9; o.life = 0.75; o.s0 = (m.s / 32) * 0.14; o.s1 = 0; o.a0 = 0.9; o.fadeIn = 0.1; + this.particle('dot', m.x, m.y, o); + } + } + } + } +} diff --git a/public/js/render/fx/simfx.js b/public/js/render/fx/simfx.js new file mode 100644 index 00000000..4eaa406d --- /dev/null +++ b/public/js/render/fx/simfx.js @@ -0,0 +1,298 @@ +// public/js/render/fx/simfx.js — FxSystem placement of a sim fx event. +// Installed on FxSystem.prototype by ./system.js (a method container: never instantiated; `this` is the effect system). + +import { SHOT_HEIGHT, bodyZ } from './camera.js'; +import { fxSpec, tilesAround, wallTiles } from './kinds.js'; +import { clamp } from './limits.js'; + +/** + * An fx anchored on a unit (extra.id) is drawn at that unit's rendered position while the event's own (x, y) is within + * this many tiles of it (it happens on the unit); farther away the fx happens at (x, y) — the sim puts the caster in + * `id` of many area / target effects (an 'aoe' ahead of the caster, a 'crit' on the victim, 蕾缪安's 'bombard'). + */ +const ANCHOR_SNAP = 0.75; + +const num = (v, d) => { const n = typeof v === 'number' ? v : typeof v === 'string' && v.trim() !== '' ? Number(v) : NaN; return Number.isFinite(n) ? n : d; }; + +export class FxSim { + // ---- sim fx (b.ev ['fx', kind, x, y, extra]) ------------------------------------------------------------- + + /** Unit view of an fx's `id` / `src` / … (null when unknown or gone). */ + _viewOf(id) { + if (id == null || !this.ctx.view) return null; + const v = this.ctx.view(id); + return v && !v.destroyed ? v : null; + } + + /** + * Where an fx happens: on its anchor unit `ex.id` (at the unit's rendered position, so it sticks to a moving unit) + * when the event's (x, y) is that unit's spot (within ANCHOR_SNAP tiles) or has none; otherwise at (x, y) on the ground + * — the sim often names the caster in `id` of an effect elsewhere ('aoe' at a target or ahead of the caster, 'crit' + * on the victim, 'zone' where a skill lands), which used to be drawn on the caster. + */ + _where(x, y, ex) { + const v = this._viewOf(ex.id); + if (v && Number.isFinite(v.x) && Number.isFinite(v.y)) { + const here = !Number.isFinite(x) || !Number.isFinite(y) || Math.hypot(v.x - x, v.y - y) <= ANCHOR_SNAP; + if (here) return { x: v.x, y: v.y, z: (v.z || 0) + (v.hover || 0), v }; + } + return this._point(x, y); + } + + /** An fx at the sim position (x, y), on the ground there. */ + _point(x, y) { + return { x, y, z: Number.isFinite(x) && Number.isFinite(y) ? this._groundZ(x, y) : 0, v: null }; + } + + /** + * b.ev 'fx': every kind the sim / content emits has a visual (FX_KINDS archetypes: blast, shell, zone, telegraph, + * heal, sp, shield, shatter, summon, vanish, blink, move, wave, mark, reticle, buff, lift, sleep, crit, dodge, + * counter, dp, coin, crate, down, beam, bolt, strike, volley, pillar, lp, chill, element, flame), except kinds whose + * archetype is 'none' (hitCap: a leader hit cancelled by 限伤 draws nothing); unknown kinds get a generic sparkle. `extra` keys used: id (anchor unit — or the shooter of a `pt` kind), r | radius, dur | duration, + * t (shell flight, game s), src / from / to / target / targets (unit ids), fx, fy / fromX, fromY / tx, ty (positions), + * element, n, scale, kind, tiles. + */ + simFx(kind, x, y, extra) { + const ex = extra && typeof extra === 'object' ? extra : {}; + const spec = fxSpec(kind, ex); + if (spec.a === 'none') return; // an event the screen does not show (hitCap) + const at = spec.pt ? this._point(Number(x), Number(y)) : this._where(Number(x), Number(y), ex); + if (!Number.isFinite(at.x) || !Number.isFinite(at.y)) return; + // 蕾缪安 S2: the aimed snipe ('crit' on the locked enemy, from her) ends that aim lock + if (kind === 'crit' && ex.src != null && ex.id != null) { + for (const L of this.locks) if (L.src === ex.src && L.id === ex.id) this._releaseLock(L); + } + const col = spec.c; + const r = clamp(num(ex.r ?? ex.radius, spec.r ?? 1), 0.3, 30); + const ts = this.ctx.timeScale ? Math.max(0.25, this.ctx.timeScale()) : 2; + const dur = num(ex.dur ?? ex.duration, spec.dur ?? 0) / ts; + const cam = this.ctx.cam(); + const chest = (v, out = this._p) => (v ? this._chest(v, out) : cam.project(at.x, at.y, at.z + 0.5, out)); + const p = chest(at.v); + const s = p.s; + switch (spec.a) { + case 'blast': { + if (r >= 12) { this.flashScreen(col, 0.5); break; } + if (kind === 'bombard') { this._touchLocks(ex.id ?? ex.src ?? null); this._landed(ex.id ?? ex.src ?? null, at.x, at.y, r); } + this.explosion(at.x, at.y, at.z, r, col, { smoke: spec.smoke, heavy: !!spec.heavy, tiles: ex.tiles ? tilesAround(at.x, at.y, r, ex.tiles) : null }); + break; + } + case 'shell': { + // 蕾缪安 S3: a shell fired now by `id` that lands at (x, y) after `t` game seconds (its 'bombard' explodes there) + const flight = num(ex.t ?? ex.flight ?? ex.dur ?? ex.duration, 1) / ts; + this._touchLocks(ex.id ?? ex.src ?? null); + this.mortar(this._viewOf(ex.id ?? ex.src), at.x, at.y, r, flight); + break; + } + case 'zone': this.zone(at.x, at.y, at.z, r, col, Math.max(0.6, dur || 1.5), spec.tex); break; + case 'wall': { + // a line of burning tiles through the anchor tile along `axis` ('col' | 'row', from the sim event) + const rect = this.ctx.fieldRect ? this.ctx.fieldRect() : null; + this.tileFlash(wallTiles(Number(x), Number(y), ex.axis === 'row' ? 'row' : 'col', rect), col, Math.max(0.6, dur || 1.5)); + this.zone(at.x, at.y, at.z, 0.6, col, Math.max(0.6, dur || 1.5), spec.tex); + break; + } + case 'telegraph': { + const d = Math.max(0.4, dur || 1); + if (r >= 12) { this.flashScreen(col, 0.45, d); break; } + if (ex.tiles) this.tileFlash(tilesAround(at.x, at.y, r, ex.tiles), col, d, true); + else this.zone(at.x, at.y, at.z, r, col, d, 'ring', true); + break; + } + case 'chill': this.flashScreen(col, 0.35, 0.8); this.snowfall(col); break; + case 'heal': this.heal(at.v || { x: at.x, y: at.y, z: at.z }, 0); break; + case 'healAoe': { + this.ring(at.x, at.y, at.z, 0.2, r, col, 0.6); + const n = this.quality === 'low' ? 4 : 9; + for (let i = 0; i < n; i++) { + const a = Math.random() * Math.PI * 2, d = Math.random() * r * 0.85; + const q = cam.project(at.x + Math.cos(a) * d, at.y + Math.sin(a) * d, at.z + 0.2); + this.particle('plus', q.x, q.y, { tint: col, vy: -q.s * 0.9, life: 0.8, s0: q.s / 64 * 0.3, s1: q.s / 64 * 0.18, a0: 0.9, a1: 0, fadeIn: 0.1 }); + } + break; + } + case 'sp': { + for (let i = 0; i < (this.quality === 'low' ? 3 : 7); i++) { + this.particle('dot', p.x + (Math.random() - 0.5) * s * 0.6, p.y + (Math.random() - 0.2) * s * 0.4, { tint: col, vy: -s * (0.6 + Math.random() * 0.6), life: 0.7, s0: s / 32 * 0.2, s1: 0, a0: 1, a1: 0, fadeIn: 0.05 }); + } + this.particle('glow', p.x, p.y, { tint: col, life: 0.3, s0: s / 128 * 0.6, s1: s / 128 * 1.1, a0: 0.7, a1: 0 }); + break; + } + case 'shield': { + this.particle('hex', p.x, p.y, { tint: col, life: 0.55, s0: s / 128 * 0.9, s1: s / 128 * 1.25, a0: 0.85, a1: 0, sx: 0.85 }); + this.particle('glow', p.x, p.y, { tint: col, life: 0.4, s0: s / 128 * 0.9, s1: s / 128 * 1.3, a0: 0.5, a1: 0 }); + break; + } + case 'shatter': { + this.particle('hex', p.x, p.y, { tint: col, life: 0.25, s0: s / 128 * 1.1, s1: s / 128 * 1.5, a0: 0.9, a1: 0 }); + for (let i = 0; i < (this.quality === 'low' ? 5 : 12); i++) { + const a = Math.random() * Math.PI * 2; + this.particle('shard', p.x + Math.cos(a) * s * 0.3, p.y + Math.sin(a) * s * 0.3, { tint: col, vx: Math.cos(a) * s * 1.6, vy: Math.sin(a) * s * 1.2 - s * 0.3, g: s * 3, life: 0.55, s0: s / 32 * 0.16, s1: s / 32 * 0.05, a0: 1, a1: 0, spin: (Math.random() - 0.5) * 12 }); + } + break; + } + case 'summon': { + const g = cam.project(at.x, at.y, at.z); + this.particle('pillar', g.x, g.y, { tint: col, life: 0.55, s0: g.s / 64 * 0.8, s1: g.s / 64 * 0.3, a0: 0.9, a1: 0, sx: 1, ay: 1 }); + this.ring(at.x, at.y, at.z, 0.1, 0.9, col, 0.5, 'hex'); + this.burst(g.x, g.y - g.s * 0.3, g.s, 8, col, { speed: 1.4, up: 0.8, life: 0.5, tex: 'dot' }); + break; + } + case 'vanish': { + this.particle('glow', p.x, p.y, { tint: col, life: 0.4, s0: s / 128 * 0.7, s1: s / 128 * 1.5, a0: 0.8, a1: 0 }); + this.smoke(p.x, p.y, s * 0.7, 0x2c2436, 0.5); + this.burst(p.x, p.y, s, 7, col, { speed: 1.5, tex: 'dot', life: 0.45 }); + break; + } + case 'blink': { + const fx0 = num(ex.fx ?? ex.fromX, NaN), fy0 = num(ex.fy ?? ex.fromY, NaN); + if (Number.isFinite(fx0) && Number.isFinite(fy0)) { + const q = cam.project(fx0, fy0, at.z + 0.5, this._q); + this.smoke(q.x, q.y, q.s * 0.6, 0x2c2436, 0.45); + this.particle('glow', q.x, q.y, { tint: col, life: 0.3, s0: q.s / 128 * 0.8, s1: q.s / 128 * 0.2, a0: 0.8, a1: 0 }); + this.streak(fx0, fy0, at.x, at.y, at.z + 0.5, col, 0.3); + } + this.particle('glow', p.x, p.y, { tint: col, life: 0.35, s0: s / 128 * 0.3, s1: s / 128 * 1.2, a0: 0.9, a1: 0 }); + this.burst(p.x, p.y, s, 6, col, { speed: 1.4, tex: 'dot', life: 0.4 }); + break; + } + case 'move': { + const fx0 = num(ex.fromX ?? ex.fx ?? ex.x0, NaN), fy0 = num(ex.fromY ?? ex.fy ?? ex.y0, NaN); + const tx = num(ex.tx, NaN), ty = num(ex.ty, NaN); + if (Number.isFinite(fx0) && Number.isFinite(fy0)) this.streak(fx0, fy0, at.x, at.y, at.z + 0.35, col, 0.35); + else if (Number.isFinite(tx) && Number.isFinite(ty)) this.streak(at.x, at.y, tx, ty, at.z + 0.35, col, 0.35); + const g = cam.project(at.x, at.y, at.z + 0.05); + for (let i = 0; i < (this.quality === 'low' ? 2 : 5); i++) { + this.particle('smoke', g.x + (Math.random() - 0.5) * g.s * 0.5, g.y, { add: false, tint: 0x6b6358, vx: (Math.random() - 0.5) * g.s * 0.6, vy: -g.s * 0.2, drag: 2, life: 0.5, s0: g.s / 128 * 0.25, s1: g.s / 128 * 0.55, a0: 0.45, a1: 0 }); + } + break; + } + case 'wave': { + const k = clamp(num(ex.scale, 1), 0.5, 4); + const rr = Math.max(0.8, r * (ex.scale ? Math.min(2, k / 2) : 1)); + for (let i = 0; i < 3; i++) this.ring(at.x, at.y, at.z, 0.15 + i * 0.15, rr * (0.7 + i * 0.25), col, 0.45 + i * 0.12); + this.particle('glow', p.x, p.y, { tint: col, life: 0.3, s0: s / 128 * 0.5, s1: s / 128 * 1.2, a0: 0.7, a1: 0 }); + break; + } + case 'mark': case 'reticle': { + if (kind === 'lock') { + // `id` is always the locked enemy: the reticle sticks to its view even a little off the event's spot + const lv = at.v || this._viewOf(ex.id); + this._touchLocks(ex.src ?? null); + this._lock(lv, ex.src ?? null, lv ? lv.x : at.x, lv ? lv.y : at.y, lv ? bodyZ(cam, lv, SHOT_HEIGHT.aim) : at.z + 0.55); + break; + } + const v = at.v; + const hz = v ? (v.z || 0) + (v.hover || 0) + (v._headTiles || 1.2) + 0.25 : at.z + 1.4; + const q = cam.project(at.x, at.y, hz, this._q); + if (spec.a === 'mark') { + this.particle('glow', q.x, q.y, { tint: col, life: 0.6, s0: q.s / 128 * 0.5, s1: q.s / 128 * 0.7, a0: 0.8, a1: 0 }); + this.numberAt(q.x, q.y, '!', col, 0.8); + } else { + this.particle('ring', p.x, p.y, { tint: col, life: 0.6, s0: s / 128 * 1.3, s1: s / 128 * 0.7, a0: 0.95, a1: 0, spin: 3 }); + this.particle('hex', p.x, p.y, { tint: col, life: 0.6, s0: s / 128 * 0.5, s1: s / 128 * 0.9, a0: 0.8, a1: 0, spin: -2 }); + } + break; + } + case 'buff': { + const g = cam.project(at.x, at.y, at.z + 0.05); + const n = this.quality === 'low' ? 3 : 6; + for (let i = 0; i < n; i++) { + const ox = (Math.random() - 0.5) * g.s * 0.7; + this.particle('chevron', g.x + ox, g.y - g.s * (0.1 + Math.random() * 0.5), { tint: col, vy: -g.s * (1 + Math.random() * 0.5), life: 0.65, s0: g.s / 64 * 0.22, s1: g.s / 64 * 0.12, a0: 0.95, a1: 0, rot: -Math.PI / 2, fadeIn: 0.06 }); + } + this.ring(at.x, at.y, at.z, 0.2, 0.75, col, 0.4); + this.particle('glow', p.x, p.y, { tint: col, life: 0.35, s0: s / 128 * 0.6, s1: s / 128 * 1.1, a0: 0.6, a1: 0 }); + break; + } + case 'lift': { + const g = cam.project(at.x, at.y, at.z); + this.ring(at.x, at.y, at.z, 0.2, 0.8, col, 0.5); + for (let i = 0; i < (this.quality === 'low' ? 3 : 7); i++) { + this.particle('streak', g.x + (Math.random() - 0.5) * g.s * 0.6, g.y - g.s * Math.random() * 0.4, { tint: col, vy: -g.s * 1.8, life: 0.4, s0: g.s / 128 * 0.4, s1: g.s / 128 * 0.2, a0: 0.8, a1: 0, rot: -Math.PI / 2, sx: 1 }); + } + break; + } + case 'sleep': { + const v = at.v; + const hz = v ? (v.z || 0) + (v._headTiles || 1.2) : at.z + 1.2; + const q = cam.project(at.x + 0.2, at.y, hz, this._q); + for (let i = 0; i < 3; i++) this.particle('st_sleep', q.x + i * q.s * 0.12, q.y - i * q.s * 0.12, { add: false, vy: -q.s * 0.5, vx: q.s * 0.15, life: 0.9 + i * 0.2, s0: q.s / 32 * 0.22, s1: q.s / 32 * 0.32, a0: 1, a1: 0, fadeIn: 0.1 * i }); + break; + } + case 'crit': { + this.particle('spark', p.x, p.y, { tint: col, life: 0.28, s0: s / 64 * 1.3, s1: s / 64 * 0.2, a0: 1, a1: 0, rot: Math.random() * Math.PI }); + this.particle('glow', p.x, p.y, { tint: 0xffffff, life: 0.18, s0: s / 128 * 0.9, s1: s / 128 * 1.4, a0: 0.9, a1: 0 }); + this.burst(p.x, p.y, s, 8, col, { speed: 3.2, life: 0.3, size: 0.5 }); + break; + } + case 'dodge': { + this.particle('soft', p.x, p.y, { tint: 0xffffff, life: 0.3, s0: s / 128 * 0.5, s1: s / 128 * 0.9, a0: 0.5, a1: 0 }); + this.particle('slash', p.x, p.y, { tint: col, life: 0.22, s0: s / 128 * 0.8, s1: s / 128 * 1.1, a0: 0.7, a1: 0, rot: -0.5 }); + break; + } + case 'counter': { + this.particle('spark', p.x, p.y, { tint: col, life: 0.22, s0: s / 64 * 0.9, s1: 0, a0: 1, a1: 0 }); + this.particle('slash', p.x, p.y, { tint: col, life: 0.22, s0: s / 128 * 0.9, s1: s / 128 * 1.2, a0: 0.9, a1: 0, rot: 0.6 }); + this.burst(p.x, p.y, s, 4, col, { speed: 2.4, life: 0.25 }); + break; + } + case 'dp': { + const n = Math.round(num(ex.n, 0)); + this.particle('shard', p.x, p.y, { tint: col, vy: -s * 0.6, life: 0.9, s0: s / 32 * 0.28, s1: s / 32 * 0.22, a0: 1, a1: 0 }); + if (n > 0) this.numberAt(p.x + s * 0.2, p.y, `+${n}`, col, 0.7); + break; + } + case 'coin': { + for (let i = 0; i < 4; i++) this.particle('coin', p.x + (Math.random() - 0.5) * s * 0.4, p.y, { add: false, vx: (Math.random() - 0.5) * s, vy: -s * (1.2 + Math.random() * 0.6), g: s * 4, life: 0.7, s0: s / 48 * 0.2, s1: s / 48 * 0.16, a0: 1, a1: 0.2 }); + break; + } + case 'crate': this.crateBreak(at.x, at.y, at.z); break; + case 'down': { + this.smoke(p.x, p.y, s * 0.6, spec.smoke ?? 0x1a1a1a, 0.5); + this.burst(p.x, p.y, s, 5, col, { speed: 1.2, tex: 'dot', life: 0.5, g: 2 }); + break; + } + case 'beam': case 'bolt': { + const a = this._viewOf(ex.from ?? ex.src) || (spec.a === 'bolt' ? null : at.v); + const b = this._viewOf(ex.to ?? ex.target) || (a === at.v ? null : at.v); + if (a && b && a !== b) this._beam(a, b, col, spec.a === 'bolt' ? 0.28 : 0.4, spec.a === 'bolt' ? 1 : 0.4); + else this.strike(at.x, at.y, at.z, col); + break; + } + case 'flame': { + // at the event's spot (the locked target), not snapped onto the dragon hovering 0.25 tile from it + const fx0 = Number(x), fy0 = Number(y); + this._flame(ex.id ?? ex.src ?? null, ex.target ?? ex.to ?? null, fx0, fy0, r, col, Math.max(0.2, dur || 0.5)); + break; + } + case 'strike': case 'pillar': this.strike(at.x, at.y, at.z, col, spec.a === 'pillar'); break; + case 'volley': { + const list = Array.isArray(ex.targets) ? ex.targets.slice(0, 12) : []; + const src = this._viewOf(ex.src ?? ex.id); + for (const id of list) { + const t = this._viewOf(id); + if (!t) continue; + if (src && src !== t) this.attack(src, t, 'arrow'); + else { const q = this._chest(t, this._q); this.burst(q.x, q.y, q.s, 4, col, { speed: 2, life: 0.3 }); } + } + if (!list.length) this.burst(p.x, p.y, s, 6, col, { speed: 2.4, life: 0.35 }); + break; + } + case 'lp': this.leak(); break; + case 'element': { + const el = ex.element; + const c2 = el === 'burn' ? 0xff7a33 : el === 'neural' ? 0xff5ad0 : el === 'necrosis' || el === 'apoptosis' ? 0x9dff6a : el === 'erosion' ? 0x6fe0ff : col; + this.particle('glow', p.x, p.y, { tint: c2, life: 0.45, s0: s / 128 * 1.2, s1: s / 128 * 2.6, a0: 1, a1: 0 }); + this.ring(at.x, at.y, at.z, 0.2, 1.5, c2, 0.55); + this.burst(p.x, p.y, s, 12, c2, { speed: 3, life: 0.5 }); + break; + } + default: { + this.particle('spark', p.x, p.y, { tint: col, life: 0.35, s0: s / 64 * 0.6, s1: 0, a0: 0.9, a1: 0, rot: Math.random() }); + this.particle('glow', p.x, p.y, { tint: col, life: 0.3, s0: s / 128 * 0.4, s1: s / 128 * 0.9, a0: 0.6, a1: 0 }); + this.burst(p.x, p.y, s, 4, col, { speed: 1.6, tex: 'dot', life: 0.35 }); + } + } + } +} diff --git a/public/js/render/fx/speeds.js b/public/js/render/fx/speeds.js new file mode 100644 index 00000000..a4759b73 --- /dev/null +++ b/public/js/render/fx/speeds.js @@ -0,0 +1,38 @@ +// public/js/render/fx/speeds.js — sim projectile speeds (one module-level table) and the flight-time lookup. + +import { PROJ } from '../style.js'; + +/** + * The sim's projectile speeds (server/sim/constants.js PROJECTILE_SPEEDS — pure data, served read-only at + * /sim/constants.js with the rest of the client-side sim, DESIGN §14): a shot's visual flight ends when the sim lands it, + * i.e. with its damage number. Loaded once in a browser (the game already has the module from its battle runner); + * until it arrives — and in Node, where '/sim/' is no module path — style.js PROJ speeds (a copy the unit tests keep + * equal to the sim's) stand in. + */ +let simSpeeds = null; + +/** + * Use the sim's speeds (tiles per game second): `table` = PROJECTILE_SPEEDS by kind, `boomerangReturn` = + * BOOMERANG_RETURN_SPEED (the sim's visual kind of a boomerang flying back); null = style.js PROJ only. + */ +export function setSimProjectileSpeeds(table, boomerangReturn = null) { + if (!table || typeof table !== 'object') { simSpeeds = null; return; } + simSpeeds = Object.assign({}, table); + if (Number(boomerangReturn) > 0) simSpeeds.boomerangReturn = Number(boomerangReturn); +} + +if (typeof window !== 'undefined' && typeof window.location?.origin === 'string') { + import('/sim/constants.js').then((m) => { if (m?.PROJECTILE_SPEEDS) setSimProjectileSpeeds(m.PROJECTILE_SPEEDS, m.BOOMERANG_RETURN_SPEED); }, () => {}); +} + +/** + * Tiles per game second of a projectile kind ('boomerangReturn': a boomerang's way back): the sim's value, else + * style.js PROJ (`speed`; the boomerang's `back`), else 15 for a boomerang (the sim's outbound speed) / 12. + */ +export function projSpeed(kind) { + const v = simSpeeds ? Number(simSpeeds[kind]) : NaN; + if (v > 0) return v; + if (kind === 'boomerangReturn') return PROJ.boomerang?.back > 0 ? PROJ.boomerang.back : projSpeed('boomerang'); + const f = PROJ[kind]?.speed; + return f > 0 ? f : kind === 'boomerang' ? 15 : 12; +} diff --git a/public/js/render/fx/system.js b/public/js/render/fx/system.js new file mode 100644 index 00000000..b57aa188 --- /dev/null +++ b/public/js/render/fx/system.js @@ -0,0 +1,174 @@ +// public/js/render/fx/system.js — FxSystem: the pools are built here; the effect methods are installed from the containers. +// The containers are installed in source order. A name defined twice is an error. + +import { fxAtlas } from '../textures.js'; +import { ensureDamageFonts } from './fonts.js'; +import { MAX_PARTICLES } from './limits.js'; +import { FxParticles } from './particles.js'; +import { FxProjectiles } from './projectiles.js'; +import { FxBeams } from './beams.js'; +import { FxLocks } from './locks.js'; +import { FxNumbers } from './numbers.js'; +import { FxRings } from './rings.js'; +import { FxArrivals } from './arrivals.js'; +import { FxSim } from './simfx.js'; +import { FxZones } from './zones.js'; + +export class FxSystem { + /** + * @param {{ P, layers: { groundFx, fxAdd, fxNormal, text, screen }, cam: () => any, view: (id) => any, + * heightAt: (r,c)=>number, settings: object, assets: any, timeScale: () => number, + * subProfOf: (defId) => string|null, screenSize: () => {width,height}, fieldTop: () => number }} ctx + */ + constructor(ctx) { + const P = ctx.P; + this.ctx = ctx; + this.P = P; + this.atlas = fxAtlas(); + this.tex = this.atlas.tex; + ensureDamageFonts(); + const props = { vertices: true, position: true, rotation: true, uvs: true, tint: true }; + this.addPc = new P.ParticleContainer(MAX_PARTICLES.high, props, 512, true); + this.addPc.blendMode = P.BLEND_MODES.ADD; + this.normPc = new P.ParticleContainer(MAX_PARTICLES.high, props, 512, true); + // ground shadows of flying shells / boomerangs: normal blend, under the units and the smoke + this.shadowLayer = new P.Container(); + ctx.layers.fxNormal.addChild(this.shadowLayer, this.normPc); + ctx.layers.fxAdd.addChild(this.addPc); + this.parts = []; // active particle records { sp, add, x, y, vx, … } + this.freeAdd = []; this.freeNorm = []; // pooled particle records (their sprites stay in the containers) + this.projs = []; + this.projFree = []; + this.projLayer = new P.Container(); + ctx.layers.fxAdd.addChild(this.projLayer); + this.locks = []; // lock-on reticles { view, id, src, x, y, z, t, idle, max, out, ring, core, shell } + this.lockFree = []; + this._po = {}; // options of the per-frame emitters (see _o) + this.beams = new P.Graphics(); + this.beams.blendMode = P.BLEND_MODES.ADD; + ctx.layers.fxAdd.addChild(this.beams); + this.beamList = []; + this.flames = []; // fire jets (炎佑 祛恶之焰) { src, tgt, x, y, z, r, col, t, end, disc, edge, … } + this.nums = []; + this._numPools = new Map(); // bitmap font → free number records + this.auras = new Map(); // unit id → { sprite, ring, t } + this.rings = []; // ground rings { sprite, x, y, z, t, dur, r0, r1, tint } + this.ringFree = []; + this.pops = []; // screen-space pops (bond glyphs, coins) + this.zones = []; // persistent ground areas (zone / telegraph fx) + this.labels = []; // floating '!' / '+n' labels + this.tileFlashes = []; // flashing tile sets (telegraphed boxes) + this.tileGfx = new P.Graphics(); + this.tileGfx.blendMode = P.BLEND_MODES.ADD; + ctx.layers.groundFx.addChild(this.tileGfx); + this.tintSprite = new P.Sprite(P.Texture.WHITE); + this.tintSprite.alpha = 0; + this.tintSprite.blendMode = P.BLEND_MODES.ADD; + ctx.layers.screen.addChild(this.tintSprite); + this.tintT = 0; this.tintDur = 1; this.tintA = 0; + this.vignette = new P.Sprite(ctx.redVignette || P.Texture.EMPTY); + this.vignette.alpha = 0; + ctx.layers.screen.addChild(this.vignette); + this.vigT = 0; + this.time = 0; + this._p = { x: 0, y: 0, s: 0, depth: 0 }; + this._q = { x: 0, y: 0, s: 0, depth: 0 }; + this._g = { x: 0, y: 0, s: 0, depth: 0 }; + } + + /** Remove everything (battle reset). */ + clear() { + for (const p of this.parts) this._freeParticle(p); + this.parts.length = 0; + for (const pr of this.projs) this._releaseProj(pr); + this.projs.length = 0; + for (const L of this.locks) this._freeLock(L); + this.locks.length = 0; + this._slashAt = null; + for (const t of this.nums) this._releaseNum(t); + this.nums.length = 0; + for (const r of this.rings) { r.sp.visible = false; this.ringFree.push(r); } + this.rings.length = 0; + for (const a of this.auras.values()) a.sp.destroy({ children: true }); + this.auras.clear(); + for (const p of this.pops) p.c.destroy({ children: true }); + this.pops.length = 0; + this.beamList.length = 0; + this.beams.clear(); + for (const F of this.flames) this._freeFlame(F); + this.flames.length = 0; + this.vigT = 0; + this.vignette.alpha = 0; + for (const zn of this.zones) this._freeZone(zn); + this.zones.length = 0; + for (const l of this.labels) l.t.destroy(); + this.labels.length = 0; + this.tileFlashes.length = 0; + this.tileGfx.clear(); + this.tintT = 0; this.tintSprite.alpha = 0; + } + + update(dt) { + this.time += dt; + this._updateParticles(dt); + this._updateProjs(dt); + this._updateLocks(dt); + this._updateBeams(dt); + this._updateFlames(dt); + this._updateNums(dt); + this._updateRings(dt); + this._updateAuras(dt); + this._updatePops(dt); + this._updateZones(dt); + this._updateLabels(dt); + this._updateTileFlashes(dt); + if (this.tintT > 0) { + this.tintT = Math.max(0, this.tintT - dt); + const size = this.ctx.screenSize(); + this.tintSprite.width = size.width; this.tintSprite.height = size.height; + this.tintSprite.alpha = Math.sin((this.tintT / this.tintDur) * Math.PI) * this.tintA * 0.35; + } else if (this.tintSprite.alpha) this.tintSprite.alpha = 0; + if (this.vigT > 0) { + this.vigT = Math.max(0, this.vigT - dt); + const size = this.ctx.screenSize(); + this.vignette.width = size.width; this.vignette.height = size.height; + this.vignette.alpha = Math.sin((this.vigT / 0.9) * Math.PI) * 0.55; + } else if (this.vignette.alpha) this.vignette.alpha = 0; + } + + get counts() { + return { particles: this.parts.length, projectiles: this.projs.length, numbers: this.nums.length, rings: this.rings.length, auras: this.auras.size, locks: this.locks.length, flames: this.flames.length, promotions: this.promotions || 0 }; + } + + destroy() { + this.clear(); + this.addPc.destroy({ children: true }); + this.normPc.destroy({ children: true }); + this.projLayer.destroy({ children: true }); + this.shadowLayer.destroy({ children: true }); + this.lockFree.length = 0; + this.projFree.length = 0; + this.freeAdd.length = 0; this.freeNorm.length = 0; + this.beams.destroy(); + this.vignette.destroy(); + this.tileGfx.destroy(); + this.tintSprite.destroy(); + for (const list of this._numPools.values()) for (const t of list) t.text.destroy(); + this._numPools.clear(); + } +} + +const FX_PARTS = [FxParticles, FxProjectiles, FxBeams, FxLocks, FxNumbers, FxRings, FxArrivals, FxSim, FxZones]; +const FX_METHOD_ORDER = ["quality","load","maxParticles","rich","_room","_ts","_groundZ","particle","_o","_freeParticle","_updateParticles","_proj","_onGround","_chest","_bodyPt","burst","attack","_takeProj","_dressProj","_releaseProj","_updateProjs","_shotPoint","_stepShot","_stepBoomerang","_stepMortar","_mote","_puff","_muzzle","_impact","_catch","mortar","_landed","_beam","_updateBeams","_flame","_freeFlame","_updateFlames","_lock","_touchLocks","_nearestLock","_releaseLock","_freeLock","_updateLocks","damage","_slash","heal","number","_growFits","_numSlotPx","_layoutNums","_layoutNumsOnce","_sizeNum","_fadeNum","_takeNum","_releaseNum","_updateNums","ring","_updateRings","skill","_aura","_updateAuras","deploy","promote","death","crateBreak","_viewOf","_where","_point","simFx","explosion","smoke","streak","strike","numberAt","_updateLabels","zone","_freeZone","_updateZones","tileFlash","_updateTileFlashes","flashScreen","snowfall","leak","pop","_updatePops"]; + +for (const key of FX_METHOD_ORDER) { + let found = null; + for (const part of FX_PARTS) { + if (!Object.prototype.hasOwnProperty.call(part.prototype, key)) continue; + if (found) throw new Error(`FxSystem.${key} is defined twice`); + found = part; + } + if (!found) throw new Error(`FxSystem.${key} is missing`); + if (Object.prototype.hasOwnProperty.call(FxSystem.prototype, key)) throw new Error(`FxSystem.${key} is defined twice`); + Object.defineProperty(FxSystem.prototype, key, Object.getOwnPropertyDescriptor(found.prototype, key)); +} diff --git a/public/js/render/fx/zones.js b/public/js/render/fx/zones.js new file mode 100644 index 00000000..bb9fc917 --- /dev/null +++ b/public/js/render/fx/zones.js @@ -0,0 +1,155 @@ +// public/js/render/fx/zones.js — FxSystem blasts, zones, tile flashes and screen washes. +// Installed on FxSystem.prototype by ./system.js (a method container: never instantiated; `this` is the effect system). + +import { NO_OPTS, clamp, easeOut } from './limits.js'; + +export class FxZones { + /** + * An explosion on the ground at (x, y, z) of radius r tiles: fireball in `col`, a white flash, a shockwave and a + * coloured ring on the ground, sparks and smoke. o.heavy (bombard, airstrike …): longer, more sparks, debris flying + * and a scorch mark; o.small (shell impacts): no coloured ring, fewer sparks; o.tiles: flash those tiles too. + */ + explosion(x, y, z, r, col, o = NO_OPTS) { + const cam = this.ctx.cam(); + const g = cam.project(x, y, z + 0.3, this._g); + const gx = g.x, gy = g.y, s = g.s; + const R = Math.max(0.4, r), heavy = !!o.heavy, small = !!o.small, rich = this.rich; + this.particle('glow', gx, gy, { tint: col, life: heavy ? 0.5 : small ? 0.3 : 0.4, s0: (s / 128) * (0.7 + R * 0.7), s1: (s / 128) * (1.2 + R * 1.2), a0: 1, a1: 0 }); + this.particle('flare', gx, gy, { tint: 0xfff4e0, life: heavy ? 0.28 : 0.18, s0: (s / 128) * (0.9 + R * 0.7), s1: (s / 128) * (0.3 + R * 0.2), a0: 1, a1: 0, rot: Math.random() * 3 }); + this.ring(x, y, z, 0.1, R * 1.1, 0xfff0d8, heavy ? 0.42 : 0.3, 'shock'); + if (!small) this.ring(x, y, z, 0.15, R, col, heavy ? 0.6 : 0.45); + this.burst(gx, gy, s, Math.round((small ? 4 : 6) + R * (heavy ? 6 : 3)), col, { speed: 1.8 + R, life: heavy ? 0.6 : 0.45, up: 0.5 }); + this.smoke(gx, gy - s * 0.15, s * (0.35 + R * 0.35), o.smoke ?? 0x2a2522, heavy ? 0.5 : 0.35); + if (rich && heavy) { + // debris: dark chunks and hot embers thrown up, falling back + for (let i = 0; i < 8; i++) { + const a = -Math.PI * (0.15 + Math.random() * 0.7), v = s * (1.6 + Math.random() * 1.8); + const hot = i % 2 === 0; + this.particle(hot ? 'dot' : 'shard', gx, gy, { + add: hot, tint: hot ? col : 0x2e2620, vx: Math.cos(a) * v, vy: Math.sin(a) * v, g: s * 5, life: 0.55 + Math.random() * 0.25, + s0: (s / 32) * (hot ? 0.16 : 0.2), s1: (s / 32) * (hot ? 0.04 : 0.14), a0: 1, a1: hot ? 0 : 0.4, spin: (Math.random() - 0.5) * 14, + }); + } + // a scorch mark fading on the ground (flattened like the ground under the camera) + const c = cam.project(x, y, z + 0.01, this._p); + const cx = c.x, cy = c.y; + const flat = clamp((cy - cam.project(x, y + 1, z + 0.01, this._q).y) / Math.max(1, c.s), 0.25, 1); + this.particle('soft', cx, cy, { add: false, tint: 0x000000, life: 1.6, s0: (c.s / 128) * R * 1.5 * flat, s1: (c.s / 128) * R * 1.6 * flat, sx: 1 / flat, a0: 0.4, a1: 0 }); + } + if (o.tiles) this.tileFlash(o.tiles, col, 0.45); + } + + /** Dark (normal-blend) smoke puff. */ + smoke(x, y, size, tint, alpha = 0.45) { + this.particle('smoke', x, y, { add: false, tint, life: 0.8, s0: size / 128 * 0.9, s1: size / 128 * 2, a0: alpha, a1: 0 }); + } + + /** Motion streak between two world points at height z (dash / pull / blink trails). */ + streak(x0, y0, x1, y1, z, tint, life = 0.3) { + const cam = this.ctx.cam(); + const a = cam.project(x0, y0, z, this._p), ax = a.x, ay = a.y; + const b = cam.project(x1, y1, z, this._q); + const len = Math.hypot(b.x - ax, b.y - ay); + if (len < 2) return; + const th = Math.max(0.05, (b.s * 0.3) / 32); + this.particle('streak', b.x, b.y, { tint, life, s0: th, s1: th * 0.5, sx: len / 128 / th, a0: 0.8, a1: 0, rot: Math.atan2(b.y - ay, b.x - ax), anchorX: 1 }); + } + + /** Light strike from the sky onto a point (lightning / skill strikes / columns). */ + strike(x, y, z, tint, wide = false) { + const cam = this.ctx.cam(); + const g = cam.project(x, y, z); + this.particle('pillar', g.x, g.y, { tint, life: 0.4, s0: g.s / 64 * (wide ? 1.2 : 0.7), s1: g.s / 64 * (wide ? 0.9 : 0.2), a0: 1, a1: 0, sx: wide ? 1 : 0.5, ay: 1 }); + this.particle('glow', g.x, g.y - g.s * 0.2, { tint, life: 0.3, s0: g.s / 128 * 0.8, s1: g.s / 128 * 1.6, a0: 0.9, a1: 0 }); + this.ring(x, y, z, 0.1, wide ? 1.2 : 0.7, tint, 0.35); + this.burst(g.x, g.y - g.s * 0.2, g.s, 6, tint, { speed: 2.4, life: 0.35 }); + } + + /** Persistent ground area: soft disc + pulsing edge ring for `dur` real seconds (telegraphs pulse faster). */ + zone(x, y, z, r, tint, dur, tex = 'soft', warn = false) { + const P = this.P; + const disc = new P.Sprite(this.tex[tex === 'ring' ? 'soft' : tex] || this.tex.soft); + disc.anchor.set(0.5); disc.blendMode = P.BLEND_MODES.ADD; disc.tint = tint; + const edge = new P.Sprite(this.tex.ring); + edge.anchor.set(0.5); edge.blendMode = P.BLEND_MODES.ADD; edge.tint = tint; + this._onGround(disc, y, z); this._onGround(edge, y, z); + this.zones.push({ disc, edge, x, y, z, r, t: 0, dur, warn }); + if (this.zones.length > 24) this._freeZone(this.zones.shift()); + } + + _freeZone(zn) { zn.disc.destroy(); zn.edge.destroy(); } + + _updateZones(dt) { + const cam = this.ctx.cam(); + const p = this._p, q = this._q; + let w = 0; + for (const zn of this.zones) { + zn.t += dt; + if (zn.t >= zn.dur) { this._freeZone(zn); continue; } + const k = zn.t / zn.dur; + const grow = Math.min(1, zn.t / 0.25); + const rad = zn.r * (0.35 + 0.65 * easeOut(grow)); + cam.project(zn.x, zn.y, zn.z + 0.02, p); + cam.project(zn.x, zn.y + rad, zn.z + 0.02, q); + const rx = p.s * rad, ry = Math.max(1, p.y - q.y); + const fade = k > 0.8 ? (1 - k) / 0.2 : 1; + const pulse = zn.warn ? 0.55 + 0.45 * Math.abs(Math.sin(zn.t * 7)) : 0.8 + 0.2 * Math.sin(zn.t * 3); + zn.disc.position.set(p.x, p.y); zn.disc.scale.set((rx * 2) / 128, (ry * 2) / 128); zn.disc.alpha = (zn.warn ? 0.35 : 0.28) * fade * pulse; + zn.edge.position.set(p.x, p.y); zn.edge.scale.set((rx * 2.1) / 128, (ry * 2.1) / 128); zn.edge.alpha = 0.75 * fade * pulse; + this.zones[w++] = zn; + } + this.zones.length = w; + } + + /** Flash a set of tiles ([[r,c]]) on the ground (telegraphed boxes, blast tiles). */ + tileFlash(tiles, tint, dur, warn = false) { + if (!Array.isArray(tiles) || !tiles.length) return; + this.tileFlashes.push({ tiles: tiles.slice(0, 60), tint, dur: Math.max(0.2, dur), t: 0, warn }); + if (this.tileFlashes.length > 12) this.tileFlashes.shift(); + } + + _updateTileFlashes(dt) { + const g = this.tileGfx; + g.clear(); + if (!this.tileFlashes.length) return; + const cam = this.ctx.cam(); + const p = this._p; + let w = 0; + for (const f of this.tileFlashes) { + f.t += dt; + if (f.t >= f.dur) continue; + const k = f.t / f.dur; + const a = (f.warn ? 0.35 + 0.35 * Math.abs(Math.sin(f.t * 7)) : 0.55 * (1 - k)) * (k > 0.85 ? (1 - k) / 0.15 : 1); + for (const [r, c] of f.tiles) { + const z = (this.ctx.heightAt ? this.ctx.heightAt(r, c) : 0) + 0.015; + const pts = []; + for (const [dx, dy] of [[-0.46, 0.46], [0.46, 0.46], [0.46, -0.46], [-0.46, -0.46]]) { cam.project(c + dx, r + dy, z, p); pts.push(p.x, p.y); } + g.lineStyle(Math.max(1, p.s * 0.03), f.tint, Math.min(1, a * 1.6)); + g.beginFill(f.tint, a * 0.6); + g.drawPolygon(pts); + g.endFill(); + } + this.tileFlashes[w++] = f; + } + this.tileFlashes.length = w; + } + + /** Full-screen colour pulse (field-wide telegraphs, cold wind). */ + flashScreen(tint, alpha = 0.4, dur = 0.6) { + this.tintT = dur; this.tintDur = dur; this.tintA = alpha; + this.tintSprite.tint = tint; + } + + /** A short flurry of snow over the field (cold wind). */ + snowfall(tint) { + const size = this.ctx.screenSize(); + for (let i = 0; i < (this.quality === 'low' ? 10 : 26); i++) { + this.particle('dot', Math.random() * size.width, Math.random() * size.height * 0.7, { tint, vx: 30 + Math.random() * 40, vy: 60 + Math.random() * 60, life: 1 + Math.random() * 0.6, s0: 0.25 + Math.random() * 0.3, s1: 0.1, a0: 0.8, a1: 0, fadeIn: 0.2 }); + } + } + + /** Leak: objective flash + red screen vignette pulse. */ + leak() { + this.vigT = 0.9; + } +} From aaef748ad95b33117654bf2f82b956de6b6509ff Mon Sep 17 00:00:00 2001 From: sganggs Date: Mon, 5 Oct 2026 17:22:20 +0800 Subject: [PATCH 3/5] feedback5 R5: split the field-view helpers out of the render app PIXI loading, pick records, tunables, view kind, unit info and the asset / GL host now live in public/js/render/app/*.js. createFieldView stays in app.js, unchanged, and every name the file used to export is re-exported. One pixiPromise. Two source pins follow the lines that moved. --- public/js/render/app.js | 289 ++------------------------- public/js/render/app/host.js | 66 ++++++ public/js/render/app/info.js | 46 +++++ public/js/render/app/pick.js | 29 +++ public/js/render/app/pixi.js | 31 +++ public/js/render/app/tune.js | 34 ++++ public/js/render/app/view.js | 95 +++++++++ test/docs-consistency.test.js | 3 +- test/ui/playtest-item-detail.test.js | 6 +- 9 files changed, 323 insertions(+), 276 deletions(-) create mode 100644 public/js/render/app/host.js create mode 100644 public/js/render/app/info.js create mode 100644 public/js/render/app/pick.js create mode 100644 public/js/render/app/pixi.js create mode 100644 public/js/render/app/tune.js create mode 100644 public/js/render/app/view.js diff --git a/public/js/render/app.js b/public/js/render/app.js index 97df1c9d..0b0098f7 100644 --- a/public/js/render/app.js +++ b/public/js/render/app.js @@ -103,8 +103,11 @@ // Piece shape (DESIGN §8.3): { uid, kind: 'chess'|'item'|'token', id, golden, tier, items: [{uid,id}], count, ownerUid }. // `assets` may be the store from public/js/assets.js or the raw /data/assets.json manifest (it is wrapped); // `data` is the client data store (public/js/data.js: lookup(file, id)) or plain { chess, tokens, items, enemies } maps. +// +// The helpers live in public/js/render/app/*.js and are re-exported below, so existing imports keep +// working. createFieldView stays here: it is one closure over the view's own state. -import { GEO, ANIM, UF } from '../../../shared/constants.js'; +import { GEO, ANIM } from '../../../shared/constants.js'; import { fxForm } from '../../../shared/protocol.js'; import { Camera, presetCamera, lerpCamera, easeInOutCubic, pickTile, normRect } from './projection.js'; import { SnapshotBuffer, frameTime } from './interp.js'; @@ -114,285 +117,27 @@ import { FxSystem, ensureDamageFonts } from './fx.js'; import { createDragController, pieceTile } from './drag.js'; import { backdropTextures, shadowTexture, refreshTierChips, silhouetteTexture } from './textures.js'; import { TILE_H, TIER_COLORS, COLORS } from './style.js'; -import { createAssets, assets as defaultAssets } from '../assets.js'; import { loadBoardArt } from './boardArt.js'; import { ImpostorAtlas } from './impostor.js'; import { loadThree, loadBoardPack, webgl2Available, boardArtListed } from './board3d/load.js'; import { BoardScene } from './board3d/scene.js'; -import { AREAS, areaFor, unionAreas } from './board3d/layout.js'; +import { unionAreas } from './board3d/layout.js'; import { layoutPen, penSignature } from './pen.js'; import { IDENTITY, bossPrepField, tilesToDisp, leaderStand } from './prepfield.js'; -import { pickOnTile, pickBattle, hitRectAt, hitTiles } from './pick.js'; +import { pickOnTile, pickBattle, hitTiles } from './pick.js'; import { promotionsOf } from './promote.js'; +import { ensurePixi } from './app/pixi.js'; +import { pieceDirOf, pickUnitOf } from './app/pick.js'; +import { CAMERA_MS, BOARD3D_STABLE_MS, BOARD3D_RETRY_MS, PEN_CAMERA_MS, RANGE_GROUPS, LEADER_HIT_STYLE, DRAG_HOLD_TILES, CHAIN_KINDS, DROP_PENDING_MS } from './app/tune.js'; +import { boardPreference, switchableBox, bandFor, fieldRows, boardArea, viewKind, penShown, leaderShown } from './app/view.js'; +import { renderInfo, FORCED_EXIT, showsDeathFx } from './app/info.js'; +import { resolveAssets, makeData, withTimeout, QUALITY_RES, BOARD_RES, releaseGl } from './app/host.js'; -const VENDOR = { pixi: '/vendor/pixi.min.js', spine: '/vendor/pixi-spine.js' }; -const PIECE_DIRS = new Set(['UP', 'RIGHT', 'DOWN', 'LEFT']); -/** Stored facing of a prep piece (m.private board pieces carry `dir`; bench pieces have none ⇒ undefined). */ -const pieceDirOf = (piece) => (typeof piece?.dir === 'string' && PIECE_DIRS.has(piece.dir.toUpperCase()) ? piece.dir.toUpperCase() : undefined); -const CAMERA_MS = 750; -/** Camera pan to / from the enemy preview pen (configBlackBoard move_time 0.25). */ -export const PEN_CAMERA_MS = 250; -/** Recovery of a lost 3D board context: delays of the rebuild attempts (ms) and how soon a new loss counts as failure. */ -const BOARD3D_RETRY_MS = [1200, 4000, 12000]; -const BOARD3D_STABLE_MS = 10000; -/** Highlight groups that show a unit's range: never drawn on bench / temp pads (they are not part of any battle). */ -const RANGE_GROUPS = new Set(['facing', 'range', 'rangeStand', 'select', 'sel', 'selRange']); -/** - * The round leader's hit tiles, lit beside an operator's range preview in the Final Assault / Hidden Core prep (see the - * header; community report #12 "boss受击范围可以像官方原版那样用红色"). [ASSUMED] the red and its strength: the players' request, - * no source shows the official colour; the range preview stays orange. - */ -export const LEADER_HIT_STYLE = Object.freeze({ group: 'leaderHit', color: 0xff3b30, fill: 0.3, line: 0.95 }); -/** atk projectile kinds whose first id is the previous bounce target (sim ai.js), not the attacker. */ -const CHAIN_KINDS = new Set(['chain', 'chainHeal']); -const DROP_PENDING_MS = 1300; -/** - * A dragged unit is held with its drawn feet this many tiles below the pointer — the pointer on its body, the model - * under the finger / mouse (user playtest #4 item 1: as in v2.1; mouse and touch alike). An item plate is centred on it. - */ -export const DRAG_HOLD_TILES = 0.45; - -/** - * A view as a render/pick.js unit, or null when it cannot be picked (gone, faded out, dead): standing on the display - * tile it is drawn on, or — `walks` (battle enemies) — by its ground position and its drawn body (feet to head, on - * screen; a flying one by its body alone). - */ -function pickUnitOf(v, walks = false, hitArea = null) { - // a knocked-out operator lying on its tile waiting to redeploy (b.snap `down`, user playtest #4 item 9) is on that tile - // too: a press there selects it; other dead / dying views are gone - if (!v || v.destroyed || (v.alive === false && !v.down)) return null; - if (Number.isFinite(v.alpha) && v.alpha < 0.05) return null; - const sc = v.screen; - const body = walks && sc && sc.s > 0 && !v.culled ? { x: sc.x, top: Number.isFinite(sc.top) ? sc.top : sc.y, feet: sc.y, s: sc.s } : null; - const tile = walks ? null : { row: Math.round(v.y), col: Math.round(v.x) }; - // a huge boss (data `hitArea`): its hit area on the ground and a body box as wide as it are pickable (render/pick.js) - const area = walks && !v.flying ? hitRectAt(v.x, v.y, hitArea) : null; - if (area && body) body.hw = hitArea.w / 2; - return { tile, x: v.x, y: v.y, fly: walks && !!v.flying, body, area, depth: v.root && !v.root.destroyed ? v.root.zIndex : 0, ref: v }; -} - -let pixiPromise = null; - -function loadScript(src) { - return new Promise((resolve, reject) => { - const s = document.createElement('script'); - s.src = src; - s.async = false; - s.onload = () => resolve(); - s.onerror = () => reject(new Error(`failed to load ${src}`)); - document.head.appendChild(s); - }); -} - -/** Make sure PIXI and PIXI.spine exist (classic scripts; loaded once). */ -export function ensurePixi() { - if (globalThis.PIXI && globalThis.PIXI.spine) return Promise.resolve(globalThis.PIXI); - if (!pixiPromise) { - pixiPromise = (async () => { - if (!globalThis.PIXI) await loadScript(VENDOR.pixi); - if (!globalThis.PIXI?.spine) await loadScript(VENDOR.spine); - if (!globalThis.PIXI || !globalThis.PIXI.spine) throw new Error('PIXI / pixi-spine unavailable'); - return globalThis.PIXI; - })(); - pixiPromise.catch(() => { pixiPromise = null; }); - } - return pixiPromise; -} - -const VIEW_KINDS = new Set(['prep', 'normal', 'unite', 'boss', 'bossPrep', 'pen']); - -/** - * The field a camera request actually shows — what render/projection.js presetCamera frames: 'hidden' → 'boss'; - * a 'prep' camera asked for the boss rows (Final Assault prep: rect r1 ≤ 6) → 'bossPrep'; unknown kinds → 'normal'. - * Drives the built 3D area, the drawn 2D rows and the lit rect, so they always match the camera. - */ -export function viewKind(kind, opts) { - let k = kind === 'hidden' ? 'boss' : kind; - if (!VIEW_KINDS.has(k)) k = 'normal'; - if (k === 'prep' && opts && opts.rect && normRect(opts.rect).r1 <= 6) k = 'bossPrep'; - return k; -} - -/** 3D areas without the enemy preview pen block (the own field / both normal halves); see `boardArea`. */ -const AREA_NO_PEN = Object.freeze({ - normal: Object.freeze(AREAS.normal.filter((a) => a.r1 <= 13)), - unite: Object.freeze(AREAS.unite.filter((a) => a.r1 <= 13)), -}); - -/** - * 3D area built for a view kind (viewKind): the enemy preview pen (rows 14–18) only for the 'pen' camera — the prep, - * battle and 联防 cameras show the field alone (user playtest #2 item 6) with its separator rows 6 and 13 (the row-13 - * devices blow into the field: act2 m01's blowers, user playtest #5 item 6; the boss field's row-6 devices are drawn - * with the boss field only — board3d/layout.js stageDevices); the boss kinds build the boss field. - */ -export function boardArea(vk) { - if (vk === 'pen') return AREAS.normal; - if (vk === 'prep' || vk === 'normal') return AREA_NO_PEN.normal.length ? AREA_NO_PEN.normal : AREAS.normal; - if (vk === 'unite') return AREA_NO_PEN.unite.length ? AREA_NO_PEN.unite : AREAS.unite; - return areaFor(vk); -} - -/** - * 2D rows drawn for a view kind: the pen rows (14–18) only for the 'pen' camera; the boss field with the separator - * and the normal rows behind it as scenery. - */ -export function bandFor(kind) { - if (kind === 'boss' || kind === 'hidden' || kind === 'bossPrep') return [0, 13]; - return kind === 'pen' ? [6, 18] : [6, 13]; -} - -/** - * Active field rows [r0, r1] of a view kind for the 2D board (render/tiles.js `setView` field: drawn rows outside it are - * dim scenery without devices): the normal / 联防 / prep fields live between the separator walls (rows 6–13, the - * devices on the row-13 wall included; the row-6 wall's belong to the boss field — tiles.js _stageDevices); the pen - * camera adds the pen (6–18); the boss field 0–6. - */ -export function fieldRows(kind) { - return kind === 'boss' || kind === 'hidden' || kind === 'bossPrep' ? [0, 6] : kind === 'pen' ? [6, 18] : [6, 13]; -} - -/** Are the pen's figures shown for a view kind (a camera flight shows them when either end is the pen)? */ -export const penShown = (vk, prevVk = null) => vk === 'pen' || prevVk === 'pen'; -/** Is the prep's leader on the boss field shown for a view kind (a flight shows it when either end is the boss-field prep)? */ -export const leaderShown = (vk, prevVk = null) => vk === 'bossPrep' || prevVk === 'bossPrep'; - -/** - * 'die' reason of an operator that enters the battle already knocked out — a 联防 helper's operator down at the end of - * its own combat, deployed and forced out at once (server/sim/constants.js FORCED_EXIT, user playtest #5 item 2): its - * view goes straight to the held knocked-down pose (UnitView.die(true)) without the death burst; b.snap `down` keeps it. - */ -export const FORCED_EXIT = 'forcedExit'; - -/** - * Generic death particles for a battle unit's 'die' event: never for stage devices, nor for a summon used up by its - * own effect (user playtest #2 item 4: 香槟炸弹's blast — sim fx `{ consumed: true, id }` before its 'die' — is its end, - * not a knock-out), nor for an operator entering the battle knocked out (FORCED_EXIT). - */ -export const showsDeathFx = (info, consumed = false, reason = null) => !consumed && reason !== FORCED_EXIT && info?.kind !== 'device'; - -/** - * The views' info of a battle unit from its UnitInfo (m.field / fieldMeta `units`, a 'spawn' event; snapshot.js - * unitInfo), sanitised; null for a malformed entry. `form` — the unit's current model form (an enemy's, content/enemies.js - * setForm: 转译基底·α's forms, a 逐火 余烬, a leader after its 重生, 掠海漂移体's crawl; a 傀儡师 fighting as its 替身, sim - * professions.js) — makes a view built mid-battle (a teammate's - * field watched later, 联防 observers, a reconnect, server-run watchers: no fx of the change is replayed) start on that - * clip set (render/units.js FORMS); it used to be dropped here, so such views drew the first form (player report #5). - */ -export function renderInfo(u) { - if (!u || typeof u !== 'object' || (typeof u.id !== 'number' && typeof u.id !== 'string')) return null; - return { - id: u.id, uid: u.uid ?? null, kind: u.kind || 'enemy', side: u.side === 'ally' ? 'ally' : 'enemy', ownerId: u.ownerId ?? null, - defId: u.defId ?? null, name: u.name ?? '', tier: u.tier ?? 1, golden: !!u.golden, spine: u.spine ?? u.defId ?? null, - avatar: u.avatar ?? u.defId ?? null, x: Number(u.x) || 0, y: Number(u.y) || 0, facing: u.facing === -1 ? -1 : 1, - maxHp: Number(u.maxHp) || 1, boss: !!u.boss, motion: u.motion, - // deploy direction of allies (UnitInfo.dir, DESIGN §3): the model (Back for UP, mirrored for LEFT) and the - // ground wedge follow it; absent = unknown (legacy frames) → derived from `facing`, no wedge - dir: typeof u.dir === 'string' ? u.dir : undefined, - // the unit's current model form (UnitInfo.form: an enemy's mode, a 傀儡师's 替身): the view starts in it (UnitView reads info.form) - form: typeof u.form === 'string' ? u.form : undefined, - // DESIGN §16 loadout of an ally (UnitInfo.skillIndex / moduleId): the Spine actor plays that skill's clip, and a - // tap hands them to the detail card (a teammate's unit shows its owner's skill / module) - skillIndex: Number.isInteger(u.skillIndex) ? u.skillIndex : undefined, - moduleId: typeof u.moduleId === 'string' ? u.moduleId : undefined, - // the ally's equipped item ids (UnitInfo.items, DESIGN §16 / §21.11): the detail card needs them for a teammate's - // unit (resolveDetail `unitItems` → the read-only 装备 section and the 变形同构体 pairing chips); the owner's own - // unit takes its items from the piece instead, so only other players' boards ever read this field - items: Array.isArray(u.items) ? u.items.filter((x) => typeof x === 'string') : undefined, - }; -} - -/** '2d' | '3d' | 'auto' board preference: `?board=` in the page URL (dev), else the view option. */ -export function boardPreference(opt) { - let q = null; - try { q = new URLSearchParams(globalThis.location?.search || '').get('board'); } catch { /* no location */ } - const v = q || opt; - return v === '2d' || v === '3d' ? v : 'auto'; -} - -/** - * A battle device box (render/units.js DeviceView `ctx.createBox()` contract: `{ mesh, update(cam, b), destroy() }`) - * that follows the board layer: the 3D scene's crate mesh while `board()` returns a BoardScene, else the Pixi box of - * the 2D board. A board switch mid-battle (lost WebGL context → 2D, or back to 3D) swaps the inner box on the next - * update instead of leaving the crate on a dead scene. `mesh` is the Pixi mesh (null in 3D: nothing to add to Pixi). - */ -export function switchableBox({ board, pixi }) { - let inner = null, owner; - const drop = () => { try { inner?.destroy(); } catch { /* ignore */ } inner = null; owner = undefined; }; - return { - get mesh() { return inner ? inner.mesh || null : null; }, - get inner() { return inner; }, - update(cam, b) { - const scene = board() || null; - if (!inner || owner !== scene || inner.destroyed) { - drop(); - inner = scene ? scene.createDevice() : pixi(); - owner = scene; - } - inner?.update(cam, b); - }, - destroy: drop, - }; -} - -function withTimeout(p, ms) { - return Promise.race([p, new Promise((resolve) => setTimeout(resolve, ms))]); -} - -/** Wrap whatever the caller passed as `assets` into the store API of public/js/assets.js. */ -function resolveAssets(a) { - if (a && typeof a === 'object' && typeof a.ready === 'function' && typeof a.spineEntry === 'function') return a; - if (a && typeof a === 'object' && (a.chars || a.enemies || a.version)) return createAssets({ manifest: a }); - return defaultAssets; -} - -function makeData(src) { - const look = (file, id) => { - if (id == null || id === '') return null; - try { - if (src && typeof src.lookup === 'function') return src.lookup(file, id) || null; - const m = src && src[file]; - if (m instanceof Map) return m.get(id) || null; - if (m && typeof m === 'object' && Object.hasOwn(m, id)) return m[id] || null; - } catch { /* ignore */ } - return null; - }; - return { - chess: (id) => look('chess', id), token: (id) => look('tokens', id), item: (id) => look('items', id), - enemy: (id) => look('enemies', id), stage: (id) => look('stages', id), bond: (id) => look('bonds', id), - }; -} - -const QUALITY_RES = { high: 2, medium: 1.5, low: 1 }; -/** Pixel-ratio cap of the 3D board canvas per quality (its fill cost is the PBR board, not the sprites). */ -const BOARD_RES = { high: 2, medium: 1.25, low: 1 }; - -/** - * Before a renderer is destroyed: free its GL copies of every texture / buffer / geometry / framebuffer it - * uploaded. Module-level textures (FX atlas, tier chips, backdrop, diamonds, Spine pages, PIXI.Texture.WHITE…) - * outlive the view; PIXI 7 leaves their per-context GL entries and 'dispose' listeners pointing at the dead - * renderer, which kept every unmounted view's renderer, canvas and WebGL context reachable. - */ -export function releaseGl(renderer) { - const ts = renderer && renderer.texture; - if (ts && Array.isArray(ts.managedTextures) && typeof ts.destroyTexture === 'function') { - for (const bt of ts.managedTextures.slice()) { try { ts.destroyTexture(bt, true); } catch { /* ignore */ } } - ts.managedTextures.length = 0; - } - for (const sys of [renderer?.geometry, renderer?.buffer, renderer?.framebuffer]) { - try { if (sys && typeof sys.disposeAll === 'function') sys.disposeAll(false); } catch { /* ignore */ } - } - // shader programs are cached globally by source (PIXI.utils.ProgramCache) with one GLProgram per context - const uid = renderer?.CONTEXT_UID, gl = renderer?.gl; - const programs = globalThis.PIXI?.utils?.ProgramCache; - if (uid != null && programs && typeof programs === 'object') { - for (const prog of Object.values(programs)) { - const g = prog && prog.glPrograms && prog.glPrograms[uid]; - if (!g) continue; - try { if (gl && g.program) gl.deleteProgram(g.program); } catch { /* ignore */ } - delete prog.glPrograms[uid]; - } - } -} +export { ensurePixi } from './app/pixi.js'; +export { PEN_CAMERA_MS, LEADER_HIT_STYLE, DRAG_HOLD_TILES } from './app/tune.js'; +export { viewKind, boardArea, bandFor, fieldRows, penShown, leaderShown, boardPreference, switchableBox } from './app/view.js'; +export { FORCED_EXIT, showsDeathFx, renderInfo } from './app/info.js'; +export { releaseGl } from './app/host.js'; /** * Create the battlefield view inside `host` (an element sized by CSS; the canvas fills it). diff --git a/public/js/render/app/host.js b/public/js/render/app/host.js new file mode 100644 index 00000000..5e551cfa --- /dev/null +++ b/public/js/render/app/host.js @@ -0,0 +1,66 @@ +// public/js/render/app/host.js — asset lookup, data lookup and GL teardown for one field view. + +import { createAssets, assets as defaultAssets } from '../../assets.js'; + +function withTimeout(p, ms) { + return Promise.race([p, new Promise((resolve) => setTimeout(resolve, ms))]); +} + +/** Wrap whatever the caller passed as `assets` into the store API of public/js/assets.js. */ +function resolveAssets(a) { + if (a && typeof a === 'object' && typeof a.ready === 'function' && typeof a.spineEntry === 'function') return a; + if (a && typeof a === 'object' && (a.chars || a.enemies || a.version)) return createAssets({ manifest: a }); + return defaultAssets; +} + +function makeData(src) { + const look = (file, id) => { + if (id == null || id === '') return null; + try { + if (src && typeof src.lookup === 'function') return src.lookup(file, id) || null; + const m = src && src[file]; + if (m instanceof Map) return m.get(id) || null; + if (m && typeof m === 'object' && Object.hasOwn(m, id)) return m[id] || null; + } catch { /* ignore */ } + return null; + }; + return { + chess: (id) => look('chess', id), token: (id) => look('tokens', id), item: (id) => look('items', id), + enemy: (id) => look('enemies', id), stage: (id) => look('stages', id), bond: (id) => look('bonds', id), + }; +} + +const QUALITY_RES = { high: 2, medium: 1.5, low: 1 }; + +/** Pixel-ratio cap of the 3D board canvas per quality (its fill cost is the PBR board, not the sprites). */ +const BOARD_RES = { high: 2, medium: 1.25, low: 1 }; + +/** + * Before a renderer is destroyed: free its GL copies of every texture / buffer / geometry / framebuffer it + * uploaded. Module-level textures (FX atlas, tier chips, backdrop, diamonds, Spine pages, PIXI.Texture.WHITE…) + * outlive the view; PIXI 7 leaves their per-context GL entries and 'dispose' listeners pointing at the dead + * renderer, which kept every unmounted view's renderer, canvas and WebGL context reachable. + */ +export function releaseGl(renderer) { + const ts = renderer && renderer.texture; + if (ts && Array.isArray(ts.managedTextures) && typeof ts.destroyTexture === 'function') { + for (const bt of ts.managedTextures.slice()) { try { ts.destroyTexture(bt, true); } catch { /* ignore */ } } + ts.managedTextures.length = 0; + } + for (const sys of [renderer?.geometry, renderer?.buffer, renderer?.framebuffer]) { + try { if (sys && typeof sys.disposeAll === 'function') sys.disposeAll(false); } catch { /* ignore */ } + } + // shader programs are cached globally by source (PIXI.utils.ProgramCache) with one GLProgram per context + const uid = renderer?.CONTEXT_UID, gl = renderer?.gl; + const programs = globalThis.PIXI?.utils?.ProgramCache; + if (uid != null && programs && typeof programs === 'object') { + for (const prog of Object.values(programs)) { + const g = prog && prog.glPrograms && prog.glPrograms[uid]; + if (!g) continue; + try { if (gl && g.program) gl.deleteProgram(g.program); } catch { /* ignore */ } + delete prog.glPrograms[uid]; + } + } +} + +export { withTimeout, resolveAssets, makeData, QUALITY_RES, BOARD_RES }; diff --git a/public/js/render/app/info.js b/public/js/render/app/info.js new file mode 100644 index 00000000..f5d3e27f --- /dev/null +++ b/public/js/render/app/info.js @@ -0,0 +1,46 @@ +// public/js/render/app/info.js — battle-unit info handed to a view, and which deaths draw particles. + +/** + * 'die' reason of an operator that enters the battle already knocked out — a 联防 helper's operator down at the end of + * its own combat, deployed and forced out at once (server/sim/constants.js FORCED_EXIT, user playtest #5 item 2): its + * view goes straight to the held knocked-down pose (UnitView.die(true)) without the death burst; b.snap `down` keeps it. + */ +export const FORCED_EXIT = 'forcedExit'; + +/** + * Generic death particles for a battle unit's 'die' event: never for stage devices, nor for a summon used up by its + * own effect (user playtest #2 item 4: 香槟炸弹's blast — sim fx `{ consumed: true, id }` before its 'die' — is its end, + * not a knock-out), nor for an operator entering the battle knocked out (FORCED_EXIT). + */ +export const showsDeathFx = (info, consumed = false, reason = null) => !consumed && reason !== FORCED_EXIT && info?.kind !== 'device'; + +/** + * The views' info of a battle unit from its UnitInfo (m.field / fieldMeta `units`, a 'spawn' event; snapshot.js + * unitInfo), sanitised; null for a malformed entry. `form` — the unit's current model form (an enemy's, content/enemies.js + * setForm: 转译基底·α's forms, a 逐火 余烬, a leader after its 重生, 掠海漂移体's crawl; a 傀儡师 fighting as its 替身, sim + * professions.js) — makes a view built mid-battle (a teammate's + * field watched later, 联防 observers, a reconnect, server-run watchers: no fx of the change is replayed) start on that + * clip set (render/units.js FORMS); it used to be dropped here, so such views drew the first form (player report #5). + */ +export function renderInfo(u) { + if (!u || typeof u !== 'object' || (typeof u.id !== 'number' && typeof u.id !== 'string')) return null; + return { + id: u.id, uid: u.uid ?? null, kind: u.kind || 'enemy', side: u.side === 'ally' ? 'ally' : 'enemy', ownerId: u.ownerId ?? null, + defId: u.defId ?? null, name: u.name ?? '', tier: u.tier ?? 1, golden: !!u.golden, spine: u.spine ?? u.defId ?? null, + avatar: u.avatar ?? u.defId ?? null, x: Number(u.x) || 0, y: Number(u.y) || 0, facing: u.facing === -1 ? -1 : 1, + maxHp: Number(u.maxHp) || 1, boss: !!u.boss, motion: u.motion, + // deploy direction of allies (UnitInfo.dir, DESIGN §3): the model (Back for UP, mirrored for LEFT) and the + // ground wedge follow it; absent = unknown (legacy frames) → derived from `facing`, no wedge + dir: typeof u.dir === 'string' ? u.dir : undefined, + // the unit's current model form (UnitInfo.form: an enemy's mode, a 傀儡师's 替身): the view starts in it (UnitView reads info.form) + form: typeof u.form === 'string' ? u.form : undefined, + // DESIGN §16 loadout of an ally (UnitInfo.skillIndex / moduleId): the Spine actor plays that skill's clip, and a + // tap hands them to the detail card (a teammate's unit shows its owner's skill / module) + skillIndex: Number.isInteger(u.skillIndex) ? u.skillIndex : undefined, + moduleId: typeof u.moduleId === 'string' ? u.moduleId : undefined, + // the ally's equipped item ids (UnitInfo.items, DESIGN §16 / §21.11): the detail card needs them for a teammate's + // unit (resolveDetail `unitItems` → the read-only 装备 section and the 变形同构体 pairing chips); the owner's own + // unit takes its items from the piece instead, so only other players' boards ever read this field + items: Array.isArray(u.items) ? u.items.filter((x) => typeof x === 'string') : undefined, + }; +} diff --git a/public/js/render/app/pick.js b/public/js/render/app/pick.js new file mode 100644 index 00000000..c9cecc2e --- /dev/null +++ b/public/js/render/app/pick.js @@ -0,0 +1,29 @@ +// public/js/render/app/pick.js — prep-piece facing and the pick record of a view. + +import { hitRectAt } from '../pick.js'; + +const PIECE_DIRS = new Set(['UP', 'RIGHT', 'DOWN', 'LEFT']); + +/** Stored facing of a prep piece (m.private board pieces carry `dir`; bench pieces have none ⇒ undefined). */ +const pieceDirOf = (piece) => (typeof piece?.dir === 'string' && PIECE_DIRS.has(piece.dir.toUpperCase()) ? piece.dir.toUpperCase() : undefined); + +/** + * A view as a render/pick.js unit, or null when it cannot be picked (gone, faded out, dead): standing on the display + * tile it is drawn on, or — `walks` (battle enemies) — by its ground position and its drawn body (feet to head, on + * screen; a flying one by its body alone). + */ +function pickUnitOf(v, walks = false, hitArea = null) { + // a knocked-out operator lying on its tile waiting to redeploy (b.snap `down`, user playtest #4 item 9) is on that tile + // too: a press there selects it; other dead / dying views are gone + if (!v || v.destroyed || (v.alive === false && !v.down)) return null; + if (Number.isFinite(v.alpha) && v.alpha < 0.05) return null; + const sc = v.screen; + const body = walks && sc && sc.s > 0 && !v.culled ? { x: sc.x, top: Number.isFinite(sc.top) ? sc.top : sc.y, feet: sc.y, s: sc.s } : null; + const tile = walks ? null : { row: Math.round(v.y), col: Math.round(v.x) }; + // a huge boss (data `hitArea`): its hit area on the ground and a body box as wide as it are pickable (render/pick.js) + const area = walks && !v.flying ? hitRectAt(v.x, v.y, hitArea) : null; + if (area && body) body.hw = hitArea.w / 2; + return { tile, x: v.x, y: v.y, fly: walks && !!v.flying, body, area, depth: v.root && !v.root.destroyed ? v.root.zIndex : 0, ref: v }; +} + +export { pieceDirOf, pickUnitOf }; diff --git a/public/js/render/app/pixi.js b/public/js/render/app/pixi.js new file mode 100644 index 00000000..3184afb1 --- /dev/null +++ b/public/js/render/app/pixi.js @@ -0,0 +1,31 @@ +// public/js/render/app/pixi.js — load PIXI and pixi-spine once (one module-level promise). + +const VENDOR = { pixi: '/vendor/pixi.min.js', spine: '/vendor/pixi-spine.js' }; + +let pixiPromise = null; + +function loadScript(src) { + return new Promise((resolve, reject) => { + const s = document.createElement('script'); + s.src = src; + s.async = false; + s.onload = () => resolve(); + s.onerror = () => reject(new Error(`failed to load ${src}`)); + document.head.appendChild(s); + }); +} + +/** Make sure PIXI and PIXI.spine exist (classic scripts; loaded once). */ +export function ensurePixi() { + if (globalThis.PIXI && globalThis.PIXI.spine) return Promise.resolve(globalThis.PIXI); + if (!pixiPromise) { + pixiPromise = (async () => { + if (!globalThis.PIXI) await loadScript(VENDOR.pixi); + if (!globalThis.PIXI?.spine) await loadScript(VENDOR.spine); + if (!globalThis.PIXI || !globalThis.PIXI.spine) throw new Error('PIXI / pixi-spine unavailable'); + return globalThis.PIXI; + })(); + pixiPromise.catch(() => { pixiPromise = null; }); + } + return pixiPromise; +} diff --git a/public/js/render/app/tune.js b/public/js/render/app/tune.js new file mode 100644 index 00000000..eda59596 --- /dev/null +++ b/public/js/render/app/tune.js @@ -0,0 +1,34 @@ +// public/js/render/app/tune.js — camera, drag and highlight tunables of the field view. + +const CAMERA_MS = 750; + +/** Camera pan to / from the enemy preview pen (configBlackBoard move_time 0.25). */ +export const PEN_CAMERA_MS = 250; + +/** Recovery of a lost 3D board context: delays of the rebuild attempts (ms) and how soon a new loss counts as failure. */ +const BOARD3D_RETRY_MS = [1200, 4000, 12000]; + +const BOARD3D_STABLE_MS = 10000; + +/** Highlight groups that show a unit's range: never drawn on bench / temp pads (they are not part of any battle). */ +const RANGE_GROUPS = new Set(['facing', 'range', 'rangeStand', 'select', 'sel', 'selRange']); + +/** + * The round leader's hit tiles, lit beside an operator's range preview in the Final Assault / Hidden Core prep (see the + * header; community report #12 "boss受击范围可以像官方原版那样用红色"). [ASSUMED] the red and its strength: the players' request, + * no source shows the official colour; the range preview stays orange. + */ +export const LEADER_HIT_STYLE = Object.freeze({ group: 'leaderHit', color: 0xff3b30, fill: 0.3, line: 0.95 }); + +/** atk projectile kinds whose first id is the previous bounce target (sim ai.js), not the attacker. */ +const CHAIN_KINDS = new Set(['chain', 'chainHeal']); + +const DROP_PENDING_MS = 1300; + +/** + * A dragged unit is held with its drawn feet this many tiles below the pointer — the pointer on its body, the model + * under the finger / mouse (user playtest #4 item 1: as in v2.1; mouse and touch alike). An item plate is centred on it. + */ +export const DRAG_HOLD_TILES = 0.45; + +export { CAMERA_MS, BOARD3D_RETRY_MS, BOARD3D_STABLE_MS, RANGE_GROUPS, CHAIN_KINDS, DROP_PENDING_MS }; diff --git a/public/js/render/app/view.js b/public/js/render/app/view.js new file mode 100644 index 00000000..460dc3e2 --- /dev/null +++ b/public/js/render/app/view.js @@ -0,0 +1,95 @@ +// public/js/render/app/view.js — which field a camera shows, and the board box that follows it. + +import { AREAS, areaFor } from '../board3d/layout.js'; +import { normRect } from '../projection.js'; + +const VIEW_KINDS = new Set(['prep', 'normal', 'unite', 'boss', 'bossPrep', 'pen']); + +/** + * The field a camera request actually shows — what render/projection.js presetCamera frames: 'hidden' → 'boss'; + * a 'prep' camera asked for the boss rows (Final Assault prep: rect r1 ≤ 6) → 'bossPrep'; unknown kinds → 'normal'. + * Drives the built 3D area, the drawn 2D rows and the lit rect, so they always match the camera. + */ +export function viewKind(kind, opts) { + let k = kind === 'hidden' ? 'boss' : kind; + if (!VIEW_KINDS.has(k)) k = 'normal'; + if (k === 'prep' && opts && opts.rect && normRect(opts.rect).r1 <= 6) k = 'bossPrep'; + return k; +} + +/** 3D areas without the enemy preview pen block (the own field / both normal halves); see `boardArea`. */ +const AREA_NO_PEN = Object.freeze({ + normal: Object.freeze(AREAS.normal.filter((a) => a.r1 <= 13)), + unite: Object.freeze(AREAS.unite.filter((a) => a.r1 <= 13)), +}); + +/** + * 3D area built for a view kind (viewKind): the enemy preview pen (rows 14–18) only for the 'pen' camera — the prep, + * battle and 联防 cameras show the field alone (user playtest #2 item 6) with its separator rows 6 and 13 (the row-13 + * devices blow into the field: act2 m01's blowers, user playtest #5 item 6; the boss field's row-6 devices are drawn + * with the boss field only — board3d/layout.js stageDevices); the boss kinds build the boss field. + */ +export function boardArea(vk) { + if (vk === 'pen') return AREAS.normal; + if (vk === 'prep' || vk === 'normal') return AREA_NO_PEN.normal.length ? AREA_NO_PEN.normal : AREAS.normal; + if (vk === 'unite') return AREA_NO_PEN.unite.length ? AREA_NO_PEN.unite : AREAS.unite; + return areaFor(vk); +} + +/** + * 2D rows drawn for a view kind: the pen rows (14–18) only for the 'pen' camera; the boss field with the separator + * and the normal rows behind it as scenery. + */ +export function bandFor(kind) { + if (kind === 'boss' || kind === 'hidden' || kind === 'bossPrep') return [0, 13]; + return kind === 'pen' ? [6, 18] : [6, 13]; +} + +/** + * Active field rows [r0, r1] of a view kind for the 2D board (render/tiles.js `setView` field: drawn rows outside it are + * dim scenery without devices): the normal / 联防 / prep fields live between the separator walls (rows 6–13, the + * devices on the row-13 wall included; the row-6 wall's belong to the boss field — tiles.js _stageDevices); the pen + * camera adds the pen (6–18); the boss field 0–6. + */ +export function fieldRows(kind) { + return kind === 'boss' || kind === 'hidden' || kind === 'bossPrep' ? [0, 6] : kind === 'pen' ? [6, 18] : [6, 13]; +} + +/** Are the pen's figures shown for a view kind (a camera flight shows them when either end is the pen)? */ +export const penShown = (vk, prevVk = null) => vk === 'pen' || prevVk === 'pen'; + +/** Is the prep's leader on the boss field shown for a view kind (a flight shows it when either end is the boss-field prep)? */ +export const leaderShown = (vk, prevVk = null) => vk === 'bossPrep' || prevVk === 'bossPrep'; + +/** '2d' | '3d' | 'auto' board preference: `?board=` in the page URL (dev), else the view option. */ +export function boardPreference(opt) { + let q = null; + try { q = new URLSearchParams(globalThis.location?.search || '').get('board'); } catch { /* no location */ } + const v = q || opt; + return v === '2d' || v === '3d' ? v : 'auto'; +} + +/** + * A battle device box (render/units.js DeviceView `ctx.createBox()` contract: `{ mesh, update(cam, b), destroy() }`) + * that follows the board layer: the 3D scene's crate mesh while `board()` returns a BoardScene, else the Pixi box of + * the 2D board. A board switch mid-battle (lost WebGL context → 2D, or back to 3D) swaps the inner box on the next + * update instead of leaving the crate on a dead scene. `mesh` is the Pixi mesh (null in 3D: nothing to add to Pixi). + */ +export function switchableBox({ board, pixi }) { + let inner = null, owner; + const drop = () => { try { inner?.destroy(); } catch { /* ignore */ } inner = null; owner = undefined; }; + return { + get mesh() { return inner ? inner.mesh || null : null; }, + get inner() { return inner; }, + update(cam, b) { + const scene = board() || null; + if (!inner || owner !== scene || inner.destroyed) { + drop(); + inner = scene ? scene.createDevice() : pixi(); + owner = scene; + } + inner?.update(cam, b); + }, + destroy: drop, + }; +} diff --git a/test/docs-consistency.test.js b/test/docs-consistency.test.js index 65a796dc..1e1b21a5 100644 --- a/test/docs-consistency.test.js +++ b/test/docs-consistency.test.js @@ -289,7 +289,8 @@ test('user playtest #4 (DESIGN §18): picking by tile, timers, 机变 two taps, // #1 the tile under the pointer; the dragged model held under the pointer (no touch lift, probe or body shapes) assert.equal(ENEMY_REACH, 0.6); const app = readFileSync(join(ROOT, 'public/js/render/app.js'), 'utf8'); - assert.match(app, /export const DRAG_HOLD_TILES = 0\.45;/); + const tune = readFileSync(join(ROOT, 'public/js/render/app/tune.js'), 'utf8'); + assert.match(tune, /export const DRAG_HOLD_TILES = 0\.45;/); assert.ok(!/TOUCH_LIFT_TILES|drawnAt|pickShape|pieceDragOver/.test(app), 'no touch lift, pixel probe or body shapes (user playtest #4 item 1)'); assert.match(DESIGN, /`DRAG_HOLD_TILES` = 0\.45 tile/); assert.match(DESIGN, /`ENEMY_REACH` 0\.6 tile/); diff --git a/test/ui/playtest-item-detail.test.js b/test/ui/playtest-item-detail.test.js index 73f0e3d9..6e788b8b 100644 --- a/test/ui/playtest-item-detail.test.js +++ b/test/ui/playtest-item-detail.test.js @@ -1,9 +1,9 @@ // 点击 / 观战队友单位时详情卡显示装备(游玩记录 #2:「观战队友时看不到干员的装备」)。DESIGN §16 // 承诺 "a teammate's unit shows its owner's loadout in the detail card":服务器把 an ally operator 的 // 装备 id 放进战斗快照与侦察 meta 的 UnitInfo.items(server/sim/snapshot.js、Match.prepFieldMeta), -// detailPanel 的 unit 分支也从 `u.items` 取 `unitItems` —— 但 renderInfo(public/js/render/app.js) +// detailPanel 的 unit 分支也从 `u.items` 取 `unitItems` —— 但 renderInfo(public/js/render/app/info.js) // 构建 info 白名单时把 items 丢了,队友单位点开的卡永远没有 装备 区,变形同构体的「同构」pairing -// chips 对队友单位同样失效。renderInfo 在 app.js 里(模块顶层有 canvas 副作用,node 无法 import), +// chips 对队友单位同样失效。renderInfo 在 app/info.js 里(app.js 模块顶层有 canvas 副作用,node 无法 import 整份视图), // 按docs-consistency / feedback1d 的惯例对源码断言;消费端行为已由 morph-bonds.test.js 锁住, // 这里再走一遍 unit → resolveDetail → ChessDetail 的完整用户可见结果。 import { test } from 'node:test'; @@ -22,7 +22,7 @@ const HAMMER = 'chess_item_1_01_e_a'; // 维式重锤 → 维多利亚 // ---- renderInfo: the snapshot UnitInfo reaches the client info object --------------------------------------------- test('renderInfo passes UnitInfo.items through (the teammate-unit equipment into the detail card)', () => { - const app = readFileSync(path.join(ROOT, 'public/js/render/app.js'), 'utf8'); + const app = readFileSync(path.join(ROOT, 'public/js/render/app/info.js'), 'utf8'); const start = app.indexOf('export function renderInfo('); assert.ok(start > 0, 'renderInfo exists'); const body = app.slice(start, app.indexOf('\n}', start)); From dd07dc1ec10a47cb93490018c047c1eac5ab5157 Mon Sep 17 00:00:00 2001 From: sganggs Date: Mon, 5 Oct 2026 17:24:20 +0800 Subject: [PATCH 4/5] feedback5 R5: split the match screen's standalone pieces out The match-ended plate, the solo pause plate, the highlight styles and keepEarly now live in public/js/screens/game/*.js. game.js stays the screen entry. MatchScreen stays one component. Imports only those pieces used moved with them. Two source pins follow the lines that moved. --- public/js/screens/game.js | 53 +++-------------------- public/js/screens/game/early.js | 9 ++++ public/js/screens/game/marks.js | 11 +++++ public/js/screens/game/overlays.js | 35 +++++++++++++++ test/match/feedback1-runner-forms.test.js | 3 +- test/ui/playtest6-elite.test.js | 2 +- 6 files changed, 65 insertions(+), 48 deletions(-) create mode 100644 public/js/screens/game/early.js create mode 100644 public/js/screens/game/marks.js create mode 100644 public/js/screens/game/overlays.js diff --git a/public/js/screens/game.js b/public/js/screens/game.js index 47a1d602..3225f684 100644 --- a/public/js/screens/game.js +++ b/public/js/screens/game.js @@ -65,8 +65,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from '../../vendor/hooks.module.js'; import { PHASE, GEO } from '../../../shared/constants.js'; -import { fxForm } from '../../../shared/protocol.js'; -import { html, Spinner, PhaseBanner, Icon, Button, MicroLabel, confirmDialog, closeAllDialogs, useTicker } from '../ui/components.js'; +import { html, Spinner, PhaseBanner, Icon, Button, confirmDialog, closeAllDialogs, useTicker } from '../ui/components.js'; import { useGameData, GIcon } from '../ui/gameComponents.js'; import { useFieldView } from '../ui/fieldHost.js'; import { TopBar, liveLp, ownLeaks, uniteRemaining, tempInfo, tempReadyReason } from '../ui/hud.js'; @@ -103,24 +102,17 @@ import { BriefingScreen } from './briefing.js'; import { BandDraftScreen } from './bandDraft.js'; import { ResultScreen } from './result.js'; import { net } from '../net.js'; -import { store, useStore, shallowEqual, serverNow, emptyMatch, isSpectating } from '../store.js'; +import { useStore, shallowEqual, serverNow, isSpectating } from '../store.js'; import { battleRunner } from '../battle/runner.js'; import { isClientCombat, observeTarget, teammateProgress, cameraLayers, layerCamera, sidesOf, resumedWatch } from '../battle/observe.js'; import { screenStrip, playerBonds, playerLayer, detailBondOwner, toggleBond, popupView } from '../ui/watchBonds.js'; -import { data, localAsset, getMode } from '../data.js'; +import { data, getMode } from '../data.js'; import { audio } from '../audio.js'; import { useDocClass, FullscreenButton } from '../ui/device.js'; - -const cx = (...p) => p.flat().filter(Boolean).join(' '); -const HUD_HZ_MS = 200; -/** Range tiles of the selected unit (its own highlight group: the wheel's 'facing' group may be up at the same time). */ -const SEL_RANGE = Object.freeze({ group: 'selRange', color: 0xff9c33, fill: 0.3, line: 0.95 }); -/** The tile an armed merge-completing card's elite will take (its own group; gold like the promotion cue, render/fx.js). */ -const MERGE_HL = Object.freeze({ group: 'mergeTile', color: 0xffd45a, fill: 0.34, line: 1 }); -const STATE_EV = new Set(['spawn', 'die', 'deploy', 'status', 'skill']); -/** Event tuples replayed when a field is entered late: the state-bearing kinds and the fx that change an enemy's model - * form (shared/protocol.js fxForm — the field meta's UnitInfo `form` predates them). */ -const keepEarly = (e) => Array.isArray(e) && (STATE_EV.has(e[0]) || fxForm(e) !== undefined); +// MatchEnded, PausedOverlay, the highlight styles and keepEarly live in ./game/*.js. +import { HUD_HZ_MS, MERGE_HL, SEL_RANGE, cx } from './game/marks.js'; +import { keepEarly } from './game/early.js'; +import { MatchEnded, PausedOverlay } from './game/overlays.js'; /** Router for the in-match screens. */ export function GameScreen() { @@ -147,36 +139,6 @@ export function GameScreen() { ${ended && !hasResult && mode !== 'result' ? html`<${MatchEnded} />` : null}`; } -/** The room went back to its lobby without a result (match aborted): offer the way back. */ -function MatchEnded() { - return html``; -} - -/** Solo pause (m.public.paused): the field dims under the 暂停中 plate; 继续作战 resumes (g.pause off). */ -function PausedOverlay({ canResume, busy, onResume, onExit }) { - const plate = localAsset('ui/battle', 'matte_pause'); - return html``; -} - // ---- match screen ---------------------------------------------------------------------------------------- function MatchScreen() { @@ -1325,4 +1287,3 @@ function MatchScreen() { <${ExitModal} open=${exitOpen} onClose=${() => setExitOpen(false)} solo=${solo} /> `; } - diff --git a/public/js/screens/game/early.js b/public/js/screens/game/early.js new file mode 100644 index 00000000..62f3c282 --- /dev/null +++ b/public/js/screens/game/early.js @@ -0,0 +1,9 @@ +// public/js/screens/game/early.js — which events are replayed when a field is entered late. + +import { fxForm } from '../../../../shared/protocol.js'; + +export const STATE_EV = new Set(['spawn', 'die', 'deploy', 'status', 'skill']); + +/** Event tuples replayed when a field is entered late: the state-bearing kinds and the fx that change an enemy's model + * form (shared/protocol.js fxForm — the field meta's UnitInfo `form` predates them). */ +export const keepEarly = (e) => Array.isArray(e) && (STATE_EV.has(e[0]) || fxForm(e) !== undefined); diff --git a/public/js/screens/game/marks.js b/public/js/screens/game/marks.js new file mode 100644 index 00000000..1f11b3c8 --- /dev/null +++ b/public/js/screens/game/marks.js @@ -0,0 +1,11 @@ +// public/js/screens/game/marks.js — class-name join and the field highlight styles the match screen passes to the view. + +export const cx = (...p) => p.flat().filter(Boolean).join(' '); + +export const HUD_HZ_MS = 200; + +/** Range tiles of the selected unit (its own highlight group: the wheel's 'facing' group may be up at the same time). */ +export const SEL_RANGE = Object.freeze({ group: 'selRange', color: 0xff9c33, fill: 0.3, line: 0.95 }); + +/** The tile an armed merge-completing card's elite will take (its own group; gold like the promotion cue, render/fx.js). */ +export const MERGE_HL = Object.freeze({ group: 'mergeTile', color: 0xffd45a, fill: 0.34, line: 1 }); diff --git a/public/js/screens/game/overlays.js b/public/js/screens/game/overlays.js new file mode 100644 index 00000000..4ab312a4 --- /dev/null +++ b/public/js/screens/game/overlays.js @@ -0,0 +1,35 @@ +// public/js/screens/game/overlays.js — the match-ended plate and the solo pause plate. + +import { Button, MicroLabel, html } from '../../ui/components.js'; +import { emptyMatch, store } from '../../store.js'; +import { localAsset } from '../../data.js'; + +/** The room went back to its lobby without a result (match aborted): offer the way back. */ +export function MatchEnded() { + return html``; +} + +/** Solo pause (m.public.paused): the field dims under the 暂停中 plate; 继续作战 resumes (g.pause off). */ +export function PausedOverlay({ canResume, busy, onResume, onExit }) { + const plate = localAsset('ui/battle', 'matte_pause'); + return html``; +} diff --git a/test/match/feedback1-runner-forms.test.js b/test/match/feedback1-runner-forms.test.js index 4c61b64b..4d6336c8 100644 --- a/test/match/feedback1-runner-forms.test.js +++ b/test/match/feedback1-runner-forms.test.js @@ -234,7 +234,8 @@ test('a hidden-tab backlog still beyond HELD_MAX after compaction: the first fra test('screens/game.js buffers the form fx with the state-bearing events it replays when a field is entered late', () => { const src = readFileSync(path.join(ROOT, 'public/js/screens/game.js'), 'utf8'); - assert.match(src, /const keepEarly = \(e\) => Array\.isArray\(e\) && \(STATE_EV\.has\(e\[0\]\) \|\| fxForm\(e\) !== undefined\);/); + const early = readFileSync(path.join(ROOT, 'public/js/screens/game/early.js'), 'utf8'); + assert.match(early, /const keepEarly = \(e\) => Array\.isArray\(e\) && \(STATE_EV\.has\(e\[0\]\) \|\| fxForm\(e\) !== undefined\);/); assert.match(src, /for \(const e of msg\.ev\) if \(keepEarly\(e\)\) buf\.push\(e\);/); // a new m.field for the field on screen buffers its frames until the enter effect re-enters it (enterBattle resets) assert.match(src, /if \(msg\.fieldId === lastFieldRef\.current\) reentryRef\.current = msg\.fieldId;/); diff --git a/test/ui/playtest6-elite.test.js b/test/ui/playtest6-elite.test.js index edf384c6..95afc3d6 100644 --- a/test/ui/playtest6-elite.test.js +++ b/test/ui/playtest6-elite.test.js @@ -171,6 +171,6 @@ describe('shop bar: the merge tag and the armed card', () => { assert.match(src, /onArm=\$\{setArmedCard\}/, 'ShopBar reports the armed card'); assert.match(src, /mergeTarget\(priv, armedCard\.id, gd\.chess\)/, 'the target comes from gameLogic.mergeTarget'); assert.match(src, /view\.highlightTiles\(mergeAt \? \[\[mergeAt\.row, mergeAt\.col\]\] : \[\], MERGE_HL\)/, 'its own highlight group (cleared with [])'); - assert.match(src, /const MERGE_HL = Object\.freeze\(\{ group: 'mergeTile'/); + assert.match(read('public/js/screens/game/marks.js'), /const MERGE_HL = Object\.freeze\(\{ group: 'mergeTile'/); }); }); From 29b0ccb9626b5a1f66e4cf7a9cea351cb91864cc Mon Sep 17 00:00:00 2001 From: sganggs Date: Mon, 5 Oct 2026 17:26:08 +0800 Subject: [PATCH 5/5] feedback5 R5: point the layout docs at the split client modules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DESIGN §2 names gameLogic/, render/app/, render/fx/ and screens/game/. §9, SIM and ASSETS name renderInfo and keepEarly in the modules that hold them. Blame ignores the four move commits. --- .git-blame-ignore-revs | 8 ++++++++ docs/ASSETS.md | 2 +- docs/DESIGN.md | 9 +++++---- docs/SIM.md | 4 ++-- 4 files changed, 16 insertions(+), 7 deletions(-) diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs index 3372a17c..cc8a301b 100644 --- a/.git-blame-ignore-revs +++ b/.git-blame-ignore-revs @@ -11,3 +11,11 @@ b48a67caec5ba0699097e21f3db4d4dbb35a2aed 560328cacc863c26d07780096527e255ebf62bc4 # feedback5 R4: kits/tier1..6.js → kits/ops/ (one file per kit), kits/shared/, kits/index.js 1169102d46742474bf0f0189d467c791984d9e05 +# feedback5 R5: public/js/ui/gameLogic.js → public/js/ui/gameLogic/*.js (the barrel re-exports) +eb04f5dfa4f396b17e987b61a47fbcdc2b8b2fdf +# feedback5 R5: public/js/render/fx.js → public/js/render/fx/*.js (methods installed in source order) +c1b8292f92f5f07581342babe84ba0ac8a1dcf82 +# feedback5 R5: public/js/render/app.js prelude → public/js/render/app/*.js (createFieldView stays) +aaef748ad95b33117654bf2f82b956de6b6509ff +# feedback5 R5: public/js/screens/game.js plates, marks and keepEarly → public/js/screens/game/*.js +dd07dc1ec10a47cb93490018c047c1eac5ab5157 diff --git a/docs/ASSETS.md b/docs/ASSETS.md index 18835aaa..deee4fe7 100644 --- a/docs/ASSETS.md +++ b/docs/ASSETS.md @@ -238,7 +238,7 @@ The manifest roles describe a unit's first form. Units whose skeleton holds anot `public/js/render/units.js FORMS` (keyed by Spine id, switched by the `form` of the sim's 'phase' / 'ember' / 'revive' / 'telegraph' / 'stone' / 'substitute' / 'swap' / 'dollEnd' fx — `shared/protocol.js fxForm`; no client stage drops these fx: the runner keeps them through catch-up frames and hidden tabs (`keepsState`), the game screen's pre-entry buffer (`keepEarly`) and the render engine's -event queue (`render/interp.js isCosmeticEvent`) too — or, for a view built mid-battle, UnitInfo `form`, which `render/app.js renderInfo` passes to the view; a +event queue (`render/interp.js isCosmeticEvent`) too — or, for a view built mid-battle, UnitInfo `form`, which `render/app/info.js renderInfo` passes to the view; a `change` clip plays once first, an `end` clip is timed from the fx's `dur` to finish as that state ends, keeping the current form's death clip until the next form's fx). A blocked or revealed 隐匿 enemy is drawn solid: the sim sends the stealth bit only while its 隐匿 is on: diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 16a9c3e7..a27b038b 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -116,10 +116,11 @@ public/ data.js fetches /data/*.json, same indexes as server/data.js audio.js BGM/SFX manager (Web Audio), volume settings assets.js asset URLs, image cache, Spine loader with LRU + fallback - screens/ title.js, lobby.js, room.js, briefing.js, bandDraft.js, game.js, result.js + screens/ title.js, lobby.js, room.js, briefing.js, bandDraft.js, game.js (entry; game/*.js plates, marks, early events), result.js ui/ hud.js, bondStrip.js, bondPopup.js, shopBar.js, teamPanel.js, detailPanel.js, - tooltip.js, choiceOverlay.js, rewardOverlay.js, ticker.js, emotes.js, settings.js, toasts.js - render/ app.js, projection.js, tiles.js, units.js, spine.js, fx.js, interp.js, drag.js, pick.js, promote.js (API §9) + tooltip.js, choiceOverlay.js, rewardOverlay.js, ticker.js, emotes.js, settings.js, toasts.js, + gameLogic.js (barrel; gameLogic/*.js placement, bonds, phases, shop, loadout) + render/ app.js (field view; app/*.js helpers), projection.js, tiles.js, units.js, spine.js, fx.js (effects; fx/*.js), interp.js, drag.js, pick.js, promote.js (API §9) vendor/ pixi, pixi-spine, preact, hooks, htm (copied from node_modules by tools/vendor.mjs) assets/ downloaded art/audio (git-ignored), see docs/ASSETS.md fonts/ self-hosted fonts @@ -498,7 +499,7 @@ view.holdPiece(uid, {row,col}|null) // keep a dropped piece on a tile w view.setPieceDir(uid, dir) // show a prep piece facing UP|RIGHT|DOWN|LEFT (wheel preview / stored dir) view.resize() ; view.destroy() ``` -Visuals: oblique perspective board (tilted ~30°, trapezoid tiles), raised highland blocks with side faces, road/floor/forbidden styles per research 07 §7, pulsing red gates, blue objective, dashed hand pads. Units: Spine (Front/Back rule, feet anchored, shadow sprite), elite gold outline glow, tier chip, HP bar (green ally / red enemy), SP bar (skill ready glow), status icons, the element gauge row under the bars — icon + white bar of the remaining 元素值, element-coloured refill during a 爆发冷却 (§18.3, §20.8); enemies drawn × their official prefab factor (enemies.json `modelScale`, §20.4); an enemy's mode — the `form` of a sim fx (`shared/protocol.js fxForm`), or UnitInfo `form` for a view built later through `render/app.js renderInfo` — switches clip sets (`render/units.js FORMS`: 掠海漂移体 crawl, 暴鸰 bombed, 转译基底's forms, the 逐火 embers, the leaders' 重生, 守墓石像; closing clips timed from the fx `dur`), a one-off cast (`PROJ[kind].once`) plays once, and a local-only model's web alias is tinted (`ALIAS_TINT`: 灼热 / 炽焰源石虫, §21.14); damage numbers (phys orange-white, arts purple, true white, heal green); projectiles drawn per kind, hit sparks and melee slashes, skill activation bursts and auras (§17.3); death fade for enemies, summons and devices — a knocked-out operator stays on its tile in its held Die pose under a redeploy ring (§18.3); leak flash at the objective. Fallback when Spine missing/failed: avatar in a rarity-coloured diamond with bob/lunge tweens — a failed or timed-out model is loaded again (bounded), and a view re-resolves its model and avatar when the asset manifest arrives late or the tab is shown again (§21.27). 60 fps target with ≤ 120 units. Picking (which unit a press / hover / dropped item is on) and the drag target follow one rule, `render/pick.js` — the tile under the pointer (§18.1); the Spine store never hands out a skeleton whose unload is in flight (§17.1). +Visuals: oblique perspective board (tilted ~30°, trapezoid tiles), raised highland blocks with side faces, road/floor/forbidden styles per research 07 §7, pulsing red gates, blue objective, dashed hand pads. Units: Spine (Front/Back rule, feet anchored, shadow sprite), elite gold outline glow, tier chip, HP bar (green ally / red enemy), SP bar (skill ready glow), status icons, the element gauge row under the bars — icon + white bar of the remaining 元素值, element-coloured refill during a 爆发冷却 (§18.3, §20.8); enemies drawn × their official prefab factor (enemies.json `modelScale`, §20.4); an enemy's mode — the `form` of a sim fx (`shared/protocol.js fxForm`), or UnitInfo `form` for a view built later through `render/app/info.js renderInfo` — switches clip sets (`render/units.js FORMS`: 掠海漂移体 crawl, 暴鸰 bombed, 转译基底's forms, the 逐火 embers, the leaders' 重生, 守墓石像; closing clips timed from the fx `dur`), a one-off cast (`PROJ[kind].once`) plays once, and a local-only model's web alias is tinted (`ALIAS_TINT`: 灼热 / 炽焰源石虫, §21.14); damage numbers (phys orange-white, arts purple, true white, heal green); projectiles drawn per kind, hit sparks and melee slashes, skill activation bursts and auras (§17.3); death fade for enemies, summons and devices — a knocked-out operator stays on its tile in its held Die pose under a redeploy ring (§18.3); leak flash at the objective. Fallback when Spine missing/failed: avatar in a rarity-coloured diamond with bob/lunge tweens — a failed or timed-out model is loaded again (bounded), and a view re-resolves its model and avatar when the asset manifest arrives late or the tab is shown again (§21.27). 60 fps target with ≤ 120 units. Picking (which unit a press / hover / dropped item is on) and the drag target follow one rule, `render/pick.js` — the tile under the pointer (§18.1); the Spine store never hands out a skeleton whose unload is in flight (§17.1). --- diff --git a/docs/SIM.md b/docs/SIM.md index 5b01b931..c7168db8 100644 --- a/docs/SIM.md +++ b/docs/SIM.md @@ -352,7 +352,7 @@ still standing after `Revive[Trigger].interval` s stands up again with full HP), 转译基底·α (its original form cancels every damage instance, and an HP loss stops at 1 HP; the 4th physical / arts instance or a block starts a 2 s change). Each form change goes through `setForm(b, e, form, fxKind, params)`: the unit keeps it (`e.form`, published as UnitInfo `form`, so a view built mid-battle from `fieldMeta()` — a watched -teammate's field, 联防 observers, a reconnect — starts in it: `render/app.js renderInfo` hands it to the view) and the +teammate's field, 联防 observers, a reconnect — starts in it: `render/app/info.js renderInfo` hands it to the view) and the fx announces it as its `form`: a 'phase' fx (crawl, translator_* — also its `kind`), 'ember' ('husk'), 'revive' ('revived' / 'form2' / 'fly'), 'telegraph' ('reborn') or 'stone' ('stone') — render/units.js FORMS. An operator has one form too: a 傀儡师's <替身> (`professions.js installDollkeeper`: `u.form` 'doll' from the switch to it — fx 'substitute' @@ -362,7 +362,7 @@ after the 'die' event). Barrier / charge keeps every fx with a `form` (`shared/protocol.js fxForm`) where it drops other events — the runner's catch-up frames and its hidden-tab backlog (`battle/runner.js keepsState`; a backlog past `HELD_MAX` drops only superseded status / skill toggles, and a battle that ended while hidden delivers it when the tab is shown), the game screen's events -buffered before a field is entered (`screens/game.js keepEarly`, also while a re-sent field meta re-enters the field on +buffered before a field is entered (`screens/game/early.js keepEarly`, also while a re-sent field meta re-enters the field on screen) and the render engine's event queue (`render/interp.js isCosmeticEvent`: a form fx is never dropped as stale, more than 1.5 game s behind the render clock, nor shed from a full queue; one handed out late switches the model without its telegraph or a change clip that would already have ended); dropping them was report #5's look again after a stall or a background tab.