P4 hazards: food spoilage with rot speed setting, smoking rack and salt, food poisoning, harsher blizzards, hard winters with lake ice, lightning on trees, snow on roofs (protocol 11)

This commit is contained in:
Paul Bailey
2026-11-05 11:49:13 +00:00
parent 657c3140fe
commit 3389ec9a21
27 changed files with 1541 additions and 56 deletions

View File

@@ -18,7 +18,17 @@ const RECIPES := {
{"inputs": {"iron_ore": 1, "charcoal": 1}, "output": "iron_bar", "seconds": 30.0},
{"inputs": {"iron_nugget": 3, "charcoal": 1}, "output": "iron_bar", "seconds": 30.0},
],
# P4: smoked food keeps for days (raw rots in two).
"smoking_rack": [
{"inputs": {"raw_meat": 2, "wood": 1}, "output": "smoked_meat", "count": 2, "seconds": 60.0},
{"inputs": {"perch": 2, "wood": 1}, "output": "smoked_fish", "count": 2, "seconds": 60.0},
{"inputs": {"trout": 1, "wood": 1}, "output": "smoked_fish", "count": 2, "seconds": 60.0},
],
}
## What the prompt says it makes, and what it needs when idle.
const MAKES := {"charcoal_kiln": "charcoal", "smelter": "an iron bar", "smoking_rack": "smoked food"}
const NEEDS := {"charcoal_kiln": "put in wood", "smelter": "put in iron ore (or 3 nuggets) and charcoal",
"smoking_rack": "put in raw meat or fish, and wood for the fire"}
var inventory := Inventory.new(SLOTS)
var progress := 0.0 ## seconds into the current batch
@@ -49,7 +59,9 @@ func _build() -> void:
m.emission = Color(1.0, 0.45, 0.12)
m.emission_energy_multiplier = 3.0
_glow.material_override = m
_glow.position = Vector3(0, 0.3 if item_id == "smelter" else 0.25, 0.58 if item_id == "smelter" else 0.72)
_glow.position = {"smelter": Vector3(0, 0.3, 0.58), "smoking_rack": Vector3(0, 0.12, 0.0)}.get(item_id, Vector3(0, 0.25, 0.72))
if item_id == "smoking_rack":
_glow.rotation.x = -PI * 0.5 # (embers on the ground under the rack)
_glow.visible = false
add_child(_glow)
@@ -82,7 +94,7 @@ func step(secs: float) -> void:
progress -= float(r.seconds)
for id in r.inputs:
inventory.remove(id, int(r.inputs[id]))
inventory.add(r.output, 1)
inventory.add(r.output, int(r.get("count", 1)))
r = current()
if r.is_empty():
progress = 0.0
@@ -107,10 +119,9 @@ func dismantle_block_reason() -> String:
func interact_prompt(_player: Node) -> String:
var r := current()
var what := "charcoal" if item_id == "charcoal_kiln" else "an iron bar"
var what: String = MAKES.get(item_id, "something")
if r.is_empty():
var needs := "put in wood" if item_id == "charcoal_kiln" else "put in iron ore (or 3 nuggets) and charcoal"
return "%s (idle: %s) [E] Open" % [display_name(), needs]
return "%s (idle: %s) [E] Open" % [display_name(), NEEDS.get(item_id, "put something in")]
if WorldNet.is_client():
return "%s (making %s) [E] Open" % [display_name(), what]
return "%s (making %s, %d s) [E] Open" % [display_name(), what, ceili(float(r.seconds) - progress)]

View File

@@ -622,14 +622,24 @@ func eat(player: Node3D, slot: int) -> bool:
var food: Dictionary = ItemDB.get_item(s.id).get("food", {})
if food.is_empty():
return false
if player.survival.sick > 0.0:
tell(player, "You feel too sick to eat")
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())
# Raw meat and fish can make you ill (P4). Cooked, smoked or salted can't.
if food_rng.randf() < float(food.get("risk", 0.0)):
player.survival.poison()
tell(player, "Your stomach turns: food poisoning. Cook your meat!")
return true
## Rolls for food poisoning (tests seed it).
var food_rng := RandomNumberGenerator.new()
## Feeds a campfire from the player's inventory: wood first, then sticks.
func add_fuel(player: Node3D, fire: Node3D) -> bool:

View File

