<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, y: number]
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 (дальние — позади)
const items = computed(() =>
[...props.state.items].sort((a, b) => a.y - b.y || a.x - b.x),
)
// Размер растения по стадии и цвет по редкости
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 drag = ref<{ id: number; dx: number; dy: number; moved: boolean } | null>(null)
const dragPos = ref<{ id: number; 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 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 * CELL,
dy: (e.clientY - rect.top) * scale - item.y * 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
}
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
}
}
// Позиция элемента с учётом перетаскивания
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, y: item.y }
}
// --- Поповер роста растения ---
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>
</div>
</template>
<style scoped>
.scene-wrap {
margin-bottom: 2rem;
}
.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));
}
.decor {
pointer-events: none;
}
.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>