Skip to content

Make Repel action a bit more generic#2088

Open
manuq wants to merge 5 commits intomainfrom
abilities-prototypes
Open

Make Repel action a bit more generic#2088
manuq wants to merge 5 commits intomainfrom
abilities-prototypes

Conversation

@manuq
Copy link
Copy Markdown
Collaborator

@manuq manuq commented Mar 26, 2026

The repel mechanic was initially intended for the ink blobs, then to any projectile. But we can now consider a repel action that affects more game elements.


Physics layers: Rename projectile to repellable

Similar to the "hookable" layer. To make the repel mechanic a bit more generic.

Update the enum and the references in eldrune StoryQuest.


Project: Set default gravity to zero

Otherwise adding a RigidBody2D to the scene, the bodies go down
until they hit a wall.

Setting the default gravity to zero, the object looks like it's on
the ground.


Repel mechanic: Make it work with any Node2D

That has a got_repelled() method.

So far we've been assuming a Projectile node for the on_body_entered handler of the Repel area.
But the Area2D.body_entered signal is sent with the body: Node2D parameter (which in reality
can be a PhysicsBody2D or a TileMap).

So use duck typing and also rename the expected method to "got_repelled". Also, call it
with the direction of the repel. Previously the Player was passed and the repel direction
was calculated on the called node.

This makes the repel ability more generic.

Also adapt the eldrune_projectile.gd script.


Player repel: Split to its own scene

So the action can be used in other entities of the game, like in NPCs. Export a player controlled boolean
for that same reason.

Move all animation tracks related to the repel action, except the player animation, to its own AnimationPlayer.
These two animations (the player moving the button and the repel air stream growing) should be kept in sync.


Add examples of custom repellable objects

Add a test gym scene with multiple things to repel:

  • A box that can be pushed along a grid.
  • Bouncy ball.
  • A lever that can be swithed in the repelled direction.

Resolve #2059

@github-actions
Copy link
Copy Markdown

Play this branch at https://play.threadbare.game/branches/endlessm/abilities-prototypes/.

(This launches the game from the start, not directly at the change(s) in this pull request.)

@manuq manuq force-pushed the abilities-prototypes branch 4 times, most recently from 31d9bd2 to 2b85c96 Compare March 27, 2026 18:07
@manuq manuq changed the title Abilities prototypes Make Repel action a bit more generic Mar 27, 2026
@manuq manuq force-pushed the abilities-prototypes branch from 12a60c1 to fd2f9c8 Compare March 27, 2026 18:18
@manuq manuq mentioned this pull request Mar 27, 2026
@manuq manuq force-pushed the abilities-prototypes branch from fd2f9c8 to a7e99a6 Compare March 27, 2026 18:38
manuq added 5 commits March 30, 2026 09:20
Similar to the "hookable" layer. To make the repel mechanic a bit more generic.

Update the enum and the references in eldrune StoryQuest.
Otherwise adding a RigidBody2D to the scene, the bodies go down
until they hit a wall.

Setting the default gravity to zero, the object looks like it's on
the ground.
That has a got_repelled() method.

So far we've been assuming a Projectile node for the on_body_entered handler of the Repel area.
But the Area2D.body_entered signal is sent with the body: Node2D parameter (which in reality
can be a PhysicsBody2D or a TileMap).

So use duck typing and also rename the expected method to "got_repelled". Also, call it
with the direction of the repel. Previously the Player was passed and the repel direction
was calculated on the called node.

This makes the repel ability more generic.

Also adapt the eldrune_projectile.gd script.
So the action can be used in other entities of the game, like in NPCs. Export a player controlled boolean
for that same reason.

Move all animation tracks related to the repel action, except the player animation, to its own AnimationPlayer.
These two animations (the player moving the button and the repel air stream growing) should be kept in sync.
Add a test gym scene with multiple things to repel:
- A box that can be pushed along a grid.
- Bouncy ball.
- A lever that can be swithed in the repelled direction.
@manuq manuq force-pushed the abilities-prototypes branch from a7e99a6 to 62a9980 Compare March 30, 2026 22:08
@manuq manuq marked this pull request as ready for review March 30, 2026 22:20
@manuq manuq requested review from a team as code owners March 30, 2026 22:20
@manuq
Copy link
Copy Markdown
Collaborator Author

