extends RefCounted

# Autorité unique du **déroulé** d'une attaque: qui frappe, quand, où va
# l'acteur, et à quels moments il est cuirassé ou intouchable.
#
# --- Pourquoi une frise --------------------------------------------------------
#
# L'ancien pattern décrivait une séquence par une poignée de durées éparses
# (`windup`, `escape_grace_duration`, `active_duration`) plus un mouvement dont
# la durée ne s'écrivait nulle part: elle se déduisait de la distance et de la
# vitesse. Un seul coup, un seul avertissement, un seul déplacement.
#
# Ici, une attaque est une **frise**: cinq pistes de blocs posés dans le temps.
# Coups, avertissements, mouvements, cuirasse, invulnérabilité. Un curseur la
# parcourt de gauche à droite et ce qu'il traverse agit.
#
# --- Un seul moteur ------------------------------------------------------------
#
# Les patterns écrits à l'ancienne ne sont pas joués à l'ancienne: ils sont
# **compilés** en frise au chargement. Il n'existe donc qu'un chemin d'exécution,
# et la compatibilité n'est pas une promesse mais une conséquence. C'est aussi ce
# qui rend le passage en mode avancé gratuit: la frise existe déjà, il suffit de
# l'écrire dans la donnée pour qu'elle devienne modifiable.
#
# --- Ce que cette classe ne fait pas -------------------------------------------
#
# Elle ne touche à aucune entité. Elle reçoit un curseur et un delta, elle rend
# un **plan** pour cette image: l'acteur l'applique. C'est ce qui permet à la
# créature et au héros de partager le déroulé sans partager leur corps — ils
# n'infligent pas les dégâts de la même façon et ne poursuivent pas la même
# cible, mais ils suivent la même chorégraphie.

const SHAKE_FREQUENCY := 5.57
const SHAKE_AMPLITUDE := 3.0

# --- Compilation ---------------------------------------------------------------

# Durée réelle d'un déplacement à l'ancienne, celle qui ne s'écrivait nulle part.
# Une charge accélère depuis l'arrêt et plafonne à sa vitesse; les autres partent
# déjà lancées.
static func movement_duration(distance: float, speed: float, acceleration: float,
		movement_type: String) -> float:
	if distance <= 0.0 or speed <= 0.0:
		return 0.0
	if movement_type == "charge" and acceleration > 0.0:
		var ramp_distance := speed * speed / (2.0 * acceleration)
		if ramp_distance >= distance:
			return sqrt(2.0 * distance / acceleration)
		return speed / acceleration + (distance - ramp_distance) / speed
	return distance / speed

# L'angle de déplacement remplace les directions nommées: 0° va vers la cible,
# 180° s'en éloigne, 90° passe à côté. Les trois cas que connaissait l'ancien
# vocabulaire y tiennent tous.
static func movement_angle_of(movement_type: String) -> float:
	match movement_type:
		"retreat":
			return 180.0
		"strafe":
			return 90.0
	return 0.0

static func movement_manner_of(movement_type: String) -> String:
	match movement_type:
		"charge":
			return "charge"
		"leap":
			return "leap"
	return "linear"

static func _zone_of(pattern: Dictionary, anchor: String, tracking: float) -> Dictionary:
	return {
		"area_shape": str(pattern.get("area_shape", "sector")),
		"range": float(pattern.get("range", 44.0)),
		"angle": float(pattern.get("angle", 90.0)),
		"area_width": float(pattern.get("area_width", 0.0)),
		"anchor": anchor,
		"tracking_speed": tracking,
	}

