extends Node3D
## Monster VIEW (client side, N1 step 3): model, name, HP bar, animations and hit / freeze / death
## effects for one server monster (server/mob.gd, same `id`). Position, facing, AI state and HP come
## from the server (snapshot + events via main.gd); nothing here changes the game.
## Look ("model"): a procedural chibi kind (slime, bunny, fly, wolf, ...), a CC0 animated GLB id from
## env_assets.gd (bee, mushroom, tree, ...; toon + thin outline, optional "tint"), or "tripo:<id>"
## (rigged Tripo model res://assets/models/<id>/anim_*.glb, or a static Tripo mesh
## res://assets/models/mon_<id>/model.glb animated procedurally, else "fallback_model"). "scale" enlarges the model;
## "miniboss": gold name, HP bar always shown.
## Clicks: an Area3D on layer 4 (the old body layer) so the player's raycast still finds monsters.

const Chibi = preload("res://scripts/chibi.gd")
const Bar3D = preload("res://scripts/bar3d.gd")
const Fx = preload("res://scripts/fx.gd")
const Buffs = preload("res://scripts/buffs.gd")
const EnvAssets = preload("res://scripts/env_assets.gd")
const ModelLoader = preload("res://scripts/model_loader.gd")
const MobAccessory = preload("res://scripts/mob_accessory.gd")
const BossFx = preload("res://scripts/boss_fx.gd")

## Chibi height (metres) of GLB monsters by classic size, before "scale".
const SIZE_H := {"small": 0.95, "medium": 1.3, "large": 1.8}
## Height (metres) of static Tripo monster meshes by classic size, before "scale".
const TRIPO_H := {"small": 0.8, "medium": 1.3, "large": 2.0}
## Static Tripo meshes: image-to-3D output faces +X (checked on screenshots: wolf, fox-wolf, condor,
## scorpion) -> turned -90 deg to face +Z; TRIPO_YAW overrides per model. TRIPO_FLY hover and bank,
## TRIPO_HOP bounce higher when walking (the condor mesh stands with folded wings).
const TRIPO_YAW_DEFAULT := -PI / 2.0
const TRIPO_YAW := {}
## Hollowroot ghosts (Pale Maiden, Grand Whisper) float; the jiangshi (Munny, Bonbon) hop.
const TRIPO_FLY := ["pale_maiden", "grand_whisper"]
const TRIPO_HOP := ["dune_condor", "munny", "bonbon"]
## Animation clip candidates per role (Quaternius CC0 names first, then Tripo names).
const CLIPS := {
	"idle": ["Idle", "Flying", "idle"], "walk": ["Walk", "Flying", "walk"], "attack": ["Bite_InPlace", "Bite_Front", "slash"],
	"hit": ["HitRecieve", "HitReceive", "hurt"], "death": ["Death", "death"],
	# showcase MVP heavy attacks (on_telegraph): wind-up roar, leap slam, spin
	"windup": ["roar"], "slam": ["slam"], "whirl": ["whirl"],
}

## Server entity id (server/mob.gd).
var id := 0
var mid := ""
## Monster definition (data/monsters.json, read only).
var data: Dictionary = {}
## Replicated from the server: HP, dead, AI state ("idle" / "chase" / "return"), chased entity id,
## debuffs (for the target frame + AUTO), stolen-from flag.
var hp := 1
var max_hp := 1
var dead := false
var state := "idle"
var target_id := 0
var buffs = Buffs.new()
var stolen := false
var is_boss := false
var is_mini := false
var t := 0.0
## Model scale from data ("scale", 1 = normal).
var scl := 1.0
var model: Node3D
## Animated GLB / Tripo model: its AnimationPlayer and role -> clip name (idle/walk/attack/hit/death).
var anim: AnimationPlayer = null
var clips: Dictionary = {}
var _anim_lock := 0.0
var _clip := ""
var rig: Node3D
var bar
var name_label: Label3D
var pick: Area3D
var _meshes: Array = []
var _colors: Array = []
var _moving := false
## Server facing (radians); NAN until the monster first turns.
var _yaw_goal := NAN
## Network play (N2): snapshots arrive every `dt` s -> glide from the last position to the new one.
## The in-process server sends one every physics frame without "dt": positions are taken as they are.
var _net_from := Vector3.ZERO
var _net_to := Vector3.ZERO
var _net_t := 1.0
var _net_dt := 0.0
## Static Tripo mesh (no clips): procedural idle / walk / lunge / hit on the "Rig" pivot.
var _static := false
## 0..1 attack lunge and hit recoil, driven by tweens (static meshes).
var _lunge := 0.0
var _recoil := 0.0

