extends Node

# Le pont 2D → 3D.
#
# **Le jeu ne change pas.** Le restaurant, le donjon, les clients, la brigade, la
# console, la cuisine, les sauvegardes: tout tourne exactement comme dans
# `big-egg/`, avec les mêmes scripts et les mêmes règles. Ce pont ne fait que
# deux choses:
#
#   1. il **éteint le dessin 2D** de ce qui a un corps dans le monde;
#   2. il **donne à chaque entité un avatar 3D** qu'il garde collé à elle.
#
# La 2D reste donc la seule autorité: positions, collisions, portées, plan de la
# salle, tout est décidé par le jeu. La 3D ne décide de rien, elle montre. C'est
# l'inverse d'un portage, et c'est ce qui permet à cette branche de suivre le
# jeu sans se recoder à chaque changement.
#
# Une seule règle d'unités: **1 pixel du jeu = 1 unité 3D** (`Shape3DFactory`).

const SHAPES_PATH := "res://data/shapes_3d.json"

# Miroir de l'outil PHP `tools/shapes-3d-manager`. Les deux doivent dire la même
# chose, sinon une forme saisie dans l'outil est refusée ici.
const SHAPE_DEFAULTS := {
	"enemies": {"shape": "capsule", "tall": 92.0},
	"customer_profiles": {"shape": "capsule", "tall": 124.0},
	"employees": {"shape": "capsule", "tall": 128.0},
	"player_configs": {"shape": "capsule", "tall": 132.0},
	"restaurant_elements": {"shape": "box", "tall": 70.0},
	"ingredients": {"shape": "sphere", "tall": 16.0},
	"recipes": {"shape": "cylinder", "tall": 10.0},
}

# Le drapeau vit dans `Render3D`, une classe nommée sans dépendance: un script
# du jeu qui nommait cet autoload ne compilait pas, Godot résolvant les scripts
# d'autoload avant de les enregistrer.
var active: bool:
	get: return Render3D.active
	set(value): Render3D.active = value

var shapes: Dictionary = {}
# Un afficheur de diagnostic, activable par F3. Il montre ce qu'un headless ne
# peut pas montrer: ce que le clavier envoie réellement à la fenêtre, et à
# quelle cadence la scène tourne. Une manette qui répond quand le clavier se
# tait désigne soit le focus de la fenêtre, soit une cadence effondrée.
var show_diagnostics := false
var _hud: Label

# Les tables n'ont aucun noeud dans le jeu: la salle les dessine elle-même. Le
# pont ne pouvait donc pas les voir, et le restaurant paraissait sans table. On
# les lit dans `restaurant.tables`, comme le dessin 2D.
const TABLE_TALL := 46.0
const STOOL_TALL := 30.0
# Le rayon dessiné d'un ramassable dans le jeu 2D. Sa collision fait 13, mais
# c'est le dessin qu'on met en volume.
const PICKUP_RADIUS := 10.0
const DISH_TOP_SIZE := 42.0
const PICKUP_TOP_SIZE := 23.0
const DISH_CLEAN_COLOR := Color("ded9cf")
const DISH_DIRTY_COLOR := Color("897466")
# Le pas du grain au sol, en unités de monde. 160 px place un repère un peu plus
# souvent que la largeur d'un personnage: assez pour lire le déplacement, assez
# rare pour rester discret.
const GROUND_TILE := 160.0
# La taille anatomique se trouve légèrement au-dessus de la moitié du corps.
# Le curseur et le secteur du héros emploient exactement ce même plan.
const PLAYER_AIM_HEIGHT_RATIO := 0.52
const TARGET_GLOW_AVAILABLE := Color(1.0, 0.94, 0.84, 0.58)
const TARGET_GLOW_UNAVAILABLE := Color(1.0, 0.42, 0.40, 0.56)
const RESTAURANT_DOOR_TALL := 112.0
const RESTAURANT_DOOR_SPEED := 0.12
const RESTAURANT_SIGN_OPEN := Color("7dffb2")
const RESTAURANT_SIGN_CLOSED := Color("ff8f87")

var _root: Node3D
var _tables: Node3D
var _avatars: Dictionary = {}      # Node2D -> Node3D
var _camera: Camera3DRig
var _scene_key := ""
var _target_glow_materials: Dictionary = {}
var _dirty_plate_texture: ImageTexture

# Élévation absolue du plan de visée d'une entité. Le pivot des avatars est à
# leurs pieds; leur métadonnée `tall` fonctionne pour les primitives comme pour
# les sprites 3D.
func aim_elevation(entity: Node2D) -> float:
	var avatar: Node3D = _avatars.get(entity)
	if avatar == null or not is_instance_valid(avatar):
		return SHAPE_DEFAULTS.player_configs.tall * PLAYER_AIM_HEIGHT_RATIO
	return avatar.global_position.y \
		+ float(avatar.get_meta("tall", SHAPE_DEFAULTS.player_configs.tall)) * PLAYER_AIM_HEIGHT_RATIO

# Position de plan réellement pointée dans l'image 3D. Le paramètre d'écran
# sert aussi aux contrôles automatisés; en jeu, la position courante de la
# souris est l'unique source.
func pointer_plan_position(entity: Node2D, screen_position: Variant = null) -> Vector2:
	if not active or _camera == null or not is_instance_valid(_camera):
		return Vector2.INF
	var pointer := get_viewport().get_mouse_position()
	if screen_position is Vector2:
		pointer = screen_position
	return _camera.screen_to_plan(pointer, aim_elevation(entity))

# Point d'ancrage d'interface réellement projeté depuis le volume ciblé. Les
# zones d'interaction invisibles reprennent leur meuble physique, exactement
# comme le halo, puis le popup se pose au-dessus de sa silhouette.
func interaction_screen_position(entity: Node2D) -> Vector2:
	if not active or _camera == null or not is_instance_valid(_camera) \
			or entity == null or not is_instance_valid(entity):
		return Vector2.INF
	var subject := entity
	if entity is InteractiveProp:
		subject = (entity as InteractiveProp).target_highlight_subject()
	var avatar: Node3D = _avatars.get(subject)
	if avatar == null or not is_instance_valid(avatar):
		return Vector2.INF
	var top := avatar.global_position + Vector3(
		0.0, float(avatar.get_meta("tall", 60.0)) + 18.0, 0.0)
	if _camera.is_position_behind(top):
		return Vector2.INF
	return _camera.unproject_position(top)

func _ready() -> void:
	shapes = _read_shapes()
	# Le pont se réveille après le jeu: la scène doit exister avant qu'on la
	# regarde. `_process` s'en charge, il n'y a rien à attendre ici.
	set_process(true)

func _read_shapes() -> Dictionary:
	if not FileAccess.file_exists(SHAPES_PATH):
		return {}
	var parsed: Variant = JSON.parse_string(FileAccess.get_file_as_string(SHAPES_PATH))
	return parsed if parsed is Dictionary else {}

# La forme d'une entité: sa fiche si elle en a une, sinon le défaut de sa
# collection. Rien n'est jamais sans forme — un pont qui refuse d'afficher une
# entité parce qu'on ne l'a pas encore saisie ne sert à rien.
func shape_of(collection: String, id: String) -> Dictionary:
	var fallback: Dictionary = SHAPE_DEFAULTS.get(collection, {"shape": "box", "tall": 32.0})
	var declared: Variant = (shapes.get("entries", {}) as Dictionary).get("%s.%s" % [collection, id], null)
	if not declared is Dictionary:
		return fallback.duplicate()
	var result := fallback.duplicate()
	for key: String in (declared as Dictionary):
		result[key] = (declared as Dictionary)[key]
	if not Shape3DFactory.KINDS.has(str(result.get("shape", ""))):
		result["shape"] = fallback.shape
	return result

# --- La boucle ----------------------------------------------------------------

