Playtest #6 fixes: summons placed by hand, boss pool stall, bounties, 联防 leak counter, distinct offers, 机变 card text, phone buy tap, skill triggers, blocking, push force, 孤立, enemies, element bar

- #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
This commit is contained in:
sganggs committed 2026-10-01 19:08:23 +08:00
1 parent e6628f0cfe
commit 6464e4b46a
138 files changed
+7946 -1379

No files matched your search

+84 -29
View File
@@ -7,7 +7,9 @@
// game never blocks on Spine). Every bar is a tinted Texture.WHITE sprite, so HUDs batch into few draw calls.
//
// Placement: feet anchored at world (x, y, z); scale = camera px-per-tile at the feet × UNIT.modelScale, so
// chibis shrink with distance like the original. Operators and summons face their deploy direction `dir`
// chibis shrink with distance like the original. An enemy's model is also scaled by its official prefab factor
// (enemies.json `modelScale`, user playtest #6 item 9: the official battle prefab shrinks e.g. 威龙 to 0.16 / 0.27 of
// the standard size — tools/build-data.mjs MODEL_SCALES; `enemyModelScale`). Operators and summons face their deploy direction `dir`
// (research 09 §1.2, DESIGN §3: UP|RIGHT|DOWN|LEFT, chosen with the direction wheel; UnitInfo / piece `dir`, else
// the legacy `facing` ±1): the Back model for UP (when one exists, research 07 §5.5), the Front model for RIGHT and
// DOWN, mirrored for LEFT; the orange ground wedge "›" of prep board pieces points along `dir` (rotated on the ground
@@ -24,10 +26,16 @@
// goes down with `die(true)`: straight to the held end of the clip, no fall.
// Enemy modes (sim fx 'phase' { id, kind } → `setForm(kind)`): 掠海漂移体 dropping to 爬行模式 (user playtest #5 item 1)
// plays its skeleton's 'Change' clip once, then the crawl set (*_02) — FORMS; a view built later keeps the mode.
// Element gauges (item 8, b.snap `elem` → sample `el` / `elFill` / `elUntil` / `elDur`): the official element icon
// beside the bars (PRTS 元素: operators the element's disc with its glyph, enemies a smaller plain disc of the
// element's colour) inside a white ring of the remaining 元素值 (1 − fill); during a 爆发冷却 the ring refills over
// the cooldown. Both rings are sprites of one atlas (textures.js hudRings), created on first use and hidden when idle.
// Element gauges (b.snap `elem` → sample `el` / `elFill` / `elUntil` / `elDur`), the official form (PRTS 元素: "模型
// 下部会显示对应的元素图标,并以白条显示剩余的元素值"; enemies "小尺寸图标(不显示元素图标,仅根据元素种类改变背景色)"): a row
// right under the unit's own HP / SP bars and inside their span — the element's disc at the left (operators with its
// glyph, enemies smaller and plain) and a white bar of the remaining 元素值 (1 − fill) that runs out right to left
// like the HP bar above it; during a 爆发冷却 the bar refills over the cooldown (PRTS: "元素条显示缓慢恢复至上限"), drawn in
// the element's colour with the disc pulsing [ASSUMED look], so a refill never reads as 元素值 still left while the
// burst's stun / damage / 凋亡 ticks hit the unit. User playtest #6 (report 11): the v2.3 ring right of the bars sat on
// the next operator's tier chip and bars (drawn under them) and read as that operator's gauge, a shrinking ring reads
// like a filling progress ring, and its refill was the same white as the 元素值 left. The disc comes from the hudRings
// atlas, the bars are tinted Texture.WHITE sprites; built on first use and hidden when idle.
//
// ItemView renders hand items as a floating icon plate; DeviceView renders battle devices (crates) as 3D boxes
// with an HP bar once damaged.
@@ -44,7 +52,7 @@
import { UF, ANIM } from '../../../shared/constants.js';
import { SpineActor } from './spine.js';
import { diamondTexture, shadowTexture, fxAtlas, tierChip, statusTexture, itemTexture, hudRings, ringArc } from './textures.js';
import { diamondTexture, shadowTexture, fxAtlas, tierChip, statusTexture, itemTexture, hudRings, ringArc, HUD_DISC, ELEMENT_RING } from './textures.js';
import { COLORS, TIER_COLORS, ENEMY_FRAME, UNIT, statusIconKey } from './style.js';
import { drawCrate, rowDepthKey, ROW_KEY, deviceBoxOf, DEVICE_BOX } from './tiles.js';
@@ -56,6 +64,14 @@ export function unitDir(info) {
if (d && DIRS.includes(d)) return d;
return info?.facing === -1 ? 'LEFT' : 'RIGHT';
}
/**
* Official size factor of an enemy's model (enemies.json `modelScale`: its battle prefab's scale / the standard 0.27;
* 1 when absent or unusable) — applied on top of UNIT.modelScale to the skeleton and to the head (bar) height.
*/
export function enemyModelScale(rec) {
const k = Number(rec && rec.modelScale);
return Number.isFinite(k) && k > 0.05 && k < 20 ? k : 1;
}
/** World step (x = col, y = row) of a direction. */
export const DIR_STEP = Object.freeze({ UP: [0, 1], RIGHT: [1, 0], DOWN: [0, -1], LEFT: [-1, 0] });
const nowMs = () => (globalThis.performance ? globalThis.performance.now() : Date.now());
@@ -75,8 +91,13 @@ export const DOWN_LOOK = Object.freeze({
ring: Object.freeze({ [DOWN_STATE.COUNTING]: 0x4ed8af, [DOWN_STATE.WAIT_DP]: 0xffc600, [DOWN_STATE.WAIT_TILE]: 0xff4b3e }),
size: 0.42, height: 1.02,
});
/** Element ring diameter in tiles (operators; enemies ×0.8) and its pixel clamp. */
const EL_RING = 0.3;
/**
* Element gauge row under the bars (see header): the disc's diameter in tiles and its pixel clamp (enemies × `enemy`),
* the gap under the bars (px). The white bar is as tall as the SP bar and fills the rest of the bars' width. During a
* 爆发冷却 the refilling bar takes the element's colour (textures.js ELEMENT_RING `tint`) and the disc's alpha pulses
* between `pulse` and 1 at `pulseHz` (real time).
*/
export const EL_BAR = Object.freeze({ icon: 0.15, min: 8, max: 15, enemy: 0.8, gap: 1, pulse: 0.45, pulseHz: 1.5 });
/**
* Enemy modes drawn with another clip set of the same skeleton (sim fx 'phase' kind → UnitView.setForm), per Spine
@@ -156,6 +177,8 @@ export class UnitView {
this._far = false; // small on screen (adaptive LOD, with hysteresis)
this.isEnemy = info.side === 'enemy';
this.isBoss = !!info.boss;
// enemies: the official prefab's size factor (1 for operators, summons and enemies at the standard size)
this.modelK = this.isEnemy ? enemyModelScale(ctx.lookupDef ? ctx.lookupDef(info) : null) : 1;
this.isToken = info.kind === 'token';
this.golden = !!info.golden;
this.tier = clamp(Number(info.tier) || 1, 1, 6);
@@ -178,9 +201,9 @@ export class UnitView {
this.dieT = 0; // seconds since die() (the Die clip's clock; a late Spine model catches up)
this.remove = false; // set when the death fade ended (owner removes the view)
this.down = null; // knocked out, waiting to redeploy: { until, total, state } (setDown) — no fade meanwhile
this.gameT = 0; // battle game time of the frame (render clock), for the rings' countdowns
this.gameT = 0; // battle game time of the frame (render clock), for the redeploy ring's countdown and the element bar's refill
this.el = null; this.elFill = 0; this.elUntil = 0; this.elDur = 0; // shown element gauge (sync)
this._elRing = null; // { track, disc, arc } sprites, built on first use
this._elBar = null; // { root, disc, bg, fill } sprites of the element gauge row, built on first use
this._downRing = null; // { disc, track, arc, text } sprites, built on first use
this.alpha = 1; this.fadeIn = this.prep ? 1 : 0;
this.lunge = 0; this.lungeDir = { x: 1, y: 0 };
@@ -648,7 +671,7 @@ export class UnitView {
if (this.swapT < 1) this.swapT = Math.min(1, this.swapT + dt * 5);
this.fallback.alpha = 1 - this.swapT;
this.fallback.visible = this.swapT < 1;
const sc = s * UNIT.modelScale;
const sc = s * UNIT.modelScale * this.modelK;
const flashK = this.flash > 0 ? this.flash : 0;
let tint = 0xffffff;
if (this.down) tint = DOWN_LOOK.tint;
@@ -725,10 +748,12 @@ export class UnitView {
this.aura.scale.set((s * 1.3) / 128, (s * 1.9) / 128);
}
// head height: operators/tokens are uniform chibis; enemies vary (setup-pose bounds, when known)
// head height: operators/tokens are uniform chibis; enemies vary (setup-pose bounds, when known; else the chibi
// headroom × their official model factor)
let headTiles = UNIT.headroom;
if (this.isEnemy && spineShown && this.actor.entry.bounds) headTiles = clamp(this.actor.height * UNIT.modelScale * 0.92, 0.55, this.isBoss ? 3.2 : 2.2);
if (this.isEnemy && spineShown && this.actor.entry.bounds) headTiles = clamp(this.actor.height * UNIT.modelScale * this.modelK * 0.92, 0.55, this.isBoss ? 3.2 : 2.2);
else if (this.isEnemy && this.isBoss) headTiles = 2.2;
else if (this.isEnemy && spineShown) headTiles = clamp(UNIT.headroom * this.modelK, 0.55, 2.2);
this._headTiles = headTiles;
this.screen.top = by - headTiles * s;
this._updateHud(dt, s, bx, by - headTiles * s, alpha, t);
@@ -829,8 +854,8 @@ export class UnitView {
ic.width = ic.height = isz;
ic.position.set(x - ((icons.length - 1) * (isz + 2)) / 2 + i * (isz + 2), iy);
}
// element gauge right of the bars (b.snap `elem`); redeploy ring above a knocked-down operator (b.snap `down`)
this._updateElementRing(showBars && !!this.el, x0 + bw, cy + (showSp ? spH / 2 : 0), s);
// element gauge row under the bars (b.snap `elem`); redeploy ring above a knocked-down operator (b.snap `down`)
this._updateElementBar(showBars && !!this.el, x0, bw, cy + bh / 2 + (showSp ? spH + 1.5 : 0) + 1, spH, s, t);
this._updateDownRing(!prep && !!this.down && !this.alive, x, this.screen.y - DOWN_LOOK.height * s, s, t);
// blocked marker at the feet (enemies held by a blocker)
const blocked = !prep && this.alive && this.isEnemy && (this.flags & UF.BLOCKED);
@@ -855,29 +880,59 @@ export class UnitView {
}
}
/** True while the shown gauge is in its 爆发冷却 (b.snap `elem` carries the cooldown's end and length). */
elementCooling() {
return !!this.el && this.elDur > 0 && this.elUntil > 0;
}
/**
* Element gauge icon (see header): the element's disc — operators with its glyph, enemies plain and smaller — in a
* white ring of the remaining 元素值 (1 − fill), which refills over a 爆发冷却; left edge at `xl`, centred on `cy`.
* Sprites of the hudRings atlas, built on the first gauge and hidden while there is none.
* The share of the element bar drawn (0..1): the remaining 元素值 (1 − fill), or during a 爆发冷却 the part of the
* cooldown already run (the bar refills to full, PRTS 元素 "元素条显示缓慢恢复至上限"). 0 without a gauge.
*/
_updateElementRing(show, xl, cy, s) {
let r = this._elRing;
elementLeft() {
if (!this.el) return 0;
if (this.elementCooling()) return clamp(1 - (this.elUntil - this.gameT) / this.elDur, 0, 1);
return clamp(1 - this.elFill, 0, 1);
}
/**
* Element gauge row (see header) under the bars spanning x0 … x0 + bw, its top at `top`: the element's disc at the
* left (operators with its glyph; enemies plain, × EL_BAR.enemy) and, beside it to the bars' right end, a bar of
* `elementLeft()` on a dark track `h` px tall — white for the 元素值 left; in the element's colour, the disc pulsing
* (clock `t`), while it refills over a 爆发冷却. Built on the first gauge, hidden while there is none.
*/
_updateElementBar(show, x0, bw, top, h, s, t = 0) {
let r = this._elBar;
if (!show) { if (r) r.root.visible = false; return; }
const tex = hudRings();
if (!r) {
const P = this.P;
const root = new P.Container();
const mk = (tx) => { const sp = new P.Sprite(tx); sp.anchor.set(0.5); root.addChild(sp); return sp; };
r = this._elRing = { root, track: mk(tex.track), disc: mk(tex.disc.burn), arc: mk(tex.arcs[0]) };
const bg = bar(P, root, COLORS.hpBack, 0.85);
const fill = bar(P, root, 0xffffff);
const disc = new P.Sprite(tex.disc.burn);
disc.anchor.set(0.5);
root.addChild(disc);
r = this._elBar = { root, disc, bg, fill };
this.hud.addChild(root);
}
const cooling = this.elDur > 0 && this.elUntil > 0;
const frac = cooling ? clamp(1 - (this.elUntil - this.gameT) / this.elDur, 0, 1) : clamp(1 - this.elFill, 0, 1);
const d = clamp(s * EL_BAR.icon, EL_BAR.min, EL_BAR.max) * (this.isEnemy ? EL_BAR.enemy : 1);
const cy = top + EL_BAR.gap + d / 2;
r.disc.texture = (this.isEnemy ? tex.discEnemy : tex.disc)[this.el] || tex.disc.burn;
r.arc.texture = ringArc(frac);
const d = clamp(s * EL_RING * (this.isEnemy ? 0.8 : 1), 13, 32);
r.root.scale.set(d / tex.size);
r.root.position.set(xl + d / 2 + 2, cy);
r.disc.width = r.disc.height = d / HUD_DISC; // the disc fills HUD_DISC of its atlas cell
r.disc.position.set(x0 + d / 2, cy);
const bx = x0 + d + 2, w = Math.max(4, x0 + bw - bx);
r.bg.position.set(bx - 1, cy); r.bg.width = w + 2; r.bg.height = h + 2;
const k = this.elementLeft();
r.fill.visible = k > 0;
r.fill.position.set(bx, cy); r.fill.width = w * k; r.fill.height = h;
if (this.elementCooling()) {
r.fill.tint = (ELEMENT_RING[this.el] || ELEMENT_RING.burn).tint;
r.disc.alpha = EL_BAR.pulse + (1 - EL_BAR.pulse) * (0.5 + 0.5 * Math.cos(t * Math.PI * 2 * EL_BAR.pulseHz));
} else {
r.fill.tint = 0xffffff;
r.disc.alpha = 1;
}
r.root.visible = true;
}
@@ -1039,7 +1094,7 @@ export class UnitView {
for (const k of this.statuses) {
if (out.length >= 4) break;
if (k === 'skill') continue;
// a burst's lock ('burnBurst', 'neuralBurst' … — the 爆发冷却) is shown by the element icon beside the bars
// a burst's lock ('burnBurst', 'neuralBurst' … — the 爆发冷却) is shown by the element gauge row under the bars
// (b.snap `elem`); only a feed without gauges (an older recording) shows it as a status
if (this.el && k.endsWith('Burst')) continue;
const icon = statusIconKey(k);