@@ -33,19 +33,103 @@ static func _stack_max(id: String) -> int:
## A stack can carry "dur": uses left on a tool or weapon that wears out
## (ItemDB "durability"). No "dur" means as new.
## Food that spoils (ItemDB "keeps", in days) carries "rot": the world time
## (seconds since day 1, 00:00) at which it goes rotten. It's stamped when
## the food first goes into an inventory; two stacks that merge share the
## average. world.gd turns rotten stacks into rotten food (spoil()).
static func _norm(stack):
if stack == null or int(stack.count) <= 0:
return null
var out := {"id": String(stack.id), "count": int(stack.count)}
if stack.has("dur"):
out.dur = int(stack.dur)
if stack.has("rot"):
out.rot = float(stack.rot)
return out
## Adds a whole stack, keeping what's special about it (a worn tool's "dur").
## Returns how many didn't fit.
## What's special about a stack, for when it's dropped on the ground (and
## kept when it's picked up again): a tool's wear, food's freshness.
static func extra_of(stack: Dictionary) -> Dictionary:
var out := {}
if stack.has("dur"):
out.dur = int(stack.dur)
if stack.has("rot"):
out.rot = float(stack.rot)
return out
# --- spoilage (P4, docs/PROGRESSION_DESIGN.md §5) ---------------------------------
## The world clock (seconds since day 1, 00:00) and how long a day is, kept
## up to date by world.gd. `rot_speed`: 1 normal, 0.5 slow, 0 off (the
## world's or server's "rot" setting).
static var clock := 0.0
static var day_seconds := 1200.0
static var rot_speed := 1.0
const ROTTEN := "rotten_food"
static func _db() -> Node:
return (Engine.get_main_loop() as SceneTree).root.get_node_or_null("ItemDB")
## Days `id` keeps before it rots (0: it doesn't).
static func keeps_days(id: String) -> float:
var db := _db()
return float(db.get_item(id).get("keeps", 0.0)) if db != null else 0.0
## How long `id` keeps, in world seconds, at the current rot speed (0: forever).
static func keeps_for(id: String) -> float:
var d := keeps_days(id)
if d <= 0.0 or rot_speed <= 0.0:
return 0.0
return d * day_seconds / rot_speed
## The "rot" stamp for `id` made right now (-1: it doesn't spoil).
static func fresh_rot(id: String) -> float:
var k := keeps_for(id)
# (Whole seconds, so a stamp comes back from a JSON save exactly as it went in.)
return roundf(clock + k) if k > 0.0 else -1.0
## 0..1: how fresh a stack is (1 = just made or doesn't spoil).
static func freshness(stack) -> float:
if stack == null or not stack.has("rot"):
return 1.0
var k := keeps_for(String(stack.id))
if k <= 0.0:
return 1.0
return clampf((float(stack.rot) - clock) / k, 0.0, 1.0)
## In-game hours until a stack rots (INF: never).
static func hours_left(stack) -> float:
if stack == null or not stack.has("rot") or rot_speed <= 0.0:
return INF
return maxf(float(stack.rot) - clock, 0.0) / day_seconds * 24.0
## Stamps food that has no "rot" yet, and turns food past its time into
## rotten food. Returns how many items went rotten.
func spoil() -> int:
if rot_speed <= 0.0:
return 0
var rotted := 0
var touched := false
for i in range(slots.size()):
var s = slots[i]
if s == null or keeps_days(String(s.id)) <= 0.0:
continue
if not s.has("rot"):
s.rot = fresh_rot(String(s.id))
touched = true
elif clock >= float(s.rot):
rotted += int(s.count)
slots[i] = {"id": ROTTEN, "count": int(s.count)}
touched = true
if touched:
changed.emit()
return rotted
## Adds a whole stack, keeping what's special about it (a worn tool's "dur",
## food's "rot"). Returns how many didn't fit.
func add_stack(stack: Dictionary) -> int:
if not stack.has("dur"):
return add(String(stack.id), int(stack.count))
return add(String(stack.id), int(stack.count), float(stack.get("rot", -1.0)))
for i in range(slots.size()):
if slots[i] == null:
slots[i] = _norm(stack)
@@ -72,14 +156,18 @@ func space_for(id: String) -> int:
return room
## Adds up to `n` of `id`, topping up existing stacks first. Returns the
## leftover that didn't fit (0 = everything fitted).
func add(id: String, n: int) -> int:
## leftover that didn't fit (0 = everything fitted). Food gets a fresh "rot"
## stamp, or `rot` if it's coming from somewhere with one of its own.
func add(id: String, n: int, rot: float = -1.0) -> int:
var cap := _stack_max(id)
var r := rot if rot >= 0.0 else fresh_rot(id)
for s in slots:
if n <= 0:
break
if s != null and s.id == id and s.count < cap:
var take := mini(cap - s.count, n)
if r >= 0.0:
s.rot = _mix_rot(s, r, take)
s.count += take
n -= take
for i in range(slots.size()):
@@ -88,10 +176,20 @@ func add(id: String, n: int) -> int:
if slots[i] == null:
var take := mini(cap, n)
slots[i] = {"id": id, "count": take}
if r >= 0.0:
slots[i].rot = r
n -= take
changed.emit()
return n
## The "rot" of stack `s` once `n` more with `rot` are added to it: the
## average, so mixing fresh and old food gives you food in between.
static func _mix_rot(s: Dictionary, rot: float, n: int) -> float:
if rot < 0.0:
return float(s.get("rot", -1.0))
var have := float(s.get("rot", rot))
return roundf((have * int(s.count) + rot * n) / float(int(s.count) + n))
## Removes `n` of `id` (from the last stacks first, so the hotbar keeps its
## items longest). Returns false and changes nothing if there aren't enough.
func remove(id: String, n: int) -> bool:
@@ -135,9 +233,16 @@ func quick_move(i: int, other: Inventory) -> void:
if s == null:
return
var left := other.add_stack(s)
slots[i] = null if left <= 0 else _norm({"id": s.id, "count": left, "dur": s.dur} if s.has("dur") else {"id": s.id, "count": left})
slots[i] = null if left <= 0 else _part(s, left)
changed.emit()
## `n` of stack `s`, with its wear or freshness.
static func _part(s: Dictionary, n: int):
var out := extra_of(s)
out.id = s.id
out.count = n
return _norm(out)
## Clicking slot `i` while holding `cursor` (a stack or null). Returns the new
## cursor stack. Left click: pick up / put down / merge / swap. Right click:
## pick up half, or put down one.
@@ -150,23 +255,25 @@ func click(i: int, cursor, right: bool):
if right and s.count > 1:
var half := int(ceil(s.count / 2.0))
s.count -= half
result = {"id": s.id, "count": half}
result = _part(s, half)
else:
result = s
slots[i] = null
elif s == null:
if right and cursor.count > 1:
slots[i] = {"id": cursor.id, "count": 1}
result = _norm({"id": cursor.id, "count": cursor.count - 1})
slots[i] = _part(cursor, 1)
result = _part(cursor, cursor.count - 1)
else:
slots[i] = cursor
result = null
elif s.id == cursor.id:
elif s.id == cursor.id and not s.has("dur") and not cursor.has("dur"):
var cap := _stack_max(s.id)
var move: int = 1 if right else cursor.count
move = mini(move, cap - s.count)
if move > 0 and cursor.has("rot"):
s.rot = _mix_rot(s, float(cursor.rot), move)
s.count += move
result = _norm({"id": cursor.id, "count": cursor.count - move})
result = _part(cursor, cursor.count - move)
else:
slots[i] = cursor
result = s

View File

@@ -40,7 +40,9 @@ func list_worlds() -> Array:
out.sort_custom(func(a, b): return float(a.get("last_played", 0)) > float(b.get("last_played", 0)))
return out
func create_world(world_name: String, seed: int) -> String:
## `options` are the world's own settings, kept in meta.json (P4: "rot",
## how fast food spoils: "normal", "slow" or "off").
func create_world(world_name: String, seed: int, options: Dictionary = {}) -> String:
DirAccess.make_dir_recursive_absolute(root_dir)
var base := world_name.to_lower().replace(" ", "_").validate_filename()
if base.is_empty():
@@ -52,10 +54,13 @@ func create_world(world_name: String, seed: int) -> String:
n += 1
DirAccess.make_dir_recursive_absolute(_dir(id))
var now := Time.get_unix_time_from_system()
_write_json(_dir(id).path_join("meta.json"), {
var meta := {
"name": world_name, "seed": seed, "created": now, "last_played": now,
"play_time": 0.0, "version": VERSION,
})
}
for k in options:
meta[k] = options[k]
_write_json(_dir(id).path_join("meta.json"), meta)
return id
## The world slot called exactly `id` (a server's world), made with `seed` if

View File

