Files
Emberwild/scripts/world/hazards.gd

289 lines
9.9 KiB
GDScript

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