Загрузка данных


// CrashAviatorVisual.ts
//
// PixiJS 8 / TypeScript
//
// ВАЖНО:
// Этот класс НЕ содержит игровую механику.
// Он только визуализирует уже существующий Crash.
//
// Подключение к существующей игре:
//
// const visual = await CrashAviatorVisual.mount(gameFieldElement);
//
// когда реально начинается раунд:
// visual.startRound();
//
// когда обновляется существующий multiplier:
// visual.setMultiplier(currentMultiplier);
//
// когда существующая игра сообщает CRASH:
// visual.crash();
//
// перед следующим раундом:
// visual.reset();
//
// при размонтировании страницы:
// visual.destroy();

import {
  Application,
  Container,
  Graphics,
  Text,
} from 'pixi.js';

type CrashVisualState = 'idle' | 'flying' | 'crashed';

interface CrashVisualOptions {
  showMultiplier?: boolean;
  maxVisualMultiplier?: number;
}

interface Debris {
  view: Graphics;
  depth: number;
  vx: number;
  vy: number;
  rotationSpeed: number;
}

interface Particle {
  view: Graphics;
  depth: number;
  vx: number;
  vy: number;
}

const clamp = (value: number, min: number, max: number) =>
  Math.max(min, Math.min(max, value));

const lerp = (a: number, b: number, t: number) =>
  a + (b - a) * t;

const easeOut = (t: number) =>
  1 - Math.pow(1 - t, 3);

const easeInCubic = (t: number) =>
  t * t * t;

export class CrashAviatorVisual {
  private app!: Application;
  private readonly host: HTMLElement;

  private readonly root = new Container();

  private readonly background = new Graphics();
  private readonly ambient = new Graphics();
  private readonly grid = new Graphics();

  private readonly graphOuter = new Graphics();
  private readonly graphMiddle = new Graphics();
  private readonly graphMain = new Graphics();

  private readonly graphPointGlow = new Graphics();
  private readonly graphPoint = new Graphics();

  private readonly debrisLayer = new Container();
  private readonly particlesLayer = new Container();

  private readonly rocket = new Container();
  private readonly rocketTrail = new Graphics();
  private readonly rocketEngine = new Graphics();
  private readonly rocketBody = new Container();

  private readonly multiplierText: Text;

  private readonly debris: Debris[] = [];
  private readonly particles: Particle[] = [];

  private readonly options: Required<CrashVisualOptions>;

  private state: CrashVisualState = 'idle';

  private targetMultiplier = 1;
  private displayMultiplier = 1;

  private visualProgress = 0;
  private targetProgress = 0;

  private elapsed = 0;

  private crashElapsed = 0;
  private crashStartX = 0;
  private crashStartY = 0;
  private crashStartRotation = 0;

  private rocketX = 0;
  private rocketY = 0;
  private rocketRotation = 0;

  private width = 1;
  private height = 1;

  private resizeObserver?: ResizeObserver;

  private constructor(
    host: HTMLElement,
    options: CrashVisualOptions = {},
  ) {
    this.host = host;

    this.options = {
      showMultiplier: options.showMultiplier ?? true,
      maxVisualMultiplier: options.maxVisualMultiplier ?? 50,
    };

    this.multiplierText = new Text({
      text: '1.00x',
      style: {
        fill: 0xffffff,
        fontFamily: 'Inter, Arial, sans-serif',
        fontSize: 72,
        fontWeight: '800',
        letterSpacing: -2,
      },
    });

    this.multiplierText.anchor.set(0.5);
  }

  static async mount(
    host: HTMLElement,
    options?: CrashVisualOptions,
  ): Promise<CrashAviatorVisual> {
    const instance = new CrashAviatorVisual(host, options);

    await instance.init();

    return instance;
  }

