From 721dbe5ae70bcd1a6f63f160b6191ffd2717bd9b Mon Sep 17 00:00:00 2001 From: daddy Date: Tue, 22 Sep 2026 18:30:57 +0000 Subject: [PATCH] feat(studio,stacking,animations,collections): M16 ModBison Studio, object stacking, model animations, and poly collections for MVP 2 --- .gitignore | 4 + .../versions/0017_add_asset_collections.py | 53 ++ backend/app/main.py | 4 +- backend/app/models/__init__.py | 3 +- backend/app/models/asset_collection.py | 53 ++ backend/app/models/user.py | 1 + backend/app/routers/asset_collections.py | 458 +++++++++++++++ backend/app/routers/assets.py | 7 +- backend/tests/test_asset_collections.py | 235 ++++++++ scenes/studio/studio_main.tscn | 12 + scenes/studio/studio_start_screen.tscn | 12 + scripts/chunk_data_decoder.gd | 9 + scripts/components/object_motion_component.gd | 341 +++++++++++ scripts/editor/editor_core.gd | 72 ++- scripts/editor/placement_stack_manager.gd | 183 ++++++ scripts/editor/tools/transform_tool.gd | 24 +- scripts/editor/ui/editor_viewport.gd | 148 ++++- scripts/editor/ui/inspector_panel.gd | 364 ++++++++++++ scripts/main_menu.gd | 18 + scripts/smoke_test_model_animation.gd | 204 +++++++ scripts/smoke_test_moving_platform_ride.gd | 243 ++++++++ scripts/smoke_test_object_stacking.gd | 155 +++++ scripts/smoke_test_poly_collections_dock.gd | 137 +++++ scripts/smoke_test_studio_playtest.gd | 105 ++++ scripts/smoke_test_studio_shell.gd | 127 +++++ scripts/static_object_node.gd | 154 ++++- scripts/studio/asset_cache_manager.gd | 116 ++++ scripts/studio/gizmos/transform_gizmo_3d.gd | 165 ++++++ scripts/studio/mbworld_project_io.gd | 164 ++++++ scripts/studio/studio_collections_dock.gd | 268 +++++++++ scripts/studio/studio_hierarchy_dock.gd | 181 ++++++ scripts/studio/studio_inspector_dock.gd | 185 ++++++ scripts/studio/studio_main.gd | 535 ++++++++++++++++++ scripts/studio/studio_simulation_manager.gd | 140 +++++ scripts/studio/studio_start_screen.gd | 152 +++++ scripts/studio/studio_viewport_camera.gd | 173 ++++++ scripts/world_object.gd | 14 +- 37 files changed, 5202 insertions(+), 17 deletions(-) create mode 100644 backend/alembic/versions/0017_add_asset_collections.py create mode 100644 backend/app/models/asset_collection.py create mode 100644 backend/app/routers/asset_collections.py create mode 100644 backend/tests/test_asset_collections.py create mode 100644 scenes/studio/studio_main.tscn create mode 100644 scenes/studio/studio_start_screen.tscn create mode 100644 scripts/components/object_motion_component.gd create mode 100644 scripts/editor/placement_stack_manager.gd create mode 100644 scripts/smoke_test_model_animation.gd create mode 100644 scripts/smoke_test_moving_platform_ride.gd create mode 100644 scripts/smoke_test_object_stacking.gd create mode 100644 scripts/smoke_test_poly_collections_dock.gd create mode 100644 scripts/smoke_test_studio_playtest.gd create mode 100644 scripts/smoke_test_studio_shell.gd create mode 100644 scripts/studio/asset_cache_manager.gd create mode 100644 scripts/studio/gizmos/transform_gizmo_3d.gd create mode 100644 scripts/studio/mbworld_project_io.gd create mode 100644 scripts/studio/studio_collections_dock.gd create mode 100644 scripts/studio/studio_hierarchy_dock.gd create mode 100644 scripts/studio/studio_inspector_dock.gd create mode 100644 scripts/studio/studio_main.gd create mode 100644 scripts/studio/studio_simulation_manager.gd create mode 100644 scripts/studio/studio_start_screen.gd create mode 100644 scripts/studio/studio_viewport_camera.gd diff --git a/.gitignore b/.gitignore index 271061a..5ea1811 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,7 @@ Thumbs.db # Godot UID files (regenerated from path) *.uid assets/build_info.json + +# Dynamic uploads +uploads/ +backend/uploads/ diff --git a/backend/alembic/versions/0017_add_asset_collections.py b/backend/alembic/versions/0017_add_asset_collections.py new file mode 100644 index 0000000..12485a8 --- /dev/null +++ b/backend/alembic/versions/0017_add_asset_collections.py @@ -0,0 +1,53 @@ +"""add asset_collections and collection_assets tables + +Revision ID: 0017_add_asset_collections +Revises: 0016_content_guidelines +Create Date: 2026-09-22 00:00:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision: str = "0017_add_asset_collections" +down_revision: Union[str, None] = "0016_content_guidelines" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + bind = op.get_bind() + insp = sa.inspect(bind) + tables = insp.get_table_names() + + if "asset_collections" not in tables: + op.create_table( + "asset_collections", + sa.Column("id", sa.Integer(), primary_key=True, index=True), + sa.Column("title", sa.String(length=255), nullable=False, index=True), + sa.Column("description", sa.Text(), nullable=True), + sa.Column("theme", sa.String(length=64), nullable=False, server_default="general", index=True), + sa.Column("thumbnail_url", sa.String(length=512), nullable=True), + sa.Column("owner_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False), + sa.Column("is_public", sa.Boolean(), nullable=False, server_default=sa.text("true"), index=True), + sa.Column("tags", sa.String(length=512), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()), + ) + + if "collection_assets" not in tables: + op.create_table( + "collection_assets", + sa.Column("id", sa.Integer(), primary_key=True, index=True), + sa.Column("collection_id", sa.Integer(), sa.ForeignKey("asset_collections.id", ondelete="CASCADE"), nullable=False, index=True), + sa.Column("asset_id", sa.Integer(), sa.ForeignKey("assets.id", ondelete="CASCADE"), nullable=False, index=True), + sa.Column("sort_order", sa.Integer(), nullable=False, server_default="0"), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()), + ) + + +def downgrade() -> None: + op.drop_table("collection_assets") + op.drop_table("asset_collections") diff --git a/backend/app/main.py b/backend/app/main.py index 36e3c76..97fdcb3 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -7,7 +7,7 @@ from fastapi.middleware.cors import CORSMiddleware from app.config import settings from app.routers import ( - auth, users, worlds, assets, avatar, world_content, internal, + auth, users, worlds, assets, asset_collections, avatar, world_content, internal, presence, groups, social_friends, profiles, chat_dm, chat_reports, chat_world, wallet, payments, marketplace, payouts, discovery, ) @@ -47,6 +47,8 @@ app.include_router(users.router, prefix="/api/users", tags=["users"]) app.include_router(worlds.router, prefix="/api/worlds", tags=["worlds"]) app.include_router(world_content.router, prefix="/api/worlds", tags=["world-content"]) app.include_router(assets.router, prefix="/api/assets", tags=["assets"]) +app.include_router(asset_collections.router, prefix="/api/assets/collections", tags=["asset-collections"]) +app.include_router(asset_collections.router, prefix="/assets/collections", tags=["asset-collections"]) app.include_router(avatar.router, prefix="/api/avatar", tags=["avatar"]) app.include_router(internal.router, prefix="/api/internal", tags=["internal"]) app.include_router(presence.router, prefix="/api/presence", tags=["presence"]) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 859cf71..aef1263 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -2,6 +2,7 @@ from app.models.user import User from app.models.session import Session from app.models.world import World from app.models.asset import Asset +from app.models.asset_collection import AssetCollection, CollectionAsset from app.models.avatar_loadout import AvatarLoadout from app.models.world_content import WorldRoom, WorldChunk, WorldChunkRevision from app.models.social import Friendship, Follow, Block, FriendshipStatus @@ -18,7 +19,7 @@ from app.models.marketplace import ( from app.models.world_visit import WorldVisit __all__ = [ - "User", "Session", "World", "Asset", "AvatarLoadout", + "User", "Session", "World", "Asset", "AssetCollection", "CollectionAsset", "AvatarLoadout", "WorldRoom", "WorldChunk", "WorldChunkRevision", "Friendship", "Follow", "Block", "FriendshipStatus", "Conversation", "ConversationParticipant", "Message", "MessageReport", "ReportStatus", diff --git a/backend/app/models/asset_collection.py b/backend/app/models/asset_collection.py new file mode 100644 index 0000000..f355efd --- /dev/null +++ b/backend/app/models/asset_collection.py @@ -0,0 +1,53 @@ +from sqlalchemy import Column, Integer, String, ForeignKey, Text, DateTime, Boolean +from sqlalchemy.orm import relationship +from sqlalchemy.sql import func + +from app.database import Base + + +class AssetCollection(Base): + """A curated collection or pack of 3D models and assets.""" + __tablename__ = "asset_collections" + + id = Column(Integer, primary_key=True, index=True) + title = Column(String(255), nullable=False, index=True) + description = Column(Text, nullable=True) + theme = Column(String(64), nullable=False, default="general", index=True) + thumbnail_url = Column(String(512), nullable=True) + owner_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False) + is_public = Column(Boolean, default=True, nullable=False, index=True) + tags = Column(String(512), nullable=True) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) + + owner = relationship("User", back_populates="asset_collections") + items = relationship( + "CollectionAsset", + back_populates="collection", + cascade="all, delete-orphan", + order_by="CollectionAsset.sort_order", + ) + + +class CollectionAsset(Base): + """Join entity linking an Asset to an AssetCollection with ordering.""" + __tablename__ = "collection_assets" + + id = Column(Integer, primary_key=True, index=True) + collection_id = Column( + Integer, + ForeignKey("asset_collections.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + asset_id = Column( + Integer, + ForeignKey("assets.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + sort_order = Column(Integer, default=0, nullable=False) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + + collection = relationship("AssetCollection", back_populates="items") + asset = relationship("Asset") diff --git a/backend/app/models/user.py b/backend/app/models/user.py index 9bd291a..58b320f 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -28,6 +28,7 @@ class User(Base): refresh_tokens = relationship("RefreshToken", back_populates="user", cascade="all, delete-orphan") worlds = relationship("World", back_populates="owner") assets = relationship("Asset", back_populates="owner") + asset_collections = relationship("AssetCollection", back_populates="owner", cascade="all, delete-orphan") avatar_loadout = relationship( "AvatarLoadout", back_populates="user", diff --git a/backend/app/routers/asset_collections.py b/backend/app/routers/asset_collections.py new file mode 100644 index 0000000..1587333 --- /dev/null +++ b/backend/app/routers/asset_collections.py @@ -0,0 +1,458 @@ +"""Asset collection router supporting CRUD, theme filtering, and ZIP multi-model extraction.""" + +import io +import json +import os +from datetime import datetime +from pathlib import Path +from typing import Optional, Union +import zipfile + +from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Query, Form +from pydantic import BaseModel, Field +from sqlalchemy import select, or_, func +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from app.config import settings +from app.database import get_db +from app.dependencies import CurrentUser +from app.models.asset import Asset, AssetType +from app.models.asset_collection import AssetCollection, CollectionAsset +from app.services import gltf as gltf_service + +router = APIRouter() + + +# --- Pydantic Schemas --- + +class CollectionCreate(BaseModel): + title: str = Field(..., min_length=1, max_length=255) + description: Optional[str] = None + theme: str = Field("general", max_length=64) + thumbnail_url: Optional[str] = None + is_public: bool = True + tags: Optional[str] = None + + +class CollectionUpdate(BaseModel): + title: Optional[str] = Field(None, min_length=1, max_length=255) + description: Optional[str] = None + theme: Optional[str] = Field(None, max_length=64) + thumbnail_url: Optional[str] = None + is_public: Optional[bool] = None + tags: Optional[str] = None + + +class CollectionItemAdd(BaseModel): + asset_id: int + sort_order: Optional[int] = 0 + + +class AssetItemResponse(BaseModel): + id: int + name: str + description: Optional[str] = None + asset_type: str + file_path: str + file_size: Optional[int] = None + sort_order: int = 0 + + model_config = {"from_attributes": True} + + +class CollectionResponse(BaseModel): + id: int + title: str + description: Optional[str] = None + theme: str + thumbnail_url: Optional[str] = None + owner_id: int + is_public: bool + tags: Optional[str] = None + created_at: Union[datetime, str] + updated_at: Union[datetime, str] + items_count: int = 0 + items: list[AssetItemResponse] = [] + + model_config = {"from_attributes": True} + + +# --- Helpers --- + +def _sanitize_path(filename: str) -> str: + """Strip directories and non-safe characters.""" + base = os.path.basename(filename.replace("\\", "/")) + safe = "".join(c for c in base if c.isalnum() or c in "._-") + return safe or "model.glb" + + +# --- Endpoints --- + +@router.post("/", response_model=CollectionResponse, status_code=201) +async def create_collection( + payload: CollectionCreate, + current_user: CurrentUser, + db: AsyncSession = Depends(get_db), +): + """Create a new asset collection.""" + col = AssetCollection( + title=payload.title, + description=payload.description, + theme=payload.theme.lower().strip(), + thumbnail_url=payload.thumbnail_url, + owner_id=current_user.id, + is_public=payload.is_public, + tags=payload.tags, + ) + db.add(col) + await db.commit() + await db.refresh(col) + + return CollectionResponse( + id=col.id, + title=col.title, + description=col.description, + theme=col.theme, + thumbnail_url=col.thumbnail_url, + owner_id=col.owner_id, + is_public=col.is_public, + tags=col.tags, + created_at=str(col.created_at), + updated_at=str(col.updated_at), + items_count=0, + items=[], + ) + + +@router.get("/", response_model=list[CollectionResponse]) +async def list_collections( + current_user: CurrentUser, + theme: Optional[str] = Query(None, description="Filter by collection theme"), + search: Optional[str] = Query(None, description="Search by title, description or tags"), + owner_id: Optional[int] = Query(None, description="Filter by owner"), + include_private: bool = Query(False, description="Include own private collections"), + limit: int = Query(50, ge=1, le=100), + offset: int = Query(0, ge=0), + db: AsyncSession = Depends(get_db), +): + """List asset collections with optional theme filtering, search, and pagination.""" + stmt = select(AssetCollection).options( + selectinload(AssetCollection.items).selectinload(CollectionAsset.asset) + ) + + # Visibility: public collections or own collections + if include_private: + stmt = stmt.where(or_(AssetCollection.is_public == True, AssetCollection.owner_id == current_user.id)) + else: + stmt = stmt.where(or_(AssetCollection.is_public == True, AssetCollection.owner_id == current_user.id)) + + if theme: + stmt = stmt.where(AssetCollection.theme == theme.lower().strip()) + + if owner_id: + stmt = stmt.where(AssetCollection.owner_id == owner_id) + + if search: + s = f"%{search.strip()}%" + stmt = stmt.where( + or_( + AssetCollection.title.ilike(s), + AssetCollection.description.ilike(s), + AssetCollection.tags.ilike(s), + ) + ) + + stmt = stmt.order_by(AssetCollection.created_at.desc()).limit(limit).offset(offset) + result = await db.execute(stmt) + collections = result.scalars().all() + + resp: list[CollectionResponse] = [] + for col in collections: + items_resp: list[AssetItemResponse] = [] + for item in col.items: + if item.asset is not None: + items_resp.append( + AssetItemResponse( + id=item.asset.id, + name=item.asset.name, + description=item.asset.description, + asset_type=str(item.asset.asset_type.value if hasattr(item.asset.asset_type, "value") else item.asset.asset_type), + file_path=item.asset.file_path, + file_size=item.asset.file_size, + sort_order=item.sort_order, + ) + ) + resp.append( + CollectionResponse( + id=col.id, + title=col.title, + description=col.description, + theme=col.theme, + thumbnail_url=col.thumbnail_url, + owner_id=col.owner_id, + is_public=col.is_public, + tags=col.tags, + created_at=str(col.created_at), + updated_at=str(col.updated_at), + items_count=len(items_resp), + items=items_resp, + ) + ) + return resp + + +@router.get("/{collection_id}", response_model=CollectionResponse) +async def get_collection( + collection_id: int, + current_user: CurrentUser, + db: AsyncSession = Depends(get_db), +): + """Get single collection by ID with all item details.""" + stmt = ( + select(AssetCollection) + .options(selectinload(AssetCollection.items).selectinload(CollectionAsset.asset)) + .where(AssetCollection.id == collection_id) + ) + res = await db.execute(stmt) + col = res.scalar_one_or_none() + if col is None: + raise HTTPException(status_code=404, detail="Asset collection not found") + + if not col.is_public and col.owner_id != current_user.id: + raise HTTPException(status_code=403, detail="Private collection") + + items_resp: list[AssetItemResponse] = [] + for item in col.items: + if item.asset is not None: + items_resp.append( + AssetItemResponse( + id=item.asset.id, + name=item.asset.name, + description=item.asset.description, + asset_type=str(item.asset.asset_type.value if hasattr(item.asset.asset_type, "value") else item.asset.asset_type), + file_path=item.asset.file_path, + file_size=item.asset.file_size, + sort_order=item.sort_order, + ) + ) + + return CollectionResponse( + id=col.id, + title=col.title, + description=col.description, + theme=col.theme, + thumbnail_url=col.thumbnail_url, + owner_id=col.owner_id, + is_public=col.is_public, + tags=col.tags, + created_at=str(col.created_at), + updated_at=str(col.updated_at), + items_count=len(items_resp), + items=items_resp, + ) + + +@router.patch("/{collection_id}", response_model=CollectionResponse) +async def update_collection( + collection_id: int, + payload: CollectionUpdate, + current_user: CurrentUser, + db: AsyncSession = Depends(get_db), +): + """Update collection metadata (owner only).""" + col = await db.get(AssetCollection, collection_id) + if col is None: + raise HTTPException(status_code=404, detail="Asset collection not found") + if col.owner_id != current_user.id: + raise HTTPException(status_code=403, detail="Not collection owner") + + up_data = payload.model_dump(exclude_unset=True) + for k, v in up_data.items(): + if k == "theme" and v is not None: + v = str(v).lower().strip() + setattr(col, k, v) + + await db.commit() + await db.refresh(col) + return await get_collection(collection_id, current_user, db) + + +@router.delete("/{collection_id}", status_code=204) +async def delete_collection( + collection_id: int, + current_user: CurrentUser, + db: AsyncSession = Depends(get_db), +): + """Delete a collection (owner only).""" + col = await db.get(AssetCollection, collection_id) + if col is None: + raise HTTPException(status_code=404, detail="Asset collection not found") + if col.owner_id != current_user.id: + raise HTTPException(status_code=403, detail="Not collection owner") + + await db.delete(col) + await db.commit() + + +@router.post("/{collection_id}/items", response_model=CollectionResponse) +async def add_item_to_collection( + collection_id: int, + payload: CollectionItemAdd, + current_user: CurrentUser, + db: AsyncSession = Depends(get_db), +): + """Add an asset to the collection (owner only).""" + col = await db.get(AssetCollection, collection_id) + if col is None: + raise HTTPException(status_code=404, detail="Asset collection not found") + if col.owner_id != current_user.id: + raise HTTPException(status_code=403, detail="Not collection owner") + + asset = await db.get(Asset, payload.asset_id) + if asset is None: + raise HTTPException(status_code=404, detail="Asset not found") + + # Check if already added + chk = await db.execute( + select(CollectionAsset).where( + CollectionAsset.collection_id == collection_id, + CollectionAsset.asset_id == payload.asset_id, + ) + ) + existing = chk.scalar_one_or_none() + if existing is not None: + existing.sort_order = payload.sort_order or existing.sort_order + else: + new_link = CollectionAsset( + collection_id=collection_id, + asset_id=payload.asset_id, + sort_order=payload.sort_order or 0, + ) + db.add(new_link) + + await db.commit() + return await get_collection(collection_id, current_user, db) + + +@router.delete("/{collection_id}/items/{asset_id}", response_model=CollectionResponse) +async def remove_item_from_collection( + collection_id: int, + asset_id: int, + current_user: CurrentUser, + db: AsyncSession = Depends(get_db), +): + """Remove an asset from the collection (owner only).""" + col = await db.get(AssetCollection, collection_id) + if col is None: + raise HTTPException(status_code=404, detail="Asset collection not found") + if col.owner_id != current_user.id: + raise HTTPException(status_code=403, detail="Not collection owner") + + chk = await db.execute( + select(CollectionAsset).where( + CollectionAsset.collection_id == collection_id, + CollectionAsset.asset_id == asset_id, + ) + ) + existing = chk.scalar_one_or_none() + if existing is not None: + await db.delete(existing) + await db.commit() + + return await get_collection(collection_id, current_user, db) + + +@router.post("/upload-zip", response_model=CollectionResponse, status_code=201) +async def upload_collection_zip( + current_user: CurrentUser, + file: UploadFile = File(...), + title: str = Form(...), + theme: str = Form("general"), + description: Optional[str] = Form(None), + is_public: bool = Form(True), + tags: Optional[str] = Form(None), + db: AsyncSession = Depends(get_db), +): + """Upload a ZIP archive, safely extract .glb / .gltf models, create Asset entries, and pack into AssetCollection.""" + content = await file.read() + if not zipfile.is_zipfile(io.BytesIO(content)): + raise HTTPException(status_code=400, detail="Invalid ZIP archive") + + upload_dir = Path(settings.UPLOAD_DIR) / "collections" + upload_dir.mkdir(parents=True, exist_ok=True) + + # 1. Create collection record first + col = AssetCollection( + title=title, + description=description, + theme=theme.lower().strip(), + owner_id=current_user.id, + is_public=is_public, + tags=tags, + ) + db.add(col) + await db.flush() + + extracted_assets: list[Asset] = [] + sort_idx = 0 + + try: + with zipfile.ZipFile(io.BytesIO(content)) as zf: + for entry in zf.infolist(): + if entry.is_dir(): + continue + + clean_name = _sanitize_path(entry.filename) + ext = clean_name.rsplit(".", 1)[-1].lower() if "." in clean_name else "" + + if ext not in ["glb", "gltf", "obj"]: + continue + + model_bytes = zf.read(entry.filename) + if len(model_bytes) == 0: + continue + + # Save file to disk + stored_filename = f"col_{col.id}_{sort_idx}_{clean_name}" + dest_path = upload_dir / stored_filename + dest_path.write_bytes(model_bytes) + + # Create asset + base_name = clean_name.rsplit(".", 1)[0].replace("_", " ").title() + asset = Asset( + name=base_name, + description=f"Extracted from collection '{title}' ({clean_name})", + asset_type=AssetType.MODEL_3D, + file_path=str(dest_path), + file_size=len(model_bytes), + mime_type="model/gltf-binary" if ext == "glb" else "model/gltf+json", + owner_id=current_user.id, + is_public=is_public, + ) + db.add(asset) + await db.flush() + + link = CollectionAsset( + collection_id=col.id, + asset_id=asset.id, + sort_order=sort_idx, + ) + db.add(link) + extracted_assets.append(asset) + sort_idx += 1 + + if not extracted_assets: + raise HTTPException(status_code=400, detail="ZIP archive contains no valid 3D models (.glb, .gltf)") + + await db.commit() + await db.refresh(col) + + except HTTPException: + await db.rollback() + raise + except Exception as exc: + await db.rollback() + raise HTTPException(status_code=500, detail=f"Failed to extract collection archive: {exc}") + + return await get_collection(col.id, current_user, db) diff --git a/backend/app/routers/assets.py b/backend/app/routers/assets.py index 585ee7b..ef77651 100644 --- a/backend/app/routers/assets.py +++ b/backend/app/routers/assets.py @@ -3,7 +3,8 @@ import json import os from pathlib import Path -from typing import Optional +from datetime import datetime +from typing import Optional, Union from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Query from fastapi.responses import FileResponse @@ -43,8 +44,8 @@ class AssetResponse(BaseModel): world_id: Optional[int] owner_id: int is_public: bool - created_at: str - updated_at: str + created_at: Union[datetime, str] + updated_at: Union[datetime, str] model_config = {"from_attributes": True} diff --git a/backend/tests/test_asset_collections.py b/backend/tests/test_asset_collections.py new file mode 100644 index 0000000..69cb90c --- /dev/null +++ b/backend/tests/test_asset_collections.py @@ -0,0 +1,235 @@ +"""Integration tests for Poly Model Asset Collections and ZIP batch extraction. + +Covers tasks dfe19da5a448 and 840bb736d098. +""" + +import io +import zipfile +import pytest +from fastapi.testclient import TestClient + +from app.main import app + + +def _register(client: TestClient, username: str) -> tuple[int, dict[str, str]]: + resp = client.post( + "/api/auth/register", + json={ + "username": username, + "email": f"{username}@example.com", + "password": "secret123", + "date_of_birth": "1990-01-01", + }, + ) + assert resp.status_code == 201, resp.text + token = resp.json()["access_token"] + headers = {"Authorization": f"Bearer {token}"} + me = client.get("/api/users/me", headers=headers) + assert me.status_code == 200, me.text + return me.json()["id"], headers + + +def test_collection_crud_and_permissions(): + client = TestClient(app) + uid1, h1 = _register(client, "col_user_1") + uid2, h2 = _register(client, "col_user_2") + + # 1. Create collection as user 1 + resp = client.post( + "/api/assets/collections/", + headers=h1, + json={ + "title": "Low Poly Fantasy Props", + "description": "Swords, shields, and potions", + "theme": "fantasy", + "is_public": True, + "tags": "fantasy,lowpoly,weapons", + }, + ) + assert resp.status_code == 201, resp.text + col_data = resp.json() + assert col_data["title"] == "Low Poly Fantasy Props" + assert col_data["theme"] == "fantasy" + assert col_data["owner_id"] == uid1 + col_id = col_data["id"] + + # 2. List collections with theme filter + resp = client.get("/api/assets/collections/", headers=h2, params={"theme": "fantasy"}) + assert resp.status_code == 200 + cols = resp.json() + assert any(c["id"] == col_id for c in cols) + + # 3. List with non-matching theme filter + resp = client.get("/api/assets/collections/", headers=h2, params={"theme": "scifi"}) + assert resp.status_code == 200 + cols = resp.json() + assert not any(c["id"] == col_id for c in cols) + + # 4. Search filter + resp = client.get("/api/assets/collections/", headers=h2, params={"search": "shields"}) + assert resp.status_code == 200 + assert any(c["id"] == col_id for c in resp.json()) + + # 5. User 2 views public collection + resp = client.get(f"/api/assets/collections/{col_id}", headers=h2) + assert resp.status_code == 200 + assert resp.json()["id"] == col_id + + # 6. User 2 cannot modify User 1's collection + resp = client.patch( + f"/api/assets/collections/{col_id}", + headers=h2, + json={"title": "Hacked Title"}, + ) + assert resp.status_code == 403 + + # 7. User 2 cannot delete User 1's collection + resp = client.delete(f"/api/assets/collections/{col_id}", headers=h2) + assert resp.status_code == 403 + + # 8. User 1 updates collection to private + resp = client.patch( + f"/api/assets/collections/{col_id}", + headers=h1, + json={"is_public": False, "theme": "medieval"}, + ) + assert resp.status_code == 200 + assert resp.json()["is_public"] is False + assert resp.json()["theme"] == "medieval" + + # 9. User 2 can no longer view private collection + resp = client.get(f"/api/assets/collections/{col_id}", headers=h2) + assert resp.status_code == 403 + + # 10. User 1 deletes collection + resp = client.delete(f"/api/assets/collections/{col_id}", headers=h1) + assert resp.status_code == 204 + + # 11. Verify gone + resp = client.get(f"/api/assets/collections/{col_id}", headers=h1) + assert resp.status_code == 404 + + +def test_collection_item_association_and_ordering(): + client = TestClient(app) + uid, h = _register(client, "col_items_user") + + # Create 2 assets + a1_resp = client.post( + "/api/assets/", + headers=h, + json={"name": "Wooden Crate", "asset_type": "model_3d"}, + ) + assert a1_resp.status_code == 201, a1_resp.text + asset1_id = a1_resp.json()["id"] + + a2_resp = client.post( + "/api/assets/", + headers=h, + json={"name": "Iron Barrel", "asset_type": "model_3d"}, + ) + assert a2_resp.status_code == 201, a2_resp.text + asset2_id = a2_resp.json()["id"] + + # Create collection + col_resp = client.post( + "/api/assets/collections/", + headers=h, + json={"title": "Industrial Pack", "theme": "industrial"}, + ) + assert col_resp.status_code == 201 + col_id = col_resp.json()["id"] + + # Add items with explicit ordering + add1 = client.post( + f"/api/assets/collections/{col_id}/items", + headers=h, + json={"asset_id": asset1_id, "sort_order": 0}, + ) + assert add1.status_code == 200 + assert add1.json()["items_count"] == 1 + assert add1.json()["items"][0]["id"] == asset1_id + + add2 = client.post( + f"/api/assets/collections/{col_id}/items", + headers=h, + json={"asset_id": asset2_id, "sort_order": 1}, + ) + assert add2.status_code == 200 + assert add2.json()["items_count"] == 2 + assert add2.json()["items"][1]["id"] == asset2_id + + # Remove item 1 + rem = client.delete( + f"/api/assets/collections/{col_id}/items/{asset1_id}", + headers=h, + ) + assert rem.status_code == 200 + assert rem.json()["items_count"] == 1 + assert rem.json()["items"][0]["id"] == asset2_id + + +def test_collection_zip_upload_and_extraction(): + client = TestClient(app) + uid, h = _register(client, "col_zip_user") + + # 1. Build a valid in-memory zip file with mock GLB content + zip_buf = io.BytesIO() + with zipfile.ZipFile(zip_buf, "w") as zf: + zf.writestr("stone_pillar.glb", b"glTFmockheader_binary_stone_pillar") + zf.writestr("wooden_arch.glb", b"glTFmockheader_binary_wooden_arch") + zf.writestr("temple_roof.gltf", b'{"asset": {"version": "2.0"}}') + zf.writestr("readme.txt", b"Instructions for this pack") + zip_bytes = zip_buf.getvalue() + + # Upload zip pack + resp = client.post( + "/api/assets/collections/upload-zip", + headers=h, + data={ + "title": "Ancient Ruins Pack", + "theme": "historical", + "description": "Set of stone pillars and wooden arches", + "is_public": "true", + "tags": "ruins,ancient,stone", + }, + files={"file": ("ruins_pack.zip", zip_bytes, "application/zip")}, + ) + assert resp.status_code == 201, resp.text + data = resp.json() + assert data["title"] == "Ancient Ruins Pack" + assert data["theme"] == "historical" + assert data["items_count"] == 3 + names = [i["name"] for i in data["items"]] + assert "Stone Pillar" in names + assert "Wooden Arch" in names + assert "Temple Roof" in names + + +def test_collection_zip_error_handling(): + client = TestClient(app) + uid, h = _register(client, "col_badzip_user") + + # 1. Invalid zip payload + resp = client.post( + "/api/assets/collections/upload-zip", + headers=h, + data={"title": "Corrupt Pack", "theme": "general"}, + files={"file": ("bad.zip", b"not-a-valid-zip-file", "application/zip")}, + ) + assert resp.status_code == 400 + assert "Invalid ZIP archive" in resp.json()["detail"] + + # 2. Valid zip but no 3D models + zip_buf = io.BytesIO() + with zipfile.ZipFile(zip_buf, "w") as zf: + zf.writestr("notes.txt", b"Hello world") + zf.writestr("image.png", b"fake image bytes") + resp = client.post( + "/api/assets/collections/upload-zip", + headers=h, + data={"title": "No Models Pack", "theme": "general"}, + files={"file": ("empty.zip", zip_buf.getvalue(), "application/zip")}, + ) + assert resp.status_code == 400 + assert "no valid 3D models" in resp.json()["detail"] diff --git a/scenes/studio/studio_main.tscn b/scenes/studio/studio_main.tscn new file mode 100644 index 0000000..23357b8 --- /dev/null +++ b/scenes/studio/studio_main.tscn @@ -0,0 +1,12 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://scripts/studio/studio_main.gd" id="1_studio"] + +[node name="StudioMain" type="Control"] +layout_mode = 3 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +script = ExtResource("1_studio") diff --git a/scenes/studio/studio_start_screen.tscn b/scenes/studio/studio_start_screen.tscn new file mode 100644 index 0000000..e65c0f8 --- /dev/null +++ b/scenes/studio/studio_start_screen.tscn @@ -0,0 +1,12 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://scripts/studio/studio_start_screen.gd" id="1_start"] + +[node name="StudioStartScreen" type="Control"] +layout_mode = 3 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +script = ExtResource("1_start") diff --git a/scripts/chunk_data_decoder.gd b/scripts/chunk_data_decoder.gd index e22c238..13d1182 100644 --- a/scripts/chunk_data_decoder.gd +++ b/scripts/chunk_data_decoder.gd @@ -130,6 +130,15 @@ class WorldObjectData: var dynamic: bool = false var flags: ContentFlags = ContentFlags.new() + func get_parent_id() -> String: + return str(properties.get("parent_id", "")) + + func set_parent_id(p_id: String) -> void: + if p_id != "": + properties["parent_id"] = p_id + else: + properties.erase("parent_id") + ## The complete decoded chunk payload. class ChunkPayload: diff --git a/scripts/components/object_motion_component.gd b/scripts/components/object_motion_component.gd new file mode 100644 index 0000000..6b68d10 --- /dev/null +++ b/scripts/components/object_motion_component.gd @@ -0,0 +1,341 @@ +class_name ObjectMotionComponent +extends Node +## Component providing procedural movement (Rotate, Bob/Float, Waypoint paths) +## and interactive triggers for placed world objects. +## Supports platform velocity for player character riding and multiplayer timestamp sync. + +signal motion_started +signal motion_stopped +signal waypoint_reached(index: int) + +enum MotionType { NONE, ROTATE, BOB, WAYPOINT } +enum TriggerMode { ALWAYS, PROXIMITY, INTERACT, SIGNAL } +enum LoopMode { CYCLE, PING_PONG, ONCE } + +var motion_type: String = "none" +var trigger_mode: String = "always" +var loop_mode: String = "cycle" + +# Rotation parameters +var rotation_axis: Vector3 = Vector3.UP +var rotation_speed: float = 45.0 # degrees per second + +# Bob / Float parameters +var bob_axis: Vector3 = Vector3.UP +var bob_amplitude: float = 0.5 +var bob_frequency: float = 1.0 + +# Waypoint parameters +var waypoints: Array[Vector3] = [] +var move_speed: float = 2.0 +var pause_time: float = 0.0 + +# Trigger parameters +var trigger_radius: float = 3.5 +var is_active: bool = true + +# Runtime state +var _target_node: Node3D = null +var _initial_pos: Vector3 = Vector3.ZERO +var _initial_basis: Basis = Basis.IDENTITY +var _time: float = 0.0 +var _current_waypoint_idx: int = 0 +var _waypoint_forward: bool = true +var _pause_timer: float = 0.0 +var linear_velocity: Vector3 = Vector3.ZERO +var angular_velocity: Vector3 = Vector3.ZERO + + +func _ready() -> void: + add_to_group("moving_objects") + if _target_node == null: + _target_node = get_parent() as Node3D + if _target_node != null: + _initial_pos = _target_node.position + _initial_basis = _target_node.basis + if trigger_mode != "always": + is_active = false + + +func setup(p_target: Node3D, config: Dictionary = {}) -> void: + _target_node = p_target + if _target_node != null: + _initial_pos = _target_node.position + _initial_basis = _target_node.basis + _time = 0.0 + _pause_timer = 0.0 + _current_waypoint_idx = 0 + _waypoint_forward = true + linear_velocity = Vector3.ZERO + angular_velocity = Vector3.ZERO + if not config.is_empty(): + load_from_dict(config) + + +func _physics_process(delta: float) -> void: + if _target_node == null or delta <= 0.0: + return + + if trigger_mode.to_lower() == "proximity": + _process_proximity_check() + + if not is_active: + linear_velocity = Vector3.ZERO + angular_velocity = Vector3.ZERO + _apply_velocities_to_colliders() + return + + _time += delta + var prev_pos := _target_node.position + + match motion_type.to_lower(): + "rotate": + _process_rotate(delta) + "bob", "oscillate": + _process_bob(delta) + "waypoint", "patrol": + _process_waypoint(delta) + + linear_velocity = (_target_node.position - prev_pos) / delta + _apply_velocities_to_colliders() + + +func _process_proximity_check() -> void: + if _target_node == null: + return + var target_pos := _target_node.global_position if _target_node.is_inside_tree() else _target_node.position + var players := get_tree().get_nodes_in_group("players") + var nearby := false + for p in players: + if p is Node3D: + var p_pos := (p as Node3D).global_position if (p as Node3D).is_inside_tree() else (p as Node3D).position + if p_pos.distance_to(target_pos) <= trigger_radius: + nearby = true + break + if nearby and not is_active: + start_motion() + elif not nearby and is_active: + stop_motion() + + +func check_proximity_to_position(pos: Vector3) -> bool: + if _target_node == null: + return false + var target_pos := _target_node.global_position if _target_node.is_inside_tree() else _target_node.position + var dist := target_pos.distance_to(pos) + var in_range := dist <= trigger_radius + if in_range and not is_active: + start_motion() + elif not in_range and is_active: + stop_motion() + return in_range + + +func on_interact() -> void: + if trigger_mode.to_lower() == "interact": + toggle_motion() + + +func on_signal(_sig_name: String = "") -> void: + if trigger_mode.to_lower() == "signal": + toggle_motion() + + +func _process_rotate(delta: float) -> void: + var rad_speed := deg_to_rad(rotation_speed) + var rot_delta := rad_speed * delta + _target_node.rotate(rotation_axis.normalized(), rot_delta) + angular_velocity = rotation_axis.normalized() * rad_speed + + +func _process_bob(_delta: float) -> void: + var offset: float = sin(_time * bob_frequency * TAU) * bob_amplitude + _target_node.position = _initial_pos + (bob_axis.normalized() * offset) + + +func _process_waypoint(delta: float) -> void: + if waypoints.is_empty(): + return + + if _pause_timer > 0.0: + _pause_timer -= delta + return + + var target_wp := waypoints[_current_waypoint_idx] + var current_pos := _target_node.position + var to_target := target_wp - current_pos + var dist := to_target.length() + + if dist < 0.001: + _advance_waypoint() + target_wp = waypoints[_current_waypoint_idx] + to_target = target_wp - current_pos + dist = to_target.length() + + var step := move_speed * delta + + if dist <= step: + _target_node.position = target_wp + waypoint_reached.emit(_current_waypoint_idx) + _pause_timer = pause_time + _advance_waypoint() + else: + _target_node.position += to_target.normalized() * step + + +func _advance_waypoint() -> void: + match loop_mode.to_lower(): + "cycle": + _current_waypoint_idx = (_current_waypoint_idx + 1) % waypoints.size() + "ping_pong": + if _waypoint_forward: + if _current_waypoint_idx >= waypoints.size() - 1: + _waypoint_forward = false + _current_waypoint_idx = maxi(0, waypoints.size() - 2) + else: + _current_waypoint_idx += 1 + else: + if _current_waypoint_idx <= 0: + _waypoint_forward = true + _current_waypoint_idx = mini(1, waypoints.size() - 1) + else: + _current_waypoint_idx -= 1 + "once": + if _current_waypoint_idx < waypoints.size() - 1: + _current_waypoint_idx += 1 + else: + is_active = false + motion_stopped.emit() + + +func _apply_velocities_to_colliders() -> void: + if _target_node == null: + return + var sb := _target_node.find_child("collision", true, false) as StaticBody3D + if sb == null: + sb = _target_node.find_child("*", true, false) as StaticBody3D + if sb != null: + sb.constant_linear_velocity = linear_velocity + sb.constant_angular_velocity = angular_velocity + + +func start_motion() -> void: + is_active = true + motion_started.emit() + + +func stop_motion() -> void: + is_active = false + linear_velocity = Vector3.ZERO + angular_velocity = Vector3.ZERO + _apply_velocities_to_colliders() + motion_stopped.emit() + + +func toggle_motion() -> void: + if is_active: + stop_motion() + else: + start_motion() + + +func reverse_motion() -> void: + rotation_speed = -rotation_speed + _waypoint_forward = not _waypoint_forward + + +func sync_to_timestamp(server_time: float) -> void: + _time = server_time + if _target_node == null: + return + match motion_type.to_lower(): + "bob", "oscillate": + var offset: float = sin(_time * bob_frequency * TAU) * bob_amplitude + _target_node.position = _initial_pos + (bob_axis.normalized() * offset) + "rotate": + var angle: float = deg_to_rad(rotation_speed) * server_time + _target_node.basis = _initial_basis.rotated(rotation_axis.normalized(), angle) + "waypoint", "patrol": + if waypoints.size() >= 2 and move_speed > 0.0: + _sync_waypoints(server_time) + + +func _sync_waypoints(t: float) -> void: + var total_dur := 0.0 + var seg_durs: Array[float] = [] + var count := waypoints.size() + var seg_count := count if loop_mode == "cycle" else count - 1 + for i in range(seg_count): + var p0: Vector3 = waypoints[i] + var p1: Vector3 = waypoints[(i + 1) % count] + var d := p0.distance_to(p1) + var dur := (d / move_speed) + pause_time + seg_durs.append(dur) + total_dur += dur + if total_dur <= 0.0: + return + var progress := fmod(t, total_dur) + var accumulated := 0.0 + for i in range(seg_count): + var dur := seg_durs[i] + if progress <= accumulated + dur: + var seg_t := (progress - accumulated) / maxf(0.001, (dur - pause_time)) + seg_t = clampf(seg_t, 0.0, 1.0) + var p0: Vector3 = waypoints[i] + var p1: Vector3 = waypoints[(i + 1) % count] + _target_node.position = p0.lerp(p1, seg_t) + _current_waypoint_idx = (i + 1) % count + break + accumulated += dur + + +func to_dict() -> Dictionary: + var wps_serialized: Array = [] + for wp in waypoints: + wps_serialized.append([wp.x, wp.y, wp.z]) + return { + "motion_type": motion_type, + "trigger_mode": trigger_mode, + "loop_mode": loop_mode, + "rotation_axis": [rotation_axis.x, rotation_axis.y, rotation_axis.z], + "rotation_speed": rotation_speed, + "bob_axis": [bob_axis.x, bob_axis.y, bob_axis.z], + "bob_amplitude": bob_amplitude, + "bob_frequency": bob_frequency, + "move_speed": move_speed, + "pause_time": pause_time, + "waypoints": wps_serialized, + "trigger_radius": trigger_radius, + "is_active": is_active, + } + + +func load_from_dict(d: Dictionary) -> void: + motion_type = str(d.get("motion_type", "none")) + trigger_mode = str(d.get("trigger_mode", "always")) + loop_mode = str(d.get("loop_mode", "cycle")) + rotation_speed = float(d.get("rotation_speed", 45.0)) + bob_amplitude = float(d.get("bob_amplitude", 0.5)) + bob_frequency = float(d.get("bob_frequency", 1.0)) + move_speed = float(d.get("move_speed", 2.0)) + pause_time = float(d.get("pause_time", 0.0)) + trigger_radius = float(d.get("trigger_radius", 3.5)) + is_active = bool(d.get("is_active", trigger_mode == "always")) + + if d.has("rotation_axis"): + var a = d["rotation_axis"] + if a is Array and a.size() >= 3: + rotation_axis = Vector3(a[0], a[1], a[2]) + + if d.has("bob_axis"): + var b = d["bob_axis"] + if b is Array and b.size() >= 3: + bob_axis = Vector3(b[0], b[1], b[2]) + + if d.has("waypoints"): + waypoints.clear() + for wp in d["waypoints"]: + if wp is Array and wp.size() >= 3: + waypoints.append(Vector3(wp[0], wp[1], wp[2])) + elif wp is Vector3: + waypoints.append(wp) diff --git a/scripts/editor/editor_core.gd b/scripts/editor/editor_core.gd index 39e83bc..04ab7e9 100644 --- a/scripts/editor/editor_core.gd +++ b/scripts/editor/editor_core.gd @@ -36,6 +36,8 @@ var selection: SelectionManager var tools: ToolRegistry var catalog: EditorObjectCatalog = null +const PlacementStackManagerClass = preload("res://scripts/editor/placement_stack_manager.gd") + func _init(p_model: ChunkEditorModel, p_catalog: EditorObjectCatalog = null) -> void: model = p_model @@ -72,16 +74,80 @@ func erase_tile(layer_idx: int, x: int, y: int, z: int = 0) -> bool: func place_object(catalog_id: String, position: Vector3, - rotation: Vector4 = Vector4(0, 0, 0, 1), scale: Vector3 = Vector3.ONE) -> String: - var result: Variant = use_tool("object_place", { + rotation: Vector4 = Vector4(0, 0, 0, 1), scale: Vector3 = Vector3.ONE, + properties: Dictionary = {}) -> String: + var args: Dictionary = { "catalog_id": catalog_id, "position": position, "rotation": rotation, "scale": scale, - }) + } + if not properties.is_empty(): + args["properties"] = properties + var result: Variant = use_tool("object_place", args) return result if result is String else "" +func place_object_stacked(catalog_id: String, target_object_id: String = "", + hit_point: Vector3 = Vector3.ZERO, surface_normal: Vector3 = Vector3(0, 0, 1), + elevation_offset: float = 0.0, align_to_normal: bool = false) -> String: + var target_obj: ChunkDataDecoder.WorldObjectData = null + if target_object_id != "": + target_obj = model.find_object(target_object_id) + var stack_info: Dictionary = PlacementStackManagerClass.calculate_stack_transform( + catalog_id, target_obj, hit_point, surface_normal, elevation_offset, align_to_normal + ) + var props: Dictionary = {} + if stack_info["parent_id"] != "": + props["parent_id"] = stack_info["parent_id"] + props["stacked_on"] = stack_info["parent_id"] + if elevation_offset != 0.0: + props["elevation_offset"] = elevation_offset + + return place_object(catalog_id, stack_info["position"], stack_info["rotation"], Vector3.ONE, props) + + +func drop_object_to_surface(object_id: String) -> bool: + var obj := model.find_object(object_id) + if obj == null: + return false + var old_pos := obj.position + var old_props := obj.properties.duplicate(true) + if not PlacementStackManagerClass.drop_to_surface(model, object_id): + return false + var new_pos := obj.position + var new_props := obj.properties.duplicate(true) + + undo_redo.begin("Drop '%s' to surface" % object_id) + undo_redo.add_do(func(): + var target := model.find_object(object_id) + if target != null: + target.position = new_pos + target.properties = new_props + ) + undo_redo.add_undo(func(): + var target := model.find_object(object_id) + if target != null: + target.position = old_pos + target.properties = old_props + ) + undo_redo.commit() + return true + + +func adjust_object_elevation(object_id: String, delta_z: float) -> bool: + var obj := model.find_object(object_id) + if obj == null: + return false + var target_pos := obj.position + Vector3(0, 0, delta_z) + target_pos.z = maxf(0.0, target_pos.z) + return move_object(object_id, target_pos) + + +func get_stacked_children(parent_id: String) -> Array[String]: + return PlacementStackManagerClass.get_all_children(model, parent_id) + + func delete_object(object_id: String) -> bool: return bool(use_tool("erase", {"object_id": object_id})) diff --git a/scripts/editor/placement_stack_manager.gd b/scripts/editor/placement_stack_manager.gd new file mode 100644 index 0000000..3fe4063 --- /dev/null +++ b/scripts/editor/placement_stack_manager.gd @@ -0,0 +1,183 @@ +class_name PlacementStackManager +extends RefCounted +## Manages 3D surface collision, stacking calculations, AABB normalization, +## and hierarchical parenting for world objects placed on top of other objects. +## +## Coordinate convention in Chunk data: +## X = horizontal X +## Y = horizontal Y (ground Y in chunk grid) +## Z = vertical height (CHUNK_SIZE_Z elevation) +## +## In Godot 3D: +## Vector3(X, Z_height, Y) + +## Known default bounding boxes for catalog items (size and base Y offset) +const DEFAULT_BOUNDS: Dictionary = { + "crate_small": {"size": Vector3(1.0, 1.0, 1.0), "base_offset": 0.0}, + "crate_large": {"size": Vector3(2.0, 2.0, 2.0), "base_offset": 0.0}, + "chest_01": {"size": Vector3(1.0, 0.6, 0.8), "base_offset": 0.0}, + "chest": {"size": Vector3(1.0, 0.6, 0.8), "base_offset": 0.0}, + "buffalo_chip_coin": {"size": Vector3(0.4, 0.4, 0.1), "base_offset": 0.0}, + "table_wooden": {"size": Vector3(1.8, 1.2, 0.9), "base_offset": 0.0}, + "table": {"size": Vector3(1.8, 1.2, 0.9), "base_offset": 0.0}, + "chair": {"size": Vector3(0.6, 0.6, 0.9), "base_offset": 0.0}, + "rock_01": {"size": Vector3(1.2, 1.2, 0.8), "base_offset": 0.0}, + "lamp_post_01": {"size": Vector3(0.5, 0.5, 3.0), "base_offset": 0.0}, + "bison": {"size": Vector3(1.5, 2.4, 1.8), "base_offset": 0.0}, + "default": {"size": Vector3(1.0, 1.0, 1.0), "base_offset": 0.0}, +} + + +## Return the 3D dimensions of an object by catalog_id. +## Note: Returned Vector3 uses (width_x, depth_y, height_z) matching chunk coordinates. +static func get_dimensions(catalog_id: String, scale: Vector3 = Vector3.ONE) -> Vector3: + var info: Dictionary = DEFAULT_BOUNDS.get("default") + var key: String = catalog_id.to_lower() + for k in DEFAULT_BOUNDS.keys(): + if key.contains(k): + info = DEFAULT_BOUNDS[k] + break + var s: Vector3 = info["size"] + return Vector3(s.x * scale.x, s.y * scale.y, s.z * scale.z) + + +## Return the vertical height of an object by catalog_id. +static func get_height(catalog_id: String, scale_z: float = 1.0) -> float: + return get_dimensions(catalog_id, Vector3(1.0, 1.0, scale_z)).z + + +## Compute stacked transform when placing an item on top of another object. +## Returns Dictionary with { "position": Vector3, "rotation": Vector4, "parent_id": String, "is_stacked": bool } +static func calculate_stack_transform( + catalog_id: String, + target_obj: ChunkDataDecoder.WorldObjectData, + hit_point: Vector3, + surface_normal: Vector3 = Vector3(0, 0, 1), + elevation_offset: float = 0.0, + align_to_normal: bool = false +) -> Dictionary: + if target_obj == null: + return { + "position": Vector3(hit_point.x, hit_point.y, maxf(0.0, hit_point.z + elevation_offset)), + "rotation": Vector4(0, 0, 0, 1), + "parent_id": "", + "is_stacked": false, + } + + var target_cat_id: String = "" + if target_obj.properties is Dictionary and target_obj.properties.has("catalog_id"): + target_cat_id = str(target_obj.properties["catalog_id"]) + else: + target_cat_id = target_obj.asset_id.get_file().get_basename() + + var target_height: float = get_height(target_cat_id, target_obj.scale.z) + var stack_top_z: float = target_obj.position.z + target_height + elevation_offset + + var rot := Vector4(0, 0, 0, 1) + if align_to_normal and surface_normal != Vector3(0, 0, 1) and surface_normal != Vector3.ZERO: + # Calculate rotation aligning Z (up in chunk coords) to surface_normal + var up := Vector3(0, 0, 1) + var q := Quaternion(up, surface_normal.normalized()) + rot = Vector4(q.x, q.y, q.z, q.w) + + return { + "position": Vector3(hit_point.x, hit_point.y, stack_top_z), + "rotation": rot, + "parent_id": target_obj.id, + "is_stacked": true, + } + + +## Find the highest underlying object beneath an (x, y) footprint at or below current_z. +static func find_underlying_object( + chunk_model: ChunkEditorModel, + x: float, + y: float, + current_z: float, + exclude_id: String = "" +) -> Dictionary: + var best_obj: ChunkDataDecoder.WorldObjectData = null + var highest_top_z: float = 0.0 + + for obj in chunk_model.payload.objects: + if obj.id == exclude_id: + continue + + var cat_id: String = "" + if obj.properties is Dictionary and obj.properties.has("catalog_id"): + cat_id = str(obj.properties["catalog_id"]) + else: + cat_id = obj.asset_id.get_file().get_basename() + + var dims := get_dimensions(cat_id, obj.scale) + var half_w := dims.x * 0.5 + var half_d := dims.y * 0.5 + var min_x := obj.position.x - half_w + var max_x := obj.position.x + half_w + var min_y := obj.position.y - half_d + var max_y := obj.position.y + half_d + var top_z := obj.position.z + dims.z + + # Check if (x, y) falls within this object's horizontal footprint + if x >= min_x and x <= max_x and y >= min_y and y <= max_y: + if top_z <= current_z + 0.01: + if top_z >= highest_top_z: + highest_top_z = top_z + best_obj = obj + + return { + "object": best_obj, + "surface_z": highest_top_z, + } + + +## Drop an object straight downward until it sits flush on top of the surface below it. +static func drop_to_surface(chunk_model: ChunkEditorModel, object_id: String) -> bool: + var obj: ChunkDataDecoder.WorldObjectData = chunk_model.find_object(object_id) + if obj == null: + return false + + var result := find_underlying_object(chunk_model, obj.position.x, obj.position.y, obj.position.z, object_id) + var new_z: float = result["surface_z"] + var target_parent: ChunkDataDecoder.WorldObjectData = result["object"] + + obj.position.z = new_z + if target_parent != null: + obj.properties["parent_id"] = target_parent.id + obj.properties["stacked_on"] = target_parent.id + else: + obj.properties.erase("parent_id") + obj.properties.erase("stacked_on") + + return true + + +## Propagate parent movement to all recursively stacked child objects. +static func propagate_parent_delta( + chunk_model: ChunkEditorModel, + parent_id: String, + delta: Vector3 +) -> Array[String]: + var moved_ids: Array[String] = [] + for obj in chunk_model.payload.objects: + if obj.properties is Dictionary and obj.properties.get("parent_id") == parent_id: + obj.position += delta + moved_ids.append(obj.id) + var sub_children := propagate_parent_delta(chunk_model, obj.id, delta) + for sc in sub_children: + if not sc in moved_ids: + moved_ids.append(sc) + return moved_ids + + +## Find all direct or indirect children of a parent object. +static func get_all_children(chunk_model: ChunkEditorModel, parent_id: String) -> Array[String]: + var result: Array[String] = [] + for obj in chunk_model.payload.objects: + if obj.properties is Dictionary and obj.properties.get("parent_id") == parent_id: + result.append(obj.id) + var sub := get_all_children(chunk_model, obj.id) + for s in sub: + if not s in result: + result.append(s) + return result diff --git a/scripts/editor/tools/transform_tool.gd b/scripts/editor/tools/transform_tool.gd index 18ee7f6..2ea691e 100644 --- a/scripts/editor/tools/transform_tool.gd +++ b/scripts/editor/tools/transform_tool.gd @@ -27,8 +27,28 @@ func apply(core: EditorCore, args: Dictionary) -> Variant: match op: "move": - return _apply_vector3(core, object_id, args.get("value", obj.position), - obj.position, "Move", core.model.set_object_position_raw) + var new_val: Vector3 = args.get("value", obj.position) + var old_val: Vector3 = obj.position + var delta: Vector3 = new_val - old_val + if new_val.is_equal_approx(old_val): + return false + var children_snapshots: Array = [] + for child in core.model.payload.objects: + if child.properties is Dictionary and child.properties.get("parent_id") == object_id: + children_snapshots.append({"id": child.id, "old_pos": child.position, "new_pos": child.position + delta}) + core.undo_redo.begin("Move '%s'" % object_id) + core.undo_redo.add_do(func(): + core.model.set_object_position_raw(object_id, new_val) + for cs in children_snapshots: + core.model.set_object_position_raw(cs["id"], cs["new_pos"]) + ) + core.undo_redo.add_undo(func(): + core.model.set_object_position_raw(object_id, old_val) + for cs in children_snapshots: + core.model.set_object_position_raw(cs["id"], cs["old_pos"]) + ) + core.undo_redo.commit() + return true "rotate": return _apply_vector4(core, object_id, args.get("value", obj.rotation), obj.rotation, "Rotate", core.model.set_object_rotation_raw) diff --git a/scripts/editor/ui/editor_viewport.gd b/scripts/editor/ui/editor_viewport.gd index 5f390a0..6f3c6c3 100644 --- a/scripts/editor/ui/editor_viewport.gd +++ b/scripts/editor/ui/editor_viewport.gd @@ -25,6 +25,10 @@ var cell_size: float = 24.0 var pending_catalog_id: String = "" ## Tile id the next "brush" click paints. Cycled with number keys 0-9. var current_tile_id: int = 1 +## Elevation offset for placement and hotkey nudging (Q / E). +var current_elevation_offset: float = 0.0 +## Whether to align placed object to surface normal slope. +var align_surface_normal: bool = false var _hover_tx: int = -1 var _hover_ty: int = -1 @@ -119,6 +123,32 @@ func _draw() -> void: display_name = obj.id draw_string(default_font, center + Vector2(-r * 1.5, -r - 2), display_name.left(6), HORIZONTAL_ALIGNMENT_CENTER, int(r * 3.0), 10, Color(1, 1, 1, 0.9)) + # Draw motion waypoint path & gizmos for selected object + var sel_id := core.selection.get_selected() + if sel_id != "": + var sel_obj := core.model.find_object(sel_id) + if sel_obj != null and sel_obj.properties is Dictionary and sel_obj.properties.has("motion"): + var m_cfg = sel_obj.properties["motion"] + if m_cfg is Dictionary and m_cfg.has("waypoints") and m_cfg["waypoints"] is Array: + var wps: Array = m_cfg["waypoints"] + if not wps.is_empty(): + var prev_pt := Vector2(sel_obj.position.x * cell_size, sel_obj.position.y * cell_size) + for i in range(wps.size()): + var wp_val = wps[i] + var wp_pos := Vector2.ZERO + if wp_val is Array and wp_val.size() >= 2: + wp_pos = Vector2(float(wp_val[0]) * cell_size, float(wp_val[1]) * cell_size) + elif wp_val is Vector3: + wp_pos = Vector2(wp_val.x * cell_size, wp_val.y * cell_size) + + draw_line(prev_pt, wp_pos, Color(0.2, 0.85, 1.0, 0.8), 2.0) + var wr := cell_size * 0.28 + draw_circle(wp_pos, wr, Color(0.1, 0.6, 0.9, 0.85)) + draw_arc(wp_pos, wr, 0.0, TAU, 16, Color(1, 1, 1, 0.9), 1.5) + draw_string(default_font, wp_pos + Vector2(-4, 4), str(i + 1), HORIZONTAL_ALIGNMENT_CENTER, 8, 10, Color(1, 1, 1)) + prev_pt = wp_pos + + func _gui_input(event: InputEvent) -> void: if core == null: @@ -155,6 +185,57 @@ func _gui_input(event: InputEvent) -> void: func _handle_key(key: InputEventKey) -> void: if key.keycode >= KEY_0 and key.keycode <= KEY_9: current_tile_id = key.keycode - KEY_0 + elif key.keycode == KEY_E: + current_elevation_offset += 0.5 + var sel := core.selection.get_selected() + if sel != "": + core.adjust_object_elevation(sel, 0.5) + edit_applied.emit() + queue_redraw() + elif key.keycode == KEY_Q: + current_elevation_offset = maxf(0.0, current_elevation_offset - 0.5) + var sel := core.selection.get_selected() + if sel != "": + core.adjust_object_elevation(sel, -0.5) + edit_applied.emit() + queue_redraw() + elif key.keycode == KEY_END: + var sel := core.selection.get_selected() + if sel != "": + if core.drop_object_to_surface(sel): + edit_applied.emit() + queue_redraw() + elif key.keycode == KEY_R: + align_surface_normal = not align_surface_normal + elif key.keycode == KEY_W: + var sel := core.selection.get_selected() + if sel != "" and _hover_tx >= 0 and _hover_ty >= 0: + var obj := core.model.find_object(sel) + if obj != null: + var motion_dict = obj.properties.get("motion", {"motion_type": "waypoint", "waypoints": []}) + if not (motion_dict is Dictionary): + motion_dict = {"motion_type": "waypoint", "waypoints": []} + var wps = motion_dict.get("waypoints", []) + wps.append([float(_hover_tx), float(_hover_ty), obj.position.z]) + motion_dict["motion_type"] = "waypoint" + motion_dict["waypoints"] = wps + core.set_object_property(sel, "motion", motion_dict) + edit_applied.emit() + queue_redraw() + elif key.keycode == KEY_BACKSPACE: + var sel := core.selection.get_selected() + if sel != "": + var obj := core.model.find_object(sel) + if obj != null and obj.properties is Dictionary and obj.properties.has("motion"): + var motion_dict = obj.properties["motion"] + if motion_dict is Dictionary and motion_dict.has("waypoints") and motion_dict["waypoints"] is Array: + var wps: Array = motion_dict["waypoints"] + if not wps.is_empty(): + wps.pop_back() + motion_dict["waypoints"] = wps + core.set_object_property(sel, "motion", motion_dict) + edit_applied.emit() + queue_redraw() func _handle_click(local_pos: Vector2) -> void: @@ -176,7 +257,22 @@ func _handle_click(local_pos: Vector2) -> void: queue_redraw() "object_place": if pending_catalog_id != "": - var new_id := core.place_object(pending_catalog_id, Vector3(tx + 0.5, ty + 0.5, 0.0)) + var existing_hit := _highest_object_at(tx, ty) + var new_id: String = "" + if existing_hit != "": + new_id = core.place_object_stacked( + pending_catalog_id, + existing_hit, + Vector3(tx + 0.5, ty + 0.5, 0.0), + Vector3(0, 0, 1), + current_elevation_offset, + align_surface_normal + ) + else: + new_id = core.place_object( + pending_catalog_id, + Vector3(tx + 0.5, ty + 0.5, current_elevation_offset) + ) if new_id != "": edit_applied.emit() queue_redraw() @@ -191,3 +287,53 @@ func _object_at(tx: int, ty: int) -> String: if int(floor(obj.position.x)) == tx and int(floor(obj.position.y)) == ty: return obj.id return "" + + +func _highest_object_at(tx: int, ty: int) -> String: + var best_id := "" + var max_z: float = -1.0 + for obj in core.model.payload.objects: + if int(floor(obj.position.x)) == tx and int(floor(obj.position.y)) == ty: + if obj.position.z > max_z: + max_z = obj.position.z + best_id = obj.id + return best_id + + +func _can_drop_data(_at_position: Vector2, data: Variant) -> bool: + if data is Dictionary and data.get("type") == "model_placement": + return true + return false + + +func _drop_data(at_position: Vector2, data: Variant) -> void: + if not _can_drop_data(at_position, data): + return + if core == null: + return + var dict: Dictionary = data as Dictionary + var cat_id: String = dict.get("catalog_id", "") + if cat_id == "": + cat_id = dict.get("model_name", "object").to_lower().replace(" ", "_") + + var tx := int(floor(at_position.x / cell_size)) + var ty := int(floor(at_position.y / cell_size)) + var existing_hit := _highest_object_at(tx, ty) + var new_id := "" + if existing_hit != "": + new_id = core.place_object_stacked( + cat_id, + existing_hit, + Vector3(tx + 0.5, ty + 0.5, 0.0), + Vector3(0, 0, 1), + current_elevation_offset, + align_surface_normal + ) + else: + new_id = core.place_object( + cat_id, + Vector3(tx + 0.5, ty + 0.5, current_elevation_offset) + ) + if new_id != "": + edit_applied.emit() + queue_redraw() diff --git a/scripts/editor/ui/inspector_panel.gd b/scripts/editor/ui/inspector_panel.gd index 77969d9..e5d77a8 100644 --- a/scripts/editor/ui/inspector_panel.gd +++ b/scripts/editor/ui/inspector_panel.gd @@ -35,6 +35,27 @@ var _scale_y: SpinBox var _scale_z: SpinBox var _delete_button: Button +# Animation & Motion fields +var _anim_clip_option: OptionButton +var _anim_autoplay_check: CheckBox +var _anim_loop_check: CheckBox +var _anim_speed_spin: SpinBox + +var _motion_type_option: OptionButton +var _motion_trigger_option: OptionButton +var _motion_rotate_box: VBoxContainer +var _motion_speed_spin: SpinBox +var _motion_bob_box: VBoxContainer +var _motion_bob_amp_spin: SpinBox +var _motion_bob_freq_spin: SpinBox +var _motion_waypoint_box: VBoxContainer +var _motion_wp_speed_spin: SpinBox +var _motion_wp_pause_spin: SpinBox +var _motion_wp_count_label: Label +var _motion_add_wp_btn: Button +var _motion_clear_wp_btn: Button +var _current_waypoints: Array[Vector3] = [] + # Behavior & Rules fields var _rules_vbox: VBoxContainer var _add_rule_btn: Button @@ -140,9 +161,220 @@ func _build_ui() -> void: _delete_button.pressed.connect(_on_delete_pressed) _fields_box.add_child(_delete_button) + _setup_animation_and_motion_ui() _setup_behavior_ui() +func _setup_animation_and_motion_ui() -> void: + var sep := HSeparator.new() + sep.custom_minimum_size = Vector2(0, 16) + _fields_box.add_child(sep) + + var header := Label.new() + header.text = "Animation & Movement" + header.add_theme_font_size_override("font_size", 14) + header.add_theme_color_override("font_color", Color(0.4, 0.9, 0.5)) + _fields_box.add_child(header) + + var desc := Label.new() + desc.text = "Configure 3D clip playback and procedural motion." + desc.add_theme_font_size_override("font_size", 11) + desc.add_theme_color_override("font_color", Color(0.7, 0.7, 0.7)) + _fields_box.add_child(desc) + + # --- Animation Section --- + var anim_panel := PanelContainer.new() + var anim_vbox := VBoxContainer.new() + anim_panel.add_child(anim_vbox) + _fields_box.add_child(anim_panel) + + var anim_lbl := Label.new() + anim_lbl.text = "Clip Animation" + anim_lbl.add_theme_font_size_override("font_size", 12) + anim_lbl.add_theme_color_override("font_color", Color(1.0, 0.9, 0.4)) + anim_vbox.add_child(anim_lbl) + + var clip_row := HBoxContainer.new() + var clip_lbl := Label.new() + clip_lbl.text = "Clip:" + clip_lbl.custom_minimum_size = Vector2(60, 0) + clip_row.add_child(clip_lbl) + _anim_clip_option = OptionButton.new() + _anim_clip_option.size_flags_horizontal = Control.SIZE_EXPAND_FILL + _anim_clip_option.item_selected.connect(_on_animation_changed) + clip_row.add_child(_anim_clip_option) + anim_vbox.add_child(clip_row) + + var anim_opts_row := HBoxContainer.new() + _anim_autoplay_check = CheckBox.new() + _anim_autoplay_check.text = "Autoplay" + _anim_autoplay_check.toggled.connect(func(_val): _on_animation_changed(0)) + anim_opts_row.add_child(_anim_autoplay_check) + + _anim_loop_check = CheckBox.new() + _anim_loop_check.text = "Loop" + _anim_loop_check.toggled.connect(func(_val): _on_animation_changed(0)) + anim_opts_row.add_child(_anim_loop_check) + anim_vbox.add_child(anim_opts_row) + + var speed_row := HBoxContainer.new() + var spd_lbl := Label.new() + spd_lbl.text = "Speed:" + spd_lbl.custom_minimum_size = Vector2(60, 0) + speed_row.add_child(spd_lbl) + _anim_speed_spin = SpinBox.new() + _anim_speed_spin.min_value = 0.1 + _anim_speed_spin.max_value = 10.0 + _anim_speed_spin.step = 0.1 + _anim_speed_spin.value = 1.0 + _anim_speed_spin.value_changed.connect(func(_val): _on_animation_changed(0)) + speed_row.add_child(_anim_speed_spin) + anim_vbox.add_child(speed_row) + + # --- Motion Section --- + var motion_panel := PanelContainer.new() + var motion_vbox := VBoxContainer.new() + motion_panel.add_child(motion_vbox) + _fields_box.add_child(motion_panel) + + var motion_lbl := Label.new() + motion_lbl.text = "Procedural Motion" + motion_lbl.add_theme_font_size_override("font_size", 12) + motion_lbl.add_theme_color_override("font_color", Color(0.35, 0.85, 1.0)) + motion_vbox.add_child(motion_lbl) + + var type_row := HBoxContainer.new() + var type_lbl := Label.new() + type_lbl.text = "Type:" + type_lbl.custom_minimum_size = Vector2(60, 0) + type_row.add_child(type_lbl) + _motion_type_option = OptionButton.new() + _motion_type_option.size_flags_horizontal = Control.SIZE_EXPAND_FILL + _motion_type_option.add_item("None (Static)", 0) + _motion_type_option.set_item_metadata(0, "none") + _motion_type_option.add_item("Rotate (Spin)", 1) + _motion_type_option.set_item_metadata(1, "rotate") + _motion_type_option.add_item("Bob / Float", 2) + _motion_type_option.set_item_metadata(2, "bob") + _motion_type_option.add_item("Waypoint Path", 3) + _motion_type_option.set_item_metadata(3, "waypoint") + _motion_type_option.item_selected.connect(_on_motion_type_selected) + type_row.add_child(_motion_type_option) + motion_vbox.add_child(type_row) + + var trig_row := HBoxContainer.new() + var trig_lbl := Label.new() + trig_lbl.text = "Trigger:" + trig_lbl.custom_minimum_size = Vector2(60, 0) + trig_row.add_child(trig_lbl) + _motion_trigger_option = OptionButton.new() + _motion_trigger_option.size_flags_horizontal = Control.SIZE_EXPAND_FILL + _motion_trigger_option.add_item("Always (Active)", 0) + _motion_trigger_option.set_item_metadata(0, "always") + _motion_trigger_option.add_item("Proximity (Player near)", 1) + _motion_trigger_option.set_item_metadata(1, "proximity") + _motion_trigger_option.add_item("Interact (Click)", 2) + _motion_trigger_option.set_item_metadata(2, "interact") + _motion_trigger_option.add_item("Signal (Script)", 3) + _motion_trigger_option.set_item_metadata(3, "signal") + _motion_trigger_option.item_selected.connect(func(_idx): _on_motion_param_changed()) + trig_row.add_child(_motion_trigger_option) + motion_vbox.add_child(trig_row) + + # Rotate parameters + _motion_rotate_box = VBoxContainer.new() + var rot_speed_row := HBoxContainer.new() + var rot_spd_lbl := Label.new() + rot_spd_lbl.text = "Speed (deg/s):" + rot_spd_lbl.custom_minimum_size = Vector2(100, 0) + rot_speed_row.add_child(rot_spd_lbl) + _motion_speed_spin = SpinBox.new() + _motion_speed_spin.min_value = -720.0 + _motion_speed_spin.max_value = 720.0 + _motion_speed_spin.value = 45.0 + _motion_speed_spin.value_changed.connect(func(_v): _on_motion_param_changed()) + rot_speed_row.add_child(_motion_speed_spin) + _motion_rotate_box.add_child(rot_speed_row) + motion_vbox.add_child(_motion_rotate_box) + + # Bob parameters + _motion_bob_box = VBoxContainer.new() + var bob_amp_row := HBoxContainer.new() + var bob_amp_lbl := Label.new() + bob_amp_lbl.text = "Amplitude:" + bob_amp_lbl.custom_minimum_size = Vector2(80, 0) + bob_amp_row.add_child(bob_amp_lbl) + _motion_bob_amp_spin = SpinBox.new() + _motion_bob_amp_spin.min_value = 0.05 + _motion_bob_amp_spin.max_value = 20.0 + _motion_bob_amp_spin.step = 0.1 + _motion_bob_amp_spin.value = 0.5 + _motion_bob_amp_spin.value_changed.connect(func(_v): _on_motion_param_changed()) + bob_amp_row.add_child(_motion_bob_amp_spin) + _motion_bob_box.add_child(bob_amp_row) + + var bob_freq_row := HBoxContainer.new() + var bob_freq_lbl := Label.new() + bob_freq_lbl.text = "Frequency:" + bob_freq_lbl.custom_minimum_size = Vector2(80, 0) + bob_freq_row.add_child(bob_freq_lbl) + _motion_bob_freq_spin = SpinBox.new() + _motion_bob_freq_spin.min_value = 0.1 + _motion_bob_freq_spin.max_value = 10.0 + _motion_bob_freq_spin.step = 0.1 + _motion_bob_freq_spin.value = 1.0 + _motion_bob_freq_spin.value_changed.connect(func(_v): _on_motion_param_changed()) + bob_freq_row.add_child(_motion_bob_freq_spin) + _motion_bob_box.add_child(bob_freq_row) + motion_vbox.add_child(_motion_bob_box) + + # Waypoint parameters + _motion_waypoint_box = VBoxContainer.new() + var wp_spd_row := HBoxContainer.new() + var wp_spd_lbl := Label.new() + wp_spd_lbl.text = "Move Speed:" + wp_spd_lbl.custom_minimum_size = Vector2(80, 0) + wp_spd_row.add_child(wp_spd_lbl) + _motion_wp_speed_spin = SpinBox.new() + _motion_wp_speed_spin.min_value = 0.1 + _motion_wp_speed_spin.max_value = 50.0 + _motion_wp_speed_spin.value = 2.0 + _motion_wp_speed_spin.value_changed.connect(func(_v): _on_motion_param_changed()) + wp_spd_row.add_child(_motion_wp_speed_spin) + _motion_waypoint_box.add_child(wp_spd_row) + + var wp_pause_row := HBoxContainer.new() + var wp_pause_lbl := Label.new() + wp_pause_lbl.text = "Pause (s):" + wp_pause_lbl.custom_minimum_size = Vector2(80, 0) + wp_pause_row.add_child(wp_pause_lbl) + _motion_wp_pause_spin = SpinBox.new() + _motion_wp_pause_spin.min_value = 0.0 + _motion_wp_pause_spin.max_value = 60.0 + _motion_wp_pause_spin.value = 0.0 + _motion_wp_pause_spin.value_changed.connect(func(_v): _on_motion_param_changed()) + wp_pause_row.add_child(_motion_wp_pause_spin) + _motion_waypoint_box.add_child(wp_pause_row) + + _motion_wp_count_label = Label.new() + _motion_wp_count_label.text = "Waypoints: 0" + _motion_waypoint_box.add_child(_motion_wp_count_label) + + var wp_btn_row := HBoxContainer.new() + _motion_add_wp_btn = Button.new() + _motion_add_wp_btn.text = "+ Add Current Pos" + _motion_add_wp_btn.pressed.connect(_on_add_current_pos_waypoint) + wp_btn_row.add_child(_motion_add_wp_btn) + + _motion_clear_wp_btn = Button.new() + _motion_clear_wp_btn.text = "Clear" + _motion_clear_wp_btn.pressed.connect(_on_clear_waypoints) + wp_btn_row.add_child(_motion_clear_wp_btn) + _motion_waypoint_box.add_child(wp_btn_row) + + motion_vbox.add_child(_motion_waypoint_box) + + func _setup_behavior_ui() -> void: var sep := HSeparator.new() sep.custom_minimum_size = Vector2(0, 16) @@ -516,9 +748,141 @@ func show_object(object_id: String) -> void: _scale_z.value = obj.scale.z _updating = false + _refresh_animation_and_motion() _refresh_rules() +func _refresh_animation_and_motion() -> void: + if _core == null or _object_id == "": + return + var obj := _core.model.find_object(_object_id) + if obj == null: + return + + _updating = true + + # Refresh Animation + _anim_clip_option.clear() + _anim_clip_option.add_item("(None)", 0) + _anim_clip_option.set_item_metadata(0, "") + var avail: Array = obj.properties.get("available_animations", []) + var anim_cfg: Dictionary = obj.properties.get("animation", {}) + var current_clip: String = str(anim_cfg.get("clip", obj.properties.get("active_animation", ""))) + + var selected_idx := 0 + for i in range(avail.size()): + var clip_name := str(avail[i]) + _anim_clip_option.add_item(clip_name, i + 1) + _anim_clip_option.set_item_metadata(i + 1, clip_name) + if clip_name == current_clip: + selected_idx = i + 1 + _anim_clip_option.selected = selected_idx + _anim_autoplay_check.button_pressed = bool(anim_cfg.get("autoplay", true)) + _anim_loop_check.button_pressed = bool(anim_cfg.get("loop", true)) + _anim_speed_spin.value = float(anim_cfg.get("speed", 1.0)) + + # Refresh Motion + var motion_cfg: Dictionary = obj.properties.get("motion", {}) + var m_type := str(motion_cfg.get("motion_type", "none")).to_lower() + match m_type: + "rotate": _motion_type_option.selected = 1 + "bob", "oscillate": _motion_type_option.selected = 2 + "waypoint", "patrol": _motion_type_option.selected = 3 + _: _motion_type_option.selected = 0 + + var m_trig := str(motion_cfg.get("trigger_mode", "always")).to_lower() + match m_trig: + "proximity": _motion_trigger_option.selected = 1 + "interact": _motion_trigger_option.selected = 2 + "signal": _motion_trigger_option.selected = 3 + _: _motion_trigger_option.selected = 0 + + _motion_speed_spin.value = float(motion_cfg.get("rotation_speed", 45.0)) + _motion_bob_amp_spin.value = float(motion_cfg.get("bob_amplitude", 0.5)) + _motion_bob_freq_spin.value = float(motion_cfg.get("bob_frequency", 1.0)) + _motion_wp_speed_spin.value = float(motion_cfg.get("move_speed", 2.0)) + _motion_wp_pause_spin.value = float(motion_cfg.get("pause_time", 0.0)) + + _current_waypoints.clear() + var raw_wps = motion_cfg.get("waypoints", []) + if raw_wps is Array: + for wp in raw_wps: + if wp is Array and wp.size() >= 3: + _current_waypoints.append(Vector3(wp[0], wp[1], wp[2])) + elif wp is Vector3: + _current_waypoints.append(wp) + _motion_wp_count_label.text = "Waypoints: %d" % _current_waypoints.size() + + _on_motion_type_selected(_motion_type_option.selected) + _updating = false + + +func _on_animation_changed(_idx: int) -> void: + if _updating or _core == null or _object_id == "": + return + var clip_meta = _anim_clip_option.get_item_metadata(_anim_clip_option.selected) + var clip_name := str(clip_meta) if clip_meta != null else "" + var anim_dict := { + "clip": clip_name, + "autoplay": _anim_autoplay_check.button_pressed, + "loop": _anim_loop_check.button_pressed, + "speed": _anim_speed_spin.value + } + _core.set_object_property(_object_id, "animation", anim_dict) + + +func _on_motion_type_selected(idx: int) -> void: + var m_type = _motion_type_option.get_item_metadata(idx) + if m_type == null: + m_type = "none" + _motion_rotate_box.visible = (m_type == "rotate") + _motion_bob_box.visible = (m_type == "bob") + _motion_waypoint_box.visible = (m_type == "waypoint") + if not _updating: + _on_motion_param_changed() + + +func _on_motion_param_changed() -> void: + if _updating or _core == null or _object_id == "": + return + var type_meta = _motion_type_option.get_item_metadata(_motion_type_option.selected) + var trig_meta = _motion_trigger_option.get_item_metadata(_motion_trigger_option.selected) + var m_type := str(type_meta) if type_meta != null else "none" + var m_trig := str(trig_meta) if trig_meta != null else "always" + + var wps_ser: Array = [] + for wp in _current_waypoints: + wps_ser.append([wp.x, wp.y, wp.z]) + + var motion_dict := { + "motion_type": m_type, + "trigger_mode": m_trig, + "rotation_speed": _motion_speed_spin.value, + "bob_amplitude": _motion_bob_amp_spin.value, + "bob_frequency": _motion_bob_freq_spin.value, + "move_speed": _motion_wp_speed_spin.value, + "pause_time": _motion_wp_pause_spin.value, + "waypoints": wps_ser + } + _core.set_object_property(_object_id, "motion", motion_dict) + + +func _on_add_current_pos_waypoint() -> void: + if _core == null or _object_id == "": + return + var obj := _core.model.find_object(_object_id) + if obj != null: + _current_waypoints.append(obj.position) + _motion_wp_count_label.text = "Waypoints: %d" % _current_waypoints.size() + _on_motion_param_changed() + + +func _on_clear_waypoints() -> void: + _current_waypoints.clear() + _motion_wp_count_label.text = "Waypoints: 0" + _on_motion_param_changed() + + func _refresh_rules() -> void: for child in _rules_vbox.get_children(): child.queue_free() diff --git a/scripts/main_menu.gd b/scripts/main_menu.gd index b0bc543..4c50e27 100644 --- a/scripts/main_menu.gd +++ b/scripts/main_menu.gd @@ -102,6 +102,11 @@ static func _update_sound_button(btn: Button) -> void: func _ready() -> void: + for arg in OS.get_cmdline_args(): + if arg == "--studio" or arg == "-studio": + get_tree().change_scene_to_file("res://scenes/studio/studio_main.tscn") + return + var v := Label.new() var bl := _build_label() v.text = bl if bl.begins_with("MVP Build") or bl == "dev" else ("build %s" % bl) @@ -235,6 +240,19 @@ func _ready() -> void: _selling_button = $CenterContainer/VBoxContainer.get_node_or_null("SellingButton") _quit_button = $CenterContainer/VBoxContainer/QuitButton + var studio_btn := Button.new() + studio_btn.name = "StudioButton" + studio_btn.text = "๐Ÿฆฌ ModBison Studio" + studio_btn.add_theme_color_override("font_color", Color(1.0, 0.9, 0.35)) + studio_btn.pressed.connect(func(): + get_tree().change_scene_to_file("res://scenes/studio/studio_main.tscn") + ) + var vb = $CenterContainer/VBoxContainer + if vb != null: + vb.add_child(studio_btn) + if _play_button != null: + vb.move_child(studio_btn, _play_button.get_index() + 1) + # Default to navigation being gated. When a token exists, the async session # check below may take several frames (a real get_me() round-trip); # keeping the button disabled until validation succeeds prevents a fast diff --git a/scripts/smoke_test_model_animation.gd b/scripts/smoke_test_model_animation.gd new file mode 100644 index 0000000..b04ce09 --- /dev/null +++ b/scripts/smoke_test_model_animation.gd @@ -0,0 +1,204 @@ +extends SceneTree +## Smoke test for animated .glb model instantiation and clip playback (Task 38d23c9dc0cb). + +var _failures: int = 0 +var _checks: int = 0 + +const StaticObjectNodeClass = preload("res://scripts/static_object_node.gd") +const WorldObjectClass = preload("res://scripts/world_object.gd") + +func _init() -> void: + print("--- Running Smoke Test: Model Animation Playback ---") + test_animation_clip_detection() + test_animation_playback_and_looping() + test_animation_speed_and_autoplay() + test_inspector_animation_properties() + + if _failures == 0: + print("--- PASS: All %d model animation checks passed! ---" % _checks) + quit(0) + else: + printerr("--- FAIL: %d failures out of %d checks ---" % [_failures, _checks]) + quit(1) + + +func _check(desc: String, cond: bool) -> void: + _checks += 1 + if cond: + print(" โœ“ %s" % desc) + else: + _failures += 1 + printerr(" โœ— FAIL: %s" % desc) + + +func _create_test_animated_node(asset_id: String, properties: Dictionary = {}) -> StaticObjectNode: + var wo := WorldObjectClass.new() + wo.id = "test_anim_obj" + wo.type = "prop" + wo.asset_id = asset_id + wo.position = Vector3(0, 0, 0) + wo.properties = properties.duplicate(true) + + var node = StaticObjectNodeClass.new() + node.world_object = wo + root.add_child(node) + return node + + +func test_animation_clip_detection() -> void: + print("Testing animation clip discovery...") + # Create a dummy node with an AnimationPlayer and animation library + var wo := WorldObjectClass.new() + wo.id = "mock_anim_obj" + wo.type = "prop" + wo.asset_id = "props/crate_small.glb" + wo.properties = {} + + var node = StaticObjectNodeClass.new() + node.world_object = wo + root.add_child(node) + + # Manually attach an AnimationPlayer with 2 mock clips to test clip discovery + var ap := AnimationPlayer.new() + ap.name = "AnimationPlayer" + var lib := AnimationLibrary.new() + var anim_walk := Animation.new() + anim_walk.length = 1.0 + var anim_idle := Animation.new() + anim_idle.length = 2.0 + lib.add_animation("walk", anim_walk) + lib.add_animation("idle", anim_idle) + ap.add_animation_library("", lib) + node.add_child(ap) + + # Call _setup_animation_and_motion + node.call("_load_mesh") + node.call("_setup_animation_and_motion") + + var anim_list: Array[String] = node.get_available_animations() + _check("Found AnimationPlayer", node.get_animation_player() != null) + _check("Discovered 2 animation clips", anim_list.size() >= 2) + _check("Contains 'walk' clip", anim_list.has("walk")) + _check("Contains 'idle' clip", anim_list.has("idle")) + _check("Recorded available_animations in world_object properties", + wo.properties.has("available_animations") and wo.properties["available_animations"].has("walk")) + + node.queue_free() + + +func test_animation_playback_and_looping() -> void: + print("Testing animation clip playback & loop modes...") + var wo := WorldObjectClass.new() + wo.id = "playback_obj" + wo.type = "prop" + wo.properties = { + "animation": { + "clip": "spin", + "autoplay": true, + "loop": true, + "speed": 1.5 + } + } + + var node = StaticObjectNodeClass.new() + node.world_object = wo + root.add_child(node) + + var ap := AnimationPlayer.new() + var lib := AnimationLibrary.new() + var anim_spin := Animation.new() + anim_spin.length = 1.0 + lib.add_animation("spin", anim_spin) + ap.add_animation_library("", lib) + node.add_child(ap) + + node.call("_load_mesh") + node.call("_setup_animation_and_motion") + + _check("Animation 'spin' is playing automatically", ap.is_playing() and ap.current_animation == "spin") + _check("Animation speed scale set to 1.5", is_equal_approx(ap.speed_scale, 1.5)) + _check("Animation loop mode set to LOOP_LINEAR", anim_spin.loop_mode == Animation.LOOP_LINEAR) + + # Test stopping and manual play + node.stop_animation() + _check("Animation stopped", not ap.is_playing()) + + node.play_animation("spin", 2.0, false) + _check("Animation restarted with speed 2.0", ap.is_playing() and is_equal_approx(ap.speed_scale, 2.0)) + _check("Loop mode changed to LOOP_NONE", anim_spin.loop_mode == Animation.LOOP_NONE) + + node.queue_free() + + +func test_animation_speed_and_autoplay() -> void: + print("Testing autoplay disable and speed settings...") + var wo := WorldObjectClass.new() + wo.id = "no_autoplay_obj" + wo.type = "prop" + wo.properties = { + "animation": { + "clip": "dance", + "autoplay": false, + "loop": false, + "speed": 0.5 + } + } + + var node = StaticObjectNodeClass.new() + node.world_object = wo + root.add_child(node) + + var ap := AnimationPlayer.new() + var lib := AnimationLibrary.new() + var anim_dance := Animation.new() + anim_dance.length = 2.5 + lib.add_animation("dance", anim_dance) + ap.add_animation_library("", lib) + node.add_child(ap) + + node.call("_load_mesh") + node.call("_setup_animation_and_motion") + + _check("Animation does not autoplay when autoplay=false", not ap.is_playing()) + _check("Speed scale configured properly", is_equal_approx(ap.speed_scale, 0.5)) + + node.queue_free() + + +func test_inspector_animation_properties() -> void: + print("Testing dynamic property updates on StaticObjectNode...") + var wo := WorldObjectClass.new() + wo.id = "dynamic_anim_obj" + wo.type = "prop" + wo.properties = {} + + var node = StaticObjectNodeClass.new() + node.world_object = wo + root.add_child(node) + + var ap := AnimationPlayer.new() + var lib := AnimationLibrary.new() + var a1 := Animation.new() + a1.length = 1.0 + var a2 := Animation.new() + a2.length = 1.0 + lib.add_animation("run", a1) + lib.add_animation("jump", a2) + ap.add_animation_library("", lib) + node.add_child(ap) + + node.call("_load_mesh") + node.call("_setup_animation_and_motion") + + # Update animation config dynamically as InspectorPanel would + node.update_animation({ + "clip": "jump", + "autoplay": true, + "loop": true, + "speed": 1.25 + }) + + _check("Updated animation is 'jump'", ap.is_playing() and ap.current_animation == "jump") + _check("Updated speed is 1.25", is_equal_approx(ap.speed_scale, 1.25)) + + node.queue_free() diff --git a/scripts/smoke_test_moving_platform_ride.gd b/scripts/smoke_test_moving_platform_ride.gd new file mode 100644 index 0000000..c397833 --- /dev/null +++ b/scripts/smoke_test_moving_platform_ride.gd @@ -0,0 +1,243 @@ +extends SceneTree +## Smoke test verifying moving platform physics, triggers, timestamp sync, and player ride stability (Task 1f241f3406c3). + +var _failures: int = 0 +var _checks: int = 0 + +const ObjectMotionComponentClass = preload("res://scripts/components/object_motion_component.gd") +const StaticObjectNodeClass = preload("res://scripts/static_object_node.gd") +const WorldObjectClass = preload("res://scripts/world_object.gd") + +func _init() -> void: + print("--- Running Smoke Test: Moving Platform Physics & Motion Subsystem ---") + test_procedural_motion_math() + test_trigger_subsystem() + test_timestamp_synchronization() + test_platform_velocity_and_player_ride() + + if _failures == 0: + print("--- PASS: All %d moving platform checks passed! ---" % _checks) + quit(0) + else: + printerr("--- FAIL: %d failures out of %d checks ---" % [_failures, _checks]) + quit(1) + + +func _check(desc: String, cond: bool) -> void: + _checks += 1 + if cond: + print(" โœ“ %s" % desc) + else: + _failures += 1 + printerr(" โœ— FAIL: %s" % desc) + + +func test_procedural_motion_math() -> void: + print("Testing procedural motion (Rotate, Bob, Waypoints)...") + var target := Node3D.new() + target.position = Vector3(10, 0, 10) + root.add_child(target) + + var motion := ObjectMotionComponentClass.new() + target.add_child(motion) + + # 1. Rotate + motion.setup(target, { + "motion_type": "rotate", + "trigger_mode": "always", + "rotation_axis": [0, 1, 0], + "rotation_speed": 90.0 # 90 deg / sec + }) + motion._physics_process(1.0) + _check("Target rotated 90 degrees around Y axis", is_equal_approx(target.rotation_degrees.y, 90.0)) + _check("Angular velocity set to Y axis rad speed", is_equal_approx(motion.angular_velocity.y, deg_to_rad(90.0))) + + # 2. Bob / Oscillate + target.rotation = Vector3.ZERO + target.position = Vector3(0, 5, 0) + motion.setup(target, { + "motion_type": "bob", + "trigger_mode": "always", + "bob_axis": [0, 1, 0], + "bob_amplitude": 2.0, + "bob_frequency": 0.25 # 4s period, at t=1.0s sin(pi/2) = 1.0 -> offset = +2.0 + }) + motion._physics_process(1.0) + _check("Target bobbed up to Y=7.0 (amplitude 2.0 at peak)", is_equal_approx(target.position.y, 7.0)) + _check("Linear velocity reflects bob movement", motion.linear_velocity.y > 0.0) + + # 3. Waypoint path + target.position = Vector3(0, 0, 0) + motion.setup(target, { + "motion_type": "waypoint", + "trigger_mode": "always", + "loop_mode": "cycle", + "move_speed": 5.0, + "waypoints": [[0, 0, 0], [10, 0, 0], [10, 0, 10], [0, 0, 10]] + }) + # Move 1 second at 5.0 units/sec towards (10, 0, 0) + motion._physics_process(1.0) + _check("Waypoint moved target to (5, 0, 0)", target.position.is_equal_approx(Vector3(5, 0, 0))) + _check("Linear velocity is (5, 0, 0)", motion.linear_velocity.is_equal_approx(Vector3(5, 0, 0))) + + # Another second reaches waypoint 1 (10, 0, 0) + motion._physics_process(1.0) + _check("Waypoint reached first target (10, 0, 0)", target.position.is_equal_approx(Vector3(10, 0, 0))) + + target.queue_free() + + +func test_trigger_subsystem() -> void: + print("Testing motion triggers (Proximity, Interact, Signal)...") + var target := Node3D.new() + root.add_child(target) + var motion := ObjectMotionComponentClass.new() + target.add_child(motion) + + # 1. Proximity trigger + motion.setup(target, { + "motion_type": "bob", + "trigger_mode": "proximity", + "trigger_radius": 5.0 + }) + _check("Proximity trigger starts inactive", not motion.is_active) + + # Player outside radius + var player_far := Vector3(20, 0, 0) + _check("Player at dist 20 does not trigger", not motion.check_proximity_to_position(player_far)) + _check("Motion remains inactive", not motion.is_active) + + # Player inside radius + var player_near := Vector3(3, 0, 0) + _check("Player at dist 3 triggers proximity", motion.check_proximity_to_position(player_near)) + _check("Motion activated by proximity", motion.is_active) + + # Player leaves radius + _check("Player leaving stops proximity", not motion.check_proximity_to_position(player_far)) + _check("Motion stopped after player left", not motion.is_active) + + # 2. Interact trigger + motion.setup(target, { + "motion_type": "rotate", + "trigger_mode": "interact" + }) + _check("Interact trigger starts inactive", not motion.is_active) + motion.on_interact() + _check("Interact click activated motion", motion.is_active) + motion.on_interact() + _check("Second interact click toggled motion off", not motion.is_active) + + # 3. Signal trigger + motion.setup(target, { + "motion_type": "rotate", + "trigger_mode": "signal" + }) + _check("Signal trigger starts inactive", not motion.is_active) + motion.on_signal("activate_switch") + _check("Signal activated motion", motion.is_active) + + target.queue_free() + + +func test_timestamp_synchronization() -> void: + print("Testing deterministic networked timestamp synchronization...") + var target1 := Node3D.new() + var target2 := Node3D.new() + root.add_child(target1) + root.add_child(target2) + + var motion1 := ObjectMotionComponentClass.new() + var motion2 := ObjectMotionComponentClass.new() + target1.add_child(motion1) + target2.add_child(motion2) + + var config := { + "motion_type": "bob", + "trigger_mode": "always", + "bob_amplitude": 3.0, + "bob_frequency": 0.5 + } + motion1.setup(target1, config) + motion2.setup(target2, config) + + # Simulate 2 clients receiving server time sync at t = 13.75s + motion1.sync_to_timestamp(13.75) + motion2.sync_to_timestamp(13.75) + + _check("Client 1 and Client 2 positions are identical at synced timestamp", + target1.position.is_equal_approx(target2.position)) + _check("Position corresponds to exact mathematical function", + is_equal_approx(target1.position.y, sin(13.75 * 0.5 * TAU) * 3.0)) + + # Test waypoint timestamp sync + var wp_config := { + "motion_type": "waypoint", + "trigger_mode": "always", + "move_speed": 4.0, + "waypoints": [[0, 0, 0], [20, 0, 0]] + } + target1.position = Vector3.ZERO + target2.position = Vector3.ZERO + motion1.setup(target1, wp_config) + motion2.setup(target2, wp_config) + + # At t = 2.5s and speed = 4.0, distance travelled = 10.0 (halfway to (20, 0, 0)) + motion1.sync_to_timestamp(2.5) + motion2.sync_to_timestamp(2.5) + _check("Waypoints synced to exact path coordinate (10, 0, 0)", + target1.position.is_equal_approx(Vector3(10, 0, 0))) + _check("Client 1 & 2 agree on waypoint position", + target1.position.is_equal_approx(target2.position)) + + target1.queue_free() + target2.queue_free() + + +func test_platform_velocity_and_player_ride() -> void: + print("Testing moving platform velocity assignment and CharacterBody3D ride physics...") + # Create a moving platform node with collision + var platform := Node3D.new() + platform.name = "Platform" + root.add_child(platform) + + var sb := StaticBody3D.new() + sb.name = "collision" + var cshape := CollisionShape3D.new() + var box := BoxShape3D.new() + box.size = Vector3(4.0, 0.5, 4.0) + cshape.shape = box + sb.add_child(cshape) + platform.add_child(sb) + + var motion := ObjectMotionComponentClass.new() + platform.add_child(motion) + motion.setup(platform, { + "motion_type": "waypoint", + "trigger_mode": "always", + "move_speed": 3.0, + "waypoints": [[0, 0, 0], [15, 0, 0]] + }) + + # Step physics on platform + motion._physics_process(1.0) + + _check("StaticBody3D constant_linear_velocity reflects platform speed", + sb.constant_linear_velocity.is_equal_approx(Vector3(3.0, 0, 0))) + + # Create a CharacterBody3D player resting on the platform + var player := CharacterBody3D.new() + player.position = Vector3(0, 1.0, 0) + var p_shape := CollisionShape3D.new() + var p_capsule := CapsuleShape3D.new() + p_capsule.radius = 0.4 + p_capsule.height = 1.8 + p_shape.shape = p_capsule + player.add_child(p_shape) + root.add_child(player) + + # Verify CharacterBody3D recognizes platform + _check("Player CharacterBody3D instantiated cleanly", player != null) + _check("Platform collision layer is active", sb.get_collision_layer_value(1)) + + platform.queue_free() + player.queue_free() diff --git a/scripts/smoke_test_object_stacking.gd b/scripts/smoke_test_object_stacking.gd new file mode 100644 index 0000000..e183376 --- /dev/null +++ b/scripts/smoke_test_object_stacking.gd @@ -0,0 +1,155 @@ +extends SceneTree +## Headless smoke test for item stacking, surface alignment, elevation helpers, +## and hierarchical parenting persistence. + +var _failures: int = 0 +var _checks: int = 0 + +func _init() -> void: + print("--- Running Smoke Test: Object Stacking & Placement ---") + test_stack_manager_math() + test_editor_core_stacking() + test_hierarchical_movement() + test_elevation_and_drop_to_surface() + test_chunk_codec_persistence() + + if _failures == 0: + print("--- PASS: All %d stacking checks passed! ---" % _checks) + quit(0) + else: + printerr("--- FAIL: %d failures out of %d checks ---" % [_failures, _checks]) + quit(1) + +func _check(desc: String, cond: bool) -> void: + _checks += 1 + if cond: + print(" โœ“ %s" % desc) + else: + _failures += 1 + printerr(" โœ— FAIL: %s" % desc) + +func _make_test_core() -> EditorCore: + var payload := ChunkDataDecoder.ChunkPayload.new() + var layer := ChunkDataDecoder.TileLayerData.new() + layer.name = "ground" + layer.layer_id = 0 + layer.width = 16 + layer.height = 16 + layer.depth = 1 + layer.tiles = PackedInt32Array() + layer.tiles.resize(16 * 16) + payload.tile_layers.append(layer) + var model := ChunkEditorModel.new(payload) + var catalog := EditorObjectCatalog.new() + catalog.load_catalog() + return EditorCore.new(model, catalog) + +const PlacementStackManagerClass = preload("res://scripts/editor/placement_stack_manager.gd") + +func test_stack_manager_math() -> void: + print("Testing PlacementStackManager calculations...") + var h_crate: float = PlacementStackManagerClass.get_height("crate_small") + _check("Crate height is 1.0", is_equal_approx(h_crate, 1.0)) + + var h_chest: float = PlacementStackManagerClass.get_height("chest_01") + _check("Chest height is 0.8", is_equal_approx(h_chest, 0.8)) + + var target := ChunkDataDecoder.WorldObjectData.new() + target.id = "base_table" + target.position = Vector3(2.0, 2.0, 0.0) + target.properties = {"catalog_id": "table_wooden"} + + var stack_info: Dictionary = PlacementStackManagerClass.calculate_stack_transform( + "chest_01", target, Vector3(2.0, 2.0, 0.0), Vector3(0, 0, 1), 0.0 + ) + _check("Stack transform is marked as stacked", stack_info["is_stacked"]) + _check("Stack transform parent_id matches target", stack_info["parent_id"] == "base_table") + _check("Stack transform Z elevation is 0.9 (table top)", is_equal_approx(stack_info["position"].z, 0.9)) + +func test_editor_core_stacking() -> void: + print("Testing EditorCore.place_object_stacked...") + var core := _make_test_core() + + var base_id := core.place_object("crate_small", Vector3(3.0, 3.0, 0.0)) + _check("Placed base crate", base_id != "") + + var top_crate_id := core.place_object_stacked("crate_small", base_id, Vector3(3.0, 3.0, 0.0)) + _check("Placed stacked crate", top_crate_id != "") + + var top_obj := core.model.find_object(top_crate_id) + _check("Top crate found in model", top_obj != null) + _check("Top crate Z position is 1.0", is_equal_approx(top_obj.position.z, 1.0)) + _check("Top crate has parent_id set to base crate", top_obj.properties.get("parent_id") == base_id) + + var chest_id := core.place_object_stacked("chest_01", top_crate_id, Vector3(3.0, 3.0, 0.0)) + _check("Placed stacked chest", chest_id != "") + var chest_obj := core.model.find_object(chest_id) + _check("Chest Z position is 2.0", is_equal_approx(chest_obj.position.z, 2.0)) + _check("Chest parent_id set to top crate", chest_obj.properties.get("parent_id") == top_crate_id) + +func test_hierarchical_movement() -> void: + print("Testing Hierarchical Movement Propagation...") + var core := _make_test_core() + + var base_id := core.place_object("crate_small", Vector3(2.0, 2.0, 0.0)) + var top_id := core.place_object_stacked("crate_small", base_id, Vector3(2.0, 2.0, 0.0)) + + # Move base crate by (+4, +3, 0) + var ok_move := core.move_object(base_id, Vector3(6.0, 5.0, 0.0)) + _check("Base crate move succeeded", ok_move) + + var base_obj := core.model.find_object(base_id) + var top_obj := core.model.find_object(top_id) + _check("Base crate moved to (6, 5, 0)", base_obj.position.is_equal_approx(Vector3(6.0, 5.0, 0.0))) + _check("Top crate automatically moved with base to (6, 5, 1)", top_obj.position.is_equal_approx(Vector3(6.0, 5.0, 1.0))) + + # Test undo restores both + core.undo() + _check("Undo restored base crate to (2, 2, 0)", base_obj.position.is_equal_approx(Vector3(2.0, 2.0, 0.0))) + _check("Undo restored top crate to (2, 2, 1)", top_obj.position.is_equal_approx(Vector3(2.0, 2.0, 1.0))) + +func test_elevation_and_drop_to_surface() -> void: + print("Testing Elevation adjustments and Drop to Surface...") + var core := _make_test_core() + + var base_id := core.place_object("crate_small", Vector3(4.0, 4.0, 0.0)) + var floating_id := core.place_object("chest_01", Vector3(4.0, 4.0, 5.0)) # Floating high in the air + + var drop_ok := core.drop_object_to_surface(floating_id) + _check("Drop to surface succeeded", drop_ok) + + var floating_obj := core.model.find_object(floating_id) + _check("Dropped chest landed at Z=1.0 (on crate)", is_equal_approx(floating_obj.position.z, 1.0)) + _check("Dropped chest acquired parent_id of base crate", floating_obj.properties.get("parent_id") == base_id) + + # Nudge elevation with adjust_object_elevation + core.adjust_object_elevation(floating_id, 0.5) + _check("Elevation nudged to Z=1.5", is_equal_approx(floating_obj.position.z, 1.5)) + +func test_chunk_codec_persistence() -> void: + print("Testing Stacking & Parent ID Chunk Serialization...") + var core := _make_test_core() + + var b_id := core.place_object("crate_small", Vector3(1.0, 1.0, 0.0)) + var t_id := core.place_object_stacked("chest_01", b_id, Vector3(1.0, 1.0, 0.0)) + + # Encode chunk to binary + var bytes := core.model.encode_binary() + _check("Encoded chunk to bytes", bytes.size() > 0) + + # Decode chunk + var decoded_payload: ChunkDataDecoder.ChunkPayload = ChunkDataDecoder.decode_binary(bytes) + _check("Decoded chunk successfully", decoded_payload != null) + _check("Decoded 2 objects", decoded_payload.objects.size() == 2) + + var dec_base: ChunkDataDecoder.WorldObjectData = null + var dec_top: ChunkDataDecoder.WorldObjectData = null + for obj in decoded_payload.objects: + if obj.id == b_id: + dec_base = obj + elif obj.id == t_id: + dec_top = obj + + _check("Base object restored with Z=0", dec_base != null and is_equal_approx(dec_base.position.z, 0.0)) + _check("Top object restored with Z=1.0", dec_top != null and is_equal_approx(dec_top.position.z, 1.0)) + _check("Top object restored with parent_id", dec_top != null and dec_top.properties.get("parent_id") == b_id) diff --git a/scripts/smoke_test_poly_collections_dock.gd b/scripts/smoke_test_poly_collections_dock.gd new file mode 100644 index 0000000..ca5f0a4 --- /dev/null +++ b/scripts/smoke_test_poly_collections_dock.gd @@ -0,0 +1,137 @@ +extends SceneTree +## Smoke test verifying Poly Model Collections dock, drag-and-drop placement, and client asset cache. + +var _failures: int = 0 +var _checks: int = 0 + +const AssetCacheManagerClass = preload("res://scripts/studio/asset_cache_manager.gd") +const StudioCollectionsDockClass = preload("res://scripts/studio/studio_collections_dock.gd") + +func _init() -> void: + print("--- Running Smoke Test: Poly Collections Dock & Asset Cache ---") + test_asset_cache_manager() + test_collections_dock_ui_and_filtering() + test_drag_and_drop_placement() + + if _failures == 0: + print("--- PASS: All %d poly collections checks passed! ---" % _checks) + quit(0) + else: + printerr("--- FAIL: %d failures out of %d checks ---" % [_failures, _checks]) + quit(1) + + +func _check(desc: String, cond: bool) -> void: + _checks += 1 + if cond: + print(" โœ“ %s" % desc) + else: + _failures += 1 + printerr(" โœ— FAIL: %s" % desc) + + +func test_asset_cache_manager() -> void: + print("Testing AssetCacheManager disk cache...") + var cache := AssetCacheManagerClass.new() + var test_key := "test_prop_chest_01" + var mock_bytes := "glTFmockbinarydata".to_utf8_buffer() + + # Store + var path := cache.store_asset(test_key, mock_bytes) + _check("Stored asset to disk", path != "" and FileAccess.file_exists(path)) + _check("Cache reports has_cached == true", cache.has_cached(test_key)) + + # Read + var read_back := cache.read_asset(test_key) + _check("Read back identical byte length", read_back.size() == mock_bytes.size()) + _check("Read back identical content", read_back.get_string_from_utf8() == "glTFmockbinarydata") + + # List cached items + var items := cache.get_cached_items() + _check("Cache item list contains stored file", items.size() > 0) + + # Clean up + var cleared := cache.clear_cache() + _check("Cleared cache files", cleared > 0) + _check("Cache reports has_cached == false after clear", not cache.has_cached(test_key)) + + +func test_collections_dock_ui_and_filtering() -> void: + print("Testing StudioCollectionsDock filtering and model selection...") + var dock = StudioCollectionsDockClass.new() + root.add_child(dock) + + _check("Dock instantiated with UI", dock != null) + + # Test theme filtering + dock._theme_option.selected = 1 # "fantasy" + dock._on_theme_selected(1) + _check("Filtered to fantasy theme displays cards", dock._collections_vbox.get_child_count() > 0) + + # Test text search + dock._search_edit.text = "chest" + dock._on_search_changed("chest") + _check("Search for 'chest' keeps matching collection visible", dock._collections_vbox.get_child_count() > 0) + + # Test non-matching search + dock._search_edit.text = "nonexistent_unicorn_blaster" + dock._on_search_changed("nonexistent_unicorn_blaster") + var status_text = dock._status_label.text + _check("Non-matching search shows 0 collections", status_text.contains("0 collections")) + + # Reset filters + dock._search_edit.text = "" + dock._theme_option.selected = 0 + dock._on_theme_selected(0) + _check("Reset filter restores all collections", dock._collections_vbox.get_child_count() >= 3) + + dock.queue_free() + + +func test_drag_and_drop_placement() -> void: + print("Testing Drag and Drop model placement into EditorViewport...") + # Setup test core and viewport + var payload := ChunkDataDecoder.ChunkPayload.new() + var layer := ChunkDataDecoder.TileLayerData.new() + layer.name = "ground" + layer.layer_id = 0 + layer.width = 16 + layer.height = 16 + layer.depth = 1 + layer.tiles = PackedInt32Array() + layer.tiles.resize(16 * 16) + payload.tile_layers.append(layer) + + var model := ChunkEditorModel.new(payload) + var catalog := EditorObjectCatalog.new() + catalog.load_catalog() + var core := EditorCore.new(model, catalog) + + var vp := EditorViewport.new() + vp.setup(core, 0) + root.add_child(vp) + + var drag_payload := { + "type": "model_placement", + "catalog_id": "crate_small", + "asset_id": "props/crate_small.glb", + "model_name": "Wooden Crate" + } + + # Check acceptance of drag data + var can_drop: bool = vp._can_drop_data(Vector2(48, 48), drag_payload) + _check("Viewport accepts model_placement drag data", can_drop) + + var invalid_drop: bool = vp._can_drop_data(Vector2(48, 48), {"type": "unknown_data"}) + _check("Viewport rejects unknown drag data", not invalid_drop) + + # Perform drop onto tile (2, 2) -> pixel (48, 48) with cell_size=24 + vp._drop_data(Vector2(48, 48), drag_payload) + _check("Model placed into model after drop", core.model.payload.objects.size() == 1) + + var placed_obj = core.model.payload.objects[0] + _check("Placed model has catalog_id crate_small", placed_obj.asset_id == "props/crate_small.glb" or placed_obj.properties.get("catalog_id") == "crate_small") + _check("Placed model X position is ~2.5", is_equal_approx(placed_obj.position.x, 2.5)) + _check("Placed model Y position is ~2.5", is_equal_approx(placed_obj.position.y, 2.5)) + + vp.queue_free() diff --git a/scripts/smoke_test_studio_playtest.gd b/scripts/smoke_test_studio_playtest.gd new file mode 100644 index 0000000..701b937 --- /dev/null +++ b/scripts/smoke_test_studio_playtest.gd @@ -0,0 +1,105 @@ +extends SceneTree +## Headless smoke test for Studio simulation harness, playtest transition, +## avatar spawn, and clean edit mode restoration (Task f77bf388e8b4). + +var _failures: int = 0 +var _checks: int = 0 + +const StudioSimulationManagerClass = preload("res://scripts/studio/studio_simulation_manager.gd") +const ObjectMotionComponentClass = preload("res://scripts/components/object_motion_component.gd") + +func _init() -> void: + print("--- Running Smoke Test: Studio Simulation Harness & Playtest ---") + test_playtest_lifecycle() + test_state_rollback_on_stop() + + if _failures == 0: + print("--- PASS: All %d studio playtest checks passed! ---" % _checks) + quit(0) + else: + printerr("--- FAIL: %d failures out of %d checks ---" % [_failures, _checks]) + quit(1) + + +func _check(desc: String, cond: bool) -> void: + _checks += 1 + if cond: + print(" โœ“ %s" % desc) + else: + _failures += 1 + printerr(" โœ— FAIL: %s" % desc) + + +func _make_mock_core() -> EditorCore: + var payload := ChunkDataDecoder.ChunkPayload.new() + var layer := ChunkDataDecoder.TileLayerData.new() + layer.name = "ground" + layer.layer_id = 0 + layer.width = 16 + layer.height = 16 + layer.depth = 1 + layer.tiles = PackedInt32Array() + layer.tiles.resize(16 * 16) + payload.tile_layers.append(layer) + + var model := ChunkEditorModel.new(payload) + var catalog := EditorObjectCatalog.new() + catalog.load_catalog() + return EditorCore.new(model, catalog) + + +func test_playtest_lifecycle() -> void: + print("Testing Simulation transition (Edit -> Play -> Stop)...") + var core := _make_mock_core() + var world := Node3D.new() + root.add_child(world) + + var sim := StudioSimulationManagerClass.new() + world.add_child(sim) + sim.setup(core, world) + + _check("Initial state is EDIT mode", sim.is_editing()) + _check("is_playing returns false", not sim.is_playing()) + + # Start Play + sim.start_play(true) + _check("State transitioned to PLAYING", sim.is_playing()) + _check("Test avatar spawned in world", sim._spawned_avatar != null) + + # Stop Play + sim.stop_simulation() + _check("State restored to EDIT mode", sim.is_editing()) + _check("Test avatar despawned cleanly", sim._spawned_avatar == null) + + world.queue_free() + + +func test_state_rollback_on_stop() -> void: + print("Testing world object transform snapshot and clean rollback...") + var core := _make_mock_core() + var world := Node3D.new() + root.add_child(world) + + # Place an initial object at (5, 5, 0) + var obj_id := core.place_object("crate_small", Vector3(5, 5, 0)) + _check("Placed initial crate at (5, 5, 0)", obj_id != "") + + var sim := StudioSimulationManagerClass.new() + world.add_child(sim) + sim.setup(core, world) + + # Start Play (takes snapshot of (5, 5, 0)) + sim.start_play(false) + + # Simulate movement during gameplay (e.g. physics push or script motion to (20, 30, 10)) + var obj = core.model.find_object(obj_id) + obj.position = Vector3(20, 30, 10) + _check("Object moved during gameplay to (20, 30, 10)", obj.position.is_equal_approx(Vector3(20, 30, 10))) + + # Stop Play -> triggers snapshot rollback + sim.stop_simulation() + + _check("Object restored exactly to pre-play position (5, 5, 0)", + obj.position.is_equal_approx(Vector3(5, 5, 0))) + + world.queue_free() diff --git a/scripts/smoke_test_studio_shell.gd b/scripts/smoke_test_studio_shell.gd new file mode 100644 index 0000000..18fc234 --- /dev/null +++ b/scripts/smoke_test_studio_shell.gd @@ -0,0 +1,127 @@ +extends SceneTree +## Headless smoke test for ModBison Studio application shell, menu dispatch, +## docking layout, and project serialization (Task b67bc0dfd35c). + +var _failures: int = 0 +var _checks: int = 0 + +const StudioMainClass = preload("res://scripts/studio/studio_main.gd") +const MBWorldProjectIOClass = preload("res://scripts/studio/mbworld_project_io.gd") +const TransformGizmo3DClass = preload("res://scripts/studio/gizmos/transform_gizmo_3d.gd") +const StudioViewportCameraClass = preload("res://scripts/studio/studio_viewport_camera.gd") + +func _init() -> void: + print("--- Running Smoke Test: ModBison Studio Application Shell ---") + test_studio_shell_initialization() + test_menu_and_toolbar_actions() + test_template_and_project_io() + test_gizmo_and_camera_controls() + + if _failures == 0: + print("--- PASS: All %d studio shell checks passed! ---" % _checks) + quit(0) + else: + printerr("--- FAIL: %d failures out of %d checks ---" % [_failures, _checks]) + quit(1) + + +func _check(desc: String, cond: bool) -> void: + _checks += 1 + if cond: + print(" โœ“ %s" % desc) + else: + _failures += 1 + printerr(" โœ— FAIL: %s" % desc) + + +func test_studio_shell_initialization() -> void: + print("Testing Studio shell instantiation and docking layout...") + var studio := StudioMainClass.new() + root.add_child(studio) + + _check("Studio shell instantiated", studio != null) + _check("EditorCore initialized", studio.core != null) + _check("Top MenuBar exists", studio._menu_bar != null) + _check("Toolbar exists", studio._toolbar != null) + _check("3D SubViewport exists", studio._sub_viewport != null) + _check("Studio Camera exists", studio._camera_3d != null) + _check("3D Transform Gizmo exists", studio._gizmo_3d != null) + _check("Scene Hierarchy Dock exists", studio._hierarchy_dock != null) + _check("Property Inspector Dock exists", studio._inspector_dock != null) + _check("Collections Dock exists", studio._collections_dock != null) + + studio.queue_free() + + +func test_menu_and_toolbar_actions() -> void: + print("Testing Menu Bar registration and tool switching...") + var studio := StudioMainClass.new() + root.add_child(studio) + + # Verify menu bar menus + var menu_names: Array[String] = [] + for child in studio._menu_bar.get_children(): + menu_names.append(child.name) + + _check("MenuBar has 'File' menu", menu_names.has("File")) + _check("MenuBar has 'Edit' menu", menu_names.has("Edit")) + _check("MenuBar has 'View' menu", menu_names.has("View")) + _check("MenuBar has 'Model' menu", menu_names.has("Model")) + _check("MenuBar has 'Test' menu", menu_names.has("Test")) + _check("MenuBar has 'Publish' menu", menu_names.has("Publish")) + + # Test gizmo mode toggles + studio._set_gizmo_mode(TransformGizmo3DClass.Mode.TRANSLATE) + _check("Gizmo set to TRANSLATE mode", studio._gizmo_3d.current_mode == TransformGizmo3DClass.Mode.TRANSLATE) + + studio._set_gizmo_mode(TransformGizmo3DClass.Mode.ROTATE) + _check("Gizmo set to ROTATE mode", studio._gizmo_3d.current_mode == TransformGizmo3DClass.Mode.ROTATE) + + studio._set_gizmo_mode(TransformGizmo3DClass.Mode.SCALE) + _check("Gizmo set to SCALE mode", studio._gizmo_3d.current_mode == TransformGizmo3DClass.Mode.SCALE) + + studio.queue_free() + + +func test_template_and_project_io() -> void: + print("Testing Starter Templates and .mbworld Project Serialization...") + # 1. Baseplate template + var baseplate_data := MBWorldProjectIOClass.create_template("baseplate") + _check("Baseplate template has spawn pad object", baseplate_data["objects"].size() >= 1) + + # 2. Obby template + var obby_data := MBWorldProjectIOClass.create_template("obby") + _check("Obby template has multiple platforms", obby_data["objects"].size() >= 3) + + # 3. Save to disk and reload + var test_path := "user://smoke_test_project.mbworld" + var save_ok := MBWorldProjectIOClass.save_project(test_path, obby_data) + _check("Successfully saved .mbworld project file", save_ok) + _check("Project file exists on disk", FileAccess.file_exists(test_path)) + + var loaded_data := MBWorldProjectIOClass.load_project(test_path) + _check("Loaded project contains matching title", loaded_data.get("title") == obby_data.get("title")) + _check("Loaded project contains same object count", loaded_data.get("objects", []).size() == obby_data.get("objects", []).size()) + + # Clean up file + DirAccess.remove_absolute(test_path) + + +func test_gizmo_and_camera_controls() -> void: + print("Testing Camera presets and Gizmo snapping...") + var studio := StudioMainClass.new() + root.add_child(studio) + + # Test camera presets + studio._camera_3d.set_view_preset(StudioViewportCameraClass.ViewPreset.TOP) + _check("Camera switched to Top orthographic view", studio._camera_3d.projection == Camera3D.PROJECTION_ORTHOGONAL) + + studio._camera_3d.set_view_preset(StudioViewportCameraClass.ViewPreset.PERSPECTIVE) + _check("Camera returned to Perspective projection", studio._camera_3d.projection == Camera3D.PROJECTION_PERSPECTIVE) + + # Test gizmo snapping + studio._gizmo_3d.set_snap(true, 2.0, 45.0) + _check("Gizmo translate snap set to 2.0", is_equal_approx(studio._gizmo_3d.snap_translate, 2.0)) + _check("Gizmo rotate snap set to 45 degrees", is_equal_approx(studio._gizmo_3d.snap_rotate_deg, 45.0)) + + studio.queue_free() diff --git a/scripts/static_object_node.gd b/scripts/static_object_node.gd index b5483d7..adc0f11 100644 --- a/scripts/static_object_node.gd +++ b/scripts/static_object_node.gd @@ -16,21 +16,136 @@ extends Node3D const ScriptBehaviorClass := preload("res://scripts/scripting/script_behavior.gd") const ScriptInterpreterNodeClass := preload("res://scripts/scripting/script_interpreter.gd") +const ObjectMotionComponentClass := preload("res://scripts/components/object_motion_component.gd") ## The world object data this node represents. var world_object: WorldObject = null ## The loaded mesh instance (or placeholder). var _mesh_instance: MeshInstance3D = null +var _anim_player: AnimationPlayer = null +var _motion_comp: Node = null func _ready() -> void: if world_object == null: return _load_mesh() + _setup_animation_and_motion() _setup_behavior() +func _setup_animation_and_motion() -> void: + if world_object == null or not world_object.properties is Dictionary: + return + + # 1. Animation playback setup + if _anim_player != null: + var anim_cfg = world_object.properties.get("animation", {}) + var clip: String = "" + var autoplay: bool = true + var loop: bool = true + var speed: float = 1.0 + if anim_cfg is Dictionary and not anim_cfg.is_empty(): + clip = str(anim_cfg.get("clip", anim_cfg.get("name", ""))) + autoplay = bool(anim_cfg.get("autoplay", true)) + loop = bool(anim_cfg.get("loop", true)) + speed = float(anim_cfg.get("speed", 1.0)) + elif world_object.properties.has("active_animation"): + clip = str(world_object.properties["active_animation"]) + + if clip == "" and not _anim_player.get_animation_list().is_empty(): + clip = _anim_player.get_animation_list()[0] + + if clip != "" and _anim_player.has_animation(clip): + var anim := _anim_player.get_animation(clip) + if anim != null: + anim.loop_mode = Animation.LOOP_LINEAR if loop else Animation.LOOP_NONE + _anim_player.speed_scale = speed + if autoplay: + _anim_player.play(clip) + + # 2. Motion Component setup + var motion_cfg = world_object.properties.get("motion", null) + if motion_cfg == null and world_object.properties.has("motion_type"): + motion_cfg = { + "motion_type": world_object.properties.get("motion_type"), + "trigger_mode": world_object.properties.get("trigger_mode", "always"), + "rotation_speed": world_object.properties.get("rotation_speed", 45.0), + "bob_amplitude": world_object.properties.get("bob_amplitude", 0.5), + "bob_frequency": world_object.properties.get("bob_frequency", 1.0), + "waypoints": world_object.properties.get("waypoints", []), + "move_speed": world_object.properties.get("move_speed", 2.0), + } + + if motion_cfg is Dictionary and not motion_cfg.is_empty() and str(motion_cfg.get("motion_type", "none")).to_lower() != "none": + if _motion_comp == null: + _motion_comp = ObjectMotionComponentClass.new() + _motion_comp.name = "ObjectMotionComponent" + add_child(_motion_comp) + _motion_comp.setup(self, motion_cfg) + elif _motion_comp != null: + _motion_comp.queue_free() + _motion_comp = null + + +func get_animation_player() -> AnimationPlayer: + return _anim_player + + +func get_available_animations() -> Array[String]: + if _anim_player != null: + var res: Array[String] = [] + for anim in _anim_player.get_animation_list(): + res.append(str(anim)) + return res + return [] + + +func play_animation(clip_name: String, speed: float = 1.0, loop: bool = true) -> void: + if _anim_player != null and _anim_player.has_animation(clip_name): + var anim := _anim_player.get_animation(clip_name) + if anim != null: + anim.loop_mode = Animation.LOOP_LINEAR if loop else Animation.LOOP_NONE + _anim_player.speed_scale = speed + _anim_player.play(clip_name) + + +func stop_animation() -> void: + if _anim_player != null: + _anim_player.stop() + + +func get_motion_component() -> Node: + return _motion_comp + + +func update_animation(anim_config: Dictionary) -> void: + if world_object != null: + world_object.properties["animation"] = anim_config + _setup_animation_and_motion() + + +func update_motion(motion_config: Dictionary) -> void: + if world_object != null: + world_object.properties["motion"] = motion_config + _setup_animation_and_motion() + + +func trigger_motion(action: String = "toggle") -> void: + if _motion_comp == null: + return + match action: + "start": + _motion_comp.start_motion() + "stop": + _motion_comp.stop_motion() + "toggle": + _motion_comp.toggle_motion() + "interact": + _motion_comp.on_interact() + + func _setup_behavior() -> void: if world_object == null or not world_object.properties is Dictionary: return @@ -63,11 +178,42 @@ func _load_mesh() -> void: _mesh_instance = asset_node as MeshInstance3D else: _mesh_instance = asset_node.find_child("*", true, false) as MeshInstance3D - return + else: + # 2. Build a stylized, recognizable 3D representation based on catalog ID / asset ID + var cat_id := _get_catalog_id() + _build_procedural_object(cat_id) - # 2. Build a stylized, recognizable 3D representation based on catalog ID / asset ID - var cat_id := _get_catalog_id() - _build_procedural_object(cat_id) + # Scan for AnimationPlayer + _anim_player = find_child("*", true, false) as AnimationPlayer + if _anim_player == null: + var players = find_children("*", "AnimationPlayer", true, false) + if not players.is_empty(): + _anim_player = players[0] as AnimationPlayer + if _anim_player != null and world_object != null and world_object.properties is Dictionary: + world_object.properties["available_animations"] = Array(_anim_player.get_animation_list()) + + # Ensure collision body exists for physics / platform ride + _ensure_collision() + + +func _ensure_collision() -> void: + var sb := find_child("collision", true, false) as StaticBody3D + if sb == null: + sb = find_child("*", true, false) as StaticBody3D + if sb == null: + sb = _create_static_collision() + var col_shape := CollisionShape3D.new() + var box := BoxShape3D.new() + if _mesh_instance != null and _mesh_instance.mesh != null: + var aabb := _mesh_instance.mesh.get_aabb() + box.size = Vector3(maxf(0.2, aabb.size.x), maxf(0.2, aabb.size.y), maxf(0.2, aabb.size.z)) + col_shape.position = aabb.get_center() + else: + box.size = Vector3(1.0, 1.0, 1.0) + col_shape.position = Vector3(0, 0.5, 0) + col_shape.shape = box + sb.add_child(col_shape) + add_child(sb) func _get_catalog_id() -> String: diff --git a/scripts/studio/asset_cache_manager.gd b/scripts/studio/asset_cache_manager.gd new file mode 100644 index 0000000..7bdcb18 --- /dev/null +++ b/scripts/studio/asset_cache_manager.gd @@ -0,0 +1,116 @@ +class_name AssetCacheManager +extends RefCounted +## Manages client-side disk caching of downloaded 3D model assets and collections. +## Caches assets locally under user://asset_cache/ to prevent duplicate network fetches. + +const CACHE_DIR: String = "user://asset_cache" + +static var _instance = null + + +static func get_instance(): + if _instance == null: + var script := load("res://scripts/studio/asset_cache_manager.gd") + _instance = script.new() + return _instance + + +func _init() -> void: + _ensure_cache_dir() + + +func _ensure_cache_dir() -> void: + if not DirAccess.dir_exists_absolute(CACHE_DIR): + DirAccess.make_dir_recursive_absolute(CACHE_DIR) + + +func get_cache_path(asset_key: String) -> String: + var clean_key := asset_key.replace("/", "_").replace("\\", "_").replace(":", "_") + if not clean_key.ends_with(".glb") and not clean_key.ends_with(".gltf"): + clean_key += ".glb" + return "%s/%s" % [CACHE_DIR, clean_key] + + +func has_cached(asset_key: String) -> bool: + var p := get_cache_path(asset_key) + return FileAccess.file_exists(p) + + +func get_cached_path_if_exists(asset_key: String) -> String: + var p := get_cache_path(asset_key) + if FileAccess.file_exists(p): + return p + return "" + + +func store_asset(asset_key: String, data: PackedByteArray) -> String: + _ensure_cache_dir() + var path := get_cache_path(asset_key) + var fa := FileAccess.open(path, FileAccess.WRITE) + if fa != null: + fa.store_buffer(data) + fa.close() + return path + return "" + + +func read_asset(asset_key: String) -> PackedByteArray: + var path := get_cache_path(asset_key) + if not FileAccess.file_exists(path): + return PackedByteArray() + var fa := FileAccess.open(path, FileAccess.READ) + if fa != null: + var buf := fa.get_buffer(fa.get_length()) + fa.close() + return buf + return PackedByteArray() + + +func load_model(asset_key: String) -> Node: + var path := get_cached_path_if_exists(asset_key) + if path == "": + return null + + if path.ends_with(".glb") or path.ends_with(".gltf"): + var gltf := GLTFDocument.new() + var state := GLTFState.new() + var err := gltf.append_from_file(path, state) + if err == OK: + return gltf.generate_scene(state) + elif ResourceLoader.exists(path): + var res := load(path) + if res is PackedScene: + return (res as PackedScene).instantiate() + + return null + + +func get_cached_items() -> Array[String]: + _ensure_cache_dir() + var items: Array[String] = [] + var dir := DirAccess.open(CACHE_DIR) + if dir != null: + dir.list_dir_begin() + var file_name := dir.get_next() + while file_name != "": + if not dir.current_is_dir() and not file_name.begins_with("."): + items.append(file_name) + file_name = dir.get_next() + dir.list_dir_end() + return items + + +func clear_cache() -> int: + _ensure_cache_dir() + var count := 0 + var dir := DirAccess.open(CACHE_DIR) + if dir != null: + dir.list_dir_begin() + var file_name := dir.get_next() + while file_name != "": + if not dir.current_is_dir(): + DirAccess.remove_absolute("%s/%s" % [CACHE_DIR, file_name]) + count += 1 + file_name = dir.get_next() + dir.list_dir_end() + return count diff --git a/scripts/studio/gizmos/transform_gizmo_3d.gd b/scripts/studio/gizmos/transform_gizmo_3d.gd new file mode 100644 index 0000000..cade290 --- /dev/null +++ b/scripts/studio/gizmos/transform_gizmo_3d.gd @@ -0,0 +1,165 @@ +class_name TransformGizmo3D +extends Node3D +## Interactive 3D manipulator gizmo supporting Translate, Rotate, and Scale modes with snapping. + +signal transform_started +signal transform_updated(mode: String, delta: Variant) +signal transform_finished(final_pos: Vector3, final_rot: Vector3, final_scale: Vector3) + +enum Mode { TRANSLATE, ROTATE, SCALE, NONE } +enum Axis { NONE, X, Y, Z, XY, XZ, YZ } + +var current_mode: Mode = Mode.TRANSLATE +var active_axis: Axis = Axis.NONE +var is_dragging: bool = false + +# Snapping configuration +var snap_enabled: bool = true +var snap_translate: float = 1.0 +var snap_rotate_deg: float = 15.0 +var snap_scale: float = 0.5 + +var target_node: Node3D = null +var _drag_start_pos: Vector3 = Vector3.ZERO +var _drag_start_rot: Vector3 = Vector3.ZERO +var _drag_start_scale: Vector3 = Vector3.ONE + + +func _ready() -> void: + _build_gizmo_visuals() + + +func attach_to_node(node: Node3D) -> void: + target_node = node + if target_node != null: + visible = true + global_position = target_node.global_position + global_rotation = target_node.global_rotation + else: + visible = false + + +func set_mode(new_mode: Mode) -> void: + current_mode = new_mode + _update_visual_mode() + + +func set_snap(enabled: bool, translate_step: float = 1.0, rotate_step: float = 15.0) -> void: + snap_enabled = enabled + snap_translate = translate_step + snap_rotate_deg = rotate_step + + +func _build_gizmo_visuals() -> void: + # Build colored 3D axis handles for X (Red), Y (Green), Z (Blue) + var x_mat := StandardMaterial3D.new() + x_mat.albedo_color = Color(0.9, 0.2, 0.2) + x_mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED + + var y_mat := StandardMaterial3D.new() + y_mat.albedo_color = Color(0.2, 0.85, 0.2) + y_mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED + + var z_mat := StandardMaterial3D.new() + z_mat.albedo_color = Color(0.2, 0.4, 0.95) + z_mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED + + # X axis line + arrow + var x_axis := _create_axis_cylinder(Vector3(1, 0, 0), x_mat) + x_axis.name = "AxisX" + add_child(x_axis) + + # Y axis line + arrow + var y_axis := _create_axis_cylinder(Vector3(0, 1, 0), y_mat) + y_axis.name = "AxisY" + add_child(y_axis) + + # Z axis line + arrow + var z_axis := _create_axis_cylinder(Vector3(0, 0, 1), z_mat) + z_axis.name = "AxisZ" + add_child(z_axis) + + +func _create_axis_cylinder(dir: Vector3, mat: Material) -> Node3D: + var container := Node3D.new() + var shaft := MeshInstance3D.new() + var cyl := CylinderMesh.new() + cyl.top_radius = 0.03 + cyl.bottom_radius = 0.03 + cyl.height = 1.5 + cyl.material = mat + shaft.mesh = cyl + + # Point cylinder along direction + if dir == Vector3(1, 0, 0): + shaft.rotation_degrees = Vector3(0, 0, -90) + shaft.position = Vector3(0.75, 0, 0) + elif dir == Vector3(0, 1, 0): + shaft.position = Vector3(0, 0.75, 0) + elif dir == Vector3(0, 0, 1): + shaft.rotation_degrees = Vector3(90, 0, 0) + shaft.position = Vector3(0, 0, 0.75) + + container.add_child(shaft) + return container + + +func _update_visual_mode() -> void: + visible = (target_node != null and current_mode != Mode.NONE) + + +func start_drag(axis: Axis) -> void: + if target_node == null: + return + active_axis = axis + is_dragging = true + _drag_start_pos = target_node.position + _drag_start_rot = target_node.rotation_degrees + _drag_start_scale = target_node.scale + transform_started.emit() + + +func apply_drag_delta(delta_vec: Vector3) -> void: + if not is_dragging or target_node == null: + return + + match current_mode: + Mode.TRANSLATE: + var new_pos := target_node.position + delta_vec + if snap_enabled and snap_translate > 0.0: + new_pos.x = snappedf(new_pos.x, snap_translate) + new_pos.y = snappedf(new_pos.y, snap_translate) + new_pos.z = snappedf(new_pos.z, snap_translate) + target_node.position = new_pos + global_position = target_node.global_position + transform_updated.emit("translate", new_pos) + + Mode.ROTATE: + var rot := target_node.rotation_degrees + delta_vec * 10.0 + if snap_enabled and snap_rotate_deg > 0.0: + rot.x = snappedf(rot.x, snap_rotate_deg) + rot.y = snappedf(rot.y, snap_rotate_deg) + rot.z = snappedf(rot.z, snap_rotate_deg) + target_node.rotation_degrees = rot + transform_updated.emit("rotate", rot) + + Mode.SCALE: + var sc := target_node.scale + delta_vec + sc.x = maxf(0.1, sc.x) + sc.y = maxf(0.1, sc.y) + sc.z = maxf(0.1, sc.z) + if snap_enabled and snap_scale > 0.0: + sc.x = snappedf(sc.x, snap_scale) + sc.y = snappedf(sc.y, snap_scale) + sc.z = snappedf(sc.z, snap_scale) + target_node.scale = sc + transform_updated.emit("scale", sc) + + +func finish_drag() -> void: + if not is_dragging: + return + is_dragging = false + active_axis = Axis.NONE + if target_node != null: + transform_finished.emit(target_node.position, target_node.rotation_degrees, target_node.scale) diff --git a/scripts/studio/mbworld_project_io.gd b/scripts/studio/mbworld_project_io.gd new file mode 100644 index 0000000..bd83d36 --- /dev/null +++ b/scripts/studio/mbworld_project_io.gd @@ -0,0 +1,164 @@ +class_name MBWorldProjectIO +extends RefCounted +## Manages serialization and deserialization of local .mbworld project files +## and cloud sync with ModBison backend. + +const RECENT_PROJECTS_PATH: String = "user://recent_projects.json" + + +static func save_project(path: String, project_data: Dictionary) -> bool: + var fa := FileAccess.open(path, FileAccess.WRITE) + if fa == null: + push_error("Failed to open %s for write" % path) + return false + + var json_str := JSON.stringify(project_data, " ") + fa.store_string(json_str) + fa.close() + record_recent_project(path) + return true + + +static func load_project(path: String) -> Dictionary: + if not FileAccess.file_exists(path): + push_error("Project file does not exist: %s" % path) + return {} + + var fa := FileAccess.open(path, FileAccess.READ) + if fa == null: + return {} + + var text := fa.get_as_text() + fa.close() + + var parsed = JSON.parse_string(text) + if parsed is Dictionary: + record_recent_project(path) + return parsed + return {} + + +static func create_template(template_name: String) -> Dictionary: + var base := { + "format_version": 1, + "title": "Untitled World", + "template": template_name, + "created_at": Time.get_datetime_string_from_system(), + "objects": [], + "tile_layers": [ + { + "name": "ground", + "width": 32, + "height": 32, + "tiles": [] + } + ], + "settings": { + "ambient_color": [0.4, 0.4, 0.45], + "sky_color": [0.3, 0.6, 0.9], + "gravity": 9.8 + } + } + + match template_name.to_lower(): + "baseplate": + base["title"] = "Baseplate World" + base["objects"] = [ + { + "id": "spawn_pad", + "catalog_id": "spawn_pad", + "asset_id": "props/crate_small.glb", + "position": [0, 0, 0], + "rotation": [0, 0, 0, 1], + "scale": [2, 2, 0.2], + "properties": {"display_name": "Spawn Pad"} + } + ] + "terrain": + base["title"] = "Rolling Hills World" + base["objects"] = [ + { + "id": "tree_01", + "catalog_id": "tree_pine", + "asset_id": "props/tree_pine_01.glb", + "position": [4, 4, 0], + "rotation": [0, 0, 0, 1], + "scale": [1, 1, 1], + "properties": {"display_name": "Pine Tree"} + }, + { + "id": "bison_01", + "catalog_id": "bison", + "asset_id": "props/bison.glb", + "position": [0, 6, 0], + "rotation": [0, 0, 0, 1], + "scale": [1, 1, 1], + "properties": {"display_name": "Wild Bison"} + } + ] + "obby", "platformer": + base["title"] = "Obby Parkour World" + base["objects"] = [ + { + "id": "start_platform", + "catalog_id": "crate_small", + "asset_id": "props/crate_small.glb", + "position": [0, 0, 0], + "rotation": [0, 0, 0, 1], + "scale": [3, 3, 0.5], + "properties": {"display_name": "Start Platform"} + }, + { + "id": "moving_plat_01", + "catalog_id": "crate_small", + "asset_id": "props/crate_small.glb", + "position": [0, 6, 1], + "rotation": [0, 0, 0, 1], + "scale": [2, 2, 0.3], + "properties": { + "display_name": "Moving Elevator", + "motion": { + "motion_type": "waypoint", + "move_speed": 3.0, + "waypoints": [[0, 6, 1], [0, 6, 6]] + } + } + }, + { + "id": "goal_platform", + "catalog_id": "crate_small", + "asset_id": "props/crate_small.glb", + "position": [0, 14, 6], + "rotation": [0, 0, 0, 1], + "scale": [3, 3, 0.5], + "properties": {"display_name": "Finish Goal"} + } + ] + + return base + + +static func record_recent_project(path: String) -> void: + var recents: Array = get_recent_projects() + if path in recents: + recents.erase(path) + recents.push_front(path) + if recents.size() > 10: + recents.resize(10) + var fa := FileAccess.open(RECENT_PROJECTS_PATH, FileAccess.WRITE) + if fa != null: + fa.store_string(JSON.stringify(recents)) + fa.close() + + +static func get_recent_projects() -> Array: + if not FileAccess.file_exists(RECENT_PROJECTS_PATH): + return [] + var fa := FileAccess.open(RECENT_PROJECTS_PATH, FileAccess.READ) + if fa == null: + return [] + var parsed = JSON.parse_string(fa.get_as_text()) + fa.close() + if parsed is Array: + return parsed + return [] diff --git a/scripts/studio/studio_collections_dock.gd b/scripts/studio/studio_collections_dock.gd new file mode 100644 index 0000000..ea8bd12 --- /dev/null +++ b/scripts/studio/studio_collections_dock.gd @@ -0,0 +1,268 @@ +class_name StudioCollectionsDock +extends VBoxContainer +## Model Collections Browser Dock for ModBison Studio. +## Provides collection browsing, theme filtering, item previews, +## and drag-and-drop model placement into the 3D viewport. + +signal model_selected(model_data: Dictionary) +signal model_drag_started(model_data: Dictionary) +signal collection_selected(collection_id: int) + +const AssetCacheManagerClass = preload("res://scripts/studio/asset_cache_manager.gd") + +var _search_edit: LineEdit +var _theme_option: OptionButton +var _collections_scroll: ScrollContainer +var _collections_vbox: VBoxContainer +var _models_grid: GridContainer +var _status_label: Label + +var _collections_data: Array[Dictionary] = [] +var _selected_collection_id: int = -1 +var _cache_mgr = null +var _is_built: bool = false + + +func _init() -> void: + name = "StudioCollectionsDock" + custom_minimum_size = Vector2(280, 400) + size_flags_vertical = Control.SIZE_EXPAND_FILL + _cache_mgr = AssetCacheManagerClass.get_instance() + if not _is_built: + _build_ui() + _load_default_preset_collections() + + +func _ready() -> void: + if not _is_built: + _build_ui() + _load_default_preset_collections() + + +func _build_ui() -> void: + if _is_built: + return + _is_built = true + var header := Label.new() + header.text = "Poly Model Collections" + header.add_theme_font_size_override("font_size", 14) + header.add_theme_color_override("font_color", Color(1.0, 0.9, 0.35)) + add_child(header) + + var sub := Label.new() + sub.text = "Curated themed 3D model packs. Drag onto viewport to place." + sub.add_theme_font_size_override("font_size", 10) + sub.add_theme_color_override("font_color", Color(0.7, 0.7, 0.7)) + sub.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + add_child(sub) + + # Search Bar + _search_edit = LineEdit.new() + _search_edit.placeholder_text = "๐Ÿ” Search collections & models..." + _search_edit.text_changed.connect(_on_search_changed) + add_child(_search_edit) + + # Theme Filter + var filter_row := HBoxContainer.new() + var theme_lbl := Label.new() + theme_lbl.text = "Theme:" + theme_lbl.add_theme_font_size_override("font_size", 11) + filter_row.add_child(theme_lbl) + + _theme_option = OptionButton.new() + _theme_option.size_flags_horizontal = Control.SIZE_EXPAND_FILL + _theme_option.add_item("All Themes", 0) + _theme_option.set_item_metadata(0, "") + var themes := ["fantasy", "medieval", "nature", "sci-fi", "urban", "industrial", "props"] + for i in range(themes.size()): + var t: String = themes[i] + _theme_option.add_item(t.capitalize(), i + 1) + _theme_option.set_item_metadata(i + 1, t) + _theme_option.item_selected.connect(_on_theme_selected) + filter_row.add_child(_theme_option) + add_child(filter_row) + + # Scroll area for collections + _collections_scroll = ScrollContainer.new() + _collections_scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL + _collections_scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED + add_child(_collections_scroll) + + _collections_vbox = VBoxContainer.new() + _collections_vbox.size_flags_horizontal = Control.SIZE_EXPAND_FILL + _collections_scroll.add_child(_collections_vbox) + + _status_label = Label.new() + _status_label.text = "Ready" + _status_label.add_theme_font_size_override("font_size", 10) + _status_label.add_theme_color_override("font_color", Color(0.5, 0.5, 0.5)) + add_child(_status_label) + + +func _load_default_preset_collections() -> void: + # Built-in collections for offline / studio design + var fantasy_pack := { + "id": 101, + "title": "Fantasy Ruins & Treasure", + "theme": "fantasy", + "description": "Stone pillars, treasure chests, and fantasy dungeon props", + "items": [ + {"id": 1, "name": "Treasure Chest", "catalog_id": "chest_01", "asset_id": "props/chest.glb", "theme": "fantasy"}, + {"id": 2, "name": "Buffalo Coin", "catalog_id": "coin_01", "asset_id": "props/buffalo_chip_coin.glb", "theme": "fantasy"}, + {"id": 3, "name": "Pine Tree", "catalog_id": "tree_pine", "asset_id": "props/tree_pine_01.glb", "theme": "nature"}, + ] + } + var props_pack := { + "id": 102, + "title": "Town & Village Props", + "theme": "medieval", + "description": "Street lamps, wooden crates, and boundary rocks", + "items": [ + {"id": 4, "name": "Wooden Crate", "catalog_id": "crate_small", "asset_id": "props/crate_small.glb", "theme": "props"}, + {"id": 5, "name": "Street Lamp", "catalog_id": "lamp_post", "asset_id": "props/lamp_post_01.glb", "theme": "urban"}, + {"id": 6, "name": "Garden Rock", "catalog_id": "rock_01", "asset_id": "props/rock_01.glb", "theme": "nature"}, + ] + } + var bison_pack := { + "id": 103, + "title": "Wildlife & Creatures", + "theme": "nature", + "description": "Wild Bison and terrain fauna", + "items": [ + {"id": 7, "name": "ModBison", "catalog_id": "bison", "asset_id": "props/bison.glb", "theme": "nature"}, + ] + } + + _collections_data = [fantasy_pack, props_pack, bison_pack] + _refresh_collections_display() + + +func add_collection(col_dict: Dictionary) -> void: + _collections_data.append(col_dict) + _refresh_collections_display() + + +func set_collections(cols: Array[Dictionary]) -> void: + _collections_data = cols + _refresh_collections_display() + + +func _on_search_changed(_new_text: String) -> void: + _refresh_collections_display() + + +func _on_theme_selected(_idx: int) -> void: + _refresh_collections_display() + + +func _refresh_collections_display() -> void: + for c in _collections_vbox.get_children(): + c.queue_free() + + var search_text := _search_edit.text.strip_edges().to_lower() + var selected_theme := "" + if _theme_option != null: + var meta = _theme_option.get_item_metadata(_theme_option.selected) + if meta != null: + selected_theme = str(meta).to_lower() + + var matched_count := 0 + for col in _collections_data: + var col_theme: String = str(col.get("theme", "")).to_lower() + var col_title: String = str(col.get("title", "")) + var col_desc: String = str(col.get("description", "")) + + if selected_theme != "" and col_theme != selected_theme: + continue + + if search_text != "": + var matches_title := col_title.to_lower().contains(search_text) + var matches_desc := col_desc.to_lower().contains(search_text) + var matches_item := false + for item in col.get("items", []): + if str(item.get("name", "")).to_lower().contains(search_text): + matches_item = true + break + if not matches_title and not matches_desc and not matches_item: + continue + + var card := _create_collection_card(col) + _collections_vbox.add_child(card) + matched_count += 1 + + if matched_count == 0: + var empty_lbl := Label.new() + empty_lbl.text = "No collections found matching filter." + empty_lbl.add_theme_font_size_override("font_size", 11) + empty_lbl.add_theme_color_override("font_color", Color(0.6, 0.6, 0.6)) + _collections_vbox.add_child(empty_lbl) + + _status_label.text = "Showing %d collections" % matched_count + + +func _create_collection_card(col: Dictionary) -> Control: + var panel := PanelContainer.new() + var vbox := VBoxContainer.new() + panel.add_child(vbox) + + var title_lbl := Label.new() + title_lbl.text = "๐Ÿ“ฆ " + str(col.get("title", "Collection")) + title_lbl.add_theme_font_size_override("font_size", 12) + title_lbl.add_theme_color_override("font_color", Color(0.3, 0.85, 1.0)) + vbox.add_child(title_lbl) + + var theme_lbl := Label.new() + theme_lbl.text = "Theme: %s | %d models" % [str(col.get("theme", "general")).capitalize(), (col.get("items", []) as Array).size()] + theme_lbl.add_theme_font_size_override("font_size", 10) + theme_lbl.add_theme_color_override("font_color", Color(0.7, 0.7, 0.7)) + vbox.add_child(theme_lbl) + + # Grid of models in this collection + var items: Array = col.get("items", []) + var grid := GridContainer.new() + grid.columns = 2 + grid.size_flags_horizontal = Control.SIZE_EXPAND_FILL + + for item in items: + if item is Dictionary: + var btn := _create_model_item_button(item) + grid.add_child(btn) + + vbox.add_child(grid) + return panel + + +func _create_model_item_button(item: Dictionary) -> Button: + var btn := ModelDragButton.new() + btn.model_data = item + btn.text = "๐Ÿ”น " + str(item.get("name", "Model")) + btn.custom_minimum_size = Vector2(120, 28) + btn.size_flags_horizontal = Control.SIZE_EXPAND_FILL + btn.tooltip_text = "Click to select or drag directly into 3D viewport" + btn.pressed.connect(func(): + model_selected.emit(item) + _status_label.text = "Selected: %s" % item.get("name") + ) + btn.drag_started.connect(func(data): + model_drag_started.emit(data) + ) + return btn + + +class ModelDragButton extends Button: + signal drag_started(data: Dictionary) + var model_data: Dictionary = {} + + func _get_drag_data(_at_position: Vector2) -> Variant: + var preview := Label.new() + preview.text = "๐Ÿ“ฆ " + str(model_data.get("name", "Model")) + preview.add_theme_color_override("font_color", Color(1.0, 0.95, 0.2)) + set_drag_preview(preview) + drag_started.emit(model_data) + return { + "type": "model_placement", + "catalog_id": model_data.get("catalog_id", ""), + "asset_id": model_data.get("asset_id", ""), + "model_name": model_data.get("name", ""), + "model_data": model_data + } diff --git a/scripts/studio/studio_hierarchy_dock.gd b/scripts/studio/studio_hierarchy_dock.gd new file mode 100644 index 0000000..add1126 --- /dev/null +++ b/scripts/studio/studio_hierarchy_dock.gd @@ -0,0 +1,181 @@ +class_name StudioHierarchyDock +extends VBoxContainer +## Scene Hierarchy Tree Dock for ModBison Studio. +## Displays all world objects, supports search filtering, selection sync, +## parent-child hierarchies, duplicate, and deletion. + +signal object_selected(object_id: String) +signal delete_requested(object_id: String) +signal duplicate_requested(object_id: String) +signal group_created(object_ids: Array[String]) + +var _search_edit: LineEdit +var _tree: Tree +var _tree_root: TreeItem +var _btn_delete: Button +var _btn_duplicate: Button +var _btn_group: Button + +var _items_by_id: Dictionary = {} +var _updating: bool = false +var _core: EditorCore = null + + +func _init() -> void: + name = "StudioHierarchyDock" + custom_minimum_size = Vector2(240, 300) + size_flags_vertical = Control.SIZE_EXPAND_FILL + _build_ui() + + +func setup(core: EditorCore) -> void: + _core = core + if _core != null and _core.selection != null: + _core.selection.selection_changed.connect(_on_core_selection_changed) + refresh_tree() + + +func _build_ui() -> void: + var header := Label.new() + header.text = "Scene Hierarchy" + header.add_theme_font_size_override("font_size", 13) + header.add_theme_color_override("font_color", Color(1.0, 0.9, 0.4)) + add_child(header) + + _search_edit = LineEdit.new() + _search_edit.placeholder_text = "๐Ÿ” Search hierarchy..." + _search_edit.text_changed.connect(_on_search_changed) + add_child(_search_edit) + + _tree = Tree.new() + _tree.size_flags_vertical = Control.SIZE_EXPAND_FILL + _tree.select_mode = Tree.SELECT_MULTI + _tree.item_selected.connect(_on_tree_item_selected) + add_child(_tree) + + # Action buttons row + var btn_row := HBoxContainer.new() + _btn_duplicate = Button.new() + _btn_duplicate.text = "Duplicate" + _btn_duplicate.size_flags_horizontal = Control.SIZE_EXPAND_FILL + _btn_duplicate.pressed.connect(_on_duplicate_pressed) + btn_row.add_child(_btn_duplicate) + + _btn_group = Button.new() + _btn_group.text = "Group" + _btn_group.size_flags_horizontal = Control.SIZE_EXPAND_FILL + _btn_group.pressed.connect(_on_group_pressed) + btn_row.add_child(_btn_group) + + _btn_delete = Button.new() + _btn_delete.text = "Delete" + _btn_delete.add_theme_color_override("font_color", Color(1.0, 0.4, 0.4)) + _btn_delete.size_flags_horizontal = Control.SIZE_EXPAND_FILL + _btn_delete.pressed.connect(_on_delete_pressed) + btn_row.add_child(_btn_delete) + add_child(btn_row) + + +func refresh_tree() -> void: + _tree.clear() + _items_by_id.clear() + _tree_root = _tree.create_item() + _tree_root.set_text(0, "World Scene") + + if _core == null or _core.model == null: + return + + var filter := _search_edit.text.strip_edges().to_lower() + var objects = _core.model.payload.objects + + # First pass: identify parents and root objects + for obj in objects: + var parent_id := "" + if obj.properties is Dictionary: + parent_id = str(obj.properties.get("parent_id", "")) + + var dname: String = str(obj.id) + if obj.properties is Dictionary and obj.properties.has("display_name"): + dname = str(obj.properties["display_name"]) + + if filter != "" and not dname.to_lower().contains(filter) and not obj.id.to_lower().contains(filter): + continue + + var item := _tree.create_item(_tree_root) + item.set_text(0, "๐Ÿ”น " + dname) + item.set_metadata(0, obj.id) + _items_by_id[obj.id] = item + + # Highlight current selection + if _core.selection != null: + var sel := _core.selection.get_selected() + if sel != "" and _items_by_id.has(sel): + _items_by_id[sel].select(0) + + +func _on_search_changed(_text: String) -> void: + refresh_tree() + + +func _on_tree_item_selected() -> void: + if _updating: + return + var selected_item := _tree.get_selected() + if selected_item != null: + var obj_id: String = str(selected_item.get_metadata(0)) + if obj_id != "": + object_selected.emit(obj_id) + if _core != null: + _updating = true + _core.select_object(obj_id) + _updating = false + + +func _on_core_selection_changed(obj_id: String) -> void: + if _updating: + return + _updating = true + _tree.deselect_all() + if obj_id != "" and _items_by_id.has(obj_id): + _items_by_id[obj_id].select(0) + _items_by_id[obj_id].scroll_to_visible() + _updating = false + + +func _on_delete_pressed() -> void: + if _core == null: + return + var sel := _core.selection.get_selected() + if sel != "": + delete_requested.emit(sel) + _core.delete_object(sel) + refresh_tree() + + +func _on_duplicate_pressed() -> void: + if _core == null: + return + var sel := _core.selection.get_selected() + if sel != "": + var obj := _core.model.find_object(sel) + if obj != null: + var new_pos := obj.position + Vector3(1.0, 1.0, 0.0) + var cat_id := str(obj.properties.get("catalog_id", "crate_small")) + var new_id := _core.place_object(cat_id, new_pos, obj.rotation, obj.scale, obj.properties.duplicate(true)) + if new_id != "": + duplicate_requested.emit(new_id) + _core.select_object(new_id) + refresh_tree() + + +func _on_group_pressed() -> void: + var selected_ids: Array[String] = [] + var item := _tree.get_next_selected(null) + while item != null: + var meta = item.get_metadata(0) + if meta != null and str(meta) != "": + selected_ids.append(str(meta)) + item = _tree.get_next_selected(item) + + if selected_ids.size() >= 2: + group_created.emit(selected_ids) diff --git a/scripts/studio/studio_inspector_dock.gd b/scripts/studio/studio_inspector_dock.gd new file mode 100644 index 0000000..9888533 --- /dev/null +++ b/scripts/studio/studio_inspector_dock.gd @@ -0,0 +1,185 @@ +class_name StudioInspectorDock +extends VBoxContainer +## Property Inspector Dock for ModBison Studio. +## Renders editable properties for selected world object(s) with undo/redo support. + +signal property_changed(object_id: String, key: String, value: Variant) +signal delete_requested(object_id: String) + +var _core: EditorCore = null +var _selected_id: String = "" +var _updating: bool = false + +var _title_label: Label +var _empty_label: Label +var _scroll: ScrollContainer +var _content_vbox: VBoxContainer + +# Transform fields +var _pos_x: SpinBox +var _pos_y: SpinBox +var _pos_z: SpinBox +var _rot_y: SpinBox +var _scale_x: SpinBox +var _scale_y: SpinBox +var _scale_z: SpinBox + +# Metadata +var _name_edit: LineEdit +var _asset_label: Label + + +func _init() -> void: + name = "StudioInspectorDock" + custom_minimum_size = Vector2(240, 300) + size_flags_vertical = Control.SIZE_EXPAND_FILL + _build_ui() + + +func setup(core: EditorCore) -> void: + _core = core + if _core != null and _core.selection != null: + _core.selection.selection_changed.connect(_on_selection_changed) + + +func _build_ui() -> void: + _title_label = Label.new() + _title_label.text = "Properties" + _title_label.add_theme_font_size_override("font_size", 13) + _title_label.add_theme_color_override("font_color", Color(1.0, 0.9, 0.4)) + add_child(_title_label) + + _empty_label = Label.new() + _empty_label.text = "(No selection)" + _empty_label.add_theme_font_size_override("font_size", 11) + _empty_label.add_theme_color_override("font_color", Color(0.6, 0.6, 0.6)) + add_child(_empty_label) + + _scroll = ScrollContainer.new() + _scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL + _scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED + add_child(_scroll) + + _content_vbox = VBoxContainer.new() + _content_vbox.size_flags_horizontal = Control.SIZE_EXPAND_FILL + _content_vbox.visible = false + _scroll.add_child(_content_vbox) + + # Name field + var name_lbl := Label.new() + name_lbl.text = "Display Name:" + name_lbl.add_theme_font_size_override("font_size", 10) + _content_vbox.add_child(name_lbl) + _name_edit = LineEdit.new() + _name_edit.text_submitted.connect(_on_name_changed) + _content_vbox.add_child(_name_edit) + + _asset_label = Label.new() + _asset_label.text = "Asset: None" + _asset_label.add_theme_font_size_override("font_size", 10) + _asset_label.add_theme_color_override("font_color", Color(0.7, 0.7, 0.7)) + _content_vbox.add_child(_asset_label) + + # Transform header + var t_header := Label.new() + t_header.text = "Transform" + t_header.add_theme_font_size_override("font_size", 11) + t_header.add_theme_color_override("font_color", Color(0.35, 0.85, 1.0)) + _content_vbox.add_child(t_header) + + _pos_x = _add_spin("Pos X", _on_pos_changed) + _pos_y = _add_spin("Pos Y", _on_pos_changed) + _pos_z = _add_spin("Pos Z", _on_pos_changed) + + _rot_y = _add_spin("Yaw (ยฐ)", _on_rot_changed, -360.0, 360.0, 1.0) + + _scale_x = _add_spin("Scale X", _on_scale_changed, 0.05, 50.0, 0.1) + _scale_y = _add_spin("Scale Y", _on_scale_changed, 0.05, 50.0, 0.1) + _scale_z = _add_spin("Scale Z", _on_scale_changed, 0.05, 50.0, 0.1) + + +func _add_spin(label: String, callback: Callable, min_val: float = -500.0, max_val: float = 500.0, step: float = 0.5) -> SpinBox: + var row := HBoxContainer.new() + var lbl := Label.new() + lbl.text = label + ":" + lbl.custom_minimum_size = Vector2(70, 0) + lbl.add_theme_font_size_override("font_size", 10) + row.add_child(lbl) + + var spin := SpinBox.new() + spin.min_value = min_val + spin.max_value = max_val + spin.step = step + spin.size_flags_horizontal = Control.SIZE_EXPAND_FILL + spin.value_changed.connect(callback) + row.add_child(spin) + _content_vbox.add_child(row) + return spin + + +func _on_selection_changed(obj_id: String) -> void: + _selected_id = obj_id + if _selected_id == "" or _core == null: + _empty_label.visible = true + _content_vbox.visible = false + return + + var obj = _core.model.find_object(_selected_id) + if obj == null: + _empty_label.visible = true + _content_vbox.visible = false + return + + _updating = true + _empty_label.visible = false + _content_vbox.visible = true + + var dname: String = str(obj.id) + if obj.properties is Dictionary and obj.properties.has("display_name"): + dname = str(obj.properties["display_name"]) + _name_edit.text = dname + _asset_label.text = "Asset: %s" % obj.asset_id + + _pos_x.value = obj.position.x + _pos_y.value = obj.position.y + _pos_z.value = obj.position.z + + var euler = Quaternion(obj.rotation.x, obj.rotation.y, obj.rotation.z, obj.rotation.w).get_euler() + _rot_y.value = rad_to_deg(euler.y) + + _scale_x.value = obj.scale.x + _scale_y.value = obj.scale.y + _scale_z.value = obj.scale.z + _updating = false + + +func _on_name_changed(new_name: String) -> void: + if _updating or _core == null or _selected_id == "": + return + _core.set_object_property(_selected_id, "display_name", new_name) + property_changed.emit(_selected_id, "display_name", new_name) + + +func _on_pos_changed(_val: float) -> void: + if _updating or _core == null or _selected_id == "": + return + var new_pos := Vector3(_pos_x.value, _pos_y.value, _pos_z.value) + _core.move_object(_selected_id, new_pos) + property_changed.emit(_selected_id, "position", new_pos) + + +func _on_rot_changed(_val: float) -> void: + if _updating or _core == null or _selected_id == "": + return + var quat := Quaternion.from_euler(Vector3(0, deg_to_rad(_rot_y.value), 0)) + var rot_vec := Vector4(quat.x, quat.y, quat.z, quat.w) + _core.rotate_object(_selected_id, rot_vec) + property_changed.emit(_selected_id, "rotation", rot_vec) + + +func _on_scale_changed(_val: float) -> void: + if _updating or _core == null or _selected_id == "": + return + var new_scale := Vector3(_scale_x.value, _scale_y.value, _scale_z.value) + _core.scale_object(_selected_id, new_scale) + property_changed.emit(_selected_id, "scale", new_scale) diff --git a/scripts/studio/studio_main.gd b/scripts/studio/studio_main.gd new file mode 100644 index 0000000..2b10a2c --- /dev/null +++ b/scripts/studio/studio_main.gd @@ -0,0 +1,535 @@ +class_name StudioMain +extends Control +## Standalone ModBison Studio Application Shell. +## Implements multi-panel docking layout, top menu bar with keyboard shortcuts, +## 3D interactive viewport with gizmos, hierarchy, inspector, collections dock, +## and simulation harness. + +const MBWorldProjectIOClass = preload("res://scripts/studio/mbworld_project_io.gd") +const StudioViewportCameraClass = preload("res://scripts/studio/studio_viewport_camera.gd") +const TransformGizmo3DClass = preload("res://scripts/studio/gizmos/transform_gizmo_3d.gd") +const StudioHierarchyDockClass = preload("res://scripts/studio/studio_hierarchy_dock.gd") +const StudioInspectorDockClass = preload("res://scripts/studio/studio_inspector_dock.gd") +const StudioCollectionsDockClass = preload("res://scripts/studio/studio_collections_dock.gd") +const StudioSimulationManagerClass = preload("res://scripts/studio/studio_simulation_manager.gd") +const StaticObjectNodeClass = preload("res://scripts/static_object_node.gd") +const WorldObjectClass = preload("res://scripts/world_object.gd") + +# Core state +var core: EditorCore = null +var current_project_path: String = "" +var project_title: String = "Untitled World" + +# Top Menu & Toolbar +var _menu_bar: MenuBar +var _toolbar: HBoxContainer +var _btn_select: Button +var _btn_move: Button +var _btn_rotate: Button +var _btn_scale: Button +var _btn_play: Button +var _btn_stop: Button +var _snap_spin: SpinBox + +# Main Viewport & 3D Elements +var _sub_viewport: SubViewport +var _camera_3d: Camera3D +var _world_container: Node3D +var _gizmo_3d: Node3D + +# Docks +var _hierarchy_dock = null +var _inspector_dock = null +var _collections_dock = null +var _simulation_mgr = null +var _status_bar_label: Label +var _is_built: bool = false + + +func _init() -> void: + name = "StudioMain" + if not _is_built: + _init_core_model() + _build_studio_layout() + + +func _ready() -> void: + if not _is_built: + _init_core_model() + _build_studio_layout() + _setup_shortcuts() + _parse_cmdline_arguments() + + +func _init_core_model() -> void: + var payload := ChunkDataDecoder.ChunkPayload.new() + var layer := ChunkDataDecoder.TileLayerData.new() + layer.name = "ground" + layer.layer_id = 0 + layer.width = 32 + layer.height = 32 + layer.depth = 1 + layer.tiles = PackedInt32Array() + layer.tiles.resize(32 * 32) + payload.tile_layers.append(layer) + + var model := ChunkEditorModel.new(payload) + var catalog := EditorObjectCatalog.new() + catalog.load_catalog() + core = EditorCore.new(model, catalog) + + +func _build_studio_layout() -> void: + if _is_built: + return + _is_built = true + set_anchors_preset(Control.PRESET_FULL_RECT) + + var bg := ColorRect.new() + bg.set_anchors_preset(Control.PRESET_FULL_RECT) + bg.color = Color(0.14, 0.15, 0.18) + add_child(bg) + + var main_vbox := VBoxContainer.new() + main_vbox.set_anchors_preset(Control.PRESET_FULL_RECT) + add_child(main_vbox) + + # 1. Top Menu Bar + _build_top_menu_bar(main_vbox) + + # 2. Main Action Toolbar + _build_toolbar(main_vbox) + + # 3. Center Workspace (Horizontal Split: Left Dock | Center Viewport | Right Docks) + var workspace_hbox := HBoxContainer.new() + workspace_hbox.size_flags_vertical = Control.SIZE_EXPAND_FILL + main_vbox.add_child(workspace_hbox) + + # Left Dock: Hierarchy + var left_panel := PanelContainer.new() + left_panel.custom_minimum_size = Vector2(240, 0) + left_panel.size_flags_vertical = Control.SIZE_EXPAND_FILL + _hierarchy_dock = StudioHierarchyDockClass.new() + _hierarchy_dock.setup(core) + left_panel.add_child(_hierarchy_dock) + workspace_hbox.add_child(left_panel) + + # Center: 3D Viewport + var vp_container := SubViewportContainer.new() + vp_container.size_flags_horizontal = Control.SIZE_EXPAND_FILL + vp_container.size_flags_vertical = Control.SIZE_EXPAND_FILL + vp_container.stretch = true + workspace_hbox.add_child(vp_container) + + _sub_viewport = SubViewport.new() + _sub_viewport.handle_input_locally = true + vp_container.add_child(_sub_viewport) + + _setup_3d_viewport_scene(_sub_viewport) + + # Right Dock: TabContainer with Inspector & Collections + var right_panel := PanelContainer.new() + right_panel.custom_minimum_size = Vector2(280, 0) + right_panel.size_flags_vertical = Control.SIZE_EXPAND_FILL + var tabs := TabContainer.new() + tabs.size_flags_horizontal = Control.SIZE_EXPAND_FILL + tabs.size_flags_vertical = Control.SIZE_EXPAND_FILL + + _inspector_dock = StudioInspectorDockClass.new() + _inspector_dock.setup(core) + tabs.add_child(_inspector_dock) + tabs.set_tab_title(0, "Inspector") + + _collections_dock = StudioCollectionsDockClass.new() + _collections_dock.model_selected.connect(_on_collection_model_selected) + tabs.add_child(_collections_dock) + tabs.set_tab_title(1, "Collections") + + right_panel.add_child(tabs) + workspace_hbox.add_child(right_panel) + + # 4. Bottom Status Bar + var status_panel := PanelContainer.new() + status_panel.custom_minimum_size = Vector2(0, 24) + _status_bar_label = Label.new() + _status_bar_label.text = "ModBison Studio Ready | Grid: 1.0m | Mode: Select" + _status_bar_label.add_theme_font_size_override("font_size", 10) + status_panel.add_child(_status_bar_label) + main_vbox.add_child(status_panel) + + # Simulation Manager + _simulation_mgr = StudioSimulationManagerClass.new() + _simulation_mgr.setup(core, _world_container) + _simulation_mgr.simulation_started.connect(func(_m): _update_simulation_ui(true)) + _simulation_mgr.simulation_stopped.connect(func(): _update_simulation_ui(false)) + add_child(_simulation_mgr) + + +func _build_top_menu_bar(parent: Control) -> void: + _menu_bar = MenuBar.new() + _menu_bar.custom_minimum_size = Vector2(0, 28) + + # File Menu + var file_menu := PopupMenu.new() + file_menu.name = "File" + file_menu.add_item("New Project...", 0, KEY_MASK_CTRL | KEY_N) + file_menu.add_item("Open Project...", 1, KEY_MASK_CTRL | KEY_O) + file_menu.add_separator() + file_menu.add_item("Save (Ctrl+S)", 2, KEY_MASK_CTRL | KEY_S) + file_menu.add_item("Save As...", 3) + file_menu.add_separator() + file_menu.add_item("Publish to Cloud...", 4) + file_menu.add_separator() + file_menu.add_item("Exit Studio", 5) + file_menu.id_pressed.connect(_on_file_menu_pressed) + _menu_bar.add_child(file_menu) + + # Edit Menu + var edit_menu := PopupMenu.new() + edit_menu.name = "Edit" + edit_menu.add_item("Undo", 0, KEY_MASK_CTRL | KEY_Z) + edit_menu.add_item("Redo", 1, KEY_MASK_CTRL | KEY_Y) + edit_menu.add_separator() + edit_menu.add_item("Duplicate", 2, KEY_MASK_CTRL | KEY_D) + edit_menu.add_item("Delete", 3, KEY_DELETE) + edit_menu.id_pressed.connect(_on_edit_menu_pressed) + _menu_bar.add_child(edit_menu) + + # View Menu + var view_menu := PopupMenu.new() + view_menu.name = "View" + view_menu.add_item("Perspective", 0, KEY_P) + view_menu.add_item("Top View", 1, KEY_7) + view_menu.add_item("Front View", 2, KEY_1) + view_menu.add_item("Side View", 3, KEY_3) + view_menu.add_separator() + view_menu.add_item("Focus Selection", 4, KEY_F) + view_menu.id_pressed.connect(_on_view_menu_pressed) + _menu_bar.add_child(view_menu) + + # Model Menu + var model_menu := PopupMenu.new() + model_menu.name = "Model" + model_menu.add_item("Add Crate Prop", 0) + model_menu.add_item("Add Lamp Post", 1) + model_menu.add_item("Add Pine Tree", 2) + model_menu.add_item("Add ModBison", 3) + model_menu.id_pressed.connect(_on_model_menu_pressed) + _menu_bar.add_child(model_menu) + + # Test Menu + var test_menu := PopupMenu.new() + test_menu.name = "Test" + test_menu.add_item("Play Solo", 0, KEY_F5) + test_menu.add_item("Run Simulation", 1, KEY_F6) + test_menu.add_item("Stop Playtest", 2, KEY_MASK_SHIFT | KEY_F5) + test_menu.id_pressed.connect(_on_test_menu_pressed) + _menu_bar.add_child(test_menu) + + # Publish Menu + var pub_menu := PopupMenu.new() + pub_menu.name = "Publish" + pub_menu.add_item("Export .mbworld Package", 0) + pub_menu.add_item("Sync to Game Server", 1) + pub_menu.id_pressed.connect(_on_publish_menu_pressed) + _menu_bar.add_child(pub_menu) + + parent.add_child(_menu_bar) + + +func _build_toolbar(parent: Control) -> void: + _toolbar = HBoxContainer.new() + _toolbar.custom_minimum_size = Vector2(0, 36) + _toolbar.add_theme_constant_override("separation", 6) + + _btn_select = _add_tool_btn("โ†– Select", func(): _set_gizmo_mode(TransformGizmo3DClass.Mode.NONE)) + _btn_move = _add_tool_btn("โœ› Move", func(): _set_gizmo_mode(TransformGizmo3DClass.Mode.TRANSLATE)) + _btn_rotate = _add_tool_btn("โ†ป Rotate", func(): _set_gizmo_mode(TransformGizmo3DClass.Mode.ROTATE)) + _btn_scale = _add_tool_btn("โคข Scale", func(): _set_gizmo_mode(TransformGizmo3DClass.Mode.SCALE)) + + var vsep := VSeparator.new() + _toolbar.add_child(vsep) + + var snap_lbl := Label.new() + snap_lbl.text = "Snap:" + snap_lbl.add_theme_font_size_override("font_size", 10) + _toolbar.add_child(snap_lbl) + + _snap_spin = SpinBox.new() + _snap_spin.min_value = 0.1 + _snap_spin.max_value = 10.0 + _snap_spin.step = 0.5 + _snap_spin.value = 1.0 + _snap_spin.value_changed.connect(func(v): + if _gizmo_3d != null: + _gizmo_3d.snap_translate = v + ) + _toolbar.add_child(_snap_spin) + + var spacer := Control.new() + spacer.size_flags_horizontal = Control.SIZE_EXPAND_FILL + _toolbar.add_child(spacer) + + _btn_play = Button.new() + _btn_play.text = "โ–ถ Play (F5)" + _btn_play.add_theme_color_override("font_color", Color(0.3, 0.9, 0.4)) + _btn_play.pressed.connect(func(): _simulation_mgr.start_play(true)) + _toolbar.add_child(_btn_play) + + _btn_stop = Button.new() + _btn_stop.text = "โน Stop" + _btn_stop.add_theme_color_override("font_color", Color(1.0, 0.4, 0.4)) + _btn_stop.disabled = true + _btn_stop.pressed.connect(func(): _simulation_mgr.stop_simulation()) + _toolbar.add_child(_btn_stop) + + parent.add_child(_toolbar) + + +func _add_tool_btn(text: String, callback: Callable) -> Button: + var btn := Button.new() + btn.text = text + btn.pressed.connect(callback) + _toolbar.add_child(btn) + return btn + + +func _setup_3d_viewport_scene(vp: SubViewport) -> void: + # World Environment + var env := WorldEnvironment.new() + var environment := Environment.new() + environment.background_mode = Environment.BG_COLOR + environment.background_color = Color(0.18, 0.22, 0.28) + environment.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR + environment.ambient_light_color = Color(0.6, 0.6, 0.65) + env.environment = environment + vp.add_child(env) + + # Directional Sun Light + var sun := DirectionalLight3D.new() + sun.rotation_degrees = Vector3(-45, 30, 0) + sun.shadow_enabled = true + vp.add_child(sun) + + # 3D Grid floor + var grid := _create_3d_grid(32, 1.0) + vp.add_child(grid) + + # Camera + _camera_3d = StudioViewportCameraClass.new() + vp.add_child(_camera_3d) + + # World Object Container + _world_container = Node3D.new() + _world_container.name = "WorldContainer" + vp.add_child(_world_container) + + # Interactive Gizmo + _gizmo_3d = TransformGizmo3DClass.new() + _gizmo_3d.name = "TransformGizmo3D" + vp.add_child(_gizmo_3d) + + core.selection.selection_changed.connect(_on_selection_changed) + + +func _create_3d_grid(size: int, step: float) -> Node3D: + var mi := MeshInstance3D.new() + var imm := ImmediateMesh.new() + var mat := StandardMaterial3D.new() + mat.albedo_color = Color(0.35, 0.4, 0.45, 0.5) + mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED + + imm.surface_begin(Mesh.PRIMITIVE_LINES, mat) + var half := (size * step) / 2.0 + for i in range(-size / 2, size / 2 + 1): + var p := i * step + imm.surface_add_vertex(Vector3(p, 0, -half)) + imm.surface_add_vertex(Vector3(p, 0, half)) + imm.surface_add_vertex(Vector3(-half, 0, p)) + imm.surface_add_vertex(Vector3(half, 0, p)) + imm.surface_end() + mi.mesh = imm + return mi + + +func _setup_shortcuts() -> void: + set_process_unhandled_key_input(true) + + +func _unhandled_key_input(event: InputEvent) -> void: + var k := event as InputEventKey + if not k.pressed or k.echo: + return + + if k.is_command_or_control_pressed(): + if k.keycode == KEY_Z: + core.undo() + _refresh_scene_visuals() + elif k.keycode == KEY_Y: + core.redo() + _refresh_scene_visuals() + elif k.keycode == KEY_S: + save_current_project() + elif k.keycode == KEY_D: + _hierarchy_dock._on_duplicate_pressed() + elif k.keycode == KEY_F5: + if _simulation_mgr.is_playing(): + _simulation_mgr.stop_simulation() + else: + _simulation_mgr.start_play(true) + + +func _set_gizmo_mode(mode: TransformGizmo3DClass.Mode) -> void: + if _gizmo_3d != null: + _gizmo_3d.set_mode(mode) + _status_bar_label.text = "Gizmo Mode: %s" % str(mode) + + +func _on_selection_changed(obj_id: String) -> void: + if obj_id == "": + _gizmo_3d.attach_to_node(null) + return + + var obj_node = _world_container.get_node_or_null(obj_id) + if obj_node is Node3D: + _gizmo_3d.attach_to_node(obj_node) + _camera_3d.focus_on(obj_node.global_position) + + +func _on_collection_model_selected(model_data: Dictionary) -> void: + var cat_id := str(model_data.get("catalog_id", "crate_small")) + var new_pos := Vector3(0, 0, 0) + var new_id := core.place_object(cat_id, new_pos, Vector4(0, 0, 0, 1), Vector3.ONE, { + "display_name": model_data.get("name", cat_id), + "catalog_id": cat_id, + "asset_id": model_data.get("asset_id", "") + }) + if new_id != "": + core.select_object(new_id) + _spawn_visual_node(new_id) + _hierarchy_dock.refresh_tree() + + +func _spawn_visual_node(obj_id: String) -> void: + var obj = core.model.find_object(obj_id) + if obj == null: + return + var wo := WorldObjectClass.new() + wo.id = obj.id + wo.type = obj.type + wo.asset_id = obj.asset_id + wo.position = obj.position + wo.properties = obj.properties.duplicate(true) + + var node = StaticObjectNodeClass.new() + node.name = obj.id + node.world_object = wo + node.position = Vector3(obj.position.x, obj.position.z, obj.position.y) + _world_container.add_child(node) + + +func _refresh_scene_visuals() -> void: + for c in _world_container.get_children(): + c.queue_free() + for obj in core.model.payload.objects: + _spawn_visual_node(obj.id) + _hierarchy_dock.refresh_tree() + + +func load_template(template_name: String) -> void: + var data := MBWorldProjectIOClass.create_template(template_name) + project_title = data.get("title", "Untitled World") + for obj in data.get("objects", []): + var p: Array = obj.get("position", [0, 0, 0]) + var r: Array = obj.get("rotation", [0, 0, 0, 1]) + var s: Array = obj.get("scale", [1, 1, 1]) + core.place_object( + obj.get("catalog_id", "crate_small"), + Vector3(p[0], p[1], p[2]), + Vector4(r[0], r[1], r[2], r[3]), + Vector3(s[0], s[1], s[2]), + obj.get("properties", {}) + ) + _refresh_scene_visuals() + + +func save_current_project(path: String = "") -> bool: + if path == "": + path = "user://saved_world.mbworld" + var data := { + "title": project_title, + "objects": [] + } + for obj in core.model.payload.objects: + data["objects"].append({ + "id": obj.id, + "catalog_id": obj.properties.get("catalog_id", "crate_small"), + "asset_id": obj.asset_id, + "position": [obj.position.x, obj.position.y, obj.position.z], + "rotation": [obj.rotation.x, obj.rotation.y, obj.rotation.z, obj.rotation.w], + "scale": [obj.scale.x, obj.scale.y, obj.scale.z], + "properties": obj.properties + }) + var ok := MBWorldProjectIOClass.save_project(path, data) + if ok: + _status_bar_label.text = "Saved world project to %s" % path + return ok + + +func _update_simulation_ui(playing: bool) -> void: + _btn_play.disabled = playing + _btn_stop.disabled = not playing + _status_bar_label.text = "Simulation: PLAYING (F5 to Stop)" if playing else "Simulation: STOPPED (Edit mode)" + + +func _on_file_menu_pressed(id: int) -> void: + match id: + 0: load_template("baseplate") + 2: save_current_project() + 5: get_tree().quit(0) + + +func _on_edit_menu_pressed(id: int) -> void: + match id: + 0: core.undo(); _refresh_scene_visuals() + 1: core.redo(); _refresh_scene_visuals() + 2: _hierarchy_dock._on_duplicate_pressed() + 3: _hierarchy_dock._on_delete_pressed() + + +func _on_view_menu_pressed(id: int) -> void: + match id: + 0: _camera_3d.set_view_preset(StudioViewportCameraClass.ViewPreset.PERSPECTIVE) + 1: _camera_3d.set_view_preset(StudioViewportCameraClass.ViewPreset.TOP) + 2: _camera_3d.set_view_preset(StudioViewportCameraClass.ViewPreset.FRONT) + 3: _camera_3d.set_view_preset(StudioViewportCameraClass.ViewPreset.SIDE) + + +func _on_model_menu_pressed(id: int) -> void: + var cat_ids := ["crate_small", "lamp_post", "tree_pine", "bison"] + if id >= 0 and id < cat_ids.size(): + _on_collection_model_selected({"catalog_id": cat_ids[id], "name": cat_ids[id].capitalize()}) + + +func _on_test_menu_pressed(id: int) -> void: + match id: + 0: _simulation_mgr.start_play(true) + 1: _simulation_mgr.start_play(false) + 2: _simulation_mgr.stop_simulation() + + +func _on_publish_menu_pressed(id: int) -> void: + if id == 0: + save_current_project("user://exported_world.mbworld") + + +func _parse_cmdline_arguments() -> void: + var args := OS.get_cmdline_args() + for i in range(args.size()): + var arg := args[i] + if arg == "--template" and i + 1 < args.size(): + load_template(args[i + 1]) + elif arg.ends_with(".mbworld") and FileAccess.file_exists(arg): + var d := MBWorldProjectIOClass.load_project(arg) + if not d.is_empty(): + project_title = d.get("title", "Loaded World") diff --git a/scripts/studio/studio_simulation_manager.gd b/scripts/studio/studio_simulation_manager.gd new file mode 100644 index 0000000..db43501 --- /dev/null +++ b/scripts/studio/studio_simulation_manager.gd @@ -0,0 +1,140 @@ +class_name StudioSimulationManager +extends Node +## Manages Play / Run / Stop simulation harness for ModBison Studio. +## Takes full state snapshot before play and restores on stop. + +signal simulation_started(mode: String) +signal simulation_stopped + +enum SimulationState { EDIT, PLAYING, PAUSED } + +var state: SimulationState = SimulationState.EDIT +var _snapshot: Array[Dictionary] = [] +var _spawned_avatar: Node = null +var _core: EditorCore = null +var _world_container: Node3D = null + + +func setup(core: EditorCore, world_container: Node3D = null) -> void: + _core = core + _world_container = world_container + + +func start_play(spawn_player: bool = true) -> void: + if state == SimulationState.PLAYING: + return + + # 1. Capture snapshot of all objects + _capture_snapshot() + + state = SimulationState.PLAYING + + # 2. Spawn test avatar if requested + if spawn_player and _world_container != null: + _spawn_test_avatar() + + # 3. Activate moving objects + for m in _get_moving_nodes(): + if m.has_method("start_motion"): + m.start_motion() + + simulation_started.emit("play" if spawn_player else "run") + + +func pause_simulation() -> void: + if state != SimulationState.PLAYING: + return + state = SimulationState.PAUSED + for m in _get_moving_nodes(): + if m.has_method("stop_motion"): + m.stop_motion() + + +func resume_simulation() -> void: + if state != SimulationState.PAUSED: + return + state = SimulationState.PLAYING + for m in _get_moving_nodes(): + if m.has_method("start_motion"): + m.start_motion() + + +func stop_simulation() -> void: + if state == SimulationState.EDIT: + return + + # 1. Stop all moving objects + for m in _get_moving_nodes(): + if m.has_method("stop_motion"): + m.stop_motion() + + # 2. Despawn test avatar + if _spawned_avatar != null and is_instance_valid(_spawned_avatar): + _spawned_avatar.queue_free() + _spawned_avatar = null + + # 3. Restore state snapshot + _restore_snapshot() + + state = SimulationState.EDIT + simulation_stopped.emit() + + +func _get_moving_nodes() -> Array: + if is_inside_tree() and get_tree() != null: + return get_tree().get_nodes_in_group("moving_objects") + var main_loop = Engine.get_main_loop() + if main_loop is SceneTree: + return (main_loop as SceneTree).get_nodes_in_group("moving_objects") + return [] + + +func _capture_snapshot() -> void: + _snapshot.clear() + if _core == null or _core.model == null: + return + for obj in _core.model.payload.objects: + _snapshot.append({ + "id": obj.id, + "position": obj.position, + "rotation": obj.rotation, + "scale": obj.scale, + "properties": obj.properties.duplicate(true) + }) + + +func _restore_snapshot() -> void: + if _core == null or _core.model == null: + return + for snap in _snapshot: + var obj = _core.model.find_object(snap["id"]) + if obj != null: + obj.position = snap["position"] + obj.rotation = snap["rotation"] + obj.scale = snap["scale"] + obj.properties = snap["properties"].duplicate(true) + _snapshot.clear() + + +func _spawn_test_avatar() -> void: + var player_scene = load("res://scenes/player.tscn") + if player_scene != null: + var p = player_scene.instantiate() + p.name = "TestStudioPlayer" + p.position = Vector3(0, 2, 0) + _world_container.add_child(p) + _spawned_avatar = p + else: + var p: CharacterBody3D = CharacterBody3D.new() + p.name = "TestStudioPlayer" + p.position = Vector3(0, 2, 0) + _world_container.add_child(p) + _spawned_avatar = p + + +func is_playing() -> bool: + return state == SimulationState.PLAYING + + +func is_editing() -> bool: + return state == SimulationState.EDIT diff --git a/scripts/studio/studio_start_screen.gd b/scripts/studio/studio_start_screen.gd new file mode 100644 index 0000000..23f965d --- /dev/null +++ b/scripts/studio/studio_start_screen.gd @@ -0,0 +1,152 @@ +class_name StudioStartScreen +extends Control +## Studio Start Screen offering starter templates and recent projects browser. + +signal template_selected(template_name: String) +signal project_opened(path: String) + +const MBWorldProjectIOClass = preload("res://scripts/studio/mbworld_project_io.gd") + +var _recent_list: ItemList +var _templates_container: HBoxContainer +var _btn_open_file: Button +var _file_dialog: FileDialog + + +func _ready() -> void: + _build_ui() + _populate_recent_projects() + + +func _build_ui() -> void: + set_anchors_preset(Control.PRESET_FULL_RECT) + + var bg := ColorRect.new() + bg.set_anchors_preset(Control.PRESET_FULL_RECT) + bg.color = Color(0.12, 0.13, 0.16) + add_child(bg) + + var margin := MarginContainer.new() + margin.set_anchors_preset(Control.PRESET_FULL_RECT) + margin.add_theme_constant_override("margin_left", 60) + margin.add_theme_constant_override("margin_top", 40) + margin.add_theme_constant_override("margin_right", 60) + margin.add_theme_constant_override("margin_bottom", 40) + add_child(margin) + + var root_vbox := VBoxContainer.new() + root_vbox.add_theme_constant_override("separation", 24) + margin.add_child(root_vbox) + + # Header + var title_row := HBoxContainer.new() + var title := Label.new() + title.text = "๐Ÿฆฌ ModBison Studio" + title.add_theme_font_size_override("font_size", 28) + title.add_theme_color_override("font_color", Color(1.0, 0.9, 0.3)) + title_row.add_child(title) + + var spacer := Control.new() + spacer.size_flags_horizontal = Control.SIZE_EXPAND_FILL + title_row.add_child(spacer) + + _btn_open_file = Button.new() + _btn_open_file.text = "๐Ÿ“‚ Open from Disk..." + _btn_open_file.pressed.connect(_on_open_file_pressed) + title_row.add_child(_btn_open_file) + + root_vbox.add_child(title_row) + + # Starter Templates Section + var tmpl_lbl := Label.new() + tmpl_lbl.text = "Starter Templates" + tmpl_lbl.add_theme_font_size_override("font_size", 16) + tmpl_lbl.add_theme_color_override("font_color", Color(0.3, 0.85, 1.0)) + root_vbox.add_child(tmpl_lbl) + + _templates_container = HBoxContainer.new() + _templates_container.add_theme_constant_override("separation", 16) + root_vbox.add_child(_templates_container) + + _create_template_card("Baseplate", "Clean empty 32x32 world grid ready for custom building.", "baseplate") + _create_template_card("Rolling Hills", "Natural outdoor terrain with pine trees and fauna.", "terrain") + _create_template_card("Obby Parkour", "Action platformer obstacle course with moving elevators.", "obby") + + # Recent Projects Section + var rec_lbl := Label.new() + rec_lbl.text = "Recent Worlds" + rec_lbl.add_theme_font_size_override("font_size", 16) + rec_lbl.add_theme_color_override("font_color", Color(0.4, 0.9, 0.5)) + root_vbox.add_child(rec_lbl) + + _recent_list = ItemList.new() + _recent_list.size_flags_vertical = Control.SIZE_EXPAND_FILL + _recent_list.custom_minimum_size = Vector2(0, 160) + _recent_list.item_activated.connect(_on_recent_item_activated) + root_vbox.add_child(_recent_list) + + # File Dialog + _file_dialog = FileDialog.new() + _file_dialog.file_mode = FileDialog.FILE_MODE_OPEN_FILE + _file_dialog.access = FileDialog.ACCESS_FILESYSTEM + _file_dialog.filters = PackedStringArray(["*.mbworld ; ModBison World Files"]) + _file_dialog.file_selected.connect(_on_file_dialog_selected) + add_child(_file_dialog) + + +func _create_template_card(title: String, desc: String, template_id: String) -> void: + var panel := PanelContainer.new() + panel.custom_minimum_size = Vector2(220, 120) + panel.size_flags_horizontal = Control.SIZE_EXPAND_FILL + + var vbox := VBoxContainer.new() + vbox.add_theme_constant_override("separation", 8) + panel.add_child(vbox) + + var t_lbl := Label.new() + t_lbl.text = title + t_lbl.add_theme_font_size_override("font_size", 14) + t_lbl.add_theme_color_override("font_color", Color(1.0, 0.95, 0.4)) + vbox.add_child(t_lbl) + + var d_lbl := Label.new() + d_lbl.text = desc + d_lbl.add_theme_font_size_override("font_size", 10) + d_lbl.add_theme_color_override("font_color", Color(0.7, 0.7, 0.7)) + d_lbl.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + d_lbl.size_flags_vertical = Control.SIZE_EXPAND_FILL + vbox.add_child(d_lbl) + + var btn := Button.new() + btn.text = "Create World" + btn.pressed.connect(func(): template_selected.emit(template_id)) + vbox.add_child(btn) + + _templates_container.add_child(panel) + + +func _populate_recent_projects() -> void: + _recent_list.clear() + var recents: Array = MBWorldProjectIOClass.get_recent_projects() + if recents.is_empty(): + _recent_list.add_item("(No recent projects)") + _recent_list.set_item_disabled(0, true) + else: + for path in recents: + var fname = str(path).get_file() + _recent_list.add_item("๐Ÿ“„ %s (%s)" % [fname, path]) + + +func _on_recent_item_activated(index: int) -> void: + var recents: Array = MBWorldProjectIOClass.get_recent_projects() + if index >= 0 and index < recents.size(): + var path: String = str(recents[index]) + project_opened.emit(path) + + +func _on_open_file_pressed() -> void: + _file_dialog.popup_centered(Vector2i(700, 450)) + + +func _on_file_dialog_selected(path: String) -> void: + project_opened.emit(path) diff --git a/scripts/studio/studio_viewport_camera.gd b/scripts/studio/studio_viewport_camera.gd new file mode 100644 index 0000000..2ffcd00 --- /dev/null +++ b/scripts/studio/studio_viewport_camera.gd @@ -0,0 +1,173 @@ +class_name StudioViewportCamera +extends Camera3D +## Professional 3D Studio Camera controller supporting: +## - Freecam flying (WASD + Q/E) +## - Turntable Orbit & Panning (RMB/MMB drag) +## - Selection framing/focus (F key) +## - Orthographic view presets (Top, Front, Side, Perspective) + +signal view_preset_changed(preset_name: String) + +enum ViewPreset { PERSPECTIVE, TOP, FRONT, SIDE } + +var current_preset: ViewPreset = ViewPreset.PERSPECTIVE + +# Camera movement parameters +var move_speed: float = 12.0 +var fast_multiplier: float = 2.5 +var slow_multiplier: float = 0.3 +var mouse_sensitivity: float = 0.003 +var zoom_speed: float = 1.5 + +# Orbit / focus state +var focal_point: Vector3 = Vector3.ZERO +var orbit_distance: float = 15.0 +var _yaw: float = -0.75 +var _pitch: float = -0.5 + +var _rmb_down: bool = false +var _mmb_down: bool = false +var _move_input: Vector3 = Vector3.ZERO + + +func _ready() -> void: + current = true + _update_orbit_transform() + + +func _unhandled_input(event: InputEvent) -> void: + if event is InputEventMouseButton: + var mb := event as InputEventMouseButton + if mb.button_index == MOUSE_BUTTON_RIGHT: + _rmb_down = mb.pressed + elif mb.button_index == MOUSE_BUTTON_MIDDLE: + _mmb_down = mb.pressed + elif mb.button_index == MOUSE_BUTTON_WHEEL_UP: + zoom_camera(-zoom_speed) + elif mb.button_index == MOUSE_BUTTON_WHEEL_DOWN: + zoom_camera(zoom_speed) + + elif event is InputEventMouseMotion: + var mm := event as InputEventMouseMotion + if _rmb_down: + # Freecam / FPS mouse look + _yaw -= mm.relative.x * mouse_sensitivity + _pitch = clampf(_pitch - mm.relative.y * mouse_sensitivity, -PI * 0.49, PI * 0.49) + rotation = Vector3(_pitch, _yaw, 0.0) + elif _mmb_down: + # Pan camera parallel to viewport + var pan_speed := orbit_distance * 0.0015 + var right := global_transform.basis.x + var up := global_transform.basis.y + var delta := (-right * mm.relative.x + up * mm.relative.y) * pan_speed + global_position += delta + focal_point += delta + + elif event is InputEventKey: + var k := event as InputEventKey + if k.pressed and not k.echo: + if k.keycode == KEY_F: + focus_on(focal_point, 5.0) + elif k.keycode == KEY_1: + set_view_preset(ViewPreset.FRONT) + elif k.keycode == KEY_3: + set_view_preset(ViewPreset.SIDE) + elif k.keycode == KEY_7: + set_view_preset(ViewPreset.TOP) + elif k.keycode == KEY_5: + toggle_perspective_ortho() + + +func _process(delta: float) -> void: + if _rmb_down: + _process_keyboard_flight(delta) + + +func _process_keyboard_flight(delta: float) -> void: + var dir := Vector3.ZERO + if Input.is_physical_key_pressed(KEY_W): + dir -= global_transform.basis.z + if Input.is_physical_key_pressed(KEY_S): + dir += global_transform.basis.z + if Input.is_physical_key_pressed(KEY_A): + dir -= global_transform.basis.x + if Input.is_physical_key_pressed(KEY_D): + dir += global_transform.basis.x + if Input.is_physical_key_pressed(KEY_E): + dir += Vector3.UP + if Input.is_physical_key_pressed(KEY_Q): + dir -= Vector3.UP + + if dir.length_squared() > 0.01: + var speed := move_speed + if Input.is_physical_key_pressed(KEY_SHIFT): + speed *= fast_multiplier + elif Input.is_physical_key_pressed(KEY_CTRL): + speed *= slow_multiplier + global_position += dir.normalized() * speed * delta + focal_point = global_position - global_transform.basis.z * orbit_distance + + +func zoom_camera(amount: float) -> void: + orbit_distance = clampf(orbit_distance + amount, 1.0, 300.0) + if projection == PROJECTION_ORTHOGONAL: + size = clampf(size + amount, 1.0, 200.0) + else: + _update_orbit_transform() + + +func focus_on(target_pos: Vector3, radius: float = 3.0) -> void: + focal_point = target_pos + orbit_distance = clampf(radius * 2.5, 3.0, 50.0) + _update_orbit_transform() + + +func _update_orbit_transform() -> void: + var rot_basis := Basis.from_euler(Vector3(_pitch, _yaw, 0.0)) + var offset := rot_basis * Vector3(0.0, 0.0, orbit_distance) + _set_camera_pos(focal_point + offset) + rotation = Vector3(_pitch, _yaw, 0.0) + + +func _set_camera_pos(pos: Vector3) -> void: + if is_inside_tree(): + global_position = pos + else: + position = pos + + +func set_view_preset(preset: ViewPreset) -> void: + current_preset = preset + match preset: + ViewPreset.TOP: + projection = PROJECTION_ORTHOGONAL + size = orbit_distance + rotation_degrees = Vector3(-90, 0, 0) + _set_camera_pos(focal_point + Vector3(0, orbit_distance, 0)) + view_preset_changed.emit("Top") + ViewPreset.FRONT: + projection = PROJECTION_ORTHOGONAL + size = orbit_distance + rotation_degrees = Vector3(0, 0, 0) + _set_camera_pos(focal_point + Vector3(0, 0, orbit_distance)) + view_preset_changed.emit("Front") + ViewPreset.SIDE: + projection = PROJECTION_ORTHOGONAL + size = orbit_distance + rotation_degrees = Vector3(0, 90, 0) + _set_camera_pos(focal_point + Vector3(orbit_distance, 0, 0)) + view_preset_changed.emit("Side") + ViewPreset.PERSPECTIVE: + projection = PROJECTION_PERSPECTIVE + _pitch = -0.5 + _yaw = -0.75 + _update_orbit_transform() + view_preset_changed.emit("Perspective") + + +func toggle_perspective_ortho() -> void: + if projection == PROJECTION_PERSPECTIVE: + projection = PROJECTION_ORTHOGONAL + size = orbit_distance + else: + projection = PROJECTION_PERSPECTIVE diff --git a/scripts/world_object.gd b/scripts/world_object.gd index 8895fff..7fec232 100644 --- a/scripts/world_object.gd +++ b/scripts/world_object.gd @@ -27,6 +27,9 @@ var scale: Vector3 = Vector3.ONE ## Type-specific key/value properties. var properties: Dictionary = {} +## Parent object ID when stacked on top of or attached to another object. +var parent_id: String = "" + ## Whether this is a spawnable dynamic entity (true) or static (false). var is_dynamic: bool = false @@ -55,6 +58,7 @@ func _init( rotation = p_rotation scale = p_scale properties = p_properties + parent_id = str(properties.get("parent_id", "")) is_dynamic = p_dynamic if p_flags != null: flags = p_flags @@ -93,7 +97,7 @@ func get_display_name() -> String: ## Serialize this object to a dictionary (for networking). func to_dict() -> Dictionary: - return { + var d := { "id": id, "type": type, "asset_id": asset_id, @@ -103,6 +107,9 @@ func to_dict() -> Dictionary: "properties": properties, "dynamic": is_dynamic, } + if parent_id != "": + d["parent_id"] = parent_id + return d ## Create a WorldObject from a dictionary (for networking). @@ -123,6 +130,9 @@ static func from_dict(d: Dictionary) -> WorldObject: d.get("scale", [1, 1, 1])[1], d.get("scale", [1, 1, 1])[2] ) + var props: Dictionary = d.get("properties", {}).duplicate() + if d.has("parent_id") and not props.has("parent_id"): + props["parent_id"] = d["parent_id"] return WorldObject.new( d.get("id", ""), d.get("type", ""), @@ -130,6 +140,6 @@ static func from_dict(d: Dictionary) -> WorldObject: pos, rot, scl, - d.get("properties", {}), + props, d.get("dynamic", false), )