220 lines
5.8 KiB
GDScript
220 lines
5.8 KiB
GDScript
extends CharacterBody3D
|
|
|
|
signal attacked_player(damage: float, knockback_dir: Vector3)
|
|
|
|
@export var max_health: float = 50.0
|
|
@export var damage: float = 20.0
|
|
@export var detection_radius: float = 15.0 # chase trigger distance (15 units)
|
|
@export var move_speed: float = 4.0
|
|
@export var chase_speed: float = 7.0
|
|
@export var knockback_force: float = 14.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()
|
|
add_to_group("shark")
|
|
|
|
|
|
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:
|
|
var kb_dir: Vector3 = (_player.global_position - global_position).normalized()
|
|
attacked_player.emit(damage, kb_dir)
|
|
# Apply knockback directly on player
|
|
if _player.has_method("apply_knockback"):
|
|
_player.call("apply_knockback", kb_dir * knockback_force)
|
|
elif "velocity" in _player:
|
|
_player.velocity += kb_dir * knockback_force
|
|
_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:
|
|
_drop_loot()
|
|
queue_free()
|
|
|
|
|
|
func _drop_loot() -> void:
|
|
# Give shark tooth to nearby player inventory
|
|
if not is_instance_valid(_player):
|
|
return
|
|
var dist: float = global_position.distance_to(_player.global_position)
|
|
if dist > 12.0:
|
|
return
|
|
var main: Node = get_tree().get_first_node_in_group("main")
|
|
if main == null or main.get("inventory") == null:
|
|
return
|
|
# Drop 1-2 shark teeth
|
|
var count: int = randi_range(1, 2)
|
|
main.inventory.add_item(107, count)
|
|
# Visual popup
|
|
if main.has_method("_spawn_xp_popup"):
|
|
main.call("_spawn_xp_popup", 0, global_position)
|
|
var pp: Node = get_node_or_null("/root/PlayerProgress")
|
|
if pp != null:
|
|
pp.award(15, "requin", global_position)
|