Newer
Older
rpg / apps / game / src / systems / Atmosphere.ts
import { Container, Graphics, ParticleEmitter, type Updatable } from '@rpg/engine';
import { Game } from '../Game';
import type { AreaDef, HazardDef } from '../data/locations';
import type { PlayerController } from './PlayerController';

/**
 * Атмосфера локации: пепел/мотыли (частицы мира), дымка зон наката (UI-слой),
 * механика низин (замедление без маски, тост при входе) и виньетка опасности.
 */

/** Виньетка: цель при низком hp / в накате без маски и фейд перехода. */
const VIGNETTE_LOW_HP = 0.3;
const VIGNETTE_HAZARD = 0.35;
const VIGNETTE_FADE = 0.5;

export interface AtmosphereDeps {
    game: Game;
    area: AreaDef;
    /** Слои: частицы — в мир, дымка — под UI (index 0 uiRoot). */
    worldRoot: Container;
    uiRoot: Container;
    /** Реестр тикающихся (engine.fx). */
    addFx(u: Updatable): void;
    player: PlayerController;
    /** Текущий hp героя (виньетка при низком). */
    heroHp: () => number;
    /** Виньетка движкового света: уровень + цель с фейдом. */
    vignette: { level(): number; set(intensity: number, fadeSec: number): void };
    showToast(text: string): void;
}

export class Atmosphere {
    /** Пепел на лугах/деревне (в интерьерах null). */
    readonly ash: ParticleEmitter | null;
    /** Тёплые мотыльки над лугами (светящиеся, дрейф по синусу). */
    readonly moths: ParticleEmitter | null;
    /** Оверлей дымки (зоны наката). Виден только в низинах. */
    readonly fog: Graphics;
    /** Зона наката, в которой герой был в прошлом кадре (тост при входе). */
    inHazard: HazardDef | null = null;
    /** Текущая зона наката перекрыта полотном (для виньетки). */
    hazardMasked = false;

    constructor(private deps: AtmosphereDeps) {
        const area = deps.area;
        // Пепел на лугах, туман над прудами, в интерьере — ничего.
        this.ash =
            area.atmosphere === 'none'
                ? null
                : area.atmosphere === 'fog'
                ? new ParticleEmitter({
                      color: 0x7a7a88,
                      rate: 6,
                      lifetime: [5, 10],
                      velocity: { x: [-3, 3], y: [-1, 1] },
                      size: 2,
                      spawnArea: { width: 560, height: 340 },
                      seed: 20260906
                  })
                : new ParticleEmitter({
                      color: 0x666677,
                      rate: 5,
                      lifetime: [4, 9],
                      velocity: { x: [-9, -3], y: [-2, 2] },
                      size: 1,
                      spawnArea: { width: 520, height: 300 },
                      seed: 20260905
                  });
        if (this.ash) {
            this.ash.position.set(240, 120);
            deps.worldRoot.addChild(this.ash);
            // Пепел тикается реестром эффектов (engine.fx) — без ручного update.
            deps.addFx(this.ash);
        }

        // Тёплые мотыльки над лугами: светятся (add), дышат по синусу, в интерьере их нет.
        this.moths =
            area.atmosphere === '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);
            deps.worldRoot.addChild(this.moths);
            deps.addFx(this.moths);
        }

        // Дымка наката: полупрозрачная пелена на весь экран (в низинах без маски — гуще).
        this.fog = new Graphics().rect(0, 0, Game.VIRTUAL_W, Game.VIRTUAL_H).fill(0x5a6a78);
        this.fog.alpha = 0;
        deps.uiRoot.addChildAt(this.fog, 0);
    }

    /** Зона наката под ногами: без маски герой еле идёт (тост при входе). */
    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;
        const masked = hazard !== null && game.inventory.has(hazard.requiresItem);
        player.speedMul = hazard !== null && !masked ? 0.5 : 1;

        // Вход в низину: предупреждение один раз за вход.
        if (hazard !== null && this.inHazard === null) {
            this.deps.showToast(
                masked ? `${hazard.name}: полотно держит — но пепел у самых губ.` : `${hazard.name}! Дышать поверх — потерять голос.`
            );
            void game.audio.play('sfx/ash_hiss', 0.5);
        }
        this.inHazard = hazard;
        this.hazardMasked = masked;

        // Дымка: плотная без маски, лёгкая с ней, вне низин её нет.
        this.fog.alpha = hazard === null ? 0 : masked ? 0.12 : 0.28;
    }

    /** Виньетка: низкий hp или накат без маски — края экрана темнеют. */
    updateVignette(): void {
        const target = Math.max(
            this.deps.heroHp() <= 2 ? VIGNETTE_LOW_HP : 0,
            this.inHazard !== null && !this.hazardMasked ? VIGNETTE_HAZARD : 0
        );
        if (this.deps.vignette.level() !== target) this.deps.vignette.set(target, VIGNETTE_FADE);
    }

    /** Погасить/уничтожить свои вьюхи (exit сцены; engine.fx дочистит сам). */
    destroy(): void {
        this.ash?.clear();
        this.moths?.clear();
        this.ash?.destroy();
        this.moths?.destroy();
        this.fog.destroy();
    }
}