Newer
Older
gnexus-tasks / frontend / src / components / GardenScene.vue
<script setup lang="ts">
// Сцена сада (ТЗ 3.13): SVG-карта с домиком в центре, растениями и декорациями.
// Мелкая сетка (ячейка 20px), drag&drop со привязкой к ячейке, наложения разрешены.
// Растение = закрытая задача (item_key — вид, rarity — редкость); клик — поповер роста.
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import type { GardenItem, GardenState } from '../api'

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 CELL = 20
const HOUSE_COLS = 4
const HOUSE_ROWS = 3
const HEDGE = 10 // ширина живой изгороди по границе

const width = computed(() => props.state.grid.cols * CELL)
const height = computed(() => props.state.grid.rows * CELL)

// Домик в центре сетки (footprint 4×3 ячейки)
const house = computed(() => ({
  x: (props.state.grid.cols / 2 - HOUSE_COLS / 2) * CELL,
  y: (props.state.grid.rows / 2 - HOUSE_ROWS / 2) * CELL,
  w: HOUSE_COLS * CELL,
  h: HOUSE_ROWS * CELL,
}))

// Растения и декорации в одном списке; z-порядок по y (дальние — позади).
// Неразмещенные декорации (x/y null) — в инвентаре под сценой.
const items = computed(() =>
  [...props.state.items]
    .filter((i) => i.x !== null && i.y !== null)
    .sort((a, b) => (a.y ?? 0) - (b.y ?? 0) || (a.x ?? 0) - (b.x ?? 0)),
)

const inventoryItems = computed(() =>
  props.state.items.filter((i) => i.x === null || i.y === null),
)

// Размер растения по стадии и цвет по редкости
function stageSize(stage: number): number {
  return [20, 28, 36][Math.min(stage, 2)]
}

function plantTitle(item: GardenItem): string {
  const species = item.item_key ? t(`garden.speciesNames.${item.item_key}`) : ''
  const stageNames = [t('garden.stage.sprout'), t('garden.stage.bush'), t('garden.stage.bloom')]
  const stage = stageNames[Math.min(item.stage, 2)]
  return item.task_title ? `${species} · ${stage} — ${item.task_title}` : `${species} · ${stage}`
}

function decorTitle(item: GardenItem): string {
  return item.item_key ? t(`garden.decorNames.${item.item_key}`) : ''
}

// Цена следующей стадии роста (UPGRADE_COSTS из 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
}

// --- Drag & drop: pointer events, snap к ячейке, emit на отпускании ---
// Источник перетаскивания: со сцены или из инвентаря (призрак у курсора).

const svgEl = ref<SVGSVGElement | null>(null)
const invEl = ref<HTMLElement | null>(null)
const drag = ref<{ id: number; dx: number; dy: number; moved: boolean } | null>(null)
const dragPos = ref<{ id: number; x: number; y: number } | null>(null)
// Перетаскивание из инвентаря: призрак следует за курсором
const ghost = ref<{ id: number; icon: string; x: number; y: number } | null>(null)

function cellFromEvent(e: PointerEvent, offX: number, offY: number): { x: number; y: number } {
  const svg = svgEl.value
  if (!svg) return { x: 0, y: 0 }
  const rect = svg.getBoundingClientRect()
  const scale = width.value / rect.width
  const px = (e.clientX - rect.left) * scale - offX
  const py = (e.clientY - rect.top) * scale - offY
  const maxCols = props.state.grid.cols
  const maxRows = props.state.grid.rows
  return {
    x: Math.max(0, Math.min(maxCols - 1, Math.round(px / CELL))),
    y: Math.max(0, Math.min(maxRows - 1, Math.round(py / CELL))),
  }
}

// Курсор внутри карты?
function inSvg(e: PointerEvent): boolean {
  const rect = svgEl.value?.getBoundingClientRect()
  return (
    !!rect &&
    e.clientX >= rect.left &&
    e.clientX <= rect.right &&
    e.clientY >= rect.top &&
    e.clientY <= rect.bottom
  )
}

// Курсор над полосой инвентаря?
function inInventory(e: PointerEvent): boolean {
  const rect = invEl.value?.getBoundingClientRect()
  return (
    !!rect &&
    e.clientX >= rect.left &&
    e.clientX <= rect.right &&
    e.clientY >= rect.top &&
    e.clientY <= rect.bottom
  )
}

