diff --git a/scenes/mobs/FishSchool.tscn b/scenes/mobs/FishSchool.tscn new file mode 100644 index 0000000..244b2fe --- /dev/null +++ b/scenes/mobs/FishSchool.tscn @@ -0,0 +1,9 @@ +[gd_scene load_steps=2 format=3 uid="uid://fishschool001"] + +[ext_resource type="Script" path="res://scripts/mobs/FishSchool.gd" id="1_fishschool"] + +[node name="FishSchool" type="Node3D"] +script = ExtResource("1_fishschool") +fish_count = 12 +school_radius = 4.0 +swim_speed = 2.5 diff --git a/scenes/mobs/Jellyfish.tscn b/scenes/mobs/Jellyfish.tscn new file mode 100644 index 0000000..51a433e --- /dev/null +++ b/scenes/mobs/Jellyfish.tscn @@ -0,0 +1,11 @@ +[gd_scene load_steps=2 format=3 uid="uid://jellyfish001"] + +[ext_resource type="Script" path="res://scripts/mobs/Jellyfish.gd" id="1_jellyfish"] + +[node name="Jellyfish" type="CharacterBody3D"] +script = ExtResource("1_jellyfish") +max_health = 20.0 +damage = 5.0 +detection_radius = 6.0 +attack_radius = 1.5 +move_speed = 1.0 diff --git a/scenes/mobs/Shark.tscn b/scenes/mobs/Shark.tscn new file mode 100644 index 0000000..3ec3db0 --- /dev/null +++ b/scenes/mobs/Shark.tscn @@ -0,0 +1,11 @@ +[gd_scene load_steps=2 format=3 uid="uid://shark001"] + +[ext_resource type="Script" path="res://scripts/mobs/Shark.gd" id="1_shark"] + +[node name="Shark" type="CharacterBody3D"] +script = ExtResource("1_shark") +max_health = 50.0 +damage = 15.0 +detection_radius = 12.0 +move_speed = 4.0 +chase_speed = 7.0 diff --git a/scripts/mobs/FishSchool.gd b/scripts/mobs/FishSchool.gd new file mode 100644 index 0000000..1aeefbc --- /dev/null +++ b/scripts/mobs/FishSchool.gd @@ -0,0 +1,123 @@ +extends Node3D + +@export var fish_count: int = 12 +@export var school_radius: float = 4.0 +@export var swim_speed: float = 2.5 + +var _fish_meshes: Array[MeshInstance3D] = [] +var _fish_velocities: Array[Vector3] = [] +var _time: float = 0.0 +var _player: CharacterBody3D = null + +func _ready() -> void: + _find_player() + _spawn_fish() + + +func _find_player() -> void: + var players := get_tree().get_nodes_in_group("player") + if players.size() > 0: + _player = players[0] as CharacterBody3D + + +func _spawn_fish() -> void: + var mat := StandardMaterial3D.new() + mat.albedo_color = Color(0.75, 0.82, 0.95) + mat.metallic = 0.7 + mat.roughness = 0.3 + + for i: int in range(fish_count): + var mesh_inst := MeshInstance3D.new() + var cyl := CylinderMesh.new() + cyl.top_radius = 0.05 + cyl.bottom_radius = 0.15 + cyl.height = 0.4 + mesh_inst.mesh = cyl + mesh_inst.material_override = mat + + # Random position in sphere + var offset := Vector3( + randf_range(-school_radius, school_radius), + randf_range(-school_radius * 0.5, school_radius * 0.5), + randf_range(-school_radius, school_radius) + ) + mesh_inst.position = offset + # Rotate mesh so cone points forward (cylinder is vertical by default) + mesh_inst.rotation.z = PI * 0.5 + + add_child(mesh_inst) + _fish_meshes.append(mesh_inst) + + var rand_vel := Vector3( + randf_range(-1.0, 1.0), + randf_range(-0.3, 0.3), + randf_range(-1.0, 1.0) + ).normalized() * swim_speed + _fish_velocities.append(rand_vel) + + +func _process(delta: float) -> void: + _time += delta + + if _player == null: + _find_player() + + for i: int in range(_fish_meshes.size()): + var fish: MeshInstance3D = _fish_meshes[i] + var vel: Vector3 = _fish_velocities[i] + + # --- Boids: cohesion + alignment with 3 nearest neighbors --- + var avg_pos: Vector3 = Vector3.ZERO + var avg_vel: Vector3 = Vector3.ZERO + var neighbor_count: int = 0 + + # Sort by distance to find 3 nearest + var distances: Array = [] + for j: int in range(_fish_meshes.size()): + if j == i: + continue + var d: float = fish.position.distance_to(_fish_meshes[j].position) + distances.append({"idx": j, "dist": d}) + distances.sort_custom(func(a: Dictionary, b: Dictionary) -> bool: return a["dist"] < b["dist"]) + + for k: int in range(min(3, distances.size())): + var ni: int = distances[k]["idx"] + avg_pos += _fish_meshes[ni].position + avg_vel += _fish_velocities[ni] + neighbor_count += 1 + + if neighbor_count > 0: + avg_pos /= float(neighbor_count) + avg_vel /= float(neighbor_count) + var cohesion_force: Vector3 = (avg_pos - fish.position).normalized() * 0.5 + var alignment_force: Vector3 = avg_vel.normalized() * 0.3 + vel += (cohesion_force + alignment_force) * delta * 2.0 + + # --- Global cohesion: return to school center --- + var center_offset: Vector3 = fish.position # positions are local to school node + if center_offset.length() > school_radius: + vel += -center_offset.normalized() * 1.5 * delta * 4.0 + + # --- Player avoidance --- + if is_instance_valid(_player): + var world_fish_pos: Vector3 = global_position + fish.position + var dist_to_player: float = world_fish_pos.distance_to(_player.global_position) + if dist_to_player < 3.0: + var flee: Vector3 = (world_fish_pos - _player.global_position).normalized() + vel += flee * 4.0 * delta * (3.0 - dist_to_player) + + # Clamp speed + if vel.length() > swim_speed * 1.5: + vel = vel.normalized() * swim_speed * 1.5 + if vel.length() < 0.3: + vel = vel.normalized() * 0.3 + + _fish_velocities[i] = vel + fish.position += vel * delta + + # Rotate mesh to face velocity direction + if vel.length_squared() > 0.001: + var look_pos: Vector3 = fish.position + vel.normalized() + fish.look_at(look_pos, Vector3.UP) + # Compensate for the mesh's local rotation offset + fish.rotation.z += PI * 0.5 diff --git a/scripts/mobs/Jellyfish.gd b/scripts/mobs/Jellyfish.gd new file mode 100644 index 0000000..84b26c4 --- /dev/null +++ b/scripts/mobs/Jellyfish.gd @@ -0,0 +1,163 @@ +extends CharacterBody3D + +signal attacked_player(damage: float) + +@export var max_health: float = 20.0 +@export var damage: float = 5.0 +@export var detection_radius: float = 6.0 +@export var attack_radius: float = 1.5 +@export var move_speed: float = 1.0 + +enum State { WANDER, CHASE, ATTACK } + +var health: float +var _state: State = State.WANDER +var _time: float = 0.0 +var _wander_target: Vector3 = Vector3.ZERO +var _wander_timer: float = 0.0 +var _attack_cooldown: float = 0.0 +var _player: CharacterBody3D = null +var _base_scale: Vector3 = Vector3.ONE + +# Visual nodes +var _dome: MeshInstance3D +var _tentacles: Array[MeshInstance3D] = [] + + +func _ready() -> void: + health = max_health + _build_visuals() + _find_player() + _base_scale = scale + _pick_wander_target() + + +func _find_player() -> void: + var players := get_tree().get_nodes_in_group("player") + if players.size() > 0: + _player = players[0] as CharacterBody3D + + +func _build_visuals() -> void: + var mat := StandardMaterial3D.new() + mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA + mat.albedo_color = Color(0.9, 0.6, 0.9, 0.5) + mat.emission_enabled = true + mat.emission = Color(0.5, 0.1, 0.7) + mat.emission_energy_multiplier = 0.8 + mat.cull_mode = BaseMaterial3D.CULL_DISABLED + + # Dome (sphere scaled to look like a bell) + _dome = MeshInstance3D.new() + var sphere := SphereMesh.new() + sphere.radius = 0.5 + sphere.height = 0.7 + _dome.mesh = sphere + _dome.material_override = mat + _dome.scale = Vector3(1.0, 0.7, 1.0) + add_child(_dome) + + # 4 tentacles + var tent_mat := StandardMaterial3D.new() + tent_mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA + tent_mat.albedo_color = Color(0.85, 0.5, 0.85, 0.35) + tent_mat.emission_enabled = true + tent_mat.emission = Color(0.4, 0.0, 0.6) + tent_mat.emission_energy_multiplier = 0.5 + tent_mat.cull_mode = BaseMaterial3D.CULL_DISABLED + + var offsets: Array[Vector2] = [ + Vector2(0.2, 0.0), Vector2(-0.2, 0.0), + Vector2(0.0, 0.2), Vector2(0.0, -0.2) + ] + for off: Vector2 in offsets: + var tent := MeshInstance3D.new() + var cap := CapsuleMesh.new() + cap.radius = 0.04 + cap.height = 0.6 + tent.mesh = cap + tent.material_override = tent_mat + tent.position = Vector3(off.x, -0.55, off.y) + add_child(tent) + _tentacles.append(tent) + + # Collision shape + var col := CollisionShape3D.new() + var cshape := SphereShape3D.new() + cshape.radius = 0.55 + col.shape = cshape + add_child(col) + + +func _process(delta: float) -> void: + _time += delta + # Pulse animation + var pulse: float = 1.0 + sin(_time * 3.0) * 0.08 + _dome.scale.y = 0.7 * pulse + + +func _physics_process(delta: float) -> void: + if _player == null: + _find_player() + + _attack_cooldown = max(0.0, _attack_cooldown - delta) + _wander_timer = max(0.0, _wander_timer - delta) + + var player_dist: float = INF + if is_instance_valid(_player): + player_dist = global_position.distance_to(_player.global_position) + + match _state: + State.WANDER: + _do_wander(delta) + if player_dist < detection_radius: + _state = State.CHASE + + State.CHASE: + if is_instance_valid(_player): + var dir: Vector3 = (_player.global_position - global_position).normalized() + velocity = dir * move_speed + if player_dist < attack_radius: + _state = State.ATTACK + if player_dist > detection_radius * 1.5: + _state = State.WANDER + + State.ATTACK: + if is_instance_valid(_player) and _attack_cooldown <= 0.0: + attacked_player.emit(damage) + _attack_cooldown = 1.5 + if player_dist > attack_radius * 1.5: + _state = State.CHASE + + # Smooth rotation toward movement + if velocity.length_squared() > 0.01: + var target_basis: Basis = Basis.looking_at(velocity.normalized(), Vector3.UP) + transform.basis = transform.basis.slerp(target_basis, delta * 3.0) + + move_and_slide() + + +func _do_wander(delta: float) -> void: + if _wander_timer <= 0.0: + _pick_wander_target() + + var dir: Vector3 = (_wander_target - global_position) + if dir.length() < 0.5: + _pick_wander_target() + else: + velocity = dir.normalized() * move_speed * 0.5 + + +func _pick_wander_target() -> void: + _wander_target = global_position + Vector3( + randf_range(-5.0, 5.0), + randf_range(-2.0, 2.0), + randf_range(-5.0, 5.0) + ) + _wander_timer = randf_range(3.0, 6.0) + + +func take_damage(dmg: float) -> void: + health -= dmg + if health <= 0.0: + queue_free() diff --git a/scripts/mobs/MobSpawner.gd b/scripts/mobs/MobSpawner.gd new file mode 100644 index 0000000..cceb71d --- /dev/null +++ b/scripts/mobs/MobSpawner.gd @@ -0,0 +1,161 @@ +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 + +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" + +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] = [] + + +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) + + # --- 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 + + +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 _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 diff --git a/scripts/mobs/Shark.gd b/scripts/mobs/Shark.gd new file mode 100644 index 0000000..1b7f20a --- /dev/null +++ b/scripts/mobs/Shark.gd @@ -0,0 +1,189 @@ +extends CharacterBody3D + +signal attacked_player(damage: float) + +@export var max_health: float = 50.0 +@export var damage: float = 15.0 +@export var detection_radius: float = 12.0 +@export var move_speed: float = 4.0 +@export var chase_speed: float = 7.0 + +enum State { PATROL, CHASE, ATTACK } + +var health: float +var _state: State = State.PATROL +var _time: float = 0.0 +var _patrol_points: Array[Vector3] = [] +var _patrol_index: int = 0 +var _attack_cooldown: float = 0.0 +var _player: CharacterBody3D = null + +# Visual +var _body: MeshInstance3D +var _head: MeshInstance3D +var _tail: MeshInstance3D +var _fin: MeshInstance3D + + +func _ready() -> void: + health = max_health + _build_visuals() + _find_player() + _generate_patrol_points() + + +func _find_player() -> void: + var players := get_tree().get_nodes_in_group("player") + if players.size() > 0: + _player = players[0] as CharacterBody3D + + +func _build_visuals() -> void: + # Top dark grey material + var mat_dark := StandardMaterial3D.new() + mat_dark.albedo_color = Color(0.165, 0.208, 0.251) # #2a3540 + mat_dark.roughness = 0.8 + + # Bottom dirty white material + var mat_white := StandardMaterial3D.new() + mat_white.albedo_color = Color(0.816, 0.835, 0.847) # #d0d5d8 + mat_white.roughness = 0.8 + + # Body (central capsule) + _body = MeshInstance3D.new() + var body_mesh := CapsuleMesh.new() + body_mesh.radius = 0.4 + body_mesh.height = 1.6 + _body.mesh = body_mesh + _body.material_override = mat_dark + _body.rotation.z = PI * 0.5 + add_child(_body) + + # Head (smaller capsule in front) + _head = MeshInstance3D.new() + var head_mesh := CapsuleMesh.new() + head_mesh.radius = 0.28 + head_mesh.height = 0.8 + _head.mesh = head_mesh + _head.material_override = mat_dark + _head.rotation.z = PI * 0.5 + _head.position = Vector3(0.95, 0.0, 0.0) + add_child(_head) + + # Tail (smaller capsule behind) + _tail = MeshInstance3D.new() + var tail_mesh := CapsuleMesh.new() + tail_mesh.radius = 0.2 + tail_mesh.height = 0.7 + _tail.mesh = tail_mesh + _tail.material_override = mat_dark + _tail.rotation.z = PI * 0.5 + _tail.position = Vector3(-0.95, 0.0, 0.0) + add_child(_tail) + + # Dorsal fin (prism) + _fin = MeshInstance3D.new() + var prism := PrismMesh.new() + prism.size = Vector3(0.5, 0.5, 0.15) + _fin.mesh = prism + _fin.material_override = mat_dark + _fin.position = Vector3(0.1, 0.4, 0.0) + add_child(_fin) + + # Bottom lighter area (belly) + var belly := MeshInstance3D.new() + var belly_mesh := CapsuleMesh.new() + belly_mesh.radius = 0.38 + belly_mesh.height = 1.4 + belly.mesh = belly_mesh + belly.material_override = mat_white + belly.rotation.z = PI * 0.5 + belly.position = Vector3(0.0, -0.05, 0.0) + belly.scale = Vector3(1.0, 0.3, 1.0) + add_child(belly) + + # Collision + var col := CollisionShape3D.new() + var cshape := CapsuleShape3D.new() + cshape.radius = 0.4 + cshape.height = 2.4 + col.shape = cshape + col.rotation.z = PI * 0.5 + add_child(col) + + +func _generate_patrol_points() -> void: + _patrol_points.clear() + for _i: int in range(3): + _patrol_points.append(global_position + Vector3( + randf_range(-15.0, 15.0), + randf_range(-3.0, 3.0), + randf_range(-15.0, 15.0) + )) + + +func _physics_process(delta: float) -> void: + _time += delta + + if _player == null: + _find_player() + + _attack_cooldown = max(0.0, _attack_cooldown - delta) + + var player_dist: float = INF + if is_instance_valid(_player): + player_dist = global_position.distance_to(_player.global_position) + + match _state: + State.PATROL: + _do_patrol(delta) + if player_dist < detection_radius: + _state = State.CHASE + + State.CHASE: + if is_instance_valid(_player): + var dir: Vector3 = (_player.global_position - global_position).normalized() + velocity = dir * chase_speed + if player_dist < 2.0: + _state = State.ATTACK + if player_dist > detection_radius * 2.0: + _state = State.PATROL + _generate_patrol_points() + _patrol_index = 0 + + State.ATTACK: + if is_instance_valid(_player) and _attack_cooldown <= 0.0: + attacked_player.emit(damage) + _attack_cooldown = 2.0 + if player_dist > 3.0: + _state = State.CHASE + + # Smooth rotation toward velocity + if velocity.length_squared() > 0.1: + var target_basis: Basis = Basis.looking_at(velocity.normalized(), Vector3.UP) + transform.basis = transform.basis.slerp(target_basis, delta * 4.0) + + # Tail wag animation + if is_instance_valid(_tail): + _tail.rotation.y = sin(_time * 5.0) * 0.4 + + move_and_slide() + + +func _do_patrol(delta: float) -> void: + if _patrol_points.is_empty(): + _generate_patrol_points() + return + + var target: Vector3 = _patrol_points[_patrol_index] + var dir: Vector3 = target - global_position + if dir.length() < 1.0: + _patrol_index = (_patrol_index + 1) % _patrol_points.size() + else: + velocity = dir.normalized() * move_speed + + +func take_damage(dmg: float) -> void: + health -= dmg + if health <= 0.0: + queue_free()