Newer
Older
gnexus-tasks / frontend / src / game / GardenRenderer.ts
// PixiJS-рендерер сцены сада (ТЗ 3.13): пиксель-арт на WebGL.
// Вся Pixi-логика здесь; GardenScene.vue — тонкая обёртка (DOM-инвентарь,
// поповер, drag-ghost). Клетка = 32 мировых px (матрица 16px × 2).
import { Application, Container, Sprite, TilingSprite } from 'pixi.js'
import type { FederatedPointerEvent, Texture } from 'pixi.js'
import type { GardenItem, GardenState } from '../api'
import {
  decorTexture,
  fenceTexture,
  houseTexture,
  plantTexture,
  ringTexture,
  selectOutlineTexture,
  shadowTexture,
  sparkleTexture,
  tileTexture,
} from './textures'

export const CELL_WORLD = 32 // мировых px на клетку (16px матрица × 2)
const PX = 2 // масштаб «нативного пикселя»

export interface GardenRendererCallbacks {
  /** Перемещение: x/y null — бросок в инвентарь (только декорации). */
  onMove(id: number, x: number | null, y: number | null): void
  /** Клик по растению (поповер) или мимо (id null — закрыть). */
  onSelect(id: number | null): void
  /** DOM-rect полосы инвентаря (для броска декорации с карты в инвентарь). */
  inventoryRect(): DOMRect | null
  /** Начало/конец перетаскивания по канвасу (ТЗ 3.14: подавить refetch на жесте). */
  onDragChange?(dragging: boolean): void
}

interface ItemView {
  root: Container
  shadow: Sprite
  body: Sprite
  ring: Sprite | null
  sparkles: Sprite[]
  item: GardenItem
  growing: boolean
  /** Маска соединений забора, по которой построена текущая текстура. */
  fenceMask: number
}

interface Tween {
  until: number
  apply: (progress: number) => void
  done: () => void
}

export class GardenRenderer {
  private app = new Application()
  private cb: GardenRendererCallbacks
  private cols = 0
  private rows = 0
  private disposed = false

  private root = new Container()
  private terrain = new Container()
  private houseLayer = new Container()
  private items = new Container()
  private fx = new Container()

  private views = new Map<number, ItemView>()
  private selectedId: number | null = null
  private hoverOutline: Sprite | null = null
  // клетки с забором (для самосоединения секций): "x,y" → маска соседей
  private fenceCells = new Set<string>()

  private drag: {
    id: number
    moved: boolean
    startClientX: number
    startClientY: number
  } | null = null
  private tweens: Tween[] = []
  private time = 0

  // Зум/панорама: масштаб root = baseScale (fit по ширине) × zoom,
  // panOffset — сдвиг в экранных px (0 при zoom = 1)
  private zoom = 1
  private baseScale = 1
  private panOffset = { x: 0, y: 0 }
  // базовый сдвиг центрирования (контент меньше экрана) — вне panOffset
  private baseOffset = { x: 0, y: 0 }
  private panning: { sx: number; sy: number; px: number; py: number } | null = null

  private constructor(cb: GardenRendererCallbacks) {
    this.cb = cb
  }

  static async create(
    host: HTMLElement,
    state: GardenState,
    cb: GardenRendererCallbacks,
  ): Promise<GardenRenderer> {
    const renderer = new GardenRenderer(cb)
    await renderer.app.init({
      preference: 'webgl',
      antialias: false,
      roundPixels: true,
      backgroundAlpha: 0,
      resolution: Math.min(window.devicePixelRatio || 1, 2),
      autoDensity: true,
      width: Math.max(1, host.clientWidth),
      height: Math.max(1, host.clientHeight),
    })
    if (renderer.disposed) {
      renderer.app.destroy(true, { children: true, texture: false, textureSource: false })
      return renderer
    }
    renderer.setup(host, state)
    return renderer
  }

  private setup(host: HTMLElement, state: GardenState): void {
    const app = this.app
    app.canvas.style.touchAction = 'none'
    app.canvas.style.display = 'block'
    app.canvas.style.width = '100%'
    app.canvas.style.height = '100%'
    app.stage.eventMode = 'static'
    app.stage.hitArea = app.screen

    app.stage.addChild(this.root)
    this.root.addChild(this.terrain, this.houseLayer, this.items, this.fx)
    this.items.sortableChildren = true

    this.cols = state.grid.cols
    this.rows = state.grid.rows
    this.rebuildTerrain()
    this.buildHouse()
    this.syncState(state)
    this.applyRootScale(host)

    app.stage.on('pointermove', this.onStageMove)
    app.stage.on('pointerup', this.onStageUp)
    app.stage.on('pointerupoutside', this.onStageUp)
    app.stage.on('pointerdown', this.onStageDown)

    this.resizeObserver = new ResizeObserver(() => this.onHostResize(host))
    this.resizeObserver.observe(host)
    app.canvas.addEventListener('wheel', this.onWheel, { passive: false })

    app.ticker.add(this.onTick)
    host.appendChild(app.canvas)
  }

