src / player / player.gd

Code

extends CharacterBody2D
class_name Player

@export var GhostEffect : PackedScene
@export var LandedEffect : PackedScene
@export var fatigue_curve: Curve

signal dashed
signal jumped
signal landed
signal failed
signal reached(area: Area2D)

enum State {
	CLIMBING,
	DASHED,
	DASHING,
	EXHAUSTED,
	FAILED,
	FALLING,
	GROUNDING,
	JUMPED,
	KICKED,
	LOCKED,
}

const SPEED = 700.0
const DASH_SPEED = SPEED * 4
const CLIMBING_SPEED = SPEED / 6
const JUMP_VELOCITY = -1200.0
const GRAVITY = 4000
const ALLOWED_TIME_TO_JUMP = 0.13
const DASH_TIME = 0.1
const DASH_ATTENUATION = Vector2(55, 50)
const MAX_DASH_COUNT = 1
const MAX_CLIMBING_TIME = 7.0
const DEFAULT_SCALE = Vector2(6.0, 6.0)
var state : State = State.GROUNDING
var falling_time := 0.0
var dash_rest_time := 0.0
var dash_rest_count := MAX_DASH_COUNT
var climb_rest_time := 0.0
var sprite_tween : Tween
var restart_position : Vector2
var jump_boost : Vector2
static var last_owner_name : String
static var playable : bool = false


func _ready() -> void:
	playable = true
	turn_right()
	if not owner: return
	if last_owner_name == owner.name:
		position = restart_position
	else:
		restart_position = position
	last_owner_name = owner.name


func _physics_process(delta: float) -> void:
	var dx := Input.get_axis(&'move_left', &'move_right')
	var dy := Input.get_axis(&'move_up', &'move_down')
	var direction := Vector2(dx, dy)
	var last_state := state
	state = compute_state(delta, direction)
	match state:
		State.CLIMBING:
			dx = 0
			velocity.y = dy * CLIMBING_SPEED
		State.DASHED:
			velocity = direction.normalized() * DASH_SPEED
		State.DASHING:
			velocity *= DASH_ATTENUATION * delta
		State.EXHAUSTED:
			dx = 0
		State.FAILED:
			velocity.x = lerp(velocity.x, 0.0, 0.2)
			velocity.y = lerp(velocity.y, 0.0, 0.2)
		State.FALLING:
			if last_state == State.CLIMBING \
			and dy < 0:
				velocity.y = JUMP_VELOCITY * 0.8
			else:
				var move_x := dx * SPEED * 0.8
				if velocity.x * dx < 0: move_x *= 0.1
				velocity.x = lerp(velocity.x, move_x, 0.2)

				if velocity.y < JUMP_VELOCITY * 0.85:
					velocity.y += GRAVITY * 1.15 * delta
				else:
					velocity.y += GRAVITY * delta
		State.JUMPED:
			velocity.y = JUMP_VELOCITY
		State.KICKED:
			velocity.y = JUMP_VELOCITY * 0.9
			velocity.x = get_wall_normal().x * DASH_SPEED * 0.4
		State.LOCKED:
			dx = 0
			velocity = Vector2(0, 0)
		_: pass

	if not playable: dx = 0
	if not (state == State.DASHED \
		or state == State.DASHING \
		or state == State.FALLING \
		or state == State.KICKED
	):
		if dx: velocity.x = dx * SPEED
		else: velocity.x = move_toward(velocity.x, 0, SPEED*0.2)
	if state == State.JUMPED:
		velocity += jump_boost
		jump_boost = Vector2(0, 0)

	animate(playable)
	move_and_slide()


func compute_state(delta: float, direction: Vector2) -> State:
	if state == State.FAILED: return State.FAILED
	if state == State.LOCKED: return State.LOCKED
	if is_just_input_action(&'move_dash') \
	and dash_rest_count > 0 \
	and direction:
		compute_dashed()
		return State.DASHED
	elif dash_rest_time > 0:
		dash_rest_time -= delta
		return State.DASHING
	else:
		if not is_on_floor() and not state == State.CLIMBING:
			falling_time += delta
		else:
			if state == State.FALLING: landed.emit()
			falling_time = 0
			if not state == State.CLIMBING:
				dash_rest_count = MAX_DASH_COUNT
				if direction.y > 0: may_drop_collisions()

		var on_wall = %FrontRayCast.is_colliding()
		if is_just_input_action(&'move_jump') \
		and falling_time < ALLOWED_TIME_TO_JUMP:
			jumped.emit()
			return State.JUMPED
		elif is_just_input_action(&'move_jump') \
		and on_wall:
			jumped.emit()
			return State.KICKED
		elif on_wall and is_input_action(&'move_climb') \
		and (velocity.y >= 0 or state == State.CLIMBING) \
		and not (state == State.FALLING and falling_time < 0.2):
			falling_time = 0
			climb_rest_time -= delta
			if climb_rest_time > 0:
				return State.CLIMBING
			else:
				return State.EXHAUSTED

		if not is_on_floor():
			return State.FALLING
		else:
			climb_rest_time = MAX_CLIMBING_TIME
			return State.GROUNDING


func may_drop_collisions() -> void:
	for i in get_slide_collision_count():
		var collider = get_slide_collision(i).get_collider()
		if collider.is_in_group('droppable'):
			collider.drop()


