Files
GDevelop/Extensions/PlatformBehavior
2025-05-12 10:51:09 +02:00
..

Platformer Extension technical documentation

Floor following

When the character walks on a platform, he must follow its slope. The slopeMaxAngle property is used to calculate how much the character can move vertically to follow it. If the platform is too high, the platform is considered to be an obstacle and the character will stop before it.

When there is no obstacle detected by the horizontal search, the movement is done in 1 step and the vertical search is done at the new x position.

RequestedDeltaX

Otherwise, when there is a junction, 2 vertical searches are done:

  • one before a potential obstacle (in pink)
  • one at the end of the movement

RequestedDeltaX

This allows to calculate the right slope angle. Indeed, in one step, the angle could appear lower (the dotted line). Which means that the character could climb it during 1 frame and then stop.

RequestedDeltaX

For further details on the implementation, please take a look at the comments in:

  • the function gdjs.PlatformerObjectRuntimeBehavior._moveX
  • the function gdjs.PlatformerObjectRuntimeBehavior.OnFloor.beforeMovingY

The aim of the vertical search is to find the highest platform where the character can land. There are 2 constraints:

  • allowedMinDeltaY how much the character can go upward
  • allowedMaxDeltaY how much the character can go downward

During the search, these 2 constraints can tighten around the character. If they become incompatible, it means that the character can't go through the hole, it will go back to its original position and lose its speed.

There are also more obvious obstacles that cover the character in the middle and end the search directly.

RequestedDeltaX

Obstacles can eventually encompass the character. So, platforms edges don't have any collision with character. To detect such cases, 2 flags are used:

  • foundOverHead when an edge is over headMaxY
  • foundUnderHead when an edge is under floorMinY

RequestedDeltaX

For further details on the implementation, please take a look at the comments in:

  • the function gdjs.PlatformerObjectRuntimeBehavior._findHighestFloorAndMoveOnTop
  • the class gdjs.PlatformerObjectRuntimeBehavior.FollowConstraintContext

Jump trajectory

Sequential calculus of the engine

The engine uses the previous speeds and positions to evaluate the new ones (with Verlet integrations). This is fast for sequential access and it's a good fit for what the engine does.

The model doesn't follow the laws of physics and this is fine because more realism doesn't mean more fun. Internally, it uses 2 independent speeds:

  • the current jumping speed currentJumpSpeed
  • the current falling speed currentFallSpeed

Note that in the engine currentJumpSpeed is actually an absolute speed but it's easier to visualize as a negative value as it is subtracted to currentFallSpeed.

The jump speed stays at its initial value while the jump is sustained. Then, it decreases (in absolute) according to the gravity until it reaches 0 and stays constant until the end. The falling speed starts at 0 and increase according to the gravity, as soon as the jump start. Until, it reaches the maximum falling speed and stays constant until the end too.

Jump speed Falling speed

Requests solving and random access

Why a continuous model?

A function allows to get a position for any given time without calculating every previous positions.

Polynomial piece-wise function

The character trajectory during a jump looks like a quadratic function, but it's actually a piece-wise function.

On this first trajectory example, the curve almost follows the quadratic function in white, but it actually follows the grey ones at the start and the end of the jump. There is also an affine part at the end of the jump. It's not shown on this plot because it happens later on.

Trajectory with default settings

This example of trajectory shows the 4 functions. It has an affine part in the middle instead of a quadratic one like in the previous example. In practice, this jump trajectory will seem off, but a tiny affine part can happen in some configurations.

Trajectory with an affine part

The piece-wise function has 3 borders:

  • at the end of the jump sustaining (jumpSustainTime)
  • when the maximum falling speed is reached (maxFallingTime)
  • when the jump speed reach 0 (jumpEndTime)

Depending on the when the maximum falling speed is reached, there can be 3 piece-wise functions:

  • after the jump end
    • sustain case
    • common case
    • free fall case
    • gliding case
  • before the jump end and after the sustaining
    • sustain case
    • common case
    • max falling case
    • gliding case
  • during the sustaining
    • sustain case
    • affine case
    • max falling case
    • gliding case