  private resizeObserver: ResizeObserver | null = null

  // --- Геометрия ---

  private worldWidth(): number {
    return this.cols * CELL_WORLD
  }
  private worldHeight(): number {
    return this.rows * CELL_WORLD
  }

  private applyRootScale(host: HTMLElement): void {
    const w = host.clientWidth || 1
    const h = host.clientHeight || 1
    // fit по меньшей стороне: в fullscreen на вертикальном телефоне бокс не
    // совпадает по аспекту с сеткой — вписываем сад целиком (в обычном режиме
    // бокс следует аспекту сетки, так что это эквивалентно fit по ширине)
    this.baseScale = Math.min(w / this.worldWidth(), h / this.worldHeight())
    this.applyView()
  }

  /** Применить зум и панораму к корневому контейнеру. */
  private applyView(): void {
    this.root.scale.set(this.baseScale * this.zoom)
    // контент меньше экрана — центрируем (fullscreen на вертикальном телефоне);
    // panOffset остаётся нулевым, базовый сдвиг живёт отдельно
    const cw = this.worldWidth() * this.baseScale * this.zoom
    const ch = this.worldHeight() * this.baseScale * this.zoom
    this.baseOffset.x = Math.max(0, (this.app.screen.width - cw) / 2)
    this.baseOffset.y = Math.max(0, (this.app.screen.height - ch) / 2)
    this.root.position.set(
      this.panOffset.x + this.baseOffset.x,
      this.panOffset.y + this.baseOffset.y,
    )
  }

  /** Ограничить панораму: контент не должен уходить с экрана. */
  private clampPan(): void {
    const w = this.app.screen.width
    const h = this.app.screen.height
    const cw = this.worldWidth() * this.baseScale * this.zoom
    const ch = this.worldHeight() * this.baseScale * this.zoom
    this.panOffset.x = Math.max(w - cw, Math.min(0, this.panOffset.x))
    // по вертикали, пока контент меньше экрана — прижат кверху (как без зума)
    this.panOffset.y = ch >= h ? Math.max(h - ch, Math.min(0, this.panOffset.y)) : 0
  }

  /** Сменить зум, сохранив точку мира под экранными координатами (ax, ay). */
  private applyZoom(next: number, ax: number, ay: number): void {
    const scale = this.baseScale * this.zoom
    const wx = (ax - this.panOffset.x - this.baseOffset.x) / scale
    const wy = (ay - this.panOffset.y - this.baseOffset.y) / scale
    this.zoom = Math.max(1, Math.min(4, next))
    const ns = this.baseScale * this.zoom
    this.panOffset.x = ax - wx * ns - this.baseOffset.x
    this.panOffset.y = ay - wy * ns - this.baseOffset.y
    this.clampPan()
    this.applyView()
  }

  /** Кнопки зума: якорь — центр экрана. */
  zoomIn(): void {
    this.applyZoom(this.zoom * 1.25, this.app.screen.width / 2, this.app.screen.height / 2)
  }

  zoomOut(): void {
    this.applyZoom(this.zoom / 1.25, this.app.screen.width / 2, this.app.screen.height / 2)
  }

  /** Сброс: fit по меньшей стороне, без сдвига. */
  resetView(): void {
    this.zoom = 1
    this.panOffset = { x: 0, y: 0 }
    this.applyView()
  }

  private readonly onWheel = (e: WheelEvent): void => {
    e.preventDefault()
    const rect = this.app.canvas.getBoundingClientRect()
    this.applyZoom(this.zoom * (e.deltaY < 0 ? 1.2 : 1 / 1.2), e.clientX - rect.left, e.clientY - rect.top)
  }

  /** Мировые координаты центра клетки. */
  private cellCenter(x: number, y: number): { cx: number; cy: number } {
    return { cx: x * CELL_WORLD + CELL_WORLD / 2, cy: y * CELL_WORLD + CELL_WORLD / 2 }
  }

