From 4640f5e5775186bab28011f84c1f24470b79ad5f Mon Sep 17 00:00:00 2001 From: Paul Bailey Date: Wed, 4 Nov 2026 00:34:46 +0000 Subject: [PATCH] M1a: survival core, pause menu, title screen and save/load Design: docs/SURVIVAL_DESIGN.md (core loop, systems, architecture, phases). Survival core (M1a): - Items/recipes as data (data/*.json) in the addon SDK's define_item / define_recipe shape; procedural item meshes and offscreen-rendered icons - Inventory class (40 slots, hotbar = row 1) shared by player and chests; click / right-click split / shift-click quick-move - Actions autoload: the intent layer (craft, pickup, harvest_hit, place, transfer) that validates every change; becomes the RPC surface in M2 - Gathering: ground pickups (sticks, stones, flint); trees chopped with an axe fall and leave stumps; boulders mined with a pickaxe shrink per hit - Crafting screen with station checks (workbench); placement mode with green/red ghost, rotation and overlap/slope validation - Pieces: campfire (light, flames, embers), workbench, chest (16 slots) - HUD: hotbar, held tool view model with swing, prompts, toasts Menus and saving: - ESC pause menu: resume, controls, fullscreen, mouse sensitivity, quit - Title screen over a flyover of the island: Continue / New World (name + seed) / Load World (with delete) / Quit - Saves autoload: one folder per world under user://worlds, atomic writes with a .bak; stores only player changes (player, pieces, chest contents, harvested resources, taken pickups) and regenerates the rest from the seed - Autosave every 2 min, Save & Quit to title/desktop, save on window close Fixes: UI screens now fill the viewport (were pinned top-left); title backdrop no longer captures the mouse. Tests: test_survival_core (31 checks), test_save_load (round trip + slots), test_terrain_edges now checks 8 seeds for deep pits / unbanked water. --- data/items.json | 32 ++++ data/recipes.json | 16 ++ docs/SETUP_ON_NEW_MACHINE.md | 12 +- docs/SURVIVAL_DESIGN.md | 161 ++++++++++++++++++ project.godot | 8 +- scenes/title.tscn | 6 + scenes/world.tscn | 2 +- scripts/building/campfire.gd | 99 +++++++++++ scripts/building/campfire.gd.uid | 1 + scripts/building/chest.gd | 15 ++ scripts/building/chest.gd.uid | 1 + scripts/building/piece.gd | 36 ++++ scripts/building/piece.gd.uid | 1 + scripts/building/workbench.gd | 11 ++ scripts/building/workbench.gd.uid | 1 + scripts/core/actions.gd | 149 ++++++++++++++++ scripts/core/actions.gd.uid | 1 + scripts/core/inventory.gd | 167 ++++++++++++++++++ scripts/core/inventory.gd.uid | 1 + scripts/core/item_db.gd | 135 +++++++++++++++ scripts/core/item_db.gd.uid | 1 + scripts/core/saves.gd | 110 ++++++++++++ scripts/core/saves.gd.uid | 1 + scripts/items/item_meshes.gd | 160 ++++++++++++++++++ scripts/items/item_meshes.gd.uid | 1 + scripts/player/hands.gd | 179 ++++++++++++++++++++ scripts/player/hands.gd.uid | 1 + scripts/player/player.gd | 40 ++++- scripts/ui/hud.gd | 139 +++++++++++++++ scripts/ui/hud.gd.uid | 1 + scripts/ui/inventory_screen.gd | 263 +++++++++++++++++++++++++++++ scripts/ui/inventory_screen.gd.uid | 1 + scripts/ui/pause_menu.gd | 167 ++++++++++++++++++ scripts/ui/pause_menu.gd.uid | 1 + scripts/ui/slot.gd | 66 ++++++++ scripts/ui/slot.gd.uid | 1 + scripts/ui/theme.gd | 35 ++++ scripts/ui/theme.gd.uid | 1 + scripts/ui/title_screen.gd | 238 ++++++++++++++++++++++++++ scripts/ui/title_screen.gd.uid | 1 + scripts/world/pickup.gd | 35 ++++ scripts/world/pickup.gd.uid | 1 + scripts/world/props.gd | 145 ++++++++++++++-- scripts/world/resource_node.gd | 176 +++++++++++++++++++ scripts/world/resource_node.gd.uid | 1 + scripts/world/terrain.gd | 4 + scripts/world/world.gd | 226 ++++++++++++++++++++++++- tests/test_save_load.gd | 143 ++++++++++++++++ tests/test_save_load.gd.uid | 1 + tests/test_survival_core.gd | 175 +++++++++++++++++++ tests/test_survival_core.gd.uid | 1 + tests/test_terrain_edges.gd | 57 +++++-- tests/test_terrain_edges.gd.uid | 1 + 53 files changed, 3192 insertions(+), 36 deletions(-) create mode 100644 data/items.json create mode 100644 data/recipes.json create mode 100644 docs/SURVIVAL_DESIGN.md create mode 100644 scenes/title.tscn create mode 100644 scripts/building/campfire.gd create mode 100644 scripts/building/campfire.gd.uid create mode 100644 scripts/building/chest.gd create mode 100644 scripts/building/chest.gd.uid create mode 100644 scripts/building/piece.gd create mode 100644 scripts/building/piece.gd.uid create mode 100644 scripts/building/workbench.gd create mode 100644 scripts/building/workbench.gd.uid create mode 100644 scripts/core/actions.gd create mode 100644 scripts/core/actions.gd.uid create mode 100644 scripts/core/inventory.gd create mode 100644 scripts/core/inventory.gd.uid create mode 100644 scripts/core/item_db.gd create mode 100644 scripts/core/item_db.gd.uid create mode 100644 scripts/core/saves.gd create mode 100644 scripts/core/saves.gd.uid create mode 100644 scripts/items/item_meshes.gd create mode 100644 scripts/items/item_meshes.gd.uid create mode 100644 scripts/player/hands.gd create mode 100644 scripts/player/hands.gd.uid create mode 100644 scripts/ui/hud.gd create mode 100644 scripts/ui/hud.gd.uid create mode 100644 scripts/ui/inventory_screen.gd create mode 100644 scripts/ui/inventory_screen.gd.uid create mode 100644 scripts/ui/pause_menu.gd create mode 100644 scripts/ui/pause_menu.gd.uid create mode 100644 scripts/ui/slot.gd create mode 100644 scripts/ui/slot.gd.uid create mode 100644 scripts/ui/theme.gd create mode 100644 scripts/ui/theme.gd.uid create mode 100644 scripts/ui/title_screen.gd create mode 100644 scripts/ui/title_screen.gd.uid create mode 100644 scripts/world/pickup.gd create mode 100644 scripts/world/pickup.gd.uid create mode 100644 scripts/world/resource_node.gd create mode 100644 scripts/world/resource_node.gd.uid create mode 100644 tests/test_save_load.gd create mode 100644 tests/test_save_load.gd.uid create mode 100644 tests/test_survival_core.gd create mode 100644 tests/test_survival_core.gd.uid create mode 100644 tests/test_terrain_edges.gd.uid diff --git a/data/items.json b/data/items.json new file mode 100644 index 0000000..3c1b9ec --- /dev/null +++ b/data/items.json @@ -0,0 +1,32 @@ +{ + "items": [ + { "id": "stick", "name": "Stick", "category": "resource", "stack": 50, + "description": "A sturdy branch. Handles, kindling, fuel.", "mesh": "stick", "fuel": 20 }, + { "id": "stone", "name": "Stone", "category": "resource", "stack": 50, + "description": "A fist-sized stone.", "mesh": "stone" }, + { "id": "flint", "name": "Flint", "category": "resource", "stack": 50, + "description": "Sharp-edged stone. Knaps into blades and axe heads.", "mesh": "flint" }, + { "id": "wood", "name": "Wood", "category": "resource", "stack": 50, + "description": "Split timber from a felled tree.", "mesh": "wood", "fuel": 60 }, + + { "id": "stone_axe", "name": "Stone Axe", "category": "tool", "stack": 1, + "description": "Chops trees. Hold it and click on a tree.", "mesh": "stone_axe", + "tool": { "kind": "axe", "tier": 1, "damage": 1.0 } }, + { "id": "stone_pickaxe", "name": "Stone Pickaxe", "category": "tool", "stack": 1, + "description": "Breaks boulders into stone and flint.", "mesh": "stone_pickaxe", + "tool": { "kind": "pickaxe", "tier": 1, "damage": 1.0 } }, + { "id": "club", "name": "Wooden Club", "category": "weapon", "stack": 1, + "description": "Crude, heavy, effective. (Creatures arrive in M1d.)", "mesh": "club", + "weapon": { "damage": 12, "reach": 2.2, "stamina": 8 } }, + + { "id": "campfire", "name": "Campfire", "category": "placeable", "stack": 5, + "description": "Light and warmth. Place it, then keep it burning.", "mesh": "campfire", + "place": { "piece": "campfire", "footprint": [1.4, 1.4] } }, + { "id": "workbench", "name": "Workbench", "category": "placeable", "stack": 1, + "description": "Crafting station for better gear and storage.", "mesh": "workbench", + "place": { "piece": "workbench", "footprint": [2.0, 1.1] } }, + { "id": "chest", "name": "Wooden Chest", "category": "placeable", "stack": 5, + "description": "Stores 16 stacks.", "mesh": "chest", + "place": { "piece": "chest", "footprint": [1.2, 0.8] } } + ] +} diff --git a/data/recipes.json b/data/recipes.json new file mode 100644 index 0000000..fb49454 --- /dev/null +++ b/data/recipes.json @@ -0,0 +1,16 @@ +{ + "recipes": [ + { "id": "stone_axe", "output": "stone_axe", "count": 1, "station": "", + "inputs": { "stick": 2, "flint": 2 } }, + { "id": "stone_pickaxe", "output": "stone_pickaxe", "count": 1, "station": "", + "inputs": { "stick": 2, "stone": 3, "flint": 1 } }, + { "id": "campfire", "output": "campfire", "count": 1, "station": "", + "inputs": { "stone": 5, "stick": 4 } }, + { "id": "workbench", "output": "workbench", "count": 1, "station": "", + "inputs": { "wood": 10, "flint": 2 } }, + { "id": "club", "output": "club", "count": 1, "station": "", + "inputs": { "wood": 4 } }, + { "id": "chest", "output": "chest", "count": 1, "station": "workbench", + "inputs": { "wood": 10, "stick": 4 } } + ] +} diff --git a/docs/SETUP_ON_NEW_MACHINE.md b/docs/SETUP_ON_NEW_MACHINE.md index 0920ae8..14015bc 100644 --- a/docs/SETUP_ON_NEW_MACHINE.md +++ b/docs/SETUP_ON_NEW_MACHINE.md @@ -17,10 +17,20 @@ download — the M0 world is generated in code. ```bash # Boundary: tries to walk/jump out of the valley in 24 directions godot --headless --path . --fixed-fps 30 --script res://tests/test_boundary.gd -# Terrain: no pits/hanging water just inside the boundary wall +# Terrain: no deep pits / floating water at the boundary, across 8 seeds godot --headless --path . --script res://tests/test_terrain_edges.gd +# Survival loop: inventory, crafting, chopping, mining, pickups, building, chests +godot --headless --path . --script res://tests/test_survival_core.gd +# Save/load round trip + world slots (uses user://test_worlds, not your saves) +godot --headless --path . --script res://tests/test_save_load.gd ``` +## Save files +Worlds are per machine, under +`~/Library/Application Support/Godot/app_userdata/Valheimlike/worlds/` on +macOS (`%APPDATA%\Godot\app_userdata\Valheimlike\worlds\` on Windows). Copy a +world's folder across to move it to another machine. + ## 2. Get the project onto the machine You have the folder (from the zip, or a git remote). Then: diff --git a/docs/SURVIVAL_DESIGN.md b/docs/SURVIVAL_DESIGN.md new file mode 100644 index 0000000..004c721 --- /dev/null +++ b/docs/SURVIVAL_DESIGN.md @@ -0,0 +1,161 @@ +# Survival game design + +How the survival game plays and how it is built. The core idea: **the world is +beautiful but indifferent.** You arrive on the island with nothing and have to +work out how to stay fed, warm and dry, then turn that into a home. + +## 1. Core loop + +``` +gather ──► craft ──► build / equip ──► survive the night & weather + ▲ │ + └──────────── hunt, explore, upgrade tools ◄──────────┘ +``` + +A good first evening for a new player (the "first hour" we tune toward): +1. Pick up sticks, stones and flint from the ground. (Hands only.) +2. Craft a **stone axe** and a **stone pickaxe**. +3. Chop trees for wood, mine boulders for stone. +4. Build a **campfire** before dark. It's light, warmth and (later) cooking. +5. Build a **workbench**, then a **chest** to stash what you've gathered. +6. Rain rolls in at night: build a rough shelter so you can dry off by the fire. +7. Hunger bites: hunt a rabbit or deer, cook the meat on the fire. + +## 2. Systems + +### 2.1 Items (data-driven) +All items and recipes are **data** (`data/items.json`, `data/recipes.json`) in the +same shape the addon SDK uses (`define_item` / `define_recipe`, see +ADDON_SDK.md). An addon adding an item later uses the same path as the base game. + +Item fields: `id`, `name`, `description`, `stack`, `category` +(`resource` | `tool` | `weapon` | `food` | `placeable`), plus optional blocks: +- `tool`: `{ "kind": "axe" | "pickaxe" | "hammer", "tier": 1, "damage": 1.0 }` +- `weapon`: `{ "damage": 10, "reach": 2.2, "stamina": 8 }` +- `food`: `{ "hunger": 30, "health": 5, "spoils_after": 600 }` +- `place`: `{ "piece": "campfire", "footprint": [1.4, 1.4] }` +- `fuel`: seconds of burn time in a fire + +### 2.2 Inventory & storage +- Slot grid with stacks. The player has **40 slots**; the first row of 8 is the + **hotbar** (keys 1-8 or the mouse wheel select the held item). +- Click to pick up a stack, click to drop or swap it, right-click to split, + shift-click to quick-move between your inventory and an open chest. +- **Chests** (16 slots) are crafted at a workbench and placed in the world. +- One `Inventory` class backs the player, chests and later corpses, so moving + items is the same operation everywhere. + +### 2.3 Gathering +| Source | Needs | Gives | +|---|---|---| +| Sticks, stones, flint on the ground | hands (E) | 1 each | +| Trees (chop, falls over) | axe | wood, sticks | +| Boulders (mine, shrinks per hit) | pickaxe | stone, flint | +| Bushes *(M1b)* | hands | fiber, berries | +| Animals *(M1d)* | weapon | raw meat, hide, bone | + +Tool **tiers** gate harder resources later (e.g. iron ore needs a tier-2 +pickaxe). Felled trees leave stumps. Resources regrow over time *(M1c)*. + +### 2.4 Crafting & stations +Recipes list inputs, an output and an optional **station** that must be within +6 m: none (hand), `workbench`, `campfire` (cooking), later `forge`. The crafting +panel shows every recipe you know, greyed out with the missing inputs in red. + +### 2.5 Building +- **Placement mode**: holding a placeable shows a ghost preview (green = valid, + red = blocked). R rotates it, left-click places it. +- M1a pieces: campfire, workbench, chest. +- *(M1c)* Snap-together pieces: wood floor, wall, door, roof, stairs, with + snap points and Valheim-style **structural support** (stability falls off + with distance from the ground; unsupported pieces collapse). +- **Shelter detection**: you count as sheltered when rays upward hit a roof and + most sideways rays hit walls. That drives "dry" and "rested" status. + +### 2.6 Survival stats *(M1b)* +| Stat | Drains from | Restored by | At zero | +|---|---|---|---| +| Health | damage, starving, freezing | food, rest | death | +| Stamina | sprint, swing, jump | resting, food buffs | can't sprint/swing | +| Hunger | time (faster when cold) | eating | health drains | +| Warmth | night, rain/wet, snow, altitude | fire, shelter, clothes | health drains, slow | + +Status effects: **Wet** (rain or swimming: warmth drains faster, lasts after +you're out), **Cold** / **Freezing**, **Warm** (near a fire), **Sheltered**, +**Rested** (sat by a fire under a roof: stamina regen buff). + +### 2.7 Time & weather *(M1b / M1c)* +- Day/night cycle: 20 real minutes per day by default. The sun, sky, fog and + ambient light follow time of day. Nights are dark enough that a campfire or + torch matters. +- Weather is a seeded state machine: clear → cloudy → rain → storm, plus + **snow** on cold days and always near the snowline. Each state sets the + cloud cover, fog, wind (grass/trees already read wind), particles and + sound, plus a warmth modifier. Transitions blend over about a minute. + +### 2.8 Creatures & hunting *(M1d)* +- **Rabbit**: skittish, fast, gives a little meat. +- **Deer**: flees on sight or sound, gives meat and hide. +- **Boar**: neutral; charges if hit; gives meat and hide. +- *(later)* **Wolf**: night predator, hunts in packs. +- Simple state machines (idle / wander / flee / attack) on navigation-free + steering over the heightmap. Spawned around the player within budget. + +### 2.9 Weapons & combat *(M1d)* +- Club (hand), wooden spear (thrust; can be thrown), bow + arrows (workbench; + needs feathers and flint). +- A melee swing is a short shape cast in front of the camera. It costs stamina + and has a cooldown. Damage numbers come from item data. + +### 2.10 Saving & worlds *(built)* +- **One continuous world per save slot.** The title screen offers Continue + (most recent), New World (name and seed: blank = random, 1337 = the + original island) and Load World (with delete). You can't save and reload at + will to undo mistakes: survival tension depends on choices sticking. +- **Autosave** every 2 minutes, on Save & Quit (to the title or desktop) and + when the window is closed. +- Saves live in `user://worlds//` (on macOS: + `~/Library/Application Support/Godot/app_userdata/Valheimlike/worlds/`) as + `meta.json` + `world.json`. Writes are atomic, and the previous save is + kept as `world.json.bak`. +- A save stores only what the player changed: player position and look, + inventory, hotbar slot; placed pieces and chest contents; tree and boulder + damage, stumps, mined rocks; collected pickups. Everything else regenerates + from the seed. *(Later: stats, time of day, weather, regrowth timers.)* + +### 2.11 Death *(M1c)* +- On death you respawn at your bed (or the island spawn) and leave a **grave + bag** with your inventory where you died (Valheim-style: punishing but + recoverable). The world carries on. +- Optional **hardcore** world setting later: death deletes the world. + +## 3. Architecture (fits docs/ARCHITECTURE.md) +- **Intents, not results.** UI and input never change game state directly. They + call `Actions.*` (`craft`, `harvest_hit`, `pickup`, `place`, `transfer`), + which validates and applies. In M2 these same calls become RPCs to the + authoritative server. That's the reason the layer exists now. +- **Model / view split.** `Inventory` is a plain data class with a `changed` + signal. UI widgets only render it and send intents. +- **Data in JSON**, behaviour in small scripts per piece (`campfire.gd`, + `chest.gd`, ...). +- **Everything procedural**: item meshes are built in code and turned into + icons at startup by an offscreen renderer, so there are still no art assets + to license. + +## 4. Build phases +| Phase | Delivers | Playable "done when" | +|---|---|---| +| **M1a** *(done)* | items/recipes data, inventory + hotbar, pickups, chop/mine with tools, crafting UI, campfire / workbench / chest placement, chest storage, pause menu, **title screen + world slots + save/load + autosave** (pulled forward from M1c) | you craft an axe, fell a tree, build a fire, stash wood in a chest, quit and continue | +| **M1b** | hunger/warmth/stamina/health HUD, day/night cycle, campfire warmth + fuel, cooking, bushes (fiber, berries), torches | you survive your first night by the fire | +| **M1c** | weather (rain, storm, snow) + wet/shelter, snap building (floor/wall/roof/door) with support, death + grave bag, resource regrowth | you build a hut and wait out a storm | +| **M1d** | rabbit/deer/boar AI, club/spear/bow, hides, cooked food buffs | you hunt a deer and eat its meat | + +## 5. Open decisions (current defaults in bold) +- Thirst stat? **No**, to keep the stats readable (Valheim has none). + Easy to add as a fifth stat later. +- Perspective: **first person** for now. Third person can come with the + character model. +- Death penalty: **grave bag** (recoverable). Alternatives: lose everything + (Rust) or keep everything (casual). +- Day length: **20 min**. +- Durability on tools: **no for M1**, revisit when metals arrive. diff --git a/project.godot b/project.godot index c5df1ba..3384871 100644 --- a/project.godot +++ b/project.godot @@ -16,10 +16,16 @@ compatibility/default_parent_skeleton_in_mesh_instance_3d=true config/name="Valheimlike" config/description="Low-poly survival game — Valheim look, Rust depth, self-hosted, moddable." -run/main_scene="res://scenes/world.tscn" +run/main_scene="res://scenes/title.tscn" config/features=PackedStringArray("4.7", "Forward Plus") config/icon="res://icon.svg" +[autoload] + +ItemDB="*res://scripts/core/item_db.gd" +Actions="*res://scripts/core/actions.gd" +Saves="*res://scripts/core/saves.gd" + [display] window/size/viewport_width=1280 diff --git a/scenes/title.tscn b/scenes/title.tscn new file mode 100644 index 0000000..a0e6313 --- /dev/null +++ b/scenes/title.tscn @@ -0,0 +1,6 @@ +[gd_scene load_steps=2 format=3 uid="uid://c0title0valheim00"] + +[ext_resource type="Script" path="res://scripts/ui/title_screen.gd" id="1_title"] + +[node name="Title" type="Node"] +script = ExtResource("1_title") diff --git a/scenes/world.tscn b/scenes/world.tscn index 084dc53..bd5aa9b 100644 --- a/scenes/world.tscn +++ b/scenes/world.tscn @@ -43,5 +43,5 @@ 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" +text = "WASD move · Shift sprint · Space jump · E interact · LMB use/place · R rotate · 1-8 hotbar · Tab inventory & crafting · ESC menu" label_settings = SubResource("Label_1") diff --git a/scripts/building/campfire.gd b/scripts/building/campfire.gd new file mode 100644 index 0000000..db8d884 --- /dev/null +++ b/scripts/building/campfire.gd @@ -0,0 +1,99 @@ +extends "res://scripts/building/piece.gd" +## Campfire: flickering light, flames, embers and smoke. A "campfire" station +## (cooking arrives in M1b along with fuel and warmth). + +var _light: OmniLight3D +var _t := 0.0 +var _noise := FastNoiseLite.new() + +func _build() -> void: + add_to_group("station:campfire") + _noise.frequency = 2.5 + _light = OmniLight3D.new() + _light.light_color = Color(1.0, 0.62, 0.3) + _light.light_energy = 2.2 + _light.omni_range = 11.0 + _light.omni_attenuation = 1.4 + _light.shadow_enabled = true + _light.position = Vector3(0, 0.7, 0) + add_child(_light) + + add_child(_particles("Flames", 26, 0.55, Vector3(0, 0.12, 0), 0.5, 1.0, 0.5, 0.9, _gradient([ + [0.0, Color(1.0, 0.8, 0.35, 0.0)], [0.15, Color(1.0, 0.62, 0.2, 0.75)], + [0.6, Color(0.95, 0.3, 0.06, 0.45)], [1.0, Color(0.5, 0.08, 0.02, 0.0)]]), true, 0.32, 0.12)) + add_child(_particles("Embers", 10, 2.0, Vector3(0, 0.3, 0), 0.8, 2.0, 0.5, 1.0, _gradient([ + [0.0, Color(1.0, 0.75, 0.35, 1.0)], [1.0, Color(1.0, 0.3, 0.05, 0.0)]]), true, 0.04, 0.15)) + var smoke := _particles("Smoke", 10, 5.0, Vector3(0, 1.0, 0), 0.6, 1.1, 0.6, 1.4, _gradient([ + [0.0, Color(0.55, 0.54, 0.52, 0.0)], [0.25, Color(0.58, 0.57, 0.55, 0.07)], + [1.0, Color(0.7, 0.7, 0.7, 0.0)]]), false, 0.9, 0.15) + smoke.spread = 25.0 + smoke.gravity = Vector3(0.35, 0.25, 0.1) # drifts with the wind + # TODO(M1b): smoke renders as an opaque dark ball whatever its ramp colour + # (alpha-blended unshaded particles; additive flames are fine). Disabled + # until that's fixed, likely by moving to a small particle shader. + smoke.queue_free() + +func display_name() -> String: + return "Campfire" + +func _process(delta: float) -> void: + _t += delta + var f := _noise.get_noise_1d(_t * 10.0) + _light.light_energy = 2.2 + f * 0.7 + _light.position = Vector3(f * 0.05, 0.7 + f * 0.05, 0) + +static func _gradient(stops: Array) -> Gradient: + var g := Gradient.new() + g.offsets = PackedFloat32Array(stops.map(func(s): return s[0])) + g.colors = PackedColorArray(stops.map(func(s): return s[1])) + return g + +static func _soft_dot() -> GradientTexture2D: + var g := Gradient.new() + g.offsets = PackedFloat32Array([0.0, 1.0]) + g.colors = PackedColorArray([Color(1, 1, 1, 1), Color(1, 1, 1, 0)]) + var t := GradientTexture2D.new() + t.gradient = g + t.fill = GradientTexture2D.FILL_RADIAL + t.fill_from = Vector2(0.5, 0.5) + t.fill_to = Vector2(0.5, 0.0) + t.width = 64 + t.height = 64 + return t + +func _particles(label: String, amount: int, lifetime: float, pos: Vector3, vmin: float, vmax: float, + smin: float, smax: float, ramp: Gradient, additive: bool, quad_size: float, radius: float) -> CPUParticles3D: + var p := CPUParticles3D.new() + p.name = label + p.amount = amount + p.lifetime = lifetime + p.position = pos + p.direction = Vector3.UP + p.spread = 12.0 + p.initial_velocity_min = vmin + p.initial_velocity_max = vmax + p.gravity = Vector3(0.15, 0.4, 0.05) + p.emission_shape = CPUParticles3D.EMISSION_SHAPE_SPHERE + p.emission_sphere_radius = radius + p.scale_amount_min = smin + p.scale_amount_max = smax + p.color_ramp = ramp + # Flames shrink as they rise. + var curve := Curve.new() + curve.add_point(Vector2(0.0, 1.0)) + curve.add_point(Vector2(1.0, 0.35 if additive else 1.6)) + p.scale_amount_curve = curve + var quad := QuadMesh.new() + quad.size = Vector2.ONE * quad_size + var m := StandardMaterial3D.new() + m.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED + m.billboard_mode = BaseMaterial3D.BILLBOARD_PARTICLES + m.vertex_color_use_as_albedo = true + m.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA + m.blend_mode = BaseMaterial3D.BLEND_MODE_ADD if additive else BaseMaterial3D.BLEND_MODE_MIX + m.albedo_texture = _soft_dot() + if additive: + m.albedo_color = Color(1.5, 1.5, 1.5) # mildly HDR so the glow picks it up + quad.material = m + p.mesh = quad + return p diff --git a/scripts/building/campfire.gd.uid b/scripts/building/campfire.gd.uid new file mode 100644 index 0000000..8ffc2d3 --- /dev/null +++ b/scripts/building/campfire.gd.uid @@ -0,0 +1 @@ +uid://di4xtvyvo151n diff --git a/scripts/building/chest.gd b/scripts/building/chest.gd new file mode 100644 index 0000000..8901a06 --- /dev/null +++ b/scripts/building/chest.gd @@ -0,0 +1,15 @@ +extends "res://scripts/building/piece.gd" +## Wooden chest: 16 slots of storage. E opens it next to your inventory. + +const SLOTS := 16 +var inventory := Inventory.new(SLOTS) + +func interact_prompt(_player: Node) -> String: + var used := 0 + for s in inventory.slots: + if s != null: + used += 1 + return "Chest (%d/%d) [E] Open" % [used, SLOTS] + +func interact(player: Node) -> void: + player.open_inventory(self) diff --git a/scripts/building/chest.gd.uid b/scripts/building/chest.gd.uid new file mode 100644 index 0000000..25c7084 --- /dev/null +++ b/scripts/building/chest.gd.uid @@ -0,0 +1 @@ +uid://yma48ens54gt diff --git a/scripts/building/piece.gd b/scripts/building/piece.gd new file mode 100644 index 0000000..2960bbd --- /dev/null +++ b/scripts/building/piece.gd @@ -0,0 +1,36 @@ +extends StaticBody3D +## Base for placed pieces (campfire, workbench, chest, later walls/floors). +## Builds the visual from the item's procedural mesh and a box collider from +## the item's footprint. Subclasses add behaviour. + +var item_id := "" +var def: Dictionary = {} + +func setup(d: Dictionary) -> void: + def = d + item_id = d.id + var mi := MeshInstance3D.new() + mi.name = "Mesh" + mi.mesh = ItemDB.mesh_for(item_id) + mi.material_override = ItemDB.item_material() + add_child(mi) + var aabb := mi.mesh.get_aabb() + var cs := CollisionShape3D.new() + var box := BoxShape3D.new() + box.size = Vector3(aabb.size.x, maxf(aabb.size.y, 0.3), aabb.size.z) + cs.shape = box + cs.position = aabb.get_center() + add_child(cs) + _build() + +func _build() -> void: + pass + +func display_name() -> String: + return ItemDB.item_name(item_id) + +func interact_prompt(_player: Node) -> String: + return display_name() + +func interact(_player: Node) -> void: + pass diff --git a/scripts/building/piece.gd.uid b/scripts/building/piece.gd.uid new file mode 100644 index 0000000..c3fe1b9 --- /dev/null +++ b/scripts/building/piece.gd.uid @@ -0,0 +1 @@ +uid://c65x7w4vb84ua diff --git a/scripts/building/workbench.gd b/scripts/building/workbench.gd new file mode 100644 index 0000000..cf48b88 --- /dev/null +++ b/scripts/building/workbench.gd @@ -0,0 +1,11 @@ +extends "res://scripts/building/piece.gd" +## Workbench: unlocks "workbench" recipes within 6 m. E opens crafting. + +func _build() -> void: + add_to_group("station:workbench") + +func interact_prompt(_player: Node) -> String: + return "Workbench [E] Craft" + +func interact(player: Node) -> void: + player.open_inventory(null) diff --git a/scripts/building/workbench.gd.uid b/scripts/building/workbench.gd.uid new file mode 100644 index 0000000..2af168b --- /dev/null +++ b/scripts/building/workbench.gd.uid @@ -0,0 +1 @@ +uid://ciu8ajr75fv2a diff --git a/scripts/core/actions.gd b/scripts/core/actions.gd new file mode 100644 index 0000000..6191fc7 --- /dev/null +++ b/scripts/core/actions.gd @@ -0,0 +1,149 @@ +extends Node +## Autoload "Actions": the intent layer. +## +## UI and input never change game state directly. They call these functions, +## which validate and then apply. In M2 (multiplayer) these become the RPCs a +## client sends to the authoritative server, so every rule lives here and +## nothing is trusted from the caller. See docs/SURVIVAL_DESIGN.md §3. +## +## Also registers the input actions (so project.godot stays readable). + +signal notify(text: String) ## short HUD toast ("+4 Wood", "Need an axe") +signal item_gained(id: String, n: int) + +const STATION_RANGE := 6.0 +const REACH := 3.2 + +func _ready() -> void: + _bind("interact", [KEY_E]) + _bind("inventory", [KEY_TAB, KEY_I]) + _bind("rotate_piece", [KEY_R]) + for i in range(8): + _bind("hotbar_%d" % (i + 1), [KEY_1 + i]) + _bind_mouse("attack", MOUSE_BUTTON_LEFT) + _bind_mouse("cancel", MOUSE_BUTTON_RIGHT) + +func _bind(action: String, keys: Array) -> void: + if not InputMap.has_action(action): + InputMap.add_action(action) + for k in keys: + var ev := InputEventKey.new() + ev.physical_keycode = k + InputMap.action_add_event(action, ev) + +func _bind_mouse(action: String, button: MouseButton) -> void: + if not InputMap.has_action(action): + InputMap.add_action(action) + var ev := InputEventMouseButton.new() + ev.button_index = button + InputMap.action_add_event(action, ev) + +# --- helpers ---------------------------------------------------------------- + +func _give(player: Node, id: String, n: int) -> int: + var inv: Inventory = player.inventory + var left := inv.add(id, n) + var got := n - left + if got > 0: + item_gained.emit(id, got) + notify.emit("+%d %s" % [got, ItemDB.item_name(id)]) + if left > 0: + notify.emit("Inventory full") + return got + +func _in_reach(player: Node3D, target: Node3D, extra: float = 0.0) -> bool: + return player.global_position.distance_to(target.global_position) <= REACH + 2.5 + extra + +## Station nodes register themselves in group "station:". +func station_near(player: Node3D, station: String) -> bool: + if station.is_empty(): + return true + for s in player.get_tree().get_nodes_in_group("station:" + station): + if (s as Node3D).global_position.distance_to(player.global_position) <= STATION_RANGE: + return true + return false + +# --- intents ---------------------------------------------------------------- + +func can_craft(player: Node, recipe_id: String) -> bool: + var r: Dictionary = ItemDB.recipes.get(recipe_id, {}) + if r.is_empty(): + return false + var inv: Inventory = player.inventory + return inv.has_all(r.inputs) and station_near(player, r.station) + +func craft(player: Node, recipe_id: String) -> bool: + var r: Dictionary = ItemDB.recipes.get(recipe_id, {}) + if r.is_empty(): + return false + var inv: Inventory = player.inventory + if not station_near(player, r.station): + notify.emit("Needs a %s nearby" % r.station) + return false + if not inv.has_all(r.inputs): + notify.emit("Missing materials") + return false + # Check the output fits *after* the inputs are consumed. + var trial := Inventory.new(inv.size()) + trial.load_data(inv.to_data()) + for id in r.inputs: + trial.remove(id, int(r.inputs[id])) + if trial.space_for(r.output) < r.count: + notify.emit("Inventory full") + return false + for id in r.inputs: + inv.remove(id, int(r.inputs[id])) + _give(player, r.output, r.count) + return true + +func pickup(player: Node3D, pickup_node: Node3D) -> bool: + if not is_instance_valid(pickup_node) or not _in_reach(player, pickup_node): + return false + var id: String = pickup_node.item_id + if _give(player, id, pickup_node.amount) <= 0: + return false + pickup_node.collected() + return true + +## One swing at a resource node with whatever the player is holding. +func harvest_hit(player: Node3D, node: Node3D, hit_point: Vector3) -> bool: + if not is_instance_valid(node) or node.depleted: + return false + if player.global_position.distance_to(hit_point) > REACH + 1.0: + return false + var held: Dictionary = ItemDB.get_item(player.held_item_id()) + var tool: Dictionary = held.get("tool", {}) + if tool.get("kind", "") != node.tool_kind or int(tool.get("tier", 0)) < node.min_tier: + notify.emit("Need a %s" % ("stone " + node.tool_kind)) + return false + var drops: Dictionary = node.hit(float(tool.get("damage", 1.0)), hit_point, player.global_position) + for id in drops: + _give(player, id, int(drops[id])) + return true + +func can_place(player: Node3D, slot: int) -> bool: + var s = player.inventory.get_slot(slot) + return s != null and ItemDB.get_item(s.id).has("place") + +## Places the item in `slot` at `xf`. `xf` comes from the client's ghost +## preview, so it's re-validated here. +func place(player: Node3D, slot: int, xf: Transform3D, world: Node3D) -> bool: + if not can_place(player, slot): + return false + if player.global_position.distance_to(xf.origin) > 8.0: + return false + var id: String = player.inventory.get_slot(slot).id + var def: Dictionary = ItemDB.get_item(id) + if not world.placement_valid(def, xf): + notify.emit("Can't place that here") + return false + player.inventory.take_from(slot, 1) + world.spawn_piece(def, xf) + return true + +## Moves between two inventories (quick-move / shift-click). The player must +## be next to the container. +func transfer(player: Node3D, from: Inventory, slot: int, to: Inventory, container: Node3D = null) -> void: + if container != null and not _in_reach(player, container, 1.0): + return + from.quick_move(slot, to) diff --git a/scripts/core/actions.gd.uid b/scripts/core/actions.gd.uid new file mode 100644 index 0000000..12fb98c --- /dev/null +++ b/scripts/core/actions.gd.uid @@ -0,0 +1 @@ +uid://nwo7dhy67nkv diff --git a/scripts/core/inventory.gd b/scripts/core/inventory.gd new file mode 100644 index 0000000..9cb3395 --- /dev/null +++ b/scripts/core/inventory.gd @@ -0,0 +1,167 @@ +class_name Inventory +extends RefCounted +## Slot-based inventory with stacking. Backs the player, chests and (later) +## grave bags, so moving items is the same operation everywhere. +## +## Pure data: no nodes, no UI. Each slot is either null or {"id": String, +## "count": int}. UI widgets render it and listen to `changed`; mutations from +## gameplay go through Actions (see docs/SURVIVAL_DESIGN.md §3). + +signal changed + +var slots: Array = [] + +func _init(size: int = 40) -> void: + slots.resize(size) + +func size() -> int: + return slots.size() + +func get_slot(i: int): + return slots[i] + +func set_slot(i: int, stack) -> void: + slots[i] = _norm(stack) + changed.emit() + +## Looked up at runtime rather than via the ItemDB global, so this plain data +## class also compiles in contexts that load it before autoloads exist (tests, +## a future headless server tool). +static func _stack_max(id: String) -> int: + var db := (Engine.get_main_loop() as SceneTree).root.get_node_or_null("ItemDB") + return db.stack_size(id) if db != null else 50 + +static func _norm(stack): + if stack == null or int(stack.count) <= 0: + return null + return {"id": String(stack.id), "count": int(stack.count)} + +func count(id: String) -> int: + var n := 0 + for s in slots: + if s != null and s.id == id: + n += s.count + return n + +## How many of `id` would fit right now. +func space_for(id: String) -> int: + var cap := _stack_max(id) + var room := 0 + for s in slots: + if s == null: + room += cap + elif s.id == id: + room += cap - s.count + return room + +## Adds up to `n` of `id`, topping up existing stacks first. Returns the +## leftover that didn't fit (0 = everything fitted). +func add(id: String, n: int) -> int: + var cap := _stack_max(id) + for s in slots: + if n <= 0: + break + if s != null and s.id == id and s.count < cap: + var take := mini(cap - s.count, n) + s.count += take + n -= take + for i in range(slots.size()): + if n <= 0: + break + if slots[i] == null: + var take := mini(cap, n) + slots[i] = {"id": id, "count": take} + n -= take + changed.emit() + return n + +## Removes `n` of `id` (from the last stacks first, so the hotbar keeps its +## items longest). Returns false and changes nothing if there aren't enough. +func remove(id: String, n: int) -> bool: + if count(id) < n: + return false + for i in range(slots.size() - 1, -1, -1): + if n <= 0: + break + var s = slots[i] + if s != null and s.id == id: + var take := mini(s.count, n) + s.count -= take + n -= take + if s.count <= 0: + slots[i] = null + changed.emit() + return true + +## Removes `n` from a specific slot. Returns how many were removed. +func take_from(i: int, n: int) -> int: + var s = slots[i] + if s == null: + return 0 + var take := mini(s.count, n) + s.count -= take + if s.count <= 0: + slots[i] = null + changed.emit() + return take + +func has_all(inputs: Dictionary) -> bool: + for id in inputs: + if count(id) < int(inputs[id]): + return false + return true + +## Moves the stack in slot `i` into `other` (stacking where possible). Whatever +## doesn't fit stays put. Used for shift-click quick-move. +func quick_move(i: int, other: Inventory) -> void: + var s = slots[i] + if s == null: + return + var left := other.add(s.id, s.count) + slots[i] = null if left <= 0 else {"id": s.id, "count": left} + changed.emit() + +## Clicking slot `i` while holding `cursor` (a stack or null). Returns the new +## cursor stack. Left click: pick up / put down / merge / swap. Right click: +## pick up half, or put down one. +func click(i: int, cursor, right: bool): + var s = slots[i] + var result = cursor + if cursor == null: + if s == null: + return null + if right and s.count > 1: + var half := int(ceil(s.count / 2.0)) + s.count -= half + result = {"id": s.id, "count": half} + else: + result = s + slots[i] = null + elif s == null: + if right: + slots[i] = {"id": cursor.id, "count": 1} + result = _norm({"id": cursor.id, "count": cursor.count - 1}) + else: + slots[i] = cursor + result = null + elif s.id == cursor.id: + var cap := _stack_max(s.id) + var move: int = 1 if right else cursor.count + move = mini(move, cap - s.count) + s.count += move + result = _norm({"id": cursor.id, "count": cursor.count - move}) + else: + slots[i] = cursor + result = s + changed.emit() + return result + +## Serialisable snapshot (for saves and, later, network replication). +func to_data() -> Array: + return slots.duplicate(true) + +func load_data(data: Array) -> void: + slots.resize(data.size()) + for i in range(data.size()): + slots[i] = _norm(data[i]) if data[i] != null else null + changed.emit() diff --git a/scripts/core/inventory.gd.uid b/scripts/core/inventory.gd.uid new file mode 100644 index 0000000..8c0968e --- /dev/null +++ b/scripts/core/inventory.gd.uid @@ -0,0 +1 @@ +uid://b6q13eerjpee2 diff --git a/scripts/core/item_db.gd b/scripts/core/item_db.gd new file mode 100644 index 0000000..c8ac6f3 --- /dev/null +++ b/scripts/core/item_db.gd @@ -0,0 +1,135 @@ +extends Node +## Autoload "ItemDB": item and recipe definitions loaded from data/*.json, plus +## procedurally rendered inventory icons. +## +## Definitions use the same shape as the addon SDK's define_item / +## define_recipe (docs/ADDON_SDK.md), so addon content will later be +## registered through `define_item()` / `define_recipe()` like the base game. + +signal icons_ready + +const ItemMeshes = preload("res://scripts/items/item_meshes.gd") +const ICON_SIZE := 96 + +var items: Dictionary = {} # id -> definition Dictionary +var recipes: Dictionary = {} # id -> recipe Dictionary (insertion order kept) +var icons: Dictionary = {} # id -> Texture2D +var _item_mat: StandardMaterial3D + +func _ready() -> void: + _load("res://data/items.json", "items", define_item) + _load("res://data/recipes.json", "recipes", define_recipe) + if DisplayServer.get_name() != "headless": + _render_icons.call_deferred() + +func _load(path: String, key: String, define: Callable) -> void: + var text := FileAccess.get_file_as_string(path) + var data = JSON.parse_string(text) + if typeof(data) != TYPE_DICTIONARY or not data.has(key): + push_error("ItemDB: could not parse %s" % path) + return + for d in data[key]: + define.call(d) + +func define_item(d: Dictionary) -> void: + for f in ["id", "name", "category", "stack"]: + if not d.has(f): + push_error("ItemDB: item missing '%s': %s" % [f, d]) + return + d.stack = int(d.stack) + items[d.id] = d + +func define_recipe(r: Dictionary) -> void: + if not items.has(r.get("output", "")): + push_error("ItemDB: recipe '%s' outputs unknown item" % r.get("id", "?")) + return + for id in r.inputs: + if not items.has(id): + push_error("ItemDB: recipe '%s' uses unknown item '%s'" % [r.id, id]) + return + r.count = int(r.get("count", 1)) + r.station = String(r.get("station", "")) + recipes[r.id] = r + +func get_item(id: String) -> Dictionary: + return items.get(id, {}) + +func item_name(id: String) -> String: + return items.get(id, {}).get("name", id) + +func stack_size(id: String) -> int: + return int(items.get(id, {}).get("stack", 1)) + +func mesh_for(id: String) -> ArrayMesh: + return ItemMeshes.build(items.get(id, {}).get("mesh", id)) + +## Shared material for item meshes (vertex colours, authored as sRGB). +func item_material() -> StandardMaterial3D: + if _item_mat == null: + _item_mat = StandardMaterial3D.new() + _item_mat.vertex_color_use_as_albedo = true + _item_mat.vertex_color_is_srgb = true + _item_mat.roughness = 0.85 + return _item_mat + +func icon(id: String) -> Texture2D: + return icons.get(id, null) + +# --- icon renderer ---------------------------------------------------------- + +## Renders every item mesh once in an offscreen viewport and keeps the images. +func _render_icons() -> void: + var vp := SubViewport.new() + vp.size = Vector2i(ICON_SIZE, ICON_SIZE) + vp.transparent_bg = true + vp.own_world_3d = true + vp.msaa_3d = Viewport.MSAA_4X + vp.render_target_update_mode = SubViewport.UPDATE_ALWAYS + add_child(vp) + + var env := Environment.new() + env.background_mode = Environment.BG_CLEAR_COLOR + env.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR + env.ambient_light_color = Color(0.75, 0.78, 0.85) + env.ambient_light_energy = 0.7 + env.tonemap_mode = Environment.TONE_MAPPER_FILMIC + var we := WorldEnvironment.new() + we.environment = env + vp.add_child(we) + + var key := DirectionalLight3D.new() + key.light_energy = 1.6 + key.rotation = Vector3(deg_to_rad(-50), deg_to_rad(-35), 0) + vp.add_child(key) + var fill := DirectionalLight3D.new() + fill.light_energy = 0.5 + fill.light_color = Color(0.7, 0.8, 1.0) + fill.rotation = Vector3(deg_to_rad(-20), deg_to_rad(140), 0) + vp.add_child(fill) + + var cam := Camera3D.new() + cam.projection = Camera3D.PROJECTION_ORTHOGONAL + vp.add_child(cam) + var mi := MeshInstance3D.new() + mi.material_override = item_material() + vp.add_child(mi) + + for id in items: + var mesh := mesh_for(id) + mi.mesh = mesh + # Tools are long and thin: tilt them diagonally across the icon. + var cat: String = items[id].category + mi.rotation = Vector3(0, 0, deg_to_rad(-45)) if cat in ["tool", "weapon"] else Vector3(0, deg_to_rad(30), 0) + var aabb := mi.transform * mesh.get_aabb() + var c := aabb.get_center() + var extent := maxf(aabb.size.length(), 0.05) + cam.size = extent * 0.95 + cam.position = c + Vector3(0, 0.55, 1).normalized() * extent * 2.0 + cam.look_at(c, Vector3.UP) + cam.near = 0.01 + cam.far = extent * 5.0 + await RenderingServer.frame_post_draw + await RenderingServer.frame_post_draw + icons[id] = ImageTexture.create_from_image(vp.get_texture().get_image()) + vp.queue_free() + icons_ready.emit() diff --git a/scripts/core/item_db.gd.uid b/scripts/core/item_db.gd.uid new file mode 100644 index 0000000..cad7bdd --- /dev/null +++ b/scripts/core/item_db.gd.uid @@ -0,0 +1 @@ +uid://bv6di5sbrx4fj diff --git a/scripts/core/saves.gd b/scripts/core/saves.gd new file mode 100644 index 0000000..1c91423 --- /dev/null +++ b/scripts/core/saves.gd @@ -0,0 +1,110 @@ +extends Node +## Autoload "Saves": world slots on disk. +## +## Each world is a folder under `root_dir` (user://worlds//) holding: +## meta.json name, seed, created, last played, play time (read by the title screen) +## world.json the game state, written by world.gd's collect_save() +## +## The world itself is regenerated from its seed, so a save only stores what +## the player changed: their own state, placed pieces, chest contents, harvested +## resources and collected pickups. That keeps saves small, and it's the same +## data a dedicated server will persist in M2. +## +## Writes are atomic (temp file + rename) and the previous world.json is kept +## as world.json.bak, so a crash mid-save can't destroy a world. + +const VERSION := 1 + +var root_dir := "user://worlds" +var current_id := "" ## world being played; "" = unsaved session (title screen, tests) + +func has_world() -> bool: + return not current_id.is_empty() + +func world_seed() -> int: + return int(read_meta(current_id).get("seed", 1337)) + +func _dir(id: String) -> String: + return root_dir.path_join(id) + +func list_worlds() -> Array: + var out: Array = [] + var d := DirAccess.open(root_dir) + if d == null: + return out + for id in d.get_directories(): + var m := read_meta(id) + if not m.is_empty(): + m["id"] = id + out.append(m) + out.sort_custom(func(a, b): return float(a.get("last_played", 0)) > float(b.get("last_played", 0))) + return out + +func create_world(world_name: String, seed: int) -> String: + DirAccess.make_dir_recursive_absolute(root_dir) + var base := world_name.to_lower().replace(" ", "_").validate_filename() + if base.is_empty(): + base = "world" + var id := base + var n := 2 + while DirAccess.dir_exists_absolute(_dir(id)): + id = "%s_%d" % [base, n] + n += 1 + DirAccess.make_dir_recursive_absolute(_dir(id)) + var now := Time.get_unix_time_from_system() + _write_json(_dir(id).path_join("meta.json"), { + "name": world_name, "seed": seed, "created": now, "last_played": now, + "play_time": 0.0, "version": VERSION, + }) + return id + +func delete_world(id: String) -> void: + var dir := _dir(id) + var d := DirAccess.open(dir) + if d == null: + return + for f in d.get_files(): + d.remove(f) + DirAccess.remove_absolute(dir) + if current_id == id: + current_id = "" + +func read_meta(id: String) -> Dictionary: + return _read_json(_dir(id).path_join("meta.json")) + +func load_state(id: String) -> Dictionary: + var s := _read_json(_dir(id).path_join("world.json")) + if s.is_empty() and FileAccess.file_exists(_dir(id).path_join("world.json.bak")): + push_warning("Saves: world.json unreadable, using backup") + s = _read_json(_dir(id).path_join("world.json.bak")) + return s + +## Writes the state for `id` and updates its meta. Returns true on success. +func save_state(id: String, state: Dictionary, session_seconds: float) -> bool: + var path := _dir(id).path_join("world.json") + if FileAccess.file_exists(path): + DirAccess.copy_absolute(path, path + ".bak") + state["version"] = VERSION + if not _write_json(path, state): + return false + var m := read_meta(id) + m["last_played"] = Time.get_unix_time_from_system() + m["play_time"] = float(m.get("play_time", 0.0)) + session_seconds + _write_json(_dir(id).path_join("meta.json"), m) + return true + +func _write_json(path: String, data: Dictionary) -> bool: + var tmp := path + ".tmp" + var f := FileAccess.open(tmp, FileAccess.WRITE) + if f == null: + push_error("Saves: can't write %s (%s)" % [tmp, error_string(FileAccess.get_open_error())]) + return false + f.store_string(JSON.stringify(data, "\t")) + f.close() + return DirAccess.rename_absolute(tmp, path) == OK + +func _read_json(path: String) -> Dictionary: + if not FileAccess.file_exists(path): + return {} + var data = JSON.parse_string(FileAccess.get_file_as_string(path)) + return data if typeof(data) == TYPE_DICTIONARY else {} diff --git a/scripts/core/saves.gd.uid b/scripts/core/saves.gd.uid new file mode 100644 index 0000000..4ee8493 --- /dev/null +++ b/scripts/core/saves.gd.uid @@ -0,0 +1 @@ +uid://c5tln66oif2hb diff --git a/scripts/items/item_meshes.gd b/scripts/items/item_meshes.gd new file mode 100644 index 0000000..3f0b98a --- /dev/null +++ b/scripts/items/item_meshes.gd @@ -0,0 +1,160 @@ +extends RefCounted +## Procedural meshes for items and placeable pieces, keyed by the "mesh" field +## in data/items.json. The same mesh is used for the inventory icon, the tool +## held in your hand and the object placed in the world. +## +## Convention: origin at the grip (tools) or at ground level, centred +## (placeables). Units are metres. Built from the low-poly helpers in +## proc_mesh.gd, so everything is flat-shaded with vertex colours. + +const P = preload("res://scripts/world/proc_mesh.gd") + +const HANDLE := Color(0.55, 0.40, 0.24) +const STONE := Color(0.55, 0.54, 0.52) +const FLINT := Color(0.22, 0.23, 0.27) +const PLANK := Color(0.62, 0.45, 0.27) +const PLANK_DARK := Color(0.42, 0.29, 0.17) +const IRON := Color(0.30, 0.30, 0.32) +const ASH := Color(0.18, 0.17, 0.16) +const BINDING := Color(0.75, 0.68, 0.48) + +static var _cache := {} + +static func build(key: String) -> ArrayMesh: + if _cache.has(key): + return _cache[key] + var m: ArrayMesh + match key: + "stick": m = _stick() + "stone": m = _pebble(11, STONE, Vector3(0.14, 0.10, 0.12)) + "flint": m = _pebble(23, FLINT, Vector3(0.12, 0.07, 0.15), 0.5) + "wood": m = _wood() + "stone_axe": m = _axe() + "stone_pickaxe": m = _pickaxe() + "club": m = _club() + "campfire": m = _campfire() + "workbench": m = _workbench() + "chest": m = _chest() + _: + push_warning("item_meshes: no mesh for '%s', using a stone" % key) + m = _pebble(1, Color.MAGENTA, Vector3.ONE * 0.15) + _cache[key] = m + return m + +static func _rng(seed: int) -> RandomNumberGenerator: + var r := RandomNumberGenerator.new() + r.seed = seed + return r + +static func _box(st: SurfaceTool, center: Vector3, size: Vector3, col: Color, basis: Basis = Basis()) -> void: + var h := size * 0.5 + var c := [ + Vector3(-h.x, -h.y, -h.z), Vector3(h.x, -h.y, -h.z), Vector3(h.x, h.y, -h.z), Vector3(-h.x, h.y, -h.z), + Vector3(-h.x, -h.y, h.z), Vector3(h.x, -h.y, h.z), Vector3(h.x, h.y, h.z), Vector3(-h.x, h.y, h.z), + ] + for i in range(8): + c[i] = center + basis * c[i] + var faces := [[0, 1, 2, 3], [5, 4, 7, 6], [4, 0, 3, 7], [1, 5, 6, 2], [3, 2, 6, 7], [4, 5, 1, 0]] + for f in faces: + var a: Vector3 = c[f[0]] + var b: Vector3 = c[f[1]] + var cc: Vector3 = c[f[2]] + var d: Vector3 = c[f[3]] + var out := (a + b + cc + d) * 0.25 - center + P._tri(st, a, b, cc, out, col) + P._tri(st, a, cc, d, out, col) + +static func _pebble(seed: int, col: Color, radii: Vector3, sharp: float = 0.3) -> ArrayMesh: + var n := FastNoiseLite.new() + n.seed = seed + n.frequency = 1.4 + var st := P._begin() + P._blob(st, Vector3(0, radii.y * 0.6, 0), radii, n, sharp, col, _rng(seed), 1) + return P._commit(st) + +static func _stick() -> ArrayMesh: + var st := P._begin() + var r := _rng(5) + P._cylinder(st, Vector3(-0.35, 0.03, 0), Vector3(0.35, 0.03, 0.02), 0.028, 0.02, 5, HANDLE, r, -1.0, PLANK) + P._cylinder(st, Vector3(0.05, 0.03, 0.0), Vector3(0.2, 0.05, 0.1), 0.012, 0.006, 4, HANDLE, r) + return P._commit(st) + +static func _wood() -> ArrayMesh: + var st := P._begin() + var r := _rng(9) + P._cylinder(st, Vector3(-0.3, 0.1, -0.06), Vector3(0.3, 0.1, -0.06), 0.1, 0.1, 6, P.BARK, r, -1.0, PLANK) + P._cylinder(st, Vector3(-0.28, 0.1, 0.13), Vector3(0.3, 0.1, 0.13), 0.09, 0.09, 6, P.BARK, r, -1.0, PLANK) + P._cylinder(st, Vector3(-0.29, 0.27, 0.04), Vector3(0.29, 0.27, 0.04), 0.09, 0.09, 6, P.BARK, r, -1.0, PLANK) + return P._commit(st) + +## Handle along +Y from the grip at the origin. +static func _handle(st: SurfaceTool, length: float, r: RandomNumberGenerator) -> void: + P._cylinder(st, Vector3(0, -0.1, 0), Vector3(0, length, 0), 0.022, 0.02, 6, HANDLE, r, -1.0, HANDLE) + +static func _axe() -> ArrayMesh: + var st := P._begin() + var r := _rng(13) + _handle(st, 0.55, r) + _box(st, Vector3(0, 0.47, 0), Vector3(0.06, 0.07, 0.07), BINDING) + var n := FastNoiseLite.new() + n.seed = 3 + n.frequency = 2.0 + # Flint head: a flattened wedge pointing along +X. + P._blob(st, Vector3(0.075, 0.47, 0), Vector3(0.085, 0.055, 0.025), n, 0.2, FLINT, r, 1) + return P._commit(st) + +static func _pickaxe() -> ArrayMesh: + var st := P._begin() + var r := _rng(17) + _handle(st, 0.6, r) + _box(st, Vector3(0, 0.55, 0), Vector3(0.06, 0.07, 0.07), BINDING) + P._cylinder(st, Vector3(-0.02, 0.56, 0), Vector3(0.22, 0.5, 0), 0.04, 0.008, 5, STONE, r, -1.0, STONE) + P._cylinder(st, Vector3(0.02, 0.56, 0), Vector3(-0.2, 0.51, 0), 0.04, 0.008, 5, STONE, r, -1.0, STONE) + return P._commit(st) + +static func _club() -> ArrayMesh: + var st := P._begin() + var r := _rng(21) + P._cylinder(st, Vector3(0, -0.1, 0), Vector3(0, 0.62, 0), 0.025, 0.07, 7, PLANK_DARK, r, -1.0, PLANK) + return P._commit(st) + +static func _campfire() -> ArrayMesh: + var st := P._begin() + var r := _rng(31) + var n := FastNoiseLite.new() + n.seed = 31 + n.frequency = 1.6 + for i in range(9): + var a := TAU * i / 9.0 + var c := Vector3(cos(a) * 0.55, 0.06, sin(a) * 0.55) + P._blob(st, c, Vector3(0.15, 0.11, 0.13) * r.randf_range(0.85, 1.15), n, 0.3, P._mul(STONE, r.randf_range(0.8, 1.05)), r, 1) + # Teepee of logs leaning into the middle. + for i in range(5): + var a := TAU * i / 5.0 + 0.3 + var base := Vector3(cos(a) * 0.36, 0.0, sin(a) * 0.36) + P._cylinder(st, base, Vector3(0, 0.55, 0), 0.05, 0.035, 5, P.BARK, r, -1.0, PLANK) + P._blob(st, Vector3(0, 0.03, 0), Vector3(0.3, 0.04, 0.3), n, 0.2, ASH, r, 1) + return P._commit(st) + +static func _workbench() -> ArrayMesh: + var st := P._begin() + _box(st, Vector3(0, 0.82, 0), Vector3(1.8, 0.1, 0.9), PLANK) + _box(st, Vector3(0, 0.79, 0.47), Vector3(1.82, 0.06, 0.04), PLANK_DARK) + for sx in [-0.8, 0.8]: + for sz in [-0.36, 0.36]: + _box(st, Vector3(sx, 0.38, sz), Vector3(0.1, 0.8, 0.1), PLANK_DARK) + _box(st, Vector3(0, 0.25, 0), Vector3(1.6, 0.06, 0.7), PLANK_DARK) + # A few tools lying on top. + _box(st, Vector3(0.45, 0.89, 0.1), Vector3(0.35, 0.03, 0.05), HANDLE, Basis(Vector3.UP, 0.4)) + _box(st, Vector3(0.6, 0.9, 0.18), Vector3(0.09, 0.06, 0.09), FLINT) + _box(st, Vector3(-0.5, 0.92, -0.15), Vector3(0.4, 0.1, 0.22), P.BARK) + return P._commit(st) + +static func _chest() -> ArrayMesh: + var st := P._begin() + _box(st, Vector3(0, 0.3, 0), Vector3(1.1, 0.6, 0.7), PLANK) + _box(st, Vector3(0, 0.66, 0), Vector3(1.14, 0.14, 0.74), PLANK_DARK) + for sx in [-0.4, 0.4]: + _box(st, Vector3(sx, 0.36, 0), Vector3(0.07, 0.74, 0.76), IRON) + _box(st, Vector3(0, 0.55, 0.37), Vector3(0.12, 0.14, 0.03), IRON) + return P._commit(st) diff --git a/scripts/items/item_meshes.gd.uid b/scripts/items/item_meshes.gd.uid new file mode 100644 index 0000000..5635aae --- /dev/null +++ b/scripts/items/item_meshes.gd.uid @@ -0,0 +1 @@ +uid://byqcvpim33bkk diff --git a/scripts/player/hands.gd b/scripts/player/hands.gd new file mode 100644 index 0000000..78f3886 --- /dev/null +++ b/scripts/player/hands.gd @@ -0,0 +1,179 @@ +extends Node3D +## The player's hands: what you're looking at, what you're holding, and what +## clicking does. Lives under the camera. +## +## - Looking at something interactable shows its prompt (E to interact). +## - LMB with a tool swings it; the hit lands mid-swing via Actions.harvest_hit. +## - Holding a placeable shows a ghost preview: green = valid, red = blocked. +## R rotates, LMB places (Actions.place). +## - Keys 1-8 / mouse wheel pick the hotbar slot. + +signal prompt_changed(text: String) + +const REACH := 3.2 +const PLACE_REACH := 6.0 +const SWING_TIME := 0.5 + +var player: CharacterBody3D +var _ray: RayCast3D +var _view: MeshInstance3D ## held item, first-person +var _view_rest := Transform3D() +var _swing_cd := 0.0 +var _ghost: MeshInstance3D +var _ghost_ok_mat: StandardMaterial3D +var _ghost_bad_mat: StandardMaterial3D +var _ghost_yaw := 0.0 +var _place_xf := Transform3D() +var _place_ok := false +var _prompt := "" +var _held_shown := "~" + +func _ready() -> void: + _ray = RayCast3D.new() + _ray.target_position = Vector3(0, 0, -REACH) + _ray.collision_mask = 1 | 2 + _ray.collide_with_areas = true + _ray.add_exception(player) + add_child(_ray) + + _view = MeshInstance3D.new() + _view.material_override = ItemDB.item_material() + _view.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF + _view_rest = Transform3D(Basis.from_euler(Vector3(deg_to_rad(-25), deg_to_rad(-10), deg_to_rad(-20))), Vector3(0.36, -0.42, -0.62)) + _view.transform = _view_rest + add_child(_view) + + _ghost_ok_mat = _ghost_material(Color(0.3, 1.0, 0.4, 0.45)) + _ghost_bad_mat = _ghost_material(Color(1.0, 0.25, 0.2, 0.45)) + _ghost = MeshInstance3D.new() + _ghost.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF + _ghost.visible = false + _ghost.top_level = true + add_child(_ghost) + + player.inventory.changed.connect(_refresh_held) + player.selected_changed.connect(_refresh_held) + _refresh_held() + +static func _ghost_material(c: Color) -> StandardMaterial3D: + var m := StandardMaterial3D.new() + m.albedo_color = c + m.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA + m.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED + m.no_depth_test = false + return m + +func _held_def() -> Dictionary: + return ItemDB.get_item(player.held_item_id()) + +func _refresh_held() -> void: + var id: String = player.held_item_id() + if id == _held_shown: + return + _held_shown = id + var def := _held_def() + _view.visible = not id.is_empty() and not def.has("place") + if _view.visible: + _view.mesh = ItemDB.mesh_for(id) + var s := 0.85 if def.category in ["tool", "weapon"] else 0.6 + _view.scale = Vector3.ONE * s + if def.has("place"): + _ghost.mesh = ItemDB.mesh_for(id) + _ghost_yaw = 0.0 + else: + _ghost.visible = false + +func _set_prompt(t: String) -> void: + if t != _prompt: + _prompt = t + prompt_changed.emit(t) + +func _target() -> Object: + if not _ray.is_colliding(): + return null + var c := _ray.get_collider() + if c == null or not c.has_method("interact_prompt"): + return null + if c.get("depleted") == true: + return null + return c + +func _physics_process(delta: float) -> void: + _swing_cd = maxf(_swing_cd - delta, 0.0) + if player.ui_open: + _ghost.visible = false + _set_prompt("") + return + var def := _held_def() + if def.has("place"): + _update_ghost(def) + _set_prompt("[LMB] Place %s [R] Rotate" % def.name) + return + _ghost.visible = false + var t := _target() + _set_prompt(t.interact_prompt(player) if t != null else "") + +func _update_ghost(def: Dictionary) -> void: + var cam := get_parent() as Camera3D + var from := cam.global_position + var to := from - cam.global_basis.z * PLACE_REACH + var q := PhysicsRayQueryParameters3D.create(from, to, 1, [player.get_rid()]) + var hit := cam.get_world_3d().direct_space_state.intersect_ray(q) + if hit.is_empty(): + _ghost.visible = false + _place_ok = false + return + var yaw := player.rotation.y + _ghost_yaw # piece's front (+Z) faces the player + _place_xf = Transform3D(Basis(Vector3.UP, yaw), hit.position) + _place_ok = player.get_parent().placement_valid(def, _place_xf) + _ghost.global_transform = _place_xf + _ghost.material_override = _ghost_ok_mat if _place_ok else _ghost_bad_mat + _ghost.visible = true + +func _unhandled_input(event: InputEvent) -> void: + if player.ui_open: + return + for i in range(8): + if event.is_action_pressed("hotbar_%d" % (i + 1)): + player.select(i) + return + if event is InputEventMouseButton and event.pressed and Input.mouse_mode == Input.MOUSE_MODE_CAPTURED: + if event.button_index == MOUSE_BUTTON_WHEEL_UP: + player.select(player.selected - 1) + elif event.button_index == MOUSE_BUTTON_WHEEL_DOWN: + player.select(player.selected + 1) + if event.is_action_pressed("rotate_piece"): + _ghost_yaw += deg_to_rad(22.5) + elif event.is_action_pressed("interact"): + var t := _target() + if t != null and t.has_method("interact"): + t.interact(player) + elif event.is_action_pressed("attack") and Input.mouse_mode == Input.MOUSE_MODE_CAPTURED: + if _held_def().has("place"): + if _place_ok: + Actions.place(player, player.selected, _place_xf, player.get_parent()) + else: + Actions.notify.emit("Can't place that here") + else: + swing() + +## Swings the held item (or fist). The hit is applied at the peak of the swing. +func swing() -> void: + if _swing_cd > 0.0: + return + _swing_cd = SWING_TIME + var tw := create_tween() + var wind := Transform3D(_view_rest.basis.rotated(Vector3.RIGHT, deg_to_rad(35)), _view_rest.origin + Vector3(0, 0.08, 0.05)) + var strike := Transform3D(_view_rest.basis.rotated(Vector3.RIGHT, deg_to_rad(-70)), _view_rest.origin + Vector3(-0.05, -0.05, -0.15)) + tw.tween_property(_view, "transform", wind, 0.1).set_ease(Tween.EASE_OUT) + tw.tween_property(_view, "transform", strike, 0.1).set_ease(Tween.EASE_IN) + tw.tween_callback(_apply_hit) + tw.tween_property(_view, "transform", _view_rest, 0.25).set_trans(Tween.TRANS_SINE) + +func _apply_hit() -> void: + _ray.force_raycast_update() + if not _ray.is_colliding(): + return + var c := _ray.get_collider() + if c != null and c.is_in_group("harvestable"): + Actions.harvest_hit(player, c, _ray.get_collision_point()) diff --git a/scripts/player/hands.gd.uid b/scripts/player/hands.gd.uid new file mode 100644 index 0000000..39658e7 --- /dev/null +++ b/scripts/player/hands.gd.uid @@ -0,0 +1 @@ +uid://dx85p3jnm5f65 diff --git a/scripts/player/player.gd b/scripts/player/player.gd index 5c0ed07..5ff7199 100644 --- a/scripts/player/player.gd +++ b/scripts/player/player.gd @@ -18,19 +18,46 @@ const FLOAT_DEPTH := 1.3 @onready var camera: Camera3D = $Camera3D +signal selected_changed +signal inventory_requested(container: Node) ## the HUD opens the inventory screen + +const HOTBAR := 8 +var inventory := Inventory.new(40) ## slots 0-7 are the hotbar +var selected := 0 +var ui_open := false ## set by the inventory screen; freezes look/move +var hands: Node3D + var _gravity: float = ProjectSettings.get_setting("physics/3d/default_gravity", 9.8) func _ready() -> void: - Input.mouse_mode = Input.MOUSE_MODE_CAPTURED + if not get_parent().get("menu_mode"): # title-screen backdrop: leave the cursor alone + Input.mouse_mode = Input.MOUSE_MODE_CAPTURED + hands = preload("res://scripts/player/hands.gd").new() + hands.name = "Hands" + hands.player = self + camera.add_child(hands) + +func held_item_id() -> String: + var s = inventory.get_slot(selected) + return s.id if s != null else "" + +func select(i: int) -> void: + selected = wrapi(i, 0, HOTBAR) + selected_changed.emit() + +## Called by workbenches and chests (container = null for plain crafting). +func open_inventory(container: Node) -> void: + inventory_requested.emit(container) func _unhandled_input(event: InputEvent) -> void: + if ui_open: + return 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: + # Recapture after the window lost focus (ESC now opens the pause menu). Input.mouse_mode = Input.MOUSE_MODE_CAPTURED func _physics_process(delta: float) -> void: @@ -43,13 +70,14 @@ func _physics_process(delta: float) -> void: elif not is_on_floor(): velocity.y -= _gravity * delta - if Input.is_action_just_pressed("jump") and is_on_floor(): + var jump := Input.is_action_just_pressed("jump") and not ui_open + if jump and is_on_floor(): velocity.y = jump_velocity - elif Input.is_action_just_pressed("jump") and global_position.y > float_y - 0.4 and global_position.y < float_y + 0.3: + elif jump and global_position.y > float_y - 0.4 and global_position.y < float_y + 0.3: # Swim-jump: kick up out of the water to climb onto a bank. velocity.y = jump_velocity - var input_dir := Input.get_vector("move_left", "move_right", "move_forward", "move_back") + var input_dir := Vector2.ZERO if ui_open else 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 diff --git a/scripts/ui/hud.gd b/scripts/ui/hud.gd new file mode 100644 index 0000000..e9c816a --- /dev/null +++ b/scripts/ui/hud.gd @@ -0,0 +1,139 @@ +extends CanvasLayer +## In-game HUD: crosshair, interaction prompt, hotbar, toasts, and the +## inventory screen. Rendering only: it reads player state and forwards +## clicks to Actions. + +const UITheme = preload("res://scripts/ui/theme.gd") +const SlotScript = preload("res://scripts/ui/slot.gd") +const InventoryScreen = preload("res://scripts/ui/inventory_screen.gd") +const PauseMenu = preload("res://scripts/ui/pause_menu.gd") + +var player: CharacterBody3D +var _prompt: Label +var _held_name: Label +var _hotbar: Array = [] +var _toasts: VBoxContainer +var _screen: Control +var _menu: Control + +func bind(p: CharacterBody3D) -> void: + player = p + layer = 5 + var root := Control.new() + root.set_anchors_preset(Control.PRESET_FULL_RECT) + root.mouse_filter = Control.MOUSE_FILTER_IGNORE + add_child(root) + + # Crosshair. + var dot := ColorRect.new() + dot.color = Color(1, 1, 1, 0.8) + dot.size = Vector2(4, 4) + dot.set_anchors_preset(Control.PRESET_CENTER) + dot.position -= Vector2(2, 2) + dot.mouse_filter = Control.MOUSE_FILTER_IGNORE + root.add_child(dot) + + _prompt = UITheme.label("", 18) + _prompt.set_anchors_and_offsets_preset(Control.PRESET_CENTER) + _prompt.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + _prompt.grow_horizontal = Control.GROW_DIRECTION_BOTH + _prompt.offset_top = 28 + _prompt.offset_bottom = 56 + root.add_child(_prompt) + + # Hotbar. + var bar := HBoxContainer.new() + bar.add_theme_constant_override("separation", 6) + bar.set_anchors_and_offsets_preset(Control.PRESET_CENTER_BOTTOM) + bar.grow_horizontal = Control.GROW_DIRECTION_BOTH + bar.grow_vertical = Control.GROW_DIRECTION_BEGIN + bar.offset_bottom = -18 + bar.mouse_filter = Control.MOUSE_FILTER_IGNORE + for i in range(8): + var s: Panel = SlotScript.new(i, 64, str(i + 1)) + s.mouse_filter = Control.MOUSE_FILTER_IGNORE + bar.add_child(s) + _hotbar.append(s) + root.add_child(bar) + + _held_name = UITheme.label("", 15, UITheme.TEXT_DIM) + _held_name.set_anchors_and_offsets_preset(Control.PRESET_CENTER_BOTTOM) + _held_name.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + _held_name.grow_horizontal = Control.GROW_DIRECTION_BOTH + _held_name.offset_top = -112 + _held_name.offset_bottom = -90 + root.add_child(_held_name) + + _toasts = VBoxContainer.new() + _toasts.set_anchors_and_offsets_preset(Control.PRESET_BOTTOM_RIGHT) + _toasts.grow_horizontal = Control.GROW_DIRECTION_BEGIN + _toasts.grow_vertical = Control.GROW_DIRECTION_BEGIN + _toasts.offset_right = -24 + _toasts.offset_bottom = -110 + _toasts.alignment = BoxContainer.ALIGNMENT_END + _toasts.mouse_filter = Control.MOUSE_FILTER_IGNORE + root.add_child(_toasts) + + _screen = InventoryScreen.new() + root.add_child(_screen) + _screen.bind(player) + + _menu = PauseMenu.new() + root.add_child(_menu) + _menu.bind(player) + process_mode = Node.PROCESS_MODE_ALWAYS # so ESC works while paused + _screen.process_mode = Node.PROCESS_MODE_PAUSABLE + + player.inventory.changed.connect(_refresh) + player.selected_changed.connect(_refresh) + player.inventory_requested.connect(_on_inventory_requested) + player.hands.prompt_changed.connect(func(t): _prompt.text = t) + Actions.notify.connect(toast) + ItemDB.icons_ready.connect(_refresh) + _refresh() + +func _on_inventory_requested(container: Node) -> void: + if not _screen.is_open(): + _screen.open(container) + +## ESC: close the inventory if it's open, otherwise toggle the pause menu. +func _input(event: InputEvent) -> void: + if event is InputEventKey and event.pressed and not event.echo and event.keycode == KEY_ESCAPE: + if _screen.is_open(): + _screen.close() + elif _menu.is_open(): + _menu.back() + else: + _menu.open() + get_viewport().set_input_as_handled() + +func _unhandled_input(event: InputEvent) -> void: + if _menu.is_open(): + return + if event.is_action_pressed("inventory") and not _screen.is_open(): + _screen.open(null) + get_viewport().set_input_as_handled() + +func _refresh() -> void: + for i in range(_hotbar.size()): + _hotbar[i].set_stack(player.inventory.get_slot(i)) + _hotbar[i].set_selected(i == player.selected) + var id: String = player.held_item_id() + _held_name.text = ItemDB.item_name(id) if not id.is_empty() else "" + +func toast(text: String) -> void: + # Merge repeated "+N Item" toasts so chopping doesn't spam the corner. + for c in _toasts.get_children(): + if c.get_meta("base", "") == text: + c.modulate.a = 1.0 + return + var l := UITheme.label(text, 17) + l.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT + l.set_meta("base", text) + _toasts.add_child(l) + while _toasts.get_child_count() > 6: + _toasts.get_child(0).free() + var tw := l.create_tween() + tw.tween_interval(2.2) + tw.tween_property(l, "modulate:a", 0.0, 0.8) + tw.tween_callback(l.queue_free) diff --git a/scripts/ui/hud.gd.uid b/scripts/ui/hud.gd.uid new file mode 100644 index 0000000..cdb8aed --- /dev/null +++ b/scripts/ui/hud.gd.uid @@ -0,0 +1 @@ +uid://c4dm11w8jqsiv diff --git a/scripts/ui/inventory_screen.gd b/scripts/ui/inventory_screen.gd new file mode 100644 index 0000000..6c776f0 --- /dev/null +++ b/scripts/ui/inventory_screen.gd @@ -0,0 +1,263 @@ +extends Control +## Inventory screen (Tab): your 40 slots, the crafting list, and an open +## chest beside them. +## +## Click picks up or puts down a stack, right-click splits it or drops one, +## shift-click quick-moves between you and the chest. Crafting shows every +## recipe; the ones you can't make yet are dimmed, with the missing inputs and +## station in red. All changes go through Actions. + +const UITheme = preload("res://scripts/ui/theme.gd") +const SlotScript = preload("res://scripts/ui/slot.gd") +const COLS := 8 + +var player: CharacterBody3D +var container: Node = null ## open chest, or null + +var _player_slots: Array = [] +var _chest_slots: Array = [] +var _chest_panel: PanelContainer +var _chest_grid: GridContainer +var _chest_title: Label +var _recipe_list: VBoxContainer +var _station_label: Label +var _cursor = null ## stack held on the mouse +var _cursor_view: Control +var _cursor_icon: TextureRect +var _cursor_count: Label + +func bind(p: CharacterBody3D) -> void: + player = p + set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + mouse_filter = Control.MOUSE_FILTER_STOP + visible = false + + var dim := ColorRect.new() + dim.color = Color(0, 0, 0, 0.35) + dim.set_anchors_preset(Control.PRESET_FULL_RECT) + dim.mouse_filter = Control.MOUSE_FILTER_IGNORE + add_child(dim) + + var center := CenterContainer.new() + center.set_anchors_preset(Control.PRESET_FULL_RECT) + center.mouse_filter = Control.MOUSE_FILTER_IGNORE + add_child(center) + var row := HBoxContainer.new() + row.add_theme_constant_override("separation", 16) + center.add_child(row) + + # Chest panel (only while a chest is open). + _chest_panel = UITheme.panel() + var cv := VBoxContainer.new() + _chest_title = UITheme.label("Chest", 20) + cv.add_child(_chest_title) + _chest_grid = GridContainer.new() + _chest_grid.columns = 4 + cv.add_child(_chest_grid) + _chest_panel.add_child(cv) + row.add_child(_chest_panel) + + # Player inventory. + var pp := UITheme.panel() + var pv := VBoxContainer.new() + pv.add_theme_constant_override("separation", 8) + pv.add_child(UITheme.label("Inventory", 20)) + var grid := GridContainer.new() + grid.columns = COLS + for i in range(player.inventory.size()): + var s: Panel = SlotScript.new(i, 60, str(i + 1) if i < 8 else "") + s.clicked.connect(_on_player_slot) + grid.add_child(s) + _player_slots.append(s) + if i == 7: + pass + pv.add_child(grid) + pv.add_child(UITheme.label("Row 1 is your hotbar. Shift-click moves stacks to/from a chest.", 13, UITheme.TEXT_DIM)) + pp.add_child(pv) + row.add_child(pp) + + # Crafting. + var cp := UITheme.panel() + cp.custom_minimum_size = Vector2(340, 0) + var crv := VBoxContainer.new() + crv.add_theme_constant_override("separation", 6) + crv.add_child(UITheme.label("Crafting", 20)) + _station_label = UITheme.label("", 13, UITheme.TEXT_DIM) + crv.add_child(_station_label) + var scroll := ScrollContainer.new() + scroll.custom_minimum_size = Vector2(320, 380) + scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED + _recipe_list = VBoxContainer.new() + _recipe_list.size_flags_horizontal = Control.SIZE_EXPAND_FILL + _recipe_list.add_theme_constant_override("separation", 6) + scroll.add_child(_recipe_list) + crv.add_child(scroll) + cp.add_child(crv) + row.add_child(cp) + + # Stack on the cursor. + _cursor_view = Control.new() + _cursor_view.mouse_filter = Control.MOUSE_FILTER_IGNORE + _cursor_view.top_level = true + _cursor_icon = TextureRect.new() + _cursor_icon.expand_mode = TextureRect.EXPAND_IGNORE_SIZE + _cursor_icon.size = Vector2(52, 52) + _cursor_icon.position = Vector2(-26, -26) + _cursor_icon.mouse_filter = Control.MOUSE_FILTER_IGNORE + _cursor_view.add_child(_cursor_icon) + _cursor_count = UITheme.label("", 15) + _cursor_count.position = Vector2(8, 6) + _cursor_view.add_child(_cursor_count) + add_child(_cursor_view) + + player.inventory.changed.connect(_refresh) + ItemDB.icons_ready.connect(_refresh) + +func is_open() -> bool: + return visible + +func open(chest: Node) -> void: + container = chest + if container != null: + container.inventory.changed.connect(_refresh) + visible = true + player.ui_open = true + Input.mouse_mode = Input.MOUSE_MODE_VISIBLE + _rebuild_chest() + _refresh() + +func close() -> void: + if _cursor != null: + var left: int = player.inventory.add(_cursor.id, _cursor.count) + if left > 0 and container != null: + left = container.inventory.add(_cursor.id, left) + _cursor = null + if container != null and container.inventory.changed.is_connected(_refresh): + container.inventory.changed.disconnect(_refresh) + container = null + visible = false + player.ui_open = false + Input.mouse_mode = Input.MOUSE_MODE_CAPTURED + +func _input(event: InputEvent) -> void: + if not visible: + return + if event.is_action_pressed("inventory"): # ESC is handled by the HUD + close() + get_viewport().set_input_as_handled() + elif event is InputEventMouseMotion: + _cursor_view.global_position = event.position + # Walking away from a chest closes it. + if container != null and player.global_position.distance_to(container.global_position) > 5.0: + close() + +func _process(_d: float) -> void: + if visible and container != null and player.global_position.distance_to(container.global_position) > 5.0: + close() + +func _rebuild_chest() -> void: + for c in _chest_grid.get_children(): + c.queue_free() + _chest_slots.clear() + _chest_panel.visible = container != null + if container == null: + return + _chest_title.text = container.display_name() + for i in range(container.inventory.size()): + var s: Panel = SlotScript.new(i, 60) + s.clicked.connect(_on_chest_slot) + _chest_grid.add_child(s) + _chest_slots.append(s) + +func _on_player_slot(i: int, right: bool, shift: bool) -> void: + if shift and container != null: + Actions.transfer(player, player.inventory, i, container.inventory, container) + else: + _cursor = player.inventory.click(i, _cursor, right) + _refresh() + +func _on_chest_slot(i: int, right: bool, shift: bool) -> void: + if not Actions._in_reach(player, container, 1.0): + return + if shift: + Actions.transfer(player, container.inventory, i, player.inventory, container) + else: + _cursor = container.inventory.click(i, _cursor, right) + _refresh() + +func _refresh() -> void: + if player == null: + return + for i in range(_player_slots.size()): + _player_slots[i].set_stack(player.inventory.get_slot(i)) + _player_slots[i].set_selected(i == player.selected) + if container != null: + for i in range(_chest_slots.size()): + _chest_slots[i].set_stack(container.inventory.get_slot(i)) + _cursor_view.visible = _cursor != null + if _cursor != null: + _cursor_icon.texture = ItemDB.icon(_cursor.id) + _cursor_count.text = str(_cursor.count) if _cursor.count > 1 else "" + _rebuild_recipes() + +func _rebuild_recipes() -> void: + if not visible: + return + for c in _recipe_list.get_children(): + c.queue_free() + var stations: Array[String] = [] + for st in ["workbench", "campfire"]: + if Actions.station_near(player, st): + stations.append(st) + _station_label.text = "Nearby: " + (", ".join(stations) if not stations.is_empty() else "nothing (hand crafting only)") + for rid in ItemDB.recipes: + _recipe_list.add_child(_recipe_row(ItemDB.recipes[rid])) + +func _recipe_row(r: Dictionary) -> Control: + var ok := Actions.can_craft(player, r.id) + var row := PanelContainer.new() + row.add_theme_stylebox_override("panel", UITheme.box(UITheme.SLOT_BG, UITheme.SELECTED if ok else UITheme.SLOT_BORDER, 1, 5, 6)) + var h := HBoxContainer.new() + h.add_theme_constant_override("separation", 8) + row.add_child(h) + var icon := TextureRect.new() + icon.texture = ItemDB.icon(r.output) + icon.custom_minimum_size = Vector2(44, 44) + icon.expand_mode = TextureRect.EXPAND_IGNORE_SIZE + icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED + icon.modulate = Color.WHITE if ok else Color(1, 1, 1, 0.45) + h.add_child(icon) + var v := VBoxContainer.new() + v.size_flags_horizontal = Control.SIZE_EXPAND_FILL + var title := ItemDB.item_name(r.output) + (" x%d" % r.count if r.count > 1 else "") + v.add_child(UITheme.label(title, 16, UITheme.TEXT if ok else UITheme.TEXT_DIM)) + var req := RichTextLabel.new() + req.bbcode_enabled = true + req.fit_content = true + req.scroll_active = false + req.autowrap_mode = TextServer.AUTOWRAP_OFF + req.add_theme_font_size_override("normal_font_size", 13) + var parts: Array[String] = [] + for id in r.inputs: + var need := int(r.inputs[id]) + var have: int = player.inventory.count(id) + var col := UITheme.OK if have >= need else UITheme.BAD + parts.append("[color=#%s]%s %d/%d[/color]" % [col.to_html(false), ItemDB.item_name(id), have, need]) + if not r.station.is_empty(): + var col := UITheme.OK if Actions.station_near(player, r.station) else UITheme.BAD + parts.append("[color=#%s]@ %s[/color]" % [col.to_html(false), r.station]) + req.text = " ".join(parts) + req.custom_minimum_size = Vector2(200, 0) + v.add_child(req) + h.add_child(v) + var b := Button.new() + b.text = "Craft" + b.disabled = not ok + b.focus_mode = Control.FOCUS_NONE + b.pressed.connect(_craft.bind(r.id)) + h.add_child(b) + return row + +func _craft(recipe_id: String) -> void: + Actions.craft(player, recipe_id) + _refresh() diff --git a/scripts/ui/inventory_screen.gd.uid b/scripts/ui/inventory_screen.gd.uid new file mode 100644 index 0000000..a707391 --- /dev/null +++ b/scripts/ui/inventory_screen.gd.uid @@ -0,0 +1 @@ +uid://cuoylqtlj5dmy diff --git a/scripts/ui/pause_menu.gd b/scripts/ui/pause_menu.gd new file mode 100644 index 0000000..48a645f --- /dev/null +++ b/scripts/ui/pause_menu.gd @@ -0,0 +1,167 @@ +extends Control +## Pause menu (ESC): resume, controls, fullscreen, mouse sensitivity, quit. +## Pauses the scene tree while open; runs with PROCESS_MODE_ALWAYS so it still +## gets input. + +const UITheme = preload("res://scripts/ui/theme.gd") + +const CONTROLS := [ + ["W A S D", "Move"], ["Shift", "Sprint"], ["Space", "Jump / swim up"], + ["Mouse", "Look"], ["E", "Interact / pick up"], ["Left click", "Use tool / place"], + ["R", "Rotate piece"], ["1 - 8, wheel", "Hotbar"], ["Tab", "Inventory & crafting"], + ["Shift + click", "Move stack to/from a chest"], ["Right click", "Split stack"], + ["Esc", "This menu (game autosaves every 2 min)"], +] + +var player: CharacterBody3D +var _main: VBoxContainer +var _controls: VBoxContainer +var _quit_btn: Button +var _quit_armed := false + +func bind(p: CharacterBody3D) -> void: + player = p + process_mode = Node.PROCESS_MODE_ALWAYS + set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + mouse_filter = Control.MOUSE_FILTER_STOP + visible = false + + var dim := ColorRect.new() + dim.color = Color(0, 0, 0, 0.55) + dim.set_anchors_preset(Control.PRESET_FULL_RECT) + dim.mouse_filter = Control.MOUSE_FILTER_IGNORE + add_child(dim) + + var center := CenterContainer.new() + center.set_anchors_preset(Control.PRESET_FULL_RECT) + center.mouse_filter = Control.MOUSE_FILTER_IGNORE + add_child(center) + var panel := UITheme.panel() + panel.custom_minimum_size = Vector2(380, 0) + center.add_child(panel) + var stack := VBoxContainer.new() + panel.add_child(stack) + + # Main page. + _main = VBoxContainer.new() + _main.add_theme_constant_override("separation", 10) + stack.add_child(_main) + var title := UITheme.label("Paused", 30) + title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + _main.add_child(title) + _main.add_child(_button("Resume", close)) + _main.add_child(_button("Controls", _show_controls.bind(true))) + + var fs := CheckButton.new() + fs.text = "Fullscreen" + fs.focus_mode = Control.FOCUS_NONE + fs.button_pressed = DisplayServer.window_get_mode() == DisplayServer.WINDOW_MODE_FULLSCREEN + fs.add_theme_font_size_override("font_size", 18) + fs.toggled.connect(_on_fullscreen) + _main.add_child(fs) + + var sens_row := HBoxContainer.new() + sens_row.add_child(UITheme.label("Mouse sensitivity", 16)) + var sens := HSlider.new() + sens.min_value = 0.0005 + sens.max_value = 0.006 + sens.step = 0.0001 + sens.value = player.mouse_sensitivity + sens.size_flags_horizontal = Control.SIZE_EXPAND_FILL + sens.focus_mode = Control.FOCUS_NONE + sens.value_changed.connect(func(v: float): player.mouse_sensitivity = v) + sens_row.add_child(sens) + _main.add_child(sens_row) + + var saved: bool = Saves.has_world() + if saved: + _main.add_child(_button("Save & Quit to Title", _on_quit_to_title)) + _quit_btn = _button("Save & Quit to Desktop", _on_quit) + else: + _quit_btn = _button("Quit to Desktop", _on_quit) + _main.add_child(_quit_btn) + var note_text := "Autosaves every 2 minutes. World: %s" % Saves.read_meta(Saves.current_id).get("name", "?") if saved \ + else "Not playing a saved world: progress won't be kept." + var note := UITheme.label(note_text, 13, UITheme.TEXT_DIM) + note.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + _main.add_child(note) + + # Controls page. + _controls = VBoxContainer.new() + _controls.add_theme_constant_override("separation", 6) + _controls.visible = false + stack.add_child(_controls) + var ct := UITheme.label("Controls", 26) + ct.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + _controls.add_child(ct) + var grid := GridContainer.new() + grid.columns = 2 + grid.add_theme_constant_override("h_separation", 24) + for row in CONTROLS: + grid.add_child(UITheme.label(row[0], 16, UITheme.SELECTED)) + grid.add_child(UITheme.label(row[1], 16)) + _controls.add_child(grid) + _controls.add_child(_button("Back", _show_controls.bind(false))) + +func _button(text: String, cb: Callable) -> Button: + var b := Button.new() + b.text = text + b.focus_mode = Control.FOCUS_NONE + b.custom_minimum_size = Vector2(0, 42) + b.add_theme_font_size_override("font_size", 18) + b.pressed.connect(cb) + return b + +func is_open() -> bool: + return visible + +func open() -> void: + visible = true + _show_controls(false) + _disarm_quit() + player.ui_open = true + get_tree().paused = true + Input.mouse_mode = Input.MOUSE_MODE_VISIBLE + +func close() -> void: + visible = false + player.ui_open = false + get_tree().paused = false + Input.mouse_mode = Input.MOUSE_MODE_CAPTURED + +## ESC on the controls page goes back; on the main page it resumes. +func back() -> void: + if _controls.visible: + _show_controls(false) + else: + close() + +func _show_controls(on: bool) -> void: + _controls.visible = on + _main.visible = not on + +func _on_fullscreen(on: bool) -> void: + DisplayServer.window_set_mode(DisplayServer.WINDOW_MODE_FULLSCREEN if on else DisplayServer.WINDOW_MODE_WINDOWED) + +func _on_quit() -> void: + if Saves.has_world(): + player.get_parent().save_now() + get_tree().quit() + return + # Unsaved session: ask twice so nobody loses a run by accident. + if not _quit_armed: + _quit_armed = true + _quit_btn.text = "Click again to quit" + return + get_tree().quit() + +func _on_quit_to_title() -> void: + player.get_parent().save_now() + get_tree().paused = false + Saves.current_id = "" + get_tree().auto_accept_quit = true + get_tree().change_scene_to_file("res://scenes/title.tscn") + +func _disarm_quit() -> void: + _quit_armed = false + _quit_btn.text = "Save & Quit to Desktop" if Saves.has_world() else "Quit to Desktop" diff --git a/scripts/ui/pause_menu.gd.uid b/scripts/ui/pause_menu.gd.uid new file mode 100644 index 0000000..233a14a --- /dev/null +++ b/scripts/ui/pause_menu.gd.uid @@ -0,0 +1 @@ +uid://cufp0428mqob7 diff --git a/scripts/ui/slot.gd b/scripts/ui/slot.gd new file mode 100644 index 0000000..1c43c8b --- /dev/null +++ b/scripts/ui/slot.gd @@ -0,0 +1,66 @@ +extends Panel +## One inventory slot: icon, stack count, optional hotbar number. Renders a +## stack and reports clicks; it never changes the inventory itself. + +signal clicked(index: int, right: bool, shift: bool) + +const UITheme = preload("res://scripts/ui/theme.gd") + +var index := 0 +var _icon: TextureRect +var _count: Label +var _key: Label +var _style: StyleBoxFlat +var _selected_style: StyleBoxFlat +var _stack = null + +func _init(i: int = 0, size_px: int = 64, key_label: String = "") -> void: + index = i + custom_minimum_size = Vector2(size_px, size_px) + mouse_filter = Control.MOUSE_FILTER_STOP + _style = UITheme.box(UITheme.SLOT_BG, UITheme.SLOT_BORDER, 2, 5, 0) + _selected_style = UITheme.box(UITheme.SLOT_BG.lightened(0.15), UITheme.SELECTED, 3, 5, 0) + add_theme_stylebox_override("panel", _style) + + _icon = TextureRect.new() + _icon.expand_mode = TextureRect.EXPAND_IGNORE_SIZE + _icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED + _icon.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + _icon.offset_left = 5; _icon.offset_top = 5; _icon.offset_right = -5; _icon.offset_bottom = -5 + _icon.mouse_filter = Control.MOUSE_FILTER_IGNORE + add_child(_icon) + + _count = UITheme.label("", 15) + _count.set_anchors_and_offsets_preset(Control.PRESET_BOTTOM_RIGHT) + _count.grow_horizontal = Control.GROW_DIRECTION_BEGIN + _count.grow_vertical = Control.GROW_DIRECTION_BEGIN + _count.offset_right = -5 + _count.offset_bottom = -2 + _count.mouse_filter = Control.MOUSE_FILTER_IGNORE + add_child(_count) + + _key = UITheme.label(key_label, 12, UITheme.TEXT_DIM) + _key.position = Vector2(5, 2) + _key.mouse_filter = Control.MOUSE_FILTER_IGNORE + add_child(_key) + +func set_stack(stack) -> void: + _stack = stack + if stack == null: + _icon.texture = null + _count.text = "" + tooltip_text = "" + return + _icon.texture = ItemDB.icon(stack.id) + _count.text = str(stack.count) if stack.count > 1 else "" + var def := ItemDB.get_item(stack.id) + tooltip_text = "%s\n%s" % [def.get("name", stack.id), def.get("description", "")] + +func set_selected(on: bool) -> void: + add_theme_stylebox_override("panel", _selected_style if on else _style) + +func _gui_input(event: InputEvent) -> void: + if event is InputEventMouseButton and event.pressed: + if event.button_index == MOUSE_BUTTON_LEFT or event.button_index == MOUSE_BUTTON_RIGHT: + clicked.emit(index, event.button_index == MOUSE_BUTTON_RIGHT, event.shift_pressed) + accept_event() diff --git a/scripts/ui/slot.gd.uid b/scripts/ui/slot.gd.uid new file mode 100644 index 0000000..95d360a --- /dev/null +++ b/scripts/ui/slot.gd.uid @@ -0,0 +1 @@ +uid://bfaf1tn6bi3if diff --git a/scripts/ui/theme.gd b/scripts/ui/theme.gd new file mode 100644 index 0000000..5b5a5ab --- /dev/null +++ b/scripts/ui/theme.gd @@ -0,0 +1,35 @@ +extends RefCounted +## Shared UI styling: dark, warm, slightly translucent panels. + +const PANEL_BG := Color(0.09, 0.075, 0.06, 0.88) +const PANEL_BORDER := Color(0.42, 0.33, 0.22, 0.9) +const SLOT_BG := Color(0.16, 0.13, 0.10, 0.85) +const SLOT_BORDER := Color(0.32, 0.26, 0.18, 0.9) +const SELECTED := Color(0.95, 0.78, 0.35, 1.0) +const TEXT := Color(0.93, 0.89, 0.80) +const TEXT_DIM := Color(0.68, 0.63, 0.55) +const OK := Color(0.55, 0.85, 0.45) +const BAD := Color(0.95, 0.45, 0.35) + +static func box(bg: Color, border: Color, width: int = 2, radius: int = 6, pad: int = 10) -> StyleBoxFlat: + var s := StyleBoxFlat.new() + s.bg_color = bg + s.border_color = border + s.set_border_width_all(width) + s.set_corner_radius_all(radius) + s.set_content_margin_all(pad) + return s + +static func panel() -> PanelContainer: + var p := PanelContainer.new() + p.add_theme_stylebox_override("panel", box(PANEL_BG, PANEL_BORDER, 2, 8, 14)) + return p + +static func label(text: String, size: int = 16, color: Color = TEXT) -> Label: + var l := Label.new() + l.text = text + l.add_theme_font_size_override("font_size", size) + l.add_theme_color_override("font_color", color) + l.add_theme_color_override("font_outline_color", Color(0, 0, 0, 0.8)) + l.add_theme_constant_override("outline_size", 4) + return l diff --git a/scripts/ui/theme.gd.uid b/scripts/ui/theme.gd.uid new file mode 100644 index 0000000..93379a7 --- /dev/null +++ b/scripts/ui/theme.gd.uid @@ -0,0 +1 @@ +uid://jhtk2nvq1tft diff --git a/scripts/ui/title_screen.gd b/scripts/ui/title_screen.gd new file mode 100644 index 0000000..c06db0d --- /dev/null +++ b/scripts/ui/title_screen.gd @@ -0,0 +1,238 @@ +extends Node +## Title screen: Continue, New World, Load World (with delete), Quit. +## The island itself, in menu mode, slowly flies past in the background. + +const UITheme = preload("res://scripts/ui/theme.gd") +const WORLD_SCENE := "res://scenes/world.tscn" + +var _pages: Dictionary = {} ## name -> Control +var _name_edit: LineEdit +var _seed_edit: LineEdit +var _list: VBoxContainer +var _armed_delete := "" + +func _ready() -> void: + Saves.current_id = "" + get_tree().auto_accept_quit = true + get_tree().paused = false + Input.mouse_mode = Input.MOUSE_MODE_VISIBLE + + var backdrop: Node3D = load(WORLD_SCENE).instantiate() + backdrop.menu_mode = true + add_child(backdrop) + # The backdrop's (disabled) player captures the mouse in its _ready; take + # it back, or the menu can't be clicked. + Input.mouse_mode = Input.MOUSE_MODE_VISIBLE + + var ui := CanvasLayer.new() + ui.layer = 10 + add_child(ui) + var root := Control.new() + root.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + ui.add_child(root) + + # Soft vignette on the left so the text reads over the scenery. + var shade := TextureRect.new() + var g := Gradient.new() + g.offsets = PackedFloat32Array([0.0, 1.0]) + g.colors = PackedColorArray([Color(0, 0, 0, 0.6), Color(0, 0, 0, 0)]) + var gt := GradientTexture2D.new() + gt.gradient = g + gt.fill_to = Vector2(1, 0) + shade.texture = gt + shade.set_anchors_and_offsets_preset(Control.PRESET_LEFT_WIDE) + shade.offset_right = 720 + shade.mouse_filter = Control.MOUSE_FILTER_IGNORE + root.add_child(shade) + + var col := VBoxContainer.new() + col.set_anchors_and_offsets_preset(Control.PRESET_LEFT_WIDE) + col.offset_left = 70 + col.offset_top = 90 + col.offset_right = 520 + col.add_theme_constant_override("separation", 14) + root.add_child(col) + col.add_child(UITheme.label("VALHEIMLIKE", 54)) + col.add_child(UITheme.label("Survive the island. Build a home.", 18, UITheme.TEXT_DIM)) + var spacer := Control.new() + spacer.custom_minimum_size = Vector2(0, 24) + col.add_child(spacer) + + _pages["main"] = _main_page() + _pages["new"] = _new_page() + _pages["load"] = _load_page() + for p in _pages.values(): + col.add_child(p) + _show("main") + _maybe_capture() + +## Dev hook, same as world.gd: `-- --shot=/abs/prefix` saves the title pages. +func _maybe_capture() -> void: + var prefix := "" + for a in OS.get_cmdline_user_args(): + if a.begins_with("--shot="): + prefix = a.trim_prefix("--shot=") + if prefix.is_empty(): + return + await get_tree().create_timer(1.0).timeout + print("[title] mouse mode: %s" % ["visible", "hidden", "captured", "confined"][Input.mouse_mode]) + for page in ["main", "new", "load"]: + _show(page) + await get_tree().create_timer(4.0 if page == "main" else 0.5).timeout + await RenderingServer.frame_post_draw + get_viewport().get_texture().get_image().save_png("%s_title_%s.png" % [prefix, page]) + print("[title] saved %s_title_%s.png" % [prefix, page]) + get_tree().quit() + +func _unhandled_input(event: InputEvent) -> void: + if event is InputEventKey and event.pressed and event.keycode == KEY_ESCAPE and not _pages["main"].visible: + _show("main") + +func _show(page: String) -> void: + for k in _pages: + _pages[k].visible = k == page + _armed_delete = "" + if page == "load": + _fill_list() + elif page == "new": + _name_edit.text = _unique_name() + _seed_edit.text = "" + _name_edit.grab_focus() + _name_edit.select_all() + +func _button(text: String, cb: Callable, min_w: int = 320) -> Button: + var b := Button.new() + b.text = text + b.focus_mode = Control.FOCUS_NONE + b.custom_minimum_size = Vector2(min_w, 46) + b.add_theme_font_size_override("font_size", 19) + b.alignment = HORIZONTAL_ALIGNMENT_LEFT + b.pressed.connect(cb) + return b + +func _main_page() -> Control: + var v := VBoxContainer.new() + v.add_theme_constant_override("separation", 10) + var worlds := Saves.list_worlds() + if not worlds.is_empty(): + var w: Dictionary = worlds[0] + v.add_child(_button("Continue: %s" % w.name, _play.bind(w.id))) + v.add_child(_button("New World", _show.bind("new"))) + var lb := _button("Load World", _show.bind("load")) + lb.disabled = worlds.is_empty() + v.add_child(lb) + v.add_child(_button("Quit", get_tree().quit)) + return v + +func _new_page() -> Control: + var p := UITheme.panel() + var v := VBoxContainer.new() + v.add_theme_constant_override("separation", 8) + p.add_child(v) + v.add_child(UITheme.label("New World", 24)) + v.add_child(UITheme.label("Name", 15, UITheme.TEXT_DIM)) + _name_edit = LineEdit.new() + _name_edit.custom_minimum_size = Vector2(360, 40) + _name_edit.max_length = 32 + _name_edit.text_submitted.connect(func(_t): _create()) + v.add_child(_name_edit) + v.add_child(UITheme.label("Seed (blank = random island, 1337 = the original)", 15, UITheme.TEXT_DIM)) + _seed_edit = LineEdit.new() + _seed_edit.placeholder_text = "random" + _seed_edit.custom_minimum_size = Vector2(360, 40) + _seed_edit.text_submitted.connect(func(_t): _create()) + v.add_child(_seed_edit) + var h := HBoxContainer.new() + h.add_theme_constant_override("separation", 10) + h.add_child(_button("Create & Play", _create, 170)) + h.add_child(_button("Back", _show.bind("main"), 120)) + v.add_child(h) + return p + +func _load_page() -> Control: + var p := UITheme.panel() + var v := VBoxContainer.new() + v.add_theme_constant_override("separation", 8) + p.add_child(v) + v.add_child(UITheme.label("Load World", 24)) + var scroll := ScrollContainer.new() + scroll.custom_minimum_size = Vector2(460, 320) + scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED + _list = VBoxContainer.new() + _list.size_flags_horizontal = Control.SIZE_EXPAND_FILL + _list.add_theme_constant_override("separation", 6) + scroll.add_child(_list) + v.add_child(scroll) + v.add_child(_button("Back", _show.bind("main"), 120)) + return p + +func _fill_list() -> void: + for c in _list.get_children(): + c.queue_free() + var worlds := Saves.list_worlds() + if worlds.is_empty(): + _list.add_child(UITheme.label("No worlds yet.", 16, UITheme.TEXT_DIM)) + return + for w in worlds: + var row := PanelContainer.new() + row.add_theme_stylebox_override("panel", UITheme.box(UITheme.SLOT_BG, UITheme.SLOT_BORDER, 1, 5, 8)) + var h := HBoxContainer.new() + h.add_theme_constant_override("separation", 8) + row.add_child(h) + var info := VBoxContainer.new() + info.size_flags_horizontal = Control.SIZE_EXPAND_FILL + info.add_child(UITheme.label(w.name, 18)) + var played := int(w.get("play_time", 0.0)) + var when := Time.get_datetime_string_from_unix_time(int(w.get("last_played", 0)), true) + info.add_child(UITheme.label("seed %d · played %dh %02dm · %s" % [int(w.seed), played / 3600, (played / 60) % 60, when], 13, UITheme.TEXT_DIM)) + h.add_child(info) + h.add_child(_button("Play", _play.bind(w.id), 80)) + var del := Button.new() + del.text = "Delete" + del.focus_mode = Control.FOCUS_NONE + del.custom_minimum_size = Vector2(90, 46) + del.add_theme_font_size_override("font_size", 19) + del.pressed.connect(_on_delete.bind(w.id, del)) + h.add_child(del) + _list.add_child(row) + +func _on_delete(id: String, b: Button) -> void: + if _armed_delete != id: + _armed_delete = id + b.text = "Sure?" + return + Saves.delete_world(id) + _armed_delete = "" + _fill_list() + if Saves.list_worlds().is_empty(): + _rebuild_main() + +func _rebuild_main() -> void: + var old: Control = _pages["main"] + var parent := old.get_parent() + var idx := old.get_index() + old.queue_free() + _pages["main"] = _main_page() + parent.add_child(_pages["main"]) + parent.move_child(_pages["main"], idx) + _pages["main"].visible = false + +func _unique_name() -> String: + var names := Saves.list_worlds().map(func(w): return w.name) + var n := names.size() + 1 + var base := "Island" + while ("%s %d" % [base, n]) in names: + n += 1 + return "%s %d" % [base, n] + +func _create() -> void: + var world_name := _name_edit.text.strip_edges() + if world_name.is_empty(): + world_name = _unique_name() + var seed_text := _seed_edit.text.strip_edges() + var seed := int(seed_text) if seed_text.is_valid_int() else (seed_text.hash() if not seed_text.is_empty() else randi()) + _play(Saves.create_world(world_name, seed)) + +func _play(id: String) -> void: + Saves.current_id = id + get_tree().change_scene_to_file(WORLD_SCENE) diff --git a/scripts/ui/title_screen.gd.uid b/scripts/ui/title_screen.gd.uid new file mode 100644 index 0000000..5bbdab8 --- /dev/null +++ b/scripts/ui/title_screen.gd.uid @@ -0,0 +1 @@ +uid://dxm34hvuh7wk1 diff --git a/scripts/world/pickup.gd b/scripts/world/pickup.gd new file mode 100644 index 0000000..82732f4 --- /dev/null +++ b/scripts/world/pickup.gd @@ -0,0 +1,35 @@ +extends Area3D +## An item lying on the ground (stick, stone, flint). Look at it and press E. +## Lives on collision layer 2, so the player walks through it but the +## interaction ray still finds it. + +var item_id := "stick" +var amount := 1 +var uid := "" ## stable id for saves (see props.gd) +var taken := false + +func setup(id: String, mesh: Mesh, material: Material, radius: float) -> void: + item_id = id + collision_layer = 2 + collision_mask = 0 + monitoring = false + var mi := MeshInstance3D.new() + mi.mesh = mesh + mi.material_override = material + add_child(mi) + var cs := CollisionShape3D.new() + var sph := SphereShape3D.new() + sph.radius = radius + cs.shape = sph + cs.position = Vector3(0, radius * 0.5, 0) + add_child(cs) + +func interact_prompt(_player: Node) -> String: + return "[E] Pick up %s" % ItemDB.item_name(item_id) + +func interact(player: Node) -> void: + Actions.pickup(player, self) + +func collected() -> void: + taken = true + queue_free() diff --git a/scripts/world/pickup.gd.uid b/scripts/world/pickup.gd.uid new file mode 100644 index 0000000..af331e6 --- /dev/null +++ b/scripts/world/pickup.gd.uid @@ -0,0 +1 @@ +uid://d027tu0w5dt0r diff --git a/scripts/world/props.gd b/scripts/world/props.gd index 4ebc7a1..7b24cee 100644 --- a/scripts/world/props.gd +++ b/scripts/world/props.gd @@ -11,6 +11,8 @@ extends Node3D ## dense to place over the whole map at once. const ProcMesh = preload("res://scripts/world/proc_mesh.gd") +const ResourceNodeScript = preload("res://scripts/world/resource_node.gd") +const PickupScript = preload("res://scripts/world/pickup.gd") const CELL := 48.0 const GRASS_TILE := 12.0 @@ -32,6 +34,12 @@ var _spawn := Vector3.ZERO var _forest_noise := FastNoiseLite.new() var _meadow_noise := FastNoiseLite.new() var _colliders: StaticBody3D +var _resources: Node3D ## harvestable trees/boulders (each its own body) + +## Stable ids for saving. Placement is deterministic for a given seed, so +## creation order gives the same id to the same tree on every load. +var resource_nodes: Dictionary = {} ## uid -> resource node +var pickups: Dictionary = {} ## uid -> pickup var _bark_mat: StandardMaterial3D var _leaf_mat: ShaderMaterial @@ -48,6 +56,7 @@ func place_on(t: Node3D, player: Node3D, spawn: Vector3) -> void: terrain = t _player = player _spawn = spawn + world_seed = terrain.noise_seed # one seed drives terrain and scenery _forest_noise.seed = world_seed + 11 _forest_noise.noise_type = FastNoiseLite.TYPE_SIMPLEX_SMOOTH _forest_noise.frequency = 0.011 @@ -60,12 +69,16 @@ func place_on(t: Node3D, player: Node3D, spawn: Vector3) -> void: _colliders = StaticBody3D.new() _colliders.name = "PropColliders" add_child(_colliders) + _resources = Node3D.new() + _resources.name = "Resources" + add_child(_resources) _make_materials() _scatter_trees() _scatter_rocks() _scatter_bushes() _place_logs() + _scatter_pickups() _grass_root = Node3D.new() _grass_root.name = "Grass" @@ -141,18 +154,58 @@ func _jittered(spacing: float, rng: RandomNumberGenerator) -> PackedVector2Array func _forest(x: float, z: float) -> float: return smoothstep(-0.15, 0.35, _forest_noise.get_noise_2d(x, z)) -func _group(groups: Dictionary, x: float, z: float, vi: int, xf: Transform3D) -> void: +## Adds a transform to its cell batch. Returns [key, index] so a resource node +## can find its own instance once the batch exists. +func _group(groups: Dictionary, x: float, z: float, vi: int, xf: Transform3D) -> Array: var key := Vector3i(floori(x / CELL), floori(z / CELL), vi) if not groups.has(key): groups[key] = [] groups[key].append(xf) + return [key, groups[key].size() - 1] -func _flush(groups: Dictionary, meshes: Array, shadows: bool, label: String) -> void: +## Builds the batches. Returns key -> MultiMeshInstance3D. +func _flush(groups: Dictionary, meshes: Array, shadows: bool, label: String) -> Dictionary: var root := Node3D.new() root.name = label add_child(root) + var out := {} for key: Vector3i in groups: - root.add_child(_mm_instance(meshes[key.z], groups[key], [], shadows)) + var mmi := _mm_instance(meshes[key.z], groups[key], [], shadows) + root.add_child(mmi) + out[key] = mmi + return out + +## Hands each pending resource node its batch instance. +func _bind_nodes(pending: Array, batches: Dictionary) -> void: + for p in pending: + var node = p[0] + node.mmi = batches[p[1]] + node.index = p[2] + +func _resource_node(kind: String, xf: Transform3D) -> StaticBody3D: + var node: StaticBody3D = ResourceNodeScript.new() + node.uid = "%s%d" % [kind[0], resource_nodes.size()] + resource_nodes[node.uid] = node + node.kind = kind + node.base_xf = xf + node.stump_material = _bark_mat + node.rng.seed = hash(xf.origin) + node.add_to_group("harvestable") + if kind == "tree": + node.tool_kind = "axe" + node.max_hp = 5.0 + node.drops_final = {"wood": [4, 6], "stick": [1, 3]} + node.chip_color = Color(0.62, 0.46, 0.28) + else: + node.tool_kind = "pickaxe" + node.max_hp = 6.0 + node.drops_per_hit = {"stone": 1} + node.drops_final = {"stone": [3, 5], "flint": [1, 2]} + node.chip_color = Color(0.55, 0.54, 0.52) + node.hp = node.max_hp + _resources.add_child(node) + node.global_position = xf.origin + return node func _mm_instance(mesh: Mesh, xforms: Array, colors: Array, shadows: bool) -> MultiMeshInstance3D: var mm := MultiMesh.new() @@ -206,6 +259,7 @@ func _scatter_trees() -> void: var rng := RandomNumberGenerator.new() rng.seed = world_seed + 1 var groups := {} + var pending: Array = [] for p in _jittered(tree_spacing, rng): # Draw every random number up front so the sequence never depends on # which branch below is taken (stable layout when rules are tweaked). @@ -227,15 +281,25 @@ func _scatter_trees() -> void: else: vi = birches[int(pick * 7919.0) % birches.size()] var xf := Transform3D(Basis(Vector3.UP, yaw) * Basis.from_scale(Vector3.ONE * s), Vector3(p.x, h - 0.1, p.y)) - _group(groups, p.x, p.y, vi, xf) + var slot := _group(groups, p.x, p.y, vi, xf) if terrain.inside_boundary(p.x, p.y, -4.0): + # Reachable trees are harvestable: their own body + trunk capsule. var v: Dictionary = variants[vi] var cap := CapsuleShape3D.new() cap.radius = v.trunk_r * s cap.height = maxf(v.trunk_h * s, cap.radius * 2.0 + 0.1) - _add_shape(cap, Transform3D(Basis(), Vector3(p.x, h + cap.height * 0.5, p.y))) + var node := _resource_node("tree", xf) + if vi == dead: + node.drops_final = {"wood": [2, 3], "stick": [2, 4]} + node.max_hp = 3.0 + node.hp = 3.0 + var cs := CollisionShape3D.new() + cs.shape = cap + cs.position = Vector3(0, cap.height * 0.5 + 0.1, 0) + node.add_child(cs) + pending.append([node, slot[0], slot[1]]) var meshes := variants.map(func(v): return v.mesh) - _flush(groups, meshes, true, "Trees") + _bind_nodes(pending, _flush(groups, meshes, true, "Trees")) ## Open grassy strip along the sea-cliff edge: no trees, bushes or boulders, so ## the view out to sea is clear and you can walk right up to the edge. @@ -270,6 +334,7 @@ func _scatter_rocks() -> void: var rng := RandomNumberGenerator.new() rng.seed = world_seed + 2 var groups := {} + var pending: Array = [] # Small stones: everywhere, more on slopes and in the shallows. for p in _jittered(6.0, rng): @@ -292,7 +357,7 @@ func _scatter_rocks() -> void: continue if roll > 0.07 + _forest(p.x, p.y) * 0.08: continue - _place_rock(groups, meshes, hulls, p, h, n, vi, yaw, s, 0.5, 0.3) + _place_rock(groups, meshes, hulls, p, h, n, vi, yaw, s, 0.5, 0.3, pending) # Cliff outcrops: big slabs breaking up the steep mountain faces. for p in _jittered(8.0, rng): @@ -321,20 +386,30 @@ func _scatter_rocks() -> void: _group(groups, p.x, p.y, vi, Transform3D(b, Vector3(p.x, h - 1.0, p.y))) placed += 1 - _flush(groups, meshes, true, "Rocks") + _bind_nodes(pending, _flush(groups, meshes, true, "Rocks")) +## Places one rock. Passing `pending` makes it a mineable boulder (if it's +## inside the walkable area); otherwise it just gets plain collision. func _place_rock(groups: Dictionary, meshes: Array, hulls: Array, p: Vector2, h: float, n: Vector3, - vi: int, yaw: float, s: float, follow: float, sink: float) -> void: + vi: int, yaw: float, s: float, follow: float, sink: float, pending = null) -> void: var b := _ground_basis(n, follow, yaw, s) var pos := Vector3(p.x, h - sink * s, p.y) - _group(groups, p.x, p.y, vi, Transform3D(b, pos)) + var slot := _group(groups, p.x, p.y, vi, Transform3D(b, pos)) if s >= 0.55 and terrain.inside_boundary(p.x, p.y, -6.0): var pts := PackedVector3Array() for q: Vector3 in hulls[vi]: pts.append(q * s) var shape := ConvexPolygonShape3D.new() shape.points = pts - _add_shape(shape, Transform3D(b.orthonormalized(), pos)) + if pending != null and terrain.inside_boundary(p.x, p.y, 0.0): + var node := _resource_node("rock", Transform3D(b, pos)) + var cs := CollisionShape3D.new() + cs.shape = shape + cs.basis = b.orthonormalized() + node.add_child(cs) + pending.append([node, slot[0], slot[1]]) + else: + _add_shape(shape, Transform3D(b.orthonormalized(), pos)) # --- bushes, logs, stumps --------------------------------------------------- @@ -415,6 +490,54 @@ func _place_logs() -> void: col_xf.origin.y += (shape as CylinderShape3D).height * 0.5 - 0.3 _add_shape(shape, col_xf) +# --- ground pickups --------------------------------------------------------- + +## Sticks under trees, stones everywhere, flint near water and on rocky ground. +## Enough for the first tools without hunting around for long. +func _scatter_pickups() -> void: + var root := Node3D.new() + root.name = "Pickups" + add_child(root) + var rng := RandomNumberGenerator.new() + rng.seed = world_seed + 6 + var want := {"stick": 260, "stone": 170, "flint": 80} + var radius: Dictionary = {"stick": 0.35, "stone": 0.25, "flint": 0.25} + for id: String in want: + var mesh := ItemDB.mesh_for(id) + var placed := 0 + var tries := 0 + while placed < want[id] and tries < 20000: + tries += 1 + var x := rng.randf_range(-terrain.play_radius, terrain.play_radius) + var z := rng.randf_range(-terrain.play_radius, terrain.play_radius) + var yaw := rng.randf() * TAU + var keep := rng.randf() + if not terrain.inside_boundary(x, z, 3.0): + continue + var h: float = terrain.sample_height(x, z) + var n: Vector3 = terrain.sample_normal(x, z) + if h < terrain.water_level + 0.3 or n.y < 0.8: + continue + var chance := 1.0 + match id: + "stick": + chance = 0.08 + _forest(x, z) * 0.9 + "flint": + var shore := 1.0 - smoothstep(terrain.water_level + 0.5, terrain.water_level + 4.0, h) + chance = 0.06 + shore * 0.9 + (1.0 - n.y) * 2.0 + # A few right by the spawn so the first craft is quick. + if Vector2(x - _spawn.x, z - _spawn.z).length() < 18.0: + chance += 0.5 + if keep > chance: + continue + var pk: Area3D = PickupScript.new() + pk.setup(id, mesh, ItemDB.item_material(), radius[id]) + pk.uid = "p%d" % pickups.size() + pickups[pk.uid] = pk + root.add_child(pk) + pk.global_transform = Transform3D(_ground_basis(n, 1.0, yaw, 1.0), Vector3(x, h - 0.02, z)) + placed += 1 + # --- grass streaming -------------------------------------------------------- func _update_grass(budget: int) -> void: diff --git a/scripts/world/resource_node.gd b/scripts/world/resource_node.gd new file mode 100644 index 0000000..797af4e --- /dev/null +++ b/scripts/world/resource_node.gd @@ -0,0 +1,176 @@ +extends StaticBody3D +## A harvestable tree or boulder. +## +## The visible mesh is one instance inside a MultiMesh batch (props.gd), so this +## body only holds collision plus a reference to its instance. Hits shake or +## shrink that instance. When depleted the instance is hidden; a tree is +## replaced by a falling copy and a stump. +## +## Game rules (damage, drops) run through Actions.harvest_hit, which calls hit(). + +const ProcMesh = preload("res://scripts/world/proc_mesh.gd") + +var uid := "" ## stable id for saves (see props.gd) +var kind := "tree" ## "tree" | "rock" +var tool_kind := "axe" +var min_tier := 1 +var max_hp := 5.0 +var hp := 5.0 +var drops_per_hit: Dictionary = {} ## id -> count, given on every hit +var drops_final: Dictionary = {} ## id -> [min, max], given when depleted +var depleted := false +var chip_color := Color(0.6, 0.45, 0.28) + +var mmi: MultiMeshInstance3D ## batch holding the visual +var index := -1 ## instance index in that batch +var base_xf := Transform3D() +var stump_material: Material +var rng := RandomNumberGenerator.new() + +var _shake := 0.0 +var _shake_axis := Vector3.RIGHT + +func display_name() -> String: + return "Tree" if kind == "tree" else "Boulder" + +func interact_prompt(player: Node) -> String: + var tool: Dictionary = ItemDB.get_item(player.held_item_id()).get("tool", {}) + var verb := "Chop" if kind == "tree" else "Mine" + if tool.get("kind", "") == tool_kind: + return "%s [LMB] %s" % [display_name(), verb] + return "%s (needs a %s)" % [display_name(), tool_kind] + +## Applies one hit. Returns the items earned by this hit. +func hit(damage: float, point: Vector3, from: Vector3) -> Dictionary: + var out := {} + hp -= damage + for id in drops_per_hit: + out[id] = drops_per_hit[id] + _burst(point, chip_color, 14) + if hp <= 0.0: + depleted = true + for id in drops_final: + var r: Array = drops_final[id] + out[id] = out.get(id, 0) + rng.randi_range(int(r[0]), int(r[1])) + _deplete(from) + else: + _shake = 1.0 + var away := (global_position - from) + away.y = 0.0 + _shake_axis = away.normalized().cross(Vector3.UP) if away.length() > 0.01 else Vector3.RIGHT + if kind == "rock": + _set_instance(_rock_xf()) + return out + +func _process(delta: float) -> void: + if _shake <= 0.0 or depleted: + return + _shake = maxf(_shake - delta * 3.5, 0.0) + var base := _rock_xf() if kind == "rock" else base_xf + var wobble := sin(_shake * 40.0) * 0.035 * _shake + _set_instance(Transform3D(Basis(_shake_axis, wobble) * base.basis, base.origin)) + +## Rocks shrink toward 55% of their size as they're mined. +func _rock_xf() -> Transform3D: + var k := 0.55 + 0.45 * clampf(hp / max_hp, 0.0, 1.0) + return Transform3D(base_xf.basis.scaled_local(Vector3.ONE * k), base_xf.origin) + +func _set_instance(xf: Transform3D) -> void: + if mmi != null and index >= 0: + mmi.multimesh.set_instance_transform(index, xf) + +## Restores saved damage when a world loads. hp <= 0 = already felled/mined +## (no animation, no drops). +func restore(saved_hp: float) -> void: + hp = saved_hp + if hp <= 0.0: + depleted = true + _deplete(global_position, false) + elif kind == "rock": + _set_instance(_rock_xf()) + +func _deplete(from: Vector3, animate: bool = true) -> void: + # Hide the batched instance (moved far below ground: a zero scale would + # give the wind shaders a non-invertible matrix). + _set_instance(Transform3D(base_xf.basis, base_xf.origin + Vector3(0, -1000, 0))) + collision_layer = 0 + if kind == "tree" and mmi != null: + _fell(from, animate) + else: + if animate: + _burst(global_position + Vector3(0, 0.8, 0), chip_color, 40) + queue_free() + +func _fell(from: Vector3, animate: bool) -> void: + var world := get_parent() + if animate: + _fall_animation(world, from) + _make_stump(world) + +func _fall_animation(world: Node, from: Vector3) -> void: + var falling := MeshInstance3D.new() + falling.mesh = mmi.multimesh.mesh + world.add_child(falling) + falling.global_transform = base_xf + var away := base_xf.origin - from + away.y = 0.0 + var axis := away.normalized().cross(Vector3.DOWN) if away.length() > 0.01 else Vector3.RIGHT + var start := base_xf.basis + var tw := falling.create_tween() + tw.tween_method(func(a: float): falling.global_basis = Basis(axis, a) * start, 0.0, deg_to_rad(86.0), 1.6) \ + .set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_IN) + tw.tween_callback(func(): _burst(falling.global_position + away.normalized() * 3.0, Color(0.3, 0.45, 0.2), 30)) + tw.tween_interval(1.2) + tw.tween_property(falling, "position:y", falling.position.y - 4.0, 1.5) + tw.tween_callback(falling.queue_free) + +func _make_stump(world: Node) -> void: + var stump := MeshInstance3D.new() + var s := base_xf.basis.get_scale().x + stump.mesh = ProcMesh.stump_mesh(rng.randi(), 0.45, 0.3 * s) + stump.material_override = stump_material + world.add_child(stump) + stump.global_position = base_xf.origin + # Keep collision as a short stump. + for c in get_children(): + if c is CollisionShape3D and c.shape is CapsuleShape3D: + var cyl := CylinderShape3D.new() + cyl.radius = (c.shape as CapsuleShape3D).radius + cyl.height = 0.6 + c.shape = cyl + c.position = Vector3(0, 0.3, 0) + collision_layer = 1 + set_process(false) + remove_from_group("harvestable") + set_meta("is_stump", true) + +static func _burst_material(color: Color) -> StandardMaterial3D: + var m := StandardMaterial3D.new() + m.albedo_color = color + m.roughness = 1.0 + m.vertex_color_use_as_albedo = false + return m + +func _burst(pos: Vector3, color: Color, amount: int) -> void: + var p := CPUParticles3D.new() + var box := BoxMesh.new() + box.size = Vector3.ONE * 0.06 + box.material = _burst_material(color) + p.mesh = box + p.amount = amount + p.one_shot = true + p.explosiveness = 0.95 + p.lifetime = 0.9 + p.direction = Vector3.UP + p.spread = 70.0 + p.initial_velocity_min = 2.0 + p.initial_velocity_max = 4.5 + p.gravity = Vector3(0, -12, 0) + p.scale_amount_min = 0.6 + p.scale_amount_max = 1.4 + p.angular_velocity_min = -400.0 + p.angular_velocity_max = 400.0 + get_parent().add_child(p) + p.global_position = pos + p.emitting = true + p.finished.connect(p.queue_free) diff --git a/scripts/world/resource_node.gd.uid b/scripts/world/resource_node.gd.uid new file mode 100644 index 0000000..6170a2b --- /dev/null +++ b/scripts/world/resource_node.gd.uid @@ -0,0 +1 @@ +uid://ctglldcf8jo4c diff --git a/scripts/world/terrain.gd b/scripts/world/terrain.gd index 04dcbe3..34475cf 100644 --- a/scripts/world/terrain.gd +++ b/scripts/world/terrain.gd @@ -69,6 +69,10 @@ var _heights := PackedFloat32Array() var _normals := PackedVector3Array() func _ready() -> void: + # A saved world brings its own seed; otherwise keep the exported default. + var saves := get_node_or_null("/root/Saves") + if saves != null and saves.has_world(): + noise_seed = saves.world_seed() _build() # --- height function -------------------------------------------------------- diff --git a/scripts/world/world.gd b/scripts/world/world.gd index e1333c3..8da773d 100644 --- a/scripts/world/world.gd +++ b/scripts/world/world.gd @@ -18,10 +18,45 @@ extends Node3D @onready var player: CharacterBody3D = $Player @onready var sun: DirectionalLight3D = $Sun +const PIECES := { + "campfire": preload("res://scripts/building/campfire.gd"), + "workbench": preload("res://scripts/building/workbench.gd"), + "chest": preload("res://scripts/building/chest.gd"), +} + +const AUTOSAVE_SECONDS := 120.0 + +## Set by the title screen before adding the world: no player, no HUD, no +## saving, just a slow camera flight over the island as a menu backdrop. +var menu_mode := false + var _spawn := Vector3.ZERO var _spawn_yaw := 0.0 +var _pieces: Node3D +var hud: CanvasLayer +var _session_time := 0.0 ## seconds played since the last save +var _autosave_left := AUTOSAVE_SECONDS +var _menu_cam: Camera3D +var _menu_t := 0.0 func _ready() -> void: + _pieces = Node3D.new() + _pieces.name = "Pieces" + add_child(_pieces) + if menu_mode: + player.process_mode = Node.PROCESS_MODE_DISABLED + player.get_node("Camera3D").current = false + _menu_cam = Camera3D.new() + add_child(_menu_cam) + _menu_cam.current = true + $HUD.visible = false + elif DisplayServer.get_name() != "headless": + hud = preload("res://scripts/ui/hud.gd").new() + add_child(hud) + hud.bind(player) + if Saves.has_world() and not menu_mode: + # Save on window close instead of quitting straight away. + get_tree().auto_accept_quit = false _aim_sun() player.set_physics_process(false) set_physics_process(false) @@ -43,13 +78,124 @@ func _on_terrain_ready() -> void: var to_lake := lake - Vector2(_spawn.x, _spawn.z) _spawn_yaw = atan2(-to_lake.x, -to_lake.y) player.water_level = terrain.water_level - props.place_on(terrain, player, _spawn) + props.place_on(terrain, player if not menu_mode else _menu_cam, _spawn) print("[world] scenery placed in %d ms" % (Time.get_ticks_msec() - t0)) + if menu_mode: + return _respawn() + if Saves.has_world(): + var state: Dictionary = Saves.load_state(Saves.current_id) + if not state.is_empty(): + apply_save(state) + print("[world] loaded '%s'" % Saves.current_id) player.set_physics_process(true) set_physics_process(true) _maybe_capture() +func _process(delta: float) -> void: + if menu_mode: + # Slow orbit over the valley, looking toward the lake. + _menu_t += delta * 0.025 + var r := 70.0 + var p := Vector3(cos(_menu_t) * r, 0.0, sin(_menu_t) * r) + p.y = maxf(terrain.sample_height(p.x, p.z), terrain.water_level) + 24.0 + _menu_cam.global_position = p + var lake: Vector2 = terrain.lake_center + _menu_cam.look_at(Vector3(lake.x * 0.5, 2.0, lake.y * 0.5), Vector3.UP) + return + if not Saves.has_world() or get_tree().paused: + return + _session_time += delta + _autosave_left -= delta + if _autosave_left <= 0.0: + save_now() + +# --- saving ----------------------------------------------------------------- + +## Writes the current game to the active world slot. Returns false if this +## session isn't tied to a slot (e.g. running world.tscn directly). +func save_now() -> bool: + _autosave_left = AUTOSAVE_SECONDS + if not Saves.has_world(): + return false + var ok: bool = Saves.save_state(Saves.current_id, collect_save(), _session_time) + if ok: + _session_time = 0.0 + Actions.notify.emit("Game saved" if ok else "Save FAILED") + return ok + +func _notification(what: int) -> void: + if what == NOTIFICATION_WM_CLOSE_REQUEST and Saves.has_world() and not menu_mode: + save_now() + get_tree().quit() + +static func _xf_to_array(xf: Transform3D) -> Array: + var b := xf.basis + return [b.x.x, b.x.y, b.x.z, b.y.x, b.y.y, b.y.z, b.z.x, b.z.y, b.z.z, xf.origin.x, xf.origin.y, xf.origin.z] + +static func _array_to_xf(a: Array) -> Transform3D: + return Transform3D(Vector3(a[0], a[1], a[2]), Vector3(a[3], a[4], a[5]), Vector3(a[6], a[7], a[8]), Vector3(a[9], a[10], a[11])) + +## Everything the player changed. The rest regenerates from the seed. +func collect_save() -> Dictionary: + var cam: Camera3D = player.get_node("Camera3D") + var harvested := {} + for uid in props.resource_nodes: + var n = props.resource_nodes[uid] + if not is_instance_valid(n) or n.depleted: + harvested[uid] = 0.0 + elif n.hp < n.max_hp: + harvested[uid] = n.hp + var taken: Array = [] + for uid in props.pickups: + var pk = props.pickups[uid] + if not is_instance_valid(pk) or pk.taken: + taken.append(uid) + var pieces: Array = [] + for piece in _pieces.get_children(): + if piece.is_queued_for_deletion(): + continue + var entry := {"item": piece.item_id, "xf": _xf_to_array(piece.global_transform)} + if piece.get("inventory") != null: + entry["inventory"] = piece.inventory.to_data() + pieces.append(entry) + return { + "player": { + "pos": [player.global_position.x, player.global_position.y, player.global_position.z], + "yaw": player.rotation.y, + "pitch": cam.rotation.x, + "inventory": player.inventory.to_data(), + "selected": player.selected, + }, + "harvested": harvested, + "pickups_taken": taken, + "pieces": pieces, + } + +func apply_save(s: Dictionary) -> void: + var p: Dictionary = s.get("player", {}) + if p.has("pos"): + player.global_position = Vector3(p.pos[0], p.pos[1] + 0.05, p.pos[2]) + player.rotation = Vector3(0.0, float(p.get("yaw", 0.0)), 0.0) + player.get_node("Camera3D").rotation.x = float(p.get("pitch", 0.0)) + if p.has("inventory"): + player.inventory.load_data(p.inventory) + player.select(int(p.get("selected", 0))) + var harvested: Dictionary = s.get("harvested", {}) + for uid in harvested: + if props.resource_nodes.has(uid): + props.resource_nodes[uid].restore(float(harvested[uid])) + for uid in s.get("pickups_taken", []): + if props.pickups.has(uid) and is_instance_valid(props.pickups[uid]): + props.pickups[uid].collected() + for e in s.get("pieces", []): + var def: Dictionary = ItemDB.get_item(e.get("item", "")) + if def.is_empty() or not def.has("place"): + continue + var piece := spawn_piece(def, _array_to_xf(e.xf)) + if e.has("inventory") and piece.get("inventory") != null: + piece.inventory.load_data(e.inventory) + func _respawn() -> void: player.global_position = _spawn + Vector3(0.0, 1.5, 0.0) player.velocity = Vector3.ZERO @@ -64,6 +210,42 @@ func _physics_process(_delta: float) -> void: push_warning("[world] player left the map at %s; respawning" % p) _respawn() +# --- building --------------------------------------------------------------- + +## Whether a piece can go at `xf`: inside the valley, on dry fairly flat +## ground, and not overlapping trees, rocks or other pieces. +func placement_valid(def: Dictionary, xf: Transform3D) -> bool: + var p := xf.origin + if not terrain.inside_boundary(p.x, p.z, 1.5): + return false + if p.y < terrain.water_level + 0.15: + return false + var fp: Array = def.place.get("footprint", [1.0, 1.0]) + var hx := float(fp[0]) * 0.5 + var hz := float(fp[1]) * 0.5 + # Ground under the corners must be within reach of the base. + for c in [Vector3(-hx, 0, -hz), Vector3(hx, 0, -hz), Vector3(-hx, 0, hz), Vector3(hx, 0, hz)]: + var w: Vector3 = xf * c + if absf(terrain.sample_height(w.x, w.z) - p.y) > 0.45: + return false + # Overlap test against everything solid except the ground itself. + var box := BoxShape3D.new() + box.size = Vector3(hx * 2.0 - 0.1, 1.0, hz * 2.0 - 0.1) + var q := PhysicsShapeQueryParameters3D.new() + q.shape = box + q.transform = Transform3D(xf.basis, p + Vector3(0, 0.75, 0)) + q.collision_mask = 1 + q.exclude = [terrain.get_node("TerrainBody").get_rid(), player.get_rid()] + return get_world_3d().direct_space_state.intersect_shape(q, 1).is_empty() + +func spawn_piece(def: Dictionary, xf: Transform3D) -> Node3D: + var script: Script = PIECES.get(def.place.piece, preload("res://scripts/building/piece.gd")) + var piece: StaticBody3D = script.new() + piece.setup(def) + _pieces.add_child(piece) + piece.global_transform = xf + return piece + # --- dev screenshot hook ---------------------------------------------------- func _maybe_capture() -> void: @@ -113,4 +295,46 @@ func _maybe_capture() -> void: var path := "%s_%s.png" % [prefix, v[0]] get_viewport().get_texture().get_image().save_png(path) print("[world] saved ", path) + await _capture_survival(prefix, cam) get_tree().quit() + +## Survival views: a small camp in front of the spawn (axe in hand), then the +## inventory screen with a chest open. +func _capture_survival(prefix: String, cam: Camera3D) -> void: + _respawn() + var inv: Inventory = player.inventory + for pair in [["stone_axe", 1], ["stone_pickaxe", 1], ["campfire", 1], ["club", 1], + ["wood", 23], ["stick", 12], ["stone", 17], ["flint", 5]]: + inv.add(pair[0], pair[1]) + var fwd := -player.global_basis.z + var right := player.global_basis.x + var chest: Node = null + for k in [["campfire", 4.5, 0.0], ["workbench", 7.0, -2.8], ["chest", 6.5, 2.4]]: + var p: Vector3 = player.global_position + fwd * float(k[1]) + right * float(k[2]) + p.y = terrain.sample_height(p.x, p.z) + var piece := spawn_piece(ItemDB.get_item(k[0]), Transform3D(Basis(Vector3.UP, player.rotation.y), p)) + if k[0] == "chest": + chest = piece + player.select(0) + cam.rotation.x = -0.22 + await get_tree().create_timer(2.5).timeout + await RenderingServer.frame_post_draw + get_viewport().get_texture().get_image().save_png("%s_camp.png" % prefix) + print("[world] saved %s_camp.png" % prefix) + + chest.inventory.add("wood", 40) + chest.inventory.add("stone", 12) + player.global_position = chest.global_position + fwd * -2.0 + hud._screen.open(chest) + await get_tree().create_timer(0.6).timeout + await RenderingServer.frame_post_draw + get_viewport().get_texture().get_image().save_png("%s_inventory.png" % prefix) + print("[world] saved %s_inventory.png" % prefix) + + hud._screen.close() + hud._menu.open() + await get_tree().create_timer(0.4).timeout + await RenderingServer.frame_post_draw + get_viewport().get_texture().get_image().save_png("%s_menu.png" % prefix) + print("[world] saved %s_menu.png" % prefix) + hud._menu.close() diff --git a/tests/test_save_load.gd b/tests/test_save_load.gd new file mode 100644 index 0000000..93d76aa --- /dev/null +++ b/tests/test_save_load.gd @@ -0,0 +1,143 @@ +extends SceneTree +## Save/load round trip: play a bit (chop a tree, mine a boulder, pick +## something up, build a workbench + chest with items in it, walk somewhere), +## save, throw the world away, load it fresh and check everything came back. +## Also covers world slots (create/list/delete) and a seed being honoured. +## +## Uses its own folder (user://test_worlds) so real saves are never touched. +## Run: godot --headless --path . --script res://tests/test_save_load.gd + +var _fails := 0 +var A: Node # Actions +var S: Node # Saves +var DB: Node # ItemDB + +func check(cond: bool, what: String) -> void: + print((" ok " if cond else " FAIL ") + what) + if not cond: + _fails += 1 + +func _initialize() -> void: + _run.call_deferred() + +func _spawn_world() -> Node3D: + var w: Node3D = load("res://scenes/world.tscn").instantiate() + root.add_child(w) + for _i in range(5): + await physics_frame + return w + +func _run() -> void: + A = root.get_node("Actions") + S = root.get_node("Saves") + DB = root.get_node("ItemDB") + S.root_dir = "user://test_worlds" + for w in S.list_worlds(): + S.delete_world(w.id) + + print("slots") + var id: String = S.create_world("Test Island", 4242) + var id2: String = S.create_world("Test Island", 7) + check(id != id2, "two worlds with the same name get different ids (%s, %s)" % [id, id2]) + check(S.list_worlds().size() == 2, "both worlds are listed") + S.delete_world(id2) + check(S.list_worlds().size() == 1, "deleting a world removes it") + S.current_id = id + + print("play + save") + var world: Node3D = await _spawn_world() + var terrain: Node3D = world.get_node("Terrain") + check(terrain.noise_seed == 4242, "world generated from its saved seed") + var player: CharacterBody3D = world.get_node("Player") + var inv: Inventory = player.inventory + var props: Node3D = world.get_node("Props") + + inv.add("stone_axe", 1); inv.add("stone_pickaxe", 1) + var tree := _nearest(world, player, "tree") + player.global_position = tree.global_position + Vector3(1.5, 0.2, 0) + _hold(player, "stone_axe") + while not tree.depleted: + A.harvest_hit(player, tree, tree.global_position) + var tree_uid: String = tree.uid + var rock := _nearest(world, player, "rock") + var rock_uid: String = rock.uid + player.global_position = rock.global_position + Vector3(2.0, 0.5, 0) + _hold(player, "stone_pickaxe") + A.harvest_hit(player, rock, rock.global_position) + A.harvest_hit(player, rock, rock.global_position) + var rock_hp: float = rock.hp + var pk_uid: String = props.pickups.keys()[0] + var pk: Node3D = props.pickups[pk_uid] + player.global_position = pk.global_position + Vector3(0.4, 0.4, 0) + A.pickup(player, pk) + + inv.add("workbench", 1); inv.add("chest", 1); inv.add("wood", 33) + var spot := Vector3(player.global_position.x + 3.0, 0, player.global_position.z) + spot.y = terrain.sample_height(spot.x, spot.z) + world.spawn_piece(DB.get_item("workbench"), Transform3D(Basis(Vector3.UP, 0.7), spot)) + var chest: Node = world.spawn_piece(DB.get_item("chest"), Transform3D(Basis(), spot + Vector3(0, 0, 3))) + chest.inventory.add("flint", 7) + chest.inventory.add("wood", 12) + player.global_position = Vector3(5.0, terrain.sample_height(5.0, 6.0) + 1.0, 6.0) + player.rotation.y = 1.25 + player.select(3) + var wood_before := inv.count("wood") + var pos_before := player.global_position + check(world.save_now(), "save_now() succeeded") + check(FileAccess.file_exists(S.root_dir.path_join(id).path_join("world.json")), "world.json written") + + world.queue_free() + await process_frame + await process_frame + + print("load") + world = await _spawn_world() + player = world.get_node("Player") + inv = player.inventory + props = world.get_node("Props") + check(player.global_position.distance_to(pos_before) < 0.6, "player back where they saved (%.2f m off)" % player.global_position.distance_to(pos_before)) + check(absf(player.rotation.y - 1.25) < 0.01 and player.selected == 3, "facing and hotbar slot restored") + check(inv.count("wood") == wood_before and inv.count("stone_axe") == 1, "inventory restored") + check(props.resource_nodes[tree_uid].depleted, "felled tree is still a stump") + var r = props.resource_nodes[rock_uid] + check(is_instance_valid(r) and absf(r.hp - rock_hp) < 0.01, "half-mined boulder kept its damage") + var pk2 = props.pickups[pk_uid] + check(not is_instance_valid(pk2) or pk2.taken, "picked-up item stays picked up") + var benches := 0 + var chest2: Node = null + for p in world.get_node("Pieces").get_children(): + if p.item_id == "workbench": + benches += 1 + elif p.item_id == "chest": + chest2 = p + check(benches == 1, "workbench rebuilt") + check(chest2 != null and chest2.inventory.count("flint") == 7 and chest2.inventory.count("wood") == 12, "chest rebuilt with its contents") + check(S.read_meta(id).play_time >= 0.0, "meta updated") + + world.queue_free() + S.delete_world(id) + DirAccess.remove_absolute(S.root_dir) + print("RESULT: %s (%d failures)" % ["PASS" if _fails == 0 else "FAIL", _fails]) + quit(1 if _fails > 0 else 0) + +func _hold(player: Node, id: String) -> void: + var inv: Inventory = player.inventory + for i in range(inv.size()): + var s = inv.get_slot(i) + if s != null and s.id == id: + var tmp = inv.get_slot(0) + inv.set_slot(0, s) + inv.set_slot(i, tmp) + break + player.select(0) + +func _nearest(world: Node, player: Node3D, kind: String) -> Node3D: + var best: Node3D = null + var bd := INF + for n in world.get_tree().get_nodes_in_group("harvestable"): + if n.kind == kind and not n.depleted: + var d := player.global_position.distance_to(n.global_position) + if d < bd: + bd = d + best = n + return best diff --git a/tests/test_save_load.gd.uid b/tests/test_save_load.gd.uid new file mode 100644 index 0000000..0bb5d56 --- /dev/null +++ b/tests/test_save_load.gd.uid @@ -0,0 +1 @@ +uid://dkskynbe6enoo diff --git a/tests/test_survival_core.gd b/tests/test_survival_core.gd new file mode 100644 index 0000000..f043702 --- /dev/null +++ b/tests/test_survival_core.gd @@ -0,0 +1,175 @@ +extends SceneTree +## M1a core loop, end to end through the Actions intent layer: inventory +## stacking, crafting (hand + station), chopping, mining, pickups, placing +## pieces and chest storage. +## +## Run: godot --headless --path . --script res://tests/test_survival_core.gd + +var _fails := 0 +var _actions: Node +var _db: Node + +func check(cond: bool, what: String) -> void: + print((" ok " if cond else " FAIL ") + what) + if not cond: + _fails += 1 + +func _initialize() -> void: + _run.call_deferred() + +func _run() -> void: + # Autoloads aren't resolvable by name in a --script SceneTree before the + # main loop runs, so grab them from the root. + _actions = root.get_node("Actions") + _db = root.get_node("ItemDB") + _test_inventory() + var world: Node3D = load("res://scenes/world.tscn").instantiate() + root.add_child(world) + for _i in range(5): + await physics_frame + await _test_world(world) + print("RESULT: %s (%d failures)" % ["PASS" if _fails == 0 else "FAIL", _fails]) + quit(1 if _fails > 0 else 0) + +func _test_inventory() -> void: + print("inventory") + var inv := Inventory.new(4) + check(inv.add("stick", 120) == 0, "120 sticks fit in 4 slots of 50") + check(inv.get_slot(0).count == 50 and inv.get_slot(2).count == 20, "stacks fill to 50 then spill") + check(inv.add("stone", 200) == 150, "overflow is returned as leftover (1 free slot = 50 fit)") + check(inv.remove("stick", 70) and inv.count("stick") == 50, "remove takes from the end") + check(not inv.remove("flint", 1), "can't remove what you don't have") + var cursor = inv.click(0, null, true) + check(cursor.count == 25 and inv.get_slot(0).count == 25, "right-click picks up half") + cursor = inv.click(0, cursor, false) + check(cursor == null and inv.get_slot(0).count == 50, "left-click merges it back") + +func _test_world(world: Node3D) -> void: + var player: CharacterBody3D = world.get_node("Player") + var terrain: Node3D = world.get_node("Terrain") + var inv: Inventory = player.inventory + + print("crafting") + check(not _actions.craft(player, "stone_axe"), "can't craft an axe with nothing") + inv.add("stick", 4); inv.add("flint", 3); inv.add("stone", 3) + check(_actions.craft(player, "stone_axe") and inv.count("stone_axe") == 1, "craft stone axe") + check(inv.count("stick") == 2 and inv.count("flint") == 1, "axe consumed 2 sticks + 2 flint") + check(_actions.craft(player, "stone_pickaxe") and inv.count("stone_pickaxe") == 1, "craft stone pickaxe") + + print("chopping") + var tree := _nearest(world, player, "tree") + check(tree != null, "found a harvestable tree") + if tree != null: + player.global_position = tree.global_position + Vector3(1.5, 0.2, 0) + _hold(player, "stone_axe") + var hits := 0 + while not tree.depleted and hits < 20: + _actions.harvest_hit(player, tree, tree.global_position + Vector3(0, 1, 0)) + hits += 1 + check(tree.depleted, "tree felled after %d hits" % hits) + check(inv.count("wood") >= 4, "got wood from the tree (%d)" % inv.count("wood")) + check(not _actions.harvest_hit(player, tree, tree.global_position), "a stump can't be chopped again") + + print("mining") + var rock := _nearest(world, player, "rock") + check(rock != null, "found a mineable boulder") + if rock != null: + player.global_position = rock.global_position + Vector3(2.0, 0.5, 0) + _hold(player, "stone_axe") + check(not _actions.harvest_hit(player, rock, rock.global_position), "an axe can't mine rock") + _hold(player, "stone_pickaxe") + var stone_before := inv.count("stone") + var hits := 0 + while is_instance_valid(rock) and not rock.depleted and hits < 20: + _actions.harvest_hit(player, rock, rock.global_position) + hits += 1 + check(inv.count("stone") > stone_before and inv.count("flint") >= 1, "mined stone and flint") + + print("pickups") + var pk: Node3D = null + var best := INF + for p in world.get_node("Props/Pickups").get_children(): + var d := player.global_position.distance_to(p.global_position) + if d < best: + best = d + pk = p + check(pk != null, "pickups exist on the ground") + if pk != null: + var id: String = pk.item_id + var before := inv.count(id) + player.global_position = pk.global_position + Vector3(30.0, 0.5, 0) + check(not _actions.pickup(player, pk), "out-of-reach pickup is refused") + player.global_position = pk.global_position + Vector3(0.5, 0.5, 0) + var picked: bool = _actions.pickup(player, pk) + check(picked and inv.count(id) == before + 1, "picked up a %s" % id) + + print("building") + inv.add("wood", 30); inv.add("flint", 4); inv.add("stick", 8) + check(not _actions.craft(player, "chest"), "chest needs a workbench") + check(_actions.craft(player, "workbench"), "craft workbench by hand") + var spot := _flat_spot(terrain, world) + player.global_position = spot + Vector3(0, 0.5, 3.0) + var slot := _hold(player, "workbench") + var bench_xf := Transform3D(Basis(), spot) + check(world.placement_valid(ItemDB_get(player, "workbench"), bench_xf), "flat clear ground is valid") + check(_actions.place(player, slot, bench_xf, world), "placed the workbench") + check(inv.count("workbench") == 0, "placing consumed the item") + await physics_frame # let the physics server register the new body + check(not world.placement_valid(ItemDB_get(player, "workbench"), bench_xf), "can't place on top of it") + check(_actions.craft(player, "chest"), "chest crafts next to the workbench") + + var chest_xf := Transform3D(Basis(), Vector3(spot.x + 2.6, terrain.sample_height(spot.x + 2.6, spot.z), spot.z)) + slot = _hold(player, "chest") + check(_actions.place(player, slot, chest_xf, world), "placed the chest") + var chest: Node = null + for c in world.get_node("Pieces").get_children(): + if c.item_id == "chest": + chest = c + check(chest != null, "chest exists in the world") + if chest != null: + var wood_slot := -1 + for i in range(inv.size()): + if inv.get_slot(i) != null and inv.get_slot(i).id == "wood": + wood_slot = i + var moving: int = inv.get_slot(wood_slot).count + _actions.transfer(player, inv, wood_slot, chest.inventory, chest) + check(chest.inventory.count("wood") == moving, "shift-click stored %d wood in the chest" % moving) + +func ItemDB_get(_p, id: String) -> Dictionary: + return _db.get_item(id) + +## Puts one of `id` into hotbar slot 0 and selects it. Returns the slot. +func _hold(player: Node, id: String) -> int: + var inv: Inventory = player.inventory + for i in range(inv.size()): + var s = inv.get_slot(i) + if s != null and s.id == id: + var tmp = inv.get_slot(0) + inv.set_slot(0, s) + inv.set_slot(i, tmp) + break + player.select(0) + return 0 + +func _nearest(world: Node, player: Node3D, kind: String) -> Node3D: + var best: Node3D = null + var bd := INF + for n in world.get_tree().get_nodes_in_group("harvestable"): + if n.kind == kind and not n.depleted: + var d := player.global_position.distance_to(n.global_position) + if d < bd: + bd = d + best = n + return best + +func _flat_spot(terrain: Node3D, world: Node3D) -> Vector3: + var def: Dictionary = _db.get_item("workbench") + for r in range(0, 60, 3): + for k in range(12): + var a := TAU * k / 12.0 + var x := cos(a) * r + var z := sin(a) * r + var p := Vector3(x, terrain.sample_height(x, z), z) + if world.placement_valid(def, Transform3D(Basis(), p)) and world.placement_valid(def, Transform3D(Basis(), p + Vector3(2.6, 0, 0))): + return p + return Vector3.ZERO diff --git a/tests/test_survival_core.gd.uid b/tests/test_survival_core.gd.uid new file mode 100644 index 0000000..b279aaa --- /dev/null +++ b/tests/test_survival_core.gd.uid @@ -0,0 +1 @@ +uid://b8vgvxcn2b5ho diff --git a/tests/test_terrain_edges.gd b/tests/test_terrain_edges.gd index 475901d..efcfe66 100644 --- a/tests/test_terrain_edges.gd +++ b/tests/test_terrain_edges.gd @@ -1,19 +1,39 @@ extends SceneTree -## Terrain edge check: there must be no ground below the inland waterline in the -## band just inside the boundary wall. Ground like that shows up as inland water -## hanging over a drop (the lake water surface stops at the wall) and makes pits -## the player can fall into and not climb out of. +## Terrain edge check, for several seeds: in the band just inside the boundary +## wall there must be (1) no water deeper than you can wade out of, and (2) no +## water without a bank, i.e. ground that drops away outward. The inland water +## surface stops at the wall, so unbanked water hangs in the air over the drop. +## This is the bug where the player fell into floating water by the sea cliff. +## Shallow ponds at the foot of the mountains are fine: the slope holds them. ## ## Run: godot --headless --path . --script res://tests/test_terrain_edges.gd -const BAND := 10.0 # metres inside the wall to check +const BAND := 10.0 # metres inside the wall to check +const MAX_EDGE_DEPTH := 1.0 # deepest allowed water near the edge (wadeable) +const BANK_CHECK := 12.0 # how far outward the bank must rise above water + +## The original island plus a spread of other seeds: new worlds get random +## seeds, so the edge rule has to hold for any of them. +const SEEDS := [1337, 1, 42, 777, 4242, 9001, 31337, 123456] func _initialize() -> void: - var terrain: Node3D = load("res://scripts/world/terrain.gd").new() - root.add_child(terrain) # builds in _ready - _run.call_deferred(terrain) + _all.call_deferred() -func _run(terrain: Node3D) -> void: +func _all() -> void: + var failed := 0 + for s in SEEDS: + var terrain: Node3D = load("res://scripts/world/terrain.gd").new() + terrain.noise_seed = s + root.add_child(terrain) # builds in _ready + var bad := _check(terrain) + print("seed %6d: %s" % [s, "ok" if bad == 0 else "%d bad samples" % bad]) + if bad > 0: + failed += 1 + terrain.free() + print("RESULT: %s (%d/%d seeds clean)" % ["PASS" if failed == 0 else "FAIL", SEEDS.size() - failed, SEEDS.size()]) + quit(1 if failed > 0 else 0) + +func _check(terrain: Node3D) -> int: var bad := 0 var worst := INF var worst_at := Vector2.ZERO @@ -24,7 +44,16 @@ func _run(terrain: Node3D) -> void: while z < half: if terrain.inside_boundary(x, z, 0.0) and not terrain.inside_boundary(x, z, BAND): var h: float = terrain.sample_height(x, z) - if h < terrain.water_level + 0.2: + # Hazard 1: a pit too deep to wade out of near the edge. + var too_deep: bool = h < terrain.water_level - MAX_EDGE_DEPTH + # Hazard 2: water that isn't held in by a bank. The inland water + # surface stops at the wall, so wet ground must rise again just + # outward, or the water edge hangs over a drop. + var unbanked := false + if h < terrain.water_level: + var out := Vector2(x, z).normalized() * BANK_CHECK + unbanked = terrain.sample_height(x + out.x, z + out.y) < terrain.water_level + if too_deep or unbanked: bad += 1 if h < worst: worst = h @@ -32,8 +61,6 @@ func _run(terrain: Node3D) -> void: z += 1.0 x += 1.0 if bad > 0: - print("FAIL: %d samples near the wall are below the waterline; lowest %.1f m at (%.0f, %.0f), sea weight %.2f" % [ - bad, worst, worst_at.x, worst_at.y, terrain.sea_weight(worst_at.x, worst_at.y)]) - else: - print("PASS: ground near the wall stays above the waterline") - quit(1 if bad > 0 else 0) + print(" lowest %.1f m at (%.0f, %.0f), sea weight %.2f" % [ + worst, worst_at.x, worst_at.y, terrain.sea_weight(worst_at.x, worst_at.y)]) + return bad diff --git a/tests/test_terrain_edges.gd.uid b/tests/test_terrain_edges.gd.uid new file mode 100644 index 0000000..5d7f69f --- /dev/null +++ b/tests/test_terrain_edges.gd.uid @@ -0,0 +1 @@ +uid://s8y1xr0ylelw