extends Node3D

# A disposable cartoon: no physics bodies, layout writes, damage or gameplay RNG.
# The restaurant saves before this starts; only its visual bodies are displaced.
signal finished

const WINDUP := 1.15
const RUN_TIME := 0.62
const AFTERMATH := 0.70
const LANE_RADIUS := 36.0
const HOLE_HALF_WIDTH := 64.0

var elapsed := 0.0
var wall_broken := false
var launched_count := 0
var _scene: Node2D
var _world: Node3D
var _hero: Node3D
var _camera: Camera3D
var _camera_transform := Transform3D.IDENTITY
var _scene_mode: ProcessMode
var _origin := Vector3.ZERO
var _lane_z := 0.0
var _exit_x := 0.0
var _wall: MeshInstance3D
var _wall_x := 0.0
var _originals: Dictionary = {}
var _hidden: Dictionary = {}
var _targets: Array[Dictionary] = []
var _flying: Array[Dictionary] = []
var _particles: Array[Dictionary] = []
var _random := RandomNumberGenerator.new()
var _puff_clock := 0.0
var _impact_age := -1.0
var _overlay: CanvasLayer
var _fade: ColorRect
var _restored := false
var _started := false

func begin(scene: Node2D) -> bool:
	name = "DungeonDeparture"
	process_mode = Node.PROCESS_MODE_PAUSABLE
	_world = get_parent() as Node3D
	_hero = Bridge3D.avatar_of(scene.get("player"))
	if not is_instance_valid(_hero) or not is_instance_valid(_world):
		return false
	_scene = scene
	_scene_mode = scene.process_mode
	Stage.claim("dungeon_departure", Stage.PRIORITY_CEREMONY + 1)
	_camera = Bridge3D._camera
	if is_instance_valid(_camera):
		_camera_transform = _camera.transform
	_random.randomize()
	_origin = _hero.global_position
	_lane_z = _origin.z
	var room: Rect2 = scene.get("ROOM_RECT")
	_wall_x = room.end.x
	_exit_x = room.end.x + 520.0
	_select_wall(room)
	_remember(_hero)
	# Une clé peut désigner une entité déjà libérée — un client parti cette
	# image-ci, que le pont n'élaguera qu'à sa prochaine synchronisation. Une
	# variable typée `Node2D` refusait l'affectation et le départ plantait:
	# on lit la clé sans type, et on saute ce qui n'existe plus.
	for entity: Variant in Bridge3D._avatars:
		if not is_instance_valid(entity):
			continue
		var body := Bridge3D.avatar_of(entity)
		if body != _hero and is_instance_valid(body) and body.visible:
			_add_target(body)
	if is_instance_valid(Bridge3D._tables):
		for table: Node3D in Bridge3D._tables.get_children():
			if table.visible:
				_add_target(table)
	_hide_badges(_world)
	var hud := scene.get("hud") as CanvasLayer
	if hud != null:
		_hidden[hud] = hud.visible
		hud.hide()
	_world.set_meta("departure_active", true)
	scene.process_mode = Node.PROCESS_MODE_DISABLED
	_hero.process_mode = Node.PROCESS_MODE_PAUSABLE
	Model3DFactory.show_state(_hero, "walk", "walk", "", true)
	_build_overlay()
	_started = true
	# Le cri de guerre, avant meme la chauffe: le meme que celui des monstres
	# qui reperent le heros, retourne contre eux. Un evenement a lui, pour que
	# l'auteur puisse le changer sans toucher au cri des monstres.
	AudioDirector.play_event("departure_shout", {"emitter": "depart", "scene": AUDIO_SCENE})
	AudioDirector.play_event("departure_windup", {"emitter": "depart", "scene": AUDIO_SCENE})
	return true

func _input(_event: InputEvent) -> void:
	if _started:
		get_viewport().set_input_as_handled()

func _select_wall(room: Rect2) -> void:
	# La course est une LIGNE DROITE, coute que coute. Elle devait autrefois
	# eviter la porte en obliquant vers le mur le plus proche; ce detour
	# distrayait plus que la continuite du gag. On garde donc le couloir du
	# heros, et on cherche seulement quel morceau de mur il traverse — s'il en
	# traverse un. Face a la porte, il n'y a rien a defoncer: il la franchit.
	_lane_z = _origin.z
	_wall = null
	for child: Node in _world.get_children():
		if not child is MeshInstance3D or not child.mesh is BoxMesh:
			continue
		var body := child as MeshInstance3D
		var size: Vector3 = body.mesh.size
		if size.x > 40.0 or absf(body.position.x - room.end.x) > 24.0:
			continue
		if absf(_lane_z - body.position.z) <= size.z * 0.5:
			_wall = body
			_wall_x = body.position.x
			return