# ---- melee impact reaction (hit_feel.gd; view only - the server position never moves)
## Knock-back away from the attacker (m) that springs back; squash of blob monsters (0..1).
const KICK := 0.18
const KICK_CRIT := 0.3
const SQUASH := 0.35
const SQUASH_CRIT := 0.6
## White flash (s).
const WHITE_T := 0.07
const WHITE_T_CRIT := 0.11
var _white_mat: StandardMaterial3D = null
var _stop_t := 0.0
var _squash := 0.0
var _kick_tw: Tween = null
var _blob := false
var _dying := false


func setup(entity_id: int, mon_id: String, at: Vector3, cur_hp: int = -1, cur_max: int = -1) -> void:
	id = entity_id
	mid = mon_id
	data = GameData.monsters[mon_id]
	_kind_cache = ""  # sweep: new kind
	max_hp = cur_max if cur_max > 0 else int(data.get("hp", 10))
	hp = cur_hp if cur_hp >= 0 else max_hp
	position = at
	is_boss = bool(data.get("boss", false))
	is_mini = bool(data.get("miniboss", false))
	scl = float(data.get("scale", 1.0))


func _ready() -> void:
	add_to_group("monsters")
	var big := maxf(2.2, scl) if is_boss else scl
	# click target (the server body has no collision layer)
	pick = Area3D.new()
	pick.collision_layer = 4
	pick.collision_mask = 0
	pick.monitoring = false
	pick.monitorable = true
	pick.set_meta("view", self)
	var cs := CollisionShape3D.new()
	var cap := CapsuleShape3D.new()
	cap.radius = 0.5 * big
	cap.height = 1.6 * big
	cs.shape = cap
	cs.position.y = cap.height / 2.0
	pick.add_child(cs)
	add_child(pick)
	var kind := model_kind()
	var top := 1.75
	if EnvAssets.category_of(kind) == "monsters" or kind.begins_with("tripo:"):
		model = _glb_model(kind)
		top = _model_h() + 0.45
	else:
		model = Chibi.monster(kind, Color(str(data.get("color", "#88ccff"))))
		if scl != 1.0:
			model.scale = Vector3.ONE * scl
			top = 1.75 * scl
	add_child(model)
	# a small procedural accessory (data "acc": scarf, hats, bow ...; scripts/mob_accessory.gd)
	if data.has("acc"):
		MobAccessory.attach(model, data, kind, (top - 0.45) if (EnvAssets.category_of(kind) == "monsters" or kind.begins_with("tripo:")) else 0.0)
	# readability pass (mob_look.gd): ghost trail, horns, weapons, element aura, idle sway, camera fade
	var _ml = load("res://scripts/mob_look.gd").new()
	add_child(_ml)
	_ml.setup(self, top - 0.45 if top > 0.6 else 1.3 * scl, kind)
	# showcase MVP aura (data "aura", boss_fx.gd): flame shell, rune circle, embers, coloured light
	if data.has("aura"):
		var ac: Dictionary = (data["aura"] as Dictionary).duplicate()
		ac["h"] = top - 0.45
		ac["r"] = maxf(0.6, (top - 0.45) * 0.22)
		BossFx.attach(self, ac)
	_blob = kind in ["slime", "king"]
	Fx.blob_shadow(self, 0.5 * big)
	rig = model.get_node_or_null("Rig")
	if rig == null:
		# a model without its pivot (data / script out of sync): animate a plain pivot instead of erroring each frame
		rig = Node3D.new()
		rig.name = "Rig"
		model.add_child(rig)
	for mi in model.find_children("*", "MeshInstance3D", true, false):
		_meshes.append(mi)
		_colors.append(_get_color(mi))
	name_label = Label3D.new()
	name_label.text = ("[MVP] " if is_boss else "") + "%s  Lv%d" % [GameData.monster_name(mid), int(data.get("lv", 1))]  # th-pass2
	name_label.font_size = 40 if is_boss else (36 if is_mini else 32)
	name_label.outline_size = 8
	name_label.pixel_size = 0.006
	name_label.billboard = BaseMaterial3D.BILLBOARD_ENABLED
	name_label.no_depth_test = true
	name_label.modulate = Color(1, 0.45, 0.35) if is_boss else (Color(1, 0.8, 0.3) if is_mini else Color(1, 1, 1))
	name_label.position.y = maxf(5.72, top + 0.5) if is_boss else top
	add_child(name_label)
	bar = Bar3D.new()
	add_child(bar)
	bar.setup(Color(0.9, 0.2, 0.2), 1.6 if is_boss else (1.2 if is_mini else 0.9))
	bar.position.y = name_label.position.y - 0.18
	bar.visible = is_boss or is_mini
	if hp < max_hp:
		_update_bar()
	rotation.y = 0.0
	model.rotation.y = randf() * TAU


