Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
c16290e
Player: Enable repel and grapple abilities
manuq Mar 6, 2026
0527389
Player: Remove unused OccluderPolygon2D node
manuq Mar 6, 2026
fa74ff3
Player animation: Remove get_repel_animation()
manuq Mar 6, 2026
dd6ce40
Player: Rename Fighting to Repel
manuq Mar 6, 2026
4e26af8
Remove SwitchModeArea hack
manuq Mar 6, 2026
5e9635f
Player animation: Refactor
manuq Mar 6, 2026
2d08b1b
Input map: Change keyboard and mouse for repel and grapple
manuq Mar 6, 2026
f702dca
Input map: Change joypad repel action
manuq Mar 6, 2026
0e2d673
Prevent starting the repel action by smashing buttons
manuq Mar 9, 2026
0d601ea
Grapple action interrupts the repel action too
manuq Mar 9, 2026
4bd5cf5
Player: Extract "got hit" from PlayerRepel
manuq Mar 9, 2026
a202945
Dialogue balloon: Set cursor to arrow and back to cross
manuq Mar 9, 2026
ef977ea
Pause overlay: Set cursor to arrow and back to cross
manuq Mar 9, 2026
8609dc4
Mouse manager: Set default cursor to cross
manuq Mar 9, 2026
1409790
Quest Resource: Add property to identify lore quests
manuq Mar 10, 2026
7e7206e
Game State: Add player abilities for lore and StoryQuests
manuq Mar 11, 2026
ccd72b0
Player: Enable abilities from game state
manuq Mar 11, 2026
cb13a7c
Lore quests: grant abilities
manuq Mar 11, 2026
c4a46c1
GameState: Persist current scene in global, not scene, state
wjt Mar 11, 2026
23138b9
Overworld prototype
wjt Mar 11, 2026
24e0e48
Merge branch 'wjt/overworld-prototype' into overworld-abilities-remix
manuq Mar 11, 2026
d3d126f
Comment SQ abilities after merging overworld prototype
manuq Mar 11, 2026
5e3112b
Quest separator
manuq Mar 11, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions project.godot
Original file line number Diff line number Diff line change
Expand Up @@ -256,13 +256,14 @@ interact={
}
repel={
"deadzone": 0.2,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":32,"key_label":0,"unicode":32,"location":0,"echo":false,"script":null)
, Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"button_index":0,"pressure":0.0,"pressed":true,"script":null)
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":90,"key_label":0,"unicode":122,"location":0,"echo":false,"script":null)
, Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"button_index":10,"pressure":0.0,"pressed":true,"script":null)
, Object(InputEventMouseButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"button_mask":2,"position":Vector2(53, 19),"global_position":Vector2(62, 67),"factor":1.0,"button_index":2,"canceled":false,"pressed":true,"double_click":false,"script":null)
]
}
throw={
"deadzone": 0.2,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":32,"key_label":0,"unicode":32,"location":0,"echo":false,"script":null)
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":88,"key_label":0,"unicode":120,"location":0,"echo":false,"script":null)
, Object(InputEventJoypadMotion,"resource_local_to_scene":false,"resource_name":"","device":-1,"axis":5,"axis_value":1.0,"script":null)
, Object(InputEventMouseButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"button_mask":1,"position":Vector2(92, 25),"global_position":Vector2(92, 54),"factor":1.0,"button_index":1,"canceled":false,"pressed":true,"double_click":false,"script":null)
]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -86,9 +86,9 @@ func _before_dialogue() -> void:
func _on_interaction_ended() -> void:
if chosen_quest:
interact_area.disabled = true
GameState.start_quest(chosen_quest)
var target := GameState.start_quest(chosen_quest)
SceneSwitcher.change_to_file_with_transition(
chosen_quest.first_scene, ^"", Transition.Effect.FADE, Transition.Effect.FADE
target.scene_path, target.spawn_point, Transition.Effect.FADE, Transition.Effect.FADE
)
chosen_quest = null

Expand Down
Original file line number Diff line number Diff line change
@@ -1,32 +1,16 @@
# SPDX-FileCopyrightText: The Threadbare Authors
# SPDX-License-Identifier: MPL-2.0
~ start
LoreQuest Elder: [[Hi|Hello|Greetings]], StoryWeaver. We've been waiting for you. Our world is unraveling!
LoreQuest Elder: We need you to recover the Sacred Elements: the threads of Memory, Imagination, and Spirit! Will you help us?
do show_storybook()
if chosen_quest == null:
% LoreQuest Elder: Please stay. Our world will perish without your help!
% LoreQuest Elder: I hope to see you again. We really need you!
% LoreQuest Elder: The people of Threadbare are counting on you. I hope you change your mind!
elif chosen_quest.resource_path in GameState.completed_quests:
# Custom dialogues for completed quests
if chosen_quest.resource_path == "res://scenes/quests/lore_quests/quest_001/quest.tres":
% LoreQuest Elder: Ah, the Song Sanctuary sings once more... a melody you already helped restore.
% LoreQuest Elder: If you wish to revisit its echoes, traveler, the sanctuary always welcomes a gentle hand to tune its harmony again.