Jump cases

SageMath model

The following model is written for SageMath, an open-source mathematics software system.

# First define the jump and falling functions #

currentFallingSpeed(t, gravity) = gravity * t
fallingX(t, gravity) = integral(currentFallingSpeed(t, gravity), t)
maxFallingTime(gravity, maxFallingSpeed) = solve([currentFallingSpeed(t, gravity)==maxFallingSpeed],t)[0].rhs()
fallEndedX(t, gravity) = fallingX(maxFallingTime(gravity, maxFallingSpeed), gravity) + maxFallingSpeed * (t - maxFallingTime(gravity, maxFallingSpeed))

currentJumpSpeed(t, gravity, jumpSpeed, jumpSustainTime) = -jumpSpeed + gravity * (t - jumpSustainTime)
sustainedX(t, jumpSpeed) = -jumpSpeed * t
jumpingX(t, gravity, jumpSpeed, jumpSustainTime) = sustainedX(jumpSustainTime, jumpSpeed) + integral(currentJumpSpeed(t, gravity, jumpSpeed, jumpSustainTime), t, jumpSustainTime, t)
jumpEndTime(gravity, jumpSpeed, jumpSustainTime) = jumpSustainTime + jumpSpeed / gravity
jumpEndedX(t, gravity, jumpSpeed, jumpSustainTime) = jumpingX(jumpEndTime(gravity, jumpSpeed, jumpSustainTime), gravity, jumpSpeed, jumpSustainTime)


# t < jumpSustainTime && t < maxFallingTime #

sustainCaseSpeed(t, gravity, jumpSpeed) = -jumpSpeed + currentFallingSpeed(t, gravity)
sustainCaseHeight(t, gravity, jumpSpeed) = sustainedX(t, jumpSpeed) + fallingX(t, gravity)
sustainCasePeakTime(gravity, jumpSpeed, jumpSustainTime) = solve([sustainCaseSpeed(t, gravity, jumpSpeed)==0], t)[0].rhs()
sustainCasePeakHeight(gravity, jumpSpeed, jumpSustainTime) = sustainCaseHeight(sustainCasePeakTime(gravity, jumpSpeed, jumpSustainTime), gravity, jumpSpeed, jumpSustainTime)


# jumpSustainTime < t && t < maxFallingTime && t < jumpEndTime #

commonCaseSpeed(t, gravity, jumpSpeed, jumpSustainTime) = currentJumpSpeed(t, gravity, jumpSpeed, jumpSustainTime) + currentFallingSpeed(t, gravity)
commonCaseHeight(t, gravity, jumpSpeed, jumpSustainTime) = jumpingX(t, gravity, jumpSpeed, jumpSustainTime) + fallingX(t, gravity)
commonCasePeakTime(gravity, jumpSpeed, jumpSustainTime) = solve([commonCaseSpeed(t, gravity, jumpSpeed, jumpSustainTime)==0], t)[0].rhs()
commonCasePeakHeight(gravity, jumpSpeed, jumpSustainTime) = commonCaseHeight(commonCasePeakTime(gravity, jumpSpeed, jumpSustainTime), gravity, jumpSpeed, jumpSustainTime)


# jumpSustainTime < t && maxFallingTime < t && t < jumpEndTime #

maxFallingCaseSpeed(t, gravity, jumpSpeed, jumpSustainTime, maxFallingSpeed) = currentJumpSpeed(t, gravity, jumpSpeed, jumpSustainTime) + maxFallingSpeed
maxFallingCaseHeight(t, gravity, jumpSpeed, jumpSustainTime, maxFallingSpeed) = jumpingX(t, gravity, jumpSpeed, jumpSustainTime) + fallEndedX(t, gravity)
maxFallingCasePeakTime(gravity, jumpSpeed, jumpSustainTime, maxFallingSpeed) = solve([maxFallingCaseSpeed(t, gravity, jumpSpeed, jumpSustainTime, maxFallingSpeed)==0], t)[0].rhs()
maxFallingCasePeakHeight(gravity, jumpSpeed, jumpSustainTime, maxFallingSpeed) = maxFallingCaseHeight(maxFallingCasePeakTime(gravity, jumpSpeed, jumpSustainTime, maxFallingSpeed), gravity, jumpSpeed, jumpSustainTime, maxFallingSpeed)

