<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>