# Traduit un pattern écrit à l'ancienne en frise équivalente. Chaque durée
# implicite devient un bloc explicite, et rien d'autre ne change.
static func compile_simple(pattern: Dictionary) -> Dictionary:
	var windup := maxf(0.0, float(pattern.get("windup", 0.0)))
	var attack_type := str(pattern.get("type", "burst"))
	var sticky := attack_type == "sticky"
	var swipe := attack_type == "swipe"
	var grace := maxf(0.0, float(pattern.get("escape_grace_duration", 0.0))) if sticky else 0.0
	var movement_type := str(pattern.get("movement_type", "stationary"))
	var moves := movement_type not in ["stationary", "unaffected"]
	var travel := movement_duration(float(pattern.get("movement_distance", 0.0)),
		float(pattern.get("movement_speed", 0.0)),
		float(pattern.get("movement_acceleration", 0.0)), movement_type) if moves else 0.0
	var release := windup + grace

	var anchor := str(pattern.get("anchor", "auto"))
	if anchor == "auto":
		anchor = "world" if sticky else "actor"
	var hits: Array[Dictionary] = []
	var hit_zone := _zone_of(pattern, anchor, 0.0)
	hit_zone["delivery"] = str(pattern.get("delivery", "direct"))
	hit_zone["projectile"] = str(pattern.get("projectile", ""))
	hit_zone["damage_scale"] = 1.0
	# Rien n'arretait une lame avant ce reglage: zero reste donc le defaut,
	# et un pattern compile se joue exactement comme avant.
	hit_zone["stop_after_hits"] = 0.0
	hit_zone["kill_cost"] = 1.0
	hit_zone["start"] = release if swipe else release + travel
	if swipe:
		# Le balayage frappe en cadence pendant toute sa durée; les autres
		# n'ont qu'un instant d'impact, et un instant n'a pas de longueur.
		hit_zone["end"] = release + maxf(0.05, float(pattern.get("active_duration", 0.0)))
		hit_zone["mode"] = "rotating"
		hit_zone["rotation_speed"] = float(pattern.get("rotation_speed", 0.0))
		hit_zone["tick_interval"] = float(pattern.get("tick_interval", 0.0))
	else:
		hit_zone["end"] = hit_zone["start"]
		hit_zone["mode"] = "instant"
		hit_zone["rotation_speed"] = 0.0
		hit_zone["tick_interval"] = 0.0
	hits.append(hit_zone)

	# L'avertissement suivait le coup sans jamais pouvoir en différer. Il garde
	# ici sa forme, en trois temps au plus: la poursuite, le verrouillage, puis
	# la frappe elle-même.
	var warnings: Array[Dictionary] = []
	if windup > 0.0:
		var pursuit := _zone_of(pattern, anchor, float(pattern.get("tracking_speed", 0.0)) if sticky else 0.0)
		pursuit["start"] = 0.0
		pursuit["end"] = windup
		warnings.append(pursuit)
	if grace > 0.0:
		var locked := _zone_of(pattern, anchor, 0.0)
		locked["locked"] = true
		locked["start"] = windup
		locked["end"] = release
		warnings.append(locked)
	if float(hit_zone["end"]) > release:
		var struck := _zone_of(pattern, anchor, 0.0)
		struck["start"] = release
		struck["end"] = float(hit_zone["end"])
		warnings.append(struck)

	var moves_track: Array[Dictionary] = []
	var retreat_distance := maxf(0.0, float(pattern.get("movement_windup_distance", 0.0)))
	if retreat_distance > 0.0 and movement_type == "charge" and windup > 0.0:
		# Le recul de charge n'était pas un temps à part: il se parcourait
		# pendant l'avertissement, en tremblant. C'est exactement un bloc.
		moves_track.append({
			"start": 0.0, "end": windup, "distance": retreat_distance, "angle": 180.0,
			"manner": "shake", "arc_height": 0.0, "frequency": SHAKE_FREQUENCY,
			"amplitude": SHAKE_AMPLITUDE, "visual_only": false, "mirror": false,
		})
	if moves and travel > 0.0:
		moves_track.append({
			"start": release, "end": release + travel,
			"distance": float(pattern.get("movement_distance", 0.0)),
			"angle": movement_angle_of(movement_type),
			"manner": movement_manner_of(movement_type),
			"arc_height": float(pattern.get("movement_arc_height", 0.0)),
			"frequency": 0.0, "amplitude": 0.0, "visual_only": false,
			"mirror": movement_type == "strafe",
		})

	return _finish_timeline({
		"hits": hits, "warnings": warnings, "moves": moves_track,
		"armor": [] as Array[Dictionary], "iframes": [] as Array[Dictionary],
		"free_movement": movement_type == "unaffected",
	})

# La frise d'un pattern, quel que soit le mode dans lequel il est écrit.
static func of(pattern: Dictionary) -> Dictionary:
	if str(pattern.get("mode", "simple")) != "advanced":
		return compile_simple(pattern)
	var authored: Dictionary = pattern.get("timeline", {})
	return _finish_timeline({
		"hits": _blocks(authored.get("hits", [])),
		"warnings": _blocks(authored.get("warnings", [])),
		"moves": _blocks(authored.get("moves", [])),
		"armor": _blocks(authored.get("armor", [])),
		"iframes": _blocks(authored.get("iframes", [])),
		"free_movement": bool(pattern.get("free_movement", false)),
	})

