diff --git a/apps/game/src/Game.ts b/apps/game/src/Game.ts index 060d8b6..155a503 100644 --- a/apps/game/src/Game.ts +++ b/apps/game/src/Game.ts @@ -47,9 +47,7 @@ 'tiles/tower', 'tiles/floor', 'tiles/wall', - 'tiles/well', - 'chars/elder_irwin', - 'chars/trader_mila' + 'tiles/well' ]; /** Ключи аудио (WAV): sfx + амбиент-лупы. */ diff --git a/apps/game/src/scenes/BootScene.ts b/apps/game/src/scenes/BootScene.ts index 776e653..bafd157 100644 --- a/apps/game/src/scenes/BootScene.ts +++ b/apps/game/src/scenes/BootScene.ts @@ -41,6 +41,7 @@ .then(async () => await this.game.assets.loadAtlas('chars/hero_sheet.json')) .then(async () => await this.game.assets.loadAtlas('chars/clumps_sheet.json')) .then(async () => await this.game.assets.loadAtlas('chars/fauna_sheet.json')) + .then(async () => await this.game.assets.loadAtlas('chars/npcs_sheet.json')) .then(async () => { // Карты областей: файлы .map (rpg-map, RLE) -> parseMap. for (const id of Object.keys(AREAS)) { diff --git a/apps/game/src/scenes/LocationScene.ts b/apps/game/src/scenes/LocationScene.ts index 79909a5..b2e95e5 100644 --- a/apps/game/src/scenes/LocationScene.ts +++ b/apps/game/src/scenes/LocationScene.ts @@ -5,6 +5,8 @@ ParticleEmitter, PixelText, Sprite, + SpriteAnimator, + SpriteMotion, Texture, IsometricTileMap, worldToScreen, @@ -64,6 +66,10 @@ private hint: Container; /** Атмосферные частицы (в интерьерах их нет). */ private ash: ParticleEmitter | null; + /** Тёплые мотыльки над лугами (светящиеся, дрейф по синусу). */ + private moths: ParticleEmitter | null; + /** Мигание при неуязвимости — процедурный blink (duty живой). */ + private heroMotion: SpriteMotion; private locationLabel: PixelText; private camera: Camera; @@ -79,8 +85,6 @@ /** Интерактивные объекты области (сундуки, очаги, прилавки). */ private interactables: Interactables; private interactViews: { def: InteractableDef; label: PixelText }[] = []; - /** Таймер мигания при неуязвимости (свой, не боевой). */ - private blinkT = 0; /** Оверлей дымки (зоны наката). Виден только в низинах. */ private fog: Graphics; /** Пейзажная фауна (безгласные олени). */ @@ -110,6 +114,12 @@ this.camera = game.engine.camera; const data = this.game.mapFiles.get(area.id)!; this.map = new IsometricTileMap(data, this.tileTextures()); + // Живая вода: один общий таймлайн на все клетки с id WATER (2 кадра). + this.map.setTileAnimation( + TILES.WATER, + [this.game.assets.texture('tiles/water_1'), this.game.assets.texture('tiles/water_2')], + 2 + ); this.world.addChild(this.map.view, this.actors); // Крупные объекты карты (дома) — в общий 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); @@ -126,6 +136,10 @@ this.combat.noise(this.player.position, 0.35); }); + // Мигание героя при неуязвимости — процедурный blink (duty меняется на лету). + this.heroMotion = new SpriteMotion(this.player.view, { blink: { period: 0.25, duty: 1 } }); + this.game.engine.fx.add(this.heroMotion); + // Герой и NPC — в один depth-слой: глубина = tx + ty (кто юго-восточнее, тот ближе). this.actors.add(this.player.view, startTile.x, startTile.y); for (const def of area.npcs) { @@ -165,7 +179,13 @@ for (const s of area.enemies) { this.combat.spawnEnemy(s.kind, tileToWorld(s.tile.x, s.tile.y), s.patrol ? { patrol: s.patrol } : undefined); } - this.combatViews = new CombatViews(this.combat, this.actors, this.enemyTextures(), this.world); + this.combatViews = new CombatViews( + this.combat, + this.actors, + this.enemyTextures(), + this.world, + this.game.engine.fx + ); // Вспышка урона и брызги пепла — реакция вьюх на события ECS-мира. this.game.engine.events.on<{ entity: Entity }>('combat:hurt', ({ entity }) => { @@ -186,7 +206,7 @@ ); this.fauna.setBlocked(data.blocked); this.fauna.setFrames( - this.game.assets.frames('chars/fauna_sheet.json', 'fauna_deer') as [Texture, Texture] + this.game.assets.animation('chars/fauna_sheet.json', 'fauna_deer_walk') as [Texture, Texture] ); // Мотыли над потревоженным пеплом: серые, дрейф вверх от точки звона. this.game.engine.events.on<{ origin: Vec2 }>('combat:attack', ({ origin }) => { @@ -283,6 +303,30 @@ if (this.ash) { this.ash.position.set(240, 120); this.game.renderer.worldRoot.addChild(this.ash); + // Пепел тикается реестром эффектов (engine.fx) — без ручного update. + this.game.engine.fx.add(this.ash); + } + + // Тёплые мотыльки над лугами: светятся (add), дышат по синусу, в интерьере их нет. + this.moths = + area.ambience === 'ash' + ? new ParticleEmitter({ + colors: [0xf0d878, 0xd8b050], + blend: 'add', + rate: 2, + lifetime: [3, 6], + velocity: { x: [-4, 4], y: [-2, 2] }, + size: 1, + wobble: 8, + fadeIn: 0.6, + spawnArea: { width: 520, height: 300 }, + seed: 20260907 + }) + : null; + if (this.moths) { + this.moths.position.set(240, 120); + this.game.renderer.worldRoot.addChild(this.moths); + this.game.engine.fx.add(this.moths); } // Дымка наката: полупрозрачная пелена на весь экран (в низинах без маски — гуще). @@ -342,6 +386,7 @@ this.ash?.clear(); this.world.destroy({ children: true }); this.ash?.destroy(); + this.moths?.destroy(); this.fog.destroy(); this.healthBar.destroy({ children: true }); this.hint.destroy({ children: true }); @@ -352,7 +397,7 @@ } update(dt: number): void { - this.ash?.update(dt); + this.map.update(dt); // анимированные тайлы (вода) — no-op без анимаций this.fauna.update(dt); // Кат-сцена: мир на паузе, камера под контролем раннера. if (this.cutscene.active) { @@ -416,10 +461,8 @@ 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; + // Мигание героя при неуязвимости: duty blink-оживителя живой. + this.heroMotion.params.blink!.duty = this.playerCombat.invuln ? 0.5 : 1; if (this.joystick.active) { this.player.moveFree(this.joystick.getVector(), dt); @@ -619,14 +662,13 @@ ]); } - /** Кадры героя из атласа chars/hero_sheet. */ + /** Кадры героя из атласа chars/hero_sheet (именованные анимации генератора). */ private heroTextures(): HeroTextures { - const frames = (prefix: string) => - this.game.assets.frames('chars/hero_sheet.json', prefix) as [Texture, Texture]; + const a = this.game.assets; return { - down: frames('hero_down'), - up: frames('hero_up'), - side: frames('hero_side') + down: a.animation('chars/hero_sheet.json', 'hero_walk_down') as [Texture, Texture], + up: a.animation('chars/hero_sheet.json', 'hero_walk_up') as [Texture, Texture], + side: a.animation('chars/hero_sheet.json', 'hero_walk_side') as [Texture, Texture] }; } @@ -783,6 +825,14 @@ g.rect(-5, -12, 10, 2).fill(0x8a6d3a); // бронзовая кромка: «звонное» железо } view.addChild(g); + // Искра в кучке пепла пульсирует (alpha+scale, детерминированная фаза). + if (def.kind === 'pickup') { + this.game.engine.fx.add( + new SpriteMotion(g, { pulse: { min: 0.4, max: 1, period: 1.6 } }, { + seed: def.tile.x * 31 + def.tile.y + }) + ); + } const label = new PixelText({ text: def.label ?? def.id, size: 9, color: 0xd8c79a }); label.anchor.set(0.5, 1); label.position.set(0, -16); @@ -796,8 +846,18 @@ const p = this.tileCenter(def.tile.x, def.tile.y); view.position.set(p.x, p.y); - const sprite = new Sprite(this.game.assets.texture(`chars/${def.sprite}`)); + const sprite = new Sprite(); sprite.anchor.set(0.5, 1); // ноги в центре ромба + // idle-дыхание кадрами из атласа, рассинхрон по координатам тайла. + const idle = this.game.assets.animation('chars/npcs_sheet.json', `${def.sprite}_idle`); + sprite.texture = idle[0]!; + const animator = new SpriteAnimator( + sprite, + { idle: { frames: idle, fps: 1.2 } }, + 'idle', + { randomPhase: def.tile.y * 64 + def.tile.x } + ); + this.game.engine.fx.add(animator); view.addChild(sprite); // Маркер «с ним можно говорить»: восклицательный штрих. diff --git a/apps/game/src/systems/PlayerController.ts b/apps/game/src/systems/PlayerController.ts index 45783c7..1f77106 100644 --- a/apps/game/src/systems/PlayerController.ts +++ b/apps/game/src/systems/PlayerController.ts @@ -7,7 +7,7 @@ worldToTile, tileToWorld, moveTowardsW, - FrameAnimation, + SpriteAnimator, Container, Sprite, Texture, @@ -30,11 +30,11 @@ */ export class PlayerController { readonly view: Container; + /** Именованные клипы: walk_* на ходу, idle_* — стоп-кадр направления. */ + readonly animator: SpriteAnimator; private sprite: Sprite; - private anim: FrameAnimation; private facing: Facing = 'down'; - private textures: HeroTextures; /** Позиция в мировых юнитах (источник истины, ноги героя). */ private pos: Vec2; @@ -51,13 +51,18 @@ startTile: { x: number; y: number }, private onStep?: () => void ) { - this.textures = textures; - this.view = new Container(); this.sprite = new Sprite(textures.down[0]); this.sprite.anchor.set(0.5, 1); // ноги в точке позиции this.view.addChild(this.sprite); - this.anim = new FrameAnimation(this.sprite, textures.down, 6, true); + this.animator = new SpriteAnimator(this.sprite, { + walk_down: { frames: textures.down, fps: 6 }, + walk_up: { frames: textures.up, fps: 6 }, + walk_side: { frames: textures.side, fps: 6 }, + idle_down: { frames: [textures.down[0]], loop: 'once' }, + idle_up: { frames: [textures.up[0]], loop: 'once' }, + idle_side: { frames: [textures.side[0]], loop: 'once' } + }, 'idle_down'); this.pos = tileToWorld(startTile.x, startTile.y); this.syncView(); @@ -106,7 +111,10 @@ } update(dt: number): void { - if (!this.waypoint) return; + if (!this.waypoint) { + this.playIdle(); + return; + } this.pos = moveTowardsW(this.pos, this.waypoint, this.speed * this.speedMul * dt); if (this.pos.x === this.waypoint.x && this.pos.y === this.waypoint.y) { this.onStep?.(); // ступили на новый тайл @@ -114,7 +122,9 @@ return; } this.faceMovement(); - this.anim.update(dt); + this.animator.resume(); + this.animator.play(this.walkClip()); + this.animator.update(dt); this.syncView(); } @@ -151,7 +161,9 @@ 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.animator.resume(); + this.animator.play(this.walkClip()); + this.animator.update(dt); this.syncView(); } else { this.stop(); @@ -174,6 +186,16 @@ this.waypoint = null; } + /** Имя клипа ходьбы по текущему направлению (side — и для right: флип). */ + private walkClip(): string { + return `walk_${this.facing === 'left' || this.facing === 'right' ? 'side' : this.facing}`; + } + + /** Стоп-кадр текущего направления (без «застыла на произвольном кадре ходьбы»). */ + private playIdle(): void { + this.animator.play(`idle_${this.facing === 'left' || this.facing === 'right' ? 'side' : this.facing}`); + } + /** Мгновенно переместить героя в тайл (респаун, переходы между локациями). */ teleportTo(tile: { x: number; y: number }): void { this.stop(); @@ -208,9 +230,6 @@ private setFacing(facing: Facing): void { if (facing === this.facing) return; this.facing = facing; - const frames = - facing === 'down' ? this.textures.down : facing === 'up' ? this.textures.up : this.textures.side; - this.anim.setFrames([...frames], false); this.sprite.scale.x = facing === 'right' ? -1 : 1; } diff --git a/apps/game/src/systems/combat/CombatViews.ts b/apps/game/src/systems/combat/CombatViews.ts index d8226a1..d82b1f6 100644 --- a/apps/game/src/systems/combat/CombatViews.ts +++ b/apps/game/src/systems/combat/CombatViews.ts @@ -4,11 +4,13 @@ IsoDepthLayer, ParticleEmitter, Sprite, + SpriteAnimator, SpriteFlash, Texture, worldToTile, worldToScreen, type Entity, + type Updatables, type Vec2 } from '@rpg/engine'; import type { CombatWorld } from './CombatWorld'; @@ -23,9 +25,7 @@ root: Container; sprite: Sprite; flash: SpriteFlash; - frames: [Texture, Texture]; - animT: number; - animI: number; + animator: SpriteAnimator; } export class CombatViews { @@ -37,7 +37,9 @@ private combat: CombatWorld, private actors: IsoDepthLayer, private enemyTextures: Map, - private fxRoot: Container + private fxRoot: Container, + /** Реестр тика эффектов (engine.fx): oneShot-эмиттеры тикаются сами. */ + private fx: Updatables ) {} /** Спавн-вспышка пепла (взрыв частиц). */ @@ -46,28 +48,33 @@ color: 0x52525c, rate: 0, lifetime: [0.2, 0.45], - velocity: { x: [0, 0], y: [0, 0] }, radialSpeed: [30, 70], size: 2, + drag: 2.5, + scaleOverLife: 'shrink', seed: (pos.x * 31 + pos.y) | 0 }); fx.position.set(pos.x, pos.y - 6); this.fxRoot.addChild(fx); + this.fx.add(fx); // тик и самоуничтожение — через реестр (раньше стояли на месте) } /** Смерть: заметный выброс пепла. */ deathBurst(pos: Vec2): void { const fx = ParticleEmitter.oneShot(16, { - color: 0x6a6a74, + colors: [0x6a6a74, 0x8a8a96], rate: 0, lifetime: [0.3, 0.6], - velocity: { x: [0, 0], y: [0, 0] }, radialSpeed: [40, 90], size: 2, + drag: 2, + spin: [-4, 4], + scaleOverLife: 'shrink', seed: (pos.x * 17 + pos.y) | 0 }); fx.position.set(pos.x, pos.y - 8); this.fxRoot.addChild(fx); + this.fx.add(fx); } /** Кольцо резонанса — расходящаяся окружность (тикает в sync). */ @@ -100,9 +107,7 @@ root, sprite, flash: new SpriteFlash(sprite, 0xf2b45a, 0.15), - frames, - animT: 0, - animI: 0 + animator: new SpriteAnimator(sprite, { walk: { frames, fps: 4 } }, 'walk') }); } } @@ -125,15 +130,13 @@ // Мигание неактивных: спящий сгусток почти невидим в пепле 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]; - } + // Покадровая анимация (4 Гц) — только когда активен; спящим pause. + if (en.brain.asleep) view.animator.pause(); + else { + view.animator.resume(); + view.animator.play('walk'); } + view.animator.update(dt); // Вспышка урона: короткая подсветка в момент hp снижения — через flash.start // (запускает CombatWorld через damageEnemy -> событие, здесь упрощённо по alpha) diff --git a/apps/game/src/systems/fauna/FaunaSystem.ts b/apps/game/src/systems/fauna/FaunaSystem.ts index 61635e3..f03e3ea 100644 --- a/apps/game/src/systems/fauna/FaunaSystem.ts +++ b/apps/game/src/systems/fauna/FaunaSystem.ts @@ -1,6 +1,7 @@ import { Container, Sprite, + SpriteAnimator, StateMachine, Texture, moveTowardsW, @@ -32,9 +33,7 @@ dest: Vec2 | null; view: Container; sprite: Sprite; - frames: [Texture, Texture]; - animT: number; - animI: number; + animator: SpriteAnimator; /** Пауза перед следующим шагом (idle). */ pause: number; } @@ -74,8 +73,8 @@ /** Привязать кадры (вызывается сценой после конструктора). */ setFrames(frames: [Texture, Texture]): void { for (const e of this.entities) { - e.frames = frames; - e.sprite.texture = frames[0]; + e.animator.setClipFrames('walk', frames); + e.animator.showStep(0); } } @@ -95,9 +94,7 @@ dest: null, view, sprite, - frames: [sprite.texture, sprite.texture], - animT: 0, - animI: 0, + animator: new SpriteAnimator(sprite, { walk: { frames: [sprite.texture, sprite.texture], fps: 3 } }), pause: 0 }; @@ -170,18 +167,14 @@ update(dt: number): void { for (const e of this.entities) { e.machine.update(dt); - // Покадровая анимация: 2 кадра, 3 Гц, только на ходу. + // Покадровая анимация: на ходу play, в покое — стоп-кадр 0. if (e.dest) { - e.animT += dt; - if (e.animT >= 0.33) { - e.animT -= 0.33; - e.animI = 1 - e.animI; - e.sprite.texture = e.frames[e.animI]!; - } + e.animator.resume(); + e.animator.play('walk'); } else { - e.animI = 0; - e.sprite.texture = e.frames[0]!; + e.animator.showStep(0); } + e.animator.update(dt); 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)); diff --git a/docs/engine/agent.md b/docs/engine/agent.md index b5a53e5..311f9d3 100644 --- a/docs/engine/agent.md +++ b/docs/engine/agent.md @@ -84,10 +84,16 @@ ## Ручной шаг и атомарность инъекции `Engine.stepTick()` — один шаг логики (60 Гц) + `events.emit('engine:tick')`; -`stepTicks(n, render)` — серия с `GameLoop.setManual(true)` и `resetTiming()` после, +`stepTicks(n, render)` — серия с `GameLoop.setManual(true)` и `resetTiming()` после +(в `finally` — исключение в шаге не оставляет цикл в ручном режиме навсегда), чтобы rAF не «догонял» пропущенное. Реальный rAF-цикл при этом живёт: между вызовами из страницы кадры продолжают тикать. +Мост **глотает исключения шага** (`step` возвращает последний тик) — если игра +«замерла» без ошибок в консоли, скорее всего тик кидает каждый кадр. Диагностика: +обернуть `engine.fx/tweens/scenes.update` в странице через `__game` и печатать +stack (`console.error`) — см. practices.md. + Отсюда **главное правило**: инъекция ввода и шаг, который её видит, должны быть атомарны (один JS-стек). `tapVirtual/press/key` внутри делают инъекцию + один `stepTick` — между `evaluate`-вызовами rAF успел бы очистить «just pressed». diff --git a/docs/engine/anim.md b/docs/engine/anim.md index 9ca30ec..baa964e 100644 --- a/docs/engine/anim.md +++ b/docs/engine/anim.md @@ -28,9 +28,11 @@ engine.fx.clear(); // при выходе из сцены, если она не разрушает дерево ``` -Самозачистка: объект с `destroyed === true` вычищается из реестра после +Самозачистка: объект с `destroyed === true` не тикается и вычищается из +реестра после очередного прохода (Pixi `destroy()` выставляет флаг сам — эффекты, -живущие в разрушаемом дереве мира, отпадают без кода). Допускается и +живущие в разрушаемом дереве мира, отпадают без кода; `SpriteAnimator` +и `SpriteMotion` проксируют `destroyed` своего вью). Допускается и локальный инстанс `Updatables` внутри сцены (тот же класс, свой тик). ## Клипы и SpriteAnimator diff --git a/docs/engine/core.md b/docs/engine/core.md index a7024ae..551f734 100644 --- a/docs/engine/core.md +++ b/docs/engine/core.md @@ -65,8 +65,9 @@ engine.fx.clear(); // при выходе из сцены, если она не разрушает дерево ``` -Записи с `destroyed === true` (Pixi `destroy()` выставляет флаг сам) -вычищаются из реестра автоматически. +Записи с `destroyed === true` (Pixi `destroy()` выставляет флаг сам) не +тикаются и вычищаются из реестра автоматически — разрушенный Pixi-объект +касаться нельзя (transform обнулён). ## GameState diff --git a/docs/engine/practices.md b/docs/engine/practices.md index a007671..e8738dd 100644 --- a/docs/engine/practices.md +++ b/docs/engine/practices.md @@ -210,4 +210,12 @@ W×H молча дорастит разреженный массив — ловится только тестом «fresh» («тайлов 583, ожидалось 560»). После правки генератора всегда `npm run maps`. - **Асинхронный шаг в каркасе Checks**: `stepResult` обязан `await fn()` — - без await промис считается успехом (ok:true, ms:1, details:{}). \ No newline at end of file + без await промис считается успехом (ok:true, ms:1, details:{}). +- **Мост глотает исключения шага**: `EngineAgent.step` ловит throw от + `stepTicks`, а симптом залипшего ручного режима — игра мертва в реальном + времени (tick в снапшоте не растёт), переход `transitioning` не кончается, + в консоли пусто. Диагностика: в странице обернуть `__game.engine.fx / + tweens / scenes` — их `update` в try/catch с `console.error(e.stack)` — + и продёрнуть переход; настоящий стектрейс всплывёт. Причина однажды + была в тике разрушенного Pixi-объекта из `engine.fx` (см. core.md: + destroyed-записи не тикаются). \ No newline at end of file diff --git a/docs/engine/render.md b/docs/engine/render.md index 004d4ad..c923e5e 100644 --- a/docs/engine/render.md +++ b/docs/engine/render.md @@ -133,7 +133,8 @@ engine.fx.add(moths); // тик и зачистка (по destroyed) — сами ``` -Основные опции (все, кроме `rate`/`lifetime`/`velocity`, опциональны): +Основные опции (все, кроме `rate`/`lifetime`, опциональны; в burst-опциях с +`radialSpeed` `velocity` не нужен): | Опция | Что делает | | --- | --- | diff --git a/packages/engine/src/anim/SpriteAnimator.ts b/packages/engine/src/anim/SpriteAnimator.ts index 9ee9be1..814be92 100644 --- a/packages/engine/src/anim/SpriteAnimator.ts +++ b/packages/engine/src/anim/SpriteAnimator.ts @@ -36,6 +36,11 @@ /** Вызывается один раз при finished у once/pingpong-прохода. */ onFinish?: (clip: string) => void; + /** Самозачистка из engine.fx: спрайт разрушен — реестр отпустит запись. */ + get destroyed(): boolean { + return this.sprite.destroyed; + } + private clips: Record; private defaults: Required>; private speedScale_: number; diff --git a/packages/engine/src/anim/SpriteMotion.ts b/packages/engine/src/anim/SpriteMotion.ts index 80835b1..c0b8bee 100644 --- a/packages/engine/src/anim/SpriteMotion.ts +++ b/packages/engine/src/anim/SpriteMotion.ts @@ -59,4 +59,9 @@ this.view.alpha = this.baseAlpha; this.view.scale.set(this.baseScaleX, this.baseScaleY); } + + /** Самозачистка из engine.fx: view разрушен — реестр отпустит запись. */ + get destroyed(): boolean { + return this.view.destroyed; + } } \ No newline at end of file diff --git a/packages/engine/src/core/Engine.ts b/packages/engine/src/core/Engine.ts index 3433205..a8f6b62 100644 --- a/packages/engine/src/core/Engine.ts +++ b/packages/engine/src/core/Engine.ts @@ -127,14 +127,19 @@ /** * n фиксированных шагов детерминированно: цикл на время переводится в ручной * режим, после — тайминги сбрасываются, чтобы rAF не «догонял» пропущенное. + * finally: исключение в шаге не оставляет цикл в ручном режиме навсегда. */ stepTicks(n = 1, render = false): number { this.loop.setManual(true); - for (let i = 0; i < n; i++) { - this.stepTick(); - if (render) this.render(); + try { + for (let i = 0; i < n; i++) { + this.stepTick(); + if (render) this.render(); + } + } finally { + this.loop.setManual(false); + this.loop.resetTiming(); } - this.loop.setManual(false); return this.ticks; } diff --git a/packages/engine/src/core/Updatables.ts b/packages/engine/src/core/Updatables.ts index a34e5e4..efee0e4 100644 --- a/packages/engine/src/core/Updatables.ts +++ b/packages/engine/src/core/Updatables.ts @@ -31,9 +31,14 @@ this.list.length = 0; } - /** Тик в порядке добавления; destroyed-записи вычищаются после прохода. */ + /** + * Тик в порядке добавления; разрушенные записи не тикаются (Pixi после + * destroy обнуляет transform — касаться их нельзя) и вычищаются после прохода. + */ update(dt: number): void { - for (const u of this.list) u.update(dt); + for (const u of this.list) { + if (!u.destroyed) u.update(dt); + } if (this.list.some((u) => u.destroyed)) { this.list = this.list.filter((u) => !u.destroyed); } diff --git a/packages/engine/src/core/__tests__/Updatables.test.ts b/packages/engine/src/core/__tests__/Updatables.test.ts index 1d5edae..33d854d 100644 --- a/packages/engine/src/core/__tests__/Updatables.test.ts +++ b/packages/engine/src/core/__tests__/Updatables.test.ts @@ -38,16 +38,34 @@ r.remove(u); // отсутствие — не ошибка }); - it('destroyed-записи вычищаются после прохода, тик им всё же доставляется', () => { + it('destroyed-записи не тикаются и вычищаются после прохода', () => { const log: string[] = []; const r = new Updatables(); r.add(make(log, 'zombie', true)); r.add(make(log, 'alive')); r.update(0.1); - expect(log).toEqual(['zombie@0.1', 'alive@0.1']); // тик получен + expect(log).toEqual(['alive@0.1']); // разрушенный не тикается (transform обнулён) expect(r.count).toBe(1); r.update(0.1); - expect(log).toEqual(['zombie@0.1', 'alive@0.1', 'alive@0.1']); // zombie больше не тикается + expect(log).toEqual(['alive@0.1', 'alive@0.1']); + }); + + it('запись, разрушенная в чужом update, не тикается на следующем проходе', () => { + const log: string[] = []; + const r = new Updatables(); + const victim = make(log, 'victim'); + r.add(victim); + r.add({ + update(dt: number) { + log.push(`killer@${dt}`); + victim.destroyed = true; // сцена разрушила вьюху между тиками + } + }); + r.update(0.1); + expect(log).toEqual(['victim@0.1', 'killer@0.1']); + r.update(0.1); + expect(log).toEqual(['victim@0.1', 'killer@0.1', 'killer@0.1']); // victim молчит + expect(r.count).toBe(1); }); it('clear опустошает реестр', () => { diff --git a/packages/engine/src/render/Particles.ts b/packages/engine/src/render/Particles.ts index 0dcaa8e..5a9f0f6 100644 --- a/packages/engine/src/render/Particles.ts +++ b/packages/engine/src/render/Particles.ts @@ -21,7 +21,7 @@ /** Время жизни частицы, секунды (min..max). */ lifetime: [number, number]; /** Начальная скорость, виртуальных пикселей/сек: (minX..maxX, minY..maxY). */ - velocity: { x: [number, number]; y: [number, number] }; + velocity?: { x: [number, number]; y: [number, number] }; /** Ускорение (гравитация/ветер), пикселей/сек². */ acceleration?: { x?: number; y?: number }; /** Размер квадрата в px (1..4) или диапазон размеров. */ diff --git a/packages/engine/src/render/particleSim.ts b/packages/engine/src/render/particleSim.ts index 3be064f..aac24f0 100644 --- a/packages/engine/src/render/particleSim.ts +++ b/packages/engine/src/render/particleSim.ts @@ -101,8 +101,8 @@ return { x: area ? rng.int(0, area.width) - area.width / 2 : 0, y: area ? rng.int(0, area.height) - area.height / 2 : 0, - vx: v.x[0] + rng.next() * (v.x[1] - v.x[0]), - vy: v.y[0] + rng.next() * (v.y[1] - v.y[0]), + vx: v ? v.x[0] + rng.next() * (v.x[1] - v.x[0]) : 0, + vy: v ? v.y[0] + rng.next() * (v.y[1] - v.y[0]) : 0, age: 0, lifetime: options.lifetime[0] + rng.next() * (options.lifetime[1] - options.lifetime[0]),