MultiplayerFighter
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

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