M0 scaffold: Godot 4 survival game skeleton + architecture/addon-SDK docs

This commit is contained in:
dev
2026-11-03 22:23:20 +00:00
commit 180b776392
13 changed files with 724 additions and 0 deletions

19
.gitignore vendored Normal file
View File

@@ -0,0 +1,19 @@
# Godot 4
.godot/
.import/
export.cfg
export_presets.cfg
# Exported builds
/build/
*.app
*.exe
*.dmg
*.zip
# OS
.DS_Store
Thumbs.db
# Editor
*.tmp

53
README.md Normal file
View File

@@ -0,0 +1,53 @@
# Valheim-like Survival Game (working title)
A stylised-realistic, low-poly survival game built in **Godot 4**, inspired by
Valheim (look/feel) and Rust (survival depth, player-run servers). Cross-platform
(Windows / macOS / Linux). Self-hosted authoritative dedicated server. Sandboxed
addon SDK with guard rails.
## North star
- **Look:** low-poly meshes + atmospheric lighting + fog/post-processing. Mood over texture detail. (Valheim recipe — a 5-person team made it run on potatoes.)
- **World:** mesh/heightmap terrain, procedurally generated, biomes. NOT voxels.
- **Building:** snap-together prefab pieces (walls, floors, roofs) with structural rules.
- **Survival loop:** gather → craft → build → defend. Health/stamina, day/night, enemies.
- **Multiplayer:** authoritative headless dedicated server, same codebase, self-hostable on any OS. Clients are never trusted.
- **Addons:** sandboxed (Lua), capped API, CPU/memory quotas, no host filesystem/network/escape. Guard rails from day one.
## Status
See `docs/ROADMAP.md` for milestones and `docs/ARCHITECTURE.md` for the design.
Current milestone: **M0 — playable single-player world skeleton** (scaffolding on disk; needs Godot 4 installed to open & run).
## Requirements
- **Godot 4.3+** (standard build; no C# needed — this project uses GDScript).
- Git.
## Quick start (once Godot is installed)
```bash
# Open the project
godot --path . --editor # or open Godot and import this folder's project.godot
# Run the game
godot --path .
# Run the headless dedicated server (later milestone)
godot --headless --path . -- --server
```
## Layout
```
project.godot Godot project definition
scenes/ game scenes (.tscn)
scripts/ GDScript (.gd)
core/ shared systems (world, net, save)
player/ player controller, camera
world/ terrain, chunks, generation
building/ snap-together building system
addons_sdk/ the sandboxed addon host + API
addons_examples/ example mods written against the SDK
assets/ models, materials, textures (low-poly)
docs/ architecture, roadmap, addon SDK spec
```
## License
TBD (lean toward MIT or a source-available license — decide before first public release).

125
docs/ADDON_SDK.md Normal file
View File

@@ -0,0 +1,125 @@
# Addon SDK — design spec
This is the differentiating feature: **easy add-ons with guard rails.** Modders
get a friendly, well-documented API; the server stays safe from buggy or
malicious addons.
## Design goals
1. **Easy to write.** A modder writes a small Lua script + a manifest. No build
step, no engine knowledge.
2. **Impossible to hurt the host.** No filesystem, no network, no arbitrary code
against the engine, no hanging the server tick, no unbounded memory.
3. **Server-authoritative.** Addons run on the server. Their effects replicate to
clients like any other game state.
4. **Content without code where possible.** Many mods (new items, recipes,
building pieces) are pure *data* — declared in a manifest, no Lua needed.
## Why Lua (not WASM, not GDScript, not Python)
- Lua is the industry-standard embedded game-modding language. Modders already
know it (WoW, Factorio-ish, Garry's Mod, countless others).
- A Lua VM is trivially sandboxable: you build the environment table yourself, so
the addon literally cannot see anything you don't hand it.
- GDScript was rejected: it can reach engine internals — too powerful for untrusted
code.
- WASM was considered (stronger isolation, authors use Rust/AssemblyScript) but is
harder for casual modders. Keep it as a possible *advanced* lane later.
- Embedding: a Lua-in-Godot binding (e.g. `godot-luaAPI` GDExtension) or a custom
GDExtension wrapping Lua 5.4 / LuaJIT.
## Addon package layout
```
my_addon/
addon.json manifest (required)
main.lua entry script (optional — data-only addons skip it)
items/ data definitions (optional)
prefabs/ low-poly meshes/scenes the addon adds (optional, validated)
assets/ textures, icons (optional, size-capped)
```
### addon.json (manifest)
```json
{
"id": "com.paul.ironpike",
"name": "Iron Pike",
"version": "1.0.0",
"api": "1", // SDK API version it targets
"author": "Paul",
"permissions": ["items", "recipes", "events"], // explicit capability opt-in
"entry": "main.lua" // omit for data-only addons
}
```
- **Permissions are explicit.** An addon only gets the API groups it declares, and
the server can refuse/limit them. A data-only addon declares no code perms at all.
## The sandbox (how the guard rails actually work)
1. Each addon gets its **own fresh Lua VM** (isolation between addons).
2. The global environment is a **whitelist table we build** — standard-library
access is stripped: no `os`, `io`, `package`, `require`, `dofile`, `loadfile`,
`load`, `debug`. Only safe primitives (`math`, `string`, `table`, `pairs`,
`ipairs`, `tostring`, a sandboxed `print` that routes to the server log).
3. The host injects a single global, `game`, which is the **entire API surface** —
the only way an addon touches the world.
4. **Instruction/time quota:** every addon callback runs under a hook that counts
VM instructions (or wall-clock) and aborts if it exceeds the budget. An addon
that loops forever is killed, the tick is never blocked.
5. **Memory quota:** the VM's allocator is capped; exceeding it aborts the addon,
not the server.
6. **No reentry into the engine:** the `game` API functions are thin, validated
shims written in GDScript/C++. The addon can't reach the Godot API, the
filesystem, or the network.
7. **Fault isolation:** an error in an addon callback is caught, logged with the
addon id, and the addon can be disabled — the server keeps running.
## API surface (v1 sketch — `game.*`)
Events (register handlers):
```lua
game.on("player_join", function(player) ... end)
game.on("block_place", function(player, piece, pos) ... end) -- can veto
game.on("tick", function(dt) ... end) -- quota-capped
game.on("entity_death", function(entity, killer) ... end)
```
World queries (read-only, validated):
```lua
local p = game.players.get(id) -- safe player handle
local n = game.world.count_entities("tree")
local b = game.world.get_block(x, y, z) -- within loaded bounds only
```
World commands (intents the host validates & applies):
```lua
game.world.spawn("wolf", x, y, z) -- subject to limits/quotas
game.chat.broadcast("Welcome, " .. p.name)
game.inventory.give(p, "wood", 5) -- bounded
```
Content declaration (data, validated at load):
```lua
game.define_item{ id="iron_pike", name="Iron Pike", damage=42, stack=1 }
game.define_recipe{ output="iron_pike", inputs={iron=10, wood=4}, station="forge" }
```
Everything an addon can do lives under `game.`. If it's not there, the addon can't
do it — that's the guard rail.
## Limits the server enforces (configurable by the host owner)
- Max addons loaded
- Per-callback instruction/time budget
- Per-addon memory ceiling
- Rate limits on spawn/give/broadcast (so an addon can't spam)
- Asset size caps (prefab poly count, texture dimensions)
- Permission allow/deny per addon
## Threat model (what we explicitly defend against)
| Threat | Defence |
|--------|---------|
| Addon reads server files / secrets | No `io`/`os`; no filesystem in the sandbox env |
| Addon opens network connections | No socket library exposed |
| Addon hangs the server (infinite loop) | Instruction/time quota aborts the callback |
| Addon exhausts RAM | Per-VM memory cap |
| Addon crashes the server | Every callback wrapped; errors isolate & disable the addon |
| Addon escalates via another addon | Each addon = separate VM, no shared globals |
| Addon spams spawns/messages | Host rate limits on world commands |
| Malicious prefab (huge mesh) | Asset validation + poly/texture caps at load |
## A test we must pass (M3 done-criteria)
Ship a deliberately-malicious example addon in the test suite that tries to:
read a file, open a socket, loop forever, and allocate gigabytes. The server must
contain all four, log them, disable the addon, and keep running. If that passes,
the guard rails are real.

116
docs/ARCHITECTURE.md Normal file
View File

@@ -0,0 +1,116 @@
# Architecture
This is the big-picture design. It's deliberately written before heavy coding so
the hard decisions (networking authority, addon sandbox) are made up front, not
bolted on.
## 1. Guiding principles
1. **Server is authoritative.** The client is a renderer + input device. It never
decides game state (health, inventory, block placement, hits). This is the
anti-cheat baseline — you cannot bolt it on later.
2. **Same binary, two modes.** One Godot project runs as the client *and* as the
headless dedicated server (`--headless -- --server`). No separate codebase.
3. **Addons can never hurt the host.** Mods run in a sandbox (Lua) with a capped
API, CPU/memory quotas, and no filesystem/network/host access. Designed in from
day one.
4. **Low-poly + lighting, not textures.** Art pipeline stays cheap so a solo dev
can actually finish.
## 2. Process topology
```
┌────────────────────────────────────────────┐
│ Dedicated Server (headless Godot) │
│ - owns ALL game state │
│ - runs the simulation tick (fixed timestep) │
│ - loads & sandboxes addons │
│ - persists the world (save system) │
└───────────────▲───────────────▲──────────────┘
│ ENet/UDP │
┌───────────┴───┐ ┌───────┴─────────┐
│ Client A │ │ Client B │
│ render+input │ │ render+input │
│ client-side │ │ client-side │
│ prediction │ │ prediction │
└────────────────┘ └──────────────────┘
```
Single-player = the client spawns an in-process server (loopback). So
single-player and multiplayer share the exact same code path — no "SP special
case" that rots.
## 3. Networking
- **Transport:** Godot's built-in high-level multiplayer over **ENet** (reliable
UDP). Good for 2–16 players, which is the realistic self-hosted survival scale.
- **Model:** server-authoritative with client-side prediction + reconciliation for
the local player's movement (so it feels responsive), and interpolation for
remote entities (so they look smooth).
- **RPC discipline:** clients send *intents* ("I want to place a wall here",
"I swung at this"), never *results*. The server validates, applies, and
broadcasts the authoritative result.
- **Scale target:** 2–16 players per server. NOT Rust's 200. That's a deliberate
scope cut — 200-player netcode is a multi-year problem on its own.
## 4. World
- **Terrain:** heightmap-based mesh, chunked. Each chunk is a mesh the server
generates deterministically from a seed, so clients can regenerate geometry
locally and the server only syncs *edits* (not the whole mesh).
- **Generation:** deterministic noise (seed → biome map → heightmap → object
placement). Deterministic = client and server agree without syncing megabytes.
- **Biomes:** start with 2–3 (meadows, forest, maybe a harsher zone) à la Valheim.
- **Streaming:** load/unload chunks around each player. Server keeps a slightly
larger radius than clients.
## 5. Entities & simulation
- Fixed-timestep simulation tick on the server (e.g. 20–30 Hz). Rendering runs at
the client's framerate, decoupled.
- Entity types: player, resource node (tree/rock), dropped item, creature,
building piece, projectile.
- Each entity has a network id; the server owns it and replicates state deltas.
## 6. Building system
- **Snap-together prefabs:** walls, floors, roofs, stairs — each with snap points.
- **Structural rules (Valheim-style):** pieces need support; stability falls off
with distance from the ground, unsupported pieces collapse. Great emergent
gameplay, and it's a clean server-authoritative calculation.
- Placement is a client *intent*; the server validates snap + support + resource
cost, then commits.
## 7. Survival systems
- Health, stamina, hunger (and later temperature/comfort like Valheim).
- Crafting: recipes (inputs → output + required crafting station + skill level).
- Inventory: grid or slot-based, server-authoritative.
- Day/night cycle + weather driving lighting and creature behaviour.
## 8. Persistence
- World save = seed + chunk edits + entity states + player inventories.
- Server writes periodic snapshots + on clean shutdown. Format: Godot resources or
a simple binary/JSON hybrid. Keep it moddable-inspectable where cheap.
## 9. Addon SDK — the differentiator (full spec in ADDON_SDK.md)
- Addons are **Lua scripts** run inside a sandboxed Lua VM embedded in the server.
- The host exposes a **deliberately limited API surface** — the only things an
addon can touch. No `os`, no `io`, no `require` of host files, no raw sockets.
- **Quotas:** instruction-count / wall-time caps per callback (so an addon can't
hang the tick), and a memory ceiling per addon VM.
- **Event model:** addons register handlers (`on_player_join`, `on_block_place`,
`on_tick`, …). The host calls them inside the sandbox with the quota enforced.
- **Content model:** addons can also *declare* data (new items, recipes, prefabs)
via manifest files the host validates before loading.
- Addons run **server-side** (authoritative). Clients receive the resulting state
like any other. (A later client-side cosmetic addon lane is possible but separate
and even more locked down.)
## 10. Why Godot 4 + GDScript here
- Free, open-source, no licensing worry, exports to all three desktop OSes.
- Godot 4's renderer (SDFGI / voxel GI, volumetric fog) is enough for the Valheim
atmosphere without expensive assets.
- GDScript keeps iteration fast for a solo dev; drop to C++ GDExtension only for
hotspots (terrain meshing, noise) if profiling demands it.
- The Lua addon VM is embedded via a GDExtension or a Lua-in-Godot addon
(e.g. `godot-luaAPI`), keeping mods off GDScript (so a mod can't call engine
internals).
## 11. Deliberate scope cuts (so this can actually ship)
- 2–16 players, not 200.
- Desktop only first (no console/mobile).
- A handful of biomes, items, creatures for the vertical slice — breadth comes later.
- No photoreal art. Low-poly + lighting only.

58
docs/ROADMAP.md Normal file
View File

@@ -0,0 +1,58 @@
# Roadmap
One milestone ≈ a chunk of working sessions. Each ends with something you can
actually run and see. Timelines assume solo dev + heavy AI assistance, steady
(not full-time) pace.
## M0 — World skeleton (single-player) · ~2–4 weeks
**Goal:** walk around a lit, atmospheric terrain. Prove the look.
- [x] Project scaffold, git repo, docs
- [ ] Godot installed, project opens
- [ ] First-person / third-person character controller (move, jump, mouse-look)
- [ ] One flat/heightmap terrain chunk with a material
- [ ] Atmospheric lighting: directional sun, sky, volumetric fog, tonemapping
- [ ] Place one low-poly tree + rock to set the mood
- **Done when:** you load the game and walk around a Valheim-ish lit field.
## M1 — Survival loop (single-player) · ~3–5 weeks
**Goal:** the core game verbs.
- [ ] Resource nodes: chop tree → wood, mine rock → stone
- [ ] Inventory + simple hotbar UI
- [ ] Crafting (recipe: wood → tools / building pieces)
- [ ] Health / stamina, day-night cycle
- [ ] Snap-together building: place a wall/floor, resource cost, basic support rule
- **Done when:** gather → craft → build a tiny shelter, single-player.
## M2 — Multiplayer · ~2–4 months
**Goal:** self-hosted, authoritative, 2–8 players.
- [ ] Refactor state to server-authoritative (single-player becomes loopback server)
- [ ] Headless dedicated server mode (`--headless -- --server`)
- [ ] ENet transport, client prediction for local movement, interpolation for others
- [ ] Replicate resource nodes, inventory, building, health
- [ ] Persistence: world save/load on the server
- **Done when:** a friend joins your self-hosted server and you build together.
## M3 — Addon SDK + sandbox · ~2–3 months
**Goal:** the differentiator. Easy, safe mods.
- [ ] Embed sandboxed Lua VM in the server
- [ ] Capped API surface (events + safe world queries/commands)
- [ ] Per-addon CPU/time + memory quotas
- [ ] Addon manifest format (declare items/recipes/prefabs as data)
- [ ] 2–3 example addons (a new craftable item; a "welcome" message on join; a
custom spawn rule)
- [ ] SDK docs for modders
- **Done when:** a modder drops an addon folder in and it safely adds content,
and a deliberately-malicious test addon is contained (can't read files,
can't hang the server).
## M4 — Content & polish · ongoing
- More biomes, creatures, items, building pieces
- PvP / raiding rules, building damage
- Better UI, settings, controls
- Audio, music, weather
- Packaged builds for Win/Mac/Linux (+ Mac signing/notarisation)
## Honest overall estimate
A genuinely playable **self-hosted survival game with a working sandboxed addon
SDK** is roughly **6–12 months** of steady solo work with AI help. The M0–M1
vertical slice (a few weeks) is what proves the concept and keeps you motivated.

7
icon.svg Normal file
View File

@@ -0,0 +1,7 @@
<svg width="128" height="128" viewBox="0 0 128 128" xmlns="http://www.w3.org/2000/svg">
<rect width="128" height="128" rx="16" fill="#2a3b2a"/>
<polygon points="64,20 92,72 36,72" fill="#2e7d4f"/>
<polygon points="64,38 84,80 44,80" fill="#3a9d63"/>
<rect x="58" y="78" width="12" height="20" fill="#5c422b"/>
<rect x="24" y="96" width="80" height="8" rx="4" fill="#6b5b3a"/>
</svg>

After

Width:  |  Height:  |  Size: 393 B

51
project.godot Normal file
View File

@@ -0,0 +1,51 @@
; Engine configuration file.
; Godot 4.3+ project. Open by importing this folder in the Godot editor,
; or run: godot --path .
config_version=5
[application]
config/name="Valheimlike"
config/description="Low-poly survival game — Valheim look, Rust depth, self-hosted, moddable."
run/main_scene="res://scenes/world.tscn"
config/features=PackedStringArray("4.3", "Forward Plus")
config/icon="res://icon.svg"
[display]
window/size/viewport_width=1280
window/size/viewport_height=720
window/stretch/mode="canvas_items"
[input]
move_forward={
"deadzone": 0.5,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":87,"key_label":0,"unicode":119,"location":0,"echo":false,"script":null)]
}
move_back={
"deadzone": 0.5,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":83,"key_label":0,"unicode":115,"location":0,"echo":false,"script":null)]
}
move_left={
"deadzone": 0.5,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":65,"key_label":0,"unicode":97,"location":0,"echo":false,"script":null)]
}
move_right={
"deadzone": 0.5,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":68,"key_label":0,"unicode":100,"location":0,"echo":false,"script":null)]
}
jump={
"deadzone": 0.5,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":32,"key_label":0,"unicode":32,"location":0,"echo":false,"script":null)]
}
[physics]
common/physics_ticks_per_second=30
[rendering]
renderer/rendering_method="forward_plus"
environment/defaults/default_environment="res://scenes/default_env.tres"

28
scenes/default_env.tres Normal file
View File

@@ -0,0 +1,28 @@
[gd_resource type="Environment" load_steps=4 format=3]
[sub_resource type="ProceduralSkyMaterial" id="SkyMat_1"]
sky_top_color = Color(0.38, 0.52, 0.68, 1)
sky_horizon_color = Color(0.7, 0.75, 0.78, 1)
ground_bottom_color = Color(0.2, 0.22, 0.2, 1)
ground_horizon_color = Color(0.64, 0.68, 0.67, 1)
[sub_resource type="Sky" id="Sky_1"]
sky_material = SubResource("SkyMat_1")
[resource]
background_mode = 2
sky = SubResource("Sky_1")
ambient_light_source = 3
ambient_light_color = Color(0.6, 0.66, 0.72, 1)
ambient_light_energy = 0.4
tonemap_mode = 3
tonemap_white = 6.0
ssao_enabled = true
fog_enabled = true
fog_light_color = Color(0.72, 0.78, 0.82, 1)
fog_density = 0.012
fog_sky_affect = 0.4
volumetric_fog_enabled = true
volumetric_fog_density = 0.02
adjustment_enabled = true
adjustment_saturation = 1.05

27
scenes/player.tscn Normal file
View File

@@ -0,0 +1,27 @@
[gd_scene load_steps=4 format=3 uid="uid://b0player0valheim0"]
[ext_resource type="Script" path="res://scripts/player/player.gd" id="1_player"]
[sub_resource type="CapsuleShape3D" id="Capsule_1"]
radius = 0.4
height = 1.8
[sub_resource type="CapsuleMesh" id="CapsuleMesh_1"]
radius = 0.4
height = 1.8
[node name="Player" type="CharacterBody3D"]
script = ExtResource("1_player")
[node name="CollisionShape3D" type="CollisionShape3D" parent="."]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.9, 0)
shape = SubResource("Capsule_1")
[node name="Body" type="MeshInstance3D" parent="."]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.9, 0)
visible = false
mesh = SubResource("CapsuleMesh_1")
[node name="Camera3D" type="Camera3D" parent="."]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1.6, 0)
current = true