  private async init(): Promise<void> {
    this.app = new Application();

    await this.app.init({
      resizeTo: this.host,
      antialias: true,
      autoDensity: true,
      resolution: Math.min(window.devicePixelRatio || 1, 2),
      backgroundAlpha: 0,
    });

    this.host.style.position ||= 'relative';
    this.host.style.overflow = 'hidden';

    this.app.canvas.style.position = 'absolute';
    this.app.canvas.style.inset = '0';
    this.app.canvas.style.width = '100%';
    this.app.canvas.style.height = '100%';

    this.host.appendChild(this.app.canvas);

    this.app.stage.addChild(this.root);

    this.root.addChild(
      this.background,
      this.ambient,
      this.grid,

      this.graphOuter,
      this.graphMiddle,
      this.graphMain,

      this.debrisLayer,

      this.particlesLayer,

      this.graphPointGlow,
      this.graphPoint,

      this.rocket,
    );

    if (this.options.showMultiplier) {
      this.root.addChild(this.multiplierText);
    }

    this.createRocket();
    this.createDebris();
    this.createParticles();

    this.resize();

    this.resizeObserver = new ResizeObserver(() => {
      this.resize();
    });

    this.resizeObserver.observe(this.host);

    this.app.ticker.add(this.tick);
  }

  // ============================================================
  // PUBLIC API
  // ============================================================

  public startRound(): void {
    this.state = 'flying';

    this.targetMultiplier = 1;
    this.displayMultiplier = 1;

    this.targetProgress = 0;
    this.visualProgress = 0;

    this.crashElapsed = 0;

    this.rocket.visible = true;
    this.rocket.alpha = 1;

    this.drawGraph(0);
  }

  public setMultiplier(multiplier: number): void {
    if (!Number.isFinite(multiplier)) return;

    this.targetMultiplier = Math.max(1, multiplier);

    if (this.state !== 'flying') return;

    this.targetProgress =
      this.progressFromMultiplier(this.targetMultiplier);
  }

  public crash(): void {
    if (this.state === 'crashed') return;

    this.state = 'crashed';

    this.crashElapsed = 0;

    this.crashStartX = this.rocket.x;
    this.crashStartY = this.rocket.y;
    this.crashStartRotation = this.rocket.rotation;
  }

  public reset(): void {
    this.state = 'idle';

    this.targetMultiplier = 1;
    this.displayMultiplier = 1;

    this.targetProgress = 0;
    this.visualProgress = 0;

    this.multiplierText.text = '1.00x';

    this.rocket.visible = true;
    this.rocket.alpha = 1;

    this.positionRocket(0);

    this.drawGraph(0);
  }

  public destroy(): void {
    this.resizeObserver?.disconnect();

    this.app.ticker.remove(this.tick);

    this.app.destroy(true);
  }

  // ============================================================
  // MAIN LOOP
  // ============================================================

  private tick = (ticker: { deltaMS: number }): void => {
    const dt = Math.min(ticker.deltaMS / 1000, 0.05);

    this.elapsed += dt;

    if (this.state === 'flying') {
      this.updateFlying(dt);
    }

    if (this.state === 'crashed') {
      this.updateCrash(dt);
    }

    if (this.state === 'idle') {
      this.updateIdle();
    }

    this.updateParticles(dt);
    this.updateDebris(dt);
  };

  private updateFlying(dt: number): void {
    // Плавно догоняем реальный multiplier.
    this.displayMultiplier +=
      (this.targetMultiplier - this.displayMultiplier) *
      Math.min(1, dt * 12);

    this.visualProgress +=
      (this.targetProgress - this.visualProgress) *
      Math.min(1, dt * 8);

    this.multiplierText.text =
      `${this.displayMultiplier.toFixed(2)}x`;

    const intensity =
      this.getVisualIntensity(this.displayMultiplier);

    this.positionRocket(this.visualProgress);

    this.updateRocketFX(intensity);

    this.drawGraph(this.visualProgress);

    this.updateGraphPoint();
  }

  // ============================================================
  // AVIATOR STYLE FLIGHT PATH
  // ============================================================