func _remember(body: Node3D) -> void:
	if not _originals.has(body):
		_originals[body] = {"transform": body.transform, "visible": body.visible,
			"process_mode": body.process_mode}

func _hide_badges(node: Node) -> void:
	for child: Node in node.get_children():
		if child is InfoBadge3D or child is AttackField3D:
			_hidden[child] = child.visible
			child.hide()
			_hide_badges(child)
		elif child is CanvasLayer:
			_hidden[child] = child.visible
			child.hide()
		else:
			_hide_badges(child)

func _add_target(body: Node3D) -> void:
	_remember(body)
	var bounds := _visual_bounds(body)
	_targets.append({"body": body, "bounds": bounds, "hit": false})

func _visual_bounds(body: Node3D) -> AABB:
	var bounds := AABB(body.global_position - Vector3(18, 0, 18), Vector3(36, 50, 36))
	for child: Node in body.get_children():
		if child is InfoBadge3D or child is AttackField3D:
			continue
		if child is MeshInstance3D:
			bounds = bounds.merge(child.global_transform * child.get_aabb())
		elif child is Node3D:
			bounds = bounds.merge(_visual_bounds(child))
	return bounds

## Cette mise en scene est cosmetique, mais ses sons ont besoin d'un jeton de
## scene comme les autres: sa restauration ne doit rien laisser trainer.
const AUDIO_SCENE := "depart"
## La course se signale une seule fois, au franchissement de la chauffe.
var _sprint_sounded := false

func _build_overlay() -> void:
	_overlay = CanvasLayer.new()
	_overlay.layer = 90
	add_child(_overlay)
	_fade = ColorRect.new()
	_fade.color = Color(0.06, 0.035, 0.025, 0.0)
	_fade.mouse_filter = Control.MOUSE_FILTER_STOP
	_fade.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
	_overlay.add_child(_fade)

func _process(delta: float) -> void:
	if not _started or _restored:
		return
	if not is_instance_valid(_hero):
		_complete()
		return
	elapsed += delta
	_update_particles(delta)
	_update_flying(delta)
	if elapsed < WINDUP:
		_wind_up(delta)
	else:
		# Au PASSAGE de la chauffe a la course, pas a chaque image de course.
		if not _sprint_sounded:
			_sprint_sounded = true
			AudioDirector.play_event("departure_sprint", {"emitter": "depart", "scene": AUDIO_SCENE})
		_sprint(delta)
	if _impact_age >= 0.0:
		_impact_age += delta
		if is_instance_valid(_camera):
			var force := 8.0 * maxf(0.0, 1.0 - _impact_age / 0.28)
			_camera.transform = _camera_transform
			_camera.position += Vector3(sin(_impact_age * 113.0), cos(_impact_age * 97.0), 0) * force
	_fade.color.a = smoothstep(WINDUP + RUN_TIME + AFTERMATH - 0.22,
		WINDUP + RUN_TIME + AFTERMATH, elapsed)
	if elapsed >= WINDUP + RUN_TIME + AFTERMATH:
		_complete()

func _wind_up(delta: float) -> void:
	var charge := elapsed / WINDUP
	var shake := sin(elapsed * lerpf(35.0, 115.0, charge)) * charge
	_hero.global_position = _origin + Vector3(shake * 6.0 - charge * 12.0,
		absf(sin(elapsed * 39.0)) * charge * 5.0, 0)
	_hero.rotation = Vector3(0, PI * 0.5, 0.14 * charge + shake * 0.06)
	_hero.scale = Vector3(1.0 + charge * 0.16, 1.0 - charge * 0.19, 1.0 + charge * 0.12)
	_set_run_speed(lerpf(1.6, 6.0, charge))
	_puff_clock -= delta
	if _puff_clock <= 0.0:
		_puff_clock = lerpf(0.13, 0.045, charge)
		for side: float in [-1.0, 1.0]:
			_smoke(_origin + Vector3(_random.randf_range(-20, 12), 7, side * 18),
				Vector3(-25, 30, side * 30), lerpf(10, 21, charge), 0.6)

func _run_position(progress: float) -> Vector3:
	# Ligne droite: seul X avance. Le couloir `_lane_z` est celui du depart.
	return Vector3(lerpf(_origin.x, _exit_x, progress), 0, _lane_z)

