extends Node3D
## Asura ride body motion (asura_ride.dress() adds it under the mount view). The Tripo quadruped rigs only have one
## walk clip, so the life comes from here, on top of mount_view / DragonMotion:
##  - locomotion: the walk clip follows the ground speed (data "stride"), gallop bob + chest pitch above a trot,
##    banking into turns, leaning on acceleration, breathing when standing, footfall dust / wind streaks;
##  - attacks (motion pass 2, Boss 2026-10-02 "stiff, strange poses"): every move is keyed as anticipation -> a fast
##    strike -> hold -> recover (+ squash / stretch), read through a Catmull-Rom curve and an underdamped spring per
##    channel (overshoot + follow-through, and a new move or the end blends from wherever the body is - no pops);
##    the keys are time-warped so the strike lands on the server's hit frame; the body pitches about its hind feet
##    when it rears and about its front feet when it dives / bites (feet stay planted); part of the pitch / yaw
##    bends the spine and neck (asura_rig.gd, which also swings tails / wings / manes / ears); displacements fly an
##    arc; a short hit pause holds the contact pose;
##  - the rider sits on a spring: leans with the beast, rocks back on a lunge and settles.
## Everything is a view offset on the mount view's model wrapper (`mv.model`), reset when the ride ends.

const Fx = preload("res://scripts/fx.gd")
const AsuraRig = preload("res://scripts/asura_rig.gd")

var mv: Node3D = null
var model: Node3D = null
var flyer := false
var stride := 3.2
var col := Color(1, 1, 1)
var height := 3.0
var _rest_pos := Vector3.ZERO
var _rest_rot := Vector3.ZERO
var _rest_scale := Vector3.ONE
var _last := Vector3.INF
var _last_yaw := 0.0
var _speed := 0.0
var _accel := 0.0
var _bank := 0.0
var _pitch := 0.0
var _phase := 0.0
var _t := 0.0
var _dust_phase := 0.0
var _base_rate := {}
var body := "dragon"
## Body half length along +Z (wrapper space, model units): the pitch pivots sit at the feet ends.
var half_len := 1.0
## Bone layer (spine bend + secondary motion), null when the rig could not be read.
var rig: Node = null
## Attack moves per body (fallback when an event has no key), the ride-start move per body.
const MOVES := {"dragon": ["bite", "claw", "tail"], "feline": ["claw", "bite", "claw2"], "fox": ["tail", "bite", "claw"],
	"deer": ["horn", "kick", "horn"]}