func _unhandled_input(event: InputEvent) -> void:
	if event is InputEventKey and (event as InputEventKey).pressed and (event as InputEventKey).keycode == KEY_F2:
		style = "sprites" if style == "primitives" else "primitives"
		# Les corps se refont au style suivant: on vide, `_sync` repeuple.
		for entity: Variant in _avatars.keys():
			var old_body: Node3D = _avatars[entity]
			if old_body != null and is_instance_valid(old_body):
				old_body.get_parent().remove_child(old_body)
				old_body.queue_free()
		_avatars.clear()
		print("BIG EGG 3D — affichage: %s" % style)
		return
	if event is InputEventKey and (event as InputEventKey).pressed and (event as InputEventKey).keycode == KEY_F3:
		show_diagnostics = not show_diagnostics
		if _hud != null and is_instance_valid(_hud):
			_hud.visible = show_diagnostics

func _process(_delta: float) -> void:
	_update_diagnostics()
	if not active:
		return
	var scene := get_tree().current_scene
	if scene == null:
		return
	# La clé porte la scène **et la forme du monde**.
	#
	# Une salle de donjon suivante ne change pas de scène: `advance_to_next_room()`
	# vide la salle et recalcule `arena` sur place. En ne surveillant que le
	# chemin du fichier, le pont gardait les murs et le cadrage de la salle
	# précédente — dans une salle qui défile, le joueur passait donc au travers
	# d'un mur resté à l'ancienne taille, et la caméra ne le suivait pas puisque
	# son axe avait été déduit d'une arène qui n'existait plus.
	var key := "%s|%s" % [scene.scene_file_path, world_bounds(scene)]
	if key != _scene_key:
		_scene_key = key
		_rebuild(scene)
	_sync()

func _rebuild(scene: Node) -> void:
	_avatars.clear()
	if _root != null and is_instance_valid(_root):
		_root.get_parent().remove_child(_root)
		_root.queue_free()
	_root = null
	_camera = null
	# Une scène sans monde (menu, arbre d'upgrades) reste en 2D: il n'y a rien à
	# mettre en volume, et l'interface doit rester lisible.
	if not (scene is Node2D) or not (scene.has_method("_draw") or scene.get("player") != null):
		return
	_root = Node3D.new()
	_root.name = "Bridge3D"
	scene.add_child(_root)
	_build_environment()
	_build_ground(scene)
	_build_walls(scene)
	_build_camera(scene)
	_tables = Node3D.new()
	_tables.name = "Tables"
	_root.add_child(_tables)

func _build_environment() -> void:
	var sun := DirectionalLight3D.new()
	sun.rotation_degrees = Vector3(-58.0, -38.0, 0.0)
	sun.light_energy = 1.15
	sun.shadow_enabled = true
	_root.add_child(sun)
	var holder := WorldEnvironment.new()
	var environment := Environment.new()
	environment.background_mode = Environment.BG_COLOR
	environment.background_color = Color("0b0d16")
	environment.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR
	environment.ambient_light_color = Color("6a7390")
	environment.ambient_light_energy = 0.55
	holder.environment = environment
	_root.add_child(holder)

# Le sol et les murs viennent des mêmes rectangles que le dessin 2D: on ne
# redessine pas un plan, on met en volume celui que le jeu connaît déjà.
func _build_ground(scene: Node) -> void:
	var bounds := world_bounds(scene)
	if scene.has_method("should_draw_floor"):
		# Donjon: le sol prend la couleur du biome, comme son dessin.
		var floor_tint: Variant = scene.call("_biome_color", "floor_color", "18233a")
		# Le grain se répète toutes les `GROUND_TILE` unités: dans une salle qui
		# défile, c'est la seule chose qui dise au joueur qu'il avance.
		_root.add_child(Shape3DFactory.slab(bounds.grow(160.0), 8.0, -4.0,
			floor_tint if floor_tint is Color else Color("18233a"), GROUND_TILE))
	else:
		# Le sol doit couvrir le trottoir aussi: les passants y marchent, et sans
		# lui ils traversaient le vide au-dessus de la rue.
		# La rue est déclarée par la salle: on ne suppose plus qu'elle longe le
		# mur nord. Le sol doit la couvrir, sinon les passants marchent au-dessus
		# du vide.
		var walks := _sidewalk_rects(scene)
		var ground := bounds.grow(60.0)
		for walk: Rect2 in walks:
			ground = ground.merge(walk.grow(40.0))
		_root.add_child(Shape3DFactory.slab(ground, 8.0, -4.0, Color("2b2135")))
		# Les zones nommées de la salle, en dalles teintées: elles disent la même
		# chose que les rectangles dessinés du jeu 2D.
		for pair: Array in [
			[scene.get("KITCHEN_RECT"), Color(0.14, 0.22, 0.28)],
			[scene.get("DINING_RECT"), Color(0.44, 0.31, 0.22)],
		]:
			if pair[0] is Rect2:
				_root.add_child(Shape3DFactory.slab(pair[0] as Rect2, 2.0, 1.0, pair[1]))
		for walk: Rect2 in walks:
			_root.add_child(Shape3DFactory.slab(walk, 2.0, 1.0, Color(0.36, 0.30, 0.46)))

const WALL_TALL := 150.0
const DUNGEON_WALL_THICKNESS := 52.0

# Les murs, en volume. Ils viennent des mêmes rectangles que le dessin 2D: on ne
# redessine pas un plan, on met en relief celui que le jeu connaît déjà.
func _build_walls(scene: Node) -> void:
	var tint := Color("7a5235")
	if scene.has_method("should_draw_floor"):
		# Donjon: quatre murs sur le pourtour de l'arène, couleur du biome.
		var stone: Variant = scene.call("_biome_color", "wall_color", "53617b")
		tint = stone if stone is Color else Color("53617b")
		var arena := world_bounds(scene)
		var half := DUNGEON_WALL_THICKNESS * 0.5
		for rect: Rect2 in [
			Rect2(arena.position.x - half, arena.position.y - half, arena.size.x + DUNGEON_WALL_THICKNESS, DUNGEON_WALL_THICKNESS),
			Rect2(arena.position.x - half, arena.end.y - half, arena.size.x + DUNGEON_WALL_THICKNESS, DUNGEON_WALL_THICKNESS),
			Rect2(arena.position.x - half, arena.position.y, DUNGEON_WALL_THICKNESS, arena.size.y),
			Rect2(arena.end.x - half, arena.position.y, DUNGEON_WALL_THICKNESS, arena.size.y),
		]:
			_add_wall(rect, tint)
		return
	# Restaurant: le mur nord est percé par la porte, donc deux tronçons.
	var room: Variant = scene.get("ROOM_RECT")
	if not room is Rect2:
		return
	var bounds: Rect2 = room
	var wall_thickness := float(scene.get("WALL_THICKNESS"))
	var door_w := float(scene.get("DOOR_WIDTH"))
	var door_along := float(scene.call("street_door"))
	var low := door_along - door_w * 0.5
	var high := door_along + door_w * 0.5
	var walls: Array[Rect2] = []
	if bool(scene.call("street_is_vertical")):
		# Rue sur un flanc: la porte perce ce mur-là, en deux tronçons.
		var walls_room: Rect2 = scene.get("ROOM_RECT")
		var pierced := walls_room.position.x if str(scene.get("STREET_SIDE")) == "west" else walls_room.end.x - wall_thickness
		walls = [
			Rect2(pierced, walls_room.position.y, wall_thickness, maxf(0.0, low - walls_room.position.y)),
			Rect2(pierced, high, wall_thickness, maxf(0.0, walls_room.end.y - high)),
			# Le mur nord est traité à part: il porte la vitrine du comptoir.
			Rect2(walls_room.position.x, walls_room.end.y - wall_thickness, walls_room.size.x, wall_thickness),
			Rect2(walls_room.end.x - wall_thickness if str(scene.get("STREET_SIDE")) == "west" else walls_room.position.x, walls_room.position.y, wall_thickness, walls_room.size.y),
		]
	else:
		walls = [
			Rect2(bounds.position.x, bounds.position.y, maxf(0.0, low - bounds.position.x), wall_thickness),
			Rect2(high, bounds.position.y, maxf(0.0, bounds.end.x - high), wall_thickness),
			Rect2(bounds.position.x, bounds.end.y - wall_thickness, bounds.size.x, wall_thickness),
			Rect2(bounds.position.x, bounds.position.y, wall_thickness, bounds.size.y),
			Rect2(bounds.end.x - wall_thickness, bounds.position.y, wall_thickness, bounds.size.y),
		]
	for rect: Rect2 in walls:
		_add_wall(rect, tint)
	if bool(scene.call("street_is_vertical")):
		_build_shopfront(scene, tint, wall_thickness)
	_build_restaurant_entrance(scene, wall_thickness)

