0.2.4-dev: round-29 community fixes and the official shared card pool
- Shared card pool and item stock as in the official mode (the owner's request, from the 路标档案馆 video and public notes): solo low-tier exceptions, co-op operators x2, shared item stock with special-item exceptions, normal / advanced occupancy 1 / 2, re-check on buy, 机变 exhaustion, release on consume / destroy / elimination; 整备 never exceeds stock; invariant held <= cap. - 突袭: one taunt-first reselect right after a revive (the owner's rule); ordinary polls and redeploys keep the 0.2.3 rule; a revive on an enemy's tile stays. #475: online boss / hidden-boss Raid may land on the empty half (owner-limited scope). - 浊心斯卡蒂 S1 x 坚守 no longer bounce redirected damage, and the two shares conserve the hit (idea of PR #464 by @HappyTanuki). - Operator fixes adapted from PRs by @Xinglan233 (#459 哈洛德 S2 elemental trigger, #460 涤火杰西卡 shield on the four adjacent tiles, #461 家族徽章 first hit after stealth, #462 松果 S2 regrow per deploy); 克莱门莎 S2 splash 1.1 / 100% ATK and air auto-trigger (#467); 白面 copying 歌蕾蒂娅 / 余 skips the three-member condition (#473, the owner's video). - 香槟炸弹 is not chosen by friendly auras / heals (PR #468 / #476 by @Siornya); 琳琅诗怀雅 coin balance (PR #450 by @IceCodeNew); Touch prep display (PR #451 by @YinqiMargatroid); lobby header (PR #471 by @YuriRestia); Japanese UI terms (PR #448 by @scutkane). - AI-only boss countdown grace, render-failure cache / recovery without restarting a battle, device units kept on a same-field re-entry, DIY detail / skill previews, large-text result buttons. - golden: battle families equal 0.2.3 except diy-135 / diy-136 (克莱门莎); all 18 release matches move through the shared pool, every flip named in docs/history/0.2.4.md §29.12; two coverage scenarios added.
This commit is contained in:
1 parent
1db8e51023
commit
6bfc08270c
129 files changed
+3477
-1011
No files matched your search
+22
-6
@@ -432,6 +432,13 @@ export async function createFieldView(host, options = {}) {
|
||||
}
|
||||
// Attach the board only after synchronous setup completes; enable3d also guards late arrivals.
|
||||
const boardReady = Promise.all([threePromise, packPromise]).then(([THREE, pack]) => (THREE && pack ? enable3d(THREE, pack) : false), () => false);
|
||||
let boardRetry = null;
|
||||
function retryBoard() {
|
||||
if (!want3d || destroyed || board3d || recover.off || recover.timer || boardRetry) return;
|
||||
boardRetry = Promise.all([loadThree(), loadBoardPack(assets)])
|
||||
.then(([THREE, pack]) => enable3d(THREE, pack), () => false)
|
||||
.finally(() => { boardRetry = null; });
|
||||
}
|
||||
// the official soft shadow sprite replaces the procedural one once loaded (may already be cached; asked again when the
|
||||
// manifest arrives late)
|
||||
let shadowAsked = false;
|
||||
@@ -797,8 +804,16 @@ export async function createFieldView(host, options = {}) {
|
||||
if (e.area === 'board' && info.dir && !held.has(e.uid) && typeof v.setDir === 'function') v.setDir(info.dir);
|
||||
}
|
||||
for (const k of [...views.keys()]) if (String(k).startsWith('p:') && !seen.has(Number(String(k).slice(2)))) dropView(k);
|
||||
// the boss round's prep: the pair partner's board on its half of the boss field (item 51; display only)
|
||||
syncMates(prepXf.kind === 'bossPrep' && src.bossMate && Array.isArray(src.bossMate.units) ? src.bossMate.units : []);
|
||||
// The 外勤医疗 reserve medic is visible before battle but not a movable board piece. Map its board-space slot to
|
||||
// the displayed boss half exactly as for the player's own pieces; the partner's units already use field coordinates.
|
||||
const mapChars = Array.isArray(src.prepMapChars) ? src.prepMapChars.map((u) => {
|
||||
if (!u || !Number.isInteger(u.x) || !Number.isInteger(u.y)) return null;
|
||||
const p = prepXf.toDisp(u.y, u.x);
|
||||
const dir = prepXf.dirToDisp(u.dir);
|
||||
return { ...u, x: p.col, y: p.row, dir, facing: dir === 'LEFT' ? -1 : 1 };
|
||||
}).filter(Boolean) : [];
|
||||
const mate = prepXf.kind === 'bossPrep' && src.bossMate && Array.isArray(src.bossMate.units) ? src.bossMate.units : [];
|
||||
syncReadOnlyPrepUnits([...mapChars, ...mate]);
|
||||
prepPieces = list.filter((e) => e.key && views.has(e.key));
|
||||
if (dragState && !views.has(dragState.key)) { drag.reset(); endDragVisual(false); }
|
||||
holdScene(false); // the prep pieces reference their models now (a battle's hold ends here)
|
||||
@@ -806,11 +821,10 @@ export async function createFieldView(host, options = {}) {
|
||||
}
|
||||
|
||||
/**
|
||||
* The pair partner's pieces in the Final Assault / Hidden Core prep (m.private bossMate units: UnitInfo in boss-field
|
||||
* coordinates, facing as the battle will place them): read-only 'm:<uid>' views — not prep pieces, so they are never
|
||||
* picked, dragged or swept with the own ones; a view is rebuilt when its body, tile or facing changes.
|
||||
* The reserve medic and a boss pair's partner pieces: read-only 'm:<uid>' views. These are not prep pieces, so they
|
||||
* are never picked, dragged or swept with the own board; a view is rebuilt when its body, tile or facing changes.
|
||||
*/
|
||||
function syncMates(units) {
|
||||
function syncReadOnlyPrepUnits(units) {
|
||||
const keep = new Set();
|
||||
for (const u of units) {
|
||||
const info = u && Number.isInteger(u.uid) ? renderInfo({ ...u, id: `m:${u.uid}` }) : null;
|
||||
@@ -1747,6 +1761,7 @@ export async function createFieldView(host, options = {}) {
|
||||
// load it again at once (their bounded retries never run while hidden: no frames).
|
||||
function onAssets() {
|
||||
if (destroyed) return;
|
||||
retryBoard();
|
||||
loadShadow();
|
||||
loadMountains();
|
||||
if (mode === 'prep' && lastPrep) setPrep(lastPrep.ps, lastPrep.o);
|
||||
@@ -1758,6 +1773,7 @@ export async function createFieldView(host, options = {}) {
|
||||
setupCleanup.push(() => offAssets?.());
|
||||
const onVisible = () => {
|
||||
if (destroyed || globalThis.document?.visibilityState !== 'visible') return;
|
||||
retryBoard();
|
||||
if (assets.loaded === false && typeof assets.ready === 'function') assets.ready();
|
||||
for (const v of views.values()) v.retryAssets?.();
|
||||
for (const v of penViews.values()) v.retryAssets?.();
|
||||
|
||||
@@ -16,10 +16,20 @@ import { parseObj } from './obj.js';
|
||||
export const THREE_URL = '/vendor/three.module.js';
|
||||
|
||||
let threePromise = null;
|
||||
let threeFailures = 0;
|
||||
/** Dynamic import of the vendored three.js ESM build (browser only); null when unavailable. */
|
||||
export function loadThree(url = THREE_URL) {
|
||||
if (!threePromise) {
|
||||
threePromise = import(/* @vite-ignore */ url).then((m) => (m && m.WebGLRenderer ? m : null), () => null);
|
||||
// Browsers also cache failed module imports. Two bounded retry URLs permit a transient download failure to
|
||||
// recover; clearing only our promise would keep receiving that same cached rejection without another request.
|
||||
if (threeFailures >= 3) return Promise.resolve(null);
|
||||
const source = new URL(url, import.meta.url);
|
||||
if (threeFailures) source.searchParams.set('sp-retry', String(threeFailures));
|
||||
const pending = import(/* @vite-ignore */ source.href).then((m) => (m && m.WebGLRenderer ? m : null), () => null).then((m) => {
|
||||
if (!m && threePromise === pending) { threePromise = null; threeFailures++; }
|
||||
return m;
|
||||
});
|
||||
threePromise = pending;
|
||||
}
|
||||
return threePromise;
|
||||
}
|
||||
@@ -95,7 +105,7 @@ async function fetchJson(url) {
|
||||
*/
|
||||
export function loadBoardPack(assets) {
|
||||
if (cached) return cached;
|
||||
cached = (async () => {
|
||||
const pending = (async () => {
|
||||
if (!assets || typeof assets.local !== 'function' || typeof assets.localUrl !== 'function' || typeof assets.image !== 'function') return null;
|
||||
const manifest = await assets.local().catch(() => null);
|
||||
if (!isObj(manifest)) return null;
|
||||
@@ -106,7 +116,7 @@ export function loadBoardPack(assets) {
|
||||
await Promise.all(Object.entries(PACK_IMAGES).map(async ([k, [g, n]]) => {
|
||||
const u = url(g, n);
|
||||
if (!u) return;
|
||||
const img = await assets.image(u).catch(() => null);
|
||||
const img = await assets.image(u, { retry: true }).catch(() => null);
|
||||
if (img && (img.width || img.naturalWidth) > 0) images[k] = img;
|
||||
}));
|
||||
if (!images.D) return null;
|
||||
@@ -138,9 +148,15 @@ export function loadBoardPack(assets) {
|
||||
images, meshes, tiles: isObj(tiles) ? tiles : null, uv: resolveUvTable(isObj(tiles) ? tiles : null),
|
||||
materials: { theme: isObj(theme) ? theme : null, fx: isObj(fxMats) ? fxMats : null },
|
||||
};
|
||||
})().catch((err) => { console.warn('[board3d] art load failed', err); return null; });
|
||||
return cached;
|
||||
})().catch((err) => { console.warn('[board3d] art load failed', err); return null; }).then((pack) => {
|
||||
// A timeout / failed atlas is not proof the host lacks 3D art. A later mount or visibility change may retry.
|
||||
// An older attempt finishing after reset must not discard a newer cached request.
|
||||
if (!pack && cached === pending) cached = null;
|
||||
return pack;
|
||||
});
|
||||
cached = pending;
|
||||
return pending;
|
||||
}
|
||||
|
||||
/** Forget the cached pack (tests / hot reload). */
|
||||
export function resetBoardPack() { cached = null; threePromise = null; }
|
||||
/** Forget the cached pack and the three.js import attempt (tests / hot reload). */
|
||||
export function resetBoardPack() { cached = null; threePromise = null; threeFailures = 0; }
|
||||
@@ -116,7 +116,7 @@ export function normalizeSnapshot(snap) {
|
||||
if (tu && tu.length === 9) tu.push(e[1], clamp(finite(e[2]), 0, 1), finite(e[3]), Math.max(0, finite(e[4])));
|
||||
}
|
||||
}
|
||||
const ammo = countList(snap.ammo, units), wolves = countList(snap.wolves, units);
|
||||
const ammo = countList(snap.ammo, units), wolves = countList(snap.wolves, units), coins = countList(snap.coins, units);
|
||||
let neg = null;
|
||||
if (Array.isArray(snap.neg)) {
|
||||
for (const e of snap.neg) {
|
||||
@@ -147,7 +147,7 @@ export function normalizeSnapshot(snap) {
|
||||
(standCut || (standCut = new Map())).set(e[0], e[1]);
|
||||
}
|
||||
}
|
||||
return { t, units, down, ammo, wolves, neg, stand, standCut, raw: snap };
|
||||
return { t, units, down, ammo, wolves, coins, neg, stand, standCut, raw: snap };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -402,6 +402,7 @@ export class SnapshotBuffer {
|
||||
// the HP-bar readouts come with the older snapshot, like flags (whole rounds step with the skill flag)
|
||||
o.ammo = A.ammo ? A.ammo.get(id) || null : null;
|
||||
o.wolves = A.wolves ? A.wolves.get(id) || null : null;
|
||||
o.coins = A.coins?.get(id) ?? null;
|
||||
o.neg = A.neg ? A.neg.get(id) || 0 : 0;
|
||||
o.seen = stamp;
|
||||
}
|
||||
|
||||
@@ -477,6 +477,8 @@ export class UnitView {
|
||||
this.sp = 0; this.spMax = 0;
|
||||
this.ammo = null; // [rounds left, rounds in the magazine] while an ammo skill runs (sync; b.snap `ammo`)
|
||||
this.wolves = null; // [狼影 left, the talent's maximum] of a 狼群 (sync; b.snap `wolves`)
|
||||
this.coins = null; // [balance, cap] of 琳琅诗怀雅's skill purse, including zero
|
||||
this.coinHud = null;
|
||||
this.neg = 0; // the share of its cap a negative-HP pool holds, 0 = none (sync; b.snap `neg`, 斩业星熊's 我执)
|
||||
this.flags = 0; this.anim = ANIM.IDLE;
|
||||
this.statuses = new Set();
|
||||
@@ -825,6 +827,7 @@ export class UnitView {
|
||||
this.hp = hp;
|
||||
this.sp = s.sp; this.spMax = s.spMax;
|
||||
this.ammo = s.ammo || null; this.wolves = s.wolves || null; this.neg = s.neg > 0 ? Math.min(1, s.neg) : 0;
|
||||
this.coins = s.coins || null;
|
||||
const prevFlags = this.flags;
|
||||
this.flags = s.flags | 0;
|
||||
this.anim = s.anim | 0;
|
||||
@@ -1412,7 +1415,8 @@ export class UnitView {
|
||||
// knocked-down operator (b.snap `down`)
|
||||
const rowsTop = cy + bh / 2 + (showSp ? spH + 1.5 : 0);
|
||||
const wolfH = this._updateWolfPips(showBars && !this.isEnemy && !!this.wolves, x0, rowsTop, s);
|
||||
this._updateElementBar(showBars && !!this.el, x0, bw, rowsTop + wolfH + 1, spH, s, t);
|
||||
const coinH = this._updateCoins(showBars && !this.isEnemy && !!this.coins, x0, rowsTop + wolfH, s);
|
||||
this._updateElementBar(showBars && !!this.el, x0, bw, rowsTop + wolfH + coinH + 1, spH, s, t);
|
||||
this._updateDownRing(!prep && !!this.down && !this.alive, x, this.screen.y - DOWN_LOOK.height * s, s, t);
|
||||
// blocked marker at the feet (enemies held by a blocker)
|
||||
const blocked = !prep && this.alive && this.isEnemy && (this.flags & UF.BLOCKED);
|
||||
@@ -1493,6 +1497,26 @@ export class UnitView {
|
||||
return d + 2 + WOLF_PIPS.pad;
|
||||
}
|
||||
|
||||
/** Coin icon and balance/cap, read from the current snapshot rather than transient FX. */
|
||||
_updateCoins(show, x, top, s) {
|
||||
let r = this.coinHud;
|
||||
if (!show) { if (r) r.root.visible = false; return 0; }
|
||||
if (!r) {
|
||||
const P = this.P, root = new P.Container(), icon = new P.Sprite(fxAtlas().tex.coin);
|
||||
icon.width = icon.height = 16;
|
||||
const text = new P.Text('', { fontFamily: 'Bender, Oxanium, sans-serif', fontSize: 16, fontWeight: '700', fill: '#ffd04a', stroke: '#0b0f0e', strokeThickness: 3 });
|
||||
text.position.set(19, -2); root.addChild(icon, text); this.hud.addChild(root);
|
||||
r = this.coinHud = { root, text, label: '' };
|
||||
}
|
||||
const label = `${this.coins[0]}/${this.coins[1]}`;
|
||||
if (r.label !== label) { r.label = label; r.text.text = label; }
|
||||
const size = clamp(s * 0.2, 10, 16);
|
||||
r.root.scale.set(size / 16); r.root.position.set(x, top + 3); r.root.visible = true;
|
||||
// Text includes font metrics and stroke beyond its nominal 16 px size; reserve the measured row so the next
|
||||
// elemental gauge cannot overlap it (especially with a substituted browser font).
|
||||
return Math.max(16, r.root.getLocalBounds?.().height || 24) * (size / 16) + 8;
|
||||
}
|
||||
|
||||
/** True while the shown gauge is in its 爆发冷却 (b.snap `elem` carries the cooldown's end and length). */
|
||||
elementCooling() {
|
||||
return !!this.el && this.elDur > 0 && this.elUntil > 0;
|
||||
|
||||
Reference in new issue
Block a user