diff --git a/scripts/chunk.gd b/scripts/chunk.gd index bb9d8cf..409529b 100644 --- a/scripts/chunk.gd +++ b/scripts/chunk.gd @@ -70,11 +70,14 @@ static func compute_bounds(p_coord: Vector3i) -> AABB: ## Compute the chunk coordinate that contains a world-space position. +## Maps Godot 3D world space (X = East/West, Y = Height/Up, Z = North/South) +## to chunk coordinates (cx = X, cy = Z, cz = Y). static func coord_for_position(p_pos: Vector3) -> Vector3i: + var cz: int = int(floor((p_pos.y - CHUNK_ORIGIN.y) / CHUNK_SIZE_Z)) if CHUNK_SIZE_Z > 1 else 0 return Vector3i( int(floor((p_pos.x - CHUNK_ORIGIN.x) / CHUNK_SIZE_X)), - int(floor((p_pos.y - CHUNK_ORIGIN.y) / CHUNK_SIZE_Y)), - int(floor((p_pos.z - CHUNK_ORIGIN.z) / CHUNK_SIZE_Z)) + int(floor((p_pos.z - CHUNK_ORIGIN.z) / CHUNK_SIZE_Y)), + cz ) diff --git a/scripts/chunk_stream_manager.gd b/scripts/chunk_stream_manager.gd index e3dbfd0..ba2845a 100644 --- a/scripts/chunk_stream_manager.gd +++ b/scripts/chunk_stream_manager.gd @@ -58,6 +58,7 @@ var player: Node3D = null ## The current player chunk coordinate. var _player_chunk_coord: Vector3i = Vector3i.ZERO +var _has_player_coord: bool = false ## Map of loaded chunk coordinates to their scene nodes. var _loaded_chunks: Dictionary = {} @@ -95,7 +96,7 @@ func _process(delta: float) -> void: return _update_accumulator += delta - if _update_accumulator < UPDATE_INTERVAL: + if _has_player_coord and _update_accumulator < UPDATE_INTERVAL: return _update_accumulator = 0.0 @@ -110,9 +111,10 @@ func _update_streaming() -> void: return var current_coord: Vector3i = Chunk.coord_for_position(player.global_position) - if current_coord == _player_chunk_coord: + if _has_player_coord and current_coord == _player_chunk_coord: return + _has_player_coord = true _player_chunk_coord = current_coord _rebuild_queues() @@ -144,9 +146,11 @@ func _compute_target_chunks() -> Dictionary: """Compute the set of chunk coordinates that should be loaded.""" var target: Dictionary = {} var half: int = streaming_radius + var z_min: int = -half if Chunk.CHUNK_SIZE_Z > 1 else 0 + var z_max: int = half if Chunk.CHUNK_SIZE_Z > 1 else 0 for dx in range(-half, half + 1): for dy in range(-half, half + 1): - for dz in range(-half, half + 1): + for dz in range(z_min, z_max + 1): var coord: Vector3i = Vector3i( _player_chunk_coord.x + dx, _player_chunk_coord.y + dy, diff --git a/scripts/smoke_test_chunk_streaming_coords.gd b/scripts/smoke_test_chunk_streaming_coords.gd new file mode 100644 index 0000000..19c6bc0 --- /dev/null +++ b/scripts/smoke_test_chunk_streaming_coords.gd @@ -0,0 +1,109 @@ +extends SceneTree +## Smoke test verifying chunk coordinate mapping and streaming behavior. +## Regression test for the issue where moving forward in Z unloaded chunk (0,0,0) +## because of transposed Godot Y (Height) and Godot Z (Ground) in coord_for_position. + +var passed := 0 +var failed := 0 + + +func _check(label: String, cond: bool) -> void: + if cond: + passed += 1 + print(" PASS: ", label) + else: + failed += 1 + printerr(" FAIL: ", label) + + +func _init() -> void: + _test_coord_for_position() + _test_chunk_stream_manager_proximity() + print("\n=== SMOKE RESULT: %d/%d passed ===" % [passed, passed + failed]) + quit(0 if failed == 0 else 1) + + +func _test_coord_for_position() -> void: + print("--- Chunk.coord_for_position coordinate mapping ---") + # In Godot 3D: X is East/West (chunk X), Y is Height/Up (chunk Z), Z is North/South (chunk Y). + + # Spawn origin + var c0 := Chunk.coord_for_position(Vector3(0.0, 1.5, 0.0)) + _check("Origin at (0, 1.5, 0) maps to (0, 0, 0)", c0 == Vector3i(0, 0, 0)) + + # Moving along Godot Z (North/South) within chunk 0: + # Previously at Z=3, coord.z was incorrectly 3, which exceeded streaming_radius and unloaded chunk 0! + var c3 := Chunk.coord_for_position(Vector3(0.0, 1.5, 3.0)) + _check("Walking 3m forward to Z=3 maps to chunk (0, 0, 0)", c3 == Vector3i(0, 0, 0)) + + var c20 := Chunk.coord_for_position(Vector3(10.0, 1.5, 20.0)) + _check("Inside chunk at (10, 1.5, 20) maps to (0, 0, 0)", c20 == Vector3i(0, 0, 0)) + + var c31 := Chunk.coord_for_position(Vector3(31.0, 1.5, 31.0)) + _check("Far corner of chunk at (31, 1.5, 31) maps to (0, 0, 0)", c31 == Vector3i(0, 0, 0)) + + # Crossing into adjacent chunks + var cz_next := Chunk.coord_for_position(Vector3(0.0, 1.5, 32.0)) + _check("Crossing Z=32 maps to chunk Y=1 (0, 1, 0)", cz_next == Vector3i(0, 1, 0)) + + var cx_next := Chunk.coord_for_position(Vector3(32.0, 1.5, 0.0)) + _check("Crossing X=32 maps to chunk X=1 (1, 0, 0)", cx_next == Vector3i(1, 0, 0)) + + var cz_prev := Chunk.coord_for_position(Vector3(0.0, 1.5, -1.0)) + _check("Negative Z maps to chunk Y=-1 (0, -1, 0)", cz_prev == Vector3i(0, -1, 0)) + + var cx_prev := Chunk.coord_for_position(Vector3(-1.0, 1.5, 0.0)) + _check("Negative X maps to chunk X=-1 (-1, 0, 0)", cx_prev == Vector3i(-1, 0, 0)) + + # Player height / jumping: + var c_jump := Chunk.coord_for_position(Vector3(0.0, 5.0, 0.0)) + _check("Jumping to height Y=5 in flat world stays at cz=0", c_jump == Vector3i(0, 0, 0)) + + +func _test_chunk_stream_manager_proximity() -> void: + print("--- ChunkStreamManager streaming proximity ---") + var sm := ChunkStreamManager.new() + sm.streaming_radius = 2 + + var player := Node3D.new() + player.position = Vector3(0.0, 1.5, 0.0) + root.add_child(player) + root.add_child(sm) + + sm.player = player + sm.is_active = true + + # Simulate immediate streaming update on spawn + sm._update_streaming() + var targets: Dictionary = sm._compute_target_chunks() + + _check("Target chunks computed on spawn", targets.size() > 0) + _check("Spawn chunk (0,0,0) is in target set", targets.has(Vector3i(0, 0, 0))) + + # Ensure flat world doesn't spam non-zero cz chunks + var all_cz_zero := true + for coord in targets.keys(): + if coord.z != 0: + all_cz_zero = false + break + _check("Flat world targets only contain cz=0", all_cz_zero) + + # Simulate moving close to placed lampposts (Z = 1, 2, 3, 4, 5, 10, 20) + player.position = Vector3(0.0, 1.5, 3.0) + sm._update_streaming() + targets = sm._compute_target_chunks() + _check("Approaching lamppost at Z=3: chunk (0,0,0) is STILL in target set", targets.has(Vector3i(0, 0, 0))) + + player.position = Vector3(5.0, 1.5, 15.0) + sm._update_streaming() + targets = sm._compute_target_chunks() + _check("Approaching lamppost at Z=15: chunk (0,0,0) is STILL in target set", targets.has(Vector3i(0, 0, 0))) + + player.position = Vector3(0.0, 1.5, 31.0) + sm._update_streaming() + targets = sm._compute_target_chunks() + _check("Approaching lamppost at Z=31: chunk (0,0,0) is STILL in target set", targets.has(Vector3i(0, 0, 0))) + + # Clean up + sm.queue_free() + player.queue_free()