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.
617 lines
18 KiB
617 lines
18 KiB
|
1 month ago
|
extends BaseEnemy
|
||
|
|
class_name ArmedEnemy
|
||
|
|
|
||
|
|
## An enemy that uses the player model, animations, and can equip weapons
|
||
|
|
## Drops equipped weapons on death using the existing world weapon spawn system
|
||
|
|
|
||
|
|
## Movement
|
||
|
|
@export var move_speed: float = 4.0
|
||
|
|
@export var chase_range: float = 20.0
|
||
|
|
@export var attack_range: float = 2.5
|
||
|
|
|
||
|
|
## Combat (unarmed fallback)
|
||
|
|
@export var unarmed_damage: float = 10.0
|
||
|
|
@export var unarmed_knockback: float = 5.0
|
||
|
|
@export var attack_cooldown: float = 1.0
|
||
|
|
@export_category("Unarmed Attack Timing")
|
||
|
|
@export var unarmed_startup: float = 0.15
|
||
|
|
@export var unarmed_active: float = 0.2
|
||
|
|
|
||
|
|
## Weapon system
|
||
|
|
@export_category("Weapons")
|
||
|
|
@export var starting_weapon: WeaponData = null ## Weapon to equip on spawn
|
||
|
|
@export var starting_offhand: WeaponData = null ## Off-hand weapon to equip on spawn
|
||
|
|
|
||
|
|
## Body reference (LilguyBody for animations)
|
||
|
|
@export var _body: Node3D = null
|
||
|
|
@export var _weapon_attachment: BoneAttachment3D = null
|
||
|
|
@export var _weapon_container: Node3D = null
|
||
|
|
@export var _offhand_attachment: BoneAttachment3D = null
|
||
|
|
@export var _offhand_container: Node3D = null
|
||
|
|
|
||
|
|
## Runtime weapon state
|
||
|
|
var equipped_weapon: BaseWeapon = null
|
||
|
|
var equipped_offhand: BaseWeapon = null
|
||
|
|
|
||
|
|
## AI State
|
||
|
|
var _attack_timer: float = 0.0
|
||
|
|
var _is_attacking: bool = false
|
||
|
|
var _unarmed_hitbox: HitBox = null
|
||
|
|
|
||
|
|
## Visual feedback
|
||
|
|
var _hit_flash_timer: float = 0.0
|
||
|
|
const HIT_FLASH_DURATION: float = 0.2
|
||
|
|
|
||
|
|
## Position sync (manual sync instead of MultiplayerSynchronizer for dynamic spawning)
|
||
|
|
var _sync_timer: float = 0.0
|
||
|
|
const SYNC_INTERVAL: float = 0.05 # 20 times per second
|
||
|
|
|
||
|
|
func _enter_tree():
|
||
|
|
# Enemies are always server-authoritative
|
||
|
|
set_multiplayer_authority(1)
|
||
|
|
|
||
|
|
func _ready():
|
||
|
|
super._ready()
|
||
|
|
|
||
|
|
# Auto-find body if not set
|
||
|
|
if _body == null:
|
||
|
|
if has_node("LilguyRigged/Armature"):
|
||
|
|
_body = get_node("LilguyRigged/Armature")
|
||
|
|
|
||
|
|
# Auto-find weapon attachments
|
||
|
|
if _weapon_attachment == null:
|
||
|
|
_weapon_attachment = get_node_or_null("LilguyRigged/Armature/Skeleton3D/WeaponPoint")
|
||
|
|
|
||
|
|
if _weapon_container == null and _weapon_attachment:
|
||
|
|
_weapon_container = _weapon_attachment.get_node_or_null("WeaponContainer")
|
||
|
|
|
||
|
|
if _offhand_attachment == null:
|
||
|
|
_offhand_attachment = get_node_or_null("LilguyRigged/Armature/Skeleton3D/OffhandPoint")
|
||
|
|
|
||
|
|
if _offhand_container == null and _offhand_attachment:
|
||
|
|
_offhand_container = _offhand_attachment.get_node_or_null("OffhandContainer")
|
||
|
|
|
||
|
|
# Setup unarmed hitbox
|
||
|
|
call_deferred("_setup_unarmed_hitbox")
|
||
|
|
|
||
|
|
# Equip starting weapons
|
||
|
|
# Server will equip and send RPC to sync; clients also equip directly to handle late-join
|
||
|
|
call_deferred("_equip_starting_weapons_local")
|
||
|
|
|
||
|
|
## Equip starting weapons - server uses RPC to sync, clients equip directly
|
||
|
|
func _equip_starting_weapons_local():
|
||
|
|
# Wait a frame to ensure everything is ready
|
||
|
|
await get_tree().process_frame
|
||
|
|
|
||
|
|
# Check if multiplayer peer is assigned
|
||
|
|
if multiplayer.multiplayer_peer == null:
|
||
|
|
# No multiplayer yet, just equip locally
|
||
|
|
if starting_weapon:
|
||
|
|
_equip_weapon(starting_weapon, false)
|
||
|
|
if starting_offhand:
|
||
|
|
_equip_weapon(starting_offhand, true)
|
||
|
|
return
|
||
|
|
|
||
|
|
if multiplayer.is_server():
|
||
|
|
# Server equips via RPC to sync to all clients
|
||
|
|
if starting_weapon:
|
||
|
|
print("[ArmedEnemy ", name, "] Server equipping starting weapon: ", starting_weapon.resource_path)
|
||
|
|
rpc("_equip_weapon_sync", starting_weapon.resource_path, false)
|
||
|
|
if starting_offhand:
|
||
|
|
print("[ArmedEnemy ", name, "] Server equipping starting offhand: ", starting_offhand.resource_path)
|
||
|
|
rpc("_equip_weapon_sync", starting_offhand.resource_path, true)
|
||
|
|
else:
|
||
|
|
# Client equips directly (for late-join clients who won't receive server's initial RPC)
|
||
|
|
# Skip if already equipped (from server RPC)
|
||
|
|
if starting_weapon and not equipped_weapon:
|
||
|
|
print("[ArmedEnemy ", name, "] Client equipping starting weapon directly")
|
||
|
|
_equip_weapon(starting_weapon, false)
|
||
|
|
if starting_offhand and not equipped_offhand:
|
||
|
|
print("[ArmedEnemy ", name, "] Client equipping starting offhand directly")
|
||
|
|
_equip_weapon(starting_offhand, true)
|
||
|
|
|
||
|
|
## Equip weapon on all clients
|
||
|
|
@rpc("any_peer", "call_local", "reliable")
|
||
|
|
func _equip_weapon_sync(weapon_data_path: String, is_offhand: bool):
|
||
|
|
print("[ArmedEnemy ", name, "] _equip_weapon_sync called on peer ", multiplayer.get_unique_id(), " path: ", weapon_data_path)
|
||
|
|
if weapon_data_path == "":
|
||
|
|
push_error("[ArmedEnemy] Empty weapon path!")
|
||
|
|
return
|
||
|
|
var data = load(weapon_data_path) as WeaponData
|
||
|
|
if data:
|
||
|
|
_equip_weapon(data, is_offhand)
|
||
|
|
else:
|
||
|
|
push_error("[ArmedEnemy] Failed to load weapon data from: ", weapon_data_path)
|
||
|
|
|
||
|
|
func _equip_weapon(data: WeaponData, is_offhand: bool = false):
|
||
|
|
# Unequip current weapon in that hand first
|
||
|
|
if is_offhand:
|
||
|
|
if equipped_offhand:
|
||
|
|
_unequip_weapon(true)
|
||
|
|
else:
|
||
|
|
if equipped_weapon:
|
||
|
|
_unequip_weapon(false)
|
||
|
|
|
||
|
|
# Determine attachment point
|
||
|
|
var attach_point: Node3D
|
||
|
|
if is_offhand:
|
||
|
|
attach_point = _offhand_container if _offhand_container else _offhand_attachment
|
||
|
|
else:
|
||
|
|
attach_point = _weapon_container if _weapon_container else _weapon_attachment
|
||
|
|
|
||
|
|
if not attach_point:
|
||
|
|
push_error("[ArmedEnemy] No weapon attachment point found")
|
||
|
|
return
|
||
|
|
|
||
|
|
# Create weapon instance
|
||
|
|
var weapon = BaseWeapon.new()
|
||
|
|
weapon.weapon_data = data
|
||
|
|
weapon.name = "EquippedOffHand" if is_offhand else "EquippedWeapon"
|
||
|
|
|
||
|
|
# Add to scene first (so _ready is called and hitbox is set up)
|
||
|
|
attach_point.add_child(weapon)
|
||
|
|
|
||
|
|
# Set owner for damage routing (must be after add_child so hitbox exists)
|
||
|
|
weapon.set_owner_character(self)
|
||
|
|
|
||
|
|
# Store reference
|
||
|
|
if is_offhand:
|
||
|
|
equipped_offhand = weapon
|
||
|
|
else:
|
||
|
|
equipped_weapon = weapon
|
||
|
|
|
||
|
|
print("[ArmedEnemy ", name, "] Equipped: ", data.weapon_name)
|
||
|
|
|
||
|
|
func _unequip_weapon(is_offhand: bool = false):
|
||
|
|
if is_offhand:
|
||
|
|
if equipped_offhand:
|
||
|
|
equipped_offhand.queue_free()
|
||
|
|
equipped_offhand = null
|
||
|
|
else:
|
||
|
|
if equipped_weapon:
|
||
|
|
equipped_weapon.queue_free()
|
||
|
|
equipped_weapon = null
|
||
|
|
|
||
|
|
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 and movement (check peer is assigned first)
|
||
|
|
if multiplayer.multiplayer_peer == null or 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()
|
||
|
|
|
||
|
|
# Rotate body to face movement direction (like player does)
|
||
|
|
var body_rotation_y: float = 0.0
|
||
|
|
if _body and _body.has_method("apply_rotation") and velocity.length() > 0.1:
|
||
|
|
_body.apply_rotation(velocity)
|
||
|
|
if _body:
|
||
|
|
body_rotation_y = _body.rotation.y
|
||
|
|
|
||
|
|
# Animate body
|
||
|
|
if _body and _body.has_method("animate"):
|
||
|
|
_body.animate(velocity)
|
||
|
|
|
||
|
|
# Sync position, rotation, and animation to clients periodically
|
||
|
|
_sync_timer -= delta
|
||
|
|
if _sync_timer <= 0:
|
||
|
|
_sync_timer = SYNC_INTERVAL
|
||
|
|
var current_anim = ""
|
||
|
|
if _body:
|
||
|
|
var anim_player = _body.get_node_or_null("../AnimationPlayer") as AnimationPlayer
|
||
|
|
if anim_player:
|
||
|
|
current_anim = anim_player.current_animation
|
||
|
|
rpc("_sync_transform", global_position, body_rotation_y, current_anim)
|
||
|
|
|
||
|
|
## Sync position, body rotation, and animation from server to clients
|
||
|
|
@rpc("authority", "call_remote", "unreliable")
|
||
|
|
func _sync_transform(pos: Vector3, body_rot_y: float, anim_name: String = ""):
|
||
|
|
# Only apply on clients (server is authoritative)
|
||
|
|
if multiplayer.is_server():
|
||
|
|
return
|
||
|
|
|
||
|
|
global_position = pos
|
||
|
|
if _body:
|
||
|
|
_body.rotation.y = body_rot_y
|
||
|
|
|
||
|
|
# Sync animation
|
||
|
|
if anim_name != "":
|
||
|
|
var anim_player = _body.get_node_or_null("../AnimationPlayer") as AnimationPlayer
|
||
|
|
if anim_player and anim_player.has_animation(anim_name):
|
||
|
|
if anim_player.current_animation != anim_name:
|
||
|
|
anim_player.play(anim_name)
|
||
|
|
|
||
|
|
## Override to find nearest player
|
||
|
|
func get_nearest_player() -> Node:
|
||
|
|
var players = get_players_in_range(1000.0) # Essentially 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
|
||
|
|
func _update_target():
|
||
|
|
if not is_aggressive:
|
||
|
|
return
|
||
|
|
|
||
|
|
var nearest = get_nearest_player()
|
||
|
|
|
||
|
|
if nearest:
|
||
|
|
if current_target != nearest:
|
||
|
|
current_target = nearest
|
||
|
|
target_changed.emit(nearest)
|
||
|
|
else:
|
||
|
|
if current_target != null:
|
||
|
|
current_target = null
|
||
|
|
target_changed.emit(null)
|
||
|
|
|
||
|
|
## Combat AI
|
||
|
|
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)
|
||
|
|
|
||
|
|
# Get attack range - use a close fixed range to ensure hits connect
|
||
|
|
# The hitbox is on the weapon in the enemy's hand, so we need to be close
|
||
|
|
var current_attack_range = 2.0 # Fixed close range for melee
|
||
|
|
|
||
|
|
# If in attack range, attack
|
||
|
|
if distance <= current_attack_range:
|
||
|
|
velocity.x = 0
|
||
|
|
velocity.z = 0
|
||
|
|
|
||
|
|
# Face target while attacking (use body rotation like player does)
|
||
|
|
if _body and _body.has_method("apply_rotation"):
|
||
|
|
var face_dir = Vector3(direction.x, 0, direction.z) * move_speed
|
||
|
|
_body.apply_rotation(face_dir)
|
||
|
|
|
||
|
|
if _attack_timer <= 0 and not _is_attacking:
|
||
|
|
_perform_attack()
|
||
|
|
else:
|
||
|
|
# Chase target - velocity direction will be used for body rotation
|
||
|
|
velocity.x = direction.x * move_speed
|
||
|
|
velocity.z = direction.z * move_speed
|
||
|
|
|
||
|
|
## Perform attack
|
||
|
|
func _perform_attack():
|
||
|
|
if _is_attacking or not multiplayer.is_server():
|
||
|
|
return
|
||
|
|
|
||
|
|
# Use weapon if equipped
|
||
|
|
if equipped_weapon and equipped_weapon.weapon_data:
|
||
|
|
_perform_weapon_attack()
|
||
|
|
else:
|
||
|
|
_perform_unarmed_attack()
|
||
|
|
|
||
|
|
func _perform_weapon_attack():
|
||
|
|
var weapon = equipped_weapon
|
||
|
|
var data = weapon.weapon_data
|
||
|
|
|
||
|
|
var total_duration = data.startup_time + data.active_time
|
||
|
|
var cooldown = max(data.attack_cooldown, total_duration)
|
||
|
|
|
||
|
|
_attack_timer = cooldown
|
||
|
|
_is_attacking = true
|
||
|
|
|
||
|
|
# Play animation on all clients
|
||
|
|
var anim_name = data.attack_animation if data.attack_animation else "Attack_OneHand"
|
||
|
|
rpc("_sync_attack_animation", anim_name)
|
||
|
|
|
||
|
|
# Use weapon's built-in attack activation
|
||
|
|
_activate_weapon_hitbox_direct(weapon)
|
||
|
|
|
||
|
|
func _perform_unarmed_attack():
|
||
|
|
var total_duration = unarmed_startup + unarmed_active
|
||
|
|
var cooldown = max(attack_cooldown, total_duration)
|
||
|
|
|
||
|
|
_attack_timer = cooldown
|
||
|
|
_is_attacking = true
|
||
|
|
|
||
|
|
# Play animation
|
||
|
|
rpc("_sync_attack_animation", "Attack_OneHand")
|
||
|
|
|
||
|
|
# Activate unarmed hitbox
|
||
|
|
_activate_unarmed_hitbox()
|
||
|
|
|
||
|
|
## Activate weapon hitbox for attack (direct access to weapon's internal hitbox)
|
||
|
|
func _activate_weapon_hitbox_direct(weapon: BaseWeapon):
|
||
|
|
if not weapon or not multiplayer.is_server():
|
||
|
|
_is_attacking = false
|
||
|
|
return
|
||
|
|
|
||
|
|
var data = weapon.weapon_data
|
||
|
|
|
||
|
|
# Access weapon's internal hitbox
|
||
|
|
var hitbox = weapon._hitbox
|
||
|
|
|
||
|
|
if not hitbox:
|
||
|
|
print("[ArmedEnemy] No hitbox found on weapon, trying to find it")
|
||
|
|
hitbox = weapon.get_node_or_null("HitBox") as HitBox
|
||
|
|
|
||
|
|
if not hitbox:
|
||
|
|
push_error("[ArmedEnemy] Cannot find hitbox on weapon!")
|
||
|
|
_is_attacking = false
|
||
|
|
return
|
||
|
|
|
||
|
|
# Make sure hitbox has correct owner
|
||
|
|
hitbox.owner_entity = self
|
||
|
|
|
||
|
|
# STARTUP PHASE
|
||
|
|
if data.startup_time > 0:
|
||
|
|
await get_tree().create_timer(data.startup_time).timeout
|
||
|
|
|
||
|
|
if not is_instance_valid(hitbox) or is_dead:
|
||
|
|
_is_attacking = false
|
||
|
|
return
|
||
|
|
|
||
|
|
# ACTIVE PHASE
|
||
|
|
hitbox.activate()
|
||
|
|
|
||
|
|
await get_tree().create_timer(data.active_time).timeout
|
||
|
|
|
||
|
|
# RECOVERY PHASE
|
||
|
|
if hitbox and is_instance_valid(hitbox):
|
||
|
|
hitbox.deactivate()
|
||
|
|
|
||
|
|
_is_attacking = false
|
||
|
|
|
||
|
|
## Setup unarmed hitbox
|
||
|
|
func _setup_unarmed_hitbox():
|
||
|
|
_unarmed_hitbox = HitBox.new()
|
||
|
|
_unarmed_hitbox.name = "UnarmedHitBox"
|
||
|
|
_unarmed_hitbox.owner_entity = self
|
||
|
|
_unarmed_hitbox.set_stats(unarmed_damage, unarmed_knockback)
|
||
|
|
|
||
|
|
# Add collision shape BEFORE adding hitbox to tree (so _ready can find it)
|
||
|
|
var collision = CollisionShape3D.new()
|
||
|
|
var sphere = SphereShape3D.new()
|
||
|
|
sphere.radius = attack_range
|
||
|
|
collision.shape = sphere
|
||
|
|
collision.position = Vector3(0, 0.8, -attack_range * 0.75)
|
||
|
|
_unarmed_hitbox.add_child(collision)
|
||
|
|
|
||
|
|
# Now attach the fully configured hitbox to body
|
||
|
|
if _body:
|
||
|
|
_body.add_child(_unarmed_hitbox)
|
||
|
|
else:
|
||
|
|
add_child(_unarmed_hitbox)
|
||
|
|
|
||
|
|
# Connect hit signal
|
||
|
|
_unarmed_hitbox.hit_landed.connect(_on_hitbox_hit)
|
||
|
|
|
||
|
|
func _activate_unarmed_hitbox():
|
||
|
|
if not _unarmed_hitbox or not multiplayer.is_server():
|
||
|
|
_is_attacking = false
|
||
|
|
return
|
||
|
|
|
||
|
|
# STARTUP PHASE
|
||
|
|
if unarmed_startup > 0:
|
||
|
|
await get_tree().create_timer(unarmed_startup).timeout
|
||
|
|
|
||
|
|
if not _unarmed_hitbox or not is_instance_valid(_unarmed_hitbox) or is_dead:
|
||
|
|
_is_attacking = false
|
||
|
|
return
|
||
|
|
|
||
|
|
# ACTIVE PHASE
|
||
|
|
_unarmed_hitbox.activate()
|
||
|
|
|
||
|
|
await get_tree().create_timer(unarmed_active).timeout
|
||
|
|
|
||
|
|
# RECOVERY PHASE
|
||
|
|
if _unarmed_hitbox and is_instance_valid(_unarmed_hitbox):
|
||
|
|
_unarmed_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()
|
||
|
|
|
||
|
|
# Apply damage directly (we're server)
|
||
|
|
if target is BaseUnit:
|
||
|
|
target.take_damage(damage_amount, 1, knockback_amount, global_position)
|
||
|
|
|
||
|
|
## Sync attack animation
|
||
|
|
@rpc("any_peer", "call_local", "reliable")
|
||
|
|
func _sync_attack_animation(anim_name: String):
|
||
|
|
if _body and _body.has_method("play_attack"):
|
||
|
|
_body.play_attack(anim_name)
|
||
|
|
|
||
|
|
## Override hurt animation
|
||
|
|
@rpc("any_peer", "call_local", "reliable")
|
||
|
|
func _play_hurt_animation():
|
||
|
|
_flash_red()
|
||
|
|
|
||
|
|
## Flash red when hit
|
||
|
|
func _flash_red():
|
||
|
|
_hit_flash_timer = HIT_FLASH_DURATION
|
||
|
|
|
||
|
|
# Flash all mesh instances in body
|
||
|
|
if _body:
|
||
|
|
var meshes = _find_mesh_instances(_body)
|
||
|
|
for mesh in meshes:
|
||
|
|
_apply_red_flash(mesh)
|
||
|
|
|
||
|
|
func _find_mesh_instances(node: Node) -> Array[MeshInstance3D]:
|
||
|
|
var meshes: Array[MeshInstance3D] = []
|
||
|
|
if node is MeshInstance3D:
|
||
|
|
meshes.append(node)
|
||
|
|
for child in node.get_children():
|
||
|
|
meshes.append_array(_find_mesh_instances(child))
|
||
|
|
return meshes
|
||
|
|
|
||
|
|
func _apply_red_flash(mesh: MeshInstance3D):
|
||
|
|
if not mesh:
|
||
|
|
return
|
||
|
|
|
||
|
|
for i in range(mesh.get_surface_override_material_count()):
|
||
|
|
var material = mesh.get_surface_override_material(i)
|
||
|
|
if not material:
|
||
|
|
material = mesh.mesh.surface_get_material(i)
|
||
|
|
if material:
|
||
|
|
material = material.duplicate()
|
||
|
|
mesh.set_surface_override_material(i, material)
|
||
|
|
|
||
|
|
if material and material is StandardMaterial3D:
|
||
|
|
material.albedo_color = Color(1.5, 0.3, 0.3)
|
||
|
|
|
||
|
|
func _reset_material():
|
||
|
|
# Reset to a neutral color after flash
|
||
|
|
if _body:
|
||
|
|
var meshes = _find_mesh_instances(_body)
|
||
|
|
for mesh in meshes:
|
||
|
|
_reset_mesh_material(mesh)
|
||
|
|
|
||
|
|
func _reset_mesh_material(mesh: MeshInstance3D):
|
||
|
|
if not mesh:
|
||
|
|
return
|
||
|
|
|
||
|
|
for i in range(mesh.get_surface_override_material_count()):
|
||
|
|
var material = mesh.get_surface_override_material(i)
|
||
|
|
if material and material is StandardMaterial3D:
|
||
|
|
# Reset to a default enemy color (reddish)
|
||
|
|
material.albedo_color = Color(0.8, 0.3, 0.3)
|
||
|
|
|
||
|
|
## Death callback - drop weapons
|
||
|
|
func _on_enemy_died(killer_id: int):
|
||
|
|
super._on_enemy_died(killer_id)
|
||
|
|
|
||
|
|
# Hide body
|
||
|
|
if _body:
|
||
|
|
_body.visible = false
|
||
|
|
|
||
|
|
# Deactivate hitboxes
|
||
|
|
if _unarmed_hitbox:
|
||
|
|
_unarmed_hitbox.deactivate()
|
||
|
|
|
||
|
|
# Drop equipped weapons (server only)
|
||
|
|
if multiplayer.is_server():
|
||
|
|
_drop_all_weapons()
|
||
|
|
|
||
|
|
print("[ArmedEnemy ", name, "] killed by ", killer_id)
|
||
|
|
|
||
|
|
## Drop all equipped weapons
|
||
|
|
func _drop_all_weapons():
|
||
|
|
if not multiplayer.is_server():
|
||
|
|
return
|
||
|
|
|
||
|
|
# Drop main hand weapon
|
||
|
|
if equipped_weapon and equipped_weapon.weapon_data:
|
||
|
|
_spawn_dropped_weapon(equipped_weapon.weapon_data, false)
|
||
|
|
|
||
|
|
# Drop off-hand weapon
|
||
|
|
if equipped_offhand and equipped_offhand.weapon_data:
|
||
|
|
_spawn_dropped_weapon(equipped_offhand.weapon_data, true)
|
||
|
|
|
||
|
|
# Clear equipped weapons on all clients
|
||
|
|
rpc("_clear_equipped_weapons")
|
||
|
|
|
||
|
|
## Spawn a dropped weapon in the world
|
||
|
|
func _spawn_dropped_weapon(data: WeaponData, is_offhand: bool):
|
||
|
|
if not multiplayer.is_server():
|
||
|
|
return
|
||
|
|
|
||
|
|
var resource_path = data.resource_path
|
||
|
|
if resource_path == "":
|
||
|
|
push_error("[ArmedEnemy] WeaponData has no resource path!")
|
||
|
|
return
|
||
|
|
|
||
|
|
# Calculate spawn position with slight offset and upward velocity
|
||
|
|
var offset = Vector3.ZERO
|
||
|
|
if is_offhand:
|
||
|
|
offset = transform.basis.x * -0.5 # Left side
|
||
|
|
else:
|
||
|
|
offset = transform.basis.x * 0.5 # Right side
|
||
|
|
|
||
|
|
var spawn_pos = global_position + offset
|
||
|
|
spawn_pos.y += 1.5 # Spawn above death position
|
||
|
|
|
||
|
|
# Random velocity to scatter weapons
|
||
|
|
var velocity = Vector3(
|
||
|
|
randf_range(-2.0, 2.0),
|
||
|
|
randf_range(3.0, 5.0), # Upward
|
||
|
|
randf_range(-2.0, 2.0)
|
||
|
|
)
|
||
|
|
|
||
|
|
# Use level's weapon spawning system
|
||
|
|
var level = get_tree().get_current_scene()
|
||
|
|
if level and level.has_method("spawn_world_weapon"):
|
||
|
|
level._weapon_spawn_counter += 1
|
||
|
|
level.rpc("spawn_world_weapon", resource_path, spawn_pos, velocity, level._weapon_spawn_counter)
|
||
|
|
print("[ArmedEnemy ", name, "] Dropped weapon: ", data.weapon_name)
|
||
|
|
|
||
|
|
## Clear equipped weapons on all clients
|
||
|
|
@rpc("any_peer", "call_local", "reliable")
|
||
|
|
func _clear_equipped_weapons():
|
||
|
|
_unequip_weapon(false)
|
||
|
|
_unequip_weapon(true)
|
||
|
|
|
||
|
|
## Respawn callback
|
||
|
|
func _on_enemy_respawned():
|
||
|
|
super._on_enemy_respawned()
|
||
|
|
|
||
|
|
# Show body
|
||
|
|
if _body:
|
||
|
|
_body.visible = true
|
||
|
|
_reset_material()
|
||
|
|
|
||
|
|
# Reset state
|
||
|
|
_attack_timer = 0.0
|
||
|
|
_is_attacking = false
|
||
|
|
|
||
|
|
# Re-equip starting weapons
|
||
|
|
call_deferred("_equip_starting_weapons_local")
|
||
|
|
|
||
|
|
print("[ArmedEnemy ", name, "] respawned")
|
||
|
|
|
||
|
|
## Set enemy color (hue-based like player)
|
||
|
|
func set_enemy_color(hue: float):
|
||
|
|
if _body and _body.has_method("set_character_color"):
|
||
|
|
_body.set_character_color(hue)
|