## Model to show: data "model", or its "fallback_model" when a tripo:<id> model is not made yet.
## sweep: kind cache (model_kind ran every physics tick)
var _kind_cache := ""


func model_kind() -> String:
	if _kind_cache != "":
		return _kind_cache
	_kind_cache = _model_kind_raw()
	return _kind_cache


func _model_kind_raw() -> String:
	var k := str(data.get("model", "slime"))
	if k.begins_with("tripo:") and not (ModelLoader.has_model(k.substr(6)) or ModelLoader.has_static(k.substr(6))):
		k = str(data.get("fallback_model", "slime"))
	return k


func _model_h() -> float:
	var k := model_kind()
	if k.begins_with("tripo:") and not ModelLoader.has_model(k.substr(6)):
		return float(TRIPO_H.get(str(data.get("size", "medium")), 1.3)) * scl
	return float(SIZE_H.get(str(data.get("size", "medium")), 1.3)) * scl


## Animated model (CC0 GLB or rigged Tripo) under a "Rig" pivot, scaled to the chibi height for its
## size, with its own toon + outline material per mesh (so hit flash / freeze tint one monster only).
func _glb_model(kind: String) -> Node3D:
	var root := Node3D.new()
	var body := Chibi.pivot(root, Vector3.ZERO, "Rig")
	var h := _model_h()
	var inst: Node3D = null
	var tint := Color(str(data.get("tint", "#ffffff")))
	if kind.begins_with("tripo:") and ModelLoader.has_model(kind.substr(6)):
		inst = ModelLoader.build(kind.substr(6), h)
		anim = inst.get_meta("anim") if inst and inst.has_meta("anim") else null
	elif kind.begins_with("tripo:"):
		var tid := kind.substr(6)
		inst = ModelLoader.build_static(tid, h, float(TRIPO_YAW.get(tid, TRIPO_YAW_DEFAULT)))
		_static = inst != null
		# "tint" recolours the CC0 fallback; the Tripo texture already has the right colours
		tint = Color.WHITE
		if tid in TRIPO_FLY:
			body.position.y = 0.45 * scl
	else:
		inst = EnvAssets.spawn_height(kind, h, false)
		anim = EnvAssets.anim_player(inst) if inst else null
	if inst == null:
		Chibi.add(body, Chibi.sphere(0.4, 8), Color(str(data.get("color", "#88ccff"))), Vector3(0, 0.4, 0), "Body")
		return root
	body.add_child(inst)
	for node in inst.find_children("*", "MeshInstance3D", true, false):
		var mi := node as MeshInstance3D
		mi.material_override = _toon(mi.get_active_material(0), tint)
		if kind.begins_with("tripo:"):
			# Tripo textures carry painted / baked lighting: flat low-contrast ramp (toon.gdshader "baked")
			(mi.material_override as ShaderMaterial).set_shader_parameter("baked", 1.0)
	if anim:
		for role in CLIPS:
			for want in CLIPS[role]:
				var found := ""
				for a in anim.get_animation_list():
					if str(a) == want or str(a).ends_with("|" + want):
						found = str(a)
						break
				if found != "":
					clips[role] = found
					break
		for role in ["idle", "walk"]:
			if clips.has(role):
				anim.get_animation(clips[role]).loop_mode = Animation.LOOP_LINEAR
		# flyers (bee, bat, dragon...) only have a hover loop: lift them off the ground a little
		if str(clips.get("idle", "")) == "Flying":
			body.position.y = 0.35 * scl
		_play("idle")
		anim.seek(randf() * 0.8, true)
	return root


