372 lines
13 KiB
TypeScript
372 lines
13 KiB
TypeScript
import { AttackType, EnemyType, IEnemyConfig, IReinforcementRule } from '../data/Interfaces';
|
|
|
|
/**
|
|
* Enemy AI base class + four concrete subclasses (req 6.1-6.7).
|
|
*
|
|
* Each enemy is modelled as a tiny state machine that ticks on `update(dt)`
|
|
* and emits an `IEnemyAction[]` the level manager then spawns into the world
|
|
* (bullets / smoke bombs / fireballs / sword swings).
|
|
*
|
|
* Enemies outside the camera's culling rect can be frozen by simply skipping
|
|
* `update()` — see `EnemyManager.update()` below (requirement 6.7 / 18.5).
|
|
*/
|
|
|
|
export interface IEnemyAction {
|
|
kind: 'fire_bullet' | 'melee_swing' | 'spawn_item' | 'drop_item';
|
|
attackType?: AttackType;
|
|
/** Origin of the projectile, world coords. */
|
|
originX?: number;
|
|
originY?: number;
|
|
/** Velocity of the projectile. */
|
|
velX?: number;
|
|
velY?: number;
|
|
/** For drop_item only — item id + world coords. */
|
|
itemId?: string;
|
|
}
|
|
|
|
export interface IPlayerSense {
|
|
x: number;
|
|
y: number;
|
|
isGrounded: boolean;
|
|
}
|
|
|
|
export interface IEnemyAABB {
|
|
x: number;
|
|
y: number;
|
|
w: number;
|
|
h: number;
|
|
}
|
|
|
|
export interface IEnemyUpdateCtx {
|
|
dtSec: number;
|
|
nowMs: number;
|
|
player: IPlayerSense;
|
|
}
|
|
|
|
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;
|
|
|
|
constructor(cfg: IEnemyConfig, spawnX: number, spawnY: number) {
|
|
this.cfg = cfg;
|
|
this.type = cfg.id;
|
|
this.pos = { x: spawnX, y: spawnY };
|
|
this.hp = cfg.hp;
|
|
}
|
|
|
|
public abstract update(ctx: IEnemyUpdateCtx): IEnemyAction[];
|
|
|
|
public get aabb(): IEnemyAABB {
|
|
return { x: this.pos.x, y: this.pos.y, w: this.cfg.size.w, h: this.cfg.size.h };
|
|
}
|
|
|
|
protected decrementCooldown(dtSec: number): boolean {
|
|
this.cooldownSec -= dtSec;
|
|
if (this.cooldownSec > 0) return false;
|
|
this.cooldownSec = this.cfg.attackIntervalSec;
|
|
return true;
|
|
}
|
|
}
|
|
|
|
// ---------- Qing Ren (req 6.1) --------------------------------------------
|
|
export class QingRenAI extends EnemyAIBase {
|
|
public update(ctx: IEnemyUpdateCtx): IEnemyAction[] {
|
|
if (!this.decrementCooldown(ctx.dtSec)) return [];
|
|
const dx = ctx.player.x - this.pos.x;
|
|
const horizontalDistance = Math.abs(dx);
|
|
const direction = dx >= 0 ? 1 : -1;
|
|
if (horizontalDistance < 64) {
|
|
return [{ kind: 'melee_swing', attackType: 'sword' }];
|
|
}
|
|
return [
|
|
{
|
|
kind: 'fire_bullet',
|
|
attackType: 'shuriken',
|
|
originX: this.pos.x,
|
|
originY: this.pos.y,
|
|
velX: 240 * direction,
|
|
velY: 0,
|
|
},
|
|
];
|
|
}
|
|
}
|
|
|
|
// ---------- Chi Ren (req 6.2-6.4) -----------------------------------------
|
|
export class ChiRenAI extends EnemyAIBase {
|
|
private interceptCooldown = 0;
|
|
|
|
public update(ctx: IEnemyUpdateCtx): IEnemyAction[] {
|
|
// Patrol horizontally toward the player at 120px/s (req 6.2).
|
|
const dx = ctx.player.x - this.pos.x;
|
|
const direction = dx >= 0 ? 1 : -1;
|
|
this.pos.x += direction * this.cfg.moveSpeed * ctx.dtSec;
|
|
|
|
// Proactive intercept jump when player stands still within vision (req 6.3).
|
|
if (this.interceptCooldown > 0) this.interceptCooldown -= ctx.dtSec;
|
|
const playerIdle = Math.abs(ctx.player.x - this.pos.x) < 200 && ctx.player.isGrounded;
|
|
if (playerIdle && this.interceptCooldown <= 0) {
|
|
this.interceptCooldown = 3;
|
|
// Intercept arc: +X velocity + upward bounce, treated as position warp.
|
|
this.pos.y += 48;
|
|
}
|
|
|
|
if (!this.decrementCooldown(ctx.dtSec)) return [];
|
|
return [
|
|
{
|
|
kind: 'fire_bullet',
|
|
attackType: 'smoke_bomb',
|
|
originX: this.pos.x,
|
|
originY: this.pos.y,
|
|
velX: 140 * direction,
|
|
velY: 0,
|
|
},
|
|
];
|
|
}
|
|
}
|
|
|
|
// ---------- Hei Ren (req 6.5) ---------------------------------------------
|
|
export class HeiRenAI extends EnemyAIBase {
|
|
private hasDroppedMagicFlute = false;
|
|
|
|
public update(ctx: IEnemyUpdateCtx): IEnemyAction[] {
|
|
if (!this.decrementCooldown(ctx.dtSec)) return [];
|
|
const dx = ctx.player.x - this.pos.x;
|
|
const direction = dx >= 0 ? 1 : -1;
|
|
if (Math.abs(dx) < 96) {
|
|
return [{ kind: 'melee_swing', attackType: 'sword' }];
|
|
}
|
|
return [
|
|
{
|
|
kind: 'fire_bullet',
|
|
attackType: 'shuriken',
|
|
originX: this.pos.x,
|
|
originY: this.pos.y,
|
|
velX: 200 * direction,
|
|
velY: 0,
|
|
},
|
|
];
|
|
}
|
|
|
|
/** Called by EnemyManager on death — yields at most one magic flute. */
|
|
public onKilled(): IEnemyAction[] {
|
|
if (this.hasDroppedMagicFlute) return [];
|
|
this.hasDroppedMagicFlute = true;
|
|
return [{ kind: 'drop_item', itemId: 'mo_di', originX: this.pos.x, originY: this.pos.y }];
|
|
}
|
|
}
|
|
|
|
// ---------- Yao Fang (req 6.6) --------------------------------------------
|
|
export class YaoFangAI extends EnemyAIBase {
|
|
public update(ctx: IEnemyUpdateCtx): IEnemyAction[] {
|
|
if (!this.decrementCooldown(ctx.dtSec)) return [];
|
|
const dx = ctx.player.x - this.pos.x;
|
|
const direction = dx >= 0 ? 1 : -1;
|
|
return [
|
|
{
|
|
kind: 'fire_bullet',
|
|
attackType: 'fireball',
|
|
originX: this.pos.x,
|
|
originY: this.pos.y,
|
|
velX: 260 * direction,
|
|
velY: 0,
|
|
},
|
|
];
|
|
}
|
|
}
|
|
|
|
// ---------- Manager with camera-culling (req 6.7) -------------------------
|
|
|
|
export interface ICullingRect {
|
|
leftX: number;
|
|
rightX: number;
|
|
topY: number;
|
|
bottomY: number;
|
|
}
|
|
|
|
export class EnemyManager {
|
|
private readonly enemies: EnemyAIBase[] = [];
|
|
|
|
public spawn(enemy: EnemyAIBase): void {
|
|
this.enemies.push(enemy);
|
|
}
|
|
|
|
public get all(): ReadonlyArray<EnemyAIBase> {
|
|
return this.enemies;
|
|
}
|
|
|
|
/**
|
|
* 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, cullingMargin = 0): IEnemyAction[] {
|
|
const actions: IEnemyAction[] = [];
|
|
for (const e of this.enemies) {
|
|
if (!e.alive) continue;
|
|
if (!this.inside(e, cull, cullingMargin)) continue;
|
|
const ctx: IEnemyUpdateCtx = { dtSec, nowMs, player };
|
|
actions.push(...e.update(ctx));
|
|
}
|
|
return actions;
|
|
}
|
|
|
|
public kill(enemy: EnemyAIBase): IEnemyAction[] {
|
|
enemy.alive = false;
|
|
if (enemy instanceof HeiRenAI) return enemy.onKilled();
|
|
return [];
|
|
}
|
|
|
|
public clear(): void {
|
|
this.enemies.length = 0;
|
|
}
|
|
|
|
private inside(e: EnemyAIBase, cull: ICullingRect, margin = 0): boolean {
|
|
return (
|
|
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);
|
|
}
|
|
}
|