@@ -81,7 +81,7 @@ func _process(delta: float) -> void:
for kind in BUDGET:
if _cooldown[kind] > 0.0:
continue
if alive(kind).size() < budget(kind, everyone.size()):
if alive(kind).size() < winter_budget(kind, everyone.size()):
var who: Node3D = everyone[0] if everyone.size() == 1 else everyone[_rng.randi() % everyone.size()]
if _spawn_near(kind, RING_MIN, RING_MAX, who.global_position) != null:
_cooldown[kind] = RESPAWN_DELAY * 0.25
@@ -91,6 +91,13 @@ func _process(delta: float) -> void:
static func budget(kind: String, players: int) -> int:
return BUDGET[kind] * clampi(players, 1, 2)
## In a hard winter (P4) half as many: the animals are holed up.
func winter_budget(kind: String, players: int) -> int:
var b := budget(kind, players)
if world != null and world.weather != null and world.weather.in_hard_winter():
b = maxi(b / 2, 1)
return b
func _on_died(c: Node) -> void:
_cooldown[c.kind] = RESPAWN_DELAY

View File

@@ -116,6 +116,13 @@ static func build(key: String) -> ArrayMesh:
"bear_fat": m = _pebble(271, Color(0.92, 0.86, 0.68), Vector3(0.1, 0.06, 0.08), 0.1)
"fur_rug": m = _rug()
"barrow_key": m = _barrow_key()
"rotten_food": m = _pebble(321, Color(0.33, 0.3, 0.16), Vector3(0.11, 0.06, 0.1), 0.45)
"salt": m = _salt()
"smoking_rack": m = _smoking_rack()
"smoked_meat": m = _meat(Color(0.36, 0.17, 0.1))
"salted_meat": m = _meat(Color(0.68, 0.42, 0.38))
"smoked_fish": m = _fish(Color(0.72, 0.5, 0.22), 0.2, true)
"salted_fish": m = _fish(Color(0.75, 0.73, 0.66), 0.2, false)
"berry_bush": m = _berry_bush(true)
"berry_bush_bare": m = _berry_bush(false)
_:
@@ -1019,3 +1026,39 @@ static func _barrow_key() -> ArrayMesh:
_box(st, c + Vector3(0, 0.02, 0), Vector3(w, 0.04, 0.12), P._mul(Color(0.45, 0.46, 0.44), r.randf_range(0.9, 1.05)), Basis(Vector3.UP, (a0 + a1) * 0.5))
_box(st, Vector3(0, 0.042, 0.1), Vector3(0.1, 0.006, 0.02), Color(0.82, 0.8, 0.7))
return P._commit(st)
# --- P4 food keeping ---------------------------------------------------------------------
## A crust of white salt crystals.
static func _salt() -> ArrayMesh:
var st := P._begin()
var r := _rng(331)
for i in range(7):
var c := Vector3(r.randf_range(-0.07, 0.07), 0.02, r.randf_range(-0.06, 0.06))
_box(st, c, Vector3.ONE * r.randf_range(0.025, 0.05), P._mul(Color(0.93, 0.93, 0.9), r.randf_range(0.9, 1.05)),
Basis(Vector3(r.randf(), r.randf(), r.randf()).normalized(), r.randf() * TAU))
return P._commit(st)
## Two forked posts with a crossbar, strips of meat hanging from it, embers
## below. The fire sits at the origin.
static func _smoking_rack() -> ArrayMesh:
var st := P._begin()
var r := _rng(333)
var n := FastNoiseLite.new()
n.seed = 333
for sx in [-0.7, 0.7]:
P._cylinder(st, Vector3(sx, 0.0, 0), Vector3(sx, 1.35, 0), 0.05, 0.04, 6, P.BARK, r, -1.0, PLANK)
P._cylinder(st, Vector3(sx, 1.25, 0), Vector3(sx + 0.08 * signf(sx), 1.45, 0), 0.03, 0.02, 4, P.BARK, r)
P._cylinder(st, Vector3(-0.8, 1.32, 0), Vector3(0.8, 1.32, 0), 0.035, 0.035, 6, P.BARK, r, -1.0, PLANK)
for i in range(5):
var x := -0.48 + 0.24 * i
_box(st, Vector3(x, 1.12, 0), Vector3(0.12, 0.32 + 0.06 * sin(i * 2.1), 0.03), P._mul(Color(0.42, 0.18, 0.12), r.randf_range(0.8, 1.1)))
for i in range(6):
var a := TAU * i / 6.0
P._blob(st, Vector3(cos(a) * 0.32, 0.05, sin(a) * 0.32), Vector3(0.1, 0.07, 0.09), n, 0.3, STONE, r, 1)
P._blob(st, Vector3(0, 0.03, 0), Vector3(0.24, 0.035, 0.24), n, 0.2, ASH, r, 1)
for i in range(3):
var a := 0.9 * i
var d := Vector3(cos(a), 0, sin(a)) * 0.18
P._cylinder(st, Vector3(0, 0.06, 0) - d, Vector3(0, 0.06, 0) + d, 0.035, 0.035, 5, P.BARK, r, -1.0, CHARCOAL)
return P._commit(st)

View File

@@ -32,7 +32,7 @@ signal clock(day: int, time: float) ## the server's time of day
const Identity = preload("res://scripts/net/identity.gd")
const ServerConfig = preload("res://scripts/net/server_config.gd")
const PROTOCOL := 10 ## 3: shared world; 4: the server decides; 5: wildlife; 6: rules and chat (M2.6); 7: the alpha, broken doors; 8: the bear; 9: crows and foxes; 10: the barrow (P3)
const PROTOCOL := 11 ## 3: shared world; 4: the server decides; 5: wildlife; 6: rules and chat (M2.6); 7: the alpha, broken doors; 8: the bear; 9: crows and foxes; 10: the barrow (P3); 11: food spoilage, lightning (P4)
const DEFAULT_PORT := 24242
const HANDSHAKE_TIMEOUT := 10.0
const JOIN_TIMEOUT := 20.0 ## includes the server building its terrain on first join

View File

@@ -226,7 +226,7 @@ func _on_died(peer: int) -> void:
var at: Vector3 = pos + off
at.y = w.terrain.sample_height(at.x, at.z) + 0.05
w.drop_item(st.id, int(st.count), Transform3D(Basis(Vector3.UP, rng.randf() * TAU), at),
{"dur": int(st.dur)} if st.has("dur") else {})
Inventory.extra_of(st))
p.inventory.load_data(Inventory.new(p.inventory.size()).to_data())
# ("keep": you keep everything.)
# They stay down on the death screen for a few seconds, then ask to respawn.
@@ -369,7 +369,7 @@ func _run(p: Node3D, what: String, a: Array) -> void:
var left: int = p.inventory.add_stack(p.cursor)
if left > 0:
w.drop_item(p.cursor.id, left, Transform3D(Basis(), p.global_position + Vector3(0, 0.1, 0)),
{"dur": int(p.cursor.dur)} if p.cursor.has("dur") else {})
Inventory.extra_of(p.cursor))
p.cursor = null
"debug_give":
if cheats and not ItemDB.get_item(_s(a, 0)).is_empty():