## Toon material (+ thin texture-tinted outline, same as the characters) from an imported one.
func _toon(src: Material, tint: Color) -> ShaderMaterial:
	var m := ShaderMaterial.new()
	m.shader = Chibi.TOON
	m.set_shader_parameter("albedo", tint)
	var o := ShaderMaterial.new()
	o.shader = Chibi.OUTLINE
	o.set_shader_parameter("thickness", 0.02)
	o.set_shader_parameter("outline_color", Color(0.227, 0.165, 0.2))
	if src is BaseMaterial3D:
		var b := src as BaseMaterial3D
		m.set_shader_parameter("albedo", b.albedo_color * tint)
		if b.albedo_texture:
			m.set_shader_parameter("tex", b.albedo_texture)
			o.set_shader_parameter("tex", b.albedo_texture)
			o.set_shader_parameter("tex_mix", 1.0)
			o.set_shader_parameter("tex_darken", 0.4)
	elif src is ShaderMaterial and (src as ShaderMaterial).get_shader_parameter("tex") is Texture2D:
		# rigged Tripo model (ModelLoader.build already toonified it): keep its painted texture
		var t: Texture2D = (src as ShaderMaterial).get_shader_parameter("tex")
		m.set_shader_parameter("tex", t)
		o.set_shader_parameter("tex", t)
		o.set_shader_parameter("tex_mix", 1.0)
		o.set_shader_parameter("tex_darken", 0.4)
	m.next_pass = o
	return m


static func _get_color(mi: MeshInstance3D) -> Color:
	var m: Material = mi.material_override if mi.material_override else mi.get_surface_override_material(0)
	if m is ShaderMaterial:
		return (m as ShaderMaterial).get_shader_parameter("albedo")
	return Color.WHITE


static func _set_color(mi: MeshInstance3D, c: Color) -> void:
	var m: Material = mi.material_override if mi.material_override else mi.get_surface_override_material(0)
	if m is ShaderMaterial:
		(m as ShaderMaterial).set_shader_parameter("albedo", c)


## Plays an animation role if this model has it. once = attack / hit / death (holds for `lock` s).
func _play(role: String, once: bool = false, lock: float = 0.0) -> void:
	if anim == null or not clips.has(role):
		return
	var c: String = clips[role]
	if once:
		anim.stop()
		anim.play(c, 0.08)
		_anim_lock = lock
	elif _clip != c or not anim.is_playing():
		anim.play(c, 0.2)
	_clip = c


## Debuffs as seen on the client (target frame, AUTO); the server decides when they start / end.
func has_status(key: String) -> bool:
	return buffs.has(key)


func _flat_dist(p: Vector3) -> float:
	return Vector2(p.x - global_position.x, p.z - global_position.z).length()


# ---------------------------------------------------------------- server -> view

