diff --git a/docs/TZ.md b/docs/TZ.md index f4b9ed2..0d6a83c 100644 --- a/docs/TZ.md +++ b/docs/TZ.md @@ -4,7 +4,7 @@ | | | |---|---| -| Версия ТЗ | 0.31 | +| Версия ТЗ | 0.32 | | Дата | 2026-09-21 | | Статус | На обсуждении | @@ -260,6 +260,14 @@ во Vue-reactive (deep-прокси ломает внутренние identity-проверки Pixi); Pixi загружается динамическим импортом (отдельный чанк). Инвентарь, поповер и drag-ghost — DOM поверх канваса. + - **Перемещение объектов (drag&drop UX)**: зона захвата объекта — клетка + (с запасом), а не тонкая текстура — промахи исключены. Фидбек жеста: + «взял в руку» (лёгкое увеличение + подъём тени), подсветка целевой клетки + по валидности (зелёная — можно, красная — нельзя, зеркало правил бэка: + растениям запрещён домик, за краем сада подсветка скрыта), «приземление» — + плавная доводка к центру клетки. Бросок мимо сада или на запрещённую + клетку возвращает объект на исходную позицию анимацией **без запроса** + к API (валидность фронт проверяет сам; бэк остаётся источником истины). - **Две награды: опыт и монеты.** Опыт — «ранг садовника»: только растёт, ничего не открывает в магазине напрямую. Монеты — «валюта сада»: тратятся. Общий принцип: **контент открывается за опыт, покупается за монеты** — опыт (уровень) diff --git a/frontend/src/game/GardenRenderer.ts b/frontend/src/game/GardenRenderer.ts index 523c5ea..cb1959c 100644 --- a/frontend/src/game/GardenRenderer.ts +++ b/frontend/src/game/GardenRenderer.ts @@ -1,7 +1,7 @@ // PixiJS-рендерер сцены сада (ТЗ 3.13): пиксель-арт на WebGL. // Вся Pixi-логика здесь; GardenScene.vue — тонкая обёртка (DOM-инвентарь, // поповер, drag-ghost). Клетка = 32 мировых px (матрица 16px × 2). -import { Application, Container, Sprite, TilingSprite } from 'pixi.js' +import { Application, Container, Rectangle, Sprite, TilingSprite } from 'pixi.js' import type { FederatedPointerEvent, Texture } from 'pixi.js' import type { GardenItem, GardenState } from '../api' import { @@ -44,6 +44,9 @@ interface Tween { until: number + total: number + /** Владелец (view) — новый tween владельца убивает старые (масштаб/позиция). */ + owner?: ItemView apply: (progress: number) => void done: () => void } @@ -72,9 +75,23 @@ moved: boolean startClientX: number startClientY: number + /** Клетка, откуда взяли объект (возврат при недопустимом броске). */ + origin: { x: number; y: number } } | null = null private tweens: Tween[] = [] private time = 0 + // Клетки домика (растения ставить нельзя — как на бэке, api/garden.py) + private houseCells = new Set() + + /** След домика на сетке: пересчитывается при смене сетки. */ + private rebuildHouseCells(): void { + // HOUSE_COLS=4, HOUSE_ROWS=3 — из services/garden.py + const cx = Math.floor(this.cols / 2) - 2 + const cy = Math.floor(this.rows / 2) - 1 + this.houseCells = new Set( + Array.from({ length: 12 }, (_, i) => `${cx + (i % 4)},${cy + Math.floor(i / 4)}`), + ) + } // Зум/панорама: масштаб root = baseScale (fit по ширине) × zoom, // panOffset — сдвиг в экранных px (0 при zoom = 1) @@ -130,6 +147,7 @@ this.rows = state.grid.rows this.rebuildTerrain() this.buildHouse() + this.rebuildHouseCells() this.syncState(state) this.applyRootScale(host) @@ -333,6 +351,7 @@ this.rows = state.grid.rows this.rebuildTerrain() this.buildHouse() + this.rebuildHouseCells() } this.syncFences(state) const seen = new Set() @@ -395,6 +414,7 @@ fenceMask: this.isLinked(item) ? this.linkMaskOf(item) : -1, } this.fitShadow(view) + this.applyHitArea(view) const sparkles: Sprite[] = [] if (item.kind === 'plant' && item.rarity === 'epic') { @@ -412,14 +432,10 @@ 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) - }, + this.tweenView(view, 0.35, (p) => { + rootC.scale.set(0.2 + 0.8 * p) + }, () => { + rootC.scale.set(1) }) } @@ -439,12 +455,46 @@ view.fenceMask = this.linkMaskOf(item) } else if (item.item_key) view.body.texture = decorTexture(item.item_key) this.fitShadow(view) + this.applyHitArea(view) + } + + /** Твин конкретного view: новый твин владельца снимает старые. */ + private tweenView( + owner: ItemView, + seconds: number, + apply: (progress: number) => void, + done?: () => void, + ): void { + this.cancelTweens(owner) + this.tweens.push({ until: seconds, total: seconds, owner, apply, done: done ?? (() => {}) }) + } + + private cancelTweens(view: ItemView): void { + this.tweens = this.tweens.filter((t) => t.owner !== view) + } + + /** Хват-зона: вся клетка объекта + сама текстура с запасом — раньше + * цеплялась только текстура (у ростка/забора пару пикселей — промахи). */ + private applyHitArea(view: ItemView): void { + const b = view.body + const W = b.texture.width + const H = b.texture.height + const pad = 3 // native px запаса поверх текстуры + // клетка в локальных координатах тела: [-8..8] native (32 мировых / PX=2), + // сдвинута на anchor-точку текстуры + const x0 = Math.min(b.anchor.x * W - 8, -b.anchor.x * W - pad) + const y0 = Math.min(b.anchor.y * H - 8, -b.anchor.y * H - pad) + const x1 = Math.max(b.anchor.x * W + 8, (1 - b.anchor.x) * W + pad) + const y1 = Math.max(b.anchor.y * H + 8, (1 - b.anchor.y) * H + pad) + b.hitArea = new Rectangle(x0, y0, x1 - x0, y1 - y0) } private removeView(id: number): void { const view = this.views.get(id) if (!view) return this.views.delete(id) + // твины удалённого view больше не трогают уничтоженные объекты + this.tweens = this.tweens.filter((t) => t.owner !== view) if (this.selectedId === id) { this.selectedId = null this.cb.onSelect(null) @@ -498,20 +548,63 @@ // --- Drag & drop на канвасе --- private beginDrag(id: number, e: FederatedPointerEvent): void { + const view = this.views.get(id) + if (!view) return this.drag = { id, moved: false, startClientX: e.globalX, startClientY: e.globalY, + origin: { x: view.item.x ?? 0, y: view.item.y ?? 0 }, } + // мгновенная обратная связь на подхват: объект чуть приподнимается + this.lift(view) this.cb.onDragChange?.(true) } + /** «Взять в руку»: приподнимаем объект (scale + тень бледнеет). */ + private lift(view: ItemView): void { + view.body.cursor = 'grabbing' + this.tweenView(view, 0.12, (p) => { + view.root.scale.set(1 + 0.12 * p) + view.shadow.alpha = 0.45 - 0.2 * p + }) + } + + /** Опустить обратно в клетку (приземление или возврат). */ + private settle(view: ItemView, cell: { x: number; y: number }): void { + const { cx, cy } = this.cellCenter(cell.x, cell.y) + const fromX = view.root.x + const fromY = view.root.y + // лёгкий отскок: ease-out cubic + приземляющийся scale + this.tweenView(view, 0.16, (p) => { + const q = 1 - Math.pow(1 - p, 3) + view.root.position.set(fromX + (cx - fromX) * q, fromY + (cy - fromY) * q) + view.root.scale.set(1.12 - 0.12 * q) + view.shadow.alpha = 0.25 + 0.2 * q + }, () => { + view.root.position.set(cx, cy) + view.root.scale.set(1) + view.shadow.alpha = 0.45 + }) + view.body.cursor = 'grab' + } + private dragView(): ItemView | null { if (!this.drag) return null return this.views.get(this.drag.id) ?? null } + /** Клетка без клампа (raw), чтобы отличать «за краем сада» от края. */ + private rawCellOfWorld(wx: number, wy: number): { x: number; y: number } { + return { x: Math.floor(wx / CELL_WORLD), y: Math.floor(wy / CELL_WORLD) } + } + + /** Клетка допустима для этого объекта? (домик — только растениям нельзя) */ + private cellAllowed(item: GardenItem, cell: { x: number; y: number }): boolean { + return !(item.kind === 'plant' && this.houseCells.has(`${cell.x},${cell.y}`)) + } + private readonly onStageMove = (e: FederatedPointerEvent): void => { // панорама: drag по пустому месту при зуме if (this.panning) { @@ -527,14 +620,24 @@ const d = this.drag! if (!d.moved && Math.hypot(e.globalX - d.startClientX, e.globalY - d.startClientY) < 4) return d.moved = true + view.body.alpha = 0.9 + view.root.zIndex = 1_000_000 const local = this.root.toLocal(e.global) - const cell = this.cellOfWorld(local.x, local.y) - const { cx, cy } = this.cellCenter(cell.x, cell.y) + const raw = this.rawCellOfWorld(local.x, local.y) + const inGrid = raw.x >= 0 && raw.y >= 0 && raw.x < this.cols && raw.y < this.rows + if (!inGrid) { + // за краем сада предмет не ведём и подсветку прячем + this.setHoverCell(null) + return + } + const { cx, cy } = this.cellCenter(raw.x, raw.y) // визуальный сдвиг на клетку; курсор чуть «поднимает» элемент view.root.position.set(cx, cy - 6) - view.body.alpha = 0.85 - this.setHoverCell(cell) - view.root.zIndex = 1_000_000 + this.setHoverCell(raw) + // зелёный — сервер примет, красный — домик (растениям нельзя) + if (this.hoverOutline) { + this.hoverOutline.tint = this.cellAllowed(view.item, raw) ? 0x9ece6a : 0xf7768e + } } private readonly onStageUp = (e: FederatedPointerEvent): void => { @@ -553,39 +656,48 @@ view.root.zIndex = (it.y ?? 0) * 1000 + (it.x ?? 0) } if (!d.moved) { + // клик: мгновенно вернуть на клетку — выбор объекта (поповер/подпись) + this.cancelTweens(view) + const { cx, cy } = this.cellCenter(item.x ?? 0, item.y ?? 0) + view.root.position.set(cx, cy) + view.root.scale.set(1) + view.shadow.alpha = 0.45 + view.body.cursor = 'grab' 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 outside = + local.x < 0 || local.y < 0 || local.x >= this.worldWidth() || local.y >= this.worldHeight() const inv = this.cb.inventoryRect() - if (inv && item.kind === 'decoration' && this.clientIn(e, inv)) { - this.setHoverCell(null) + const toInventory = + !outside && !!inv && item.kind === 'decoration' && this.clientIn(e, inv) + const raw = this.rawCellOfWorld(local.x, local.y) + const inGrid = !outside && raw.x >= 0 && raw.y >= 0 && raw.x < this.cols && raw.y < this.rows + const allowed = inGrid && this.cellAllowed(item, raw) + this.setHoverCell(null) + if (toInventory) { + restoreZ(item) 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) + // мимо сада или на домик (растение) — плавный возврат на исходную клетку, + // без запроса: раньше предмет молча «прилипал» к ближайшему краю + if (!inGrid || !allowed) { + restoreZ(item) + this.settle(view, d.origin) + return } + if (raw.x !== item.x || raw.y !== item.y) { + this.cb.onMove(item.id, raw.x, raw.y) + } + // приземление с отскоком в любом случае (и на новую клетку, и на свою) + view.item.x = raw.x + view.item.y = raw.y restoreZ(view.item) + this.settle(view, raw) } private clientIn(e: FederatedPointerEvent, rect: DOMRect): boolean { @@ -635,7 +747,7 @@ } } - /** Подсветка целевой клетки при drag из инвентаря. */ + /** Подсветка целевой клетки при drag (нейтральная; цвет валидности — у drag'а). */ setHoverCell(cell: { x: number; y: number } | null): void { if (!cell) { this.hoverOutline?.destroy() @@ -644,10 +756,11 @@ } if (!this.hoverOutline) { this.hoverOutline = new Sprite(selectOutlineTexture()) - this.hoverOutline.alpha = 0.4 + this.hoverOutline.alpha = 0.5 this.hoverOutline.scale.set(PX) this.fx.addChild(this.hoverOutline) } + this.hoverOutline.tint = 0xffffff this.hoverOutline.position.set(cell.x * CELL_WORLD, cell.y * CELL_WORLD) } @@ -689,12 +802,11 @@ 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 + // твины (grow-in, lift/settle) 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)) + t.apply(Math.min(1, 1 - t.until / t.total)) if (t.until <= 0) { t.done() this.tweens.splice(i, 1)