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

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