50
scenes/world.tscn Normal file
View File

@@ -0,0 +1,50 @@
[gd_scene load_steps=6 format=3 uid="uid://c0world0valheim00"]
[ext_resource type="Script" path="res://scripts/world/terrain.gd" id="1_terrain"]
[ext_resource type="Script" path="res://scripts/world/props.gd" id="2_props"]
[ext_resource type="PackedScene" path="res://scenes/player.tscn" id="3_player"]
[sub_resource type="Environment" id="Env_inline"]
background_mode = 2
ambient_light_source = 3
ambient_light_color = Color(0.6, 0.66, 0.72, 1)
ambient_light_energy = 0.4
tonemap_mode = 3
fog_enabled = true
fog_light_color = Color(0.72, 0.78, 0.82, 1)
fog_density = 0.012
[sub_resource type="LabelSettings" id="Label_1"]
font_size = 16
outline_size = 4
outline_color = Color(0, 0, 0, 0.7)
[node name="World" type="Node3D"]
[node name="WorldEnvironment" type="WorldEnvironment" parent="."]
environment = SubResource("Env_inline")
[node name="Sun" type="DirectionalLight3D" parent="."]
transform = Transform3D(0.86, -0.35, 0.37, 0, 0.72, 0.69, -0.51, -0.6, 0.62, 0, 40, 0)
light_color = Color(1, 0.96, 0.86, 1)
light_energy = 1.2
shadow_enabled = true
[node name="Terrain" type="Node3D" parent="."]
script = ExtResource("1_terrain")
[node name="Props" type="Node3D" parent="."]
script = ExtResource("2_props")
[node name="Player" parent="." instance=ExtResource("3_player")]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 12, 0)
[node name="HUD" type="CanvasLayer" parent="."]
[node name="Hint" type="Label" parent="HUD"]
offset_left = 16.0
offset_top = 12.0
offset_right = 600.0
offset_bottom = 60.0
text = "WASD move · Mouse look · Shift sprint · Space jump · ESC free cursor"
label_settings = SubResource("Label_1")

