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 seeking (when unarmed) @export_category("Weapon Seeking") @export var seek_weapons_when_unarmed: bool = true ## If true, unarmed enemies will seek nearby weapons @export var weapon_seek_range: float = 30.0 ## How far to look for weapons @export var weapon_pickup_range: float = 1.5 ## How close to get before picking up ## 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 - prioritize weapon seeking when unarmed var seeking_weapon = false if seek_weapons_when_unarmed and equipped_weapon == null: seeking_weapon = _ai_seek_weapon(delta) # If not seeking a weapon (or armed), do normal combat if not seeking_weapon: 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 ## Find the nearest WorldWeapon within range func get_nearest_world_weapon() -> WorldWeapon: var level = get_tree().get_current_scene() if not level: return null var weapons_container = level.get_node_or_null("WeaponsContainer") if not weapons_container: return null var nearest_weapon: WorldWeapon = null var nearest_distance = weapon_seek_range for child in weapons_container.get_children(): if child is WorldWeapon: var distance = global_position.distance_to(child.global_position) if distance < nearest_distance: nearest_distance = distance nearest_weapon = child return nearest_weapon ## 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 ## Weapon seeking AI - returns true if actively seeking a weapon func _ai_seek_weapon(delta) -> bool: var nearest_weapon = get_nearest_world_weapon() if not nearest_weapon or not is_instance_valid(nearest_weapon): return false var weapon_pos = nearest_weapon.global_position var direction = (weapon_pos - global_position).normalized() var distance = global_position.distance_to(weapon_pos) # If close enough, pick up the weapon if distance <= weapon_pickup_range: velocity.x = 0 velocity.z = 0 _pickup_world_weapon(nearest_weapon) return true # Move towards the weapon velocity.x = direction.x * move_speed velocity.z = direction.z * move_speed return true ## Pick up a world weapon (server only) func _pickup_world_weapon(world_weapon: WorldWeapon): if not multiplayer.is_server(): return if not world_weapon or not is_instance_valid(world_weapon): return var weapon_data = world_weapon.weapon_data if not weapon_data: return var resource_path = weapon_data.resource_path if resource_path == "": push_error("[ArmedEnemy] WorldWeapon has no resource path!") return var weapon_id = world_weapon.weapon_id if weapon_id == -1: push_error("[ArmedEnemy] WorldWeapon has invalid weapon_id!") return print("[ArmedEnemy ", name, "] Picking up weapon: ", weapon_data.weapon_name) # Equip the weapon on all clients rpc("_equip_weapon_sync", resource_path, false) # Remove the world weapon from all clients using level's system var level = get_tree().get_current_scene() if level and level.has_method("remove_world_weapon"): level.remove_world_weapon(weapon_id) else: push_error("[ArmedEnemy] Level doesn't have remove_world_weapon method!") ## 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 # Disable collision so players can walk through var collision_shape = get_node_or_null("CollisionShape3D") if collision_shape: collision_shape.disabled = true # Disable hurtbox var hurtbox = get_node_or_null("HurtBox") if hurtbox: hurtbox.monitoring = false hurtbox.monitorable = 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() # Re-enable collision var collision_shape = get_node_or_null("CollisionShape3D") if collision_shape: collision_shape.disabled = false # Re-enable hurtbox var hurtbox = get_node_or_null("HurtBox") if hurtbox: hurtbox.monitoring = false # Hurtbox doesn't monitor, it's monitored hurtbox.monitorable = true # 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)