Files
2026-06-07 22:10:03 +08:00

351 lines
15 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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();
}
}