update spirit

This commit is contained in:
jakciehan
2026-06-07 22:10:03 +08:00
parent 427a33c55b
commit 9c57deff6d
82 changed files with 5465 additions and 149 deletions
+128 -9
View File
@@ -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 {