167 lines
5.6 KiB
TypeScript
167 lines
5.6 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;
|
|
}
|
|
|
|
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;
|
|
|
|
constructor(options: IPlayerMotionOptions) {
|
|
this._aabb = { ...options.aabb };
|
|
this._platforms = options.platforms.slice();
|
|
this._colorState = options.initialColorState ?? PlayerColorState.Red;
|
|
this.gravity = options.gravity ?? DEFAULT_GRAVITY;
|
|
}
|
|
|
|
// -- 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 {
|
|
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,
|
|
};
|
|
// Resolve against platforms (basic AABB vs. top-surface only).
|
|
this._grounded = false;
|
|
for (const p of this._platforms) {
|
|
if (this.isRestingOn(p)) {
|
|
this._grounded = true;
|
|
this._aabb = { ...this._aabb, y: p.topY + this._aabb.h / 2 };
|
|
if (this._vy < 0) this._vy = 0;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
// -- 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;
|
|
}
|
|
}
|