  /** Клетка из мировых координат (clamped). */
  private cellOfWorld(wx: number, wy: number): { x: number; y: number } {
    return {
      x: Math.max(0, Math.min(this.cols - 1, Math.floor(wx / CELL_WORLD))),
      y: Math.max(0, Math.min(this.rows - 1, Math.floor(wy / CELL_WORLD))),
    }
  }

  // --- Слои terrain/house (пересобираются только при смене сетки) ---

  private rebuildTerrain(): void {
    this.terrain.removeChildren().forEach((c) => c.destroy())
    const W = this.worldWidth()
    const H = this.worldHeight()

    // земля с запасом за края сетки: в fullscreen (вертикальный телефон) сад
    // меньше экрана — вокруг поля земля, а не фон страницы; в обычном режиме
    // канвас совпадает с сеткой, запас не виден
    const m = CELL_WORLD * 32

    const wild = new TilingSprite({
      texture: tileTexture('wild'),
      width: W + m * 2,
      height: H + m * 2,
    })
    wild.position.set(-m, -m)
    this.terrain.addChild(wild)

    // газон из супер-тайла 64px (4 тайла, период 4 клетки) — на всё поле
    const lawnTex = tileTexture('grass-super')
    const lawn = new TilingSprite({
      texture: lawnTex,
      width: W + m * 2,
      height: H + m * 2,
    })
    lawn.position.set(-m, -m)
    this.terrain.addChild(lawn)

    // кустики травы: ~8% клеток, два вида (пучок / с цветком);
    // раскидка по целочисленному хешу клетки — без видимого паттерна
    for (let y = 0; y < this.rows; y++)
      for (let x = 0; x < this.cols; x++) {
        const h = GardenRenderer.hash2(x, y)
        if (h % 100 >= 8) continue
        const tex = (h >>> 8) % 4 === 0 ? tileTexture('grass-daisy') : tileTexture('grass-tuft')
        const tuft = new Sprite(tex)
        const { cx, cy } = this.cellCenter(x, y)
        tuft.anchor.set(0.5)
        // небольшой детерминированный сдвиг внутри клетки — живее
        tuft.position.set(cx + ((h >>> 16) % 9) - 4, cy + ((h >>> 20) % 7) - 3)
        tuft.scale.set(PX)
        this.terrain.addChild(tuft)
      }
  }

  /** Целочисленный хеш пары координат (раскидка декора без паттернов). */
  private static hash2(x: number, y: number): number {
    let h = x * 374761393 + y * 668265263
    h = (h ^ (h >> 13)) * 1274126177
    return (h ^ (h >> 16)) >>> 0
  }

  private buildHouse(): void {
    this.houseLayer.removeChildren().forEach((c) => c.destroy())
    // изометрическая тень — эллипс под основанием домика
    const shadow = new Sprite(shadowTexture())
    shadow.anchor.set(0.5)
    shadow.alpha = 0.5
    shadow.scale.set(116 / 22, 44 / 8)
    shadow.position.set(this.worldWidth() / 2, this.worldHeight() / 2 + 8)
    this.houseLayer.addChild(shadow)
    const house = new Sprite(houseTexture())
    house.scale.set(PX)
    // anchor — центр основания ромба в матрице HOUSE (27, 32)
    house.anchor.set(27 / 62, 32 / 52)
    house.position.set(this.worldWidth() / 2, this.worldHeight() / 2)
    this.houseLayer.addChild(house)
  }

  // --- Дифф элементов ---

  private itemChanged(a: GardenItem, b: GardenItem): boolean {
    return (
      a.x !== b.x ||
      a.y !== b.y ||
      a.stage !== b.stage ||
      a.rarity !== b.rarity ||
      a.item_key !== b.item_key ||
      a.kind !== b.kind
    )
  }

  /** Обновить сцену из нового состояния (родитель перезагружает state после API). */
  syncState(state: GardenState): void {
    if (this.disposed) return
    if (state.grid.cols !== this.cols || state.grid.rows !== this.rows) {
      this.cols = state.grid.cols
      this.rows = state.grid.rows
      this.rebuildTerrain()
      this.buildHouse()
    }
    this.syncFences(state)
    const seen = new Set<number>()
    for (const item of state.items) {
      if (item.x === null || item.y === null) {
        this.removeView(item.id)
        continue
      }
      seen.add(item.id)
      const view = this.views.get(item.id)
      if (!view) this.addView(item)
      // у соединяемых секций (забор/дорожка) текстура зависит и от соседей
      else if (
        this.itemChanged(view.item, item) ||
        (this.isLinked(item) && this.linkMaskOf(item) !== view.fenceMask)
      )
        this.updateView(view, item)
    }
    for (const id of [...this.views.keys()]) if (!seen.has(id)) this.removeView(id)
  }

