Start work on battle system

This commit is contained in:
TheThomaas 2026-10-06 13:27:26 +02:00
parent 0714a303c7
commit 9a3c383b26
31 changed files with 920 additions and 0 deletions

297
game/battle/battle.gd Normal file
View file

@ -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

View file

@ -0,0 +1 @@
uid://jqtd5v08rwtv

View file

@ -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

View file

@ -0,0 +1 @@
uid://hispkfk8qdt6

143
game/battle/battle_ai.gd Normal file
View file

@ -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

View file

@ -0,0 +1 @@
uid://dbkfxdanlrke1

View file

@ -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

View file

@ -0,0 +1 @@
uid://cw4o36nfqrbh8

View file

@ -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

View file

@ -0,0 +1 @@
uid://ulemh0n2lfft

View file

@ -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 ""

View file

@ -0,0 +1 @@
uid://dxdhsdh0y2fxr

33
game/battle/battle_mon.gd Normal file
View file

@ -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

View file

@ -0,0 +1 @@
uid://5ja6agfwkfku

View file

@ -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()

View file

@ -0,0 +1 @@
uid://sxqh5bfin87t

View file

@ -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

View file

@ -0,0 +1 @@
uid://c1ibvn41p4rxv

View file

@ -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)

View file

@ -0,0 +1 @@
uid://ddehrcr54nu2i

View file

@ -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)

View file

@ -0,0 +1 @@
uid://wmyjd47h18dh

View file

@ -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

View file

@ -0,0 +1 @@
uid://daogeyd6po2en

View file

@ -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"])

View file

@ -0,0 +1 @@
uid://d2o31jkem2b5l

View file

@ -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

View file

@ -0,0 +1 @@
uid://bum2it3yoslcj

41
game/battle/type_chart.gd Normal file
View file

@ -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 "<nom>.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()

View file

@ -0,0 +1 @@
uid://bmlmqh38u8hpu

6
game/world/battle.tscn Normal file
View file

@ -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")