import { MOVE_SPEED, PlayerColorState } from '../common/Constants'; /** * Pure-TS motion model for the player character. * * This is the foundation used by tasks 4.1, 4.2 (jumping/parabolic) and * later 5.x (combo attacks). It is deliberately engine-free so that the * entire movement state-machine is Jest-testable (requirement 2.1, 5.1-5.2). * * Coordinate convention: landscape design resolution, **+y is up**. All * numbers are in design pixels / seconds. */ export interface IAxisAlignedBox { /** Centre x. */ x: number; /** Centre y. */ y: number; /** Full width. */ w: number; /** Full height. */ h: number; } /** A simple horizontal platform the player may stand on. */ export interface IPlatform { /** Platform top edge y (world px). */ topY: number; /** Platform left edge x (world px). */ leftX: number; /** Platform right edge x (world px). */ rightX: number; } /** Horizontal input reported by `InputModel` / `FloatingControlLayer`. */ export type HorizontalInput = -1 | 0 | 1; export interface IPlayerMotionOptions { /** World gravity (px/s²). Default derived so a 250-px jump lasts ~0.45 s. */ gravity?: number; /** Starting AABB of the player. */ aabb: IAxisAlignedBox; /** Platforms defining the walkable terrain. Can be swapped per-level. */ platforms: IPlatform[]; /** Starting color state. */ initialColorState?: PlayerColorState; /** Level horizontal extent (px). Used to clamp player X so they cannot leave the level. */ levelLengthPx: number; } export const DEFAULT_GRAVITY = 2500; // px/s² — tuned so a 250px jump peaks in ~0.45s /** * Encapsulates player horizontal/vertical movement + ground detection. * Call `setHorizontalInput()` + `requestJump()` every frame from the view * layer, then invoke `update(dt)` to advance the simulation. */ export class PlayerMotionModel { // -- mutable state ------------------------------------------------------ private _vx = 0; private _vy = 0; private _grounded = false; private _colorState: PlayerColorState; private _horizontalInput: HorizontalInput = 0; private _aabb: IAxisAlignedBox; private _platforms: IPlatform[]; private readonly gravity: number; private readonly _levelLengthPx: number; constructor(options: IPlayerMotionOptions) { this._aabb = { ...options.aabb }; this._platforms = options.platforms.slice(); this._colorState = options.initialColorState ?? PlayerColorState.Red; this.gravity = options.gravity ?? DEFAULT_GRAVITY; this._levelLengthPx = options.levelLengthPx; } // -- accessors ---------------------------------------------------------- public get aabb(): IAxisAlignedBox { return this._aabb; } public get vx(): number { return this._vx; } public get vy(): number { return this._vy; } public get isGrounded(): boolean { return this._grounded; } public get colorState(): PlayerColorState { return this._colorState; } // -- inputs ------------------------------------------------------------- /** -1 moves left, 1 moves right, 0 stops (req 2.1). */ public setHorizontalInput(input: HorizontalInput): void { this._horizontalInput = input; } /** * Update the player's color state (e.g. after a crystal-jade pickup). * Movement speed will immediately reflect the new bucket (req 5.1-5.2). */ public setColorState(state: PlayerColorState): void { this._colorState = state; } /** Impulse-based vertical jump. Does nothing if not grounded (req 2.4). */ public applyJumpImpulse(verticalPxPerSec: number): boolean { if (!this._grounded) return false; this._vy = verticalPxPerSec; this._grounded = false; return true; } /** Additional horizontal impulse used by parabolic jumps (req 2.5). */ public applyHorizontalImpulse(vx: number): void { this._vx = vx; } /** Swap level terrain; also clears grounded so we re-settle on next update. */ public setPlatforms(platforms: IPlatform[]): void { this._platforms = platforms.slice(); this._grounded = false; } // -- simulation step ---------------------------------------------------- /** * Advance the simulation by `dt` seconds. In hardcore mode (req 13.4) the * horizontal velocity is **only** rewritten from input when on the * ground; mid-air `_vx` is preserved (起跳定型). */ public update(dt: number): void { // Remember feet position before integration (for sweep test). const prevFeetY = this._aabb.y - this._aabb.h / 2; if (this._grounded) { this._vx = this._horizontalInput * MOVE_SPEED[this._colorState]; this._vy = 0; } else { // Apply gravity (requirement 13.4: no air-control). this._vy -= this.gravity * dt; } // Integrate position. this._aabb = { ...this._aabb, x: this._aabb.x + this._vx * dt, y: this._aabb.y + this._vy * dt, }; const curFeetY = this._aabb.y - this._aabb.h / 2; // Resolve against platforms using sweep test. // If the feet crossed a platform surface this frame (prev above → cur below), // snap the player onto that platform — prevents tunneling at high fall speeds. this._grounded = false; for (const p of this._platforms) { const withinHorizontal = this._aabb.x >= p.leftX && this._aabb.x <= p.rightX; if (!withinHorizontal) continue; if (this._vy > 0) continue; // moving upward — cannot land // Sweep test: feet were above topY last frame, now at or below topY. const crossedSurface = prevFeetY >= p.topY - 0.5 && curFeetY <= p.topY + 0.5; // Also catch the small-window case (slow fall, feet near surface). const nearSurface = curFeetY <= p.topY + 0.5 && curFeetY >= p.topY - 6; if (crossedSurface || nearSurface) { this._grounded = true; this._aabb = { ...this._aabb, y: p.topY + this._aabb.h / 2 }; if (this._vy < 0) this._vy = 0; break; } } // Clamp AABB X within level boundaries so the player cannot leave the level. const halfW = this._aabb.w / 2; this._aabb.x = Math.max(halfW, Math.min(this._aabb.x, this._levelLengthPx - halfW)); } // -- helpers ------------------------------------------------------------ private isRestingOn(p: IPlatform): boolean { const feetY = this._aabb.y - this._aabb.h / 2; const withinHorizontal = this._aabb.x >= p.leftX && this._aabb.x <= p.rightX; const atOrJustBelowTop = feetY <= p.topY + 0.5 && feetY >= p.topY - 6 && this._vy <= 0; return withinHorizontal && atOrJustBelowTop; } }