update spirit
This commit is contained in:
@@ -65,8 +65,12 @@ export class BossController {
|
||||
if (!this.butterflyRevealed) return [];
|
||||
if (this.killed) return [];
|
||||
const out: BossOutcomeEvent[] = [];
|
||||
|
||||
this.hp = Math.max(0, this.hp - 1);
|
||||
out.push(...this.checkPhaseTransition(), ...this.checkPrincessCutscene());
|
||||
// Check phase transition AFTER decrementing HP so the ratio
|
||||
// reflects the new HP (e.g. 3→2 = 2/3).
|
||||
const hpRatio = this.hp / this.cfg.hp;
|
||||
out.push(...this.checkPhaseTransitionAtRatio(hpRatio), ...this.checkPrincessCutscene());
|
||||
if (this.hp === 0) {
|
||||
this.killed = true;
|
||||
out.push({ kind: 'boss_killed' });
|
||||
@@ -76,8 +80,7 @@ export class BossController {
|
||||
|
||||
// --------------------------------------------------------------------
|
||||
|
||||
private checkPhaseTransition(): BossOutcomeEvent[] {
|
||||
const hpRatio = this.hp / this.cfg.hp;
|
||||
private checkPhaseTransitionAtRatio(hpRatio: number): BossOutcomeEvent[] {
|
||||
for (let i = this.phaseIndex + 1; i < this.cfg.phases.length; i++) {
|
||||
if (hpRatio <= this.cfg.phases[i].hpThreshold) {
|
||||
this.phaseIndex = i;
|
||||
|
||||
+149
-11
@@ -1,4 +1,4 @@
|
||||
import { AttackType, EnemyType, IEnemyConfig } from '../data/Interfaces';
|
||||
import { AttackType, EnemyType, IEnemyConfig, IReinforcementRule } from '../data/Interfaces';
|
||||
|
||||
/**
|
||||
* Enemy AI base class + four concrete subclasses (req 6.1-6.7).
|
||||
@@ -47,6 +47,7 @@ export abstract class EnemyAIBase {
|
||||
public readonly type: EnemyType;
|
||||
public pos: { x: number; y: number };
|
||||
public alive = true;
|
||||
public hp: number;
|
||||
protected cooldownSec = 0;
|
||||
protected readonly cfg: IEnemyConfig;
|
||||
|
||||
@@ -54,6 +55,7 @@ export abstract class EnemyAIBase {
|
||||
this.cfg = cfg;
|
||||
this.type = cfg.id;
|
||||
this.pos = { x: spawnX, y: spawnY };
|
||||
this.hp = cfg.hp;
|
||||
}
|
||||
|
||||
public abstract update(ctx: IEnemyUpdateCtx): IEnemyAction[];
|
||||
@@ -197,15 +199,18 @@ export class EnemyManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* Update all live enemies that intersect `cull`. Returns the concatenated
|
||||
* list of actions emitted so the caller (LevelMgr) can instantiate
|
||||
* projectiles, drops, etc.
|
||||
* Update all live enemies that intersect `cull` (with optional margin).
|
||||
* Returns the concatenated list of actions emitted so the caller
|
||||
* (LevelMgr) can instantiate projectiles, drops, etc.
|
||||
*
|
||||
* @param cullingMargin Extra pixels beyond `cull` to still activate enemies.
|
||||
* Used for reinforcements that spawn just outside the screen.
|
||||
*/
|
||||
public update(dtSec: number, nowMs: number, player: IPlayerSense, cull: ICullingRect): IEnemyAction[] {
|
||||
public update(dtSec: number, nowMs: number, player: IPlayerSense, cull: ICullingRect, cullingMargin = 0): IEnemyAction[] {
|
||||
const actions: IEnemyAction[] = [];
|
||||
for (const e of this.enemies) {
|
||||
if (!e.alive) continue;
|
||||
if (!this.inside(e, cull)) continue;
|
||||
if (!this.inside(e, cull, cullingMargin)) continue;
|
||||
const ctx: IEnemyUpdateCtx = { dtSec, nowMs, player };
|
||||
actions.push(...e.update(ctx));
|
||||
}
|
||||
@@ -222,12 +227,145 @@ export class EnemyManager {
|
||||
this.enemies.length = 0;
|
||||
}
|
||||
|
||||
private inside(e: EnemyAIBase, cull: ICullingRect): boolean {
|
||||
private inside(e: EnemyAIBase, cull: ICullingRect, margin = 0): boolean {
|
||||
return (
|
||||
e.pos.x + e.aabb.w / 2 >= cull.leftX &&
|
||||
e.pos.x - e.aabb.w / 2 <= cull.rightX &&
|
||||
e.pos.y + e.aabb.h / 2 >= cull.bottomY &&
|
||||
e.pos.y - e.aabb.h / 2 <= cull.topY
|
||||
e.pos.x + e.aabb.w / 2 >= cull.leftX - margin &&
|
||||
e.pos.x - e.aabb.w / 2 <= cull.rightX + margin &&
|
||||
e.pos.y + e.aabb.h / 2 >= cull.bottomY - margin &&
|
||||
e.pos.y - e.aabb.h / 2 <= cull.topY + margin
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Reinforcement Scheduler (dynamic edge spawns) -----------------
|
||||
|
||||
/** An enemy spawned by the reinforcement scheduler, with a jump-in arc. */
|
||||
export interface IReinforcementSpawn {
|
||||
enemy: EnemyAIBase;
|
||||
/** The edge the enemy is jumping in from. */
|
||||
edge: 'left' | 'right';
|
||||
}
|
||||
|
||||
/**
|
||||
* Manages periodic reinforcement waves — enemies that jump in from screen
|
||||
* edges even when the player is standing still. Each rule specifies an
|
||||
* enemy type, interval, count, and which edge(s) they appear from.
|
||||
*
|
||||
* Usage: call `tick()` every frame. It returns newly-spawned enemies that
|
||||
* the caller must register with `EnemyManager.spawn()` and create visual
|
||||
* nodes for.
|
||||
*/
|
||||
export class ReinforcementScheduler {
|
||||
private readonly timers = new Map<number, number>();
|
||||
private readonly totals = new Map<number, number>();
|
||||
|
||||
constructor(private readonly rules: IReinforcementRule[]) {}
|
||||
|
||||
/**
|
||||
* Tick the scheduler. Returns enemies that should be spawned this frame.
|
||||
* The caller is responsible for:
|
||||
* 1. Passing the new enemies to `EnemyManager.spawn()`
|
||||
* 2. Creating visual nodes for them
|
||||
* 3. Calling `EnemyManager.update()` as usual
|
||||
*
|
||||
* @param dtSec Frame delta in seconds.
|
||||
* @param elapsedSec Total elapsed seconds since level start.
|
||||
* @param cull Current camera culling rect (used to determine edge positions).
|
||||
* @param groundY Ground Y in physics coords (feet surface).
|
||||
* @param enemyCfgFactory A function that returns the IEnemyConfig for a given EnemyType.
|
||||
*/
|
||||
public tick(
|
||||
dtSec: number,
|
||||
elapsedSec: number,
|
||||
cull: ICullingRect,
|
||||
groundY: number,
|
||||
enemyCfgFactory: (type: EnemyType) => { moveSpeed: number; attackIntervalSec: number; size: { w: number; h: number } },
|
||||
): IReinforcementSpawn[] {
|
||||
const spawns: IReinforcementSpawn[] = [];
|
||||
|
||||
for (let i = 0; i < this.rules.length; i++) {
|
||||
const rule = this.rules[i];
|
||||
|
||||
// Check delay
|
||||
if (rule.delaySec && elapsedSec < rule.delaySec) continue;
|
||||
|
||||
// Check max total
|
||||
const total = this.totals.get(i) ?? 0;
|
||||
if (rule.maxTotal && rule.maxTotal > 0 && total >= rule.maxTotal) continue;
|
||||
|
||||
// Accumulate timer
|
||||
const timer = this.timers.get(i) ?? 0;
|
||||
const newTimer = timer + dtSec;
|
||||
this.timers.set(i, newTimer);
|
||||
|
||||
if (newTimer < rule.intervalSec) continue;
|
||||
|
||||
// Reset timer (keep remainder for smoother intervals)
|
||||
this.timers.set(i, newTimer - rule.intervalSec);
|
||||
|
||||
// Determine edge(s)
|
||||
const edges: Array<'left' | 'right'> = rule.edge === 'both'
|
||||
? (Math.random() < 0.5 ? ['left'] : ['right'])
|
||||
: [rule.edge];
|
||||
|
||||
for (const edge of edges) {
|
||||
for (let j = 0; j < rule.count; j++) {
|
||||
const cfg = enemyCfgFactory(rule.type);
|
||||
// Spawn position: just outside the culling rect on the chosen edge.
|
||||
// Moving enemies (moveSpeed > 0) start farther out and walk in;
|
||||
// stationary enemies (YaoFang) start just inside the visible edge.
|
||||
const margin = cfg.moveSpeed > 0 ? 60 : -30; // negative = inside screen
|
||||
const spawnX = edge === 'left'
|
||||
? cull.leftX - margin - j * 50
|
||||
: cull.rightX + margin + j * 50;
|
||||
const spawnY = groundY + cfg.size.h / 2;
|
||||
|
||||
const enemy = createReinforcementEnemy(rule.type, cfg, spawnX, spawnY, edge);
|
||||
spawns.push({ enemy, edge });
|
||||
this.totals.set(i, (this.totals.get(i) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return spawns;
|
||||
}
|
||||
|
||||
/** Reset all timers and counters (e.g. on level restart). */
|
||||
public reset(): void {
|
||||
this.timers.clear();
|
||||
this.totals.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an enemy AI instance for a reinforcement spawn.
|
||||
* Reuses the same AI classes as static spawns. The enemy spawns at ground
|
||||
* level on the chosen edge; moving enemies (ChiRen) will walk into the
|
||||
* screen automatically, while stationary enemies (YaoFang) are placed
|
||||
* just inside the visible edge so they're immediately active.
|
||||
*/
|
||||
function createReinforcementEnemy(
|
||||
type: EnemyType,
|
||||
cfg: { moveSpeed: number; attackIntervalSec: number; size: { w: number; h: number } },
|
||||
spawnX: number,
|
||||
spawnY: number,
|
||||
_edge: 'left' | 'right',
|
||||
): EnemyAIBase {
|
||||
const fullCfg = {
|
||||
id: type,
|
||||
displayName: type,
|
||||
size: cfg.size,
|
||||
moveSpeed: cfg.moveSpeed,
|
||||
attackIntervalSec: cfg.attackIntervalSec,
|
||||
attacks: [] as never[],
|
||||
hp: type === EnemyType.HeiRen ? 2 : 1,
|
||||
};
|
||||
|
||||
switch (type) {
|
||||
case EnemyType.ChiRen: return new ChiRenAI(fullCfg, spawnX, spawnY);
|
||||
case EnemyType.HeiRen: return new HeiRenAI(fullCfg, spawnX, spawnY);
|
||||
case EnemyType.YaoFang: return new YaoFangAI(fullCfg, spawnX, spawnY);
|
||||
case EnemyType.QingRen:
|
||||
default: return new QingRenAI(fullCfg, spawnX, spawnY);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,13 +80,16 @@ export class JumpController {
|
||||
/** Called on `jumpPressed` UI event. */
|
||||
public pressJump(nowMs: number): IJumpDispatchResult {
|
||||
if (!this.motion.isGrounded) {
|
||||
console.log('[JumpController] pressJump REJECTED — airborne, phase=', this.phase);
|
||||
return { phase: this.phase, height: 0, horizontalImpulse: 0, reason: 'airborne' };
|
||||
}
|
||||
if (this.phase !== 'idle') {
|
||||
console.log('[JumpController] pressJump REJECTED — phase=', this.phase, ', not idle');
|
||||
return { phase: this.phase, height: 0, horizontalImpulse: 0, reason: 'already_in_jump' };
|
||||
}
|
||||
this.phase = 'charging';
|
||||
this.pressTs = nowMs;
|
||||
console.log('[JumpController] pressJump ACCEPTED — entering charging phase');
|
||||
return { phase: this.phase, height: 0, horizontalImpulse: 0 };
|
||||
}
|
||||
|
||||
|
||||
@@ -44,6 +44,8 @@ export interface IPlayerMotionOptions {
|
||||
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
|
||||
@@ -63,12 +65,14 @@ export class PlayerMotionModel {
|
||||
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 ----------------------------------------------------------
|
||||
@@ -130,6 +134,9 @@ export class PlayerMotionModel {
|
||||
* 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;
|
||||
@@ -143,16 +150,34 @@ export class PlayerMotionModel {
|
||||
x: this._aabb.x + this._vx * dt,
|
||||
y: this._aabb.y + this._vy * dt,
|
||||
};
|
||||
// Resolve against platforms (basic AABB vs. top-surface only).
|
||||
|
||||
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) {
|
||||
if (this.isRestingOn(p)) {
|
||||
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 ------------------------------------------------------------
|
||||
|
||||
@@ -36,7 +36,7 @@ export interface IPlayerState {
|
||||
export class PlayerStateMachine {
|
||||
private state: IPlayerState;
|
||||
|
||||
constructor(initialLives = 3) {
|
||||
constructor(initialLives = 1) {
|
||||
this.state = {
|
||||
color: PlayerColorState.Red,
|
||||
lives: initialLives,
|
||||
@@ -135,8 +135,11 @@ export class PlayerStateMachine {
|
||||
this.startIFrames();
|
||||
if (this.state.lives === 0) {
|
||||
this.state.isDead = true;
|
||||
return { kind: 'died', cause };
|
||||
}
|
||||
return { kind: 'died', cause };
|
||||
// Lost a life but still alive — treat as a downgrade so callers
|
||||
// can distinguish real death from a mere life-loss.
|
||||
return { kind: 'downgraded', from: this.state.color, to: PlayerColorState.Red };
|
||||
}
|
||||
|
||||
private startIFrames(): void {
|
||||
|
||||
Reference in New Issue
Block a user