From b2752b10743653d1ee38c55faef5febe88a1b855 Mon Sep 17 00:00:00 2001 From: Voyager Date: Fri, 21 Aug 2026 16:38:30 -0400 Subject: [PATCH] Expand generated terrain and forest variation --- docs/DEVELOPMENT.md | 129 ++++------- docs/README-PLAYTEST.txt | 50 ++-- tests/generated_world_runtime_validation.gd | 180 ++++++++++++++- .../biomes/definitions/biome_coast.tres | 6 +- .../biomes/definitions/biome_forest.tres | 2 +- .../biomes/definitions/biome_pine_forest.tres | 2 +- world/generation/generated_world_region.gd | 215 ++++++++++++++++-- .../props/assets/prop_pine_large.glb | Bin 0 -> 9624 bytes .../props/assets/prop_pine_large.glb.import | 45 ++++ .../props/assets/prop_tree_large.glb | Bin 0 -> 4304 bytes .../props/assets/prop_tree_large.glb.import | 45 ++++ .../props/definitions/prop_palm.tres | 11 +- .../props/definitions/prop_pine.tres | 6 +- .../props/definitions/prop_pine_large.tres | 20 ++ .../props/definitions/prop_tree_1.tres | 14 +- .../props/definitions/prop_tree_2.tres | 14 +- .../props/definitions/prop_tree_3.tres | 14 +- .../props/definitions/prop_tree_large.tres | 30 +++ .../generation/props/materials/wood_dark.tres | 9 + .../props/materials/wood_light.tres | 9 + .../generation/props/materials/wood_mid.tres | 9 + .../props/terrain_prop_catalog.tres | 6 +- world/generation/terrain_chunk_generator.gd | 172 ++++++++++++++ world/generation/terrain_prop_catalog.gd | 39 ++++ world/generation/terrain_prop_definition.gd | 16 ++ 25 files changed, 898 insertions(+), 145 deletions(-) create mode 100644 world/generation/props/assets/prop_pine_large.glb create mode 100644 world/generation/props/assets/prop_pine_large.glb.import create mode 100644 world/generation/props/assets/prop_tree_large.glb create mode 100644 world/generation/props/assets/prop_tree_large.glb.import create mode 100644 world/generation/props/definitions/prop_pine_large.tres create mode 100644 world/generation/props/definitions/prop_tree_large.tres create mode 100644 world/generation/props/materials/wood_dark.tres create mode 100644 world/generation/props/materials/wood_light.tres create mode 100644 world/generation/props/materials/wood_mid.tres diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index c82c549..6e19e1c 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -89,9 +89,8 @@ presented through a uniformly scaled SubViewport. Shared UI components and palette resources prevent page-specific geometry and style drift. World presentation systems (time/weather visuals, procedural sky and water, -shoreline ribbons, player blob shadows) do not own gameplay collision or -network authority. Generated shoreline meshes are deterministic presentation -resources baked from explicitly configured static terrain. +shoreline treatment, player blob shadows) do not own gameplay collision or +network authority. ### Engineering consequences @@ -129,9 +128,9 @@ remain authoritative in `.tres` resources and typed scripts. wallet exactly once. - Reserved assets are rejected atomically; mixed valid and reserved batches must not partially succeed. -- Item effects, shop prices, cooler capacity, fishing upgrades, jobs, and - experience are separate balance axes. Avoid changing several in an - unrelated presentation pass. +- Item effects, shop prices, inventory and storage capacity, fishing upgrades, + jobs, and experience are separate balance axes. Avoid changing several in + an unrelated presentation pass. Record affected stable IDs and resource paths, old and new values, intended player-facing outcome, interactions with quality/availability/economy, @@ -143,70 +142,57 @@ authored number changed. ### World composition -The active test world uses the canonical starter island through -`world/regions/starter_island_region.tscn`. +`world/test_world.tscn` is the shared gameplay stage. It owns the environment, +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, -and below-world failsafe. `StarterIslandRegion` owns the placed island content: +- the generated world, which is the default for new games and is rebuilt from + 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 -StarterIslandRegion +WorldRegion ├── Terrain -│ ├── Visual -│ └── Collision ├── WaterBodies -│ ├── Pond -│ │ ├── VisualWater -│ │ ├── FishingRegion -│ │ └── RecoveryRegion -│ └── Ocean -│ ├── VisualWater -│ ├── FishingRegions -│ └── RecoveryRegions ├── PlayerSpawn ├── SafeRespawns +├── DiggableAreas +├── GatherableAnchors └── Interactables ├── FishingShopWorld - └── PelicanCoolerPerch + └── PlayerStorageBox ``` -Move a meaningful feature root rather than one of its implementation children. -Child transforms are local offsets owned by that feature. +The generated region owns its chunk, biome, and prop catalogs and derives +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 -`FRESH_WATER`; the starter coast is `SALT_WATER`. Fish species use the same -central type through an allowed-water-type bitmask. Do not infer habitat from -node names, pool filenames, coordinates, or water height. +Every fishable water body authors its water type explicitly. Fish species use +the same central type through an allowed-water-type bitmask. Do not infer +habitat from node names, pool filenames, coordinates, or water height. -Select `WaterBodies/Pond` to move or resize the pond. Its transform is the -authoritative surface position; surface size and fishing/recovery coverage are -owned together. Select `WaterBodies/Ocean` to move the surrounding water as one -feature. Its fishing coverage is derived from the visible water mesh and the -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. +In the generated layout, seed and layout ID are persistent data. Chunk IDs, +edge rules, biome definitions, and prop definitions are authored resources; +changing them can change the world produced by an existing seed and therefore +requires deliberate compatibility review. -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. -- `SafeRespawns` contains authoritative recovery destinations. Recovery - 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. +The active `Environment` and `Sun` remain Inspector-authored on the shared +stage and are not recreated by either region. ### 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 materials local to the scene. -### Shoreline tide ribbons +### Shoreline presentation -Maps using generated tide ribbons own a `ShorelineRibbonBaker` and one -`ShorelineRibbonConfig` per water body. Each configuration selects only its -static terrain source, water height, generation bounds, water-facing reference, -smoothing overrides, and generated `.tres` path. Props, players, bobbers, and -gameplay areas are not scanned unless selected explicitly. Only `SALT_WATER` -bodies generate ribbons. - -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. +The visible shoreline treatment is currently produced by the water materials. +The older smooth-ribbon baker and generated starter-island ribbon resource are +retained as development history/tooling, but the ribbon mesh is disabled and +is not part of the current rendered shoreline. Do not treat a ribbon rebuild +as a normal terrain-authoring requirement or re-enable the mesh as incidental +cleanup. ### 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: ```bash -scripts/import_animalese_voice.sh /path/to/source_clips sample_set_directory +scripts/import_animalese_voice.sh /path/to/source_clips ``` The source directory may contain lowercase letter, number, and underscore WAV 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//`. Add a new runtime set to `player/animalese_voice_profiles.gd` after import, then run the quick validation suite. Keep the untouched submission and its original hashes in the private intake record; repository files are normalized derivatives. diff --git a/docs/README-PLAYTEST.txt b/docs/README-PLAYTEST.txt index 32b07d6..a0e9b20 100644 --- a/docs/README-PLAYTEST.txt +++ b/docs/README-PLAYTEST.txt @@ -1,6 +1,5 @@ NETfishing -v0.14.0-alpha -Alpha 0.14.0 +Alpha playtest Thank you for trying this early private playtest. @@ -30,13 +29,16 @@ Sprint Shift Sneak Ctrl or click right stick Slow walk Alt 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 Zoom camera Shift + mouse wheel -Cast / withdraw / reel Left mouse -Cooler, Bag, and Logbook Tab +Primary tool / fishing Left mouse or right trigger +Inventory and player pages Tab 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 @@ -48,12 +50,14 @@ FISHING - At barriers, use distinct left-click presses to damage the barrier. - Do not let the red chase meter catch the green meter. - Accessibility auto-click can be enabled in Settings. -- Approach the bright Fishing Shop booth and press E to open it. -- The Fishing Shop buys one Cooler fish at a time for full base value. -- Buy Coffee, Energy Drinks, Snacks, and Fish Finders as Bag supplies. -- Drag supplies from Bag to the hotbar and left-click in READY to use one. +- Approach the Fishing Shop and interact to open it. +- Move sellable inventory entries into the shop's sell tray, review the total, + and confirm the sale. +- 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. -- 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. - Rod Power upgrades increase damage per valid barrier action. - Shop upgrades persist in the local progression save. @@ -71,28 +75,24 @@ FEATURES TO TRY - Barrier-and-chase catching - Accessibility auto-click settings - Catch showcase and fish size variation -- Cooler, Bag, Logbook, favorites, and sorting -- Basic Fishing Rod equipment and the 1–9 hotbar -- Pelican selling and wallet updates -- Physical Fishing Shop sales and persistent fishing upgrades +- General inventory, separate tackle, hotbar storage, player storage, and the + Logbook +- Fishing, net gathering, and beach digging +- 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 - Game Menu and persistent camera settings - Water-entry recovery from several shores SAVES AND SETTINGS -Progression and settings are stored in Godot's per-user application-data -directory, outside this extracted game folder. Delete Save removes progression -but preserves settings. - -Windows: - %APPDATA%\Godot\app_userdata\NETFISHING\ - -Linux: - ~/.local/share/godot/app_userdata/NETFISHING/ +Progression and settings are stored outside the extracted game folder. The +Data & Identity page shows the active player-data location. Delete Save removes +progression but preserves device settings. 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 diff --git a/tests/generated_world_runtime_validation.gd b/tests/generated_world_runtime_validation.gd index 03a5211..e00178a 100644 --- a/tests/generated_world_runtime_validation.gd +++ b/tests/generated_world_runtime_validation.gd @@ -23,11 +23,13 @@ const EXPECTED_PROP_IDS: Array[StringName] = [ &"prop_mushroom", &"prop_palm", &"prop_pine", + &"prop_pine_large", &"prop_timber_1", &"prop_timber_2", &"prop_tree_1", &"prop_tree_2", &"prop_tree_3", + &"prop_tree_large", ] const EXPECTED_BIOME_IDS: Array[StringName] = [ &"biome_plains", @@ -40,9 +42,11 @@ const PROCEDURAL_PROP_IDS: Array[StringName] = [ &"prop_mushroom", &"prop_palm", &"prop_pine", + &"prop_pine_large", &"prop_tree_1", &"prop_tree_2", &"prop_tree_3", + &"prop_tree_large", ] @@ -53,6 +57,11 @@ func _initialize() -> void: func _run() -> void: var region := RegionScene.instantiate() as GeneratedWorldRegion 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) await process_frame await physics_frame @@ -124,7 +133,10 @@ func _validate_generated_region( ) assert( generator.get_primary_terrain_meshes().size() - == expected_chunk_count + == ( + expected_chunk_count + + generator.stacked_elevated_placement_keys().size() + ) ) _validate_elevated_cliff_feature(generator) @@ -179,6 +191,16 @@ func _validate_generated_region( assert(decorations != null and decorations.get_child_count() > 0) assert(anchors != null) 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(): var prop_id := StringName(child.get_meta(&"terrain_prop_id", &"")) assert(PROCEDURAL_PROP_IDS.has(prop_id)) @@ -211,13 +233,80 @@ func _validate_generated_region( child as Node3D, 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( _has_material_variant_override( child.get_node_or_null("Visual"), 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, &"sand_tree") > 0) assert( @@ -297,6 +386,35 @@ func _validate_elevated_cliff_feature( assert(base_mesh.has_node("TerrainBaseLayerCollision")) assert(overlay_mesh.has_node("TerrainCollision")) 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( @@ -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: var catalog := region.get_prop_catalog() assert(catalog != null) @@ -343,16 +486,49 @@ func _validate_prop_catalog(region: GeneratedWorldRegion) -> void: &"prop_tree_1", &"prop_tree_2", &"prop_tree_3", + &"prop_tree_large", ]: var tree := catalog.definition_for_id(tree_id) assert(tree != null) assert(tree.minimum_visual_scale < tree.maximum_visual_scale) assert(tree.material_variants.size() >= 3) 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") assert(pine != null) assert(pine.minimum_visual_scale < pine.maximum_visual_scale) 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( diff --git a/world/generation/biomes/definitions/biome_coast.tres b/world/generation/biomes/definitions/biome_coast.tres index 635181b..76b3c75 100644 --- a/world/generation/biomes/definitions/biome_coast.tres +++ b/world/generation/biomes/definitions/biome_coast.tres @@ -6,9 +6,9 @@ [sub_resource type="Resource" id="Rule_sand_tree"] script = ExtResource("2_rule") procedural_group = &"sand_tree" -placement_chance = 4200 -adjacency_bonus = 1200 -maximum_density = 5000 +placement_chance = 2200 +adjacency_bonus = 900 +maximum_density = 2800 minimum_placements = 1 allowed_prop_ids = PackedStringArray("prop_palm") diff --git a/world/generation/biomes/definitions/biome_forest.tres b/world/generation/biomes/definitions/biome_forest.tres index 21cd724..c0764e2 100644 --- a/world/generation/biomes/definitions/biome_forest.tres +++ b/world/generation/biomes/definitions/biome_forest.tres @@ -11,7 +11,7 @@ adjacency_bonus = 1200 maximum_density = 8500 minimum_placements = 2 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"] script = ExtResource("2_rule") diff --git a/world/generation/biomes/definitions/biome_pine_forest.tres b/world/generation/biomes/definitions/biome_pine_forest.tres index eec39a4..6582a40 100644 --- a/world/generation/biomes/definitions/biome_pine_forest.tres +++ b/world/generation/biomes/definitions/biome_pine_forest.tres @@ -11,7 +11,7 @@ adjacency_bonus = 1200 maximum_density = 8500 minimum_placements = 2 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"] script = ExtResource("2_rule") diff --git a/world/generation/generated_world_region.gd b/world/generation/generated_world_region.gd index e8ac760..94a04dd 100644 --- a/world/generation/generated_world_region.gd +++ b/world/generation/generated_world_region.gd @@ -31,6 +31,7 @@ const OCEAN_POOL: FishPool = preload( const WATER_HEIGHT := -0.25 const PROP_EDGE_MARGIN := 2.2 const PROP_PLACEMENT_ATTEMPTS := 12 +const PROP_CLUSTER_PLACEMENT_ATTEMPTS := 10 const PROP_MINIMUM_GROUND_CLEARANCE := 0.05 const PROP_CHANCE_SCALE := 10000 const PROP_SELECTION_WEIGHT_SCALE := 1000 @@ -268,6 +269,7 @@ func _on_generation_completed(summary: Dictionary) -> void: record.get("position", Vector3.ZERO), coordinate, biome.stable_id, + int(record.get("ocean_facing_edges", 0)), random, terrain_triangles, ): @@ -445,6 +447,7 @@ func _maybe_add_prop( chunk_center: Vector3, chunk_coordinate: Vector2i, biome_id: StringName, + ocean_facing_edges: int, random: RandomNumberGenerator, terrain_triangles: Array[PackedVector3Array], ) -> bool: @@ -460,6 +463,7 @@ func _maybe_add_prop( chunk_center, chunk_coordinate, biome_id, + ocean_facing_edges, random, terrain_triangles, ) @@ -470,19 +474,26 @@ func _add_prop( chunk_center: Vector3, chunk_coordinate: Vector2i, biome_id: StringName, + ocean_facing_edges: int, random: RandomNumberGenerator, terrain_triangles: Array[PackedVector3Array], ) -> bool: if definition == null or definition.packed_scene == null: return false - var visual_scale := random.randf_range( - definition.minimum_visual_scale, - definition.maximum_visual_scale, + var cluster_size := random.randi_range( + definition.minimum_cluster_size, + 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 found_surface := false - for attempt: int in PROP_PLACEMENT_ATTEMPTS: + for _attempt: int in PROP_PLACEMENT_ATTEMPTS: var offset := Vector3( random.randf_range(-PROP_EDGE_MARGIN, PROP_EDGE_MARGIN), 0.0, @@ -507,6 +518,118 @@ func _add_prop( break if not found_surface: 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 visual_root := packed_instance as Node3D if visual_root == null: @@ -522,8 +645,10 @@ func _add_prop( prop.set_meta(&"terrain_chunk_coordinate", chunk_coordinate) prop.set_meta(&"terrain_biome_id", biome_id) 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.rotation.y = random.randf_range(-PI, PI) + prop.rotation.y = yaw _decorations.add_child(prop) visual_root.name = "Visual" prop.add_child(visual_root) @@ -533,7 +658,9 @@ func _add_prop( _apply_prop_material_variant(visual_root, definition, random) _add_prop_collision(prop, definition, visual_scale) _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) if definition.gatherable_anchor_height > 0.0: var anchor := Marker3D.new() @@ -547,6 +674,46 @@ func _add_prop( 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: if prop_catalog == null: push_error("Generated world terrain prop catalog is unavailable.") @@ -776,6 +943,7 @@ func _ensure_minimum_props( record.get("position", Vector3.ZERO), coordinate, biome.stable_id, + int(record.get("ocean_facing_edges", 0)), random, terrain_triangles, ): @@ -819,17 +987,32 @@ func _apply_prop_material_variant( definition: TerrainPropDefinition, random: RandomNumberGenerator, ) -> void: - if ( - 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( + _apply_random_material_variant( root_node, 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, ) diff --git a/world/generation/props/assets/prop_pine_large.glb b/world/generation/props/assets/prop_pine_large.glb new file mode 100644 index 0000000000000000000000000000000000000000..99c9cd46061fb8f2b8f7dfc2d1db77f582d226a1 GIT binary patch literal 9624 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 M19X}U%nYpl4TObVTL1t6 literal 0 HcmV?d00001 diff --git a/world/generation/props/assets/prop_pine_large.glb.import b/world/generation/props/assets/prop_pine_large.glb.import new file mode 100644 index 0000000..08db8fb --- /dev/null +++ b/world/generation/props/assets/prop_pine_large.glb.import @@ -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 diff --git a/world/generation/props/assets/prop_tree_large.glb b/world/generation/props/assets/prop_tree_large.glb new file mode 100644 index 0000000000000000000000000000000000000000..7cf2c4fd795ba2ba0fe605e4012e7ec9063ef2e5 GIT binary patch literal 4304 zcmd5;c~}(Z86Oc{P%cFdm17;N5?R){4`jb@5wYTh!829F1x8r|yJUCqD5098KGc}R zsA)}}#u}|hwI1=RQJ5J#aw3{08gf+=pQIQ~LSodUNlf2aNf_i2<3D{0&-~^$?>F!J zz1R27&M%)jCWIh}(=i0GE{q_?P062BO}hMkB|v6Ylk*ji;&TPOJ~ERWU*_|AyngF^ zP_d3GS3ISP&zd(P-@1^uGj>)cZRA45=XZNO@G@hk$Qm2zF98E&Ce_CLWag}D(&MTC z8961mW+Oe`QpH%H3Tq16pQ_0U#a|XwZF|4c=dCOX_!On6+~u3Ez;-@wz!eDYH;b}! ztSHbNCsHCqF)~eawhYS7^E}5e6eUrt$O|HEYZK8DBQp#uu#5zvo2mjWi!3cuEDdh> zOI+pPq0~`BFl@M#0EpDa0L{f+f{xu>QZl2ak(zs#ffLA#nPcKMYYZ@Jeu##Ium z1_VsWl*GbC@ifCjEIdR)@v-Oj0?tNyZJgZ$h% z6QMKg6vY_6_}mrlfO}!v3$8%G=Ps@a7?0KDnM{q1bbCtOC58$MiD@eu=olHfok7k<{$!BvXizX46wLeKz%iDoEGA~fPErYhxGDFFXB=bBaO3)`t1|=Ffb;BW@1qX!V1s=`< z#Y)irpcXtdoJh+&EekZ*W?48$tX+~sqp>7fmL#6xIj|83dPq*s&0*oFLywvwfh6B4 zlCIR5yHRI4?G9z^yn9d>%p(hVY}VY z1c9}&c7|sKSzsuZVQC3k+(;iJnPz2?f@~Y-u)PAo*J**Fp%63&W5$-j*ab?GSdOQ7 z3iL%mvSrA2##k0)D6K?sf?=Vn1VT^VDS)ojgQ4EO5smoU)jQ`Oy1w)7?fHi({j>g0 z1%PIY@kaFWy-t^0udjb(ruZ{c$exahSE;sab^=j(^{A77uomu{2(JUORcGp@PF4vj)c(+$AY*?&W!h6-5 zdCd5w+JX3$^?i?C?qJg_H{1VT?hEyRzh(IU_1=T%NyHF6;EDzpLx>2#2%;BZA;O7R zz*wRW(VYk-q5z|aNFt2rM)U^kP4p$MM#t-^#fi?ejnC_5{=?w2xM+WGr}dc)x|t8@ zeM<8_+|610#kczUW&7~wfBpbBPWw*()274PA-Pd|clZr`ZSzaG?DN&y?~=dOuZMiE zjoo6^cXL}i@MG&joaev(PO}{Pi*DxUuD58qxEy6PSsK1fOLo@A-Ovu@p4B}YccG!P zQ*>*|st&wK=%qW7QuVW&KGd^MQO*rdf9Yt9eXIljV(%FJbbqTF_SsXqnKu@uX~VXY z&b`mA)XjY44?Uen<_vVM8vUN$LjAse*qaefJjT+{l6#Ln^8H-r?38Bxvz?#dxtUwg zTPc=?+Tq!brav@kC%0G{78RUTpUj%<{ABVwx)ilGtL5W8=o@>#hSBeBMMJI+)n`t7 zUr%^>oT_(QU4JcQfxc+>Xy@s|rRcS|X8ja9Szk8q^{l|E5A+*U*%{wEo>y)HzxJVZ* zTJ7Au>Ztd6)#tQst$Vz2DIQp`1i!lC3CG3}g=+oQfOcf3P(S^Lwd(oiOGv$tj*q@D z6hE2~jhiEQGV*i~efIVHci9BTtKXTS6h)watcGzj+uf3Y~&0KO2HlpUuHe zeHB{p%o6@Bf8}vZYy94otvnLmCkk6^mQq*P?xTF5WQlnB(M)F?iY0GmcSX z{dmLhX0)387HvDf9OXV$ik-i0&6@k_AWXPEMi*BO!KXKWg06+k!LKY#z;#={Mq}5% zi1IFlp_S3ac=;QlIPF?Ao_pW>j&o5qtiRNN`kyU;nHWbT!MsT# z`U49g5&#q6DG9I#U=Jb@R+5642s0=YSTbNTOsGUy9RN50o{|AAfEHpPtRx3!0-P82I7t* literal 0 HcmV?d00001 diff --git a/world/generation/props/assets/prop_tree_large.glb.import b/world/generation/props/assets/prop_tree_large.glb.import new file mode 100644 index 0000000..2b5f6fd --- /dev/null +++ b/world/generation/props/assets/prop_tree_large.glb.import @@ -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 diff --git a/world/generation/props/definitions/prop_palm.tres b/world/generation/props/definitions/prop_palm.tres index 80f2193..4d1d344 100644 --- a/world/generation/props/definitions/prop_palm.tres +++ b/world/generation/props/definitions/prop_palm.tres @@ -11,6 +11,15 @@ packed_scene = ExtResource("2_scene") procedural_group = &"sand_tree" required_chunk_tags = PackedStringArray("sand") 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_height = 6.0 diff --git a/world/generation/props/definitions/prop_pine.tres b/world/generation/props/definitions/prop_pine.tres index 0a569cf..aa5a638 100644 --- a/world/generation/props/definitions/prop_pine.tres +++ b/world/generation/props/definitions/prop_pine.tres @@ -6,15 +6,15 @@ [resource] script = ExtResource("1_definition") stable_id = &"prop_pine" -label = "pine" +label = "small pine" packed_scene = ExtResource("2_scene") procedural_group = &"grass_tree" required_chunk_tags = PackedStringArray("grass") selection_weight = 0.7 minimum_spawn_chunk_distance = 2 clearance_radius = 1.55 -minimum_visual_scale = 0.75 -maximum_visual_scale = 1.22 +minimum_visual_scale = 0.65 +maximum_visual_scale = 1.2 collision_radius = 0.5 collision_height = 4.0 gatherable_anchor_height = 2.15 diff --git a/world/generation/props/definitions/prop_pine_large.tres b/world/generation/props/definitions/prop_pine_large.tres new file mode 100644 index 0000000..05e4d2d --- /dev/null +++ b/world/generation/props/definitions/prop_pine_large.tres @@ -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 diff --git a/world/generation/props/definitions/prop_tree_1.tres b/world/generation/props/definitions/prop_tree_1.tres index 41ab406..bb5d2af 100644 --- a/world/generation/props/definitions/prop_tree_1.tres +++ b/world/generation/props/definitions/prop_tree_1.tres @@ -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="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_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_1" -label = "small tree" +label = "small deciduous tree" packed_scene = ExtResource("2_scene") procedural_group = &"grass_tree" required_chunk_tags = PackedStringArray("grass") +selection_weight = 1.0 minimum_spawn_chunk_distance = 2 clearance_radius = 1.3 -minimum_visual_scale = 0.75 -maximum_visual_scale = 1.15 +minimum_visual_scale = 0.65 +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.4 collision_height = 3.2 gatherable_anchor_height = 2.15 diff --git a/world/generation/props/definitions/prop_tree_2.tres b/world/generation/props/definitions/prop_tree_2.tres index c4d09fa..a6931be 100644 --- a/world/generation/props/definitions/prop_tree_2.tres +++ b/world/generation/props/definitions/prop_tree_2.tres @@ -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="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_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_2" -label = "medium tree" +label = "medium deciduous tree" packed_scene = ExtResource("2_scene") procedural_group = &"grass_tree" required_chunk_tags = PackedStringArray("grass") +selection_weight = 1.0 minimum_spawn_chunk_distance = 2 clearance_radius = 1.5 -minimum_visual_scale = 0.8 -maximum_visual_scale = 1.18 +minimum_visual_scale = 0.7 +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.5 collision_height = 3.8 gatherable_anchor_height = 2.15 diff --git a/world/generation/props/definitions/prop_tree_3.tres b/world/generation/props/definitions/prop_tree_3.tres index 642a8de..382117e 100644 --- a/world/generation/props/definitions/prop_tree_3.tres +++ b/world/generation/props/definitions/prop_tree_3.tres @@ -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="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_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_3" -label = "large tree" +label = "decorative deciduous tree" packed_scene = ExtResource("2_scene") procedural_group = &"grass_tree" required_chunk_tags = PackedStringArray("grass") +selection_weight = 1.0 minimum_spawn_chunk_distance = 2 clearance_radius = 1.8 -minimum_visual_scale = 0.85 -maximum_visual_scale = 1.15 +minimum_visual_scale = 0.7 +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.55 collision_height = 4.2 gatherable_anchor_height = 2.15 diff --git a/world/generation/props/definitions/prop_tree_large.tres b/world/generation/props/definitions/prop_tree_large.tres new file mode 100644 index 0000000..b902944 --- /dev/null +++ b/world/generation/props/definitions/prop_tree_large.tres @@ -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 diff --git a/world/generation/props/materials/wood_dark.tres b/world/generation/props/materials/wood_dark.tres new file mode 100644 index 0000000..dc810b1 --- /dev/null +++ b/world/generation/props/materials/wood_dark.tres @@ -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 diff --git a/world/generation/props/materials/wood_light.tres b/world/generation/props/materials/wood_light.tres new file mode 100644 index 0000000..ec4b63f --- /dev/null +++ b/world/generation/props/materials/wood_light.tres @@ -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 diff --git a/world/generation/props/materials/wood_mid.tres b/world/generation/props/materials/wood_mid.tres new file mode 100644 index 0000000..894f446 --- /dev/null +++ b/world/generation/props/materials/wood_mid.tres @@ -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 diff --git a/world/generation/props/terrain_prop_catalog.tres b/world/generation/props/terrain_prop_catalog.tres index f6fd1dd..12c9b51 100644 --- a/world/generation/props/terrain_prop_catalog.tres +++ b/world/generation/props/terrain_prop_catalog.tres @@ -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="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_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="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] 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")]) diff --git a/world/generation/terrain_chunk_generator.gd b/world/generation/terrain_chunk_generator.gd index 8f6235d..6555568 100644 --- a/world/generation/terrain_chunk_generator.gd +++ b/world/generation/terrain_chunk_generator.gd @@ -34,6 +34,11 @@ const MAX_PACKED_SOLVER_VARIANTS := 62 @export var elevated_cliff_edge_chunk_id: StringName = &"chunk_0011" @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 +## 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_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 @@ -68,6 +73,7 @@ var _generated_chunks: Node3D var _backtrack_count := 0 var _elevated_feature_center := Vector2i(-1, -1) var _elevated_feature_reserved_grass_indices: Dictionary[int, bool] = {} +var _stacked_elevated_placements: Array[Dictionary] = [] func _ready() -> void: @@ -100,6 +106,9 @@ func generate() -> bool: if not _apply_elevated_feature(): _assign_placements(previous_placements) return false + if not _configure_stacked_elevated_feature(): + _assign_placements(previous_placements) + return false var solution_root := _build_solution_root() if solution_root == null: _assign_placements(previous_placements) @@ -138,6 +147,9 @@ func generate_from_placement_keys(keys: PackedStringArray) -> bool: previous_placements.assign(_placements) _assign_placements(resolved) _backtrack_count = 0 + if not _configure_stacked_elevated_feature(): + _assign_placements(previous_placements) + return false var solution_root := _build_solution_root() if solution_root == null: _assign_placements(previous_placements) @@ -1184,6 +1196,7 @@ func _candidate_can_occupy_cell( func _prepare_elevated_feature_region() -> bool: _elevated_feature_center = Vector2i(-1, -1) _elevated_feature_reserved_grass_indices.clear() + _stacked_elevated_placements.clear() if not elevated_cliff_feature_enabled: _build_grid_solver_caches() return true @@ -1439,6 +1452,65 @@ func _apply_elevated_feature() -> bool: 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( variant: TerrainChunkVariant, coordinate: Vector2i, @@ -2040,9 +2112,74 @@ func _build_solution_root() -> Node3D: _add_collision(chunk_root, variant.definition) if show_chunk_labels: _add_chunk_label(chunk_root, variant) + if not _add_stacked_elevated_chunks(solution_root): + solution_root.free() + return null 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( definition: TerrainChunkDefinition, ) -> Node3D: @@ -2171,6 +2308,7 @@ func _build_summary() -> Dictionary: "counts": counts, "variant_counts": variant_counts, "placements": placement_keys(), + "stacked_elevated_placements": stacked_elevated_placement_keys(), "layout_fingerprint": placement_fingerprint(), } @@ -2214,9 +2352,29 @@ func placement_fingerprint() -> String: "chunk_size:%.6f" % (catalog.chunk_size if catalog != null else 0.0), ]) fingerprint_input.append_array(placement_keys()) + fingerprint_input.append_array(stacked_elevated_placement_keys()) 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]: var result: Array[Dictionary] = [] 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: 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 diff --git a/world/generation/terrain_prop_catalog.gd b/world/generation/terrain_prop_catalog.gd index e73fbe0..0bc1ca7 100644 --- a/world/generation/terrain_prop_catalog.gd +++ b/world/generation/terrain_prop_catalog.gd @@ -87,6 +87,23 @@ func validation_errors() -> PackedStringArray: "%s has an inverted visual scale range." % 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 ( not definition.material_variants.is_empty() and definition.variant_material_slot_names.is_empty() @@ -101,4 +118,26 @@ func validation_errors() -> PackedStringArray: "%s contains an empty material variant." % 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 diff --git a/world/generation/terrain_prop_definition.gd b/world/generation/terrain_prop_definition.gd index f6e73ae..bfe3677 100644 --- a/world/generation/terrain_prop_definition.gd +++ b/world/generation/terrain_prop_definition.gd @@ -21,12 +21,28 @@ extends Resource ## 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 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. @export var visual_offset := Vector3.ZERO ## A single material is chosen per prop and applied only to matching imported ## material slots. This keeps trunks untouched while varying foliage. @export var variant_material_slot_names := PackedStringArray() @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, 20.0, 0.05) var collision_height := 0.0 @export var collision_box_size := Vector3.ZERO