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

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