274 lines
13 KiB
JavaScript
274 lines
13 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").
|
|
// Everything is judged at the pointer (user playtest #4 item 1: the ground is drawn as tiles): a press / hover is on the
|
|
// piece standing on the tile under it (`hitPiece`, render/pick.js), and while dragging the target, `tileHover` and the
|
|
// legality are that tile — an item dropped on a unit's tile goes to that unit. app.js draws a dragged unit standing on
|
|
// that tile while it is a legal target (`dragStandTile`, the official deploy drag) and held under the pointer
|
|
// otherwise; an item plate stays on the pointer. A release over DOM UI covering the canvas (the shop bar) is 'outside'
|
|
// (mouse and touch alike).
|
|
//
|
|
// 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, clientX, clientY, target, legal } (for the ghost; high frequency)
|
|
// 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 };
|
|
}
|
|
|
|
/**
|
|
* The tile a dragged UNIT is drawn standing on, from a `pieceDragMove` payload: the drop target under the pointer while
|
|
* it is legal — a board tile, or a bench slot (row HAND_ROW) — as `{ row, col, bench }` in board space; null otherwise
|
|
* (no target, an illegal tile, a temp slot, the pointer over DOM UI such as the shop bar): the unit is then held under
|
|
* the pointer. As in the official deploy drag (the owner's recording of 2026-10-09) the model stands on the tile the
|
|
* finger is on, and moving within that tile leaves it where it is. app.js never snaps an item plate.
|
|
*/
|
|
export function dragStandTile(move, geo = GEO) {
|
|
const t = move && move.legal === true ? move.target : null;
|
|
if (!t) return null;
|
|
if (t.area === 'board' && Number.isInteger(t.row) && Number.isInteger(t.col)) return { row: t.row, col: t.col, bench: false };
|
|
if (t.area === 'hand' && Number.isInteger(t.idx)) return { row: geo.HAND_ROW, col: t.idx, bench: true };
|
|
return null;
|
|
}
|
|
|
|
/** 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) => (object|null), // piece on the tile under canvas point (render/pick.js); piece.uid required
|
|
* pickTile: (x, y) => ({row, col}|null), // grid tile under canvas point
|
|
* 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) => { try { return h.hitPiece(x, y); } catch { return null; } };
|
|
const overCanvas = (cx, cy) => { try { return h.isOverCanvas ? h.isOverCanvas(cx, cy) !== false : true; } catch { return true; } };
|
|
|
|
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);
|
|
}
|
|
|
|
function moveDrag(e) {
|
|
const tile = tileAt(e.x, e.y);
|
|
const over = overCanvas(e.clientX, e.clientY);
|
|
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 });
|
|
}
|
|
|
|
function endDrag(e, cancelled) {
|
|
const s = st;
|
|
st = null;
|
|
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 });
|
|
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), 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 = '';
|
|
},
|
|
};
|
|
}
|