- #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)
274 lines
14 KiB
JavaScript
274 lines
14 KiB
JavaScript
// render/drag.js — prep-phase pointer interactions: hover, click, right-click / long-press detail, and
|
|
// drag & drop of pieces between hand, temp and board (DESIGN §9, §10). Pure logic (no PIXI, no DOM access):
|
|
// app.js feeds it canvas pointer events and supplies hit-testing callbacks, so the state machine and the drop
|
|
// target resolution are unit tested in Node.
|
|
//
|
|
// Drop targets (protocol g.move `to`): { area: 'board', row, col } | { area: 'hand', idx }, plus
|
|
// { area: 'outside', clientX, clientY } when the pointer is released off the stage grid or over DOM UI that
|
|
// covers the canvas (the UI decides what, if anything, that means). Releasing over a grid tile that is not a legal
|
|
// 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 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, 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 }
|
|
|
|
import { GEO } from '../../../shared/constants.js';
|
|
|
|
/** Where a piece currently sits, as a tile. `piece.area` 'board' uses row/col; 'hand'/'temp' use idx. */
|
|
export function pieceTile(piece) {
|
|
if (!piece || typeof piece !== 'object') return null;
|
|
if (piece.area === 'hand' && Number.isInteger(piece.idx)) return { row: GEO.HAND_ROW, col: piece.idx };
|
|
if (piece.area === 'temp' && Number.isInteger(piece.idx)) return { row: GEO.TEMP_ROW, col: GEO.TEMP_C0 + piece.idx };
|
|
if (Number.isInteger(piece.row) && Number.isInteger(piece.col)) return { row: piece.row, col: piece.col };
|
|
return null;
|
|
}
|
|
|
|
/** The slot a piece occupies, in protocol target form (temp slots → { area: 'temp', idx }). */
|
|
export function pieceSlot(piece) {
|
|
if (!piece || typeof piece !== 'object') return null;
|
|
if (piece.area === 'hand' && Number.isInteger(piece.idx)) return { area: 'hand', idx: piece.idx };
|
|
if (piece.area === 'temp' && Number.isInteger(piece.idx)) return { area: 'temp', idx: piece.idx };
|
|
if (Number.isInteger(piece.row) && Number.isInteger(piece.col)) return { area: 'board', row: piece.row, col: piece.col };
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Classify a grid tile as a potential drop slot.
|
|
* @returns {{area:'board',row,col}|{area:'hand',idx}|{area:'temp',idx}|null}
|
|
*/
|
|
export function tileSlot(tile, geo = GEO) {
|
|
if (!tile || !Number.isInteger(tile.row) || !Number.isInteger(tile.col)) return null;
|
|
const { row, col } = tile;
|
|
if (row === geo.HAND_ROW && col >= 0 && col < geo.HAND_SIZE) return { area: 'hand', idx: col };
|
|
if (row === geo.TEMP_ROW && col >= geo.TEMP_C0 && col < geo.TEMP_C0 + geo.TEMP_SIZE) return { area: 'temp', idx: col - geo.TEMP_C0 };
|
|
const F = geo.FIELD;
|
|
if (row >= F.r0 && row <= F.r1 && col >= F.c0 && col <= F.c1) return { area: 'board', row, col };
|
|
return null;
|
|
}
|
|
|
|
export const sameSlot = (a, b) => !!a && !!b && a.area === b.area &&
|
|
(a.area === 'board' ? a.row === b.row && a.col === b.col : a.idx === b.idx);
|
|
|
|
/**
|
|
* Decide what a release does.
|
|
* @param {{ piece, from, tile, overCanvas: boolean, clientX: number, clientY: number,
|
|
* canPlace?: (piece, row, col, target) => boolean, geo? }} p
|
|
* @returns {{ kind: 'drop', target } | { kind: 'cancel', reason: string }}
|
|
*/
|
|
export function resolveDrop(p) {
|
|
const { piece, from, tile, overCanvas, clientX, clientY } = p;
|
|
if (!overCanvas || !tile) {
|
|
return { kind: 'drop', target: { area: 'outside', clientX: Number(clientX) || 0, clientY: Number(clientY) || 0 } };
|
|
}
|
|
const slot = tileSlot(tile, p.geo || GEO);
|
|
if (!slot) return { kind: 'cancel', reason: 'notTarget' };
|
|
if (slot.area === 'temp') return { kind: 'cancel', reason: 'temp' };
|
|
if (sameSlot(slot, from) && slot.area !== 'board') return { kind: 'cancel', reason: 'same' };
|
|
if (!isLegal(p.canPlace, piece, slot)) return { kind: 'cancel', reason: 'illegal' };
|
|
return { kind: 'drop', target: slot };
|
|
}
|
|
|
|
/** Legality via the UI callback (board → (piece,row,col); hand → (piece, HAND_ROW, idx)). Throwing ⇒ illegal. */
|
|
export function isLegal(canPlace, piece, slot) {
|
|
if (!slot || slot.area === 'temp' || slot.area === 'outside') return false;
|
|
if (typeof canPlace !== 'function') return true;
|
|
const row = slot.area === 'board' ? slot.row : GEO.HAND_ROW;
|
|
const col = slot.area === 'board' ? slot.col : slot.idx;
|
|
try { return canPlace(piece, row, col, slot) !== false; } catch { return false; }
|
|
}
|
|
|
|
const DEFAULTS = { threshold: 6, touchThreshold: 10, longPressMs: 480 };
|
|
|
|
/**
|
|
* Pointer state machine.
|
|
* @param {{
|
|
* 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,
|
|
* setTimeout?, clearTimeout?, threshold?, touchThreshold?, longPressMs?
|
|
* }} hooks
|
|
*/
|
|
export function createDragController(hooks) {
|
|
const h = { ...DEFAULTS, ...hooks };
|
|
const setT = h.setTimeout || ((fn, ms) => setTimeout(fn, ms));
|
|
const clearT = h.clearTimeout || ((id) => clearTimeout(id));
|
|
let editable = false;
|
|
let canPlace = typeof h.canPlace === 'function' ? h.canPlace : null;
|
|
/** @type {null | { mode: 'pressed'|'dragging'|'held', pointerId, pointerType, piece, from, sx, sy, timer }} */
|
|
let st = null;
|
|
let hoverUid = null;
|
|
let hoverTileKey = '';
|
|
|
|
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, 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;
|
|
const key = tile ? `${tile.row},${tile.col}` : '';
|
|
if (key === hoverTileKey) return;
|
|
hoverTileKey = key;
|
|
emit('tileHover', tile ? { row: tile.row, col: tile.col, area: slot ? slot.area : null, idx: slot && slot.area !== 'board' ? slot.idx : undefined } : null);
|
|
}
|
|
|
|
function setHoverPiece(piece, e) {
|
|
const uid = piece ? piece.uid : null;
|
|
if (uid === hoverUid) return;
|
|
hoverUid = uid;
|
|
emit('pieceHover', piece ? { uid, piece, clientX: e.clientX, clientY: e.clientY } : { uid: null });
|
|
}
|
|
|
|
function clearTimer() { if (st && st.timer != null) { clearT(st.timer); st.timer = null; } }
|
|
|
|
function startDrag(e) {
|
|
clearTimer();
|
|
st.mode = 'dragging';
|
|
setHoverPiece(null, e);
|
|
emit('pieceDragStart', { uid: st.piece.uid, piece: st.piece, from: st.from });
|
|
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 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, hx: pt.x, hy: pt.y, clientX: e.clientX, clientY: e.clientY, target: slot, legal });
|
|
}
|
|
|
|
function endDrag(e, cancelled) {
|
|
const s = st;
|
|
st = null;
|
|
if (!s) return;
|
|
let dropped = false;
|
|
if (!cancelled) {
|
|
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 });
|
|
}
|
|
}
|
|
emit('pieceDragEnd', { uid: s.piece.uid, piece: s.piece, dropped, cancelled: !dropped });
|
|
}
|
|
|
|
return {
|
|
get editable() { return editable; },
|
|
get dragging() { return !!st && st.mode === 'dragging'; },
|
|
get dragPiece() { return st && st.mode === 'dragging' ? st.piece : null; },
|
|
get hoverUid() { return hoverUid; },
|
|
|
|
setEditable(v) {
|
|
editable = !!v;
|
|
if (!editable && st && st.mode === 'dragging') endDrag(null, true);
|
|
},
|
|
setCanPlace(fn) { canPlace = typeof fn === 'function' ? fn : null; },
|
|
|
|
/** e: { pointerId, pointerType, button, x, y (canvas CSS px), clientX, clientY } */
|
|
pointerDown(e) {
|
|
if (st) return false; // one pointer at a time
|
|
const piece = pieceAt(e.x, e.y);
|
|
if (!piece) { setHoverTile(tileAt(e.x, e.y)); return false; }
|
|
if (e.button === 2) {
|
|
emit('pieceClick', { uid: piece.uid, piece, button: 2, detail: true, clientX: e.clientX, clientY: e.clientY });
|
|
emit('pieceDetail', { uid: piece.uid, piece, clientX: e.clientX, clientY: e.clientY });
|
|
return true;
|
|
}
|
|
if (e.button != null && e.button !== 0) return false;
|
|
st = { mode: 'pressed', pointerId: e.pointerId, pointerType: e.pointerType || 'mouse', piece, from: pieceSlot(piece), sx: e.x, sy: e.y, cx: e.clientX, cy: e.clientY, timer: null };
|
|
if (st.pointerType === 'touch' || st.pointerType === 'pen') {
|
|
const snapshot = st;
|
|
st.timer = setT(() => {
|
|
if (st !== snapshot || st.mode !== 'pressed') return;
|
|
st.timer = null;
|
|
st.mode = 'held';
|
|
emit('pieceClick', { uid: piece.uid, piece, button: 0, detail: true, clientX: snapshot.cx, clientY: snapshot.cy });
|
|
emit('pieceDetail', { uid: piece.uid, piece, clientX: snapshot.cx, clientY: snapshot.cy });
|
|
}, h.longPressMs);
|
|
}
|
|
return true;
|
|
},
|
|
|
|
pointerMove(e) {
|
|
if (!st) {
|
|
if (e.pointerType === 'touch') return;
|
|
setHoverPiece(pieceAt(e.x, e.y, true), e);
|
|
setHoverTile(tileAt(e.x, e.y));
|
|
return;
|
|
}
|
|
if (e.pointerId !== st.pointerId) return;
|
|
if (st.mode === 'pressed') {
|
|
const th = st.pointerType === 'touch' ? h.touchThreshold : h.threshold;
|
|
if (Math.hypot(e.x - st.sx, e.y - st.sy) >= th) {
|
|
if (editable && draggable(st.piece)) startDrag(e);
|
|
else { clearTimer(); st.mode = 'held'; }
|
|
}
|
|
} else if (st.mode === 'dragging') moveDrag(e);
|
|
},
|
|
|
|
pointerUp(e) {
|
|
if (!st || e.pointerId !== st.pointerId) return;
|
|
if (st.mode === 'dragging') { endDrag(e, false); return; }
|
|
const s = st;
|
|
clearTimer();
|
|
st = null;
|
|
if (s.mode === 'pressed') emit('pieceClick', { uid: s.piece.uid, piece: s.piece, button: 0, detail: false, clientX: e.clientX, clientY: e.clientY });
|
|
},
|
|
|
|
pointerCancel(e) {
|
|
if (!st || (e && e.pointerId !== st.pointerId)) return;
|
|
if (st.mode === 'dragging') { endDrag(e, true); return; }
|
|
clearTimer();
|
|
st = null;
|
|
},
|
|
|
|
pointerLeave(e) {
|
|
if (st) return;
|
|
setHoverPiece(null, e || {});
|
|
setHoverTile(null);
|
|
},
|
|
|
|
/** Abort any interaction (piece list replaced, view destroyed…). */
|
|
reset() {
|
|
if (st && st.mode === 'dragging') endDrag(null, true);
|
|
else { clearTimer(); st = null; }
|
|
hoverUid = null;
|
|
hoverTileKey = '';
|
|
},
|
|
};
|
|
}
|