Files
KateLegend2_proj/assets/scripts/ui/InputModel.ts
T
2026-06-07 22:10:03 +08:00

319 lines
11 KiB
TypeScript

/**
* Input model for the floating control layer.
*
* This module is intentionally **free of `cc` dependencies** so that:
* - 45°/135° parabolic recognition (req 2.5, 20.3)
* - joystick dead-zone (req 1.5)
* - safe-area adaptation (req 1.7, 18.6)
* - multi-touch routing (req 1.3, 1.8)
*
* can all be unit-tested under Jest with deterministic coordinates.
*
* The Cocos Creator view layer (`FloatingControlLayer.ts`) is a thin adapter
* that forwards `TouchEvent` data into this model and renders whatever the
* model reports.
*/
import {
DESIGN_WIDTH,
DESIGN_HEIGHT,
PARABOLIC_ANGLE_RIGHT,
PARABOLIC_ANGLE_LEFT,
PARABOLIC_ANGLE_TOLERANCE,
} from '../common/Constants';
/** Control IDs addressable by the HUD. */
export enum ControlId {
Joystick = 'joystick',
Jump = 'jump',
Shuriken = 'shuriken',
NinjaSword = 'ninja_sword',
}
/**
* A rectangular region defined in **landscape design coordinates**
* (origin at bottom-left, width=960, height=540).
*
* ┌─────────────────────────────┐
* │ │
* │ game world │
* │ │
* │ [joy] [S][K]│ ← joystick bottom-left, attacks bottom-right
* │ [J] │ ← jump above joystick-right
* └─────────────────────────────┘
*/
export interface IHitRect {
/** x of the rect's center, in design pixels. */
cx: number;
/** y of the rect's center, in design pixels. */
cy: number;
/** Full width (design px). */
w: number;
/** Full height (design px). */
h: number;
}
/** Landscape default layout — requirement 1.1. */
export interface IFloatingLayout {
joystick: IHitRect;
jump: IHitRect;
shuriken: IHitRect;
ninjaSword: IHitRect;
/** Dead-zone radius inside the joystick (req 1.5). */
joystickDeadzone: number;
/** Default opacity (0-1). Req 1.1 specifies 0.7. */
opacity: number;
}
export const DEFAULT_LAYOUT: IFloatingLayout = {
// Left-third safe area: joystick and jump stacked (req 1.1)
joystick: { cx: 120, cy: 100, w: 120, h: 120 },
jump: { cx: 235, cy: 180, w: 90, h: 90 },
// Right-third safe area: two attack buttons side-by-side (req 1.1)
shuriken: { cx: DESIGN_WIDTH - 195, cy: 100, w: 90, h: 90 },
ninjaSword: { cx: DESIGN_WIDTH - 85, cy: 100, w: 90, h: 90 },
joystickDeadzone: 10,
opacity: 0.7,
};
/** Direction vector, already normalised (or zero). */
export interface IDirection {
x: number;
y: number;
/** Magnitude of the raw vector **before** normalisation. */
magnitude: number;
}
export const ZERO_DIRECTION: IDirection = Object.freeze({ x: 0, y: 0, magnitude: 0 });
/**
* Classification of a joystick direction relative to the parabolic trigger.
*
* - `none` — inside dead-zone.
* - `horizontal` — left/right movement, vertical jump allowed.
* - `parabolic_right` — ~45°, triggers ↗ parabolic jump (req 2.5).
* - `parabolic_left` — ~135°, triggers ↖ parabolic jump (req 2.5).
* - `other` — any other 2D vector.
*/
export type JoystickAngleClass =
| 'none'
| 'horizontal'
| 'parabolic_right'
| 'parabolic_left'
| 'other';
/** Clamp `v` to [min, max]. */
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 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 + HIT_TOLERANCE && Math.abs(y - rect.cy) <= halfH + HIT_TOLERANCE;
}
/**
* 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 (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;
}
/**
* Compute a joystick direction vector from a touch point. Touches **outside**
* the joystick disc still map to a direction: we use the offset from the
* joystick centre (requirement 1.4). Inside the dead-zone the result is zero.
*/
export function joystickDirection(layout: IFloatingLayout, touchX: number, touchY: number): IDirection {
const dx = touchX - layout.joystick.cx;
const dy = touchY - layout.joystick.cy;
const mag = Math.hypot(dx, dy);
if (mag < layout.joystickDeadzone) {
return ZERO_DIRECTION;
}
return { x: dx / mag, y: dy / mag, magnitude: mag };
}
/**
* Map a direction vector into an `JoystickAngleClass` bucket.
*
* The canonical angles are:
* - 0° → right
* - 90° → up
* - 180° → left
*
* Parabolic trigger windows are 45°±15° and 135°±15° (req 2.5 + tolerance
* picked to stay within req 20.3's ≥95% recognition rate).
*/
export function classifyDirection(dir: IDirection): JoystickAngleClass {
if (dir.magnitude === 0) return 'none';
// atan2 returns [-PI, PI]. Convert to [0, 360).
let deg = (Math.atan2(dir.y, dir.x) * 180) / Math.PI;
if (deg < 0) deg += 360;
// Pure horizontal (≤ ~10° off x-axis) treated as `horizontal`.
if (deg <= 10 || deg >= 350 || (deg >= 170 && deg <= 190)) return 'horizontal';
if (Math.abs(deg - PARABOLIC_ANGLE_RIGHT) <= PARABOLIC_ANGLE_TOLERANCE) return 'parabolic_right';
if (Math.abs(deg - PARABOLIC_ANGLE_LEFT) <= PARABOLIC_ANGLE_TOLERANCE) return 'parabolic_left';
return 'other';
}
// ---------------------------------------------------------------------------
// Safe-area adaptation — requirement 1.7, 18.6
// ---------------------------------------------------------------------------
/** Screen aspect ratios handled without letterboxing (req 1.7). */
export interface ISafeAreaInsets {
/** Px added on the left edge to avoid notches / sensors. */
left: number;
right: number;
top: number;
bottom: number;
}
/**
* Returns a shifted copy of `layout` that respects the given safe-area
* insets. The joystick group slides **rightwards** by `insets.left`; the
* attack group slides **leftwards** by `insets.right`; vertical shifts are
* symmetric. This keeps every control inside the device safe area without
* changing the relative geometry.
*/
export function applySafeArea(layout: IFloatingLayout, insets: ISafeAreaInsets): IFloatingLayout {
const shiftLeftGroup = (r: IHitRect): IHitRect => ({
...r,
cx: r.cx + insets.left,
cy: clamp(r.cy + insets.bottom, r.h / 2, DESIGN_HEIGHT - insets.top - r.h / 2),
});
const shiftRightGroup = (r: IHitRect): IHitRect => ({
...r,
cx: r.cx - insets.right,
cy: clamp(r.cy + insets.bottom, r.h / 2, DESIGN_HEIGHT - insets.top - r.h / 2),
});
return {
...layout,
joystick: shiftLeftGroup(layout.joystick),
jump: shiftLeftGroup(layout.jump),
shuriken: shiftRightGroup(layout.shuriken),
ninjaSword: shiftRightGroup(layout.ninjaSword),
};
}
// ---------------------------------------------------------------------------
// Multi-touch router — requirement 1.3, 1.8
// ---------------------------------------------------------------------------
/** Payload stored per active finger. */
interface TouchSlot {
control: ControlId | null;
x: number;
y: number;
/** Timestamp (ms) captured on touchstart — used for combo recognition. */
startTs: number;
}
/**
* Tracks all currently-down fingers and routes each to the appropriate
* control. Events that miss every button fall through to the game-world
* layer by reporting `control === null` (requirement 1.3).
*/
export class MultiTouchRouter {
private readonly slots = new Map<number, TouchSlot>();
constructor(private readonly layout: IFloatingLayout) {}
/** Begin tracking a new finger. Returns the hit control (or null). */
public begin(id: number, x: number, y: number, ts: number): ControlId | null {
const control = hitTest(this.layout, x, y);
this.slots.set(id, { control, x, y, startTs: ts });
return control;
}
/** Update an in-flight finger. Returns the same control it bound to. */
public move(id: number, x: number, y: number): ControlId | null {
const slot = this.slots.get(id);
if (!slot) return null;
slot.x = x;
slot.y = y;
return slot.control;
}
/** Release a finger. Returns the control it was bound to. */
public end(id: number): ControlId | null {
const slot = this.slots.get(id);
this.slots.delete(id);
return slot?.control ?? null;
}
/** Returns the joystick slot (if any finger is currently driving it). */
public joystickSlot(): TouchSlot | undefined {
for (const s of this.slots.values()) {
if (s.control === ControlId.Joystick) return s;
}
return undefined;
}
/** Convenience — is this control currently pressed? */
public isPressed(control: ControlId): boolean {
for (const s of this.slots.values()) {
if (s.control === control) return true;
}
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;
}
/** Returns the earliest-pressed start timestamp among currently-active controls. */
public earliestPressTs(controls: ControlId[]): number | undefined {
let best: number | undefined;
for (const s of this.slots.values()) {
if (!s.control || !controls.includes(s.control)) continue;
if (best === undefined || s.startTs < best) best = s.startTs;
}
return best;
}
public clear(): void {
this.slots.clear();
}
}