289 lines
9.9 KiB
GDScript
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
|