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 {
+35 -6
View File
@@ -109,21 +109,45 @@ export function clamp(v: number, min: number, max: number): number {
return v < min ? min : v > max ? max : v;
}
/** Hit-test tolerance (design px). Touches this far outside the visual
* bounding box still register as a hit. This compensates for finger
* imprecision on small touch targets (req 1.3, 20.3).
* Increased from 10→15 to better accommodate finger-pad size on mobile. */
export const HIT_TOLERANCE = 15;
/**
* Returns true if `(x, y)` lies inside `rect`. Origin is bottom-left.
* Used by both `isInside` and the touch router.
* Used for the joystick which is rendered as a full rectangle.
*/
export function isInsideRect(rect: IHitRect, x: number, y: number): boolean {
const halfW = rect.w / 2;
const halfH = rect.h / 2;
return Math.abs(x - rect.cx) <= halfW && Math.abs(y - rect.cy) <= halfH;
return Math.abs(x - rect.cx) <= halfW + HIT_TOLERANCE && Math.abs(y - rect.cy) <= halfH + HIT_TOLERANCE;
}
/** Which control (if any) is hit at `(x, y)`. Priority: attack > jump > joystick. */
/**
* Returns true if `(x, y)` lies inside the **circle** inscribed by `rect`.
* Buttons are rendered as circles via `Graphics.circle`; using a circular
* hit-test ensures the visual shape and the touch area match — no "dead
* zones" in the corners of a rectangular hit rect that visually lie outside
* the circle, and no missing the upper/lower arc of the circle.
*
* The effective radius is `min(w, h) / 2 + HIT_TOLERANCE`.
*/
export function isInsideCircle(rect: IHitRect, x: number, y: number): boolean {
const radius = Math.min(rect.w, rect.h) / 2;
const dx = x - rect.cx;
const dy = y - rect.cy;
return (dx * dx + dy * dy) <= (radius + HIT_TOLERANCE) * (radius + HIT_TOLERANCE);
}
/** Which control (if any) is hit at `(x, y)`. Priority: attack > jump > joystick.
* Buttons (jump, shuriken, ninjaSword) use circular hit-test to match their
* visual shape. The joystick retains rectangular hit-test for full-area coverage. */
export function hitTest(layout: IFloatingLayout, x: number, y: number): ControlId | null {
if (isInsideRect(layout.ninjaSword, x, y)) return ControlId.NinjaSword;
if (isInsideRect(layout.shuriken, x, y)) return ControlId.Shuriken;
if (isInsideRect(layout.jump, x, y)) return ControlId.Jump;
if (isInsideCircle(layout.ninjaSword, x, y)) return ControlId.NinjaSword;
if (isInsideCircle(layout.shuriken, x, y)) return ControlId.Shuriken;
if (isInsideCircle(layout.jump, x, y)) return ControlId.Jump;
if (isInsideRect(layout.joystick, x, y)) return ControlId.Joystick;
return null;
}
@@ -268,6 +292,11 @@ export class MultiTouchRouter {
return false;
}
/** Check whether a specific touchId still has an active slot in the router. */
public isPressedById(id: number): boolean {
return this.slots.has(id);
}
/** Returns how many simultaneous fingers are currently tracked. */
public get activeTouchCount(): number {
return this.slots.size;