diff --git a/apps/game/assets/chars/clump_crawler_1.png b/apps/game/assets/chars/clump_crawler_1.png new file mode 100644 index 0000000..47d4dff --- /dev/null +++ b/apps/game/assets/chars/clump_crawler_1.png Binary files differ diff --git a/apps/game/assets/chars/clump_crawler_2.png b/apps/game/assets/chars/clump_crawler_2.png new file mode 100644 index 0000000..c3b7ff5 --- /dev/null +++ b/apps/game/assets/chars/clump_crawler_2.png Binary files differ diff --git a/apps/game/assets/chars/clump_heavy_1.png b/apps/game/assets/chars/clump_heavy_1.png new file mode 100644 index 0000000..062a423 --- /dev/null +++ b/apps/game/assets/chars/clump_heavy_1.png Binary files differ diff --git a/apps/game/assets/chars/clump_heavy_2.png b/apps/game/assets/chars/clump_heavy_2.png new file mode 100644 index 0000000..0bdc30f --- /dev/null +++ b/apps/game/assets/chars/clump_heavy_2.png Binary files differ diff --git a/apps/game/assets/chars/clump_spitter_1.png b/apps/game/assets/chars/clump_spitter_1.png new file mode 100644 index 0000000..9df11c0 --- /dev/null +++ b/apps/game/assets/chars/clump_spitter_1.png Binary files differ diff --git a/apps/game/assets/chars/clump_spitter_2.png b/apps/game/assets/chars/clump_spitter_2.png new file mode 100644 index 0000000..4e1b5d9 --- /dev/null +++ b/apps/game/assets/chars/clump_spitter_2.png Binary files differ diff --git a/apps/game/assets/chars/clumps_sheet.json b/apps/game/assets/chars/clumps_sheet.json new file mode 100644 index 0000000..e095675 --- /dev/null +++ b/apps/game/assets/chars/clumps_sheet.json @@ -0,0 +1,96 @@ +{ + "frames": { + "clump_crawler_1": { + "frame": { + "x": 0, + "y": 0, + "w": 20, + "h": 20 + }, + "rotated": false, + "trimmed": false, + "sourceSize": { + "w": 20, + "h": 20 + } + }, + "clump_crawler_2": { + "frame": { + "x": 20, + "y": 0, + "w": 20, + "h": 20 + }, + "rotated": false, + "trimmed": false, + "sourceSize": { + "w": 20, + "h": 20 + } + }, + "clump_spitter_1": { + "frame": { + "x": 40, + "y": 0, + "w": 20, + "h": 20 + }, + "rotated": false, + "trimmed": false, + "sourceSize": { + "w": 20, + "h": 20 + } + }, + "clump_spitter_2": { + "frame": { + "x": 60, + "y": 0, + "w": 20, + "h": 20 + }, + "rotated": false, + "trimmed": false, + "sourceSize": { + "w": 20, + "h": 20 + } + }, + "clump_heavy_1": { + "frame": { + "x": 80, + "y": 0, + "w": 20, + "h": 20 + }, + "rotated": false, + "trimmed": false, + "sourceSize": { + "w": 20, + "h": 20 + } + }, + "clump_heavy_2": { + "frame": { + "x": 100, + "y": 0, + "w": 20, + "h": 20 + }, + "rotated": false, + "trimmed": false, + "sourceSize": { + "w": 20, + "h": 20 + } + } + }, + "meta": { + "image": "clumps_sheet.png", + "size": { + "w": 120, + "h": 20 + }, + "scale": 1 + } +} \ No newline at end of file diff --git a/apps/game/assets/chars/clumps_sheet.png b/apps/game/assets/chars/clumps_sheet.png new file mode 100644 index 0000000..ca3bb01 --- /dev/null +++ b/apps/game/assets/chars/clumps_sheet.png Binary files differ diff --git a/apps/game/src/data/enemies.ts b/apps/game/src/data/enemies.ts new file mode 100644 index 0000000..6e5e095 --- /dev/null +++ b/apps/game/src/data/enemies.ts @@ -0,0 +1,84 @@ +/** + * Враги демо-боя: «пепельные сгустки» — оживший пепел, просыпается от гулкого звука. + * Чистые данные (без Pixi) — используются боевой системой и тестами. + */ + +export type EnemyKindId = 'crawler' | 'spitter' | 'heavy'; + +export interface EnemyKindDef { + id: EnemyKindId; + /** Здоровье. */ + hp: number; + /** Скорость, виртуальных пикселей/сек. */ + speed: number; + /** Радиус тела для попаданий, пикселей. */ + radius: number; + /** Дистанция начала атаки, пикселей. */ + attackRange: number; + /** Урон атаки. */ + attackDamage: number; + /** Пауза между атаками, сек. */ + attackCooldown: number; + /** Замах до удара, сек (у ползуна 0 — контактный урон). */ + windup: number; + /** Восстановление после удара, сек. */ + recover: number; + /** Плевун держит дистанцию (пикселей) — отходит при сближении. */ + keepDistance?: number; + /** Дроп «пепельной пыли». */ + motes: number; + /** Кадры из атласа chars/clumps_sheet.json. */ + frames: [string, string]; +} + +export const ENEMY_KINDS: Record = { + crawler: { + id: 'crawler', + hp: 3, + speed: 22, + radius: 7, + attackRange: 12, + attackDamage: 1, + attackCooldown: 0.8, + windup: 0, + recover: 0.3, + motes: 1, + frames: ['clump_crawler_1', 'clump_crawler_2'] + }, + spitter: { + id: 'spitter', + hp: 2, + speed: 18, + radius: 7, + attackRange: 90, + attackDamage: 1, + attackCooldown: 2.2, + windup: 0.35, + recover: 0.4, + keepDistance: 70, + motes: 1, + frames: ['clump_spitter_1', 'clump_spitter_2'] + }, + heavy: { + id: 'heavy', + hp: 8, + speed: 14, + radius: 10, + attackRange: 26, + attackDamage: 2, + attackCooldown: 2, + windup: 0.6, + recover: 0.5, + motes: 3, + frames: ['clump_heavy_1', 'clump_heavy_2'] + } +}; + +/** Точки спавна на лугах (тайловые координаты). */ +export const ENEMY_SPAWNS_MEADOWS: { kind: EnemyKindId; tile: { x: number; y: number } }[] = [ + { kind: 'crawler', tile: { x: 8, y: 9 } }, + { kind: 'crawler', tile: { x: 10, y: 7 } }, + { kind: 'spitter', tile: { x: 20, y: 8 } }, + { kind: 'crawler', tile: { x: 22, y: 21 } }, + { kind: 'heavy', tile: { x: 6, y: 22 } } +]; \ No newline at end of file diff --git a/apps/game/src/main.ts b/apps/game/src/main.ts index 71a9d92..b7eaee6 100644 --- a/apps/game/src/main.ts +++ b/apps/game/src/main.ts @@ -18,6 +18,7 @@ await engine.init(); engine.input.bindActions({ advance: ['Space', 'Enter'], + attack: ['Space'], // вне диалога: тап — удар, удержание — резонанс menu: ['Escape'], up: ['KeyW', 'ArrowUp'], down: ['KeyS', 'ArrowDown'], @@ -26,6 +27,7 @@ }); engine.input.bindGamepad({ advance: [0], // A + attack: [2], // X menu: [9], // Start up: [12], down: [13], diff --git a/apps/game/src/scenes/BootScene.ts b/apps/game/src/scenes/BootScene.ts index b63c4f3..bd133fa 100644 --- a/apps/game/src/scenes/BootScene.ts +++ b/apps/game/src/scenes/BootScene.ts @@ -38,6 +38,7 @@ this.progress = p; }) .then(async () => await this.game.assets.loadAtlas('chars/hero_sheet.json')) + .then(async () => await this.game.assets.loadAtlas('chars/clumps_sheet.json')) .then(() => ensurePixelFont(Game.FONT_URL)) // Звуки: декодируем заранее, чтобы первый play не тормозил. .then(async () => { diff --git a/apps/game/src/scenes/LocationScene.ts b/apps/game/src/scenes/LocationScene.ts index 08add29..42db640 100644 --- a/apps/game/src/scenes/LocationScene.ts +++ b/apps/game/src/scenes/LocationScene.ts @@ -9,20 +9,29 @@ IsometricTileMap, isoToScreen, screenToIsoExact, + inCircle, DEFAULT_ISO, type Camera, - type Scene + type Entity, + type Scene, + type Vec2 } from '@rpg/engine'; import { Game } from '../Game'; import { MenuScene, type SaveData } from './MenuScene'; import { buildMeadowsMap, TILES } from '../data/map'; import { NPCS, type NpcDef } from '../data/npcs'; import { DIALOGUES } from '../data/dialogues'; +import { ENEMY_SPAWNS_MEADOWS, type EnemyKindId } from '../data/enemies'; import { PlayerController, type HeroTextures } from '../systems/PlayerController'; import { DialogueSystem } from '../systems/DialogueSystem'; +import { CombatWorld } from '../systems/combat/CombatWorld'; +import { CombatViews } from '../systems/combat/CombatViews'; +import { PlayerCombat } from '../systems/combat/PlayerCombat'; +import { HealthBar } from '../systems/combat/HealthBar'; +import { PLAYER_COMBAT } from '../systems/combat/stats'; /** - * Локация «Выжженные луга»: карта, герой, NPC, диалоги, автосейв по Esc. + * Локация «Выжженные луга»: карта, герой, NPC, диалоги, бой со сгустками, автосейв по Esc. */ export class LocationScene implements Scene { private world = new Container(); @@ -35,6 +44,19 @@ private ash: ParticleEmitter; private camera: Camera; + // --- бой --- + private combat: CombatWorld; + private combatViews: CombatViews; + private playerCombat: PlayerCombat; + private healthBar: HealthBar; + /** Индикатор заряда резонанса (под героем). */ + private chargeRing: Graphics; + /** Цель, выбранная кликом по врагу (авто-подход и удар). */ + private target: Entity | null = null; + private repathTimer = 0; + /** Таймер мигания при неуязвимости (свой, не боевой). */ + private blinkT = 0; + constructor(private game: Game, save: SaveData | null) { this.camera = game.engine.camera; const data = buildMeadowsMap(); @@ -56,6 +78,44 @@ this.npcs.push({ def, view }); } + // --- боевая система: враги на ECS, звуки/события/урон героя — через колбэки --- + this.combat = new CombatWorld( + { + map: this.map, + events: this.game.engine.events, + audio: this.game.audio, + getPlayerPos: () => this.player.position, + damagePlayer: (dmg, from) => this.onPlayerDamaged(dmg, from) + }, + (kind) => { + // счётчики прогресса — в vars GameState + this.game.state.setVar('kills', this.game.state.getNumber('kills') + 1); + this.game.state.setVar('motes', this.game.state.getNumber('motes') + kind.motes); + } + ); + for (const s of ENEMY_SPAWNS_MEADOWS) { + this.combat.spawnEnemy(s.kind, this.tileCenter(s.tile.x, s.tile.y)); + } + this.combatViews = new CombatViews(this.combat, this.actors, this.enemyTextures(), this.world); + + // Вспышка урона и брызги пепла — реакция вьюх на события ECS-мира. + this.game.engine.events.on<{ entity: Entity }>('combat:hurt', ({ entity }) => { + const en = this.combat.enemies.get(entity); + if (!en) return; + this.combatViews.flashEnemy(entity); + if (en.brain.dead) this.combatViews.deathBurst(en.pos); + else this.combatViews.hitBurst(en.pos); + }); + + const savedHp = this.game.state.getNumber('hp') || PLAYER_COMBAT.maxHp; + this.playerCombat = new PlayerCombat(savedHp); + this.healthBar = new HealthBar(); + this.healthBar.setHp(this.playerCombat.hp); + this.game.renderer.uiRoot.addChild(this.healthBar); + + this.chargeRing = new Graphics(); + this.game.renderer.worldRoot.addChild(this.chargeRing); + this.dialogue = new DialogueSystem(this.game.renderer.uiRoot, this.game.state, { width: 480, height: 270, @@ -90,7 +150,7 @@ this.hint = new Container(); const text = new PixelText({ - text: 'клик — идти · клик по NPC — говорить · Esc — меню', + text: 'клик — идти · клик по сгустку — атаковать · Space — удар (удержать — резонанс) · Esc — меню', size: 8, color: 0x999988 }); @@ -105,9 +165,11 @@ } exit(): void { + this.combatViews.exit(); this.ash.clear(); this.world.destroy({ children: true }); this.ash.destroy(); + this.healthBar.destroy({ children: true }); this.hint.destroy({ children: true }); } @@ -127,11 +189,33 @@ this.saveAndExit(); return; } + + // --- ввод боя: тап = короткий удар, удержание = заряд резонанса --- + if (input.isActionJustPressed('attack')) { + this.playerCombat.startCharge(); + } + if (input.isActionJustReleased('attack') && this.playerCombat.isCharging()) { + this.doPlayerRelease(); + } + const pointer = input.getPointer(); if (pointer.justPressed) { this.handleWorldClick(pointer.x, pointer.y); } + // --- боевой мир --- + this.combat.update(dt); + this.combatViews.sync(dt); + this.playerCombat.update(dt); + this.updateTarget(dt); + this.updateChargeRing(); + this.healthBar.setHp(this.playerCombat.hp); + + // Мигание героя при неуязвимости + this.blinkT += dt; + this.player.view.alpha = + this.playerCombat.invuln && Math.floor(this.blinkT * 8) % 2 === 0 ? 0.4 : 1; + this.player.update(dt); const tile = this.player.currentTile(); this.actors.setDepth(this.player.view, tile.x, tile.y); @@ -140,6 +224,85 @@ render(): void {} + // ---------- бой ---------- + + /** Отпускание атаки: резонанс (долгое) или короткий удар. */ + private doPlayerRelease(): void { + const action = this.playerCombat.release(); + const from = this.player.position; + if (action === 'resonance') { + const slept = this.combat.resonancePulse(from); + this.combatViews.resonanceRing(from); + this.game.camera.addShake(1.5, 0.25); + this.game.state.setVar('resonances', this.game.state.getNumber('resonances') + (slept > 0 ? 1 : 0)); + } else if (action === 'attack') { + this.combat.playerConeAttack(from, this.player.dirVector); + } + } + + /** Авто-подход к выбранной цели и удар при входе в конус. */ + private updateTarget(dt: number): void { + if (this.target === null) return; + const en = this.combat.enemies.get(this.target); + if (!en || en.brain.dead) { + this.target = null; + return; + } + const from = this.player.position; + const dist = Math.hypot(en.pos.x - from.x, en.pos.y - from.y); + if (dist <= PLAYER_COMBAT.coneRange - 6) { + // В зоне — стоим и бьём по кулдауну + this.player.stop(); + if (this.playerCombat.attackCd.trigger()) { + this.combat.playerConeAttack(from, normalize({ x: en.pos.x - from.x, y: en.pos.y - from.y })); + } + this.target = null; // цель «снята» ударом; дальше игрок решает сам + return; + } + // Перестраиваем путь к цели пару раз в секунду + this.repathTimer -= dt; + if (this.repathTimer <= 0) { + this.repathTimer = 0.5; + this.player.onWorldClick(en.pos.x, en.pos.y); + } + } + + /** Индикатор заряда резонанса под героем. */ + private updateChargeRing(): void { + this.chargeRing.clear(); + if (!this.playerCombat.isCharging()) return; + const p = this.player.position; + const k = Math.min(1, this.playerCombat.chargeTime / PLAYER_COMBAT.resonanceCharge); + const color = k >= 1 ? 0xd99a32 : 0x888899; + this.chargeRing + .circle(p.x, p.y - 2, 6 + 4 * k) + .stroke({ color, width: 1, alpha: 0.9 }); + } + + /** Урон герою: неуязвимость внутри PlayerCombat; здесь отброс, шейк, звук, смерть. */ + private onPlayerDamaged(damage: number, from: Vec2): void { + const applied = this.playerCombat.takeDamage(damage); + if (applied <= 0) return; + this.game.engine.events.emit('combat:playerHit', { hp: this.playerCombat.hp }); + void this.game.audio.play('sfx/hurt', 0.8); + this.game.camera.addShake(2.5, 0.3); + // Отброс от источника урона + const dx = this.player.position.x - from.x; + const dy = this.player.position.y - from.y; + const len = Math.hypot(dx, dy) || 1; + this.player.applyKnockback({ x: dx / len, y: dy / len }, PLAYER_COMBAT.knockback); + + if (this.playerCombat.dead) { + this.game.state.setVar('deaths', this.game.state.getNumber('deaths') + 1); + this.playerCombat.revive(); + // Респаун на стартовом тайле + this.player.teleportTo({ x: 14, y: 14 }); + this.healthBar.setHp(this.playerCombat.hp); + } + } + + // ---------- остальное ---------- + private updateCameraFollow(): void { const tile = this.player.currentTile(); const c = this.tileCenter(tile.x, tile.y); @@ -159,6 +322,17 @@ ]); } + /** Кадры сгустков из атласа chars/clumps_sheet. */ + private enemyTextures(): Map { + const frames = (prefix: string) => + this.game.assets.frames('chars/clumps_sheet.json', prefix) as [Texture, Texture]; + return new Map([ + ['crawler', frames('clump_crawler')], + ['spitter', frames('clump_spitter')], + ['heavy', frames('clump_heavy')] + ]); + } + /** Кадры героя из атласа chars/hero_sheet. */ private heroTextures(): HeroTextures { const frames = (prefix: string) => @@ -199,6 +373,19 @@ return; } } + + // Клик по сгустку — выбрать цель (авто-подход и удар). + const world = { x: worldX, y: worldY }; + for (const [e, en] of this.combat.enemies) { + if (en.brain.dead) continue; + if (inCircle({ x: en.pos.x, y: en.pos.y - 6 }, 12, world)) { + this.target = e; + this.repathTimer = 0; + return; + } + } + + this.target = null; this.player.onWorldClick(worldX, worldY); } @@ -232,6 +419,7 @@ private saveAndExit(): void { const pos = this.player.currentTile(); + this.game.state.setVar('hp', this.playerCombat.hp); this.game.saves.save('autosave', { pos, state: this.game.state.serialize(), @@ -239,4 +427,9 @@ } satisfies SaveData); void this.game.scenes.replace(new MenuScene(this.game), { duration: 0.3 }); } +} + +function normalize(v: Vec2): Vec2 { + const len = Math.hypot(v.x, v.y) || 1; + return { x: v.x / len, y: v.y / len }; } \ No newline at end of file diff --git a/apps/game/src/systems/PlayerController.ts b/apps/game/src/systems/PlayerController.ts index b94562d..a3b8092 100644 --- a/apps/game/src/systems/PlayerController.ts +++ b/apps/game/src/systems/PlayerController.ts @@ -125,6 +125,37 @@ ); } + /** Позиция героя (ноги) в виртуальных пикселях. */ + get position(): Vec2 { + return { x: this.pos.x, y: this.pos.y }; + } + + /** Прервать текущий путь (например, при уроне). */ + stop(): void { + this.path = []; + this.waypoint = null; + } + + /** Мгновенно переместить героя в тайл (респаун, переходы между локациями). */ + teleportTo(tile: { x: number; y: number }): void { + this.stop(); + const p = isoToScreen(tile.x, tile.y); + this.pos = { x: p.x, y: p.y + DEFAULT_ISO.tileH / 2 }; + this.view.position.set(Math.round(this.pos.x), Math.round(this.pos.y)); + } + + /** Отброс: сдвиг позиции с проверкой проходимости (для боя). */ + applyKnockback(dir: Vec2, dist: number): void { + const nx = this.pos.x + dir.x * dist; + const ny = this.pos.y + dir.y * dist; + const tile = screenToIsoExact(nx, ny, this.map.data.width, this.map.data.height, DEFAULT_ISO); + if (tile && this.map.isWalkable(tile.x, tile.y)) { + this.pos = { x: nx, y: ny }; + this.view.position.set(Math.round(this.pos.x), Math.round(this.pos.y)); + } + this.stop(); + } + /** Направление — по доминирующей оси движения к следующей точке пути. */ private faceMovement(): void { if (!this.waypoint) return; diff --git a/apps/game/src/systems/combat/CombatViews.ts b/apps/game/src/systems/combat/CombatViews.ts new file mode 100644 index 0000000..5933096 --- /dev/null +++ b/apps/game/src/systems/combat/CombatViews.ts @@ -0,0 +1,197 @@ +import { + Container, + Graphics, + IsoDepthLayer, + ParticleEmitter, + Sprite, + SpriteFlash, + Texture, + screenToIsoExact, + DEFAULT_ISO, + type Entity, + type Vec2 +} from '@rpg/engine'; +import type { CombatWorld } from './CombatWorld'; +import type { EnemyKindId } from '../../data/enemies'; + +/** + * Пиксельные вьюхи боя: спрайты сгустков, снаряды, вспышки урона, растворение + * трупов, частицы ударов. Данные живут в CombatWorld (ECS) — здесь только отображение. + */ + +interface EnemyView { + root: Container; + sprite: Sprite; + flash: SpriteFlash; + frames: [Texture, Texture]; + animT: number; + animI: number; +} + +export class CombatViews { + private views = new Map(); + private projectileViews = new Map(); + private rings: { g: Graphics; t: number }[] = []; + + constructor( + private combat: CombatWorld, + private actors: IsoDepthLayer, + private enemyTextures: Map, + private fxRoot: Container + ) {} + + /** Спавн-вспышка пепла (взрыв частиц). */ + hitBurst(pos: Vec2): void { + const fx = ParticleEmitter.oneShot(10, { + color: 0x52525c, + rate: 0, + lifetime: [0.2, 0.45], + velocity: { x: [0, 0], y: [0, 0] }, + radialSpeed: [30, 70], + size: 2, + seed: (pos.x * 31 + pos.y) | 0 + }); + fx.position.set(pos.x, pos.y - 6); + this.fxRoot.addChild(fx); + } + + /** Смерть: заметный выброс пепла. */ + deathBurst(pos: Vec2): void { + const fx = ParticleEmitter.oneShot(16, { + color: 0x6a6a74, + rate: 0, + lifetime: [0.3, 0.6], + velocity: { x: [0, 0], y: [0, 0] }, + radialSpeed: [40, 90], + size: 2, + seed: (pos.x * 17 + pos.y) | 0 + }); + fx.position.set(pos.x, pos.y - 8); + this.fxRoot.addChild(fx); + } + + /** Кольцо резонанса — расходящаяся окружность (тикает в sync). */ + resonanceRing(pos: Vec2): void { + const g = new Graphics(); + g.position.set(pos.x, pos.y - 4); + this.fxRoot.addChild(g); + this.rings.push({ g, t: 0 }); + } + + /** Синхронизация вьюх с ECS-миром. Вызывать каждый тик после combat.update. */ + sync(dt: number): void { + const map = this.combat.deps.map; + const tileOf = (p: Vec2): { x: number; y: number } => + screenToIsoExact(p.x, p.y, map.data.width, map.data.height, DEFAULT_ISO) ?? { x: 0, y: 0 }; + + // Спавн новых + for (const [e, en] of this.combat.enemies) { + if (!this.views.has(e)) { + const frames = this.enemyTextures.get(en.kind.id)!; + const root = new Container(); + const sprite = new Sprite(frames[0]); + sprite.anchor.set(0.5, 1); + root.addChild(sprite); + root.position.set(en.pos.x, en.pos.y); + const t = tileOf(en.pos); + this.actors.add(root, t.x, t.y); + this.views.set(e, { + root, + sprite, + flash: new SpriteFlash(sprite, 0xf2b45a, 0.15), + frames, + animT: 0, + animI: 0 + }); + } + } + + // Обновление/смерть + for (const [e, en] of this.combat.enemies) { + const view = this.views.get(e)!; + view.root.position.set(en.pos.x, en.pos.y); + const t = tileOf(en.pos); + this.actors.setDepth(view.root, t.x, t.y); + + if (en.brain.dead) { + // Растворение трупа + view.root.alpha = Math.max(0, en.corpseTimer / 0.5); + view.sprite.tint = 0x555560; + continue; + } + + // Мигание неактивных: спящий сгусток почти невидим в пепле + view.root.alpha = en.brain.asleep ? 0.45 : 1; + + // Покадровая анимация (два кадра, 4 Гц, только когда активен) + if (!en.brain.asleep) { + view.animT += dt; + if (view.animT >= 0.25) { + view.animT -= 0.25; + view.animI = 1 - view.animI; + view.sprite.texture = view.frames[view.animI]; + } + } + + // Вспышка урона: короткая подсветка в момент hp снижения — через flash.start + // (запускает CombatWorld через damageEnemy -> событие, здесь упрощённо по alpha) + view.flash.update(dt); + } + + // Удаление уничтоженных сущностей + for (const [e, view] of this.views) { + if (!this.combat.world.isAlive(e)) { + this.actors.removeChild(view.root); + view.root.destroy({ children: true }); + this.views.delete(e); + } + } + + // Снаряды + for (const [e, pr] of this.combat.projectiles) { + let pv = this.projectileViews.get(e); + if (!pv) { + pv = new Graphics(); + pv.circle(0, 0, 2).fill(0x86868f); + this.fxRoot.addChild(pv); + this.projectileViews.set(e, pv); + } + pv.position.set(pr.pos.x, pr.pos.y); + } + for (const [e, pv] of this.projectileViews) { + if (!this.combat.world.isAlive(e)) { + pv.destroy(); + this.projectileViews.delete(e); + } + } + + // Кольца резонанса + for (const ring of this.rings) { + ring.t += dt; + const k = Math.min(1, ring.t / 0.4); + ring.g.clear(); + ring.g.circle(0, 0, 96 * k).stroke({ color: 0xd99a32, width: 1, alpha: 1 - k }); + if (k >= 1) { + ring.g.destroy(); + } + } + this.rings = this.rings.filter((r) => r.t < 0.4); + } + + /** Вспышка конкретного врага (вызывает CombatWorld при попадании). */ + flashEnemy(e: Entity): void { + this.views.get(e)?.flash.start(); + } + + exit(): void { + for (const [, view] of this.views) { + this.actors.removeChild(view.root); + view.root.destroy({ children: true }); + } + this.views.clear(); + for (const [, pv] of this.projectileViews) pv.destroy(); + this.projectileViews.clear(); + for (const r of this.rings) r.g.destroy(); + this.rings = []; + } +} \ No newline at end of file diff --git a/apps/game/src/systems/combat/CombatWorld.ts b/apps/game/src/systems/combat/CombatWorld.ts new file mode 100644 index 0000000..6908d52 --- /dev/null +++ b/apps/game/src/systems/combat/CombatWorld.ts @@ -0,0 +1,246 @@ +import { + EventBus, + AudioManager, + IsometricTileMap, + World, + screenToIsoExact, + inCircle, + inCone, + type Entity, + type System, + type Vec2 +} from '@rpg/engine'; +import { DEFAULT_ISO } from '@rpg/engine'; +import { ENEMY_KINDS, type EnemyKindDef, type EnemyKindId } from '../../data/enemies'; +import { EnemyBrain, type EnemyIntent } from './EnemyBrain'; +import { PLAYER_COMBAT } from './stats'; + +/** + * Боевой мир: сущности (враги, снаряды) в движковом ECS, поведение — в системах. + * Пиксельные вьюхи держатся отдельно (CombatViews) — в ECS только данные. + */ + +export interface EnemyComp { + kind: EnemyKindDef; + brain: EnemyBrain; + pos: Vec2; + hp: number; + /** Секунды до исчезновения трупа (после смерти). */ + corpseTimer: number; +} + +export interface ProjectileComp { + pos: Vec2; + vel: Vec2; + damage: number; + life: number; +} + +export interface CombatDeps { + map: IsometricTileMap; + events: EventBus; + audio: AudioManager; + /** Позиция героя (ноги), виртуальные пиксели. */ + getPlayerPos: () => Vec2; + /** Герой получил урон (сцена отбросит/шейкнет/проиграет звук). */ + damagePlayer: (damage: number, from: Vec2) => 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 = screenToIsoExact(nx, ny, map.data.width, map.data.height, DEFAULT_ISO); + if (tile && map.isWalkable(tile.x, tile.y)) { + pos.x = nx; + pos.y = ny; + } +} + +// ---------- системы ECS ---------- + +/** ИИ и движение сгустков. */ +class EnemySystem implements System { + constructor(private combat: CombatWorld) {} + + update(_world: World, dt: number): void { + const playerPos = this.combat.deps.getPlayerPos(); + for (const [e, en] of this.combat.enemies) { + const world = this.combat.world; + if (en.brain.dead) { + en.corpseTimer -= dt; + if (en.corpseTimer <= 0) { + world.destroyEntity(e); + this.combat.enemies.delete(e); + } + continue; + } + + const dx = playerPos.x - en.pos.x; + const dy = playerPos.y - en.pos.y; + const dist = Math.hypot(dx, dy) || 1; + const intent: EnemyIntent = en.brain.update(dt, { + dist, + dirToPlayer: { x: dx / dist, y: dy / dist } + }); + + switch (intent.type) { + case 'move': + stepWithCollision(this.combat.deps.map, en.pos, intent.dir, en.kind.speed, dt); + break; + case 'strike': + // Удар доходит, если герой в радиусе тела + замах + if (dist <= en.kind.attackRange + 8) { + this.combat.deps.damagePlayer(en.kind.attackDamage, en.pos); + } + break; + case 'shoot': + this.combat.spawnProjectile(en.pos, intent.dir, en.kind.attackDamage); + void this.combat.deps.audio.play('sfx/spit', 0.6); + break; + default: + break; + } + } + } +} + +/** Снаряды: полёт, попадание в героя, срок жизни. */ +class ProjectileSystem implements System { + constructor(private combat: CombatWorld) {} + + update(_world: World, dt: number): void { + const playerPos = this.combat.deps.getPlayerPos(); + for (const [e, pr] of this.combat.projectiles) { + pr.life -= dt; + pr.pos.x += pr.vel.x * dt; + pr.pos.y += pr.vel.y * dt; + + // Столкновение с героем + if (inCircle(playerPos, 6, pr.pos)) { + this.combat.deps.damagePlayer(pr.damage, pr.pos); + this.combat.killProjectile(e); + continue; + } + // Вне карты или срок истёк + const tile = screenToIsoExact(pr.pos.x, pr.pos.y, this.combat.deps.map.data.width, this.combat.deps.map.data.height, DEFAULT_ISO); + if (pr.life <= 0 || !tile) { + this.combat.killProjectile(e); + } + } + } +} + +// ---------- фасад ---------- + +export class CombatWorld { + readonly world = new World(); + readonly enemies = new Map(); + readonly projectiles = new Map(); + + constructor( + readonly deps: CombatDeps, + private onEnemyKilled?: (kind: EnemyKindDef) => void + ) { + this.world.addSystem(new EnemySystem(this)); + this.world.addSystem(new ProjectileSystem(this)); + } + + /** Заспавнить сгусток (спит в пепле, пока не разбудит громкий звук). */ + spawnEnemy(kindId: EnemyKindId, pos: Vec2): Entity { + const kind = ENEMY_KINDS[kindId]; + const e = this.world.createEntity(); + this.enemies.set(e, { + kind, + brain: new EnemyBrain(kind), + pos: { ...pos }, + hp: kind.hp, + corpseTimer: 0.5 + }); + return e; + } + + spawnProjectile(from: Vec2, dir: Vec2, damage: number): Entity { + const e = this.world.createEntity(); + this.projectiles.set(e, { + pos: { x: from.x + dir.x * 10, y: from.y + dir.y * 10 }, + vel: { x: dir.x * 70, y: dir.y * 70 }, + damage, + life: 1.5 + }); + return e; + } + + killProjectile(e: Entity): void { + if (!this.world.isAlive(e)) return; + this.world.destroyEntity(e); + this.projectiles.delete(e); + } + + /** + * Короткий удар конусом. Возвращает число поражённых сгустков. + * Побочный эффект: гулкий звук будит спящих в wakeRadius. + */ + playerConeAttack(from: Vec2, dir: Vec2): number { + void this.deps.audio.play('sfx/bell_hit', 0.8); + this.loudSound(from, PLAYER_COMBAT.wakeRadius); + this.deps.events.emit('combat:attack', { mode: 'cone', origin: { ...from } }); + + let hits = 0; + for (const [e, en] of this.enemies) { + if (en.brain.dead) continue; + if (!inCone(from, dir, PLAYER_COMBAT.coneRange, PLAYER_COMBAT.coneHalfAngle, en.pos)) continue; + this.damageEnemy(e, en, PLAYER_COMBAT.coneDamage, from); + hits++; + } + return hits; + } + + /** Низкий резонанс: усыпляет сгустков в радиусе. Возвращает число усыпленных. */ + resonancePulse(from: Vec2): number { + void this.deps.audio.play('sfx/bell_low', 0.9); + this.deps.events.emit('combat:attack', { mode: 'resonance', origin: { ...from } }); + + let slept = 0; + for (const [, en] of this.enemies) { + if (en.brain.dead) continue; + if (inCircle(from, PLAYER_COMBAT.resonanceRange, en.pos)) { + en.brain.putToSleep(PLAYER_COMBAT.resonanceSleep); + slept++; + } + } + return slept; + } + + /** Гулкий звук в точке: будит спящих сгустков в радиусе. */ + loudSound(pos: Vec2, radius: number): void { + for (const [, en] of this.enemies) { + if (inCircle(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 }); + } + } + } + + /** Урон сгустку; смерть — дроп и событие. */ + damageEnemy(e: Entity, en: EnemyComp, damage: number, from: Vec2): void { + en.hp -= damage; + en.brain.hurt(); + this.deps.events.emit('combat:hurt', { entity: e, died: en.hp <= 0 }); + if (en.hp <= 0) { + if (en.brain.die()) { + void this.deps.audio.play('sfx/ash_die', 0.8); + this.deps.events.emit('combat:kill', { kind: en.kind.id, tile: { ...en.pos } }); + this.onEnemyKilled?.(en.kind); + // Отброс трупа от героя — лёгкий визуальный эффект делает CombatViews. + en.pos.x += Math.sign(en.pos.x - from.x) * 2; + en.pos.y += Math.sign(en.pos.y - from.y) * 2; + } + } + } + + update(dt: number): void { + 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 new file mode 100644 index 0000000..f002bc6 --- /dev/null +++ b/apps/game/src/systems/combat/EnemyBrain.ts @@ -0,0 +1,181 @@ +import { StateMachine, type Vec2 } from '@rpg/engine'; +import { Cooldown } from '@rpg/engine'; +import type { EnemyKindDef } from '../../data/enemies'; + +/** + * Чистый ИИ «пепельного сгустка» поверх движкового StateMachine. + * Сенсоры входят (дистанция/направление до героя), намерения выходят + * (куда идти, когда бить/плевать). Без Pixi — тестируется в Vitest. + * + * Состояния: dormant (спит в пепле) -> rise (поднимается) -> chase -> windup -> + * attack (удар/плевок) -> recover -> chase; sleeping (от низкого резонанса), dead. + */ +export type EnemyIntent = + | { type: 'idle' } + | { type: 'move'; dir: Vec2 } + | { type: 'strike' } + | { type: 'shoot'; dir: Vec2 }; + +export interface EnemySenses { + /** Дистанция до героя, пикселей. */ + dist: number; + /** Нормализованное направление к герою. */ + dirToPlayer: Vec2; +} + +export class EnemyBrain { + readonly sm = new StateMachine(); + private attackCd: Cooldown; + private sleepTimer = 0; + private hurtTimer = 0; + + constructor(readonly kind: EnemyKindDef) { + 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', {}); + + // Обычные переходы + this.sm.transition('dormant', 'wake', 'rise'); + this.sm.transition('rise', 'done', 'chase'); + this.sm.transition('chase', 'inRange', 'windup'); + this.sm.transition('chase', 'exhausted', 'sleeping'); + this.sm.transition('windup', 'strike', 'attack'); + this.sm.transition('attack', 'done', 'recover'); + this.sm.transition('recover', 'done', 'chase'); + this.sm.transition('hurt', 'done', 'chase'); + this.sm.transition('sleeping', 'wake', 'rise'); + + // Из любого состояния: урон и смерть (кроме dead — повторный вход игнорируется) + this.sm.transitionAny('hurt', 'hurt'); + this.sm.transitionAny('die', 'dead'); + + this.sm.change('dormant'); + } + + /** Громкий звук рядом — проснуться. */ + hearLoud(): void { + if (this.sm.current === 'dormant' || this.sm.current === 'sleeping') { + this.sm.handleEvent('wake'); + } + } + + /** Низкий резонанс: усыпить на seconds (мёртвых не трогает). */ + putToSleep(seconds: number): void { + if (this.sm.current === 'dead') return; + this.sm.change('sleeping'); + this.sleepTimer = seconds; + } + + /** Получил урон: короткий стан (не выбивает из dead). */ + hurt(): void { + if (this.sm.current === 'dead') return; + this.hurtTimer = 0.25; + this.sm.handleEvent('hurt'); + } + + /** Умер. Возвращает true, если умер именно сейчас. */ + die(): boolean { + if (this.sm.current === 'dead') return false; + this.sm.handleEvent('die'); + return true; + } + + get state(): string | null { + return this.sm.current; + } + + get asleep(): boolean { + return this.sm.current === 'sleeping' || this.sm.current === 'dormant'; + } + + get dead(): boolean { + return this.sm.current === 'dead'; + } + + /** Тик ИИ: сенсоры внутрь, намерение наружу. */ + update(dt: number, senses: EnemySenses): EnemyIntent { + this.sm.update(dt); + this.attackCd.update(dt); + + // Таймеры поверх автомата + if (this.hurtTimer > 0) { + this.hurtTimer -= dt; + if (this.hurtTimer <= 0) this.sm.handleEvent('done'); + return { type: 'idle' }; + } + if (this.sm.current === 'sleeping') { + this.sleepTimer -= dt; + if (this.sleepTimer <= 0) this.sm.handleEvent('wake'); + return { type: 'idle' }; + } + if (this.sm.current === 'rise') { + // Подъём из пепла: 0.6 с, потом в погоню + if (this.sm.time >= 0.6) this.sm.handleEvent('done'); + return { type: 'idle' }; + } + + 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 - 12) { + 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 + 12) { + return { type: 'move', dir: senses.dirToPlayer }; + } + return { type: 'idle' }; + } + return { type: 'move', dir: senses.dirToPlayer }; + } + case 'windup': { + if (this.sm.time >= this.kind.windup) { + this.sm.handleEvent('strike'); + this.attackCd.triggerForce(); + return this.kind.keepDistance !== undefined + ? { type: 'shoot', dir: senses.dirToPlayer } + : { type: 'strike' }; + } + return { type: 'idle' }; + } + case 'attack': { + if (this.sm.time >= Math.max(this.kind.recover, 0.3)) { + this.sm.handleEvent('done'); + } + return { type: 'idle' }; + } + case 'recover': { + if (this.sm.time >= this.kind.recover) this.sm.handleEvent('done'); + return { type: 'idle' }; + } + default: + return { type: 'idle' }; + } + } +} + +function negate(v: Vec2): Vec2 { + return { x: -v.x, y: -v.y }; +} \ No newline at end of file diff --git a/apps/game/src/systems/combat/HealthBar.ts b/apps/game/src/systems/combat/HealthBar.ts new file mode 100644 index 0000000..6075f40 --- /dev/null +++ b/apps/game/src/systems/combat/HealthBar.ts @@ -0,0 +1,46 @@ +import { Container, Graphics } from '@rpg/engine'; +import { PLAYER_COMBAT } from './stats'; + +/** + * Полоса здоровья героя: сердечки в левом верхнем углу (uiRoot). + */ +export class HealthBar extends Container { + private hearts: Graphics[] = []; + + constructor() { + super(); + for (let i = 0; i < PLAYER_COMBAT.maxHp; i++) { + const h = new Graphics(); + h.position.set(8 + i * 11, 16); + this.addChild(h); + this.hearts.push(h); + } + this.draw(PLAYER_COMBAT.maxHp); + } + + /** Обновить по текущему HP. */ + setHp(hp: number): void { + this.draw(hp); + } + + private draw(hp: number): void { + this.hearts.forEach((h, i) => { + h.clear(); + // Пиксельное сердечко 7x6 из прямоугольников + const color = i < hp ? 0xb0453f : 0x2a2a33; + const border = i < hp ? 0x7a2f2b : 0x1c1c24; + const px = (x: number, y: number, w: number, hh: number, c: number): void => { + h.rect(x, y, w, hh).fill(c); + }; + px(-3, -2, 6, 4, border); // тело + px(-2, 2, 4, 1, border); + px(-3, -2, 2, 2, color); + px(1, -2, 2, 2, color); + px(-2, -1, 4, 2, color); + px(-2, 1, 2, 1, color); + px(0, 1, 1, 1, color); + px(-3, -3, 2, 1, color); // горбики + px(1, -3, 2, 1, color); + }); + } +} \ No newline at end of file diff --git a/apps/game/src/systems/combat/PlayerCombat.ts b/apps/game/src/systems/combat/PlayerCombat.ts new file mode 100644 index 0000000..741bf79 --- /dev/null +++ b/apps/game/src/systems/combat/PlayerCombat.ts @@ -0,0 +1,80 @@ +import { Cooldown } from '@rpg/engine'; +import { PLAYER_COMBAT } from './stats'; + +/** + * Боевое состояние героя: HP, неуязвимость, кулдауны, заряд резонанса. + * Чистая логика (без Pixi) — тестируется в Vitest. + */ +export class PlayerCombat { + hp: number; + /** Секунды удержания атаки (заряд низкого резонанса). */ + chargeTime = 0; + private invulnTimer = 0; + /** Кулдаун короткого удара. */ + readonly attackCd = new Cooldown(PLAYER_COMBAT.coneCooldown); + /** Кулдаун низкого резонанса. */ + readonly resonanceCd = new Cooldown(PLAYER_COMBAT.resonanceCooldown); + + constructor(hp: number = PLAYER_COMBAT.maxHp) { + this.hp = Math.min(hp, PLAYER_COMBAT.maxHp); + } + + update(dt: number): void { + if (this.invulnTimer > 0) this.invulnTimer = Math.max(0, this.invulnTimer - dt); + this.attackCd.update(dt); + this.resonanceCd.update(dt); + if (this.isCharging()) this.chargeTime += dt; + } + + /** Атака удерживается (Space зажат) — копим заряд резонанса. */ + startCharge(): void { + if (this.chargeTime === 0) this.chargeTime = 0.0001; // старт отсчёта + } + + isCharging(): boolean { + return this.chargeTime > 0; + } + + /** + * Отпустил: если зарядился до резонанса и кулдаун готов — вернуть 'resonance', + * иначе — обычный удар (если готов). Кулдауны триггерятся здесь. + */ + release(): 'resonance' | 'attack' | null { + const charged = this.chargeTime >= PLAYER_COMBAT.resonanceCharge; + this.chargeTime = 0; + if (charged) { + if (this.resonanceCd.trigger()) return 'resonance'; + return null; // резонанс ещё перезаряжается — молча + } + return this.attackCd.trigger() ? 'attack' : null; + } + + /** + * Получить урон. Возвращает фактически применённый урон (0 — неуязвим). + */ + takeDamage(damage: number): number { + if (this.invulnTimer > 0 || this.hp <= 0) return 0; + const applied = Math.min(damage, this.hp); + this.hp -= applied; + this.invulnTimer = PLAYER_COMBAT.invulnTime; + return applied; + } + + /** Неуязвим (мигание спрайта в этот период). */ + get invuln(): boolean { + return this.invulnTimer > 0; + } + + get dead(): boolean { + return this.hp <= 0; + } + + /** Восстановление после смерти (респаун). */ + revive(): void { + this.hp = PLAYER_COMBAT.maxHp; + this.invulnTimer = PLAYER_COMBAT.invulnTime; + this.chargeTime = 0; + this.attackCd.reset(); + this.resonanceCd.reset(); + } +} \ No newline at end of file diff --git a/apps/game/src/systems/combat/__tests__/EnemyBrain.test.ts b/apps/game/src/systems/combat/__tests__/EnemyBrain.test.ts new file mode 100644 index 0000000..5d6b79b --- /dev/null +++ b/apps/game/src/systems/combat/__tests__/EnemyBrain.test.ts @@ -0,0 +1,133 @@ +import { describe, expect, it } from 'vitest'; +import { ENEMY_KINDS } from '../../../data/enemies'; +import { EnemyBrain, type EnemySenses } from '../EnemyBrain'; + +/** Тик ИИ на 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; +}; + +/** Прогон с ловлей конкретного намерения (strike/shoot выдаются одним тиком). */ +const sawIntent = (b: EnemyBrain, seconds: number, senses: EnemySenses, type: string): boolean => { + for (let i = 0; i < Math.round(seconds * 60); i++) { + if (b.update(1 / 60, senses).type === type) return true; + } + return false; +}; + +const away: EnemySenses = { dist: 100, dirToPlayer: { x: 0, y: 1 } }; +const close: EnemySenses = { dist: 8, dirToPlayer: { x: 1, y: 0 } }; + +/** Разбудить и дождаться погони (спит -> rise 0.6 с -> chase). */ +const wakeUp = (b: EnemyBrain, senses: EnemySenses): void => { + b.hearLoud(); + run(b, 0.7, senses); +}; + +describe('EnemyBrain (ползун)', () => { + it('стартует спящим в пепле и не двигается', () => { + const b = new EnemyBrain(ENEMY_KINDS.crawler); + expect(b.asleep).toBe(true); + expect(b.update(1 / 60, close).type).toBe('idle'); + }); + + it('гулкий звук поднимает: rise -> chase', () => { + const b = new EnemyBrain(ENEMY_KINDS.crawler); + b.hearLoud(); + expect(b.state).toBe('rise'); + run(b, 0.7, away); + expect(b.state).toBe('chase'); + // Вдали от героя — движение к нему + expect(run(b, 0.05, away)).toEqual({ type: 'move', dir: { x: 0, y: 1 } }); + }); + + it('в радиусе атаки бьёт контактом по кулдауну', () => { + const b = new EnemyBrain(ENEMY_KINDS.crawler); + wakeUp(b, away); + expect(sawIntent(b, 0.1, close, 'strike')).toBe(true); + // Кулдаун: в следующее окно удара нет, после — снова есть + expect(sawIntent(b, 0.1, close, 'strike')).toBe(false); + run(b, ENEMY_KINDS.crawler.attackCooldown, away); + expect(sawIntent(b, 0.1, close, 'strike')).toBe(true); + }); + + it('урон станит и возвращает в погоню', () => { + const b = new EnemyBrain(ENEMY_KINDS.crawler); + wakeUp(b, away); + b.hurt(); + expect(b.state).toBe('hurt'); + expect(b.update(1 / 60, away).type).toBe('idle'); + run(b, 0.3, away); + expect(b.state).toBe('chase'); + }); + + it('резонанс усыпляет на заданное время, потом просыпается', () => { + const b = new EnemyBrain(ENEMY_KINDS.crawler); + wakeUp(b, away); + b.putToSleep(4); + expect(b.asleep).toBe(true); + expect(run(b, 2, away).type).toBe('idle'); + expect(b.asleep).toBe(true); + run(b, 2.2, away); + expect(b.state).toBe('rise'); // проснулся — сначала подъём + run(b, 0.7, away); + expect(b.state).toBe('chase'); + expect(b.asleep).toBe(false); + }); + + it('смерть окончательна: повторный die возвращает false', () => { + const b = new EnemyBrain(ENEMY_KINDS.crawler); + expect(b.die()).toBe(true); + expect(b.dead).toBe(true); + expect(b.die()).toBe(false); + // Урон и сон мёртвого не трогают + b.hurt(); + b.putToSleep(4); + expect(b.dead).toBe(true); + }); +}); + +describe('EnemyBrain (наст)', () => { + it('перед ударом есть замах 0.6 с', () => { + const b = new EnemyBrain(ENEMY_KINDS.heavy); + wakeUp(b, away); + // Сразу — только замах, урона нет + run(b, 0.05, close); + expect(b.state).toBe('windup'); + run(b, 0.3, close); + expect(b.state).toBe('windup'); + // Через 0.6 с замаха — удар и фаза атаки + run(b, 0.35, close); + expect(b.state).toBe('attack'); + expect(sawIntent(b, 0.05, close, 'strike')).toBe(false); // уже прошёл + }); +}); + +describe('EnemyBrain (плевун)', () => { + it('сближение — отходит, издали — идёт ближе', () => { + const b = new EnemyBrain(ENEMY_KINDS.spitter); + wakeUp(b, { dist: 100, dirToPlayer: { x: 1, y: 0 } }); + expect(b.state).toBe('chase'); + // Слишком близко к keepDistance — пятится + const back = run(b, 0.05, { dist: 40, dirToPlayer: { x: 1, y: 0 } }); + expect(back.type).toBe('move'); + if (back.type === 'move') expect(back.dir.x).toBeLessThan(0); + // Слишком далеко — подходит + const toward = run(b, 0.05, { dist: 120, dirToPlayer: { x: 0, y: 1 } }); + expect(toward.type).toBe('move'); + if (toward.type === 'move') expect(toward.dir.y).toBeGreaterThan(0); + }); + + it('на дистанции атаки — замах и плевок', () => { + const b = new EnemyBrain(ENEMY_KINDS.spitter); + wakeUp(b, { dist: 100, dirToPlayer: { x: 1, y: 0 } }); + const mid: EnemySenses = { dist: 80, dirToPlayer: { x: 1, y: 0 } }; + // Входим в радиус атаки — замах + run(b, 0.05, mid); + expect(b.state).toBe('windup'); + // Через windup — плевок (ловим намерение в тик срабатывания) + expect(sawIntent(b, 0.45, mid, 'shoot')).toBe(true); + }); +}); \ No newline at end of file diff --git a/apps/game/src/systems/combat/__tests__/PlayerCombat.test.ts b/apps/game/src/systems/combat/__tests__/PlayerCombat.test.ts new file mode 100644 index 0000000..bdf693b --- /dev/null +++ b/apps/game/src/systems/combat/__tests__/PlayerCombat.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from 'vitest'; +import { PLAYER_COMBAT } from '../stats'; +import { PlayerCombat } from '../PlayerCombat'; + +/** Тик на 1/60 с — как в игровом цикле. */ +const step = (c: PlayerCombat, seconds: number): void => { + for (let i = 0; i < Math.round(seconds * 60); i++) c.update(1 / 60); +}; + +describe('PlayerCombat', () => { + it('стартует с полным здоровьем', () => { + const c = new PlayerCombat(); + expect(c.hp).toBe(PLAYER_COMBAT.maxHp); + expect(c.dead).toBe(false); + expect(c.invuln).toBe(false); + }); + + it('урон не превышает остаток HP, после него неуязвимость', () => { + const c = new PlayerCombat(); + expect(c.takeDamage(3)).toBe(3); + expect(c.hp).toBe(2); + expect(c.invuln).toBe(true); + // В окно неуязвимости урон не проходит + expect(c.takeDamage(1)).toBe(0); + expect(c.hp).toBe(2); + }); + + it('смерть при HP 0; revive возвращает полные сердечки', () => { + const c = new PlayerCombat(); + c.takeDamage(PLAYER_COMBAT.maxHp); + expect(c.dead).toBe(true); + c.revive(); + expect(c.hp).toBe(PLAYER_COMBAT.maxHp); + expect(c.dead).toBe(false); + expect(c.invuln).toBe(true); + }); + + it('короткий тап — удар по кулдауну', () => { + const c = new PlayerCombat(); + c.startCharge(); + step(c, 0.1); + expect(c.isCharging()).toBe(true); + expect(c.release()).toBe('attack'); + // Кулдаун ещё не истёк — второй удар не проходит + c.startCharge(); + step(c, 0.1); + expect(c.release()).toBeNull(); + // После кулдауна снова готов + step(c, PLAYER_COMBAT.coneCooldown); + c.startCharge(); + step(c, 0.1); + expect(c.release()).toBe('attack'); + }); + + it('удержание дольше resonanceCharge — резонанс по своему кулдауну', () => { + const c = new PlayerCombat(); + c.startCharge(); + step(c, PLAYER_COMBAT.resonanceCharge + 0.1); + expect(c.release()).toBe('resonance'); + // Резонанс перезаряжается — повторный заряд молча отклоняется + c.startCharge(); + step(c, PLAYER_COMBAT.resonanceCharge + 0.1); + expect(c.release()).toBeNull(); + step(c, PLAYER_COMBAT.resonanceCooldown); + c.startCharge(); + step(c, PLAYER_COMBAT.resonanceCharge + 0.1); + expect(c.release()).toBe('resonance'); + }); + + it('неуязвимость спадает со временем', () => { + const c = new PlayerCombat(); + c.takeDamage(1); + step(c, PLAYER_COMBAT.invulnTime + 0.05); + expect(c.invuln).toBe(false); + expect(c.takeDamage(1)).toBe(1); + }); +}); \ No newline at end of file diff --git a/apps/game/src/systems/combat/__tests__/stats.test.ts b/apps/game/src/systems/combat/__tests__/stats.test.ts new file mode 100644 index 0000000..773d40f --- /dev/null +++ b/apps/game/src/systems/combat/__tests__/stats.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from 'vitest'; +import { PLAYER_COMBAT } from '../stats'; +import { ENEMY_KINDS } from '../../../data/enemies'; + +/** + * Таблица баланса боевой модели демо-среза (согласована с планом и docs/world.md): + * удар — «гулкий звон» (будит пепел), резонанс — «низкий звон» (усыпляет). + */ +describe('PLAYER_COMBAT — таблица баланса', () => { + it('герой: 5 сердечек, окно неуязвимости 0.8 с', () => { + expect(PLAYER_COMBAT.maxHp).toBe(5); + expect(PLAYER_COMBAT.invulnTime).toBeCloseTo(0.8); + expect(PLAYER_COMBAT.knockback).toBe(24); + }); + + it('короткий удар: конус 34 px, 55°, урон 2, кулдаун 0.45 с', () => { + expect(PLAYER_COMBAT.coneRange).toBe(34); + expect((PLAYER_COMBAT.coneHalfAngle * 180) / Math.PI).toBeCloseTo(55); + expect(PLAYER_COMBAT.coneDamage).toBe(2); + expect(PLAYER_COMBAT.coneCooldown).toBeCloseTo(0.45); + }); + + it('резонанс: заряд 0.6 с, радиус 96, сон 4 с, кулдаун 3 с', () => { + expect(PLAYER_COMBAT.resonanceCharge).toBeCloseTo(0.6); + expect(PLAYER_COMBAT.resonanceRange).toBe(96); + expect(PLAYER_COMBAT.resonanceSleep).toBe(4); + expect(PLAYER_COMBAT.resonanceCooldown).toBe(3); + }); + + it('гулкий удар будит спящих в радиусе 150 px', () => { + expect(PLAYER_COMBAT.wakeRadius).toBe(150); + }); + + it('ползун: слабый, но быстрый, контактный', () => { + const c = ENEMY_KINDS.crawler; + expect(c.hp).toBe(3); + expect(c.attackDamage).toBe(1); + expect(c.windup).toBe(0); + // Убивается одним коротким ударом (урон 2) не должен: нужно 2 удара + expect(Math.ceil(c.hp / PLAYER_COMBAT.coneDamage)).toBe(2); + }); + + it('плевун: хрупкий стрелок, держит дистанцию', () => { + const s = ENEMY_KINDS.spitter; + expect(s.hp).toBe(2); + expect(s.keepDistance).toBeGreaterThan(PLAYER_COMBAT.coneRange); + expect(s.attackRange).toBeGreaterThan(s.keepDistance ?? 0); + expect(s.windup).toBeGreaterThan(0); + }); + + it('наст: танк с тяжёлым замахом и уроном 2', () => { + const h = ENEMY_KINDS.heavy; + expect(h.hp).toBe(8); + expect(h.attackDamage).toBe(2); + expect(h.windup).toBeGreaterThanOrEqual(0.6); + // Четыре коротких удара (урон 2) убивают + expect(Math.ceil(h.hp / PLAYER_COMBAT.coneDamage)).toBe(4); + }); +}); \ No newline at end of file diff --git a/apps/game/src/systems/combat/stats.ts b/apps/game/src/systems/combat/stats.ts new file mode 100644 index 0000000..3a4ad1b --- /dev/null +++ b/apps/game/src/systems/combat/stats.ts @@ -0,0 +1,25 @@ +/** + * Числовые константы боя героя — чистые данные без Pixi (тестируются). + * Согласованы с боевой моделью демо-среза и сеттингом: + * удар — «гулкий звон» (будит пепел), резонанс — «низкий звон» (усыпляет). + */ + +export const PLAYER_COMBAT = { + /** Здоровье героя (сердечки). */ + maxHp: 5, + /** Конус удара: дальность, полуугол (рад), урон, кулдаун. */ + coneRange: 34, + coneHalfAngle: (55 * Math.PI) / 180, + coneDamage: 2, + coneCooldown: 0.45, + /** Низкий резонанс: сколько держать заряд, радиус, сон врага, кулдаун. */ + resonanceCharge: 0.6, + resonanceRange: 96, + resonanceSleep: 4, + resonanceCooldown: 3, + /** Неуязвимость после урона, сек; отброс, пикселей. */ + invulnTime: 0.8, + knockback: 24, + /** Гулкий удар излучает звук: будит сгустков в этом радиусе. */ + wakeRadius: 150 +} as const; \ No newline at end of file diff --git a/tools/pixelart/gen.mjs b/tools/pixelart/gen.mjs index 8cc2d3e..49f527a 100644 --- a/tools/pixelart/gen.mjs +++ b/tools/pixelart/gen.mjs @@ -401,6 +401,170 @@ save(elderIrwin, 'chars/elder_irwin.png'); save(traderMila, 'chars/trader_mila.png'); +// ---------- пепельные сгустки (враги демо-боя) ---------- +// Серые P*/G* тела; тёплое ядро F* — «жизнь» загорается, когда сгусток просыпается. + +const CLUMP_MAP = { o: 'P0', d: 'P2', p: 'P3', q: 'P4', h: 'P5H', f: 'F0', F: 'F1' }; + +// Ползун 14x12: плоский слизень на коротких ножках. +const crawler1 = fromAscii([ + '..............', + '..............', + '...oooooo.....', + '..odppppdo....', + '.odpppppppdo..', + '.dpppffppppdo.', + '.dpppppppppdo.', + '.odpppppppdo..', + '..oddpppddo...', + '..o.o....o.o..', + '..o.o....o.o..', + '..............' +], CLUMP_MAP); + +const crawler2 = fromAscii([ + '..............', + '..............', + '...oooooo.....', + '..odppppdo....', + '.odpppppppdo..', + '.dpppffppppdo.', + '.dpppppppppdo.', + '.odpppppppdo..', + '..oddpppddo...', + '...o.o..o.o...', + '...o.o..o.o...', + '..............' +], CLUMP_MAP); + +// Плевун 14x14: столбик с тёмной пастью. +const spitter1 = fromAscii([ + '....oooooo....', + '...odppppdo...', + '..odppppppdo..', + '..dpppppppppd.', + '..dpppffppppd.', + '..dpppppppppd.', + '..dpoooopppd..', + '..dpppppppppd.', + '...dpppppppd..', + '...odpppppdo..', + '....odpppdo...', + '....o.o.o.o...', + '...o..o..o....', + '..............' +], CLUMP_MAP); + +const spitter2 = fromAscii([ + '....oooooo....', + '...odppppdo...', + '..odppppppdo..', + '..dpppppppppd.', + '..dpppffppppd.', + '..dpppppppppd.', + '..dppoooopppd.', + '..dpppppppppd.', + '...dpppppppd..', + '...odpppppdo..', + '....odpppdo...', + '...o..o..o....', + '....o.o.o.o...', + '..............' +], CLUMP_MAP); + +// Наст 20x20: тяжёлая корка с трещинами; медленно давит. +const heavy1 = fromAscii([ + '.......oooooo.......', + '.....oodppppdoo.....', + '....odppppppppdo....', + '...odppppppppppdo...', + '...dpppddppppppppd..', + '..dppppppddpppppppd.', + '..dppppppppddppffpd.', + '..dpppffppppppppfpd.', + '..dpppffppppppppppd.', + '..dpppppppppppppppd.', + '..dpppppppppppppppd.', + '..dpppppppppppppppd.', + '..odpppppppppppppdo.', + '..odqpppppppppppdo..', + '...odqqppppqqpdo....', + '....odqpppqqpdo.....', + '.....oddddddddo.....', + '...ooooddddooooo....', + '....................', + '....................' +], CLUMP_MAP); + +const heavy2 = fromAscii([ + '.......oooooo.......', + '.....oodppppdoo.....', + '....odppppppppdo....', + '...odppppppppppdo...', + '...dpppppppddppppd..', + '..dpppddpppppddpppd.', + '..dpppppddpppppffpd.', + '..dpppffpppddpppfpd.', + '..dpppffppppppppppd.', + '..dpppppppppppppppd.', + '..dpppppppppppppppd.', + '..dpppppppppppppppd.', + '..odpppppppppppppdo.', + '..odqpppppppppppdo..', + '...odqqppppqqpdo....', + '....odqpppqqpdo.....', + '.....oddddddddo.....', + '...ooooddddooooo....', + '....................', + '....................' +], CLUMP_MAP); + +save(crawler1, 'chars/clump_crawler_1.png'); +save(crawler2, 'chars/clump_crawler_2.png'); +save(spitter1, 'chars/clump_spitter_1.png'); +save(spitter2, 'chars/clump_spitter_2.png'); +save(heavy1, 'chars/clump_heavy_1.png'); +save(heavy2, 'chars/clump_heavy_2.png'); + +// атлас сгустков (кадры разной ширины — выравнивание по низу ячейки 20x20) +const clumpFrames = [crawler1, crawler2, spitter1, spitter2, heavy1, heavy2]; +const clumpNames = ['clump_crawler_1', 'clump_crawler_2', 'clump_spitter_1', 'clump_spitter_2', 'clump_heavy_1', 'clump_heavy_2']; +const CLUMP_W = 20; +const CLUMP_H = 20; +const clumpSheetW = CLUMP_W * clumpFrames.length; +const clumpSheet = new Canvas(clumpSheetW, CLUMP_H); +const clumpJson = {}; +for (let i = 0; i < clumpFrames.length; i++) { + const src = clumpFrames[i]; + for (let y = 0; y < src.height; y++) { + for (let x = 0; x < src.width; x++) { + const rgba = src.get(x, y); + if (rgba) { + clumpSheet.set(i * CLUMP_W + x, CLUMP_H - src.height + y, rgba); + } + } + } + clumpJson[clumpNames[i]] = { + frame: { x: i * CLUMP_W, y: 0, w: CLUMP_W, h: CLUMP_H }, + rotated: false, + trimmed: false, + sourceSize: { w: CLUMP_W, h: CLUMP_H } + }; +} +writeFileSync(OUT + 'chars/clumps_sheet.png', clumpSheet.toPng()); +writeFileSync( + OUT + 'chars/clumps_sheet.json', + JSON.stringify( + { + frames: clumpJson, + meta: { image: 'clumps_sheet.png', size: { w: clumpSheetW, h: CLUMP_H }, scale: 1 } + }, + null, + 2 + ) +); +console.log(` chars/clumps_sheet.png (+ .json): ${clumpSheetW}x${CLUMP_H}`); + // ---------- атлас героя (Spritesheet: один PNG + JSON) ---------- const FRAME_W = 16;