# Le mur nord, avec sa vitrine.
#
# Le comptoir takeout garde sa façade nord tandis que la rue passe à l'est: ses
# clients attendent donc derrière ce mur, invisibles depuis la salle. On abaisse
# le tronçon qui leur fait face — une vitrine de comptoir, par laquelle on voit
# la file et on sert.
func _build_shopfront(scene: Node, tint: Color, wall_thickness: float) -> void:
	var room: Variant = scene.get("ROOM_RECT")
	if not room is Rect2:
		return
	var bounds: Rect2 = room
	var counter: Vector2 = scene.call("takeout_position")
	var glass := 210.0
	var left := clampf(counter.x - glass * 0.5, bounds.position.x, bounds.end.x)
	var right := clampf(counter.x + glass * 0.5, bounds.position.x, bounds.end.x)
	_add_wall(Rect2(bounds.position.x, bounds.position.y, maxf(0.0, left - bounds.position.x), wall_thickness), tint)
	_add_wall(Rect2(right, bounds.position.y, maxf(0.0, bounds.end.x - right), wall_thickness), tint)
	# L'allège de la vitrine: assez basse pour voir par-dessus, assez haute pour
	# rester un mur.
	_add_wall(Rect2(left, bounds.position.y, maxf(0.0, right - left), wall_thickness), tint.darkened(0.15), 34.0)

# Deux vantaux coulissent dans les murs déjà percés par la simulation 2D. Leur
# état ne décide rien: la collision `RestaurantDoor` reste l'autorité; le pont
# ne fait que montrer le même ouvert/fermé en volume.
func _build_restaurant_entrance(scene: Node, wall_thickness: float) -> void:
	if not scene.has_method("dining_is_open") or not scene.has_method("street_is_vertical"):
		return
	var room: Variant = scene.get("ROOM_RECT")
	if not room is Rect2:
		return
	var bounds := room as Rect2
	var vertical := bool(scene.call("street_is_vertical"))
	var side := str(scene.get("STREET_SIDE"))
	var door_width := float(scene.get("DOOR_WIDTH"))
	var center_plan := Vector2.ZERO
	var outward := Vector3.ZERO
	if vertical:
		center_plan = Vector2(
			bounds.position.x + wall_thickness * 0.5 if side == "west" \
			else bounds.end.x - wall_thickness * 0.5,
			float(scene.call("street_door")))
		outward = Vector3.LEFT if side == "west" else Vector3.RIGHT
	else:
		center_plan = Vector2(float(scene.call("street_door")), bounds.position.y + wall_thickness * 0.5)
		outward = Vector3(0.0, 0.0, -1.0)

	var entrance := Node3D.new()
	entrance.name = "RestaurantEntrance"
	entrance.position = Shape3DFactory.to_3d(center_plan)
	entrance.set_meta("vertical_wall", vertical)
	_root.add_child(entrance)

	var clear_width := maxf(40.0, door_width - 18.0)
	var leaf_width := clear_width * 0.5
	for index: int in 2:
		var sign := -1.0 if index == 0 else 1.0
		var leaf_size := Vector3(wall_thickness * 0.72, RESTAURANT_DOOR_TALL, leaf_width) \
			if vertical else Vector3(leaf_width, RESTAURANT_DOOR_TALL, wall_thickness * 0.72)
		var closed := Vector3(0.0, RESTAURANT_DOOR_TALL * 0.5, sign * leaf_width * 0.5) \
			if vertical else Vector3(sign * leaf_width * 0.5, RESTAURANT_DOOR_TALL * 0.5, 0.0)
		var opened := Vector3(0.0, RESTAURANT_DOOR_TALL * 0.5, sign * (door_width * 0.5 + leaf_width * 0.5)) \
			if vertical else Vector3(sign * (door_width * 0.5 + leaf_width * 0.5), RESTAURANT_DOOR_TALL * 0.5, 0.0)
		var leaf := _entrance_box("DoorLeaf%d" % index, leaf_size, closed, Color("335c67"))
		leaf.set_meta("closed_position", closed)
		leaf.set_meta("open_position", opened)
		entrance.add_child(leaf)

	var post_size := Vector3(wall_thickness + 10.0, RESTAURANT_DOOR_TALL + 10.0, 12.0) \
		if vertical else Vector3(12.0, RESTAURANT_DOOR_TALL + 10.0, wall_thickness + 10.0)
	for side_sign: float in [-1.0, 1.0]:
		var post_position := Vector3(0.0, RESTAURANT_DOOR_TALL * 0.5, side_sign * door_width * 0.5) \
			if vertical else Vector3(side_sign * door_width * 0.5, RESTAURANT_DOOR_TALL * 0.5, 0.0)
		entrance.add_child(_entrance_box("DoorFrame", post_size, post_position, Color("d9a066")))
	var lintel_size := Vector3(wall_thickness + 10.0, 12.0, door_width + 12.0) \
		if vertical else Vector3(door_width + 12.0, 12.0, wall_thickness + 10.0)
	entrance.add_child(_entrance_box("DoorLintel", lintel_size,
		Vector3(0.0, RESTAURANT_DOOR_TALL + 6.0, 0.0), Color("d9a066")))

	var housing_size := Vector3(8.0, 34.0, 136.0) if vertical else Vector3(136.0, 34.0, 8.0)
	var housing := _entrance_box("EntranceSignHousing", housing_size,
		outward * 7.0 + Vector3(0.0, RESTAURANT_DOOR_TALL + 36.0, 0.0), Color("241c25"))
	entrance.add_child(housing)
	var label := Label3D.new()
	label.name = "EntranceSign"
	label.billboard = BaseMaterial3D.BILLBOARD_ENABLED
	label.no_depth_test = true
	label.font_size = 64
	label.pixel_size = 0.34
	label.outline_size = 12
	label.outline_modulate = Color(0.03, 0.02, 0.04, 0.96)
	label.position = outward * 15.0 + Vector3(0.0, RESTAURANT_DOOR_TALL + 36.0, 0.0)
	entrance.add_child(label)
	var glow := OmniLight3D.new()
	glow.name = "EntranceSignLight"
	glow.position = outward * 24.0 + Vector3(0.0, RESTAURANT_DOOR_TALL + 36.0, 0.0)
	glow.omni_range = 150.0
	glow.light_energy = 0.8
	entrance.add_child(glow)

	var initially_open := bool(scene.call("dining_is_open"))
	entrance.set_meta("door_open_amount", 1.0 if initially_open else 0.0)
	_apply_restaurant_entrance(entrance, initially_open, true)

func _entrance_box(node_name: String, size: Vector3, at: Vector3, tint: Color) -> MeshInstance3D:
	var mesh := BoxMesh.new()
	mesh.size = size
	var instance := MeshInstance3D.new()
	instance.name = node_name
	instance.mesh = mesh
	instance.position = at
	var material := StandardMaterial3D.new()
	material.albedo_color = tint
	material.roughness = 0.72
	instance.material_override = material
	return instance

func _sync_restaurant_entrance(scene: Node) -> void:
	if scene == null or not scene.has_method("dining_is_open") \
			or _root == null or not is_instance_valid(_root):
		return
	var entrance := _root.get_node_or_null("RestaurantEntrance") as Node3D
	if entrance == null:
		return
	_apply_restaurant_entrance(entrance, bool(scene.call("dining_is_open")), false)

