Expand generated terrain and forest variation

This commit is contained in:
Alexander Sellite 2026-08-21 16:38:30 -04:00
parent 779e3d983e
commit b2752b1074
25 changed files with 898 additions and 145 deletions

View file

@ -89,9 +89,8 @@ presented through a uniformly scaled SubViewport. Shared UI components and
palette resources prevent page-specific geometry and style drift. palette resources prevent page-specific geometry and style drift.
World presentation systems (time/weather visuals, procedural sky and water, World presentation systems (time/weather visuals, procedural sky and water,
shoreline ribbons, player blob shadows) do not own gameplay collision or shoreline treatment, player blob shadows) do not own gameplay collision or
network authority. Generated shoreline meshes are deterministic presentation network authority.
resources baked from explicitly configured static terrain.
### Engineering consequences ### Engineering consequences
@ -129,9 +128,9 @@ remain authoritative in `.tres` resources and typed scripts.
wallet exactly once. wallet exactly once.
- Reserved assets are rejected atomically; mixed valid and reserved batches - Reserved assets are rejected atomically; mixed valid and reserved batches
must not partially succeed. must not partially succeed.
- Item effects, shop prices, cooler capacity, fishing upgrades, jobs, and - Item effects, shop prices, inventory and storage capacity, fishing upgrades,
experience are separate balance axes. Avoid changing several in an jobs, and experience are separate balance axes. Avoid changing several in
unrelated presentation pass. an unrelated presentation pass.
Record affected stable IDs and resource paths, old and new values, intended Record affected stable IDs and resource paths, old and new values, intended
player-facing outcome, interactions with quality/availability/economy, player-facing outcome, interactions with quality/availability/economy,
@ -143,70 +142,57 @@ authored number changed.
### World composition ### World composition
The active test world uses the canonical starter island through `world/test_world.tscn` is the shared gameplay stage. It owns the environment,
`world/regions/starter_island_region.tscn`. sun, world bounds, and below-world failsafe, then hosts exactly one active
`WorldRegion`. Two canonical region implementations are supported:
`world/test_world.tscn` owns the gameplay-wide environment, sun, world bounds, - the generated world, which is the default for new games and is rebuilt from
and below-world failsafe. `StarterIslandRegion` owns the placed island content: the saved seed; and
- the starter island, which remains an authored selectable layout and a useful
compatibility/reference scene.
The stable layout IDs live in `world/world_layout.gd`. Do not infer a layout
from a scene filename, display label, or region node name. Both implementations
must satisfy the shared `WorldRegion` contract for player spawn, safe respawns,
water regions, recovery, gathering, digging, the shop, and player storage.
A region owns the content that changes with the selected world:
```text ```text
StarterIslandRegion WorldRegion
├── Terrain ├── Terrain
│ ├── Visual
│ └── Collision
├── WaterBodies ├── WaterBodies
│ ├── Pond
│ │ ├── VisualWater
│ │ ├── FishingRegion
│ │ └── RecoveryRegion
│ └── Ocean
│ ├── VisualWater
│ ├── FishingRegions
│ └── RecoveryRegions
├── PlayerSpawn ├── PlayerSpawn
├── SafeRespawns ├── SafeRespawns
├── DiggableAreas
├── GatherableAnchors
└── Interactables └── Interactables
├── FishingShopWorld ├── FishingShopWorld
└── PelicanCoolerPerch └── PlayerStorageBox
``` ```
Move a meaningful feature root rather than one of its implementation children. The generated region owns its chunk, biome, and prop catalogs and derives
Child transforms are local offsets owned by that feature. terrain collision, water coverage, biome placement, and presentation reference
geometry from the generated result. The starter-island region owns its imported
terrain hierarchy and explicitly placed content. Code shared by both layouts
must depend on `WorldRegion`, not a starter-island child path.
Every fishable region authors its `water_type` explicitly. The starter pond is Every fishable water body authors its water type explicitly. Fish species use
`FRESH_WATER`; the starter coast is `SALT_WATER`. Fish species use the same the same central type through an allowed-water-type bitmask. Do not infer
central type through an allowed-water-type bitmask. Do not infer habitat from habitat from node names, pool filenames, coordinates, or water height.
node names, pool filenames, coordinates, or water height.
Select `WaterBodies/Pond` to move or resize the pond. Its transform is the In the generated layout, seed and layout ID are persistent data. Chunk IDs,
authoritative surface position; surface size and fishing/recovery coverage are edge rules, biome definitions, and prop definitions are authored resources;
owned together. Select `WaterBodies/Ocean` to move the surrounding water as one changing them can change the world produced by an existing seed and therefore
feature. Its fishing coverage is derived from the visible water mesh and the requires deliberate compatibility review.
terrain collision determines the shoreline. New or revised land meshes do not
require hand-authored fishing exclusions. Recovery volumes remain explicit so
they can follow the playable world bounds rather than the horizon-sized visual
ocean.
Placed-feature ownership: For the authored starter island, move a meaningful feature root rather than one
of its implementation children. Child transforms are local offsets owned by
that feature. Its imported GLB hierarchy is the starter region's terrain and
collision authority; this rule does not describe generated-world collision.
- `PlayerSpawn` defines the initial player transform and facing. The active `Environment` and `Sun` remain Inspector-authored on the shared
- `SafeRespawns` contains authoritative recovery destinations. Recovery stage and are not recreated by either region.
preserves current facing.
- `Interactables/FishingShopWorld` owns the shop visual, collision, interaction
area, prompt, and entrance marker.
- `Interactables/PelicanCoolerPerch` owns the Pelican landmark visual; selling
remains a Cooler action.
- `Terrain` owns imported visuals and generated collision.
- `BelowWorldFailsafe/Coverage` owns below-world recovery coverage.
The starter-island GLB hierarchy is the terrain-collision authority. The
region rebuilds one concave collision shape from every imported
`MeshInstance3D` when it loads, so newly exported terrain and props participate
without a separate stale collision bake. Keep purely decorative meshes out of
that hierarchy if they should not block players.
The active `Environment`, `Sun`, and starter-island grass material remain
Inspector-authored and are not recreated by runtime scripts.
### Reusable world props ### Reusable world props
@ -226,29 +212,14 @@ root scaling is acceptable when visual and collision scale together; avoid
non-uniform root scaling. Make mutable per-instance shapes, meshes, and non-uniform root scaling. Make mutable per-instance shapes, meshes, and
materials local to the scene. materials local to the scene.
### Shoreline tide ribbons ### Shoreline presentation
Maps using generated tide ribbons own a `ShorelineRibbonBaker` and one The visible shoreline treatment is currently produced by the water materials.
`ShorelineRibbonConfig` per water body. Each configuration selects only its The older smooth-ribbon baker and generated starter-island ribbon resource are
static terrain source, water height, generation bounds, water-facing reference, retained as development history/tooling, but the ribbon mesh is disabled and
smoothing overrides, and generated `.tres` path. Props, players, bobbers, and is not part of the current rendered shoreline. Do not treat a ribbon rebuild
gameplay areas are not scanned unless selected explicitly. Only `SALT_WATER` as a normal terrain-authoring requirement or re-enable the mesh as incidental
bodies generate ribbons. cleanup.
The editor automatically rebuilds configured shoreline resources when an
authored terrain GLB is reimported. Select the baker and press **Rebuild
Shoreline Ribbons** only when intentionally forcing a rebuild without a terrain
reimport. The equivalent headless fallback and validation command is:
```sh
godot --headless --path . --script scripts/bake_shoreline_ribbons.gd
```
Use `debug_path_stage` only while comparing extraction stages and leave it Off
in committed scenes. A rebuild is required when saltwater terrain at the
waterline, water height, or generation bounds change; terrain reimports handle
the usual case automatically. Normal gameplay loads committed generated meshes
and never runs extraction.
### Facial-feature textures ### Facial-feature textures
@ -299,12 +270,12 @@ with a -3 dBFS peak ceiling, mono, 48 kHz, 16-bit PCM. Import a supplied set
through the normalization script rather than copying its WAV files directly: through the normalization script rather than copying its WAV files directly:
```bash ```bash
scripts/import_animalese_voice.sh /path/to/source_clips sample_set_directory scripts/import_animalese_voice.sh /path/to/source_clips <sample-set-id>
``` ```
The source directory may contain lowercase letter, number, and underscore WAV The source directory may contain lowercase letter, number, and underscore WAV
filenames. The script converts every top-level WAV and writes the results under filenames. The script converts every top-level WAV and writes the results under
`sound/dialogue/animalese/sample_set_directory/`. Add a new runtime set to `sound/dialogue/animalese/<sample-set-id>/`. Add a new runtime set to
`player/animalese_voice_profiles.gd` after import, then run the quick validation `player/animalese_voice_profiles.gd` after import, then run the quick validation
suite. Keep the untouched submission and its original hashes in the private suite. Keep the untouched submission and its original hashes in the private
intake record; repository files are normalized derivatives. intake record; repository files are normalized derivatives.

View file

