feat(studio,stacking,animations,collections): M16 ModBison Studio, object stacking, model animations, and poly collections for MVP 2
checkpoint / checkpoint (push) Successful in 6m32s

This commit is contained in:
2026-09-22 18:30:57 +00:00
parent 654c1ec6bf
commit 721dbe5ae7
37 changed files with 5202 additions and 17 deletions
+4
View File
@@ -12,3 +12,7 @@ Thumbs.db
# Godot UID files (regenerated from path)
*.uid
assets/build_info.json
# Dynamic uploads
uploads/
backend/uploads/
@@ -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")
+3 -1
View File
@@ -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"])
+2 -1
View File
@@ -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",
+53
View File
@@ -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")
+1
View File
@@ -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",
+458
View File
@@ -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)
+4 -3
View File
@@ -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}
+235
View File
@@ -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"]
+12
View File
@@ -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")
+12
View File
@@ -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")
+9
View File
@@ -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:
@@ -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)
+69 -3
View File
@@ -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}))
+183
View File
@@ -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
+22 -2
View File
@@ -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)
+147 -1
View File
@@ -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()
+364
View File
@@ -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()
+18
View File
@@ -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
+204
View File
@@ -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()
+243
View File
@@ -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()
+155
View File
@@ -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)
+137
View File
@@ -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()
+105
View File
@@ -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()
+127
View File
@@ -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()
+148 -2
View File
@@ -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,12 +178,43 @@ 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)
# 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:
if world_object == null:
+116
View File
@@ -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
+165
View File
@@ -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)
+164
View File
@@ -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 []
+268
View File
@@ -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
}
+181
View File
@@ -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)
+185
View File
@@ -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)
+535
View File
@@ -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")
+140
View File
@@ -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
+152
View File
@@ -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)
+173
View File
@@ -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
+12 -2
View File
@@ -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),
)