extends CharacterBody2D signal died # First real enemy on purpose: no generic Enemy base class yet. # Once a second/third enemy exists, we can extract only the behaviour they # actually share instead of guessing an abstraction in advance. const PLAYER_HURTBOX_MASK: int = 1 << 3 enum AttackState { IDLE, WINDUP, ACTIVE, RECOVERY, } # ============================================================================ # MOVEMENT # ============================================================================ @export_category("Movement") @export var move_speed: float = 85.0 @export var max_fall_speed: float = 1200.0 # ============================================================================ # COMBAT # ============================================================================ @export_category("Combat") @export_range(1, 100, 1) var max_health: int = 3 @export_range(0, 100, 1) var attack_damage: int = 1 ## Minimum time between the beginning of two attacks. @export var attack_interval: float = 2.0 ## Horizontal distance at which the Walker stops chasing and prepares a hit. ## This is intentionally wider than the final hitbox so entering range does not ## mean taking unavoidable damage. @export var attack_trigger_distance: float = 125.0 ## Keeps the Walker from starting a ground attack against a player far above or ## below it. @export var attack_vertical_tolerance: float = 70.0 ## Readable pause before the dangerous part of the attack starts. During this ## time the direction is locked, so moving behind the enemy is a valid dodge. @export var attack_windup_duration: float = 0.35 ## Duration of the actual hit. The hitbox sweeps forward during this window. @export var attack_duration: float = 0.22 ## Small recovery after the hit before the enemy can move again. @export var attack_recovery_duration: float = 0.30 ## Where the hitbox starts, relative to the enemy while facing right. @export var attack_start_offset: Vector2 = Vector2(34.0, 0.0) ## Furthest horizontal position reached by the moving hitbox. @export var attack_reach: float = 105.0 ## A successful player hit briefly interrupts the Walker. This makes attacking ## during its windup useful instead of forcing an unavoidable damage trade. @export var hit_stun_duration: float = 0.18 # ============================================================================ # RESPAWN # ============================================================================ @export_category("Respawn") ## Enough downtime to make defeating an enemy meaningful before it comes back. @export var respawn_delay: float = 5.0 # ============================================================================ # DEBUG # ============================================================================ @export_category("Debug") ## Enabled while the enemy has no final sprite/animation. The grey rectangle is ## the temporary body. Red overlays are the hurtbox and active attack hitbox. ## The orange area during windup previews the complete attack sweep. @export var show_debug_hitboxes: bool = true # ============================================================================ # NODES # ============================================================================ @onready var body_collision: CollisionShape2D = $CollisionShape2D @onready var hurtbox_collision: CollisionShape2D = $Hurtbox/CollisionShape2D @onready var attack_hitbox: Area2D = $AttackHitbox @onready var attack_collision: CollisionShape2D = $AttackHitbox/CollisionShape2D @onready var floor_probe: RayCast2D = $FloorProbe @onready var visibility_notifier: VisibleOnScreenNotifier2D = $VisibleOnScreenNotifier2D # ============================================================================ # RUNTIME STATE # ============================================================================ var current_health: int var facing_direction: float = -1.0 var attack_state: int = AttackState.IDLE var attack_phase_timer: float = 0.0 var attack_cooldown_timer: float = 0.0 var attack_direction: float = -1.0 var hit_stun_timer: float = 0.0 var is_awake: bool = false var is_dead: bool = false var spawn_position: Vector2 var target: Node2D ## Prevents one sweep from damaging the same target once per physics frame. var attack_hit_targets: Dictionary = {} func _ready() -> void: spawn_position = global_position current_health = max_health target = get_tree().get_first_node_in_group(&"player") as Node2D attack_hitbox.collision_layer = 0 attack_hitbox.collision_mask = PLAYER_HURTBOX_MASK attack_hitbox.monitoring = true visibility_notifier.screen_entered.connect(_wake_up) _update_facing() _reset_attack_hitbox_position() queue_redraw() func _physics_process(delta: float) -> void: if is_dead: return _update_timers(delta) _apply_gravity(delta) # The enemy deliberately does nothing until the camera has shown it once. # After waking up it stays active, even if the player later runs away from # the screen area where the enemy was first seen. if not is_awake: velocity.x = 0.0 move_and_slide() queue_redraw() return _acquire_target_if_needed() if target == null: velocity.x = 0.0 move_and_slide() queue_redraw() return # Being hit pauses both movement and attack for only a few frames. The # cooldown keeps running, so interrupting an attack does not make the enemy # immediately start another one. if hit_stun_timer > 0.0: velocity.x = 0.0 move_and_slide() queue_redraw() return if attack_state == AttackState.IDLE: _update_idle_behaviour() else: _update_attack(delta) move_and_slide() queue_redraw() # ============================================================================ # ACTIVATION AND TARGETING # ============================================================================ func _wake_up() -> void: is_awake = true func _acquire_target_if_needed() -> void: if target != null and is_instance_valid(target): return target = get_tree().get_first_node_in_group(&"player") as Node2D func _face_target() -> void: var direction_to_target: float = signf(target.global_position.x - global_position.x) if direction_to_target == 0.0 or direction_to_target == facing_direction: return facing_direction = direction_to_target _update_facing() func _update_facing() -> void: # The ledge probe follows the current movement direction. Attack direction is # stored separately once a windup starts so the enemy cannot rotate mid-hit. floor_probe.position.x = abs(floor_probe.position.x) * facing_direction if attack_state == AttackState.IDLE: _reset_attack_hitbox_position() # ============================================================================ # MOVEMENT # ============================================================================ func _apply_gravity(delta: float) -> void: if is_on_floor(): return velocity += get_gravity() * delta velocity.y = min(velocity.y, max_fall_speed) func _update_idle_behaviour() -> void: _face_target() if attack_cooldown_timer <= 0.0 and _is_target_in_attack_trigger(): _start_attack() return _chase_target() func _chase_target() -> void: # Do not blindly walk off a platform while chasing. Proper pathfinding is # intentionally postponed until levels actually require it. if is_on_floor() and not floor_probe.is_colliding(): velocity.x = 0.0 return if is_on_wall(): velocity.x = 0.0 return velocity.x = facing_direction * move_speed # ============================================================================ # ATTACK # ============================================================================ func _is_target_in_attack_trigger() -> bool: var offset_to_target: Vector2 = target.global_position - global_position return ( abs(offset_to_target.x) <= attack_trigger_distance and abs(offset_to_target.y) <= attack_vertical_tolerance ) func _start_attack() -> void: # Lock the direction here. During windup/active frames the player can dodge # behind the enemy without the hitbox snapping around to follow them. _face_target() attack_direction = facing_direction attack_state = AttackState.WINDUP attack_phase_timer = attack_windup_duration attack_cooldown_timer = attack_interval attack_hit_targets.clear() velocity.x = 0.0 _reset_attack_hitbox_position() func _update_attack(delta: float) -> void: velocity.x = 0.0 match attack_state: AttackState.WINDUP: _update_attack_windup(delta) AttackState.ACTIVE: _update_active_attack(delta) AttackState.RECOVERY: _update_attack_recovery(delta) func _update_attack_windup(delta: float) -> void: attack_phase_timer = max(attack_phase_timer - delta, 0.0) if attack_phase_timer > 0.0: return attack_state = AttackState.ACTIVE attack_phase_timer = attack_duration _set_attack_hitbox_progress(0.0) func _update_active_attack(delta: float) -> void: attack_phase_timer = max(attack_phase_timer - delta, 0.0) # Sweep the actual physics hitbox from close to the enemy towards its maximum # reach. This gives the player time to jump, back away or move behind it. var progress: float = 1.0 if attack_duration > 0.0: progress = 1.0 - (attack_phase_timer / attack_duration) _set_attack_hitbox_progress(clampf(progress, 0.0, 1.0)) _update_attack_damage() if attack_phase_timer > 0.0: return attack_state = AttackState.RECOVERY attack_phase_timer = attack_recovery_duration func _update_attack_recovery(delta: float) -> void: attack_phase_timer = max(attack_phase_timer - delta, 0.0) if attack_phase_timer > 0.0: return attack_state = AttackState.IDLE attack_hit_targets.clear() _reset_attack_hitbox_position() func _set_attack_hitbox_progress(progress: float) -> void: var start_x: float = abs(attack_start_offset.x) var current_x: float = lerpf(start_x, attack_reach, progress) attack_hitbox.position = Vector2( current_x * attack_direction, attack_start_offset.y ) func _reset_attack_hitbox_position() -> void: var direction: float = attack_direction if attack_state == AttackState.IDLE: direction = facing_direction attack_hitbox.position = Vector2( abs(attack_start_offset.x) * direction, attack_start_offset.y ) func _update_attack_damage() -> void: for area in attack_hitbox.get_overlapping_areas(): var damage_target: Node = area.get_parent() if damage_target == null or not damage_target.has_method("take_damage"): continue var target_id: int = damage_target.get_instance_id() if attack_hit_targets.has(target_id): continue attack_hit_targets[target_id] = true damage_target.take_damage(attack_damage, attack_hitbox.global_position) func _interrupt_attack() -> void: if attack_state == AttackState.IDLE: return attack_state = AttackState.IDLE attack_phase_timer = 0.0 attack_hit_targets.clear() _reset_attack_hitbox_position() func _update_timers(delta: float) -> void: attack_cooldown_timer = max(attack_cooldown_timer - delta, 0.0) hit_stun_timer = max(hit_stun_timer - delta, 0.0) # ============================================================================ # DAMAGE AND RESPAWN # ============================================================================ func take_damage(amount: int, source_position: Vector2) -> void: if amount <= 0 or is_dead: return current_health = max(current_health - amount, 0) if current_health == 0: _die() return # Light enemies are interruptible. This makes a well-timed player attack a # defensive option and avoids mandatory damage trades at close range. _interrupt_attack() hit_stun_timer = hit_stun_duration velocity.x = 0.0 var reaction_direction: float = signf(source_position.x - global_position.x) if reaction_direction != 0.0: facing_direction = reaction_direction _update_facing() func _die() -> void: is_dead = true died.emit() velocity = Vector2.ZERO visible = false # take_damage() can run while Godot is flushing physics queries, therefore # collision shapes are disabled deferred instead of changing them directly. body_collision.set_deferred("disabled", true) hurtbox_collision.set_deferred("disabled", true) attack_collision.set_deferred("disabled", true) await get_tree().create_timer(respawn_delay).timeout _respawn() func _respawn() -> void: global_position = spawn_position velocity = Vector2.ZERO current_health = max_health attack_state = AttackState.IDLE attack_phase_timer = 0.0 attack_cooldown_timer = 0.0 hit_stun_timer = 0.0 attack_hit_targets.clear() attack_direction = facing_direction _reset_attack_hitbox_position() body_collision.set_deferred("disabled", false) hurtbox_collision.set_deferred("disabled", false) attack_collision.set_deferred("disabled", false) visible = true is_dead = false # If the spawn point is currently inside the camera, wake immediately. # Otherwise the notifier will wake the enemy when the camera reaches it. is_awake = visibility_notifier.is_on_screen() queue_redraw() # ============================================================================ # TEMPORARY VISUAL + DEBUG HITBOXES # ============================================================================ func _draw() -> void: # The enemy has no sprite yet. This neutral block makes the actual entity # visible independently from the debug overlays. _draw_rectangle_collision( body_collision, Color(0.18, 0.18, 0.20, 0.90), Color(0.85, 0.85, 0.90, 1.0) ) if not show_debug_hitboxes: return # Hurtbox: where punches / ground pound are allowed to damage the enemy. _draw_rectangle_collision( hurtbox_collision, Color(1.0, 0.05, 0.05, 0.12), Color(1.0, 0.05, 0.05, 0.55) ) # Windup: preview the full sweep in orange. It is explicitly NOT dangerous # yet, so the different colour makes the reaction window easy to understand. if attack_state == AttackState.WINDUP: _draw_attack_telegraph() # Active attack: draw the real moving physics hitbox in strong red. if attack_state == AttackState.ACTIVE: _draw_rectangle_collision( attack_collision, Color(1.0, 0.05, 0.05, 0.28), Color(1.0, 0.05, 0.05, 0.95), attack_hitbox.position ) func _draw_attack_telegraph() -> void: var rectangle: RectangleShape2D = attack_collision.shape as RectangleShape2D if rectangle == null: return var start_x: float = abs(attack_start_offset.x) * attack_direction var end_x: float = attack_reach * attack_direction var center_x: float = (start_x + end_x) * 0.5 var sweep_width: float = abs(end_x - start_x) + rectangle.size.x var center: Vector2 = Vector2(center_x, attack_start_offset.y) var sweep_size: Vector2 = Vector2(sweep_width, rectangle.size.y) var rect: Rect2 = Rect2(center - sweep_size / 2.0, sweep_size) draw_rect(rect, Color(1.0, 0.55, 0.05, 0.12), true) draw_rect(rect, Color(1.0, 0.55, 0.05, 0.85), false, 2.0) func _draw_rectangle_collision( collision: CollisionShape2D, fill_color: Color, outline_color: Color, extra_offset: Vector2 = Vector2.ZERO ) -> void: var rectangle: RectangleShape2D = collision.shape as RectangleShape2D if rectangle == null: return var center: Vector2 = collision.position + extra_offset var rect: Rect2 = Rect2(center - rectangle.size / 2.0, rectangle.size) draw_rect(rect, fill_color, true) draw_rect(rect, outline_color, false, 2.0)