const OPENER := {"dragon": "dive", "feline": "pounce", "fox": "spin", "deer": "rear"}
## move -> [length s, strike key index, keys [u, lunge (x height, + forward), lift (x height), pitch (rad, + nose
## down), roll (rad), yaw (turns, + left), squash (+ squash / - stretch)]]. Shape of every move: rest -> anticipation
## (wind up the other way) -> strike (short segment) -> hold / follow-through -> recover; the spring adds overshoot.
const KEYS := {
	"bite": [0.42, 2, [[0, 0, 0, 0, 0, 0, 0], [0.34, -0.05, 0.01, -0.24, 0, 0, 0.06], [0.5, 0.11, 0, 0.36, 0, 0, -0.06],
		[0.64, 0.09, 0, 0.28, 0, 0, 0.02], [1, 0, 0, 0, 0, 0, 0]]],
	"claw": [0.4, 2, [[0, 0, 0, 0, 0, 0, 0], [0.3, -0.03, 0.05, -0.26, 0.2, 0.02, 0.04], [0.48, 0.07, 0, 0.22, -0.24, -0.03, -0.05],
		[0.64, 0.05, 0, 0.12, -0.12, -0.01, 0.02], [1, 0, 0, 0, 0, 0, 0]]],
	"claw2": [0.4, 2, [[0, 0, 0, 0, 0, 0, 0], [0.3, -0.03, 0.05, -0.26, -0.2, -0.02, 0.04], [0.48, 0.07, 0, 0.22, 0.24, 0.03, -0.05],
		[0.64, 0.05, 0, 0.12, 0.12, 0.01, 0.02], [1, 0, 0, 0, 0, 0, 0]]],
	# tail sweep: wind the other way, whip through (the tail chain lags and snaps), the rider keeps facing forward
	"tail": [0.55, 2, [[0, 0, 0, 0, 0, 0, 0], [0.26, 0, 0.02, 0.04, 0.06, -0.06, 0.03], [0.52, 0, 0.03, 0, -0.08, 0.13, -0.03],
		[0.7, 0, 0.01, 0, -0.03, 0.09, 0.01], [1, 0, 0, 0, 0, 0, 0]]],
	"horn": [0.5, 2, [[0, 0, 0, 0, 0, 0, 0], [0.32, -0.07, 0, -0.3, 0, 0, 0.06], [0.48, 0.17, 0, 0.34, 0, 0, -0.06],
		[0.62, 0.13, 0, 0.24, 0, 0, 0.02], [1, 0, 0, 0, 0, 0, 0]]],
	"kick": [0.45, 2, [[0, 0, 0, 0, 0, 0, 0], [0.3, 0, 0.03, -0.12, 0, 0, 0.05], [0.5, -0.04, 0.05, 0.4, 0, 0, -0.04],
		[0.68, 0, 0.02, 0.22, 0, 0, 0.01], [1, 0, 0, 0, 0, 0, 0]]],
	# ride opener of the dragons: wind up, rise, hang, dive, slam (squash), settle
	"dive": [0.95, 4, [[0, 0, 0, 0, 0, 0, 0], [0.24, -0.04, -0.03, -0.18, 0, 0, 0.08], [0.48, -0.03, 0.5, -0.32, 0, 0, -0.06],
		[0.66, 0.1, 0.38, 0.34, 0, 0, -0.05], [0.8, 0.2, 0, 0.3, 0, 0, 0.13], [0.9, 0.16, 0, 0.12, 0, 0, 0.03], [1, 0, 0, 0, 0, 0, 0]]],
	# feline pounce: crouch, launch (stretch), fly, land on the prey (squash), settle
	"pounce": [0.85, 4, [[0, 0, 0, 0, 0, 0, 0], [0.28, -0.08, -0.05, 0.14, 0, 0, 0.12], [0.42, -0.02, 0.14, -0.4, 0, 0, -0.08],
		[0.62, 0.22, 0.34, -0.04, 0, 0, -0.04], [0.78, 0.3, 0, 0.3, 0, 0, 0.14], [0.9, 0.24, 0, 0.1, 0, 0, 0.03], [1, 0, 0, 0, 0, 0, 0]]],
	"spin": [0.8, 2, [[0, 0, 0, 0, 0, 0, 0], [0.22, 0, 0.05, 0, 0.05, -0.05, 0.05], [0.46, 0, 0.16, 0, -0.08, 0.12, -0.05],
		[0.64, 0, 0.06, 0, 0.06, -0.08, 0.04], [0.8, 0, 0, 0, 0, 0.02, 0], [1, 0, 0, 0, 0, 0, 0]]],
	# ride-combat moves (data/asura_moves.json "key")
	"hop_back": [0.6, 2, [[0, 0, 0, 0, 0, 0, 0], [0.24, 0, -0.02, 0.1, 0, 0, 0.08], [0.46, -0.1, 0.16, -0.2, 0, 0, -0.06],
		[0.68, -0.1, 0, 0.06, 0, 0, 0.09], [0.84, -0.04, 0, 0.02, 0, 0, 0.02], [1, 0, 0, 0, 0, 0, 0]]],
	"rise": [1.2, 2, [[0, 0, 0, 0, 0, 0, 0], [0.22, 0, -0.02, 0.08, 0, 0, 0.07], [0.42, 0, 0.42, -0.3, 0, 0, -0.06],
		[0.75, 0.02, 0.44, 0.14, 0, 0, 0], [0.9, 0, 0.08, 0.04, 0, 0, 0.05], [1, 0, 0, 0, 0, 0, 0]]],
	"recoil": [0.8, 2, [[0, 0, 0, 0, 0, 0, 0], [0.4, -0.03, 0.02, -0.3, 0, 0, 0.05], [0.55, -0.05, 0.02, -0.32, 0, 0, 0.06],
		[0.68, -0.13, 0, 0.14, 0, 0, -0.05], [0.85, -0.05, 0, 0.04, 0, 0, 0.01], [1, 0, 0, 0, 0, 0, 0]]],
	"crouch_leap": [0.85, 4, [[0, 0, 0, 0, 0, 0, 0], [0.22, 0, -0.06, 0.15, 0, 0, 0.14], [0.42, 0.06, 0.28, -0.24, 0, 0, -0.1],
		[0.62, 0.12, 0.4, 0.04, 0, 0, -0.04], [0.78, 0.15, 0, 0.3, 0, 0, 0.14], [0.9, 0.1, 0, 0.08, 0, 0, 0.03], [1, 0, 0, 0, 0, 0, 0]]],
	"side_dash": [0.6, 1, [[0, 0, 0, 0, 0, 0, 0], [0.2, 0, 0.08, 0, 0.24, 0.04, -0.05], [0.5, 0, 0.02, 0.1, -0.12, -0.02, 0.06],
		[0.72, 0, 0, 0.04, -0.04, 0, 0.01], [1, 0, 0, 0, 0, 0, 0]]],
	"slam": [0.8, 2, [[0, 0, 0, 0, 0, 0, 0], [0.34, -0.05, 0.14, -0.38, 0, 0, -0.06], [0.52, 0.06, 0, 0.34, 0, 0, 0.16],
		[0.7, 0.03, 0, 0.12, 0, 0, 0.04], [1, 0, 0, 0, 0, 0, 0]]],
	"twist": [0.7, 2, [[0, 0, 0, 0, 0, 0, 0], [0.2, 0, 0.08, 0, 0.05, -0.06, 0.04], [0.48, 0, 0.1, 0, -0.06, 0.12, -0.04],
		[0.72, 0, 0.03, 0, 0.02, -0.04, 0.02], [1, 0, 0, 0, 0, 0, 0]]],
	# rear up on the hind legs (pivot at the hind feet), hang, stomp down (squash)
	"rear": [0.85, 3, [[0, 0, 0, 0, 0, 0, 0], [0.24, 0, -0.02, 0.08, 0, 0, 0.08], [0.46, -0.04, 0.06, -0.58, 0, 0, -0.06],
		[0.68, 0.08, 0, 0.26, 0, 0, 0.13], [0.84, 0.04, 0, 0.06, 0, 0, 0.02], [1, 0, 0, 0, 0, 0, 0]]],
}
## Spring per channel: natural frequency (rad/s) and damping ratio (< 1 = overshoot / follow-through).
const OMEGA := [24.0, 22.0, 26.0, 20.0, 18.0, 30.0]
const ZETA := [0.55, 0.6, 0.5, 0.5, 0.55, 0.42]
## Share of the move pitch / yaw the spine + neck take (the rest turns the whole body).
const SPINE_PITCH := 0.55
const SPINE_YAW := 0.5
var _move := ""
var _move_t := 0.0
var _len := 0.5
var _hit_u := -1.0
var _strike_u := 0.5
var _arcs: Array = []
var _pause := 0.0
var _x := [0.0, 0.0, 0.0, 0.0, 0.0, 0.0]
var _xv := [0.0, 0.0, 0.0, 0.0, 0.0, 0.0]
var _rider_x := Vector3.ZERO
var _rider_v := Vector3.ZERO
var _rider_z := 0.0
var _rider_zv := 0.0
var _lunge_v := 0.0
## Moves played / hit pauses (tests).
var played := 0
var pauses := 0


