feat(mobs): fish schools + jellyfish + shark + spawner with distance-based life cycle
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
9
scenes/mobs/FishSchool.tscn
Normal file
9
scenes/mobs/FishSchool.tscn
Normal file
@@ -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
|
||||||
11
scenes/mobs/Jellyfish.tscn
Normal file
11
scenes/mobs/Jellyfish.tscn
Normal file
@@ -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
|
||||||
11
scenes/mobs/Shark.tscn
Normal file
11
scenes/mobs/Shark.tscn
Normal file
@@ -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
|
||||||
123
scripts/mobs/FishSchool.gd
Normal file
123
scripts/mobs/FishSchool.gd
Normal file
@@ -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
|
||||||
163
scripts/mobs/Jellyfish.gd
Normal file
163
scripts/mobs/Jellyfish.gd
Normal file
@@ -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()
|
||||||
161
scripts/mobs/MobSpawner.gd
Normal file
161
scripts/mobs/MobSpawner.gd
Normal file
@@ -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
|
||||||
189
scripts/mobs/Shark.gd
Normal file
189
scripts/mobs/Shark.gd
Normal file
@@ -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()
|
||||||
Reference in New Issue
Block a user