extends Node3D @export var player: NodePath = NodePath("") @export var spawn_radius: float = 25.0 @export var despawn_radius: float = 40.0 @export var max_fish_schools: int = 3 @export var max_jellyfish: int = 5 @export var max_sharks: int = 1 @export var abyssal_jellyfish_max: int = 8 const FISH_SCHOOL_SCENE := "res://scenes/mobs/FishSchool.tscn" const JELLYFISH_SCENE := "res://scenes/mobs/Jellyfish.tscn" const SHARK_SCENE := "res://scenes/mobs/Shark.tscn" const ABYSSAL_JELLYFISH_SCENE := "res://scenes/mobs/AbyssalJellyfish.tscn" var _player_node: CharacterBody3D = null var _chunk_manager: Node = null var _timer: float = 0.0 const SPAWN_INTERVAL: float = 5.0 var _fish_schools: Array[Node3D] = [] var _jellyfish_list: Array[Node3D] = [] var _sharks: Array[Node3D] = [] var _abyssal_jellyfish_list: Array[Node3D] = [] func _ready() -> void: _resolve_player() _find_chunk_manager() func _resolve_player() -> void: if not player.is_empty(): var n: Node = get_node_or_null(player) if n is CharacterBody3D: _player_node = n as CharacterBody3D if _player_node == null: var players := get_tree().get_nodes_in_group("player") if players.size() > 0: _player_node = players[0] as CharacterBody3D func _find_chunk_manager() -> void: # Try common path var cm: Node = get_node_or_null("/root/Main/World/ChunkManager") if cm != null: _chunk_manager = cm return # Fallback: search by class name var candidates := get_tree().get_nodes_in_group("chunk_manager") if candidates.size() > 0: _chunk_manager = candidates[0] func _physics_process(delta: float) -> void: _timer += delta if _timer >= SPAWN_INTERVAL: _timer = 0.0 _resolve_player() _tick_spawner() func _tick_spawner() -> void: if not is_instance_valid(_player_node): return var player_pos: Vector3 = _player_node.global_position # --- Despawn far mobs --- _cull_array(_fish_schools, player_pos) _cull_array(_jellyfish_list, player_pos) _cull_array(_sharks, player_pos) _cull_array(_abyssal_jellyfish_list, player_pos) # --- Spawn fish schools --- while _fish_schools.size() < max_fish_schools: var pos: Vector3 = _random_spawn_pos(player_pos) if _is_valid_spawn(pos): var inst: Node3D = _load_and_instantiate(FISH_SCHOOL_SCENE, pos) if inst != null: _fish_schools.append(inst) else: break # --- Spawn jellyfish --- while _jellyfish_list.size() < max_jellyfish: var pos: Vector3 = _random_spawn_pos(player_pos) if _is_valid_spawn(pos): var inst: Node3D = _load_and_instantiate(JELLYFISH_SCENE, pos) if inst != null: _connect_mob_signal(inst) _jellyfish_list.append(inst) else: break # --- Spawn sharks --- while _sharks.size() < max_sharks: var pos: Vector3 = _random_spawn_pos(player_pos) if _is_valid_spawn(pos): var inst: Node3D = _load_and_instantiate(SHARK_SCENE, pos) if inst != null: _connect_mob_signal(inst) _sharks.append(inst) else: break # --- Spawn abyssal jellyfish only in deep zone --- if player_pos.y < -30.0: while _abyssal_jellyfish_list.size() < abyssal_jellyfish_max: var pos: Vector3 = _random_abyss_spawn_pos(player_pos) if _is_valid_spawn(pos): var inst: Node3D = _load_and_instantiate(ABYSSAL_JELLYFISH_SCENE, pos) if inst != null: _connect_mob_signal(inst) _abyssal_jellyfish_list.append(inst) else: break func _cull_array(arr: Array[Node3D], player_pos: Vector3) -> void: var i: int = arr.size() - 1 while i >= 0: if not is_instance_valid(arr[i]): arr.remove_at(i) elif arr[i].global_position.distance_to(player_pos) > despawn_radius: arr[i].queue_free() arr.remove_at(i) i -= 1 func _random_spawn_pos(player_pos: Vector3) -> Vector3: var angle: float = randf_range(0.0, TAU) var dist: float = randf_range(15.0, spawn_radius) var depth: float = randf_range(-40.0, 50.0) return Vector3( player_pos.x + cos(angle) * dist, depth, player_pos.z + sin(angle) * dist ) func _random_abyss_spawn_pos(player_pos: Vector3) -> Vector3: var angle: float = randf_range(0.0, TAU) var dist: float = randf_range(10.0, spawn_radius) var depth: float = randf_range(player_pos.y - 15.0, player_pos.y + 5.0) depth = min(depth, -30.0) return Vector3( player_pos.x + cos(angle) * dist, depth, player_pos.z + sin(angle) * dist ) func _is_valid_spawn(pos: Vector3) -> bool: if _chunk_manager == null: return true if not _chunk_manager.has_method("get_block"): return true var block_id: int = _chunk_manager.call("get_block", pos) # Block IDs 2..8 are solid — don't spawn inside solid blocks if block_id >= 2 and block_id <= 8: return false return true func _load_and_instantiate(scene_path: String, pos: Vector3) -> Node3D: var packed: PackedScene = load(scene_path) if packed == null: return null var inst: Node3D = packed.instantiate() as Node3D if inst == null: return null get_tree().current_scene.add_child(inst) inst.global_position = pos return inst func _connect_mob_signal(mob: Node3D) -> void: if mob.has_signal("attacked_player"): if not mob.is_connected("attacked_player", _on_mob_attacked_player): mob.attacked_player.connect(_on_mob_attacked_player) func _on_mob_attacked_player(dmg: float) -> void: if is_instance_valid(_player_node): if _player_node.has_method("take_damage"): _player_node.take_damage(dmg) elif "health" in _player_node: _player_node.health -= dmg