func _apply_restaurant_entrance(entrance: Node3D, opened: bool, snap: bool) -> void:
	var target_amount := 1.0 if opened else 0.0
	var amount := target_amount if snap else move_toward(
		float(entrance.get_meta("door_open_amount", target_amount)), target_amount,
		RESTAURANT_DOOR_SPEED)
	entrance.set_meta("door_open", opened)
	entrance.set_meta("door_open_amount", amount)
	for index: int in 2:
		var leaf := entrance.get_node_or_null("DoorLeaf%d" % index) as MeshInstance3D
		if leaf == null:
			continue
		var closed: Vector3 = leaf.get_meta("closed_position", leaf.position)
		var open_position: Vector3 = leaf.get_meta("open_position", leaf.position)
		leaf.position = closed.lerp(open_position, amount)
	var tint := RESTAURANT_SIGN_OPEN if opened else RESTAURANT_SIGN_CLOSED
	var label := entrance.get_node_or_null("EntranceSign") as Label3D
	if label != null:
		label.text = "OUVERT" if opened else "FERMÉ"
		label.modulate = tint
	var housing := entrance.get_node_or_null("EntranceSignHousing") as MeshInstance3D
	if housing != null:
		var material := housing.material_override as StandardMaterial3D
		if material != null:
			material.albedo_color = tint.darkened(0.72)
			material.emission_enabled = true
			material.emission = tint
			material.emission_energy_multiplier = 2.2
	var glow := entrance.get_node_or_null("EntranceSignLight") as OmniLight3D
	if glow != null:
		glow.light_color = tint

func _add_wall(rect: Rect2, tint: Color, tall: float = WALL_TALL) -> void:
	if rect.size.x <= 0.5 or rect.size.y <= 0.5:
		return
	var mesh := BoxMesh.new()
	mesh.size = Vector3(rect.size.x, tall, rect.size.y)
	var instance := MeshInstance3D.new()
	instance.mesh = mesh
	var material := StandardMaterial3D.new()
	material.albedo_color = tint
	material.roughness = 0.9
	instance.material_override = material
	instance.position = Shape3DFactory.to_3d(rect.position + rect.size * 0.5, tall * 0.5)
	_root.add_child(instance)

# L'emprise du monde: l'arène du donjon si elle existe, sinon la salle.
func world_bounds(scene: Node) -> Rect2:
	var arena: Variant = scene.get("arena")
	if arena is Rect2:
		return arena as Rect2
	var room: Variant = scene.get("ROOM_RECT")
	if not room is Rect2:
		return Rect2(0, 0, 1920, 1080)
	# La rue fait partie de ce qu'il faut voir: la file d'attente et les passants
	# y vivent, et le magasin s'ouvre dessus.
	var bounds: Rect2 = room
	for walk: Rect2 in _sidewalk_rects(scene):
		bounds = bounds.merge(walk.grow(30.0))
	return bounds

# Physical world bounds can include long off-screen spawn paths. A scene may
# publish a tighter composition for its camera without shrinking floors, walls
# or simulation space. Dungeons keep the physical bounds unchanged.
func camera_bounds(scene: Node) -> Rect2:
	if scene != null and scene.has_method("camera_frame_rect"):
		var framed: Variant = scene.call("camera_frame_rect")
		if framed is Rect2 and (framed as Rect2).size.x > 1.0 \
				and (framed as Rect2).size.y > 1.0:
			return framed as Rect2
	return world_bounds(scene)

func _sidewalk_rects(scene: Node) -> Array[Rect2]:
	var result: Array[Rect2] = []
	if scene.has_method("sidewalk_rects"):
		var declared: Variant = scene.call("sidewalk_rects")
		if declared is Array:
			for candidate: Variant in declared:
				if candidate is Rect2:
					result.append(candidate)
	elif scene.has_method("street_rect"):
		var legacy: Variant = scene.call("street_rect")
		if legacy is Rect2:
			result.append(legacy)
	return result

func _build_camera(scene: Node) -> void:
	_camera = Camera3DRig.new()
	_camera.name = "Camera3D"
	_root.add_child(_camera)
	# L'axe se déduit de l'arène, sans interroger `DiveMap`: cet appel tirait
	# `GameState` dans la compilation de l'autoload et cassait toute la chaîne.
	var axis := "none"
	if scene.has_method("should_draw_floor"):
		var base: Variant = scene.get("ARENA_RECT")
		var live: Variant = scene.get("arena")
		if base is Rect2 and live is Rect2:
			if (live as Rect2).size.x > (base as Rect2).size.x + 1.0:
				axis = "horizontal"
			elif (live as Rect2).size.y > (base as Rect2).size.y + 1.0:
				axis = "vertical"
	var follow: Node2D = scene.get("player") as Node2D
	_camera.setup(camera_bounds(scene), axis, follow)
	# La caméra 2D d'origine cède la place, sans être détruite: la branche doit
	# pouvoir repasser en 2D d'un drapeau.
	for child: Node in scene.get_children():
		if child is Camera2D:
			(child as Camera2D).enabled = false

# --- Les avatars --------------------------------------------------------------

func _sync() -> void:
	if _root == null or not is_instance_valid(_root):
		return
	_sync_restaurant_entrance(get_tree().current_scene)
	_sync_tables()
	var highlighted_target := _highlighted_target()
	var highlighted_entity := highlighted_target.get("entity") as Node2D
	var highlighted_color: Color = highlighted_target.get("color", TARGET_GLOW_AVAILABLE)
	var seen := {}
	for entity: Node2D in _entities():
		seen[entity] = true
		var avatar: Node3D = _avatars.get(entity)
		if avatar == null or not is_instance_valid(avatar):
			avatar = _spawn_avatar(entity)
			if avatar == null:
				continue
			_avatars[entity] = avatar
		# Le seul travail de chaque frame: recopier la position. Le jeu 2D a
		# déjà tout décidé — à une nuance près, ci-dessous.
		avatar.position = _placement_of(entity, avatar)
		avatar.visible = entity.visible
		_refresh_entity_appearance(entity, avatar)
		_refresh_target_glow(avatar, entity == highlighted_entity, highlighted_color)
		_face_avatar(entity, avatar)
		_refresh_badge(entity, avatar)
		_refresh_attack_field(entity, avatar)
	# Une entité disparue emporte son avatar: sans ça un monstre abattu
	# laisserait sa boîte au milieu de l'arène.
	for entity: Variant in _avatars.keys():
		if seen.has(entity) and is_instance_valid(entity):
			continue
		var orphan: Node3D = _avatars[entity]
		if orphan != null and is_instance_valid(orphan):
			orphan.get_parent().remove_child(orphan)
			orphan.queue_free()
		_avatars.erase(entity)

# Ce qui mérite un corps. On passe par les groupes du jeu quand ils existent, et
# par le type sinon: aucune liste d'identifiants à tenir à jour.
func _entities() -> Array[Node2D]:
	var result: Array[Node2D] = []
	var scene := get_tree().current_scene
	if scene == null:
		return result
	# On balaie l'arbre par TYPE. Les groupes ne couvrent pas tout: un client
	# n'est dans aucun groupe `customer`, et chercher un nom de groupe supposé
	# est un lien qui casse en silence — c'est ce qui rendait les clients et la
	# brigade entièrement invisibles.
	_collect(scene, result)
	return result

func _collect(node: Node, into: Array[Node2D]) -> void:
	for child: Node in node.get_children():
		if child is Node2D and _is_entity(child as Node2D) and not into.has(child):
			into.append(child as Node2D)
		_collect(child, into)

func _is_entity(node: Node2D) -> bool:
	# A hit zone can be an InteractiveProp without having a body of its own. In
	# 3D it points to its physical owner; materialising it would turn an invisible
	# interaction proxy into the very floor rectangle this bridge must remove.
	if node is InteractiveProp:
		return (node as InteractiveProp).draw_body
	return node is Player or node is Customer \
		or node is Employee or node is Dish or node is Enemy \
		or node is IngredientPickup

func _highlighted_target() -> Dictionary:
	var current_player := get_tree().get_first_node_in_group("player") as Player
	if current_player == null or not is_instance_valid(current_player):
		return {}
	var selected := current_player.get_contextual_interactable()
	if selected == null or not is_instance_valid(selected):
		return {}
	var actionable := current_player.target_is_actionable(selected)
	var explains_unavailable := selected.has_method("allows_unavailable_prompt") \
		and bool(selected.call("allows_unavailable_prompt"))
	if not actionable and not explains_unavailable:
		return {}
	var color := TARGET_GLOW_AVAILABLE if actionable else TARGET_GLOW_UNAVAILABLE
	if selected is InteractiveProp:
		var prop := selected as InteractiveProp
		color = prop.target_highlight_color_for(actionable)
		return {"entity": prop.target_highlight_subject(), "color": color}
	return {"entity": selected, "color": color}

