Playtest #4 fixes: tile picking, knocked-down state, element gauges, draft timer, live stats, shop items, drones, 赫默, 炎佑, UI flash

- #1 picking by the tile under the pointer (prep, battle, pen, equipment drops); the dragged model stays
  under the pointer (mouse and touch); pixel probe removed; phone ghost-click on the underframe fixed
- #2 two-tap confirm on 机变 cards (like the shop)
- #3 a 同盟 room with one human is untimed like 独立模拟 (no countdown outside battles)
- #4 strategy draft: one synced countdown, 30 s per human turn, timeout takes the highlighted band
- #5 special 维式重锤 (4) and 突变细胞 no longer sold; item card shows where they come from
- #6 operator skill-mode sounds no longer play on normal attacks (纯烬艾雅法拉 and 55 others)
- #7 detail card shows live battle stats and the prep-time battle-start stats (g.unitStats)
- #8 official element gauges on units; burst cooldown (阻回) per PRTS
- #9 knocked-down operators stay on their tile with a redeploy countdown ring
- #10 drone behaviour per PRTS (暴鸰, 先兆者 shells, 黑云, 萨科塔之眼)
- #11 赫默's drone only from her skill; placeable summons limited to talent tokens
- #12 炎佑 stays where it is with no enemies; 祛恶之焰 as a continuous channel
- #13 the occasional giant bond icon (loading texture scaled ×46) fixed
- docs (DESIGN §18), recordings, data and tests updated (2467 unit tests, all browser suites green)
This commit is contained in:
sganggs committed 2026-09-30 05:35:45 +08:00
1 parent 0aba602ecf
commit d4f868cd19
107 files changed
+4510 -1653

No files matched your search

