420 lines
16 KiB
GDScript
420 lines
16 KiB
GDScript
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.
|
|
#
|
|
#les effets (statuts, confusion...) se branchent par des crochets : voir BattleEffect.
|
|
#les capacites sont executees par MoveExecutor.
|
|
|
|
signal event_emitted(event : BattleEvent)
|
|
|
|
enum Outcome { NONE, PLAYER_WON, PLAYER_LOST, FLED, DRAW }
|
|
|
|
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 max_turns : int = 0 #0 = illimite ; sinon le combat s'arrete en match nul apres ce nombre de tours (utile pour les tests)
|
|
var outcome : Outcome = Outcome.NONE
|
|
var slots : Array[BattleSlot] = []
|
|
var field : BattleField = BattleField.new() #meteo (terrains et barrieres plus tard)
|
|
|
|
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
|
|
_place(slot, pokemon)
|
|
slots.append(slot)
|
|
|
|
#---------- boucle principale ----------
|
|
|
|
func run() -> Outcome:
|
|
await emit_event(BattleEvent.make(BattleEvent.Type.BATTLE_START))
|
|
for slot in slots:
|
|
await emit_event(BattleEvent.make(BattleEvent.Type.SENT_OUT, slot))
|
|
for slot in _slots_by_speed():
|
|
await _run_on_enter(slot) #talents d'entree (Intimidation, Crachin...)
|
|
|
|
if check_end():
|
|
return await _finish()
|
|
|
|
while outcome == Outcome.NONE:
|
|
if max_turns > 0 and turn >= max_turns:
|
|
outcome = Outcome.DRAW
|
|
break
|
|
turn += 1
|
|
var turn_event := BattleEvent.make(BattleEvent.Type.TURN_START)
|
|
turn_event.amount = turn
|
|
await emit_event(turn_event)
|
|
for slot in slots:
|
|
if slot.mon != null: slot.mon.acted_this_turn = false
|
|
|
|
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 _end_of_turn()
|
|
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."
|
|
Outcome.DRAW: end_event.text = "Match nul (limite de tours atteinte)."
|
|
await emit_event(end_event)
|
|
return outcome
|
|
|
|
#---------- evenements et aides pour les effets ----------
|
|
|
|
func emit_event(event : BattleEvent) -> void:
|
|
event_emitted.emit(event)
|
|
if presenter != null:
|
|
await presenter.present(event)
|
|
|
|
## Affiche un message.
|
|
func message(text : String) -> void:
|
|
var e := BattleEvent.make(BattleEvent.Type.MESSAGE)
|
|
e.text = text
|
|
await emit_event(e)
|
|
|
|
## Inflige des degats et renvoie ceux reellement subis (limites aux PV restants). Emet l'evenement DAMAGE.
|
|
func deal_damage(source : BattleSlot, target : BattleSlot, amount : int) -> int:
|
|
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(event)
|
|
return event.amount
|
|
|
|
## Degats "de l'environnement" (brulure, poison, contrecoup...) : message, degats, puis K.O. eventuel.
|
|
func hurt(slot : BattleSlot, amount : int, text : String = "") -> void:
|
|
if !slot.is_active(): return
|
|
if text != "": await message(text)
|
|
await deal_damage(slot, slot, amount)
|
|
if slot.mon.is_fainted():
|
|
await emit_event(BattleEvent.make(BattleEvent.Type.FAINTED, slot))
|
|
|
|
## Soigne un pokemon et renvoie les PV reellement rendus (0 = deja au maximum, aucun evenement).
|
|
func heal(slot : BattleSlot, amount : int, text : String = "") -> int:
|
|
if !slot.is_active() or amount <= 0: return 0
|
|
var pokemon : PokemonInstance = slot.mon.pokemon
|
|
var before : int = pokemon.current_hp
|
|
pokemon.heal(amount)
|
|
var healed : int = pokemon.current_hp - before
|
|
if healed > 0:
|
|
var event := BattleEvent.make(BattleEvent.Type.HEAL, null, slot)
|
|
event.amount = healed
|
|
event.hp = pokemon.current_hp
|
|
event.max_hp = pokemon.get_max_hp()
|
|
event.text = text
|
|
await emit_event(event)
|
|
return healed
|
|
|
|
## Annonce qu'un talent se declenche.
|
|
func announce_ability(slot : BattleSlot) -> void:
|
|
if slot.mon == null or slot.mon.pokemon.ability == null: return
|
|
await message("%s utilise son talent %s !" % [slot.mon.display_name(), slot.mon.pokemon.ability.get_display_name()])
|
|
|
|
## Change la meteo. Renvoie false si c'est deja la meteo en cours (la capacite ou le talent echoue).
|
|
func set_weather(weather : BattleField.Weather, _source : BattleSlot = null, turns : int = BattleField.DEFAULT_WEATHER_TURNS) -> bool:
|
|
if weather == BattleField.Weather.NONE or field.weather == weather: return false
|
|
field.set_weather(weather, turns)
|
|
var event := BattleEvent.make(BattleEvent.Type.WEATHER_CHANGED)
|
|
event.amount = weather
|
|
event.text = BattleField.start_message(weather)
|
|
await emit_event(event)
|
|
return true
|
|
|
|
## Tente d'infliger un statut (nom PokeAPI : "burn", "paralysis", "confusion"...). Renvoie true si applique.
|
|
## announce_failure : afficher pourquoi ca echoue (capacite de statut), sinon echec silencieux (effet secondaire).
|
|
func inflict_status(source : BattleSlot, target : BattleSlot, ailment : String, move : MoveData, announce_failure : bool) -> bool:
|
|
if !target.is_active(): return false
|
|
var mon : BattleMon = target.mon
|
|
var pokemon_name : String = mon.display_name()
|
|
|
|
if ailment == "confusion":
|
|
if mon.has_volatile(&"confusion"):
|
|
if announce_failure: await message("%s est déjà confus !" % pokemon_name)
|
|
return false
|
|
mon.add_volatile(ConfusionEffect.new(rng.randi_range(2, 5)))
|
|
await message("%s est confus !" % pokemon_name)
|
|
return true
|
|
|
|
var status : int = StatusRegistry.from_ailment(ailment, move)
|
|
if status == -1:
|
|
if announce_failure: await message("Mais cela n'a pas d'effet pour l'instant.")
|
|
return false
|
|
if mon.pokemon.status != PokemonInstance.Status.NONE:
|
|
if announce_failure: await message("Mais cela échoue !")
|
|
return false
|
|
if StatusRegistry.is_immune(mon.get_types(), status):
|
|
if announce_failure: await emit_event(BattleEvent.make(BattleEvent.Type.NO_EFFECT, source, target))
|
|
return false
|
|
|
|
mon.pokemon.status = status as PokemonInstance.Status
|
|
mon.on_status_applied(status, rng)
|
|
var event := BattleEvent.make(BattleEvent.Type.STATUS_CHANGED, null, target)
|
|
event.amount = status
|
|
event.text = StatusRegistry.apply_message(status, pokemon_name)
|
|
await emit_event(event)
|
|
return true
|
|
|
|
## Guerit le statut principal d'un pokemon (reveil, degel...).
|
|
func cure_status(slot : BattleSlot, text : String = "") -> void:
|
|
var mon : BattleMon = slot.mon
|
|
mon.pokemon.status = PokemonInstance.Status.NONE
|
|
mon.status_turns = 0
|
|
mon.toxic_counter = 0
|
|
var event := BattleEvent.make(BattleEvent.Type.STATUS_CHANGED, null, slot)
|
|
event.amount = 0
|
|
event.text = text
|
|
await emit_event(event)
|
|
|
|
## Change un boost de stat (nom PokeAPI : "attack", "speed", "accuracy"...) et affiche le message.
|
|
func change_stat(slot : BattleSlot, stat_name : String, delta : int) -> void:
|
|
if !slot.is_active() or delta == 0: return
|
|
var applied : int = slot.mon.change_stage(stat_name, delta)
|
|
var label : String = BattleMon.STAT_LABELS.get(stat_name, stat_name)
|
|
var pokemon_name : String = slot.mon.display_name()
|
|
if applied == 0:
|
|
await message("%s de %s ne peut plus %s !" % [label, pokemon_name, "monter" if delta > 0 else "baisser"])
|
|
return
|
|
var degree : String = "" if absi(applied) == 1 else (" beaucoup" if absi(applied) == 2 else " énormément")
|
|
await message("%s de %s %s%s !" % [label, pokemon_name, "monte" if applied > 0 else "baisse", degree])
|
|
|
|
## Crochet "avant l'action" : false si un effet empeche le pokemon d'agir (sommeil, peur...).
|
|
func run_before_action(action : BattleAction) -> bool:
|
|
for effect in action.slot.mon.get_effects():
|
|
if !action.slot.is_active(): return false
|
|
if !await effect.before_action(self, action.slot, action): return false
|
|
return action.slot.is_active()
|
|
|
|
#---------- 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
|
|
action.slot.mon.acted_this_turn = true
|
|
match action.type:
|
|
BattleAction.Type.MOVE: await MoveExecutor.execute(self, 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_event(BattleEvent.make(BattleEvent.Type.FLED, action.slot))
|
|
else:
|
|
await message("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_event(BattleEvent.make(BattleEvent.Type.WITHDRAWN, slot))
|
|
_place(slot, action.switch_to)
|
|
slot.mon.acted_this_turn = true
|
|
await emit_event(BattleEvent.make(BattleEvent.Type.SENT_OUT, slot))
|
|
await _run_on_enter(slot)
|
|
|
|
#---------- fin de tour ----------
|
|
|
|
func _end_of_turn() -> void:
|
|
#effets de fin de tour (brulure, poison...), du plus rapide au plus lent
|
|
for slot in _slots_by_speed():
|
|
if !slot.is_active(): continue
|
|
for effect in slot.mon.get_effects():
|
|
if !slot.is_active(): break
|
|
await effect.on_turn_end(self, slot)
|
|
if check_end(): return
|
|
|
|
await _weather_end_of_turn()
|
|
for slot in slots:
|
|
if slot.mon != null: slot.mon.cleanup_volatile()
|
|
|
|
func _weather_end_of_turn() -> void:
|
|
if field.weather == BattleField.Weather.NONE: return
|
|
|
|
if field.weather == BattleField.Weather.SANDSTORM:
|
|
for slot in get_active_slots():
|
|
if BattleField.is_sandstorm_immune(slot.mon.get_types()): continue
|
|
await hurt(slot, maxi(1, floori(slot.mon.pokemon.get_max_hp() / 16.0)), "%s est frappé par la tempête de sable !" % slot.mon.display_name())
|
|
if check_end(): return
|
|
|
|
field.weather_turns -= 1
|
|
if field.weather_turns <= 0:
|
|
var ended : BattleField.Weather = field.weather
|
|
field.set_weather(BattleField.Weather.NONE)
|
|
var event := BattleEvent.make(BattleEvent.Type.WEATHER_CHANGED)
|
|
event.amount = BattleField.Weather.NONE
|
|
event.text = BattleField.end_message(ended)
|
|
await emit_event(event)
|
|
|
|
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]
|
|
_place(slot, chosen)
|
|
await emit_event(BattleEvent.make(BattleEvent.Type.SENT_OUT, slot))
|
|
await _run_on_enter(slot)
|
|
|
|
#---------- placement et entree en combat ----------
|
|
|
|
func _place(slot : BattleSlot, pokemon : PokemonInstance) -> void:
|
|
slot.set_pokemon(pokemon)
|
|
slot.mon.field = field
|
|
|
|
func _run_on_enter(slot : BattleSlot) -> void:
|
|
if slot.mon == null: return
|
|
for effect in slot.mon.get_effects():
|
|
if !slot.is_active(): return
|
|
await effect.on_enter(self, slot)
|
|
|
|
func _slots_by_speed() -> Array[BattleSlot]:
|
|
var ordered : Array[BattleSlot] = get_active_slots()
|
|
ordered.sort_custom(func(a : BattleSlot, b : BattleSlot) -> bool:
|
|
return a.mon.get_stat(PokemonNature.Stat.SPEED) > b.mon.get_stat(PokemonNature.Stat.SPEED))
|
|
return ordered
|
|
|
|
#---------- requetes (utilisees par les controleurs, les effets et TargetResolver) ----------
|
|
|
|
func random_pick(items : Array) -> Variant:
|
|
#tirage dans le generateur du combat (et non le hasard global) : un meme seed rejoue exactement le meme combat
|
|
return items[rng.randi_range(0, items.size() - 1)]
|
|
|
|
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
|