  private getFlightPosition(progress: number): {
    x: number;
    y: number;
    angle: number;
  } {
    const p = clamp(progress, 0, 1);

    /*
     * Ракета СТАРТУЕТ снизу-слева.
     *
     * Сначала летит почти горизонтально,
     * затем всё сильнее поднимается.
     *
     * То есть логика движения ближе к Aviator,
     * а не к статичной ракете с картинки.
     */

    const startX = this.width * 0.08;
    const endX = this.width * 0.84;

    const startY = this.height * 0.80;
    const endY = this.height * 0.17;

    const x =
      lerp(startX, endX, p);

    // Экспоненциальный подъём.
    const verticalProgress =
      Math.pow(p, 2.15);

    const y =
      lerp(startY, endY, verticalProgress);

    /*
     * Вычисляем касательную,
     * чтобы нос ракеты реально смотрел
     * по направлению траектории.
     */

    const dx =
      endX - startX;

    const derivative =
      2.15 * Math.pow(Math.max(p, 0.001), 1.15);

    const dy =
      -(startY - endY) * derivative;

    const angle =
      Math.atan2(dy, dx);

    return {
      x,
      y,
      angle,
    };
  }

  private positionRocket(progress: number): void {
    const flight =
      this.getFlightPosition(progress);

    const intensity =
      this.getVisualIntensity(this.displayMultiplier);

    // Микровибрация двигателя.
    const floatingX =
      Math.sin(this.elapsed * 4.2) *
      (1.2 + intensity * 1.5);

    const floatingY =
      Math.sin(this.elapsed * 5.7) *
      (1.5 + intensity * 2);

    const rotationNoise =
      Math.sin(this.elapsed * 3.4) *
      (0.003 + intensity * 0.005);

    this.rocketX = flight.x + floatingX;
    this.rocketY = flight.y + floatingY;

    this.rocketRotation =
      flight.angle + rotationNoise;

    this.rocket.position.set(
      this.rocketX,
      this.rocketY,
    );

    this.rocket.rotation =
      this.rocketRotation;
  }

  // ============================================================
  // MULTIPLIER -> VISUAL POSITION
  // ============================================================

  private progressFromMultiplier(multiplier: number): number {
    /*
     * ВАЖНО:
     *
     * Это НЕ игровая математика.
     *
     * Она только определяет,
     * где визуально находится ракета.
     *
     * Реальный multiplier приходит извне.
     */

    const maximum =
      this.options.maxVisualMultiplier;

    const progress =
      Math.log(Math.max(1, multiplier)) /
      Math.log(maximum);

    return clamp(progress, 0, 1);
  }

  private getVisualIntensity(multiplier: number): number {
    if (multiplier <= 1) return 0.05;

    const t =
      Math.log(multiplier) /
      Math.log(20);

    return clamp(t, 0.05, 1);
  }

  // ============================================================
  // ROCKET
  // ============================================================

  private createRocket(): void {
    /*
     * rocket local coordinate system:
     *
     * двигатель = LEFT
     * нос       = RIGHT
     *
     * Сам контейнер потом вращается
     * по касательной графика.
     */

    this.rocket.addChild(
      this.rocketTrail,
      this.rocketEngine,
      this.rocketBody,
    );

    this.drawRocketBody();

    this.rocket.scale.set(
      this.getRocketScale(),
    );
  }

