519 lines
15 KiB
GDScript
519 lines
15 KiB
GDScript
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)
|