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:
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()
|
||||
Reference in New Issue
Block a user