first commmit
This commit is contained in:
@@ -0,0 +1,91 @@
|
||||
import { _decorator, Component, director, screen, view, Settings, settings, sys, game, view as viewMgr } from 'cc';
|
||||
import { DESIGN_WIDTH, DESIGN_HEIGHT, TARGET_FPS } from './common/Constants';
|
||||
import { UIFlowMgr, ISceneEnter } from './ui/UIFlowMgr';
|
||||
|
||||
const { ccclass } = _decorator;
|
||||
|
||||
/**
|
||||
* Game bootstrap component. Attached to the root node of the initial scene
|
||||
* (`assets/scenes/Boot.scene`). Responsible for:
|
||||
*
|
||||
* 1. Locking landscape orientation (requirement - tech-stack constraint).
|
||||
* 2. Forcing the design resolution to 960x540 with the "fit height" policy
|
||||
* so that any wider aspect ratio (18:9 / 19.5:9 / 20:9) simply extends
|
||||
* horizontally without distorting the UI (requirement 1.7 / 18.6).
|
||||
* 3. Locking frame rate to 30 fps (requirement 18.1-18.3).
|
||||
* 4. Loading the first gameplay-facing scene (story intro if not seen, else
|
||||
* main menu). This is the very first place we branch on the local storage
|
||||
* flag defined in `STORAGE_KEY.StoryIntroSeen` (requirement 19.5).
|
||||
*
|
||||
* NOTE: Do NOT put any heavy logic here; this component runs on the very
|
||||
* first frame and must stay cheap.
|
||||
*/
|
||||
@ccclass('GameBoot')
|
||||
export class GameBoot extends Component {
|
||||
protected onLoad(): void {
|
||||
this.lockOrientationLandscape();
|
||||
this.applyDesignResolution();
|
||||
this.lockFrameRate();
|
||||
}
|
||||
|
||||
protected start(): void {
|
||||
// Drive the very first scene transition through UIFlowMgr so that
|
||||
// story-intro / main-menu selection lives in one place (req 19.5).
|
||||
const flow = new UIFlowMgr(undefined, {
|
||||
onSceneEnter: (ev: ISceneEnter) => this.jumpToScene(ev),
|
||||
});
|
||||
flow.onBoot();
|
||||
}
|
||||
|
||||
/**
|
||||
* Translate the abstract UIFlow event into a concrete Cocos scene load.
|
||||
* Boot scene itself never needs persisting; once we jump away the node
|
||||
* is discarded naturally.
|
||||
*/
|
||||
private jumpToScene(ev: ISceneEnter): void {
|
||||
const map: Record<string, string> = {
|
||||
story_intro: 'StoryIntro',
|
||||
main_menu: 'MainMenu',
|
||||
};
|
||||
const scene = map[ev.scene];
|
||||
if (scene) {
|
||||
director.loadScene(scene);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lock the device to landscape. On WeChat Mini Game this is declared in
|
||||
* `game.json` (deviceOrientation: "landscape"); on web/simulator we call
|
||||
* the `screen.orientation` API when available.
|
||||
*/
|
||||
private lockOrientationLandscape(): void {
|
||||
// `screen.orientation` is not declared as writable in every engine
|
||||
// build, so we access it guardedly.
|
||||
try {
|
||||
// @ts-ignore - engine API surface differs across platforms.
|
||||
if (typeof screen.orientation === 'number') {
|
||||
// macro.ORIENTATION_LANDSCAPE = 3 in Cocos Creator 3.8.
|
||||
// @ts-ignore - write is legal on runtime builds.
|
||||
screen.orientation = 3;
|
||||
}
|
||||
} catch (e) {
|
||||
// Silently ignore: editor preview already enforces orientation
|
||||
// through `profiles/v2/project.json`.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply the 960x540 landscape design resolution. Any physical screen
|
||||
* whose aspect ratio is >= 16:9 will simply show more horizontal world;
|
||||
* narrower screens (unlikely on landscape) will letterbox.
|
||||
*/
|
||||
private applyDesignResolution(): void {
|
||||
const v = view;
|
||||
v.setDesignResolutionSize(DESIGN_WIDTH, DESIGN_HEIGHT, 4 /* FIT_HEIGHT */);
|
||||
}
|
||||
|
||||
/** Clamp the engine game loop to 30 fps. */
|
||||
private lockFrameRate(): void {
|
||||
game.frameRate = TARGET_FPS;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "319b4227-1397-4aa8-882e-e69a74e4bf7d",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "1.2.0",
|
||||
"importer": "directory",
|
||||
"imported": true,
|
||||
"uuid": "3ea8f4b3-59f2-48dd-aff8-38bf7ec19f1e",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
@@ -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": {}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "1.2.0",
|
||||
"importer": "directory",
|
||||
"imported": true,
|
||||
"uuid": "8d3f10f6-9875-461e-9d0f-1965bdc2f91a",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
import {
|
||||
IChapter1ConfigBundle,
|
||||
IEnemyConfig,
|
||||
IItemConfig,
|
||||
IWeaponConfig,
|
||||
ILevelConfig,
|
||||
IBossConfig,
|
||||
IStorySceneConfig,
|
||||
EnemyType,
|
||||
ItemType,
|
||||
WeaponType,
|
||||
ONLY_DIFFICULTY,
|
||||
} from './Interfaces';
|
||||
|
||||
/**
|
||||
* Abstraction over how JSON is actually fetched. In Cocos Creator 3.8 this
|
||||
* will be backed by `resources.load('configs/enemies', JsonAsset, cb)`. In
|
||||
* Jest we inject an in-memory `MapLoader` so tests stay fast and offline.
|
||||
*/
|
||||
export interface IJsonLoader {
|
||||
load<T>(path: string): Promise<T>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal in-memory loader, fed with a plain key→JSON map. Used by unit
|
||||
* tests and by any non-Cocos runtime (e.g. a future web leaderboard tool).
|
||||
*/
|
||||
export class MapJsonLoader implements IJsonLoader {
|
||||
constructor(private readonly map: Record<string, unknown>) {}
|
||||
public async load<T>(path: string): Promise<T> {
|
||||
if (!(path in this.map)) {
|
||||
throw new Error(`MapJsonLoader: missing path "${path}"`);
|
||||
}
|
||||
return this.map[path] as T;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads the chapter-1 config bundle and validates it against the interface
|
||||
* contract. Any missing required field, unknown enum value, or any reference
|
||||
* to a removed difficulty mode causes `load()` to reject with a descriptive
|
||||
* error (requirement 13.6 — no casual/普通 mode may ever load).
|
||||
*/
|
||||
export class ConfigMgr {
|
||||
private _bundle: IChapter1ConfigBundle | undefined;
|
||||
|
||||
constructor(private readonly loader: IJsonLoader) {}
|
||||
|
||||
public get bundle(): IChapter1ConfigBundle {
|
||||
if (!this._bundle) {
|
||||
throw new Error('ConfigMgr: load() must be awaited before accessing bundle');
|
||||
}
|
||||
return this._bundle;
|
||||
}
|
||||
|
||||
public async load(): Promise<IChapter1ConfigBundle> {
|
||||
const [enemies, items, weapons, levels, bosses, stories] = await Promise.all([
|
||||
this.loader.load<IEnemyConfig[]>('configs/enemies'),
|
||||
this.loader.load<IItemConfig[]>('configs/items'),
|
||||
this.loader.load<IWeaponConfig[]>('configs/weapons'),
|
||||
this.loader.load<ILevelConfig[]>('configs/levels'),
|
||||
this.loader.load<IBossConfig[]>('configs/bosses'),
|
||||
this.loader.load<IStorySceneConfig[]>('configs/stories'),
|
||||
]);
|
||||
const bundle: IChapter1ConfigBundle = { enemies, items, weapons, levels, bosses, stories };
|
||||
this.validate(bundle);
|
||||
this._bundle = bundle;
|
||||
return bundle;
|
||||
}
|
||||
|
||||
/** Look up an enemy config, throwing if it's missing. */
|
||||
public enemy(id: EnemyType): IEnemyConfig {
|
||||
const e = this.bundle.enemies.find((x) => x.id === id);
|
||||
if (!e) throw new Error(`ConfigMgr: enemy "${id}" not found`);
|
||||
return e;
|
||||
}
|
||||
|
||||
/** Look up an item config, throwing if it's missing. */
|
||||
public item(id: ItemType): IItemConfig {
|
||||
const it = this.bundle.items.find((x) => x.id === id);
|
||||
if (!it) throw new Error(`ConfigMgr: item "${id}" not found`);
|
||||
return it;
|
||||
}
|
||||
|
||||
/** Look up a weapon config, throwing if it's missing. */
|
||||
public weapon(id: WeaponType): IWeaponConfig {
|
||||
const w = this.bundle.weapons.find((x) => x.id === id);
|
||||
if (!w) throw new Error(`ConfigMgr: weapon "${id}" not found`);
|
||||
return w;
|
||||
}
|
||||
|
||||
/** Look up a level config, throwing if it's missing. */
|
||||
public level(id: string): ILevelConfig {
|
||||
const lv = this.bundle.levels.find((x) => x.id === id);
|
||||
if (!lv) throw new Error(`ConfigMgr: level "${id}" not found`);
|
||||
return lv;
|
||||
}
|
||||
|
||||
/** Look up a boss config, throwing if it's missing. */
|
||||
public boss(id: string): IBossConfig {
|
||||
const b = this.bundle.bosses.find((x) => x.id === id);
|
||||
if (!b) throw new Error(`ConfigMgr: boss "${id}" not found`);
|
||||
return b;
|
||||
}
|
||||
|
||||
/** Look up a story scene, throwing if it's missing. */
|
||||
public story(id: string): IStorySceneConfig {
|
||||
const s = this.bundle.stories.find((x) => x.id === id);
|
||||
if (!s) throw new Error(`ConfigMgr: story "${id}" not found`);
|
||||
return s;
|
||||
}
|
||||
|
||||
// ---------- validation ----------
|
||||
|
||||
private validate(b: IChapter1ConfigBundle): void {
|
||||
// Guard against the D-4 decision — casual mode must never load.
|
||||
const stringified = JSON.stringify(b).toLowerCase();
|
||||
if (stringified.includes('"casual"') || stringified.includes('"normal_mode"')) {
|
||||
throw new Error(
|
||||
`ConfigMgr: detected forbidden casual/normal_mode token — only difficulty "${ONLY_DIFFICULTY}" is permitted`
|
||||
);
|
||||
}
|
||||
this.validateEnemies(b.enemies);
|
||||
this.validateItems(b.items);
|
||||
this.validateWeapons(b.weapons);
|
||||
this.validateLevels(b);
|
||||
this.validateBosses(b.bosses);
|
||||
this.validateStories(b.stories);
|
||||
}
|
||||
|
||||
private validateEnemies(list: IEnemyConfig[]): void {
|
||||
if (list.length === 0) throw new Error('ConfigMgr: enemies list is empty');
|
||||
const required: Array<keyof IEnemyConfig> = ['id', 'displayName', 'size', 'moveSpeed', 'attackIntervalSec', 'attacks', 'hp'];
|
||||
for (const e of list) {
|
||||
for (const key of required) {
|
||||
if (e[key] === undefined || e[key] === null) {
|
||||
throw new Error(`ConfigMgr: enemy "${e.id ?? '?'}" missing field "${String(key)}"`);
|
||||
}
|
||||
}
|
||||
if (!Object.values(EnemyType).includes(e.id as EnemyType)) {
|
||||
throw new Error(`ConfigMgr: enemy id "${e.id}" is not a known EnemyType`);
|
||||
}
|
||||
if (e.attacks.length === 0) throw new Error(`ConfigMgr: enemy "${e.id}" must declare at least one attack`);
|
||||
}
|
||||
}
|
||||
|
||||
private validateItems(list: IItemConfig[]): void {
|
||||
if (list.length === 0) throw new Error('ConfigMgr: items list is empty');
|
||||
for (const it of list) {
|
||||
if (!it.id || !it.displayName || !it.icon || typeof it.lifetimeSec !== 'number') {
|
||||
throw new Error(`ConfigMgr: item "${it.id ?? '?'}" has missing required fields`);
|
||||
}
|
||||
if (!Object.values(ItemType).includes(it.id as ItemType)) {
|
||||
throw new Error(`ConfigMgr: item id "${it.id}" is not a known ItemType`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private validateWeapons(list: IWeaponConfig[]): void {
|
||||
if (list.length === 0) throw new Error('ConfigMgr: weapons list is empty');
|
||||
for (const w of list) {
|
||||
if (!w.id || !w.displayName || typeof w.baseIntervalSec !== 'number' || typeof w.damage !== 'number') {
|
||||
throw new Error(`ConfigMgr: weapon "${w.id ?? '?'}" has missing required fields`);
|
||||
}
|
||||
if (!Object.values(WeaponType).includes(w.id as WeaponType)) {
|
||||
throw new Error(`ConfigMgr: weapon id "${w.id}" is not a known WeaponType`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private validateLevels(b: IChapter1ConfigBundle): void {
|
||||
if (b.levels.length === 0) throw new Error('ConfigMgr: levels list is empty');
|
||||
const enemyIds = new Set(b.enemies.map((e) => e.id));
|
||||
const bossIds = new Set(b.bosses.map((x) => x.id));
|
||||
for (const lv of b.levels) {
|
||||
if (!lv.id || !lv.displayName || !lv.sceneTheme || !lv.scrollDirection || !lv.objective) {
|
||||
throw new Error(`ConfigMgr: level "${lv.id ?? '?'}" has missing required fields`);
|
||||
}
|
||||
if (lv.timeLimitSec <= 0) {
|
||||
throw new Error(`ConfigMgr: level "${lv.id}" must have positive timeLimitSec`);
|
||||
}
|
||||
if (lv.objective.kind === 'kill_count') {
|
||||
if (!lv.objective.enemy || !lv.objective.count) {
|
||||
throw new Error(`ConfigMgr: level "${lv.id}" kill_count objective missing enemy/count`);
|
||||
}
|
||||
if (!enemyIds.has(lv.objective.enemy)) {
|
||||
throw new Error(`ConfigMgr: level "${lv.id}" references unknown enemy "${lv.objective.enemy}"`);
|
||||
}
|
||||
}
|
||||
if (lv.objective.kind === 'defeat_boss') {
|
||||
if (!lv.objective.bossId || !bossIds.has(lv.objective.bossId)) {
|
||||
throw new Error(`ConfigMgr: level "${lv.id}" references unknown boss "${lv.objective.bossId}"`);
|
||||
}
|
||||
}
|
||||
for (const sp of lv.enemySpawns) {
|
||||
if (!enemyIds.has(sp.type)) {
|
||||
throw new Error(`ConfigMgr: level "${lv.id}" spawn references unknown enemy "${sp.type}"`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private validateBosses(list: IBossConfig[]): void {
|
||||
if (list.length === 0) throw new Error('ConfigMgr: bosses list is empty');
|
||||
for (const bo of list) {
|
||||
if (!bo.id || !bo.displayName || typeof bo.hp !== 'number' || !Array.isArray(bo.phases)) {
|
||||
throw new Error(`ConfigMgr: boss "${bo.id ?? '?'}" has missing required fields`);
|
||||
}
|
||||
if (bo.phases.length === 0) {
|
||||
throw new Error(`ConfigMgr: boss "${bo.id}" must have at least one phase`);
|
||||
}
|
||||
let prev = Number.POSITIVE_INFINITY;
|
||||
for (const p of bo.phases) {
|
||||
if (p.hpThreshold > prev) {
|
||||
throw new Error(`ConfigMgr: boss "${bo.id}" phases must be ordered by descending hpThreshold`);
|
||||
}
|
||||
prev = p.hpThreshold;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private validateStories(list: IStorySceneConfig[]): void {
|
||||
if (list.length === 0) throw new Error('ConfigMgr: stories list is empty');
|
||||
for (const s of list) {
|
||||
if (!s.id || !s.bgm || !Array.isArray(s.pages) || s.pages.length < 3) {
|
||||
throw new Error(`ConfigMgr: story "${s.id ?? '?'}" must include id, bgm, and ≥3 pages (req 19.2)`);
|
||||
}
|
||||
if (s.maxDurationSec > 30) {
|
||||
throw new Error(`ConfigMgr: story "${s.id}" maxDurationSec exceeds 30s budget (req 19.1)`);
|
||||
}
|
||||
const indices = s.pages.map((p) => p.index).sort((a, b) => a - b);
|
||||
for (let i = 0; i < indices.length; i++) {
|
||||
if (indices[i] !== i + 1) {
|
||||
throw new Error(`ConfigMgr: story "${s.id}" page indices must be contiguous starting from 1`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "9877a4f9-e13f-412f-a572-dfe501faba39",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
/**
|
||||
* Data-driven configuration interfaces for《影之传说》MVP.
|
||||
*
|
||||
* Every numeric default in here traces directly back to a requirement in
|
||||
* `.codebuddy/plan/kage_legend_mvp/requirements.md`. When you change a field,
|
||||
* keep the inline `req` comment in sync so QA can rebuild the traceability
|
||||
* matrix.
|
||||
*
|
||||
* NOTE: This module is platform-agnostic and MUST NOT depend on `cc`.
|
||||
*/
|
||||
|
||||
import { PlayerColorState } from '../common/Constants';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Enemies
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Enum covers all enemy types required by MVP Chapter 1 (req 6.1-6.7). */
|
||||
export enum EnemyType {
|
||||
/** Green ninja — shuriken + sword, 2s interval (req 6.1). */
|
||||
QingRen = 'qing_ren',
|
||||
/** Red ninja — 120px/s + smoke bomb, proactive intercept jump (req 6.2-6.3). */
|
||||
ChiRen = 'chi_ren',
|
||||
/** Black ninja — drops magic flute scroll on castle stages (req 6.5). */
|
||||
HeiRen = 'hei_ren',
|
||||
/** Monster priest — straight-line fireball, 3.0s interval (req 6.6). */
|
||||
YaoFang = 'yao_fang',
|
||||
}
|
||||
|
||||
/** Allowed damage types (req 3.7, 3.8, 10.4-10.5). */
|
||||
export type AttackType = 'shuriken' | 'sword' | 'fireball' | 'smoke_bomb';
|
||||
|
||||
export interface IEnemyConfig {
|
||||
id: EnemyType;
|
||||
displayName: string;
|
||||
/** Pixel sprite size (width x height). */
|
||||
size: { w: number; h: number };
|
||||
/** Horizontal movement speed (px/s). 0 means stationary. */
|
||||
moveSpeed: number;
|
||||
/** Attack interval in seconds. */
|
||||
attackIntervalSec: number;
|
||||
/** Possible attack types. */
|
||||
attacks: AttackType[];
|
||||
/** Hit points (1 means dies to any successful hit). */
|
||||
hp: number;
|
||||
/** How many enemies of this type must be killed for chapter objective (optional). */
|
||||
killObjective?: number;
|
||||
/** Drop rules specific to this enemy type (see `IItemDropRule`). */
|
||||
drops?: IItemDropRule[];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Items
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Item IDs used by MVP Chapter 1 (req 7.1-7.6, 5.1-5.6). */
|
||||
export enum ItemType {
|
||||
/** 水晶玉 — auto-upgrades red → green → yellow (req 5.1-5.2). */
|
||||
CrystalJade = 'crystal_jade',
|
||||
/** 点丸 — +50% attack power, 30s (req 7.3). */
|
||||
DianWan = 'dian_wan',
|
||||
/** 术丸 — +30% move speed, 20s (req 7.3). */
|
||||
ShuWan = 'shu_wan',
|
||||
/** 魔笛 — screen-wipe one-shot kill (req 7.4). */
|
||||
MoDi = 'mo_di',
|
||||
/** 增丸 — +1 permanent life (req 7.5). */
|
||||
ZengWan = 'zeng_wan',
|
||||
}
|
||||
|
||||
export interface IItemConfig {
|
||||
id: ItemType;
|
||||
displayName: string;
|
||||
/** Icon asset path under `assets/resources/textures/items`. */
|
||||
icon: string;
|
||||
/** Duration in seconds for timed buffs (0 / omitted for instant items). */
|
||||
durationSec?: number;
|
||||
/**
|
||||
* Relative strength of the effect (interpretation is per item type;
|
||||
* see `IItemEffectApplier` in logic layer for the actual math).
|
||||
*/
|
||||
magnitude?: number;
|
||||
/** Lifetime in seconds after spawning on the map (req 7.2). */
|
||||
lifetimeSec: number;
|
||||
}
|
||||
|
||||
/** Drop rule attached to an enemy type. Evaluated on enemy death. */
|
||||
export interface IItemDropRule {
|
||||
item: ItemType;
|
||||
/** Required consecutive kills of this enemy type before drop can happen. */
|
||||
afterKills?: number;
|
||||
/** Probability 0~1 once `afterKills` condition is satisfied. */
|
||||
probability: number;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Weapons
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export enum WeaponType {
|
||||
Shuriken = 'shuriken',
|
||||
NinjaSword = 'ninja_sword',
|
||||
}
|
||||
|
||||
export interface IWeaponConfig {
|
||||
id: WeaponType;
|
||||
displayName: string;
|
||||
/** Base attack interval (s). Yellow state may override for shuriken. */
|
||||
baseIntervalSec: number;
|
||||
/** Upgraded (green/yellow) interval (s). */
|
||||
upgradedIntervalSec?: number;
|
||||
/** Damage applied to standard enemies on hit. */
|
||||
damage: number;
|
||||
/** Supports parry (req 3.7 — only sword). */
|
||||
canParry: boolean;
|
||||
/** When long-pressed, max shots in a burst (req 3.5 — shuriken only). */
|
||||
burstMax?: number;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Levels (Chapter 1 only)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Scroll direction for a level (req 8.1-8.5). */
|
||||
export type ScrollDirection = 'horizontal' | 'horizontal_bi' | 'vertical';
|
||||
|
||||
export interface ILevelObjective {
|
||||
/** e.g. 'kill_yao_fang', 'reach_top', 'boss_defeated'. */
|
||||
kind: 'kill_count' | 'reach_top' | 'defeat_boss';
|
||||
/** For kill_count objectives — which enemy type, and how many. */
|
||||
enemy?: EnemyType;
|
||||
/** Required count for kill_count objectives. */
|
||||
count?: number;
|
||||
/** For defeat_boss objectives — boss ID. */
|
||||
bossId?: string;
|
||||
}
|
||||
|
||||
export interface ILevelConfig {
|
||||
id: string; // e.g. '1-1'
|
||||
chapter: 1 | 2 | 3;
|
||||
displayName: string;
|
||||
/** One of 'forest' / 'cave' / 'castle_wall' / 'demon_castle'. */
|
||||
sceneTheme: string;
|
||||
scrollDirection: ScrollDirection;
|
||||
/** Time limit in seconds (req 8.1-8.4). */
|
||||
timeLimitSec: number;
|
||||
objective: ILevelObjective;
|
||||
/** Scene length in pixels (landscape 16:9 baseline, req 8.8). */
|
||||
levelLengthPx: number;
|
||||
/** BGM bundle key under `assets/resources/audio`. */
|
||||
bgm: string;
|
||||
/** Enemy spawn list evaluated by the LevelMgr. */
|
||||
enemySpawns: Array<{ type: EnemyType; atPx: number; count?: number }>;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Bosses
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface IBossAttackPhase {
|
||||
/** HP threshold at which this phase activates (1.0, 0.66, 0.33, ...). */
|
||||
hpThreshold: number;
|
||||
/** Human-readable mode id (e.g. 'pair_pincer', 'fireball_spread', 'clone_confuse'). */
|
||||
mode: string;
|
||||
/** Interval between actions in this phase (s). */
|
||||
actionIntervalSec: number;
|
||||
}
|
||||
|
||||
export interface IBossConfig {
|
||||
id: string;
|
||||
displayName: string;
|
||||
hp: number;
|
||||
/** A non-zero value means "butterfly appearance required before damage". */
|
||||
butterflyReveal: boolean;
|
||||
/** Ordered from highest hpThreshold to lowest. */
|
||||
phases: IBossAttackPhase[];
|
||||
/** Cutscene trigger (req 8.6 / 14.1): play short "princess taken" at hp<=X. */
|
||||
princessCutsceneAtHpRatio?: number;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Story intro (req 19)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface IStoryPageConfig {
|
||||
/** 1-based page index. */
|
||||
index: number;
|
||||
/** Texture path under `assets/resources/textures/story`. */
|
||||
illustration: string;
|
||||
/** Pixel typewriter text to display. */
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface IStorySceneConfig {
|
||||
id: string; // e.g. 'chapter_1_intro'
|
||||
bgm: string; // e.g. 'bgm_story'
|
||||
/** Max total duration (seconds); UI should auto-advance if exceeded. */
|
||||
maxDurationSec: number;
|
||||
pages: IStoryPageConfig[];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Aggregate configuration table
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface IChapter1ConfigBundle {
|
||||
enemies: IEnemyConfig[];
|
||||
items: IItemConfig[];
|
||||
weapons: IWeaponConfig[];
|
||||
levels: ILevelConfig[];
|
||||
bosses: IBossConfig[];
|
||||
stories: IStorySceneConfig[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Describes which player-state the JSON config applies to. We explicitly
|
||||
* leave **no room** for a 'casual' mode string so that future edits cannot
|
||||
* silently reintroduce the removed difficulty (decision D-4, req 13.1-13.6).
|
||||
*/
|
||||
export type DifficultyProfile = 'hardcore';
|
||||
|
||||
export const ONLY_DIFFICULTY: DifficultyProfile = 'hardcore';
|
||||
|
||||
/** Convenience map for looking up the red/green/yellow state that unlocks each move-speed bucket. */
|
||||
export const COLOR_STATE_MOVE_BUCKET: Record<PlayerColorState, number> = {
|
||||
[PlayerColorState.Red]: 100,
|
||||
[PlayerColorState.Green]: 100,
|
||||
[PlayerColorState.Yellow]: 150,
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "f26544af-4281-4c60-b85e-1cf89aadda32",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Data layer — TypeScript interfaces, data-driven JSON loaders, and schema
|
||||
* validators. See `Interfaces.ts` for the contract and `ConfigMgr.ts` for
|
||||
* the runtime loader + validator.
|
||||
*/
|
||||
|
||||
export * from './Interfaces';
|
||||
export * from './ConfigMgr';
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "d907f3f3-211b-46eb-be38-01e62ae11409",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "1.2.0",
|
||||
"importer": "directory",
|
||||
"imported": true,
|
||||
"uuid": "d6003e00-00cd-4b56-b944-0bc3c53e50dc",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import {
|
||||
SHURIKEN_INTERVAL_BASE,
|
||||
SHURIKEN_INTERVAL_UPGRADED,
|
||||
SWORD_INTERVAL,
|
||||
SHURIKEN_BURST_MAX,
|
||||
COMBO_INPUT_WINDOW_MS,
|
||||
PlayerColorState,
|
||||
} from '../common/Constants';
|
||||
import { WeaponType, AttackType } from '../data/Interfaces';
|
||||
|
||||
/**
|
||||
* Attack controller — models the two mutually-exclusive weapon buttons
|
||||
* (手里剑 / 忍者刀) plus the combo recognition window (req 3.1-3.9, 4.1-4.5).
|
||||
*
|
||||
* Outputs a single `IAttackDispatchEvent` per "fire" that gameplay code
|
||||
* applies through `DamageSystem` (task 6.2). Nothing here talks to `cc`;
|
||||
* it is deterministic on `now` timestamps provided by the caller.
|
||||
*/
|
||||
|
||||
export type ActiveWeapon = 'none' | WeaponType;
|
||||
|
||||
export interface IAttackDispatchEvent {
|
||||
weapon: WeaponType;
|
||||
attackType: AttackType;
|
||||
/** true when this attack was chained with a jump within the combo window (req 4.1). */
|
||||
comboWithJump: boolean;
|
||||
/** 1-based index within a shuriken burst. Always 1 for the sword. */
|
||||
burstIndex: number;
|
||||
/** realtime timestamp of the swing / throw (ms). */
|
||||
ts: number;
|
||||
}
|
||||
|
||||
/** Interface used by AttackController to know whether combo window applies. */
|
||||
export interface IJumpStateProvider {
|
||||
/** Timestamp of the latest jump press (ms). `undefined` means no pending jump. */
|
||||
lastJumpPressTs(): number | undefined;
|
||||
/** Current grounded flag. */
|
||||
isGrounded(): boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Null jump-state provider — used when attack is fired on the ground and no
|
||||
* combo is expected (e.g. unit tests).
|
||||
*/
|
||||
export const NullJumpState: IJumpStateProvider = {
|
||||
lastJumpPressTs: () => undefined,
|
||||
isGrounded: () => true,
|
||||
};
|
||||
|
||||
export class AttackController {
|
||||
private active: ActiveWeapon = 'none';
|
||||
/** Earliest press timestamp among currently-held attack buttons. */
|
||||
private pressedAt = new Map<WeaponType, number>();
|
||||
/** Next-eligible-fire timestamp per weapon. */
|
||||
private readyAt = new Map<WeaponType, number>();
|
||||
/** Current shuriken burst index (1..SHURIKEN_BURST_MAX). */
|
||||
private shurikenBurstIndex = 0;
|
||||
|
||||
constructor(
|
||||
private readonly jumpState: IJumpStateProvider = NullJumpState,
|
||||
private readonly comboWindowMs: number = COMBO_INPUT_WINDOW_MS
|
||||
) {}
|
||||
|
||||
/** Returns the currently active weapon (or `'none'`). */
|
||||
public getActive(): ActiveWeapon {
|
||||
return this.active;
|
||||
}
|
||||
|
||||
public isPressed(weapon: WeaponType): boolean {
|
||||
return this.pressedAt.has(weapon);
|
||||
}
|
||||
|
||||
/**
|
||||
* Press handler. Implements mutual exclusion (req 3.1-3.3): the first
|
||||
* button pressed wins until it is released.
|
||||
*/
|
||||
public press(weapon: WeaponType, nowMs: number): void {
|
||||
if (!this.pressedAt.has(weapon)) {
|
||||
this.pressedAt.set(weapon, nowMs);
|
||||
}
|
||||
if (this.active === 'none') {
|
||||
this.active = weapon;
|
||||
if (weapon === WeaponType.Shuriken) {
|
||||
this.shurikenBurstIndex = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public release(weapon: WeaponType): void {
|
||||
this.pressedAt.delete(weapon);
|
||||
if (this.active === weapon) {
|
||||
// If the other button is still pressed, transfer activation.
|
||||
const remaining = Array.from(this.pressedAt.keys())[0];
|
||||
this.active = remaining ?? 'none';
|
||||
if (weapon === WeaponType.Shuriken) {
|
||||
this.shurikenBurstIndex = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Called every frame. Returns the list of attacks to dispatch **this
|
||||
* frame** (usually 0 or 1; can be >1 only if dt is huge in tests).
|
||||
*/
|
||||
public tick(nowMs: number, colorState: PlayerColorState): IAttackDispatchEvent[] {
|
||||
if (this.active === 'none') return [];
|
||||
const weapon = this.active;
|
||||
const ready = this.readyAt.get(weapon) ?? 0;
|
||||
if (nowMs < ready) return [];
|
||||
return [this.fire(weapon, nowMs, colorState)];
|
||||
}
|
||||
|
||||
/** Cancel everything (scene unload, pause, death). */
|
||||
public reset(): void {
|
||||
this.active = 'none';
|
||||
this.pressedAt.clear();
|
||||
this.readyAt.clear();
|
||||
this.shurikenBurstIndex = 0;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
private fire(weapon: WeaponType, nowMs: number, colorState: PlayerColorState): IAttackDispatchEvent {
|
||||
const interval = this.intervalFor(weapon, colorState);
|
||||
const comboWithJump = this.isComboWithJump(nowMs);
|
||||
|
||||
let burstIndex = 1;
|
||||
if (weapon === WeaponType.Shuriken) {
|
||||
this.shurikenBurstIndex = Math.min(SHURIKEN_BURST_MAX, this.shurikenBurstIndex + 1);
|
||||
burstIndex = this.shurikenBurstIndex;
|
||||
}
|
||||
|
||||
this.readyAt.set(weapon, nowMs + interval * 1000);
|
||||
const attackType: AttackType = weapon === WeaponType.Shuriken ? 'shuriken' : 'sword';
|
||||
return { weapon, attackType, comboWithJump, burstIndex, ts: nowMs };
|
||||
}
|
||||
|
||||
private intervalFor(weapon: WeaponType, color: PlayerColorState): number {
|
||||
if (weapon === WeaponType.NinjaSword) return SWORD_INTERVAL;
|
||||
return color === PlayerColorState.Yellow ? SHURIKEN_INTERVAL_UPGRADED : SHURIKEN_INTERVAL_BASE;
|
||||
}
|
||||
|
||||
private isComboWithJump(nowMs: number): boolean {
|
||||
const lastJump = this.jumpState.lastJumpPressTs();
|
||||
if (lastJump === undefined) return false;
|
||||
const dt = Math.abs(nowMs - lastJump);
|
||||
return dt <= this.comboWindowMs;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "db605e8d-fb86-469f-9dc2-d5430752d693",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import { IBossConfig } from '../data/Interfaces';
|
||||
|
||||
/**
|
||||
* Boss controller — 双幻坊 (req 9.1-9.6 + 8.6-8.7).
|
||||
*
|
||||
* Key beats:
|
||||
* 1. A lone butterfly orbits the boss. Until the butterfly is hit, the boss
|
||||
* is invulnerable (`butterflyRevealed = false`).
|
||||
* 2. First hit on the butterfly → `revealedAt` timestamp stamped, boss
|
||||
* becomes vulnerable.
|
||||
* 3. While revealed, ANY single clean hit kills the boss (req 9.3). We still
|
||||
* honour `phase` transitions at 2/3 and 1/3 HP for visual variety.
|
||||
* 4. When HP ≤ `princessCutsceneAtHpRatio`, we emit a one-shot `princess_taken`
|
||||
* event (≤ 3s, battle keeps running — req 8.6 / 14.1).
|
||||
* 5. On death we emit `boss_killed` + `chapter_end_cutscene` (≤ 2s). Neither
|
||||
* code path produces a "rope-severing rescue" event — decision D-5 / req
|
||||
* 14.5.
|
||||
*/
|
||||
|
||||
export type BossOutcomeEvent =
|
||||
| { kind: 'phase_changed'; phase: string; actionIntervalSec: number }
|
||||
| { kind: 'butterfly_revealed' }
|
||||
| { kind: 'princess_taken_cutscene' }
|
||||
| { kind: 'boss_killed' };
|
||||
|
||||
export class BossController {
|
||||
private hp: number;
|
||||
private butterflyRevealed = false;
|
||||
private phaseIndex = 0;
|
||||
private princessCutscenePlayed = false;
|
||||
private killed = false;
|
||||
public readonly cfg: IBossConfig;
|
||||
|
||||
constructor(cfg: IBossConfig) {
|
||||
this.cfg = cfg;
|
||||
this.hp = cfg.hp;
|
||||
}
|
||||
|
||||
public get currentHp(): number {
|
||||
return this.hp;
|
||||
}
|
||||
public get currentPhase() {
|
||||
return this.cfg.phases[this.phaseIndex];
|
||||
}
|
||||
public get isButterflyRevealed(): boolean {
|
||||
return this.butterflyRevealed;
|
||||
}
|
||||
public get isDead(): boolean {
|
||||
return this.killed;
|
||||
}
|
||||
|
||||
/** Called when a player's attack hits the butterfly (req 9.2). */
|
||||
public onButterflyHit(): BossOutcomeEvent[] {
|
||||
if (this.butterflyRevealed) return [];
|
||||
this.butterflyRevealed = true;
|
||||
return [{ kind: 'butterfly_revealed' }];
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when a player's attack hits the boss body. Before the butterfly
|
||||
* is revealed this call is a no-op; after reveal, the boss dies in one
|
||||
* hit (req 9.3).
|
||||
*/
|
||||
public onBodyHit(): BossOutcomeEvent[] {
|
||||
if (!this.butterflyRevealed) return [];
|
||||
if (this.killed) return [];
|
||||
const out: BossOutcomeEvent[] = [];
|
||||
this.hp = Math.max(0, this.hp - 1);
|
||||
out.push(...this.checkPhaseTransition(), ...this.checkPrincessCutscene());
|
||||
if (this.hp === 0) {
|
||||
this.killed = true;
|
||||
out.push({ kind: 'boss_killed' });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------
|
||||
|
||||
private checkPhaseTransition(): BossOutcomeEvent[] {
|
||||
const hpRatio = this.hp / this.cfg.hp;
|
||||
for (let i = this.phaseIndex + 1; i < this.cfg.phases.length; i++) {
|
||||
if (hpRatio <= this.cfg.phases[i].hpThreshold) {
|
||||
this.phaseIndex = i;
|
||||
return [
|
||||
{
|
||||
kind: 'phase_changed',
|
||||
phase: this.cfg.phases[i].mode,
|
||||
actionIntervalSec: this.cfg.phases[i].actionIntervalSec,
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
private checkPrincessCutscene(): BossOutcomeEvent[] {
|
||||
if (this.princessCutscenePlayed) return [];
|
||||
const threshold = this.cfg.princessCutsceneAtHpRatio;
|
||||
if (threshold === undefined) return [];
|
||||
const hpRatio = this.hp / this.cfg.hp;
|
||||
if (hpRatio <= threshold) {
|
||||
this.princessCutscenePlayed = true;
|
||||
return [{ kind: 'princess_taken_cutscene' }];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "452bb8f0-7423-4ca0-87b4-70bc9dc8d382",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { ILevelConfig, ScrollDirection } from '../data/Interfaces';
|
||||
|
||||
/**
|
||||
* Camera-scrolling model (task 7.1).
|
||||
*
|
||||
* Captures the level's camera/scrolling state without depending on `cc`. The
|
||||
* Cocos view layer maps `CameraScroller.offsetX / offsetY` into a `Camera`
|
||||
* component position every frame.
|
||||
*
|
||||
* Supported scroll modes (req 8.1-8.5, 8.8):
|
||||
* - `horizontal` — scroll never rewinds (森林/魔城).
|
||||
* - `horizontal_bi` — left/right both allowed (洞穴水路).
|
||||
* - `vertical` — scrolls upward as the player climbs (城壁).
|
||||
*
|
||||
* Values are in **landscape design pixels** (960x540 baseline).
|
||||
*/
|
||||
|
||||
export interface ICameraConfig {
|
||||
/** Scroll direction, mirrors `ILevelConfig.scrollDirection`. */
|
||||
direction: ScrollDirection;
|
||||
/** Horizontal level length (for `horizontal` and `horizontal_bi`). */
|
||||
lengthX: number;
|
||||
/** Vertical level length (for `vertical`). */
|
||||
lengthY?: number;
|
||||
/** Camera viewport (design px). */
|
||||
viewportW: number;
|
||||
viewportH: number;
|
||||
}
|
||||
|
||||
/** Four-layer parallax scroller (req 8.8). Speed ratios 1 : 2 : 4 : 4. */
|
||||
export const PARALLAX_RATIOS = [1, 2, 4, 4] as const;
|
||||
export type ParallaxLayer = 'far' | 'mid' | 'near' | 'fx';
|
||||
export const PARALLAX_LAYERS: ParallaxLayer[] = ['far', 'mid', 'near', 'fx'];
|
||||
|
||||
export class CameraScroller {
|
||||
private _offsetX = 0;
|
||||
private _offsetY = 0;
|
||||
private readonly cfg: ICameraConfig;
|
||||
|
||||
constructor(cfg: ICameraConfig) {
|
||||
this.cfg = cfg;
|
||||
}
|
||||
|
||||
public get offsetX(): number {
|
||||
return this._offsetX;
|
||||
}
|
||||
|
||||
public get offsetY(): number {
|
||||
return this._offsetY;
|
||||
}
|
||||
|
||||
/** Camera target follows the player but never rewinds on `horizontal`. */
|
||||
public followPlayer(playerX: number, playerY: number): void {
|
||||
const halfW = this.cfg.viewportW / 2;
|
||||
const halfH = this.cfg.viewportH / 2;
|
||||
if (this.cfg.direction === 'horizontal') {
|
||||
const desired = Math.max(0, playerX - halfW);
|
||||
this._offsetX = Math.min(
|
||||
Math.max(this._offsetX, desired),
|
||||
Math.max(0, this.cfg.lengthX - this.cfg.viewportW)
|
||||
);
|
||||
} else if (this.cfg.direction === 'horizontal_bi') {
|
||||
const desired = Math.max(0, playerX - halfW);
|
||||
this._offsetX = Math.min(desired, Math.max(0, this.cfg.lengthX - this.cfg.viewportW));
|
||||
} else if (this.cfg.direction === 'vertical') {
|
||||
const ly = this.cfg.lengthY ?? this.cfg.viewportH;
|
||||
const desiredY = Math.max(0, playerY - halfH);
|
||||
this._offsetY = Math.min(desiredY, Math.max(0, ly - this.cfg.viewportH));
|
||||
}
|
||||
}
|
||||
|
||||
/** Compute the world offset for a given parallax layer. */
|
||||
public offsetForLayer(layer: ParallaxLayer): { x: number; y: number } {
|
||||
const ratio = PARALLAX_RATIOS[PARALLAX_LAYERS.indexOf(layer)];
|
||||
return { x: this._offsetX / ratio, y: this._offsetY / ratio };
|
||||
}
|
||||
|
||||
/** Return the level's culling rect in world coordinates. */
|
||||
public cullRect(): { leftX: number; rightX: number; topY: number; bottomY: number } {
|
||||
return {
|
||||
leftX: this._offsetX,
|
||||
rightX: this._offsetX + this.cfg.viewportW,
|
||||
topY: this._offsetY + this.cfg.viewportH,
|
||||
bottomY: this._offsetY,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** Build a CameraScroller from a level config. */
|
||||
export function cameraFromLevel(level: ILevelConfig, viewportW = 960, viewportH = 540): CameraScroller {
|
||||
return new CameraScroller({
|
||||
direction: level.scrollDirection,
|
||||
lengthX: level.levelLengthPx,
|
||||
lengthY: level.scrollDirection === 'vertical' ? level.levelLengthPx : undefined,
|
||||
viewportW,
|
||||
viewportH,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "8f42b819-8901-4709-b784-ff4c5f7fb61e",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* Chapter settlement logic (task 8.2, req 14.1-14.5).
|
||||
*
|
||||
* After the boss dies the chapter-1 cutscene sequence is strictly:
|
||||
*
|
||||
* princess_taken_cutscene (≤ 3s) — optional, plays mid-fight when HP ≤ 1/2
|
||||
* boss_killed (≤ 2s) — freeze-frame
|
||||
* settlement_screen — score + stats UI
|
||||
*
|
||||
* There is **no** rope-severing rescue event in the MVP (req 14.5), so we
|
||||
* expose a single `BANNED_RESCUE_SEQUENCE` constant the QA test asserts
|
||||
* against; any future code that tries to play it will fail the guardrail.
|
||||
*/
|
||||
|
||||
export type CutsceneId = 'princess_taken' | 'boss_killed_freeze' | 'settlement_screen';
|
||||
|
||||
/** Any rescue-style cutscene ID in this set will fail CI (req 14.5). */
|
||||
export const BANNED_RESCUE_SEQUENCE: ReadonlyArray<string> = Object.freeze([
|
||||
'rope_cut_rescue',
|
||||
'princess_rescued',
|
||||
'chapter_end_rescue',
|
||||
]);
|
||||
|
||||
export interface ISettlementStats {
|
||||
totalScore: number;
|
||||
stageScore: number;
|
||||
comboCount: number;
|
||||
flawless: boolean;
|
||||
remainingTimeSec: number;
|
||||
}
|
||||
|
||||
export interface ISettlementResult {
|
||||
stats: ISettlementStats;
|
||||
closingLine: string;
|
||||
}
|
||||
|
||||
export class ChapterSettlement {
|
||||
private stats: ISettlementStats;
|
||||
|
||||
constructor(initialStats: ISettlementStats) {
|
||||
this.stats = { ...initialStats };
|
||||
}
|
||||
|
||||
public addScore(pts: number): void {
|
||||
this.stats.stageScore += pts;
|
||||
this.stats.totalScore += pts;
|
||||
}
|
||||
|
||||
public registerCombo(): void {
|
||||
this.stats.comboCount++;
|
||||
}
|
||||
|
||||
public markTaken(): void {
|
||||
this.stats.flawless = false;
|
||||
}
|
||||
|
||||
public setRemainingTime(sec: number): void {
|
||||
this.stats.remainingTimeSec = sec;
|
||||
}
|
||||
|
||||
public assertCutsceneAllowed(id: CutsceneId | string): void {
|
||||
if (BANNED_RESCUE_SEQUENCE.includes(id)) {
|
||||
throw new Error(
|
||||
`ChapterSettlement: cutscene "${id}" is explicitly banned — chapter 1 must end with the princess taken (req 14.5)`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public build(): ISettlementResult {
|
||||
const closing = '公主被带走,续章待续…';
|
||||
return { stats: { ...this.stats }, closingLine: closing };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "817d0864-8a09-49b5-bdcf-2dd3e74c26de",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { AttackType } from '../data/Interfaces';
|
||||
import { PlayerStateMachine, DamageOutcome } from './PlayerStateMachine';
|
||||
|
||||
/**
|
||||
* DamageSystem — the single funnel through which **every** player-facing
|
||||
* damage event must flow (req 10.1-10.6).
|
||||
*
|
||||
* Decision precedence (req 10.3):
|
||||
* 1. i-frames → no effect
|
||||
* 2. sword parry → no effect (only vs. shuriken/sword; see PSM)
|
||||
* 3. attack type × distance → dispatch to PlayerStateMachine.takeHit
|
||||
*
|
||||
* Distance thresholds:
|
||||
* - fireball: lethal within 100px (req 10.4)
|
||||
* - smoke bomb: lethal within 80px (req 10.5)
|
||||
* - shuriken / sword: any contact is eligible
|
||||
*
|
||||
* Enemy-facing damage is a separate, simpler `applyToEnemy` helper.
|
||||
*/
|
||||
|
||||
export interface IDamageContext {
|
||||
attackType: AttackType;
|
||||
attackerX: number;
|
||||
attackerY: number;
|
||||
victimX: number;
|
||||
victimY: number;
|
||||
}
|
||||
|
||||
export const FIREBALL_KILL_RADIUS = 100;
|
||||
export const SMOKE_KILL_RADIUS = 80;
|
||||
|
||||
export class DamageSystem {
|
||||
constructor(private readonly psm: PlayerStateMachine) {}
|
||||
|
||||
/** Try to damage the player. Returns `null` if the attack missed by distance. */
|
||||
public applyToPlayer(ctx: IDamageContext): DamageOutcome | null {
|
||||
const distance = Math.hypot(ctx.attackerX - ctx.victimX, ctx.attackerY - ctx.victimY);
|
||||
if (ctx.attackType === 'fireball' && distance > FIREBALL_KILL_RADIUS) return null;
|
||||
if (ctx.attackType === 'smoke_bomb' && distance > SMOKE_KILL_RADIUS) return null;
|
||||
// shuriken / sword rely on caller-side hitbox; reaching here means "hit".
|
||||
return this.psm.takeHit(ctx.attackType);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply flat damage to an enemy HP bucket. The damage number comes from
|
||||
* the active weapon config. Returns the remaining HP (0 means killed).
|
||||
*/
|
||||
public applyToEnemy(currentHp: number, damage: number): number {
|
||||
return Math.max(0, currentHp - damage);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "36078a13-9596-4b92-9236-b207a585035a",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { EnemyType, ItemType } from '../data/Interfaces';
|
||||
|
||||
/**
|
||||
* Drop system (req 7.1-7.6).
|
||||
*
|
||||
* Rules encoded here:
|
||||
*
|
||||
* 1. **水晶玉 (crystal jade)** — deterministic: every 12th kill on forest
|
||||
* stages spawns one above the player (req 7.1). Lifetime 13-20s (req 7.2,
|
||||
* handled by caller).
|
||||
* 2. **点丸 / 术丸** — after 3 consecutive 赤忍 kills, 50% chance of one or
|
||||
* the other (req 7.3).
|
||||
* 3. **魔笛** — dropped by 黑忍 on death (implemented in `HeiRenAI`). Picking
|
||||
* it up triggers a screen-wipe (req 7.4), applied by the level manager.
|
||||
* 4. **增丸** — fixed spawn point per level config, no probability (req 7.5).
|
||||
*
|
||||
* All probabilistic decisions funnel through an injectable `random()` so
|
||||
* deterministic tests stay stable.
|
||||
*/
|
||||
|
||||
export interface IDropSystemCfg {
|
||||
/** Kills required before a crystal jade is guaranteed. Default 12 (req 7.1). */
|
||||
crystalJadeEveryN?: number;
|
||||
/** Kills of Chi Ren required before point/spell ball eligible. Default 3. */
|
||||
dianShuWanThresholdKills?: number;
|
||||
/** Probability for dian_wan or shu_wan drop once threshold reached. Default 0.5. */
|
||||
dianShuWanProbability?: number;
|
||||
/** Injectable RNG. Default `Math.random`. */
|
||||
random?: () => number;
|
||||
}
|
||||
|
||||
export interface IDropEvent {
|
||||
item: ItemType;
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
export class DropSystem {
|
||||
private globalKills = 0;
|
||||
private chiRenConsecutiveKills = 0;
|
||||
private readonly crystalJadeEveryN: number;
|
||||
private readonly dianShuWanThresholdKills: number;
|
||||
private readonly dianShuWanProbability: number;
|
||||
private readonly random: () => number;
|
||||
|
||||
constructor(cfg: IDropSystemCfg = {}) {
|
||||
this.crystalJadeEveryN = cfg.crystalJadeEveryN ?? 12;
|
||||
this.dianShuWanThresholdKills = cfg.dianShuWanThresholdKills ?? 3;
|
||||
this.dianShuWanProbability = cfg.dianShuWanProbability ?? 0.5;
|
||||
this.random = cfg.random ?? Math.random;
|
||||
}
|
||||
|
||||
/** Register an enemy kill and return any drops produced. */
|
||||
public onEnemyKilled(enemy: EnemyType, at: { x: number; y: number }): IDropEvent[] {
|
||||
this.globalKills++;
|
||||
const drops: IDropEvent[] = [];
|
||||
// Crystal-jade rule (deterministic, req 7.1).
|
||||
if (this.globalKills % this.crystalJadeEveryN === 0) {
|
||||
drops.push({ item: ItemType.CrystalJade, x: at.x, y: at.y + 180 });
|
||||
}
|
||||
// Chi Ren consecutive rule (req 7.3).
|
||||
if (enemy === EnemyType.ChiRen) {
|
||||
this.chiRenConsecutiveKills++;
|
||||
if (this.chiRenConsecutiveKills >= this.dianShuWanThresholdKills) {
|
||||
this.chiRenConsecutiveKills = 0;
|
||||
if (this.random() < this.dianShuWanProbability) {
|
||||
const pickDian = this.random() < 0.5;
|
||||
drops.push({
|
||||
item: pickDian ? ItemType.DianWan : ItemType.ShuWan,
|
||||
x: at.x,
|
||||
y: at.y,
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
this.chiRenConsecutiveKills = 0;
|
||||
}
|
||||
return drops;
|
||||
}
|
||||
|
||||
public reset(): void {
|
||||
this.globalKills = 0;
|
||||
this.chiRenConsecutiveKills = 0;
|
||||
}
|
||||
|
||||
public get kills(): number {
|
||||
return this.globalKills;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "0027692e-e7b0-4146-a401-25842bc5d1c0",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
import { AttackType, EnemyType, IEnemyConfig } from '../data/Interfaces';
|
||||
|
||||
/**
|
||||
* Enemy AI base class + four concrete subclasses (req 6.1-6.7).
|
||||
*
|
||||
* Each enemy is modelled as a tiny state machine that ticks on `update(dt)`
|
||||
* and emits an `IEnemyAction[]` the level manager then spawns into the world
|
||||
* (bullets / smoke bombs / fireballs / sword swings).
|
||||
*
|
||||
* Enemies outside the camera's culling rect can be frozen by simply skipping
|
||||
* `update()` — see `EnemyManager.update()` below (requirement 6.7 / 18.5).
|
||||
*/
|
||||
|
||||
export interface IEnemyAction {
|
||||
kind: 'fire_bullet' | 'melee_swing' | 'spawn_item' | 'drop_item';
|
||||
attackType?: AttackType;
|
||||
/** Origin of the projectile, world coords. */
|
||||
originX?: number;
|
||||
originY?: number;
|
||||
/** Velocity of the projectile. */
|
||||
velX?: number;
|
||||
velY?: number;
|
||||
/** For drop_item only — item id + world coords. */
|
||||
itemId?: string;
|
||||
}
|
||||
|
||||
export interface IPlayerSense {
|
||||
x: number;
|
||||
y: number;
|
||||
isGrounded: boolean;
|
||||
}
|
||||
|
||||
export interface IEnemyAABB {
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
h: number;
|
||||
}
|
||||
|
||||
export interface IEnemyUpdateCtx {
|
||||
dtSec: number;
|
||||
nowMs: number;
|
||||
player: IPlayerSense;
|
||||
}
|
||||
|
||||
export abstract class EnemyAIBase {
|
||||
public readonly type: EnemyType;
|
||||
public pos: { x: number; y: number };
|
||||
public alive = true;
|
||||
protected cooldownSec = 0;
|
||||
protected readonly cfg: IEnemyConfig;
|
||||
|
||||
constructor(cfg: IEnemyConfig, spawnX: number, spawnY: number) {
|
||||
this.cfg = cfg;
|
||||
this.type = cfg.id;
|
||||
this.pos = { x: spawnX, y: spawnY };
|
||||
}
|
||||
|
||||
public abstract update(ctx: IEnemyUpdateCtx): IEnemyAction[];
|
||||
|
||||
public get aabb(): IEnemyAABB {
|
||||
return { x: this.pos.x, y: this.pos.y, w: this.cfg.size.w, h: this.cfg.size.h };
|
||||
}
|
||||
|
||||
protected decrementCooldown(dtSec: number): boolean {
|
||||
this.cooldownSec -= dtSec;
|
||||
if (this.cooldownSec > 0) return false;
|
||||
this.cooldownSec = this.cfg.attackIntervalSec;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Qing Ren (req 6.1) --------------------------------------------
|
||||
export class QingRenAI extends EnemyAIBase {
|
||||
public update(ctx: IEnemyUpdateCtx): IEnemyAction[] {
|
||||
if (!this.decrementCooldown(ctx.dtSec)) return [];
|
||||
const dx = ctx.player.x - this.pos.x;
|
||||
const horizontalDistance = Math.abs(dx);
|
||||
const direction = dx >= 0 ? 1 : -1;
|
||||
if (horizontalDistance < 64) {
|
||||
return [{ kind: 'melee_swing', attackType: 'sword' }];
|
||||
}
|
||||
return [
|
||||
{
|
||||
kind: 'fire_bullet',
|
||||
attackType: 'shuriken',
|
||||
originX: this.pos.x,
|
||||
originY: this.pos.y,
|
||||
velX: 240 * direction,
|
||||
velY: 0,
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Chi Ren (req 6.2-6.4) -----------------------------------------
|
||||
export class ChiRenAI extends EnemyAIBase {
|
||||
private interceptCooldown = 0;
|
||||
|
||||
public update(ctx: IEnemyUpdateCtx): IEnemyAction[] {
|
||||
// Patrol horizontally toward the player at 120px/s (req 6.2).
|
||||
const dx = ctx.player.x - this.pos.x;
|
||||
const direction = dx >= 0 ? 1 : -1;
|
||||
this.pos.x += direction * this.cfg.moveSpeed * ctx.dtSec;
|
||||
|
||||
// Proactive intercept jump when player stands still within vision (req 6.3).
|
||||
if (this.interceptCooldown > 0) this.interceptCooldown -= ctx.dtSec;
|
||||
const playerIdle = Math.abs(ctx.player.x - this.pos.x) < 200 && ctx.player.isGrounded;
|
||||
if (playerIdle && this.interceptCooldown <= 0) {
|
||||
this.interceptCooldown = 3;
|
||||
// Intercept arc: +X velocity + upward bounce, treated as position warp.
|
||||
this.pos.y += 48;
|
||||
}
|
||||
|
||||
if (!this.decrementCooldown(ctx.dtSec)) return [];
|
||||
return [
|
||||
{
|
||||
kind: 'fire_bullet',
|
||||
attackType: 'smoke_bomb',
|
||||
originX: this.pos.x,
|
||||
originY: this.pos.y,
|
||||
velX: 140 * direction,
|
||||
velY: 0,
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Hei Ren (req 6.5) ---------------------------------------------
|
||||
export class HeiRenAI extends EnemyAIBase {
|
||||
private hasDroppedMagicFlute = false;
|
||||
|
||||
public update(ctx: IEnemyUpdateCtx): IEnemyAction[] {
|
||||
if (!this.decrementCooldown(ctx.dtSec)) return [];
|
||||
const dx = ctx.player.x - this.pos.x;
|
||||
const direction = dx >= 0 ? 1 : -1;
|
||||
if (Math.abs(dx) < 96) {
|
||||
return [{ kind: 'melee_swing', attackType: 'sword' }];
|
||||
}
|
||||
return [
|
||||
{
|
||||
kind: 'fire_bullet',
|
||||
attackType: 'shuriken',
|
||||
originX: this.pos.x,
|
||||
originY: this.pos.y,
|
||||
velX: 200 * direction,
|
||||
velY: 0,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/** Called by EnemyManager on death — yields at most one magic flute. */
|
||||
public onKilled(): IEnemyAction[] {
|
||||
if (this.hasDroppedMagicFlute) return [];
|
||||
this.hasDroppedMagicFlute = true;
|
||||
return [{ kind: 'drop_item', itemId: 'mo_di', originX: this.pos.x, originY: this.pos.y }];
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Yao Fang (req 6.6) --------------------------------------------
|
||||
export class YaoFangAI extends EnemyAIBase {
|
||||
public update(ctx: IEnemyUpdateCtx): IEnemyAction[] {
|
||||
if (!this.decrementCooldown(ctx.dtSec)) return [];
|
||||
const dx = ctx.player.x - this.pos.x;
|
||||
const direction = dx >= 0 ? 1 : -1;
|
||||
return [
|
||||
{
|
||||
kind: 'fire_bullet',
|
||||
attackType: 'fireball',
|
||||
originX: this.pos.x,
|
||||
originY: this.pos.y,
|
||||
velX: 260 * direction,
|
||||
velY: 0,
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Manager with camera-culling (req 6.7) -------------------------
|
||||
|
||||
export interface ICullingRect {
|
||||
leftX: number;
|
||||
rightX: number;
|
||||
topY: number;
|
||||
bottomY: number;
|
||||
}
|
||||
|
||||
export class EnemyManager {
|
||||
private readonly enemies: EnemyAIBase[] = [];
|
||||
|
||||
public spawn(enemy: EnemyAIBase): void {
|
||||
this.enemies.push(enemy);
|
||||
}
|
||||
|
||||
public get all(): ReadonlyArray<EnemyAIBase> {
|
||||
return this.enemies;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update all live enemies that intersect `cull`. Returns the concatenated
|
||||
* list of actions emitted so the caller (LevelMgr) can instantiate
|
||||
* projectiles, drops, etc.
|
||||
*/
|
||||
public update(dtSec: number, nowMs: number, player: IPlayerSense, cull: ICullingRect): IEnemyAction[] {
|
||||
const actions: IEnemyAction[] = [];
|
||||
for (const e of this.enemies) {
|
||||
if (!e.alive) continue;
|
||||
if (!this.inside(e, cull)) continue;
|
||||
const ctx: IEnemyUpdateCtx = { dtSec, nowMs, player };
|
||||
actions.push(...e.update(ctx));
|
||||
}
|
||||
return actions;
|
||||
}
|
||||
|
||||
public kill(enemy: EnemyAIBase): IEnemyAction[] {
|
||||
enemy.alive = false;
|
||||
if (enemy instanceof HeiRenAI) return enemy.onKilled();
|
||||
return [];
|
||||
}
|
||||
|
||||
public clear(): void {
|
||||
this.enemies.length = 0;
|
||||
}
|
||||
|
||||
private inside(e: EnemyAIBase, cull: ICullingRect): boolean {
|
||||
return (
|
||||
e.pos.x + e.aabb.w / 2 >= cull.leftX &&
|
||||
e.pos.x - e.aabb.w / 2 <= cull.rightX &&
|
||||
e.pos.y + e.aabb.h / 2 >= cull.bottomY &&
|
||||
e.pos.y - e.aabb.h / 2 <= cull.topY
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "2a16b768-32a1-48f3-8456-7f63c6ac109d",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import { PlayerMotionModel, DEFAULT_GRAVITY } from './PlayerMotionModel';
|
||||
import {
|
||||
JUMP_HEIGHT_STANDARD,
|
||||
JUMP_HEIGHT_CHARGED,
|
||||
JUMP_HEIGHT_YELLOW,
|
||||
JUMP_PREPARE_DELAY_MS,
|
||||
JUMP_CHARGE_THRESHOLD_MS,
|
||||
PlayerColorState,
|
||||
} from '../common/Constants';
|
||||
import { JoystickAngleClass } from '../ui/InputModel';
|
||||
|
||||
/**
|
||||
* Jump controller — orchestrates the jump lifecycle on top of
|
||||
* `PlayerMotionModel` (task 4.2).
|
||||
*
|
||||
* Lifecycle (ms timestamps supplied by caller so Jest can stay deterministic):
|
||||
*
|
||||
* pressJump(ts)
|
||||
* ├─ not grounded? ignore (req 2.4)
|
||||
* ├─ enter `charging` state, start timer
|
||||
* └─ emit `jump_prepare_start`
|
||||
*
|
||||
* releaseJump(ts, direction)
|
||||
* ├─ ts - pressTs >= JUMP_CHARGE_THRESHOLD_MS → charged high-jump (req 2.3)
|
||||
* ├─ else → standard jump (req 2.2)
|
||||
* ├─ +150ms crouch delay before launch (req 2.8)
|
||||
* └─ parabolic_right / parabolic_left → horizontal impulse too (req 2.5)
|
||||
*
|
||||
* Yellow-state uses a taller vertical impulse (req 2.2).
|
||||
*/
|
||||
|
||||
export type JumpPhase = 'idle' | 'charging' | 'crouching' | 'launched';
|
||||
|
||||
export interface IJumpDispatchResult {
|
||||
phase: JumpPhase;
|
||||
height: number;
|
||||
horizontalImpulse: number;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
/** How much horizontal velocity a parabolic jump imparts (px/s). */
|
||||
export const PARABOLIC_HORIZONTAL_SPEED = 180;
|
||||
|
||||
/**
|
||||
* Converts `verticalTravel (px)` to the initial velocity needed to reach it.
|
||||
* Using `v = sqrt(2 * g * h)` under constant gravity.
|
||||
*/
|
||||
export function heightToImpulse(heightPx: number, gravity: number = DEFAULT_GRAVITY): number {
|
||||
return Math.sqrt(2 * gravity * heightPx);
|
||||
}
|
||||
|
||||
export class JumpController {
|
||||
private phase: JumpPhase = 'idle';
|
||||
private pressTs = 0;
|
||||
private crouchEndsAt = 0;
|
||||
private pendingImpulse: { vy: number; vx: number } | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly motion: PlayerMotionModel,
|
||||
private readonly prepareDelayMs: number = JUMP_PREPARE_DELAY_MS,
|
||||
private readonly chargeThresholdMs: number = JUMP_CHARGE_THRESHOLD_MS
|
||||
) {}
|
||||
|
||||
/** Called each frame with `now` from `TimeMgr.realTime * 1000`. */
|
||||
public tick(nowMs: number): void {
|
||||
if (this.phase === 'crouching' && nowMs >= this.crouchEndsAt) {
|
||||
if (this.pendingImpulse) {
|
||||
this.motion.applyJumpImpulse(this.pendingImpulse.vy);
|
||||
this.motion.applyHorizontalImpulse(this.pendingImpulse.vx);
|
||||
this.pendingImpulse = null;
|
||||
}
|
||||
this.phase = 'launched';
|
||||
}
|
||||
// Once the motion model reports grounded again, reset to idle.
|
||||
if (this.phase === 'launched' && this.motion.isGrounded) {
|
||||
this.phase = 'idle';
|
||||
}
|
||||
}
|
||||
|
||||
/** Called on `jumpPressed` UI event. */
|
||||
public pressJump(nowMs: number): IJumpDispatchResult {
|
||||
if (!this.motion.isGrounded) {
|
||||
return { phase: this.phase, height: 0, horizontalImpulse: 0, reason: 'airborne' };
|
||||
}
|
||||
if (this.phase !== 'idle') {
|
||||
return { phase: this.phase, height: 0, horizontalImpulse: 0, reason: 'already_in_jump' };
|
||||
}
|
||||
this.phase = 'charging';
|
||||
this.pressTs = nowMs;
|
||||
return { phase: this.phase, height: 0, horizontalImpulse: 0 };
|
||||
}
|
||||
|
||||
/** Called on `jumpReleased` UI event. */
|
||||
public releaseJump(
|
||||
nowMs: number,
|
||||
joystickClass: JoystickAngleClass,
|
||||
colorState: PlayerColorState = PlayerColorState.Red
|
||||
): IJumpDispatchResult {
|
||||
if (this.phase !== 'charging') {
|
||||
return { phase: this.phase, height: 0, horizontalImpulse: 0, reason: 'not_charging' };
|
||||
}
|
||||
const heldMs = nowMs - this.pressTs;
|
||||
const charged = heldMs >= this.chargeThresholdMs;
|
||||
|
||||
let height = charged
|
||||
? JUMP_HEIGHT_CHARGED
|
||||
: colorState === PlayerColorState.Yellow
|
||||
? JUMP_HEIGHT_YELLOW
|
||||
: JUMP_HEIGHT_STANDARD;
|
||||
|
||||
let vx = 0;
|
||||
if (joystickClass === 'parabolic_right') {
|
||||
vx = PARABOLIC_HORIZONTAL_SPEED;
|
||||
} else if (joystickClass === 'parabolic_left') {
|
||||
vx = -PARABOLIC_HORIZONTAL_SPEED;
|
||||
}
|
||||
|
||||
this.phase = 'crouching';
|
||||
this.crouchEndsAt = nowMs + this.prepareDelayMs;
|
||||
const vy = heightToImpulse(height);
|
||||
this.pendingImpulse = { vy, vx };
|
||||
return { phase: this.phase, height, horizontalImpulse: vx };
|
||||
}
|
||||
|
||||
/** Cancel any pending jump (used on pause / scene unload). */
|
||||
public cancel(): void {
|
||||
this.phase = 'idle';
|
||||
this.pendingImpulse = null;
|
||||
}
|
||||
|
||||
/** Expose the current jump phase for HUD feedback (disabled button, etc.). */
|
||||
public getPhase(): JumpPhase {
|
||||
return this.phase;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the UI should render the jump button as enabled. Disabled when
|
||||
* airborne or mid-cycle (req 2.4).
|
||||
*/
|
||||
public isButtonEnabled(): boolean {
|
||||
return this.motion.isGrounded && this.phase === 'idle';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "449ab620-ad69-4b91-9043-c588ec6182f3",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import { EnemyType, ILevelConfig, ILevelObjective } from '../data/Interfaces';
|
||||
|
||||
/**
|
||||
* Level lifecycle manager (task 7.1 + 7.2).
|
||||
*
|
||||
* Given an `ILevelConfig`, this class:
|
||||
* - ticks the time-limit countdown (req 8.1-8.5)
|
||||
* - tracks kill counters by enemy type
|
||||
* - evaluates the level objective each tick
|
||||
* - emits a structured `LevelResult` when the level ends
|
||||
*
|
||||
* Scene + parallax rendering live in the Cocos view layer; this module is
|
||||
* intentionally engine-agnostic so the whole progression logic is Jest-
|
||||
* testable.
|
||||
*/
|
||||
|
||||
export type LevelStatus = 'running' | 'victory' | 'timeout' | 'player_dead';
|
||||
|
||||
export interface ILevelResult {
|
||||
status: LevelStatus;
|
||||
elapsedSec: number;
|
||||
kills: Record<string, number>;
|
||||
remainingSec: number;
|
||||
}
|
||||
|
||||
export class LevelMgr {
|
||||
private elapsedSec = 0;
|
||||
private kills = new Map<EnemyType, number>();
|
||||
private totalKills = 0;
|
||||
private bossKilled = false;
|
||||
private reachedTop = false;
|
||||
private playerDead = false;
|
||||
public readonly level: ILevelConfig;
|
||||
|
||||
constructor(level: ILevelConfig) {
|
||||
this.level = level;
|
||||
}
|
||||
|
||||
public tick(dtSec: number): LevelStatus {
|
||||
if (this.isTerminal()) return this.currentStatus();
|
||||
this.elapsedSec += dtSec;
|
||||
return this.currentStatus();
|
||||
}
|
||||
|
||||
public onEnemyKilled(enemy: EnemyType): void {
|
||||
this.kills.set(enemy, (this.kills.get(enemy) ?? 0) + 1);
|
||||
this.totalKills++;
|
||||
}
|
||||
|
||||
public onBossKilled(): void {
|
||||
this.bossKilled = true;
|
||||
}
|
||||
|
||||
public onReachedTop(): void {
|
||||
this.reachedTop = true;
|
||||
}
|
||||
|
||||
public onPlayerDied(): void {
|
||||
this.playerDead = true;
|
||||
}
|
||||
|
||||
public result(): ILevelResult {
|
||||
return {
|
||||
status: this.currentStatus(),
|
||||
elapsedSec: this.elapsedSec,
|
||||
kills: this.killsAsObject(),
|
||||
remainingSec: Math.max(0, this.level.timeLimitSec - this.elapsedSec),
|
||||
};
|
||||
}
|
||||
|
||||
public get totalKillsCount(): number {
|
||||
return this.totalKills;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
private currentStatus(): LevelStatus {
|
||||
if (this.playerDead) return 'player_dead';
|
||||
if (this.evaluateObjective(this.level.objective)) return 'victory';
|
||||
if (this.elapsedSec >= this.level.timeLimitSec) return 'timeout';
|
||||
return 'running';
|
||||
}
|
||||
|
||||
private isTerminal(): boolean {
|
||||
const s = this.currentStatus();
|
||||
return s !== 'running';
|
||||
}
|
||||
|
||||
private evaluateObjective(o: ILevelObjective): boolean {
|
||||
if (o.kind === 'kill_count' && o.enemy && o.count) {
|
||||
return (this.kills.get(o.enemy) ?? 0) >= o.count;
|
||||
}
|
||||
if (o.kind === 'reach_top') {
|
||||
return this.reachedTop;
|
||||
}
|
||||
if (o.kind === 'defeat_boss') {
|
||||
return this.bossKilled;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private killsAsObject(): Record<string, number> {
|
||||
const out: Record<string, number> = {};
|
||||
for (const [k, v] of this.kills.entries()) out[k] = v;
|
||||
return out;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "97238e1a-49db-41c8-9d3f-60a510388814",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
import { MOVE_SPEED, PlayerColorState } from '../common/Constants';
|
||||
|
||||
/**
|
||||
* Pure-TS motion model for the player character.
|
||||
*
|
||||
* This is the foundation used by tasks 4.1, 4.2 (jumping/parabolic) and
|
||||
* later 5.x (combo attacks). It is deliberately engine-free so that the
|
||||
* entire movement state-machine is Jest-testable (requirement 2.1, 5.1-5.2).
|
||||
*
|
||||
* Coordinate convention: landscape design resolution, **+y is up**. All
|
||||
* numbers are in design pixels / seconds.
|
||||
*/
|
||||
|
||||
export interface IAxisAlignedBox {
|
||||
/** Centre x. */
|
||||
x: number;
|
||||
/** Centre y. */
|
||||
y: number;
|
||||
/** Full width. */
|
||||
w: number;
|
||||
/** Full height. */
|
||||
h: number;
|
||||
}
|
||||
|
||||
/** A simple horizontal platform the player may stand on. */
|
||||
export interface IPlatform {
|
||||
/** Platform top edge y (world px). */
|
||||
topY: number;
|
||||
/** Platform left edge x (world px). */
|
||||
leftX: number;
|
||||
/** Platform right edge x (world px). */
|
||||
rightX: number;
|
||||
}
|
||||
|
||||
/** Horizontal input reported by `InputModel` / `FloatingControlLayer`. */
|
||||
export type HorizontalInput = -1 | 0 | 1;
|
||||
|
||||
export interface IPlayerMotionOptions {
|
||||
/** World gravity (px/s²). Default derived so a 250-px jump lasts ~0.45 s. */
|
||||
gravity?: number;
|
||||
/** Starting AABB of the player. */
|
||||
aabb: IAxisAlignedBox;
|
||||
/** Platforms defining the walkable terrain. Can be swapped per-level. */
|
||||
platforms: IPlatform[];
|
||||
/** Starting color state. */
|
||||
initialColorState?: PlayerColorState;
|
||||
}
|
||||
|
||||
export const DEFAULT_GRAVITY = 2500; // px/s² — tuned so a 250px jump peaks in ~0.45s
|
||||
|
||||
/**
|
||||
* Encapsulates player horizontal/vertical movement + ground detection.
|
||||
* Call `setHorizontalInput()` + `requestJump()` every frame from the view
|
||||
* layer, then invoke `update(dt)` to advance the simulation.
|
||||
*/
|
||||
export class PlayerMotionModel {
|
||||
// -- mutable state ------------------------------------------------------
|
||||
private _vx = 0;
|
||||
private _vy = 0;
|
||||
private _grounded = false;
|
||||
private _colorState: PlayerColorState;
|
||||
private _horizontalInput: HorizontalInput = 0;
|
||||
private _aabb: IAxisAlignedBox;
|
||||
private _platforms: IPlatform[];
|
||||
private readonly gravity: number;
|
||||
|
||||
constructor(options: IPlayerMotionOptions) {
|
||||
this._aabb = { ...options.aabb };
|
||||
this._platforms = options.platforms.slice();
|
||||
this._colorState = options.initialColorState ?? PlayerColorState.Red;
|
||||
this.gravity = options.gravity ?? DEFAULT_GRAVITY;
|
||||
}
|
||||
|
||||
// -- accessors ----------------------------------------------------------
|
||||
public get aabb(): IAxisAlignedBox {
|
||||
return this._aabb;
|
||||
}
|
||||
public get vx(): number {
|
||||
return this._vx;
|
||||
}
|
||||
public get vy(): number {
|
||||
return this._vy;
|
||||
}
|
||||
public get isGrounded(): boolean {
|
||||
return this._grounded;
|
||||
}
|
||||
public get colorState(): PlayerColorState {
|
||||
return this._colorState;
|
||||
}
|
||||
|
||||
// -- inputs -------------------------------------------------------------
|
||||
|
||||
/** -1 moves left, 1 moves right, 0 stops (req 2.1). */
|
||||
public setHorizontalInput(input: HorizontalInput): void {
|
||||
this._horizontalInput = input;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the player's color state (e.g. after a crystal-jade pickup).
|
||||
* Movement speed will immediately reflect the new bucket (req 5.1-5.2).
|
||||
*/
|
||||
public setColorState(state: PlayerColorState): void {
|
||||
this._colorState = state;
|
||||
}
|
||||
|
||||
/** Impulse-based vertical jump. Does nothing if not grounded (req 2.4). */
|
||||
public applyJumpImpulse(verticalPxPerSec: number): boolean {
|
||||
if (!this._grounded) return false;
|
||||
this._vy = verticalPxPerSec;
|
||||
this._grounded = false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Additional horizontal impulse used by parabolic jumps (req 2.5). */
|
||||
public applyHorizontalImpulse(vx: number): void {
|
||||
this._vx = vx;
|
||||
}
|
||||
|
||||
/** Swap level terrain; also clears grounded so we re-settle on next update. */
|
||||
public setPlatforms(platforms: IPlatform[]): void {
|
||||
this._platforms = platforms.slice();
|
||||
this._grounded = false;
|
||||
}
|
||||
|
||||
// -- simulation step ----------------------------------------------------
|
||||
|
||||
/**
|
||||
* Advance the simulation by `dt` seconds. In hardcore mode (req 13.4) the
|
||||
* horizontal velocity is **only** rewritten from input when on the
|
||||
* ground; mid-air `_vx` is preserved (起跳定型).
|
||||
*/
|
||||
public update(dt: number): void {
|
||||
if (this._grounded) {
|
||||
this._vx = this._horizontalInput * MOVE_SPEED[this._colorState];
|
||||
this._vy = 0;
|
||||
} else {
|
||||
// Apply gravity (requirement 13.4: no air-control).
|
||||
this._vy -= this.gravity * dt;
|
||||
}
|
||||
// Integrate position.
|
||||
this._aabb = {
|
||||
...this._aabb,
|
||||
x: this._aabb.x + this._vx * dt,
|
||||
y: this._aabb.y + this._vy * dt,
|
||||
};
|
||||
// Resolve against platforms (basic AABB vs. top-surface only).
|
||||
this._grounded = false;
|
||||
for (const p of this._platforms) {
|
||||
if (this.isRestingOn(p)) {
|
||||
this._grounded = true;
|
||||
this._aabb = { ...this._aabb, y: p.topY + this._aabb.h / 2 };
|
||||
if (this._vy < 0) this._vy = 0;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -- helpers ------------------------------------------------------------
|
||||
|
||||
private isRestingOn(p: IPlatform): boolean {
|
||||
const feetY = this._aabb.y - this._aabb.h / 2;
|
||||
const withinHorizontal = this._aabb.x >= p.leftX && this._aabb.x <= p.rightX;
|
||||
const atOrJustBelowTop = feetY <= p.topY + 0.5 && feetY >= p.topY - 6 && this._vy <= 0;
|
||||
return withinHorizontal && atOrJustBelowTop;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "37159f2f-c9e3-4a3c-a735-caa7476b0266",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import { PlayerColorState, PLAYER_IFRAME_SECONDS } from '../common/Constants';
|
||||
import { AttackType } from '../data/Interfaces';
|
||||
|
||||
/**
|
||||
* Player color-state machine (req 5.1-5.6) + parry (req 3.7-3.8) + i-frames
|
||||
* (req 10.2-10.3).
|
||||
*
|
||||
* Transitions:
|
||||
*
|
||||
* [Red] --crystal_jade--> [Green] --crystal_jade--> [Yellow]
|
||||
* ^ | |
|
||||
* |---- shuriken|sword --|---- shuriken|sword -----|
|
||||
* | |
|
||||
* ---- fireball / smoke_bomb -----------------> ⚰️ dead
|
||||
* ---- shuriken|sword while red ----------------> ⚰️ dead
|
||||
*
|
||||
* The machine is deliberately engine-agnostic so combat logic can be unit-
|
||||
* tested on either the web or inside a future dedicated simulator.
|
||||
*/
|
||||
|
||||
export type DamageOutcome =
|
||||
| { kind: 'no_effect'; reason: 'iframe' | 'parried' }
|
||||
| { kind: 'downgraded'; from: PlayerColorState; to: PlayerColorState }
|
||||
| { kind: 'died'; cause: AttackType };
|
||||
|
||||
export interface IPlayerState {
|
||||
color: PlayerColorState;
|
||||
lives: number;
|
||||
/** Whether the ninja sword is currently in its parry-active frame window. */
|
||||
swordActive: boolean;
|
||||
/** Remaining i-frame time (seconds). */
|
||||
iframeSec: number;
|
||||
isDead: boolean;
|
||||
}
|
||||
|
||||
export class PlayerStateMachine {
|
||||
private state: IPlayerState;
|
||||
|
||||
constructor(initialLives = 3) {
|
||||
this.state = {
|
||||
color: PlayerColorState.Red,
|
||||
lives: initialLives,
|
||||
swordActive: false,
|
||||
iframeSec: 0,
|
||||
isDead: false,
|
||||
};
|
||||
}
|
||||
|
||||
public get snapshot(): IPlayerState {
|
||||
return { ...this.state };
|
||||
}
|
||||
|
||||
public get color(): PlayerColorState {
|
||||
return this.state.color;
|
||||
}
|
||||
|
||||
public get lives(): number {
|
||||
return this.state.lives;
|
||||
}
|
||||
|
||||
public get isDead(): boolean {
|
||||
return this.state.isDead;
|
||||
}
|
||||
|
||||
// ---- external events --------------------------------------------------
|
||||
|
||||
/** Player picked up a crystal jade (req 5.1-5.2). */
|
||||
public pickupCrystalJade(): PlayerColorState {
|
||||
if (this.state.color === PlayerColorState.Red) {
|
||||
this.state.color = PlayerColorState.Green;
|
||||
} else if (this.state.color === PlayerColorState.Green) {
|
||||
this.state.color = PlayerColorState.Yellow;
|
||||
}
|
||||
return this.state.color;
|
||||
}
|
||||
|
||||
/** Player picked up an 增丸 — permanently +1 life (req 7.5). */
|
||||
public pickupZengWan(): number {
|
||||
this.state.lives += 1;
|
||||
return this.state.lives;
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle sword active window. Called by `AttackController.tick()` for the
|
||||
* duration of a sword swing so the parry window (req 3.7-3.8) stays tight.
|
||||
*/
|
||||
public setSwordActive(active: boolean): void {
|
||||
this.state.swordActive = active;
|
||||
}
|
||||
|
||||
/** Advance i-frames on every physics tick. */
|
||||
public tick(dtSec: number): void {
|
||||
if (this.state.iframeSec > 0) {
|
||||
this.state.iframeSec = Math.max(0, this.state.iframeSec - dtSec);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply incoming damage of `attackType`. Returns the resulting outcome so
|
||||
* the caller can render the appropriate FX/HUD change.
|
||||
*/
|
||||
public takeHit(attackType: AttackType): DamageOutcome {
|
||||
if (this.state.iframeSec > 0) {
|
||||
return { kind: 'no_effect', reason: 'iframe' };
|
||||
}
|
||||
// Sword-active parry applies to shuriken & sword only (req 3.7-3.8).
|
||||
if (this.state.swordActive && (attackType === 'shuriken' || attackType === 'sword')) {
|
||||
this.startIFrames();
|
||||
return { kind: 'no_effect', reason: 'parried' };
|
||||
}
|
||||
// Fireball / smoke bomb are always lethal (req 5.5, 10.4-10.5).
|
||||
if (attackType === 'fireball' || attackType === 'smoke_bomb') {
|
||||
return this.consumeLife(attackType);
|
||||
}
|
||||
// Ordinary shuriken / sword damage: downgrade by one tier or die.
|
||||
if (this.state.color === PlayerColorState.Yellow) {
|
||||
this.state.color = PlayerColorState.Red;
|
||||
this.startIFrames();
|
||||
return { kind: 'downgraded', from: PlayerColorState.Yellow, to: PlayerColorState.Red };
|
||||
}
|
||||
if (this.state.color === PlayerColorState.Green) {
|
||||
this.state.color = PlayerColorState.Red;
|
||||
this.startIFrames();
|
||||
return { kind: 'downgraded', from: PlayerColorState.Green, to: PlayerColorState.Red };
|
||||
}
|
||||
// Red → dead
|
||||
return this.consumeLife(attackType);
|
||||
}
|
||||
|
||||
// ---- helpers ----------------------------------------------------------
|
||||
|
||||
private consumeLife(cause: AttackType): DamageOutcome {
|
||||
this.state.lives = Math.max(0, this.state.lives - 1);
|
||||
this.state.color = PlayerColorState.Red;
|
||||
this.startIFrames();
|
||||
if (this.state.lives === 0) {
|
||||
this.state.isDead = true;
|
||||
}
|
||||
return { kind: 'died', cause };
|
||||
}
|
||||
|
||||
private startIFrames(): void {
|
||||
this.state.iframeSec = PLAYER_IFRAME_SECONDS;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "f13127c9-f546-4df3-8e63-3a2e7d4fa23c",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { WeaponType } from '../data/Interfaces';
|
||||
|
||||
/**
|
||||
* Score system (task 9.3, req 12.1-12.8).
|
||||
*
|
||||
* Scoring table:
|
||||
* - Ninja sword kill ×2.0 base
|
||||
* - Shuriken kill ×1.0 base
|
||||
* - Perfect parry counterkill ×3.0 base
|
||||
* - 5-combo "刃接触" bonus +1500
|
||||
* - Flawless level (no damage) ×3.0 total
|
||||
* - Remaining time bonus +10 pts / remaining sec
|
||||
*
|
||||
* Everything is deterministic and `Math.random`-free so QA can reproduce
|
||||
* every score calculation in unit tests.
|
||||
*/
|
||||
|
||||
export const BASE_ENEMY_SCORE = 100;
|
||||
export const COMBO_BONUS = 1500;
|
||||
export const COMBO_THRESHOLD = 5;
|
||||
export const TIME_BONUS_PER_SEC = 10;
|
||||
|
||||
export interface IScoreSnapshot {
|
||||
baseScore: number;
|
||||
comboBonus: number;
|
||||
timeBonus: number;
|
||||
flawlessMultiplier: number;
|
||||
finalScore: number;
|
||||
killCount: number;
|
||||
comboCount: number;
|
||||
consecutiveBladeHits: number;
|
||||
}
|
||||
|
||||
export class ScoreSystem {
|
||||
private baseScore = 0;
|
||||
private comboBonus = 0;
|
||||
private killCount = 0;
|
||||
private consecutiveBladeHits = 0;
|
||||
private comboCount = 0;
|
||||
private flawless = true;
|
||||
private timeBonus = 0;
|
||||
|
||||
public reset(): void {
|
||||
this.baseScore = 0;
|
||||
this.comboBonus = 0;
|
||||
this.killCount = 0;
|
||||
this.consecutiveBladeHits = 0;
|
||||
this.comboCount = 0;
|
||||
this.flawless = true;
|
||||
this.timeBonus = 0;
|
||||
}
|
||||
|
||||
/** Record a kill weighted by weapon type (req 12.1-12.2). */
|
||||
public recordEnemyKill(weapon: WeaponType): void {
|
||||
this.killCount++;
|
||||
const multiplier = weapon === WeaponType.NinjaSword ? 2 : 1;
|
||||
this.baseScore += BASE_ENEMY_SCORE * multiplier;
|
||||
}
|
||||
|
||||
/** Perfect parry followed by a counter-kill (req 12.3). */
|
||||
public recordParryKill(): void {
|
||||
this.killCount++;
|
||||
this.baseScore += BASE_ENEMY_SCORE * 3;
|
||||
}
|
||||
|
||||
/** Record a single blade contact; every 5 contacts award a combo bonus (req 12.4). */
|
||||
public recordBladeContact(): void {
|
||||
this.consecutiveBladeHits++;
|
||||
if (this.consecutiveBladeHits >= COMBO_THRESHOLD) {
|
||||
this.comboCount++;
|
||||
this.comboBonus += COMBO_BONUS;
|
||||
this.consecutiveBladeHits = 0;
|
||||
}
|
||||
}
|
||||
|
||||
/** Resets the consecutive blade counter if the combo is broken. */
|
||||
public breakBladeChain(): void {
|
||||
this.consecutiveBladeHits = 0;
|
||||
}
|
||||
|
||||
/** Player took damage → flawless multiplier lost (req 12.5). */
|
||||
public markTaken(): void {
|
||||
this.flawless = false;
|
||||
}
|
||||
|
||||
/** Stage-end timing bonus (req 12.6). */
|
||||
public setRemainingTimeBonus(remainingSec: number): void {
|
||||
this.timeBonus = Math.max(0, Math.floor(remainingSec)) * TIME_BONUS_PER_SEC;
|
||||
}
|
||||
|
||||
public snapshot(): IScoreSnapshot {
|
||||
const flawlessMultiplier = this.flawless ? 3 : 1;
|
||||
const finalScore = (this.baseScore + this.comboBonus + this.timeBonus) * flawlessMultiplier;
|
||||
return {
|
||||
baseScore: this.baseScore,
|
||||
comboBonus: this.comboBonus,
|
||||
timeBonus: this.timeBonus,
|
||||
flawlessMultiplier,
|
||||
finalScore,
|
||||
killCount: this.killCount,
|
||||
comboCount: this.comboCount,
|
||||
consecutiveBladeHits: this.consecutiveBladeHits,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "96dc60f4-e45f-426a-8832-c36a6662f45f",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import { StorageMgr, globalStorageMgr } from '../common/StorageMgr';
|
||||
import { STORAGE_KEY } from '../common/Constants';
|
||||
|
||||
/**
|
||||
* Tutorial manager (req 11.1-11.5, task 9.3).
|
||||
*
|
||||
* Pre-defined tutorial sequences for levels 1-1, 1-2, 1-3. Each step has an
|
||||
* ID the view layer uses to drive highlight-arrows; the step is "completed"
|
||||
* when the player performs the action, which the view layer signals via
|
||||
* `reportAction()`.
|
||||
*/
|
||||
|
||||
export interface ITutorialStep {
|
||||
id: string;
|
||||
/** Human-readable hint (displayed by the view layer). */
|
||||
hint: string;
|
||||
/** Action id the player must perform to advance. */
|
||||
requiredAction: string;
|
||||
}
|
||||
|
||||
export interface ITutorialSequence {
|
||||
levelId: string;
|
||||
steps: ITutorialStep[];
|
||||
}
|
||||
|
||||
/** Built-in tutorials for Chapter 1 (req 11.1-11.3). */
|
||||
export const BUILTIN_TUTORIALS: ITutorialSequence[] = [
|
||||
{
|
||||
levelId: '1-1',
|
||||
steps: [
|
||||
{ id: 'attack', hint: '点击右下的手里剑按钮', requiredAction: 'fire_shuriken' },
|
||||
{ id: 'joystick', hint: '拖动左下摇杆移动', requiredAction: 'move' },
|
||||
{ id: 'jump', hint: '点击跳跃按钮', requiredAction: 'jump' },
|
||||
],
|
||||
},
|
||||
{
|
||||
levelId: '1-2',
|
||||
steps: [
|
||||
{ id: 'parabolic', hint: '摇杆 45° 并跳跃 — 抛物线跳跃', requiredAction: 'parabolic_jump' },
|
||||
{ id: 'exclusive', hint: '两个攻击按钮互斥,选一个用', requiredAction: 'attack_switch' },
|
||||
{ id: 'parry', hint: '忍者刀可以格挡敌人刀剑', requiredAction: 'parry' },
|
||||
{ id: 'combo', hint: '跳跃中同时攻击', requiredAction: 'jump_attack' },
|
||||
{ id: 'auto_upgrade', hint: '拾取水晶玉自动强化', requiredAction: 'pickup_crystal' },
|
||||
],
|
||||
},
|
||||
{
|
||||
levelId: '1-3',
|
||||
steps: [
|
||||
{ id: 'butterfly', hint: '先击中 BOSS 身旁的蝴蝶', requiredAction: 'hit_butterfly' },
|
||||
{ id: 'boss_identify', hint: '识别 BOSS 攻击模式', requiredAction: 'dodge_boss_attack' },
|
||||
{ id: 'one_shot', hint: '显形后一击必杀', requiredAction: 'hit_revealed_boss' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export class TutorialMgr {
|
||||
private currentLevelId: string | null = null;
|
||||
private currentStepIndex = 0;
|
||||
|
||||
constructor(
|
||||
private readonly storage: StorageMgr = globalStorageMgr,
|
||||
private readonly sequences: ITutorialSequence[] = BUILTIN_TUTORIALS
|
||||
) {}
|
||||
|
||||
/** Start the tutorial for `levelId` if not already completed. */
|
||||
public maybeStart(levelId: string): ITutorialStep | null {
|
||||
if (this.isCompleted(levelId)) return null;
|
||||
const seq = this.sequences.find((s) => s.levelId === levelId);
|
||||
if (!seq) return null;
|
||||
this.currentLevelId = levelId;
|
||||
this.currentStepIndex = 0;
|
||||
return seq.steps[0];
|
||||
}
|
||||
|
||||
/** Called by gameplay whenever the player performs an action. */
|
||||
public reportAction(action: string): ITutorialStep | 'finished' | 'no_op' {
|
||||
if (!this.currentLevelId) return 'no_op';
|
||||
const seq = this.sequences.find((s) => s.levelId === this.currentLevelId);
|
||||
if (!seq) return 'no_op';
|
||||
const current = seq.steps[this.currentStepIndex];
|
||||
if (action !== current.requiredAction) return 'no_op';
|
||||
this.currentStepIndex++;
|
||||
if (this.currentStepIndex >= seq.steps.length) {
|
||||
this.markCompleted(this.currentLevelId);
|
||||
this.currentLevelId = null;
|
||||
this.currentStepIndex = 0;
|
||||
return 'finished';
|
||||
}
|
||||
return seq.steps[this.currentStepIndex];
|
||||
}
|
||||
|
||||
public isCompleted(levelId: string): boolean {
|
||||
const completed = this.storage.get<string[]>(STORAGE_KEY.TutorialDone, []);
|
||||
return completed.includes(levelId);
|
||||
}
|
||||
|
||||
public resetAll(): void {
|
||||
this.storage.remove(STORAGE_KEY.TutorialDone);
|
||||
this.currentLevelId = null;
|
||||
this.currentStepIndex = 0;
|
||||
}
|
||||
|
||||
public get isActive(): boolean {
|
||||
return this.currentLevelId !== null;
|
||||
}
|
||||
|
||||
public currentStep(): ITutorialStep | null {
|
||||
if (!this.currentLevelId) return null;
|
||||
const seq = this.sequences.find((s) => s.levelId === this.currentLevelId);
|
||||
return seq?.steps[this.currentStepIndex] ?? null;
|
||||
}
|
||||
|
||||
private markCompleted(levelId: string): void {
|
||||
const current = this.storage.get<string[]>(STORAGE_KEY.TutorialDone, []);
|
||||
if (!current.includes(levelId)) current.push(levelId);
|
||||
this.storage.set(STORAGE_KEY.TutorialDone, current);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "e1bd54ae-07af-4ad3-8085-24c1a88bdce0",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Logic layer — game-state machines, AI, damage system, level framework.
|
||||
*/
|
||||
|
||||
export * from './PlayerMotionModel';
|
||||
export * from './JumpController';
|
||||
export * from './AttackController';
|
||||
export * from './PlayerStateMachine';
|
||||
export * from './EnemyAI';
|
||||
export * from './DropSystem';
|
||||
export * from './DamageSystem';
|
||||
export * from './CameraScroller';
|
||||
export * from './LevelMgr';
|
||||
export * from './BossController';
|
||||
export * from './ChapterSettlement';
|
||||
export * from './TutorialMgr';
|
||||
export * from './ScoreSystem';
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "a0972eb0-c1b0-4e73-adff-5481e47d3b19",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "1.2.0",
|
||||
"importer": "directory",
|
||||
"imported": true,
|
||||
"uuid": "38cdf707-78a1-4404-924c-12ba494e24a9",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { _decorator, Component, director } from 'cc';
|
||||
import { ConfigMgr } from '../data/ConfigMgr';
|
||||
import { CCJsonLoader } from './CCJsonLoader';
|
||||
import { BossController } from '../logic/BossController';
|
||||
import { ensureCanvasSize, createLabel, createButton } from './MainMenuEntry';
|
||||
import { Color } from 'cc';
|
||||
import { DESIGN_WIDTH, DESIGN_HEIGHT } from '../common/Constants';
|
||||
|
||||
const { ccclass, property } = _decorator;
|
||||
|
||||
/**
|
||||
* Boss scene entry (task 7.3 / 8.2 hookup, req 9.x + 14.x).
|
||||
*
|
||||
* Attach to the root node of `Boss_ShuangHuanFang.scene`. Default bossId
|
||||
* matches the chapter-1 final boss.
|
||||
*
|
||||
* When `autoBuildUI` is enabled, two temporary debug buttons are placed on
|
||||
* screen so the flow can be validated before the combat view layer lands:
|
||||
* - Left side: "Hit Butterfly" → `onButterflyHit`
|
||||
* - Right side: "Hit Body" → `onBodyHit`
|
||||
* These will be removed once the real combat HUD is built.
|
||||
*/
|
||||
@ccclass('BossEntry')
|
||||
export class BossEntry extends Component {
|
||||
@property({ tooltip: '对应 configs/bosses.json 中的 id' })
|
||||
public bossId: string = 'shuang_huan_fang';
|
||||
|
||||
@property({ tooltip: '是否自动创建调试按钮 (战斗 HUD 就绪前使用)' })
|
||||
public autoBuildUI: boolean = true;
|
||||
|
||||
private ctrl: BossController | undefined;
|
||||
|
||||
protected async onLoad(): Promise<void> {
|
||||
if (this.autoBuildUI) this.buildDefaultUI();
|
||||
const cfg = new ConfigMgr(new CCJsonLoader());
|
||||
await cfg.load();
|
||||
this.ctrl = new BossController(cfg.boss(this.bossId));
|
||||
}
|
||||
|
||||
/** Dev-hook: attack landed on the butterfly (req 9.2). */
|
||||
public onButterflyHit(): void {
|
||||
const events = this.ctrl?.onButterflyHit() ?? [];
|
||||
this.processEvents(events);
|
||||
}
|
||||
|
||||
/** Dev-hook: attack landed on boss body (req 9.3). */
|
||||
public onBodyHit(): void {
|
||||
const events = this.ctrl?.onBodyHit() ?? [];
|
||||
this.processEvents(events);
|
||||
}
|
||||
|
||||
private processEvents(events: ReturnType<NonNullable<BossController['onBodyHit']>>): void {
|
||||
for (const ev of events) {
|
||||
if (ev.kind === 'boss_killed') {
|
||||
director.loadScene('Settlement');
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private buildDefaultUI(): void {
|
||||
ensureCanvasSize(this.node);
|
||||
createLabel(this.node, 'BOSS · 双幻坊', 0, DESIGN_HEIGHT / 2 - 60, 32, Color.WHITE);
|
||||
createLabel(
|
||||
this.node,
|
||||
'调试:先击中蝴蝶 → 再击中本体',
|
||||
0,
|
||||
DESIGN_HEIGHT / 2 - 110,
|
||||
18,
|
||||
new Color(200, 200, 200, 255),
|
||||
);
|
||||
// Left / right debug buttons, 180px off center.
|
||||
createButton(this.node, 'Hit Butterfly', -180, -120, 220, 70, () => this.onButterflyHit());
|
||||
createButton(this.node, 'Hit Body', 180, -120, 220, 70, () => this.onBodyHit());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "1ac5856f-147f-410c-8b8b-8de267952b40",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { resources, JsonAsset } from 'cc';
|
||||
import { IJsonLoader } from '../data/ConfigMgr';
|
||||
|
||||
/**
|
||||
* Cocos Creator backed JSON loader. Used by Scene Entry components to feed
|
||||
* `ConfigMgr`. Production-only: unit tests inject `MapJsonLoader` instead.
|
||||
*
|
||||
* The path parameter matches what `ConfigMgr` requests, e.g. `configs/enemies`.
|
||||
* Cocos resolves it against the `assets/resources/` root.
|
||||
*/
|
||||
export class CCJsonLoader implements IJsonLoader {
|
||||
public load<T>(path: string): Promise<T> {
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
resources.load(path, JsonAsset, (err: Error | null, asset: unknown) => {
|
||||
if (err || !asset) {
|
||||
reject(err ?? new Error(`CCJsonLoader: asset not found at ${path}`));
|
||||
return;
|
||||
}
|
||||
resolve((asset as JsonAsset).json as T);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "933a1f2f-13d2-4c18-9a1d-62abd39776b2",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { _decorator, Component, director, Color, Label, Node } from 'cc';
|
||||
import { ConfigMgr } from '../data/ConfigMgr';
|
||||
import { CCJsonLoader } from './CCJsonLoader';
|
||||
import { LevelMgr } from '../logic/LevelMgr';
|
||||
import { ensureCanvasSize, createLabel } from './MainMenuEntry';
|
||||
import { DESIGN_HEIGHT } from '../common/Constants';
|
||||
|
||||
const { ccclass, property } = _decorator;
|
||||
|
||||
/**
|
||||
* Generic Level scene entry (task 7.1-7.2 hookup).
|
||||
*
|
||||
* Attach to the root node of `Level_1_1` … `Level_1_5`. Configure the
|
||||
* `levelId` property in the Inspector ("1-1", "1-2", ...).
|
||||
*
|
||||
* For MVP the scene has no real gameplay rendering yet; `autoBuildUI`
|
||||
* simply draws a top-centered label with the current level id so you can
|
||||
* verify scene transitions by eye.
|
||||
*/
|
||||
@ccclass('LevelEntry')
|
||||
export class LevelEntry extends Component {
|
||||
@property({ tooltip: '本关的 levelId (与 configs/levels.json 对应),如 1-1 / 1-2 / 1-3 / 1-4 / 1-5' })
|
||||
public levelId: string = '1-1';
|
||||
|
||||
@property({ tooltip: '胜利后跳转的场景,留空则按 1-1 → 1-2 → ... → 1-5 → Boss 自动推导' })
|
||||
public nextSceneName: string = '';
|
||||
|
||||
@property({ tooltip: '是否自动创建关卡 HUD (顶部显示 Level / 倒计时)' })
|
||||
public autoBuildUI: boolean = true;
|
||||
|
||||
private mgr: LevelMgr | undefined;
|
||||
private hudNode: Node | null = null;
|
||||
|
||||
protected async onLoad(): Promise<void> {
|
||||
if (this.autoBuildUI) this.buildDefaultUI();
|
||||
const cfg = new ConfigMgr(new CCJsonLoader());
|
||||
await cfg.load();
|
||||
this.mgr = new LevelMgr(cfg.level(this.levelId));
|
||||
}
|
||||
|
||||
protected update(dt: number): void {
|
||||
if (!this.mgr) return;
|
||||
const status = this.mgr.tick(dt);
|
||||
this.refreshHud();
|
||||
if (status === 'victory') {
|
||||
director.loadScene(this.nextSceneName || this.deriveNextScene());
|
||||
} else if (status === 'timeout' || status === 'player_dead') {
|
||||
director.loadScene('Settlement');
|
||||
}
|
||||
}
|
||||
|
||||
private deriveNextScene(): string {
|
||||
const map: Record<string, string> = {
|
||||
'1-1': 'Level_1_2',
|
||||
'1-2': 'Level_1_3',
|
||||
'1-3': 'Level_1_4',
|
||||
'1-4': 'Level_1_5',
|
||||
'1-5': 'Boss_ShuangHuanFang',
|
||||
};
|
||||
return map[this.levelId] ?? 'Settlement';
|
||||
}
|
||||
|
||||
private buildDefaultUI(): void {
|
||||
ensureCanvasSize(this.node);
|
||||
createLabel(this.node, `Level ${this.levelId}`, 0, DESIGN_HEIGHT / 2 - 50, 28, Color.WHITE);
|
||||
this.hudNode = createLabel(
|
||||
this.node,
|
||||
'Time: --',
|
||||
0,
|
||||
DESIGN_HEIGHT / 2 - 90,
|
||||
22,
|
||||
new Color(255, 220, 120, 255),
|
||||
);
|
||||
}
|
||||
|
||||
private refreshHud(): void {
|
||||
if (!this.hudNode || !this.mgr) return;
|
||||
const lb = this.hudNode.getComponent(Label);
|
||||
if (lb) {
|
||||
const r = this.mgr.result();
|
||||
lb.string = `Time: ${Math.max(0, Math.ceil(r.remainingSec))}s Kills: ${Object.values(r.kills).reduce((a, b) => a + b, 0)}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "621ccf9a-6948-463f-9e06-bbb94027a369",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
import { _decorator, Component, director, Node, Label, Button, UITransform, Color, Vec3, Graphics } from 'cc';
|
||||
import { UIFlowMgr, ISceneEnter } from '../ui/UIFlowMgr';
|
||||
import { DESIGN_WIDTH, DESIGN_HEIGHT } from '../common/Constants';
|
||||
|
||||
const { ccclass, property } = _decorator;
|
||||
|
||||
/** Maps abstract SceneId → physical Cocos Creator scene name. */
|
||||
const SCENE_MAP: Record<string, string> = {
|
||||
boot: 'Boot',
|
||||
story_intro: 'StoryIntro',
|
||||
main_menu: 'MainMenu',
|
||||
// level_select currently reuses MainMenu until a dedicated LevelSelect scene exists.
|
||||
level_select: 'MainMenu',
|
||||
// `gameplay` is dispatched by levelId — resolved at runtime.
|
||||
gameplay: 'Level_1_1',
|
||||
settlement: 'Settlement',
|
||||
// Settings panel is overlayed on MainMenu for the MVP.
|
||||
settings: 'MainMenu',
|
||||
};
|
||||
|
||||
/** levelId → physical scene name mapping (chapter 1 only). */
|
||||
const LEVEL_SCENE_MAP: Record<string, string> = {
|
||||
'1-1': 'Level_1_1',
|
||||
'1-2': 'Level_1_2',
|
||||
'1-3': 'Level_1_3',
|
||||
'1-4': 'Level_1_4',
|
||||
'1-5': 'Level_1_5',
|
||||
'1-5-boss': 'Boss_ShuangHuanFang',
|
||||
};
|
||||
|
||||
/**
|
||||
* MainMenu scene entry (task 9.2 hookup).
|
||||
*
|
||||
* Owns a `UIFlowMgr` instance and translates each abstract `onSceneEnter`
|
||||
* callback into a concrete `director.loadScene` call. Attach this component
|
||||
* to the root node of `MainMenu.scene`.
|
||||
*
|
||||
* When `autoBuildUI` is enabled (default), two centered buttons (Start /
|
||||
* Settings) and a title label are created programmatically so the scene is
|
||||
* usable out of the box even before any art pass.
|
||||
*/
|
||||
@ccclass('MainMenuEntry')
|
||||
export class MainMenuEntry extends Component {
|
||||
@property({ tooltip: '是否自动生成 Start / Settings 按钮 (方便没美术时就能跑通)' })
|
||||
public autoBuildUI: boolean = true;
|
||||
|
||||
private flow: UIFlowMgr | undefined;
|
||||
|
||||
protected onLoad(): void {
|
||||
this.flow = new UIFlowMgr(undefined, {
|
||||
onSceneEnter: (ev) => this.handleSceneEnter(ev),
|
||||
});
|
||||
if (this.autoBuildUI) this.buildDefaultUI();
|
||||
}
|
||||
|
||||
/** Bind this to the "Start" button's click event in the Inspector. */
|
||||
public onPressStart(): void {
|
||||
this.flow?.onPressStartGame();
|
||||
}
|
||||
|
||||
/** Bind this to the "Settings" button's click event. */
|
||||
public onPressSettings(): void {
|
||||
this.flow?.onOpenSettings();
|
||||
}
|
||||
|
||||
private handleSceneEnter(ev: ISceneEnter): void {
|
||||
const payload = ev.payload ?? {};
|
||||
if (ev.scene === 'gameplay' && typeof payload.levelId === 'string') {
|
||||
const physical = LEVEL_SCENE_MAP[payload.levelId];
|
||||
if (physical) {
|
||||
director.loadScene(physical);
|
||||
return;
|
||||
}
|
||||
}
|
||||
const physical = SCENE_MAP[ev.scene];
|
||||
if (physical) director.loadScene(physical);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Auto-built UI (development affordance; art pass will replace it)
|
||||
// ------------------------------------------------------------------
|
||||
private buildDefaultUI(): void {
|
||||
// Ensure the host node has a UITransform matching the design resolution.
|
||||
ensureCanvasSize(this.node);
|
||||
|
||||
// Title.
|
||||
createLabel(this.node, '影 之 传 说', 0, 140, 44, Color.WHITE);
|
||||
|
||||
// Start button (centered, 40 above origin).
|
||||
createButton(this.node, 'Start', 0, 40, 220, 60, () => this.onPressStart());
|
||||
|
||||
// Settings button (centered, 40 below origin).
|
||||
createButton(this.node, 'Settings', 0, -40, 220, 60, () => this.onPressSettings());
|
||||
|
||||
// Hint line at the bottom.
|
||||
createLabel(this.node, 'Chapter 1 · MVP', 0, -200, 20, new Color(180, 180, 180, 255));
|
||||
}
|
||||
}
|
||||
|
||||
// ======================================================================
|
||||
// Shared UI helpers — intentionally kept inline (no external module) so
|
||||
// each Scene Entry stays self-contained and easy to remove once real UI is
|
||||
// authored in the editor.
|
||||
// ======================================================================
|
||||
|
||||
function ensureCanvasSize(host: Node): void {
|
||||
let ut = host.getComponent(UITransform);
|
||||
if (!ut) ut = host.addComponent(UITransform);
|
||||
ut.setContentSize(DESIGN_WIDTH, DESIGN_HEIGHT);
|
||||
host.setPosition(0, 0, 0);
|
||||
}
|
||||
|
||||
function createLabel(parent: Node, text: string, x: number, y: number, fontSize: number, color: Color): Node {
|
||||
const n = new Node('AutoLabel');
|
||||
n.layer = parent.layer;
|
||||
parent.addChild(n);
|
||||
const ut = n.addComponent(UITransform);
|
||||
ut.setContentSize(DESIGN_WIDTH, fontSize * 1.6);
|
||||
const lb = n.addComponent(Label);
|
||||
lb.useSystemFont = true;
|
||||
lb.string = text;
|
||||
lb.fontSize = fontSize;
|
||||
lb.lineHeight = Math.floor(fontSize * 1.2);
|
||||
lb.color = color;
|
||||
lb.horizontalAlign = 1; // CENTER
|
||||
lb.verticalAlign = 1; // CENTER
|
||||
n.setPosition(new Vec3(x, y, 0));
|
||||
return n;
|
||||
}
|
||||
|
||||
function createButton(
|
||||
parent: Node,
|
||||
text: string,
|
||||
x: number,
|
||||
y: number,
|
||||
w: number,
|
||||
h: number,
|
||||
onClick: () => void
|
||||
): Node {
|
||||
const n = new Node(`Btn_${text}`);
|
||||
n.layer = parent.layer;
|
||||
parent.addChild(n);
|
||||
const ut = n.addComponent(UITransform);
|
||||
ut.setContentSize(w, h);
|
||||
|
||||
// Background drawn via Graphics — avoids needing any texture asset.
|
||||
const g = n.addComponent(Graphics);
|
||||
g.fillColor = new Color(40, 40, 60, 230);
|
||||
g.rect(-w / 2, -h / 2, w, h);
|
||||
g.fill();
|
||||
g.strokeColor = new Color(200, 200, 220, 255);
|
||||
g.lineWidth = 2;
|
||||
g.rect(-w / 2, -h / 2, w, h);
|
||||
g.stroke();
|
||||
|
||||
// Label child for the text.
|
||||
const labelNode = new Node('Label');
|
||||
labelNode.layer = parent.layer;
|
||||
n.addChild(labelNode);
|
||||
const lut = labelNode.addComponent(UITransform);
|
||||
lut.setContentSize(w, h);
|
||||
const lb = labelNode.addComponent(Label);
|
||||
lb.useSystemFont = true;
|
||||
lb.string = text;
|
||||
lb.fontSize = 24;
|
||||
lb.lineHeight = 28;
|
||||
lb.color = Color.WHITE;
|
||||
lb.horizontalAlign = 1;
|
||||
lb.verticalAlign = 1;
|
||||
|
||||
const btn = n.addComponent(Button);
|
||||
btn.transition = Button.Transition.SCALE;
|
||||
btn.target = n;
|
||||
btn.zoomScale = 0.95;
|
||||
n.on(Node.EventType.TOUCH_END, onClick, n);
|
||||
|
||||
n.setPosition(new Vec3(x, y, 0));
|
||||
return n;
|
||||
}
|
||||
|
||||
// Re-export helpers so sibling Scene Entries can reuse them.
|
||||
export { ensureCanvasSize, createLabel, createButton };
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "3dc13e83-a51d-4530-ae5d-1ebceb69550f",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { _decorator, Component, director, Label, Node, Color } from 'cc';
|
||||
import { ChapterSettlement, ISettlementStats } from '../logic/ChapterSettlement';
|
||||
import { ensureCanvasSize, createLabel, createButton } from './MainMenuEntry';
|
||||
import { DESIGN_HEIGHT } from '../common/Constants';
|
||||
|
||||
const { ccclass, property } = _decorator;
|
||||
|
||||
/**
|
||||
* Settlement scene entry (task 8.2 hookup, req 14.x).
|
||||
*
|
||||
* Attach to the root of `Settlement.scene`. All UI is auto-built by default
|
||||
* (title, score, closing line, back-to-menu button). If `autoBuildUI` is
|
||||
* disabled and you wire up `scoreLabelNode` / `closingLabelNode` manually,
|
||||
* those wins.
|
||||
*/
|
||||
@ccclass('SettlementEntry')
|
||||
export class SettlementEntry extends Component {
|
||||
@property({ type: Node, tooltip: '得分 Label 节点 (可留空自动创建)' })
|
||||
public scoreLabelNode: Node | null = null;
|
||||
|
||||
@property({ type: Node, tooltip: '结局旁白 Label 节点 (可留空自动创建)' })
|
||||
public closingLabelNode: Node | null = null;
|
||||
|
||||
@property({ tooltip: '是否自动创建结算界面 (Label + Back 按钮)' })
|
||||
public autoBuildUI: boolean = true;
|
||||
|
||||
protected onLoad(): void {
|
||||
if (this.autoBuildUI) this.buildDefaultUI();
|
||||
|
||||
const defaultStats: ISettlementStats = {
|
||||
totalScore: 0,
|
||||
stageScore: 0,
|
||||
comboCount: 0,
|
||||
flawless: true,
|
||||
remainingTimeSec: 0,
|
||||
};
|
||||
const settlement = new ChapterSettlement(defaultStats);
|
||||
const result = settlement.build();
|
||||
this.setLabel(this.scoreLabelNode, `Stage Score: ${result.stats.stageScore}`);
|
||||
this.setLabel(this.closingLabelNode, result.closingLine);
|
||||
}
|
||||
|
||||
/** Bind to a "Back to Menu" button. */
|
||||
public onReturnToMenu(): void {
|
||||
director.loadScene('MainMenu');
|
||||
}
|
||||
|
||||
private setLabel(node: Node | null, text: string): void {
|
||||
if (!node) return;
|
||||
const label = node.getComponent(Label);
|
||||
if (label) label.string = text;
|
||||
}
|
||||
|
||||
private buildDefaultUI(): void {
|
||||
ensureCanvasSize(this.node);
|
||||
// Title.
|
||||
createLabel(this.node, '章 节 结 算', 0, DESIGN_HEIGHT / 2 - 70, 40, Color.WHITE);
|
||||
// Score label (created only if inspector did not supply one).
|
||||
if (!this.scoreLabelNode) {
|
||||
this.scoreLabelNode = createLabel(this.node, '', 0, 40, 30, new Color(255, 220, 120, 255));
|
||||
}
|
||||
// Closing line label.
|
||||
if (!this.closingLabelNode) {
|
||||
this.closingLabelNode = createLabel(this.node, '', 0, -30, 24, new Color(200, 200, 200, 255));
|
||||
}
|
||||
// Bottom "Back to Menu" button.
|
||||
createButton(this.node, 'Back to Menu', 0, -DESIGN_HEIGHT / 2 + 80, 240, 60, () => this.onReturnToMenu());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "cab3e1e4-d2db-4b2f-bb14-179cfb6243eb",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { _decorator, Component, director, Label, Node, Color, EventTouch, UITransform } from 'cc';
|
||||
import { ConfigMgr } from '../data/ConfigMgr';
|
||||
import { CCJsonLoader } from './CCJsonLoader';
|
||||
import { StorySceneCtrl } from '../ui/StorySceneCtrl';
|
||||
import { ensureCanvasSize, createLabel, createButton } from './MainMenuEntry';
|
||||
import { DESIGN_WIDTH, DESIGN_HEIGHT } from '../common/Constants';
|
||||
|
||||
const { ccclass, property } = _decorator;
|
||||
|
||||
/**
|
||||
* StoryIntro scene entry (task 9.1 hookup, req 19.x).
|
||||
*
|
||||
* Attach to the root of `StoryIntro.scene`. Inspector:
|
||||
* - labelNode: optional — if left empty and `autoBuildUI` is true, a
|
||||
* centered Label and a bottom-right "Skip" button are auto-created.
|
||||
* - storyId: story id in `configs/stories.json` (default `chapter_1_intro`).
|
||||
*
|
||||
* Tap anywhere → accelerate / advance (req 19.3).
|
||||
* "Skip" button → finish immediately (req 19.4).
|
||||
*/
|
||||
@ccclass('StorySceneEntry')
|
||||
export class StorySceneEntry extends Component {
|
||||
@property({ type: Node, tooltip: '打字机 Label 节点 (可留空,自动创建)' })
|
||||
public labelNode: Node | null = null;
|
||||
|
||||
@property({ tooltip: '对应 configs/stories.json 中的 id' })
|
||||
public storyId: string = 'chapter_1_intro';
|
||||
|
||||
@property({ tooltip: '是否自动创建 Label / Skip 按钮 / 背景遮罩' })
|
||||
public autoBuildUI: boolean = true;
|
||||
|
||||
private ctrl: StorySceneCtrl | undefined;
|
||||
|
||||
protected async onLoad(): Promise<void> {
|
||||
if (this.autoBuildUI && !this.labelNode) {
|
||||
this.buildDefaultUI();
|
||||
}
|
||||
// Full-screen tap accelerator: listen on the host node itself.
|
||||
this.node.on(Node.EventType.TOUCH_END, () => this.onTap(), this);
|
||||
|
||||
const cfg = await this.loadStoryConfig();
|
||||
this.ctrl = new StorySceneCtrl(cfg, undefined, {
|
||||
onTextChanged: (text) => this.updateLabel(text),
|
||||
onFinished: () => director.loadScene('Level_1_1'),
|
||||
});
|
||||
const outcome = this.ctrl.start();
|
||||
if (outcome === 'already_seen') {
|
||||
director.loadScene('Level_1_1');
|
||||
}
|
||||
}
|
||||
|
||||
protected update(dt: number): void {
|
||||
this.ctrl?.tick(dt);
|
||||
}
|
||||
|
||||
/** Tap handler (called by auto-built full-screen listener or external). */
|
||||
public onTap(): void {
|
||||
this.ctrl?.onTap();
|
||||
}
|
||||
|
||||
/** Bound to the "Skip" button. */
|
||||
public onSkip(): void {
|
||||
this.ctrl?.onSkip();
|
||||
}
|
||||
|
||||
private updateLabel(text: string): void {
|
||||
if (!this.labelNode) return;
|
||||
const label = this.labelNode.getComponent(Label);
|
||||
if (label) label.string = text;
|
||||
}
|
||||
|
||||
private async loadStoryConfig() {
|
||||
const mgr = new ConfigMgr(new CCJsonLoader());
|
||||
await mgr.load();
|
||||
return mgr.story(this.storyId);
|
||||
}
|
||||
|
||||
private buildDefaultUI(): void {
|
||||
ensureCanvasSize(this.node);
|
||||
// Ensure the root node can receive touch (size = design resolution).
|
||||
// Central typewriter label.
|
||||
this.labelNode = createLabel(this.node, '', 0, 0, 28, Color.WHITE);
|
||||
const ut = this.labelNode.getComponent(UITransform);
|
||||
if (ut) ut.setContentSize(DESIGN_WIDTH - 80, DESIGN_HEIGHT - 120);
|
||||
// Skip button at bottom-right.
|
||||
const skipX = DESIGN_WIDTH / 2 - 90;
|
||||
const skipY = -DESIGN_HEIGHT / 2 + 50;
|
||||
createButton(this.node, 'Skip >>', skipX, skipY, 140, 50, () => this.onSkip());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "2c3b5227-420d-4421-a500-36519776ea9d",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "1.2.0",
|
||||
"importer": "directory",
|
||||
"imported": true,
|
||||
"uuid": "bc3870ea-323d-4754-b96b-d9d0db12b29e",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
import { _decorator, Component, EventTouch, Node, Touch, UITransform, Vec2, Vec3, view } from 'cc';
|
||||
import { globalEventBus, globalLogger } from '../common/index';
|
||||
import { PERF_TOUCH_RESPONSE_MAX_MS } from '../common/Constants';
|
||||
import {
|
||||
ControlId,
|
||||
DEFAULT_LAYOUT,
|
||||
IFloatingLayout,
|
||||
ISafeAreaInsets,
|
||||
MultiTouchRouter,
|
||||
applySafeArea,
|
||||
classifyDirection,
|
||||
joystickDirection,
|
||||
} from './InputModel';
|
||||
import { InputEvents } from './InputEvents';
|
||||
|
||||
const { ccclass, property } = _decorator;
|
||||
|
||||
/**
|
||||
* View component for the floating control layer.
|
||||
*
|
||||
* Responsibilities (req 1.x, 20.1):
|
||||
* - Subscribe to all touch events on a full-screen UI node.
|
||||
* - Translate device coordinates into landscape-design coordinates.
|
||||
* - Delegate hit-testing / dead-zone / angle classification to `InputModel`
|
||||
* (platform-agnostic, already unit-tested under Jest).
|
||||
* - Emit high-level input events through `globalEventBus`.
|
||||
* - Record touch→response latency to `globalLogger` for QA (req 20.1).
|
||||
*
|
||||
* IMPORTANT: This class intentionally avoids any gameplay logic so that the
|
||||
* player controller (task 4.x) can be swapped / re-tested without touching
|
||||
* the input layer.
|
||||
*/
|
||||
@ccclass('FloatingControlLayer')
|
||||
export class FloatingControlLayer extends Component {
|
||||
@property({ tooltip: 'The root node of the joystick visual (bg + handle).' })
|
||||
public joystickRoot: Node | null = null;
|
||||
|
||||
@property({ tooltip: 'The root node of the jump button visual.' })
|
||||
public jumpRoot: Node | null = null;
|
||||
|
||||
@property({ tooltip: 'The root node of the shuriken button visual.' })
|
||||
public shurikenRoot: Node | null = null;
|
||||
|
||||
@property({ tooltip: 'The root node of the ninja-sword button visual.' })
|
||||
public ninjaSwordRoot: Node | null = null;
|
||||
|
||||
private layout: IFloatingLayout = DEFAULT_LAYOUT;
|
||||
private router: MultiTouchRouter = new MultiTouchRouter(DEFAULT_LAYOUT);
|
||||
|
||||
protected onLoad(): void {
|
||||
this.applyInitialLayout();
|
||||
this.bindTouchEvents();
|
||||
}
|
||||
|
||||
protected onDestroy(): void {
|
||||
this.unbindTouchEvents();
|
||||
}
|
||||
|
||||
/** Public API — called by `UIFlowMgr` when safe-area changes. */
|
||||
public updateSafeArea(insets: ISafeAreaInsets): void {
|
||||
this.layout = applySafeArea(DEFAULT_LAYOUT, insets);
|
||||
this.router = new MultiTouchRouter(this.layout);
|
||||
this.syncLayoutToNodes();
|
||||
}
|
||||
|
||||
/** Public API — replace the layout (used by the layout-customisation flow, task 3.2). */
|
||||
public setLayout(layout: IFloatingLayout): void {
|
||||
this.layout = layout;
|
||||
this.router = new MultiTouchRouter(layout);
|
||||
this.syncLayoutToNodes();
|
||||
}
|
||||
|
||||
public getLayout(): IFloatingLayout {
|
||||
return this.layout;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// internals
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
private applyInitialLayout(): void {
|
||||
// On first frame the engine gives us a visibleSize in real pixels;
|
||||
// we derive insets from it so the landscape 960x540 baseline still
|
||||
// maps correctly into a notched screen (req 1.7).
|
||||
const size = view.getVisibleSize();
|
||||
// Heuristic: if the device is wider than 16:9 we add insets on both
|
||||
// left/right to keep controls in the safe area.
|
||||
const baselineRatio = 16 / 9;
|
||||
const actualRatio = size.width / size.height;
|
||||
let leftInset = 0;
|
||||
let rightInset = 0;
|
||||
if (actualRatio > baselineRatio) {
|
||||
const extra = (actualRatio - baselineRatio) * size.height;
|
||||
leftInset = extra / 2 / (size.width / 960);
|
||||
rightInset = leftInset;
|
||||
}
|
||||
this.updateSafeArea({ left: leftInset, right: rightInset, top: 0, bottom: 0 });
|
||||
}
|
||||
|
||||
private bindTouchEvents(): void {
|
||||
this.node.on(Node.EventType.TOUCH_START, this.onTouchStart, this);
|
||||
this.node.on(Node.EventType.TOUCH_MOVE, this.onTouchMove, this);
|
||||
this.node.on(Node.EventType.TOUCH_END, this.onTouchEnd, this);
|
||||
this.node.on(Node.EventType.TOUCH_CANCEL, this.onTouchEnd, this);
|
||||
}
|
||||
|
||||
private unbindTouchEvents(): void {
|
||||
this.node.off(Node.EventType.TOUCH_START, this.onTouchStart, this);
|
||||
this.node.off(Node.EventType.TOUCH_MOVE, this.onTouchMove, this);
|
||||
this.node.off(Node.EventType.TOUCH_END, this.onTouchEnd, this);
|
||||
this.node.off(Node.EventType.TOUCH_CANCEL, this.onTouchEnd, this);
|
||||
}
|
||||
|
||||
private onTouchStart(ev: EventTouch): void {
|
||||
const t = ev.getUILocation();
|
||||
const start = FloatingControlLayer.now();
|
||||
const touchId = this.touchId(ev);
|
||||
const hit = this.router.begin(touchId, t.x, t.y, start);
|
||||
this.recordLatency('input/touchStart', start);
|
||||
if (!hit) {
|
||||
// Let the touch fall through to the gameplay layer (req 1.3).
|
||||
return;
|
||||
}
|
||||
ev.propagationStopped = true;
|
||||
switch (hit) {
|
||||
case ControlId.Jump:
|
||||
globalEventBus.emit(InputEvents.JumpPressed, {});
|
||||
break;
|
||||
case ControlId.Shuriken:
|
||||
globalEventBus.emit(InputEvents.ShurikenPressed, {});
|
||||
break;
|
||||
case ControlId.NinjaSword:
|
||||
globalEventBus.emit(InputEvents.NinjaSwordPressed, {});
|
||||
break;
|
||||
case ControlId.Joystick:
|
||||
this.broadcastJoystick(t.x, t.y);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private onTouchMove(ev: EventTouch): void {
|
||||
const t = ev.getUILocation();
|
||||
const touchId = this.touchId(ev);
|
||||
const bound = this.router.move(touchId, t.x, t.y);
|
||||
if (bound === ControlId.Joystick) {
|
||||
this.broadcastJoystick(t.x, t.y);
|
||||
}
|
||||
}
|
||||
|
||||
private onTouchEnd(ev: EventTouch): void {
|
||||
const touchId = this.touchId(ev);
|
||||
const end = FloatingControlLayer.now();
|
||||
const bound = this.router.end(touchId);
|
||||
if (!bound) return;
|
||||
switch (bound) {
|
||||
case ControlId.Jump: {
|
||||
const slotStart = this.lastStartTs.get(touchId);
|
||||
const hold = slotStart !== undefined ? end - slotStart : 0;
|
||||
globalEventBus.emit(InputEvents.JumpReleased, { holdMs: hold });
|
||||
break;
|
||||
}
|
||||
case ControlId.Shuriken:
|
||||
globalEventBus.emit(InputEvents.ShurikenReleased, {});
|
||||
break;
|
||||
case ControlId.NinjaSword:
|
||||
globalEventBus.emit(InputEvents.NinjaSwordReleased, {});
|
||||
break;
|
||||
case ControlId.Joystick:
|
||||
globalEventBus.emit(InputEvents.JoystickMove, { dx: 0, dy: 0, klass: 'none' });
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
this.lastStartTs.delete(touchId);
|
||||
}
|
||||
|
||||
private broadcastJoystick(x: number, y: number): void {
|
||||
const dir = joystickDirection(this.layout, x, y);
|
||||
const klass = classifyDirection(dir);
|
||||
globalEventBus.emit(InputEvents.JoystickMove, { dx: dir.x, dy: dir.y, klass });
|
||||
}
|
||||
|
||||
/** Mirror layout geometry onto the bound visual nodes. */
|
||||
private syncLayoutToNodes(): void {
|
||||
this.placeNode(this.joystickRoot, this.layout.joystick.cx, this.layout.joystick.cy);
|
||||
this.placeNode(this.jumpRoot, this.layout.jump.cx, this.layout.jump.cy);
|
||||
this.placeNode(this.shurikenRoot, this.layout.shuriken.cx, this.layout.shuriken.cy);
|
||||
this.placeNode(this.ninjaSwordRoot, this.layout.ninjaSword.cx, this.layout.ninjaSword.cy);
|
||||
}
|
||||
|
||||
private placeNode(node: Node | null, cx: number, cy: number): void {
|
||||
if (!node) return;
|
||||
// Landscape design coordinates: origin at bottom-left of 960x540.
|
||||
const worldX = cx - 480;
|
||||
const worldY = cy - 270;
|
||||
node.setPosition(new Vec3(worldX, worldY, 0));
|
||||
const ui = node.getComponent(UITransform);
|
||||
if (ui) ui.setAnchorPoint(new Vec2(0.5, 0.5));
|
||||
}
|
||||
|
||||
/** Map a Cocos `Touch` object to a stable numeric id we can track. */
|
||||
private readonly lastStartTs = new Map<number, number>();
|
||||
private touchId(ev: EventTouch): number {
|
||||
const touch: Touch | null = ev.touch;
|
||||
const id = touch ? touch.getID() : 0;
|
||||
if (!this.lastStartTs.has(id)) {
|
||||
this.lastStartTs.set(id, FloatingControlLayer.now());
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
/** Capture input→emit latency into the perf metric store (req 20.1). */
|
||||
private recordLatency(name: string, start: number): void {
|
||||
const elapsed = FloatingControlLayer.now() - start;
|
||||
globalLogger.metric({ name, value: elapsed });
|
||||
if (elapsed > PERF_TOUCH_RESPONSE_MAX_MS) {
|
||||
globalLogger.warn(
|
||||
'Input',
|
||||
`latency ${elapsed.toFixed(1)}ms exceeds ${PERF_TOUCH_RESPONSE_MAX_MS}ms target`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private static now(): number {
|
||||
return typeof performance !== 'undefined' && typeof performance.now === 'function'
|
||||
? performance.now()
|
||||
: Date.now();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "135fb141-7a56-4376-a09f-9e991ac191bf",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* Event constants emitted by the floating control layer and consumed by the
|
||||
* gameplay layer. Centralising them avoids typo-driven wiring bugs and gives
|
||||
* Jest a place to assert against expected strings.
|
||||
*/
|
||||
|
||||
export const InputEvents = {
|
||||
/** payload: `{ dx: number; dy: number; klass: JoystickAngleClass }` */
|
||||
JoystickMove: 'input/joystickMove',
|
||||
/** payload: `{}` — jump button went down (req 2.2). */
|
||||
JumpPressed: 'input/jumpPressed',
|
||||
/** payload: `{ holdMs: number }` — jump button released. */
|
||||
JumpReleased: 'input/jumpReleased',
|
||||
/** payload: `{}` — shuriken button down. */
|
||||
ShurikenPressed: 'input/shurikenPressed',
|
||||
ShurikenReleased: 'input/shurikenReleased',
|
||||
/** payload: `{}` — ninja sword button down. */
|
||||
NinjaSwordPressed: 'input/ninjaSwordPressed',
|
||||
NinjaSwordReleased: 'input/ninjaSwordReleased',
|
||||
/**
|
||||
* payload: `{ id: 'jump' | 'shuriken' | 'ninja_sword'; disabled: boolean; reason?: string }`
|
||||
* — button must repaint (e.g. airborne → jump disabled, req 2.4).
|
||||
*/
|
||||
ButtonVisualChanged: 'input/buttonVisualChanged',
|
||||
} as const;
|
||||
|
||||
export type InputEventName = (typeof InputEvents)[keyof typeof InputEvents];
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "b1386e5b-3b36-4b68-9854-d41efb7f7dc7",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
/**
|
||||
* Input model for the floating control layer.
|
||||
*
|
||||
* This module is intentionally **free of `cc` dependencies** so that:
|
||||
* - 45°/135° parabolic recognition (req 2.5, 20.3)
|
||||
* - joystick dead-zone (req 1.5)
|
||||
* - safe-area adaptation (req 1.7, 18.6)
|
||||
* - multi-touch routing (req 1.3, 1.8)
|
||||
*
|
||||
* can all be unit-tested under Jest with deterministic coordinates.
|
||||
*
|
||||
* The Cocos Creator view layer (`FloatingControlLayer.ts`) is a thin adapter
|
||||
* that forwards `TouchEvent` data into this model and renders whatever the
|
||||
* model reports.
|
||||
*/
|
||||
|
||||
import {
|
||||
DESIGN_WIDTH,
|
||||
DESIGN_HEIGHT,
|
||||
PARABOLIC_ANGLE_RIGHT,
|
||||
PARABOLIC_ANGLE_LEFT,
|
||||
PARABOLIC_ANGLE_TOLERANCE,
|
||||
} from '../common/Constants';
|
||||
|
||||
/** Control IDs addressable by the HUD. */
|
||||
export enum ControlId {
|
||||
Joystick = 'joystick',
|
||||
Jump = 'jump',
|
||||
Shuriken = 'shuriken',
|
||||
NinjaSword = 'ninja_sword',
|
||||
}
|
||||
|
||||
/**
|
||||
* A rectangular region defined in **landscape design coordinates**
|
||||
* (origin at bottom-left, width=960, height=540).
|
||||
*
|
||||
* ┌─────────────────────────────┐
|
||||
* │ │
|
||||
* │ game world │
|
||||
* │ │
|
||||
* │ [joy] [S][K]│ ← joystick bottom-left, attacks bottom-right
|
||||
* │ [J] │ ← jump above joystick-right
|
||||
* └─────────────────────────────┘
|
||||
*/
|
||||
export interface IHitRect {
|
||||
/** x of the rect's center, in design pixels. */
|
||||
cx: number;
|
||||
/** y of the rect's center, in design pixels. */
|
||||
cy: number;
|
||||
/** Full width (design px). */
|
||||
w: number;
|
||||
/** Full height (design px). */
|
||||
h: number;
|
||||
}
|
||||
|
||||
/** Landscape default layout — requirement 1.1. */
|
||||
export interface IFloatingLayout {
|
||||
joystick: IHitRect;
|
||||
jump: IHitRect;
|
||||
shuriken: IHitRect;
|
||||
ninjaSword: IHitRect;
|
||||
/** Dead-zone radius inside the joystick (req 1.5). */
|
||||
joystickDeadzone: number;
|
||||
/** Default opacity (0-1). Req 1.1 specifies 0.7. */
|
||||
opacity: number;
|
||||
}
|
||||
|
||||
export const DEFAULT_LAYOUT: IFloatingLayout = {
|
||||
// Left-third safe area: joystick and jump stacked (req 1.1)
|
||||
joystick: { cx: 120, cy: 100, w: 120, h: 120 },
|
||||
jump: { cx: 235, cy: 180, w: 90, h: 90 },
|
||||
|
||||
// Right-third safe area: two attack buttons side-by-side (req 1.1)
|
||||
shuriken: { cx: DESIGN_WIDTH - 195, cy: 100, w: 90, h: 90 },
|
||||
ninjaSword: { cx: DESIGN_WIDTH - 85, cy: 100, w: 90, h: 90 },
|
||||
|
||||
joystickDeadzone: 10,
|
||||
opacity: 0.7,
|
||||
};
|
||||
|
||||
/** Direction vector, already normalised (or zero). */
|
||||
export interface IDirection {
|
||||
x: number;
|
||||
y: number;
|
||||
/** Magnitude of the raw vector **before** normalisation. */
|
||||
magnitude: number;
|
||||
}
|
||||
|
||||
export const ZERO_DIRECTION: IDirection = Object.freeze({ x: 0, y: 0, magnitude: 0 });
|
||||
|
||||
/**
|
||||
* Classification of a joystick direction relative to the parabolic trigger.
|
||||
*
|
||||
* - `none` — inside dead-zone.
|
||||
* - `horizontal` — left/right movement, vertical jump allowed.
|
||||
* - `parabolic_right` — ~45°, triggers ↗ parabolic jump (req 2.5).
|
||||
* - `parabolic_left` — ~135°, triggers ↖ parabolic jump (req 2.5).
|
||||
* - `other` — any other 2D vector.
|
||||
*/
|
||||
export type JoystickAngleClass =
|
||||
| 'none'
|
||||
| 'horizontal'
|
||||
| 'parabolic_right'
|
||||
| 'parabolic_left'
|
||||
| 'other';
|
||||
|
||||
/** Clamp `v` to [min, max]. */
|
||||
export function clamp(v: number, min: number, max: number): number {
|
||||
return v < min ? min : v > max ? max : v;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if `(x, y)` lies inside `rect`. Origin is bottom-left.
|
||||
* Used by both `isInside` and the touch router.
|
||||
*/
|
||||
export function isInsideRect(rect: IHitRect, x: number, y: number): boolean {
|
||||
const halfW = rect.w / 2;
|
||||
const halfH = rect.h / 2;
|
||||
return Math.abs(x - rect.cx) <= halfW && Math.abs(y - rect.cy) <= halfH;
|
||||
}
|
||||
|
||||
/** Which control (if any) is hit at `(x, y)`. Priority: attack > jump > joystick. */
|
||||
export function hitTest(layout: IFloatingLayout, x: number, y: number): ControlId | null {
|
||||
if (isInsideRect(layout.ninjaSword, x, y)) return ControlId.NinjaSword;
|
||||
if (isInsideRect(layout.shuriken, x, y)) return ControlId.Shuriken;
|
||||
if (isInsideRect(layout.jump, x, y)) return ControlId.Jump;
|
||||
if (isInsideRect(layout.joystick, x, y)) return ControlId.Joystick;
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute a joystick direction vector from a touch point. Touches **outside**
|
||||
* the joystick disc still map to a direction: we use the offset from the
|
||||
* joystick centre (requirement 1.4). Inside the dead-zone the result is zero.
|
||||
*/
|
||||
export function joystickDirection(layout: IFloatingLayout, touchX: number, touchY: number): IDirection {
|
||||
const dx = touchX - layout.joystick.cx;
|
||||
const dy = touchY - layout.joystick.cy;
|
||||
const mag = Math.hypot(dx, dy);
|
||||
if (mag < layout.joystickDeadzone) {
|
||||
return ZERO_DIRECTION;
|
||||
}
|
||||
return { x: dx / mag, y: dy / mag, magnitude: mag };
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a direction vector into an `JoystickAngleClass` bucket.
|
||||
*
|
||||
* The canonical angles are:
|
||||
* - 0° → right
|
||||
* - 90° → up
|
||||
* - 180° → left
|
||||
*
|
||||
* Parabolic trigger windows are 45°±15° and 135°±15° (req 2.5 + tolerance
|
||||
* picked to stay within req 20.3's ≥95% recognition rate).
|
||||
*/
|
||||
export function classifyDirection(dir: IDirection): JoystickAngleClass {
|
||||
if (dir.magnitude === 0) return 'none';
|
||||
// atan2 returns [-PI, PI]. Convert to [0, 360).
|
||||
let deg = (Math.atan2(dir.y, dir.x) * 180) / Math.PI;
|
||||
if (deg < 0) deg += 360;
|
||||
// Pure horizontal (≤ ~10° off x-axis) treated as `horizontal`.
|
||||
if (deg <= 10 || deg >= 350 || (deg >= 170 && deg <= 190)) return 'horizontal';
|
||||
if (Math.abs(deg - PARABOLIC_ANGLE_RIGHT) <= PARABOLIC_ANGLE_TOLERANCE) return 'parabolic_right';
|
||||
if (Math.abs(deg - PARABOLIC_ANGLE_LEFT) <= PARABOLIC_ANGLE_TOLERANCE) return 'parabolic_left';
|
||||
return 'other';
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Safe-area adaptation — requirement 1.7, 18.6
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Screen aspect ratios handled without letterboxing (req 1.7). */
|
||||
export interface ISafeAreaInsets {
|
||||
/** Px added on the left edge to avoid notches / sensors. */
|
||||
left: number;
|
||||
right: number;
|
||||
top: number;
|
||||
bottom: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a shifted copy of `layout` that respects the given safe-area
|
||||
* insets. The joystick group slides **rightwards** by `insets.left`; the
|
||||
* attack group slides **leftwards** by `insets.right`; vertical shifts are
|
||||
* symmetric. This keeps every control inside the device safe area without
|
||||
* changing the relative geometry.
|
||||
*/
|
||||
export function applySafeArea(layout: IFloatingLayout, insets: ISafeAreaInsets): IFloatingLayout {
|
||||
const shiftLeftGroup = (r: IHitRect): IHitRect => ({
|
||||
...r,
|
||||
cx: r.cx + insets.left,
|
||||
cy: clamp(r.cy + insets.bottom, r.h / 2, DESIGN_HEIGHT - insets.top - r.h / 2),
|
||||
});
|
||||
const shiftRightGroup = (r: IHitRect): IHitRect => ({
|
||||
...r,
|
||||
cx: r.cx - insets.right,
|
||||
cy: clamp(r.cy + insets.bottom, r.h / 2, DESIGN_HEIGHT - insets.top - r.h / 2),
|
||||
});
|
||||
return {
|
||||
...layout,
|
||||
joystick: shiftLeftGroup(layout.joystick),
|
||||
jump: shiftLeftGroup(layout.jump),
|
||||
shuriken: shiftRightGroup(layout.shuriken),
|
||||
ninjaSword: shiftRightGroup(layout.ninjaSword),
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Multi-touch router — requirement 1.3, 1.8
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Payload stored per active finger. */
|
||||
interface TouchSlot {
|
||||
control: ControlId | null;
|
||||
x: number;
|
||||
y: number;
|
||||
/** Timestamp (ms) captured on touchstart — used for combo recognition. */
|
||||
startTs: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tracks all currently-down fingers and routes each to the appropriate
|
||||
* control. Events that miss every button fall through to the game-world
|
||||
* layer by reporting `control === null` (requirement 1.3).
|
||||
*/
|
||||
export class MultiTouchRouter {
|
||||
private readonly slots = new Map<number, TouchSlot>();
|
||||
|
||||
constructor(private readonly layout: IFloatingLayout) {}
|
||||
|
||||
/** Begin tracking a new finger. Returns the hit control (or null). */
|
||||
public begin(id: number, x: number, y: number, ts: number): ControlId | null {
|
||||
const control = hitTest(this.layout, x, y);
|
||||
this.slots.set(id, { control, x, y, startTs: ts });
|
||||
return control;
|
||||
}
|
||||
|
||||
/** Update an in-flight finger. Returns the same control it bound to. */
|
||||
public move(id: number, x: number, y: number): ControlId | null {
|
||||
const slot = this.slots.get(id);
|
||||
if (!slot) return null;
|
||||
slot.x = x;
|
||||
slot.y = y;
|
||||
return slot.control;
|
||||
}
|
||||
|
||||
/** Release a finger. Returns the control it was bound to. */
|
||||
public end(id: number): ControlId | null {
|
||||
const slot = this.slots.get(id);
|
||||
this.slots.delete(id);
|
||||
return slot?.control ?? null;
|
||||
}
|
||||
|
||||
/** Returns the joystick slot (if any finger is currently driving it). */
|
||||
public joystickSlot(): TouchSlot | undefined {
|
||||
for (const s of this.slots.values()) {
|
||||
if (s.control === ControlId.Joystick) return s;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Convenience — is this control currently pressed? */
|
||||
public isPressed(control: ControlId): boolean {
|
||||
for (const s of this.slots.values()) {
|
||||
if (s.control === control) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Returns how many simultaneous fingers are currently tracked. */
|
||||
public get activeTouchCount(): number {
|
||||
return this.slots.size;
|
||||
}
|
||||
|
||||
/** Returns the earliest-pressed start timestamp among currently-active controls. */
|
||||
public earliestPressTs(controls: ControlId[]): number | undefined {
|
||||
let best: number | undefined;
|
||||
for (const s of this.slots.values()) {
|
||||
if (!s.control || !controls.includes(s.control)) continue;
|
||||
if (best === undefined || s.startTs < best) best = s.startTs;
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
public clear(): void {
|
||||
this.slots.clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "1feee932-4b48-4e7b-8dfa-6bf1fcec491b",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import { DEFAULT_LAYOUT, IFloatingLayout, IHitRect } from './InputModel';
|
||||
import { StorageMgr, globalStorageMgr } from '../common/StorageMgr';
|
||||
import { STORAGE_KEY } from '../common/Constants';
|
||||
|
||||
/**
|
||||
* Persisted representation of the user's custom control layout.
|
||||
*
|
||||
* We intentionally persist **deltas** on top of the design-baseline
|
||||
* `DEFAULT_LAYOUT` rather than absolute positions:
|
||||
*
|
||||
* - Keeps old save data forward-compatible when we retune the baseline.
|
||||
* - Keeps the stored blob under ~100 bytes (well within the 17.x budget).
|
||||
*
|
||||
* Requirement traceability:
|
||||
* - req 1.6 — long-press customisation mode stores this payload.
|
||||
* - req 17.2 — persists across sessions.
|
||||
* - req 17.6 — any parse failure must fall back to the default, not crash.
|
||||
*/
|
||||
export interface ILayoutDelta {
|
||||
/** Offset applied on top of the default rect, in landscape design px. */
|
||||
joystickOffset: { dx: number; dy: number };
|
||||
jumpOffset: { dx: number; dy: number };
|
||||
shurikenOffset: { dx: number; dy: number };
|
||||
ninjaSwordOffset: { dx: number; dy: number };
|
||||
/** Multipliers applied to default `w`/`h`. Clamped to 0.7 — 1.4. */
|
||||
buttonSizeScale: number;
|
||||
/** UI opacity 0.3 — 1.0 (req 1.1 default 0.7). */
|
||||
opacity: number;
|
||||
}
|
||||
|
||||
export const DEFAULT_LAYOUT_DELTA: ILayoutDelta = {
|
||||
joystickOffset: { dx: 0, dy: 0 },
|
||||
jumpOffset: { dx: 0, dy: 0 },
|
||||
shurikenOffset: { dx: 0, dy: 0 },
|
||||
ninjaSwordOffset: { dx: 0, dy: 0 },
|
||||
buttonSizeScale: 1.0,
|
||||
opacity: 0.7,
|
||||
};
|
||||
|
||||
/** Numeric clamps enforced on any delta the user (or stale storage) gives us. */
|
||||
export const LAYOUT_DELTA_BOUNDS = {
|
||||
offsetPxMax: 240,
|
||||
sizeScaleMin: 0.7,
|
||||
sizeScaleMax: 1.4,
|
||||
opacityMin: 0.3,
|
||||
opacityMax: 1.0,
|
||||
} as const;
|
||||
|
||||
/** Clamp + sanitise a raw delta object received from storage. */
|
||||
export function sanitiseLayoutDelta(raw: Partial<ILayoutDelta> | null | undefined): ILayoutDelta {
|
||||
if (!raw || typeof raw !== 'object') {
|
||||
return { ...DEFAULT_LAYOUT_DELTA };
|
||||
}
|
||||
const clamp = (v: number, lo: number, hi: number): number => {
|
||||
if (typeof v !== 'number' || Number.isNaN(v)) return (lo + hi) / 2;
|
||||
return v < lo ? lo : v > hi ? hi : v;
|
||||
};
|
||||
const clampOffset = (o?: { dx?: number; dy?: number }) => ({
|
||||
dx: clamp(o?.dx ?? 0, -LAYOUT_DELTA_BOUNDS.offsetPxMax, LAYOUT_DELTA_BOUNDS.offsetPxMax),
|
||||
dy: clamp(o?.dy ?? 0, -LAYOUT_DELTA_BOUNDS.offsetPxMax, LAYOUT_DELTA_BOUNDS.offsetPxMax),
|
||||
});
|
||||
return {
|
||||
joystickOffset: clampOffset(raw.joystickOffset),
|
||||
jumpOffset: clampOffset(raw.jumpOffset),
|
||||
shurikenOffset: clampOffset(raw.shurikenOffset),
|
||||
ninjaSwordOffset: clampOffset(raw.ninjaSwordOffset),
|
||||
buttonSizeScale: clamp(
|
||||
raw.buttonSizeScale ?? 1,
|
||||
LAYOUT_DELTA_BOUNDS.sizeScaleMin,
|
||||
LAYOUT_DELTA_BOUNDS.sizeScaleMax
|
||||
),
|
||||
opacity: clamp(raw.opacity ?? 0.7, LAYOUT_DELTA_BOUNDS.opacityMin, LAYOUT_DELTA_BOUNDS.opacityMax),
|
||||
};
|
||||
}
|
||||
|
||||
/** Apply a sanitised delta on top of the baseline default layout. */
|
||||
export function applyLayoutDelta(baseline: IFloatingLayout, delta: ILayoutDelta): IFloatingLayout {
|
||||
const offsetRect = (r: IHitRect, off: { dx: number; dy: number }): IHitRect => ({
|
||||
cx: r.cx + off.dx,
|
||||
cy: r.cy + off.dy,
|
||||
w: r.w * delta.buttonSizeScale,
|
||||
h: r.h * delta.buttonSizeScale,
|
||||
});
|
||||
return {
|
||||
joystick: offsetRect(baseline.joystick, delta.joystickOffset),
|
||||
jump: offsetRect(baseline.jump, delta.jumpOffset),
|
||||
shuriken: offsetRect(baseline.shuriken, delta.shurikenOffset),
|
||||
ninjaSword: offsetRect(baseline.ninjaSword, delta.ninjaSwordOffset),
|
||||
joystickDeadzone: baseline.joystickDeadzone,
|
||||
opacity: delta.opacity,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Thin adapter over `StorageMgr` that handles the `kl_control_layout` key.
|
||||
* The adapter always produces a valid `IFloatingLayout` — even when the
|
||||
* underlying storage is corrupted (req 17.6).
|
||||
*/
|
||||
export class LayoutCustomizer {
|
||||
constructor(
|
||||
private readonly baseline: IFloatingLayout = DEFAULT_LAYOUT,
|
||||
private readonly storage: StorageMgr = globalStorageMgr
|
||||
) {}
|
||||
|
||||
/** Load the saved delta (or default) and produce the concrete layout. */
|
||||
public loadLayout(): { layout: IFloatingLayout; delta: ILayoutDelta } {
|
||||
const raw = this.storage.get<Partial<ILayoutDelta> | null>(STORAGE_KEY.ControlLayout, null);
|
||||
const delta = sanitiseLayoutDelta(raw);
|
||||
return { layout: applyLayoutDelta(this.baseline, delta), delta };
|
||||
}
|
||||
|
||||
/** Persist the given delta after sanitising it. */
|
||||
public saveDelta(delta: ILayoutDelta): void {
|
||||
this.storage.set(STORAGE_KEY.ControlLayout, sanitiseLayoutDelta(delta));
|
||||
}
|
||||
|
||||
/** Reset the layout back to the landscape default. */
|
||||
public reset(): void {
|
||||
this.storage.remove(STORAGE_KEY.ControlLayout);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "d66e41d9-2102-4866-864d-163852b8ffd0",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
import { IStorySceneConfig, IStoryPageConfig } from '../data/Interfaces';
|
||||
import { StorageMgr, globalStorageMgr } from '../common/StorageMgr';
|
||||
import { STORAGE_KEY } from '../common/Constants';
|
||||
|
||||
/**
|
||||
* Story-intro cutscene controller (task 9.1, req 19.1 — 19.9).
|
||||
*
|
||||
* Responsibilities:
|
||||
* 1. Decide whether the intro must play (first-time gate, req 19.5).
|
||||
* 2. Drive the 3-page typewriter sequence (req 19.2-19.3).
|
||||
* 3. Honour taps (speed up printing) and "Skip" (immediate dismiss, req 19.4).
|
||||
* 4. Persist the "seen" flag so it plays only once (req 19.5).
|
||||
* 5. Provide a `reset()` API the Settings menu calls (req 19.6).
|
||||
*
|
||||
* The view layer binds `onTextChanged` / `onFinished` to render text and
|
||||
* trigger the next scene load.
|
||||
*/
|
||||
|
||||
export type StoryPhase = 'idle' | 'typing' | 'waiting_next' | 'finished';
|
||||
|
||||
export interface IStorySceneCallbacks {
|
||||
onTextChanged?: (text: string, page: IStoryPageConfig) => void;
|
||||
onPageEntered?: (page: IStoryPageConfig) => void;
|
||||
onFinished?: (skipped: boolean) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* How many characters per real-time second a page types out. Boosts to
|
||||
* `FAST_MULTIPLIER` while the user is tapping (req 19.3).
|
||||
*/
|
||||
export const BASE_TYPING_CPS = 30;
|
||||
export const FAST_MULTIPLIER = 4;
|
||||
|
||||
export class StorySceneCtrl {
|
||||
private phase: StoryPhase = 'idle';
|
||||
private pageIndex = 0;
|
||||
private cursor = 0;
|
||||
private elapsedSecOnPage = 0;
|
||||
private typingFast = false;
|
||||
|
||||
constructor(
|
||||
private readonly scene: IStorySceneConfig,
|
||||
private readonly storage: StorageMgr = globalStorageMgr,
|
||||
private readonly callbacks: IStorySceneCallbacks = {}
|
||||
) {}
|
||||
|
||||
/** Returns true if the user has already seen / skipped the intro. */
|
||||
public hasBeenSeen(): boolean {
|
||||
return this.storage.get<boolean>(STORAGE_KEY.StoryIntroSeen, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Called by the boot flow. If the intro was already consumed, the view
|
||||
* should skip straight to the next scene; otherwise this begins playback.
|
||||
*/
|
||||
public start(): 'playing' | 'already_seen' {
|
||||
if (this.hasBeenSeen()) {
|
||||
return 'already_seen';
|
||||
}
|
||||
this.phase = 'typing';
|
||||
this.pageIndex = 0;
|
||||
this.cursor = 0;
|
||||
this.elapsedSecOnPage = 0;
|
||||
this.typingFast = false;
|
||||
this.callbacks.onPageEntered?.(this.currentPage());
|
||||
this.emitText();
|
||||
return 'playing';
|
||||
}
|
||||
|
||||
/** Call every frame with real-time delta. */
|
||||
public tick(dtSec: number): void {
|
||||
if (this.phase !== 'typing') return;
|
||||
this.elapsedSecOnPage += dtSec;
|
||||
const page = this.currentPage();
|
||||
const cps = BASE_TYPING_CPS * (this.typingFast ? FAST_MULTIPLIER : 1);
|
||||
const targetCursor = Math.floor(this.elapsedSecOnPage * cps);
|
||||
if (targetCursor !== this.cursor) {
|
||||
this.cursor = Math.min(targetCursor, page.text.length);
|
||||
this.emitText();
|
||||
}
|
||||
if (this.cursor >= page.text.length) {
|
||||
this.phase = 'waiting_next';
|
||||
}
|
||||
}
|
||||
|
||||
/** Tap anywhere — speed up typewriter or advance to next page (req 19.3). */
|
||||
public onTap(): void {
|
||||
if (this.phase === 'typing') {
|
||||
// First tap: reveal full page immediately (req 19.3 "accelerate").
|
||||
const page = this.currentPage();
|
||||
this.cursor = page.text.length;
|
||||
this.emitText();
|
||||
this.phase = 'waiting_next';
|
||||
return;
|
||||
}
|
||||
if (this.phase === 'waiting_next') {
|
||||
this.advancePage();
|
||||
}
|
||||
}
|
||||
|
||||
/** Skip button pressed — immediate dismissal (req 19.4). */
|
||||
public onSkip(): void {
|
||||
if (this.phase === 'finished') return;
|
||||
this.markSeen();
|
||||
this.phase = 'finished';
|
||||
this.callbacks.onFinished?.(true);
|
||||
}
|
||||
|
||||
/** Called by the Settings screen to re-enable the intro (req 19.6). */
|
||||
public reset(): void {
|
||||
this.storage.remove(STORAGE_KEY.StoryIntroSeen);
|
||||
}
|
||||
|
||||
/** Expose current page for HUD rendering. */
|
||||
public get currentPageNumber(): number {
|
||||
return this.pageIndex + 1;
|
||||
}
|
||||
|
||||
/** Current visible text on the active page. */
|
||||
public get visibleText(): string {
|
||||
return this.currentPage().text.slice(0, this.cursor);
|
||||
}
|
||||
|
||||
public get status(): StoryPhase {
|
||||
return this.phase;
|
||||
}
|
||||
|
||||
public get totalPages(): number {
|
||||
return this.scene.pages.length;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
|
||||
private currentPage(): IStoryPageConfig {
|
||||
return this.scene.pages[this.pageIndex];
|
||||
}
|
||||
|
||||
private emitText(): void {
|
||||
this.callbacks.onTextChanged?.(this.visibleText, this.currentPage());
|
||||
}
|
||||
|
||||
private advancePage(): void {
|
||||
if (this.pageIndex < this.scene.pages.length - 1) {
|
||||
this.pageIndex++;
|
||||
this.cursor = 0;
|
||||
this.elapsedSecOnPage = 0;
|
||||
this.phase = 'typing';
|
||||
this.typingFast = false;
|
||||
this.callbacks.onPageEntered?.(this.currentPage());
|
||||
this.emitText();
|
||||
} else {
|
||||
// Last page complete → finish naturally.
|
||||
this.markSeen();
|
||||
this.phase = 'finished';
|
||||
this.callbacks.onFinished?.(false);
|
||||
}
|
||||
}
|
||||
|
||||
private markSeen(): void {
|
||||
this.storage.set(STORAGE_KEY.StoryIntroSeen, true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "43ba3070-8f9a-436c-802f-71ed7bfab8eb",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import { StorageMgr, globalStorageMgr } from '../common/StorageMgr';
|
||||
import { STORAGE_KEY } from '../common/Constants';
|
||||
|
||||
/**
|
||||
* UIFlowMgr — scene-flow state machine (task 9.2, req 12.7-12.8, 13.1).
|
||||
*
|
||||
* It does **not** perform `director.loadScene()` itself; the Cocos view layer
|
||||
* subscribes to `onSceneEnter` and performs the actual scene swap. Keeping
|
||||
* the flow engine-agnostic makes it trivial to Jest-test every boot path,
|
||||
* every settlement path, and the new story-intro gate (req 19.x).
|
||||
*
|
||||
* Decision D-4 / req 13.1 guardrail:
|
||||
* The `showDifficultyPicker()` action simply does not exist; and
|
||||
* `availableSettingsEntries()` purposefully omits it. A future contributor
|
||||
* who tries to add a "difficulty" key will hit a TypeScript compile-error
|
||||
* because the union `SettingsKey` is exhaustive.
|
||||
*/
|
||||
|
||||
export type SceneId =
|
||||
| 'boot'
|
||||
| 'story_intro'
|
||||
| 'main_menu'
|
||||
| 'level_select'
|
||||
| 'gameplay'
|
||||
| 'settlement'
|
||||
| 'settings';
|
||||
|
||||
export type SettingsKey =
|
||||
| 'audio_volume'
|
||||
| 'layout_customisation'
|
||||
| 'replay_tutorial'
|
||||
| 'replay_story_intro';
|
||||
|
||||
export interface ISceneEnter {
|
||||
scene: SceneId;
|
||||
/** Optional payload (e.g. `{ levelId: '1-1' }` for gameplay). */
|
||||
payload?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface IUIFlowCallbacks {
|
||||
onSceneEnter?: (ev: ISceneEnter) => void;
|
||||
}
|
||||
|
||||
export class UIFlowMgr {
|
||||
private current: SceneId = 'boot';
|
||||
|
||||
constructor(
|
||||
private readonly storage: StorageMgr = globalStorageMgr,
|
||||
private readonly callbacks: IUIFlowCallbacks = {}
|
||||
) {}
|
||||
|
||||
public get currentScene(): SceneId {
|
||||
return this.current;
|
||||
}
|
||||
|
||||
/** Invoked by `GameBoot.start()` once engine is ready. */
|
||||
public onBoot(): void {
|
||||
if (this.storage.get<boolean>(STORAGE_KEY.StoryIntroSeen, false)) {
|
||||
this.enter('main_menu');
|
||||
} else {
|
||||
this.enter('story_intro');
|
||||
}
|
||||
}
|
||||
|
||||
/** Called by StorySceneCtrl.onFinished(). */
|
||||
public onStoryFinished(): void {
|
||||
this.enter('gameplay', { levelId: '1-1' });
|
||||
}
|
||||
|
||||
/** Main menu → level select. */
|
||||
public onPressStartGame(): void {
|
||||
// First-time "Start Game" may jump through the story again if we
|
||||
// ever reset; otherwise go to level select.
|
||||
if (!this.storage.get<boolean>(STORAGE_KEY.StoryIntroSeen, false)) {
|
||||
this.enter('story_intro');
|
||||
} else {
|
||||
this.enter('level_select');
|
||||
}
|
||||
}
|
||||
|
||||
public onPickLevel(levelId: string): void {
|
||||
this.enter('gameplay', { levelId });
|
||||
}
|
||||
|
||||
public onOpenSettings(): void {
|
||||
this.enter('settings');
|
||||
}
|
||||
|
||||
public onCloseSettings(): void {
|
||||
this.enter('main_menu');
|
||||
}
|
||||
|
||||
public onLevelCleared(nextLevelId: string | null): void {
|
||||
if (nextLevelId) {
|
||||
this.enter('settlement', { nextLevelId });
|
||||
} else {
|
||||
// After final boss settlement, back to the main menu.
|
||||
this.enter('settlement', { isChapterEnd: true });
|
||||
}
|
||||
}
|
||||
|
||||
public onSettlementContinue(nextLevelId?: string): void {
|
||||
if (nextLevelId) this.enter('gameplay', { levelId: nextLevelId });
|
||||
else this.enter('main_menu');
|
||||
}
|
||||
|
||||
public onPlayerDied(currentLevelId: string): void {
|
||||
this.enter('settlement', { levelId: currentLevelId, dead: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Exhaustive list of settings entries available in the Settings scene.
|
||||
* Purposefully omits any difficulty-selection entry (req 13.1).
|
||||
*/
|
||||
public availableSettingsEntries(): ReadonlyArray<SettingsKey> {
|
||||
return ['audio_volume', 'layout_customisation', 'replay_tutorial', 'replay_story_intro'];
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
|
||||
private enter(scene: SceneId, payload?: Record<string, unknown>): void {
|
||||
this.current = scene;
|
||||
this.callbacks.onSceneEnter?.({ scene, payload });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "0cab93e7-afa4-407b-93ef-67686fcb7110",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* UI layer — floating control layer, layout persistence, story cutscene,
|
||||
* scene-flow manager, HUD, main menu.
|
||||
*/
|
||||
|
||||
export * from './InputModel';
|
||||
export * from './InputEvents';
|
||||
export * from './LayoutCustomizer';
|
||||
export * from './FloatingControlLayer';
|
||||
export * from './StorySceneCtrl';
|
||||
export * from './UIFlowMgr';
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "7be3334f-8d77-4356-b1ae-1ae930a5db4d",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
Reference in New Issue
Block a user