48
scripts/player/player.gd Normal file
View File

@@ -0,0 +1,48 @@
extends CharacterBody3D
## First-person player controller — M0.
##
## Move (WASD), look (mouse), jump (space). Gravity + ground collision via
## CharacterBody3D. Deliberately minimal: no stamina/health yet (M1), no network
## ownership yet (M2). Press ESC to free the mouse, click to recapture.
@export var speed: float = 5.0
@export var sprint_speed: float = 8.0
@export var jump_velocity: float = 5.5
@export var mouse_sensitivity: float = 0.0025
@onready var camera: Camera3D = $Camera3D
var _gravity: float = ProjectSettings.get_setting("physics/3d/default_gravity", 9.8)
func _ready() -> void:
Input.mouse_mode = Input.MOUSE_MODE_CAPTURED
func _unhandled_input(event: InputEvent) -> void:
if event is InputEventMouseMotion and Input.mouse_mode == Input.MOUSE_MODE_CAPTURED:
rotate_y(-event.relative.x * mouse_sensitivity)
camera.rotate_x(-event.relative.y * mouse_sensitivity)
camera.rotation.x = clamp(camera.rotation.x, -1.4, 1.4)
elif event is InputEventKey and event.pressed and event.keycode == KEY_ESCAPE:
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
elif event is InputEventMouseButton and event.pressed:
Input.mouse_mode = Input.MOUSE_MODE_CAPTURED
func _physics_process(delta: float) -> void:
if not is_on_floor():
velocity.y -= _gravity * delta
if Input.is_action_just_pressed("jump") and is_on_floor():
velocity.y = jump_velocity
var input_dir := Input.get_vector("move_left", "move_right", "move_forward", "move_back")
var direction := (transform.basis * Vector3(input_dir.x, 0, input_dir.y)).normalized()
var current_speed := sprint_speed if Input.is_key_pressed(KEY_SHIFT) else speed
if direction:
velocity.x = direction.x * current_speed
velocity.z = direction.z * current_speed
else:
velocity.x = move_toward(velocity.x, 0, current_speed)
velocity.z = move_toward(velocity.z, 0, current_speed)
move_and_slide()