  private drawRocketBody(): void {
    this.rocketBody.removeChildren();

    // ----------------------------------------------------------
    // BACK FIN
    // ----------------------------------------------------------

    const backFin = new Graphics();

    backFin
      .moveTo(-58, -20)
      .bezierCurveTo(
        -82, -42,
        -100, -52,
        -108, -48,
      )
      .bezierCurveTo(
        -102, -25,
        -89, -8,
        -69, 2,
      )
      .closePath()
      .fill({
        color: 0x130d15,
      })
      .stroke({
        color: 0xb71935,
        width: 2,
        alpha: 0.55,
      });

    this.rocketBody.addChild(backFin);

    // ----------------------------------------------------------
    // BOTTOM FIN
    // ----------------------------------------------------------

    const bottomFin = new Graphics();

    bottomFin
      .moveTo(-20, 26)
      .bezierCurveTo(
        -28, 54,
        -17, 67,
        -4, 68,
      )
      .bezierCurveTo(
        6, 53,
        13, 37,
        18, 20,
      )
      .closePath()
      .fill({
        color: 0x100d14,
      })
      .stroke({
        color: 0xd32140,
        width: 2,
        alpha: 0.65,
      });

    this.rocketBody.addChild(bottomFin);

    // ----------------------------------------------------------
    // MAIN BODY SHADOW
    // ----------------------------------------------------------

    const shadow = new Graphics();

    shadow
      .moveTo(-73, 0)
      .bezierCurveTo(
        -58, -35,
        -9, -49,
        48, -40,
      )
      .bezierCurveTo(
        76, -35,
        95, -17,
        105, 0,
      )
      .bezierCurveTo(
        94, 19,
        73, 38,
        41, 44,
      )
      .bezierCurveTo(
        -14, 51,
        -57, 34,
        -73, 0,
      )
      .closePath()
      .fill({
        color: 0x07080d,
      });

    this.rocketBody.addChild(shadow);

    // ----------------------------------------------------------
    // MAIN BODY
    // ----------------------------------------------------------

    const body = new Graphics();

    body
      .moveTo(-72, -2)
      .bezierCurveTo(
        -58, -39,
        -4, -52,
        52, -40,
      )
      .bezierCurveTo(
        79, -34,
        98, -17,
        108, 0,
      )
      .bezierCurveTo(
        94, 13,
        77, 26,
        46, 34,
      )
      .bezierCurveTo(
        -6, 45,
        -52, 30,
        -72, -2,
      )
      .closePath()
      .fill({
        color: 0x11121a,
      });

    this.rocketBody.addChild(body);

    // ----------------------------------------------------------
    // BODY HIGHLIGHT
    // ----------------------------------------------------------

    const highlight = new Graphics();

    highlight
      .moveTo(-42, -28)
      .bezierCurveTo(
        1, -45,
        50, -34,
        82, -15,
      )
      .bezierCurveTo(
        48, -24,
        2, -23,
        -42, -12,
      )
      .closePath()
      .fill({
        color: 0x45424d,
        alpha: 0.24,
      });

    this.rocketBody.addChild(highlight);

    // ----------------------------------------------------------
    // RED NOSE LIGHT
    // ----------------------------------------------------------

    const noseGlow = new Graphics();

    noseGlow
      .moveTo(79, -28)
      .bezierCurveTo(
        94, -17,
        103, -6,
        108, 0,
      )
      .bezierCurveTo(
        102, 6,
        96, 10,
        91, 13,
      )
      .bezierCurveTo(
        94, -3,
        89, -16,
        79, -28,
      )
      .closePath()
      .fill({
        color: 0xee2945,
        alpha: 0.55,
      });

    this.rocketBody.addChild(noseGlow);

    // ----------------------------------------------------------
    // TOP/BACK BODY LINE
    // ----------------------------------------------------------

    const seam = new Graphics();

    seam
      .moveTo(65, -36)
      .lineTo(91, -15)
      .stroke({
        color: 0x06060a,
        width: 3,
        alpha: 0.9,
      });

    this.rocketBody.addChild(seam);

    // ----------------------------------------------------------
    // WINDOW OUTER GLOW
    // ----------------------------------------------------------

    const windowGlow = new Graphics();

    windowGlow
      .circle(35, -4, 22)
      .fill({
        color: 0xc81738,
        alpha: 0.13,
      });

    this.rocketBody.addChild(windowGlow);

    // ----------------------------------------------------------
    // WINDOW RED RING
    // ----------------------------------------------------------

    const ringOuter = new Graphics();

    ringOuter
      .circle(35, -4, 17)
      .fill({
        color: 0xe02a45,
      });

    ringOuter
      .circle(35, -4, 12)
      .fill({
        color: 0x331019,
      });

    this.rocketBody.addChild(ringOuter);

    // ----------------------------------------------------------
    // WINDOW
    // ----------------------------------------------------------

    const glass = new Graphics();

    glass
      .circle(35, -4, 10)
      .fill({
        color: 0x05060a,
      });

    glass
      .circle(31, -8, 4)
      .fill({
        color: 0x4a3540,
        alpha: 0.35,
      });

    this.rocketBody.addChild(glass);

    // ----------------------------------------------------------
    // LOWER RED RIM LIGHT
    // ----------------------------------------------------------

    const rim = new Graphics();

    rim
      .moveTo(-52, 26)
      .bezierCurveTo(
        -3, 44,
        52, 28,
        88, 11,
      )
      .stroke({
        color: 0xe92744,
        width: 2.5,
        alpha: 0.7,
      });

    this.rocketBody.addChild(rim);

    // ----------------------------------------------------------
    // ENGINE
    // ----------------------------------------------------------

    const engine = new Graphics();

    engine
      .moveTo(-73, -14)
      .lineTo(-88, -11)
      .lineTo(-92, 11)
      .lineTo(-73, 15)
      .closePath()
      .fill({
        color: 0x161016,
      })
      .stroke({
        color: 0xa51730,
        width: 2,
        alpha: 0.65,
      });

    this.rocketBody.addChild(engine);
  }

