Playtest #3 fixes: lost models, picking, live LP, temp overflow, 蕾缪安 S3, 回环射手, FX, detail card, spinners
- #1 lost operator models: the Spine store no longer hands out a skeleton whose unload is in flight (PIXI.Assets.unload is async); eviction runs 1 s after a release and the empty-scene budget only after 3 s - #7 picking: one shared rule (render/pick.js, pixel probe where bodies overlap) for prep/battle clicks, hover, the enemy pen and equipment drops; the drag ghost stands on its drop tile; hover probes capped at one per frame - #2 the top-bar LP drops live as the own battle's enemies enter the blue gate (−N, 联防中), teammates' pending LP in the team panel - #3 temp overflow pieces that arrive after a prep deadline survive through the next prep (tempDue); temp-row warning and ready-button reason - #4 蕾缪安 S3: locks every 0.5 s, then one shell every 0.3 s in lock order (PRTS notes), bombardShell fx - #5 回环射手: real boomerang (out 15, back 3.75 tiles/s, attack only when every boomerang is caught) - #6 visible projectiles, muzzle flashes, slashes, skill bursts and auras at unchanged frame cost - #8 特质 right under the detail card header, with its official type icon - #9 no spinning busy cursors / spinners next to texts; game data warmed before the match - docs, recordings and tests updated (2414 unit tests, all browser suites green)
This commit is contained in:
1 parent
01a44cd288
commit
0aba602ecf
72 files changed
+5132
-571
No files matched your search
+154
-38
@@ -14,11 +14,22 @@
|
||||
// view.highlightTiles(tiles, style) [[r,c]] | [{row,col}]; style 'legal'|'illegal'|'range'|'rangeStand'|
|
||||
// 'hover'|'target'|{color,fill,line,group}; highlightTiles(null) clears all
|
||||
// view.on(name, fn) → unsubscribe ; view.off(name, fn)
|
||||
// pieceDragStart { uid, piece, from } · pieceDrop { uid, piece, from, target } · pieceDragEnd {uid, dropped}
|
||||
// pieceDragStart { uid, piece, from } · pieceDrop { uid, piece, from, target, over } · pieceDragEnd {uid, dropped, over}
|
||||
// pieceDragOver { uid, over, clientX, clientY } — while an item is dragged: `over` = uid of the own operator /
|
||||
// summon under the dragged plate (null: none), sent when it changes, BEFORE the drag asks canPlace
|
||||
// pieceClick { uid, piece, button, detail, clientX, clientY } (battle units: { unitId, uid, unit, … })
|
||||
// pieceDetail (right-click / long-press) · pieceHover { uid } | { uid: null } (battle: + unitId, unit)
|
||||
// tileHover { row, col, area, idx } | null
|
||||
// view.pieceScreenRect(uid) → { left, top, right, bottom, width, height, x, y } (client px) | null
|
||||
// tileHover { row, col, area, idx } | null (while dragging: the drop target, the tile under the ghost's feet)
|
||||
// view.pieceScreenRect(uid) → { left, top, right, bottom, width, height, x, y, shape } (client px) | null;
|
||||
// `shape` = the body for render/pick.js (ui/gameLogic.js pickPieceAt)
|
||||
// Picking (user playtest #3 item 7): every "which unit is under the pointer" — prep press / drag start / detail
|
||||
// (pieceAt), battle clicks and hover (battleUnitAt), the pen (penUnitAt), the operator under a dragged item
|
||||
// (pieceUnder) — is render/pick.js pickBody over the views' drawn bodies (UnitView.pickShape) and the tile under the
|
||||
// pointer, with a pixel probe (drawnAt: a view rendered alone into a 1×1 target) wherever bodies overlap: the front-most
|
||||
// unit whose model draws the pixel under the pointer wins; elsewhere the face / torso of a unit is its own, then the
|
||||
// unit standing on the pressed tile. A dragged
|
||||
// piece's ghost stands on its drop point — the pointer (mouse / pen) or TOUCH_LIFT_TILES above the finger (touch) —
|
||||
// and the drop target is the tile under it (render/drag.js dropPoint).
|
||||
// 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
|
||||
@@ -84,6 +95,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 { pickBody } from './pick.js';
|
||||
|
||||
const VENDOR = { pixi: '/vendor/pixi.min.js', spine: '/vendor/pixi-spine.js' };
|
||||
const PIECE_DIRS = new Set(['UP', 'RIGHT', 'DOWN', 'LEFT']);
|
||||
@@ -100,6 +112,21 @@ const RANGE_GROUPS = new Set(['facing', 'range', 'rangeStand', 'select', 'sel',
|
||||
/** 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 touch drag holds its ghost — and so its drop point — this many tiles above the finger (neither is hidden). */
|
||||
export const TOUCH_LIFT_TILES = 0.6;
|
||||
|
||||
/** The display tile a view stands on (its drawn position). */
|
||||
const viewTile = (v) => ({ row: Math.round(v.y), col: Math.round(v.x) });
|
||||
/** A view's body for render/pick.js, or null when it cannot be picked (gone, faded out, dead). */
|
||||
function pickShapeOf(v, tile) {
|
||||
if (!v || v.destroyed || typeof v.pickShape !== 'function' || v.alive === false) return null;
|
||||
if (Number.isFinite(v.alpha) && v.alpha < 0.05) return null;
|
||||
const b = v.pickShape();
|
||||
if (!b) return null;
|
||||
b.tile = tile === undefined ? viewTile(v) : tile;
|
||||
b.ref = v;
|
||||
return b;
|
||||
}
|
||||
|
||||
let pixiPromise = null;
|
||||
|
||||
@@ -894,16 +921,13 @@ export async function createFieldView(host, options = {}) {
|
||||
penList = null;
|
||||
}
|
||||
|
||||
/** Front-most pen enemy under a canvas point. */
|
||||
/** The pen enemy under a canvas point (render/pick.js; the figures idle on their pen tiles). */
|
||||
function penUnitAt(x, y) {
|
||||
if (penHidden) return null;
|
||||
let best = null, bestZ = -Infinity;
|
||||
for (const v of penViews.values()) {
|
||||
if (!v.hitTest(x, y)) continue;
|
||||
const z = v.root ? v.root.zIndex : 0;
|
||||
if (z > bestZ) { bestZ = z; best = v; }
|
||||
}
|
||||
return best;
|
||||
const bodies = [];
|
||||
for (const v of penViews.values()) { const b = pickShapeOf(v); if (b) bodies.push(b); }
|
||||
const hit = pickBody(bodies, x, y, groundTile(x, y), probeAt(x, y));
|
||||
return hit ? hit.ref : null;
|
||||
}
|
||||
|
||||
function emitPenClick(v, e) {
|
||||
@@ -913,27 +937,100 @@ export async function createFieldView(host, options = {}) {
|
||||
|
||||
// ---- drag & pointer ----------------------------------------------------------------------------------------
|
||||
|
||||
function pieceAt(x, y) {
|
||||
if (mode !== 'prep') return null;
|
||||
let best = null, bestZ = -Infinity;
|
||||
/** The display tile under a canvas point (raised tops first), or null off the grid. */
|
||||
function groundTile(x, y) {
|
||||
const t = pickTile(cam, x, y, heightAt, tiles.levels);
|
||||
return t ? { row: t.row, col: t.col } : null;
|
||||
}
|
||||
|
||||
// pixel probe of render/pick.js: one view's display tree rendered alone into a 1×1 target over the canvas pixel
|
||||
let probeRT = null;
|
||||
let probeM = null;
|
||||
/**
|
||||
* Does view `v` draw an opaque pixel at canvas point (x, y) — as last rendered (its world transforms of the last
|
||||
* frame: what the player sees)? null when it cannot tell (no WebGL readback, lost context…). Only asked where two or
|
||||
* more bodies overlap (render/pick.js probeBodies), i.e. a few 1-px renders per press / hover move there.
|
||||
*/
|
||||
function drawnAt(v, x, y) {
|
||||
const root = v && v.root;
|
||||
if (!root || root.destroyed) return false;
|
||||
if (!root.visible || !(root.worldAlpha > 0.02)) return false;
|
||||
try {
|
||||
const R = app.renderer;
|
||||
if (!R || !R.extract || typeof R.extract.pixels !== 'function' || !P.RenderTexture || !P.Matrix) return null;
|
||||
if (!probeRT) probeRT = P.RenderTexture.create({ width: 1, height: 1, resolution: 1 });
|
||||
if (!probeM) probeM = new P.Matrix();
|
||||
probeM.set(1, 0, 0, 1, 0.5 - x, 0.5 - y); // the pixel centred on the point
|
||||
R.render(root, { renderTexture: probeRT, clear: true, transform: probeM, skipUpdateTransform: true });
|
||||
const px = R.extract.pixels(probeRT);
|
||||
return px && px.length >= 4 ? px[3] > 40 : null;
|
||||
} catch { return null; }
|
||||
}
|
||||
const probeAt = (x, y) => (b) => drawnAt(b.ref, x, y);
|
||||
// Hover picks (mouse moves over the field, item drags) probe at most once per frame: every readback waits for the
|
||||
// GPU and a mouse reports up to 1000 moves per second, so a second hover pick in the same frame gets the shape rule
|
||||
// alone (it only moves a highlight). Presses, releases and drops always probe.
|
||||
let hoverProbeFrame = -1;
|
||||
const hoverProbeAt = (x, y) => {
|
||||
if (hoverProbeFrame === frameNo) return null;
|
||||
hoverProbeFrame = frameNo;
|
||||
return probeAt(x, y);
|
||||
};
|
||||
|
||||
/** The prep piece entry under a canvas point (render/pick.js), among the entries `accept` lets through. */
|
||||
function prepEntryAt(x, y, accept = null, hover = false) {
|
||||
const bodies = [];
|
||||
for (const e of prepPieces) {
|
||||
const v = views.get(e.key);
|
||||
if (!v || !v.hitTest(x, y)) continue;
|
||||
const z = v.root.zIndex;
|
||||
if (z > bestZ) { bestZ = z; best = e; }
|
||||
}
|
||||
if (!best) {
|
||||
// fall back to the tile under the pointer
|
||||
const t = pickBoardTile(x, y);
|
||||
if (t) best = prepPieces.find((e) => { const w = pieceTile(e); return w && w.row === t.row && w.col === t.col; }) || null;
|
||||
if (accept && !accept(e)) continue;
|
||||
const b = pickShapeOf(views.get(e.key));
|
||||
if (b) { b.entry = e; bodies.push(b); }
|
||||
}
|
||||
const hit = pickBody(bodies, x, y, groundTile(x, y), hover ? hoverProbeAt(x, y) : probeAt(x, y));
|
||||
return hit ? hit.entry : null;
|
||||
}
|
||||
|
||||
function pieceAt(x, y, hover = false) {
|
||||
if (mode !== 'prep') return null;
|
||||
const best = prepEntryAt(x, y, null, hover);
|
||||
if (!best) return null;
|
||||
return { uid: best.uid, kind: best.piece.kind, id: best.piece.id, area: best.area, idx: best.idx, row: best.row, col: best.col, piece: best.piece, draggable: true };
|
||||
}
|
||||
|
||||
/** uid of the own operator / summon under a canvas point (not `exceptUid`): where a dragged item would go. */
|
||||
function pieceUnder(x, y, exceptUid, hover = false) {
|
||||
if (mode !== 'prep') return null;
|
||||
const e = prepEntryAt(x, y, (en) => en.uid !== exceptUid && (en.piece.kind === 'chess' || en.piece.kind === 'token'), hover);
|
||||
return e ? e.uid : null;
|
||||
}
|
||||
|
||||
// the current drag's drop point and the own unit under it (items): { uid, over, x, y }
|
||||
let dragHit = null;
|
||||
/**
|
||||
* Drop point of a dragged piece (render/drag.js dropPoint): the pointer, or TOUCH_LIFT_TILES above a finger. For an
|
||||
* item it also finds the operator under the plate and tells the UI (pieceDragOver) before the drag asks canPlace
|
||||
* (a move is a hover pick; the release (`final`) always probes).
|
||||
*/
|
||||
function dropPoint(e, piece, final = false) {
|
||||
let x = e.x, y = e.y;
|
||||
if (e.pointerType === 'touch') {
|
||||
const g = cam.unproject(x, y, 0);
|
||||
y -= TOUCH_LIFT_TILES * (g ? cam.scaleAt(g.x, g.y, 0) : cam.scale);
|
||||
}
|
||||
const uid = piece ? piece.uid : null;
|
||||
const over = piece && piece.kind === 'item' ? pieceUnder(x, y, uid, !final) : null;
|
||||
const changed = !dragHit || dragHit.uid !== uid || dragHit.over !== over;
|
||||
dragHit = { uid, over, x, y };
|
||||
if (changed && piece && piece.kind === 'item') {
|
||||
const r = canvas.getBoundingClientRect();
|
||||
emit('pieceDragOver', { uid, over, clientX: r.left + x, clientY: r.top + y });
|
||||
}
|
||||
return { x, y };
|
||||
}
|
||||
|
||||
const drag = createDragController({
|
||||
hitPiece: (x, y) => pieceAt(x, y),
|
||||
hitPiece: (x, y, hover) => pieceAt(x, y, !!hover),
|
||||
pickTile: (x, y) => pickBoardTile(x, y),
|
||||
dropPoint: (e, piece, final) => dropPoint(e, piece, !!final),
|
||||
isOverCanvas: (cx, cy) => {
|
||||
try { const el = document.elementFromPoint(cx, cy); return !el || el === canvas; } catch { return true; }
|
||||
},
|
||||
@@ -947,8 +1044,10 @@ export async function createFieldView(host, options = {}) {
|
||||
|
||||
function onDragEvent(name, payload) {
|
||||
const out = payload && payload.piece ? { ...payload, piece: publicPiece(payload.piece) } : payload;
|
||||
// the own unit under a dropped item (render/pick.js at the drop point; null for units / nothing there)
|
||||
if ((name === 'pieceDrop' || name === 'pieceDragEnd') && out && typeof out === 'object') out.over = dragHit && dragHit.uid === payload.uid ? dragHit.over : null;
|
||||
switch (name) {
|
||||
case 'pieceDragStart': startDragVisual(payload); break;
|
||||
case 'pieceDragStart': dragHit = null; startDragVisual(payload); break;
|
||||
case 'pieceDragMove': moveDragVisual(payload); return; // internal
|
||||
case 'pieceDrop': dropDragVisual(payload); break;
|
||||
case 'pieceDragEnd': if (!payload.dropped) endDragVisual(true); else clearHl('dragTarget'); break;
|
||||
@@ -1012,12 +1111,15 @@ export async function createFieldView(host, options = {}) {
|
||||
function moveDragVisual(p) {
|
||||
if (!dragState) return;
|
||||
const v = dragState.view;
|
||||
const zPlane = 0.1;
|
||||
const w = cam.unproject(p.x, p.y + cam.scaleAt(v.x, v.y, 0) * 0.45, zPlane);
|
||||
// the ghost stands (an item: floats centred) on its drop point — the tile the drop goes to is the one under it
|
||||
const hx = Number.isFinite(p.hx) ? p.hx : p.x, hy = Number.isFinite(p.hy) ? p.hy : p.y;
|
||||
const t = pickTile(cam, hx, hy, heightAt, tiles.levels);
|
||||
const z = t ? heightAt(t.row, t.col) : 0;
|
||||
const w = cam.unproject(hx, hy, z);
|
||||
if (w) {
|
||||
v.x = Math.max(-1, Math.min(21, w.x));
|
||||
v.y = Math.max(-1, Math.min(19, w.y));
|
||||
v.z = zPlane;
|
||||
v.z = z;
|
||||
}
|
||||
if (p.target && p.target.area !== 'temp') {
|
||||
const r = p.target.area === 'board' ? p.target.row : GEO.HAND_ROW;
|
||||
@@ -1065,15 +1167,16 @@ export async function createFieldView(host, options = {}) {
|
||||
return { pointerId: e.pointerId, pointerType: e.pointerType, button: e.button, x: p.x, y: p.y, clientX: e.clientX, clientY: e.clientY };
|
||||
}
|
||||
|
||||
function battleUnitAt(x, y) {
|
||||
let best = null, bestZ = -Infinity;
|
||||
/** The battle unit under a canvas point (render/pick.js; allies stand on their tiles, enemies walk: no tile). */
|
||||
function battleUnitAt(x, y, hover = false) {
|
||||
const bodies = [];
|
||||
for (const v of views.values()) {
|
||||
if (!v.hitTest || !v.alive || !v.info || v.info.kind === 'device') continue;
|
||||
if (!v.hitTest(x, y)) continue;
|
||||
const z = v.root ? v.root.zIndex : 0;
|
||||
if (z > bestZ) { bestZ = z; best = v; }
|
||||
if (!v.info || v.info.kind === 'device') continue;
|
||||
const b = pickShapeOf(v, v.info.side === 'ally' ? viewTile(v) : null);
|
||||
if (b) bodies.push(b);
|
||||
}
|
||||
return best;
|
||||
const hit = pickBody(bodies, x, y, groundTile(x, y), hover ? hoverProbeAt(x, y) : probeAt(x, y));
|
||||
return hit ? hit.ref : null;
|
||||
}
|
||||
|
||||
const onPointerDown = (e) => {
|
||||
@@ -1103,7 +1206,7 @@ export async function createFieldView(host, options = {}) {
|
||||
const ev = evPayload(e);
|
||||
if (mode === 'battle') {
|
||||
if (e.pointerType === 'touch') return;
|
||||
const v = battleUnitAt(ev.x, ev.y);
|
||||
const v = battleUnitAt(ev.x, ev.y, true);
|
||||
if (v !== hoverUnit) {
|
||||
hoverUnit = v;
|
||||
const info = v ? infos.get(v.id) || v.info : null;
|
||||
@@ -1551,7 +1654,14 @@ export async function createFieldView(host, options = {}) {
|
||||
const b = v.bounds();
|
||||
const r = canvas.getBoundingClientRect();
|
||||
const left = r.left + b.x, top = r.top + b.y;
|
||||
return { left, top, right: left + b.width, bottom: top + b.height, width: b.width, height: b.height, x: left, y: top };
|
||||
// the drawn body in client px (render/pick.js, ui/gameLogic.js pickPieceAt)
|
||||
let shape = null;
|
||||
if (typeof v.pickShape === 'function') {
|
||||
const s = v.pickShape();
|
||||
const hud = s.hud ? { x0: s.hud.x0 + r.left, y0: s.hud.y0 + r.top, x1: s.hud.x1 + r.left, y1: s.hud.y1 + r.top } : null;
|
||||
shape = { ...s, x: s.x + r.left, y: s.y + r.top, hud };
|
||||
}
|
||||
return { left, top, right: left + b.width, bottom: top + b.height, width: b.width, height: b.height, x: left, y: top, shape };
|
||||
},
|
||||
/**
|
||||
* Screen geometry of a BOARD-space tile (in the Final Assault prep: where that board tile is drawn on the boss
|
||||
@@ -1636,6 +1746,8 @@ export async function createFieldView(host, options = {}) {
|
||||
try { fx.destroy(); } catch { /* ignore */ }
|
||||
try { tiles.destroy(); } catch { /* ignore */ }
|
||||
try { impostors.destroy(); } catch { /* ignore */ }
|
||||
try { probeRT?.destroy(true); } catch { /* ignore */ }
|
||||
probeRT = null;
|
||||
listeners.clear();
|
||||
try { board3d?.destroy(); } catch { /* ignore */ }
|
||||
board3d = null;
|
||||
@@ -1654,7 +1766,11 @@ export async function createFieldView(host, options = {}) {
|
||||
},
|
||||
get mode() { return mode; },
|
||||
/** Dev hooks (demo / tests). */
|
||||
debug: { app, get cam() { return cam; }, get board3d() { return board3d; }, tiles, views, penViews, interp, fx, ctx, drag, get camKind() { return viewKind(camKind, camOpts); } },
|
||||
debug: {
|
||||
app, get cam() { return cam; }, get board3d() { return board3d; }, tiles, views, penViews, interp, fx, ctx, drag, get camKind() { return viewKind(camKind, camOpts); },
|
||||
// picking (render/pick.js) at canvas px: the prep piece / battle view / pen view / own unit under a dragged item
|
||||
pick: { pieceAt, battleUnitAt, penUnitAt, pieceUnder, groundTile, drawnAt }, get dragHit() { return dragHit; },
|
||||
},
|
||||
};
|
||||
return view;
|
||||
}
|
||||
+31
-10
@@ -9,14 +9,20 @@
|
||||
// target (temp slot, enemy lane, `canPlace` false, or the piece's own bench slot) cancels the drag without a
|
||||
// pieceDrop. A board piece released on its OWN tile is a drop (when `canPlace` allows it): the UI opens the
|
||||
// direction wheel to re-orient it in place (research 09 §1.2 — "drag the unit onto its own tile and swipe").
|
||||
// While dragging, the target is the tile under the dragged ghost's feet: `dropPoint(e, piece)` (app.js) gives that
|
||||
// canvas point — the pointer itself (mouse / pen), a little above the finger (touch) — and the ghost is drawn standing
|
||||
// on it, so what the player sees is where the piece lands (user playtest #3 item 7); whether DOM UI covers the drop is
|
||||
// tested there too (a lifted touch ghost stands on the bench while the finger is on the shop bar below it). Hover and
|
||||
// presses use the pointer.
|
||||
//
|
||||
// Events emitted through `emit(name, payload)`:
|
||||
// pieceHover { uid, piece, clientX, clientY } | { uid: null } (mouse/pen hover changes)
|
||||
// tileHover { row, col, area, idx } | null (hover / drag tile changes)
|
||||
// tileHover { row, col, area, idx } | null (hover / drag target tile changes)
|
||||
// pieceClick { uid, piece, button, detail, clientX, clientY } (detail: right-click / long-press)
|
||||
// pieceDetail { uid, piece, clientX, clientY } (right-click / long-press)
|
||||
// pieceDragStart { uid, piece, from }
|
||||
// pieceDragMove { uid, piece, x, y, clientX, clientY, target, legal } (for the ghost; high frequency)
|
||||
// pieceDragMove { uid, piece, x, y, hx, hy, clientX, clientY, target, legal } (for the ghost; high frequency;
|
||||
// hx, hy = the drop point)
|
||||
// pieceDrop { uid, piece, from, target }
|
||||
// pieceDragEnd { uid, piece, dropped, cancelled }
|
||||
|
||||
@@ -90,8 +96,11 @@ const DEFAULTS = { threshold: 6, touchThreshold: 10, longPressMs: 480 };
|
||||
/**
|
||||
* Pointer state machine.
|
||||
* @param {{
|
||||
* hitPiece: (x, y) => (object|null), // piece under canvas point (front-most); piece.uid required
|
||||
* hitPiece: (x, y, hover) => (object|null), // piece under canvas point (render/pick.js rule); piece.uid required;
|
||||
* // hover = a mouse move (not a press): may skip the pixel probe
|
||||
* pickTile: (x, y) => ({row, col}|null), // grid tile under canvas point
|
||||
* dropPoint?: (e, piece, final) => ({x, y}|null), // canvas point of the dragged ghost's feet (default: the pointer);
|
||||
* // final = the release (a move may skip the pixel probe)
|
||||
* isOverCanvas?: (clientX, clientY) => boolean, // false when DOM UI covers the point (default true)
|
||||
* canPlace?: (piece, row, col, target) => boolean,
|
||||
* emit: (name, payload) => void,
|
||||
@@ -112,8 +121,14 @@ export function createDragController(hooks) {
|
||||
const emit = (name, payload) => { try { h.emit(name, payload); } catch (err) { console.error('[drag] listener failed', err); } };
|
||||
const draggable = (piece) => !!piece && piece.draggable !== false && piece.uid != null;
|
||||
const tileAt = (x, y) => { try { return h.pickTile(x, y); } catch { return null; } };
|
||||
const pieceAt = (x, y) => { try { return h.hitPiece(x, y); } catch { return null; } };
|
||||
const pieceAt = (x, y, hover = false) => { try { return h.hitPiece(x, y, hover); } catch { return null; } };
|
||||
const overCanvas = (cx, cy) => { try { return h.isOverCanvas ? h.isOverCanvas(cx, cy) !== false : true; } catch { return true; } };
|
||||
/** The drop point of a dragged piece (its ghost's feet) for pointer event `e`. */
|
||||
const dropAt = (e, piece, final = false) => {
|
||||
let p = null;
|
||||
try { p = typeof h.dropPoint === 'function' ? h.dropPoint(e, piece, final) : null; } catch { p = null; }
|
||||
return p && Number.isFinite(p.x) && Number.isFinite(p.y) ? p : { x: e.x, y: e.y };
|
||||
};
|
||||
|
||||
function setHoverTile(tile) {
|
||||
const slot = tile ? tileSlot(tile) : null;
|
||||
@@ -140,13 +155,18 @@ export function createDragController(hooks) {
|
||||
moveDrag(e);
|
||||
}
|
||||
|
||||
/** Is the drop point (not the finger) on the canvas? A touch ghost is lifted over the board while the finger may be
|
||||
* on the DOM below it (the shop bar under the bench on a phone): what counts is where the piece is drawn. */
|
||||
const dropOverCanvas = (e, pt) => overCanvas(e.clientX + (pt.x - e.x), e.clientY + (pt.y - e.y));
|
||||
|
||||
function moveDrag(e) {
|
||||
const tile = tileAt(e.x, e.y);
|
||||
const over = overCanvas(e.clientX, e.clientY);
|
||||
const pt = dropAt(e, st.piece);
|
||||
const tile = tileAt(pt.x, pt.y);
|
||||
const over = dropOverCanvas(e, pt);
|
||||
const slot = over && tile ? tileSlot(tile) : null;
|
||||
const legal = !!slot && slot.area !== 'temp' && (slot.area === 'board' || !sameSlot(slot, st.from)) && isLegal(canPlace, st.piece, slot);
|
||||
setHoverTile(over ? tile : null);
|
||||
emit('pieceDragMove', { uid: st.piece.uid, piece: st.piece, x: e.x, y: e.y, clientX: e.clientX, clientY: e.clientY, target: slot, legal });
|
||||
emit('pieceDragMove', { uid: st.piece.uid, piece: st.piece, x: e.x, y: e.y, hx: pt.x, hy: pt.y, clientX: e.clientX, clientY: e.clientY, target: slot, legal });
|
||||
}
|
||||
|
||||
function endDrag(e, cancelled) {
|
||||
@@ -155,8 +175,9 @@ export function createDragController(hooks) {
|
||||
if (!s) return;
|
||||
let dropped = false;
|
||||
if (!cancelled) {
|
||||
const tile = tileAt(e.x, e.y);
|
||||
const d = resolveDrop({ piece: s.piece, from: s.from, tile, overCanvas: overCanvas(e.clientX, e.clientY), clientX: e.clientX, clientY: e.clientY, canPlace });
|
||||
const pt = dropAt(e, s.piece, true);
|
||||
const tile = tileAt(pt.x, pt.y);
|
||||
const d = resolveDrop({ piece: s.piece, from: s.from, tile, overCanvas: dropOverCanvas(e, pt), clientX: e.clientX, clientY: e.clientY, canPlace });
|
||||
if (d.kind === 'drop') {
|
||||
dropped = true;
|
||||
emit('pieceDrop', { uid: s.piece.uid, piece: s.piece, from: s.from, target: d.target });
|
||||
@@ -205,7 +226,7 @@ export function createDragController(hooks) {
|
||||
pointerMove(e) {
|
||||
if (!st) {
|
||||
if (e.pointerType === 'touch') return;
|
||||
setHoverPiece(pieceAt(e.x, e.y), e);
|
||||
setHoverPiece(pieceAt(e.x, e.y, true), e);
|
||||
setHoverTile(tileAt(e.x, e.y));
|
||||
return;
|
||||
}
|
||||
|
||||
+821
-152
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,224 @@
|
||||
// render/pick.js — which unit the pointer is on (user playtest #3 item 7: "clicking a unit on the map often selects
|
||||
// the one a row below it; dropping equipment too"). Pure screen-space geometry (no PIXI, no DOM), the ONE rule behind
|
||||
// every picking path: prep press / drag start / right-click detail and equipment dropped on an operator
|
||||
// (render/app.js pieceAt / pieceUnder, and ui/gameLogic.js pickPieceAt over view.pieceScreenRect shapes), battle
|
||||
// clicks and hover (battleUnitAt) and the enemy preview pen (penUnitAt).
|
||||
//
|
||||
// Why "the front-most rectangle under the pointer" failed: a chibi is an upright billboard ~1.27 tiles tall (median
|
||||
// of the 138 official operators' Front / Back skeletons in their idle pose, p10–p90 1.17–1.41) standing on its tile,
|
||||
// while the rows of the official 30° cameras are only ~0.84 (prep) – 0.91 (battle) of a tile apart on screen. The unit
|
||||
// in front covers the legs / feet of the unit behind it and its head sits over that unit's tile; its 0.7-tile-wide
|
||||
// rectangle also covered the ground and the legs showing beside its head (a head is only ~0.5 tile wide), and a
|
||||
// pressed tile was only looked at when no rectangle at all was hit — so a press on the lower part of a unit, on its
|
||||
// feet or on its tile went to the unit in front of it: the one a row lower on screen.
|
||||
//
|
||||
// Body of a unit (all px on one screen; `x`, `y` = its feet, `s` = px per tile at the feet, `h` = model height in
|
||||
// tiles, `w` = width factor (1 for operators; enemies scale with their size), `flip` = +1 facing right / −1 left). The
|
||||
// measured opaque profile of the idle chibis is ~±0.3 tile wide through the body (the back side a little wider: hair,
|
||||
// capes) and ~±0.2 at the crown; a taller model is taller hair / ears / hats — its face stays ~0.86 tile up:
|
||||
// outline head ellipse (centre h − 0.33·w above the feet, radii 0.27·w × 0.33·w) ∪ body box (0.08 below the feet …
|
||||
// the shoulders at min(0.62·h, 0.78·w); −0.34·w … +0.28·w facing right) ∪ the unit's HUD (tier chip /
|
||||
// bars) — what is drawn
|
||||
// core face (ellipse 0.21·w × 0.15·w centred min(0.86·w, h − 0.36·w) above the feet) ∪ torso / legs (±0.2·w, from
|
||||
// 0.1 above the feet to the shoulders) — what a player aims at
|
||||
// 'diamond' (the avatar diamond shown until the Spine model is there): the rhombus of side `d` tiles above the feet
|
||||
// 'plate' (hand items): a square of half-size `r` tiles centred on (x, y)
|
||||
// Rule (pickBody):
|
||||
// 1. the pointer is on units' cores → the front-most of them (drawn on top: the face of the unit in front is its own;
|
||||
// the torso of the unit behind is its own above that face)
|
||||
// 2. else a unit stands on the tile under the pointer → that unit (its feet, its tile — also where the hair or a
|
||||
// weapon of the unit in front hangs over it)
|
||||
// 3. else the pointer is on outlines only (hair, hands, feet, weapon edges) → the unit whose core is nearest (px / its
|
||||
// own scale); ties → the front-most
|
||||
// The feet of a unit hidden behind the face of the unit in front are not pickable (nothing of them shows there); its
|
||||
// tile beside that face and everything of it above that face are.
|
||||
//
|
||||
// Pixel probe (optional `probe(body)` → true / false / null, render/app.js drawnAt): the shapes above are a model of
|
||||
// the drawn chibi, and where the bodies of two units overlap the model cannot tell whose arm, hair or weapon is drawn
|
||||
// on top. Measured on the mock match against the rendered pixels (1920×1080, every opaque unit pixel sampled, the
|
||||
// front-most drawn unit = the truth): the rule alone put 7 % of the prep presses (18 % in battle) on the wrong unit —
|
||||
// mostly the unit BEHIND: the hair crown and the sides (arms, clothes, weapons) of the unit in front lie over the legs /
|
||||
// tile of the one behind — against 2 % / 11 % for the old front-most rectangle; with the probe 0.7 % / 1.7 % (the rest:
|
||||
// anti-aliased edges). So wherever the probe can change the answer (two or more bodies within their outline +
|
||||
// PROBE_MARGIN of the pointer, or one the rule did not pick), the front-most body whose model really draws the pixel
|
||||
// under the pointer wins — what the player sees. Only when none of them draws there (a gap, the bare tile) or the probe
|
||||
// cannot tell (null) does the rule above decide.
|
||||
|
||||
/** Median model height of the official chibis (tiles above the feet), when the model's own is not known. */
|
||||
export const BODY_H = 1.27;
|
||||
/** Shape constants in tiles (see the header). */
|
||||
export const BODY = Object.freeze({
|
||||
headRx: 0.27, headRy: 0.33, faceRx: 0.21, faceRy: 0.15, face: 0.86, faceTop: 0.36, back: 0.34, front: 0.28,
|
||||
below: 0.08, shoulder: 0.62, neck: 0.78, torso: 0.2, torsoFrom: 0.1, lean: 0.03,
|
||||
});
|
||||
|
||||
const clamp = (v, lo, hi) => (v < lo ? lo : v > hi ? hi : v);
|
||||
const inRect = (r, px, py) => !!r && px >= r.x0 && px <= r.x1 && py >= r.y0 && py <= r.y1;
|
||||
const inEllipse = (e, px, py) => ((px - e.cx) / e.rx) ** 2 + ((py - e.cy) / e.ry) ** 2 <= 1;
|
||||
const rectDist = (r, px, py) => Math.hypot(Math.max(r.x0 - px, 0, px - r.x1), Math.max(r.y0 - py, 0, py - r.y1));
|
||||
function ellipseDist(e, px, py) {
|
||||
const k = Math.hypot((px - e.cx) / e.rx, (py - e.cy) / e.ry);
|
||||
return k > 1 ? (k - 1) * Math.min(e.rx, e.ry) : 0;
|
||||
}
|
||||
/** Rhombus |dx|/a + |dy|/a ≤ k around (cx, cy). */
|
||||
const inRhombus = (d, px, py, k = 1) => Math.abs(px - d.cx) / d.a + Math.abs(py - d.cy) / d.a <= k;
|
||||
|
||||
/**
|
||||
* @typedef {{ x: number, y: number, s: number, h?: number, w?: number, flip?: number, kind?: 'chibi'|'diamond'|'plate',
|
||||
* d?: number, r?: number, depth?: number, hud?: { x0: number, y0: number, x1: number, y1: number } | null,
|
||||
* tile?: { row: number, col: number } | null }} PickBody
|
||||
* `depth`: draw order (larger = nearer the camera, drawn on top); `tile`: the tile it stands on (null: moving units)
|
||||
*/
|
||||
|
||||
/**
|
||||
* Screen geometry of a body: `{ outline: [parts], core: [parts] }`, parts `{ rect }` / `{ ellipse }` / `{ rhombus }`.
|
||||
* @param {PickBody} b
|
||||
*/
|
||||
export function bodyParts(b) {
|
||||
const s = b.s, x = b.x, y = b.y;
|
||||
const hud = b.hud && [b.hud.x0, b.hud.y0, b.hud.x1, b.hud.y1].every(Number.isFinite) ? { rect: b.hud } : null;
|
||||
if (b.kind === 'plate') {
|
||||
const a = (Number(b.r) > 0 ? b.r : 0.31) * s;
|
||||
return { outline: [{ rect: { x0: x - a, x1: x + a, y0: y - a, y1: y + a } }], core: [{ rect: { x0: x - a * 0.7, x1: x + a * 0.7, y0: y - a * 0.7, y1: y + a * 0.7 } }] };
|
||||
}
|
||||
if (b.kind === 'diamond') {
|
||||
const half = ((Number(b.d) > 0 ? b.d : 0.78) / 2) * s;
|
||||
const rh = { cx: x, cy: y - BODY.below * s - half, a: half };
|
||||
return { outline: [{ rhombus: rh, k: 1 }, ...(hud ? [hud] : [])], core: [{ rhombus: rh, k: 0.7 }] };
|
||||
}
|
||||
const w = Number(b.w) > 0 ? b.w : 1;
|
||||
const h = clamp(Number(b.h) > 0 ? b.h : BODY_H, 0.3, 4);
|
||||
const flip = b.flip === -1 ? -1 : 1;
|
||||
const cx = x - BODY.lean * flip * w * s;
|
||||
const head = { cx, cy: y - (h - BODY.headRy * w) * s, rx: BODY.headRx * w * s, ry: BODY.headRy * w * s };
|
||||
const face = { cx, cy: y - Math.min(BODY.face * w, h - BODY.faceTop * w) * s, rx: BODY.faceRx * w * s, ry: BODY.faceRy * w * s };
|
||||
const top = y - Math.min(BODY.shoulder * h, BODY.neck * w) * s;
|
||||
const box = { x0: x - (flip > 0 ? BODY.back : BODY.front) * w * s, x1: x + (flip > 0 ? BODY.front : BODY.back) * w * s, y0: top, y1: y + BODY.below * s };
|
||||
const torso = { x0: cx - BODY.torso * w * s, x1: cx + BODY.torso * w * s, y0: top, y1: y - BODY.torsoFrom * Math.min(1, h) * s };
|
||||
// the HUD (tier chip / bars) is drawn above every unit, but over the unit behind: an outline, never a core
|
||||
return { outline: [{ ellipse: head }, { rect: box }, ...(hud ? [hud] : [])], core: [{ ellipse: face }, { rect: torso }] };
|
||||
}
|
||||
|
||||
const partHit = (p, px, py) => (p.rect ? inRect(p.rect, px, py) : p.ellipse ? inEllipse(p.ellipse, px, py) : inRhombus(p.rhombus, px, py, p.k));
|
||||
function partDist(p, px, py) {
|
||||
if (p.rect) return rectDist(p.rect, px, py);
|
||||
if (p.ellipse) return ellipseDist(p.ellipse, px, py);
|
||||
const r = p.rhombus, k = (Math.abs(px - r.cx) + Math.abs(py - r.cy)) / r.a;
|
||||
return k > p.k ? (k - p.k) * r.a * Math.SQRT1_2 : 0;
|
||||
}
|
||||
|
||||
/** 2 = on the body's core, 1 = on its outline only, 0 = off it. */
|
||||
export function hitBody(b, px, py) {
|
||||
if (!b || !(b.s > 0) || !Number.isFinite(b.x) || !Number.isFinite(b.y)) return 0;
|
||||
// cheap reject (battle hover tests every unit on every pointer move): far outside the body and its HUD
|
||||
if (b.kind !== 'plate' && !(b.hud && inRect(b.hud, px, py))) {
|
||||
const w = Number(b.w) > 0 ? b.w : 1, reach = Math.max(BODY.back * w, (Number(b.d) || 0) / 2) * b.s + 1;
|
||||
const top = b.y - (b.kind === 'diamond' ? (Number(b.d) || 0.78) + BODY.below : clamp(Number(b.h) > 0 ? b.h : BODY_H, 0.3, 4)) * b.s - 1;
|
||||
if (Math.abs(px - b.x) > reach || py < top || py > b.y + BODY.below * b.s + 1) return 0;
|
||||
}
|
||||
const parts = bodyParts(b);
|
||||
if (parts.core.some((p) => partHit(p, px, py))) return 2;
|
||||
return parts.outline.some((p) => partHit(p, px, py)) ? 1 : 0;
|
||||
}
|
||||
|
||||
/** Distance (px) from a point to the body's core, divided by the body's scale (0 on the core). */
|
||||
export function coreDistance(b, px, py) {
|
||||
const parts = bodyParts(b);
|
||||
let d = Infinity;
|
||||
for (const p of parts.core) d = Math.min(d, partDist(p, px, py));
|
||||
return d / b.s;
|
||||
}
|
||||
|
||||
/** The screen bounding box of a body's outline (px): `{ x, y, width, height }`. */
|
||||
export function bodyBounds(b) {
|
||||
const parts = bodyParts(b);
|
||||
let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity;
|
||||
for (const p of [...parts.outline, ...parts.core]) {
|
||||
if (p.rect) { x0 = Math.min(x0, p.rect.x0); x1 = Math.max(x1, p.rect.x1); y0 = Math.min(y0, p.rect.y0); y1 = Math.max(y1, p.rect.y1); }
|
||||
else if (p.ellipse) { const e = p.ellipse; x0 = Math.min(x0, e.cx - e.rx); x1 = Math.max(x1, e.cx + e.rx); y0 = Math.min(y0, e.cy - e.ry); y1 = Math.max(y1, e.cy + e.ry); }
|
||||
else { const r = p.rhombus, a = r.a * p.k; x0 = Math.min(x0, r.cx - a); x1 = Math.max(x1, r.cx + a); y0 = Math.min(y0, r.cy - a); y1 = Math.max(y1, r.cy + a); }
|
||||
}
|
||||
if (!Number.isFinite(x0)) return { x: b.x, y: b.y, width: 0, height: 0 };
|
||||
return { x: x0, y: y0, width: x1 - x0, height: y1 - y0 };
|
||||
}
|
||||
|
||||
const depthOf = (b) => (Number.isFinite(b.depth) ? b.depth : 0);
|
||||
|
||||
/** Margin (tiles) around a body's outline in which the pixel probe still looks (weapons, capes, hair, big enemies past
|
||||
* the modelled outline; 0.25 left 4 % of the battle presses wrong, 0.5 1.7 %). */
|
||||
export const PROBE_MARGIN = 0.5;
|
||||
/** At most this many overlapping bodies are probed (front-most first). */
|
||||
const PROBE_MAX = 6;
|
||||
|
||||
/** Bodies whose outline (+ PROBE_MARGIN) contains the point, front-most first. */
|
||||
export function probeCandidates(bodies, px, py) {
|
||||
const out = [];
|
||||
for (const b of bodies) {
|
||||
if (!b || !(b.s > 0) || !Number.isFinite(b.x) || !Number.isFinite(b.y)) continue;
|
||||
const r = bodyBounds({ ...b, hud: null }), m = PROBE_MARGIN * b.s;
|
||||
if (px >= r.x - m && px <= r.x + r.width + m && py >= r.y - m && py <= r.y + r.height + m) out.push(b);
|
||||
}
|
||||
return out.sort((a, b) => depthOf(b) - depthOf(a));
|
||||
}
|
||||
|
||||
/**
|
||||
* Pixel probe step of pickBody: the front-most body around the point whose model draws the pixel under it. Asked only
|
||||
* when it can change the answer: two or more bodies there, or one that the shape rule did not pick (`rule`: e.g. its
|
||||
* arm over the tile of a unit standing behind it). null when none of them draws there or the probe cannot tell for one
|
||||
* in front of the first that does (then the shape rule decides).
|
||||
*/
|
||||
function probeBodies(bodies, px, py, probe, rule) {
|
||||
const cand = probeCandidates(bodies, px, py);
|
||||
if (!cand.length || (cand.length === 1 && cand[0] === rule)) return null;
|
||||
for (const b of cand.slice(0, PROBE_MAX)) {
|
||||
let d = null;
|
||||
try { d = probe(b); } catch { d = null; }
|
||||
if (d === true) return b;
|
||||
if (d !== false) return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
const onTile = (b, t) => !!t && !!b.tile && b.tile.row === t.row && b.tile.col === t.col;
|
||||
function nearest(list, px, py) {
|
||||
let best = null, bd = Infinity;
|
||||
for (const b of list) {
|
||||
const d = coreDistance(b, px, py);
|
||||
if (d < bd - 1e-9 || (Math.abs(d - bd) <= 1e-9 && best && depthOf(b) > depthOf(best))) { best = b; bd = d; }
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/**
|
||||
* The body the pointer is on (see the header for the rule), or null.
|
||||
* @param {Array<PickBody|null>} bodies
|
||||
* @param {number} px
|
||||
* @param {number} py
|
||||
* @param {{ row: number, col: number } | null} [ground] the tile under the pointer (same space as the bodies' `tile`)
|
||||
* @param {((body: PickBody) => (boolean|null)) | null} [probe] does this body's model draw the pixel under the pointer
|
||||
* (null: cannot tell)? See the header.
|
||||
* @returns {PickBody|null} one of `bodies`
|
||||
*/
|
||||
export function pickBody(bodies, px, py, ground = null, probe = null) {
|
||||
if (!Number.isFinite(px) || !Number.isFinite(py) || !Array.isArray(bodies)) return null;
|
||||
const rule = ruleBody(bodies, px, py, ground);
|
||||
if (typeof probe === 'function') {
|
||||
const drawn = probeBodies(bodies, px, py, probe, rule);
|
||||
if (drawn) return drawn;
|
||||
}
|
||||
return rule;
|
||||
}
|
||||
|
||||
/** The shape rule of pickBody (the header's steps 1–3). */
|
||||
function ruleBody(bodies, px, py, ground) {
|
||||
let core = null;
|
||||
const rims = [];
|
||||
for (const b of bodies) {
|
||||
const hit = hitBody(b, px, py);
|
||||
if (hit === 2) { if (!core || depthOf(b) > depthOf(core)) core = b; }
|
||||
else if (hit === 1) rims.push(b);
|
||||
}
|
||||
if (core) return core;
|
||||
const standing = ground ? bodies.filter((b) => b && b.s > 0 && onTile(b, ground)) : [];
|
||||
if (standing.length) return nearest(standing, px, py);
|
||||
return rims.length ? nearest(rims, px, py) : null;
|
||||
}
|
||||
@@ -88,15 +88,29 @@ export function dmgStyleKey(type) {
|
||||
/** 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 atk projKind (sim: none|arrow|bolt|bomb|lob|orb|drone|enemy|chain|chainHeal). */
|
||||
/**
|
||||
* Projectile visuals per b.ev 'atk' projKind (sim: none | arrow | bolt | bomb | lob | orb | drone | enemy | boomerang;
|
||||
* 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
|
||||
*/
|
||||
export const PROJ = Object.freeze({
|
||||
arrow: { speed: 14, tex: 'streak', tint: 0xfff2d0, len: 0.55, width: 0.09, trail: 0 },
|
||||
bolt: { speed: 11, tex: 'orb', tint: 0xd49bff, len: 0.28, width: 0.28, trail: 0xa35cff },
|
||||
orb: { speed: 11, tex: 'orb', tint: 0x9dffb8, len: 0.26, width: 0.26, trail: 0x4ee07a },
|
||||
bomb: { speed: 8, tex: 'orb', tint: 0xffb066, len: 0.24, width: 0.24, trail: 0xff7a33, arc: 0.9 },
|
||||
lob: { speed: 8, tex: 'orb', tint: 0xffc78a, len: 0.22, width: 0.22, trail: 0xffa04a, arc: 1.2 },
|
||||
drone: { speed: 16, tex: 'orb', tint: 0x8fe6ff, len: 0.16, width: 0.16, trail: 0x57c9ff },
|
||||
enemy: { speed: 12, tex: 'orb', tint: 0xff6a5a, len: 0.22, width: 0.22, trail: 0xff3b30 },
|
||||
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' },
|
||||
});
|
||||
|
||||
/** Status keys (b.ev 'status' + UF flags) → icon atlas key (render/textures.js) and colour. */
|
||||
|
||||
+160
-50
@@ -600,11 +600,21 @@ export function groundTexture(img = null, crop = null) {
|
||||
// =============================================================================================================
|
||||
// FX atlas
|
||||
|
||||
const FX_SIZE = 512;
|
||||
const FX_W = 1024, FX_H = 512;
|
||||
/** Status icons: one 32 px row at the bottom of the FX atlas (frames `st_<key>`). */
|
||||
const STATUS_ROW_Y = 448;
|
||||
let _fx = null;
|
||||
|
||||
/**
|
||||
* FX atlas frames — name: [x, y, w, h, draw(ctx, x, y, w, h)]. Every frame owns its cell: no two frames share a pixel
|
||||
* (the light pillar once ran into the status-icon row, so every skill / deploy pillar carried the 'silence' and 'slow'
|
||||
* icons at its foot) and each drawing stays off its cell's outer pixel (mipmapped sampling of a small sprite must not
|
||||
* pull in a neighbour). Frame sizes are part of the API — sprites are scaled by them: glow / soft / ring / hex / shock /
|
||||
* reticle / smoke / flare 128², spark / plus / orb / chevron / boomerang / muzzle 64², coin 48², dot / shard / square
|
||||
* 32², streak / tracer / bolt 128×32 (head at the right), slash 128×64 (arc bulging up), pillar 64×256 (foot at the
|
||||
* bottom).
|
||||
*/
|
||||
const FX_DRAW = {
|
||||
// name: [x, y, w, h, draw(ctx, x, y, w, h)]
|
||||
glow: [0, 0, 128, 128, (c, x, y, w) => { radial(c, x + w / 2, y + w / 2, w / 2, [[0, 'rgba(255,255,255,1)'], [0.25, 'rgba(255,255,255,0.6)'], [1, 'rgba(255,255,255,0)']]); }],
|
||||
soft: [128, 0, 128, 128, (c, x, y, w) => { radial(c, x + w / 2, y + w / 2, w / 2, [[0, 'rgba(255,255,255,0.8)'], [1, 'rgba(255,255,255,0)']]); }],
|
||||
ring: [256, 0, 128, 128, (c, x, y, w) => {
|
||||
@@ -625,68 +635,154 @@ const FX_DRAW = {
|
||||
c.stroke(); c.globalAlpha = 1;
|
||||
for (let k = 0; k < 6; k++) { const a = (k * Math.PI) / 3; c.fillStyle = '#fff'; c.fillRect(cx + Math.cos(a) * 60 - 3, cy + Math.sin(a) * 60 - 3, 6, 6); }
|
||||
}],
|
||||
spark: [0, 128, 64, 64, (c, x, y, w) => {
|
||||
// a thin sharp ring with a faint wake inside: shockwaves (skill bursts, explosions, the bombard warning)
|
||||
shock: [512, 0, 128, 128, (c, x, y, w) => {
|
||||
radial(c, x + w / 2, y + w / 2, w / 2 - 1, [[0, 'rgba(255,255,255,0)'], [0.52, 'rgba(255,255,255,0)'], [0.8, 'rgba(255,255,255,0.16)'],
|
||||
[0.9, 'rgba(255,255,255,1)'], [0.96, 'rgba(255,255,255,0.3)'], [1, 'rgba(255,255,255,0)']]);
|
||||
}],
|
||||
// lock-on reticle: circle, cross ticks, corner brackets, centre dot (蕾缪安 locks, marks)
|
||||
reticle: [640, 0, 128, 128, (c, x, y, w) => {
|
||||
const cx = x + w / 2, cy = y + w / 2;
|
||||
c.strokeStyle = '#fff'; c.lineCap = 'round';
|
||||
c.lineWidth = 5; c.beginPath(); c.arc(cx, cy, 36, 0, Math.PI * 2); c.stroke();
|
||||
c.lineWidth = 2; c.globalAlpha = 0.55; c.beginPath(); c.arc(cx, cy, 27, 0, Math.PI * 2); c.stroke(); c.globalAlpha = 1;
|
||||
c.lineWidth = 5;
|
||||
for (let k = 0; k < 4; k++) {
|
||||
const a = (k * Math.PI) / 2, ca = Math.cos(a), sa = Math.sin(a);
|
||||
c.beginPath(); c.moveTo(cx + ca * 22, cy + sa * 22); c.lineTo(cx + ca * 50, cy + sa * 50); c.stroke();
|
||||
}
|
||||
c.lineWidth = 4;
|
||||
for (let k = 0; k < 4; k++) {
|
||||
const sx = k & 1 ? 1 : -1, sy = k & 2 ? 1 : -1, bx = cx + sx * 52, by = cy + sy * 52;
|
||||
c.beginPath(); c.moveTo(bx - sx * 14, by); c.lineTo(bx, by); c.lineTo(bx, by - sy * 14); c.stroke();
|
||||
}
|
||||
c.fillStyle = '#fff'; c.beginPath(); c.arc(cx, cy, 4, 0, Math.PI * 2); c.fill();
|
||||
}],
|
||||
smoke: [768, 0, 128, 128, (c, x, y) => {
|
||||
const r = rng(77);
|
||||
for (let i = 0; i < 14; i++) radial(c, x + 30 + r() * 68, y + 30 + r() * 68, 18 + r() * 22, [[0, 'rgba(255,255,255,0.22)'], [1, 'rgba(255,255,255,0)']]);
|
||||
}],
|
||||
// star flare: hot centre, four long and four short rays (skill flash, explosion core, impacts)
|
||||
flare: [896, 0, 128, 128, (c, x, y, w) => {
|
||||
const cx = x + w / 2, cy = y + w / 2;
|
||||
radial(c, cx, cy, w / 2 - 1, [[0, 'rgba(255,255,255,0.95)'], [0.12, 'rgba(255,255,255,0.55)'], [0.4, 'rgba(255,255,255,0.1)'], [1, 'rgba(255,255,255,0)']]);
|
||||
const ray = (a, len, half) => {
|
||||
c.save(); c.translate(cx, cy); c.rotate(a);
|
||||
const g = c.createLinearGradient(0, 0, len, 0);
|
||||
g.addColorStop(0, 'rgba(255,255,255,1)'); g.addColorStop(1, 'rgba(255,255,255,0)');
|
||||
c.fillStyle = g;
|
||||
c.beginPath(); c.moveTo(0, -half); c.lineTo(len, 0); c.lineTo(0, half); c.closePath(); c.fill();
|
||||
c.restore();
|
||||
};
|
||||
for (let k = 0; k < 4; k++) ray((k * Math.PI) / 2, 60, 5);
|
||||
for (let k = 0; k < 4; k++) ray(Math.PI / 4 + (k * Math.PI) / 2, 30, 3);
|
||||
}],
|
||||
pillar: [0, 128, 64, 256, (c, x, y, w, h) => {
|
||||
const g = c.createLinearGradient(x, 0, x + w, 0);
|
||||
g.addColorStop(0, 'rgba(255,255,255,0)'); g.addColorStop(0.5, 'rgba(255,255,255,1)'); g.addColorStop(1, 'rgba(255,255,255,0)');
|
||||
c.fillStyle = g; c.fillRect(x, y + 1, w, h - 2);
|
||||
const v = c.createLinearGradient(0, y, 0, y + h);
|
||||
v.addColorStop(0, 'rgba(0,0,0,1)'); v.addColorStop(0.35, 'rgba(0,0,0,0)'); v.addColorStop(1, 'rgba(0,0,0,0)');
|
||||
c.globalCompositeOperation = 'destination-out'; c.fillStyle = v; c.fillRect(x, y, w, h); c.globalCompositeOperation = 'source-over';
|
||||
}],
|
||||
spark: [64, 128, 64, 64, (c, x, y, w) => {
|
||||
const cx = x + w / 2, cy = y + w / 2;
|
||||
radial(c, cx, cy, w / 2, [[0, 'rgba(255,255,255,0.9)'], [0.2, 'rgba(255,255,255,0.35)'], [1, 'rgba(255,255,255,0)']]);
|
||||
c.fillStyle = 'rgba(255,255,255,1)';
|
||||
c.beginPath(); c.moveTo(cx, y + 2); c.lineTo(cx + 3, cy - 3); c.lineTo(x + w - 2, cy); c.lineTo(cx + 3, cy + 3); c.lineTo(cx, y + w - 2);
|
||||
c.lineTo(cx - 3, cy + 3); c.lineTo(x + 2, cy); c.lineTo(cx - 3, cy - 3); c.closePath(); c.fill();
|
||||
}],
|
||||
plus: [64, 128, 64, 64, (c, x, y, w) => {
|
||||
plus: [128, 128, 64, 64, (c, x, y, w) => {
|
||||
const cx = x + w / 2, cy = y + w / 2;
|
||||
radial(c, cx, cy, w / 2, [[0, 'rgba(255,255,255,0.5)'], [1, 'rgba(255,255,255,0)']]);
|
||||
c.fillStyle = '#fff';
|
||||
c.fillRect(cx - 5, cy - 18, 10, 36); c.fillRect(cx - 18, cy - 5, 36, 10);
|
||||
}],
|
||||
orb: [128, 128, 64, 64, (c, x, y, w) => {
|
||||
orb: [192, 128, 64, 64, (c, x, y, w) => {
|
||||
radial(c, x + w / 2, y + w / 2, w / 2, [[0, 'rgba(255,255,255,1)'], [0.3, 'rgba(255,255,255,0.85)'], [0.55, 'rgba(255,255,255,0.3)'], [1, 'rgba(255,255,255,0)']]);
|
||||
}],
|
||||
dot: [192, 128, 32, 32, (c, x, y, w) => { radial(c, x + w / 2, y + w / 2, w / 2, [[0, 'rgba(255,255,255,1)'], [0.5, 'rgba(255,255,255,0.7)'], [1, 'rgba(255,255,255,0)']]); }],
|
||||
shard: [224, 128, 32, 32, (c, x, y, w) => {
|
||||
c.fillStyle = '#fff'; c.beginPath(); c.moveTo(x + w / 2, y + 1); c.lineTo(x + w - 8, y + w / 2); c.lineTo(x + w / 2, y + w - 1); c.lineTo(x + 8, y + w / 2); c.closePath(); c.fill();
|
||||
}],
|
||||
streak: [0, 192, 128, 32, (c, x, y, w, h) => {
|
||||
const g = c.createLinearGradient(x, 0, x + w, 0);
|
||||
g.addColorStop(0, 'rgba(255,255,255,0)'); g.addColorStop(0.7, 'rgba(255,255,255,0.7)'); g.addColorStop(1, 'rgba(255,255,255,1)');
|
||||
c.fillStyle = g;
|
||||
c.beginPath(); c.moveTo(x, y + h / 2 - 1); c.lineTo(x + w - 10, y + h / 2 - 5); c.lineTo(x + w, y + h / 2); c.lineTo(x + w - 10, y + h / 2 + 5); c.lineTo(x, y + h / 2 + 1); c.closePath(); c.fill();
|
||||
}],
|
||||
slash: [128, 192, 128, 64, (c, x, y, w, h) => {
|
||||
c.save(); c.beginPath(); c.rect(x, y, w, h); c.clip();
|
||||
const g = c.createLinearGradient(x, y, x + w, y);
|
||||
g.addColorStop(0, 'rgba(255,255,255,0)'); g.addColorStop(0.5, 'rgba(255,255,255,1)'); g.addColorStop(1, 'rgba(255,255,255,0)');
|
||||
c.fillStyle = g;
|
||||
c.beginPath(); c.ellipse(x + w / 2, y + h * 1.3, w * 0.48, h * 1.05, 0, Math.PI, 0); c.ellipse(x + w / 2, y + h * 1.45, w * 0.44, h * 1.05, 0, 0, Math.PI, true); c.fill();
|
||||
c.restore();
|
||||
}],
|
||||
pillar: [256, 128, 64, 256, (c, x, y, w, h) => {
|
||||
const g = c.createLinearGradient(x, 0, x + w, 0);
|
||||
g.addColorStop(0, 'rgba(255,255,255,0)'); g.addColorStop(0.5, 'rgba(255,255,255,1)'); g.addColorStop(1, 'rgba(255,255,255,0)');
|
||||
c.fillStyle = g; c.fillRect(x, y, w, h);
|
||||
const v = c.createLinearGradient(0, y, 0, y + h);
|
||||
v.addColorStop(0, 'rgba(0,0,0,1)'); v.addColorStop(0.35, 'rgba(0,0,0,0)'); v.addColorStop(1, 'rgba(0,0,0,0)');
|
||||
c.globalCompositeOperation = 'destination-out'; c.fillStyle = v; c.fillRect(x, y, w, h); c.globalCompositeOperation = 'source-over';
|
||||
}],
|
||||
smoke: [320, 128, 128, 128, (c, x, y, w) => {
|
||||
const r = rng(77);
|
||||
for (let i = 0; i < 14; i++) radial(c, x + 30 + r() * 68, y + 30 + r() * 68, 18 + r() * 22, [[0, 'rgba(255,255,255,0.22)'], [1, 'rgba(255,255,255,0)']]);
|
||||
}],
|
||||
chevron: [448, 128, 64, 64, (c, x, y, w) => {
|
||||
chevron: [256, 128, 64, 64, (c, x, y) => {
|
||||
c.fillStyle = '#fff';
|
||||
c.beginPath(); c.moveTo(x + 14, y + 8); c.lineTo(x + 30, y + 8); c.lineTo(x + 52, y + 32); c.lineTo(x + 30, y + 56); c.lineTo(x + 14, y + 56); c.lineTo(x + 36, y + 32); c.closePath(); c.fill();
|
||||
}],
|
||||
bolt: [0, 256, 128, 32, (c, x, y, w, h) => {
|
||||
const g = c.createLinearGradient(0, y, 0, y + h);
|
||||
g.addColorStop(0, 'rgba(255,255,255,0)'); g.addColorStop(0.5, 'rgba(255,255,255,1)'); g.addColorStop(1, 'rgba(255,255,255,0)');
|
||||
c.fillStyle = g; c.fillRect(x, y, w, h);
|
||||
// 回环射手 boomerang: two curved arms meeting at an elbow, centred on the cell (it spins about the centre)
|
||||
boomerang: [320, 128, 64, 64, (c, x, y, w) => {
|
||||
c.save(); c.translate(x + w / 2, y + w / 2 + 3);
|
||||
c.beginPath();
|
||||
c.moveTo(0, -21);
|
||||
c.quadraticCurveTo(19, -17, 27, 14); c.quadraticCurveTo(21, 18, 14, 11); c.quadraticCurveTo(8, -5, 0, -8);
|
||||
c.quadraticCurveTo(-8, -5, -14, 11); c.quadraticCurveTo(-21, 18, -27, 14); c.quadraticCurveTo(-19, -17, 0, -21);
|
||||
c.closePath();
|
||||
c.fillStyle = 'rgba(255,255,255,0.9)'; c.fill();
|
||||
c.lineWidth = 2.5; c.lineJoin = 'round'; c.strokeStyle = '#fff'; c.stroke();
|
||||
c.restore();
|
||||
}],
|
||||
square: [128, 256, 32, 32, (c, x, y, w) => { c.fillStyle = '#fff'; c.fillRect(x + 1, y + 1, w - 2, w - 2); }],
|
||||
coin: [160, 256, 48, 48, (c, x, y, w) => {
|
||||
// muzzle flash pointing right (+x) from a hot spot at (12, 32): sprite anchor (0.19, 0.5), rotated to the shot
|
||||
muzzle: [384, 128, 64, 64, (c, x, y, w, h) => {
|
||||
const cx = x + 12, cy = y + h / 2;
|
||||
radial(c, cx, cy, 11, [[0, 'rgba(255,255,255,1)'], [0.5, 'rgba(255,255,255,0.6)'], [1, 'rgba(255,255,255,0)']]);
|
||||
const g = c.createLinearGradient(cx, 0, x + w - 2, 0);
|
||||
g.addColorStop(0, 'rgba(255,255,255,0.95)'); g.addColorStop(1, 'rgba(255,255,255,0)');
|
||||
c.fillStyle = g;
|
||||
c.beginPath(); c.moveTo(cx, cy - 7); c.quadraticCurveTo(cx + 26, cy - 12, x + w - 2, cy); c.quadraticCurveTo(cx + 26, cy + 12, cx, cy + 7); c.closePath(); c.fill();
|
||||
c.beginPath(); c.moveTo(cx + 2, cy - 3); c.lineTo(cx + 18, cy - 19); c.lineTo(cx + 9, cy); c.closePath(); c.fill();
|
||||
c.beginPath(); c.moveTo(cx + 2, cy + 3); c.lineTo(cx + 18, cy + 19); c.lineTo(cx + 9, cy); c.closePath(); c.fill();
|
||||
}],
|
||||
coin: [448, 128, 48, 48, (c, x, y, w) => {
|
||||
const cx = x + w / 2, cy = y + w / 2;
|
||||
c.fillStyle = '#ffc600'; c.beginPath(); c.arc(cx, cy, 20, 0, Math.PI * 2); c.fill();
|
||||
c.fillStyle = '#fff2a8'; c.beginPath(); c.arc(cx - 4, cy - 4, 11, 0, Math.PI * 2); c.fill();
|
||||
c.fillStyle = '#c98f00'; c.font = 'bold 22px sans-serif'; c.textAlign = 'center'; c.textBaseline = 'middle'; c.fillText('$', cx, cy + 1);
|
||||
}],
|
||||
dot: [512, 128, 32, 32, (c, x, y, w) => { radial(c, x + w / 2, y + w / 2, w / 2, [[0, 'rgba(255,255,255,1)'], [0.5, 'rgba(255,255,255,0.7)'], [1, 'rgba(255,255,255,0)']]); }],
|
||||
shard: [544, 128, 32, 32, (c, x, y, w) => {
|
||||
c.fillStyle = '#fff'; c.beginPath(); c.moveTo(x + w / 2, y + 1); c.lineTo(x + w - 8, y + w / 2); c.lineTo(x + w / 2, y + w - 1); c.lineTo(x + 8, y + w / 2); c.closePath(); c.fill();
|
||||
}],
|
||||
square: [576, 128, 32, 32, (c, x, y, w) => { c.fillStyle = '#fff'; c.fillRect(x + 1, y + 1, w - 2, w - 2); }],
|
||||
// melee swing: a thick crescent (arc bulging up), hot in the middle, tips fading, a bright rim
|
||||
slash: [640, 128, 128, 64, (c, x, y, w, h) => {
|
||||
const cx = x + w / 2;
|
||||
const g = c.createLinearGradient(x, 0, x + w, 0);
|
||||
g.addColorStop(0, 'rgba(255,255,255,0)'); g.addColorStop(0.28, 'rgba(255,255,255,0.8)'); g.addColorStop(0.55, 'rgba(255,255,255,1)');
|
||||
g.addColorStop(0.82, 'rgba(255,255,255,0.7)'); g.addColorStop(1, 'rgba(255,255,255,0)');
|
||||
c.save(); c.beginPath(); c.rect(x + 1, y + 1, w - 2, h - 2); c.clip();
|
||||
c.beginPath();
|
||||
c.ellipse(cx, y + h * 1.2, w * 0.47, h, 0, Math.PI, 0);
|
||||
c.ellipse(cx, y + h * 1.42, w * 0.43, h, 0, 0, Math.PI, true);
|
||||
c.closePath();
|
||||
c.globalAlpha = 0.85; c.fillStyle = g; c.fill(); c.globalAlpha = 1;
|
||||
c.lineWidth = 3; c.strokeStyle = g;
|
||||
c.beginPath(); c.ellipse(cx, y + h * 1.2, w * 0.47, h, 0, Math.PI * 1.06, Math.PI * 1.94); c.stroke();
|
||||
c.restore();
|
||||
}],
|
||||
// soft trail (orbs, shells, dashes): tapered band with a soft cross-section, transparent tail at the left
|
||||
streak: [768, 128, 128, 32, (c, x, y, w, h) => {
|
||||
const cy = y + h / 2;
|
||||
const v = c.createLinearGradient(0, cy - 10, 0, cy + 10);
|
||||
v.addColorStop(0, 'rgba(255,255,255,0)'); v.addColorStop(0.5, 'rgba(255,255,255,0.95)'); v.addColorStop(1, 'rgba(255,255,255,0)');
|
||||
c.fillStyle = v;
|
||||
c.beginPath(); c.moveTo(x + 1, cy - 2); c.lineTo(x + w - 14, cy - 10); c.quadraticCurveTo(x + w - 1, cy, x + w - 14, cy + 10); c.lineTo(x + 1, cy + 2); c.closePath(); c.fill();
|
||||
c.fillStyle = '#fff';
|
||||
c.beginPath(); c.moveTo(x + 1, cy - 0.5); c.lineTo(x + w - 10, cy - 2.5); c.lineTo(x + w - 4, cy); c.lineTo(x + w - 10, cy + 2.5); c.lineTo(x + 1, cy + 0.5); c.closePath(); c.fill();
|
||||
fadeTail(c, x, y, w, h, 0.6);
|
||||
}],
|
||||
// bullet tracer: glow band + hot core line + bright head at the right
|
||||
tracer: [768, 160, 128, 32, (c, x, y, w, h) => {
|
||||
const cy = y + h / 2;
|
||||
const v = c.createLinearGradient(0, y + 1, 0, y + h - 1);
|
||||
v.addColorStop(0, 'rgba(255,255,255,0)'); v.addColorStop(0.32, 'rgba(255,255,255,0.22)'); v.addColorStop(0.5, 'rgba(255,255,255,0.75)');
|
||||
v.addColorStop(0.68, 'rgba(255,255,255,0.22)'); v.addColorStop(1, 'rgba(255,255,255,0)');
|
||||
c.fillStyle = v; c.fillRect(x + 1, y + 1, w - 10, h - 2);
|
||||
c.fillStyle = '#fff';
|
||||
c.beginPath(); c.moveTo(x + 1, cy - 0.8); c.lineTo(x + w - 10, cy - 2.6); c.lineTo(x + w - 10, cy + 2.6); c.lineTo(x + 1, cy + 0.8); c.closePath(); c.fill();
|
||||
fadeTail(c, x, y, w, h, 0.45);
|
||||
radial(c, x + w - 10, cy, 9, [[0, 'rgba(255,255,255,1)'], [0.45, 'rgba(255,255,255,0.75)'], [1, 'rgba(255,255,255,0)']]);
|
||||
}],
|
||||
bolt: [896, 128, 128, 32, (c, x, y, w, h) => {
|
||||
const g = c.createLinearGradient(0, y, 0, y + h);
|
||||
g.addColorStop(0, 'rgba(255,255,255,0)'); g.addColorStop(0.5, 'rgba(255,255,255,1)'); g.addColorStop(1, 'rgba(255,255,255,0)');
|
||||
c.fillStyle = g; c.fillRect(x + 1, y, w - 2, h);
|
||||
}],
|
||||
};
|
||||
|
||||
function radial(c, cx, cy, r, stops) {
|
||||
@@ -696,27 +792,41 @@ function radial(c, cx, cy, r, stops) {
|
||||
c.fillRect(cx - r, cy - r, r * 2, r * 2);
|
||||
}
|
||||
|
||||
/** Fade a horizontal frame towards its tail (left edge transparent → `mid` alpha at 55 % → opaque head at the right). */
|
||||
function fadeTail(c, x, y, w, h, mid) {
|
||||
c.globalCompositeOperation = 'destination-in';
|
||||
const g = c.createLinearGradient(x, 0, x + w - 8, 0);
|
||||
g.addColorStop(0, 'rgba(255,255,255,0)'); g.addColorStop(0.55, `rgba(255,255,255,${mid})`); g.addColorStop(1, 'rgba(255,255,255,1)');
|
||||
c.fillStyle = g; c.fillRect(x, y, w, h);
|
||||
c.globalCompositeOperation = 'source-over';
|
||||
}
|
||||
|
||||
/** The FX atlas layout (no canvas needed): `{ size: [w, h], frames: { name: [x, y, w, h] } }` incl. `st_<key>` icons. */
|
||||
export function fxFrames() {
|
||||
const frames = {};
|
||||
for (const [name, [x, y, w, h]] of Object.entries(FX_DRAW)) frames[name] = [x, y, w, h];
|
||||
STATUS_KEYS.forEach((k, i) => { frames['st_' + k] = [(i % 32) * 32, STATUS_ROW_Y + Math.floor(i / 32) * 32, 32, 32]; });
|
||||
return { size: [FX_W, FX_H], frames };
|
||||
}
|
||||
|
||||
/** FX atlas: `{ base, tex: { [name]: PIXI.Texture } }` (all frames share one base texture). */
|
||||
export function fxAtlas() {
|
||||
if (_fx) return _fx;
|
||||
const P = PIXI();
|
||||
const canvas = makeCanvas(FX_SIZE, FX_SIZE);
|
||||
const canvas = makeCanvas(FX_W, FX_H);
|
||||
const ctx = canvas.getContext('2d');
|
||||
const frames = {};
|
||||
const { frames } = fxFrames();
|
||||
for (const [name, [x, y, w, h, draw]] of Object.entries(FX_DRAW)) {
|
||||
ctx.save(); ctx.beginPath(); ctx.rect(x, y, w, h); ctx.clip();
|
||||
try { draw(ctx, x, y, w, h); } catch { /* keep blank */ }
|
||||
ctx.restore();
|
||||
frames[name] = [x, y, w, h];
|
||||
}
|
||||
// status icons row (32 px each) at y = 320
|
||||
STATUS_KEYS.forEach((k, i) => {
|
||||
const x = (i % 16) * 32, y = 320 + Math.floor(i / 16) * 32;
|
||||
for (const k of STATUS_KEYS) {
|
||||
const [x, y] = frames['st_' + k];
|
||||
ctx.save(); ctx.beginPath(); ctx.rect(x, y, 32, 32); ctx.clip();
|
||||
try { drawStatusIcon(ctx, k, x, y, 32); } catch { /* ignore */ }
|
||||
ctx.restore();
|
||||
frames['st_' + k] = [x, y, 32, 32];
|
||||
});
|
||||
}
|
||||
const base = P.BaseTexture.from(canvas, { mipmap: P.MIPMAP_MODES.ON });
|
||||
const tex = {};
|
||||
for (const [name, [x, y, w, h]] of Object.entries(frames)) tex[name] = new P.Texture(base, new P.Rectangle(x, y, w, h));
|
||||
|
||||
+67
-12
@@ -22,12 +22,18 @@
|
||||
// pen: idle loops only) animates through the impostor atlas every 3rd frame; under the view's adaptive load level
|
||||
// (`ctx.loadLevel()` 1–3, app.js) small / far units (< ~56 px per tile) animate every 2nd frame, and from level 2 on
|
||||
// every unit does.
|
||||
//
|
||||
// Picking (user playtest #3 item 7): `pickShape()` describes the body as drawn — feet, px per tile, the model's own
|
||||
// height (measured once from the posed skeleton's bounds; the median chibi until then), facing, draw order and the
|
||||
// HUD it shows (tier chip / bars) — for the shared rule of render/pick.js; `bounds()` / `hitTest()` use the same shape.
|
||||
// A dragged (lifted) item plate is drawn centred on its ground point, i.e. on the pointer (render/app.js).
|
||||
|
||||
import { UF, ANIM } from '../../../shared/constants.js';
|
||||
import { SpineActor } from './spine.js';
|
||||
import { diamondTexture, shadowTexture, fxAtlas, tierChip, statusTexture, itemTexture } 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';
|
||||
import { BODY_H, bodyBounds, hitBody } from './pick.js';
|
||||
|
||||
const clamp = (v, lo, hi) => (v < lo ? lo : v > hi ? hi : v);
|
||||
const DIRS = ['UP', 'RIGHT', 'DOWN', 'LEFT'];
|
||||
@@ -137,6 +143,9 @@ export class UnitView {
|
||||
this.shake = 0;
|
||||
this.screen = { x: 0, y: 0, s: 1, top: 0 };
|
||||
this.destroyed = false;
|
||||
this._bodyH = null; // model height (tiles above the feet), measured once per Spine model (pickShape)
|
||||
this._hudBox = null; // screen rect of the HUD shown this frame (tier chip / bars), or null
|
||||
this._flip = this.visFacing; // drawn facing (±1) of the model
|
||||
|
||||
// --- display objects
|
||||
this.shadow = new P.Sprite(ctx.shadowTex || shadowTexture());
|
||||
@@ -397,6 +406,7 @@ export class UnitView {
|
||||
this.imp = null;
|
||||
}
|
||||
this._box = null;
|
||||
this._bodyH = null; // the next model is measured again
|
||||
const old = this.actor;
|
||||
this.actor = null;
|
||||
this.spineReady = false;
|
||||
@@ -514,6 +524,7 @@ export class UnitView {
|
||||
// off-screen: nothing to animate or draw (bounds / hit-testing still follow `screen`)
|
||||
if (this._cull(bx, by, s, dt)) return;
|
||||
const flip = this.isEnemy ? (ENEMY_MODEL_FACES_LEFT ? -this.visFacing : this.visFacing) : this.visFacing;
|
||||
this._flip = flip;
|
||||
|
||||
// shadow (on a raised top it is drawn with that block row, else in the shadow layer under everything)
|
||||
placeOnGround(this.ctx, this.shadow, this.ctx.layers.shadow, this.y, this.z);
|
||||
@@ -558,6 +569,8 @@ export class UnitView {
|
||||
this.actor.update(animDt);
|
||||
if (this._tint !== tint) { this._tint = tint; this.actor.spine.tint = tint; }
|
||||
}
|
||||
// the model's own height for picking: once, from its first resting pose (not a deploy / skill clip)
|
||||
if (this._bodyH == null && this.actor.clock > 0 && this.actor.mode === 'base') this._bodyH = this._measureBody();
|
||||
}
|
||||
// the diamond is only needed while no model shows (a cross-fade keeps whatever diamond was already up)
|
||||
if (!spineShown) this._ensurePicture();
|
||||
@@ -693,6 +706,16 @@ export class UnitView {
|
||||
if (prep) this.chip.position.set(x, y - this.chip.height / 2 + 2);
|
||||
else this.chip.position.set(x0 - this.chip.width / 2 - 1, cy + (showSp ? spH / 2 : 0));
|
||||
}
|
||||
// the HUD's screen rect (drawn above every unit: a press on a unit's chip / bars is on that unit, render/pick.js)
|
||||
let hx0 = Infinity, hy0 = Infinity, hx1 = -Infinity, hy1 = -Infinity;
|
||||
const hudAdd = (ax, ay, bx2, by2) => { hx0 = Math.min(hx0, ax); hy0 = Math.min(hy0, ay); hx1 = Math.max(hx1, bx2); hy1 = Math.max(hy1, by2); };
|
||||
if (showHp) hudAdd(x0 - 1, cy - bh / 2 - 1, x0 + bw + 1, cy + bh / 2 + 1);
|
||||
if (showSp) hudAdd(x0 - 1, cy, x0 + bw + 1, this._spY + spH / 2 + 1);
|
||||
if (this.chip && this.chip.visible) {
|
||||
const cw = this.chip.width / 2, ch = this.chip.height / 2;
|
||||
hudAdd(this.chip.position.x - cw, this.chip.position.y - ch, this.chip.position.x + cw, this.chip.position.y + ch);
|
||||
}
|
||||
this._hudBox = hx1 > hx0 && hy1 > hy0 && this.hud.alpha > 0.05 ? { x0: hx0, y0: hy0, x1: hx1, y1: hy1 } : null;
|
||||
// status icons row above the bars
|
||||
const icons = this._iconKeys();
|
||||
const isz = clamp(s * 0.26, 12, 26);
|
||||
@@ -858,18 +881,39 @@ export class UnitView {
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Canvas-space bounds (CSS px) of the body. */
|
||||
/** Model height (tiles above the feet) of the posed Spine model: its local bounds' top, clamped per kind. */
|
||||
_measureBody() {
|
||||
let b = null;
|
||||
try { b = this.actor.spine.getLocalBounds(); } catch { b = null; }
|
||||
const top = b && Number.isFinite(b.y) && b.height > 1 ? -b.y * UNIT.modelScale : NaN;
|
||||
const [lo, hi] = this.isEnemy ? (this.isBoss ? [0.6, 3.2] : [0.45, 2.4]) : [1.0, 1.55];
|
||||
if (Number.isFinite(top) && top > 0.2) return clamp(top, lo, hi);
|
||||
return this.isEnemy ? clamp(this._headTiles || BODY_H, lo, hi) : BODY_H;
|
||||
}
|
||||
|
||||
/**
|
||||
* The body as drawn, for render/pick.js (canvas CSS px): the Spine chibi (feet, px per tile, its height — measured,
|
||||
* else the median chibi / the enemy's bounds —, width factor, facing, draw order, HUD) or the avatar diamond shown
|
||||
* until the model is there.
|
||||
* @returns {import('./pick.js').PickBody}
|
||||
*/
|
||||
pickShape() {
|
||||
const sc = this.screen;
|
||||
const depth = this.root && !this.root.destroyed ? this.root.zIndex : 0;
|
||||
const hud = this.culled ? null : this._hudBox; // a culled unit's HUD box is where it was last drawn: stale
|
||||
if (!(this.actor && this.spineReady)) return { kind: 'diamond', x: sc.x, y: sc.y, s: sc.s || 1, d: UNIT.diamond * (this.isBoss ? 1.5 : 1), depth, hud };
|
||||
const h = this._bodyH || (this.isEnemy ? clamp(this._headTiles || BODY_H, 0.45, this.isBoss ? 3.2 : 2.4) : BODY_H);
|
||||
return { kind: 'chibi', x: sc.x, y: sc.y, s: sc.s || 1, h, w: this.isEnemy ? clamp(h / 1.2, 0.75, 2.2) : 1, flip: this._flip === -1 ? -1 : 1, depth, hud };
|
||||
}
|
||||
|
||||
/** Canvas-space bounds (CSS px) of the drawn body (the outline of pickShape, without the HUD). */
|
||||
bounds() {
|
||||
const s = this.screen.s || 1;
|
||||
const h = (this._headTiles || UNIT.headroom) * s;
|
||||
const w = s * 0.7;
|
||||
return { x: this.screen.x - w / 2, y: this.screen.y - h, width: w, height: h + s * 0.1 };
|
||||
return bodyBounds({ ...this.pickShape(), hud: null });
|
||||
}
|
||||
|
||||
hitTest(x, y) {
|
||||
if (!this.alive || this.alpha < 0.05) return false;
|
||||
const b = this.bounds();
|
||||
return x >= b.x && x <= b.x + b.width && y >= b.y && y <= b.y + b.height;
|
||||
return hitBody(this.pickShape(), x, y) > 0;
|
||||
}
|
||||
|
||||
setHover(on) { this.hovered = !!on; }
|
||||
@@ -921,7 +965,10 @@ function defaultInterval(ctx, info) {
|
||||
|
||||
// =============================================================================================================
|
||||
|
||||
/** Hand item: icon plate floating above its slot. */
|
||||
/**
|
||||
* Hand item: icon plate floating above its slot. While dragged (`lift` > 0) the plate is centred on its ground point —
|
||||
* the pointer (render/app.js moveDragVisual) — so the item lands, and equips, where it is drawn.
|
||||
*/
|
||||
export class ItemView {
|
||||
constructor(ctx, info) {
|
||||
const P = ctx.P;
|
||||
@@ -946,21 +993,29 @@ export class ItemView {
|
||||
}
|
||||
setWorld(x, y, z = 0) { this.x = x; this.y = y; this.z = z; }
|
||||
update(dt, cam, t) {
|
||||
const p = cam.project(this.x, this.y, this.z + this.lift + 0.12 + Math.sin(t * 2 + this.bob) * 0.03, this.screen);
|
||||
const lifted = this.lift > 0;
|
||||
const p = cam.project(this.x, this.y, lifted ? this.z : this.z + this.lift + 0.12 + Math.sin(t * 2 + this.bob) * 0.03, this.screen);
|
||||
this.root.position.set(p.x, p.y);
|
||||
this.root.zIndex = unitDepthKey(cam, this.x, this.y, this.lift);
|
||||
const size = p.s * 0.62;
|
||||
this.plate.scale.set(size / 128);
|
||||
this.plate.anchor.set(0.5, lifted ? 0.5 : 1);
|
||||
this.root.alpha = this.dimmed ? 0.35 : 1;
|
||||
placeOnGround(this.ctx, this.shadow, this.ctx.layers.shadow, this.y, this.z);
|
||||
const sh = cam.project(this.x, this.y, this.z, SH_P);
|
||||
this.shadow.position.set(sh.x, sh.y);
|
||||
this.shadow.scale.set((p.s * 0.55) / this.shadow.texture.width);
|
||||
this.shadow.alpha = 0.35;
|
||||
this.screen.top = p.y - size;
|
||||
this.screen.top = lifted ? p.y - size / 2 : p.y - size;
|
||||
}
|
||||
bounds() { const s = this.screen.s; const w = s * 0.62; return { x: this.screen.x - w / 2, y: this.screen.y - w, width: w, height: w }; }
|
||||
hitTest(x, y) { const b = this.bounds(); return x >= b.x && x <= b.x + b.width && y >= b.y && y <= b.y + b.height; }
|
||||
/** The plate on screen for render/pick.js: a square centred on the icon. */
|
||||
pickShape() {
|
||||
const s = this.screen.s || 1;
|
||||
const cy = this.lift > 0 ? this.screen.y : this.screen.y - s * 0.31;
|
||||
return { kind: 'plate', x: this.screen.x, y: cy, s, r: 0.31, depth: this.root && !this.root.destroyed ? this.root.zIndex : 0, hud: null };
|
||||
}
|
||||
bounds() { return bodyBounds(this.pickShape()); }
|
||||
hitTest(x, y) { return hitBody(this.pickShape(), x, y) > 0; }
|
||||
setHover() {}
|
||||
destroy() { if (this.destroyed) return; this.destroyed = true; this.shadow.destroy(); this.root.destroy({ children: true }); }
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user