Turns the sandbox into a survival roguelike loop: outfit your gladiator with banked gold, enter the arena, earn gold from kills and wave clears, die, keep 10% of your total value (floored at the 500 fresh-start). Economy (GameState autoload): - banked_gold, owned items, and loadout persist to user://save.cfg - Kill gold (basic 10g, armed 25g) credited server-side to the killer - Wave-clear bonus (25 + 5/wave) for all players; wave synced to peers - Death settlement liquidates gear, guarded against double-fire Character creation / shop: - Menu reframed: armory shop + live Character Sheet loadout preview - Character Sheet gains preview mode (renders from loadout, no player) - Buy/Equip with hand rules; two-handers and off-hands displace each other symmetrically (shop and in-game pickups) - Chosen loadout auto-equips on spawn via existing RPC path Combat feel: - Attacks snap to camera facing and lock direction for the swing - Dash commits to its direction for the full duration - Weapon slots show name text when no icon is set - Hitbox/hurtbox debug meshes hidden by default (H toggles) Death is final: players no longer respawn; a results screen shows waves, time, kills, and the settlement, then returns to camp. Armed enemies no longer respawn (were an infinite gold farm and respawned invisible).
736 lines
21 KiB
GDScript
736 lines
21 KiB
GDScript
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()
|
|
|
|
# Armed gladiators are worth more than basic enemies (arena economy)
|
|
gold_reward = 25
|
|
|
|
# Gladiators die for good - respawning would make them a repeatable gold farm
|
|
# (they also don't re-show their body on respawn, so this avoids invisible enemies)
|
|
can_respawn = false
|
|
|
|
# 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)
|