  private addView(item: GardenItem): void {
    const rootC = new Container()
    const { cx, cy } = this.cellCenter(item.x ?? 0, item.y ?? 0)
    rootC.position.set(cx, cy)
    rootC.zIndex = (item.y ?? 0) * 1000 + (item.x ?? 0)

    // мягкая тень под объектом (ширина — по телу)
    const shadow = new Sprite(shadowTexture())
    shadow.anchor.set(0.5)
    shadow.alpha = 0.45
    rootC.addChild(shadow)

    let ring: Sprite | null = null
    if (item.kind === 'plant' && (item.rarity === 'rare' || item.rarity === 'epic')) {
      ring = new Sprite(ringTexture(item.rarity ?? 'rare'))
      ring.anchor.set(0.5)
      ring.position.set(0, -2)
      ring.scale.set(PX)
      rootC.addChild(ring)
    }

    const body = new Sprite(this.textureOf(item))
    if (item.kind === 'plant') body.anchor.set(0.5, 0.95)
    else body.anchor.set(0.5, 0.75)
    body.scale.set(PX)
    body.eventMode = 'static'
    body.cursor = 'grab'
    body.on('pointerdown', (e) => this.beginDrag(item.id, e))
    rootC.addChild(body)

    const view: ItemView = {
      root: rootC,
      shadow,
      body,
      ring,
      sparkles: [],
      item: { ...item },
      growing: true,
      fenceMask: this.isLinked(item) ? this.linkMaskOf(item) : -1,
    }
    this.fitShadow(view)

    const sparkles: Sprite[] = []
    if (item.kind === 'plant' && item.rarity === 'epic') {
      for (let i = 0; i < 3; i++) {
        const s = new Sprite(sparkleTexture())
        s.anchor.set(0.5)
        s.scale.set(PX)
        s.position.set(-14 + i * 14, -20 - (i % 2) * 10)
        rootC.addChild(s)
        sparkles.push(s)
      }
    }
    view.sparkles = sparkles

    this.items.addChild(rootC)
    this.views.set(item.id, view)
    // появление: рост от земли (scale контейнера от 0.2 до 1)
    this.tweens.push({
      until: 0.35,
      apply: (p) => {
        rootC.scale.set(0.2 + 0.8 * p)
      },
      done: () => {
        rootC.scale.set(1)
      },
    })
  }

  private updateView(view: ItemView, item: GardenItem): void {
    const moved =
      item.x !== view.item.x ||
      item.y !== view.item.y
    view.item = { ...item }
    if (moved) {
      const { cx, cy } = this.cellCenter(item.x ?? 0, item.y ?? 0)
      view.root.position.set(cx, cy)
      view.root.zIndex = (item.y ?? 0) * 1000 + (item.x ?? 0)
    }
    if (item.kind === 'plant') view.body.texture = plantTexture(item.item_key ?? '', item.stage)
    else if (this.isLinked(item)) {
      view.body.texture = this.linkTexture(item)
      view.fenceMask = this.linkMaskOf(item)
    } else if (item.item_key) view.body.texture = decorTexture(item.item_key)
    this.fitShadow(view)
  }

  private removeView(id: number): void {
    const view = this.views.get(id)
    if (!view) return
    this.views.delete(id)
    if (this.selectedId === id) {
      this.selectedId = null
      this.cb.onSelect(null)
    }
    view.root.destroy({ children: true })
  }

  /** Клетки соединяемых секций (забор) из нового состояния. */
  private syncFences(state: GardenState): void {
    this.fenceCells = new Set(
      state.items
        .filter((i) => i.kind === 'decoration' && i.item_key === 'fence')
        .map((i) => `${i.x},${i.y}`),
    )
  }

  /** Секция забора, размещённая на карте. */
  private isLinked(item: GardenItem): boolean {
    return item.kind === 'decoration' && item.item_key === 'fence' && item.x !== null && item.y !== null
  }

  /** Маска соединений секции с соседями: N=1, E=2, S=4, W=8. */
  private linkMaskOf(item: GardenItem): number {
    const cells = this.fenceCells
    const x = item.x ?? 0
    const y = item.y ?? 0
    const at = (cx: number, cy: number): boolean => cells.has(`${cx},${cy}`)
    return (at(x, y - 1) ? 1 : 0) | (at(x + 1, y) ? 2 : 0) | (at(x, y + 1) ? 4 : 0) | (at(x - 1, y) ? 8 : 0)
  }

