Tales-of-PKMN/game/battle/battle_ui.gd

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