@ -1,6 +1,5 @@
NETfishing NETfishing
v0.14.0-alpha Alpha playtest
Alpha 0.14.0
Thank you for trying this early private playtest. Thank you for trying this early private playtest.
@ -30,13 +29,16 @@ Sprint Shift
Sneak Ctrl or click right stick Sneak Ctrl or click right stick
Slow walk Alt Slow walk Alt
Rotate camera Hold right mouse, or use right stick Rotate camera Hold right mouse, or use right stick
Cycle active hotbar slot Mouse wheel Cycle active hotbar slot Mouse wheel or D-pad left/right
Select hotbar slot 1 through 9 Select hotbar slot 1 through 9
Zoom camera Shift + mouse wheel Zoom camera Shift + mouse wheel
Cast / withdraw / reel Left mouse Primary tool / fishing Left mouse or right trigger
Cooler, Bag, and Logbook Tab Inventory and player pages Tab
Game Menu / back Escape Game Menu / back Escape
Open Fishing Shop E while near the shop Interact E
Chat T
Keyboard and controller bindings can be reviewed and changed in Settings.
FISHING FISHING
@ -48,12 +50,14 @@ FISHING
- At barriers, use distinct left-click presses to damage the barrier. - At barriers, use distinct left-click presses to damage the barrier.
- Do not let the red chase meter catch the green meter. - Do not let the red chase meter catch the green meter.
- Accessibility auto-click can be enabled in Settings. - Accessibility auto-click can be enabled in Settings.
- Approach the bright Fishing Shop booth and press E to open it. - Approach the Fishing Shop and interact to open it.
- The Fishing Shop buys one Cooler fish at a time for full base value. - Move sellable inventory entries into the shop's sell tray, review the total,
- Buy Coffee, Energy Drinks, Snacks, and Fish Finders as Bag supplies. and confirm the sale.
- Drag supplies from Bag to the hotbar and left-click in READY to use one. - Buy supplies, equipment, and persistent upgrades from their shop tabs.
- Move world-use items into the hotbar and select their slot to use or hold
them.
- Coffee improves movement; other supplies temporarily improve fishing. - Coffee improves movement; other supplies temporarily improve fishing.
- The Cooler starts at 12 fish; buy permanent capacity expansions at the shop. - Inventory and storage capacity can be expanded permanently at the shop.
- Reel Speed upgrades increase authoritative green reeling progress. - Reel Speed upgrades increase authoritative green reeling progress.
- Rod Power upgrades increase damage per valid barrier action. - Rod Power upgrades increase damage per valid barrier action.
- Shop upgrades persist in the local progression save. - Shop upgrades persist in the local progression save.
@ -71,28 +75,24 @@ FEATURES TO TRY
- Barrier-and-chase catching - Barrier-and-chase catching
- Accessibility auto-click settings - Accessibility auto-click settings
- Catch showcase and fish size variation - Catch showcase and fish size variation
- Cooler, Bag, Logbook, favorites, and sorting - General inventory, separate tackle, hotbar storage, player storage, and the
- Basic Fishing Rod equipment and the 19 hotbar Logbook
- Pelican selling and wallet updates - Fishing, net gathering, and beach digging
- Physical Fishing Shop sales and persistent fishing upgrades - Equipment and consumable use from the hotbar
- Fishing Shop sell tray, wallet updates, and persistent upgrades
- Seeded generated worlds and the authored starter-island layout
- Save, Continue, New Game, and Delete Save - Save, Continue, New Game, and Delete Save
- Game Menu and persistent camera settings - Game Menu and persistent camera settings
- Water-entry recovery from several shores - Water-entry recovery from several shores
SAVES AND SETTINGS SAVES AND SETTINGS
Progression and settings are stored in Godot's per-user application-data Progression and settings are stored outside the extracted game folder. The
directory, outside this extracted game folder. Delete Save removes progression Data & Identity page shows the active player-data location. Delete Save removes
but preserves settings. progression but preserves device settings.
Windows:
%APPDATA%\Godot\app_userdata\NETFISHING\
Linux:
~/.local/share/godot/app_userdata/NETFISHING/
This is an early save format. Keep expectations modest and report any failure This is an early save format. Keep expectations modest and report any failure
to Continue, save, sell, favorite, or retain an individual fish. to Continue, save, sell, move, favorite, or retain an inventory entry.
FEEDBACK FEEDBACK

View file