func setup(view: Node3D, d: Dictionary) -> void:
	mv = view
	model = mv.get("model") as Node3D
	flyer = bool(d.get("flyer", false))
	stride = float(d.get("stride", 3.6 if flyer else 3.0))
	body = str(d.get("body", "dragon"))
	col = Color(str(d.get("look", {}).get("color", "#ffffff")))
	height = float(d.get("height", 3.0))
	if model:
		_rest_pos = model.position
		_rest_rot = model.rotation
		_rest_scale = model.scale
		half_len = _measure_half_len()
		var sks := model.find_children("*", "Skeleton3D", true, false)
		if not sks.is_empty() and not ("--noasurarig" in OS.get_cmdline_user_args()):
			var r = AsuraRig.new()
			r.name = "AsuraRig"
			if r.setup(sks[0], model):
				(sks[0] as Skeleton3D).add_child(r)
				rig = r
			else:
				r.free()
	# the mount view's gait rates live in its cfg: give this ride its own copy so the walk rate can follow the speed
	var cfg = mv.get("cfg")
	if cfg is Dictionary:
		var own: Dictionary = (cfg as Dictionary).duplicate(true)
		if not (own.get("rate") is Dictionary):
			own["rate"] = {}
		_base_rate = (own["rate"] as Dictionary).duplicate()
		mv.set("cfg", own)