## A position jump in one snapshot between GLIDE_MIN and GLIDE_MAX m (knock-back: Magnum Break,
## Arrow Shower, Fire Wall, Fire Ball) slides over GLIDE_TIME s instead of popping (single-player /
## host: snapshots without "dt"). View only.
const GLIDE_MIN := 0.5
const GLIDE_MAX := 3.5
const GLIDE_TIME := 0.18
var _glide := false


## Snapshot entry from the server: position, facing, walking, AI state, chased entity.
func apply_snap(s: Dictionary) -> void:
	if dead:
		return
	var p: Vector3 = s["pos"]
	if s.has("dt"):
		_net_from = global_position
		_net_to = Vector3(p.x, 0.0, p.z)
		_net_dt = maxf(0.001, float(s["dt"]))
		_net_t = 0.0
	else:
		var q := Vector3(p.x, 0.0, p.z)
		var jump := Vector2(q.x - global_position.x, q.z - global_position.z).length()
		if _glide and _net_t < 1.0:
			_net_to = q
		elif jump > GLIDE_MIN and jump < GLIDE_MAX and is_inside_tree():
			_glide = true
			_net_from = global_position
			_net_to = q
			_net_dt = GLIDE_TIME
			_net_t = 0.0
		else:
			_glide = false
			_net_t = 1.0
			global_position = q
	var y: float = float(s.get("yaw", NAN))
	if not is_nan(y):
		_yaw_goal = y
	_moving = bool(s.get("moving", false))
	state = str(s.get("state", state))
	target_id = int(s.get("target", 0))


## Took damage (event "mob_hurt"): bar, red flash, hurt clip / recoil. fx = false: a melee hit that
## shows when the blade connects (hit_feel.gd) - the HP changes now, main calls hurt_fx() then.
func on_hurt(new_hp: int, new_max: int, fx: bool = true) -> void:
	if dead:
		return
	hp = new_hp
	max_hp = new_max
	if fx:
		hurt_fx()


func hurt_fx() -> void:
	if dead:
		return
	bar.visible = true
	_update_bar()
	_flash()
	if hp > 0 and _anim_lock <= 0.0:
		_play("hit", true, 0.3)
	if _static and hp > 0:
		_recoil = 1.0
		create_tween().tween_property(self, "_recoil", 0.0, 0.25)


## HP changed without a hit (poison tick, healed back home). show: reveal the bar.
func set_hp(new_hp: int, new_max: int, show: bool) -> void:
	hp = new_hp
	max_hp = new_max
	if show:
		bar.visible = true
	_update_bar()


## A debuff started / ended (event "status"): frozen = icy tint + animation paused.
func on_status(key: String, on: bool, dur: float = 0.0, lv: int = 1) -> void:
	if on:
		buffs.add(key, dur, lv)
	else:
		buffs.remove(key)
	if key == "frozen":
		_restore_colors()
		if anim:
			anim.speed_scale = 0.0 if on else 1.0


## Normal attack (event "attack"): bite clip or lunge; ranged attackers also shoot a visible arrow.
func on_attack(target_view, ranged: bool, delay: float) -> void:
	if dead:
		return
	if target_view and is_instance_valid(target_view):
		_face_now(target_view.global_position)
	_play("attack", true, minf(1.3 if is_boss else 0.6, delay * 0.8))
	if ranged and get_parent() and target_view and is_instance_valid(target_view):
		var from := global_position + Vector3(0, _model_h() * 0.7, 0)
		var to: Vector3 = target_view.global_position + Vector3(0, 0.9, 0)
		Fx.projectile(get_parent(), from, to, Color(1, 0.9, 0.7), clampf(from.distance_to(to) / 30.0, 0.05, 0.4), 0.9, 0.06)
	var tw := create_tween()
	if _static:
		tw.tween_property(self, "_lunge", 1.0, 0.1)
		tw.tween_property(self, "_lunge", 0.0, 0.2)
	else:
		var fwd := Vector3(sin(model.rotation.y), 0, cos(model.rotation.y)) * (0.15 if anim else 0.35)
		var base := Vector3(0, rig.position.y, 0) if anim else Vector3.ZERO
		tw.tween_property(rig, "position", base + fwd, 0.08)
		tw.tween_property(rig, "position", base, 0.15)