@ -23,11 +23,13 @@ const EXPECTED_PROP_IDS: Array[StringName] = [
&"prop_mushroom", &"prop_mushroom",
&"prop_palm", &"prop_palm",
&"prop_pine", &"prop_pine",
&"prop_pine_large",
&"prop_timber_1", &"prop_timber_1",
&"prop_timber_2", &"prop_timber_2",
&"prop_tree_1", &"prop_tree_1",
&"prop_tree_2", &"prop_tree_2",
&"prop_tree_3", &"prop_tree_3",
&"prop_tree_large",
] ]
const EXPECTED_BIOME_IDS: Array[StringName] = [ const EXPECTED_BIOME_IDS: Array[StringName] = [
&"biome_plains", &"biome_plains",
@ -40,9 +42,11 @@ const PROCEDURAL_PROP_IDS: Array[StringName] = [
&"prop_mushroom", &"prop_mushroom",
&"prop_palm", &"prop_palm",
&"prop_pine", &"prop_pine",
&"prop_pine_large",
&"prop_tree_1", &"prop_tree_1",
&"prop_tree_2", &"prop_tree_2",
&"prop_tree_3", &"prop_tree_3",
&"prop_tree_large",
] ]
@ -53,6 +57,11 @@ func _initialize() -> void:
func _run() -> void: func _run() -> void:
var region := RegionScene.instantiate() as GeneratedWorldRegion var region := RegionScene.instantiate() as GeneratedWorldRegion
assert(region != null) assert(region != null)
var configured_generator := region.get_node(
"Terrain/TerrainChunkGenerator"
) as TerrainChunkGenerator
assert(configured_generator != null)
configured_generator.elevated_cliff_third_level_chance = 1.0
root.add_child(region) root.add_child(region)
await process_frame await process_frame
await physics_frame await physics_frame
@ -124,7 +133,10 @@ func _validate_generated_region(
) )
assert( assert(
generator.get_primary_terrain_meshes().size() generator.get_primary_terrain_meshes().size()
== expected_chunk_count == (
expected_chunk_count
+ generator.stacked_elevated_placement_keys().size()
)
) )
_validate_elevated_cliff_feature(generator) _validate_elevated_cliff_feature(generator)
@ -179,6 +191,16 @@ func _validate_generated_region(
assert(decorations != null and decorations.get_child_count() > 0) assert(decorations != null and decorations.get_child_count() > 0)
assert(anchors != null) assert(anchors != null)
assert(anchors.get_spawn_positions().size() > 0) assert(anchors.get_spawn_positions().size() > 0)
var palm_clusters: Dictionary[int, Array] = {}
var tree_scales: Array[float] = []
var tree_yaws: Dictionary[float, bool] = {}
var ocean_edges_by_coordinate: Dictionary[Vector2i, int] = {}
for record: Dictionary in generator.placement_records():
var record_edges := int(record.get("ocean_facing_edges", 0))
if record_edges != 0:
ocean_edges_by_coordinate[
record.get("coordinate", Vector2i.ZERO)
] = record_edges
for child: Node in decorations.get_children(): for child: Node in decorations.get_children():
var prop_id := StringName(child.get_meta(&"terrain_prop_id", &"")) var prop_id := StringName(child.get_meta(&"terrain_prop_id", &""))
assert(PROCEDURAL_PROP_IDS.has(prop_id)) assert(PROCEDURAL_PROP_IDS.has(prop_id))
@ -211,13 +233,80 @@ func _validate_generated_region(
child as Node3D, child as Node3D,
definition, definition,
) )
if prop_id in [&"prop_tree_1", &"prop_tree_2", &"prop_tree_3"]: if prop_id == &"prop_palm":
var cluster_id := int(
child.get_meta(&"terrain_prop_cluster_id", -1)
)
var members: Array = palm_clusters.get(cluster_id, [])
members.append(child)
palm_clusters[cluster_id] = members
var ocean_direction := _ocean_direction(
ocean_edges_by_coordinate.get(coordinate, 0)
)
if ocean_direction.is_zero_approx():
ocean_direction = _nearest_ocean_direction(
region,
generator,
(child as Node3D).position,
)
var local_overhang := Vector3(
definition.local_overhang_direction.x,
0.0,
definition.local_overhang_direction.y,
).normalized()
var actual_overhang := local_overhang.rotated(
Vector3.UP,
(child as Node3D).rotation.y,
)
assert(
actual_overhang.dot(ocean_direction)
>= cos(deg_to_rad(definition.ocean_facing_spread_degrees))
- 0.001
)
elif prop_id in [
&"prop_tree_1",
&"prop_tree_2",
&"prop_tree_3",
&"prop_tree_large",
&"prop_pine",
&"prop_pine_large",
]:
tree_scales.append(
float(child.get_meta(&"terrain_prop_visual_scale", 1.0))
)
tree_yaws[snappedf((child as Node3D).rotation.y, 0.01)] = true
if prop_id in [
&"prop_tree_1",
&"prop_tree_2",
&"prop_tree_3",
&"prop_tree_large",
]:
assert( assert(
_has_material_variant_override( _has_material_variant_override(
child.get_node_or_null("Visual"), child.get_node_or_null("Visual"),
definition.material_variants, definition.material_variants,
) )
) )
assert(
_has_material_variant_override(
child.get_node_or_null("Visual"),
definition.secondary_material_variants,
)
)
assert(not palm_clusters.is_empty())
for members: Array in palm_clusters.values():
assert(members.size() >= 2 and members.size() <= 3)
for first_index: int in members.size():
for second_index: int in range(first_index + 1, members.size()):
var first := members[first_index] as Node3D
var second := members[second_index] as Node3D
var separation := Vector2(
first.position.x - second.position.x,
first.position.z - second.position.z,
).length()
assert(separation > 0.5 and separation < 6.1)
assert(tree_scales.max() - tree_scales.min() > 0.25)
assert(tree_yaws.size() >= 8)
assert(_decoration_group_count(region, &"grass_tree") > 0) assert(_decoration_group_count(region, &"grass_tree") > 0)
assert(_decoration_group_count(region, &"sand_tree") > 0) assert(_decoration_group_count(region, &"sand_tree") > 0)
assert( assert(
@ -297,6 +386,35 @@ func _validate_elevated_cliff_feature(
assert(base_mesh.has_node("TerrainBaseLayerCollision")) assert(base_mesh.has_node("TerrainBaseLayerCollision"))
assert(overlay_mesh.has_node("TerrainCollision")) assert(overlay_mesh.has_node("TerrainCollision"))
assert(layered_count == 9) assert(layered_count == 9)
var stacked_keys := generator.stacked_elevated_placement_keys()
assert(stacked_keys.size() in [0, 4])
if stacked_keys.is_empty():
return
for key: String in stacked_keys:
assert(key.begins_with("chunk_0010@"))
var stacked_count := 0
for chunk_root: Node in generated.get_children():
for child: Node in chunk_root.get_children():
if not bool(child.get_meta(&"terrain_stacked_elevation", false)):
continue
stacked_count += 1
assert(
StringName(child.get_meta(&"terrain_chunk_id", &""))
== &"chunk_0010"
)
assert(
is_equal_approx(
(child as Node3D).position.y,
generator.elevated_cliff_level_height,
)
)
var stacked_mesh := TerrainChunkAnalyzer.find_primary_mesh(
child,
&"chunk_0010",
)
assert(stacked_mesh != null and stacked_mesh.mesh != null)
assert(stacked_mesh.has_node("TerrainCollision"))
assert(stacked_count == 4)
func _validate_ocean_facing_record( func _validate_ocean_facing_record(
@ -323,6 +441,31 @@ func _validate_ocean_facing_record(
) )
func _ocean_direction(ocean_edges: int) -> Vector3:
var result := Vector3.ZERO
for edge_value: int in TerrainChunkTopology.Edge.values():
if (ocean_edges & (1 << edge_value)) != 0:
result += TerrainChunkTopology.edge_normal(
edge_value as TerrainChunkTopology.Edge
)
return result.normalized()
func _nearest_ocean_direction(
region: GeneratedWorldRegion,
generator: TerrainChunkGenerator,
position: Vector3,
) -> Vector3:
var half_extents := region.get_playable_half_extents()
var distance_x := half_extents.x - absf(position.x)
var distance_z := half_extents.y - absf(position.z)
var direction_x := Vector3.RIGHT if position.x >= 0.0 else Vector3.LEFT
var direction_z := Vector3.BACK if position.z >= 0.0 else Vector3.FORWARD
if absf(distance_x - distance_z) <= generator.catalog.chunk_size * 0.35:
return (direction_x + direction_z).normalized()
return direction_x if distance_x < distance_z else direction_z
func _validate_prop_catalog(region: GeneratedWorldRegion) -> void: func _validate_prop_catalog(region: GeneratedWorldRegion) -> void:
var catalog := region.get_prop_catalog() var catalog := region.get_prop_catalog()
assert(catalog != null) assert(catalog != null)
@ -343,16 +486,49 @@ func _validate_prop_catalog(region: GeneratedWorldRegion) -> void:
&"prop_tree_1", &"prop_tree_1",
&"prop_tree_2", &"prop_tree_2",
&"prop_tree_3", &"prop_tree_3",
&"prop_tree_large",
]: ]:
var tree := catalog.definition_for_id(tree_id) var tree := catalog.definition_for_id(tree_id)
assert(tree != null) assert(tree != null)
assert(tree.minimum_visual_scale < tree.maximum_visual_scale) assert(tree.minimum_visual_scale < tree.maximum_visual_scale)
assert(tree.material_variants.size() >= 3) assert(tree.material_variants.size() >= 3)
assert(not tree.variant_material_slot_names.is_empty()) assert(not tree.variant_material_slot_names.is_empty())
assert(tree.secondary_material_variants.size() >= 3)
assert(not tree.secondary_variant_material_slot_names.is_empty())
var pine := catalog.definition_for_id(&"prop_pine") var pine := catalog.definition_for_id(&"prop_pine")
assert(pine != null) assert(pine != null)
assert(pine.minimum_visual_scale < pine.maximum_visual_scale) assert(pine.minimum_visual_scale < pine.maximum_visual_scale)
assert(pine.material_variants.is_empty()) assert(pine.material_variants.is_empty())
var large_pine := catalog.definition_for_id(&"prop_pine_large")
assert(large_pine != null)
assert(large_pine.minimum_visual_scale < large_pine.maximum_visual_scale)
assert(large_pine.material_variants.is_empty())
var palm := catalog.definition_for_id(&"prop_palm")
assert(palm != null)
assert(is_equal_approx(palm.minimum_visual_scale, 0.5))
assert(is_equal_approx(palm.maximum_visual_scale, 1.0))
assert(palm.minimum_cluster_size == 2)
assert(palm.maximum_cluster_size == 3)
assert(palm.minimum_cluster_radius > palm.clearance_radius)
assert(palm.maximum_cluster_radius > palm.minimum_cluster_radius)
assert(palm.prefer_ocean_facing)
assert(not palm.local_overhang_direction.is_zero_approx())
var forest := region.get_biome_catalog().definition_for_id(&"biome_forest")
var forest_rule := forest.prop_rule_for_group(&"grass_tree")
var large_tree := catalog.definition_for_id(&"prop_tree_large")
var small_tree_weight := (
catalog.definition_for_id(&"prop_tree_1").selection_weight
+ catalog.definition_for_id(&"prop_tree_2").selection_weight
+ catalog.definition_for_id(&"prop_tree_3").selection_weight
)
assert(forest_rule.allows_prop(large_tree))
assert(large_tree.selection_weight > small_tree_weight)
var pine_forest := region.get_biome_catalog().definition_for_id(
&"biome_pine_forest"
)
var pine_rule := pine_forest.prop_rule_for_group(&"grass_tree")
assert(pine_rule.allows_prop(large_pine))
assert(large_pine.selection_weight > pine.selection_weight)
func _validate_biome_catalog( func _validate_biome_catalog(

View file

@ -6,9 +6,9 @@
[sub_resource type="Resource" id="Rule_sand_tree"] [sub_resource type="Resource" id="Rule_sand_tree"]
script = ExtResource("2_rule") script = ExtResource("2_rule")
procedural_group = &"sand_tree" procedural_group = &"sand_tree"
placement_chance = 4200 placement_chance = 2200
adjacency_bonus = 1200 adjacency_bonus = 900
maximum_density = 5000 maximum_density = 2800
minimum_placements = 1 minimum_placements = 1
allowed_prop_ids = PackedStringArray("prop_palm") allowed_prop_ids = PackedStringArray("prop_palm")

View file

@ -11,7 +11,7 @@ adjacency_bonus = 1200
maximum_density = 8500 maximum_density = 8500
minimum_placements = 2 minimum_placements = 2
placement_attempts_per_chunk = 3 placement_attempts_per_chunk = 3
allowed_prop_ids = PackedStringArray("prop_tree_1", "prop_tree_2", "prop_tree_3") allowed_prop_ids = PackedStringArray("prop_tree_large", "prop_tree_1", "prop_tree_2", "prop_tree_3")
[sub_resource type="Resource" id="Rule_grass_detail"] [sub_resource type="Resource" id="Rule_grass_detail"]
script = ExtResource("2_rule") script = ExtResource("2_rule")

View file

@ -11,7 +11,7 @@ adjacency_bonus = 1200
maximum_density = 8500 maximum_density = 8500
minimum_placements = 2 minimum_placements = 2
placement_attempts_per_chunk = 3 placement_attempts_per_chunk = 3
allowed_prop_ids = PackedStringArray("prop_pine") allowed_prop_ids = PackedStringArray("prop_pine_large", "prop_pine")
[sub_resource type="Resource" id="Rule_grass_detail"] [sub_resource type="Resource" id="Rule_grass_detail"]
script = ExtResource("2_rule") script = ExtResource("2_rule")

View file

@ -31,6 +31,7 @@ const OCEAN_POOL: FishPool = preload(
const WATER_HEIGHT := -0.25 const WATER_HEIGHT := -0.25
const PROP_EDGE_MARGIN := 2.2 const PROP_EDGE_MARGIN := 2.2
const PROP_PLACEMENT_ATTEMPTS := 12 const PROP_PLACEMENT_ATTEMPTS := 12
const PROP_CLUSTER_PLACEMENT_ATTEMPTS := 10
const PROP_MINIMUM_GROUND_CLEARANCE := 0.05 const PROP_MINIMUM_GROUND_CLEARANCE := 0.05
const PROP_CHANCE_SCALE := 10000 const PROP_CHANCE_SCALE := 10000
const PROP_SELECTION_WEIGHT_SCALE := 1000 const PROP_SELECTION_WEIGHT_SCALE := 1000
@ -268,6 +269,7 @@ func _on_generation_completed(summary: Dictionary) -> void:
record.get("position", Vector3.ZERO), record.get("position", Vector3.ZERO),
coordinate, coordinate,
biome.stable_id, biome.stable_id,
int(record.get("ocean_facing_edges", 0)),
random, random,
terrain_triangles, terrain_triangles,
): ):
@ -445,6 +447,7 @@ func _maybe_add_prop(
chunk_center: Vector3, chunk_center: Vector3,
chunk_coordinate: Vector2i, chunk_coordinate: Vector2i,
biome_id: StringName, biome_id: StringName,
ocean_facing_edges: int,
random: RandomNumberGenerator, random: RandomNumberGenerator,
terrain_triangles: Array[PackedVector3Array], terrain_triangles: Array[PackedVector3Array],
) -> bool: ) -> bool:
@ -460,6 +463,7 @@ func _maybe_add_prop(
chunk_center, chunk_center,
chunk_coordinate, chunk_coordinate,
biome_id, biome_id,
ocean_facing_edges,
random, random,
terrain_triangles, terrain_triangles,
) )
@ -470,19 +474,26 @@ func _add_prop(
chunk_center: Vector3, chunk_center: Vector3,
chunk_coordinate: Vector2i, chunk_coordinate: Vector2i,
biome_id: StringName, biome_id: StringName,
ocean_facing_edges: int,
random: RandomNumberGenerator, random: RandomNumberGenerator,
terrain_triangles: Array[PackedVector3Array], terrain_triangles: Array[PackedVector3Array],
) -> bool: ) -> bool:
if definition == null or definition.packed_scene == null: if definition == null or definition.packed_scene == null:
return false return false
var visual_scale := random.randf_range( var cluster_size := random.randi_range(
definition.minimum_visual_scale, definition.minimum_cluster_size,
definition.maximum_visual_scale, definition.maximum_cluster_size,
) )
var scaled_clearance := definition.clearance_radius * visual_scale var visual_scales := _prop_visual_scales(
definition,
cluster_size,
random,
)
var primary_scale := visual_scales[0]
var scaled_clearance := definition.clearance_radius * primary_scale
var placement := Vector3.ZERO var placement := Vector3.ZERO
var found_surface := false var found_surface := false
for attempt: int in PROP_PLACEMENT_ATTEMPTS: for _attempt: int in PROP_PLACEMENT_ATTEMPTS:
var offset := Vector3( var offset := Vector3(
random.randf_range(-PROP_EDGE_MARGIN, PROP_EDGE_MARGIN), random.randf_range(-PROP_EDGE_MARGIN, PROP_EDGE_MARGIN),
0.0, 0.0,
@ -507,6 +518,118 @@ func _add_prop(
break break
if not found_surface: if not found_surface:
return false return false
var cluster_id := _decorations.get_child_count()
if not _instantiate_prop(
definition,
placement,
primary_scale,
_prop_yaw(definition, ocean_facing_edges, placement, random),
chunk_coordinate,
biome_id,
cluster_id,
0,
random,
):
return false
if cluster_size <= 1:
return true
var cluster_angle := random.randf_range(-PI, PI)
var companion_count := cluster_size - 1
var chunk_half_extent := _generator.catalog.chunk_size * 0.5 - 0.25
for member_index: int in range(1, cluster_size):
var member_scale := visual_scales[member_index]
var member_clearance := definition.clearance_radius * member_scale
for _attempt: int in PROP_CLUSTER_PLACEMENT_ATTEMPTS:
var sector_angle := (
cluster_angle
+ TAU * float(member_index - 1) / float(companion_count)
+ random.randf_range(-0.45, 0.45)
)
var radius := random.randf_range(
definition.minimum_cluster_radius,
definition.maximum_cluster_radius,
)
var candidate := placement + Vector3(
cos(sector_angle) * radius,
0.0,
sin(sector_angle) * radius,
)
if (
absf(candidate.x - chunk_center.x) > chunk_half_extent
or absf(candidate.z - chunk_center.z) > chunk_half_extent
):
continue
var surface_height := _surface_height_at(
candidate,
terrain_triangles,
)
if (
surface_height <= WATER_HEIGHT + PROP_MINIMUM_GROUND_CLEARANCE
or not _has_prop_clearance(candidate, member_clearance)
):
continue
candidate.y = surface_height
if _instantiate_prop(
definition,
candidate,
member_scale,
_prop_yaw(
definition,
ocean_facing_edges,
candidate,
random,
),
chunk_coordinate,
biome_id,
cluster_id,
member_index,
random,
):
break
return true
func _prop_visual_scales(
definition: TerrainPropDefinition,
count: int,
random: RandomNumberGenerator,
) -> Array[float]:
var result: Array[float] = []
if count <= 1:
result.append(
random.randf_range(
definition.minimum_visual_scale,
definition.maximum_visual_scale,
)
)
return result
for index: int in count:
var descending_band := count - index - 1
var band_minimum := lerpf(
definition.minimum_visual_scale,
definition.maximum_visual_scale,
float(descending_band) / float(count),
)
var band_maximum := lerpf(
definition.minimum_visual_scale,
definition.maximum_visual_scale,
float(descending_band + 1) / float(count),
)
result.append(random.randf_range(band_minimum, band_maximum))
return result
func _instantiate_prop(
definition: TerrainPropDefinition,
placement: Vector3,
visual_scale: float,
yaw: float,
chunk_coordinate: Vector2i,
biome_id: StringName,
cluster_id: int,
cluster_member_index: int,
random: RandomNumberGenerator,
) -> bool:
var packed_instance := definition.packed_scene.instantiate() var packed_instance := definition.packed_scene.instantiate()
var visual_root := packed_instance as Node3D var visual_root := packed_instance as Node3D
if visual_root == null: if visual_root == null:
@ -522,8 +645,10 @@ func _add_prop(
prop.set_meta(&"terrain_chunk_coordinate", chunk_coordinate) prop.set_meta(&"terrain_chunk_coordinate", chunk_coordinate)
prop.set_meta(&"terrain_biome_id", biome_id) prop.set_meta(&"terrain_biome_id", biome_id)
prop.set_meta(&"terrain_prop_visual_scale", visual_scale) prop.set_meta(&"terrain_prop_visual_scale", visual_scale)
prop.set_meta(&"terrain_prop_cluster_id", cluster_id)
prop.set_meta(&"terrain_prop_cluster_member_index", cluster_member_index)
prop.position = placement prop.position = placement
prop.rotation.y = random.randf_range(-PI, PI) prop.rotation.y = yaw
_decorations.add_child(prop) _decorations.add_child(prop)
visual_root.name = "Visual" visual_root.name = "Visual"
prop.add_child(visual_root) prop.add_child(visual_root)
@ -533,7 +658,9 @@ func _add_prop(
_apply_prop_material_variant(visual_root, definition, random) _apply_prop_material_variant(visual_root, definition, random)
_add_prop_collision(prop, definition, visual_scale) _add_prop_collision(prop, definition, visual_scale)
_placed_prop_positions.append(placement) _placed_prop_positions.append(placement)
_placed_prop_clearance_radii.append(scaled_clearance) _placed_prop_clearance_radii.append(
definition.clearance_radius * visual_scale
)
_placed_prop_groups.append(definition.procedural_group) _placed_prop_groups.append(definition.procedural_group)
if definition.gatherable_anchor_height > 0.0: if definition.gatherable_anchor_height > 0.0:
var anchor := Marker3D.new() var anchor := Marker3D.new()
@ -547,6 +674,46 @@ func _add_prop(
return true return true
func _prop_yaw(
definition: TerrainPropDefinition,
ocean_facing_edges: int,
placement: Vector3,
random: RandomNumberGenerator,
) -> float:
if not definition.prefer_ocean_facing:
return random.randf_range(-PI, PI)
var ocean_direction := Vector2.ZERO
for edge_value: int in TerrainChunkTopology.Edge.values():
if (ocean_facing_edges & (1 << edge_value)) == 0:
continue
var edge_normal := TerrainChunkTopology.edge_normal(
edge_value as TerrainChunkTopology.Edge
)
ocean_direction += Vector2(edge_normal.x, edge_normal.z)
if ocean_direction.is_zero_approx():
ocean_direction = _nearest_ocean_direction(placement)
var local_direction := definition.local_overhang_direction.normalized()
var target_angle := atan2(ocean_direction.x, ocean_direction.y)
var local_angle := atan2(local_direction.x, local_direction.y)
var spread := deg_to_rad(definition.ocean_facing_spread_degrees)
return (
target_angle
- local_angle
+ random.randf_range(-spread, spread)
)
func _nearest_ocean_direction(position: Vector3) -> Vector2:
var half_extents := get_playable_half_extents()
var distance_x := half_extents.x - absf(position.x)
var distance_z := half_extents.y - absf(position.z)
var direction_x := Vector2.RIGHT if position.x >= 0.0 else Vector2.LEFT
var direction_z := Vector2.DOWN if position.z >= 0.0 else Vector2.UP
if absf(distance_x - distance_z) <= _generator.catalog.chunk_size * 0.35:
return (direction_x + direction_z).normalized()
return direction_x if distance_x < distance_z else direction_z
func _validate_prop_catalog() -> void: func _validate_prop_catalog() -> void:
if prop_catalog == null: if prop_catalog == null:
push_error("Generated world terrain prop catalog is unavailable.") push_error("Generated world terrain prop catalog is unavailable.")
@ -776,6 +943,7 @@ func _ensure_minimum_props(
record.get("position", Vector3.ZERO), record.get("position", Vector3.ZERO),
coordinate, coordinate,
biome.stable_id, biome.stable_id,
int(record.get("ocean_facing_edges", 0)),
random, random,
terrain_triangles, terrain_triangles,
): ):
@ -819,17 +987,32 @@ func _apply_prop_material_variant(
definition: TerrainPropDefinition, definition: TerrainPropDefinition,
random: RandomNumberGenerator, random: RandomNumberGenerator,
) -> void: ) -> void:
if ( _apply_random_material_variant(
definition.material_variants.is_empty()
or definition.variant_material_slot_names.is_empty()
):
return
var variant := definition.material_variants[
random.randi_range(0, definition.material_variants.size() - 1)
]
_apply_material_variant_to_meshes(
root_node, root_node,
definition.variant_material_slot_names, definition.variant_material_slot_names,
definition.material_variants,
random,
)
_apply_random_material_variant(
root_node,
definition.secondary_variant_material_slot_names,
definition.secondary_material_variants,
random,
)
func _apply_random_material_variant(
root_node: Node,
target_material_names: PackedStringArray,
variants: Array[Material],
random: RandomNumberGenerator,
) -> void:
if variants.is_empty() or target_material_names.is_empty():
return
var variant := variants[random.randi_range(0, variants.size() - 1)]
_apply_material_variant_to_meshes(
root_node,
target_material_names,
variant, variant,
) )

Binary file not shown.

View file

@ -0,0 +1,45 @@
[remap]
importer="scene"
importer_version=1
type="PackedScene"
uid="uid://bsislktcsck1y"
path="res://.godot/imported/prop_pine_large.glb-97f0e7e162a6a624faaf2cc023211d3e.scn"
[deps]
source_file="res://world/generation/props/assets/prop_pine_large.glb"
dest_files=["res://.godot/imported/prop_pine_large.glb-97f0e7e162a6a624faaf2cc023211d3e.scn"]
[params]
nodes/root_type=""
nodes/root_name=""
nodes/root_script=null
mesh_library/use_node_names_as_mesh_names=false
array_mesh/deduplicate_surfaces=true
nodes/apply_root_scale=true
nodes/root_scale=1.0
nodes/import_as_skeleton_bones=false
nodes/use_name_suffixes=true
nodes/use_node_type_suffixes=true
meshes/ensure_tangents=true
meshes/generate_lods=true
meshes/create_shadow_meshes=true
meshes/light_baking=1
meshes/lightmap_texel_size=0.2
meshes/force_disable_compression=false
skins/use_named_skins=true
animation/import=true
animation/fps=30
animation/trimming=false
animation/remove_immutable_tracks=true
animation/import_rest_as_RESET=false
import_script/path=""
materials/extract=0
materials/extract_format=0
materials/extract_path=""
_subresources={}
gltf/naming_version=2
gltf/embedded_image_handling=1
gltf/texture_map_mode=1

Binary file not shown.

View file

@ -0,0 +1,45 @@
[remap]
importer="scene"
importer_version=1
type="PackedScene"
uid="uid://c46jcb2vo2fd"
path="res://.godot/imported/prop_tree_large.glb-0a4ab94061e4876be025fa3d63aa7bd8.scn"
[deps]
source_file="res://world/generation/props/assets/prop_tree_large.glb"
dest_files=["res://.godot/imported/prop_tree_large.glb-0a4ab94061e4876be025fa3d63aa7bd8.scn"]
[params]
nodes/root_type=""
nodes/root_name=""
nodes/root_script=null
mesh_library/use_node_names_as_mesh_names=false
array_mesh/deduplicate_surfaces=true
nodes/apply_root_scale=true
nodes/root_scale=1.0
nodes/import_as_skeleton_bones=false
nodes/use_name_suffixes=true
nodes/use_node_type_suffixes=true
meshes/ensure_tangents=true
meshes/generate_lods=true
meshes/create_shadow_meshes=true
meshes/light_baking=1
meshes/lightmap_texel_size=0.2
meshes/force_disable_compression=false
skins/use_named_skins=true
animation/import=true
animation/fps=30
animation/trimming=false
animation/remove_immutable_tracks=true
animation/import_rest_as_RESET=false
import_script/path=""
materials/extract=0
materials/extract_format=0
materials/extract_path=""
_subresources={}
gltf/naming_version=2
gltf/embedded_image_handling=1
gltf/texture_map_mode=1

View file

@ -11,6 +11,15 @@ packed_scene = ExtResource("2_scene")
procedural_group = &"sand_tree" procedural_group = &"sand_tree"
required_chunk_tags = PackedStringArray("sand") required_chunk_tags = PackedStringArray("sand")
minimum_spawn_chunk_distance = 2 minimum_spawn_chunk_distance = 2
clearance_radius = 2.0 clearance_radius = 0.65
minimum_visual_scale = 0.5
maximum_visual_scale = 1.0
minimum_cluster_size = 2
maximum_cluster_size = 3
minimum_cluster_radius = 1.4
maximum_cluster_radius = 3.0
prefer_ocean_facing = true
local_overhang_direction = Vector2(-0.883, 0.469)
ocean_facing_spread_degrees = 55.0
collision_radius = 0.4 collision_radius = 0.4
collision_height = 6.0 collision_height = 6.0

View file

@ -6,15 +6,15 @@
[resource] [resource]
script = ExtResource("1_definition") script = ExtResource("1_definition")
stable_id = &"prop_pine" stable_id = &"prop_pine"
label = "pine" label = "small pine"
packed_scene = ExtResource("2_scene") packed_scene = ExtResource("2_scene")
procedural_group = &"grass_tree" procedural_group = &"grass_tree"
required_chunk_tags = PackedStringArray("grass") required_chunk_tags = PackedStringArray("grass")
selection_weight = 0.7 selection_weight = 0.7
minimum_spawn_chunk_distance = 2 minimum_spawn_chunk_distance = 2
clearance_radius = 1.55 clearance_radius = 1.55
minimum_visual_scale = 0.75 minimum_visual_scale = 0.65
maximum_visual_scale = 1.22 maximum_visual_scale = 1.2
collision_radius = 0.5 collision_radius = 0.5
collision_height = 4.0 collision_height = 4.0
gatherable_anchor_height = 2.15 gatherable_anchor_height = 2.15

View file

@ -0,0 +1,20 @@
[gd_resource type="Resource" script_class="TerrainPropDefinition" load_steps=3 format=3]
[ext_resource type="Script" path="res://world/generation/terrain_prop_definition.gd" id="1_definition"]
[ext_resource type="PackedScene" path="res://world/generation/props/assets/prop_pine_large.glb" id="2_scene"]
[resource]
script = ExtResource("1_definition")
stable_id = &"prop_pine_large"
label = "large pine"
packed_scene = ExtResource("2_scene")
procedural_group = &"grass_tree"
required_chunk_tags = PackedStringArray("grass")
selection_weight = 3.0
minimum_spawn_chunk_distance = 2
clearance_radius = 1.65
minimum_visual_scale = 0.75
maximum_visual_scale = 1.2
collision_radius = 0.65
collision_height = 9.5
gatherable_anchor_height = 2.15

View file

@ -1,24 +1,30 @@
[gd_resource type="Resource" script_class="TerrainPropDefinition" load_steps=6 format=3] [gd_resource type="Resource" script_class="TerrainPropDefinition" load_steps=9 format=3]
[ext_resource type="Script" path="res://world/generation/terrain_prop_definition.gd" id="1_definition"] [ext_resource type="Script" path="res://world/generation/terrain_prop_definition.gd" id="1_definition"]
[ext_resource type="PackedScene" path="res://world/generation/props/assets/prop_tree_1.glb" id="2_scene"] [ext_resource type="PackedScene" path="res://world/generation/props/assets/prop_tree_1.glb" id="2_scene"]
[ext_resource type="Material" path="res://world/generation/props/materials/leaf_light.tres" id="3_leaf_light"] [ext_resource type="Material" path="res://world/generation/props/materials/leaf_light.tres" id="3_leaf_light"]
[ext_resource type="Material" path="res://world/generation/props/materials/leaf_mid.tres" id="4_leaf_mid"] [ext_resource type="Material" path="res://world/generation/props/materials/leaf_mid.tres" id="4_leaf_mid"]
[ext_resource type="Material" path="res://world/generation/props/materials/leaf_dark.tres" id="5_leaf_dark"] [ext_resource type="Material" path="res://world/generation/props/materials/leaf_dark.tres" id="5_leaf_dark"]
[ext_resource type="Material" path="res://world/generation/props/materials/wood_light.tres" id="6_wood_light"]
[ext_resource type="Material" path="res://world/generation/props/materials/wood_mid.tres" id="7_wood_mid"]
[ext_resource type="Material" path="res://world/generation/props/materials/wood_dark.tres" id="8_wood_dark"]
[resource] [resource]
script = ExtResource("1_definition") script = ExtResource("1_definition")
stable_id = &"prop_tree_1" stable_id = &"prop_tree_1"
label = "small tree" label = "small deciduous tree"
packed_scene = ExtResource("2_scene") packed_scene = ExtResource("2_scene")
procedural_group = &"grass_tree" procedural_group = &"grass_tree"
required_chunk_tags = PackedStringArray("grass") required_chunk_tags = PackedStringArray("grass")
selection_weight = 1.0
minimum_spawn_chunk_distance = 2 minimum_spawn_chunk_distance = 2
clearance_radius = 1.3 clearance_radius = 1.3
minimum_visual_scale = 0.75 minimum_visual_scale = 0.65
maximum_visual_scale = 1.15 maximum_visual_scale = 1.2
variant_material_slot_names = PackedStringArray("leaf_light", "leaf", "leaf_dark") variant_material_slot_names = PackedStringArray("leaf_light", "leaf", "leaf_dark")
material_variants = Array[Material]([ExtResource("3_leaf_light"), ExtResource("4_leaf_mid"), ExtResource("5_leaf_dark")]) material_variants = Array[Material]([ExtResource("3_leaf_light"), ExtResource("4_leaf_mid"), ExtResource("5_leaf_dark")])
secondary_variant_material_slot_names = PackedStringArray("wood", "wood_light", "wood_mid")
secondary_material_variants = Array[Material]([ExtResource("6_wood_light"), ExtResource("7_wood_mid"), ExtResource("8_wood_dark")])
collision_radius = 0.4 collision_radius = 0.4
collision_height = 3.2 collision_height = 3.2
gatherable_anchor_height = 2.15 gatherable_anchor_height = 2.15

View file

@ -1,24 +1,30 @@
[gd_resource type="Resource" script_class="TerrainPropDefinition" load_steps=6 format=3] [gd_resource type="Resource" script_class="TerrainPropDefinition" load_steps=9 format=3]
[ext_resource type="Script" path="res://world/generation/terrain_prop_definition.gd" id="1_definition"] [ext_resource type="Script" path="res://world/generation/terrain_prop_definition.gd" id="1_definition"]
[ext_resource type="PackedScene" path="res://world/generation/props/assets/prop_tree_2.glb" id="2_scene"] [ext_resource type="PackedScene" path="res://world/generation/props/assets/prop_tree_2.glb" id="2_scene"]
[ext_resource type="Material" path="res://world/generation/props/materials/leaf_light.tres" id="3_leaf_light"] [ext_resource type="Material" path="res://world/generation/props/materials/leaf_light.tres" id="3_leaf_light"]
[ext_resource type="Material" path="res://world/generation/props/materials/leaf_mid.tres" id="4_leaf_mid"] [ext_resource type="Material" path="res://world/generation/props/materials/leaf_mid.tres" id="4_leaf_mid"]
[ext_resource type="Material" path="res://world/generation/props/materials/leaf_dark.tres" id="5_leaf_dark"] [ext_resource type="Material" path="res://world/generation/props/materials/leaf_dark.tres" id="5_leaf_dark"]
[ext_resource type="Material" path="res://world/generation/props/materials/wood_light.tres" id="6_wood_light"]
[ext_resource type="Material" path="res://world/generation/props/materials/wood_mid.tres" id="7_wood_mid"]
[ext_resource type="Material" path="res://world/generation/props/materials/wood_dark.tres" id="8_wood_dark"]
[resource] [resource]
script = ExtResource("1_definition") script = ExtResource("1_definition")
stable_id = &"prop_tree_2" stable_id = &"prop_tree_2"
label = "medium tree" label = "medium deciduous tree"
packed_scene = ExtResource("2_scene") packed_scene = ExtResource("2_scene")
procedural_group = &"grass_tree" procedural_group = &"grass_tree"
required_chunk_tags = PackedStringArray("grass") required_chunk_tags = PackedStringArray("grass")
selection_weight = 1.0
minimum_spawn_chunk_distance = 2 minimum_spawn_chunk_distance = 2
clearance_radius = 1.5 clearance_radius = 1.5
minimum_visual_scale = 0.8 minimum_visual_scale = 0.7
maximum_visual_scale = 1.18 maximum_visual_scale = 1.2
variant_material_slot_names = PackedStringArray("leaf_light", "leaf", "leaf_dark") variant_material_slot_names = PackedStringArray("leaf_light", "leaf", "leaf_dark")
material_variants = Array[Material]([ExtResource("3_leaf_light"), ExtResource("4_leaf_mid"), ExtResource("5_leaf_dark")]) material_variants = Array[Material]([ExtResource("3_leaf_light"), ExtResource("4_leaf_mid"), ExtResource("5_leaf_dark")])
secondary_variant_material_slot_names = PackedStringArray("wood", "wood_light", "wood_mid")
secondary_material_variants = Array[Material]([ExtResource("6_wood_light"), ExtResource("7_wood_mid"), ExtResource("8_wood_dark")])
collision_radius = 0.5 collision_radius = 0.5
collision_height = 3.8 collision_height = 3.8
gatherable_anchor_height = 2.15 gatherable_anchor_height = 2.15

View file

@ -1,24 +1,30 @@
[gd_resource type="Resource" script_class="TerrainPropDefinition" load_steps=6 format=3] [gd_resource type="Resource" script_class="TerrainPropDefinition" load_steps=9 format=3]
[ext_resource type="Script" path="res://world/generation/terrain_prop_definition.gd" id="1_definition"] [ext_resource type="Script" path="res://world/generation/terrain_prop_definition.gd" id="1_definition"]
[ext_resource type="PackedScene" path="res://world/generation/props/assets/prop_tree_3.glb" id="2_scene"] [ext_resource type="PackedScene" path="res://world/generation/props/assets/prop_tree_3.glb" id="2_scene"]
[ext_resource type="Material" path="res://world/generation/props/materials/leaf_light.tres" id="3_leaf_light"] [ext_resource type="Material" path="res://world/generation/props/materials/leaf_light.tres" id="3_leaf_light"]
[ext_resource type="Material" path="res://world/generation/props/materials/leaf_mid.tres" id="4_leaf_mid"] [ext_resource type="Material" path="res://world/generation/props/materials/leaf_mid.tres" id="4_leaf_mid"]
[ext_resource type="Material" path="res://world/generation/props/materials/leaf_dark.tres" id="5_leaf_dark"] [ext_resource type="Material" path="res://world/generation/props/materials/leaf_dark.tres" id="5_leaf_dark"]
[ext_resource type="Material" path="res://world/generation/props/materials/wood_light.tres" id="6_wood_light"]
[ext_resource type="Material" path="res://world/generation/props/materials/wood_mid.tres" id="7_wood_mid"]
[ext_resource type="Material" path="res://world/generation/props/materials/wood_dark.tres" id="8_wood_dark"]
[resource] [resource]
script = ExtResource("1_definition") script = ExtResource("1_definition")
stable_id = &"prop_tree_3" stable_id = &"prop_tree_3"
label = "large tree" label = "decorative deciduous tree"
packed_scene = ExtResource("2_scene") packed_scene = ExtResource("2_scene")
procedural_group = &"grass_tree" procedural_group = &"grass_tree"
required_chunk_tags = PackedStringArray("grass") required_chunk_tags = PackedStringArray("grass")
selection_weight = 1.0
minimum_spawn_chunk_distance = 2 minimum_spawn_chunk_distance = 2
clearance_radius = 1.8 clearance_radius = 1.8
minimum_visual_scale = 0.85 minimum_visual_scale = 0.7
maximum_visual_scale = 1.15 maximum_visual_scale = 1.2
variant_material_slot_names = PackedStringArray("leaf_light", "leaf", "leaf_dark") variant_material_slot_names = PackedStringArray("leaf_light", "leaf", "leaf_dark")
material_variants = Array[Material]([ExtResource("3_leaf_light"), ExtResource("4_leaf_mid"), ExtResource("5_leaf_dark")]) material_variants = Array[Material]([ExtResource("3_leaf_light"), ExtResource("4_leaf_mid"), ExtResource("5_leaf_dark")])
secondary_variant_material_slot_names = PackedStringArray("wood", "wood_light", "wood_mid")
secondary_material_variants = Array[Material]([ExtResource("6_wood_light"), ExtResource("7_wood_mid"), ExtResource("8_wood_dark")])
collision_radius = 0.55 collision_radius = 0.55
collision_height = 4.2 collision_height = 4.2
gatherable_anchor_height = 2.15 gatherable_anchor_height = 2.15

View file

@ -0,0 +1,30 @@
[gd_resource type="Resource" script_class="TerrainPropDefinition" load_steps=9 format=3]
[ext_resource type="Script" path="res://world/generation/terrain_prop_definition.gd" id="1_definition"]
[ext_resource type="PackedScene" path="res://world/generation/props/assets/prop_tree_large.glb" id="2_scene"]
[ext_resource type="Material" path="res://world/generation/props/materials/leaf_light.tres" id="3_leaf_light"]
[ext_resource type="Material" path="res://world/generation/props/materials/leaf_mid.tres" id="4_leaf_mid"]
[ext_resource type="Material" path="res://world/generation/props/materials/leaf_dark.tres" id="5_leaf_dark"]
[ext_resource type="Material" path="res://world/generation/props/materials/wood_light.tres" id="6_wood_light"]
[ext_resource type="Material" path="res://world/generation/props/materials/wood_mid.tres" id="7_wood_mid"]
[ext_resource type="Material" path="res://world/generation/props/materials/wood_dark.tres" id="8_wood_dark"]
[resource]
script = ExtResource("1_definition")
stable_id = &"prop_tree_large"
label = "large deciduous tree"
packed_scene = ExtResource("2_scene")
procedural_group = &"grass_tree"
required_chunk_tags = PackedStringArray("grass")
selection_weight = 12.0
minimum_spawn_chunk_distance = 2
clearance_radius = 2.5
minimum_visual_scale = 0.75
maximum_visual_scale = 1.2
variant_material_slot_names = PackedStringArray("leaf_light", "leaf", "leaf_dark")
material_variants = Array[Material]([ExtResource("3_leaf_light"), ExtResource("4_leaf_mid"), ExtResource("5_leaf_dark")])
secondary_variant_material_slot_names = PackedStringArray("wood", "wood_light", "wood_mid")
secondary_material_variants = Array[Material]([ExtResource("6_wood_light"), ExtResource("7_wood_mid"), ExtResource("8_wood_dark")])
collision_radius = 0.85
collision_height = 7.5
gatherable_anchor_height = 2.15

View file

@ -0,0 +1,9 @@
[gd_resource type="StandardMaterial3D" format=3]
[resource]
resource_name = "wood_variant_dark"
albedo_color = Color(0.163, 0.139, 0.0254, 1)
metallic = 0.0
roughness = 1.0
shading_mode = 1
texture_filter = 0

View file

@ -0,0 +1,9 @@
[gd_resource type="StandardMaterial3D" format=3]
[resource]
resource_name = "wood_variant_light"
albedo_color = Color(0.644, 0.563, 0.356, 1)
metallic = 0.0
roughness = 1.0
shading_mode = 1
texture_filter = 0

View file

@ -0,0 +1,9 @@
[gd_resource type="StandardMaterial3D" format=3]
[resource]
resource_name = "wood_variant_mid"
albedo_color = Color(0.333, 0.271, 0.114, 1)
metallic = 0.0
roughness = 1.0
shading_mode = 1
texture_filter = 0

View file

@ -1,4 +1,4 @@
[gd_resource type="Resource" script_class="TerrainPropCatalog" load_steps=16 format=3] [gd_resource type="Resource" script_class="TerrainPropCatalog" load_steps=18 format=3]
[ext_resource type="Script" path="res://world/generation/terrain_prop_catalog.gd" id="1_catalog"] [ext_resource type="Script" path="res://world/generation/terrain_prop_catalog.gd" id="1_catalog"]
[ext_resource type="Resource" path="res://world/generation/props/definitions/prop_bridge.tres" id="2_bridge"] [ext_resource type="Resource" path="res://world/generation/props/definitions/prop_bridge.tres" id="2_bridge"]
@ -15,7 +15,9 @@
[ext_resource type="Resource" path="res://world/generation/props/definitions/prop_tree_2.tres" id="13_tree_2"] [ext_resource type="Resource" path="res://world/generation/props/definitions/prop_tree_2.tres" id="13_tree_2"]
[ext_resource type="Resource" path="res://world/generation/props/definitions/prop_tree_3.tres" id="14_tree_3"] [ext_resource type="Resource" path="res://world/generation/props/definitions/prop_tree_3.tres" id="14_tree_3"]
[ext_resource type="Script" path="res://world/generation/terrain_prop_definition.gd" id="15_definition_script"] [ext_resource type="Script" path="res://world/generation/terrain_prop_definition.gd" id="15_definition_script"]
[ext_resource type="Resource" path="res://world/generation/props/definitions/prop_tree_large.tres" id="16_tree_large"]
[ext_resource type="Resource" path="res://world/generation/props/definitions/prop_pine_large.tres" id="17_pine_large"]
[resource] [resource]
script = ExtResource("1_catalog") script = ExtResource("1_catalog")
definitions = Array[ExtResource("15_definition_script")]([ExtResource("2_bridge"), ExtResource("3_dock"), ExtResource("4_log"), ExtResource("5_post_1"), ExtResource("6_post_2"), ExtResource("7_mushroom"), ExtResource("8_palm"), ExtResource("9_pine"), ExtResource("10_timber_1"), ExtResource("11_timber_2"), ExtResource("12_tree_1"), ExtResource("13_tree_2"), ExtResource("14_tree_3")]) definitions = Array[ExtResource("15_definition_script")]([ExtResource("2_bridge"), ExtResource("3_dock"), ExtResource("4_log"), ExtResource("5_post_1"), ExtResource("6_post_2"), ExtResource("7_mushroom"), ExtResource("8_palm"), ExtResource("9_pine"), ExtResource("10_timber_1"), ExtResource("11_timber_2"), ExtResource("12_tree_1"), ExtResource("13_tree_2"), ExtResource("14_tree_3"), ExtResource("16_tree_large"), ExtResource("17_pine_large")])

View file

@ -34,6 +34,11 @@ const MAX_PACKED_SOLVER_VARIANTS := 62
@export var elevated_cliff_edge_chunk_id: StringName = &"chunk_0011" @export var elevated_cliff_edge_chunk_id: StringName = &"chunk_0011"
@export var elevated_cliff_ramp_chunk_id: StringName = &"chunk_0012" @export var elevated_cliff_ramp_chunk_id: StringName = &"chunk_0012"
@export_range(0.0, 1.0, 0.05) var elevated_cliff_ramp_chance := 0.75 @export_range(0.0, 1.0, 0.05) var elevated_cliff_ramp_chance := 0.75
## A smaller third tier reuses the existing corner geometry at the authored
## two-meter level interval. It is nested into the lower cliff assembly rather
## than occupying or replacing additional terrain-grid cells.
@export_range(0.0, 1.0, 0.05) var elevated_cliff_third_level_chance := 0.7
@export_range(0.1, 10.0, 0.1) var elevated_cliff_level_height := 2.0
@export_group("") @export_group("")
@export_range(1.0, 100.0, 1.0) var required_chunk_weight_multiplier := 64.0 @export_range(1.0, 100.0, 1.0) var required_chunk_weight_multiplier := 64.0
@export_range(1.0, 4.0, 0.05) var preferred_neighbor_weight_multiplier := 1.6 @export_range(1.0, 4.0, 0.05) var preferred_neighbor_weight_multiplier := 1.6
@ -68,6 +73,7 @@ var _generated_chunks: Node3D
var _backtrack_count := 0 var _backtrack_count := 0
var _elevated_feature_center := Vector2i(-1, -1) var _elevated_feature_center := Vector2i(-1, -1)
var _elevated_feature_reserved_grass_indices: Dictionary[int, bool] = {} var _elevated_feature_reserved_grass_indices: Dictionary[int, bool] = {}
var _stacked_elevated_placements: Array[Dictionary] = []
func _ready() -> void: func _ready() -> void:
@ -100,6 +106,9 @@ func generate() -> bool:
if not _apply_elevated_feature(): if not _apply_elevated_feature():
_assign_placements(previous_placements) _assign_placements(previous_placements)
return false return false
if not _configure_stacked_elevated_feature():
_assign_placements(previous_placements)
return false
var solution_root := _build_solution_root() var solution_root := _build_solution_root()
if solution_root == null: if solution_root == null:
_assign_placements(previous_placements) _assign_placements(previous_placements)
@ -138,6 +147,9 @@ func generate_from_placement_keys(keys: PackedStringArray) -> bool:
previous_placements.assign(_placements) previous_placements.assign(_placements)
_assign_placements(resolved) _assign_placements(resolved)
_backtrack_count = 0 _backtrack_count = 0
if not _configure_stacked_elevated_feature():
_assign_placements(previous_placements)
return false
var solution_root := _build_solution_root() var solution_root := _build_solution_root()
if solution_root == null: if solution_root == null:
_assign_placements(previous_placements) _assign_placements(previous_placements)
@ -1184,6 +1196,7 @@ func _candidate_can_occupy_cell(
func _prepare_elevated_feature_region() -> bool: func _prepare_elevated_feature_region() -> bool:
_elevated_feature_center = Vector2i(-1, -1) _elevated_feature_center = Vector2i(-1, -1)
_elevated_feature_reserved_grass_indices.clear() _elevated_feature_reserved_grass_indices.clear()
_stacked_elevated_placements.clear()
if not elevated_cliff_feature_enabled: if not elevated_cliff_feature_enabled:
_build_grid_solver_caches() _build_grid_solver_caches()
return true return true
@ -1439,6 +1452,65 @@ func _apply_elevated_feature() -> bool:
return true return true
func _configure_stacked_elevated_feature() -> bool:
_stacked_elevated_placements.clear()
if (
not elevated_cliff_feature_enabled
or elevated_cliff_third_level_chance <= 0.0
):
return true
var top_coordinate := Vector2i(-1, -1)
for index: int in _placements.size():
var placement := _placements[index]
if (
placement != null
and placement.definition.stable_id == elevated_cliff_top_chunk_id
):
top_coordinate = Vector2i(index % grid_size.x, index / grid_size.x)
break
if top_coordinate.x < 0:
return true
var stack_random := RandomNumberGenerator.new()
stack_random.seed = generation_seed ^ 0x7312DC11F
if stack_random.randf() >= elevated_cliff_third_level_chance:
return true
var corner_definition := catalog.definition_for_id(
elevated_cliff_corner_chunk_id
)
if corner_definition == null:
push_error(
"Stacked cliff feature references missing corner chunk %s."
% elevated_cliff_corner_chunk_id
)
return false
# Four nearly half-cell-offset corners form a closed 2x2 ring. A slight
# inward overlap keeps the authored curved feet fully seated on the lower
# plateau instead of exposing a hairline gap at its outermost vertices.
var specs: Array[Dictionary] = [
{"offset": Vector2(-0.45, -0.45), "turns": 2},
{"offset": Vector2(0.45, -0.45), "turns": 1},
{"offset": Vector2(-0.45, 0.45), "turns": 3},
{"offset": Vector2(0.45, 0.45), "turns": 0},
]
for spec: Dictionary in specs:
var variant := _authored_variant(
corner_definition,
int(spec["turns"]),
)
if variant == null:
push_error(
"Stacked cliff feature cannot resolve %s rotation %d."
% [elevated_cliff_corner_chunk_id, spec["turns"]]
)
return false
_stacked_elevated_placements.append({
"support_coordinate": top_coordinate,
"offset": spec["offset"],
"variant": variant,
})
return true
func _variant_respects_ocean_boundary( func _variant_respects_ocean_boundary(
variant: TerrainChunkVariant, variant: TerrainChunkVariant,
coordinate: Vector2i, coordinate: Vector2i,
@ -2040,9 +2112,74 @@ func _build_solution_root() -> Node3D:
_add_collision(chunk_root, variant.definition) _add_collision(chunk_root, variant.definition)
if show_chunk_labels: if show_chunk_labels:
_add_chunk_label(chunk_root, variant) _add_chunk_label(chunk_root, variant)
if not _add_stacked_elevated_chunks(solution_root):
solution_root.free()
return null
return solution_root return solution_root
func _add_stacked_elevated_chunks(solution_root: Node3D) -> bool:
for index: int in _stacked_elevated_placements.size():
var record := _stacked_elevated_placements[index]
var variant := record.get("variant") as TerrainChunkVariant
var support_coordinate: Vector2i = record.get(
"support_coordinate",
Vector2i(-1, -1),
)
var support_root := _find_chunk_root_for_coordinate(
solution_root,
support_coordinate,
)
if variant == null or support_root == null:
push_error("Stacked cliff feature lost its supporting terrain cell.")
return false
var stacked_root := variant.definition.packed_scene.instantiate() as Node3D
if stacked_root == null:
push_error(
"%s does not instantiate as a stacked Node3D."
% variant.stable_key()
)
return false
stacked_root.name = "Stacked_%s_%d" % [
variant.stable_key().replace("@", "r"),
index,
]
var offset: Vector2 = record.get("offset", Vector2.ZERO)
stacked_root.position = Vector3(
offset.x * catalog.chunk_size,
elevated_cliff_level_height,
offset.y * catalog.chunk_size,
)
stacked_root.rotation.y = variant.rotation_radians()
stacked_root.set_meta(&"terrain_stacked_elevation", true)
stacked_root.set_meta(
&"terrain_chunk_id",
variant.definition.stable_id,
)
stacked_root.set_meta(
&"terrain_chunk_coordinate",
support_coordinate,
)
support_root.add_child(stacked_root)
if build_collision:
_add_collision(stacked_root, variant.definition)
if show_chunk_labels:
_add_chunk_label(stacked_root, variant)
return true
func _find_chunk_root_for_coordinate(
solution_root: Node3D,
coordinate: Vector2i,
) -> Node3D:
for child: Node in solution_root.get_children():
if child.get_meta(&"terrain_chunk_coordinate", Vector2i(-1, -1)) == (
coordinate
):
return child as Node3D
return null
func _instantiate_chunk_root( func _instantiate_chunk_root(
definition: TerrainChunkDefinition, definition: TerrainChunkDefinition,
) -> Node3D: ) -> Node3D:
@ -2171,6 +2308,7 @@ func _build_summary() -> Dictionary:
"counts": counts, "counts": counts,
"variant_counts": variant_counts, "variant_counts": variant_counts,
"placements": placement_keys(), "placements": placement_keys(),
"stacked_elevated_placements": stacked_elevated_placement_keys(),
"layout_fingerprint": placement_fingerprint(), "layout_fingerprint": placement_fingerprint(),
} }
@ -2214,9 +2352,29 @@ func placement_fingerprint() -> String:
"chunk_size:%.6f" % (catalog.chunk_size if catalog != null else 0.0), "chunk_size:%.6f" % (catalog.chunk_size if catalog != null else 0.0),
]) ])
fingerprint_input.append_array(placement_keys()) fingerprint_input.append_array(placement_keys())
fingerprint_input.append_array(stacked_elevated_placement_keys())
return "\n".join(fingerprint_input).sha256_text() return "\n".join(fingerprint_input).sha256_text()
func stacked_elevated_placement_keys() -> PackedStringArray:
var result := PackedStringArray()
for record: Dictionary in _stacked_elevated_placements:
var variant := record.get("variant") as TerrainChunkVariant
var offset: Vector2 = record.get("offset", Vector2.ZERO)
if variant == null:
continue
result.append(
"%s@%.2f,%.2f,+%.2fm"
% [
variant.stable_key(),
offset.x,
offset.y,
elevated_cliff_level_height,
]
)
return result
func placement_records() -> Array[Dictionary]: func placement_records() -> Array[Dictionary]:
var result: Array[Dictionary] = [] var result: Array[Dictionary] = []
for index: int in _placements.size(): for index: int in _placements.size():
@ -2276,4 +2434,18 @@ func get_primary_terrain_meshes() -> Array[MeshInstance3D]:
) )
if primary_mesh != null and primary_mesh.mesh != null: if primary_mesh != null and primary_mesh.mesh != null:
result.append(primary_mesh) result.append(primary_mesh)
for child: Node in chunk_root.get_children():
if not bool(child.get_meta(&"terrain_stacked_elevation", false)):
continue
var definition := catalog.definition_for_id(
StringName(child.get_meta(&"terrain_chunk_id", &""))
)
if definition == null:
continue
var stacked_mesh := TerrainChunkAnalyzer.find_primary_mesh(
child,
definition.primary_mesh_name,
)
if stacked_mesh != null and stacked_mesh.mesh != null:
result.append(stacked_mesh)
return result return result

