Compare commits

..

16 Commits

Author SHA1 Message Date
coder-bot 1c5c0f8c8e WIP checkpoint: Implement chunk streaming system in GDScript 2026-08-18 00:15:16 +00:00
coder-bot 97e0804dfe WIP checkpoint: Implement chunk streaming system in GDScript 2026-08-18 00:14:34 +00:00
coder-bot 342d0f69d9 WIP checkpoint: Implement chunk streaming system in GDScript 2026-08-18 00:14:14 +00:00
coder-bot 97949d6b44 WIP checkpoint: Implement chunk streaming system in GDScript 2026-08-18 00:08:24 +00:00
coder-bot 720cf6f6b9 WIP checkpoint: Implement chunk streaming system in GDScript 2026-08-18 00:03:28 +00:00
coder-bot d752cf5280 Recovered orphaned WIP before switching to a new task 2026-08-17 21:57:57 +00:00
coder-bot 504c92301a WIP checkpoint: Implement chunk streaming system in GDScript 2026-08-17 21:57:04 +00:00
coder-bot 67f6a9c43f WIP checkpoint: Implement chunk streaming system in GDScript 2026-08-17 21:54:29 +00:00
coder-bot 5c0a61870f WIP checkpoint: Implement chunk streaming system in GDScript 2026-08-17 21:46:23 +00:00
coder-bot cc27d73ca6 WIP checkpoint: Implement chunk streaming system in GDScript 2026-08-17 21:36:13 +00:00
coder-bot 62172e0ee4 WIP checkpoint: Implement chunk streaming system in GDScript 2026-08-17 21:23:53 +00:00
coder-bot ce2690e8b9 WIP checkpoint: Implement chunk streaming system in GDScript 2026-08-17 21:23:26 +00:00
agent-bot 8e2f17f159 WIP checkpoint: Implement chunk streaming system in GDScript 2026-08-17 21:21:12 +00:00
agent-bot 2d4a7e91cb WIP checkpoint: Implement chunk streaming system in GDScript 2026-08-17 21:21:12 +00:00
agent-bot 2ad8e34971 WIP checkpoint: Implement chunk streaming system in GDScript 2026-08-17 21:21:12 +00:00
agent-bot 7e81c09265 WIP checkpoint: Implement chunk streaming system in GDScript 2026-08-17 21:21:12 +00:00
52 changed files with 1980 additions and 1231 deletions
+1
View File
@@ -0,0 +1 @@
+22
View File
@@ -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=[]
+11
View File
@@ -0,0 +1,11 @@
[game_view]
select_mode=0
[editor_metadata]
executable_path="/home/daddy/godot/godot"
[recent_files]
scenes=["res://scenes/main.tscn"]
+81
View File
@@ -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.
-20
View File
@@ -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
-4
View File
@@ -1,4 +0,0 @@
.env
__pycache__/
*.pyc
.venv/
View File
-42
View File
@@ -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
-65
View File
@@ -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()
-27
View File
@@ -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"}
-126
View File
@@ -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)
View File
-50
View File
@@ -1,50 +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
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()
-38
View File
@@ -1,38 +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."""
async with async_session_factory() as session:
try:
yield session
await session.commit()
except Exception:
await session.rollback()
raise
-50
View File
@@ -1,50 +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
result = await db.execute(select(User).where(User.id == int(user_id)))
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)]
-46
View File
@@ -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}
-6
View File
@@ -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"]
-40
View File
@@ -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")
-20
View File
@@ -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")
-21
View File
@@ -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")
-31
View File
@@ -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")
View File
-179
View File
@@ -1,179 +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.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
class 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")
# TODO: Implement actual file upload logic (S3, local filesystem, etc.)
file_path = f"uploads/{asset_id}_{file.filename}"
asset.file_path = file_path
asset.mime_type = file.content_type
asset.file_size = 0 # Will be updated after actual write
await db.commit()
await db.refresh(asset)
return {"message": "File upload endpoint", "asset_id": asset_id, "file_path": file_path}
-131
View File
@@ -1,131 +0,0 @@
"""Authentication router - login, register, token refresh."""
from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, Depends, HTTPException, status
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 LoginRequest(BaseModel):
username: str
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(req: LoginRequest, db: AsyncSession = Depends(get_db)):
"""Authenticate user and return an access token."""
result = await db.execute(select(User).where(User.username == req.username))
user = result.scalar_one_or_none()
if not user or not verify_password(req.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
-130
View File
@@ -1,130 +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.models.user import User
router = APIRouter()
# ── Pydantic schemas ──────────────────────────────────────────────────────
class UserCreate(BaseModel):
username: str
email: EmailStr
password: str
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.post(
"/",
response_model=UserResponse,
status_code=status.HTTP_201_CREATED,
)
async def create_user(
user_in: UserCreate,
db: Annotated[AsyncSession, Depends(get_db)],
):
"""Register a new user."""
# Check uniqueness of username
existing = await db.execute(select(User).where(User.username == user_in.username))
if existing.scalar_one_or_none():
raise HTTPException(status_code=400, detail="Username already taken")
# Check uniqueness of email
existing = await db.execute(select(User).where(User.email == user_in.email))
if existing.scalar_one_or_none():
raise HTTPException(status_code=400, detail="Email already registered")
from app.routers.auth import hash_password
hashed = hash_password(user_in.password)
user = User(
username=user_in.username,
email=user_in.email,
hashed_password=hashed,
)
db.add(user)
await db.commit()
await db.refresh(user)
return user
@router.get("/me", response_model=UserResponse)
async def get_current_user(
db: Annotated[AsyncSession, Depends(get_db)],
# current_user would come from JWT dependency in real usage
):
"""Get the currently authenticated user (placeholder)."""
raise HTTPException(status_code=501, detail="Not implemented")
@router.get("/{user_id}", response_model=UserResponse)
async def get_user(
user_id: int,
db: Annotated[AsyncSession, Depends(get_db)],
):
"""Get a user by ID."""
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")
return user
@router.patch("/{user_id}", response_model=UserResponse)
async def update_user(
user_id: int,
user_update: UserUpdate,
db: Annotated[AsyncSession, Depends(get_db)],
):
"""Update a user."""
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_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,
db: Annotated[AsyncSession, Depends(get_db)],
):
"""Delete a user."""
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")
await db.delete(user)
await db.commit()
-154
View File
@@ -1,154 +0,0 @@
"""Worlds router - CRUD for game worlds."""
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Query
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)]
@router.get("/")
async def list_worlds(
db: DbDep,
skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=100),
visibility: WorldVisibility | None = None,
):
"""List worlds with optional visibility filter."""
query = select(World).options(selectinload(World.owner))
if visibility:
query = query.where(World.visibility == visibility)
query = query.offset(skip).limit(limit).order_by(World.created_at.desc())
result = await db.execute(query)
worlds = result.scalars().all()
return [
{
"id": w.id,
"name": w.name,
"description": w.description,
"visibility": w.visibility.value if w.visibility else None,
"owner_id": w.owner_id,
"created_at": w.created_at.isoformat() if w.created_at else None,
}
for w in worlds
]
@router.get("/{world_id}")
async def get_world(
world_id: int,
db: DbDep,
):
"""Get a single world by ID."""
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")
return {
"id": world.id,
"name": world.name,
"description": world.description,
"visibility": world.visibility.value if world.visibility else None,
"owner_id": world.owner_id,
"created_at": world.created_at.isoformat() if world.created_at else None,
}
@router.post("/")
async def create_world(
world_data: dict,
current_user: CurrentUser,
db: DbDep,
):
"""Create a new world."""
name = world_data.get("name")
if not name:
raise HTTPException(status_code=400, detail="World name is required")
world = World(
name=name,
description=world_data.get("description"),
owner_id=current_user.id,
visibility=WorldVisibility(
world_data.get("visibility", "public")
),
)
db.add(world)
await db.commit()
await db.refresh(world)
return {
"id": world.id,
"name": world.name,
"description": world.description,
"visibility": world.visibility.value,
"owner_id": world.owner_id,
"created_at": world.created_at.isoformat() if world.created_at else None,
}
@router.put("/{world_id}")
async def update_world(
world_id: int,
world_data: dict,
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")
if world_data.get("name"):
world.name = world_data["name"]
if world_data.get("description") is not None:
world.description = world_data["description"]
if world_data.get("visibility"):
world.visibility = WorldVisibility(world_data["visibility"])
await db.commit()
await db.refresh(world)
return {
"id": world.id,
"name": world.name,
"description": world.description,
"visibility": world.visibility.value if world.visibility else None,
"owner_id": world.owner_id,
"created_at": world.created_at.isoformat() if world.created_at else None,
}
@router.delete("/{world_id}")
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()
return {"detail": "World deleted"}
-34
View File
@@ -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:
-17
View File
@@ -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
+16
View File
@@ -0,0 +1,16 @@
; Engine configuration file.
; It's best edited using the editor UI and not directly,
; but it can also be edited by hand.
config_version=5
[application]
config/name="AGP Game"
run/main_scene="res://scenes/main.tscn"
config/features=PackedStringArray("4.6.2")
[display]
window/size/viewport_width=1280
window/size/viewport_height=720
+6
View File
@@ -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")
+105
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
uid://dc6j7kqjitc6g
+688
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
uid://bv445xrd6f5tk
+170
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
uid://d4gh4h5iey6xb
+358
View File
@@ -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])
)
+1
View File
@@ -0,0 +1 @@
uid://8yokqwe23c23
+68
View File
@@ -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()
+1
View File
@@ -0,0 +1 @@
uid://dyjqcstdnr4u1
+67
View File
@@ -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()
+1
View File
@@ -0,0 +1 @@
uid://chf6mruev1kc4
+42
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
uid://bavmpbmthilbx
+83
View File
@@ -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()
+1
View File
@@ -0,0 +1 @@
uid://came2ygke3ajs
+112
View File
@@ -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()
+1
View File
@@ -0,0 +1 @@
uid://d1x05tq8h43gm
+135
View File
@@ -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),
)
+1
View File
@@ -0,0 +1 @@
uid://cxut2ghbsvobn