elif chosen_quest.resource_path == "res://scenes/quests/lore_quests/quest_002/quest.tres":
% LoreQuest Elder: The Void still stirs at the edges… even after the balance you once brought.
% LoreQuest Elder: Should you feel ready to face it again, your presence can steady the archipelago’s stories once more.

elif chosen_quest.resource_path == "res://scenes/quests/lore_quests/quest_003/quest.tres":
% LoreQuest Elder: You’ve walked this path before—where Imagination and Spirit intertwine.
% LoreQuest Elder: If you seek its lessons again, the Loom is always willing to be rewoven by steady hands.

else:
% LoreQuest Elder: This tale is one you already helped mend, traveler… yet its threads shift with each return.
% LoreQuest Elder: If your heart pulls you back to it, the story will open itself to you once again.
if not ("res://scenes/quests/lore_quests/quest_001/quest.tres" in GameState.completed_quests):
LoreQuest Elder: [[Hi|Hello|Greetings]], StoryWeaver. We've been waiting for you. Our world is unraveling!
LoreQuest Elder: We need you to recover the Sacred Elements: the threads of Memory, Imagination, and Spirit! Will you help us?
LoreQuest Elder: Seek out the musician. He lives in the Song Sanctuary.
elif not ("res://scenes/quests/lore_quests/quest_002/quest.tres" in GameState.completed_quests):
LoreQuest Elder: Hello again, StoryWeaver. Have you encountered the Void?
LoreQuest Elder: You must find Moss the Monk. She lives on an island outside Linenville.
else:
LoreQuest Elder: We believe in you, StoryWeaver.
LoreQuest Elder: OK, I'm out of ideas now. Maybe you can still find some stuff to do.
LoreQuest Elder: We believe in you, StoryWeaver.
=> END
~ go_to_loom
LoreQuest Elder: Go on, StoryWeaver: take the threads to the Eternal Loom.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,74 +6,67 @@ const REPEL_ANTICIPATION_TIME: float = 0.3

@onready var player: Player = owner
@onready var player_sprite: AnimatedSprite2D = %PlayerSprite
@onready var player_fighting: Node2D = %PlayerFighting
@onready var player_hook: Node2D = %PlayerHook
@onready var player_repel: PlayerRepel = %PlayerRepel
@onready var player_hook: PlayerHook = %PlayerHook
@onready var original_speed_scale: float = speed_scale


func _ready() -> void:
animation_finished.connect(_on_animation_finished)
player.mode_changed.connect(_on_player_mode_changed)
player_repel.repelling_changed.connect(_on_player_repel_repelling_changed)
player_hook.string_thrown.connect(_on_player_hook_string_thrown)


func _process(_delta: float) -> void:
match player.mode:
Player.Mode.COZY:
_process_walk_idle(_delta)
Player.Mode.FIGHTING:
_process_fighting(_delta)
Player.Mode.HOOKING:
_process_hooking(_delta)

var double_speed: bool = current_animation == &"walk" and player.is_running()
speed_scale = original_speed_scale * (2.0 if double_speed else 1.0)


func _get_repel_animation() -> StringName:
return &"repel"

if player.mode == player.Mode.DEFEATED:
return
if current_animation in [&"repel", &"throw_string"]:
return

func _process_walk_idle(_delta: float) -> void:
if player.velocity.is_zero_approx():
play(&"idle")
elif player_sprite.sprite_frames.has_animation(&"run") and player.is_running():
play(&"run")
else:
play(&"walk")

var double_speed: bool = current_animation == &"walk" and player.is_running()
speed_scale = original_speed_scale * (2.0 if double_speed else 1.0)

func _process_fighting(delta: float) -> void:
var repel: StringName = _get_repel_animation()
if not player_fighting.is_fighting:
# If the current animation is repel and it has passed the anticipation
# phase, it plays until the end.
if not (
current_animation == repel and current_animation_position > REPEL_ANTICIPATION_TIME
):
_process_walk_idle(delta)
return

if current_animation != repel:
# Fighting animation is being played for the first time. So skip the anticipation and go
# directly to the action.
play(repel)
seek(REPEL_ANTICIPATION_TIME, false, false)


func _process_hooking(delta: float) -> void:
if current_animation == &"throw_string":
return

_process_walk_idle(delta)
func _on_animation_finished(animation_name: StringName) -> void:
if animation_name == &"repel" and player_repel.repelling:
speed_scale = original_speed_scale
play(&"repel")


func _on_player_mode_changed(mode: Player.Mode) -> void:
match player.mode:
Player.Mode.DEFEATED:
speed_scale = original_speed_scale
play(&"defeated")


func _on_player_repel_repelling_changed(repelling: bool) -> void:
if not repelling:
return

# The repel animation is already ongoing. Prevent starting it again by smashing the buttons.
if current_animation == &"repel":
return

# Repel animation is being played for the first time. So skip the anticipation and go
# directly to the action.
speed_scale = original_speed_scale
play(&"repel")
seek(REPEL_ANTICIPATION_TIME, false, false)