65
scripts/world/props.gd Normal file
View File

@@ -0,0 +1,65 @@
extends Node3D
## M0 mood props — a few low-poly trees and rocks built from primitives in code,
## so the scene looks "alive" with no imported assets. These are placeholders;
## real low-poly models (free asset packs, see docs/ROADMAP.md M0) replace them.
func _ready() -> void:
# A deterministic scattering so it looks intentional, not random each run.
var rng := RandomNumberGenerator.new()
rng.seed = 42
for i in range(18):
var x := rng.randf_range(-55.0, 55.0)
var z := rng.randf_range(-55.0, 55.0)
if rng.randf() < 0.65:
_make_tree(Vector3(x, 0, z), rng)
else:
_make_rock(Vector3(x, 0, z), rng)
func _make_tree(pos: Vector3, rng: RandomNumberGenerator) -> void:
var root := Node3D.new()
root.position = pos
add_child(root)
var trunk := MeshInstance3D.new()
var tm := CylinderMesh.new()
tm.top_radius = 0.18
tm.bottom_radius = 0.28
tm.height = 3.0
trunk.mesh = tm
trunk.position = Vector3(0, 1.5, 0)
trunk.material_override = _mat(Color(0.33, 0.24, 0.16))
root.add_child(trunk)
# Low-poly conifer canopy: stacked cones.
var h := 3.0
for j in range(3):
var cone := MeshInstance3D.new()
var cm := CylinderMesh.new()
cm.top_radius = 0.0
cm.bottom_radius = 1.6 - j * 0.4
cm.height = 1.6
cm.radial_segments = 6 # low-poly
cone.mesh = cm
cone.position = Vector3(0, h + j * 1.0, 0)
cone.material_override = _mat(Color(0.16, 0.34, 0.2))
root.add_child(cone)
root.rotate_y(rng.randf_range(0, TAU))
var s := rng.randf_range(0.85, 1.35)
root.scale = Vector3(s, s, s)
func _make_rock(pos: Vector3, rng: RandomNumberGenerator) -> void:
var rock := MeshInstance3D.new()
var bm := BoxMesh.new()
bm.size = Vector3(rng.randf_range(0.8, 1.8), rng.randf_range(0.6, 1.2), rng.randf_range(0.8, 1.8))
rock.mesh = bm
rock.position = pos + Vector3(0, 0.3, 0)
rock.rotation = Vector3(rng.randf_range(-0.2, 0.2), rng.randf_range(0, TAU), rng.randf_range(-0.2, 0.2))
rock.material_override = _mat(Color(0.42, 0.42, 0.45))
add_child(rock)
func _mat(c: Color) -> StandardMaterial3D:
var m := StandardMaterial3D.new()
m.albedo_color = c
m.roughness = 0.95
return m

