diff --git a/apps/game/src/agent/SceneAgentView.ts b/apps/game/src/agent/SceneAgentView.ts index 5bc8dc6..31a014e 100644 --- a/apps/game/src/agent/SceneAgentView.ts +++ b/apps/game/src/agent/SceneAgentView.ts @@ -9,6 +9,7 @@ type IsometricTileMap, type JsonValue, type SceneAgent, + type SceneRegistry, type SnapshotLayer } from '@rpg/engine'; import type { CutsceneRunner } from '@rpg/engine'; @@ -47,6 +48,8 @@ /** Определения NPC области (вьюхи не нужны мосту). */ npcs: readonly NpcDef[]; interactables: Interactables; + /** Реестр объектов сцены (для инварианта согласованности). */ + registry: SceneRegistry; dialogue: DialogueSystem; cutscene: CutsceneRunner; lastToast(): { text: string; tick: number } | null; @@ -144,10 +147,36 @@ checkRange('hero.hp', d.playerCombat.hp, 0, PLAYER_COMBAT.maxHp, where), checkWalkable('герой', heroTile, d.map, where), enemyChecks, + this.registryInvariant(where), d.combat.agentInvariants() ); } + /** Реестр согласован с ECS: у каждого живого врага есть запись, позиции совпадают. */ + private registryInvariant(where: string): Invariant[] { + const out: Invariant[] = []; + for (const [e, en] of this.deps.combat.enemies) { + if (en.brain.dead) continue; + const obj = this.deps.registry.get(`enemy:${e}`); + if (!obj) { + out.push({ + id: 'registry-consistent', + severity: 'error', + message: `враг #${e} (${en.kind.id}) нет в реестре сцены`, + where + }); + } else if (obj.pos.x !== en.pos.x || obj.pos.y !== en.pos.y) { + out.push({ + id: 'registry-consistent', + severity: 'error', + message: `враг #${e} (${en.kind.id}): позиция реестра разошлась с ECS`, + where + }); + } + } + return out; + } + /** Whitelist-команды для проверок (перемотки/читы). Неизвестная — null. */ agentCommand(name: string, args?: JsonValue): JsonValue { const d = this.deps; diff --git a/apps/game/src/data/__tests__/interactables.test.ts b/apps/game/src/data/__tests__/interactables.test.ts index 083c197..a3f5a6d 100644 --- a/apps/game/src/data/__tests__/interactables.test.ts +++ b/apps/game/src/data/__tests__/interactables.test.ts @@ -11,6 +11,7 @@ const def = (over: Partial = {}): InteractableDef => ({ id: 'obj', + pos: { x: 5.5, y: 5.5 }, tile: { x: 5, y: 5 }, kind: 'examine', responses: [{ text: 'обычная' }], diff --git a/apps/game/src/data/interactables.ts b/apps/game/src/data/interactables.ts index c00cb41..e232256 100644 --- a/apps/game/src/data/interactables.ts +++ b/apps/game/src/data/interactables.ts @@ -1,3 +1,4 @@ +import type { Vec2 } from '@rpg/engine'; import type { ItemId } from './items'; import type { AreaId } from './locations'; import { FLAGS, VARS } from './ids'; @@ -36,11 +37,24 @@ sound?: string; } +/** Вьюха объекта из ассетов: если задана — рисуется вместо плейсхолдера Graphics. */ +export interface InteractableView { + /** Ключ текстуры в ассетах (tiles/…, chars/…). */ + tex: string; + /** Якорь по Y в пикселях кадра от низа (по умолчанию низ спрайта). */ + anchorY?: number; +} + /** Объект мира, с которым герой взаимодействует кликом. */ export interface InteractableDef { id: string; + /** Позиция в мировых юнитах (центр тайла = координата + 0.5). */ + pos: Vec2; + /** Тайл привязки (floor от pos; вычисляется — в контенте не писать). */ tile: { x: number; y: number }; kind: 'examine' | 'pickup' | 'container' | 'lever' | 'signpost'; + /** Вьюха из ассетов (без неё — плейсхолдер Graphics по kind). */ + view?: InteractableView; /** Подпись при близости (и в снапшоте). */ label?: string; /** Одноразовый: помечается флагом used: и больше не реагирует. */ @@ -89,13 +103,13 @@ return { ok: true, response }; } -/** Контент по областям (см. docs/world.md): интерьеры и улицы. */ -export const INTERACTABLES: Partial> = { +/** Контент по областям (см. docs/world.md): интерьеры и улицы (pos — первичен). */ +const INTERACTABLES_RAW: Partial[]>> = { // --- луга: столбы у троп и подборные пепельные моты (зачаток экономики) --- meadows: [ { id: 'sign_meadows_east', - tile: { x: 25, y: 13 }, + pos: { x: 25.5, y: 13.5 }, kind: 'signpost', label: 'Столб у тропы', responses: [ @@ -107,7 +121,7 @@ }, { id: 'sign_meadows_west', - tile: { x: 2, y: 3 }, + pos: { x: 2.5, y: 3.5 }, kind: 'signpost', label: 'Столб у тропы', responses: [ @@ -119,7 +133,7 @@ }, { id: 'mote_1', - tile: { x: 11, y: 14 }, + pos: { x: 11.5, y: 14.5 }, kind: 'pickup', label: 'Пепельный мот', once: true, @@ -134,7 +148,7 @@ }, { id: 'mote_2', - tile: { x: 14, y: 20 }, + pos: { x: 14.5, y: 20.5 }, kind: 'pickup', label: 'Пепельный мот', once: true, @@ -149,7 +163,7 @@ }, { id: 'mote_3', - tile: { x: 14, y: 6 }, + pos: { x: 14.5, y: 6.5 }, kind: 'pickup', label: 'Пепельный мот', once: true, @@ -166,7 +180,7 @@ ponds: [ { id: 'sign_ponds', - tile: { x: 3, y: 2 }, + pos: { x: 3.5, y: 2.5 }, kind: 'signpost', label: 'Столб у берега', responses: [ @@ -180,7 +194,7 @@ zvenets: [ { id: 'sign_zvenets', - tile: { x: 3, y: 9 }, + pos: { x: 3.5, y: 9.5 }, kind: 'signpost', label: 'Столб у входа', responses: [ @@ -192,7 +206,7 @@ }, { id: 'bell_rope', - tile: { x: 13, y: 7 }, + pos: { x: 13.5, y: 7.5 }, kind: 'lever', label: 'Верёвка колокола', responses: [ @@ -212,7 +226,7 @@ house_elder: [ { id: 'chest_elder', - tile: { x: 3, y: 3 }, + pos: { x: 3.5, y: 3.5 }, kind: 'container', label: 'Сундук', once: true, @@ -226,7 +240,7 @@ }, { id: 'hearth_elder', - tile: { x: 8, y: 0 }, + pos: { x: 8.5, y: 0.5 }, kind: 'examine', label: 'Очаг', responses: [ @@ -240,7 +254,7 @@ }, { id: 'note_elder', - tile: { x: 10, y: 4 }, + pos: { x: 10.5, y: 4.5 }, kind: 'examine', label: 'Записка', once: true, @@ -256,7 +270,7 @@ shop: [ { id: 'counter_shop', - tile: { x: 5, y: 3 }, + pos: { x: 5.5, y: 3.5 }, kind: 'examine', label: 'Прилавок', responses: [ @@ -272,4 +286,11 @@ ] } ] -}; \ No newline at end of file +}; +/** Тайл привязки вычисляется из pos при загрузке модуля. */ +export const INTERACTABLES: Partial> = Object.fromEntries( + Object.entries(INTERACTABLES_RAW).map(([area, defs]) => [ + area, + defs.map((d) => ({ ...d, tile: { x: Math.floor(d.pos.x), y: Math.floor(d.pos.y) } })) + ]) +); diff --git a/apps/game/src/data/npcs.ts b/apps/game/src/data/npcs.ts index ee97f52..ce17454 100644 --- a/apps/game/src/data/npcs.ts +++ b/apps/game/src/data/npcs.ts @@ -1,12 +1,22 @@ +import type { Vec2 } from '@rpg/engine'; import type { FlagId } from './ids'; -/** NPC локации: тайл, спрайт, диалоги и флаг сюжета после первого разговора. */ +/** + * NPC локации: позиция в юнитах, спрайт/атлас, диалоги и флаг сюжета + * после первого разговора. Тайл привязки вычисляется из pos при загрузке + * модуля — снапшот/валидатор/клики читают `tile`, реестр объектов — `pos`. + */ export interface NpcDef { id: string; name: string; + /** Позиция ног в мировых юнитах (центр тайла = координата + 0.5). */ + pos: Vec2; + /** Тайл привязки (floor от pos; вычисляется — в контенте не писать). */ tile: { x: number; y: number }; /** Ключ спрайта в ассетах (chars/.png). */ sprite: string; + /** Атлас idle-анимации (по умолчанию chars/npcs_sheet.json). */ + atlas?: string; /** Первый диалог (когда флаг ещё не поднят). */ dialogueFirst: string; /** Повторный диалог. */ @@ -15,11 +25,11 @@ flagKey: FlagId; } -export const NPCS: NpcDef[] = [ +const NPCS_RAW: Omit[] = [ { id: 'elder', name: 'Старейшина Ирвин', - tile: { x: 12, y: 9 }, + pos: { x: 12.5, y: 9.5 }, sprite: 'elder_irwin', dialogueFirst: 'elder_first', dialogueRepeat: 'elder_repeat', @@ -28,10 +38,15 @@ { id: 'trader', name: 'Торговка Мила', - tile: { x: 17, y: 11 }, + pos: { x: 17.5, y: 11.5 }, sprite: 'trader_mila', dialogueFirst: 'trader_first', dialogueRepeat: 'trader_repeat', flagKey: 'metTrader' } -]; \ No newline at end of file +]; + +export const NPCS: NpcDef[] = NPCS_RAW.map((d) => ({ + ...d, + tile: { x: Math.floor(d.pos.x), y: Math.floor(d.pos.y) } +})); \ No newline at end of file diff --git a/apps/game/src/scenes/LocationScene.ts b/apps/game/src/scenes/LocationScene.ts index b3e37ff..2ea7868 100644 --- a/apps/game/src/scenes/LocationScene.ts +++ b/apps/game/src/scenes/LocationScene.ts @@ -51,6 +51,7 @@ import { HealthBar } from '../systems/combat/HealthBar'; import { PLAYER_COMBAT } from '../systems/combat/stats'; import { Interactables } from '../systems/Interactables'; +import { SceneObjects } from '../systems/SceneObjects'; import type { InteractableDef } from '../data/interactables'; /** @@ -84,6 +85,8 @@ private router: InteractionRouter; /** Интерактивные объекты области (сундуки, очаги, прилавки). */ private interactables: Interactables; + /** Объекты сцены на движковом реестре (кто на тайле — для кликов/коллизий). */ + private objects: SceneObjects; private interactViews: { def: InteractableDef; label: PixelText }[] = []; /** Оверлей дымки (зоны наката). Виден только в низинах. */ private fog: Graphics; @@ -124,6 +127,9 @@ // Крупные объекты карты (дома) — в общий depth-слой по footprint'у. for (const p of this.map.propViews) this.actors.addRect(p.view, p.prop.x, p.prop.y, p.prop.w, p.prop.h); this.game.renderer.worldRoot.addChild(this.world); + // Единый реестр объектов сцены: NPC, интерактивы, пропы (враги/фауна + // добавляются позже своими системами). + this.objects = new SceneObjects(area, this.map); const startTile = entry ?? save?.pos ?? area.spawn; // Тайл входа: step-переходы на нём глушатся, пока герой с него не ушёл @@ -168,7 +174,8 @@ events: this.game.engine.events, audio: this.game.audio, getPlayerPos: () => this.player.position, - damagePlayer: (dmg, from) => this.onPlayerDamaged(dmg, from) + damagePlayer: (dmg, from) => this.onPlayerDamaged(dmg, from), + registry: this.objects.registry }, (kind) => { // счётчики прогресса — в vars GameState @@ -202,7 +209,8 @@ this.actors, this.game.engine.events, data, - (area.fauna ?? []).map((t) => tileToWorld(t.x, t.y)) + (area.fauna ?? []).map((t) => tileToWorld(t.x, t.y)), + this.objects.registry ); this.fauna.setBlocked(data.blocked); this.fauna.setFrames( @@ -248,6 +256,7 @@ player: this.player, combat: this.combat, interactables: this.interactables, + objects: this.objects, disarmTile, worldRootOffset: () => this.game.renderer.worldRoot.position, callbacks: { @@ -270,6 +279,7 @@ combat: this.combat, npcs: this.npcs.map((n) => n.def), interactables: this.interactables, + registry: this.objects.registry, dialogue: this.dialogue, cutscene: this.cutscene, lastToast: () => this.lastToast, @@ -798,31 +808,40 @@ }); } - /** Вьюха интерактивного объекта: плейсхолдер-ящик + подпись (видна вблизи). */ + /** Вьюха интерактивного объекта: спрайт из def.view или плейсхолдер + подпись. */ private makeInteractableView(def: InteractableDef): { view: Container; label: PixelText } { const view = new Container(); const p = this.tileCenter(def.tile.x, def.tile.y); view.position.set(p.x, p.y); - const g = new Graphics(); - switch (def.kind) { - case 'signpost': // столбик с доской - g.rect(-1, -10, 2, 10).fill(0x4a3b2a); - g.rect(-6, -14, 12, 5).fill(0x5c4a33); - g.rect(-6, -14, 12, 1).fill(0x8a6d3a); - break; - case 'lever': // рычаг: основание и наклонная рукоять - g.rect(-3, -2, 6, 2).fill(0x4a3b2a); - g.rect(0, -8, 2, 7).fill(0x8a6d3a); - g.rect(-2, -10, 4, 2).fill(0x8a6d3a); - break; - case 'pickup': // кучка пепла с тёплой искрой - g.rect(-4, -3, 8, 3).fill(0x3a3a3a); - g.rect(-2, -4, 4, 2).fill(0x4a4a4a); - g.rect(0, -4, 1, 1).fill(0xf0d878); - break; - default: // контейнер/предмет осмотра: ящик с бронзовой кромкой - g.rect(-5, -12, 10, 12).fill(0x4a3b2a); - g.rect(-5, -12, 10, 2).fill(0x8a6d3a); // бронзовая кромка: «звонное» железо + let g: Graphics | Sprite; + if (def.view?.tex) { + // Настоящий арт из def.view: якорь низом в центр ромба (или задан). + const sprite = new Sprite(this.game.assets.texture(def.view.tex)); + sprite.anchor.set(0.5, def.view.anchorY ?? 1); + g = sprite; + } else { + // Плейсхолдер-ящик по роду (пока нет арта конкретного объекта). + g = new Graphics(); + switch (def.kind) { + case 'signpost': // столбик с доской + g.rect(-1, -10, 2, 10).fill(0x4a3b2a); + g.rect(-6, -14, 12, 5).fill(0x5c4a33); + g.rect(-6, -14, 12, 1).fill(0x8a6d3a); + break; + case 'lever': // рычаг: основание и наклонная рукоять + g.rect(-3, -2, 6, 2).fill(0x4a3b2a); + g.rect(0, -8, 2, 7).fill(0x8a6d3a); + g.rect(-2, -10, 4, 2).fill(0x8a6d3a); + break; + case 'pickup': // кучка пепла с тёплой искрой + g.rect(-4, -3, 8, 3).fill(0x3a3a3a); + g.rect(-2, -4, 4, 2).fill(0x4a4a4a); + g.rect(0, -4, 1, 1).fill(0xf0d878); + break; + default: // контейнер/предмет осмотра: ящик с бронзовой кромкой + g.rect(-5, -12, 10, 12).fill(0x4a3b2a); + g.rect(-5, -12, 10, 2).fill(0x8a6d3a); // бронзовая кромка: «звонное» железо + } } view.addChild(g); // Искра в кучке пепла пульсирует (alpha+scale, детерминированная фаза). @@ -848,8 +867,11 @@ const sprite = new Sprite(); sprite.anchor.set(0.5, 1); // ноги в центре ромба - // idle-дыхание кадрами из атласа, рассинхрон по координатам тайла. - const idle = this.game.assets.animation('chars/npcs_sheet.json', `${def.sprite}_idle`); + // idle-дыхание кадрами из атласа (по умолчанию — общий лист NPC). + const idle = this.game.assets.animation( + def.atlas ?? 'chars/npcs_sheet.json', + `${def.sprite}_idle` + ); sprite.texture = idle[0]!; const animator = new SpriteAnimator( sprite, diff --git a/apps/game/src/systems/ClickRouting.ts b/apps/game/src/systems/ClickRouting.ts index bde3ca4..5f92f89 100644 --- a/apps/game/src/systems/ClickRouting.ts +++ b/apps/game/src/systems/ClickRouting.ts @@ -16,6 +16,7 @@ import type { NpcDef } from '../data/npcs'; import type { InteractableDef } from '../data/interactables'; import type { Interactables } from './Interactables'; +import type { SceneObjects } from './SceneObjects'; import type { CombatWorld } from './combat/CombatWorld'; import { PLAYER_COMBAT } from './combat/stats'; import type { PlayerController } from './PlayerController'; @@ -108,6 +109,8 @@ player: PlayerController; combat: CombatWorld; interactables: Interactables; + /** Реестр объектов сцены: «кто на тайле» для NPC/интерактивов/врагов. */ + objects: SceneObjects; /** Тайл входа в область (step-переходы на нём дисармованы). */ disarmTile: { x: number; y: number } | null; /** Смещение worldRoot (виртуальные px) — перевод клика в юниты. */ @@ -151,7 +154,7 @@ transitionPick: clicked ? resolveTransition(this.deps.area.transitions, clicked, 'click', this.transitionCtx(), null) : null, - interactableAt: (x, y) => this.deps.interactables.defAt(x, y), + interactableAt: (x, y) => this.deps.objects.interactableAt(x, y), flowerAt: (x, y) => this.isFlower(x, y), enemyAt: (world) => this.enemyAt(world) }); @@ -319,7 +322,7 @@ } private npcAt(x: number, y: number): NpcDef | null { - return this.deps.area.npcs.find((n) => n.tile.x === x && n.tile.y === y) ?? null; + return this.deps.objects.npcAt(x, y); } /** Лунный колокольчик: сборный тайл, только в прудах (id области). */ @@ -329,11 +332,16 @@ return d.tiles[y * d.width + x] === TILES.BELLFLOWER; } - /** Живой враг под точкой мира (с запасом на радиус тела). */ + /** Живой враг под точкой мира (реестр: тело в радиусе + 0.15 клик-запаса). */ private enemyAt(world: Vec2): Entity | null { - for (const [e, en] of this.deps.combat.enemies) { - if (en.brain.dead) continue; - if (inCircleW(en.pos, en.kind.radius + 0.15, world)) return e; + // Выборка с запасом (крупные виды) — точная метрика ниже. + const hits = this.deps.objects.registry.near(world, 1.0, 'enemy'); + for (const hit of hits) { + const e = hit.ref as Entity; + const en = this.deps.combat.enemies.get(e); + if (!en || en.brain.dead) continue; + // Точная метрика как раньше: радиус тела + клик-запас. + if (inCircleW(hit.pos, hit.radius + 0.15, world)) return e; } return null; } diff --git a/apps/game/src/systems/SceneObjects.ts b/apps/game/src/systems/SceneObjects.ts new file mode 100644 index 0000000..0158b7a --- /dev/null +++ b/apps/game/src/systems/SceneObjects.ts @@ -0,0 +1,64 @@ +import { SceneRegistry, tileToWorld, type IsometricTileMap, type SceneObject } from '@rpg/engine'; +import type { AreaDef } from '../data/locations'; +import type { NpcDef } from '../data/npcs'; +import type { InteractableDef } from '../data/interactables'; + +/** + * Объекты сцены на движковом SceneRegistry: единый список «кто на карте» + * (NPC, интерактивы, пропы, фауна, враги) с индексом по тайлам. + * Клик-роутинг, подписи и телесные коллизии спрашивают здесь, а не перебирают + * параллельные списки. Роды задаёт игра: 'npc' | 'interactable' | 'prop' | + * 'fauna' | 'enemy'. + */ + +/** Радиус тела NPC (юниты) — для будущих телесных коллизий. */ +const NPC_RADIUS = 0.35; + +export class SceneObjects { + readonly registry = new SceneRegistry(); + + constructor(area: AreaDef, map: IsometricTileMap) { + for (const def of area.npcs) { + this.registry.add({ + id: `npc:${def.id}`, + kind: 'npc', + pos: tileToWorld(def.tile.x, def.tile.y), + radius: NPC_RADIUS, + ref: def + }); + } + for (const def of area.interactables ?? []) { + this.registry.add({ + id: `int:${def.id}`, + kind: 'interactable', + pos: tileToWorld(def.tile.x, def.tile.y), + ref: def + }); + } + // Пропы карты: footprint-объекты, занимают все свои тайлы. + for (const prop of map.props) { + this.registry.add({ + id: `prop:${prop.x},${prop.y}`, + kind: 'prop', + pos: { x: prop.x + prop.w / 2, y: prop.y + prop.h / 2 }, + footprint: { w: prop.w, h: prop.h }, + ref: prop + }); + } + } + + /** NPC на тайле (клик-роутинг). */ + npcAt(x: number, y: number): NpcDef | null { + return (this.registry.at(x, y, 'npc')?.ref as NpcDef | undefined) ?? null; + } + + /** Интерактивный объект на тайле (клик-роутинг). */ + interactableAt(x: number, y: number): InteractableDef | null { + return (this.registry.at(x, y, 'interactable')?.ref as InteractableDef | undefined) ?? null; + } + + /** Все объекты рода (подписи, вьюхи, проверки). */ + byKind(kind: string): readonly SceneObject[] { + return this.registry.byKind(kind); + } +} \ No newline at end of file diff --git a/apps/game/src/systems/__tests__/ClickRouting.test.ts b/apps/game/src/systems/__tests__/ClickRouting.test.ts index 3347617..36c7661 100644 --- a/apps/game/src/systems/__tests__/ClickRouting.test.ts +++ b/apps/game/src/systems/__tests__/ClickRouting.test.ts @@ -9,6 +9,7 @@ id: 'elder', name: 'Ирвин', sprite: 'elder', + pos: { x: 5.5, y: 5.5 }, tile: { x: 5, y: 5 }, flagKey: 'met_elder', dialogueFirst: 'd1', @@ -18,6 +19,7 @@ const chest: InteractableDef = { id: 'chest', kind: 'container', + pos: { x: 6.5, y: 5.5 }, tile: { x: 6, y: 5 }, responses: [] }; diff --git a/apps/game/src/systems/__tests__/Interactables.test.ts b/apps/game/src/systems/__tests__/Interactables.test.ts index e5d4753..c513b0e 100644 --- a/apps/game/src/systems/__tests__/Interactables.test.ts +++ b/apps/game/src/systems/__tests__/Interactables.test.ts @@ -5,6 +5,7 @@ const def = (over: Partial = {}): InteractableDef => ({ id: 'chest', + pos: { x: 3.5, y: 3.5 }, tile: { x: 3, y: 3 }, kind: 'container', once: true, diff --git a/apps/game/src/systems/combat/CombatWorld.ts b/apps/game/src/systems/combat/CombatWorld.ts index 4a2dd9f..f1310aa 100644 --- a/apps/game/src/systems/combat/CombatWorld.ts +++ b/apps/game/src/systems/combat/CombatWorld.ts @@ -10,6 +10,7 @@ inConeW, tileToWorld, type Entity, + type SceneRegistry, type System, type Vec2, type Invariant @@ -61,6 +62,11 @@ getPlayerPos: () => Vec2; /** Герой получил урон (сцена отбросит/шейкнет/проиграет звук). */ damagePlayer: (damage: number, from: Vec2) => void; + /** + * Реестр объектов сцены (опция): враги регистрируются в нём для кликов + * и телесных коллизий — боевая система остаётся на ECS. + */ + registry?: SceneRegistry; } /** Окно слышимости шума: событие живёт столько секунд для бодрых ушей. */ @@ -107,6 +113,7 @@ if (en.corpseTimer <= 0) { world.destroyEntity(e); this.combat.enemies.delete(e); + this.combat.deps.registry?.remove(`enemy:${e}`); } continue; } @@ -142,6 +149,7 @@ en.kind.speed * (intent.run ? RUN_MULT : 1), dt ); + this.combat.deps.registry?.move(`enemy:${e}`, en.pos); break; case 'strike': // Удар доходит, если герой в радиусе тела + замах @@ -228,6 +236,14 @@ los: false, losTimer: 0 }); + // В реестре сцены — та же ссылка pos: move() после шага переиндексирует. + this.deps.registry?.add({ + id: `enemy:${e}`, + kind: 'enemy', + pos: this.enemies.get(e)!.pos, + radius: kind.radius, + ref: e + }); return e; } diff --git a/apps/game/src/systems/fauna/FaunaSystem.ts b/apps/game/src/systems/fauna/FaunaSystem.ts index f03e3ea..0916c70 100644 --- a/apps/game/src/systems/fauna/FaunaSystem.ts +++ b/apps/game/src/systems/fauna/FaunaSystem.ts @@ -9,6 +9,7 @@ worldToScreen, type EventBus, type IsoDepthLayer, + type SceneRegistry, type TileMapData, type Vec2 } from '@rpg/engine'; @@ -24,6 +25,8 @@ /** Скорость брожения / подхода (юниты/сек). */ const WANDER_SPEED = 0.35; const APPROACH_SPEED = 0.7; +/** Радиус тела оленя (юниты) — для телесных коллизий. */ +const FAUNA_RADIUS = 0.4; interface FaunaEntity { /** Позиция (ноги) в мировых юнитах. */ @@ -48,6 +51,8 @@ events: EventBus, private data: TileMapData, spawns: Vec2[], + /** Реестр сцены (опция): олени регистрируются для «кто рядом». */ + private registry?: SceneRegistry, seed = 20260908 ) { // Детерминированный ГПСЧ — брожение одинаково между запусками. @@ -115,6 +120,12 @@ e.machine.change('idle'); e.pause = 1.5 + this.rng() * 3; this.entities.push(e); + this.registry?.add({ + id: `fauna:${this.entities.length - 1}`, + kind: 'fauna', + pos: e.pos, + radius: FAUNA_RADIUS + }); } /** Начать шаг к случайной соседней проходимой точке. */ @@ -165,7 +176,7 @@ } update(dt: number): void { - for (const e of this.entities) { + for (const [i, e] of this.entities.entries()) { e.machine.update(dt); // Покадровая анимация: на ходу play, в покое — стоп-кадр 0. if (e.dest) { @@ -175,6 +186,7 @@ e.animator.showStep(0); } e.animator.update(dt); + this.registry?.move(`fauna:${i}`, e.pos); const sp = worldToScreen(e.pos.x, e.pos.y); e.view.position.set(sp.x, sp.y); this.actors.setDepth(e.view, Math.floor(e.pos.x), Math.floor(e.pos.y));