From fc7eb3cfed724912b9edec1c0899eef662480975 Mon Sep 17 00:00:00 2001 From: Paul Bailey Date: Wed, 4 Nov 2026 01:19:41 +0000 Subject: [PATCH] M1b: survival stats, day/night, fire fuel, cooking, foraging, torches, death + grave - Health, stamina, hunger, warmth and wet with HUD bars, status tags and clock - 20-minute day/night cycle with moon, stars and lit clouds - Campfire fuel (2 min start, 15 min max), heat radius, cooking needs a lit fire - Berry bushes (regrow 10 min), mushrooms, roasted mushroom, torch - Stamina costs for sprint, jump and harvest hits - Death respawns at island start and leaves a grave bag - Saves include stats, time of day, fire fuel, regrowth timers and graves - New test_survival_stats suite; tests fail if a script error aborts them --- data/items.json | 220 ++++++++++++++++++++++++++++----- data/recipes.json | 91 ++++++++++++-- docs/ROADMAP.md | 11 +- docs/SETUP_ON_NEW_MACHINE.md | 2 + docs/SURVIVAL_DESIGN.md | 24 +++- scenes/world.tscn | 2 +- scripts/building/campfire.gd | 89 ++++++++++--- scripts/building/grave.gd | 23 ++++ scripts/building/grave.gd.uid | 1 + scripts/core/actions.gd | 49 +++++++- scripts/items/item_meshes.gd | 87 +++++++++++++ scripts/player/hands.gd | 46 ++++++- scripts/player/player.gd | 20 ++- scripts/player/survival.gd | 127 +++++++++++++++++++ scripts/player/survival.gd.uid | 1 + scripts/ui/hud.gd | 62 ++++++++++ scripts/ui/inventory_screen.gd | 17 ++- scripts/ui/pause_menu.gd | 3 +- scripts/world/day_night.gd | 116 +++++++++++++++++ scripts/world/day_night.gd.uid | 1 + scripts/world/forage.gd | 59 +++++++++ scripts/world/forage.gd.uid | 1 + scripts/world/props.gd | 51 +++++++- scripts/world/world.gd | 93 ++++++++++++-- shaders/sky.gdshader | 32 +++-- tests/test_save_load.gd | 3 + tests/test_survival_core.gd | 3 + tests/test_survival_stats.gd | 189 ++++++++++++++++++++++++++++ 28 files changed, 1326 insertions(+), 97 deletions(-) create mode 100644 scripts/building/grave.gd create mode 100644 scripts/building/grave.gd.uid create mode 100644 scripts/player/survival.gd create mode 100644 scripts/player/survival.gd.uid create mode 100644 scripts/world/day_night.gd create mode 100644 scripts/world/day_night.gd.uid create mode 100644 scripts/world/forage.gd create mode 100644 scripts/world/forage.gd.uid create mode 100644 tests/test_survival_stats.gd diff --git a/data/items.json b/data/items.json index 3c1b9ec..07c6e88 100644 --- a/data/items.json +++ b/data/items.json @@ -1,32 +1,196 @@ { "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] } } + { + "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 + ] + } + }, + { + "id": "fiber", + "name": "Plant Fiber", + "category": "resource", + "stack": 50, + "description": "Tough stems from bushes. Twine, torch wrapping.", + "mesh": "fiber", + "fuel": 5 + }, + { + "id": "berries", + "name": "Berries", + "category": "food", + "stack": 30, + "description": "A handful of wild berries. Right-click to eat.", + "mesh": "berries", + "food": { + "hunger": 9, + "health": 2 + } + }, + { + "id": "mushroom", + "name": "Mushroom", + "category": "food", + "stack": 30, + "description": "Edible raw, much better roasted at a campfire.", + "mesh": "mushroom", + "food": { + "hunger": 6, + "health": 0 + } + }, + { + "id": "roasted_mushroom", + "name": "Roasted Mushroom", + "category": "food", + "stack": 30, + "description": "Smoky and filling. Right-click to eat.", + "mesh": "roasted_mushroom", + "food": { + "hunger": 24, + "health": 8 + } + }, + { + "id": "torch", + "name": "Torch", + "category": "tool", + "stack": 1, + "description": "Hold it for light and a little warmth.", + "mesh": "torch", + "light": { + "range": 10.0, + "energy": 1.6, + "heat": 0.3 + } + }, + { + "id": "grave", + "name": "Grave", + "category": "placeable", + "stack": 1, + "description": "Where you fell. Your belongings are inside.", + "mesh": "grave", + "hidden": true, + "place": { + "piece": "grave", + "footprint": [ + 1.0, + 1.6 + ] + } + } ] } diff --git a/data/recipes.json b/data/recipes.json index fb49454..ca13359 100644 --- a/data/recipes.json +++ b/data/recipes.json @@ -1,16 +1,83 @@ { "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 } } + { + "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 + } + }, + { + "id": "torch", + "output": "torch", + "count": 1, + "station": "", + "inputs": { + "stick": 1, + "fiber": 2 + } + }, + { + "id": "roasted_mushroom", + "output": "roasted_mushroom", + "count": 1, + "station": "campfire", + "inputs": { + "mushroom": 1 + } + } ] } diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index d058f87..05c988b 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -23,10 +23,13 @@ actually run and see. Timelines assume solo dev + heavy AI assistance, steady ## M1 — Survival loop (single-player) · ~3–5 weeks **Goal:** the core game verbs. -- [ ] Resource nodes: chop tree → wood, mine rock → stone -- [ ] Inventory + simple hotbar UI -- [ ] Crafting (recipe: wood → tools / building pieces) -- [ ] Health / stamina, day-night cycle +Detailed plan in `docs/SURVIVAL_DESIGN.md` (phases M1a–M1d). +- [x] Resource nodes: chop tree → wood, mine rock → stone *(M1a)* +- [x] Inventory + simple hotbar UI, chests *(M1a)* +- [x] Crafting (recipe: wood → tools / building pieces) *(M1a)* +- [x] Pause menu, title screen, world slots, save/load, autosave *(M1a)* +- [x] Health / stamina / hunger / warmth, day-night cycle, fire fuel, cooking, foraging, torches, death + grave *(M1b)* +- [ ] Weather (rain, storm, snow), wet/shelter *(M1c)* - [ ] Snap-together building: place a wall/floor, resource cost, basic support rule - **Done when:** gather → craft → build a tiny shelter, single-player. diff --git a/docs/SETUP_ON_NEW_MACHINE.md b/docs/SETUP_ON_NEW_MACHINE.md index 14015bc..a1b8622 100644 --- a/docs/SETUP_ON_NEW_MACHINE.md +++ b/docs/SETUP_ON_NEW_MACHINE.md @@ -23,6 +23,8 @@ godot --headless --path . --script res://tests/test_terrain_edges.gd 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 +# Survival stats: hunger, warmth, fire fuel, cooking, foraging, death + grave +godot --headless --path . --script res://tests/test_survival_stats.gd ``` ## Save files diff --git a/docs/SURVIVAL_DESIGN.md b/docs/SURVIVAL_DESIGN.md index 004c721..08db7a8 100644 --- a/docs/SURVIVAL_DESIGN.md +++ b/docs/SURVIVAL_DESIGN.md @@ -84,6 +84,21 @@ 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). +Built in M1b (`scripts/player/survival.gd`), current numbers: +- Hunger: full to empty in 1500 s (about 1.25 days), 1.5x faster below 30 + warmth. Eating is refused at 98+. Berries +9, mushroom +6, roasted + mushroom +24. +- Warmth eases toward a target: air warmth (22 at night to 68 at noon, -0.9 per metre above 12 m) plus + up to +70 from a fire or a held torch. Warms at 7/s, cools at 1.6/s. +- Wet: 90 s after leaving deep water, -22 to the warmth target, dries 3x + faster by a fire. +- Stamina: regen 24/s after a 1 s pause. Sprint 14/s, jump 8, harvest hit 6. +- Health: -0.6/s starving, -0.5/s below 12 warmth, +0.5/s when fed (>40) + and warm (>35). +- Campfire: starts with 2 min of fuel, holds 15 min, +fuel with wood (E), + heat radius 7 m. Out of fuel = no heat, no cooking. +- Berry bushes regrow after 10 min. Torch = 1 stick + 2 fiber. + ### 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 @@ -121,9 +136,10 @@ you're out), **Cold** / **Freezing**, **Warm** (near a fire), **Sheltered**, - 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.)* + from the seed. Since M1b it also stores stats, time of day, bush regrowth + timers, campfire fuel and graves. *(Later: weather.)* -### 2.11 Death *(M1c)* +### 2.11 Death *(built in M1b)* - 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. @@ -146,8 +162,8 @@ you're out), **Cold** / **Freezing**, **Warm** (near a fire), **Sheltered**, | 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 | +| **M1b** *(done)* | hunger/warmth/stamina/health HUD, day/night cycle with moon and stars, campfire warmth + fuel, cooking, bushes (fiber, berries), mushrooms, torches, **death + grave bag** (pulled forward from M1c) | you survive your first night by the fire | +| **M1c** | weather (rain, storm, snow) + wet/shelter, snap building (floor/wall/roof/door) with support, bed spawn point, 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) diff --git a/scenes/world.tscn b/scenes/world.tscn index bd5aa9b..5f481c8 100644 --- a/scenes/world.tscn +++ b/scenes/world.tscn @@ -43,5 +43,5 @@ offset_left = 16.0 offset_top = 12.0 offset_right = 600.0 offset_bottom = 60.0 -text = "WASD move · Shift sprint · Space jump · E interact · LMB use/place · R rotate · 1-8 hotbar · Tab inventory & crafting · ESC menu" +text = "WASD move · Shift sprint · Space jump · E interact · LMB use/place · RMB eat · R rotate · 1-8 hotbar · Tab inventory & crafting · ESC menu" label_settings = SubResource("Label_1") diff --git a/scripts/building/campfire.gd b/scripts/building/campfire.gd index db8d884..751451a 100644 --- a/scripts/building/campfire.gd +++ b/scripts/building/campfire.gd @@ -1,13 +1,24 @@ 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). +## Campfire: light, warmth and cooking, while it has fuel. +## +## Starts with a little kindling. E adds wood (or sticks) from your inventory; +## it burns down in play time and goes out at zero. While lit it is a +## "campfire" station (cooking) and a heat source (warmth within ~7 m). +const MAX_FUEL := 900.0 ## 15 min +const START_FUEL := 120.0 +const HEAT_RADIUS := 7.0 + +var fuel := START_FUEL var _light: OmniLight3D +var _fx: Array[CPUParticles3D] = [] var _t := 0.0 var _noise := FastNoiseLite.new() +var _lit := true func _build() -> void: add_to_group("station:campfire") + add_to_group("heat") _noise.frequency = 2.5 _light = OmniLight3D.new() _light.light_color = Color(1.0, 0.62, 0.3) @@ -18,30 +29,76 @@ func _build() -> void: _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([ + _fx.append(_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([ + _fx.append(_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() + for p in _fx: + add_child(p) + # TODO: smoke. Alpha-blended unshaded particles rendered as an opaque dark + # ball whatever their ramp colour (additive flames are fine). Needs a small + # particle shader. + _set_lit(fuel > 0.0) func display_name() -> String: return "Campfire" +func is_lit() -> bool: + return _lit + +func station_active() -> bool: + return _lit + +## 0..1 warmth this fire gives at `pos`. +func heat_at(pos: Vector3) -> float: + if not _lit: + return 0.0 + return 1.0 - smoothstep(1.5, HEAT_RADIUS, pos.distance_to(global_position)) + +func add_fuel(seconds: float) -> void: + fuel = minf(fuel + seconds, MAX_FUEL) + _set_lit(true) + +func interact_prompt(player: Node) -> String: + var has_fuel: bool = player.inventory.count("wood") + player.inventory.count("stick") > 0 + if _lit: + var left := "%d:%02d" % [int(fuel) / 60, int(fuel) % 60] + return "Campfire (%s left)%s" % [left, " [E] Add fuel" if has_fuel else " (add wood or sticks)"] + return "Campfire (out)%s" % (" [E] Add fuel to light" if has_fuel else " (needs wood or sticks)") + +func interact(player: Node) -> void: + Actions.add_fuel(player, self) + func _process(delta: float) -> void: + if not _lit: + return + fuel -= delta + if fuel <= 0.0: + fuel = 0.0 + _set_lit(false) + return _t += delta var f := _noise.get_noise_1d(_t * 10.0) - _light.light_energy = 2.2 + f * 0.7 + # Burns lower in its last minute. + var strength := clampf(fuel / 60.0, 0.35, 1.0) + _light.light_energy = (2.2 + f * 0.7) * strength _light.position = Vector3(f * 0.05, 0.7 + f * 0.05, 0) +func _set_lit(on: bool) -> void: + _lit = on + if _light != null: + _light.visible = on + for p in _fx: + p.emitting = on + +func save_extra() -> Dictionary: + return {"fuel": fuel} + +func load_extra(d: Dictionary) -> void: + fuel = float(d.get("fuel", fuel)) + _set_lit(fuel > 0.0) + static func _gradient(stops: Array) -> Gradient: var g := Gradient.new() g.offsets = PackedFloat32Array(stops.map(func(s): return s[0])) @@ -61,7 +118,7 @@ static func _soft_dot() -> GradientTexture2D: t.height = 64 return t -func _particles(label: String, amount: int, lifetime: float, pos: Vector3, vmin: float, vmax: float, +static 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 @@ -81,7 +138,7 @@ func _particles(label: String, amount: int, lifetime: float, pos: Vector3, vmin: # 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)) + curve.add_point(Vector2(1.0, 0.35)) p.scale_amount_curve = curve var quad := QuadMesh.new() quad.size = Vector2.ONE * quad_size diff --git a/scripts/building/grave.gd b/scripts/building/grave.gd new file mode 100644 index 0000000..6789528 --- /dev/null +++ b/scripts/building/grave.gd @@ -0,0 +1,23 @@ +extends "res://scripts/building/piece.gd" +## Grave: left where you died, holding everything you carried. Open it with E +## to take your things back; it crumbles once empty. + +var inventory := Inventory.new(40) + +func _build() -> void: + inventory.changed.connect(_on_changed) + +func display_name() -> String: + return "Your grave" + +func interact_prompt(_player: Node) -> String: + return "Your grave [E] Recover belongings" + +func interact(player: Node) -> void: + player.open_inventory(self) + +func _on_changed() -> void: + for s in inventory.slots: + if s != null: + return + queue_free.call_deferred() diff --git a/scripts/building/grave.gd.uid b/scripts/building/grave.gd.uid new file mode 100644 index 0000000..f2223cb --- /dev/null +++ b/scripts/building/grave.gd.uid @@ -0,0 +1 @@ +uid://ok0js1e4pb4s diff --git a/scripts/core/actions.gd b/scripts/core/actions.gd index a86624a..a5eba4c 100644 --- a/scripts/core/actions.gd +++ b/scripts/core/actions.gd @@ -13,6 +13,7 @@ signal item_gained(id: String, n: int) const STATION_RANGE := 6.0 const REACH := 3.2 +const FULL_HUNGER := 98.0 ## can't eat above this func _ready() -> void: _bind("interact", [KEY_E]) @@ -21,7 +22,7 @@ func _ready() -> void: 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) + _bind_mouse("use_item", MOUSE_BUTTON_RIGHT) func _bind(action: String, keys: Array) -> void: if not InputMap.has_action(action): @@ -59,6 +60,8 @@ 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.has_method("station_active") and not s.station_active(): + continue # e.g. a campfire that has gone out if (s as Node3D).global_position.distance_to(player.global_position) <= STATION_RANGE: return true return false @@ -116,6 +119,9 @@ func harvest_hit(player: Node3D, node: Node3D, hit_point: Vector3) -> bool: if tool.get("kind", "") != node.tool_kind or int(tool.get("tier", 0)) < node.min_tier: notify.emit("Needs a %s (Tab to craft)" % ItemDB.item_name("stone_" + node.tool_kind)) return false + if not player.survival.use_stamina(6.0): + notify.emit("Too tired") + 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])) @@ -141,6 +147,47 @@ func place(player: Node3D, slot: int, xf: Transform3D, world: Node3D) -> bool: world.spawn_piece(def, xf) return true +func forage(player: Node3D, bush: Node3D) -> bool: + if not is_instance_valid(bush) or not _in_reach(player, bush) or not bush.ripe(): + return false + var got: Dictionary = bush.harvest() + for id in got: + _give(player, id, int(got[id])) + return not got.is_empty() + +## Eats one of the food in `slot`. +func eat(player: Node3D, slot: int) -> bool: + var s = player.inventory.get_slot(slot) + if s == null: + return false + var food: Dictionary = ItemDB.get_item(s.id).get("food", {}) + if food.is_empty(): + return false + if player.survival.hunger >= FULL_HUNGER: + notify.emit("You're full") + return false + player.inventory.take_from(slot, 1) + player.survival.eat(food) + notify.emit("Ate %s" % ItemDB.item_name(s.id).to_lower()) + return true + + +## Feeds a campfire from the player's inventory: wood first, then sticks. +func add_fuel(player: Node3D, fire: Node3D) -> bool: + if not is_instance_valid(fire) or not _in_reach(player, fire): + return false + if fire.fuel >= fire.MAX_FUEL - 1.0: + notify.emit("The fire is fully stoked") + return false + for id in ["wood", "stick", "fiber"]: + if player.inventory.count(id) > 0: + player.inventory.remove(id, 1) + fire.add_fuel(float(ItemDB.get_item(id).get("fuel", 10))) + notify.emit("Added %s to the fire" % ItemDB.item_name(id).to_lower()) + return true + notify.emit("Nothing to burn (wood or sticks)") + return false + ## 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: diff --git a/scripts/items/item_meshes.gd b/scripts/items/item_meshes.gd index 3f0b98a..0d3a2b9 100644 --- a/scripts/items/item_meshes.gd +++ b/scripts/items/item_meshes.gd @@ -18,6 +18,14 @@ 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) +const FIBER := Color(0.45, 0.55, 0.22) +const BERRY := Color(0.62, 0.08, 0.16) +const MUSH_CAP := Color(0.72, 0.30, 0.18) +const MUSH_ROAST := Color(0.40, 0.24, 0.12) +const MUSH_STEM := Color(0.88, 0.84, 0.74) +const CLOTH := Color(0.60, 0.50, 0.32) +const SOIL := Color(0.30, 0.22, 0.15) + static var _cache := {} static func build(key: String) -> ArrayMesh: @@ -35,6 +43,14 @@ static func build(key: String) -> ArrayMesh: "campfire": m = _campfire() "workbench": m = _workbench() "chest": m = _chest() + "fiber": m = _fiber() + "berries": m = _berries() + "mushroom": m = _mushroom(MUSH_CAP) + "roasted_mushroom": m = _mushroom(MUSH_ROAST) + "torch": m = _torch() + "grave": m = _grave() + "berry_bush": m = _berry_bush(true) + "berry_bush_bare": m = _berry_bush(false) _: push_warning("item_meshes: no mesh for '%s', using a stone" % key) m = _pebble(1, Color.MAGENTA, Vector3.ONE * 0.15) @@ -158,3 +174,74 @@ static func _chest() -> ArrayMesh: _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) + +static func _fiber() -> ArrayMesh: + var st := P._begin() + var r := _rng(41) + for i in range(7): + var a := r.randf_range(-0.25, 0.25) + var b := Vector3(r.randf_range(-0.03, 0.03), 0.02, r.randf_range(-0.03, 0.03)) + P._cylinder(st, b + Vector3(-0.22, 0, 0), b + Vector3(0.22, 0.03 + a * 0.1, a), 0.008, 0.005, 3, P._mul(FIBER, r.randf_range(0.8, 1.15)), r) + _box(st, Vector3(0, 0.03, 0), Vector3(0.05, 0.05, 0.08), CLOTH) + return P._commit(st) + +static func _berries() -> ArrayMesh: + var st := P._begin() + var r := _rng(43) + var n := FastNoiseLite.new() + n.seed = 43 + for i in range(9): + var c := Vector3(r.randf_range(-0.07, 0.07), 0.03 + r.randf_range(0.0, 0.05), r.randf_range(-0.07, 0.07)) + P._blob(st, c, Vector3.ONE * r.randf_range(0.025, 0.035), n, 0.1, P._mul(BERRY, r.randf_range(0.8, 1.2)), r, 0) + P._blob(st, Vector3(0, 0.08, 0), Vector3(0.05, 0.012, 0.03), n, 0.2, P.BUSH, r, 0) + return P._commit(st) + +static func _mushroom(cap: Color) -> ArrayMesh: + var st := P._begin() + var r := _rng(47) + var n := FastNoiseLite.new() + n.seed = 47 + P._cylinder(st, Vector3(0, -0.02, 0), Vector3(0, 0.1, 0), 0.025, 0.02, 6, MUSH_STEM, r, -1.0, MUSH_STEM) + P._blob(st, Vector3(0, 0.11, 0), Vector3(0.075, 0.04, 0.075), n, 0.15, cap, r, 1) + return P._commit(st) + +static func _torch() -> ArrayMesh: + var st := P._begin() + var r := _rng(53) + _handle(st, 0.5, r) + P._cylinder(st, Vector3(0, 0.42, 0), Vector3(0, 0.58, 0), 0.045, 0.05, 6, CLOTH, r, -1.0, ASH) + return P._commit(st) + +static func _grave() -> ArrayMesh: + var st := P._begin() + var r := _rng(59) + var n := FastNoiseLite.new() + n.seed = 59 + P._blob(st, Vector3(0, 0.0, 0), Vector3(0.5, 0.22, 0.8), n, 0.15, SOIL, r, 1) + _box(st, Vector3(0, 0.55, -0.62), Vector3(0.08, 1.0, 0.06), PLANK_DARK) + _box(st, Vector3(0, 0.8, -0.62), Vector3(0.5, 0.08, 0.06), PLANK_DARK) + for i in range(4): + var c := Vector3(r.randf_range(-0.35, 0.35), 0.15, r.randf_range(-0.6, 0.6)) + P._blob(st, c, Vector3.ONE * r.randf_range(0.07, 0.12), n, 0.3, STONE, r, 0) + return P._commit(st) + +## World berry bush (not an item). `ripe` adds the berries. +static func _berry_bush(ripe: bool) -> ArrayMesh: + var st := P._begin() + var r := _rng(61) + var n := FastNoiseLite.new() + n.seed = 61 + n.frequency = 2.0 + var leaf := P._mul(P.BUSH, 1.1) + var blobs: Array[Vector3] = [] + for i in range(4): + var c := Vector3(r.randf_range(-0.4, 0.4), r.randf_range(0.3, 0.55), r.randf_range(-0.4, 0.4)) + var rad := r.randf_range(0.35, 0.5) + P._blob(st, c, Vector3(rad, rad * 0.8, rad), n, 0.3, P._mul(leaf, r.randf_range(0.85, 1.1)), r, 1) + blobs.append(c + Vector3(0, rad * 0.4, 0)) + if ripe: + for c in blobs: + for k in range(6): + var d := Vector3(r.randf_range(-1, 1), r.randf_range(0.2, 1), r.randf_range(-1, 1)).normalized() * 0.4 + P._blob(st, c + d, Vector3.ONE * 0.045, n, 0.1, BERRY, r, 0) + return P._commit(st) diff --git a/scripts/player/hands.gd b/scripts/player/hands.gd index 78f3886..f7d144e 100644 --- a/scripts/player/hands.gd +++ b/scripts/player/hands.gd @@ -10,6 +10,7 @@ extends Node3D signal prompt_changed(text: String) +const Campfire = preload("res://scripts/building/campfire.gd") const REACH := 3.2 const PLACE_REACH := 6.0 const SWING_TIME := 0.5 @@ -27,6 +28,9 @@ var _place_xf := Transform3D() var _place_ok := false var _prompt := "" var _held_shown := "~" +var _torch_light: OmniLight3D +var _torch_flame: CPUParticles3D +var _t := 0.0 func _ready() -> void: _ray = RayCast3D.new() @@ -51,6 +55,22 @@ func _ready() -> void: _ghost.top_level = true add_child(_ghost) + _torch_light = OmniLight3D.new() + _torch_light.light_color = Color(1.0, 0.65, 0.35) + _torch_light.omni_attenuation = 1.3 + _torch_light.shadow_enabled = true + _torch_light.position = Vector3(0, 0.62, 0) + _torch_light.visible = false + _view.add_child(_torch_light) + _torch_flame = Campfire._particles( + "TorchFlame", 14, 0.4, Vector3(0, 0.58, 0), 0.2, 0.5, 0.25, 0.5, + Campfire._gradient([ + [0.0, Color(1.0, 0.8, 0.35, 0.0)], [0.2, Color(1.0, 0.6, 0.2, 0.8)], [1.0, Color(0.6, 0.1, 0.02, 0.0)]]), + true, 0.12, 0.03) + _torch_flame.local_coords = false + _torch_flame.emitting = false + _view.add_child(_torch_flame) + player.inventory.changed.connect(_refresh_held) player.selected_changed.connect(_refresh_held) _refresh_held() @@ -82,6 +102,17 @@ func _refresh_held() -> void: _ghost_yaw = 0.0 else: _ghost.visible = false + # Torch: a flickering light at the head of the held torch. + var light: Dictionary = def.get("light", {}) + _torch_light.visible = not light.is_empty() + _torch_flame.emitting = not light.is_empty() + if not light.is_empty(): + _torch_light.omni_range = float(light.get("range", 8.0)) + _torch_light.light_energy = float(light.get("energy", 1.5)) + +## 0..1 warmth from what's held (a torch). +func held_heat() -> float: + return float(_held_def().get("light", {}).get("heat", 0.0)) func _set_prompt(t: String) -> void: if t != _prompt: @@ -110,8 +141,16 @@ func _physics_process(delta: float) -> void: _set_prompt("[LMB] Place %s [R] Rotate" % def.name) return _ghost.visible = false + if _torch_light.visible: + _t += delta + _torch_light.light_energy = float(def.get("light", {}).get("energy", 1.5)) * (0.85 + 0.15 * sin(_t * 17.0) * sin(_t * 5.3)) var t := _target() - _set_prompt(t.interact_prompt(player) if t != null else "") + if t != null: + _set_prompt(t.interact_prompt(player)) + elif def.has("food"): + _set_prompt("[RMB] Eat %s" % def.name) + else: + _set_prompt("") func _update_ghost(def: Dictionary) -> void: var cam := get_parent() as Camera3D @@ -142,7 +181,10 @@ func _unhandled_input(event: InputEvent) -> void: 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"): + if event.is_action_pressed("use_item") and Input.mouse_mode == Input.MOUSE_MODE_CAPTURED: + if _held_def().has("food"): + Actions.eat(player, player.selected) + elif event.is_action_pressed("rotate_piece"): _ghost_yaw += deg_to_rad(22.5) elif event.is_action_pressed("interact"): var t := _target() diff --git a/scripts/player/player.gd b/scripts/player/player.gd index 5ff7199..08a935b 100644 --- a/scripts/player/player.gd +++ b/scripts/player/player.gd @@ -26,6 +26,7 @@ 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 survival: Node ## health / stamina / hunger / warmth (survival.gd) var _gravity: float = ProjectSettings.get_setting("physics/3d/default_gravity", 9.8) @@ -36,6 +37,11 @@ func _ready() -> void: hands.name = "Hands" hands.player = self camera.add_child(hands) + survival = preload("res://scripts/player/survival.gd").new() + survival.name = "Survival" + survival.player = self + survival.world = get_parent() + add_child(survival) func held_item_id() -> String: var s = inventory.get_slot(selected) @@ -70,17 +76,21 @@ func _physics_process(delta: float) -> void: elif not is_on_floor(): velocity.y -= _gravity * delta + if global_position.y < water_level - 0.6: + survival.soak() # waist-deep or more: you're wet + var jump := Input.is_action_just_pressed("jump") and not ui_open - if jump and is_on_floor(): - velocity.y = jump_velocity - 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. + var can_swim_jump := global_position.y > float_y - 0.4 and global_position.y < float_y + 0.3 + if jump and (is_on_floor() or can_swim_jump) and survival.use_stamina(8.0): + # (A swim-jump kicks you up out of the water onto a bank.) velocity.y = jump_velocity var input_dir := Vector2.ZERO if ui_open else Input.get_vector("move_left", "move_right", "move_forward", "move_back") var direction := (transform.basis * Vector3(input_dir.x, 0, input_dir.y)).normalized() - var current_speed := sprint_speed if Input.is_key_pressed(KEY_SHIFT) else speed + var sprinting: bool = Input.is_key_pressed(KEY_SHIFT) and direction.length() > 0.1 and not ui_open \ + and survival.use_stamina(14.0 * delta, true) + var current_speed := sprint_speed if sprinting else speed if swimming: current_speed = swim_speed if direction: diff --git a/scripts/player/survival.gd b/scripts/player/survival.gd new file mode 100644 index 0000000..1ca1e6a --- /dev/null +++ b/scripts/player/survival.gd @@ -0,0 +1,127 @@ +extends Node +## Survival stats: health, stamina, hunger (satiety) and warmth, each 0..100. +## +## - Hunger drains over about a day and a quarter, faster when you're cold. +## - Warmth moves toward a target: air temperature (day/night, altitude), +## plus heat from fires and a held torch, minus being wet. +## - Health drains while starving or freezing, and regenerates when fed and warm. +## - Stamina pays for sprinting, jumping and swinging, and regenerates after a +## short pause. +## +## step(dt) holds all the rules, so tests can fast-forward time. In M2 this runs +## on the server. + +signal changed +signal died + +const MAX := 100.0 +const HUNGER_DRAIN := 100.0 / 1500.0 ## full to empty in ~1.25 in-game days +const STAMINA_REGEN := 24.0 +const STAMINA_DELAY := 1.0 +const WET_SECONDS := 90.0 + +var health := MAX +var stamina := MAX +var hunger := MAX ## satiety: 100 = full, 0 = starving +var warmth := 70.0 +var wet := 0.0 ## seconds of "Wet" left +var heat := 0.0 ## 0..1 from nearby fires etc (last step) +var statuses: Array[String] = [] +var dead := false + +var player: CharacterBody3D +var world: Node3D +var _stamina_wait := 0.0 + +func _process(delta: float) -> void: + if world != null and not world.menu_mode: + step(delta) + +func step(dt: float) -> void: + if dead: + return + var p := player.global_position + heat = world.heat_at(p) + var target: float = world.air_warmth(p) + heat * 70.0 + if wet > 0.0: + target -= 22.0 + wet = maxf(wet - dt * (3.0 if heat > 0.3 else 1.0), 0.0) # fires dry you off + target = clampf(target, 0.0, MAX) + # Warm up faster than you cool down: getting to a fire should feel good. + warmth = move_toward(warmth, target, (7.0 if target > warmth else 1.6) * dt) + + hunger = maxf(hunger - HUNGER_DRAIN * dt * (1.5 if warmth < 30.0 else 1.0), 0.0) + + if _stamina_wait > 0.0: + _stamina_wait -= dt + else: + stamina = minf(stamina + STAMINA_REGEN * dt, MAX) + + var dmg := 0.0 + if hunger <= 0.0: + dmg += 0.6 + if warmth < 12.0: + dmg += 0.5 + if dmg > 0.0: + health = maxf(health - dmg * dt, 0.0) + elif hunger > 40.0 and warmth > 35.0: + health = minf(health + 0.5 * dt, MAX) + + _update_statuses() + changed.emit() + if health <= 0.0: + dead = true + died.emit() + +func _update_statuses() -> void: + statuses.clear() + if heat > 0.25: + statuses.append("Warm") + if warmth < 12.0: + statuses.append("Freezing") + elif warmth < 30.0: + statuses.append("Cold") + if hunger <= 0.0: + statuses.append("Starving") + elif hunger < 25.0: + statuses.append("Hungry") + if wet > 0.0: + statuses.append("Wet") + +## Spends stamina for an action. Returns false (and spends nothing) if you're +## too tired. Continuous costs (sprinting) pass `partial = true`. +func use_stamina(amount: float, partial: bool = false) -> bool: + if stamina <= 0.0 or (not partial and stamina < amount * 0.5): + return false + stamina = maxf(stamina - amount, 0.0) + _stamina_wait = STAMINA_DELAY + return true + +func eat(food: Dictionary) -> void: + hunger = minf(hunger + float(food.get("hunger", 0)), MAX) + health = minf(health + float(food.get("health", 0)), MAX) + changed.emit() + +func soak() -> void: + wet = WET_SECONDS + +func reset_after_death() -> void: + dead = false + health = 50.0 + stamina = MAX + hunger = 50.0 + warmth = 60.0 + wet = 0.0 + changed.emit() + +func to_data() -> Dictionary: + return {"health": health, "stamina": stamina, "hunger": hunger, "warmth": warmth, "wet": wet} + +func load_data(d: Dictionary) -> void: + health = float(d.get("health", health)) + stamina = float(d.get("stamina", stamina)) + hunger = float(d.get("hunger", hunger)) + warmth = float(d.get("warmth", warmth)) + wet = float(d.get("wet", 0.0)) + dead = false + changed.emit() diff --git a/scripts/player/survival.gd.uid b/scripts/player/survival.gd.uid new file mode 100644 index 0000000..a8170b0 --- /dev/null +++ b/scripts/player/survival.gd.uid @@ -0,0 +1 @@ +uid://ibwy562x1kqp diff --git a/scripts/ui/hud.gd b/scripts/ui/hud.gd index e9c816a..c468998 100644 --- a/scripts/ui/hud.gd +++ b/scripts/ui/hud.gd @@ -15,6 +15,9 @@ var _hotbar: Array = [] var _toasts: VBoxContainer var _screen: Control var _menu: Control +var _bars: Dictionary = {} +var _status: Label +var _clock: Label func bind(p: CharacterBody3D) -> void: player = p @@ -74,6 +77,8 @@ func bind(p: CharacterBody3D) -> void: _toasts.mouse_filter = Control.MOUSE_FILTER_IGNORE root.add_child(_toasts) + _build_stats(root) + _screen = InventoryScreen.new() root.add_child(_screen) _screen.bind(player) @@ -92,6 +97,63 @@ func bind(p: CharacterBody3D) -> void: ItemDB.icons_ready.connect(_refresh) _refresh() +## Bars bottom-left (health, stamina, food, warmth), status tags above them, +## clock top-right. +func _build_stats(root: Control) -> void: + var box := VBoxContainer.new() + box.set_anchors_and_offsets_preset(Control.PRESET_BOTTOM_LEFT) + box.grow_vertical = Control.GROW_DIRECTION_BEGIN + box.offset_left = 22 + box.offset_bottom = -22 + box.add_theme_constant_override("separation", 5) + box.mouse_filter = Control.MOUSE_FILTER_IGNORE + root.add_child(box) + _status = UITheme.label("", 15, UITheme.TEXT) + box.add_child(_status) + for spec in [["health", "Health", Color(0.85, 0.22, 0.2)], ["stamina", "Stamina", Color(0.9, 0.78, 0.25)], + ["hunger", "Food", Color(0.85, 0.5, 0.2)], ["warmth", "Warmth", Color(0.95, 0.55, 0.3)]]: + var row := HBoxContainer.new() + row.add_theme_constant_override("separation", 8) + row.mouse_filter = Control.MOUSE_FILTER_IGNORE + var name_l := UITheme.label(spec[1], 14, UITheme.TEXT_DIM) + name_l.custom_minimum_size = Vector2(62, 0) + row.add_child(name_l) + var bar := ProgressBar.new() + bar.custom_minimum_size = Vector2(190, 14) + bar.show_percentage = false + bar.max_value = 100.0 + bar.mouse_filter = Control.MOUSE_FILTER_IGNORE + bar.add_theme_stylebox_override("background", UITheme.box(Color(0, 0, 0, 0.55), UITheme.SLOT_BORDER, 1, 4, 0)) + bar.add_theme_stylebox_override("fill", UITheme.box(spec[2], spec[2].lightened(0.2), 1, 4, 0)) + row.add_child(bar) + box.add_child(row) + _bars[spec[0]] = bar + _clock = UITheme.label("", 18) + _clock.set_anchors_and_offsets_preset(Control.PRESET_TOP_RIGHT) + _clock.grow_horizontal = Control.GROW_DIRECTION_BEGIN + _clock.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT + _clock.offset_right = -22 + _clock.offset_top = 14 + root.add_child(_clock) + +func _process(_d: float) -> void: + var s = player.survival + for k in _bars: + _bars[k].value = s.get(k) + # Warmth bar shifts from icy blue (cold) to orange (warm). + var w: float = s.warmth / 100.0 + var wc := Color(0.35, 0.6, 0.95).lerp(Color(0.95, 0.55, 0.3), w) + (_bars["warmth"].get_theme_stylebox("fill") as StyleBoxFlat).bg_color = wc + _status.text = " ".join(s.statuses) + var dn = player.get_parent().day_night + if dn != null: + _clock.text = dn.clock_text() + +## Closes the inventory / menu (e.g. on death). +func close_screens() -> void: + if _screen.is_open(): + _screen.close() + func _on_inventory_requested(container: Node) -> void: if not _screen.is_open(): _screen.open(container) diff --git a/scripts/ui/inventory_screen.gd b/scripts/ui/inventory_screen.gd index 6c776f0..87f4155 100644 --- a/scripts/ui/inventory_screen.gd +++ b/scripts/ui/inventory_screen.gd @@ -132,7 +132,7 @@ func close() -> void: 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): + if container != null and is_instance_valid(container) and container.inventory.changed.is_connected(_refresh): container.inventory.changed.disconnect(_refresh) container = null visible = false @@ -147,12 +147,17 @@ func _input(event: InputEvent) -> void: 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: + if not visible or container == null: + return + # Containers can vanish while open (an emptied grave crumbles). + if not is_instance_valid(container) or container.is_queued_for_deletion(): + container = null + _rebuild_chest() + return + if player.global_position.distance_to(container.global_position) > 5.0: close() func _rebuild_chest() -> void: @@ -177,7 +182,7 @@ func _on_player_slot(i: int, right: bool, shift: bool) -> void: _refresh() func _on_chest_slot(i: int, right: bool, shift: bool) -> void: - if not Actions._in_reach(player, container, 1.0): + if container == null or not is_instance_valid(container) or not Actions._in_reach(player, container, 1.0): return if shift: Actions.transfer(player, container.inventory, i, player.inventory, container) diff --git a/scripts/ui/pause_menu.gd b/scripts/ui/pause_menu.gd index 48a645f..ac5257a 100644 --- a/scripts/ui/pause_menu.gd +++ b/scripts/ui/pause_menu.gd @@ -7,7 +7,8 @@ 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"], + ["Mouse", "Look"], ["E", "Interact / pick up / add fuel"], ["Left click", "Use tool / place"], + ["Right click (holding food)", "Eat"], ["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)"], diff --git a/scripts/world/day_night.gd b/scripts/world/day_night.gd new file mode 100644 index 0000000..477781a --- /dev/null +++ b/scripts/world/day_night.gd @@ -0,0 +1,116 @@ +extends Node +## Day/night cycle. Moves the sun along its arc and drives sky colours, sun and +## moon light, ambient light and fog from the time of day. +## +## Time is a fraction of a day (0 = midnight, 0.25 = 06:00 sunrise, +## 0.5 = noon, 0.75 = 18:00 sunset). One day lasts `day_length` real seconds +## (20 min). Saved with the world (see world.gd collect_save). + +signal new_day(day: int) + +@export var day_length := 1200.0 +@export var sun_tilt_deg := 35.0 ## arc leans toward +Z, so noon sun sits at 55° +const DAY_BIAS := 0.2 + +var day := 1 +var time := 8.0 / 24.0 +var frozen := false ## title-screen backdrop: hold a fixed time + +var sun: DirectionalLight3D +var moon: DirectionalLight3D +var env: Environment +var sky_mat: ShaderMaterial +var daylight := 1.0 ## 0 at night, 1 in full day (smooth) +var sun_height := 1.0 ## sin of sun elevation (-1..1) +var _sky_timer := 0.0 + +# Palette: [zenith, horizon, fog] +const DAY_COLS := [Color(0.24, 0.44, 0.72), Color(0.72, 0.79, 0.85), Color(0.70, 0.77, 0.85)] +const DUSK_COLS := [Color(0.22, 0.27, 0.48), Color(0.98, 0.62, 0.38), Color(0.82, 0.62, 0.50)] +const NIGHT_COLS := [Color(0.02, 0.03, 0.07), Color(0.07, 0.09, 0.16), Color(0.07, 0.09, 0.14)] + +func setup(world_sun: DirectionalLight3D, environment: Environment) -> void: + sun = world_sun + env = environment + sky_mat = env.sky.sky_material as ShaderMaterial + moon = DirectionalLight3D.new() + moon.name = "Moon" + moon.light_color = Color(0.55, 0.65, 1.0) + moon.shadow_enabled = false + moon.directional_shadow_max_distance = 120.0 + moon.sky_mode = DirectionalLight3D.SKY_MODE_LIGHT_ONLY # keep the sky shader's LIGHT0 = sun + sun.add_sibling(moon) + apply(true) + +func hours() -> float: + return time * 24.0 + +func clock_text() -> String: + var h := hours() + return "Day %d %02d:%02d" % [day, int(h), int(fmod(h, 1.0) * 60.0)] + +func is_night() -> bool: + return daylight < 0.3 + +func advance(seconds: float) -> void: + time += seconds / day_length + while time >= 1.0: + time -= 1.0 + day += 1 + new_day.emit(day) + apply(false) + +func _process(delta: float) -> void: + if not frozen: + advance(delta) + +## Updates lights every call; the sky material (which triggers a radiance +## rebuild) only a few times a second unless `force`. +func apply(force: bool) -> void: + if sun == null: + return + var a := (time - 0.25) * TAU + var t := deg_to_rad(sun_tilt_deg) + # +DAY_BIAS lifts the whole arc: sunrise ~05:00, sunset ~19:00, so days + # are longer than nights and dusk lingers. + var to_sun := Vector3(cos(a), sin(a) * cos(t) + DAY_BIAS, sin(a) * sin(t)).normalized() + sun_height = to_sun.y + daylight = smoothstep(-0.2, 0.25, to_sun.y) + var low := 1.0 - smoothstep(0.05, 0.45, absf(to_sun.y)) # 1 near the horizon + + sun.global_basis = Basis.looking_at(-to_sun if to_sun.y > -0.2 else Vector3.DOWN, Vector3.FORWARD) + sun.light_energy = 1.8 * smoothstep(-0.03, 0.15, to_sun.y) + sun.light_color = Color(1.0, 0.95, 0.83).lerp(Color(1.0, 0.58, 0.32), low) + sun.visible = sun.light_energy > 0.01 + sun.shadow_enabled = sun.visible + + var moon_dir := Vector3(-to_sun.x, absf(to_sun.y) * 0.8 + 0.25, -to_sun.z * 0.5).normalized() + moon.global_basis = Basis.looking_at(-moon_dir, Vector3.FORWARD) + moon.light_energy = 0.38 * (1.0 - daylight) + moon.visible = moon.light_energy > 0.01 + moon.shadow_enabled = moon.visible and not sun.visible + + env.ambient_light_energy = lerpf(0.3, 1.0, daylight) + env.fog_light_energy = lerpf(0.25, 1.0, daylight) + env.volumetric_fog_density = lerpf(0.0025, 0.0045, daylight) + + _sky_timer -= get_process_delta_time() + if not force and _sky_timer > 0.0: + return + _sky_timer = 0.25 + var cols: Array = [] + for i in range(3): + var dayc: Color = DAY_COLS[i].lerp(DUSK_COLS[i], low * daylight) + cols.append(NIGHT_COLS[i].lerp(dayc, daylight)) + env.fog_light_color = cols[2] + if sky_mat != null: + sky_mat.set_shader_parameter("zenith_col", cols[0]) + sky_mat.set_shader_parameter("horizon_col", cols[1]) + sky_mat.set_shader_parameter("energy", lerpf(0.35, 1.0, daylight)) + sky_mat.set_shader_parameter("star_strength", 1.0 - smoothstep(0.0, 0.35, daylight)) + sky_mat.set_shader_parameter("cloud_lit", Color(0.16, 0.18, 0.24).lerp(Color(1, 1, 1), daylight)) + sky_mat.set_shader_parameter("sun_tint", Color(1.0, 0.86, 0.62).lerp(Color(1.0, 0.55, 0.3), low)) + +## 0..100 "how warm the air is" before fires, shelter and clothes. +func air_warmth() -> float: + return lerpf(22.0, 68.0, daylight) diff --git a/scripts/world/day_night.gd.uid b/scripts/world/day_night.gd.uid new file mode 100644 index 0000000..e388f67 --- /dev/null +++ b/scripts/world/day_night.gd.uid @@ -0,0 +1 @@ +uid://2vp5n05qr6yn diff --git a/scripts/world/forage.gd b/scripts/world/forage.gd new file mode 100644 index 0000000..d4d8533 --- /dev/null +++ b/scripts/world/forage.gd @@ -0,0 +1,59 @@ +extends Area3D +## A berry bush you can forage with E: berries plus some fiber. It's then bare +## until it regrows (`regrow_seconds` of play time). Walk-through, on +## collision layer 2 like pickups. Regrow timers are saved (props.forage). + +const ItemMeshes = preload("res://scripts/items/item_meshes.gd") + +var uid := "" +var regrow_seconds := 600.0 +var regrow_left := 0.0 ## 0 = ripe +var rng := RandomNumberGenerator.new() +var _mesh: MeshInstance3D + +func setup(material: Material) -> void: + collision_layer = 2 + collision_mask = 0 + monitoring = false + _mesh = MeshInstance3D.new() + _mesh.material_override = material + add_child(_mesh) + var cs := CollisionShape3D.new() + var sph := SphereShape3D.new() + sph.radius = 0.7 + cs.shape = sph + cs.position = Vector3(0, 0.5, 0) + add_child(cs) + _refresh() + +func ripe() -> bool: + return regrow_left <= 0.0 + +func _refresh() -> void: + _mesh.mesh = ItemMeshes.build("berry_bush" if ripe() else "berry_bush_bare") + +func _process(delta: float) -> void: + if regrow_left > 0.0: + regrow_left = maxf(regrow_left - delta, 0.0) + if regrow_left <= 0.0: + _refresh() + +func interact_prompt(_player: Node) -> String: + if ripe(): + return "Berry bush [E] Pick berries" + return "Berry bush (regrowing, %d min)" % ceili(regrow_left / 60.0) + +func interact(player: Node) -> void: + Actions.forage(player, self) + +## Called by Actions.forage. Returns what was picked. +func harvest() -> Dictionary: + if not ripe(): + return {} + regrow_left = regrow_seconds + _refresh() + return {"berries": rng.randi_range(2, 4), "fiber": rng.randi_range(1, 2)} + +func restore(left: float) -> void: + regrow_left = left + _refresh() diff --git a/scripts/world/forage.gd.uid b/scripts/world/forage.gd.uid new file mode 100644 index 0000000..2e41653 --- /dev/null +++ b/scripts/world/forage.gd.uid @@ -0,0 +1 @@ +uid://ceb8l5850ldj4 diff --git a/scripts/world/props.gd b/scripts/world/props.gd index 7b24cee..80d9c90 100644 --- a/scripts/world/props.gd +++ b/scripts/world/props.gd @@ -13,6 +13,7 @@ extends Node3D 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 ForageScript = preload("res://scripts/world/forage.gd") const CELL := 48.0 const GRASS_TILE := 12.0 @@ -40,6 +41,7 @@ var _resources: Node3D ## harvestable trees/boulders (each its own body) ## 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 forage: Dictionary = {} ## uid -> berry bush (forage.gd) var _bark_mat: StandardMaterial3D var _leaf_mat: ShaderMaterial @@ -500,8 +502,9 @@ func _scatter_pickups() -> void: 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} + # Order matters: new kinds go at the END so existing saves keep their ids. + var want := {"stick": 260, "stone": 170, "flint": 80, "mushroom": 110} + var radius: Dictionary = {"stick": 0.35, "stone": 0.25, "flint": 0.25, "mushroom": 0.25} for id: String in want: var mesh := ItemDB.mesh_for(id) var placed := 0 @@ -525,6 +528,8 @@ func _scatter_pickups() -> void: "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 + "mushroom": + chance = _forest(x, z) * 0.7 # deep forest only # 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 @@ -537,6 +542,48 @@ func _scatter_pickups() -> void: root.add_child(pk) pk.global_transform = Transform3D(_ground_basis(n, 1.0, yaw, 1.0), Vector3(x, h - 0.02, z)) placed += 1 + _scatter_berry_bushes(root) + +## Berry bushes along forest edges and in meadows, plus a couple near spawn. +func _scatter_berry_bushes(root: Node3D) -> void: + var rng := RandomNumberGenerator.new() + rng.seed = world_seed + 7 + var placed := 0 + var tries := 0 + while placed < 70 and tries < 8000: + 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, 4.0): + continue + var h: float = terrain.sample_height(x, z) + var n: Vector3 = terrain.sample_normal(x, z) + if h < terrain.water_level + 0.6 or n.y < 0.85: + continue + var f := _forest(x, z) + var chance := 0.12 + (1.0 - absf(f - 0.45) * 2.0) * 0.5 + var near_spawn := Vector2(x - _spawn.x, z - _spawn.z).length() + if near_spawn < 6.0: + continue + if near_spawn < 25.0: + chance += 0.4 + if keep > chance: + continue + var bush: Area3D = ForageScript.new() + bush.uid = "b%d" % forage.size() + bush.rng.seed = hash(bush.uid) ^ world_seed + bush.setup(_leaf_mat_static()) + root.add_child(bush) + bush.global_transform = Transform3D(_ground_basis(n, 0.5, yaw, rng.randf_range(0.9, 1.2)), Vector3(x, h - 0.05, z)) + forage[bush.uid] = bush + placed += 1 + +## Berry-bush meshes carry their own vertex colours (including red berries), +## so they use the plain item material rather than the wind foliage shader. +func _leaf_mat_static() -> Material: + return ItemDB.item_material() # --- grass streaming -------------------------------------------------------- diff --git a/scripts/world/world.gd b/scripts/world/world.gd index 8da773d..100df0d 100644 --- a/scripts/world/world.gd +++ b/scripts/world/world.gd @@ -10,8 +10,6 @@ extends Node3D ## Dev hook: `godot --path . -- --shot=/abs/prefix` renders a few fixed views to ## _.png and quits. Handy for checking visual changes. -@export var sun_elevation_deg: float = 34.0 -@export var sun_azimuth_deg: float = 40.0 ## 0 = behind the spawn view, + = to its right @onready var terrain: Node3D = $Terrain @onready var props: Node3D = $Props @@ -22,6 +20,7 @@ const PIECES := { "campfire": preload("res://scripts/building/campfire.gd"), "workbench": preload("res://scripts/building/workbench.gd"), "chest": preload("res://scripts/building/chest.gd"), + "grave": preload("res://scripts/building/grave.gd"), } const AUTOSAVE_SECONDS := 120.0 @@ -38,12 +37,20 @@ 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 +var day_night: Node ## day_night.gd: time of day, sun, sky func _ready() -> void: _pieces = Node3D.new() _pieces.name = "Pieces" add_child(_pieces) + day_night = preload("res://scripts/world/day_night.gd").new() + day_night.name = "DayNight" + add_child(day_night) + day_night.setup(sun, $WorldEnvironment.environment) if menu_mode: + day_night.time = 9.5 / 24.0 + day_night.frozen = true + day_night.apply(true) player.process_mode = Node.PROCESS_MODE_DISABLED player.get_node("Camera3D").current = false _menu_cam = Camera3D.new() @@ -54,10 +61,11 @@ func _ready() -> void: hud = preload("res://scripts/ui/hud.gd").new() add_child(hud) hud.bind(player) + if not menu_mode: + player.survival.died.connect(_on_player_died) if Saves.has_world() and not menu_mode: # Save on window close instead of quitting straight away. get_tree().auto_accept_quit = false - _aim_sun() player.set_physics_process(false) set_physics_process(false) if terrain.built: @@ -65,11 +73,44 @@ func _ready() -> void: else: terrain.ready_built.connect(_on_terrain_ready) -func _aim_sun() -> void: - var el := deg_to_rad(sun_elevation_deg) - var az := deg_to_rad(sun_azimuth_deg) - var to_sun := Vector3(sin(az) * cos(el), sin(el), cos(az) * cos(el)) - sun.global_basis = Basis.looking_at(-to_sun, Vector3.UP) +# --- survival environment --------------------------------------------------- + +## 0..100 air warmth at `pos` before fires: time of day, minus altitude. +func air_warmth(pos: Vector3) -> float: + var w: float = day_night.air_warmth() + w -= maxf(pos.y - 12.0, 0.0) * 0.9 + return w + +## 0..1 heat from fires and a held torch. +func heat_at(pos: Vector3) -> float: + var h := 0.0 + for n in get_tree().get_nodes_in_group("heat"): + h = maxf(h, n.heat_at(pos)) + if player.hands != null: + h = maxf(h, player.hands.held_heat()) + return h + +## Death: everything you carried goes into a grave where you fell, and you +## wake up at the island start. The world carries on. +func _on_player_died() -> void: + var pos := player.global_position + pos.y = terrain.sample_height(pos.x, pos.z) + if pos.y < terrain.water_level: + # Drowned or died in the lake: put the grave on the nearest dry shore. + pos = terrain.find_spawn(Vector2(pos.x, pos.z)) + var has_items := false + for st in player.inventory.slots: + if st != null: + has_items = true + if has_items: + var grave := spawn_piece(ItemDB.get_item("grave"), Transform3D(Basis(Vector3.UP, player.rotation.y), pos)) + grave.inventory.load_data(player.inventory.to_data()) + player.inventory.load_data(Inventory.new(player.inventory.size()).to_data()) + if hud != null: + hud.close_screens() + _respawn() + player.survival.reset_after_death() + Actions.notify.emit("You died. Your belongings are in your grave." if has_items else "You died.") func _on_terrain_ready() -> void: var t0 := Time.get_ticks_msec() @@ -146,6 +187,11 @@ func collect_save() -> Dictionary: harvested[uid] = 0.0 elif n.hp < n.max_hp: harvested[uid] = n.hp + var regrow := {} + for uid in props.forage: + var f = props.forage[uid] + if is_instance_valid(f) and f.regrow_left > 0.0: + regrow[uid] = f.regrow_left var taken: Array = [] for uid in props.pickups: var pk = props.pickups[uid] @@ -158,6 +204,8 @@ func collect_save() -> Dictionary: var entry := {"item": piece.item_id, "xf": _xf_to_array(piece.global_transform)} if piece.get("inventory") != null: entry["inventory"] = piece.inventory.to_data() + if piece.has_method("save_extra"): + entry["extra"] = piece.save_extra() pieces.append(entry) return { "player": { @@ -166,7 +214,10 @@ func collect_save() -> Dictionary: "pitch": cam.rotation.x, "inventory": player.inventory.to_data(), "selected": player.selected, + "stats": player.survival.to_data(), }, + "time": {"day": day_night.day, "time": day_night.time}, + "forage_regrow": regrow, "harvested": harvested, "pickups_taken": taken, "pieces": pieces, @@ -181,6 +232,17 @@ func apply_save(s: Dictionary) -> void: if p.has("inventory"): player.inventory.load_data(p.inventory) player.select(int(p.get("selected", 0))) + if p.has("stats"): + player.survival.load_data(p.stats) + var t: Dictionary = s.get("time", {}) + if not t.is_empty(): + day_night.day = int(t.get("day", 1)) + day_night.time = float(t.get("time", day_night.time)) + day_night.apply(true) + var regrow: Dictionary = s.get("forage_regrow", {}) + for uid in regrow: + if props.forage.has(uid): + props.forage[uid].restore(float(regrow[uid])) var harvested: Dictionary = s.get("harvested", {}) for uid in harvested: if props.resource_nodes.has(uid): @@ -195,6 +257,8 @@ func apply_save(s: Dictionary) -> void: 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) + if e.has("extra") and piece.has_method("load_extra"): + piece.load_extra(e.extra) func _respawn() -> void: player.global_position = _spawn + Vector3(0.0, 1.5, 0.0) @@ -322,6 +386,19 @@ func _capture_survival(prefix: String, cam: Camera3D) -> void: get_viewport().get_texture().get_image().save_png("%s_camp.png" % prefix) print("[world] saved %s_camp.png" % prefix) + # Same camp at dusk and at night, torch in hand. + inv.set_slot(1, {"id": "torch", "count": 1}) + player.select(1) + for shot in [["dusk", 18.3], ["night", 22.5]]: + day_night.time = float(shot[1]) / 24.0 + day_night.apply(true) + await get_tree().create_timer(1.5).timeout + await RenderingServer.frame_post_draw + get_viewport().get_texture().get_image().save_png("%s_camp_%s.png" % [prefix, shot[0]]) + print("[world] saved %s_camp_%s.png" % [prefix, shot[0]]) + day_night.time = 9.0 / 24.0 + day_night.apply(true) + chest.inventory.add("wood", 40) chest.inventory.add("stone", 12) player.global_position = chest.global_position + fwd * -2.0 diff --git a/shaders/sky.gdshader b/shaders/sky.gdshader index 5d32676..5a65310 100644 --- a/shaders/sky.gdshader +++ b/shaders/sky.gdshader @@ -1,16 +1,25 @@ shader_type sky; -// Gradient sky + sun disc/halo + a static cloud layer projected onto a dome. -// Deliberately no TIME: a time-varying sky forces Godot to rebuild the ambient -// radiance map every frame. +// Gradient sky + sun disc/halo + a static cloud layer projected onto a dome, +// plus stars at night. Colours and energy are driven by day_night.gd, a few +// times a second rather than every frame. Deliberately no TIME: a time-varying +// sky forces Godot to rebuild the ambient radiance map every frame. uniform vec3 zenith_col : source_color = vec3(0.24, 0.44, 0.72); uniform vec3 horizon_col : source_color = vec3(0.72, 0.79, 0.85); uniform vec3 ground_col : source_color = vec3(0.30, 0.33, 0.32); uniform vec3 sun_tint : source_color = vec3(1.0, 0.86, 0.62); +uniform vec3 cloud_lit : source_color = vec3(1.0, 1.0, 1.0); uniform sampler2D cloud_tex : filter_linear_mipmap, repeat_enable; uniform float cloud_cover = 0.5; uniform float energy = 1.0; +uniform float star_strength = 0.0; + +float hash(vec3 p) { + p = fract(p * 0.3183099 + 0.1); + p *= 17.0; + return fract(p.x * p.y * p.z * (p.x + p.y + p.z)); +} void sky() { vec3 d = EYEDIR; @@ -23,19 +32,28 @@ void sky() { sd = max(dot(d, LIGHT0_DIRECTION), 0.0); col += sun_tint * pow(sd, 6.0) * 0.35 * (1.0 - smoothstep(0.0, 0.5, h)); col += sun_tint * pow(sd, 64.0) * 0.6; - col += LIGHT0_COLOR * smoothstep(0.9994, 0.9997, sd) * 20.0; + col += LIGHT0_COLOR * smoothstep(0.9994, 0.9997, sd) * 20.0 * LIGHT0_ENERGY; } + float cov = 0.0; if (h > 0.0) { vec2 uv = d.xz / (h + 0.12); float c = texture(cloud_tex, uv * 0.08).r * 0.65 + texture(cloud_tex, uv * 0.23 + 0.31).r * 0.35; - float cov = smoothstep(1.0 - cloud_cover, 1.0 - cloud_cover + 0.22, c); + cov = smoothstep(1.0 - cloud_cover, 1.0 - cloud_cover + 0.22, c); cov *= smoothstep(0.0, 0.18, h); float shade = smoothstep(0.35, 1.0, c); - vec3 cloud = mix(vec3(1.0), vec3(0.62, 0.66, 0.72), shade * 0.8); + vec3 cloud = mix(cloud_lit, cloud_lit * vec3(0.62, 0.66, 0.72), shade * 0.8); cloud += sun_tint * pow(sd, 4.0) * 0.5; col = mix(col, cloud, cov * 0.9); } + col *= energy; - COLOR = col * energy; + // Stars: a sparse hash over direction cells, hidden by clouds and haze. + if (star_strength > 0.0 && h > 0.0) { + float s = hash(floor(d * 260.0)); + float star = step(0.9972, s) * (0.5 + 0.5 * fract(s * 91.7)); + col += vec3(0.9, 0.93, 1.0) * star * star_strength * smoothstep(0.02, 0.25, h) * (1.0 - cov) * 2.0; + } + + COLOR = col; } diff --git a/tests/test_save_load.gd b/tests/test_save_load.gd index 93d76aa..bc642d3 100644 --- a/tests/test_save_load.gd +++ b/tests/test_save_load.gd @@ -8,6 +8,7 @@ extends SceneTree ## Run: godot --headless --path . --script res://tests/test_save_load.gd var _fails := 0 +var _finished := false var A: Node # Actions var S: Node # Saves var DB: Node # ItemDB @@ -114,9 +115,11 @@ func _run() -> void: 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") + _finished = true world.queue_free() S.delete_world(id) DirAccess.remove_absolute(S.root_dir) + check(_finished, "test ran to the end (no script errors)") print("RESULT: %s (%d failures)" % ["PASS" if _fails == 0 else "FAIL", _fails]) quit(1 if _fails > 0 else 0) diff --git a/tests/test_survival_core.gd b/tests/test_survival_core.gd index f043702..92e794e 100644 --- a/tests/test_survival_core.gd +++ b/tests/test_survival_core.gd @@ -6,6 +6,7 @@ extends SceneTree ## Run: godot --headless --path . --script res://tests/test_survival_core.gd var _fails := 0 +var _finished := false ## set at the very end; a script error aborts before it var _actions: Node var _db: Node @@ -28,6 +29,7 @@ func _run() -> void: for _i in range(5): await physics_frame await _test_world(world) + check(_finished, "test ran to the end (no script errors)") print("RESULT: %s (%d failures)" % ["PASS" if _fails == 0 else "FAIL", _fails]) quit(1 if _fails > 0 else 0) @@ -134,6 +136,7 @@ func _test_world(world: Node3D) -> void: 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) + _finished = true func ItemDB_get(_p, id: String) -> Dictionary: return _db.get_item(id) diff --git a/tests/test_survival_stats.gd b/tests/test_survival_stats.gd new file mode 100644 index 0000000..553fa64 --- /dev/null +++ b/tests/test_survival_stats.gd @@ -0,0 +1,189 @@ +extends SceneTree +## M1b: day/night, hunger, warmth, stamina, campfire fuel + heat + cooking, +## berry bushes, eating, death + grave, and saving all of it. +## +## Fast-forwards time by calling survival.step() / day_night.advance() / the +## fire's _process() directly, so the whole test runs in seconds. +## Run: godot --headless --path . --script res://tests/test_survival_stats.gd + +var _fails := 0 +var _finished := false +var A: Node +var S: 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 _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 + +## Advances the simulation `seconds` in 1 s steps (stats + clock + fires). +func _ff(world: Node3D, seconds: float) -> void: + var player: Node = world.get_node("Player") + for _i in range(int(seconds)): + world.day_night.advance(1.0) + for f in world.get_tree().get_nodes_in_group("heat"): + f._process(1.0) + player.survival.step(1.0) + +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) + var id: String = S.create_world("Stats", 1337) + S.current_id = id + var world: Node3D = await _spawn_world() + await _body(world, id) + check(_finished, "test ran to the end (no script errors)") + 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 _body(world: Node3D, id: String) -> void: + var player: CharacterBody3D = world.get_node("Player") + var sv: Node = player.survival + var dn: Node = world.day_night + var inv: Inventory = player.inventory + var terrain: Node3D = world.get_node("Terrain") + player.set_physics_process(false) # keep the player put while we fast-forward + sv.set_process(false) + + print("day / night") + check(dn.day == 1 and absf(dn.hours() - 8.0) < 0.1, "new world starts on day 1 at 08:00 (%s)" % dn.clock_text()) + dn.time = 0.5; dn.apply(true) + check(dn.daylight > 0.9, "noon is bright (daylight %.2f)" % dn.daylight) + dn.time = 0.0; dn.apply(true) + check(dn.daylight < 0.05, "midnight is dark (daylight %.2f)" % dn.daylight) + dn.time = 0.99 + dn.advance(dn.day_length * 0.02) + check(dn.day == 2, "clock rolls over to day 2") + + print("hunger and eating") + dn.time = 0.5; dn.apply(true) + var h0: float = sv.hunger + _ff(world, 300) + check(sv.hunger < h0 - 15.0, "hunger drains over 5 min (%.0f -> %.0f)" % [h0, sv.hunger]) + inv.add("berries", 3) + _hold(player, "berries") + var h1: float = sv.hunger + check(A.eat(player, 0) and sv.hunger > h1 and inv.count("berries") == 2, "eating berries restores food") + + print("cold and fire") + dn.time = 21.0 / 24.0; dn.apply(true) # night (fast-forward below stays before dawn) + sv.warmth = 60.0 + _ff(world, 240) + check(sv.warmth < 30.0 and "Cold" in sv.statuses, "a night in the open gets cold (warmth %.0f, %s)" % [sv.warmth, sv.statuses]) + var spot := player.global_position + Vector3(2.0, 0, 0) + spot.y = terrain.sample_height(spot.x, spot.z) + var fire: Node = world.spawn_piece(DB.get_item("campfire"), Transform3D(Basis(), spot)) + _ff(world, 30) + check(sv.warmth > 55.0 and "Warm" in sv.statuses, "sitting by the fire warms you (warmth %.0f)" % sv.warmth) + + print("fuel and cooking") + inv.add("mushroom", 2) + check(A.can_craft(player, "roasted_mushroom"), "can roast a mushroom at a lit fire") + fire._process(fire.fuel + 1.0) + check(not fire.is_lit() and fire.heat_at(player.global_position) == 0.0, "the fire burns out when its fuel runs out") + check(not A.can_craft(player, "roasted_mushroom"), "an unlit fire can't cook") + inv.add("wood", 2) + check(A.add_fuel(player, fire) and fire.is_lit() and inv.count("wood") == 1, "adding wood relights it") + check(A.craft(player, "roasted_mushroom") and inv.count("roasted_mushroom") == 1, "roasted a mushroom") + + print("stamina") + sv.stamina = 100.0 + var swings := 0 + while sv.use_stamina(6.0) and swings < 100: + swings += 1 + check(swings >= 14 and swings < 30, "about 16 swings empty your stamina (%d)" % swings) + _ff(world, 6) + check(sv.stamina > 90.0, "stamina regenerates after a pause") + + print("berry bushes") + var bush: Node3D = world.get_node("Props").forage.values()[0] + player.global_position = bush.global_position + Vector3(1.0, 0.3, 0) + var b0 := inv.count("berries") + check(A.forage(player, bush) and inv.count("berries") > b0 and inv.count("fiber") >= 1, "foraged berries and fiber") + check(not A.forage(player, bush), "a picked bush is bare") + bush._process(bush.regrow_seconds + 1.0) + check(bush.ripe(), "it regrows") + inv.add("stick", 1) + inv.add("fiber", 2) + check(A.craft(player, "torch") and inv.count("torch") == 1, "torch crafted from stick + fiber") + _hold(player, "torch") + check(player.hands.held_heat() > 0.0, "a held torch gives a little warmth") + + print("save + load") + sv.hunger = 42.0 + sv.warmth = 33.0 + fire.fuel = 321.0 + A.forage(player, bush) + var regrow_left: float = bush.regrow_left + dn.day = 3; dn.time = 0.75 + world.save_now() + world.queue_free() + await process_frame + await process_frame + world = await _spawn_world() + player = world.get_node("Player") + sv = player.survival + check(absf(sv.hunger - 42.0) < 0.5 and absf(sv.warmth - 33.0) < 0.5, "stats restored (food %.0f, warmth %.0f)" % [sv.hunger, sv.warmth]) + check(world.day_night.day == 3 and absf(world.day_night.hours() - 18.0) < 0.2, "time of day restored (%s)" % world.day_night.clock_text()) + var fire2: Node = null + for p in world.get_node("Pieces").get_children(): + if p.item_id == "campfire": + fire2 = p + check(fire2 != null and absf(fire2.fuel - 321.0) < 2.0 and fire2.is_lit(), "campfire fuel restored") + var bush2: Node = world.get_node("Props").forage[bush.uid] if is_instance_valid(bush) else world.get_node("Props").forage.values()[0] + check(absf(bush2.regrow_left - regrow_left) < 2.0, "bush regrow timer restored") + + print("death and grave") + player.set_physics_process(false) + sv.set_process(false) + player.inventory.add("wood", 9) + player.inventory.add("stone_axe", 1) + var death_pos := player.global_position + sv.hunger = 0.0 + sv.health = 0.5 + sv.step(2.0) + check(sv.health == 50.0 and not sv.dead, "you respawn with half health") + check(player.inventory.count("wood") == 0, "your inventory went into the grave") + var grave: Node = null + for p in world.get_node("Pieces").get_children(): + if p.item_id == "grave": + grave = p + check(grave != null and grave.inventory.count("wood") >= 9 and grave.inventory.count("stone_axe") == 1, "the grave holds your things") + check(grave != null and grave.global_position.distance_to(death_pos) < 15.0, "grave is where you fell") + player.global_position = grave.global_position + Vector3(1, 0.5, 0) + for i in range(grave.inventory.size()): + if grave.inventory.get_slot(i) != null: + A.transfer(player, grave.inventory, i, player.inventory, grave) + await process_frame + check(player.inventory.count("stone_axe") == 1, "recovered your belongings") + check(not is_instance_valid(grave) or grave.is_queued_for_deletion(), "the empty grave crumbles") + world.queue_free() + _finished = true + +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)