extends RefCounted
## Asura Rider ride look (client). The ridden beast is the player's mount view (mount_view.gd, so the rider seat,
## gait and bob all work) built from the beast's mount rig ("mount" in data/asura.json), scaled up to the beast's
## "height" (~2x a hero), dressed in the fierce asura material (asura_view.gd) with the element aura (boss_fx.gd) and,
## for ground beasts, the element trail. player.gd uses mount ids "asura:<id>" (character_state.gd mount_id while
## riding); anything that is not an asura id is an everyday mount and never comes here.
##  - dress(): scale, material, aura, the rider's seat offset ("rider" [x, y, z] m) and - on a fresh ride (the server
##    "asura" event just said so) - the grand intro: a rune circle opens, the beast rises out of it, roars, and lands
##    with a shockwave (camera shake).
##  - on_ride_event(): the "asura" server event (intro flag / leave burst).
##  - present_skill(): a class skill cast while riding = the Mega look: the normal skill FX scaled up, an element-
##    tinted burst at the impact, the "MEGA" tag.
##  - skill_fx(): the beast's own skills (start burst / pulse): element rings, bolts, flash, name.

const AsuraView = preload("res://scripts/asura_view.gd")
const BossFx = preload("res://scripts/boss_fx.gd")
const Fx = preload("res://scripts/fx.gd")

const PREFIX := "asura:"
const INTRO := 1.05
const MEGA_SCALE := 1.45
## Default seat offset (m, in the rider's space) when a beast has no "rider" entry: a little up and back.
const SEAT_DEFAULT := Vector3(0.0, 0.3, -0.25)
## Element -> Fx.element_bolts kind + colour (ghost / holy / earth use orbs in the beast's colour).
const EL_FX := {"water": "water", "wind": "wind", "fire": "fire", "earth": "earth", "ghost": "ghost", "holy": "holy"}

## pid -> ticks (ms) of a fresh ride start (the next dress() of that player's view plays the intro)
static var _intro: Dictionary = {}


static func is_asura(mount_id: String) -> bool:
	return mount_id.begins_with(PREFIX)


static func asura_of(mount_id: String) -> String:
	return mount_id.substr(PREFIX.length()) if is_asura(mount_id) else ""


## The mount rig a beast rides on (mount_view.gd MOUNTS id).
static func rig_of(mount_id: String) -> String:
	return str(AsuraView.def(asura_of(mount_id)).get("mount", "golden_dragon"))


static func color_of(id: String) -> Color:
	return Color(str(AsuraView.def(id).get("look", {}).get("color", "#b45cff")))


## After mount_view.setup(rig_of(id)): scale to the beast height, asura material, aura + trail, seat, intro.
static func dress(mv: Node3D, mount_id: String) -> float:
	var id := asura_of(mount_id)
	var d: Dictionary = AsuraView.def(id)
	if d.is_empty() or mv == null:
		return 1.0
	var base_h := float(mv.get("cfg").get("height", 2.6)) if mv.get("cfg") is Dictionary else 2.6
	var h := float(d.get("height", 4.5))
	var k := h / maxf(0.5, base_h)
	mv.scale = Vector3.ONE * k
	var ro: Array = d.get("rider", [SEAT_DEFAULT.x, SEAT_DEFAULT.y, SEAT_DEFAULT.z])
	mv.set_meta("seat_off", Vector3(float(ro[0]), float(ro[1]), float(ro[2])))
	var model = mv.get("model")
	var look: Dictionary = d.get("look", {})
	if model is Node3D:
		AsuraView._asura_material(model, look)
	# the aura lives on an unscaled child so its sizes are world metres
	var holder := Node3D.new()
	holder.name = "AsuraAura"
	holder.scale = Vector3.ONE / k
	mv.add_child(holder)
	if holder.is_inside_tree():
		BossFx.attach(holder, {"color": str(look.get("color", "#b45cff")), "color_hi": str(look.get("color_hi", "#ffffff")),
			"h": h * 0.7, "r": h * 0.26, "light": 1.4, "light_color": str(look.get("color_hi", "#ffffff")), "shell": 0.4,
			"shell_h": 0.3, "ring": h * 0.5})
		if not bool(d.get("flyer", false)):
			AsuraView._trail(holder, look, h)
	# body motion: speed-matched walk, gallop bob, banking, breathing, footfall dust, rider lean (asura_motion.gd)
	var mo = load("res://scripts/asura_motion.gd").new()
	mo.name = "AsuraMotion"
	mv.add_child(mo)
	mo.setup(mv, d)
	var p = mv.get_parent()
	var pid := int(p.get("id")) if p != null and p.get("id") != null else 0
	if _intro.has(pid) and Time.get_ticks_msec() - int(_intro[pid]) < 3000:
		_intro.erase(pid)
		_play_intro(mv, id, h)
	return k


