319 lines
11 KiB
GDScript
319 lines
11 KiB
GDScript
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
|
|
const REST_AFTER := 20.0 ## seconds sheltered by a fire before you're Rested
|
|
const REST_SECONDS := 300.0 ## Rested: stamina regenerates 50% faster
|
|
|
|
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 roofed := false ## a roof overhead (rain can't reach you)
|
|
var sheltered := false ## roof and walls (the weather barely reaches you)
|
|
var rested := 0.0 ## seconds of Rested left
|
|
var _shelter_left := 0.0
|
|
var _rest_build := 0.0
|
|
## Food buffs: id -> {name, left (s), stamina_regen, health_regen (multipliers)}.
|
|
var buffs: Dictionary = {}
|
|
var sick := 0.0 ## seconds of food poisoning left (P4): slow stamina, can't eat
|
|
const SICK_SECONDS := 60.0
|
|
var _hurt_cd := 0.0 ## no natural healing for a few seconds after a hit
|
|
var cause := "" ## what last hurt you, as the death screen says it ("You froze")
|
|
var drain_mult := 1.0 ## hunger and diet drain (a server's difficulty, rules.gd)
|
|
|
|
signal hurt(amount: float, from: Vector3)
|
|
const HURT_NO_REGEN := 6.0
|
|
|
|
## Mixed diet: each food group (meat, fish, greens) has a level that runs
|
|
## down over about a day and a half. Food from a group tops it up.
|
|
## - One group low: you don't heal naturally ("Poor diet").
|
|
## - Two or more low: Malnourished. Health slowly drains, stamina recovers
|
|
## slower. Living on berries alone will kill you.
|
|
## - All three kept up: Balanced diet, healing x1.5.
|
|
const DIET_GROUPS := ["meat", "fish", "plants"]
|
|
const DIET_NAMES := {"meat": "meat", "fish": "fish", "plants": "greens"}
|
|
const DIET_DRAIN := 100.0 / 1800.0
|
|
const DIET_LOW := 15.0
|
|
const DIET_GOOD := 40.0
|
|
const DIET_START := 60.0
|
|
const MALNOURISHED_DRAIN := 0.12
|
|
var diet := {"meat": DIET_START, "fish": DIET_START, "plants": DIET_START}
|
|
|
|
## The groups that are running low right now.
|
|
func diet_lacking() -> Array:
|
|
var out := []
|
|
for g in DIET_GROUPS:
|
|
if float(diet[g]) < DIET_LOW:
|
|
out.append(g)
|
|
return out
|
|
|
|
func balanced_diet() -> bool:
|
|
for g in DIET_GROUPS:
|
|
if float(diet[g]) < DIET_GOOD:
|
|
return false
|
|
return true
|
|
|
|
var stone_room := false ## sheltered by mostly stone walls (P2): a little warmer
|
|
const STONE_ROOM_WARMTH := 6.0
|
|
var cozy := false ## sheltered with a fur rug near (P3, the bear)
|
|
const COZY_WARMTH := 4.0
|
|
|
|
func _rug_near(p: Vector3) -> bool:
|
|
for r in world.get_tree().get_nodes_in_group("rug"):
|
|
if (r as Node3D).global_position.distance_to(p) < r.COZY_RADIUS:
|
|
return true
|
|
return false
|
|
|
|
var player: Node3D ## the local player, or a server_player.gd on a server
|
|
var world: Node3D
|
|
var _stamina_wait := 0.0
|
|
|
|
func _process(delta: float) -> void:
|
|
# (Online, the server runs our stats and sends them: player_net.gd.)
|
|
if world != null and not world.menu_mode and not world.is_online():
|
|
step(delta)
|
|
|
|
func step(dt: float) -> void:
|
|
if dead:
|
|
return
|
|
var p := player.global_position
|
|
heat = world.heat_at(p, player)
|
|
_shelter_left -= dt
|
|
if _shelter_left <= 0.0:
|
|
_shelter_left = 0.25
|
|
roofed = world.build.roofed_at(p)
|
|
sheltered = roofed and world.build.enclosure_at(p) >= 0.74
|
|
stone_room = sheltered and world.build.stone_walls_at(p) >= 0.5
|
|
cozy = sheltered and _rug_near(p)
|
|
# Rain soaks you unless there's a roof over your head.
|
|
if not roofed and world.weather.rain_at(p) > 0.25:
|
|
wet = WET_SECONDS
|
|
# Resting: a while by a fire with a roof and walls around you (half the
|
|
# time on a fur rug).
|
|
if sheltered and heat > 0.25:
|
|
_rest_build += dt * (2.0 if cozy else 1.0)
|
|
if _rest_build >= REST_AFTER:
|
|
rested = maxf(rested, REST_SECONDS)
|
|
else:
|
|
_rest_build = 0.0
|
|
rested = maxf(rested - dt, 0.0)
|
|
var wear: Dictionary = ItemDB.get_item(player.worn).get("wear", {}) if not player.worn.is_empty() else {}
|
|
var target: float = world.air_warmth(p, sheltered) + heat * 70.0 + float(wear.get("warmth", 0.0))
|
|
if stone_room:
|
|
target += STONE_ROOM_WARMTH
|
|
if cozy:
|
|
target += COZY_WARMTH
|
|
if wet > 0.0:
|
|
target -= 22.0 * float(wear.get("wet_chill", 1.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.
|
|
# (Out in a blizzard you lose it twice as fast.)
|
|
var cool := 1.6 * (2.0 if world.weather.blizzard_chill(sheltered) > 0.0 else 1.0)
|
|
warmth = move_toward(warmth, target, (7.0 if target > warmth else cool) * dt)
|
|
|
|
hunger = maxf(hunger - HUNGER_DRAIN * drain_mult * dt * (1.5 if warmth < 30.0 else 1.0), 0.0)
|
|
|
|
if _stamina_wait > 0.0:
|
|
_stamina_wait -= dt
|
|
else:
|
|
var malnourished := diet_lacking().size() >= 2
|
|
stamina = minf(stamina + STAMINA_REGEN * (1.5 if rested > 0.0 else 1.0) * buff_mult("stamina_regen")
|
|
* (0.5 if sick > 0.0 else 1.0)
|
|
* (0.7 if malnourished else 1.0) * dt, MAX)
|
|
|
|
for g in DIET_GROUPS:
|
|
diet[g] = maxf(float(diet[g]) - DIET_DRAIN * drain_mult * dt, 0.0)
|
|
var lacking := diet_lacking()
|
|
|
|
for id in buffs.keys():
|
|
buffs[id].left -= dt
|
|
if buffs[id].left <= 0.0:
|
|
buffs.erase(id)
|
|
_hurt_cd = maxf(_hurt_cd - dt, 0.0)
|
|
sick = maxf(sick - dt, 0.0)
|
|
|
|
var dmg := 0.0
|
|
if hunger <= 0.0:
|
|
dmg += 0.6
|
|
if warmth < 12.0:
|
|
dmg += 0.5
|
|
if lacking.size() >= 2:
|
|
dmg += MALNOURISHED_DRAIN
|
|
if dmg > 0.0:
|
|
health = maxf(health - dmg * dt, 0.0)
|
|
if hunger <= 0.0:
|
|
cause = "You starved"
|
|
elif warmth < 12.0:
|
|
cause = "You froze to death"
|
|
else:
|
|
cause = "Malnourished: you needed %s" % " and ".join(PackedStringArray(lacking.map(func(g): return DIET_NAMES[g])))
|
|
elif hunger > 40.0 and warmth > 35.0 and _hurt_cd <= 0.0 and lacking.is_empty():
|
|
var diet_mult := 1.5 if balanced_diet() else 1.0
|
|
health = minf(health + 0.5 * buff_mult("health_regen") * diet_mult * 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")
|
|
var lacking := diet_lacking()
|
|
if not lacking.is_empty():
|
|
var need: Array[String] = []
|
|
for g in lacking:
|
|
need.append(DIET_NAMES[g])
|
|
statuses.append("%s (need %s)" % ["Malnourished" if lacking.size() >= 2 else "Poor diet", ", ".join(need)])
|
|
elif balanced_diet():
|
|
statuses.append("Balanced diet")
|
|
if sick > 0.0:
|
|
statuses.append("Food poisoning")
|
|
if sheltered:
|
|
statuses.append("Sheltered")
|
|
if cozy:
|
|
statuses.append("Cozy")
|
|
if rested > 0.0:
|
|
statuses.append("Rested")
|
|
for id in buffs:
|
|
statuses.append("%s %d:%02d" % [buffs[id].name, int(buffs[id].left) / 60, int(buffs[id].left) % 60])
|
|
|
|
## 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)
|
|
# Raw meat can hurt a little, but never kills you.
|
|
health = clampf(health + float(food.get("health", 0)), minf(health, 1.0), MAX)
|
|
if food.has("buff"):
|
|
add_buff(food.buff)
|
|
var d: Dictionary = food.get("diet", {})
|
|
for g in d:
|
|
if diet.has(g):
|
|
diet[g] = minf(float(diet[g]) + float(d[g]), MAX)
|
|
_update_statuses()
|
|
changed.emit()
|
|
|
|
func soak() -> void:
|
|
wet = WET_SECONDS
|
|
|
|
## Something you ate disagreed with you (raw meat, raw fish): stamina comes
|
|
## back at half speed and you can't keep anything down for a minute.
|
|
func poison() -> void:
|
|
sick = SICK_SECONDS
|
|
_update_statuses()
|
|
changed.emit()
|
|
|
|
## After a night's sleep: a little hungrier, dry, warm and rested.
|
|
func after_sleep(seconds: float) -> void:
|
|
hunger = maxf(hunger - HUNGER_DRAIN * seconds * 0.3, 0.0)
|
|
wet = 0.0
|
|
warmth = maxf(warmth, 60.0)
|
|
stamina = MAX
|
|
rested = REST_SECONDS * 2.0
|
|
_update_statuses()
|
|
changed.emit()
|
|
|
|
func reset_after_death() -> void:
|
|
dead = false
|
|
health = 50.0
|
|
stamina = MAX
|
|
hunger = 50.0
|
|
warmth = 60.0
|
|
wet = 0.0
|
|
buffs.clear()
|
|
cause = ""
|
|
# Don't respawn straight back into malnutrition.
|
|
for g in DIET_GROUPS:
|
|
diet[g] = maxf(float(diet[g]), 30.0)
|
|
changed.emit()
|
|
|
|
## Takes a hit (a boar, later wolves). Pauses natural healing briefly.
|
|
## `by` is what did it ("a wolf"), for the death screen.
|
|
func damage(amount: float, from: Vector3, by: String = "") -> void:
|
|
if dead:
|
|
return
|
|
cause = "Killed by %s" % by if not by.is_empty() else "You were killed"
|
|
health = maxf(health - amount, 0.0)
|
|
_hurt_cd = HURT_NO_REGEN
|
|
hurt.emit(amount, from)
|
|
changed.emit()
|
|
if health <= 0.0:
|
|
dead = true
|
|
died.emit()
|
|
|
|
## Multiplier from food buffs for one effect (1.0 = none). Buffs don't stack:
|
|
## the strongest applies.
|
|
func buff_mult(key: String) -> float:
|
|
var m := 1.0
|
|
for id in buffs:
|
|
m = maxf(m, float(buffs[id].get(key, 1.0)))
|
|
return m
|
|
|
|
func add_buff(b: Dictionary) -> void:
|
|
var id: String = b.get("id", "buff")
|
|
buffs[id] = {"name": b.get("name", id), "left": float(b.get("seconds", 300.0)),
|
|
"stamina_regen": float(b.get("stamina_regen", 1.0)), "health_regen": float(b.get("health_regen", 1.0))}
|
|
_update_statuses()
|
|
|
|
func to_data() -> Dictionary:
|
|
return {"health": health, "stamina": stamina, "hunger": hunger, "warmth": warmth, "wet": wet, "rested": rested,
|
|
"buffs": buffs.duplicate(true), "diet": diet.duplicate(), "sick": sick}
|
|
|
|
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))
|
|
rested = float(d.get("rested", 0.0))
|
|
sick = float(d.get("sick", 0.0))
|
|
buffs = (d.get("buffs", {}) as Dictionary).duplicate(true)
|
|
# Older saves have no diet: start it where a new world does.
|
|
var sd: Dictionary = d.get("diet", {})
|
|
for g in DIET_GROUPS:
|
|
diet[g] = float(sd.get(g, DIET_START))
|
|
dead = false
|
|
changed.emit()
|