static func _blocks(raw: Variant) -> Array[Dictionary]:
	var result: Array[Dictionary] = []
	if raw is Array:
		for entry: Variant in raw:
			if entry is Dictionary:
				result.append(entry as Dictionary)
	result.sort_custom(func(a: Dictionary, b: Dictionary) -> bool:
		return float(a.get("start", 0.0)) < float(b.get("start", 0.0)))
	return result

# La durée totale n'est pas écrite: elle est ce que les blocs occupent. Une
# valeur écrite se serait désynchronisée au premier bloc déplacé.
static func _finish_timeline(timeline: Dictionary) -> Dictionary:
	var duration := 0.0
	for track: String in ["hits", "warnings", "moves", "armor", "iframes"]:
		for block: Dictionary in timeline[track]:
			duration = maxf(duration, float(block.get("end", block.get("start", 0.0))))
	timeline["duration"] = duration
	# La portée qui décide du déclenchement quand le pattern n'en fixe aucune:
	# la plus longue des zones qui blessent, puisque c'est la première à mordre.
	var reach := 0.0
	for block: Dictionary in timeline["hits"]:
		reach = maxf(reach, float(block.get("range", 0.0)))
	timeline["hit_reach"] = reach
	return timeline

# --- Lecture -------------------------------------------------------------------

# La premiere zone de la frise est-elle posee dans le monde? C'est elle qui
# decide ou la sequence plante son origine au depart.
static func starts_anchored(timeline: Dictionary) -> bool:
	for track: String in ["warnings", "hits"]:
		for block: Dictionary in timeline.get(track, []):
			return str(block.get("anchor", "actor")) == "world"
	return false

static func begin() -> Dictionary:
	return {"elapsed": 0.0, "fired": {}, "ticks": {}, "travelled": {}, "spent": {}}

## Ce qu'un bloc peut encaisser de contacts avant que la lame soit arretee.
##
## Un corps rencontre coute 1; un corps TUE coute `kill_cost`, parce qu'un
## ennemi qui tombe ne retient plus rien. Un budget de 1 donne une lame que
## le premier corps stoppe net; un budget plus large donne une lame qui
## tranche a travers.
##
## Zero veut dire « rien ne l'arrete », et c'est le defaut: aucun pattern
## ecrit avant ce reglage ne change de comportement.
static func spend_hit_budget(cursor: Dictionary, block: Dictionary, cost: float) -> bool:
	var budget := float(block.get("stop_after_hits", 0.0))
	if budget <= 0.0 or cost <= 0.0:
		return false
	var key := str(block.get("start", 0.0))
	var spent := float(cursor["spent"].get(key, 0.0)) + cost
	cursor["spent"][key] = spent
	return spent >= budget - 0.0001

## Ce que coute un corps touche: un ennemi tue retient moins la lame.
static func hit_cost(block: Dictionary, killed: bool) -> float:
	return maxf(0.0, float(block.get("kill_cost", 1.0))) if killed else 1.0

static func block_at(blocks: Array, moment: float) -> Dictionary:
	for block: Dictionary in blocks:
		var start := float(block.get("start", 0.0))
		var end := float(block.get("end", start))
		if moment >= start and (moment < end or (end <= start and moment <= start)):
			return block
	return {}

static func covers(blocks: Array, moment: float) -> bool:
	for block: Dictionary in blocks:
		if moment >= float(block.get("start", 0.0)) and moment < float(block.get("end", 0.0)):
			return true
	return false

# Fraction de la distance d'un bloc déjà parcourue à cet instant. Une charge
# part de l'arrêt et accélère, donc sa course est quadratique; tout le reste
# avance d'un pas régulier.
static func travelled_ratio(block: Dictionary, moment: float) -> float:
	var start := float(block.get("start", 0.0))
	var end := float(block.get("end", start))
	if end <= start:
		return 1.0
	var progress := clampf((moment - start) / (end - start), 0.0, 1.0)
	if str(block.get("manner", "linear")) == "charge":
		return progress * progress
	return progress

# Ce que le corps montre sans que le plan de collision bouge: la cloche d'un
# bond, le tremblement d'une secouade.
static func visual_offset(block: Dictionary, moment: float, lateral: Vector2) -> Vector2:
	var manner := str(block.get("manner", "linear"))
	if manner == "leap":
		var arc := float(block.get("arc_height", 0.0))
		if arc <= 0.0:
			return Vector2.ZERO
		return Vector2.UP * sin(travelled_ratio(block, moment) * PI) * arc
	if manner == "shake":
		var amplitude := float(block.get("amplitude", SHAKE_AMPLITUDE))
		var frequency := float(block.get("frequency", SHAKE_FREQUENCY))
		return lateral * sin(moment * TAU * frequency) * amplitude
	return Vector2.ZERO

