extends CharacterBody3D const GRAVITY: float = 18.0 @export var stats_resource: EnemyStats @export var is_boss: bool = false @onready var nav: NavigationAgent3D = $NavAgent @onready var mesh_holder: Node3D = $MeshHolder @onready var collision: CollisionShape3D = $Collision @onready var detection: Area3D = $DetectionArea @onready var sync: MultiplayerSynchronizer = $Synchronizer @onready var healthbar: MeshInstance3D = $Healthbar @onready var name_label: Label3D = $NameLabel var origin: Vector3 = Vector3.ZERO var attack_cd: float = 0.0 var dead: bool = false var invasion_target: Node = null @export var sync_position: Vector3 = Vector3.ZERO @export var sync_yaw: float = 0.0 func _enter_tree() -> void: set_multiplayer_authority(1) func _ready() -> void: if is_boss: add_to_group("boss") add_to_group("enemies") if stats_resource == null: stats_resource = EnemyStats.new() Stats.register(self, stats_resource) origin = global_position detection.body_entered.connect(_on_body_entered) EventBus.health_changed.connect(_on_health_changed) EventBus.entity_died.connect(_on_entity_died) name_label.text = "Boss" if is_boss else "Enemy" if is_boss: name_label.text = "Boss" var mesh: MeshInstance3D = mesh_holder.get_node("Mesh") mesh.scale = Vector3(1.5, 1.5, 1.5) var mat: StandardMaterial3D = StandardMaterial3D.new() mat.albedo_color = Color(0.6, 0.2, 0.8) mesh.material_override = mat func _exit_tree() -> void: Stats.deregister(self) func _physics_process(delta: float) -> void: if not is_multiplayer_authority(): global_position = global_position.lerp(sync_position, clamp(delta * 20.0, 0.0, 1.0)) rotation.y = lerp_angle(rotation.y, sync_yaw, clamp(delta * 20.0, 0.0, 1.0)) return if dead: return if not is_on_floor(): velocity.y -= GRAVITY * delta attack_cd = max(0.0, attack_cd - delta) var target: Node = _get_target() if target == null: _return_to_origin(delta) else: _chase_or_attack(target, delta) move_and_slide() sync_position = global_position sync_yaw = rotation.y func _get_target() -> Node: var aggro: Node = get_node_or_null("/root/World/Systems/AggroSystem") if aggro == null: aggro = get_node_or_null("/root/Dungeon/Systems/AggroSystem") if aggro and aggro.has_method("target_for"): var t: Node = aggro.target_for(self) if t: return t if invasion_target and is_instance_valid(invasion_target): return invasion_target return null func _chase_or_attack(target: Node, delta: float) -> void: var t_pos: Vector3 = (target as Node3D).global_position var d: float = global_position.distance_to(t_pos) var attack_range: float = float(Stats.get_stat(self, "attack_range", 2.0)) if d <= attack_range: velocity.x = move_toward(velocity.x, 0.0, 20.0 * delta) velocity.z = move_toward(velocity.z, 0.0, 20.0 * delta) var look := Vector3(t_pos.x - global_position.x, 0.0, t_pos.z - global_position.z) if look.length() > 0.01: rotation.y = atan2(look.x, look.z) if attack_cd <= 0.0: attack_cd = float(Stats.get_stat(self, "attack_cooldown", 1.5)) var dmg: float = float(Stats.get_stat(self, "attack_damage", 5.0)) EventBus.damage_requested.emit(self, target, dmg, Element.NONE) else: var dir := Vector3(t_pos.x - global_position.x, 0.0, t_pos.z - global_position.z).normalized() var speed: float = float(Stats.get_stat(self, "speed", 3.0)) velocity.x = dir.x * speed velocity.z = dir.z * speed if dir.length() > 0.01: rotation.y = atan2(dir.x, dir.z) func _return_to_origin(delta: float) -> void: var d: float = global_position.distance_to(origin) if d < 0.5: velocity.x = move_toward(velocity.x, 0.0, 20.0 * delta) velocity.z = move_toward(velocity.z, 0.0, 20.0 * delta) var max_hp: float = float(Stats.get_stat(self, "max_health", 100.0)) var hp: float = float(Stats.get_stat(self, "health", 0.0)) if hp > 0.0 and hp < max_hp: Stats.set_stat(self, "health", min(max_hp, hp + max_hp * 0.20 * delta)) EventBus.health_changed.emit(self, Stats.get_stat(self, "health"), max_hp) else: var dir: Vector3 = Vector3(origin.x - global_position.x, 0.0, origin.z - global_position.z).normalized() var speed: float = float(Stats.get_stat(self, "speed", 3.0)) * 1.5 velocity.x = dir.x * speed velocity.z = dir.z * speed if dir.length() > 0.01: rotation.y = atan2(dir.x, dir.z) func _on_body_entered(body: Node) -> void: if not multiplayer.is_server() and multiplayer.multiplayer_peer != null: return if body.is_in_group("player") and not dead: EventBus.enemy_detected.emit(self, body) func _on_health_changed(entity: Node, current: float, max: float) -> void: if entity != self: return var ratio: float = clamp(current / max if max > 0 else 0.0, 0.0, 1.0) if healthbar: healthbar.scale.x = max(0.01, ratio) func _on_entity_died(entity: Node) -> void: if entity != self or dead: return dead = true if multiplayer.is_server() or multiplayer.multiplayer_peer == null: _on_death.rpc() var loot: Node = get_node_or_null("/root/World/Systems/LootSystem") if loot == null: loot = get_node_or_null("/root/Dungeon/Systems/LootSystem") if loot and loot.has_method("drop_loot_for"): loot.drop_loot_for(self, global_position) var xp_sys: Node = get_node_or_null("/root/World/Systems/XpSystem") if xp_sys == null: xp_sys = get_node_or_null("/root/Dungeon/Systems/XpSystem") if xp_sys and xp_sys.has_method("award_for_enemy"): xp_sys.award_for_enemy(self) if is_boss: EventBus.boss_defeated.emit(self) var t := get_tree().create_timer(2.0) t.timeout.connect(func(): if is_instance_valid(self): queue_free()) @rpc("any_peer", "reliable", "call_local") func _on_death() -> void: if multiplayer.multiplayer_peer != null and not (multiplayer.multiplayer_peer is OfflineMultiplayerPeer) and multiplayer.get_remote_sender_id() != 0 and multiplayer.get_remote_sender_id() != 1: return dead = true collision.disabled = true modulate_alpha(0.4) func modulate_alpha(a: float) -> void: for child in mesh_holder.get_children(): if child is MeshInstance3D and child.material_override: var c: Color = child.material_override.albedo_color c.a = a child.material_override.albedo_color = c