Compare commits

..

25 Commits

Author SHA1 Message Date
planner-bot 30b8dc6de2 WIP checkpoint: Plan: Economy — Currency, Marketplace & Creator Payouts 2026-08-18 15:12:46 +00:00
reviewer-bot a9947a0a80 Merge pull request 'Plan: Social — Friends, Chat & Groups' (#21) from agent/5b8c6bf79631 into main 2026-08-18 14:56:28 +00:00
reviewer-bot 99762887be Merge pull request 'Address review feedback: Implement chunk streaming system in GDScript' (#20) from agent/bd718b31b595 into main 2026-08-18 14:41:08 +00:00
reviewer-bot dbdc01f8d6 Merge remote-tracking branch 'origin/main' into merge-fix
# Conflicts:
#	.gitignore
#	project.godot
2026-08-18 14:40:41 +00:00
planner-bot 1061dc0742 WIP checkpoint: Plan: Social — Friends, Chat & Groups 2026-08-18 14:40:30 +00:00
coder-bot 88cd255040 WIP checkpoint: Address review feedback: Implement chunk streaming system in GDScript 2026-08-18 13:12:51 +00:00
coder-bot 93d83a28fc WIP checkpoint: Address review feedback: Implement chunk streaming system in GDScript 2026-08-18 13:11:54 +00:00
coder-bot 25f2dffc53 WIP checkpoint: Address review feedback: Implement chunk streaming system in GDScript 2026-08-18 02:45:44 +00:00
coder-bot 929c69e5da WIP checkpoint: Address review feedback: Implement chunk streaming system in GDScript 2026-08-18 02:40:34 +00:00
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
24 changed files with 2296 additions and 0 deletions
+8
View File
@@ -1,2 +1,10 @@
# Godot 4 editor cache/import directory # Godot 4 editor cache/import directory
.godot/ .godot/
# Godot export artifacts
export/
*.tmp
# OS
.DS_Store
Thumbs.db
+295
View File
@@ -0,0 +1,295 @@
# Economy — Currency, Marketplace & Creator Payouts — Plan
> **Status:** Planned
> **Owner:** Platform team
> **Feature ID:** `economy`
> **Godot version:** 4.6.2 stable
> **Backend:** FastAPI + SQLAlchemy (async) + PostgreSQL + Redis
## 1. Overview
This document breaks the **Economy** feature into user stories and right-sized
implementation tasks. The feature delivers:
- **Virtual currency ("Credits")** — a single, server-authoritative currency
with an immutable, append-only transaction ledger.
- **Premium purchase** — buying Credits with real money through a pluggable
payment-provider hook.
- **Marketplace** — user-generated content (UGC) listings where creators sell
items (assets, avatar items, experience passes) to players for Credits.
- **Creator payouts** — accumulating creator earnings and paying them out to
external accounts through a pluggable payout-provider hook.
- **Godot client** — wallet/transaction history, marketplace browse/buy/sell,
and creator payout screens.
The economy is the monetization backbone of the platform. Because it moves
virtual value between real people, correctness (no double-spend, no lost funds,
no balance drift) and safety (fraud prevention, moderation, audit) are treated
as **blocking requirements**, not add-ons.
## 2. Why correctness is the differentiator
The platform is a creator economy (players buy UGC from other players). The
economy is the highest-risk surface after chat: a single arithmetic or
concurrency bug can mint or destroy value and irreversibly erode trust.
Correctness is therefore designed in from the start:
- **One currency, integer units.** Credits are whole-number integers. There are
no floats, no rounding, and no client-computed totals.
- **Append-only ledger.** Every balance change is a row in a `transactions`
table with a signed amount, a `balance_after` snapshot, a reference to the
event that caused it, and an **idempotency key** so retries cannot double
credit or double debit.
- **Atomic, locked mutations.** Balance changes run in a single database
transaction with row locking (`SELECT ... FOR UPDATE`) so concurrent
purchases/payouts cannot race.
- **Escrow hold.** Marketplace proceeds are held in `pending_escrow` for a
configurable window before becoming available to the seller, so fraudulent
or moderated sales can be reversed without clawing back paid-out funds.
- **Server-authoritative fees.** The platform commission is computed
server-side from a configured rate; the client never supplies the fee or the
seller's net.
## 3. Existing constraints (do not re-build)
- **Auth:** JWT bearer tokens; `get_current_user` / `CurrentUser` dependency in
`app/dependencies.py`. All economy endpoints reuse this.
- **DB:** async SQLAlchemy + Alembic; migration `0001_initial` is the head.
Existing tables: `users`, `sessions`, `worlds`, `assets`.
- **Redis:** configured (`REDIS_URL`) but unused so far; adopted here for
idempotency short-caching and rate limiting.
- **Models:** `User` (owner_id), `Asset` (owner_id, is_public, asset_type).
Marketplace items reference existing `assets` rows and/or avatar items.
- **Content moderation:** `docs/architecture/content_moderation.md` defines
flag vocabulary, age gate, and moderation actions. Marketplace items and
listings must inherit and enforce these rules (see §7).
- **Client:** Godot 4.6.2, minimal `main_menu.gd`; no economy UI yet.
## 4. Key design decisions
| Concern | Decision |
|---|---|
| Currency model | Single virtual currency **"Credits" (CR)**, integer units. One wallet row per user; no fractional amounts. |
| Ledger | Append-only `transactions` table: signed `amount`, `balance_after`, `reference_type`/`reference_id`, unique `idempotency_key`. |
| Concurrency | `SELECT ... FOR UPDATE` on the wallet row inside the mutation transaction; never read-modify-write from stale data. |
| Payments | Pluggable `PaymentProvider` interface with a **no-op default** that simulates a confirmed payment for dev/testing (clearly flagged). Stripe/PayPal later. |
| Marketplace items | `catalog_items` (what is sold) + `marketplace_listings` (a seller's offer with a price/status). One item can have multiple listings over time. |
| Purchase flow | Atomic transaction: debit buyer → hold funds in escrow (`pending_escrow`) → grant item. Platform fee computed server-side. |
| Escrow | Configurable hold window (default 72h) before `pending_escrow` becomes available seller earnings. Refunds during the window reverse the sale. |
| Fees | Single configurable `MARKETPLACE_FEE_RATE` (default `0.30`). `seller_net = floor(price * (1 - fee_rate))`. |
| Creator balance | `creator_balances` table: `available` (earned, payable), `pending` (inside an open payout request), `lifetime_earned`. Updated in the same transaction as the sale completion. |
| Payouts | Pluggable `PayoutProvider.pay()` with a no-op default (simulated external ref). Minimum payout threshold, admin review for large payouts. |
| Idempotency | Every money-moving request carries a client-generated `Idempotency-Key`; duplicate keys return the original result. |
| Rate limiting | Redis token-bucket dependency reused from the social feature for purchase/listing/payout endpoints. |
| Moderation | Listings enforce content flags + age gate; moderators can `delist`/`remove`; fraud/chargeback reversal is an audited admin action. |
## 5. User stories
### US-1 Currency & wallet
As a player, I can see my Credit balance and transaction history, and the system
credits/debits my wallet only through server-authoritative events.
**Acceptance criteria**
- Exactly one wallet per user, created lazily on first use.
- Balance is always a non-negative integer; no operation can make it negative.
- Every mutation appends an immutable ledger row with amount, resulting balance,
reference, and idempotency key.
- Retrying a request with the same idempotency key does not double-apply.
- `GET` endpoints expose current balance and paged transaction history.
### US-2 Premium purchase (buy Credits)
As a player, I can purchase Credits with real money and have them credited only
after the payment is confirmed.
**Acceptance criteria**
- A purchase session is created through the payment provider (or the no-op
default) and is idempotent.
- Credits are granted exactly once, only after a verified provider confirmation
(webhook/confirm endpoint), never on client claim.
- Purchase amounts are bounded and server-defined; clients cannot pick arbitrary
prices.
- Confirmation is idempotent and audited (provider reference recorded).
### US-3 Marketplace listing (sell)
As a creator, I can list a catalog item for sale at a Credit price and
manage/remove my listings.
**Acceptance criteria**
- A listing references a valid `catalog_item` owned by the seller (or an
authorized seller).
- Price is bounded (min/max) and set server-side; status transitions are
explicit (`active`, `sold`, `delisted`, `removed_by_moderation`).
- Only the seller (or a moderator) can update/remove a listing.
- Listings are searchable/filterable (item type, price range, owner).
### US-4 Marketplace purchase (buy)
As a player, I can buy a listed item. The purchase is atomic, holds funds in
escrow, grants the item, and splits the payment between platform and seller.
**Acceptance criteria**
- A single transaction debits the buyer, holds funds in escrow, and grants the
item — no partial states on failure.
- Platform fee and seller net are computed server-side from the configured rate.
- After the escrow hold window, funds move from escrow to the seller's
available balance.
- Refunds during the hold window reverse the sale and return funds to the buyer.
- Concurrent purchases of a one-of-a-kind listing cannot both succeed.
### US-5 Creator payouts
As a creator, I can view my earned balance, request a payout, and track its
status; admins can review and approve/reject payouts.
**Acceptance criteria**
- Available balance is derived only from completed (escrow-released) sales and
completed payouts; it never goes negative.
- Payout requests are bounded (min threshold, max per period) and rate-limited.
- Large payouts require admin review; all payout requests carry an audit trail
(requested by, reviewed by, provider reference, status).
- The payout provider is called exactly once; idempotent on retry.
### US-6 Godot client
As a player, I can manage Credits, browse/buy/sell marketplace items, and manage
payouts from the game UI.
**Acceptance criteria**
- An API client handles JWT auth, HTTP, and idempotency keys for all economy
calls.
- Dedicated screens: wallet + transaction history, marketplace browse/buy,
sell-listing, and creator payout.
- The client never computes balances, fees, or seller nets — it only displays
server responses.
## 6. Task breakdown
Right-sized to 1–3 files / one coherent piece of functionality each. Task IDs
are sequential for dependency chaining.
| # | Task | Story | Depends on |
|---|------|-------|-----------|
| 1 | Wallet & ledger models + Alembic migration (`wallets`, `transactions`) | US-1 | — |
| 2 | Wallet service (atomic credit/debit, idempotency, locking) + wallet/transactions API | US-1 | 1 |
| 3 | Premium purchase: `PaymentProvider` interface + purchase/confirm endpoints | US-2 | 1 |
| 4 | Catalog item & listing models + migration (`catalog_items`, `marketplace_listings`, `creator_balances`) | US-3 | 1 |
| 5 | Marketplace listing API (create/list/search/update/remove) | US-3 | 4 |
| 6 | Marketplace purchase service + API (atomic escrow, fee split, item grant) | US-4 | 2, 4, 5 |
| 7 | Creator payout request models + API (`payout_requests`) | US-5 | 2, 6 |
| 8 | Payout admin review + `PayoutProvider` + audit | US-5 | 7 |
| 9 | Marketplace moderation + content-flag/age-gate enforcement | cross-cutting | 5, 6 |
| 10 | Godot economy API client (auth + HTTP + idempotency) | US-6 | 2 |
| 11 | Godot wallet & transaction history UI | US-6 | 10 |
| 12 | Godot marketplace browse/buy UI | US-6 | 6, 10 |
| 13 | Godot selling & payout UI | US-6 | 5, 7, 10 |
## 7. Phasing
- **Phase 1 (currency + payments):** tasks 1–3. The foundation — ledger and
money movement must be rock-solid before anything spends Credits.
- **Phase 2 (marketplace):** tasks 4–6. Listing and atomic purchase with escrow.
- **Phase 3 (payouts):** tasks 7–8. Turn released escrow into creator payouts.
- **Phase 4 (moderation):** task 9. Lock down the marketplace against mature
content and abuse.
- **Phase 5 (client):** tasks 10–13, each landing after its backend dependency.
## 8. Data model sketch
```text
wallets
id bigint PK
user_id int UNIQUE FK -> users.id
balance bigint NOT NULL DEFAULT 0 CHECK (balance >= 0)
created_at timestamptz
updated_at timestamptz
transactions (append-only ledger)
id bigint PK
wallet_id bigint FK -> wallets.id
type enum(credit_purchase, marketplace_purchase,
marketplace_sale, escrow_release, payout,
refund, admin_adjustment)
amount bigint NOT NULL (signed)
balance_after bigint NOT NULL
reference_type text (e.g. "purchase", "sale", "payout_request")
reference_id text
idempotency_key text UNIQUE
metadata_json jsonb
created_at timestamptz
catalog_items
id bigint PK
owner_id int FK -> users.id
asset_id int FK -> assets.id (nullable; null for non-asset virtual items)
item_type enum(asset, avatar_item, experience_pass)
name text
description text
metadata_json jsonb
created_at timestamptz
marketplace_listings
id bigint PK
catalog_item_id bigint FK -> catalog_items.id
seller_id int FK -> users.id
price bigint NOT NULL CHECK (price > 0)
status enum(active, sold, delisted, removed_by_moderation)
created_at timestamptz
updated_at timestamptz
purchases
id bigint PK
listing_id bigint FK -> marketplace_listings.id
buyer_id int FK -> users.id
seller_id int FK -> users.id
price bigint NOT NULL
platform_fee bigint NOT NULL
seller_net bigint NOT NULL
status enum(pending_escrow, completed, refunded, reversed)
escrow_hold_until timestamptz
created_at timestamptz
creator_balances
id bigint PK
user_id int UNIQUE FK -> users.id
available bigint NOT NULL DEFAULT 0
pending bigint NOT NULL DEFAULT 0
lifetime_earned bigint NOT NULL DEFAULT 0
updated_at timestamptz
payout_requests
id bigint PK
creator_id int FK -> users.id
amount bigint NOT NULL CHECK (amount > 0)
status enum(pending, approved, paid, rejected)
external_ref text
provider_ref text
requested_by int FK -> users.id
reviewed_by int FK -> users.id (nullable)
reviewed_at timestamptz
created_at timestamptz
```
## 9. Security, anti-fraud & audit
- **Server-authoritative balances/fees:** clients never compute totals; amounts
are validated server-side against bounds and the current listing.
- **Row locking:** wallet and creator-balance mutations use `SELECT ... FOR
UPDATE` within one transaction.
- **Idempotency:** every money-moving endpoint requires an `Idempotency-Key`;
duplicate keys return the stored original outcome.
- **Rate limiting:** purchase, listing, and payout endpoints are token-bucket
rate-limited per user.
- **Escrow & reversibility:** funds are not payable until the hold window
elapses; chargebacks/refunds reverse before release.
- **Moderation:** listings carry the item's effective content flags; mature
items require age verification (per `content_moderation.md`); moderators can
`delist`/`remove` and trigger audited reversals.
- **Audit:** the ledger plus `payout_requests` review fields form an append-only
audit trail for every movement of value.
## 10. Out of scope for this feature
- Real Stripe/PayPal/KYC integrations (provider interfaces leave the hooks; the
defaults are deterministic no-ops).
- Subscriptions, recurring billing, or premium memberships.
- Gift cards, promotional codes, or user-to-user Credit transfers.
- Partial refunds, chargeback disputes, and seller tax/VAT handling.
- In-world vending machines / scripted economy triggers (future, built on this
ledger via the scripting sandbox).
+170
View File
@@ -0,0 +1,170 @@
# Social — Friends, Chat & Groups — Plan
> **Status:** Planned
> **Owner:** Platform team
> **Feature ID:** `social`
> **Godot version:** 4.6.2 stable
> **Backend:** FastAPI + SQLAlchemy (async) + PostgreSQL + Redis
## 1. Overview
This document breaks the **Social** feature into user stories and right-sized
implementation tasks. The feature delivers:
- **Friends & following** — a friend request/accept model plus a one-way follow
relationship, with block lists.
- **Presence & status** — online/offline, custom status message, last-seen.
- **Chat** — direct messages (DM) and in-experience (world/room) chat with
**first-class safety**: server-side text filtering, report-message, and rate
limiting.
- **Groups** — named groups with roles (owner/admin/member) and a simple group
page.
- **Public profiles** — avatar, bio, favorite experiences, and a friends list
that respects privacy settings.
## 2. Why chat safety is the differentiator
The platform is explicitly meant to pull adults away from a youth-oriented
competitor. Chat is the highest-risk surface: it is real-time, user-generated,
and where abuse happens fastest. Safety is therefore treated as a **blocking
requirement**, not an add-on:
- Every message passes a **server-side text filter** before it is stored or
delivered. The client filter is cosmetic only; the server is authoritative.
- Every message can be **reported**; reports flow into a moderation queue with
actor + reason + audit trail (mirroring `content_moderation.md`).
- Sending is **rate limited** per user (Redis token bucket) to stop flooding and
harassment-by-volume.
- Report and block are always reachable from a message context in the UI.
## 3. Existing constraints (do not re-build)
- **Auth:** JWT bearer tokens; `get_current_user` / `CurrentUser` dependency in
`app/dependencies.py`. All social endpoints reuse this.
- **DB:** async SQLAlchemy + Alembic; migration `0001_initial` is the head.
- **Redis:** configured (`REDIS_URL`) but unused so far; adopted here for
presence and rate limiting.
- **Models:** `User`, `World` (owner_id), `Asset` (owner_id, is_public). Avatars
and favorite-experience references point at existing rows.
- **Client:** Godot 4.6.2, minimal `main_menu.gd`; no social UI yet.
## 4. Key design decisions
| Concern | Decision |
|---|---|
| Message transport | REST for send/history; **WebSocket** gateway for real-time delivery + presence (matches `networking.md`'s "WebSocket as secondary transport" for chat) |
| Presence store | Redis (TTL heartbeat) + persisted `last_seen_at` on the user record |
| Text filtering | Pluggable `TextFilter` interface with a deterministic default (profanity list + normalization) so it runs without external services; third-party hook later |
| Rate limiting | Redis token-bucket dependency, reusable across all social endpoints |
| Reports | `message_reports` table + moderation endpoints (list/review/resolve) with audit fields |
| Friends vs. follow | Friends = mutual (request→accept). Follow = one-way (no approval). Block = hard deny that also hides content and prevents DMs |
| Privacy | Per-user settings JSON (`profile_visibility`, `friends_list_visibility`, `favorites_visibility`) enforced server-side on every read |
| Groups | `groups` + `group_members` (role: owner/admin/member) + group page aggregate endpoint |
| Real-time | One WebSocket endpoint multiplexing presence, DM, and world-chat messages via Redis pub/sub |
## 5. User stories
### US-1 Friends & following
As a player, I can send a friend request, accept/decline incoming requests,
unfriend, follow/unfollow other players, and block users so they can't interact
with me.
**Acceptance criteria**
- Friend requests are one-directional until accepted; accepted friendships are mutual.
- Follow is one-way and requires no approval.
- Block prevents DMs, hides the blocked user's profile/friends interactions, and
removes any existing friendship/follow between the two.
- Duplicate/self requests are rejected idempotently.
### US-2 Presence & status
As a player, I can see whether my friends are online, their custom status, and
when they were last seen.
**Acceptance criteria**
- Online/offline is derived from a server-side heartbeat (Redis TTL), not client claims.
- `last_seen_at` persists and is visible (subject to privacy settings).
- Custom status text is optional and length-limited.
### US-3 Public profiles
As a player, I can view a public profile page showing avatar, bio, favorite
experiences, and a friends list that respects the owner's privacy settings.
**Acceptance criteria**
- Avatar and bio are editable by the owner only.
- Favorite experiences reference existing `World` rows.
- A viewer sees only the sections the owner's privacy settings allow; blocked
users see a restricted view.
### US-4 Chat (DM + in-experience) with safety
As a player, I can send direct messages and chat inside a world/room. Messages
are filtered, rate limited, and reportable.
**Acceptance criteria**
- Server filters message text before storing/delivering; filtered content is either
rejected (blocking) or redacted (configurable per filter policy).
- Send rate is limited per user (token bucket); excess sends get `429`.
- Every message has a stable id, author, timestamp, and conversation/world scope.
- Any message can be reported with a reason; reports are stored and reviewable.
- Real-time delivery over WebSocket with REST fallback for history.
### US-5 Groups
As a player, I can create a group, join/leave groups, and (as owner/admin)
manage roles and view a simple group page.
**Acceptance criteria**
- Each group has exactly one owner and any number of admins/members.
- Role transitions are authorized (only owner can transfer ownership or promote/demote admins).
- Group page lists name, description, member list with roles, and member count.
### US-6 Godot client
As a player, I can access friends, presence, chat, profiles, and groups from the
game UI.
**Acceptance criteria**
- An API client handles JWT auth, HTTP, and WebSocket for all social calls.
- Dedicated screens/panels: friends+presence, DM & world chat (with report),
profile page, group page.
- Client-side filtering is cosmetic; server responses remain authoritative.
## 6. Task breakdown
Right-sized to 1–3 files / one coherent piece of functionality each. Task IDs
are sequential for dependency chaining.
| # | Task | Story | Depends on |
|---|------|-------|-----------|
| 1 | Friends/follow/block models + Alembic migration | US-1 | — |
| 2 | Friends & follow API router | US-1 | 1 |
| 3 | Presence & status model + endpoints (Redis heartbeat) | US-2 | — |
| 4 | Profile model + migration + own-profile endpoints | US-3 | 1 |
| 5 | Public profile view + favorites + privacy enforcement | US-3 | 2, 4 |
| 6 | Chat data models + migration (conversations, messages, reports) | US-4 | — |
| 7 | Text filtering service + profanity config | US-4 | — |
| 8 | Redis rate-limiting dependency | US-4 | — |
| 9 | DM chat send/list endpoints (filter + rate limit) | US-4 | 6, 7, 8 |
| 10 | Report-message endpoint + moderation queue | US-4 | 6, 9 |
| 11 | In-experience (world/room) chat endpoints | US-4 | 9 |
| 12 | Real-time WebSocket gateway (presence + DM + world chat) | US-4 | 3, 9, 11 |
| 13 | Group + membership models + migration | US-5 | — |
| 14 | Groups API (CRUD, join/leave, roles, group page) | US-5 | 13 |
| 15 | Godot API client (auth + HTTP + WebSocket) | US-6 | 2 |
| 16 | Godot friends & presence UI | US-6 | 3, 15 |
| 17 | Godot chat UI (DM + world chat + report) | US-6 | 12, 15 |
| 18 | Godot profile page UI | US-6 | 5, 15 |
| 19 | Godot groups UI + group page | US-6 | 14, 15 |
## 7. Phasing
- **Phase 1 (data + core APIs):** tasks 1–5 (friends, presence, profiles).
- **Phase 2 (chat safety — the differentiator):** tasks 6–12 (chat + safety +
realtime). This is the critical path and highest priority.
- **Phase 3 (groups):** tasks 13–14.
- **Phase 4 (client):** tasks 15–19, each landing after its backend dependency.
## 8. Out of scope for this feature
- Age verification / maturity gating (covered by `content_moderation.md`).
- Voice chat, media/image messages, message editing/deletion history.
- Third-party profanity/toxicity vendors (the `TextFilter` interface leaves a
hook, but the default is deterministic and dependency-free).
- Mobile push notifications.
+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
+667
View File
@@ -0,0 +1,667 @@
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 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 ─────────────────
static var MAGIC := 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 := ("0x" + hex_str.substr(i, 2)).hex_to_int()
result.append(byte_val)
return result
# ───────────────── SHA-256 ─────────────────
static func _sha256_hex(data: PackedByteArray) -> String:
var ctx := HashingContext.new()
ctx.start(HashingContext.HASH_SHA256)
ctx.update(data)
var hash := ctx.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.quaternion = Quaternion(obj.rotation.x, obj.rotation.y, obj.rotation.z, obj.rotation.w)
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.quaternion = Quaternion(obj.rotation.x, obj.rotation.y, obj.rotation.z, obj.rotation.w)
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: int = abs(coord.x - other_coord.x)
var dy: int = abs(coord.y - other_coord.y)
var dz: int = 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