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()