Playtest #5 fixes: hovering enemies, target order, blocking hand-over, 联防 knocked-out, elements, huge-boss hit area, maps, gear icon, phone HUD, 至简 price
- #1 近地悬浮 enemies count as air units (Unit.isFlying): ground-only attacks, splash, terrain and traps skip them; PRTS 不可对空 skill effects made ground-only; 掠海漂移体 / 吉兆飞鳞 drop modes; the bot plans them as air - #2 an operator knocked out before 联防 is fielded and force-exited (PRTS 强制退场): it lies on its tile with the redeploy ring and redeploys (full timer [ASSUMED]); partners, observers and the server fallback included - #3 element audit of every operator, item and enemy: 海嗣 numbers are official; bursts are 无来源, 元素脆弱 only raises 元素伤害, 元素抗性 applied, 盟约·辅助干员 fills all three gauges, 菲莱 / 哈洛德 / 纯烬 / enemy kits fixed - #4 blocking by the official contact radius (0.7071): another operator in contact takes over at once when the blocker dies, withdraws or is stunned; melee blockers attack what they block - #5 enemy target order per PRTS 索敌: blocker, special priority, taunt, last deployed; initial deploy 从上到下>从左到右, operators before summons; special priorities break ties by taunt then deploy order - #6 act2 m01's normal-field blower machines (row 13) are drawn in the normal / 联防 views - #7 Final Assault prep legality reads the player's half of the boss field (fence tiles deployable); all 11 stages audited against the official level files - #8 regular settings gear; #9 the prep camera keeps the bench and temp rows clear of the shop bar on phones - #10 huge bosses have the official 4.95 × 2.95 hit area (sim/body.js) and are 自缚 / unblockable - #11 至简 costs 1: 购买价格为N's bb.price is a discount off the tier price, applied before other price effects - DESIGN §19 and the normative §3 / §5 / §6 / §7 lines, SIM / META / DATA / PLAYING updated; 2595 unit tests and every browser suite green; dev recordings regenerated
This commit is contained in:
1 parent
d4f868cd19
commit
15291c2293
104 files changed
+5539
-662
No files matched your search
+72
-25
@@ -1,16 +1,22 @@
|
||||
// render/app.js — battlefield view (DESIGN §9). PixiJS 7 (global PIXI) + pixi-spine (PIXI.spine), loaded on demand
|
||||
// from /vendor when the page did not include them as classic <script> tags.
|
||||
//
|
||||
// const view = await createFieldView(host, { data, assets, audio, settings, padding })
|
||||
// const view = await createFieldView(host, { data, assets, audio, settings, padding, hud })
|
||||
// view.setStage(stage) procedural tiles + devices (data/stages.json entry)
|
||||
// view.setCamera(kind, { rect, side, padding, instant }) 'prep'|'normal'|'unite'|'boss'('hidden'); animated
|
||||
// — the prep cameras (own board / Final Assault half) keep the bench
|
||||
// and the field clear of `hud(kind, size)` = { top, bottom } px of
|
||||
// DOM HUD along the top / bottom edge (projection.js clearHud; user
|
||||
// playtest #5 item 9: the shop bar covered the bench on phones)
|
||||
// view.setPrep(privateState, { editable, canPlace }) hand/temp/board pieces; editable enables drag & drop
|
||||
// view.enterBattle(fieldMeta) m.field { fieldId, kind, rect, stageId, units: [UnitInfo] }
|
||||
// view.pushSnapshot(snap); view.pushEvents(ev | { ev, gt }) b.snap / b.ev wire frames as received (game time in
|
||||
// `gt`; a numeric `t` is accepted for raw Battle snapshots / recordings)
|
||||
// — 100 ms interpolation buffer; b.snap `down` keeps knocked-out
|
||||
// operators on the field under a redeploy ring and `elem` draws the
|
||||
// element gauges (user playtest #4 items 8 / 9, render/units.js)
|
||||
// element gauges (user playtest #4 items 8 / 9, render/units.js); a
|
||||
// 'die' with reason FORCED_EXIT (an operator entering 联防 knocked out,
|
||||
// user playtest #5 item 2) goes straight to the held pose, no burst
|
||||
// view.setLocalFeed({ on, speed }) frames come from the local sim every frame (client-side combat):
|
||||
// ~2-frame buffer at the battle's game speed
|
||||
// view.highlightTiles(tiles, style) [[r,c]] | [{row,col}]; style 'legal'|'illegal'|'range'|'rangeStand'|
|
||||
@@ -25,9 +31,10 @@
|
||||
// there): every "which unit is under the pointer" — prep press / click / detail / drag start / hover (pieceAt), battle
|
||||
// clicks and hover (battleUnitAt), the pen (penUnitAt) — is render/pick.js over the tile under the pointer (groundTile:
|
||||
// raised tops first); battle enemies, which walk between tiles, by their ground position (flying ones by their drawn
|
||||
// body). A dragged piece is held under the pointer — a unit with its drawn feet DRAG_HOLD_TILES below it (the pointer
|
||||
// on its body), an item plate centred on it — and drops on the tile under the pointer (render/drag.js); an item
|
||||
// dropped on a unit's tile equips that unit.
|
||||
// body; a huge boss also anywhere on its hit area — data/enemies.json `hitArea`, user playtest #5). A dragged piece
|
||||
// is held under the pointer — a unit with its drawn feet DRAG_HOLD_TILES below it (the pointer on its body), an item
|
||||
// plate centred on it — and drops on the tile under the pointer (render/drag.js); an item dropped on a unit's tile
|
||||
// equips that unit.
|
||||
// Direction step (ui/facingWheel.js, research 09 §1.2):
|
||||
// view.tileScreen(row, col) → { x, y, s, poly: [[x,y]×4] } (client px: tile-top centre, px per tile, corners) | null
|
||||
// view.holdPiece(uid, {row,col} | null) keep a dropped prep piece standing on a tile while its direction is
|
||||
@@ -93,7 +100,7 @@ import { BoardScene } from './board3d/scene.js';
|
||||
import { AREAS, areaFor, unionAreas } from './board3d/layout.js';
|
||||
import { layoutPen, penSignature } from './pen.js';
|
||||
import { IDENTITY, bossPrepField, tilesToDisp } from './prepfield.js';
|
||||
import { pickOnTile, pickBattle } from './pick.js';
|
||||
import { pickOnTile, pickBattle, hitRectAt } from './pick.js';
|
||||
|
||||
const VENDOR = { pixi: '/vendor/pixi.min.js', spine: '/vendor/pixi-spine.js' };
|
||||
const PIECE_DIRS = new Set(['UP', 'RIGHT', 'DOWN', 'LEFT']);
|
||||
@@ -121,7 +128,7 @@ export const DRAG_HOLD_TILES = 0.45;
|
||||
* 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) {
|
||||
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;
|
||||
@@ -129,7 +136,10 @@ function pickUnitOf(v, walks = false) {
|
||||
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) };
|
||||
return { tile, x: v.x, y: v.y, fly: walks && !!v.flying, body, depth: v.root && !v.root.destroyed ? v.root.zIndex : 0, ref: v };
|
||||
// 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;
|
||||
@@ -176,13 +186,15 @@ export function viewKind(kind, opts) {
|
||||
|
||||
/** 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 <= 12)),
|
||||
unite: Object.freeze(AREAS.unite.filter((a) => a.r1 <= 12)),
|
||||
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 13–18) only for the 'pen' camera — the prep,
|
||||
* battle and 联防 cameras show the field alone (user playtest #2 item 6); the boss kinds build the boss field.
|
||||
* 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;
|
||||
@@ -200,15 +212,32 @@ export function bandFor(kind) {
|
||||
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';
|
||||
|
||||
/**
|
||||
* '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).
|
||||
* not a knock-out), nor for an operator entering the battle knocked out (FORCED_EXIT).
|
||||
*/
|
||||
export const showsDeathFx = (info, consumed = false) => !consumed && info?.kind !== 'device';
|
||||
export const showsDeathFx = (info, consumed = false, reason = null) => !consumed && reason !== FORCED_EXIT && info?.kind !== 'device';
|
||||
|
||||
/** '2d' | '3d' | 'auto' board preference: `?board=` in the page URL (dev), else the view option. */
|
||||
export function boardPreference(opt) {
|
||||
@@ -307,7 +336,8 @@ export function releaseGl(renderer) {
|
||||
* Create the battlefield view inside `host` (an element sized by CSS; the canvas fills it).
|
||||
* @param {HTMLElement} host
|
||||
* @param {{ data?: any, assets?: any, audio?: any, settings?: { damageNumbers?: boolean, quality?: string },
|
||||
* padding?: object|((kind:string, size:{width:number,height:number}) => object) }} [opts]
|
||||
* padding?: object|((kind:string, size:{width:number,height:number}) => object),
|
||||
* hud?: {top:number,bottom:number}|((kind:'prep'|'bossPrep', size:{width:number,height:number}) => {top:number,bottom:number}|null) }} [opts]
|
||||
*/
|
||||
export async function createFieldView(host, options = {}) {
|
||||
if (!host || typeof host.appendChild !== 'function') throw new TypeError('createFieldView: host element required');
|
||||
@@ -392,7 +422,7 @@ export async function createFieldView(host, options = {}) {
|
||||
let destroyed = false;
|
||||
let mode = 'idle'; // 'idle' | 'prep' | 'battle'
|
||||
let stageRec = null;
|
||||
let cam = presetCamera('prep', { width: s0.width, height: s0.height, padding: defaultPadding('prep', s0) });
|
||||
let cam = presetCamera('prep', { width: s0.width, height: s0.height, padding: defaultPadding('prep', s0) }, { hud: hudBands('prep', s0) });
|
||||
let camFrom = null, camTo = null, camT0 = 0, camKind = 'prep', camOpts = {}, camMs = CAMERA_MS;
|
||||
let pendingView = null; // tile band/focus to apply when the camera transition ends
|
||||
const views = new Map(); // key → view (prep: 'p:'+uid; battle: unit id)
|
||||
@@ -463,7 +493,7 @@ export async function createFieldView(host, options = {}) {
|
||||
ctx.createBox = () => switchableBox({ board: () => board3d, pixi: () => tiles.createBox() });
|
||||
const impostors = new ImpostorAtlas(app.renderer);
|
||||
ctx.impostors = impostors;
|
||||
tiles.setView(bandFor('prep'), camRect(), fieldFor('prep'));
|
||||
tiles.setView(bandFor('prep'), camRect(), fieldRows('prep'));
|
||||
// the real board art of the local client (optional): wait briefly so the first frame already uses it; a late
|
||||
// arrival swaps the atlas in place
|
||||
const artPromise = loadBoardArt(assets).then((art) => {
|
||||
@@ -575,6 +605,14 @@ export async function createFieldView(host, options = {}) {
|
||||
return { top: h * 0.13, bottom: h * 0.12, left: Math.min(170, w * 0.09), right: w * 0.05 };
|
||||
}
|
||||
|
||||
// the HUD bands the prep cameras keep the bench / field clear of (projection.js clearHud; user playtest #5 item 9):
|
||||
// `opts.hud` = (kind, size) => { top, bottom } | null, or a fixed object; none → the plain official framing
|
||||
function hudBands(kind, sz) {
|
||||
if (kind !== 'prep' && kind !== 'bossPrep') return null;
|
||||
if (typeof opts.hud === 'function') { try { return opts.hud(kind, sz) || null; } catch { return null; } }
|
||||
return opts.hud && typeof opts.hud === 'object' ? opts.hud : null;
|
||||
}
|
||||
|
||||
function camRect() {
|
||||
const k = viewKind(camKind, camOpts);
|
||||
if (k === 'pen') return { r0: 14, r1: 18, c0: 7, c1: 13 };
|
||||
@@ -590,21 +628,21 @@ export async function createFieldView(host, options = {}) {
|
||||
let rect = o.rect ? normRect(o.rect) : null;
|
||||
if (k === 'prep') rect = rect ? { ...rect, r0: Math.min(rect.r0, GEO.HAND_ROW) } : null;
|
||||
// official configBlackBoard framing (render/projection.js presetCamera); the padding only matters for the
|
||||
// fitted fallback (custom rects, portrait viewports)
|
||||
// fitted fallback (custom rects, portrait viewports); a prep camera keeps the bench and the field clear of the HUD
|
||||
const vk = viewKind(kind, o); // (a 'prep' camera on the boss rows = the Final Assault prep)
|
||||
return presetCamera(k, { width: sz.width, height: sz.height, padding: o.padding || defaultPadding(k, sz) }, {
|
||||
rect, side: o.side, half: !!o.half, shop: o.shop, fit: !!o.fit, config: stageRec?.config || null,
|
||||
hud: hudBands(vk, sz),
|
||||
});
|
||||
}
|
||||
|
||||
// rows drawn per camera kind (module `bandFor`): the normal/unite/prep fields live between the separator walls
|
||||
// (rows 6–13); the enemy preview pen behind them (rows 14–18) only for the pen camera; the boss field below (0–6)
|
||||
function fieldFor(kind) { return kind === 'boss' || kind === 'hidden' || kind === 'bossPrep' ? [0, 6] : kind === 'pen' ? [6, 18] : [6, 13]; }
|
||||
// rows drawn per camera kind: module `bandFor`; the active field rows: module `fieldRows`
|
||||
|
||||
function setCamera(kind, options) {
|
||||
if (destroyed) return false;
|
||||
let o = options && typeof options === 'object' ? options : {};
|
||||
const prevView = viewKind(camKind, camOpts);
|
||||
const prevBand = bandFor(prevView), prevField = fieldFor(prevView);
|
||||
const prevBand = bandFor(prevView), prevField = fieldRows(prevView);
|
||||
const nextKind = typeof kind === 'string' ? kind : 'normal';
|
||||
// the enemy pen is a detour of the prep camera: remember where it came from; the same kind asked again without
|
||||
// framing options goes back to exactly that camera (Final Assault half, shop state…)
|
||||
@@ -622,7 +660,7 @@ export async function createFieldView(host, options = {}) {
|
||||
else if (vk === 'bossPrep') setPrepField(bossPrepField(camOpts.side === 'R' ? 'R' : 'L'));
|
||||
const target = targetCamera(camKind, camOpts);
|
||||
const band = bandFor(vk);
|
||||
const field = fieldFor(vk);
|
||||
const field = fieldRows(vk);
|
||||
const focus = camRect();
|
||||
board3d?.setFocus(focus);
|
||||
camMs = Number.isFinite(o.ms) && o.ms >= 0 ? o.ms : (vk === 'pen' || prevView === 'pen' ? PEN_CAMERA_MS : CAMERA_MS);
|
||||
@@ -1109,7 +1147,9 @@ export async function createFieldView(host, options = {}) {
|
||||
const units = [];
|
||||
for (const v of views.values()) {
|
||||
if (!v.info || v.info.kind === 'device') continue;
|
||||
const u = pickUnitOf(v, v.info.side !== 'ally');
|
||||
const enemy = v.info.side !== 'ally';
|
||||
// a huge boss: its hit area (data/enemies.json `hitArea`, the sim's hit rectangle) is pickable too
|
||||
const u = pickUnitOf(v, enemy, enemy ? data.enemy(v.info.defId)?.hitArea ?? null : null);
|
||||
if (u) units.push(u);
|
||||
}
|
||||
const hit = pickBattle(units, groundTile(x, y), x, y);
|
||||
@@ -1323,7 +1363,7 @@ export async function createFieldView(host, options = {}) {
|
||||
case 'die': {
|
||||
const v = views.get(e[1]);
|
||||
const used = consumedIds.delete(e[1]);
|
||||
if (v && v.alive) { v.die(); if (showsDeathFx(v.info, used)) fx.death(v); }
|
||||
if (v && v.alive) { v.die(e[2] === FORCED_EXIT); if (showsDeathFx(v.info, used, e[2])) fx.death(v); }
|
||||
break;
|
||||
}
|
||||
case 'leak': {
|
||||
@@ -1342,6 +1382,13 @@ export async function createFieldView(host, options = {}) {
|
||||
consumedIds.add(e[4].id);
|
||||
if (consumedIds.size > 200) consumedIds.delete(consumedIds.values().next().value);
|
||||
}
|
||||
// an enemy's mode change (掠海漂移体 → 爬行模式, user playtest #5 item 1): its view switches clip set
|
||||
// (UnitView.setForm); the info keeps it for a view built later
|
||||
if (e[1] === 'phase' && e[4] && typeof e[4] === 'object' && e[4].id != null) {
|
||||
const inf = infos.get(e[4].id);
|
||||
if (inf) inf.form = typeof e[4].kind === 'string' ? e[4].kind : null;
|
||||
views.get(e[4].id)?.setForm?.(e[4].kind);
|
||||
}
|
||||
fx.simFx(e[1], Number(e[2]), Number(e[3]), e[4]);
|
||||
break;
|
||||
case 'layer': {
|
||||
|
||||
@@ -2,9 +2,12 @@
|
||||
// autochess board at runtime (DESIGN §15): pure JS, no three import (tested in Node; render/board3d/scene.js turns
|
||||
// the buckets into merged BufferGeometries).
|
||||
//
|
||||
// const board = buildBoard(stage, { uv: resolveUvTable(tiles), crate?: objMesh })
|
||||
// const board = buildBoard(stage, { uv: resolveUvTable(tiles), crate?: objMesh, area?: AREAS.normal })
|
||||
// → { grid, buckets: { board, decal, pipe }, gates: [{ r, c, kind: 'start'|'end' }], devices: [...],
|
||||
// terrain: { water, mire, smog, infection: [[r,c]…] }, edges: [...cyan field edge strips], bounds, stats }
|
||||
// `devices` = the active stage devices standing on a built tile — the separator row 13 included (act2 m01's blowers at
|
||||
// the top edge of the normal / 联防 field); the boss field's (rows ≤ 6: its row-6 blowers) only when the boss field is
|
||||
// built — user playtest #5 item 6 (stageDevices).
|
||||
//
|
||||
// World space = render/projection.js: x = col, y = row, z = height (up); tile (r,c) covers [c±½]×[r±½].
|
||||
// Heights follow render/style.js TILE_H (the Pixi layers stand units on the same tops: tiles.js heightAt).
|
||||
@@ -45,10 +48,16 @@ const CONTENT_OF = (g) => g !== '#' && g !== 'X';
|
||||
* ((6,11),(12,20)), leftBoss ((0,0),(6,10)), rightBoss ((0,11),(6,20)); enemy_place_rect ((14,7),(18,13)) + the
|
||||
* separator in front of it). The official prep / battle view shows only the player's own field and the preview pen
|
||||
* (the island ends under the bench row); 联防 joins both normal halves; the Final Assault builds the boss field.
|
||||
* The normal / 联防 field rects also take the separator row 13 above the field (like leftBoss takes row 6 above the
|
||||
* boss field): the devices standing on it act on the field — act2 m01's 源石流发生装置 #001/#002 (#101/#102) at (13, 5 /
|
||||
* 9 / 13 / 17) blow DOWN into rows 12–10 — and the official normal rounds show those machines at the field's top edge
|
||||
* (user playtest #5 item 6: the wind lanes worked but the blowers were missing; the 2D board always drew row 13). The
|
||||
* row-6 wall under the bench is built in these views too (in leftNormal), without the boss field's devices on it
|
||||
* (stageDevices).
|
||||
*/
|
||||
export const AREAS = Object.freeze({
|
||||
normal: Object.freeze([Object.freeze({ r0: 6, r1: 12, c0: 0, c1: 10 }), Object.freeze({ r0: 13, r1: 18, c0: 6, c1: 14 })]),
|
||||
unite: Object.freeze([Object.freeze({ r0: 6, r1: 12, c0: 0, c1: 20 }), Object.freeze({ r0: 13, r1: 18, c0: 6, c1: 14 })]),
|
||||
normal: Object.freeze([Object.freeze({ r0: 6, r1: 13, c0: 0, c1: 10 }), Object.freeze({ r0: 13, r1: 18, c0: 6, c1: 14 })]),
|
||||
unite: Object.freeze([Object.freeze({ r0: 6, r1: 13, c0: 0, c1: 20 }), Object.freeze({ r0: 13, r1: 18, c0: 6, c1: 14 })]),
|
||||
boss: Object.freeze([Object.freeze({ r0: 0, r1: 6, c0: 0, c1: 20 })]),
|
||||
all: Object.freeze([Object.freeze({ r0: 0, r1: 18, c0: 0, c1: 20 })]),
|
||||
});
|
||||
@@ -431,7 +440,7 @@ export function buildBoard(stage, opts = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
const devices = opts.devices === false ? [] : stageDevices(stage, grid);
|
||||
const devices = opts.devices === false ? [] : stageDevices(stage, grid, opts.area || null);
|
||||
for (const d of devices) {
|
||||
if (d.kind === 'platform' || d.kind === 'sealedFloor' || d.kind === 'waterPlatform') slabDevice(board, decal, UVT, d, grid);
|
||||
}
|
||||
@@ -478,9 +487,23 @@ export function tube(g, a, b, rad, seg = 8, col = [1, 1, 1]) {
|
||||
|
||||
const DEVICE_KINDS = new Set(['crate', 'platform', 'mound', 'blower', 'turret', 'waterPlatform', 'bush', 'sealedFloor']);
|
||||
|
||||
/** Active stage devices standing on drawn tiles: `{ kind, r, c, dir, z0, key, alias }`. */
|
||||
export function stageDevices(stage, grid) {
|
||||
/** The boss field's top wall — the row under the normal field's bench (rows ≤ this one are the boss field's). */
|
||||
export const BOSS_WALL_ROW = 6;
|
||||
|
||||
/**
|
||||
* Active stage devices standing on drawn tiles: `{ kind, r, c, dir, z0, key, alias, rangeTiles }` — every visible
|
||||
* predefined token of the level is a scene object, a device on the separator row 13 included (act2 m01's 源石流发生装置
|
||||
* #001 / #002 blow DOWN into the normal field: the official normal rounds show them at its top edge although row 13
|
||||
* lies outside every configBlackBoard area rect, user playtest #5 item 6). The boss field's devices (rows ≤ 6, its top
|
||||
* wall under the bench included: act2 m01's #201 / #202 blow DOWN into rows 5–3) are drawn only when `area` builds the
|
||||
* boss field (a rect below row 6): the official normal cameras never show that wall — it lies under the shop bar in
|
||||
* prep and off-screen in battle and in the shop-collapsed prep view — while the remake's prep view showed the boss
|
||||
* round's machines under the bench ("反而在关底吹风机的贴图又出现了"). `area` null = the whole map. The 2D board
|
||||
* (render/tiles.js _stageDevices) follows the same rule.
|
||||
*/
|
||||
export function stageDevices(stage, grid, area = null) {
|
||||
const list = Array.isArray(stage?.devices) ? stage.devices : [];
|
||||
const bossShown = !area || area.some((a) => a.r0 < BOSS_WALL_ROW);
|
||||
const out = [];
|
||||
for (const d of list) {
|
||||
if (!d || !Array.isArray(d.pos) || !Number.isInteger(d.pos[0]) || !Number.isInteger(d.pos[1])) continue;
|
||||
@@ -488,6 +511,7 @@ export function stageDevices(stage, grid) {
|
||||
const active = typeof d.active === 'boolean' ? d.active : !d.hidden;
|
||||
if (!active) continue;
|
||||
const [r, c] = d.pos;
|
||||
if (r <= BOSS_WALL_ROW && !bossShown) continue;
|
||||
const t = grid?.[r]?.[c];
|
||||
if (!t || !t.drawn) continue;
|
||||
out.push({ kind: d.role, r, c, dir: ['UP', 'DOWN', 'LEFT', 'RIGHT'].includes(d.dir) ? d.dir : 'UP', z0: t.h, key: d.key || null, alias: d.alias || null, rangeTiles: Array.isArray(d.rangeTiles) ? d.rangeTiles : null });
|
||||
|
||||
@@ -12,12 +12,36 @@
|
||||
// pointer's point on the ground, or its drawn body (on screen: the upright line from its feet to its head, in its px
|
||||
// per tile), whichever is nearer, so a press on a tall enemy's / a boss's body picks it too; a flying enemy (drawn in
|
||||
// the air, over whatever tile it crosses) by its drawn body alone.
|
||||
// * a huge boss (巨型单位, data `hitArea` — the sim's hit rectangle, server/sim/body.js; user playtest #5 item 10) is
|
||||
// also picked by a press anywhere on its drawn body — its hit area on the ground (the 5 × 3 tiles under the model)
|
||||
// or the upright box from its feet to its head as wide as that area — counted as AREA_PICK tile away, so an ally
|
||||
// standing on the pressed tile, or a regular enemy near the pointer, still wins.
|
||||
// * an ally's tile and an enemy both qualify → the nearer one (distance in tiles to the pointer's point), ties → the
|
||||
// ally, then the front-most.
|
||||
// (Replaces the drawn-body shapes and the 1-px render probe of v2.2, DESIGN §17.2: the player aims at the grid.)
|
||||
|
||||
/** Battle enemies are picked within this many tiles of the pointer's ground point or of their drawn body on screen. */
|
||||
export const ENEMY_REACH = 0.6;
|
||||
/**
|
||||
* A press on a huge boss's hit area (not near its feet / drawn body line) counts as this far — farther than any ally
|
||||
* on the pressed tile (≤ √0.5) or any enemy within ENEMY_REACH, so those win [ASSUMED].
|
||||
*/
|
||||
export const AREA_PICK = 1;
|
||||
|
||||
/**
|
||||
* World rectangle (tiles: x = col, y = row) of a hit area `{ w, h, dx, dy }` for a unit at (x, y) — the same rectangle
|
||||
* as server/sim/body.js hitRect: `w` along the columns × `h` along the rows, centred `dx` columns right / `dy` rows up.
|
||||
* @returns {{ x0: number, x1: number, y0: number, y1: number } | null}
|
||||
*/
|
||||
export function hitRectAt(x, y, a) {
|
||||
if (!a || !(a.w > 0) || !(a.h > 0) || !Number.isFinite(x) || !Number.isFinite(y)) return null;
|
||||
const cx = x + (Number(a.dx) || 0), cy = y + (Number(a.dy) || 0);
|
||||
return { x0: cx - a.w / 2, x1: cx + a.w / 2, y0: cy - a.h / 2, y1: cy + a.h / 2 };
|
||||
}
|
||||
const inArea = (A, g) => !!A && !!g && g.x >= A.x0 && g.x <= A.x1 && g.y >= A.y0 && g.y <= A.y1;
|
||||
/** Screen point inside a huge body's upright box: `hw` tiles (its px per tile) either side of its x, feet up to head. */
|
||||
const inBox = (b, px, py) => !!b && b.hw > 0 && b.s > 0 && Math.abs(px - b.x) <= b.hw * b.s
|
||||
&& py >= Math.min(b.top, b.feet) && py <= Math.max(b.top, b.feet);
|
||||
|
||||
const EPS = 1e-9;
|
||||
const isTile = (t) => !!t && Number.isInteger(t.row) && Number.isInteger(t.col);
|
||||
@@ -39,10 +63,13 @@ function bodyDist(b, px, py) {
|
||||
* @typedef {{ row: number, col: number, x?: number, y?: number }} PickGround the tile under the pointer and the pointer's
|
||||
* point on its top (world tiles: x = col, y = row), or null off the grid
|
||||
* @typedef {{ tile?: { row: number, col: number } | null, x?: number, y?: number, depth?: number, fly?: boolean,
|
||||
* body?: { x: number, top: number, feet: number, s: number } | null }} PickUnit
|
||||
* body?: { x: number, top: number, feet: number, s: number, hw?: number } | null,
|
||||
* area?: { x0: number, x1: number, y0: number, y1: number } | null }} PickUnit
|
||||
* `tile`: the tile it stands on (null: it walks — a battle enemy); `x`, `y`: its ground position (world tiles);
|
||||
* `body`: an enemy's drawn body on screen (px: x, head `top`, `feet`; `s` px per tile there); `fly`: in the air — its
|
||||
* ground position does not count; `depth`: draw order (larger = in front)
|
||||
* `body`: an enemy's drawn body on screen (px: x, head `top`, `feet`; `s` px per tile there; `hw`: a huge boss's half
|
||||
* width in tiles); `fly`: in the air — its
|
||||
* ground position does not count; `area`: a huge boss's hit rectangle on the ground (world tiles, hitRectAt);
|
||||
* `depth`: draw order (larger = in front)
|
||||
*/
|
||||
|
||||
/**
|
||||
@@ -87,7 +114,10 @@ export function pickBattle(units, ground, px, py) {
|
||||
else {
|
||||
d = bodyDist(u.body, px, py);
|
||||
if (!u.fly && onGround && Number.isFinite(u.x) && Number.isFinite(u.y)) d = Math.min(d, Math.hypot(u.x - ground.x, u.y - ground.y));
|
||||
if (!(d <= ENEMY_REACH)) continue;
|
||||
if (!(d <= ENEMY_REACH)) {
|
||||
if (u.fly || !(inBox(u.body, px, py) || (onGround && inArea(u.area, ground)))) continue;
|
||||
d = AREA_PICK;
|
||||
}
|
||||
}
|
||||
if (d < Infinity && better(u, d)) { best = u; bd = d; }
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
// The view keeps every public coordinate in BOARD space (the server's, g.move targets, canPlace, highlightTiles,
|
||||
// tileScreen, holdPiece, setPieceDir): only what is drawn and picked goes through this transform. Pure functions.
|
||||
|
||||
/** Board row → display (boss-field) row: row − 7 (server/match/board.js BOARD_ROWS_ABOVE_BOSS is +7, the inverse). */
|
||||
export const BOSS_ROW_SHIFT = -7;
|
||||
export const MAX_COL = 20;
|
||||
|
||||
|
||||
@@ -25,6 +25,18 @@
|
||||
// an official shop-view screenshot gave col 5.17, row 2.40, height 11.57 at 30°/40°.) Screens narrower than 16:9
|
||||
// back the camera off like the client (up to 4:3: Δ = (0, −1.4, −2.8)·t). `presetCamera` returns these official
|
||||
// cameras; `fitCamera` (frame any rect inside a padded viewport) remains for custom rects and portrait viewports.
|
||||
//
|
||||
// HUD clearance of the prep views (user playtest #5 item 9, `clearHud`): the official shop camera puts the bench's
|
||||
// near edge right on the shop bar's top and the field's back row right under the bond strip at 16:9 (both measured
|
||||
// flush at 1920×1080). The remake's HUD is sized in rem with a 40 px floor (css/theme.css), so on screens shorter
|
||||
// than 432 CSS px (phones in landscape) it is taller than the official one relative to the screen and the shop bar
|
||||
// covered the lower part of bench pads 3–9 (29 % at 844×390, 46 % at the user's Android = 756×366, 51 % at 800×360;
|
||||
// the Final Assault prep alike). With `opts.hud` (the px bands the HUD covers along the top and bottom edge) the
|
||||
// official prep camera is kept whenever the band from the bench's near edge to the field's back row fits between them
|
||||
// — every desktop viewport measured (the Final Assault bench, 1.2 px lower, is nudged up < 3 px at 16:9) — and
|
||||
// otherwise panned and, only when the band is taller than the space, zoomed out (844×390: ×0.90, 756×366: ×0.84,
|
||||
// 800×360: ×0.83): a 2D pan / zoom of the image (principal point and focal length), so the perspective and the
|
||||
// three.js camera stay the official ones.
|
||||
|
||||
export const DEG = Math.PI / 180;
|
||||
|
||||
@@ -57,15 +69,22 @@ export const OFFICIAL_PARAMS = Object.freeze({
|
||||
/** Default optics of the fitted (non-official) cameras: the official pitch; dist is a moderate lens. */
|
||||
export const DEFAULT_OPTICS = Object.freeze({ tilt: OFFICIAL.pitch, dist: 16 });
|
||||
|
||||
/** Tile-top heights (tiles) of the prep band's edges (render/style.js TILE_H): bench pads, raised high ground. */
|
||||
const KEEP_Z_BENCH = 0.16, KEEP_Z_HIGH = 0.42;
|
||||
|
||||
/**
|
||||
* Rects framed by each camera kind (inclusive tile bounds), framing parameters of the fitted fallback, and the
|
||||
* official configBlackBoard param(s) of the kind (`official`: side → param key; `half` for the ‹ › half views).
|
||||
* `keep` (prep views): the band `clearHud` keeps clear of the HUD — `near` = world row (y) of the bench's near edge
|
||||
* (hand row 7 / boss row 0) with the pads' tops at `zNear`, `far` = the field's back edge (row 12 / boss row 5) with
|
||||
* its raised tops at `zFar`.
|
||||
*/
|
||||
export const CAMERA_PRESETS = Object.freeze({
|
||||
prep: Object.freeze({
|
||||
rect: Object.freeze({ r0: 7, r1: 12, c0: 0, c1: 10 }), margin: 0.35, headroom: 1.25, tilt: 30, dist: 13,
|
||||
official: Object.freeze({ L: 'left_shop_camera_param', R: 'left_shop_camera_param' }),
|
||||
officialNoShop: Object.freeze({ L: 'left_prepare_camera_param', R: 'left_prepare_camera_param' }),
|
||||
keep: Object.freeze({ near: 6.5, zNear: KEEP_Z_BENCH, far: 12.5, zFar: KEEP_Z_HIGH }),
|
||||
}),
|
||||
normal: Object.freeze({
|
||||
rect: Object.freeze({ r0: 9, r1: 12, c0: 0, c1: 10 }), margin: 0.45, headroom: 1.35, tilt: 30, dist: 13,
|
||||
@@ -85,6 +104,7 @@ export const CAMERA_PRESETS = Object.freeze({
|
||||
rect: Object.freeze({ r0: 0, r1: 5, c0: 0, c1: 10 }), margin: 0.35, headroom: 1.25, tilt: 30, dist: 13,
|
||||
official: Object.freeze({ L: 'left_boss_shop_camera_param', R: 'right_boss_shop_camera_param' }),
|
||||
officialNoShop: Object.freeze({ L: 'left_boss_prepare_camera_param', R: 'right_boss_prepare_camera_param' }),
|
||||
keep: Object.freeze({ near: -0.5, zNear: KEEP_Z_BENCH, far: 5.5, zFar: KEEP_Z_HIGH }),
|
||||
}),
|
||||
pen: Object.freeze({
|
||||
rect: Object.freeze({ r0: 14, r1: 18, c0: 7, c1: 13 }), margin: 0.3, headroom: 1.2, tilt: 30, dist: 11,
|
||||
@@ -346,6 +366,47 @@ export function officialCamera(param, viewport, opts) {
|
||||
return new Camera({ tx: cx, ty: cy + height * Math.tan(t), tz: 0, tilt: pitch, dist, scale: focal / dist, cx: W / 2, cy: H / 2 });
|
||||
}
|
||||
|
||||
/** Overlap (px) between the kept band and a HUD band that still counts as clear (float noise). */
|
||||
const HUD_TOLERANCE = 0.5;
|
||||
/** Gap (px) an adjusted camera leaves between the kept band and the HUD when there is room (no seam under an edge). */
|
||||
const HUD_GAP = 1;
|
||||
|
||||
/**
|
||||
* Keep a band of the board clear of the HUD (see the header). `hud` = { top, bottom }: CSS px the HUD covers along
|
||||
* the viewport's top edge (top bar + bond strip) and bottom edge (the shop bar); `keep` = { near, zNear?, far, zFar? }:
|
||||
* world rows (y) of the band's near and far edge, at the heights zNear / zFar. Returns `cam` itself when the band
|
||||
* fits between the HUD bands; else a new camera whose image is panned vertically by the smallest shift that clears
|
||||
* the HUD (by up to HUD_GAP where there is room) or — only when the band is taller than the space between — zoomed
|
||||
* out about the viewport's centre so it fills that space less HUD_GAP at both ends. The screen y of a horizontal
|
||||
* world line is the same at every x (the camera has no roll), so one point per edge decides.
|
||||
* @param {Camera} cam
|
||||
* @param {{ top?: number, bottom?: number }|null} hud
|
||||
* @param {{ near: number, zNear?: number, far: number, zFar?: number }|null} keep
|
||||
* @param {{ width: number, height: number }} viewport CSS px
|
||||
* @returns {Camera}
|
||||
*/
|
||||
export function clearHud(cam, hud, keep, viewport) {
|
||||
if (!cam || !hud || typeof hud !== 'object' || !keep) return cam;
|
||||
const W = Math.max(1, finite(viewport?.width, 1)), H = Math.max(1, finite(viewport?.height, 1));
|
||||
const top = Math.max(0, finite(hud.top, 0)), bottom = H - Math.max(0, finite(hud.bottom, 0));
|
||||
if (!(bottom - top > 16)) return cam; // no usable space: keep the official framing
|
||||
const yNear = cam.project(cam.tx, keep.near, finite(keep.zNear, 0)).y;
|
||||
const yFar = cam.project(cam.tx, keep.far, finite(keep.zFar, 0)).y;
|
||||
if (!Number.isFinite(yNear) || !Number.isFinite(yFar) || !(yNear > yFar)) return cam;
|
||||
if (yNear <= bottom + HUD_TOLERANCE && yFar >= top - HUD_TOLERANCE) return cam;
|
||||
const spare = bottom - top - (yNear - yFar);
|
||||
const gap = spare >= 0 ? Math.min(HUD_GAP, spare / 2) : HUD_GAP;
|
||||
const lo = top + gap, hi = bottom - gap;
|
||||
const f = spare >= 0 ? 1 : (hi - lo) / (yNear - yFar);
|
||||
const out = cam.clone();
|
||||
// image transform x' = xc + f·(x − xc), y' = lo + f·(y − yFar) (zoom) or y' = y + dy (pan, the smallest shift
|
||||
// that puts [yFar, yNear] inside [lo, hi]): scale the focal length by f and move the principal point accordingly
|
||||
out.scale = cam.scale * f;
|
||||
out.cx = W / 2 + f * (cam.cx - W / 2);
|
||||
out.cy = f < 1 ? lo + f * (cam.cy - yFar) : cam.cy + Math.min(Math.max(0, lo - yFar), hi - yNear);
|
||||
return out.update();
|
||||
}
|
||||
|
||||
const sameRect = (a, b) => !!a && !!b && a.r0 === b.r0 && a.r1 === b.r1 && a.c0 === b.c0 && a.c1 === b.c1;
|
||||
|
||||
/**
|
||||
@@ -354,6 +415,8 @@ const sameRect = (a, b) => !!a && !!b && a.r0 === b.r0 && a.r1 === b.r1 && a.c0
|
||||
* viewport is narrower than 4:3 (portrait), `opts.fit` is set, or `opts.rect` is a custom rect — then the rect is
|
||||
* fitted into the padded viewport (`fitCamera`). `opts.side` 'L'|'R' picks the side's param (right boss half);
|
||||
* `opts.half` frames only that half of a unite/boss field; `opts.shop === false` uses the shop-collapsed prep camera.
|
||||
* `opts.hud` = { top, bottom } (CSS px of HUD along the top / bottom edge): the official prep / Final Assault prep
|
||||
* camera keeps the bench and the field clear of it (`clearHud`, the preset's `keep` band).
|
||||
*/
|
||||
export function presetCamera(kind, viewport, options) {
|
||||
const opts = options && typeof options === 'object' ? options : {};
|
||||
@@ -374,7 +437,8 @@ export function presetCamera(kind, viewport, options) {
|
||||
const key = table[side];
|
||||
const cfg = opts.config && typeof opts.config === 'object' ? opts.config : null;
|
||||
const param = (cfg && parseCameraParam(cfg[key])) || parseCameraParam(OFFICIAL_PARAMS[key]);
|
||||
return officialCamera(param, { width: W, height: H }, opts);
|
||||
const cam = officialCamera(param, { width: W, height: H }, opts);
|
||||
return preset.keep && opts.hud ? clearHud(cam, opts.hud, preset.keep, { width: W, height: H }) : cam;
|
||||
}
|
||||
if (!rect) {
|
||||
rect = { ...preset.rect };
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
// deploy() 'Start' once, then base
|
||||
// die() die clip once (callers fade out afterwards)
|
||||
// stunned (setBase('stun')) stun clip, or the current track frozen at timeScale 0
|
||||
// setForm(roles, change) another clip set of the skeleton (an enemy's mode), after a change clip
|
||||
// update(dt) advances the skeleton (autoUpdate is off: one clock for everything)
|
||||
// Attack mode lasts until ~1.4 attack intervals without a new attack, then the end clip (if any) and base.
|
||||
|
||||
@@ -114,6 +115,25 @@ export class SpineActor {
|
||||
this.roles = clip ? { ...anims, skill: clip } : anims;
|
||||
}
|
||||
|
||||
/**
|
||||
* Another clip set of the same skeleton — an enemy's mode (render/units.js FORMS: 掠海漂移体's 爬行模式 plays its *_02
|
||||
* clips): `roles` override the manifest roles (null = back to them); `change` = a transition clip played once first
|
||||
* (also while stunned: the pose it ends in is the one a stun then holds).
|
||||
*/
|
||||
setForm(roles, change = null) {
|
||||
const anims = this.entry?.anims || {};
|
||||
this.roles = roles ? { ...anims, ...roles } : anims;
|
||||
if (this.dead) return;
|
||||
if (change && this.has(change)) {
|
||||
this.stunWanted = this.mode === 'stun';
|
||||
this.frozen = false;
|
||||
this.mode = 'change';
|
||||
this._play(change, false, { mix: 0.08 });
|
||||
this.changeUntil = this.clock + this.dur(change);
|
||||
} else if (this.mode === 'base') this._play(this._baseName(), true);
|
||||
else if (this.mode === 'stun' && this.has(this.roles.stun?.loop)) this._play(this.roles.stun.loop, true);
|
||||
}
|
||||
|
||||
has(name) { return !!name && this.names.has(name); }
|
||||
dur(name) { const d = this.durations[name]; return typeof d === 'number' && d > 0 ? d : this._durFromData(name); }
|
||||
|
||||
@@ -159,6 +179,8 @@ export class SpineActor {
|
||||
setBase(base) {
|
||||
if (this.dead) return;
|
||||
const b = base === 'move' || base === 'stun' ? base : 'idle';
|
||||
// a mode change clip plays out first; the resting state it lands in is remembered
|
||||
if (this.mode === 'change') { this.stunWanted = b === 'stun'; if (b !== 'stun') this.base = b; return; }
|
||||
if (b === 'stun') { this._enterStun(); return; }
|
||||
if (this.mode === 'stun') this._leaveStun();
|
||||
if (b === this.base && this.mode !== 'stun') return;
|
||||
@@ -340,6 +362,12 @@ export class SpineActor {
|
||||
case 'deploy':
|
||||
if (this.clock >= this.deployUntil) { this.mode = 'base'; this._play(this._baseName(), true); }
|
||||
break;
|
||||
case 'change':
|
||||
if (this.clock >= this.changeUntil) {
|
||||
this.mode = 'base';
|
||||
if (this.stunWanted) { this.stunWanted = false; this._enterStun(); } else this._play(this._baseName(), true);
|
||||
}
|
||||
break;
|
||||
default: break;
|
||||
}
|
||||
if (!this.frozen) {
|
||||
|
||||
@@ -45,6 +45,8 @@ export const ROW_KEY = Object.freeze({ blocks: 0, devices: 0.01, surface: 0.02 }
|
||||
export const boxDepthKey = (cam, row) => -cam.depthOf(0, row + 0.45, 0) * 100;
|
||||
|
||||
const ROWS = 19, COLS = 21;
|
||||
/** The boss field's top wall under the normal field's bench (= board3d/layout.js BOSS_WALL_ROW). */
|
||||
const BOSS_WALL_ROW = 6;
|
||||
const PLAYABLE_DIST = 2; // margin tiles farther than this from a playable tile are not drawn
|
||||
const CLIFF_DEPTH = 0.62; // how far the island's edge faces go down to the ground plane (tiles)
|
||||
/** Brightness of the board meshes (vertex colour above 1 lifts the mid-grey board art a little). */
|
||||
@@ -362,6 +364,9 @@ export class TileField {
|
||||
|
||||
_stageDevices() {
|
||||
const list = Array.isArray(this.stage?.devices) ? this.stage.devices : [];
|
||||
// the boss field's devices (rows ≤ 6, its top wall under the bench included) only in the views of the boss field
|
||||
// (board3d/layout.js stageDevices: the official normal cameras never show that wall; user playtest #5 item 6)
|
||||
const bossShown = this.field[0] < BOSS_WALL_ROW;
|
||||
const out = [];
|
||||
for (const d of list) {
|
||||
if (!d || !Array.isArray(d.pos) || !Number.isInteger(d.pos[0]) || !Number.isInteger(d.pos[1])) continue;
|
||||
@@ -369,14 +374,11 @@ export class TileField {
|
||||
const active = typeof d.active === 'boolean' ? d.active : !d.hidden;
|
||||
if (!active) continue;
|
||||
if (d.pos[0] < 0 || d.pos[0] >= ROWS || d.pos[1] < 0 || d.pos[1] >= COLS || d.pos[0] >= 14) continue;
|
||||
if (d.pos[0] <= BOSS_WALL_ROW && !bossShown) continue;
|
||||
const gt = this.grid[d.pos[0]] && this.grid[d.pos[0]][d.pos[1]];
|
||||
if (gt && (!gt.drawn || gt.scenery)) continue;
|
||||
// a device on the field's edge wall only matters when it acts on this field (blowers blowing into it)
|
||||
const F = this.field;
|
||||
if (Array.isArray(d.rangeTiles) && d.rangeTiles.length > 1 && (d.pos[0] === F[0] || d.pos[0] === F[1])) {
|
||||
const into = d.rangeTiles.some(([r, c]) => (r !== d.pos[0] || c !== d.pos[1]) && r > F[0] && r < F[1]);
|
||||
if (!into) continue;
|
||||
}
|
||||
// devices stand on the island (playable tiles + the ring around them, dist ≤ 1 — the 3D board's drawn tiles), not
|
||||
// on the faded margin; the separator row 13 above the field included (act2 m01's blowers)
|
||||
if (gt && (!gt.drawn || gt.scenery || gt.dist > 1)) continue;
|
||||
out.push(d);
|
||||
}
|
||||
return out;
|
||||
|
||||
@@ -20,6 +20,10 @@
|
||||
// a dark disc, a mint arc filling as the respawn timer runs and the seconds left; once the timer is done and it still
|
||||
// waits, a full amber ring with "DP" (not enough DP) or a red ring with "!" (its tile is taken). `onDeploy` (the
|
||||
// redeploy) plays the deploy clip and restores the normal look; `setDown(null)` on a dead view lets it fade out.
|
||||
// An operator that enters a battle already knocked out (联防, user playtest #5 item 2: sim 'die' reason 'forcedExit')
|
||||
// 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
|
||||
@@ -74,6 +78,25 @@ export const DOWN_LOOK = Object.freeze({
|
||||
/** Element ring diameter in tiles (operators; enemies ×0.8) and its pixel clamp. */
|
||||
const EL_RING = 0.3;
|
||||
|
||||
/**
|
||||
* Enemy modes drawn with another clip set of the same skeleton (sim fx 'phase' kind → UnitView.setForm), per Spine
|
||||
* id: 掠海漂移体 (PRTS: 受晕眩/沉睡/冻结影响后进入爬行模式 — for good) crawls on its *_02 clips after 'Change'. The mode's
|
||||
* roles override the manifest's (data/assets.json anims); 吉兆飞鳞's 晕眩模式 is its Stun clip already.
|
||||
*/
|
||||
export const FORMS = Object.freeze({
|
||||
enemy_2025_syufo: Object.freeze({
|
||||
crawl: Object.freeze({
|
||||
change: 'Change',
|
||||
roles: Object.freeze({
|
||||
idle: 'Idle_02', deploy: 'Idle_02', die: 'Die_02',
|
||||
move: Object.freeze({ begin: null, loop: 'Move_02', end: null }),
|
||||
attack: Object.freeze({ begin: null, loop: 'Attack_02', end: null, via: 'attackAny' }),
|
||||
skill: Object.freeze({ begin: null, loop: 'Attack_02', end: null, via: 'attack', index: 0, idle: null }),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
/**
|
||||
* Keep `obj` in the right layer: the surface container of block row round(y) when it lies on a raised top at
|
||||
* height `z` (drawn after that row's blocks), else `fallback`. Reparents only on change.
|
||||
@@ -168,6 +191,7 @@ export class UnitView {
|
||||
this.shake = 0;
|
||||
this.screen = { x: 0, y: 0, s: 1, top: 0 };
|
||||
this.destroyed = false;
|
||||
this.form = typeof info.form === 'string' ? info.form : null; // an enemy's mode (setForm, FORMS)
|
||||
|
||||
// --- display objects
|
||||
this.shadow = new P.Sprite(ctx.shadowTex || shadowTexture());
|
||||
@@ -275,6 +299,9 @@ export class UnitView {
|
||||
this.spineReady = true;
|
||||
this.swapT = swap ? 1 : 0;
|
||||
this.actor.spine.alpha = swap ? 1 : 0;
|
||||
// a mode the unit is already in (a model built or rebuilt after the change): its clip set, no change clip
|
||||
const f = this._formSpec();
|
||||
if (f) this.actor.setForm(f.roles);
|
||||
// replay current state (a dead model resumes its Die clip where it would be — a knocked-down one holds its end)
|
||||
if (!this.alive) {
|
||||
const d = this.actor.die();
|
||||
@@ -287,6 +314,26 @@ export class UnitView {
|
||||
}, () => { this._releaseEntry(entry); /* keep the fallback */ });
|
||||
}
|
||||
|
||||
_formSpec() {
|
||||
return this.form ? FORMS[this.info.spine || this.info.defId]?.[this.form] || null : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The unit changed mode (sim fx 'phase' { id, kind }): the mode's clip set (FORMS) after its change clip; a kind with
|
||||
* no clip set of its own goes back to the manifest clips. Kept for a model built later.
|
||||
*/
|
||||
setForm(kind) {
|
||||
const k = typeof kind === 'string' ? kind : null;
|
||||
if (k === this.form) return;
|
||||
const had = !!this._formSpec();
|
||||
this.form = k;
|
||||
this.info.form = k;
|
||||
const f = this._formSpec();
|
||||
if (!this.actor) return;
|
||||
if (f) this.actor.setForm(f.roles, f.change || null);
|
||||
else if (had) this.actor.setForm(null);
|
||||
}
|
||||
|
||||
// ---- HUD -------------------------------------------------------------------------------------------------
|
||||
|
||||
_buildHud() {
|
||||
@@ -495,7 +542,11 @@ export class UnitView {
|
||||
if (on) this.statuses.add(key); else this.statuses.delete(key);
|
||||
}
|
||||
|
||||
die() {
|
||||
/**
|
||||
* The unit died: its Die clip plays, then it fades (unless it stays down, setDown). `instant`: it starts on the held
|
||||
* end of the clip — a unit that is already down (a field joined mid-battle, an operator entering 联防 knocked out).
|
||||
*/
|
||||
die(instant = false) {
|
||||
if (!this.alive) return;
|
||||
this.alive = false;
|
||||
const d = this.actor ? this.actor.die() : 0;
|
||||
@@ -503,6 +554,7 @@ export class UnitView {
|
||||
this.dieT = 0;
|
||||
this.dying = clamp(d / rate, 0.35, 1.6) + 0.55;
|
||||
this.dieDur = this.dying;
|
||||
if (instant) { this.dieT = 30; if (this.actor) this.actor.update(30); }
|
||||
}
|
||||
|
||||
revive() {
|
||||
@@ -528,10 +580,7 @@ export class UnitView {
|
||||
if (!this.alive) { this.dying = 0.55; this.dieDur = 0.55; }
|
||||
return;
|
||||
}
|
||||
if (this.alive) {
|
||||
this.die();
|
||||
if (instant) { this.dieT = 30; if (this.actor) this.actor.update(30); }
|
||||
}
|
||||
if (this.alive) this.die(instant);
|
||||
if (this.zTarget == null) this.z = this.zTarget = groundZ(this.ctx, this.x, this.y); // never synced: its tile top
|
||||
this.dying = 0; // no death fade while down
|
||||
const dn = this.down || (this.down = { until: 0, total: 0, state: DOWN_STATE.COUNTING });
|
||||
|
||||
Reference in new issue
Block a user