  private getRocketScale(): number {
    const base =
      this.height / 380;

    return clamp(base, 0.48, 1.05);
  }

  // ============================================================
  // ENGINE + SPEED TRAILS
  // ============================================================

  private updateRocketFX(intensity: number): void {
    const pulse =
      0.94 +
      Math.sin(this.elapsed * 19) * 0.06;

    const length =
      70 +
      intensity * 110;

    this.rocketEngine.clear();

    // Outer glow.
    this.rocketEngine
      .moveTo(-84, -13)
      .lineTo(-84 - length * 1.25 * pulse, 0)
      .lineTo(-84, 13)
      .closePath()
      .fill({
        color: 0x8d0828,
        alpha: 0.18 + intensity * 0.12,
      });

    // Red flame.
    this.rocketEngine
      .moveTo(-83, -8)
      .lineTo(-83 - length * pulse, 0)
      .lineTo(-83, 8)
      .closePath()
      .fill({
        color: 0xff304a,
        alpha: 0.68 + intensity * 0.2,
      });

    // Hot inner core.
    this.rocketEngine
      .moveTo(-82, -3.5)
      .lineTo(-82 - length * 0.52 * pulse, 0)
      .lineTo(-82, 3.5)
      .closePath()
      .fill({
        color: 0xffc1a9,
        alpha: 0.88,
      });

    this.drawSpeedTrails(intensity);
  }

  private drawSpeedTrails(intensity: number): void {
    this.rocketTrail.clear();

    const baseLength =
      125 + intensity * 230;

    const trails = [
      {
        y: -16,
        width: 2,
        alpha: 0.20,
        length: 0.78,
        color: 0x8c0927,
      },
      {
        y: -9,
        width: 3,
        alpha: 0.35,
        length: 0.95,
        color: 0xbc1233,
      },
      {
        y: -3,
        width: 4,
        alpha: 0.50,
        length: 1.0,
        color: 0xff304a,
      },
      {
        y: 5,
        width: 3,
        alpha: 0.33,
        length: 0.88,
        color: 0xd4193b,
      },
      {
        y: 13,
        width: 2,
        alpha: 0.18,
        length: 0.72,
        color: 0x710720,
      },
    ];

    for (const trail of trails) {
      const wobble =
        Math.sin(
          this.elapsed * 7 +
          trail.y,
        ) * 2;

      this.rocketTrail
        .moveTo(-75, trail.y)
        .bezierCurveTo(
          -130,
          trail.y + wobble,

          -180,
          trail.y * 1.3,

          -75 -
            baseLength *
            trail.length,

          trail.y * 1.6,
        )
        .stroke({
          width:
            trail.width +
            intensity * 1.2,

          color:
            trail.color,

          alpha:
            trail.alpha +
            intensity * 0.08,
        });
    }
  }

  // ============================================================
  // GRAPH
  // ============================================================