## Died (event "died"): hide bar + name, stop being clickable, death clip / topple, then go away.
## fx = false: the killing melee hit shows later (hit_feel.gd) - dead now, main calls die_fx() then.
func on_died(fx: bool = true) -> void:
	if dead:
		return
	dead = true
	pick.collision_layer = 0
	if fx:
		die_fx()


func die_fx() -> void:
	if _dying or not is_inside_tree():
		return
	if _stop_t > 0.0:
		# the killing blow's hit-stop first
		var w := _stop_t
		_stop_t = 0.0
		get_tree().create_timer(w).timeout.connect(die_fx)
		return
	_dying = true
	bar.visible = false
	name_label.visible = false
	var aura = get_node_or_null("BossAura")
	if aura:
		aura.fade_out(0.8)
	var tw := create_tween()
	if anim:
		anim.speed_scale = 1.0
	if _static:
		# topple onto the side while shrinking into the ground
		tw.tween_property(rig, "rotation:z", 1.3, 0.3)
		tw.parallel().tween_property(model, "scale", model.scale * Vector3(1.2, 0.3, 1.2), 0.3)
		tw.tween_property(model, "scale", model.scale * Vector3(1.3, 0.05, 1.3), 0.2)
	elif anim and clips.has("death"):
		# fall over (death clip), then sink away
		_play("death", true, 99.0)
		tw.tween_interval(minf(1.0, anim.get_animation(clips["death"]).length))
		tw.tween_property(model, "scale", model.scale * Vector3(1.2, 0.05, 1.2), 0.3)
	else:
		tw.tween_property(model, "scale", model.scale * Vector3(1.4, 0.05, 1.4), 0.35)
	tw.tween_interval(0.2)
	tw.tween_callback(queue_free)


## A melee blade connected (hit_feel.gd; view only): white flash, a small knock-back away from the
## attacker at `from` that springs back, a squash on blob monsters, animation held still for `stop` s
## (hit-stop). Stronger on a crit, weaker on bosses. Also plays on the killing blow.
func on_impact(from: Vector3, crit: bool, stop: float) -> void:
	if not is_inside_tree() or model == null:
		return
	_white(WHITE_T_CRIT if crit else WHITE_T)
	var dir := global_position - from
	dir.y = 0.0
	dir = dir.normalized() if dir.length() > 0.01 else Vector3.ZERO
	var k := (KICK_CRIT if crit else KICK) * (0.4 if is_boss else 1.0)
	if _kick_tw and _kick_tw.is_valid():
		_kick_tw.kill()
	_kick_tw = create_tween()
	_kick_tw.tween_property(model, "position", dir * k, 0.05).set_ease(Tween.EASE_OUT)
	_kick_tw.tween_interval(stop)
	_kick_tw.tween_property(model, "position", Vector3.ZERO, 0.2).set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_IN_OUT)
	if _blob:
		_squash = SQUASH_CRIT if crit else SQUASH
		# absolute, not _apply_squash()'s *=: during a hit-stop _process skips the per-frame scale reset, so every
		# hit of a multi-hit skill multiplied the squash again and slimes ended up as flat pancakes (skill anim pass 2)
		rig.scale = Vector3(1.0 + _squash * 0.5, 1.0 - _squash * 0.45, 1.0 + _squash * 0.5)
		create_tween().tween_property(self, "_squash", 0.0, 0.28).set_delay(stop)
	if _dying:
		return
	_stop_t = maxf(_stop_t, stop)
	if anim:
		anim.speed_scale = 0.0


func _apply_squash() -> void:
	if _squash > 0.001:
		rig.scale *= Vector3(1.0 + _squash * 0.5, 1.0 - _squash * 0.45, 1.0 + _squash * 0.5)


