From 9a3c383b269e835d0fcffe472eefb6e2acc73ddb Mon Sep 17 00:00:00 2001 From: TheThomaas Date: Tue, 6 Oct 2026 13:27:26 +0200 Subject: [PATCH] Start work on battle system --- game/battle/battle.gd | 297 ++++++++++++++++++++++ game/battle/battle.gd.uid | 1 + game/battle/battle_action.gd | 48 ++++ game/battle/battle_action.gd.uid | 1 + game/battle/battle_ai.gd | 143 +++++++++++ game/battle/battle_ai.gd.uid | 1 + game/battle/battle_controller.gd | 11 + game/battle/battle_controller.gd.uid | 1 + game/battle/battle_debug.gd | 49 ++++ game/battle/battle_debug.gd.uid | 1 + game/battle/battle_event.gd | 55 ++++ game/battle/battle_event.gd.uid | 1 + game/battle/battle_mon.gd | 33 +++ game/battle/battle_mon.gd.uid | 1 + game/battle/battle_participant.gd | 36 +++ game/battle/battle_participant.gd.uid | 1 + game/battle/battle_presenter.gd | 8 + game/battle/battle_presenter.gd.uid | 1 + game/battle/battle_print_presenter.gd | 8 + game/battle/battle_print_presenter.gd.uid | 1 + game/battle/battle_setup.gd | 38 +++ game/battle/battle_setup.gd.uid | 1 + game/battle/battle_slot.gd | 25 ++ game/battle/battle_slot.gd.uid | 1 + game/battle/damage_calculator.gd | 40 +++ game/battle/damage_calculator.gd.uid | 1 + game/battle/target_resolver.gd | 67 +++++ game/battle/target_resolver.gd.uid | 1 + game/battle/type_chart.gd | 41 +++ game/battle/type_chart.gd.uid | 1 + game/world/battle.tscn | 6 + 31 files changed, 920 insertions(+) create mode 100644 game/battle/battle.gd create mode 100644 game/battle/battle.gd.uid create mode 100644 game/battle/battle_action.gd create mode 100644 game/battle/battle_action.gd.uid create mode 100644 game/battle/battle_ai.gd create mode 100644 game/battle/battle_ai.gd.uid create mode 100644 game/battle/battle_controller.gd create mode 100644 game/battle/battle_controller.gd.uid create mode 100644 game/battle/battle_debug.gd create mode 100644 game/battle/battle_debug.gd.uid create mode 100644 game/battle/battle_event.gd create mode 100644 game/battle/battle_event.gd.uid create mode 100644 game/battle/battle_mon.gd create mode 100644 game/battle/battle_mon.gd.uid create mode 100644 game/battle/battle_participant.gd create mode 100644 game/battle/battle_participant.gd.uid create mode 100644 game/battle/battle_presenter.gd create mode 100644 game/battle/battle_presenter.gd.uid create mode 100644 game/battle/battle_print_presenter.gd create mode 100644 game/battle/battle_print_presenter.gd.uid create mode 100644 game/battle/battle_setup.gd create mode 100644 game/battle/battle_setup.gd.uid create mode 100644 game/battle/battle_slot.gd create mode 100644 game/battle/battle_slot.gd.uid create mode 100644 game/battle/damage_calculator.gd create mode 100644 game/battle/damage_calculator.gd.uid create mode 100644 game/battle/target_resolver.gd create mode 100644 game/battle/target_resolver.gd.uid create mode 100644 game/battle/type_chart.gd create mode 100644 game/battle/type_chart.gd.uid create mode 100644 game/world/battle.tscn diff --git a/game/battle/battle.gd b/game/battle/battle.gd new file mode 100644 index 0000000..46004af --- /dev/null +++ b/game/battle/battle.gd @@ -0,0 +1,297 @@ +class_name Battle +extends RefCounted + +#moteur de combat au tour par tour, sans aucun visuel. +#gere n emplacements par camp (1v1, 2v2, 1 contre plusieurs, plusieurs contre 1). +# +#usage : +# var battle := Battle.new(setup, mon_presenter) +# var outcome : Battle.Outcome = await battle.run() +# +#a chaque evenement, la presentation est appelee et le combat attend qu'elle ait fini. + +signal event_emitted(event : BattleEvent) + +enum Outcome { NONE, PLAYER_WON, PLAYER_LOST, FLED } + +const PLAYER_SIDE : int = 0 + +static var _struggle : MoveData = null + +var setup : BattleSetup +var presenter : BattlePresenter +var rng : RandomNumberGenerator = RandomNumberGenerator.new() +var turn : int = 0 +var outcome : Outcome = Outcome.NONE +var slots : Array[BattleSlot] = [] + +func _init(p_setup : BattleSetup, p_presenter : BattlePresenter = null) -> void: + setup = p_setup + presenter = p_presenter + if setup.seed_value != 0: rng.seed = setup.seed_value + else: rng.randomize() + _build_slots() + +func _build_slots() -> void: + var per_side_count : Dictionary = {} + for participant in setup.participants: + for pokemon in participant.pick_starters(): + var slot := BattleSlot.new() + slot.id = slots.size() + slot.side = participant.side + slot.index = int(per_side_count.get(participant.side, 0)) + per_side_count[participant.side] = slot.index + 1 + slot.participant = participant + slot.set_pokemon(pokemon) + slots.append(slot) + +#---------- boucle principale ---------- + +func run() -> Outcome: + await _emit(BattleEvent.make(BattleEvent.Type.BATTLE_START)) + for slot in slots: + await _emit(BattleEvent.make(BattleEvent.Type.SENT_OUT, slot)) + + if _check_end(): + return await _finish() + + while outcome == Outcome.NONE: + turn += 1 + var turn_event := BattleEvent.make(BattleEvent.Type.TURN_START) + turn_event.amount = turn + await _emit(turn_event) + + var actions : Array[BattleAction] = await _collect_actions() + _sort_actions(actions) + for action in actions: + await _execute(action) + if _check_end(): break + if outcome != Outcome.NONE: break + + await _replace_fainted() + if _check_end(): break + + return await _finish() + +func _finish() -> Outcome: + var end_event := BattleEvent.make(BattleEvent.Type.BATTLE_END) + match outcome: + Outcome.PLAYER_WON: end_event.text = "Victoire !" + Outcome.PLAYER_LOST: end_event.text = "Défaite..." + Outcome.FLED: end_event.text = "Vous avez pris la fuite." + await _emit(end_event) + return outcome + +func _emit(event : BattleEvent) -> void: + event_emitted.emit(event) + if presenter != null: + await presenter.present(event) + +func _msg(text : String) -> void: + var e := BattleEvent.make(BattleEvent.Type.MESSAGE) + e.text = text + await _emit(e) + +#---------- fin de combat ---------- + +func _side_alive(side : int) -> bool: + for participant in setup.participants: + if participant.side == side and participant.has_able_pokemon(): return true + return false + +func _check_end() -> bool: + if outcome != Outcome.NONE: return true + var player_alive : bool = _side_alive(PLAYER_SIDE) + var enemy_alive : bool = _side_alive(1 - PLAYER_SIDE) + if player_alive and enemy_alive: return false + outcome = Outcome.PLAYER_WON if player_alive else Outcome.PLAYER_LOST + return true + +#---------- actions du tour ---------- + +func _collect_actions() -> Array[BattleAction]: + var actions : Array[BattleAction] = [] + for slot in slots: + if !slot.is_active(): continue + var action : BattleAction = await slot.participant.controller.choose_action(self, slot) + if action == null: action = BattleAction.use_struggle(slot) #securite : un controleur qui ne decide rien + actions.append(action) + return actions + +func _sort_actions(actions : Array[BattleAction]) -> void: + for a in actions: + match a.type: + BattleAction.Type.FLEE: a.priority = 8 + BattleAction.Type.SWITCH: a.priority = 7 + BattleAction.Type.ITEM: a.priority = 6 + _: a.priority = a.move.priority if a.move else 0 + a.speed = a.slot.mon.get_stat(PokemonNature.Stat.SPEED) + a.tiebreak = rng.randf() + + actions.sort_custom(func(a : BattleAction, b : BattleAction) -> bool: + if a.priority != b.priority: return a.priority > b.priority + if a.speed != b.speed: return a.speed > b.speed + return a.tiebreak > b.tiebreak) + +func _execute(action : BattleAction) -> void: + if !action.slot.is_active(): return #mis K.O. avant son tour + match action.type: + BattleAction.Type.MOVE: await _execute_move(action) + BattleAction.Type.SWITCH: await _execute_switch(action) + BattleAction.Type.FLEE: await _execute_flee(action) + +func _execute_flee(action : BattleAction) -> void: + if setup.can_flee and action.slot.side == PLAYER_SIDE: + outcome = Outcome.FLED + await _emit(BattleEvent.make(BattleEvent.Type.FLED, action.slot)) + else: + await _msg("Impossible de fuir !") + +func _execute_switch(action : BattleAction) -> void: + var slot : BattleSlot = action.slot + if !get_replacement_options(slot).has(action.switch_to): return + await _emit(BattleEvent.make(BattleEvent.Type.WITHDRAWN, slot)) + slot.set_pokemon(action.switch_to) + await _emit(BattleEvent.make(BattleEvent.Type.SENT_OUT, slot)) + +func _execute_move(action : BattleAction) -> void: + var actor : BattleSlot = action.slot + var move : MoveData = action.move + + if action.move_slot != null: + if !action.move_slot.use(): + await _msg("%s n'a plus de PP pour cette capacité !" % actor.mon.display_name()) + return + + var used := BattleEvent.make(BattleEvent.Type.MOVE_USED, actor) + used.move = move + await _emit(used) + + var targets : Array[BattleSlot] = TargetResolver.resolve(self, actor, move, action.chosen_target) + if targets.is_empty(): + await _msg("Mais cela échoue !") + return + + var spread : bool = targets.size() > 1 + var status_message_shown : bool = false + + for target in targets: + if !target.is_active(): continue + + if move.power <= 0 or move.category == "status": + #les effets des capacites de statut ne sont pas encore implementes + if !status_message_shown: + status_message_shown = true + await _msg("Mais cela n'a pas d'effet pour l'instant.") + continue + + if !_hits(move): + await _emit(BattleEvent.make(BattleEvent.Type.MOVE_MISSED, actor, target)) + continue + + var calc : Dictionary = DamageCalculator.calculate(actor.mon, target.mon, move, rng, spread) + var effectiveness : float = calc["effectiveness"] + if effectiveness == 0.0: + await _emit(BattleEvent.make(BattleEvent.Type.NO_EFFECT, actor, target)) + continue + + await _apply_damage(actor, target, int(calc["damage"])) + if calc["critical"]: await _emit(BattleEvent.make(BattleEvent.Type.CRITICAL, actor, target)) + if effectiveness > 1.0: await _emit(BattleEvent.make(BattleEvent.Type.SUPER_EFFECTIVE, actor, target)) + elif effectiveness < 1.0: await _emit(BattleEvent.make(BattleEvent.Type.NOT_VERY_EFFECTIVE, actor, target)) + if target.mon.is_fainted(): await _emit(BattleEvent.make(BattleEvent.Type.FAINTED, target)) + + if _check_end(): return + + #Lutte : contrecoup pour l'utilisateur + if move.api_name == "struggle" and actor.mon != null and !actor.mon.is_fainted(): + var recoil : int = maxi(1, floori(actor.mon.pokemon.get_max_hp() / 4.0)) + await _apply_damage(actor, actor, recoil) + if actor.mon.is_fainted(): await _emit(BattleEvent.make(BattleEvent.Type.FAINTED, actor)) + +func _apply_damage(source : BattleSlot, target : BattleSlot, amount : int) -> void: + var before : int = target.mon.pokemon.current_hp + target.mon.pokemon.take_damage(amount) + var event := BattleEvent.make(BattleEvent.Type.DAMAGE, source, target) + event.amount = before - target.mon.pokemon.current_hp + event.hp = target.mon.pokemon.current_hp + event.max_hp = target.mon.pokemon.get_max_hp() + await _emit(event) + +func _hits(move : MoveData) -> bool: + #precision 0 = ne rate jamais (valeur PokeAPI "null") + return move.accuracy <= 0 or rng.randi_range(1, 100) <= move.accuracy + +#---------- remplacements ---------- + +func _replace_fainted() -> void: + for slot in slots: + if slot.mon == null or !slot.mon.is_fainted(): continue + + var options : Array[PokemonInstance] = get_replacement_options(slot) + if options.is_empty(): + slot.clear() #plus de remplacant : l'emplacement reste vide + continue + + var chosen : PokemonInstance = await slot.participant.controller.choose_replacement(self, slot) + if chosen == null or !options.has(chosen): chosen = options[0] + slot.set_pokemon(chosen) + await _emit(BattleEvent.make(BattleEvent.Type.SENT_OUT, slot)) + +#---------- requetes (utilisees par les controleurs et TargetResolver) ---------- + +func get_slot(id : int) -> BattleSlot: + return slots[id] if id >= 0 and id < slots.size() else null + +func get_active_slots() -> Array[BattleSlot]: + var result : Array[BattleSlot] = [] + for s in slots: + if s.is_active(): result.append(s) + return result + +func get_opponents(slot : BattleSlot) -> Array[BattleSlot]: + var result : Array[BattleSlot] = [] + for s in slots: + if s.is_active() and s.side != slot.side: result.append(s) + return result + +func get_allies(slot : BattleSlot) -> Array[BattleSlot]: + var result : Array[BattleSlot] = [] + for s in slots: + if s.is_active() and s.side == slot.side and s != slot: result.append(s) + return result + +func get_usable_moves(mon : BattleMon) -> Array[MoveSlot]: + var result : Array[MoveSlot] = [] + for ms in mon.pokemon.active_moves: + if ms.has_pp(): result.append(ms) + return result + +func get_replacement_options(slot : BattleSlot) -> Array[PokemonInstance]: + #pokemon aptes de l'equipe du participant qui ne sont pas deja en jeu + var result : Array[PokemonInstance] = [] + for p in slot.participant.team.members: + if p.is_fainted() or _is_in_play(p): continue + result.append(p) + return result + +func _is_in_play(pokemon : PokemonInstance) -> bool: + for s in slots: + if s.mon != null and s.mon.pokemon == pokemon: return true + return false + +#---------- Lutte ---------- + +static func make_struggle() -> MoveData: + if _struggle == null: + _struggle = MoveData.new() + _struggle.api_name = "struggle" + _struggle.names = {"fr": "Lutte", "en": "Struggle"} + _struggle.type = "typeless" + _struggle.category = "physical" + _struggle.power = 50 + _struggle.accuracy = 0 + _struggle.pp = 1 + _struggle.priority = 0 + _struggle.target = "random-opponent" + return _struggle diff --git a/game/battle/battle.gd.uid b/game/battle/battle.gd.uid new file mode 100644 index 0000000..d46c8bb --- /dev/null +++ b/game/battle/battle.gd.uid @@ -0,0 +1 @@ +uid://jqtd5v08rwtv diff --git a/game/battle/battle_action.gd b/game/battle/battle_action.gd new file mode 100644 index 0000000..8631838 --- /dev/null +++ b/game/battle/battle_action.gd @@ -0,0 +1,48 @@ +class_name BattleAction +extends RefCounted + +#ce qu'un emplacement choisit de faire pendant un tour + +enum Type { MOVE, SWITCH, ITEM, FLEE } + +var type : Type = Type.MOVE +var slot : BattleSlot #qui agit +var move : MoveData +var move_slot : MoveSlot #null pour Lutte +var chosen_target : BattleSlot = null #cible choisie (null = automatique) +var switch_to : PokemonInstance + +#calcules par Battle au moment de trier les actions +var priority : int = 0 +var speed : float = 0.0 +var tiebreak : float = 0.0 + +static func use_move(p_slot : BattleSlot, p_move_slot : MoveSlot, p_target : BattleSlot = null) -> BattleAction: + var a := BattleAction.new() + a.type = Type.MOVE + a.slot = p_slot + a.move_slot = p_move_slot + a.move = p_move_slot.move + a.chosen_target = p_target + return a + +static func use_struggle(p_slot : BattleSlot) -> BattleAction: + #plus aucune capacite utilisable : Lutte + var a := BattleAction.new() + a.type = Type.MOVE + a.slot = p_slot + a.move = Battle.make_struggle() + return a + +static func switch_pokemon(p_slot : BattleSlot, p_pokemon : PokemonInstance) -> BattleAction: + var a := BattleAction.new() + a.type = Type.SWITCH + a.slot = p_slot + a.switch_to = p_pokemon + return a + +static func flee(p_slot : BattleSlot) -> BattleAction: + var a := BattleAction.new() + a.type = Type.FLEE + a.slot = p_slot + return a diff --git a/game/battle/battle_action.gd.uid b/game/battle/battle_action.gd.uid new file mode 100644 index 0000000..8539461 --- /dev/null +++ b/game/battle/battle_action.gd.uid @@ -0,0 +1 @@ +uid://hispkfk8qdt6 diff --git a/game/battle/battle_ai.gd b/game/battle/battle_ai.gd new file mode 100644 index 0000000..ec1a735 --- /dev/null +++ b/game/battle/battle_ai.gd @@ -0,0 +1,143 @@ +class_name BattleAI +extends BattleController + +#IA simple : +#- attaque : choisit la capacite et la cible qui font le plus de degats attendus +# (achever un adversaire est favorise, taper un allie est penalise) +#- changement volontaire : si son pokemon est tres desavantage (types) et a peu de PV, elle peut +# le remplacer par un pokemon du banc qui a un meilleur rapport de types face aux adversaires + +var randomness : float = 0.15 #chance de jouer un coup au hasard, pour ne pas etre previsible + +var can_switch : bool = true #false : ne change jamais volontairement (ex. un pokemon sauvage n'a de toute facon pas de banc) +var switch_chance : float = 0.6 #quand les conditions sont reunies, probabilite de vraiment changer +var hp_threshold : float = 0.4 #ne change que si ses PV sont sous ce ratio +var matchup_threshold : float = -1.0 #ne change que si le rapport de types est pire que cette valeur +var min_improvement : float = 1.0 #le remplacant doit etre meilleur d'au moins cette valeur +var min_replacement_hp : float = 0.3 #ne fait pas entrer un pokemon quasi K.O. + +const KILL_SCORE : float = 20.0 #bonus de score d'une attaque qui acheve un adversaire (cf. _score) + +var _claimed : Array[PokemonInstance] = [] #remplacants deja choisis ce tour (evite 2 emplacements sur le meme) +var _claimed_turn : int = -1 + +func choose_action(battle : Battle, slot : BattleSlot) -> BattleAction: + if _claimed_turn != battle.turn: + _claimed.clear() + _claimed_turn = battle.turn + + var usable : Array[MoveSlot] = battle.get_usable_moves(slot.mon) + + #options d'attaque = [MoveSlot, cible (BattleSlot ou null), score] + var options : Array = [] + for move_slot in usable: + var move : MoveData = move_slot.move + var candidates : Array = [null] + if TargetResolver.needs_selection(battle, slot, move): + candidates = TargetResolver.get_selectable_targets(battle, slot, move) + for candidate in candidates: + options.append([move_slot, candidate, _score(battle, slot, move, candidate) + battle.rng.randf() * 0.01]) + + var best : Array = [] + for option in options: + if best.is_empty() or option[2] > best[2]: best = option + + #changement volontaire ? + var best_score : float = best[2] if !best.is_empty() else -1.0 + var swap : BattleAction = _consider_switch(battle, slot, best_score, usable.is_empty()) + if swap != null: return swap + + if options.is_empty(): return BattleAction.use_struggle(slot) + + var chosen : Array = options.pick_random() if battle.rng.randf() < randomness else best + return BattleAction.use_move(slot, chosen[0], chosen[1]) + +func choose_replacement(battle : Battle, slot : BattleSlot) -> PokemonInstance: + #remplacement force (pokemon K.O.) : le meilleur rapport de types face aux adversaires + var options : Array[PokemonInstance] = battle.get_replacement_options(slot) + if options.is_empty(): return null + var opponents : Array[BattleSlot] = battle.get_opponents(slot) + var best : PokemonInstance = options[0] + var best_value : float = -INF + for p in options: + var value : float = _matchup(_types_of(p), opponents) + if value > best_value: + best = p + best_value = value + return best + +#---------- changement volontaire ---------- + +func _consider_switch(battle : Battle, slot : BattleSlot, best_attack_score : float, no_usable_move : bool) -> BattleAction: + if !can_switch: return null + + var bench : Array[PokemonInstance] = [] + for p in battle.get_replacement_options(slot): + if !_claimed.has(p): bench.append(p) + if bench.is_empty(): return null + + var opponents : Array[BattleSlot] = battle.get_opponents(slot) + var current : float = _matchup(slot.mon.get_types(), opponents) + + if !no_usable_move: + if best_attack_score >= KILL_SCORE: return null #peut achever un adversaire : on attaque + var hp_ratio : float = float(slot.mon.pokemon.current_hp) / float(slot.mon.pokemon.get_max_hp()) + if hp_ratio > hp_threshold or current > matchup_threshold: return null + if battle.rng.randf() > switch_chance: return null + + var best : PokemonInstance = null + var best_value : float = -INF + for p in bench: + if !no_usable_move and float(p.current_hp) / float(p.get_max_hp()) < min_replacement_hp: continue + var value : float = _matchup(_types_of(p), opponents) + if value > best_value: + best = p + best_value = value + if best == null: return null + if !no_usable_move and best_value < current + min_improvement: return null + + _claimed.append(best) + return BattleAction.switch_pokemon(slot, best) + +func _matchup(my_types : Array[String], opponents : Array[BattleSlot]) -> float: + #rapport de types moyen face aux adversaires : (meilleure efficacite offensive) - (meilleure efficacite defensive subie) + #positif = avantage, negatif = desavantage + if opponents.is_empty(): return 0.0 + var total : float = 0.0 + for opponent in opponents: + var their_types : Array[String] = opponent.mon.get_types() + var offense : float = 0.0 + for t in my_types: + offense = maxf(offense, TypeChart.get_multiplier(t, their_types)) + var defense : float = 0.0 + for t in their_types: + defense = maxf(defense, TypeChart.get_multiplier(t, my_types)) + total += offense - defense + return total / opponents.size() + +func _types_of(pokemon : PokemonInstance) -> Array[String]: + var result : Array[String] = [] + for t in pokemon.species.types: + result.append(TypeChart.type_name(t)) + return result + +#---------- attaque ---------- + +func _score(battle : Battle, slot : BattleSlot, move : MoveData, candidate : Variant) -> float: + var targets : Array[BattleSlot] = TargetResolver.resolve(battle, slot, move, candidate) + if targets.is_empty(): return -1.0 + if move.power <= 0 or move.category == "status": return 0.1 #pas d'effet implemente : dernier recours + + var accuracy : float = 1.0 if move.accuracy <= 0 else move.accuracy / 100.0 + var spread : bool = targets.size() > 1 + var score : float = 0.0 + for t in targets: + var damage : int = DamageCalculator.estimate(slot.mon, t.mon, move, spread) + var remaining : int = t.mon.pokemon.current_hp + var value : float = float(mini(damage, remaining)) * accuracy + if t.side == slot.side: + score -= value + else: + score += value + if damage >= remaining: score += KILL_SCORE + return score diff --git a/game/battle/battle_ai.gd.uid b/game/battle/battle_ai.gd.uid new file mode 100644 index 0000000..97c04be --- /dev/null +++ b/game/battle/battle_ai.gd.uid @@ -0,0 +1 @@ +uid://dbkfxdanlrke1 diff --git a/game/battle/battle_controller.gd b/game/battle/battle_controller.gd new file mode 100644 index 0000000..edb557a --- /dev/null +++ b/game/battle/battle_controller.gd @@ -0,0 +1,11 @@ +class_name BattleController +extends RefCounted + +#decide pour un participant : IA (BattleAI) ou joueur (menus, a venir). +#les deux fonctions peuvent utiliser "await" (le joueur attend un choix dans l'interface). + +func choose_action(_battle : Battle, _slot : BattleSlot) -> BattleAction: + return null + +func choose_replacement(_battle : Battle, _slot : BattleSlot) -> PokemonInstance: + return null diff --git a/game/battle/battle_controller.gd.uid b/game/battle/battle_controller.gd.uid new file mode 100644 index 0000000..8170771 --- /dev/null +++ b/game/battle/battle_controller.gd.uid @@ -0,0 +1 @@ +uid://cw4o36nfqrbh8 diff --git a/game/battle/battle_debug.gd b/game/battle/battle_debug.gd new file mode 100644 index 0000000..026c47d --- /dev/null +++ b/game/battle/battle_debug.gd @@ -0,0 +1,49 @@ +class_name BattleDebug +extends Node + +#banc d'essai : simule un combat complet IA contre IA et ecrit tout dans la console. +#ajoute ce node a une scene, regle le format dans l'inspecteur, lance la scene. +#utilise des equipes aleatoires (par defaut) : n'abime pas ton equipe de jeu. + +@export var run_on_ready : bool = true +@export_range(1, 3) var player_slots : int = 1 #emplacements du joueur +@export_range(1, 5) var enemy_count : int = 1 #nombre d'adversaires (chacun est un participant) +@export_range(1, 3) var enemy_slots_each : int = 1 #emplacements de chaque adversaire +@export_range(1, 6) var team_size : int = 3 +@export_range(1, 100) var level : int = 10 +@export var use_game_team : bool = false #true : utilise GameData.player_team (qui sera modifiee !) + +func _ready() -> void: + if run_on_ready: + await get_tree().create_timer(0.5).timeout + run_test() + +func run_test() -> void: + var player_team : PokemonTeam = GameData.player_team if use_game_team else _random_team(maxi(team_size, player_slots)) + + var setup := BattleSetup.new() + setup.add(BattleSetup.player_participant(player_team, player_slots, null, BattleAI.new())) + for i in enemy_count: + var team : PokemonTeam = _random_team(enemy_slots_each + 1) + var participant := BattleParticipant.new("Adversaire %d" % (i + 1), team, 1, BattleAI.new(), enemy_slots_each) + setup.add(participant) + + if !setup.is_valid(): + push_error("BattleDebug : combat invalide (une equipe n'a aucun pokemon apte)") + return + + print("===== Combat : %d contre %d =====" % [player_slots, enemy_count * enemy_slots_each]) + var battle := Battle.new(setup, BattlePrintPresenter.new()) + var outcome : Battle.Outcome = await battle.run() + print("===== Resultat : %s apres %d tours =====" % [Battle.Outcome.keys()[outcome], battle.turn]) + +func _random_team(size : int) -> PokemonTeam: + var team := PokemonTeam.new() + var ids : Array = Pokedex.all_ids() + if ids.is_empty(): + push_error("BattleDebug : le Pokedex ne contient aucun pokemon") + return team + for i in size: + var species : PokemonData = Pokedex.get_pokemon(ids.pick_random()) + if species: team.add_pokemon(PokemonInstance.create(species, level)) + return team diff --git a/game/battle/battle_debug.gd.uid b/game/battle/battle_debug.gd.uid new file mode 100644 index 0000000..ebd1267 --- /dev/null +++ b/game/battle/battle_debug.gd.uid @@ -0,0 +1 @@ +uid://ulemh0n2lfft diff --git a/game/battle/battle_event.gd b/game/battle/battle_event.gd new file mode 100644 index 0000000..ca3aa79 --- /dev/null +++ b/game/battle/battle_event.gd @@ -0,0 +1,55 @@ +class_name BattleEvent +extends RefCounted + +#ce qui se passe pendant un combat. La logique les emet ; la presentation (texte, animations 3D, +#barres de PV) les lit un par un. Les emplacements sont identifies par leur id. + +enum Type { + BATTLE_START, BATTLE_END, TURN_START, + SENT_OUT, WITHDRAWN, + MOVE_USED, MOVE_MISSED, DAMAGE, CRITICAL, + SUPER_EFFECTIVE, NOT_VERY_EFFECTIVE, NO_EFFECT, + FAINTED, FLED, MESSAGE, +} + +var type : Type = Type.MESSAGE +var source_slot : int = -1 +var target_slot : int = -1 +var source_name : String = "" +var target_name : String = "" +var move : MoveData = null +var amount : int = 0 #degats, numero de tour... +var hp : int = 0 #PV restants de la cible +var max_hp : int = 0 +var text : String = "" + +static func make(p_type : Type, p_source : BattleSlot = null, p_target : BattleSlot = null) -> BattleEvent: + var e := BattleEvent.new() + e.type = p_type + if p_source != null: + e.source_slot = p_source.id + if p_source.mon != null: e.source_name = p_source.mon.display_name() + if p_target != null: + e.target_slot = p_target.id + if p_target.mon != null: e.target_name = p_target.mon.display_name() + return e + +func describe() -> String: + #texte par defaut (boite de dialogue, journal de debug) + match type: + Type.BATTLE_START: return "Le combat commence !" + Type.BATTLE_END: return text + Type.TURN_START: return "— Tour %d —" % amount + Type.SENT_OUT: return "%s entre en combat !" % source_name + Type.WITHDRAWN: return "%s est rappelé !" % source_name + Type.MOVE_USED: return "%s utilise %s !" % [source_name, move.get_display_name() if move else "?"] + Type.MOVE_MISSED: return "%s évite l'attaque !" % target_name + Type.DAMAGE: return "%s perd %d PV (%d/%d)" % [target_name, amount, hp, max_hp] + Type.CRITICAL: return "Coup critique !" + Type.SUPER_EFFECTIVE: return "C'est super efficace !" + Type.NOT_VERY_EFFECTIVE: return "Ce n'est pas très efficace..." + Type.NO_EFFECT: return "Ça n'affecte pas %s..." % target_name + Type.FAINTED: return "%s est K.O. !" % source_name + Type.FLED: return "Vous prenez la fuite !" + Type.MESSAGE: return text + return "" diff --git a/game/battle/battle_event.gd.uid b/game/battle/battle_event.gd.uid new file mode 100644 index 0000000..4103835 --- /dev/null +++ b/game/battle/battle_event.gd.uid @@ -0,0 +1 @@ +uid://dxdhsdh0y2fxr diff --git a/game/battle/battle_mon.gd b/game/battle/battle_mon.gd new file mode 100644 index 0000000..2a38594 --- /dev/null +++ b/game/battle/battle_mon.gd @@ -0,0 +1,33 @@ +class_name BattleMon +extends RefCounted + +#un pokemon en combat : enveloppe autour du PokemonInstance qui porte l'etat TEMPORAIRE du combat. +#les PV et le K.O. s'appliquent directement au PokemonInstance (ils persistent apres le combat). + +var pokemon : PokemonInstance +var slot_id : int = -1 +var stages : Dictionary = {} #PokemonNature.Stat -> -6..6 (boosts/malus ; appliques par les effets, V2) + +func _init(p_pokemon : PokemonInstance) -> void: + pokemon = p_pokemon + +func display_name() -> String: + return pokemon.get_display_name() + +func is_fainted() -> bool: + return pokemon.is_fainted() + +func get_types() -> Array[String]: + var result : Array[String] = [] + for t in pokemon.species.types: + result.append(TypeChart.type_name(t)) + return result + +func get_stage(stat : PokemonNature.Stat) -> int: + return int(stages.get(stat, 0)) + +func get_stat(stat : PokemonNature.Stat) -> float: + #stat effective = stat du pokemon x multiplicateur de boost + var stage : int = get_stage(stat) + var multiplier : float = (2.0 + stage) / 2.0 if stage >= 0 else 2.0 / (2.0 - stage) + return pokemon.get_stat(stat) * multiplier diff --git a/game/battle/battle_mon.gd.uid b/game/battle/battle_mon.gd.uid new file mode 100644 index 0000000..af5342a --- /dev/null +++ b/game/battle/battle_mon.gd.uid @@ -0,0 +1 @@ +uid://5ja6agfwkfku diff --git a/game/battle/battle_participant.gd b/game/battle/battle_participant.gd new file mode 100644 index 0000000..219c221 --- /dev/null +++ b/game/battle/battle_participant.gd @@ -0,0 +1,36 @@ +class_name BattleParticipant +extends RefCounted + +#qui prend part au combat : joueur, dresseur ou pokemon sauvage. +#il possede une equipe (le "banc"), un controleur, et pilote slot_count emplacements. + +var name : String = "" +var team : PokemonTeam +var side : int = 0 +var slot_count : int = 1 +var controller : BattleController +var lead : Array[PokemonInstance] = [] #pokemon a envoyer en premier (ex. celui lance par le joueur) +var is_player : bool = false + +func _init(p_name : String, p_team : PokemonTeam, p_side : int, p_controller : BattleController = null, p_slot_count : int = 1) -> void: + name = p_name + team = p_team + side = p_side + slot_count = maxi(p_slot_count, 1) + controller = p_controller if p_controller != null else BattleAI.new() + +func pick_starters() -> Array[PokemonInstance]: + #d'abord les "lead" (aptes), puis l'ordre de l'equipe, jusqu'a slot_count + var result : Array[PokemonInstance] = [] + for p in lead: + if result.size() >= slot_count: break + if p != null and !p.is_fainted() and team.members.has(p) and !result.has(p): + result.append(p) + for p in team.members: + if result.size() >= slot_count: break + if !p.is_fainted() and !result.has(p): + result.append(p) + return result + +func has_able_pokemon() -> bool: + return team.has_able_pokemon() diff --git a/game/battle/battle_participant.gd.uid b/game/battle/battle_participant.gd.uid new file mode 100644 index 0000000..160ee26 --- /dev/null +++ b/game/battle/battle_participant.gd.uid @@ -0,0 +1 @@ +uid://sxqh5bfin87t diff --git a/game/battle/battle_presenter.gd b/game/battle/battle_presenter.gd new file mode 100644 index 0000000..c9fd3fb --- /dev/null +++ b/game/battle/battle_presenter.gd @@ -0,0 +1,8 @@ +class_name BattlePresenter +extends RefCounted + +#la presentation d'un combat (texte, animations 3D, barres de PV...) herite de cette classe. +#present() peut utiliser "await" : le combat attend la fin de l'animation avant l'evenement suivant. + +func present(_event : BattleEvent) -> void: + pass diff --git a/game/battle/battle_presenter.gd.uid b/game/battle/battle_presenter.gd.uid new file mode 100644 index 0000000..681102b --- /dev/null +++ b/game/battle/battle_presenter.gd.uid @@ -0,0 +1 @@ +uid://c1ibvn41p4rxv diff --git a/game/battle/battle_print_presenter.gd b/game/battle/battle_print_presenter.gd new file mode 100644 index 0000000..dddd72e --- /dev/null +++ b/game/battle/battle_print_presenter.gd @@ -0,0 +1,8 @@ +class_name BattlePrintPresenter +extends BattlePresenter + +#presentation de debug : ecrit les evenements dans la console + +func present(event : BattleEvent) -> void: + var line : String = event.describe() + if line != "": print(line) diff --git a/game/battle/battle_print_presenter.gd.uid b/game/battle/battle_print_presenter.gd.uid new file mode 100644 index 0000000..55438c4 --- /dev/null +++ b/game/battle/battle_print_presenter.gd.uid @@ -0,0 +1 @@ +uid://ddehrcr54nu2i diff --git a/game/battle/battle_setup.gd b/game/battle/battle_setup.gd new file mode 100644 index 0000000..3f2c3b0 --- /dev/null +++ b/game/battle/battle_setup.gd @@ -0,0 +1,38 @@ +class_name BattleSetup +extends RefCounted + +#description complete d'un combat, construite par le monde avant de le lancer. +#le format (1v1, 2v2, 1 contre plusieurs, plusieurs contre 1) vient simplement du nombre +#de participants par camp et du nombre d'emplacements (slot_count) de chacun. + +var participants : Array[BattleParticipant] = [] +var can_flee : bool = true #false pour un combat de dresseur +var seed_value : int = 0 #0 = hasard ; autre valeur = combat reproductible (tests) + +func add(participant : BattleParticipant) -> BattleSetup: + participants.append(participant) + return self + +func is_valid() -> bool: + var sides : Dictionary = {} + for p in participants: + if p.team != null and p.team.has_able_pokemon(): sides[p.side] = true + return sides.has(0) and sides.has(1) + +#---------- fabriques ---------- + +static func player_participant(team : PokemonTeam, slot_count : int = 1, thrown : PokemonInstance = null, controller : BattleController = null) -> BattleParticipant: + #joueur qui initie : le pokemon lance passe en premier, puis l'ordre de l'equipe + #joueur defie (thrown = null) : l'ordre de l'equipe (le 1er, puis le 2e pour un duo...) + var p := BattleParticipant.new("Joueur", team, 0, controller, slot_count) + p.is_player = true + if thrown != null: p.lead.append(thrown) + return p + +static func wild_participant(pokemon : PokemonInstance, controller : BattleController = null) -> BattleParticipant: + var team := PokemonTeam.new() + team.add_pokemon(pokemon) + return BattleParticipant.new(pokemon.get_display_name(), team, 1, controller, 1) + +static func trainer_participant(trainer : TrainerData, slot_count : int = 1, controller : BattleController = null) -> BattleParticipant: + return BattleParticipant.new(trainer.get_display_name(), trainer.create_team(), 1, controller, slot_count) diff --git a/game/battle/battle_setup.gd.uid b/game/battle/battle_setup.gd.uid new file mode 100644 index 0000000..69a6253 --- /dev/null +++ b/game/battle/battle_setup.gd.uid @@ -0,0 +1 @@ +uid://wmyjd47h18dh diff --git a/game/battle/battle_slot.gd b/game/battle/battle_slot.gd new file mode 100644 index 0000000..61fd5b9 --- /dev/null +++ b/game/battle/battle_slot.gd @@ -0,0 +1,25 @@ +class_name BattleSlot +extends RefCounted + +#un emplacement actif sur le terrain. Contient un BattleMon ou reste vide. + +var id : int = 0 #unique dans le combat +var side : int = 0 #0 = camp du joueur, 1 = camp adverse +var index : int = 0 #position dans son camp (0, 1, 2...) +var participant : BattleParticipant +var mon : BattleMon = null + +func is_occupied() -> bool: + return mon != null + +func is_active() -> bool: + #occupe par un pokemon encore apte au combat + return mon != null and !mon.is_fainted() + +func set_pokemon(pokemon : PokemonInstance) -> BattleMon: + mon = BattleMon.new(pokemon) + mon.slot_id = id + return mon + +func clear() -> void: + mon = null diff --git a/game/battle/battle_slot.gd.uid b/game/battle/battle_slot.gd.uid new file mode 100644 index 0000000..4a7907d --- /dev/null +++ b/game/battle/battle_slot.gd.uid @@ -0,0 +1 @@ +uid://daogeyd6po2en diff --git a/game/battle/damage_calculator.gd b/game/battle/damage_calculator.gd new file mode 100644 index 0000000..f4bd8b5 --- /dev/null +++ b/game/battle/damage_calculator.gd @@ -0,0 +1,40 @@ +class_name DamageCalculator +extends RefCounted + +const CRIT_CHANCE : float = 1.0 / 24.0 +const CRIT_MULTIPLIER : float = 1.5 +const STAB_MULTIPLIER : float = 1.5 +const SPREAD_MULTIPLIER : float = 0.75 #capacite touchant plusieurs cibles + +static func calculate(attacker : BattleMon, defender : BattleMon, move : MoveData, rng : RandomNumberGenerator = null, spread : bool = false, deterministic : bool = false) -> Dictionary: + #renvoie {"damage": int, "effectiveness": float, "critical": bool} + var result : Dictionary = {"damage": 0, "effectiveness": 1.0, "critical": false} + if move.power <= 0 or move.category == "status": return result + + var effectiveness : float = TypeChart.get_multiplier(move.type, defender.get_types()) + result["effectiveness"] = effectiveness + if effectiveness == 0.0: return result + + var physical : bool = move.category == "physical" + var attack : float = attacker.get_stat(PokemonNature.Stat.ATTACK if physical else PokemonNature.Stat.SP_ATTACK) + var defense : float = defender.get_stat(PokemonNature.Stat.DEFENSE if physical else PokemonNature.Stat.SP_DEFENSE) + var level : int = attacker.pokemon.level + + #formule officielle + var damage : float = floorf(floorf(floorf(2.0 * level / 5.0 + 2.0) * move.power * attack / maxf(defense, 1.0)) / 50.0) + 2.0 + + var critical : bool = !deterministic and rng != null and rng.randf() < CRIT_CHANCE + var roll : float = 0.925 if (deterministic or rng == null) else rng.randi_range(85, 100) / 100.0 + var stab : float = STAB_MULTIPLIER if attacker.get_types().has(move.type.to_lower()) else 1.0 + + damage *= roll * stab * effectiveness + if critical: damage *= CRIT_MULTIPLIER + if spread: damage *= SPREAD_MULTIPLIER + + result["critical"] = critical + result["damage"] = maxi(1, floori(damage)) + return result + +static func estimate(attacker : BattleMon, defender : BattleMon, move : MoveData, spread : bool = false) -> int: + #degats attendus, sans hasard (utilise par l'IA) + return int(calculate(attacker, defender, move, null, spread, true)["damage"]) diff --git a/game/battle/damage_calculator.gd.uid b/game/battle/damage_calculator.gd.uid new file mode 100644 index 0000000..8664bc3 --- /dev/null +++ b/game/battle/damage_calculator.gd.uid @@ -0,0 +1 @@ +uid://d2o31jkem2b5l diff --git a/game/battle/target_resolver.gd b/game/battle/target_resolver.gd new file mode 100644 index 0000000..99a5289 --- /dev/null +++ b/game/battle/target_resolver.gd @@ -0,0 +1,67 @@ +class_name TargetResolver +extends RefCounted + +#convertit MoveData.target (valeurs PokeAPI) + le choix du joueur en liste d'emplacements. + +const SELECTABLE : Array[String] = ["selected-pokemon", "selected-pokemon-me-first", "specific-move", "ally", "user-or-ally"] + +static func get_target_type(move : MoveData) -> String: + return move.target if move.target != "" else "selected-pokemon" + +static func get_selectable_targets(battle : Battle, actor : BattleSlot, move : MoveData) -> Array[BattleSlot]: + #emplacements parmi lesquels le joueur peut choisir (ordre : par camp puis par position) + var result : Array[BattleSlot] = [] + match get_target_type(move): + "ally": + result = battle.get_allies(actor) + "user-or-ally": + result = battle.get_allies(actor) + result.append(actor) + _: + for s in battle.get_active_slots(): + if s != actor: result.append(s) + return result + +static func needs_selection(battle : Battle, actor : BattleSlot, move : MoveData) -> bool: + #une etape "choisir la cible" n'est utile que s'il y a vraiment un choix + return SELECTABLE.has(get_target_type(move)) and get_selectable_targets(battle, actor, move).size() > 1 + +static func resolve(battle : Battle, actor : BattleSlot, move : MoveData, chosen : BattleSlot) -> Array[BattleSlot]: + #liste finale des cibles au moment ou la capacite est utilisee (cible K.O. entre-temps : redirigee) + var opponents : Array[BattleSlot] = battle.get_opponents(actor) + var allies : Array[BattleSlot] = battle.get_allies(actor) + var result : Array[BattleSlot] = [] + + match get_target_type(move): + "user": + result.append(actor) + "all-opponents": + result = opponents + "random-opponent": + if !opponents.is_empty(): result.append(opponents.pick_random()) + "all-other-pokemon": + result.append_array(opponents) + result.append_array(allies) + "all-pokemon", "entire-field": + result.append_array(opponents) + result.append_array(allies) + result.append(actor) + "all-allies", "user-and-allies": + result = allies + result.append(actor) + "ally", "user-or-ally": + if chosen != null and chosen.is_active() and (allies.has(chosen) or chosen == actor): + result.append(chosen) + elif !allies.is_empty(): + result.append(allies.pick_random()) + elif get_target_type(move) == "user-or-ally": + result.append(actor) + "users-field", "opponents-field", "fainting-pokemon": + pass #effets de terrain : plus tard + _: + #cible choisie ; si elle est K.O. ou absente, redirection vers un adversaire au hasard + if chosen != null and chosen != actor and chosen.is_active(): + result.append(chosen) + elif !opponents.is_empty(): + result.append(opponents.pick_random()) + return result diff --git a/game/battle/target_resolver.gd.uid b/game/battle/target_resolver.gd.uid new file mode 100644 index 0000000..d726524 --- /dev/null +++ b/game/battle/target_resolver.gd.uid @@ -0,0 +1 @@ +uid://bum2it3yoslcj diff --git a/game/battle/type_chart.gd b/game/battle/type_chart.gd new file mode 100644 index 0000000..72cdf4d --- /dev/null +++ b/game/battle/type_chart.gd @@ -0,0 +1,41 @@ +class_name TypeChart +extends RefCounted + +#table d'efficacite des types (generation 6+, avec Fee). Seules les valeurs differentes de 1 sont listees. +#point de remplacement unique si tu veux la deriver plus tard des TypeData importes. + +const CHART : Dictionary = { + "normal": {"rock": 0.5, "steel": 0.5, "ghost": 0.0}, + "fire": {"fire": 0.5, "water": 0.5, "grass": 2.0, "ice": 2.0, "bug": 2.0, "rock": 0.5, "dragon": 0.5, "steel": 2.0}, + "water": {"fire": 2.0, "water": 0.5, "grass": 0.5, "ground": 2.0, "rock": 2.0, "dragon": 0.5}, + "electric": {"water": 2.0, "electric": 0.5, "grass": 0.5, "ground": 0.0, "flying": 2.0, "dragon": 0.5}, + "grass": {"fire": 0.5, "water": 2.0, "grass": 0.5, "poison": 0.5, "ground": 2.0, "flying": 0.5, "bug": 0.5, "rock": 2.0, "dragon": 0.5, "steel": 0.5}, + "ice": {"fire": 0.5, "water": 0.5, "grass": 2.0, "ice": 0.5, "ground": 2.0, "flying": 2.0, "dragon": 2.0, "steel": 0.5}, + "fighting": {"normal": 2.0, "ice": 2.0, "poison": 0.5, "flying": 0.5, "psychic": 0.5, "bug": 0.5, "rock": 2.0, "ghost": 0.0, "dark": 2.0, "steel": 2.0, "fairy": 0.5}, + "poison": {"grass": 2.0, "poison": 0.5, "ground": 0.5, "rock": 0.5, "ghost": 0.5, "steel": 0.0, "fairy": 2.0}, + "ground": {"fire": 2.0, "electric": 2.0, "grass": 0.5, "poison": 2.0, "flying": 0.0, "bug": 0.5, "rock": 2.0, "steel": 2.0}, + "flying": {"electric": 0.5, "grass": 2.0, "fighting": 2.0, "bug": 2.0, "rock": 0.5, "steel": 0.5}, + "psychic": {"fighting": 2.0, "poison": 2.0, "psychic": 0.5, "dark": 0.0, "steel": 0.5}, + "bug": {"fire": 0.5, "grass": 2.0, "fighting": 0.5, "poison": 0.5, "flying": 0.5, "psychic": 2.0, "ghost": 0.5, "dark": 2.0, "steel": 0.5, "fairy": 0.5}, + "rock": {"fire": 2.0, "ice": 2.0, "fighting": 0.5, "ground": 0.5, "flying": 2.0, "bug": 2.0, "steel": 0.5}, + "ghost": {"normal": 0.0, "psychic": 2.0, "ghost": 2.0, "dark": 0.5}, + "dragon": {"dragon": 2.0, "steel": 0.5, "fairy": 0.0}, + "dark": {"fighting": 0.5, "psychic": 2.0, "ghost": 2.0, "dark": 0.5, "fairy": 0.5}, + "steel": {"fire": 0.5, "water": 0.5, "electric": 0.5, "ice": 2.0, "rock": 2.0, "steel": 0.5, "fairy": 2.0}, + "fairy": {"fire": 0.5, "fighting": 2.0, "poison": 0.5, "dragon": 2.0, "dark": 2.0, "steel": 0.5}, +} + +static func get_multiplier(attack_type : String, defender_types : Array[String]) -> float: + var row : Dictionary = CHART.get(attack_type.to_lower(), {}) + var multiplier : float = 1.0 + for t in defender_types: + multiplier *= float(row.get(t, 1.0)) + return multiplier + +static func type_name(type_data : Resource) -> String: + #nom d'API du type ("electric"...) ; A ADAPTER si TypeData utilise un autre champ. + #a defaut, on utilise le nom du fichier (ton Pokedex charge les types par ".tres") + var n : Variant = type_data.get("api_name") + if n == null or str(n) == "": + n = type_data.resource_path.get_file().get_basename() + return str(n).to_lower() diff --git a/game/battle/type_chart.gd.uid b/game/battle/type_chart.gd.uid new file mode 100644 index 0000000..95c23f7 --- /dev/null +++ b/game/battle/type_chart.gd.uid @@ -0,0 +1 @@ +uid://bmlmqh38u8hpu diff --git a/game/world/battle.tscn b/game/world/battle.tscn new file mode 100644 index 0000000..264c155 --- /dev/null +++ b/game/world/battle.tscn @@ -0,0 +1,6 @@ +[gd_scene format=3 uid="uid://cu5ednry26cds"] + +[ext_resource type="Script" uid="uid://ulemh0n2lfft" path="res://game/battle/battle_debug.gd" id="1_5bjgp"] + +[node name="Battle" type="Node" unique_id=520840168] +script = ExtResource("1_5bjgp")