<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, PLANT_BUILDERS } 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]
dragging: [dragging: boolean]
}>()
const { t } = useI18n()
const hostEl = ref<HTMLElement | null>(null)
const invEl = ref<HTMLElement | null>(null)
const wrapEl = 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)
const isFullscreen = ref(false)
// Неразмещенные декорации (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 matrix = PLANT_BUILDERS[key] ? PLANT_BUILDERS[key]!(0) : (DECOR[key] ?? DECOR.flowerbed!)
const url = dataUrlFor(matrix, 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)
}
// Подпись выбранного объекта (имя у клетки) — для любых кликнутых предметов
const objNameStyle = computed(() => {
const item = selectedItem.value
const r = renderer.value
if (!item || item.x === null || item.y === null || !r) return { display: 'none' }
const p = r.clientPointOfCell(item.x, item.y)
const left = Math.max(8, Math.min(window.innerWidth - 150, p.x - 60))
return { left: `${left}px`, top: `${p.y + 22}px` }
})
function onKeydown(e: KeyboardEvent): void {
if (e.key === 'Escape') closePopover()
}
function onScroll(): void {
closePopover()
}
// --- Полноэкранный режим ---
function onFullscreenChange(): void {
isFullscreen.value = document.fullscreenElement === wrapEl.value
}
function toggleFullscreen(): void {
if (document.fullscreenElement) void document.exitFullscreen()
else void wrapEl.value?.requestFullscreen()
}
watch(selected, (id) => renderer.value?.setSelected(id))
// --- Синхронизация состояния (родитель перезагружает state после API) ---
watch(
() => props.state,
(st) => renderer.value?.syncState(st),
{ deep: false },
)
function itemName(item: GardenItem): string {
if (!item.item_key) return ''
return item.kind === 'plant'
? t(`garden.speciesNames.${item.item_key}`)
: t(`garden.decorNames.${item.item_key}`)
}
// --- 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 }
emit('dragging', true)
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)
emit('dragging', false)
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()
}
// Бросок мимо карты — остаётся в инвентаре
}
// --- Жизненный цикл ---
// флаг «компонент размонтирован» — init Pixi резолвится асинхронно
let unmounted = false
onMounted(async () => {
window.addEventListener('keydown', onKeydown)
window.addEventListener('scroll', onScroll, { capture: true, passive: true })
document.addEventListener('fullscreenchange', onFullscreenChange)
const { GardenRenderer: R } = await import('../game/GardenRenderer')
// unmount во время загрузки модуля/init: create() ниже может уже не выполняться
if (unmounted) return
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
},
onDragChange: (dragging) => emit('dragging', dragging),
inventoryRect: () => invEl.value?.getBoundingClientRect() ?? null,
})
// init мог резолвнуться уже после unmount — уничтожаем сразу, иначе ticker
// и ResizeObserver живут вечно
if (unmounted) {
renderer.value.destroy()
renderer.value = null
}
})
onBeforeUnmount(() => {
unmounted = true
window.removeEventListener('keydown', onKeydown)
window.removeEventListener('scroll', onScroll, { capture: true })
document.removeEventListener('fullscreenchange', onFullscreenChange)
window.removeEventListener('pointermove', onInvMove)
window.removeEventListener('pointerup', onInvUp)
renderer.value?.destroy()
renderer.value = null
})
</script>
<template>
<div ref="wrapEl" class="scene-wrap">
<div class="scene-box">
<!-- Canvas-host: в обычном режиме аспект по сетке; в fullscreen бокс занимает
весь экран, а сад вписывает сам рендерер (fit по меньшей стороне) -->
<div
ref="hostEl"
class="scene"
:style="{ aspectRatio: isFullscreen ? 'auto' : `${state.grid.cols} / ${state.grid.rows}` }"
/>
<!-- Управление зумом и полноэкранным режимом -->
<div class="scene-controls">
<button
class="ctrl-btn"
:title="t('garden.zoomOut')"
:aria-label="t('garden.zoomOut')"
@click="renderer?.zoomOut()"
>
<i class="ph ph-magnifying-glass-minus" aria-hidden="true" />
</button>
<button
class="ctrl-btn"
:title="t('garden.zoomIn')"
:aria-label="t('garden.zoomIn')"
@click="renderer?.zoomIn()"
>
<i class="ph ph-magnifying-glass-plus" aria-hidden="true" />
</button>
<button
class="ctrl-btn"
:title="t('garden.zoomReset')"
:aria-label="t('garden.zoomReset')"
@click="renderer?.resetView()"
>
<i class="ph ph-arrows-counter-clockwise" aria-hidden="true" />
</button>
<button
class="ctrl-btn"
:title="isFullscreen ? t('garden.fullscreenExit') : t('garden.fullscreen')"
:aria-label="isFullscreen ? t('garden.fullscreenExit') : t('garden.fullscreen')"
@click="toggleFullscreen"
>
<!-- ph-compress/ph-expand в ките нет — стрелки внутрь/наружу -->
<i :class="isFullscreen ? 'ph ph-arrows-in' : 'ph ph-arrows-out'" aria-hidden="true" />
</button>
</div>
</div>
<!-- Поповер роста: 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>
<!-- Название выбранного объекта: подпись у клетки (любой предмет) -->
<div v-if="selectedItem" class="obj-name" :style="objNameStyle" @pointerdown.stop>
<i :class="selectedItem.kind === 'plant' ? 'ph ph-leaf' : 'ph ph-flower'" aria-hidden="true" />
{{ itemName(selectedItem) }}
</div>
<!-- Инвентарь: купленные, но не размещённые декорации — drag на карту -->
<div 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="itemName(item)"
@pointerdown="onInvPointerDown(item, $event)"
>
<img :src="chipPreview(item)" alt="" class="inv-img" />
<span>{{ itemName(item) }}</span>
</div>
<span v-if="!inventoryItems.length" class="inv-hint">{{ t('garden.inventoryEmpty') }}</span>
<span v-else 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-box {
position: relative;
}
.scene {
width: 100%;
overflow: hidden;
cursor: default;
}
/* Кнопки зума/фулскрина над сценой */
.scene-controls {
position: absolute;
top: 8px;
right: 8px;
display: flex;
gap: 6px;
z-index: 10;
}
.ctrl-btn {
display: flex;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
border: 1px solid var(--border, #2a2f45);
background: color-mix(in srgb, var(--bg-card, #24283b) 85%, transparent);
color: var(--text, #d6dae4);
font-size: 15px;
cursor: pointer;
}
.ctrl-btn:hover {
background: color-mix(in srgb, var(--accent, #7aa2f7) 25%, var(--bg-card, #24283b));
}
/* Полноэкранный режим: бокс занимает весь экран (на вертикальном телефоне —
всю высоту), сад вписывает рендерер и центрирует; инвентарь остаётся снизу */
.scene-wrap:fullscreen {
margin: 0;
display: flex;
flex-direction: column;
background: var(--bg-main, #1a1f2e);
}
.scene-wrap:fullscreen .scene-box {
width: 100%;
flex: 1;
min-height: 0;
}
.scene-wrap:fullscreen .scene {
width: 100%;
height: 100%;
}
/* Поповер роста: fixed у клетки растения */
.popover-card {
position: fixed;
z-index: 20;
display: flex;
flex-direction: column;
gap: 3px;
padding: 5px;
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);
}
/* Подпись имени выбранного объекта (скворечник, куст, …) у клетки */
.obj-name {
position: fixed;
z-index: 20;
display: flex;
align-items: center;
gap: 5px;
padding: 3px 8px;
background: var(--bg-card, #24283b);
border: 1px solid var(--border, #2a2f45);
font-size: 12px;
line-height: 1.2;
white-space: nowrap;
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;
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);
}
.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);
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>