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.
This commit is contained in:
32
data/items.json
Normal file
32
data/items.json
Normal file
@@ -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] } }
|
||||||
|
]
|
||||||
|
}
|
||||||
16
data/recipes.json
Normal file
16
data/recipes.json
Normal file
@@ -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 } }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -17,10 +17,20 @@ download — the M0 world is generated in code.
|
|||||||
```bash
|
```bash
|
||||||
# Boundary: tries to walk/jump out of the valley in 24 directions
|
# Boundary: tries to walk/jump out of the valley in 24 directions
|
||||||
godot --headless --path . --fixed-fps 30 --script res://tests/test_boundary.gd
|
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
|
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
|
## 2. Get the project onto the machine
|
||||||
You have the folder (from the zip, or a git remote). Then:
|
You have the folder (from the zip, or a git remote). Then:
|
||||||
|
|
||||||
|
|||||||
161
docs/SURVIVAL_DESIGN.md
Normal file
161
docs/SURVIVAL_DESIGN.md
Normal file
@@ -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/<id>/` (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.
|
||||||
@@ -16,10 +16,16 @@ compatibility/default_parent_skeleton_in_mesh_instance_3d=true
|
|||||||
|
|
||||||
config/name="Valheimlike"
|
config/name="Valheimlike"
|
||||||
config/description="Low-poly survival game — Valheim look, Rust depth, self-hosted, moddable."
|
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/features=PackedStringArray("4.7", "Forward Plus")
|
||||||
config/icon="res://icon.svg"
|
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]
|
[display]
|
||||||
|
|
||||||
window/size/viewport_width=1280
|
window/size/viewport_width=1280
|
||||||
|
|||||||
6
scenes/title.tscn
Normal file
6
scenes/title.tscn
Normal file
@@ -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")
|
||||||
@@ -43,5 +43,5 @@ offset_left = 16.0
|
|||||||
offset_top = 12.0
|
offset_top = 12.0
|
||||||
offset_right = 600.0
|
offset_right = 600.0
|
||||||
offset_bottom = 60.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")
|
label_settings = SubResource("Label_1")
|
||||||
|
|||||||
99
scripts/building/campfire.gd
Normal file
99
scripts/building/campfire.gd
Normal file
@@ -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
|
||||||
1
scripts/building/campfire.gd.uid
Normal file
1
scripts/building/campfire.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://di4xtvyvo151n
|
||||||
15
scripts/building/chest.gd
Normal file
15
scripts/building/chest.gd
Normal file
@@ -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)
|
||||||
1
scripts/building/chest.gd.uid
Normal file
1
scripts/building/chest.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://yma48ens54gt
|
||||||
36
scripts/building/piece.gd
Normal file
36
scripts/building/piece.gd
Normal file
@@ -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
|
||||||
1
scripts/building/piece.gd.uid
Normal file
1
scripts/building/piece.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://c65x7w4vb84ua
|
||||||
11
scripts/building/workbench.gd
Normal file
11
scripts/building/workbench.gd
Normal file
@@ -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)
|
||||||
1
scripts/building/workbench.gd.uid
Normal file
1
scripts/building/workbench.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://ciu8ajr75fv2a
|
||||||
149
scripts/core/actions.gd
Normal file
149
scripts/core/actions.gd
Normal file
@@ -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:<name>".
|
||||||
|
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)
|
||||||
1
scripts/core/actions.gd.uid
Normal file
1
scripts/core/actions.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://nwo7dhy67nkv
|
||||||
167
scripts/core/inventory.gd
Normal file
167
scripts/core/inventory.gd
Normal file
@@ -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()
|
||||||
1
scripts/core/inventory.gd.uid
Normal file
1
scripts/core/inventory.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://b6q13eerjpee2
|
||||||
135
scripts/core/item_db.gd
Normal file
135
scripts/core/item_db.gd
Normal file
@@ -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()
|
||||||
1
scripts/core/item_db.gd.uid
Normal file
1
scripts/core/item_db.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://bv6di5sbrx4fj
|
||||||
110
scripts/core/saves.gd
Normal file
110
scripts/core/saves.gd
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
extends Node
|
||||||
|
## Autoload "Saves": world slots on disk.
|
||||||
|
##
|
||||||
|
## Each world is a folder under `root_dir` (user://worlds/<id>/) 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 {}
|
||||||
1
scripts/core/saves.gd.uid
Normal file
1
scripts/core/saves.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://c5tln66oif2hb
|
||||||
160
scripts/items/item_meshes.gd
Normal file
160
scripts/items/item_meshes.gd
Normal file
@@ -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)
|
||||||
1
scripts/items/item_meshes.gd.uid
Normal file
1
scripts/items/item_meshes.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://byqcvpim33bkk
|
||||||
179
scripts/player/hands.gd
Normal file
179
scripts/player/hands.gd
Normal file
@@ -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())
|
||||||
1
scripts/player/hands.gd.uid
Normal file
1
scripts/player/hands.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://dx85p3jnm5f65
|
||||||
@@ -18,19 +18,46 @@ const FLOAT_DEPTH := 1.3
|
|||||||
|
|
||||||
@onready var camera: Camera3D = $Camera3D
|
@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)
|
var _gravity: float = ProjectSettings.get_setting("physics/3d/default_gravity", 9.8)
|
||||||
|
|
||||||
func _ready() -> void:
|
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:
|
func _unhandled_input(event: InputEvent) -> void:
|
||||||
|
if ui_open:
|
||||||
|
return
|
||||||
if event is InputEventMouseMotion and Input.mouse_mode == Input.MOUSE_MODE_CAPTURED:
|
if event is InputEventMouseMotion and Input.mouse_mode == Input.MOUSE_MODE_CAPTURED:
|
||||||
rotate_y(-event.relative.x * mouse_sensitivity)
|
rotate_y(-event.relative.x * mouse_sensitivity)
|
||||||
camera.rotate_x(-event.relative.y * mouse_sensitivity)
|
camera.rotate_x(-event.relative.y * mouse_sensitivity)
|
||||||
camera.rotation.x = clamp(camera.rotation.x, -1.4, 1.4)
|
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:
|
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
|
Input.mouse_mode = Input.MOUSE_MODE_CAPTURED
|
||||||
|
|
||||||
func _physics_process(delta: float) -> void:
|
func _physics_process(delta: float) -> void:
|
||||||
@@ -43,13 +70,14 @@ func _physics_process(delta: float) -> void:
|
|||||||
elif not is_on_floor():
|
elif not is_on_floor():
|
||||||
velocity.y -= _gravity * delta
|
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
|
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.
|
# Swim-jump: kick up out of the water to climb onto a bank.
|
||||||
velocity.y = jump_velocity
|
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 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
|
var current_speed := sprint_speed if Input.is_key_pressed(KEY_SHIFT) else speed
|
||||||
|
|||||||
139
scripts/ui/hud.gd
Normal file
139
scripts/ui/hud.gd
Normal file
@@ -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)
|
||||||
1
scripts/ui/hud.gd.uid
Normal file
1
scripts/ui/hud.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://c4dm11w8jqsiv
|
||||||
263
scripts/ui/inventory_screen.gd
Normal file
263
scripts/ui/inventory_screen.gd
Normal file
@@ -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()
|
||||||
1
scripts/ui/inventory_screen.gd.uid
Normal file
1
scripts/ui/inventory_screen.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://cuoylqtlj5dmy
|
||||||
167
scripts/ui/pause_menu.gd
Normal file
167
scripts/ui/pause_menu.gd
Normal file
@@ -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"
|
||||||
1
scripts/ui/pause_menu.gd.uid
Normal file
1
scripts/ui/pause_menu.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://cufp0428mqob7
|
||||||
66
scripts/ui/slot.gd
Normal file
66
scripts/ui/slot.gd
Normal file
@@ -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()
|
||||||
1
scripts/ui/slot.gd.uid
Normal file
1
scripts/ui/slot.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://bfaf1tn6bi3if
|
||||||
35
scripts/ui/theme.gd
Normal file
35
scripts/ui/theme.gd
Normal file
@@ -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
|
||||||
1
scripts/ui/theme.gd.uid
Normal file
1
scripts/ui/theme.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://jhtk2nvq1tft
|
||||||
238
scripts/ui/title_screen.gd
Normal file
238
scripts/ui/title_screen.gd
Normal file
@@ -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)
|
||||||
1
scripts/ui/title_screen.gd.uid
Normal file
1
scripts/ui/title_screen.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://dxm34hvuh7wk1
|
||||||
35
scripts/world/pickup.gd
Normal file
35
scripts/world/pickup.gd
Normal file
@@ -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()
|
||||||
1
scripts/world/pickup.gd.uid
Normal file
1
scripts/world/pickup.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://d027tu0w5dt0r
|
||||||
@@ -11,6 +11,8 @@ extends Node3D
|
|||||||
## dense to place over the whole map at once.
|
## dense to place over the whole map at once.
|
||||||
|
|
||||||
const ProcMesh = preload("res://scripts/world/proc_mesh.gd")
|
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 CELL := 48.0
|
||||||
const GRASS_TILE := 12.0
|
const GRASS_TILE := 12.0
|
||||||
@@ -32,6 +34,12 @@ var _spawn := Vector3.ZERO
|
|||||||
var _forest_noise := FastNoiseLite.new()
|
var _forest_noise := FastNoiseLite.new()
|
||||||
var _meadow_noise := FastNoiseLite.new()
|
var _meadow_noise := FastNoiseLite.new()
|
||||||
var _colliders: StaticBody3D
|
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 _bark_mat: StandardMaterial3D
|
||||||
var _leaf_mat: ShaderMaterial
|
var _leaf_mat: ShaderMaterial
|
||||||
@@ -48,6 +56,7 @@ func place_on(t: Node3D, player: Node3D, spawn: Vector3) -> void:
|
|||||||
terrain = t
|
terrain = t
|
||||||
_player = player
|
_player = player
|
||||||
_spawn = spawn
|
_spawn = spawn
|
||||||
|
world_seed = terrain.noise_seed # one seed drives terrain and scenery
|
||||||
_forest_noise.seed = world_seed + 11
|
_forest_noise.seed = world_seed + 11
|
||||||
_forest_noise.noise_type = FastNoiseLite.TYPE_SIMPLEX_SMOOTH
|
_forest_noise.noise_type = FastNoiseLite.TYPE_SIMPLEX_SMOOTH
|
||||||
_forest_noise.frequency = 0.011
|
_forest_noise.frequency = 0.011
|
||||||
@@ -60,12 +69,16 @@ func place_on(t: Node3D, player: Node3D, spawn: Vector3) -> void:
|
|||||||
_colliders = StaticBody3D.new()
|
_colliders = StaticBody3D.new()
|
||||||
_colliders.name = "PropColliders"
|
_colliders.name = "PropColliders"
|
||||||
add_child(_colliders)
|
add_child(_colliders)
|
||||||
|
_resources = Node3D.new()
|
||||||
|
_resources.name = "Resources"
|
||||||
|
add_child(_resources)
|
||||||
|
|
||||||
_make_materials()
|
_make_materials()
|
||||||
_scatter_trees()
|
_scatter_trees()
|
||||||
_scatter_rocks()
|
_scatter_rocks()
|
||||||
_scatter_bushes()
|
_scatter_bushes()
|
||||||
_place_logs()
|
_place_logs()
|
||||||
|
_scatter_pickups()
|
||||||
|
|
||||||
_grass_root = Node3D.new()
|
_grass_root = Node3D.new()
|
||||||
_grass_root.name = "Grass"
|
_grass_root.name = "Grass"
|
||||||
@@ -141,18 +154,58 @@ func _jittered(spacing: float, rng: RandomNumberGenerator) -> PackedVector2Array
|
|||||||
func _forest(x: float, z: float) -> float:
|
func _forest(x: float, z: float) -> float:
|
||||||
return smoothstep(-0.15, 0.35, _forest_noise.get_noise_2d(x, z))
|
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)
|
var key := Vector3i(floori(x / CELL), floori(z / CELL), vi)
|
||||||
if not groups.has(key):
|
if not groups.has(key):
|
||||||
groups[key] = []
|
groups[key] = []
|
||||||
groups[key].append(xf)
|
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()
|
var root := Node3D.new()
|
||||||
root.name = label
|
root.name = label
|
||||||
add_child(root)
|
add_child(root)
|
||||||
|
var out := {}
|
||||||
for key: Vector3i in groups:
|
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:
|
func _mm_instance(mesh: Mesh, xforms: Array, colors: Array, shadows: bool) -> MultiMeshInstance3D:
|
||||||
var mm := MultiMesh.new()
|
var mm := MultiMesh.new()
|
||||||
@@ -206,6 +259,7 @@ func _scatter_trees() -> void:
|
|||||||
var rng := RandomNumberGenerator.new()
|
var rng := RandomNumberGenerator.new()
|
||||||
rng.seed = world_seed + 1
|
rng.seed = world_seed + 1
|
||||||
var groups := {}
|
var groups := {}
|
||||||
|
var pending: Array = []
|
||||||
for p in _jittered(tree_spacing, rng):
|
for p in _jittered(tree_spacing, rng):
|
||||||
# Draw every random number up front so the sequence never depends on
|
# Draw every random number up front so the sequence never depends on
|
||||||
# which branch below is taken (stable layout when rules are tweaked).
|
# which branch below is taken (stable layout when rules are tweaked).
|
||||||
@@ -227,15 +281,25 @@ func _scatter_trees() -> void:
|
|||||||
else:
|
else:
|
||||||
vi = birches[int(pick * 7919.0) % birches.size()]
|
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))
|
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):
|
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 v: Dictionary = variants[vi]
|
||||||
var cap := CapsuleShape3D.new()
|
var cap := CapsuleShape3D.new()
|
||||||
cap.radius = v.trunk_r * s
|
cap.radius = v.trunk_r * s
|
||||||
cap.height = maxf(v.trunk_h * s, cap.radius * 2.0 + 0.1)
|
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)
|
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
|
## 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.
|
## 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()
|
var rng := RandomNumberGenerator.new()
|
||||||
rng.seed = world_seed + 2
|
rng.seed = world_seed + 2
|
||||||
var groups := {}
|
var groups := {}
|
||||||
|
var pending: Array = []
|
||||||
|
|
||||||
# Small stones: everywhere, more on slopes and in the shallows.
|
# Small stones: everywhere, more on slopes and in the shallows.
|
||||||
for p in _jittered(6.0, rng):
|
for p in _jittered(6.0, rng):
|
||||||
@@ -292,7 +357,7 @@ func _scatter_rocks() -> void:
|
|||||||
continue
|
continue
|
||||||
if roll > 0.07 + _forest(p.x, p.y) * 0.08:
|
if roll > 0.07 + _forest(p.x, p.y) * 0.08:
|
||||||
continue
|
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.
|
# Cliff outcrops: big slabs breaking up the steep mountain faces.
|
||||||
for p in _jittered(8.0, rng):
|
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)))
|
_group(groups, p.x, p.y, vi, Transform3D(b, Vector3(p.x, h - 1.0, p.y)))
|
||||||
placed += 1
|
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,
|
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 b := _ground_basis(n, follow, yaw, s)
|
||||||
var pos := Vector3(p.x, h - sink * s, p.y)
|
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):
|
if s >= 0.55 and terrain.inside_boundary(p.x, p.y, -6.0):
|
||||||
var pts := PackedVector3Array()
|
var pts := PackedVector3Array()
|
||||||
for q: Vector3 in hulls[vi]:
|
for q: Vector3 in hulls[vi]:
|
||||||
pts.append(q * s)
|
pts.append(q * s)
|
||||||
var shape := ConvexPolygonShape3D.new()
|
var shape := ConvexPolygonShape3D.new()
|
||||||
shape.points = pts
|
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 ---------------------------------------------------
|
# --- bushes, logs, stumps ---------------------------------------------------
|
||||||
|
|
||||||
@@ -415,6 +490,54 @@ func _place_logs() -> void:
|
|||||||
col_xf.origin.y += (shape as CylinderShape3D).height * 0.5 - 0.3
|
col_xf.origin.y += (shape as CylinderShape3D).height * 0.5 - 0.3
|
||||||
_add_shape(shape, col_xf)
|
_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 --------------------------------------------------------
|
# --- grass streaming --------------------------------------------------------
|
||||||
|
|
||||||
func _update_grass(budget: int) -> void:
|
func _update_grass(budget: int) -> void:
|
||||||
|
|||||||
176
scripts/world/resource_node.gd
Normal file
176
scripts/world/resource_node.gd
Normal file
@@ -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)
|
||||||
1
scripts/world/resource_node.gd.uid
Normal file
1
scripts/world/resource_node.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://ctglldcf8jo4c
|
||||||
@@ -69,6 +69,10 @@ var _heights := PackedFloat32Array()
|
|||||||
var _normals := PackedVector3Array()
|
var _normals := PackedVector3Array()
|
||||||
|
|
||||||
func _ready() -> void:
|
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()
|
_build()
|
||||||
|
|
||||||
# --- height function --------------------------------------------------------
|
# --- height function --------------------------------------------------------
|
||||||
|
|||||||
@@ -18,10 +18,45 @@ extends Node3D
|
|||||||
@onready var player: CharacterBody3D = $Player
|
@onready var player: CharacterBody3D = $Player
|
||||||
@onready var sun: DirectionalLight3D = $Sun
|
@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 := Vector3.ZERO
|
||||||
var _spawn_yaw := 0.0
|
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:
|
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()
|
_aim_sun()
|
||||||
player.set_physics_process(false)
|
player.set_physics_process(false)
|
||||||
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)
|
var to_lake := lake - Vector2(_spawn.x, _spawn.z)
|
||||||
_spawn_yaw = atan2(-to_lake.x, -to_lake.y)
|
_spawn_yaw = atan2(-to_lake.x, -to_lake.y)
|
||||||
player.water_level = terrain.water_level
|
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))
|
print("[world] scenery placed in %d ms" % (Time.get_ticks_msec() - t0))
|
||||||
|
if menu_mode:
|
||||||
|
return
|
||||||
_respawn()
|
_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)
|
player.set_physics_process(true)
|
||||||
set_physics_process(true)
|
set_physics_process(true)
|
||||||
_maybe_capture()
|
_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:
|
func _respawn() -> void:
|
||||||
player.global_position = _spawn + Vector3(0.0, 1.5, 0.0)
|
player.global_position = _spawn + Vector3(0.0, 1.5, 0.0)
|
||||||
player.velocity = Vector3.ZERO
|
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)
|
push_warning("[world] player left the map at %s; respawning" % p)
|
||||||
_respawn()
|
_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 ----------------------------------------------------
|
# --- dev screenshot hook ----------------------------------------------------
|
||||||
|
|
||||||
func _maybe_capture() -> void:
|
func _maybe_capture() -> void:
|
||||||
@@ -113,4 +295,46 @@ func _maybe_capture() -> void:
|
|||||||
var path := "%s_%s.png" % [prefix, v[0]]
|
var path := "%s_%s.png" % [prefix, v[0]]
|
||||||
get_viewport().get_texture().get_image().save_png(path)
|
get_viewport().get_texture().get_image().save_png(path)
|
||||||
print("[world] saved ", path)
|
print("[world] saved ", path)
|
||||||
|
await _capture_survival(prefix, cam)
|
||||||
get_tree().quit()
|
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()
|
||||||
|
|||||||
143
tests/test_save_load.gd
Normal file
143
tests/test_save_load.gd
Normal file
@@ -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
|
||||||
1
tests/test_save_load.gd.uid
Normal file
1
tests/test_save_load.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://dkskynbe6enoo
|
||||||
175
tests/test_survival_core.gd
Normal file
175
tests/test_survival_core.gd
Normal file
@@ -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
|
||||||
1
tests/test_survival_core.gd.uid
Normal file
1
tests/test_survival_core.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://b8vgvxcn2b5ho
|
||||||
@@ -1,19 +1,39 @@
|
|||||||
extends SceneTree
|
extends SceneTree
|
||||||
## Terrain edge check: there must be no ground below the inland waterline in the
|
## Terrain edge check, for several seeds: in the band just inside the boundary
|
||||||
## band just inside the boundary wall. Ground like that shows up as inland water
|
## wall there must be (1) no water deeper than you can wade out of, and (2) no
|
||||||
## hanging over a drop (the lake water surface stops at the wall) and makes pits
|
## water without a bank, i.e. ground that drops away outward. The inland water
|
||||||
## the player can fall into and not climb out of.
|
## 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
|
## 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:
|
func _initialize() -> void:
|
||||||
var terrain: Node3D = load("res://scripts/world/terrain.gd").new()
|
_all.call_deferred()
|
||||||
root.add_child(terrain) # builds in _ready
|
|
||||||
_run.call_deferred(terrain)
|
|
||||||
|
|
||||||
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 bad := 0
|
||||||
var worst := INF
|
var worst := INF
|
||||||
var worst_at := Vector2.ZERO
|
var worst_at := Vector2.ZERO
|
||||||
@@ -24,7 +44,16 @@ func _run(terrain: Node3D) -> void:
|
|||||||
while z < half:
|
while z < half:
|
||||||
if terrain.inside_boundary(x, z, 0.0) and not terrain.inside_boundary(x, z, BAND):
|
if terrain.inside_boundary(x, z, 0.0) and not terrain.inside_boundary(x, z, BAND):
|
||||||
var h: float = terrain.sample_height(x, z)
|
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
|
bad += 1
|
||||||
if h < worst:
|
if h < worst:
|
||||||
worst = h
|
worst = h
|
||||||
@@ -32,8 +61,6 @@ func _run(terrain: Node3D) -> void:
|
|||||||
z += 1.0
|
z += 1.0
|
||||||
x += 1.0
|
x += 1.0
|
||||||
if bad > 0:
|
if bad > 0:
|
||||||
print("FAIL: %d samples near the wall are below the waterline; lowest %.1f m at (%.0f, %.0f), sea weight %.2f" % [
|
print(" 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)])
|
worst, worst_at.x, worst_at.y, terrain.sea_weight(worst_at.x, worst_at.y)])
|
||||||
else:
|
return bad
|
||||||
print("PASS: ground near the wall stays above the waterline")
|
|
||||||
quit(1 if bad > 0 else 0)
|
|
||||||
|
|||||||
1
tests/test_terrain_edges.gd.uid
Normal file
1
tests/test_terrain_edges.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://s8y1xr0ylelw
|
||||||
Reference in New Issue
Block a user