351 lines
15 KiB
TypeScript
351 lines
15 KiB
TypeScript
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<string>();
|
||
|
||
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<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();
|
||
}
|
||
}
|