## Half the body length along the wrapper's +Z (model units of the wrapper's parent).
func _measure_half_len() -> float:
	var lo := INF
	var hi := -INF
	var inv := model.global_transform.affine_inverse()
	for m in model.find_children("*", "MeshInstance3D", true, false):
		var mi := m as MeshInstance3D
		if mi.mesh == null:
			continue
		var ab: AABB = (inv * mi.global_transform) * mi.get_aabb()
		lo = minf(lo, ab.position.z)
		hi = maxf(hi, ab.end.z)
	if lo == INF:
		return 1.0
	return maxf(0.2, (hi - lo) * 0.5) * model.scale.z


func _process(delta: float) -> void:
	if mv == null or not is_instance_valid(mv) or model == null or not is_instance_valid(model) or delta <= 0.0:
		return
	_t += delta
	var rider = mv.get_parent() as Node3D
	var p: Vector3 = rider.global_position if rider else mv.global_position
	if _last == Vector3.INF:
		_last = p
		_last_yaw = mv.rotation.y
	var v := Vector2(p.x - _last.x, p.z - _last.z).length() / delta
	_last = p
	# smoothed speed / acceleration (m/s, m/s2)
	var sp0 := _speed
	_speed = lerpf(_speed, minf(v, 20.0), 1.0 - exp(-8.0 * delta))
	_accel = lerpf(_accel, (_speed - sp0) / delta, 1.0 - exp(-6.0 * delta))
	var yaw := mv.rotation.y
	var dyaw := wrapf(yaw - _last_yaw, -PI, PI) / delta
	_last_yaw = yaw
	# the walk clip at the ground speed (stride metres per loop at rate 1)
	var cfg = mv.get("cfg")
	if cfg is Dictionary and (cfg as Dictionary).get("rate") is Dictionary:
		var r := clampf(_speed / maxf(0.5, stride) * 1.2, 0.6, 2.6)
		for k in ["walk", "run", "idle"]:
			(cfg["rate"] as Dictionary)[k] = r * float(_base_rate.get(k, 1.0))
	# gait phase: one bob per stride
	_phase += _speed / maxf(0.5, stride) * TAU * delta
	var gallop := clampf((_speed - 4.0) / 4.0, 0.0, 1.0)
	var moving := _speed > 0.4
	# bank into turns, lean on acceleration / squat on braking
	_bank = lerpf(_bank, clampf(-dyaw * 0.09 * clampf(_speed / 4.0, 0.0, 1.0), -0.28, 0.28) * (1.6 if flyer else 1.0), 1.0 - exp(-5.0 * delta))
	_pitch = lerpf(_pitch, clampf(_accel * 0.025, -0.12, 0.12), 1.0 - exp(-5.0 * delta))
	var bob := 0.0
	var chest := 0.0
	var breathe := 0.0
	if flyer:
		bob = sin(_t * 2.1) * 0.05 * height * (0.6 if moving else 1.0) + sin(_phase) * 0.025 * height * gallop
		chest = sin(_t * 2.1 + 0.6) * 0.03
	else:
		bob = absf(sin(_phase)) * 0.045 * height * gallop
		chest = sin(_phase * 2.0) * 0.06 * gallop
	if not moving:
		breathe = sin(_t * 1.7) * 0.012
	# attack move: target from the keys, a spring follows it (a hit pause nearly stops both)
	var mdt := delta
	if _pause > 0.0:
		_pause -= delta
		mdt = delta * 0.06
	var tgt := [0.0, 0.0, 0.0, 0.0, 0.0, 0.0]
	if _move != "":
		_move_t += mdt
		var u := _move_t / _len
		if u >= 1.0:
			_move = ""
		else:
			tgt = _target(_warp(u))
	var lunge0: float = _x[0]
	_spring(tgt, mdt)
	_lunge_v = (float(_x[0]) - lunge0) * height / maxf(delta, 0.0001)
	# displacement arcs (metres)
	var arc := 0.0
	for a in _arcs:
		var s := (_move_t - float(a[0])) / maxf(0.05, float(a[1]))
		if s > 0.0 and s < 1.0:
			arc += sin(PI * s) * float(a[2])
	# body vs spine shares
	var mp: float = _x[2]
	var my: float = _x[4]
	var body_pitch := mp * (1.0 - SPINE_PITCH) if rig else mp
	var body_yaw := my * (1.0 - SPINE_YAW) if rig else my
	if rig:
		rig.pitch = mp * SPINE_PITCH * 1.6
		rig.yaw = my * TAU * SPINE_YAW
	var k := 1.0 / maxf(0.01, mv.scale.y)
	var rx := clampf(-_pitch + chest + body_pitch, -0.6, 0.5)
	var rz := clampf(_bank + float(_x[3]), -0.45, 0.45)
	var euler := Vector3(rx, body_yaw * TAU, rz)
	# pitch pivot: the hind feet when rearing (nose up), the front feet when diving / biting (nose down)
	var pz := clampf(rx / 0.18, -1.0, 1.0) * half_len * 0.85
	var pivot := Vector3(0, 0, pz)
	var rb := Basis.from_euler(euler)
	var piv_off := pivot - rb * pivot
	model.rotation = _rest_rot + euler
	model.position = _rest_pos + piv_off + Vector3(0, (bob + float(_x[1]) * height + arc) * k, float(_x[0]) * height * k)
	var sq := clampf(float(_x[5]), -0.25, 0.25)
	model.scale = _rest_scale * Vector3(1.0 + breathe * 0.5 + sq * 0.45, 1.0 + breathe - sq, 1.0 + breathe * 0.5 + sq * 0.45)
	# the rider sits where the saddle is NOW (player.gd seats it before this node moved the beast this frame)
	if rider and rider.has_method("_seat_rider"):
		rider._seat_rider()
	# the rider: a spring toward leaning with the beast, rocking back on a lunge
	var pm = rider.get("model") if rider else null
	if pm is Node3D:
		var want := Vector3(-_pitch * 0.5 + chest * 0.4 + body_pitch * 0.35 - clampf(_lunge_v * 0.03, -0.3, 0.3), 0.0, rz * 0.6)
		var a2 := (want - _rider_x) * 140.0 - _rider_v * 2.0 * 0.45 * sqrt(140.0)
		_rider_v += a2 * delta
		_rider_x += _rider_v * delta
		(pm as Node3D).rotation.x = clampf(_rider_x.x, -0.5, 0.5)
		(pm as Node3D).rotation.z = clampf(_rider_x.z, -0.4, 0.4)
		# inertia: the hips slide back a little on a forward lunge and settle (the saddle seat already follows)
		var zw := clampf(-_lunge_v * 0.012, -0.12, 0.12)
		var a3 := (zw - _rider_z) * 160.0 - _rider_zv * 2.0 * 0.4 * sqrt(160.0)
		_rider_zv += a3 * delta
		_rider_z += _rider_zv * delta
		var fz: Vector3 = mv.transform.basis.z
		fz.y = 0.0
		if fz.length() > 0.001:
			(pm as Node3D).position += fz.normalized() * _rider_z
	# footfall dust (walkers) / wind streaks (flyers) at each stride while moving fast enough
	if moving and _speed > 2.5:
		var step := floorf(_phase / PI)
		if step != _dust_phase:
			_dust_phase = step
			var w = rider.get("world") if rider else null
			if w is Node:
				if flyer:
					Fx.particles(w, p + Vector3(0, height * 0.5, 0), {"amount": 6, "life": 0.5, "color": Color(col.r, col.g, col.b, 0.6),
						"size": 0.18, "radius": height * 0.3, "speed": [0.5, 1.5], "gravity": -0.5, "soft": true})
				else:
					Fx.particles(w, p + Vector3(0, 0.1, 0), {"amount": 8, "life": 0.6, "color": Color(0.9, 0.84, 0.72, 0.7),
						"size": 0.3, "radius": height * 0.18, "speed": [0.8, 2.2], "gravity": 1.2, "soft": true, "flat": true})