func _refresh_target_glow(avatar: Node3D, active_now: bool, glow_color: Color) -> void:
	var effective_color := glow_color if active_now else Color.TRANSPARENT
	var previous_color: Color = avatar.get_meta("target_glow_color", Color.TRANSPARENT)
	if bool(avatar.get_meta("target_glowing", false)) == active_now \
			and previous_color.is_equal_approx(effective_color):
		return
	avatar.set_meta("target_glowing", active_now)
	avatar.set_meta("target_glow_color", effective_color)
	_apply_target_glow_to(avatar, active_now, glow_color)

func _apply_target_glow_to(node: Node, active_now: bool, glow_color: Color) -> void:
	# Badges and attack fields are interface feedback around an entity, not part
	# of its body. They keep their own colors while the body is shaded.
	if node.name == "Badge" or node.name == "AttackField":
		return
	if node is Sprite3D:
		var sprite := node as Sprite3D
		if not sprite.has_meta("target_glow_base_modulate"):
			sprite.set_meta("target_glow_base_modulate", sprite.modulate)
		var base: Color = sprite.get_meta("target_glow_base_modulate", Color.WHITE)
		var tint := Color.WHITE.lerp(Color(glow_color.r, glow_color.g, glow_color.b, 1.0), 0.42)
		var luminous := Color(tint.r * 1.22, tint.g * 1.22, tint.b * 1.22, 1.0)
		sprite.modulate = base * Color.WHITE.lerp(luminous, glow_color.a) if active_now else base
	elif node is GeometryInstance3D:
		(node as GeometryInstance3D).material_overlay = target_glow_material(glow_color) if active_now else null
	for child: Node in node.get_children():
		_apply_target_glow_to(child, active_now, glow_color)

func target_glow_material(glow_color: Color = TARGET_GLOW_AVAILABLE) -> StandardMaterial3D:
	var key := glow_color.to_html(true)
	var cached := _target_glow_materials.get(key) as StandardMaterial3D
	if cached != null:
		return cached
	var material := StandardMaterial3D.new()
	material.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
	material.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
	material.cull_mode = BaseMaterial3D.CULL_DISABLED
	material.albedo_color = glow_color
	material.emission_enabled = true
	material.emission = Color(glow_color.r, glow_color.g, glow_color.b, 1.0)
	material.emission_energy_multiplier = 1.55
	_target_glow_materials[key] = material
	return material

func _spawn_avatar(entity: Node2D) -> Node3D:
	var form := {"shape": "box", "tall": 32.0}
	var footprint := Vector2(32, 32)
	var tint := Color("8ecae6")
	var top_visual: Array = []

	if entity is Player:
		form = shape_of("player_configs", str(DataCatalog.active_player_config_id))
		var radius: float = (entity as Player).body_radius()
		footprint = Vector2(radius * 2.0, radius * 2.0)
		tint = Color("f2e8cf")
	elif entity is InteractiveProp:
		var prop := entity as InteractiveProp
		var source := str(prop.visual_source) if prop.get("visual_source") != null else ""
		form = shape_of("restaurant_elements", source)
		footprint = prop.size
		tint = prop.color
	elif entity is Enemy:
		var enemy_id := str(entity.get("enemy_id"))
		form = _varied_shape("enemies", enemy_id, shape_of("enemies", enemy_id))
		var body: float = entity.call("body_radius") if entity.has_method("body_radius") else 18.0
		footprint = Vector2(body * 2.0, body * 2.0)
		tint = _varied_tint("enemies", enemy_id, Color("ff006e"))
	elif entity is Customer:
		var profile := str(entity.get("profile_id"))
		# La graine mêle le profil et le nom du noeud: deux habitués du quartier
		# ne sont pas jumeaux, et chacun garde son allure tant qu'il est là.
		var who := "%s#%s" % [profile, entity.name]
		form = _varied_shape("customer_profiles", who, shape_of("customer_profiles", profile))
		footprint = Vector2(30.0 + _spread(who, 7) * 14.0, 30.0 + _spread(who, 7) * 14.0)
		tint = _varied_tint("customer_profiles", who, Color("ffb703"))
	elif entity is Employee:
		var staff := str(entity.get("employee_id"))
		form = _varied_shape("employees", staff, shape_of("employees", staff))
		footprint = Vector2(34, 34)
		tint = _varied_tint("employees", staff, Color("90be6d"))
	elif entity is Dish:
		form = shape_of("recipes", str(entity.get("recipe_id")))
		# A plate is a prop, not a character. Its recipe art is printed on the top
		# face and replaces the orientation eyes used by living primitives.
		form["eyes"] = 0.0
		footprint = Vector2(39, 39)
		tint = DISH_CLEAN_COLOR
		top_visual = ["recipes", str(entity.get("recipe_id")), "plated", DISH_TOP_SIZE]
	elif entity is IngredientPickup:
		# Le butin tombé. Il était reconnu comme entité mais n'avait aucune
		# branche ici: `_spawn_avatar` rendait `null`, et comme son dessin 2D est
		# éteint par la garde du pont, rien n'apparaissait — ni au sol, ni en
		# volume. Les matériaux tombaient dans le vide.
		var material := str(entity.get("ingredient_id"))
		form = shape_of("ingredients", material)
		footprint = Vector2(PICKUP_RADIUS * 2.0, PICKUP_RADIUS * 2.0)
		# `DataCatalog` a déjà converti la teinte en `Color` au chargement: la
		# relire par `color_of()`, qui attend du texte hexadécimal, rendrait
		# toujours le repli. On la prend comme le dessin 2D la prenait.
		var declared: Variant = DataCatalog.INGREDIENTS.get(material, {}).get("color", null)
		tint = declared if declared is Color else Color("ffd166")
		top_visual = ["ingredients", material, "icon", PICKUP_TOP_SIZE]
	else:
		return null

	var avatar: Node3D = null
	# Dishes and ingredients retain their 3D body in both display modes: their
	# existing 2D art is now a texture on that body, not a replacement billboard.
	if style == "sprites" and top_visual.is_empty():
		# On tente la feuille déjà produite; à défaut, le volume reprend la main.
		# Une entité sans art ne doit pas disparaître de la salle.
		var key := _visual_key(entity)
		if key.size() == 2:
			avatar = _sprite_avatar(str(key[0]), str(key[1]), float(form.get("tall", 60.0)))
	if avatar == null:
		avatar = Shape3DFactory.build(form, footprint, tint)
	if not top_visual.is_empty():
		_attach_top_visual(avatar, str(top_visual[0]), str(top_visual[1]),
			str(top_visual[2]), float(top_visual[3]))
	if entity is Dish:
		var dish_body := avatar.get_child(0) as MeshInstance3D if avatar.get_child_count() > 0 else null
		if dish_body != null:
			dish_body.name = "DishBody"
		_refresh_dish_appearance(entity as Dish, avatar)
	avatar.name = "Avatar_%s" % entity.name
	_root.add_child(avatar)
	return avatar

# Existing item art becomes a horizontal decal on the upper face of its 3D
# primitive. The transparent pixels preserve the silhouette of the mesh below.
func _attach_top_visual(avatar: Node3D, collection: String, id: String,
		state: String, shown_size: float) -> void:
	var texture := _visual_texture(collection, id, state)
	if texture == null:
		return
	var top := Sprite3D.new()
	top.name = "TopTexture"
	top.texture = texture
	top.shaded = false
	top.alpha_cut = SpriteBase3D.ALPHA_CUT_DISCARD
	top.texture_filter = BaseMaterial3D.TEXTURE_FILTER_NEAREST
	top.pixel_size = shown_size / maxf(1.0,
		maxf(float(texture.get_width()), float(texture.get_height())))
	# Sprite3D is an XY plane. A -90° pitch lays it on XZ with its front toward
	# the camera above the room.
	top.rotation_degrees.x = -90.0
	top.position.y = float(avatar.get_meta("tall", 16.0)) + 0.6
	avatar.add_child(top)

func _refresh_entity_appearance(entity: Node2D, avatar: Node3D) -> void:
	if entity is Dish:
		_refresh_dish_appearance(entity as Dish, avatar)

