Files
ark-stronghold-protocol/public/js/render/style.js
T
IceCodeNewandConvey123 8ae891f1f3 feedback7 R22-HUD: the HP bar shows the ammo skill's rounds as yellow cells (隐现 S2: 14), 伺夜's 狼影 left as diamonds and 斩业星熊's 我执 pool as a red bar — one snapshot change for PR #303 and #378 by @IceCodeNew and PR #383 by @Convey123; golden: unchanged
The three PRs patched the same snapshot and HUD lines (Grok's review: do not merge three diffs), so this is one design
built from their code and tests; IceCodeNew is the commit author (#303, #378), Convey123 co-author (#383).

- b.snap gains three optional, display-only lists beside the unchanged 9-field tuples (snapshot.js ammoView / wolfView /
  negView, called from battle/events.js snapshot(); a living, deployed, visible ally, only when it has one):
  ammo [[id, rounds left, magazine]] (whole rounds; the magazine is the activation's real total that unitTuple's SP
  fraction runs on, extras and refills included), wolves [[id, 狼影 left, talent maximum]] (mem.shadows of the new
  mem.wolfCapacity, set by tokens.js wolfPack and the managed vigil kit), neg [[id, fill]] (unit.negFill(), a function
  op-hsgma2.js sets: the pool is read when the snapshot is taken, no per-tick sync). UnitInfo.ammoSkill (an ally whose
  skill is an ammo magazine) is known from the first appearance: fx events run before the frame's sample, so the aura
  cannot wait for the first ammo row.
- render/interp.js keeps the lists per snapshot (malformed rows / unknown ids dropped) and sample() hands them out from the
  older snapshot like flags (they step with the skill flag); render/units.js draws them: the ammo magazine replaces the
  SP bar (a cell per round, thin cuts, exact fill, no cuts under 2 px a cell), a row of diamonds under the bars for the
  wolves (the element gauge row moves below), the red bar over the drained HP bar for the pool. An ammo skill draws no
  standing aura (rings.js _aura; the activation burst stays).
- PR #378's wolf-head avatars are NOT taken: the count is data (the talent text 至多3只, mem.shadows), the HUD art of the
  official counter is in no game data we have, so the pips are plain diamonds [ASSUMED look]. The PRs' own tests are
  folded in and extended (test/sim/hud-readouts.test.js, test/render/hud-readouts.test.js, op_hsgma2.test.js).
- Docs: network.md, SIM.md §9, client.md, PLAYING.md, types/core.js.
- Golden: unchanged (283 scenarios; the canon hashes units / down / elem only, no sim state moved).
- Tests: 4 + 14 new + 1 in op_hsgma2 (12), all fail on the parent; 344 related tests pass; lint 0 errors, typecheck,
  check:imports, docs tests.

Co-authored-by: Convey123 <Convey123@users.noreply.github.com>
2026-10-08 19:22:08 +08:00

212 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// render/style.js — palette and visual tunables of the battlefield renderer (pure data).
// Colours follow research 07 §7 (tile palette, tuned by eye) and the UI design system (theme.css).
export const TILE_H = Object.freeze({
wall: 0.42, // 'h' high ground
forbid: 0.3, // '#' forbidden block (data: HIGH)
sep: 0.55, // 'X' hard separator wall (rows 6 / 13)
bench: 0.16, // 'a' / 'A' hand & temp slots (data: HIGH)
platform: 0.26, // active 射击台 / mound devices
});
/** Stage glyph → material + height class (data/stages.json legend, DATA.md §12). */
export const GLYPH = Object.freeze({
'#': { mat: 'forbid', h: 'forbid' },
X: { mat: 'sep', h: 'sep' },
r: { mat: 'road', h: null },
R: { mat: 'roadN', h: null },
f: { mat: 'floor', h: null },
p: { mat: 'preview', h: null },
h: { mat: 'wall', h: 'wall' },
b: { mat: 'fence', h: null },
a: { mat: 'hand', h: 'bench' },
A: { mat: 'temp', h: 'bench' },
S: { mat: 'start', h: null },
E: { mat: 'end', h: null },
I: { mat: 'telin', h: null },
O: { mat: 'telout', h: null },
m: { mat: 'mire', h: null },
g: { mat: 'smog', h: null },
d: { mat: 'deepsea', h: null },
i: { mat: 'infection', h: null },
});
/** tileKey (when a stage uses glyphs outside the legend) → glyph. */
export const TILEKEY_GLYPH = Object.freeze({
tile_forbidden: '#', tile_road: 'r', tile_floor: 'f', tile_wall: 'h', tile_fence_bound: 'b', tile_fence: 'b',
tile_achand: 'a', tile_start: 'S', tile_end: 'E', tile_telin: 'I', tile_telout: 'O', tile_mire: 'm',
tile_smog: 'g', tile_deepsea: 'd', tile_infection: 'i', tile_grass: 'r', tile_hole: '#',
});
export const COLORS = Object.freeze({
bgTop: '#0b0f0e',
bgMid: '#131a18',
bgBottom: '#07090a',
fog: [0.62, 0.68, 0.72],
mint: 0x4ed8af,
mintHi: 0x59f4ca,
gold: 0xffc600,
gateRed: 0xff3b30,
gateRedDeep: 0xd0453b,
objBlue: 0x39a7ff,
objBlueDeep: 0x2d7fd6,
legal: 0x3ce08c,
illegal: 0xff4040,
range: 0xff9c33,
rangeStand: 0x4ed8af,
hpAlly: 0x5fe07a,
hpAllyLow: 0xe8c547,
hpEnemy: 0xff4b3e,
hpBoss: 0xff2d55,
hpGhost: 0xfff0c8,
hpBack: 0x0c0f0e,
// the red bar of a negative-HP pool (斩业星熊's 我执, b.snap `neg`) — the same red as an enemy's HP
hpNeg: 0xff4b3e,
sp: 0x6fd3ff,
spReady: 0xffe066,
spActive: 0xffb347,
// the ammo bar's yellow cells (b.snap `ammo`) and 伺夜's 狼影 pips (b.snap `wolves`)
ammo: 0xffd04a,
wolf: 0xe8f0ff,
shield: 0xdfe8ff,
});
/** Chess tier accents (theme.css --tier-1…6) and rarity-ish frames for enemies. */
export const TIER_COLORS = Object.freeze([0x9aa5a0, 0x9aa5a0, 0x7fd37a, 0x52b6ff, 0xb98cff, 0xffc600, 0xff6b3d]);
export const ENEMY_FRAME = Object.freeze({ normal: 0xc84a3c, elite: 0xff7a33, boss: 0xff2d55 });
/**
* 活性源石 (infection) — the ONE palette of that tile, shared by the two boards (GitHub #184: the 2D atlas cell and the
* 3D shader used to be two different materials — a beveled brick with crystal clusters against a world-space crust).
* The values are the 3D board's own working colours (`render/board3d/materials.js` writes them into its shaders, which
* end in `#include <colorspace_fragment>`, i.e. they are LINEAR); `textures.js` converts them for its canvas with
* `linearToHex`, so both renderers show the same colour.
* base — the crust's dark side, crust — its lit side, vein — the glowing veins, spec — the bright crystal grains.
*/
export const ORIGINIUM = Object.freeze({
base: Object.freeze([0.16, 0.05, 0.05]),
crust: Object.freeze([0.3, 0.1, 0.07]),
vein: Object.freeze([1.0, 0.46, 0.18]),
spec: Object.freeze([1.0, 0.78, 0.52]),
glow: 0xff6a3d, // the additive pulse the 2D board tints per tile
});
/**
* One channel of a working (linear) colour as the 0–255 sRGB value a canvas stores — the shaders' `#include
* <colorspace_fragment>` does this for the 3D board, the canvas has to do it itself.
*/
export function linearToSrgb255(v) {
const k = Number(v);
const s = k <= 0.0031308 ? k * 12.92 : 1.055 * Math.pow(Math.max(0, k), 1 / 2.4) - 0.055;
return Math.max(0, Math.min(255, Math.round(s * 255)));
}
/** A working (linear) `ORIGINIUM` colour as the `#rrggbb` sRGB string of the canvas (`textures.js originiumCanvas`). */
export function linearToHex(rgb) {
return `#${rgb.map((v) => linearToSrgb255(v).toString(16).padStart(2, '0')).join('')}`;
}
export const DMG_STYLE = Object.freeze({
phys: { font: 'sp-dmg-phys', fill: ['#fffbe8', '#ffb35c'], stroke: '#3b1400' },
arts: { font: 'sp-dmg-arts', fill: ['#fbe8ff', '#c77dff'], stroke: '#2a0b45' },
true: { font: 'sp-dmg-true', fill: ['#ffffff', '#e8e8e8'], stroke: '#2a2a2a' },
heal: { font: 'sp-dmg-heal', fill: ['#eafff0', '#62f08a'], stroke: '#07361a' },
elem: { font: 'sp-dmg-elem', fill: ['#fff4e0', '#ff7b3a'], stroke: '#3a1000' },
});
/** Map b.ev dmg types to a style key. */
export function dmgStyleKey(type) {
if (type === 'phys' || type === 'arts' || type === 'true' || type === 'heal') return type;
if (type === 'burn' || type === 'neural' || type === 'necrosis' || type === 'apoptosis' || type === 'erosion' || type === 'element') return 'elem';
return 'phys';
}
/** Hit spark colour per damage type. */
export const HIT_TINT = Object.freeze({ phys: 0xffd9a0, arts: 0xc77dff, true: 0xffffff, heal: 0x62f08a, elem: 0xff7b3a });
/**
* Projectile visuals per b.ev 'atk' projKind (sim: none | arrow | bolt | bomb | lob | orb | drone | enemy | boomerang | droneBomb;
* chain / chainHeal / beam are beams). Drawn by render/fx.js:
* look tracer (bullet streak + muzzle flash) | orb (glowing ball, particle trail) | shell (lobbed on an arc with a
* ground shadow, smoke trail) | dart (small fast bolt) | boomerang (spins to the target and back to the thrower)
* speed tiles per game second — a copy of the sim's PROJECTILE_SPEEDS (server/sim/constants.js; `back` of the
* boomerang = BOOMERANG_RETURN_SPEED, PRTS 跃跃: out 15, back 3.75). fx.js prefers the sim's own values, loaded
* from /sim/constants.js, so the visual flight ends with the sim's hit; this copy stands in until then (and in
* Node). test/render/fxproj.test.js keeps the two equal.
* sizes in tiles (× the camera's px per tile at the shot): `len` trail length, `width` trail thickness, `head` glow
* diameter; `arc` peak height of a lob over a 3-tile throw (scales with the range)
* colours `tint` hot core, `glow` halo, `trail` trail particles, `muzzle` flash at the shooter
* hit arrival burst (fx.js _impact): spark | arts | heal | boom | splash | zap | enemy
* once a one-off cast, not an attack rhythm: the shooter's attack clip plays once at its own speed (render/spine.js
* SpineActor.attack / windUp) — 暴鸰's bomb drop
*/
export const PROJ = Object.freeze({
arrow: { look: 'tracer', speed: 14, tint: 0xfff6dc, glow: 0xffc45a, len: 1.05, width: 0.2, head: 0.36, muzzle: 0xffd27a, hit: 'spark' },
bolt: { look: 'orb', speed: 11, tint: 0xf4e2ff, glow: 0xb36bff, len: 0.6, width: 0.34, head: 0.52, trail: 0xa35cff, muzzle: 0xc77dff, hit: 'arts' },
orb: { look: 'orb', speed: 10, tint: 0xeafff0, glow: 0x3fe07a, len: 0.55, width: 0.3, head: 0.48, trail: 0x62f08a, muzzle: 0x62f08a, hit: 'heal' },
bomb: { look: 'shell', speed: 8, tint: 0xffeed0, glow: 0xff8a3d, len: 0.85, width: 0.3, head: 0.56, trail: 0xff9c4a, arc: 1.1, smoke: 0x2e2824, muzzle: 0xffb35c, hit: 'boom' },
lob: { look: 'shell', speed: 8, tint: 0xfff4dc, glow: 0xffb04a, len: 0.7, width: 0.26, head: 0.5, trail: 0xffc27a, arc: 1.4, smoke: 0x3a322c, muzzle: 0xffc27a, hit: 'splash' },
drone: { look: 'dart', speed: 16, tint: 0xe4fbff, glow: 0x57c9ff, len: 0.7, width: 0.16, head: 0.3, trail: 0x57c9ff, hit: 'zap' },
enemy: { look: 'orb', speed: 10, tint: 0xffe2da, glow: 0xff3b30, len: 0.55, width: 0.28, head: 0.44, trail: 0xff4a3a, muzzle: 0xff6a5a, hit: 'enemy' },
boomerang: { look: 'boomerang', speed: 15, back: 3.75, tint: 0xfff4d6, glow: 0x9ff0dc, len: 0.4, width: 0.3, head: 0.5, trail: 0x9ff0dc, hit: 'spark' },
// 暴鸰's bomb (sim content/enemies/fly.js kitBombd; official projectile_bombd, speed 5): dropped from the drone, it falls
// onto its target with a low arc and bursts where the sim's 'explode' blast goes off
droneBomb: { look: 'shell', speed: 5, once: true, tint: 0xffe2c8, glow: 0xff5a3a, len: 0.6, width: 0.3, head: 0.5, trail: 0xff7a4a, arc: 0.35, smoke: 0x2e2824, hit: 'boom' },
});
/** Status keys (b.ev 'status' + UF flags) → icon atlas key (render/textures.js) and colour. */
export const STATUS_ICON = Object.freeze({
stun: 'stun', freeze: 'freeze', cold: 'cold', stealth: 'stealth', shield: 'shield', fragile: 'fragile',
artsFragile: 'fragile', physFragile: 'fragile', elemFragile: 'fragile', sleep: 'sleep', invulnerable: 'invuln',
silence: 'silence', slow: 'slow', sluggish: 'slow', bind: 'bind', groundbind: 'bind', fear: 'fear', tremble: 'fear', weaken: 'weaken',
levitate: 'levitate', taunt: 'taunt', defDown: 'weaken', resDown: 'weaken', aspdDown: 'slow', disarm: 'silence',
burn: 'burn', burnBurst: 'burn', neural: 'neural', neuralBurst: 'neural', necrosis: 'necrosis', apoptosis: 'necrosis',
// a 傀儡师 fighting as its <替身> (sim professions.js buff 'trait:substitute', the 20 s form)
substitute: 'doll',
// 禁疗 (sim status 'healFree': 史尔特尔's 余烬 — no heal reaches her until she leaves)
healFree: 'healFree',
// 折射 (sim buff 'ab:refraction', visible while the RES bonus is on). The tail of 'ab:refraction' hits this key.
refraction: 'refraction',
});
/** Keyword fallbacks for namespaced / content status keys ('ab:frost', 'reed2:scorch', 'skill:shotst_shred' …). */
const STATUS_GUESS = [
[/frost|chill|cold/i, 'cold'], [/freez/i, 'freeze'], [/scorch|burn|ignit|flame/i, 'burn'], [/stun|daze/i, 'stun'],
[/sleep|slumber/i, 'sleep'], [/silenc/i, 'silence'], [/slow|slugg|bind|root|snare/i, 'slow'], [/fear|trembl/i, 'fear'],
[/fragil|shred|expos|wanted|vulner|mark/i, 'fragile'], [/weak|down$/i, 'weaken'], [/shield|resist|guard|ward|barrier/i, 'shield'],
[/invul|immun/i, 'invuln'], [/stealth|camou|invis/i, 'stealth'], [/levit|float/i, 'levitate'], [/taunt/i, 'taunt'],
[/neural/i, 'neural'], [/necro|apopt|erosion/i, 'necrosis'],
];
/**
* The 折射 icon is not drawn while the unit is silenced: the RES bonus is already off (sim content/enemies/archetypes.js refraction)
* and the status must not keep looking active. Other icons stay.
* @param {string} key a b.ev status key
* @param {Set<string>|string[]|null} statuses
*/
export function statusIconSuppressed(key, statuses) {
if (key !== 'ab:refraction' && key !== 'refraction') return false;
if (!statuses) return false;
return typeof statuses.has === 'function' ? statuses.has('silence') : Array.isArray(statuses) && statuses.includes('silence');
}
/** Icon key (textures STATUS_KEYS) for a b.ev status key or flag name; null when it has no icon. */
export function statusIconKey(key) {
if (typeof key !== 'string' || !key) return null;
if (STATUS_ICON[key]) return STATUS_ICON[key];
const tail = key.includes(':') ? key.slice(key.lastIndexOf(':') + 1) : key;
if (STATUS_ICON[tail]) return STATUS_ICON[tail];
for (const [re, icon] of STATUS_GUESS) if (re.test(tail)) return icon;
return null;
}
export const UNIT = Object.freeze({
/** Spine skeleton units → world tiles (skeletons are ~360–450 units tall ⇒ ~1.2 tiles). */
modelScale: 1 / 320,
/** Fallback diamond size in tiles. */
diamond: 0.78,
headroom: 1.18, // tiles above the feet where bars sit (operators; enemies use their bounds)
barWidth: 0.64,
bossBarWidth: 2.2,
});