using UnityEngine; using UnityEngine.Events; using UnityEngine.UI; // Base commune aux boutons à icône interchangeable (côtés, coins, ...). // // Hiérarchie attendue : // Btn_... <- un script dérivé + Button // Icon <- Image : l'icône affichée par le bouton // Selection <- le panneau des choix // 1 / 2 / 3 / ... <- Button // Icon <- Image : l'icône du choix // // Clic sur le bouton : Selection s'ouvre. // Clic sur un choix : son icône devient celle du bouton, tournée selon l'angle // du choix (+ l'angle du bouton), puis Selection se referme. // // Le nombre de choix n'est pas fixé par le code : il suit le nombre d'enfants de // Selection. Les choix sont listés dans l'Inspector : bouton, icône et angle sont // visibles et modifiables. Le menu contextuel du script les retrouve tout seuls. [DisallowMultipleComponent] public abstract class ChoiceButton : MonoBehaviour { [System.Serializable] public class Choice { public Button button; // l'objet 1, 2, 3... sous Selection public Image icon; // son enfant Icon : c'est ce sprite qui est copié public float angle; // angle appliqué à l'icône du bouton pour ce choix } [Header("Références (vide = trouvées automatiquement)")] [SerializeField] private Button button; [SerializeField] private Image icon; // enfant "Icon" du bouton [SerializeField] private GameObject selection; // enfant "Selection" [Header("Choix")] [Tooltip("Un élément par choix, dans l'ordre 1, 2, 3, ...")] [SerializeField] private Choice[] choices; [Header("Angle (en degrés)")] [Tooltip("Orientation du bouton. S'ajoute à l'angle du choix ci-dessus.")] [SerializeField] private float angle; [Tooltip("Orienter aussi les icônes de la liste Selection, pour les voir comme sur le bouton.")] [SerializeField] private bool rotateChoiceIcons = true; [Header("Affichage")] [Tooltip("Passer le bouton devant les autres tant que sa liste est ouverte.")] [SerializeField] private bool bringToFrontOnOpen = true; [Tooltip("Coché : le bouton montre son icône sans jamais ouvrir sa liste. C'est le réglage d'un panneau de vérification, qui récapitule les choix sans les rouvrir.")] [SerializeField] private bool readOnly; [Header("Départ")] [Tooltip("NUMÉRO du choix affiché au départ (pas un angle) : 1 au nombre de choix.")] [SerializeField] private int startChoice = 1; [Header("Événement")] [Tooltip("Appelé à chaque changement : (bouton, numéro du choix).")] public ChoiceEvent onChoiceChanged; [System.Serializable] public class ChoiceEvent : UnityEvent { } // Une seule liste de choix ouverte à la fois, tous boutons confondus. private static ChoiceButton _openButton; // Rang du bouton parmi ses voisins avant d'être passé devant. -1 = pas déplacé. private int _siblingIndex = -1; // Canvas ajouté au bouton pour forcer son ordre de rendu pendant l'ouverture. private Canvas _sortingCanvas; // Assez haut pour passer devant le reste du panneau, sans toucher aux Canvas // qui ont leur propre ordre (fenêtres modales, etc.). private const int OpenSortingOrder = 100; public int Selected { get; private set; } public int ChoiceCount => choices != null ? choices.Length : 0; // Un bouton en lecture seule affiche, il ne décide pas : la forme lui pousse // le choix retenu et ne lui en demande jamais. public bool ReadOnly { get => readOnly; set => readOnly = value; } public Sprite SelectedIcon => icon != null ? icon.sprite : null; public bool IsOpen => selection != null && selection.activeSelf; // Angle du bouton, assignable aussi à l'exécution. public float Angle { get => angle; set { angle = value; Apply(); } } // Angle réellement appliqué à l'icône (bouton + choix). public float AppliedAngle { get { Choice choice = GetChoice(Selected); return choice != null ? angle + choice.angle : angle; } } // Choix et angle assignés au bouton d'un coup, par le code qui monte la forme. public void Setup(int choice, float angle) { this.angle = angle; startChoice = Mathf.Clamp(choice, 1, Mathf.Max(1, ChoiceCount)); SetChoice(startChoice, false); } protected virtual void Awake() { ResolveReferences(); FindChoices(false); if (button != null) button.onClick.AddListener(ToggleSelection); else Debug.LogError(GetType().Name + " : aucun Button sur " + name + ".", this); if (icon == null) Debug.LogError(GetType().Name + " : pas d'Image à modifier sur " + name + ". Vérifier l'enfant Icon ou remplir le champ Icon.", this); for (int i = 0; i < ChoiceCount; i++) { Choice choice = choices[i]; if (choice.button == null) continue; int selected = i + 1; choice.button.onClick.AddListener(() => Select(selected)); } Selected = ValidateStartChoice(); } protected virtual void OnEnable() { Apply(); CloseSelection(); } protected virtual void OnDisable() { RestoreOrder(); if (_openButton == this) _openButton = null; } // À brancher sur le OnClick d'un choix si on préfère câbler à la main. public void Select(int choice) { SetChoice(choice, true); CloseSelection(); } // choice : 1 au nombre de choix, ou 0 pour masquer l'icône du bouton. public void SetChoice(int choice, bool notify = true) { Selected = Mathf.Clamp(choice, 0, ChoiceCount); Apply(); if (notify) onChoiceChanged?.Invoke(this, Selected); } public void ToggleSelection() { SetSelectionOpen(!IsOpen); } public void OpenSelection() { SetSelectionOpen(true); } public void CloseSelection() { SetSelectionOpen(false); } // À brancher sur le fond du panneau pour refermer la liste ouverte. public static void CloseOpenedSelection() { if (_openButton != null) _openButton.CloseSelection(); } // Écrit le sprite et l'angle du choix courant sur l'icône du bouton, // et oriente les icônes de la liste de la même façon. private void Apply() { ApplyChoiceAngles(); if (icon == null) return; Choice choice = GetChoice(Selected); // Aucun choix : l'icône du bouton est masquée. Sprite sprite = choice != null && choice.icon != null ? choice.icon.sprite : null; icon.sprite = sprite; icon.enabled = sprite != null; icon.rectTransform.localRotation = Quaternion.Euler(0f, 0f, AppliedAngle); } // Chaque icône de la liste prend l'angle du bouton plus le sien : le choix est // présenté dans l'orientation qu'il aura une fois sur le bouton. private void ApplyChoiceAngles() { if (!rotateChoiceIcons) return; for (int i = 0; i < ChoiceCount; i++) { Image choiceIcon = choices[i].icon; if (choiceIcon == null) continue; choiceIcon.rectTransform.localRotation = Quaternion.Euler(0f, 0f, angle + choices[i].angle); } } // Le bouton démarre toujours sur un des choix. Start Choice est un numéro, // pas un angle : une valeur hors plage est corrigée et signalée. private int ValidateStartChoice() { if (ChoiceCount == 0) return 0; if (startChoice >= 1 && startChoice <= ChoiceCount) return startChoice; Debug.LogWarning(GetType().Name + " " + name + " : Start Choice = " + startChoice + " est hors plage (1 à " + ChoiceCount + "). C'est un numéro de choix, pas un angle : utiliser le champ Angle pour l'orientation.", this); startChoice = 1; return 1; } private Choice GetChoice(int choice) { if (choice < 1 || choice > ChoiceCount) return null; return choices[choice - 1]; } private void SetSelectionOpen(bool open) { if (selection == null) return; // Fermer reste permis : un bouton passé en lecture seule alors que sa liste // était ouverte doit pouvoir la refermer. if (open && readOnly) return; if (open && _openButton != null && _openButton != this) _openButton.CloseSelection(); selection.SetActive(open); if (open) BringToFront(); else RestoreOrder(); if (open) _openButton = this; else if (_openButton == this) _openButton = null; } // Deux niveaux de recouvrement à traiter : // - entre voisins, c'est le dernier enfant qui est dessiné devant ; // - face à une autre branche du Canvas, seul un ordre de tri forcé passe devant. private void BringToFront() { if (!bringToFrontOnOpen || _siblingIndex >= 0) return; _siblingIndex = transform.GetSiblingIndex(); transform.SetAsLastSibling(); if (_sortingCanvas == null) { _sortingCanvas = GetComponent(); if (_sortingCanvas == null) _sortingCanvas = gameObject.AddComponent(); } // Un Canvas imbriqué qui trie lui-même a besoin de son propre raycaster, // sinon les clics sur les choix ne l'atteignent plus. if (GetComponent() == null) gameObject.AddComponent(); _sortingCanvas.overrideSorting = true; _sortingCanvas.sortingOrder = OpenSortingOrder; } private void RestoreOrder() { if (_sortingCanvas != null) _sortingCanvas.overrideSorting = false; if (_siblingIndex < 0) return; transform.SetSiblingIndex(_siblingIndex); _siblingIndex = -1; } private void ResolveReferences() { if (button == null) button = GetComponent