View File

@@ -69,6 +69,11 @@ static func difficulty(k: String) -> float:
var d: Variant = v("difficulty")
return float(DIFFICULTY.get(String(d) if d != null else "normal", DIFFICULTY.normal)[k])
## How fast food rots: "normal", "slow" (half speed) or "off".
static func rot() -> String:
var r: Variant = v("rot")
return String(r) if r != null else "normal"
static func grace_days() -> int:
var g: Variant = v("wolf_grace_days")
return clampi(int(g), 0, 10) if g != null else 3

View File

@@ -23,6 +23,7 @@ const DEFAULTS := {
"day_length_minutes": 20,
"wolf_grace_days": 3,
"difficulty": "normal",
"rot": "normal",
"autosave_minutes": 2,
"backups_kept": 10,
"backup_hours": 6,
@@ -32,7 +33,7 @@ const DEFAULTS := {
}
## Settings sent to players when they join (never the password or the lists).
const PUBLIC := ["server_name", "motd", "max_players", "worldlet", "pvp", "raiding", "locks",
"death_penalty", "sleep", "pause_when_empty", "day_length_minutes", "wolf_grace_days", "difficulty"]
"death_penalty", "sleep", "pause_when_empty", "day_length_minutes", "wolf_grace_days", "difficulty", "rot"]
var path := DEFAULT_PATH
var data: Dictionary = {} ## what's in server.json (saved)
@@ -146,6 +147,7 @@ const SETTABLE := {
"sleep": ["all_online", "half", "anyone", "off"],
"pause_when_empty": ["false", "true"],
"difficulty": ["easy", "normal", "hard"],
"rot": ["normal", "slow", "off"],
"wolf_grace_days": "0-10",
"motd": "text",
"server_name": "text",

View File

@@ -365,6 +365,21 @@ func _s2c_node(uid: String, hp: float, point: Vector3, from: Vector3) -> void:
n.apply_remote(hp, point, from)
received += 1
## Lightning struck a tree (hazards.gd, P4): everyone sees it burn. (It
## falls later through _s2c_node, like any felled tree.)
func struck(n: Node3D) -> void:
_all("_s2c_strike", [String(n.uid)])
@rpc("authority", "call_remote", "reliable")
func _s2c_strike(uid: String) -> void:
if not is_client():
return
var n: Node3D = world.props.resource_nodes.get(uid)
var hz: Node = world.get_node_or_null("Hazards")
if n != null and is_instance_valid(n) and hz != null:
hz.show_strike(n)
received += 1
@rpc("authority", "call_remote", "reliable")
func _s2c_regrown(uid: String) -> void:
if not is_client():

View File

@@ -37,6 +37,8 @@ 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)
@@ -128,7 +130,9 @@ func step(dt: float) -> void:
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)
# (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)
@@ -137,6 +141,7 @@ func step(dt: float) -> void:
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:
@@ -148,6 +153,7 @@ func step(dt: float) -> void:
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:
@@ -196,6 +202,8 @@ func _update_statuses() -> void:
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:
@@ -230,6 +238,13 @@ func eat(food: Dictionary) -> void:
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)
@@ -284,7 +299,7 @@ func add_buff(b: Dictionary) -> void:
func to_data() -> Dictionary:
return {"health": health, "stamina": stamina, "hunger": hunger, "warmth": warmth, "wet": wet, "rested": rested,
"buffs": buffs.duplicate(true), "diet": diet.duplicate()}
"buffs": buffs.duplicate(true), "diet": diet.duplicate(), "sick": sick}
func load_data(d: Dictionary) -> void:
health = float(d.get("health", health))
@@ -293,6 +308,7 @@ func load_data(d: Dictionary) -> void:
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", {})

View File

@@ -82,9 +82,27 @@ func set_stack(stack) -> void:
_wear_fill.anchor_right = k
_wear_fill.color = Color(0.85, 0.25, 0.2) if k < 0.2 else (Color(0.9, 0.75, 0.25) if k < 0.5 else Color(0.45, 0.8, 0.35))
tooltip_text += "\n%d of %d uses left" % [left, full]
elif stack.has("rot") and Inventory.keeps_for(String(stack.id)) > 0.0:
# Food: a bar that runs down green to brown as it goes off.
var k := Inventory.freshness(stack)
_wear.visible = true
_wear_fill.anchor_right = maxf(k, 0.04)
_wear_fill.color = Color(0.45, 0.8, 0.35).lerp(Color(0.5, 0.33, 0.15), 1.0 - k)
tooltip_text += "\n" + freshness_text(stack)
else:
_wear.visible = false
## "Fresh", "Going off (about 9 hours left)", "Nearly rotten (2 hours left)".
static func freshness_text(stack) -> String:
var k := Inventory.freshness(stack)
var h := Inventory.hours_left(stack)
var left := ("%d hours left" % roundi(h)) if h < 48.0 else ("%d days left" % roundi(h / 24.0))
if k > 0.6:
return "Fresh (%s)" % left
if k > 0.2:
return "Going off (%s)" % left
return "Nearly rotten (%s)" % left
func set_selected(on: bool) -> void:
add_theme_stylebox_override("panel", _selected_style if on else _style)

View File

@@ -8,6 +8,9 @@ const WORLD_SCENE := "res://scenes/world.tscn"
var _pages: Dictionary = {} ## name -> Control
var _name_edit: LineEdit
var _seed_edit: LineEdit
var _rot_pick: OptionButton
## [setting, what the New World page calls it]
const ROT_CHOICES := [["normal", "Normally (raw meat lasts 2 days)"], ["slow", "Slowly (twice as long)"], ["off", "Never"]]
var _list: VBoxContainer
var _armed_delete := ""
@@ -181,6 +184,7 @@ func _show(page: String) -> void:
elif page == "new":
_name_edit.text = _unique_name()
_seed_edit.text = ""
_rot_pick.select(0)
_name_edit.grab_focus()
_name_edit.select_all()
@@ -571,6 +575,12 @@ func _new_page() -> Control:
_seed_edit.custom_minimum_size = Vector2(360, 40)
_seed_edit.text_submitted.connect(func(_t): _create())
v.add_child(_seed_edit)
v.add_child(UITheme.label("Food spoils", 15, UITheme.TEXT_DIM))
_rot_pick = OptionButton.new()
for t in ROT_CHOICES:
_rot_pick.add_item(t[1])
_rot_pick.custom_minimum_size = Vector2(360, 36)
v.add_child(_rot_pick)
var h := HBoxContainer.new()
h.add_theme_constant_override("separation", 10)
h.add_child(_button("Create & Play", _create, 170))
@@ -660,7 +670,8 @@ func _create() -> void:
world_name = _unique_name()
var seed_text := _seed_edit.text.strip_edges()
var seed := int(seed_text) if seed_text.is_valid_int() else (seed_text.hash() if not seed_text.is_empty() else randi())
_play(Saves.create_world(world_name, seed))
var rot: String = ROT_CHOICES[maxi(_rot_pick.selected, 0)][0]
_play(Saves.create_world(world_name, seed, {"rot": rot}))
func _play(id: String) -> void:
Saves.current_id = id