# Le plan de l'image: ce que l'acteur doit appliquer, et rien de plus.
#
# `lateral` est la perpendiculaire au regard de l'acteur, la seule chose que
# cette classe ne peut pas connaître seule.
static func advance(cursor: Dictionary, timeline: Dictionary, delta: float,
		lateral: Vector2) -> Dictionary:
	var previous := float(cursor.get("elapsed", 0.0))
	var moment := previous + delta
	cursor["elapsed"] = moment
	var duration := float(timeline.get("duration", 0.0))

	var plan := {
		"finished": moment >= duration,
		"state": "idle",
		"telegraphing": false,
		"zone": {},
		"step": 0.0,
		"angle": 0.0,
		"mirror": false,
		"moving": false,
		"manner": "",
		"remaining": 0.0,
		"visual_offset": Vector2.ZERO,
		"rotation_step": 0.0,
		"hits": [] as Array[Dictionary],
		"armored": covers(timeline.get("armor", []), moment),
		"invulnerable": covers(timeline.get("iframes", []), moment),
	}

	var warning := block_at(timeline.get("warnings", []), moment)
	if not warning.is_empty():
		plan["telegraphing"] = true
		plan["zone"] = warning


	# Les coups: un instant se déclenche au passage du curseur, une zone qui dure
	# frappe en cadence. Le passage se teste sur l'intervalle traversé, sinon une
	# image longue enjambe l'instant sans le voir.
	var live_hit := {}
	for index: int in range(timeline.get("hits", []).size()):
		var block: Dictionary = timeline["hits"][index]
		var start := float(block.get("start", 0.0))
		var end := float(block.get("end", start))
		if end <= start:
			if previous <= start and moment >= start and not cursor["fired"].has(index):
				cursor["fired"][index] = true
				plan["hits"].append(block)
			continue
		if moment < start or moment >= end:
			continue
		live_hit = block
		var interval := maxf(0.05, float(block.get("tick_interval", 0.0)))
		var next_tick := float(cursor["ticks"].get(index, start))
		if moment >= next_tick:
			cursor["ticks"][index] = moment + interval
			plan["hits"].append(block)
		plan["rotation_step"] = deg_to_rad(float(block.get("rotation_speed", 0.0))) * delta

	if not live_hit.is_empty():
		plan["telegraphing"] = true
		if plan["zone"].is_empty():
			plan["zone"] = live_hit
	# L'instant d'un coup n'est couvert par aucun avertissement: sans cela, la
	# zone qui frappe serait resolue depuis l'acteur alors qu'elle est posee
	# dans le monde, et une zone poursuivante frapperait au mauvais endroit.
	if plan["zone"].is_empty() and not plan["hits"].is_empty():
		plan["zone"] = plan["hits"].back()

	var move := block_at(timeline.get("moves", []), moment)
	if move.is_empty():
		# Le telegraphe de trajectoire doit montrer le deplacement AVANT qu'il
		# parte: pendant l'avertissement, c'est la course entiere qui menace.
		for block: Dictionary in timeline.get("moves", []):
			if float(block.get("start", 0.0)) > moment:
				plan["angle"] = float(block.get("angle", 0.0))
				plan["mirror"] = bool(block.get("mirror", false))
				plan["remaining"] = float(block.get("distance", 0.0))
				break
	else:
		var distance := float(move.get("distance", 0.0))
		var done := float(cursor["travelled"].get(str(move.get("start", 0.0)), 0.0))
		var wanted := travelled_ratio(move, moment) * distance
		var step := maxf(0.0, wanted - done)
		cursor["travelled"][str(move.get("start", 0.0))] = wanted
		plan["moving"] = true
		plan["manner"] = str(move.get("manner", "linear"))
		plan["angle"] = float(move.get("angle", 0.0))
		plan["mirror"] = bool(move.get("mirror", false))
		plan["remaining"] = maxf(0.0, distance - wanted)
		plan["step"] = 0.0 if bool(move.get("visual_only", false)) else step
		plan["visual_offset"] = visual_offset(move, moment, lateral)

	if not plan["finished"]:
		if not live_hit.is_empty() or not plan["hits"].is_empty() or bool(plan["moving"]):
			plan["state"] = "active"
		elif bool(block_at(timeline.get("warnings", []), moment).get("locked", false)):
			plan["state"] = "grace"
		else:
			plan["state"] = "windup"
	return plan
