290 lines
10 KiB
TypeScript
290 lines
10 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;
|
|
}
|
|
|
|
/**
|
|
* Returns true if `(x, y)` lies inside `rect`. Origin is bottom-left.
|
|
* Used by both `isInside` and the touch router.
|
|
*/
|
|
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;
|
|
}
|
|
|
|
/** Which control (if any) is hit at `(x, y)`. Priority: attack > jump > joystick. */
|
|
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 (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;
|
|
}
|
|
|
|
/** 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();
|
|
}
|
|
}
|