diff --git a/game/battle/battle_actor.gd b/game/battle/battle_actor.gd new file mode 100644 index 0000000..9955f3b --- /dev/null +++ b/game/battle/battle_actor.gd @@ -0,0 +1,80 @@ +class_name BattleActor +extends Node3D + +#un pokemon visible dans le monde pendant le combat (un par emplacement), avec ses petites animations. + +var slot_id : int = -1 +var pokemon : PokemonInstance +var model : Node3D +var home : Vector3 = Vector3.ZERO + +var _marker : Label3D + +func setup(p_pokemon : PokemonInstance, p_slot_id : int) -> void: + process_mode = Node.PROCESS_MODE_ALWAYS #le monde est en pause pendant le combat, pas les acteurs + pokemon = p_pokemon + slot_id = p_slot_id + model = SentPokemon.create_model(p_pokemon) + add_child(model) + + #marqueur "▼" au-dessus de la tete (pokemon qui agit / cible en cours de choix) + _marker = Label3D.new() + _marker.text = "▼" + _marker.font_size = 96 + _marker.pixel_size = 0.01 + _marker.billboard = BaseMaterial3D.BILLBOARD_ENABLED + _marker.no_depth_test = true + _marker.modulate = Color(1.0, 0.85, 0.2) + _marker.position = Vector3(0, 2.3, 0) + _marker.visible = false + add_child(_marker) + +func place(position_world : Vector3, look_at_pos : Vector3) -> void: + global_position = position_world + home = position_world + var flat : Vector3 = Vector3(look_at_pos.x, position_world.y, look_at_pos.z) + if flat.distance_to(position_world) > 0.01: + look_at(flat, Vector3.UP) + if SentPokemon.MODEL_FACES_PLUS_Z: rotate_y(PI) #look_at oriente -Z vers la cible + +func set_highlight(on : bool) -> void: + if _marker: _marker.visible = on + +#---------- animations (awaitables) ---------- + +func play_enter() -> void: + scale = Vector3.ONE * 0.01 + var tween := create_tween() + tween.tween_property(self, "scale", Vector3.ONE, 0.3).set_trans(Tween.TRANS_BACK).set_ease(Tween.EASE_OUT) + await tween.finished + +func play_leave() -> void: + var tween := create_tween() + tween.tween_property(self, "scale", Vector3.ONE * 0.01, 0.25).set_ease(Tween.EASE_IN) + await tween.finished + +func play_attack(toward : Vector3) -> void: + var dir : Vector3 = toward - home + dir.y = 0.0 + if dir.length() < 0.01: return + var lunge : Vector3 = home + dir.normalized() * 1.2 + var tween := create_tween() + tween.tween_property(self, "global_position", lunge, 0.12).set_ease(Tween.EASE_OUT) + tween.tween_property(self, "global_position", home, 0.2).set_ease(Tween.EASE_IN_OUT) + await tween.finished + +func play_hit() -> void: + var tween := create_tween() + for i in 3: + tween.tween_property(self, "global_position", home + Vector3(0.15, 0, 0), 0.04) + tween.tween_property(self, "global_position", home + Vector3(-0.15, 0, 0), 0.04) + tween.tween_property(self, "global_position", home, 0.04) + await tween.finished + +func play_faint() -> void: + var tween := create_tween() + tween.set_parallel(true) + tween.tween_property(self, "global_position", home + Vector3(0, -0.6, 0), 0.5) + tween.tween_property(self, "scale", Vector3.ONE * 0.01, 0.5) + await tween.finished + visible = false diff --git a/game/battle/battle_actor.gd.uid b/game/battle/battle_actor.gd.uid new file mode 100644 index 0000000..ffca6b3 --- /dev/null +++ b/game/battle/battle_actor.gd.uid @@ -0,0 +1 @@ +uid://cftfencf611ul diff --git a/game/battle/battle_encounter.gd b/game/battle/battle_encounter.gd new file mode 100644 index 0000000..c1e352b --- /dev/null +++ b/game/battle/battle_encounter.gd @@ -0,0 +1,33 @@ +class_name BattleEncounter +extends Resource + +#description d'une rencontre : qui est en face et sous quel format. +#a assigner dans l'inspecteur d'un AimTarget (champ "Encounter"). +#exemples : +# - 1v1 sauvage : wild_pokemon = [1 entree] +# - 1 contre plusieurs sauvages : wild_pokemon = [3 entrees] +# - dresseur : trainer = un TrainerData, can_flee = false +# - combat double : player_slots = 2 et trainer_slots = 2 +# - plusieurs contre 1 (boss) : player_slots = 3 et une seule entree adverse + +@export_group("Adversaires") +@export var trainer : TrainerData +@export_range(1, 3) var trainer_slots : int = 1 #pokemon que le dresseur envoie en meme temps +@export var wild_pokemon : Array[TrainerPartyEntry] = [] #chaque entree = un adversaire (1 emplacement) + +@export_group("Joueur") +@export_range(1, 3) var player_slots : int = 1 #pokemon du joueur envoyes en meme temps + +@export_group("Regles") +@export var can_flee : bool = true #mets false pour un combat de dresseur +@export var remove_world_actor_on_win : bool = true #fait disparaitre le pokemon/dresseur du monde apres la victoire + +func build_participants() -> Array[BattleParticipant]: + var result : Array[BattleParticipant] = [] + if trainer != null: + result.append(BattleSetup.trainer_participant(trainer, trainer_slots)) + for entry in wild_pokemon: + if entry == null: continue + var pokemon : PokemonInstance = entry.create_instance() + if pokemon != null: result.append(BattleSetup.wild_participant(pokemon)) + return result diff --git a/game/battle/battle_encounter.gd.uid b/game/battle/battle_encounter.gd.uid new file mode 100644 index 0000000..931acea --- /dev/null +++ b/game/battle/battle_encounter.gd.uid @@ -0,0 +1 @@ +uid://u2t6dm1ee3se diff --git a/game/battle/battle_launcher.gd b/game/battle/battle_launcher.gd new file mode 100644 index 0000000..e6e3c41 --- /dev/null +++ b/game/battle/battle_launcher.gd @@ -0,0 +1,121 @@ +class_name BattleLauncher +extends Node + +#lance les combats dans le monde. A ajouter UNE FOIS a ta scene de jeu (node "BattleLauncher"). +# +#- ecoute GameData.battle_requested (lancer d'un pokemon sur un AimTarget qui a un "Encounter") +#- start_encounter() sert aussi a un dresseur qui defie le joueur (dialogue, zone de detection...) +# +#pendant le combat : le monde est en pause (GameData.in_battle = true), une camera de combat prend le relais, +#puis tout est restitue a la fin. + +signal battle_started +signal battle_ended(outcome : int) + +@export var heal_team_on_defeat : bool = true #provisoire : evite de bloquer le joueur si toute son equipe est K.O. + +var _running : bool = false + +func _ready() -> void: + process_mode = Node.PROCESS_MODE_ALWAYS + GameData.battle_requested.connect(_on_battle_requested) + print("[BattleLauncher] pret, en attente de GameData.battle_requested") + +func _on_battle_requested(sent : SentPokemon, target : Node) -> void: + print("[BattleLauncher] battle_requested recu (cible : %s)" % target) + if _running: + push_warning("[BattleLauncher] un combat est deja en cours") + return + if !(target is AimTarget): + push_warning("[BattleLauncher] la cible n'est pas un AimTarget") + return + var aim_target : AimTarget = target + if aim_target.encounter == null: + push_warning("[BattleLauncher] cet AimTarget n'a pas d'Encounter (inspecteur : Battle > Encounter)") + return + await start_encounter(aim_target.encounter, aim_target.global_position, sent, aim_target.world_actor, aim_target) + +func start_encounter(encounter : BattleEncounter, enemy_pos : Vector3, sent : SentPokemon = null, world_actor : Node3D = null, target : AimTarget = null) -> Battle.Outcome: + #sent = pokemon lance par le joueur (il passe en premier) ; null si le joueur est defie : l'ordre de l'equipe s'applique + if _running or encounter == null: + push_warning("[BattleLauncher] start_encounter ignore (combat deja en cours ou Encounter vide)") + return Battle.Outcome.NONE + + #camera et acteurs doivent etre dans le monde que le joueur voit (SubViewport eventuel du jeu) + var world_viewport : Viewport = get_viewport() + var world_root : Node = world_viewport if world_viewport is SubViewport else get_tree().current_scene + var game_camera : Camera3D = world_viewport.get_camera_3d() + var player_pos : Vector3 = sent.global_position if (sent != null and is_instance_valid(sent)) else _find_player_position() + + var ui := BattleUI.new() + get_tree().root.add_child(ui) #directement sous la fenetre : toujours affiche par-dessus tout + + var setup := BattleSetup.new() + setup.can_flee = encounter.can_flee + var lead : PokemonInstance = sent.pokemon if (sent != null and is_instance_valid(sent)) else null + setup.add(BattleSetup.player_participant(GameData.player_team, encounter.player_slots, lead, PlayerBattleController.new(ui))) + for participant in encounter.build_participants(): + setup.add(participant) + + if !setup.is_valid(): + var details : PackedStringArray = PackedStringArray() + for participant in setup.participants: + details.append("%s (camp %d, %d pokemon aptes)" % [participant.name, participant.side, participant.team.get_able_pokemon().size()]) + push_warning("[BattleLauncher] combat invalide : chaque camp doit avoir un participant avec un pokemon apte. Participants : " + ", ".join(details) + ". Verifie que l'Encounter contient bien une espece (wild_pokemon / trainer) et que ton equipe a un pokemon non K.O.") + ui.queue_free() + return Battle.Outcome.NONE + print("[BattleLauncher] combat lance : %d participants" % setup.participants.size()) + + _running = true + GameData.in_battle = true + get_tree().paused = true + battle_started.emit() + + #les pokemon sortis et l'adversaire du monde sont caches : les acteurs du combat les remplacent + var hidden : Array[Node3D] = [] + for node in get_tree().get_nodes_in_group(SentPokemon.GROUP): + if node is Node3D and node.visible: + node.visible = false + hidden.append(node) + if world_actor != null and world_actor.visible: + world_actor.visible = false + hidden.append(world_actor) + + var presenter := BattleWorldPresenter.new(ui) + var battle := Battle.new(setup, presenter) + presenter.setup(battle, player_pos, enemy_pos, world_root, game_camera) + print("[BattleLauncher] formation et camera prets, UI : visible=%s layer=%d" % [ui.visible, ui.layer]) #suivi : a retirer ensuite + var outcome : Battle.Outcome = await battle.run() + print("[BattleLauncher] combat termine : %s" % Battle.Outcome.keys()[outcome]) #suivi : a retirer ensuite + await presenter.cleanup() + ui.queue_free() + + #retour au monde + for node in hidden: + if is_instance_valid(node): node.visible = true + for node in get_tree().get_nodes_in_group(SentPokemon.GROUP): + if node is SentPokemon and node.pokemon != null and node.pokemon.is_fainted(): + node.recall() #un pokemon K.O. est rappele + + if world_actor != null and is_instance_valid(world_actor): + if outcome == Battle.Outcome.PLAYER_WON and encounter.remove_world_actor_on_win: + world_actor.queue_free() + + if outcome == Battle.Outcome.PLAYER_LOST and heal_team_on_defeat: + GameData.player_team.heal_all() + + GameData.in_battle = false + get_tree().paused = false + _running = false + + if target != null and is_instance_valid(target): target.battle_finished.emit(outcome) + GameData.battle_finished.emit(outcome, target) + battle_ended.emit(outcome) + return outcome + +func _find_player_position() -> Vector3: + #ajoute ton PlayerCharacter au groupe "player" (sinon la camera du jeu sert de repere) + var player : Node3D = get_tree().get_first_node_in_group("player") as Node3D + if player != null: return player.global_position + var cam : Camera3D = get_viewport().get_camera_3d() + return cam.global_position if cam != null else Vector3.ZERO diff --git a/game/battle/battle_launcher.gd.uid b/game/battle/battle_launcher.gd.uid new file mode 100644 index 0000000..7da661a --- /dev/null +++ b/game/battle/battle_launcher.gd.uid @@ -0,0 +1 @@ +uid://bx0htbw7ygkww diff --git a/game/battle/battle_ui.gd b/game/battle/battle_ui.gd new file mode 100644 index 0000000..eede257 --- /dev/null +++ b/game/battle/battle_ui.gd @@ -0,0 +1,353 @@ +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) + 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 diff --git a/game/battle/battle_ui.gd.uid b/game/battle/battle_ui.gd.uid new file mode 100644 index 0000000..54538c4 --- /dev/null +++ b/game/battle/battle_ui.gd.uid @@ -0,0 +1 @@ +uid://dekhp3tad1xga diff --git a/game/battle/battle_world_presenter.gd b/game/battle/battle_world_presenter.gd new file mode 100644 index 0000000..19f5629 --- /dev/null +++ b/game/battle/battle_world_presenter.gd @@ -0,0 +1,190 @@ +class_name BattleWorldPresenter +extends BattlePresenter + +#met le combat en scene dans le monde 3D : +#- place les pokemon (un BattleActor par emplacement) face a face, a l'endroit de la rencontre +#- deplace une camera de combat qui cadre l'ensemble +#- joue les animations et affiche les messages / PV via BattleUI +# +#le monde (hors combat) est en pause : tout ce qui est ici fonctionne en mode "toujours". + +var ui : BattleUI +var battle : Battle + +#formation +var side_distance : float = 7.0 #distance entre les deux camps +var lateral_spacing : float = 2.6 #ecart entre deux pokemon d'un meme camp +var ground_mask : int = 1 + +#camera +var camera_back : float = 4.5 #recul derriere le camp du joueur +var camera_height : float = 3.4 +var camera_move_time : float = 0.9 + +var _actors : Dictionary = {} #id d'emplacement -> BattleActor +var _formation : Dictionary = {} #id d'emplacement -> position au sol +var _center : Vector3 = Vector3.ZERO +var _axis : Vector3 = Vector3.FORWARD #direction camp joueur -> camp adverse +var _camera : Camera3D = null +var _previous_camera : Camera3D = null +var _world_root : Node = null #ou ajouter camera et acteurs : le monde que le joueur voit + +func _init(p_ui : BattleUI) -> void: + ui = p_ui + ui.highlight_requested.connect(_on_highlight_requested) + +#---------- mise en place ---------- + +func setup(p_battle : Battle, player_pos : Vector3, enemy_pos : Vector3, p_world_root : Node, p_game_camera : Camera3D) -> void: + battle = p_battle + _world_root = p_world_root + _previous_camera = p_game_camera + + _axis = enemy_pos - player_pos + _axis.y = 0.0 + if _axis.length() < 0.1: + #pas de direction evidente : on utilise la direction de la camera du jeu + var cam : Camera3D = _previous_camera + _axis = -cam.global_basis.z if cam else Vector3.FORWARD + _axis.y = 0.0 + _axis = _axis.normalized() + _center = (player_pos + enemy_pos) * 0.5 + + var lateral : Vector3 = _axis.cross(Vector3.UP).normalized() + + var count_per_side : Dictionary = {0: 0, 1: 0} + for slot in battle.slots: + count_per_side[slot.side] += 1 + + for slot in battle.slots: + var n : int = count_per_side[slot.side] + var offset : float = (slot.index - (n - 1) * 0.5) * lateral_spacing + var side_sign : float = -1.0 if slot.side == 0 else 1.0 + var pos : Vector3 = _center + _axis * side_sign * side_distance * 0.5 + lateral * offset + _formation[slot.id] = _snap_to_ground(pos) + + _start_camera(maxi(count_per_side[0], count_per_side[1])) + +func _start_camera(max_slots : int) -> void: + + var back : float = camera_back + max_slots * 0.8 + var cam_pos : Vector3 = _center - _axis * (side_distance * 0.5 + back) + Vector3.UP * (camera_height + max_slots * 0.3) + var look_target : Vector3 = _center + Vector3.UP * 1.0 + var final_transform := Transform3D(Basis.looking_at(look_target - cam_pos, Vector3.UP), cam_pos) + + _camera = Camera3D.new() + _world_root.add_child(_camera) + _camera.global_transform = _previous_camera.global_transform if _previous_camera else final_transform + if _previous_camera: _camera.fov = _previous_camera.fov + _camera.make_current() + print("[BattleCamera] camera du jeu : %s | camera de combat : parent=%s, current=%s" % [_previous_camera, _camera.get_parent().get_path(), _camera.current]) #suivi : a retirer ensuite + + var tween : Tween = ui.create_tween() + tween.tween_property(_camera, "global_transform", final_transform, camera_move_time).set_trans(Tween.TRANS_SINE).set_ease(Tween.EASE_IN_OUT) + +func cleanup() -> void: + #fin du combat : ecran propre, camera du jeu reprise + ui.highlight_requested.emit([]) + ui.hide_message() + ui.clear_panels() + for actor in _actors.values(): + if is_instance_valid(actor): (actor as Node).queue_free() + _actors.clear() + + if _camera != null: + if _previous_camera != null and is_instance_valid(_previous_camera): + var tween : Tween = ui.create_tween() + tween.tween_property(_camera, "global_transform", _previous_camera.global_transform, 0.6).set_trans(Tween.TRANS_SINE).set_ease(Tween.EASE_IN_OUT) + await tween.finished + _previous_camera.make_current() + _camera.queue_free() + _camera = null + +#---------- evenements ---------- + +func present(event : BattleEvent) -> void: + match event.type: + BattleEvent.Type.BATTLE_START: + await ui.show_message(event.describe(), 1.0) + + BattleEvent.Type.SENT_OUT: + await _show_actor(event.source_slot) + await ui.show_message(event.describe(), 0.9) + + BattleEvent.Type.WITHDRAWN: + await ui.show_message(event.describe(), 0.7) + await _remove_actor(event.source_slot) + + BattleEvent.Type.MOVE_USED: + var actor : BattleActor = _actors.get(event.source_slot) + if actor: actor.play_attack(_opponents_center(event.source_slot)) #en parallele du message + await ui.show_message(event.describe(), 1.0) + + BattleEvent.Type.DAMAGE: + var actor : BattleActor = _actors.get(event.target_slot) + if actor: actor.play_hit() + await ui.animate_hp(event.target_slot, event.hp, event.max_hp) + + BattleEvent.Type.FAINTED: + var actor : BattleActor = _actors.get(event.source_slot) + if actor: actor.play_faint() + ui.remove_slot_panel(event.source_slot) + await ui.show_message(event.describe(), 1.0) + + BattleEvent.Type.TURN_START: + ui.hide_message() #pas de texte : les menus prennent la suite + + _: + #MOVE_MISSED, CRITICAL, SUPER_EFFECTIVE, NOT_VERY_EFFECTIVE, NO_EFFECT, FLED, MESSAGE, BATTLE_END... + var text : String = event.describe() + if text != "": await ui.show_message(text, 1.0) + +func _show_actor(slot_id : int) -> void: + var slot : BattleSlot = battle.get_slot(slot_id) + if slot == null or slot.mon == null: return + + if _actors.has(slot_id): + (_actors[slot_id] as Node).queue_free() + _actors.erase(slot_id) + + var actor := BattleActor.new() + _world_root.add_child(actor) + actor.setup(slot.mon.pokemon, slot_id) + var facing : Vector3 = _center + _axis * (side_distance if slot.side == 0 else -side_distance) + actor.place(_formation.get(slot_id, _center), facing) + _actors[slot_id] = actor + + ui.set_slot_pokemon(slot) + await actor.play_enter() + +func _remove_actor(slot_id : int) -> void: + ui.remove_slot_panel(slot_id) + var actor : BattleActor = _actors.get(slot_id) + if actor == null: return + await actor.play_leave() + actor.queue_free() + _actors.erase(slot_id) + +func _on_highlight_requested(slot_ids : Array) -> void: + for id in _actors: + (_actors[id] as BattleActor).set_highlight(slot_ids.has(id)) + +#---------- utilitaires ---------- + +func _opponents_center(slot_id : int) -> Vector3: + var slot : BattleSlot = battle.get_slot(slot_id) + if slot == null: return _center + var total : Vector3 = Vector3.ZERO + var count : int = 0 + for other in battle.slots: + if other.side != slot.side and _actors.has(other.id): + total += (_actors[other.id] as BattleActor).home + count += 1 + return total / count if count > 0 else _center + +func _snap_to_ground(point : Vector3) -> Vector3: + var world : World3D = _previous_camera.get_world_3d() if _previous_camera != null else _world_root.get_viewport().world_3d + var space : PhysicsDirectSpaceState3D = world.direct_space_state + var query := PhysicsRayQueryParameters3D.create(point + Vector3.UP * 3.0, point + Vector3.DOWN * 8.0, ground_mask) + var hit : Dictionary = space.intersect_ray(query) + return hit["position"] if !hit.is_empty() else point diff --git a/game/battle/battle_world_presenter.gd.uid b/game/battle/battle_world_presenter.gd.uid new file mode 100644 index 0000000..0be52d7 --- /dev/null +++ b/game/battle/battle_world_presenter.gd.uid @@ -0,0 +1 @@ +uid://cojynjb18vw8m diff --git a/game/battle/player_battle_controller.gd b/game/battle/player_battle_controller.gd new file mode 100644 index 0000000..bb9bdbb --- /dev/null +++ b/game/battle/player_battle_controller.gd @@ -0,0 +1,15 @@ +class_name PlayerBattleController +extends BattleController + +#controleur du joueur : les choix passent par les menus de BattleUI (manette, clavier, souris) + +var ui : BattleUI + +func _init(p_ui : BattleUI) -> void: + ui = p_ui + +func choose_action(battle : Battle, slot : BattleSlot) -> BattleAction: + return await ui.request_action(battle, slot) + +func choose_replacement(battle : Battle, slot : BattleSlot) -> PokemonInstance: + return await ui.request_replacement(battle, slot) diff --git a/game/battle/player_battle_controller.gd.uid b/game/battle/player_battle_controller.gd.uid new file mode 100644 index 0000000..7fb65d5 --- /dev/null +++ b/game/battle/player_battle_controller.gd.uid @@ -0,0 +1 @@ +uid://boa8hppadjl2e diff --git a/game/core/game_data.gd b/game/core/game_data.gd index 6448036..19491d5 100644 --- a/game/core/game_data.gd +++ b/game/core/game_data.gd @@ -2,11 +2,13 @@ extends Node # Autoload : GameData signal selected_pokemon_changed -signal battle_requested(sent : SentPokemon, target : Node) #emis par ThrowState : le systeme de combat s'y branchera +signal battle_requested(sent : SentPokemon, target : Node) #emis par ThrowState : BattleLauncher s'y branche +signal battle_finished(outcome : int, target : Node) #emis par BattleLauncher (outcome = valeur de Battle.Outcome) var story_progress : int = 0 # 0 = début, 1 = après 1er badge, 2 = après 2e badge, etc. var player_team : PokemonTeam = PokemonTeam.new() var player_storage : PokemonStorage = PokemonStorage.new() +var in_battle : bool = false #true pendant un combat (le monde est en pause) var selected_index : int = -1 #pokemon de l'equipe choisi pour etre envoye (HUD), -1 = aucun func give_pokemon(pokemon : PokemonInstance) -> PokemonStorage.ReceiveResult: diff --git a/game/player/aim/aim_target.gd b/game/player/aim/aim_target.gd index c83b38b..ce3bc85 100644 --- a/game/player/aim/aim_target.gd +++ b/game/player/aim/aim_target.gd @@ -3,10 +3,15 @@ extends Marker3D class_name AimTarget signal locked_changed(is_locked : bool) +signal battle_finished(outcome : int) #emis a la fin d'un combat lance depuis cette cible (valeur de Battle.Outcome) @export var enabled : bool = true @export var priority : float = 0.0 #plus c'est haut, plus la cible est favorisee +@export_group("Battle") +@export var encounter : BattleEncounter #si defini, lancer un pokemon sur cette cible demarre ce combat +@export var world_actor : Node3D #le pokemon/dresseur visible dans le monde : cache pendant le combat, supprime apres une victoire + @export_group("Indicator") @export var indicator : Node3D #optionnel : ton propre visuel (remplace le losange par defaut) @export var show_default_diamond : bool = true @@ -16,6 +21,10 @@ signal locked_changed(is_locked : bool) var is_locked : bool = false +#utilise par ThrowState : true si cette cible declenche un combat +var starts_battle : bool: + get: return encounter != null + func _ready() -> void: add_to_group("aim_targets") diff --git a/game/player/states/throw_state.gd b/game/player/states/throw_state.gd index e5baf95..7d7e191 100644 --- a/game/player/states/throw_state.gd +++ b/game/player/states/throw_state.gd @@ -143,6 +143,9 @@ func _on_ball_landed(landing_pos : Vector3, pokemon : PokemonInstance, target : if target_valid and _starts_battle(target): GameData.battle_requested.emit(sent, target) + elif target_valid: + #diagnostic : retire ce message quand tout fonctionne + push_warning("ThrowState : la cible '%s' ne lance pas de combat (starts_battle faux : Encounter vide, ou aim_target.gd pas remplace)." % target.name) #---------- nettoyage ---------- diff --git a/game/pokemon/sent_pokemon.gd b/game/pokemon/sent_pokemon.gd index 82da5ec..239fefc 100644 --- a/game/pokemon/sent_pokemon.gd +++ b/game/pokemon/sent_pokemon.gd @@ -54,19 +54,24 @@ func _ready() -> void: add_to_group(GROUP) func _build_model() -> void: - var scene : PackedScene = pokemon.species.model_scene + model = create_model(pokemon) + add_child(model) + +static func create_model(p : PokemonInstance) -> Node3D: + #modele 3D de l'espece ; capsule de remplacement s'il n'y en a pas + var result : Node3D = null + var scene : PackedScene = p.species.model_scene if scene: - model = scene.instantiate() as Node3D - if model == null: - #modele de remplacement si l'espece n'a pas de modele 3D + result = scene.instantiate() as Node3D + if result == null: var mesh_instance := MeshInstance3D.new() var capsule := CapsuleMesh.new() capsule.radius = 0.3 capsule.height = 1.0 mesh_instance.mesh = capsule mesh_instance.position.y = 0.5 - model = mesh_instance - add_child(model) + result = mesh_instance + return result func recall() -> void: recalled.emit() diff --git a/game/ui/main_menu/main_menu.gd b/game/ui/main_menu/main_menu.gd index 3f797e4..0a673a2 100644 --- a/game/ui/main_menu/main_menu.gd +++ b/game/ui/main_menu/main_menu.gd @@ -24,7 +24,7 @@ func _ready() -> void: play_menu_music() fade_in_game_infos() -func _process(delta: float) -> void: +func _process(_delta: float) -> void: if !is_transitioning: Start.grab_focus() diff --git a/game/ui/pause_menu/pause_menu.gd b/game/ui/pause_menu/pause_menu.gd index f7e33f0..4773de5 100644 --- a/game/ui/pause_menu/pause_menu.gd +++ b/game/ui/pause_menu/pause_menu.gd @@ -23,7 +23,7 @@ func _ready() -> void: animation_player.play("RESET") func _unhandled_input(event: InputEvent) -> void: - if is_transitioning or sub_screen != null: return #une page ouverte gere ses propres entrees + if is_transitioning or sub_screen != null or GameData.in_battle: return #une page ouverte gere ses propres entrees if event.is_action_pressed("pause"): if get_tree().paused: resume() diff --git a/game/world/main.tscn b/game/world/main.tscn index 0381bbf..302e617 100644 --- a/game/world/main.tscn +++ b/game/world/main.tscn @@ -6,9 +6,14 @@ [ext_resource type="Texture2D" uid="uid://djg8y25y06h5n" path="res://assets/textures/grids/Green/texture_09.png" id="4_jcfev"] [ext_resource type="Texture2D" uid="uid://hu5rfl6qr2nv" path="res://assets/textures/grids/Red/texture_09.png" id="5_rkxil"] [ext_resource type="Script" uid="uid://ce2wj6d8xb055" path="res://game/player/aim/aim_target.gd" id="6_ajh0j"] +[ext_resource type="Script" uid="uid://u2t6dm1ee3se" path="res://game/battle/battle_encounter.gd" id="7_g80jn"] [ext_resource type="PackedScene" uid="uid://di22cs2qposdm" path="res://game/ui/hud/ui.tscn" id="7_yebv1"] +[ext_resource type="Script" uid="uid://cqsokrfi2d05y" path="res://game/pokemon/trainer_party_entry.gd" id="8_42ub6"] [ext_resource type="PackedScene" uid="uid://d1mcef8gl66j" path="res://game/ui/pause_menu/pause_menu.tscn" id="8_g80jn"] [ext_resource type="PackedScene" uid="uid://dwxpt0s7vn36k" path="res://game/player/player_character.tscn" id="9_42ub6"] +[ext_resource type="Script" uid="uid://chc1mf57j5m4p" path="res://addons/pokeapi_importer/data/move_data.gd" id="9_qk77p"] +[ext_resource type="Script" uid="uid://bx0htbw7ygkww" path="res://game/battle/battle_launcher.gd" id="10_rr16o"] +[ext_resource type="Resource" uid="uid://caim7okcsrjq4" path="res://data/pokeapi/pokemon/0025_pikachu.tres" id="10_ysylv"] [sub_resource type="ProceduralSkyMaterial" id="ProceduralSkyMaterial_tipki"] sky_horizon_color = Color(0.66224277, 0.6717428, 0.6867428, 1) @@ -45,6 +50,18 @@ uv1_triplanar = true [sub_resource type="StandardMaterial3D" id="StandardMaterial3D_tipki"] albedo_texture = ExtResource("5_rkxil") +[sub_resource type="Resource" id="Resource_yebv1"] +script = ExtResource("8_42ub6") +species = ExtResource("10_ysylv") +nickname = "Testkatchu" +metadata/_custom_type_script = "uid://cqsokrfi2d05y" + +[sub_resource type="Resource" id="Resource_rr16o"] +script = ExtResource("7_g80jn") +wild_pokemon = Array[ExtResource("8_42ub6")]([SubResource("Resource_yebv1")]) +can_flee = false +metadata/_custom_type_script = "uid://u2t6dm1ee3se" + [node name="world" type="Node3D" unique_id=832308324] [node name="DirectionalLight3D" type="DirectionalLight3D" parent="." unique_id=455638012] @@ -105,14 +122,19 @@ material_override = SubResource("StandardMaterial3D_tipki") use_collision = true size = Vector3(0.5, 0.5, 0.5) -[node name="AimTarget" type="Marker3D" parent="map/target3" unique_id=1848628150] +[node name="AimTarget" type="Marker3D" parent="map/target3" unique_id=1848628150 node_paths=PackedStringArray("world_actor")] transform = Transform3D(1, 0, 3.7252903e-09, 0, 1, 0, -3.7252903e-09, 0, 1, 0, 0.030708313, 0) script = ExtResource("6_ajh0j") +encounter = SubResource("Resource_rr16o") +world_actor = NodePath("..") + +[node name="BattleLauncher" type="Node" parent="." unique_id=1234981764] +script = ExtResource("10_rr16o") [node name="Ui" parent="." unique_id=1881560149 instance=ExtResource("7_yebv1")] [node name="PauseMenu" parent="." unique_id=871841579 instance=ExtResource("8_g80jn")] process_mode = 3 -[node name="PlayerCharacter" parent="." unique_id=1830301535 instance=ExtResource("9_42ub6")] +[node name="PlayerCharacter" parent="." unique_id=1830301535 groups=["player"] instance=ExtResource("9_42ub6")] transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 1.2155721, -0.00040316582, 0.73769236) diff --git a/project.godot b/project.godot index 97bad7c..6dc7a03 100644 --- a/project.godot +++ b/project.godot @@ -37,6 +37,10 @@ window/stretch/aspect="expand" enabled=PackedStringArray("res://addons/controller_icons/plugin.cfg", "res://addons/pokeapi_importer/plugin.cfg") +[global_group] + +player="" + [input] ui_accept={