288
scripts/world/hazards.gd Normal file
View File

@@ -0,0 +1,288 @@
extends Node3D
## Weather hazards (P4, docs/PROGRESSION_DESIGN.md §5) that need more than
## the weather model:
##
## - Hard winters (weather.gd hard_winter()): a warning the day before and
## on the day; the lake's edge freezes over (walkable ice you can't fish
## through), on every machine since it comes from the shared clock.
## - Lightning: in a storm, every minute or so a bolt hits a tree near
## someone, preferring tall trees near the rim. It burns for a while (a
## fire you can warm yourself by), then falls, leaving charcoal. Decided:
## trees only for now; buildings are never struck.
##
## Strikes and warnings happen where the game decides (single-player, a
## server); players' games are told (world_net.gd _s2c_strike).
const Weather = preload("res://scripts/world/weather.gd")
const STRIKE_MIN := 50.0
const STRIKE_MAX := 110.0
const STRIKE_RANGE := 90.0 ## trees this near someone can be struck
const BURN_SECONDS := 40.0
const CHARCOAL := 2
const ICE_IN := 0.62 ## the ice: from this x lake radius out to the shore
const ICE_OUT := 1.7
var world: Node3D
var deciding := true ## false on a player's game online
var strikes := 0
var _strike_left := 30.0
var _rng := RandomNumberGenerator.new()
var _burning: Dictionary = {} ## resource node -> [fire node, seconds left]
var _ice: MeshInstance3D
var _ice_body: StaticBody3D
var _warned := {} ## day -> true
func setup(w: Node3D, decides: bool) -> void:
world = w
deciding = decides
_rng.seed = w.terrain.noise_seed + 1201
_build_ice()
w.day_night.new_day.connect(_on_new_day)
func _physics_process(delta: float) -> void:
step(delta)
func step(dt: float) -> void:
_set_ice(world.weather.in_hard_winter())
_snow_left -= dt
if _snow_left <= 0.0:
_snow_left = 2.0
_update_roof_snow()
for n in _burning.keys():
var e: Array = _burning[n]
e[1] = float(e[1]) - dt
if float(e[1]) <= 0.0 or not is_instance_valid(n):
_burn_out(n)
if not deciding:
return
var w: Node = world.weather
if w.state == "storm" and w.precip > 0.7:
_strike_left -= dt
if _strike_left <= 0.0:
_strike_left = _rng.randf_range(STRIKE_MIN, STRIKE_MAX)
var t := pick_tree()
if t != null:
strike(t)
# --- lightning -------------------------------------------------------------------------
## A standing tree near someone, the taller and nearer the rim the likelier.
func pick_tree() -> Node3D:
var best: Node3D = null
var best_s := -INF
for n in world.props.resource_nodes.values():
if n.kind != "tree" or n.depleted or _burning.has(n):
continue
var near := false
for p in world.players():
if (n as Node3D).global_position.distance_to(p.global_position) < STRIKE_RANGE:
near = true
break
if not near:
continue
var pos: Vector3 = n.global_position
var s: float = world.props._near_rim(pos.x, pos.z) * 2.0 + n.base_xf.basis.get_scale().y + _rng.randf() * 1.5
if s > best_s:
best_s = s
best = n
return best
## Lightning hits tree `n`: a flash, a crack, and it's on fire.
func strike(n: Node3D) -> void:
strikes += 1
show_strike(n)
if world.server_mode:
WorldNet.struck(n)
for p in world.players():
if p.global_position.distance_to(n.global_position) < 60.0:
Actions.tell(p, "Lightning struck a tree! It's burning.")
## What everyone sees: the flash, and the tree alight.
func show_strike(n: Node3D) -> void:
world.weather.flash = 1.0
if _burning.has(n):
return
var fire := TreeFire.new()
fire.name = "TreeFire"
add_child(fire)
fire.global_position = n.global_position
fire.build(DisplayServer.get_name() != "headless")
_burning[n] = [fire, BURN_SECONDS]
func burning(n: Node3D) -> bool:
return _burning.has(n)
## Burnt through: the tree falls (where the game decides; players' games
## see it fall via world_net), leaving charcoal at its foot.
func _burn_out(n: Node3D) -> void:
var e: Array = _burning[n]
_burning.erase(n)
if is_instance_valid(e[0]):
(e[0] as Node).queue_free()
if not deciding or not is_instance_valid(n) or n.depleted:
return
var top: Vector3 = n.global_position + Vector3(0, 2.0, 0)
var from: Vector3 = n.global_position + Vector3(1, 0, 0)
n._show_hit(0.0, top, from)
if world.server_mode:
WorldNet.node_hit(n, top, from)
world.drop_item("charcoal", CHARCOAL, Transform3D(Basis(), n.global_position + Vector3(0.8, 0.3, 0.4)))
## A burning tree's fire: flames up the trunk, a light, and warmth.
class TreeFire extends Node3D:
const RADIUS := 6.0
func build(visual: bool) -> void:
add_to_group("heat")
if not visual:
return
var light := OmniLight3D.new()
light.light_color = Color(1.0, 0.55, 0.25)
light.light_energy = 3.0
light.omni_range = 14.0
light.position = Vector3(0, 3.0, 0)
add_child(light)
# The campfire's soft additive flames, bigger and up the trunk.
var CF: Script = load("res://scripts/building/campfire.gd")
var ramp: Gradient = CF._gradient([[0.0, Color(1.0, 0.8, 0.35, 0.0)], [0.15, Color(1.0, 0.62, 0.2, 0.8)],
[0.6, Color(0.95, 0.3, 0.06, 0.5)], [1.0, Color(0.5, 0.08, 0.02, 0.0)]])
for i in range(3):
var p: CPUParticles3D = CF._particles("Flames%d" % i, 30, 1.0, Vector3(0, 1.2 + 1.6 * i, 0), 0.8, 2.0,
0.6, 1.2, ramp, true, 0.9, 0.7 - 0.15 * i)
add_child(p)
p.emitting = true
func heat_at(pos: Vector3) -> float:
return 1.0 - smoothstep(2.0, RADIUS, pos.distance_to(global_position))
# --- hard winters ---------------------------------------------------------------------
func _on_new_day(day: int) -> void:
if not deciding or _warned.has(day):
return
_warned[day] = true
if Weather.hard_winter(day + 1) and not Weather.hard_winter(day):
Actions.tell_all("The air has a bite to it. A hard winter is coming tomorrow: three bitter days. Stock up on food and firewood.")
elif Weather.hard_winter(day) and not Weather.hard_winter(day - 1):
Actions.tell_all("A hard winter has set in. The lake's edge has frozen, and the animals are holed up.")
elif Weather.hard_winter(day - 1) and not Weather.hard_winter(day):
Actions.tell_all("The hard winter is over.")
## A ring of ice round the lake, at the water line: it shows (and you can
## walk on it) wherever the water is, from the shore out to ICE_IN of the
## lake's radius. Off except in a hard winter.
func _build_ice() -> void:
var t: Node3D = world.terrain
var c: Vector2 = t.lake_center
var r0: float = t.lake_radius * ICE_IN
var r1: float = t.lake_radius * ICE_OUT
var y: float = t.water_level + 0.04
var segs := 64
var st := SurfaceTool.new()
st.begin(Mesh.PRIMITIVE_TRIANGLES)
var faces := PackedVector3Array()
var nz := FastNoiseLite.new()
nz.seed = t.noise_seed + 1202
for i in range(segs):
var a0 := TAU * i / segs
var a1 := TAU * (i + 1) / segs
var k0 := 1.0 + 0.12 * nz.get_noise_1d(i * 7.0)
var k1 := 1.0 + 0.12 * nz.get_noise_1d((i + 1) * 7.0)
var p := [Vector3(c.x + cos(a0) * r0 * k0, y, c.y + sin(a0) * r0 * k0), Vector3(c.x + cos(a0) * r1, y, c.y + sin(a0) * r1),
Vector3(c.x + cos(a1) * r1, y, c.y + sin(a1) * r1), Vector3(c.x + cos(a1) * r0 * k1, y, c.y + sin(a1) * r0 * k1)]
for tri in [[p[0], p[2], p[1]], [p[0], p[3], p[2]]]:
for v in tri:
st.set_normal(Vector3.UP)
st.add_vertex(v)
faces.append(v)
_ice = MeshInstance3D.new()
_ice.name = "LakeIce"
_ice.mesh = st.commit()
var m := StandardMaterial3D.new()
m.albedo_color = Color(0.86, 0.93, 0.98, 0.96)
m.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
m.roughness = 0.35
m.metallic_specular = 0.8
_ice.material_override = m
_ice.visible = false
add_child(_ice)
_ice_body = StaticBody3D.new()
_ice_body.name = "IceBody"
var cs := CollisionShape3D.new()
var shape := ConcavePolygonShape3D.new()
shape.set_faces(faces)
shape.backface_collision = true
cs.shape = shape
_ice_body.add_child(cs)
_ice_body.collision_layer = 0
add_child(_ice_body)
func _set_ice(on: bool) -> void:
if _ice.visible == on:
return
_ice.visible = on
_ice_body.collision_layer = 1 if on else 0
# --- snow on roofs ----------------------------------------------------------------------
var _snow_left := 0.0
var _snow_mat: StandardMaterial3D
var _snow_mesh: BoxMesh
## Snow piles up on roofs as it settles (weather.gd snow_cover), and melts
## off with it. Just a look: a white layer on each roof piece, thicker the
## more has settled. (Visual only, so not on a headless server.)
func _update_roof_snow() -> void:
if DisplayServer.get_name() == "headless":
return
var cover: float = world.weather.snow_cover
if _snow_mesh == null:
var BP: Script = load("res://scripts/building/build_piece.gd")
_snow_mesh = BoxMesh.new()
_snow_mesh.size = Vector3(2.04, 0.12, sqrt(5.0) + 0.25)
_snow_mat = StandardMaterial3D.new()
_snow_mat.albedo_color = Color(0.93, 0.95, 0.98)
_snow_mat.roughness = 0.9
_snow_mesh.material = _snow_mat
_snow_angle = BP.ROOF_ANGLE
_snow_t = BP.ROOF_T
for p in world.build.build_pieces():
if p.kind != "roof":
continue
var s: MeshInstance3D = p.get_node_or_null("Snow")
if cover < 0.2:
if s != null:
s.visible = false
continue
if s == null:
s = MeshInstance3D.new()
s.name = "Snow"
s.mesh = _snow_mesh
p.add_child(s)
var k := clampf((cover - 0.2) / 0.8, 0.0, 1.0)
var n := Vector3(0, cos(_snow_angle), sin(_snow_angle))
var along := Vector3(0, sin(_snow_angle), -cos(_snow_angle))
s.visible = true
s.transform = Transform3D(Basis(Vector3.RIGHT, _snow_angle).scaled(Vector3(1.0, maxf(k, 0.15), 1.0)),
along * (sqrt(5.0) * 0.5 - 0.1) + n * (_snow_t + 0.11 + 0.06 * k))
var _snow_angle := 0.4636476
var _snow_t := 0.12
## How many roofs have snow on them right now (tests).
func snowy_roofs() -> int:
var n := 0
for p in world.build.build_pieces():
var s: Node = p.get_node_or_null("Snow")
if s != null and s.visible:
n += 1
return n
func lake_frozen() -> bool:
return _ice.visible
## Frozen over at (x, z): ice, not open water.
func iced(x: float, z: float) -> bool:
if not lake_frozen():
return false
var c: Vector2 = world.terrain.lake_center
return Vector2(x - c.x, z - c.y).length() > world.terrain.lake_radius * ICE_IN * 0.9

