1
\$\begingroup\$

I'm using this video as guide for my Godot 3.5 project, but for some reason the code does fan the card but also the cards keep moving and spreading forever.

The code:

extends Node2D


var card_count = 0
var cards_in_hand = 0
var spread_curve = preload("res://UI/resources/spread_curve.tres") as Curve
var height_curve = preload("res://UI/resources/height_curve.tres") as Curve

export var card_spread_x : float = 2.0 

 

func _process(_delta):
    fan_cards()

        
func fan_cards():
    cards_in_hand = get_children()
    
  
    card_count = cards_in_hand.size()
    var hand_ratio = 0.5
    if card_count > 1:
        for card in cards_in_hand:
            hand_ratio = float(card.get_index()) / float(card_count - 1)
            var position = card.global_transform
            position.origin.x += spread_curve.interpolate(hand_ratio) * card_spread_x
            position.origin += height_curve.interpolate(hand_ratio) * Vector2.UP
            card.global_transform = position

Cards keep spreading apart

I realize that the function _process will keep calling the fan_Cards function endlessly. The video doesn't say anything else about the code regarding the positioning of the cards.

How can I stop the cards from spreading indefinitely?

\$\endgroup\$

1 Answer 1

2
\$\begingroup\$

How do cards stop

You are using two curves to achieve that: spread_curve and height_curve.

Now, the interpolate method is evaluating the Curve given a value between the min_value and max_value of the Curve (which by default are 0.0 and 1.0).

That should be your target, not your step.

The code in the video goes like this (link):

for card in hand.get_children():
    var hand_ratio = # ...
    var destination := hand.global_transform
    destination.origin.x += spread_curve.interpolate(hand_ratio)

Notice it is building a destination variable using the global_transform of the hand.

The idea is that the process does not really stop. The code would still be running... Except that when the cards are already at the destination they don't move.


How do cards move

It isn't clear to me what approach is used to move the cards to that destination. At least one clue is that the video says that the cards handle their own rotation (link), which is in the _process of the card.

You don't seem to be placing logic in the cards, but we can start by the _process of the hand.

By the way, you are playing loose with the type of cards_in_hand, you initialize it as an ìnt (0) but then set it an Array (get_children()).

I also notice you made the case of a lone card an special case, which would be to avoid a division by zero. Here is the deal: you need to separate the cards by 1/n, but you need to offset that by half a separation. That way you don't need special cases.

I did something similar for How can I align my troops on a battle screen?.

The following is my take on the code:

extends Node2D


const spread_curve := preload("res://UI/resources/spread_curve.tres") as Curve
const height_curve := preload("res://UI/resources/height_curve.tres") as Curve


export var card_spread_x:float = 2.0
export var card_spread_y:float = 1.0
export var card_speed:float = 100.0


func _process(delta:float) -> void:
    var cards_in_hand := get_children()
    var card_count := cards_in_hand.size()
    var separation := 1.0/card_count
    var offset := separation * 0.5
    for index in card_count:
        var card := cards_in_hand[index] as Node2D
        var hand_ratio := index * separation + offset
        var destination := _compute_destination(hand_ratio)
        _move_card(card, destination, delta)


func _compute_destination(hand_ratio:float) -> Transform2D:
    var destination := global_transform
    destination.origin.x += spread_curve.interpolate(hand_ratio) * card_spread_x
    destination.origin.y += height_curve.interpolate(hand_ratio) * card_spread_y
    return destination


func _move_card(card:Node2D, destination:Transform2D, delta:float) -> void:
    var src_pos := card.global_transform.origin
    var dst_pos := destination.origin
    var max_displacement := card_speed * delta
    card.global_transform.origin = src_pos.move_toward(dst_pos, max_displacement)
  • I have added a card_spread_y because why not?
  • I have also added a card_speed because we need to define the card motion somehow.
  • I have separated _compute_destination and _move_card into their own methods to keep them simple, and also to make it easier if you want to move logic to the card nodes (they can have a destination property, and run the code we have in _move_card in their own _process instead).

How do card rotate

You are going to want rotation aren't you?

We can add one more Curve.

const angle_curve := preload("res://UI/resources/angle_curve.tres") as Curve

And why not another spread:

export var card_spread_angle:float = 1.0

And we got to use it to compute the destination. I'll take the opportunity to change the way we do this:

func _compute_destination(hand_ratio:float) -> Transform2D:
    # Compute displacement:
    var displacement := Vector2(
        spread_curve.interpolate(hand_ratio) * card_spread_x,
        height_curve.interpolate(hand_ratio) * card_spread_y
    )

    # Compute rotation
    var rotation := angle_curve.interpolate(hand_ratio) * card_spread_angle

    # Decompose the hand transform in position and rotation
    var pos := global_transform.origin
    var rot := global_transform.get_rotation()

    # Update position and rotation:
    pos += displacement
    rot += rotation

    # Compose the result
    return Transform2D(rot, pos)

And we have to move the card...

We could compute how much time does the card take to move and use that to figure out the angular speed.

In particular, if you recall that speed is distance divided by time, then we can solve for time. Then the angular speed is the angle difference divided by that time... And how much we rotate - at most - this frame is that angular speed multiplied by delta.