View file

@ -87,6 +87,23 @@ func validation_errors() -> PackedStringArray:
"%s has an inverted visual scale range." "%s has an inverted visual scale range."
% definition.stable_id % definition.stable_id
) )
if definition.minimum_cluster_size > definition.maximum_cluster_size:
errors.append(
"%s has an inverted cluster size range."
% definition.stable_id
)
if (
definition.maximum_cluster_size > 1
and (
definition.minimum_cluster_radius <= 0.0
or definition.minimum_cluster_radius
> definition.maximum_cluster_radius
)
):
errors.append(
"%s has an invalid loose-cluster radius."
% definition.stable_id
)
if ( if (
not definition.material_variants.is_empty() not definition.material_variants.is_empty()
and definition.variant_material_slot_names.is_empty() and definition.variant_material_slot_names.is_empty()
@ -101,4 +118,26 @@ func validation_errors() -> PackedStringArray:
"%s contains an empty material variant." "%s contains an empty material variant."
% definition.stable_id % definition.stable_id
) )
if (
not definition.secondary_material_variants.is_empty()
and definition.secondary_variant_material_slot_names.is_empty()
):
errors.append(
"%s has secondary variants but no target material slots."
% definition.stable_id
)
for material: Material in definition.secondary_material_variants:
if material == null:
errors.append(
"%s contains an empty secondary material variant."
% definition.stable_id
)
if (
definition.prefer_ocean_facing
and definition.local_overhang_direction.is_zero_approx()
):
errors.append(
"%s prefers the ocean but has no local overhang direction."
% definition.stable_id
)
return errors return errors

