first commmit
This commit is contained in:
@@ -0,0 +1,233 @@
|
||||
import { AttackType, EnemyType, IEnemyConfig } 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;
|
||||
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 };
|
||||
}
|
||||
|
||||
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`. Returns the concatenated
|
||||
* list of actions emitted so the caller (LevelMgr) can instantiate
|
||||
* projectiles, drops, etc.
|
||||
*/
|
||||
public update(dtSec: number, nowMs: number, player: IPlayerSense, cull: ICullingRect): IEnemyAction[] {
|
||||
const actions: IEnemyAction[] = [];
|
||||
for (const e of this.enemies) {
|
||||
if (!e.alive) continue;
|
||||
if (!this.inside(e, cull)) 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): 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
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user