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
+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": {}
}