update spirit
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { _decorator, Component, EventTouch, Node, Touch, UITransform, Vec2, Vec3, view } from 'cc';
|
||||
import { _decorator, Component, EventTouch, Node, Touch, UITransform, Vec2, Vec3, view, input, Input } from 'cc';
|
||||
import { globalEventBus, globalLogger } from '../common/index';
|
||||
import { PERF_TOUCH_RESPONSE_MAX_MS } from '../common/Constants';
|
||||
import { DESIGN_WIDTH, DESIGN_HEIGHT, PERF_TOUCH_RESPONSE_MAX_MS } from '../common/Constants';
|
||||
import {
|
||||
ControlId,
|
||||
DEFAULT_LAYOUT,
|
||||
@@ -47,13 +47,43 @@ export class FloatingControlLayer extends Component {
|
||||
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. */
|
||||
@@ -98,6 +128,33 @@ export class FloatingControlLayer extends Component {
|
||||
}
|
||||
|
||||
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);
|
||||
@@ -105,17 +162,58 @@ export class FloatingControlLayer extends Component {
|
||||
}
|
||||
|
||||
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 t = ev.getUILocation();
|
||||
const start = FloatingControlLayer.now();
|
||||
const touchId = this.touchId(ev);
|
||||
const hit = this.router.begin(touchId, t.x, t.y, start);
|
||||
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).
|
||||
@@ -133,7 +231,7 @@ export class FloatingControlLayer extends Component {
|
||||
globalEventBus.emit(InputEvents.NinjaSwordPressed, {});
|
||||
break;
|
||||
case ControlId.Joystick:
|
||||
this.broadcastJoystick(t.x, t.y);
|
||||
this.broadcastJoystick(d.x, d.y);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
@@ -142,18 +240,36 @@ export class FloatingControlLayer extends Component {
|
||||
|
||||
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, t.x, t.y);
|
||||
const bound = this.router.move(touchId, d.x, d.y);
|
||||
if (bound === ControlId.Joystick) {
|
||||
this.broadcastJoystick(t.x, t.y);
|
||||
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);
|
||||
if (!bound) return;
|
||||
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);
|
||||
@@ -174,6 +290,9 @@ export class FloatingControlLayer extends Component {
|
||||
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 {
|
||||
|
||||
Reference in New Issue
Block a user