  private drawGraph(progress: number): void {
    this.graphOuter.clear();
    this.graphMiddle.clear();
    this.graphMain.clear();

    if (progress <= 0.002) return;

    this.drawSingleGraph(
      this.graphOuter,
      progress,
      18,
      0xd7173b,
      0.10,
    );

    this.drawSingleGraph(
      this.graphMiddle,
      progress,
      9,
      0xf22947,
      0.23,
    );

    this.drawSingleGraph(
      this.graphMain,
      progress,
      4,
      0xff6670,
      0.95,
    );
  }

  private drawSingleGraph(
    graphics: Graphics,
    progress: number,
    width: number,
    color: number,
    alpha: number,
  ): void {
    const samples =
      Math.max(
        10,
        Math.floor(70 * progress),
      );

    for (let i = 0; i <= samples; i++) {
      const p =
        progress *
        (i / samples);

      const point =
        this.getFlightPosition(p);

      if (i === 0) {
        graphics.moveTo(
          point.x,
          point.y,
        );
      } else {
        graphics.lineTo(
          point.x,
          point.y,
        );
      }
    }

    graphics.stroke({
      color,
      width,
      alpha,
    });
  }

  private updateGraphPoint(): void {
    if (this.visualProgress <= 0.005) {
      this.graphPoint.visible = false;
      this.graphPointGlow.visible = false;

      return;
    }

    this.graphPoint.visible = true;
    this.graphPointGlow.visible = true;

    const point =
      this.getFlightPosition(
        this.visualProgress,
      );

    const pulse =
      1 +
      Math.sin(this.elapsed * 6) *
      0.09;

    this.graphPointGlow.clear();

    this.graphPointGlow
      .circle(
        point.x,
        point.y,
        22 * pulse,
      )
      .fill({
        color: 0xff183d,
        alpha: 0.12,
      });

    this.graphPoint.clear();

    this.graphPoint
      .circle(
        point.x,
        point.y,
        6.5 * pulse,
      )
      .fill({
        color: 0xff7a81,
      });
  }

  // ============================================================
  // BACKGROUND / GRID
  // ============================================================

  private drawBackground(): void {
    this.background.clear();

    this.background
      .rect(
        0,
        0,
        this.width,
        this.height,
      )
      .fill({
        color: 0x050812,
      });

    this.ambient.clear();

    // Большие слабые круги создают псевдо-gradient.
    this.ambient
      .circle(
        this.width * 0.74,
        this.height * 0.46,
        this.width * 0.38,
      )
      .fill({
        color: 0x520719,
        alpha: 0.08,
      });

    this.ambient
      .circle(
        this.width * 0.86,
        this.height * 0.30,
        this.width * 0.22,
      )
      .fill({
        color: 0x8b0b28,
        alpha: 0.055,
      });
  }

  private drawGrid(): void {
    this.grid.clear();

    const startX =
      this.width * 0.43;

    const top =
      this.height * 0.08;

    const spacingX =
      Math.max(
        44,
        this.width * 0.095,
      );

    const spacingY =
      Math.max(
        38,
        this.height * 0.17,
      );

    for (
      let x = startX;
      x <= this.width;
      x += spacingX
    ) {
      const relative =
        (x - startX) /
        (this.width - startX);

      this.grid
        .moveTo(x, top)
        .lineTo(x, this.height)
        .stroke({
          color: 0x75152a,
          width: 1,
          alpha:
            0.025 +
            relative * 0.09,
        });
    }

    for (
      let y = top;
      y <= this.height;
      y += spacingY
    ) {
      this.grid
        .moveTo(startX, y)
        .lineTo(this.width, y)
        .stroke({
          color: 0x75152a,
          width: 1,
          alpha: 0.075,
        });
    }
  }

  // ============================================================
  // DEBRIS
  // ============================================================

