commit 180b776392260936d4b5c5081224471046570af2 Author: dev Date: Tue Nov 3 22:23:20 2026 +0000 M0 scaffold: Godot 4 survival game skeleton + architecture/addon-SDK docs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2755249 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..74ff111 --- /dev/null +++ b/README.md @@ -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). diff --git a/docs/ADDON_SDK.md b/docs/ADDON_SDK.md new file mode 100644 index 0000000..913123e --- /dev/null +++ b/docs/ADDON_SDK.md @@ -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. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..c7cfeba --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -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. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md new file mode 100644 index 0000000..4761cf7 --- /dev/null +++ b/docs/ROADMAP.md @@ -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. diff --git a/icon.svg b/icon.svg new file mode 100644 index 0000000..179fe2a --- /dev/null +++ b/icon.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/project.godot b/project.godot new file mode 100644 index 0000000..0827f9c --- /dev/null +++ b/project.godot @@ -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" diff --git a/scenes/default_env.tres b/scenes/default_env.tres new file mode 100644 index 0000000..48e8ed7 --- /dev/null +++ b/scenes/default_env.tres @@ -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 diff --git a/scenes/player.tscn b/scenes/player.tscn new file mode 100644 index 0000000..9153862 --- /dev/null +++ b/scenes/player.tscn @@ -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 diff --git a/scenes/world.tscn b/scenes/world.tscn new file mode 100644 index 0000000..94d4134 --- /dev/null +++ b/scenes/world.tscn @@ -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") diff --git a/scripts/player/player.gd b/scripts/player/player.gd new file mode 100644 index 0000000..9acaaa3 --- /dev/null +++ b/scripts/player/player.gd @@ -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() diff --git a/scripts/world/props.gd b/scripts/world/props.gd new file mode 100644 index 0000000..fa6103a --- /dev/null +++ b/scripts/world/props.gd @@ -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 diff --git a/scripts/world/terrain.gd b/scripts/world/terrain.gd new file mode 100644 index 0000000..1d5289c --- /dev/null +++ b/scripts/world/terrain.gd @@ -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)