# A consumed meal no longer leaves the recipe image on its plate. The top swaps
# to a stained ceramic texture and the plate body darkens, so dirty dishes remain
# readable even at the restaurant camera's scale.
func _refresh_dish_appearance(dish: Dish, avatar: Node3D) -> void:
	var live_state := int(dish.state)
	if int(avatar.get_meta("dish_state", -1)) == live_state:
		return
	avatar.set_meta("dish_state", live_state)
	var top := avatar.get_node_or_null("TopTexture") as Sprite3D
	if top != null:
		top.texture = dirty_plate_texture() if dish.state == Dish.State.DIRTY \
			else _visual_texture("recipes", dish.recipe_id, "plated")
	var body := avatar.get_node_or_null("DishBody") as MeshInstance3D
	if body != null and body.material_override is StandardMaterial3D:
		(body.material_override as StandardMaterial3D).albedo_color = \
			DISH_DIRTY_COLOR if dish.state == Dish.State.DIRTY else DISH_CLEAN_COLOR

# Small code-native fallback for the state that has no authored sprite. It is
# built once, remains transparent outside the plate and deliberately uses large
# stains that survive the tilted camera.
func dirty_plate_texture() -> ImageTexture:
	if _dirty_plate_texture != null:
		return _dirty_plate_texture
	const SIDE := 128
	var image := Image.create(SIDE, SIDE, false, Image.FORMAT_RGBA8)
	image.fill(Color(0.0, 0.0, 0.0, 0.0))
	var centre := Vector2(SIDE * 0.5, SIDE * 0.5)
	for y: int in SIDE:
		for x: int in SIDE:
			var point := Vector2(float(x), float(y))
			var offset := point - centre
			var distance := offset.length()
			if distance > 58.0:
				continue
			var pixel := Color("d8d0c4") if distance < 51.0 else Color("75675f")
			var first_stain := (point - centre - Vector2(-18.0, -5.0)).length() < 17.0
			var second_stain := (point - centre - Vector2(17.0, 13.0)).length() < 12.0
			var smear := absf(offset.y + 18.0) < 5.0 and offset.x > -4.0 and offset.x < 31.0
			if first_stain or second_stain or smear:
				pixel = Color("76503a")
			image.set_pixel(x, y, pixel)
	_dirty_plate_texture = ImageTexture.create_from_image(image)
	return _dirty_plate_texture

# --- L'afficheur de diagnostic ------------------------------------------------

func _update_diagnostics() -> void:
	if not show_diagnostics:
		return
	if _hud == null or not is_instance_valid(_hud):
		var layer := CanvasLayer.new()
		layer.layer = 90
		get_tree().root.add_child(layer)
		_hud = Label.new()
		_hud.position = Vector2(20, 120)
		_hud.add_theme_font_size_override("font_size", 18)
		_hud.add_theme_color_override("font_color", Color("9dfc9d"))
		layer.add_child(_hud)
	var scene := get_tree().current_scene
	var player: Node = scene.get("player") if scene != null else null
	var vector := Input.get_vector("move_left", "move_right", "move_up", "move_down")
	var lines := [
		"F3 masque cet affichage",
		"images/s: %d" % Engine.get_frames_per_second(),
		"fenêtre active: %s" % str(DisplayServer.window_is_focused()),
		"clavier D (position physique): %s" % str(Input.is_physical_key_pressed(KEY_D)),
		"vecteur de déplacement: %.2f, %.2f" % [vector.x, vector.y],
		"manettes branchées: %d" % Input.get_connected_joypads().size(),
	]
	if player != null:
		lines.append("commandes: %s" % str(player.get("controls_enabled")))
		lines.append("vitesse du héros: %.0f" % (player.get("velocity") as Vector2).length())
	lines.append("avatars 3D: %d" % _avatars.size())
	_hud.text = "
".join(lines)

# --- Les tables ---------------------------------------------------------------
# Elles suivent le plan comme le reste: le mode construction peut les déplacer,
# une upgrade peut en ouvrir une de plus, et un tabouret retiré les fait
# disparaître. On les refait donc à partir de ce que la salle publie.
func _sync_tables() -> void:
	var scene := get_tree().current_scene
	if _tables == null or not is_instance_valid(_tables) or scene == null or not scene.has_method("visible_table_indices"):
		return
	var wanted: Array = scene.call("visible_table_indices")
	while _tables.get_child_count() < wanted.size():
		_tables.add_child(_build_table())
	for slot: int in _tables.get_child_count():
		var node: Node3D = _tables.get_child(slot)
		node.visible = slot < wanted.size()
		if not node.visible:
			continue
		var index: int = wanted[slot]
		node.position = Shape3DFactory.to_3d(scene.call("table_center", index))
		var stool: Node3D = node.get_node_or_null("Stool")
		if stool != null:
			# Le tabouret tourne autour de sa table: sa place est publiée par le
			# jeu, on ne la recalcule pas.
			var seat: Vector2 = scene.call("table_seat_position", index)
			stool.position = Shape3DFactory.to_3d(seat) - node.position

func _build_table() -> Node3D:
	var root := Node3D.new()
	var top := CylinderMesh.new()
	top.top_radius = 42.0
	top.bottom_radius = 36.0
	top.height = TABLE_TALL
	var plate := MeshInstance3D.new()
	plate.mesh = top
	var wood := StandardMaterial3D.new()
	wood.albedo_color = Color("c58c5b")
	wood.roughness = 0.9
	plate.material_override = wood
	plate.position = Vector3(0.0, TABLE_TALL * 0.5, 0.0)
	root.add_child(plate)

	var stool := Node3D.new()
	stool.name = "Stool"
	var seat_mesh := CylinderMesh.new()
	seat_mesh.top_radius = 19.0
	seat_mesh.bottom_radius = 16.0
	seat_mesh.height = STOOL_TALL
	var seat := MeshInstance3D.new()
	seat.mesh = seat_mesh
	var cloth := StandardMaterial3D.new()
	cloth.albedo_color = Color("8f5f3b")
	seat.material_override = cloth
	seat.position = Vector3(0.0, STOOL_TALL * 0.5, 0.0)
	stool.add_child(seat)
	root.add_child(stool)
	return root

# Où poser un avatar.
#
# En 2D, ce qu'on porte est dessiné **au-dessus** de son porteur par un simple
# décalage vertical: l'assiette suit le héros à `-34` px en Y. C'est une fausse
# élévation, et transposée telle quelle elle poserait le plat par terre, devant
# lui. En 3D l'élévation est réelle: l'objet porté se place sur son porteur, à
# hauteur de mains.
func _placement_of(entity: Node2D, avatar: Node3D) -> Vector3:
	var carrier: Variant = entity.get("carrier")
	if carrier is Node2D and is_instance_valid(carrier):
		var bearer: Node2D = carrier
		var lift := 44.0
		var heading := 0.0
		var bearer_avatar: Node3D = _avatars.get(bearer)
		if bearer_avatar != null and is_instance_valid(bearer_avatar):
			lift = float(bearer_avatar.get_meta("tall", 60.0)) * 0.62
			heading = bearer_avatar.rotation.y
		# Devant le porteur et décalé d'un côté: la main, pas le nombril.
		var forward := Vector2(sin(heading), cos(heading))
		var side := Vector2(forward.y, -forward.x)
		var hand := bearer.global_position + forward * 26.0 + side * 18.0
		return Shape3DFactory.to_3d(hand, lift)
	# Une assiette posée l'est **sur quelque chose**: un four, une étagère, une
	# table. En 2D la surface est suggérée par la superposition; en 3D, laissée
	# au sol, l'assiette disparaît sous le meuble qui la porte.
	if entity is Dish:
		return Shape3DFactory.to_3d(entity.global_position, _support_height(entity.global_position))
	# Le butin rebondit: sa collision suit le sol tandis que son dessin décrit une
	# parabole. En 2D c'était un décalage vertical du dessin; en 3D c'est une
	# élévation — la même information, dans la dimension qui lui revient.
	if entity is IngredientPickup:
		return Shape3DFactory.to_3d(entity.global_position, maxf(0.0, float(entity.get("magnet_visual_height"))))
	return Shape3DFactory.to_3d(entity.global_position)

