- #1 lost operator models: the Spine store no longer hands out a skeleton whose unload is in flight (PIXI.Assets.unload is async); eviction runs 1 s after a release and the empty-scene budget only after 3 s - #7 picking: one shared rule (render/pick.js, pixel probe where bodies overlap) for prep/battle clicks, hover, the enemy pen and equipment drops; the drag ghost stands on its drop tile; hover probes capped at one per frame - #2 the top-bar LP drops live as the own battle's enemies enter the blue gate (−N, 联防中), teammates' pending LP in the team panel - #3 temp overflow pieces that arrive after a prep deadline survive through the next prep (tempDue); temp-row warning and ready-button reason - #4 蕾缪安 S3: locks every 0.5 s, then one shell every 0.3 s in lock order (PRTS notes), bombardShell fx - #5 回环射手: real boomerang (out 15, back 3.75 tiles/s, attack only when every boomerang is caught) - #6 visible projectiles, muzzle flashes, slashes, skill bursts and auras at unchanged frame cost - #8 特质 right under the detail card header, with its official type icon - #9 no spinning busy cursors / spinners next to texts; game data warmed before the match - docs, recordings and tests updated (2414 unit tests, all browser suites green)
211 lines
7.8 KiB
JavaScript
211 lines
7.8 KiB
JavaScript
// Minimal observable store for the browser client.
|
||
//
|
||
// One app-wide store holds everything the UI renders from:
|
||
// connection – socket status + latency (fed by net.js via main.js)
|
||
// me – { playerId, name, token } from `welcome`
|
||
// session – { entered } (the player pressed 开始 on the title screen in this tab)
|
||
// room – last `room.state` payload (without `t`) or null
|
||
// match – { public, private, field, result } from the `m.*` pushes; `battle` = the local battle runner's
|
||
// state (client-side combat, public/js/battle/runner.js): { battleId, fieldId, kind, authoritative,
|
||
// watch, done, own, members, loading, speed, paused, leaks } | null — `paused`: the solo pause holds
|
||
// every local clock; `leaks`: { [fieldId]: counted leaks so far } of every normal field simulated
|
||
// locally (the live LP of ui/hud.js liveLp, user playtest #3 item 2). Under client-side combat `field`
|
||
// is published by the runner (the m.field shape of the battle on screen, `local: true`).
|
||
// ticker – recent `m.ticker` lines, emotes – recent `m.emote` events
|
||
// clock – { offset, rtt } server clock correction: serverNow ≈ Date.now() + offset
|
||
// ui – small bits of local UI state shared between screens
|
||
//
|
||
// The store is framework-agnostic (get/set/subscribe); `useStore(selector)` binds it to Preact.
|
||
// Updates are immutable at the top level: `set` shallow-merges a patch object, `patch(key, obj)`
|
||
// shallow-merges into one nested object. Subscribers are notified synchronously.
|
||
|
||
import { useLayoutEffect, useReducer, useRef } from '../vendor/hooks.module.js';
|
||
import { PHASE } from '../../shared/constants.js';
|
||
|
||
/**
|
||
* Create an observable store.
|
||
* @template S
|
||
* @param {S} initial initial state object
|
||
* @returns {{ get: () => S, set: (patch: Partial<S> | ((s: S) => Partial<S>)) => void,
|
||
* patch: (key: keyof S, value: object | ((prev: any) => object)) => void,
|
||
* subscribe: (fn: (s: S, prev: S) => void) => () => void, reset: () => void }}
|
||
*/
|
||
export function createStore(initial) {
|
||
let state = { ...initial };
|
||
const listeners = new Set();
|
||
|
||
const notify = (prev) => {
|
||
for (const fn of [...listeners]) {
|
||
try { fn(state, prev); } catch (err) { console.error('[store] listener failed', err); }
|
||
}
|
||
};
|
||
|
||
return {
|
||
get: () => state,
|
||
set(patch) {
|
||
const p = typeof patch === 'function' ? patch(state) : patch;
|
||
if (!p || typeof p !== 'object') return;
|
||
const prev = state;
|
||
state = { ...state, ...p };
|
||
notify(prev);
|
||
},
|
||
patch(key, value) {
|
||
const cur = state[key];
|
||
const p = typeof value === 'function' ? value(cur) : value;
|
||
if (!p || typeof p !== 'object') return;
|
||
const prev = state;
|
||
state = { ...state, [key]: { ...(cur && typeof cur === 'object' ? cur : {}), ...p } };
|
||
notify(prev);
|
||
},
|
||
subscribe(fn) {
|
||
listeners.add(fn);
|
||
return () => listeners.delete(fn);
|
||
},
|
||
reset() {
|
||
const prev = state;
|
||
state = { ...initial };
|
||
notify(prev);
|
||
},
|
||
};
|
||
}
|
||
|
||
/** Fresh (empty) match slice. */
|
||
export const emptyMatch = () => ({ public: null, private: null, field: null, result: null, battle: null });
|
||
|
||
/** Initial app state (exported for tests and resets). */
|
||
export const initialState = Object.freeze({
|
||
connection: { status: 'idle', ping: null, attempt: 0, retryAt: 0, lastError: null, everOnline: false },
|
||
me: { playerId: null, name: '', token: null },
|
||
session: { entered: false },
|
||
room: null,
|
||
match: emptyMatch(),
|
||
ticker: [],
|
||
emotes: [],
|
||
clock: { offset: 0, rtt: null, synced: false },
|
||
ui: { pendingJoin: null, restoring: false },
|
||
});
|
||
|
||
/** The app-wide store singleton. */
|
||
export const store = createStore(initialState);
|
||
|
||
/**
|
||
* Which screen the router shows for a given app state:
|
||
* not entered → title; m.public.phase ≠ LOBBY or room.inMatch → game; in a room → room; else lobby.
|
||
* @param {any} s store state
|
||
* @returns {'title'|'lobby'|'room'|'game'}
|
||
*/
|
||
export function selectRoute(s) {
|
||
if (!s?.session?.entered) return 'title';
|
||
const phase = s.match?.public?.phase;
|
||
if (phase && phase !== PHASE.LOBBY) return 'game';
|
||
if (s.room?.inMatch) return 'game'; // match starting: m.public is on its way
|
||
if (s.room) return 'room';
|
||
return 'lobby';
|
||
}
|
||
|
||
/**
|
||
* The notice after a `welcome` that starts a brand-new server session (the server restarted, or this session expired
|
||
* on it: a new playerId) while a room or a match was on screen — everything shown is gone. Null when nothing is lost
|
||
* (the first welcome, a resumed session, only the title / lobby on screen).
|
||
* @param {any} prev store state before the welcome
|
||
* @param {string|null|undefined} playerId the welcome's playerId
|
||
* @returns {string|null}
|
||
*/
|
||
export function sessionResetNotice(prev, playerId) {
|
||
const prevId = prev?.me?.playerId;
|
||
if (prevId == null || prevId === playerId) return null;
|
||
if (prev?.match?.public || prev?.room?.inMatch) return '服务器会话已重置,上一局模拟已结束';
|
||
if (prev?.room) return '服务器会话已重置,已返回大厅';
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* Current server time in ms since epoch, corrected by the measured clock offset.
|
||
* @returns {number}
|
||
*/
|
||
export function serverNow() {
|
||
const off = store.get().clock?.offset;
|
||
return Date.now() + (Number.isFinite(off) ? off : 0);
|
||
}
|
||
|
||
/**
|
||
* Shallow equality for plain objects / arrays (use as `useStore(sel, shallowEqual)`).
|
||
* @param {any} a
|
||
* @param {any} b
|
||
* @returns {boolean}
|
||
*/
|
||
export function shallowEqual(a, b) {
|
||
if (Object.is(a, b)) return true;
|
||
if (!a || !b || typeof a !== 'object' || typeof b !== 'object') return false;
|
||
if (Array.isArray(a) !== Array.isArray(b)) return false;
|
||
const ka = Object.keys(a);
|
||
const kb = Object.keys(b);
|
||
if (ka.length !== kb.length) return false;
|
||
for (const k of ka) if (!Object.prototype.hasOwnProperty.call(b, k) || !Object.is(a[k], b[k])) return false;
|
||
return true;
|
||
}
|
||
|
||
// ---- tiny persisted preferences (localStorage, failure-tolerant) --------------------------------
|
||
|
||
/**
|
||
* Read a JSON preference from localStorage.
|
||
* @template T
|
||
* @param {string} key
|
||
* @param {T} fallback
|
||
* @returns {T}
|
||
*/
|
||
export function loadPref(key, fallback) {
|
||
try {
|
||
const raw = globalThis.localStorage?.getItem(`sp.pref.${key}`);
|
||
return raw == null ? fallback : JSON.parse(raw);
|
||
} catch {
|
||
return fallback;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Write a JSON preference to localStorage (silently ignores quota/privacy errors).
|
||
* @param {string} key
|
||
* @param {any} value
|
||
*/
|
||
export function savePref(key, value) {
|
||
try { globalThis.localStorage?.setItem(`sp.pref.${key}`, JSON.stringify(value)); } catch { /* ignore */ }
|
||
}
|
||
|
||
const identity = (s) => s;
|
||
|
||
/**
|
||
* Preact hook: subscribe to a slice of a store. Re-renders only when the selected value changes
|
||
* (by `isEqual`, default `Object.is`).
|
||
* @template S, T
|
||
* @param {(s: S) => T} [selector]
|
||
* @param {(a: T, b: T) => boolean} [isEqual]
|
||
* @param {ReturnType<typeof createStore>} [target] store to read (defaults to the app store)
|
||
* @returns {T}
|
||
*/
|
||
export function useStore(selector = identity, isEqual = Object.is, target = store) {
|
||
const [, force] = useReducer((c) => c + 1, 0);
|
||
const selRef = useRef(selector);
|
||
const eqRef = useRef(isEqual);
|
||
selRef.current = selector;
|
||
eqRef.current = isEqual;
|
||
|
||
let value;
|
||
try { value = selector(target.get()); } catch (err) { console.error('[store] selector failed', err); value = undefined; }
|
||
const valRef = useRef(value);
|
||
valRef.current = value;
|
||
|
||
useLayoutEffect(() => {
|
||
const check = (s) => {
|
||
let next;
|
||
try { next = selRef.current(s); } catch { return; }
|
||
if (!eqRef.current(next, valRef.current)) force();
|
||
};
|
||
const unsub = target.subscribe(check);
|
||
check(target.get()); // catch updates between render and subscription
|
||
return unsub;
|
||
}, [target]);
|
||
|
||
return value;
|
||
}
|