Add deterministic generated worlds

This commit is contained in:
Alexander Sellite 2026-08-19 17:32:40 -04:00
parent 19f63bd656
commit e8888ed05a
87 changed files with 3743 additions and 139 deletions

View file

@ -409,6 +409,12 @@ func _start_dedicated_server() -> void:
):
_fail_dedicated_server("The server operator list is invalid.")
return
if not _apply_generated_world(config.world_seed, false):
_fail_dedicated_server("The configured world seed could not be generated.")
return
if not _network_session.set_host_world_seed(config.world_seed):
_fail_dedicated_server("The configured world seed is invalid.")
return
_configure_portable_stores()
if not _discovery.configure_dedicated_runtime(config.server_name):
_fail_dedicated_server("The server room name is invalid.")
@ -1666,14 +1672,24 @@ func _resize_native_overlays() -> void:
_shop_backdrop.size = Vector2(get_window().size)
func _on_new_game_requested() -> void:
func _on_new_game_requested(world_seed: int) -> void:
if _gameplay_started or _quit_in_progress:
return
if not _apply_generated_world(world_seed, true):
_game_ui.get_title_screen().report_network_error(
"Could not generate that world seed. Try rolling another world."
)
return
if not _prepare_host_world_seed(world_seed):
_game_ui.get_title_screen().report_network_error(
"Could not prepare the generated world for hosting."
)
return
if not _prepare_private_host():
return
if (
not _save_manager.delete_progression_save()
or not _save_manager.initialize_new_game()
or not _save_manager.initialize_new_game(world_seed)
):
_network_session.disconnect_session("New Game setup failed.")
_game_ui.get_title_screen().report_network_error(
@ -1686,14 +1702,22 @@ func _on_new_game_requested() -> void:
func _on_continue_game_requested() -> void:
if _gameplay_started or _quit_in_progress:
return
if not _prepare_private_host():
return
if not _save_manager.load_player_data():
_network_session.disconnect_session("Continue failed.")
_game_ui.get_title_screen().report_network_error(
"Failed to load save. The original was preserved."
)
return
var world_seed: int = _save_manager.get_world_seed()
if (
not _apply_generated_world(world_seed, true)
or not _prepare_host_world_seed(world_seed)
):
_game_ui.get_title_screen().report_network_error(
"The saved world could not be generated. The save was preserved."
)
return
if not _prepare_private_host():
return
_enter_gameplay()
@ -1716,6 +1740,15 @@ func _prepare_private_host() -> bool:
return true
func _prepare_host_world_seed(world_seed: int) -> bool:
if _network_session.state in [
NetworkSessionType.State.CONNECTION_FAILED,
NetworkSessionType.State.SERVER_LOST,
]:
_network_session.reset_failure()
return _network_session.set_host_world_seed(world_seed)
func _enter_gameplay() -> void:
_fade_out_title_music()
_game_ui.get_title_screen().hide()
@ -1805,10 +1838,14 @@ func _on_network_join_authenticated() -> void:
)
_join_requested_from_title = false
return
if not _apply_joined_world(server_metadata):
return
_fade_out_title_music()
_game_ui.get_title_screen().hide()
_set_gameplay_active(true)
elif _join_requested_from_pause:
if not _apply_joined_world(server_metadata):
return
_set_gameplay_active(true)
_join_requested_from_title = false
_join_requested_from_pause = false
@ -1912,7 +1949,12 @@ func _on_reset_progress_requested() -> void:
if not _save_manager.delete_progression_save():
pause_menu.report_reset_failure()
return
if not _save_manager.initialize_new_game():
var world_seed: int = PlayerSaveManager.roll_world_seed()
if not _save_manager.initialize_new_game(world_seed):
pause_menu.report_reset_failure()
return
_network_session.disconnect_session("Progress reset.")
if not _apply_generated_world(world_seed, true):
pause_menu.report_reset_failure()
return
pause_menu.close_for_title_transition()
@ -1921,6 +1963,44 @@ func _on_reset_progress_requested() -> void:
_show_title_music(true)
func _apply_joined_world(server_metadata: Dictionary) -> bool:
var world_seed: int = int(server_metadata.get("world_seed", 0))
if _apply_generated_world(world_seed, true):
return true
_network_session.disconnect_session("World generation failed.")
_join_requested_from_title = false
_join_requested_from_pause = false
_set_gameplay_active(false)
var title_screen: TitleScreenType = _game_ui.get_title_screen()
title_screen.reopen_to_menu()
title_screen.report_network_error(
"The host's generated world could not be built locally."
)
_show_title_music(true)
return false
func _apply_generated_world(world_seed: int, reposition_player: bool) -> bool:
if not _test_world.generate_world(world_seed):
return false
var spawn_transform: Transform3D = _test_world.get_player_spawn_transform()
_player_spawn_service.set_spawn_transform(spawn_transform)
if reposition_player:
_player.global_transform = spawn_transform
_player.velocity = Vector3.ZERO
if _application_initialized and not _dedicated_runtime:
_water_recovery.update_world_context(
spawn_transform,
_test_world.get_player_water_triggers(),
_test_world.get_safe_respawn_points(),
)
_shoreline_ambience.configure(
_player,
_test_world.get_saltwater_shoreline_mesh(),
)
return true
func _on_water_recovery_starting() -> void:
_local_recovery_attempt_id = (
"recovery:%s"

View file

@ -298,48 +298,11 @@ unique_name_in_owner = true
script = ExtResource("36_player_list")
[node name="TestWorld" parent="." unique_id=1334932296 instance=ExtResource("1_world")]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -2.4475808, 0, -1.8959346)
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0)
[node name="Sun" parent="TestWorld/Environment" index="1"]
transform = Transform3D(0.8480481, 0.4287137, -0.31147876, 0, 0.5877853, 0.809017, 0.52991927, -0.68608534, 0.49847022, 0, 0, 0)
[node name="Shape" parent="TestWorld/Regions/StarterIslandRegion/Terrain/Collision" index="0" unique_id=674742331]
shape = SubResource("ConcavePolygonShape3D_hifdc")
[node name="Pond" parent="TestWorld/Regions/StarterIslandRegion/WaterBodies" index="0" unique_id=995477450]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -9.4, 2.51, 2.1)
[node name="VisualWater" parent="TestWorld/Regions/StarterIslandRegion/WaterBodies/Pond" index="0" unique_id=1034265960]
mesh = SubResource("PlaneMesh_7ny4s")
[node name="Shape" parent="TestWorld/Regions/StarterIslandRegion/WaterBodies/Pond/FishingRegion" index="0" unique_id=1185360438]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -2, 0)
shape = SubResource("BoxShape3D_agj3o")
[node name="Shape" parent="TestWorld/Regions/StarterIslandRegion/WaterBodies/Pond/RecoveryRegion" index="0" unique_id=1718808476]
shape = SubResource("BoxShape3D_l6210")
[node name="VisualWater" parent="TestWorld/Regions/StarterIslandRegion/WaterBodies/Ocean" index="0" unique_id=1176408937]
mesh = SubResource("PlaneMesh_6upbg")
[node name="Shape" parent="TestWorld/Regions/StarterIslandRegion/WaterBodies/Ocean/FishingRegions/OceanFishingRegion" index="0" unique_id=357406062]
shape = SubResource("BoxShape3D_umo15")
[node name="WestShape" parent="TestWorld/Regions/StarterIslandRegion/WaterBodies/Ocean/RecoveryRegions/OceanRecoveryRegion" index="0" unique_id=1183368645]
shape = SubResource("BoxShape3D_1fdkm")
[node name="EastShape" parent="TestWorld/Regions/StarterIslandRegion/WaterBodies/Ocean/RecoveryRegions/OceanRecoveryRegion" index="1" unique_id=1742419808]
shape = SubResource("BoxShape3D_y2c0j")
[node name="NorthShape" parent="TestWorld/Regions/StarterIslandRegion/WaterBodies/Ocean/RecoveryRegions/OceanRecoveryRegion" index="2" unique_id=1844920260]
shape = SubResource("BoxShape3D_uwwqy")
[node name="SouthShape" parent="TestWorld/Regions/StarterIslandRegion/WaterBodies/Ocean/RecoveryRegions/OceanRecoveryRegion" index="3" unique_id=1316495770]
shape = SubResource("BoxShape3D_hf28p")
[node name="FishingShopWorld" parent="TestWorld/Regions/StarterIslandRegion/Interactables" index="0" unique_id=1255373524]
transform = Transform3D(-0.9976046, 0, -0.06917416, 0, 1, 0, 0.06917416, 0, -0.9976046, 7.3292284, 3.4012775, 14.888193)
[node name="BelowWorldFailsafe" parent="TestWorld/Safety" index="0"]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 12, -7, 0)
@ -436,4 +399,3 @@ unique_name_in_owner = true
unique_name_in_owner = true
[editable path="TestWorld"]
[editable path="TestWorld/Regions/StarterIslandRegion"]

View file