## Semi-implicit spring step of every channel toward `tgt` (two sub-steps above 30 fps frames).
func _spring(tgt: Array, dt: float) -> void:
	var n := 2 if dt > 1.0 / 45.0 else 1
	var h := dt / float(n)
	for s in n:
		for c in 6:
			var w: float = OMEGA[c]
			var a: float = w * w * (float(tgt[c]) - float(_x[c])) - 2.0 * float(ZETA[c]) * w * float(_xv[c])
			_xv[c] = float(_xv[c]) + a * h
			_x[c] = float(_x[c]) + float(_xv[c]) * h


## Event time u (0..1) -> key time: the strike key lands on the event's first hit.
func _warp(u: float) -> float:
	if _hit_u <= 0.0:
		return u
	if u <= _hit_u:
		return u / _hit_u * _strike_u
	return _strike_u + (u - _hit_u) / maxf(0.001, 1.0 - _hit_u) * (1.0 - _strike_u)


## Channel targets at key time u: Catmull-Rom through the keys (smooth arcs, no corners).
func _target(u: float) -> Array:
	var keys: Array = (KEYS[_move] as Array)[2]
	var n := keys.size()
	var i := 0
	while i < n - 2 and u > float(keys[i + 1][0]):
		i += 1
	var k1: Array = keys[i]
	var k2: Array = keys[i + 1]
	var k0: Array = keys[maxi(0, i - 1)]
	var k3: Array = keys[mini(n - 1, i + 2)]
	var s := clampf((u - float(k1[0])) / maxf(0.0001, float(k2[0]) - float(k1[0])), 0.0, 1.0)
	var out := []
	for c in range(1, 7):
		var p0: float = k0[c]
		var p1: float = k1[c]
		var p2: float = k2[c]
		var p3: float = k3[c]
		var s2 := s * s
		var s3 := s2 * s
		out.append(0.5 * (2.0 * p1 + (-p0 + p2) * s + (2.0 * p0 - 5.0 * p1 + 4.0 * p2 - p3) * s2 + (-p0 + 3.0 * p1 - 3.0 * p2 + p3) * s3))
	return out