## The rise-in: rune circle, the beast climbs out of the ground, roar + wing beat, landing shockwave.
static func _play_intro(mv: Node3D, id: String, h: float) -> void:
	var p = mv.get_parent()
	var w = p.get("world") if p != null else null
	var col := color_of(id)
	var at: Vector3 = (p as Node3D).global_position if p is Node3D else Vector3.ZERO
	if w is Node:
		Fx.ground_ring(w, at, h * 0.75, col, INTRO + 0.4, 2.8, 0.2, 0.0, 0.3)
		Fx.ground_ring(w, at, h * 0.5, Color(1, 1, 1, 0.9), INTRO * 0.8, 2.4, 0.12, 0.15, 0.0)
		Fx.flash(w, at + Vector3(0, 0.2, 0), Color(col.r, col.g, col.b, 0.8), h * 0.8, INTRO, Fx.S_STAR, 2.6)
		Fx.particles(w, at + Vector3(0, 0.2, 0), {"amount": 60, "life": 1.2, "color": col, "size": 0.3, "shape": Fx.S_STAR,
			"radius": h * 0.4, "speed": [2.0, 6.0], "dir": Vector3.UP, "spread": 25.0, "gravity": -0.5, "energy": 3.0})
	var y1 := mv.position.y
	mv.position.y = y1 - h * 0.85
	var s1 := mv.scale
	mv.scale = s1 * 0.75
	var tw := mv.create_tween()
	tw.set_parallel(true)
	tw.tween_property(mv, "position:y", y1 + h * 0.08, INTRO * 0.7).set_trans(Tween.TRANS_BACK).set_ease(Tween.EASE_OUT)
	tw.tween_property(mv, "scale", s1, INTRO * 0.7).set_trans(Tween.TRANS_BACK).set_ease(Tween.EASE_OUT)
	tw.chain().tween_property(mv, "position:y", y1, INTRO * 0.3).set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_IN)
	tw.chain().tween_callback(func(): _land(mv, id, h))


static func _land(mv: Node3D, id: String, h: float) -> void:
	if not is_instance_valid(mv):
		return
	var p = mv.get_parent()
	var w = p.get("world") if p != null else null
	if not (w is Node) or not (p is Node3D):
		return
	var col := color_of(id)
	var at: Vector3 = (p as Node3D).global_position
	for i in 3:
		Fx.ground_ring(w, at, h * (0.6 + 0.45 * i), col.lerp(Color.WHITE, 0.3 * i), 0.55 + 0.15 * i, 2.6, 0.18, 0.08 * i, 0.0)
	Fx.particles(w, at + Vector3(0, 0.15, 0), {"amount": 40, "life": 0.8, "color": Color(0.95, 0.9, 0.8, 0.8), "size": 0.35,
		"radius": h * 0.35, "speed": [3.0, 7.0], "dir": Vector3.UP, "spread": 80.0, "gravity": 4.0, "energy": 1.4})
	var nm := str(AsuraView.def(id).get("name_th" if _th() else "name", id)).split(",")[0]
	Fx.float_text(w, at + Vector3(0, h + 1.2, 0), nm + "!", col.lightened(0.2), 76)
	Fx.sfx("skill_boom", at)
	var rig = w.get("rig")
	if rig and rig.has_method("shake"):
		rig.shake(0.35, 0.45)
	var model = mv.get("model")
	var anim = model.get_meta("anim") if model is Node and model.has_meta("anim") else null
	if anim is AnimationPlayer:
		# the roar / wing beat: a quick burst of its own motion
		(anim as AnimationPlayer).speed_scale = 2.2
		mv.get_tree().create_timer(0.6).timeout.connect(func(): if is_instance_valid(anim): (anim as AnimationPlayer).speed_scale = 1.0)


