Playtest #6 follow-up (v2.5.1): leader HP review — 直接乘算, one debuff instance, 999 layer cap, 限伤; elite to the board; 假想敌:胄 kit
- co-op leaders died within seconds: leader HP is the official data (40 values pinned); bond / strategy / item / 机变 / 特质 "+X%" bonuses now add up (PRTS 直接乘算) instead of compounding; a same-named enemy debuff keeps one (strongest) instance per target (奥术 across a pair field); leader parts pass damage 1:1; fixed co-op pool (the user's call) - official limits read from the client: each bond stops at 999 layers (MAX_GARRISON_STACK); a leader hit of 300000+ in boss / hidden battles is cancelled (MAX_BATTLE_DAMAGE, 限伤) — shared/constants.js switches, research 11 - a merge that consumes a deployed copy puts the elite on that tile and facing (user-confirmed); gold target highlight, promotion effect - 假想敌:胄 per PRTS: one shell, shells within range, any drone death costs 2 % max HP, 剑/锤 sorties and 瘫痪 - DESIGN §20.10–§20.14; 2842 unit tests, all 28 browser suites green; Chrome = Node on boss fields
This commit is contained in:
1 parent
6464e4b46a
commit
620f88c0d8
81 files changed
+4194
-447
No files matched your search
@@ -199,6 +199,8 @@
|
||||
.dempty { font-size: .14rem; }
|
||||
.dhint { display: flex; align-items: flex-start; gap: .06rem; font-size: .14rem; color: var(--text-lo); line-height: 1.6; }
|
||||
.dhint .icon { width: .16rem; height: .16rem; margin-top: .03rem; flex: none; }
|
||||
/* a merge-completing shop / reward card's 可晋升 destination (the tag's colour; touch shows no title — QA 6b) */
|
||||
.dhint--merge { color: var(--gold); }
|
||||
.dactions { display: flex; justify-content: flex-end; gap: .1rem; padding-top: .06rem; border-top: 1px solid var(--line); }
|
||||
.dsell { margin-left: .06rem; color: #1d1400; }
|
||||
.dfactions { display: flex; flex-wrap: wrap; gap: .06rem; }
|
||||
|
||||
+32
-2
@@ -8,7 +8,10 @@
|
||||
// and the field clear of `hud(kind, size)` = { top, bottom } px of
|
||||
// DOM HUD along the top / bottom edge (projection.js clearHud; user
|
||||
// playtest #5 item 9: the shop bar covered the bench on phones)
|
||||
// view.setPrep(privateState, { editable, canPlace }) hand/temp/board pieces; editable enables drag & drop
|
||||
// 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
|
||||
// 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] }
|
||||
// view.pushSnapshot(snap); view.pushEvents(ev | { ev, gt }) b.snap / b.ev wire frames as received (game time in
|
||||
// `gt`; a numeric `t` is accepted for raw Battle snapshots / recordings)
|
||||
@@ -101,6 +104,7 @@ import { AREAS, areaFor, unionAreas } from './board3d/layout.js';
|
||||
import { layoutPen, penSignature } from './pen.js';
|
||||
import { IDENTITY, bossPrepField, tilesToDisp } from './prepfield.js';
|
||||
import { pickOnTile, pickBattle, hitRectAt } from './pick.js';
|
||||
import { promotionsOf } from './promote.js';
|
||||
|
||||
const VENDOR = { pixi: '/vendor/pixi.min.js', spine: '/vendor/pixi-spine.js' };
|
||||
const PIECE_DIRS = new Set(['UP', 'RIGHT', 'DOWN', 'LEFT']);
|
||||
@@ -427,6 +431,10 @@ export async function createFieldView(host, options = {}) {
|
||||
let pendingView = null; // tile band/focus to apply when the camera transition ends
|
||||
const views = new Map(); // key → view (prep: 'p:'+uid; battle: unit id)
|
||||
let prepPieces = []; // { uid, piece, area, idx, row, col, key }
|
||||
// the last prep pieces before a battle / scouting board emptied prepPieces: a merge completed between two preps (a band
|
||||
// or 机变 grant at ROUND_START, a SETTLE merge) is still recognised by the next setPrep (QA 6b)
|
||||
let promoBase = [];
|
||||
const promotions = []; // the last merges cued by setPrep (fx.promote): { uid, id, area, row, col, idx, copies } — dev / tests
|
||||
let battleMeta = null;
|
||||
const infos = new Map(); // battle unit id → UnitInfo
|
||||
// battle ids whose view finished its death / leak fade: a snapshot may still list them for a moment (the sim keeps
|
||||
@@ -845,6 +853,20 @@ export async function createFieldView(host, options = {}) {
|
||||
addList(src.board, 'board');
|
||||
const seen = new Set();
|
||||
const prevBoard = new Set(prepPieces.filter((p) => p.area === 'board').map((p) => p.uid));
|
||||
// merges since the last state (render/promote.js): new elite uid → where its consumed copies stood (their views
|
||||
// still exist until the sweep below) — the elite gets the promotion cue instead of the deploy flash
|
||||
const promoFrom = new Map();
|
||||
const before = prepPieces.length ? prepPieces : promoBase;
|
||||
promoBase = [];
|
||||
for (const [uid, copies] of promotionsOf(before, list, (id) => data.chess(id))) {
|
||||
// the consumed copies' views (gone after a battle rebuilt the scene: their slots instead) — the streaks' origins
|
||||
promoFrom.set(uid, copies.map((g) => {
|
||||
const cv = views.get(g.key);
|
||||
if (cv && !cv.destroyed) return { x: cv.x, y: cv.y, z: cv.z || 0 };
|
||||
const w = slotWorld(g);
|
||||
return w ? { x: w.x, y: w.y, z: w.z || 0 } : null;
|
||||
}).filter(Boolean));
|
||||
}
|
||||
for (const e of list) {
|
||||
if (seen.has(e.uid)) continue; // duplicate uid in bad state: keep the first
|
||||
seen.add(e.uid);
|
||||
@@ -863,7 +885,13 @@ export async function createFieldView(host, options = {}) {
|
||||
v._sig = sig;
|
||||
v.setWorld(w.x, w.y, w.z);
|
||||
views.set(key, v);
|
||||
if (e.area === 'board' && prevBoard.size && !prevBoard.has(e.uid)) { v.onDeploy?.(); fx.deploy(v); }
|
||||
if (promoFrom.has(e.uid)) {
|
||||
// a merge's elite: on the tile of the deployed copy it replaced, or on its bench slot
|
||||
if (e.area === 'board') v.onDeploy?.();
|
||||
fx.promote(v, promoFrom.get(e.uid).filter((f) => Math.abs(f.x - w.x) + Math.abs(f.y - w.y) > 1e-3));
|
||||
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 {
|
||||
const prevHome = v._home;
|
||||
const moved = !prevHome || prevHome.x !== w.x || prevHome.y !== w.y || prevHome.z !== w.z;
|
||||
@@ -1226,6 +1254,7 @@ export async function createFieldView(host, options = {}) {
|
||||
drag.reset();
|
||||
endDragVisual(false);
|
||||
clearViews();
|
||||
if (prepPieces.length) promoBase = prepPieces;
|
||||
prepPieces = [];
|
||||
infos.clear();
|
||||
gone.clear();
|
||||
@@ -1783,6 +1812,7 @@ export async function createFieldView(host, options = {}) {
|
||||
/** Dev hooks (demo / tests). */
|
||||
debug: {
|
||||
app, get cam() { return cam; }, get board3d() { return board3d; }, tiles, views, penViews, interp, fx, ctx, drag, get camKind() { return viewKind(camKind, camOpts); },
|
||||
promotions,
|
||||
// picking (render/pick.js) at canvas px: the prep piece / battle view / pen view there, the ground tile under it
|
||||
pick: { pieceAt, battleUnitAt, penUnitAt, groundTile },
|
||||
},
|
||||
|
||||
+30
-3
@@ -212,6 +212,9 @@ export const FX_KINDS = Object.freeze({
|
||||
thorns: { a: 'counter', c: 0xff9aa6 }, downed: { a: 'down', c: 0xbfeee2 }, stone: { a: 'down', c: 0xc0b8a8, smoke: 0x5a5448 },
|
||||
dp: { a: 'dp', c: 0x9fd4ff }, coin: { a: 'coin', c: 0xffc600 }, steal: { a: 'coin', c: 0xffc600 }, crateBreak: { a: 'crate', c: 0xc89a5a },
|
||||
lpLoss: { a: 'lp', c: 0xff3b30 },
|
||||
// 限伤 (sim/damage.js leaderHitCancelled): a leader's hit of ≥ 300000 dealt nothing — the official shows no number and
|
||||
// no effect [ASSUMED], so nothing is drawn (`a: 'none'`)
|
||||
hitCap: { a: 'none', c: 0xffffff },
|
||||
// beams
|
||||
beam: { a: 'beam', c: 0xff7a5a }, link: { a: 'beam', c: 0x9ff0dc }, lightning: { a: 'bolt', c: 0xc9a2ff }, tentacle: { a: 'beam', c: 0x5fe0ff },
|
||||
sandChains: { a: 'beam', c: 0xd8c8a0 }, sandChainsCharged: { a: 'beam', c: 0xffd45a },
|
||||
@@ -1578,6 +1581,29 @@ export class FxSystem {
|
||||
this.burst(p.x, p.y, s, 6, col, { speed: 1.6, up: 0.4, life: 0.4, tex: 'dot' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Promotion (精锐晋升, a merge) at the elite's spot — the board tile of the consumed copy it replaced (PRTS 卫戍协议/
|
||||
* 帮助 "若消耗已部署至作战区的干员,则发送至作战区对应位置") or its bench slot: a gold pillar with a white core, a shockwave
|
||||
* and a hex ring on the ground, rising motes; `from` = world points of the other consumed copies (gold streaks from
|
||||
* them to the elite). Counted in `promotions` (render/app.js setPrep; tests).
|
||||
* @param {any} view
|
||||
* @param {Array<{ x: number, y: number, z?: number }>} [from]
|
||||
*/
|
||||
promote(view, from = []) {
|
||||
if (!view) return;
|
||||
this.promotions = (this.promotions || 0) + 1;
|
||||
const z = view.z || 0;
|
||||
const g = this._proj(view.x, view.y, z, this._g);
|
||||
const gx = g.x, gy = g.y, s = g.s;
|
||||
for (const f of from) if (f && Number.isFinite(f.x) && Number.isFinite(f.y)) this.streak(f.x, f.y, view.x, view.y, Math.max(z, f.z || 0) + 0.3, SKILL_GOLD, 0.45);
|
||||
this.particle('pillar', gx, gy, { tint: SKILL_GOLD, life: 0.8, s0: (s / 64) * 1.1, s1: (s / 64) * 1.4, a0: 0.95, a1: 0, sx: 0.85, ay: 1 });
|
||||
this.particle('pillar', gx, gy, { tint: 0xffffff, life: 0.42, s0: (s / 64) * 0.9, s1: (s / 64) * 1.2, a0: 0.9, a1: 0, sx: 0.3, ay: 1 });
|
||||
this.ring(view.x, view.y, z, 0.15, 1.6, 0xffe7a0, 0.5, 'shock');
|
||||
this.ring(view.x, view.y, z, 0.3, 1.2, SKILL_GOLD, 0.7, 'hex');
|
||||
const c = this._chest(view, this._q);
|
||||
this.burst(c.x, c.y, s, this.rich ? 12 : 5, 0xffe28a, { speed: 1.5, up: 1.8, life: 0.8, tex: 'dot', size: 0.36 });
|
||||
}
|
||||
|
||||
death(view) {
|
||||
if (!view) return;
|
||||
const p = this._chest(view);
|
||||
@@ -1637,14 +1663,15 @@ export class FxSystem {
|
||||
/**
|
||||
* b.ev 'fx': every kind the sim / content emits has a visual (FX_KINDS archetypes: blast, shell, zone, telegraph,
|
||||
* heal, sp, shield, shatter, summon, vanish, blink, move, wave, mark, reticle, buff, lift, sleep, crit, dodge,
|
||||
* counter, dp, coin, crate, down, beam, bolt, strike, volley, pillar, lp, chill, element, flame); unknown kinds get a
|
||||
* generic sparkle. `extra` keys used: id (anchor unit — or the shooter of a `pt` kind), r | radius, dur | duration,
|
||||
* counter, dp, coin, crate, down, beam, bolt, strike, volley, pillar, lp, chill, element, flame), except kinds whose
|
||||
* archetype is 'none' (hitCap: a leader hit cancelled by 限伤 draws nothing); unknown kinds get a generic sparkle. `extra` keys used: id (anchor unit — or the shooter of a `pt` kind), r | radius, dur | duration,
|
||||
* t (shell flight, game s), src / from / to / target / targets (unit ids), fx, fy / fromX, fromY / tx, ty (positions),
|
||||
* element, n, scale, kind, tiles.
|
||||
*/
|
||||
simFx(kind, x, y, extra) {
|
||||
const ex = extra && typeof extra === 'object' ? extra : {};
|
||||
const spec = fxSpec(kind, ex);
|
||||
if (spec.a === 'none') return; // an event the screen does not show (hitCap)
|
||||
const at = spec.pt ? this._point(Number(x), Number(y)) : this._where(Number(x), Number(y), ex);
|
||||
if (!Number.isFinite(at.x) || !Number.isFinite(at.y)) return;
|
||||
// 蕾缪安 S2: the aimed snipe ('crit' on the locked enemy, from her) ends that aim lock
|
||||
@@ -2174,7 +2201,7 @@ export class FxSystem {
|
||||
}
|
||||
|
||||
get counts() {
|
||||
return { particles: this.parts.length, projectiles: this.projs.length, numbers: this.nums.length, rings: this.rings.length, auras: this.auras.size, locks: this.locks.length, flames: this.flames.length };
|
||||
return { particles: this.parts.length, projectiles: this.projs.length, numbers: this.nums.length, rings: this.rings.length, auras: this.auras.size, locks: this.locks.length, flames: this.flames.length, promotions: this.promotions || 0 };
|
||||
}
|
||||
|
||||
destroy() {
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
// public/js/render/promote.js — which prep pieces were just promoted (精锐晋升), for the merge cue of render/app.js setPrep.
|
||||
//
|
||||
// A merge replaces the consumed normal copies by ONE new elite piece in the same m.private update: on the board tile of
|
||||
// the consumed copy that deploys first when a copy was deployed (PRTS 卫戍协议/帮助 "若消耗已部署至作战区的干员,则发送至
|
||||
// 作战区对应位置"; server board.js mergeTile), else in the hand / temp. So a promotion = a chess piece with a new uid that is
|
||||
// an elite, while normal copies of the same operator (same baseId) vanished from the previous state. The cue then plays
|
||||
// on the elite (fx.promote: gold pillar + rings, streaks from the vanished copies) instead of the plain deploy flash.
|
||||
|
||||
/**
|
||||
* @param {Array<{ uid: number, area: string, piece: any }>} prev entries of the previous setPrep (render/app.js prepPieces)
|
||||
* @param {Array<{ uid: number, area: string, piece: any }>} next entries of this setPrep
|
||||
* @param {(id: string) => any} chessOf chess record lookup (`baseId`, `isGolden`)
|
||||
* @returns {Map<number, Array<{ uid: number, area: string, piece: any }>>} new elite uid → the vanished copies (prev entries)
|
||||
*/
|
||||
export function promotionsOf(prev, next, chessOf) {
|
||||
const out = new Map();
|
||||
if (!Array.isArray(prev) || !prev.length || !Array.isArray(next)) return out;
|
||||
const before = new Set(prev.map((e) => e.uid));
|
||||
const now = new Set(next.map((e) => e.uid));
|
||||
const baseOf = (id) => chessOf(id)?.baseId || id;
|
||||
const isElite = (p) => !!(p?.golden || chessOf(p?.id)?.isGolden);
|
||||
const gone = prev.filter((e) => e.piece?.kind === 'chess' && !now.has(e.uid) && !isElite(e.piece));
|
||||
if (!gone.length) return out;
|
||||
const used = new Set();
|
||||
for (const e of next) {
|
||||
if (e.piece?.kind !== 'chess' || before.has(e.uid) || !isElite(e.piece)) continue;
|
||||
const base = baseOf(e.piece.id);
|
||||
const copies = gone.filter((g) => !used.has(g.uid) && baseOf(g.piece.id) === base);
|
||||
if (!copies.length) continue;
|
||||
for (const g of copies) used.add(g.uid);
|
||||
out.set(e.uid, copies);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -44,6 +44,10 @@
|
||||
// User playtest #4: the detail card shows live stats (item 7) — in battle the local sim's unit (battle/runner.js
|
||||
// unitStats), in prep an own board unit's start-of-battle stats (g.unitStats → m.unitStats); 机变 cards take two taps
|
||||
// (item 2, ui/choiceOverlay.js).
|
||||
// Merges (user playtest #6 follow-up, PRTS 卫戍协议/帮助 "若消耗已部署至作战区的干员,则发送至作战区对应位置"): a shop /
|
||||
// reward card armed for a purchase that completes a merge lights, in gold, the board tile its elite will take (the
|
||||
// deployed copy that deploys first, gameLogic.mergeTarget — none when no copy is deployed: the elite goes to the hand);
|
||||
// the elite then appears there with the promotion cue (render/app.js setPrep → fx.promote).
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from '../../vendor/hooks.module.js';
|
||||
import { PHASE, GEO } from '../../../shared/constants.js';
|
||||
@@ -76,7 +80,7 @@ import {
|
||||
phaseMode, phaseBanner, isCombatPhase, showDeadPill, isBossPhase, placementContext, canPlace, boardTargets, dropIntent,
|
||||
snapHud, activeBubbles, shortcutFor, shortcutBlocked, closesOnFieldPress, phaseTotalSeconds, homeFieldId, ownFieldId, normalizeSp, sortedPlayers,
|
||||
countdownState, shopBlockReason, stageOverrides, effectiveStage, watchTarget, dropFailureReason,
|
||||
previewEnemyKey, prepCamera, deployFieldOf, panelSide, panelSlots, bondPopupPlace, chessLoadout, unitLoadout,
|
||||
previewEnemyKey, prepCamera, deployFieldOf, panelSide, panelSlots, bondPopupPlace, chessLoadout, unitLoadout, mergeTarget,
|
||||
} from '../ui/gameLogic.js';
|
||||
import { toast } from '../ui/toasts.js';
|
||||
import { BriefingScreen } from './briefing.js';
|
||||
@@ -94,6 +98,8 @@ const cx = (...p) => p.flat().filter(Boolean).join(' ');
|
||||
const HUD_HZ_MS = 200;
|
||||
/** Range tiles of the selected unit (its own highlight group: the wheel's 'facing' group may be up at the same time). */
|
||||
const SEL_RANGE = Object.freeze({ group: 'selRange', color: 0xff9c33, fill: 0.3, line: 0.95 });
|
||||
/** The tile an armed merge-completing card's elite will take (its own group; gold like the promotion cue, render/fx.js). */
|
||||
const MERGE_HL = Object.freeze({ group: 'mergeTile', color: 0xffd45a, fill: 0.34, line: 1 });
|
||||
const STATE_EV = new Set(['spawn', 'die', 'deploy', 'status', 'skill']);
|
||||
|
||||
/** Router for the in-match screens. */
|
||||
@@ -192,6 +198,7 @@ function MatchScreen() {
|
||||
const [camKind, setCamKind] = useState('prep'); // kind of the last camera request (data-camera)
|
||||
const [replace, setReplace] = useState(null); // equip-replace dialog: { request, resolve }
|
||||
const [pauseBusy, setPauseBusy] = useState(false);
|
||||
const [armedCard, setArmedCard] = useState(null); // the shop bar's armed card { kind, id } (merge tile cue)
|
||||
const cc = isClientCombat(pub);
|
||||
const battleState = useStore((s) => s.match.battle, shallowEqual); // local battle runner (client-side combat)
|
||||
|
||||
@@ -518,6 +525,14 @@ function MatchScreen() {
|
||||
// promotion (merge reward offered) and bond activation cues
|
||||
const hasOffer = !!priv?.shop?.rewardOffer;
|
||||
useEffect(() => { if (hasOffer) audio.sfx('merge'); }, [hasOffer]);
|
||||
// a shop / reward card armed for a purchase that completes a merge lights the tile its elite will take (the deployed
|
||||
// copy that deploys first — PRTS 卫戍协议/帮助 "若消耗已部署至作战区的干员,则发送至作战区对应位置", gameLogic.mergeTarget)
|
||||
const mergeAt = armedCard?.kind === 'chess' && editable && showPrep ? mergeTarget(priv, armedCard.id, gd.chess) : null;
|
||||
const mergeAtKey = mergeAt ? `${mergeAt.row},${mergeAt.col}` : '';
|
||||
useEffect(() => {
|
||||
if (!view) return;
|
||||
try { view.highlightTiles(mergeAt ? [[mergeAt.row, mergeAt.col]] : [], MERGE_HL); } catch { /* cosmetic */ }
|
||||
}, [view, mergeAtKey]);
|
||||
const activeBonds = (priv?.bonds || []).filter((b) => b && b.active).length;
|
||||
const prevActive = useRef(activeBonds);
|
||||
useEffect(() => {
|
||||
@@ -842,6 +857,9 @@ function MatchScreen() {
|
||||
// panel anyway (css z-index), this keeps it visible too
|
||||
const ufTile = selEntry && editable && showPrep ? pieceTile(selEntry) : null;
|
||||
const ufGeo = useTileScreen(view, ufTile ? ufTile.row : null, ufTile ? ufTile.col : null);
|
||||
// the gold tile of an armed merge card (mergeAt): the detail card the first tap opened docks away from it (QA 6b — at
|
||||
// 1920×1080 the left card covered half of a target tile in the leftmost legal column)
|
||||
const mergeGeo = useTileScreen(view, mergeAt ? mergeAt.row : null, mergeAt ? mergeAt.col : null);
|
||||
const retreatSel = useCallback(async () => {
|
||||
const L = live.current;
|
||||
const uid = L.sel?.uid;
|
||||
@@ -1003,9 +1021,16 @@ function MatchScreen() {
|
||||
return { rem, vp };
|
||||
};
|
||||
const dSide = (() => {
|
||||
if (!ufShown) return 'left';
|
||||
const { rem, vp } = panelFrame();
|
||||
return panelSide(underframeRect(ufGeo, rem), panelSlots(vp, rem, { shopOpen }));
|
||||
if (ufShown) {
|
||||
const { rem, vp } = panelFrame();
|
||||
return panelSide(underframeRect(ufGeo, rem), panelSlots(vp, rem, { shopOpen }));
|
||||
}
|
||||
if (mergeGeo && resolved) {
|
||||
const { rem, vp } = panelFrame();
|
||||
const half = (mergeGeo.s > 0 ? mergeGeo.s : 64) * 1.05; // the tile diamond's box (underframeRect's)
|
||||
return panelSide({ left: mergeGeo.x - half, right: mergeGeo.x + half, top: mergeGeo.y - half, bottom: mergeGeo.y + half }, panelSlots(vp, rem, { shopOpen }));
|
||||
}
|
||||
return 'left';
|
||||
})();
|
||||
// the bond popup: next to the card, never over the selected unit's underframe when a place is free (bondPopupPlace)
|
||||
const bpPlace = (() => {
|
||||
@@ -1062,11 +1087,11 @@ function MatchScreen() {
|
||||
${showShop ? html`<${ShopBar} priv=${priv} editable=${editable} collapsed=${collapsed} onCollapse=${setCollapsed}
|
||||
barRef=${barRef}
|
||||
onBuy=${buy} onLevel=${() => actions.levelUp()} onRefresh=${() => actions.refresh()} onFreeze=${() => actions.freeze()}
|
||||
onDetail=${(id, kind) => setDetail({ kind: kind === 'item' ? 'item' : 'chess', id })}
|
||||
onDetail=${(id, kind, hint) => setDetail({ kind: kind === 'item' ? 'item' : 'chess', id, hint: hint || null })}
|
||||
onDetailClose=${() => setDetail((d) => (d?.kind === 'chess' || d?.kind === 'item' ? null : d))}
|
||||
onRefuse=${(reason) => { toast(reason, 'warn'); audio.sfx('error', { volume: 0.5 }); }}
|
||||
reward=${phase === PHASE.PREP && !rewardMin ? priv?.shop?.rewardOffer || null : null}
|
||||
onReward=${(i) => actions.reward(i)} onRewardLater=${() => setRewardMin(true)} />` : null}
|
||||
onReward=${(i) => actions.reward(i)} onRewardLater=${() => setRewardMin(true)} onArm=${setArmedCard} />` : null}
|
||||
|
||||
${phase === PHASE.PREP && priv?.shop?.rewardOffer ? html`<${RewardOverlay} priv=${priv} minimized=${rewardMin || collapsed}
|
||||
onMinimize=${(m) => { setRewardMin(m); if (!m) setCollapsed(false); }} />` : null}
|
||||
|
||||
@@ -204,7 +204,7 @@ function GarrisonBlock({ garrison, m }) {
|
||||
</section>`;
|
||||
}
|
||||
|
||||
export function ChessDetail({ chess, piece, unit, snapHp, editable, onSell, bonds, loadout, onBond, live = null }) {
|
||||
export function ChessDetail({ chess, piece, unit, snapHp, editable, onSell, bonds, loadout, onBond, live = null, hint = null }) {
|
||||
const m = data.get('assets');
|
||||
const hp = hpOf(live, snapHp);
|
||||
const lo = chessLoadout(chess, loadout, (id) => data.lookup('chess', id));
|
||||
@@ -304,7 +304,10 @@ export function ChessDetail({ chess, piece, unit, snapHp, editable, onSell, bond
|
||||
blocks.actions = piece && editable && piece.kind !== 'item' ? html`<div key="actions" class="dactions">
|
||||
<${Button} variant="amber" icon="close" class="dpanel__sell" onClick=${() => onSell(piece, c)}>出售<span class="dsell num">+${sell}</span><//>
|
||||
</div>` : null;
|
||||
return CHESS_SECTIONS.map((k) => blocks[k]).filter(Boolean);
|
||||
const out = CHESS_SECTIONS.map((k) => blocks[k]).filter(Boolean);
|
||||
// a merge-completing shop / reward card: where the elite goes (shopBar mergeHint), right under the header
|
||||
if (hint) out.splice(1, 0, html`<p key="merge" class="dhint dhint--merge"><${Icon} name="info" />可晋升:${hint}</p>`);
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -467,7 +470,7 @@ export function resolveDetail(target, pieces) {
|
||||
const c = data.lookup('chess', p.id);
|
||||
return c ? { type: 'chess', chess: c, piece: p } : null;
|
||||
}
|
||||
if (target.kind === 'chess') { const c = data.lookup('chess', target.id); return c ? { type: 'chess', chess: c } : null; }
|
||||
if (target.kind === 'chess') { const c = data.lookup('chess', target.id); return c ? { type: 'chess', chess: c, hint: target.hint || null } : null; }
|
||||
if (target.kind === 'item') { const it = data.lookup('items', target.id); return it ? { type: 'item', item: it } : null; }
|
||||
if (target.kind === 'enemy') { const en = data.lookup('enemies', target.id); return en ? { type: 'enemy', enemy: en, count: target.count } : null; }
|
||||
if (target.kind === 'token') { const t = data.lookup('tokens', target.id); return t ? { type: 'token', token: t } : null; }
|
||||
@@ -518,7 +521,7 @@ export function DetailPanel({ detail, editable, snapHp, onClose, onSell, onDestr
|
||||
<button type="button" class="dpanel__close" aria-label="关闭" onClick=${onClose}><${Icon} name="close" /></button>
|
||||
<div class="dpanel__scroll">
|
||||
${detail.type === 'chess' ? html`<${ChessDetail} chess=${detail.chess} piece=${detail.piece} snapHp=${snapHp} editable=${editable} onSell=${sellIt}
|
||||
bonds=${bonds} loadout=${loadout} onBond=${onBond} live=${liveNow} />` : null}
|
||||
bonds=${bonds} loadout=${loadout} onBond=${onBond} live=${liveNow} hint=${detail.hint || null} />` : null}
|
||||
${detail.type === 'item' ? html`<${ItemDetail} item=${detail.item} piece=${detail.piece} editable=${editable} onDestroy=${destroyIt} />` : null}
|
||||
${detail.type === 'enemy' ? html`<${EnemyDetail} enemy=${detail.enemy} snapHp=${snapHp} count=${detail.count} live=${liveNow} />` : null}
|
||||
${detail.type === 'token' ? html`<${TokenDetail} token=${detail.token} piece=${detail.piece} ownerId=${detail.ownerId ?? null} snapHp=${snapHp} live=${liveNow} />` : null}
|
||||
|
||||
@@ -458,7 +458,9 @@ export function priceTone(slot) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Owned copies of a base chess (normal pieces on board/hand/temp) — shop cards show merge progress.
|
||||
* Owned copies of a base chess (normal pieces on board/hand/temp) — shop cards show merge progress. An elite card
|
||||
* never merges (server PlayerState.completesChessMerge refuses isGolden), so it reports 0 copies: no pips, no 可晋升
|
||||
* tag and no mergeTarget tile.
|
||||
* @param {any} priv
|
||||
* @param {string} chessId
|
||||
* @param {(id:string)=>any} [getChess]
|
||||
@@ -468,12 +470,36 @@ export function mergeProgress(priv, chessId, getChess = () => null) {
|
||||
const c = getChess(chessId);
|
||||
const base = c?.baseId || chessId;
|
||||
const need = Number.isInteger(c?.upgradeNum) && c.upgradeNum > 0 ? c.upgradeNum : 3;
|
||||
if (c?.isGolden) return { copies: 0, need };
|
||||
let copies = 0;
|
||||
const pieces = [...(Array.isArray(priv?.board) ? priv.board : []), ...(Array.isArray(priv?.hand) ? priv.hand : []), ...(Array.isArray(priv?.temp) ? priv.temp : [])];
|
||||
for (const p of pieces) if (p?.kind === 'chess' && !p.golden && (p.id === base || getChess(p.id)?.baseId === base) && !String(p.id).endsWith('_b')) copies += 1;
|
||||
return { copies, need };
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the elite appears when gaining one more normal copy of `chessId` completes a merge now (PRTS 卫戍协议/帮助
|
||||
* "若消耗已部署至作战区的干员,则发送至作战区对应位置"; mirror of server board.js mergeTile / PlayerState._mergeChess): the
|
||||
* board tile of the deployed copy that deploys first (row desc, then col asc) — `{ row, col, dir }` — or null (no
|
||||
* merge — an elite card never merges, see mergeProgress — or no copy is deployed: the elite goes to the hand). The
|
||||
* copies stand on legal tiles, and the elite is the same operator, so the tile needs no legality check here.
|
||||
* @param {any} priv
|
||||
* @param {string} chessId
|
||||
* @param {(id:string)=>any} [getChess]
|
||||
* @returns {{ row: number, col: number, dir: string } | null}
|
||||
*/
|
||||
export function mergeTarget(priv, chessId, getChess = () => null) {
|
||||
const { copies, need } = mergeProgress(priv, chessId, getChess);
|
||||
if (!(copies > 0 && copies + 1 >= need)) return null;
|
||||
const c = getChess(chessId);
|
||||
const base = c?.baseId || chessId;
|
||||
const board = (Array.isArray(priv?.board) ? priv.board : []).filter((p) => p?.kind === 'chess' && !p.golden && Number.isInteger(p.row) && Number.isInteger(p.col)
|
||||
&& !String(p.id).endsWith('_b') && (p.id === base || getChess(p.id)?.baseId === base));
|
||||
if (!board.length) return null;
|
||||
board.sort((a, b) => b.row - a.row || a.col - b.col);
|
||||
return { row: board[0].row, col: board[0].col, dir: board[0].dir || 'RIGHT' };
|
||||
}
|
||||
|
||||
/** Whether every hand slot is taken (the server refuses a purchase / reward that needs a slot: HAND_FULL). */
|
||||
export function handFull(priv) {
|
||||
const hand = Array.isArray(priv?.hand) ? priv.hand : [];
|
||||
|
||||
+39
-8
@@ -19,7 +19,7 @@
|
||||
import { useEffect, useState } from '../../vendor/hooks.module.js';
|
||||
import { html, Icon, HexBadge, TierChip, Tooltip, MicroLabel } from './components.js';
|
||||
import { Img, BondGlyph, CoinGlyph, GIcon, RichText } from './gameComponents.js';
|
||||
import { priceTone, mergeProgress, shopBlockReason, chessLoadout } from './gameLogic.js';
|
||||
import { priceTone, mergeProgress, mergeTarget, shopBlockReason, chessLoadout } from './gameLogic.js';
|
||||
import { chessPortraitUrl, itemIconUrl, profIconUrl, uiUrl, skillIconUrl, skillRecordIconUrl, moduleTypeIconUrl } from './assetUrls.js';
|
||||
import { data } from '../data.js';
|
||||
|
||||
@@ -37,6 +37,16 @@ function PriceHex({ slot, free, poor = false }) {
|
||||
/** Data lookups for shopBlockReason (full-hand purchases that complete a merge stay allowed). */
|
||||
const LOOKUPS = { getChess: (id) => data.lookup('chess', id), getItem: (id) => data.lookup('items', id) };
|
||||
|
||||
/**
|
||||
* Where a merge-completing buy sends the elite (DESIGN §20.11), null when the card completes no merge: the 可晋升 tag's
|
||||
* title, and a line of the detail card the first tap opens — a touch screen never shows a title (QA 6b).
|
||||
*/
|
||||
export function mergeHint(priv, chessId) {
|
||||
const prog = mergeProgress(priv, chessId, LOOKUPS.getChess);
|
||||
if (!(prog.copies > 0 && prog.copies + 1 >= prog.need)) return null;
|
||||
return mergeTarget(priv, chessId, LOOKUPS.getChess) ? '精锐干员将出现在作战区原位置' : '精锐干员将进入整备区';
|
||||
}
|
||||
|
||||
/** The armed (first-tapped) card's confirm strip: 确认购买 / 确认选择, or 无法购买 + why. */
|
||||
function ArmedTag({ reason, free }) {
|
||||
if (reason) return html`<span class="scard__confirm is-no" role="status"><b>无法购买</b><small>${reason}</small></span>`;
|
||||
@@ -53,13 +63,14 @@ export function ChessCard({ slot, idx, priv, frozen = false, reason = null, free
|
||||
const m = data.get('assets');
|
||||
const tier = c?.tier ?? 1;
|
||||
const prog = mergeProgress(priv, slot.id, (id) => data.lookup('chess', id));
|
||||
const willMerge = prog.copies + 1 >= prog.need && prog.copies > 0;
|
||||
const hint = mergeHint(priv, slot.id);
|
||||
const willMerge = !!hint;
|
||||
const bonds = Array.isArray(c?.bonds) ? c.bonds : [];
|
||||
const disabled = !!reason;
|
||||
const lo = c ? chessLoadout(c, priv?.loadout, LOOKUPS.getChess) : null;
|
||||
const tap = () => { if (onTap) onTap(idx); else if (!disabled) onBuy(idx); else onDetail(slot.id); };
|
||||
const tap = () => { if (onTap) onTap(idx); else if (!disabled) onBuy(idx); else onDetail(slot.id, 'chess', hint); };
|
||||
const card = html`<button type="button" class=${cx('scard', `scard--t${tier}`, frozen && 'is-frozen', disabled && 'is-disabled', willMerge && 'is-merge', armed && 'is-armed')}
|
||||
onClick=${tap} onContextMenu=${(e) => { e.preventDefault(); onDetail(slot.id); }}
|
||||
onClick=${tap} onContextMenu=${(e) => { e.preventDefault(); onDetail(slot.id, 'chess', hint); }}
|
||||
aria-label=${`${c?.name || '干员'},价格 ${slot.price}${armed ? (disabled ? ',无法购买' : ',再次点击确认') : ''}`} aria-pressed=${onTap ? String(!!armed) : undefined}>
|
||||
<span class="scard__bg" aria-hidden="true"></span>
|
||||
<span class="scard__water" aria-hidden="true">${bonds[0] ? html`<${BondGlyph} bondId=${bonds[0]} />` : null}</span>
|
||||
@@ -82,7 +93,7 @@ export function ChessCard({ slot, idx, priv, frozen = false, reason = null, free
|
||||
<span>${c?.subProfessionName || ''}</span>
|
||||
</span>
|
||||
</span>
|
||||
${willMerge ? html`<span class="scard__mergetag">可晋升</span>` : null}
|
||||
${willMerge ? html`<span class="scard__mergetag" title=${hint}>可晋升</span>` : null}
|
||||
${frozen ? html`<span class="scard__ice" aria-hidden="true"><${Icon} name="snow" /></span>` : null}
|
||||
${armed ? html`<${ArmedTag} reason=${reason} free=${free} />` : null}
|
||||
</button>`;
|
||||
@@ -160,6 +171,20 @@ function LevelCard({ shop, reason, armed = false, onTap }) {
|
||||
/** Key of an armed shop card: kind + slot index + the id it showed (a reroll / sale changes it ⇒ disarm). */
|
||||
export const armKey = (kind, idx, slot) => `${kind}:${idx}:${slot?.id ?? ''}`;
|
||||
|
||||
/**
|
||||
* The slot an armed key names (`c` / `i` shop slots, `r` reward slots), or null ('lv', nothing armed, a stale key).
|
||||
* @param {string|null} key
|
||||
* @param {any[]} slots the shop's slots
|
||||
* @param {any[]|null} rewardSlots the shown reward offer's slots
|
||||
*/
|
||||
export function armedSlotOf(key, slots, rewardSlots = null) {
|
||||
const m = typeof key === 'string' ? /^([cir]):(\d+):(.*)$/.exec(key) : null;
|
||||
if (!m) return null;
|
||||
const list = m[1] === 'r' ? rewardSlots : slots;
|
||||
const s = Array.isArray(list) ? list[Number(m[2])] : null;
|
||||
return s && !s.sold && s.id === m[3] ? s : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Two-tap state of the bar (first tap arms, second confirms). Disarms on a tap outside the shop / detail panel, on
|
||||
* Escape, when the armed card changes or disappears, and when the bar stops being editable.
|
||||
@@ -210,12 +235,12 @@ function RewardCards({ offer, priv, editable, onPick, onDetail, onLater, armed,
|
||||
/**
|
||||
* The bar.
|
||||
* @param {{ priv:any, editable:boolean, collapsed:boolean, onCollapse:(c:boolean)=>void,
|
||||
* onBuy:(i:number)=>void, onLevel:Function, onRefresh:Function, onFreeze:Function, onDetail:(id:string, kind?:string)=>void,
|
||||
* onBuy:(i:number)=>void, onLevel:Function, onRefresh:Function, onFreeze:Function, onDetail:(id:string, kind?:string, hint?:string|null)=>void,
|
||||
* onDetailClose?: () => void, onRefuse?: (reason: string) => void, barRef:any,
|
||||
* reward?: any, onReward?: (idx:number)=>void, onRewardLater?: Function }} props
|
||||
*/
|
||||
export function ShopBar({ priv, editable, collapsed, onCollapse, onBuy, onLevel, onRefresh, onFreeze, onDetail, onDetailClose, onRefuse, barRef,
|
||||
reward = null, onReward, onRewardLater }) {
|
||||
reward = null, onReward, onRewardLater, onArm = null }) {
|
||||
const shop = priv?.shop || {};
|
||||
const slots = Array.isArray(shop.slots) ? shop.slots : [];
|
||||
const chessSlots = slots.map((s, i) => ({ s, i })).filter(({ s }) => !s || s.kind !== 'item');
|
||||
@@ -237,10 +262,16 @@ export function ShopBar({ priv, editable, collapsed, onCollapse, onBuy, onLevel,
|
||||
if (showReward) reward.slots.forEach((s, i) => { if (s && !s.sold) keys.add(armKey('r', i, s)); });
|
||||
}
|
||||
const [armed, setArmed] = useTwoTap({ editable, keys });
|
||||
// the armed card's slot (game.js lights the tile a merge's elite will take — gameLogic.mergeTarget); kept before the
|
||||
// collapsed tab's early return (hook order)
|
||||
const armedSlot = armedSlotOf(armed, slots, showReward ? reward.slots : null);
|
||||
const armedSig = armedSlot ? `${armedSlot.kind}:${armedSlot.id}` : '';
|
||||
useEffect(() => { onArm?.(armedSlot ? { kind: armedSlot.kind === 'item' ? 'item' : 'chess', id: armedSlot.id } : null); }, [armedSig]);
|
||||
useEffect(() => () => onArm?.(null), []);
|
||||
/** First tap arms + opens the detail; the second buys (or says why it can't). */
|
||||
const tapCard = (kind, idx, slot, detailKind, reason, buy) => {
|
||||
const key = armKey(kind, idx, slot);
|
||||
if (armed !== key) { setArmed(key); onDetail(slot.id, detailKind); return; }
|
||||
if (armed !== key) { setArmed(key); onDetail(slot.id, detailKind, detailKind === 'chess' ? mergeHint(priv, slot.id) : null); return; }
|
||||
if (reason) { onRefuse?.(reason); return; }
|
||||
setArmed(null);
|
||||
onDetailClose?.();
|
||||
|
||||
Reference in new issue
Block a user