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 %s's edge has frozen, and the animals are holed up." % ("lake" if world.worldlet_id() == "valley" else "pond")) 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