function onPointerDown(item: GardenItem, e: PointerEvent): void {
  // Клик без перетаскивания обрабатывается на pointerup (поповер роста)
  const rect = svgEl.value?.getBoundingClientRect()
  if (!rect) return
  const scale = width.value / rect.width
  drag.value = {
    id: item.id,
    dx: (e.clientX - rect.left) * scale - (item.x ?? 0) * CELL,
    dy: (e.clientY - rect.top) * scale - (item.y ?? 0) * CELL,
    moved: false,
  }
  dragPos.value = null
  ;(e.target as Element).setPointerCapture?.(e.pointerId)
}

function onPointerMove(e: PointerEvent): void {
  if (!drag.value) return
  drag.value.moved = true
  const pos = cellFromEvent(e, drag.value.dx, drag.value.dy)
  dragPos.value = { id: drag.value.id, x: pos.x, y: pos.y }
}

function onPointerUp(item: GardenItem, e: PointerEvent): void {
  const isDrag = drag.value !== null && drag.value.moved
  drag.value = null
  const pos = dragPos.value
  dragPos.value = null
  if (!isDrag) {
    // Простой клик по растению — поповер роста; клик по кнопке поповера
    // обрабатывается сам (pointerup по ней удалил бы кнопку до click)
    if (item.kind === 'plant' && !(e.target as Element).closest?.('.popover-card')) {
      selected.value = selected.value === item.id ? null : item.id
    }
    return
  }
  // Декорацию можно вернуть в инвентарь — бросок на полосу инвентаря
  if (item.kind === 'decoration' && inInventory(e)) {
    emit('move', item.id, null, null)
    return
  }
  const cell = pos && pos.id === item.id ? pos : null
  if (cell && (cell.x !== item.x || cell.y !== item.y)) {
    emit('move', item.id, cell.x, cell.y)
    // Снимок позиции применён визуально — бэк подтвердит при следующем load
    item.x = cell.x
    item.y = cell.y
  }
}

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

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

function onInvMove(e: PointerEvent): void {
  if (ghost.value) {
    ghost.value.x = e.clientX
    ghost.value.y = e.clientY
  }
}

function onInvUp(e: PointerEvent): void {
  window.removeEventListener('pointermove', onInvMove)
  window.removeEventListener('pointerup', onInvUp)
  const item = props.state.items.find((i) => i.id === ghost.value?.id)
  ghost.value = null
  if (!item) return
  if (inSvg(e)) {
    const cell = cellFromEvent(e, 0, 0)
    emit('move', item.id, cell.x, cell.y)
    item.x = cell.x
    item.y = cell.y
  }
  // Бросок мимо карты — остаётся в инвентаре
}

// Позиция элемента с учётом перетаскивания
function itemPos(item: GardenItem): { x: number; y: number } {
  if (dragPos.value && dragPos.value.id === item.id) {
    return { x: dragPos.value.x, y: dragPos.value.y }
  }
  return { x: item.x ?? 0, y: item.y ?? 0 }
}

// --- Поповер роста растения ---
const selected = ref<number | null>(null)

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