## Plays a beast move ("opener" = this body's big ride-start move); `e` = the server's asura_move event (len / hit /
## disp / blink) when there is one. A new move replaces the running one (the spring blends from the current pose).
func move(name: String, e: Dictionary = {}) -> void:
	_move = str(OPENER.get(body, "dive")) if name == "opener" else (name if KEYS.has(name) else str((MOVES.get(body, ["bite"]) as Array)[0]))
	_move_t = 0.0
	played += 1
	var spec: Array = KEYS[_move]
	var base := float(spec[0])
	_len = clampf(float(e.get("len", base)), base * 0.6, base * 1.8) if e.has("len") else base
	_strike_u = float(spec[2][int(spec[1])][0])
	var hits: Array = e.get("hit", [])
	_hit_u = clampf(float(hits[0]) / _len, 0.15, 0.92) if not hits.is_empty() else -1.0
	_arcs.clear()
	if not bool(e.get("blink", false)):
		for d in e.get("disp", []):
			var m := float(d.get("m", 0.0))
			if m >= 2.5 and str(d.get("dir", "")) != "back":
				_arcs.append([float(d.get("t", 0.0)), float(d.get("dur", 0.3)), clampf(m * 0.05, 0.15, 0.7)])
			elif m >= 2.0:
				_arcs.append([float(d.get("t", 0.0)), float(d.get("dur", 0.3)), clampf(m * 0.04, 0.1, 0.4)])


## Contact: the move and its spring nearly stop for `t` s (asura_ride.gd at each hit frame).
func hit_pause(t: float) -> void:
	_pause = maxf(_pause, t)
	pauses += 1


## The ride ended: the model wrapper and the rider's pose go back to rest.
func _exit_tree() -> void:
	if rig and is_instance_valid(rig):
		rig.queue_free()
	if model and is_instance_valid(model):
		model.position = _rest_pos
		model.rotation = _rest_rot
		model.scale = _rest_scale
	var rider = mv.get_parent() if mv and is_instance_valid(mv) else null
	var pm = rider.get("model") if rider else null
	if pm is Node3D and is_instance_valid(pm):
		(pm as Node3D).rotation.z = 0.0
		(pm as Node3D).rotation.x = 0.0
