// PixiJS-рендерер сцены сада (ТЗ 3.13): пиксель-арт на WebGL.
// Вся Pixi-логика здесь; GardenScene.vue — тонкая обёртка (DOM-инвентарь,
// поповер, drag-ghost). Клетка = 32 мировых px (матрица 16px × 2).
import { Application, Container, Sprite, TilingSprite } from 'pixi.js'
import type { FederatedPointerEvent, Texture } from 'pixi.js'
import type { GardenItem, GardenState } from '../api'
import {
decorTexture,
fenceTexture,
houseTexture,
plantTexture,
ringTexture,
selectOutlineTexture,
shadowTexture,
sparkleTexture,
tileTexture,
} from './textures'
export const CELL_WORLD = 32 // мировых px на клетку (16px матрица × 2)
const PX = 2 // масштаб «нативного пикселя»
export interface GardenRendererCallbacks {
/** Перемещение: x/y null — бросок в инвентарь (только декорации). */
onMove(id: number, x: number | null, y: number | null): void
/** Клик по растению (поповер) или мимо (id null — закрыть). */
onSelect(id: number | null): void
/** DOM-rect полосы инвентаря (для броска декорации с карты в инвентарь). */
inventoryRect(): DOMRect | null
/** Начало/конец перетаскивания по канвасу (ТЗ 3.14: подавить refetch на жесте). */
onDragChange?(dragging: boolean): void
}
interface ItemView {
root: Container
shadow: Sprite
body: Sprite
ring: Sprite | null
sparkles: Sprite[]
item: GardenItem
growing: boolean
/** Маска соединений забора, по которой построена текущая текстура. */
fenceMask: number
}
interface Tween {
until: number
apply: (progress: number) => void
done: () => void
}
export class GardenRenderer {
private app = new Application()
private cb: GardenRendererCallbacks
private cols = 0
private rows = 0
private disposed = false
private root = new Container()
private terrain = new Container()
private houseLayer = new Container()
private items = new Container()
private fx = new Container()
private views = new Map<number, ItemView>()
private selectedId: number | null = null
private hoverOutline: Sprite | null = null
// клетки с забором (для самосоединения секций): "x,y" → маска соседей
private fenceCells = new Set<string>()
private drag: {
id: number
moved: boolean
startClientX: number
startClientY: number
} | null = null
private tweens: Tween[] = []
private time = 0
// Зум/панорама: масштаб root = baseScale (fit по ширине) × zoom,
// panOffset — сдвиг в экранных px (0 при zoom = 1)
private zoom = 1
private baseScale = 1
private panOffset = { x: 0, y: 0 }
// базовый сдвиг центрирования (контент меньше экрана) — вне panOffset
private baseOffset = { x: 0, y: 0 }
private panning: { sx: number; sy: number; px: number; py: number } | null = null
private constructor(cb: GardenRendererCallbacks) {
this.cb = cb
}
static async create(
host: HTMLElement,
state: GardenState,
cb: GardenRendererCallbacks,
): Promise<GardenRenderer> {
const renderer = new GardenRenderer(cb)
await renderer.app.init({
preference: 'webgl',
antialias: false,
roundPixels: true,
backgroundAlpha: 0,
resolution: Math.min(window.devicePixelRatio || 1, 2),
autoDensity: true,
width: Math.max(1, host.clientWidth),
height: Math.max(1, host.clientHeight),
})
if (renderer.disposed) {
renderer.app.destroy(true, { children: true, texture: false, textureSource: false })
return renderer
}
renderer.setup(host, state)
return renderer
}
private setup(host: HTMLElement, state: GardenState): void {
const app = this.app
app.canvas.style.touchAction = 'none'
app.canvas.style.display = 'block'
app.canvas.style.width = '100%'
app.canvas.style.height = '100%'
app.stage.eventMode = 'static'
app.stage.hitArea = app.screen
app.stage.addChild(this.root)
this.root.addChild(this.terrain, this.houseLayer, this.items, this.fx)
this.items.sortableChildren = true
this.cols = state.grid.cols
this.rows = state.grid.rows
this.rebuildTerrain()
this.buildHouse()
this.syncState(state)
this.applyRootScale(host)
app.stage.on('pointermove', this.onStageMove)
app.stage.on('pointerup', this.onStageUp)
app.stage.on('pointerupoutside', this.onStageUp)
app.stage.on('pointerdown', this.onStageDown)
this.resizeObserver = new ResizeObserver(() => this.onHostResize(host))
this.resizeObserver.observe(host)
app.canvas.addEventListener('wheel', this.onWheel, { passive: false })
app.ticker.add(this.onTick)
host.appendChild(app.canvas)
}
private resizeObserver: ResizeObserver | null = null
// --- Геометрия ---
private worldWidth(): number {
return this.cols * CELL_WORLD
}
private worldHeight(): number {
return this.rows * CELL_WORLD
}
private applyRootScale(host: HTMLElement): void {
const w = host.clientWidth || 1
const h = host.clientHeight || 1
// fit по меньшей стороне: в fullscreen на вертикальном телефоне бокс не
// совпадает по аспекту с сеткой — вписываем сад целиком (в обычном режиме
// бокс следует аспекту сетки, так что это эквивалентно fit по ширине)
this.baseScale = Math.min(w / this.worldWidth(), h / this.worldHeight())
this.applyView()
}
/** Применить зум и панораму к корневому контейнеру. */
private applyView(): void {
this.root.scale.set(this.baseScale * this.zoom)
// контент меньше экрана — центрируем (fullscreen на вертикальном телефоне);
// panOffset остаётся нулевым, базовый сдвиг живёт отдельно
const cw = this.worldWidth() * this.baseScale * this.zoom
const ch = this.worldHeight() * this.baseScale * this.zoom
this.baseOffset.x = Math.max(0, (this.app.screen.width - cw) / 2)
this.baseOffset.y = Math.max(0, (this.app.screen.height - ch) / 2)
this.root.position.set(
this.panOffset.x + this.baseOffset.x,
this.panOffset.y + this.baseOffset.y,
)
}
/** Ограничить панораму: контент не должен уходить с экрана. */
private clampPan(): void {
const w = this.app.screen.width
const h = this.app.screen.height
const cw = this.worldWidth() * this.baseScale * this.zoom
const ch = this.worldHeight() * this.baseScale * this.zoom
this.panOffset.x = Math.max(w - cw, Math.min(0, this.panOffset.x))
// по вертикали, пока контент меньше экрана — прижат кверху (как без зума)
this.panOffset.y = ch >= h ? Math.max(h - ch, Math.min(0, this.panOffset.y)) : 0
}
/** Сменить зум, сохранив точку мира под экранными координатами (ax, ay). */
private applyZoom(next: number, ax: number, ay: number): void {
const scale = this.baseScale * this.zoom
const wx = (ax - this.panOffset.x - this.baseOffset.x) / scale
const wy = (ay - this.panOffset.y - this.baseOffset.y) / scale
this.zoom = Math.max(1, Math.min(4, next))
const ns = this.baseScale * this.zoom
this.panOffset.x = ax - wx * ns - this.baseOffset.x
this.panOffset.y = ay - wy * ns - this.baseOffset.y
this.clampPan()
this.applyView()
}
/** Кнопки зума: якорь — центр экрана. */
zoomIn(): void {
this.applyZoom(this.zoom * 1.25, this.app.screen.width / 2, this.app.screen.height / 2)
}
zoomOut(): void {
this.applyZoom(this.zoom / 1.25, this.app.screen.width / 2, this.app.screen.height / 2)
}
/** Сброс: fit по меньшей стороне, без сдвига. */
resetView(): void {
this.zoom = 1
this.panOffset = { x: 0, y: 0 }
this.applyView()
}
private readonly onWheel = (e: WheelEvent): void => {
e.preventDefault()
const rect = this.app.canvas.getBoundingClientRect()
this.applyZoom(this.zoom * (e.deltaY < 0 ? 1.2 : 1 / 1.2), e.clientX - rect.left, e.clientY - rect.top)
}
/** Мировые координаты центра клетки. */
private cellCenter(x: number, y: number): { cx: number; cy: number } {
return { cx: x * CELL_WORLD + CELL_WORLD / 2, cy: y * CELL_WORLD + CELL_WORLD / 2 }
}
/** Клетка из мировых координат (clamped). */
private cellOfWorld(wx: number, wy: number): { x: number; y: number } {
return {
x: Math.max(0, Math.min(this.cols - 1, Math.floor(wx / CELL_WORLD))),
y: Math.max(0, Math.min(this.rows - 1, Math.floor(wy / CELL_WORLD))),
}
}
// --- Слои terrain/house (пересобираются только при смене сетки) ---
private rebuildTerrain(): void {
this.terrain.removeChildren().forEach((c) => c.destroy())
const W = this.worldWidth()
const H = this.worldHeight()
// земля с запасом за края сетки: в fullscreen (вертикальный телефон) сад
// меньше экрана — вокруг поля земля, а не фон страницы; в обычном режиме
// канвас совпадает с сеткой, запас не виден
const m = CELL_WORLD * 32
const wild = new TilingSprite({
texture: tileTexture('wild'),
width: W + m * 2,
height: H + m * 2,
})
wild.position.set(-m, -m)
this.terrain.addChild(wild)
// газон из супер-тайла 64px (4 тайла, период 4 клетки) — на всё поле
const lawnTex = tileTexture('grass-super')
const lawn = new TilingSprite({
texture: lawnTex,
width: W + m * 2,
height: H + m * 2,
})
lawn.position.set(-m, -m)
this.terrain.addChild(lawn)
// кустики травы: ~8% клеток, два вида (пучок / с цветком);
// раскидка по целочисленному хешу клетки — без видимого паттерна
for (let y = 0; y < this.rows; y++)
for (let x = 0; x < this.cols; x++) {
const h = GardenRenderer.hash2(x, y)
if (h % 100 >= 8) continue
const tex = (h >>> 8) % 4 === 0 ? tileTexture('grass-daisy') : tileTexture('grass-tuft')
const tuft = new Sprite(tex)
const { cx, cy } = this.cellCenter(x, y)
tuft.anchor.set(0.5)
// небольшой детерминированный сдвиг внутри клетки — живее
tuft.position.set(cx + ((h >>> 16) % 9) - 4, cy + ((h >>> 20) % 7) - 3)
tuft.scale.set(PX)
this.terrain.addChild(tuft)
}
}
/** Целочисленный хеш пары координат (раскидка декора без паттернов). */
private static hash2(x: number, y: number): number {
let h = x * 374761393 + y * 668265263
h = (h ^ (h >> 13)) * 1274126177
return (h ^ (h >> 16)) >>> 0
}
private buildHouse(): void {
this.houseLayer.removeChildren().forEach((c) => c.destroy())
// изометрическая тень — эллипс под основанием домика
const shadow = new Sprite(shadowTexture())
shadow.anchor.set(0.5)
shadow.alpha = 0.5
shadow.scale.set(116 / 22, 44 / 8)
shadow.position.set(this.worldWidth() / 2, this.worldHeight() / 2 + 8)
this.houseLayer.addChild(shadow)
const house = new Sprite(houseTexture())
house.scale.set(PX)
// anchor — центр основания ромба в матрице HOUSE (27, 32)
house.anchor.set(27 / 62, 32 / 52)
house.position.set(this.worldWidth() / 2, this.worldHeight() / 2)
this.houseLayer.addChild(house)
}
// --- Дифф элементов ---
private itemChanged(a: GardenItem, b: GardenItem): boolean {
return (
a.x !== b.x ||
a.y !== b.y ||
a.stage !== b.stage ||
a.rarity !== b.rarity ||
a.item_key !== b.item_key ||
a.kind !== b.kind
)
}
/** Обновить сцену из нового состояния (родитель перезагружает state после API). */
syncState(state: GardenState): void {
if (this.disposed) return
if (state.grid.cols !== this.cols || state.grid.rows !== this.rows) {
this.cols = state.grid.cols
this.rows = state.grid.rows
this.rebuildTerrain()
this.buildHouse()
}
this.syncFences(state)
const seen = new Set<number>()
for (const item of state.items) {
if (item.x === null || item.y === null) {
this.removeView(item.id)
continue
}
seen.add(item.id)
const view = this.views.get(item.id)
if (!view) this.addView(item)
// у соединяемых секций (забор/дорожка) текстура зависит и от соседей
else if (
this.itemChanged(view.item, item) ||
(this.isLinked(item) && this.linkMaskOf(item) !== view.fenceMask)
)
this.updateView(view, item)
}
for (const id of [...this.views.keys()]) if (!seen.has(id)) this.removeView(id)
}
private addView(item: GardenItem): void {
const rootC = new Container()
const { cx, cy } = this.cellCenter(item.x ?? 0, item.y ?? 0)
rootC.position.set(cx, cy)
rootC.zIndex = (item.y ?? 0) * 1000 + (item.x ?? 0)
// мягкая тень под объектом (ширина — по телу)
const shadow = new Sprite(shadowTexture())
shadow.anchor.set(0.5)
shadow.alpha = 0.45
rootC.addChild(shadow)
let ring: Sprite | null = null
if (item.kind === 'plant' && (item.rarity === 'rare' || item.rarity === 'epic')) {
ring = new Sprite(ringTexture(item.rarity ?? 'rare'))
ring.anchor.set(0.5)
ring.position.set(0, -2)
ring.scale.set(PX)
rootC.addChild(ring)
}
const body = new Sprite(this.textureOf(item))
if (item.kind === 'plant') body.anchor.set(0.5, 0.95)
else body.anchor.set(0.5, 0.75)
body.scale.set(PX)
body.eventMode = 'static'
body.cursor = 'grab'
body.on('pointerdown', (e) => this.beginDrag(item.id, e))
rootC.addChild(body)
const view: ItemView = {
root: rootC,
shadow,
body,
ring,
sparkles: [],
item: { ...item },
growing: true,
fenceMask: this.isLinked(item) ? this.linkMaskOf(item) : -1,
}
this.fitShadow(view)
const sparkles: Sprite[] = []
if (item.kind === 'plant' && item.rarity === 'epic') {
for (let i = 0; i < 3; i++) {
const s = new Sprite(sparkleTexture())
s.anchor.set(0.5)
s.scale.set(PX)
s.position.set(-14 + i * 14, -20 - (i % 2) * 10)
rootC.addChild(s)
sparkles.push(s)
}
}
view.sparkles = sparkles
this.items.addChild(rootC)
this.views.set(item.id, view)
// появление: рост от земли (scale контейнера от 0.2 до 1)
this.tweens.push({
until: 0.35,
apply: (p) => {
rootC.scale.set(0.2 + 0.8 * p)
},
done: () => {
rootC.scale.set(1)
},
})
}
private updateView(view: ItemView, item: GardenItem): void {
const moved =
item.x !== view.item.x ||
item.y !== view.item.y
view.item = { ...item }
if (moved) {
const { cx, cy } = this.cellCenter(item.x ?? 0, item.y ?? 0)
view.root.position.set(cx, cy)
view.root.zIndex = (item.y ?? 0) * 1000 + (item.x ?? 0)
}
if (item.kind === 'plant') view.body.texture = plantTexture(item.item_key ?? '', item.stage)
else if (this.isLinked(item)) {
view.body.texture = this.linkTexture(item)
view.fenceMask = this.linkMaskOf(item)
} else if (item.item_key) view.body.texture = decorTexture(item.item_key)
this.fitShadow(view)
}
private removeView(id: number): void {
const view = this.views.get(id)
if (!view) return
this.views.delete(id)
if (this.selectedId === id) {
this.selectedId = null
this.cb.onSelect(null)
}
view.root.destroy({ children: true })
}
/** Клетки соединяемых секций (забор) из нового состояния. */
private syncFences(state: GardenState): void {
this.fenceCells = new Set(
state.items
.filter((i) => i.kind === 'decoration' && i.item_key === 'fence')
.map((i) => `${i.x},${i.y}`),
)
}
/** Секция забора, размещённая на карте. */
private isLinked(item: GardenItem): boolean {
return item.kind === 'decoration' && item.item_key === 'fence' && item.x !== null && item.y !== null
}
/** Маска соединений секции с соседями: N=1, E=2, S=4, W=8. */
private linkMaskOf(item: GardenItem): number {
const cells = this.fenceCells
const x = item.x ?? 0
const y = item.y ?? 0
const at = (cx: number, cy: number): boolean => cells.has(`${cx},${cy}`)
return (at(x, y - 1) ? 1 : 0) | (at(x + 1, y) ? 2 : 0) | (at(x, y + 1) ? 4 : 0) | (at(x - 1, y) ? 8 : 0)
}
private linkTexture(item: GardenItem): Texture {
return fenceTexture(this.linkMaskOf(item))
}
private textureOf(item: GardenItem) {
if (item.kind === 'plant') return plantTexture(item.item_key ?? 'ph-flower', item.stage)
if (this.isLinked(item)) return this.linkTexture(item)
return decorTexture(item.item_key ?? 'flowerbed')
}
/** Подогнать тень под текущую текстуру тела (при смене стадии/вида).
* У «плоских» декораций (озеро, дорожка) тени нет — она лежит в самой текстуре. */
private fitShadow(view: ItemView): void {
view.shadow.visible = view.item.item_key !== 'pond' // тень лежит в текстуре
if (!view.shadow.visible) return
const w = view.body.texture.width * PX * 0.9 + 4
view.shadow.scale.set(w / 24, (w / 24) * 0.7)
view.shadow.position.set(0, 2)
}
// --- Drag & drop на канвасе ---
private beginDrag(id: number, e: FederatedPointerEvent): void {
this.drag = {
id,
moved: false,
startClientX: e.globalX,
startClientY: e.globalY,
}
this.cb.onDragChange?.(true)
}
private dragView(): ItemView | null {
if (!this.drag) return null
return this.views.get(this.drag.id) ?? null
}
private readonly onStageMove = (e: FederatedPointerEvent): void => {
// панорама: drag по пустому месту при зуме
if (this.panning) {
const p = this.panning
this.panOffset.x = p.px + (e.globalX - p.sx)
this.panOffset.y = p.py + (e.globalY - p.sy)
this.clampPan()
this.applyView()
return
}
const view = this.dragView()
if (!view) return
const d = this.drag!
if (!d.moved && Math.hypot(e.globalX - d.startClientX, e.globalY - d.startClientY) < 4) return
d.moved = true
const local = this.root.toLocal(e.global)
const cell = this.cellOfWorld(local.x, local.y)
const { cx, cy } = this.cellCenter(cell.x, cell.y)
// визуальный сдвиг на клетку; курсор чуть «поднимает» элемент
view.root.position.set(cx, cy - 6)
view.body.alpha = 0.85
this.setHoverCell(cell)
view.root.zIndex = 1_000_000
}
private readonly onStageUp = (e: FederatedPointerEvent): void => {
if (this.panning) {
this.panning = null
return
}
const view = this.dragView()
const d = this.drag
this.drag = null
if (d) this.cb.onDragChange?.(false)
if (!view || !d) return
view.body.alpha = 1
const item = view.item
const restoreZ = (it: GardenItem): void => {
view.root.zIndex = (it.y ?? 0) * 1000 + (it.x ?? 0)
}
if (!d.moved) {
restoreZ(item)
// клик: выбор объекта — поповер у растений, подпись имени у любых предметов
this.selectedId = item.id
this.cb.onSelect(this.selectedId)
return
}
const local = this.root.toLocal(e.global)
// бросок мимо сада (за краем мира) — позицию не меняем: cellOfWorld клампит
// координаты и без этой проверки предмет «прилипал» бы к ближайшему краю
if (local.x < 0 || local.y < 0 || local.x >= this.worldWidth() || local.y >= this.worldHeight()) {
this.setHoverCell(null)
const { cx, cy } = this.cellCenter(item.x ?? 0, item.y ?? 0)
view.root.position.set(cx, cy)
restoreZ(item)
return
}
const inv = this.cb.inventoryRect()
if (inv && item.kind === 'decoration' && this.clientIn(e, inv)) {
this.setHoverCell(null)
this.cb.onMove(item.id, null, null)
return
}
const cell = this.cellOfWorld(local.x, local.y)
this.setHoverCell(null)
if (cell.x !== item.x || cell.y !== item.y) {
// оптимистичная позиция до перезагрузки state
const { cx, cy } = this.cellCenter(cell.x, cell.y)
view.root.position.set(cx, cy)
view.item.x = cell.x
view.item.y = cell.y
this.cb.onMove(item.id, cell.x, cell.y)
}
restoreZ(view.item)
}
private clientIn(e: FederatedPointerEvent, rect: DOMRect): boolean {
const canvasRect = this.app.canvas.getBoundingClientRect()
const px = canvasRect.left + e.globalX
const py = canvasRect.top + e.globalY
return px >= rect.left && px <= rect.right && py >= rect.top && py <= rect.bottom
}
private readonly onStageDown = (e: FederatedPointerEvent): void => {
// клик по пустому месту: панорама при зуме, иначе закрыть поповер
if (e.target === this.app.stage) {
if (this.zoom > 1)
this.panning = { sx: e.globalX, sy: e.globalY, px: this.panOffset.x, py: this.panOffset.y }
this.selectedId = null
this.cb.onSelect(null)
}
}
// --- Наружу для Vue-части ---
/** Клетка из client-координат (бросок из инвентаря). */
cellFromClient(clientX: number, clientY: number): { x: number; y: number } | null {
const rect = this.app.canvas.getBoundingClientRect()
if (
clientX < rect.left ||
clientX > rect.right ||
clientY < rect.top ||
clientY > rect.bottom
)
return null
const scale = this.baseScale * this.zoom
return this.cellOfWorld(
(clientX - rect.left - this.panOffset.x - this.baseOffset.x) / scale,
(clientY - rect.top - this.panOffset.y - this.baseOffset.y) / scale,
)
}
/** Экранные координаты центра клетки (позиция поповера). */
clientPointOfCell(x: number, y: number): { x: number; y: number } {
const rect = this.app.canvas.getBoundingClientRect()
const scale = this.baseScale * this.zoom
const { cx, cy } = this.cellCenter(x, y)
return {
x: rect.left + cx * scale + this.panOffset.x + this.baseOffset.x,
y: rect.top + cy * scale + this.panOffset.y + this.baseOffset.y,
}
}
/** Подсветка целевой клетки при drag из инвентаря. */
setHoverCell(cell: { x: number; y: number } | null): void {
if (!cell) {
this.hoverOutline?.destroy()
this.hoverOutline = null
return
}
if (!this.hoverOutline) {
this.hoverOutline = new Sprite(selectOutlineTexture())
this.hoverOutline.alpha = 0.4
this.hoverOutline.scale.set(PX)
this.fx.addChild(this.hoverOutline)
}
this.hoverOutline.position.set(cell.x * CELL_WORLD, cell.y * CELL_WORLD)
}
/** Выделение (поповер роста). */
setSelected(id: number | null): void {
this.selectedId = id
}
private onHostResize(host: HTMLElement): void {
if (this.disposed) return
const w = Math.max(1, host.clientWidth)
const h = Math.max(1, host.clientHeight)
this.app.renderer.resize(w, h)
this.applyRootScale(host)
}
destroy(): void {
this.disposed = true
this.resizeObserver?.disconnect()
this.app.canvas.removeEventListener('wheel', this.onWheel)
this.app.stage.off('pointermove', this.onStageMove)
this.app.stage.off('pointerup', this.onStageUp)
this.app.stage.off('pointerupoutside', this.onStageUp)
this.app.stage.off('pointerdown', this.onStageDown)
this.app.ticker.remove(this.onTick)
this.app.destroy(true, { children: true, texture: false, textureSource: false })
}
// --- Анимации (один тикер) ---
private readonly onTick = (): void => {
if (this.disposed) return
this.time += this.app.ticker.deltaMS / 1000
// покачивание взрослых растений и пульс блёсток эпика
for (const view of this.views.values()) {
if (view.item.kind === 'plant' && view.item.stage >= 2 && !this.drag?.moved)
view.body.rotation = Math.sin(this.time * 2 + view.item.id) * 0.03
for (let i = 0; i < view.sparkles.length; i++)
view.sparkles[i]!.alpha =
0.4 + 0.6 * Math.abs(Math.sin(this.time * 3 + view.item.id + i * 2.1))
}
// твины (grow-in)
const total = 0.35
for (let i = this.tweens.length - 1; i >= 0; i--) {
const t = this.tweens[i]!
t.until -= this.app.ticker.deltaMS / 1000
t.apply(Math.min(1, 1 - t.until / total))
if (t.until <= 0) {
t.done()
this.tweens.splice(i, 1)
}
}
}
}