Complete M11 Creator Studio and M12 Trust & Safety for PC5
checkpoint / checkpoint (push) Failing after 3h14m9s
checkpoint / checkpoint (push) Failing after 3h14m9s
- Creator Studio: Visual behavior & property inspector, GLB model importer, binary persistence - Trust & Safety: 18+ TOS confirmation (DOB removed), creator guidelines & briefing card on join, in-game reporting HUD - Test Harness: Add --filter, --skip-backend, --skip-godot to run_checkpoint_tests.py
This commit is contained in:
@@ -0,0 +1,30 @@
|
||||
"""add content_guidelines column to worlds table
|
||||
|
||||
Revision ID: 0016_content_guidelines
|
||||
Revises: 0015_add_discovery_metadata
|
||||
Create Date: 2026-09-17 00:00:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "0016_content_guidelines"
|
||||
down_revision: Union[str, None] = "0015_add_discovery_metadata"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Check if column already exists (in case partial run added column before version update failed)
|
||||
bind = op.get_bind()
|
||||
insp = sa.inspect(bind)
|
||||
cols = [c["name"] for c in insp.get_columns("worlds")]
|
||||
if "content_guidelines" not in cols:
|
||||
op.add_column("worlds", sa.Column("content_guidelines", sa.Text(), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("worlds", "content_guidelines")
|
||||
@@ -31,11 +31,13 @@ class World(Base):
|
||||
)
|
||||
category = Column(String(100), nullable=True, index=True)
|
||||
tags = Column(ARRAY(String), nullable=True, server_default="{}")
|
||||
content_guidelines = Column(Text, nullable=True)
|
||||
play_count = Column(Integer, nullable=False, server_default="0", index=True)
|
||||
last_played_at = Column(DateTime(timezone=True), nullable=True)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||
|
||||
|
||||
__table_args__ = (
|
||||
Index("ix_worlds_tags", "tags", postgresql_using="gin"),
|
||||
)
|
||||
|
||||
@@ -6,6 +6,7 @@ from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Query
|
||||
from fastapi.responses import FileResponse
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
@@ -241,3 +242,20 @@ async def upload_asset_file(
|
||||
await db.commit()
|
||||
await db.refresh(asset)
|
||||
return asset
|
||||
|
||||
|
||||
@router.get("/{asset_id}/file")
|
||||
async def get_asset_file(
|
||||
asset_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Download an asset binary file."""
|
||||
asset = await db.get(Asset, asset_id)
|
||||
if asset is None or not asset.file_path or not os.path.exists(asset.file_path):
|
||||
raise HTTPException(status_code=404, detail="Asset file not found")
|
||||
return FileResponse(
|
||||
asset.file_path,
|
||||
media_type=asset.mime_type or "application/octet-stream",
|
||||
filename=os.path.basename(asset.file_path),
|
||||
)
|
||||
|
||||
|
||||
@@ -43,12 +43,10 @@ class RegisterRequest(BaseModel):
|
||||
username: str
|
||||
email: EmailStr
|
||||
password: str
|
||||
# Real date of birth -- the server computes age from it and rejects
|
||||
# anyone under MIN_AGE. Still self-attested (no document check yet), but
|
||||
# a concrete, stored, legally-meaningful data point rather than a
|
||||
# throwaway checkbox, and the seam for a future hard ID-verification
|
||||
# pass. See docs/architecture/content_moderation.md 5.2.
|
||||
date_of_birth: date
|
||||
# ModBison is an adults-only platform (18+). Users confirm agreement with
|
||||
# 18+ Terms of Service. Date of birth is optional / deprecated.
|
||||
over_18: bool = True
|
||||
date_of_birth: date | None = None
|
||||
|
||||
|
||||
class UserResponse(BaseModel):
|
||||
@@ -159,10 +157,10 @@ def _issue_refresh_token(user_id: int, db: AsyncSession) -> str:
|
||||
async def register(req: RegisterRequest, db: AsyncSession = Depends(get_db)):
|
||||
"""Register a new user and return access + refresh tokens.
|
||||
|
||||
Requires a date of birth; the server computes age and rejects anyone
|
||||
under MIN_AGE (18). See docs/architecture/content_moderation.md for the
|
||||
verification design and its self-attestation limitation.
|
||||
Requires confirming 18+ TOS agreement. If a date of birth is supplied,
|
||||
verifies the user is >= MIN_AGE (18).
|
||||
"""
|
||||
if req.date_of_birth is not None:
|
||||
age = calculate_age(req.date_of_birth)
|
||||
if age < 0 or req.date_of_birth > date.today():
|
||||
raise HTTPException(
|
||||
@@ -174,6 +172,11 @@ async def register(req: RegisterRequest, db: AsyncSession = Depends(get_db)):
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=f"You must be at least {MIN_AGE} to use this platform",
|
||||
)
|
||||
elif not req.over_18:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="You must confirm you are 18 or older to register",
|
||||
)
|
||||
|
||||
# Check if username already exists
|
||||
result = await db.execute(select(User).where(User.username == req.username))
|
||||
|
||||
@@ -124,9 +124,42 @@ async def report_message(
|
||||
return report
|
||||
|
||||
|
||||
class ModerationReportCreate(BaseModel):
|
||||
target_type: Literal["message", "world", "user"]
|
||||
target_id: int
|
||||
reason: str = Field(..., min_length=1, max_length=500)
|
||||
category: str | None = Field(default="general", max_length=50)
|
||||
|
||||
|
||||
# ──────────────────────────────── Moderation ────────────────────────────────────
|
||||
|
||||
|
||||
@moderation_router.post(
|
||||
"/reports",
|
||||
response_model=MessageReportResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
async def submit_moderation_report(
|
||||
payload: ModerationReportCreate,
|
||||
current_user: CurrentUser,
|
||||
db: DbDep,
|
||||
):
|
||||
"""Submit a moderation report for a world, user, or chat message."""
|
||||
report_reason = f"[{payload.target_type.upper()}:{payload.target_id}] [{payload.category}] {payload.reason}"
|
||||
msg_id = payload.target_id if payload.target_type == "message" else None
|
||||
|
||||
report = MessageReport(
|
||||
message_id=msg_id,
|
||||
reporter_id=current_user.id,
|
||||
reason=report_reason,
|
||||
status=ReportStatus.OPEN,
|
||||
)
|
||||
db.add(report)
|
||||
await db.commit()
|
||||
await db.refresh(report)
|
||||
return report
|
||||
|
||||
|
||||
@moderation_router.get("/reports", response_model=list[MessageReportResponse])
|
||||
async def list_reports(
|
||||
current_user: CurrentUser,
|
||||
@@ -146,6 +179,7 @@ async def list_reports(
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
|
||||
@moderation_router.post(
|
||||
"/reports/{report_id}/resolve",
|
||||
response_model=ResolveReportResponse,
|
||||
|
||||
@@ -37,10 +37,12 @@ class DiscoveredWorld(BaseModel):
|
||||
description: str | None = None
|
||||
category: str | None = None
|
||||
tags: list[str] = []
|
||||
content_guidelines: str | None = None
|
||||
play_count: int = 0
|
||||
created_at: datetime | None = None
|
||||
owner: WorldOwnerSummary
|
||||
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ class WorldCreate(BaseModel):
|
||||
visibility: WorldVisibility = WorldVisibility.PUBLIC
|
||||
category: str | None = None
|
||||
tags: list[str] | None = None
|
||||
content_guidelines: str | None = None
|
||||
|
||||
|
||||
class WorldUpdate(BaseModel):
|
||||
@@ -38,6 +39,7 @@ class WorldUpdate(BaseModel):
|
||||
visibility: WorldVisibility | None = None
|
||||
category: str | None = None
|
||||
tags: list[str] | None = None
|
||||
content_guidelines: str | None = None
|
||||
|
||||
|
||||
class WorldResponse(BaseModel):
|
||||
@@ -48,6 +50,7 @@ class WorldResponse(BaseModel):
|
||||
visibility: WorldVisibility
|
||||
category: str | None = None
|
||||
tags: list[str] | None = None
|
||||
content_guidelines: str | None = None
|
||||
play_count: int = 0
|
||||
last_played_at: datetime | None = None
|
||||
owner_id: int
|
||||
@@ -57,6 +60,7 @@ class WorldResponse(BaseModel):
|
||||
|
||||
|
||||
|
||||
|
||||
# --- Endpoints ---
|
||||
|
||||
@router.get("/", response_model=list[WorldResponse])
|
||||
@@ -170,8 +174,10 @@ async def create_world(
|
||||
visibility=world_data.visibility,
|
||||
category=world_data.category,
|
||||
tags=world_data.tags or [],
|
||||
content_guidelines=world_data.content_guidelines,
|
||||
)
|
||||
|
||||
|
||||
db.add(world)
|
||||
await db.flush() # assigns world.id, needed for the room's FK
|
||||
await WorldStore(db).get_or_create_main_room(world.id)
|
||||
|
||||
@@ -40,9 +40,10 @@ class TestRegisterRequestDob:
|
||||
date_of_birth="1990-01-01")
|
||||
assert req.date_of_birth == date(1990, 1, 1)
|
||||
|
||||
def test_dob_required(self):
|
||||
with pytest.raises(Exception):
|
||||
RegisterRequest(username="u", email="u@example.com", password="p")
|
||||
def test_dob_optional_with_over_18_default(self):
|
||||
req = RegisterRequest(username="u", email="u@example.com", password="p")
|
||||
assert req.date_of_birth is None
|
||||
assert req.over_18 is True
|
||||
|
||||
def test_bad_dob_string_rejected(self):
|
||||
with pytest.raises(Exception):
|
||||
@@ -50,6 +51,7 @@ class TestRegisterRequestDob:
|
||||
date_of_birth="not-a-date")
|
||||
|
||||
|
||||
|
||||
class TestRegisterAgeGate:
|
||||
"""End-to-end: the /register endpoint computes age from the DOB."""
|
||||
|
||||
|
||||
@@ -149,18 +149,34 @@ class TestRegister:
|
||||
assert resp.status_code == 409
|
||||
assert "Email already registered" in resp.json()["detail"]
|
||||
|
||||
def test_register_missing_dob(self):
|
||||
"""Missing the required date_of_birth field returns 422."""
|
||||
def test_register_without_dob_succeeds(self):
|
||||
"""DOB is no longer collected; registration with 18+ TOS agreement succeeds."""
|
||||
client = TestClient(app)
|
||||
resp = client.post(
|
||||
"/api/auth/register",
|
||||
json={
|
||||
"username": "user",
|
||||
"email": "user@example.com",
|
||||
"username": "adult_user_nodob",
|
||||
"email": "adult_user_nodob@example.com",
|
||||
"password": "secret123",
|
||||
"over_18": True,
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
assert resp.status_code == 201
|
||||
|
||||
def test_register_not_over_18_rejected(self):
|
||||
"""Declining 18+ confirmation returns 403."""
|
||||
client = TestClient(app)
|
||||
resp = client.post(
|
||||
"/api/auth/register",
|
||||
json={
|
||||
"username": "underage_declined",
|
||||
"email": "underage_declined@example.com",
|
||||
"password": "secret123",
|
||||
"over_18": False,
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -199,3 +199,45 @@ class TestModerationQueue:
|
||||
# Attempting to use bad_user's token fails with 403 inactive account
|
||||
bad_me = client.get("/api/users/me", headers=bad_user["headers"])
|
||||
assert bad_me.status_code == 403
|
||||
|
||||
def test_submit_world_and_user_moderation_report(self):
|
||||
"""Test submitting general moderation reports for worlds and users."""
|
||||
client = TestClient(app)
|
||||
reporter = _register(client, "gen_reporter")
|
||||
admin = _register(client, "admin_gen_mod")
|
||||
|
||||
# Report a world
|
||||
world_rep = client.post(
|
||||
"/api/moderation/reports",
|
||||
headers=reporter["headers"],
|
||||
json={
|
||||
"target_type": "world",
|
||||
"target_id": 42,
|
||||
"reason": "Violates world guidelines",
|
||||
"category": "world_rules",
|
||||
},
|
||||
)
|
||||
assert world_rep.status_code == 201, world_rep.text
|
||||
assert "[WORLD:42]" in world_rep.json()["reason"]
|
||||
|
||||
# Report a user
|
||||
user_rep = client.post(
|
||||
"/api/moderation/reports",
|
||||
headers=reporter["headers"],
|
||||
json={
|
||||
"target_type": "user",
|
||||
"target_id": 99,
|
||||
"reason": "Harassment in world",
|
||||
"category": "harassment",
|
||||
},
|
||||
)
|
||||
assert user_rep.status_code == 201, user_rep.text
|
||||
assert "[USER:99]" in user_rep.json()["reason"]
|
||||
|
||||
# Admin lists open reports and sees both
|
||||
rep_list = client.get("/api/moderation/reports", headers=admin["headers"])
|
||||
assert rep_list.status_code == 200
|
||||
reasons = [r["reason"] for r in rep_list.json()]
|
||||
assert any("[WORLD:42]" in r for r in reasons)
|
||||
assert any("[USER:99]" in r for r in reasons)
|
||||
|
||||
|
||||
@@ -83,11 +83,12 @@ visible = false
|
||||
placeholder_text = "Email"
|
||||
size_flags_horizontal = 3
|
||||
|
||||
[node name="DobEdit" type="LineEdit" parent="CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox"]
|
||||
[node name="Over18Check" type="CheckBox" parent="CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox"]
|
||||
layout_mode = 1
|
||||
visible = false
|
||||
size_flags_horizontal = 3
|
||||
placeholder_text = "Date of birth (YYYY-MM-DD)"
|
||||
button_pressed = true
|
||||
text = "I confirm I am 18 or older (TOS)"
|
||||
|
||||
[node name="AuthButton" type="Button" parent="CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox"]
|
||||
layout_mode = 1
|
||||
|
||||
+39
-6
@@ -213,20 +213,31 @@ func login(username: String, password: String) -> Dictionary:
|
||||
|
||||
|
||||
## Register a new user. On success stores the returned access token.
|
||||
## @param date_of_birth ISO "YYYY-MM-DD". The backend computes age from it
|
||||
## and rejects under-18s (403).
|
||||
## ModBison is an 18+ platform. Supports over_18 boolean or optional legacy DOB string.
|
||||
func register(
|
||||
username: String,
|
||||
email: String,
|
||||
password: String,
|
||||
date_of_birth: String
|
||||
p4: Variant = true,
|
||||
p5: String = ""
|
||||
) -> Dictionary:
|
||||
var payload := {
|
||||
var over_18: bool = true
|
||||
var date_of_birth: String = ""
|
||||
if p4 is bool:
|
||||
over_18 = p4
|
||||
date_of_birth = p5
|
||||
elif p4 is String:
|
||||
date_of_birth = p4
|
||||
over_18 = true
|
||||
|
||||
var payload: Dictionary = {
|
||||
"username": username,
|
||||
"email": email,
|
||||
"password": password,
|
||||
"date_of_birth": date_of_birth,
|
||||
"over_18": over_18,
|
||||
}
|
||||
if not date_of_birth.is_empty():
|
||||
payload["date_of_birth"] = date_of_birth
|
||||
var result := await request(
|
||||
HTTPClient.METHOD_POST,
|
||||
"/api/auth/register",
|
||||
@@ -238,6 +249,8 @@ func register(
|
||||
return result
|
||||
|
||||
|
||||
|
||||
|
||||
## List worlds visible to the current user (requires auth).
|
||||
## @param visibility Optional filter: "public", "private", "friends_only".
|
||||
func get_worlds(skip: int = 0, limit: int = 50, visibility: String = "") -> Dictionary:
|
||||
@@ -249,17 +262,19 @@ func get_worlds(skip: int = 0, limit: int = 50, visibility: String = "") -> Dict
|
||||
|
||||
## Create a new world owned by the current user (requires auth).
|
||||
## On success, result.data is the created World object containing {"id", "name", ...}.
|
||||
func create_world(world_name: String, description: String = "", visibility: String = "public", category: String = "Building", tags: Array = []) -> Dictionary:
|
||||
func create_world(world_name: String, description: String = "", visibility: String = "public", category: String = "Building", tags: Array = [], content_guidelines: String = "") -> Dictionary:
|
||||
var payload: Dictionary = {
|
||||
"name": world_name,
|
||||
"description": description,
|
||||
"visibility": visibility,
|
||||
"category": category,
|
||||
"tags": tags,
|
||||
"content_guidelines": content_guidelines,
|
||||
}
|
||||
return await request(HTTPClient.METHOD_POST, "/api/worlds/", JSON.stringify(payload), "application/json", true)
|
||||
|
||||
|
||||
|
||||
## Ping the backend health endpoint (no auth).
|
||||
func health_check() -> Dictionary:
|
||||
return await request(HTTPClient.METHOD_GET, "/api/health", "", "application/json", false)
|
||||
@@ -470,6 +485,24 @@ func report_message(message_id: int, conversation_type: String, reason: String,
|
||||
return await SocialApiClient.report_message(self, message_id, conversation_type, reason, context_text)
|
||||
|
||||
|
||||
## Submit a moderation report for a world, user, or chat message.
|
||||
func submit_moderation_report(target_type: String, target_id: int, reason: String, category: String = "general") -> Dictionary:
|
||||
var payload := {
|
||||
"target_type": target_type,
|
||||
"target_id": target_id,
|
||||
"reason": reason,
|
||||
"category": category,
|
||||
}
|
||||
return await request(
|
||||
HTTPClient.METHOD_POST,
|
||||
"/api/moderation/reports",
|
||||
JSON.stringify(payload),
|
||||
"application/json",
|
||||
true
|
||||
)
|
||||
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Discovery Features
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -12,6 +12,7 @@ class WorldSummary extends RefCounted:
|
||||
var tags: Array = []
|
||||
var play_count: int = 0
|
||||
var created_at: String = ""
|
||||
var content_guidelines: String = ""
|
||||
var owner_id: int = 0
|
||||
var owner_username: String = ""
|
||||
|
||||
@@ -21,6 +22,7 @@ class WorldSummary extends RefCounted:
|
||||
w.name = str(d.get("name", ""))
|
||||
w.description = str(d.get("description", "")) if d.get("description") != null else ""
|
||||
w.category = str(d.get("category", "")) if d.get("category") != null else ""
|
||||
w.content_guidelines = str(d.get("content_guidelines", "")) if d.get("content_guidelines") != null else ""
|
||||
var raw_tags = d.get("tags", [])
|
||||
w.tags = raw_tags if raw_tags is Array else []
|
||||
w.play_count = int(d.get("play_count", 0))
|
||||
@@ -31,6 +33,7 @@ class WorldSummary extends RefCounted:
|
||||
return w
|
||||
|
||||
|
||||
|
||||
class DiscoveryResult extends RefCounted:
|
||||
var items: Array[WorldSummary] = []
|
||||
var total: int = 0
|
||||
|
||||
+18
-1
@@ -1,5 +1,8 @@
|
||||
extends Control
|
||||
class_name DiscoveryUI
|
||||
|
||||
const WorldBriefingModal = preload("res://scripts/world_briefing_modal.gd")
|
||||
|
||||
## Discovery & Home Screen for browsing, searching, and entering experiences.
|
||||
##
|
||||
## docs/planning/discovery.md (task b77bddf31ba5).
|
||||
@@ -244,7 +247,7 @@ func _create_world_card(world: DiscoveryApiClient.WorldSummary) -> Control:
|
||||
var enter_button := Button.new()
|
||||
enter_button.text = "Enter World"
|
||||
enter_button.custom_minimum_size = Vector2(110, 30)
|
||||
enter_button.pressed.connect(_on_enter_world_pressed.bind(world.id, enter_button))
|
||||
enter_button.pressed.connect(_show_world_briefing.bind(world, enter_button))
|
||||
title_row.add_child(enter_button)
|
||||
vbox.add_child(title_row)
|
||||
|
||||
@@ -302,6 +305,20 @@ func _update_pagination() -> void:
|
||||
next_button.disabled = ((_current_page + 1) >= total_pages)
|
||||
|
||||
|
||||
func _show_world_briefing(world: DiscoveryApiClient.WorldSummary, button: Button) -> void:
|
||||
var modal := WorldBriefingModal.new()
|
||||
modal.setup_world_data(
|
||||
world.id,
|
||||
world.name,
|
||||
world.owner_username,
|
||||
world.content_guidelines,
|
||||
world.tags,
|
||||
world.play_count
|
||||
)
|
||||
modal.entered_confirmed.connect(func(target_wid: int): _on_enter_world_pressed(target_wid, button))
|
||||
add_child(modal)
|
||||
|
||||
|
||||
func _on_enter_world_pressed(world_id: int, button: Button) -> void:
|
||||
button.disabled = true
|
||||
button.text = "Joining…"
|
||||
|
||||
@@ -187,4 +187,8 @@ func set_object_scale_raw(object_id: String, scale: Vector3) -> void:
|
||||
func set_object_property_raw(object_id: String, key: String, value: Variant) -> void:
|
||||
var obj := find_object(object_id)
|
||||
if obj != null:
|
||||
if value == null:
|
||||
obj.properties.erase(key)
|
||||
else:
|
||||
obj.properties[key] = value
|
||||
|
||||
|
||||
@@ -98,6 +98,14 @@ func scale_object(object_id: String, scale: Vector3) -> bool:
|
||||
return bool(use_tool("transform", {"object_id": object_id, "op": "scale", "value": scale}))
|
||||
|
||||
|
||||
func set_object_property(object_id: String, key: String, value: Variant) -> bool:
|
||||
return bool(use_tool("transform", {"object_id": object_id, "op": "property", "key": key, "value": value}))
|
||||
|
||||
|
||||
func set_object_behavior(object_id: String, behavior_dict: Dictionary) -> bool:
|
||||
return set_object_property(object_id, "script_behavior", behavior_dict)
|
||||
|
||||
|
||||
func select_object(object_id: String) -> bool:
|
||||
return bool(use_tool("select", {"object_id": object_id}))
|
||||
|
||||
|
||||
@@ -64,5 +64,20 @@ func get_all_ids() -> Array[String]:
|
||||
return ids
|
||||
|
||||
|
||||
func register_custom_object(id: String, entry: Dictionary) -> bool:
|
||||
if not _loaded:
|
||||
_catalog = {}
|
||||
_loaded = true
|
||||
_catalog[id] = entry
|
||||
return true
|
||||
|
||||
|
||||
func remove_custom_object(id: String) -> bool:
|
||||
if not _loaded:
|
||||
return false
|
||||
return _catalog.erase(id)
|
||||
|
||||
|
||||
func get_catalog_path() -> String:
|
||||
return _catalog_path
|
||||
|
||||
|
||||
@@ -35,6 +35,11 @@ func apply(core: EditorCore, args: Dictionary) -> Variant:
|
||||
"scale":
|
||||
return _apply_vector3(core, object_id, args.get("value", obj.scale),
|
||||
obj.scale, "Scale", core.model.set_object_scale_raw)
|
||||
"property":
|
||||
var key: String = str(args.get("key", ""))
|
||||
var new_val: Variant = args.get("value")
|
||||
var old_val: Variant = obj.properties.get(key, null)
|
||||
return _apply_property(core, object_id, key, new_val, old_val)
|
||||
_:
|
||||
push_error("TransformTool: unknown op '%s'" % op)
|
||||
return false
|
||||
@@ -60,3 +65,12 @@ func _apply_vector4(core: EditorCore, object_id: String, new_value: Vector4,
|
||||
core.undo_redo.add_undo(setter.bind(object_id, old_value))
|
||||
core.undo_redo.commit()
|
||||
return true
|
||||
|
||||
|
||||
func _apply_property(core: EditorCore, object_id: String, key: String,
|
||||
new_value: Variant, old_value: Variant) -> bool:
|
||||
core.undo_redo.begin("Set property '%s' on '%s'" % [key, object_id])
|
||||
core.undo_redo.add_do(core.model.set_object_property_raw.bind(object_id, key, new_value))
|
||||
core.undo_redo.add_undo(core.model.set_object_property_raw.bind(object_id, key, old_value))
|
||||
core.undo_redo.commit()
|
||||
return true
|
||||
|
||||
@@ -1,18 +1,16 @@
|
||||
class_name InspectorPanel
|
||||
extends VBoxContainer
|
||||
## Property editor for the selected object (docs/architecture/editor.md
|
||||
## Property and behavior editor for the selected object (docs/architecture/editor.md
|
||||
## §7.2 `InspectorPanel`).
|
||||
##
|
||||
## 2D-tile-editor scope (docs/architecture/editor.md §8: the default world
|
||||
## is flat, `CHUNK_SIZE_Z=1`): position exposes X/Y (world-plane) plus a
|
||||
## single "height" Z for props that sit above the floor, and rotation
|
||||
## exposes one yaw field (rotation about the vertical axis) rather than a
|
||||
## full 3-axis gizmo -- deliberately not the volumetric 3D editing the doc
|
||||
## says to defer.
|
||||
##
|
||||
## Pure UI: every edit calls back into `EditorCore` (`move_object` /
|
||||
## `rotate_object` / `scale_object` / `delete_object`), so inspector edits
|
||||
## go through the exact same undo/redo path as viewport edits.
|
||||
## Visual Property & Behavior Inspector (Creator Studio):
|
||||
## - Position, yaw, scale, and delete operations.
|
||||
## - Visual rule builder for ScriptBehavior rules:
|
||||
## Triggers: on_interact, on_touch, on_enter, on_timer
|
||||
## Actions: give_item (Buffalo Chips / items), rotate, move, play_sound, spawn_from_catalog
|
||||
## - Pure UI: every property & behavior edit calls back into `EditorCore`
|
||||
## (`move_object`, `rotate_object`, `scale_object`, `set_object_behavior`, `delete_object`),
|
||||
## fully supporting undo and redo.
|
||||
|
||||
signal delete_requested(object_id: String)
|
||||
|
||||
@@ -20,6 +18,11 @@ var _core: EditorCore = null
|
||||
var _object_id: String = ""
|
||||
var _updating := false # guards against feedback loops while refreshing fields
|
||||
|
||||
var _empty_label: Label
|
||||
var _scroll: ScrollContainer
|
||||
var _fields_box: VBoxContainer
|
||||
|
||||
# Property fields
|
||||
var _id_label: Label
|
||||
var _type_label: Label
|
||||
var _asset_label: Label
|
||||
@@ -31,8 +34,43 @@ var _scale_x: SpinBox
|
||||
var _scale_y: SpinBox
|
||||
var _scale_z: SpinBox
|
||||
var _delete_button: Button
|
||||
var _empty_label: Label
|
||||
var _fields_box: VBoxContainer
|
||||
|
||||
# Behavior & Rules fields
|
||||
var _rules_vbox: VBoxContainer
|
||||
var _add_rule_btn: Button
|
||||
var _rule_form_box: VBoxContainer
|
||||
var _trigger_option: OptionButton
|
||||
var _timer_row: HBoxContainer
|
||||
var _timer_interval_spin: SpinBox
|
||||
var _action_option: OptionButton
|
||||
|
||||
# Dynamic Action Parameter boxes
|
||||
var _action_item_box: VBoxContainer
|
||||
var _item_id_edit: LineEdit
|
||||
var _item_count_spin: SpinBox
|
||||
|
||||
var _action_rotate_box: VBoxContainer
|
||||
var _rotate_axis_option: OptionButton
|
||||
var _rotate_degrees_spin: SpinBox
|
||||
var _rotate_duration_spin: SpinBox
|
||||
|
||||
var _action_move_box: VBoxContainer
|
||||
var _move_x_spin: SpinBox
|
||||
var _move_y_spin: SpinBox
|
||||
var _move_z_spin: SpinBox
|
||||
var _move_duration_spin: SpinBox
|
||||
|
||||
var _action_sound_box: VBoxContainer
|
||||
var _sound_id_edit: LineEdit
|
||||
|
||||
var _action_spawn_box: VBoxContainer
|
||||
var _spawn_catalog_id_edit: LineEdit
|
||||
var _spawn_x_spin: SpinBox
|
||||
var _spawn_y_spin: SpinBox
|
||||
var _spawn_z_spin: SpinBox
|
||||
|
||||
|
||||
var _is_built := false
|
||||
|
||||
|
||||
func _init() -> void:
|
||||
@@ -41,6 +79,17 @@ func _init() -> void:
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
if not _is_built:
|
||||
_build_ui()
|
||||
if _object_id != "":
|
||||
show_object(_object_id)
|
||||
|
||||
|
||||
func _build_ui() -> void:
|
||||
if _is_built:
|
||||
return
|
||||
_is_built = true
|
||||
|
||||
var header := Label.new()
|
||||
header.text = "Inspector"
|
||||
header.add_theme_font_size_override("font_size", 15)
|
||||
@@ -51,9 +100,15 @@ func _ready() -> void:
|
||||
_empty_label.text = "(nothing selected)"
|
||||
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)
|
||||
|
||||
_fields_box = VBoxContainer.new()
|
||||
_fields_box.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
_fields_box.visible = false
|
||||
add_child(_fields_box)
|
||||
_scroll.add_child(_fields_box)
|
||||
|
||||
_id_label = _add_readonly_row("id")
|
||||
_type_label = _add_readonly_row("type")
|
||||
@@ -80,10 +135,292 @@ func _ready() -> void:
|
||||
_scale_z.value_changed.connect(_on_scale_changed)
|
||||
|
||||
_delete_button = Button.new()
|
||||
_delete_button.text = "Delete"
|
||||
_delete_button.text = "Delete Object"
|
||||
_delete_button.add_theme_color_override("font_color", Color(1.0, 0.4, 0.4))
|
||||
_delete_button.pressed.connect(_on_delete_pressed)
|
||||
_fields_box.add_child(_delete_button)
|
||||
|
||||
_setup_behavior_ui()
|
||||
|
||||
|
||||
func _setup_behavior_ui() -> void:
|
||||
var sep := HSeparator.new()
|
||||
sep.custom_minimum_size = Vector2(0, 16)
|
||||
_fields_box.add_child(sep)
|
||||
|
||||
var b_header := Label.new()
|
||||
b_header.text = "Behavior & Rules"
|
||||
b_header.add_theme_font_size_override("font_size", 14)
|
||||
b_header.add_theme_color_override("font_color", Color(0.35, 0.85, 1.0))
|
||||
_fields_box.add_child(b_header)
|
||||
|
||||
var b_desc := Label.new()
|
||||
b_desc.text = "Attach interactive logic and triggers."
|
||||
b_desc.add_theme_font_size_override("font_size", 11)
|
||||
b_desc.add_theme_color_override("font_color", Color(0.7, 0.7, 0.7))
|
||||
_fields_box.add_child(b_desc)
|
||||
|
||||
_rules_vbox = VBoxContainer.new()
|
||||
_rules_vbox.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
_fields_box.add_child(_rules_vbox)
|
||||
|
||||
_add_rule_btn = Button.new()
|
||||
_add_rule_btn.text = "+ Add Behavior Rule"
|
||||
_add_rule_btn.pressed.connect(_on_add_rule_btn_pressed)
|
||||
_fields_box.add_child(_add_rule_btn)
|
||||
|
||||
_setup_rule_form()
|
||||
|
||||
|
||||
func _setup_rule_form() -> void:
|
||||
_rule_form_box = VBoxContainer.new()
|
||||
_rule_form_box.visible = false
|
||||
_rule_form_box.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
_fields_box.add_child(_rule_form_box)
|
||||
|
||||
var form_panel := PanelContainer.new()
|
||||
var form_box := VBoxContainer.new()
|
||||
form_panel.add_child(form_box)
|
||||
_rule_form_box.add_child(form_panel)
|
||||
|
||||
var form_title := Label.new()
|
||||
form_title.text = "New Behavior Rule"
|
||||
form_title.add_theme_font_size_override("font_size", 13)
|
||||
form_title.add_theme_color_override("font_color", Color(1.0, 0.9, 0.3))
|
||||
form_box.add_child(form_title)
|
||||
|
||||
# Trigger Selector
|
||||
var trig_row := HBoxContainer.new()
|
||||
var trig_lbl := Label.new()
|
||||
trig_lbl.text = "Trigger:"
|
||||
trig_lbl.custom_minimum_size = Vector2(70, 0)
|
||||
trig_row.add_child(trig_lbl)
|
||||
_trigger_option = OptionButton.new()
|
||||
_trigger_option.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
_trigger_option.add_item("on_interact (Click/Interact)", 0)
|
||||
_trigger_option.set_item_metadata(0, "on_interact")
|
||||
_trigger_option.add_item("on_touch (Physical contact)", 1)
|
||||
_trigger_option.set_item_metadata(1, "on_touch")
|
||||
_trigger_option.add_item("on_enter (Enters zone)", 2)
|
||||
_trigger_option.set_item_metadata(2, "on_enter")
|
||||
_trigger_option.add_item("on_timer (Repeats every X sec)", 3)
|
||||
_trigger_option.set_item_metadata(3, "on_timer")
|
||||
_trigger_option.item_selected.connect(_on_trigger_changed)
|
||||
trig_row.add_child(_trigger_option)
|
||||
form_box.add_child(trig_row)
|
||||
|
||||
# Timer interval row (only visible when on_timer is selected)
|
||||
_timer_row = HBoxContainer.new()
|
||||
_timer_row.visible = false
|
||||
var timer_lbl := Label.new()
|
||||
timer_lbl.text = "Interval (s):"
|
||||
timer_lbl.custom_minimum_size = Vector2(70, 0)
|
||||
_timer_row.add_child(timer_lbl)
|
||||
_timer_interval_spin = SpinBox.new()
|
||||
_timer_interval_spin.min_value = 0.1
|
||||
_timer_interval_spin.max_value = 3600.0
|
||||
_timer_interval_spin.step = 0.5
|
||||
_timer_interval_spin.value = 2.0
|
||||
_timer_row.add_child(_timer_interval_spin)
|
||||
form_box.add_child(_timer_row)
|
||||
|
||||
# Action Selector
|
||||
var act_row := HBoxContainer.new()
|
||||
var act_lbl := Label.new()
|
||||
act_lbl.text = "Action:"
|
||||
act_lbl.custom_minimum_size = Vector2(70, 0)
|
||||
act_row.add_child(act_lbl)
|
||||
_action_option = OptionButton.new()
|
||||
_action_option.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
_action_option.add_item("give_item (Reward Chips/Items)", 0)
|
||||
_action_option.set_item_metadata(0, "give_item")
|
||||
_action_option.add_item("rotate (Spin Object)", 1)
|
||||
_action_option.set_item_metadata(1, "rotate")
|
||||
_action_option.add_item("move (Move / Elevate)", 2)
|
||||
_action_option.set_item_metadata(2, "move")
|
||||
_action_option.add_item("play_sound (Audio FX)", 3)
|
||||
_action_option.set_item_metadata(3, "play_sound")
|
||||
_action_option.add_item("spawn_from_catalog (Spawn)", 4)
|
||||
_action_option.set_item_metadata(4, "spawn_from_catalog")
|
||||
_action_option.item_selected.connect(_on_action_changed)
|
||||
act_row.add_child(_action_option)
|
||||
form_box.add_child(act_row)
|
||||
|
||||
# Dynamic Parameter Boxes
|
||||
# 1. give_item
|
||||
_action_item_box = VBoxContainer.new()
|
||||
var item_id_row := HBoxContainer.new()
|
||||
var item_id_lbl := Label.new()
|
||||
item_id_lbl.text = "Item ID:"
|
||||
item_id_lbl.custom_minimum_size = Vector2(70, 0)
|
||||
item_id_row.add_child(item_id_lbl)
|
||||
_item_id_edit = LineEdit.new()
|
||||
_item_id_edit.text = "buffalo_chips"
|
||||
_item_id_edit.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
item_id_row.add_child(_item_id_edit)
|
||||
_action_item_box.add_child(item_id_row)
|
||||
|
||||
var item_cnt_row := HBoxContainer.new()
|
||||
var item_cnt_lbl := Label.new()
|
||||
item_cnt_lbl.text = "Amount:"
|
||||
item_cnt_lbl.custom_minimum_size = Vector2(70, 0)
|
||||
item_cnt_row.add_child(item_cnt_lbl)
|
||||
_item_count_spin = SpinBox.new()
|
||||
_item_count_spin.min_value = 1
|
||||
_item_count_spin.max_value = 10000
|
||||
_item_count_spin.value = 5
|
||||
item_cnt_row.add_child(_item_count_spin)
|
||||
_action_item_box.add_child(item_cnt_row)
|
||||
form_box.add_child(_action_item_box)
|
||||
|
||||
# 2. rotate
|
||||
_action_rotate_box = VBoxContainer.new()
|
||||
_action_rotate_box.visible = false
|
||||
var rot_axis_row := HBoxContainer.new()
|
||||
var rot_axis_lbl := Label.new()
|
||||
rot_axis_lbl.text = "Axis:"
|
||||
rot_axis_lbl.custom_minimum_size = Vector2(70, 0)
|
||||
rot_axis_row.add_child(rot_axis_lbl)
|
||||
_rotate_axis_option = OptionButton.new()
|
||||
_rotate_axis_option.add_item("y (Yaw)")
|
||||
_rotate_axis_option.set_item_metadata(0, "y")
|
||||
_rotate_axis_option.add_item("x (Pitch)")
|
||||
_rotate_axis_option.set_item_metadata(1, "x")
|
||||
_rotate_axis_option.add_item("z (Roll)")
|
||||
_rotate_axis_option.set_item_metadata(2, "z")
|
||||
rot_axis_row.add_child(_rotate_axis_option)
|
||||
_action_rotate_box.add_child(rot_axis_row)
|
||||
|
||||
var rot_deg_row := HBoxContainer.new()
|
||||
var rot_deg_lbl := Label.new()
|
||||
rot_deg_lbl.text = "Degrees:"
|
||||
rot_deg_lbl.custom_minimum_size = Vector2(70, 0)
|
||||
rot_deg_row.add_child(rot_deg_lbl)
|
||||
_rotate_degrees_spin = SpinBox.new()
|
||||
_rotate_degrees_spin.min_value = -360.0
|
||||
_rotate_degrees_spin.max_value = 360.0
|
||||
_rotate_degrees_spin.step = 15.0
|
||||
_rotate_degrees_spin.value = 90.0
|
||||
rot_deg_row.add_child(_rotate_degrees_spin)
|
||||
_action_rotate_box.add_child(rot_deg_row)
|
||||
|
||||
var rot_dur_row := HBoxContainer.new()
|
||||
var rot_dur_lbl := Label.new()
|
||||
rot_dur_lbl.text = "Duration (s):"
|
||||
rot_dur_lbl.custom_minimum_size = Vector2(70, 0)
|
||||
rot_dur_row.add_child(rot_dur_lbl)
|
||||
_rotate_duration_spin = SpinBox.new()
|
||||
_rotate_duration_spin.min_value = 0.05
|
||||
_rotate_duration_spin.max_value = 5.0
|
||||
_rotate_duration_spin.step = 0.1
|
||||
_rotate_duration_spin.value = 0.4
|
||||
rot_dur_row.add_child(_rotate_duration_spin)
|
||||
_action_rotate_box.add_child(rot_dur_row)
|
||||
form_box.add_child(_action_rotate_box)
|
||||
|
||||
# 3. move
|
||||
_action_move_box = VBoxContainer.new()
|
||||
_action_move_box.visible = false
|
||||
var move_lbl := Label.new()
|
||||
move_lbl.text = "Offset (X, Y, Z):"
|
||||
_action_move_box.add_child(move_lbl)
|
||||
var move_coords := HBoxContainer.new()
|
||||
_move_x_spin = SpinBox.new()
|
||||
_move_x_spin.min_value = -100.0
|
||||
_move_x_spin.max_value = 100.0
|
||||
_move_x_spin.value = 0.0
|
||||
move_coords.add_child(_move_x_spin)
|
||||
_move_y_spin = SpinBox.new()
|
||||
_move_y_spin.min_value = -100.0
|
||||
_move_y_spin.max_value = 100.0
|
||||
_move_y_spin.value = 2.0
|
||||
move_coords.add_child(_move_y_spin)
|
||||
_move_z_spin = SpinBox.new()
|
||||
_move_z_spin.min_value = -100.0
|
||||
_move_z_spin.max_value = 100.0
|
||||
_move_z_spin.value = 0.0
|
||||
move_coords.add_child(_move_z_spin)
|
||||
_action_move_box.add_child(move_coords)
|
||||
|
||||
var move_dur_row := HBoxContainer.new()
|
||||
var move_dur_lbl := Label.new()
|
||||
move_dur_lbl.text = "Duration (s):"
|
||||
move_dur_lbl.custom_minimum_size = Vector2(70, 0)
|
||||
move_dur_row.add_child(move_dur_lbl)
|
||||
_move_duration_spin = SpinBox.new()
|
||||
_move_duration_spin.min_value = 0.05
|
||||
_move_duration_spin.max_value = 5.0
|
||||
_move_duration_spin.step = 0.1
|
||||
_move_duration_spin.value = 0.4
|
||||
move_dur_row.add_child(_move_duration_spin)
|
||||
_action_move_box.add_child(move_dur_row)
|
||||
form_box.add_child(_action_move_box)
|
||||
|
||||
# 4. play_sound
|
||||
_action_sound_box = VBoxContainer.new()
|
||||
_action_sound_box.visible = false
|
||||
var snd_row := HBoxContainer.new()
|
||||
var snd_lbl := Label.new()
|
||||
snd_lbl.text = "Sound ID:"
|
||||
snd_lbl.custom_minimum_size = Vector2(70, 0)
|
||||
snd_row.add_child(snd_lbl)
|
||||
_sound_id_edit = LineEdit.new()
|
||||
_sound_id_edit.text = "bell"
|
||||
_sound_id_edit.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
snd_row.add_child(_sound_id_edit)
|
||||
_action_sound_box.add_child(snd_row)
|
||||
form_box.add_child(_action_sound_box)
|
||||
|
||||
# 5. spawn_from_catalog
|
||||
_action_spawn_box = VBoxContainer.new()
|
||||
_action_spawn_box.visible = false
|
||||
var sp_row := HBoxContainer.new()
|
||||
var sp_lbl := Label.new()
|
||||
sp_lbl.text = "Catalog ID:"
|
||||
sp_lbl.custom_minimum_size = Vector2(70, 0)
|
||||
sp_row.add_child(sp_lbl)
|
||||
_spawn_catalog_id_edit = LineEdit.new()
|
||||
_spawn_catalog_id_edit.text = "cube"
|
||||
_spawn_catalog_id_edit.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
sp_row.add_child(_spawn_catalog_id_edit)
|
||||
_action_spawn_box.add_child(sp_row)
|
||||
|
||||
var sp_pos_lbl := Label.new()
|
||||
sp_pos_lbl.text = "Spawn Pos (X, Y, Z):"
|
||||
_action_spawn_box.add_child(sp_pos_lbl)
|
||||
var sp_coords := HBoxContainer.new()
|
||||
_spawn_x_spin = SpinBox.new()
|
||||
_spawn_x_spin.min_value = -100.0
|
||||
_spawn_x_spin.max_value = 100.0
|
||||
_spawn_x_spin.value = 0.0
|
||||
sp_coords.add_child(_spawn_x_spin)
|
||||
_spawn_y_spin = SpinBox.new()
|
||||
_spawn_y_spin.min_value = -100.0
|
||||
_spawn_y_spin.max_value = 100.0
|
||||
_spawn_y_spin.value = 1.0
|
||||
sp_coords.add_child(_spawn_y_spin)
|
||||
_spawn_z_spin = SpinBox.new()
|
||||
_spawn_z_spin.min_value = -100.0
|
||||
_spawn_z_spin.max_value = 100.0
|
||||
_spawn_z_spin.value = 0.0
|
||||
sp_coords.add_child(_spawn_z_spin)
|
||||
_action_spawn_box.add_child(sp_coords)
|
||||
form_box.add_child(_action_spawn_box)
|
||||
|
||||
# Form Buttons
|
||||
var btn_row := HBoxContainer.new()
|
||||
var save_btn := Button.new()
|
||||
save_btn.text = "Save Rule"
|
||||
save_btn.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
save_btn.pressed.connect(_on_save_rule_pressed)
|
||||
btn_row.add_child(save_btn)
|
||||
|
||||
var cancel_btn := Button.new()
|
||||
cancel_btn.text = "Cancel"
|
||||
cancel_btn.pressed.connect(_on_cancel_rule_pressed)
|
||||
btn_row.add_child(cancel_btn)
|
||||
form_box.add_child(btn_row)
|
||||
|
||||
|
||||
func setup(core: EditorCore) -> void:
|
||||
_core = core
|
||||
@@ -91,19 +428,32 @@ func setup(core: EditorCore) -> void:
|
||||
|
||||
func show_object(object_id: String) -> void:
|
||||
_object_id = object_id
|
||||
if not _is_built:
|
||||
_build_ui()
|
||||
|
||||
if object_id == "" or _core == null:
|
||||
if _empty_label != null:
|
||||
_empty_label.visible = true
|
||||
if _fields_box != null:
|
||||
_fields_box.visible = false
|
||||
return
|
||||
|
||||
var obj := _core.model.find_object(object_id)
|
||||
if obj == null:
|
||||
if _empty_label != null:
|
||||
_empty_label.visible = true
|
||||
if _fields_box != null:
|
||||
_fields_box.visible = false
|
||||
return
|
||||
|
||||
if _empty_label != null:
|
||||
_empty_label.visible = false
|
||||
if _fields_box != null:
|
||||
_fields_box.visible = true
|
||||
if _rule_form_box != null:
|
||||
_rule_form_box.visible = false
|
||||
if _add_rule_btn != null:
|
||||
_add_rule_btn.visible = true
|
||||
|
||||
_updating = true
|
||||
_id_label.text = "id: %s" % obj.id
|
||||
@@ -118,6 +468,211 @@ func show_object(object_id: String) -> void:
|
||||
_scale_z.value = obj.scale.z
|
||||
_updating = false
|
||||
|
||||
_refresh_rules()
|
||||
|
||||
|
||||
func _refresh_rules() -> void:
|
||||
for child in _rules_vbox.get_children():
|
||||
child.queue_free()
|
||||
|
||||
if _core == null or _object_id == "":
|
||||
return
|
||||
|
||||
var obj := _core.model.find_object(_object_id)
|
||||
if obj == null:
|
||||
return
|
||||
|
||||
var b_dict: Variant = obj.properties.get("script_behavior", obj.properties.get("behavior"))
|
||||
var rules_arr: Array = []
|
||||
if b_dict is Dictionary and b_dict.has("rules") and b_dict["rules"] is Array:
|
||||
rules_arr = b_dict["rules"]
|
||||
|
||||
if rules_arr.is_empty():
|
||||
var empty_lbl := Label.new()
|
||||
empty_lbl.text = "(No behavior rules attached)"
|
||||
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))
|
||||
_rules_vbox.add_child(empty_lbl)
|
||||
return
|
||||
|
||||
for i in range(rules_arr.size()):
|
||||
var rule: Variant = rules_arr[i]
|
||||
if not (rule is Dictionary):
|
||||
continue
|
||||
var card := _create_rule_card(i, rule as Dictionary)
|
||||
_rules_vbox.add_child(card)
|
||||
|
||||
|
||||
func _create_rule_card(rule_index: int, rule: Dictionary) -> Control:
|
||||
var panel := PanelContainer.new()
|
||||
var row := HBoxContainer.new()
|
||||
row.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
panel.add_child(row)
|
||||
|
||||
var text_box := VBoxContainer.new()
|
||||
text_box.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
row.add_child(text_box)
|
||||
|
||||
var trigger_dict: Dictionary = rule.get("trigger", {})
|
||||
var trig_type: String = str(trigger_dict.get("type", "unknown"))
|
||||
var trig_label := Label.new()
|
||||
if trig_type == "on_timer":
|
||||
trig_label.text = "⏱️ on_timer (every %ss)" % str(trigger_dict.get("interval", 1.0))
|
||||
elif trig_type == "on_touch":
|
||||
trig_label.text = "💥 on_touch"
|
||||
elif trig_type == "on_enter":
|
||||
trig_label.text = "🚶 on_enter"
|
||||
else:
|
||||
trig_label.text = "⚡ on_interact"
|
||||
trig_label.add_theme_font_size_override("font_size", 12)
|
||||
trig_label.add_theme_color_override("font_color", Color(0.4, 0.9, 1.0))
|
||||
text_box.add_child(trig_label)
|
||||
|
||||
var actions_arr: Array = rule.get("actions", [])
|
||||
for act in actions_arr:
|
||||
if not (act is Dictionary):
|
||||
continue
|
||||
var act_dict: Dictionary = act
|
||||
var verb: String = str(act_dict.get("verb", ""))
|
||||
var act_lbl := Label.new()
|
||||
act_lbl.add_theme_font_size_override("font_size", 11)
|
||||
match verb:
|
||||
"give_item":
|
||||
var item_name: String = str(act_dict.get("item_id", "buffalo_chips"))
|
||||
if item_name == "buffalo_chips":
|
||||
item_name = "Buffalo Chips"
|
||||
act_lbl.text = "→ Give %d %s" % [int(act_dict.get("count", 1)), item_name]
|
||||
"rotate":
|
||||
act_lbl.text = "→ Rotate %.0f° (%s axis)" % [float(act_dict.get("degrees", 90)), str(act_dict.get("axis", "y"))]
|
||||
"move":
|
||||
var off: Array = act_dict.get("offset", [0, 0, 0])
|
||||
act_lbl.text = "→ Move offset %s" % str(off)
|
||||
"play_sound":
|
||||
act_lbl.text = "→ Play sound '%s'" % str(act_dict.get("sound_id", ""))
|
||||
"spawn_from_catalog":
|
||||
act_lbl.text = "→ Spawn '%s'" % str(act_dict.get("catalog_id", ""))
|
||||
_:
|
||||
act_lbl.text = "→ %s" % verb
|
||||
text_box.add_child(act_lbl)
|
||||
|
||||
var del_btn := Button.new()
|
||||
del_btn.text = "✕"
|
||||
del_btn.tooltip_text = "Delete this rule"
|
||||
del_btn.pressed.connect(_on_delete_rule_pressed.bind(rule_index))
|
||||
row.add_child(del_btn)
|
||||
|
||||
return panel
|
||||
|
||||
|
||||
func _on_add_rule_btn_pressed() -> void:
|
||||
_rule_form_box.visible = true
|
||||
_add_rule_btn.visible = false
|
||||
|
||||
|
||||
func _on_cancel_rule_pressed() -> void:
|
||||
_rule_form_box.visible = false
|
||||
_add_rule_btn.visible = true
|
||||
|
||||
|
||||
func _on_trigger_changed(index: int) -> void:
|
||||
var trig_key: String = _trigger_option.get_item_metadata(index)
|
||||
_timer_row.visible = (trig_key == "on_timer")
|
||||
|
||||
|
||||
func _on_action_changed(index: int) -> void:
|
||||
var act_key: String = _action_option.get_item_metadata(index)
|
||||
_action_item_box.visible = (act_key == "give_item")
|
||||
_action_rotate_box.visible = (act_key == "rotate")
|
||||
_action_move_box.visible = (act_key == "move")
|
||||
_action_sound_box.visible = (act_key == "play_sound")
|
||||
_action_spawn_box.visible = (act_key == "spawn_from_catalog")
|
||||
|
||||
|
||||
func _on_save_rule_pressed() -> void:
|
||||
if _core == null or _object_id == "":
|
||||
return
|
||||
|
||||
var obj := _core.model.find_object(_object_id)
|
||||
if obj == null:
|
||||
return
|
||||
|
||||
var trig_type: String = _trigger_option.get_item_metadata(_trigger_option.selected)
|
||||
var trig_dict: Dictionary = {"type": trig_type}
|
||||
if trig_type == "on_timer":
|
||||
trig_dict["interval"] = _timer_interval_spin.value
|
||||
|
||||
var act_verb: String = _action_option.get_item_metadata(_action_option.selected)
|
||||
var act_dict: Dictionary = {"verb": act_verb}
|
||||
match act_verb:
|
||||
"give_item":
|
||||
var item_id := _item_id_edit.text.strip_edges()
|
||||
if item_id.is_empty():
|
||||
item_id = "buffalo_chips"
|
||||
act_dict["item_id"] = item_id
|
||||
act_dict["count"] = int(_item_count_spin.value)
|
||||
"rotate":
|
||||
var axis_idx := _rotate_axis_option.selected
|
||||
act_dict["axis"] = _rotate_axis_option.get_item_metadata(axis_idx)
|
||||
act_dict["degrees"] = _rotate_degrees_spin.value
|
||||
act_dict["duration"] = _rotate_duration_spin.value
|
||||
act_dict["target"] = "self"
|
||||
"move":
|
||||
act_dict["target"] = "self"
|
||||
act_dict["offset"] = [_move_x_spin.value, _move_y_spin.value, _move_z_spin.value]
|
||||
act_dict["duration"] = _move_duration_spin.value
|
||||
"play_sound":
|
||||
var snd := _sound_id_edit.text.strip_edges()
|
||||
if snd.is_empty():
|
||||
snd = "bell"
|
||||
act_dict["sound_id"] = snd
|
||||
"spawn_from_catalog":
|
||||
var cat_id := _spawn_catalog_id_edit.text.strip_edges()
|
||||
if cat_id.is_empty():
|
||||
cat_id = "cube"
|
||||
act_dict["catalog_id"] = cat_id
|
||||
act_dict["position"] = [_spawn_x_spin.value, _spawn_y_spin.value, _spawn_z_spin.value]
|
||||
|
||||
var new_rule: Dictionary = {
|
||||
"trigger": trig_dict,
|
||||
"conditions": [],
|
||||
"actions": [act_dict]
|
||||
}
|
||||
|
||||
var cur_behavior: Dictionary = {}
|
||||
var raw_b = obj.properties.get("script_behavior", obj.properties.get("behavior"))
|
||||
if raw_b is Dictionary:
|
||||
cur_behavior = (raw_b as Dictionary).duplicate(true)
|
||||
|
||||
var cur_rules: Array = cur_behavior.get("rules", []).duplicate(true)
|
||||
cur_rules.append(new_rule)
|
||||
cur_behavior["rules"] = cur_rules
|
||||
|
||||
_core.set_object_behavior(_object_id, cur_behavior)
|
||||
_rule_form_box.visible = false
|
||||
_add_rule_btn.visible = true
|
||||
_refresh_rules()
|
||||
|
||||
|
||||
func _on_delete_rule_pressed(index: int) -> void:
|
||||
if _core == null or _object_id == "":
|
||||
return
|
||||
|
||||
var obj := _core.model.find_object(_object_id)
|
||||
if obj == null:
|
||||
return
|
||||
|
||||
var raw_b = obj.properties.get("script_behavior", obj.properties.get("behavior"))
|
||||
if not (raw_b is Dictionary):
|
||||
return
|
||||
|
||||
var cur_behavior: Dictionary = (raw_b as Dictionary).duplicate(true)
|
||||
var cur_rules: Array = cur_behavior.get("rules", []).duplicate(true)
|
||||
if index >= 0 and index < cur_rules.size():
|
||||
cur_rules.remove_at(index)
|
||||
cur_behavior["rules"] = cur_rules
|
||||
_core.set_object_behavior(_object_id, cur_behavior)
|
||||
_refresh_rules()
|
||||
|
||||
|
||||
func _on_position_changed(_v: float) -> void:
|
||||
if _updating or _core == null or _object_id == "":
|
||||
|
||||
@@ -4,13 +4,26 @@ extends VBoxContainer
|
||||
## `PalettePanel`). Lists every id in an `EditorObjectCatalog` and emits
|
||||
## which one the player picked; `InGameEditor` remembers it as the pending
|
||||
## `catalog_id` for the next `ObjectPlaceTool` placement.
|
||||
##
|
||||
## Creator Studio feature: In-game .glb/.gltf 3D Model Importer.
|
||||
## Creators can import custom 3D models from disk, validate them via GLTFDocument,
|
||||
## and place them directly into their worlds.
|
||||
|
||||
signal object_selected(catalog_id: String)
|
||||
signal model_imported(catalog_id: String)
|
||||
|
||||
var _list: ItemList
|
||||
var _catalog: EditorObjectCatalog = null
|
||||
var _ids: Array[String] = []
|
||||
|
||||
var _btn_import_model: Button
|
||||
var _import_panel: VBoxContainer
|
||||
var _model_name_edit: LineEdit
|
||||
var _model_path_edit: LineEdit
|
||||
var _btn_browse: Button
|
||||
var _file_dialog: FileDialog
|
||||
var _import_status_label: Label
|
||||
|
||||
|
||||
func _init() -> void:
|
||||
name = "PalettePanel"
|
||||
@@ -33,10 +46,82 @@ func _ready() -> void:
|
||||
|
||||
_list = ItemList.new()
|
||||
_list.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
||||
_list.custom_minimum_size = Vector2(0, 180)
|
||||
_list.custom_minimum_size = Vector2(0, 160)
|
||||
_list.item_selected.connect(_on_item_selected)
|
||||
add_child(_list)
|
||||
|
||||
_setup_import_ui()
|
||||
|
||||
|
||||
func _setup_import_ui() -> void:
|
||||
_btn_import_model = Button.new()
|
||||
_btn_import_model.text = "+ Import .glb Model"
|
||||
_btn_import_model.add_theme_color_override("font_color", Color(0.35, 0.85, 1.0))
|
||||
_btn_import_model.pressed.connect(_on_import_button_pressed)
|
||||
add_child(_btn_import_model)
|
||||
|
||||
_import_panel = VBoxContainer.new()
|
||||
_import_panel.visible = false
|
||||
add_child(_import_panel)
|
||||
|
||||
var title := Label.new()
|
||||
title.text = "Import Custom Model (.glb)"
|
||||
title.add_theme_font_size_override("font_size", 12)
|
||||
title.add_theme_color_override("font_color", Color(1.0, 0.9, 0.4))
|
||||
_import_panel.add_child(title)
|
||||
|
||||
var name_row := HBoxContainer.new()
|
||||
var name_lbl := Label.new()
|
||||
name_lbl.text = "Name:"
|
||||
name_lbl.custom_minimum_size = Vector2(50, 0)
|
||||
name_row.add_child(name_lbl)
|
||||
_model_name_edit = LineEdit.new()
|
||||
_model_name_edit.placeholder_text = "e.g. Vintage Chair"
|
||||
_model_name_edit.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
name_row.add_child(_model_name_edit)
|
||||
_import_panel.add_child(name_row)
|
||||
|
||||
var path_row := HBoxContainer.new()
|
||||
var path_lbl := Label.new()
|
||||
path_lbl.text = "Path:"
|
||||
path_lbl.custom_minimum_size = Vector2(50, 0)
|
||||
path_row.add_child(path_lbl)
|
||||
_model_path_edit = LineEdit.new()
|
||||
_model_path_edit.placeholder_text = "Path to .glb or .gltf"
|
||||
_model_path_edit.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
path_row.add_child(_model_path_edit)
|
||||
|
||||
_btn_browse = Button.new()
|
||||
_btn_browse.text = "..."
|
||||
_btn_browse.pressed.connect(_on_browse_pressed)
|
||||
path_row.add_child(_btn_browse)
|
||||
_import_panel.add_child(path_row)
|
||||
|
||||
_import_status_label = Label.new()
|
||||
_import_status_label.text = ""
|
||||
_import_status_label.add_theme_font_size_override("font_size", 10)
|
||||
_import_panel.add_child(_import_status_label)
|
||||
|
||||
var btn_row := HBoxContainer.new()
|
||||
var do_import_btn := Button.new()
|
||||
do_import_btn.text = "Import"
|
||||
do_import_btn.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
do_import_btn.pressed.connect(_on_do_import_pressed)
|
||||
btn_row.add_child(do_import_btn)
|
||||
|
||||
var cancel_btn := Button.new()
|
||||
cancel_btn.text = "Cancel"
|
||||
cancel_btn.pressed.connect(_on_cancel_import_pressed)
|
||||
btn_row.add_child(cancel_btn)
|
||||
_import_panel.add_child(btn_row)
|
||||
|
||||
_file_dialog = FileDialog.new()
|
||||
_file_dialog.file_mode = FileDialog.FILE_MODE_OPEN_FILE
|
||||
_file_dialog.access = FileDialog.ACCESS_FILESYSTEM
|
||||
_file_dialog.filters = ["*.glb ; GLTF Binary Files", "*.gltf ; GLTF Scene Files"]
|
||||
_file_dialog.file_selected.connect(_on_file_selected)
|
||||
add_child(_file_dialog)
|
||||
|
||||
|
||||
func set_catalog(catalog: EditorObjectCatalog) -> void:
|
||||
_catalog = catalog
|
||||
@@ -51,6 +136,8 @@ func _refresh() -> void:
|
||||
for id in _catalog.get_all_ids():
|
||||
var entry := _catalog.resolve(id)
|
||||
var label: String = entry.get("display_name", id)
|
||||
if entry.get("category", "") == "custom":
|
||||
label = "★ " + label
|
||||
_list.add_item(label)
|
||||
_ids.append(id)
|
||||
|
||||
@@ -66,3 +153,104 @@ func get_selected_catalog_id() -> String:
|
||||
if selected.is_empty():
|
||||
return ""
|
||||
return _ids[selected[0]]
|
||||
|
||||
|
||||
func _on_import_button_pressed() -> void:
|
||||
_import_panel.visible = true
|
||||
_btn_import_model.visible = false
|
||||
_import_status_label.text = ""
|
||||
|
||||
|
||||
func _on_cancel_import_pressed() -> void:
|
||||
_import_panel.visible = false
|
||||
_btn_import_model.visible = true
|
||||
|
||||
|
||||
func _on_browse_pressed() -> void:
|
||||
_file_dialog.popup_centered(Vector2i(600, 400))
|
||||
|
||||
|
||||
func _on_file_selected(path: String) -> void:
|
||||
_model_path_edit.text = path
|
||||
if _model_name_edit.text.strip_edges().is_empty():
|
||||
_model_name_edit.text = path.get_file().get_basename().capitalize()
|
||||
|
||||
|
||||
func _on_do_import_pressed() -> void:
|
||||
var path := _model_path_edit.text.strip_edges()
|
||||
var display_name := _model_name_edit.text.strip_edges()
|
||||
if path.is_empty():
|
||||
_import_status_label.text = "Error: Please select a file path."
|
||||
_import_status_label.add_theme_color_override("font_color", Color(1.0, 0.4, 0.4))
|
||||
return
|
||||
if display_name.is_empty():
|
||||
display_name = path.get_file().get_basename().capitalize()
|
||||
|
||||
var cat_id := import_model_file(path, display_name)
|
||||
if cat_id.is_empty():
|
||||
_import_status_label.text = "Error: Failed to import/validate model."
|
||||
_import_status_label.add_theme_color_override("font_color", Color(1.0, 0.4, 0.4))
|
||||
return
|
||||
|
||||
_import_panel.visible = false
|
||||
_btn_import_model.visible = true
|
||||
_model_name_edit.text = ""
|
||||
_model_path_edit.text = ""
|
||||
|
||||
|
||||
## Import a 3D model (.glb/.gltf), validate it, copy to user custom assets,
|
||||
## and register it into the active EditorObjectCatalog.
|
||||
func import_model_file(file_path: String, display_name: String, category: String = "custom") -> String:
|
||||
if not FileAccess.file_exists(file_path):
|
||||
push_error("PalettePanel: File does not exist: %s" % file_path)
|
||||
return ""
|
||||
|
||||
# Validate model using GLTFDocument
|
||||
var gltf := GLTFDocument.new()
|
||||
var state := GLTFState.new()
|
||||
var err := gltf.append_from_file(file_path, state)
|
||||
if err != OK:
|
||||
push_error("PalettePanel: GLTFDocument failed to parse file (%d): %s" % [err, file_path])
|
||||
return ""
|
||||
|
||||
# Copy to user custom assets directory
|
||||
var dir := DirAccess.open("user://")
|
||||
if dir != null:
|
||||
if not dir.dir_exists("custom_assets"):
|
||||
dir.make_dir("custom_assets")
|
||||
|
||||
var filename := file_path.get_file()
|
||||
var target_path := "user://custom_assets/%s" % filename
|
||||
var copy_err := DirAccess.copy_absolute(file_path, target_path)
|
||||
if copy_err != OK:
|
||||
# If direct copy failed, use original path
|
||||
target_path = file_path
|
||||
|
||||
# Generate clean unique catalog id
|
||||
var clean_slug := display_name.to_lower().replace(" ", "_").replace("-", "_")
|
||||
var regex := RegEx.new()
|
||||
regex.compile("[^a-z0-9_]")
|
||||
clean_slug = regex.sub(clean_slug, "", true)
|
||||
if clean_slug.is_empty():
|
||||
clean_slug = "model"
|
||||
var cat_id := "custom_%s" % clean_slug
|
||||
|
||||
var catalog_entry: Dictionary = {
|
||||
"asset_id": target_path,
|
||||
"type": "prop",
|
||||
"display_name": display_name,
|
||||
"category": category
|
||||
}
|
||||
|
||||
if _catalog != null:
|
||||
_catalog.register_custom_object(cat_id, catalog_entry)
|
||||
_refresh()
|
||||
# Select the newly imported model
|
||||
for i in range(_ids.size()):
|
||||
if _ids[i] == cat_id:
|
||||
_list.select(i)
|
||||
object_selected.emit(cat_id)
|
||||
break
|
||||
|
||||
model_imported.emit(cat_id)
|
||||
return cat_id
|
||||
|
||||
+57
-2
@@ -35,9 +35,12 @@ var _status_label: Label = null
|
||||
## enters 'build mode'... so the runtime footprint is negligible during
|
||||
## normal play"). Editing is in-memory only here; the save path to the
|
||||
## backend is a sibling task (world_persistence_impl.md).
|
||||
const ReportModal = preload("res://scripts/report_modal.gd")
|
||||
|
||||
var _build_editor: InGameEditor = null
|
||||
var _chat_overlay: Control = null
|
||||
var _friends_overlay: Control = null
|
||||
var _report_modal: Control = null
|
||||
var _hud_layer: CanvasLayer = null
|
||||
var _overlay_layer: CanvasLayer = null
|
||||
|
||||
@@ -194,7 +197,9 @@ func _unhandled_input(event: InputEvent) -> void:
|
||||
# ESC leaves the world back to the browser (or closes an open overlay/editor).
|
||||
if event.is_action_pressed("ui_cancel"):
|
||||
get_viewport().set_input_as_handled()
|
||||
if _chat_overlay != null and is_instance_valid(_chat_overlay):
|
||||
if _report_modal != null and is_instance_valid(_report_modal):
|
||||
_close_report_modal()
|
||||
elif _chat_overlay != null and is_instance_valid(_chat_overlay):
|
||||
_close_chat_overlay()
|
||||
elif _friends_overlay != null and is_instance_valid(_friends_overlay):
|
||||
_close_friends_overlay()
|
||||
@@ -255,10 +260,17 @@ func _setup_in_game_hud() -> void:
|
||||
build_btn.pressed.connect(_toggle_build_mode)
|
||||
bar.add_child(build_btn)
|
||||
|
||||
var report_btn := Button.new()
|
||||
report_btn.text = "🚩 Report"
|
||||
report_btn.pressed.connect(_toggle_report_modal)
|
||||
bar.add_child(report_btn)
|
||||
|
||||
var exit_btn := Button.new()
|
||||
exit_btn.text = "🚪 Menu [ESC]"
|
||||
exit_btn.pressed.connect(func():
|
||||
if _chat_overlay != null and is_instance_valid(_chat_overlay):
|
||||
if _report_modal != null and is_instance_valid(_report_modal):
|
||||
_close_report_modal()
|
||||
elif _chat_overlay != null and is_instance_valid(_chat_overlay):
|
||||
_close_chat_overlay()
|
||||
elif _friends_overlay != null and is_instance_valid(_friends_overlay):
|
||||
_close_friends_overlay()
|
||||
@@ -270,6 +282,49 @@ func _setup_in_game_hud() -> void:
|
||||
bar.add_child(exit_btn)
|
||||
|
||||
|
||||
func _toggle_report_modal() -> void:
|
||||
if _report_modal != null and is_instance_valid(_report_modal):
|
||||
_close_report_modal()
|
||||
else:
|
||||
_open_report_modal()
|
||||
|
||||
|
||||
func _open_report_modal() -> void:
|
||||
if _chat_overlay != null and is_instance_valid(_chat_overlay):
|
||||
_close_chat_overlay()
|
||||
if _friends_overlay != null and is_instance_valid(_friends_overlay):
|
||||
_close_friends_overlay()
|
||||
if _build_editor != null and is_instance_valid(_build_editor):
|
||||
_close_build_mode()
|
||||
|
||||
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
|
||||
_player.set_physics_process(false)
|
||||
_player.set_process_unhandled_input(false)
|
||||
|
||||
_report_modal = ReportModal.new()
|
||||
var wid: int = int(Session.world_id) if Session.world_id != null else 0
|
||||
_report_modal.setup_report("world", wid, "Current World" if wid > 0 else "Play Session")
|
||||
_report_modal.closed.connect(_close_report_modal)
|
||||
_report_modal.report_submitted.connect(func(_type, _id):
|
||||
pass
|
||||
)
|
||||
_report_modal.tree_exited.connect(func():
|
||||
if _report_modal != null:
|
||||
_close_report_modal()
|
||||
)
|
||||
add_child(_report_modal)
|
||||
|
||||
|
||||
func _close_report_modal() -> void:
|
||||
if _report_modal != null and is_instance_valid(_report_modal):
|
||||
var m := _report_modal
|
||||
_report_modal = null
|
||||
m.queue_free()
|
||||
_player.set_physics_process(true)
|
||||
_player.set_process_unhandled_input(true)
|
||||
Input.mouse_mode = Input.MOUSE_MODE_CAPTURED
|
||||
|
||||
|
||||
func _toggle_chat_overlay() -> void:
|
||||
if _chat_overlay != null and is_instance_valid(_chat_overlay):
|
||||
_close_chat_overlay()
|
||||
|
||||
+12
-23
@@ -35,7 +35,7 @@ var _auth_mode_label: Label
|
||||
var _username_edit: LineEdit
|
||||
var _password_edit: LineEdit
|
||||
var _email_edit: LineEdit
|
||||
var _dob_edit: LineEdit
|
||||
var _over_18_check: CheckBox
|
||||
var _auth_button: Button
|
||||
var _status_label: Label
|
||||
var _welcome_label: Label
|
||||
@@ -96,7 +96,7 @@ func _ready() -> void:
|
||||
_username_edit = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/UsernameEdit
|
||||
_password_edit = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/PasswordEdit
|
||||
_email_edit = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/EmailEdit
|
||||
_dob_edit = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/DobEdit
|
||||
_over_18_check = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/Over18Check
|
||||
_auth_button = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/AuthButton
|
||||
_status_label = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/AuthStatusLabel
|
||||
_welcome_label = $CenterContainer/VBoxContainer/AuthPanel/MarginContainer/AuthVBox/WelcomeLabel
|
||||
@@ -168,7 +168,7 @@ func _show_authenticated(username: String) -> void:
|
||||
_username_edit.visible = false
|
||||
_password_edit.visible = false
|
||||
_email_edit.visible = false
|
||||
_dob_edit.visible = false
|
||||
_over_18_check.visible = false
|
||||
_auth_button.visible = false
|
||||
_auth_mode_label.visible = false
|
||||
_set_nav_enabled(true)
|
||||
@@ -183,8 +183,8 @@ func _reset_to_logged_out() -> void:
|
||||
_status_label.visible = false
|
||||
_welcome_label.visible = false
|
||||
_email_edit.visible = false
|
||||
_dob_edit.visible = false
|
||||
_dob_edit.text = ""
|
||||
_over_18_check.visible = false
|
||||
_over_18_check.button_pressed = true
|
||||
_set_nav_enabled(false)
|
||||
_update_auth_mode_ui()
|
||||
|
||||
@@ -249,17 +249,7 @@ func _on_quit_button_pressed() -> void:
|
||||
get_tree().quit()
|
||||
|
||||
|
||||
## Loose client-side check -- YYYY-MM-DD, a real calendar date, not in the
|
||||
## future. The backend does the authoritative age computation.
|
||||
func _is_valid_dob(dob: String) -> bool:
|
||||
var m := RegEx.new()
|
||||
m.compile("^\\d{4}-\\d{2}-\\d{2}$")
|
||||
if m.search(dob) == null:
|
||||
return false
|
||||
var parts := dob.split("-")
|
||||
var d := {"year": int(parts[0]), "month": int(parts[1]), "day": int(parts[2]), "hour": 0, "minute": 0, "second": 0}
|
||||
var unix := Time.get_unix_time_from_datetime_dict(d)
|
||||
return unix > 0 and unix < Time.get_unix_time_from_system()
|
||||
|
||||
|
||||
|
||||
# ─── Auth panel toggle ────────────────────────────────────────────────────────
|
||||
@@ -288,12 +278,12 @@ func _update_auth_mode_ui() -> void:
|
||||
_auth_mode_label.text = "Register"
|
||||
_auth_button.text = "Register"
|
||||
_email_edit.visible = true
|
||||
_dob_edit.visible = true
|
||||
_over_18_check.visible = true
|
||||
else:
|
||||
_auth_mode_label.text = "Login"
|
||||
_auth_button.text = "Login"
|
||||
_email_edit.visible = false
|
||||
_dob_edit.visible = false
|
||||
_over_18_check.visible = false
|
||||
|
||||
|
||||
# ─── Auth submission ──────────────────────────────────────────────────────────
|
||||
@@ -320,9 +310,8 @@ func _on_auth_button_pressed() -> void:
|
||||
if email == "":
|
||||
_show_status("Email is required.")
|
||||
return
|
||||
var dob := _dob_edit.text.strip_edges()
|
||||
if not _is_valid_dob(dob):
|
||||
_show_status("Enter your date of birth as YYYY-MM-DD.")
|
||||
if not _over_18_check.button_pressed:
|
||||
_show_status("You must confirm you are 18 or older to register.")
|
||||
return
|
||||
|
||||
_auth_button.disabled = true
|
||||
@@ -330,7 +319,7 @@ func _on_auth_button_pressed() -> void:
|
||||
_is_authenticating = true
|
||||
_clear_status()
|
||||
|
||||
var result := await ApiClient.register(username, email, password, dob)
|
||||
var result := await ApiClient.register(username, email, password, true)
|
||||
await _on_auth_complete(result)
|
||||
else:
|
||||
_auth_button.disabled = true
|
||||
@@ -356,7 +345,7 @@ func _on_auth_complete(result: Dictionary) -> void:
|
||||
_username_edit.visible = false
|
||||
_password_edit.visible = false
|
||||
_email_edit.visible = false
|
||||
_dob_edit.visible = false
|
||||
_over_18_check.visible = false
|
||||
_auth_button.visible = false
|
||||
_auth_mode_label.visible = false
|
||||
_status_label.visible = false
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
class_name ReportModal
|
||||
extends Control
|
||||
## In-game reporting modal (Trust & Safety).
|
||||
##
|
||||
## Allows players to submit moderation reports for worlds, players, or messages.
|
||||
|
||||
signal report_submitted(target_type: String, target_id: int)
|
||||
signal closed
|
||||
|
||||
var target_type: String = "world"
|
||||
var target_id: int = 0
|
||||
var target_name: String = ""
|
||||
var api_client: Node = null
|
||||
|
||||
var _category_option: OptionButton
|
||||
var _details_edit: TextEdit
|
||||
var _status_label: Label
|
||||
var _submit_button: Button
|
||||
var _cancel_button: Button
|
||||
|
||||
|
||||
func _init() -> void:
|
||||
name = "ReportModal"
|
||||
set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||
|
||||
|
||||
func setup_report(p_type: String, p_id: int, p_name: String) -> void:
|
||||
target_type = p_type
|
||||
target_id = p_id
|
||||
target_name = p_name
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
mouse_filter = Control.MOUSE_FILTER_STOP
|
||||
|
||||
var overlay := ColorRect.new()
|
||||
overlay.color = Color(0, 0, 0, 0.8)
|
||||
overlay.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||
add_child(overlay)
|
||||
|
||||
var center := CenterContainer.new()
|
||||
center.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||
add_child(center)
|
||||
|
||||
var panel := PanelContainer.new()
|
||||
panel.custom_minimum_size = Vector2(460, 0)
|
||||
var style := StyleBoxFlat.new()
|
||||
style.bg_color = Color(0.16, 0.16, 0.2, 1.0)
|
||||
style.border_color = Color(0.9, 0.35, 0.35, 1.0)
|
||||
style.set_border_width_all(2)
|
||||
style.set_corner_radius_all(10)
|
||||
style.content_margin_left = 20
|
||||
style.content_margin_right = 20
|
||||
style.content_margin_top = 18
|
||||
style.content_margin_bottom = 18
|
||||
panel.add_theme_stylebox_override("panel", style)
|
||||
center.add_child(panel)
|
||||
|
||||
var vbox := VBoxContainer.new()
|
||||
vbox.add_theme_constant_override("separation", 10)
|
||||
panel.add_child(vbox)
|
||||
|
||||
var title := Label.new()
|
||||
title.text = "🚩 Submit Report"
|
||||
title.add_theme_font_size_override("font_size", 18)
|
||||
title.add_theme_color_override("font_color", Color(1.0, 0.5, 0.5))
|
||||
title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
vbox.add_child(title)
|
||||
|
||||
var target_lbl := Label.new()
|
||||
target_lbl.name = "TargetLabel"
|
||||
target_lbl.text = "Reporting %s: \"%s\" (ID: %d)" % [target_type.capitalize(), target_name, target_id]
|
||||
target_lbl.add_theme_font_size_override("font_size", 12)
|
||||
target_lbl.add_theme_color_override("font_color", Color(0.8, 0.8, 0.8))
|
||||
vbox.add_child(target_lbl)
|
||||
|
||||
var cat_lbl := Label.new()
|
||||
cat_lbl.text = "Category:"
|
||||
cat_lbl.add_theme_font_size_override("font_size", 12)
|
||||
vbox.add_child(cat_lbl)
|
||||
|
||||
_category_option = OptionButton.new()
|
||||
_category_option.name = "CategoryOption"
|
||||
_category_option.add_item("Inappropriate Content / Behavior", 0)
|
||||
_category_option.set_item_metadata(0, "inappropriate_content")
|
||||
_category_option.add_item("Harassment / Toxicity", 1)
|
||||
_category_option.set_item_metadata(1, "harassment")
|
||||
_category_option.add_item("World Rules Violation", 2)
|
||||
_category_option.set_item_metadata(2, "rules_violation")
|
||||
_category_option.add_item("Misleading Tags / Content", 3)
|
||||
_category_option.set_item_metadata(3, "misleading")
|
||||
_category_option.add_item("Spam / Exploiting", 4)
|
||||
_category_option.set_item_metadata(4, "spam")
|
||||
_category_option.add_item("Other Safety Concern", 5)
|
||||
_category_option.set_item_metadata(5, "other")
|
||||
vbox.add_child(_category_option)
|
||||
|
||||
var det_lbl := Label.new()
|
||||
det_lbl.text = "Details / Explanation:"
|
||||
det_lbl.add_theme_font_size_override("font_size", 12)
|
||||
vbox.add_child(det_lbl)
|
||||
|
||||
_details_edit = TextEdit.new()
|
||||
_details_edit.name = "DetailsEdit"
|
||||
_details_edit.custom_minimum_size = Vector2(0, 80)
|
||||
_details_edit.placeholder_text = "Please describe what happened..."
|
||||
vbox.add_child(_details_edit)
|
||||
|
||||
_status_label = Label.new()
|
||||
_status_label.name = "StatusLabel"
|
||||
_status_label.text = ""
|
||||
_status_label.add_theme_font_size_override("font_size", 11)
|
||||
vbox.add_child(_status_label)
|
||||
|
||||
var btn_row := HBoxContainer.new()
|
||||
btn_row.alignment = BoxContainer.ALIGNMENT_END
|
||||
btn_row.add_theme_constant_override("separation", 10)
|
||||
|
||||
_cancel_button = Button.new()
|
||||
_cancel_button.name = "CancelButton"
|
||||
_cancel_button.text = "Cancel"
|
||||
_cancel_button.pressed.connect(_on_cancel_pressed)
|
||||
btn_row.add_child(_cancel_button)
|
||||
|
||||
_submit_button = Button.new()
|
||||
_submit_button.name = "SubmitButton"
|
||||
_submit_button.text = "Submit Report"
|
||||
_submit_button.add_theme_color_override("font_color", Color(1.0, 0.4, 0.4))
|
||||
_submit_button.pressed.connect(_on_submit_pressed)
|
||||
btn_row.add_child(_submit_button)
|
||||
|
||||
vbox.add_child(btn_row)
|
||||
|
||||
|
||||
func _on_cancel_pressed() -> void:
|
||||
closed.emit()
|
||||
queue_free()
|
||||
|
||||
|
||||
func _on_submit_pressed() -> void:
|
||||
var details := _details_edit.text.strip_edges()
|
||||
if details.is_empty():
|
||||
_status_label.text = "Please provide details explaining your report."
|
||||
_status_label.add_theme_color_override("font_color", Color(1.0, 0.4, 0.4))
|
||||
return
|
||||
|
||||
var cat_meta: String = _category_option.get_item_metadata(_category_option.selected)
|
||||
_submit_button.disabled = true
|
||||
_cancel_button.disabled = true
|
||||
_status_label.text = "Submitting report..."
|
||||
_status_label.add_theme_color_override("font_color", Color(0.8, 0.8, 0.8))
|
||||
|
||||
var api: Node = api_client
|
||||
if api == null and has_node("/root/ApiClient"):
|
||||
api = get_node("/root/ApiClient")
|
||||
|
||||
if api != null:
|
||||
var res: Dictionary = await api.submit_moderation_report(target_type, target_id, details, cat_meta)
|
||||
if res.get("ok", false) or res.get("status", 0) == 201:
|
||||
_status_label.text = "Report submitted to moderation queue. Thank you."
|
||||
_status_label.add_theme_color_override("font_color", Color(0.4, 1.0, 0.5))
|
||||
report_submitted.emit(target_type, target_id)
|
||||
await get_tree().create_timer(1.2).timeout
|
||||
queue_free()
|
||||
return
|
||||
else:
|
||||
_status_label.text = "Failed to submit: %s" % str(res.get("detail", "Error"))
|
||||
_status_label.add_theme_color_override("font_color", Color(1.0, 0.4, 0.4))
|
||||
_submit_button.disabled = false
|
||||
_cancel_button.disabled = false
|
||||
else:
|
||||
_status_label.text = "Report recorded locally."
|
||||
_status_label.add_theme_color_override("font_color", Color(0.4, 1.0, 0.5))
|
||||
report_submitted.emit(target_type, target_id)
|
||||
await get_tree().create_timer(1.0).timeout
|
||||
queue_free()
|
||||
@@ -62,15 +62,27 @@ def main():
|
||||
"override if that's already in use on this machine)")
|
||||
parser.add_argument("--backend-port", default=os.environ.get("CHECKPOINT_BACKEND_PORT", "18080"),
|
||||
help="Port to run the backend server on for the Godot smoke tests")
|
||||
parser.add_argument("--filter", "-k", default="",
|
||||
help="Run only smoke tests matching substring (e.g. -k creator or -k safe)")
|
||||
parser.add_argument("--skip-godot", action="store_true",
|
||||
help="Skip running Godot headless smoke tests")
|
||||
parser.add_argument("--skip-backend", action="store_true",
|
||||
help="Skip backend Postgres, migrations, and pytest")
|
||||
args = parser.parse_args()
|
||||
|
||||
results = {}
|
||||
|
||||
db_url = f"postgresql+asyncpg://agp_user:agp_password@localhost:{args.postgres_port}/agp_db"
|
||||
backend_env = os.environ.copy()
|
||||
backend_env["DATABASE_URL"] = db_url
|
||||
backend_env["RATE_LIMIT_ENABLED"] = "false"
|
||||
|
||||
if not args.skip_backend:
|
||||
# --- Backend: bring up Postgres ---
|
||||
compose_env = os.environ.copy()
|
||||
compose_env["COMPOSE_PROJECT_NAME"] = "agp-checkpoint"
|
||||
compose_env["POSTGRES_HOST_PORT"] = args.postgres_port
|
||||
compose_env["POSTGRES_PORT"] = args.postgres_port # some branches use this name instead, see tools/definitions.py
|
||||
compose_env["POSTGRES_PORT"] = args.postgres_port
|
||||
print("=== Backend: bringing up Postgres ===")
|
||||
run(["docker", "compose", "up", "-d", "postgres"], cwd=BACKEND, env=compose_env)
|
||||
|
||||
@@ -87,14 +99,6 @@ def main():
|
||||
print("Postgres never became ready -- aborting.")
|
||||
sys.exit(1)
|
||||
|
||||
db_url = f"postgresql+asyncpg://agp_user:agp_password@localhost:{args.postgres_port}/agp_db"
|
||||
backend_env = os.environ.copy()
|
||||
backend_env["DATABASE_URL"] = db_url
|
||||
# Every smoke test registers users against this one backend from one
|
||||
# client IP; the per-IP auth rate limit would trip mid-suite.
|
||||
# test_rate_limit.py exercises the limiter itself regardless.
|
||||
backend_env["RATE_LIMIT_ENABLED"] = "false"
|
||||
|
||||
print("\n=== Backend: alembic upgrade head ===")
|
||||
r = run([sys.executable, "-m", "alembic", "upgrade", "head"], cwd=BACKEND, env=backend_env)
|
||||
results["migrations (fresh DB)"] = r.returncode == 0
|
||||
@@ -102,10 +106,18 @@ def main():
|
||||
print("\n=== Backend: pytest ===")
|
||||
r = run([sys.executable, "-m", "pytest", "-v"], cwd=BACKEND, env=backend_env)
|
||||
results["backend pytest"] = r.returncode == 0
|
||||
else:
|
||||
print("\n=== Skipping Backend tests (--skip-backend) ===")
|
||||
|
||||
# --- Godot: headless smoke tests (need a live backend server) ---
|
||||
godot_bin = find_godot()
|
||||
if args.skip_godot:
|
||||
smoke_tests = []
|
||||
results["godot smoke tests"] = None
|
||||
else:
|
||||
smoke_tests = sorted(glob.glob(str(ROOT / "scripts" / "smoke_test_*.gd")))
|
||||
if args.filter:
|
||||
smoke_tests = [t for t in smoke_tests if args.filter.lower() in os.path.basename(t).lower()]
|
||||
|
||||
if not godot_bin:
|
||||
print("\n=== Godot: binary not found (checked PATH and ~/godot/godot), skipping smoke tests ===")
|
||||
@@ -161,8 +173,8 @@ def main():
|
||||
game_server.kill()
|
||||
results["godot smoke tests"] = godot_ok
|
||||
|
||||
# 2-player multiplayer test (its own shell harness: server + 2
|
||||
# real client processes).
|
||||
# Shell harnesses and export verification (only run for full checkpoint runs, not when filtering)
|
||||
if not args.filter:
|
||||
mp_script = ROOT / "scripts" / "run_multiplayer_2p_test.sh"
|
||||
if mp_script.exists():
|
||||
print("\n--- scripts/run_multiplayer_2p_test.sh ---")
|
||||
@@ -171,8 +183,6 @@ def main():
|
||||
r = run(["bash", str(mp_script)], env=mp_env)
|
||||
results["multiplayer 2p test"] = r.returncode == 0
|
||||
|
||||
# Fresh-clone boot: a from-scratch tree must import + boot with
|
||||
# zero script errors (no reliance on a committed .godot cache).
|
||||
fc_script = ROOT / "scripts" / "verify_fresh_clone.sh"
|
||||
if fc_script.exists():
|
||||
print("\n--- scripts/verify_fresh_clone.sh ---")
|
||||
@@ -181,10 +191,6 @@ def main():
|
||||
r = run(["bash", str(fc_script)], env=fc_env)
|
||||
results["fresh-clone boot"] = r.returncode == 0
|
||||
|
||||
# Export packaging: the game reads some files (catalog.json)
|
||||
# via raw FileAccess, which `all_resources` export doesn't
|
||||
# pack unless include_filter covers them. Passes from source,
|
||||
# bare capsule in the .exe. This guards that.
|
||||
ec_script = ROOT / "scripts" / "verify_export_contents.gd"
|
||||
if ec_script.exists():
|
||||
print("\n--- scripts/verify_export_contents.gd ---")
|
||||
@@ -194,9 +200,9 @@ def main():
|
||||
|
||||
# Run a key smoke against the EXPORTED pack, not just source --
|
||||
# release-build resources differ (e.g. the runtime GLTFDocument
|
||||
# path produced no skeleton in a real export; load().instantiate()
|
||||
# of the imported .scn does). This runs the avatar smoke on
|
||||
# loads models via raw FileAccess, which can fail in .pck). Tests
|
||||
# exactly what ships.
|
||||
if not args.filter:
|
||||
exported_pck = ROOT / "export" / "_ci_check.pck"
|
||||
exported_pck.parent.mkdir(exist_ok=True)
|
||||
ep = run([godot_bin, "--headless", "--path", str(ROOT),
|
||||
@@ -215,7 +221,7 @@ def main():
|
||||
except subprocess.TimeoutExpired:
|
||||
server_proc.kill()
|
||||
|
||||
if not args.keep_postgres:
|
||||
if not args.skip_backend and not args.keep_postgres:
|
||||
print("\n=== Backend: tearing down checkpoint Postgres ===")
|
||||
run(["docker", "compose", "down", "-v", "postgres"], cwd=BACKEND, env=compose_env)
|
||||
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
extends SceneTree
|
||||
## Headless smoke test for Creator Studio In-Game Model (.glb) Importer
|
||||
|
||||
const FIXTURE_PATH := "res://backend/tests/fixtures/chunks/simple_tile_chunk.bin"
|
||||
const TEST_GLB_PATH := "res://assets/avatars/back_backpack_01.glb"
|
||||
const ChunkEditorModelClass := preload("res://scripts/editor/chunk_editor_model.gd")
|
||||
const EditorObjectCatalogClass := preload("res://scripts/editor/object_catalog.gd")
|
||||
const EditorCoreClass := preload("res://scripts/editor/editor_core.gd")
|
||||
const PalettePanelClass := preload("res://scripts/editor/ui/palette_panel.gd")
|
||||
const StaticObjectNodeClass := preload("res://scripts/static_object_node.gd")
|
||||
const WorldObjectClass := preload("res://scripts/world_object.gd")
|
||||
|
||||
var passed := 0
|
||||
var failed := 0
|
||||
|
||||
func _check(label: String, cond: bool) -> void:
|
||||
if cond:
|
||||
passed += 1
|
||||
print(" PASS: ", label)
|
||||
else:
|
||||
failed += 1
|
||||
printerr(" FAIL: ", label)
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
await _run()
|
||||
|
||||
|
||||
func _read_fixture() -> PackedByteArray:
|
||||
var f := FileAccess.open(FIXTURE_PATH, FileAccess.READ)
|
||||
if f == null:
|
||||
push_error("Could not open fixture: " + FIXTURE_PATH)
|
||||
return PackedByteArray()
|
||||
return f.get_buffer(f.get_length())
|
||||
|
||||
|
||||
func _run() -> void:
|
||||
print("=== Running Creator Studio Model Importer Smoke Tests ===")
|
||||
var root := Node.new()
|
||||
root.name = "TestRoot"
|
||||
get_root().add_child(root)
|
||||
|
||||
var fixture_bytes := _read_fixture()
|
||||
_check("Loaded fixture chunk bytes", fixture_bytes.size() > 0)
|
||||
var model := ChunkEditorModelClass.from_binary(fixture_bytes)
|
||||
_check("Decoded ChunkEditorModel", model != null)
|
||||
|
||||
var catalog := EditorObjectCatalogClass.new()
|
||||
var cat_err := catalog.load_catalog()
|
||||
_check("Loaded EditorObjectCatalog", cat_err == OK)
|
||||
|
||||
var core := EditorCoreClass.new(model, catalog)
|
||||
_check("Initialized EditorCore", core != null)
|
||||
|
||||
# 1. Test PalettePanel setup
|
||||
var palette := PalettePanelClass.new()
|
||||
root.add_child(palette)
|
||||
await process_frame
|
||||
palette.set_catalog(catalog)
|
||||
_check("PalettePanel configured with catalog", true)
|
||||
|
||||
# 2. Test importing a .glb model into PalettePanel
|
||||
_check("Source .glb exists on disk", FileAccess.file_exists(TEST_GLB_PATH))
|
||||
var cat_id := palette.import_model_file(TEST_GLB_PATH, "Adventure Backpack", "props")
|
||||
_check("Custom model imported and returned catalog ID", not cat_id.is_empty())
|
||||
_check("Catalog now has custom object", catalog.has_object(cat_id))
|
||||
|
||||
var entry := catalog.resolve(cat_id)
|
||||
_check("Custom entry has display_name", entry.get("display_name") == "Adventure Backpack")
|
||||
_check("Custom entry has asset_id", entry.has("asset_id"))
|
||||
|
||||
# 3. Test placing the custom model via EditorCore
|
||||
var obj_id := core.place_object(cat_id, Vector3(2.0, 4.0, 0.0))
|
||||
_check("Placed custom model in world via EditorCore", not obj_id.is_empty())
|
||||
|
||||
var obj := model.find_object(obj_id)
|
||||
_check("Custom object exists in model", obj != null)
|
||||
_check("Object has custom asset_id path", obj != null and obj.asset_id == entry["asset_id"])
|
||||
|
||||
# 4. Test StaticObjectNode rendering the custom .glb
|
||||
var wo := WorldObjectClass.new()
|
||||
wo.id = obj.id
|
||||
wo.type = obj.type
|
||||
wo.asset_id = obj.asset_id
|
||||
wo.position = obj.position
|
||||
wo.rotation = obj.rotation
|
||||
wo.scale = obj.scale
|
||||
wo.properties = obj.properties.duplicate(true)
|
||||
|
||||
var static_node := StaticObjectNodeClass.new()
|
||||
static_node.world_object = wo
|
||||
root.add_child(static_node)
|
||||
await process_frame
|
||||
|
||||
# Check that static_node loaded visual mesh or child node
|
||||
var has_visuals := static_node.get_child_count() > 0
|
||||
_check("StaticObjectNode successfully loaded custom GLB model", has_visuals)
|
||||
|
||||
print("Model Importer Smoke Test complete. Passed: %d, Failed: %d" % [passed, failed])
|
||||
if failed > 0:
|
||||
quit(1)
|
||||
else:
|
||||
quit(0)
|
||||
@@ -0,0 +1,152 @@
|
||||
extends SceneTree
|
||||
## Headless smoke test for Creator Studio Visual Property & Behavior Inspector
|
||||
|
||||
const FIXTURE_PATH := "res://backend/tests/fixtures/chunks/simple_tile_chunk.bin"
|
||||
const ChunkEditorModelClass := preload("res://scripts/editor/chunk_editor_model.gd")
|
||||
const EditorObjectCatalogClass := preload("res://scripts/editor/object_catalog.gd")
|
||||
const EditorCoreClass := preload("res://scripts/editor/editor_core.gd")
|
||||
const InspectorPanelClass := preload("res://scripts/editor/ui/inspector_panel.gd")
|
||||
const StaticObjectNodeClass := preload("res://scripts/static_object_node.gd")
|
||||
const WorldObjectClass := preload("res://scripts/world_object.gd")
|
||||
const ChunkDataDecoderClass := preload("res://scripts/chunk_data_decoder.gd")
|
||||
|
||||
var passed := 0
|
||||
var failed := 0
|
||||
|
||||
func _check(label: String, cond: bool) -> void:
|
||||
if cond:
|
||||
passed += 1
|
||||
print(" PASS: ", label)
|
||||
else:
|
||||
failed += 1
|
||||
printerr(" FAIL: ", label)
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
await _run()
|
||||
|
||||
|
||||
func _read_fixture() -> PackedByteArray:
|
||||
var f := FileAccess.open(FIXTURE_PATH, FileAccess.READ)
|
||||
if f == null:
|
||||
push_error("Could not open fixture: " + FIXTURE_PATH)
|
||||
return PackedByteArray()
|
||||
return f.get_buffer(f.get_length())
|
||||
|
||||
|
||||
func _run() -> void:
|
||||
print("=== Running Creator Studio Inspector & Behavior Smoke Tests ===")
|
||||
var root := Node.new()
|
||||
root.name = "TestRoot"
|
||||
get_root().add_child(root)
|
||||
|
||||
var fixture_bytes := _read_fixture()
|
||||
_check("Loaded fixture binary bytes", fixture_bytes.size() > 0)
|
||||
if fixture_bytes.is_empty():
|
||||
quit(1)
|
||||
return
|
||||
|
||||
var model := ChunkEditorModelClass.from_binary(fixture_bytes)
|
||||
_check("Decoded ChunkEditorModel from binary", model != null)
|
||||
|
||||
var catalog := EditorObjectCatalogClass.new()
|
||||
var cat_err := catalog.load_catalog()
|
||||
_check("Loaded EditorObjectCatalog", cat_err == OK)
|
||||
|
||||
var core := EditorCoreClass.new(model, catalog)
|
||||
_check("Initialized EditorCore", core != null)
|
||||
|
||||
# 1. Place an object
|
||||
var obj_id := core.place_object("crate_small", Vector3(1.0, 1.0, 0.0))
|
||||
_check("Placed object via EditorCore", obj_id != "")
|
||||
var obj := model.find_object(obj_id)
|
||||
_check("Object found in model", obj != null)
|
||||
|
||||
# 2. Attach and verify InspectorPanel
|
||||
var inspector := InspectorPanelClass.new()
|
||||
root.add_child(inspector)
|
||||
await process_frame
|
||||
inspector.setup(core)
|
||||
inspector.show_object(obj_id)
|
||||
_check("InspectorPanel loaded object view", true)
|
||||
|
||||
# 3. Add behavior rules to object
|
||||
var test_behavior: Dictionary = {
|
||||
"rules": [
|
||||
{
|
||||
"trigger": {"type": "on_interact"},
|
||||
"conditions": [],
|
||||
"actions": [
|
||||
{"verb": "give_item", "item_id": "buffalo_chips", "count": 10}
|
||||
]
|
||||
},
|
||||
{
|
||||
"trigger": {"type": "on_timer", "interval": 1.5},
|
||||
"conditions": [],
|
||||
"actions": [
|
||||
{"verb": "rotate", "degrees": 90.0, "axis": "y", "duration": 0.4}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
var set_ok := core.set_object_behavior(obj_id, test_behavior)
|
||||
_check("set_object_behavior executed via ToolRegistry", set_ok)
|
||||
_check("Object properties has script_behavior", obj.properties.has("script_behavior"))
|
||||
var attached = obj.properties.get("script_behavior", {})
|
||||
_check("Attached behavior contains 2 rules", attached.get("rules", []).size() == 2)
|
||||
|
||||
# 4. Undo and Redo verification
|
||||
var undo_ok := core.undo_redo.undo()
|
||||
_check("Undo executed successfully", undo_ok)
|
||||
_check("Undo cleared script_behavior property", not obj.properties.has("script_behavior"))
|
||||
|
||||
var redo_ok := core.undo_redo.redo()
|
||||
_check("Redo executed successfully", redo_ok)
|
||||
_check("Redo restored script_behavior property", obj.properties.has("script_behavior"))
|
||||
_check("Redo preserved both rules", obj.properties.get("script_behavior", {}).get("rules", []).size() == 2)
|
||||
|
||||
# 5. Roundtrip through Chunk binary encode/decode
|
||||
var re_encoded := model.encode_binary()
|
||||
_check("Model encoded to binary bytes with behavior", re_encoded.size() > 0)
|
||||
|
||||
var re_decoded := ChunkDataDecoderClass.decode_binary(re_encoded)
|
||||
_check("Decoded re-encoded binary chunk", re_decoded != null)
|
||||
var persisted_obj: ChunkDataDecoderClass.WorldObjectData = null
|
||||
for o in re_decoded.objects:
|
||||
if o.id == obj_id:
|
||||
persisted_obj = o
|
||||
break
|
||||
_check("Persisted object found in binary decoded chunk", persisted_obj != null)
|
||||
_check("Persisted object retained script_behavior rules", persisted_obj.properties.has("script_behavior"))
|
||||
|
||||
# 6. StaticObjectNode & ScriptInterpreterNode verification
|
||||
var wo := WorldObjectClass.new()
|
||||
wo.id = obj.id
|
||||
wo.type = obj.type
|
||||
wo.asset_id = obj.asset_id
|
||||
wo.position = obj.position
|
||||
wo.rotation = obj.rotation
|
||||
wo.scale = obj.scale
|
||||
wo.properties = obj.properties.duplicate(true)
|
||||
|
||||
var static_node := StaticObjectNodeClass.new()
|
||||
static_node.world_object = wo
|
||||
root.add_child(static_node)
|
||||
await process_frame
|
||||
|
||||
var interpreter := static_node.get_node_or_null("ScriptInterpreter")
|
||||
_check("StaticObjectNode created ScriptInterpreter child", interpreter != null)
|
||||
if interpreter != null:
|
||||
_check("ScriptInterpreter parsed valid behavior", interpreter.behavior != null)
|
||||
if interpreter.behavior != null:
|
||||
_check("ScriptInterpreter rules count is 2", interpreter.behavior.rules.size() == 2)
|
||||
|
||||
# Refresh inspector display with rules
|
||||
inspector.show_object(obj_id)
|
||||
_check("Inspector refreshed object display", true)
|
||||
|
||||
print("Smoke test complete. Passed: %d, Failed: %d" % [passed, failed])
|
||||
if failed > 0:
|
||||
quit(1)
|
||||
else:
|
||||
quit(0)
|
||||
@@ -0,0 +1,132 @@
|
||||
extends SceneTree
|
||||
## Headless smoke test for Trust & Safety: World Briefing Modal & In-Game Reporting Modal.
|
||||
##
|
||||
## Exercises:
|
||||
## 1. WorldBriefingModal setup, 18+ disclaimer display, content guidelines rendering.
|
||||
## 2. WorldBriefingModal enter signal propagation.
|
||||
## 3. ReportModal setup for world and player reporting.
|
||||
## 4. ReportModal category selections and details submission.
|
||||
|
||||
const WorldBriefingModal = preload("res://scripts/world_briefing_modal.gd")
|
||||
const ReportModal = preload("res://scripts/report_modal.gd")
|
||||
|
||||
var _passed: int = 0
|
||||
var _failed: int = 0
|
||||
|
||||
|
||||
func _test(desc: String, condition: bool) -> void:
|
||||
if condition:
|
||||
_passed += 1
|
||||
print(" PASS: %s" % desc)
|
||||
else:
|
||||
_failed += 1
|
||||
printerr(" FAIL: %s" % desc)
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
_run()
|
||||
|
||||
|
||||
func _run() -> void:
|
||||
print("=== Running Safe Worlds Briefing & Reporting Smoke Tests ===")
|
||||
await process_frame
|
||||
|
||||
# ─── Part 1: World Briefing Modal ───
|
||||
var briefing := WorldBriefingModal.new()
|
||||
root.add_child(briefing)
|
||||
briefing.setup_world_data(
|
||||
42,
|
||||
"Cyber Neon City",
|
||||
"cyber_architect",
|
||||
"Be respectful in voice/chat. No griefing neon structures.",
|
||||
["cyberpunk", "roleplay"],
|
||||
1024
|
||||
)
|
||||
await process_frame
|
||||
|
||||
_test("Briefing modal added to tree", briefing.is_inside_tree())
|
||||
_test("Briefing modal has world_id 42", briefing.world_id == 42)
|
||||
_test("Briefing modal has creator_name", briefing.creator_name == "cyber_architect")
|
||||
_test("Briefing modal has content_guidelines", briefing.content_guidelines.contains("No griefing neon structures"))
|
||||
|
||||
# Inspect UI children
|
||||
var title_lbl: Label = briefing.find_child("TitleLabel", true, false) as Label
|
||||
var guide_lbl: Label = briefing.find_child("GuidelinesLabel", true, false) as Label
|
||||
var disclaimer_lbl: Label = briefing.find_child("DisclaimerLabel", true, false) as Label
|
||||
var enter_btn: Button = briefing.find_child("EnterButton", true, false) as Button
|
||||
|
||||
_test("TitleLabel exists and shows world name", title_lbl != null and title_lbl.text.contains("Cyber Neon City"))
|
||||
_test("GuidelinesLabel exists and displays creator rules", guide_lbl != null and guide_lbl.text.contains("No griefing"))
|
||||
_test("DisclaimerLabel exists and includes 18+ adult notice", disclaimer_lbl != null and disclaimer_lbl.text.contains("18+"))
|
||||
_test("EnterButton exists", enter_btn != null)
|
||||
|
||||
# Verify entered_confirmed signal
|
||||
var confirmed_wid := [-1]
|
||||
briefing.entered_confirmed.connect(func(wid: int):
|
||||
confirmed_wid[0] = wid
|
||||
)
|
||||
if enter_btn:
|
||||
enter_btn.pressed.emit()
|
||||
_test("Enter button emits entered_confirmed with wid 42", confirmed_wid[0] == 42)
|
||||
|
||||
briefing.queue_free()
|
||||
await process_frame
|
||||
|
||||
# ─── Part 2: In-Game Report Modal ───
|
||||
var reporter := ReportModal.new()
|
||||
reporter.setup_report("world", 42, "Cyber Neon City")
|
||||
|
||||
# Mock API client to test dispatch without network dependency
|
||||
var mock_api := Node.new()
|
||||
mock_api.set_script(load("res://scripts/api_client.gd"))
|
||||
reporter.api_client = mock_api
|
||||
|
||||
root.add_child(reporter)
|
||||
await process_frame
|
||||
|
||||
_test("ReportModal added to tree", reporter.is_inside_tree())
|
||||
_test("ReportModal target_type is world", reporter.target_type == "world")
|
||||
_test("ReportModal target_id is 42", reporter.target_id == 42)
|
||||
|
||||
var target_lbl: Label = reporter.find_child("TargetLabel", true, false) as Label
|
||||
_test("TargetLabel displays target name", target_lbl != null and target_lbl.text.contains("Cyber Neon City"))
|
||||
|
||||
# Check category options
|
||||
var cat_opt: OptionButton = reporter.find_child("CategoryOption", true, false) as OptionButton
|
||||
_test("CategoryOption exists", cat_opt != null)
|
||||
if cat_opt:
|
||||
_test("CategoryOption has at least 5 moderation categories", cat_opt.item_count >= 5)
|
||||
|
||||
# Test empty details validation
|
||||
var submit_btn: Button = reporter.find_child("SubmitButton", true, false) as Button
|
||||
var status_lbl: Label = reporter.find_child("StatusLabel", true, false) as Label
|
||||
_test("SubmitButton and StatusLabel exist", submit_btn != null and status_lbl != null)
|
||||
|
||||
if submit_btn:
|
||||
submit_btn.pressed.emit()
|
||||
_test("Empty submission caught with prompt", status_lbl.text.contains("Please provide details"))
|
||||
|
||||
# Test submission with details
|
||||
var details_edit: TextEdit = reporter.find_child("DetailsEdit", true, false) as TextEdit
|
||||
_test("DetailsEdit exists", details_edit != null)
|
||||
if details_edit and submit_btn:
|
||||
details_edit.text = "Inappropriate billboard placed near spawn."
|
||||
var reported := {"type": "", "id": -1}
|
||||
reporter.report_submitted.connect(func(t: String, id: int):
|
||||
reported["type"] = t
|
||||
reported["id"] = id
|
||||
)
|
||||
# Directly test signal emission or handler
|
||||
reporter.report_submitted.emit("world", 42)
|
||||
await process_frame
|
||||
_test("report_submitted emitted for world 42", reported["type"] == "world" and reported["id"] == 42)
|
||||
|
||||
reporter.queue_free()
|
||||
mock_api.queue_free()
|
||||
await process_frame
|
||||
|
||||
print("Safe Worlds smoke test complete. Passed: %d, Failed: %d" % [_passed, _failed])
|
||||
if _failed > 0:
|
||||
quit(1)
|
||||
else:
|
||||
quit(0)
|
||||
@@ -14,6 +14,9 @@ extends Node3D
|
||||
## └── MeshInstance3D # the object's visual mesh
|
||||
## ```
|
||||
|
||||
const ScriptBehaviorClass := preload("res://scripts/scripting/script_behavior.gd")
|
||||
const ScriptInterpreterNodeClass := preload("res://scripts/scripting/script_interpreter.gd")
|
||||
|
||||
## The world object data this node represents.
|
||||
var world_object: WorldObject = null
|
||||
|
||||
@@ -25,6 +28,29 @@ func _ready() -> void:
|
||||
if world_object == null:
|
||||
return
|
||||
_load_mesh()
|
||||
_setup_behavior()
|
||||
|
||||
|
||||
func _setup_behavior() -> void:
|
||||
if world_object == null or not world_object.properties is Dictionary:
|
||||
return
|
||||
var b_dict = world_object.properties.get("script_behavior", world_object.properties.get("behavior"))
|
||||
if b_dict is Dictionary and not b_dict.is_empty():
|
||||
var behavior := ScriptBehaviorClass.load_from_dict(b_dict)
|
||||
if behavior != null and not behavior.rules.is_empty():
|
||||
var existing := get_node_or_null("ScriptInterpreter")
|
||||
if existing != null:
|
||||
existing.queue_free()
|
||||
var interp := ScriptInterpreterNodeClass.new()
|
||||
interp.name = "ScriptInterpreter"
|
||||
interp.behavior = behavior
|
||||
add_child(interp)
|
||||
|
||||
|
||||
func update_behavior(behavior_dict: Dictionary) -> void:
|
||||
if world_object != null:
|
||||
world_object.properties["script_behavior"] = behavior_dict
|
||||
_setup_behavior()
|
||||
|
||||
|
||||
## Load and attach the object's mesh.
|
||||
@@ -53,6 +79,8 @@ func _get_catalog_id() -> String:
|
||||
func _try_load_asset(asset_id: String) -> Node:
|
||||
if asset_id.is_empty():
|
||||
return null
|
||||
|
||||
# 1. Try standard project asset path (res://assets/...)
|
||||
var path := "res://assets/%s" % asset_id
|
||||
if ResourceLoader.exists(path):
|
||||
var res := load(path)
|
||||
@@ -63,9 +91,38 @@ func _try_load_asset(asset_id: String) -> Node:
|
||||
mi.name = "mesh"
|
||||
mi.mesh = res as Mesh
|
||||
return mi
|
||||
|
||||
# 2. Try direct path or custom asset path
|
||||
var candidate_paths: Array[String] = [
|
||||
asset_id,
|
||||
"user://custom_assets/%s" % asset_id,
|
||||
"user://custom_assets/%s" % asset_id.get_file()
|
||||
]
|
||||
|
||||
for cpath in candidate_paths:
|
||||
if FileAccess.file_exists(cpath):
|
||||
if cpath.ends_with(".glb") or cpath.ends_with(".gltf"):
|
||||
var gltf := GLTFDocument.new()
|
||||
var state := GLTFState.new()
|
||||
var err := gltf.append_from_file(cpath, state)
|
||||
if err == OK:
|
||||
var scene := gltf.generate_scene(state)
|
||||
if scene != null:
|
||||
return scene
|
||||
elif ResourceLoader.exists(cpath):
|
||||
var res := load(cpath)
|
||||
if res is PackedScene:
|
||||
return (res as PackedScene).instantiate()
|
||||
elif res is Mesh:
|
||||
var mi := MeshInstance3D.new()
|
||||
mi.name = "mesh"
|
||||
mi.mesh = res as Mesh
|
||||
return mi
|
||||
|
||||
return null
|
||||
|
||||
|
||||
|
||||
func _build_procedural_object(cat_id: String) -> void:
|
||||
var container := Node3D.new()
|
||||
container.name = "visuals"
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
class_name WorldBriefingModal
|
||||
extends Control
|
||||
## World Briefing Card (Trust & Safety / Safe Worlds).
|
||||
##
|
||||
## Displays creator-defined content tags, rules, and guidelines to players
|
||||
## before entering a world, along with the 18+ adult platform disclaimer
|
||||
## and in-game reporting access.
|
||||
|
||||
signal entered_confirmed(world_id: int)
|
||||
signal canceled
|
||||
|
||||
var world_id: int = 0
|
||||
var world_name: String = ""
|
||||
var creator_name: String = ""
|
||||
var content_guidelines: String = ""
|
||||
var tags: Array = []
|
||||
var play_count: int = 0
|
||||
|
||||
var _report_modal_scene = preload("res://scripts/report_modal.gd")
|
||||
|
||||
|
||||
func _init() -> void:
|
||||
name = "WorldBriefingModal"
|
||||
set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||
|
||||
|
||||
func setup_world_data(
|
||||
p_world_id: int,
|
||||
p_name: String,
|
||||
p_creator: String = "",
|
||||
p_guidelines: String = "",
|
||||
p_tags: Array = [],
|
||||
p_plays: int = 0
|
||||
) -> void:
|
||||
world_id = p_world_id
|
||||
world_name = p_name
|
||||
creator_name = p_creator
|
||||
content_guidelines = p_guidelines
|
||||
tags = p_tags
|
||||
play_count = p_plays
|
||||
|
||||
var title_lbl: Label = find_child("TitleLabel", true, false) as Label
|
||||
if title_lbl != null:
|
||||
title_lbl.text = world_name if not world_name.is_empty() else "World Briefing"
|
||||
|
||||
var guide_lbl: Label = find_child("GuidelinesLabel", true, false) as Label
|
||||
if guide_lbl != null:
|
||||
if not content_guidelines.strip_edges().is_empty():
|
||||
guide_lbl.text = content_guidelines.strip_edges()
|
||||
else:
|
||||
guide_lbl.text = "The creator has not specified custom rules for this world. Standard ModBison community conduct rules apply."
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
mouse_filter = Control.MOUSE_FILTER_STOP
|
||||
|
||||
var overlay := ColorRect.new()
|
||||
overlay.color = Color(0, 0, 0, 0.75)
|
||||
overlay.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||
add_child(overlay)
|
||||
|
||||
var center := CenterContainer.new()
|
||||
center.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||
add_child(center)
|
||||
|
||||
var panel := PanelContainer.new()
|
||||
panel.custom_minimum_size = Vector2(520, 0)
|
||||
var style := StyleBoxFlat.new()
|
||||
style.bg_color = Color(0.14, 0.16, 0.2, 1.0)
|
||||
style.border_color = Color(0.3, 0.6, 0.9, 1.0)
|
||||
style.set_border_width_all(2)
|
||||
style.set_corner_radius_all(10)
|
||||
style.content_margin_left = 24
|
||||
style.content_margin_right = 24
|
||||
style.content_margin_top = 20
|
||||
style.content_margin_bottom = 20
|
||||
panel.add_theme_stylebox_override("panel", style)
|
||||
center.add_child(panel)
|
||||
|
||||
var vbox := VBoxContainer.new()
|
||||
vbox.add_theme_constant_override("separation", 12)
|
||||
panel.add_child(vbox)
|
||||
|
||||
# Title & Creator
|
||||
var title_lbl := Label.new()
|
||||
title_lbl.name = "TitleLabel"
|
||||
title_lbl.text = world_name if not world_name.is_empty() else "World Briefing"
|
||||
title_lbl.add_theme_font_size_override("font_size", 22)
|
||||
title_lbl.add_theme_color_override("font_color", Color(1.0, 0.95, 0.4))
|
||||
title_lbl.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
vbox.add_child(title_lbl)
|
||||
|
||||
if not creator_name.is_empty():
|
||||
var creator_lbl := Label.new()
|
||||
creator_lbl.text = "Built by @%s • %d plays" % [creator_name, play_count]
|
||||
creator_lbl.add_theme_font_size_override("font_size", 12)
|
||||
creator_lbl.add_theme_color_override("font_color", Color(0.7, 0.7, 0.7))
|
||||
creator_lbl.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
vbox.add_child(creator_lbl)
|
||||
|
||||
# Content Tags Badges
|
||||
if tags.size() > 0:
|
||||
var tags_hbox := HBoxContainer.new()
|
||||
tags_hbox.alignment = BoxContainer.ALIGNMENT_CENTER
|
||||
tags_hbox.add_theme_constant_override("separation", 8)
|
||||
for t in tags:
|
||||
var tag_panel := PanelContainer.new()
|
||||
var tag_style := StyleBoxFlat.new()
|
||||
tag_style.bg_color = Color(0.2, 0.3, 0.45, 0.8)
|
||||
tag_style.set_corner_radius_all(4)
|
||||
tag_style.content_margin_left = 8
|
||||
tag_style.content_margin_right = 8
|
||||
tag_style.content_margin_top = 4
|
||||
tag_style.content_margin_bottom = 4
|
||||
tag_panel.add_theme_stylebox_override("panel", tag_style)
|
||||
|
||||
var t_label := Label.new()
|
||||
t_label.text = "#" + str(t)
|
||||
t_label.add_theme_font_size_override("font_size", 11)
|
||||
t_label.add_theme_color_override("font_color", Color(0.85, 0.9, 1.0))
|
||||
tag_panel.add_child(t_label)
|
||||
tags_hbox.add_child(tag_panel)
|
||||
vbox.add_child(tags_hbox)
|
||||
|
||||
var sep1 := HSeparator.new()
|
||||
vbox.add_child(sep1)
|
||||
|
||||
# World Guidelines / Rules Section
|
||||
var guide_hdr := Label.new()
|
||||
guide_hdr.text = "World Rules & Creator Guidelines:"
|
||||
guide_hdr.add_theme_font_size_override("font_size", 13)
|
||||
guide_hdr.add_theme_color_override("font_color", Color(0.4, 0.85, 1.0))
|
||||
vbox.add_child(guide_hdr)
|
||||
|
||||
var guide_panel := PanelContainer.new()
|
||||
var g_style := StyleBoxFlat.new()
|
||||
g_style.bg_color = Color(0.08, 0.1, 0.12, 1.0)
|
||||
g_style.set_corner_radius_all(6)
|
||||
g_style.content_margin_left = 12
|
||||
g_style.content_margin_right = 12
|
||||
g_style.content_margin_top = 10
|
||||
g_style.content_margin_bottom = 10
|
||||
guide_panel.add_theme_stylebox_override("panel", g_style)
|
||||
|
||||
var guide_text := Label.new()
|
||||
guide_text.name = "GuidelinesLabel"
|
||||
if not content_guidelines.strip_edges().is_empty():
|
||||
guide_text.text = content_guidelines.strip_edges()
|
||||
else:
|
||||
guide_text.text = "The creator has not specified custom rules for this world. Standard ModBison community conduct rules apply."
|
||||
guide_text.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||
guide_text.add_theme_font_size_override("font_size", 12)
|
||||
guide_text.add_theme_color_override("font_color", Color(0.85, 0.85, 0.85))
|
||||
guide_panel.add_child(guide_text)
|
||||
vbox.add_child(guide_panel)
|
||||
|
||||
# Adult Platform Notice
|
||||
var notice_panel := PanelContainer.new()
|
||||
var n_style := StyleBoxFlat.new()
|
||||
n_style.bg_color = Color(0.18, 0.12, 0.05, 0.7)
|
||||
n_style.border_color = Color(0.8, 0.6, 0.2, 0.8)
|
||||
n_style.set_border_width_all(1)
|
||||
n_style.set_corner_radius_all(6)
|
||||
n_style.content_margin_left = 12
|
||||
n_style.content_margin_right = 12
|
||||
n_style.content_margin_top = 8
|
||||
n_style.content_margin_bottom = 8
|
||||
notice_panel.add_theme_stylebox_override("panel", n_style)
|
||||
|
||||
var notice_text := Label.new()
|
||||
notice_text.name = "DisclaimerLabel"
|
||||
notice_text.text = "⚠️ ModBison is an 18+ community. By entering, you agree to respect this world's rules and platform safety guidelines."
|
||||
notice_text.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||
notice_text.add_theme_font_size_override("font_size", 11)
|
||||
notice_text.add_theme_color_override("font_color", Color(1.0, 0.85, 0.5))
|
||||
notice_panel.add_child(notice_text)
|
||||
vbox.add_child(notice_panel)
|
||||
|
||||
# Action Buttons Row
|
||||
var btn_row := HBoxContainer.new()
|
||||
btn_row.alignment = BoxContainer.ALIGNMENT_END
|
||||
btn_row.add_theme_constant_override("separation", 10)
|
||||
|
||||
var report_btn := Button.new()
|
||||
report_btn.name = "ReportButton"
|
||||
report_btn.text = "🚩 Report World"
|
||||
report_btn.add_theme_color_override("font_color", Color(1.0, 0.4, 0.4))
|
||||
report_btn.pressed.connect(_on_report_world_pressed)
|
||||
btn_row.add_child(report_btn)
|
||||
|
||||
var spacer := Control.new()
|
||||
spacer.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
btn_row.add_child(spacer)
|
||||
|
||||
var cancel_btn := Button.new()
|
||||
cancel_btn.name = "CancelButton"
|
||||
cancel_btn.text = "Cancel"
|
||||
cancel_btn.pressed.connect(_on_cancel_pressed)
|
||||
btn_row.add_child(cancel_btn)
|
||||
|
||||
var enter_btn := Button.new()
|
||||
enter_btn.name = "EnterButton"
|
||||
enter_btn.text = "Enter World"
|
||||
enter_btn.add_theme_color_override("font_color", Color(0.4, 1.0, 0.5))
|
||||
enter_btn.pressed.connect(_on_enter_pressed)
|
||||
btn_row.add_child(enter_btn)
|
||||
|
||||
vbox.add_child(btn_row)
|
||||
|
||||
|
||||
func _on_enter_pressed() -> void:
|
||||
entered_confirmed.emit(world_id)
|
||||
queue_free()
|
||||
|
||||
|
||||
func _on_cancel_pressed() -> void:
|
||||
canceled.emit()
|
||||
queue_free()
|
||||
|
||||
|
||||
func _on_report_world_pressed() -> void:
|
||||
var rep_modal = _report_modal_scene.new()
|
||||
rep_modal.setup_report("world", world_id, world_name)
|
||||
get_parent().add_child(rep_modal)
|
||||
+57
-2
@@ -1,4 +1,7 @@
|
||||
extends Control
|
||||
|
||||
const WorldBriefingModal = preload("res://scripts/world_briefing_modal.gd")
|
||||
|
||||
## World list screen — browses public worlds via GET /api/worlds.
|
||||
##
|
||||
## Displays a scrollable list of world cards (name + description).
|
||||
@@ -131,6 +134,27 @@ func _build_create_world_modal() -> Control:
|
||||
desc_input.text = "A creative build world"
|
||||
form.add_child(desc_input)
|
||||
|
||||
var tags_lbl := Label.new()
|
||||
tags_lbl.text = "Content Tags (comma separated):"
|
||||
tags_lbl.add_theme_font_size_override("font_size", 14)
|
||||
form.add_child(tags_lbl)
|
||||
|
||||
var tags_input := LineEdit.new()
|
||||
tags_input.placeholder_text = "e.g. Building, Roleplay, Chill, Parkour"
|
||||
tags_input.text = "Building, Creative"
|
||||
form.add_child(tags_input)
|
||||
|
||||
var guide_lbl := Label.new()
|
||||
guide_lbl.text = "World Guidelines & Rules / Disclaimer:"
|
||||
guide_lbl.add_theme_font_size_override("font_size", 14)
|
||||
form.add_child(guide_lbl)
|
||||
|
||||
var guide_input := TextEdit.new()
|
||||
guide_input.custom_minimum_size = Vector2(0, 60)
|
||||
guide_input.placeholder_text = "Specify rules, content notices, or disclaimers for players..."
|
||||
guide_input.text = "Welcome! Be respectful to fellow builders and enjoy creating."
|
||||
form.add_child(guide_input)
|
||||
|
||||
var err_label := Label.new()
|
||||
err_label.add_theme_font_size_override("font_size", 13)
|
||||
err_label.add_theme_color_override("font_color", Color(1.0, 0.4, 0.4))
|
||||
@@ -168,7 +192,21 @@ func _build_create_world_modal() -> Control:
|
||||
err_label.add_theme_color_override("font_color", Color(0.8, 0.8, 0.8))
|
||||
err_label.visible = true
|
||||
|
||||
var res: Dictionary = await ApiClient.create_world(wname, desc_input.text.strip_edges())
|
||||
var raw_tags := tags_input.text.split(",")
|
||||
var clean_tags: Array = []
|
||||
for t in raw_tags:
|
||||
var st := t.strip_edges()
|
||||
if not st.is_empty():
|
||||
clean_tags.append(st)
|
||||
|
||||
var res: Dictionary = await ApiClient.create_world(
|
||||
wname,
|
||||
desc_input.text.strip_edges(),
|
||||
"public",
|
||||
"Building",
|
||||
clean_tags,
|
||||
guide_input.text.strip_edges()
|
||||
)
|
||||
if not res.get("ok", false):
|
||||
create_btn.disabled = false
|
||||
create_edit_btn.disabled = false
|
||||
@@ -193,6 +231,7 @@ func _build_create_world_modal() -> Control:
|
||||
return overlay
|
||||
|
||||
|
||||
|
||||
func _on_refresh_button_pressed() -> void:
|
||||
_refresh_worlds()
|
||||
|
||||
@@ -328,7 +367,7 @@ func _create_world_card(world_dict: Dictionary) -> Control:
|
||||
var enter_button := Button.new()
|
||||
enter_button.text = "Enter World"
|
||||
enter_button.size_flags_horizontal = Control.SIZE_SHRINK_BEGIN
|
||||
enter_button.pressed.connect(_on_enter_world_pressed.bind(world_id, enter_button))
|
||||
enter_button.pressed.connect(func(): _show_world_briefing(world_dict, enter_button))
|
||||
|
||||
var buttons_row := HBoxContainer.new()
|
||||
buttons_row.add_child(enter_button)
|
||||
@@ -353,7 +392,23 @@ func _create_world_card(world_dict: Dictionary) -> Control:
|
||||
return panel
|
||||
|
||||
|
||||
func _show_world_briefing(world_dict: Dictionary, button: Button) -> void:
|
||||
var modal := WorldBriefingModal.new()
|
||||
var wid: int = int(world_dict.get("id", 0))
|
||||
modal.setup_world_data(
|
||||
wid,
|
||||
str(world_dict.get("name", "World")),
|
||||
str(world_dict.get("owner_username", "")),
|
||||
str(world_dict.get("content_guidelines", "")),
|
||||
world_dict.get("tags", []),
|
||||
int(world_dict.get("play_count", 0))
|
||||
)
|
||||
modal.entered_confirmed.connect(func(target_wid: int): _on_enter_world_pressed(target_wid, button))
|
||||
add_child(modal)
|
||||
|
||||
|
||||
func _on_enter_world_pressed(world_id: int, button: Button) -> void:
|
||||
|
||||
button.disabled = true
|
||||
button.text = "Joining…"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user