static func _th() -> bool:
	return TranslationServer.get_locale().begins_with("th")


## Server "asura" event (main.gd): a fresh ride -> the intro plays when the view dresses the beast; the end ->
## a burst where the beast leaves.
static func on_ride_event(world: Node, e: Dictionary) -> void:
	var pid := int(e.get("id", 0))
	if bool(e.get("on", false)):
		_intro[pid] = Time.get_ticks_msec()
		return
	var v = world.call("view", pid) if world and world.has_method("view") else null
	if v is Node3D:
		var col := Color(0.8, 0.8, 1.0)
		var cur := str(v.get("_mount_cur"))
		if is_asura(cur):
			col = color_of(asura_of(cur))
		var at: Vector3 = (v as Node3D).global_position
		Fx.ground_ring(world, at, 3.0, col, 0.6, 2.4, 0.18)
		Fx.particles(world, at + Vector3(0, 1.2, 0), {"amount": 46, "life": 0.9, "color": col, "size": 0.3, "shape": Fx.S_STAR,
			"radius": 1.6, "speed": [1.5, 4.5], "dir": Vector3.UP, "spread": 70.0, "gravity": -1.0, "energy": 2.8})


## A class skill: riding an asura -> the Mega presentation, else the normal one.
static func present_skill(world: Node, skillfx, e: Dictionary) -> void:
	var v = world.call("view", int(e.get("id", 0))) if world.has_method("view") else null
	var cur := str(v.get("_mount_cur")) if v != null and v.get("_mount_cur") != null else ""
	if not is_asura(cur) or bool(e.get("trap", false)):
		skillfx.on_skill(e)
		return
	# the beast performs the skill (asura_move event -> on_move): no class FX at the rider's hand
	return
	var id := asura_of(cur)
	var col := color_of(id)
	var n0 := world.get_child_count()
	skillfx.on_skill(e)
	# the skill's own effects spawned right now: bigger
	for i in range(n0, world.get_child_count()):
		var c = world.get_child(i)
		if c is Node3D:
			(c as Node3D).scale *= MEGA_SCALE
	var at: Vector3 = (v as Node3D).global_position
	var tv = world.call("view", int(e.get("target", 0))) if e.has("target") else null
	if tv is Node3D:
		at = (tv as Node3D).global_position
	elif e.get("at") is Vector3:
		at = e["at"]
	at.y = 0.0
	mega_burst(world, at, id, col)
	Fx.float_text(world, (v as Node3D).global_position + Vector3(0, 5.2, 0), "MEGA", Color("#ffd24a"), 64)


## The extra Mega burst: an element-tinted shock ring, a flash column and element bolts.
static func mega_burst(world: Node, at: Vector3, id: String, col: Color) -> void:
	var el := str(AsuraView.def(id).get("element", "fire"))
	Fx.ground_ring(world, at, 2.6, col, 0.55, 3.0, 0.2, 0.05, 0.25)
	Fx.ground_ring(world, at, 3.8, col.lightened(0.35), 0.7, 2.2, 0.12, 0.18, 0.0)
	Fx.flash(world, at + Vector3(0, 1.6, 0), Color(col.r, col.g, col.b, 0.75), 3.0, 0.4, Fx.S_STAR, 3.2, 0.05)
	Fx.element_bolts(world, at, str(EL_FX.get(el, "fire")), 4)


