public/js/ui/gameLogic.js becomes a re-export barrel. The bodies move verbatim into public/js/ui/gameLogic/*.js (shared, phases, camera, watch, bonds, shop, placement, draft, enemies, format, shortcuts, settings, result, loadout, panel). The barrel re-exports the same 96 names in the same order. isObj and int gain export on shared.js so sibling modules can import them; they stay off the barrel. No importer changes.
97 lines
5.1 KiB
JavaScript
97 lines
5.1 KiB
JavaScript
// ui/gameLogic/enemies.js — enemy groups, factions, the preview pen. Re-exported from ../gameLogic.js.
|
||
|
||
import { layoutPen } from '../../render/pen.js';
|
||
import { isObj } from './shared.js';
|
||
|
||
|
||
// ---- enemies ------------------------------------------------------------------------------------------------
|
||
|
||
const TAG_ORDER = { boss: 0, bounty: 1, escort: 2 };
|
||
const RANK_ORDER = { BOSS: 0, ELITE: 1, NORMAL: 2 };
|
||
|
||
/**
|
||
* Next-round enemy preview rows: merged by (enemyKey, tag), sorted boss → bounty → elite → normal → count.
|
||
* @param {Array<{enemyKey:string, count?:number, tag?:string}>} list
|
||
* @param {(key:string)=>any} [getEnemy]
|
||
*/
|
||
export function groupEnemies(list, getEnemy = () => null) {
|
||
const map = new Map();
|
||
for (const e of Array.isArray(list) ? list : []) {
|
||
if (!isObj(e) || typeof e.enemyKey !== 'string') continue;
|
||
const tag = typeof e.tag === 'string' ? e.tag : null;
|
||
const k = `${e.enemyKey}|${tag}`;
|
||
const cur = map.get(k) || { enemyKey: e.enemyKey, tag, count: 0 };
|
||
cur.count += Number.isFinite(e.count) && e.count > 0 ? e.count : 1;
|
||
map.set(k, cur);
|
||
}
|
||
return [...map.values()].map((row) => {
|
||
const en = getEnemy(row.enemyKey);
|
||
return { ...row, name: en?.name || row.enemyKey, rank: en?.rank || 'NORMAL', acTypes: Array.isArray(en?.acTypes) ? en.acTypes : (en?.acType ? [en.acType] : []), fly: en?.stats?.motion === 'FLY' || !!en?.isFlyEnemy };
|
||
}).sort((a, b) => (TAG_ORDER[a.tag] ?? 9) - (TAG_ORDER[b.tag] ?? 9)
|
||
|| (RANK_ORDER[a.rank] ?? 9) - (RANK_ORDER[b.rank] ?? 9)
|
||
|| b.count - a.count || (a.enemyKey < b.enemyKey ? -1 : 1));
|
||
}
|
||
|
||
// ---- enemy preview pen (research 09 §2, research 08 §4.2) ---------------------------------------------------
|
||
|
||
/** Pen rect and zones: rows 14–18 × cols 7–13, row 16 unused; lower-gate zone rows 14–15, upper-gate zone rows 17–18. */
|
||
export const PEN = Object.freeze({ r0: 14, r1: 18, c0: 7, c1: 13, emptyRow: 16, cap: 50, anchors: Object.freeze({ lower: [15, 7], upper: [18, 7] }) });
|
||
|
||
/** Zone tiles in row-major order (low row first, col 7→13), without the zone's anchor (tile_start) — 13 per zone. */
|
||
export function penZoneTiles(gate) {
|
||
const rows = gate === 'upper' ? [17, 18] : [14, 15];
|
||
const [ar, ac] = PEN.anchors[gate === 'upper' ? 'upper' : 'lower'];
|
||
const out = [];
|
||
for (const r of rows) for (let c = PEN.c0; c <= PEN.c1; c++) if (!(r === ar && c === ac)) out.push([r, c]);
|
||
return out;
|
||
}
|
||
|
||
/**
|
||
* Placement of the preview models in the pen for the DOM fallback view — the SAME layout as the render engine
|
||
* (render/pen.js layoutPen: research 08 §4.2, client AutoChessEnemyPreviewManager — spawn-time order; > 50 enemies
|
||
* thinned per entry to max(1, round(count·50/total)), elites / leaders always all of theirs; each gate's models in its
|
||
* zone, the k-th model of a zone at zoneTiles[round(k/totalShowCnt·len) − jitter]; ≤ 3 per tile; seeded, so a re-sent
|
||
* m.private never reshuffles it). Both views show one teammate's pen identically (review regression: the fallback used
|
||
* its own variant — global k, no per-tile cap — and placed the same enemies elsewhere). `slot` numbers the models
|
||
* sharing a tile.
|
||
* @param {Array<{ enemyKey: string, count?: number, gate?: string, t?: number, fly?: boolean, elite?: boolean, boss?: boolean }>} entries m.private.nextEnemies
|
||
* @param {{ cap?: number, stage?: any }} [opts] cap = MAX_PREVIEW_CNT (50); stage = the stage (its pen rect / tiles)
|
||
* @returns {Array<{ enemyKey: string, gate: 'upper'|'lower', row: number, col: number, slot: number, fly: boolean, elite: boolean, boss: boolean, k: number }>}
|
||
*/
|
||
export function penPlacement(entries, opts = {}) {
|
||
const max = Number.isInteger(opts.cap) && opts.cap > 0 ? opts.cap : PEN.cap;
|
||
const { figures } = layoutPen(Array.isArray(entries) ? entries : [], { stage: opts.stage || null, max });
|
||
const perTile = new Map();
|
||
return figures.map((f, k) => {
|
||
const key = `${f.row},${f.col}`;
|
||
const slot = perTile.get(key) || 0;
|
||
perTile.set(key, slot + 1);
|
||
return { enemyKey: f.enemyKey, gate: f.zone === 'upper' ? 'upper' : 'lower', row: f.row, col: f.col, slot, fly: !!f.fly, elite: !!f.elite, boss: !!f.boss, k };
|
||
});
|
||
}
|
||
|
||
/**
|
||
* The enemy a view `pieceClick` points at in the preview pen, or null: `{ enemyKey }` or a `preview` enemy unit
|
||
* (`{ preview: true, unit: { side: 'enemy', defId } }`). Battle units (live fights) are not pen enemies.
|
||
* @param {any} e
|
||
*/
|
||
export function previewEnemyKey(e) {
|
||
if (!isObj(e)) return null;
|
||
if (typeof e.enemyKey === 'string' && e.enemyKey) return e.enemyKey;
|
||
const u = isObj(e.unit) ? e.unit : null;
|
||
if (!u || !(e.preview || u.preview)) return null;
|
||
if (u.side !== 'enemy' && u.kind !== 'enemy') return null;
|
||
const k = typeof u.enemyKey === 'string' && u.enemyKey ? u.enemyKey : u.defId;
|
||
return typeof k === 'string' && k ? k : null;
|
||
}
|
||
|
||
/** Normalise m.public.factions (type strings or objects) to type ids. */
|
||
export function factionTypes(factions) {
|
||
const out = [];
|
||
for (const f of Array.isArray(factions) ? factions : []) {
|
||
const t = typeof f === 'string' ? f : isObj(f) ? (f.type || f.id || f.key) : null;
|
||
if (typeof t === 'string' && t && !out.includes(t)) out.push(t);
|
||
}
|
||
return out;
|
||
}
|