class_name DemonSamurai extends CharacterBody2D @onready var _hurtbox: HurtBox = $HurtBox @onready var _health: HealthComponent = $HealthComponent @onready var _sprite: AnimatedSprite2D = $AnimatedSprite2D @onready var _detection_area: Area2D = $DetectionArea @onready var _combo_hitbox: HitBox = $ComboHitBox @onready var _combo_hitbox_collision: CollisionShape2D = $ComboHitBox/CollisionShape2D @onready var _combo_attack_range_area: Area2D = $ComboAttackRangeArea @onready var _jump_attack_hitbox: HitBox = $JumpAttackHitBox @onready var _jump_attack_hitbox_collision: CollisionShape2D = $JumpAttackHitBox/CollisionShape2D @onready var _jump_attack_range_area: Area2D = $JumpAttackRangeArea @onready var _ledge_detector: RayCast2D = $RayCast2D # FHit frames for combo const _COMBO_HIT_FRAMES: Dictionary = { 1: Vector2i(2, 4), 2: Vector2i(2, 4), 3: Vector2i(3, 5), } # Exported Variables @export_group("Movement") @export var move_speed: float = 80.0 @export var patrol_speed: float = 40.0 @export var ledge_check_offset: float = 10.0 @export_group("Combat") @export var attack_cooldown: float = 0.6 @export var combo_damage: Array[int] = [2, 2, 2] @export var jump_attack_damage: int = 4 @export var jump_attack_lunge_speed: float = 260.0 @export var jump_attack_hit_frame_start: int = 7 @export var jump_attack_hit_frame_end: int = 9 @export var hits_to_trigger_jump_attack: int = 3 @export_group("Phase") @export var flame_phase_health_ratio: float = 0.5 @export var flame_move_speed_multiplier: float = 1.4 @export var flame_damage_multiplier: float = 2.0 @export_group("Defend") @export var spam_hit_window: float = 0.65 @export var block_reaction_time: float = 0.1 @export var blocks_to_trigger_jump_attack: int = 3 # Runtime Variables var _player: Node2D = null var _player_in_combo_range: bool = false var _player_in_jump_range: bool = false var _combo_step: int = 0 var _hits_taken: int = 0 var _is_attacking: bool = false var _is_jump_attacking: bool = false var _is_jump_attack_uninterruptible: bool = false var _is_shouting: bool = false var _is_hurt: bool = false var _is_blocking: bool = false var _is_dead: bool = false var _is_flame_phase: bool = false var _attack_cooldown_timer: float = 0.0 var _time_since_last_action: float = 999.0 var _block_timer: float = 0.0 var _block_count: int = 0 var _facing_direction: float = 1.0 var _patrol_direction: float = 1.0 func _ready() -> void: add_to_group("enemies") _hurtbox.hit_received.connect(_on_hurtbox_hit_received) _health.health_changed.connect(_on_health_changed) _health.died.connect(_on_died) _sprite.animation_finished.connect(_on_animation_finished) _detection_area.body_entered.connect(_on_detection_area_body_entered) _detection_area.body_exited.connect(_on_detection_area_body_exited) _combo_attack_range_area.area_entered.connect(_on_combo_range_area_entered) _combo_attack_range_area.area_exited.connect(_on_combo_range_area_exited) _jump_attack_range_area.area_entered.connect(_on_jump_range_area_entered) _jump_attack_range_area.area_exited.connect(_on_jump_range_area_exited) func _physics_process(delta: float) -> void: _apply_gravity(delta) _update_attack_cooldown(delta) if not _is_dead: _check_phase_transition() _process_ai() _update_hitboxes() move_and_slide() func _update_hitboxes() -> void: var multiplier: float = flame_damage_multiplier if _is_flame_phase else 1.0 var combo_hit_frames: Vector2i = _COMBO_HIT_FRAMES.get(_combo_step, Vector2i(-1, -1)) var is_combo_hit_frame: bool = _sprite.frame >= combo_hit_frames.x and _sprite.frame <= combo_hit_frames.y _combo_hitbox_collision.disabled = not (_is_attacking and is_combo_hit_frame) if _combo_step >= 1 and _combo_step <= combo_damage.size(): _combo_hitbox.damage = int(round(combo_damage[_combo_step - 1] * multiplier)) var is_jump_hit_frame: bool = _sprite.frame >= jump_attack_hit_frame_start \ and _sprite.frame <= jump_attack_hit_frame_end _jump_attack_hitbox_collision.disabled = not (_is_jump_attacking and is_jump_hit_frame) _jump_attack_hitbox.damage = int(round(jump_attack_damage * multiplier)) func _apply_gravity(delta: float) -> void: if not is_on_floor(): velocity.y += ProjectSettings.get_setting("physics/2d/default_gravity") * delta func _update_attack_cooldown(delta: float) -> void: if _attack_cooldown_timer > 0.0: _attack_cooldown_timer -= delta _time_since_last_action += delta if _is_blocking: _block_timer -= delta if _block_timer <= 0.0: _is_blocking = false func _check_phase_transition() -> void: if _is_flame_phase or _is_shouting or _is_dead: return if _health.current_health <= _health.max_health * flame_phase_health_ratio: _start_shout() func _process_ai() -> void: if _is_hurt or _is_shouting or _is_blocking: velocity.x = 0.0 return if _is_jump_attacking: var is_jump_hit_frame: bool = _sprite.frame >= jump_attack_hit_frame_start \ and _sprite.frame <= jump_attack_hit_frame_end velocity.x = _facing_direction * jump_attack_lunge_speed if is_jump_hit_frame else 0.0 return if _is_attacking: velocity.x = 0.0 return if _player_in_jump_range and _hits_taken >= hits_to_trigger_jump_attack and _attack_cooldown_timer <= 0.0: _start_jump_attack() return if _player_in_combo_range and _attack_cooldown_timer <= 0.0: _start_combo_attack() return if _player_in_combo_range or _player_in_jump_range: _facing_direction = signf(_player.global_position.x - global_position.x) velocity.x = 0.0 if _sprite.animation != _phase_anim(&"idle"): _sprite.play(_phase_anim(&"idle")) _update_facing() return if _player: _facing_direction = signf(_player.global_position.x - global_position.x) if _has_ground_ahead(_facing_direction): velocity.x = _facing_direction * _current_move_speed() if _sprite.animation != _phase_anim(&"running"): _sprite.play(_phase_anim(&"running")) else: velocity.x = 0.0 if _sprite.animation != _phase_anim(&"idle"): _sprite.play(_phase_anim(&"idle")) else: _facing_direction = _patrol_direction velocity.x = _patrol_direction * patrol_speed if _sprite.animation != _phase_anim(&"running"): _sprite.play(_phase_anim(&"running")) _update_facing() if not _player: _update_patrol_direction() func _current_move_speed() -> float: return move_speed * flame_move_speed_multiplier if _is_flame_phase else move_speed func _phase_anim(base_name: StringName) -> StringName: return StringName(("flame_" if _is_flame_phase else "normal_") + String(base_name)) func _update_facing() -> void: _sprite.flip_h = _facing_direction < 0.0 _combo_hitbox.scale.x = _facing_direction _combo_attack_range_area.scale.x = _facing_direction _jump_attack_hitbox.scale.x = _facing_direction _jump_attack_range_area.scale.x = _facing_direction func _has_ground_ahead(direction: float) -> bool: _ledge_detector.position.x = ledge_check_offset * direction _ledge_detector.force_raycast_update() return _ledge_detector.is_colliding() func _update_patrol_direction() -> void: if is_on_wall() or not _has_ground_ahead(_patrol_direction): _patrol_direction = -_patrol_direction func _start_combo_attack() -> void: _combo_step = (_combo_step % 3) + 1 _is_attacking = true _attack_cooldown_timer = attack_cooldown velocity.x = 0.0 _sprite.play(_phase_anim(StringName("attack%d" % _combo_step))) func _start_jump_attack(uninterruptible: bool = false) -> void: _is_jump_attacking = true _is_jump_attack_uninterruptible = uninterruptible _hits_taken = 0 _attack_cooldown_timer = attack_cooldown velocity.x = 0.0 _sprite.play(_phase_anim(&"jump_attack")) func _trigger_block() -> void: _block_count += 1 _is_attacking = false _combo_step = 0 _is_blocking = true _block_timer = block_reaction_time velocity.x = 0.0 _sprite.play(&"defend") if _block_count >= blocks_to_trigger_jump_attack: _block_count = 0 _is_blocking = false _start_jump_attack(true) func _start_shout() -> void: _is_shouting = true _is_attacking = false _is_jump_attacking = false _is_jump_attack_uninterruptible = false _is_hurt = false _is_blocking = false _combo_step = 0 _block_count = 0 velocity.x = 0.0 _sprite.play(&"shout") func _on_detection_area_body_entered(body: Node2D) -> void: _player = body func _on_detection_area_body_exited(body: Node2D) -> void: if body == _player: _player = null func _on_combo_range_area_entered(area: Area2D) -> void: if area is HurtBox and area.get_parent() == _player: _player_in_combo_range = true func _on_combo_range_area_exited(area: Area2D) -> void: if area is HurtBox and area.get_parent() == _player: _player_in_combo_range = false func _on_jump_range_area_entered(area: Area2D) -> void: if area is HurtBox and area.get_parent() == _player: _player_in_jump_range = true func _on_jump_range_area_exited(area: Area2D) -> void: if area is HurtBox and area.get_parent() == _player: _player_in_jump_range = false func _on_hurtbox_hit_received(hitbox: HitBox) -> void: if _is_dead: return if _is_hurt or _is_shouting: _time_since_last_action = 0.0 return if not _is_jump_attacking and _time_since_last_action < spam_hit_window: _time_since_last_action = 0.0 _trigger_block() return _health.take_damage(hitbox.damage) _hits_taken += 1 _block_count = 0 _time_since_last_action = 0.0 func _on_health_changed(_new_value: int, _max_value: int) -> void: if _is_shouting or _is_dead: return if _is_jump_attacking and _is_jump_attack_uninterruptible: return _is_attacking = false _is_jump_attacking = false _is_jump_attack_uninterruptible = false _combo_step = 0 _is_hurt = true _sprite.play(_phase_anim(&"hurt")) func _on_died() -> void: _is_dead = true _is_blocking = false velocity = Vector2.ZERO _hurtbox.set_deferred("monitoring", false) _combo_hitbox_collision.set_deferred("disabled", true) _jump_attack_hitbox_collision.set_deferred("disabled", true) remove_from_group("enemies") EventBus.enemy_defeated.emit() _sprite.play(&"death") await _sprite.animation_finished SceneManager.change_scene("res://scenes/components/outro.tscn") func _on_animation_finished() -> void: if _sprite.animation == _phase_anim(&"hurt"): _is_hurt = false _time_since_last_action = 0.0 if _is_dead: _sprite.play(&"death") elif String(_sprite.animation).ends_with("attack1") or String(_sprite.animation).ends_with("attack2") \ or String(_sprite.animation).ends_with("attack3"): _is_attacking = false if _combo_step == 3: _combo_step = 0 elif _sprite.animation == _phase_anim(&"jump_attack"): _is_jump_attacking = false _is_jump_attack_uninterruptible = false elif _sprite.animation == &"shout": _is_shouting = false _is_flame_phase = true elif _sprite.animation == &"death": queue_free()