## Server "asura_skill" event: the beast's own skill - bigger rings, bolts, a flash column, its name.
static func skill_fx(world: Node, e: Dictionary) -> void:
	if world == null:
		return
	var at = e.get("at", Vector3.ZERO)
	if not (at is Vector3):
		return
	var id := str(e.get("asura", ""))
	var c := Color(str(e.get("color", "#ffffff")))
	var r := float(e.get("r", 4.0))
	var el := str(AsuraView.def(id).get("element", "fire"))
	var sk: Dictionary = AsuraView.data().get("skills", {}).get(str(e.get("skill", "")), {})
	var start := str(sk.get("when", "")) == "start"
	if bool(sk.get("opener", false)):
		_opener(world, e, at, id, c, r, el, sk)
		return
	for i in (3 if start else 2):
		Fx.ground_ring(world, at, r * (0.55 + 0.3 * i), c.lerp(Color.WHITE, 0.25 * i), 0.7 + 0.12 * i, 2.8, 0.22, 0.1 * i, 0.25 if i == 0 else 0.0)
	Fx.flash(world, at + Vector3(0, 1.4, 0), Color(c.r, c.g, c.b, 0.85), r * 0.9, 0.5, Fx.S_STAR, 3.0)
	Fx.element_bolts(world, at, str(EL_FX.get(el, "fire")), 8 if start else 5)
	Fx.particles(world, at + Vector3(0, 0.3, 0), {"amount": 50 if start else 30, "life": 0.9, "color": c, "size": 0.28, "shape": Fx.S_STAR,
		"radius": r * 0.6, "speed": [2.0, 5.5], "dir": Vector3.UP, "spread": 55.0, "gravity": 1.5, "energy": 3.0})
	var nm := str(sk.get("name_th" if _th() else "name", ""))
	if nm != "":
		Fx.float_text(world, at + Vector3(0, 4.2, 0), nm, c.lightened(0.25), 64)
	if start:
		var rig = world.get("rig")
		if rig and rig.has_method("shake"):
			rig.shake(0.25, 0.35)


# ---------------------------------------------------------------- openers (Boss 2026-10-01: a unique big AoE per beast)

## The ride-start opener: the beast's big move (asura_motion.gd), its own effect by data "fx", hit-stop on the rider,
## a strong camera shake and the move's name.
static func _opener(world: Node, e: Dictionary, at: Vector3, id: String, c: Color, r: float, el: String, sk: Dictionary) -> void:
	var v = world.call("view", int(e.get("id", 0))) if world.has_method("view") else null
	var mv = v.get("mount_view") if v != null else null
	var mo = mv.get_node_or_null("AsuraMotion") if mv is Node else null
	if mo and mo.has_method("move"):
		mo.move("opener")
	var kind := str(sk.get("fx", "frost_nova"))
	# the blow lands when the move hits the ground (~0.55 s in)
	var land := 0.55
	world.get_tree().create_timer(land).timeout.connect(func():
		if not is_instance_valid(world):
			return
		match kind:
			"frost_nova":
				_fx_frost_nova(world, at, r, c)
			"storm_strikes":
				_fx_storm(world, at, r, c)
			"gold_meteor":
				_fx_meteor(world, at, r, c)
			"solar_roar":
				_fx_solar(world, at, r, c)
			"spirit_spiral":
				_fx_spiral(world, at, r, c)
			_:
				_fx_crystal(world, at, r, c)
		Fx.element_bolts(world, at, str(EL_FX.get(el, "fire")), 6)
		var rig = world.get("rig")
		if rig and rig.has_method("shake"):
			rig.shake(0.55, 0.6)
		if v != null and is_instance_valid(v) and v.has_method("hit_stop"):
			v.hit_stop(0.12)
		Fx.sfx("skill_boom", at))
	var nm := str(sk.get("name_th" if _th() else "name", ""))
	if nm != "":
		Fx.float_text(world, at + Vector3(0, 5.0, 0), nm + "!", c.lightened(0.25), 84)