View File

@@ -611,9 +611,9 @@ func _scatter_pickups() -> void:
rng.seed = world_seed + 6
# Order matters: new kinds go at the END so existing saves keep their ids.
var want := {"stick": 260, "stone": 170, "flint": 80, "mushroom": 110, "feather": 70, "fiber": 150,
"iron_nugget": 70, "clay": 60}
"iron_nugget": 70, "clay": 60, "salt": 40}
var radius: Dictionary = {"stick": 0.35, "stone": 0.25, "flint": 0.25, "mushroom": 0.25, "feather": 0.25, "fiber": 0.45,
"iron_nugget": 0.25, "clay": 0.3}
"iron_nugget": 0.25, "clay": 0.3, "salt": 0.25}
for id: String in want:
# Fibre grows as a plant (wild flax) you pick, 2 fibre a clump.
var mesh: Mesh = ItemMeshes.build("fiber_plant") if id == "fiber" else ItemDB.mesh_for(id)
@@ -655,8 +655,11 @@ func _scatter_pickups() -> void:
var by_lake: bool = Vector2(x - lake.x, z - lake.y).length() < terrain.lake_radius * 1.6
var shore: float = 1.0 - smoothstep(terrain.water_level + 0.4, terrain.water_level + 1.8, h)
chance = shore * 0.95 if by_lake else 0.0
"salt":
# Crusts on the cliff tops above the sea (P4).
chance = 0.95 if terrain.sea_weight(x, z) > 0.6 and _near_rim(x, z) > 0.35 else 0.0
# A few right by the spawn so the first craft is quick.
if Vector2(x - _spawn.x, z - _spawn.z).length() < 18.0 and not id in ["iron_nugget", "clay"]:
if Vector2(x - _spawn.x, z - _spawn.z).length() < 18.0 and not id in ["iron_nugget", "clay", "salt"]:
chance += 0.5
if keep > chance:
continue

