Newer
Older
gnexus-tasks / frontend / src / components / GardenScene.vue
<script setup lang="ts">
// Сцена сада (ТЗ 3.13): пиксель-арт на PixiJS (WebGL) — рендер в src/game/.
// Этот компонент — тонкая обёртка: canvas-host, инвентарь-полоса (DOM),
// поповер роста (DOM-оверлей) и drag из инвентаря. Растение = закрытая задача.
import { computed, onBeforeUnmount, onMounted, ref, shallowRef, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import type { GardenItem, GardenState } from '../api'
import { dataUrlFor } from '../game/pixelart'
import { DECOR, PAL } from '../game/sprites'
import type { GardenRenderer } from '../game/GardenRenderer'

defineOptions({ name: 'GardenScene' })

const props = defineProps<{
  state: GardenState
  balance: number
}>()

const emit = defineEmits<{
  move: [id: number, x: number | null, y: number | null]
  upgrade: [id: number]
}>()

const { t } = useI18n()

const hostEl = ref<HTMLElement | null>(null)
const invEl = ref<HTMLElement | null>(null)
// shallowRef: внутри рендерера — Pixi-объекты; deep-reactive-прокси Vue ломает
// identity-проверки Pixi (shared defaultScale) → TypeError при первом setHoverCell.
const renderer = shallowRef<GardenRenderer | null>(null)
const selected = ref<number | null>(null)

// Неразмещенные декорации (x/y null) — в инвентаре под сценой.
const inventoryItems = computed(() =>
  props.state.items.filter((i) => i.x === null || i.y === null),
)
const selectedItem = computed(
  () => props.state.items.find((i) => i.id === selected.value) ?? null,
)

// --- Превью чипов/ghost из тех же матриц, что рендерит Pixi ---

const previewCache = new Map<string, string>()

function chipPreview(item: GardenItem): string {
  const key = item.item_key ?? 'flowerbed'
  const hit = previewCache.get(key)
  if (hit) return hit
  const url = dataUrlFor(DECOR[key] ?? DECOR.flowerbed!, PAL, 3)
  previewCache.set(key, url)
  return url
}

// --- Цена следующей стадии роста (цены из state, редкость растения) ---

function upgradeCost(item: GardenItem): number | null {
  if (item.kind !== 'plant' || item.stage >= 2) return null
  const pair = props.state.upgrade_costs[item.rarity ?? 'common']
  return pair ? pair[item.stage] : null
}

function onUpgrade(item: GardenItem): void {
  emit('upgrade', item.id)
  selected.value = null
}

// --- Поповер (DOM поверх canvas) ---

const popoverStyle = computed(() => {
  const item = selectedItem.value
  const r = renderer.value
  if (!item || item.x === null || item.y === null || !r) return {}
  const p = r.clientPointOfCell(item.x, item.y)
  // клэмп: карточка ~150×44, не выезжать за вьюпорт
  const left = Math.max(8, Math.min(window.innerWidth - 160, p.x - 75))
  const top = Math.max(8, p.y - 70)
  return { left: `${left}px`, top: `${top}px` }
})

function closePopover(): void {
  selected.value = null
  renderer.value?.setSelected(null)
}

function onKeydown(e: KeyboardEvent): void {
  if (e.key === 'Escape') closePopover()
}
function onScroll(): void {
  closePopover()
}

watch(selected, (id) => renderer.value?.setSelected(id))

// --- Синхронизация состояния (родитель перезагружает state после API) ---

watch(
  () => props.state,
  (st) => renderer.value?.syncState(st),
  { deep: false },
)

// --- Drag из инвентаря: призрак у курсора, бросок на карту размещает ---

const ghost = ref<{ id: number; key: string; x: number; y: number } | null>(null)
const ghostSrc = computed(() =>
  ghost.value ? chipPreview({ item_key: ghost.value.key } as GardenItem) : '',
)

function onInvPointerDown(item: GardenItem, e: PointerEvent): void {
  e.preventDefault()
  ghost.value = { id: item.id, key: item.item_key ?? 'flowerbed', x: e.clientX, y: e.clientY }
  window.addEventListener('pointermove', onInvMove)
  window.addEventListener('pointerup', onInvUp)
}

function onInvMove(e: PointerEvent): void {
  if (!ghost.value) return
  ghost.value.x = e.clientX
  ghost.value.y = e.clientY
  const cell = renderer.value?.cellFromClient(e.clientX, e.clientY) ?? null
  renderer.value?.setHoverCell(cell)
}

function onInvUp(e: PointerEvent): void {
  window.removeEventListener('pointermove', onInvMove)
  window.removeEventListener('pointerup', onInvUp)
  const id = ghost.value?.id
  ghost.value = null
  renderer.value?.setHoverCell(null)
  if (id === undefined) return
  const cell = renderer.value?.cellFromClient(e.clientX, e.clientY)
  if (cell) {
    emit('move', id, cell.x, cell.y)
    // оптимистичная позиция не нужна: бэк подтвердит при load()
  }
  // Бросок мимо карты — остаётся в инвентаре
}

// --- Жизненный цикл ---

onMounted(async () => {
  window.addEventListener('keydown', onKeydown)
  window.addEventListener('scroll', onScroll, { capture: true, passive: true })
  const { GardenRenderer: R } = await import('../game/GardenRenderer')
  if (!hostEl.value) return
  renderer.value = await R.create(hostEl.value, props.state, {
    onMove: (id, x, y) => emit('move', id, x, y),
    onSelect: (id) => {
      selected.value = id
    },
    inventoryRect: () => invEl.value?.getBoundingClientRect() ?? null,
  })
})

onBeforeUnmount(() => {
  window.removeEventListener('keydown', onKeydown)
  window.removeEventListener('scroll', onScroll, { capture: true })
  window.removeEventListener('pointermove', onInvMove)
  window.removeEventListener('pointerup', onInvUp)
  renderer.value?.destroy()
  renderer.value = null
})
</script>

<template>
  <div class="scene-wrap">
    <!-- Canvas-host: аспект по сетке, рендерер сам растягивает canvas на 100% -->
    <div
      ref="hostEl"
      class="scene"
      :style="{ aspectRatio: `${state.grid.cols} / ${state.grid.rows}` }"
    />

    <!-- Поповер роста: DOM поверх canvas, фиксированный у клетки растения -->
    <div
      v-if="selectedItem && selectedItem.kind === 'plant'"
      class="popover-card"
      :style="popoverStyle"
      @pointerdown.stop
    >
      <template v-if="upgradeCost(selectedItem) !== null">
        <button
          class="popover-btn"
          :disabled="balance < (upgradeCost(selectedItem) ?? 0)"
          @click.stop="onUpgrade(selectedItem)"
        >
          <i class="ph ph-arrow-fat-up" aria-hidden="true" />
          {{ t('garden.upgrade', { n: upgradeCost(selectedItem) }) }}
        </button>
        <span v-if="balance < (upgradeCost(selectedItem) ?? 0)" class="popover-hint">
          {{ t('garden.notEnoughCoins', { n: (upgradeCost(selectedItem) ?? 0) - balance }) }}
        </span>
      </template>
      <span v-else class="popover-max">
        <i class="ph ph-check-circle" aria-hidden="true" />
        {{ t('garden.maxStage') }}
      </span>
    </div>

    <!-- Инвентарь: купленные, но не размещённые декорации — drag на карту -->
    <div v-if="inventoryItems.length" ref="invEl" class="inventory">
      <span class="inv-label">
        <i class="ph ph-package" aria-hidden="true" />
        {{ t('garden.inventory') }}
      </span>
      <div
        v-for="item in inventoryItems"
        :key="item.id"
        class="inv-chip"
        :title="item.item_key ? t(`garden.decorNames.${item.item_key}`) : ''"
        @pointerdown="onInvPointerDown(item, $event)"
      >
        <img :src="chipPreview(item)" alt="" class="inv-img" />
        <span>{{ item.item_key ? t(`garden.decorNames.${item.item_key}`) : '' }}</span>
      </div>
      <span class="inv-hint">{{ t('garden.inventoryHint') }}</span>
    </div>

    <!-- Призрак перетаскивания из инвентаря -->
    <div v-if="ghost" class="drag-ghost" :style="{ left: `${ghost.x}px`, top: `${ghost.y}px` }">
      <img :src="ghostSrc" alt="" />
    </div>
  </div>
</template>

<style scoped>
.scene-wrap {
  margin-bottom: 2rem;
}
.scene {
  width: 100%;
  border-radius: 12px;
  border: 1px solid var(--border, #2a2f45);
  overflow: hidden;
  cursor: default;
}
/* Поповер роста: fixed у клетки растения */
.popover-card {
  position: fixed;
  z-index: 20;
  display: flex;
  flex-direction: column;
  gap: 3px;
  padding: 5px;
  border-radius: 8px;
  background: var(--bg-card, #24283b);
  border: 1px solid var(--border, #2a2f45);
  font-size: 12px;
  line-height: 1.2;
  box-shadow: 0 4px 14px rgba(0, 0, 0, 0.4);
}
.popover-btn {
  display: flex;
  gap: 4px;
  align-items: center;
  padding: 3px 7px;
  border: none;
  border-radius: 6px;
  background: var(--success, #9ece6a);
  color: #1a1f2e;
  font-weight: 600;
  font-size: 12px;
  cursor: pointer;
  white-space: nowrap;
}
.popover-btn:disabled {
  opacity: 0.5;
  cursor: not-allowed;
}
.popover-hint {
  color: #f7768e;
  font-size: 10px;
  white-space: nowrap;
}
.popover-max {
  display: flex;
  gap: 4px;
  align-items: center;
  color: var(--text-muted, #888);
  font-size: 10px;
  white-space: nowrap;
}
/* Инвентарь: купленные, но не размещённые декорации */
.inventory {
  display: flex;
  flex-wrap: wrap;
  gap: 0.5rem;
  align-items: center;
  margin-top: 0.6rem;
  padding: 0.6rem 0.75rem;
  border: 1px dashed var(--border, #2a2f45);
  border-radius: 10px;
}
.inv-label {
  display: flex;
  gap: 0.35rem;
  align-items: center;
  font-weight: 600;
  font-size: 0.88em;
}
.inv-label > i {
  color: var(--accent, #7aa2f7);
}
.inv-chip {
  display: flex;
  gap: 0.3rem;
  align-items: center;
  padding: 0.25rem 0.6rem 0.25rem 0.35rem;
  border: 1px solid var(--border, #2a2f45);
  border-radius: 8px;
  background: color-mix(in srgb, var(--warning, #e0af68) 10%, transparent);
  font-size: 0.85em;
  font-weight: 600;
  cursor: grab;
  touch-action: none;
  user-select: none;
}
.inv-chip:active {
  cursor: grabbing;
}
.inv-img {
  width: 26px;
  height: 26px;
  object-fit: contain;
  image-rendering: pixelated;
}
.inv-hint {
  font-size: 0.78em;
  color: var(--text-muted, #888);
}
/* Призрак перетаскивания из инвентаря (fixed у курсора) */
.drag-ghost {
  position: fixed;
  z-index: 1000;
  transform: translate(-50%, -60%);
  pointer-events: none;
  filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.4));
}
.drag-ghost img {
  width: 44px;
  height: 44px;
  object-fit: contain;
  image-rendering: pixelated;
}
</style>