## Additive white overlay on every mesh for `t` s (on top of the red hurt tint).
func _white(t: float) -> void:
	if _white_mat == null:
		_white_mat = StandardMaterial3D.new()
		_white_mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
		_white_mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
		_white_mat.blend_mode = BaseMaterial3D.BLEND_MODE_ADD
		_white_mat.albedo_color = Color(1, 1, 1, 0.85)
	for mi in _meshes:
		if is_instance_valid(mi):
			(mi as MeshInstance3D).material_overlay = _white_mat
	if not is_inside_tree():  # sweep: left the tree (white)
		return
	get_tree().create_timer(t).timeout.connect(_unwhite)


func _unwhite() -> void:
	for mi in _meshes:
		if is_instance_valid(mi):
			(mi as MeshInstance3D).material_overlay = null


func _face_now(p: Vector3) -> void:
	var d := p - global_position
	if Vector2(d.x, d.z).length() >= 0.01:
		_yaw_goal = atan2(d.x, d.z)


func _physics_process(delta: float) -> void:
	t += delta
	if dead:
		return
	if _net_t < 1.0:
		_net_t = minf(1.0, _net_t + delta / _net_dt)
		global_position = _net_from.lerp(_net_to, _net_t)
	# display countdown only (the server ends debuffs with a "status" event)
	for k in buffs.list.keys():
		buffs.list[k]["t"] = maxf(0.05, float(buffs.list[k]["t"]) - delta)
	# hit-stop (on_impact): the pose holds still
	if _stop_t > 0.0:
		_stop_t -= delta
		if _stop_t > 0.0:
			return
		if anim:
			anim.speed_scale = 0.0 if buffs.has("frozen") else 1.0
	if not is_nan(_yaw_goal):
		model.rotation.y = lerp_angle(model.rotation.y, _yaw_goal, 0.25)
	_animate()
	if _blob:
		_apply_squash()


func _update_bar() -> void:
	bar.set_ratio(float(hp) / float(max_hp))


func _animate() -> void:
	if anim:
		_anim_lock = maxf(0.0, _anim_lock - get_physics_process_delta_time())
		if _anim_lock <= 0.0:
			_play("walk" if _moving else "idle")
		return
	if _static:
		_animate_static()
		return
	var kind := model_kind()
	var sp := 9.0 if _moving else 4.0
	match kind:
		"slime", "king":
			var s := sin(t * sp) * (0.08 if _moving else 0.04)
			rig.scale = Vector3(1.0 + s, 1.0 - s, 1.0 + s)
		"bunny":
			rig.position.y = absf(sin(t * 9.0)) * 0.3 if _moving else 0.0
		"fly", "bird":
			rig.position.y = sin(t * 3.0) * 0.12
			var wl = rig.get_node_or_null("WingL")
			var wr = rig.get_node_or_null("WingR")
			var flap := 40.0 if kind == "fly" else 9.0
			if wl and wr:
				wl.rotation.z = sin(t * flap) * 0.6
				wr.rotation.z = -sin(t * flap) * 0.6
		"wolf":
			rig.position.y = absf(sin(t * 10.0)) * 0.07 if _moving else 0.0
			var tail = rig.get_node_or_null("Tail")
			if tail:
				tail.rotation.y = sin(t * (12.0 if _moving else 5.0)) * 0.45
		_:
			# QA-sweep kinds (tin soldier, frog, top, jelly...) have their own motion in chibi.gd
			if not Chibi.animate(kind, rig, t, _moving):
				rig.position.y = absf(sin(t * sp)) * 0.05


