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

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

View File

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

View File

@@ -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)"],