update spirit

This commit is contained in:
jakciehan
2026-06-07 22:10:03 +08:00
parent 427a33c55b
commit 9c57deff6d
82 changed files with 5465 additions and 149 deletions
+149 -11
View File
@@ -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);
}
}