Files
ark-stronghold-protocol/public/js/render/pick.js
T
sganggs 15291c2293 Playtest #5 fixes: hovering enemies, target order, blocking hand-over, 联防 knocked-out, elements, huge-boss hit area, maps, gear icon, phone HUD, 至简 price
- #1 近地悬浮 enemies count as air units (Unit.isFlying): ground-only attacks, splash, terrain and traps skip them;
  PRTS 不可对空 skill effects made ground-only; 掠海漂移体 / 吉兆飞鳞 drop modes; the bot plans them as air
- #2 an operator knocked out before 联防 is fielded and force-exited (PRTS 强制退场): it lies on its tile with the
  redeploy ring and redeploys (full timer [ASSUMED]); partners, observers and the server fallback included
- #3 element audit of every operator, item and enemy: 海嗣 numbers are official; bursts are 无来源, 元素脆弱 only
  raises 元素伤害, 元素抗性 applied, 盟约·辅助干员 fills all three gauges, 菲莱 / 哈洛德 / 纯烬 / enemy kits fixed
- #4 blocking by the official contact radius (0.7071): another operator in contact takes over at once when the
  blocker dies, withdraws or is stunned; melee blockers attack what they block
- #5 enemy target order per PRTS 索敌: blocker, special priority, taunt, last deployed; initial deploy 从上到下>从左到右,
  operators before summons; special priorities break ties by taunt then deploy order
- #6 act2 m01's normal-field blower machines (row 13) are drawn in the normal / 联防 views
- #7 Final Assault prep legality reads the player's half of the boss field (fence tiles deployable); all 11 stages
  audited against the official level files
- #8 regular settings gear; #9 the prep camera keeps the bench and temp rows clear of the shop bar on phones
- #10 huge bosses have the official 4.95 × 2.95 hit area (sim/body.js) and are 自缚 / unblockable
- #11 至简 costs 1: 购买价格为N's bb.price is a discount off the tier price, applied before other price effects
- DESIGN §19 and the normative §3 / §5 / §6 / §7 lines, SIM / META / DATA / PLAYING updated; 2595 unit tests and
  every browser suite green; dev recordings regenerated
2026-09-30 19:12:36 +08:00