static func _ring_pts(at: Vector3, rad: float, n: int, off: float = 0.0) -> Array:
	var out := []
	for i in n:
		var a := TAU * float(i) / float(n) + off
		out.append(at + Vector3(cos(a), 0, sin(a)) * rad)
	return out


## Glacier: three rings of ice spikes erupt outward, frost mist.
static func _fx_frost_nova(w: Node, at: Vector3, r: float, c: Color) -> void:
	for k in 3:
		var rad := r * (0.35 + 0.3 * k)
		Fx.ground_ring(w, at, rad, c.lerp(Color.WHITE, 0.3), 0.6, 2.6, 0.2, 0.09 * k, 0.15)
		for p in _ring_pts(at, rad, 8 + 4 * k, 0.3 * k):
			Fx.particles(w, p, {"amount": 10, "life": 0.7, "color": Color(0.85, 0.97, 1.0), "shape": Fx.S_STREAK, "size": 0.5,
				"speed": [3.0, 6.0], "dir": Vector3.UP, "spread": 12.0, "gravity": 5.0, "energy": 3.2, "delay": 0.09 * k})
	Fx.particles(w, at + Vector3(0, 0.3, 0), {"amount": 40, "life": 1.4, "color": Color(0.8, 0.92, 1.0, 0.6), "size": 1.0,
		"radius": r * 0.7, "speed": [0.3, 1.0], "gravity": -0.2, "soft": true, "flat": true})
	Fx.flash(w, at + Vector3(0, 1.0, 0), Color(c.r, c.g, c.b, 0.8), r * 0.9, 0.5, Fx.S_STAR, 3.2)


## Tempest: lightning columns crash down across the area one after another.
static func _fx_storm(w: Node, at: Vector3, r: float, c: Color) -> void:
	for i in 9:
		var a := randf() * TAU
		var p := at + Vector3(cos(a), 0, sin(a)) * r * sqrt(randf()) * 0.9
		var d := 0.06 * i
		Fx.flash(w, p + Vector3(0, 4.0, 0), Color(0.85, 0.75, 1.0, 0.95), 1.2, 0.25, Fx.S_BEAM, 4.0, d)
		Fx.ground_ring(w, p, 1.6, c.lightened(0.3), 0.4, 3.0, 0.2, d, 0.3)
		Fx.particles(w, p + Vector3(0, 0.2, 0), {"amount": 14, "life": 0.4, "color": Color(0.9, 0.85, 1.0), "shape": Fx.S_STAR, "size": 0.2,
			"speed": [2.0, 5.0], "spread": 80.0, "dir": Vector3.UP, "gravity": 3.0, "energy": 3.5, "delay": d})
	Fx.ground_ring(w, at, r, c, 0.7, 2.4, 0.16, 0.0, 0.2)


## Gilded: golden meteors fall into the area, quake rings and dust.
static func _fx_meteor(w: Node, at: Vector3, r: float, c: Color) -> void:
	for i in 6:
		var a := TAU * float(i) / 6.0 + randf() * 0.4
		var p := at + Vector3(cos(a), 0, sin(a)) * r * (0.3 + 0.5 * randf())
		var d := 0.08 * i
		w.get_tree().create_timer(d).timeout.connect(func():
			if not is_instance_valid(w):
				return
			Fx.projectile(w, p + Vector3(-2.0, 9.0, -1.0), p, Color(1.0, 0.8, 0.3), 0.3, 1.6, 0.35))
		Fx.ground_ring(w, p, 2.2, Color(1.0, 0.82, 0.35), 0.5, 2.8, 0.22, d + 0.3, 0.35)
		Fx.particles(w, p + Vector3(0, 0.2, 0), {"amount": 18, "life": 0.7, "color": Color(0.85, 0.72, 0.5, 0.85), "size": 0.45,
			"radius": 0.6, "speed": [2.0, 4.0], "gravity": 3.0, "soft": true, "flat": true, "delay": d + 0.3})
	Fx.ground_ring(w, at, r, c, 0.9, 2.2, 0.25, 0.3, 0.12)