  private createDebris(): void {
    const count =
      window.innerWidth < 600
        ? 9
        : 15;

    for (let i = 0; i < count; i++) {
      const view =
        this.createDebrisGraphic();

      const depth =
        0.3 + Math.random() * 0.9;

      view.scale.set(
        0.4 +
        depth * 0.7,
      );

      this.debrisLayer.addChild(view);

      this.debris.push({
        view,
        depth,

        vx:
          4 +
          Math.random() * 12,

        vy:
          1 +
          Math.random() * 5,

        rotationSpeed:
          (Math.random() - 0.5) *
          0.35,
      });
    }
  }

  private createDebrisGraphic(): Graphics {
    const g =
      new Graphics();

    const radius =
      6 + Math.random() * 12;

    const points: {
      x: number;
      y: number;
    }[] = [];

    const vertexCount =
      5 +
      Math.floor(Math.random() * 4);

    for (
      let i = 0;
      i < vertexCount;
      i++
    ) {
      const angle =
        (i / vertexCount) *
        Math.PI *
        2;

      const variation =
        0.55 +
        Math.random() * 0.45;

      points.push({
        x:
          Math.cos(angle) *
          radius *
          variation,

        y:
          Math.sin(angle) *
          radius *
          variation,
      });
    }

    g.moveTo(
      points[0].x,
      points[0].y,
    );

    for (
      let i = 1;
      i < points.length;
      i++
    ) {
      g.lineTo(
        points[i].x,
        points[i].y,
      );
    }

    g.closePath().fill({
      color: 0x171019,
    });

    // Красная подсвеченная грань.
    if (points.length >= 3) {
      g
        .moveTo(0, 0)
        .lineTo(
          points[1].x,
          points[1].y,
        )
        .lineTo(
          points[2].x,
          points[2].y,
        )
        .closePath()
        .fill({
          color: 0x79142b,
          alpha:
            0.25 +
            Math.random() * 0.25,
        });
    }

    return g;
  }

  private resetDebrisPosition(
    item: Debris,
    initial = false,
  ): void {
    item.view.x =
      initial
        ? Math.random() * this.width
        : this.width +
          Math.random() *
          this.width *
          0.25;

    item.view.y =
      this.height * 0.12 +
      Math.random() *
      this.height *
      0.80;
  }

  private updateDebris(dt: number): void {
    const intensity =
      this.getVisualIntensity(
        this.displayMultiplier,
      );

    for (
      const item of this.debris
    ) {
      const speed =
        (5 + intensity * 38) *
        item.depth;

      item.view.x -=
        speed * dt;

      item.view.y +=
        speed *
        0.18 *
        dt;

      item.view.rotation +=
        item.rotationSpeed *
        dt;

      if (
        item.view.x < -70 ||
        item.view.y >
          this.height + 70
      ) {
        this.resetDebrisPosition(
          item,
        );
      }
    }
  }

  // ============================================================
  // PARTICLES
  // ============================================================

  private createParticles(): void {
    const count =
      window.innerWidth < 600
        ? 22
        : 38;

    for (let i = 0; i < count; i++) {
      const g =
        new Graphics();

      const radius =
        0.6 +
        Math.random() * 1.8;

      g.circle(0, 0, radius).fill({
        color:
          Math.random() > 0.5
            ? 0xff3049
            : 0xb71632,

        alpha:
          0.15 +
          Math.random() * 0.35,
      });

      const depth =
        0.3 +
        Math.random() * 1.2;

      this.particlesLayer.addChild(g);

      this.particles.push({
        view: g,

        depth,

        vx:
          10 +
          Math.random() * 30,

        vy:
          2 +
          Math.random() * 10,
      });
    }
  }

  private updateParticles(
    dt: number,
  ): void {
    const intensity =
      this.getVisualIntensity(
        this.displayMultiplier,
      );

    for (
      const particle
      of this.particles
    ) {
      const speed =
        particle.vx *
        (0.25 + intensity * 1.5) *
        particle.depth;

      particle.view.x -=
        speed * dt;

      particle.view.y +=
        particle.vy *
        dt *
        (0.3 + intensity);

      if (
        particle.view.x < -10 ||
        particle.view.y >
          this.height + 10
      ) {
        particle.view.x =
          this.width +
          Math.random() * 80;

        particle.view.y =
          Math.random() *
          this.height *
          0.85;
      }
    }
  }

