// Direction step of a board placement — the standard Arknights 4-direction deploy wheel (research 09 §1.2 / §6.1 5).
//
// After a legal drop of an operator / summon on a board tile (also its own tile: re-orient in place) — or of a
// directional Art (画卷) — the piece stays on the tile as a ghost and this overlay opens:
// * a white diamond centred on the tile, ~3 tiles across its diagonal, a smaller inner diamond (the centre
// dead-zone, ~0.5 tile) and 4 chevrons (UP / RIGHT / DOWN / LEFT);
// * a red "✕ 点击取消" tag on the diamond's upper-left edge;
// * press and drag from the centre: past the dead-zone that side's chevron lights and the unit's range grid, rotated
// to that direction (DESIGN §3), shows as orange striped tiles (view.highlightTiles group 'facing' under the units
// + a striped SVG layer — slotted between the 3D board canvas and the Pixi canvas when the 3D board is on, so the
// units stand on the stripes; an overlay otherwise); outside the centre the tooltip "拖回中心区域取消" shows;
// * on the mirrored right half of the Final Assault prep a screen direction maps to its board direction (RIGHT ↔
// LEFT, facing.js boardDir): the chevrons follow the finger, the range / model / intent use the board direction;
// * release outside the centre commits (g.move {uid, to, dir} / g.art {…, dir}); release inside the centre, a tap
// on ✕, a press outside the diamond or Esc cancels (the piece returns to where it came from). Arrow keys preview a
// direction and Enter commits it (keyboard alternative). Mouse, pen and touch all use pointer events.
//
// View bridge: the field view (render/app.js or the DOM fallback) may implement `tileScreen(row, col)`,
// `holdPiece(uid, tile|null)` and `setPieceDir(uid, dir)` (render/app.js does, DESIGN §9); a view without them falls
// back to the engine's dev hooks (`view.debug.cam / tiles / views / app`) for the same effect (projection of tile corners, pinning
// the dropped piece's view on the target tile, re-orienting its model). All of it is guarded: a missing hook only
// loses the visual, never the interaction.
import { useEffect, useLayoutEffect, useRef, useState } from '../../vendor/hooks.module.js';
import { html } from './components.js';
import { LocalSprite } from './gameComponents.js';
import { DIRS, DIR_LABEL, DEAD_ZONE_TILES, dirFromDelta, dirFromKey, rangeTiles, normDir, boardDir, viewMirrored } from './facing.js';
const cx = (...p) => p.flat().filter(Boolean).join(' ');
const rawOf = (view) => (view && view.raw) || view || null;
/** Range highlight of the wheel / a selected unit (orange, drawn under the units by the render engine). */
export const FACING_STYLE = Object.freeze({ group: 'facing', color: 0xff9c33, fill: 0.36, line: 1 });
// ---- view bridge ---------------------------------------------------------------------------------------------------
/**
* Screen geometry of a tile's top face, in client px: centre, px per tile at that depth, the 4 corners.
* @returns {{ x: number, y: number, s: number, poly: Array<[number, number]> } | null}
*/
export function tileScreen(view, row, col) {
const raw = rawOf(view);
if (!raw || !Number.isInteger(row) || !Number.isInteger(col)) return null;
try {
if (typeof raw.tileScreen === 'function') {
const r = raw.tileScreen(row, col);
if (r && Number.isFinite(r.x) && Number.isFinite(r.y)) return r;
}
const dbg = raw.debug;
const cam = dbg && dbg.cam;
const canvas = dbg && dbg.app && dbg.app.view;
if (cam && typeof cam.project === 'function' && canvas && typeof canvas.getBoundingClientRect === 'function') {
const z = dbg.tiles && typeof dbg.tiles.heightAt === 'function' ? dbg.tiles.heightAt(row, col) : 0;
const cr = canvas.getBoundingClientRect();
const c = cam.project(col, row, z);
const corner = (dx, dy) => { const p = cam.project(col + dx, row + dy, z); return [cr.left + p.x, cr.top + p.y]; };
return { x: cr.left + c.x, y: cr.top + c.y, s: c.s, poly: [corner(-0.5, 0.5), corner(0.5, 0.5), corner(0.5, -0.5), corner(-0.5, -0.5)] };
}
} catch { /* no geometry: the caller hides its visuals */ }
return null;
}
/** Per-view pins of the engine fallback (dev hooks): uid → { row, col }. */
const pins = new WeakMap();
function pinLoop(raw) {
const st = pins.get(raw);
if (!st || st.raf) return;
const step = () => {
st.raf = 0;
if (!st.map.size) return;
try {
const views = raw.debug && raw.debug.views;
const heightAt = raw.debug && raw.debug.tiles && raw.debug.tiles.heightAt ? (r, c) => raw.debug.tiles.heightAt(r, c) : () => 0;
for (const [uid, t] of st.map) {
const v = views && views.get('p:' + uid);
if (!v || v.destroyed) continue;
const w = { x: t.col, y: t.row, z: heightAt(t.row, t.col) };
v._home = w;
if (!v._tween || v._tween.tx !== w.x || v._tween.ty !== w.y) {
if (Math.abs(v.x - w.x) + Math.abs(v.y - w.y) > 1e-3) v._tween = { fx: v.x, fy: v.y, fz: v.z, tx: w.x, ty: w.y, tz: w.z, t: 0 };
else v._tween = null;
}
v.lift = 0;
}
} catch { /* view torn down */ }
st.raf = requestAnimationFrame(step);
};
st.raf = requestAnimationFrame(step);
}
/**
* Keep a dropped piece standing on `tile` while its direction is being chosen / the move is in flight (`tile` null
* releases it; the caller re-applies the prep state so the piece settles where the server says).
*/
export function holdPiece(view, uid, tile) {
const raw = rawOf(view);
if (!raw || !Number.isInteger(uid)) return;
try {
if (typeof raw.holdPiece === 'function') { raw.holdPiece(uid, tile || null); return; }
} catch { return; }
if (!raw.debug || !raw.debug.views) return;
let st = pins.get(raw);
if (!st) { st = { map: new Map(), raf: 0 }; pins.set(raw, st); }
if (tile && Number.isInteger(tile.row) && Number.isInteger(tile.col)) {
st.map.set(uid, { row: tile.row, col: tile.col });
pinLoop(raw);
} else {
st.map.delete(uid);
if (!st.map.size && st.raf) { cancelAnimationFrame(st.raf); st.raf = 0; }
}
}
/** Show a piece facing `dir` (live wheel preview, or the stored m.private `dir`). */
export function setPieceDir(view, uid, dir) {
const raw = rawOf(view);
if (!raw || !Number.isInteger(uid)) return;
try {
if (typeof raw.setPieceDir === 'function') { raw.setPieceDir(uid, normDir(dir)); return; }
const v = raw.debug && raw.debug.views && raw.debug.views.get('p:' + uid);
if (v && typeof v.setDir === 'function') v.setDir(normDir(dir));
} catch { /* cosmetic only */ }
}
/**
* Apply the stored facing of every own piece (board: `piece.dir`, default RIGHT; bench: RIGHT) to the view, except
* `skip` (the piece whose direction the wheel is previewing).
*/
export function syncPieceDirs(view, priv, skip = null) {
if (!view || !priv) return;
for (const p of Array.isArray(priv.board) ? priv.board : []) if (p && p.uid !== skip) setPieceDir(view, p.uid, p.dir);
for (const list of [priv.hand, priv.temp]) for (const p of Array.isArray(list) ? list : []) if (p && p.uid !== skip && p.kind !== 'item') setPieceDir(view, p.uid, 'RIGHT');
}
/** Light the rotated range of a piece on (row, col) facing `dir` (no grid / dir clears the style's group). */
export function showRange(view, grid, row, col, dir, style = FACING_STYLE) {
if (!view || typeof view.highlightTiles !== 'function') return [];
const tiles = grid && dir ? rangeTiles(grid, row, col, dir) : [];
try { view.highlightTiles(tiles, style); } catch { /* cosmetic */ }
return tiles;
}
/** Follow a tile's screen position (the camera may still be moving, the window may resize). */
export function useTileScreen(view, row, col) {
const [g, setG] = useState(() => tileScreen(view, row, col));
const last = useRef('');
useEffect(() => {
let raf = 0;
let alive = true;
// no tile (nothing selected): nothing to follow
if (!view || !Number.isInteger(row) || !Number.isInteger(col)) { last.current = ''; setG(null); return undefined; }
const tick = () => {
if (!alive) return;
const t = tileScreen(view, row, col);
const key = t ? `${t.x.toFixed(1)},${t.y.toFixed(1)},${t.s.toFixed(2)}` : '';
if (key !== last.current) { last.current = key; setG(t); }
raf = requestAnimationFrame(tick);
};
tick();
return () => { alive = false; cancelAnimationFrame(raf); };
}, [view, row, col]);
return g;
}
// ---- the wheel -------------------------------------------------------------------------------------------------------
/** Chevron centre (viewBox units, diamond half-diagonal = 100) and rotation per direction. */
const CHEV = { UP: [0, -64, -90], RIGHT: [64, 0, 0], DOWN: [0, 64, 90], LEFT: [-64, 0, 180] };
function Chevron({ dir, on }) {
const [x, y, rot] = CHEV[dir];
return html`