# La hauteur de la surface qui porte ce point, zéro s'il n'y en a aucune.
#
# On interroge les corps déjà posés plutôt qu'une table de correspondances: un
# meuble déplacé, retiré ou agrandi emporte alors sa surface avec lui, sans que
# personne ait à s'en souvenir.
func _support_height(plan: Vector2) -> float:
	var best := 0.0
	for entity: Variant in _avatars:
		# La validité d'abord: `is` sur une instance déjà libérée plante avant
		# même que la garde ne s'exécute. C'est le piège consigné dans les
		# directives, et il s'appliquait ici mot pour mot.
		if not is_instance_valid(entity) or not (entity is InteractiveProp):
			continue
		var prop: InteractiveProp = entity
		if not prop.visible:
			continue
		var footprint := Rect2(prop.global_position - prop.size * 0.5, prop.size)
		if not footprint.has_point(plan):
			continue
		var avatar: Node3D = _avatars[entity]
		best = maxf(best, float(avatar.get_meta("tall", 0.0)))
	# Les tables n'ont pas d'avatar dans `_avatars`: elles vivent à part.
	var scene := get_tree().current_scene
	if scene != null and scene.has_method("visible_table_indices"):
		for index: Variant in scene.call("visible_table_indices"):
			var centre: Vector2 = scene.call("table_center", index)
			if plan.distance_to(centre) <= 46.0:
				best = maxf(best, TABLE_TALL)
	return best

# Où regarde une entité.
#
# `facing` n'existe que sur le héros: clients, employés et créatures ne
# tournaient donc jamais. On prend leur cap déclaré s'ils en ont un, sinon le
# sens de leur dernier déplacement — ce qui couvre tout ce qui bouge.
func _face_avatar(entity: Node2D, avatar: Node3D) -> void:
	var heading := Vector2.ZERO
	var declared: Variant = entity.get("facing")
	if declared is Vector2 and (declared as Vector2).length_squared() > 0.001:
		heading = declared
	else:
		var now := entity.global_position
		if avatar.has_meta("plan"):
			var step: Vector2 = now - (avatar.get_meta("plan") as Vector2)
			if step.length_squared() > 0.02:
				heading = step
		avatar.set_meta("plan", now)
	if heading.length_squared() <= 0.001:
		return
	# Un cap se garde: sans lissage, une entité arrêtée reviendrait au sud.
	var wanted := atan2(heading.x, heading.y)
	avatar.rotation.y = lerp_angle(avatar.rotation.y, wanted, 0.35)

# --- Ce que l'entité a à dire -------------------------------------------------

# Le badge lit ce que l'entité publie déjà — commande, patience, avancement de
# cuisson — et le montre au-dessus d'elle. Il ne calcule rien: dupliquer une
# règle ici la ferait diverger du jeu au premier changement.
func _refresh_badge(entity: Node2D, avatar: Node3D) -> void:
	var badge: InfoBadge3D = avatar.get_node_or_null("Badge")
	if badge == null:
		badge = InfoBadge3D.new()
		badge.name = "Badge"
		avatar.add_child(badge)
	var above := float(avatar.get_meta("tall", 60.0)) + 26.0
	badge.show_name(_display_name(entity) if show_names else "", above)

	if entity is Customer:
		var client := entity as Customer
		match client.state:
			Customer.State.WAITING:
				if client.shows_recipe_bubble():
					badge.show_info(DataCatalog.recipe_name(client.requested_recipe),
						clampf(client.patience / maxf(1.0, client.max_patience), 0.0, 1.0),
						Color("ffb703"), above)
					return
			Customer.State.TAKEOUT_QUEUED:
				var scene := get_tree().current_scene
				if scene != null and scene.has_method("takeout_customer_service_progress"):
					var served := float(scene.call("takeout_customer_service_progress", client))
					if served >= 0.0:
						badge.show_info("", served, Color("4ade80"), above)
						return
		badge.hide_all()
		return

	# La vie d'une créature était dessinée sous elle par `_draw()`: la garde du
	# pont l'a emportée avec le corps. Elle dit l'état du jeu, pas l'apparence —
	# sans elle un combat ne se lit plus, on frappe en espérant.
	if entity is Enemy:
		var beast := entity as Enemy
		if beast.health < beast.max_health:
			badge.show_info("", clampf(beast.health / maxf(1.0, beast.max_health), 0.0, 1.0),
				Color("ef233c"), above)
		else:
			badge.hide_all()
		return

	if entity is InteractiveProp:
		var status: Dictionary = (entity as InteractiveProp)._status()
		if status.is_empty():
			badge.hide_all()
			return
		var lines: Array = status.get("lines", [])
		var text := "
".join(PackedStringArray(lines.map(func(l: Variant) -> String: return str(l))))
		var progress := float(status.get("progress", -1.0)) if status.has("progress") else -1.0
		badge.show_info(text, progress, Color("76cfe0"), above)
		return

	badge.hide_all()

# --- Ce que l'attaque couvre --------------------------------------------------

# Le secteur d'une attaque.
#
# En 2D il était dessiné par l'entité elle-même; la garde qui éteint son corps
# l'a emporté, et le combat est devenu illisible: un monstre chargeait son coup
# sans que rien ne l'annonce. Comme le badge, ce champ **relit** ce que l'entité
# publie déjà — `telegraphing`, `attack_range`, `attack_angle`,
# `attack_direction` — et n'en décide rien.
func _refresh_attack_field(entity: Node2D, avatar: Node3D) -> void:
	if entity is Enemy:
		var beast := entity as Enemy
		var field := _field_of(avatar, "AttackField")
		if beast.telegraphing:
			var tint: Color = beast.attack_visual_color
			# Les deux alphas du dessin 2D: plus franc quand le coup part.
			tint.a = 0.48 if beast.attack_state == "active" else 0.30
			# Une attaque « sticky » se cale sur la cible au moment de l'annonce,
			# pas sur la bête: le décalage est un écart de monde, pas un local.
			var origin := beast.global_position
			if beast.attack_mode == "sticky":
				origin += beast.attack_origin_offset
			field.show_field(origin, beast.attack_direction, beast.attack_range,
				beast.attack_angle, tint)
		else:
			field.hide_field()
		# Le coup de contact n'a pas de portée à montrer, seulement un contact.
		var flash := _field_of(avatar, "DirectFlash")
		if beast.direct_flash_remaining > 0.0:
			var pulse: Color = beast.attack_visual_color
			pulse.a = 0.55
			flash.show_ring(beast.global_position, 25.0, pulse)
		else:
			flash.hide_field()
		return

	if entity is Player:
		var hero := entity as Player
		var swing := _field_of(avatar, "AttackField")
		if hero.attack_flash > 0.0:
			swing.show_field(hero.global_position, hero.facing, hero.attack_range,
				hero.attack_arc_degrees, Color(1.0, 0.9, 0.3, 0.35), aim_elevation(hero))
		else:
			swing.hide_field()

func _field_of(avatar: Node3D, key: String) -> AttackField3D:
	var field: AttackField3D = avatar.get_node_or_null(key)
	if field == null:
		field = AttackField3D.new()
		field.name = key
		avatar.add_child(field)
	return field

# --- La variété -----------------------------------------------------------
#
# La donnée d'auteur ne déclare ni couleur ni forme pour les créatures: elles
# sortaient donc toutes identiques. On les dérive de leur **identifiant**, pas
# d'un tirage: le même monstre a toujours la même allure, d'une partie à
# l'autre, et deux monstres voisins ne se ressemblent pas.
#
# La teinte part de la palette de son biome: les créatures d'une même région
# forment une famille, et chacune s'en écarte à sa façon.
#
# Tout cela reste **surchargeable** par `shapes_3d.json`: une entrée explicite
# gagne toujours. C'est ce qui permettra de retirer cette variété d'un coup si
# elle ne sert plus qu'à faire joli.
var varied := true
# Les noms au-dessus des têtes. Un aide-mémoire de développement: on les coupe
# d'un drapeau le jour où le jeu se lit sans eux.
var show_names := true