  // ============================================================
  // CRASH
  // ============================================================

  private updateCrash(dt: number): void {
    this.crashElapsed += dt;

    const duration =
      0.42;

    const progress =
      clamp(
        this.crashElapsed /
        duration,
        0,
        1,
      );

    const eased =
      easeInCubic(progress);

    /*
     * При CRASH ракета резко улетает
     * верх-вправо.
     *
     * Никакого взрыва.
     */

    this.rocket.x =
      this.crashStartX +
      this.width *
      0.58 *
      eased;

    this.rocket.y =
      this.crashStartY -
      this.height *
      0.80 *
      eased;

    this.rocket.rotation =
      this.crashStartRotation -
      0.12 * eased;

    this.updateRocketFX(
      clamp(
        0.75 +
        eased * 0.4,
        0,
        1,
      ),
    );

    if (progress >= 1) {
      this.rocket.visible = false;
    }
  }

  // ============================================================
  // IDLE
  // ============================================================

  private updateIdle(): void {
    this.multiplierText.text =
      '1.00x';

    const floating =
      Math.sin(
        this.elapsed * 2.3,
      );

    const start =
      this.getFlightPosition(0);

    this.rocket.position.set(
      start.x,
      start.y +
      floating * 2,
    );

    this.rocket.rotation =
      start.angle +
      floating * 0.004;

    this.updateRocketFX(0.04);
  }

  // ============================================================
  // RESIZE
  // ============================================================

  private resize(): void {
    this.width =
      this.app.screen.width;

    this.height =
      this.app.screen.height;

    this.drawBackground();
    this.drawGrid();

    this.rocket.scale.set(
      this.getRocketScale(),
    );

    this.multiplierText.style.fontSize =
      clamp(
        this.height * 0.20,
        38,
        88,
      );

    /*
     * Коэффициент оставляем примерно
     * в центре игрового поля.
     *
     * Если существующая игра уже
     * рисует multiplier в DOM,
     * при mount() передать:
     *
     * { showMultiplier: false }
     */

    this.multiplierText.position.set(
      this.width * 0.50,
      this.height * 0.56,
    );

    for (
      const item of this.debris
    ) {
      this.resetDebrisPosition(
        item,
        true,
      );
    }

    for (
      const particle
      of this.particles
    ) {
      particle.view.position.set(
        Math.random() *
          this.width,

        Math.random() *
          this.height,
      );
    }

    if (
      this.state === 'flying'
    ) {
      this.positionRocket(
        this.visualProgress,
      );

      this.drawGraph(
        this.visualProgress,
      );

      this.updateGraphPoint();
    } else {
      this.positionRocket(0);
    }
  }
}




У меня уже существует рабочая Crash-игра.

Не создавай игру заново и не меняй backend, ставки, Gold, START,
cashout, crash multiplier и логику раунда.

Встрой CrashAviatorVisual.ts в существующее игровое поле.

Найди в проекте:
1. текущий multiplier;
2. событие начала настоящего раунда;
3. событие настоящего CRASH;
4. reset следующего раунда.

Свяжи их:

round started
→ visual.startRound()

при каждом обновлении существующего multiplier
→ visual.setMultiplier(currentMultiplier)

existing crash event
→ visual.crash()

reset/new round
→ visual.reset()

Если существующий multiplier уже нарисован HTML/CSS,
создай visual через:
CrashAviatorVisual.mount(element, { showMultiplier: false })

Старые белые частицы/старый canvas-визуал удалить или отключить.

Не трогай нижний блок ставки и остальной интерфейс.

Если проект уже использует одну Pixi Application,
не создавай вторую Application:
адаптируй класс под существующий stage/container,
сохранив всю визуальную реализацию.

Используй PixiJS 8 API проекта и исправь несовместимости,
если установленная версия имеет немного другой API Graphics.

Главное:
не переписывай работающую Crash-логику.
Замени только renderer/visual игрового поля.