Online co-op browser remake of Arknights' Stronghold Protocol: Alliance (client-simulated combat, official waves/bonds/items/strategies, 3D board, facing wheel, operator loadout, official emotes, deployment scripts).
240 lines
12 KiB
JavaScript
240 lines
12 KiB
JavaScript
// Facing (direction) rules of the prep board — pure logic, no DOM (research 09 §1.2, DESIGN §3 "Facing (corrected)").
|
||
//
|
||
// Every board piece has a direction `dir ∈ UP|RIGHT|DOWN|LEFT`, chosen with the standard Arknights 4-direction deploy
|
||
// wheel after a legal drop on a board tile (also the piece's own tile: re-orienting in place). Range grids are
|
||
// `[dRow, dCol]` offsets relative to facing RIGHT (row 0 = the bottom row, nearest the camera) and rotate as
|
||
// RIGHT (dr, dc) · UP (dc, −dr) · LEFT (−dr, −dc) · DOWN (−dc, dr).
|
||
// Screen space of the wheel: +x right, +y down; the camera looks "up" the board, so screen-up = towards higher rows =
|
||
// UP. A swipe shorter than the dead-zone (~0.5 tile) chooses nothing (release there = cancel). On the mirrored right
|
||
// half of the Final Assault prep a screen direction maps to its board direction through `boardDir(dir, mirror)`.
|
||
//
|
||
// Also here: which drops enter the direction step, which range grid previews, and the underframe (selection diamond)
|
||
// actions of a tapped piece — 撤退 / 出售 +N on a board operator, 撤退 on a board summon, 出售 +N on a bench operator,
|
||
// 销毁 on items and Arts (research 09 §5 / §6.5).
|
||
|
||
import { GEO } from '../../../shared/constants.js';
|
||
import { attackRangeGrid } from '../../../shared/loadoutRecord.js';
|
||
|
||
export const DIRS = Object.freeze(['UP', 'RIGHT', 'DOWN', 'LEFT']);
|
||
export const DEFAULT_DIR = 'RIGHT';
|
||
/** Dead-zone radius of the wheel, in tiles (research 09 §1.2: ~0.5 tile). */
|
||
export const DEAD_ZONE_TILES = 0.5;
|
||
|
||
/** Row / column unit step of a direction (row 0 = bottom). */
|
||
export const DIR_VEC = Object.freeze({
|
||
UP: Object.freeze({ dr: 1, dc: 0 }),
|
||
RIGHT: Object.freeze({ dr: 0, dc: 1 }),
|
||
DOWN: Object.freeze({ dr: -1, dc: 0 }),
|
||
LEFT: Object.freeze({ dr: 0, dc: -1 }),
|
||
});
|
||
|
||
/** Chinese label (for aria / tooltips). */
|
||
export const DIR_LABEL = Object.freeze({ UP: '上', RIGHT: '右', DOWN: '下', LEFT: '左' });
|
||
|
||
/** Normalise any direction spelling ('up', 'Right', 1/−1 facing) to UP|RIGHT|DOWN|LEFT; unknown → `fallback`. */
|
||
export function normDir(d, fallback = DEFAULT_DIR) {
|
||
if (typeof d === 'string') {
|
||
const u = d.trim().toUpperCase();
|
||
if (DIRS.includes(u)) return u;
|
||
}
|
||
if (d === -1) return 'LEFT';
|
||
if (d === 1) return 'RIGHT';
|
||
return fallback;
|
||
}
|
||
|
||
/** Rotate a range offset given relative to facing RIGHT (DESIGN §3). */
|
||
export function rotateOffset(dr, dc, dir) {
|
||
const z = (v) => (v === 0 ? 0 : v); // no −0
|
||
switch (normDir(dir)) {
|
||
case 'UP': return [z(dc), z(-dr)];
|
||
case 'LEFT': return [z(-dr), z(-dc)];
|
||
case 'DOWN': return [z(-dc), z(dr)];
|
||
default: return [z(dr), z(dc)];
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Absolute board tiles of a range grid for a piece standing on (row, col) facing `dir` (clipped to the stage grid,
|
||
* deduplicated). `extend` adds columns along +dCol before rotating (rangeExtend, DESIGN §3).
|
||
* @param {Array<[number, number]>} grid
|
||
* @returns {Array<[number, number]>}
|
||
*/
|
||
export function rangeTiles(grid, row, col, dir, { extend = 0, rows = GEO.ROWS, cols = GEO.COLS } = {}) {
|
||
const out = [];
|
||
const seen = new Set();
|
||
const list = Array.isArray(grid) ? grid : [];
|
||
const ext = Number.isInteger(extend) && extend > 0 ? extend : 0;
|
||
const push = (dr, dc) => {
|
||
const [rr, rc] = rotateOffset(dr, dc, dir);
|
||
const r = row + rr, c = col + rc;
|
||
if (r < 0 || r >= rows || c < 0 || c >= cols) return;
|
||
const k = `${r},${c}`;
|
||
if (seen.has(k)) return;
|
||
seen.add(k);
|
||
out.push([r, c]);
|
||
};
|
||
for (const g of list) {
|
||
if (!Array.isArray(g) || !Number.isFinite(g[0]) || !Number.isFinite(g[1])) continue;
|
||
push(g[0], g[1]);
|
||
}
|
||
if (ext) {
|
||
// rangeExtend: every row of the grid grows by `ext` columns at its far (+dCol) end
|
||
const rowsOf = new Map();
|
||
for (const g of list) if (Array.isArray(g) && Number.isFinite(g[0]) && Number.isFinite(g[1])) rowsOf.set(g[0], Math.max(rowsOf.get(g[0]) ?? -Infinity, g[1]));
|
||
for (const [dr, far] of rowsOf) for (let k = 1; k <= ext; k++) push(dr, far + k);
|
||
}
|
||
return out;
|
||
}
|
||
|
||
/**
|
||
* Direction of a swipe from the wheel centre. `dx`, `dy` in screen px (+y down); `dead` = dead-zone radius in px.
|
||
* The dominant axis decides (ties go to the horizontal axis, where the lanes are).
|
||
* @returns {'UP'|'RIGHT'|'DOWN'|'LEFT'|null}
|
||
*/
|
||
export function dirFromDelta(dx, dy, dead = 0) {
|
||
if (!Number.isFinite(dx) || !Number.isFinite(dy)) return null;
|
||
if (Math.hypot(dx, dy) <= Math.max(0, dead)) return null;
|
||
if (Math.abs(dx) >= Math.abs(dy)) return dx >= 0 ? 'RIGHT' : 'LEFT';
|
||
return dy < 0 ? 'UP' : 'DOWN';
|
||
}
|
||
|
||
const MIRROR = Object.freeze({ UP: 'UP', RIGHT: 'LEFT', DOWN: 'DOWN', LEFT: 'RIGHT' });
|
||
|
||
/**
|
||
* Board direction of a direction picked on screen (the wheel's chevrons / arrow keys are screen-oriented). On the
|
||
* right half of the Final Assault prep the board is drawn mirrored (research 09 §1.2: col c → 20 − c, RIGHT ↔ LEFT;
|
||
* render/prepfield.js), so a swipe to the screen's right means board LEFT there; UP / DOWN are unchanged. The mapping
|
||
* is its own inverse: `boardDir(boardDir(d, m), m) === d` (also the screen chevron of a board direction).
|
||
* @param {'UP'|'RIGHT'|'DOWN'|'LEFT'|null} dir
|
||
* @param {boolean} mirror `tileScreen(row, col).mirror` / `view.prepField().mirror` of the render view
|
||
*/
|
||
export function boardDir(dir, mirror = false) {
|
||
if (dir == null) return null;
|
||
const d = normDir(dir, null);
|
||
if (!d) return null;
|
||
return mirror ? MIRROR[d] : d;
|
||
}
|
||
|
||
/** Whether the view draws the prep board mirrored (right-hand Final Assault prep); false for views without the hook. */
|
||
export function viewMirrored(view) {
|
||
const raw = view && (view.raw || view);
|
||
try { return !!(raw && typeof raw.prepField === 'function' && raw.prepField()?.mirror); } catch { return false; }
|
||
}
|
||
|
||
/** Direction of an arrow key (keyboard alternative of the swipe). */
|
||
export function dirFromKey(key) {
|
||
return { ArrowUp: 'UP', ArrowRight: 'RIGHT', ArrowDown: 'DOWN', ArrowLeft: 'LEFT' }[key] || null;
|
||
}
|
||
|
||
const isObj = (v) => !!v && typeof v === 'object';
|
||
|
||
/** The piece's stored facing (m.private board pieces carry `dir`; absent ⇒ RIGHT, the server default). */
|
||
export const pieceDir = (piece) => normDir(isObj(piece) ? piece.dir : null);
|
||
|
||
/**
|
||
* Range grid previewed by the wheel / on a selected unit: the operator's own grid (heal range for medics — the data
|
||
* grid is the one the unit uses; an elite's equipped "攻击范围扩大" module grid, DESIGN §16 — `chessRecord` resolves
|
||
* the player's loadout), a summon's grid, an Art's grid (画卷 `1-1`). Null when there is nothing to show.
|
||
* @param {{ getChess:(id:string)=>any, getToken:(id:string)=>any, getItem:(id:string)=>any, chessRecord?:(rec:any)=>any }} lookups
|
||
*/
|
||
export function previewGrid(lookups, piece) {
|
||
if (!isObj(piece) || !lookups) return null;
|
||
let rec = null;
|
||
if (piece.kind === 'chess') {
|
||
rec = lookups.getChess?.(piece.id);
|
||
if (rec && typeof lookups.chessRecord === 'function') { try { rec = lookups.chessRecord(rec) || rec; } catch { /* the data record */ } }
|
||
} else if (piece.kind === 'token') rec = lookups.getToken?.(piece.id);
|
||
else if (piece.kind === 'item') { rec = lookups.getItem?.(piece.id); if (rec?.itemType !== 'MAGIC') return null; }
|
||
const grid = !rec ? null : piece.kind === 'chess' ? attackRangeGrid(rec) : (Array.isArray(rec.rangeGrid) ? rec.rangeGrid : null);
|
||
return grid && grid.length ? grid : null;
|
||
}
|
||
|
||
/** Whether an Art is placed with a direction (its range reaches beyond its own tile, e.g. 画卷 `[[0,0],[0,1]]`). */
|
||
export function artHasFacing(item) {
|
||
const g = Array.isArray(item?.rangeGrid) ? item.rangeGrid : [];
|
||
return g.some((x) => Array.isArray(x) && (x[0] !== 0 || x[1] !== 0));
|
||
}
|
||
|
||
/**
|
||
* Whether dropping `uid` on `target` enters the direction step (instead of sending the intent at once): operators and
|
||
* summons dropped on a board tile (move, swap or their own tile), and directional Arts used on a tile.
|
||
* @param {{ pieces: Map<number, any>, getItem?: (id:string)=>any }} ctx placementContext (gameLogic.js)
|
||
* @param {number} uid
|
||
* @param {any} target
|
||
* @param {{ ok: boolean, action?: string }} res canPlace(ctx, uid, target)
|
||
*/
|
||
export function needsFacing(ctx, uid, target, res) {
|
||
if (!res || !res.ok || !isObj(target) || target.area !== 'board') return false;
|
||
const piece = ctx?.pieces?.get(uid)?.piece;
|
||
if (!piece) return false;
|
||
if (piece.kind === 'chess' || piece.kind === 'token') return res.action === 'move' || res.action === 'swap' || res.action === 'orient';
|
||
if (piece.kind === 'item' && res.action === 'art') return artHasFacing(ctx.getItem?.(piece.id));
|
||
return false;
|
||
}
|
||
|
||
/**
|
||
* The intent that commits a direction step. g.move carries the direction twice: top-level `dir` (the protocol field)
|
||
* and `to.dir`, which server/match/PlayerState.move reads when the top-level one does not reach it — Match.handle
|
||
* currently forwards only `msg.to` (`ps.move(msg.uid, msg.to)`), and without the copy every placement was stored
|
||
* facing RIGHT and the model snapped back after the wheel. g.art has no such fallback (see the review report).
|
||
* @returns {{ t: 'g.move', fields: { uid, to: { area, row, col, dir }, dir } } | { t: 'g.art', fields: { itemUid, row, col, dir } }}
|
||
*/
|
||
export function facingIntent(piece, target, dir) {
|
||
const d = normDir(dir);
|
||
if (piece?.kind === 'item') return { t: 'g.art', fields: { itemUid: piece.uid, row: target.row, col: target.col, dir: d } };
|
||
return { t: 'g.move', fields: { uid: piece.uid, to: { area: 'board', row: target.row, col: target.col, dir: d }, dir: d } };
|
||
}
|
||
|
||
/**
|
||
* Whether 销毁 may be offered for item `uid`: a loose item in the hand or the temp slots. Items equipped on an operator
|
||
* (a piece's `items`, or an entry some other index marks `equipped`) are locked — g.destroy would be refused.
|
||
* @param {{ pieces: Map<number, any> }} ctx placementContext / indexPieces view
|
||
* @param {number} uid
|
||
*/
|
||
export function itemDestroyable(ctx, uid) {
|
||
const e = ctx?.pieces?.get(uid);
|
||
if (!e || e.piece?.kind !== 'item' || e.equipped) return false;
|
||
if (e.area !== 'hand' && e.area !== 'temp') return false;
|
||
for (const o of ctx.pieces.values()) {
|
||
if (o.piece?.kind === 'chess' && Array.isArray(o.piece.items) && o.piece.items.some((it) => it && it.uid === uid)) return false;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
/**
|
||
* Underframe (selection diamond) of a tapped own piece (research 09 §1.2 / §5): the buttons it offers.
|
||
* board operator → 撤退 (to the bench) + 出售 +N · board summon → 撤退 · bench / temp operator → 出售 +N ·
|
||
* item or Art (bench / temp) → 销毁 · bench summon → nothing.
|
||
* @param {{ pieces: Map<number, any>, getChess?: (id:string)=>any }} ctx
|
||
* @returns {{ retreat: boolean, sell: number|null, destroy: boolean } | null} sell = the price shown ("+N")
|
||
*/
|
||
export function underframeActions(ctx, uid) {
|
||
const e = ctx?.pieces?.get(uid);
|
||
if (!e) return null;
|
||
const p = e.piece;
|
||
const onBoard = e.area === 'board';
|
||
// only a loose item (bench / temp) can be destroyed: an equipped one is locked (the server refuses g.destroy —
|
||
// research 04 §2; replacing it is the equip-replace dialog's job, g.equip replaceUid)
|
||
if (p.kind === 'item') return itemDestroyable(ctx, uid) ? { retreat: false, sell: null, destroy: true } : null;
|
||
if (p.kind === 'token') return onBoard ? { retreat: true, sell: null, destroy: false } : null;
|
||
if (p.kind !== 'chess') return null;
|
||
const price = ctx.getChess?.(p.id)?.sellPrice;
|
||
return { retreat: onBoard, sell: Number.isFinite(price) ? price : 1, destroy: false };
|
||
}
|
||
|
||
/**
|
||
* Where 撤退 puts a board piece: the first free bench slot (a summon goes back onto its stack — any slot works; an
|
||
* operator whose own summon stacks sit on the bench frees them). Null when the bench is full.
|
||
* @param {{ pieces: Map<number, any>, handAt: Map<number, any> }} ctx
|
||
*/
|
||
export function retreatSlot(ctx, uid) {
|
||
const e = ctx?.pieces?.get(uid);
|
||
if (!e || e.area !== 'board') return null;
|
||
for (let i = 0; i < GEO.HAND_SIZE; i++) if (!ctx.handAt?.has(i)) return { area: 'hand', idx: i };
|
||
if (e.piece.kind === 'token') return { area: 'hand', idx: 0 };
|
||
for (const [i, h] of ctx.handAt || []) {
|
||
if (h?.piece?.kind === 'token' && h.piece.ownerUid === e.piece.uid) return { area: 'hand', idx: i };
|
||
}
|
||
return null;
|
||
}
|