extends Node3D ## Scenery: forests, rocks, cliff outcrops, bushes, logs, grass and flowers. ## ## All placement is seeded, so a server and its clients would agree on where ## every tree is (docs/ARCHITECTURE.md ยง4). Rendering uses MultiMesh batches ## bucketed into CELL x CELL world cells: a few hundred draw batches instead of ## thousands of nodes, and off-screen cells get frustum-culled. ## ## Trees, logs and larger rocks in or near the walkable valley get simple ## collision. Grass is streamed in tiles around the player because it's too ## dense to place over the whole map at once. const ProcMesh = preload("res://scripts/world/proc_mesh.gd") const ResourceNodeScript = preload("res://scripts/world/resource_node.gd") const PickupScript = preload("res://scripts/world/pickup.gd") const ForageScript = preload("res://scripts/world/forage.gd") const CELL := 48.0 const GRASS_TILE := 12.0 const FLOWER_COLORS := [ Color(0.96, 0.95, 0.90), Color(0.98, 0.84, 0.25), Color(0.62, 0.42, 0.86), Color(0.45, 0.60, 0.95), Color(0.93, 0.45, 0.55), ] @export var world_seed: int = 1337 @export var tree_spacing: float = 3.6 @export var forest_density: float = 1.0 @export var grass_spacing: float = 0.42 @export var grass_view_distance: float = 55.0 @export var grass_tiles_per_update: int = 6 var terrain: Node3D var _player: Node3D var _spawn := Vector3.ZERO var _forest_noise := FastNoiseLite.new() var _meadow_noise := FastNoiseLite.new() var _colliders: StaticBody3D var _resources: Node3D ## harvestable trees/boulders (each its own body) ## Stable ids for saving. Placement is deterministic for a given seed, so ## creation order gives the same id to the same tree on every load. var resource_nodes: Dictionary = {} ## uid -> resource node var pickups: Dictionary = {} ## uid -> pickup var forage: Dictionary = {} ## uid -> berry bush (forage.gd) ## Regrowth: uid -> seconds until a felled tree, mined boulder or picked-up ## item comes back. Saved with the world. Bushes keep their own timers. var regrow: Dictionary = {} const REGROW_SECONDS := {"tree": 2400.0, "rock": 3600.0, "pickup": 900.0} const REGROW_RETRY := 120.0 ## blocked (player or a building nearby): try again later var _regrow_tick := 0.0 var _bark_mat: StandardMaterial3D var _leaf_mat: ShaderMaterial var _rock_mat: ShaderMaterial var _grass_mat: ShaderMaterial var _grass_root: Node3D var _grass_tiles: Dictionary = {} # Vector2i -> Node3D (or null for empty tiles) var _grass_mesh: ArrayMesh var _flower_mesh: ArrayMesh var _grass_timer := 0.0 func place_on(t: Node3D, player: Node3D, spawn: Vector3) -> void: terrain = t _player = player _spawn = spawn world_seed = terrain.noise_seed # one seed drives terrain and scenery _forest_noise.seed = world_seed + 11 _forest_noise.noise_type = FastNoiseLite.TYPE_SIMPLEX_SMOOTH _forest_noise.frequency = 0.011 _forest_noise.fractal_octaves = 3 _meadow_noise.seed = world_seed + 23 _meadow_noise.noise_type = FastNoiseLite.TYPE_SIMPLEX_SMOOTH _meadow_noise.frequency = 0.03 _meadow_noise.fractal_octaves = 2 _colliders = StaticBody3D.new() _colliders.name = "PropColliders" add_child(_colliders) _resources = Node3D.new() _resources.name = "Resources" add_child(_resources) _make_materials() _scatter_trees() _scatter_rocks() _scatter_bushes() _place_logs() _scatter_pickups() _grass_root = Node3D.new() _grass_root.name = "Grass" add_child(_grass_root) _grass_mesh = ProcMesh.grass_clump(world_seed + 5) _grass_mesh.surface_set_material(0, _grass_mat) _flower_mesh = ProcMesh.flower(world_seed + 6) var stem := StandardMaterial3D.new() stem.vertex_color_use_as_albedo = true stem.vertex_color_is_srgb = true stem.cull_mode = BaseMaterial3D.CULL_DISABLED var head := stem.duplicate() as StandardMaterial3D head.roughness = 0.7 _flower_mesh.surface_set_material(0, stem) _flower_mesh.surface_set_material(1, head) _update_grass(100000) # everything in range, before the first frame func _process(delta: float) -> void: if _player == null: return _regrow_tick += delta if _regrow_tick >= 1.0: skip_time(_regrow_tick) _regrow_tick = 0.0 _grass_timer -= delta if _grass_timer > 0.0: return _grass_timer = 0.2 _update_grass(grass_tiles_per_update) # --- regrowth --------------------------------------------------------------- func _regrow_kind(uid: String) -> String: if pickups.has(uid): return "pickup" return resource_nodes[uid].kind func _on_depleted(node: Node) -> void: regrow[node.uid] = REGROW_SECONDS[node.kind] func _on_taken(pk: Node) -> void: regrow[pk.uid] = REGROW_SECONDS["pickup"] ## Advances regrowth timers by `secs` (every second while playing, or in one ## go when you sleep). func skip_time(secs: float) -> void: for uid in regrow.keys(): regrow[uid] -= secs if regrow[uid] <= 0.0: _try_regrow(uid) for uid in forage: var f = forage[uid] if secs > 1.5 and f.regrow_left > 0.0: f._process(secs) func _try_regrow(uid: String) -> void: var node: Node3D = pickups.get(uid, resource_nodes.get(uid)) if node == null: regrow.erase(uid) return var r := 0.5 if pickups.has(uid) else 1.4 if _player != null and _player.global_position.distance_to(node.global_position) < r + 3.0 \ or _built_over(node.global_position, r): regrow[uid] = REGROW_RETRY return regrow.erase(uid) node.regrow() ## Something built (or being built) where a tree or rock would come back. func _built_over(pos: Vector3, r: float) -> bool: var sph := SphereShape3D.new() sph.radius = r var q := PhysicsShapeQueryParameters3D.new() q.shape = sph q.transform = Transform3D(Basis(), pos + Vector3(0, r, 0)) q.collision_mask = 4 # building pieces (build_piece.gd BUILD_LAYER) if not get_world_3d().direct_space_state.intersect_shape(q, 1).is_empty(): return true # Chests, fires and beds aren't on the building layer. for p in get_tree().get_nodes_in_group("piece"): if (p as Node3D).global_position.distance_to(pos) < r + 0.8: return true return false ## From a save. Anything gone without a saved timer (older saves) starts a ## fresh one. func restore_regrow(saved: Dictionary) -> void: for uid in saved: if pickups.has(uid) or resource_nodes.has(uid): regrow[uid] = float(saved[uid]) for uid in resource_nodes: if resource_nodes[uid].depleted and not regrow.has(uid): regrow[uid] = REGROW_SECONDS[resource_nodes[uid].kind] for uid in pickups: if pickups[uid].taken and not regrow.has(uid): regrow[uid] = REGROW_SECONDS["pickup"] # --- materials -------------------------------------------------------------- func _make_materials() -> void: var noise_tex: Texture2D = terrain.noise_tex _bark_mat = StandardMaterial3D.new() _bark_mat.vertex_color_use_as_albedo = true _bark_mat.vertex_color_is_srgb = true _bark_mat.roughness = 1.0 _leaf_mat = ShaderMaterial.new() _leaf_mat.shader = preload("res://shaders/foliage.gdshader") _leaf_mat.set_shader_parameter("noise_tex", noise_tex) _rock_mat = ShaderMaterial.new() _rock_mat.shader = preload("res://shaders/rock.gdshader") _rock_mat.set_shader_parameter("noise_tex", noise_tex) _rock_mat.set_shader_parameter("snow_height", terrain.snow_height) _grass_mat = ShaderMaterial.new() _grass_mat.shader = preload("res://shaders/grass.gdshader") _grass_mat.set_shader_parameter("noise_tex", noise_tex) _grass_mat.set_shader_parameter("grass_lush", terrain.grass_lush) _grass_mat.set_shader_parameter("grass_dry", terrain.grass_dry) func _apply_materials(v: Dictionary) -> Dictionary: var mesh: ArrayMesh = v.mesh var surfaces: Array = v.surfaces for i in range(surfaces.size()): mesh.surface_set_material(i, _bark_mat if surfaces[i] == "bark" else _leaf_mat) return v # --- helpers ---------------------------------------------------------------- ## Jittered grid covering the whole chunk. func _jittered(spacing: float, rng: RandomNumberGenerator) -> PackedVector2Array: var pts := PackedVector2Array() var half: float = terrain.size * 0.5 - 2.0 var z := -half while z < half: var x := -half while x < half: pts.append(Vector2(x + rng.randf() * spacing, z + rng.randf() * spacing)) x += spacing z += spacing return pts func _forest(x: float, z: float) -> float: return smoothstep(-0.15, 0.35, _forest_noise.get_noise_2d(x, z)) ## Adds a transform to its cell batch. Returns [key, index] so a resource node ## can find its own instance once the batch exists. func _group(groups: Dictionary, x: float, z: float, vi: int, xf: Transform3D) -> Array: var key := Vector3i(floori(x / CELL), floori(z / CELL), vi) if not groups.has(key): groups[key] = [] groups[key].append(xf) return [key, groups[key].size() - 1] ## Builds the batches. Returns key -> MultiMeshInstance3D. func _flush(groups: Dictionary, meshes: Array, shadows: bool, label: String) -> Dictionary: var root := Node3D.new() root.name = label add_child(root) var out := {} for key: Vector3i in groups: var mmi := _mm_instance(meshes[key.z], groups[key], [], shadows) root.add_child(mmi) out[key] = mmi return out ## Hands each pending resource node its batch instance. func _bind_nodes(pending: Array, batches: Dictionary) -> void: for p in pending: var node = p[0] node.mmi = batches[p[1]] node.index = p[2] func _resource_node(kind: String, xf: Transform3D) -> StaticBody3D: var node: StaticBody3D = ResourceNodeScript.new() node.uid = "%s%d" % [kind[0], resource_nodes.size()] resource_nodes[node.uid] = node node.kind = kind node.base_xf = xf node.stump_material = _bark_mat node.rng.seed = hash(xf.origin) node.add_to_group("harvestable") if kind == "tree": node.tool_kind = "axe" node.max_hp = 5.0 node.drops_final = {"wood": [4, 6], "stick": [1, 3]} node.chip_color = Color(0.62, 0.46, 0.28) else: node.tool_kind = "pickaxe" node.max_hp = 6.0 node.drops_per_hit = {"stone": 1} node.drops_final = {"stone": [3, 5], "flint": [1, 2]} node.chip_color = Color(0.55, 0.54, 0.52) node.hp = node.max_hp node.depleted_now.connect(_on_depleted) _resources.add_child(node) node.global_position = xf.origin return node func _mm_instance(mesh: Mesh, xforms: Array, colors: Array, shadows: bool) -> MultiMeshInstance3D: var mm := MultiMesh.new() mm.transform_format = MultiMesh.TRANSFORM_3D mm.use_colors = not colors.is_empty() # must be set before instance_count mm.mesh = mesh mm.instance_count = xforms.size() for i in range(xforms.size()): mm.set_instance_transform(i, xforms[i]) if not colors.is_empty(): mm.set_instance_color(i, colors[i]) var mmi := MultiMeshInstance3D.new() mmi.multimesh = mm mmi.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_ON if shadows else GeometryInstance3D.SHADOW_CASTING_SETTING_OFF return mmi ## Basis that tips `UP` part-way toward the ground normal, then yaws and scales. func _ground_basis(normal: Vector3, follow: float, yaw: float, s: float) -> Basis: var up := Vector3.UP.lerp(normal, follow).normalized() var b := Basis(Vector3.UP, yaw) var axis := Vector3.UP.cross(up) if axis.length() > 0.0001: b = Basis(axis.normalized(), Vector3.UP.angle_to(up)) * b return b * Basis.from_scale(Vector3.ONE * s) func _add_shape(shape: Shape3D, xf: Transform3D) -> void: var cs := CollisionShape3D.new() cs.shape = shape cs.transform = xf _colliders.add_child(cs) # --- trees ------------------------------------------------------------------ func _scatter_trees() -> void: var variants: Array = [] var conifers: Array[int] = [] var broad: Array[int] = [] var birches: Array[int] = [] for i in range(3): conifers.append(variants.size()); variants.append(_apply_materials(ProcMesh.conifer(world_seed + 100 + i, false))) for i in range(2): conifers.append(variants.size()); variants.append(_apply_materials(ProcMesh.conifer(world_seed + 110 + i, true))) for i in range(3): var col: Color = ProcMesh.LEAF_GOLD if i == 2 else ProcMesh.LEAF_GREEN broad.append(variants.size()); variants.append(_apply_materials(ProcMesh.broadleaf(world_seed + 120 + i, col))) for i in range(2): birches.append(variants.size()); variants.append(_apply_materials(ProcMesh.birch(world_seed + 130 + i))) var dead := variants.size() variants.append(_apply_materials(ProcMesh.dead_tree(world_seed + 140))) var rng := RandomNumberGenerator.new() rng.seed = world_seed + 1 var groups := {} var pending: Array = [] for p in _jittered(tree_spacing, rng): # Draw every random number up front so the sequence never depends on # which branch below is taken (stable layout when rules are tweaked). var roll := rng.randf() var pick := rng.randf() var yaw := rng.randf() * TAU var s := rng.randf_range(0.75, 1.3) var h: float = terrain.sample_height(p.x, p.y) if roll > _tree_chance(p.x, p.y, h) * forest_density: continue var vi: int var conifer_p := lerpf(0.4, 0.97, smoothstep(2.0, 24.0, h)) if pick < 0.025: vi = dead elif pick < conifer_p: vi = conifers[int(pick * 9973.0) % conifers.size()] elif (pick - conifer_p) / (1.0 - conifer_p) < 0.6: vi = broad[int(pick * 7919.0) % broad.size()] else: vi = birches[int(pick * 7919.0) % birches.size()] var xf := Transform3D(Basis(Vector3.UP, yaw) * Basis.from_scale(Vector3.ONE * s), Vector3(p.x, h - 0.1, p.y)) var slot := _group(groups, p.x, p.y, vi, xf) if terrain.inside_boundary(p.x, p.y, -4.0): # Reachable trees are harvestable: their own body + trunk capsule. var v: Dictionary = variants[vi] var cap := CapsuleShape3D.new() cap.radius = v.trunk_r * s cap.height = maxf(v.trunk_h * s, cap.radius * 2.0 + 0.1) var node := _resource_node("tree", xf) if vi == dead: node.drops_final = {"wood": [2, 3], "stick": [2, 4]} node.max_hp = 3.0 node.hp = 3.0 var cs := CollisionShape3D.new() cs.shape = cap cs.position = Vector3(0, cap.height * 0.5 + 0.1, 0) node.add_child(cs) pending.append([node, slot[0], slot[1]]) var meshes := variants.map(func(v): return v.mesh) _bind_nodes(pending, _flush(groups, meshes, true, "Trees")) ## Open grassy strip along the sea-cliff edge: no trees, bushes or boulders, so ## the view out to sea is clear and you can walk right up to the edge. func _on_clifftop(x: float, z: float) -> bool: return terrain.sea_weight(x, z) > 0.5 and not terrain.inside_boundary(x, z, 10.0) \ and terrain.inside_boundary(x, z, -8.0) func _tree_chance(x: float, z: float, h: float) -> float: if h < terrain.water_level + 0.8: return 0.0 if Vector2(x - _spawn.x, z - _spawn.z).length() < 9.0 or _on_clifftop(x, z): return 0.0 var n: Vector3 = terrain.sample_normal(x, z) var slope := 1.0 - n.y if slope > 0.5: return 0.0 var c := lerpf(0.035, 0.8, _forest(x, z)) c *= 1.0 - smoothstep(0.25, 0.5, slope) c *= 1.0 - smoothstep(terrain.snow_height - 12.0, terrain.snow_height - 2.0, h) return c # --- rocks ------------------------------------------------------------------ func _scatter_rocks() -> void: var meshes: Array = [] var hulls: Array = [] for i in range(7): var m := ProcMesh.rock(world_seed + 300 + i, i >= 5) m.surface_set_material(0, _rock_mat) meshes.append(m) hulls.append((m.create_convex_shape(true, true) as ConvexPolygonShape3D).points) var rng := RandomNumberGenerator.new() rng.seed = world_seed + 2 var groups := {} var pending: Array = [] # Small stones: everywhere, more on slopes and in the shallows. for p in _jittered(6.0, rng): var roll := rng.randf(); var vi := rng.randi_range(0, 4); var yaw := rng.randf() * TAU var s := rng.randf_range(0.2, 0.75) var h: float = terrain.sample_height(p.x, p.y) var n: Vector3 = terrain.sample_normal(p.x, p.y) var chance := 0.1 + (1.0 - n.y) * 0.8 if h < terrain.water_level - 2.0 or roll > chance: continue _place_rock(groups, meshes, hulls, p, h, n, vi, yaw, s, 0.6, 0.25) # Boulders: sparse, a little more common in forests. for p in _jittered(14.0, rng): var roll := rng.randf(); var vi := rng.randi_range(0, 4); var yaw := rng.randf() * TAU var s := rng.randf_range(1.2, 3.0) var h: float = terrain.sample_height(p.x, p.y) var n: Vector3 = terrain.sample_normal(p.x, p.y) if h < terrain.water_level - 0.5 or Vector2(p.x - _spawn.x, p.y - _spawn.z).length() < 8.0 or _on_clifftop(p.x, p.y): continue if roll > 0.07 + _forest(p.x, p.y) * 0.08: continue _place_rock(groups, meshes, hulls, p, h, n, vi, yaw, s, 0.5, 0.3, pending) # Cliff outcrops: big slabs breaking up the steep mountain faces. for p in _jittered(8.0, rng): var roll := rng.randf(); var vi := rng.randi_range(0, 6); var yaw := rng.randf() * TAU var s := rng.randf_range(3.0, 8.0) var n: Vector3 = terrain.sample_normal(p.x, p.y) if 1.0 - n.y < 0.32 or roll > 0.22: continue var h: float = terrain.sample_height(p.x, p.y) _place_rock(groups, meshes, hulls, p, h, n, vi, yaw, s, 0.9, 0.6) # Sea stacks: tall pillars standing off the cliff (no collision, unreachable). var placed := 0 var tries := 0 while placed < 9 and tries < 200: tries += 1 var ang: float = terrain.sea_angle_deg + rng.randf_range(-1.0, 1.0) * (terrain.sea_half_width_deg + 8.0) var p: Vector2 = terrain.coast_point(ang, rng.randf_range(10.0, 48.0)) var vi := rng.randi_range(5, 6) var yaw := rng.randf() * TAU var s := rng.randf_range(3.5, 7.0) var h: float = terrain.sample_height(p.x, p.y) if h > terrain.sea_level - 3.0: continue var b := Basis(Vector3.UP, yaw) * Basis.from_scale(Vector3(s, s * rng.randf_range(1.8, 2.6), s)) _group(groups, p.x, p.y, vi, Transform3D(b, Vector3(p.x, h - 1.0, p.y))) placed += 1 _bind_nodes(pending, _flush(groups, meshes, true, "Rocks")) ## Places one rock. Passing `pending` makes it a mineable boulder (if it's ## inside the walkable area); otherwise it just gets plain collision. func _place_rock(groups: Dictionary, meshes: Array, hulls: Array, p: Vector2, h: float, n: Vector3, vi: int, yaw: float, s: float, follow: float, sink: float, pending = null) -> void: var b := _ground_basis(n, follow, yaw, s) var pos := Vector3(p.x, h - sink * s, p.y) var slot := _group(groups, p.x, p.y, vi, Transform3D(b, pos)) if s >= 0.55 and terrain.inside_boundary(p.x, p.y, -6.0): var pts := PackedVector3Array() for q: Vector3 in hulls[vi]: pts.append(q * s) var shape := ConvexPolygonShape3D.new() shape.points = pts if pending != null and terrain.inside_boundary(p.x, p.y, 0.0): var node := _resource_node("rock", Transform3D(b, pos)) var cs := CollisionShape3D.new() cs.shape = shape cs.basis = b.orthonormalized() node.add_child(cs) pending.append([node, slot[0], slot[1]]) else: _add_shape(shape, Transform3D(b.orthonormalized(), pos)) # --- bushes, logs, stumps --------------------------------------------------- func _scatter_bushes() -> void: var meshes: Array = [] for i in range(3): meshes.append(_apply_materials(ProcMesh.bush(world_seed + 200 + i)).mesh) var rng := RandomNumberGenerator.new() rng.seed = world_seed + 3 var groups := {} for p in _jittered(4.5, rng): var roll := rng.randf(); var vi := rng.randi_range(0, 2); var yaw := rng.randf() * TAU var s := rng.randf_range(0.7, 1.5) var h: float = terrain.sample_height(p.x, p.y) var n: Vector3 = terrain.sample_normal(p.x, p.y) if h < terrain.water_level + 0.5 or n.y < 0.75 or h > terrain.snow_height - 8.0: continue if Vector2(p.x - _spawn.x, p.y - _spawn.z).length() < 4.0 or _on_clifftop(p.x, p.y): continue var f := _forest(p.x, p.y) var edge := 1.0 - absf(f - 0.5) * 2.0 # forest edges get the most undergrowth if roll > 0.05 + 0.4 * edge: continue _group(groups, p.x, p.y, vi, Transform3D(_ground_basis(n, 0.5, yaw, s), Vector3(p.x, h - 0.1, p.y))) _flush(groups, meshes, true, "Bushes") func _place_logs() -> void: var root := Node3D.new() root.name = "Logs" add_child(root) var rng := RandomNumberGenerator.new() rng.seed = world_seed + 4 var logs := 0 var stumps := 0 var tries := 0 while (logs < 22 or stumps < 30) and tries < 4000: tries += 1 var x := rng.randf_range(-terrain.play_radius, terrain.play_radius) var z := rng.randf_range(-terrain.play_radius, terrain.play_radius) var yaw := rng.randf() * TAU var make_log := rng.randf() < 0.45 if not terrain.inside_boundary(x, z, 6.0) or _forest(x, z) < 0.35: continue var h: float = terrain.sample_height(x, z) var n: Vector3 = terrain.sample_normal(x, z) if h < terrain.water_level + 0.6 or n.y < 0.97 or Vector2(x - _spawn.x, z - _spawn.z).length() < 8.0: continue var mi := MeshInstance3D.new() var shape: Shape3D var xf: Transform3D if make_log and logs < 22: var length := rng.randf_range(3.0, 5.0) var r := rng.randf_range(0.22, 0.34) mi.mesh = ProcMesh.log_mesh(world_seed + 400 + logs, length, r) xf = Transform3D(_ground_basis(n, 1.0, yaw, 1.0), Vector3(x, h + r * 0.75, z)) var box := BoxShape3D.new() box.size = Vector3(length, r * 1.8, r * 1.8) shape = box logs += 1 elif not make_log and stumps < 30: var sh := rng.randf_range(0.35, 0.7) var r := rng.randf_range(0.25, 0.4) mi.mesh = ProcMesh.stump_mesh(world_seed + 500 + stumps, sh, r) xf = Transform3D(Basis(Vector3.UP, yaw), Vector3(x, h, z)) var cyl := CylinderShape3D.new() cyl.radius = r * 1.1 cyl.height = sh + 0.3 shape = cyl stumps += 1 else: mi.free() continue mi.material_override = _bark_mat mi.transform = xf root.add_child(mi) var col_xf := xf if shape is CylinderShape3D: col_xf.origin.y += (shape as CylinderShape3D).height * 0.5 - 0.3 _add_shape(shape, col_xf) # --- ground pickups --------------------------------------------------------- ## Sticks under trees, stones everywhere, flint near water and on rocky ground. ## Enough for the first tools without hunting around for long. func _scatter_pickups() -> void: var root := Node3D.new() root.name = "Pickups" add_child(root) var rng := RandomNumberGenerator.new() 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} var radius: Dictionary = {"stick": 0.35, "stone": 0.25, "flint": 0.25, "mushroom": 0.25, "feather": 0.25} for id: String in want: var mesh := ItemDB.mesh_for(id) var placed := 0 var tries := 0 while placed < want[id] and tries < 20000: tries += 1 var x := rng.randf_range(-terrain.play_radius, terrain.play_radius) var z := rng.randf_range(-terrain.play_radius, terrain.play_radius) var yaw := rng.randf() * TAU var keep := rng.randf() if not terrain.inside_boundary(x, z, 3.0): continue var h: float = terrain.sample_height(x, z) var n: Vector3 = terrain.sample_normal(x, z) if h < terrain.water_level + 0.3 or n.y < 0.8: continue var chance := 1.0 match id: "stick": chance = 0.08 + _forest(x, z) * 0.9 "flint": var shore := 1.0 - smoothstep(terrain.water_level + 0.5, terrain.water_level + 4.0, h) chance = 0.06 + shore * 0.9 + (1.0 - n.y) * 2.0 "mushroom": chance = _forest(x, z) * 0.7 # deep forest only "feather": chance = 0.1 + (1.0 - _forest(x, z)) * 0.5 # open meadows # A few right by the spawn so the first craft is quick. if Vector2(x - _spawn.x, z - _spawn.z).length() < 18.0: chance += 0.5 if keep > chance: continue var pk: Area3D = PickupScript.new() pk.setup(id, mesh, ItemDB.item_material(), radius[id]) pk.uid = "p%d" % pickups.size() pickups[pk.uid] = pk pk.taken_now.connect(_on_taken) root.add_child(pk) pk.global_transform = Transform3D(_ground_basis(n, 1.0, yaw, 1.0), Vector3(x, h - 0.02, z)) placed += 1 _scatter_berry_bushes(root) ## Berry bushes along forest edges and in meadows, plus a couple near spawn. func _scatter_berry_bushes(root: Node3D) -> void: var rng := RandomNumberGenerator.new() rng.seed = world_seed + 7 var placed := 0 var tries := 0 while placed < 70 and tries < 8000: tries += 1 var x := rng.randf_range(-terrain.play_radius, terrain.play_radius) var z := rng.randf_range(-terrain.play_radius, terrain.play_radius) var yaw := rng.randf() * TAU var keep := rng.randf() if not terrain.inside_boundary(x, z, 4.0): continue var h: float = terrain.sample_height(x, z) var n: Vector3 = terrain.sample_normal(x, z) if h < terrain.water_level + 0.6 or n.y < 0.85: continue var f := _forest(x, z) var chance := 0.12 + (1.0 - absf(f - 0.45) * 2.0) * 0.5 var near_spawn := Vector2(x - _spawn.x, z - _spawn.z).length() if near_spawn < 6.0: continue if near_spawn < 25.0: chance += 0.4 if keep > chance: continue var bush: Area3D = ForageScript.new() bush.uid = "b%d" % forage.size() bush.rng.seed = hash(bush.uid) ^ world_seed bush.setup(_leaf_mat_static()) root.add_child(bush) bush.global_transform = Transform3D(_ground_basis(n, 0.5, yaw, rng.randf_range(0.9, 1.2)), Vector3(x, h - 0.05, z)) forage[bush.uid] = bush placed += 1 ## Berry-bush meshes carry their own vertex colours (including red berries), ## so they use the plain item material rather than the wind foliage shader. func _leaf_mat_static() -> Material: return ItemDB.item_material() # --- grass streaming -------------------------------------------------------- func _update_grass(budget: int) -> void: var p := _player.global_position var reach := grass_view_distance + GRASS_TILE var c := Vector2i(floori(p.x / GRASS_TILE), floori(p.z / GRASS_TILE)) var tr := int(ceil(reach / GRASS_TILE)) var wanted := {} var todo: Array = [] for dz in range(-tr, tr + 1): for dx in range(-tr, tr + 1): var k := c + Vector2i(dx, dz) var d := Vector2((k.x + 0.5) * GRASS_TILE, (k.y + 0.5) * GRASS_TILE).distance_to(Vector2(p.x, p.z)) if d > reach: continue wanted[k] = true if not _grass_tiles.has(k): todo.append([d, k]) todo.sort_custom(func(a, b): return a[0] < b[0]) for i in range(mini(budget, todo.size())): var k: Vector2i = todo[i][1] _grass_tiles[k] = _make_grass_tile(k) for k: Vector2i in _grass_tiles.keys(): if wanted.has(k): continue var d := Vector2((k.x + 0.5) * GRASS_TILE, (k.y + 0.5) * GRASS_TILE).distance_to(Vector2(p.x, p.z)) if d > reach + GRASS_TILE * 1.5: var node = _grass_tiles[k] if node != null and is_instance_valid(node): node.queue_free() _grass_tiles.erase(k) func _make_grass_tile(k: Vector2i) -> Node3D: var rng := RandomNumberGenerator.new() rng.seed = hash(k) ^ world_seed var ox := k.x * GRASS_TILE var oz := k.y * GRASS_TILE var centre := Vector3(ox + GRASS_TILE * 0.5, 0.0, oz + GRASS_TILE * 0.5) var grass_xf: Array = [] var flower_xf: Array = [] var flower_col: Array = [] var steps := int(GRASS_TILE / grass_spacing) # Floors that overlap this tile (grass doesn't grow through them). var cuts: Array = [] var tile := Rect2(ox, oz, GRASS_TILE, GRASS_TILE).grow(2.0) for c in _grass_cuts: if tile.has_point(Vector2(c[1].x, c[1].z)): cuts.append(c) for iz in range(steps): for ix in range(steps): var x := ox + (ix + rng.randf()) * grass_spacing var z := oz + (iz + rng.randf()) * grass_spacing var keep := rng.randf(); var yaw := rng.randf() * TAU var s := rng.randf_range(0.75, 1.25); var fl := rng.randf() if not terrain.in_bounds(x, z): continue var h: float = terrain.sample_height(x, z) if h < terrain.water_level + 0.25 or h > terrain.snow_height - 6.0: continue var n: Vector3 = terrain.sample_normal(x, z) if n.y < 0.72: continue if not cuts.is_empty() and _under_cut(cuts, x, z): continue var meadow := _meadow_noise.get_noise_2d(x, z) var dens := smoothstep(-0.45, 0.1, meadow) * (1.0 - smoothstep(0.85, 0.72, n.y) * 0.7) if keep > dens: continue var tall := lerpf(0.7, 1.35, smoothstep(-0.1, 0.4, meadow)) var b := Basis(Vector3.UP, yaw) * Basis.from_scale(Vector3(s, s * tall, s)) grass_xf.append(Transform3D(b, Vector3(x - centre.x, h - 0.03, z - centre.z))) if fl < 0.02 * smoothstep(0.0, 0.4, meadow): var ci := int(absf(_forest_noise.get_noise_2d(x * 3.0, z * 3.0)) * 10.0) % FLOWER_COLORS.size() flower_xf.append(Transform3D(Basis(Vector3.UP, yaw) * Basis.from_scale(Vector3.ONE * s), Vector3(x - centre.x, h, z - centre.z))) flower_col.append(FLOWER_COLORS[ci]) if grass_xf.is_empty(): return null var node := Node3D.new() node.position = centre var g := _mm_instance(_grass_mesh, grass_xf, [], false) _fade(g, grass_view_distance) node.add_child(g) if not flower_xf.is_empty(): var f := _mm_instance(_flower_mesh, flower_xf, flower_col, false) _fade(f, grass_view_distance * 0.7) node.add_child(f) _grass_root.add_child(node) return node ## Floor footprints to keep clear of grass: [world->local transform, centre, ## half width]. Set by world.gd whenever floors are built or removed; only the ## grass tiles around old and new floors are rebuilt. var _grass_cuts: Array = [] func set_grass_cuts(xforms: Array, half: float) -> void: var old := _grass_cuts _grass_cuts = [] for xf: Transform3D in xforms: _grass_cuts.append([xf.affine_inverse(), xf.origin, half]) var touched := {} for c in old + _grass_cuts: var p: Vector3 = c[1] for dz in [-3.0, 0.0, 3.0]: for dx in [-3.0, 0.0, 3.0]: touched[Vector2i(floori((p.x + dx) / GRASS_TILE), floori((p.z + dz) / GRASS_TILE))] = true for k in touched: if _grass_tiles.has(k): var node = _grass_tiles[k] if node != null and is_instance_valid(node): node.queue_free() _grass_tiles.erase(k) if _player != null: _update_grass(touched.size()) static func _under_cut(cuts: Array, x: float, z: float) -> bool: for c in cuts: var l: Vector3 = (c[0] as Transform3D) * Vector3(x, (c[1] as Vector3).y, z) if absf(l.x) < c[2] and absf(l.z) < c[2]: return true return false func _fade(gi: GeometryInstance3D, dist: float) -> void: gi.visibility_range_end = dist gi.visibility_range_end_margin = 10.0 gi.visibility_range_fade_mode = GeometryInstance3D.VISIBILITY_RANGE_FADE_SELF