diff --git a/apps/game/src/scenes/LocationScene.ts b/apps/game/src/scenes/LocationScene.ts index 233d806..e650f49 100644 --- a/apps/game/src/scenes/LocationScene.ts +++ b/apps/game/src/scenes/LocationScene.ts @@ -577,11 +577,15 @@ !(iv.def.once && this.interactables.isUsed(iv.def.id)); } - this.debug.setLines([ - `tile ${tile.x},${tile.y}`, - `pos ${Math.round(this.player.position.x)},${Math.round(this.player.position.y)}`, - `hp ${this.flow.playerCombat.hp} kills ${this.game.state.getNumber(VARS.kills)}` - ]); + // Отладочные строки собираются только при видимой панели (F3): иначе — + // три template-строки и массив каждый тик впустую. + if (this.debug.view.visible) { + this.debug.setLines([ + `tile ${tile.x},${tile.y}`, + `pos ${Math.round(this.player.position.x)},${Math.round(this.player.position.y)}`, + `hp ${this.flow.playerCombat.hp} kills ${this.game.state.getNumber(VARS.kills)}` + ]); + } this.debug.update(dt); if (this.charDebug.view.visible) this.charDebug.setTexture(this.player.currentTexture); } diff --git a/apps/game/src/systems/Atmosphere.ts b/apps/game/src/systems/Atmosphere.ts index c36a151..5001764 100644 --- a/apps/game/src/systems/Atmosphere.ts +++ b/apps/game/src/systems/Atmosphere.ts @@ -40,6 +40,9 @@ inHazard: HazardDef | null = null; /** Текущая зона наката перекрыта полотном (для виньетки). */ hazardMasked = false; + /** Тайл прошлого поиска зоны и его результат — поиск только при смене тайла. */ + private lastTile: { x: number; y: number } | null = null; + private lastHazard: HazardDef | null = null; constructor(private deps: AtmosphereDeps) { const area = deps.area; @@ -104,10 +107,16 @@ /** Зона наката под ногами: без маски герой еле идёт (тост при входе). */ updateHazard(tile: { x: number; y: number }): void { const { game, area, player } = this.deps; - const hazard = - (area.hazards ?? []).find((h) => - h.tiles.some((t) => t.x === tile.x && t.y === tile.y) - ) ?? null; + // Зона зависит только от тайла: ищем заново лишь при его смене + // (поиск — замыкание + some по всем зонам, иначе он в каждом тике). + if (!this.lastTile || this.lastTile.x !== tile.x || this.lastTile.y !== tile.y) { + this.lastTile = { x: tile.x, y: tile.y }; + this.lastHazard = + (area.hazards ?? []).find((h) => + h.tiles.some((t) => t.x === tile.x && t.y === tile.y) + ) ?? null; + } + const hazard = this.lastHazard; const masked = hazard !== null && game.inventory.has(hazard.requiresItem); player.speedMul = hazard !== null && !masked ? 0.5 : 1; diff --git a/apps/game/src/systems/Lighting.ts b/apps/game/src/systems/Lighting.ts index b193c6d..2550874 100644 --- a/apps/game/src/systems/Lighting.ts +++ b/apps/game/src/systems/Lighting.ts @@ -84,6 +84,18 @@ private activeIds = new Set(); /** Активные id тёмных пятен на прошлом тике (ауры хазардов). */ private spotIds = new Set(); + /** + * Дифф-буферы (каждый тик, без аллокаций): пара Set'ов на слой, своп после + * тика — clear() не должен задевать активный набор. + */ + private idsB = new Set(); + private spotsB = new Set(); + /** Переиспользуемые списки активных def/позиций (каждый тик). */ + private readonly defsBuf: LightSourceDef[] = []; + private readonly posBuf: { x: number; y: number }[] = []; + /** Мутируемый def лампы героя (пушится в defsBuf без пересоздания). */ + private readonly lampAt = { x: 0, y: 0 }; + private readonly lampDef: LightSourceDef = { id: 'lamp', at: this.lampAt }; /** Последняя заданная цель ambient — guard от повторного setAmbient. */ private lastAmbientTarget: number | null = null; private destroyed = false; @@ -128,7 +140,8 @@ update(): void { if (this.destroyed) return; this.updateNightAmbient(); - const next = new Set(); + const next = this.idsB; + next.clear(); for (const def of this.activeDefs()) { for (const pos of this.sourcePositions(def)) { if (next.size >= MAX_LIGHTS) break; @@ -151,6 +164,7 @@ for (const id of this.activeIds) { if (!next.has(id)) this.deps.lighting.removeLight(id); } + this.idsB = this.activeIds; // старый набор — на следующей итерации буфер this.activeIds = next; this.updateHazardSpots(); } @@ -172,7 +186,8 @@ /** Тёмные ауры зон наката: по тайлу HazardDef, снятие погасших через diff. */ private updateHazardSpots(): void { - const next = new Set(); + const next = this.spotsB; + next.clear(); for (const hazard of this.deps.area.hazards ?? []) { for (const t of hazard.tiles) { if (next.size >= MAX_DARK_SPOTS) break; @@ -192,6 +207,7 @@ for (const id of this.spotIds) { if (!next.has(id)) this.deps.lighting.removeDarkSpot(id); } + this.spotsB = this.spotIds; this.spotIds = next; } @@ -221,8 +237,10 @@ // --- внутреннее --- + /** Активные def области + лампа героя (в defsBuf, переиспользуется каждый тик). */ private activeDefs(): LightSourceDef[] { - const out: LightSourceDef[] = []; + const out = this.defsBuf; + out.length = 0; const all = this.deps.area.lighting?.sources ?? []; const night = this.nightFactor; for (const def of all) { @@ -237,23 +255,30 @@ // Позиция героя уже в мировых юнитах: центры тайлов = tileToWorld(x+0.5), // поэтому отнимаем 0.5 — источник ляжет ровно на героя. const hero = this.deps.heroPos(); - out.push({ - id: 'lamp', - at: { x: hero.x - 0.5, y: hero.y - 0.5 }, - radius: lamp.radius, - color: lamp.color, - intensity: lamp.intensity, - flicker: lamp.flicker - }); + this.lampAt.x = hero.x - 0.5; + this.lampAt.y = hero.y - 0.5; + const d = this.lampDef; + d.radius = lamp.radius; + d.color = lamp.color; + d.intensity = lamp.intensity; + d.flicker = lamp.flicker; + out.push(d); } return out; } - /** Позиции источника в тайлах: точка at или все тайлы заданных id. */ + /** Позиции источника в тайлах (в posBuf): точка at или все тайлы заданных id. */ private sourcePositions(def: LightSourceDef): { x: number; y: number }[] { - if (def.at) return [def.at]; - const out: { x: number; y: number }[] = []; - for (const id of def.tiles ?? []) out.push(...(this.deps.layerTilePos.get(id) ?? [])); + const out = this.posBuf; + out.length = 0; + if (def.at) { + out.push(def.at); + return out; + } + for (const id of def.tiles ?? []) { + const positions = this.deps.layerTilePos.get(id); + if (positions) for (const pos of positions) out.push(pos); + } return out; } } \ No newline at end of file diff --git a/apps/game/src/systems/PlayerController.ts b/apps/game/src/systems/PlayerController.ts index 536e77b..3986806 100644 --- a/apps/game/src/systems/PlayerController.ts +++ b/apps/game/src/systems/PlayerController.ts @@ -48,6 +48,8 @@ /** Спрайт тела (публичный: хит-флэши урона через FxLayer.flash). */ readonly sprite: Sprite; private facing: Facing = 'down'; + /** Кэш имени клипа ходьбы (меняется только с facing — setFacing). */ + private facingClip = 'walk_down'; /** Позиция в мировых юнитах (источник истины, ноги героя). */ private pos: Vec2; @@ -222,9 +224,9 @@ this.waypoint = null; } - /** Имя клипа ходьбы по текущему направлению (side — и для right: флип). */ + /** Имя клипа ходьбы по текущему направлению — кэш из setFacing. */ private walkClip(): string { - return `walk_${this.facing === 'left' || this.facing === 'right' ? 'side' : this.facing}`; + return this.facingClip; } /** Стоп-кадр текущего направления (без «застыла на произвольном кадре ходьбы»). */ @@ -267,11 +269,13 @@ return Math.abs(this.pos.x - t.x) < ARRIVE_EPS && Math.abs(this.pos.y - t.y) < ARRIVE_EPS; } - /** Сменить направление взгляда (кадры + флип спрайта). */ + /** Сменить направление взгляда (кадры + флип спрайта + кэш имени клипа). */ private setFacing(facing: Facing): void { if (facing === this.facing) return; this.facing = facing; this.sprite.scale.x = facing === 'right' ? -1 : 1; + // Имя клипа ходьбы зависит только от facing: кэш вместо строки каждый тик. + this.facingClip = `walk_${facing === 'left' || facing === 'right' ? 'side' : facing}`; } private advanceWaypoint(): void { diff --git a/apps/game/src/systems/combat/CombatFlow.ts b/apps/game/src/systems/combat/CombatFlow.ts index 3225d03..abe001b 100644 --- a/apps/game/src/systems/combat/CombatFlow.ts +++ b/apps/game/src/systems/combat/CombatFlow.ts @@ -56,6 +56,8 @@ /** Полоска сердец (сцена добавляет в uiRoot). */ readonly healthBar: HealthBar; private ring: Graphics; + /** Кольцо сейчас нарисовано: clear/перерисовка только на переходах. */ + private ringVisible = false; private offs: (() => void)[] = []; constructor(private deps: CombatFlowDeps, savedHp: number) { @@ -188,17 +190,26 @@ } } - /** Индикатор заряда резонанса под героем. */ + /** Индикатор заряда резонанса под героем (Graphics трогаем только на переходах). */ private updateRing(): void { - this.ring.clear(); - if (!this.playerCombat.isCharging()) return; + const charging = this.playerCombat.isCharging(); + if (!charging) { + if (this.ringVisible) { + this.ringVisible = false; + this.ring.clear(); + } + return; + } const u = this.deps.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.ring - .circle(p.x, p.y - 2, 6 + 4 * k) - .stroke({ color, width: 1, alpha: 0.9 }); + if (!this.ringVisible) { + this.ringVisible = true; + this.ring + .circle(p.x, p.y - 2, 6 + 4 * k) + .stroke({ color, width: 1, alpha: 0.9 }); + } } /** Инкремент боевого счётчика в vars GameState. */ diff --git a/apps/game/src/systems/combat/CombatWorld.ts b/apps/game/src/systems/combat/CombatWorld.ts index a941b7f..c8d5832 100644 --- a/apps/game/src/systems/combat/CombatWorld.ts +++ b/apps/game/src/systems/combat/CombatWorld.ts @@ -216,6 +216,8 @@ readonly projectiles = new Map(); /** Последний шум мира (шаги, удары, плевки); живёт NOISE_WINDOW секунд. */ private lastNoise: { origin: Vec2; level: number; age: number } | null = null; + /** Переиспользуемый view шума для noiseFor — без копии origin на врага/тик. */ + private readonly noiseView: { origin: Vec2; level: number } = { origin: { x: 0, y: 0 }, level: 0 }; /** Непрозрачность тайлов (стены/дома) — для конуса удара и снарядов. */ readonly opaque: (tx: number, ty: number) => boolean; @@ -337,7 +339,10 @@ if (!n || n.age > NOISE_WINDOW || n.level < NOISE_HEAR_LEVEL) return null; if (en.brain.asleep) return null; // спящего будит только noise() от 0.7 if (worldDist(en.pos, n.origin) > (en.kind.hearRadius ?? 5)) return null; - return { origin: { ...n.origin }, level: n.level }; + // Общий view (потребитель копирует origin перед хранением — см. EnemyBrain). + this.noiseView.origin = n.origin; + this.noiseView.level = n.level; + return this.noiseView; } /** Урон сгустку; смерть — дроп, событие и приглушённый шум. */ diff --git a/apps/game/src/systems/combat/EnemyBrain.ts b/apps/game/src/systems/combat/EnemyBrain.ts index db2a7ab..154c0b6 100644 --- a/apps/game/src/systems/combat/EnemyBrain.ts +++ b/apps/game/src/systems/combat/EnemyBrain.ts @@ -21,6 +21,10 @@ | { type: 'strike' } | { type: 'shoot'; dir: Vec2 }; +/** Варианты намерений с направлением — для типизации переиспользуемых констант. */ +type MoveIntent = Extract; +type ShootIntent = Extract; + export interface EnemySenses { /** Дистанция до героя, мировых юнитов (1 юнит = 1 тайл). */ dist: number; @@ -66,6 +70,18 @@ /** Дальность, на которой считается «дошёл до точки» (юниты). */ const ARRIVE_DIST = 0.35; +/** + * Переиспользуемые намерения и вектор направления: update() вызывается на + * каждый тик каждого врага — новые объекты там не создаются. Потребитель + * (CombatWorld) читает intent сразу, между тиками не хранит. + */ +const INTENT_IDLE: EnemyIntent = { type: 'idle' }; +const INTENT_STRIKE: EnemyIntent = { type: 'strike' }; +const INTENT_MOVE: MoveIntent = { type: 'move', dir: { x: 0, y: 0 } }; +const INTENT_MOVE_RUN: MoveIntent = { type: 'move', dir: { x: 0, y: 0 }, run: true }; +const INTENT_SHOOT: ShootIntent = { type: 'shoot', dir: { x: 0, y: 0 } }; +const MOVE_DIR: Vec2 = { x: 0, y: 0 }; + export class EnemyBrain { readonly sm = new StateMachine(); private attackCd: Cooldown; @@ -169,17 +185,17 @@ if (this.hurtTimer > 0) { this.hurtTimer -= dt; if (this.hurtTimer <= 0) this.sm.handleEvent('done'); - return { type: 'idle' }; + return INTENT_IDLE; } if (this.sm.current === 'sleeping') { this.sleepTimer -= dt; if (this.sleepTimer <= 0) this.sm.handleEvent('wake'); - return { type: 'idle' }; + return INTENT_IDLE; } if (this.sm.current === 'rise') { // Подъём из пепла: 0.6 с, потом в погоню if (this.sm.time >= 0.6) this.sm.handleEvent('done'); - return { type: 'idle' }; + return INTENT_IDLE; } switch (this.sm.current) { @@ -197,63 +213,62 @@ if (this.sm.time >= this.kind.windup) { this.sm.handleEvent('strike'); this.attackCd.triggerForce(); - return this.kind.keepDistance !== undefined - ? { type: 'shoot', dir: senses.dirToPlayer } - : { type: 'strike' }; + if (this.kind.keepDistance !== undefined) { + INTENT_SHOOT.dir.x = senses.dirToPlayer.x; + INTENT_SHOOT.dir.y = senses.dirToPlayer.y; + return INTENT_SHOOT; + } + return INTENT_STRIKE; } - return { type: 'idle' }; + return INTENT_IDLE; } case 'attack': { if (this.sm.time >= Math.max(this.kind.recover, 0.3)) { this.sm.handleEvent('done'); } - return { type: 'idle' }; + return INTENT_IDLE; } case 'recover': { if (this.sm.time >= this.kind.recover) this.sm.handleEvent('done'); - return { type: 'idle' }; + return INTENT_IDLE; } default: - return { type: 'idle' }; + return INTENT_IDLE; } } /** Патруль: ход по точкам туда-обратно; герой/шум вырывают из круга. */ private updatePatrol(dt: number, senses: EnemySenses, canSee: boolean, aggro: number): EnemyIntent { - const seen = this.checkSee(senses, canSee, aggro); - if (seen) return { type: 'idle' }; - const noise = this.checkNoise(senses); - if (noise) return { type: 'idle' }; + if (this.checkSee(senses, canSee, aggro)) return INTENT_IDLE; + if (this.checkNoise(senses)) return INTENT_IDLE; const points = this.patrol?.points ?? senses.patrolPoints ?? []; - if (points.length === 0) return { type: 'idle' }; + if (points.length === 0) return INTENT_IDLE; if (this.patrolPause > 0) { this.patrolPause -= dt; - return { type: 'idle' }; + return INTENT_IDLE; } - const move = this.dirToward(senses.pos, points[this.patrolIndex]!); - if (move === null) { + if (!this.dirInto(MOVE_DIR, senses.pos, points[this.patrolIndex]!)) { // Точка достигнута: пауза и разворот на следующую (ping-pong). this.patrolPause = this.patrol?.pauseSec ?? 0; this.advancePatrolIndex(points.length); + return INTENT_IDLE; } - return move ? { type: 'move', dir: move } : { type: 'idle' }; + return this.moveIntentFrom(MOVE_DIR, false); } /** Настороженность: идём к источнику шума, осматриваемся warySec — и домой. */ private updateWary(dt: number, senses: EnemySenses, canSee: boolean, aggro: number): EnemyIntent { - const seen = this.checkSee(senses, canSee, aggro); - if (seen) return { type: 'idle' }; - const noise = this.checkNoise(senses); - if (noise) return { type: 'idle' }; // свежий шум обновляет цель и таймер + if (this.checkSee(senses, canSee, aggro)) return INTENT_IDLE; + if (this.checkNoise(senses)) return INTENT_IDLE; // свежий шум обновляет цель и таймер this.waryTimer -= dt; if (this.waryTimer <= 0) { this.sm.handleEvent('lost'); - return { type: 'idle' }; + return INTENT_IDLE; } - const move = this.waryTarget ? this.dirToward(senses.pos, this.waryTarget) : null; - return move ? { type: 'move', dir: move } : { type: 'idle' }; + if (!this.waryTarget || !this.dirInto(MOVE_DIR, senses.pos, this.waryTarget)) return INTENT_IDLE; + return this.moveIntentFrom(MOVE_DIR, false); } /** Погоня: потерял героя дольше deaggroSec — домой; мало hp — прочь. */ @@ -264,18 +279,18 @@ this.lostTimer += dt; if (this.lostTimer >= (this.kind.deaggroSec ?? 2.5)) { this.sm.handleEvent('lost'); - return { type: 'idle' }; + return INTENT_IDLE; } } const fleeAt = this.kind.fleeBelowHpFraction ?? 0; if (fleeAt > 0 && (senses.hpFraction ?? 1) <= fleeAt) { this.sm.handleEvent('flee'); - return { type: 'idle' }; + return INTENT_IDLE; } if (senses.dist > aggro * 1.5) { // Убежал слишком далеко даже с LOS — не гоняемся по всей карте. this.sm.handleEvent('lost'); - return { type: 'idle' }; + return INTENT_IDLE; } const inRange = senses.dist <= this.kind.attackRange; @@ -283,26 +298,26 @@ // контактный (ползун): без замаха if (this.kind.windup === 0) { this.attackCd.triggerForce(); - return { type: 'strike' }; + return INTENT_STRIKE; } this.sm.handleEvent('inRange'); - return { type: 'idle' }; + return INTENT_IDLE; } // Плевун держит дистанцию if (this.kind.keepDistance !== undefined) { if (senses.dist < this.kind.keepDistance - 0.4) { - return { type: 'move', dir: negate(senses.dirToPlayer) }; + return this.moveIntentFrom(negate(MOVE_DIR, senses.dirToPlayer), false); } if (senses.dist <= this.kind.attackRange && this.attackCd.ready) { this.sm.handleEvent('inRange'); - return { type: 'idle' }; + return INTENT_IDLE; } if (senses.dist > this.kind.keepDistance + 0.4) { - return { type: 'move', dir: senses.dirToPlayer }; + return this.moveIntentFrom(senses.dirToPlayer, false); } - return { type: 'idle' }; + return INTENT_IDLE; } - return { type: 'move', dir: senses.dirToPlayer }; + return this.moveIntentFrom(senses.dirToPlayer, false); } /** Отступление: прочь от героя бегом, пока он не отстал на fleeRange. */ @@ -312,27 +327,24 @@ this.waryTarget = null; this.waryTimer = this.kind.warySec ?? 2.5; this.sm.handleEvent('calm'); - return { type: 'idle' }; + return INTENT_IDLE; } - return { type: 'move', dir: negate(senses.dirToPlayer), run: true }; + return this.moveIntentFrom(negate(MOVE_DIR, senses.dirToPlayer), true); } /** Возврат к дому; на месте — снова в патруль или обратно в пепел. */ private updateReturn(senses: EnemySenses, canSee: boolean, aggro: number): EnemyIntent { - const seen = this.checkSee(senses, canSee, aggro); - if (seen) return { type: 'idle' }; - const noise = this.checkNoise(senses); - if (noise) return { type: 'idle' }; + if (this.checkSee(senses, canSee, aggro)) return INTENT_IDLE; + if (this.checkNoise(senses)) return INTENT_IDLE; if (senses.home) { - const move = this.dirToward(senses.pos, senses.home); - if (move === null) { + if (!this.dirInto(MOVE_DIR, senses.pos, senses.home)) { this.lostTimer = 0; this.sm.handleEvent('home'); } else { - return { type: 'move', dir: move }; + return this.moveIntentFrom(MOVE_DIR, false); } } - return { type: 'idle' }; + return INTENT_IDLE; } /** Переход «увидел героя» (LOS + aggroRange); true — событие отправлено. */ @@ -357,14 +369,24 @@ return false; } - /** Направление к точке; null — точка достигнута (в пределах ARRIVE_DIST). */ - private dirToward(from: Vec2 | undefined, target: Vec2): Vec2 | null { - if (!from) return null; + /** Нормализованное направление к точке в out; false — точка достигнута (ARRIVE_DIST). */ + private dirInto(out: Vec2, from: Vec2 | undefined, target: Vec2): boolean { + if (!from) return false; const dx = target.x - from.x; const dy = target.y - from.y; const d = Math.hypot(dx, dy); - if (d < ARRIVE_DIST) return null; - return { x: dx / d, y: dy / d }; + if (d < ARRIVE_DIST) return false; + out.x = dx / d; + out.y = dy / d; + return true; + } + + /** move-намерение с копией направления из dir (общий intent.dir мутируется). */ + private moveIntentFrom(dir: Vec2, run: boolean): EnemyIntent { + const intent = run ? INTENT_MOVE_RUN : INTENT_MOVE; + intent.dir.x = dir.x; + intent.dir.y = dir.y; + return intent; } private advancePatrolIndex(count: number): void { @@ -376,6 +398,9 @@ } } -function negate(v: Vec2): Vec2 { - return { x: -v.x, y: -v.y }; +/** Обратный вектор в out (без аллокации — вызывается в тике). */ +function negate(out: Vec2, v: Vec2): Vec2 { + out.x = -v.x; + out.y = -v.y; + return out; } \ No newline at end of file diff --git a/docs/engine/render.md b/docs/engine/render.md index afadadc..fd9e6e6 100644 --- a/docs/engine/render.md +++ b/docs/engine/render.md @@ -257,6 +257,8 @@ // breaking (v2): stepParticle возвращает видимые кривые, а не мутирует альфу const visual = stepParticle(state, dt, { drag: 2, scaleOverLife: 'shrink' }); // visual: { alpha, scale, rotation, tint } +// Горячий путь: последний аргумент out — переиспользуемый визуал, без аллокации: +stepParticle(state, dt, opts, out); ``` `sampleSpawn`/`sampleBurst` — спавн по опциям эмиттера и радиальный «взрыв»; diff --git a/packages/engine/src/core/Tween.ts b/packages/engine/src/core/Tween.ts index 0e38503..b747294 100644 --- a/packages/engine/src/core/Tween.ts +++ b/packages/engine/src/core/Tween.ts @@ -82,7 +82,8 @@ /** dt в секундах (фиксированный шаг). */ update(dt: number): void { - // Твины + // Твины: компакт на месте (без filter — тик не должен аллоцировать). + let w = 0; for (const t of this.tweens) { if (t.cancelled) continue; const delay = t.options.delay ?? 0; @@ -91,7 +92,10 @@ const skipped = Math.min(dtLeft, delay - t.elapsed); t.elapsed += skipped; dtLeft -= skipped; - if (dtLeft <= 0) continue; + if (dtLeft <= 0) { + this.tweens[w++] = t; // dt целиком ушёл в delay — твин остаётся + continue; + } } if (!t.from) { t.from = {}; @@ -109,10 +113,12 @@ t.cancelled = true; t.options.onDone?.(); } + if (!t.cancelled) this.tweens[w++] = t; } - this.tweens = this.tweens.filter((t) => !t.cancelled); + this.tweens.length = w; // Таймеры + w = 0; for (const d of this.delays) { if (d.cancelled) continue; d.remaining -= dt; @@ -120,8 +126,9 @@ d.cancelled = true; d.fn(); } + if (!d.cancelled) this.delays[w++] = d; } - this.delays = this.delays.filter((d) => !d.cancelled); + this.delays.length = w; } get activeCount(): number { diff --git a/packages/engine/src/core/Updatables.ts b/packages/engine/src/core/Updatables.ts index 725e4a1..1e00c86 100644 --- a/packages/engine/src/core/Updatables.ts +++ b/packages/engine/src/core/Updatables.ts @@ -38,15 +38,17 @@ /** * Тик в порядке добавления; разрушенные записи не тикаются (Pixi после - * destroy обнуляет transform — касаться их нельзя) и вычищаются после прохода. + * destroy обнуляет transform — касаться их нельзя) и вычищаются + * компактом на месте (без filter — тик не должен аллоцировать). */ update(dt: number): void { + let w = 0; for (const u of this.list) { - if (!u.destroyed) u.update(dt); + if (u.destroyed) continue; + u.update(dt); + if (!u.destroyed) this.list[w++] = u; // update мог разрушить сам объект } - if (this.list.some((u) => u.destroyed)) { - this.list = this.list.filter((u) => !u.destroyed); - } + this.list.length = w; } get count(): number { diff --git a/packages/engine/src/render/Lighting.ts b/packages/engine/src/render/Lighting.ts index 50eb2ca..c8bd627 100644 --- a/packages/engine/src/render/Lighting.ts +++ b/packages/engine/src/render/Lighting.ts @@ -105,6 +105,19 @@ sprite: Sprite; } +/** Апсейт def по полям (id — ключ карты, не меняется): без пересоздания на тике. */ +function copyLightDef(into: LightDef, from: LightDef): void { + into.x = from.x; + into.y = from.y; + into.color = from.color; + into.intensity = from.intensity; + into.radius = from.radius; + into.flicker = from.flicker; + into.flickerPeriod = from.flickerPeriod; + into.seed = from.seed; + into.enabled = from.enabled; +} + interface PulseEntry { def: LightDef; /** Базовая интенсивность до огибающей. */ @@ -141,6 +154,8 @@ private readonly maxLights: number; private readonly maxDarkSpots: number; private pulseSeq = 0; + /** Переиспользуемый кадр applyFrame — без аллокации на источник/тик. */ + private readonly frame: LightFrame = { id: '', x: 0, y: 0, tint: 0xffffff, alpha: 0, scale: 1 }; private time = 0; private ambientFade: FadeState; @@ -248,7 +263,7 @@ this.lights.set(def.id, entry); sprite.visible = true; } - entry.def = { ...def }; + copyLightDef(entry.def, def); // апсейт по полям — без пересоздания на тике } removeLight(id: string): void { @@ -340,7 +355,11 @@ this.darks.set(def.id, entry); sprite.visible = true; } - entry.def = { ...def }; + const d = entry.def; + d.x = def.x; + d.y = def.y; + d.radius = def.radius; + d.alpha = def.alpha; const s = entry.sprite; s.position.set(def.x, def.y); s.alpha = Math.max(0, Math.min(1, def.alpha)); @@ -369,11 +388,11 @@ this.ambient.tint = this.ambientFade.value; this.vignetteFade.update(dt); this.vignette.alpha = this.vignetteFade.value; - for (const [id, p] of [...this.pulses]) { + for (const [id, p] of this.pulses) { p.elapsed += dt; if (p.elapsed >= pulseTotal(p.spec)) { this.removeLight(id); - this.pulses.delete(id); + this.pulses.delete(id); // удаление во время итерации Map — безопасно continue; } p.def.intensity = p.base * pulseEnvelope(p.elapsed, p.spec); @@ -392,7 +411,7 @@ } private applyFrame(def: LightDef, sprite: Sprite): void { - const f = lightFrame(def, this.time); + const f = lightFrame(def, this.time, this.frame); sprite.position.set(f.x, f.y); sprite.tint = f.tint; sprite.alpha = Math.min(1, f.alpha); diff --git a/packages/engine/src/render/Particles.ts b/packages/engine/src/render/Particles.ts index 5a9f0f6..53b4f47 100644 --- a/packages/engine/src/render/Particles.ts +++ b/packages/engine/src/render/Particles.ts @@ -1,6 +1,6 @@ import { Container, Sprite, Texture } from 'pixi.js'; import { createRng, type Rng } from '../math/rng'; -import { stepParticle, sampleSpawn, sampleBurst, mergeEmitterOptions, type ParticleState } from './particleSim'; +import { stepParticle, sampleSpawn, sampleBurst, mergeEmitterOptions, type ParticleState, type ParticleVisual } from './particleSim'; /** * Система частиц: квадраты (Texture.WHITE) или текстуры, цвета/кривые по @@ -68,6 +68,8 @@ private spawnTimer = 0; private rng: Rng; private selfDestruct = false; + /** Переиспользуемый визуал шага: без аллокации на частицу/тик. */ + private readonly visual: ParticleVisual = { alpha: 0, scale: 1, rotation: 0, tint: 0xffffff }; constructor(private options: EmitterOptions) { super(); @@ -82,9 +84,18 @@ this.spawn(); } - // Обновление через чистую симуляцию - for (const p of this.particles) { - const v = stepParticle(p.state, dt, this.options); + // Обновление через чистую симуляцию + компакт в один проход: + // умершие (age >= lifetime) — спрайт в пул, не destroy; без filter. + let w = 0; + for (let i = 0; i < this.particles.length; i++) { + const p = this.particles[i]; + if (p.state.age >= p.state.lifetime) { + p.sprite.visible = false; + p.sprite.alpha = 0; + this.free.push(p.sprite); + continue; + } + const v = stepParticle(p.state, dt, this.options, this.visual); p.sprite.x = p.state.x; p.sprite.y = p.state.y; p.sprite.alpha = v.alpha; @@ -96,18 +107,9 @@ p.sprite.width = v.scale; p.sprite.height = v.scale; } + this.particles[w++] = p; } - - // «Умершие» — спрайт в пул, не destroy - const dead = this.particles.filter((p) => p.state.age >= p.state.lifetime); - if (dead.length > 0) { - for (const p of dead) { - p.sprite.visible = false; - p.sprite.alpha = 0; - this.free.push(p.sprite); - } - this.particles = this.particles.filter((p) => p.state.age < p.state.lifetime); - } + this.particles.length = w; // oneShot: все частицы умерли и новые не рождаются — уничтожиться if (this.selfDestruct && this.particles.length === 0 && this.options.rate <= 0) { this.onFinish?.(); diff --git a/packages/engine/src/render/__tests__/particleSim.test.ts b/packages/engine/src/render/__tests__/particleSim.test.ts index a43b6a2..8098d9d 100644 --- a/packages/engine/src/render/__tests__/particleSim.test.ts +++ b/packages/engine/src/render/__tests__/particleSim.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { stepParticle, sampleSpawn, sampleBurst, mergeEmitterOptions, type ParticleState } from '../particleSim'; +import { stepParticle, sampleSpawn, sampleBurst, mergeEmitterOptions, type ParticleState, type ParticleVisual } from '../particleSim'; import { createRng } from '../../math/rng'; import type { EmitterOptions } from '../Particles'; @@ -73,6 +73,15 @@ stepParticle(p, 0.1, { wobble: 10 }); // sin(0.3) > 0 → дрейф вправо expect(p.x).toBeGreaterThan(before); }); + + it('out — визуал заполняется без аллокации (горячий путь)', () => { + const p = state({ lifetime: 2, spin: 1 }); + const out: ParticleVisual = { alpha: 0, scale: 0, rotation: 0, tint: 0 }; + const v = stepParticle(p, 1, { colorOverLife: [0xff0000, 0x0000ff] }, out); + expect(v).toBe(out); // тот же объект, не новый + expect(v.tint).toBe(0x800080); + expect(v.rotation).toBeCloseTo(1); + }); }); describe('sampleSpawn', () => { diff --git a/packages/engine/src/render/lightSim.ts b/packages/engine/src/render/lightSim.ts index b8ec65d..1067dcc 100644 --- a/packages/engine/src/render/lightSim.ts +++ b/packages/engine/src/render/lightSim.ts @@ -55,14 +55,21 @@ } /** Кадр источника: чистая функция, без Pixi и без мутаций. */ -export function lightFrame(def: LightDef, time: number): LightFrame { +export function lightFrame(def: LightDef, time: number, out?: LightFrame): LightFrame { const intensity = def.intensity ?? 1; const radius = def.radius ?? 2 * GLOW_BASE_PX; const enabled = def.enabled ?? true; const alpha = enabled ? intensity * flickerFactor(time, def.seed ?? 0, def.flicker ?? 0, def.flickerPeriod ?? 0.9) : 0; - return { id: def.id, x: def.x, y: def.y, tint: def.color, alpha, scale: radius / GLOW_BASE_PX }; + const f: LightFrame = out ?? { id: '', x: 0, y: 0, tint: 0xffffff, alpha: 0, scale: 1 }; + f.id = def.id; + f.x = def.x; + f.y = def.y; + f.tint = def.color; + f.alpha = alpha; + f.scale = radius / GLOW_BASE_PX; + return f; } /** diff --git a/packages/engine/src/render/particleSim.ts b/packages/engine/src/render/particleSim.ts index aac24f0..502386c 100644 --- a/packages/engine/src/render/particleSim.ts +++ b/packages/engine/src/render/particleSim.ts @@ -55,8 +55,10 @@ /** * Один шаг интеграции (полу-неявный Эйлер) + видимые кривые по жизни. * Мутирует p (позиция/скорость/угол/возраст), возвращает визуал. + * out — переиспользуемый визуал (горячий путь: без аллокации на частицу/тик); + * без out — свежий объект. */ -export function stepParticle(p: ParticleState, dt: number, opts: ParticleSimOptions): ParticleVisual { +export function stepParticle(p: ParticleState, dt: number, opts: ParticleSimOptions, out?: ParticleVisual): ParticleVisual { p.age += dt; const drag = Math.max(0, 1 - (opts.drag ?? 0) * dt); p.vx *= drag; @@ -86,12 +88,12 @@ ? opts.scaleOverLife[0] + (opts.scaleOverLife[1] - opts.scaleOverLife[0]) * lifeK : 1; - return { - alpha, - scale: Math.max(0, p.scale * curve), - rotation: p.rot, - tint: opts.colorOverLife ? lerpColor(opts.colorOverLife[0], opts.colorOverLife[1], lifeK) : p.tint - }; + const v: ParticleVisual = out ?? { alpha: 0, scale: 1, rotation: 0, tint: 0xffffff }; + v.alpha = alpha; + v.scale = Math.max(0, p.scale * curve); + v.rotation = p.rot; + v.tint = opts.colorOverLife ? lerpColor(opts.colorOverLife[0], opts.colorOverLife[1], lifeK) : p.tint; + return v; } /** Случайная начальная частица по опциям эмиттера (позиция относительно центра). */ diff --git a/packages/engine/src/render/shake.ts b/packages/engine/src/render/shake.ts index 7ac829b..6acf696 100644 --- a/packages/engine/src/render/shake.ts +++ b/packages/engine/src/render/shake.ts @@ -30,13 +30,15 @@ update(dt: number): void { this.offsetX = 0; this.offsetY = 0; + let w = 0; for (const e of this.entries) { e.elapsed += dt; const k = Math.max(0, 1 - e.elapsed / e.duration); // линейное затухание this.offsetX += (this.rng.next() * 2 - 1) * e.strength * k; this.offsetY += (this.rng.next() * 2 - 1) * e.strength * k; + if (e.elapsed < e.duration) this.entries[w++] = e; // компакт на месте, без filter } - this.entries = this.entries.filter((e) => e.elapsed < e.duration); + this.entries.length = w; } /** Текущее смещение в пикселях (0 вне активной тряски). */ diff --git a/packages/engine/src/scene/SceneRegistry.ts b/packages/engine/src/scene/SceneRegistry.ts index f2eee80..3bc5c12 100644 --- a/packages/engine/src/scene/SceneRegistry.ts +++ b/packages/engine/src/scene/SceneRegistry.ts @@ -38,6 +38,8 @@ private byId = new Map(); /** Индекс по тайлу → множество объектов, чей тайл/footprint покрывает его. */ private byTile = new Map>(); + /** Переиспользуемый Set дедупликации near() — без Set на вызов. */ + private nearSeen = new Set(); /** Добавить объект; дубликат id — ошибка (ловится инвариантом сцены). */ add(def: SceneObjectDef): SceneObject { @@ -125,10 +127,15 @@ return null; } - /** Объекты в радиусе (юниты) от точки; фильтр по роду опционален. */ - near(p: Vec2, radius: number, kind?: string): SceneObject[] { - const found: SceneObject[] = []; - const seen = new Set(); + /** + * Объекты в радиусе (юниты) от точки; фильтр по роду опционален. + * out — переиспользуемый массив результата (горячий путь без аллокаций). + */ + near(p: Vec2, radius: number, kind?: string, out?: SceneObject[]): SceneObject[] { + const found = out ?? []; + found.length = 0; + const seen = this.nearSeen; + seen.clear(); const x0 = Math.floor(p.x - radius - 1); const x1 = Math.floor(p.x + radius + 1); const y0 = Math.floor(p.y - radius - 1);