M0 scaffold: Godot 4 survival game skeleton + architecture/addon-SDK docs
This commit is contained in:
19
.gitignore
vendored
Normal file
19
.gitignore
vendored
Normal 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
53
README.md
Normal 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
125
docs/ADDON_SDK.md
Normal 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
116
docs/ARCHITECTURE.md
Normal 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
58
docs/ROADMAP.md
Normal 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
7
icon.svg
Normal 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
51
project.godot
Normal 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
28
scenes/default_env.tres
Normal 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
27
scenes/player.tscn
Normal 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
50
scenes/world.tscn
Normal 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
48
scripts/player/player.gd
Normal 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
65
scripts/world/props.gd
Normal 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
77
scripts/world/terrain.gd
Normal 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)
|
||||||
Reference in New Issue
Block a user