diff --git a/backend/alembic/versions/e1f0a2b3c4d5_garden_coins_items.py b/backend/alembic/versions/e1f0a2b3c4d5_garden_coins_items.py index 8ad0a96..3d02ef9 100644 --- a/backend/alembic/versions/e1f0a2b3c4d5_garden_coins_items.py +++ b/backend/alembic/versions/e1f0a2b3c4d5_garden_coins_items.py @@ -24,7 +24,9 @@ op.create_table( "coin_events", sa.Column("id", sa.Integer(), primary_key=True), - sa.Column("task_id", sa.Integer(), sa.ForeignKey("tasks.id", ondelete="SET NULL"), nullable=True), + sa.Column( + "task_id", sa.Integer(), sa.ForeignKey("tasks.id", ondelete="SET NULL"), nullable=True + ), sa.Column("level", sa.Integer(), nullable=True), sa.Column("source", sa.String(length=16), nullable=False, server_default="task"), sa.Column("amount", sa.Integer(), nullable=False), @@ -35,7 +37,9 @@ "garden_items", sa.Column("id", sa.Integer(), primary_key=True), sa.Column("kind", sa.String(length=16), nullable=False), - sa.Column("ref_id", sa.Integer(), sa.ForeignKey("xp_events.id", ondelete="CASCADE"), nullable=True), + sa.Column( + "ref_id", sa.Integer(), sa.ForeignKey("xp_events.id", ondelete="CASCADE"), nullable=True + ), sa.Column("item_key", sa.String(length=50), nullable=True), sa.Column("x", sa.Integer(), nullable=False, server_default="0"), sa.Column("y", sa.Integer(), nullable=False, server_default="0"), @@ -54,4 +58,4 @@ def downgrade() -> None: op.drop_table("garden_items") - op.drop_table("coin_events") \ No newline at end of file + op.drop_table("coin_events") diff --git a/backend/alembic/versions/f2a3b4c5d6e7_garden_inventory.py b/backend/alembic/versions/f2a3b4c5d6e7_garden_inventory.py new file mode 100644 index 0000000..7e18544 --- /dev/null +++ b/backend/alembic/versions/f2a3b4c5d6e7_garden_inventory.py @@ -0,0 +1,29 @@ +"""Инвентарь сада: декорации покупаются без координат (x/y NULL — на складе). + +Revision ID: f2a3b4c5d6e7 +Revises: e1f0a2b3c4d5 +Create Date: 2026-09-20 17:48:46.993892 +""" + +from collections.abc import Sequence + +import sqlalchemy as sa + +from alembic import op + +revision: str = "f2a3b4c5d6e7" +down_revision: str | Sequence[str] | None = "e1f0a2b3c4d5" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.alter_column("garden_items", "x", existing_type=sa.Integer(), nullable=True) + op.alter_column("garden_items", "y", existing_type=sa.Integer(), nullable=True) + + +def downgrade() -> None: + # Неразмещенные декорации при откате ставятся в левый верхний угол + op.get_bind().execute(sa.text("UPDATE garden_items SET x = 0, y = 0 WHERE x IS NULL")) + op.alter_column("garden_items", "x", existing_type=sa.Integer(), nullable=False) + op.alter_column("garden_items", "y", existing_type=sa.Integer(), nullable=False) diff --git a/backend/app/api/garden.py b/backend/app/api/garden.py index fd5feba..74c4c43 100644 --- a/backend/app/api/garden.py +++ b/backend/app/api/garden.py @@ -20,7 +20,6 @@ house_footprint, next_expansion, species_catalog, - spiral_position, ) from app.services.xp import level_from_xp, total_xp @@ -28,8 +27,10 @@ class MoveIn(BaseModel): - x: int - y: int + """Позиция элемента: x/y None — вернуть в инвентарь (не размещено).""" + + x: int | None = None + y: int | None = None class BuyIn(BaseModel): @@ -113,6 +114,13 @@ item = db.get(GardenItem, item_id) if item is None: raise HTTPException(status_code=404, detail="Item not found") + # x/y None — декорация возвращается в инвентарь (растениям нельзя: они от задач) + if move.x is None or move.y is None: + if item.kind != "decoration": + raise HTTPException(status_code=400, detail="Plants cannot be stored") + item.x, item.y = None, None + db.flush() + return {"ok": True} size = grid_size(db) if not (0 <= move.x < size["cols"] and 0 <= move.y < size["rows"]): raise HTTPException(status_code=400, detail="Position outside the garden") @@ -176,12 +184,9 @@ raise HTTPException(status_code=409, detail="Already bought") if not garden.can_afford(db, spec["cost"]): raise HTTPException(status_code=402, detail=f"Not enough coins: need {spec['cost']}") - size = grid_size(db) - occupied = { - (int(x), int(y)) for x, y in db.execute(select(GardenItem.x, GardenItem.y)).all() - } | house_footprint(size["cols"], size["rows"]) - x, y = spiral_position(occupied, size["cols"], size["rows"]) - item = GardenItem(kind="decoration", item_key=item_key, x=x, y=y) + # Покупка кладёт декорацию в инвентарь (x/y NULL) — пользователь сам + # перетаскивает её на карту, место покупки всегда видно + item = GardenItem(kind="decoration", item_key=item_key, x=None, y=None) db.add(item) garden.spend(db, spec["cost"], item_key) db.flush() diff --git a/backend/app/models.py b/backend/app/models.py index 357b0f8..9673642 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -301,8 +301,9 @@ ) # Для растения — ключ вида (species), для декорации — ключ каталога item_key: Mapped[str | None] = mapped_column(String(50), nullable=True, default=None) - x: Mapped[int] = mapped_column(Integer, default=0) - y: Mapped[int] = mapped_column(Integer, default=0) + # Позиция в ячейках сетки; NULL — декорация в инвентаре (не размещена) + x: Mapped[int | None] = mapped_column(Integer, nullable=True, default=None) + y: Mapped[int | None] = mapped_column(Integer, nullable=True, default=None) # Стадия роста растения: 0 росток, 1 куст, 2 цветение stage: Mapped[int] = mapped_column(Integer, default=0) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow) diff --git a/backend/app/services/garden.py b/backend/app/services/garden.py index 8a557e5..abebb79 100644 --- a/backend/app/services/garden.py +++ b/backend/app/services/garden.py @@ -199,8 +199,14 @@ ] if pending: size = grid_size(db) + # Неразмещенные декорации (в инвентаре) ячейки не занимают occupied = { - (int(x), int(y)) for x, y in db.execute(select(GardenItem.x, GardenItem.y)).all() + (int(x), int(y)) + for x, y in db.execute( + select(GardenItem.x, GardenItem.y).where( + GardenItem.x.is_not(None), GardenItem.y.is_not(None) + ) + ).all() } | house_footprint(size["cols"], size["rows"]) for event in pending: # вид и уровень — на момент события, чтобы старые закрытия diff --git a/backend/tests/test_garden.py b/backend/tests/test_garden.py index 08fbb10..0e8f91d 100644 --- a/backend/tests/test_garden.py +++ b/backend/tests/test_garden.py @@ -120,10 +120,38 @@ assert resp.status_code == 200 balance_after = resp.json()["balance"] assert balance_after == 40 - DECORATIONS["fence"]["cost"] - assert any( - i["kind"] == "decoration" and i["item_key"] == "fence" + # Покупка кладёт декорацию в инвентарь: элемент есть, координат нет + item = next( + i for i in client.get("/api/garden").json()["items"] + if i["kind"] == "decoration" and i["item_key"] == "fence" ) + assert item["x"] is None and item["y"] is None + + # Из инвентаря на карту — перетаскиванием (PATCH с координатами) + resp = client.patch(f"/api/garden/items/{item['id']}", json={"x": 3, "y": 4}) + assert resp.status_code == 200 + placed = next( + i + for i in client.get("/api/garden").json()["items"] + if i["id"] == item["id"] + ) + assert (placed["x"], placed["y"]) == (3, 4) + + # Обратно в инвентарь (x/y None — только декорациям) + resp = client.patch(f"/api/garden/items/{item['id']}", json={"x": None, "y": None}) + assert resp.status_code == 200 + stored = next( + i + for i in client.get("/api/garden").json()["items"] + if i["id"] == item["id"] + ) + assert stored["x"] is None and stored["y"] is None + + # Растение в инвентарь убрать нельзя + plant = next(i for i in client.get("/api/garden").json()["items"] if i["kind"] == "plant") + resp = client.patch(f"/api/garden/items/{plant['id']}", json={"x": None, "y": None}) + assert resp.status_code == 400 def test_shop_unknown_400_and_unique_409(client: TestClient, monkeypatch: Any) -> None: diff --git a/docs/TZ.md b/docs/TZ.md index 635cfa3..294ba58 100644 --- a/docs/TZ.md +++ b/docs/TZ.md @@ -4,7 +4,7 @@ | | | |---|---| -| Версия ТЗ | 0.14 | +| Версия ТЗ | 0.15 | | Дата | 2026-09-20 | | Статус | На обсуждении | @@ -212,8 +212,12 @@ «откроется на уровне N» (если уровень не открывает), цена золотом (кнопка активна), цена серым + «не хватает N монет». - **Декорации** — чисто косметика: фонарь, лавочка, поилка, пруд, беседка - (уникальные), изгородь и клумба (повторяемые). Размещаются автоматически на - свободной ячейке, двигаются как растения. + (уникальные), изгородь и клумба (повторяемые). Двигаются как растения. + - **Инвентарь декораций**: покупка кладёт декорацию в **инвентарь** под картой + (`garden_items.x/y = NULL`) — место покупки всегда видно пользователю. Из + инвентаря декорация перетаскивается на карту; перетаскивание размещённой + декорации обратно на полосу инвентаря убирает её с карты (растения в + инвентарь вернуть нельзя — сад только растёт). - **Расширения карты**: стартовая сетка 32×20 ячеек; каждое расширение (+8 клеток периметра) открывается уровнем (I — ур. 3, II — ур. 5, III — ур. 8) и покупается монетами (100/250/500). Финал ~56×44. diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 6085572..dd89a9a 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -118,8 +118,9 @@ kind: 'plant' | 'decoration' ref_id: number | null item_key: string | null - x: number - y: number + /** Ячейки сетки; null — декорация в инвентаре (не размещена) */ + x: number | null + y: number | null stage: number // 0 росток, 1 куст, 2 цветение rarity: string | null task_title: string | null @@ -275,7 +276,7 @@ getXpEvents: () => request('/api/xp/events'), // сад (ТЗ 3.13) getGarden: () => request('/api/garden'), - moveGardenItem: (id: number, x: number, y: number) => + moveGardenItem: (id: number, x: number | null, y: number | null) => request<{ ok: boolean }>(`/api/garden/items/${id}`, { method: 'PATCH', body: JSON.stringify({ x, y }), diff --git a/frontend/src/components/GardenScene.vue b/frontend/src/components/GardenScene.vue index d5649f5..0765533 100644 --- a/frontend/src/components/GardenScene.vue +++ b/frontend/src/components/GardenScene.vue @@ -14,7 +14,7 @@ }>() const emit = defineEmits<{ - move: [id: number, x: number, y: number] + move: [id: number, x: number | null, y: number | null] upgrade: [id: number] }>() @@ -36,9 +36,16 @@ h: HOUSE_ROWS * CELL, })) -// Растения и декорации в одном списке; z-порядок по y (дальние — позади) +// Растения и декорации в одном списке; z-порядок по y (дальние — позади). +// Неразмещенные декорации (x/y null) — в инвентаре под сценой. const items = computed(() => - [...props.state.items].sort((a, b) => a.y - b.y || a.x - b.x), + [...props.state.items] + .filter((i) => i.x !== null && i.y !== null) + .sort((a, b) => (a.y ?? 0) - (b.y ?? 0) || (a.x ?? 0) - (b.x ?? 0)), +) + +const inventoryItems = computed(() => + props.state.items.filter((i) => i.x === null || i.y === null), ) // Размер растения по стадии и цвет по редкости @@ -65,10 +72,14 @@ } // --- Drag & drop: pointer events, snap к ячейке, emit на отпускании --- +// Источник перетаскивания: со сцены или из инвентаря (призрак у курсора). const svgEl = ref(null) +const invEl = ref(null) const drag = ref<{ id: number; dx: number; dy: number; moved: boolean } | null>(null) const dragPos = ref<{ id: number; x: number; y: number } | null>(null) +// Перетаскивание из инвентаря: призрак следует за курсором +const ghost = ref<{ id: number; icon: string; x: number; y: number } | null>(null) function cellFromEvent(e: PointerEvent, offX: number, offY: number): { x: number; y: number } { const svg = svgEl.value @@ -85,6 +96,30 @@ } } +// Курсор внутри карты? +function inSvg(e: PointerEvent): boolean { + const rect = svgEl.value?.getBoundingClientRect() + return ( + !!rect && + e.clientX >= rect.left && + e.clientX <= rect.right && + e.clientY >= rect.top && + e.clientY <= rect.bottom + ) +} + +// Курсор над полосой инвентаря? +function inInventory(e: PointerEvent): boolean { + const rect = invEl.value?.getBoundingClientRect() + return ( + !!rect && + e.clientX >= rect.left && + e.clientX <= rect.right && + e.clientY >= rect.top && + e.clientY <= rect.bottom + ) +} + function onPointerDown(item: GardenItem, e: PointerEvent): void { // Клик без перетаскивания обрабатывается на pointerup (поповер роста) const rect = svgEl.value?.getBoundingClientRect() @@ -92,8 +127,8 @@ const scale = width.value / rect.width drag.value = { id: item.id, - dx: (e.clientX - rect.left) * scale - item.x * CELL, - dy: (e.clientY - rect.top) * scale - item.y * CELL, + dx: (e.clientX - rect.left) * scale - (item.x ?? 0) * CELL, + dy: (e.clientY - rect.top) * scale - (item.y ?? 0) * CELL, moved: false, } dragPos.value = null @@ -120,6 +155,11 @@ } return } + // Декорацию можно вернуть в инвентарь — бросок на полосу инвентаря + if (item.kind === 'decoration' && inInventory(e)) { + emit('move', item.id, null, null) + return + } const cell = pos && pos.id === item.id ? pos : null if (cell && (cell.x !== item.x || cell.y !== item.y)) { emit('move', item.id, cell.x, cell.y) @@ -129,12 +169,43 @@ } } +// --- Drag из инвентаря: призрак у курсора, бросок на карту размещает --- + +function onInvPointerDown(item: GardenItem, e: PointerEvent): void { + e.preventDefault() + ghost.value = { id: item.id, icon: item.item_key ?? 'ph-flower', x: e.clientX, y: e.clientY } + window.addEventListener('pointermove', onInvMove) + window.addEventListener('pointerup', onInvUp) +} + +function onInvMove(e: PointerEvent): void { + if (ghost.value) { + ghost.value.x = e.clientX + ghost.value.y = e.clientY + } +} + +function onInvUp(e: PointerEvent): void { + window.removeEventListener('pointermove', onInvMove) + window.removeEventListener('pointerup', onInvUp) + const item = props.state.items.find((i) => i.id === ghost.value?.id) + ghost.value = null + if (!item) return + if (inSvg(e)) { + const cell = cellFromEvent(e, 0, 0) + emit('move', item.id, cell.x, cell.y) + item.x = cell.x + item.y = cell.y + } + // Бросок мимо карты — остаётся в инвентаре +} + // Позиция элемента с учётом перетаскивания function itemPos(item: GardenItem): { x: number; y: number } { if (dragPos.value && dragPos.value.id === item.id) { return { x: dragPos.value.x, y: dragPos.value.y } } - return { x: item.x, y: item.y } + return { x: item.x ?? 0, y: item.y ?? 0 } } // --- Поповер роста растения --- @@ -294,6 +365,30 @@ + + +
+ +