first commmit
This commit is contained in:
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* Project-wide constants. This module is platform-agnostic and MUST NOT import
|
||||
* from `cc` so that it can be unit-tested under Jest.
|
||||
*
|
||||
* All numeric values are defined against the landscape baseline design
|
||||
* resolution 960x540 (16:9). Physical screen adaptation is handled by the
|
||||
* UI layer (see `@ui/FloatingControlLayer`).
|
||||
*/
|
||||
|
||||
/** Landscape design resolution baseline width (px). */
|
||||
export const DESIGN_WIDTH = 960;
|
||||
|
||||
/** Landscape design resolution baseline height (px). */
|
||||
export const DESIGN_HEIGHT = 540;
|
||||
|
||||
/** Target frame rate (locked 30fps per performance requirement 18.1-18.3). */
|
||||
export const TARGET_FPS = 30;
|
||||
|
||||
/** Max first-package size (bytes) per requirement 18.7. */
|
||||
export const MAX_FIRST_PACKAGE_BYTES = 4 * 1024 * 1024;
|
||||
|
||||
/** Max audio bundle size (bytes) per requirement 16.5 / 19.7. */
|
||||
export const MAX_AUDIO_BUNDLE_BYTES = 500 * 1024;
|
||||
|
||||
/** Max runtime memory peak (bytes) per requirement 18.4. */
|
||||
export const MAX_MEMORY_PEAK_BYTES = 200 * 1024 * 1024;
|
||||
|
||||
/**
|
||||
* Player character color states. Red = base (1-hit kill),
|
||||
* Green = 1 crystal buff, Yellow = 2 crystals (faster movement).
|
||||
* Per requirement 5.1-5.6.
|
||||
*/
|
||||
export enum PlayerColorState {
|
||||
Red = 'red',
|
||||
Green = 'green',
|
||||
Yellow = 'yellow',
|
||||
}
|
||||
|
||||
/** Horizontal movement speed (px/s) per color state, per requirement 5.1-5.2. */
|
||||
export const MOVE_SPEED: Record<PlayerColorState, number> = {
|
||||
[PlayerColorState.Red]: 100,
|
||||
[PlayerColorState.Green]: 100,
|
||||
[PlayerColorState.Yellow]: 150,
|
||||
};
|
||||
|
||||
/** Standard vertical jump height (px) per requirement 2.2 (red/green baseline). */
|
||||
export const JUMP_HEIGHT_STANDARD = 250;
|
||||
|
||||
/** Charged jump height (px) per requirement 2.3. */
|
||||
export const JUMP_HEIGHT_CHARGED = 375;
|
||||
|
||||
/** Yellow-state jump height (px) per requirement 2.2. */
|
||||
export const JUMP_HEIGHT_YELLOW = 300;
|
||||
|
||||
/** Crouch delay before actually leaving the ground (ms) per requirement 2.8. */
|
||||
export const JUMP_PREPARE_DELAY_MS = 150;
|
||||
|
||||
/** Long-press threshold to trigger charged jump (ms) per requirement 2.3. */
|
||||
export const JUMP_CHARGE_THRESHOLD_MS = 500;
|
||||
|
||||
/**
|
||||
* Parabolic jump angle tolerance windows (degrees). A joystick direction that
|
||||
* lies within ±ANGLE_TOLERANCE of 45° or 135° triggers a parabolic jump.
|
||||
* Per requirement 2.5 and requirement 20.3 (>=95% recognition rate).
|
||||
*/
|
||||
export const PARABOLIC_ANGLE_RIGHT = 45;
|
||||
export const PARABOLIC_ANGLE_LEFT = 135;
|
||||
export const PARABOLIC_ANGLE_TOLERANCE = 15;
|
||||
|
||||
/** Weapon attack intervals (s). Per requirement 3.4 / 3.6. */
|
||||
export const SHURIKEN_INTERVAL_BASE = 0.3;
|
||||
export const SHURIKEN_INTERVAL_UPGRADED = 0.25;
|
||||
export const SWORD_INTERVAL = 0.5;
|
||||
|
||||
/** Max shuriken burst count when long-pressing attack button (req 3.5). */
|
||||
export const SHURIKEN_BURST_MAX = 3;
|
||||
|
||||
/** Combo-input recognition window (ms) for "jump + attack" per req 4.1. */
|
||||
export const COMBO_INPUT_WINDOW_MS = 100;
|
||||
|
||||
/** Player invincibility frames duration (s) after a knockback per req 10.2. */
|
||||
export const PLAYER_IFRAME_SECONDS = 0.5;
|
||||
|
||||
/**
|
||||
* Performance KPI thresholds used by Logger / BI埋点 layer.
|
||||
* Per requirement 20.1-20.6.
|
||||
*/
|
||||
export const PERF_TOUCH_RESPONSE_MAX_MS = 50;
|
||||
export const PERF_JUMP_STATE_TOGGLE_MAX_MS = 50;
|
||||
export const PERF_COMBO_RECOGNITION_MAX_MS = 100;
|
||||
export const PERF_PARABOLIC_ANGLE_ACCURACY_TARGET = 0.95;
|
||||
export const PERF_AIR_JUMP_BLOCK_RATE_TARGET = 0.99;
|
||||
|
||||
/** Local storage keys (req 17.1-17.5, 19.5). */
|
||||
export const STORAGE_KEY = {
|
||||
LevelUnlock: 'kl_level_unlock',
|
||||
ControlLayout: 'kl_control_layout',
|
||||
AudioVolume: 'kl_audio_volume',
|
||||
TutorialDone: 'kl_tutorial_done',
|
||||
StoryIntroSeen: 'kl_story_intro_seen',
|
||||
} as const;
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "e42a8542-158c-4965-96fd-4b0cb6457b41",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* A lightweight, framework-agnostic pub/sub event bus.
|
||||
*
|
||||
* Used project-wide to decouple:
|
||||
* - UI layer (floating controls) → logic layer (player controller)
|
||||
* - Logic layer (damage system) → UI layer (HUD / feedback)
|
||||
* - Any manager → Logger / BI埋点
|
||||
*
|
||||
* Design notes:
|
||||
* - `emit` is synchronous to avoid a frame of latency for combat events.
|
||||
* - Subscribing the same callback twice is a no-op (idempotent) to avoid
|
||||
* double-fire bugs when views are rebuilt.
|
||||
* - `once` unsubscribes itself after the first invocation.
|
||||
* - Handler errors are caught and forwarded to a user-provided error hook
|
||||
* (defaulting to `console.error`) so that one bad listener cannot break
|
||||
* the rest of the fan-out.
|
||||
*/
|
||||
|
||||
export type EventHandler<TPayload = unknown> = (payload: TPayload) => void;
|
||||
export type ErrorHook = (event: string, err: unknown) => void;
|
||||
|
||||
interface HandlerRecord {
|
||||
fn: EventHandler<any>;
|
||||
once: boolean;
|
||||
}
|
||||
|
||||
export class EventBus {
|
||||
private readonly handlers = new Map<string, HandlerRecord[]>();
|
||||
private errorHook: ErrorHook = (event, err) => {
|
||||
// Fallback error hook; replaced via `setErrorHook` once Logger is
|
||||
// available.
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(`[EventBus] handler for "${event}" threw:`, err);
|
||||
};
|
||||
|
||||
/** Override the default error hook (used by Logger integration). */
|
||||
public setErrorHook(hook: ErrorHook): void {
|
||||
this.errorHook = hook;
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe a handler. Idempotent — the same `fn` cannot be registered
|
||||
* twice for the same event.
|
||||
*/
|
||||
public on<T>(event: string, fn: EventHandler<T>): void {
|
||||
this.register(event, fn as EventHandler<any>, false);
|
||||
}
|
||||
|
||||
/** Subscribe a handler that auto-unsubscribes after one invocation. */
|
||||
public once<T>(event: string, fn: EventHandler<T>): void {
|
||||
this.register(event, fn as EventHandler<any>, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unsubscribe. If `fn` is omitted, all handlers for `event` are cleared.
|
||||
*/
|
||||
public off<T>(event: string, fn?: EventHandler<T>): void {
|
||||
const list = this.handlers.get(event);
|
||||
if (!list) {
|
||||
return;
|
||||
}
|
||||
if (!fn) {
|
||||
this.handlers.delete(event);
|
||||
return;
|
||||
}
|
||||
const filtered = list.filter((r) => r.fn !== fn);
|
||||
if (filtered.length === 0) {
|
||||
this.handlers.delete(event);
|
||||
} else {
|
||||
this.handlers.set(event, filtered);
|
||||
}
|
||||
}
|
||||
|
||||
/** Synchronously dispatch `payload` to every handler registered for `event`. */
|
||||
public emit<T>(event: string, payload?: T): void {
|
||||
const list = this.handlers.get(event);
|
||||
if (!list || list.length === 0) {
|
||||
return;
|
||||
}
|
||||
// Snapshot first: `once` handlers will mutate `list` via `off`.
|
||||
const snapshot = list.slice();
|
||||
for (const record of snapshot) {
|
||||
try {
|
||||
record.fn(payload as unknown);
|
||||
} catch (err) {
|
||||
this.errorHook(event, err);
|
||||
}
|
||||
if (record.once) {
|
||||
this.off(event, record.fn);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Return the number of handlers registered for `event`. */
|
||||
public listenerCount(event: string): number {
|
||||
return this.handlers.get(event)?.length ?? 0;
|
||||
}
|
||||
|
||||
/** Remove every handler of every event (used in unit tests / scene unload). */
|
||||
public clear(): void {
|
||||
this.handlers.clear();
|
||||
}
|
||||
|
||||
private register(event: string, fn: EventHandler<any>, once: boolean): void {
|
||||
const list = this.handlers.get(event) ?? [];
|
||||
if (list.some((r) => r.fn === fn)) {
|
||||
return;
|
||||
}
|
||||
list.push({ fn, once });
|
||||
this.handlers.set(event, list);
|
||||
}
|
||||
}
|
||||
|
||||
/** Shared, process-wide event bus. Tests should create a fresh `new EventBus()`. */
|
||||
export const globalEventBus = new EventBus();
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "459040db-2675-4865-969c-fba50c3a40e1",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* Minimal structured logger + performance-metric emitter.
|
||||
*
|
||||
* Two responsibilities live here to keep the common layer thin:
|
||||
*
|
||||
* 1. **Leveled logging** — wraps `console.*` with a monotonically increasing
|
||||
* severity threshold so we can downgrade chatty modules in production.
|
||||
* 2. **Performance埋点** — records named samples (e.g. touch→response latency)
|
||||
* and computes p50/p95/avg for QA validation of requirement 20.1-20.6.
|
||||
*
|
||||
* Both halves are deliberately kept independent: the `metric()` API can be
|
||||
* routed to a BI endpoint later without touching the logging API.
|
||||
*/
|
||||
|
||||
export enum LogLevel {
|
||||
Debug = 0,
|
||||
Info = 1,
|
||||
Warn = 2,
|
||||
Error = 3,
|
||||
Silent = 4,
|
||||
}
|
||||
|
||||
export type LogSink = (level: LogLevel, module: string, msg: string, ...rest: unknown[]) => void;
|
||||
|
||||
export interface MetricSample {
|
||||
/** Metric name (e.g. 'touch_response_ms'). */
|
||||
name: string;
|
||||
/** Numeric value (ms, count, %, etc.). */
|
||||
value: number;
|
||||
/** Optional tags for slicing (e.g. `{ button: 'jump' }`). */
|
||||
tags?: Record<string, string | number>;
|
||||
/** `realTime` timestamp, ms since app start. */
|
||||
ts: number;
|
||||
}
|
||||
|
||||
export interface MetricAggregate {
|
||||
count: number;
|
||||
min: number;
|
||||
max: number;
|
||||
avg: number;
|
||||
p50: number;
|
||||
p95: number;
|
||||
}
|
||||
|
||||
export class Logger {
|
||||
private threshold: LogLevel = LogLevel.Debug;
|
||||
|
||||
private sink: LogSink = (level, module, msg, ...rest) => {
|
||||
const prefix = `[${LogLevel[level]}][${module}]`;
|
||||
switch (level) {
|
||||
case LogLevel.Error:
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(prefix, msg, ...rest);
|
||||
break;
|
||||
case LogLevel.Warn:
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(prefix, msg, ...rest);
|
||||
break;
|
||||
case LogLevel.Info:
|
||||
// eslint-disable-next-line no-console
|
||||
console.info(prefix, msg, ...rest);
|
||||
break;
|
||||
default:
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(prefix, msg, ...rest);
|
||||
}
|
||||
};
|
||||
|
||||
private readonly metrics = new Map<string, number[]>();
|
||||
private readonly startedTimers = new Map<string, number>();
|
||||
|
||||
/** Control verbosity globally (call once at boot). */
|
||||
public setLevel(level: LogLevel): void {
|
||||
this.threshold = level;
|
||||
}
|
||||
|
||||
/** Redirect logs (used in tests to assert against messages). */
|
||||
public setSink(sink: LogSink): void {
|
||||
this.sink = sink;
|
||||
}
|
||||
|
||||
public debug(mod: string, msg: string, ...rest: unknown[]): void {
|
||||
this.dispatch(LogLevel.Debug, mod, msg, rest);
|
||||
}
|
||||
public info(mod: string, msg: string, ...rest: unknown[]): void {
|
||||
this.dispatch(LogLevel.Info, mod, msg, rest);
|
||||
}
|
||||
public warn(mod: string, msg: string, ...rest: unknown[]): void {
|
||||
this.dispatch(LogLevel.Warn, mod, msg, rest);
|
||||
}
|
||||
public error(mod: string, msg: string, ...rest: unknown[]): void {
|
||||
this.dispatch(LogLevel.Error, mod, msg, rest);
|
||||
}
|
||||
|
||||
// ---------- metric API ----------
|
||||
|
||||
/** Record a single metric sample. */
|
||||
public metric(sample: Omit<MetricSample, 'ts'>): void {
|
||||
const list = this.metrics.get(sample.name) ?? [];
|
||||
list.push(sample.value);
|
||||
this.metrics.set(sample.name, list);
|
||||
}
|
||||
|
||||
/** Start a named stopwatch. */
|
||||
public timerStart(name: string, now: number = Logger.now()): void {
|
||||
this.startedTimers.set(name, now);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop a named stopwatch and record its elapsed time (ms) under `name`.
|
||||
* Returns the elapsed value or `undefined` if the timer was not started.
|
||||
*/
|
||||
public timerEnd(name: string, now: number = Logger.now()): number | undefined {
|
||||
const start = this.startedTimers.get(name);
|
||||
if (start === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
this.startedTimers.delete(name);
|
||||
const elapsed = now - start;
|
||||
this.metric({ name, value: elapsed });
|
||||
return elapsed;
|
||||
}
|
||||
|
||||
/** Compute aggregate stats (used by QA dashboards and test assertions). */
|
||||
public aggregate(name: string): MetricAggregate | undefined {
|
||||
const list = this.metrics.get(name);
|
||||
if (!list || list.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
const sorted = list.slice().sort((a, b) => a - b);
|
||||
const count = sorted.length;
|
||||
const sum = sorted.reduce((s, v) => s + v, 0);
|
||||
const pct = (p: number): number => {
|
||||
// Inclusive nearest-rank definition (matches common QA tools).
|
||||
const rank = Math.min(count - 1, Math.max(0, Math.ceil((p / 100) * count) - 1));
|
||||
return sorted[rank];
|
||||
};
|
||||
return {
|
||||
count,
|
||||
min: sorted[0],
|
||||
max: sorted[count - 1],
|
||||
avg: sum / count,
|
||||
p50: pct(50),
|
||||
p95: pct(95),
|
||||
};
|
||||
}
|
||||
|
||||
/** Clear all recorded metrics (useful between unit tests). */
|
||||
public resetMetrics(): void {
|
||||
this.metrics.clear();
|
||||
this.startedTimers.clear();
|
||||
}
|
||||
|
||||
private dispatch(level: LogLevel, mod: string, msg: string, rest: unknown[]): void {
|
||||
if (level < this.threshold) {
|
||||
return;
|
||||
}
|
||||
this.sink(level, mod, msg, ...rest);
|
||||
}
|
||||
|
||||
private static now(): number {
|
||||
if (typeof performance !== 'undefined' && typeof performance.now === 'function') {
|
||||
return performance.now();
|
||||
}
|
||||
return Date.now();
|
||||
}
|
||||
}
|
||||
|
||||
/** Shared project-wide logger. */
|
||||
export const globalLogger = new Logger();
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "6d7e102a-3d02-4d19-b555-17a7b19c965b",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* Generic object pool used by damage effects, bullets, enemies and VFX
|
||||
* (requirement 18.5). Pure-TS, platform-agnostic, Jest-testable.
|
||||
*
|
||||
* Design notes:
|
||||
* - `factory` creates a brand-new instance when the free list is empty.
|
||||
* - `resetter` is invoked on every released object, letting the caller wipe
|
||||
* transient state (position, timers, listeners) before it goes back to
|
||||
* the pool.
|
||||
* - `maxSize` caps the retained instances; objects released beyond the cap
|
||||
* are dropped (letting the GC collect them) to bound memory usage
|
||||
* (requirement 18.4: memory peak ≤ 200MB).
|
||||
* - Double-release is silently ignored but reported through `onDoubleRelease`
|
||||
* so tests / Logger can assert correctness.
|
||||
*/
|
||||
|
||||
export type ObjectFactory<T> = () => T;
|
||||
export type ObjectResetter<T> = (obj: T) => void;
|
||||
|
||||
export interface ObjectPoolOptions<T> {
|
||||
/** Required creator invoked when the pool is empty. */
|
||||
factory: ObjectFactory<T>;
|
||||
/** Optional cleaner invoked on every `release`. */
|
||||
resetter?: ObjectResetter<T>;
|
||||
/** Max retained objects; excess releases are discarded. Default 128. */
|
||||
maxSize?: number;
|
||||
/** Optional pre-warm count (creates this many objects upfront). Default 0. */
|
||||
preAlloc?: number;
|
||||
/** Optional diagnostic hook. */
|
||||
onDoubleRelease?: (obj: T) => void;
|
||||
}
|
||||
|
||||
export class ObjectPool<T> {
|
||||
private readonly free: T[] = [];
|
||||
private readonly borrowed = new Set<T>();
|
||||
private readonly factory: ObjectFactory<T>;
|
||||
private readonly resetter?: ObjectResetter<T>;
|
||||
private readonly maxSize: number;
|
||||
private readonly onDoubleRelease?: (obj: T) => void;
|
||||
|
||||
// Diagnostics
|
||||
private _acquiredTotal = 0;
|
||||
private _recycledTotal = 0;
|
||||
private _createdTotal = 0;
|
||||
|
||||
constructor(options: ObjectPoolOptions<T>) {
|
||||
this.factory = options.factory;
|
||||
this.resetter = options.resetter;
|
||||
this.maxSize = options.maxSize ?? 128;
|
||||
this.onDoubleRelease = options.onDoubleRelease;
|
||||
|
||||
const preAlloc = options.preAlloc ?? 0;
|
||||
for (let i = 0; i < preAlloc; i++) {
|
||||
const inst = this.factory();
|
||||
this._createdTotal++;
|
||||
this.free.push(inst);
|
||||
}
|
||||
}
|
||||
|
||||
/** Acquire an object from the pool (creates one if empty). */
|
||||
public acquire(): T {
|
||||
this._acquiredTotal++;
|
||||
const inst = this.free.pop();
|
||||
if (inst !== undefined) {
|
||||
this.borrowed.add(inst);
|
||||
return inst;
|
||||
}
|
||||
const created = this.factory();
|
||||
this._createdTotal++;
|
||||
this.borrowed.add(created);
|
||||
return created;
|
||||
}
|
||||
|
||||
/** Release an object back to the pool. Double-releases are ignored. */
|
||||
public release(obj: T): void {
|
||||
if (!this.borrowed.has(obj)) {
|
||||
this.onDoubleRelease?.(obj);
|
||||
return;
|
||||
}
|
||||
this.borrowed.delete(obj);
|
||||
this.resetter?.(obj);
|
||||
if (this.free.length < this.maxSize) {
|
||||
this.free.push(obj);
|
||||
this._recycledTotal++;
|
||||
}
|
||||
// else: drop the object so the GC can reclaim it.
|
||||
}
|
||||
|
||||
/** Number of objects currently held in the free list. */
|
||||
public get freeCount(): number {
|
||||
return this.free.length;
|
||||
}
|
||||
|
||||
/** Number of objects that are currently out on loan. */
|
||||
public get borrowedCount(): number {
|
||||
return this.borrowed.size;
|
||||
}
|
||||
|
||||
/** Drop everything. Used on scene unload. */
|
||||
public drain(): void {
|
||||
this.free.length = 0;
|
||||
this.borrowed.clear();
|
||||
}
|
||||
|
||||
/** Diagnostic stats (used by Logger / perf BI埋点). */
|
||||
public stats() {
|
||||
return {
|
||||
free: this.freeCount,
|
||||
borrowed: this.borrowedCount,
|
||||
acquired: this._acquiredTotal,
|
||||
recycled: this._recycledTotal,
|
||||
created: this._createdTotal,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "42063546-8ae6-4255-be58-f1d3f180ea42",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { Logger, MetricAggregate } from './Logger';
|
||||
import {
|
||||
PERF_TOUCH_RESPONSE_MAX_MS,
|
||||
PERF_JUMP_STATE_TOGGLE_MAX_MS,
|
||||
PERF_COMBO_RECOGNITION_MAX_MS,
|
||||
PERF_PARABOLIC_ANGLE_ACCURACY_TARGET,
|
||||
PERF_AIR_JUMP_BLOCK_RATE_TARGET,
|
||||
MAX_FIRST_PACKAGE_BYTES,
|
||||
MAX_AUDIO_BUNDLE_BYTES,
|
||||
MAX_MEMORY_PEAK_BYTES,
|
||||
} from './Constants';
|
||||
|
||||
/**
|
||||
* Performance monitor (task 10.2, req 18 & 20).
|
||||
*
|
||||
* Aggregates all the KPI samples recorded through `Logger.metric(...)` and
|
||||
* reports pass/fail against every threshold listed in the requirements doc.
|
||||
* CI can run `collectReport()` and assert that `allPassing === true`.
|
||||
*/
|
||||
|
||||
export interface IPerfThreshold {
|
||||
metric: string;
|
||||
/** Budget target (max for latency, min for rates). */
|
||||
limit: number;
|
||||
comparator: '<=' | '>=';
|
||||
requirementId: string;
|
||||
}
|
||||
|
||||
export const CORE_PERF_THRESHOLDS: ReadonlyArray<IPerfThreshold> = [
|
||||
{ metric: 'input/touchStart', limit: PERF_TOUCH_RESPONSE_MAX_MS, comparator: '<=', requirementId: 'req 20.1' },
|
||||
{ metric: 'jump/state_toggle_ms', limit: PERF_JUMP_STATE_TOGGLE_MAX_MS, comparator: '<=', requirementId: 'req 20.2' },
|
||||
{ metric: 'input/parabolic_accuracy', limit: PERF_PARABOLIC_ANGLE_ACCURACY_TARGET, comparator: '>=', requirementId: 'req 20.3' },
|
||||
{ metric: 'input/combo_recognition_ms', limit: PERF_COMBO_RECOGNITION_MAX_MS, comparator: '<=', requirementId: 'req 20.4' },
|
||||
{ metric: 'jump/air_jump_block_rate', limit: PERF_AIR_JUMP_BLOCK_RATE_TARGET, comparator: '>=', requirementId: 'req 20.5' },
|
||||
];
|
||||
|
||||
export interface IPerfCheckResult {
|
||||
threshold: IPerfThreshold;
|
||||
aggregate?: MetricAggregate;
|
||||
passing: boolean;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export interface IPerfReport {
|
||||
allPassing: boolean;
|
||||
checks: IPerfCheckResult[];
|
||||
/** Optional build/runtime sizes filled in by CI (bytes). */
|
||||
firstPackageBytes?: number;
|
||||
audioBundleBytes?: number;
|
||||
memoryPeakBytes?: number;
|
||||
/** Top-level pass/fail for the size budgets. */
|
||||
sizeBudgetPassing?: boolean;
|
||||
}
|
||||
|
||||
export class PerfMonitor {
|
||||
constructor(
|
||||
private readonly logger: Logger,
|
||||
private readonly thresholds: ReadonlyArray<IPerfThreshold> = CORE_PERF_THRESHOLDS
|
||||
) {}
|
||||
|
||||
public collectReport(buildSizes?: {
|
||||
firstPackageBytes?: number;
|
||||
audioBundleBytes?: number;
|
||||
memoryPeakBytes?: number;
|
||||
}): IPerfReport {
|
||||
const checks: IPerfCheckResult[] = this.thresholds.map((t) => this.check(t));
|
||||
let sizeBudgetPassing: boolean | undefined;
|
||||
if (buildSizes) {
|
||||
sizeBudgetPassing =
|
||||
(buildSizes.firstPackageBytes ?? 0) <= MAX_FIRST_PACKAGE_BYTES &&
|
||||
(buildSizes.audioBundleBytes ?? 0) <= MAX_AUDIO_BUNDLE_BYTES &&
|
||||
(buildSizes.memoryPeakBytes ?? 0) <= MAX_MEMORY_PEAK_BYTES;
|
||||
}
|
||||
const allPassing = checks.every((c) => c.passing) && (sizeBudgetPassing ?? true);
|
||||
return {
|
||||
allPassing,
|
||||
checks,
|
||||
firstPackageBytes: buildSizes?.firstPackageBytes,
|
||||
audioBundleBytes: buildSizes?.audioBundleBytes,
|
||||
memoryPeakBytes: buildSizes?.memoryPeakBytes,
|
||||
sizeBudgetPassing,
|
||||
};
|
||||
}
|
||||
|
||||
private check(t: IPerfThreshold): IPerfCheckResult {
|
||||
const agg = this.logger.aggregate(t.metric);
|
||||
if (!agg) {
|
||||
return {
|
||||
threshold: t,
|
||||
passing: false,
|
||||
reason: `no samples recorded for "${t.metric}"`,
|
||||
};
|
||||
}
|
||||
// For latency, use p95. For rate metrics, use avg.
|
||||
const isRate = t.comparator === '>=';
|
||||
const observed = isRate ? agg.avg : agg.p95;
|
||||
const passing = isRate ? observed >= t.limit : observed <= t.limit;
|
||||
return {
|
||||
threshold: t,
|
||||
aggregate: agg,
|
||||
passing,
|
||||
reason: `${t.metric} ${isRate ? 'avg' : 'p95'}=${observed.toFixed(2)} vs limit ${t.limit} (${t.comparator})`,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "7da5826e-75ae-4fe1-aad7-f9096222ce93",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* Local-storage facade used by:
|
||||
* - Level unlock state (req 17.1)
|
||||
* - Floating control layout (req 17.2)
|
||||
* - BGM / SFX volume (req 17.3, 16.4)
|
||||
* - Tutorial completion flags (req 17.4)
|
||||
* - Story-intro seen flag (req 17.5 / 19.5)
|
||||
*
|
||||
* Rationale for the thin facade:
|
||||
* - The WeChat Mini Game runtime exposes `wx.setStorageSync` while the
|
||||
* in-editor / browser preview exposes `sys.localStorage`. We isolate
|
||||
* both behind a single `IStorageDriver` interface so that switching
|
||||
* platforms is a single line.
|
||||
* - On read, a failure never throws: it returns the provided default value
|
||||
* (req 17.6 — "must not crash if local storage is unreadable").
|
||||
* - All values go through JSON serialisation so that structured objects
|
||||
* round-trip without callers having to remember to stringify.
|
||||
*/
|
||||
|
||||
export interface IStorageDriver {
|
||||
getItem(key: string): string | null;
|
||||
setItem(key: string, value: string): void;
|
||||
removeItem(key: string): void;
|
||||
}
|
||||
|
||||
/** Selects the best available driver at runtime. */
|
||||
function detectDriver(): IStorageDriver {
|
||||
// 1. WeChat Mini Game global
|
||||
const wxGlobal = (globalThis as any).wx;
|
||||
if (wxGlobal && typeof wxGlobal.setStorageSync === 'function') {
|
||||
return {
|
||||
getItem(key) {
|
||||
try {
|
||||
const v = wxGlobal.getStorageSync(key);
|
||||
return v === '' ? null : (v as string);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
setItem(key, value) {
|
||||
try {
|
||||
wxGlobal.setStorageSync(key, value);
|
||||
} catch {
|
||||
// swallow — req 17.6
|
||||
}
|
||||
},
|
||||
removeItem(key) {
|
||||
try {
|
||||
wxGlobal.removeStorageSync(key);
|
||||
} catch {
|
||||
// swallow
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// 2. Browser localStorage
|
||||
if (typeof globalThis !== 'undefined' && (globalThis as any).localStorage) {
|
||||
const ls = (globalThis as any).localStorage as Storage;
|
||||
return {
|
||||
getItem: (k) => ls.getItem(k),
|
||||
setItem: (k, v) => ls.setItem(k, v),
|
||||
removeItem: (k) => ls.removeItem(k),
|
||||
};
|
||||
}
|
||||
|
||||
// 3. In-memory fallback (Jest, Node-only unit tests).
|
||||
const mem = new Map<string, string>();
|
||||
return {
|
||||
getItem: (k) => (mem.has(k) ? (mem.get(k) as string) : null),
|
||||
setItem: (k, v) => {
|
||||
mem.set(k, v);
|
||||
},
|
||||
removeItem: (k) => {
|
||||
mem.delete(k);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export class StorageMgr {
|
||||
private driver: IStorageDriver;
|
||||
|
||||
constructor(driver?: IStorageDriver) {
|
||||
this.driver = driver ?? detectDriver();
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a JSON-serialisable value. Returns `defaultValue` if the key is
|
||||
* missing, unparseable, or the underlying driver throws (req 17.6).
|
||||
*/
|
||||
public get<T>(key: string, defaultValue: T): T {
|
||||
try {
|
||||
const raw = this.driver.getItem(key);
|
||||
if (raw == null) {
|
||||
return defaultValue;
|
||||
}
|
||||
return JSON.parse(raw) as T;
|
||||
} catch {
|
||||
return defaultValue;
|
||||
}
|
||||
}
|
||||
|
||||
/** Write a JSON-serialisable value. Silently ignores driver errors. */
|
||||
public set<T>(key: string, value: T): void {
|
||||
try {
|
||||
this.driver.setItem(key, JSON.stringify(value));
|
||||
} catch {
|
||||
// req 17.6
|
||||
}
|
||||
}
|
||||
|
||||
public remove(key: string): void {
|
||||
this.driver.removeItem(key);
|
||||
}
|
||||
|
||||
/** Swap the driver at runtime. Used in unit tests and platform ports. */
|
||||
public setDriver(driver: IStorageDriver): void {
|
||||
this.driver = driver;
|
||||
}
|
||||
}
|
||||
|
||||
/** Shared project-wide storage manager. */
|
||||
export const globalStorageMgr = new StorageMgr();
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "580d04ad-4749-4b5a-ba2c-5da77a73126a",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* Centralised time manager. Game-logic should read deltas from here instead
|
||||
* of consuming raw `dt` from Cocos Creator components so that a single
|
||||
* `pause()` call freezes gameplay without freezing UI (menu / settlement).
|
||||
*
|
||||
* Used by:
|
||||
* - Pause menu, settings overlay, "公主被带走" cutscene (requirement 14.1).
|
||||
* - Story intro cutscene (requirement 19.x) — UI time keeps ticking while
|
||||
* gameplay time is held at zero.
|
||||
*
|
||||
* Two independent clocks are exposed:
|
||||
* - `gameTime` : respects pause / time-scale, used by AI, physics, weapons.
|
||||
* - `realTime` : ignores pause, used by UI animation, typewriter text, and
|
||||
* Logger timestamps.
|
||||
*/
|
||||
|
||||
export class TimeMgr {
|
||||
private _gameTime = 0;
|
||||
private _realTime = 0;
|
||||
private _timeScale = 1;
|
||||
private _paused = false;
|
||||
|
||||
/** Should be called once per frame (e.g. from a root node's `update`). */
|
||||
public update(rawDt: number): void {
|
||||
this._realTime += rawDt;
|
||||
if (this._paused) {
|
||||
return;
|
||||
}
|
||||
this._gameTime += rawDt * this._timeScale;
|
||||
}
|
||||
|
||||
public pause(): void {
|
||||
this._paused = true;
|
||||
}
|
||||
|
||||
public resume(): void {
|
||||
this._paused = false;
|
||||
}
|
||||
|
||||
public get paused(): boolean {
|
||||
return this._paused;
|
||||
}
|
||||
|
||||
/** 1.0 = normal, 0.5 = slow-mo, 0 = hard freeze. Negative values clamped. */
|
||||
public setTimeScale(scale: number): void {
|
||||
this._timeScale = Math.max(0, scale);
|
||||
}
|
||||
|
||||
public get timeScale(): number {
|
||||
return this._timeScale;
|
||||
}
|
||||
|
||||
public get gameTime(): number {
|
||||
return this._gameTime;
|
||||
}
|
||||
|
||||
public get realTime(): number {
|
||||
return this._realTime;
|
||||
}
|
||||
|
||||
/** Produce the scaled dt to pass into logic `update(dt)` calls. */
|
||||
public scaledDelta(rawDt: number): number {
|
||||
return this._paused ? 0 : rawDt * this._timeScale;
|
||||
}
|
||||
|
||||
/** Reset all clocks (used between scenes and in unit tests). */
|
||||
public reset(): void {
|
||||
this._gameTime = 0;
|
||||
this._realTime = 0;
|
||||
this._timeScale = 1;
|
||||
this._paused = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Shared project-wide time manager. */
|
||||
export const globalTimeMgr = new TimeMgr();
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "cefd7ec2-a0d2-4494-b87d-981dcda9d5b2",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Common (platform-agnostic) utilities. Anything imported from this barrel
|
||||
* file must stay free of `cc` dependencies so that it can be unit-tested
|
||||
* under Jest (see `tests/__mocks__/cc.ts`).
|
||||
*/
|
||||
|
||||
export * from './Constants';
|
||||
export * from './EventBus';
|
||||
export * from './ObjectPool';
|
||||
export * from './TimeMgr';
|
||||
export * from './StorageMgr';
|
||||
export * from './Logger';
|
||||
export * from './PerfMonitor';
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "b4c9b9e1-0b45-41d2-a05d-eda1098969a1",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
Reference in New Issue
Block a user