// PixiJS-рендерер сцены сада (ТЗ 3.13): пиксель-арт на WebGL.
// Вся Pixi-логика здесь; GardenScene.vue — тонкая обёртка (DOM-инвентарь,
// поповер, drag-ghost). Клетка = 32 мировых px (матрица 16px × 2).
import { Application, Container, Graphics, Sprite, TilingSprite } from 'pixi.js'
import type { FederatedPointerEvent } from 'pixi.js'
import type { GardenItem, GardenState } from '../api'
import {
decorTexture,
houseTexture,
plantTexture,
ringTexture,
selectOutlineTexture,
shadowTexture,
sparkleTexture,
tileTexture,
} from './textures'
export const CELL_WORLD = 32 // мировых px на клетку (16px матрица × 2)
const PX = 2 // масштаб «нативного пикселя»
const LAWN_INSET = 16 // газон внутри изгороди (10 svg-px ≈ 16 мировых)
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
}
interface ItemView {
root: Container
shadow: Sprite
body: Sprite
ring: Sprite | null
sparkles: Sprite[]
item: GardenItem
growing: boolean
}
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
private drag: {
id: number
moved: boolean
startClientX: number
startClientY: number
} | null = null
private tweens: Tween[] = []
private time = 0
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.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
this.root.scale.set(w / this.worldWidth())
}
/** Мировые координаты центра клетки. */
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()
const wild = new TilingSprite({ texture: tileTexture('wild'), width: W, height: H })
this.terrain.addChild(wild)
// газон из супер-тайла 32px (шахматка grass-a/b, период 2 клетки)
const lawnTex = tileTexture('grass-super')
const lawn = new TilingSprite({
texture: lawnTex,
width: W - LAWN_INSET * 2,
height: H - LAWN_INSET * 2,
})
lawn.position.set(LAWN_INSET, LAWN_INSET)
this.terrain.addChild(lawn)
// кустики травы: ~8% клеток детерминированно
for (let y = 0; y < this.rows; y++)
for (let x = 0; x < this.cols; x++) {
if ((x * 7 + y * 11 + this.cols) % 12 !== 0) continue
const tuft = new Sprite(tileTexture('grass-tuft'))
const { cx, cy } = this.cellCenter(x, y)
tuft.anchor.set(0.5)
tuft.position.set(cx, cy)
tuft.scale.set(PX)
this.terrain.addChild(tuft)
}
// сетка
const grid = new Graphics()
for (let x = 0; x <= this.cols; x++)
grid.moveTo(x * CELL_WORLD, LAWN_INSET).lineTo(x * CELL_WORLD, H - LAWN_INSET)
for (let y = 0; y <= this.rows; y++)
grid.moveTo(LAWN_INSET, y * CELL_WORLD).lineTo(W - LAWN_INSET, y * CELL_WORLD)
grid.stroke({ color: 0x000000, alpha: 0.07, width: 1 })
this.terrain.addChild(grid)
// изгородь по периметру (полосы толщиной с клетку)
const hedgeTex = tileTexture('hedge')
const top = new TilingSprite({ texture: hedgeTex, width: W, height: CELL_WORLD / 2 })
const bottom = new TilingSprite({ texture: hedgeTex, width: W, height: CELL_WORLD / 2 })
bottom.position.set(0, H - CELL_WORLD / 2)
const left = new TilingSprite({ texture: hedgeTex, width: H, height: CELL_WORLD / 2 })
left.position.set(CELL_WORLD / 2, 0)
left.rotation = Math.PI / 2
const right = new TilingSprite({ texture: hedgeTex, width: H, height: CELL_WORLD / 2 })
right.position.set(W - CELL_WORLD / 2, H)
right.rotation = -Math.PI / 2
this.terrain.addChild(top, bottom, left, right)
}
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 / 56, 32 / 46)
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()
}
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.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 }
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 (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 textureOf(item: GardenItem) {
if (item.kind === 'plant') return plantTexture(item.item_key ?? 'ph-flower', item.stage)
return decorTexture(item.item_key ?? 'flowerbed')
}
/** Подогнать тень под текущую текстуру тела (при смене стадии/вида). */
private fitShadow(view: ItemView): void {
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,
}
}
private dragView(): ItemView | null {
if (!this.drag) return null
return this.views.get(this.drag.id) ?? null
}
private readonly onStageMove = (e: FederatedPointerEvent): void => {
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 => {
const view = this.dragView()
const d = this.drag
this.drag = null
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.kind === 'plant' ? item.id : null
this.cb.onSelect(this.selectedId)
return
}
const local = this.root.toLocal(e.global)
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 => {
// клик по пустому месту закрывает поповер (target === stage)
if (e.target === this.app.stage) {
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 = rect.width / this.worldWidth()
return this.cellOfWorld((clientX - rect.left) / scale, (clientY - rect.top) / scale)
}
/** Экранные координаты центра клетки (позиция поповера). */
clientPointOfCell(x: number, y: number): { x: number; y: number } {
const rect = this.app.canvas.getBoundingClientRect()
const scale = rect.width / this.worldWidth()
const { cx, cy } = this.cellCenter(x, y)
return { x: rect.left + cx * scale, y: rect.top + cy * scale }
}
/** Подсветка целевой клетки при 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.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)
}
}
}
}