func is_just_input_action(key: StringName) -> bool:
	return playable and state != State.EXHAUSTED \
		and Input.is_action_just_pressed(key)


func is_input_action(key: StringName) -> bool:
	return playable and state != State.EXHAUSTED \
		and Input.is_action_pressed(key)


func animate(force: bool = true) -> void:
	if state == State.LOCKED: return
	if force: animate_with(velocity)
	update_sprite_states_shader_parameters()
	if dash_rest_time > 0 && int(dash_rest_time*100)%3 > 0:
		spawn_ghost_effect()


func animate_with(_velocity: Vector2) -> void:
	if _velocity.x:
		%FrontRayCast.target_position.x = sign(_velocity.x) \
			* abs(%FrontRayCast.target_position.x)
		for st in ['Idle', 'Run', 'Jump', 'Fall', 'Climb']:
			%AnimationTree.set('parameters/%s/blend_position' % [st], _velocity.x)
	%AnimationTree.get('parameters/playback').travel(get_state_by(_velocity))


func compute_dashed() -> void:
	dashed.emit()
	dash_rest_time = DASH_TIME
	dash_rest_count -= 1


func idle() -> void:
	%AnimationPlayer.play(&'RESET')
	animate_with(Vector2(0, 0))


func walk_right() -> void:
	animate_with(Vector2(1, 0))


func walk_left() -> void:
	animate_with(Vector2(-1, 0))


func turn_right() -> void:
	walk_right(); idle()


func turn_left() -> void:
	walk_left(); idle()


func restart() -> void:
	%RestartCurtain.play()
	await get_tree().create_timer(1).timeout
	position = restart_position
	playable = true
	jump_boost = Vector2(0, 0)
	unlock()
	idle.call_deferred()


func dash(direction: Vector2) -> void:
	compute_dashed()
	state = State.DASHED
	velocity = direction.normalized() * DASH_SPEED


func lock() -> void:
	state = State.LOCKED
	%AnimationTree.active = false


func unlock() -> void:
	state = State.EXHAUSTED
	%AnimationTree.active = true


func vibrate(key: String):
	var values = [0, 0, 0]
	match key:
		&'Dashed': values = [0.4, 0.6, 0.2]
		&'Landed': values = [0.1, 0.3, 0.1]
	Input.start_joy_vibration(0, values[0], values[1], values[2])


func get_state_by(_velocity) -> String:
	if state == State.CLIMBING: return &'Climb'
	if _velocity:
		if _velocity.y < 0: return &'Jump'
		if _velocity.y > 0: return &'Fall'
		else: return &'Run'
	else: return &'Idle'


func tween_sprite(key: StringName) -> void:
	if sprite_tween: sprite_tween.kill()
	sprite_tween = %Sprites.create_tween()
	var coef = Vector2(1, 1)
	match key:
		&'dashed':
			var dir := velocity.normalized()
			coef = dir.abs().clamp(Vector2(0.75, 0.75), Vector2(1.0, 1.0))
		&'landed': coef = Vector2(1.08, 0.92)
		&'jumped': coef = Vector2(0.65, 1.45)

	sprite_tween.tween_property(%Sprites, 'scale', coef * DEFAULT_SCALE, 0.10) \
		.set_ease(Tween.EASE_IN_OUT).set_trans(Tween.TRANS_CUBIC)
	sprite_tween.tween_property(%Sprites, 'scale', DEFAULT_SCALE, 0.16) \
		.set_ease(Tween.EASE_OUT).set_trans(Tween.TRANS_SINE)


func update_sprite_states_shader_parameters() -> void:
	for sprite in %SpriteStates.get_children():
		sprite.material.set_shader_parameter(&'dashed', is_dashed())
		var fatigue = 0.0
		if state == State.CLIMBING:
			fatigue = fatigue_curve.sample(climb_rest_time / MAX_CLIMBING_TIME)
		sprite.material.set_shader_parameter(&'fatigue', fatigue)


func spawn_ghost_effect() -> void:
	var ghost = GhostEffect.instantiate()
	ghost.spawn(self)


func current_sprite_state() -> Sprite2D:
	return %SpriteStates.get_children().filter(
		func(c): return c.visible)[0]


func is_dashed() -> bool:
	return true if dash_rest_count < MAX_DASH_COUNT else false


func _on_dashed() -> void:
	vibrate(&'Dashed')
	tween_sprite(&'dashed')
	%Camera.shake(&'dash')


func _on_jumped() -> void:
	tween_sprite(&'jumped')


func _on_landed() -> void:
	vibrate(&'Landed')
	tween_sprite(&'landed')
	var effect = LandedEffect.instantiate()
	effect.spawn(self)
	idle()


func _on_failed() -> void:
	playable = false
	state = State.FAILED
	velocity *= -1
	%Camera.shake(&'failure')
	%AnimationTree.active = false
	%AnimationPlayer.play(&'burst')


func _on_reached(area: Area2D) -> void:
	restart_position = area.position


func _on_frame_subject_area_entered(area: Area2D) -> void:
	%Camera.add_frame(area)


func _on_frame_subject_area_exited(area: Area2D) -> void:
	%Camera.remove_frame(area)


func _on_area_collision_area_entered(area: Area2D) -> void:
	if area.get_collision_layer_value(4): # Failure
		failed.emit()
	elif area.get_collision_layer_value(6): # CheckPoint
		reached.emit(area)