first commmit
This commit is contained in:
@@ -0,0 +1,231 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user