@ -1,7 +1,7 @@
class_name NetworkProtocol
extends RefCounted
const PROTOCOL_VERSION: int = 8
const PROTOCOL_VERSION: int = 9
const GAME_BUILD: String = "prealpha"
const MAX_GAME_VERSION_LENGTH: int = 64
const MAX_DISPLAY_NAME_LENGTH: int = 24
@ -28,6 +28,9 @@ const FISH_QUALITY_CAPABILITY: String = "fish_quality_v1"
const JOBS_CAPABILITY: String = "jobs_v1"
const WORLD_SPAWN_CAPABILITY: String = "world_spawn_envelope_v1"
const APPEARANCE_PREVIEW_CAPABILITY: String = "appearance_preview_v1"
const WORLD_GENERATION_CAPABILITY: String = "world_generation_v1"
const DEFAULT_WORLD_SEED: int = 13001
const MAX_WORLD_SEED: int = 2147483646
enum RejectionCode {
NONE,
@ -178,6 +181,7 @@ static func make_client_hello(
WORLD_SPAWN_CAPABILITY,
APPEARANCE_PREVIEW_CAPABILITY,
BACKPACK_SHOP_CAPABILITY,
WORLD_GENERATION_CAPABILITY,
]),
"cosmetic_snapshot": cosmetic_snapshot,
"identity_fingerprint": identity_fingerprint,
@ -281,6 +285,7 @@ static func make_server_hello(
player_count: int,
max_players: int,
server_display_name: String = "NETfishing",
world_seed: int = DEFAULT_WORLD_SEED,
) -> Dictionary:
return {
"accepted": accepted,
@ -292,6 +297,7 @@ static func make_server_hello(
"server_display_name": server_display_name,
"player_count": player_count,
"max_players": max_players,
"world_seed": world_seed,
"capability_flags": PackedStringArray([
"movement_v1",
"fishing_v1",
@ -309,6 +315,7 @@ static func make_server_hello(
JOBS_CAPABILITY,
WORLD_SPAWN_CAPABILITY,
APPEARANCE_PREVIEW_CAPABILITY,
WORLD_GENERATION_CAPABILITY,
"chat_v1",
"mail_v1",
"profile_v1",

View file

@ -85,6 +85,7 @@ var _last_server_max_players: int = DEFAULT_SESSION_MAX_PLAYERS
var _last_server_player_count: int = 0
var _last_server_display_name: String = ""
var _last_server_protocol_version: int = 0
var _last_server_world_seed: int = NetworkProtocol.DEFAULT_WORLD_SEED
var _server_capabilities: PackedStringArray = PackedStringArray()
var _profile_ready: bool = false
var _player_identity: PlayerIdentityStore
@ -111,6 +112,7 @@ var _configured_operator_fingerprints: Dictionary[String, bool] = {}
var _session_operator_fingerprints: Dictionary[String, bool] = {}
var _operator_peer_ids: Dictionary[int, bool] = {}
var _local_operator: bool = false
var _host_world_seed: int = NetworkProtocol.DEFAULT_WORLD_SEED
func _ready() -> void:
@ -272,6 +274,7 @@ func _register_player_host() -> void:
NetworkProtocol.JOBS_CAPABILITY,
NetworkProtocol.WORLD_SPAWN_CAPABILITY,
NetworkProtocol.BACKPACK_SHOP_CAPABILITY,
NetworkProtocol.WORLD_GENERATION_CAPABILITY,
]),
)
_registry.update_appearance(1, _local_appearance_snapshot)
@ -527,9 +530,25 @@ func get_last_server_metadata() -> Dictionary:
"protocol_version": _last_server_protocol_version,
"player_count": _last_server_player_count,
"max_players": _last_server_max_players,
"world_seed": _last_server_world_seed,
}
func set_host_world_seed(seed: int) -> bool:
if (
state != State.INACTIVE
or seed <= 0
or seed > NetworkProtocol.MAX_WORLD_SEED
):
return false
_host_world_seed = seed
return true
func get_authoritative_world_seed() -> int:
return _last_server_world_seed if is_joined_client() else _host_world_seed
func can_use_host_gameplay() -> bool:
return is_host()
@ -563,6 +582,7 @@ func supports_server_capability(capability: StringName) -> bool:
NetworkProtocol.WORLD_WEATHER_CAPABILITY,
NetworkProtocol.WORLD_SPAWN_CAPABILITY,
NetworkProtocol.APPEARANCE_PREVIEW_CAPABILITY,
NetworkProtocol.WORLD_GENERATION_CAPABILITY,
"chat_v1",
"mail_v1",
"profile_v1",
@ -1164,6 +1184,12 @@ func submit_client_hello(data: Dictionary) -> void:
NetworkProtocol.RejectionCode.UNSUPPORTED_CLIENT,
)
return
if NetworkProtocol.WORLD_GENERATION_CAPABILITY not in client_capabilities:
_reject_peer(
sender_id,
NetworkProtocol.RejectionCode.UNSUPPORTED_CLIENT,
)
return
var identity: Dictionary = _authenticated_identity_cache.get(sender_id, {})
if identity.is_empty():
_reject_peer(sender_id, NetworkProtocol.RejectionCode.INVALID_IDENTITY_PROOF)
@ -1251,6 +1277,7 @@ func submit_client_hello(data: Dictionary) -> void:
_registry.size(),
session_max_players,
_session_display_name,
_host_world_seed,
)
)
receive_spawn_list.rpc_id(sender_id, _build_spawn_list())
@ -1304,6 +1331,7 @@ func receive_server_hello(data: Dictionary) -> void:
or typeof(data.get("protocol_version")) != TYPE_INT
or typeof(data.get("game_version")) != TYPE_STRING
or typeof(data.get("rejection_code")) != TYPE_INT
or typeof(data.get("world_seed")) != TYPE_INT
):
_teardown_peer()
_fail("The server sent an invalid handshake response.")
@ -1329,6 +1357,11 @@ func receive_server_hello(data: Dictionary) -> void:
_teardown_peer()
_fail("The server uses a different network protocol.")
return
var received_world_seed := int(data["world_seed"])
if received_world_seed <= 0 or received_world_seed > NetworkProtocol.MAX_WORLD_SEED:
_teardown_peer()
_fail("The server sent an invalid world seed.")
return
_session_id = str(data.get("session_id", ""))
_last_server_max_players = int(data.get(
"max_players",
@ -1339,6 +1372,7 @@ func receive_server_hello(data: Dictionary) -> void:
_last_server_protocol_version = int(data.get(
"protocol_version", NetworkProtocol.PROTOCOL_VERSION
))
_last_server_world_seed = received_world_seed
_server_capabilities = PackedStringArray()
var advertised_capabilities: Variant = data.get(
"capability_flags", PackedStringArray()
@ -1353,6 +1387,10 @@ func receive_server_hello(data: Dictionary) -> void:
_teardown_peer()
_fail("This server does not support fish quality data.")
return
if NetworkProtocol.WORLD_GENERATION_CAPABILITY not in _server_capabilities:
_teardown_peer()
_fail("This server does not support generated worlds.")
return
var local_peer_id: int = multiplayer.get_unique_id()
_registry.clear()
_registry.add_peer(
@ -1368,6 +1406,7 @@ func receive_server_hello(data: Dictionary) -> void:
NetworkProtocol.WORLD_TIME_CAPABILITY,
NetworkProtocol.WORLD_WEATHER_CAPABILITY,
NetworkProtocol.BACKPACK_SHOP_CAPABILITY,
NetworkProtocol.WORLD_GENERATION_CAPABILITY,
]),
)
_registry.update_appearance(local_peer_id, _local_appearance_snapshot)

View file

@ -23,6 +23,10 @@ func setup(
_spawn_transform = spawn_transform
func set_spawn_transform(spawn_transform: Transform3D) -> void:
_spawn_transform = spawn_transform
func register_local_player(peer_id: int) -> void:
if _local_player == null:
return

View file

@ -33,9 +33,11 @@ const WorldWeatherServiceType = preload(
)
const PlayerJobServiceType = preload("res://jobs/player_job_service.gd")
const SAVE_VERSION: int = 8
const SAVE_VERSION: int = 9
const BASIC_ROD_ID: StringName = &"basic_fishing_rod"
const MAX_SAFE_BALANCE: int = 1000000000000
const DEFAULT_WORLD_SEED: int = 13001
const MAX_WORLD_SEED: int = 2147483646
class LoadSnapshot:
extends RefCounted
@ -56,6 +58,7 @@ class LoadSnapshot:
var cooler_capacity_level: int = 0
var art_unlock_mask: int = 0
var total_experience: int = 0
var world_seed: int = DEFAULT_WORLD_SEED
var world_time_hours: float = WorldTimeServiceType.DEFAULT_START_HOUR
var has_world_weather_state: bool = false
var world_weather: WorldWeatherServiceType.Weather = (
@ -93,6 +96,7 @@ var _autosave_enabled: bool = false
var _save_path := ""
var _expected_hash := ""
var _data_root: PlayerDataRoot
var _world_seed: int = DEFAULT_WORLD_SEED
func configure_storage(path: String, data_root: PlayerDataRoot) -> void:
@ -298,6 +302,7 @@ func load_player_data() -> bool:
var world_time_restored: bool = (
_world_time.restore_persistent_time_hours(snapshot.world_time_hours)
)
_world_seed = snapshot.world_seed
var world_weather_restored: bool = true
if snapshot.has_world_weather_state:
world_weather_restored = _world_weather.restore_persistent_state(
@ -403,14 +408,28 @@ func inspect_save() -> SaveInspectionType:
return result
func initialize_new_game() -> bool:
func initialize_new_game(world_seed: int = DEFAULT_WORLD_SEED) -> bool:
if not _is_configured:
return false
if world_seed <= 0 or world_seed > MAX_WORLD_SEED:
return false
_automatic_saving_blocked = false
_restore_defaults()
_world_seed = world_seed
_is_dirty = true
return true
func get_world_seed() -> int:
return _world_seed
static func roll_world_seed() -> int:
var random := RandomNumberGenerator.new()
random.randomize()
return random.randi_range(1, MAX_WORLD_SEED)
func delete_progression_save() -> bool:
if FileAccess.file_exists(_save_path) and not _remove_if_present(_save_path):
return false
@ -584,6 +603,7 @@ func _build_save_dictionary() -> Dictionary:
"art": _art_unlocks.to_save_data(),
"experience": _experience.to_save_data(),
"world": {
"seed": _world_seed,
"time_hours": _world_time.get_persistent_time_hours(),
"weather": int(_world_weather.get_persistent_weather()),
"weather_seconds_remaining": (
@ -664,6 +684,14 @@ func _build_load_snapshot(save_data: Dictionary) -> LoadSnapshot:
return null
var snapshot := LoadSnapshot.new()
if world_data.has("seed"):
snapshot.world_seed = _read_integer(
world_data["seed"],
-1,
MAX_WORLD_SEED,
)
if snapshot.world_seed <= 0:
return null
snapshot.inventory_layout_data = inventory_layout_data.duplicate(true)
snapshot.wallet_balance = balance
snapshot.total_experience = _read_integer(
@ -965,6 +993,8 @@ func _migrate_save(
migrated = _migrate_version_6_to_7(migrated)
7:
migrated = _migrate_version_7_to_8(migrated)
8:
migrated = _migrate_version_8_to_9(migrated)
_:
return {}
if migrated.is_empty():
@ -1201,6 +1231,17 @@ func _migrate_version_7_to_8(data: Dictionary) -> Dictionary:
return migrated
func _migrate_version_8_to_9(data: Dictionary) -> Dictionary:
var migrated: Dictionary = data.duplicate(true)
var world_data: Dictionary = {}
if typeof(migrated.get("world")) == TYPE_DICTIONARY:
world_data = (migrated["world"] as Dictionary).duplicate(true)
world_data["seed"] = DEFAULT_WORLD_SEED
migrated["world"] = world_data
migrated["save_version"] = 9
return migrated
func _mark_dirty() -> void:
if (
_is_restoring
@ -1319,6 +1360,7 @@ func _restore_defaults() -> void:
WorldTimeServiceType.DEFAULT_START_HOUR
)
_world_weather.reset_persistent_state()
_world_seed = DEFAULT_WORLD_SEED
_jobs.reset_to_defaults()
_is_restoring = false
_is_dirty = false

View file

@ -30,6 +30,7 @@ readonly -a QUICK_TESTS=(
"tests/fishing_audio_validation.gd"
"tests/fishing_surface_validation.gd"
"tests/fur_pattern_validation.gd"
"tests/generated_world_runtime_validation.gd"
"tests/gathering_marker_surface_validation.gd"
"tests/inventory_storage_validation.gd"
"tests/keyboard_mouse_mapping_validation.gd"
@ -43,6 +44,7 @@ readonly -a QUICK_TESTS=(
"tests/surface_drawing_validation.gd"
"tests/tackle_order_validation.gd"
"tests/terrain_blender_material_validation.gd"
"tests/terrain_chunk_generator_validation.gd"
"tests/texture_sampling_validation.gd"
"tests/tree_gathering_prototype_validation.gd"
"tests/unified_inventory_validation.gd"

View file

@ -5,12 +5,15 @@ const DEFAULT_NAME: String = "NETfishing Dedicated Server"
const DEFAULT_BIND_ADDRESS: String = "*"
const DEFAULT_PORT: int = 7777
const DEFAULT_MAX_PLAYERS: int = 8
const DEFAULT_WORLD_SEED: int = 13001
const MAX_WORLD_SEED: int = 2147483646
const MAX_OPERATORS: int = 64
var server_name: String = DEFAULT_NAME
var bind_address: String = DEFAULT_BIND_ADDRESS
var port: int = DEFAULT_PORT
var max_players: int = DEFAULT_MAX_PLAYERS
var world_seed: int = DEFAULT_WORLD_SEED
var public_listing: bool = false
var discovery_url: String = ""
var data_directory: String = ""
@ -58,6 +61,7 @@ func _load_file(path: String) -> bool:
max_players = int(file.get_value(
"server", "max_players", max_players
))
world_seed = int(file.get_value("world", "seed", world_seed))
public_listing = bool(file.get_value(
"server", "public", public_listing
))
@ -84,6 +88,9 @@ func _apply_environment() -> void:
max_players = _environment_int(
"NETFISHING_SERVER_MAX_PLAYERS", max_players
)
world_seed = _environment_int(
"NETFISHING_WORLD_SEED", world_seed
)
public_listing = _environment_bool(
"NETFISHING_SERVER_PUBLIC", public_listing
)
@ -111,6 +118,10 @@ func _apply_arguments(arguments: PackedStringArray) -> void:
max_players = _parse_int(
argument.trim_prefix("--max-players="), max_players
)
elif argument.begins_with("--world-seed="):
world_seed = _parse_int(
argument.trim_prefix("--world-seed="), world_seed
)
elif argument.begins_with("--data-dir="):
data_directory = argument.trim_prefix("--data-dir=")
elif argument.begins_with("--discovery-url="):
@ -141,6 +152,8 @@ func _validate() -> void:
error_message = "Server port must be between 1 and 65535."
elif max_players < 1 or max_players > 128:
error_message = "Maximum players must be between 1 and 128."
elif world_seed <= 0 or world_seed > MAX_WORLD_SEED:
error_message = "World seed must be between 1 and %d." % MAX_WORLD_SEED
elif not data_directory.is_empty() and not data_directory.is_absolute_path():
error_message = "Server data directory must be an absolute path."
elif public_listing and not (

View file

@ -28,6 +28,7 @@ func _run() -> void:
file.set_value("server", "bind_address", "127.0.0.1")
file.set_value("server", "port", 17777)
file.set_value("server", "max_players", 12)
file.set_value("world", "seed", 928374)
file.set_value("server", "public", true)
file.set_value("server", "data_directory", "/tmp/configured-server")
file.set_value("discovery", "url", "https://discovery.netfishing.org/")
@ -44,6 +45,7 @@ func _run() -> void:
assert(str(configured.get("bind_address")) == "127.0.0.1")
assert(int(configured.get("port")) == 17777)
assert(int(configured.get("max_players")) == 12)
assert(int(configured.get("world_seed")) == 928374)
assert(bool(configured.get("public_listing")))
assert(str(configured.get("discovery_url")) == "https://discovery.netfishing.org")
assert(
@ -81,6 +83,12 @@ func _run() -> void:
invalid.call("_validate")
assert(not invalid.is_valid())
var invalid_seed := ConfigType.new()
invalid_seed.set("world_seed", 0)
invalid_seed.set("data_directory", "/tmp/invalid-seed-server")
invalid_seed.call("_validate")
assert(not invalid_seed.is_valid())
var discovery := DiscoveryClient.new()
assert(
str(discovery.call("_default_room_name", "River"))

View file

@ -106,7 +106,7 @@ func _run() -> void:
var hotbar_data: Dictionary = (parsed as Dictionary)["hotbar"]
assert(typeof(hotbar_data.get("fish_slots")) == TYPE_ARRAY)
assert(str((hotbar_data["fish_slots"] as Array)[1]) == fish_catch.catch_id)
assert(int((parsed as Dictionary)["save_version"]) == 8)
assert(int((parsed as Dictionary)["save_version"]) == 9)
assert(
int((parsed as Dictionary)["experience"]["total_experience"])
== 125
@ -185,7 +185,7 @@ func _run() -> void:
var invalid_state: Dictionary = valid_state.duplicate(true)
invalid_state["display_scale"] = 1000.0
assert(not NetworkFishShowcaseProtocol.validate_state(invalid_state))
assert(NetworkProtocol.PROTOCOL_VERSION == 8)
assert(NetworkProtocol.PROTOCOL_VERSION == 9)
assert(NetworkProtocol.ENET_CHANNEL_COUNT == 10)
assert(
NetworkProtocol.FISH_QUALITY_CAPABILITY

View file

@ -25,7 +25,7 @@ func _run() -> void:
_validate_mail_round_trip()
_validate_collection_mastery()
_validate_version_four_migration()
assert(NetworkProtocol.PROTOCOL_VERSION == 8)
assert(NetworkProtocol.PROTOCOL_VERSION == 9)
assert(NetworkProtocol.ENET_CHANNEL_COUNT == 10)
assert(NetworkProtocol.FISH_QUALITY_CAPABILITY == "fish_quality_v1")
print("Fish quality validation: PASS")
@ -433,7 +433,7 @@ func _validate_version_four_migration() -> void:
version_four,
4,
)
assert(int(migrated.get("save_version", -1)) == 8)
assert(int(migrated.get("save_version", -1)) == 9)
assert(int((migrated["experience"] as Dictionary)["total_experience"]) == 0)
assert(
is_equal_approx(
@ -441,6 +441,10 @@ func _validate_version_four_migration() -> void:
8.0,
)
)
assert(
int((migrated["world"] as Dictionary)["seed"])
== PlayerSaveManager.DEFAULT_WORLD_SEED
)
var catches: Array = migrated["inventory"]["catches"]
assert(int((catches[0] as Dictionary)["quality"]) == 0)
var collection: Dictionary = migrated["collection"]

View file

@ -39,19 +39,33 @@ func _run() -> void:
assert(not session.is_open_host())
assert(service != null and fishing_spot != null and player != null)
assert(player.hotbar.get_selected_item_id() == &"basic_fishing_rod")
var pond := main.get_node(
"TestWorld/Regions/StarterIslandRegion/WaterBodies/Pond"
var fresh_root := main.get_node(
"TestWorld/Regions/GeneratedWorldRegion/WaterBodies/FreshWaterBodies"
) as Node3D
var pond: WaterBodyAuthoring
for child: Node in fresh_root.get_children():
var candidate := child as WaterBodyAuthoring
if candidate != null and &"pond" in candidate.location_tags:
pond = candidate
break
assert(pond != null)
var pond_region := pond.get_node("FishingRegion") as FishableWaterRegion
assert(pond != null and pond_region != null)
assert(pond_region != null)
var pond_surface_y: float = pond_region.get_surface_height()
player.global_position = pond.global_position + Vector3(8.9, 1.44, 0.0)
player.global_position = pond.global_transform * Vector3(
pond.surface_size.x * 0.5 + 0.8,
1.44,
0.0,
)
var pond_direction := pond.global_position - player.global_position
pond_direction.y = 0.0
pond_direction = pond_direction.normalized()
var visuals := player.get_node("Visuals") as Node3D
visuals.rotation.y = PI * 0.5
visuals.global_rotation.y = atan2(-pond_direction.x, -pond_direction.z)
for _frame: int in 4:
await physics_frame
assert(player.get_facing_direction().dot(Vector3.LEFT) > 0.99)
assert(player.get_facing_direction().dot(pond_direction) > 0.99)
fishing_spot.call("_begin_aiming", player)
assert(fishing_spot.state == FishingSpotType.FishingState.AIMING_CAST)
@ -59,7 +73,9 @@ func _run() -> void:
fishing_spot.set("_cast_charge", 0.32)
fishing_spot.call("_update_cast_charge", 0.0)
var aimed_target: Vector3 = fishing_spot.get("_cast_target")
assert(aimed_target.x < player.global_position.x - 0.85)
var aimed_direction := aimed_target - player.global_position
aimed_direction.y = 0.0
assert(aimed_direction.normalized().dot(pond_direction) > 0.99)
assert(is_equal_approx(aimed_target.y, pond_surface_y))
assert(fishing_spot.is_target_fishable(aimed_target))
fishing_spot.call("_confirm_cast")

View file

@ -0,0 +1,236 @@
extends SceneTree
const RegionScene: PackedScene = preload(
"res://world/generation/generated_world_region.tscn"
)
const FIRST_SEED := 13001
const SECOND_SEED := 13002
func _initialize() -> void:
call_deferred(&"_run")
func _run() -> void:
var region := RegionScene.instantiate() as GeneratedWorldRegion
assert(region != null)
root.add_child(region)
await process_frame
await physics_frame
var generator := region.get_node(
"Terrain/TerrainChunkGenerator"
) as TerrainChunkGenerator
assert(generator != null)
assert(region.get_generation_seed() == FIRST_SEED)
_validate_generated_region(region, generator)
var first_layout := generator.placement_keys()
var first_decorations := _decoration_signature(region)
assert(region.generate_world(FIRST_SEED))
assert(generator.placement_keys() == first_layout)
assert(_decoration_signature(region) == first_decorations)
assert(region.generate_world(SECOND_SEED))
await physics_frame
_validate_generated_region(region, generator)
assert(generator.placement_keys() != first_layout)
region.queue_free()
await process_frame
print("Generated world runtime validation: PASS")
quit()
func _validate_generated_region(
region: GeneratedWorldRegion,
generator: TerrainChunkGenerator,
) -> void:
var placements := generator.placement_keys()
assert(placements.size() == 25)
assert(placements[12].begins_with("chunk_spawn@"))
assert(_placement_count(placements, "chunk_spawn") == 1)
assert(_placement_count(placements, "chunk_0001") >= 1)
assert(_placement_count(placements, "chunk_0002") >= 1)
assert(_placement_count(placements, "chunk_0003") >= 1)
assert(_placement_count(placements, "chunk_0004") >= 1)
assert(generator.get_generated_chunks_root().get_child_count() == 25)
var shop := region.get_node("Interactables/FishingShopWorld") as Node3D
var storage := region.get_node("Interactables/PlayerStorageBox") as Node3D
assert(shop != null and shop.has_node("Shopkeeper"))
assert(storage != null and storage.has_node("InteractionArea"))
assert(absf(shop.position.x) < 5.0 and absf(shop.position.z) < 5.0)
assert(absf(storage.position.x) < 5.0 and absf(storage.position.z) < 5.0)
assert(region.get_fishing_shop() != null)
assert(region.get_player_storage() != null)
assert(region.get_player_spawn_transform().origin.y > 0.0)
var ocean := region.get_node("WaterBodies/OceanWater") as WaterBodyAuthoring
var fresh_root := region.get_node("WaterBodies/FreshWaterBodies") as Node3D
assert(ocean != null and fresh_root != null)
assert(ocean.water_type == WaterType.Type.SALT_WATER)
var fresh_placement_count := 0
for record: Dictionary in generator.placement_records():
var tags: PackedStringArray = record.get("tags", PackedStringArray())
if "fresh_water" in tags:
fresh_placement_count += 1
assert(fresh_root.get_child_count() == fresh_placement_count)
for child: Node in fresh_root.get_children():
var fresh := child as WaterBodyAuthoring
assert(fresh != null)
assert(fresh.water_type == WaterType.Type.FRESH_WATER)
assert(fresh.visible)
assert(fresh.surface_size.x < 10.0 or fresh.surface_size.y < 10.0)
assert(not fresh.visual_surface_enabled)
assert(not (fresh.get_node("VisualWater") as MeshInstance3D).visible)
_validate_authored_chunk_surfaces(region, generator)
var decorations := region.get_node("Decorations") as Node3D
var anchors := region.get_node(
"GatherableAnchors/ReachableTreeTrunks"
) as GatherableAnchorSet3D
assert(decorations != null and decorations.get_child_count() > 0)
assert(anchors != null)
assert(anchors.get_spawn_positions().size() > 0)
for child: Node in decorations.get_children():
assert(
child.name.begins_with("regular_tree_")
or child.name.begins_with("palm_tree_")
)
_validate_decoration_transform(child as Node3D)
assert(_decoration_count(decorations, "regular_tree_") > 0)
assert(_decoration_count(decorations, "palm_tree_") > 0)
func _validate_decoration_transform(prop: Node3D) -> void:
assert(prop != null)
assert(prop.scale.is_equal_approx(Vector3.ONE))
var visual := prop.get_node_or_null("Visual") as MeshInstance3D
var collision := prop.get_node_or_null(
"TrunkCollision/CollisionShape"
) as CollisionShape3D
assert(visual != null and visual.mesh != null)
assert(collision != null and collision.shape is CylinderShape3D)
assert(collision.global_basis.get_scale().is_equal_approx(Vector3.ONE))
var bounds := visual.mesh.get_aabb()
var minimum_y := INF
for corner_index: int in 8:
var corner := bounds.position + Vector3(
bounds.size.x if (corner_index & 1) != 0 else 0.0,
bounds.size.y if (corner_index & 2) != 0 else 0.0,
bounds.size.z if (corner_index & 4) != 0 else 0.0,
)
minimum_y = minf(
minimum_y,
(visual.global_transform * corner).y,
)
assert(absf(minimum_y - prop.global_position.y) <= 0.01)
func _validate_authored_chunk_surfaces(
region: GeneratedWorldRegion,
generator: TerrainChunkGenerator,
) -> void:
var generated := generator.get_generated_chunks_root()
var found_beach := false
var found_pond := false
for chunk_root: Node in generated.get_children():
if chunk_root.name.begins_with("chunk_0002r"):
var beach := chunk_root.find_child(
"chunk_0002", true, false
) as MeshInstance3D
assert(beach != null and beach.mesh != null)
var material := beach.get_active_material(0)
assert(material != null and material.resource_name == "sand")
found_beach = true
elif chunk_root.name.begins_with("chunk_0004r"):
var pond := chunk_root.find_child(
"chunk_0004", true, false
) as MeshInstance3D
assert(pond != null and pond.mesh != null)
for surface_index: int in pond.mesh.get_surface_count():
var arrays := pond.mesh.surface_get_arrays(surface_index)
var normals := arrays[Mesh.ARRAY_NORMAL] as PackedVector3Array
for normal: Vector3 in normals:
assert(normal.y >= -0.001)
_validate_pond_collision(region, pond)
found_pond = true
assert(found_beach)
assert(found_pond)
func _validate_pond_collision(
region: GeneratedWorldRegion,
pond: MeshInstance3D,
) -> void:
var best_centroid := Vector3.ZERO
var best_height := -INF
for surface_index: int in pond.mesh.get_surface_count():
var arrays := pond.mesh.surface_get_arrays(surface_index)
var vertices := arrays[Mesh.ARRAY_VERTEX] as PackedVector3Array
var normals := arrays[Mesh.ARRAY_NORMAL] as PackedVector3Array
var indices := arrays[Mesh.ARRAY_INDEX] as PackedInt32Array
var triangle_count := (
indices.size() / 3 if not indices.is_empty() else vertices.size() / 3
)
for triangle_index: int in triangle_count:
var offset := triangle_index * 3
var index_a: int = indices[offset] if not indices.is_empty() else offset
var index_b: int = (
indices[offset + 1] if not indices.is_empty() else offset + 1
)
var index_c: int = (
indices[offset + 2] if not indices.is_empty() else offset + 2
)
var a: Vector3 = vertices[index_a]
var b: Vector3 = vertices[index_b]
var c: Vector3 = vertices[index_c]
var normal := (
(normals[index_a] + normals[index_b] + normals[index_c]) / 3.0
).normalized()
var centroid := (a + b + c) / 3.0
if normal.y > 0.5 and centroid.y > best_height:
best_height = centroid.y
best_centroid = centroid
assert(best_height > -INF)
var target := pond.global_transform * best_centroid
var query := PhysicsRayQueryParameters3D.create(
target + Vector3.UP * 2.0,
target + Vector3.DOWN * 2.0,
1,
)
var hit := region.get_world_3d().direct_space_state.intersect_ray(query)
assert(not hit.is_empty())
var collider := hit.get("collider") as Node
assert(collider != null and collider.get_parent() == pond)
func _decoration_count(decorations: Node3D, prefix: String) -> int:
var count := 0
for child: Node in decorations.get_children():
if child.name.begins_with(prefix):
count += 1
return count
func _placement_count(keys: PackedStringArray, stable_id: String) -> int:
var count := 0
for key: String in keys:
if key.begins_with(stable_id + "@"):
count += 1
return count
func _decoration_signature(region: GeneratedWorldRegion) -> PackedStringArray:
var result := PackedStringArray()
var decorations := region.get_node("Decorations") as Node3D
for child: Node in decorations.get_children():
var prop := child as Node3D
result.append("%s:%s:%s" % [
prop.name,
prop.position,
prop.rotation,
])
return result

View file

@ -0,0 +1 @@
uid://opamwsr0t7px

View file

@ -22,7 +22,8 @@ func _run() -> void:
var save_manager := main.get("_save_manager") as PlayerSaveManager
var player := main.get("_player") as Player
assert(save_manager != null and player != null)
assert(save_manager.initialize_new_game())
const TEST_WORLD_SEED := 918273
assert(save_manager.initialize_new_game(TEST_WORLD_SEED))
save_manager.set_autosave_enabled(true)
assert(player.bag.add_item(&"art_kit"))
assert(player.bag.add_item(&"coffee"))
@ -39,12 +40,14 @@ func _run() -> void:
save_file.close()
assert(typeof(parsed) == TYPE_DICTIONARY)
var records: Array = (parsed as Dictionary)["bag"]["items"]
assert(int((parsed as Dictionary)["world"]["seed"]) == TEST_WORLD_SEED)
assert(_saved_slot(records, &"basic_fishing_rod") == 14)
assert(_saved_slot(records, &"coffee") == 12)
assert(player.bag.move_item_to_storage_slot(&"basic_fishing_rod", 0))
assert(player.bag.move_item_to_storage_slot(&"coffee", 0))
assert(save_manager.load_player_data())
assert(save_manager.get_world_seed() == TEST_WORLD_SEED)
assert(player.bag.get_storage_slot(&"basic_fishing_rod") == 14)
assert(player.bag.get_storage_slot(&"coffee") == 12)

View file

@ -203,7 +203,7 @@ func _run() -> void:
save_file.close()
assert(typeof(parsed) == TYPE_DICTIONARY)
var save_data: Dictionary = parsed
assert(int(save_data.get("save_version", -1)) == 8)
assert(int(save_data.get("save_version", -1)) == 9)
assert(PlayerJobService.validate_save_data(save_data.get("jobs", {})))
_validate_pause_session_switch(main, session)

View file

@ -4,11 +4,6 @@ const MainScene: PackedScene = preload("res://main/main.tscn")
const RuntimePerformanceProfileType = preload(
"res://main/runtime_performance_profile.gd"
)
const FOLIAGE_WIND_SHADER: Shader = preload(
"res://world/materials/foliage_wind.gdshader"
)
func _initialize() -> void:
call_deferred("_run")
@ -77,27 +72,19 @@ func _validate_main_profile(main: Node) -> void:
var screen_grid := pixelation.get_node("ScreenGrid") as ColorRect
assert(screen_grid != null and not screen_grid.visible)
var pond := main.get_node(
"TestWorld/Regions/StarterIslandRegion/WaterBodies/Pond/VisualWater"
) as MeshInstance3D
var pond := _first_fresh_water_visual(main)
var ocean := main.get_node(
"TestWorld/Regions/StarterIslandRegion/WaterBodies/Ocean/VisualWater"
"TestWorld/Regions/GeneratedWorldRegion/WaterBodies/OceanWater/VisualWater"
) as MeshInstance3D
assert(pond.material_override is StandardMaterial3D)
assert(not pond.visible)
assert(ocean.material_override is StandardMaterial3D)
assert(not pond.material_override is ShaderMaterial)
assert(not ocean.material_override is ShaderMaterial)
assert(ocean is WaterSurfaceMotion)
assert(not ocean.is_processing())
assert(is_zero_approx(ocean.position.y))
_validate_ocean_fishing_coverage(main, ocean)
var island := main.get_node(
"TestWorld/Regions/StarterIslandRegion"
) as StarterIslandRegion
assert(island != null and not island.is_foliage_wind_enabled())
assert(_count_foliage_wind_overrides(
island.get_node("Terrain/Visual")
) == 0)
var region := main.get_node(
"TestWorld/Regions/GeneratedWorldRegion"
) as GeneratedWorldRegion
assert(region != null)
assert(region.get_node("Decorations").get_child_count() > 0)
var title_background := main.get_node("%TitleBackground") as ColorRect
var title_material := title_background.material as ShaderMaterial
@ -143,24 +130,18 @@ func _validate_normal_profile(main: Node) -> void:
assert(is_equal_approx(root.scaling_3d_scale, 1.0))
var pixelation: Node = main.get_node("%WorldPixelationPostprocess")
assert(not bool(pixelation.call("is_light_performance_profile")))
var pond := main.get_node(
"TestWorld/Regions/StarterIslandRegion/WaterBodies/Pond/VisualWater"
) as MeshInstance3D
var pond := _first_fresh_water_visual(main)
var ocean := main.get_node(
"TestWorld/Regions/StarterIslandRegion/WaterBodies/Ocean/VisualWater"
"TestWorld/Regions/GeneratedWorldRegion/WaterBodies/OceanWater/VisualWater"
) as MeshInstance3D
assert(pond.material_override is ShaderMaterial)
assert(not pond.visible)
assert(ocean.material_override is ShaderMaterial)
assert(ocean is WaterSurfaceMotion)
assert(ocean.is_processing())
_validate_ocean_fishing_coverage(main, ocean)
var island := main.get_node(
"TestWorld/Regions/StarterIslandRegion"
) as StarterIslandRegion
assert(island != null and island.is_foliage_wind_enabled())
assert(_count_foliage_wind_overrides(
island.get_node("Terrain/Visual")
) > 0)
var region := main.get_node(
"TestWorld/Regions/GeneratedWorldRegion"
) as GeneratedWorldRegion
assert(region != null)
assert(region.get_node("Decorations").get_child_count() > 0)
var game_ui := main.get_node("%GameUI") as GameUI
var title_screen := game_ui.get_title_screen()
assert(title_screen != null)
@ -211,6 +192,14 @@ func _validate_normal_profile(main: Node) -> void:
)
func _first_fresh_water_visual(main: Node) -> MeshInstance3D:
var root := main.get_node(
"TestWorld/Regions/GeneratedWorldRegion/WaterBodies/FreshWaterBodies"
) as Node3D
assert(root != null and root.get_child_count() > 0)
return root.get_child(0).get_node("VisualWater") as MeshInstance3D
func _validate_ocean_fishing_coverage(
main: Node,
ocean: MeshInstance3D,
@ -218,34 +207,19 @@ func _validate_ocean_fishing_coverage(
var ocean_mesh := ocean.mesh as PlaneMesh
assert(ocean_mesh != null)
var shape_node := main.get_node(
"TestWorld/Regions/StarterIslandRegion/WaterBodies/Ocean/"
+ "FishingRegions/OceanFishingRegion/Shape"
"TestWorld/Regions/GeneratedWorldRegion/WaterBodies/OceanWater/"
+ "FishingRegion/Shape"
) as CollisionShape3D
assert(shape_node != null)
var fishing_shape := shape_node.shape as BoxShape3D
assert(fishing_shape != null)
assert(fishing_shape.size.is_equal_approx(Vector3(
ocean_mesh.size.x,
2.0,
4.0,
ocean_mesh.size.y,
)))
func _count_foliage_wind_overrides(root_node: Node) -> int:
var count: int = 0
var mesh_instance := root_node as MeshInstance3D
if mesh_instance != null and mesh_instance.mesh != null:
for surface_index: int in mesh_instance.mesh.get_surface_count():
var material := mesh_instance.get_surface_override_material(
surface_index
) as ShaderMaterial
if material != null and material.shader == FOLIAGE_WIND_SHADER:
count += 1
for child: Node in root_node.get_children():
count += _count_foliage_wind_overrides(child)
return count
func _stop_audio_players(root_node: Node) -> void:
if root_node is AudioStreamPlayer:
(root_node as AudioStreamPlayer).stop()

View file

@ -0,0 +1,81 @@
extends SceneTree
const CATALOG: TerrainChunkCatalog = preload(
"res://world/generation/chunks/terrain_chunk_catalog.tres"
)
const MATCH_TOLERANCE := 0.01
func _initialize() -> void:
call_deferred(&"_run")
func _run() -> void:
var variants: Array[TerrainChunkVariant] = []
for definition: TerrainChunkDefinition in CATALOG.definitions:
var seen: Dictionary[String, bool] = {}
for variant: TerrainChunkVariant in TerrainChunkAnalyzer.create_variants(
definition,
CATALOG.chunk_size,
):
var signature := variant.topology_signature(0.001)
if seen.has(signature):
continue
seen[signature] = true
variants.append(variant)
print("Terrain chunk variants: %d" % variants.size())
for variant: TerrainChunkVariant in variants:
print("\n", variant.stable_key())
for edge_value: int in TerrainChunkTopology.Edge.values():
var edge := edge_value as TerrainChunkTopology.Edge
var matches := PackedStringArray()
for other: TerrainChunkVariant in variants:
var opposite := TerrainChunkTopology.opposite_edge(edge)
if variant.profile(edge).matches(
other.profile(opposite),
MATCH_TOLERANCE,
):
matches.append(
"%s.%s"
% [
other.stable_key(),
TerrainChunkTopology.edge_name(opposite),
]
)
print(
" %s [%d points]: %s"
% [
TerrainChunkTopology.edge_name(edge),
variant.profile(edge).points.size(),
", ".join(matches) if not matches.is_empty() else "NONE",
]
)
var generator := TerrainChunkGenerator.new()
generator.catalog = CATALOG
generator.grid_size = Vector2i(5, 5)
generator.generation_seed = 13001
generator.generate_on_ready = false
generator.build_collision = false
generator.force_center_chunk_id = &"chunk_spawn"
generator.required_chunk_ids = PackedStringArray(
[
"chunk_spawn",
"chunk_0001",
"chunk_0002",
"chunk_0003",
"chunk_0004",
]
)
root.add_child(generator)
if generator.generate():
print("\nSeeded diagnostic layout:")
var keys := generator.placement_keys()
for row: int in generator.grid_size.y:
var cells := PackedStringArray()
for column: int in generator.grid_size.x:
cells.append(keys[row * generator.grid_size.x + column])
print(" ", " ".join(cells))
generator.free()
quit(0)

View file

@ -0,0 +1 @@
uid://di4yaor4f3s4e

View file

@ -0,0 +1,33 @@
extends Node3D
@onready var _generator: TerrainChunkGenerator = $TerrainChunkGenerator
@onready var _status: Label = $Instructions/Status
func _unhandled_input(event: InputEvent) -> void:
if event.is_action_pressed(&"ui_cancel"):
get_viewport().set_input_as_handled()
get_tree().quit()
return
var key_event := event as InputEventKey
if (
key_event != null
and key_event.pressed
and not key_event.echo
and key_event.physical_keycode == KEY_R
):
get_viewport().set_input_as_handled()
_generator.generation_seed += 1
_generator.generate()
func _on_generation_completed(summary: Dictionary) -> void:
_status.text = (
"Terrain generator diagnostic • seed %d%d chunks • "
+ "%d variants • %d backtracks\nR: regenerate • Esc/B: close"
) % [
int(summary["seed"]),
int(summary["chunk_count"]),
int(summary["variant_count"]),
int(summary["backtracks"]),
]

View file

@ -0,0 +1 @@
uid://0dmjswsm038h

View file

@ -0,0 +1,46 @@
[gd_scene load_steps=7 format=3]
[ext_resource type="Script" path="res://tests/manual/terrain_chunk_generator/terrain_chunk_generator_test.gd" id="1_test"]
[ext_resource type="Script" path="res://world/generation/terrain_chunk_generator.gd" id="2_generator"]
[ext_resource type="Resource" path="res://world/generation/chunks/terrain_chunk_catalog.tres" id="3_catalog"]
[ext_resource type="PackedScene" path="res://player/player.tscn" id="4_player"]
[ext_resource type="Environment" path="res://world/environment/netfishing_environment.tres" id="5_environment"]
[node name="TerrainChunkGeneratorTest" type="Node3D"]
script = ExtResource("1_test")
[node name="WorldEnvironment" type="WorldEnvironment" parent="."]
environment = ExtResource("5_environment")
[node name="Sun" type="DirectionalLight3D" parent="."]
rotation_degrees = Vector3(-54, -32, 0)
light_color = Color(0.88, 0.94, 0.96, 1)
light_energy = 1.0
shadow_enabled = true
[node name="TerrainChunkGenerator" type="Node3D" parent="."]
script = ExtResource("2_generator")
catalog = ExtResource("3_catalog")
grid_size = Vector2i(5, 5)
generation_seed = 13001
build_collision = true
show_chunk_labels = false
force_center_chunk_id = &"chunk_spawn"
required_chunk_ids = PackedStringArray("chunk_spawn", "chunk_0001", "chunk_0002", "chunk_0003", "chunk_0004")
[node name="Player" parent="." instance=ExtResource("4_player")]
position = Vector3(0, 0.3, 0)
[node name="Instructions" type="CanvasLayer" parent="."]
layer = 20
[node name="Status" type="Label" parent="Instructions"]
offset_left = 18.0
offset_top = 18.0
offset_right = 930.0
offset_bottom = 74.0
text = "Terrain generator diagnostic\nR: regenerate • Esc/B: close"
theme_override_colors/font_color = Color(0.76, 0.88, 0.9, 1)
theme_override_font_sizes/font_size = 18
[connection signal="generation_completed" from="TerrainChunkGenerator" to="." method="_on_generation_completed"]

View file

@ -143,11 +143,15 @@ func _validate_save_migration() -> void:
version_five,
5,
)
assert(int(migrated.get("save_version", -1)) == 8)
assert(int(migrated.get("save_version", -1)) == 9)
var experience_data: Dictionary = migrated.get("experience", {})
assert(int(experience_data.get("total_experience", -1)) == 0)
var world_data: Dictionary = migrated.get("world", {})
assert(is_equal_approx(float(world_data.get("time_hours", -1.0)), 8.0))
assert(
int(world_data.get("seed", 0))
== PlayerSaveManager.DEFAULT_WORLD_SEED
)
assert(
PlayerJobService.validate_save_data(migrated.get("jobs", {}))
)

View file

@ -0,0 +1,216 @@
extends SceneTree
const CATALOG: TerrainChunkCatalog = preload(
"res://world/generation/chunks/terrain_chunk_catalog.tres"
)
const EXPECTED_IDS: Array[String] = [
"chunk_0000",
"chunk_0001",
"chunk_0002",
"chunk_0003",
"chunk_0004",
"chunk_spawn",
]
const REQUIRED_IDS: Array[String] = [
"chunk_spawn",
"chunk_0001",
"chunk_0002",
"chunk_0003",
"chunk_0004",
]
const TEST_SEED := 13001
var _failures: Array[String] = []
func _initialize() -> void:
call_deferred(&"_run")
func _run() -> void:
_validate_catalog()
_validate_profiles()
_validate_generation()
_finish()
func _validate_catalog() -> void:
for error: String in CATALOG.validation_errors():
_check(false, error)
_check(
CATALOG.definitions.size() == EXPECTED_IDS.size(),
"Catalog must contain the authored chunks and spawn definition.",
)
for stable_id: String in EXPECTED_IDS:
var definition := CATALOG.definition_for_id(StringName(stable_id))
_check(definition != null, "Catalog is missing %s." % stable_id)
if definition == null:
continue
_check(
definition.packed_scene != null,
"%s must reference an imported GLB." % stable_id,
)
func _validate_profiles() -> void:
for definition: TerrainChunkDefinition in CATALOG.definitions:
var variants := TerrainChunkAnalyzer.create_variants(
definition,
CATALOG.chunk_size,
)
_check(
not variants.is_empty(),
"%s must produce at least one rotation variant."
% definition.stable_id,
)
for variant: TerrainChunkVariant in variants:
_check(
variant.edge_profiles.size() == 4,
"%s must expose four edge profiles." % variant.stable_key(),
)
for profile: TerrainChunkEdgeProfile in variant.edge_profiles:
_check(
not profile.points.is_empty(),
"%s has an empty edge profile." % variant.stable_key(),
)
func _validate_generation() -> void:
var first := _make_generator()
root.add_child(first)
var first_solved := first.generate()
_check(first_solved, "Generator must solve the prototype 5x5 grid.")
if not first_solved:
first.free()
return
var first_keys := first.placement_keys()
_check(first_keys.size() == 25, "Generator must place exactly 25 chunks.")
for stable_id: String in REQUIRED_IDS:
_check(
_placements_contain(first_keys, stable_id),
"Generated diagnostic must contain %s." % stable_id,
)
_check(
_all_neighbor_edges_match(first),
"Every generated neighboring edge must match.",
)
_check(
_count_placements(first_keys, "chunk_spawn") == 1,
"Generated layouts must contain exactly one spawn chunk.",
)
_check(
first_keys[12].begins_with("chunk_spawn@"),
"The spawn chunk must occupy the center cell.",
)
var second := _make_generator()
root.add_child(second)
var second_solved := second.generate()
_check(second_solved, "Repeated deterministic generation must solve.")
if not second_solved:
first.free()
second.free()
return
_check(
second.placement_keys() == first_keys,
"The same seed and catalog must produce the same layout.",
)
first.free()
second.free()
for seed_offset: int in range(1, 6):
var generator := _make_generator()
generator.generation_seed = TEST_SEED + seed_offset
root.add_child(generator)
var solved := generator.generate()
_check(
solved,
"Generator must solve seed %d." % generator.generation_seed,
)
if solved:
var keys := generator.placement_keys()
for stable_id: String in REQUIRED_IDS:
_check(
_placements_contain(keys, stable_id),
"Seed %d must contain %s."
% [generator.generation_seed, stable_id],
)
_check(
_all_neighbor_edges_match(generator),
"Seed %d has a mismatched neighboring edge."
% generator.generation_seed,
)
generator.free()
func _make_generator() -> TerrainChunkGenerator:
var generator := TerrainChunkGenerator.new()
generator.catalog = CATALOG
generator.grid_size = Vector2i(5, 5)
generator.generation_seed = TEST_SEED
generator.generate_on_ready = false
generator.build_collision = false
generator.force_center_chunk_id = &"chunk_spawn"
generator.required_chunk_ids = PackedStringArray(REQUIRED_IDS)
generator.maximum_backtracks = 100000
return generator
func _placements_contain(keys: PackedStringArray, stable_id: String) -> bool:
for key: String in keys:
if key.begins_with(stable_id + "@"):
return true
return false
func _count_placements(keys: PackedStringArray, stable_id: String) -> int:
var count := 0
for key: String in keys:
if key.begins_with(stable_id + "@"):
count += 1
return count
func _all_neighbor_edges_match(generator: TerrainChunkGenerator) -> bool:
for index: int in generator._placements.size():
var coordinate := Vector2i(
index % generator.grid_size.x,
index / generator.grid_size.x,
)
var current: TerrainChunkVariant = generator._placements[index]
if coordinate.x > 0:
var west: TerrainChunkVariant = generator._placements[index - 1]
if not generator._edges_are_compatible(
current,
TerrainChunkTopology.Edge.WEST,
west,
TerrainChunkTopology.Edge.EAST,
):
return false
if coordinate.y > 0:
var north: TerrainChunkVariant = (
generator._placements[index - generator.grid_size.x]
)
if not generator._edges_are_compatible(
current,
TerrainChunkTopology.Edge.NORTH,
north,
TerrainChunkTopology.Edge.SOUTH,
):
return false
return true
func _check(condition: bool, message: String) -> void:
if not condition:
_failures.append(message)
func _finish() -> void:
if _failures.is_empty():
print("Terrain chunk generator validation: PASS")
quit(0)
return
for failure: String in _failures:
printerr("Terrain chunk generator validation: ", failure)
quit(1)

View file

@ -0,0 +1 @@
uid://bt6pjgkomx76s

View file

@ -16,7 +16,7 @@ const CalendarSeasonType = preload("res://world/calendar_season.gd")
func _initialize() -> void:
assert(NetworkProtocol.PROTOCOL_VERSION == 8)
assert(NetworkProtocol.PROTOCOL_VERSION == 9)
assert(
NetworkWorldSpawnProtocol.CAPABILITY
== NetworkProtocol.WORLD_SPAWN_CAPABILITY

View file

@ -0,0 +1,438 @@
#!/usr/bin/env python3
"""Export convention-named terrain chunk collections to individual GLBs.
Run through Blender rather than a standalone Python interpreter:
blender --background terrain_chunks.blend \
--python tools/blender/export_terrain_chunks.py -- \
--output /path/to/terrain_chunks
Collections use ``chunk_####_description`` and contain one primary terrain
mesh named ``chunk_####``. Additional production objects may live in the same
collection; unrelated collections are ignored.
"""
from __future__ import annotations
import argparse
import hashlib
import json
import math
import re
import struct
import sys
from dataclasses import dataclass
from pathlib import Path
from typing import Iterable
import bpy
from mathutils import Vector
CHUNK_SIZE_METERS = 10.0
DEFAULT_TOLERANCE = 0.001
COLLECTION_PATTERN = re.compile(
r"^chunk_(?P<number>\d{4})(?:_(?P<label>[a-z0-9]+(?:_[a-z0-9]+)*))?$"
)
ALLOWED_OBJECT_TYPES = {"EMPTY", "MESH"}
class ExportValidationError(RuntimeError):
"""Raised when the source file does not satisfy the chunk convention."""
@dataclass(frozen=True)
class Bounds:
minimum: tuple[float, float, float]
maximum: tuple[float, float, float]
@property
def size(self) -> tuple[float, float, float]:
return tuple(
self.maximum[axis] - self.minimum[axis] for axis in range(3)
)
@property
def center(self) -> tuple[float, float, float]:
return tuple(
(self.minimum[axis] + self.maximum[axis]) * 0.5
for axis in range(3)
)
@dataclass(frozen=True)
class ChunkSource:
number: int
stable_id: str
label: str
collection: bpy.types.Collection
primary_mesh: bpy.types.Object
objects: tuple[bpy.types.Object, ...]
bounds: Bounds
def _parse_arguments() -> argparse.Namespace:
script_arguments: list[str] = []
if "--" in sys.argv:
script_arguments = sys.argv[sys.argv.index("--") + 1 :]
parser = argparse.ArgumentParser(
description="Export NETfishing terrain chunk collections to GLB files."
)
parser.add_argument(
"--output",
type=Path,
required=True,
help="Directory that will receive chunk_####.glb and chunks.json.",
)
parser.add_argument(
"--tolerance",
type=float,
default=DEFAULT_TOLERANCE,
help="World-space validation tolerance in meters.",
)
parser.add_argument(
"--dry-run",
action="store_true",
help="Validate and report chunks without writing output files.",
)
return parser.parse_args(script_arguments)
def _close_enough(actual: float, expected: float, tolerance: float) -> bool:
return math.isclose(actual, expected, rel_tol=0.0, abs_tol=tolerance)
def _format_vector(values: Iterable[float]) -> str:
return "(" + ", ".join(f"{value:.4f}" for value in values) + ")"
def _world_bounds(mesh_object: bpy.types.Object) -> Bounds:
evaluated_object = mesh_object.evaluated_get(
bpy.context.evaluated_depsgraph_get()
)
world_corners = [
evaluated_object.matrix_world @ Vector(corner)
for corner in evaluated_object.bound_box
]
minimum = tuple(
min(corner[axis] for corner in world_corners) for axis in range(3)
)
maximum = tuple(
max(corner[axis] for corner in world_corners) for axis in range(3)
)
return Bounds(minimum=minimum, maximum=maximum)
def _validate_transform(
mesh_object: bpy.types.Object,
tolerance: float,
) -> list[str]:
problems: list[str] = []
for axis, value in zip("XYZ", mesh_object.location):
if not _close_enough(value, 0.0, tolerance):
problems.append(f"location {axis} is {value:.6f}, expected 0")
for axis, value in zip("XYZ", mesh_object.rotation_euler):
if not _close_enough(value, 0.0, tolerance):
problems.append(f"rotation {axis} is {value:.6f}, expected 0")
for axis, value in zip("XYZ", mesh_object.scale):
if not _close_enough(value, 1.0, tolerance):
problems.append(f"scale {axis} is {value:.6f}, expected 1")
return problems
def _validate_bounds(bounds: Bounds, tolerance: float) -> list[str]:
problems: list[str] = []
expected_half_size = CHUNK_SIZE_METERS * 0.5
expected_values = {
"minimum X": (bounds.minimum[0], -expected_half_size),
"maximum X": (bounds.maximum[0], expected_half_size),
"minimum Y": (bounds.minimum[1], -expected_half_size),
"maximum Y": (bounds.maximum[1], expected_half_size),
}
for label, (actual, expected) in expected_values.items():
if not _close_enough(actual, expected, tolerance):
problems.append(f"{label} is {actual:.6f}, expected {expected:.6f}")
return problems
def _discover_chunks(tolerance: float) -> list[ChunkSource]:
chunks: list[ChunkSource] = []
errors: list[str] = []
claimed_objects: dict[str, str] = {}
claimed_numbers: dict[int, str] = {}
for collection in sorted(bpy.data.collections, key=lambda item: item.name):
match = COLLECTION_PATTERN.fullmatch(collection.name)
if match is None:
continue
number = int(match.group("number"))
stable_id = f"chunk_{number:04d}"
label = match.group("label") or ""
if number in claimed_numbers:
errors.append(
f"{collection.name}: duplicates numeric ID already used by "
f"{claimed_numbers[number]}"
)
continue
claimed_numbers[number] = collection.name
objects = tuple(sorted(collection.all_objects, key=lambda item: item.name))
if not objects:
errors.append(f"{collection.name}: collection is empty")
continue
invalid_objects = [
item for item in objects if item.type not in ALLOWED_OBJECT_TYPES
]
if invalid_objects:
details = ", ".join(
f"{item.name} ({item.type})" for item in invalid_objects
)
errors.append(
f"{collection.name}: contains unsupported objects: {details}"
)
primary_matches = [
item
for item in objects
if item.name == stable_id and item.type == "MESH"
]
if len(primary_matches) != 1:
errors.append(
f"{collection.name}: expected exactly one primary mesh named "
f"{stable_id}, found {len(primary_matches)}"
)
continue
primary_mesh = primary_matches[0]
for item in objects:
previous_collection = claimed_objects.get(item.name)
if previous_collection is not None:
errors.append(
f"{collection.name}: object {item.name} is also exported by "
f"{previous_collection}"
)
else:
claimed_objects[item.name] = collection.name
transform_problems = _validate_transform(primary_mesh, tolerance)
errors.extend(
f"{collection.name}/{primary_mesh.name}: {problem}"
for problem in transform_problems
)
bounds = _world_bounds(primary_mesh)
bound_problems = _validate_bounds(bounds, tolerance)
errors.extend(
f"{collection.name}/{primary_mesh.name}: {problem}"
for problem in bound_problems
)
if len(primary_mesh.data.vertices) < 3:
errors.append(
f"{collection.name}/{primary_mesh.name}: mesh has fewer than "
"three vertices"
)
if len(primary_mesh.data.materials) == 0:
errors.append(
f"{collection.name}/{primary_mesh.name}: mesh has no material"
)
chunks.append(
ChunkSource(
number=number,
stable_id=stable_id,
label=label,
collection=collection,
primary_mesh=primary_mesh,
objects=objects,
bounds=bounds,
)
)
if not chunks:
errors.append(
"No collections matched chunk_####_description (for example, "
"chunk_0000_grass)."
)
if errors:
raise ExportValidationError("\n".join(errors))
return sorted(chunks, key=lambda item: item.number)
def _select_chunk(chunk: ChunkSource) -> None:
if bpy.context.object is not None and bpy.context.object.mode != "OBJECT":
bpy.ops.object.mode_set(mode="OBJECT")
# Blender's selection operator can miss objects after a glTF export has
# temporarily changed visibility/context. Set every object explicitly so
# one chunk can never leak into a later chunk's selected-only export.
selected_objects = set(chunk.objects)
for item in bpy.context.view_layer.objects:
item.select_set(item in selected_objects)
for item in chunk.objects:
item.hide_select = False
item.hide_viewport = False
item.hide_set(False)
item.select_set(True)
bpy.context.view_layer.objects.active = chunk.primary_mesh
def _export_chunk(chunk: ChunkSource, output_path: Path) -> None:
_select_chunk(chunk)
result = bpy.ops.export_scene.gltf(
filepath=str(output_path),
check_existing=False,
export_format="GLB",
use_selection=True,
export_apply=True,
export_animations=False,
export_cameras=False,
export_lights=False,
export_materials="EXPORT",
export_morph=False,
export_normals=True,
export_skins=False,
export_tangents=False,
export_texcoords=True,
export_yup=True,
export_extras=True,
will_save_settings=False,
)
if result != {"FINISHED"}:
raise RuntimeError(
f"Blender failed to export {chunk.collection.name}: {result}"
)
_validate_exported_object_membership(chunk, output_path)
def _validate_exported_object_membership(
chunk: ChunkSource,
output_path: Path,
) -> None:
"""Confirm selected-only export did not leak objects from other chunks."""
with output_path.open("rb") as source:
header = source.read(20)
if len(header) != 20 or header[:4] != b"glTF":
raise RuntimeError(f"{output_path.name} is not a valid GLB file")
json_length, json_kind = struct.unpack_from("<II", header, 12)
if json_kind != 0x4E4F534A:
raise RuntimeError(
f"{output_path.name} does not begin with a GLB JSON chunk"
)
document = json.loads(source.read(json_length))
exported_names = {
str(node.get("name", ""))
for node in document.get("nodes", [])
if str(node.get("name", ""))
}
expected_names = {item.name for item in chunk.objects}
if exported_names != expected_names:
raise RuntimeError(
f"{output_path.name} object membership mismatch: expected "
f"{sorted(expected_names)}, exported {sorted(exported_names)}"
)
def _sha256(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as source:
for block in iter(lambda: source.read(1024 * 1024), b""):
digest.update(block)
return digest.hexdigest()
def _round_vector(values: Iterable[float]) -> list[float]:
return [round(value, 6) for value in values]
def _manifest_entry(chunk: ChunkSource, output_path: Path) -> dict[str, object]:
return {
"id": chunk.stable_id,
"number": chunk.number,
"label": chunk.label,
"collection": chunk.collection.name,
"primary_mesh": chunk.primary_mesh.name,
"objects": [item.name for item in chunk.objects],
"file": output_path.name,
"sha256": _sha256(output_path),
"bounds": {
"minimum": _round_vector(chunk.bounds.minimum),
"maximum": _round_vector(chunk.bounds.maximum),
"size": _round_vector(chunk.bounds.size),
"center": _round_vector(chunk.bounds.center),
},
}
def _run() -> int:
arguments = _parse_arguments()
if arguments.tolerance <= 0.0:
raise ExportValidationError("--tolerance must be greater than zero")
chunks = _discover_chunks(arguments.tolerance)
print(
f"Validated {len(chunks)} terrain chunks from "
f"{Path(bpy.data.filepath).resolve()}"
)
for chunk in chunks:
print(
f" {chunk.stable_id}: {chunk.label or '(no label)'} | "
f"bounds {_format_vector(chunk.bounds.minimum)} to "
f"{_format_vector(chunk.bounds.maximum)} | "
f"{len(chunk.objects)} object(s)"
)
if arguments.dry_run:
print("Dry run complete; no files written.")
return 0
output_directory = arguments.output.expanduser().resolve()
output_directory.mkdir(parents=True, exist_ok=True)
entries: list[dict[str, object]] = []
expected_files: set[str] = set()
for chunk in chunks:
output_path = output_directory / f"{chunk.stable_id}.glb"
_export_chunk(chunk, output_path)
expected_files.add(output_path.name)
entry = _manifest_entry(chunk, output_path)
entries.append(entry)
print(
f"Exported {output_path.name} | {output_path.stat().st_size} bytes | "
f"{entry['sha256']}"
)
manifest = {
"format_version": 1,
"chunk_size_meters": CHUNK_SIZE_METERS,
"source_blend": str(Path(bpy.data.filepath).resolve()),
"blender_version": bpy.app.version_string,
"chunks": entries,
}
manifest_path = output_directory / "chunks.json"
manifest_path.write_text(
json.dumps(manifest, indent=2, sort_keys=True) + "\n",
encoding="utf-8",
)
expected_files.add(manifest_path.name)
stale_files = sorted(
path.name
for path in output_directory.glob("chunk_*.glb")
if path.name not in expected_files
)
if stale_files:
print(
"WARNING: stale chunk outputs were retained: "
+ ", ".join(stale_files)
)
print(f"Wrote manifest {manifest_path}")
return 0
if __name__ == "__main__":
try:
raise SystemExit(_run())
except ExportValidationError as error:
print(f"EXPORT VALIDATION FAILED:\n{error}", file=sys.stderr)
raise SystemExit(2) from error

View file

@ -94,7 +94,7 @@ const INTRO_BRANDING_TRAVEL_DURATION: float = 2.0
const INTRO_BRANDING_START_DELAY: float = 0.35
const HOST_CLUSTER_SAFE_MARGIN: float = 24.0
signal new_game_requested
signal new_game_requested(world_seed: int)
signal continue_game_requested
signal quit_requested
signal join_game_requested(endpoint: String)
@ -147,6 +147,7 @@ enum ConfirmationAction {
var _save_manager: SaveManagerType
var _settings_manager: SettingsManagerType
var _inspection: SaveInspectionType
var _pending_new_game_seed: int = PlayerSaveManager.DEFAULT_WORLD_SEED
var _confirmation_action: ConfirmationAction = ConfirmationAction.NONE
var _action_in_progress: bool = false
var _decorative_rng := RandomNumberGenerator.new()
@ -1019,9 +1020,6 @@ func _on_new_game_pressed() -> void:
):
return
_hide_continue_stats_context()
if _inspection.status == SaveInspectionType.Status.MISSING:
new_game_requested.emit()
return
if _inspection.status == SaveInspectionType.Status.UNSUPPORTED_VERSION:
_feedback_label.text = _center_feedback_text(
"this save is from a newer game version. "
@ -1033,9 +1031,14 @@ func _on_new_game_pressed() -> void:
"the existing save cannot be accessed safely."
)
return
_pending_new_game_seed = PlayerSaveManager.roll_world_seed()
var warning := "start a new world?"
if _inspection.status != SaveInspectionType.Status.MISSING:
warning += " existing progression will be deleted."
warning += "\nworld seed: %d" % _pending_new_game_seed
_open_confirmation(
ConfirmationAction.NEW_GAME,
"start a new game? existing progression will be deleted.",
warning,
"start\nnew game",
true
)
@ -1073,7 +1076,7 @@ func _on_confirmation_accepted() -> void:
_confirmation_transition_generation += 1
_cancel_confirmation_transition()
_confirmation_page.hide_page()
new_game_requested.emit()
new_game_requested.emit(_pending_new_game_seed)
_action_in_progress = false
return
if not _save_manager.delete_progression_save():

Binary file not shown.

View file

@ -0,0 +1,45 @@
[remap]
importer="scene"
importer_version=1
type="PackedScene"
uid="uid://jv50rtyjn3j1"
path="res://.godot/imported/chunk_0000.glb-8221861eeebadbe0547c1162b0dc4018.scn"
[deps]
source_file="res://world/generation/chunks/assets/chunk_0000.glb"
dest_files=["res://.godot/imported/chunk_0000.glb-8221861eeebadbe0547c1162b0dc4018.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.

After

Width:  |  Height:  |  Size: 6.2 KiB

View file

@ -0,0 +1,45 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://doju2lbj2ghwa"
path.s3tc="res://.godot/imported/chunk_0000_grass_lite.png-12306f832f5f55ac4ec98276bdf209b9.s3tc.ctex"
path.etc2="res://.godot/imported/chunk_0000_grass_lite.png-12306f832f5f55ac4ec98276bdf209b9.etc2.ctex"
metadata={
"imported_formats": ["s3tc_bptc", "etc2_astc"],
"vram_texture": true
}
generator_parameters={
"md5": "4dc1ac400d85d9b3e4f43a5af0a38ed4"
}
[deps]
source_file="res://world/generation/chunks/assets/chunk_0000_grass_lite.png"
dest_files=["res://.godot/imported/chunk_0000_grass_lite.png-12306f832f5f55ac4ec98276bdf209b9.s3tc.ctex", "res://.godot/imported/chunk_0000_grass_lite.png-12306f832f5f55ac4ec98276bdf209b9.etc2.ctex"]
[params]
compress/mode=2
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=0

Binary file not shown.

View file

@ -0,0 +1,45 @@
[remap]
importer="scene"
importer_version=1
type="PackedScene"
uid="uid://uxb30e8bfw5v"
path="res://.godot/imported/chunk_0001.glb-a498b960fb79406faee2341d1edecb1a.scn"
[deps]
source_file="res://world/generation/chunks/assets/chunk_0001.glb"
dest_files=["res://.godot/imported/chunk_0001.glb-a498b960fb79406faee2341d1edecb1a.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.

After

Width:  |  Height:  |  Size: 6.1 KiB

View file

@ -0,0 +1,45 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://c3s1ebrqrsylj"
path.s3tc="res://.godot/imported/chunk_0001_sand.png-67653175c1ccdaa529250778f806d3f8.s3tc.ctex"
path.etc2="res://.godot/imported/chunk_0001_sand.png-67653175c1ccdaa529250778f806d3f8.etc2.ctex"
metadata={
"imported_formats": ["s3tc_bptc", "etc2_astc"],
"vram_texture": true
}
generator_parameters={
"md5": "adfa66b8fe9da7f3450d3ddfd432b86a"
}
[deps]
source_file="res://world/generation/chunks/assets/chunk_0001_sand.png"
dest_files=["res://.godot/imported/chunk_0001_sand.png-67653175c1ccdaa529250778f806d3f8.s3tc.ctex", "res://.godot/imported/chunk_0001_sand.png-67653175c1ccdaa529250778f806d3f8.etc2.ctex"]
[params]
compress/mode=2
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=0

Binary file not shown.

View file

@ -0,0 +1,45 @@
[remap]
importer="scene"
importer_version=1
type="PackedScene"
uid="uid://dgd530dol7mfd"
path="res://.godot/imported/chunk_0002.glb-0a0d64db0049184f39957f77063387f4.scn"
[deps]
source_file="res://world/generation/chunks/assets/chunk_0002.glb"
dest_files=["res://.godot/imported/chunk_0002.glb-0a0d64db0049184f39957f77063387f4.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.

After

Width:  |  Height:  |  Size: 6.2 KiB

View file

@ -0,0 +1,45 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://delam1fb8dqwx"
path.s3tc="res://.godot/imported/chunk_0002_grass_lite.png-c1692a06eb751734c5b996f92a9ca1af.s3tc.ctex"
path.etc2="res://.godot/imported/chunk_0002_grass_lite.png-c1692a06eb751734c5b996f92a9ca1af.etc2.ctex"
metadata={
"imported_formats": ["s3tc_bptc", "etc2_astc"],
"vram_texture": true
}
generator_parameters={
"md5": "4dc1ac400d85d9b3e4f43a5af0a38ed4"
}
[deps]
source_file="res://world/generation/chunks/assets/chunk_0002_grass_lite.png"
dest_files=["res://.godot/imported/chunk_0002_grass_lite.png-c1692a06eb751734c5b996f92a9ca1af.s3tc.ctex", "res://.godot/imported/chunk_0002_grass_lite.png-c1692a06eb751734c5b996f92a9ca1af.etc2.ctex"]
[params]
compress/mode=2
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=0

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

View file

@ -0,0 +1,45 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://bjsjdf3lknj1g"
path.s3tc="res://.godot/imported/chunk_0002_sand.png-044d6a274c3b25b166e2b43f026482ed.s3tc.ctex"
path.etc2="res://.godot/imported/chunk_0002_sand.png-044d6a274c3b25b166e2b43f026482ed.etc2.ctex"
metadata={
"imported_formats": ["s3tc_bptc", "etc2_astc"],
"vram_texture": true
}
generator_parameters={
"md5": "adfa66b8fe9da7f3450d3ddfd432b86a"
}
[deps]
source_file="res://world/generation/chunks/assets/chunk_0002_sand.png"
dest_files=["res://.godot/imported/chunk_0002_sand.png-044d6a274c3b25b166e2b43f026482ed.s3tc.ctex", "res://.godot/imported/chunk_0002_sand.png-044d6a274c3b25b166e2b43f026482ed.etc2.ctex"]
[params]
compress/mode=2
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=0

Binary file not shown.

View file

@ -0,0 +1,45 @@
[remap]
importer="scene"
importer_version=1
type="PackedScene"
uid="uid://2wl647bi18v5"
path="res://.godot/imported/chunk_0003.glb-913e99fdd0f30f24832fe5a7c1b7b865.scn"
[deps]
source_file="res://world/generation/chunks/assets/chunk_0003.glb"
dest_files=["res://.godot/imported/chunk_0003.glb-913e99fdd0f30f24832fe5a7c1b7b865.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.

After

Width:  |  Height:  |  Size: 5.5 KiB

View file

@ -0,0 +1,45 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://614ano6tj7ny"
path.s3tc="res://.godot/imported/chunk_0003_dirt.png-aec7f6dc456d2c2fe0bb22bdba6b692f.s3tc.ctex"
path.etc2="res://.godot/imported/chunk_0003_dirt.png-aec7f6dc456d2c2fe0bb22bdba6b692f.etc2.ctex"
metadata={
"imported_formats": ["s3tc_bptc", "etc2_astc"],
"vram_texture": true
}
generator_parameters={
"md5": "07d90b3c46a4c3d0c172bed143dc9436"
}
[deps]
source_file="res://world/generation/chunks/assets/chunk_0003_dirt.png"
dest_files=["res://.godot/imported/chunk_0003_dirt.png-aec7f6dc456d2c2fe0bb22bdba6b692f.s3tc.ctex", "res://.godot/imported/chunk_0003_dirt.png-aec7f6dc456d2c2fe0bb22bdba6b692f.etc2.ctex"]
[params]
compress/mode=2
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=0

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

View file

@ -0,0 +1,45 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://c2votr034k2g3"
path.s3tc="res://.godot/imported/chunk_0003_dirt_wall.png-19ae8be57bf9c33db0df849cb088b83c.s3tc.ctex"
path.etc2="res://.godot/imported/chunk_0003_dirt_wall.png-19ae8be57bf9c33db0df849cb088b83c.etc2.ctex"
metadata={
"imported_formats": ["s3tc_bptc", "etc2_astc"],
"vram_texture": true
}
generator_parameters={
"md5": "b5430dba0b52db87d30bae85ae640b09"
}
[deps]
source_file="res://world/generation/chunks/assets/chunk_0003_dirt_wall.png"
dest_files=["res://.godot/imported/chunk_0003_dirt_wall.png-19ae8be57bf9c33db0df849cb088b83c.s3tc.ctex", "res://.godot/imported/chunk_0003_dirt_wall.png-19ae8be57bf9c33db0df849cb088b83c.etc2.ctex"]
[params]
compress/mode=2
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=0

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

View file

@ -0,0 +1,45 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://cynuisx0jdsq5"
path.s3tc="res://.godot/imported/chunk_0003_grass_lite.png-4a0a167fc9e6752477003141049a1d21.s3tc.ctex"
path.etc2="res://.godot/imported/chunk_0003_grass_lite.png-4a0a167fc9e6752477003141049a1d21.etc2.ctex"
metadata={
"imported_formats": ["s3tc_bptc", "etc2_astc"],
"vram_texture": true
}
generator_parameters={
"md5": "4dc1ac400d85d9b3e4f43a5af0a38ed4"
}
[deps]
source_file="res://world/generation/chunks/assets/chunk_0003_grass_lite.png"
dest_files=["res://.godot/imported/chunk_0003_grass_lite.png-4a0a167fc9e6752477003141049a1d21.s3tc.ctex", "res://.godot/imported/chunk_0003_grass_lite.png-4a0a167fc9e6752477003141049a1d21.etc2.ctex"]
[params]
compress/mode=2
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=0

Binary file not shown.

View file

@ -0,0 +1,45 @@
[remap]
importer="scene"
importer_version=1
type="PackedScene"
uid="uid://codcyn5ta6aj"
path="res://.godot/imported/chunk_0004.glb-0ea01a6391edb5f687fee1991ba71993.scn"
[deps]
source_file="res://world/generation/chunks/assets/chunk_0004.glb"
dest_files=["res://.godot/imported/chunk_0004.glb-0ea01a6391edb5f687fee1991ba71993.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.

After

Width:  |  Height:  |  Size: 5.5 KiB

View file

@ -0,0 +1,45 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://cdpfqe4vn7235"
path.s3tc="res://.godot/imported/chunk_0004_dirt.png-9c64a7ac5c9e75161623be94b3197f43.s3tc.ctex"
path.etc2="res://.godot/imported/chunk_0004_dirt.png-9c64a7ac5c9e75161623be94b3197f43.etc2.ctex"
metadata={
"imported_formats": ["s3tc_bptc", "etc2_astc"],
"vram_texture": true
}
generator_parameters={
"md5": "07d90b3c46a4c3d0c172bed143dc9436"
}
[deps]
source_file="res://world/generation/chunks/assets/chunk_0004_dirt.png"
dest_files=["res://.godot/imported/chunk_0004_dirt.png-9c64a7ac5c9e75161623be94b3197f43.s3tc.ctex", "res://.godot/imported/chunk_0004_dirt.png-9c64a7ac5c9e75161623be94b3197f43.etc2.ctex"]
[params]
compress/mode=2
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=0

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

View file

@ -0,0 +1,45 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://cm42r4jmqis7h"
path.s3tc="res://.godot/imported/chunk_0004_dirt_wall.png-8cbb5921c02a9dc6be9e3fff14c91409.s3tc.ctex"
path.etc2="res://.godot/imported/chunk_0004_dirt_wall.png-8cbb5921c02a9dc6be9e3fff14c91409.etc2.ctex"
metadata={
"imported_formats": ["s3tc_bptc", "etc2_astc"],
"vram_texture": true
}
generator_parameters={
"md5": "b5430dba0b52db87d30bae85ae640b09"
}
[deps]
source_file="res://world/generation/chunks/assets/chunk_0004_dirt_wall.png"
dest_files=["res://.godot/imported/chunk_0004_dirt_wall.png-8cbb5921c02a9dc6be9e3fff14c91409.s3tc.ctex", "res://.godot/imported/chunk_0004_dirt_wall.png-8cbb5921c02a9dc6be9e3fff14c91409.etc2.ctex"]
[params]
compress/mode=2
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=0

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

View file

@ -0,0 +1,45 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://kvyeovkwx72h"
path.s3tc="res://.godot/imported/chunk_0004_grass_lite.png-152b0775592d1fbccc26c10057d15004.s3tc.ctex"
path.etc2="res://.godot/imported/chunk_0004_grass_lite.png-152b0775592d1fbccc26c10057d15004.etc2.ctex"
metadata={
"imported_formats": ["s3tc_bptc", "etc2_astc"],
"vram_texture": true
}
generator_parameters={
"md5": "4dc1ac400d85d9b3e4f43a5af0a38ed4"
}
[deps]
source_file="res://world/generation/chunks/assets/chunk_0004_grass_lite.png"
dest_files=["res://.godot/imported/chunk_0004_grass_lite.png-152b0775592d1fbccc26c10057d15004.s3tc.ctex", "res://.godot/imported/chunk_0004_grass_lite.png-152b0775592d1fbccc26c10057d15004.etc2.ctex"]
[params]
compress/mode=2
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=0

View file

@ -0,0 +1,14 @@
[gd_resource type="Resource" script_class="TerrainChunkDefinition" load_steps=3 format=3]
[ext_resource type="Script" path="res://world/generation/terrain_chunk_definition.gd" id="1_definition"]
[ext_resource type="PackedScene" path="res://world/generation/chunks/assets/chunk_0000.glb" id="2_scene"]
[resource]
script = ExtResource("1_definition")
stable_id = &"chunk_0000"
label = "grass"
packed_scene = ExtResource("2_scene")
primary_mesh_name = &"chunk_0000"
allowed_rotation_mask = 15
selection_weight = 4.0
tags = PackedStringArray("land", "grass", "flat")

View file

@ -0,0 +1,14 @@
[gd_resource type="Resource" script_class="TerrainChunkDefinition" load_steps=3 format=3]
[ext_resource type="Script" path="res://world/generation/terrain_chunk_definition.gd" id="1_definition"]
[ext_resource type="PackedScene" path="res://world/generation/chunks/assets/chunk_0001.glb" id="2_scene"]
[resource]
script = ExtResource("1_definition")
stable_id = &"chunk_0001"
label = "sand"
packed_scene = ExtResource("2_scene")
primary_mesh_name = &"chunk_0001"
allowed_rotation_mask = 15
selection_weight = 1.5
tags = PackedStringArray("land", "sand", "flat")

View file

@ -0,0 +1,14 @@
[gd_resource type="Resource" script_class="TerrainChunkDefinition" format=3 uid="uid://b2ha4ekenoiaj"]
[ext_resource type="Script" uid="uid://ck6m4auf5epde" path="res://world/generation/terrain_chunk_definition.gd" id="1_definition"]
[ext_resource type="PackedScene" uid="uid://dgd530dol7mfd" path="res://world/generation/chunks/assets/chunk_0002.glb" id="2_scene"]
[resource]
script = ExtResource("1_definition")
stable_id = &"chunk_0002"
label = "beach"
packed_scene = ExtResource("2_scene")
primary_mesh_name = &"chunk_0002"
selection_weight = 0.75
maximum_placements = 10
tags = PackedStringArray("land", "sand", "coast", "slope")

View file

@ -0,0 +1,17 @@
[gd_resource type="Resource" script_class="TerrainChunkDefinition" format=3 uid="uid://dl7tsxml6g6na"]
[ext_resource type="Script" uid="uid://ck6m4auf5epde" path="res://world/generation/terrain_chunk_definition.gd" id="1_definition"]
[ext_resource type="PackedScene" uid="uid://2wl647bi18v5" path="res://world/generation/chunks/assets/chunk_0003.glb" id="2_scene"]
[resource]
script = ExtResource("1_definition")
stable_id = &"chunk_0003"
label = "stream"
packed_scene = ExtResource("2_scene")
primary_mesh_name = &"chunk_0003"
selection_weight = 0.6
tags = PackedStringArray("land", "fresh_water", "river", "flowing")
water_inlet_edges = 1
water_outlet_edges = 4
water_surface_size = Vector2(2.1, 10)
water_surface_offset = Vector2(-0.315, 0)

View file

@ -0,0 +1,16 @@
[gd_resource type="Resource" script_class="TerrainChunkDefinition" format=3 uid="uid://duotyi05ua5gp"]
[ext_resource type="Script" uid="uid://ck6m4auf5epde" path="res://world/generation/terrain_chunk_definition.gd" id="1_definition"]
[ext_resource type="PackedScene" uid="uid://codcyn5ta6aj" path="res://world/generation/chunks/assets/chunk_0004.glb" id="2_scene"]
[resource]
script = ExtResource("1_definition")
stable_id = &"chunk_0004"
label = "pond"
packed_scene = ExtResource("2_scene")
primary_mesh_name = &"chunk_0004"
selection_weight = 0.35
tags = PackedStringArray("land", "fresh_water", "pond")
water_inlet_edges = 1
water_surface_size = Vector2(6.7, 7.75)
water_surface_offset = Vector2(-0.32, -1.03)

View file

@ -0,0 +1,15 @@
[gd_resource type="Resource" script_class="TerrainChunkDefinition" load_steps=3 format=3]
[ext_resource type="Script" path="res://world/generation/terrain_chunk_definition.gd" id="1_definition"]
[ext_resource type="PackedScene" path="res://world/generation/chunks/assets/chunk_0000.glb" id="2_scene"]
[resource]
script = ExtResource("1_definition")
stable_id = &"chunk_spawn"
label = "spawn"
packed_scene = ExtResource("2_scene")
primary_mesh_name = &"chunk_0000"
allowed_rotation_mask = 1
selection_weight = 0.01
maximum_placements = 1
tags = PackedStringArray("land", "grass", "flat", "spawn")

View file

@ -0,0 +1,15 @@
[gd_resource type="Resource" script_class="TerrainChunkCatalog" load_steps=9 format=3]
[ext_resource type="Script" path="res://world/generation/terrain_chunk_catalog.gd" id="1_catalog"]
[ext_resource type="Resource" path="res://world/generation/chunks/definitions/chunk_0000.tres" id="2_grass"]
[ext_resource type="Resource" path="res://world/generation/chunks/definitions/chunk_0001.tres" id="3_sand"]
[ext_resource type="Resource" path="res://world/generation/chunks/definitions/chunk_0002.tres" id="4_beach"]
[ext_resource type="Resource" path="res://world/generation/chunks/definitions/chunk_0003.tres" id="5_stream"]
[ext_resource type="Resource" path="res://world/generation/chunks/definitions/chunk_0004.tres" id="6_pond"]
[ext_resource type="Script" path="res://world/generation/terrain_chunk_definition.gd" id="7_definition_script"]
[ext_resource type="Resource" path="res://world/generation/chunks/definitions/chunk_spawn.tres" id="8_spawn"]
[resource]
script = ExtResource("1_catalog")
chunk_size = 10.0
definitions = Array[ExtResource("7_definition_script")]([ExtResource("2_grass"), ExtResource("3_sand"), ExtResource("4_beach"), ExtResource("5_stream"), ExtResource("6_pond"), ExtResource("8_spawn")])

View file

@ -0,0 +1,507 @@
class_name GeneratedWorldRegion
extends WorldRegion
signal world_generated(seed: int, summary: Dictionary)
const FishingShopInteractionType = preload(
"res://world/fishing_shop_interaction.gd"
)
const PlayerStorageInteractionType = preload(
"res://world/player_storage_interaction.gd"
)
const PROP_SOURCE: PackedScene = preload(
"res://art/exported/environment/terrain/starter_island.glb"
)
const WATER_BODY_SCENE: PackedScene = preload("res://world/water_body.tscn")
const SALT_WATER_MATERIAL: Material = preload(
"res://world/materials/stylized_water.tres"
)
const FRESH_WATER_MATERIAL: Material = preload(
"res://world/materials/stylized_water_fresh.tres"
)
const POND_POOL: FishPool = preload(
"res://fish/pools/starter_pond_pool.tres"
)
const OCEAN_POOL: FishPool = preload(
"res://fish/pools/starter_ocean_pool.tres"
)
const TREE_SOURCE_NAME := "tree_2_001"
const PALM_SOURCE_NAME := "plam_tree"
const TREE_RUNTIME_NAME := "regular_tree"
const PALM_RUNTIME_NAME := "palm_tree"
const WATER_HEIGHT := -0.25
const PROP_EDGE_MARGIN := 2.2
const TREE_CHANCE := 0.58
const PALM_CHANCE := 0.42
@export var initial_seed := PlayerSaveManager.DEFAULT_WORLD_SEED
@onready var _generator: TerrainChunkGenerator = %TerrainChunkGenerator
@onready var _player_spawn: Marker3D = %PlayerSpawn
@onready var _safe_spawn: SafeRespawnPoint = %SafeSpawn
@onready var _fishing_shop: FishingShopInteractionType = (
$Interactables/FishingShopWorld/InteractionArea
)
@onready var _player_storage: PlayerStorageInteractionType = (
$Interactables/PlayerStorageBox/InteractionArea
)
@onready var _shop_root: Node3D = %FishingShopWorld
@onready var _storage_root: Node3D = %PlayerStorageBox
@onready var _decorations: Node3D = %Decorations
@onready var _tree_anchors: GatherableAnchorSet3D = %ReachableTreeTrunks
@onready var _diggable_beach: DiggableArea3D = %DiggableBeach
@onready var _ocean: WaterBodyAuthoring = %OceanWater
@onready var _fresh_water_root: Node3D = %FreshWaterBodies
@onready var _shoreline_reference: MeshInstance3D = %ShorelineReference
var _current_seed := PlayerSaveManager.DEFAULT_WORLD_SEED
var _light_performance_profile := false
var _source_meshes: Dictionary[String, Mesh] = {}
var _source_bases: Dictionary[String, Basis] = {}
var _source_minimum_y: Dictionary[String, float] = {}
func _ready() -> void:
_generator.generation_completed.connect(_on_generation_completed)
_cache_prop_source(TREE_SOURCE_NAME)
_cache_prop_source(PALM_SOURCE_NAME)
_configure_static_water()
_build_shoreline_reference()
if not generate_world(initial_seed):
push_error("The initial generated world could not be built.")
func generate_world(seed: int) -> bool:
if seed <= 0 or seed > PlayerSaveManager.MAX_WORLD_SEED:
return false
_current_seed = seed
_generator.generation_seed = seed
return _generator.generate()
func get_generation_seed() -> int:
return _current_seed
func get_playable_half_extents() -> Vector2:
var size := Vector2(
float(_generator.grid_size.x),
float(_generator.grid_size.y),
) * _generator.catalog.chunk_size
return size * 0.5
func get_player_spawn_transform() -> Transform3D:
return _player_spawn.global_transform
func get_fishing_shop() -> FishingShopInteractionType:
return _fishing_shop
func get_player_storage() -> PlayerStorageInteractionType:
return _player_storage
func get_saltwater_shoreline_mesh() -> MeshInstance3D:
return _shoreline_reference
func set_light_performance_profile(enabled: bool) -> void:
_light_performance_profile = enabled
_apply_water_materials()
func get_spawn_surface_triangles(
material_names: Array[StringName],
minimum_global_y: float,
minimum_up_dot: float = 0.6,
) -> Array[PackedVector3Array]:
var triangles: Array[PackedVector3Array] = []
var root: Node3D = _generator.get_generated_chunks_root()
if root == null or material_names.is_empty():
return triangles
for mesh_instance: MeshInstance3D in _collect_mesh_instances(root):
var mesh: Mesh = mesh_instance.mesh
if mesh == null:
continue
for surface_index: int in mesh.get_surface_count():
var material: Material = mesh_instance.get_active_material(surface_index)
if material == null or not material_names.has(
StringName(material.resource_name)
):
continue
_append_surface_triangles(
triangles,
mesh_instance,
mesh.surface_get_arrays(surface_index),
minimum_global_y,
minimum_up_dot,
)
return triangles
func _on_generation_completed(summary: Dictionary) -> void:
var records: Array[Dictionary] = _generator.placement_records()
var spawn_position := Vector3.ZERO
_clear_children(_decorations)
_clear_children(_tree_anchors)
var random := RandomNumberGenerator.new()
random.seed = _current_seed ^ 0x5EED71
var grass_centers: Array[Vector3] = []
var sand_centers: Array[Vector3] = []
var tree_count := 0
var palm_count := 0
for record: Dictionary in records:
var position: Vector3 = record.get("position", Vector3.ZERO)
var tags: PackedStringArray = record.get(
"tags", PackedStringArray()
)
if "spawn" in tags:
spawn_position = position
if "spawn" not in tags and "grass" in tags:
grass_centers.append(position)
if _maybe_add_prop(
TREE_SOURCE_NAME,
TREE_RUNTIME_NAME,
position,
TREE_CHANCE,
random,
true,
):
tree_count += 1
elif "sand" in tags:
sand_centers.append(position)
if _maybe_add_prop(
PALM_SOURCE_NAME,
PALM_RUNTIME_NAME,
position,
PALM_CHANCE,
random,
false,
):
palm_count += 1
if tree_count == 0 and not grass_centers.is_empty():
_add_prop(
TREE_SOURCE_NAME,
TREE_RUNTIME_NAME,
grass_centers[random.randi_range(0, grass_centers.size() - 1)],
random,
true,
)
if palm_count == 0 and not sand_centers.is_empty():
_add_prop(
PALM_SOURCE_NAME,
PALM_RUNTIME_NAME,
sand_centers[random.randi_range(0, sand_centers.size() - 1)],
random,
false,
)
_place_spawn_amenities(spawn_position)
_configure_fresh_water(records)
_configure_diggable_area()
world_generated.emit(_current_seed, summary)
func _place_spawn_amenities(center: Vector3) -> void:
_player_spawn.position = center + Vector3(0.0, 0.18, 2.2)
_safe_spawn.position = _player_spawn.position
_shop_root.position = center + Vector3(2.35, 0.0, -1.35)
_shop_root.rotation.y = PI
_storage_root.position = center + Vector3(-2.4, 0.0, 1.2)
_storage_root.rotation.y = PI * 0.5
func _configure_static_water() -> void:
_ocean.water_type = WaterType.Type.SALT_WATER
_ocean.fish_pool = OCEAN_POOL
_ocean.location_tags = [&"coast", &"ocean", &"generated_ocean"]
_ocean.surface_size = Vector2(10000.0, 10000.0)
_ocean.position.y = WATER_HEIGHT - 0.025
_apply_water_materials()
func _configure_fresh_water(records: Array[Dictionary]) -> void:
_clear_children(_fresh_water_root)
for record: Dictionary in records:
var tags: PackedStringArray = record.get(
"tags", PackedStringArray()
)
if "fresh_water" not in tags:
continue
var surface_size: Vector2 = record.get(
"water_surface_size", Vector2.ZERO
)
if surface_size.x <= 0.0 or surface_size.y <= 0.0:
continue
var body := WATER_BODY_SCENE.instantiate() as WaterBodyAuthoring
if body == null:
continue
var coordinate: Vector2i = record.get("coordinate", Vector2i.ZERO)
body.name = "FreshWater_%d_%d" % [coordinate.x, coordinate.y]
_fresh_water_root.add_child(body)
var turns := int(record.get("rotation_quarters", 0))
var angle := float(posmod(turns, 4)) * PI * 0.5
var offset: Vector2 = record.get(
"water_surface_offset", Vector2.ZERO
)
var rotated_offset := Vector3(offset.x, 0.0, offset.y).rotated(
Vector3.UP,
angle,
)
body.position = (
record.get("position", Vector3.ZERO)
+ rotated_offset
+ Vector3.UP * WATER_HEIGHT
)
body.rotation.y = angle
body.surface_size = surface_size
body.visual_surface_enabled = false
body.water_material = _fresh_water_material()
body.water_type = WaterType.Type.FRESH_WATER
body.fish_pool = POND_POOL
body.location_tags = _fresh_water_location_tags(tags)
body.selection_priority = 10
func _fresh_water_location_tags(tags: PackedStringArray) -> Array[StringName]:
var result: Array[StringName] = [&"generated_fresh_water"]
if "pond" in tags:
result.append(&"pond")
if "river" in tags:
result.append(&"river")
return result
func _configure_diggable_area() -> void:
var half_extents := get_playable_half_extents()
_diggable_beach.generation_bounds = Rect2(
-half_extents,
half_extents * 2.0,
)
func _apply_water_materials() -> void:
if not is_node_ready():
return
if _light_performance_profile:
_ocean.water_material = _light_water_material(
Color(0.11, 0.345, 0.435),
)
else:
_ocean.water_material = SALT_WATER_MATERIAL
for child: Node in _fresh_water_root.get_children():
var body := child as WaterBodyAuthoring
if body != null:
body.water_material = _fresh_water_material()
func _fresh_water_material() -> Material:
if _light_performance_profile:
return _light_water_material(Color(0.18, 0.46, 0.50))
return FRESH_WATER_MATERIAL
func _light_water_material(color: Color) -> StandardMaterial3D:
var material := StandardMaterial3D.new()
material.albedo_color = color
material.roughness = 1.0
material.shading_mode = BaseMaterial3D.SHADING_MODE_PER_VERTEX
material.texture_filter = BaseMaterial3D.TEXTURE_FILTER_NEAREST
return material
func _maybe_add_prop(
source_name: String,
runtime_name: String,
chunk_center: Vector3,
chance: float,
random: RandomNumberGenerator,
add_anchor: bool,
) -> bool:
if random.randf() > chance or not _source_meshes.has(source_name):
return false
_add_prop(
source_name,
runtime_name,
chunk_center,
random,
add_anchor,
)
return true
func _add_prop(
source_name: String,
runtime_name: String,
chunk_center: Vector3,
random: RandomNumberGenerator,
add_anchor: bool,
) -> void:
if not _source_meshes.has(source_name):
return
var prop := Node3D.new()
prop.name = "%s_%d" % [runtime_name, _decorations.get_child_count()]
var offset := Vector3(
random.randf_range(-PROP_EDGE_MARGIN, PROP_EDGE_MARGIN),
0.0,
random.randf_range(-PROP_EDGE_MARGIN, PROP_EDGE_MARGIN),
)
prop.position = chunk_center + offset
prop.rotation.y = random.randf_range(-PI, PI)
_decorations.add_child(prop)
var visual := MeshInstance3D.new()
visual.name = "Visual"
visual.mesh = _source_meshes[source_name]
visual.basis = _source_bases[source_name]
visual.position.y = -_source_minimum_y.get(source_name, 0.0)
visual.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
prop.add_child(visual)
_add_prop_collision(prop, source_name == TREE_SOURCE_NAME)
if add_anchor:
var anchor := Marker3D.new()
anchor.name = "TreeAnchor_%d" % _tree_anchors.get_child_count()
anchor.position = prop.position + Vector3(0.0, 2.15, 0.0)
_tree_anchors.add_child(anchor)
func _add_prop_collision(prop: Node3D, broad_tree: bool) -> void:
var body := StaticBody3D.new()
body.name = "TrunkCollision"
body.collision_layer = 1
body.collision_mask = 0
prop.add_child(body)
var collision := CollisionShape3D.new()
collision.name = "CollisionShape"
var shape := CylinderShape3D.new()
shape.radius = 0.5 if broad_tree else 0.35
shape.height = 4.2 if broad_tree else 4.8
collision.position.y = shape.height * 0.5
collision.shape = shape
body.add_child(collision)
func _cache_prop_source(source_name: String) -> void:
var source_root := PROP_SOURCE.instantiate()
var source := source_root.find_child(
source_name,
true,
false,
) as MeshInstance3D
if source != null and source.mesh != null:
_source_meshes[source_name] = source.mesh
_source_bases[source_name] = source.transform.basis
_source_minimum_y[source_name] = _minimum_transformed_mesh_y(
source.mesh,
source.transform.basis,
)
else:
push_warning("Generated terrain prop source '%s' is unavailable." % source_name)
source_root.free()
func _minimum_transformed_mesh_y(mesh: Mesh, basis: Basis) -> float:
var bounds := mesh.get_aabb()
var minimum_y := INF
for corner_index: int in 8:
var corner := bounds.position + Vector3(
bounds.size.x if (corner_index & 1) != 0 else 0.0,
bounds.size.y if (corner_index & 2) != 0 else 0.0,
bounds.size.z if (corner_index & 4) != 0 else 0.0,
)
minimum_y = minf(minimum_y, (basis * corner).y)
return minimum_y if minimum_y < INF else 0.0
func _build_shoreline_reference() -> void:
var half := get_playable_half_extents()
var width := 0.05
var vertices := PackedVector3Array()
var indices := PackedInt32Array()
var corners := [
Vector3(-half.x, WATER_HEIGHT, -half.y),
Vector3(half.x, WATER_HEIGHT, -half.y),
Vector3(half.x, WATER_HEIGHT, half.y),
Vector3(-half.x, WATER_HEIGHT, half.y),
]
for index: int in 4:
var start: Vector3 = corners[index]
var finish: Vector3 = corners[(index + 1) % 4]
var direction := (finish - start).normalized()
var perpendicular := Vector3(-direction.z, 0.0, direction.x) * width
var base := vertices.size()
vertices.append_array(PackedVector3Array([
start - perpendicular,
start + perpendicular,
finish + perpendicular,
finish - perpendicular,
]))
indices.append_array(PackedInt32Array([
base, base + 1, base + 2,
base, base + 2, base + 3,
]))
var arrays := []
arrays.resize(Mesh.ARRAY_MAX)
arrays[Mesh.ARRAY_VERTEX] = vertices
arrays[Mesh.ARRAY_INDEX] = indices
var mesh := ArrayMesh.new()
mesh.add_surface_from_arrays(Mesh.PRIMITIVE_TRIANGLES, arrays)
_shoreline_reference.mesh = mesh
func _append_surface_triangles(
result: Array[PackedVector3Array],
mesh_instance: MeshInstance3D,
arrays: Array,
minimum_global_y: float,
minimum_up_dot: float,
) -> void:
if arrays.size() <= Mesh.ARRAY_INDEX:
return
var vertices := arrays[Mesh.ARRAY_VERTEX] as PackedVector3Array
var indices := arrays[Mesh.ARRAY_INDEX] as PackedInt32Array
if vertices.is_empty():
return
if indices.is_empty():
for index: int in range(0, vertices.size() - 2, 3):
_append_triangle(result, mesh_instance, vertices[index], vertices[index + 1], vertices[index + 2], minimum_global_y, minimum_up_dot)
return
for index: int in range(0, indices.size() - 2, 3):
_append_triangle(result, mesh_instance, vertices[indices[index]], vertices[indices[index + 1]], vertices[indices[index + 2]], minimum_global_y, minimum_up_dot)
func _append_triangle(
result: Array[PackedVector3Array],
mesh_instance: MeshInstance3D,
local_a: Vector3,
local_b: Vector3,
local_c: Vector3,
minimum_global_y: float,
minimum_up_dot: float,
) -> void:
var a := mesh_instance.to_global(local_a)
var b := mesh_instance.to_global(local_b)
var c := mesh_instance.to_global(local_c)
if minf(a.y, minf(b.y, c.y)) <= minimum_global_y:
return
var cross := (b - a).cross(c - a)
if cross.length_squared() <= 0.0000001:
return
if absf(cross.normalized().dot(Vector3.UP)) < minimum_up_dot:
return
result.append(PackedVector3Array([a, b, c]))
func _collect_mesh_instances(root: Node) -> Array[MeshInstance3D]:
var result: Array[MeshInstance3D] = []
if root is MeshInstance3D:
result.append(root as MeshInstance3D)
for child: Node in root.get_children():
result.append_array(_collect_mesh_instances(child))
return result
func _clear_children(root: Node) -> void:
for child: Node in root.get_children():
root.remove_child(child)
child.free()

View file

@ -0,0 +1 @@
uid://byikr3n8hs2f

View file

@ -0,0 +1,94 @@
[gd_scene load_steps=12 format=3]
[ext_resource type="Script" path="res://world/generation/generated_world_region.gd" id="1_region"]
[ext_resource type="Script" path="res://world/generation/terrain_chunk_generator.gd" id="2_generator"]
[ext_resource type="Resource" path="res://world/generation/chunks/terrain_chunk_catalog.tres" id="3_catalog"]
[ext_resource type="PackedScene" path="res://world/water_body.tscn" id="4_water"]
[ext_resource type="PackedScene" path="res://world/interactables/fishing_shop_world.tscn" id="5_shop"]
[ext_resource type="PackedScene" path="res://world/interactables/player_storage_box.tscn" id="6_storage"]
[ext_resource type="Script" path="res://world/safe_respawn_point.gd" id="7_safe"]
[ext_resource type="Script" path="res://world/digging/diggable_area_3d.gd" id="8_diggable"]
[ext_resource type="Script" path="res://world/gathering/gatherable_anchor_set_3d.gd" id="9_anchors"]
[ext_resource type="Resource" path="res://fish/pools/starter_ocean_pool.tres" id="10_ocean_pool"]
[ext_resource type="Resource" path="res://fish/pools/starter_pond_pool.tres" id="11_pond_pool"]
[node name="GeneratedWorldRegion" type="Node3D"]
script = ExtResource("1_region")
region_id = &"generated_world"
fishable_water_root = NodePath("WaterBodies")
water_recovery_root = NodePath("WaterBodies")
safe_respawns_root = NodePath("SafeRespawns")
diggable_area_root = NodePath("DiggableAreas")
gatherable_anchor_root = NodePath("GatherableAnchors")
[node name="Terrain" type="Node3D" parent="."]
[node name="TerrainChunkGenerator" type="Node3D" parent="Terrain"]
unique_name_in_owner = true
script = ExtResource("2_generator")
catalog = ExtResource("3_catalog")
grid_size = Vector2i(5, 5)
generation_seed = 13001
generate_on_ready = false
build_collision = true
force_center_chunk_id = &"chunk_spawn"
required_chunk_ids = PackedStringArray("chunk_spawn", "chunk_0001", "chunk_0002", "chunk_0003", "chunk_0004")
required_chunk_weight_multiplier = 64.0
maximum_backtracks = 100000
[node name="WaterBodies" type="Node3D" parent="."]
[node name="OceanWater" parent="WaterBodies" instance=ExtResource("4_water")]
unique_name_in_owner = true
surface_size = Vector2(10000, 10000)
fish_pool = ExtResource("10_ocean_pool")
water_type = 1
location_tags = Array[StringName]([&"coast", &"ocean", &"generated_ocean"])
[node name="FreshWaterBodies" type="Node3D" parent="WaterBodies"]
unique_name_in_owner = true
[node name="DiggableAreas" type="Node3D" parent="."]
[node name="DiggableBeach" type="Node3D" parent="DiggableAreas"]
unique_name_in_owner = true
script = ExtResource("8_diggable")
area_id = &"starter_beach"
terrain_source = NodePath("../../Terrain/TerrainChunkGenerator")
surface_materials = Array[StringName]([&"sand"])
generation_bounds = Rect2(-35, -35, 70, 70)
minimum_global_y = -0.24
maximum_global_y = 0.5
minimum_up_dot = 0.72
[node name="GatherableAnchors" type="Node3D" parent="."]
[node name="ReachableTreeTrunks" type="Node3D" parent="GatherableAnchors"]
unique_name_in_owner = true
script = ExtResource("9_anchors")
anchor_set_id = &"starter_reachable_tree_trunks"
[node name="SafeRespawns" type="Node3D" parent="."]
[node name="SafeSpawn" type="Marker3D" parent="SafeRespawns"]
unique_name_in_owner = true
script = ExtResource("7_safe")
[node name="PlayerSpawn" type="Marker3D" parent="."]
unique_name_in_owner = true
[node name="Interactables" type="Node3D" parent="."]
[node name="FishingShopWorld" parent="Interactables" instance=ExtResource("5_shop")]
unique_name_in_owner = true
[node name="PlayerStorageBox" parent="Interactables" instance=ExtResource("6_storage")]
unique_name_in_owner = true
[node name="Decorations" type="Node3D" parent="."]
unique_name_in_owner = true
[node name="ShorelineReference" type="MeshInstance3D" parent="."]
unique_name_in_owner = true
visible = false
cast_shadow = 0

View file

@ -0,0 +1,216 @@
class_name TerrainChunkAnalyzer
extends RefCounted
const DEFAULT_EDGE_EPSILON := 0.005
const DEFAULT_PROFILE_QUANTIZATION := 0.001
static func create_variants(
definition: TerrainChunkDefinition,
chunk_size: float,
edge_epsilon := DEFAULT_EDGE_EPSILON,
profile_quantization := DEFAULT_PROFILE_QUANTIZATION,
) -> Array[TerrainChunkVariant]:
var variants: Array[TerrainChunkVariant] = []
if definition == null or definition.packed_scene == null:
return variants
var chunk_root := definition.packed_scene.instantiate()
var primary_mesh := find_primary_mesh(
chunk_root,
definition.primary_mesh_name,
)
if primary_mesh == null or primary_mesh.mesh == null:
push_error(
"Terrain chunk %s has no MeshInstance3D named %s."
% [definition.stable_id, definition.primary_mesh_name]
)
chunk_root.free()
return variants
var mesh_transform := _transform_relative_to(primary_mesh, chunk_root)
var boundary_points := _collect_boundary_points(
primary_mesh.mesh,
mesh_transform,
chunk_size,
edge_epsilon,
)
for quarter_turns: int in 4:
if not definition.allows_quarter_turn(quarter_turns):
continue
var rotated_points := _rotate_points(boundary_points, quarter_turns)
var variant := TerrainChunkVariant.new()
variant.definition = definition
variant.quarter_turns = quarter_turns
variant.edge_profiles = _build_profiles(
rotated_points,
chunk_size,
edge_epsilon,
profile_quantization,
)
variants.append(variant)
chunk_root.free()
return variants
static func find_primary_mesh(
root: Node,
primary_mesh_name: StringName,
) -> MeshInstance3D:
if root is MeshInstance3D and root.name == primary_mesh_name:
return root as MeshInstance3D
for candidate: Node in root.find_children(
"*",
"MeshInstance3D",
true,
false,
):
var mesh_instance := candidate as MeshInstance3D
if mesh_instance != null and mesh_instance.name == primary_mesh_name:
return mesh_instance
return null
static func _transform_relative_to(node: Node3D, root: Node) -> Transform3D:
var relative_transform := Transform3D.IDENTITY
var current: Node = node
while current != null and current != root:
if current is Node3D:
relative_transform = (
(current as Node3D).transform * relative_transform
)
current = current.get_parent()
return relative_transform
static func _collect_boundary_points(
mesh: Mesh,
mesh_transform: Transform3D,
chunk_size: float,
edge_epsilon: float,
) -> PackedVector3Array:
var result := PackedVector3Array()
var half_size := chunk_size * 0.5
var unique_points: Dictionary[Vector3i, bool] = {}
for surface_index: int in mesh.get_surface_count():
var arrays := mesh.surface_get_arrays(surface_index)
if arrays.is_empty():
continue
var vertices: PackedVector3Array = arrays[Mesh.ARRAY_VERTEX]
for source_vertex: Vector3 in vertices:
var vertex := mesh_transform * source_vertex
if not _is_boundary_point(vertex, half_size, edge_epsilon):
continue
var key := Vector3i(
roundi(vertex.x / edge_epsilon),
roundi(vertex.y / edge_epsilon),
roundi(vertex.z / edge_epsilon),
)
if unique_points.has(key):
continue
unique_points[key] = true
result.append(vertex)
return result
static func _is_boundary_point(
point: Vector3,
half_size: float,
tolerance: float,
) -> bool:
return (
absf(absf(point.x) - half_size) <= tolerance
or absf(absf(point.z) - half_size) <= tolerance
)
static func _rotate_points(
points: PackedVector3Array,
quarter_turns: int,
) -> PackedVector3Array:
var result := PackedVector3Array()
var angle := float(posmod(quarter_turns, 4)) * PI * 0.5
for point: Vector3 in points:
result.append(point.rotated(Vector3.UP, angle))
return result
static func _build_profiles(
boundary_points: PackedVector3Array,
chunk_size: float,
edge_epsilon: float,
profile_quantization: float,
) -> Array[TerrainChunkEdgeProfile]:
var profiles: Array[TerrainChunkEdgeProfile] = []
for edge_value: int in TerrainChunkTopology.Edge.values():
var edge := edge_value as TerrainChunkTopology.Edge
profiles.append(
_build_profile(
edge,
boundary_points,
chunk_size * 0.5,
edge_epsilon,
profile_quantization,
)
)
return profiles
static func _build_profile(
edge: TerrainChunkTopology.Edge,
boundary_points: PackedVector3Array,
half_size: float,
edge_epsilon: float,
profile_quantization: float,
) -> TerrainChunkEdgeProfile:
var unique_points: Dictionary[Vector2i, bool] = {}
var profile_points: Array[Vector2] = []
for point: Vector3 in boundary_points:
if not _point_is_on_edge(point, edge, half_size, edge_epsilon):
continue
var tangent := (
point.x
if edge in [
TerrainChunkTopology.Edge.NORTH,
TerrainChunkTopology.Edge.SOUTH,
]
else point.z
)
var key := Vector2i(
roundi(tangent / profile_quantization),
roundi(point.y / profile_quantization),
)
if unique_points.has(key):
continue
unique_points[key] = true
profile_points.append(
Vector2(key.x, key.y) * profile_quantization
)
profile_points.sort_custom(_profile_point_less_than)
return TerrainChunkEdgeProfile.new(
edge,
PackedVector2Array(profile_points),
)
static func _point_is_on_edge(
point: Vector3,
edge: TerrainChunkTopology.Edge,
half_size: float,
tolerance: float,
) -> bool:
match edge:
TerrainChunkTopology.Edge.NORTH:
return absf(point.z + half_size) <= tolerance
TerrainChunkTopology.Edge.EAST:
return absf(point.x - half_size) <= tolerance
TerrainChunkTopology.Edge.SOUTH:
return absf(point.z - half_size) <= tolerance
TerrainChunkTopology.Edge.WEST:
return absf(point.x + half_size) <= tolerance
return false
static func _profile_point_less_than(a: Vector2, b: Vector2) -> bool:
if is_equal_approx(a.x, b.x):
return a.y < b.y
return a.x < b.x

View file

@ -0,0 +1 @@
uid://cga3uyv3p1jf8

View file

@ -0,0 +1,50 @@
class_name TerrainChunkCatalog
extends Resource
@export_range(1.0, 100.0, 0.5) var chunk_size := 10.0
@export var definitions: Array[TerrainChunkDefinition] = []
func definition_for_id(stable_id: StringName) -> TerrainChunkDefinition:
for definition: TerrainChunkDefinition in definitions:
if definition != null and definition.stable_id == stable_id:
return definition
return null
func validation_errors() -> PackedStringArray:
var errors := PackedStringArray()
var seen_ids: Dictionary[StringName, bool] = {}
for index: int in definitions.size():
var definition := definitions[index]
if definition == null:
errors.append("Definition %d is empty." % index)
continue
if definition.stable_id == &"":
errors.append("Definition %d has no stable ID." % index)
elif seen_ids.has(definition.stable_id):
errors.append("Stable ID %s is duplicated." % definition.stable_id)
else:
seen_ids[definition.stable_id] = true
if definition.packed_scene == null:
errors.append("%s has no PackedScene." % definition.stable_id)
if definition.primary_mesh_name == &"":
errors.append("%s has no primary mesh name." % definition.stable_id)
if definition.allowed_rotation_mask == 0:
errors.append("%s allows no rotations." % definition.stable_id)
var has_fresh_water := "fresh_water" in definition.tags
var has_water_footprint := (
definition.water_surface_size.x > 0.0
and definition.water_surface_size.y > 0.0
)
if has_fresh_water and not has_water_footprint:
errors.append(
"%s is freshwater but has no water footprint."
% definition.stable_id
)
elif not has_fresh_water and has_water_footprint:
errors.append(
"%s has a water footprint without the freshwater tag."
% definition.stable_id
)
return errors

View file

@ -0,0 +1 @@
uid://oyvltip6k4oy

View file

@ -0,0 +1,24 @@
class_name TerrainChunkDefinition
extends Resource
@export var stable_id: StringName
@export var label := ""
@export var packed_scene: PackedScene
@export var primary_mesh_name: StringName
@export_flags("0 degrees", "90 degrees", "180 degrees", "270 degrees")
var allowed_rotation_mask := 15
@export_range(0.01, 100.0, 0.01) var selection_weight := 1.0
## Negative values allow unlimited placements; zero disables random placement.
@export_range(-1, 1024, 1) var maximum_placements := -1
@export var tags := PackedStringArray()
@export_flags("North", "East", "South", "West") var water_inlet_edges := 0
@export_flags("North", "East", "South", "West") var water_outlet_edges := 0
## Optional generated water footprint in the chunk's unrotated local X/Z plane.
## Zero means this chunk contributes no standalone water surface.
@export var water_surface_size := Vector2.ZERO
@export var water_surface_offset := Vector2.ZERO
func allows_quarter_turn(quarter_turns: int) -> bool:
var normalized_turns := posmod(quarter_turns, 4)
return (allowed_rotation_mask & (1 << normalized_turns)) != 0

View file

@ -0,0 +1 @@
uid://ck6m4auf5epde

View file

@ -0,0 +1,97 @@
class_name TerrainChunkEdgeProfile
extends RefCounted
var edge := TerrainChunkTopology.Edge.NORTH
var points := PackedVector2Array()
func _init(
profile_edge := TerrainChunkTopology.Edge.NORTH,
profile_points := PackedVector2Array(),
) -> void:
edge = profile_edge
points = profile_points
func matches(other: TerrainChunkEdgeProfile, tolerance: float) -> bool:
if other == null:
return false
var first_surface := _upper_surface_points()
var second_surface := other._upper_surface_points()
if first_surface.size() < 2 or second_surface.size() < 2:
return false
if (
absf(first_surface[0].x - second_surface[0].x) > tolerance
or absf(
first_surface[first_surface.size() - 1].x
- second_surface[second_surface.size() - 1].x
) > tolerance
):
return false
var sample_tangents := PackedFloat32Array()
for point: Vector2 in first_surface:
_append_unique_tangent(sample_tangents, point.x, tolerance)
for point: Vector2 in second_surface:
_append_unique_tangent(sample_tangents, point.x, tolerance)
sample_tangents.sort()
for tangent: float in sample_tangents:
var first_height := _sample_height(first_surface, tangent)
var second_height := _sample_height(second_surface, tangent)
if absf(first_height - second_height) > tolerance:
return false
return true
func _upper_surface_points() -> PackedVector2Array:
var result := PackedVector2Array()
for point: Vector2 in points:
if (
not result.is_empty()
and is_equal_approx(result[result.size() - 1].x, point.x)
):
var previous := result[result.size() - 1]
previous.y = maxf(previous.y, point.y)
result[result.size() - 1] = previous
continue
result.append(point)
return result
func _append_unique_tangent(
tangents: PackedFloat32Array,
tangent: float,
tolerance: float,
) -> void:
for existing: float in tangents:
if absf(existing - tangent) <= tolerance:
return
tangents.append(tangent)
func _sample_height(surface: PackedVector2Array, tangent: float) -> float:
if tangent <= surface[0].x:
return surface[0].y
for index: int in range(1, surface.size()):
var right := surface[index]
if tangent > right.x:
continue
var left := surface[index - 1]
var span := right.x - left.x
if is_zero_approx(span):
return maxf(left.y, right.y)
return lerpf(left.y, right.y, (tangent - left.x) / span)
return surface[surface.size() - 1].y
func signature(quantization: float) -> String:
var tokens := PackedStringArray()
for point: Vector2 in points:
tokens.append(
"%d:%d"
% [
roundi(point.x / quantization),
roundi(point.y / quantization),
]
)
return ",".join(tokens)

View file

@ -0,0 +1 @@
uid://c3nlec8uibu1h

View file

@ -0,0 +1,396 @@
class_name TerrainChunkGenerator
extends Node3D
signal generation_completed(summary: Dictionary)
const EDGE_PROFILE_QUANTIZATION := 0.001
@export var catalog: TerrainChunkCatalog
@export var grid_size := Vector2i(5, 5)
@export var generation_seed := 13001
@export var generate_on_ready := true
@export var build_collision := true
@export var show_chunk_labels := false
@export var force_center_chunk_id: StringName = &"chunk_0000"
@export var required_chunk_ids := PackedStringArray()
@export_range(1.0, 100.0, 1.0) var required_chunk_weight_multiplier := 64.0
@export_range(0.001, 0.1, 0.001) var edge_match_tolerance := 0.01
@export_range(1, 100000, 1) var maximum_backtracks := 20000
var _variants: Array[TerrainChunkVariant] = []
var _placements: Array[TerrainChunkVariant] = []
var _random := RandomNumberGenerator.new()
var _generated_chunks: Node3D
var _backtrack_count := 0
func _ready() -> void:
if generate_on_ready:
call_deferred(&"generate")
func generate() -> bool:
_clear_generated_chunks()
if not _prepare_catalog():
return false
_random.seed = generation_seed
_backtrack_count = 0
_placements.clear()
_placements.resize(grid_size.x * grid_size.y)
if not _solve_cell(0):
push_error(
"Terrain generation could not solve a %dx%d grid with seed %d."
% [grid_size.x, grid_size.y, generation_seed]
)
return false
_instantiate_solution()
var summary := _build_summary()
generation_completed.emit(summary)
return true
func _prepare_catalog() -> bool:
if catalog == null:
push_error("TerrainChunkGenerator requires a catalog.")
return false
if grid_size.x <= 0 or grid_size.y <= 0:
push_error("TerrainChunkGenerator grid dimensions must be positive.")
return false
var catalog_errors := catalog.validation_errors()
if not catalog_errors.is_empty():
push_error("Terrain chunk catalog is invalid:\n" + "\n".join(catalog_errors))
return false
_variants.clear()
for definition: TerrainChunkDefinition in catalog.definitions:
var analyzed := TerrainChunkAnalyzer.create_variants(
definition,
catalog.chunk_size,
)
var seen_signatures: Dictionary[String, bool] = {}
for variant: TerrainChunkVariant in analyzed:
var signature := variant.topology_signature(
EDGE_PROFILE_QUANTIZATION
)
if seen_signatures.has(signature):
continue
seen_signatures[signature] = true
_variants.append(variant)
if _variants.is_empty():
push_error("Terrain chunk catalog produced no usable variants.")
return false
return true
func _solve_cell(index: int) -> bool:
if index >= _placements.size():
return _required_chunks_are_present()
if _backtrack_count >= maximum_backtracks:
return false
var candidates := _compatible_candidates(index)
for candidate: TerrainChunkVariant in _weighted_candidate_order(candidates):
_placements[index] = candidate
if _solve_cell(index + 1):
return true
_placements[index] = null
_backtrack_count += 1
if _backtrack_count >= maximum_backtracks:
break
return false
func _compatible_candidates(index: int) -> Array[TerrainChunkVariant]:
var result: Array[TerrainChunkVariant] = []
var coordinate := Vector2i(index % grid_size.x, index / grid_size.x)
var center := Vector2i(grid_size.x / 2, grid_size.y / 2)
for candidate: TerrainChunkVariant in _variants:
if not _definition_has_capacity(candidate.definition):
continue
if (
coordinate == center
and force_center_chunk_id != &""
and candidate.definition.stable_id != force_center_chunk_id
):
continue
if coordinate.x > 0:
var west_neighbor := _placements[index - 1]
if (
west_neighbor != null
and not _edges_are_compatible(
candidate,
TerrainChunkTopology.Edge.WEST,
west_neighbor,
TerrainChunkTopology.Edge.EAST,
)
):
continue
if coordinate.y > 0:
var north_neighbor := _placements[index - grid_size.x]
if (
north_neighbor != null
and not _edges_are_compatible(
candidate,
TerrainChunkTopology.Edge.NORTH,
north_neighbor,
TerrainChunkTopology.Edge.SOUTH,
)
):
continue
result.append(candidate)
return result
func _definition_has_capacity(definition: TerrainChunkDefinition) -> bool:
if definition.maximum_placements < 0:
return true
var count := 0
for placement: TerrainChunkVariant in _placements:
if placement != null and placement.definition == definition:
count += 1
return count < definition.maximum_placements
func _edges_are_compatible(
first: TerrainChunkVariant,
first_edge: TerrainChunkTopology.Edge,
second: TerrainChunkVariant,
second_edge: TerrainChunkTopology.Edge,
) -> bool:
if not first.profile(first_edge).matches(
second.profile(second_edge),
edge_match_tolerance,
):
return false
var first_inlet := _variant_edge_has_connector(
first,
first.definition.water_inlet_edges,
first_edge,
)
var first_outlet := _variant_edge_has_connector(
first,
first.definition.water_outlet_edges,
first_edge,
)
var second_inlet := _variant_edge_has_connector(
second,
second.definition.water_inlet_edges,
second_edge,
)
var second_outlet := _variant_edge_has_connector(
second,
second.definition.water_outlet_edges,
second_edge,
)
var first_has_water := first_inlet or first_outlet
var second_has_water := second_inlet or second_outlet
if not first_has_water and not second_has_water:
return true
return (
(first_outlet and second_inlet)
or (first_inlet and second_outlet)
)
func _variant_edge_has_connector(
variant: TerrainChunkVariant,
source_mask: int,
edge: TerrainChunkTopology.Edge,
) -> bool:
return (variant.rotated_edge_mask(source_mask) & (1 << int(edge))) != 0
func _required_chunks_are_present() -> bool:
if required_chunk_ids.is_empty():
return true
var found: Dictionary[StringName, bool] = {}
for placement: TerrainChunkVariant in _placements:
if placement != null:
found[placement.definition.stable_id] = true
for required_id: String in required_chunk_ids:
if not found.has(StringName(required_id)):
return false
return true
func _weighted_candidate_order(
candidates: Array[TerrainChunkVariant],
) -> Array[TerrainChunkVariant]:
var scored: Array[Dictionary] = []
for candidate: TerrainChunkVariant in candidates:
var weight := maxf(candidate.definition.selection_weight, 0.01)
if _required_chunk_is_missing(candidate.definition.stable_id):
weight *= required_chunk_weight_multiplier
var sample := maxf(_random.randf(), 0.000001)
scored.append(
{
"variant": candidate,
"score": pow(sample, 1.0 / weight),
}
)
scored.sort_custom(_higher_candidate_score)
var result: Array[TerrainChunkVariant] = []
for entry: Dictionary in scored:
result.append(entry["variant"] as TerrainChunkVariant)
return result
func _required_chunk_is_missing(stable_id: StringName) -> bool:
if not required_chunk_ids.has(String(stable_id)):
return false
for placement: TerrainChunkVariant in _placements:
if (
placement != null
and placement.definition.stable_id == stable_id
):
return false
return true
func _instantiate_solution() -> void:
_generated_chunks = Node3D.new()
_generated_chunks.name = "GeneratedChunks"
add_child(_generated_chunks)
var half_grid := Vector2(
float(grid_size.x - 1) * 0.5,
float(grid_size.y - 1) * 0.5,
)
for index: int in _placements.size():
var variant := _placements[index]
if variant == null:
continue
var coordinate := Vector2i(index % grid_size.x, index / grid_size.x)
var chunk_root := variant.definition.packed_scene.instantiate() as Node3D
if chunk_root == null:
push_error("%s does not instantiate as Node3D." % variant.stable_key())
continue
chunk_root.name = "%s_%d_%d" % [
variant.stable_key().replace("@", "r"),
coordinate.x,
coordinate.y,
]
chunk_root.position = Vector3(
(float(coordinate.x) - half_grid.x) * catalog.chunk_size,
0.0,
(float(coordinate.y) - half_grid.y) * catalog.chunk_size,
)
chunk_root.rotation.y = variant.rotation_radians()
_generated_chunks.add_child(chunk_root)
if build_collision:
_add_collision(chunk_root, variant.definition)
if show_chunk_labels:
_add_chunk_label(chunk_root, variant)
func _add_collision(
chunk_root: Node3D,
definition: TerrainChunkDefinition,
) -> void:
var primary_mesh := TerrainChunkAnalyzer.find_primary_mesh(
chunk_root,
definition.primary_mesh_name,
)
if primary_mesh == null or primary_mesh.mesh == null:
return
var terrain_shape := primary_mesh.mesh.create_trimesh_shape()
if terrain_shape == null or terrain_shape.get_faces().is_empty():
push_warning("%s produced no terrain collision." % definition.stable_id)
return
var collision_body := StaticBody3D.new()
collision_body.name = "TerrainCollision"
collision_body.collision_layer = 1
collision_body.collision_mask = 0
primary_mesh.add_child(collision_body)
var collision_shape := CollisionShape3D.new()
collision_shape.name = "TerrainShape"
collision_shape.shape = terrain_shape
collision_body.add_child(collision_shape)
func _add_chunk_label(
chunk_root: Node3D,
variant: TerrainChunkVariant,
) -> void:
var label := Label3D.new()
label.name = "ChunkLabel"
label.text = "%s r%d" % [
variant.definition.stable_id,
variant.quarter_turns,
]
label.position = Vector3(0.0, 1.25, 0.0)
label.font_size = 24
label.billboard = BaseMaterial3D.BILLBOARD_ENABLED
label.no_depth_test = true
chunk_root.add_child(label)
func _clear_generated_chunks() -> void:
if is_instance_valid(_generated_chunks):
remove_child(_generated_chunks)
_generated_chunks.free()
_generated_chunks = null
func _build_summary() -> Dictionary:
var counts: Dictionary[StringName, int] = {}
for variant: TerrainChunkVariant in _placements:
if variant == null:
continue
var stable_id := variant.definition.stable_id
counts[stable_id] = counts.get(stable_id, 0) + 1
return {
"seed": generation_seed,
"grid_size": grid_size,
"chunk_count": _placements.size(),
"variant_count": _variants.size(),
"backtracks": _backtrack_count,
"counts": counts,
"placements": placement_keys(),
}
func placement_keys() -> PackedStringArray:
var result := PackedStringArray()
for variant: TerrainChunkVariant in _placements:
result.append(variant.stable_key() if variant != null else "empty")
return result
func placement_records() -> Array[Dictionary]:
var result: Array[Dictionary] = []
for index: int in _placements.size():
var variant: TerrainChunkVariant = _placements[index]
if variant == null:
continue
var coordinate := Vector2i(index % grid_size.x, index / grid_size.x)
result.append({
"coordinate": coordinate,
"position": chunk_position(coordinate),
"rotation_quarters": variant.quarter_turns,
"stable_id": variant.definition.stable_id,
"tags": variant.definition.tags,
"water_surface_size": variant.definition.water_surface_size,
"water_surface_offset": variant.definition.water_surface_offset,
})
return result
func chunk_position(coordinate: Vector2i) -> Vector3:
if catalog == null:
return Vector3.ZERO
var half_grid := Vector2(
float(grid_size.x - 1) * 0.5,
float(grid_size.y - 1) * 0.5,
)
return Vector3(
(float(coordinate.x) - half_grid.x) * catalog.chunk_size,
0.0,
(float(coordinate.y) - half_grid.y) * catalog.chunk_size,
)
func get_generated_chunks_root() -> Node3D:
return _generated_chunks
static func _higher_candidate_score(a: Dictionary, b: Dictionary) -> bool:
return float(a["score"]) > float(b["score"])

View file

@ -0,0 +1 @@
uid://cj6ofpdn68fsf

View file

@ -0,0 +1,53 @@
class_name TerrainChunkTopology
extends RefCounted
enum Edge {
NORTH,
EAST,
SOUTH,
WEST,
}
static func opposite_edge(edge: Edge) -> Edge:
return ((int(edge) + 2) % 4) as Edge
static func edge_name(edge: Edge) -> String:
match edge:
Edge.NORTH:
return "north"
Edge.EAST:
return "east"
Edge.SOUTH:
return "south"
Edge.WEST:
return "west"
return "unknown"
static func edge_normal(edge: Edge) -> Vector3:
match edge:
Edge.NORTH:
return Vector3.FORWARD
Edge.EAST:
return Vector3.RIGHT
Edge.SOUTH:
return Vector3.BACK
Edge.WEST:
return Vector3.LEFT
return Vector3.ZERO
static func rotated_edge(edge: Edge, quarter_turns: int) -> Edge:
var angle := float(posmod(quarter_turns, 4)) * PI * 0.5
var normal := edge_normal(edge).rotated(Vector3.UP, angle)
var best_edge := Edge.NORTH
var best_dot := -INF
for candidate_value: int in Edge.values():
var candidate := candidate_value as Edge
var alignment := normal.dot(edge_normal(candidate))
if alignment > best_dot:
best_dot = alignment
best_edge = candidate
return best_edge

View file

@ -0,0 +1 @@
uid://bm1cjv0w541fp

View file

@ -0,0 +1,39 @@
class_name TerrainChunkVariant
extends RefCounted
var definition: TerrainChunkDefinition
var quarter_turns := 0
var edge_profiles: Array[TerrainChunkEdgeProfile] = []
func profile(edge: TerrainChunkTopology.Edge) -> TerrainChunkEdgeProfile:
return edge_profiles[int(edge)]
func rotation_radians() -> float:
return float(posmod(quarter_turns, 4)) * PI * 0.5
func stable_key() -> String:
return "%s@%d" % [definition.stable_id, posmod(quarter_turns, 4)]
func topology_signature(quantization: float) -> String:
var tokens := PackedStringArray()
for edge_value: int in TerrainChunkTopology.Edge.values():
var edge := edge_value as TerrainChunkTopology.Edge
tokens.append(profile(edge).signature(quantization))
return "|".join(tokens)
func rotated_edge_mask(source_mask: int) -> int:
var result := 0
for edge_value: int in TerrainChunkTopology.Edge.values():
if (source_mask & (1 << edge_value)) == 0:
continue
var rotated := TerrainChunkTopology.rotated_edge(
edge_value as TerrainChunkTopology.Edge,
quarter_turns,
)
result |= 1 << int(rotated)
return result

View file

@ -0,0 +1 @@
uid://cvpoujhk6t3mn

View file

@ -10,6 +10,38 @@ extends Node3D
@export var gatherable_anchor_root: NodePath = ^"GatherableAnchors"
func get_player_spawn_transform() -> Transform3D:
return global_transform
func get_fishing_shop() -> FishingShopInteraction:
return null
func get_player_storage() -> PlayerStorageInteraction:
return null
func get_saltwater_shoreline_mesh() -> MeshInstance3D:
return null
func set_light_performance_profile(_enabled: bool) -> void:
pass
func get_playable_half_extents() -> Vector2:
return Vector2(50.0, 50.0)
func get_spawn_surface_triangles(
_material_names: Array[StringName],
_minimum_global_y: float,
_minimum_up_dot: float = 0.6,
) -> Array[PackedVector3Array]:
return []
func get_fishable_water_regions() -> Array[FishableWaterRegion]:
var regions: Array[FishableWaterRegion] = []
var root: Node = get_node_or_null(fishable_water_root)

View file

@ -9,18 +9,10 @@ const PlayerStorageInteractionType = preload(
)
@onready var _regions_root: Node3D = $Regions
@onready var _starter_island: StarterIslandRegion = (
$Regions/StarterIslandRegion
)
@onready var _active_region: WorldRegion = _find_active_region()
@onready var _below_world_failsafe: PlayerWaterTrigger = (
$Safety/BelowWorldFailsafe
)
@onready var _fishing_shop: FishingShopInteractionType = (
_starter_island.get_fishing_shop()
)
@onready var _player_storage: PlayerStorageInteractionType = (
_starter_island.get_player_storage()
)
@onready var _world_environment: WorldEnvironment = $Environment/WorldEnvironment
@onready var _sun: DirectionalLight3D = $Environment/Sun
@ -41,15 +33,15 @@ func get_safe_respawn_points() -> Array[SafeRespawnPoint]:
func get_fishing_shop() -> FishingShopInteractionType:
return _fishing_shop
return _active_region.get_fishing_shop()
func get_player_storage() -> PlayerStorageInteractionType:
return _player_storage
return _active_region.get_player_storage()
func get_player_spawn_transform() -> Transform3D:
return _starter_island.get_player_spawn_transform()
return _active_region.get_player_spawn_transform()
func get_world_environment() -> WorldEnvironment:
@ -61,7 +53,7 @@ func get_sun() -> DirectionalLight3D:
func set_light_performance_profile(enabled: bool) -> void:
_starter_island.set_light_performance_profile(enabled)
_active_region.set_light_performance_profile(enabled)
func get_fishable_water_regions() -> Array[FishableWaterRegion]:
@ -72,19 +64,34 @@ func get_fishable_water_regions() -> Array[FishableWaterRegion]:
func get_saltwater_shoreline_mesh() -> MeshInstance3D:
return _starter_island.get_saltwater_shoreline_mesh()
return _active_region.get_saltwater_shoreline_mesh()
func get_spawn_surface_triangles(
material_names: Array[StringName],
minimum_global_y: float,
) -> Array[PackedVector3Array]:
return _starter_island.get_spawn_surface_triangles(
return _active_region.get_spawn_surface_triangles(
material_names,
minimum_global_y,
)
func generate_world(seed: int) -> bool:
if _active_region == null or not _active_region.has_method("generate_world"):
return false
var generated: bool = bool(_active_region.call("generate_world", seed))
if generated:
_configure_world_coverage()
return generated
func get_generation_seed() -> int:
if _active_region != null and _active_region.has_method("get_generation_seed"):
return int(_active_region.call("get_generation_seed"))
return PlayerSaveManager.DEFAULT_WORLD_SEED
func get_diggable_area_triangles(
area_id: StringName,
) -> Array[PackedVector3Array]:
@ -114,3 +121,51 @@ func _get_regions() -> Array[WorldRegion]:
if region != null:
regions.append(region)
return regions
func _ready() -> void:
_configure_world_coverage()
func _find_active_region() -> WorldRegion:
for child: Node in _regions_root.get_children():
var region := child as WorldRegion
if region != null:
return region
return null
func _configure_world_coverage() -> void:
if _active_region == null:
return
var half: Vector2 = _active_region.get_playable_half_extents()
var wall_margin := 2.0
var wall_height := 14.0
var bounds_root := $WorldBounds as Node3D
var north_south_size := Vector3(
half.x * 2.0 + wall_margin * 2.0,
wall_height,
2.0,
)
var east_west_size := Vector3(
2.0,
wall_height,
half.y * 2.0 + wall_margin * 2.0,
)
_set_bound(bounds_root.get_node("North"), Vector3(0.0, 5.0, half.y + wall_margin), north_south_size)
_set_bound(bounds_root.get_node("South"), Vector3(0.0, 5.0, -half.y - wall_margin), north_south_size)
_set_bound(bounds_root.get_node("West"), Vector3(-half.x - wall_margin, 5.0, 0.0), east_west_size)
_set_bound(bounds_root.get_node("East"), Vector3(half.x + wall_margin, 5.0, 0.0), east_west_size)
_below_world_failsafe.position = Vector3(0.0, -7.0, 0.0)
var coverage := _below_world_failsafe.get_node("Coverage") as CollisionShape3D
var coverage_shape := coverage.shape as BoxShape3D
if coverage_shape != null:
coverage_shape.size = Vector3(half.x * 2.0, 4.0, half.y * 2.0)
func _set_bound(body: Node3D, position: Vector3, size: Vector3) -> void:
body.position = position
var collision := body.get_node("Shape") as CollisionShape3D
var shape := collision.shape as BoxShape3D
if shape != null:
shape.size = size

View file

@ -1,7 +1,7 @@
[gd_scene load_steps=10 format=3]
[ext_resource type="Script" path="res://world/test_world.gd" id="1_world"]
[ext_resource type="PackedScene" path="res://world/regions/starter_island_region.tscn" id="2_island"]
[ext_resource type="PackedScene" path="res://world/generation/generated_world_region.tscn" id="2_island"]
[ext_resource type="Script" path="res://world/below_world_failsafe.gd" id="3_water_trigger"]
[ext_resource type="Environment" path="res://world/environment/netfishing_environment.tres" id="4_environment"]
@ -32,7 +32,7 @@ shadow_blur = 2.0
[node name="Regions" type="Node3D" parent="."]
[node name="StarterIslandRegion" parent="Regions" instance=ExtResource("2_island")]
[node name="GeneratedWorldRegion" parent="Regions" instance=ExtResource("2_island")]
[node name="Safety" type="Node3D" parent="."]

View file

@ -25,6 +25,12 @@ var surface_size: Vector2 = Vector2(10.0, 10.0):
set(value):
water_material = value
_sync_owned_nodes()
## Render the owned plane. Disable this when another shared water surface
## already provides visible water and this body only supplies gameplay data.
@export var visual_surface_enabled := true:
set(value):
visual_surface_enabled = value
_sync_owned_nodes()
@export_group("Fishing Coverage")
@export_range(0.1, 20.0, 0.1, "or_greater", "suffix:m")
@ -85,6 +91,7 @@ func _sync_owned_nodes() -> void:
return
var visual_water := get_node_or_null(visual_water_path) as MeshInstance3D
if visual_water != null:
visual_water.visible = visual_surface_enabled
var plane_mesh := visual_water.mesh as PlaneMesh
if plane_mesh != null and not derive_coverage_from_visual_mesh:
plane_mesh.size = surface_size

View file

@ -69,6 +69,37 @@ func setup(
)
func update_world_context(
initial_spawn_transform: Transform3D,
water_triggers: Array[PlayerWaterTrigger],
safe_points: Array[SafeRespawnPoint],
) -> void:
for water_trigger: PlayerWaterTrigger in _water_triggers:
if (
water_trigger != null
and is_instance_valid(water_trigger)
and water_trigger.recovery_requested.is_connected(
_on_recovery_requested
)
):
water_trigger.recovery_requested.disconnect(
_on_recovery_requested
)
_initial_spawn_transform = initial_spawn_transform
_water_triggers = water_triggers
_safe_points = safe_points
for water_trigger: PlayerWaterTrigger in _water_triggers:
if (
water_trigger != null
and not water_trigger.recovery_requested.is_connected(
_on_recovery_requested
)
):
water_trigger.recovery_requested.connect(
_on_recovery_requested
)
func _process(delta: float) -> void:
if state != RecoveryState.BOBBING or _player == null:
return