  private linkTexture(item: GardenItem): Texture {
    return fenceTexture(this.linkMaskOf(item))
  }

  private textureOf(item: GardenItem) {
    if (item.kind === 'plant') return plantTexture(item.item_key ?? 'ph-flower', item.stage)
    if (this.isLinked(item)) return this.linkTexture(item)
    return decorTexture(item.item_key ?? 'flowerbed')
  }

  /** Подогнать тень под текущую текстуру тела (при смене стадии/вида).
   *  У «плоских» декораций (озеро, дорожка) тени нет — она лежит в самой текстуре. */
  private fitShadow(view: ItemView): void {
    view.shadow.visible = view.item.item_key !== 'pond' // тень лежит в текстуре
    if (!view.shadow.visible) return
    const w = view.body.texture.width * PX * 0.9 + 4
    view.shadow.scale.set(w / 24, (w / 24) * 0.7)
    view.shadow.position.set(0, 2)
  }

  // --- Drag & drop на канвасе ---

  private beginDrag(id: number, e: FederatedPointerEvent): void {
    this.drag = {
      id,
      moved: false,
      startClientX: e.globalX,
      startClientY: e.globalY,
    }
    this.cb.onDragChange?.(true)
  }

  private dragView(): ItemView | null {
    if (!this.drag) return null
    return this.views.get(this.drag.id) ?? null
  }

  private readonly onStageMove = (e: FederatedPointerEvent): void => {
    // панорама: drag по пустому месту при зуме
    if (this.panning) {
      const p = this.panning
      this.panOffset.x = p.px + (e.globalX - p.sx)
      this.panOffset.y = p.py + (e.globalY - p.sy)
      this.clampPan()
      this.applyView()
      return
    }
    const view = this.dragView()
    if (!view) return
    const d = this.drag!
    if (!d.moved && Math.hypot(e.globalX - d.startClientX, e.globalY - d.startClientY) < 4) return
    d.moved = true
    const local = this.root.toLocal(e.global)
    const cell = this.cellOfWorld(local.x, local.y)
    const { cx, cy } = this.cellCenter(cell.x, cell.y)
    // визуальный сдвиг на клетку; курсор чуть «поднимает» элемент
    view.root.position.set(cx, cy - 6)
    view.body.alpha = 0.85
    this.setHoverCell(cell)
    view.root.zIndex = 1_000_000
  }

  private readonly onStageUp = (e: FederatedPointerEvent): void => {
    if (this.panning) {
      this.panning = null
      return
    }
    const view = this.dragView()
    const d = this.drag
    this.drag = null
    if (d) this.cb.onDragChange?.(false)
    if (!view || !d) return
    view.body.alpha = 1
    const item = view.item
    const restoreZ = (it: GardenItem): void => {
      view.root.zIndex = (it.y ?? 0) * 1000 + (it.x ?? 0)
    }
    if (!d.moved) {
      restoreZ(item)
      // клик: выбор объекта — поповер у растений, подпись имени у любых предметов
      this.selectedId = item.id
      this.cb.onSelect(this.selectedId)
      return
    }
    const local = this.root.toLocal(e.global)
    // бросок мимо сада (за краем мира) — позицию не меняем: cellOfWorld клампит
    // координаты и без этой проверки предмет «прилипал» бы к ближайшему краю
    if (local.x < 0 || local.y < 0 || local.x >= this.worldWidth() || local.y >= this.worldHeight()) {
      this.setHoverCell(null)
      const { cx, cy } = this.cellCenter(item.x ?? 0, item.y ?? 0)
      view.root.position.set(cx, cy)
      restoreZ(item)
      return
    }
    const inv = this.cb.inventoryRect()
    if (inv && item.kind === 'decoration' && this.clientIn(e, inv)) {
      this.setHoverCell(null)
      this.cb.onMove(item.id, null, null)
      return
    }
    const cell = this.cellOfWorld(local.x, local.y)
    this.setHoverCell(null)
    if (cell.x !== item.x || cell.y !== item.y) {
      // оптимистичная позиция до перезагрузки state
      const { cx, cy } = this.cellCenter(cell.x, cell.y)
      view.root.position.set(cx, cy)
      view.item.x = cell.x
      view.item.y = cell.y
      this.cb.onMove(item.id, cell.x, cell.y)
    }
    restoreZ(view.item)
  }

