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 { 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(); private readonly totals = new Map(); 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); } }