import { _decorator, Component, EventTouch, Node, Touch, UITransform, Vec2, Vec3, view, input, Input } from 'cc'; import { globalEventBus, globalLogger } from '../common/index'; import { DESIGN_WIDTH, DESIGN_HEIGHT, 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); /** * Convert a touch point from the engine's UI coordinate space back into * the 960×540 design-coordinate space used by `InputModel`. * * Under `FIT_HEIGHT`, the engine may widen the visible area beyond 960px * on ultra-wide screens (19.5:9 etc.). `getUILocation()` returns values * in that wider space, but our layout & hit-test are anchored to 960×540. * * The mapping is: * designX = (uiX - extraLeft) * 960 / visibleWidth * designY = uiY * 540 / visibleHeight (always 1:1 under FIT_HEIGHT) */ private uiToDesign(uiX: number, uiY: number): { x: number; y: number } { const vs = view.getVisibleSize(); const scaleX = DESIGN_WIDTH / vs.width; const scaleY = DESIGN_HEIGHT / vs.height; // Under FIT_HEIGHT the design height always equals the visible height // so scaleY ≈ 1, but we apply it anyway for robustness. return { x: uiX * scaleX, y: uiY * scaleY, }; } protected onLoad(): void { console.log('[FloatingControlLayer] onLoad — node name=', this.node?.name, 'isValid=', this.node?.isValid); this.applyInitialLayout(); this.bindTouchEvents(); console.log('[FloatingControlLayer] onLoad done — touch listeners bound'); } protected onDestroy(): void { console.log('[FloatingControlLayer] onDestroy — unbinding touch listeners'); this.unbindTouchEvents(); this.router.clear(); this.processedPhases.clear(); this.lastStartTs.clear(); } /** 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 { // CRITICAL: Cocos Creator 3.x has a known issue where // `input.off(type, cb, target)` may fail to remove listeners whose // target was a destroyed Component. This leaves "ghost" handlers on // the global input dispatcher that silently break the touch event // chain after scene transitions. We defensively clear ALL listeners // for our touch events first, then re-register — guaranteeing a // clean state. try { (input as any).off(Input.EventType.TOUCH_START); (input as any).off(Input.EventType.TOUCH_MOVE); (input as any).off(Input.EventType.TOUCH_END); (input as any).off(Input.EventType.TOUCH_CANCEL); } catch (e) { console.warn('[FloatingControlLayer] defensive input.off (no target) failed:', e); } // PRIMARY channel: global input API — receives raw touch events // directly from the engine, bypassing node-tree dispatch entirely. input.on(Input.EventType.TOUCH_START, this.onTouchStart, this); input.on(Input.EventType.TOUCH_MOVE, this.onTouchMove, this); input.on(Input.EventType.TOUCH_END, this.onTouchEnd, this); input.on(Input.EventType.TOUCH_CANCEL, this.onTouchEnd, this); console.log('[FloatingControlLayer] bindTouchEvents — global input listeners registered'); // SECONDARY (back-compat): node-level listeners so that // `ev.propagationStopped` keeps any underlying gameplay sprite from // also reacting to the same touch. 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 { input.off(Input.EventType.TOUCH_START, this.onTouchStart, this); input.off(Input.EventType.TOUCH_MOVE, this.onTouchMove, this); input.off(Input.EventType.TOUCH_END, this.onTouchEnd, this); input.off(Input.EventType.TOUCH_CANCEL, this.onTouchEnd, this); 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); } /** * Event de-dup. Because we listen on BOTH the global `input` API and the * node-level `this.node.on` channel, the same touch may fire the same * handler twice. This set records the `"${eventKind}:${touchId}"` string * for touches already processed in this "phase"; the entry is cleared * when the corresponding END/CANCEL arrives (or START starts a new * phase for that id). */ private readonly processedPhases = new Set(); private onTouchStart(ev: EventTouch): void { const touchId = this.touchId(ev); console.log('[FloatingControlLayer] onTouchStart — touchId=', touchId); const key = `start:${touchId}`; // Defensive: if a stale `start` marker exists for this touchId AND // the router no longer has an active slot for it, the marker leaked // from a previous touch cycle (e.g. onTouchEnd was not called on // both channels, or a TOUCH_CANCEL race left the set dirty). // Clear it so this new touch is not silently swallowed. if (this.processedPhases.has(key) && !this.router.isPressedById(touchId)) { console.log('[FloatingControlLayer] onTouchStart — clearing stale start marker for touchId=', touchId); this.processedPhases.delete(key); } if (this.processedPhases.has(key)) { console.log('[FloatingControlLayer] onTouchStart DEDUP-skip touchId=', touchId, 'processedPhases=', JSON.stringify([...this.processedPhases])); return; } this.processedPhases.add(key); // A new TOUCH_START invalidates any stale END marker for the same id. this.processedPhases.delete(`end:${touchId}`); const t = ev.getUILocation(); const d = this.uiToDesign(t.x, t.y); console.log('[FloatingControlLayer] onTouchStart uiLoc=(', t.x, ',', t.y, ') design=(', d.x.toFixed(1), ',', d.y.toFixed(1), ')'); const start = FloatingControlLayer.now(); const hit = this.router.begin(touchId, d.x, d.y, start); console.log('[FloatingControlLayer] onTouchStart hit=', hit, 'router.activeTouchCount=', this.router.activeTouchCount); 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(d.x, d.y); break; default: break; } } private onTouchMove(ev: EventTouch): void { const t = ev.getUILocation(); const d = this.uiToDesign(t.x, t.y); const touchId = this.touchId(ev); const bound = this.router.move(touchId, d.x, d.y); if (bound === ControlId.Joystick) { this.broadcastJoystick(d.x, d.y); } } private onTouchEnd(ev: EventTouch): void { const touchId = this.touchId(ev); const key = `end:${touchId}`; if (this.processedPhases.has(key)) { console.log('[FloatingControlLayer] onTouchEnd DEDUP-skip touchId=', touchId); return; } this.processedPhases.add(key); // End of this phase — allow a future TOUCH_START with the same id. this.processedPhases.delete(`start:${touchId}`); const end = FloatingControlLayer.now(); const bound = this.router.end(touchId); console.log('[FloatingControlLayer] onTouchEnd touchId=', touchId, 'bound=', bound, 'slots remaining=', this.router.activeTouchCount); if (!bound) { // Touch that missed every control — still clean up its timestamp // record so the Map does not leak over long play sessions. this.lastStartTs.delete(touchId); this.processedPhases.delete(key); 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); // Allow the same phase key to be re-used on a future touch id // reassignment (some platforms recycle ids aggressively). this.processedPhases.delete(key); } 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(); } }