126 lines
7.6 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// render/pick.js — which unit the pointer is on (user playtest #4 item 1: "地上都画好了一个一个方格,点击对应方格就选中那个
// 方格的人物就行" — the ground is drawn as tiles: a press on a tile selects the unit on that tile). Pure logic (no PIXI, no
// DOM): render/app.js hands in the units and the ground under the pointer (render/projection.js pickTile, raised tops
// first, in the tiles the views are drawn on). The one rule behind every picking path — prep press / click / right-click
// and long-press detail / drag start / hover (app.js pieceAt), battle clicks and hover (battleUnitAt), the enemy preview
// pen (penUnitAt); a dragged piece (an item too) drops on the tile under the pointer (render/drag.js):
// * prep pieces (board tiles, bench row 7, temp row 8), battle allies and the figures of the enemy pen stand on
// tiles: the unit on the tile under the pointer. An empty tile selects nothing — a press on a unit's head where it is
// drawn over the tile behind it is a press on that tile. Several on one tile (the pen's clusters of up to 3, a piece
// held for a moment on an occupied tile) → the one nearest to the pointer's point on the tile, ties → the front-most.
// * battle enemies walk between tiles: the one within ENEMY_REACH tile of the pointer — its ground position from the
// pointer's point on the ground, or its drawn body (on screen: the upright line from its feet to its head, in its px
// per tile), whichever is nearer, so a press on a tall enemy's / a boss's body picks it too; a flying enemy (drawn in
// the air, over whatever tile it crosses) by its drawn body alone.
// * a huge boss (巨型单位, data `hitArea` — the sim's hit rectangle, server/sim/body.js; user playtest #5 item 10) is
// also picked by a press anywhere on its drawn body — its hit area on the ground (the 5 × 3 tiles under the model)
// or the upright box from its feet to its head as wide as that area — counted as AREA_PICK tile away, so an ally
// standing on the pressed tile, or a regular enemy near the pointer, still wins.
// * an ally's tile and an enemy both qualify → the nearer one (distance in tiles to the pointer's point), ties → the
// ally, then the front-most.
// (Replaces the drawn-body shapes and the 1-px render probe of v2.2, DESIGN §17.2: the player aims at the grid.)
/** Battle enemies are picked within this many tiles of the pointer's ground point or of their drawn body on screen. */
export const ENEMY_REACH = 0.6;
/**
* A press on a huge boss's hit area (not near its feet / drawn body line) counts as this far — farther than any ally
* on the pressed tile (≤ √0.5) or any enemy within ENEMY_REACH, so those win [ASSUMED].
*/
export const AREA_PICK = 1;
/**
* World rectangle (tiles: x = col, y = row) of a hit area `{ w, h, dx, dy }` for a unit at (x, y) — the same rectangle
* as server/sim/body.js hitRect: `w` along the columns × `h` along the rows, centred `dx` columns right / `dy` rows up.
* @returns {{ x0: number, x1: number, y0: number, y1: number } | null}
*/
export function hitRectAt(x, y, a) {
if (!a || !(a.w > 0) || !(a.h > 0) || !Number.isFinite(x) || !Number.isFinite(y)) return null;
const cx = x + (Number(a.dx) || 0), cy = y + (Number(a.dy) || 0);
return { x0: cx - a.w / 2, x1: cx + a.w / 2, y0: cy - a.h / 2, y1: cy + a.h / 2 };
}
const inArea = (A, g) => !!A && !!g && g.x >= A.x0 && g.x <= A.x1 && g.y >= A.y0 && g.y <= A.y1;
/** Screen point inside a huge body's upright box: `hw` tiles (its px per tile) either side of its x, feet up to head. */
const inBox = (b, px, py) => !!b && b.hw > 0 && b.s > 0 && Math.abs(px - b.x) <= b.hw * b.s
&& py >= Math.min(b.top, b.feet) && py <= Math.max(b.top, b.feet);
const EPS = 1e-9;
const isTile = (t) => !!t && Number.isInteger(t.row) && Number.isInteger(t.col);
const onTile = (u, t) => isTile(u.tile) && isTile(t) && u.tile.row === t.row && u.tile.col === t.col;
const depthOf = (u) => (u && Number.isFinite(u.depth) ? u.depth : 0);
/** Tiles between a unit's ground position and the pointer's point on the ground (0 when either is unknown). */
function groundDist(u, g) {
if (!g || !Number.isFinite(g.x) || !Number.isFinite(g.y) || !Number.isFinite(u.x) || !Number.isFinite(u.y)) return 0;
return Math.hypot(u.x - g.x, u.y - g.y);
}
/** Tiles (its px per tile) between screen point (px, py) and an enemy's drawn body — the line from its feet up to its head. */
function bodyDist(b, px, py) {
if (!b || !(b.s > 0) || ![b.x, b.top, b.feet, px, py].every(Number.isFinite)) return Infinity;
const y = Math.max(Math.min(b.top, b.feet), Math.min(Math.max(b.top, b.feet), py));
return Math.hypot(px - b.x, py - y) / b.s;
}
/**
* @typedef {{ row: number, col: number, x?: number, y?: number }} PickGround the tile under the pointer and the pointer's
* point on its top (world tiles: x = col, y = row), or null off the grid
* @typedef {{ tile?: { row: number, col: number } | null, x?: number, y?: number, depth?: number, fly?: boolean,
* body?: { x: number, top: number, feet: number, s: number, hw?: number } | null,
* area?: { x0: number, x1: number, y0: number, y1: number } | null }} PickUnit
* `tile`: the tile it stands on (null: it walks — a battle enemy); `x`, `y`: its ground position (world tiles);
* `body`: an enemy's drawn body on screen (px: x, head `top`, `feet`; `s` px per tile there; `hw`: a huge boss's half
* width in tiles); `fly`: in the air — its
* ground position does not count; `area`: a huge boss's hit rectangle on the ground (world tiles, hitRectAt);
* `depth`: draw order (larger = in front)
*/
/**
* The unit standing on the tile under the pointer (several → the nearest to the pointer's point, ties → the front-most).
* @param {Array<PickUnit|null>} units
* @param {PickGround|null} ground
* @returns {PickUnit|null} one of `units`
*/
export function pickOnTile(units, ground) {
if (!Array.isArray(units) || !isTile(ground)) return null;
let best = null, bd = Infinity;
for (const u of units) {
if (!u || !onTile(u, ground)) continue;
const d = groundDist(u, ground);
if (d < bd - EPS || (Math.abs(d - bd) <= EPS && depthOf(u) > depthOf(best))) { best = u; bd = d; }
}
return best;
}
/**
* Battle pick (see the header): allies on the tile under the pointer, enemies within ENEMY_REACH of it; the nearer wins.
* @param {Array<PickUnit|null>} units allies with `tile`, enemies with `tile: null` and their drawn `body`
* @param {PickGround|null} ground
* @param {number} px pointer on screen (enemies' drawn bodies)
* @param {number} py
* @returns {PickUnit|null} one of `units`
*/
export function pickBattle(units, ground, px, py) {
if (!Array.isArray(units)) return null;
const onGround = !!ground && Number.isFinite(ground.x) && Number.isFinite(ground.y);
let best = null, bd = Infinity;
const better = (u, d) => {
if (d < bd - EPS) return true;
if (Math.abs(d - bd) > EPS) return false;
if (!!u.tile !== !!best.tile) return !!u.tile; // a tie: the ally standing there
return depthOf(u) > depthOf(best);
};
for (const u of units) {
if (!u) continue;
let d = Infinity;
if (u.tile) { if (onTile(u, ground)) d = groundDist(u, ground); }
else {
d = bodyDist(u.body, px, py);
if (!u.fly && onGround && Number.isFinite(u.x) && Number.isFinite(u.y)) d = Math.min(d, Math.hypot(u.x - ground.x, u.y - ground.y));
if (!(d <= ENEMY_REACH)) {
if (u.fly || !(inBox(u.body, px, py) || (onGround && inArea(u.area, ground)))) continue;
d = AREA_PICK;
}
}
if (d < Infinity && better(u, d)) { best = u; bd = d; }
}
return best;
}