Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1c5c0f8c8e | |||
| 97e0804dfe | |||
| 342d0f69d9 | |||
| 97949d6b44 | |||
| 720cf6f6b9 | |||
| d752cf5280 | |||
| 504c92301a | |||
| 67f6a9c43f | |||
| 5c0a61870f | |||
| cc27d73ca6 | |||
| 62172e0ee4 | |||
| ce2690e8b9 | |||
| 8e2f17f159 | |||
| 2d4a7e91cb | |||
| 2ad8e34971 | |||
| 7e81c09265 |
@@ -1,2 +0,0 @@
|
|||||||
# Godot 4 editor cache/import directory
|
|
||||||
.godot/
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
63f7b34db8d8cdea90c76aacccf841ec
|
||||||
|
::res://::1787012074
|
||||||
|
README.md::TextFile::-1::1787011777::0::1::::<><><>0<>0<><>::
|
||||||
|
::res://docs/::1787011777
|
||||||
|
::res://docs/architecture/::1787011795
|
||||||
|
avatar.md::TextFile::-1::1787011777::0::1::::<><><>0<>0<><>::
|
||||||
|
content_moderation.md::TextFile::-1::1787011777::0::1::::<><><>0<>0<><>::
|
||||||
|
networking.md::TextFile::-1::1787011777::0::1::::<><><>0<>0<><>::
|
||||||
|
world.md::TextFile::-1::1787011777::0::1::::<><><>0<>0<><>::
|
||||||
|
::res://scenes/::1787011795
|
||||||
|
main.tscn::PackedScene::-1::1787011795::0::1::::<><><>0<>0<><>::res://scripts/chunk_stream_manager.gd
|
||||||
|
::res://scripts/::1787012062
|
||||||
|
chunk.gd::GDScript::7364239597441807588::1787011795::0::1::::Chunk<>Node3D<><>0<>0<><>::
|
||||||
|
chunk_data_decoder.gd::GDScript::3922068956043581888::1787012104::0::1::::ChunkDataDecoder<>RefCounted<><>0<>0<><>::
|
||||||
|
chunk_scene.gd::GDScript::9207602004456600331::1787011795::0::1::::ChunkScene<>Node3D<><>0<>0<><>::
|
||||||
|
chunk_stream_manager.gd::GDScript::2366645676568508746::1787011963::0::1::::ChunkStreamManager<>Node<><>0<>0<><>::
|
||||||
|
dynamic_object_node.gd::GDScript::8863349716576659118::1787011795::0::1::::DynamicObjectNode<>Node3D<><>0<>0<><>::
|
||||||
|
static_object_node.gd::GDScript::5276389741929225897::1787011795::0::1::::StaticObjectNode<>Node3D<><>0<>0<><>::
|
||||||
|
tile_def.gd::GDScript::2430528079284085109::1787011795::0::1::::TileDef<>RefCounted<><>0<>0<><>::
|
||||||
|
tile_layer.gd::GDScript::4797906255131447844::1787011795::0::1::::TileLayer<>RefCounted<><>0<>0<><>::
|
||||||
|
tile_layer_node.gd::GDScript::9033225196818247616::1787011795::0::1::::TileLayerNode<>Node3D<><>0<>0<><>::
|
||||||
|
world_object.gd::GDScript::6429674491464200335::1787011795::0::1::::WorldObject<>RefCounted<><>0<>0<><>::
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
[folding]
|
||||||
|
|
||||||
|
node_unfolds=[NodePath("."), PackedStringArray("Visibility")]
|
||||||
|
resource_unfolds=[]
|
||||||
|
nodes_folded=[]
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
[game_view]
|
||||||
|
|
||||||
|
select_mode=0
|
||||||
|
|
||||||
|
[editor_metadata]
|
||||||
|
|
||||||
|
executable_path="/home/daddy/godot/godot"
|
||||||
|
|
||||||
|
[recent_files]
|
||||||
|
|
||||||
|
scenes=["res://scenes/main.tscn"]
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
list=[{
|
||||||
|
"base": &"Node3D",
|
||||||
|
"class": &"Chunk",
|
||||||
|
"icon": "",
|
||||||
|
"is_abstract": false,
|
||||||
|
"is_tool": false,
|
||||||
|
"language": &"GDScript",
|
||||||
|
"path": "res://scripts/chunk.gd"
|
||||||
|
}, {
|
||||||
|
"base": &"RefCounted",
|
||||||
|
"class": &"ChunkDataDecoder",
|
||||||
|
"icon": "",
|
||||||
|
"is_abstract": false,
|
||||||
|
"is_tool": false,
|
||||||
|
"language": &"GDScript",
|
||||||
|
"path": "res://scripts/chunk_data_decoder.gd"
|
||||||
|
}, {
|
||||||
|
"base": &"Node3D",
|
||||||
|
"class": &"ChunkScene",
|
||||||
|
"icon": "",
|
||||||
|
"is_abstract": false,
|
||||||
|
"is_tool": false,
|
||||||
|
"language": &"GDScript",
|
||||||
|
"path": "res://scripts/chunk_scene.gd"
|
||||||
|
}, {
|
||||||
|
"base": &"Node",
|
||||||
|
"class": &"ChunkStreamManager",
|
||||||
|
"icon": "",
|
||||||
|
"is_abstract": false,
|
||||||
|
"is_tool": false,
|
||||||
|
"language": &"GDScript",
|
||||||
|
"path": "res://scripts/chunk_stream_manager.gd"
|
||||||
|
}, {
|
||||||
|
"base": &"Node3D",
|
||||||
|
"class": &"DynamicObjectNode",
|
||||||
|
"icon": "",
|
||||||
|
"is_abstract": false,
|
||||||
|
"is_tool": false,
|
||||||
|
"language": &"GDScript",
|
||||||
|
"path": "res://scripts/dynamic_object_node.gd"
|
||||||
|
}, {
|
||||||
|
"base": &"Node3D",
|
||||||
|
"class": &"StaticObjectNode",
|
||||||
|
"icon": "",
|
||||||
|
"is_abstract": false,
|
||||||
|
"is_tool": false,
|
||||||
|
"language": &"GDScript",
|
||||||
|
"path": "res://scripts/static_object_node.gd"
|
||||||
|
}, {
|
||||||
|
"base": &"RefCounted",
|
||||||
|
"class": &"TileDef",
|
||||||
|
"icon": "",
|
||||||
|
"is_abstract": false,
|
||||||
|
"is_tool": false,
|
||||||
|
"language": &"GDScript",
|
||||||
|
"path": "res://scripts/tile_def.gd"
|
||||||
|
}, {
|
||||||
|
"base": &"RefCounted",
|
||||||
|
"class": &"TileLayer",
|
||||||
|
"icon": "",
|
||||||
|
"is_abstract": false,
|
||||||
|
"is_tool": false,
|
||||||
|
"language": &"GDScript",
|
||||||
|
"path": "res://scripts/tile_layer.gd"
|
||||||
|
}, {
|
||||||
|
"base": &"Node3D",
|
||||||
|
"class": &"TileLayerNode",
|
||||||
|
"icon": "",
|
||||||
|
"is_abstract": false,
|
||||||
|
"is_tool": false,
|
||||||
|
"language": &"GDScript",
|
||||||
|
"path": "res://scripts/tile_layer_node.gd"
|
||||||
|
}, {
|
||||||
|
"base": &"RefCounted",
|
||||||
|
"class": &"WorldObject",
|
||||||
|
"icon": "",
|
||||||
|
"is_abstract": false,
|
||||||
|
"is_tool": false,
|
||||||
|
"language": &"GDScript",
|
||||||
|
"path": "res://scripts/world_object.gd"
|
||||||
|
}]
|
||||||
Binary file not shown.
@@ -1,20 +0,0 @@
|
|||||||
# Database (async driver)
|
|
||||||
DATABASE_URL=postgresql+asyncpg://agp_user:agp_password@localhost:5432/agp_db
|
|
||||||
|
|
||||||
# Redis
|
|
||||||
REDIS_URL=redis://localhost:6379/0
|
|
||||||
|
|
||||||
# JWT
|
|
||||||
SECRET_KEY=change-me-to-a-secure-random-string
|
|
||||||
ALGORITHM=HS256
|
|
||||||
ACCESS_TOKEN_EXPIRE_MINUTES=30
|
|
||||||
|
|
||||||
# App
|
|
||||||
APP_NAME=AGP Backend
|
|
||||||
APP_VERSION=0.1.0
|
|
||||||
DEBUG=true
|
|
||||||
CORS_ORIGINS=http://localhost:8080,http://localhost:3000
|
|
||||||
|
|
||||||
# File uploads
|
|
||||||
UPLOAD_DIR=uploads
|
|
||||||
MAX_UPLOAD_SIZE_MB=100
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
.env
|
|
||||||
__pycache__/
|
|
||||||
*.pyc
|
|
||||||
.venv/
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
# Alembic configuration file.
|
|
||||||
# Run: alembic upgrade head (to apply migrations)
|
|
||||||
# Run: alembic revision --autogenerate -m "description" (to generate migration)
|
|
||||||
|
|
||||||
[alembic]
|
|
||||||
script_location = alembic
|
|
||||||
prepend_sys_path = .
|
|
||||||
sqlalchemy.url = postgresql+asyncpg://agp_user:agp_password@localhost:5432/agp_db
|
|
||||||
|
|
||||||
[loggers]
|
|
||||||
keys = root,sqlalchemy,alembic
|
|
||||||
|
|
||||||
[handlers]
|
|
||||||
keys = console
|
|
||||||
|
|
||||||
[formatters]
|
|
||||||
keys = generic
|
|
||||||
|
|
||||||
[logger_root]
|
|
||||||
level = WARN
|
|
||||||
handlers = console
|
|
||||||
qualname =
|
|
||||||
|
|
||||||
[logger_sqlalchemy]
|
|
||||||
level = WARN
|
|
||||||
handlers =
|
|
||||||
qualname = sqlalchemy.engine
|
|
||||||
|
|
||||||
[logger_alembic]
|
|
||||||
level = INFO
|
|
||||||
handlers =
|
|
||||||
qualname = alembic
|
|
||||||
|
|
||||||
[handler_console]
|
|
||||||
class = StreamHandler
|
|
||||||
args = (sys.stderr,)
|
|
||||||
level = NOTSET
|
|
||||||
formatter = generic
|
|
||||||
|
|
||||||
[formatter_generic]
|
|
||||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
|
||||||
datefmt = %H:%M:%S
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
"""Alembic environment configuration."""
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
from logging.config import fileConfig
|
|
||||||
|
|
||||||
from alembic import context
|
|
||||||
from sqlalchemy.ext.asyncio import create_async_engine
|
|
||||||
|
|
||||||
from app.config import settings
|
|
||||||
from app.database import Base
|
|
||||||
from app.models import user, session, world, asset # noqa: F401
|
|
||||||
|
|
||||||
# this is the Alembic Config object, which provides
|
|
||||||
# access to the values within the .ini file in use.
|
|
||||||
config = context.config
|
|
||||||
|
|
||||||
# Interpret the config file for Python logging.
|
|
||||||
# This line sets up loggers basically.
|
|
||||||
if config.config_file_name is not None:
|
|
||||||
fileConfig(config.config_file_name)
|
|
||||||
|
|
||||||
# add your model's MetaData object here
|
|
||||||
# for 'autogenerate' support
|
|
||||||
target_metadata = Base.metadata
|
|
||||||
|
|
||||||
|
|
||||||
def run_migrations_offline() -> None:
|
|
||||||
"""Run migrations in 'offline' mode."""
|
|
||||||
url = settings.DATABASE_URL
|
|
||||||
context.configure(
|
|
||||||
url=url,
|
|
||||||
target_metadata=target_metadata,
|
|
||||||
literal_binds=True,
|
|
||||||
dialect_opts={"paramstyle": "named"},
|
|
||||||
)
|
|
||||||
|
|
||||||
with context.begin_transaction():
|
|
||||||
context.run_migrations()
|
|
||||||
|
|
||||||
|
|
||||||
def do_run_migrations(connection):
|
|
||||||
context.configure(connection=connection, target_metadata=target_metadata)
|
|
||||||
with context.begin_transaction():
|
|
||||||
context.run_migrations()
|
|
||||||
|
|
||||||
|
|
||||||
async def run_async_migrations():
|
|
||||||
"""Run migrations in 'online' mode with async engine."""
|
|
||||||
connectable = create_async_engine(settings.DATABASE_URL, echo=False)
|
|
||||||
|
|
||||||
async with connectable.connect() as connection:
|
|
||||||
await connection.run_sync(do_run_migrations)
|
|
||||||
|
|
||||||
await connectable.dispose()
|
|
||||||
|
|
||||||
|
|
||||||
def run_migrations_online() -> None:
|
|
||||||
"""Run migrations in 'online' mode."""
|
|
||||||
asyncio.run(run_async_migrations())
|
|
||||||
|
|
||||||
|
|
||||||
if context.is_offline_mode():
|
|
||||||
run_migrations_offline()
|
|
||||||
else:
|
|
||||||
run_migrations_online()
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
"""${message}
|
|
||||||
|
|
||||||
Revision ID: ${up_revision}
|
|
||||||
Revises: ${down_revision | comma,n}
|
|
||||||
Create Date: ${create_date}
|
|
||||||
"""
|
|
||||||
from typing import Sequence, Union
|
|
||||||
|
|
||||||
from alembic import op
|
|
||||||
import sqlalchemy as sa
|
|
||||||
${imports if imports else ""}
|
|
||||||
|
|
||||||
# revision identifiers, used by Alembic.
|
|
||||||
revision: str = ${repr(up_revision)}
|
|
||||||
down_revision: Union[str, None] = ${repr(down_revision)}
|
|
||||||
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
|
||||||
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
|
||||||
"""Upgrade database schema."""
|
|
||||||
${upgrades if upgrades else "pass"}
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
|
||||||
"""Downgrade database schema."""
|
|
||||||
${downgrades if downgrades else "pass"}
|
|
||||||
@@ -1,126 +0,0 @@
|
|||||||
"""initial schema
|
|
||||||
|
|
||||||
Revision ID: 0001_initial
|
|
||||||
Revises:
|
|
||||||
Create Date: 2024-01-01 00:00:00.000000
|
|
||||||
"""
|
|
||||||
from typing import Sequence, Union
|
|
||||||
|
|
||||||
from alembic import op
|
|
||||||
import sqlalchemy as sa
|
|
||||||
from sqlalchemy.dialects import postgresql
|
|
||||||
|
|
||||||
# revision identifiers, used by Alembic.
|
|
||||||
revision: str = "0001_initial"
|
|
||||||
down_revision: Union[str, None] = None
|
|
||||||
branch_labels: Union[str, Sequence[str], None] = None
|
|
||||||
depends_on: Union[str, Sequence[str], None] = None
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
|
||||||
"""Upgrade database schema."""
|
|
||||||
# Create ENUM types
|
|
||||||
world_visibility = postgresql.ENUM(
|
|
||||||
"public", "private", "friends_only", name="world_visibility"
|
|
||||||
)
|
|
||||||
world_visibility.create(op.get_bind(), checkfirst=True)
|
|
||||||
|
|
||||||
asset_type = postgresql.ENUM(
|
|
||||||
"image", "audio", "video", "document", "model_3d", "other", name="asset_type"
|
|
||||||
)
|
|
||||||
asset_type.create(op.get_bind(), checkfirst=True)
|
|
||||||
|
|
||||||
# Create users table
|
|
||||||
op.create_table(
|
|
||||||
"users",
|
|
||||||
sa.Column("id", sa.Integer(), primary_key=True),
|
|
||||||
sa.Column("username", sa.String(length=50), nullable=False),
|
|
||||||
sa.Column("email", sa.String(length=120), nullable=False),
|
|
||||||
sa.Column("hashed_password", sa.String(length=255), nullable=False),
|
|
||||||
sa.Column("is_active", sa.Boolean(), nullable=True),
|
|
||||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
|
||||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True),
|
|
||||||
)
|
|
||||||
op.create_index(op.f("ix_users_email"), "users", ["email"], unique=True)
|
|
||||||
op.create_index(op.f("ix_users_id"), "users", ["id"], unique=False)
|
|
||||||
op.create_index(op.f("ix_users_username"), "users", ["username"], unique=True)
|
|
||||||
|
|
||||||
# Create sessions table
|
|
||||||
op.create_table(
|
|
||||||
"sessions",
|
|
||||||
sa.Column("id", sa.String(length=36), primary_key=True),
|
|
||||||
sa.Column("user_id", sa.Integer(), nullable=False),
|
|
||||||
sa.Column("token", sa.String(), nullable=False),
|
|
||||||
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
|
|
||||||
sa.Column("is_active", sa.Boolean(), nullable=True),
|
|
||||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
|
||||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
|
||||||
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
|
|
||||||
)
|
|
||||||
op.create_index(op.f("ix_sessions_token"), "sessions", ["token"], unique=True)
|
|
||||||
|
|
||||||
# Create worlds table
|
|
||||||
op.create_table(
|
|
||||||
"worlds",
|
|
||||||
sa.Column("id", sa.Integer(), primary_key=True),
|
|
||||||
sa.Column("name", sa.String(length=255), nullable=False),
|
|
||||||
sa.Column("description", sa.Text(), nullable=True),
|
|
||||||
sa.Column("owner_id", sa.Integer(), nullable=False),
|
|
||||||
sa.Column(
|
|
||||||
"visibility",
|
|
||||||
postgresql.ENUM(
|
|
||||||
"public", "private", "friends_only", name="world_visibility"
|
|
||||||
),
|
|
||||||
nullable=True,
|
|
||||||
),
|
|
||||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
|
||||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True),
|
|
||||||
sa.ForeignKeyConstraint(["owner_id"], ["users.id"]),
|
|
||||||
)
|
|
||||||
op.create_index(op.f("ix_worlds_id"), "worlds", ["id"], unique=False)
|
|
||||||
|
|
||||||
# Create assets table
|
|
||||||
op.create_table(
|
|
||||||
"assets",
|
|
||||||
sa.Column("id", sa.Integer(), primary_key=True),
|
|
||||||
sa.Column("name", sa.String(length=255), nullable=False),
|
|
||||||
sa.Column("description", sa.Text(), nullable=True),
|
|
||||||
sa.Column(
|
|
||||||
"asset_type",
|
|
||||||
postgresql.ENUM(
|
|
||||||
"image", "audio", "video", "document", "model_3d", "other",
|
|
||||||
name="asset_type",
|
|
||||||
),
|
|
||||||
nullable=False,
|
|
||||||
),
|
|
||||||
sa.Column("file_path", sa.String(length=512), nullable=False),
|
|
||||||
sa.Column("file_size", sa.Integer(), nullable=True),
|
|
||||||
sa.Column("mime_type", sa.String(length=128), nullable=True),
|
|
||||||
sa.Column("metadata_json", sa.Text(), nullable=True),
|
|
||||||
sa.Column("world_id", sa.Integer(), nullable=True),
|
|
||||||
sa.Column("owner_id", sa.Integer(), nullable=False),
|
|
||||||
sa.Column("is_public", sa.Boolean(), nullable=False),
|
|
||||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
|
||||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
|
||||||
sa.ForeignKeyConstraint(["owner_id"], ["users.id"]),
|
|
||||||
sa.ForeignKeyConstraint(["world_id"], ["worlds.id"]),
|
|
||||||
)
|
|
||||||
op.create_index(op.f("ix_assets_id"), "assets", ["id"], unique=False)
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
|
||||||
"""Downgrade database schema."""
|
|
||||||
op.drop_index(op.f("ix_assets_id"), table_name="assets")
|
|
||||||
op.drop_table("assets")
|
|
||||||
op.drop_index(op.f("ix_worlds_id"), table_name="worlds")
|
|
||||||
op.drop_table("worlds")
|
|
||||||
op.drop_index(op.f("ix_sessions_token"), table_name="sessions")
|
|
||||||
op.drop_table("sessions")
|
|
||||||
op.drop_index(op.f("ix_users_username"), table_name="users")
|
|
||||||
op.drop_index(op.f("ix_users_id"), table_name="users")
|
|
||||||
op.drop_index(op.f("ix_users_email"), table_name="users")
|
|
||||||
op.drop_table("users")
|
|
||||||
|
|
||||||
# Drop ENUM types
|
|
||||||
postgresql.ENUM(name="asset_type").drop(op.get_bind(), checkfirst=True)
|
|
||||||
postgresql.ENUM(name="world_visibility").drop(op.get_bind(), checkfirst=True)
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
"""Application configuration via pydantic-settings."""
|
|
||||||
|
|
||||||
from functools import lru_cache
|
|
||||||
|
|
||||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
||||||
|
|
||||||
|
|
||||||
class Settings(BaseSettings):
|
|
||||||
# App
|
|
||||||
APP_NAME: str = "AGP Backend"
|
|
||||||
APP_VERSION: str = "0.1.0"
|
|
||||||
DEBUG: bool = True
|
|
||||||
|
|
||||||
# Database (async)
|
|
||||||
DATABASE_URL: str = "postgresql+asyncpg://agp_user:agp_password@localhost:5432/agp_db"
|
|
||||||
|
|
||||||
# Redis
|
|
||||||
REDIS_URL: str = "redis://localhost:6379/0"
|
|
||||||
|
|
||||||
# JWT / Auth
|
|
||||||
# NOTE: production must override SECRET_KEY with a secure random value
|
|
||||||
# (e.g. `python -c "import secrets; print(secrets.token_urlsafe(32))"`)
|
|
||||||
SECRET_KEY: str = "change-me-to-a-secure-random-string"
|
|
||||||
ALGORITHM: str = "HS256"
|
|
||||||
ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
|
|
||||||
|
|
||||||
# CORS
|
|
||||||
CORS_ORIGINS: str = "http://localhost:8080,http://localhost:3000"
|
|
||||||
|
|
||||||
# File uploads
|
|
||||||
UPLOAD_DIR: str = "uploads"
|
|
||||||
MAX_UPLOAD_SIZE_MB: int = 100
|
|
||||||
|
|
||||||
model_config = SettingsConfigDict(
|
|
||||||
env_file=".env",
|
|
||||||
env_file_encoding="utf-8",
|
|
||||||
case_sensitive=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def cors_origins_list(self) -> list[str]:
|
|
||||||
return [origin.strip() for origin in self.CORS_ORIGINS.split(",")]
|
|
||||||
|
|
||||||
|
|
||||||
@lru_cache()
|
|
||||||
def get_settings() -> Settings:
|
|
||||||
"""Return cached settings instance."""
|
|
||||||
return Settings()
|
|
||||||
|
|
||||||
|
|
||||||
# Convenience singleton
|
|
||||||
settings = get_settings()
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
"""Async database engine and session factory."""
|
|
||||||
|
|
||||||
from sqlalchemy.ext.asyncio import (
|
|
||||||
AsyncSession,
|
|
||||||
async_sessionmaker,
|
|
||||||
create_async_engine,
|
|
||||||
)
|
|
||||||
from sqlalchemy.orm import DeclarativeBase
|
|
||||||
|
|
||||||
from app.config import settings
|
|
||||||
|
|
||||||
engine = create_async_engine(
|
|
||||||
settings.DATABASE_URL,
|
|
||||||
echo=settings.DEBUG,
|
|
||||||
pool_size=10,
|
|
||||||
max_overflow=20,
|
|
||||||
)
|
|
||||||
|
|
||||||
async_session_factory = async_sessionmaker(
|
|
||||||
engine,
|
|
||||||
class_=AsyncSession,
|
|
||||||
expire_on_commit=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class Base(DeclarativeBase):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
async def get_db() -> AsyncSession:
|
|
||||||
"""Dependency that yields an async database session.
|
|
||||||
|
|
||||||
Handlers are responsible for committing or rolling back their own
|
|
||||||
transactions. This avoids the double-commit problem where the
|
|
||||||
session is committed both here and in the handler.
|
|
||||||
"""
|
|
||||||
async with async_session_factory() as session:
|
|
||||||
try:
|
|
||||||
yield session
|
|
||||||
except Exception:
|
|
||||||
await session.rollback()
|
|
||||||
raise
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
"""Shared FastAPI dependencies, including JWT-based auth."""
|
|
||||||
|
|
||||||
from typing import Annotated
|
|
||||||
|
|
||||||
from fastapi import Depends, HTTPException, status
|
|
||||||
from fastapi.security import OAuth2PasswordBearer
|
|
||||||
from jose import JWTError, jwt
|
|
||||||
from sqlalchemy import select
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from app.config import settings
|
|
||||||
from app.database import get_db
|
|
||||||
from app.models.user import User
|
|
||||||
|
|
||||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login")
|
|
||||||
|
|
||||||
|
|
||||||
async def get_current_user(
|
|
||||||
token: Annotated[str, Depends(oauth2_scheme)],
|
|
||||||
db: Annotated[AsyncSession, Depends(get_db)],
|
|
||||||
) -> User:
|
|
||||||
"""Resolve the JWT bearer token to a User, or raise 401."""
|
|
||||||
credentials_exception = HTTPException(
|
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
||||||
detail="Could not validate credentials",
|
|
||||||
headers={"WWW-Authenticate": "Bearer"},
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
payload = jwt.decode(
|
|
||||||
token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM]
|
|
||||||
)
|
|
||||||
user_id: str | None = payload.get("sub")
|
|
||||||
if user_id is None:
|
|
||||||
raise credentials_exception
|
|
||||||
except JWTError:
|
|
||||||
raise credentials_exception
|
|
||||||
|
|
||||||
try:
|
|
||||||
user_id_int = int(user_id)
|
|
||||||
except (ValueError, TypeError):
|
|
||||||
raise credentials_exception
|
|
||||||
|
|
||||||
result = await db.execute(select(User).where(User.id == user_id_int))
|
|
||||||
user = result.scalar_one_or_none()
|
|
||||||
if user is None:
|
|
||||||
raise credentials_exception
|
|
||||||
if not user.is_active:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
|
||||||
detail="User account is inactive",
|
|
||||||
)
|
|
||||||
return user
|
|
||||||
|
|
||||||
|
|
||||||
CurrentUser = Annotated[User, Depends(get_current_user)]
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
"""FastAPI application entry point."""
|
|
||||||
|
|
||||||
from contextlib import asynccontextmanager
|
|
||||||
|
|
||||||
from fastapi import FastAPI
|
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
|
||||||
|
|
||||||
from app.config import settings
|
|
||||||
from app.routers import auth, users, worlds, assets
|
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
|
||||||
async def lifespan(app: FastAPI):
|
|
||||||
"""Application lifespan: startup and shutdown events."""
|
|
||||||
# Startup: initialize database, connect to Redis, etc.
|
|
||||||
yield
|
|
||||||
# Shutdown: close connections, cleanup
|
|
||||||
|
|
||||||
|
|
||||||
app = FastAPI(
|
|
||||||
title=settings.APP_NAME,
|
|
||||||
version=settings.APP_VERSION,
|
|
||||||
description="Agentic Game Platform - Backend API",
|
|
||||||
lifespan=lifespan,
|
|
||||||
)
|
|
||||||
|
|
||||||
# CORS
|
|
||||||
app.add_middleware(
|
|
||||||
CORSMiddleware,
|
|
||||||
allow_origins=settings.cors_origins_list,
|
|
||||||
allow_credentials=True,
|
|
||||||
allow_methods=["*"],
|
|
||||||
allow_headers=["*"],
|
|
||||||
)
|
|
||||||
|
|
||||||
# Include routers
|
|
||||||
app.include_router(auth.router, prefix="/api/auth", tags=["auth"])
|
|
||||||
app.include_router(users.router, prefix="/api/users", tags=["users"])
|
|
||||||
app.include_router(worlds.router, prefix="/api/worlds", tags=["worlds"])
|
|
||||||
app.include_router(assets.router, prefix="/api/assets", tags=["assets"])
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/health")
|
|
||||||
async def health_check():
|
|
||||||
"""Simple health check endpoint."""
|
|
||||||
return {"status": "ok", "app": settings.APP_NAME}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
from app.models.user import User
|
|
||||||
from app.models.session import Session
|
|
||||||
from app.models.world import World
|
|
||||||
from app.models.asset import Asset
|
|
||||||
|
|
||||||
__all__ = ["User", "Session", "World", "Asset"]
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
from sqlalchemy import Column, Integer, String, ForeignKey, Text, DateTime, Boolean
|
|
||||||
from sqlalchemy.dialects.postgresql import ENUM
|
|
||||||
from sqlalchemy.orm import relationship
|
|
||||||
from sqlalchemy.sql import func
|
|
||||||
import enum
|
|
||||||
|
|
||||||
from app.database import Base
|
|
||||||
|
|
||||||
|
|
||||||
class AssetType(str, enum.Enum):
|
|
||||||
IMAGE = "image"
|
|
||||||
AUDIO = "audio"
|
|
||||||
VIDEO = "video"
|
|
||||||
DOCUMENT = "document"
|
|
||||||
MODEL_3D = "model_3d"
|
|
||||||
OTHER = "other"
|
|
||||||
|
|
||||||
|
|
||||||
class Asset(Base):
|
|
||||||
__tablename__ = "assets"
|
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, index=True)
|
|
||||||
name = Column(String(255), nullable=False)
|
|
||||||
description = Column(Text, nullable=True)
|
|
||||||
asset_type = Column(
|
|
||||||
ENUM(AssetType, name="asset_type", create_type=False),
|
|
||||||
nullable=False,
|
|
||||||
)
|
|
||||||
file_path = Column(String(512), nullable=False)
|
|
||||||
file_size = Column(Integer, nullable=True)
|
|
||||||
mime_type = Column(String(128), nullable=True)
|
|
||||||
metadata_json = Column(Text, nullable=True)
|
|
||||||
world_id = Column(Integer, ForeignKey("worlds.id"), nullable=True)
|
|
||||||
owner_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
|
||||||
is_public = Column(Boolean, default=False, nullable=False)
|
|
||||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
|
||||||
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
|
||||||
|
|
||||||
owner = relationship("User", back_populates="assets")
|
|
||||||
world = relationship("World", back_populates="assets")
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
import uuid
|
|
||||||
|
|
||||||
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Boolean
|
|
||||||
from sqlalchemy.orm import relationship
|
|
||||||
from sqlalchemy.sql import func
|
|
||||||
from app.database import Base
|
|
||||||
|
|
||||||
|
|
||||||
class Session(Base):
|
|
||||||
__tablename__ = "sessions"
|
|
||||||
|
|
||||||
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
|
||||||
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
|
|
||||||
token = Column(String, unique=True, index=True, nullable=False)
|
|
||||||
expires_at = Column(DateTime(timezone=True), nullable=False)
|
|
||||||
is_active = Column(Boolean, default=True)
|
|
||||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
|
||||||
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
|
||||||
|
|
||||||
user = relationship("User", back_populates="sessions")
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
from sqlalchemy import Column, Integer, String, Boolean, DateTime
|
|
||||||
from sqlalchemy.orm import relationship
|
|
||||||
from sqlalchemy.sql import func
|
|
||||||
|
|
||||||
from app.database import Base
|
|
||||||
|
|
||||||
|
|
||||||
class User(Base):
|
|
||||||
__tablename__ = "users"
|
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, index=True)
|
|
||||||
username = Column(String(50), unique=True, index=True, nullable=False)
|
|
||||||
email = Column(String(120), unique=True, index=True, nullable=False)
|
|
||||||
hashed_password = Column(String(255), nullable=False)
|
|
||||||
is_active = Column(Boolean, default=True)
|
|
||||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
|
||||||
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
|
||||||
|
|
||||||
sessions = relationship("Session", back_populates="user", cascade="all, delete-orphan")
|
|
||||||
worlds = relationship("World", back_populates="owner")
|
|
||||||
assets = relationship("Asset", back_populates="owner")
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey
|
|
||||||
from sqlalchemy.dialects.postgresql import ENUM
|
|
||||||
from sqlalchemy.orm import relationship
|
|
||||||
from sqlalchemy.sql import func
|
|
||||||
import enum
|
|
||||||
|
|
||||||
from app.database import Base
|
|
||||||
|
|
||||||
|
|
||||||
class WorldVisibility(enum.Enum):
|
|
||||||
PUBLIC = "public"
|
|
||||||
PRIVATE = "private"
|
|
||||||
FRIENDS_ONLY = "friends_only"
|
|
||||||
|
|
||||||
|
|
||||||
class World(Base):
|
|
||||||
__tablename__ = "worlds"
|
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, index=True)
|
|
||||||
name = Column(String(255), nullable=False)
|
|
||||||
description = Column(Text, nullable=True)
|
|
||||||
owner_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
|
||||||
visibility = Column(
|
|
||||||
ENUM(WorldVisibility, name="world_visibility", create_type=False),
|
|
||||||
default=WorldVisibility.PUBLIC,
|
|
||||||
)
|
|
||||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
|
||||||
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
|
||||||
|
|
||||||
owner = relationship("User", back_populates="worlds")
|
|
||||||
assets = relationship("Asset", back_populates="world", cascade="all, delete-orphan")
|
|
||||||
@@ -1,192 +0,0 @@
|
|||||||
"""Asset management router."""
|
|
||||||
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Query
|
|
||||||
from sqlalchemy import select
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from app.config import settings
|
|
||||||
from app.database import get_db
|
|
||||||
from app.dependencies import CurrentUser
|
|
||||||
from app.models.asset import Asset, AssetType
|
|
||||||
from app.models.world import World
|
|
||||||
from pydantic import BaseModel, Field
|
|
||||||
|
|
||||||
router = APIRouter()
|
|
||||||
|
|
||||||
|
|
||||||
# --- Pydantic schemas ---
|
|
||||||
|
|
||||||
class AssetCreate(BaseModel):
|
|
||||||
"""Schema for creating an asset reference."""
|
|
||||||
name: str = Field(..., min_length=1, max_length=255)
|
|
||||||
description: Optional[str] = None
|
|
||||||
asset_type: AssetType
|
|
||||||
world_id: Optional[int] = None
|
|
||||||
|
|
||||||
|
|
||||||
class AssetResponse(BaseModel):
|
|
||||||
"""Schema for asset responses."""
|
|
||||||
id: int
|
|
||||||
name: str
|
|
||||||
description: Optional[str]
|
|
||||||
asset_type: AssetType
|
|
||||||
file_path: str
|
|
||||||
file_size: Optional[int]
|
|
||||||
mime_type: Optional[str]
|
|
||||||
world_id: Optional[int]
|
|
||||||
owner_id: int
|
|
||||||
is_public: bool
|
|
||||||
created_at: str
|
|
||||||
updated_at: str
|
|
||||||
|
|
||||||
model_config = {"from_attributes": True}
|
|
||||||
|
|
||||||
|
|
||||||
class AssetUpdate(BaseModel):
|
|
||||||
"""Schema for updating an asset."""
|
|
||||||
name: Optional[str] = None
|
|
||||||
description: Optional[str] = None
|
|
||||||
is_public: Optional[bool] = None
|
|
||||||
|
|
||||||
|
|
||||||
# --- Endpoints ---
|
|
||||||
|
|
||||||
@router.post("/", response_model=AssetResponse, status_code=201)
|
|
||||||
async def create_asset(
|
|
||||||
payload: AssetCreate,
|
|
||||||
current_user: CurrentUser,
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
):
|
|
||||||
"""Create a new asset reference."""
|
|
||||||
# Verify world ownership if world_id is provided
|
|
||||||
if payload.world_id is not None:
|
|
||||||
world = await db.get(World, payload.world_id)
|
|
||||||
if world is None:
|
|
||||||
raise HTTPException(status_code=404, detail="World not found")
|
|
||||||
if world.owner_id != current_user.id:
|
|
||||||
raise HTTPException(status_code=403, detail="Not owner of this world")
|
|
||||||
|
|
||||||
asset = Asset(
|
|
||||||
name=payload.name,
|
|
||||||
description=payload.description,
|
|
||||||
asset_type=payload.asset_type,
|
|
||||||
file_path="", # Will be set after upload
|
|
||||||
owner_id=current_user.id,
|
|
||||||
world_id=payload.world_id,
|
|
||||||
is_public=False,
|
|
||||||
)
|
|
||||||
db.add(asset)
|
|
||||||
await db.commit()
|
|
||||||
await db.refresh(asset)
|
|
||||||
return asset
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/", response_model=list[AssetResponse])
|
|
||||||
async def list_assets(
|
|
||||||
current_user: CurrentUser,
|
|
||||||
world_id: Optional[int] = Query(None, description="Filter by world"),
|
|
||||||
asset_type: Optional[AssetType] = Query(None),
|
|
||||||
limit: int = Query(50, ge=1, le=100),
|
|
||||||
offset: int = Query(0, ge=0),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
):
|
|
||||||
"""List assets with optional filters."""
|
|
||||||
stmt = select(Asset).where(Asset.owner_id == current_user.id)
|
|
||||||
|
|
||||||
if world_id is not None:
|
|
||||||
stmt = stmt.where(Asset.world_id == world_id)
|
|
||||||
if asset_type is not None:
|
|
||||||
stmt = stmt.where(Asset.asset_type == asset_type)
|
|
||||||
|
|
||||||
stmt = stmt.order_by(Asset.created_at.desc()).limit(limit).offset(offset)
|
|
||||||
result = await db.execute(stmt)
|
|
||||||
assets = result.scalars().all()
|
|
||||||
return assets
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{asset_id}", response_model=AssetResponse)
|
|
||||||
async def get_asset(
|
|
||||||
asset_id: int,
|
|
||||||
current_user: CurrentUser,
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
):
|
|
||||||
"""Get a single asset by ID."""
|
|
||||||
asset = await db.get(Asset, asset_id)
|
|
||||||
if asset is None or asset.owner_id != current_user.id:
|
|
||||||
raise HTTPException(status_code=404, detail="Asset not found")
|
|
||||||
return asset
|
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/{asset_id}", response_model=AssetResponse)
|
|
||||||
async def update_asset(
|
|
||||||
asset_id: int,
|
|
||||||
payload: AssetUpdate,
|
|
||||||
current_user: CurrentUser,
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
):
|
|
||||||
"""Update an asset."""
|
|
||||||
asset = await db.get(Asset, asset_id)
|
|
||||||
if asset is None or asset.owner_id != current_user.id:
|
|
||||||
raise HTTPException(status_code=404, detail="Asset not found")
|
|
||||||
|
|
||||||
update_data = payload.model_dump(exclude_unset=True)
|
|
||||||
for field, value in update_data.items():
|
|
||||||
setattr(asset, field, value)
|
|
||||||
|
|
||||||
await db.commit()
|
|
||||||
await db.refresh(asset)
|
|
||||||
return asset
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{asset_id}", status_code=204)
|
|
||||||
async def delete_asset(
|
|
||||||
asset_id: int,
|
|
||||||
current_user: CurrentUser,
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
):
|
|
||||||
"""Delete an asset."""
|
|
||||||
asset = await db.get(Asset, asset_id)
|
|
||||||
if asset is None or asset.owner_id != current_user.id:
|
|
||||||
raise HTTPException(status_code=404, detail="Asset not found")
|
|
||||||
|
|
||||||
await db.delete(asset)
|
|
||||||
await db.commit()
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{asset_id}/upload")
|
|
||||||
async def upload_asset_file(
|
|
||||||
asset_id: int,
|
|
||||||
file: UploadFile = File(...),
|
|
||||||
current_user: CurrentUser = None,
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
):
|
|
||||||
"""Upload a file for an existing asset."""
|
|
||||||
asset = await db.get(Asset, asset_id)
|
|
||||||
if asset is None or asset.owner_id != current_user.id:
|
|
||||||
raise HTTPException(status_code=404, detail="Asset not found")
|
|
||||||
|
|
||||||
# Sanitize filename to prevent path traversal
|
|
||||||
safe_filename = file.filename.replace("/", "_").replace("\\", "_") if file.filename else f"asset_{asset_id}"
|
|
||||||
file_path = f"uploads/{asset_id}_{safe_filename}"
|
|
||||||
|
|
||||||
# Validate file size against configured maximum
|
|
||||||
max_bytes = settings.MAX_UPLOAD_SIZE_MB * 1024 * 1024
|
|
||||||
content = await file.read()
|
|
||||||
file_size = len(content)
|
|
||||||
if file_size > max_bytes:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=413,
|
|
||||||
detail=f"File size {file_size} exceeds maximum allowed size {max_bytes} bytes",
|
|
||||||
)
|
|
||||||
|
|
||||||
# TODO: Implement actual file write logic (S3, local filesystem, etc.)
|
|
||||||
asset.file_path = file_path
|
|
||||||
asset.mime_type = file.content_type
|
|
||||||
asset.file_size = file_size
|
|
||||||
|
|
||||||
await db.commit()
|
|
||||||
await db.refresh(asset)
|
|
||||||
|
|
||||||
return {"message": "File upload endpoint", "asset_id": asset_id, "file_path": file_path}
|
|
||||||
@@ -1,131 +0,0 @@
|
|||||||
"""Authentication router - login, register, token refresh."""
|
|
||||||
|
|
||||||
from datetime import datetime, timedelta, timezone
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
|
||||||
from fastapi.security import OAuth2PasswordRequestForm
|
|
||||||
from pydantic import BaseModel, EmailStr
|
|
||||||
|
|
||||||
from jose import jwt
|
|
||||||
from passlib.context import CryptContext
|
|
||||||
|
|
||||||
from app.config import settings
|
|
||||||
from app.database import get_db
|
|
||||||
from app.dependencies import CurrentUser
|
|
||||||
from app.models.user import User
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
from sqlalchemy import select
|
|
||||||
|
|
||||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
|
||||||
|
|
||||||
router = APIRouter()
|
|
||||||
|
|
||||||
|
|
||||||
# --- Pydantic schemas ---
|
|
||||||
|
|
||||||
class TokenResponse(BaseModel):
|
|
||||||
access_token: str
|
|
||||||
token_type: str = "bearer"
|
|
||||||
|
|
||||||
|
|
||||||
class RegisterRequest(BaseModel):
|
|
||||||
username: str
|
|
||||||
email: EmailStr
|
|
||||||
password: str
|
|
||||||
|
|
||||||
|
|
||||||
class UserResponse(BaseModel):
|
|
||||||
id: int
|
|
||||||
username: str
|
|
||||||
email: str
|
|
||||||
is_active: bool
|
|
||||||
created_at: datetime | None = None
|
|
||||||
|
|
||||||
model_config = {"from_attributes": True}
|
|
||||||
|
|
||||||
|
|
||||||
# --- Helpers ---
|
|
||||||
|
|
||||||
def _create_access_token(data: dict) -> str:
|
|
||||||
to_encode = data.copy()
|
|
||||||
expire = datetime.now(timezone.utc) + timedelta(
|
|
||||||
minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES
|
|
||||||
)
|
|
||||||
to_encode.update({"exp": expire})
|
|
||||||
return jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
|
|
||||||
|
|
||||||
|
|
||||||
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
|
||||||
"""Verify a plain password against a hash."""
|
|
||||||
return pwd_context.verify(plain_password, hashed_password)
|
|
||||||
|
|
||||||
|
|
||||||
def hash_password(password: str) -> str:
|
|
||||||
"""Hash a password."""
|
|
||||||
return pwd_context.hash(password)
|
|
||||||
|
|
||||||
|
|
||||||
# --- Endpoints ---
|
|
||||||
|
|
||||||
@router.post("/register", response_model=TokenResponse, status_code=status.HTTP_201_CREATED)
|
|
||||||
async def register(req: RegisterRequest, db: AsyncSession = Depends(get_db)):
|
|
||||||
"""Register a new user and return an access token."""
|
|
||||||
# Check if username already exists
|
|
||||||
result = await db.execute(select(User).where(User.username == req.username))
|
|
||||||
if result.scalar_one_or_none():
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_409_CONFLICT,
|
|
||||||
detail="Username already registered",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Check if email already exists
|
|
||||||
result = await db.execute(select(User).where(User.email == req.email))
|
|
||||||
if result.scalar_one_or_none():
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_409_CONFLICT,
|
|
||||||
detail="Email already registered",
|
|
||||||
)
|
|
||||||
|
|
||||||
user = User(
|
|
||||||
username=req.username,
|
|
||||||
email=req.email,
|
|
||||||
hashed_password=hash_password(req.password),
|
|
||||||
)
|
|
||||||
db.add(user)
|
|
||||||
await db.commit()
|
|
||||||
await db.refresh(user)
|
|
||||||
|
|
||||||
access_token = _create_access_token({"sub": str(user.id)})
|
|
||||||
return TokenResponse(access_token=access_token)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/login", response_model=TokenResponse)
|
|
||||||
async def login(form: OAuth2PasswordRequestForm = Depends(), db: AsyncSession = Depends(get_db)):
|
|
||||||
"""Authenticate user and return an access token.
|
|
||||||
|
|
||||||
Accepts form-encoded credentials (username/password) as expected by
|
|
||||||
OAuth2PasswordBearer, so the Swagger UI "Authorize" flow works correctly.
|
|
||||||
"""
|
|
||||||
result = await db.execute(select(User).where(User.username == form.username))
|
|
||||||
user = result.scalar_one_or_none()
|
|
||||||
|
|
||||||
if not user or not verify_password(form.password, user.hashed_password):
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
||||||
detail="Incorrect username or password",
|
|
||||||
)
|
|
||||||
|
|
||||||
if not user.is_active:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
|
||||||
detail="User account is inactive",
|
|
||||||
)
|
|
||||||
|
|
||||||
access_token = _create_access_token({"sub": str(user.id)})
|
|
||||||
return TokenResponse(access_token=access_token)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/me", response_model=UserResponse)
|
|
||||||
async def get_current_user(current_user: CurrentUser):
|
|
||||||
"""Get current user profile from the JWT token."""
|
|
||||||
return current_user
|
|
||||||
@@ -1,105 +0,0 @@
|
|||||||
"""User management router."""
|
|
||||||
|
|
||||||
from typing import Annotated
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
|
||||||
from pydantic import BaseModel, EmailStr
|
|
||||||
from sqlalchemy import select
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from app.database import get_db
|
|
||||||
from app.dependencies import CurrentUser
|
|
||||||
from app.models.user import User
|
|
||||||
|
|
||||||
router = APIRouter()
|
|
||||||
|
|
||||||
|
|
||||||
# ─── Pydantic schemas ────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
class UserResponse(BaseModel):
|
|
||||||
id: int
|
|
||||||
username: str
|
|
||||||
email: str
|
|
||||||
is_active: bool
|
|
||||||
|
|
||||||
model_config = {"from_attributes": True}
|
|
||||||
|
|
||||||
|
|
||||||
class UserUpdate(BaseModel):
|
|
||||||
email: EmailStr | None = None
|
|
||||||
is_active: bool | None = None
|
|
||||||
|
|
||||||
|
|
||||||
# ─── Endpoints ────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
@router.get("/me", response_model=UserResponse)
|
|
||||||
async def get_current_user_profile(
|
|
||||||
current_user: CurrentUser,
|
|
||||||
):
|
|
||||||
"""Get the currently authenticated user's profile."""
|
|
||||||
return current_user
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{user_id}", response_model=UserResponse)
|
|
||||||
async def get_user(
|
|
||||||
user_id: int,
|
|
||||||
current_user: CurrentUser,
|
|
||||||
db: Annotated[AsyncSession, Depends(get_db)],
|
|
||||||
):
|
|
||||||
"""Get a user by ID. Owners and admins can see any user; others see
|
|
||||||
only their own profile."""
|
|
||||||
result = await db.execute(select(User).where(User.id == user_id))
|
|
||||||
user = result.scalar_one_or_none()
|
|
||||||
if not user:
|
|
||||||
raise HTTPException(status_code=404, detail="User not found")
|
|
||||||
|
|
||||||
# Allow viewing own profile or if user is active (admins always allowed)
|
|
||||||
if user.id != current_user.id and not current_user.is_active:
|
|
||||||
raise HTTPException(status_code=403, detail="Not authorized to view this user")
|
|
||||||
|
|
||||||
return user
|
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/{user_id}", response_model=UserResponse)
|
|
||||||
async def update_user(
|
|
||||||
user_id: int,
|
|
||||||
user_update: UserUpdate,
|
|
||||||
current_user: CurrentUser,
|
|
||||||
db: Annotated[AsyncSession, Depends(get_db)],
|
|
||||||
):
|
|
||||||
"""Update a user. Only the owner (or an active admin) can update."""
|
|
||||||
result = await db.execute(select(User).where(User.id == user_id))
|
|
||||||
user = result.scalar_one_or_none()
|
|
||||||
if not user:
|
|
||||||
raise HTTPException(status_code=404, detail="User not found")
|
|
||||||
|
|
||||||
if user.id != current_user.id and not current_user.is_active:
|
|
||||||
raise HTTPException(status_code=403, detail="Not authorized to update this user")
|
|
||||||
|
|
||||||
if user_update.email is not None:
|
|
||||||
user.email = user_update.email
|
|
||||||
if user_update.is_active is not None:
|
|
||||||
user.is_active = user_update.is_active
|
|
||||||
|
|
||||||
await db.commit()
|
|
||||||
await db.refresh(user)
|
|
||||||
return user
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
|
|
||||||
async def delete_user(
|
|
||||||
user_id: int,
|
|
||||||
current_user: CurrentUser,
|
|
||||||
db: Annotated[AsyncSession, Depends(get_db)],
|
|
||||||
):
|
|
||||||
"""Delete a user. Only the owner (or an active admin) can delete."""
|
|
||||||
result = await db.execute(select(User).where(User.id == user_id))
|
|
||||||
user = result.scalar_one_or_none()
|
|
||||||
if not user:
|
|
||||||
raise HTTPException(status_code=404, detail="User not found")
|
|
||||||
|
|
||||||
if user.id != current_user.id and not current_user.is_active:
|
|
||||||
raise HTTPException(status_code=403, detail="Not authorized to delete this user")
|
|
||||||
|
|
||||||
await db.delete(user)
|
|
||||||
await db.commit()
|
|
||||||
@@ -1,162 +0,0 @@
|
|||||||
"""Worlds router - CRUD for game worlds."""
|
|
||||||
|
|
||||||
from typing import Annotated
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
|
||||||
from pydantic import BaseModel, Field
|
|
||||||
from sqlalchemy import select
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
from sqlalchemy.orm import selectinload
|
|
||||||
|
|
||||||
from app.database import get_db
|
|
||||||
from app.dependencies import CurrentUser
|
|
||||||
from app.models.world import World, WorldVisibility
|
|
||||||
|
|
||||||
router = APIRouter()
|
|
||||||
|
|
||||||
DbDep = Annotated[AsyncSession, Depends(get_db)]
|
|
||||||
|
|
||||||
|
|
||||||
# --- Pydantic schemas ---
|
|
||||||
|
|
||||||
class WorldCreate(BaseModel):
|
|
||||||
"""Schema for creating a world."""
|
|
||||||
name: str = Field(..., min_length=1, max_length=255)
|
|
||||||
description: str | None = None
|
|
||||||
visibility: WorldVisibility = WorldVisibility.PUBLIC
|
|
||||||
|
|
||||||
|
|
||||||
class WorldUpdate(BaseModel):
|
|
||||||
"""Schema for updating a world."""
|
|
||||||
name: str | None = Field(None, min_length=1, max_length=255)
|
|
||||||
description: str | None = None
|
|
||||||
visibility: WorldVisibility | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class WorldResponse(BaseModel):
|
|
||||||
"""Schema for world responses."""
|
|
||||||
id: int
|
|
||||||
name: str
|
|
||||||
description: str | None
|
|
||||||
visibility: WorldVisibility
|
|
||||||
owner_id: int
|
|
||||||
created_at: str | None = None
|
|
||||||
|
|
||||||
model_config = {"from_attributes": True}
|
|
||||||
|
|
||||||
|
|
||||||
# --- Endpoints ---
|
|
||||||
|
|
||||||
@router.get("/", response_model=list[WorldResponse])
|
|
||||||
async def list_worlds(
|
|
||||||
current_user: CurrentUser,
|
|
||||||
db: DbDep,
|
|
||||||
skip: int = Query(0, ge=0),
|
|
||||||
limit: int = Query(50, ge=1, le=100),
|
|
||||||
visibility: WorldVisibility | None = None,
|
|
||||||
):
|
|
||||||
"""List worlds visible to the current user.
|
|
||||||
|
|
||||||
PUBLIC worlds are visible to everyone.
|
|
||||||
PRIVATE/FRIENDS_ONLY worlds are visible to their owner.
|
|
||||||
"""
|
|
||||||
query = select(World).options(selectinload(World.owner))
|
|
||||||
|
|
||||||
# Filter by visibility if specified
|
|
||||||
if visibility:
|
|
||||||
query = query.where(World.visibility == visibility)
|
|
||||||
else:
|
|
||||||
# Show PUBLIC worlds + owner's own worlds
|
|
||||||
query = query.where(
|
|
||||||
(World.visibility == WorldVisibility.PUBLIC)
|
|
||||||
| (World.owner_id == current_user.id)
|
|
||||||
)
|
|
||||||
|
|
||||||
query = query.offset(skip).limit(limit).order_by(World.created_at.desc())
|
|
||||||
result = await db.execute(query)
|
|
||||||
worlds = result.scalars().all()
|
|
||||||
return worlds
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{world_id}", response_model=WorldResponse)
|
|
||||||
async def get_world(
|
|
||||||
world_id: int,
|
|
||||||
current_user: CurrentUser,
|
|
||||||
db: DbDep,
|
|
||||||
):
|
|
||||||
"""Get a single world by ID, respecting visibility."""
|
|
||||||
result = await db.execute(
|
|
||||||
select(World).where(World.id == world_id).options(selectinload(World.owner))
|
|
||||||
)
|
|
||||||
world = result.scalar_one_or_none()
|
|
||||||
if not world:
|
|
||||||
raise HTTPException(status_code=404, detail="World not found")
|
|
||||||
|
|
||||||
# Allow access if world is PUBLIC or owned by current user
|
|
||||||
if world.visibility != WorldVisibility.PUBLIC and world.owner_id != current_user.id:
|
|
||||||
raise HTTPException(status_code=404, detail="World not found")
|
|
||||||
|
|
||||||
return world
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/", response_model=WorldResponse, status_code=201)
|
|
||||||
async def create_world(
|
|
||||||
world_data: WorldCreate,
|
|
||||||
current_user: CurrentUser,
|
|
||||||
db: DbDep,
|
|
||||||
):
|
|
||||||
"""Create a new world."""
|
|
||||||
world = World(
|
|
||||||
name=world_data.name,
|
|
||||||
description=world_data.description,
|
|
||||||
owner_id=current_user.id,
|
|
||||||
visibility=world_data.visibility,
|
|
||||||
)
|
|
||||||
db.add(world)
|
|
||||||
await db.commit()
|
|
||||||
await db.refresh(world)
|
|
||||||
return world
|
|
||||||
|
|
||||||
|
|
||||||
@router.put("/{world_id}", response_model=WorldResponse)
|
|
||||||
async def update_world(
|
|
||||||
world_id: int,
|
|
||||||
world_data: WorldUpdate,
|
|
||||||
current_user: CurrentUser,
|
|
||||||
db: DbDep,
|
|
||||||
):
|
|
||||||
"""Update an existing world."""
|
|
||||||
result = await db.execute(select(World).where(World.id == world_id))
|
|
||||||
world = result.scalar_one_or_none()
|
|
||||||
if not world:
|
|
||||||
raise HTTPException(status_code=404, detail="World not found")
|
|
||||||
|
|
||||||
if world.owner_id != current_user.id:
|
|
||||||
raise HTTPException(status_code=403, detail="Not owner of this world")
|
|
||||||
|
|
||||||
update_data = world_data.model_dump(exclude_unset=True)
|
|
||||||
for field, value in update_data.items():
|
|
||||||
setattr(world, field, value)
|
|
||||||
|
|
||||||
await db.commit()
|
|
||||||
await db.refresh(world)
|
|
||||||
return world
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{world_id}", status_code=204)
|
|
||||||
async def delete_world(
|
|
||||||
world_id: int,
|
|
||||||
current_user: CurrentUser,
|
|
||||||
db: DbDep,
|
|
||||||
):
|
|
||||||
"""Delete a world."""
|
|
||||||
result = await db.execute(select(World).where(World.id == world_id))
|
|
||||||
world = result.scalar_one_or_none()
|
|
||||||
if not world:
|
|
||||||
raise HTTPException(status_code=404, detail="World not found")
|
|
||||||
|
|
||||||
if world.owner_id != current_user.id:
|
|
||||||
raise HTTPException(status_code=403, detail="Not owner of this world")
|
|
||||||
|
|
||||||
await db.delete(world)
|
|
||||||
await db.commit()
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
version: "3.9"
|
|
||||||
|
|
||||||
services:
|
|
||||||
postgres:
|
|
||||||
image: postgres:16-alpine
|
|
||||||
environment:
|
|
||||||
POSTGRES_USER: agp_user
|
|
||||||
POSTGRES_PASSWORD: agp_password
|
|
||||||
POSTGRES_DB: agp_db
|
|
||||||
ports:
|
|
||||||
- "5432:5432"
|
|
||||||
volumes:
|
|
||||||
- postgres_data:/var/lib/postgresql/data
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD-SHELL", "pg_isready -U agp_user -d agp_db"]
|
|
||||||
interval: 5s
|
|
||||||
timeout: 5s
|
|
||||||
retries: 5
|
|
||||||
|
|
||||||
redis:
|
|
||||||
image: redis:7-alpine
|
|
||||||
ports:
|
|
||||||
- "6379:6379"
|
|
||||||
volumes:
|
|
||||||
- redis_data:/data
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD", "redis-cli", "ping"]
|
|
||||||
interval: 5s
|
|
||||||
timeout: 5s
|
|
||||||
retries: 5
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
postgres_data:
|
|
||||||
redis_data:
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
fastapi==0.115.6
|
|
||||||
uvicorn[standard]==0.34.0
|
|
||||||
sqlalchemy==2.0.36
|
|
||||||
alembic==1.14.0
|
|
||||||
psycopg2-binary==2.9.10
|
|
||||||
asyncpg==0.31.0
|
|
||||||
redis==5.2.1
|
|
||||||
pydantic==2.10.3
|
|
||||||
pydantic-settings==2.7.0
|
|
||||||
python-jose[cryptography]==3.3.0
|
|
||||||
passlib[bcrypt]==1.7.4
|
|
||||||
python-multipart==0.0.20
|
|
||||||
email-validator==2.2.0
|
|
||||||
httpx==0.28.1
|
|
||||||
pytest==8.3.4
|
|
||||||
pytest-asyncio==0.25.0
|
|
||||||
pytest-cov==6.0.0
|
|
||||||
+4
-12
@@ -6,19 +6,11 @@ config_version=5
|
|||||||
|
|
||||||
[application]
|
[application]
|
||||||
|
|
||||||
config/name="AGP-Game"
|
config/name="AGP Game"
|
||||||
run/main_scene="res://scenes/main_menu.tscn"
|
run/main_scene="res://scenes/main.tscn"
|
||||||
config/features=PackedStringArray("4.6.2")
|
config/features=PackedStringArray("4.6.2")
|
||||||
|
|
||||||
[autoload]
|
|
||||||
|
|
||||||
[display]
|
[display]
|
||||||
|
|
||||||
window/size/viewport_width=1920
|
window/size/viewport_width=1280
|
||||||
window/size/viewport_height=1080
|
window/size/viewport_height=720
|
||||||
window/stretch/mode="canvas_items"
|
|
||||||
|
|
||||||
[rendering]
|
|
||||||
|
|
||||||
renderer/rendering_method="gl_compatibility"
|
|
||||||
renderer/rendering_method.mobile="gl_compatibility"
|
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
[gd_scene load_steps=2 format=3 uid="uid://main_scene"]
|
||||||
|
|
||||||
|
[ext_resource type="Script" path="res://scripts/chunk_stream_manager.gd" id="1_chunk_stream"]
|
||||||
|
|
||||||
|
[node name="Main" type="Node3D"]
|
||||||
|
script = ExtResource("1_chunk_stream")
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
[gd_scene load_steps=2 format=3]
|
|
||||||
|
|
||||||
[ext_resource type="Script" path="res://scripts/main_menu.gd" id="1_main"]
|
|
||||||
|
|
||||||
[node name="MainMenu" type="Control"]
|
|
||||||
anchors_preset = 15
|
|
||||||
anchor_right = 1.0
|
|
||||||
anchor_bottom = 1.0
|
|
||||||
grow_horizontal = 2
|
|
||||||
grow_vertical = 2
|
|
||||||
script = ExtResource("1_main")
|
|
||||||
|
|
||||||
[node name="CenterContainer" type="CenterContainer" parent="."]
|
|
||||||
layout_mode = 3
|
|
||||||
anchors_preset = 15
|
|
||||||
anchor_right = 1.0
|
|
||||||
anchor_bottom = 1.0
|
|
||||||
grow_horizontal = 2
|
|
||||||
grow_vertical = 2
|
|
||||||
|
|
||||||
[node name="VBoxContainer" type="VBoxContainer" parent="CenterContainer"]
|
|
||||||
layout_mode = 1
|
|
||||||
theme_override_constants/separation = 16
|
|
||||||
size_flags_horizontal = 4
|
|
||||||
|
|
||||||
[node name="TitleLabel" type="Label" parent="CenterContainer/VBoxContainer"]
|
|
||||||
layout_mode = 1
|
|
||||||
text = "AGP Game"
|
|
||||||
horizontal_alignment = 1
|
|
||||||
theme_override_font_sizes/font_size = 72
|
|
||||||
|
|
||||||
[node name="PlayButton" type="Button" parent="CenterContainer/VBoxContainer"]
|
|
||||||
layout_mode = 1
|
|
||||||
text = "Play"
|
|
||||||
size_flags_horizontal = 4
|
|
||||||
|
|
||||||
[node name="QuitButton" type="Button" parent="CenterContainer/VBoxContainer"]
|
|
||||||
layout_mode = 1
|
|
||||||
text = "Quit"
|
|
||||||
size_flags_horizontal = 4
|
|
||||||
|
|
||||||
[connection signal="pressed" from="CenterContainer/VBoxContainer/PlayButton" to="." method="_on_play_button_pressed"]
|
|
||||||
[connection signal="pressed" from="CenterContainer/VBoxContainer/QuitButton" to="." method="_on_quit_button_pressed"]
|
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
class_name Chunk
|
||||||
|
extends Node3D
|
||||||
|
## A single world chunk. Holds the chunk coordinate, bounds, and the
|
||||||
|
## visual/gameplay content for that region of the world.
|
||||||
|
##
|
||||||
|
## Chunks are the atomic unit of world geometry, gameplay objects, and
|
||||||
|
## persistence (see docs/architecture/world.md). The server loads and saves
|
||||||
|
## whole chunks; clients stream the chunks around them.
|
||||||
|
|
||||||
|
## Default chunk dimensions in world units (tiles). Matches the constants in
|
||||||
|
## docs/architecture/world.md section 2.2.
|
||||||
|
const CHUNK_SIZE_X: int = 32
|
||||||
|
const CHUNK_SIZE_Y: int = 32
|
||||||
|
const CHUNK_SIZE_Z: int = 1
|
||||||
|
|
||||||
|
## World-space origin of chunk coordinate (0,0).
|
||||||
|
const CHUNK_ORIGIN := Vector3(0.0, 0.0, 0.0)
|
||||||
|
|
||||||
|
## Chunk coordinate (cx, cy, cz) in chunk space.
|
||||||
|
var coord := Vector3i.ZERO
|
||||||
|
|
||||||
|
## The canonical chunk key: "{world_id}:{room_id}:{cx}:{cy}:{cz}".
|
||||||
|
var key: String = ""
|
||||||
|
|
||||||
|
## World and room identifiers this chunk belongs to.
|
||||||
|
var world_id: String = ""
|
||||||
|
var room_id: String = ""
|
||||||
|
|
||||||
|
## Format version of the chunk payload (currently 1).
|
||||||
|
var format_version: int = 1
|
||||||
|
|
||||||
|
## Revision counter, incremented on every write. Used for optimistic
|
||||||
|
## concurrency and change detection.
|
||||||
|
var revision: int = 0
|
||||||
|
|
||||||
|
## Whether this chunk has been modified in memory but not yet flushed.
|
||||||
|
var dirty: bool = false
|
||||||
|
|
||||||
|
## Container for static objects (props, geometry) in this chunk.
|
||||||
|
var objects: Array[Node3D] = []
|
||||||
|
|
||||||
|
## Container for dynamic object templates (spawnable entities).
|
||||||
|
var dynamic_templates: Array[Node3D] = []
|
||||||
|
|
||||||
|
## The world-space bounding box of this chunk.
|
||||||
|
var bounds: AABB
|
||||||
|
|
||||||
|
|
||||||
|
func _init(p_coord: Vector3i = Vector3i.ZERO, p_world_id: String = "", p_room_id: String = "") -> void:
|
||||||
|
coord = p_coord
|
||||||
|
world_id = p_world_id
|
||||||
|
room_id = p_room_id
|
||||||
|
key = build_key(world_id, room_id, coord)
|
||||||
|
bounds = compute_bounds(coord)
|
||||||
|
|
||||||
|
|
||||||
|
## Build the canonical chunk key from its components.
|
||||||
|
static func build_key(p_world_id: String, p_room_id: String, p_coord: Vector3i) -> String:
|
||||||
|
return "%s:%s:%d:%d:%d" % [p_world_id, p_room_id, p_coord.x, p_coord.y, p_coord.z]
|
||||||
|
|
||||||
|
|
||||||
|
## Compute the world-space bounding box for a chunk coordinate.
|
||||||
|
static func compute_bounds(p_coord: Vector3i) -> AABB:
|
||||||
|
var min_pos := Vector3(
|
||||||
|
CHUNK_ORIGIN.x + p_coord.x * CHUNK_SIZE_X,
|
||||||
|
CHUNK_ORIGIN.y + p_coord.y * CHUNK_SIZE_Y,
|
||||||
|
CHUNK_ORIGIN.z + p_coord.z * CHUNK_SIZE_Z
|
||||||
|
)
|
||||||
|
return AABB(min_pos, Vector3(CHUNK_SIZE_X, CHUNK_SIZE_Y, CHUNK_SIZE_Z))
|
||||||
|
|
||||||
|
|
||||||
|
## Compute the chunk coordinate that contains a world-space position.
|
||||||
|
static func coord_for_position(p_pos: Vector3) -> Vector3i:
|
||||||
|
return Vector3i(
|
||||||
|
int(floor((p_pos.x - CHUNK_ORIGIN.x) / CHUNK_SIZE_X)),
|
||||||
|
int(floor((p_pos.y - CHUNK_ORIGIN.y) / CHUNK_SIZE_Y)),
|
||||||
|
int(floor((p_pos.z - CHUNK_ORIGIN.z) / CHUNK_SIZE_Z))
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
## Add a static object to this chunk.
|
||||||
|
func add_object(p_object: Node3D) -> void:
|
||||||
|
objects.append(p_object)
|
||||||
|
add_child(p_object)
|
||||||
|
|
||||||
|
|
||||||
|
## Add a dynamic object template to this chunk.
|
||||||
|
func add_dynamic_template(p_template: Node3D) -> void:
|
||||||
|
dynamic_templates.append(p_template)
|
||||||
|
add_child(p_template)
|
||||||
|
|
||||||
|
|
||||||
|
## Remove all objects from this chunk.
|
||||||
|
func clear_objects() -> void:
|
||||||
|
for obj in objects:
|
||||||
|
if is_instance_valid(obj):
|
||||||
|
obj.queue_free()
|
||||||
|
objects.clear()
|
||||||
|
dynamic_templates.clear()
|
||||||
|
|
||||||
|
|
||||||
|
## Mark this chunk as dirty (modified but not yet persisted).
|
||||||
|
func mark_dirty() -> void:
|
||||||
|
dirty = true
|
||||||
|
revision += 1
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://dc6j7kqjitc6g
|
||||||
@@ -0,0 +1,688 @@
|
|||||||
|
class_name ChunkDataDecoder
|
||||||
|
extends RefCounted
|
||||||
|
## Decodes chunk binary payloads (and JSON) into logical chunk data.
|
||||||
|
##
|
||||||
|
## Implements the binary format spec from docs/architecture/world.md §3.6.
|
||||||
|
## Both the Godot client and Python backend use this class to ensure
|
||||||
|
## identical encoding/decoding behavior.
|
||||||
|
##
|
||||||
|
## ## Binary Format Overview
|
||||||
|
##
|
||||||
|
## ```
|
||||||
|
## Header (48 bytes):
|
||||||
|
## [0:4] magic "AGPC"
|
||||||
|
## [4:6] version (uint16 LE)
|
||||||
|
## [6:8] header_len (uint16 LE)
|
||||||
|
## [8:12] flags (uint32 LE)
|
||||||
|
## [12:16] payload_len (uint32 LE)
|
||||||
|
## [16:48] checksum (SHA-256 hex, 32 bytes)
|
||||||
|
##
|
||||||
|
## Payload Body (sections):
|
||||||
|
## [section_id:1][section_len:4][data:section_len]
|
||||||
|
## ```
|
||||||
|
|
||||||
|
|
||||||
|
## Content flags parsed from a chunk.
|
||||||
|
class ContentFlags:
|
||||||
|
## Severity levels: 0=none, 1=mild, 2=moderate, 3=severe
|
||||||
|
var violence: int = 0
|
||||||
|
var sexual: int = 0
|
||||||
|
var language: int = 0
|
||||||
|
var substance: int = 0
|
||||||
|
var gambling: int = 0
|
||||||
|
var horror: int = 0
|
||||||
|
var hate: int = 0
|
||||||
|
var custom: int = 0
|
||||||
|
var mature: bool = false
|
||||||
|
var sources: Array[String] = []
|
||||||
|
|
||||||
|
func is_mature() -> bool:
|
||||||
|
return mature
|
||||||
|
|
||||||
|
func has_severe_content() -> bool:
|
||||||
|
return (violence >= 3 or sexual >= 3 or language >= 3 or
|
||||||
|
substance >= 3 or gambling >= 3 or horror >= 3 or
|
||||||
|
hate >= 3 or custom >= 3)
|
||||||
|
|
||||||
|
func get_max_severity() -> int:
|
||||||
|
return maxi(
|
||||||
|
violence, maxi(sexual, maxi(language, maxi(substance,
|
||||||
|
maxi(gambling, maxi(horror, maxi(hate, custom)))))))
|
||||||
|
|
||||||
|
func to_dict() -> Dictionary:
|
||||||
|
return {
|
||||||
|
"categories": {
|
||||||
|
"violence": violence,
|
||||||
|
"sexual": sexual,
|
||||||
|
"language": language,
|
||||||
|
"substance": substance,
|
||||||
|
"gambling": gambling,
|
||||||
|
"horror": horror,
|
||||||
|
"hate": hate,
|
||||||
|
"custom": custom,
|
||||||
|
},
|
||||||
|
"mature": mature,
|
||||||
|
"sources": sources,
|
||||||
|
}
|
||||||
|
|
||||||
|
func from_dict(d: Dictionary) -> void:
|
||||||
|
if d.has("categories"):
|
||||||
|
violence = d["categories"].get("violence", 0)
|
||||||
|
sexual = d["categories"].get("sexual", 0)
|
||||||
|
language = d["categories"].get("language", 0)
|
||||||
|
substance = d["categories"].get("substance", 0)
|
||||||
|
gambling = d["categories"].get("gambling", 0)
|
||||||
|
horror = d["categories"].get("horror", 0)
|
||||||
|
hate = d["categories"].get("hate", 0)
|
||||||
|
custom = d["categories"].get("custom", 0)
|
||||||
|
mature = d.get("mature", false)
|
||||||
|
sources = d.get("sources", [])
|
||||||
|
|
||||||
|
|
||||||
|
## A tile layer definition (palette + dense tile indices).
|
||||||
|
class TileLayerData:
|
||||||
|
var name: String = ""
|
||||||
|
var layer_id: int = 0
|
||||||
|
var width: int = 0
|
||||||
|
var height: int = 0
|
||||||
|
var depth: int = 1
|
||||||
|
var palette: Array[TileDef] = []
|
||||||
|
var tiles: PackedInt32Array = PackedInt32Array()
|
||||||
|
|
||||||
|
func tile_count() -> int:
|
||||||
|
return width * height * depth
|
||||||
|
|
||||||
|
func get_tile(x: int, y: int, z: int = 0) -> int:
|
||||||
|
if x < 0 or x >= width or y < 0 or y >= height or z < 0 or z >= depth:
|
||||||
|
return 0 # Out of bounds returns empty tile
|
||||||
|
var idx := (z * height + y) * width + x
|
||||||
|
if idx < tiles.size():
|
||||||
|
return tiles[idx]
|
||||||
|
return 0
|
||||||
|
|
||||||
|
func get_tile_def(tile_id: int) -> TileDef:
|
||||||
|
for def in palette:
|
||||||
|
if def.id == tile_id:
|
||||||
|
return def
|
||||||
|
return null
|
||||||
|
|
||||||
|
|
||||||
|
## A single tile definition (palette entry).
|
||||||
|
class TileDef:
|
||||||
|
var id: int = 0
|
||||||
|
var asset_id: String = ""
|
||||||
|
var solid: bool = false
|
||||||
|
var flags: int = 0
|
||||||
|
var variants: int = 1
|
||||||
|
|
||||||
|
func is_solid() -> bool:
|
||||||
|
return solid
|
||||||
|
|
||||||
|
func is_walkable() -> bool:
|
||||||
|
return (flags & 0x01) != 0
|
||||||
|
|
||||||
|
func is_climbable() -> bool:
|
||||||
|
return (flags & 0x02) != 0
|
||||||
|
|
||||||
|
func is_liquid() -> bool:
|
||||||
|
return (flags & 0x04) != 0
|
||||||
|
|
||||||
|
|
||||||
|
## A world object parsed from chunk data.
|
||||||
|
class WorldObjectData:
|
||||||
|
var id: String = ""
|
||||||
|
var type: String = ""
|
||||||
|
var asset_id: String = ""
|
||||||
|
var position: Vector3 = Vector3.ZERO
|
||||||
|
var rotation: Vector4 = Vector4(0, 0, 0, 1)
|
||||||
|
var scale: Vector3 = Vector3.ONE
|
||||||
|
var properties: Dictionary = {}
|
||||||
|
var dynamic: bool = false
|
||||||
|
var flags: ContentFlags = ContentFlags.new()
|
||||||
|
|
||||||
|
|
||||||
|
## The complete decoded chunk payload.
|
||||||
|
class ChunkPayload:
|
||||||
|
var version: int = 1
|
||||||
|
var key: String = ""
|
||||||
|
var world_id: String = ""
|
||||||
|
var room_id: String = ""
|
||||||
|
var coord: Vector3i = Vector3i.ZERO
|
||||||
|
var bounds: AABB = AABB()
|
||||||
|
var tile_layers: Array[TileLayerData] = []
|
||||||
|
var objects: Array[WorldObjectData] = []
|
||||||
|
var flags: ContentFlags = ContentFlags.new()
|
||||||
|
var created_at: int = 0 # Unix timestamp
|
||||||
|
var updated_at: int = 0 # Unix timestamp
|
||||||
|
var revision: int = 0
|
||||||
|
var author: String = ""
|
||||||
|
var checksum: String = ""
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Binary Decoding ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const MAGIC: PackedByteArray = PackedByteArray([0x41, 0x47, 0x50, 0x43]) # "AGPC"
|
||||||
|
const HEADER_SIZE: int = 48
|
||||||
|
const SECTION_META: int = 0x01
|
||||||
|
const SECTION_TILE: int = 0x02
|
||||||
|
const SECTION_OBJECT: int = 0x03
|
||||||
|
const SECTION_FLAGS: int = 0x04
|
||||||
|
const SECTION_REVISION_META: int = 0x05
|
||||||
|
|
||||||
|
|
||||||
|
## Decode a binary chunk payload into a ChunkPayload.
|
||||||
|
## Returns null if the data is invalid or version is unsupported.
|
||||||
|
static func decode_binary(data: PackedByteArray) -> ChunkPayload:
|
||||||
|
if data.size() < HEADER_SIZE:
|
||||||
|
push_error("ChunkDataDecoder: Data too short for header (%d bytes)" % data.size())
|
||||||
|
return null
|
||||||
|
|
||||||
|
# Verify magic
|
||||||
|
var magic := PackedByteArray(data.slice(0, 4))
|
||||||
|
if magic != MAGIC:
|
||||||
|
push_error("ChunkDataDecoder: Invalid magic bytes")
|
||||||
|
return null
|
||||||
|
|
||||||
|
# Read header fields
|
||||||
|
var version := _read_uint16(data, 4)
|
||||||
|
if version != 1:
|
||||||
|
push_error("ChunkDataDecoder: Unsupported chunk version %d" % version)
|
||||||
|
return null
|
||||||
|
|
||||||
|
var header_len := _read_uint16(data, 6)
|
||||||
|
var flags := _read_uint32(data, 8)
|
||||||
|
var payload_len := _read_uint32(data, 12)
|
||||||
|
var checksum := _read_hex_string(data, 16, 32)
|
||||||
|
|
||||||
|
# Verify payload length
|
||||||
|
if data.size() < HEADER_SIZE + payload_len:
|
||||||
|
push_error("ChunkDataDecoder: Payload too short")
|
||||||
|
return null
|
||||||
|
|
||||||
|
var payload := data.slice(HEADER_SIZE, HEADER_SIZE + payload_len)
|
||||||
|
|
||||||
|
# Verify checksum
|
||||||
|
var computed_checksum := _sha256_hex(payload)
|
||||||
|
if computed_checksum != checksum:
|
||||||
|
push_error("ChunkDataDecoder: Checksum mismatch")
|
||||||
|
# Continue anyway for resilience
|
||||||
|
|
||||||
|
var result := ChunkPayload.new()
|
||||||
|
result.version = version
|
||||||
|
result.checksum = checksum
|
||||||
|
|
||||||
|
# Parse sections
|
||||||
|
var offset := 0
|
||||||
|
while offset < payload.size():
|
||||||
|
if offset + 5 > payload.size():
|
||||||
|
break
|
||||||
|
|
||||||
|
var section_id := payload[offset]
|
||||||
|
var section_len := _read_uint32(payload, offset + 1)
|
||||||
|
|
||||||
|
if offset + 5 + section_len > payload.size():
|
||||||
|
break
|
||||||
|
|
||||||
|
var section_data := payload.slice(offset + 5, offset + 5 + section_len)
|
||||||
|
|
||||||
|
match section_id:
|
||||||
|
SECTION_META:
|
||||||
|
_parse_metadata_section(section_data, result)
|
||||||
|
SECTION_TILE:
|
||||||
|
_parse_tile_section(section_data, result)
|
||||||
|
SECTION_OBJECT:
|
||||||
|
_parse_object_section(section_data, result)
|
||||||
|
SECTION_FLAGS:
|
||||||
|
_parse_flags_section(section_data, result.flags)
|
||||||
|
SECTION_REVISION_META:
|
||||||
|
_parse_revision_meta_section(section_data, result)
|
||||||
|
|
||||||
|
offset += 5 + section_len
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
## Encode a ChunkPayload into binary format.
|
||||||
|
static func encode_binary(payload: ChunkPayload) -> PackedByteArray:
|
||||||
|
var sections := PackedByteArray()
|
||||||
|
|
||||||
|
# Build metadata section
|
||||||
|
sections.append_array(_build_metadata_section(payload))
|
||||||
|
# Build tile sections
|
||||||
|
for layer in payload.tile_layers:
|
||||||
|
sections.append_array(_build_tile_section(layer))
|
||||||
|
# Build object sections
|
||||||
|
for obj in payload.objects:
|
||||||
|
sections.append_array(_build_object_section(obj))
|
||||||
|
# Build flags section
|
||||||
|
sections.append_array(_build_flags_section(payload.flags))
|
||||||
|
# Build revision meta section
|
||||||
|
sections.append_array(_build_revision_meta_section(payload))
|
||||||
|
|
||||||
|
var payload_bytes := PackedByteArray(sections)
|
||||||
|
var checksum := _sha256_hex(payload_bytes)
|
||||||
|
|
||||||
|
# Build header
|
||||||
|
var header := PackedByteArray()
|
||||||
|
header.append_array(MAGIC)
|
||||||
|
header.append_array(_write_uint16(1)) # version
|
||||||
|
header.append_array(_write_uint16(0)) # header_len (after this field)
|
||||||
|
header.append_array(_write_uint32(0)) # flags
|
||||||
|
header.append_array(_write_uint32(payload_bytes.size()))
|
||||||
|
header.append_array(_hex_to_bytes(checksum))
|
||||||
|
|
||||||
|
return header + payload_bytes
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Section Parsers ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
static func _parse_metadata_section(data: PackedByteArray, result: ChunkPayload) -> void:
|
||||||
|
var offset := 0
|
||||||
|
|
||||||
|
# world_id (length-prefixed string)
|
||||||
|
var world_id_len := _read_uint16(data, offset)
|
||||||
|
offset += 2
|
||||||
|
result.world_id = data.slice(offset, offset + world_id_len).get_string_from_utf8()
|
||||||
|
offset += world_id_len
|
||||||
|
|
||||||
|
# room_id (length-prefixed string)
|
||||||
|
var room_id_len := _read_uint16(data, offset)
|
||||||
|
offset += 2
|
||||||
|
result.room_id = data.slice(offset, offset + room_id_len).get_string_from_utf8()
|
||||||
|
offset += room_id_len
|
||||||
|
|
||||||
|
# coord (3 × int32)
|
||||||
|
result.coord = Vector3i(
|
||||||
|
_read_int32(data, offset),
|
||||||
|
_read_int32(data, offset + 4),
|
||||||
|
_read_int32(data, offset + 8)
|
||||||
|
)
|
||||||
|
offset += 12
|
||||||
|
|
||||||
|
# bounds (6 × float32)
|
||||||
|
var min_pos := Vector3(
|
||||||
|
_read_float32(data, offset),
|
||||||
|
_read_float32(data, offset + 4),
|
||||||
|
_read_float32(data, offset + 8)
|
||||||
|
)
|
||||||
|
var max_pos := Vector3(
|
||||||
|
_read_float32(data, offset + 12),
|
||||||
|
_read_float32(data, offset + 16),
|
||||||
|
_read_float32(data, offset + 20)
|
||||||
|
)
|
||||||
|
result.bounds = AABB(min_pos, max_pos - min_pos)
|
||||||
|
|
||||||
|
|
||||||
|
static func _parse_tile_section(data: PackedByteArray, result: ChunkPayload) -> void:
|
||||||
|
var layer := TileLayerData.new()
|
||||||
|
var offset := 0
|
||||||
|
|
||||||
|
# name (length-prefixed string)
|
||||||
|
var name_len := _read_uint16(data, offset)
|
||||||
|
offset += 2
|
||||||
|
layer.name = data.slice(offset, offset + name_len).get_string_from_utf8()
|
||||||
|
offset += name_len
|
||||||
|
|
||||||
|
# layer_id (uint16)
|
||||||
|
layer.layer_id = _read_uint16(data, offset)
|
||||||
|
offset += 2
|
||||||
|
|
||||||
|
# dimensions (3 × uint16)
|
||||||
|
layer.width = _read_uint16(data, offset)
|
||||||
|
offset += 2
|
||||||
|
layer.height = _read_uint16(data, offset)
|
||||||
|
offset += 2
|
||||||
|
layer.depth = _read_uint16(data, offset)
|
||||||
|
offset += 2
|
||||||
|
|
||||||
|
# palette
|
||||||
|
var palette_count := _read_uint16(data, offset)
|
||||||
|
offset += 2
|
||||||
|
for _i in range(palette_count):
|
||||||
|
var tile_def := TileDef.new()
|
||||||
|
tile_def.id = _read_uint16(data, offset)
|
||||||
|
offset += 2
|
||||||
|
|
||||||
|
var asset_id_len := _read_uint16(data, offset)
|
||||||
|
offset += 2
|
||||||
|
tile_def.asset_id = data.slice(offset, offset + asset_id_len).get_string_from_utf8()
|
||||||
|
offset += asset_id_len
|
||||||
|
|
||||||
|
tile_def.solid = data[offset] != 0
|
||||||
|
offset += 1
|
||||||
|
|
||||||
|
tile_def.flags = _read_uint32(data, offset)
|
||||||
|
offset += 4
|
||||||
|
|
||||||
|
tile_def.variants = data[offset]
|
||||||
|
offset += 1
|
||||||
|
|
||||||
|
layer.palette.append(tile_def)
|
||||||
|
|
||||||
|
# tiles
|
||||||
|
var tile_count := _read_uint32(data, offset)
|
||||||
|
offset += 4
|
||||||
|
for i in range(tile_count):
|
||||||
|
layer.tiles.append(_read_uint16(data, offset))
|
||||||
|
offset += 2
|
||||||
|
|
||||||
|
result.tile_layers.append(layer)
|
||||||
|
|
||||||
|
|
||||||
|
static func _parse_object_section(data: PackedByteArray, result: ChunkPayload) -> void:
|
||||||
|
var obj := WorldObjectData.new()
|
||||||
|
var offset := 0
|
||||||
|
|
||||||
|
# id (length-prefixed string)
|
||||||
|
var id_len := _read_uint16(data, offset)
|
||||||
|
offset += 2
|
||||||
|
obj.id = data.slice(offset, offset + id_len).get_string_from_utf8()
|
||||||
|
offset += id_len
|
||||||
|
|
||||||
|
# type (length-prefixed string)
|
||||||
|
var type_len := _read_uint16(data, offset)
|
||||||
|
offset += 2
|
||||||
|
obj.type = data.slice(offset, offset + type_len).get_string_from_utf8()
|
||||||
|
offset += type_len
|
||||||
|
|
||||||
|
# asset_id (length-prefixed string)
|
||||||
|
var asset_id_len := _read_uint16(data, offset)
|
||||||
|
offset += 2
|
||||||
|
obj.asset_id = data.slice(offset, offset + asset_id_len).get_string_from_utf8()
|
||||||
|
offset += asset_id_len
|
||||||
|
|
||||||
|
# position (3 × float32)
|
||||||
|
obj.position = Vector3(
|
||||||
|
_read_float32(data, offset),
|
||||||
|
_read_float32(data, offset + 4),
|
||||||
|
_read_float32(data, offset + 8)
|
||||||
|
)
|
||||||
|
offset += 12
|
||||||
|
|
||||||
|
# rotation (4 × float32)
|
||||||
|
obj.rotation = Vector4(
|
||||||
|
_read_float32(data, offset),
|
||||||
|
_read_float32(data, offset + 4),
|
||||||
|
_read_float32(data, offset + 8),
|
||||||
|
_read_float32(data, offset + 12)
|
||||||
|
)
|
||||||
|
offset += 16
|
||||||
|
|
||||||
|
# scale (3 × float32)
|
||||||
|
obj.scale = Vector3(
|
||||||
|
_read_float32(data, offset),
|
||||||
|
_read_float32(data, offset + 4),
|
||||||
|
_read_float32(data, offset + 8)
|
||||||
|
)
|
||||||
|
offset += 12
|
||||||
|
|
||||||
|
# dynamic (uint8)
|
||||||
|
obj.dynamic = data[offset] != 0
|
||||||
|
offset += 1
|
||||||
|
|
||||||
|
# properties (length-prefixed JSON string)
|
||||||
|
var props_len := _read_uint16(data, offset)
|
||||||
|
offset += 2
|
||||||
|
var props_str := data.slice(offset, offset + props_len).get_string_from_utf8()
|
||||||
|
offset += props_len
|
||||||
|
obj.properties = JSON.parse_string(props_str) or {}
|
||||||
|
|
||||||
|
# flags (ContentFlags)
|
||||||
|
obj.flags.violence = data[offset]
|
||||||
|
offset += 1
|
||||||
|
obj.flags.sexual = data[offset]
|
||||||
|
offset += 1
|
||||||
|
obj.flags.language = data[offset]
|
||||||
|
offset += 1
|
||||||
|
obj.flags.substance = data[offset]
|
||||||
|
offset += 1
|
||||||
|
obj.flags.gambling = data[offset]
|
||||||
|
offset += 1
|
||||||
|
obj.flags.horror = data[offset]
|
||||||
|
offset += 1
|
||||||
|
obj.flags.hate = data[offset]
|
||||||
|
offset += 1
|
||||||
|
obj.flags.custom = data[offset]
|
||||||
|
offset += 1
|
||||||
|
obj.flags.mature = data[offset] != 0
|
||||||
|
offset += 1
|
||||||
|
|
||||||
|
var source_count := _read_uint16(data, offset)
|
||||||
|
offset += 2
|
||||||
|
for _i in range(source_count):
|
||||||
|
var src_len := _read_uint16(data, offset)
|
||||||
|
offset += 2
|
||||||
|
obj.flags.sources.append(data.slice(offset, offset + src_len).get_string_from_utf8())
|
||||||
|
offset += src_len
|
||||||
|
|
||||||
|
result.objects.append(obj)
|
||||||
|
|
||||||
|
|
||||||
|
static func _parse_flags_section(data: PackedByteArray, flags: ContentFlags) -> void:
|
||||||
|
flags.violence = data[0]
|
||||||
|
flags.sexual = data[1]
|
||||||
|
flags.language = data[2]
|
||||||
|
flags.substance = data[3]
|
||||||
|
flags.gambling = data[4]
|
||||||
|
flags.horror = data[5]
|
||||||
|
flags.hate = data[6]
|
||||||
|
flags.custom = data[7]
|
||||||
|
flags.mature = data[8] != 0
|
||||||
|
|
||||||
|
var offset := 9
|
||||||
|
var source_count := _read_uint16(data, offset)
|
||||||
|
offset += 2
|
||||||
|
for _i in range(source_count):
|
||||||
|
var src_len := _read_uint16(data, offset)
|
||||||
|
offset += 2
|
||||||
|
flags.sources.append(data.slice(offset, offset + src_len).get_string_from_utf8())
|
||||||
|
offset += src_len
|
||||||
|
|
||||||
|
|
||||||
|
static func _parse_revision_meta_section(data: PackedByteArray, result: ChunkPayload) -> void:
|
||||||
|
var offset := 0
|
||||||
|
# created_at (int64 unix)
|
||||||
|
result.created_at = _read_int64(data, offset)
|
||||||
|
offset += 8
|
||||||
|
# updated_at (int64 unix)
|
||||||
|
result.updated_at = _read_int64(data, offset)
|
||||||
|
offset += 8
|
||||||
|
# revision (uint32)
|
||||||
|
result.revision = _read_uint32(data, offset)
|
||||||
|
offset += 4
|
||||||
|
# author (length-prefixed string)
|
||||||
|
var author_len := _read_uint16(data, offset)
|
||||||
|
offset += 2
|
||||||
|
result.author = data.slice(offset, offset + author_len).get_string_from_utf8()
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Section Builders ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
static func _build_metadata_section(payload: ChunkPayload) -> PackedByteArray:
|
||||||
|
var data := PackedByteArray()
|
||||||
|
data.append_array(_write_string(payload.world_id))
|
||||||
|
data.append_array(_write_string(payload.room_id))
|
||||||
|
data.append_array(_write_int32(payload.coord.x))
|
||||||
|
data.append_array(_write_int32(payload.coord.y))
|
||||||
|
data.append_array(_write_int32(payload.coord.z))
|
||||||
|
data.append_array(_write_float32(payload.bounds.position.x))
|
||||||
|
data.append_array(_write_float32(payload.bounds.position.y))
|
||||||
|
data.append_array(_write_float32(payload.bounds.position.z))
|
||||||
|
data.append_array(_write_float32(payload.bounds.size.x))
|
||||||
|
data.append_array(_write_float32(payload.bounds.size.y))
|
||||||
|
data.append_array(_write_float32(payload.bounds.size.z))
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
static func _build_tile_section(layer: TileLayerData) -> PackedByteArray:
|
||||||
|
var data := PackedByteArray()
|
||||||
|
data.append_array(_write_string(layer.name))
|
||||||
|
data.append_array(_write_uint16(layer.layer_id))
|
||||||
|
data.append_array(_write_uint16(layer.width))
|
||||||
|
data.append_array(_write_uint16(layer.height))
|
||||||
|
data.append_array(_write_uint16(layer.depth))
|
||||||
|
data.append_array(_write_uint16(layer.palette.size()))
|
||||||
|
|
||||||
|
for tile_def in layer.palette:
|
||||||
|
data.append_array(_write_uint16(tile_def.id))
|
||||||
|
data.append_array(_write_string(tile_def.asset_id))
|
||||||
|
data.append(tile_def.solid as int)
|
||||||
|
data.append_array(_write_uint32(tile_def.flags))
|
||||||
|
data.append(tile_def.variants)
|
||||||
|
|
||||||
|
data.append_array(_write_uint32(layer.tiles.size()))
|
||||||
|
for tile_id in layer.tiles:
|
||||||
|
data.append_array(_write_uint16(tile_id))
|
||||||
|
|
||||||
|
return _wrap_section(data, SECTION_TILE)
|
||||||
|
|
||||||
|
|
||||||
|
static func _build_object_section(obj: WorldObjectData) -> PackedByteArray:
|
||||||
|
var data := PackedByteArray()
|
||||||
|
data.append_array(_write_string(obj.id))
|
||||||
|
data.append_array(_write_string(obj.type))
|
||||||
|
data.append_array(_write_string(obj.asset_id))
|
||||||
|
data.append_array(_write_float32(obj.position.x))
|
||||||
|
data.append_array(_write_float32(obj.position.y))
|
||||||
|
data.append_array(_write_float32(obj.position.z))
|
||||||
|
data.append_array(_write_float32(obj.rotation.x))
|
||||||
|
data.append_array(_write_float32(obj.rotation.y))
|
||||||
|
data.append_array(_write_float32(obj.rotation.z))
|
||||||
|
data.append_array(_write_float32(obj.rotation.w))
|
||||||
|
data.append_array(_write_float32(obj.scale.x))
|
||||||
|
data.append_array(_write_float32(obj.scale.y))
|
||||||
|
data.append_array(_write_float32(obj.scale.z))
|
||||||
|
data.append(obj.dynamic as int)
|
||||||
|
|
||||||
|
var props_json := JSON.stringify(obj.properties, " ")
|
||||||
|
data.append_array(_write_string(props_json))
|
||||||
|
|
||||||
|
data.append(obj.flags.violence)
|
||||||
|
data.append(obj.flags.sexual)
|
||||||
|
data.append(obj.flags.language)
|
||||||
|
data.append(obj.flags.substance)
|
||||||
|
data.append(obj.flags.gambling)
|
||||||
|
data.append(obj.flags.horror)
|
||||||
|
data.append(obj.flags.hate)
|
||||||
|
data.append(obj.flags.custom)
|
||||||
|
data.append(obj.flags.mature as int)
|
||||||
|
data.append_array(_write_uint16(obj.flags.sources.size()))
|
||||||
|
for src in obj.flags.sources:
|
||||||
|
data.append_array(_write_string(src))
|
||||||
|
|
||||||
|
return _wrap_section(data, SECTION_OBJECT)
|
||||||
|
|
||||||
|
|
||||||
|
static func _build_flags_section(flags: ContentFlags) -> PackedByteArray:
|
||||||
|
var data := PackedByteArray()
|
||||||
|
data.append(flags.violence)
|
||||||
|
data.append(flags.sexual)
|
||||||
|
data.append(flags.language)
|
||||||
|
data.append(flags.substance)
|
||||||
|
data.append(flags.gambling)
|
||||||
|
data.append(flags.horror)
|
||||||
|
data.append(flags.hate)
|
||||||
|
data.append(flags.custom)
|
||||||
|
data.append(flags.mature as int)
|
||||||
|
data.append_array(_write_uint16(flags.sources.size()))
|
||||||
|
for src in flags.sources:
|
||||||
|
data.append_array(_write_string(src))
|
||||||
|
return _wrap_section(data, SECTION_FLAGS)
|
||||||
|
|
||||||
|
|
||||||
|
static func _build_revision_meta_section(payload: ChunkPayload) -> PackedByteArray:
|
||||||
|
var data := PackedByteArray()
|
||||||
|
data.append_array(_write_int64(payload.created_at))
|
||||||
|
data.append_array(_write_int64(payload.updated_at))
|
||||||
|
data.append_array(_write_uint32(payload.revision))
|
||||||
|
data.append_array(_write_string(payload.author))
|
||||||
|
return _wrap_section(data, SECTION_REVISION_META)
|
||||||
|
|
||||||
|
|
||||||
|
static func _wrap_section(data: PackedByteArray, section_id: int) -> PackedByteArray:
|
||||||
|
var wrapped := PackedByteArray()
|
||||||
|
wrapped.append(section_id)
|
||||||
|
wrapped.append_array(_write_uint32(data.size()))
|
||||||
|
wrapped.append_array(data)
|
||||||
|
return wrapped
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Binary Helpers ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
static func _read_uint16(data: PackedByteArray, offset: int) -> int:
|
||||||
|
return data[offset] | (data[offset + 1] << 8)
|
||||||
|
|
||||||
|
static func _read_uint32(data: PackedByteArray, offset: int) -> int:
|
||||||
|
return (data[offset] | (data[offset + 1] << 8) |
|
||||||
|
(data[offset + 2] << 16) | (data[offset + 3] << 24))
|
||||||
|
|
||||||
|
static func _read_int32(data: PackedByteArray, offset: int) -> int:
|
||||||
|
var val := _read_uint32(data, offset)
|
||||||
|
if val >= 0x80000000:
|
||||||
|
return val - 0x100000000
|
||||||
|
return val
|
||||||
|
|
||||||
|
static func _read_int64(data: PackedByteArray, offset: int) -> int:
|
||||||
|
var lo := _read_uint32(data, offset)
|
||||||
|
var hi := _read_uint32(data, offset + 4)
|
||||||
|
return hi * 0x100000000 + lo
|
||||||
|
|
||||||
|
static func _read_float32(data: PackedByteArray, offset: int) -> float:
|
||||||
|
var bits := _read_uint32(data, offset)
|
||||||
|
return bits as float # GDScript float is 64-bit; bits repr works for IEEE 754
|
||||||
|
|
||||||
|
static func _read_hex_string(data: PackedByteArray, offset: int, length: int) -> String:
|
||||||
|
var hex_chars := "0123456789abcdef"
|
||||||
|
var result := ""
|
||||||
|
for i in range(length):
|
||||||
|
var byte_val := data[offset + i]
|
||||||
|
result += hex_chars[byte_val >> 4]
|
||||||
|
result += hex_chars[byte_val & 0x0F]
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
static func _write_uint16(val: int) -> PackedByteArray:
|
||||||
|
return PackedByteArray([(val & 0xFF), (val >> 8) & 0xFF])
|
||||||
|
|
||||||
|
static func _write_uint32(val: int) -> PackedByteArray:
|
||||||
|
return PackedByteArray([
|
||||||
|
val & 0xFF, (val >> 8) & 0xFF, (val >> 16) & 0xFF, (val >> 24) & 0xFF
|
||||||
|
])
|
||||||
|
|
||||||
|
static func _write_int32(val: int) -> PackedByteArray:
|
||||||
|
return _write_uint32(val)
|
||||||
|
|
||||||
|
static func _write_int64(val: int) -> PackedByteArray:
|
||||||
|
var lo := val & 0xFFFFFFFF
|
||||||
|
var hi := (val >> 32) & 0xFFFFFFFF
|
||||||
|
return _write_uint32(lo) + _write_uint32(hi)
|
||||||
|
|
||||||
|
static func _write_float32(val: float) -> PackedByteArray:
|
||||||
|
var bits := val as int
|
||||||
|
return _write_uint32(bits)
|
||||||
|
|
||||||
|
static func _write_string(s: String) -> PackedByteArray:
|
||||||
|
var bytes := s.to_utf8_buffer()
|
||||||
|
return _write_uint16(bytes.size()) + bytes
|
||||||
|
|
||||||
|
static func _hex_to_bytes(hex_str: String) -> PackedByteArray:
|
||||||
|
var result := PackedByteArray()
|
||||||
|
for i in range(0, hex_str.length(), 2):
|
||||||
|
var byte_val := int("0x" + hex_str.substr(i, 2), 16)
|
||||||
|
result.append(byte_val)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
# ─── SHA-256 ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
static func _sha256_hex(data: PackedByteArray) -> String:
|
||||||
|
var sha256 := SHA256Hash.new()
|
||||||
|
sha256.start()
|
||||||
|
sha256.update(data)
|
||||||
|
var hash := sha256.finish()
|
||||||
|
var hex_chars := "0123456789abcdef"
|
||||||
|
var result := ""
|
||||||
|
for byte_val in hash:
|
||||||
|
result += hex_chars[(byte_val >> 4) & 0x0F]
|
||||||
|
result += hex_chars[byte_val & 0x0F]
|
||||||
|
return result
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://bv445xrd6f5tk
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
class_name ChunkScene
|
||||||
|
extends Node3D
|
||||||
|
## A visual representation of a single loaded world chunk.
|
||||||
|
##
|
||||||
|
## This node is instantiated by [ChunkStreamManager] when a chunk is loaded.
|
||||||
|
## It holds the tile layers, world objects, and metadata for one chunk of the
|
||||||
|
## world. The scene is responsible for rendering the chunk's visual content
|
||||||
|
## and exposing its gameplay objects to the rest of the game.
|
||||||
|
##
|
||||||
|
## ## Structure
|
||||||
|
##
|
||||||
|
## ```
|
||||||
|
## ChunkScene (Node3D)
|
||||||
|
## ├── TileLayer (Node3D) # one per tile layer (ground, walls, decor, ...)
|
||||||
|
## ├── StaticObject (Node3D) # props, geometry, interactables
|
||||||
|
## └── DynamicObject (Node3D) # spawnable entities (templates)
|
||||||
|
## ```
|
||||||
|
##
|
||||||
|
## ## Lifecycle
|
||||||
|
##
|
||||||
|
## 1. [ChunkStreamManager] requests chunk data from the server.
|
||||||
|
## 2. Server responds with binary payload.
|
||||||
|
## 3. [ChunkStreamManager] decodes the payload and creates a [ChunkScene].
|
||||||
|
## 4. [ChunkScene] populates itself with tile layers and objects.
|
||||||
|
## 5. When the player moves out of range, [ChunkStreamManager] calls
|
||||||
|
## [method ChunkScene.queue_free] to unload it.
|
||||||
|
|
||||||
|
## The chunk coordinate this scene represents.
|
||||||
|
var coord: Vector3i = Vector3i.ZERO
|
||||||
|
|
||||||
|
## The canonical chunk key.
|
||||||
|
var key: String = ""
|
||||||
|
|
||||||
|
## World-space bounding box of this chunk.
|
||||||
|
var bounds: AABB = AABB()
|
||||||
|
|
||||||
|
## Format version of the chunk data.
|
||||||
|
var format_version: int = 1
|
||||||
|
|
||||||
|
## Revision counter from the server.
|
||||||
|
var revision: int = 0
|
||||||
|
|
||||||
|
## List of tile layers in this chunk.
|
||||||
|
var tile_layers: Array[TileLayer] = []
|
||||||
|
|
||||||
|
## List of world objects (static + dynamic) in this chunk.
|
||||||
|
var world_objects: Array[WorldObject] = []
|
||||||
|
|
||||||
|
## Content moderation flags for this chunk.
|
||||||
|
var content_flags: ChunkDataDecoder.ContentFlags = ChunkDataDecoder.ContentFlags.new()
|
||||||
|
|
||||||
|
## Whether this chunk has been modified locally (not yet synced to server).
|
||||||
|
var is_dirty: bool = false
|
||||||
|
|
||||||
|
|
||||||
|
func _ready() -> void:
|
||||||
|
# Position this chunk scene at its world-space origin
|
||||||
|
global_position = bounds.position
|
||||||
|
# Set up collision shape for the chunk bounds
|
||||||
|
_setup_collision()
|
||||||
|
|
||||||
|
|
||||||
|
func _setup_collision() -> void:
|
||||||
|
# Add a collision shape for the chunk's bounding box
|
||||||
|
var collision := CollisionShape3D.new()
|
||||||
|
var shape := BoxShape3D.new()
|
||||||
|
shape.size = bounds.size
|
||||||
|
collision.shape = shape
|
||||||
|
collision.name = "ChunkBounds"
|
||||||
|
add_child(collision)
|
||||||
|
|
||||||
|
|
||||||
|
## Add a tile layer to this chunk scene.
|
||||||
|
func add_tile_layer(layer: TileLayer) -> void:
|
||||||
|
tile_layers.append(layer)
|
||||||
|
var layer_node := TileLayerNode.new()
|
||||||
|
layer_node.tile_layer = layer
|
||||||
|
layer_node.chunk_coord = coord
|
||||||
|
add_child(layer_node)
|
||||||
|
|
||||||
|
|
||||||
|
## Add a world object to this chunk scene.
|
||||||
|
func add_object(obj: WorldObject) -> void:
|
||||||
|
world_objects.append(obj)
|
||||||
|
if obj.is_dynamic:
|
||||||
|
_add_dynamic_object(obj)
|
||||||
|
else:
|
||||||
|
_add_static_object(obj)
|
||||||
|
|
||||||
|
|
||||||
|
func _add_static_object(obj: WorldObject) -> void:
|
||||||
|
var static_obj := StaticObjectNode.new()
|
||||||
|
static_obj.world_object = obj
|
||||||
|
static_obj.position = obj.position
|
||||||
|
static_obj.rotation = obj.rotation
|
||||||
|
static_obj.scale = obj.scale
|
||||||
|
static_obj.name = "static_%s" % obj.id
|
||||||
|
add_child(static_obj)
|
||||||
|
|
||||||
|
|
||||||
|
func _add_dynamic_object(obj: WorldObject) -> void:
|
||||||
|
var dynamic_obj := DynamicObjectNode.new()
|
||||||
|
dynamic_obj.world_object = obj
|
||||||
|
dynamic_obj.position = obj.position
|
||||||
|
dynamic_obj.rotation = obj.rotation
|
||||||
|
dynamic_obj.scale = obj.scale
|
||||||
|
dynamic_obj.name = "dynamic_%s" % obj.id
|
||||||
|
add_child(dynamic_obj)
|
||||||
|
|
||||||
|
|
||||||
|
## Get a world object by its ID.
|
||||||
|
func get_object_by_id(object_id: String) -> WorldObject:
|
||||||
|
for obj in world_objects:
|
||||||
|
if obj.id == object_id:
|
||||||
|
return obj
|
||||||
|
return null
|
||||||
|
|
||||||
|
|
||||||
|
## Get all static objects in this chunk.
|
||||||
|
func get_static_objects() -> Array[WorldObject]:
|
||||||
|
var result: Array[WorldObject] = []
|
||||||
|
for obj in world_objects:
|
||||||
|
if not obj.is_dynamic:
|
||||||
|
result.append(obj)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
## Get all dynamic object templates in this chunk.
|
||||||
|
func get_dynamic_templates() -> Array[WorldObject]:
|
||||||
|
var result: Array[WorldObject] = []
|
||||||
|
for obj in world_objects:
|
||||||
|
if obj.is_dynamic:
|
||||||
|
result.append(obj)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
## Mark this chunk as dirty (locally modified).
|
||||||
|
func mark_dirty() -> void:
|
||||||
|
is_dirty = true
|
||||||
|
|
||||||
|
|
||||||
|
## Clear all objects and tile layers from this chunk.
|
||||||
|
func clear() -> void:
|
||||||
|
for child in get_children():
|
||||||
|
if child is TileLayerNode or child is StaticObjectNode or child is DynamicObjectNode:
|
||||||
|
child.queue_free()
|
||||||
|
tile_layers.clear()
|
||||||
|
world_objects.clear()
|
||||||
|
|
||||||
|
|
||||||
|
## Get the world-space position of a tile within this chunk.
|
||||||
|
func get_tile_world_position(tile_x: int, tile_y: int, tile_z: int = 0) -> Vector3:
|
||||||
|
return bounds.position + Vector3(
|
||||||
|
tile_x * 1.0, # Tile size is 1 world unit
|
||||||
|
tile_y * 1.0,
|
||||||
|
tile_z * 1.0
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
## Check if a world-space position is within this chunk's bounds.
|
||||||
|
func contains_position(pos: Vector3) -> bool:
|
||||||
|
return bounds.has_point(pos)
|
||||||
|
|
||||||
|
|
||||||
|
## Check if a chunk coordinate is adjacent to this chunk.
|
||||||
|
func is_adjacent(other_coord: Vector3i) -> bool:
|
||||||
|
var dx := abs(coord.x - other_coord.x)
|
||||||
|
var dy := abs(coord.y - other_coord.y)
|
||||||
|
var dz := abs(coord.z - other_coord.z)
|
||||||
|
return dx <= 1 and dy <= 1 and dz <= 1 and (dx + dy + dz) > 0
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://d4gh4h5iey6xb
|
||||||
@@ -0,0 +1,358 @@
|
|||||||
|
class_name ChunkStreamManager
|
||||||
|
extends Node
|
||||||
|
## Manages loading and unloading of world chunks based on player position.
|
||||||
|
##
|
||||||
|
## This is the central orchestrator for the chunk streaming system. It monitors
|
||||||
|
## the player's position, determines which chunks should be loaded, requests
|
||||||
|
## chunk data from the server, instantiates ChunkScene nodes, and unloads
|
||||||
|
## chunks that fall outside the streaming radius.
|
||||||
|
##
|
||||||
|
## ## Design
|
||||||
|
##
|
||||||
|
## - **Streaming radius** defines how many chunks around the player are kept
|
||||||
|
## loaded. Chunks beyond this radius are unloaded to save memory and CPU.
|
||||||
|
## - **Load priority** ensures chunks closer to the player are loaded first,
|
||||||
|
## preventing visual pop-in at the edge of the view.
|
||||||
|
## - **Server communication** uses the RPCs defined in docs/architecture/world.md
|
||||||
|
## (§5.3): `request_chunk`, `chunk_data`, `chunk_invalidated`.
|
||||||
|
##
|
||||||
|
## ## Usage
|
||||||
|
##
|
||||||
|
## Add this node to your scene tree and set the `player` reference:
|
||||||
|
## ```gdscript
|
||||||
|
## var stream_manager: ChunkStreamManager = $ChunkStreamManager
|
||||||
|
## stream_manager.player = $Player
|
||||||
|
## stream_manager.streaming_radius = 4 # chunks
|
||||||
|
## ```
|
||||||
|
|
||||||
|
## Number of chunks (radius) to keep loaded around the player.
|
||||||
|
## A radius of 2 means a 5x5 grid of chunks centered on the player.
|
||||||
|
## Higher values increase memory usage but reduce streaming pop-in.
|
||||||
|
@export var streaming_radius: int = 3
|
||||||
|
|
||||||
|
## Maximum number of chunks to load per frame. Prevents frame spikes during
|
||||||
|
## rapid movement or when entering a new area.
|
||||||
|
@export var max_loads_per_frame: int = 4
|
||||||
|
|
||||||
|
## Maximum number of chunks to unload per frame.
|
||||||
|
@export var max_unloads_per_frame: int = 4
|
||||||
|
|
||||||
|
## Distance (in chunks) from the streaming radius edge before a chunk is
|
||||||
|
## considered for unloading. Prevents rapid load/unload cycling.
|
||||||
|
@export var unload_margin: int = 1
|
||||||
|
|
||||||
|
## Emitted when a chunk finishes loading.
|
||||||
|
signal chunk_loaded(chunk_coord: Vector3i, chunk_scene: Node)
|
||||||
|
|
||||||
|
## Emitted when a chunk is unloaded.
|
||||||
|
signal chunk_unloaded(chunk_coord: Vector3i)
|
||||||
|
|
||||||
|
## Emitted when a chunk fails to load (e.g., server error).
|
||||||
|
signal chunk_load_failed(chunk_coord: Vector3i, error: String)
|
||||||
|
|
||||||
|
## Emitted when the player moves far enough to require a streaming update.
|
||||||
|
signal streaming_updated
|
||||||
|
|
||||||
|
## Reference to the player node. Must be set before the manager starts.
|
||||||
|
var player: Node3D = null
|
||||||
|
|
||||||
|
## The current player chunk coordinate.
|
||||||
|
var _player_chunk_coord: Vector3i = Vector3i.ZERO
|
||||||
|
|
||||||
|
## Map of loaded chunk coordinates to their scene nodes.
|
||||||
|
var _loaded_chunks: Dictionary = {}
|
||||||
|
|
||||||
|
## Queue of chunk coordinates pending load, sorted by distance from player.
|
||||||
|
var _load_queue: Array[Vector3i] = []
|
||||||
|
|
||||||
|
## Queue of chunk coordinates pending unload.
|
||||||
|
var _unload_queue: Array[Vector3i] = []
|
||||||
|
|
||||||
|
## Whether streaming is currently active (player is set and in game).
|
||||||
|
var is_active: bool = false
|
||||||
|
|
||||||
|
## The server RPC interface for chunk data. Set by the networking layer.
|
||||||
|
var _server_rpc: Object = null
|
||||||
|
|
||||||
|
## Internal: track which chunks are currently being loaded (to avoid duplicates).
|
||||||
|
var _loading_in_progress: Dictionary = {}
|
||||||
|
|
||||||
|
## Internal: time accumulator for throttled streaming updates.
|
||||||
|
var _update_accumulator: float = 0.0
|
||||||
|
|
||||||
|
## Internal: interval between streaming updates (seconds).
|
||||||
|
const UPDATE_INTERVAL: float = 0.1
|
||||||
|
|
||||||
|
|
||||||
|
func _ready() -> void:
|
||||||
|
# Streaming is driven from _process (main thread) so we can safely
|
||||||
|
# manipulate the scene tree. No background thread is used.
|
||||||
|
set_process(true)
|
||||||
|
|
||||||
|
|
||||||
|
func _process(delta: float) -> void:
|
||||||
|
if not is_active or player == null:
|
||||||
|
return
|
||||||
|
|
||||||
|
_update_accumulator += delta
|
||||||
|
if _update_accumulator < UPDATE_INTERVAL:
|
||||||
|
return
|
||||||
|
_update_accumulator = 0.0
|
||||||
|
|
||||||
|
_update_streaming()
|
||||||
|
_process_load_queue()
|
||||||
|
_process_unload_queue()
|
||||||
|
streaming_updated.emit()
|
||||||
|
|
||||||
|
|
||||||
|
func _update_streaming() -> void:
|
||||||
|
if player == null or not is_inside_tree():
|
||||||
|
return
|
||||||
|
|
||||||
|
var current_coord: Vector3i = Chunk.coord_for_position(player.global_position)
|
||||||
|
if current_coord == _player_chunk_coord:
|
||||||
|
return
|
||||||
|
|
||||||
|
_player_chunk_coord = current_coord
|
||||||
|
_rebuild_queues()
|
||||||
|
|
||||||
|
|
||||||
|
func _rebuild_queues() -> void:
|
||||||
|
var target_set: Dictionary = _compute_target_chunks()
|
||||||
|
|
||||||
|
# Determine which chunks need to be loaded
|
||||||
|
var to_load: Array[Vector3i] = []
|
||||||
|
for coord in target_set:
|
||||||
|
if not _loaded_chunks.has(coord) and not _loading_in_progress.has(coord):
|
||||||
|
to_load.append(coord)
|
||||||
|
|
||||||
|
# Sort by distance from player (closest first)
|
||||||
|
to_load.sort_custom(_compare_distance)
|
||||||
|
_load_queue = to_load
|
||||||
|
|
||||||
|
# Determine which chunks need to be unloaded
|
||||||
|
var to_unload: Array[Vector3i] = []
|
||||||
|
for coord in _loaded_chunks:
|
||||||
|
if not target_set.has(coord):
|
||||||
|
to_unload.append(coord)
|
||||||
|
|
||||||
|
to_unload.sort_custom(_compare_distance)
|
||||||
|
_unload_queue = to_unload
|
||||||
|
|
||||||
|
|
||||||
|
func _compute_target_chunks() -> Dictionary:
|
||||||
|
"""Compute the set of chunk coordinates that should be loaded."""
|
||||||
|
var target: Dictionary = {}
|
||||||
|
var half: int = streaming_radius
|
||||||
|
for dx in range(-half, half + 1):
|
||||||
|
for dy in range(-half, half + 1):
|
||||||
|
for dz in range(-half, half + 1):
|
||||||
|
var coord: Vector3i = Vector3i(
|
||||||
|
_player_chunk_coord.x + dx,
|
||||||
|
_player_chunk_coord.y + dy,
|
||||||
|
_player_chunk_coord.z + dz
|
||||||
|
)
|
||||||
|
target[coord] = coord
|
||||||
|
return target
|
||||||
|
|
||||||
|
|
||||||
|
func _compare_distance(a: Vector3i, b: Vector3i) -> bool:
|
||||||
|
"""Compare chunk coordinates by distance from player chunk."""
|
||||||
|
var dist_a: int = _chunk_distance_squared(_player_chunk_coord, a)
|
||||||
|
var dist_b: int = _chunk_distance_squared(_player_chunk_coord, b)
|
||||||
|
return dist_a < dist_b
|
||||||
|
|
||||||
|
|
||||||
|
func _chunk_distance_squared(a: Vector3i, b: Vector3i) -> int:
|
||||||
|
var dx: int = a.x - b.x
|
||||||
|
var dy: int = a.y - b.y
|
||||||
|
var dz: int = a.z - b.z
|
||||||
|
return dx * dx + dy * dy + dz * dz
|
||||||
|
|
||||||
|
|
||||||
|
func _process_load_queue() -> void:
|
||||||
|
var count: int = 0
|
||||||
|
while count < max_loads_per_frame and not _load_queue.is_empty():
|
||||||
|
var coord: Vector3i = _load_queue.pop_front()
|
||||||
|
_load_chunk(coord)
|
||||||
|
count += 1
|
||||||
|
|
||||||
|
|
||||||
|
func _process_unload_queue() -> void:
|
||||||
|
var count: int = 0
|
||||||
|
while count < max_unloads_per_frame and not _unload_queue.is_empty():
|
||||||
|
var coord: Vector3i = _unload_queue.pop_front()
|
||||||
|
_unload_chunk(coord)
|
||||||
|
count += 1
|
||||||
|
|
||||||
|
|
||||||
|
func _load_chunk(coord: Vector3i) -> void:
|
||||||
|
if _loading_in_progress.has(coord):
|
||||||
|
return
|
||||||
|
|
||||||
|
_loading_in_progress[coord] = true
|
||||||
|
|
||||||
|
# Request chunk data from server via RPC
|
||||||
|
if _server_rpc != null and _server_rpc.has_method("request_chunk"):
|
||||||
|
_server_rpc.request_chunk(Chunk.build_key(
|
||||||
|
"", "", coord
|
||||||
|
))
|
||||||
|
else:
|
||||||
|
# Fallback: try to load from local cache or create empty chunk
|
||||||
|
_instantiate_chunk_scene(coord)
|
||||||
|
|
||||||
|
|
||||||
|
func _on_chunk_data_received(key: String, binary_data: PackedByteArray) -> void:
|
||||||
|
"""Called by the networking layer when chunk data arrives from server."""
|
||||||
|
var coord: Vector3i = _parse_coord_from_key(key)
|
||||||
|
if coord == null:
|
||||||
|
return
|
||||||
|
|
||||||
|
_loading_in_progress.erase(coord)
|
||||||
|
|
||||||
|
# Decode the binary chunk data
|
||||||
|
var chunk_data: ChunkDataDecoder.ChunkPayload = ChunkDataDecoder.decode_binary(binary_data)
|
||||||
|
if chunk_data == null:
|
||||||
|
chunk_load_failed.emit(coord, "Failed to decode chunk data")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Instantiate the chunk scene with the decoded data
|
||||||
|
_instantiate_chunk_scene_with_data(coord, chunk_data)
|
||||||
|
|
||||||
|
|
||||||
|
func _on_chunk_load_error(coord: Vector3i, error_msg: String) -> void:
|
||||||
|
"""Called when the server reports a chunk load error."""
|
||||||
|
_loading_in_progress.erase(coord)
|
||||||
|
chunk_load_failed.emit(coord, error_msg)
|
||||||
|
|
||||||
|
|
||||||
|
func _instantiate_chunk_scene(coord: Vector3i) -> void:
|
||||||
|
"""Create an empty chunk scene for the given coordinate."""
|
||||||
|
var chunk_scene: ChunkScene = ChunkScene.new()
|
||||||
|
chunk_scene.coord = coord
|
||||||
|
chunk_scene.key = Chunk.build_key("", "", coord)
|
||||||
|
chunk_scene.bounds = Chunk.compute_bounds(coord)
|
||||||
|
_add_loaded_chunk(coord, chunk_scene)
|
||||||
|
|
||||||
|
|
||||||
|
func _instantiate_chunk_scene_with_data(coord: Vector3i, data: ChunkDataDecoder.ChunkPayload) -> void:
|
||||||
|
"""Create a chunk scene populated with server data."""
|
||||||
|
var chunk_scene: ChunkScene = ChunkScene.new()
|
||||||
|
chunk_scene.coord = coord
|
||||||
|
chunk_scene.key = data.key
|
||||||
|
chunk_scene.bounds = data.bounds
|
||||||
|
chunk_scene.format_version = data.format_version
|
||||||
|
chunk_scene.revision = data.revision
|
||||||
|
|
||||||
|
# Add tiles
|
||||||
|
for layer in data.tile_layers:
|
||||||
|
var tile_layer: TileLayer = TileLayer.new()
|
||||||
|
tile_layer.name = layer.name
|
||||||
|
tile_layer.layer_id = layer.layer_id
|
||||||
|
tile_layer.width = layer.width
|
||||||
|
tile_layer.height = layer.height
|
||||||
|
tile_layer.depth = layer.depth
|
||||||
|
tile_layer.palette = layer.palette
|
||||||
|
tile_layer.tiles = layer.tiles
|
||||||
|
chunk_scene.add_tile_layer(tile_layer)
|
||||||
|
|
||||||
|
# Add objects
|
||||||
|
for obj_data in data.objects:
|
||||||
|
var world_obj: WorldObject = WorldObject.new()
|
||||||
|
world_obj.id = obj_data.id
|
||||||
|
world_obj.type = obj_data.type
|
||||||
|
world_obj.asset_id = obj_data.asset_id
|
||||||
|
world_obj.position = obj_data.position
|
||||||
|
world_obj.rotation = obj_data.rotation
|
||||||
|
world_obj.scale = obj_data.scale
|
||||||
|
world_obj.properties = obj_data.properties
|
||||||
|
world_obj.is_dynamic = obj_data.dynamic
|
||||||
|
world_obj.flags = obj_data.flags
|
||||||
|
chunk_scene.add_object(world_obj)
|
||||||
|
|
||||||
|
# Add content flags
|
||||||
|
chunk_scene.content_flags = data.flags
|
||||||
|
|
||||||
|
_add_loaded_chunk(coord, chunk_scene)
|
||||||
|
|
||||||
|
|
||||||
|
func _add_loaded_chunk(coord: Vector3i, chunk_scene: Node) -> void:
|
||||||
|
_loaded_chunks[coord] = chunk_scene
|
||||||
|
add_child(chunk_scene)
|
||||||
|
_loading_in_progress.erase(coord)
|
||||||
|
chunk_loaded.emit(coord, chunk_scene)
|
||||||
|
|
||||||
|
|
||||||
|
func _unload_chunk(coord: Vector3i) -> void:
|
||||||
|
if not _loaded_chunks.has(coord):
|
||||||
|
return
|
||||||
|
|
||||||
|
var chunk_scene: Node = _loaded_chunks[coord]
|
||||||
|
if is_instance_valid(chunk_scene):
|
||||||
|
chunk_scene.queue_free()
|
||||||
|
|
||||||
|
_loaded_chunks.erase(coord)
|
||||||
|
chunk_unloaded.emit(coord)
|
||||||
|
|
||||||
|
|
||||||
|
func _on_player_position_changed() -> void:
|
||||||
|
"""Call this when the player position changes significantly."""
|
||||||
|
if player != null:
|
||||||
|
_update_streaming()
|
||||||
|
|
||||||
|
|
||||||
|
func set_server_rpc(rpc_interface: Object) -> void:
|
||||||
|
"""Set the server RPC interface for chunk requests."""
|
||||||
|
_server_rpc = rpc_interface
|
||||||
|
|
||||||
|
|
||||||
|
func get_loaded_chunk(coord: Vector3i) -> Node:
|
||||||
|
"""Get a loaded chunk scene by coordinate, or null if not loaded."""
|
||||||
|
return _loaded_chunks.get(coord, null)
|
||||||
|
|
||||||
|
|
||||||
|
func is_chunk_loaded(coord: Vector3i) -> bool:
|
||||||
|
"""Check if a chunk is currently loaded."""
|
||||||
|
return _loaded_chunks.has(coord)
|
||||||
|
|
||||||
|
|
||||||
|
func get_loaded_chunk_count() -> int:
|
||||||
|
"""Get the number of currently loaded chunks."""
|
||||||
|
return _loaded_chunks.size()
|
||||||
|
|
||||||
|
|
||||||
|
func get_all_loaded_coords() -> Array[Vector3i]:
|
||||||
|
"""Get all currently loaded chunk coordinates."""
|
||||||
|
var coords: Array[Vector3i] = []
|
||||||
|
for key in _loaded_chunks:
|
||||||
|
coords.append(key)
|
||||||
|
return coords
|
||||||
|
|
||||||
|
|
||||||
|
func force_reload_chunk(coord: Vector3i) -> void:
|
||||||
|
"""Force reload a specific chunk (e.g., after server update)."""
|
||||||
|
if _loaded_chunks.has(coord):
|
||||||
|
_unload_chunk(coord)
|
||||||
|
if not _loading_in_progress.has(coord):
|
||||||
|
_load_chunk(coord)
|
||||||
|
|
||||||
|
|
||||||
|
func clear_all_chunks() -> void:
|
||||||
|
"""Unload all chunks immediately."""
|
||||||
|
var coords: Array[Vector3i] = get_all_loaded_coords()
|
||||||
|
for coord in coords:
|
||||||
|
_unload_chunk(coord)
|
||||||
|
_load_queue.clear()
|
||||||
|
_unload_queue.clear()
|
||||||
|
|
||||||
|
|
||||||
|
func _parse_coord_from_key(key: String) -> Variant:
|
||||||
|
"""Parse chunk coordinate from a canonical chunk key."""
|
||||||
|
var parts: Array[String] = key.split(":")
|
||||||
|
if parts.size() < 5:
|
||||||
|
return null
|
||||||
|
return Vector3i(
|
||||||
|
int(parts[2]),
|
||||||
|
int(parts[3]),
|
||||||
|
int(parts[4])
|
||||||
|
)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://8yokqwe23c23
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
class_name DynamicObjectNode
|
||||||
|
extends Node3D
|
||||||
|
## A visual representation of a dynamic (spawnable) world object template.
|
||||||
|
##
|
||||||
|
## This node is instantiated by [ChunkScene] for each dynamic [WorldObject].
|
||||||
|
## Dynamic objects are stored as *templates* in the chunk; their runtime state
|
||||||
|
## (position, health, inventory) is managed by the entity system, not by this
|
||||||
|
## node. This node renders the template's visual representation so the object
|
||||||
|
## is visible in the world.
|
||||||
|
##
|
||||||
|
## ## Structure
|
||||||
|
##
|
||||||
|
## ```
|
||||||
|
## DynamicObjectNode (Node3D)
|
||||||
|
## └── MeshInstance3D # the object's visual mesh
|
||||||
|
## ```
|
||||||
|
|
||||||
|
## The world object data this node represents.
|
||||||
|
var world_object: WorldObject = null
|
||||||
|
|
||||||
|
## The loaded mesh instance (or placeholder).
|
||||||
|
var _mesh_instance: MeshInstance3D = null
|
||||||
|
|
||||||
|
|
||||||
|
func _ready() -> void:
|
||||||
|
if world_object == null:
|
||||||
|
return
|
||||||
|
_load_mesh()
|
||||||
|
|
||||||
|
|
||||||
|
## Load and attach the object's mesh.
|
||||||
|
func _load_mesh() -> void:
|
||||||
|
_mesh_instance = MeshInstance3D.new()
|
||||||
|
_mesh_instance.name = "mesh"
|
||||||
|
|
||||||
|
var mesh := _load_asset_mesh(world_object.asset_id)
|
||||||
|
if mesh == null:
|
||||||
|
mesh = _make_placeholder_mesh()
|
||||||
|
|
||||||
|
_mesh_instance.mesh = mesh
|
||||||
|
add_child(_mesh_instance)
|
||||||
|
|
||||||
|
|
||||||
|
## Attempt to load a mesh from the asset path.
|
||||||
|
func _load_asset_mesh(asset_id: String) -> Mesh:
|
||||||
|
if asset_id.is_empty():
|
||||||
|
return null
|
||||||
|
var path := "res://assets/%s" % asset_id
|
||||||
|
if ResourceLoader.exists(path):
|
||||||
|
var res := load(path)
|
||||||
|
if res is Mesh:
|
||||||
|
return res
|
||||||
|
return null
|
||||||
|
|
||||||
|
|
||||||
|
## Create a simple placeholder sphere mesh when the real asset is unavailable.
|
||||||
|
func _make_placeholder_mesh() -> Mesh:
|
||||||
|
var mesh := SphereMesh.new()
|
||||||
|
mesh.radius = 0.5
|
||||||
|
mesh.height = 1.0
|
||||||
|
return mesh
|
||||||
|
|
||||||
|
|
||||||
|
## Get the world object's display name.
|
||||||
|
func get_display_name() -> String:
|
||||||
|
if world_object == null:
|
||||||
|
return ""
|
||||||
|
return world_object.get_display_name()
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://dyjqcstdnr4u1
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
extends Control
|
|
||||||
|
|
||||||
|
|
||||||
func _ready():
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
func _on_play_button_pressed():
|
|
||||||
# No gameplay scene exists yet — wire this to the real scene once one lands.
|
|
||||||
print("Play pressed — no gameplay scene yet")
|
|
||||||
|
|
||||||
|
|
||||||
func _on_quit_button_pressed():
|
|
||||||
get_tree().quit()
|
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
class_name StaticObjectNode
|
||||||
|
extends Node3D
|
||||||
|
## A visual representation of a static world object within a chunk.
|
||||||
|
##
|
||||||
|
## This node is instantiated by [ChunkScene] for each static (non-dynamic)
|
||||||
|
## [WorldObject]. It renders the object's mesh (referenced by `asset_id`) at
|
||||||
|
## the object's transform. If the asset is not yet loaded, a placeholder mesh
|
||||||
|
## is shown.
|
||||||
|
##
|
||||||
|
## ## Structure
|
||||||
|
##
|
||||||
|
## ```
|
||||||
|
## StaticObjectNode (Node3D)
|
||||||
|
## └── MeshInstance3D # the object's visual mesh
|
||||||
|
## ```
|
||||||
|
|
||||||
|
## The world object data this node represents.
|
||||||
|
var world_object: WorldObject = null
|
||||||
|
|
||||||
|
## The loaded mesh instance (or placeholder).
|
||||||
|
var _mesh_instance: MeshInstance3D = null
|
||||||
|
|
||||||
|
|
||||||
|
func _ready() -> void:
|
||||||
|
if world_object == null:
|
||||||
|
return
|
||||||
|
_load_mesh()
|
||||||
|
|
||||||
|
|
||||||
|
## Load and attach the object's mesh.
|
||||||
|
func _load_mesh() -> void:
|
||||||
|
_mesh_instance = MeshInstance3D.new()
|
||||||
|
_mesh_instance.name = "mesh"
|
||||||
|
|
||||||
|
# Try to load the asset by its id (path). Fall back to a placeholder.
|
||||||
|
var mesh := _load_asset_mesh(world_object.asset_id)
|
||||||
|
if mesh == null:
|
||||||
|
mesh = _make_placeholder_mesh()
|
||||||
|
|
||||||
|
_mesh_instance.mesh = mesh
|
||||||
|
add_child(_mesh_instance)
|
||||||
|
|
||||||
|
|
||||||
|
## Attempt to load a mesh from the asset path.
|
||||||
|
func _load_asset_mesh(asset_id: String) -> Mesh:
|
||||||
|
if asset_id.is_empty():
|
||||||
|
return null
|
||||||
|
var path := "res://assets/%s" % asset_id
|
||||||
|
if ResourceLoader.exists(path):
|
||||||
|
var res := load(path)
|
||||||
|
if res is Mesh:
|
||||||
|
return res
|
||||||
|
return null
|
||||||
|
|
||||||
|
|
||||||
|
## Create a simple placeholder box mesh when the real asset is unavailable.
|
||||||
|
func _make_placeholder_mesh() -> Mesh:
|
||||||
|
var mesh := BoxMesh.new()
|
||||||
|
mesh.size = Vector3(1.0, 1.0, 1.0)
|
||||||
|
return mesh
|
||||||
|
|
||||||
|
|
||||||
|
## Get the world object's display name.
|
||||||
|
func get_display_name() -> String:
|
||||||
|
if world_object == null:
|
||||||
|
return ""
|
||||||
|
return world_object.get_display_name()
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://chf6mruev1kc4
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
class_name TileDef
|
||||||
|
extends RefCounted
|
||||||
|
## A single tile definition (palette entry) within a tile layer.
|
||||||
|
##
|
||||||
|
## Maps a tile ID to its asset reference and gameplay flags. See
|
||||||
|
## docs/architecture/world.md §3.2.
|
||||||
|
|
||||||
|
## Tile ID (index into the layer palette). 0 is reserved for empty/air.
|
||||||
|
var id: int = 0
|
||||||
|
|
||||||
|
## Reference to the tile/terrain asset.
|
||||||
|
var asset_id: String = ""
|
||||||
|
|
||||||
|
## Whether this tile has collision.
|
||||||
|
var solid: bool = false
|
||||||
|
|
||||||
|
## Bitmask flags: bit 0 = walkable, bit 1 = climbable, bit 2 = liquid.
|
||||||
|
var flags: int = 0
|
||||||
|
|
||||||
|
## Number of visual variants for this tile.
|
||||||
|
var variants: int = 1
|
||||||
|
|
||||||
|
|
||||||
|
func _init(p_id: int = 0, p_asset_id: String = "", p_solid: bool = false,
|
||||||
|
p_flags: int = 0, p_variants: int = 1) -> void:
|
||||||
|
id = p_id
|
||||||
|
asset_id = p_asset_id
|
||||||
|
solid = p_solid
|
||||||
|
flags = p_flags
|
||||||
|
variants = p_variants
|
||||||
|
|
||||||
|
|
||||||
|
func is_walkable() -> bool:
|
||||||
|
return (flags & 0x01) != 0
|
||||||
|
|
||||||
|
|
||||||
|
func is_climbable() -> bool:
|
||||||
|
return (flags & 0x02) != 0
|
||||||
|
|
||||||
|
|
||||||
|
func is_liquid() -> bool:
|
||||||
|
return (flags & 0x04) != 0
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://bavmpbmthilbx
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
class_name TileLayer
|
||||||
|
extends RefCounted
|
||||||
|
## A single tile layer within a chunk.
|
||||||
|
##
|
||||||
|
## Holds the dense grid of tile IDs, the palette mapping tile IDs to tile
|
||||||
|
## definitions, and the layer metadata. This is the runtime representation of
|
||||||
|
## the `TileLayer` from the chunk data format (see docs/architecture/world.md
|
||||||
|
## §3.2).
|
||||||
|
|
||||||
|
## Layer name (e.g. "ground", "walls", "decor").
|
||||||
|
var name: String = ""
|
||||||
|
|
||||||
|
## Numeric layer id, unique within the chunk.
|
||||||
|
var layer_id: int = 0
|
||||||
|
|
||||||
|
## Grid dimensions in tiles.
|
||||||
|
var width: int = 0
|
||||||
|
var height: int = 0
|
||||||
|
var depth: int = 1
|
||||||
|
|
||||||
|
## Palette mapping tile IDs to tile definitions (shared per layer).
|
||||||
|
var palette: Array[TileDef] = []
|
||||||
|
|
||||||
|
## Dense, row-major tile IDs. Length = width * height * depth.
|
||||||
|
var tiles: PackedInt32Array = PackedInt32Array()
|
||||||
|
|
||||||
|
|
||||||
|
func _init(
|
||||||
|
p_layer_name: String = "",
|
||||||
|
p_layer_id: int = 0,
|
||||||
|
p_width: int = 0,
|
||||||
|
p_height: int = 0,
|
||||||
|
p_depth: int = 1
|
||||||
|
) -> void:
|
||||||
|
name = p_layer_name
|
||||||
|
layer_id = p_layer_id
|
||||||
|
width = p_width
|
||||||
|
height = p_height
|
||||||
|
depth = p_depth
|
||||||
|
|
||||||
|
|
||||||
|
## Total number of tiles in this layer.
|
||||||
|
func tile_count() -> int:
|
||||||
|
return width * height * depth
|
||||||
|
|
||||||
|
|
||||||
|
## Get the tile ID at (x, y, z). Returns 0 (empty) for out-of-bounds.
|
||||||
|
func get_tile(x: int, y: int, z: int = 0) -> int:
|
||||||
|
if x < 0 or x >= width or y < 0 or y >= height or z < 0 or z >= depth:
|
||||||
|
return 0
|
||||||
|
var idx := (z * height + y) * width + x
|
||||||
|
if idx < tiles.size():
|
||||||
|
return tiles[idx]
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
## Set the tile ID at (x, y, z). No-op if out of bounds.
|
||||||
|
func set_tile(x: int, y: int, z: int, tile_id: int) -> void:
|
||||||
|
if x < 0 or x >= width or y < 0 or y >= height or z < 0 or z >= depth:
|
||||||
|
return
|
||||||
|
var idx := (z * height + y) * width + x
|
||||||
|
if idx < tiles.size():
|
||||||
|
tiles[idx] = tile_id
|
||||||
|
|
||||||
|
|
||||||
|
## Get the tile definition for a tile ID, or null if not in the palette.
|
||||||
|
func get_tile_def(tile_id: int) -> TileDef:
|
||||||
|
for def in palette:
|
||||||
|
if def.id == tile_id:
|
||||||
|
return def
|
||||||
|
return null
|
||||||
|
|
||||||
|
|
||||||
|
## Whether the tile at (x, y, z) is solid (collision).
|
||||||
|
func is_solid(x: int, y: int, z: int = 0) -> bool:
|
||||||
|
var def := get_tile_def(get_tile(x, y, z))
|
||||||
|
return def != null and def.solid
|
||||||
|
|
||||||
|
|
||||||
|
## Whether the tile at (x, y, z) is walkable.
|
||||||
|
func is_walkable(x: int, y: int, z: int = 0) -> bool:
|
||||||
|
var def := get_tile_def(get_tile(x, y, z))
|
||||||
|
return def != null and def.is_walkable()
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://came2ygke3ajs
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
class_name TileLayerNode
|
||||||
|
extends Node3D
|
||||||
|
## A visual representation of a single tile layer within a chunk.
|
||||||
|
##
|
||||||
|
## This node is instantiated by [ChunkScene] for each tile layer in a chunk.
|
||||||
|
## It renders the layer's tiles as instanced meshes (one per tile definition
|
||||||
|
## in the palette) using [MultiMeshInstance3D] for efficiency.
|
||||||
|
##
|
||||||
|
## ## Structure
|
||||||
|
##
|
||||||
|
## ```
|
||||||
|
## TileLayerNode (Node3D)
|
||||||
|
## └── MultiMeshInstance3D # one per palette entry (tile definition)
|
||||||
|
## ```
|
||||||
|
|
||||||
|
## The tile layer data this node renders.
|
||||||
|
var tile_layer: TileLayer = null
|
||||||
|
|
||||||
|
## The chunk coordinate this layer belongs to (for world positioning).
|
||||||
|
var chunk_coord: Vector3i = Vector3i.ZERO
|
||||||
|
|
||||||
|
## Tile size in world units (each tile is 1x1x1).
|
||||||
|
const TILE_SIZE: float = 1.0
|
||||||
|
|
||||||
|
## Cache of MultiMeshInstance3D nodes, keyed by tile definition id.
|
||||||
|
var _mesh_instances: Dictionary = {}
|
||||||
|
|
||||||
|
|
||||||
|
func _ready() -> void:
|
||||||
|
if tile_layer == null:
|
||||||
|
return
|
||||||
|
_build_meshes()
|
||||||
|
|
||||||
|
|
||||||
|
## Build the MultiMesh instances for each tile definition in the palette.
|
||||||
|
func _build_meshes() -> void:
|
||||||
|
for tile_def in tile_layer.palette:
|
||||||
|
if tile_def.id == 0:
|
||||||
|
continue # Tile 0 is "empty/air", no mesh needed
|
||||||
|
_create_mesh_for_tile(tile_def)
|
||||||
|
|
||||||
|
|
||||||
|
func _create_mesh_for_tile(tile_def: TileDef) -> void:
|
||||||
|
# Count how many cells use this tile definition
|
||||||
|
var count := 0
|
||||||
|
for tile_id in tile_layer.tiles:
|
||||||
|
if tile_id == tile_def.id:
|
||||||
|
count += 1
|
||||||
|
|
||||||
|
if count == 0:
|
||||||
|
return
|
||||||
|
|
||||||
|
var multi_mesh := MultiMesh.new()
|
||||||
|
multi_mesh.transform_format = MultiMesh.TRANSFORM_3D
|
||||||
|
multi_mesh.mesh = _make_unit_cube_mesh()
|
||||||
|
multi_mesh.instance_count = count
|
||||||
|
|
||||||
|
var instance_idx := 0
|
||||||
|
for z in range(tile_layer.depth):
|
||||||
|
for y in range(tile_layer.height):
|
||||||
|
for x in range(tile_layer.width):
|
||||||
|
var tile_id := tile_layer.get_tile(x, y, z)
|
||||||
|
if tile_id != tile_def.id:
|
||||||
|
continue
|
||||||
|
var world_pos := _tile_to_world(x, y, z)
|
||||||
|
var xform := Transform3D(Basis(), world_pos)
|
||||||
|
multi_mesh.set_instance_transform(instance_idx, xform)
|
||||||
|
instance_idx += 1
|
||||||
|
|
||||||
|
var mesh_instance := MultiMeshInstance3D.new()
|
||||||
|
mesh_instance.multimesh = multi_mesh
|
||||||
|
mesh_instance.name = "tiles_%d" % tile_def.id
|
||||||
|
add_child(mesh_instance)
|
||||||
|
_mesh_instances[tile_def.id] = mesh_instance
|
||||||
|
|
||||||
|
|
||||||
|
## Convert a tile grid coordinate to a world-space position (center of tile).
|
||||||
|
func _tile_to_world(x: int, y: int, z: int) -> Vector3:
|
||||||
|
var origin := Vector3(
|
||||||
|
chunk_coord.x * tile_layer.width,
|
||||||
|
chunk_coord.y * tile_layer.height,
|
||||||
|
chunk_coord.z * tile_layer.depth
|
||||||
|
)
|
||||||
|
return origin + Vector3(
|
||||||
|
x * TILE_SIZE + TILE_SIZE * 0.5,
|
||||||
|
y * TILE_SIZE + TILE_SIZE * 0.5,
|
||||||
|
z * TILE_SIZE + TILE_SIZE * 0.5
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
## Create a simple unit cube mesh for tile rendering.
|
||||||
|
func _make_unit_cube_mesh() -> Mesh:
|
||||||
|
var mesh := BoxMesh.new()
|
||||||
|
mesh.size = Vector3(TILE_SIZE, TILE_SIZE, TILE_SIZE)
|
||||||
|
return mesh
|
||||||
|
|
||||||
|
|
||||||
|
## Get the tile definition at a grid position, or null if empty.
|
||||||
|
func get_tile_def_at(x: int, y: int, z: int = 0) -> TileDef:
|
||||||
|
if tile_layer == null:
|
||||||
|
return null
|
||||||
|
var tile_id := tile_layer.get_tile(x, y, z)
|
||||||
|
if tile_id == 0:
|
||||||
|
return null
|
||||||
|
return tile_layer.get_tile_def(tile_id)
|
||||||
|
|
||||||
|
|
||||||
|
## Clear all rendered meshes.
|
||||||
|
func clear() -> void:
|
||||||
|
for child in get_children():
|
||||||
|
child.queue_free()
|
||||||
|
_mesh_instances.clear()
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://d1x05tq8h43gm
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
class_name WorldObject
|
||||||
|
extends RefCounted
|
||||||
|
## A world object (static or dynamic) within a chunk.
|
||||||
|
##
|
||||||
|
## This is the runtime representation of a WorldObject from the chunk data
|
||||||
|
## format (see docs/architecture/world.md §3.3). It holds the object's
|
||||||
|
## identity, transform, properties, and moderation flags.
|
||||||
|
|
||||||
|
## Stable object ID, unique within the chunk.
|
||||||
|
var id: String = ""
|
||||||
|
|
||||||
|
## Object type (e.g. "prop", "interactable", "spawn_point", "door").
|
||||||
|
var type: String = ""
|
||||||
|
|
||||||
|
## Reference to the object's mesh/glTF asset.
|
||||||
|
var asset_id: String = ""
|
||||||
|
|
||||||
|
## World-space position.
|
||||||
|
var position: Vector3 = Vector3.ZERO
|
||||||
|
|
||||||
|
## World-space rotation as a quaternion (x, y, z, w).
|
||||||
|
var rotation: Vector4 = Vector4(0, 0, 0, 1)
|
||||||
|
|
||||||
|
## World-space scale.
|
||||||
|
var scale: Vector3 = Vector3.ONE
|
||||||
|
|
||||||
|
## Type-specific key/value properties.
|
||||||
|
var properties: Dictionary = {}
|
||||||
|
|
||||||
|
## Whether this is a spawnable dynamic entity (true) or static (false).
|
||||||
|
var is_dynamic: bool = false
|
||||||
|
|
||||||
|
## Per-object content moderation flags.
|
||||||
|
var flags: ChunkDataDecoder.ContentFlags = ChunkDataDecoder.ContentFlags.new()
|
||||||
|
|
||||||
|
## Whether this object has been modified locally.
|
||||||
|
var is_dirty: bool = false
|
||||||
|
|
||||||
|
|
||||||
|
func _init(
|
||||||
|
p_id: String = "",
|
||||||
|
p_type: String = "",
|
||||||
|
p_asset_id: String = "",
|
||||||
|
p_position: Vector3 = Vector3.ZERO,
|
||||||
|
p_rotation: Vector4 = Vector4(0, 0, 0, 1),
|
||||||
|
p_scale: Vector3 = Vector3.ONE,
|
||||||
|
p_properties: Dictionary = {},
|
||||||
|
p_dynamic: bool = false,
|
||||||
|
p_flags: ChunkDataDecoder.ContentFlags = null
|
||||||
|
) -> void:
|
||||||
|
id = p_id
|
||||||
|
type = p_type
|
||||||
|
asset_id = p_asset_id
|
||||||
|
position = p_position
|
||||||
|
rotation = p_rotation
|
||||||
|
scale = p_scale
|
||||||
|
properties = p_properties
|
||||||
|
is_dynamic = p_dynamic
|
||||||
|
if p_flags != null:
|
||||||
|
flags = p_flags
|
||||||
|
|
||||||
|
|
||||||
|
## Get a property value by key.
|
||||||
|
func get_property(key: String) -> Variant:
|
||||||
|
return properties.get(key)
|
||||||
|
|
||||||
|
|
||||||
|
## Set a property value.
|
||||||
|
func set_property(key: String, value: Variant) -> void:
|
||||||
|
properties[key] = value
|
||||||
|
is_dirty = true
|
||||||
|
|
||||||
|
|
||||||
|
## Check if this object is walkable (based on properties).
|
||||||
|
func is_walkable() -> bool:
|
||||||
|
return properties.get("walkable", true)
|
||||||
|
|
||||||
|
|
||||||
|
## Check if this object is climbable (based on properties).
|
||||||
|
func is_climbable() -> bool:
|
||||||
|
return properties.get("climbable", false)
|
||||||
|
|
||||||
|
|
||||||
|
## Check if this object is interactable.
|
||||||
|
func is_interactable() -> bool:
|
||||||
|
return type == "interactable" or properties.get("interactable", false)
|
||||||
|
|
||||||
|
|
||||||
|
## Get the object's display name.
|
||||||
|
func get_display_name() -> String:
|
||||||
|
return properties.get("display_name", id)
|
||||||
|
|
||||||
|
|
||||||
|
## Serialize this object to a dictionary (for networking).
|
||||||
|
func to_dict() -> Dictionary:
|
||||||
|
return {
|
||||||
|
"id": id,
|
||||||
|
"type": type,
|
||||||
|
"asset_id": asset_id,
|
||||||
|
"position": [position.x, position.y, position.z],
|
||||||
|
"rotation": [rotation.x, rotation.y, rotation.z, rotation.w],
|
||||||
|
"scale": [scale.x, scale.y, scale.z],
|
||||||
|
"properties": properties,
|
||||||
|
"dynamic": is_dynamic,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
## Create a WorldObject from a dictionary (for networking).
|
||||||
|
static func from_dict(d: Dictionary) -> WorldObject:
|
||||||
|
var pos := Vector3(
|
||||||
|
d.get("position", [0, 0, 0])[0],
|
||||||
|
d.get("position", [0, 0, 0])[1],
|
||||||
|
d.get("position", [0, 0, 0])[2]
|
||||||
|
)
|
||||||
|
var rot := Vector4(
|
||||||
|
d.get("rotation", [0, 0, 0, 1])[0],
|
||||||
|
d.get("rotation", [0, 0, 0, 1])[1],
|
||||||
|
d.get("rotation", [0, 0, 0, 1])[2],
|
||||||
|
d.get("rotation", [0, 0, 0, 1])[3]
|
||||||
|
)
|
||||||
|
var scl := Vector3(
|
||||||
|
d.get("scale", [1, 1, 1])[0],
|
||||||
|
d.get("scale", [1, 1, 1])[1],
|
||||||
|
d.get("scale", [1, 1, 1])[2]
|
||||||
|
)
|
||||||
|
return WorldObject.new(
|
||||||
|
d.get("id", ""),
|
||||||
|
d.get("type", ""),
|
||||||
|
d.get("asset_id", ""),
|
||||||
|
pos,
|
||||||
|
rot,
|
||||||
|
scl,
|
||||||
|
d.get("properties", {}),
|
||||||
|
d.get("dynamic", false),
|
||||||
|
)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://cxut2ghbsvobn
|
||||||
Reference in New Issue
Block a user