375 lines
14 KiB
GDScript
375 lines
14 KiB
GDScript
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
|
|
var _debug_messages : int = 0 #suivi temporaire
|
|
|
|
func _ready() -> void:
|
|
layer = 50
|
|
process_mode = Node.PROCESS_MODE_ALWAYS #le monde est en pause pendant le combat
|
|
_build_ui()
|
|
print("[BattleUI] pret (layer %d, paused=%s)" % [layer, get_tree().paused]) #suivi : a retirer ensuite
|
|
|
|
_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:
|
|
if _debug_messages < 4: #suivi temporaire : les 4 premiers messages seulement
|
|
_debug_messages += 1
|
|
print("[BattleUI] message : " + text)
|
|
_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)
|
|
var name_row := HBoxContainer.new()
|
|
name_row.add_theme_constant_override("separation", 10)
|
|
box.add_child(name_row)
|
|
name_row.add_child(name_label)
|
|
var status_label := Label.new()
|
|
status_label.add_theme_font_size_override("font_size", 16)
|
|
name_row.add_child(status_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, "status_label": status_label}
|
|
_panels[slot.id] = entry
|
|
_set_hp(entry, mon.pokemon.current_hp, mon.pokemon.get_max_hp(), false)
|
|
set_slot_status(slot.id, int(mon.pokemon.status))
|
|
|
|
func set_slot_status(slot_id : int, status : int) -> void:
|
|
#badge de statut a cote du nom (brulure, paralysie...) ; vide si le pokemon n'a aucun statut
|
|
var entry : Dictionary = _panels.get(slot_id, {})
|
|
if entry.is_empty(): return
|
|
var label : Label = entry["status_label"]
|
|
var badge : Variant = TeamHUD.STATUS_BADGES.get(status)
|
|
if badge == null:
|
|
label.text = ""
|
|
return
|
|
label.text = badge[0]
|
|
label.add_theme_color_override("font_color", badge[1])
|
|
label.add_theme_constant_override("outline_size", 4)
|
|
label.add_theme_color_override("font_outline_color", Color.BLACK)
|
|
|
|
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
|