feat(studio,stacking,animations,collections): M16 ModBison Studio, object stacking, model animations, and poly collections for MVP 2
checkpoint / checkpoint (push) Successful in 6m32s
checkpoint / checkpoint (push) Successful in 6m32s
This commit is contained in:
@@ -12,3 +12,7 @@ Thumbs.db
|
|||||||
# Godot UID files (regenerated from path)
|
# Godot UID files (regenerated from path)
|
||||||
*.uid
|
*.uid
|
||||||
assets/build_info.json
|
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
@@ -7,7 +7,7 @@ from fastapi.middleware.cors import CORSMiddleware
|
|||||||
|
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.routers import (
|
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,
|
presence, groups, social_friends, profiles, chat_dm, chat_reports,
|
||||||
chat_world, wallet, payments, marketplace, payouts, discovery,
|
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(worlds.router, prefix="/api/worlds", tags=["worlds"])
|
||||||
app.include_router(world_content.router, prefix="/api/worlds", tags=["world-content"])
|
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(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(avatar.router, prefix="/api/avatar", tags=["avatar"])
|
||||||
app.include_router(internal.router, prefix="/api/internal", tags=["internal"])
|
app.include_router(internal.router, prefix="/api/internal", tags=["internal"])
|
||||||
app.include_router(presence.router, prefix="/api/presence", tags=["presence"])
|
app.include_router(presence.router, prefix="/api/presence", tags=["presence"])
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ from app.models.user import User
|
|||||||
from app.models.session import Session
|
from app.models.session import Session
|
||||||
from app.models.world import World
|
from app.models.world import World
|
||||||
from app.models.asset import Asset
|
from app.models.asset import Asset
|
||||||
|
from app.models.asset_collection import AssetCollection, CollectionAsset
|
||||||
from app.models.avatar_loadout import AvatarLoadout
|
from app.models.avatar_loadout import AvatarLoadout
|
||||||
from app.models.world_content import WorldRoom, WorldChunk, WorldChunkRevision
|
from app.models.world_content import WorldRoom, WorldChunk, WorldChunkRevision
|
||||||
from app.models.social import Friendship, Follow, Block, FriendshipStatus
|
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
|
from app.models.world_visit import WorldVisit
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"User", "Session", "World", "Asset", "AvatarLoadout",
|
"User", "Session", "World", "Asset", "AssetCollection", "CollectionAsset", "AvatarLoadout",
|
||||||
"WorldRoom", "WorldChunk", "WorldChunkRevision",
|
"WorldRoom", "WorldChunk", "WorldChunkRevision",
|
||||||
"Friendship", "Follow", "Block", "FriendshipStatus",
|
"Friendship", "Follow", "Block", "FriendshipStatus",
|
||||||
"Conversation", "ConversationParticipant", "Message", "MessageReport", "ReportStatus",
|
"Conversation", "ConversationParticipant", "Message", "MessageReport", "ReportStatus",
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -28,6 +28,7 @@ class User(Base):
|
|||||||
refresh_tokens = relationship("RefreshToken", back_populates="user", cascade="all, delete-orphan")
|
refresh_tokens = relationship("RefreshToken", back_populates="user", cascade="all, delete-orphan")
|
||||||
worlds = relationship("World", back_populates="owner")
|
worlds = relationship("World", back_populates="owner")
|
||||||
assets = relationship("Asset", back_populates="owner")
|
assets = relationship("Asset", back_populates="owner")
|
||||||
|
asset_collections = relationship("AssetCollection", back_populates="owner", cascade="all, delete-orphan")
|
||||||
avatar_loadout = relationship(
|
avatar_loadout = relationship(
|
||||||
"AvatarLoadout",
|
"AvatarLoadout",
|
||||||
back_populates="user",
|
back_populates="user",
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -3,7 +3,8 @@
|
|||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
from pathlib import Path
|
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 import APIRouter, Depends, HTTPException, UploadFile, File, Query
|
||||||
from fastapi.responses import FileResponse
|
from fastapi.responses import FileResponse
|
||||||
@@ -43,8 +44,8 @@ class AssetResponse(BaseModel):
|
|||||||
world_id: Optional[int]
|
world_id: Optional[int]
|
||||||
owner_id: int
|
owner_id: int
|
||||||
is_public: bool
|
is_public: bool
|
||||||
created_at: str
|
created_at: Union[datetime, str]
|
||||||
updated_at: str
|
updated_at: Union[datetime, str]
|
||||||
|
|
||||||
model_config = {"from_attributes": True}
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|||||||
@@ -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"]
|
||||||
@@ -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")
|
||||||
@@ -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")
|
||||||
@@ -130,6 +130,15 @@ class WorldObjectData:
|
|||||||
var dynamic: bool = false
|
var dynamic: bool = false
|
||||||
var flags: ContentFlags = ContentFlags.new()
|
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.
|
## The complete decoded chunk payload.
|
||||||
class ChunkPayload:
|
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)
|
||||||
@@ -36,6 +36,8 @@ var selection: SelectionManager
|
|||||||
var tools: ToolRegistry
|
var tools: ToolRegistry
|
||||||
var catalog: EditorObjectCatalog = null
|
var catalog: EditorObjectCatalog = null
|
||||||
|
|
||||||
|
const PlacementStackManagerClass = preload("res://scripts/editor/placement_stack_manager.gd")
|
||||||
|
|
||||||
|
|
||||||
func _init(p_model: ChunkEditorModel, p_catalog: EditorObjectCatalog = null) -> void:
|
func _init(p_model: ChunkEditorModel, p_catalog: EditorObjectCatalog = null) -> void:
|
||||||
model = p_model
|
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,
|
func place_object(catalog_id: String, position: Vector3,
|
||||||
rotation: Vector4 = Vector4(0, 0, 0, 1), scale: Vector3 = Vector3.ONE) -> String:
|
rotation: Vector4 = Vector4(0, 0, 0, 1), scale: Vector3 = Vector3.ONE,
|
||||||
var result: Variant = use_tool("object_place", {
|
properties: Dictionary = {}) -> String:
|
||||||
|
var args: Dictionary = {
|
||||||
"catalog_id": catalog_id,
|
"catalog_id": catalog_id,
|
||||||
"position": position,
|
"position": position,
|
||||||
"rotation": rotation,
|
"rotation": rotation,
|
||||||
"scale": scale,
|
"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 ""
|
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:
|
func delete_object(object_id: String) -> bool:
|
||||||
return bool(use_tool("erase", {"object_id": object_id}))
|
return bool(use_tool("erase", {"object_id": object_id}))
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -27,8 +27,28 @@ func apply(core: EditorCore, args: Dictionary) -> Variant:
|
|||||||
|
|
||||||
match op:
|
match op:
|
||||||
"move":
|
"move":
|
||||||
return _apply_vector3(core, object_id, args.get("value", obj.position),
|
var new_val: Vector3 = args.get("value", obj.position)
|
||||||
obj.position, "Move", core.model.set_object_position_raw)
|
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":
|
"rotate":
|
||||||
return _apply_vector4(core, object_id, args.get("value", obj.rotation),
|
return _apply_vector4(core, object_id, args.get("value", obj.rotation),
|
||||||
obj.rotation, "Rotate", core.model.set_object_rotation_raw)
|
obj.rotation, "Rotate", core.model.set_object_rotation_raw)
|
||||||
|
|||||||
@@ -25,6 +25,10 @@ var cell_size: float = 24.0
|
|||||||
var pending_catalog_id: String = ""
|
var pending_catalog_id: String = ""
|
||||||
## Tile id the next "brush" click paints. Cycled with number keys 0-9.
|
## Tile id the next "brush" click paints. Cycled with number keys 0-9.
|
||||||
var current_tile_id: int = 1
|
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_tx: int = -1
|
||||||
var _hover_ty: int = -1
|
var _hover_ty: int = -1
|
||||||
@@ -119,6 +123,32 @@ func _draw() -> void:
|
|||||||
display_name = obj.id
|
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_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:
|
func _gui_input(event: InputEvent) -> void:
|
||||||
if core == null:
|
if core == null:
|
||||||
@@ -155,6 +185,57 @@ func _gui_input(event: InputEvent) -> void:
|
|||||||
func _handle_key(key: InputEventKey) -> void:
|
func _handle_key(key: InputEventKey) -> void:
|
||||||
if key.keycode >= KEY_0 and key.keycode <= KEY_9:
|
if key.keycode >= KEY_0 and key.keycode <= KEY_9:
|
||||||
current_tile_id = key.keycode - KEY_0
|
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:
|
func _handle_click(local_pos: Vector2) -> void:
|
||||||
@@ -176,7 +257,22 @@ func _handle_click(local_pos: Vector2) -> void:
|
|||||||
queue_redraw()
|
queue_redraw()
|
||||||
"object_place":
|
"object_place":
|
||||||
if pending_catalog_id != "":
|
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 != "":
|
if new_id != "":
|
||||||
edit_applied.emit()
|
edit_applied.emit()
|
||||||
queue_redraw()
|
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:
|
if int(floor(obj.position.x)) == tx and int(floor(obj.position.y)) == ty:
|
||||||
return obj.id
|
return obj.id
|
||||||
return ""
|
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()
|
||||||
|
|||||||
@@ -35,6 +35,27 @@ var _scale_y: SpinBox
|
|||||||
var _scale_z: SpinBox
|
var _scale_z: SpinBox
|
||||||
var _delete_button: Button
|
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
|
# Behavior & Rules fields
|
||||||
var _rules_vbox: VBoxContainer
|
var _rules_vbox: VBoxContainer
|
||||||
var _add_rule_btn: Button
|
var _add_rule_btn: Button
|
||||||
@@ -140,9 +161,220 @@ func _build_ui() -> void:
|
|||||||
_delete_button.pressed.connect(_on_delete_pressed)
|
_delete_button.pressed.connect(_on_delete_pressed)
|
||||||
_fields_box.add_child(_delete_button)
|
_fields_box.add_child(_delete_button)
|
||||||
|
|
||||||
|
_setup_animation_and_motion_ui()
|
||||||
_setup_behavior_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:
|
func _setup_behavior_ui() -> void:
|
||||||
var sep := HSeparator.new()
|
var sep := HSeparator.new()
|
||||||
sep.custom_minimum_size = Vector2(0, 16)
|
sep.custom_minimum_size = Vector2(0, 16)
|
||||||
@@ -516,9 +748,141 @@ func show_object(object_id: String) -> void:
|
|||||||
_scale_z.value = obj.scale.z
|
_scale_z.value = obj.scale.z
|
||||||
_updating = false
|
_updating = false
|
||||||
|
|
||||||
|
_refresh_animation_and_motion()
|
||||||
_refresh_rules()
|
_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:
|
func _refresh_rules() -> void:
|
||||||
for child in _rules_vbox.get_children():
|
for child in _rules_vbox.get_children():
|
||||||
child.queue_free()
|
child.queue_free()
|
||||||
|
|||||||
@@ -102,6 +102,11 @@ static func _update_sound_button(btn: Button) -> void:
|
|||||||
|
|
||||||
|
|
||||||
func _ready() -> 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 v := Label.new()
|
||||||
var bl := _build_label()
|
var bl := _build_label()
|
||||||
v.text = bl if bl.begins_with("MVP Build") or bl == "dev" else ("build %s" % bl)
|
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")
|
_selling_button = $CenterContainer/VBoxContainer.get_node_or_null("SellingButton")
|
||||||
_quit_button = $CenterContainer/VBoxContainer/QuitButton
|
_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
|
# Default to navigation being gated. When a token exists, the async session
|
||||||
# check below may take several frames (a real get_me() round-trip);
|
# check below may take several frames (a real get_me() round-trip);
|
||||||
# keeping the button disabled until validation succeeds prevents a fast
|
# keeping the button disabled until validation succeeds prevents a fast
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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)
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -16,21 +16,136 @@ extends Node3D
|
|||||||
|
|
||||||
const ScriptBehaviorClass := preload("res://scripts/scripting/script_behavior.gd")
|
const ScriptBehaviorClass := preload("res://scripts/scripting/script_behavior.gd")
|
||||||
const ScriptInterpreterNodeClass := preload("res://scripts/scripting/script_interpreter.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.
|
## The world object data this node represents.
|
||||||
var world_object: WorldObject = null
|
var world_object: WorldObject = null
|
||||||
|
|
||||||
## The loaded mesh instance (or placeholder).
|
## The loaded mesh instance (or placeholder).
|
||||||
var _mesh_instance: MeshInstance3D = null
|
var _mesh_instance: MeshInstance3D = null
|
||||||
|
var _anim_player: AnimationPlayer = null
|
||||||
|
var _motion_comp: Node = null
|
||||||
|
|
||||||
|
|
||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
if world_object == null:
|
if world_object == null:
|
||||||
return
|
return
|
||||||
_load_mesh()
|
_load_mesh()
|
||||||
|
_setup_animation_and_motion()
|
||||||
_setup_behavior()
|
_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:
|
func _setup_behavior() -> void:
|
||||||
if world_object == null or not world_object.properties is Dictionary:
|
if world_object == null or not world_object.properties is Dictionary:
|
||||||
return
|
return
|
||||||
@@ -63,12 +178,43 @@ func _load_mesh() -> void:
|
|||||||
_mesh_instance = asset_node as MeshInstance3D
|
_mesh_instance = asset_node as MeshInstance3D
|
||||||
else:
|
else:
|
||||||
_mesh_instance = asset_node.find_child("*", true, false) as MeshInstance3D
|
_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
|
# 2. Build a stylized, recognizable 3D representation based on catalog ID / asset ID
|
||||||
var cat_id := _get_catalog_id()
|
var cat_id := _get_catalog_id()
|
||||||
_build_procedural_object(cat_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:
|
func _get_catalog_id() -> String:
|
||||||
if world_object == null:
|
if world_object == null:
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -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 []
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -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")
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -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
@@ -27,6 +27,9 @@ var scale: Vector3 = Vector3.ONE
|
|||||||
## Type-specific key/value properties.
|
## Type-specific key/value properties.
|
||||||
var properties: Dictionary = {}
|
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).
|
## Whether this is a spawnable dynamic entity (true) or static (false).
|
||||||
var is_dynamic: bool = false
|
var is_dynamic: bool = false
|
||||||
|
|
||||||
@@ -55,6 +58,7 @@ func _init(
|
|||||||
rotation = p_rotation
|
rotation = p_rotation
|
||||||
scale = p_scale
|
scale = p_scale
|
||||||
properties = p_properties
|
properties = p_properties
|
||||||
|
parent_id = str(properties.get("parent_id", ""))
|
||||||
is_dynamic = p_dynamic
|
is_dynamic = p_dynamic
|
||||||
if p_flags != null:
|
if p_flags != null:
|
||||||
flags = p_flags
|
flags = p_flags
|
||||||
@@ -93,7 +97,7 @@ func get_display_name() -> String:
|
|||||||
|
|
||||||
## Serialize this object to a dictionary (for networking).
|
## Serialize this object to a dictionary (for networking).
|
||||||
func to_dict() -> Dictionary:
|
func to_dict() -> Dictionary:
|
||||||
return {
|
var d := {
|
||||||
"id": id,
|
"id": id,
|
||||||
"type": type,
|
"type": type,
|
||||||
"asset_id": asset_id,
|
"asset_id": asset_id,
|
||||||
@@ -103,6 +107,9 @@ func to_dict() -> Dictionary:
|
|||||||
"properties": properties,
|
"properties": properties,
|
||||||
"dynamic": is_dynamic,
|
"dynamic": is_dynamic,
|
||||||
}
|
}
|
||||||
|
if parent_id != "":
|
||||||
|
d["parent_id"] = parent_id
|
||||||
|
return d
|
||||||
|
|
||||||
|
|
||||||
## Create a WorldObject from a dictionary (for networking).
|
## 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])[1],
|
||||||
d.get("scale", [1, 1, 1])[2]
|
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(
|
return WorldObject.new(
|
||||||
d.get("id", ""),
|
d.get("id", ""),
|
||||||
d.get("type", ""),
|
d.get("type", ""),
|
||||||
@@ -130,6 +140,6 @@ static func from_dict(d: Dictionary) -> WorldObject:
|
|||||||
pos,
|
pos,
|
||||||
rot,
|
rot,
|
||||||
scl,
|
scl,
|
||||||
d.get("properties", {}),
|
props,
|
||||||
d.get("dynamic", false),
|
d.get("dynamic", false),
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user