+103 -144
View File
@@ -8,28 +8,26 @@
// view.enterBattle(fieldMeta) m.field { fieldId, kind, rect, stageId, units: [UnitInfo] }
// view.pushSnapshot(snap); view.pushEvents(ev | { ev, gt }) b.snap / b.ev wire frames as received (game time in
// `gt`; a numeric `t` is accepted for raw Battle snapshots / recordings)
// — 100 ms interpolation buffer
// — 100 ms interpolation buffer; b.snap `down` keeps knocked-out
// operators on the field under a redeploy ring and `elem` draws the
// element gauges (user playtest #4 items 8 / 9, render/units.js)
// view.setLocalFeed({ on, speed }) frames come from the local sim every frame (client-side combat):
// ~2-frame buffer at the battle's game speed
// view.highlightTiles(tiles, style) [[r,c]] | [{row,col}]; style 'legal'|'illegal'|'range'|'rangeStand'|
// '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, 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
// pieceDragStart { uid, piece, from } · pieceDrop { uid, piece, from, target } · pieceDragEnd {uid, dropped}
// 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 (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).
// tileHover { row, col, area, idx } | null (while dragging: the drop target — the tile under the pointer)
// view.pieceScreenRect(uid) → { left, top, right, bottom, width, height, x, y } (client px: the drawn body) | null
// Picking (user playtest #4 item 1: the ground is drawn as tiles — a press on a tile is a press on the unit standing
// there): every "which unit is under the pointer" — prep press / click / detail / drag start / hover (pieceAt), battle
// clicks and hover (battleUnitAt), the pen (penUnitAt) — is render/pick.js over the tile under the pointer (groundTile:
// raised tops first); battle enemies, which walk between tiles, by their ground position (flying ones by their drawn
// body). A dragged piece is held under the pointer — a unit with its drawn feet DRAG_HOLD_TILES below it (the pointer
// on its body), an item plate centred on it — and drops on the tile under the pointer (render/drag.js); an item
// dropped on a unit's tile equips that unit.
// 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
@@ -95,7 +93,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';
import { pickOnTile, pickBattle } from './pick.js';
const VENDOR = { pixi: '/vendor/pixi.min.js', spine: '/vendor/pixi-spine.js' };
const PIECE_DIRS = new Set(['UP', 'RIGHT', 'DOWN', 'LEFT']);
@@ -112,20 +110,26 @@ 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;
/**
* A dragged unit is held with its drawn feet this many tiles below the pointer — the pointer on its body, the model
* under the finger / mouse (user playtest #4 item 1: as in v2.1; mouse and touch alike). An item plate is centred on it.
*/
export const DRAG_HOLD_TILES = 0.45;
/** 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;
/**
* A view as a render/pick.js unit, or null when it cannot be picked (gone, faded out, dead): standing on the display
* tile it is drawn on, or — `walks` (battle enemies) — by its ground position and its drawn body (feet to head, on
* screen; a flying one by its body alone).
*/
function pickUnitOf(v, walks = false) {
// a knocked-out operator lying on its tile waiting to redeploy (b.snap `down`, user playtest #4 item 9) is on that tile
// too: a press there selects it; other dead / dying views are gone
if (!v || v.destroyed || (v.alive === false && !v.down)) return null;
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;
const sc = v.screen;
const body = walks && sc && sc.s > 0 && !v.culled ? { x: sc.x, top: Number.isFinite(sc.top) ? sc.top : sc.y, feet: sc.y, s: sc.s } : null;
const tile = walks ? null : { row: Math.round(v.y), col: Math.round(v.x) };
return { tile, x: v.x, y: v.y, fly: walks && !!v.flying, body, depth: v.root && !v.root.destroyed ? v.root.zIndex : 0, ref: v };
}
let pixiPromise = null;
@@ -921,12 +925,12 @@ export async function createFieldView(host, options = {}) {
penList = null;
}
/** The pen enemy under a canvas point (render/pick.js; the figures idle on their pen tiles). */
/** The pen enemy under a canvas point: a figure on the tile under it (render/pick.js; ≤ 3 idle per pen tile). */
function penUnitAt(x, y) {
if (penHidden) return null;
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));
const units = [];
for (const v of penViews.values()) { const u = pickUnitOf(v); if (u) units.push(u); }
const hit = pickOnTile(units, groundTile(x, y));
return hit ? hit.ref : null;
}
@@ -937,100 +941,28 @@ export async function createFieldView(host, options = {}) {
// ---- drag & pointer ----------------------------------------------------------------------------------------
/** The display tile under a canvas point (raised tops first), or null off the grid. */
/** The display tile under a canvas point (raised tops first) and the point on its top (world x, y), or null. */
function groundTile(x, y) {
const t = pickTile(cam, x, y, heightAt, tiles.levels);
return t ? { row: t.row, col: t.col } : null;
return t ? { row: t.row, col: t.col, x: t.x, y: t.y } : 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) {
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) {
/** The prep piece under a canvas point: the one on the tile under it (render/pick.js; board, bench and temp rows). */
function pieceAt(x, y) {
if (mode !== 'prep') return null;
const best = prepEntryAt(x, y, null, hover);
const units = [];
for (const e of prepPieces) {
const u = pickUnitOf(views.get(e.key));
if (u) { u.entry = e; units.push(u); }
}
const best = pickOnTile(units, groundTile(x, y))?.entry;
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, hover) => pieceAt(x, y, !!hover),
hitPiece: (x, y) => pieceAt(x, y),
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; }
},
@@ -1044,10 +976,8 @@ 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': dragHit = null; startDragVisual(payload); break;
case 'pieceDragStart': 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;
@@ -1111,11 +1041,18 @@ export async function createFieldView(host, options = {}) {
function moveDragVisual(p) {
if (!dragState) return;
const v = dragState.view;
// 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);
// held under the pointer: a unit with its drawn (lifted) feet DRAG_HOLD_TILES of its own px per tile below it (the
// scale at the pointer's ground, refined once at the feet), an item plate centred on it; the drop target
// (p.target, highlighted below) is the tile under the pointer
const item = v instanceof ItemView;
const g = cam.unproject(p.x, p.y, 0);
const hold = item ? 0 : DRAG_HOLD_TILES;
const s0 = g ? cam.scaleAt(g.x, g.y, 0) : cam.scale;
const t = pickTile(cam, p.x, p.y + hold * s0, heightAt, tiles.levels);
const z = t ? heightAt(t.row, t.col) : 0;
const w = cam.unproject(hx, hy, z);
const up = item ? z : z + (v.lift || 0) + (v.hover || 0);
let w = cam.unproject(p.x, p.y + hold * s0, up);
if (w && hold) w = cam.unproject(p.x, p.y + hold * cam.scaleAt(w.x, w.y, up), up) || w;
if (w) {
v.x = Math.max(-1, Math.min(21, w.x));
v.y = Math.max(-1, Math.min(19, w.y));
@@ -1167,15 +1104,15 @@ 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 };
}
/** 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 = [];
/** The battle unit under a canvas point (render/pick.js: an ally on the tile under it, an enemy near its ground). */
function battleUnitAt(x, y) {
const units = [];
for (const v of views.values()) {
if (!v.info || v.info.kind === 'device') continue;
const b = pickShapeOf(v, v.info.side === 'ally' ? viewTile(v) : null);
if (b) bodies.push(b);
const u = pickUnitOf(v, v.info.side !== 'ally');
if (u) units.push(u);
}
const hit = pickBody(bodies, x, y, groundTile(x, y), hover ? hoverProbeAt(x, y) : probeAt(x, y));
const hit = pickBattle(units, groundTile(x, y), x, y);
return hit ? hit.ref : null;
}
@@ -1206,7 +1143,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, true);
const v = battleUnitAt(ev.x, ev.y);
if (v !== hoverUnit) {
hoverUnit = v;
const info = v ? infos.get(v.id) || v.info : null;
@@ -1220,12 +1157,18 @@ export async function createFieldView(host, options = {}) {
const onPointerCancel = (e) => { if (!destroyed) drag.pointerCancel(evPayload(e)); };
const onPointerLeave = (e) => { if (!destroyed && !drag.dragging) drag.pointerLeave(evPayload(e)); if (hoverUnit) { hoverUnit = null; emit('pieceHover', { uid: null, unitId: null }); } };
const onContext = (e) => e.preventDefault();
// A finger is handled through the pointer events above only. The compatibility mouse events + click of a tap come
// after touchend, hit-tested at the finger again — where the tap may just have opened DOM UI: a tap on a unit's tile
// selects it and its underframe opens over the tile (clamped under the top bar on a phone), and the click pressed
// 撤退 / 出售 (user playtest #4 item 1 on a phone). Cancelling touchend drops them.
const onTouchEnd = (e) => { if (e.cancelable) e.preventDefault(); };
canvas.addEventListener('pointerdown', onPointerDown);
canvas.addEventListener('pointermove', onPointerMove);
canvas.addEventListener('pointerup', onPointerUp);
canvas.addEventListener('pointercancel', onPointerCancel);
canvas.addEventListener('pointerleave', onPointerLeave);
canvas.addEventListener('contextmenu', onContext);
canvas.addEventListener('touchend', onTouchEnd, { passive: false });
// ---- battle ---------------------------------------------------------------------------------------------
@@ -1436,6 +1379,7 @@ export async function createFieldView(host, options = {}) {
}
let renderT0Battle = null; // game time of the first rendered battle frame (spawn puffs skip the initial wave)
let downSeq = 0; // syncBattle pass counter: a view still marked down after a pass left the `down` list
function syncBattle(renderT) {
if (renderT0Battle == null) renderT0Battle = renderT;
interp.sample(renderT, sample);
@@ -1448,13 +1392,36 @@ export async function createFieldView(host, options = {}) {
if (!v) v = battleView(id) || createUnknown(id, s);
if (!v) continue;
if (!v._seen) { v._seen = true; v.fadeIn = 0; }
if (v.alive || v.info?.kind === 'device') v.sync(s);
if (v.alive || v.info?.kind === 'device') v.sync(s, renderT);
else if (v.dying > 0) { v.x = s.x; v.y = s.y; }
else if (s.anim !== ANIM.DIE && s.hp > 0) { v.revive?.(); v.sync(s); }
else if (s.anim !== ANIM.DIE && s.hp > 0) { v.revive?.(); v.sync(s, renderT); }
}
// knocked-out operators waiting to redeploy (b.snap `down`, user playtest #4 item 9): their view stays on the
// field knocked down under a redeploy ring (UnitView.setDown) — made on the spot for one already down when this
// field was entered; one that leaves the list without a redeploy fades out
const down = interp.downAt(renderT);
downSeq++;
if (down) {
for (const d of down) {
let v = views.get(d[0]);
let fresh = false;
if (!v) {
const info = infos.get(d[0]);
if (!info || info.side !== 'ally' || info.kind === 'device') continue;
gone.delete(d[0]);
v = battleView(d[0]);
fresh = true;
}
if (!v || typeof v.setDown !== 'function') continue;
v._seen = true;
v.setDown(d, renderT, fresh);
v._downSeq = downSeq;
}
}
const now = performance.now();
for (const [id, v] of views) {
if (sample.has(id)) continue;
if (v.down && v._downSeq !== downSeq) v.setDown(null, renderT);
if (sample.has(id) || v.down) continue;
if (v._seen) {
// left the snapshot: removed (leaked / hidden / died and its DIE window passed)
if (v.alive) { v.alive = false; v.dying = 0.25; v.dieDur = 0.25; }
@@ -1654,14 +1621,7 @@ 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;
// 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 };
return { left, top, right: left + b.width, bottom: top + b.height, width: b.width, height: b.height, x: left, y: top };
},
/**
* Screen geometry of a BOARD-space tile (in the Final Assault prep: where that board tile is drawn on the boss
@@ -1736,6 +1696,7 @@ export async function createFieldView(host, options = {}) {
canvas.removeEventListener('pointercancel', onPointerCancel);
canvas.removeEventListener('pointerleave', onPointerLeave);
canvas.removeEventListener('contextmenu', onContext);
canvas.removeEventListener('touchend', onTouchEnd);
app.ticker.remove(frame);
app.ticker.remove(preRender);
app.ticker.remove(postRender);
@@ -1746,8 +1707,6 @@ 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;
@@ -1768,8 +1727,8 @@ export async function createFieldView(host, options = {}) {
/** 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); },
// 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; },
// picking (render/pick.js) at canvas px: the prep piece / battle view / pen view there, the ground tile under it
pick: { pieceAt, battleUnitAt, penUnitAt, groundTile },
},
};
return view;