feedback3: merge WJ — 高台 only for elite 歌蕾蒂娅 with HOK-Y, live prep watch, silenced 折射, grant toasts, emote timeout glyph
This commit is contained in:
commit
c9dd2a6392
41 files changed
+700
-216
No files matched your search
+83
-17
@@ -6,7 +6,10 @@
|
||||
// GAME_FILES) in the background once the player is in a room, and the match screen waits for them, so no text of the
|
||||
// match UI ever appears late. A transient failure (network error, HTTP 5xx) is retried twice (RETRY_DELAYS_MS); files
|
||||
// that stay unavailable (404, repeated failures, bad JSON) resolve to `null` and are reported once on the console — the
|
||||
// UI must degrade gracefully while data is being generated.
|
||||
// UI must degrade gracefully while data is being generated. The emote manifests (`local`, `assets`) are the exception:
|
||||
// one attempt is abandoned after ART_MANIFEST_TIMEOUT_MS (GitHub #99) and reported `missing` at once, so the 交流 button
|
||||
// draws its glyph instead of staying blank; a later retry that succeeds replaces the glyph. Other files stay `loading`
|
||||
// across their retries.
|
||||
//
|
||||
// Each file is indexed tolerantly so the getters work whether a file is
|
||||
// - an array of records carrying an id field (id / chessId / bondId / itemId / …),
|
||||
@@ -78,6 +81,16 @@ export function buildIndex(name, json) {
|
||||
/** Waits (ms) before retrying a data file whose download failed transiently (network error, HTTP 5xx / 408 / 429). */
|
||||
export const RETRY_DELAYS_MS = Object.freeze([600, 2000]);
|
||||
|
||||
/**
|
||||
* How long one attempt at an emote manifest (`local`, `assets`) may hang before it counts as a failure.
|
||||
* [ASSUMED] 8 s: long enough for a slow link, short enough that the 交流 button does not stay blank for the session
|
||||
* (GitHub #99). Other files are not on this clock.
|
||||
*/
|
||||
export const ART_MANIFEST_TIMEOUT_MS = 8000;
|
||||
|
||||
/** Manifests the emote button waits on. A hang here used to leave every cell blank (GitHub #99). */
|
||||
const ART_MANIFESTS = new Set(['local', 'assets']);
|
||||
|
||||
/**
|
||||
* A failed load worth retrying: the request itself failed (network error) or the server answered 5xx / 408 / 429 — not a
|
||||
* definite 4xx (the file is not there) nor a delivered file that is not valid JSON (`badJson`).
|
||||
@@ -92,14 +105,19 @@ const transientFailure = (err) => {
|
||||
* Create a data store bound to a fetch implementation (injectable for tests).
|
||||
* A file is downloaded once per page (the texts of the game are static data, never fetched again during a match —
|
||||
* user playtest #3 item 9); a transient failure is retried (RETRY_DELAYS_MS) while the file stays 'loading', so a
|
||||
* network hiccup does not leave the texts of a whole session missing.
|
||||
* @param {{ fetch?: typeof fetch, base?: string, retryDelays?: number[], wait?: (ms: number) => Promise<void> }} [opts]
|
||||
* network hiccup does not leave the texts of a whole session missing. `local` and `assets` are reported `missing` on
|
||||
* the first failure or timeout (the emote glyph) and stay that way through a retry; a later success is `ready`.
|
||||
* @param {{ fetch?: typeof fetch, base?: string, retryDelays?: number[], wait?: (ms: number) => Promise<void>, timeoutMs?: number, setTimeout?: typeof setTimeout, clearTimeout?: typeof clearTimeout }} [opts]
|
||||
* `timeoutMs` 0 turns the art-manifest clock off. `setTimeout` / `clearTimeout` let a test fire that clock.
|
||||
*/
|
||||
export function createDataStore(opts = {}) {
|
||||
const base = opts.base ?? '/data/';
|
||||
const doFetch = opts.fetch || ((...a) => globalThis.fetch(...a));
|
||||
const retryDelays = Array.isArray(opts.retryDelays) ? opts.retryDelays : RETRY_DELAYS_MS;
|
||||
const wait = opts.wait || ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
||||
const timeoutMs = opts.timeoutMs === undefined ? ART_MANIFEST_TIMEOUT_MS : Number(opts.timeoutMs);
|
||||
const armTimer = opts.setTimeout || ((fn, ms) => setTimeout(fn, ms));
|
||||
const disarmTimer = opts.clearTimeout || ((id) => clearTimeout(id));
|
||||
/** @type {Map<string, { status: 'loading'|'ready'|'missing', promise: Promise<any>, value: any, index: Map<string, any>|null }>} */
|
||||
const entries = new Map();
|
||||
const listeners = new Set();
|
||||
@@ -113,40 +131,88 @@ export function createDataStore(opts = {}) {
|
||||
|
||||
const urlFor = (name) => base + (DATA_FILES[name] || `${name}.json`);
|
||||
|
||||
/**
|
||||
* One attempt at a file. Art manifests reject with a transient `{ timeout: true }` when the clock fires; a response
|
||||
* that arrives after that is ignored (the retry is a new request). [ASSUMED]
|
||||
*/
|
||||
function readJson(name) {
|
||||
const run = async () => {
|
||||
const res = await doFetch(urlFor(name), { cache: 'no-cache' });
|
||||
if (!res || !res.ok) throw Object.assign(new Error(`HTTP ${res ? res.status : '???'}`), { status: res ? res.status : null });
|
||||
try {
|
||||
return await res.json();
|
||||
} catch (err) {
|
||||
throw Object.assign(err instanceof Error ? err : new Error(String(err)), { badJson: true });
|
||||
}
|
||||
};
|
||||
if (!ART_MANIFESTS.has(name) || !(timeoutMs > 0)) return run();
|
||||
let timer = null;
|
||||
let done = false;
|
||||
const finish = () => {
|
||||
if (done) return false;
|
||||
done = true;
|
||||
if (timer != null) disarmTimer(timer);
|
||||
return true;
|
||||
};
|
||||
const timed = new Promise((_, reject) => {
|
||||
timer = armTimer(() => {
|
||||
if (!finish()) return;
|
||||
reject(Object.assign(new Error('timeout'), { timeout: true }));
|
||||
}, timeoutMs);
|
||||
});
|
||||
const req = run().then(
|
||||
(json) => { finish(); return json; },
|
||||
(err) => { finish(); throw err; },
|
||||
);
|
||||
return Promise.race([req, timed]);
|
||||
}
|
||||
|
||||
function load(name) {
|
||||
if (typeof name !== 'string' || !/^[A-Za-z0-9_-]+$/.test(name)) return Promise.resolve(null);
|
||||
const cur = entries.get(name);
|
||||
if (cur) return cur.promise;
|
||||
const entry = { status: 'loading', promise: null, value: null, index: null };
|
||||
const art = ART_MANIFESTS.has(name);
|
||||
entry.promise = (async () => {
|
||||
let toldMissing = false;
|
||||
for (let attempt = 0; ; attempt++) {
|
||||
try {
|
||||
const res = await doFetch(urlFor(name), { cache: 'no-cache' });
|
||||
if (!res || !res.ok) throw Object.assign(new Error(`HTTP ${res ? res.status : '???'}`), { status: res ? res.status : null });
|
||||
let json;
|
||||
try { json = await res.json(); } catch (err) { throw Object.assign(err instanceof Error ? err : new Error(String(err)), { badJson: true }); }
|
||||
entry.value = json;
|
||||
entry.value = await readJson(name);
|
||||
entry.status = 'ready';
|
||||
break;
|
||||
} catch (err) {
|
||||
// a transient failure is tried again (still 'loading'), unless the load was superseded meanwhile
|
||||
if (transientFailure(err) && attempt < retryDelays.length && entries.get(name) === entry) {
|
||||
const current = entries.get(name) === entry;
|
||||
// Decided before notify. Invalidating inside that notify leaves `again` true; the wait then sees
|
||||
// the superseded entry and stops, so this attempt does not fetch again.
|
||||
const again = transientFailure(err) && attempt < retryDelays.length && current;
|
||||
// Emote art: glyph now. Stay `missing` through the retry wait — flipping back to `loading` blanks the button.
|
||||
if (art && current && entry.status !== 'missing') {
|
||||
entry.value = null;
|
||||
entry.index = null;
|
||||
entry.status = 'missing';
|
||||
toldMissing = true;
|
||||
notify(name);
|
||||
}
|
||||
if (again) {
|
||||
await wait(retryDelays[attempt]);
|
||||
if (entries.get(name) === entry) continue;
|
||||
entry.status = 'missing'; // superseded by invalidate() meanwhile: the new load reports for itself
|
||||
break;
|
||||
}
|
||||
if (!warned.has(name)) {
|
||||
warned.add(name);
|
||||
console.warn(`[data] ${urlFor(name)} unavailable (${err?.message || err}); continuing without it`);
|
||||
if (current) {
|
||||
if (!warned.has(name)) {
|
||||
warned.add(name);
|
||||
console.warn(`[data] ${urlFor(name)} unavailable (${err?.message || err}); continuing without it`);
|
||||
}
|
||||
entry.value = null;
|
||||
entry.index = null;
|
||||
entry.status = 'missing';
|
||||
}
|
||||
entry.value = null;
|
||||
entry.status = 'missing';
|
||||
break;
|
||||
}
|
||||
}
|
||||
// A load superseded by invalidate() must not announce itself (its entry is no longer cached).
|
||||
if (entries.get(name) === entry) notify(name);
|
||||
// An art manifest already announced `missing` does not announce that same status again.
|
||||
if (entries.get(name) === entry && !(toldMissing && entry.status === 'missing')) notify(name);
|
||||
return entry.value;
|
||||
})();
|
||||
entries.set(name, entry);
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
// (left_prepare / *_boss_prepare) clear of the folded shop's band (public
|
||||
// issue #5: folding the shop did not grow the board)
|
||||
// view.setPrep(privateState, { editable, canPlace }) hand/temp/board pieces; editable enables drag & drop; a
|
||||
// new board piece flashes (fx.deploy); a merge's elite — on the tile of
|
||||
// new board piece flashes (fx.deploy), and so does a card that just arrived in the hand or temp; a merge's elite — on the tile of
|
||||
// the deployed copy it replaced, or on its bench slot — gets the
|
||||
// promotion cue instead (render/promote.js, fx.promote)
|
||||
// view.enterBattle(fieldMeta) m.field { fieldId, kind, rect, stageId, units: [UnitInfo] } — each
|
||||
@@ -975,6 +975,7 @@ export async function createFieldView(host, options = {}) {
|
||||
promotions.push({ uid: e.uid, id: e.piece.id, area: e.area, row: e.row ?? null, col: e.col ?? null, idx: e.idx ?? null, copies: promoFrom.get(e.uid).length });
|
||||
if (promotions.length > 20) promotions.shift();
|
||||
} else if (e.area === 'board' && prevBoard.size && !prevBoard.has(e.uid)) { v.onDeploy?.(); fx.deploy(v); }
|
||||
else if (before.length && (e.area === 'hand' || e.area === 'temp') && !before.some((g) => g.uid === e.uid)) fx.deploy(v);
|
||||
} else {
|
||||
const prevHome = v._home;
|
||||
const moved = !prevHome || prevHome.x !== w.x || prevHome.y !== w.y || prevHome.z !== w.z;
|
||||
|
||||
@@ -129,6 +129,8 @@ export const STATUS_ICON = Object.freeze({
|
||||
substitute: 'doll',
|
||||
// 禁疗 (sim status 'healFree': 史尔特尔's 余烬 — no heal reaches her until she leaves)
|
||||
healFree: 'healFree',
|
||||
// 折射 (sim buff 'ab:refraction', visible while the RES bonus is on). The tail of 'ab:refraction' hits this key.
|
||||
refraction: 'refraction',
|
||||
});
|
||||
|
||||
/** Keyword fallbacks for namespaced / content status keys ('ab:frost', 'reed2:scorch', 'skill:shotst_shred' …). */
|
||||
@@ -140,6 +142,18 @@ const STATUS_GUESS = [
|
||||
[/neural/i, 'neural'], [/necro|apopt|erosion/i, 'necrosis'],
|
||||
];
|
||||
|
||||
/**
|
||||
* The 折射 icon is not drawn while the unit is silenced: the RES bonus is already off (enemies.js refraction)
|
||||
* and the status must not keep looking active. Other icons stay.
|
||||
* @param {string} key a b.ev status key
|
||||
* @param {Set<string>|string[]|null} statuses
|
||||
*/
|
||||
export function statusIconSuppressed(key, statuses) {
|
||||
if (key !== 'ab:refraction' && key !== 'refraction') return false;
|
||||
if (!statuses) return false;
|
||||
return typeof statuses.has === 'function' ? statuses.has('silence') : Array.isArray(statuses) && statuses.includes('silence');
|
||||
}
|
||||
|
||||
/** Icon key (textures STATUS_KEYS) for a b.ev status key or flag name; null when it has no icon. */
|
||||
export function statusIconKey(key) {
|
||||
if (typeof key !== 'string' || !key) return null;
|
||||
|
||||
@@ -835,7 +835,7 @@ export function fxAtlas() {
|
||||
return _fx;
|
||||
}
|
||||
|
||||
export const STATUS_KEYS = ['stun', 'freeze', 'cold', 'stealth', 'shield', 'fragile', 'sleep', 'invuln', 'silence', 'slow', 'bind', 'fear', 'weaken', 'levitate', 'taunt', 'burn', 'neural', 'necrosis', 'blocked', 'skill', 'doll', 'healFree'];
|
||||
export const STATUS_KEYS = ['stun', 'freeze', 'cold', 'stealth', 'shield', 'fragile', 'sleep', 'invuln', 'silence', 'slow', 'bind', 'fear', 'weaken', 'levitate', 'taunt', 'burn', 'neural', 'necrosis', 'blocked', 'skill', 'doll', 'healFree', 'refraction'];
|
||||
|
||||
function drawStatusIcon(c, key, x, y, s) {
|
||||
const cx = x + s / 2, cy = y + s / 2;
|
||||
@@ -883,6 +883,13 @@ function drawStatusIcon(c, key, x, y, s) {
|
||||
c.fillStyle = '#d8e0ff'; c.font = 'bold 14px sans-serif'; c.textAlign = 'center'; c.textBaseline = 'middle'; c.fillText('Zz', cx, cy + 1);
|
||||
break;
|
||||
}
|
||||
case 'refraction': {
|
||||
// 折射: a split beam. Drawn only while the buff is on; units.js drops it under silence.
|
||||
disc('rgba(12,36,48,0.92)', '#7ee0ff');
|
||||
c.strokeStyle = '#d8f7ff'; c.lineWidth = 2;
|
||||
c.beginPath(); c.moveTo(cx - 8, cy + 6); c.lineTo(cx - 1, cy - 8); c.lineTo(cx + 8, cy + 6); c.moveTo(cx + 2, cy - 2); c.lineTo(cx + 9, cy - 8); c.stroke();
|
||||
break;
|
||||
}
|
||||
case 'silence': {
|
||||
disc('rgba(45,20,55,0.92)', '#e08bff');
|
||||
c.strokeStyle = '#f0c4ff'; c.lineWidth = 2.2;
|
||||
|
||||
@@ -68,7 +68,7 @@
|
||||
import { UF, ANIM } from '../../../shared/constants.js';
|
||||
import { SpineActor } from './spine.js';
|
||||
import { diamondTexture, shadowTexture, fxAtlas, tierChip, statusTexture, itemTexture, hudRings, ringArc, HUD_DISC, ELEMENT_RING } from './textures.js';
|
||||
import { COLORS, TIER_COLORS, ENEMY_FRAME, UNIT, PROJ, statusIconKey } from './style.js';
|
||||
import { COLORS, TIER_COLORS, ENEMY_FRAME, UNIT, PROJ, statusIconKey, statusIconSuppressed } from './style.js';
|
||||
import { drawCrate, rowDepthKey, ROW_KEY, deviceBoxOf, DEVICE_BOX } from './tiles.js';
|
||||
|
||||
const clamp = (v, lo, hi) => (v < lo ? lo : v > hi ? hi : v);
|
||||
@@ -1359,6 +1359,7 @@ export class UnitView {
|
||||
// a burst's lock ('burnBurst', 'neuralBurst' … — the 爆发冷却) is shown by the element gauge row under the bars
|
||||
// (b.snap `elem`); only a feed without gauges (an older recording) shows it as a status
|
||||
if (this.el && k.endsWith('Burst')) continue;
|
||||
if (statusIconSuppressed(k, this.statuses)) continue; // 折射 while silenced
|
||||
const icon = statusIconKey(k);
|
||||
// flag-driven states are authoritative (a stale 'stun' status must not outlive the flag)
|
||||
if (!icon || icon === 'stun' || icon === 'freeze' || icon === 'sleep' || icon === 'stealth' || icon === 'invuln') continue;
|
||||
|
||||
@@ -422,8 +422,9 @@ function MatchScreen() {
|
||||
view.pushEvents(early);
|
||||
audio.handleBattleEvents(early.filter((e) => e[0] === 'spawn'));
|
||||
}
|
||||
if (!earlySnap && (field.prep || !combat) && Array.isArray(field.units) && field.units.length) {
|
||||
// prep scouting (read-only teammate board): no snapshots follow, so place the units once
|
||||
if (!earlySnap && (field.prep || !combat) && Array.isArray(field.units)) {
|
||||
// prep scouting: no battle snapshots follow. A later m.field for this board (the teammate moved) re-enters
|
||||
// above and places the units again, including an empty board (GitHub #87).
|
||||
view.pushSnapshot({
|
||||
fieldId: field.fieldId, gt: 0,
|
||||
units: field.units.filter((u) => u && u.id != null).map((u) => [u.id, Number(u.x) || 0, Number(u.y) || 0, u.maxHp || 1, u.maxHp || 1, 0, 0, 0, 0]),
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
// Enemy-card ability lines (detailPanel EnemyDetail). A line whose data format is SILENCE
|
||||
// (折射: `<$ba.refraction>折射</>`, format "SILENCE") is off while the enemy is silenced: the card greys it.
|
||||
// The sim already drops the RES bonus; this is only the line.
|
||||
|
||||
/**
|
||||
* @param {any[]|null} abilities enemies.json abilities
|
||||
* @param {boolean} silenced live unit (`unitStatsEntry.silenced`)
|
||||
* @returns {Array<{ text: string, off: boolean }>}
|
||||
*/
|
||||
export function abilityRows(abilities, silenced) {
|
||||
const list = Array.isArray(abilities) ? abilities : [];
|
||||
return list.map((a) => {
|
||||
const text = typeof a === 'string' ? a : (a && (a.textRaw || a.text)) || '';
|
||||
const off = !!(silenced && a && typeof a === 'object' && a.format === 'SILENCE');
|
||||
return { text, off };
|
||||
});
|
||||
}
|
||||
@@ -35,6 +35,7 @@ import { html, Icon, TierChip, MicroLabel, Button, confirmDialog, useTicker } fr
|
||||
import { Img, RichText, UnitThumb, BondGlyph, GIcon } from './gameComponents.js';
|
||||
import { attackInterval, rangeGridBox, fmtNum, tileKey, chessLoadout, nextThreshold, bondTier, briefingBondTip, pieceBondIds, grantedBonds, morphPairings } from './gameLogic.js';
|
||||
import { chessPortraitUrl, skillIconUrl, skillRecordIconUrl, profIconUrl, subProfIconUrl, itemIconUrl, enemyIconUrl, tokenAvatarUrl, factionIconUrl, uiUrl, moduleTypeIconUrl } from './assetUrls.js';
|
||||
import { abilityRows } from './abilityLines.js';
|
||||
import { data } from '../data.js';
|
||||
import { attackRangeGrid } from '../../../shared/loadoutRecord.js';
|
||||
import { SKILL_SUMMON_START_DEPLOY } from '../../../shared/constants.js';
|
||||
@@ -522,7 +523,7 @@ function EnemyDetail({ enemy, snapHp, count, live = null }) {
|
||||
</div>
|
||||
${imm.length ? html`<p class="dhint"><${Icon} name="shield" />免疫:${imm.join('、')}</p>` : null}
|
||||
${Array.isArray(enemy.abilities) && enemy.abilities.length ? html`<${Section} title="能力" micro="ABILITIES">
|
||||
<ul class="dabil">${enemy.abilities.map((a, i) => html`<li key=${i}><${RichText} text=${typeof a === 'string' ? a : a.textRaw || a.text} /></li>`)}</ul>
|
||||
<ul class="dabil">${abilityRows(enemy.abilities, !!live?.silenced).map((a, i) => html`<li key=${i} class=${a.off ? 'is-off' : null}><${RichText} text=${a.text} /></li>`)}</ul>
|
||||
<//>` : enemy.descRaw || enemy.desc ? html`<${Section} title="说明"><${RichText} as="p" text=${enemy.descRaw || enemy.desc} class="dtext" /><//>` : null}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -4,8 +4,9 @@
|
||||
//
|
||||
// EmoteArt the emote picture: the local-client art (data/local-assets.json → emoticon/<dir>/<picId>) first, else
|
||||
// the copy setup downloads from the public mirror (data/assets.json → ui['emoticon/<dir>/<picId>'];
|
||||
// GitHub issue #42), each tried in turn when one fails to load; a neutral glyph when neither is there,
|
||||
// an empty box while a manifest is still loading.
|
||||
// GitHub issue #42), each tried in turn when one fails to load; a neutral glyph when neither is there.
|
||||
// An empty box only while a manifest is still in flight; a timeout or a failed manifest (GitHub #99)
|
||||
// uses that glyph, and a later success replaces it.
|
||||
// EmoteBubble the pop bubble beside the sender's avatar in the team panel (official emoji_bubble_bkg: a dark rounded
|
||||
// square with a tail pointing left + the icon only); pop-in, 3 s, fade. The parent keys it by the emote's
|
||||
// seq so a newer emote replaces the old one and pops again, and passes the arrival time (`at`) so a
|
||||
@@ -68,7 +69,7 @@ export function emoteArtUrl(id) {
|
||||
return emoteArtUrls(id)[0] || null;
|
||||
}
|
||||
|
||||
/** True while the local-art manifest or the asset manifest has not settled (EmoteArt shows an empty box meanwhile). */
|
||||
/** True while local or assets is still in flight (idle or loading). A timeout or a failure is `missing`: the glyph. */
|
||||
const artManifestsPending = () => ['local', 'assets'].some((n) => { const st = data.status(n); return st === 'loading' || st === 'idle'; });
|
||||
|
||||
/** Official emote UI sprite (ui/battle: emoji_bubble_bkg, emoji_bkg, emoji_cell_bkg, emoji_btn, emoji_btn_disable). */
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
// light legal tiles while dragging; the server stays authoritative and may still refuse a move.
|
||||
//
|
||||
// Board = own normal field (GEO.FIELD rows 9–12, cols 2–10). Melee chess stand on `melee` deploy tiles
|
||||
// (LOW, buildable ALL/MELEE) — a 钩索师 / 推击手 ("可以放置于远程位", chess.json `placement` 'all') on any deploy
|
||||
// tile, the 高台 included (piecePosition 'ALL'); ranged chess on `melee ∪ rangedOnly` (stages.json → deployTiles.normal,
|
||||
// (LOW, buildable ALL/MELEE) — elite 歌蕾蒂娅 carrying HOK-Y (shared/highGround.js, the player's loadout) on any
|
||||
// deploy tile, the 高台 included (piecePosition 'ALL'); ranged chess on `melee ∪ rangedOnly` (stages.json → deployTiles.normal,
|
||||
// derived from the tile legend when missing — the legend's `buildable` is the effective type: 深水区 tile_deepsea
|
||||
// refuses deployment, PRTS 深水区 地形信息 "拒绝部署(待补充)", player report #3 after 0.1.0). Tokens follow their
|
||||
// own `position`; a summon whose text reads "只能部署在召唤者攻击范围内" (tokens.json `ownerRange`: 伺夜's 狼群,
|
||||
@@ -24,6 +24,7 @@
|
||||
import { GEO, PHASE, UF } from '../../../shared/constants.js';
|
||||
import { resolveLoadout, loadoutOptions, MODULE_NONE } from '../../../shared/protocol.js';
|
||||
import { resolveRecordLoadout, loadoutRecord, attackRangeGrid } from '../../../shared/loadoutRecord.js';
|
||||
import { meleeOnHighGround } from '../../../shared/highGround.js';
|
||||
import { rangeTiles, pieceDir } from './facing.js';
|
||||
import { layoutPen } from '../render/pen.js';
|
||||
import { BOSS_ROW_SHIFT, MAX_COL } from '../render/prepfield.js';
|
||||
@@ -1041,15 +1042,17 @@ export function placementContext({ priv, stage, editable, field = 'normal', getC
|
||||
}
|
||||
|
||||
/**
|
||||
* Deploy position ('MELEE'|'RANGED'|'ALL') of a chess/token piece, or null for items. A MELEE operator whose branch
|
||||
* trait reads "可以放置于远程位" (钩索师, 推击手: chess.json `placement` 'all') is 'ALL': any deployable tile, the 高台
|
||||
* included (server/match/board.js positionClass; GitHub issue #32 item 4, DESIGN §22.6 [ASSUMED]).
|
||||
* Deploy position ('MELEE'|'RANGED'|'ALL') of a chess/token piece, or null for items. Elite 歌蕾蒂娅 carrying HOK-Y
|
||||
* (the viewer's loadout, shared/highGround.js) is 'ALL': any deployable tile, the 高台 included
|
||||
* (server/match/board.js placeClass; owner's decision 2026-10-04). Every other MELEE chess is ground-only.
|
||||
*/
|
||||
export function piecePosition(ctx, piece) {
|
||||
if (!isObj(piece)) return null;
|
||||
if (piece.kind === 'chess') {
|
||||
const rec = ctx.getChess(piece.id);
|
||||
if (rec?.placement === 'all') return 'ALL';
|
||||
let moduleId = null;
|
||||
try { moduleId = resolveLoadout(ctx.priv?.loadout ?? null, rec, ctx.getChess)?.moduleId ?? null; } catch { moduleId = null; }
|
||||
if (meleeOnHighGround(rec, moduleId)) return 'ALL';
|
||||
return rec?.position === 'MELEE' ? 'MELEE' : 'RANGED';
|
||||
}
|
||||
// tokens: MELEE → ground only; RANGED / ALL → any deployable tile
|
||||
|
||||
Reference in new issue
Block a user