diff --git a/apps/game/src/systems/combat/CombatFlow.ts b/apps/game/src/systems/combat/CombatFlow.ts index af27eeb..2557d19 100644 --- a/apps/game/src/systems/combat/CombatFlow.ts +++ b/apps/game/src/systems/combat/CombatFlow.ts @@ -3,6 +3,7 @@ worldNorm, worldToScreen, unitsToPx, + conePath, type Camera, type Entity, type FxLayer, @@ -58,6 +59,10 @@ private ring: Graphics; /** Кольцо сейчас нарисовано: clear/перерисовка только на переходах. */ private ringVisible = false; + /** Сектор замаха (удержание атаки): полигон по conePath, двигается без перерисовки. */ + private swing: Graphics; + /** Ключ нарисованного сектора (направление + заряжен) — null сектор скрыт. */ + private swingKey: string | null = null; private offs: (() => void)[] = []; constructor(private deps: CombatFlowDeps, savedHp: number) { @@ -66,6 +71,8 @@ this.healthBar.setHp(this.playerCombat.hp); this.ring = new Graphics(); deps.game.renderer.worldRoot.addChild(this.ring); + this.swing = new Graphics(); + deps.game.renderer.worldRoot.addChild(this.swing); // Вспышка урона и брызги пепла — реакция вьюх на события ECS-мира. this.offs.push( deps.game.engine.events.on<{ entity: Entity }>('combat:hurt', ({ entity }) => { @@ -103,6 +110,7 @@ this.doResonance(from); } else if (action === 'attack') { this.deps.combat.playerConeAttack(from, this.deps.player.dirVector); + this.swingFlash(from, this.deps.player.dirVector); } } @@ -121,6 +129,7 @@ update(dt: number): void { this.playerCombat.update(dt); this.updateRing(); + this.updateSwing(); this.healthBar.setHp(this.playerCombat.hp); } @@ -152,6 +161,7 @@ for (const off of this.offs) off(); this.offs = []; this.ring.destroy(); // в worldRoot — не вычищается с this.world + this.swing.destroy(); } /** Резонанс: волна сна, кольцо, звон света, реакция наката. */ @@ -203,6 +213,53 @@ } } + /** + * Замах при удержании атаки: полупрозрачный сектор конуса по направлению + * взгляда (заряжен резонанс — круг волны). Полигон перерисовывается только + * на смене направления/состояния заряда; позиция следует за героем каждый + * тик (сдвиг вьюхи, без перестройки полигона). + */ + private updateSwing(): void { + const charging = this.playerCombat.isCharging(); + if (!charging) { + if (this.swingKey) { + this.swingKey = null; + this.swing.clear(); + } + return; + } + const ready = this.playerCombat.chargeTime >= PLAYER_COMBAT.resonanceCharge; + const dir = this.deps.player.dirVector; + const key = `${dir.x},${dir.y}|${ready ? 'r' : 'c'}`; + if (key !== this.swingKey) { + this.swingKey = key; + this.swing.clear(); + const d = worldToScreen(dir.x, dir.y); // угол сектора — в экранных осях (как inConeW) + if (ready) { + this.swing + .circle(0, 0, unitsToPx(PLAYER_COMBAT.resonanceRange) - 2) + .fill({ color: 0xd99a32, alpha: 0.12 }); + } else { + const pts = conePath({ x: 0, y: 0 }, d, unitsToPx(PLAYER_COMBAT.coneRange), PLAYER_COMBAT.coneHalfAngle); + this.swing.poly(pts.flatMap((p) => [p.x, p.y])).fill({ color: 0xf2b45a, alpha: 0.15 }); + } + } + const p = worldToScreen(this.deps.player.position.x, this.deps.player.position.y); + this.swing.position.set(p.x, p.y); + } + + /** След удара: яркая вспышка-сектор по конусу, тает за ~0.18 с (FxLayer.destroy). */ + private swingFlash(from: Vec2, dir: Vec2): void { + const p = worldToScreen(from.x, from.y); + const d = worldToScreen(dir.x, dir.y); + const pts = conePath({ x: 0, y: 0 }, d, unitsToPx(PLAYER_COMBAT.coneRange), PLAYER_COMBAT.coneHalfAngle); + const flash = new Graphics(); + flash.poly(pts.flatMap((q) => [q.x, q.y])).fill({ color: 0xf2cd68, alpha: 0.4 }); + flash.position.set(p.x, p.y); + this.deps.game.renderer.worldRoot.addChild(flash); + this.deps.fx.fade(flash, { duration: 0.18 }); + } + /** Инкремент боевого счётчика в vars GameState. */ private bump(name: keyof typeof BUMP, by: number): void { const key = BUMP[name]; diff --git a/docs/demo.md b/docs/demo.md index 4b56d2b..175fde7 100644 --- a/docs/demo.md +++ b/docs/demo.md @@ -102,7 +102,8 @@ ## Новое в движке за срез - `render/shake.ts` — `Shake` + `Camera.addShake` (детерминированный rng). -- `math/shapes.ts` — `inCircle`, `inCone`, `angleBetween`, `nearest`. +- `math/shapes.ts` — `inCircle`, `inCone`, `angleBetween`, `nearest`, `conePath` + (полигон сектора для отрисовки: замах удара и след-вспышка в демо). - `core/Cooldown.ts` — `trigger/update/ready/progress`. - `render/particleSim.ts` — чистая интеграция частиц; `Particles.burst/oneShot`. - `anim/flash.ts` + `render/spriteFx.ts` — `TintFlash`/`flashSprite`. diff --git a/packages/engine/src/index.ts b/packages/engine/src/index.ts index 0faacf3..ca3219a 100644 --- a/packages/engine/src/index.ts +++ b/packages/engine/src/index.ts @@ -97,7 +97,8 @@ inCone, inConeW, angleBetween, - nearest + nearest, + conePath } from './math/shapes'; // map diff --git a/packages/engine/src/math/__tests__/shapes.test.ts b/packages/engine/src/math/__tests__/shapes.test.ts index fce3a84..1a5539b 100644 --- a/packages/engine/src/math/__tests__/shapes.test.ts +++ b/packages/engine/src/math/__tests__/shapes.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { inCircle, inCircleW, inCone, inConeW, angleBetween, nearest } from '../shapes'; +import { inCircle, inCircleW, inCone, inConeW, angleBetween, nearest, conePath } from '../shapes'; import { worldToScreen } from '../iso'; describe('inCircle', () => { @@ -120,4 +120,37 @@ } } }); -}); \ No newline at end of file +}); +describe('conePath', () => { + it('вершина в from, точки дуги на радиусе', () => { + const from = { x: 10, y: 20 }; + const pts = conePath(from, { x: 1, y: 0 }, 50, Math.PI / 4, 8); + expect(pts[0]).toEqual({ x: 10, y: 20 }); + expect(pts).toHaveLength(10); // вершина + segments + 1 + for (const p of pts.slice(1)) { + const d = Math.hypot(p.x - from.x, p.y - from.y); + expect(d).toBeCloseTo(50, 6); + } + }); + + it('дуга симметрична относительно dir, крайние точки на ±halfAngle', () => { + const from = { x: 0, y: 0 }; + const half = Math.PI / 3; + const pts = conePath(from, { x: 0, y: -1 }, 100, half, 12); + const first = pts[1]!; + const last = pts[pts.length - 1]!; + // зеркальность по экранному X (dir вверх — ось симметрии x = 0) + expect(first.x).toBeCloseTo(-last.x, 6); + expect(first.y).toBeCloseTo(last.y, 6); + // средняя точка дуги — ровно по dir + const mid = pts[1 + 6]!; + expect(mid.x).toBeCloseTo(0, 6); + expect(mid.y).toBeCloseTo(-100, 6); + }); + + it('нулевой halfAngle — вырожденный сектор (луч)', () => { + const pts = conePath({ x: 1, y: 1 }, { x: 1, y: 0 }, 40, 0, 5); + expect(pts).toHaveLength(7); + for (const p of pts.slice(1)) expect(p.y).toBeCloseTo(1, 6); + }); +}); diff --git a/packages/engine/src/math/shapes.ts b/packages/engine/src/math/shapes.ts index 6f0898f..893bda0 100644 --- a/packages/engine/src/math/shapes.ts +++ b/packages/engine/src/math/shapes.ts @@ -85,4 +85,26 @@ } } return best; +} + +/** + * Полигон сектора для отрисовки (Graphics): вершина в from, дуга по углам + * dir±halfAngle из segments сегментов. Углы — в том же пространстве, что inCone + * (экранном); радиус — px. Возвращает [from, ...дуга]; замыкание контура — + * на вызывающей стороне (closePath). Нулевое направление — дуга по base = 0. + */ +export function conePath( + from: Vec2, + dir: Vec2, + radius: number, + halfAngle: number, + segments = 10 +): Vec2[] { + const base = Math.atan2(dir.y, dir.x); + const out: Vec2[] = [{ x: from.x, y: from.y }]; + for (let i = 0; i <= segments; i++) { + const a = base - halfAngle + (2 * halfAngle * i) / segments; + out.push({ x: from.x + Math.cos(a) * radius, y: from.y + Math.sin(a) * radius }); + } + return out; } \ No newline at end of file