Files
mortal-maze-the-game/player/player.gd
T

520 lines
16 KiB
GDScript

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")
## Reaches full running speed quickly, but not instantly.
@export var ground_acceleration: float = 3600.0
## Used when there is no horizontal input. Slightly stronger than acceleration
## so releasing the controls feels precise instead of slippery.
@export var ground_friction: float = 4800.0
## Braking used only when the player asks for the opposite direction. The
## character loses the old momentum first and then accelerates the other way,
## producing a small but readable turn delay.
@export var turn_braking: float = 5600.0
## Air control stays responsive but weaker than movement on the floor.
@export var air_acceleration: float = 1800.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():
if direction == 0.0:
velocity.x = move_toward(velocity.x, 0.0, ground_friction * delta)
elif velocity.x != 0.0 and signf(velocity.x) != signf(direction):
# Do not snap directly from full speed right to full speed left. Brake
# the previous momentum first; on the next frames normal acceleration
# takes over in the requested direction.
velocity.x = move_toward(velocity.x, 0.0, turn_braking * delta)
else:
velocity.x = move_toward(
velocity.x,
target_speed,
ground_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)