diff --git a/v2/CLAUDE.md b/v2/CLAUDE.md index c980b30..633b6d5 100644 --- a/v2/CLAUDE.md +++ b/v2/CLAUDE.md @@ -607,6 +607,45 @@ - **solidAt для blocked-гейта — колонка НАД землёй (y≥2)**: земля y0-1 сплошная всюду; проверка с y=0 даёт «всё заблокировано». +## Ревью-раунд (2026-09-10): багфиксы движка и игры + +Системный поиск ошибок (два ревьюера + сверка с v1). Изменения API движка: + +- **`GameLoop.stepTick` вне ручного режима возвращает 0 и не делает шагов** — + поверх живого цикла шаги удвоились бы. Гейты звали `stepTicks` после + `setManual(true)` — поведение не изменилось, но защита теперь в движке. +- **`EntityWorld.addComponent` на мёртвой сущности возвращает `undefined`** + (было `T`, падение TypeError) — хук урона может убить сущность в том же + тике, что другая система пишет ей компонент. +- **`AudioManager.dispose` закрывает AudioContext и гасит ВСЕ лупы**: + `Loops.stopAll` гасит и «свободные» слоты `startSlot`/`playLoopBuffer` + (раньше они не останавливались никогда — слот музыки они не занимают). +- **`validateDialogue` ловит больше**: неизвестный `kind` в `do[]` узла (не + только выбора) и циклы узлов-действий (рантайм проваливается по next без + ожидания игрока — цикл раньше взрывался MAX_STEPS посреди игры). +- **`DialogueRunner` реентрантность**: эффект, синхронно зовущий + `abort()`/`start()`, больше не оставляет «зомби-диалог» — цикл `enterNode` + проверяет, что граф не подменён/не погашен. +- **`dayNightFactor` на вырожденных границах spec** (day === dawn) даёт + 0/1, а не NaN (NaN раньше протекал в интенсивности солнца). +- **`remapSlots` с пустой палитрой сцены** — слот не перекладывается, а не + TypeError. + +Багфиксы игры (v2/apps/game): тема лугов не останавливалась при уходе и +наслаивалась при каждом возвращении (playLoopBuffer-луп не гасился — теперь +GameAudio держит хендл и кроссфейдит сам); «E» сквозь катсцену/фейд открывал +диалог по протухшим целям (гард); порядок выбора диалога NPC инвертирован +против v1 (квест-стадия перекрывала незнакомство — теперь v1: незнакомый +всегда first); «E» — ближайшая цель с приоритетом при ничьей (v1 +pickInteract), а не жёсткий порядок npc→переход→объект→цветок; радиус NPC +4.5 вокселя (v1 1.5 юнита); скорость героя 5.25 вокс/с (v1 1.75 юнита/с); +колокольчик при сборе попадает в сумку (v1); ночной свет колокольчиков +следует за сбором/посадкой (живой слой тайлов — аналог setTileTracked v1); +пустой return-стек фолбэчится на спавн (иначе фейд зацикливается); RLE +раскладки декодируется один раз с кэшем (isFlower зовётся 25 раз/тик); +камера входа в область на float-цели без округления (микро-скачок в начале +фейда-ин). + ## Рабочие привычки - Для задач из 3+ шагов веди список задач и иди автономно. diff --git a/v2/apps/game/src/audio.ts b/v2/apps/game/src/audio.ts index 091f2c9..401b492 100644 --- a/v2/apps/game/src/audio.ts +++ b/v2/apps/game/src/audio.ts @@ -6,7 +6,7 @@ * шаги и боевые звуки — треки 19+. */ import { AudioManager, renderMusicStereo } from '@rpg/engine'; -import type { MusicSpec } from '@rpg/engine'; +import type { MusicHandle, MusicSpec } from '@rpg/engine'; import { themeOf } from './data/music'; /** Ширина кроссфейда темы (сек) — как fade v1 (2 с лупов амбиента, музыка резче). */ @@ -16,6 +16,8 @@ readonly manager = new AudioManager((key) => key); /** Запущенные темы (для моста/гейта: факт запуска видно в шпионе). */ private current: string | null = null; + /** Хендл текущего лупа: startSlot не занимает слот музыки — стопим сами. */ + private handle: MusicHandle | null = null; /** Кольцевой буфер фактических запусков (мост/гейт — как в демке). */ readonly log: { key: string; volume: number }[] = []; @@ -35,17 +37,31 @@ if (spec === null) { if (this.current !== null) { this.current = null; - this.manager.stopMusic(THEME_FADE); + this.handle?.stop(THEME_FADE); + this.handle = null; } return; } if (this.current === areaId) return; // та же область — не перезапускать this.current = areaId; + const prev = this.handle; + this.handle = null; const buf = this.manager.createBuffer(renderMusicStereo(spec), 22050, 2); - if (!buf) return; - await this.manager.playLoopBuffer(`theme/${areaId}`, buf, { + if (!buf) { + prev?.stop(THEME_FADE); + return; + } + const h = await this.manager.playLoopBuffer(`theme/${areaId}`, buf, { bus: 'music', fade: THEME_FADE, volume: 0.55, }); + // за время await область могла смениться — неактуальный луп глушим сразу + if (this.current !== areaId) { + h?.stop(0); + prev?.stop(THEME_FADE); + return; + } + prev?.stop(THEME_FADE); // кроссфейд: прежняя тема затухает под новой + this.handle = h; } /** Что играет (мост/гейт). */ diff --git a/v2/apps/game/src/flowers.ts b/v2/apps/game/src/flowers.ts index 8ebe8e1..c8d1b7f 100644 --- a/v2/apps/game/src/flowers.ts +++ b/v2/apps/game/src/flowers.ts @@ -35,6 +35,8 @@ export class FlowerQuest { /** Правки по областям: areaId → (индекс тайла → стоит ли цветок). */ readonly edits: FlowerEdits = new Map(); + /** Кэш декодированных тайлов текущей раскладки (isFlower зовётся ~25 раз/тик). */ + private tilesCache: { layout: AreaLayout; flat: number[] } | null = null; constructor( private state: GameState, @@ -43,8 +45,18 @@ private layout: () => AreaLayout, private areaId: () => string, private showToast: (text: string) => void, + /** Собранный цветок — в сумку (v1: inventory.add('bellflower')). */ + private addItem: (id: string) => void = () => {}, + /** Правка тайла поля (v1 setTileTracked): свет колокольчиков следует за полем. */ + private onFlowerTile?: (tx: number, ty: number, present: boolean) => void, ) {} + /** Тайлы раскладки с кэшем (кэш живёт, пока жива ссылка на раскладку). */ + private flatTiles(l: AreaLayout): number[] { + if (this.tilesCache?.layout !== l) this.tilesCache = { layout: l, flat: tilesOf(l) }; + return this.tilesCache.flat; + } + /** Цветок стоит на тайле? (раскладка + правки) */ isFlower(tx: number, ty: number): boolean { const l = this.layout(); @@ -52,7 +64,7 @@ const idx = ty * l.width + tx; const edit = this.edits.get(this.areaId())?.get(idx); if (edit !== undefined) return edit; - return tilesOf(l)[idx] === T_BELLFLOWER; + return this.flatTiles(l)[idx] === T_BELLFLOWER; } /** Записать правку тайла (стоит/не стоит цветок). */ @@ -62,6 +74,7 @@ per.set(ty * this.layout().width + tx, present); const [x, y, z] = flowerVoxel(tx, ty); this.world().set(x, y, z, present ? FLOWER : 0); + this.onFlowerTile?.(tx, ty, present); } /** Восстановить цветки области после пересборки мира. */ @@ -77,6 +90,7 @@ /** Сбор лунного колокольчика: цветок — в сумку, прогресс — в vars. */ collect(tx: number, ty: number): void { this.setFlower(tx, ty, false); + this.addItem('bellflower'); // v1: предмет виден в сумке до сдачи this.state.setVar(VARS.flowers, this.state.getNumber(VARS.flowers) + 1); const n = this.state.getNumber(VARS.flowers); this.showToast(`Лунный колокольчик (${Math.min(n, QUEST_FLOWERS)}/${QUEST_FLOWERS})`); @@ -88,8 +102,8 @@ this.state.setVar(VARS.flowers, Math.max(0, left)); for (const [tx, ty] of QUEST_PLANT_TILES) this.setFlower(tx, ty, true); // Пересев: поляна расползается на соседние тайлы (якорь «надежда растёт»). - const tiles = tilesOf(this.layout()); const l = this.layout(); + const tiles = this.flatTiles(l); for (const [tx, ty] of QUEST_PLANT_TILES) { for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]] as const) { const x = tx + dx, y = ty + dy; @@ -102,8 +116,8 @@ /** Зелень у подножия башни: визуальный финал акта 1. */ raiseTowerGreen(): void { - const tiles = tilesOf(this.layout()); const l = this.layout(); + const tiles = this.flatTiles(l); for (const [tx, ty] of QUEST_TOWER_GREEN) { const t = tiles[ty * l.width + tx]; if ((t === T_GRASS || t === T_ASH) && !this.isFlower(tx, ty)) this.setFlower(tx, ty, true); diff --git a/v2/apps/game/src/main.ts b/v2/apps/game/src/main.ts index 182cd76..37a24b2 100644 --- a/v2/apps/game/src/main.ts +++ b/v2/apps/game/src/main.ts @@ -9,7 +9,7 @@ import { VoxelWorld, VoxelRenderer, meshChunk, ISO_YAW, mannequin, walkClip, Character, GameLoop, screenMoveToWorld, - DialogueRunner, nearestInteractable, Inventory, + DialogueRunner, Inventory, } from '@rpg/engine'; import type { VoxelCloud, DialogueGraph, DialogueView, @@ -94,7 +94,7 @@ const hero = new Character(MAN_RIG, MAN, WALK, [40, 60], { size: BODY_SIZE, - speed: 4, + speed: 5.25, // v1: 1.75 юнита/с × UNIT 3 (темп референса) strideLength: 3.2, }); const heroCloud = renderer.addVoxelCloud(hero.visual()); @@ -133,11 +133,38 @@ return out; } +/** Живой слой тайлов света текущей области (правки цветков двигают тайлы). */ +let layerPos = new Map(); +/** Тайл колокольчика (T_BELLFLOWER раскладки — источник света `bells`). */ +const T_LIGHT_FLOWER = 5; + +/** Правка цветка → слой тайлов света: собранный гаснет, посаженный загорается. */ +function trackFlowerTile( + pos: Map, + tx: number, + ty: number, + present: boolean, +): void { + const from = pos.get(T_LIGHT_FLOWER); + if (from) { + const i = from.findIndex((p) => p.x === tx && p.y === ty); + if (i >= 0) { + from.splice(i, 1); + if (from.length === 0) pos.delete(T_LIGHT_FLOWER); + } + } + if (present) { + const list = pos.get(T_LIGHT_FLOWER) ?? []; + if (!list.some((p) => p.x === tx && p.y === ty)) list.push({ x: tx, y: ty }); + pos.set(T_LIGHT_FLOWER, list); + } +} + // ---- NPC: статичные по области + размещения по флагам (Клинт) ---- -const NPC_RADIUS = 5; // радиус подхода (v1: 1.5 юнита = 4.5 вокселя, с запасом) +const NPC_RADIUS = 4.5; // радиус подхода (v1: 1.5 юнита; расчёты расстановки NPC v1 держат этот радиус) interface NpcEntity { def: NpcDef; - /** Точка взаимодействия для движкового nearestInteractable. */ + /** Точка взаимодействия (движковый формат; выбор цели — pickInteract). */ spot: Interactable; char: Character; cloud: VoxelCloud; @@ -203,6 +230,8 @@ () => layout, () => currentArea.id, showToast, + (id) => inv.add(id), // собранный цветок — в сумку (v1: inventory.add) + (tx, ty, present) => trackFlowerTile(layerPos, tx, ty, present), ); function stampAreaProps(): void { @@ -248,18 +277,23 @@ hero.body.vel = [0, 0, 0]; heroCloud.update(hero.visual()); prevTile = { ...entry }; // входной тайл — не «только что вошёл» (без триггера) - nearClick = null; // подсказка старой области не должна пережить переход - nearNpc = null; - nearInter = null; - nearFlower = null; + nearPick = null; // подсказка старой области не должна пережить переход inHazard = null; showHint(''); // свет области заново (источники/тон), пепел по атмосфере, тема с кроссфейдом lighting?.destroy(); + layerPos = layerTilePos(); + // правки цветков области (сбор/посадка) — в слой света сразу + const flowerEdits = flowers.edits.get(currentArea.id); + if (flowerEdits) { + for (const [idx, present] of flowerEdits) { + trackFlowerTile(layerPos, idx % layout.width, Math.floor(idx / layout.width), present); + } + } lighting = new GameLighting({ renderer, lighting: LIGHTING[currentArea.id] as LightingDef | undefined, - layerTilePos: layerTilePos(), + layerTilePos: layerPos, heroPos: () => ({ x: hero.body.pos[0], z: hero.body.pos[2] }), hasFlag: (f) => state.hasFlag(f), timeHours: () => clock.hours, @@ -275,7 +309,8 @@ addCloud: (v) => renderer.addVoxelCloud(v), }) : null; void audio.playTheme(currentArea.id); - renderer.setTarget([Math.round(c.x), GROUND_Y, Math.round(c.z)]); + // float-цель: следящая камера продолжит с той же точки без микро-скачка + renderer.setTarget([c.x, GROUND_Y, c.z]); } // ---- Фейд и переходы: out → смена области → in (полный = 2×duration) ---- @@ -293,14 +328,45 @@ const fade: FadeState = { active: false, t: 0, dur: 0.4, phase: 'out', apply: null }; let hintText = ''; -/** Ближайший click-переход (для «E» и подсказки). */ -let nearClick: TransitionDef | null = null; -/** Ближайший NPC в радиусе подхода (для «E — Поговорить»). */ -let nearNpc: NpcEntity | null = null; -/** Ближайший интерактивный объект (для «E — подпись»). */ -let nearInter: InteractableDef | null = null; -/** Ближайший собираемый цветок (тайл). */ -let nearFlower: { x: number; y: number } | null = null; +/** Кандидат кнопки действия (v1 InteractCandidate): то, с чем можно «E» в радиусе. */ +interface PickCandidate { + kind: 'npc' | 'transition' | 'interact' | 'flower'; + /** Дистанция от героя до центра тайла цели (воксели) — решает выбор. */ + dist: number; + /** Человекочитаемое имя для подписи «E — …». */ + label: string; + npc?: NpcEntity; + trans?: TransitionDef; + inter?: InteractableDef; + flower?: { x: number; y: number }; +} +/** Приоритет при ничьей по дистанции: NPC важнее перехода, тот — объекта, объект — цветка. */ +const PICK_PRIORITY: Record = { + npc: 0, + transition: 1, + interact: 2, + flower: 3, +}; +/** Эпсилон ничьей (воксели; v1: 0.05 юнита × UNIT). */ +const PICK_EPS = 0.15; + +/** Выбор цели «E» (v1 pickInteract): ближайший кандидат; при ничьей — приоритет kind. */ +function pickInteract(cands: PickCandidate[]): PickCandidate | null { + let best: PickCandidate | null = null; + for (const c of cands) { + if (c.dist > INTERACT_RANGE) continue; + if ( + best === null || + c.dist < best.dist - PICK_EPS || + (c.dist <= best.dist + PICK_EPS && PICK_PRIORITY[c.kind] < PICK_PRIORITY[best.kind]) + ) { + best = c; + } + } + return best; +} +/** Выбранная цель кнопки действия этого тика. */ +let nearPick: PickCandidate | null = null; /** Зона наката под ногами в прошлом тике (тост при входе — один раз). */ let inHazard: string | null = null; /** Тайл героя с прошлого тика (origin возврата + «только что вошёл»). */ @@ -378,10 +444,10 @@ } }; -/** Какой граф у NPC сейчас: квест-стадия → знакомый repeat → первый. */ +/** Какой граф у NPC сейчас (v1): незнакомый — всегда first; знакомый — квест-стадия → repeat. */ function dialogueIdFor(npc: NpcDef): string { - return questDialogueFor(state, npc.id, invWorld) - ?? (state.hasFlag(npc.flagKey) ? npc.dialogueRepeat : npc.dialogueFirst); + if (!state.hasFlag(npc.flagKey)) return npc.dialogueFirst; // знакомство не перекрывается квестом + return questDialogueFor(state, npc.id, invWorld) ?? npc.dialogueRepeat; } /** Начать разговор с NPC (id или ближайший). */ @@ -403,22 +469,26 @@ updateHud(); return; } - if (nearNpc) { - talkTo(nearNpc.def.id); + // катсцена/переход: цели «E» не сканируются (мир замер) — нажатия глотаем + if (cutscene || fade.active) return; + const pick = nearPick; + if (!pick) return; + if (pick.kind === 'npc') { + talkTo(pick.npc!.def.id); return; } - if (nearClick && !fade.active) { - startTransition(nearClick, heroTile()); + if (pick.kind === 'transition') { + startTransition(pick.trans!, heroTile()); return; } - if (nearInter) { - const pick = interactables.tryInteract(nearInter); - if (pick && !pick.ok) showToast(pick.lockedText); // заперто: сундук/машина/тон + if (pick.kind === 'interact') { + const res = interactables.tryInteract(pick.inter!); + if (res && !res.ok) showToast(res.lockedText); // заперто: сундук/машина/тон updateHud(); return; } - if (nearFlower) { - flowers.collect(nearFlower.x, nearFlower.y); + if (pick.flower) { + flowers.collect(pick.flower.x, pick.flower.y); updateHud(); } } @@ -584,7 +654,9 @@ fade.apply = () => { if (tr.target === 'return') { const back = returnStack.pop(); - if (back) setArea(back.area, back.entry); + // пустой стек (вход мостом/сейвом): v1 фолбэчится на спавн — + // иначе герой остаётся на триггере и фейд зацикливается + setArea(back ? back.area : fromArea, back ? back.entry : currentArea.spawn); } else { // origin — откуда вошли (шаг на триггер = прошлый тайл, дверь = стояли) returnStack.push({ area: fromArea, entry: origin }); @@ -712,41 +784,49 @@ const b = hero.body.pos; renderer.setTarget([b[0], GROUND_Y, b[2]]); // без округления — камера не дёргается } - // NPC рядом: ближайший в радиусе подхода (движковый чистый nearest) - const hit = talking ? null : nearestInteractable(npcEntities.map((e) => e.spot), hero.body.pos); - nearNpc = hit ? npcEntities.find((e) => e.spot === hit) ?? null : null; - nearInter = null; - nearFlower = null; + // цели «E» (v1 InteractRouting): кандидаты в радиусе → ближайший, + // приоритет kind только при ничьей + nearPick = null; if (!talking) { - // интерактив рядом: ближайший в радиусе (поднятые once — мимо) const b = hero.body.pos; - let best = Infinity; + const cands: PickCandidate[] = []; + for (const e of npcEntities) { + const c = tileCenter(e.def.tile.x, e.def.tile.y); + cands.push({ kind: 'npc', dist: Math.hypot(c.x - b[0], c.z - b[2]), label: 'Поговорить', npc: e }); + } for (const def of currentArea.interactables ?? []) { - if (def.once && interactables.isUsed(def.id)) continue; + if (def.once && interactables.isUsed(def.id)) continue; // поднято — мимо const c = tileCenter(def.tile.x, def.tile.y); - const d = Math.hypot(c.x - b[0], c.z - b[2]); - if (d <= INTERACT_RANGE && d < best) { - best = d; - nearInter = def; - } + cands.push({ + kind: 'interact', + dist: Math.hypot(c.x - b[0], c.z - b[2]), + label: def.label ?? def.id, + inter: def, + }); } // цветы: скан окна ±2 тайла вокруг героя по сборному тайлу области if (currentArea.harvestTile !== undefined) { - best = Infinity; for (let dy = -2; dy <= 2; dy++) { for (let dx = -2; dx <= 2; dx++) { const tx = tile.x + dx; const ty = tile.y + dy; if (!flowers.isFlower(tx, ty)) continue; const c = tileCenter(tx, ty); - const d = Math.hypot(c.x - b[0], c.z - b[2]); - if (d <= INTERACT_RANGE && d < best) { - best = d; - nearFlower = { x: tx, y: ty }; - } + cands.push({ + kind: 'flower', + dist: Math.hypot(c.x - b[0], c.z - b[2]), + label: 'Сорвать лунный колокольчик', + flower: { x: tx, y: ty }, + }); } } } + for (const t of currentArea.transitions) { + if (t.trigger !== 'click') continue; // step-переходы — входом на тайл + const c = tileCenter(t.tile.x, t.tile.y); + cands.push({ kind: 'transition', dist: Math.hypot(c.x - b[0], c.z - b[2]), label: t.label ?? 'перейти', trans: t }); + } + nearPick = pickInteract(cands); } if (talking) { showHint(''); @@ -764,20 +844,8 @@ } } prevTile = tile; - nearClick = - currentArea.transitions.find( - (t) => - t.trigger === 'click' && - Math.max(Math.abs(t.tile.x - tile.x), Math.abs(t.tile.y - tile.y)) <= 1, - ) ?? null; - // приоритет подсказки: NPC → переход → объект → цветок (как в v1) - showHint( - nearNpc ? 'E — Поговорить' - : nearClick ? `E — ${nearClick.label ?? 'перейти'}` - : nearInter ? `E — ${nearInter.label ?? nearInter.id}` - : nearFlower ? 'E — Сорвать лунный колокольчик' - : '', - ); + // подсказка — выбранная цель (ближайшая, приоритет при ничьей — v1) + showHint(nearPick ? `E — ${nearPick.label}` : ''); } // ---- Цикл: фиксированный шаг 60 Гц, ручной режим для гейтов ---- @@ -828,8 +896,10 @@ setFollow(on: boolean): void { followHero = on; if (!on) { + // float-цель спавна: кадр детерминирован (спавн фиксирован) и + // совпадает с кадром после gotoArea (там тоже float-цель) const c = tileCenter(currentArea.spawn.x, currentArea.spawn.y); - renderer.setTarget([Math.round(c.x), GROUND_Y, Math.round(c.z)]); + renderer.setTarget([c.x, GROUND_Y, c.z]); } }, /** Яркость солнца (утро/день/вечер — как в демке). */ @@ -922,17 +992,21 @@ }, /** Снапшот взаимодействия: ближайшая цель (приоритет v1) и подсказка. */ interactInfo() { - const kind = - nearNpc ? 'npc' : nearClick ? 'transition' : nearInter ? 'interact' : nearFlower ? 'flower' : null; + const p = nearPick; return { - label: nearClick?.label ?? null, + label: p?.kind === 'transition' ? p.trans?.label ?? null : null, hint: hintText, - kind, - near: nearNpc?.def.id ?? nearInter?.id ?? (nearFlower ? 'flower' : null), - target: nearInter - ? { id: nearInter.id, kind: nearInter.kind, tile: nearInter.tile } + kind: p ? p.kind : null, + near: + p?.kind === 'npc' ? p.npc!.def.id + : p?.kind === 'interact' ? p.inter!.id + : p?.kind === 'flower' ? 'flower' : null, - flower: nearFlower, + target: + p?.kind === 'interact' + ? { id: p.inter!.id, kind: p.inter!.kind, tile: p.inter!.tile } + : null, + flower: p?.kind === 'flower' ? p.flower! : null, }; }, /** Слот вокселя мира (гейт: пропы, цветы, вода). */ @@ -960,12 +1034,12 @@ pos: [...e.char.body.pos], dialogue: dialogueIdFor(e.def), })), - near: nearNpc?.def.id ?? null, + near: nearPick?.kind === 'npc' ? nearPick.npc!.def.id : null, }; }, /** Начать разговор (id NPC; без аргумента — ближайший). */ talkTo(id?: string): ReturnType { - const target = id ?? nearNpc?.def.id; + const target = id ?? (nearPick?.kind === 'npc' ? nearPick.npc!.def.id : undefined); if (target) talkTo(target); return dialogueSnapshot(); }, diff --git a/v2/packages/engine/src/audio/AudioManager.ts b/v2/packages/engine/src/audio/AudioManager.ts index 031e585..db6f55a 100644 --- a/v2/packages/engine/src/audio/AudioManager.ts +++ b/v2/packages/engine/src/audio/AudioManager.ts @@ -254,8 +254,17 @@ this.onVisibility = null; } this.voices.killAll(); - this.loops.stopMusic(0.05); - this.loops.stopAmbience(0.05); + this.loops.stopAll(0.05); // и свободные лупы (темы через playLoopBuffer) тоже + // контекст закрываем: иначе шины/кэш живут, а звук HMR-копии — параллельно + if (this.ctx) { + const ctx = this.ctx; + this.ctx = null; + this.buses = null; + this.specBuffers.clear(); + void ctx.close().catch(() => { + // уже закрыт — не важно + }); + } } /** Общий запуск голоса sfx (нормализация opts-числа — здесь). */ diff --git a/v2/packages/engine/src/audio/Loops.ts b/v2/packages/engine/src/audio/Loops.ts index 1cd29c7..97818b0 100644 --- a/v2/packages/engine/src/audio/Loops.ts +++ b/v2/packages/engine/src/audio/Loops.ts @@ -15,6 +15,8 @@ export class LoopSlots { private currentMusic: Slot | null = null; private currentAmbience: Slot | null = null; + /** Все живые слоты, включая свободные (startSlot) — их гасит stopAll. */ + private all = new Set(); /** * Запуск лупа из готового буфера: источник + линейное нарастание громкости. @@ -37,7 +39,16 @@ source.connect(gain).connect(buses[bus]); source.start(); onPlayed(key, { volume, rate: 1, pan: 0 }); - return { ctx, source, gain }; + const slot: Slot = { ctx, source, gain }; + this.all.add(slot); + return slot; + } + + /** Гасить все слоты разом (dispose менеджера): свободные лупы тоже. */ + stopAll(fadeSeconds: number): void { + for (const slot of [...this.all]) this.fadeOut(slot, fadeSeconds); + this.currentMusic = null; + this.currentAmbience = null; } /** Handle над слотом: затухание + мгновенная громкость (без борьбы с ramp). */ @@ -103,6 +114,7 @@ /** Затухание слота (экспонента — слуху линейный спад слышен ступенькой). */ private fadeOut(slot: Slot, fadeSeconds: number): void { + this.all.delete(slot); // слот погашен — из реестра живых уходит const t = slot.ctx.currentTime; slot.gain.gain.cancelScheduledValues(t); // Экспоненциальный ramp не определён от нуля — поднимаем до минимума. diff --git a/v2/packages/engine/src/audio/__tests__/AudioManager.test.ts b/v2/packages/engine/src/audio/__tests__/AudioManager.test.ts index 9ea1207..0582e23 100644 --- a/v2/packages/engine/src/audio/__tests__/AudioManager.test.ts +++ b/v2/packages/engine/src/audio/__tests__/AudioManager.test.ts @@ -39,6 +39,9 @@ suspend: vi.fn(async () => { ctx.state = 'suspended'; }), + close: vi.fn(async () => { + ctx.state = 'closed'; + }), createGain: () => { const node: { gain: ReturnType; @@ -106,6 +109,7 @@ createBuffer: ReturnType; suspend: ReturnType; resume: ReturnType; + close: ReturnType; state: string; } }; @@ -584,6 +588,17 @@ expect(graph.sources[0]!.stop).toHaveBeenCalled(); }); + it('dispose: свободные лупы (playLoopBuffer) гасятся, контекст закрывается', async () => { + const { audio, graph, ctx } = makeManager(); + await audio.unlock(); + const buf = audio.createBuffer(new Float32Array(22050).fill(0.2))!; + await audio.playLoopBuffer('theme/meadows', buf, { fade: 0 }); + audio.dispose(); + vi.advanceTimersByTime(100); // затухание stopAll ставит stop по таймеру + expect(graph.sources[0]!.stop).toHaveBeenCalled(); // слот вне currentMusic — но погашен + expect(ctx.close).toHaveBeenCalled(); + }); + it('pauseOnHide: false — visibilitychange игнорируется', async () => { const listeners: Record void)[]> = {}; vi.stubGlobal('document', { diff --git a/v2/packages/engine/src/core/__tests__/ecs.test.ts b/v2/packages/engine/src/core/__tests__/ecs.test.ts index 96a02e1..3dce4ef 100644 --- a/v2/packages/engine/src/core/__tests__/ecs.test.ts +++ b/v2/packages/engine/src/core/__tests__/ecs.test.ts @@ -21,6 +21,14 @@ expect(w.hasComponent(e, 'pos')).toBe(false); }); + it('addComponent на уничтоженной сущности — мимо, не TypeError (хук убил в этом же тике)', () => { + const w = new EntityWorld(); + const e = w.createEntity(); + w.destroyEntity(e); + expect(w.addComponent(e, 'pos', { x: 0, y: 0 })).toBeUndefined(); + expect(w.getComponent(e, 'pos')).toBeUndefined(); + }); + it('query возвращает живые сущности со всеми компонентами сразу', () => { const w = new EntityWorld(); const a = w.createEntity(), b = w.createEntity(), c = w.createEntity(); diff --git a/v2/packages/engine/src/core/__tests__/loop.test.ts b/v2/packages/engine/src/core/__tests__/loop.test.ts index db12a44..f410d95 100644 --- a/v2/packages/engine/src/core/__tests__/loop.test.ts +++ b/v2/packages/engine/src/core/__tests__/loop.test.ts @@ -69,4 +69,13 @@ expect(t.updates.length).toBe(4); t.loop.stop(); }); + + it('stepTick вне ручного режима отклоняется (иначе шаги удвоятся)', () => { + const t = makeLoop(60); + t.loop.start(); + expect(t.loop.stepTick(3, true)).toBe(0); + expect(t.updates.length).toBe(0); + expect(t.renders).toBe(0); + t.loop.stop(); + }); }); \ No newline at end of file diff --git a/v2/packages/engine/src/core/ecs.ts b/v2/packages/engine/src/core/ecs.ts index 3fe5c6d..a374c8d 100644 --- a/v2/packages/engine/src/core/ecs.ts +++ b/v2/packages/engine/src/core/ecs.ts @@ -35,8 +35,11 @@ return this.alive.has(e); } - addComponent(e: Entity, name: string, component: T): T { - this.components.get(e)!.set(name, component); + addComponent(e: Entity, name: string, component: T): T | undefined { + // мёртвая сущность (убита хуком в этом же тике) — молча мимо, не TypeError + const map = this.components.get(e); + if (!map) return undefined; + map.set(name, component); return component; } diff --git a/v2/packages/engine/src/core/loop.ts b/v2/packages/engine/src/core/loop.ts index f8b2aa9..9570219 100644 --- a/v2/packages/engine/src/core/loop.ts +++ b/v2/packages/engine/src/core/loop.ts @@ -72,6 +72,8 @@ /** Шаги логики вручную (мост/гейт); render — отрисовать после пачки. */ stepTick(n = 1, render = false): number { + // ручной режим обязателен: поверх живого цикла шаги удвоятся + if (!this.manual) return 0; for (let i = 0; i < n; i++) this.callbacks.update(this.step); if (render) this.callbacks.render(); return n; diff --git a/v2/packages/engine/src/dialogue/__tests__/graph.test.ts b/v2/packages/engine/src/dialogue/__tests__/graph.test.ts index 099bd0d..cc555bb 100644 --- a/v2/packages/engine/src/dialogue/__tests__/graph.test.ts +++ b/v2/packages/engine/src/dialogue/__tests__/graph.test.ts @@ -103,4 +103,38 @@ }; expect(validateDialogue(g)).toEqual([]); }); + + it('ловит цикл узлов-действий (рантайм не ждёт игрока — MAX_STEPS)', () => { + const g: DialogueGraph = { + start: 'a', + nodes: { + a: { setFlags: ['x'], next: 'b' }, + b: { setVars: { n: 1 }, next: 'a' }, + }, + }; + expect(validateDialogue(g).join('\n')).toContain('цикл узлов-действий'); + }); + + it('цепочка действий, ведущая в цикл не с себя, сам цикл не дублирует', () => { + const g: DialogueGraph = { + start: 'in', + nodes: { + in: { setFlags: ['x'], next: 'a' }, + a: { next: 'b' }, + b: { next: 'a' }, + }, + }; + const errs = validateDialogue(g); + // сообщают только участники цикла (a и b), вход «in» — нет + expect(errs).toHaveLength(2); + expect(errs.join('\n')).not.toContain('in:'); + }); + + it('ловит неизвестный kind в do[] узла, не только выбора', () => { + const g: DialogueGraph = { + start: 'a', + nodes: { a: { text: 'Привет', do: [{ kind: 'gveItem' as never }] } }, + }; + expect(validateDialogue(g).join('\n')).toContain('неизвестный kind «gveItem»'); + }); }); \ No newline at end of file diff --git a/v2/packages/engine/src/dialogue/graph.ts b/v2/packages/engine/src/dialogue/graph.ts index 1a63d0d..516a008 100644 --- a/v2/packages/engine/src/dialogue/graph.ts +++ b/v2/packages/engine/src/dialogue/graph.ts @@ -133,7 +133,10 @@ return true; } -/** Валидация графа: старт, ссылки next, структура узлов и выборов. */ +/** Известные kind игровых эффектов (неизвестный — ошибка валидатора). */ +const EFFECT_KINDS = { giveItem: 1, takeItem: 1, sound: 1, toast: 1, custom: 1 } as const; + +/** Валидация графа: старт, ссылки next, структура узлов и выборов, циклы. */ export function validateDialogue(g: DialogueGraph): string[] { const errs: string[] = []; const ids = Object.keys(g.nodes); @@ -149,14 +152,34 @@ if (!hasText(n) && !n.next && !n.choices?.length && !n.end) { errs.push(`${id}: узел без текста, next, choices и end — тупик-«действие»`); } + for (const op of n.do ?? []) { + if (!(op.kind in EFFECT_KINDS)) { + errs.push(`${id}: неизвестный kind «${String((op as { kind: string }).kind)}»`); + } + } for (const c of n.choices ?? []) { if (!c.text) errs.push(`${id}: выбор без текста`); for (const op of c.do ?? []) { - if (!(op.kind in { giveItem: 1, takeItem: 1, sound: 1, toast: 1, custom: 1 })) { + if (!(op.kind in EFFECT_KINDS)) { errs.push(`${id}: неизвестный kind «${String((op as { kind: string }).kind)}»`); } } } + // Цикл узлов-действий: рантайм проваливается по next без ожидания + // игрока — замкнутая цепочка бросит «превышен MAX_STEPS» посреди игры. + if (!hasText(n) && n.next) { + const seen = new Set([id]); + let cur: string | undefined = n.next; + while (cur && g.nodes[cur] && !hasText(g.nodes[cur]) && g.nodes[cur].next) { + if (cur === id) { + errs.push(`${id}: цикл узлов-действий (next замыкается)`); + break; + } + if (seen.has(cur)) break; // цикл не с этого узла — сообщит его участник + seen.add(cur); + cur = g.nodes[cur].next; + } + } } return errs; } \ No newline at end of file diff --git a/v2/packages/engine/src/dialogue/runner.ts b/v2/packages/engine/src/dialogue/runner.ts index 633ee7c..a5e1f13 100644 --- a/v2/packages/engine/src/dialogue/runner.ts +++ b/v2/packages/engine/src/dialogue/runner.ts @@ -119,6 +119,7 @@ this.shownChoices = []; if (atNode !== null) this.madePicks.push({ nodeId: atNode, index: shown.index, text: shown.text }); this.applyEffects(choice, atNode, shown.index); + if (!this.graph) return; // эффект реентерабельно завершил диалог (abort) — уже finish if (choice.next === undefined) this.finish(); else this.enterNode(choice.next); } @@ -130,10 +131,12 @@ /** Вход в узел: условия → пропуск, эффекты, показ или провал в next. */ private enterNode(id: string): void { - if (!this.graph) return; + const g = this.graph; + if (!g) return; let cur = id; for (let i = 0; i < MAX_STEPS; i++) { - const node = this.graph.nodes[cur]; + if (this.graph !== g) return; // эффект реентерабельно погасил/перезапустил диалог + const node = g.nodes[cur]; if (!node) { this.finish(); // битая ссылка — валидатор ловит заранее return; @@ -147,6 +150,7 @@ continue; } this.applyEffects(node, cur); + if (this.graph !== g) return; // onEffect звал abort/start — не продолжаем по старому графу this.walkedPath.push(cur); if (!hasText(node)) { if (node.next === undefined) { diff --git a/v2/packages/engine/src/models/paletteMap.ts b/v2/packages/engine/src/models/paletteMap.ts index 57df5f6..753fe86 100644 --- a/v2/packages/engine/src/models/paletteMap.ts +++ b/v2/packages/engine/src/models/paletteMap.ts @@ -21,9 +21,9 @@ let best = scene[0], bestD = Infinity; for (const s of scene) { const d = (s.rgb[0] - rgb[0]) ** 2 + (s.rgb[1] - rgb[1]) ** 2 + (s.rgb[2] - rgb[2]) ** 2; - if (d < bestD || (d === bestD && s.slot < best.slot)) { bestD = d; best = s; } + if (d < bestD || (best && d === bestD && s.slot < best.slot)) { bestD = d; best = s; } } - mapping.set(Number(slot), best.slot); + if (best) mapping.set(Number(slot), best.slot); // пустая палитра — слот не перекладывается } const grid = decodeModel(model); diff --git a/v2/packages/engine/src/render/daynight.ts b/v2/packages/engine/src/render/daynight.ts index 1383dd1..acf523c 100644 --- a/v2/packages/engine/src/render/daynight.ts +++ b/v2/packages/engine/src/render/daynight.ts @@ -68,9 +68,10 @@ export function dayNightFactor(tHours: number, spec: DayNightSpec = DEFAULT_DAY_NIGHT): number { const t = ((tHours % 24) + 24) % 24; if (t >= spec.night || t < spec.dawn) return 1; - if (t < spec.day) return 1 - (t - spec.dawn) / (spec.day - spec.dawn); + // вырожденные границы (day === dawn и т.п.) — переход нулевой длины, а не NaN + if (t < spec.day) return spec.day > spec.dawn ? 1 - (t - spec.dawn) / (spec.day - spec.dawn) : 1; if (t < spec.dusk) return 0; - return (t - spec.dusk) / (spec.night - spec.dusk); + return spec.night > spec.dusk ? (t - spec.dusk) / (spec.night - spec.dusk) : 1; } /**