diff --git a/apps/game/src/data/enemies.ts b/apps/game/src/data/enemies.ts index 0ee6a87..f952ecf 100644 --- a/apps/game/src/data/enemies.ts +++ b/apps/game/src/data/enemies.ts @@ -9,11 +9,11 @@ id: EnemyKindId; /** Здоровье. */ hp: number; - /** Скорость, виртуальных пикселей/сек. */ + /** Скорость, мировых юнитов/сек (1 юнит = 1 тайл). */ speed: number; - /** Радиус тела для попаданий, пикселей. */ + /** Радиус тела для попаданий, юнитов. */ radius: number; - /** Дистанция начала атаки, пикселей. */ + /** Дистанция начала атаки, юнитов. */ attackRange: number; /** Урон атаки. */ attackDamage: number; @@ -23,7 +23,7 @@ windup: number; /** Восстановление после удара, сек. */ recover: number; - /** Плевун держит дистанцию (пикселей) — отходит при сближении. */ + /** Плевун держит дистанцию (юнитов) — отходит при сближении. */ keepDistance?: number; /** Дроп «пепельной пыли». */ motes: number; @@ -35,9 +35,9 @@ crawler: { id: 'crawler', hp: 3, - speed: 22, - radius: 7, - attackRange: 12, + speed: 0.7, + radius: 0.2, + attackRange: 0.4, attackDamage: 1, attackCooldown: 0.8, windup: 0, @@ -48,23 +48,23 @@ spitter: { id: 'spitter', hp: 2, - speed: 18, - radius: 7, - attackRange: 90, + speed: 0.55, + radius: 0.2, + attackRange: 3.0, attackDamage: 1, attackCooldown: 2.2, windup: 0.35, recover: 0.4, - keepDistance: 70, + keepDistance: 2.25, motes: 1, frames: ['clump_spitter_1', 'clump_spitter_2'] }, heavy: { id: 'heavy', hp: 8, - speed: 14, - radius: 10, - attackRange: 26, + speed: 0.45, + radius: 0.3, + attackRange: 0.8, attackDamage: 2, attackCooldown: 2, windup: 0.6, @@ -74,4 +74,4 @@ } }; -/** Точки спавна врагов задаются в data/locations.ts (LOCATIONS[*].enemies). */ \ No newline at end of file +/** Точки спавна врагов задаются в data/locations.ts (LOCATIONS[*].enemies). */ diff --git a/apps/game/src/scenes/LocationScene.ts b/apps/game/src/scenes/LocationScene.ts index 9cb8e68..d869c7c 100644 --- a/apps/game/src/scenes/LocationScene.ts +++ b/apps/game/src/scenes/LocationScene.ts @@ -7,14 +7,17 @@ Sprite, Texture, IsometricTileMap, - isoToScreen, - screenToIsoExact, - inCircle, + worldToScreen, + screenToWorld, + worldDist, + worldNorm, + worldToTile, + tileToWorld, + inCircleW, findPathToNeighbor, DebugOverlay, SpriteDebugView, VirtualJoystick, - DEFAULT_ISO, type Camera, type Entity, type Scene, @@ -83,7 +86,7 @@ ) { this.camera = game.engine.camera; const data = this.game.mapFiles.get(location.id)!; - this.map = new IsometricTileMap(data, this.tileTextures(), DEFAULT_ISO); + this.map = new IsometricTileMap(data, this.tileTextures()); this.world.addChild(this.map.view, this.actors); this.game.renderer.worldRoot.addChild(this.world); @@ -117,7 +120,7 @@ } ); for (const s of location.enemies) { - this.combat.spawnEnemy(s.kind, this.tileCenter(s.tile.x, s.tile.y)); + this.combat.spawnEnemy(s.kind, tileToWorld(s.tile.x, s.tile.y)); } this.combatViews = new CombatViews(this.combat, this.actors, this.enemyTextures(), this.world); @@ -126,8 +129,9 @@ 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 s = worldToScreen(en.pos.x, en.pos.y); + if (en.brain.dead) this.combatViews.deathBurst(s); + else this.combatViews.hitBurst(s); }); const savedHp = this.game.state.getNumber('hp') || PLAYER_COMBAT.maxHp; @@ -334,7 +338,8 @@ const from = this.player.position; if (action === 'resonance') { const slept = this.combat.resonancePulse(from); - this.combatViews.resonanceRing(from); + const s = worldToScreen(from.x, from.y); + this.combatViews.resonanceRing(s); 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') { @@ -351,12 +356,12 @@ 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) { + const dist = worldDist(from, en.pos); + if (dist <= PLAYER_COMBAT.attackStop) { // В зоне — стоим и бьём по кулдауну 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.combat.playerConeAttack(from, worldNorm(en.pos.x - from.x, en.pos.y - from.y)); } this.target = null; // цель «снята» ударом; дальше игрок решает сам return; @@ -373,7 +378,8 @@ private updateChargeRing(): void { this.chargeRing.clear(); if (!this.playerCombat.isCharging()) return; - const p = this.player.position; + const u = this.player.position; + const p = worldToScreen(u.x, u.y); const k = Math.min(1, this.playerCombat.chargeTime / PLAYER_COMBAT.resonanceCharge); const color = k >= 1 ? 0xd99a32 : 0x888899; this.chargeRing @@ -388,11 +394,10 @@ 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); + this.player.applyKnockback(worldNorm(dx, dy), PLAYER_COMBAT.knockback); if (this.playerCombat.dead) { this.game.state.setVar('deaths', this.game.state.getNumber('deaths') + 1); @@ -450,22 +455,20 @@ /** Экранные координаты центра ромба тайла (с Origin карты). */ private tileCenter(tx: number, ty: number): { x: number; y: number } { - const p = isoToScreen(tx, ty, DEFAULT_ISO); - return { x: p.x, y: p.y + DEFAULT_ISO.tileH / 2 }; + const u = tileToWorld(tx, ty); + return worldToScreen(u.x, u.y); } private handleWorldClick(px: number, py: number): void { - // Из координат экрана в мировые (учёт позиции камеры). - const worldX = px - this.game.renderer.worldRoot.position.x; - const worldY = py - this.game.renderer.worldRoot.position.y; - - const clicked = screenToIsoExact( - worldX, - worldY, - this.map.data.width, - this.map.data.height, - DEFAULT_ISO + // Координаты указателя (виртуальные px) -> мировые юниты (учёт камеры). + const p = screenToWorld( + px - this.game.renderer.worldRoot.position.x, + py - this.game.renderer.worldRoot.position.y ); + const worldX = p.x; + const worldY = p.y; + + const clicked = worldToTile(worldX, worldY, this.map.data.width, this.map.data.height); // Клик по тайлу NPC — диалог (в радиусе сразу, издалека — подходим). if (clicked) { @@ -488,7 +491,7 @@ 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)) { + if (inCircleW(en.pos, en.kind.radius + 0.15, world)) { this.target = e; this.repathTimer = 0; return; @@ -500,11 +503,11 @@ this.player.onWorldClick(worldX, worldY); } - /** Радиус взаимодействия: расстояние от ног героя до центра тайла цели. */ - private static readonly INTERACT_RANGE = 44; + /** Радиус взаимодействия (юнитов): от ног героя до центра тайла цели. */ + private static readonly INTERACT_RANGE = 1.5; - private inInteractRange(targetPx: Vec2): boolean { - return inCircle(this.player.position, LocationScene.INTERACT_RANGE, targetPx); + private inInteractRange(tx: number, ty: number): boolean { + return inCircleW(this.player.position, LocationScene.INTERACT_RANGE, tileToWorld(tx, ty)); } /** @@ -513,8 +516,7 @@ */ private requestTalk(def: NpcDef, force = false): void { this.pendingInteraction = null; - const center = this.tileCenter(def.tile.x, def.tile.y); - if (force || this.inInteractRange(center)) { + if (force || this.inInteractRange(def.tile.x, def.tile.y)) { this.talkTo(def); return; } @@ -528,8 +530,7 @@ /** Сбор цветка: в радиусе — сразу, издалека — подойти и собрать. */ private requestCollect(x: number, y: number): void { this.pendingInteraction = null; - const center = this.tileCenter(x, y); - if (this.inInteractRange(center)) { + if (this.inInteractRange(x, y)) { this.collectFlower(x, y); return; } @@ -546,11 +547,9 @@ const p = this.pendingInteraction; this.pendingInteraction = null; if (p.kind === 'talk') { - const center = this.tileCenter(p.def.tile.x, p.def.tile.y); - if (this.inInteractRange(center)) this.talkTo(p.def); + if (this.inInteractRange(p.def.tile.x, p.def.tile.y)) this.talkTo(p.def); } else { - const center = this.tileCenter(p.x, p.y); - if (this.inInteractRange(center)) this.collectFlower(p.x, p.y); + if (this.inInteractRange(p.x, p.y)) this.collectFlower(p.x, p.y); } } @@ -651,8 +650,3 @@ 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 8a607f2..4e7315b 100644 --- a/apps/game/src/systems/PlayerController.ts +++ b/apps/game/src/systems/PlayerController.ts @@ -1,15 +1,16 @@ import { IsometricTileMap, findPath, - isoToScreen, - screenToIso, - screenToIsoExact, - moveTo, + worldToScreen, + screenToWorld, + worldNorm, + worldToTile, + tileToWorld, + moveTowardsW, FrameAnimation, Container, Sprite, Texture, - DEFAULT_ISO, type Vec2 } from '@rpg/engine'; @@ -25,6 +26,7 @@ /** * Управление героем: путь по клику (A*), плавное движение по маршруту, * покадровая анимация ходьбы по направлению движения. + * Позиция — в мировых юнитах (1 юнит = 1 тайл, ноги героя). */ export class PlayerController { readonly view: Container; @@ -34,12 +36,12 @@ private facing: Facing = 'down'; private textures: HeroTextures; - /** Позиция в виртуальных пикселях (источник истины, ноги героя). */ + /** Позиция в мировых юнитах (источник истины, ноги героя). */ private pos: Vec2; private path: { x: number; y: number }[] = []; private waypoint: Vec2 | null = null; - /** Скорость в виртуальных пикселях/сек. */ - private speed = 56; + /** Скорость в мировых юнитах/сек. */ + private speed = 1.75; constructor( private map: IsometricTileMap, @@ -55,9 +57,8 @@ this.view.addChild(this.sprite); this.anim = new FrameAnimation(this.sprite, textures.down, 6, true); - const p = isoToScreen(startTile.x, startTile.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)); + this.pos = tileToWorld(startTile.x, startTile.y); + this.syncView(); } /** Текущее направление взгляда (для боя: куда бьёт конус). */ @@ -65,37 +66,30 @@ return this.facing; } - /** Вектор направления взгляда в экранных координатах. */ + /** Вектор направления взгляда в мировых юнитах (экранные оси — мировые диагонали). */ get dirVector(): Vec2 { switch (this.facing) { case 'up': - return { x: 0, y: -1 }; + return { x: -1, y: -1 }; case 'left': - return { x: -1, y: 0 }; + return { x: -1, y: 1 }; case 'right': - return { x: 1, y: 0 }; + return { x: 1, y: -1 }; default: - return { x: 0, y: 1 }; + return { x: 1, y: 1 }; } } - /** Кликом по миру выбран тайл — строим путь A* и начинаем движение. + /** Кликом по миру (юниты) выбран тайл — строим путь A* и начинаем движение. * Клик за краем карты ведёт к ближайшему краевому тайлу (камера прижата границей). */ onWorldClick(worldX: number, worldY: number): void { - let tile = screenToIsoExact( - worldX, - worldY, - this.map.data.width, - this.map.data.height, - DEFAULT_ISO - ); - if (!tile) { - const approx = screenToIso(worldX, worldY, DEFAULT_ISO); - tile = { - x: Math.min(this.map.data.width - 1, Math.max(0, approx.x)), - y: Math.min(this.map.data.height - 1, Math.max(0, approx.y)) - }; - } + const w = this.map.data.width; + const h = this.map.data.height; + const clicked = worldToTile(worldX, worldY, w, h); + const tile = clicked ?? { + x: Math.min(w - 1, Math.max(0, Math.floor(worldX))), + y: Math.min(h - 1, Math.max(0, Math.floor(worldY))) + }; const path = findPath(this.map, this.currentTile(), tile, false); if (path && path.length > 0) { this.followPath(path); @@ -111,7 +105,7 @@ update(dt: number): void { if (!this.waypoint) return; - this.pos = moveTo(this.pos, this.waypoint, this.speed * dt); + this.pos = moveTowardsW(this.pos, this.waypoint, this.speed * dt); if (this.pos.x === this.waypoint.x && this.pos.y === this.waypoint.y) { this.onStep?.(); // ступили на новый тайл this.advanceWaypoint(); @@ -119,7 +113,7 @@ } this.faceMovement(); this.anim.update(dt); - this.view.position.set(Math.round(this.pos.x), Math.round(this.pos.y)); + this.syncView(); } get moving(): boolean { @@ -129,41 +123,40 @@ /** Текущий тайл героя. */ currentTile(): { x: number; y: number } { return ( - screenToIsoExact( - this.pos.x, - this.pos.y, - this.map.data.width, - this.map.data.height, - DEFAULT_ISO - ) ?? { x: 0, y: 0 } + worldToTile(this.pos.x, this.pos.y, this.map.data.width, this.map.data.height) ?? { x: 0, y: 0 } ); } - /** Прямое движение (тач-джойстик/стик): вектор -1..1, движение с проверкой стен. */ + /** Прямое движение (тач-джойстик/стик): экранный вектор -1..1, движение с проверкой стен. */ moveFree(dir: Vec2, dt: number): void { const len = Math.hypot(dir.x, dir.y); if (len < 0.01) { this.stop(); return; } + // Экранная ось джойстика -> мировое направление (экранные оси — мировые диагонали). + const w = screenToWorld(dir.x, dir.y); + const n = worldNorm(w.x, w.y); const step = this.speed * dt; - const nx = this.pos.x + (dir.x / len) * step; - const ny = this.pos.y + (dir.y / len) * step; + const nx = this.pos.x + n.x * step; + const ny = this.pos.y + n.y * step; // Двигаемся только если новая точка внутри карты и не в стене - const tile = screenToIsoExact(nx, ny, this.map.data.width, this.map.data.height, DEFAULT_ISO); + const tile = worldToTile(nx, ny, this.map.data.width, this.map.data.height); if (tile && this.map.isWalkable(tile.x, tile.y)) { + // Поворот — по экранным компонентам смещения (кадры down/up/side). + const proj = worldToScreen(n.x, n.y); this.setFacing( - Math.abs(dir.x) >= Math.abs(dir.y) ? (dir.x >= 0 ? 'right' : 'left') : dir.y >= 0 ? 'down' : 'up' + Math.abs(proj.x) >= Math.abs(proj.y) ? (proj.x >= 0 ? 'right' : 'left') : proj.y >= 0 ? 'down' : 'up' ); this.pos = { x: nx, y: ny }; this.anim.update(dt); - this.view.position.set(Math.round(this.pos.x), Math.round(this.pos.y)); + this.syncView(); } else { this.stop(); } } - /** Позиция героя (ноги) в виртуальных пикселях. */ + /** Позиция героя (ноги) в мировых юнитах. */ get position(): Vec2 { return { x: this.pos.x, y: this.pos.y }; } @@ -182,30 +175,30 @@ /** Мгновенно переместить героя в тайл (респаун, переходы между локациями). */ 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)); + this.pos = tileToWorld(tile.x, tile.y); + this.syncView(); } - /** Отброс: сдвиг позиции с проверкой проходимости (для боя). */ + /** Отброс: сдвиг позиции с проверкой проходимости (для боя), юниты. */ 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); + const tile = worldToTile(nx, ny, this.map.data.width, this.map.data.height); 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.syncView(); } this.stop(); } - /** Направление — по доминирующей оси движения к следующей точке пути. */ + /** Направление — по доминирующей экранной оси движения к следующей точке пути. */ private faceMovement(): void { if (!this.waypoint) return; const dx = this.waypoint.x - this.pos.x; const dy = this.waypoint.y - this.pos.y; + const proj = worldToScreen(dx, dy); this.setFacing( - Math.abs(dx) >= Math.abs(dy) ? (dx >= 0 ? 'right' : 'left') : dy >= 0 ? 'down' : 'up' + Math.abs(proj.x) >= Math.abs(proj.y) ? (proj.x >= 0 ? 'right' : 'left') : proj.y >= 0 ? 'down' : 'up' ); } @@ -221,11 +214,13 @@ private advanceWaypoint(): void { const next = this.path.shift(); - if (next) { - const p = isoToScreen(next.x, next.y); - this.waypoint = { x: p.x, y: p.y + DEFAULT_ISO.tileH / 2 }; - } else { - this.waypoint = null; - } + this.waypoint = next ? tileToWorld(next.x, next.y) : null; } -} \ No newline at end of file + + /** Вью-позиция: единственная точка перевода мир→экран (округление до px). */ + private syncView(): void { + const s = worldToScreen(this.pos.x, this.pos.y); + this.view.position.set(Math.round(s.x), Math.round(s.y)); + } +} + diff --git a/apps/game/src/systems/combat/CombatViews.ts b/apps/game/src/systems/combat/CombatViews.ts index 5933096..d8226a1 100644 --- a/apps/game/src/systems/combat/CombatViews.ts +++ b/apps/game/src/systems/combat/CombatViews.ts @@ -6,8 +6,8 @@ Sprite, SpriteFlash, Texture, - screenToIsoExact, - DEFAULT_ISO, + worldToTile, + worldToScreen, type Entity, type Vec2 } from '@rpg/engine'; @@ -82,7 +82,7 @@ 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 }; + worldToTile(p.x, p.y, map.data.width, map.data.height) ?? { x: 0, y: 0 }; // Спавн новых for (const [e, en] of this.combat.enemies) { @@ -92,7 +92,8 @@ 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 sp = worldToScreen(en.pos.x, en.pos.y); + root.position.set(sp.x, sp.y); const t = tileOf(en.pos); this.actors.add(root, t.x, t.y); this.views.set(e, { @@ -109,7 +110,8 @@ // Обновление/смерть 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 sp = worldToScreen(en.pos.x, en.pos.y); + view.root.position.set(sp.x, sp.y); const t = tileOf(en.pos); this.actors.setDepth(view.root, t.x, t.y); @@ -156,7 +158,8 @@ this.fxRoot.addChild(pv); this.projectileViews.set(e, pv); } - pv.position.set(pr.pos.x, pr.pos.y); + const sp = worldToScreen(pr.pos.x, pr.pos.y); + pv.position.set(sp.x, sp.y); } for (const [e, pv] of this.projectileViews) { if (!this.combat.world.isAlive(e)) { diff --git a/apps/game/src/systems/combat/CombatWorld.ts b/apps/game/src/systems/combat/CombatWorld.ts index 6908d52..01b7a60 100644 --- a/apps/game/src/systems/combat/CombatWorld.ts +++ b/apps/game/src/systems/combat/CombatWorld.ts @@ -3,14 +3,15 @@ AudioManager, IsometricTileMap, World, - screenToIsoExact, - inCircle, - inCone, + worldToTile, + worldDist, + worldNorm, + inCircleW, + inConeW, 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'; @@ -40,17 +41,17 @@ map: IsometricTileMap; events: EventBus; audio: AudioManager; - /** Позиция героя (ноги), виртуальные пиксели. */ + /** Позиция героя (ноги), мировые юниты (1 юнит = 1 тайл). */ 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); + const tile = worldToTile(nx, ny, map.data.width, map.data.height); if (tile && map.isWalkable(tile.x, tile.y)) { pos.x = nx; pos.y = ny; @@ -78,10 +79,10 @@ const dx = playerPos.x - en.pos.x; const dy = playerPos.y - en.pos.y; - const dist = Math.hypot(dx, dy) || 1; + const dist = worldDist(en.pos, playerPos) || 1e-4; const intent: EnemyIntent = en.brain.update(dt, { dist, - dirToPlayer: { x: dx / dist, y: dy / dist } + dirToPlayer: worldNorm(dx, dy) }); switch (intent.type) { @@ -90,7 +91,7 @@ break; case 'strike': // Удар доходит, если герой в радиусе тела + замах - if (dist <= en.kind.attackRange + 8) { + if (dist <= en.kind.attackRange + 0.25) { this.combat.deps.damagePlayer(en.kind.attackDamage, en.pos); } break; @@ -117,13 +118,13 @@ pr.pos.y += pr.vel.y * dt; // Столкновение с героем - if (inCircle(playerPos, 6, pr.pos)) { + if (inCircleW(playerPos, 0.2, 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); + const tile = worldToTile(pr.pos.x, pr.pos.y, this.combat.deps.map.data.width, this.combat.deps.map.data.height); if (pr.life <= 0 || !tile) { this.combat.killProjectile(e); } @@ -163,8 +164,8 @@ 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 }, + pos: { x: from.x + dir.x * 0.3, y: from.y + dir.y * 0.3 }, + vel: { x: dir.x * 2.2, y: dir.y * 2.2 }, damage, life: 1.5 }); @@ -189,7 +190,7 @@ 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; + if (!inConeW(from, dir, PLAYER_COMBAT.coneRange, PLAYER_COMBAT.coneHalfAngle, en.pos)) continue; this.damageEnemy(e, en, PLAYER_COMBAT.coneDamage, from); hits++; } @@ -204,7 +205,7 @@ let slept = 0; for (const [, en] of this.enemies) { if (en.brain.dead) continue; - if (inCircle(from, PLAYER_COMBAT.resonanceRange, en.pos)) { + if (inCircleW(from, PLAYER_COMBAT.resonanceRange, en.pos)) { en.brain.putToSleep(PLAYER_COMBAT.resonanceSleep); slept++; } @@ -215,7 +216,7 @@ /** Гулкий звук в точке: будит спящих сгустков в радиусе. */ loudSound(pos: Vec2, radius: number): void { for (const [, en] of this.enemies) { - if (inCircle(pos, radius, en.pos) && en.brain.asleep) { + 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 }); @@ -234,8 +235,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; + en.pos.x += Math.sign(en.pos.x - from.x) * 0.0625; + en.pos.y += Math.sign(en.pos.y - from.y) * 0.0625; } } } diff --git a/apps/game/src/systems/combat/EnemyBrain.ts b/apps/game/src/systems/combat/EnemyBrain.ts index f002bc6..d6e5fd3 100644 --- a/apps/game/src/systems/combat/EnemyBrain.ts +++ b/apps/game/src/systems/combat/EnemyBrain.ts @@ -17,7 +17,7 @@ | { type: 'shoot'; dir: Vec2 }; export interface EnemySenses { - /** Дистанция до героя, пикселей. */ + /** Дистанция до героя, мировых юнитов (1 юнит = 1 тайл). */ dist: number; /** Нормализованное направление к герою. */ dirToPlayer: Vec2; @@ -136,14 +136,14 @@ } // Плевун держит дистанцию if (this.kind.keepDistance !== undefined) { - if (senses.dist < this.kind.keepDistance - 12) { + 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 + 12) { + if (senses.dist > this.kind.keepDistance + 0.4) { return { type: 'move', dir: senses.dirToPlayer }; } return { type: 'idle' }; diff --git a/apps/game/src/systems/combat/__tests__/EnemyBrain.test.ts b/apps/game/src/systems/combat/__tests__/EnemyBrain.test.ts index 5d6b79b..8d448e1 100644 --- a/apps/game/src/systems/combat/__tests__/EnemyBrain.test.ts +++ b/apps/game/src/systems/combat/__tests__/EnemyBrain.test.ts @@ -17,8 +17,8 @@ return false; }; -const away: EnemySenses = { dist: 100, dirToPlayer: { x: 0, y: 1 } }; -const close: EnemySenses = { dist: 8, dirToPlayer: { x: 1, y: 0 } }; +const away: EnemySenses = { dist: 3.1, dirToPlayer: { x: 0, y: 1 } }; +const close: EnemySenses = { dist: 0.25, dirToPlayer: { x: 1, y: 0 } }; /** Разбудить и дождаться погони (спит -> rise 0.6 с -> chase). */ const wakeUp = (b: EnemyBrain, senses: EnemySenses): void => { @@ -108,14 +108,14 @@ describe('EnemyBrain (плевун)', () => { it('сближение — отходит, издали — идёт ближе', () => { const b = new EnemyBrain(ENEMY_KINDS.spitter); - wakeUp(b, { dist: 100, dirToPlayer: { x: 1, y: 0 } }); + wakeUp(b, { dist: 3.1, dirToPlayer: { x: 1, y: 0 } }); expect(b.state).toBe('chase'); // Слишком близко к keepDistance — пятится - const back = run(b, 0.05, { dist: 40, dirToPlayer: { x: 1, y: 0 } }); + const back = run(b, 0.05, { dist: 1.3, 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 } }); + const toward = run(b, 0.05, { dist: 3.8, dirToPlayer: { x: 0, y: 1 } }); expect(toward.type).toBe('move'); if (toward.type === 'move') expect(toward.dir.y).toBeGreaterThan(0); }); @@ -123,7 +123,7 @@ 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 } }; + const mid: EnemySenses = { dist: 2.5, dirToPlayer: { x: 1, y: 0 } }; // Входим в радиус атаки — замах run(b, 0.05, mid); expect(b.state).toBe('windup'); diff --git a/apps/game/src/systems/combat/__tests__/stats.test.ts b/apps/game/src/systems/combat/__tests__/stats.test.ts index 773d40f..fa4c4b3 100644 --- a/apps/game/src/systems/combat/__tests__/stats.test.ts +++ b/apps/game/src/systems/combat/__tests__/stats.test.ts @@ -10,25 +10,25 @@ it('герой: 5 сердечек, окно неуязвимости 0.8 с', () => { expect(PLAYER_COMBAT.maxHp).toBe(5); expect(PLAYER_COMBAT.invulnTime).toBeCloseTo(0.8); - expect(PLAYER_COMBAT.knockback).toBe(24); + expect(PLAYER_COMBAT.knockback).toBe(0.75); }); - it('короткий удар: конус 34 px, 55°, урон 2, кулдаун 0.45 с', () => { - expect(PLAYER_COMBAT.coneRange).toBe(34); + it('короткий удар: конус 1.0 юнита, 55°, урон 2, кулдаун 0.45 с', () => { + expect(PLAYER_COMBAT.coneRange).toBe(1.0); 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 с', () => { + it('резонанс: заряд 0.6 с, радиус 3.0, сон 4 с, кулдаун 3 с', () => { expect(PLAYER_COMBAT.resonanceCharge).toBeCloseTo(0.6); - expect(PLAYER_COMBAT.resonanceRange).toBe(96); + expect(PLAYER_COMBAT.resonanceRange).toBe(3.0); expect(PLAYER_COMBAT.resonanceSleep).toBe(4); expect(PLAYER_COMBAT.resonanceCooldown).toBe(3); }); - it('гулкий удар будит спящих в радиусе 150 px', () => { - expect(PLAYER_COMBAT.wakeRadius).toBe(150); + it('гулкий удар будит спящих в радиусе 4.5 юнитов', () => { + expect(PLAYER_COMBAT.wakeRadius).toBe(4.5); }); it('ползун: слабый, но быстрый, контактный', () => { diff --git a/apps/game/src/systems/combat/stats.ts b/apps/game/src/systems/combat/stats.ts index 3a4ad1b..dce8623 100644 --- a/apps/game/src/systems/combat/stats.ts +++ b/apps/game/src/systems/combat/stats.ts @@ -1,5 +1,7 @@ /** * Числовые константы боя героя — чистые данные без Pixi (тестируются). + * Позиции, дистанции и скорости — в мировых юнитах (1 юнит = 1 тайл; + * на экране 1 юнит = tileW px по линейке проекции). * Согласованы с боевой моделью демо-среза и сеттингом: * удар — «гулкий звон» (будит пепел), резонанс — «низкий звон» (усыпляет). */ @@ -8,18 +10,20 @@ /** Здоровье героя (сердечки). */ maxHp: 5, /** Конус удара: дальность, полуугол (рад), урон, кулдаун. */ - coneRange: 34, + coneRange: 1.0, coneHalfAngle: (55 * Math.PI) / 180, coneDamage: 2, coneCooldown: 0.45, + /** В зоне удара стоим ближе, чем coneRange (гистерезис авто-подхода). */ + attackStop: 0.85, /** Низкий резонанс: сколько держать заряд, радиус, сон врага, кулдаун. */ resonanceCharge: 0.6, - resonanceRange: 96, + resonanceRange: 3.0, resonanceSleep: 4, resonanceCooldown: 3, - /** Неуязвимость после урона, сек; отброс, пикселей. */ + /** Неуязвимость после урона, сек; отброс, юнитов. */ invulnTime: 0.8, - knockback: 24, + knockback: 0.75, /** Гулкий удар излучает звук: будит сгустков в этом радиусе. */ - wakeRadius: 150 -} as const; \ No newline at end of file + wakeRadius: 4.5 +} as const;