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

192 lines
6.9 KiB
TypeScript

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;
}
}