- #1 #2 skill / device summons (赫默's drone, 巫恋's doll, 凯瑟琳's devices) are hand cards placed by the player; placed summons deploy once at battle start, then a skill summon re-appears at its tile on each cast (the user's call) - #3 叙拉古 隐匿 ends on time (32 + 0.4 × layers s); 忍冬 S3 迷彩 lasts until her next cast (official), kept apart from 隐匿 - #4 multi-round bounty cards last two battles (the user's call; MULTI_ROUND_BOUNTY_BATTLES), blue / red card text - #5 the shared boss pool's float dust (≈3.6e-12 HP) stalled Final Assault leaders at 0 %; < 1 HP now clears the pool, and the team LP reaching 0 first is a defeat (PRTS) - #6 #10 机变 cards show their official effect text; an invisible 详情 button no longer swallows the confirming tap on phones - #7 live ×N counter of a leaker's enemies still on the 联防 field (LP loss stays capped at 10) - #8 #15 #16 skill triggers per 技能策略: skill-range triggers, injured-ally heals (古米, 塞雷娅), tank take-damage triggers with a 3 s cooldown; 新约能天使 S2 shields the highest-aggro operator in range - #9 official per-enemy model scales (威龙) - #11 element bar under the unit's HP bar (white = 元素值 left, element-coloured refill after a burst), floor rounding - #12 枯朽战车 kit per PRTS; every ranged enemy reaches its target's 0.25 collider - #13 恐惧 wander (萨科塔之翼 at half HP, operator fears make enemies flee) with re-planning after pushes - #14 push / pull distance by 力度 − 重量 (PRTS table); #17 every blocker attacks what it blocks; ASPD floor 20 - #18 炎佑 is 孤立: no heals or ally buffs - #19 every free pick-one chess offer (promotion reward, 信标, 寻呼模块) shows different operators - DESIGN §20 and the normative lines, SIM / META / DATA / PLAYING updated; 2747 unit tests and all 27 browser suites green; dev recordings regenerated
99 lines
4.3 KiB
JavaScript
99 lines
4.3 KiB
JavaScript
// server/sim/snapshot.js — compact serialization for clients (DESIGN §8.2).
|
|
//
|
|
// b.snap = { fieldId, t, units: [[id, x, y, hp, maxHp, sp, spMax, flags, anim]], dp, killed, total }
|
|
// UnitInfo = { id, kind, side, ownerId, defId, name, tier, golden, spine, avatar, x, y, facing, dir, maxHp, skillIndex?, moduleId? }
|
|
// dir = 'UP'|'RIGHT'|'DOWN'|'LEFT' (allies: the deploy direction, sim/dir.js); facing = its horizontal sign (±1).
|
|
// flags bits & anim codes come from shared/constants.js (UF / ANIM).
|
|
|
|
import { UF, ANIM } from '../../shared/constants.js';
|
|
import { DIE_ANIM_TIME, ATTACK_ANIM_TIME, DEPLOY_ANIM_TIME } from './constants.js';
|
|
|
|
const r2 = (v) => Math.round(v * 100) / 100;
|
|
const r1 = (v) => Math.round(v * 10) / 10;
|
|
|
|
/** Static per-unit info sent on spawn / in m.field. */
|
|
export function unitInfo(u) {
|
|
const d = u.def || {};
|
|
return {
|
|
id: u.id,
|
|
kind: u.kind,
|
|
side: u.side,
|
|
ownerId: u.ownerId ?? null,
|
|
defId: u.defId,
|
|
name: u.name,
|
|
tier: d.tier ?? (d.rank === 'BOSS' ? 3 : d.rank === 'ELITE' ? 2 : 1),
|
|
golden: !!d.golden,
|
|
spine: d.spine ?? d.charId ?? u.defId,
|
|
avatar: d.avatar ?? d.charId ?? u.defId,
|
|
x: r2(u.x),
|
|
y: r2(u.y),
|
|
facing: u.facing ?? 1,
|
|
dir: u.dir ?? 'RIGHT',
|
|
maxHp: Math.max(1, Math.round(u.s.maxHp)),
|
|
motion: u.motion === 'FLY' ? 'FLY' : undefined,
|
|
boss: u.isBoss ? true : undefined,
|
|
uid: u.uid ?? undefined,
|
|
// DESIGN §16: the equipped skill's index (the renderer / audio pick that skill's Spine clip and sound)
|
|
skillIndex: u.side === 'ally' && Number.isInteger(d.skill?.index) ? d.skill.index : undefined,
|
|
// DESIGN §16: an elite ally's equipped module (uniEquipId | 'none'; display only — a teammate's unit in a shared
|
|
// field shows its owner's module in the detail card)
|
|
moduleId: u.side === 'ally' && d.golden && typeof d.loadout?.moduleId === 'string' ? d.loadout.moduleId : undefined,
|
|
};
|
|
}
|
|
|
|
/** Status flag bitmask. */
|
|
export function flagsOf(u) {
|
|
const f = u.s.flags;
|
|
let bits = 0;
|
|
if (u.side === 'enemy' ? !!u.blockedBy : u.blocking.length > 0) bits |= UF.BLOCKED;
|
|
if (f.stun && !f.freeze && !f.sleep) bits |= UF.STUNNED;
|
|
if (f.freeze) bits |= UF.FROZEN;
|
|
// 隐匿 or 迷彩 (buffs.js camou): both shown the see-through way, blocking or not (targeting.js canTargetAlly)
|
|
if (f.stealth || f.camou) bits |= UF.STEALTH;
|
|
if (u.skill && u.skill.active && u.skill.kind !== 'passive') bits |= UF.SKILL;
|
|
if (u.s.shield > 0 || u.buffs.some((b) => b.shieldHits > 0)) bits |= UF.SHIELD;
|
|
if (f.invulnerable) bits |= UF.INVULN;
|
|
if (f.cold) bits |= UF.COLD;
|
|
if (f.sleep) bits |= UF.SLEEP;
|
|
if (u.motion === 'FLY') bits |= UF.FLYING;
|
|
return bits;
|
|
}
|
|
|
|
/** Animation code. */
|
|
export function animOf(u, t) {
|
|
if (!u.alive) return ANIM.DIE;
|
|
if (u.s.flags.stun) return ANIM.STUN;
|
|
if (t - u.deployedAt < DEPLOY_ANIM_TIME && u.side === 'ally') return ANIM.DEPLOY;
|
|
if (t < (u.skillAnimUntil ?? -1)) return ANIM.SKILL;
|
|
if (t - u.lastAttackAt < ATTACK_ANIM_TIME) return u.skill && u.skill.active && u.skill.kind !== 'passive' ? ANIM.SKILL : ANIM.ATTACK;
|
|
if (u.side === 'enemy' && u.moving && !u.blockedBy) return ANIM.MOVE;
|
|
return ANIM.IDLE;
|
|
}
|
|
|
|
/** Snapshot tuple for one unit. */
|
|
export function unitTuple(u, t) {
|
|
const sk = u.skill;
|
|
const spMax = sk && !sk.noSkill ? sk.spCost : 0;
|
|
let sp = sk && !sk.noSkill ? sk.sp : 0;
|
|
if (sk && sk.active && sk.isTimed) {
|
|
// show remaining duration/ammo as a draining bar
|
|
if (sk.kind === 'ammo') sp = spMax * (sk.ammoLeft / Math.max(1, sk.ammo));
|
|
else if (Number.isFinite(sk.timeLeft) && sk.duration > 0) sp = spMax * (sk.timeLeft / sk.duration);
|
|
}
|
|
// hp is rounded up (a living unit never shows 0) but never above the rounded max HP
|
|
const maxHp = Math.max(1, Math.round(u.s.maxHp));
|
|
const hp = u.alive ? Math.min(Math.max(1, Math.ceil(u.hp)), maxHp) : 0;
|
|
return [u.id, r2(u.x), r2(u.y), hp, maxHp, r1(sp), spMax, flagsOf(u), animOf(u, t)];
|
|
}
|
|
|
|
/** Units included in a snapshot: deployed & visible, plus recently dead ones (DIE animation). */
|
|
export function snapshotUnits(units, t) {
|
|
const out = [];
|
|
for (const u of units) {
|
|
if (u.hidden) continue;
|
|
if (u.alive && u.deployed) out.push(unitTuple(u, t));
|
|
else if (!u.alive && t - u.deathAt < DIE_ANIM_TIME && u.deathAt > -Infinity) out.push(unitTuple(u, t));
|
|
}
|
|
return out;
|
|
}
|