func _sprint(_delta: float) -> void:
	var previous := _hero.global_position
	var progress := clampf((elapsed - WINDUP) / RUN_TIME, 0.0, 1.0)
	var at := _run_position(progress)
	_hero.global_position = at + Vector3(0, 4.0 + absf(sin(elapsed * 95.0)) * 9.0, 0)
	_hero.rotation = Vector3(0, PI * 0.5, -0.40)
	_hero.scale = Vector3(1.22, 0.83, 1.0)
	_set_run_speed(7.0)
	if previous.x < at.x:
		# Sample the swept path, so even a slow frame leaves a continuous trail
		# and catches every crossed object instead of jumping past it.
		var steps := maxi(1, ceili((at.x - previous.x) / 24.0))
		for index: int in steps:
			var sample := previous.lerp(at, float(index + 1) / float(steps))
			_smoke(sample + Vector3(-24, 10, _random.randf_range(-16, 16)),
				Vector3(-45, 24, _random.randf_range(-18, 18)), _random.randf_range(15, 27), 0.85)
			if index % 2 == 0:
				_speed_line(sample)
		_launch_crossed(previous, at)
	# Face a la porte, aucun pan de mur ne tombe, mais l'explosion de briques
	# a lieu quand meme: c'est le gag, et l'auteur le veut dans les deux cas.
	# `_break_wall` sait deja se passer d'un mur a masquer.
	if not wall_broken and at.x >= _wall_x:
		_break_wall()
	if progress >= 1.0:
		_hero.hide()

func _set_run_speed(speed: float) -> void:
	var tree := _hero.get_meta("animation_tree", null) as AnimationTree
	if is_instance_valid(tree):
		tree.set("parameters/LocomotionSpeed/scale", speed)
	else:
		var animation := _hero.get_meta("animation_player", null) as AnimationPlayer
		if is_instance_valid(animation):
			animation.speed_scale = speed

func _launch_crossed(from: Vector3, to: Vector3) -> void:
	for target: Dictionary in _targets:
		if target.hit:
			continue
		var bounds: AABB = target.bounds
		# Test the whole swept segment against the object's actual visual footprint.
		var footprint := AABB(Vector3(bounds.position.x, -1000, bounds.position.z),
			Vector3(bounds.size.x, 2000, bounds.size.z)).grow(LANE_RADIUS)
		if not footprint.has_point(to) and not footprint.intersects_segment(from, to):
			continue
		target.hit = true
		AudioDirector.play_event("departure_launch", {"emitter": "depart", "scene": AUDIO_SCENE})
		var body: Node3D = target.body
		var side := -1.0 if body.global_position.z < to.z else 1.0
		_flying.append({"body": body, "velocity": Vector3(_random.randf_range(270, 490),
			_random.randf_range(290, 440), side * _random.randf_range(70, 180)),
			"spin": Vector3(_random.randf_range(-3, 3), side * 3.0, -_random.randf_range(4, 8))})
		launched_count += 1
		for puff: int in 4:
			_smoke(body.global_position + Vector3(0, 18, 0),
				Vector3(_random.randf_range(-45, 90), 65, side * 60), 15, 0.5)

func _update_flying(delta: float) -> void:
	for flight: Dictionary in _flying:
		# Sans type: un corps en vol peut etre libere avant la fin du depart.
		var held: Variant = flight.body
		if not is_instance_valid(held):
			continue
		var body := held as Node3D
		flight.velocity.y -= 580.0 * delta
		body.global_position += flight.velocity * delta
		body.rotation += flight.spin * delta

func _break_wall() -> void:
	AudioDirector.play_event("departure_wall_break", {"emitter": "depart", "scene": AUDIO_SCENE})
	wall_broken = true
	_impact_age = 0.0
	if is_instance_valid(_wall):
		_remember(_wall)
		_wall.hide()
		var size: Vector3 = _wall.mesh.size
		var low := _wall.position.z - size.z * 0.5
		var high := _wall.position.z + size.z * 0.5
		# Keep both intact sides and a ragged brick lintel over the actual breach.
		_wall_piece(low, _lane_z - HOLE_HALF_WIDTH, size.y, size.y * 0.5)
		_wall_piece(_lane_z + HOLE_HALF_WIDTH, high, size.y, size.y * 0.5)
		for brick: int in 8:
			var tall := 18.0 + float(brick % 3) * 8.0
			_wall_piece(_lane_z - HOLE_HALF_WIDTH + brick * 16.0,
				_lane_z - HOLE_HALF_WIDTH + (brick + 1) * 16.0, tall, size.y - tall * 0.5)
	for index: int in 38:
		var tint := Color("c57950") if index % 3 else Color("e8bd86")
		var brick := _box(Vector3(_random.randf_range(10, 21), _random.randf_range(8, 15),
			_random.randf_range(15, 27)), tint)
		brick.position = Vector3(_wall_x, _random.randf_range(15, 115),
			_lane_z + _random.randf_range(-HOLE_HALF_WIDTH, HOLE_HALF_WIDTH))
		_particles.append({"body": brick, "velocity": Vector3(_random.randf_range(320, 760),
			_random.randf_range(100, 370), _random.randf_range(-160, 160)),
			"spin": Vector3(_random.randf_range(-8, 8), _random.randf_range(-9, 9), -8),
			"age": 0.0, "life": 1.05, "gravity": 680.0, "growth": 0.0})
	for index: int in 22:
		_smoke(Vector3(_wall_x, _random.randf_range(12, 100), _lane_z + _random.randf_range(-55, 55)),
			Vector3(_random.randf_range(110, 330), _random.randf_range(15, 110),
			_random.randf_range(-90, 90)), _random.randf_range(20, 36), 0.85)