## Sun-Mane: a roar of fire - a sun burst and three expanding flame rings.
static func _fx_solar(w: Node, at: Vector3, r: float, c: Color) -> void:
	Fx.flash(w, at + Vector3(0, 1.6, 0), Color(1.0, 0.75, 0.3, 0.95), r * 1.1, 0.6, Fx.S_STAR, 3.6)
	for k in 3:
		Fx.ground_ring(w, at, r * (0.45 + 0.28 * k), Color(1.0, 0.55 - 0.1 * k, 0.15), 0.55, 3.0, 0.26, 0.1 * k, 0.3)
		Fx.particles(w, at + Vector3(0, 0.4, 0), {"amount": 36, "life": 0.6, "color": Color(1.0, 0.6, 0.2), "shape": Fx.S_FLAME,
			"size": 0.6, "ring": r * (0.4 + 0.28 * k), "speed": [3.0, 6.0], "dir": Vector3(1, 0, 0), "spread": 180.0, "flat": true,
			"gravity": -1.0, "energy": 3.0, "delay": 0.1 * k})


## Wraith-Fox: spirit flames spiral outward from the beast.
static func _fx_spiral(w: Node, at: Vector3, r: float, c: Color) -> void:
	for i in 18:
		var u := float(i) / 18.0
		var a := u * TAU * 2.0
		var p := at + Vector3(cos(a), 0, sin(a)) * r * (0.15 + 0.8 * u)
		Fx.particles(w, p + Vector3(0, 0.5, 0), {"amount": 8, "life": 0.8, "color": Color(0.6, 1.0, 0.95), "shape": Fx.S_FLAME,
			"size": 0.55, "speed": [0.8, 2.0], "dir": Vector3.UP, "spread": 20.0, "gravity": -1.5, "energy": 3.0, "delay": u * 0.5})
	Fx.ground_ring(w, at, r, Color(0.55, 0.9, 1.0), 0.8, 2.4, 0.18, 0.2, 0.2)
	Fx.flash(w, at + Vector3(0, 1.2, 0), Color(0.6, 0.4, 1.0, 0.8), r * 0.7, 0.5, Fx.S_DOT, 3.0)


## Prism: a ring of rainbow crystal shards bursts out.
static func _fx_crystal(w: Node, at: Vector3, r: float, c: Color) -> void:
	var cols := [Color(1, 0.5, 0.6), Color(1, 0.85, 0.4), Color(0.5, 1, 0.6), Color(0.45, 0.8, 1), Color(0.75, 0.55, 1)]
	var pts := _ring_pts(at, r * 0.7, 10)
	for i in pts.size():
		Fx.particles(w, pts[i] + Vector3(0, 0.3, 0), {"amount": 10, "life": 0.7, "color": cols[i % cols.size()], "shape": Fx.S_STAR,
			"size": 0.35, "speed": [2.0, 4.5], "dir": Vector3.UP, "spread": 30.0, "gravity": 4.0, "energy": 3.4, "delay": 0.03 * i})
	for k in cols.size():
		Fx.ground_ring(w, at, r * (0.3 + 0.15 * k), cols[k], 0.5, 2.6, 0.12, 0.05 * k, 0.0)
	Fx.flash(w, at + Vector3(0, 1.4, 0), Color(1, 1, 1, 0.8), r * 0.8, 0.45, Fx.S_STAR, 3.2)


# ---------------------------------------------------------------- beast attacks (data/asura_moves.json)