  private clientIn(e: FederatedPointerEvent, rect: DOMRect): boolean {
    const canvasRect = this.app.canvas.getBoundingClientRect()
    const px = canvasRect.left + e.globalX
    const py = canvasRect.top + e.globalY
    return px >= rect.left && px <= rect.right && py >= rect.top && py <= rect.bottom
  }

  private readonly onStageDown = (e: FederatedPointerEvent): void => {
    // клик по пустому месту: панорама при зуме, иначе закрыть поповер
    if (e.target === this.app.stage) {
      if (this.zoom > 1)
        this.panning = { sx: e.globalX, sy: e.globalY, px: this.panOffset.x, py: this.panOffset.y }
      this.selectedId = null
      this.cb.onSelect(null)
    }
  }

  // --- Наружу для Vue-части ---

  /** Клетка из client-координат (бросок из инвентаря). */
  cellFromClient(clientX: number, clientY: number): { x: number; y: number } | null {
    const rect = this.app.canvas.getBoundingClientRect()
    if (
      clientX < rect.left ||
      clientX > rect.right ||
      clientY < rect.top ||
      clientY > rect.bottom
    )
      return null
    const scale = this.baseScale * this.zoom
    return this.cellOfWorld(
      (clientX - rect.left - this.panOffset.x - this.baseOffset.x) / scale,
      (clientY - rect.top - this.panOffset.y - this.baseOffset.y) / scale,
    )
  }

  /** Экранные координаты центра клетки (позиция поповера). */
  clientPointOfCell(x: number, y: number): { x: number; y: number } {
    const rect = this.app.canvas.getBoundingClientRect()
    const scale = this.baseScale * this.zoom
    const { cx, cy } = this.cellCenter(x, y)
    return {
      x: rect.left + cx * scale + this.panOffset.x + this.baseOffset.x,
      y: rect.top + cy * scale + this.panOffset.y + this.baseOffset.y,
    }
  }

  /** Подсветка целевой клетки при drag из инвентаря. */
  setHoverCell(cell: { x: number; y: number } | null): void {
    if (!cell) {
      this.hoverOutline?.destroy()
      this.hoverOutline = null
      return
    }
    if (!this.hoverOutline) {
      this.hoverOutline = new Sprite(selectOutlineTexture())
      this.hoverOutline.alpha = 0.4
      this.hoverOutline.scale.set(PX)
      this.fx.addChild(this.hoverOutline)
    }
    this.hoverOutline.position.set(cell.x * CELL_WORLD, cell.y * CELL_WORLD)
  }

  /** Выделение (поповер роста). */
  setSelected(id: number | null): void {
    this.selectedId = id
  }

  private onHostResize(host: HTMLElement): void {
    if (this.disposed) return
    const w = Math.max(1, host.clientWidth)
    const h = Math.max(1, host.clientHeight)
    this.app.renderer.resize(w, h)
    this.applyRootScale(host)
  }

  destroy(): void {
    this.disposed = true
    this.resizeObserver?.disconnect()
    this.app.canvas.removeEventListener('wheel', this.onWheel)
    this.app.stage.off('pointermove', this.onStageMove)
    this.app.stage.off('pointerup', this.onStageUp)
    this.app.stage.off('pointerupoutside', this.onStageUp)
    this.app.stage.off('pointerdown', this.onStageDown)
    this.app.ticker.remove(this.onTick)
    this.app.destroy(true, { children: true, texture: false, textureSource: false })
  }

  // --- Анимации (один тикер) ---

  private readonly onTick = (): void => {
    if (this.disposed) return
    this.time += this.app.ticker.deltaMS / 1000
    // покачивание взрослых растений и пульс блёсток эпика
    for (const view of this.views.values()) {
      if (view.item.kind === 'plant' && view.item.stage >= 2 && !this.drag?.moved)
        view.body.rotation = Math.sin(this.time * 2 + view.item.id) * 0.03
      for (let i = 0; i < view.sparkles.length; i++)
        view.sparkles[i]!.alpha =
          0.4 + 0.6 * Math.abs(Math.sin(this.time * 3 + view.item.id + i * 2.1))
    }
    // твины (grow-in)
    const total = 0.35
    for (let i = this.tweens.length - 1; i >= 0; i--) {
      const t = this.tweens[i]!
      t.until -= this.app.ticker.deltaMS / 1000
      t.apply(Math.min(1, 1 - t.until / total))
      if (t.until <= 0) {
        t.done()
        this.tweens.splice(i, 1)
      }
    }
  }
}