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 _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 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 # 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. if sheltered and heat > 0.25: _rest_build += dt 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 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. warmth = move_toward(warmth, target, (7.0 if target > warmth else 1.6) * 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.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) 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 sheltered: statuses.append("Sheltered") 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 ## 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()} 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)) 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()