# maxFallingTime < t && t < jumpSustainTime #
# in this case the speed is constant (-jumpSpeed + maxFallingSpeed) there is no strict maximum. #

affineCaseSpeed(t, jumpSpeed, maxFallingSpeed) = -jumpSpeed + maxFallingSpeed
affineCaseHeight(t, gravity, jumpSpeed, maxFallingSpeed) = sustainedX(t, jumpSpeed) + fallEndedX(t, gravity)


# jumpEndTime < t && maxFallingTime < t #
# This is a strictly descending phase, the peak can't happen here #

glidingCaseSpeed(t, maxFallingSpeed) = maxFallingSpeed
glidingCaseHeight(t, gravity, jumpSpeed, jumpSustainTime, maxFallingSpeed) = jumpEndedX(t, gravity, jumpSpeed, jumpSustainTime) + fallEndedX(t, gravity)


# jumpEndTime < t && t < maxFallingTime #
# This is a strictly descending phase, the peak can't happen here #

freeFallCaseSpeed(t, gravity) = currentFallingSpeed(t, gravity)
freeFallCaseHeight(t, gravity, jumpSpeed, jumpSustainTime, maxFallingSpeed) = jumpEndedX(t, gravity, jumpSpeed, jumpSustainTime) + fallingX(t, gravity)

Find the initial jump speed for a given jump height

The great thing about quadratic functions is that it's easy to find solutions to a constraint. For instance, to find which initial jump speed to choose to jump a given height, the formula can be extracted like this:

var('givenHeight')
solve([sustainCasePeakHeight(gravity, jumpSpeed, jumpSustainTime)==givenHeight], jumpSpeed)[1].rhs()
solve([commonCasePeakHeight(gravity, jumpSpeed, jumpSustainTime)==givenHeight], jumpSpeed)[1].rhs()
solve([maxFallingCasePeakHeight(gravity, jumpSpeed, jumpSustainTime)==givenHeight], jumpSpeed)[1].rhs()

The program will have to determine which formula to use. As the initial jump speed is unknown, it's not possible to know where the peak will happen, but jump speed solutions of each formula can be used to check if the peak actually happens during the right interval of time.

if (maxFallingSpeedReachedTime > jumpSustainTime) {
    // common case
    jumpSpeed = commonCaseJumpSpeed(jumpHeight);
    peakTime = commonCasePeakTime(jumpSpeed);

    if (peakTime < jumpSustainTime) {
        // sustain case
        jumpSpeed = sustainCaseJumpSpeed(jumpHeight);
    }
    else if (peakTime > maxFallingSpeedReachedTime) {
        // max falling case
        jumpSpeed = maxFallingCaseJumpSpeed(jumpHeight);
    }
}
else {
    // affine case can't have a maximum

    // sustain case
    jumpSpeed = sustainCaseJumpSpeed(jumpHeight);
    peakTime = jumpSpeed / gravity;

    if (peakTime > maxFallingSpeedReachedTime) {
        // max falling case
        jumpSpeed = maxFallingCaseJumpSpeed(jumpHeight);
    }
}

Find the time for a given Y displacement

The same kind of logic from the previous section can be used to evaluate at which time a character will be at a given ordinate when going up or going down.

sustainCaseUpTime(y, gravity, jumpSpeed) = solve([sustainCaseHeight(t, gravity, jumpSpeed)==y], t)[0].rhs()
commonCaseUpTime(y, gravity, jumpSpeed, jumpSustainTime) = solve([height(t, gravity, jumpSpeed, jumpSustainTime)==y], t)[0].rhs()
maxFallingCaseCaseUpTime(y, gravity, jumpSpeed, jumpSustainTime, maxFallingSpeed) = solve([maxFallingCaseHeight(t, gravity, jumpSpeed, jumpSustainTime, maxFallingSpeed)==y], t)[0].rhs()