func _on_player_hook_string_thrown() -> void:
if current_animation == &"throw_string":
# A new throw action (blading sword) interrupts the previous one.
# It also interrupts the repel action.
if current_animation in [&"repel", &"throw_string"]:
stop()
speed_scale = original_speed_scale
play(&"throw_string")
41 changes: 23 additions & 18 deletions scenes/game_elements/characters/player/components/player.gd
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ const DEFAULT_SPRITE_FRAME: SpriteFrames = preload("uid://vwf8e1v8brdp")
var input_vector: Vector2

@onready var player_interaction: PlayerInteraction = %PlayerInteraction
@onready var player_fighting: Node2D = %PlayerFighting
@onready var player_repel: Node2D = %PlayerRepel
@onready var player_hook: PlayerHook = %PlayerHook
@onready var player_sprite: AnimatedSprite2D = %PlayerSprite
@onready var _walk_sound: AudioStreamPlayer2D = %WalkSound
Expand All @@ -87,26 +87,14 @@ func _set_mode(new_mode: Mode) -> void:
if not is_node_ready():
return
match mode:
Mode.COZY:
_toggle_player_behavior(player_interaction, true)
_toggle_player_behavior(player_fighting, false)
_toggle_player_behavior(player_hook, false)
Input.set_default_cursor_shape(Input.CURSOR_ARROW)
Mode.FIGHTING:
_toggle_player_behavior(player_interaction, false)
_toggle_player_behavior(player_fighting, true)
_toggle_player_behavior(player_hook, false)
Input.set_default_cursor_shape(Input.CURSOR_ARROW)
Mode.HOOKING:
_toggle_player_behavior(player_interaction, false)
_toggle_player_behavior(player_fighting, false)
_toggle_player_behavior(player_hook, true)
Input.set_default_cursor_shape(Input.CURSOR_CROSS)
Mode.DEFEATED:
_toggle_player_behavior(player_interaction, false)
_toggle_player_behavior(player_fighting, false)
_toggle_player_behavior(player_repel, false)
_toggle_player_behavior(player_hook, false)
Input.set_default_cursor_shape(Input.CURSOR_ARROW)
_:
_toggle_player_behavior(player_interaction, true)
_toggle_abilities()

if mode != previous_mode:
mode_changed.emit(mode)

Expand Down Expand Up @@ -159,6 +147,7 @@ func _get_configuration_warnings() -> PackedStringArray:
func _ready() -> void:
_set_mode(mode)
_set_sprite_frames(sprite_frames)
GameState.abilities_changed.connect(_on_abilities_changed)


func _unhandled_input(_event: InputEvent) -> void:
Expand Down Expand Up @@ -259,6 +248,22 @@ func defeat(falling: bool = false) -> void:
_handle_game_over()


func _toggle_abilities() -> void:
var can_repel := GameState.has_ability(Enums.PlayerAbilities.ABILITY_A)
var can_grapple := GameState.has_ability(Enums.PlayerAbilities.ABILITY_B)
_toggle_player_behavior(player_repel, can_repel)
_toggle_player_behavior(player_hook, can_grapple)
if can_grapple:
var has_longer_hook := GameState.has_ability(Enums.PlayerAbilities.ABILITY_B_MODIFIER_1)
player_hook.string_throw_length = 400.0 if has_longer_hook else 200.0
player_hook.string_max_length = 450.0 if has_longer_hook else 250.0


func _on_abilities_changed() -> void:
if mode != Mode.DEFEATED:
_toggle_abilities()


## Handles game over logic: restarts from the beginning of the current challenge
## with lives reset to 3.
func _handle_game_over() -> void:
Expand Down

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# SPDX-FileCopyrightText: The Threadbare Authors
# SPDX-License-Identifier: MPL-2.0
extends Node2D

## The player hitbox area.
@onready var hit_box: Area2D = %HitBox

## Animation to play when the player gets hit.
@onready var got_hit_animation: AnimationPlayer = %GotHitAnimation


func _on_hit_box_body_entered(body: Node2D) -> void:
body = body as Projectile
if not body:
return
body.add_small_fx()
body.queue_free()
got_hit_animation.play(&"got_hit")
CameraShake.shake()


func _notification(what: int) -> void:
match what:
NOTIFICATION_DISABLED:
got_hit_animation.play(&"RESET")
got_hit_animation.advance(0)
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
uid://dajod2qsesqte
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# SPDX-FileCopyrightText: The Threadbare Authors
# SPDX-License-Identifier: MPL-2.0
class_name PlayerRepel
extends Node2D

## Emitted when the repel starts or stops.
signal repelling_changed(repelling: bool)

## Current state of the repel.
var repelling: bool = false:
set = _set_repelling

@onready var air_stream: Area2D = %AirStream

@onready var player: Player = self.owner as Player


func _set_repelling(new_repelling: bool) -> void:
repelling = new_repelling
repelling_changed.emit(repelling)


func _unhandled_input(_event: InputEvent) -> void:
if Input.is_action_just_pressed(&"repel"):
repelling = true
elif Input.is_action_just_released(&"repel"):
repelling = false


func _on_air_stream_body_entered(body: Projectile) -> void:
body.got_hit(owner)
Loading
Loading