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, Graphics, Sprite, TilingSprite } from 'pixi.js'
import type { FederatedPointerEvent } from 'pixi.js'
import type { GardenItem, GardenState } from '../api'
import {
  decorTexture,
  houseTexture,
  plantTexture,
  ringTexture,
  selectOutlineTexture,
  shadowTexture,
  sparkleTexture,
  tileTexture,
} from './textures'

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

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
}

interface ItemView {
  root: Container
  shadow: Sprite
  body: Sprite
  ring: Sprite | null
  sparkles: Sprite[]
  item: GardenItem
  growing: boolean
}

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

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

  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.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
    this.root.scale.set(w / this.worldWidth())
  }

  /** Мировые координаты центра клетки. */
  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()

    const wild = new TilingSprite({ texture: tileTexture('wild'), width: W, height: H })
    this.terrain.addChild(wild)

    // газон из супер-тайла 32px (шахматка grass-a/b, период 2 клетки)
    const lawnTex = tileTexture('grass-super')
    const lawn = new TilingSprite({
      texture: lawnTex,
      width: W - LAWN_INSET * 2,
      height: H - LAWN_INSET * 2,
    })
    lawn.position.set(LAWN_INSET, LAWN_INSET)
    this.terrain.addChild(lawn)

    // кустики травы: ~8% клеток детерминированно
    for (let y = 0; y < this.rows; y++)
      for (let x = 0; x < this.cols; x++) {
        if ((x * 7 + y * 11 + this.cols) % 12 !== 0) continue
        const tuft = new Sprite(tileTexture('grass-tuft'))
        const { cx, cy } = this.cellCenter(x, y)
        tuft.anchor.set(0.5)
        tuft.position.set(cx, cy)
        tuft.scale.set(PX)
        this.terrain.addChild(tuft)
      }

    // сетка
    const grid = new Graphics()
    for (let x = 0; x <= this.cols; x++)
      grid.moveTo(x * CELL_WORLD, LAWN_INSET).lineTo(x * CELL_WORLD, H - LAWN_INSET)
    for (let y = 0; y <= this.rows; y++)
      grid.moveTo(LAWN_INSET, y * CELL_WORLD).lineTo(W - LAWN_INSET, y * CELL_WORLD)
    grid.stroke({ color: 0x000000, alpha: 0.07, width: 1 })
    this.terrain.addChild(grid)

    // изгородь по периметру (полосы толщиной с клетку)
    const hedgeTex = tileTexture('hedge')
    const top = new TilingSprite({ texture: hedgeTex, width: W, height: CELL_WORLD / 2 })
    const bottom = new TilingSprite({ texture: hedgeTex, width: W, height: CELL_WORLD / 2 })
    bottom.position.set(0, H - CELL_WORLD / 2)
    const left = new TilingSprite({ texture: hedgeTex, width: H, height: CELL_WORLD / 2 })
    left.position.set(CELL_WORLD / 2, 0)
    left.rotation = Math.PI / 2
    const right = new TilingSprite({ texture: hedgeTex, width: H, height: CELL_WORLD / 2 })
    right.position.set(W - CELL_WORLD / 2, H)
    right.rotation = -Math.PI / 2
    this.terrain.addChild(top, bottom, left, right)
  }

  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 / 56, 32 / 46)
    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()
    }
    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.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 }
    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 (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 textureOf(item: GardenItem) {
    if (item.kind === 'plant') return plantTexture(item.item_key ?? 'ph-flower', item.stage)
    return decorTexture(item.item_key ?? 'flowerbed')
  }

  /** Подогнать тень под текущую текстуру тела (при смене стадии/вида). */
  private fitShadow(view: ItemView): void {
    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,
    }
  }

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

  private readonly onStageMove = (e: FederatedPointerEvent): void => {
    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 => {
    const view = this.dragView()
    const d = this.drag
    this.drag = null
    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.kind === 'plant' ? item.id : null
      this.cb.onSelect(this.selectedId)
      return
    }
    const local = this.root.toLocal(e.global)
    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 => {
    // клик по пустому месту закрывает поповер (target === stage)
    if (e.target === this.app.stage) {
      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 = rect.width / this.worldWidth()
    return this.cellOfWorld((clientX - rect.left) / scale, (clientY - rect.top) / scale)
  }

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

  /** Подсветка целевой клетки при 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.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)
      }
    }
  }
}