# Deux façons de montrer une entité, et une bascule entre les deux (F2):
#
#   "primitives" — des volumes: c'est ce qui prouve l'architecture, et ce qui
#                  accueillera des modèles générés le jour venu;
#   "sprites"    — les 313 visuels 2D déjà produits, dressés face à la caméra.
#
# Le second mode ne remplace pas le premier: il permet de voir la salle habillée
# de l'art existant pendant que la 3D se construit, sans rien jeter.
var style := "primitives"

# Un nombre stable tiré d'un texte, entre 0 et 1. `hash()` suffit: on ne cherche
# pas une distribution parfaite, seulement de la diversité reproductible.
static func _spread(seed_text: String, salt: int) -> float:
	var raw: int = absi(hash(seed_text + str(salt)))
	return float(raw % 1000) / 999.0

func _varied_shape(collection: String, id: String, base: Dictionary) -> Dictionary:
	if not varied:
		return base
	# Une entrée explicite l'emporte: on ne recolore pas ce que l'auteur a réglé.
	if (shapes.get("entries", {}) as Dictionary).has("%s.%s" % [collection, id]):
		return base
	var form := base.duplicate()
	var kinds := Shape3DFactory.KINDS
	form["shape"] = kinds[int(_spread(id, 1) * float(kinds.size())) % kinds.size()]
	# ±35 % d'élévation: assez pour distinguer deux silhouettes, pas assez pour
	# qu'une créature cesse de tenir dans son rôle.
	form["tall"] = float(base.get("tall", 60.0)) * (0.68 + _spread(id, 2) * 0.7)
	return form

func _varied_tint(collection: String, id: String, fallback: Color) -> Color:
	if not varied:
		return fallback
	var family := fallback
	if collection == "enemies":
		var biome := str(entry_of("enemies", id).get("biome", ""))
		if not biome.is_empty():
			family = color_of("biomes", biome, "accent_color", "31466f")
	# On s'écarte de la famille en teinte et en clarté, sans jamais délaver:
	# une créature doit rester lisible sur le sol de sa propre région.
	var shifted := Color.from_hsv(
		fposmod(family.h + (_spread(id, 3) - 0.5) * 0.34, 1.0),
		clampf(family.s * (0.75 + _spread(id, 4) * 0.6), 0.35, 1.0),
		clampf(family.v * (0.8 + _spread(id, 5) * 0.75), 0.42, 1.0))
	return shifted

# Les collections de `DataCatalog` sont des variables **statiques**: on les nomme
# explicitement plutôt que d'aller les chercher par leur nom, ce que la classe ne
# permet pas.
func entry_of(collection: String, id: String) -> Dictionary:
	var data := {}
	match collection:
		"enemies":
			data = DataCatalog.ENEMIES
		"biomes":
			data = DataCatalog.BIOMES
		"customer_profiles":
			data = DataCatalog.CUSTOMER_PROFILES
		"employees":
			data = DataCatalog.EMPLOYEES
		"restaurant_elements":
			data = DataCatalog.RESTAURANT_ELEMENTS
		_:
			return {}
	var item: Variant = data.get(id, {})
	return item as Dictionary if item is Dictionary else {}

func color_of(collection: String, id: String, field: String, fallback: String) -> Color:
	var raw := str(entry_of(collection, id).get(field, ""))
	if raw.is_empty() or not Color.html_is_valid(raw):
		return Color.from_string(fallback, Color.WHITE)
	return Color.from_string(raw, Color.WHITE)

# Le nom lisible d'une entité, tel que le jeu le traduit déjà. On ne réécrit
# aucun libellé ici: le catalogue est la source, y compris pour les langues.
func _display_name(entity: Node2D) -> String:
	if entity is Enemy:
		return DataCatalog.enemy_name(str(entity.get("enemy_id")))
	if entity is Customer:
		var shown := str(entity.get("display_name"))
		return shown if not shown.is_empty() else DataCatalog.customer_name(str(entity.get("profile_id")))
	if entity is Employee:
		return DataCatalog.employee_name(str(entity.get("employee_id")))
	return ""

# --- Le mode « visuels 2D » ---------------------------------------------------

# La feuille déjà produite, dressée face à la caméra.
#
# On ne réinvente pas la résolution: `VisualCatalog` connaît déjà la chaîne de
# repli d'une entité vers son image, et `DataCatalog.VISUALS` porte la découpe.
# On n'en prend que la **première case** — la vue sud au repos: animer une
# feuille directionnelle en 3D demanderait de rejouer la logique de
# `VisualHolder`, ce qui est un tout autre chantier.
# Chaque collection nomme son état de repos à sa façon. Demander « idle » à
# toutes rendait vide pour la plupart d'entre elles: seules les créatures, les
# clients, la brigade et le héros l'emploient. Un matériau déclare `icon`, un
# plat `plated`, un meuble `front` — et faute de le savoir, le mode « visuels 2D »
# retombait silencieusement sur les primitives pour la moitié du jeu.
const VISUAL_REST_STATE := {
	"ingredients": "icon",
	"recipes": "plated",
	"restaurant_elements": "front",
}

static func _visual_state_for(collection: String) -> String:
	return str(VISUAL_REST_STATE.get(collection, "idle"))

func _visual_texture(collection: String, id: String, state: String) -> Texture2D:
	var visual_id := VisualCatalog.resolve(collection, id, _visual_state_for(collection))
	if state != _visual_state_for(collection):
		visual_id = VisualCatalog.resolve(collection, id, state)
	if visual_id.is_empty():
		return null
	var definition := VisualCatalog.definition(visual_id)
	var path := str(definition.get("path", ""))
	if path.is_empty() or not ResourceLoader.exists(path):
		return null
	var texture: Texture2D = load(path)
	if texture == null:
		return null
	var columns: int = maxi(1, int(definition.get("columns", 1)))
	var rows: int = maxi(1, int(definition.get("rows", 1)))
	var cell := Vector2(float(texture.get_width()) / float(columns), float(texture.get_height()) / float(rows))
	var atlas := AtlasTexture.new()
	atlas.atlas = texture
	atlas.region = Rect2(Vector2.ZERO, cell)
	return atlas

func _sprite_avatar(collection: String, id: String, tall: float) -> Node3D:
	var texture := _visual_texture(collection, id, _visual_state_for(collection))
	if texture == null:
		return null

	var sprite := Sprite3D.new()
	sprite.texture = texture
	sprite.billboard = BaseMaterial3D.BILLBOARD_ENABLED
	sprite.shaded = false
	sprite.alpha_cut = SpriteBase3D.ALPHA_CUT_DISCARD
	sprite.texture_filter = BaseMaterial3D.TEXTURE_FILTER_NEAREST
	# La feuille occupe exactement la hauteur déclarée pour l'entité: sprite et
	# primitive sont alors interchangeables, et basculer d'un style à l'autre ne
	# change pas la taille de ce qu'on voit. (L'échelle mesurée du visuel, elle,
	# cale un dessin sur le corps de référence **en 2D**; l'appliquer ici aurait
	# donné deux conventions de taille selon le style d'affichage.)
	sprite.pixel_size = tall / maxf(1.0, float(texture.get_height()))
	sprite.position = Vector3(0.0, tall * 0.5, 0.0)
	var root := Node3D.new()
	root.add_child(sprite)
	root.set_meta("tall", tall)
	return root

# À quelle collection et quel identifiant appartient cette entité. C'est ce que
# le mode « visuels » a besoin de savoir, et rien de plus.
func _visual_key(entity: Node2D) -> Array:
	if entity is Player:
		return ["player_configs", str(DataCatalog.active_player_config_id)]
	if entity is Enemy:
		return ["enemies", str(entity.get("enemy_id"))]
	if entity is Customer:
		return ["customer_profiles", str(entity.get("profile_id"))]
	if entity is Employee:
		return ["employees", str(entity.get("employee_id"))]
	if entity is InteractiveProp:
		return ["restaurant_elements", str(entity.get("visual_source"))]
	# Les deux oubliés: un plat et un matériau ont leur image dans la banque, et
	# n'avaient pourtant aucune façon d'y accéder depuis ici.
	if entity is Dish:
		return ["recipes", str(entity.get("recipe_id"))]
	if entity is IngredientPickup:
		return ["ingredients", str(entity.get("ingredient_id"))]
	return []
