/** * 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(); 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(); } }