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,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()

View File

@@ -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:

127
scripts/player/survival.gd Normal file
View File

@@ -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()

View File

@@ -0,0 +1 @@
uid://ibwy562x1kqp