chore: sync ammo readout with upstream dev (#552)
This commit is contained in:
222 files changed
+10419
-2026
No files matched your search
+70
-3
@@ -525,6 +525,9 @@ export class AudioManager {
|
||||
this.voiceGate = new VoiceGate();
|
||||
this.voiceNode = null; // { src, gain, url, token } of the line on air
|
||||
this.voiceToken = 0;
|
||||
this.bondCueNode = null;
|
||||
this.bondCueToken = 0;
|
||||
this.bondCueScope = null;
|
||||
this.voiceOverrides = {};
|
||||
this.voiceLang = 'cn'; // settings 语音语言: 'cn' (audio.voice) | 'jp' (audio.voiceJp, falling back to audio.voice)
|
||||
this.startVoiceDone = false; // 行动出发 of this field (the first operator deployed says it)
|
||||
@@ -666,6 +669,7 @@ export class AudioManager {
|
||||
muted: typeof v?.muted === 'boolean' ? v.muted : this.volumes.muted,
|
||||
};
|
||||
this._applyVolumes();
|
||||
if (this.volumes.muted || this.volumes.sfx <= 0) this.stopBondCue();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -834,6 +838,58 @@ export class AudioManager {
|
||||
|
||||
// ---- SFX ------------------------------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* True while a cue of `scope` started less than `ms` ago. One preparation update can both add layers (m.layer → layerGain)
|
||||
* and raise the active-bond count (screens/game.js): the second trigger of that same update keeps the playing cue
|
||||
* instead of cutting it off and starting it again. A later gain still replaces the cue at once (no cooldown).
|
||||
*/
|
||||
bondCueRecent(scope, ms = 400) {
|
||||
if (this.bondCueScope !== scope || typeof performance === 'undefined') return false;
|
||||
return performance.now() - (this.bondCueAt || 0) < ms;
|
||||
}
|
||||
|
||||
/** The official ADD_BOND bank has maxSoundAllowed=1 / popOldest=true. No extra cooldown. */
|
||||
stopBondCue(scope = null) {
|
||||
if (scope && this.bondCueScope !== scope) return;
|
||||
this.bondCueToken++;
|
||||
this.bondCueScope = null;
|
||||
const cur = this.bondCueNode;
|
||||
this.bondCueNode = null;
|
||||
if (!cur) return;
|
||||
try { cur.src.stop(); } catch { /* already ended */ }
|
||||
cur.end();
|
||||
}
|
||||
|
||||
_playBondCue(url, volume, scope = 'prep') {
|
||||
this.stopBondCue();
|
||||
const token = this.bondCueToken, ctx = this.ctx;
|
||||
if (!ctx || !url || this.volumes.muted || this.volumes.sfx <= 0) return;
|
||||
this.bondCueScope = scope;
|
||||
this.bondCueAt = typeof performance !== 'undefined' ? performance.now() : 0;
|
||||
this._buffer(url).then((buf) => {
|
||||
if (!buf || token !== this.bondCueToken || ctx !== this.ctx || this.volumes.muted || this.volumes.sfx <= 0) return;
|
||||
let src, gain;
|
||||
const end = () => {
|
||||
try { src?.disconnect(); gain?.disconnect(); } catch { /* ignore */ }
|
||||
if (this.bondCueNode?.src === src) this.bondCueNode = null;
|
||||
};
|
||||
try {
|
||||
src = ctx.createBufferSource(); gain = ctx.createGain();
|
||||
src.buffer = buf;
|
||||
gain.gain.value = Math.max(0, Math.min(1.5, volume));
|
||||
src.connect(gain); gain.connect(this.sfxGain);
|
||||
src.onended = end;
|
||||
this.bondCueNode = { src, gain, end };
|
||||
src.start();
|
||||
} catch { end(); }
|
||||
}, () => {});
|
||||
}
|
||||
|
||||
/** One cue per real gain, including several layers at once or an inactive bond. */
|
||||
layerGain(bondId, add, scope = 'prep') {
|
||||
if (typeof bondId === 'string' && bondId && Number.isFinite(add) && add > 0) this.sfx('bondUp', { volume: 0.7, scope });
|
||||
}
|
||||
|
||||
_play(url, { volume = 1, rate = 1, limited = false, unitKey = null } = {}) {
|
||||
if (!this.ctx || !url || this.volumes.muted || this.volumes.sfx <= 0) return;
|
||||
const now = typeof performance !== 'undefined' ? performance.now() : Date.now();
|
||||
@@ -862,12 +918,15 @@ export class AudioManager {
|
||||
/**
|
||||
* UI sound by name (sfx.ui keys). Unknown names are ignored.
|
||||
* @param {string} name
|
||||
* @param {{ volume?: number }} [o]
|
||||
* @param {{ volume?: number, scope?: string }} [o]
|
||||
*/
|
||||
sfx(name, o = {}) {
|
||||
try {
|
||||
const url = this.getManifest()?.audio?.sfx?.ui?.[name];
|
||||
if (typeof url === 'string') this._play(url, { volume: o.volume ?? 0.9 });
|
||||
if (typeof url === 'string') {
|
||||
if (name === 'bondUp') this._playBondCue(url, o.volume ?? 0.9, o.scope);
|
||||
else this._play(url, { volume: o.volume ?? 0.9 });
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
@@ -1013,6 +1072,7 @@ export class AudioManager {
|
||||
|
||||
/** Reset the unit map for a new field (m.field.units = UnitInfo[]). */
|
||||
setFieldUnits(units) {
|
||||
this.stopBondCue();
|
||||
this.units.clear();
|
||||
// unit ids belong to one battle: a cast still held for a unit of the field left must not sound for a unit of the new
|
||||
// one that happens to share its id
|
||||
@@ -1056,7 +1116,9 @@ export class AudioManager {
|
||||
if (!Array.isArray(e)) continue;
|
||||
const kind = e[0];
|
||||
if (kind === 'spawn') { this._track(e[1]); continue; }
|
||||
if (kind === 'atk') {
|
||||
if (kind === 'layer') {
|
||||
this.layerGain(e[2], e[3], 'battle');
|
||||
} else if (kind === 'atk') {
|
||||
// a chain bounce: its first id is the previous target, whose attack sound this is not (see header)
|
||||
if (CHAIN_KINDS.has(e[3])) { this.lastAttacker.delete(e[2]); continue; }
|
||||
const src = this.units.get(e[1]);
|
||||
@@ -1176,6 +1238,11 @@ export function installAudio(deps) {
|
||||
const sync = (s) => {
|
||||
try {
|
||||
const pub = s.match?.public ?? null;
|
||||
if (deps.selectRoute(s) !== 'game') audio.stopBondCue();
|
||||
else if (![PHASE.COMBAT, PHASE.UNITE, PHASE.FINAL_ASSAULT, PHASE.HIDDEN_CORE].includes(pub?.phase)) {
|
||||
// A new prep gain can precede its m.public phase update. Cancel only the previous battle's cue.
|
||||
audio.stopBondCue('battle');
|
||||
}
|
||||
// 开战 BGM: the round's own track out of bgm.combatAlts (骑士之日 / 无畏者 are fixed per round, not drawn),
|
||||
// so every client of a room hears the same one, a mid-fight re-render (or a teammate view) never switches,
|
||||
// and the next round moves on by the table. 联防 shares its round ⇒ same key ⇒ the loop keeps playing.
|
||||
|
||||
@@ -61,9 +61,10 @@
|
||||
// 0.1.0). Read-only: it takes the stats the sim computed last (`unit._s`) and the range grid it
|
||||
// keeps, and never makes the unit recompute them, so looking never changes the battle's floats.
|
||||
// battleRunner.unitIdOf(uid, ownerId, fieldId?) → the id of an own board piece's unit in that battle | null
|
||||
// battleRunner.ownerOps(ownerId, fieldId?) → [{ kind: 'op', ownerId, defId, items?, standInFor? }] that player's
|
||||
// battleRunner.ownerOps(ownerId, fieldId?) → [{ kind: 'op', ownerId, defId, items?, standInFor?, diy? }] that player's
|
||||
// operators in the battle on screen with their equipment (a teammate's bond popup: the members in
|
||||
// play, DESIGN §20.15, 变形同构体 wearers included; a 补位 stand-in names the replaced charId) | []
|
||||
// play, DESIGN §20.15, 变形同构体 wearers included; a 补位 stand-in names the replaced charId,
|
||||
// a 自选 operator carries its pick like UnitInfo) | []
|
||||
//
|
||||
// createBattleRunner(deps) builds an instance with injectable net / store / clock / frame scheduler / sim loader
|
||||
// (test/match/runner.test.js and test/match/runner-pending.test.js drive it under Node).
|
||||
@@ -883,7 +884,8 @@ export function createBattleRunner(deps) {
|
||||
.map((u) => {
|
||||
const o = Array.isArray(u.items) && u.items.length ? { kind: 'op', ownerId, defId: u.defId, items: [...u.items] } : { kind: 'op', ownerId, defId: u.defId };
|
||||
const si = u.def && typeof u.def.standInFor === 'string' ? u.def.standInFor : null;
|
||||
return si ? { ...o, standInFor: si } : o;
|
||||
const diy = u.def?.diyFor ? u.def.loadout?.diy : null;
|
||||
return { ...o, ...(si ? { standInFor: si } : {}), ...(diy ? { diy: { ...diy } } : {}) };
|
||||
});
|
||||
},
|
||||
/** Re-show the current battle (the game screen remounted). */
|
||||
|
||||
+1
-1
@@ -206,7 +206,7 @@ export function buildDiagnostics(input) {
|
||||
b.done ? 'done' : b.loading ? 'loading' : 'running', b.paused ? 'paused' : null, Number.isFinite(b.speed) ? `${b.speed}x` : null)}`);
|
||||
}
|
||||
if (settings) {
|
||||
lines.push(`settings: ${join(`quality ${settings.quality}`, settings.damageNumbers ? 'damage numbers' : null, settings.muted ? 'muted' : null)}`);
|
||||
lines.push(`settings: ${join(`quality ${settings.quality}`, settings.damageNumbers ? 'damage numbers' : null, settings.officialDamageNumbers ? 'official damage numbers' : null, settings.muted ? 'muted' : null)}`);
|
||||
}
|
||||
const shown = entries.slice().reverse();
|
||||
const tot = input.total ?? total;
|
||||
|
||||
+5
-1
@@ -44,7 +44,7 @@ import { TitleScreen, sanitizeName } from './screens/title.js';
|
||||
import { LobbyScreen, rememberRoom, parseRoomParam } from './screens/lobby.js';
|
||||
import { RoomScreen } from './screens/room.js';
|
||||
import { GameScreen } from './screens/game.js';
|
||||
import { installAudio } from './audio.js';
|
||||
import { audio, installAudio } from './audio.js';
|
||||
import { settingsStore } from './ui/settings.js';
|
||||
import { GuideHost } from './ui/guide.js';
|
||||
import { installDeviceSupport } from './ui/device.js';
|
||||
@@ -229,6 +229,10 @@ function wireNet() {
|
||||
});
|
||||
net.on('m.public', (msg) => { matchAt = Date.now(); store.patch('match', { public: payload(msg) }); maybeFinishRestore(); });
|
||||
net.on('m.private', (msg) => { matchAt = Date.now(); store.patch('match', { private: payload(msg) }); });
|
||||
net.on('m.layer', (msg) => {
|
||||
const s = store.get();
|
||||
if (selectRoute(s) === 'game' && msg.playerId === s.me?.playerId) audio.layerGain(msg.bondId, msg.add);
|
||||
});
|
||||
net.on('m.field', (msg) => store.patch('match', { field: payload(msg) }));
|
||||
net.on('m.result', (msg) => {
|
||||
const res = payload(msg);
|
||||
|
||||
+45
-16
@@ -53,7 +53,7 @@
|
||||
// view.holdPiece(uid, {row,col} | null) keep a dropped prep piece standing on a tile while its direction is
|
||||
// chosen / the move is in flight (null releases; setPrep then settles it)
|
||||
// view.setPieceDir(uid, dir) show a prep piece facing UP|RIGHT|DOWN|LEFT (wheel preview / stored dir)
|
||||
// view.setSettings({ damageNumbers, quality: 'high'|'medium'|'low' }) ; view.resize() ; view.destroy()
|
||||
// view.setSettings({ damageNumbers, officialDamageNumbers, quality: 'high'|'medium'|'low' }) ; view.resize() ; view.destroy()
|
||||
// view.stats() → { fps, frameMs, cpuMs, renderMs, units, particles, impostor, impostorAtlas, boardArt, … } (dev / perf)
|
||||
//
|
||||
// Enemy preview pen (research 09 §2.2 / 08 §4.2, render/pen.js): in prep the next round's enemies idle in the pen
|
||||
@@ -125,7 +125,7 @@ import { backdropTextures, shadowTexture, refreshTierChips, refreshDownLabels, s
|
||||
import { TILE_H, TIER_COLORS, COLORS } from './style.js';
|
||||
import { loadBoardArt } from './boardArt.js';
|
||||
import { ImpostorAtlas } from './impostor.js';
|
||||
import { loadThree, loadBoardPack, webgl2Available, boardArtListed } from './board3d/load.js';
|
||||
import { loadThree, loadBoardPack, loadScenePack, webgl2Available, boardArtListed } from './board3d/load.js';
|
||||
import { BoardScene } from './board3d/scene.js';
|
||||
import { unionAreas } from './board3d/layout.js';
|
||||
import { layoutPen, penSignature } from './pen.js';
|
||||
@@ -149,7 +149,7 @@ export { releaseGl } from './app/host.js';
|
||||
/**
|
||||
* Create the battlefield view inside `host` (an element sized by CSS; the canvas fills it).
|
||||
* @param {HTMLElement} host
|
||||
* @param {{ data?: any, assets?: any, audio?: any, settings?: { damageNumbers?: boolean, quality?: string }, signal?: AbortSignal,
|
||||
* @param {{ data?: any, assets?: any, audio?: any, settings?: { damageNumbers?: boolean, officialDamageNumbers?: boolean, quality?: string }, signal?: AbortSignal,
|
||||
* padding?: object|((kind:string, size:{width:number,height:number}) => object),
|
||||
* hud?: {top:number,bottom:number}|((kind:'prep'|'bossPrep', size:{width:number,height:number}, o:{shop:boolean}) => {top:number,bottom:number}|null) }} [opts]
|
||||
*/
|
||||
@@ -162,7 +162,7 @@ export async function createFieldView(host, options = {}) {
|
||||
signal?.throwIfAborted();
|
||||
const assets = resolveAssets(opts.assets);
|
||||
const data = makeData(opts.data);
|
||||
const settings = { damageNumbers: true, quality: 'high', ...(opts.settings || {}) };
|
||||
const settings = { damageNumbers: true, officialDamageNumbers: false, quality: 'high', ...(opts.settings || {}) };
|
||||
// the 3D board (three.js + the official art) loads in parallel with everything else
|
||||
const boardPref = boardPreference(opts.board);
|
||||
const want3d = boardPref !== '2d' && webgl2Available(boardPref === '3d');
|
||||
@@ -265,6 +265,7 @@ 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 }
|
||||
let prepMapChars = []; // { unit (board space), display } — read-only, but inspectable
|
||||
// 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 = [];
|
||||
@@ -361,6 +362,14 @@ export async function createFieldView(host, options = {}) {
|
||||
// lost-context recovery: the THREE module + art pack of the last 3D board, the pending retry, failures so far
|
||||
const recover = { THREE: null, pack: null, timer: 0, tries: 0, since: 0, off: false, count: 0 };
|
||||
setupCleanup.push(() => { clearTimeout(recover.timer); disable3d(); });
|
||||
/** The stage's official scene, once loaded (the procedural board shows meanwhile and when there is none). */
|
||||
function useOfficialScene(st) {
|
||||
const id = st && typeof st.id === 'string' ? st.id : '';
|
||||
if (!id || !board3d) return;
|
||||
loadScenePack(assets, id).then((scene) => {
|
||||
if (scene && !destroyed && board3d && stageRec && stageRec.id === id) board3d.setScenePack(id, scene);
|
||||
}).catch(() => {});
|
||||
}
|
||||
function enable3d(THREE, pack) {
|
||||
if (destroyed || board3d || !THREE || !pack) return false;
|
||||
recover.THREE = THREE; recover.pack = pack;
|
||||
@@ -380,7 +389,7 @@ export async function createFieldView(host, options = {}) {
|
||||
tiles.setExternal(true);
|
||||
backdrop.visible = false;
|
||||
b.setArea(boardArea(viewKind(camKind, camOpts)));
|
||||
if (stageRec) b.setStage(stageRec);
|
||||
if (stageRec) { b.setStage(stageRec); useOfficialScene(stageRec); }
|
||||
b.setFocus(camRect());
|
||||
b.setBattleRect(mode === 'battle' && battleMeta ? battleMeta.rect : null);
|
||||
tiles.project(cam, true);
|
||||
@@ -630,7 +639,7 @@ export async function createFieldView(host, options = {}) {
|
||||
stageRec = st;
|
||||
tiles.setStage(st);
|
||||
tiles.setBattleRect(mode === 'battle' && battleMeta ? battleMeta.rect : null);
|
||||
if (board3d) { board3d.setStage(st); board3d.setBattleRect(mode === 'battle' && battleMeta ? battleMeta.rect : null); }
|
||||
if (board3d) { board3d.setStage(st); board3d.setBattleRect(mode === 'battle' && battleMeta ? battleMeta.rect : null); useOfficialScene(st); }
|
||||
tiles.project(cam, true);
|
||||
// a pen laid out before the stage arrived (setPrep / a scouting board first) used the default zones and the old
|
||||
// tile heights: lay it out again on this stage
|
||||
@@ -804,16 +813,17 @@ 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 外勤医疗 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;
|
||||
// The 外勤医疗 reserve medic is visible before battle and can be inspected, but is not a movable board piece.
|
||||
// Keep its original board-space record for the detail card/range; only the rendered view is transformed to the
|
||||
// displayed boss half. The partner's units already use field coordinates and are not own prep map characters.
|
||||
prepMapChars = Array.isArray(src.prepMapChars) ? src.prepMapChars.map((u) => {
|
||||
if (!u || !Number.isInteger(u.uid) || !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 };
|
||||
return { unit: u, display: { ...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]);
|
||||
syncReadOnlyPrepUnits([...prepMapChars.map((e) => e.display), ...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)
|
||||
@@ -821,8 +831,8 @@ export async function createFieldView(host, options = {}) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* The reserve medic and a boss pair's partner pieces: read-only 'm:<uid>' views. These are not movable prep pieces;
|
||||
* only the owner's prepMapChars may be picked for inspection. Rebuild when body, tile or facing changes.
|
||||
*/
|
||||
function syncReadOnlyPrepUnits(units) {
|
||||
const keep = new Set();
|
||||
@@ -1016,6 +1026,17 @@ export async function createFieldView(host, options = {}) {
|
||||
return { uid: best.uid, kind: best.piece.kind, id: best.piece.id, area: best.area, idx: best.idx, row: best.row, col: best.col, piece: best.piece, draggable: true };
|
||||
}
|
||||
|
||||
/** The owner's read-only preparation map character under a canvas point (never a drag target). */
|
||||
function mapCharAt(x, y) {
|
||||
if (mode !== 'prep') return null;
|
||||
const units = [];
|
||||
for (const e of prepMapChars) {
|
||||
const u = pickUnitOf(views.get(`m:${e.unit.uid}`));
|
||||
if (u) { u.unit = e.unit; units.push(u); }
|
||||
}
|
||||
return pickOnTile(units, groundTile(x, y))?.unit || null;
|
||||
}
|
||||
|
||||
const drag = createDragController({
|
||||
hitPiece: (x, y) => pieceAt(x, y),
|
||||
pickTile: (x, y) => pickBoardTile(x, y),
|
||||
@@ -1218,6 +1239,12 @@ export async function createFieldView(host, options = {}) {
|
||||
return;
|
||||
}
|
||||
if (drag.pointerDown(ev)) { try { canvas.setPointerCapture(e.pointerId); } catch { /* ignore */ } return; }
|
||||
const mapChar = mapCharAt(ev.x, ev.y);
|
||||
if (mapChar) {
|
||||
emit('pieceClick', { unit: mapChar, unitId: null, uid: mapChar.uid, prepMapChar: true,
|
||||
button: e.button, detail: e.button === 2, clientX: e.clientX, clientY: e.clientY });
|
||||
return;
|
||||
}
|
||||
if (mode === 'prep') { const lv = leaderAt(ev.x, ev.y); if (lv) { emitPenClick(lv, e); return; } }
|
||||
if (penViews.size && mode === 'prep') {
|
||||
const pv = penUnitAt(ev.x, ev.y);
|
||||
@@ -1299,6 +1326,7 @@ export async function createFieldView(host, options = {}) {
|
||||
clearViews();
|
||||
if (prepPieces.length) promoBase = prepPieces;
|
||||
prepPieces = [];
|
||||
prepMapChars = [];
|
||||
infos.clear();
|
||||
gone.clear();
|
||||
interp.reset();
|
||||
@@ -1443,7 +1471,7 @@ export async function createFieldView(host, options = {}) {
|
||||
const m = meleePending.get(v.id);
|
||||
let src = null;
|
||||
if (m && now - m.t < 0.35) { src = m.src; meleePending.delete(v.id); fx._slashAt = src.id; }
|
||||
fx.damage(v, amt, e[3], src);
|
||||
fx.damage(v, amt, e[3], src, e[4]);
|
||||
break;
|
||||
}
|
||||
case 'heal': { const v = views.get(e[1]); if (v) fx.heal(v, Number(e[2]) || 0); break; }
|
||||
@@ -1880,6 +1908,7 @@ export async function createFieldView(host, options = {}) {
|
||||
if (!s || typeof s !== 'object') return;
|
||||
const q = settings.quality;
|
||||
if (typeof s.damageNumbers === 'boolean') settings.damageNumbers = s.damageNumbers;
|
||||
if (typeof s.officialDamageNumbers === 'boolean') settings.officialDamageNumbers = s.officialDamageNumbers;
|
||||
if (s.quality === 'high' || s.quality === 'medium' || s.quality === 'low') settings.quality = s.quality;
|
||||
if (q !== settings.quality) { board3d?.setQuality?.(settings.quality); resize(); }
|
||||
},
|
||||
@@ -1937,7 +1966,7 @@ export async function createFieldView(host, options = {}) {
|
||||
app, get cam() { return cam; }, get board3d() { return board3d; }, tiles, views, penViews, get leader() { return leader; }, 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 },
|
||||
pick: { pieceAt, mapCharAt, battleUnitAt, penUnitAt, groundTile },
|
||||
},
|
||||
};
|
||||
// All teardown dependencies now exist. Aborting during either asset wait immediately releases
|
||||
|
||||
@@ -5,6 +5,9 @@
|
||||
// → { key, images: { D, N?, R?, E?, common?, commonE?, BG?, wind?, gate?, waterN?, caustics?, noise? },
|
||||
// meshes: { crate?, blower?, bgPlane?, gate: { startDown?, startUp?, startBack?, endDown?, endUp? } },
|
||||
// tiles, uv, materials: { theme?, fx? } }
|
||||
// const scene = await loadScenePack(assets, stageId) // the stage's official scene (tools/local-extract/scenes.py),
|
||||
// → { key, stage, doc, chunks: [{ material, lightmap, group, bounds, position, uv, uv1, normal, color, index }],
|
||||
// groups: { name: [x0, y0, x1, y1] }, images: { tex: { name: img }, lm: [img|null] } } null when not extracted
|
||||
//
|
||||
// Everything is optional except the diffuse atlas: a missing map or mesh only drops that feature (e.g. no normal
|
||||
// map → flat shading of the atlas; no crate mesh → a procedural box). URLs come from data/local-assets.json only
|
||||
@@ -158,5 +161,170 @@ export function loadBoardPack(assets) {
|
||||
return pending;
|
||||
}
|
||||
|
||||
/** Manifest group of a stage's official scene, and of the textures the scenes share. */
|
||||
export const sceneGroup = (stageId) => `scene/${stageId}`;
|
||||
export const SCENE_TEX_GROUP = 'scene/tex';
|
||||
export const SCENE_VERSION = 1;
|
||||
/** Bytes per vertex of a scene chunk: position f32×3, uv f32×2, lightmap uv f32×2, normal i8×4, colour u8×4. */
|
||||
export const SCENE_VERTEX_BYTES = 36;
|
||||
|
||||
/**
|
||||
* Typed-array views of the chunks of a scene.bin (scenes.py pack_chunk); null when a record does not fit the buffer.
|
||||
* @param {object} doc scene.json
|
||||
* @param {ArrayBuffer} buf scene.bin
|
||||
*/
|
||||
export function parseSceneChunks(doc, buf) {
|
||||
if (!isObj(doc) || !Array.isArray(doc.chunks) || !(buf instanceof ArrayBuffer)) return null;
|
||||
const out = [];
|
||||
for (const c of doc.chunks) {
|
||||
const n = c?.verts | 0, m = (c?.tris | 0) * 3, o = c?.offset | 0;
|
||||
const end = o + n * SCENE_VERTEX_BYTES + m * (c.index32 ? 4 : 2);
|
||||
if (!(n > 0) || !(m > 0) || o < 0 || o % 4 || end > buf.byteLength || typeof c.material !== 'string') return null;
|
||||
let p = o;
|
||||
const position = new Float32Array(buf, p, n * 3); p += n * 12;
|
||||
const uv = new Float32Array(buf, p, n * 2); p += n * 8;
|
||||
const uv1 = new Float32Array(buf, p, n * 2); p += n * 8;
|
||||
const normal = new Int8Array(buf, p, n * 4); p += n * 4;
|
||||
const color = new Uint8Array(buf, p, n * 4); p += n * 4;
|
||||
const index = c.index32 ? new Uint32Array(buf, p, m) : new Uint16Array(buf, p, m);
|
||||
out.push({
|
||||
material: c.material, lightmap: Number.isInteger(c.lightmap) ? c.lightmap : -1, group: typeof c.group === 'string' ? c.group : '',
|
||||
bounds: Array.isArray(c.bounds) ? c.bounds : null, position, uv, uv1, normal, color, index,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mesh bucket of a scene chunk. One material and one lightmap page are one draw. Water ignores the page: that shader
|
||||
* has no lightmap (`scene.js` `sceneWaterMaterial`).
|
||||
* @param {{ material?: string, lightmap?: number }} chunk
|
||||
* @param {{ kind?: string }|null} info scene.json materials[name]
|
||||
*/
|
||||
export function sceneMaterialKey(chunk, info) {
|
||||
return `${chunk?.material}|${info?.kind === 'water' ? '' : chunk?.lightmap}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* One scene chunk from several that share a material bucket (`sceneMaterialKey`). Vertex attributes stay in the
|
||||
* scene.bin layout (position f32×3, uv f32×2, lightmap uv f32×2, normal i8×4, colour u8×4) and indices are rewritten
|
||||
* with the vertex offset; past 65535 vertices the index is 32-bit (WebGL2). A single chunk is returned as-is, so the
|
||||
* pack's views of scene.bin stay shared. The caller passes chunks of one bucket that are on screen together: a
|
||||
* file-level merge would bake a switched-off HIDDEN part into the base mesh.
|
||||
* @param {object[]|null} chunks
|
||||
* @returns {object|null}
|
||||
*/
|
||||
export function mergeSceneChunks(chunks) {
|
||||
if (!Array.isArray(chunks) || chunks.length === 0) return null;
|
||||
if (chunks.length === 1) return chunks[0];
|
||||
const first = chunks[0];
|
||||
let nv = 0, ni = 0, index32 = false;
|
||||
for (const c of chunks) {
|
||||
const n = c?.position?.length / 3;
|
||||
if (!Number.isInteger(n) || n <= 0) throw new Error(`scene chunk ${c?.material}: ${c?.position?.length ?? 0} position components`);
|
||||
if (c.material !== first.material) throw new Error(`scene merge mixes ${first.material} and ${c.material}`);
|
||||
if (c.uv?.length !== n * 2 || c.uv1?.length !== n * 2 || c.normal?.length !== n * 4 || c.color?.length !== n * 4) {
|
||||
throw new Error(`scene chunk ${c.material}: attributes do not match ${n} vertices`);
|
||||
}
|
||||
if (!c.index || c.index.length === 0) throw new Error(`scene chunk ${c.material}: no indices`);
|
||||
nv += n;
|
||||
ni += c.index.length;
|
||||
if (c.index instanceof Uint32Array) index32 = true;
|
||||
}
|
||||
if (nv > 65535) index32 = true;
|
||||
const position = new Float32Array(nv * 3);
|
||||
const uv = new Float32Array(nv * 2);
|
||||
const uv1 = new Float32Array(nv * 2);
|
||||
const normal = new Int8Array(nv * 4);
|
||||
const color = new Uint8Array(nv * 4);
|
||||
const index = index32 ? new Uint32Array(ni) : new Uint16Array(ni);
|
||||
let vo = 0, io = 0, bounds = null;
|
||||
for (const c of chunks) {
|
||||
const n = c.position.length / 3;
|
||||
position.set(c.position, vo * 3);
|
||||
uv.set(c.uv, vo * 2);
|
||||
uv1.set(c.uv1, vo * 2);
|
||||
normal.set(c.normal, vo * 4);
|
||||
color.set(c.color, vo * 4);
|
||||
for (let i = 0; i < c.index.length; i++) {
|
||||
const v = c.index[i];
|
||||
if (v < 0 || v >= n) throw new Error(`scene chunk ${c.material}: index ${v} out of ${n}`);
|
||||
index[io + i] = v + vo;
|
||||
}
|
||||
vo += n;
|
||||
io += c.index.length;
|
||||
if (Array.isArray(c.bounds) && c.bounds.length >= 6) {
|
||||
const b = c.bounds;
|
||||
bounds = bounds
|
||||
? [Math.min(bounds[0], b[0]), Math.min(bounds[1], b[1]), Math.min(bounds[2], b[2]), Math.max(bounds[3], b[3]), Math.max(bounds[4], b[4]), Math.max(bounds[5], b[5])]
|
||||
: b.slice();
|
||||
}
|
||||
}
|
||||
return { material: first.material, lightmap: first.lightmap, group: '', bounds, position, uv, uv1, normal, color, index };
|
||||
}
|
||||
|
||||
/** Ground rect [x0, y0, x1, y1] (board space) of every named group of chunks (the scene's switchable parts). */
|
||||
export function sceneGroupRects(chunks) {
|
||||
const out = {};
|
||||
for (const c of chunks || []) {
|
||||
if (!c.group || !c.bounds) continue;
|
||||
const b = c.bounds, r = out[c.group];
|
||||
out[c.group] = r ? [Math.min(r[0], b[0]), Math.min(r[1], b[1]), Math.max(r[2], b[3]), Math.max(r[3], b[4])] : [b[0], b[1], b[3], b[4]];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async function fetchBuffer(url) {
|
||||
try { const r = await fetch(url, { cache: 'no-cache' }); return r.ok ? await r.arrayBuffer() : null; } catch { return null; }
|
||||
}
|
||||
|
||||
const sceneCache = new Map();
|
||||
|
||||
/** Does the local-art manifest list the official scene of `stageId`? (manifest only) */
|
||||
export async function sceneListed(assets, stageId) {
|
||||
if (!stageId || !assets || typeof assets.local !== 'function' || typeof assets.localUrl !== 'function') return false;
|
||||
try { await assets.local(); } catch { return false; }
|
||||
return !!assets.localUrl(sceneGroup(stageId), 'scene') && !!assets.localUrl(sceneGroup(stageId), 'bin');
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a stage's official scene (scene.json + scene.bin + lightmap pages + albedo textures). A missing texture or
|
||||
* lightmap page only drops that map; a missing or malformed scene gives null (→ the procedural board).
|
||||
* @returns {Promise<object|null>}
|
||||
*/
|
||||
export function loadScenePack(assets, stageId) {
|
||||
if (typeof stageId !== 'string' || !stageId) return Promise.resolve(null);
|
||||
if (sceneCache.has(stageId)) return sceneCache.get(stageId);
|
||||
const pending = (async () => {
|
||||
if (!(await sceneListed(assets, stageId)) || typeof assets.image !== 'function') return null;
|
||||
const url = (g, n) => { const u = assets.localUrl(g, n); return typeof u === 'string' && u ? encodeURI(u) : null; };
|
||||
const group = sceneGroup(stageId);
|
||||
const [doc, buf] = await Promise.all([fetchJson(url(group, 'scene')), fetchBuffer(url(group, 'bin'))]);
|
||||
if (!isObj(doc) || doc.version !== SCENE_VERSION || !buf) return null;
|
||||
const chunks = parseSceneChunks(doc, buf);
|
||||
if (!chunks || !chunks.length) return null;
|
||||
const img = async (u) => {
|
||||
if (!u) return null;
|
||||
const i = await assets.image(u, { retry: true }).catch(() => null);
|
||||
return i && (i.width || i.naturalWidth) > 0 ? i : null;
|
||||
};
|
||||
const mats = isObj(doc.materials) ? doc.materials : {};
|
||||
const names = [...new Set(Object.values(mats).map((m) => (isObj(m) && typeof m.map === 'string' ? m.map : null)).filter(Boolean))];
|
||||
const pages = Math.max(0, Math.min(16, doc.lightmaps | 0));
|
||||
const [texList, lm] = await Promise.all([
|
||||
Promise.all(names.map((n) => img(url(SCENE_TEX_GROUP, n)))),
|
||||
Promise.all(Array.from({ length: pages }, (_, i) => img(url(group, `lm${i}`)))),
|
||||
]);
|
||||
const tex = {};
|
||||
names.forEach((n, i) => { if (texList[i]) tex[n] = texList[i]; });
|
||||
return { key: `${url(group, 'scene')}#${doc.binBytes | 0}`, stage: stageId, doc, chunks, groups: sceneGroupRects(chunks), images: { tex, lm } };
|
||||
})().catch((err) => { console.warn('[board3d] scene load failed', err); return null; }).then((pack) => {
|
||||
if (!pack && sceneCache.get(stageId) === pending) sceneCache.delete(stageId);
|
||||
return pack;
|
||||
});
|
||||
sceneCache.set(stageId, pending);
|
||||
return pending;
|
||||
}
|
||||
|
||||
/** Forget the cached pack and the three.js import attempt (tests / hot reload). */
|
||||
export function resetBoardPack() { cached = null; threePromise = null; threeFailures = 0; }
|
||||
export function resetBoardPack() { cached = null; threePromise = null; threeFailures = 0; sceneCache.clear(); }
|
||||
@@ -1,13 +1,14 @@
|
||||
// render/board3d/materials.js — three.js materials of the official board scene (DESIGN §15). THREE is injected
|
||||
// (the renderer loads /vendor/three.module.js; Node tests pass the npm package).
|
||||
//
|
||||
// MT_autochess → MeshStandardMaterial: albedo TX_autochessi_D (sRGB), normal TX_autochessi_N (BC5 → RGB,
|
||||
// tools/local-extract DERIVED), roughness 1 − smoothness of TX_autochessi_M (DERIVED), metal 0,
|
||||
// MT_autochess → MeshStandardMaterial: albedo TX_autochessi_D (sRGB), normal TX_autochessi_N (BC5 / DXT5nm →
|
||||
// RGB, tools/local-extract DERIVED), roughness 1 − smoothness of TX_autochessi_M (DERIVED), metal 0,
|
||||
// emission TX_autochessi_E (_EmissionColor white), vertex colours = baked AO / tints
|
||||
// MT_autochess_common → device plates of TX_autochessi_common_D, alpha test 0.408, emission _E × 0.559
|
||||
// MT_autochessi_BG → unlit TX_autochessi_BG × 0.713 (Torappu/Unlit/Texture, _Color)
|
||||
// MT_wind_device → unlit TX_wind_device (the blower mesh)
|
||||
// [opt]start_end_add / _ab → the gate / objective boxes: Torappu/Particles/Additive (2 · tint · tex) / AlphaBlend
|
||||
// the materials of an official battlefield scene (load.js loadScenePack) → sceneMaterial / sceneWaterMaterial
|
||||
// Every lit material gets the "focus" spotlight: fragments outside the camera's field rect fade towards `dim`
|
||||
// (a soft world-space falloff), so the active field reads like the original's lit board without re-building
|
||||
// geometry when the camera moves.
|
||||
@@ -343,6 +344,132 @@ export function smogMaterial(THREE, tex, focus) {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Tuning of the official scene materials (sceneMaterial). The official shader (Torappu/Scene/StandardRealtimeShadow) is
|
||||
* not reproduced: the albedo is lit by the baked indirect light of the scene's lightmap plus the scene's key light
|
||||
* masked by its baked shadow, in linear light (Unity's Shadowmask lighting mode). `range` — the linear light a lightmap
|
||||
* texel of 1.0 stands for when scene.json has no lightRange; [ASSUMED] `ambient` — the light of a mesh without a
|
||||
* lightmap page; `direct` / `indirect` — gains of the two terms (1 = the scene's own values).
|
||||
*/
|
||||
export const SCENE_LIGHT = Object.freeze({ range: 8.0, ambient: 0.2, direct: 0.8, indirect: 0.4 });
|
||||
|
||||
const colorOf = (info, key, fallback = [1, 1, 1, 1]) => {
|
||||
const c = info && info.colors && Array.isArray(info.colors[key]) ? info.colors[key] : fallback;
|
||||
return [0, 1, 2, 3].map((i) => (Number.isFinite(c[i]) ? c[i] : fallback[i]));
|
||||
};
|
||||
const floatOf = (info, key, fallback) => (info && info.floats && Number.isFinite(info.floats[key]) ? info.floats[key] : fallback);
|
||||
const hasKeyword = (info, kw) => !!(info && Array.isArray(info.keywords) && info.keywords.includes(kw));
|
||||
|
||||
/** Blend class of a scene material record (scene.json materials[name]): 'opaque' | 'cutout' | 'blend'. */
|
||||
export function sceneBlend(info) {
|
||||
if (hasKeyword(info, '_ALPHABLEND_ON') || hasKeyword(info, '_ALPHAPREMULTIPLY_ON')) return 'blend';
|
||||
return hasKeyword(info, '_ALPHATEST_ON') ? 'cutout' : 'opaque';
|
||||
}
|
||||
|
||||
/**
|
||||
* A lit material of an official scene: albedo (`map`, × _Color) × (the lightmap's baked light + key light · N·L ·
|
||||
* baked shadow (lightmap A, by _ShadowStrength)), alpha test / blend by the material's keywords, the focus falloff of
|
||||
* the other board materials. `map` is an sRGB texture; `lightMap` holds (light / lightRange)^(1/2.2) in RGB (no colour
|
||||
* space). Geometry attributes: position, normal, uv, aLm (lightmap uv), aColor.
|
||||
* @param {{ map?: any, lightMap?: any, info?: object, lightRange?: number, light?: { dir: number[], color: number[], intensity: number } }} o
|
||||
*/
|
||||
export function sceneMaterial(THREE, o, focus) {
|
||||
const info = o.info || null;
|
||||
const blend = sceneBlend(info);
|
||||
const L = o.light || { dir: [0.36, 0.45, 0.82], color: [1, 1, 1], intensity: 1 };
|
||||
const k = (Number.isFinite(L.intensity) ? L.intensity : 1) * SCENE_LIGHT.direct;
|
||||
const lin = (v) => Math.pow(Math.max(0, v), 2.2); // the scene's colours are authored in gamma space
|
||||
const col = colorOf(info, '_Color');
|
||||
const uniforms = {
|
||||
uMap: { value: o.map || null }, uLm: { value: o.lightMap || null },
|
||||
uColor: { value: new THREE.Vector4(lin(col[0]), lin(col[1]), lin(col[2]), col[3]) },
|
||||
uCutoff: { value: blend === 'cutout' ? floatOf(info, '_Cutoff', 0.5) : 0 },
|
||||
uLmScale: { value: o.lightMap ? (o.lightRange > 0 ? o.lightRange : SCENE_LIGHT.range) * SCENE_LIGHT.indirect : 0 },
|
||||
uAmbient: { value: o.lightMap ? 0 : SCENE_LIGHT.ambient },
|
||||
uShadow: { value: floatOf(info, '_ShadowStrength', 1) },
|
||||
uLightDir: { value: new THREE.Vector3(...L.dir).normalize() },
|
||||
uLightColor: { value: new THREE.Vector3(lin(L.color[0]) * k, lin(L.color[1]) * k, lin(L.color[2]) * k) },
|
||||
...focus,
|
||||
};
|
||||
const m = new THREE.ShaderMaterial({
|
||||
uniforms,
|
||||
defines: { ...(o.map ? { SCENE_MAP: 1 } : {}), ...(o.lightMap ? { SCENE_LM: 1 } : {}) },
|
||||
vertexShader: [
|
||||
'attribute vec2 aLm;',
|
||||
'varying vec2 vUv; varying vec2 vLm; varying vec3 vN; varying vec3 vWorld;',
|
||||
'void main() {',
|
||||
' vUv = uv; vLm = aLm;',
|
||||
' vN = normalize(mat3(modelMatrix) * normal);',
|
||||
' vec4 w = modelMatrix * vec4(position, 1.0);',
|
||||
' vWorld = w.xyz;',
|
||||
' gl_Position = projectionMatrix * viewMatrix * w;',
|
||||
'}',
|
||||
].join('\n'),
|
||||
fragmentShader: [
|
||||
'uniform sampler2D uMap; uniform sampler2D uLm; uniform vec4 uColor; uniform float uCutoff;',
|
||||
'uniform float uLmScale; uniform float uAmbient; uniform float uShadow; uniform vec3 uLightDir; uniform vec3 uLightColor;',
|
||||
FOCUS_GLSL,
|
||||
'varying vec2 vUv; varying vec2 vLm; varying vec3 vN; varying vec3 vWorld;',
|
||||
'void main() {',
|
||||
' vec4 a = uColor;',
|
||||
' #ifdef SCENE_MAP',
|
||||
' a *= texture2D(uMap, vUv);',
|
||||
' #endif',
|
||||
' if (a.a < uCutoff) discard;',
|
||||
' vec3 light = vec3(uAmbient); float shade = 1.0;',
|
||||
' #ifdef SCENE_LM',
|
||||
' vec4 lm = texture2D(uLm, vLm);',
|
||||
' light += pow(lm.rgb, vec3(2.2)) * uLmScale; shade = mix(1.0, lm.a, uShadow);',
|
||||
' #endif',
|
||||
' vec3 n = normalize(gl_FrontFacing ? vN : -vN);',
|
||||
' light += uLightColor * max(dot(n, uLightDir), 0.0) * shade;',
|
||||
' gl_FragColor = vec4(a.rgb * light * focusMask(vWorld.xy), a.a);',
|
||||
' #include <colorspace_fragment>',
|
||||
'}',
|
||||
].join('\n'),
|
||||
transparent: blend === 'blend', depthWrite: blend !== 'blend',
|
||||
});
|
||||
m.userData.blend = blend;
|
||||
return m;
|
||||
}
|
||||
|
||||
/** A water surface of an official scene (Torappu/Scene/StylizedWater): the board's water shader in the material's colours. */
|
||||
export function sceneWaterMaterial(THREE, tex, focus, info) {
|
||||
const deep = colorOf(info, '_DeepWaterColor', [0.03, 0.2, 0.27, 0.7]), shallow = colorOf(info, '_ShallowWaterColor', [0.09, 0.46, 0.55, 0.5]);
|
||||
const uniforms = {
|
||||
uTime: { value: 0 }, uNormal: { value: tex.waterN || null }, uCaustics: { value: tex.caustics || null },
|
||||
uDeep: { value: new THREE.Vector4(...deep) }, uShallow: { value: new THREE.Vector4(...shallow) }, ...focus,
|
||||
};
|
||||
return new THREE.ShaderMaterial({
|
||||
uniforms,
|
||||
defines: tex.waterN && tex.caustics ? { WATER_MAPS: 1 } : {},
|
||||
vertexShader: TERRAIN_VERT,
|
||||
fragmentShader: [
|
||||
'uniform float uTime; uniform sampler2D uNormal; uniform sampler2D uCaustics; uniform vec4 uDeep; uniform vec4 uShallow;',
|
||||
FOCUS_GLSL,
|
||||
'varying vec2 vUv; varying vec3 vWorld;',
|
||||
'void main() {',
|
||||
' float diff = 0.5, spec = 0.0, ca = 0.0;',
|
||||
' #ifdef WATER_MAPS',
|
||||
' vec2 p = vWorld.xy * 0.45;',
|
||||
' vec3 n1 = texture2D(uNormal, p + vec2(uTime * 0.03, uTime * 0.021)).xyz * 2.0 - 1.0;',
|
||||
' vec3 n2 = texture2D(uNormal, p * 1.7 - vec2(uTime * 0.025, -uTime * 0.017)).xyz * 2.0 - 1.0;',
|
||||
' vec3 n = normalize(vec3(n1.xy + n2.xy, 2.2));',
|
||||
' vec3 L = normalize(vec3(-0.45, -0.3, 0.84));',
|
||||
' diff = clamp(dot(n, L), 0.0, 1.0);',
|
||||
' spec = pow(clamp(dot(reflect(-L, n), normalize(vec3(0.0, -0.5, 0.866))), 0.0, 1.0), 40.0);',
|
||||
' ca = texture2D(uCaustics, vWorld.xy * 0.6 + n.xy * 0.08 + vec2(uTime * 0.02, 0.0)).r;',
|
||||
' #endif',
|
||||
' vec4 w = mix(uDeep, uShallow, 0.08 + 0.2 * diff);',
|
||||
' vec3 c = pow(w.rgb, vec3(2.2)) + ca * vec3(0.03, 0.05, 0.05) + spec * vec3(0.5, 0.6, 0.65);',
|
||||
' gl_FragColor = vec4(c * focusMask(vWorld.xy), clamp(w.a + 0.2, 0.0, 1.0));',
|
||||
' #include <colorspace_fragment>',
|
||||
'}',
|
||||
].join('\n'),
|
||||
transparent: true, depthWrite: false,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Soft studio environment for image-based light (PMREM of a canvas equirect: bright sky with light panels above the
|
||||
* horizon, dark void below): gives the glass hatches, steel and gold frames their sheen and fills the shadows.
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
//
|
||||
// const board = new BoardScene(THREE, pack, { canvas, quality }) (pack = load.js loadBoardPack)
|
||||
// board.setStage(stage) geometry from the stage grid (layout.js) + devices + gates + terrain
|
||||
// board.setScenePack(id, pack) the official scene of stage `id` (load.js loadScenePack): drawn instead of the
|
||||
// procedural tiles and background while that stage is set (null → procedural)
|
||||
// board.setArea(rects) the built areas (layout.js AREAS / areaFor: normal field + pen, 联防, boss)
|
||||
// board.setFocus(rect) the lit field (others fade, animated)
|
||||
// board.setBattleRect(rect|null) crates / turrets inside a running battle are sim units: static ones hide
|
||||
@@ -17,15 +19,18 @@
|
||||
// static crates (merged, same material),
|
||||
// decals (MT_autochess_common), fence pipes, blowers (merged, unlit), gate boxes (additive + alpha, merged per
|
||||
// material), cyan field edges, terrain overlays (≤ 4), background plane + its shadow catcher — ~12–16 in total.
|
||||
// An official scene replaces the tiles, glass, decals, pipes and background with one mesh per material and lightmap
|
||||
// page of the parts the built areas show (load.js mergeSceneChunks). Devices, gates, edges and terrain stay.
|
||||
// The key light's shadow map is rendered only when the geometry changes (autoUpdate off).
|
||||
|
||||
import { buildBoard, objToBoard, boxProjectUV, ROWS, COLS, DEVICE_H, AREAS } from './layout.js';
|
||||
import { surfaceUV } from './atlas.js';
|
||||
import {
|
||||
focusUniforms, makeTexture, boardMaterial, glassMaterial, decalMaterial, pipeMaterial, unlitMaterial, gateMaterial, glowMaterial,
|
||||
waterMaterial, mireMaterial, infectionMaterial, smogMaterial, dashTexture, environmentMap,
|
||||
waterMaterial, mireMaterial, infectionMaterial, smogMaterial, dashTexture, environmentMap, sceneMaterial, sceneWaterMaterial,
|
||||
} from './materials.js';
|
||||
import { syncThreeCamera } from '../projection.js';
|
||||
import { mergeSceneChunks, sceneMaterialKey } from './load.js';
|
||||
|
||||
/** Lighting rig (tuned against the official screenshots: bright even tops, darker sides, soft shadows). */
|
||||
export const LIGHTING = Object.freeze({
|
||||
@@ -53,6 +58,17 @@ export function gatePulse(t, phase = 0) {
|
||||
return 0.134 + (0.229 - 0.134) * k;
|
||||
}
|
||||
|
||||
/**
|
||||
* Is a named part of an official scene (its ground rect [x0, y0, x1, y1]) shown with the built `areas`? The official
|
||||
* scene keeps each field as a sub-graphic it switches on (HIDDEN@n: the left / right normal field, the boss field);
|
||||
* a part is shown when its centre lies in one of the areas.
|
||||
*/
|
||||
export function sceneGroupVisible(rect, areas) {
|
||||
if (!Array.isArray(rect) || rect.length < 4) return true;
|
||||
const cx = (rect[0] + rect[2]) / 2, cy = (rect[1] + rect[3]) / 2;
|
||||
return (areas || []).some((a) => cx >= a.c0 - 0.5 && cx <= a.c1 + 0.5 && cy >= a.r0 - 0.5 && cy <= a.r1 + 0.5);
|
||||
}
|
||||
|
||||
const areaKey = (list) => (list || []).map((a) => `${a.r0},${a.r1},${a.c0},${a.c1}`).sort().join(';');
|
||||
|
||||
const mergeInto = (list) => {
|
||||
@@ -194,6 +210,10 @@ export class BoardScene {
|
||||
this.scene.add(this.root, this.dynamic);
|
||||
this.stageKey = null;
|
||||
this.stage = null;
|
||||
this.scenePacks = new Map(); // stage id → official scene pack (setScenePack)
|
||||
this.sceneRes = new Map(); // pack key → { tex, lm, mats } GPU resources (kept across rebuilds)
|
||||
this.official = null;
|
||||
this.sceneWater = [];
|
||||
this.area = AREAS.normal;
|
||||
this.areaKey = areaKey(AREAS.normal);
|
||||
this.battleRect = null;
|
||||
@@ -267,7 +287,8 @@ export class BoardScene {
|
||||
/** Rebuild everything for a stage (no-op when the same stage object/grid is set again). */
|
||||
setStage(stage) {
|
||||
if (this.destroyed) return;
|
||||
const key = stage ? `${stage.id || ''}|${(stage.rows || []).join('/')}|${JSON.stringify((stage.devices || []).map((d) => [d.key, d.pos, d.active, d.dir]))}` : '';
|
||||
const official = stage ? this.scenePacks.get(stage.id) || null : null;
|
||||
const key = stage ? `${stage.id || ''}|${official ? official.key : ''}|${(stage.rows || []).join('/')}|${JSON.stringify((stage.devices || []).map((d) => [d.key, d.pos, d.active, d.dir]))}` : '';
|
||||
if (key === this.stageKey) return;
|
||||
this.stageKey = key;
|
||||
this.stage = stage || null;
|
||||
@@ -276,19 +297,107 @@ export class BoardScene {
|
||||
const board = buildBoard(stage, { uv: this.pack?.uv || null, area: this.area });
|
||||
this.board = board;
|
||||
const M = this.meshes = {};
|
||||
M.board = this._mesh(board.buckets.board, this.mat.board);
|
||||
M.glass = this._mesh(board.buckets.glass, this.mat.glass);
|
||||
M.decal = this._mesh(board.buckets.decal, this.mat.decal, { cast: false });
|
||||
M.pipe = this._mesh(board.buckets.pipe, this.mat.pipe);
|
||||
this.official = official && this._buildScene(official) ? official : null;
|
||||
if (!this.official) {
|
||||
M.board = this._mesh(board.buckets.board, this.mat.board);
|
||||
M.glass = this._mesh(board.buckets.glass, this.mat.glass);
|
||||
M.decal = this._mesh(board.buckets.decal, this.mat.decal, { cast: false });
|
||||
M.pipe = this._mesh(board.buckets.pipe, this.mat.pipe);
|
||||
}
|
||||
this._buildDevices(board);
|
||||
this._buildGates(board);
|
||||
this._buildEdges(board);
|
||||
this._buildTerrain(board);
|
||||
this._buildBackground(board);
|
||||
if (!this.official) this._buildBackground(board);
|
||||
this._fitShadow(board);
|
||||
this.renderer.shadowMap.needsUpdate = true;
|
||||
}
|
||||
|
||||
/** Use (or drop, with null) the official scene of stage `id`; rebuilds when that stage is the current one. */
|
||||
setScenePack(id, pack) {
|
||||
if (this.destroyed || typeof id !== 'string' || !id) return false;
|
||||
const prev = this.scenePacks.get(id) || null;
|
||||
if (prev === (pack || null)) return false;
|
||||
if (prev) this._disposeScene(prev);
|
||||
if (pack) this.scenePacks.set(id, pack); else this.scenePacks.delete(id);
|
||||
if (this.stage && this.stage.id === id) { this.stageKey = null; this.setStage(this.stage); }
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Textures and materials of a scene pack (created once per pack, kept across rebuilds). */
|
||||
_sceneResources(pack) {
|
||||
let res = this.sceneRes.get(pack.key);
|
||||
if (res) return res;
|
||||
const T = this.THREE;
|
||||
const aniso = Math.min(8, this.renderer.capabilities?.getMaxAnisotropy?.() || 1);
|
||||
const tex = {}, lm = [];
|
||||
for (const [name, img] of Object.entries(pack.images?.tex || {})) tex[name] = makeTexture(T, img, { aniso, repeat: true });
|
||||
for (const img of pack.images?.lm || []) lm.push(img ? makeTexture(T, img, { srgb: false }) : null);
|
||||
res = { tex, lm, mats: new Map() };
|
||||
this.sceneRes.set(pack.key, res);
|
||||
return res;
|
||||
}
|
||||
|
||||
_disposeScene(pack) {
|
||||
const res = this.sceneRes.get(pack.key);
|
||||
if (!res) return;
|
||||
for (const m of res.mats.values()) m.dispose();
|
||||
for (const t of [...Object.values(res.tex), ...res.lm]) t?.dispose?.();
|
||||
this.sceneRes.delete(pack.key);
|
||||
}
|
||||
|
||||
/**
|
||||
* The official scene's meshes: the base plus the fields of the built areas, one mesh per material and lightmap
|
||||
* page (`mergeSceneChunks`). A switched-off HIDDEN part is left out before the merge.
|
||||
*/
|
||||
_buildScene(pack) {
|
||||
const T = this.THREE;
|
||||
const res = this._sceneResources(pack);
|
||||
const infos = pack.doc?.materials || {};
|
||||
this.sceneWater = [];
|
||||
const buckets = new Map();
|
||||
for (const c of pack.chunks || []) {
|
||||
if (c.group && !sceneGroupVisible(pack.groups?.[c.group], this.area)) continue;
|
||||
const info = infos[c.material] || null;
|
||||
const mk = sceneMaterialKey(c, info);
|
||||
let bucket = buckets.get(mk);
|
||||
if (!bucket) { bucket = { info, chunks: [] }; buckets.set(mk, bucket); }
|
||||
bucket.chunks.push(c);
|
||||
}
|
||||
let n = 0;
|
||||
for (const [mk, bucket] of buckets) {
|
||||
const c = mergeSceneChunks(bucket.chunks);
|
||||
const info = bucket.info;
|
||||
const water = info?.kind === 'water';
|
||||
let mat = res.mats.get(mk);
|
||||
if (!mat) {
|
||||
mat = water
|
||||
? sceneWaterMaterial(T, this.tex, this.focus, info)
|
||||
: sceneMaterial(T, {
|
||||
map: res.tex[info?.map] || null, lightMap: c.lightmap >= 0 ? (res.lm[c.lightmap] || null) : null, info,
|
||||
light: pack.doc?.light || null, lightRange: pack.doc?.lightRange,
|
||||
}, this.focus);
|
||||
res.mats.set(mk, mat);
|
||||
}
|
||||
if (water) this.sceneWater.push(mat);
|
||||
const g = new T.BufferGeometry();
|
||||
g.setAttribute('position', new T.BufferAttribute(c.position, 3));
|
||||
g.setAttribute('normal', new T.BufferAttribute(c.normal, 4, true));
|
||||
g.setAttribute('uv', new T.BufferAttribute(c.uv, 2));
|
||||
g.setAttribute('aLm', new T.BufferAttribute(c.uv1, 2));
|
||||
g.setAttribute('aColor', new T.BufferAttribute(c.color, 4, true));
|
||||
g.setIndex(new T.BufferAttribute(c.index, 1));
|
||||
g.computeBoundingSphere();
|
||||
const mesh = new T.Mesh(g, mat);
|
||||
mesh.castShadow = false; mesh.receiveShadow = false;
|
||||
mesh.renderOrder = water ? 1 : mat.userData.blend === 'blend' ? 2 : mat.userData.blend === 'cutout' ? -3 : -4;
|
||||
mesh.matrixAutoUpdate = false; mesh.updateMatrix();
|
||||
this.root.add(mesh);
|
||||
this.meshes[`scene${n++}`] = mesh;
|
||||
}
|
||||
return n > 0;
|
||||
}
|
||||
|
||||
_buildDevices(board) {
|
||||
const crates = [], blowers = [];
|
||||
this.staticCrates = [];
|
||||
@@ -550,6 +659,7 @@ export class BoardScene {
|
||||
for (let i = this.flashes.length - 1; i >= 0; i--) { const f = this.flashes[i]; f.t += dt; if (f.t > 1.2) this.flashes.splice(i, 1); else flash = Math.max(flash, 1 - f.t / 1.2); }
|
||||
for (const m of [this.mat.gateEndAdd, this.mat.gateEndAb]) if (m) m.uniforms.uFlash.value.setRGB(flash, flash * 0.12, flash * 0.1);
|
||||
for (const k of ['water', 'mire', 'infection', 'smog']) this.mat[k].uniforms.uTime.value = t;
|
||||
if (this.official) for (const m of this.sceneWater || []) m.uniforms.uTime.value = t;
|
||||
this.renderer.render(this.scene, this.camera);
|
||||
this.frames++;
|
||||
if (t0) this.lastMs = this.lastMs * 0.9 + ((typeof performance !== 'undefined' ? performance.now() : t0) - t0) * 0.1;
|
||||
@@ -566,6 +676,8 @@ export class BoardScene {
|
||||
this.destroyed = true;
|
||||
for (const d of [...this.devices]) d.destroy();
|
||||
this._clear();
|
||||
for (const p of [...this.scenePacks.values()]) this._disposeScene(p);
|
||||
this.scenePacks.clear();
|
||||
this.mat.edge?.map?.dispose?.(); // canvas dash texture (not in this.tex)
|
||||
for (const m of Object.values(this.mat)) m?.dispose?.();
|
||||
for (const t of Object.values(this.tex)) t?.dispose?.();
|
||||
|
||||
@@ -14,7 +14,7 @@ export function ensureDamageFonts() {
|
||||
fontFamily: ['Bender', 'Oxanium', 'Rajdhani', 'Arial Black', 'sans-serif'], fontSize: 44, fontWeight: '700',
|
||||
fill: st.fill, fillGradientStops: [0.25, 1], stroke: st.stroke, strokeThickness: 7,
|
||||
dropShadow: true, dropShadowColor: '#000000', dropShadowAlpha: 0.45, dropShadowDistance: 2, dropShadowBlur: 2,
|
||||
}, { chars: [['0', '9'], '+-×!'], resolution: 2, padding: 6 });
|
||||
}, { chars: st.font === 'sp-dmg-miss' ? [['0', '9'], '+-×!', 'MISS'] : [['0', '9'], '+-×!'], resolution: 2, padding: 6 });
|
||||
} catch (err) { console.warn('[fx] bitmap font', st.font, err?.message || err); }
|
||||
}
|
||||
fontsReady = true;
|
||||
|
||||
@@ -38,8 +38,13 @@ const NUM_LANES = Object.freeze([0, -1, 1, -2, 2]); // lane order (lane widths
|
||||
/** Screen scale of a number's text (24 px font) at `s` px per tile. */
|
||||
const numScale = (s, big, style) => clamp(s / 115, 0.42, 1.05) * (big ? 1.35 : 1) * (style === 'heal' ? 0.9 : 1);
|
||||
|
||||
/** Characters of a damage number as drawn (heals get a '+'). */
|
||||
const numChars = (v, style) => String(Math.round(v)).length + (style === 'heal' ? 1 : 0);
|
||||
/** Characters of a damage number as drawn (heals get a '+'; a yellow MISS is four letters). */
|
||||
const numChars = (v, style) => (style === 'miss' ? 4 : String(Math.round(v)).length + (style === 'heal' ? 1 : 0));
|
||||
|
||||
// [ASSUMED] PRTS says a red number sits at a random spot in a range above the model and gives no pixels.
|
||||
const OFFICIAL_JITTER_X = 0.7; // tiles, full width (±0.35)
|
||||
const OFFICIAL_LIFT_MIN = 0.15; // tiles above the head
|
||||
const OFFICIAL_LIFT_SPAN = 0.4;
|
||||
|
||||
/** Sub-professions whose attacks splash around the struck target (the 阵法术师 / 轰击术师 strike every enemy in range). */
|
||||
const SPLASH_SUBS = new Set(['aoesniper', 'splashcaster', 'bombarder', 'fortress', 'hammer']);
|
||||
@@ -48,7 +53,7 @@ export class FxNumbers {
|
||||
// ---- hits / numbers -----------------------------------------------------------------------------------------
|
||||
|
||||
/** b.ev 'dmg' visual: glow + sparks in the hit colour; a melee blow (atk 'none' just before) adds its slash. */
|
||||
damage(view, amount, type, srcView) {
|
||||
damage(view, amount, type, srcView, mark) {
|
||||
if (!view) return;
|
||||
const style = dmgStyleKey(type);
|
||||
const p = this._chest(view);
|
||||
@@ -66,7 +71,13 @@ export class FxNumbers {
|
||||
}
|
||||
}
|
||||
view.onHit?.();
|
||||
if (this.ctx.settings?.damageNumbers !== false) this.number(view, amount, style, big);
|
||||
if (this.ctx.settings?.damageNumbers === false) return;
|
||||
// Official mode (设置「官方伤害数字」): only a sim-marked red integer. Sparks and the slash stay.
|
||||
if (this.ctx.settings?.officialDamageNumbers) {
|
||||
if (mark === 'r') this.officialNumber(view, amount, 'crit');
|
||||
return;
|
||||
}
|
||||
this.number(view, amount, style, big);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -96,7 +107,42 @@ export class FxNumbers {
|
||||
tint: 0x7dffa8, vy: -s * 0.9, life: 0.7, s0: s / 64 * 0.32, s1: s / 64 * 0.2, a0: 0.95, a1: 0, fadeIn: 0.08,
|
||||
});
|
||||
}
|
||||
if (this.ctx.settings?.damageNumbers !== false) this.number(view, amount, 'heal', false);
|
||||
if (this.ctx.settings?.damageNumbers !== false && !this.ctx.settings?.officialDamageNumbers) this.number(view, amount, 'heal', false);
|
||||
}
|
||||
|
||||
/**
|
||||
* One official popup: a red integer (including 0) or the yellow MISS. It does not join a running total.
|
||||
* Position is client-side only — the sim stays deterministic.
|
||||
*/
|
||||
officialNumber(view, amount, style) {
|
||||
if (!view) return;
|
||||
const miss = style === 'miss';
|
||||
const n = miss ? 0 : Math.round(amount);
|
||||
if (!miss && (!Number.isFinite(n) || n < 0)) return;
|
||||
const now = this.time;
|
||||
const cam = this.ctx.cam();
|
||||
const base = (view.z || 0) + (view.hover || 0) + (view._headTiles || 1.2) * 0.8;
|
||||
const a = cam.project(view.x, view.y, base, this._p);
|
||||
const ax = a.x, ay = a.y, s = a.s > 0 ? a.s : 100;
|
||||
const pxPerZ = ay - cam.project(view.x, view.y, base + 1, this._q).y;
|
||||
const tile = pxPerZ > 1e-3 ? pxPerZ : s;
|
||||
const cx = ax + (Math.random() - 0.5) * OFFICIAL_JITTER_X * tile;
|
||||
const cy = ay - (OFFICIAL_LIFT_MIN + Math.random() * OFFICIAL_LIFT_SPAN) * tile;
|
||||
const sc = numScale(s, false, style);
|
||||
const w = numChars(n, style) * NUM_DIGIT_EM * 24 * sc * NUM_POP + NUM_GAP_PX;
|
||||
const h = 24 * NUM_LINE_EM * sc * NUM_POP;
|
||||
const maxNums = this.load >= 2 ? MAX_NUMBERS >> 1 : MAX_NUMBERS;
|
||||
while (this.nums.length >= maxNums) this._releaseNum(this.nums.shift());
|
||||
const t = this._takeNum(style);
|
||||
t.text.visible = true;
|
||||
t.text.alpha = 1;
|
||||
t.text.text = miss ? 'MISS' : String(n);
|
||||
Object.assign(t, {
|
||||
unit: view, style, value: n, life: 0, end: NUM_LIFE, born: now, lastHit: now, pop: 1, big: false, fading: false,
|
||||
official: true, ox: cx - ax, oy: cy - ay, x0: view.x, y0: view.y, z0: base,
|
||||
risePx: NUM_RISE * tile * 0.5, _x: cx, _y: cy, _w: w, _h: h,
|
||||
});
|
||||
this.nums.push(t);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -129,7 +175,7 @@ export class FxNumbers {
|
||||
// 1. merge into this unit's running total of the same style
|
||||
let mine = 0, youngest = null, oldest = null;
|
||||
for (const t of this.nums) {
|
||||
if (t.unit !== view || t.fading) continue;
|
||||
if (t.unit !== view || t.fading || t.official) continue;
|
||||
mine++;
|
||||
if (!oldest || t.born < oldest.born) oldest = t;
|
||||
if (t.style === style && (!youngest || t.born > youngest.born)) youngest = t;
|
||||
|
||||
@@ -258,6 +258,10 @@ export class FxSim {
|
||||
case 'dodge': {
|
||||
this.particle('soft', p.x, p.y, { tint: 0xffffff, life: 0.3, s0: s / 128 * 0.5, s1: s / 128 * 0.9, a0: 0.5, a1: 0 });
|
||||
this.particle('slash', p.x, p.y, { tint: col, life: 0.22, s0: s / 128 * 0.8, s1: s / 128 * 1.1, a0: 0.7, a1: 0, rot: -0.5 });
|
||||
// num:'m' — the sim already decided this dodge would have been a red number (PRTS yellow MISS, #ff9000).
|
||||
if (ex.num === 'm' && at.v && this.ctx.settings?.damageNumbers !== false && this.ctx.settings?.officialDamageNumbers) {
|
||||
this.officialNumber(at.v, 0, 'miss');
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'counter': {
|
||||
|
||||
@@ -159,7 +159,7 @@ export class FxSystem {
|
||||
}
|
||||
|
||||
const FX_PARTS = [FxParticles, FxProjectiles, FxBeams, FxLocks, FxNumbers, FxRings, FxArrivals, FxSim, FxZones];
|
||||
const FX_METHOD_ORDER = ["quality","load","maxParticles","rich","_room","_ts","_groundZ","particle","_o","_freeParticle","_updateParticles","_proj","_onGround","_chest","_bodyPt","burst","attack","_takeProj","_dressProj","_releaseProj","_updateProjs","_shotPoint","_stepShot","_stepBoomerang","_stepMortar","_mote","_puff","_muzzle","_impact","_catch","mortar","_landed","_beam","_updateBeams","_flame","_freeFlame","_updateFlames","_lock","_touchLocks","_nearestLock","_releaseLock","_freeLock","_updateLocks","damage","_slash","heal","number","_growFits","_numSlotPx","_layoutNums","_layoutNumsOnce","_sizeNum","_fadeNum","_takeNum","_releaseNum","_updateNums","ring","_updateRings","skill","_aura","_updateAuras","deploy","promote","death","crateBreak","_viewOf","_where","_point","simFx","explosion","smoke","streak","strike","numberAt","_updateLabels","zone","_freeZone","_updateZones","tileFlash","_updateTileFlashes","flashScreen","snowfall","leak","pop","_updatePops"];
|
||||
const FX_METHOD_ORDER = ["quality","load","maxParticles","rich","_room","_ts","_groundZ","particle","_o","_freeParticle","_updateParticles","_proj","_onGround","_chest","_bodyPt","burst","attack","_takeProj","_dressProj","_releaseProj","_updateProjs","_shotPoint","_stepShot","_stepBoomerang","_stepMortar","_mote","_puff","_muzzle","_impact","_catch","mortar","_landed","_beam","_updateBeams","_flame","_freeFlame","_updateFlames","_lock","_touchLocks","_nearestLock","_releaseLock","_freeLock","_updateLocks","damage","_slash","heal","officialNumber","number","_growFits","_numSlotPx","_layoutNums","_layoutNumsOnce","_sizeNum","_fadeNum","_takeNum","_releaseNum","_updateNums","ring","_updateRings","skill","_aura","_updateAuras","deploy","promote","death","crateBreak","_viewOf","_where","_point","simFx","explosion","smoke","streak","strike","numberAt","_updateLabels","zone","_freeZone","_updateZones","tileFlash","_updateTileFlashes","flashScreen","snowfall","leak","pop","_updatePops"];
|
||||
|
||||
for (const key of FX_METHOD_ORDER) {
|
||||
let found = null;
|
||||
|
||||
+20
-23
@@ -6,8 +6,8 @@
|
||||
//
|
||||
// Driving (units.js calls these; the sim is authoritative, the actor only visualises):
|
||||
// setBase('idle'|'move'|'stun') the resting state from the snapshot anim code
|
||||
// attack(interval, once) one attack happened now (b.ev 'atk'): plays begin→loop, re-phases the loop
|
||||
// so its OnAttack frame lands now, timeScale = loopDuration / interval;
|
||||
// attack(interval, once) one attack happened now (b.ev 'atk'): consumes its wind-up or starts at
|
||||
// OnAttack, once; operator timeScale = loopDuration / interval;
|
||||
// `once` (a one-off cast, style.js PROJ[kind].once — 暴鸰's bomb drop): the
|
||||
// clip plays once at its own speed, then base; `clipPerAttack` (enemies,
|
||||
// GitHub #58): every attack plays the clip once — at its own speed, faster
|
||||
@@ -33,8 +33,7 @@
|
||||
// update(dt) advances the skeleton (autoUpdate is off: one clock for everything)
|
||||
// poseHeld() true while the pose cannot change (render/units.js then neither re-poses nor
|
||||
// redraws the model, only advances `clock`)
|
||||
// Attack mode lasts until ~1.4 attack intervals without a new attack (a `once` cast and every attack of a
|
||||
// `clipPerAttack` actor: to the end of its clip), then the end clip (if any) and base — except the attacks of a skill
|
||||
// Each attack plays its clip once, then the end clip (if any) and base — except the attacks of a skill
|
||||
// with its own idle clip, which go straight back to that idle: the skill's end clip closes the skill, not each spell of
|
||||
// attacks while it runs.
|
||||
|
||||
@@ -158,10 +157,10 @@ export class SpineActor {
|
||||
/**
|
||||
* Enemies (render/units.js, GitHub #58): each attack plays the attack clip once — at its own speed, faster only when
|
||||
* the attacks come quicker than the clip — and then the resting state; the sim stands the enemy for exactly that
|
||||
* clip (server/sim/ai.js attackStand). Off (operators): the clip loops over the attack rhythm.
|
||||
* clip (server/sim/ai.js attackStand). Off (operators): retain rhythm scaling, but still play each attack once.
|
||||
*/
|
||||
this.clipPerAttack = false;
|
||||
this.wound = false; // clipPerAttack: wound up for the coming attack (windUp → attack)
|
||||
this.wound = false; // wound up for the coming attack (windUp → attack)
|
||||
this._applied = false; // an update has posed the skeleton since its clip last changed (poseHeld)
|
||||
this._appliedTint = undefined; // the tint that update put on the slots (pixi-spine applies `tint` in update)
|
||||
this._play(this._idleName(), true);
|
||||
@@ -366,9 +365,8 @@ export class SpineActor {
|
||||
if (this.dead || this.mode === 'stun' || this.mode === 'die' || this.mode === 'change' || !(lead >= 0)) return false;
|
||||
const clip = this._attackClip();
|
||||
if (!clip) return false;
|
||||
const single = once || this.clipPerAttack;
|
||||
// in rhythm: attack() re-phases; a clip-per-attack actor starts each attack's clip anew unless already wound up for it
|
||||
if (this.mode === 'attack' && this.current === clip.loop && (!this.clipPerAttack || once || this.wound)) return false;
|
||||
// One queued attack owns one wind-up. A later attack may begin while the previous recovery is still playing.
|
||||
if (this.mode === 'attack' && this.current === clip.loop && (once || this.wound)) return false;
|
||||
const loopDur = this.dur(clip.loop);
|
||||
// a one-off cast plays at the clip's own speed (one loop per clip length), whatever the attack rhythm
|
||||
const iv = once ? loopDur : clampN(Number.isFinite(interval) && interval > 0 ? interval : this.interval, 0.08, 8);
|
||||
@@ -378,8 +376,8 @@ export class SpineActor {
|
||||
if (!(hit > 0) || lead * plan.ts > hit + 1e-6) return false;
|
||||
if (!once) this.interval = iv;
|
||||
this.mode = 'attack';
|
||||
this.attackUntil = this.clock + lead + (single ? Math.max(0, loopDur - hit) / plan.ts : Math.max(0.45, iv * 1.4));
|
||||
this._play(clip.loop, !single, { timeScale: plan.tsWind, start: plan.start, mix: 0.06 });
|
||||
this.attackUntil = this.clock + lead + Math.max(0, loopDur - hit) / plan.ts;
|
||||
this._play(clip.loop, false, { timeScale: plan.tsWind, start: plan.start, mix: 0.06 });
|
||||
this.windTs = plan.ts;
|
||||
this.windUntil = this.clock + lead;
|
||||
this.wound = true;
|
||||
@@ -397,14 +395,13 @@ export class SpineActor {
|
||||
if (!clip) return;
|
||||
const loopDur = this.dur(clip.loop);
|
||||
const per = this.clipPerAttack && !once;
|
||||
const single = once || per;
|
||||
// clip per attack: its own speed (ts 1), faster only when the attacks come quicker than the clip
|
||||
const ts = once ? 1 : per ? clampN(loopDur / Math.min(this.interval, loopDur), 1, 4) : clampN(loopDur / this.interval, 0.35, 4);
|
||||
const hit = this._hitTime(clip.loop, loopDur);
|
||||
// (a clip-per-attack actor still playing the previous attack's clip was not wound up for this one)
|
||||
const wasAttacking = this.mode === 'attack' && this.current === clip.loop && (!per || this.wound);
|
||||
const wasAttacking = this.mode === 'attack' && this.current === clip.loop && this.wound;
|
||||
this.mode = 'attack';
|
||||
this.attackUntil = this.clock + (single ? Math.max(0, loopDur - hit) / ts : Math.max(0.45, this.interval * 1.4));
|
||||
this.attackUntil = this.clock + Math.max(0, loopDur - hit) / ts;
|
||||
this.windUntil = null;
|
||||
this.wound = false;
|
||||
if (!wasAttacking) {
|
||||
@@ -414,19 +411,19 @@ export class SpineActor {
|
||||
// lifts before the slam; #246 by @TsangAsuna, accepted by the owner on 2026-10-07)
|
||||
const whole = per && this.interval >= loopDur;
|
||||
if (whole) this.attackUntil = this.clock + loopDur / ts;
|
||||
this._play(clip.loop, !single, { timeScale: ts, start: whole ? 0 : hit, mix: 0.06 });
|
||||
} else if (single) {
|
||||
const e = this.spine.state.tracks[0];
|
||||
if (e) e.timeScale = ts;
|
||||
this._play(clip.loop, false, { timeScale: ts, start: whole ? 0 : hit, mix: 0.06 });
|
||||
} else {
|
||||
const e = this.spine.state.tracks[0];
|
||||
if (e) {
|
||||
e.timeScale = ts;
|
||||
// re-phase gently so the strike frame lines up with this attack
|
||||
const t = e.trackTime % loopDur;
|
||||
let d = hit - t;
|
||||
if (d > loopDur / 2) d -= loopDur; else if (d < -loopDur / 2) d += loopDur;
|
||||
e.trackTime += d * 0.8;
|
||||
// Preserve operator hit-phase correction when a coarse/batched update crossed windUntil.
|
||||
// This adjusts the prepared single clip; it does not start another visual swing.
|
||||
if (!once && !per) {
|
||||
const t = e.trackTime % loopDur;
|
||||
let d = hit - t;
|
||||
if (d > loopDur / 2) d -= loopDur; else if (d < -loopDur / 2) d += loopDur;
|
||||
e.trackTime += d * 0.8;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,6 +112,9 @@ export const DMG_STYLE = Object.freeze({
|
||||
true: { font: 'sp-dmg-true', fill: ['#ffffff', '#e8e8e8'], stroke: '#2a2a2a' },
|
||||
heal: { font: 'sp-dmg-heal', fill: ['#eafff0', '#62f08a'], stroke: '#07361a' },
|
||||
elem: { font: 'sp-dmg-elem', fill: ['#fff4e0', '#ff7b3a'], stroke: '#3a1000' },
|
||||
// Official red integer and yellow MISS (PRTS 作战机制 「伤害红字(暴击)」, MISS #ff9000). Default numbers never use these.
|
||||
crit: { font: 'sp-dmg-crit', fill: ['#ffd0d0', '#ff2a2a'], stroke: '#4a0000' },
|
||||
miss: { font: 'sp-dmg-miss', fill: ['#ffe0a8', '#ff9000'], stroke: '#4a2400' },
|
||||
});
|
||||
|
||||
/** Map b.ev dmg types to a style key. */
|
||||
|
||||
@@ -228,6 +228,38 @@ export const ALIAS_TINT = Object.freeze({ enemy_1305_mhslim: 0xffc48a, enemy_130
|
||||
* between `pulse` and 1 at `pulseHz` (real time).
|
||||
*/
|
||||
export const EL_BAR = Object.freeze({ icon: 0.15, min: 8, max: 15, enemy: 0.8, gap: 1, pulse: 0.45, pulseHz: 1.5 });
|
||||
|
||||
/** The share of a model's height a bar is placed above (the head is not the very top of the silhouette). */
|
||||
export const HEAD_TOP_K = 0.92;
|
||||
/** Enemies whose model is taller than this many tiles keep their bar at the cap — a bar must stay on screen. */
|
||||
export const HEAD_TILES_CAP = 2.2;
|
||||
export const HEAD_TILES_CAP_BOSS = 3.2;
|
||||
|
||||
/**
|
||||
* Tiles above the feet where a unit's HP bar / name tag sits.
|
||||
*
|
||||
* Operators and tokens are uniform chibis (UNIT.headroom). Enemies vary: with a known model they use **how far the model
|
||||
* reaches above its own origin** (spine.js `top` = `bounds.y + bounds.height`, not the box's raw height — a body whose
|
||||
* origin sits mid-model, 逐腐兽, would otherwise push its bar ~a tile too high), scaled by the official model factor and
|
||||
* the vertical-stretch quirk; without bounds they fall back to the chibi headroom × that factor.
|
||||
* @param {{ isEnemy:boolean, isBoss:boolean, spineShown:boolean, bounds?:{y?:number,height?:number}|null, modelK:number, modelKY:number }} o
|
||||
* @returns {number} tiles
|
||||
*/
|
||||
export function headTilesForUnit(o) {
|
||||
const k = (Number.isFinite(o.modelK) ? o.modelK : 1) * (Number.isFinite(o.modelKY) ? o.modelKY : 1);
|
||||
if (!o.isEnemy) return UNIT.headroom;
|
||||
const cap = o.isBoss ? HEAD_TILES_CAP_BOSS : HEAD_TILES_CAP;
|
||||
const b = o.spineShown ? o.bounds : null;
|
||||
if (b && Number.isFinite(b.height) && b.height > 20) {
|
||||
// how far the box reaches *above the origin*: 猎狗pro −3.7, 大鲍勃 −2.6 (feet-anchored, ≈ height), 逐腐兽 −404.7 of
|
||||
// 722.6 and 锏 −798 of 1183 (origin mid-body). Using the raw height there put the bar about a tile too high.
|
||||
const top = Number.isFinite(b.y) ? b.y + b.height : b.height;
|
||||
return clamp(Math.min(Math.max(top, 20), 900) * UNIT.modelScale * k * HEAD_TOP_K, 0.55, cap);
|
||||
}
|
||||
if (o.isBoss) return HEAD_TILES_CAP;
|
||||
if (o.spineShown) return clamp(UNIT.headroom * k, 0.55, HEAD_TILES_CAP);
|
||||
return UNIT.headroom;
|
||||
}
|
||||
/**
|
||||
* The HP-bar readouts under the bars (b.snap `ammo`, `wolves`, `neg`; render/interp.js header):
|
||||
* - AMMO_BAR — the ammo skill's segmented bar replaces the SP bar: a cell per round, `height` of the HP bar's thickness
|
||||
@@ -1298,13 +1330,14 @@ export class UnitView {
|
||||
this.aura.scale.set((s * 1.3) / 128, (s * 1.9) / 128);
|
||||
}
|
||||
|
||||
// head height: operators/tokens are uniform chibis; enemies vary (setup-pose bounds, when known; else the chibi
|
||||
// headroom × their official model factor)
|
||||
let headTiles = UNIT.headroom;
|
||||
if (this.isEnemy && spineShown && this.actor.entry.bounds) headTiles = clamp(this.actor.height * UNIT.modelScale * this.modelK * this.modelKY * 0.92, 0.55, this.isBoss ? 3.2 : 2.2);
|
||||
else if (this.isEnemy && this.isBoss) headTiles = 2.2;
|
||||
else if (this.isEnemy && spineShown) headTiles = clamp(UNIT.headroom * this.modelK * this.modelKY, 0.55, 2.2);
|
||||
this._headTiles = headTiles;
|
||||
// keep the original short-circuit: many views never build an actor (prep chips, diamonds, tests), so the model
|
||||
// only counts when a spine is actually shown
|
||||
const actor = spineShown ? this.actor : null;
|
||||
const headTiles = this._headTiles = headTilesForUnit({
|
||||
isEnemy: this.isEnemy, isBoss: this.isBoss, spineShown,
|
||||
bounds: actor && actor.entry ? actor.entry.bounds : null,
|
||||
modelK: this.modelK, modelKY: this.modelKY,
|
||||
});
|
||||
this.screen.top = by - headTiles * s;
|
||||
this._updateHud(dt, s, bx, by - headTiles * s, alpha, t);
|
||||
}
|
||||
|
||||
@@ -84,7 +84,7 @@ import { CombatHud } from '../ui/combatHud.js';
|
||||
import { SettingsModal, settingsStore } from '../ui/settings.js';
|
||||
import { ExitModal, AwayOverlay, awayStore } from '../ui/matchChrome.js';
|
||||
import { openGuide } from '../ui/guide.js';
|
||||
import { actions } from '../ui/gameActions.js';
|
||||
import { actions, onRefreshBusy } from '../ui/gameActions.js';
|
||||
import { FacingWheel, holdPiece, setPieceDir, syncPieceDirs, showRange, useTileScreen } from '../ui/facingWheel.js';
|
||||
import { Underframe, underframeRect, TempRowNotice } from '../ui/underframe.js';
|
||||
import { StandInTags } from './game/standInTags.js';
|
||||
@@ -183,6 +183,10 @@ function MatchScreen() {
|
||||
// deals duplicates) carry the same chess id and no piece, and each must say its 选中干员 (detailPanel selectVoiceKey)
|
||||
const cardTap = useRef(0);
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
// GitHub #501: a g.refresh is still waiting for its reply — the shop's 刷新 button dims while it is (gameActions.js
|
||||
// single-flights the intent; the R hotkey is dropped by the same guard)
|
||||
const [refreshBusy, setRefreshBusy] = useState(false);
|
||||
useEffect(() => onRefreshBusy(setRefreshBusy), []);
|
||||
const [rewardMin, setRewardMin] = useState(false);
|
||||
const [emoteOpen, setEmoteOpen] = useState(false);
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
@@ -428,6 +432,7 @@ function MatchScreen() {
|
||||
if (showPrep) {
|
||||
if (field?.prep) staleFieldRef.current = field; // a prep scouting board is never a battle to enter
|
||||
if (viewModeRef.current !== 'prep') {
|
||||
audio.stopBondCue('battle');
|
||||
// the battle we just left (or whatever was stored before mount) must not be re-entered next combat;
|
||||
// an m.field that arrives during prep (the upcoming battle) is a new object and will be entered
|
||||
staleFieldRef.current = field;
|
||||
@@ -759,7 +764,8 @@ function MatchScreen() {
|
||||
const activeBonds = (priv?.bonds || []).filter((b) => b && b.active).length;
|
||||
const prevActive = useRef(activeBonds);
|
||||
useEffect(() => {
|
||||
if (activeBonds > prevActive.current && phase === PHASE.PREP) audio.sfx('bondUp', { volume: 0.7 });
|
||||
// a layer gain of the same update already started the cue (audio.bondCueRecent): keep it, don't restart it
|
||||
if (activeBonds > prevActive.current && phase === PHASE.PREP && !audio.bondCueRecent('prep')) audio.sfx('bondUp', { volume: 0.7, scope: 'prep' });
|
||||
prevActive.current = activeBonds;
|
||||
}, [activeBonds]);
|
||||
|
||||
@@ -998,7 +1004,7 @@ function MatchScreen() {
|
||||
// an enemy of the preview pen (research 09 §2.2 "Intel": tap it for its detail card)
|
||||
const penKey = previewEnemyKey(e);
|
||||
if (penKey) { setDetail({ kind: 'enemy', id: penKey }); return; }
|
||||
if (e.unitId != null || e.unit) { setDetail({ kind: 'unit', unit: e.unit || null, unitId: e.unitId, uid: e.uid }); return; }
|
||||
if (e.unitId != null || e.unit) { setDetail({ kind: 'unit', unit: e.unit || null, unitId: e.unitId, uid: e.uid, prepMapChar: e.prepMapChar === true }); return; }
|
||||
if (!Number.isInteger(e.uid)) return;
|
||||
setDetail({ kind: 'piece', uid: e.uid });
|
||||
// a tap selects an own piece (underframe + range); right-click / long-press only opens its detail card. A tap on
|
||||
@@ -1398,7 +1404,7 @@ 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
|
||||
// an own board piece or read-only prepared map character in prep; 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
|
||||
@@ -1468,7 +1474,7 @@ function MatchScreen() {
|
||||
${spectator ? null : html`<${Button} size="sm" variant="primary" icon="back" onClick=${() => watchPlayer({ playerId: myId })}>${t('返回自己')}<//>`}
|
||||
</div>` : null}
|
||||
|
||||
${showShop ? html`<${ShopBar} priv=${priv} editable=${editable} collapsed=${collapsed} onCollapse=${setCollapsed}
|
||||
${showShop ? html`<${ShopBar} priv=${priv} editable=${editable} collapsed=${collapsed} refreshBusy=${refreshBusy} onCollapse=${setCollapsed}
|
||||
barRef=${barRef} offBonds=${offBonds}
|
||||
onBuy=${buy} onLevel=${() => actions.levelUp()} onRefresh=${() => actions.refresh()} onFreeze=${() => actions.freeze()}
|
||||
onDetail=${(id, kind, hint) => setDetail({ kind: kind === 'item' ? 'item' : 'chess', id, hint: hint || null, tap: ++cardTap.current })}
|
||||
|
||||
@@ -13,15 +13,18 @@ export function inspectRange({ target, detail, pieces, showPrep = false, field,
|
||||
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) {
|
||||
} else if (target?.kind === 'unit' && (!showPrep || target.prepMapChar === true)) {
|
||||
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;
|
||||
// A prepared map character is a read-only view, not a simulated unit yet. Its board-space coordinates come from
|
||||
// m.private.prepMapChars and must not be read from a previous battle's snapshot or transformed twice on boss R.
|
||||
if (showPrep && (unit.kind !== 'token' || detail.type !== 'token' || detail.token?.kind !== 'mapChar')) 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');
|
||||
if (!showPrep && !field?.prep && (!snapshot || snapshot[3] <= 0)) return null;
|
||||
row = Math.round(!showPrep && snapshot ? snapshot[2] : unit.y);
|
||||
col = Math.round(!showPrep && snapshot ? snapshot[1] : unit.x);
|
||||
dir = (!showPrep && 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) }
|
||||
|
||||
@@ -451,6 +451,20 @@ export function importRefusal(kind, ready, status = data.status) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A loadout payload that carries `ops` (including empty `ops`) replaces 潜能 / 练度. 自选 operators are in that list
|
||||
* and only backups.json names them, so the refusal is the 自选编队 one: chess.json and backups.json both loaded.
|
||||
* Null when the payload has no `ops` (an older file leaves the current 练度 alone) or that data is ready (GitHub #538).
|
||||
* @param {Record<string, any> | null | undefined} ops
|
||||
* @param {boolean} ready
|
||||
* @param {(name: string) => string} [status]
|
||||
* @returns {{ text: string, tone: 'warn'|'error' } | null}
|
||||
*/
|
||||
export function loadoutOpsImportRefusal(ops, ready, status = data.status) {
|
||||
if (ops == null) return null;
|
||||
return importRefusal('diy', ready, status);
|
||||
}
|
||||
|
||||
/** In place of a tab whose game data did not load (missingGameData): which files, and what to try (GitHub #173). */
|
||||
export function DataMissing({ files }) {
|
||||
const urls = files.map((name) => `/data/${DATA_FILES[name] || `${name}.json`}`);
|
||||
@@ -586,6 +600,10 @@ function LoadoutScreen({ st }) {
|
||||
}
|
||||
const res = parseImport(ioText);
|
||||
if (!res.ok) { toast(t('导入失败:{error}', { error: res.error }), 'error'); return; }
|
||||
// ops replaces 潜能 / 练度, including 自选 operators that only backups.json names. Missing data used to drop
|
||||
// them and still save, clearing a 练度 the file did not mean to erase (GitHub #538).
|
||||
const opsRefused = loadoutOpsImportRefusal(res.ops, ready);
|
||||
if (opsRefused) { toast(opsRefused.text, opsRefused.tone); return; }
|
||||
// 0.2.2: a payload with `ops` replaces the 潜能 / 练度 too (an older export leaves them alone)
|
||||
const opIds = res.ops ? cultivationCharIds(data.get('chess'), data.get('backups')) : null;
|
||||
const r = applyLoadoutEntries(res.entries, getChess, res.ops ? { ops: res.ops, isOperator: (id) => opIds.has(id) } : {});
|
||||
@@ -675,7 +693,7 @@ function LoadoutScreen({ st }) {
|
||||
${inMatch && hasDeadline(infoDeadline) ? html`<${Countdown} deadline=${infoDeadline} size="sm" gauge=${false} label=${t('调配截止')} class="lo-deadline" />` : null}
|
||||
${syncText ? html`<span class=${cx('lo-sync', syncCls)} role="status">${t(syncText)}</span>` : null}
|
||||
<span class="lo-count">${t('已调整')} <b class="num">${nChanged}</b><span class="num t-dim">/${roster.length}</span></span>
|
||||
<${Button} variant="ghost" size="sm" data-testid="loadout-export" disabled=${!nChanged} onClick=${openExport} title=${t('导出当前调配(可复制或下载)')}>${t('导出')}<//>
|
||||
<${Button} variant="ghost" size="sm" data-testid="loadout-export" disabled=${!nChanged && !Object.keys(st.ops || {}).length} onClick=${openExport} title=${t('导出当前调配(可复制或下载)')}>${t('导出')}<//>
|
||||
<${Button} variant="ghost" size="sm" data-testid="loadout-import" disabled=${!ready} onClick=${openImport} title=${t('导入调配(粘贴或选择文件)')}>${t('导入')}<//>
|
||||
<${Button} variant="secondary" size="sm" icon="refresh" disabled=${!nChanged} onClick=${resetAll}>${t('全部恢复默认')}<//>
|
||||
</div>`}
|
||||
|
||||
@@ -291,8 +291,11 @@ export function TitleScreen() {
|
||||
<${ResumeMatchButton} />
|
||||
<${PwaInstallButton} />
|
||||
<span>${t('非官方同人复刻 · 游戏素材版权归 上海鹰角网络 / Yostar 所有')}</span>
|
||||
<${MicroLabel}>v${APP_VERSION} · WEB SIMULATION<//>
|
||||
${DEV_BUILD ? html`<span class="title-dev" role="note">${t('开发版 · 不稳定,请勿用于公开服务器')}</span>` : null}
|
||||
<span class="title-foot__meta">
|
||||
<a class="title-repo" href="https://github.com/sganggs/Stronghold-Protocol" target="_blank" rel="noopener noreferrer">${t('项目仓库')}</a>
|
||||
<${MicroLabel}>v${APP_VERSION} · WEB SIMULATION<//>
|
||||
${DEV_BUILD ? html`<span class="title-dev" role="note">${t('开发版 · 不稳定,请勿用于公开服务器')}</span>` : null}
|
||||
</span>
|
||||
</footer>
|
||||
</div>`;
|
||||
}
|
||||
+35
-12
@@ -534,24 +534,27 @@ export function DialogHost() {
|
||||
|
||||
// ---- Tooltip -----------------------------------------------------------------------------------
|
||||
|
||||
let tipState = null; // { id, content, rect, placement }
|
||||
let tipState = null; // { id, content, aside, rect, placement }
|
||||
let tipSeq = 0;
|
||||
const tipListeners = new Set();
|
||||
const setTip = (s) => { tipState = s; for (const fn of [...tipListeners]) fn(); };
|
||||
|
||||
/**
|
||||
* Hover/focus tooltip around its children. Content renders in <TooltipLayer/> (fixed, viewport-clamped).
|
||||
* @param {{ text: any, placement?: 'top'|'bottom', delay?: number, block?: boolean, class?: string, children?: any }} props
|
||||
* `aside`: a second section in the same clamped, scrollable box; stacked on narrow screens.
|
||||
* @param {{ text: any, aside?: any, placement?: 'top'|'bottom', delay?: number, block?: boolean, class?: string, children?: any }} props
|
||||
*/
|
||||
const TOUCH_TIP_MS = 450;
|
||||
|
||||
export function Tooltip({ text, placement = 'top', delay = 120, block = false, class: cls, children }) {
|
||||
export function Tooltip({ text, aside = null, placement = 'top', delay = 120, block = false, class: cls, children }) {
|
||||
const ref = useRef(null);
|
||||
const idRef = useRef(0);
|
||||
const timer = useRef(null);
|
||||
const textRef = useRef(text);
|
||||
const asideRef = useRef(aside);
|
||||
const pressed = useRef(false); // pointer pressed inside: the focus that follows is not a keyboard focus
|
||||
textRef.current = text;
|
||||
asideRef.current = aside;
|
||||
if (!idRef.current) idRef.current = ++tipSeq;
|
||||
const show = () => {
|
||||
clearTimeout(timer.current);
|
||||
@@ -560,7 +563,8 @@ export function Tooltip({ text, placement = 'top', delay = 120, block = false, c
|
||||
const el = ref.current;
|
||||
const content = textRef.current; // the text of the render current when the delay ends (a click may change it)
|
||||
if (!el || !el.isConnected || content == null || content === '') return;
|
||||
setTip({ id: idRef.current, content, rect: el.getBoundingClientRect(), placement });
|
||||
setTip({ id: idRef.current, content, aside: asideRef.current, rect: el.getBoundingClientRect(), placement,
|
||||
hold: () => clearTimeout(timer.current), hide });
|
||||
}, delay);
|
||||
};
|
||||
const hide = () => {
|
||||
@@ -570,7 +574,7 @@ export function Tooltip({ text, placement = 'top', delay = 120, block = false, c
|
||||
// A click hides the tooltip until the pointer leaves: the focus it gives the button must not bring it back
|
||||
// (with the text of the click's closure) over whatever the click opened. Keyboard focus still shows it.
|
||||
// Touch: a tap never shows it (the compatibility mouseenter that follows a tap is ignored); a long press
|
||||
// (≥ 450 ms) does, and it goes away shortly after the finger lifts.
|
||||
// (≥ 450 ms) does. Ordinary tips expire after release; compound lists stay open for scrolling until dismissed.
|
||||
const touchAt = useRef(0);
|
||||
const longTimer = useRef(null);
|
||||
const onPointerDown = (e) => {
|
||||
@@ -586,18 +590,24 @@ export function Tooltip({ text, placement = 'top', delay = 120, block = false, c
|
||||
if (!e || e.pointerType === 'mouse') return;
|
||||
touchAt.current = Date.now();
|
||||
clearTimeout(longTimer.current);
|
||||
if (tipState?.id === idRef.current) { clearTimeout(timer.current); timer.current = setTimeout(hide, 1600); }
|
||||
if (tipState?.id === idRef.current && !asideRef.current) { clearTimeout(timer.current); timer.current = setTimeout(hide, 1600); }
|
||||
};
|
||||
const onMouseEnter = () => { if (Date.now() - touchAt.current > 900) show(); };
|
||||
const onFocusIn = () => { if (!pressed.current && Date.now() - touchAt.current > 900) show(); };
|
||||
const onLeave = () => { pressed.current = false; if (Date.now() - touchAt.current > 900) hide(); };
|
||||
const onLeave = () => {
|
||||
pressed.current = false;
|
||||
if (Date.now() - touchAt.current > 900) {
|
||||
if (asideRef.current) { clearTimeout(timer.current); timer.current = setTimeout(hide, 250); }
|
||||
else hide();
|
||||
}
|
||||
};
|
||||
useEffect(() => () => { clearTimeout(longTimer.current); hide(); }, []);
|
||||
// Keep the visible tooltip's content fresh when `text` changes (and hide it when text goes away).
|
||||
useEffect(() => {
|
||||
if (tipState?.id !== idRef.current) return;
|
||||
if (text == null || text === '') setTip(null);
|
||||
else setTip({ ...tipState, content: text });
|
||||
}, [text]);
|
||||
else setTip({ ...tipState, content: text, aside: asideRef.current });
|
||||
}, [text, aside]);
|
||||
return html`<span ref=${ref} class=${cx('tt-anchor', block && 'tt-anchor--block', cls)}
|
||||
onMouseEnter=${onMouseEnter} onMouseLeave=${onLeave} onFocusIn=${onFocusIn} onFocusOut=${onLeave} onPointerDown=${onPointerDown}
|
||||
onPointerUp=${onPointerEnd} onPointerCancel=${onPointerEnd} onContextMenu=${(e) => { if (Date.now() - touchAt.current < 900) e.preventDefault(); }}>
|
||||
@@ -612,7 +622,12 @@ export function TooltipLayer() {
|
||||
const [pos, setPos] = useState(null);
|
||||
useEffect(() => {
|
||||
tipListeners.add(force);
|
||||
return () => tipListeners.delete(force);
|
||||
const outside = (e) => { if (tipState?.aside && !e.target?.closest?.('.tooltip')) tipState.hide?.(); };
|
||||
const escape = (e) => { if (e.key === 'Escape') tipState?.hide?.(); };
|
||||
document.addEventListener('pointerdown', outside);
|
||||
document.addEventListener('keydown', escape);
|
||||
window.addEventListener('resize', force);
|
||||
return () => { tipListeners.delete(force); document.removeEventListener('pointerdown', outside); document.removeEventListener('keydown', escape); window.removeEventListener('resize', force); };
|
||||
}, []);
|
||||
useLayoutEffect(() => {
|
||||
const el = boxRef.current;
|
||||
@@ -628,13 +643,21 @@ export function TooltipLayer() {
|
||||
if (place === 'bottom' && top + b.height > vh - m) { place = 'top'; top = Math.max(m, r.top - b.height - m); }
|
||||
let left = r.left + r.width / 2 - b.width / 2;
|
||||
left = Math.max(m, Math.min(vw - b.width - m, left));
|
||||
top = Math.max(m, Math.min(vh - b.height - m, top));
|
||||
const next = { top: Math.round(top), left: Math.round(left), place, id: tipState.id };
|
||||
if (!pos || pos.top !== next.top || pos.left !== next.left || pos.id !== next.id || pos.place !== next.place) setPos(next);
|
||||
});
|
||||
if (!tipState) return null;
|
||||
const ready = pos && pos.id === tipState.id;
|
||||
return html`<div ref=${boxRef} class=${cx('tooltip', ready && 'is-shown', ready && `tooltip--${pos.place}`)} role="tooltip"
|
||||
style=${ready ? `top:${pos.top}px;left:${pos.left}px` : 'top:-9999px;left:-9999px'}>${tipState.content}</div>`;
|
||||
const hasAside = tipState.aside != null && tipState.aside !== '';
|
||||
const off = 'top:-9999px;left:-9999px';
|
||||
return html`<div ref=${boxRef} class=${cx('tooltip', hasAside && 'tooltip--compound', ready && 'is-shown', ready && `tooltip--${pos.place}`)} role="tooltip"
|
||||
tabIndex=${hasAside ? 0 : undefined} onMouseEnter=${() => tipState?.hold?.()} onMouseLeave=${() => tipState?.hide?.()}
|
||||
onFocusIn=${() => tipState?.hold?.()} onPointerDown=${() => tipState?.hold?.()}
|
||||
onFocusOut=${(e) => { if (!e.currentTarget.contains(e.relatedTarget)) tipState?.hide?.(); }}
|
||||
style=${ready ? `top:${pos.top}px;left:${pos.left}px` : off}>
|
||||
${hasAside ? html`<div class="tooltip__main">${tipState.content}</div><div class="tooltip--aside">${tipState.aside}</div>` : tipState.content}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// ---- ProgressBar, Tabs, Spinner ----------------------------------------------------------------
|
||||
|
||||
@@ -60,6 +60,30 @@ export function fallbackTileClass(stage, row, col) {
|
||||
return 'floor';
|
||||
}
|
||||
|
||||
/**
|
||||
* The simplified board's floating label for one event, or null when that event draws no digits.
|
||||
* Official mode keeps only a marked red integer (including 0) and a yellow MISS. The slot stays above the unit;
|
||||
* the random offset of the Pixi numbers is not repeated here [ASSUMED].
|
||||
* @param {any[]} e
|
||||
* @param {{ damageNumbers?: boolean, officialDamageNumbers?: boolean }} settings
|
||||
* @returns {{ id: any, text: string, type: string }|null}
|
||||
*/
|
||||
export function fallbackFloat(e, settings) {
|
||||
if (!settings?.damageNumbers || !Array.isArray(e)) return null;
|
||||
if (settings.officialDamageNumbers === true) {
|
||||
if (e[0] === 'fx' && e[1] === 'dodge' && e[4] && e[4].num === 'm') return { id: e[4].id, text: 'MISS', type: 'miss' };
|
||||
if (e[0] !== 'dmg' || e[4] !== 'r') return null;
|
||||
const amount = Math.round(Number(e[2]) || 0);
|
||||
if (!Number.isFinite(amount) || amount < 0) return null;
|
||||
return { id: e[1], text: String(amount), type: 'crit' };
|
||||
}
|
||||
if (e[0] !== 'dmg' && e[0] !== 'heal') return null;
|
||||
const amount = Math.round(Number(e[2]) || 0);
|
||||
if (amount <= 0) return null;
|
||||
const type = e[0] === 'heal' ? 'heal' : (e[3] === 'arts' ? 'arts' : e[3] === 'true' ? 'true' : 'phys');
|
||||
return { id: e[1], text: e[0] === 'heal' ? `+${amount}` : String(amount), type };
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the DOM fallback view.
|
||||
* @param {HTMLElement} host
|
||||
@@ -78,7 +102,7 @@ export function createFallbackView(host, opts = {}) {
|
||||
stage: null, camera: 'prep', rect: { ...GEO.NORMAL_RECT }, side: 'L', deployField: 'normal',
|
||||
mode: 'prep', priv: null, editable: false, canPlace: null,
|
||||
field: null, units: new Map(), snapUnits: new Map(), highlight: new Map(), hlGroups: new Map(), held: new Map(), dirs: new Map(),
|
||||
drag: null, hoverTarget: null, floats: [], settings: { damageNumbers: true, ...(opts.settings || {}) },
|
||||
drag: null, hoverTarget: null, floats: [], settings: { damageNumbers: true, officialDamageNumbers: false, ...(opts.settings || {}) },
|
||||
size: { w: 0, h: 0 }, destroyed: false, pen: [],
|
||||
};
|
||||
|
||||
@@ -337,7 +361,9 @@ export function createFallbackView(host, opts = {}) {
|
||||
const pos = tilePos(L, u.y, u.x);
|
||||
const src = tokenAvatarUrl(m(), u.defId);
|
||||
pieces.push(html`<div key=${`map:${u.uid}`} class="ff-piece ff-piece--token ff-piece--map-char"
|
||||
style=${`transform:translate(${pos.x}px,${pos.y}px);width:${L.tile}px;height:${L.tile}px;pointer-events:none`}
|
||||
style=${`transform:translate(${pos.x}px,${pos.y}px);width:${L.tile}px;height:${L.tile}px`}
|
||||
onPointerDown=${(e) => { if (e.button === 0) emit('pieceClick', { unit: u, unitId: null, uid: u.uid, prepMapChar: true, button: 0, clientX: e.clientX, clientY: e.clientY }); }}
|
||||
onContextMenu=${(e) => { e.preventDefault(); emit('pieceClick', { unit: u, unitId: null, uid: u.uid, prepMapChar: true, button: 2, detail: true, clientX: e.clientX, clientY: e.clientY }); }}
|
||||
title=${u.name || u.defId}>
|
||||
<div class="ff-piece__art">${src ? html`<img src=${src} alt="" draggable=${false} />` : html`<span>${[...(u.name || '?')][0]}</span>`}</div>
|
||||
</div>`);
|
||||
@@ -456,11 +482,9 @@ export function createFallbackView(host, opts = {}) {
|
||||
if (e[0] === 'spawn' && e[1] && e[1].id != null) {
|
||||
st.units.set(e[1].id, e[1]);
|
||||
if (!st.snapUnits.has(e[1].id)) st.snapUnits.set(e[1].id, [e[1].id, e[1].x, e[1].y, e[1].maxHp, e[1].maxHp, 0, 0, 0, 6]);
|
||||
} else if ((e[0] === 'dmg' || e[0] === 'heal') && st.settings.damageNumbers && st.floats.length < 60) {
|
||||
const amount = Math.round(Number(e[2]) || 0);
|
||||
if (amount <= 0) continue;
|
||||
const type = e[0] === 'heal' ? 'heal' : (e[3] === 'arts' ? 'arts' : e[3] === 'true' ? 'true' : 'phys');
|
||||
st.floats.push({ key: `${now}:${Math.random()}`, id: e[1], text: e[0] === 'heal' ? `+${amount}` : String(amount), type, at: now });
|
||||
} else if (st.floats.length < 60) {
|
||||
const spec = fallbackFloat(e, st.settings);
|
||||
if (spec) st.floats.push({ key: `${now}:${Math.random()}`, id: spec.id, text: spec.text, type: spec.type, at: now });
|
||||
}
|
||||
}
|
||||
schedule();
|
||||
|
||||
@@ -20,6 +20,22 @@ const emitBusy = () => { for (const fn of [...busyListeners]) { try { fn(infligh
|
||||
/** Subscribe to the number of in-flight intents (for subtle busy indicators). */
|
||||
export function onBusy(fn) { busyListeners.add(fn); return () => busyListeners.delete(fn); }
|
||||
|
||||
// Single-flight shop refresh (GitHub #501, single-ptilopsis): under a high RTT the shop has not answered yet while the
|
||||
// player taps 刷新 again — every extra g.refresh the client sends is a paid refresh (server/match intents.js → ps.refresh).
|
||||
// A tap while the previous request is still on the wire is dropped; the button dims and the R hotkey stays inert (both
|
||||
// call actions.refresh) until the request settles (success or failure). This is not server-side cancellation or
|
||||
// idempotency: a timed-out request may already have executed. The shop bar receives onRefreshBusy via the game screen.
|
||||
let refreshPending = false;
|
||||
const refreshBusyListeners = new Set();
|
||||
const emitRefreshBusy = () => { for (const fn of [...refreshBusyListeners]) { try { fn(refreshPending); } catch { /* ignore */ } } };
|
||||
|
||||
/**
|
||||
* Subscribe to the shop refresh's in-flight state — fires once with the current state.
|
||||
* @param {(busy: boolean) => void} fn
|
||||
* @returns {() => void} unsubscribe
|
||||
*/
|
||||
export function onRefreshBusy(fn) { refreshBusyListeners.add(fn); try { fn(refreshPending); } catch { /* ignore */ } return () => refreshBusyListeners.delete(fn); }
|
||||
|
||||
/**
|
||||
* Send an intent. Resolves true on `ok`, false on error (already toasted).
|
||||
* @param {string} t
|
||||
@@ -55,7 +71,13 @@ export const actions = {
|
||||
band: (bandId) => act('g.band', { bandId }),
|
||||
bandSkip: () => act('g.bandSkip'),
|
||||
buy: (slot) => act('g.buy', { slot }),
|
||||
refresh: () => act('g.refresh'),
|
||||
// a tap while the previous g.refresh is still on the wire is dropped (GitHub #501) — see onRefreshBusy above
|
||||
refresh: () => {
|
||||
if (refreshPending) return Promise.resolve(false);
|
||||
refreshPending = true;
|
||||
emitRefreshBusy();
|
||||
return act('g.refresh').finally(() => { refreshPending = false; emitRefreshBusy(); });
|
||||
},
|
||||
freeze: () => act('g.freeze'),
|
||||
levelUp: () => act('g.levelUp'),
|
||||
sell: (uid) => act('g.sell', { uid }),
|
||||
|
||||
@@ -201,8 +201,9 @@ export function GIcon({ name, class: cls, title }) {
|
||||
|
||||
/**
|
||||
* Green LP tower + number. `pending` (> 0): the loss the running battle's leaks will cost at settlement (user
|
||||
* playtest #3 item 2) — the number shows value − pending in red followed by a −N tick that pops again on every change
|
||||
* (keyed); `note` a small tag after it (联防中); `tip` the title text.
|
||||
* playtest #3 item 2) — the number keeps the settled LP (owner-approved #479 / #504),
|
||||
* shown in red with a −N tick that pops again on every change (keyed); `note` a
|
||||
* small tag after it (联防中); `tip` the title text.
|
||||
* @param {{ value: any, size?: 'sm'|'md'|'lg', class?: string, tone?: string|null, pending?: number, note?: string|null, tip?: string|null }} props
|
||||
*/
|
||||
export function LpTower({ value, size = 'md', class: cls, tone, pending = 0, note = null, tip = null }) {
|
||||
@@ -211,7 +212,7 @@ export function LpTower({ value, size = 'md', class: cls, tone, pending = 0, not
|
||||
return html`<span class=${cx('lp', `lp--${size}`, tone && `lp--${tone}`, p > 0 && 'is-pending', cls)} title=${tip || tr('目标生命值')}
|
||||
data-pending=${p > 0 ? p : null}>
|
||||
<${Sprite} k="hudPanel/icon_hp" class="lp__icon" fallback=${html`<${Icon} name="rook" class="lp__icon" />`} />
|
||||
<b class="num lp__val">${ok ? Math.max(0, value - p) : '--'}</b>
|
||||
<b class="num lp__val">${ok ? Math.max(0, value) : '--'}</b>
|
||||
${p > 0 ? html`<span key=${p} class="lp__pend num" aria-label=${tr('结算时扣除 {p}', { p })}>−${p}</span>` : null}
|
||||
${note ? html`<span class="lp__note">${note}</span>` : null}
|
||||
</span>`;
|
||||
|
||||
@@ -4,12 +4,23 @@ import { GEO, PHASE } from '../../../../shared/constants.js';
|
||||
import { BOSS_ROW_SHIFT, MAX_COL } from '../../render/prepfield.js';
|
||||
import { int, isObj } from './shared.js';
|
||||
|
||||
/** Server-owned random pairs. Seat inference is only for an older server without the field. */
|
||||
function prepPair(pub, myId) {
|
||||
const alive = (Array.isArray(pub?.players) ? pub.players : []).filter(p => isObj(p) && p.alive !== false);
|
||||
if (!alive.some(p => p.playerId === myId)) return [];
|
||||
if (pub.bossPairs !== undefined) {
|
||||
return (Array.isArray(pub.bossPairs) ? pub.bossPairs : []).find(g => Array.isArray(g) && g.includes(myId)) || [];
|
||||
}
|
||||
const ids = alive.sort((a, b) => int(a.seat, 99) - int(b.seat, 99)).map(p => p.playerId);
|
||||
const i = ids.indexOf(myId);
|
||||
return ids.slice(i - i % 2, i - i % 2 + 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Camera of the own prep board (research 09 §1.2 "Final Assault, right side", DESIGN §15): in the prep of a boss round
|
||||
* (最终攻势 / 隐秘核心: `pub.round` is `bossRound` or `hiddenRound`) the pieces stand on the player's half of the shared
|
||||
* boss field — `{ kind: 'bossPrep', opts: { side } }`; the side follows the server's pairing (server/match/
|
||||
* finalAssault.js pairPlayers: the alive players in seat order, two by two; the second of a pair is on the mirrored
|
||||
* finalAssault.js pairPlayers, sent in m.public.bossPairs; the second of a pair is on the mirrored
|
||||
* right half 'R', a lone player on 'L'). Any other prep: the own board `{ kind: 'prep', opts: { rect, side: 'L' } }`.
|
||||
* @param {any} pub m.public
|
||||
* @param {string|null} myId
|
||||
@@ -19,9 +30,7 @@ export function prepCamera(pub, myId) {
|
||||
const normal = { kind: 'prep', opts: { rect: { ...GEO.NORMAL_RECT }, side: 'L' } };
|
||||
const r = pub?.round;
|
||||
if (!Number.isInteger(r) || r <= 0 || !(r === pub.bossRound || r === pub.hiddenRound)) return normal;
|
||||
const alive = (Array.isArray(pub.players) ? pub.players : []).filter((p) => isObj(p) && p.alive !== false)
|
||||
.sort((a, b) => int(a.seat, 99) - int(b.seat, 99));
|
||||
const idx = alive.findIndex((p) => p.playerId === myId);
|
||||
const idx = prepPair(pub, myId).indexOf(myId);
|
||||
if (idx < 0) return normal; // eliminated / unknown: no board of its own on the boss field
|
||||
return { kind: 'bossPrep', opts: { side: idx % 2 === 1 ? 'R' : 'L' } };
|
||||
}
|
||||
@@ -32,7 +41,7 @@ const BOSS_PREP_PHASES = new Set([PHASE.ROUND_START, PHASE.SP_DRAFT, PHASE.PREP]
|
||||
* The players framed in green on the team panel (community report of 2026-10-06, item 51: 「同组两个玩家屏幕左边的头像也像原版
|
||||
* 那样用绿框框起来」 — the official co-op frame, ui/battle `bg_team_border`): in a boss round (最终攻势 / 隐秘核心) the viewer
|
||||
* and its pair partner — from the round's start (回合开始 / 机变 / 休整期: the pairing `prepCamera` reads, the players still
|
||||
* in by seat, two by two — server/match/finalAssault.js pairPlayers) through the fight (the boss field listing the
|
||||
* in the server's saved pairs — server/match/finalAssault.js pairPlayers) through the fight (the boss field listing the
|
||||
* viewer). Empty for a lone player, an eliminated player or a spectator seat (no pair of its own) and in every other
|
||||
* round. PR #191 framed the partner alone, in gold (not merged).
|
||||
* @param {any} pub m.public @param {string|null} myId
|
||||
@@ -51,9 +60,7 @@ export function teamFrameIds(pub, myId) {
|
||||
return f && f.players.length > 1 ? new Set(f.players) : none;
|
||||
}
|
||||
if (!bossRound || !BOSS_PREP_PHASES.has(pub.phase)) return none;
|
||||
const alive = players.filter((p) => p.alive !== false).sort((a, b) => int(a.seat, 99) - int(b.seat, 99)).map((p) => p.playerId);
|
||||
const idx = alive.indexOf(myId);
|
||||
const pair = alive.slice(idx - (idx % 2), idx - (idx % 2) + 2);
|
||||
const pair = prepPair(pub, myId);
|
||||
return pair.length > 1 ? new Set(pair) : none;
|
||||
}
|
||||
|
||||
|
||||
@@ -24,13 +24,13 @@ export const TEXT_SIZES = Object.freeze(['sm', 'md', 'lg', 'xl']);
|
||||
|
||||
/** keys: the in-match shortcuts' key map (ui/gameLogic/shortcuts.js; settings → 快捷键). voiceLang: VOICE_LANGS.
|
||||
* textSize: TEXT_SIZES (css/theme.css `--t`, applied by ui/settings.js applyTextSize). */
|
||||
export const DEFAULT_SETTINGS = Object.freeze({ bgm: 0.6, sfx: 0.8, voice: 0.8, voiceLang: 'cn', voiceOverrides: Object.freeze({}), muted: false, damageNumbers: true, quality: 'high', textSize: 'sm', keys: DEFAULT_HOTKEYS });
|
||||
export const DEFAULT_SETTINGS = Object.freeze({ bgm: 0.6, sfx: 0.8, voice: 0.8, voiceLang: 'cn', voiceOverrides: Object.freeze({}), muted: false, damageNumbers: true, officialDamageNumbers: false, quality: 'high', textSize: 'sm', keys: DEFAULT_HOTKEYS });
|
||||
const QUALITIES = ['high', 'medium', 'low'];
|
||||
|
||||
/**
|
||||
* Sanitize persisted settings.
|
||||
* @param {any} raw
|
||||
* @returns {{ bgm: number, sfx: number, voice: number, voiceLang: 'cn'|'jp', voiceOverrides: Record<string, string>, muted: boolean, damageNumbers: boolean, quality: 'high'|'medium'|'low',
|
||||
* @returns {{ bgm: number, sfx: number, voice: number, voiceLang: 'cn'|'jp', voiceOverrides: Record<string, string>, muted: boolean, damageNumbers: boolean, officialDamageNumbers: boolean, quality: 'high'|'medium'|'low',
|
||||
* textSize: 'sm'|'md'|'lg'|'xl', keys: Record<'refresh'|'freeze'|'levelUp'|'retreat'|'sell'|'ready', string> }}
|
||||
*/
|
||||
export function sanitizeSettings(raw) {
|
||||
@@ -44,6 +44,7 @@ export function sanitizeSettings(raw) {
|
||||
voiceOverrides: sanitizeVoiceOverrides(r.voiceOverrides),
|
||||
muted: typeof r.muted === 'boolean' ? r.muted : DEFAULT_SETTINGS.muted,
|
||||
damageNumbers: typeof r.damageNumbers === 'boolean' ? r.damageNumbers : DEFAULT_SETTINGS.damageNumbers,
|
||||
officialDamageNumbers: typeof r.officialDamageNumbers === 'boolean' ? r.officialDamageNumbers : false,
|
||||
quality: QUALITIES.includes(r.quality) ? r.quality : DEFAULT_SETTINGS.quality,
|
||||
textSize: TEXT_SIZES.includes(r.textSize) ? r.textSize : DEFAULT_SETTINGS.textSize,
|
||||
keys: sanitizeHotkeys(r.keys),
|
||||
|
||||
+9
-7
@@ -12,13 +12,14 @@
|
||||
// overtimeState): "NN 秒后全队生命值开始流失" once the level time ran out, then a live "生命值 −1/秒" indicator while the
|
||||
// merged team LP drains (the LP tower turns red). Solo battles: a pause / resume button beside the countdown (g.pause);
|
||||
// while m.public.paused every clock here is frozen at the pause moment (`frozenAt`).
|
||||
// Normal rounds (user playtest #3 item 2): the LP tower drops live as the own battle's enemies enter the blue gate —
|
||||
// lp − min(lpCapPerRound, counted leaks) in red with a −N tick, 联防中 while a 联防 may still save part of it (liveLp;
|
||||
// Normal rounds (user playtest #3 item 2): the LP tower marks the own battle's leaks live as its enemies enter the blue
|
||||
// gate — the settled lp stays until settlement, in red with a −min(lpCapPerRound, counted leaks) tick (GitHub #479: the
|
||||
// official keeps the current value until settlement), 联防中 while a 联防 may still save part of it (liveLp;
|
||||
// the leaks come from the local battle runner, else m.public players[].pendingLp). 联防 (user playtest #6 item 7; PRTS
|
||||
// 卫戍协议/帮助 "防卫失败的玩家可通过上方信息栏确认自身所属敌人的剩余数量"): a leaker's phase capsule carries the official
|
||||
// runner tag ×N (research 09 `tag_miss`; art ui/battle bg_miss_enemy [ASSUMED]) = its enemies still standing on the 联防
|
||||
// field, uncapped and live — falling as the helpers kill them, rising when one splits or summons (the local 联防
|
||||
// replica's runner state().uniteLeft, else m.public players[].uniteLeft); the LP tower shows lp − min(lpCapPerRound, N).
|
||||
// replica's runner state().uniteLeft, else m.public players[].uniteLeft); the LP tower marks lp with −min(lpCapPerRound, N).
|
||||
// The same MissTag sits in the leakers' team rows (ui/teamPanel.js), its tooltip naming the teammate there (missTip).
|
||||
// 准备就绪 is refused while the temp overflow row (临时整备区) holds pieces: the reason shows under the button
|
||||
// (user playtest #3 item 3; the row's own label is ui/underframe.js TempRowNotice).
|
||||
@@ -183,9 +184,10 @@ export function uniteRemaining(local, server) {
|
||||
}
|
||||
|
||||
/**
|
||||
* The own LP while a normal round's battle runs: the loss its counted leaks will cost is shown at once (red, −N)
|
||||
* instead of only at settlement. `base` — kept by the caller between renders — is the settled m.private state the
|
||||
* pending loss applies to: { round, lp, statsLeaks } as first seen in the round's COMBAT / 联防. The pending part is
|
||||
* The own LP while a normal round's battle runs: the loss its counted leaks will cost is marked at once (red, −N) instead
|
||||
* of only at settlement — the number itself keeps the settled LP until settlement (GitHub #479: the official shows the
|
||||
* current value, not the deduction, until the round settles). `base` — kept by the caller between renders — is the
|
||||
* settled m.private state the pending loss applies to: { round, lp, statsLeaks } as first seen in the round's COMBAT / 联防. The pending part is
|
||||
* dropped as soon as the settlement lands — m.private lp or stats.leaks changed (Match.flush sends m.private before the
|
||||
* SETTLE m.public) — or the phase leaves COMBAT / UNITE or the round changes, so the loss is never subtracted twice.
|
||||
* 联防 (UNITE): a leaker finally loses min(cap, the survivors of the 联防 battle that came from them); `uniteLeft` (its
|
||||
@@ -352,7 +354,7 @@ export function PauseButton({ paused, busy = false, onToggle }) {
|
||||
* spectators?: any[]|null, myId?: any, isHost?: boolean, onRemoveSpectator?: ((playerId: any) => any)|null }} props
|
||||
* spectators: the room's spectator seats (room.state) — the 观战席 capsule beside the latency (SpectatorPill; the host removes)
|
||||
* frozenAt: the server time every clock shows while the solo match is paused (null = live)
|
||||
* live: the own battle's pending LP loss (liveLp): the tower shows lp − pending in red with a −N tick, 联防中 during 联防;
|
||||
* live: the own battle's pending LP loss (liveLp): the tower keeps the settled lp and adds a red −N tick, 联防中 during 联防;
|
||||
* `left` (a leaker in 联防): its enemies still standing — the capsule's ×N tag
|
||||
*/
|
||||
export function TopBar({ pub, priv, conn, hud, total, drawer, onExit, onDrawer, onReady, readyBusy, readyCount, playerCount, pen = false, penAvail = false, onPen = () => {},
|
||||
|
||||
@@ -169,6 +169,7 @@ function installPrefSync({ net, timers, target, notify, key, stateKey, msgType,
|
||||
const tell = notify || ((text) => toast(t(text), 'warn'));
|
||||
let timer = null;
|
||||
let seq = 0; // requests sent (the reply of an older one never overrides a newer one's state)
|
||||
let revision = 0; // edits and welcome invalidate a preparation still awaiting game data (GitHub #540)
|
||||
let pendingJson = null; // JSON of the newest request still awaiting its reply
|
||||
let lastSent = null; // JSON of the last payload the server accepted (on this session)
|
||||
let edited = false; // an edit is waiting to be sent (a lock refusal is then worth telling the player)
|
||||
@@ -189,9 +190,12 @@ function installPrefSync({ net, timers, target, notify, key, stateKey, msgType,
|
||||
async function flush() {
|
||||
if (disposed) return;
|
||||
if (net.status !== 'online') { setState('idle'); return; } // the next welcome resends
|
||||
const preparing = revision;
|
||||
try {
|
||||
const payload = await prepare();
|
||||
if (disposed) return;
|
||||
// a newer edit or welcome happened while prepare awaited chess/backups: do not send, and do not touch the
|
||||
// sync state that newer flush owns (an older skill must not overwrite a restore-defaults)
|
||||
if (disposed || preparing !== revision) return;
|
||||
// never sanitise against missing data: every entry would be dropped and the server's copy cleared
|
||||
if (payload == null) { setState('error'); return; }
|
||||
const json = JSON.stringify(payload);
|
||||
@@ -221,15 +225,16 @@ function installPrefSync({ net, timers, target, notify, key, stateKey, msgType,
|
||||
else { console.warn(`[${tag}] ${msgType} refused`, code, err && err.detail); setState('error'); }
|
||||
}
|
||||
} catch (e) {
|
||||
if (disposed || preparing !== revision) return;
|
||||
console.warn(`[${tag}] sync failed`, e);
|
||||
setState('error');
|
||||
}
|
||||
}
|
||||
|
||||
const offWelcome = net.on('welcome', () => { lastSent = null; pendingJson = null; seq++; schedule(50); });
|
||||
const offWelcome = net.on('welcome', () => { revision++; lastSent = null; pendingJson = null; seq++; schedule(50); });
|
||||
const keys = Array.isArray(key) ? key : [key];
|
||||
const offStore = target.subscribe((s, prev) => {
|
||||
if (keys.some((k) => s[k] !== prev[k])) { edited = true; schedule(); }
|
||||
if (keys.some((k) => s[k] !== prev[k])) { revision++; edited = true; schedule(); }
|
||||
// closing the overlay sends a pending edit at once (review fix): the player's next click — 准备就绪 in the solo
|
||||
// briefing, 开始模拟 in the room — must not overtake the debounced message (the match locks its loadout when
|
||||
// INFO_CHECK ends, so a late edit would silently only apply to the next match). Same socket ⇒ ordered.
|
||||
|
||||
@@ -69,14 +69,38 @@ export function DiyBannedLine({ model, class: cls = 'brief-banned__diy' }) {
|
||||
${t('{names}的盟约本局全部禁用,不会出现在你的商店', { names: list.map((x) => dn(x.name)) })}</p>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The box beside a bond's tooltip: its banned members (the badge's count), each with avatar, name and bonds (the hovered
|
||||
* one highlighted); null when none are banned.
|
||||
* @param {any} bond @param {MatchInfoModel} model @param {Set<string>} banned
|
||||
*/
|
||||
function BondBannedAside(bond, model, banned) {
|
||||
const ids = (Array.isArray(bond.visibleMembers) ? bond.visibleMembers : []).filter((id) => banned.has(id));
|
||||
if (!ids.length) return null;
|
||||
const nameOf = new Map(model.bonds.map((b) => [b.bondId, b.name]));
|
||||
return html`<div class="brief-bantip">
|
||||
<div class="brief-bantip__h">${t('本局禁用干员')}</div>
|
||||
${ids.map((id) => {
|
||||
const c = data.lookup('chess', id);
|
||||
const bonds = (Array.isArray(c?.bonds) ? c.bonds : []).filter((b) => nameOf.has(b));
|
||||
return html`<div key=${id} class="brief-bantip__op" data-chess=${id}>
|
||||
<${UnitThumb} kind="chess" id=${id} size="xs" showTier=${false} />
|
||||
<div class="brief-bantip__txt"><b>${c?.name || id}</b>
|
||||
<span>${bonds.map((b, i) => html`${i ? ' · ' : ''}<span class=${cx(b === bond.bondId && 'is-this')}>${nameOf.get(b)}</span>`)}</span></div>
|
||||
</div>`;
|
||||
})}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* One row of bond discs (核心盟约 / 附加盟约): a greyed disc (✕) for a bond in the drawn set D or one the mode never
|
||||
* activates, the red badge with its banned members, a red ring on an enabled bond that lost members; the tooltip says
|
||||
* which (briefingBondTip).
|
||||
* which (briefingBondTip). A bond with banned members lists them in a second box beside the tooltip (BondBannedAside).
|
||||
* @param {{ title: string, micro: string, bonds: any[], model: MatchInfoModel }} props
|
||||
*/
|
||||
export function MatchBondRow({ title, micro, bonds, model }) {
|
||||
const m = data.get('assets');
|
||||
const banned = new Set(model.banned);
|
||||
return html`<div class="brief-bonds">
|
||||
<h3 class="brief-h"><span>${title}</span><${MicroLabel}>${micro}</${MicroLabel}></h3>
|
||||
<div class="brief-bonds__row">
|
||||
@@ -84,7 +108,8 @@ export function MatchBondRow({ title, micro, bonds, model }) {
|
||||
const state = model.stateOf(b.bondId);
|
||||
const off = !!state;
|
||||
const bannedN = model.perBond.get(b.bondId) || 0;
|
||||
return html`<${Tooltip} key=${b.bondId} text=${briefingBondTip(b.name, state, bannedN)}>
|
||||
return html`<${Tooltip} key=${b.bondId} text=${briefingBondTip(b.name, state, bannedN)}
|
||||
aside=${bannedN > 0 ? BondBannedAside(b, model, banned) : null}>
|
||||
<div class=${cx('brief-bond', off && 'is-off', state === 'drawn' && 'is-incomplete', !off && bannedN > 0 && 'is-partial')} data-bond=${b.bondId}>
|
||||
<${BondDisc} name=${b.name} icon=${bondIconUrl(m, b.bondId)} active=${!off} disabled=${off} tier=${off ? 0 : (b.thresholds?.length || 1)}
|
||||
maxTier=${Math.max(1, b.thresholds?.length || 1)} size="md" />
|
||||
|
||||
@@ -263,6 +263,8 @@ export function SettingsModal({ open, onClose }) {
|
||||
onInput=${(v) => { updateSettings({ sfx: v }); if (!tested) { setTested(true); setTimeout(() => setTested(false), 400); audio.sfx('click'); } }} />
|
||||
<${Toggle} label=${t('静音')} micro="MUTE" value=${s.muted} onChange=${(v) => updateSettings({ muted: v })} />
|
||||
<${Toggle} label=${t('显示伤害数字')} micro="DAMAGE NUMBERS" value=${s.damageNumbers} onChange=${(v) => updateSettings({ damageNumbers: v })} />
|
||||
<${Toggle} label=${t('官方伤害数字')} micro="OFFICIAL NUMBERS" value=${s.officialDamageNumbers} onChange=${(v) => updateSettings({ officialDamageNumbers: v })} />
|
||||
<p class="set-hint">${t('只显示达到官方暴击条件的红色数字。关闭时仍显示每一次命中。')}</p>
|
||||
<div class="set-row">
|
||||
<span class="set-row__label">${t('画面质量')}<${MicroLabel}>QUALITY<//></span>
|
||||
<div class="set-seg" role="radiogroup">
|
||||
|
||||
@@ -10,7 +10,9 @@
|
||||
// pointer): there is no ⓘ corner (the official cards have none; user playtest #6 item 10 — on phones its invisible
|
||||
// 44 px touch area covered the card's bottom-right quarter, half the strip, and a second tap there only re-opened the
|
||||
// detail); a card that cannot be bought right now (the bar is not editable: ready, round start) opens its detail. The
|
||||
// D / R / F keys stay one-press shortcuts. There is no drag-to-sell zone: selling is the 出售 +N button of a tapped
|
||||
// D / R / F keys stay one-press shortcuts — a 刷新 whose request is still on the wire dims the button and swallows
|
||||
// further taps and the R key until the reply lands (GitHub #501; the single-flight lives in ui/gameActions.js
|
||||
// onRefreshBusy, ui/shopBar.js only draws it). There is no drag-to-sell zone: selling is the 出售 +N button of a tapped
|
||||
// unit's underframe (ui/underframe.js). After a promotion the operator cards are replaced by
|
||||
// the 晋升奖励 cards (3 free operators, pick 1 — also two taps) until picked or put off (稍后 → rewardOverlay.js pill);
|
||||
// a special refresh (凯瑟琳 定向投放's 3 items, 娜仁图亚, 寻呼模块, 信标, 松果) takes the same place under its own name, an
|
||||
@@ -286,13 +288,13 @@ export function RewardCards({ offer, priv, editable, onPick, onDetail, onLater,
|
||||
|
||||
/**
|
||||
* The bar.
|
||||
* @param {{ priv:any, editable:boolean, collapsed:boolean, onCollapse:(c:boolean)=>void,
|
||||
* @param {{ priv:any, editable:boolean, collapsed:boolean, refreshBusy?:boolean, onCollapse:(c:boolean)=>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, offBonds?: Set<string>|null }} props — offBonds:
|
||||
* the bonds this mode never activates (gameLogic modeOffBonds), struck through on the operator cards
|
||||
*/
|
||||
export function ShopBar({ priv, editable, collapsed, onCollapse, onBuy, onLevel, onRefresh, onFreeze, onDetail, onDetailClose, onRefuse, barRef,
|
||||
export function ShopBar({ priv, editable, collapsed, refreshBusy = false, onCollapse, onBuy, onLevel, onRefresh, onFreeze, onDetail, onDetailClose, onRefuse, barRef,
|
||||
reward = null, onReward, onRewardLater, onArm = null, offBonds = null }) {
|
||||
const shop = priv?.shop || {};
|
||||
const slots = Array.isArray(shop.slots) ? shop.slots : [];
|
||||
@@ -354,10 +356,10 @@ export function ShopBar({ priv, editable, collapsed, onCollapse, onBuy, onLevel,
|
||||
<${Img} src=${uiUrl(data.get('assets'), frozen ? 'shopPanel/frozen_icon2' : 'shopPanel/frozen_icon')} class="toolbtn__img" fallback=${html`<${Icon} name="snow" />`} />
|
||||
<span>${frozen ? t('解冻') : t('冻结')}</span><kbd>${hk.freeze}</kbd>
|
||||
</button>
|
||||
<button type="button" class="toolbtn toolbtn--amber" disabled=${!!refReason} onClick=${onRefresh} title=${refReason || t('刷新商店 · {key}', { key: hk.refresh })}>
|
||||
<button type="button" class="toolbtn toolbtn--amber" disabled=${!!refReason || refreshBusy} onClick=${onRefresh} title=${refReason || (refreshBusy ? t('正在刷新…') : t('刷新商店 · {key}', { key: hk.refresh }))}>
|
||||
<${Img} src=${uiUrl(data.get('assets'), 'shopPanel/refresh_icon')} class="toolbtn__img" fallback=${html`<${Icon} name="refresh" />`} />
|
||||
<span>${t('刷新')}</span>
|
||||
${free > 0 ? html`<span class="toolbtn__free">${t('免费 ×{free}', { free })}</span>` : html`<${HexBadge} value=${shop.refreshPrice ?? 1} tone=${refReason ? 'dark' : 'gold'} size="sm" />`}
|
||||
${free > 0 ? html`<span class="toolbtn__free">${t('免费 ×{free}', { free })}</span>` : html`<${HexBadge} value=${shop.refreshPrice ?? 1} tone=${refReason || refreshBusy ? 'dark' : 'gold'} size="sm" />`}
|
||||
<kbd>${hk.refresh}</kbd>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
Reference in new issue
Block a user