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(); 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(); } }