## Server "asura_move": the beast's attack - its motion (asura_motion.gd), FX from its socket by the move's shape at
## the hit frames, the camera (shake / pull / hit-stop) and, for class skills, the move name with the MEGA tag.
static func on_move(world: Node, e: Dictionary) -> void:
	var v = world.call("view", int(e.get("id", 0))) if world and world.has_method("view") else null
	if v == null or not is_instance_valid(v):
		return
	var id := str(e.get("asura", ""))
	var col := color_of(id)
	var h := float(AsuraView.def(id).get("height", 3.0))
	var mv = v.get("mount_view")
	var mo = mv.get_node_or_null("AsuraMotion") if mv is Node else null
	if mo and mo.has_method("move"):
		# the whole event: the strike is timed onto its hit frame, displacements get their arc (asura_motion.gd)
		mo.move(str(e.get("key", "bite")), e)
	var me := world.get("my_id") != null and int(e.get("id", 0)) == int(world.get("my_id"))
	var cam: Dictionary = e.get("cam", {})
	var rig = world.get("rig") if me else null
	if rig and cam.has("pull"):
		var d0: float = rig.dist
		var tw: Tween = world.create_tween()
		tw.tween_property(rig, "dist", d0 + float(cam["pull"]), 0.25).set_trans(Tween.TRANS_SINE)
		tw.tween_interval(maxf(0.1, float(e.get("len", 0.6)) - 0.4))
		tw.tween_property(rig, "dist", d0, 0.35).set_trans(Tween.TRANS_SINE)
	if bool(e.get("blink", false)):
		var a0: Vector3 = e.get("from", (v as Node3D).global_position)
		Fx.particles(world, a0 + Vector3(0, h * 0.5, 0), {"amount": 24, "life": 0.5, "color": col, "radius": h * 0.25,
			"speed": [1.0, 3.0], "gravity": -1.0, "size": 0.4, "soft": true})
	if bool(e.get("mega", false)):
		var nm := str(e.get("name_th" if _th() else "name", ""))
		Fx.float_text(world, (v as Node3D).global_position + Vector3(0, h + 1.6, 0), "MEGA  " + nm, Color("#ffd24a"), 58)
	var hits: Array = e.get("hit", [0.2])
	for i in hits.size():
		world.get_tree().create_timer(maxf(0.01, float(hits[i]))).timeout.connect(_move_hit.bind(world, v, e, col, h, i, rig, cam))


