Design: docs/SURVIVAL_DESIGN.md (core loop, systems, architecture, phases). Survival core (M1a): - Items/recipes as data (data/*.json) in the addon SDK's define_item / define_recipe shape; procedural item meshes and offscreen-rendered icons - Inventory class (40 slots, hotbar = row 1) shared by player and chests; click / right-click split / shift-click quick-move - Actions autoload: the intent layer (craft, pickup, harvest_hit, place, transfer) that validates every change; becomes the RPC surface in M2 - Gathering: ground pickups (sticks, stones, flint); trees chopped with an axe fall and leave stumps; boulders mined with a pickaxe shrink per hit - Crafting screen with station checks (workbench); placement mode with green/red ghost, rotation and overlap/slope validation - Pieces: campfire (light, flames, embers), workbench, chest (16 slots) - HUD: hotbar, held tool view model with swing, prompts, toasts Menus and saving: - ESC pause menu: resume, controls, fullscreen, mouse sensitivity, quit - Title screen over a flyover of the island: Continue / New World (name + seed) / Load World (with delete) / Quit - Saves autoload: one folder per world under user://worlds, atomic writes with a .bak; stores only player changes (player, pieces, chest contents, harvested resources, taken pickups) and regenerates the rest from the seed - Autosave every 2 min, Save & Quit to title/desktop, save on window close Fixes: UI screens now fill the viewport (were pinned top-left); title backdrop no longer captures the mouse. Tests: test_survival_core (31 checks), test_save_load (round trip + slots), test_terrain_edges now checks 8 seeds for deep pits / unbanked water.
465 lines
17 KiB
GDScript
465 lines
17 KiB
GDScript
extends Node3D
|
|
## Terrain generator.
|
|
##
|
|
## One heightmap chunk built from deterministic noise: a walkable valley of
|
|
## rolling hills with a lake, enclosed by a ring of steep mountains that opens on
|
|
## one side to a sheer sea cliff. The whole chunk is an island: its edge sinks
|
|
## into ocean so the end of the mesh is never visible.
|
|
##
|
|
## The rim is the world boundary: mountain slopes are too steep to walk up
|
|
## (> floor_max_angle), and an invisible wall stands partway up them, or just
|
|
## inside the cliff edge on the sea side, so nobody can climb out or fall off. Chunk
|
|
## streaming and biomes come in later milestones (docs/ARCHITECTURE.md §4).
|
|
##
|
|
## Heights are computed once into a grid that feeds the render mesh, the
|
|
## collision shape and `sample_height()`, so props and the player sit exactly on
|
|
## the visible surface.
|
|
##
|
|
## Collision is a HeightMapShape3D. Do NOT swap it for a concave trimesh: those
|
|
## are one-sided and the player fell straight through them from above.
|
|
|
|
signal ready_built ## emitted once the mesh + collision exist
|
|
|
|
@export var size: int = 320 ## chunk is size x size metres
|
|
@export var resolution: int = 256 ## grid cells per side
|
|
@export var height_scale: float = 9.0 ## rolling-hill amplitude in the valley
|
|
@export var noise_seed: int = 1337
|
|
|
|
@export_group("Boundary")
|
|
@export var play_radius: float = 100.0 ## where the mountain rim starts rising
|
|
@export var rim_width: float = 30.0 ## horizontal run of the rim's steep face
|
|
@export var mountain_height: float = 58.0
|
|
@export var rim_warp: float = 16.0 ## how irregular the rim's outline is
|
|
@export_range(0.2, 0.9) var wall_frac: float = 0.45 ## invisible wall, as a fraction up the rim
|
|
|
|
@export_group("Sea cliff")
|
|
@export var sea_angle_deg: float = -75.0 ## direction of the coast (atan2(z, x); -90 = straight -Z)
|
|
@export var sea_half_width_deg: float = 38.0 ## full-sea arc either side of sea_angle
|
|
@export var sea_fade_deg: float = 22.0 ## headland transition back to mountains
|
|
@export var sea_level: float = -24.0
|
|
@export var cliff_top: float = 0.5 ## height the coast rises/falls to at the edge
|
|
@export var cliff_offset: float = 4.0 ## cliff edge, in rim distance past play_radius
|
|
@export var cliff_drop_width: float = 6.0 ## horizontal run of the drop (smaller = sheerer)
|
|
|
|
@export_group("Water & snow")
|
|
@export var water_level: float = -2.6
|
|
@export var lake_center: Vector2 = Vector2(14.0, -46.0)
|
|
@export var lake_radius: float = 24.0
|
|
@export var snow_height: float = 40.0
|
|
|
|
@export_group("Colours")
|
|
@export var grass_lush: Color = Color(0.25, 0.40, 0.15)
|
|
@export var grass_dry: Color = Color(0.42, 0.49, 0.21)
|
|
|
|
var built: bool = false
|
|
var min_height: float = 0.0
|
|
var max_height: float = 0.0
|
|
var step: float = 1.0
|
|
var half: float = 0.0
|
|
var material: ShaderMaterial
|
|
var noise_tex: NoiseTexture2D ## shared by every world shader (see common.gdshaderinc)
|
|
var normal_tex: NoiseTexture2D ## detail normal map for ground and water
|
|
|
|
var _noise: FastNoiseLite
|
|
var _detail: FastNoiseLite
|
|
var _ridge: FastNoiseLite
|
|
var _warp: FastNoiseLite
|
|
var _wall_ring: Array[Vector3] = []
|
|
var _heights := PackedFloat32Array()
|
|
var _normals := PackedVector3Array()
|
|
|
|
func _ready() -> void:
|
|
# A saved world brings its own seed; otherwise keep the exported default.
|
|
var saves := get_node_or_null("/root/Saves")
|
|
if saves != null and saves.has_world():
|
|
noise_seed = saves.world_seed()
|
|
_build()
|
|
|
|
# --- height function --------------------------------------------------------
|
|
|
|
func _ensure_noise() -> void:
|
|
if _noise != null:
|
|
return
|
|
_noise = FastNoiseLite.new()
|
|
_noise.seed = noise_seed
|
|
_noise.noise_type = FastNoiseLite.TYPE_PERLIN
|
|
_noise.frequency = 0.012
|
|
_noise.fractal_octaves = 4
|
|
|
|
_detail = FastNoiseLite.new()
|
|
_detail.seed = noise_seed + 1
|
|
_detail.noise_type = FastNoiseLite.TYPE_SIMPLEX_SMOOTH
|
|
_detail.frequency = 0.06
|
|
_detail.fractal_octaves = 2
|
|
|
|
_ridge = FastNoiseLite.new()
|
|
_ridge.seed = noise_seed + 2
|
|
_ridge.noise_type = FastNoiseLite.TYPE_SIMPLEX_SMOOTH
|
|
_ridge.frequency = 0.022
|
|
_ridge.fractal_octaves = 4
|
|
|
|
_warp = FastNoiseLite.new()
|
|
_warp.seed = noise_seed + 3
|
|
_warp.noise_type = FastNoiseLite.TYPE_SIMPLEX_SMOOTH
|
|
_warp.frequency = 0.03
|
|
|
|
## Radial offset that makes the rim wobble instead of being a perfect circle.
|
|
## Sampled around a circle, so it depends only on the angle from the centre.
|
|
func _rim_offset(x: float, z: float) -> float:
|
|
var ang := atan2(z, x)
|
|
return _warp.get_noise_2d(cos(ang) * 50.0, sin(ang) * 50.0) * rim_warp
|
|
|
|
## "Rim distance": distance from the centre plus the wobble. The rim starts at
|
|
## play_radius and the invisible wall stands at _wall_d() in this same measure.
|
|
func _rim_d(x: float, z: float) -> float:
|
|
return sqrt(x * x + z * z) + _rim_offset(x, z)
|
|
|
|
func _wall_d() -> float:
|
|
return play_radius + rim_width * wall_frac
|
|
|
|
func _cliff_d() -> float:
|
|
return play_radius + cliff_offset
|
|
|
|
## 1 on the open-sea side of the rim, 0 where the mountains are, smooth between
|
|
## (the headlands). Depends only on the angle from the centre.
|
|
func _sea_w(x: float, z: float) -> float:
|
|
var diff := absf(wrapf(atan2(z, x) - deg_to_rad(sea_angle_deg), -PI, PI))
|
|
return 1.0 - smoothstep(deg_to_rad(sea_half_width_deg), deg_to_rad(sea_half_width_deg + sea_fade_deg), diff)
|
|
|
|
## Boundary threshold in rim distance: partway up the mountains, or just
|
|
## inside the cliff edge on the sea side.
|
|
func _wall_threshold(x: float, z: float) -> float:
|
|
return lerpf(_wall_d(), _cliff_d() - 1.0, _sea_w(x, z))
|
|
|
|
func _height_fn(x: float, z: float) -> float:
|
|
_ensure_noise()
|
|
var valley := _noise.get_noise_2d(x, z) * height_scale
|
|
valley += _detail.get_noise_2d(x, z) * 1.4
|
|
var d := _rim_d(x, z)
|
|
|
|
# Mountain rim.
|
|
var land := valley
|
|
var t := smoothstep(play_radius, play_radius + rim_width, d)
|
|
if t > 0.0:
|
|
var ridge := 1.0 - absf(_ridge.get_noise_2d(x, z))
|
|
var m := pow(t, 1.4) * mountain_height * (0.65 + 0.55 * ridge)
|
|
m += maxf(d - (play_radius + rim_width), 0.0) * 0.35 * (0.5 + ridge)
|
|
# Stepped ledges so the faces read as layered cliffs, not smooth ramps.
|
|
var band := 6.0
|
|
var stepped := floorf(m / band) * band + smoothstep(0.35, 0.65, fposmod(m, band) / band) * band
|
|
m = lerpf(m, stepped, 0.45 * t)
|
|
land = valley * (1.0 - t * 0.6) + m
|
|
|
|
# Sea side: the valley eases to a cliff top, then drops sheer to the seabed.
|
|
# The edge sits 1 m past the invisible wall at every angle, including the
|
|
# headlands where the wall moves outward. (With the edge fixed at _cliff_d(),
|
|
# the headlands dropped away INSIDE the wall: pits you could fall into, with
|
|
# lake water hanging over them. tests/test_terrain_edges.gd guards this.)
|
|
var sw := _sea_w(x, z)
|
|
if sw > 0.0:
|
|
var edge := _wall_threshold(x, z) + 1.0
|
|
var top := cliff_top + _detail.get_noise_2d(x * 0.5, z * 0.5) * 1.5
|
|
var coast := lerpf(valley, top, smoothstep(play_radius - 45.0, edge, d))
|
|
var seabed := sea_level - 7.0 - minf(maxf(d - edge, 0.0), 60.0) * 0.08 + _detail.get_noise_2d(x, z) * 2.0
|
|
coast = lerpf(coast, seabed, smoothstep(edge, edge + cliff_drop_width, d))
|
|
land = lerpf(land, coast, sw)
|
|
|
|
# Lake basin: blend the ground down to a bed below the waterline.
|
|
var ld := Vector2(x - lake_center.x, z - lake_center.y).length()
|
|
ld += _detail.get_noise_2d(x * 0.5, z * 0.5) * 5.0
|
|
land = lerpf(land, water_level - 3.2, smoothstep(lake_radius, lake_radius * 0.35, ld))
|
|
|
|
# Ocean all round the chunk edge, so the world is an island and the edge of
|
|
# the mesh is always hidden under deep water.
|
|
var e := smoothstep(size * 0.5 - 28.0, size * 0.5 - 4.0, sqrt(x * x + z * z))
|
|
return lerpf(land, sea_level - 10.0 + _detail.get_noise_2d(x, z) * 2.0, e)
|
|
|
|
# --- build ------------------------------------------------------------------
|
|
|
|
func _build() -> void:
|
|
_ensure_noise()
|
|
step = float(size) / float(resolution)
|
|
half = size * 0.5
|
|
var n := resolution + 1
|
|
|
|
_heights.resize(n * n)
|
|
min_height = INF
|
|
max_height = -INF
|
|
for iz in range(n):
|
|
for ix in range(n):
|
|
var hv := _height_fn(ix * step - half, iz * step - half)
|
|
_heights[iz * n + ix] = hv
|
|
min_height = minf(min_height, hv)
|
|
max_height = maxf(max_height, hv)
|
|
|
|
_normals.resize(n * n)
|
|
for iz in range(n):
|
|
for ix in range(n):
|
|
var hl := _heights[iz * n + maxi(ix - 1, 0)]
|
|
var hr := _heights[iz * n + mini(ix + 1, resolution)]
|
|
var hd := _heights[maxi(iz - 1, 0) * n + ix]
|
|
var hu := _heights[mini(iz + 1, resolution) * n + ix]
|
|
_normals[iz * n + ix] = Vector3(hl - hr, 2.0 * step, hd - hu).normalized()
|
|
|
|
_make_textures()
|
|
_build_mesh(n)
|
|
_build_collision(n)
|
|
_build_boundary()
|
|
_build_water()
|
|
|
|
built = true
|
|
ready_built.emit()
|
|
|
|
func _make_textures() -> void:
|
|
var fn := FastNoiseLite.new()
|
|
fn.seed = noise_seed + 10
|
|
fn.noise_type = FastNoiseLite.TYPE_SIMPLEX_SMOOTH
|
|
fn.frequency = 0.008
|
|
fn.fractal_octaves = 5
|
|
noise_tex = NoiseTexture2D.new()
|
|
noise_tex.width = 512
|
|
noise_tex.height = 512
|
|
noise_tex.seamless = true
|
|
noise_tex.noise = fn
|
|
|
|
var fn2 := FastNoiseLite.new()
|
|
fn2.seed = noise_seed + 11
|
|
fn2.noise_type = FastNoiseLite.TYPE_SIMPLEX_SMOOTH
|
|
fn2.frequency = 0.02
|
|
fn2.fractal_octaves = 4
|
|
normal_tex = NoiseTexture2D.new()
|
|
normal_tex.width = 512
|
|
normal_tex.height = 512
|
|
normal_tex.seamless = true
|
|
normal_tex.as_normal_map = true
|
|
normal_tex.bump_strength = 6.0
|
|
normal_tex.noise = fn2
|
|
|
|
func _build_mesh(n: int) -> void:
|
|
var verts := PackedVector3Array()
|
|
var uvs := PackedVector2Array()
|
|
verts.resize(n * n)
|
|
uvs.resize(n * n)
|
|
for iz in range(n):
|
|
for ix in range(n):
|
|
var i := iz * n + ix
|
|
verts[i] = Vector3(ix * step - half, _heights[i], iz * step - half)
|
|
uvs[i] = Vector2(float(ix) / resolution, float(iz) / resolution)
|
|
|
|
var idx := PackedInt32Array()
|
|
idx.resize(resolution * resolution * 6)
|
|
var k := 0
|
|
for iz in range(resolution):
|
|
for ix in range(resolution):
|
|
var i00 := iz * n + ix
|
|
var i10 := i00 + 1
|
|
var i01 := i00 + n
|
|
var i11 := i01 + 1
|
|
# Winding chosen so the top face is the front face in Godot.
|
|
idx[k] = i00; idx[k + 1] = i11; idx[k + 2] = i01
|
|
idx[k + 3] = i00; idx[k + 4] = i10; idx[k + 5] = i11
|
|
k += 6
|
|
|
|
var arrays := []
|
|
arrays.resize(Mesh.ARRAY_MAX)
|
|
arrays[Mesh.ARRAY_VERTEX] = verts
|
|
arrays[Mesh.ARRAY_NORMAL] = _normals
|
|
arrays[Mesh.ARRAY_TEX_UV] = uvs
|
|
arrays[Mesh.ARRAY_INDEX] = idx
|
|
var mesh := ArrayMesh.new()
|
|
mesh.add_surface_from_arrays(Mesh.PRIMITIVE_TRIANGLES, arrays)
|
|
|
|
material = ShaderMaterial.new()
|
|
material.shader = preload("res://shaders/terrain.gdshader")
|
|
material.set_shader_parameter("noise_tex", noise_tex)
|
|
material.set_shader_parameter("normal_tex", normal_tex)
|
|
material.set_shader_parameter("water_level", water_level)
|
|
material.set_shader_parameter("snow_height", snow_height)
|
|
material.set_shader_parameter("grass_lush", grass_lush)
|
|
material.set_shader_parameter("grass_dry", grass_dry)
|
|
|
|
var mi := MeshInstance3D.new()
|
|
mi.name = "TerrainMesh"
|
|
mi.mesh = mesh
|
|
mi.material_override = material
|
|
add_child(mi)
|
|
|
|
func _build_collision(n: int) -> void:
|
|
var hm := HeightMapShape3D.new()
|
|
hm.map_width = n
|
|
hm.map_depth = n
|
|
hm.map_data = _heights
|
|
var body := StaticBody3D.new()
|
|
body.name = "TerrainBody"
|
|
add_child(body)
|
|
var col := CollisionShape3D.new()
|
|
col.shape = hm
|
|
body.add_child(col)
|
|
# HeightMapShape3D samples are 1 unit apart; scale to match the mesh.
|
|
body.scale = Vector3(step, 1.0, step)
|
|
|
|
## Invisible wall ringing the valley partway up the mountain slopes, plus four
|
|
## world-boundary planes at the chunk edges as a last line of defence.
|
|
func _build_boundary() -> void:
|
|
var body := StaticBody3D.new()
|
|
body.name = "Boundary"
|
|
add_child(body)
|
|
|
|
var segs := 128
|
|
var pts: Array[Vector3] = []
|
|
for i in range(segs):
|
|
var ang := TAU * i / segs
|
|
var dir := Vector3(cos(ang), 0.0, sin(ang))
|
|
# Solve for the radius whose rim distance equals the wall threshold.
|
|
var r := _wall_threshold(dir.x, dir.z) - _rim_offset(dir.x, dir.z)
|
|
pts.append(dir * r)
|
|
_wall_ring = pts
|
|
for i in range(segs):
|
|
var a := pts[i]
|
|
var b := pts[(i + 1) % segs]
|
|
var mid := (a + b) * 0.5
|
|
var length := a.distance_to(b) + 0.6
|
|
var box := BoxShape3D.new()
|
|
box.size = Vector3(length, 400.0, 1.5)
|
|
var cs := CollisionShape3D.new()
|
|
cs.shape = box
|
|
var along := (b - a).normalized()
|
|
var seg_basis := Basis(along, Vector3.UP, along.cross(Vector3.UP))
|
|
cs.transform = Transform3D(seg_basis, Vector3(mid.x, 100.0, mid.z))
|
|
body.add_child(cs)
|
|
|
|
var planes := [
|
|
Plane(Vector3.RIGHT, -half), Plane(Vector3.LEFT, -half),
|
|
Plane(Vector3.BACK, -half), Plane(Vector3.FORWARD, -half),
|
|
]
|
|
for p: Plane in planes:
|
|
var wb := WorldBoundaryShape3D.new()
|
|
wb.plane = p
|
|
var cs := CollisionShape3D.new()
|
|
cs.shape = wb
|
|
body.add_child(cs)
|
|
|
|
## Two water bodies: inland water (lake and ponds) as a disc clipped to the
|
|
## boundary ring, so it can't hang in the air past the sea cliff; and an ocean
|
|
## plane out to the horizon at sea level.
|
|
func _build_water() -> void:
|
|
var verts := PackedVector3Array([Vector3.ZERO])
|
|
for p in _wall_ring:
|
|
verts.append(p)
|
|
var count := verts.size()
|
|
var normals := PackedVector3Array()
|
|
var tangents := PackedFloat32Array()
|
|
var uvs := PackedVector2Array()
|
|
for v in verts:
|
|
normals.append(Vector3.UP)
|
|
tangents.append_array([1.0, 0.0, 0.0, 1.0])
|
|
uvs.append(Vector2(v.x, v.z) / size + Vector2(0.5, 0.5))
|
|
var idx := PackedInt32Array()
|
|
for i in range(1, count):
|
|
idx.append_array([0, i, i % (count - 1) + 1])
|
|
var arrays := []
|
|
arrays.resize(Mesh.ARRAY_MAX)
|
|
arrays[Mesh.ARRAY_VERTEX] = verts
|
|
arrays[Mesh.ARRAY_NORMAL] = normals
|
|
arrays[Mesh.ARRAY_TANGENT] = tangents
|
|
arrays[Mesh.ARRAY_TEX_UV] = uvs
|
|
arrays[Mesh.ARRAY_INDEX] = idx
|
|
var disc := ArrayMesh.new()
|
|
disc.add_surface_from_arrays(Mesh.PRIMITIVE_TRIANGLES, arrays)
|
|
_add_water("Water", disc, water_level, {})
|
|
|
|
var ocean := PlaneMesh.new()
|
|
ocean.size = Vector2(8000.0, 8000.0)
|
|
_add_water("Ocean", ocean, sea_level, {
|
|
"shallow_col": Color(0.16, 0.44, 0.50),
|
|
"deep_col": Color(0.02, 0.10, 0.20),
|
|
"clarity": 6.0,
|
|
})
|
|
|
|
func _add_water(label: String, mesh: Mesh, y: float, params: Dictionary) -> void:
|
|
var mat := ShaderMaterial.new()
|
|
mat.shader = preload("res://shaders/water.gdshader")
|
|
mat.set_shader_parameter("noise_tex", noise_tex)
|
|
mat.set_shader_parameter("normal_tex", normal_tex)
|
|
for k in params:
|
|
mat.set_shader_parameter(k, params[k])
|
|
var mi := MeshInstance3D.new()
|
|
mi.name = label
|
|
mi.mesh = mesh
|
|
mi.material_override = mat
|
|
mi.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
|
|
mi.position = Vector3(0.0, y, 0.0)
|
|
add_child(mi)
|
|
|
|
# --- queries ----------------------------------------------------------------
|
|
|
|
func in_bounds(x: float, z: float) -> bool:
|
|
return absf(x) < half - step and absf(z) < half - step
|
|
|
|
## True inside the walkable valley. A positive margin shrinks the area, a
|
|
## negative one extends it past the invisible wall.
|
|
func inside_boundary(x: float, z: float, margin: float = 0.0) -> bool:
|
|
_ensure_noise()
|
|
return _rim_d(x, z) < _wall_threshold(x, z) - margin
|
|
|
|
## How strongly (x, z) lies on the sea side of the rim (0..1).
|
|
func sea_weight(x: float, z: float) -> float:
|
|
_ensure_noise()
|
|
return _sea_w(x, z)
|
|
|
|
## Point on the coast along `angle_deg` (atan2(z, x) convention), at rim
|
|
## distance `past_edge` beyond the cliff edge (negative = inland of it).
|
|
func coast_point(angle_deg: float, past_edge: float) -> Vector2:
|
|
_ensure_noise()
|
|
var dir := Vector2(cos(deg_to_rad(angle_deg)), sin(deg_to_rad(angle_deg)))
|
|
var r := _cliff_d() + past_edge - _rim_offset(dir.x, dir.y)
|
|
return dir * r
|
|
|
|
## Surface height at (x, z), interpolated on the same triangles as the mesh.
|
|
func sample_height(x: float, z: float) -> float:
|
|
if _heights.is_empty():
|
|
return _height_fn(x, z)
|
|
var n := resolution + 1
|
|
var gx := clampf((x + half) / step, 0.0, resolution - 0.0001)
|
|
var gz := clampf((z + half) / step, 0.0, resolution - 0.0001)
|
|
var ix := int(gx)
|
|
var iz := int(gz)
|
|
var fx := gx - ix
|
|
var fz := gz - iz
|
|
var h00 := _heights[iz * n + ix]
|
|
var h10 := _heights[iz * n + ix + 1]
|
|
var h01 := _heights[(iz + 1) * n + ix]
|
|
var h11 := _heights[(iz + 1) * n + ix + 1]
|
|
if fx > fz:
|
|
return h00 + (h10 - h00) * fx + (h11 - h10) * fz
|
|
return h00 + (h11 - h01) * fx + (h01 - h00) * fz
|
|
|
|
## Smoothed surface normal at (x, z).
|
|
func sample_normal(x: float, z: float) -> Vector3:
|
|
if _normals.is_empty():
|
|
return Vector3.UP
|
|
var n := resolution + 1
|
|
var gx := clampf((x + half) / step, 0.0, resolution - 0.0001)
|
|
var gz := clampf((z + half) / step, 0.0, resolution - 0.0001)
|
|
var ix := int(gx)
|
|
var iz := int(gz)
|
|
var fx := gx - ix
|
|
var fz := gz - iz
|
|
var a := _normals[iz * n + ix].lerp(_normals[iz * n + ix + 1], fx)
|
|
var b := _normals[(iz + 1) * n + ix].lerp(_normals[(iz + 1) * n + ix + 1], fx)
|
|
return a.lerp(b, fz).normalized()
|
|
|
|
## Nearest dry, gentle spot to `near` (spiral search), for spawning.
|
|
func find_spawn(near: Vector2) -> Vector3:
|
|
for ring in range(0, 30):
|
|
var r := ring * 2.0
|
|
var count := maxi(1, ring * 6)
|
|
for i in range(count):
|
|
var ang := TAU * i / count
|
|
var x := near.x + cos(ang) * r
|
|
var z := near.y + sin(ang) * r
|
|
var h := sample_height(x, z)
|
|
if h > water_level + 1.2 and sample_normal(x, z).y > 0.93 and inside_boundary(x, z, 15.0):
|
|
return Vector3(x, h, z)
|
|
return Vector3(near.x, sample_height(near.x, near.y), near.y)
|