<template>
  <div class="scene-wrap">
    <svg
      ref="svgEl"
      class="scene"
      :viewBox="`0 0 ${width} ${height}`"
      :style="{ aspectRatio: `${width} / ${height}` }"
      @pointermove="onPointerMove"
      @pointerleave="drag = null"
    >
      <defs>
        <pattern id="cells" :width="CELL" :height="CELL" patternUnits="userSpaceOnUse">
          <path
            :d="`M ${CELL} 0 L 0 0 0 ${CELL}`"
            fill="none"
            stroke="rgba(0,0,0,0.07)"
            stroke-width="1"
          />
        </pattern>
      </defs>

      <!-- Дикая земля за изгородью, затем газон текущей карты -->
      <rect x="0" y="0" :width="width" :height="height" class="wild" />
      <rect
        :x="HEDGE"
        :y="HEDGE"
        :width="width - HEDGE * 2"
        :height="height - HEDGE * 2"
        rx="6"
        class="lawn"
      />
      <rect
        :x="HEDGE"
        :y="HEDGE"
        :width="width - HEDGE * 2"
        :height="height - HEDGE * 2"
        rx="6"
        fill="url(#cells)"
      />
      <!-- Живая изгородь — визуальная граница сада -->
      <rect
        :x="HEDGE / 2"
        :y="HEDGE / 2"
        :width="width - HEDGE"
        :height="height - HEDGE"
        rx="8"
        class="hedge"
      />

      <!-- Домик в центре -->
      <g class="house" :transform="`translate(${house.x}, ${house.y})`">
        <rect x="4" y="18" :width="house.w - 8" :height="house.h - 22" rx="3" class="house-body" />
        <path :d="`M -2 20 L ${house.w / 2} -4 L ${house.w + 2} 20 Z`" class="house-roof" />
        <rect :x="house.w / 2 - 6" :y="house.h - 22" width="12" height="18" rx="1.5" class="house-door" />
        <rect x="12" y="26" width="10" height="9" rx="1.5" class="house-window" />
        <rect :x="house.w - 22" y="26" width="10" height="9" rx="1.5" class="house-window" />
      </g>

      <!-- Элементы сада: растения и декорации, z-порядок по y -->
      <g
        v-for="item in items"
        :key="item.id"
        class="item"
        :class="{ dragging: dragPos?.id === item.id, plant: item.kind === 'plant' }"
        :transform="`translate(${itemPos(item).x * CELL + CELL / 2}, ${itemPos(item).y * CELL + CELL / 2})`"
        @pointerdown="onPointerDown(item, $event)"
        @pointerup="onPointerUp(item, $event)"
      >
        <title>{{ item.kind === 'plant' ? plantTitle(item) : decorTitle(item) }}</title>

        <!-- Растение: phosphor-иконка кита во foreignObject, размер по стадии -->
        <foreignObject
          v-if="item.kind === 'plant'"
          :x="-stageSize(item.stage) / 2"
          :y="-stageSize(item.stage) / 2"
          :width="stageSize(item.stage)"
          :height="stageSize(item.stage)"
        >
          <i
            :class="`ph ${item.item_key} plant-icon rarity-${item.rarity ?? 'common'}`"
            :style="{ fontSize: `${stageSize(item.stage)}px` }"
            aria-hidden="true"
          />
        </foreignObject>

        <!-- Декорации: простые SVG-фигуры -->
        <g v-else-if="item.item_key === 'lantern'" class="decor">
          <rect x="-1.5" y="-6" width="3" height="14" class="d-wood" />
          <rect x="-5" y="-14" width="10" height="9" rx="2" class="d-glass" />
          <circle cx="0" cy="-9.5" r="1.8" class="d-glow" />
        </g>
        <g v-else-if="item.item_key === 'bench'" class="decor">
          <rect x="-9" y="-2" width="18" height="3" rx="1" class="d-wood" />
          <rect x="-9" y="-8" width="18" height="2.5" rx="1" class="d-wood" />
          <rect x="-8" y="-2" width="2" height="6" class="d-wood" />
          <rect x="6" y="-2" width="2" height="6" class="d-wood" />
        </g>
        <ellipse v-else-if="item.item_key === 'pond'" rx="9" ry="6" class="d-water" />
        <ellipse v-else-if="item.item_key === 'birdbath'" cx="0" cy="0" rx="6" ry="3" class="d-water" />
        <g v-else-if="item.item_key === 'gazebo'" class="decor">
          <path d="M -9 2 L 0 -10 L 9 2 Z" class="d-roof" />
          <rect x="-7" y="2" width="14" height="7" class="d-wood" />
        </g>
        <g v-else-if="item.item_key === 'flowerbed'" class="decor">
          <ellipse rx="8" ry="4.5" class="d-bed" />
          <circle cx="-3" cy="-1" r="1.8" class="d-flower" />
          <circle cx="2" cy="1" r="1.8" class="d-flower2" />
          <circle cx="4" cy="-1.5" r="1.6" class="d-flower" />
        </g>
        <g v-else-if="item.item_key === 'fence'" class="decor">
          <rect x="-9" y="-3" width="18" height="2" class="d-wood" />
          <rect x="-8" y="-5" width="2.5" height="7" class="d-wood" />
          <rect x="0" y="-5" width="2.5" height="7" class="d-wood" />
          <rect x="6" y="-5" width="2.5" height="7" class="d-wood" />
        </g>

        <!-- Поповер роста: цена следующей стадии или «полностью выросло» -->
        <foreignObject
          v-if="item.kind === 'plant' && selected === item.id"
          x="-72"
          y="-44"
          width="144"
          height="40"
          class="popover"
        >
          <div xmlns="http://www.w3.org/1999/xhtml" class="popover-card">
            <template v-if="upgradeCost(item) !== null">
              <button
                xmlns="http://www.w3.org/1999/xhtml"
                class="popover-btn"
                :disabled="balance < (upgradeCost(item) ?? 0)"
                @click.stop="onUpgrade(item)"
              >
                <i class="ph ph-arrow-fat-up" aria-hidden="true" />
                {{ t('garden.upgrade', { n: upgradeCost(item) }) }}
              </button>
              <span v-if="balance < (upgradeCost(item) ?? 0)" class="popover-hint">
                {{ t('garden.notEnoughCoins', { n: (upgradeCost(item) ?? 0) - balance }) }}
              </span>
            </template>
            <span v-else class="popover-max">
              <i class="ph ph-check-circle" aria-hidden="true" />
              {{ t('garden.maxStage') }}
            </span>
          </div>
        </foreignObject>
      </g>
    </svg>

    <!-- Инвентарь: купленные, но не размещённые декорации — 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="decorTitle(item)"
        @pointerdown="onInvPointerDown(item, $event)"
      >
        <i :class="`ph ${item.item_key}`" aria-hidden="true" />
        <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` }">
      <i :class="`ph ${ghost.icon}`" aria-hidden="true" />
    </div>
  </div>
</template>

<style scoped>
.scene-wrap {
  margin-bottom: 2rem;
}
/* Инвентарь: купленные, но не размещённые декорации */
.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.3rem 0.6rem;
  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-chip > i {
  font-size: 1.1rem;
  color: var(--warning, #e0af68);
}
.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;
  font-size: 1.6rem;
  color: var(--warning, #e0af68);
  filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.4));
}
.scene {
  display: block;
  width: 100%;
  border-radius: 12px;
  border: 1px solid var(--border, #2a2f45);
  touch-action: none;
  user-select: none;
}
.wild {
  fill: color-mix(in srgb, var(--text-muted, #888) 18%, #3b3222);
}
.lawn {
  fill: color-mix(in srgb, var(--success, #9ece6a) 26%, var(--bg-page, #1a1f2e));
}
.hedge {
  fill: none;
  stroke: color-mix(in srgb, var(--success, #9ece6a) 60%, #2d5a27);
  stroke-width: 10;
}
.house-body {
  fill: color-mix(in srgb, var(--warning, #e0af68) 45%, var(--bg-page, #1a1f2e));
  stroke: var(--border, #2a2f45);
}
.house-roof {
  fill: color-mix(in srgb, #f7768e 45%, var(--bg-page, #1a1f2e));
}
.house-door {
  fill: color-mix(in srgb, #7aa2f7 55%, var(--bg-page, #1a1f2e));
}
.house-window {
  fill: color-mix(in srgb, #73daca 55%, var(--bg-page, #1a1f2e));
}
.item {
  cursor: grab;
}
.item.dragging {
  cursor: grabbing;
}
.item.plant:active {
  cursor: grabbing;
}
.plant-icon {
  display: block;
  line-height: 1;
  color: var(--success, #9ece6a);
  pointer-events: none;
}
.plant-icon.rarity-rare {
  color: #e0af68;
}
.plant-icon.rarity-epic {
  color: #d4a017;
  filter: drop-shadow(0 0 4px rgba(241, 196, 15, 0.7));
}
/* Фигуры декораций участвуют в hit-testing — drag ловит родительский <g> */
.d-wood {
  fill: #8a6d4b;
}
.d-glass {
  fill: color-mix(in srgb, #f1c40f 40%, var(--bg-page, #1a1f2e));
}
.d-glow {
  fill: #f5d76e;
}
.d-water {
  fill: color-mix(in srgb, #7aa2f7 60%, var(--bg-page, #1a1f2e));
  stroke: rgba(122, 162, 247, 0.4);
}
.d-roof {
  fill: color-mix(in srgb, #f7768e 50%, var(--bg-page, #1a1f2e));
}
.d-bed {
  fill: #6b4f3a;
}
.d-flower {
  fill: #f7768e;
}
.d-flower2 {
  fill: #bb9af7;
}
.popover-card {
  display: flex;
  flex-direction: column;
  gap: 2px;
  padding: 4px;
  border-radius: 8px;
  background: var(--bg-card, #24283b);
  border: 1px solid var(--border, #2a2f45);
  font-size: 11px;
  line-height: 1.2;
}
.popover-btn {
  display: flex;
  gap: 4px;
  align-items: center;
  padding: 3px 6px;
  border: none;
  border-radius: 6px;
  background: var(--success, #9ece6a);
  color: #1a1f2e;
  font-weight: 600;
  font-size: 11px;
  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;
}
</style>