## One hit frame of a beast move: FX by shape from the beast's socket.
static func _move_hit(world: Node, v, e: Dictionary, col: Color, h: float, i: int, rig, cam: Dictionary) -> void:
	if not is_instance_valid(world) or v == null or not is_instance_valid(v):
		return
	var at: Vector3 = (v as Node3D).global_position
	var tp: Vector3 = e.get("target_at", at)
	var fwd := tp - at
	fwd.y = 0.0
	fwd = fwd.normalized() if fwd.length() > 0.1 else Vector3(sin((v as Node3D).rotation.y), 0, cos((v as Node3D).rotation.y))
	var src := str(load("res://scripts/server/asura_combat.gd").beast(str(e.get("asura", ""))).get("fx_from", "mouth"))
	var sock := _socket(v, src, h, fwd)
	var shape := str(e.get("shape", "cone"))
	var r := float(e.get("r", 3.0))
	var hot := col.lightened(0.3)
	match shape:
		"proj":
			var n := int(e.get("n", 1))
			for k in n:
				var off := fwd.cross(Vector3.UP) * (float(k) - float(n - 1) * 0.5) * 0.6
				Fx.projectile(world, sock, tp + Vector3(0, 0.8, 0) + off, hot, 0.22, 0.9, 0.12)
			Fx.flash(world, tp + Vector3(0, 0.9, 0), Color(col.r, col.g, col.b, 0.8), 1.4, 0.2, Fx.S_STAR, 3.2, 0.2)
		"line":
			var end := sock + fwd * 10.0
			Fx.projectile(world, sock, end, hot, 0.12, 6.0, 0.35)
			Fx.ground_ring(world, Vector3(end.x, 0, end.z), 1.6, col, 0.4, 2.6, 0.2)
			Fx.flash(world, sock, Color(1, 1, 1, 0.85), 1.6, 0.18, Fx.S_STAR, 3.5)
		"circle":
			var c: Vector3 = at
			if str(e.get("at", "self")) == "target":
				c = tp
			elif str(e.get("at", "")) == "start":
				c = e.get("from", at)
			if bool(e.get("rain", false)):
				for k in 3:
					var p := c + Vector3(randf_range(-r, r) * 0.6, 0, randf_range(-r, r) * 0.6)
					Fx.projectile(world, p + Vector3(-1.0, 7.0, -0.5), p, hot, 0.22, 1.2, 0.18)
			Fx.ground_ring(world, c, r, col, 0.5, 2.8, 0.22, 0.0, 0.2 if i == 0 else 0.0)
			Fx.flash(world, c + Vector3(0, 0.8, 0), Color(col.r, col.g, col.b, 0.8), r * 0.7, 0.3, Fx.S_STAR, 3.0)
		"self":
			var rr := r * ((0.45 + 0.28 * i) if bool(e.get("rings", false)) else 1.0)
			Fx.ground_ring(world, at, rr, col, 0.5, 2.8, 0.22, 0.0, 0.18)
			Fx.particles(world, at + Vector3(0, 0.4, 0), {"amount": 24, "life": 0.5, "color": hot, "shape": Fx.S_STAR, "size": 0.25,
				"ring": rr * 0.8, "speed": [2.0, 4.0], "dir": Vector3(1, 0, 0), "spread": 180.0, "flat": true, "gravity": -0.5, "energy": 3.0})
		_:
			# cone: claw / bite / tail arcs in front of the beast, a breath stream when wide
			var reach := 6.5 if bool(e.get("wide", false)) else 3.5
			var mid := at + fwd * reach * 0.6 + Vector3(0, h * 0.35, 0)
			if bool(e.get("wide", false)):
				Fx.particles(world, sock, {"amount": 40, "life": 0.5, "color": hot, "size": 0.45, "dir": fwd, "spread": 28.0,
					"speed": [6.0, 11.0], "gravity": 0.0, "soft": true, "energy": 2.6})
			for k in 3:
				Fx.flash(world, mid + fwd.cross(Vector3.UP) * (float(k) - 1.0) * 0.5, Color(hot.r, hot.g, hot.b, 0.9), 1.3, 0.16, Fx.S_STREAK, 3.4, 0.03 * k)
			Fx.flash(world, mid, Color(col.r, col.g, col.b, 0.7), 1.8, 0.22, Fx.S_STAR, 3.0)
	# contact: a short hit pause of the beast's motion (bigger with a camera shake)
	var mv2 = v.get("mount_view")
	var mo2 = mv2.get_node_or_null("AsuraMotion") if mv2 is Node else null
	if mo2 and mo2.has_method("hit_pause"):
		mo2.hit_pause(0.09 if cam.has("shake") else 0.055)
	if rig and cam.has("shake") and i == 0:
		var sh: Array = cam["shake"]
		rig.shake(float(sh[0]), float(sh[1]))
	if cam.has("hitstop") and v.has_method("hit_stop"):
		v.hit_stop(float(cam["hitstop"]))


## Where a beast's attack comes from (no rig sockets yet: offsets from its height along its facing).
static func _socket(v, from: String, h: float, fwd: Vector3) -> Vector3:
	var p: Vector3 = (v as Node3D).global_position
	match from:
		"horn":
			return p + fwd * h * 0.35 + Vector3(0, h * 0.95, 0)
		"tails":
			return p - fwd * h * 0.3 + Vector3(0, h * 0.6, 0)
		"paws":
			return p + fwd * h * 0.4 + Vector3(0, h * 0.15, 0)
		"body":
			return p + fwd * h * 0.2 + Vector3(0, h * 0.5, 0)
	return p + fwd * h * 0.45 + Vector3(0, h * 0.75, 0)