## Static Tripo mesh: breathing idle, bouncy walk with a waddle, lunge + bite dip on attack,
## recoil on hit (flyers hover and bank instead of bouncing).
func _animate_static() -> void:
	var tid := model_kind().substr(6)
	var fly := tid in TRIPO_FLY
	var hop := 0.14 if tid in TRIPO_HOP else 0.06
	var body_h := _model_h()
	if fly:
		rig.position = Vector3(0, sin(t * 2.6) * 0.1 * scl, 0)
		rig.rotation.z = sin(t * (3.2 if _moving else 1.4)) * (0.12 if _moving else 0.05)
		rig.rotation.x = 0.12 if _moving else 0.0
		var f := 1.0 + sin(t * 5.0) * 0.03
		rig.scale = Vector3(f, 1.0, 1.0)
	elif _moving:
		rig.position = Vector3(0, absf(sin(t * 9.0)) * hop * body_h, 0)
		rig.rotation.z = sin(t * 9.0) * 0.07
		rig.rotation.x = 0.0
		rig.scale = Vector3.ONE
	else:
		# breathing: chest swells a little
		var b := sin(t * 2.4) * 0.025
		rig.position = Vector3.ZERO
		rig.rotation.z = 0.0
		rig.rotation.x = 0.0
		rig.scale = Vector3(1.0 + b * 0.5, 1.0 + b, 1.0 + b * 0.5)
	# attack: lunge forward (model +Z) and dip the head; hit: rock back
	rig.position.z += _lunge * 0.35 * body_h - _recoil * 0.12 * body_h
	rig.rotation.x += _lunge * 0.3 - _recoil * 0.25


func _flash() -> void:
	for mi in _meshes:
		# textured models (GLB) keep their texture and turn red; chibi parts turn plain red
		_set_color(mi, _colors[_meshes.find(mi)] * Color(1, 0.35, 0.35) if (anim or _static) else Color(1, 0.35, 0.35))
	if not is_inside_tree():  # sweep: left the tree (flash)
		return
	get_tree().create_timer(0.12).timeout.connect(_unflash)


func _unflash() -> void:
	if not is_instance_valid(self):
		return
	_restore_colors()


## Normal colours, or icy blue while frozen.
func _restore_colors() -> void:
	var ice: bool = buffs.has("frozen")
	for i in _meshes.size():
		var c: Color = _colors[i]
		_set_color(_meshes[i], c.lerp(Color(0.55, 0.8, 1.0), 0.7) if ice else c)


## Heavy attack wind-up (event "mob_tele", server/mob_ai.gd "heavy"): danger circle that fills over the wind-up
## (boss_fx.gd), the move's name over the head, a roar, then the strike clip timed so its blow ("hit_at" of the clip)
## lands when the circle is full.
func on_telegraph(e: Dictionary) -> void:
	if dead or not is_inside_tree():
		return
	var at: Vector3 = e["at"]
	var tm := float(e.get("time", 1.5))
	var col := Color(str(e.get("color", "#ff4a2a")))
	if not bool(e.get("self", false)):
		_face_now(at)
	var w := _world()
	var near := Vector3.INF
	if w and "player" in w and w.player and is_instance_valid(w.player):
		near = w.player.global_position
	BossFx.telegraph(get_parent(), at, float(e.get("r", 4.0)), tm, col, w, near)
	var nm := str(e.get("name", ""))
	if nm != "":
		Fx.float_text(get_parent(), global_position + Vector3(0, _model_h() + 1.2, 0), nm + "!", col, 64)
	var role := str(e.get("clip", "slam"))
	var start := 0.0
	if anim and clips.has(role):
		start = maxf(0.0, tm - float(e.get("hit_at", 0.6)) * anim.get_animation(clips[role]).length)
	if start > 0.15 and clips.has("windup"):
		_play("windup", true, start)
	if start <= 0.0:
		_telegraph_strike(role)
	else:
		get_tree().create_timer(start).timeout.connect(_telegraph_strike.bind(role))


func _telegraph_strike(role: String) -> void:
	if dead or not is_inside_tree():
		return
	if clips.has(role):
		_play(role, true, anim.get_animation(clips[role]).length * 0.9)
	elif clips.has("attack"):
		_play("attack", true, 0.6)


## The main scene (camera rig + the local player): the first ancestor with a "rig" property.
func _world() -> Node:
	var n := get_parent()
	while n and not ("rig" in n):
		n = n.get_parent()
	return n
