class_name BattleUI extends CanvasLayer #interface du combat : boite de texte, panneaux de PV, menus du joueur. #manette d'abord (navigation par focus : A valide, B revient), mais chaque element est un Button #cliquable a la souris ; le survol deplace le focus. # #menus : Combat / Pokemon / Fuite -> choix de la capacite -> choix de la cible (si besoin) signal highlight_requested(slot_ids : Array) #pokemon a marquer d'un "▼" dans le monde signal _menu_chosen(index : int) const ControllerIconTextureScript = preload("res://addons/controller_icons/objects/ControllerIconTexture.gd") var _text_panel : PanelContainer var _text_label : Label var _menu_panel : PanelContainer var _menu_title : Label var _menu_box : VBoxContainer var _player_panels : VBoxContainer var _enemy_panels : VBoxContainer var _panels : Dictionary = {} #id d'emplacement -> {"panel", "bar", "fill", "hp_label"} var _menu_active : bool = false var _menu_allow_back : bool = false var _focus_callback : Callable = Callable() var _skip_requested : bool = false var _prev_mouse_mode : Input.MouseMode = Input.MOUSE_MODE_VISIBLE var _mouse_saved : bool = false func _ready() -> void: layer = 50 process_mode = Node.PROCESS_MODE_ALWAYS #le monde est en pause pendant le combat _build_ui() _prev_mouse_mode = Input.mouse_mode _mouse_saved = true Input.mouse_mode = Input.MOUSE_MODE_VISIBLE func _exit_tree() -> void: if _mouse_saved: Input.mouse_mode = _prev_mouse_mode _mouse_saved = false func _unhandled_input(event : InputEvent) -> void: if _menu_active: if _menu_allow_back and event.is_action_pressed(&"ui_cancel"): _menu_chosen.emit(-1) get_viewport().set_input_as_handled() return #hors menu : A ou clic gauche passe les messages plus vite if event.is_action_pressed(&"ui_accept") or (event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_LEFT): _skip_requested = true #---------- construction ---------- func _build_ui() -> void: var root := Control.new() root.set_anchors_preset(Control.PRESET_FULL_RECT) root.mouse_filter = Control.MOUSE_FILTER_IGNORE add_child(root) #PV du joueur : bas gauche, au-dessus de la boite de texte var player_margin := _corner(root, Control.PRESET_BOTTOM_LEFT, {"left": 32, "bottom": 170}) _player_panels = VBoxContainer.new() _player_panels.mouse_filter = Control.MOUSE_FILTER_IGNORE player_margin.add_child(_player_panels) #PV des adversaires : haut droite var enemy_margin := _corner(root, Control.PRESET_TOP_RIGHT, {"right": 32, "top": 32}) _enemy_panels = VBoxContainer.new() _enemy_panels.mouse_filter = Control.MOUSE_FILTER_IGNORE enemy_margin.add_child(_enemy_panels) #boite de texte : bas gauche var text_margin := _corner(root, Control.PRESET_BOTTOM_LEFT, {"left": 32, "bottom": 32}) _text_panel = PanelContainer.new() _text_panel.mouse_filter = Control.MOUSE_FILTER_IGNORE _text_panel.hide() text_margin.add_child(_text_panel) _text_label = Label.new() _text_label.custom_minimum_size = Vector2(620, 80) _text_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART _text_label.add_theme_font_size_override("font_size", 22) _text_panel.add_child(_text_label) #menus : bas droite var menu_margin := _corner(root, Control.PRESET_BOTTOM_RIGHT, {"right": 32, "bottom": 32}) _menu_panel = PanelContainer.new() _menu_panel.hide() menu_margin.add_child(_menu_panel) var menu_column := VBoxContainer.new() menu_column.add_theme_constant_override("separation", 6) _menu_panel.add_child(menu_column) _menu_title = Label.new() _menu_title.add_theme_font_size_override("font_size", 20) menu_column.add_child(_menu_title) _menu_box = VBoxContainer.new() _menu_box.add_theme_constant_override("separation", 4) menu_column.add_child(_menu_box) func _corner(parent : Control, preset : Control.LayoutPreset, margins : Dictionary) -> MarginContainer: #IMPORTANT : les ancres se definissent AVANT add_child. Une fois dans l'arbre, set_anchors_preset #conserve la position actuelle (0, 0) en ajustant les offsets : le conteneur resterait en haut a gauche #et grandirait hors de l'ecran. var m := MarginContainer.new() m.mouse_filter = Control.MOUSE_FILTER_IGNORE m.set_anchors_preset(preset) if preset == Control.PRESET_BOTTOM_LEFT or preset == Control.PRESET_BOTTOM_RIGHT: m.grow_vertical = Control.GROW_DIRECTION_BEGIN if preset == Control.PRESET_TOP_RIGHT or preset == Control.PRESET_BOTTOM_RIGHT: m.grow_horizontal = Control.GROW_DIRECTION_BEGIN for side in margins: m.add_theme_constant_override("margin_" + side, margins[side]) parent.add_child(m) return m #---------- messages ---------- func show_message(text : String, duration : float = 1.1) -> void: _text_label.text = text _text_panel.show() await _wait(duration) func hide_message() -> void: _text_panel.hide() func _wait(duration : float) -> void: _skip_requested = false var start : int = Time.get_ticks_msec() while float(Time.get_ticks_msec() - start) < duration * 1000.0 and !_skip_requested: await get_tree().process_frame #---------- panneaux de PV ---------- func set_slot_pokemon(slot : BattleSlot) -> void: remove_slot_panel(slot.id) var mon : BattleMon = slot.mon if mon == null: return var panel := PanelContainer.new() panel.mouse_filter = Control.MOUSE_FILTER_IGNORE var box := VBoxContainer.new() panel.add_child(box) var name_label := Label.new() name_label.text = "%s Lv.%d" % [mon.display_name(), mon.pokemon.level] name_label.add_theme_font_size_override("font_size", 20) box.add_child(name_label) var bar := ProgressBar.new() bar.show_percentage = false bar.custom_minimum_size = Vector2(240, 12) var track := StyleBoxFlat.new() track.bg_color = Color(1, 1, 1, 0.25) track.set_corner_radius_all(6) var fill := StyleBoxFlat.new() fill.set_corner_radius_all(6) bar.add_theme_stylebox_override("background", track) bar.add_theme_stylebox_override("fill", fill) box.add_child(bar) var hp_label : Label = null if slot.side == 0: #les PV chiffres ne sont montres que pour le joueur hp_label = Label.new() box.add_child(hp_label) (_player_panels if slot.side == 0 else _enemy_panels).add_child(panel) var entry : Dictionary = {"panel": panel, "bar": bar, "fill": fill, "hp_label": hp_label} _panels[slot.id] = entry _set_hp(entry, mon.pokemon.current_hp, mon.pokemon.get_max_hp(), false) func animate_hp(slot_id : int, hp : int, max_hp : int) -> void: var entry : Dictionary = _panels.get(slot_id, {}) if entry.is_empty(): return _set_hp(entry, hp, max_hp, true) await get_tree().create_timer(0.55, true).timeout func _set_hp(entry : Dictionary, hp : int, max_hp : int, animated : bool) -> void: var bar : ProgressBar = entry["bar"] bar.max_value = max_hp if animated: var tween := create_tween() tween.tween_property(bar, "value", float(hp), 0.5) else: bar.value = hp var ratio : float = float(hp) / float(max_hp) if max_hp > 0 else 0.0 var fill : StyleBoxFlat = entry["fill"] fill.bg_color = Color(0.3, 0.8, 0.35) if ratio > 0.5 else (Color(0.95, 0.7, 0.2) if ratio > 0.2 else Color(0.9, 0.25, 0.25)) var hp_label : Label = entry["hp_label"] if hp_label != null: hp_label.text = "%d/%d" % [hp, max_hp] func remove_slot_panel(slot_id : int) -> void: var entry : Dictionary = _panels.get(slot_id, {}) if entry.is_empty(): return (entry["panel"] as Node).queue_free() _panels.erase(slot_id) func clear_panels() -> void: for id in _panels.keys(): remove_slot_panel(id) #---------- menus ---------- func ask(title : String, options : Array, allow_back : bool, focus_callback : Callable = Callable()) -> int: #options : Array de {"text": String, "disabled": bool}. Renvoie l'index choisi, ou -1 pour "Retour". _clear_menu() _text_panel.hide() _menu_title.text = title for i in options.size(): var b := Button.new() b.text = options[i]["text"] b.alignment = HORIZONTAL_ALIGNMENT_LEFT b.custom_minimum_size = Vector2(320, 44) var disabled : bool = options[i].get("disabled", false) b.disabled = disabled if disabled: b.focus_mode = Control.FOCUS_NONE else: b.pressed.connect(_on_option_pressed.bind(i)) b.focus_entered.connect(_on_option_focused.bind(i)) b.mouse_entered.connect(b.grab_focus) #la souris deplace le focus _menu_box.add_child(b) if allow_back: var back := Button.new() back.text = "Retour" back.alignment = HORIZONTAL_ALIGNMENT_LEFT back.custom_minimum_size = Vector2(320, 44) back.expand_icon = true back.add_theme_constant_override("icon_max_width", 28) var icon : Texture2D = ControllerIconTextureScript.new() #icone de la touche "annuler" (suit le peripherique) icon.set("path", "ui_cancel") back.icon = icon back.pressed.connect(_menu_chosen.emit.bind(-1)) back.mouse_entered.connect(back.grab_focus) _menu_box.add_child(back) _menu_active = true _menu_allow_back = allow_back _focus_callback = focus_callback _menu_panel.show() _focus_first_enabled.call_deferred() var index : int = await _menu_chosen _menu_active = false _focus_callback = Callable() _menu_panel.hide() _clear_menu() return index func _clear_menu() -> void: for child in _menu_box.get_children(): _menu_box.remove_child(child) child.queue_free() func _focus_first_enabled() -> void: for child in _menu_box.get_children(): if child is Button and child.focus_mode == Control.FOCUS_ALL: child.grab_focus() return func _on_option_pressed(index : int) -> void: _menu_chosen.emit(index) func _on_option_focused(index : int) -> void: if _focus_callback.is_valid(): _focus_callback.call(index) #---------- choix du joueur ---------- func request_action(battle : Battle, slot : BattleSlot) -> BattleAction: highlight_requested.emit([slot.id]) var action : BattleAction = null while action == null: var options : Array = [ {"text": "Combat"}, {"text": "Pokémon"}, {"text": "Fuite", "disabled": !battle.setup.can_flee}, ] var choice : int = await ask("Que doit faire %s ?" % slot.mon.display_name(), options, false) match choice: 0: action = await _choose_move(battle, slot) 1: var pokemon : PokemonInstance = await _choose_pokemon(battle, slot, true, "Quel Pokémon envoyer ?") if pokemon != null: action = BattleAction.switch_pokemon(slot, pokemon) 2: action = BattleAction.flee(slot) highlight_requested.emit([slot.id]) highlight_requested.emit([]) return action func request_replacement(battle : Battle, slot : BattleSlot) -> PokemonInstance: #pokemon K.O. : le joueur doit en envoyer un autre (pas de retour possible) highlight_requested.emit([]) var pokemon : PokemonInstance = await _choose_pokemon(battle, slot, false, "%s est K.O. ! Qui envoyer ?" % slot.mon.display_name()) return pokemon func _choose_move(battle : Battle, slot : BattleSlot) -> BattleAction: var moves : Array[MoveSlot] = slot.mon.pokemon.active_moves if battle.get_usable_moves(slot.mon).is_empty(): return BattleAction.use_struggle(slot) #plus aucune capacite utilisable : Lutte, sans menu var options : Array = [] for ms in moves: options.append({"text": "%s PP %d/%d" % [ms.move.get_display_name(), ms.current_pp, ms.max_pp], "disabled": !ms.has_pp()}) while true: var index : int = await ask("Capacité", options, true) if index < 0: return null var move_slot : MoveSlot = moves[index] var target : BattleSlot = null if TargetResolver.needs_selection(battle, slot, move_slot.move): target = await _choose_target(battle, slot, move_slot.move) if target == null: continue #retour au choix de la capacite return BattleAction.use_move(slot, move_slot, target) return null func _choose_target(battle : Battle, slot : BattleSlot, move : MoveData) -> BattleSlot: var targets : Array[BattleSlot] = TargetResolver.get_selectable_targets(battle, slot, move) var options : Array = [] for t in targets: options.append({"text": "%s (%s)" % [t.mon.display_name(), "adversaire" if t.side != slot.side else "allié"]}) var on_focus := func(i : int) -> void: highlight_requested.emit([targets[i].id]) var index : int = await ask("Cible", options, true, on_focus) highlight_requested.emit([slot.id]) return targets[index] if index >= 0 else null func _choose_pokemon(battle : Battle, slot : BattleSlot, allow_back : bool, title : String) -> PokemonInstance: var members : Array[PokemonInstance] = slot.participant.team.members var available : Array[PokemonInstance] = battle.get_replacement_options(slot) var options : Array = [] for p in members: var note : String = "" if p.is_fainted(): note = " (K.O.)" elif !available.has(p): note = " (au combat)" options.append({ "text": "%s Lv.%d PV %d/%d%s" % [p.get_display_name(), p.level, p.current_hp, p.get_max_hp(), note], "disabled": !available.has(p), }) var index : int = await ask(title, options, allow_back) return members[index] if index >= 0 else null