extends CharacterBody2D signal health_changed(current_health: int, max_health: int) signal died # Collision layers used by this first playable slice. # Keeping the numbers here makes the scene setup easier to understand: # 1 -> world # 2 -> player body # 3 -> enemy body # 4 -> player hurtbox # 5 -> enemy hurtbox const ENEMY_HURTBOX_MASK: int = 1 << 4 # ============================================================================ # CHARACTER # ============================================================================ @export_category("Character") ## Resource that identifies the selected band member and contains the stats ## that are allowed to differ between characters. @export var character_data: CharacterData # ============================================================================ # MOVEMENT # ============================================================================ @export_category("Movement") @export var ground_acceleration: float = 2200.0 @export var ground_friction: float = 2600.0 @export var air_acceleration: float = 1000.0 # ============================================================================ # JUMP # ============================================================================ @export_category("Jump") ## Allows a jump a few milliseconds after walking off a ledge. @export var coyote_time: float = 0.12 ## Remembers an early jump press just before landing. @export var jump_buffer_time: float = 0.12 ## Releasing jump while rising cuts the jump short. @export_range(0.1, 1.0) var jump_cut_multiplier: float = 0.45 @export var gravity_multiplier: float = 1.0 @export var fall_gravity_multiplier: float = 1.7 @export var max_fall_speed: float = 1200.0 # ============================================================================ # DAMAGE RECEIVED # ============================================================================ @export_category("Damage received") ## Short grace period so a contact hitbox cannot drain all health in one frame. @export var invulnerability_duration: float = 0.65 ## Horizontal and vertical impulse applied when the player is hit. @export var damage_knockback: Vector2 = Vector2(300.0, 260.0) # ============================================================================ # PUNCH # ============================================================================ @export_category("Punch") @export var punch_duration: float = 0.13 @export var punch_cooldown: float = 0.28 @export var punch_hitbox_size: Vector2 = Vector2(55.0, 38.0) @export var punch_offset: Vector2 = Vector2(48.0, 0.0) # ============================================================================ # GROUND POUND # ============================================================================ @export_category("Ground Pound") ## Minimum downward speed when the air attack starts. @export var ground_pound_start_speed: float = 450.0 ## Extra gravity while performing the air attack. @export var ground_pound_gravity_multiplier: float = 4.5 ## Small amount of steering kept during the fall. @export_range(0.0, 1.0) var ground_pound_horizontal_control: float = 0.25 @export var ground_pound_radius: float = 85.0 @export var ground_pound_offset: Vector2 = Vector2(0.0, 28.0) # ============================================================================ # DEBUG # ============================================================================ @export_category("Debug") ## Draws the actual hurtbox and attack areas used by gameplay. ## It is deliberately enabled while the final animations do not exist. @export var show_debug_hitboxes: bool = true ## Keeps the ground-pound area visible briefly after impact. @export var debug_impact_duration: float = 0.18 # ============================================================================ # NODES # ============================================================================ @onready var animated_sprite: AnimatedSprite2D = $AnimatedSprite2D @onready var hurtbox_collision: CollisionShape2D = $Hurtbox/CollisionShape2D @onready var punch_hitbox: Area2D = $PunchHitbox @onready var punch_collision: CollisionShape2D = $PunchHitbox/CollisionShape2D @onready var ground_pound_hitbox: Area2D = $GroundPoundHitbox @onready var ground_pound_collision: CollisionShape2D = $GroundPoundHitbox/CollisionShape2D # ============================================================================ # RUNTIME STATE # ============================================================================ var current_health: int = 1 var facing_direction: float = 1.0 var coyote_timer: float = 0.0 var jump_buffer_timer: float = 0.0 var invulnerability_timer: float = 0.0 var punch_timer: float = 0.0 var attack_cooldown_timer: float = 0.0 var ground_pound_active: bool = false var ground_pound_debug_timer: float = 0.0 var is_dead: bool = false ## Prevents one target from receiving damage every physics frame during the ## same punch. A new punch starts with an empty set again. var punch_hit_targets: Dictionary = {} func _ready() -> void: if character_data == null: push_error("Player needs a CharacterData resource.") return current_health = character_data.max_health if character_data.sprite_frames != null: animated_sprite.sprite_frames = character_data.sprite_frames _setup_attack_hitboxes() _update_facing() _update_animation() health_changed.emit(current_health, character_data.max_health) func _physics_process(delta: float) -> void: if character_data == null or is_dead: return var was_on_floor: bool = is_on_floor() _update_timers(delta) _update_jump_windows(delta) _handle_attack_input() _handle_jump() _apply_gravity(delta) _handle_horizontal_movement(delta) _update_punch_damage() move_and_slide() # Landing is only reliable after move_and_slide() has resolved collisions. if ground_pound_active and not was_on_floor and is_on_floor(): _ground_pound_impact() _update_animation() queue_redraw() # ============================================================================ # MOVEMENT # ============================================================================ func _handle_horizontal_movement(delta: float) -> void: var direction: float = Input.get_axis("move_left", "move_right") if direction != 0.0: facing_direction = signf(direction) _update_facing() var target_speed: float = direction * character_data.move_speed if ground_pound_active: target_speed *= ground_pound_horizontal_control velocity.x = move_toward( velocity.x, target_speed, air_acceleration * delta ) return if is_on_floor(): var acceleration: float = ground_acceleration if direction != 0.0 else ground_friction velocity.x = move_toward(velocity.x, target_speed, acceleration * delta) else: velocity.x = move_toward(velocity.x, target_speed, air_acceleration * delta) func _update_facing() -> void: animated_sprite.flip_h = facing_direction < 0.0 # Only the horizontal offset changes when turning around. The hitbox size # stays identical for both directions. punch_hitbox.position = Vector2( abs(punch_offset.x) * facing_direction, punch_offset.y ) # ============================================================================ # GRAVITY AND JUMP # ============================================================================ func _apply_gravity(delta: float) -> void: if is_on_floor(): return var gravity: Vector2 = get_gravity() var multiplier: float = gravity_multiplier if ground_pound_active: multiplier = ground_pound_gravity_multiplier elif velocity.y > 0.0: multiplier = fall_gravity_multiplier velocity += gravity * multiplier * delta velocity.y = min(velocity.y, max_fall_speed) func _update_jump_windows(delta: float) -> void: if is_on_floor(): coyote_timer = coyote_time else: coyote_timer = max(coyote_timer - delta, 0.0) if Input.is_action_just_pressed("jump"): jump_buffer_timer = jump_buffer_time else: jump_buffer_timer = max(jump_buffer_timer - delta, 0.0) func _handle_jump() -> void: if ( jump_buffer_timer > 0.0 and coyote_timer > 0.0 and not ground_pound_active ): velocity.y = character_data.jump_velocity jump_buffer_timer = 0.0 coyote_timer = 0.0 if Input.is_action_just_released("jump") and velocity.y < 0.0: velocity.y *= jump_cut_multiplier # ============================================================================ # ATTACK INPUT # ============================================================================ func _handle_attack_input() -> void: if not Input.is_action_just_pressed("attack"): return if attack_cooldown_timer > 0.0: return if is_on_floor(): _start_punch() else: _start_ground_pound() # ============================================================================ # PUNCH # ============================================================================ func _start_punch() -> void: punch_timer = punch_duration attack_cooldown_timer = punch_cooldown punch_hit_targets.clear() func _update_punch_damage() -> void: if punch_timer <= 0.0: return for area in punch_hitbox.get_overlapping_areas(): _damage_target(area, character_data.basic_attack_damage, punch_hit_targets) # ============================================================================ # GROUND POUND # ============================================================================ func _start_ground_pound() -> void: if ground_pound_active: return ground_pound_active = true attack_cooldown_timer = punch_cooldown # If the player is still rising, the attack immediately turns the movement # into a fall instead of waiting for the jump apex. velocity.y = max(velocity.y, ground_pound_start_speed) func _ground_pound_impact() -> void: ground_pound_active = false ground_pound_debug_timer = debug_impact_duration var hit_targets: Dictionary = {} for area in ground_pound_hitbox.get_overlapping_areas(): _damage_target(area, character_data.air_attack_damage, hit_targets) # ============================================================================ # DEALING AND RECEIVING DAMAGE # ============================================================================ func _damage_target(collider: Node, damage: int, hit_targets: Dictionary) -> void: var target: Node = collider # Hurtboxes are Area2D children. Damage belongs to the entity that owns the # hurtbox, so we ask the parent for take_damage(). if not target.has_method("take_damage"): target = target.get_parent() if target == null or not target.has_method("take_damage"): return var target_id: int = target.get_instance_id() if hit_targets.has(target_id): return hit_targets[target_id] = true target.take_damage(damage, global_position) func take_damage(amount: int, source_position: Vector2) -> void: if amount <= 0 or is_dead or invulnerability_timer > 0.0: return current_health = max(current_health - amount, 0) invulnerability_timer = invulnerability_duration # Knock the player away from the source. If both centers are aligned, use # the opposite of the current facing direction as a deterministic fallback. var knockback_direction: float = signf(global_position.x - source_position.x) if knockback_direction == 0.0: knockback_direction = -facing_direction velocity.x = knockback_direction * damage_knockback.x velocity.y = -damage_knockback.y health_changed.emit(current_health, character_data.max_health) if current_health == 0: _die() func _die() -> void: is_dead = true died.emit() # Reloading from a deferred call avoids changing the scene tree while a # physics overlap is still being processed. call_deferred("_reload_level") func _reload_level() -> void: get_tree().reload_current_scene() # ============================================================================ # TIMERS AND ANIMATION # ============================================================================ func _update_timers(delta: float) -> void: punch_timer = max(punch_timer - delta, 0.0) attack_cooldown_timer = max(attack_cooldown_timer - delta, 0.0) ground_pound_debug_timer = max(ground_pound_debug_timer - delta, 0.0) invulnerability_timer = max(invulnerability_timer - delta, 0.0) func _update_animation() -> void: # There are only idle and walk animations at the moment. Until proper jump # and attack animations arrive, walk is used only for horizontal walking and # idle is the safe fallback for every other state. if is_on_floor() and abs(velocity.x) > 10.0: animated_sprite.play("walk") else: animated_sprite.play("idle") # ============================================================================ # HITBOX SETUP # ============================================================================ func _setup_attack_hitboxes() -> void: var punch_shape: RectangleShape2D = RectangleShape2D.new() punch_shape.size = punch_hitbox_size punch_collision.shape = punch_shape punch_hitbox.collision_layer = 0 punch_hitbox.collision_mask = ENEMY_HURTBOX_MASK punch_hitbox.monitoring = true var pound_shape: CircleShape2D = CircleShape2D.new() pound_shape.radius = ground_pound_radius ground_pound_collision.shape = pound_shape ground_pound_hitbox.position = ground_pound_offset ground_pound_hitbox.collision_layer = 0 ground_pound_hitbox.collision_mask = ENEMY_HURTBOX_MASK ground_pound_hitbox.monitoring = true # ============================================================================ # DEBUG HITBOX DRAWING # ============================================================================ func _draw() -> void: if not show_debug_hitboxes: return # Player hurtbox: always visible and deliberately subtle. _draw_rectangle_collision( hurtbox_collision, Color(1.0, 0.08, 0.08, 0.10), Color(1.0, 0.08, 0.08, 0.45) ) # Punch: only visible while it can actually deal damage. if punch_timer > 0.0: _draw_rectangle_collision( punch_collision, Color(1.0, 0.05, 0.05, 0.25), Color(1.0, 0.05, 0.05, 0.95), punch_hitbox.position ) # Ground pound: show the real damage radius throughout the fall, then make # it stronger for a few frames at the exact impact moment. if ground_pound_active or ground_pound_debug_timer > 0.0: var fill_alpha: float = 0.14 if ground_pound_active else 0.28 var outline_alpha: float = 0.60 if ground_pound_active else 0.95 draw_circle( ground_pound_hitbox.position, ground_pound_radius, Color(1.0, 0.18, 0.05, fill_alpha) ) draw_arc( ground_pound_hitbox.position, ground_pound_radius, 0.0, TAU, 48, Color(1.0, 0.18, 0.05, outline_alpha), 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)