View file

@ -21,12 +21,28 @@ extends Resource
## Procedural instances use a deterministic uniform scale in this range. ## Procedural instances use a deterministic uniform scale in this range.
@export_range(0.1, 4.0, 0.05) var minimum_visual_scale := 1.0 @export_range(0.1, 4.0, 0.05) var minimum_visual_scale := 1.0
@export_range(0.1, 4.0, 0.05) var maximum_visual_scale := 1.0 @export_range(0.1, 4.0, 0.05) var maximum_visual_scale := 1.0
## Values above one create a loose group of separately anchored instances.
## Cluster members are distributed across the configured scale range so a
## three-member cluster naturally contains a small, medium, and large prop.
@export_range(1, 4, 1) var minimum_cluster_size := 1
@export_range(1, 4, 1) var maximum_cluster_size := 1
@export_range(0.0, 10.0, 0.05) var minimum_cluster_radius := 0.0
@export_range(0.0, 10.0, 0.05) var maximum_cluster_radius := 0.0
## Presentation-only offset. The prop root remains exactly terrain-aligned. ## Presentation-only offset. The prop root remains exactly terrain-aligned.
@export var visual_offset := Vector3.ZERO @export var visual_offset := Vector3.ZERO
## A single material is chosen per prop and applied only to matching imported ## A single material is chosen per prop and applied only to matching imported
## material slots. This keeps trunks untouched while varying foliage. ## material slots. This keeps trunks untouched while varying foliage.
@export var variant_material_slot_names := PackedStringArray() @export var variant_material_slot_names := PackedStringArray()
@export var material_variants: Array[Material] = [] @export var material_variants: Array[Material] = []
## An independent second material channel, used when both foliage and wood
## should vary without coupling their color choices.
@export var secondary_variant_material_slot_names := PackedStringArray()
@export var secondary_material_variants: Array[Material] = []
## Some asymmetric props can loosely face an authored ocean edge. The local
## direction describes the model's natural overhang in Godot X/Z space.
@export var prefer_ocean_facing := false
@export var local_overhang_direction := Vector2(0.0, -1.0)
@export_range(0.0, 180.0, 1.0) var ocean_facing_spread_degrees := 45.0
@export_range(0.0, 5.0, 0.05) var collision_radius := 0.0 @export_range(0.0, 5.0, 0.05) var collision_radius := 0.0
@export_range(0.0, 20.0, 0.05) var collision_height := 0.0 @export_range(0.0, 20.0, 0.05) var collision_height := 0.0
@export var collision_box_size := Vector3.ZERO @export var collision_box_size := Vector3.ZERO