View File

@@ -11,6 +11,11 @@ extends Node
## (and a storm becomes a blizzard). Above the snowline it always snows.
## Outputs ease toward the state's targets over about a minute, so fronts
## roll in rather than switching.
##
## P4 (docs/PROGRESSION_DESIGN.md §5): every 12 days a hard winter, three
## bitter cold days in a row (hard_winter()), colder still, with more snow and
## blizzards. A blizzard cuts visibility right down and its wind strips the
## warmth from anyone out in it (blizzard_chill(); a shelter keeps it out).
signal state_changed(state: String)
@@ -30,12 +35,18 @@ const TARGETS := {
"rain": {"clouds": 0.92, "precip": 0.6, "wind": 0.60, "light": 0.50, "fog": 2.2, "warmth": -6.0},
"storm": {"clouds": 1.00, "precip": 1.0, "wind": 1.00, "light": 0.30, "fog": 3.0, "warmth": -12.0},
"snow": {"clouds": 0.90, "precip": 0.6, "wind": 0.35, "light": 0.68, "fog": 2.6, "warmth": -14.0},
"blizzard": {"clouds": 1.00, "precip": 1.0, "wind": 0.90, "light": 0.45, "fog": 4.5, "warmth": -24.0},
"blizzard": {"clouds": 1.00, "precip": 1.0, "wind": 1.00, "light": 0.40, "fog": 7.0, "warmth": -24.0},
}
const COLD_DAY_WARMTH := -8.0
const BLEND_SECONDS := 50.0 ## time for outputs to cross from one state to another
const CALM_START := 600.0 ## a new world's first 10 minutes are always clear
const COLD_DAY_CHANCE := 0.25
const HARD_WINTER_EVERY := 12 ## days 12-14, 24-26, ...
const HARD_WINTER_DAYS := 3
const HARD_WINTER_WARMTH := -8.0 ## on top of a cold day's
const BLIZZARD_CHILL := 18.0 ## more warmth lost out in a blizzard's wind (a hard winter's freezes you by day)
## In a hard winter the weather leans wet (more snow and blizzards).
const HARD_WINTER_FRONT := -0.18
var world_seed := 1337
var day_night: Node
@@ -76,9 +87,22 @@ func setup(seed: int, dn: Node) -> void:
func world_seconds() -> float:
return (float(day_night.day - 1) + float(day_night.time)) * float(day_night.day_length)
## Day `day` is one of a hard winter's three bitter days.
static func hard_winter(day: int) -> bool:
return day >= HARD_WINTER_EVERY and (day - HARD_WINTER_EVERY) % HARD_WINTER_EVERY < HARD_WINTER_DAYS
## The first day of the next hard winter on or after `day`.
static func next_hard_winter(day: int) -> int:
var d := maxi(day, HARD_WINTER_EVERY)
while not hard_winter(d):
d += 1
return d
func is_cold_day(day: int) -> bool:
if day <= 1:
return false
if hard_winter(day):
return true
return float(absi(hash(Vector2i(world_seed, day))) % 10000) / 10000.0 < COLD_DAY_CHANCE
## The raw front value at an absolute time (higher = fairer).
@@ -100,7 +124,7 @@ func _state_for(v: float, cold: bool) -> String:
## The state at an absolute time, ignoring smoothing and overrides.
func state_at(t: float) -> String:
var day := int(floor(t / float(day_night.day_length))) + 1
return _state_for(front_at(t), is_cold_day(day))
return _state_for(front_at(t) + (HARD_WINTER_FRONT if hard_winter(day) else 0.0), is_cold_day(day))
func force(s: String) -> void:
override = s
@@ -116,7 +140,7 @@ func snap() -> void:
wind = tg.wind
light = tg.light
fog = tg.fog
warmth = tg.warmth + (COLD_DAY_WARMTH if cold_day else 0.0)
warmth = tg.warmth + _cold_extra()
ground_wet = 1.0 if state in ["rain", "storm"] else 0.0
snow_cover = 0.7 if state in ["snow", "blizzard"] else (0.25 if cold_day else 0.0)
@@ -128,7 +152,7 @@ func _pick_state() -> void:
if frozen and s.is_empty():
s = "clear"
if s.is_empty():
s = _state_for(front_at(world_seconds()), cold_day)
s = _state_for(front_at(world_seconds()) + (HARD_WINTER_FRONT if hard_winter(day_night.day) else 0.0), cold_day)
if s != state:
state = s
state_changed.emit(state)
@@ -145,7 +169,7 @@ func step(dt: float) -> void:
wind = move_toward(wind, tg.wind, k)
light = move_toward(light, tg.light, k * 0.8)
fog = move_toward(fog, tg.fog, k * 3.0)
warmth = move_toward(warmth, tg.warmth + (COLD_DAY_WARMTH if cold_day else 0.0), k * 20.0)
warmth = move_toward(warmth, tg.warmth + _cold_extra(), k * 20.0)
# The ground soaks in a couple of minutes of rain and dries over ten.
var raining := precip > 0.2 and not snowing_globally()
ground_wet = move_toward(ground_wet, 1.0 if raining else 0.0, dt / (120.0 if raining else 600.0))
@@ -163,6 +187,22 @@ func step(dt: float) -> void:
flash = 1.0
lightning_count += 1
## Extra cold from the day: a cold day, and a hard winter on top.
func _cold_extra() -> float:
if not cold_day:
return 0.0
return COLD_DAY_WARMTH + (HARD_WINTER_WARMTH if day_night != null and hard_winter(day_night.day) else 0.0)
func in_hard_winter() -> bool:
return day_night != null and hard_winter(day_night.day)
## Extra warmth lost by someone out in a blizzard's wind (0 when sheltered or
## when it isn't blowing).
func blizzard_chill(sheltered: bool) -> float:
if sheltered or state != "blizzard":
return 0.0
return BLIZZARD_CHILL * clampf(precip, 0.0, 1.0)
func snowing_globally() -> bool:
return state in ["snow", "blizzard"]
@@ -188,4 +228,6 @@ func label() -> String:
var s: String = names[state]
if cold_day and state in ["clear", "cloudy"]:
s += ", cold"
if in_hard_winter():
s += " (hard winter)"
return s