sustainCaseDownTime(y, gravity, jumpSpeed) = solve([sustainCaseHeight(t, gravity, jumpSpeed)==y], t)[1].rhs()
commonCaseDownTime(y, gravity, jumpSpeed, jumpSustainTime) = solve([height(t, gravity, jumpSpeed, jumpSustainTime)==y], t)[1].rhs()
maxFallingCaseCaseDownTime(y, gravity, jumpSpeed, jumpSustainTime, maxFallingSpeed) = solve([maxFallingCaseHeight(t, gravity, jumpSpeed, jumpSustainTime, maxFallingSpeed)==y], t)[1].rhs()
glidingCaseDownTime(t, gravity, jumpSpeed, jumpSustainTime, maxFallingSpeed) = solve([glidingCaseHeight(t, gravity, jumpSpeed, jumpSustainTime, maxFallingSpeed)==y], t)[0].rhs() # only one solution (it's affine) #
freeFallCaseDownTime(t, gravity, jumpSpeed, jumpSustainTime, maxFallingSpeed) = solve([freeFallCaseHeight(t, gravity, jumpSpeed, jumpSustainTime, maxFallingSpeed)==y], t)[1].rhs()

affineCaseTime(y, jumpSpeed, maxFallingSpeed) = solve([affineCaseHeight(t, jumpSpeed, maxFallingSpeed)==y], t)[0].rhs()

The root conditions are the 3 piece-wise functions described at the end of the Polynomial piece-wise function section. Note that quadratic functions have a maximum or a minimum value which means there won't always be a solution. That's why there are "not a number" checks.

if (maxFallingSpeedReachedTime > jumpEndTime) {
    time = sustainCase(y);
    if (time > jumpSustainTime || Number.isNaN(time)) {
        time = commonCase(y);
        if (time > jumpEndTime || Number.isNaN(time)) {
            time = freeFallCase(y);
            if (time > maxFallingSpeedReachedTime || Number.isNaN(time)) {
                time = glidingCase(y);
            }
        }
    }
}
else if (maxFallingSpeedReachedTime > jumpSustainTime) {
    time = sustainCase(y);
    if (time > jumpSustainTime || Number.isNaN(time)) {
        time = commonCase(y);
        if (time > maxFallingSpeedReachedTime || Number.isNaN(time)) {
            time = maxFallingCase(y);
            if (time > jumpEndTime || Number.isNaN(time)) {
                time = glidingCase(y);
            }
        }
    }
}
else {
    time = sustainCase(y);
    if (time > maxFallingSpeedReachedTime || Number.isNaN(time)) {
        time = maxFallingSpeed >= jumpSpeed ? affineCase(y) : time = Number.MAX_VALUE;
        if (time > jumpSustainTime || Number.isNaN(time)) {
            time = maxFallingCase(y);
            if (time > jumpEndTime || Number.isNaN(time)) {
                time = glidingCase(y);
            }
        }
    }
}

Plotting trajectories

This is the code used to plot the first graphic of the Polynomial piece-wise function section.

# The trajectory of the default jump settings
#
# gravity: 1000
# maxFallingSpeed: 700
# jumpSustainTime: 0.2
# jumpSpeed: 600
#
# n(maxFallingTime(1000, 700))
# 0.7

(
  plot(-sustainCaseHeight(t, 1000, 600), 0, 0.8, ymin=0, color='grey')
+ plot(-commonCaseHeight(t, 1000, 600, 0.2), 0, 0.8, ymin=0, color='black')
+ plot(-maxFallingCaseHeight(t, 1000, 600, 0.2, 700), 0, 0.8, ymin=0, color='grey')
+ plot(-piecewise([
    ((0, 0.2), sustainCaseHeight(t, 1000, 600)),
    ((0.2, maxFallingTime(1000, 700)), commonCaseHeight(t, 1000, 600, 0.2)),
    ((maxFallingTime(1000, 700), 2), maxFallingCaseHeight(t, 1000, 600, 0.2, 700))
  ]), 0, 0.8, ymin=0, color='cyan')
)