class_name Enemy extends CharacterBody3D const PICKUP_SCENE := preload("res://scenes/Pickup.tscn") const LEATHER_SCENE := preload("res://scenes/Leather.tscn") const IRON_SCENE := preload("res://scenes/Iron.tscn") const ESSENCE_SCENE := preload("res://scenes/Essence.tscn") signal died(points: int) signal merged(upgrade: bool) enum State { CHASING, FLYING, STUNNED, DEAD, MERGING } static var first_leather_spawned: bool = false static var first_iron_spawned: bool = false static var first_essence_spawned: bool = false var kickable_type: String = "" var kick_tier: int = 1 var toughness_tier: int = 1 var move_speed: float = 3.0 var health: int = 30 var damage_to_player: int = 8 var score_value: int = 10 var enemy_level: int = 1 var enemy_type: String = "slime" var wall_damage_mult: float = 1.8 var chain_factor: float = 0.65 var stun_time: float = 0.5 var base_scale: float = 1.0 var wave_num: int = 1 var damage_modifier: float = 0.75 var enemy_kick_timer: float = 0.0 var kickable_kick_timer: float = 0.0 const ENEMY_KICK_COOLDOWN := 1.2 const KICKABLE_KICK_COOLDOWN := 2.5 const ENEMY_KICK_RANGE := 2.2 var state: State = State.CHASING var fly_vel: Vector3 = Vector3.ZERO var stun_timer: float = 0.0 var contact_timer: float = 0.0 var target: Node3D = null var merge_partner: Node = null var is_upgrading: bool = false @onready var mesh_node: MeshInstance3D = $BodyMesh var mat: StandardMaterial3D var COLOR_CHASE = Color(1.0, 0.28, 0.18) var COLOR_FLY = Color(1.0, 0.85, 0.1) var COLOR_STUN = Color(0.55, 0.55, 0.65) const CONTACT_CD = 0.7 const AIR_FRICTION = 0.86 func _ready() -> void: add_to_group("enemies") mat = mesh_node.material_override.duplicate() as StandardMaterial3D mesh_node.material_override = mat COLOR_CHASE = mat.albedo_color func setup(type: String, wave: int) -> void: enemy_type = type kickable_type = type wave_num = wave match type: "slime": move_speed = 2.8 + wave * 0.12 health = 28 + wave * 4 score_value = 10 damage_to_player = 8 enemy_level = 1 "bat": move_speed = 5.5 + wave * 0.15 health = 14 + wave * 2 score_value = 15 damage_to_player = 6 base_scale = 0.7 mesh_node.scale = Vector3(0.7, 0.7, 0.7) COLOR_CHASE = Color(0.6, 0.2, 0.8) mat.albedo_color = COLOR_CHASE enemy_level = 2 "ogre": move_speed = 1.8 + wave * 0.08 health = 80 + wave * 12 score_value = 25 damage_to_player = 18 base_scale = 1.5 mesh_node.scale = Vector3(1.5, 1.5, 1.5) COLOR_CHASE = Color(0.3, 0.7, 0.3) mat.albedo_color = COLOR_CHASE enemy_level = 3 kick_tier = enemy_level toughness_tier = enemy_level func _physics_process(delta: float) -> void: match state: State.CHASING: _chase(delta) State.FLYING: _fly(delta) State.STUNNED: _stun_tick(delta) State.DEAD: pass func _chase(delta: float) -> void: if not is_instance_valid(target): return contact_timer = max(0.0, contact_timer - delta) enemy_kick_timer = max(0.0, enemy_kick_timer - delta) kickable_kick_timer = max(0.0, kickable_kick_timer - delta) var diff := target.global_position - global_position diff.y = 0.0 var dist := diff.length() if dist < 1.0 and contact_timer <= 0.0: contact_timer = CONTACT_CD if target.has_method("take_damage"): target.take_damage(damage_to_player) if enemy_kick_timer <= 0.0: _try_enemy_kick() var sep := Vector3.ZERO for e in get_tree().get_nodes_in_group("enemies"): if e == self: continue var en := e as Node3D if en == null: continue var away := global_position - en.global_position away.y = 0.0 var away_dist := away.length() if away_dist < 2.2 and away_dist > 0.01: sep += away.normalized() * (2.2 - away_dist) if dist > 0.05: var dir := diff.normalized() var move_dir := (dir + sep * 0.6).normalized() velocity.x = move_dir.x * move_speed velocity.z = move_dir.z * move_speed rotation.y = lerp_angle(rotation.y, atan2(move_dir.x, move_dir.z), 8.0 * delta) velocity.y = 0.0 move_and_slide() func _try_enemy_kick() -> void: if not is_instance_valid(target): return var player_pos := target.global_position var to_player := player_pos - global_position to_player.y = 0.0 var kick_dir := to_player.normalized() if to_player.length() > 0.01 else -global_transform.basis.z # 1. Kick nearest kickable towards player if kickable_kick_timer <= 0.0: var nearest_kickable: Node3D = null var nearest_dist := ENEMY_KICK_RANGE for node in get_tree().get_nodes_in_group("kickable"): var k := node as Node3D if k == null or not is_instance_valid(k): continue if k.get("kickable_type") == "stick": continue var kfv = k.get("fly_vel") if kfv != null and Vector2((kfv as Vector3).x, (kfv as Vector3).z).length() > 15.0: continue var d := (k.global_position - global_position) d.y = 0.0 if d.length() < nearest_dist: nearest_dist = d.length() nearest_kickable = k if nearest_kickable != null: nearest_kickable.call("receive_kick", kick_dir, 35.0 + kick_tier * 8.0) kickable_kick_timer = KICKABLE_KICK_COOLDOWN return # 2. Kick lower-toughness enemy nearest to player direction if kick_tier > 0: var nearest_enemy: Node3D = null var nearest_enemy_dist := ENEMY_KICK_RANGE for node in get_tree().get_nodes_in_group("enemies"): var en := node as Node3D if en == null or en == self or not is_instance_valid(en): continue if (en.get("toughness_tier") if en.get("toughness_tier") != null else 0) >= kick_tier: continue var d := (en.global_position - global_position) d.y = 0.0 if d.length() < nearest_enemy_dist: nearest_enemy_dist = d.length() nearest_enemy = en if nearest_enemy != null: nearest_enemy.call("receive_kick", kick_dir, 40.0 + kick_tier * 10.0) enemy_kick_timer = ENEMY_KICK_COOLDOWN return # 3. Kick player directly if lower toughness and in range #if kick_tier > 0 and to_player.length() < ENEMY_KICK_RANGE: if to_player.length() < ENEMY_KICK_RANGE: var player_toughness: int = target.get("toughness_tier") if target.get("toughness_tier") != null else 0 if player_toughness < kick_tier: target.call("receive_kick", kick_dir, 35.0 + kick_tier * 8.0) enemy_kick_timer = ENEMY_KICK_COOLDOWN func _fly(delta: float) -> void: var speed_now := Vector2(fly_vel.x, fly_vel.z).length() velocity = fly_vel velocity.y = 0.0 move_and_slide() var hit_wall := false for i in get_slide_collision_count(): var col := get_slide_collision(i) var col3d := col.get_collider() as Node3D if col3d == null: continue if col3d.has_meta("is_wall"): _take_hit(int(speed_now * wall_damage_mult)) _wall_impact_effect() fly_vel = Vector3.ZERO velocity = Vector3.ZERO _enter_stun() hit_wall = true break elif col3d.is_in_group("enemies") and col3d != self: var merged := KickSystem.resolve(self, col3d, fly_vel) if not merged and is_instance_valid(col3d): var chain_dir := col3d.global_position - global_position chain_dir.y = 0.0 if chain_dir.length() > 0.01: col3d.call("receive_kick", chain_dir.normalized(), speed_now * chain_factor) elif col3d.is_in_group("kickable"): KickSystem.resolve(self, col3d, fly_vel) var rock_dir := col3d.global_position - global_position rock_dir.y = 0.0 if rock_dir.length() > 0.01: col3d.call("receive_kick", rock_dir.normalized(), speed_now * 0.5) elif col3d.is_in_group("player"): col3d.call("take_damage", int(speed_now * 0.6)) if not hit_wall: fly_vel = velocity fly_vel.y = 0.0 fly_vel *= pow(AIR_FRICTION, delta * 60.0) if Vector2(fly_vel.x, fly_vel.z).length() < 0.4: _enter_chase() mesh_node.rotation.y += delta * 10.0 func _stun_tick(delta: float) -> void: velocity = Vector3.ZERO stun_timer -= delta if stun_timer <= 0.0: _enter_chase() func can_merge_with(other: Node3D, collision_speed: float) -> bool: return (collision_speed >= 18.0 and other.get("enemy_type") == enemy_type and other.get("enemy_level") == enemy_level and not is_upgrading and not other.get("is_upgrading")) func do_merge_with(other: Node3D) -> void: _start_merge(other) func apply_collision_damage(dmg: float) -> void: _take_hit(int(dmg)) func receive_kick(direction: Vector3, force: float) -> void: if state == State.DEAD: return fly_vel = direction * force fly_vel.y = 0.0 state = State.FLYING mat.albedo_color = COLOR_FLY func _start_merge(other: Node) -> void: is_upgrading = true other.is_upgrading = true other.merge_partner = self merge_partner = other state = State.MERGING other.state = State.MERGING fly_vel = Vector3.ZERO velocity = Vector3.ZERO other.fly_vel = Vector3.ZERO other.velocity = Vector3.ZERO mat.albedo_color = Color(1.0, 1.0, 0.5) other.mat.albedo_color = Color(1.0, 1.0, 0.5) var tw := create_tween().set_parallel(true) tw.tween_property(self, "global_position", other.global_position, 0.2) tw.tween_property(other, "global_position", global_position, 0.2) tw.tween_callback(_on_merge_complete) func _on_merge_complete() -> void: var merge_pos := global_position var merge_type := enemy_type var new_level: int = enemy_level + 1 var new_wave: int = wave_num if is_instance_valid(merge_partner): merge_pos = (global_position + merge_partner.global_position) / 2.0 merge_partner.queue_free() queue_free() emit_signal("merged", true) var tree := get_tree() if tree != null and tree.has_group("main"): var main := tree.get_nodes_in_group("main")[0] as Node if main != null and main.has_method("_spawn_upgraded_enemy"): var new_enemy := main.call("_spawn_upgraded_enemy", merge_pos, merge_type, new_level, new_wave) as CharacterBody3D if new_enemy != null: new_enemy.state = State.FLYING new_enemy.fly_vel = Vector3.ZERO func _enter_stun() -> void: state = State.STUNNED stun_timer = stun_time mat.albedo_color = COLOR_STUN func _enter_chase() -> void: state = State.CHASING mat.albedo_color = COLOR_CHASE func _take_hit(dmg: int) -> void: if state == State.DEAD: return health -= dmg if health <= 0: _die() func _wall_impact_effect() -> void: var tw := create_tween() tw.tween_property(mat, "albedo_color", Color.WHITE, 0.04) tw.tween_property(mat, "albedo_color", COLOR_STUN, 0.12) func _try_drop_pickup() -> void: if enemy_level == 1: var drop_leather := not first_leather_spawned or randf() < 0.20 if drop_leather: first_leather_spawned = true var leather := LEATHER_SCENE.instantiate() as Node3D get_parent().add_child(leather) leather.global_position = global_position if enemy_level == 2: var drop_iron := not first_iron_spawned or randf() < 0.20 if drop_iron: first_iron_spawned = true var iron := IRON_SCENE.instantiate() as Node3D get_parent().add_child(iron) iron.global_position = global_position if enemy_level == 3: var drop_essence := not first_essence_spawned or randf() < 0.20 if drop_essence: first_essence_spawned = true var essence := ESSENCE_SCENE.instantiate() as Node3D get_parent().add_child(essence) essence.global_position = global_position var roll := randf() var p_type := "" var p_heal := 0 match enemy_type: "slime": if roll < 0.30: p_type = "health"; p_heal = 15 elif roll < 0.40: p_type = "score" "bat": if roll < 0.20: p_type = "health"; p_heal = 12 elif roll < 0.25: p_type = "score" "ogre": if roll < 0.55: p_type = "health"; p_heal = 35 elif roll < 0.80: p_type = "score" if p_type == "": return var pickup := PICKUP_SCENE.instantiate() pickup.setup(p_type, p_heal) get_parent().add_child(pickup) pickup.global_position = global_position func _die() -> void: if state == State.DEAD: return state = State.DEAD set_physics_process(false) emit_signal("died", score_value) _try_drop_pickup() var tw := create_tween() tw.tween_property(self, "scale", Vector3(2.0, 0.05, 2.0), 0.18) tw.tween_property(self, "scale", Vector3(0.0, 0.0, 0.0), 0.1) tw.tween_callback(queue_free)