You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
285 lines
7.0 KiB
285 lines
7.0 KiB
extends BaseEnemy |
|
class_name BasicEnemy |
|
|
|
## A basic melee enemy that chases and attacks players |
|
## Server-authoritative AI with multiplayer support |
|
|
|
## Movement |
|
@export var move_speed: float = 3.0 |
|
@export var chase_range: float = 15.0 |
|
@export var attack_range: float = 2.5 |
|
|
|
## Combat |
|
@export var attack_damage: float = 15.0 |
|
@export var attack_knockback: float = 10.0 |
|
@export var attack_cooldown: float = 1.5 |
|
@export_category("Attack Timing") |
|
@export var attack_startup: float = 0.3 # Wind-up before hit |
|
@export var attack_active: float = 0.4 # Hit window duration |
|
|
|
## References |
|
var _hitbox: HitBox = null |
|
var _hurtbox: HurtBox = null |
|
var _mesh: MeshInstance3D = null |
|
|
|
## AI State |
|
var _attack_timer: float = 0.0 |
|
var _is_attacking: bool = false |
|
var _original_material: Material = null |
|
var _hit_flash_timer: float = 0.0 |
|
const HIT_FLASH_DURATION: float = 0.2 |
|
|
|
func _enter_tree(): |
|
# Enemies are always server-authoritative |
|
set_multiplayer_authority(1) |
|
|
|
func _ready(): |
|
super._ready() |
|
|
|
# Find mesh, hitbox, and hurtbox |
|
_mesh = get_node_or_null("Mesh") |
|
_hitbox = get_node_or_null("HitBox") |
|
_hurtbox = get_node_or_null("HurtBox") |
|
|
|
# Store original material for hit flash |
|
if _mesh: |
|
_original_material = _mesh.get_surface_override_material(0) |
|
if not _original_material and _mesh.mesh: |
|
_original_material = _mesh.mesh.surface_get_material(0) |
|
|
|
# Setup hitbox |
|
if _hitbox: |
|
_hitbox.owner_entity = self |
|
_hitbox.set_stats(attack_damage, attack_knockback) |
|
_hitbox.hit_landed.connect(_on_hitbox_hit) |
|
|
|
# Setup hurtbox |
|
if _hurtbox: |
|
_hurtbox.owner_entity = self |
|
|
|
func _process(delta): |
|
# Countdown timers |
|
if _attack_timer > 0: |
|
_attack_timer -= delta |
|
|
|
# Handle hit flash |
|
if _hit_flash_timer > 0: |
|
_hit_flash_timer -= delta |
|
if _hit_flash_timer <= 0: |
|
_reset_material() |
|
|
|
func _physics_process(delta): |
|
super._physics_process(delta) |
|
|
|
# Only server runs AI |
|
if not multiplayer.is_server(): |
|
return |
|
|
|
if is_dead: |
|
return |
|
|
|
# Apply gravity |
|
if not is_on_floor(): |
|
velocity.y -= ProjectSettings.get_setting("physics/3d/default_gravity") * delta |
|
|
|
# AI behavior |
|
if current_target and is_instance_valid(current_target): |
|
_ai_combat(delta) |
|
else: |
|
# Stop moving if no target |
|
velocity.x = 0 |
|
velocity.z = 0 |
|
|
|
move_and_slide() |
|
|
|
## Override to find nearest player without range limit |
|
func get_nearest_player() -> Node: |
|
# Find all players in a very large range (essentially unlimited) |
|
var players = get_players_in_range(1000.0) # 1000m range - basically unlimited |
|
|
|
if players.is_empty(): |
|
return null |
|
|
|
var nearest_player = null |
|
var nearest_distance = INF |
|
|
|
for player in players: |
|
var distance = global_position.distance_to(player.global_position) |
|
if distance < nearest_distance: |
|
nearest_distance = distance |
|
nearest_player = player |
|
|
|
return nearest_player |
|
|
|
## Update target - called by BaseEnemy |
|
func _update_target(): |
|
if not is_aggressive: |
|
return |
|
|
|
# Always find and target the nearest player (no range limit) |
|
var nearest = get_nearest_player() |
|
|
|
if nearest: |
|
# Update target if it changed |
|
if current_target != nearest: |
|
current_target = nearest |
|
target_changed.emit(nearest) |
|
else: |
|
# No players exist |
|
if current_target != null: |
|
current_target = null |
|
target_changed.emit(null) |
|
|
|
## Combat AI behavior |
|
func _ai_combat(delta): |
|
if not current_target or not is_instance_valid(current_target): |
|
return |
|
|
|
var target_pos = current_target.global_position |
|
var direction = (target_pos - global_position).normalized() |
|
var distance = global_position.distance_to(target_pos) |
|
|
|
# Face the target |
|
if direction.length() > 0.01: |
|
var look_dir = Vector3(direction.x, 0, direction.z) |
|
if look_dir.length() > 0.01: |
|
look_at(global_position + look_dir, Vector3.UP) |
|
|
|
# If in attack range, attack |
|
if distance <= attack_range: |
|
# Stop moving when attacking |
|
velocity.x = 0 |
|
velocity.z = 0 |
|
|
|
# Try to attack |
|
if _attack_timer <= 0 and not _is_attacking: |
|
_perform_attack() |
|
else: |
|
# Always chase the target (no range limit) |
|
velocity.x = direction.x * move_speed |
|
velocity.z = direction.z * move_speed |
|
|
|
## Perform melee attack |
|
func _perform_attack(): |
|
if _is_attacking or not multiplayer.is_server(): |
|
return |
|
|
|
# Calculate total attack duration |
|
var total_duration = attack_startup + attack_active |
|
var cooldown = max(attack_cooldown, total_duration) |
|
|
|
_attack_timer = cooldown |
|
_is_attacking = true |
|
|
|
# Play attack animation on all clients |
|
rpc("_sync_attack_animation") |
|
|
|
# Activate hitbox |
|
_activate_hitbox() |
|
|
|
## Activate hitbox for attack |
|
func _activate_hitbox(): |
|
if not _hitbox or not multiplayer.is_server(): |
|
_is_attacking = false |
|
return |
|
|
|
# STARTUP PHASE - Wait before activating |
|
if attack_startup > 0: |
|
await get_tree().create_timer(attack_startup).timeout |
|
|
|
if not _hitbox or not is_instance_valid(_hitbox) or is_dead: |
|
_is_attacking = false |
|
return |
|
|
|
# ACTIVE PHASE - Hitbox on |
|
_hitbox.activate() |
|
|
|
await get_tree().create_timer(attack_active).timeout |
|
|
|
# RECOVERY PHASE - Hitbox off |
|
if _hitbox and is_instance_valid(_hitbox): |
|
_hitbox.deactivate() |
|
|
|
_is_attacking = false |
|
|
|
## Called when hitbox hits something |
|
func _on_hitbox_hit(target: Node, damage_amount: float, knockback_amount: float, attacker_pos: Vector3): |
|
if not target or not multiplayer.is_server(): |
|
return |
|
|
|
# Flash target's hurtbox |
|
if target is Node: |
|
var hurtbox = target.find_child("HurtBox", true, false) |
|
if hurtbox and hurtbox.has_method("flash_hit"): |
|
hurtbox.flash_hit() |
|
|
|
# Server applies damage directly |
|
if target is BaseUnit: |
|
target.take_damage(damage_amount, 1, knockback_amount, global_position) |
|
|
|
## Play attack animation (synced to all clients) |
|
@rpc("any_peer", "call_local", "reliable") |
|
func _sync_attack_animation(): |
|
# Visual feedback for attack |
|
# Could play animation here if you have an AnimationPlayer |
|
pass |
|
|
|
## Override hurt animation to flash red |
|
@rpc("any_peer", "call_local", "reliable") |
|
func _play_hurt_animation(): |
|
if _mesh: |
|
_flash_red() |
|
|
|
## Flash red when hit |
|
func _flash_red(): |
|
if not _mesh: |
|
return |
|
|
|
_hit_flash_timer = HIT_FLASH_DURATION |
|
|
|
# Create red material |
|
var red_material = StandardMaterial3D.new() |
|
red_material.albedo_color = Color(1.5, 0.3, 0.3) |
|
|
|
# Copy properties from original if exists |
|
if _original_material and _original_material is StandardMaterial3D: |
|
var orig = _original_material as StandardMaterial3D |
|
red_material.metallic = orig.metallic |
|
red_material.roughness = orig.roughness |
|
red_material.albedo_texture = orig.albedo_texture |
|
|
|
_mesh.set_surface_override_material(0, red_material) |
|
|
|
## Reset material |
|
func _reset_material(): |
|
if _mesh and _original_material: |
|
_mesh.set_surface_override_material(0, _original_material.duplicate()) |
|
|
|
## Death callback |
|
func _on_enemy_died(killer_id: int): |
|
super._on_enemy_died(killer_id) |
|
|
|
# Hide when dead |
|
if _mesh: |
|
_mesh.visible = false |
|
|
|
# Deactivate hitbox |
|
if _hitbox: |
|
_hitbox.deactivate() |
|
|
|
print("[BasicEnemy ", name, "] killed by ", killer_id) |
|
|
|
## Respawn callback |
|
func _on_enemy_respawned(): |
|
super._on_enemy_respawned() |
|
|
|
# Show mesh |
|
if _mesh: |
|
_mesh.visible = true |
|
_reset_material() |
|
|
|
# Reset state |
|
_attack_timer = 0.0 |
|
_is_attacking = false |
|
|
|
print("[BasicEnemy ", name, "] respawned")
|
|
|