func _wall_piece(low: float, high: float, tall: float, elevation: float) -> void:
	if high - low < 0.5:
		return
	var piece := _box(Vector3(_wall.mesh.size.x, tall, high - low), Color.WHITE)
	piece.material_override = _wall.material_override
	piece.position = Vector3(_wall.position.x, elevation, (low + high) * 0.5)

func _box(size: Vector3, tint: Color) -> MeshInstance3D:
	var mesh := BoxMesh.new()
	mesh.size = size
	return _mesh(mesh, tint)

func _mesh(mesh: Mesh, tint: Color) -> MeshInstance3D:
	var body := MeshInstance3D.new()
	body.mesh = mesh
	body.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
	var material := StandardMaterial3D.new()
	material.albedo_color = tint
	material.roughness = 1.0
	body.material_override = material
	add_child(body)
	return body

func _smoke(at: Vector3, velocity: Vector3, radius: float, life: float) -> void:
	var mesh := SphereMesh.new()
	mesh.radius = radius
	mesh.height = radius * 1.6
	mesh.radial_segments = 12
	mesh.rings = 6
	var body := _mesh(mesh, Color("e7ddc5").darkened(_random.randf_range(0.0, 0.13)))
	body.position = at
	body.material_override.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
	body.material_override.albedo_color.a = 0.82
	_particles.append({"body": body, "velocity": velocity, "spin": Vector3.ZERO,
		"age": 0.0, "life": life, "gravity": -12.0, "growth": 1.3})

func _speed_line(at: Vector3) -> void:
	var body := _box(Vector3(_random.randf_range(75, 135), 2.5, 3), Color("fff0be"))
	body.position = at + Vector3(-45, _random.randf_range(18, 58), _random.randf_range(-22, 22))
	body.material_override.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
	body.material_override.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
	_particles.append({"body": body, "velocity": Vector3(-100, 0, 0), "spin": Vector3.ZERO,
		"age": 0.0, "life": 0.19, "gravity": 0.0, "growth": 0.0})

func _update_particles(delta: float) -> void:
	for index: int in range(_particles.size() - 1, -1, -1):
		var particle: Dictionary = _particles[index]
		particle.age += delta
		var body: MeshInstance3D = particle.body
		if particle.age >= particle.life:
			body.queue_free()
			_particles.remove_at(index)
			continue
		particle.velocity.y -= float(particle.gravity) * delta
		body.position += particle.velocity * delta
		body.rotation += particle.spin * delta
		body.scale = Vector3.ONE * (1.0 + float(particle.growth) * float(particle.age))
		var material := body.material_override as StandardMaterial3D
		if material.transparency == BaseMaterial3D.TRANSPARENCY_ALPHA:
			material.albedo_color.a = 0.82 * (1.0 - smoothstep(0.25, 1.0, float(particle.age) / float(particle.life)))

func _complete() -> void:
	_restore()
	finished.emit()

func _restore() -> void:
	# La remise en place est silencieuse: elle defait un decor, elle ne raconte
	# rien. On ferme seulement ce qui appartenait a la mise en scene.
	AudioDirector.release_scene(AUDIO_SCENE)
	if _restored or not _started:
		return
	_restored = true
	for body: Variant in _originals:
		if is_instance_valid(body):
			var original: Dictionary = _originals[body]
			body.transform = original.transform
			body.visible = original.visible
			body.process_mode = original.process_mode
	for item: Variant in _hidden:
		if is_instance_valid(item):
			item.visible = _hidden[item]
	if is_instance_valid(_hero):
		var animation := _hero.get_meta("animation_player", null) as AnimationPlayer
		if is_instance_valid(animation):
			animation.speed_scale = 1.0
		Model3DFactory.show_state(_hero, "idle", "idle", "", true)
	if is_instance_valid(_camera):
		_camera.transform = _camera_transform
	if is_instance_valid(_world):
		_world.remove_meta("departure_active")
	if is_instance_valid(_scene):
		_scene.process_mode = _scene_mode
	Stage.release("dungeon_departure")

func _exit_tree() -> void:
	_restore()
