feedback7: merge fb7-late — #389 / PR #390 画卷 keeps the items to copy across a merge, #385 / PR #387 spectator bond details count the hand like the own popup, #394 / PR #395 Enter during facing selection follows the focus, #281 the attack range drawn on a tapped operator, #388 引星棘刺 S1 DEF stacks, PR #386 谢拉格 寒风 blows bottom-up (owner)
- Golden union OK (roster-033 / roster-034 moved on both sides: #298 and #388); broad node --test 5598 (5596 pass, 0 fail, 2 skipped); lint 0 errors.
This commit is contained in:
commit
2492966c4f
32 files changed
+913
-138
No files matched your search
@@ -41,6 +41,8 @@ export function renderInfo(u) {
|
||||
// skill aura is drawn for it (render/fx/rings.js _aura)
|
||||
ammoSkill: u.ammoSkill === true ? true : undefined,
|
||||
moduleId: typeof u.moduleId === 'string' ? u.moduleId : undefined,
|
||||
// Scouted hand and temp units have details but no field attack range.
|
||||
area: typeof u.area === 'string' ? u.area : undefined,
|
||||
// the ally's equipped item ids (UnitInfo.items, DESIGN §16 / §21.11): the detail card needs them for a teammate's
|
||||
// unit (resolveDetail `unitItems` → the read-only 装备 section and the 变形同构体 pairing chips); the owner's own
|
||||
// unit takes its items from the piece instead, so only other players' boards ever read this field
|
||||
|
||||
@@ -101,7 +101,12 @@ export class FxSim {
|
||||
else this.zone(at.x, at.y, at.z, r, col, d, 'ring', true);
|
||||
break;
|
||||
}
|
||||
case 'chill': this.flashScreen(col, 0.35, 0.8); this.snowfall(col); break;
|
||||
// 盟约寒风 (谢拉格's field-wide wind — the device emits it with no anchor unit): a blizzard up the field and a
|
||||
// stronger cold flash (PR #386); an operator's own cold (灵知 S3's start, 麦哲伦 S1's pulse every 3 s: `id` = the
|
||||
// unit) keeps the light flurry and the softer flash — a full-screen blizzard every pulse would bury the field
|
||||
case 'chill':
|
||||
if (ex.id == null) { this.flashScreen(col, 0.45, 1); this.snowfall(col, true); } else { this.flashScreen(col, 0.35, 0.8); this.snowfall(col); }
|
||||
break;
|
||||
case 'heal': this.heal(at.v || { x: at.x, y: at.y, z: at.z }, 0); break;
|
||||
case 'healAoe': {
|
||||
this.ring(at.x, at.y, at.z, 0.2, r, col, 0.6);
|
||||
|
||||
@@ -180,11 +180,32 @@ export class FxZones {
|
||||
this.tintSprite.tint = tint;
|
||||
}
|
||||
|
||||
/** A short flurry of snow over the field (cold wind). */
|
||||
snowfall(tint) {
|
||||
/**
|
||||
* Snow over the field (cold wind). `gust` — 盟约寒风, 谢拉格's field-wide wind: a blizzard sweeping the screen from the
|
||||
* bottom up, the official direction (the owner's confirmation of 2026-10-08; PR #386 by @leiming2333 after the
|
||||
* community report 「谢拉格的效果特性不明显,暴风雪应该从下向上刮」): snowflakes rise off the bottom edge on a crosswind
|
||||
* with gust streaks racing along, so it reads as wind driving up the field, not quiet snowfall (flake / streak counts,
|
||||
* speeds and lives are a visual choice [ASSUMED]; screen y grows downward, so a negative vy rises). Without `gust` (an
|
||||
* operator's own cold: 灵知 S3, 麦哲伦 S1's pulses) a short light flurry drifting down, as before.
|
||||
*/
|
||||
snowfall(tint, gust = false) {
|
||||
const size = this.ctx.screenSize();
|
||||
for (let i = 0; i < (this.quality === 'low' ? 10 : 26); i++) {
|
||||
this.particle('dot', Math.random() * size.width, Math.random() * size.height * 0.7, { tint, vx: 30 + Math.random() * 40, vy: 60 + Math.random() * 60, life: 1 + Math.random() * 0.6, s0: 0.25 + Math.random() * 0.3, s1: 0.1, a0: 0.8, a1: 0, fadeIn: 0.2 });
|
||||
const W = size.width, H = size.height, low = this.quality === 'low';
|
||||
if (!gust) {
|
||||
for (let i = 0; i < (low ? 10 : 26); i++) {
|
||||
this.particle('dot', Math.random() * W, Math.random() * H * 0.7, { tint, vx: 30 + Math.random() * 40, vy: 60 + Math.random() * 60, life: 1 + Math.random() * 0.6, s0: 0.25 + Math.random() * 0.3, s1: 0.1, a0: 0.8, a1: 0, fadeIn: 0.2 });
|
||||
}
|
||||
return;
|
||||
}
|
||||
// snowflakes: lifted off the bottom edge (a share starts below it), blown up-right and tumbled by the gust
|
||||
for (let i = 0; i < (low ? 22 : 48); i++) {
|
||||
const vy = -H * (0.55 + Math.random() * 0.5), vx = W * (0.04 + Math.random() * 0.1);
|
||||
this.particle('dot', Math.random() * W, H * (0.55 + Math.random() * 0.55), { tint, vx, vy, life: 1.4 + Math.random() * 0.9, s0: 0.22 + Math.random() * 0.34, s1: 0.08, a0: 0.9, a1: 0, fadeIn: 0.12, spin: (Math.random() - 0.5) * 6 });
|
||||
}
|
||||
// gust streaks: thin wind lines racing up through the field along their velocity
|
||||
for (let i = 0; i < (low ? 4 : 10); i++) {
|
||||
const vy = -H * (0.9 + Math.random() * 0.6), vx = W * (0.1 + Math.random() * 0.12);
|
||||
this.particle('streak', Math.random() * W, H * (0.6 + Math.random() * 0.5), { tint, vx, vy, life: 0.7 + Math.random() * 0.4, s0: 0.04 + Math.random() * 0.03, s1: 0.02, sx: 6 + Math.random() * 6, a0: 0.55, a1: 0, fadeIn: 0.08, rot: Math.atan2(vy, vx) });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+23
-12
@@ -117,6 +117,7 @@ import { useDocClass, FullscreenButton } from '../ui/device.js';
|
||||
import { HUD_HZ_MS, MERGE_HL, SEL_RANGE, cx } from './game/marks.js';
|
||||
import { keepEarly, audioEarly } from './game/early.js';
|
||||
import { MatchEnded, PausedOverlay } from './game/overlays.js';
|
||||
import { inspectRange } from './game/range.js';
|
||||
import { t, tParts } from '../../../shared/i18n.js';
|
||||
|
||||
/** Router for the in-match screens. */
|
||||
@@ -477,6 +478,9 @@ function MatchScreen() {
|
||||
}
|
||||
if (earlySnap) {
|
||||
view.pushSnapshot(earlySnap);
|
||||
if (Array.isArray(earlySnap.units)) {
|
||||
snapUnitsRef.current = new Map(earlySnap.units.filter(Array.isArray).map((t) => [t[0], t]));
|
||||
}
|
||||
hudRef.current = snapHud(earlySnap);
|
||||
setHud(hudRef.current);
|
||||
}
|
||||
@@ -1045,10 +1049,6 @@ function MatchScreen() {
|
||||
}, []);
|
||||
|
||||
// ---- direction step (research 09 §1.2) and the selected piece's underframe ------------------------------------
|
||||
// DESIGN §16: previews show the range the unit fights with under the player's loadout (an elite's module grid); a
|
||||
// chess the player does not own the range of its stand-in (0.2.0 补位, gameLogic deployedRecord)
|
||||
const lookups = useMemo(() => ({ getChess: ownChess, getToken: gd.token, getItem: gd.item,
|
||||
chessRecord: (rec) => deployedRecord(rec, live.current.priv, ownChess, gd.backups) }), [gd.ready]);
|
||||
const heldRef = useRef(new Map()); // uid → { row, col, t } committed placements awaiting m.private
|
||||
const releaseHold = useCallback((uid) => {
|
||||
heldRef.current.delete(uid);
|
||||
@@ -1100,16 +1100,10 @@ function MatchScreen() {
|
||||
useEffect(() => {
|
||||
if (facing && (!editable || !placeCtx.pieces.get(facing.uid))) cancelFacing();
|
||||
}, [editable, placeCtx, facing]);
|
||||
// the selected piece: gone / not editable → deselect; on the board its range tiles show (rotated to its facing)
|
||||
// the selected piece: gone / not editable → deselect (its range tiles: the detail card's, cardRange below)
|
||||
const selEntry = sel ? placeCtx.pieces.get(sel.uid) || null : null;
|
||||
live.current.showPrep = showPrep;
|
||||
useEffect(() => { if (sel && (!selEntry || !editable || !showPrep)) setSel(null); }, [sel, selEntry, editable, showPrep]);
|
||||
const selRangeKey = selEntry && selEntry.area === 'board' ? `${selEntry.piece.uid}:${selEntry.row},${selEntry.col}:${pieceDir(selEntry.piece)}` : '';
|
||||
useEffect(() => {
|
||||
if (!view || !selRangeKey) return undefined;
|
||||
showRange(view, previewGrid(lookups, selEntry.piece), selEntry.row, selEntry.col, pieceDir(selEntry.piece), SEL_RANGE);
|
||||
return () => showRange(view, null, 0, 0, null, SEL_RANGE);
|
||||
}, [view, selRangeKey]);
|
||||
// the selected piece's underframe on screen: the detail card docks on the side away from it (user playtest #2
|
||||
// item 8 — at some aspect ratios a bench unit's 出售 sat under the left card); the underframe is drawn above every
|
||||
// panel anyway (css z-index), this keeps it visible too
|
||||
@@ -1296,7 +1290,7 @@ function MatchScreen() {
|
||||
}, [strip.ownerId]);
|
||||
// the popup shows the player it was opened for (ui/watchBonds.js popupView): their entry + live layers, their name,
|
||||
// and as members your pieces or their operators on the field on screen — under client-side combat the battle's own
|
||||
// (the runner's field meta is taken before they deploy); their hand is never sent
|
||||
// (the runner's field meta is taken before they deploy); a battle sends no bench (a prep scout does: ownerBoard)
|
||||
const popOps = cc && battleRunner && field?.local && bondOpen && bondOpen.ownerId !== myId ? battleRunner.ownerOps(bondOpen.ownerId, field.fieldId) : null;
|
||||
const bondPop = popupView({ open: bondOpen, pub, priv, myId, field, units: popOps, live: liveLayers });
|
||||
const openBond = (id, ownerId, from) => { setBondOpen((b) => toggleBond(b, id, ownerId, from)); audio.sfx('click', { volume: 0.4 }); };
|
||||
@@ -1350,6 +1344,23 @@ function MatchScreen() {
|
||||
}
|
||||
return priv?.loadout ?? null; // own pieces, shop / reward / bond-member cards
|
||||
})();
|
||||
// the open detail card's unit draws its attack range on the field (screens/game/range.js inspectRange, GitHub PR #281):
|
||||
// an own board piece in prep (its loadout / stand-in record, its facing); in battle or on a scouted board an ally
|
||||
// operator / summon on the field — the range it fights with now (the live stats' `range` and `dir`, the snapshot's
|
||||
// tile) — never an enemy, a dead unit or a bench unit (UnitInfo `area` hand / temp); drag, the wheel and the pen
|
||||
// draw their own
|
||||
const cardRange = !drag && !facing && !pen ? inspectRange({
|
||||
target: detailTarget, detail: resolved, pieces: placeCtx.pieces, showPrep, field,
|
||||
snapshot: snapUnitsRef.current.get(resolved?.unitId),
|
||||
live: typeof liveStats === 'function' ? liveStats() : liveStats,
|
||||
loadout: detailLoadout, getChess: gd.chess, backups: gd.backups,
|
||||
}) : null;
|
||||
const cardRangeKey = cardRange ? JSON.stringify(cardRange) : '';
|
||||
useEffect(() => {
|
||||
if (!view) return undefined;
|
||||
showRange(view, cardRange?.grid, cardRange?.row, cardRange?.col, cardRange?.dir, SEL_RANGE);
|
||||
return () => showRange(view, null, 0, 0, null, SEL_RANGE);
|
||||
}, [view, cardRangeKey, showPrep, field]);
|
||||
// the card's bond chips — and the popup a chip opens: a battle / scouted unit's OWNER's bonds (yours, or that
|
||||
// teammate's — m.public + live layers), your own piece's yours, a popup member card the popup's player; shop / reward
|
||||
// cards read the strip's (ui/watchBonds.js detailBondOwner)
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
// screens/game/range.js — the attack range the open detail card's unit draws on the field (GitHub PR #281 by
|
||||
// @IceCodeNew; the field-detail range idea after @BBleae's fork): see game.js cardRange.
|
||||
import { attackRangeGrid } from '../../../../shared/loadoutRecord.js';
|
||||
import { chessLoadout, standInLoadout } from '../../ui/gameLogic.js';
|
||||
import { tokenVariantFor } from '../../ui/gameLogic/loadout.js';
|
||||
import { normDir } from '../../ui/facing.js';
|
||||
|
||||
/** Resolve the inspected field unit without granting placement or sale actions. */
|
||||
export function inspectRange({ target, detail, pieces, showPrep = false, field, snapshot, live, loadout, getChess, backups }) {
|
||||
if (!detail || (detail.type !== 'chess' && detail.type !== 'token')) return null;
|
||||
let row, col, dir;
|
||||
if (target?.kind === 'piece' && showPrep) {
|
||||
const e = pieces?.get(target.uid);
|
||||
if (!e || e.area !== 'board') return null;
|
||||
row = e.row; col = e.col; dir = normDir(e.piece.dir);
|
||||
} else if (target?.kind === 'unit' && !showPrep) {
|
||||
const unit = target.unit;
|
||||
if (!unit || unit.side !== 'ally' || (unit.kind !== 'op' && unit.kind !== 'token')) return null;
|
||||
if (unit.area != null && unit.area !== 'board') return null;
|
||||
// Snapshots exclude undeployed, hidden and removed units. Dead units remain briefly for their death animation.
|
||||
if (!field?.prep && (!snapshot || snapshot[3] <= 0)) return null;
|
||||
row = Math.round(snapshot ? snapshot[2] : unit.y);
|
||||
col = Math.round(snapshot ? snapshot[1] : unit.x);
|
||||
dir = normDir(live?.dir, null) || normDir(unit.dir, null) || (unit.facing === -1 ? 'LEFT' : 'RIGHT');
|
||||
} else return null;
|
||||
if (!Number.isFinite(row) || !Number.isFinite(col)) return null;
|
||||
const record = detail.type === 'token' ? { ...detail.token, ...tokenVariantFor(detail.token, detail.ownerId) }
|
||||
: detail.standIn ? standInLoadout(detail.standIn, getChess, backups)?.record || detail.standIn
|
||||
: chessLoadout(detail.chess, detail.diy ? null : loadout, getChess)?.record || detail.chess;
|
||||
const grid = Array.isArray(live?.range) ? live.range : attackRangeGrid(record);
|
||||
return grid?.length ? { grid, row, col, dir } : null;
|
||||
}
|
||||
@@ -17,7 +17,9 @@
|
||||
// already did.
|
||||
// Watching a teammate (DESIGN §20.15, ui/watchBonds.js) the strip and the popup show THAT player's bonds and layers:
|
||||
// the strip carries an amber "👁 name" tag (`owner`, the observing pill's spelling, research 09 §3.1) and amber rings,
|
||||
// the popup a "👁 name 的盟约" line; its member list reads the teammate's operators on the field (no hand: never sent).
|
||||
// the popup a "👁 name 的盟约" line; its member list reads the teammate's operators on the field — in prep their bench
|
||||
// too, counted like their own popup counts it (ui/watchBonds.js ownerBoard: hand / temp by the scout's `area`, GitHub
|
||||
// #385); a battle sends no bench.
|
||||
// 0.2.0 补位: a member the player fields as its stand-in (the own m.private.standIns; a teammate's unit that says so —
|
||||
// UnitInfo `standInFor`) is drawn as the stand-in — avatar, name, a small 「替补」 mark on the thumbnail — and its row
|
||||
// opens the stand-in's card (gameLogic memberStandIn; the owner's recall of the official mode, 2026-10-06).
|
||||
|
||||
@@ -46,6 +46,7 @@ import { SKILL_SUMMON_START_DEPLOY } from '../../../shared/constants.js';
|
||||
import { moduleBadge, fullTraitText } from './loadoutModel.js';
|
||||
import { t, tc, N_ } from '../../../shared/i18n.js';
|
||||
import { audio } from '../audio.js';
|
||||
import { tokenVariantFor } from './gameLogic/loadout.js';
|
||||
|
||||
const cx = (...p) => p.flat().filter(Boolean).join(' ');
|
||||
|
||||
@@ -580,21 +581,6 @@ export function summonDeployHint(token, startDeploy = SKILL_SUMMON_START_DEPLOY)
|
||||
: t('所属干员发动技能时才在摆放的位置出现(未摆放则不会出现)');
|
||||
}
|
||||
|
||||
/**
|
||||
* The token variant of the summon's owner (tokens.json `variants`, keyed by owner chess id): a golden owner's `_b`
|
||||
* entry (精锐 赫默's drone ATK 114, 精锐 巫恋's doll −30%), else its normal `_a` entry, else the first one.
|
||||
* @param {any} token tokens.json record
|
||||
* @param {string|null} ownerId the owner's chess id (null: unknown, e.g. a teammate's summon)
|
||||
*/
|
||||
export function tokenVariantFor(token, ownerId = null) {
|
||||
const vs = token?.variants || {};
|
||||
if (typeof ownerId === 'string') {
|
||||
const v = vs[ownerId] || vs[ownerId.replace(/_b$/, '_a')];
|
||||
if (v) return v;
|
||||
}
|
||||
return Object.values(vs)[0] || null;
|
||||
}
|
||||
|
||||
/** Chess id of the operator owning a token piece (`ownerUid`), from the player's own pieces (indexPieces). */
|
||||
function tokenOwnerId(piece, pieces) {
|
||||
if (!piece || piece.kind !== 'token' || !Number.isInteger(piece.ownerUid)) return null;
|
||||
|
||||
@@ -13,7 +13,8 @@
|
||||
// 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.
|
||||
// direction and Enter commits it (keyboard alternative) — Enter on a focused ✕ cancels, on any other focused
|
||||
// button does nothing while the wheel is open (GitHub #394). 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
|
||||
@@ -25,7 +26,7 @@ import { useEffect, useLayoutEffect, useRef, useState } from '../../vendor/hooks
|
||||
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';
|
||||
import { facingSwallows } from './gameLogic.js';
|
||||
import { facingSwallows, facingEnter } from './gameLogic.js';
|
||||
import { settingsStore } from './settings.js';
|
||||
import { t } from '../../../shared/i18n.js';
|
||||
|
||||
@@ -236,24 +237,36 @@ export function FacingWheel({ view, row, col, grid, name = '', onPreview, onComm
|
||||
};
|
||||
const onPointerCancel = () => { setDrag(null); setDir(null); };
|
||||
|
||||
// keyboard: arrows preview, Enter commits, Esc cancels (capture: the game's own shortcuts must not see them)
|
||||
// keyboard: arrows preview, Enter commits, Esc cancels (capture: the game's own shortcuts must not see them). Enter
|
||||
// follows the focus (gameLogic facingEnter, GitHub #394): on ✕ it cancels, on any other button (Tab reaches the HUD
|
||||
// behind the wheel) it does nothing, elsewhere it commits the previewed direction. The wheel takes the focus when it
|
||||
// opens, so a button focused before the drop (a click leaves it focused) cannot swallow the confirming Enter; Tab then
|
||||
// reaches ✕ first. Nothing gives the focus back when it closes (a restored 准备 would take the next Enter / Space).
|
||||
const rootRef = useRef(null);
|
||||
useEffect(() => {
|
||||
const onKey = (e) => {
|
||||
const L = live.current;
|
||||
if (e.key === 'Escape') { e.preventDefault(); e.stopImmediatePropagation(); L.onCancel(); return; }
|
||||
const k = dirFromKey(e.key);
|
||||
if (k) { e.preventDefault(); e.stopImmediatePropagation(); setDir(k); return; }
|
||||
if (e.key === 'Enter' && L.dir) { e.preventDefault(); e.stopImmediatePropagation(); L.onCommit(L.bdir); return; }
|
||||
const enter = facingEnter(e, !!L.dir);
|
||||
if (enter) {
|
||||
e.preventDefault(); e.stopImmediatePropagation();
|
||||
if (enter === 'cancel') L.onCancel();
|
||||
else if (enter === 'commit') L.onCommit(L.bdir);
|
||||
return;
|
||||
}
|
||||
// no ready / shop while choosing: Space and every key of the player's map (设置 → 快捷键)
|
||||
if (facingSwallows(e, settingsStore.get().keys)) { e.preventDefault(); e.stopImmediatePropagation(); }
|
||||
};
|
||||
window.addEventListener('keydown', onKey, true);
|
||||
try { rootRef.current?.focus({ preventScroll: true }); } catch { /* no focus: Enter still follows whatever has it */ }
|
||||
return () => window.removeEventListener('keydown', onKey, true);
|
||||
}, []);
|
||||
|
||||
const outside = !!drag && !!dir;
|
||||
const box = half * 2;
|
||||
return html`<div class=${cx('fwheel', drag && 'is-pressed', dir && `is-${dir.toLowerCase()}`)} role="dialog" aria-label=${name ? t('选择「{name}」的朝向', { name }) : t('选择朝向')}
|
||||
return html`<div ref=${rootRef} tabIndex="-1" class=${cx('fwheel', drag && 'is-pressed', dir && `is-${dir.toLowerCase()}`)} role="dialog" aria-label=${name ? t('选择「{name}」的朝向', { name }) : t('选择朝向')}
|
||||
onPointerDown=${onDown} onPointerMove=${onMove} onPointerUp=${onUp} onPointerCancel=${onPointerCancel}
|
||||
onContextMenu=${(e) => { e.preventDefault(); onCancel(); }}>
|
||||
${g ? html`<${Stripes} tiles=${tiles} view=${view} row=${row} col=${col} />` : null}
|
||||
|
||||
@@ -39,7 +39,7 @@ export { terrainInfo, deviceInfo, deviceTipAt, noteDeviceUnits } from './gameLog
|
||||
export { normalizeDraft, normalizeSp } from './gameLogic/draft.js';
|
||||
export { groupEnemies, PEN, penZoneTiles, penPlacement, previewEnemyKey, factionTypes } from './gameLogic/enemies.js';
|
||||
export { snapHud, bossFrac, bossPctText, hasFlag, UF, attackInterval, fmtNum, rangeGridBox } from './gameLogic/format.js';
|
||||
export { shortcutFor, closesOnFieldPress, shortcutBlocked, HOTKEY_ACTIONS, DEFAULT_HOTKEYS, isBindableCode, hotkeyLabel, sanitizeHotkeys, rebindHotkey, isDefaultHotkeys, hotkeyOf, actionForKey, captureHotkey, facingSwallows } from './gameLogic/shortcuts.js';
|
||||
export { shortcutFor, closesOnFieldPress, shortcutBlocked, HOTKEY_ACTIONS, DEFAULT_HOTKEYS, isBindableCode, hotkeyLabel, sanitizeHotkeys, rebindHotkey, isDefaultHotkeys, hotkeyOf, actionForKey, captureHotkey, facingSwallows, facingEnter } from './gameLogic/shortcuts.js';
|
||||
export { DEFAULT_SETTINGS, VOICE_LANGS, sanitizeSettings } from './gameLogic/settings.js';
|
||||
export { normalizeResult } from './gameLogic/result.js';
|
||||
export { chessLoadout, unitLoadout } from './gameLogic/loadout.js';
|
||||
|
||||
@@ -5,6 +5,15 @@ import { loadoutRecord, resolveRecordLoadout } from '../../../../shared/loadoutR
|
||||
import { isObj } from './shared.js';
|
||||
import { t } from '../../../../shared/i18n.js';
|
||||
|
||||
/** The summon owner's variant: exact chess id, normal sibling, then the first available variant. */
|
||||
export function tokenVariantFor(token, ownerId = null) {
|
||||
const vs = token?.variants || {};
|
||||
if (typeof ownerId === 'string') {
|
||||
const v = vs[ownerId] || vs[ownerId.replace(/_b$/, '_a')];
|
||||
if (v) return v;
|
||||
}
|
||||
return Object.values(vs)[0] || null;
|
||||
}
|
||||
|
||||
// ---- operator loadout (DESIGN §16) ----------------------------------------------------------------------------------
|
||||
|
||||
|
||||
@@ -140,8 +140,8 @@ export function captureHotkey(e) {
|
||||
* Map a keydown to a game shortcut under the player's key map (defaults: R refresh, F freeze, D level-up, Q retreat,
|
||||
* X sell, Space ready) or Esc (close; fixed). A shortcut key means its action even while a HUD button has focus (a
|
||||
* mouse click leaves the shop card / 刷新 focused, and Space must not re-trigger it); the caller prevents the button's
|
||||
* own activation. Enter still activates buttons (it cannot be a shortcut). Nothing while Ctrl / ⌘ / Alt is held or a
|
||||
* text field has the focus.
|
||||
* own activation. Enter still activates buttons (it cannot be a shortcut; behind the facing wheel only its ✕:
|
||||
* facingEnter). Nothing while Ctrl / ⌘ / Alt is held or a text field has the focus.
|
||||
* @param {{ key?: string, code?: string, ctrlKey?: boolean, metaKey?: boolean, altKey?: boolean, repeat?: boolean, target?: any }} e
|
||||
* @param {any} [keys] the player's map (settings `keys`)
|
||||
* @returns {'refresh'|'freeze'|'levelUp'|'retreat'|'sell'|'ready'|'escape'|null}
|
||||
@@ -164,6 +164,27 @@ export function shortcutFor(e, keys = DEFAULT_HOTKEYS) {
|
||||
*/
|
||||
export const facingSwallows = (e, keys = DEFAULT_HOTKEYS) => e?.key === ' ' || actionForKey(e, keys) != null;
|
||||
|
||||
// what a focused element is when Enter would activate it (a button, a link, a form field, an ARIA control)
|
||||
const CONTROL = 'button, a[href], input, select, textarea, summary, [contenteditable=""], [contenteditable="true"], '
|
||||
+ '[role="button"], [role="link"], [role="checkbox"], [role="switch"], [role="tab"], [role="menuitem"], [role="option"]';
|
||||
|
||||
/**
|
||||
* What Enter does while the facing wheel is open (GitHub #394, PR #395): on the wheel's own ✕ button it cancels; on
|
||||
* any other control (准备, 刷新, 设置, 玩法说明, a shop card … — Tab reaches them behind the wheel) nothing happens — the
|
||||
* wheel holds the keyboard as it holds Space and the shortcuts; with the focus anywhere else (the wheel itself, the
|
||||
* page) it confirms the previewed direction, and does nothing while none is previewed. null: not Enter.
|
||||
* @param {{ key?: string, target?: any }} e @param {boolean} chosen a direction is previewed
|
||||
* @returns {'cancel'|'commit'|'swallow'|null}
|
||||
*/
|
||||
export function facingEnter(e, chosen) {
|
||||
if (e?.key !== 'Enter') return null;
|
||||
const t = e.target;
|
||||
const closest = (sel) => (t && typeof t.closest === 'function' ? t.closest(sel) : null);
|
||||
if (closest('.fwheel__cancel')) return 'cancel';
|
||||
if (closest(CONTROL)) return 'swallow';
|
||||
return chosen ? 'commit' : 'swallow';
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a press on the field closes the open detail card: a card opened from the field itself (an own piece — tap,
|
||||
* right-click or long press — or a battle / teammate unit). Shop, reward, bond-member and intel (enemy) cards stay.
|
||||
|
||||
+19
-16
@@ -151,27 +151,30 @@ export function withLiveLayers(bonds, live) {
|
||||
* A teammate's board as far as the screen shows it, in the m.private shape the bond popup's member list reads
|
||||
* (gameLogic bondMembers): their operators on the field meta on screen (the prep scouting board, a server-run battle
|
||||
* field's units) and, under client-side combat, the battle on screen's (`extra` = battle/runner.js ownerOps: the meta
|
||||
* the runner publishes is taken before the operators deploy). Their hand is never sent — members count as owned only
|
||||
* when they are on the field. Their operators keep the items the unit info carries (UnitInfo `items`), so a 变形同构体
|
||||
* wearer is listed as a member of the bond it grants (gameLogic grantedBonds), and a 补位 unit its `standInFor` (the
|
||||
* popup draws the stand-in). null without a field or battle.
|
||||
* @param {any} field @param {string} ownerId @param {any[]|null} [extra] UnitInfo-like { kind, ownerId, defId, items?, standInFor? }
|
||||
* the runner publishes is taken before the operators deploy). A prep scout also carries their bench, each unit tagged
|
||||
* with its `area` (server prepFieldMeta): a 'hand' operator goes to the hand list, a 'temp' one to the temp list, so the
|
||||
* popup counts them as the player's own popup does (GitHub #385, PR #387) — owned, not 在场; a hand operator counts for
|
||||
* the bonds that count the 整备区 (投资人 远见 奇迹: memberHeadCount), the 5 temporary slots for none. A unit without
|
||||
* an area (a battle's, an older server's) is on the board; a battle sends no bench. Their operators keep the items the
|
||||
* unit info carries (UnitInfo `items`), so a 变形同构体 wearer is listed as a member of the bond it grants (gameLogic
|
||||
* grantedBonds), and a 补位 unit its `standInFor` (the popup draws the stand-in). null without a field or battle.
|
||||
* @param {any} field @param {string} ownerId @param {any[]|null} [extra] UnitInfo-like { kind, ownerId, defId, area?, items?, standInFor? }
|
||||
*/
|
||||
export function ownerBoard(field, ownerId, extra = null) {
|
||||
const fromField = isObj(field) && Array.isArray(field.units) ? field.units : null;
|
||||
const fromBattle = Array.isArray(extra) ? extra : null;
|
||||
if (!fromField && !fromBattle) return null;
|
||||
const board = [...(fromField || []), ...(fromBattle || [])]
|
||||
.filter((u) => isObj(u) && u.ownerId === ownerId && u.kind === 'op' && typeof u.defId === 'string')
|
||||
.map((u) => {
|
||||
const p = Array.isArray(u.items) && u.items.length ? { kind: 'chess', id: u.defId, items: u.items.filter((x) => typeof x === 'string') } : { kind: 'chess', id: u.defId };
|
||||
// 0.2.0 自选编队: a DIY slot's unit names its operator (UnitInfo diy) — the popup counts it for that operator's bonds
|
||||
if (isObj(u.diy) && typeof u.diy.charId === 'string') p.diy = u.diy;
|
||||
// 0.2.0 补位: a unit fighting as its stand-in says so (UnitInfo standInFor) — the popup draws that member as the stand-in
|
||||
if (typeof u.standInFor === 'string' && u.standInFor) p.standInFor = u.standInFor;
|
||||
return p;
|
||||
});
|
||||
return { board, hand: [], temp: [] };
|
||||
const out = { board: /** @type {any[]} */ ([]), hand: /** @type {any[]} */ ([]), temp: /** @type {any[]} */ ([]) };
|
||||
for (const u of [...(fromField || []), ...(fromBattle || [])]) {
|
||||
if (!isObj(u) || u.ownerId !== ownerId || u.kind !== 'op' || typeof u.defId !== 'string') continue;
|
||||
const p = Array.isArray(u.items) && u.items.length ? { kind: 'chess', id: u.defId, items: u.items.filter((x) => typeof x === 'string') } : { kind: 'chess', id: u.defId };
|
||||
// 0.2.0 自选编队: a DIY slot's unit names its operator (UnitInfo diy) — the popup counts it for that operator's bonds
|
||||
if (isObj(u.diy) && typeof u.diy.charId === 'string') p.diy = u.diy;
|
||||
// 0.2.0 补位: a unit fighting as its stand-in says so (UnitInfo standInFor) — the popup draws that member as the stand-in
|
||||
if (typeof u.standInFor === 'string' && u.standInFor) p.standInFor = u.standInFor;
|
||||
out[u.area === 'hand' ? 'hand' : u.area === 'temp' ? 'temp' : 'board'].push(p);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in new issue
Block a user