first commmit
This commit is contained in:
@@ -0,0 +1,231 @@
|
||||
import { _decorator, Component, EventTouch, Node, Touch, UITransform, Vec2, Vec3, view } from 'cc';
|
||||
import { globalEventBus, globalLogger } from '../common/index';
|
||||
import { PERF_TOUCH_RESPONSE_MAX_MS } from '../common/Constants';
|
||||
import {
|
||||
ControlId,
|
||||
DEFAULT_LAYOUT,
|
||||
IFloatingLayout,
|
||||
ISafeAreaInsets,
|
||||
MultiTouchRouter,
|
||||
applySafeArea,
|
||||
classifyDirection,
|
||||
joystickDirection,
|
||||
} from './InputModel';
|
||||
import { InputEvents } from './InputEvents';
|
||||
|
||||
const { ccclass, property } = _decorator;
|
||||
|
||||
/**
|
||||
* View component for the floating control layer.
|
||||
*
|
||||
* Responsibilities (req 1.x, 20.1):
|
||||
* - Subscribe to all touch events on a full-screen UI node.
|
||||
* - Translate device coordinates into landscape-design coordinates.
|
||||
* - Delegate hit-testing / dead-zone / angle classification to `InputModel`
|
||||
* (platform-agnostic, already unit-tested under Jest).
|
||||
* - Emit high-level input events through `globalEventBus`.
|
||||
* - Record touch→response latency to `globalLogger` for QA (req 20.1).
|
||||
*
|
||||
* IMPORTANT: This class intentionally avoids any gameplay logic so that the
|
||||
* player controller (task 4.x) can be swapped / re-tested without touching
|
||||
* the input layer.
|
||||
*/
|
||||
@ccclass('FloatingControlLayer')
|
||||
export class FloatingControlLayer extends Component {
|
||||
@property({ tooltip: 'The root node of the joystick visual (bg + handle).' })
|
||||
public joystickRoot: Node | null = null;
|
||||
|
||||
@property({ tooltip: 'The root node of the jump button visual.' })
|
||||
public jumpRoot: Node | null = null;
|
||||
|
||||
@property({ tooltip: 'The root node of the shuriken button visual.' })
|
||||
public shurikenRoot: Node | null = null;
|
||||
|
||||
@property({ tooltip: 'The root node of the ninja-sword button visual.' })
|
||||
public ninjaSwordRoot: Node | null = null;
|
||||
|
||||
private layout: IFloatingLayout = DEFAULT_LAYOUT;
|
||||
private router: MultiTouchRouter = new MultiTouchRouter(DEFAULT_LAYOUT);
|
||||
|
||||
protected onLoad(): void {
|
||||
this.applyInitialLayout();
|
||||
this.bindTouchEvents();
|
||||
}
|
||||
|
||||
protected onDestroy(): void {
|
||||
this.unbindTouchEvents();
|
||||
}
|
||||
|
||||
/** Public API — called by `UIFlowMgr` when safe-area changes. */
|
||||
public updateSafeArea(insets: ISafeAreaInsets): void {
|
||||
this.layout = applySafeArea(DEFAULT_LAYOUT, insets);
|
||||
this.router = new MultiTouchRouter(this.layout);
|
||||
this.syncLayoutToNodes();
|
||||
}
|
||||
|
||||
/** Public API — replace the layout (used by the layout-customisation flow, task 3.2). */
|
||||
public setLayout(layout: IFloatingLayout): void {
|
||||
this.layout = layout;
|
||||
this.router = new MultiTouchRouter(layout);
|
||||
this.syncLayoutToNodes();
|
||||
}
|
||||
|
||||
public getLayout(): IFloatingLayout {
|
||||
return this.layout;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// internals
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
private applyInitialLayout(): void {
|
||||
// On first frame the engine gives us a visibleSize in real pixels;
|
||||
// we derive insets from it so the landscape 960x540 baseline still
|
||||
// maps correctly into a notched screen (req 1.7).
|
||||
const size = view.getVisibleSize();
|
||||
// Heuristic: if the device is wider than 16:9 we add insets on both
|
||||
// left/right to keep controls in the safe area.
|
||||
const baselineRatio = 16 / 9;
|
||||
const actualRatio = size.width / size.height;
|
||||
let leftInset = 0;
|
||||
let rightInset = 0;
|
||||
if (actualRatio > baselineRatio) {
|
||||
const extra = (actualRatio - baselineRatio) * size.height;
|
||||
leftInset = extra / 2 / (size.width / 960);
|
||||
rightInset = leftInset;
|
||||
}
|
||||
this.updateSafeArea({ left: leftInset, right: rightInset, top: 0, bottom: 0 });
|
||||
}
|
||||
|
||||
private bindTouchEvents(): void {
|
||||
this.node.on(Node.EventType.TOUCH_START, this.onTouchStart, this);
|
||||
this.node.on(Node.EventType.TOUCH_MOVE, this.onTouchMove, this);
|
||||
this.node.on(Node.EventType.TOUCH_END, this.onTouchEnd, this);
|
||||
this.node.on(Node.EventType.TOUCH_CANCEL, this.onTouchEnd, this);
|
||||
}
|
||||
|
||||
private unbindTouchEvents(): void {
|
||||
this.node.off(Node.EventType.TOUCH_START, this.onTouchStart, this);
|
||||
this.node.off(Node.EventType.TOUCH_MOVE, this.onTouchMove, this);
|
||||
this.node.off(Node.EventType.TOUCH_END, this.onTouchEnd, this);
|
||||
this.node.off(Node.EventType.TOUCH_CANCEL, this.onTouchEnd, this);
|
||||
}
|
||||
|
||||
private onTouchStart(ev: EventTouch): void {
|
||||
const t = ev.getUILocation();
|
||||
const start = FloatingControlLayer.now();
|
||||
const touchId = this.touchId(ev);
|
||||
const hit = this.router.begin(touchId, t.x, t.y, start);
|
||||
this.recordLatency('input/touchStart', start);
|
||||
if (!hit) {
|
||||
// Let the touch fall through to the gameplay layer (req 1.3).
|
||||
return;
|
||||
}
|
||||
ev.propagationStopped = true;
|
||||
switch (hit) {
|
||||
case ControlId.Jump:
|
||||
globalEventBus.emit(InputEvents.JumpPressed, {});
|
||||
break;
|
||||
case ControlId.Shuriken:
|
||||
globalEventBus.emit(InputEvents.ShurikenPressed, {});
|
||||
break;
|
||||
case ControlId.NinjaSword:
|
||||
globalEventBus.emit(InputEvents.NinjaSwordPressed, {});
|
||||
break;
|
||||
case ControlId.Joystick:
|
||||
this.broadcastJoystick(t.x, t.y);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private onTouchMove(ev: EventTouch): void {
|
||||
const t = ev.getUILocation();
|
||||
const touchId = this.touchId(ev);
|
||||
const bound = this.router.move(touchId, t.x, t.y);
|
||||
if (bound === ControlId.Joystick) {
|
||||
this.broadcastJoystick(t.x, t.y);
|
||||
}
|
||||
}
|
||||
|
||||
private onTouchEnd(ev: EventTouch): void {
|
||||
const touchId = this.touchId(ev);
|
||||
const end = FloatingControlLayer.now();
|
||||
const bound = this.router.end(touchId);
|
||||
if (!bound) return;
|
||||
switch (bound) {
|
||||
case ControlId.Jump: {
|
||||
const slotStart = this.lastStartTs.get(touchId);
|
||||
const hold = slotStart !== undefined ? end - slotStart : 0;
|
||||
globalEventBus.emit(InputEvents.JumpReleased, { holdMs: hold });
|
||||
break;
|
||||
}
|
||||
case ControlId.Shuriken:
|
||||
globalEventBus.emit(InputEvents.ShurikenReleased, {});
|
||||
break;
|
||||
case ControlId.NinjaSword:
|
||||
globalEventBus.emit(InputEvents.NinjaSwordReleased, {});
|
||||
break;
|
||||
case ControlId.Joystick:
|
||||
globalEventBus.emit(InputEvents.JoystickMove, { dx: 0, dy: 0, klass: 'none' });
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
this.lastStartTs.delete(touchId);
|
||||
}
|
||||
|
||||
private broadcastJoystick(x: number, y: number): void {
|
||||
const dir = joystickDirection(this.layout, x, y);
|
||||
const klass = classifyDirection(dir);
|
||||
globalEventBus.emit(InputEvents.JoystickMove, { dx: dir.x, dy: dir.y, klass });
|
||||
}
|
||||
|
||||
/** Mirror layout geometry onto the bound visual nodes. */
|
||||
private syncLayoutToNodes(): void {
|
||||
this.placeNode(this.joystickRoot, this.layout.joystick.cx, this.layout.joystick.cy);
|
||||
this.placeNode(this.jumpRoot, this.layout.jump.cx, this.layout.jump.cy);
|
||||
this.placeNode(this.shurikenRoot, this.layout.shuriken.cx, this.layout.shuriken.cy);
|
||||
this.placeNode(this.ninjaSwordRoot, this.layout.ninjaSword.cx, this.layout.ninjaSword.cy);
|
||||
}
|
||||
|
||||
private placeNode(node: Node | null, cx: number, cy: number): void {
|
||||
if (!node) return;
|
||||
// Landscape design coordinates: origin at bottom-left of 960x540.
|
||||
const worldX = cx - 480;
|
||||
const worldY = cy - 270;
|
||||
node.setPosition(new Vec3(worldX, worldY, 0));
|
||||
const ui = node.getComponent(UITransform);
|
||||
if (ui) ui.setAnchorPoint(new Vec2(0.5, 0.5));
|
||||
}
|
||||
|
||||
/** Map a Cocos `Touch` object to a stable numeric id we can track. */
|
||||
private readonly lastStartTs = new Map<number, number>();
|
||||
private touchId(ev: EventTouch): number {
|
||||
const touch: Touch | null = ev.touch;
|
||||
const id = touch ? touch.getID() : 0;
|
||||
if (!this.lastStartTs.has(id)) {
|
||||
this.lastStartTs.set(id, FloatingControlLayer.now());
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
/** Capture input→emit latency into the perf metric store (req 20.1). */
|
||||
private recordLatency(name: string, start: number): void {
|
||||
const elapsed = FloatingControlLayer.now() - start;
|
||||
globalLogger.metric({ name, value: elapsed });
|
||||
if (elapsed > PERF_TOUCH_RESPONSE_MAX_MS) {
|
||||
globalLogger.warn(
|
||||
'Input',
|
||||
`latency ${elapsed.toFixed(1)}ms exceeds ${PERF_TOUCH_RESPONSE_MAX_MS}ms target`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private static now(): number {
|
||||
return typeof performance !== 'undefined' && typeof performance.now === 'function'
|
||||
? performance.now()
|
||||
: Date.now();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "135fb141-7a56-4376-a09f-9e991ac191bf",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* Event constants emitted by the floating control layer and consumed by the
|
||||
* gameplay layer. Centralising them avoids typo-driven wiring bugs and gives
|
||||
* Jest a place to assert against expected strings.
|
||||
*/
|
||||
|
||||
export const InputEvents = {
|
||||
/** payload: `{ dx: number; dy: number; klass: JoystickAngleClass }` */
|
||||
JoystickMove: 'input/joystickMove',
|
||||
/** payload: `{}` — jump button went down (req 2.2). */
|
||||
JumpPressed: 'input/jumpPressed',
|
||||
/** payload: `{ holdMs: number }` — jump button released. */
|
||||
JumpReleased: 'input/jumpReleased',
|
||||
/** payload: `{}` — shuriken button down. */
|
||||
ShurikenPressed: 'input/shurikenPressed',
|
||||
ShurikenReleased: 'input/shurikenReleased',
|
||||
/** payload: `{}` — ninja sword button down. */
|
||||
NinjaSwordPressed: 'input/ninjaSwordPressed',
|
||||
NinjaSwordReleased: 'input/ninjaSwordReleased',
|
||||
/**
|
||||
* payload: `{ id: 'jump' | 'shuriken' | 'ninja_sword'; disabled: boolean; reason?: string }`
|
||||
* — button must repaint (e.g. airborne → jump disabled, req 2.4).
|
||||
*/
|
||||
ButtonVisualChanged: 'input/buttonVisualChanged',
|
||||
} as const;
|
||||
|
||||
export type InputEventName = (typeof InputEvents)[keyof typeof InputEvents];
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "b1386e5b-3b36-4b68-9854-d41efb7f7dc7",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
/**
|
||||
* Input model for the floating control layer.
|
||||
*
|
||||
* This module is intentionally **free of `cc` dependencies** so that:
|
||||
* - 45°/135° parabolic recognition (req 2.5, 20.3)
|
||||
* - joystick dead-zone (req 1.5)
|
||||
* - safe-area adaptation (req 1.7, 18.6)
|
||||
* - multi-touch routing (req 1.3, 1.8)
|
||||
*
|
||||
* can all be unit-tested under Jest with deterministic coordinates.
|
||||
*
|
||||
* The Cocos Creator view layer (`FloatingControlLayer.ts`) is a thin adapter
|
||||
* that forwards `TouchEvent` data into this model and renders whatever the
|
||||
* model reports.
|
||||
*/
|
||||
|
||||
import {
|
||||
DESIGN_WIDTH,
|
||||
DESIGN_HEIGHT,
|
||||
PARABOLIC_ANGLE_RIGHT,
|
||||
PARABOLIC_ANGLE_LEFT,
|
||||
PARABOLIC_ANGLE_TOLERANCE,
|
||||
} from '../common/Constants';
|
||||
|
||||
/** Control IDs addressable by the HUD. */
|
||||
export enum ControlId {
|
||||
Joystick = 'joystick',
|
||||
Jump = 'jump',
|
||||
Shuriken = 'shuriken',
|
||||
NinjaSword = 'ninja_sword',
|
||||
}
|
||||
|
||||
/**
|
||||
* A rectangular region defined in **landscape design coordinates**
|
||||
* (origin at bottom-left, width=960, height=540).
|
||||
*
|
||||
* ┌─────────────────────────────┐
|
||||
* │ │
|
||||
* │ game world │
|
||||
* │ │
|
||||
* │ [joy] [S][K]│ ← joystick bottom-left, attacks bottom-right
|
||||
* │ [J] │ ← jump above joystick-right
|
||||
* └─────────────────────────────┘
|
||||
*/
|
||||
export interface IHitRect {
|
||||
/** x of the rect's center, in design pixels. */
|
||||
cx: number;
|
||||
/** y of the rect's center, in design pixels. */
|
||||
cy: number;
|
||||
/** Full width (design px). */
|
||||
w: number;
|
||||
/** Full height (design px). */
|
||||
h: number;
|
||||
}
|
||||
|
||||
/** Landscape default layout — requirement 1.1. */
|
||||
export interface IFloatingLayout {
|
||||
joystick: IHitRect;
|
||||
jump: IHitRect;
|
||||
shuriken: IHitRect;
|
||||
ninjaSword: IHitRect;
|
||||
/** Dead-zone radius inside the joystick (req 1.5). */
|
||||
joystickDeadzone: number;
|
||||
/** Default opacity (0-1). Req 1.1 specifies 0.7. */
|
||||
opacity: number;
|
||||
}
|
||||
|
||||
export const DEFAULT_LAYOUT: IFloatingLayout = {
|
||||
// Left-third safe area: joystick and jump stacked (req 1.1)
|
||||
joystick: { cx: 120, cy: 100, w: 120, h: 120 },
|
||||
jump: { cx: 235, cy: 180, w: 90, h: 90 },
|
||||
|
||||
// Right-third safe area: two attack buttons side-by-side (req 1.1)
|
||||
shuriken: { cx: DESIGN_WIDTH - 195, cy: 100, w: 90, h: 90 },
|
||||
ninjaSword: { cx: DESIGN_WIDTH - 85, cy: 100, w: 90, h: 90 },
|
||||
|
||||
joystickDeadzone: 10,
|
||||
opacity: 0.7,
|
||||
};
|
||||
|
||||
/** Direction vector, already normalised (or zero). */
|
||||
export interface IDirection {
|
||||
x: number;
|
||||
y: number;
|
||||
/** Magnitude of the raw vector **before** normalisation. */
|
||||
magnitude: number;
|
||||
}
|
||||
|
||||
export const ZERO_DIRECTION: IDirection = Object.freeze({ x: 0, y: 0, magnitude: 0 });
|
||||
|
||||
/**
|
||||
* Classification of a joystick direction relative to the parabolic trigger.
|
||||
*
|
||||
* - `none` — inside dead-zone.
|
||||
* - `horizontal` — left/right movement, vertical jump allowed.
|
||||
* - `parabolic_right` — ~45°, triggers ↗ parabolic jump (req 2.5).
|
||||
* - `parabolic_left` — ~135°, triggers ↖ parabolic jump (req 2.5).
|
||||
* - `other` — any other 2D vector.
|
||||
*/
|
||||
export type JoystickAngleClass =
|
||||
| 'none'
|
||||
| 'horizontal'
|
||||
| 'parabolic_right'
|
||||
| 'parabolic_left'
|
||||
| 'other';
|
||||
|
||||
/** Clamp `v` to [min, max]. */
|
||||
export function clamp(v: number, min: number, max: number): number {
|
||||
return v < min ? min : v > max ? max : v;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if `(x, y)` lies inside `rect`. Origin is bottom-left.
|
||||
* Used by both `isInside` and the touch router.
|
||||
*/
|
||||
export function isInsideRect(rect: IHitRect, x: number, y: number): boolean {
|
||||
const halfW = rect.w / 2;
|
||||
const halfH = rect.h / 2;
|
||||
return Math.abs(x - rect.cx) <= halfW && Math.abs(y - rect.cy) <= halfH;
|
||||
}
|
||||
|
||||
/** Which control (if any) is hit at `(x, y)`. Priority: attack > jump > joystick. */
|
||||
export function hitTest(layout: IFloatingLayout, x: number, y: number): ControlId | null {
|
||||
if (isInsideRect(layout.ninjaSword, x, y)) return ControlId.NinjaSword;
|
||||
if (isInsideRect(layout.shuriken, x, y)) return ControlId.Shuriken;
|
||||
if (isInsideRect(layout.jump, x, y)) return ControlId.Jump;
|
||||
if (isInsideRect(layout.joystick, x, y)) return ControlId.Joystick;
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute a joystick direction vector from a touch point. Touches **outside**
|
||||
* the joystick disc still map to a direction: we use the offset from the
|
||||
* joystick centre (requirement 1.4). Inside the dead-zone the result is zero.
|
||||
*/
|
||||
export function joystickDirection(layout: IFloatingLayout, touchX: number, touchY: number): IDirection {
|
||||
const dx = touchX - layout.joystick.cx;
|
||||
const dy = touchY - layout.joystick.cy;
|
||||
const mag = Math.hypot(dx, dy);
|
||||
if (mag < layout.joystickDeadzone) {
|
||||
return ZERO_DIRECTION;
|
||||
}
|
||||
return { x: dx / mag, y: dy / mag, magnitude: mag };
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a direction vector into an `JoystickAngleClass` bucket.
|
||||
*
|
||||
* The canonical angles are:
|
||||
* - 0° → right
|
||||
* - 90° → up
|
||||
* - 180° → left
|
||||
*
|
||||
* Parabolic trigger windows are 45°±15° and 135°±15° (req 2.5 + tolerance
|
||||
* picked to stay within req 20.3's ≥95% recognition rate).
|
||||
*/
|
||||
export function classifyDirection(dir: IDirection): JoystickAngleClass {
|
||||
if (dir.magnitude === 0) return 'none';
|
||||
// atan2 returns [-PI, PI]. Convert to [0, 360).
|
||||
let deg = (Math.atan2(dir.y, dir.x) * 180) / Math.PI;
|
||||
if (deg < 0) deg += 360;
|
||||
// Pure horizontal (≤ ~10° off x-axis) treated as `horizontal`.
|
||||
if (deg <= 10 || deg >= 350 || (deg >= 170 && deg <= 190)) return 'horizontal';
|
||||
if (Math.abs(deg - PARABOLIC_ANGLE_RIGHT) <= PARABOLIC_ANGLE_TOLERANCE) return 'parabolic_right';
|
||||
if (Math.abs(deg - PARABOLIC_ANGLE_LEFT) <= PARABOLIC_ANGLE_TOLERANCE) return 'parabolic_left';
|
||||
return 'other';
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Safe-area adaptation — requirement 1.7, 18.6
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Screen aspect ratios handled without letterboxing (req 1.7). */
|
||||
export interface ISafeAreaInsets {
|
||||
/** Px added on the left edge to avoid notches / sensors. */
|
||||
left: number;
|
||||
right: number;
|
||||
top: number;
|
||||
bottom: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a shifted copy of `layout` that respects the given safe-area
|
||||
* insets. The joystick group slides **rightwards** by `insets.left`; the
|
||||
* attack group slides **leftwards** by `insets.right`; vertical shifts are
|
||||
* symmetric. This keeps every control inside the device safe area without
|
||||
* changing the relative geometry.
|
||||
*/
|
||||
export function applySafeArea(layout: IFloatingLayout, insets: ISafeAreaInsets): IFloatingLayout {
|
||||
const shiftLeftGroup = (r: IHitRect): IHitRect => ({
|
||||
...r,
|
||||
cx: r.cx + insets.left,
|
||||
cy: clamp(r.cy + insets.bottom, r.h / 2, DESIGN_HEIGHT - insets.top - r.h / 2),
|
||||
});
|
||||
const shiftRightGroup = (r: IHitRect): IHitRect => ({
|
||||
...r,
|
||||
cx: r.cx - insets.right,
|
||||
cy: clamp(r.cy + insets.bottom, r.h / 2, DESIGN_HEIGHT - insets.top - r.h / 2),
|
||||
});
|
||||
return {
|
||||
...layout,
|
||||
joystick: shiftLeftGroup(layout.joystick),
|
||||
jump: shiftLeftGroup(layout.jump),
|
||||
shuriken: shiftRightGroup(layout.shuriken),
|
||||
ninjaSword: shiftRightGroup(layout.ninjaSword),
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Multi-touch router — requirement 1.3, 1.8
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Payload stored per active finger. */
|
||||
interface TouchSlot {
|
||||
control: ControlId | null;
|
||||
x: number;
|
||||
y: number;
|
||||
/** Timestamp (ms) captured on touchstart — used for combo recognition. */
|
||||
startTs: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tracks all currently-down fingers and routes each to the appropriate
|
||||
* control. Events that miss every button fall through to the game-world
|
||||
* layer by reporting `control === null` (requirement 1.3).
|
||||
*/
|
||||
export class MultiTouchRouter {
|
||||
private readonly slots = new Map<number, TouchSlot>();
|
||||
|
||||
constructor(private readonly layout: IFloatingLayout) {}
|
||||
|
||||
/** Begin tracking a new finger. Returns the hit control (or null). */
|
||||
public begin(id: number, x: number, y: number, ts: number): ControlId | null {
|
||||
const control = hitTest(this.layout, x, y);
|
||||
this.slots.set(id, { control, x, y, startTs: ts });
|
||||
return control;
|
||||
}
|
||||
|
||||
/** Update an in-flight finger. Returns the same control it bound to. */
|
||||
public move(id: number, x: number, y: number): ControlId | null {
|
||||
const slot = this.slots.get(id);
|
||||
if (!slot) return null;
|
||||
slot.x = x;
|
||||
slot.y = y;
|
||||
return slot.control;
|
||||
}
|
||||
|
||||
/** Release a finger. Returns the control it was bound to. */
|
||||
public end(id: number): ControlId | null {
|
||||
const slot = this.slots.get(id);
|
||||
this.slots.delete(id);
|
||||
return slot?.control ?? null;
|
||||
}
|
||||
|
||||
/** Returns the joystick slot (if any finger is currently driving it). */
|
||||
public joystickSlot(): TouchSlot | undefined {
|
||||
for (const s of this.slots.values()) {
|
||||
if (s.control === ControlId.Joystick) return s;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Convenience — is this control currently pressed? */
|
||||
public isPressed(control: ControlId): boolean {
|
||||
for (const s of this.slots.values()) {
|
||||
if (s.control === control) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Returns how many simultaneous fingers are currently tracked. */
|
||||
public get activeTouchCount(): number {
|
||||
return this.slots.size;
|
||||
}
|
||||
|
||||
/** Returns the earliest-pressed start timestamp among currently-active controls. */
|
||||
public earliestPressTs(controls: ControlId[]): number | undefined {
|
||||
let best: number | undefined;
|
||||
for (const s of this.slots.values()) {
|
||||
if (!s.control || !controls.includes(s.control)) continue;
|
||||
if (best === undefined || s.startTs < best) best = s.startTs;
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
public clear(): void {
|
||||
this.slots.clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "1feee932-4b48-4e7b-8dfa-6bf1fcec491b",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import { DEFAULT_LAYOUT, IFloatingLayout, IHitRect } from './InputModel';
|
||||
import { StorageMgr, globalStorageMgr } from '../common/StorageMgr';
|
||||
import { STORAGE_KEY } from '../common/Constants';
|
||||
|
||||
/**
|
||||
* Persisted representation of the user's custom control layout.
|
||||
*
|
||||
* We intentionally persist **deltas** on top of the design-baseline
|
||||
* `DEFAULT_LAYOUT` rather than absolute positions:
|
||||
*
|
||||
* - Keeps old save data forward-compatible when we retune the baseline.
|
||||
* - Keeps the stored blob under ~100 bytes (well within the 17.x budget).
|
||||
*
|
||||
* Requirement traceability:
|
||||
* - req 1.6 — long-press customisation mode stores this payload.
|
||||
* - req 17.2 — persists across sessions.
|
||||
* - req 17.6 — any parse failure must fall back to the default, not crash.
|
||||
*/
|
||||
export interface ILayoutDelta {
|
||||
/** Offset applied on top of the default rect, in landscape design px. */
|
||||
joystickOffset: { dx: number; dy: number };
|
||||
jumpOffset: { dx: number; dy: number };
|
||||
shurikenOffset: { dx: number; dy: number };
|
||||
ninjaSwordOffset: { dx: number; dy: number };
|
||||
/** Multipliers applied to default `w`/`h`. Clamped to 0.7 — 1.4. */
|
||||
buttonSizeScale: number;
|
||||
/** UI opacity 0.3 — 1.0 (req 1.1 default 0.7). */
|
||||
opacity: number;
|
||||
}
|
||||
|
||||
export const DEFAULT_LAYOUT_DELTA: ILayoutDelta = {
|
||||
joystickOffset: { dx: 0, dy: 0 },
|
||||
jumpOffset: { dx: 0, dy: 0 },
|
||||
shurikenOffset: { dx: 0, dy: 0 },
|
||||
ninjaSwordOffset: { dx: 0, dy: 0 },
|
||||
buttonSizeScale: 1.0,
|
||||
opacity: 0.7,
|
||||
};
|
||||
|
||||
/** Numeric clamps enforced on any delta the user (or stale storage) gives us. */
|
||||
export const LAYOUT_DELTA_BOUNDS = {
|
||||
offsetPxMax: 240,
|
||||
sizeScaleMin: 0.7,
|
||||
sizeScaleMax: 1.4,
|
||||
opacityMin: 0.3,
|
||||
opacityMax: 1.0,
|
||||
} as const;
|
||||
|
||||
/** Clamp + sanitise a raw delta object received from storage. */
|
||||
export function sanitiseLayoutDelta(raw: Partial<ILayoutDelta> | null | undefined): ILayoutDelta {
|
||||
if (!raw || typeof raw !== 'object') {
|
||||
return { ...DEFAULT_LAYOUT_DELTA };
|
||||
}
|
||||
const clamp = (v: number, lo: number, hi: number): number => {
|
||||
if (typeof v !== 'number' || Number.isNaN(v)) return (lo + hi) / 2;
|
||||
return v < lo ? lo : v > hi ? hi : v;
|
||||
};
|
||||
const clampOffset = (o?: { dx?: number; dy?: number }) => ({
|
||||
dx: clamp(o?.dx ?? 0, -LAYOUT_DELTA_BOUNDS.offsetPxMax, LAYOUT_DELTA_BOUNDS.offsetPxMax),
|
||||
dy: clamp(o?.dy ?? 0, -LAYOUT_DELTA_BOUNDS.offsetPxMax, LAYOUT_DELTA_BOUNDS.offsetPxMax),
|
||||
});
|
||||
return {
|
||||
joystickOffset: clampOffset(raw.joystickOffset),
|
||||
jumpOffset: clampOffset(raw.jumpOffset),
|
||||
shurikenOffset: clampOffset(raw.shurikenOffset),
|
||||
ninjaSwordOffset: clampOffset(raw.ninjaSwordOffset),
|
||||
buttonSizeScale: clamp(
|
||||
raw.buttonSizeScale ?? 1,
|
||||
LAYOUT_DELTA_BOUNDS.sizeScaleMin,
|
||||
LAYOUT_DELTA_BOUNDS.sizeScaleMax
|
||||
),
|
||||
opacity: clamp(raw.opacity ?? 0.7, LAYOUT_DELTA_BOUNDS.opacityMin, LAYOUT_DELTA_BOUNDS.opacityMax),
|
||||
};
|
||||
}
|
||||
|
||||
/** Apply a sanitised delta on top of the baseline default layout. */
|
||||
export function applyLayoutDelta(baseline: IFloatingLayout, delta: ILayoutDelta): IFloatingLayout {
|
||||
const offsetRect = (r: IHitRect, off: { dx: number; dy: number }): IHitRect => ({
|
||||
cx: r.cx + off.dx,
|
||||
cy: r.cy + off.dy,
|
||||
w: r.w * delta.buttonSizeScale,
|
||||
h: r.h * delta.buttonSizeScale,
|
||||
});
|
||||
return {
|
||||
joystick: offsetRect(baseline.joystick, delta.joystickOffset),
|
||||
jump: offsetRect(baseline.jump, delta.jumpOffset),
|
||||
shuriken: offsetRect(baseline.shuriken, delta.shurikenOffset),
|
||||
ninjaSword: offsetRect(baseline.ninjaSword, delta.ninjaSwordOffset),
|
||||
joystickDeadzone: baseline.joystickDeadzone,
|
||||
opacity: delta.opacity,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Thin adapter over `StorageMgr` that handles the `kl_control_layout` key.
|
||||
* The adapter always produces a valid `IFloatingLayout` — even when the
|
||||
* underlying storage is corrupted (req 17.6).
|
||||
*/
|
||||
export class LayoutCustomizer {
|
||||
constructor(
|
||||
private readonly baseline: IFloatingLayout = DEFAULT_LAYOUT,
|
||||
private readonly storage: StorageMgr = globalStorageMgr
|
||||
) {}
|
||||
|
||||
/** Load the saved delta (or default) and produce the concrete layout. */
|
||||
public loadLayout(): { layout: IFloatingLayout; delta: ILayoutDelta } {
|
||||
const raw = this.storage.get<Partial<ILayoutDelta> | null>(STORAGE_KEY.ControlLayout, null);
|
||||
const delta = sanitiseLayoutDelta(raw);
|
||||
return { layout: applyLayoutDelta(this.baseline, delta), delta };
|
||||
}
|
||||
|
||||
/** Persist the given delta after sanitising it. */
|
||||
public saveDelta(delta: ILayoutDelta): void {
|
||||
this.storage.set(STORAGE_KEY.ControlLayout, sanitiseLayoutDelta(delta));
|
||||
}
|
||||
|
||||
/** Reset the layout back to the landscape default. */
|
||||
public reset(): void {
|
||||
this.storage.remove(STORAGE_KEY.ControlLayout);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "d66e41d9-2102-4866-864d-163852b8ffd0",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
import { IStorySceneConfig, IStoryPageConfig } from '../data/Interfaces';
|
||||
import { StorageMgr, globalStorageMgr } from '../common/StorageMgr';
|
||||
import { STORAGE_KEY } from '../common/Constants';
|
||||
|
||||
/**
|
||||
* Story-intro cutscene controller (task 9.1, req 19.1 — 19.9).
|
||||
*
|
||||
* Responsibilities:
|
||||
* 1. Decide whether the intro must play (first-time gate, req 19.5).
|
||||
* 2. Drive the 3-page typewriter sequence (req 19.2-19.3).
|
||||
* 3. Honour taps (speed up printing) and "Skip" (immediate dismiss, req 19.4).
|
||||
* 4. Persist the "seen" flag so it plays only once (req 19.5).
|
||||
* 5. Provide a `reset()` API the Settings menu calls (req 19.6).
|
||||
*
|
||||
* The view layer binds `onTextChanged` / `onFinished` to render text and
|
||||
* trigger the next scene load.
|
||||
*/
|
||||
|
||||
export type StoryPhase = 'idle' | 'typing' | 'waiting_next' | 'finished';
|
||||
|
||||
export interface IStorySceneCallbacks {
|
||||
onTextChanged?: (text: string, page: IStoryPageConfig) => void;
|
||||
onPageEntered?: (page: IStoryPageConfig) => void;
|
||||
onFinished?: (skipped: boolean) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* How many characters per real-time second a page types out. Boosts to
|
||||
* `FAST_MULTIPLIER` while the user is tapping (req 19.3).
|
||||
*/
|
||||
export const BASE_TYPING_CPS = 30;
|
||||
export const FAST_MULTIPLIER = 4;
|
||||
|
||||
export class StorySceneCtrl {
|
||||
private phase: StoryPhase = 'idle';
|
||||
private pageIndex = 0;
|
||||
private cursor = 0;
|
||||
private elapsedSecOnPage = 0;
|
||||
private typingFast = false;
|
||||
|
||||
constructor(
|
||||
private readonly scene: IStorySceneConfig,
|
||||
private readonly storage: StorageMgr = globalStorageMgr,
|
||||
private readonly callbacks: IStorySceneCallbacks = {}
|
||||
) {}
|
||||
|
||||
/** Returns true if the user has already seen / skipped the intro. */
|
||||
public hasBeenSeen(): boolean {
|
||||
return this.storage.get<boolean>(STORAGE_KEY.StoryIntroSeen, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Called by the boot flow. If the intro was already consumed, the view
|
||||
* should skip straight to the next scene; otherwise this begins playback.
|
||||
*/
|
||||
public start(): 'playing' | 'already_seen' {
|
||||
if (this.hasBeenSeen()) {
|
||||
return 'already_seen';
|
||||
}
|
||||
this.phase = 'typing';
|
||||
this.pageIndex = 0;
|
||||
this.cursor = 0;
|
||||
this.elapsedSecOnPage = 0;
|
||||
this.typingFast = false;
|
||||
this.callbacks.onPageEntered?.(this.currentPage());
|
||||
this.emitText();
|
||||
return 'playing';
|
||||
}
|
||||
|
||||
/** Call every frame with real-time delta. */
|
||||
public tick(dtSec: number): void {
|
||||
if (this.phase !== 'typing') return;
|
||||
this.elapsedSecOnPage += dtSec;
|
||||
const page = this.currentPage();
|
||||
const cps = BASE_TYPING_CPS * (this.typingFast ? FAST_MULTIPLIER : 1);
|
||||
const targetCursor = Math.floor(this.elapsedSecOnPage * cps);
|
||||
if (targetCursor !== this.cursor) {
|
||||
this.cursor = Math.min(targetCursor, page.text.length);
|
||||
this.emitText();
|
||||
}
|
||||
if (this.cursor >= page.text.length) {
|
||||
this.phase = 'waiting_next';
|
||||
}
|
||||
}
|
||||
|
||||
/** Tap anywhere — speed up typewriter or advance to next page (req 19.3). */
|
||||
public onTap(): void {
|
||||
if (this.phase === 'typing') {
|
||||
// First tap: reveal full page immediately (req 19.3 "accelerate").
|
||||
const page = this.currentPage();
|
||||
this.cursor = page.text.length;
|
||||
this.emitText();
|
||||
this.phase = 'waiting_next';
|
||||
return;
|
||||
}
|
||||
if (this.phase === 'waiting_next') {
|
||||
this.advancePage();
|
||||
}
|
||||
}
|
||||
|
||||
/** Skip button pressed — immediate dismissal (req 19.4). */
|
||||
public onSkip(): void {
|
||||
if (this.phase === 'finished') return;
|
||||
this.markSeen();
|
||||
this.phase = 'finished';
|
||||
this.callbacks.onFinished?.(true);
|
||||
}
|
||||
|
||||
/** Called by the Settings screen to re-enable the intro (req 19.6). */
|
||||
public reset(): void {
|
||||
this.storage.remove(STORAGE_KEY.StoryIntroSeen);
|
||||
}
|
||||
|
||||
/** Expose current page for HUD rendering. */
|
||||
public get currentPageNumber(): number {
|
||||
return this.pageIndex + 1;
|
||||
}
|
||||
|
||||
/** Current visible text on the active page. */
|
||||
public get visibleText(): string {
|
||||
return this.currentPage().text.slice(0, this.cursor);
|
||||
}
|
||||
|
||||
public get status(): StoryPhase {
|
||||
return this.phase;
|
||||
}
|
||||
|
||||
public get totalPages(): number {
|
||||
return this.scene.pages.length;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
|
||||
private currentPage(): IStoryPageConfig {
|
||||
return this.scene.pages[this.pageIndex];
|
||||
}
|
||||
|
||||
private emitText(): void {
|
||||
this.callbacks.onTextChanged?.(this.visibleText, this.currentPage());
|
||||
}
|
||||
|
||||
private advancePage(): void {
|
||||
if (this.pageIndex < this.scene.pages.length - 1) {
|
||||
this.pageIndex++;
|
||||
this.cursor = 0;
|
||||
this.elapsedSecOnPage = 0;
|
||||
this.phase = 'typing';
|
||||
this.typingFast = false;
|
||||
this.callbacks.onPageEntered?.(this.currentPage());
|
||||
this.emitText();
|
||||
} else {
|
||||
// Last page complete → finish naturally.
|
||||
this.markSeen();
|
||||
this.phase = 'finished';
|
||||
this.callbacks.onFinished?.(false);
|
||||
}
|
||||
}
|
||||
|
||||
private markSeen(): void {
|
||||
this.storage.set(STORAGE_KEY.StoryIntroSeen, true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "43ba3070-8f9a-436c-802f-71ed7bfab8eb",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import { StorageMgr, globalStorageMgr } from '../common/StorageMgr';
|
||||
import { STORAGE_KEY } from '../common/Constants';
|
||||
|
||||
/**
|
||||
* UIFlowMgr — scene-flow state machine (task 9.2, req 12.7-12.8, 13.1).
|
||||
*
|
||||
* It does **not** perform `director.loadScene()` itself; the Cocos view layer
|
||||
* subscribes to `onSceneEnter` and performs the actual scene swap. Keeping
|
||||
* the flow engine-agnostic makes it trivial to Jest-test every boot path,
|
||||
* every settlement path, and the new story-intro gate (req 19.x).
|
||||
*
|
||||
* Decision D-4 / req 13.1 guardrail:
|
||||
* The `showDifficultyPicker()` action simply does not exist; and
|
||||
* `availableSettingsEntries()` purposefully omits it. A future contributor
|
||||
* who tries to add a "difficulty" key will hit a TypeScript compile-error
|
||||
* because the union `SettingsKey` is exhaustive.
|
||||
*/
|
||||
|
||||
export type SceneId =
|
||||
| 'boot'
|
||||
| 'story_intro'
|
||||
| 'main_menu'
|
||||
| 'level_select'
|
||||
| 'gameplay'
|
||||
| 'settlement'
|
||||
| 'settings';
|
||||
|
||||
export type SettingsKey =
|
||||
| 'audio_volume'
|
||||
| 'layout_customisation'
|
||||
| 'replay_tutorial'
|
||||
| 'replay_story_intro';
|
||||
|
||||
export interface ISceneEnter {
|
||||
scene: SceneId;
|
||||
/** Optional payload (e.g. `{ levelId: '1-1' }` for gameplay). */
|
||||
payload?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface IUIFlowCallbacks {
|
||||
onSceneEnter?: (ev: ISceneEnter) => void;
|
||||
}
|
||||
|
||||
export class UIFlowMgr {
|
||||
private current: SceneId = 'boot';
|
||||
|
||||
constructor(
|
||||
private readonly storage: StorageMgr = globalStorageMgr,
|
||||
private readonly callbacks: IUIFlowCallbacks = {}
|
||||
) {}
|
||||
|
||||
public get currentScene(): SceneId {
|
||||
return this.current;
|
||||
}
|
||||
|
||||
/** Invoked by `GameBoot.start()` once engine is ready. */
|
||||
public onBoot(): void {
|
||||
if (this.storage.get<boolean>(STORAGE_KEY.StoryIntroSeen, false)) {
|
||||
this.enter('main_menu');
|
||||
} else {
|
||||
this.enter('story_intro');
|
||||
}
|
||||
}
|
||||
|
||||
/** Called by StorySceneCtrl.onFinished(). */
|
||||
public onStoryFinished(): void {
|
||||
this.enter('gameplay', { levelId: '1-1' });
|
||||
}
|
||||
|
||||
/** Main menu → level select. */
|
||||
public onPressStartGame(): void {
|
||||
// First-time "Start Game" may jump through the story again if we
|
||||
// ever reset; otherwise go to level select.
|
||||
if (!this.storage.get<boolean>(STORAGE_KEY.StoryIntroSeen, false)) {
|
||||
this.enter('story_intro');
|
||||
} else {
|
||||
this.enter('level_select');
|
||||
}
|
||||
}
|
||||
|
||||
public onPickLevel(levelId: string): void {
|
||||
this.enter('gameplay', { levelId });
|
||||
}
|
||||
|
||||
public onOpenSettings(): void {
|
||||
this.enter('settings');
|
||||
}
|
||||
|
||||
public onCloseSettings(): void {
|
||||
this.enter('main_menu');
|
||||
}
|
||||
|
||||
public onLevelCleared(nextLevelId: string | null): void {
|
||||
if (nextLevelId) {
|
||||
this.enter('settlement', { nextLevelId });
|
||||
} else {
|
||||
// After final boss settlement, back to the main menu.
|
||||
this.enter('settlement', { isChapterEnd: true });
|
||||
}
|
||||
}
|
||||
|
||||
public onSettlementContinue(nextLevelId?: string): void {
|
||||
if (nextLevelId) this.enter('gameplay', { levelId: nextLevelId });
|
||||
else this.enter('main_menu');
|
||||
}
|
||||
|
||||
public onPlayerDied(currentLevelId: string): void {
|
||||
this.enter('settlement', { levelId: currentLevelId, dead: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Exhaustive list of settings entries available in the Settings scene.
|
||||
* Purposefully omits any difficulty-selection entry (req 13.1).
|
||||
*/
|
||||
public availableSettingsEntries(): ReadonlyArray<SettingsKey> {
|
||||
return ['audio_volume', 'layout_customisation', 'replay_tutorial', 'replay_story_intro'];
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
|
||||
private enter(scene: SceneId, payload?: Record<string, unknown>): void {
|
||||
this.current = scene;
|
||||
this.callbacks.onSceneEnter?.({ scene, payload });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "0cab93e7-afa4-407b-93ef-67686fcb7110",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* UI layer — floating control layer, layout persistence, story cutscene,
|
||||
* scene-flow manager, HUD, main menu.
|
||||
*/
|
||||
|
||||
export * from './InputModel';
|
||||
export * from './InputEvents';
|
||||
export * from './LayoutCustomizer';
|
||||
export * from './FloatingControlLayer';
|
||||
export * from './StorySceneCtrl';
|
||||
export * from './UIFlowMgr';
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "7be3334f-8d77-4356-b1ae-1ae930a5db4d",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
Reference in New Issue
Block a user