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
This commit is contained in:
Paul Bailey
2026-11-04 01:19:41 +00:00
parent 2b1afc86ba
commit fc7eb3cfed
28 changed files with 1326 additions and 97 deletions

View File

@@ -10,8 +10,6 @@ extends Node3D
## Dev hook: `godot --path . -- --shot=/abs/prefix` renders a few fixed views to
## <prefix>_<view>.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