first commmit

This commit is contained in:
jakciehan
2026-05-06 08:17:32 +08:00
commit 427a33c55b
269 changed files with 20906 additions and 0 deletions
+91
View File
@@ -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;
}
}
+9
View File
@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "319b4227-1397-4aa8-882e-e69a74e4bf7d",
"files": [],
"subMetas": {},
"userData": {}
}
+9
View File
@@ -0,0 +1,9 @@
{
"ver": "1.2.0",
"importer": "directory",
"imported": true,
"uuid": "3ea8f4b3-59f2-48dd-aff8-38bf7ec19f1e",
"files": [],
"subMetas": {},
"userData": {}
}
+101
View File
@@ -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;
+9
View File
@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "e42a8542-158c-4965-96fd-4b0cb6457b41",
"files": [],
"subMetas": {},
"userData": {}
}
+115
View File
@@ -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();
+9
View File
@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "459040db-2675-4865-969c-fba50c3a40e1",
"files": [],
"subMetas": {},
"userData": {}
}
+170
View File
@@ -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();
+9
View File
@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "6d7e102a-3d02-4d19-b555-17a7b19c965b",
"files": [],
"subMetas": {},
"userData": {}
}
+115
View File
@@ -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,
};
}
}
+9
View File
@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "42063546-8ae6-4255-be58-f1d3f180ea42",
"files": [],
"subMetas": {},
"userData": {}
}
+105
View File
@@ -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": {}
}
+123
View File
@@ -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();
+9
View File
@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "580d04ad-4749-4b5a-ba2c-5da77a73126a",
"files": [],
"subMetas": {},
"userData": {}
}
+76
View File
@@ -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();
+9
View File
@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "cefd7ec2-a0d2-4494-b87d-981dcda9d5b2",
"files": [],
"subMetas": {},
"userData": {}
}
+13
View File
@@ -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';
+9
View File
@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "b4c9b9e1-0b45-41d2-a05d-eda1098969a1",
"files": [],
"subMetas": {},
"userData": {}
}
+9
View File
@@ -0,0 +1,9 @@
{
"ver": "1.2.0",
"importer": "directory",
"imported": true,
"uuid": "8d3f10f6-9875-461e-9d0f-1965bdc2f91a",
"files": [],
"subMetas": {},
"userData": {}
}
+239
View File
@@ -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`);
}
}
}
}
}
+9
View File
@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "9877a4f9-e13f-412f-a572-dfe501faba39",
"files": [],
"subMetas": {},
"userData": {}
}
+228
View File
@@ -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,
};
+9
View File
@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "f26544af-4281-4c60-b85e-1cf89aadda32",
"files": [],
"subMetas": {},
"userData": {}
}
+8
View File
@@ -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';
+9
View File
@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "d907f3f3-211b-46eb-be38-01e62ae11409",
"files": [],
"subMetas": {},
"userData": {}
}
+9
View File
@@ -0,0 +1,9 @@
{
"ver": "1.2.0",
"importer": "directory",
"imported": true,
"uuid": "d6003e00-00cd-4b56-b944-0bc3c53e50dc",
"files": [],
"subMetas": {},
"userData": {}
}
+149
View File
@@ -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": {}
}
+107
View File
@@ -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": {}
}
+98
View File
@@ -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": {}
}
+73
View File
@@ -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": {}
}
+51
View File
@@ -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": {}
}
+89
View File
@@ -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;
}
}
+9
View File
@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "0027692e-e7b0-4146-a401-25842bc5d1c0",
"files": [],
"subMetas": {},
"userData": {}
}
+233
View File
@@ -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
);
}
}
+9
View File
@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "2a16b768-32a1-48f3-8456-7f63c6ac109d",
"files": [],
"subMetas": {},
"userData": {}
}
+143
View File
@@ -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": {}
}
+107
View File
@@ -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;
}
}
+9
View File
@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "97238e1a-49db-41c8-9d3f-60a510388814",
"files": [],
"subMetas": {},
"userData": {}
}
+166
View File
@@ -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": {}
}
+145
View File
@@ -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": {}
}
+105
View File
@@ -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,
};
}
}
+9
View File
@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "96dc60f4-e45f-426a-8832-c36a6662f45f",
"files": [],
"subMetas": {},
"userData": {}
}
+118
View File
@@ -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);
}
}
+9
View File
@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "e1bd54ae-07af-4ad3-8085-24c1a88bdce0",
"files": [],
"subMetas": {},
"userData": {}
}
+17
View File
@@ -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';
+9
View File
@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "a0972eb0-c1b0-4e73-adff-5481e47d3b19",
"files": [],
"subMetas": {},
"userData": {}
}
+9
View File
@@ -0,0 +1,9 @@
{
"ver": "1.2.0",
"importer": "directory",
"imported": true,
"uuid": "38cdf707-78a1-4404-924c-12ba494e24a9",
"files": [],
"subMetas": {},
"userData": {}
}
+76
View File
@@ -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": {}
}
+9
View File
@@ -0,0 +1,9 @@
{
"ver": "1.2.0",
"importer": "directory",
"imported": true,
"uuid": "bc3870ea-323d-4754-b96b-d9d0db12b29e",
"files": [],
"subMetas": {},
"userData": {}
}
+231
View File
@@ -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": {}
}
+27
View File
@@ -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];
+9
View File
@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "b1386e5b-3b36-4b68-9854-d41efb7f7dc7",
"files": [],
"subMetas": {},
"userData": {}
}
+289
View File
@@ -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();
}
}
+9
View File
@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "1feee932-4b48-4e7b-8dfa-6bf1fcec491b",
"files": [],
"subMetas": {},
"userData": {}
}
+121
View File
@@ -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": {}
}
+162
View File
@@ -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);
}
}
+9
View File
@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "43ba3070-8f9a-436c-802f-71ed7bfab8eb",
"files": [],
"subMetas": {},
"userData": {}
}
+125
View File
@@ -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 });
}
}
+9
View File
@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "0cab93e7-afa4-407b-93ef-67686fcb7110",
"files": [],
"subMetas": {},
"userData": {}
}
+11
View File
@@ -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';
+9
View File
@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "7be3334f-8d77-4356-b1ae-1ae930a5db4d",
"files": [],
"subMetas": {},
"userData": {}
}