900 lines
34 KiB
GDScript
900 lines
34 KiB
GDScript
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 ItemMeshes = preload("res://scripts/items/item_meshes.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 pier: Node3D ## the Cove's pier, kept clear of scenery (set by the world)
|
|
var keep_clear: Array = [] ## [[Vector3, radius]]: the tunnel's seam, the sea cave
|
|
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 = {}
|
|
## Off on a client playing online: the server says when things grow back.
|
|
var regrow_enabled := true
|
|
## Other places nothing may regrow on top of (a server's players).
|
|
var blockers: Array = []
|
|
var regrow_scale := 1.0 ## test hook: a server started with --debug-regrow <k>
|
|
signal regrown(uid: String)
|
|
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] * regrow_scale
|
|
|
|
func _on_taken(pk: Node) -> void:
|
|
regrow[pk.uid] = REGROW_SECONDS["pickup"] * regrow_scale
|
|
|
|
## 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() if regrow_enabled else []:
|
|
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
|
|
var blocked := _player != null and _player.global_position.distance_to(node.global_position) < r + 3.0
|
|
for b in blockers:
|
|
blocked = blocked or (b as Vector3).distance_to(node.global_position) < r + 3.0
|
|
if blocked or _built_over(node.global_position, r):
|
|
regrow[uid] = REGROW_RETRY * regrow_scale
|
|
return
|
|
regrow.erase(uid)
|
|
node.regrow()
|
|
regrown.emit(uid)
|
|
|
|
## Clears the pickups (sticks, stones, flax, grass) under a newly built
|
|
## floor, wall or roof: a short ray up from each nearby one finds the piece.
|
|
func clear_under(piece: Node3D) -> void:
|
|
if not is_instance_valid(piece) or not is_inside_tree():
|
|
return
|
|
await get_tree().physics_frame # (the piece's collider is in the world from the next step)
|
|
if not is_instance_valid(piece):
|
|
return
|
|
var space := get_world_3d().direct_space_state
|
|
var at := piece.global_position
|
|
for pk in pickups.values():
|
|
if not is_instance_valid(pk) or pk.taken:
|
|
continue
|
|
var p: Vector3 = pk.global_position
|
|
if Vector2(p.x - at.x, p.z - at.z).length() > 4.0:
|
|
continue
|
|
var q := PhysicsRayQueryParameters3D.create(p + Vector3(0, 0.02, 0), p + Vector3(0, 2.6, 0))
|
|
q.collision_mask = 4 # building pieces (build_piece.gd BUILD_LAYER)
|
|
q.hit_from_inside = true # (a floor's footing reaches into the ground, round the pickup)
|
|
if not space.intersect_ray(q).is_empty():
|
|
pk.collected()
|
|
if Net.is_server():
|
|
WorldNet.taken(WorldNet.pickup_key(pk)) # (gone for everyone playing too)
|
|
|
|
## 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] * regrow_scale
|
|
for uid in pickups:
|
|
if pickups[uid].taken and not regrow.has(uid):
|
|
regrow[uid] = REGROW_SECONDS["pickup"] * regrow_scale
|
|
|
|
# --- 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.
|
|
## (Also kept clear: the Cove's pier and the sand by its foot.)
|
|
func _on_clifftop(x: float, z: float) -> bool:
|
|
if pier != null and pier.covers(x, z, 2.0):
|
|
return true
|
|
for c in keep_clear:
|
|
if Vector2(x - c[0].x, z - c[0].z).length() < float(c[1]):
|
|
return true
|
|
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 and terrain._has_sea: # (only off a sea cliff)
|
|
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"))
|
|
|
|
## 0..1: how close (x, z) is to the foot of the mountain rim, where scree
|
|
## and iron collect. 1 within 6 m of the edge of the walkable area, 0 from
|
|
## 30 m in.
|
|
func _near_rim(x: float, z: float) -> float:
|
|
if not terrain.inside_boundary(x, z, 6.0):
|
|
return 1.0
|
|
if terrain.inside_boundary(x, z, 30.0):
|
|
return 0.0
|
|
for m in [24.0, 18.0, 12.0]: # (inside at margin m = at least m metres from the edge)
|
|
if terrain.inside_boundary(x, z, m):
|
|
return 1.0 - (m - 6.0) / 24.0
|
|
return 1.0
|
|
|
|
## 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))
|
|
# Rust-streaked boulders with iron ore in them: mostly near the
|
|
# rim, a few anywhere. (From the position, so it's the same every
|
|
# time, without disturbing the scatter's random numbers.)
|
|
var roll := float(absi(hash(Vector2i(int(p.x * 10.0), int(p.y * 10.0)))) % 1000) / 1000.0
|
|
if roll < 0.18 + _near_rim(p.x, p.y) * 0.6:
|
|
node.make_iron(hulls[vi])
|
|
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")
|
|
|
|
## Hides the decorative bushes within `r` of `center` (the barrow's and the
|
|
## standing stones' clearings: they're placed after the undergrowth).
|
|
## Deterministic, like the rest of the scenery.
|
|
func clear_bushes(center: Vector3, r: float) -> int:
|
|
var root := get_node_or_null("Bushes")
|
|
if root == null:
|
|
return 0
|
|
var n := 0
|
|
for mmi in root.get_children():
|
|
if not mmi is MultiMeshInstance3D:
|
|
continue
|
|
var mm: MultiMesh = mmi.multimesh
|
|
for i in range(mm.instance_count):
|
|
var xf: Transform3D = mmi.global_transform * mm.get_instance_transform(i)
|
|
if Vector2(xf.origin.x - center.x, xf.origin.z - center.z).length() < r:
|
|
mm.set_instance_transform(i, Transform3D(xf.basis, xf.origin + Vector3(0, -1000, 0)))
|
|
n += 1
|
|
return n
|
|
|
|
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, "fiber": 150,
|
|
"iron_nugget": 70, "clay": 60, "salt": 40}
|
|
var radius: Dictionary = {"stick": 0.35, "stone": 0.25, "flint": 0.25, "mushroom": 0.25, "feather": 0.25, "fiber": 0.45,
|
|
"iron_nugget": 0.25, "clay": 0.3, "salt": 0.25}
|
|
for id: String in want:
|
|
# Fibre grows as a plant (wild flax) you pick, 2 fibre a clump.
|
|
var mesh: Mesh = ItemMeshes.build("fiber_plant") if id == "fiber" else 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 or (pier != null and pier.covers(x, z, 1.0)):
|
|
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
|
|
"fiber":
|
|
# Meadows, and thickest along the lake shore.
|
|
var wet := 1.0 - smoothstep(terrain.water_level + 0.5, terrain.water_level + 3.0, h)
|
|
chance = 0.05 + (1.0 - _forest(x, z)) * 0.45 + wet * 0.6
|
|
"iron_nugget":
|
|
# In the scree at the foot of the mountains (and on rocky slopes).
|
|
chance = 0.02 + _near_rim(x, z) * 0.9 + (1.0 - n.y) * 1.5
|
|
"clay":
|
|
# The lake shore only.
|
|
var lake: Vector2 = terrain.lake_center
|
|
var by_lake: bool = Vector2(x - lake.x, z - lake.y).length() < terrain.lake_radius * 1.6
|
|
var shore: float = 1.0 - smoothstep(terrain.water_level + 0.4, terrain.water_level + 1.8, h)
|
|
chance = shore * 0.95 if by_lake else 0.0
|
|
"salt":
|
|
# Crusts on the cliff tops above the sea (P4).
|
|
chance = 0.95 if terrain.sea_weight(x, z) > 0.6 and _near_rim(x, z) > 0.35 else 0.0
|
|
# A few right by the spawn so the first craft is quick.
|
|
if Vector2(x - _spawn.x, z - _spawn.z).length() < 18.0 and not id in ["iron_nugget", "clay", "salt"]:
|
|
chance += 0.5
|
|
if keep > chance:
|
|
continue
|
|
var pk: Area3D = PickupScript.new()
|
|
pk.setup(id, mesh, ItemDB.item_material(), radius[id])
|
|
if id == "fiber":
|
|
pk.amount = 2
|
|
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 = []
|
|
## Permanent cuts (the wolf den floor): [transform, half width x, half depth z].
|
|
var static_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, half])
|
|
for c in static_cuts:
|
|
var xf: Transform3D = c[0]
|
|
_grass_cuts.append([xf.affine_inverse(), xf.origin, float(c[1]), float(c[2])])
|
|
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[3]:
|
|
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
|