View File

@@ -26,6 +26,7 @@ const PIECES := {
"bed": preload("res://scripts/building/bed.gd"),
"kiln": preload("res://scripts/building/processor.gd"),
"smelter": preload("res://scripts/building/processor.gd"),
"smoker": preload("res://scripts/building/processor.gd"),
"forge": preload("res://scripts/building/forge.gd"),
"hearth": preload("res://scripts/building/hearth.gd"),
"rug": preload("res://scripts/building/rug.gd"),
@@ -65,6 +66,7 @@ var bobber: Node3D ## bobber.gd: the cast fishing float, if any
var wolves: Node3D ## wolf_pack.gd (null if this seed has no den site)
var bears: Node3D ## bear_range.gd: the island's bear (null online as a client)
var thieves: Node3D ## thieves.gd: crows and foxes after food left out
var hazards: Node3D ## hazards.gd: lightning on trees, hard-winter ice (P4)
var barrow: Node3D ## barrow.gd: the burial mound and its guards (scenery; guards where creatures run)
var _saved_bear: Dictionary = {} ## its saved state, kept until bears exists (and re-saved if it never does)
var net_sync: Node ## net_sync.gd when playing on a server, else null
@@ -165,7 +167,7 @@ func air_warmth(pos: Vector3, sheltered: bool = false) -> float:
if sheltered:
w += ww * 0.25 + 8.0
else:
w += ww
w += ww - weather.blizzard_chill(false) # (P4: the wind cuts through you)
return w
## 0..1 heat from fires and a held torch.
@@ -326,6 +328,7 @@ func skip_time(secs: float) -> void:
for f in get_tree().get_nodes_in_group("heat"):
if f.has_method("burn"):
f.burn(secs)
spoil_all() # (food goes off overnight too)
func _on_terrain_ready() -> void:
var t0 := Time.get_ticks_msec()
@@ -357,6 +360,12 @@ func _on_terrain_ready() -> void:
print("[world] scenery placed in %d ms" % (Time.get_ticks_msec() - t0))
if menu_mode:
return
# Weather hazards (P4): lightning on trees, the hard winter's ice. Decided
# here unless we're a player's game online (then the server tells us).
hazards = preload("res://scripts/world/hazards.gd").new()
hazards.name = "Hazards"
add_child(hazards)
hazards.setup(self, not is_online())
if server_mode:
if Saves.has_world():
var saved: Dictionary = Saves.load_state(Saves.current_id)
@@ -376,6 +385,7 @@ func _on_terrain_ready() -> void:
_setup_bear()
apply_rules()
return
_apply_world_rot()
_respawn()
if Saves.has_world():
var state: Dictionary = Saves.load_state(Saves.current_id)
@@ -430,6 +440,48 @@ func apply_rules() -> void:
wolves.pack_bonus = int(R.difficulty("pack"))
for p in PlayerNet.proxies.values():
p.survival.drain_mult = R.difficulty("drain")
Inventory.rot_speed = ROT_SPEEDS.get(R.rot(), 1.0)
# --- food spoilage (P4, docs/PROGRESSION_DESIGN.md §5) ----------------------------
const ROT_SPEEDS := {"normal": 1.0, "slow": 0.5, "off": 0.0}
const SPOIL_EVERY := 2.0
var _spoil_left := 0.0
## Keeps Inventory's clock (food's "rot" stamps) on world time. Players'
## games online run it too (for showing freshness; the server does the rotting).
func _tick_clock() -> void:
Inventory.day_seconds = float(day_night.day_length)
Inventory.clock = weather.world_seconds()
## The single-player world's rot setting, from its meta.json (a server's
## comes from its rules: apply_rules()).
func _apply_world_rot() -> void:
var r := String(Saves.read_meta(Saves.current_id).get("rot", "normal")) if Saves.has_world() else "normal"
Inventory.rot_speed = ROT_SPEEDS.get(r, 1.0)
## Food rots: in everyone's bags, in chests, graves and racks, and lying on
## the ground (where rotten food just goes). Where the game decides (single
## player, a server); players' games online are sent the result.
func spoil_all() -> void:
if net_sync != null or menu_mode:
return
_tick_clock()
for p in players():
if p.get("inventory") != null:
p.inventory.spoil()
for piece in all_pieces():
if piece.get("inventory") != null:
piece.inventory.spoil()
if Inventory.rot_speed <= 0.0:
return
for d in all_dropped():
if Inventory.keeps_days(d.item_id) <= 0.0:
continue
if not d.extra.has("rot"):
d.extra.rot = Inventory.fresh_rot(d.item_id)
elif Inventory.clock >= float(d.extra.rot):
take_dropped(d)
func _process(delta: float) -> void:
if menu_mode:
@@ -442,6 +494,11 @@ func _process(delta: float) -> void:
var lake: Vector2 = terrain.lake_center
_menu_cam.look_at(Vector3(lake.x * 0.5, 2.0, lake.y * 0.5), Vector3.UP)
return
_tick_clock()
_spoil_left -= delta
if _spoil_left <= 0.0:
_spoil_left = SPOIL_EVERY
spoil_all()
if not Saves.has_world() or get_tree().paused:
return
_session_time += delta
@@ -619,8 +676,8 @@ func add_dropped_entry(e: Dictionary) -> Node3D:
if ItemDB.get_item(e.get("item", "")).is_empty():
return null
var ex: Dictionary = {}
if e.get("extra") is Dictionary and e.extra.has("dur"):
ex = {"dur": int(e.extra.dur)}
if e.get("extra") is Dictionary:
ex = Inventory.extra_of(e.extra) # (a worn tool's "dur", food's "rot")
var d := _make_dropped(e.item, int(e.get("count", 1)), _array_to_xf(e.xf), ex)
if e.has("nid"):
d.set_meta("nid", int(e.nid))
@@ -781,6 +838,8 @@ func _update_grass_cuts() -> void:
func water_at(x: float, z: float) -> Dictionary:
var ground: float = terrain.sample_height(x, z)
if terrain.inside_boundary(x, z, -2.0) and ground < terrain.water_level:
if hazards != null and hazards.iced(x, z):
return {} # (frozen over in a hard winter: cast further out)
return {"type": "lake", "surface": terrain.water_level, "depth": terrain.water_level - ground}
if terrain.sea_weight(x, z) > 0.5 and ground < terrain.sea_level:
return {"type": "sea", "surface": terrain.sea_level, "depth": terrain.sea_level - ground}