232 lines
8.9 KiB
TypeScript
232 lines
8.9 KiB
TypeScript
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();
|
|
}
|
|
}
|