77
scripts/world/terrain.gd Normal file
View File

@@ -0,0 +1,77 @@
extends Node3D
## Terrain generator — M0 placeholder.
##
## Builds ONE heightmap chunk from deterministic noise, so the world already
## looks like rolling ground rather than a flat plane. This is intentionally
## simple: a single chunk, generated at _ready(). Chunk streaming, biomes and
## server-side determinism (see docs/ARCHITECTURE.md §4) come in later milestones.
##
## The mesh is generated in code (no asset needed) so the project runs the moment
## Godot opens it, with nothing to import.
@export var size: int = 128 # chunk is size x size metres
@export var resolution: int = 96 # vertices per side (higher = smoother/heavier)
@export var height_scale: float = 10.0
@export var noise_seed: int = 1337
func _ready() -> void:
_build()
func _build() -> void:
var noise := FastNoiseLite.new()
noise.seed = noise_seed
noise.noise_type = FastNoiseLite.TYPE_PERLIN
noise.frequency = 0.012
noise.fractal_octaves = 4
var st := SurfaceTool.new()
st.begin(Mesh.PRIMITIVE_TRIANGLES)
var step := float(size) / float(resolution)
var half := size * 0.5
# Build a grid of quads, two triangles each.
for z in range(resolution):
for x in range(resolution):
var x0 := x * step - half
var z0 := z * step - half
var x1 := (x + 1) * step - half
var z1 := (z + 1) * step - half
var p00 := Vector3(x0, _height(noise, x0, z0), z0)
var p10 := Vector3(x1, _height(noise, x1, z0), z0)
var p01 := Vector3(x0, _height(noise, x0, z1), z1)
var p11 := Vector3(x1, _height(noise, x1, z1), z1)
_add_tri(st, p00, p01, p11)
_add_tri(st, p00, p11, p10)
st.generate_normals()
st.generate_tangents()
var mesh := st.commit()
var mi := MeshInstance3D.new()
mi.mesh = mesh
# Simple ground material. Low-poly look = flat-ish colour + lighting does the rest.
var mat := StandardMaterial3D.new()
mat.albedo_color = Color(0.33, 0.45, 0.22) # mossy green
mat.roughness = 0.95
mi.material_override = mat
add_child(mi)
# Collision so the player can walk on it.
var body := StaticBody3D.new()
var shape := CollisionShape3D.new()
var col := mesh.create_trimesh_shape()
shape.shape = col
body.add_child(shape)
add_child(body)
func _height(noise: FastNoiseLite, x: float, z: float) -> float:
return noise.get_noise_2d(x, z) * height_scale
func _add_tri(st: SurfaceTool, a: Vector3, b: Vector3, c: Vector3) -> void:
st.add_vertex(a)
st.add_vertex(b)
st.add_vertex(c)