first commmit

This commit is contained in:
jakciehan
2026-05-06 08:17:32 +08:00
commit 427a33c55b
269 changed files with 20906 additions and 0 deletions
+166
View File
@@ -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;
}
}