diff --git a/apps/game/src/data/enemies.ts b/apps/game/src/data/enemies.ts index d7310b2..3563e50 100644 --- a/apps/game/src/data/enemies.ts +++ b/apps/game/src/data/enemies.ts @@ -29,6 +29,39 @@ motes: number; /** Кадры из атласа chars/clumps_sheet.json. */ frames: [string, string]; + // --- ИИ (необязательные: withDefaults подставляет разумные значения) --- + + /** Дистанция обнаружения героя (при прямой видимости), юнитов. */ + aggroRange?: number; + /** Секунд без прямой видимости, после которых враг отступает к дому. */ + deaggroSec?: number; + /** Ниже этой доли hp — отступает (0 — не отступает никогда). */ + fleeBelowHpFraction?: number; + /** При отступлении убегает, пока герой ближе этого радиуса, юнитов. */ + fleeRange?: number; + /** Радиус слуха шума (уровень >= 0.35), юнитов. */ + hearRadius?: number; + /** Секунд настороженности без новой информации, после которых враг возвращается. */ + warySec?: number; +} + +/** Патруль отдельного спавна: тайлы маршрута (проходится туда-обратно) и пауза на точке. */ +export interface EnemyPatrolDef { + points: { x: number; y: number }[]; + pauseSec: number; +} + +/** Дефолты полей ИИ (контент задаёт только отличия). */ +export function withDefaults(kind: EnemyKindDef): EnemyKindDef { + return { + ...kind, + aggroRange: kind.aggroRange ?? 4, + deaggroSec: kind.deaggroSec ?? 2.5, + fleeBelowHpFraction: kind.fleeBelowHpFraction ?? 0, + fleeRange: kind.fleeRange ?? 4, + hearRadius: kind.hearRadius ?? 5, + warySec: kind.warySec ?? 2.5 + }; } export const ENEMY_KINDS: Record = { @@ -43,7 +76,9 @@ windup: 0, recover: 0.3, motes: 1, - frames: ['clump_crawler_1', 'clump_crawler_2'] + frames: ['clump_crawler_1', 'clump_crawler_2'], + // Сторожит пепел, шатаясь между кустов; слышит шаги вблизи. + hearRadius: 3.5 }, spitter: { id: 'spitter', @@ -57,7 +92,9 @@ recover: 0.4, keepDistance: 2.25, motes: 1, - frames: ['clump_spitter_1', 'clump_spitter_2'] + frames: ['clump_spitter_1', 'clump_spitter_2'], + // Обстреливает издали и не отступает — стоит насмерть. + aggroRange: 5 }, heavy: { id: 'heavy', @@ -70,8 +107,13 @@ windup: 0.6, recover: 0.5, motes: 3, - frames: ['clump_heavy_1', 'clump_heavy_2'] + frames: ['clump_heavy_1', 'clump_heavy_2'], + // Крупный сгусток: глуховат, но с трудом убитый — отступает при малом hp. + hearRadius: 4, + fleeBelowHpFraction: 0.35, + fleeRange: 4.5, + warySec: 3 } }; -/** Точки спавна врагов задаются в data/locations.ts (AREAS[*].enemies). */ +/** Точки спавна и патрули врагов задаются в data/locations.ts (AREAS[*].enemies). */ diff --git a/apps/game/src/data/locations.ts b/apps/game/src/data/locations.ts index d9ab410..864038d 100644 --- a/apps/game/src/data/locations.ts +++ b/apps/game/src/data/locations.ts @@ -1,5 +1,5 @@ import type { NpcDef } from './npcs'; -import type { EnemyKindId } from './enemies'; +import type { EnemyKindId, EnemyPatrolDef } from './enemies'; import { NPCS } from './npcs'; import type { ItemId } from './items'; import { INTERACTABLES, type InteractableDef } from './interactables'; @@ -53,7 +53,7 @@ /** Стартовый тайл (новая игра / респаун / фолбэк return). */ spawn: { x: number; y: number }; npcs: NpcDef[]; - enemies: { kind: EnemyKindId; tile: { x: number; y: number } }[]; + enemies: { kind: EnemyKindId; tile: { x: number; y: number }; patrol?: EnemyPatrolDef }[]; transitions: TransitionDef[]; /** Интерактивные объекты (сундуки, очаги, прилавки). */ interactables?: InteractableDef[]; @@ -75,9 +75,19 @@ enemies: [ { kind: 'crawler', tile: { x: 8, y: 9 } }, { kind: 'crawler', tile: { x: 10, y: 7 } }, - { kind: 'spitter', tile: { x: 19, y: 6 } }, + // Плевун кружит у северо-восточных бугров. + { + kind: 'spitter', + tile: { x: 19, y: 6 }, + patrol: { points: [{ x: 19, y: 6 }, { x: 22, y: 5 }], pauseSec: 1.2 } + }, { kind: 'crawler', tile: { x: 22, y: 21 } }, - { kind: 'heavy', tile: { x: 6, y: 22 } } + // Тяжеловес бродит вдоль юго-западной низины. + { + kind: 'heavy', + tile: { x: 6, y: 22 }, + patrol: { points: [{ x: 6, y: 22 }, { x: 4, y: 20 }], pauseSec: 2 } + } ], transitions: [ // Северо-западный угол: тропа на Серые пруды. diff --git a/apps/game/src/scenes/LocationScene.ts b/apps/game/src/scenes/LocationScene.ts index 7a2b4f1..1a50e2c 100644 --- a/apps/game/src/scenes/LocationScene.ts +++ b/apps/game/src/scenes/LocationScene.ts @@ -147,6 +147,8 @@ if (save?.state) this.game.state.load(save.state); this.player = new PlayerController(this.map, this.heroTextures(), startTile, () => { void this.game.audio.play('sfx/step', 0.35); + // Шаги — тихий шум (0.35): бодрых сгустков настораживает, спящих не будит. + this.combat.noise(this.player.position, 0.35); }); // Герой и NPC — в один depth-слой: глубина = tx + ty (кто юго-восточнее, тот ближе). @@ -186,7 +188,7 @@ } ); for (const s of area.enemies) { - this.combat.spawnEnemy(s.kind, tileToWorld(s.tile.x, s.tile.y)); + this.combat.spawnEnemy(s.kind, tileToWorld(s.tile.x, s.tile.y), s.patrol ? { patrol: s.patrol } : undefined); } this.combatViews = new CombatViews(this.combat, this.actors, this.enemyTextures(), this.world); @@ -695,17 +697,35 @@ ), checkRange('hero.hp', this.playerCombat.hp, 0, PLAYER_COMBAT.maxHp, where), checkWalkable('герой', heroTile, this.map, where), - enemyChecks + enemyChecks, + this.combat.agentInvariants() ); } /** Whitelist-команды для проверок (перемотки/читы). Неизвестная — null. */ agentCommand(name: string, args?: JsonValue): JsonValue { - const a = (args ?? {}) as { x?: number; y?: number; id?: string; value?: number | string | boolean; flag?: string; index?: number }; + const a = (args ?? {}) as { x?: number; y?: number; level?: number; id?: string; value?: number | string | boolean; flag?: string; index?: number }; switch (name) { case 'scene:sleepAll': for (const [, en] of this.combat.enemies) en.brain.putToSleep(9999); return true; + case 'scene:noise': { + // Шум в тайле (юниты моста — тайлы): уровень 0.35 слышат бодрые, 0.7+ будит спящих. + if (typeof a.x !== 'number' || typeof a.y !== 'number') return null; + this.combat.noise(tileToWorld(a.x, a.y), typeof a.level === 'number' ? a.level : 1); + return true; + } + case 'scene:damageEnemy': { + // Урон живому сгустку (проверки отступления); id — фильтр по виду. + if (typeof a.value !== 'number') return null; + for (const [e, en] of this.combat.enemies) { + if (en.brain.dead) continue; + if (typeof a.id === 'string' && en.kind.id !== a.id) continue; + this.combat.damageEnemy(e, en, a.value, this.player.position); + return true; + } + return false; + } case 'scene:give': if (typeof a.id !== 'string') return null; this.game.inventory.add(a.id); diff --git a/apps/game/src/systems/combat/CombatWorld.ts b/apps/game/src/systems/combat/CombatWorld.ts index 01b7a60..4a2dd9f 100644 --- a/apps/game/src/systems/combat/CombatWorld.ts +++ b/apps/game/src/systems/combat/CombatWorld.ts @@ -8,12 +8,21 @@ worldNorm, inCircleW, inConeW, + tileToWorld, type Entity, type System, - type Vec2 + type Vec2, + type Invariant } from '@rpg/engine'; -import { ENEMY_KINDS, type EnemyKindDef, type EnemyKindId } from '../../data/enemies'; -import { EnemyBrain, type EnemyIntent } from './EnemyBrain'; +import { + ENEMY_KINDS, + withDefaults, + type EnemyKindDef, + type EnemyKindId, + type EnemyPatrolDef +} from '../../data/enemies'; +import { EnemyBrain, ENEMY_STATES, NOISE_HEAR_LEVEL, NOISE_WAKE_LEVEL, type EnemyIntent } from './EnemyBrain'; +import { hasLineOfSight, tileOpaque } from './los'; import { PLAYER_COMBAT } from './stats'; /** @@ -26,8 +35,15 @@ brain: EnemyBrain; pos: Vec2; hp: number; + /** Точка спавна — «дом» для возврата после погони. */ + home: Vec2; + /** Точки патруля, мировые юниты (пусто — враг спит в пепле). */ + patrolPoints: Vec2[]; /** Секунды до исчезновения трупа (после смерти). */ corpseTimer: number; + /** Кэш прямой видимости героя: true/false и сек до пересчёта. */ + los: boolean; + losTimer: number; } export interface ProjectileComp { @@ -47,8 +63,21 @@ damagePlayer: (damage: number, from: Vec2) => void; } +/** Окно слышимости шума: событие живёт столько секунд для бодрых ушей. */ +const NOISE_WINDOW = 0.5; +/** Пересчёт LOS раз в 3 тика (60 Гц) — видимость меняется медленнее. */ +const LOS_INTERVAL = 0.05; +/** Бег (отступление) быстрее шага. */ +const RUN_MULT = 1.3; + /** Движение врага: шаг в мировых юнитах с отбраковкой по проходимости. */ -function stepWithCollision(map: IsometricTileMap, pos: Vec2, dir: Vec2, speed: number, dt: number): void { +function stepWithCollision( + map: IsometricTileMap, + pos: Vec2, + dir: Vec2, + speed: number, + dt: number +): void { const nx = pos.x + dir.x * speed * dt; const ny = pos.y + dir.y * speed * dt; const tile = worldToTile(nx, ny, map.data.width, map.data.height); @@ -62,7 +91,12 @@ /** ИИ и движение сгустков. */ class EnemySystem implements System { - constructor(private combat: CombatWorld) {} + /** Колбэк непрозрачности тайлов (карта не меняется за жизнь мира). */ + private opaque: (tx: number, ty: number) => boolean; + + constructor(private combat: CombatWorld) { + this.opaque = tileOpaque({ data: combat.deps.map.data }); + } update(_world: World, dt: number): void { const playerPos = this.combat.deps.getPlayerPos(); @@ -77,17 +111,37 @@ continue; } + // LOS с кэшем: пересчёт раз в LOS_INTERVAL. + en.losTimer -= dt; + if (en.losTimer <= 0) { + en.los = hasLineOfSight(en.pos, playerPos, this.opaque); + en.losTimer = LOS_INTERVAL; + } + const dx = playerPos.x - en.pos.x; const dy = playerPos.y - en.pos.y; const dist = worldDist(en.pos, playerPos) || 1e-4; + const noise = this.combat.noiseFor(en); const intent: EnemyIntent = en.brain.update(dt, { dist, - dirToPlayer: worldNorm(dx, dy) + dirToPlayer: worldNorm(dx, dy), + pos: en.pos, + canSee: en.los, + noise, + patrolPoints: en.patrolPoints, + home: en.home, + hpFraction: en.hp / en.kind.hp }); switch (intent.type) { case 'move': - stepWithCollision(this.combat.deps.map, en.pos, intent.dir, en.kind.speed, dt); + stepWithCollision( + this.combat.deps.map, + en.pos, + intent.dir, + en.kind.speed * (intent.run ? RUN_MULT : 1), + dt + ); break; case 'strike': // Удар доходит, если герой в радиусе тела + замах @@ -98,6 +152,8 @@ case 'shoot': this.combat.spawnProjectile(en.pos, intent.dir, en.kind.attackDamage); void this.combat.deps.audio.play('sfx/spit', 0.6); + // Плевок слышно, но спящих не будит (< 0.7) + this.combat.noise(en.pos, 0.6); break; default: break; @@ -138,6 +194,8 @@ readonly world = new World(); readonly enemies = new Map(); readonly projectiles = new Map(); + /** Последний шум мира (шаги, удары, плевки); живёт NOISE_WINDOW секунд. */ + private lastNoise: { origin: Vec2; level: number; age: number } | null = null; constructor( readonly deps: CombatDeps, @@ -147,16 +205,28 @@ this.world.addSystem(new ProjectileSystem(this)); } - /** Заспавнить сгусток (спит в пепле, пока не разбудит громкий звук). */ - spawnEnemy(kindId: EnemyKindId, pos: Vec2): Entity { - const kind = ENEMY_KINDS[kindId]; + /** + * Заспавнить сгусток. Без patrol спит в пепле (dormant), пока не разбудит + * гулкий звук; с патрулём сразу бродит по маршруту. + */ + spawnEnemy(kindId: EnemyKindId, pos: Vec2, opts?: { patrol?: EnemyPatrolDef }): Entity { + const kind = withDefaults(ENEMY_KINDS[kindId]); const e = this.world.createEntity(); this.enemies.set(e, { kind, - brain: new EnemyBrain(kind), + brain: new EnemyBrain( + kind, + opts?.patrol + ? { points: opts.patrol.points.map((p) => tileToWorld(p.x, p.y)), pauseSec: opts.patrol.pauseSec } + : undefined + ), pos: { ...pos }, hp: kind.hp, - corpseTimer: 0.5 + home: { ...pos }, + patrolPoints: opts?.patrol ? opts.patrol.points.map((p) => tileToWorld(p.x, p.y)) : [], + corpseTimer: 0.5, + los: false, + losTimer: 0 }); return e; } @@ -180,11 +250,11 @@ /** * Короткий удар конусом. Возвращает число поражённых сгустков. - * Побочный эффект: гулкий звук будит спящих в wakeRadius. + * Побочный эффект: гулкий звон (уровень 1.0) будит спящих в wakeRadius. */ playerConeAttack(from: Vec2, dir: Vec2): number { void this.deps.audio.play('sfx/bell_hit', 0.8); - this.loudSound(from, PLAYER_COMBAT.wakeRadius); + this.noise(from, 1.0); this.deps.events.emit('combat:attack', { mode: 'cone', origin: { ...from } }); let hits = 0; @@ -213,18 +283,34 @@ return slept; } - /** Гулкий звук в точке: будит спящих сгустков в радиусе. */ - loudSound(pos: Vec2, radius: number): void { - for (const [, en] of this.enemies) { - if (inCircleW(pos, radius, en.pos) && en.brain.asleep) { - en.brain.hearLoud(); - void this.deps.audio.play('sfx/ash_hiss', 0.5); - this.deps.events.emit('combat:awake', { kind: en.kind.id }); + /** + * Шум в точке мира. level 0.35 — слышат бодрые в hearRadius (патруль + * идёт проверять); 0.7+ — гулкий удар, будит и спящих в wakeRadius; + * 0.6 — плевок/смерть сгустка (слышат, но не просыпаются). + */ + noise(pos: Vec2, level: number): void { + this.lastNoise = { origin: { ...pos }, level, age: 0 }; + if (level >= NOISE_WAKE_LEVEL) { + for (const [, en] of this.enemies) { + if (inCircleW(pos, PLAYER_COMBAT.wakeRadius, en.pos) && en.brain.asleep) { + en.brain.hearLoud(); + void this.deps.audio.play('sfx/ash_hiss', 0.5); + this.deps.events.emit('combat:awake', { kind: en.kind.id }); + } } } } - /** Урон сгустку; смерть — дроп и событие. */ + /** Шум, слышимый конкретному врагу (фильтр по уровню, радиусу слуха и окну). */ + noiseFor(en: EnemyComp): { origin: Vec2; level: number } | null { + const n = this.lastNoise; + if (!n || n.age > NOISE_WINDOW || n.level < NOISE_HEAR_LEVEL) return null; + if (en.brain.asleep) return null; // спящего будит только noise() от 0.7 + if (worldDist(en.pos, n.origin) > (en.kind.hearRadius ?? 5)) return null; + return { origin: { ...n.origin }, level: n.level }; + } + + /** Урон сгустку; смерть — дроп, событие и приглушённый шум. */ damageEnemy(e: Entity, en: EnemyComp, damage: number, from: Vec2): void { en.hp -= damage; en.brain.hurt(); @@ -232,6 +318,7 @@ if (en.hp <= 0) { if (en.brain.die()) { void this.deps.audio.play('sfx/ash_die', 0.8); + this.noise(en.pos, 0.6); this.deps.events.emit('combat:kill', { kind: en.kind.id, tile: { ...en.pos } }); this.onEnemyKilled?.(en.kind); // Отброс трупа от героя — лёгкий визуальный эффект делает CombatViews. @@ -241,7 +328,39 @@ } } + /** Инварианты боевого мира: легальность состояний и позиций врагов. */ + agentInvariants(): Invariant[] { + const where = 'systems/combat'; + const { width, height } = this.deps.map.data; + const out: Invariant[] = []; + for (const [, en] of this.enemies) { + if (!ENEMY_STATES.includes(en.brain.state as (typeof ENEMY_STATES)[number])) { + out.push({ + id: 'enemy-state-legal', + severity: 'error', + message: `${en.kind.id}: неизвестное состояние "${en.brain.state}"`, + where + }); + } + const tx = Math.floor(en.pos.x); + const ty = Math.floor(en.pos.y); + if (tx < 0 || ty < 0 || tx >= width || ty >= height) { + out.push({ + id: 'enemy-in-bounds', + severity: 'error', + message: `${en.kind.id} вне карты (${en.pos.x.toFixed(2)},${en.pos.y.toFixed(2)})`, + where + }); + } + } + return out; + } + update(dt: number): void { + if (this.lastNoise) { + this.lastNoise.age += dt; + if (this.lastNoise.age > NOISE_WINDOW) this.lastNoise = null; + } this.world.update(dt); } } \ No newline at end of file diff --git a/apps/game/src/systems/combat/EnemyBrain.ts b/apps/game/src/systems/combat/EnemyBrain.ts index d6e5fd3..db2a7ab 100644 --- a/apps/game/src/systems/combat/EnemyBrain.ts +++ b/apps/game/src/systems/combat/EnemyBrain.ts @@ -1,18 +1,23 @@ -import { StateMachine, type Vec2 } from '@rpg/engine'; -import { Cooldown } from '@rpg/engine'; +import { StateMachine, Cooldown, type Vec2 } from '@rpg/engine'; import type { EnemyKindDef } from '../../data/enemies'; /** * Чистый ИИ «пепельного сгустка» поверх движкового StateMachine. - * Сенсоры входят (дистанция/направление до героя), намерения выходят - * (куда идти, когда бить/плевать). Без Pixi — тестируется в Vitest. + * Сенсоры входят (дистанция/направление до героя, видимость, шум, патруль), + * намерения выходят (куда идти, когда бить/плевать). Без Pixi — тестируется + * в Vitest. * - * Состояния: dormant (спит в пепле) -> rise (поднимается) -> chase -> windup -> - * attack (удар/плевок) -> recover -> chase; sleeping (от низкого резонанса), dead. + * Состояния: спящий враг (dormant, старт без патруля) -> rise -> chase -> + * windup -> attack -> recover -> chase; патрульный враг: patrol <-> wary -> + * chase; отступление: chase -> flee (низкое hp) / -> return (потерял героя) -> + * patrol | dormant; sleeping (от низкого резонанса), dead. + * + * Шум слышен бодрому врагу от уровня NOISE_HEAR_LEVEL (в радиусе hearRadius — + * фильтрует CombatWorld); спящих будит только уровень >= 0.7 (гулкий удар). */ export type EnemyIntent = | { type: 'idle' } - | { type: 'move'; dir: Vec2 } + | { type: 'move'; dir: Vec2; run?: boolean } | { type: 'strike' } | { type: 'shoot'; dir: Vec2 }; @@ -21,32 +26,84 @@ dist: number; /** Нормализованное направление к герою. */ dirToPlayer: Vec2; + /** Позиция врага (для хождения по патрульным точкам и к дому). */ + pos?: Vec2; + /** Герой в прямой видимости (LOS; по умолчанию true — старые сенсоры). */ + canSee?: boolean; + /** Последний шум в радиусе слуха (уже отфильтрован по hearRadius). */ + noise?: { origin: Vec2; level: number } | null; + /** Точки патруля, мировые юниты (пусто — враг без патруля). */ + patrolPoints?: Vec2[]; + /** Точка спавна — «дом», к которому враг отступает, юниты. */ + home?: Vec2; + /** Доля hp (0..1; по умолчанию 1). */ + hpFraction?: number; } +/** Шум от этого уровня слышит бодрый враг (спящих будит CombatWorld от 0.7). */ +export const NOISE_HEAR_LEVEL = 0.35; + +/** Шум от этого уровня будит спящих (гулкий удар; см. CombatWorld.noise). */ +export const NOISE_WAKE_LEVEL = 0.7; + +/** Легальные состояния автомата (инвариант enemy-state-legal). */ +export const ENEMY_STATES = [ + 'dormant', + 'rise', + 'patrol', + 'wary', + 'chase', + 'windup', + 'attack', + 'recover', + 'hurt', + 'flee', + 'return', + 'sleeping', + 'dead' +] as const; + +/** Дальность, на которой считается «дошёл до точки» (юниты). */ +const ARRIVE_DIST = 0.35; + export class EnemyBrain { readonly sm = new StateMachine(); private attackCd: Cooldown; private sleepTimer = 0; private hurtTimer = 0; + /** Секунд без прямой видимости в погоне. */ + private lostTimer = 0; + /** Таймер настороженности и точка, к которой идём проверять. */ + private waryTimer = 0; + private waryTarget: Vec2 | null = null; + /** Патруль: индекс точки (ping-pong) и пауза на точке, сек. */ + private patrolIndex = 0; + private patrolDir = 1; + private patrolPause = 0; - constructor(readonly kind: EnemyKindDef) { + constructor( + readonly kind: EnemyKindDef, + private patrol?: { points: Vec2[]; pauseSec: number } + ) { this.attackCd = new Cooldown(kind.attackCooldown); - this.sm.add('dormant', {}); - this.sm.add('rise', {}); - this.sm.add('chase', {}); - this.sm.add('windup', {}); - this.sm.add('attack', {}); - this.sm.add('recover', {}); - this.sm.add('hurt', {}); - this.sm.add('sleeping', {}); - this.sm.add('dead', {}); + for (const s of ENEMY_STATES) this.sm.add(s, {}); // Обычные переходы this.sm.transition('dormant', 'wake', 'rise'); this.sm.transition('rise', 'done', 'chase'); + // Патруль: увидел — погоня, услышал шум — насторожился. + this.sm.transition('patrol', 'see', 'chase'); + this.sm.transition('patrol', 'noise', 'wary'); + this.sm.transition('wary', 'see', 'chase'); + this.sm.transition('wary', 'lost', 'return'); this.sm.transition('chase', 'inRange', 'windup'); this.sm.transition('chase', 'exhausted', 'sleeping'); + this.sm.transition('chase', 'lost', 'return'); + this.sm.transition('chase', 'flee', 'flee'); + this.sm.transition('flee', 'calm', 'wary'); + this.sm.transition('return', 'home', this.patrol ? 'patrol' : 'dormant'); + this.sm.transition('return', 'see', 'chase'); this.sm.transition('windup', 'strike', 'attack'); this.sm.transition('attack', 'done', 'recover'); this.sm.transition('recover', 'done', 'chase'); @@ -57,10 +114,10 @@ this.sm.transitionAny('hurt', 'hurt'); this.sm.transitionAny('die', 'dead'); - this.sm.change('dormant'); + this.sm.change(this.patrol ? 'patrol' : 'dormant'); } - /** Громкий звук рядом — проснуться. */ + /** Гулкий звук рядом — проснуться (только для спящих; уровень фильтрует CombatWorld). */ hearLoud(): void { if (this.sm.current === 'dormant' || this.sm.current === 'sleeping') { this.sm.handleEvent('wake'); @@ -105,6 +162,9 @@ this.sm.update(dt); this.attackCd.update(dt); + const canSee = senses.canSee ?? true; + const aggro = this.kind.aggroRange ?? 4; + // Таймеры поверх автомата if (this.hurtTimer > 0) { this.hurtTimer -= dt; @@ -123,33 +183,16 @@ } switch (this.sm.current) { - case 'chase': { - const inRange = senses.dist <= this.kind.attackRange; - if (inRange && this.kind.keepDistance === undefined && this.attackCd.ready) { - // контактный (ползун): без замаха - if (this.kind.windup === 0) { - this.attackCd.triggerForce(); - return { type: 'strike' }; - } - this.sm.handleEvent('inRange'); - return { type: 'idle' }; - } - // Плевун держит дистанцию - if (this.kind.keepDistance !== undefined) { - if (senses.dist < this.kind.keepDistance - 0.4) { - return { type: 'move', dir: negate(senses.dirToPlayer) }; - } - if (senses.dist <= this.kind.attackRange && this.attackCd.ready) { - this.sm.handleEvent('inRange'); - return { type: 'idle' }; - } - if (senses.dist > this.kind.keepDistance + 0.4) { - return { type: 'move', dir: senses.dirToPlayer }; - } - return { type: 'idle' }; - } - return { type: 'move', dir: senses.dirToPlayer }; - } + case 'patrol': + return this.updatePatrol(dt, senses, canSee, aggro); + case 'wary': + return this.updateWary(dt, senses, canSee, aggro); + case 'chase': + return this.updateChase(dt, senses, canSee, aggro); + case 'flee': + return this.updateFlee(senses); + case 'return': + return this.updateReturn(senses, canSee, aggro); case 'windup': { if (this.sm.time >= this.kind.windup) { this.sm.handleEvent('strike'); @@ -174,6 +217,163 @@ return { type: 'idle' }; } } + + /** Патруль: ход по точкам туда-обратно; герой/шум вырывают из круга. */ + private updatePatrol(dt: number, senses: EnemySenses, canSee: boolean, aggro: number): EnemyIntent { + const seen = this.checkSee(senses, canSee, aggro); + if (seen) return { type: 'idle' }; + const noise = this.checkNoise(senses); + if (noise) return { type: 'idle' }; + + const points = this.patrol?.points ?? senses.patrolPoints ?? []; + if (points.length === 0) return { type: 'idle' }; + if (this.patrolPause > 0) { + this.patrolPause -= dt; + return { type: 'idle' }; + } + const move = this.dirToward(senses.pos, points[this.patrolIndex]!); + if (move === null) { + // Точка достигнута: пауза и разворот на следующую (ping-pong). + this.patrolPause = this.patrol?.pauseSec ?? 0; + this.advancePatrolIndex(points.length); + } + return move ? { type: 'move', dir: move } : { type: 'idle' }; + } + + /** Настороженность: идём к источнику шума, осматриваемся warySec — и домой. */ + private updateWary(dt: number, senses: EnemySenses, canSee: boolean, aggro: number): EnemyIntent { + const seen = this.checkSee(senses, canSee, aggro); + if (seen) return { type: 'idle' }; + const noise = this.checkNoise(senses); + if (noise) return { type: 'idle' }; // свежий шум обновляет цель и таймер + + this.waryTimer -= dt; + if (this.waryTimer <= 0) { + this.sm.handleEvent('lost'); + return { type: 'idle' }; + } + const move = this.waryTarget ? this.dirToward(senses.pos, this.waryTarget) : null; + return move ? { type: 'move', dir: move } : { type: 'idle' }; + } + + /** Погоня: потерял героя дольше deaggroSec — домой; мало hp — прочь. */ + private updateChase(dt: number, senses: EnemySenses, canSee: boolean, aggro: number): EnemyIntent { + if (canSee) { + this.lostTimer = 0; + } else { + this.lostTimer += dt; + if (this.lostTimer >= (this.kind.deaggroSec ?? 2.5)) { + this.sm.handleEvent('lost'); + return { type: 'idle' }; + } + } + const fleeAt = this.kind.fleeBelowHpFraction ?? 0; + if (fleeAt > 0 && (senses.hpFraction ?? 1) <= fleeAt) { + this.sm.handleEvent('flee'); + return { type: 'idle' }; + } + if (senses.dist > aggro * 1.5) { + // Убежал слишком далеко даже с LOS — не гоняемся по всей карте. + this.sm.handleEvent('lost'); + return { type: 'idle' }; + } + + const inRange = senses.dist <= this.kind.attackRange; + if (inRange && this.kind.keepDistance === undefined && this.attackCd.ready) { + // контактный (ползун): без замаха + if (this.kind.windup === 0) { + this.attackCd.triggerForce(); + return { type: 'strike' }; + } + this.sm.handleEvent('inRange'); + return { type: 'idle' }; + } + // Плевун держит дистанцию + if (this.kind.keepDistance !== undefined) { + if (senses.dist < this.kind.keepDistance - 0.4) { + return { type: 'move', dir: negate(senses.dirToPlayer) }; + } + if (senses.dist <= this.kind.attackRange && this.attackCd.ready) { + this.sm.handleEvent('inRange'); + return { type: 'idle' }; + } + if (senses.dist > this.kind.keepDistance + 0.4) { + return { type: 'move', dir: senses.dirToPlayer }; + } + return { type: 'idle' }; + } + return { type: 'move', dir: senses.dirToPlayer }; + } + + /** Отступление: прочь от героя бегом, пока он не отстал на fleeRange. */ + private updateFlee(senses: EnemySenses): EnemyIntent { + if (senses.dist >= (this.kind.fleeRange ?? 4)) { + // Оторвался: стоит и осматривается warySec, потом домой. + this.waryTarget = null; + this.waryTimer = this.kind.warySec ?? 2.5; + this.sm.handleEvent('calm'); + return { type: 'idle' }; + } + return { type: 'move', dir: negate(senses.dirToPlayer), run: true }; + } + + /** Возврат к дому; на месте — снова в патруль или обратно в пепел. */ + private updateReturn(senses: EnemySenses, canSee: boolean, aggro: number): EnemyIntent { + const seen = this.checkSee(senses, canSee, aggro); + if (seen) return { type: 'idle' }; + const noise = this.checkNoise(senses); + if (noise) return { type: 'idle' }; + if (senses.home) { + const move = this.dirToward(senses.pos, senses.home); + if (move === null) { + this.lostTimer = 0; + this.sm.handleEvent('home'); + } else { + return { type: 'move', dir: move }; + } + } + return { type: 'idle' }; + } + + /** Переход «увидел героя» (LOS + aggroRange); true — событие отправлено. */ + private checkSee(senses: EnemySenses, canSee: boolean, aggro: number): boolean { + if (canSee && senses.dist <= aggro) { + this.lostTimer = 0; + this.sm.handleEvent('see'); + return true; + } + return false; + } + + /** Переход «услышал шум» (уровень >= порога); true — событие отправлено. */ + private checkNoise(senses: EnemySenses): boolean { + const noise = senses.noise; + if (noise && noise.level >= NOISE_HEAR_LEVEL) { + this.waryTarget = { ...noise.origin }; + this.waryTimer = this.kind.warySec ?? 2.5; + this.sm.handleEvent('noise'); + return true; + } + return false; + } + + /** Направление к точке; null — точка достигнута (в пределах ARRIVE_DIST). */ + private dirToward(from: Vec2 | undefined, target: Vec2): Vec2 | null { + if (!from) return null; + const dx = target.x - from.x; + const dy = target.y - from.y; + const d = Math.hypot(dx, dy); + if (d < ARRIVE_DIST) return null; + return { x: dx / d, y: dy / d }; + } + + private advancePatrolIndex(count: number): void { + if (count < 2) return; + if (this.patrolIndex + this.patrolDir >= count || this.patrolIndex + this.patrolDir < 0) { + this.patrolDir = -this.patrolDir; + } + this.patrolIndex += this.patrolDir; + } } function negate(v: Vec2): Vec2 { diff --git a/apps/game/src/systems/combat/__tests__/EnemyAI.test.ts b/apps/game/src/systems/combat/__tests__/EnemyAI.test.ts new file mode 100644 index 0000000..1f2d9a8 --- /dev/null +++ b/apps/game/src/systems/combat/__tests__/EnemyAI.test.ts @@ -0,0 +1,228 @@ +import { describe, expect, it } from 'vitest'; +import { ENEMY_KINDS, withDefaults } from '../../../data/enemies'; +import { EnemyBrain, NOISE_HEAR_LEVEL, NOISE_WAKE_LEVEL, type EnemySenses } from '../EnemyBrain'; +import { CombatWorld } from '../CombatWorld'; +import type { IsometricTileMap, AudioManager, EventBus } from '@rpg/engine'; + +/** Тик ИИ на 1/60 с; возвращает последнее намерение. */ +const run = (b: EnemyBrain, seconds: number, senses: EnemySenses): ReturnType => { + let intent: ReturnType = { type: 'idle' }; + for (let i = 0; i < Math.round(seconds * 60); i++) intent = b.update(1 / 60, senses); + return intent; +}; + +/** Патрульные точки в юнитах и сенсоры «герой далеко и не виден». */ +const points = [ + { x: 10.5, y: 7.5 }, + { x: 12.5, y: 9.5 } +]; +const far = (pos: { x: number; y: number }): EnemySenses => ({ + dist: 30, + dirToPlayer: { x: 1, y: 0 }, + pos, + canSee: false, + home: { x: 10.5, y: 7.5 }, + hpFraction: 1 +}); + +describe('EnemyBrain: патруль', () => { + it('с патрулём стартует бодрым в patrol, без — спит в пепле', () => { + const patrolled = new EnemyBrain(withDefaults(ENEMY_KINDS.crawler), { points, pauseSec: 1 }); + expect(patrolled.state).toBe('patrol'); + expect(patrolled.asleep).toBe(false); + const sleeper = new EnemyBrain(ENEMY_KINDS.crawler); + expect(sleeper.state).toBe('dormant'); + expect(sleeper.asleep).toBe(true); + }); + + it('дошёл до точки — пауза, потом идёт к следующей (ping-pong)', () => { + const b = new EnemyBrain(ENEMY_KINDS.heavy, { points, pauseSec: 1 }); + // Стоим на точке 0 — сразу пауза + expect(b.update(1 / 60, far(points[0]!)).type).toBe('idle'); + run(b, 1.1, far(points[0]!)); + // Пауза истекла — движение к точке 1 (вниз-вправо, диагональ изометрии) + const move = run(b, 0.05, far(points[0]!)); + expect(move.type).toBe('move'); + if (move.type === 'move') { + expect(move.dir.x).toBeCloseTo(0.707, 1); + expect(move.dir.y).toBeCloseTo(0.707, 1); + expect(move.run).toBeUndefined(); // патруль шагом, не бегом + } + // Дошли до точки 1 — снова пауза и разворот назад + run(b, 3.2, far(points[1]!)); + const back = run(b, 0.05, far(points[1]!)); + expect(back.type).toBe('move'); + if (back.type === 'move') expect(back.dir.x).toBeLessThan(0); + }); + + it('шум уровня 0.35 вырывает из патруля в настороженность', () => { + const b = new EnemyBrain(ENEMY_KINDS.crawler, { points, pauseSec: 1 }); + const pos = { x: 10.5, y: 7.5 }; + b.update(1 / 60, { ...far(pos), noise: { origin: { x: 11.5, y: 8.5 }, level: NOISE_HEAR_LEVEL } }); + expect(b.state).toBe('wary'); + // Идёт к источнику шума + const move = b.update(1 / 60, { ...far(pos), noise: null }); + expect(move.type).toBe('move'); + }); + + it('тихий шум (ниже порога) не настораживает', () => { + const b = new EnemyBrain(ENEMY_KINDS.crawler, { points, pauseSec: 1 }); + b.update(1 / 60, { ...far(points[0]!), noise: { origin: { x: 11, y: 8 }, level: 0.2 } }); + expect(b.state).toBe('patrol'); + }); +}); + +describe('EnemyBrain: настороженность', () => { + it('увидел героя в aggroRange — погоня', () => { + const b = new EnemyBrain(ENEMY_KINDS.crawler, { points, pauseSec: 1 }); + const pos = { x: 10.5, y: 7.5 }; + b.update(1 / 60, { ...far(pos), noise: { origin: pos, level: 0.5 } }); + expect(b.state).toBe('wary'); + b.update(1 / 60, { dist: 3, dirToPlayer: { x: 1, y: 0 }, pos, canSee: true }); + expect(b.state).toBe('chase'); + }); + + it('герой не показался за warySec — возврат к дому', () => { + const b = new EnemyBrain(ENEMY_KINDS.heavy, { points, pauseSec: 1 }); // warySec 3 + const pos = { x: 10.5, y: 7.5 }; + b.update(1 / 60, { ...far(pos), noise: { origin: { x: 11, y: 8 }, level: 0.5 } }); + run(b, 1, { ...far({ x: 11, y: 8 }), noise: null }); + expect(b.state).toBe('wary'); + run(b, 2.2, { ...far({ x: 11, y: 8 }), noise: null }); + expect(b.state).toBe('return'); + // Идёт к дому и на месте — снова в патруль + run(b, 3, far({ x: 10.7, y: 7.7 })); + expect(b.state).toBe('patrol'); + }); +}); + +describe('EnemyBrain: погоня и отступление', () => { + it('потерял героя из виду дольше deaggroSec — возврат', () => { + const b = new EnemyBrain(ENEMY_KINDS.crawler); + b.hearLoud(); + run(b, 0.7, { dist: 3, dirToPlayer: { x: 1, y: 0 } }); + expect(b.state).toBe('chase'); + // Герой скрылся: 2.5 с без LOS — и враг уходит домой (позицию двигаем руками) + let pos = { x: 10, y: 10 }; + for (let i = 0; i < Math.round(2.6 * 60); i++) { + b.update(1 / 60, { dist: 3, dirToPlayer: { x: 1, y: 0 }, canSee: false, pos, home: { x: 5, y: 5 } }); + pos.x -= 0.007; + } + expect(b.state).toBe('return'); + }); + + it('герой дальше 1.5 aggro — не гоняется по всей карте', () => { + const b = new EnemyBrain(ENEMY_KINDS.crawler); + b.hearLoud(); + run(b, 0.7, { dist: 3, dirToPlayer: { x: 1, y: 0 } }); + run(b, 0.1, { dist: 7, dirToPlayer: { x: 1, y: 0 } }); // 1.5 * aggro 4 = 6 + expect(b.state).toBe('return'); + }); + + it('мало hp — бежит прочь бегом, оторвался — успокоился', () => { + const b = new EnemyBrain(withDefaults(ENEMY_KINDS.heavy)); // fleeBelowHpFraction 0.35 + const chase: EnemySenses = { dist: 1, dirToPlayer: { x: 1, y: 0 } }; + b.hearLoud(); + run(b, 0.7, chase); + run(b, 0.1, { ...chase, hpFraction: 0.3 }); + expect(b.state).toBe('flee'); + const flee = b.update(1 / 60, { ...chase, hpFraction: 0.3 }); + expect(flee.type).toBe('move'); + if (flee.type === 'move') { + expect(flee.dir.x).toBe(-1); // прочь от героя + expect(flee.run).toBe(true); + } + // Герой отстал на fleeRange (4.5) — отбой в настороженность + run(b, 0.1, { dist: 4.6, dirToPlayer: { x: 1, y: 0 }, hpFraction: 0.3 }); + expect(b.state).toBe('wary'); + }); + + it('возврат без патруля — дошёл до дома и снова спит', () => { + const b = new EnemyBrain(ENEMY_KINDS.crawler); + b.hearLoud(); + run(b, 0.7, { dist: 3, dirToPlayer: { x: 1, y: 0 } }); + run(b, 2.6, { dist: 3, dirToPlayer: { x: 1, y: 0 }, canSee: false, home: { x: 5, y: 5 } }); + run(b, 5, { dist: 30, dirToPlayer: { x: 1, y: 0 }, canSee: false, pos: { x: 4.8, y: 4.8 }, home: { x: 5, y: 5 } }); + expect(b.state).toBe('dormant'); + expect(b.asleep).toBe(true); + }); +}); + +// ---------- CombatWorld: пороги шума ---------- + +/** Фейковая карта: данные 20×20 травы, проходима всюду (Pixi не нужен). */ +function fakeMap(): IsometricTileMap { + const data = { + width: 20, + height: 20, + tiles: new Array(400).fill(0), + blocked: [], + tall: undefined + }; + return { data, isWalkable: () => true } as unknown as IsometricTileMap; +} + +function fakeWorld(playerPos: { x: number; y: number }): CombatWorld { + return new CombatWorld({ + map: fakeMap(), + events: { emit: () => {} } as unknown as EventBus, + audio: { play: () => Promise.resolve() } as unknown as AudioManager, + getPlayerPos: () => playerPos, + damagePlayer: () => {} + }); +} + +describe('CombatWorld: шум будит только от гулкого', () => { + it('шаги героя (0.35) не будят спящего, гулкий звон (1.0) будит', () => { + const hero = { x: 20, y: 20 }; + const w = fakeWorld(hero); + const e = w.spawnEnemy('crawler', { x: 10.5, y: 10.5 }); + const en = w.enemies.get(e)!; + expect(en.brain.asleep).toBe(true); + + w.noise({ x: 10.5, y: 10.5 }, 0.35); // шаги прямо рядом + w.update(1 / 60); + expect(en.brain.asleep).toBe(true); + + w.noise({ x: 10.5, y: 10.5 }, NOISE_WAKE_LEVEL); // гулкий удар + expect(en.brain.asleep).toBe(false); + expect(en.brain.state).toBe('rise'); + }); + + it('бодрый патрульный слышит тихий шум в hearRadius и настораживается', () => { + const hero = { x: 20, y: 20 }; + const w = fakeWorld(hero); + const e = w.spawnEnemy('heavy', { x: 10.5, y: 10.5 }, { + patrol: { points: [{ x: 10, y: 10 }, { x: 12, y: 12 }], pauseSec: 1 } + }); + const en = w.enemies.get(e)!; + expect(en.brain.state).toBe('patrol'); + // Шаги героя в 1 юните — в hearRadius 4 + w.noise({ x: 11.5, y: 10.5 }, 0.35); + w.update(1 / 60); + expect(en.brain.state).toBe('wary'); + }); + + it('шум вне радиуса слуха не слышен', () => { + const hero = { x: 20, y: 20 }; + const w = fakeWorld(hero); + const e = w.spawnEnemy('crawler', { x: 10.5, y: 10.5 }, { + patrol: { points: [{ x: 10, y: 10 }, { x: 12, y: 12 }], pauseSec: 1 } + }); + const en = w.enemies.get(e)!; + // hearRadius ползуна 3.5, шум в 20 юнитах + w.noise({ x: 18.5, y: 10.5 }, 0.5); + w.update(1 / 60); + expect(en.brain.state).toBe('patrol'); + }); + + it('инварианты боевого мира пусты на честном ИИ', () => { + const hero = { x: 20, y: 20 }; + const w = fakeWorld(hero); + w.spawnEnemy('crawler', { x: 10.5, y: 10.5 }, { + patrol: { points: [{ x: 10, y: 10 }, { x: 12, y: 12 }], pauseSec: 1 } + }); + for (let i = 0; i < 600; i++) w.update(1 / 60); + expect(w.agentInvariants()).toEqual([]); + }); +}); \ No newline at end of file diff --git a/apps/game/src/systems/combat/__tests__/los.test.ts b/apps/game/src/systems/combat/__tests__/los.test.ts new file mode 100644 index 0000000..2cbbe93 --- /dev/null +++ b/apps/game/src/systems/combat/__tests__/los.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest'; +import { hasLineOfSight, tileOpaque } from '../los'; +import { TILES, buildMeadowsMap } from '../../../data/map'; + +/** Карта 10x10 с одной стеной в центре. */ +function mapWithWall(): { width: number; height: number; tiles: number[]; tall: Record } { + const tiles = new Array(100).fill(TILES.GRASS); + tiles[5 * 10 + 5] = TILES.HOUSE; // tall-объект + return { width: 10, height: 10, tiles, tall: { [TILES.HOUSE]: { height: 1, ground: 0 } } }; +} + +describe('hasLineOfSight', () => { + const opaque = tileOpaque({ data: mapWithWall() }); + + it('свободная линия видна', () => { + expect(hasLineOfSight({ x: 0.5, y: 0.5 }, { x: 4.5, y: 0.5 }, opaque)).toBe(true); + }); + + it('стена в середине перекрывает обзор', () => { + expect(hasLineOfSight({ x: 0.5, y: 5.5 }, { x: 9.5, y: 5.5 }, opaque)).toBe(false); + }); + + it('линия мимо стены видит', () => { + expect(hasLineOfSight({ x: 0.5, y: 0.5 }, { x: 9.5, y: 4.5 }, opaque)).toBe(true); + }); + + it('короткие отрезки и нулевая длина видимы', () => { + expect(hasLineOfSight({ x: 3, y: 3 }, { x: 3.2, y: 3 }, opaque)).toBe(true); + expect(hasLineOfSight({ x: 3, y: 3 }, { x: 3, y: 3 }, opaque)).toBe(true); + }); + + it('вне карты — непрозрачно', () => { + expect(hasLineOfSight({ x: 9.5, y: 5.5 }, { x: 15.5, y: 5.5 }, opaque)).toBe(false); + }); +}); + +describe('tileOpaque на реальной карте', () => { + it('дерево/дом непрозрачны, трава/тропа/вода прозрачны', () => { + const map = { data: buildMeadowsMap() }; + const is = tileOpaque(map); + expect(is(2, 12)).toBe(true); // дерево + expect(is(1, 7)).toBe(true); // дерево + expect(is(14, 14)).toBe(false); // трава у спавна + expect(is(19, 7)).toBe(false); // вода прозрачна + }); +}); \ No newline at end of file diff --git a/apps/game/src/systems/combat/los.ts b/apps/game/src/systems/combat/los.ts new file mode 100644 index 0000000..37389a7 --- /dev/null +++ b/apps/game/src/systems/combat/los.ts @@ -0,0 +1,47 @@ +import type { Vec2 } from '@rpg/engine'; + +/** + * Линия видимости по сетке (чистая математика, без Pixi). + * Отрезок дискретизируется шагом 0.25 юнита; тайл с непрозрачным объектом + * (стена, дом, башня, дерево) перекрывает обзор. Не в движке: правила + * прозрачности зависят от игры, второй пользователь не виден. + */ + +/** Шаг дискретизации отрезка, юнитов. */ +const STEP = 0.25; + +/** Виден ли `to` из `from` с учётом непрозрачных тайлов. */ +export function hasLineOfSight( + from: Vec2, + to: Vec2, + isOpaque: (tx: number, ty: number) => boolean +): boolean { + const dx = to.x - from.x; + const dy = to.y - from.y; + const len = Math.hypot(dx, dy); + if (len < 1e-6) return true; + const n = Math.ceil(len / STEP); + const ux = dx / n; + const uy = dy / n; + // Концевые точки не проверяем: сами враг/герой стоят в своих тайлах. + for (let i = 1; i < n; i++) { + const x = from.x + ux * i; + const y = from.y + uy * i; + if (isOpaque(Math.floor(x), Math.floor(y))) return false; + } + return true; +} + +/** + * Непрозрачен ли тайл карты: высокий объект (TallSpec) перекрывает взгляд. + * Вода и ровный пепел прозрачны — сгустки видят через низину. + */ +export function tileOpaque(map: { + data: { width: number; height: number; tiles: number[]; tall?: Record }; +}): (tx: number, ty: number) => boolean { + return (tx: number, ty: number): boolean => { + const { width, height, tiles, tall } = map.data; + if (tx < 0 || ty < 0 || tx >= width || ty >= height) return true; + return tall?.[tiles[ty * width + tx]] !== undefined; + }; +} \ No newline at end of file diff --git a/docs/demo.md b/docs/demo.md index 19f4801..a6d0811 100644 --- a/docs/demo.md +++ b/docs/demo.md @@ -122,7 +122,13 @@ повторного диалога Милы по вару (`whenVar motes`), инвариант `interact-used-consistent` в мосте, `walkTo` пропускает узлы-объекты, сценарий `tools/checks/interact-world.mjs`. -- **Батч 4**: ИИ врагов (LOS/шум/патруль/wary/flee/return). +- ✅ **Батч 4 — ИИ врагов** (готово): LOS по сетке (`systems/combat/los.ts`, + кэш раз в 3 тика), шум мира `CombatWorld.noise` (шаги героя 0.35, удар + конусом 1.0, плевок/смерть 0.6; спящих будит только ≥ 0.7), состояния + `patrol`/`wary`/`flee`/`return` в `EnemyBrain` (патруль — per-spawn в + `AREAS[*].enemies`), бег при отступлении (`run` в `EnemyIntent.move`), + инварианты `enemy-state-legal`/`enemy-in-bounds`, команды + `scene:noise`/`scene:damageEnemy`, сценарий `tools/checks/ai.mjs`. - Принцип среза: каждая геймплейная система имеет сценарий в `tools/checks/`. **Дальше (запрос игрока, после моста):** diff --git a/docs/engine/agent.md b/docs/engine/agent.md index 300e73c..39789cc 100644 --- a/docs/engine/agent.md +++ b/docs/engine/agent.md @@ -45,7 +45,14 @@ Whitelist-команды `LocationScene.agentCommand` (для перемоток в проверках): `scene:sleepAll`, `scene:give {id}`, `scene:setVar {id, value}`, `scene:setFlag {flag}`, `scene:teleport {x, y}`, `scene:route {x, y}` (маршрут A*), `scene:pickChoice {index}`, -`scene:skipCutscene`. Команда вне whitelist возвращает `null` — расширять осознанно. +`scene:skipCutscene`, `scene:noise {x, y, level}` (шум в тайле: 0.35 — бодрые +слышат в hearRadius, 0.7+ — будит спящих), `scene:damageEnemy {id, value}` +(урон сгустку — проверки отступления). Команда вне whitelist возвращает +`null` — расширять осознанно. + +Состояние врага читается из снапшота: `s.enemies[i].{kind, state, hp, pos, +asleep, dead}` — состояния `dormant/rise/patrol/wary/chase/windup/attack/ +recover/hurt/flee/return/sleeping/dead` (см. `systems/combat/EnemyBrain.ts`). ## window.__agent (только DEV) diff --git a/docs/engine/practices.md b/docs/engine/practices.md index 5303174..9152a7d 100644 --- a/docs/engine/practices.md +++ b/docs/engine/practices.md @@ -98,13 +98,31 @@ ## Ситуация: пишу новый сценарий tools/checks/ 1. Каркас: `import { startDevServer, openGame, Checks } from '../agent-lib.mjs'`; - `export default async function ({ pretty })`; вернуть `c.finish({pretty})`. + `export default async function ({ pretty })`; вернуть + `c.finish({pretty}).ok ? 0 : 1` — CLI `agent.mjs run` ждёт числовой код. 2. Проверка: возвращает `false` или кидает исключение при провале (строка — это детали успеха!); удобнее `c.expect(cond, msg, details)`. 3. Ввод — через мост в юнитах (`walkTo`/`tapTile`/`press`), не через реальные клики по CSS-пикселям. 4. Текст реакций мира читай из `s.lastToast` (текст + тик) — не из скриншотов. +## Ситуация: добавляю врага / меняю ИИ + +1. Вид — `data/enemies.ts` (`ENEMY_KINDS`): базовые статы + поля ИИ + (`aggroRange`, `deaggroSec`, `fleeBelowHpFraction`, `hearRadius`, `warySec`; + дефолты — `withDefaults`). Держись: спящий (dormant) будится только гулким + звуком (шум ≥ 0.7), шаги героя (0.35) лишь настораживают бодрых. +2. Патруль — **per-spawn**, не per-kind: в `AREAS[*].enemies` у точки спавна + `patrol: { points: [тайлы], pauseSec }` (маршрут специфичен для инстанса; + per-kind заставил бы ползунов разных областей ходить по одним тайлам). + Точки проверяй по ASCII-дампу карты (проходимость) и держи маршруты вдали + от игровых троп, иначе сломаются смоуки акта. +3. Проверка без боёв: `scene:noise`/`scene:damageEnemy`/`scene:teleport` + (см. `docs/engine/agent.md`) + состояния из `s.enemies[i].state`. + Пример — `tools/checks/ai.mjs`. +4. Позицию врага в юнит-тестах двигай руками: мозг чистый, сенсор `pos` — + это то, что подал тест (см. `EnemyAI.test.ts`). + ## Грабли среды (кратко, подробности в CLAUDE.md) - `Assets.load` без `Assets.init()` висит навсегда — грузи только через `AssetLoader`. diff --git a/tools/agent.mjs b/tools/agent.mjs index 18145de..9cb7691 100644 --- a/tools/agent.mjs +++ b/tools/agent.mjs @@ -94,7 +94,8 @@ { name: 'agent:invariants', run: agentInvariants }, { name: 'transitions', run: () => runScenario('transitions', 'tools/checks/transitions.mjs') }, { name: 'interact', run: () => runScenario('interact', 'tools/checks/interact.mjs') }, - { name: 'interact-world', run: () => runScenario('interact-world', 'tools/checks/interact-world.mjs') } + { name: 'interact-world', run: () => runScenario('interact-world', 'tools/checks/interact-world.mjs') }, + { name: 'ai', run: () => runScenario('ai', 'tools/checks/ai.mjs') } ]; const only = args.only ?? all.map((p) => p.name); const skip = args.skip ?? []; diff --git a/tools/checks/ai.mjs b/tools/checks/ai.mjs new file mode 100644 index 0000000..1b6fbe2 --- /dev/null +++ b/tools/checks/ai.mjs @@ -0,0 +1,108 @@ +/** + * Сценарий ai — ИИ врагов (батч 4). + * 1) тяжеловес с патрулём стартует бодрым в patrol и реально ходит; + * 2) тихий шум (0.35) при далёком герое — настороженность (wary); + * 3) не увидел героя — wary гаснет, возврат к дому и снова patrol; + * 4) герой рядом с LOS — погоня (chase); + * 5) герой далеко — потеря погони (return -> patrol); + * 6) мало hp — отступление бегом (flee), дистанция растёт; + * 7) инварианты (enemy-state-legal, enemy-in-bounds) чисты. + * Запуск: node tools/agent.mjs run tools/checks/ai.mjs + */ +import { startDevServer, openGame, Checks } from '../agent-lib.mjs'; + +const dist = (a, b) => Math.hypot(a.x - b.x, a.y - b.y); +/** Тяжеловес из снапшота. */ +const heavy = (s) => (s.enemies ?? []).find((e) => e.kind === 'heavy'); + +export default async function ({ pretty }) { + const c = new Checks('ai'); + const server = await startDevServer(); + let ctx; + try { + await c.run('патруль: тяжеловес бодр и ходит по маршруту', async () => { + ctx = await openGame({ url: server.url, newGame: true }); + const s0 = await ctx.agent.snapshot(); + const h0 = heavy(s0); + c.expect(h0?.state === 'patrol', 'тяжеловес не в patrol на старте', h0); + // Спавн (6,22), патруль к (4,20): позиция меняется без участия героя. + const w = await ctx.agent.waitFor( + '(s.enemies.find((e) => e.kind === "heavy")?.pos.x ?? 9) < 6', + { timeoutTicks: 600 } + ); + c.expect(w.ok, 'тяжеловес не сдвинулся с места в патруле', w.snapshot.enemies); + return null; + }); + + await c.run('тихий шум настораживает патрульного', async () => { + // Герой далеко (спавн 14,14), шум у маршрута тяжеловеса. + await ctx.agent.command('scene:noise', { x: 5, y: 21, level: 0.35 }); + const w = await ctx.agent.waitFor( + 's.enemies.find((e) => e.kind === "heavy")?.state === "wary"', + { timeoutTicks: 300 } + ); + c.expect(w.ok, 'тяжеловес не насторожился от шума', w.snapshot.enemies); + return null; + }); + + await c.run('настороженность гаснет: wary -> return -> patrol', async () => { + const w = await ctx.agent.waitFor( + 's.enemies.find((e) => e.kind === "heavy")?.state === "patrol"', + { timeoutTicks: 900 } + ); + c.expect(w.ok, 'тяжеловес не вернулся к патрулю', w.snapshot.enemies); + return null; + }); + + await c.run('герой рядом — погоня', async () => { + await ctx.agent.waitFor('!s.transitioning', { timeoutTicks: 300 }); + await ctx.agent.command('scene:teleport', { x: 5, y: 22 }); + const w = await ctx.agent.waitFor( + 's.enemies.find((e) => e.kind === "heavy")?.state === "chase"', + { timeoutTicks: 300 } + ); + c.expect(w.ok, 'тяжеловес не погнался за героем', w.snapshot.enemies); + return null; + }); + + await c.run('герой далеко — погоня гаснет и враг уходит домой', async () => { + await ctx.agent.command('scene:teleport', { x: 14, y: 14 }); + const w = await ctx.agent.waitFor( + 's.enemies.find((e) => e.kind === "heavy")?.state === "patrol"', + { timeoutTicks: 900 } + ); + c.expect(w.ok, 'тяжеловес не отступил к дому', w.snapshot.enemies); + return null; + }); + + await c.run('мало hp — отступление бегом', async () => { + await ctx.agent.command('scene:teleport', { x: 6, y: 21 }); + const dmg = await ctx.agent.command('scene:damageEnemy', { id: 'heavy', value: 6 }); + c.expect(dmg === true, 'scene:damageEnemy не нашла тяжеловеса'); + const w = await ctx.agent.waitFor( + 's.enemies.find((e) => e.kind === "heavy")?.state === "flee"', + { timeoutTicks: 300 } + ); + c.expect(w.ok, 'тяжеловес не отступает при низком hp', w.snapshot.enemies); + // Держится подальше: дистанция до неподвижного героя растёт. + const s1 = await ctx.agent.snapshot(); + const d1 = dist(heavy(s1).pos, s1.hero.pos); + await ctx.agent.waitFor('false', { timeoutTicks: 60 }); // 1 сек наблюдения + const s2 = await ctx.agent.snapshot(); + const d2 = dist(heavy(s2).pos, s2.hero.pos); + c.expect(d2 > d1, 'отступающий не отдаляется', { d1, d2 }); + return null; + }); + + await c.run('инварианты ИИ чисты', async () => { + const inv = await ctx.agent.invariants(); + const errors = (inv ?? []).filter((i) => i.severity === 'error'); + c.expect(errors.length === 0, 'инварианты нарушены', errors); + return null; + }); + } finally { + await ctx?.browser?.close().catch(() => {}); + await server.stop(); + } + return c.finish({ pretty }).ok ? 0 : 1; +} \ No newline at end of file