class_name Camera3DRig
extends Camera3D

# La caméra 3/4 orthographique de la branche 3D.
#
# **Orthographique, pas en perspective.** Le jeu est un plan vu de dessus: une
# fuite donnerait de la profondeur aux murs et le plan cesserait de se lire comme
# celui de l'éditeur. L'orthographique garde la lecture d'une carte tout en
# laissant la profondeur trier les corps — c'est le compromis 2.5D.
#
# Elle remplace la `Camera2D` de la scène, qu'elle recopie: même point de mire,
# mêmes bornes, même règle de suivi sur l'axe qui défile.

const TILT_DEGREES := 52.0
const HEIGHT := 1400.0
const MARGIN := 1.06

var focus := Vector2(960, 540)
var scroll_axis := "none"
var world := Rect2(70, 90, 1780, 910)
var target: Node2D
var follow_smoothing := 0.14

func _ready() -> void:
	projection = Camera3D.PROJECTION_ORTHOGONAL
	rotation_degrees = Vector3(-TILT_DEGREES, 0.0, 0.0)
	keep_aspect = Camera3D.KEEP_HEIGHT
	_place()

func setup(bounds: Rect2, axis: String, follow: Node2D) -> void:
	world = bounds
	scroll_axis = axis
	target = follow
	focus = bounds.position + bounds.size * 0.5
	size = framing_for(bounds, axis, _aspect())
	_place()

# Le cadrage qui fait tenir le monde à l'écran.
#
# Pour une caméra orthographique inclinée, `size` est l'étendue **verticale de
# l'image**, pas celle du sol: un sol vu en biais est comprimé de
# `sin(inclinaison)`. Une profondeur de 870 unités n'occupe donc que 870 × 0,79
# de hauteur d'image. Oublier ce facteur cadre à côté.
#
# Sur un axe qui défile, on ne cherche PAS à tout faire tenir: la caméra suivra.
static func framing_for(bounds: Rect2, axis: String, aspect: float) -> float:
	var needed_depth := bounds.size.y * sin(deg_to_rad(TILT_DEGREES))
	var needed_width := bounds.size.x / maxf(0.1, aspect)
	var wanted := 0.0
	match axis:
		"horizontal":
			wanted = needed_depth
		"vertical":
			wanted = needed_width
		_:
			wanted = maxf(needed_depth, needed_width)
	return clampf(wanted * MARGIN, 240.0, 4000.0)

func _aspect() -> float:
	var viewport := get_viewport()
	if viewport == null:
		return 16.0 / 9.0
	var rect := viewport.get_visible_rect().size
	return rect.x / maxf(1.0, rect.y)

func _process(_delta: float) -> void:
	var wanted := world.position + world.size * 0.5
	if target != null and is_instance_valid(target):
		var plan := target.global_position
		# Sur l'axe déclaré, la caméra suit le joueur, bornée au monde. Sur
		# l'autre elle reste centrée: la règle du donjon 2D, transposée.
		match scroll_axis:
			"horizontal":
				wanted.x = _framed(plan.x, world.position.x, world.end.x, size * _aspect() * 0.5)
			"vertical":
				wanted.y = _framed(plan.y, world.position.y, world.end.y, size / maxf(0.05, sin(deg_to_rad(TILT_DEGREES))) * 0.5)
	focus = focus.lerp(wanted, follow_smoothing)
	_place()

# Un monde plus étroit que la vue se centre: sans ça la caméra collerait au bord.
static func _framed(value: float, low: float, high: float, half: float) -> float:
	if high - low <= half * 2.0:
		return (low + high) * 0.5
	return clampf(value, low + half, high - half)

# Le recul horizontal qui met `focus` au centre de l'image.
#
# La caméra regarde vers -Z en piquant de `TILT_DEGREES`. Pour descendre de
# `HEIGHT`, son rayon parcourt `HEIGHT / tan(inclinaison)` en profondeur —
# **divisé**, pas multiplié. Avec la multiplication elle visait 700 unités
# au-delà du point voulu, et on ne voyait que le quart bas de la salle.
func _place() -> void:
	var pull := HEIGHT / maxf(0.05, tan(deg_to_rad(TILT_DEGREES)))
	global_position = Vector3(focus.x, HEIGHT, focus.y + pull)

# Où le regard touche le sol. Sert au contrôle: une caméra qui ne vise pas ce
# qu'elle prétend viser est invisible à la lecture du code.
func ground_aim() -> Vector2:
	var forward := -global_transform.basis.z
	if absf(forward.y) < 0.001:
		# Pas de repli complaisant: rendre `focus` ferait comparer une valeur à
		# elle-même et le contrôle passerait toujours.
		return Vector2.INF
	return Shape3DFactory.to_2d(global_position + forward * (-global_position.y / forward.y))