manuq commented Mar 30, 2026

This makes the repel a separate component that is also more generic. Any physic body can become repellable, using duck-typing for the called method and being in the corresponding layer.

This PR comes with a gym scene and some custom repellable objects. But is up to the learners to come up with more interesting ones.

recording.webm


[physics]

2d/default_gravity=0.0
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Without changing this default, here is what happens when RigidBody2D are added to a scene:

recording.webm

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What our projectiles do to overcome the situation is: change Gravity Scale to zero:

image

However I think we should change the default. Because down in Threadbare is south. Not bottom, as in a platformer / side scrolling view.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree!

Copy link
Copy Markdown
Member

@wjt wjt left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I can't approve this for unknown reasons:

Image

but it's approved

Comment on lines +56 to +66
func got_repelled(direction: Vector2) -> void:
var sign_x := signf(direction.x)
var new_side: Enums.LookAtSide
if sign_x == 1:
new_side = Enums.LookAtSide.RIGHT
elif sign_x == -1:
new_side = Enums.LookAtSide.LEFT
if new_side == lever_side:
shaker.shake()
else:
lever_side = new_side
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's very unlikely but possible that sign_x could be 0. I don't think the handling is correct in this case: everything else in this file assumes that the only possibilities are LEFT and RIGHT but here it will be set to UNSPECIFIED in that unlikely case.

Since there are only 2 valid positions for the lever - left and right - and we hardcode that right is on, I think it would be simpler & less bug-prone to instead store a bool. What do you think?

# SPDX-License-Identifier: MPL-2.0
extends StaticBody2D

## Emitted when the scene starts, indicating the initial state of this unlocker.
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
## Emitted when the scene starts, indicating the initial state of this unlocker.
## Emitted when the scene starts, indicating the initial state of this lever.

Comment on lines +43 to +44
var axis := get_closest_axis(direction)
var neighbor := NEIGHBORS_FOR_AXIS[axis]
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same bug here - it's unlikely but possible that axis is Vector2i(0, 0) in which case this line will crash because there is no such key in NEIGHBORS_FOR_AXIS. You could just return early in that case.

Vector2i.RIGHT: TileSet.CELL_NEIGHBOR_RIGHT_SIDE,
}

@export var constrain_layer: TileMapLayer
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a really clever design. I wish I'd thought of this!

Comment on lines +23 to +24
func tile_coordinate_to_global_position(coord: Vector2i) -> Vector2:
return constrain_layer.map_to_local(coord)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This doesn't return a global position, it returns a local position relative to constrain_layer. Either rename the function or change this to:

Suggested change
func tile_coordinate_to_global_position(coord: Vector2i) -> Vector2:
return constrain_layer.map_to_local(coord)
func tile_coordinate_to_global_position(coord: Vector2i) -> Vector2:
return constrain_layer.to_global(constrain_layer.map_to_local(coord))

Comment on lines +33 to +39
func get_closest_axis(vector: Vector2) -> Vector2i:
if abs(vector.x) > abs(vector.y):
# Closer to Horizontal (X-axis)
return Vector2i(sign(vector.x), 0)

# Closer to Vertical (Y-axis)
return Vector2i(0, sign(vector.y))
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I always feel like there must be a built-in way to do this but I can never find it.

Comment on lines +48 to +50
var data := constrain_layer.get_cell_tile_data(new_coord)

if not data:
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You could also spell this as:

	if constrain_layer.get_cell_source_id(new_coord) == -1:

This has a very slightly different meaning because in the case where TileMapLayer.tile_map_data specifies a tile for the cell, but the source ID doesn't exist in the TileSet, using get_cell_source_id will give you a non-negative source ID and so the code would allow the box to move there, while get_cell_tile_data will log a warning then return null and so the code would not allow the box to move there. Kind of moot.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This script is pleasingly small!


var tween: Tween

@onready var shaker: Shaker = $Shaker
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Now I understand!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Repel ability: Add gym scene

2 participants