@tool
class_name FlipRoll extends RefCounted
## Overgrowth-style flip/roll system.
## Roll = ground dodge roll. Flip = air double-jump flip.
##
## Based on FlipInfo from fliproll.as (lines 38-405).
## Both are procedural rotations applied to ALL bones around center of mass.
## Not pose-driven — the skeleton is physically rotated.
# ---- Config (Overgrowth defaults) ----
const ROLL_SPEED: float = 2.0 ## flip_progress increment per second (roll)
const FLIP_SPEED: float = 2.5 ## flip_progress increment per second (flip)
const ROLL_GROUND_SPEED: float = 12.0 ## Enforced ground speed during roll
const FLIP_ACCEL: float = 50.0 ## Flip angle acceleration
const FLIP_VEL_INERTIA: float = 0.89 ## Flip velocity damping per frame
const FLIP_TUCK_INERTIA: float = 0.7 ## Tuck amount inertia
const AXIS_INERTIA_ROLL: float = 0.8 ## Roll axis blend inertia
const AXIS_INERTIA_FLIP: float = 0.9 ## Flip axis blend inertia
const FULL_ROTATION: float = TAU ## 2π = full rotation
# ---- State ----
var flipping: bool = false
var rolling: bool = false ## true = ground roll, false = air flip
var flip_progress: float = 0.0
var flip_angle: float = 0.0 ## Normalized angle [0, 1] = one full rotation
var flip_vel: float = 0.0 ## Angular velocity
var flip_axis: Vector3 = Vector3.FORWARD ## Rotation axis (world space)
var target_flip_axis: Vector3 = Vector3.FORWARD
var roll_direction: Vector3 = Vector3.FORWARD ## Movement direction for roll
var flip_tuck: float = 0.0 ## Body tuck amount (0=extended, 1=tucked)
var flip_ik_fade: float = 0.0 ## IK disable fade (0=IK on, 1=IK off)
## Overgrowth's `flipped`: the flip's ROTATION is still finishing after `flipping`
## (the gameplay state) has ended. See update().
var settling: bool = false
## A HANDSPRING (Jump during a ground roll, user 2026-10-05): the roll's turn carried on
## through the top EXTENDED — flip mechanics (the spring to one full turn), no tuck,
## no forced crouch. See start_handspring().
var handspring: bool = false
## Roll progress from which Jump turns the roll into a handspring: the very start of a
## roll is still the crouch going down, and a jump there is a jump.
const HANDSPRING_MIN_PROGRESS := 0.15
## Start a ground roll in the given movement direction.
func start_roll(direction: Vector3) -> void:
if flipping:
return
# A ROLL ENDS UPRIGHT. An air flip that just landed is still `settling` its spin;
# left set, it played out AFTER this roll — a full turn while standing, the
# accidental handspring (user 2026-10-05). The roll owns the rotation now.
settling = false
handspring = false
flipping = true
rolling = true
flip_progress = 0.0
flip_vel = 0.0
flip_angle = 0.0
roll_direction = direction.normalized()
# Roll axis = perpendicular to movement direction (cross with up)
flip_axis = Vector3.UP.cross(roll_direction).normalized()
target_flip_axis = flip_axis
flip_tuck = 0.0
## Start an air flip (double jump).
func start_flip(facing_direction: Vector3) -> void:
if flipping:
return
flipping = true
rolling = false
settling = true
flip_progress = 0.0
# Overgrowth PrepareFlipAngle (fliproll.as:73-81): near 0 so it can target
# 1.0, and a flip started while the last one settles chains from where it is.
flip_angle = flip_angle - floorf(flip_angle)
if flip_angle > 0.5:
flip_angle -= 1.0
# Windup: negative velocity for anticipation pose
flip_vel = -2.0
# Flip axis = perpendicular to facing direction
target_flip_axis = Vector3.UP.cross(facing_direction).normalized()
if target_flip_axis.length_squared() < 0.001:
target_flip_axis = Vector3.RIGHT
flip_axis = target_flip_axis
flip_tuck = 0.0
## Can a Jump right now turn the roll into a handspring?
func can_handspring() -> bool:
return flipping and rolling and flip_progress >= HANDSPRING_MIN_PROGRESS
## Turn the ground roll into a handspring: the SAME turn continues (axis and angle
## kept), now driven by the air flip's spring to one full turn, starting at the roll's
## own rate so nothing stalls or snaps. The body opens out (no tuck, no crouch) — what
## makes it read as a handspring rather than a roll in the air.
func start_handspring() -> bool:
if not can_handspring():
return false
rolling = false
handspring = true
settling = true
flip_progress = 0.0
flip_vel = ROLL_SPEED
flip_tuck = 0.0
target_flip_axis = flip_axis
return true
## Update flip/roll state. Call every frame.
## Returns the rotation quaternion to apply to ALL bones around COM.
func update(delta: float) -> Quaternion:
if not flipping and not rolling and settling:
return _settle_flip(delta)
if not flipping:
# Fade IK back in
flip_ik_fade = maxf(0.0, flip_ik_fade - 3.0 * delta)
return Quaternion.IDENTITY
# Fade IK out during flip/roll
flip_ik_fade = minf(1.0, flip_ik_fade + 5.0 * delta)
var frames: float = delta * 60.0
var speed: float = FLIP_SPEED if not rolling else ROLL_SPEED
# Progress
flip_progress += speed * delta
if rolling:
# Roll: direct angle tracking with inertia
var target_angle: float = flip_progress
flip_angle = lerpf(flip_angle, target_angle, 1.0 - pow(AXIS_INERTIA_ROLL, frames))
else:
# Flip: Overgrowth UpdateFlipAngle (fliproll.as:218-222). The target is ONE
# FULL TURN from the start, not flip_progress: tracking progress left the
# angle lagging it, and at progress > 1 the old code reset the angle to 0 --
# a ~144 degree snap back to upright in one frame (gate_flip_roll).
flip_vel += (1.0 - flip_angle) * FLIP_ACCEL * delta
flip_angle += flip_vel * delta
flip_vel *= pow(FLIP_VEL_INERTIA, frames)
# Tuck (air flip only): sine wave peaks at midpoint. A handspring stays OPEN.
if not rolling and not handspring:
var target_tuck: float = maxf(sin(flip_progress * PI), 0.0)
flip_tuck = lerpf(flip_tuck, target_tuck, 1.0 - pow(FLIP_TUCK_INERTIA, frames))
else:
flip_tuck = 0.0
# Axis blend toward target
var axis_inertia: float = AXIS_INERTIA_FLIP if not rolling else AXIS_INERTIA_ROLL
flip_axis = flip_axis.lerp(target_flip_axis, 1.0 - pow(axis_inertia, frames)).normalized()
# Check completion. An air flip's GAMEPLAY ends here (control, tuck), but its
# rotation keeps springing to the full turn -- Overgrowth's `flipped` keeps
# UpdateFlip running after `flipping` is false (fliproll.as:224-226).
if flip_progress > 1.0 and not rolling:
flipping = false
flip_tuck = 0.0
return _flip_quat()
if flip_progress > 1.0:
flipping = false
rolling = false
flip_angle = 0.0
flip_vel = 0.0
flip_tuck = 0.0
return Quaternion.IDENTITY
return _flip_quat()
func _flip_quat() -> Quaternion:
if flip_axis.length_squared() < 0.001:
return Quaternion.IDENTITY
return Quaternion(flip_axis, flip_angle * FULL_ROTATION)
## After an air flip's gameplay ends: the same spring, until it rests at one full
## turn, which IS upright -- so there is nothing left to snap.
func _settle_flip(delta: float) -> Quaternion:
var frames: float = delta * 60.0
flip_ik_fade = maxf(0.0, flip_ik_fade - 3.0 * delta)
flip_vel += (1.0 - flip_angle) * FLIP_ACCEL * delta
flip_angle += flip_vel * delta
flip_vel *= pow(FLIP_VEL_INERTIA, frames)
if absf(1.0 - flip_angle) < 0.002 and absf(flip_vel) < 0.05:
settling = false
handspring = false
flip_angle = 0.0
flip_vel = 0.0
return Quaternion.IDENTITY
return _flip_quat()
## Whether the character has movement control during this action.
## Overgrowth: rolls give control back at 70% progress.
func has_control() -> bool:
if not flipping:
return true
if rolling and flip_progress < 0.7:
return true
return false
## Whether foot IK should be suppressed.
func should_disable_ik() -> bool:
return flip_ik_fade > 0.01
## Get duck amount override (rolls force full crouch).
func get_duck_amount() -> float:
if handspring:
return 0.0 # opened out
if rolling and flip_progress < 0.8:
return 1.0
return -1.0 # -1 = no override
## Get velocity override for ground rolls.
## Returns Vector3.ZERO if no override (air flips don't change velocity).
func get_roll_velocity() -> Vector3:
if not rolling or not flipping:
return Vector3.ZERO
return roll_direction * ROLL_GROUND_SPEED