I say "at most" because you need to be careful to not overshoot.

Doing the shortest way is an extra wrinkle (we will use wrapf to get the shortest angle difference).

We would want to have the equivalent of move_toward for angles, but we don't have it (move_toward_angle has not been added to Godot 4 at the time of writing, much less Godot 3). So we will do it the messy way.

func _move_card(card:Node2D, destination:Transform2D, delta:float) -> void:
    # Decompose current transform into position and rotation
    var src_pos := card.global_transform.origin
    var src_rot := card.global_transform.get_rotation()

    # Decompose destination transform into position and rotation
    var dst_pos := destination.origin
    var dst_rot := destination.get_rotation()

    # Compute total time
    var dif_pos := dst_pos - src_pos
    var time := dif_pos / card_speed

    # Compute angular speed
    var dif_rot := wrapf(dst_rot - src_rot, -PI, PI)
    var angle_speed := abs(dif_rot) / time

    # Compute max displacement and rotation this frame:
    var delta_pos := card_speed * delta
    var delta_rot := angle_speed * delta

    # Compute new position and rotation
    var new_pos := src_pos.move_toward(dst_pos, delta_pos)
    var new_rot := src_rot + min(abs(dif_rot), delta_rot) * sign(dif_rot)

    # Compose the new transform
    card.global_transform = Transform2D(new_rot, new_pos)

Isn't there another way?

You need to know a bit of physics and a bit of math to come up with the above solution.

Plus the speed is constant, so it takes longer for cards that are away to reach the destination. We might want predictable time. Also, some more natural speed that decelerates when reaching the destination would be nice.

Plus this seems like a common enough case, and perhaps Godot has a better way to deal with it...


Using a Tween

And yes, Godot has an easier way, that would be Tweens. This would be the approach with the old API:

You create a Tween

var tween:Tween

func _ready():
    tween = Tween.new()
    add_child(tween)

You define the time:

var time_seconds = 2.0

And interpolate properties:

tween.interpolate_property(
    card,                    # On this object
    "global_transform",      # This property
    card.global_transform,   # From this value
    destination,             # To this value
    time_seconds,            # In this time
)

You can also specify easing, with the two extra optional parameters, for example:

tween.interpolate_property(
    card,
    "global_transform",
    card.global_transform,
    destination,
    time_seconds,
    Tween.TRANS_QUAD,
    Tween.EASE_OUT
)

See the cheatsheet by wandomPewlin for reference.

Don't forget to call start:

tween.start()

Most of the time a Tween is fine. However there is push to move to the new API (which would - in theory - make migration to Godot 4 easier):

var tween = get_tree().create_tween()

I'll still do my own way anyway

This is what I want:

  • Reach the target in known time.
  • Smoothly stop at the target.
  • Support moving targets.
  • In a frame rate independent way.
  • Without additional state or variables.

Given that it is a moving target and I want to reach it in known time, the speed is not constant. In fact, from reaching it in known time alone the speed is not constant (it will reach the target in the same time regardless of the starting position). This works well with the idea that I want to reach the target smoothly.

However, this is a contradiction: have no state, and smoothly reach a moving target in known time. If we don't have state we would have to resource to some formula that converges into the target... But convergence, mathematically, takes infinite time.

Ah, but this is running in a computer! At some point the distance to the target will not be representable as a floating point number. And that is not infinite time... We can compute when that is, and work out from there.

Thus, we are going to pick some concept of "distance" at which we consider that it is close enough to the target that we say it reached it (actually we will snap to it, but the gap should be too small to tell). And we are going to pick a formula that converges to the destination, and is within that "distance" of it at a known time.

Since we want frame rate independence, we want to make sure that:

lerp(lerp(a, b, f(t1)), b, f(t2))

Which is the same as:

lerp(a, b, f(t1 + t2))

The kind of functions that satisfy these criteria are exponential function.

After much experimentation, this the code I came up with:

func get_approach_factor(time:float, delta:float) -> float:
    const EPSILON := 0.00001
    const SCALE := 1000.0
    if is_nan(time) or time == INF:
        return 0.0

    if time < EPSILON / SCALE:
        return 1.0

    var base := pow(EPSILON, 1.0 / (time * SCALE))
    return 1.0 - pow(base, delta * SCALE)

You give it the total time, and the time elapsed this frame (delta), and it tells you a number from 0.0 to 1.0 that tell you how much you should approach the target (i.e. your interpolation factor).

By the way, the reason why EPSILON is not close to the smaller possible value, and the reason why there is a SCALE constant, is because we run into problems with floating point numbers (almost by definition of what we are doing). So I'm using a larger EPSILON and scaling down time by SCALE, under the idea that the behavior should be the same at different time scales. The values I picked work for times from a few milliseconds to a couple minutes, which I believe is fine for games.

Now you can do this (assuming time is a property, instead of card_speed):

func _move_card(card:Node2D, destination:Transform2D, delta:float) -> void:
    # Get the approach factor
    var factor := get_approach_factor(time, delta)

    # Interpolate the transforms
    card.global_transform = card.global_transform.interpolate_with(destination, factor)
\$\endgroup\$

You must log in to answer this question.

Not the answer you're looking for? Browse other questions tagged .