first commmit
This commit is contained in:
@@ -0,0 +1,166 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user