From 1251112e3c0ce57d2fe086b88de275f5313e6740 Mon Sep 17 00:00:00 2001 From: TheThomaas Date: Thu, 22 Oct 2026 15:25:25 +0200 Subject: [PATCH] Add abilities to battle system --- game/battle/abilities/ability_registry.gd | 57 +++++++++++++++ game/battle/abilities/ability_registry.gd.uid | 1 + game/battle/abilities/adaptability_ability.gd | 12 ++++ .../abilities/adaptability_ability.gd.uid | 1 + game/battle/abilities/attack_boost_ability.gd | 17 +++++ .../abilities/attack_boost_ability.gd.uid | 1 + .../abilities/contact_status_ability.gd | 24 +++++++ .../abilities/contact_status_ability.gd.uid | 1 + game/battle/abilities/flash_fire_ability.gd | 19 +++++ .../abilities/flash_fire_ability.gd.uid | 1 + game/battle/abilities/intimidate_ability.gd | 14 ++++ .../abilities/intimidate_ability.gd.uid | 1 + .../abilities/on_enter_weather_ability.gd | 15 ++++ .../abilities/on_enter_weather_ability.gd.uid | 1 + game/battle/abilities/pinch_boost_ability.gd | 15 ++++ .../abilities/pinch_boost_ability.gd.uid | 1 + game/battle/abilities/rough_skin_ability.gd | 12 ++++ .../abilities/rough_skin_ability.gd.uid | 1 + game/battle/abilities/speed_boost_ability.gd | 11 +++ .../abilities/speed_boost_ability.gd.uid | 1 + game/battle/abilities/technician_ability.gd | 10 +++ .../abilities/technician_ability.gd.uid | 1 + game/battle/abilities/type_absorb_ability.gd | 33 +++++++++ .../abilities/type_absorb_ability.gd.uid | 1 + .../battle/abilities/weather_speed_ability.gd | 15 ++++ .../abilities/weather_speed_ability.gd.uid | 1 + game/battle/battle.gd | 69 ++++++++++++++++--- game/battle/battle_ai.gd | 9 ++- game/battle/battle_debug.gd | 3 +- game/battle/battle_event.gd | 4 +- game/battle/battle_field.gd | 66 ++++++++++++++++++ game/battle/battle_field.gd.uid | 1 + game/battle/battle_mon.gd | 7 ++ game/battle/damage_calculator.gd | 3 + game/battle/effects/battle_effect.gd | 16 +++++ game/battle/move_executor.gd | 25 ++++++- game/pokemon/pokemon_instance.gd | 20 +++++- game/pokemon/trainer_party_entry.gd | 8 ++- game/storage/PCScreen.gd | 1 + game/storage/team_screen.gd | 1 + game/world/test_level.tscn | 7 ++ 41 files changed, 490 insertions(+), 17 deletions(-) create mode 100644 game/battle/abilities/ability_registry.gd create mode 100644 game/battle/abilities/ability_registry.gd.uid create mode 100644 game/battle/abilities/adaptability_ability.gd create mode 100644 game/battle/abilities/adaptability_ability.gd.uid create mode 100644 game/battle/abilities/attack_boost_ability.gd create mode 100644 game/battle/abilities/attack_boost_ability.gd.uid create mode 100644 game/battle/abilities/contact_status_ability.gd create mode 100644 game/battle/abilities/contact_status_ability.gd.uid create mode 100644 game/battle/abilities/flash_fire_ability.gd create mode 100644 game/battle/abilities/flash_fire_ability.gd.uid create mode 100644 game/battle/abilities/intimidate_ability.gd create mode 100644 game/battle/abilities/intimidate_ability.gd.uid create mode 100644 game/battle/abilities/on_enter_weather_ability.gd create mode 100644 game/battle/abilities/on_enter_weather_ability.gd.uid create mode 100644 game/battle/abilities/pinch_boost_ability.gd create mode 100644 game/battle/abilities/pinch_boost_ability.gd.uid create mode 100644 game/battle/abilities/rough_skin_ability.gd create mode 100644 game/battle/abilities/rough_skin_ability.gd.uid create mode 100644 game/battle/abilities/speed_boost_ability.gd create mode 100644 game/battle/abilities/speed_boost_ability.gd.uid create mode 100644 game/battle/abilities/technician_ability.gd create mode 100644 game/battle/abilities/technician_ability.gd.uid create mode 100644 game/battle/abilities/type_absorb_ability.gd create mode 100644 game/battle/abilities/type_absorb_ability.gd.uid create mode 100644 game/battle/abilities/weather_speed_ability.gd create mode 100644 game/battle/abilities/weather_speed_ability.gd.uid create mode 100644 game/battle/battle_field.gd create mode 100644 game/battle/battle_field.gd.uid diff --git a/game/battle/abilities/ability_registry.gd b/game/battle/abilities/ability_registry.gd new file mode 100644 index 0000000..fd6c924 --- /dev/null +++ b/game/battle/abilities/ability_registry.gd @@ -0,0 +1,57 @@ +class_name AbilityRegistry +extends RefCounted + +#associe un talent (nom d'API) a son effet. Un talent sans script est simplement sans effet +#(avertissement en debug, une seule fois). Pour en ajouter : cree une classe BattleEffect et declare-la ici. + +static var _warned : Dictionary = {} + +static func create(api_name : String) -> BattleEffect: + match api_name: + #entree en combat + "intimidate": return IntimidateAbility.new() + "drizzle": return OnEnterWeatherAbility.new(BattleField.Weather.RAIN) + "drought": return OnEnterWeatherAbility.new(BattleField.Weather.SUN) + "sand-stream": return OnEnterWeatherAbility.new(BattleField.Weather.SANDSTORM) + "snow-warning": return OnEnterWeatherAbility.new(BattleField.Weather.SNOW) + + #vitesse selon la meteo + "swift-swim": return WeatherSpeedAbility.new(BattleField.Weather.RAIN) + "chlorophyll": return WeatherSpeedAbility.new(BattleField.Weather.SUN) + "sand-rush": return WeatherSpeedAbility.new(BattleField.Weather.SANDSTORM) + "slush-rush": return WeatherSpeedAbility.new(BattleField.Weather.SNOW) + + #immunites et absorptions + "levitate": return TypeAbsorbAbility.new("ground", 0.0, "", true) + "water-absorb": return TypeAbsorbAbility.new("water", 0.25) + "volt-absorb": return TypeAbsorbAbility.new("electric", 0.25) + "lightning-rod": return TypeAbsorbAbility.new("electric", 0.0, "special-attack") + "storm-drain": return TypeAbsorbAbility.new("water", 0.0, "special-attack") + "sap-sipper": return TypeAbsorbAbility.new("grass", 0.0, "attack") + "motor-drive": return TypeAbsorbAbility.new("electric", 0.0, "speed") + "flash-fire": return FlashFireAbility.new() + + #contact + "static": return ContactStatusAbility.new("paralysis") + "poison-point": return ContactStatusAbility.new("poison") + "flame-body": return ContactStatusAbility.new("burn") + "rough-skin": return RoughSkinAbility.new() + + #degats et stats + "overgrow": return PinchBoostAbility.new("grass") + "blaze": return PinchBoostAbility.new("fire") + "torrent": return PinchBoostAbility.new("water") + "swarm": return PinchBoostAbility.new("bug") + "adaptability": return AdaptabilityAbility.new() + "technician": return TechnicianAbility.new() + "huge-power", "pure-power": return AttackBoostAbility.new(true) + "guts": return AttackBoostAbility.new(false) + "speed-boost": return SpeedBoostAbility.new() + + _warn_missing(api_name) + return null + +static func _warn_missing(api_name : String) -> void: + if !OS.is_debug_build() or _warned.has(api_name): return + _warned[api_name] = true + push_warning("[AbilityRegistry] talent sans script : '%s' (sans effet en combat)" % api_name) diff --git a/game/battle/abilities/ability_registry.gd.uid b/game/battle/abilities/ability_registry.gd.uid new file mode 100644 index 0000000..4d1470f --- /dev/null +++ b/game/battle/abilities/ability_registry.gd.uid @@ -0,0 +1 @@ +uid://5bs0pyc6pr8w diff --git a/game/battle/abilities/adaptability_ability.gd b/game/battle/abilities/adaptability_ability.gd new file mode 100644 index 0000000..e56431e --- /dev/null +++ b/game/battle/abilities/adaptability_ability.gd @@ -0,0 +1,12 @@ +class_name AdaptabilityAbility +extends BattleEffect + +#Adaptabilite : le bonus de meme type (STAB) passe de x1.5 a x2 + +func _init() -> void: + id = &"adaptability" + +func modify_outgoing_damage(attacker : BattleMon, _defender : BattleMon, move : MoveData, damage : float) -> float: + if attacker.get_types().has(move.type.to_lower()): + return damage * (2.0 / DamageCalculator.STAB_MULTIPLIER) + return damage diff --git a/game/battle/abilities/adaptability_ability.gd.uid b/game/battle/abilities/adaptability_ability.gd.uid new file mode 100644 index 0000000..f1bb459 --- /dev/null +++ b/game/battle/abilities/adaptability_ability.gd.uid @@ -0,0 +1 @@ +uid://kb6pgmnvnman diff --git a/game/battle/abilities/attack_boost_ability.gd b/game/battle/abilities/attack_boost_ability.gd new file mode 100644 index 0000000..02e2462 --- /dev/null +++ b/game/battle/abilities/attack_boost_ability.gd @@ -0,0 +1,17 @@ +class_name AttackBoostAbility +extends BattleEffect + +#Force Pure / Cran : Attaque x2 (Force Pure, Coloforce) ou x1.5 avec un statut (Cran, qui annule aussi le malus de brulure) + +var always_double : bool + +func _init(p_always_double : bool) -> void: + id = &"attack_boost" + always_double = p_always_double + +func modify_stat(mon : BattleMon, stat : PokemonNature.Stat, value : float) -> float: + if stat != PokemonNature.Stat.ATTACK: return value + if always_double: return value * 2.0 + if mon.pokemon.status == PokemonInstance.Status.NONE: return value + var burn_undo : float = 2.0 if mon.pokemon.status == PokemonInstance.Status.BURN else 1.0 + return value * 1.5 * burn_undo diff --git a/game/battle/abilities/attack_boost_ability.gd.uid b/game/battle/abilities/attack_boost_ability.gd.uid new file mode 100644 index 0000000..7170f91 --- /dev/null +++ b/game/battle/abilities/attack_boost_ability.gd.uid @@ -0,0 +1 @@ +uid://dskstklaa3qo0 diff --git a/game/battle/abilities/contact_status_ability.gd b/game/battle/abilities/contact_status_ability.gd new file mode 100644 index 0000000..0ff33ae --- /dev/null +++ b/game/battle/abilities/contact_status_ability.gd @@ -0,0 +1,24 @@ +class_name ContactStatusAbility +extends BattleEffect + +#Statik, Point Poison, Corps Ardent : chance d'infliger un statut a l'attaquant qui touche au contact. +#PokeAPI ne dit pas si une capacite fait contact : on considere que toutes les capacites physiques le font. + +var ailment : String +var chance : int + +func _init(p_ailment : String, p_chance : int = 30) -> void: + id = &"contact_status" + ailment = p_ailment + chance = p_chance + +func on_after_hit(battle : Battle, attacker : BattleSlot, defender : BattleSlot, move : MoveData, damage : int) -> void: + if damage <= 0 or move.category != "physical" or attacker == defender or !attacker.is_active(): return + + var status : int = StatusRegistry.from_ailment(ailment) + if attacker.mon.pokemon.status != PokemonInstance.Status.NONE: return + if StatusRegistry.is_immune(attacker.mon.get_types(), status): return + if battle.rng.randi_range(1, 100) > chance: return + + await battle.announce_ability(defender) + await battle.inflict_status(defender, attacker, ailment, null, false) diff --git a/game/battle/abilities/contact_status_ability.gd.uid b/game/battle/abilities/contact_status_ability.gd.uid new file mode 100644 index 0000000..0401e9e --- /dev/null +++ b/game/battle/abilities/contact_status_ability.gd.uid @@ -0,0 +1 @@ +uid://c5d33ammbuf5l diff --git a/game/battle/abilities/flash_fire_ability.gd b/game/battle/abilities/flash_fire_ability.gd new file mode 100644 index 0000000..543b72b --- /dev/null +++ b/game/battle/abilities/flash_fire_ability.gd @@ -0,0 +1,19 @@ +class_name FlashFireAbility +extends BattleEffect + +#Torche : immunise contre le type Feu, puis renforce ses propres capacites Feu de 50 % + +func _init() -> void: + id = &"flash_fire" + +func on_before_hit(battle : Battle, attacker : BattleSlot, defender : BattleSlot, move : MoveData) -> bool: + if attacker == defender or move.type.to_lower() != "fire": return false + await battle.announce_ability(defender) + defender.mon.flags["flash_fire"] = true + await battle.message("La puissance Feu de %s augmente !" % defender.mon.display_name()) + return true + +func modify_outgoing_damage(attacker : BattleMon, _defender : BattleMon, move : MoveData, damage : float) -> float: + if attacker.flags.get("flash_fire", false) and move.type.to_lower() == "fire": + return damage * 1.5 + return damage diff --git a/game/battle/abilities/flash_fire_ability.gd.uid b/game/battle/abilities/flash_fire_ability.gd.uid new file mode 100644 index 0000000..41ab131 --- /dev/null +++ b/game/battle/abilities/flash_fire_ability.gd.uid @@ -0,0 +1 @@ +uid://dqaqq6ufk32kp diff --git a/game/battle/abilities/intimidate_ability.gd b/game/battle/abilities/intimidate_ability.gd new file mode 100644 index 0000000..1d521b9 --- /dev/null +++ b/game/battle/abilities/intimidate_ability.gd @@ -0,0 +1,14 @@ +class_name IntimidateAbility +extends BattleEffect + +#Intimidation : a l'entree en combat, baisse l'Attaque de tous les adversaires + +func _init() -> void: + id = &"intimidate" + +func on_enter(battle : Battle, slot : BattleSlot) -> void: + var targets : Array[BattleSlot] = battle.get_opponents(slot) + if targets.is_empty(): return + await battle.announce_ability(slot) + for target in targets: + await battle.change_stat(target, "attack", -1) diff --git a/game/battle/abilities/intimidate_ability.gd.uid b/game/battle/abilities/intimidate_ability.gd.uid new file mode 100644 index 0000000..a3cb702 --- /dev/null +++ b/game/battle/abilities/intimidate_ability.gd.uid @@ -0,0 +1 @@ +uid://d25o30qt8rks2 diff --git a/game/battle/abilities/on_enter_weather_ability.gd b/game/battle/abilities/on_enter_weather_ability.gd new file mode 100644 index 0000000..d5ccae6 --- /dev/null +++ b/game/battle/abilities/on_enter_weather_ability.gd @@ -0,0 +1,15 @@ +class_name OnEnterWeatherAbility +extends BattleEffect + +#Crachin, Sechesse, Sable Volant, Alerte Neige : change la meteo a l'entree en combat + +var weather : BattleField.Weather + +func _init(p_weather : BattleField.Weather) -> void: + id = &"weather_setter" + weather = p_weather + +func on_enter(battle : Battle, slot : BattleSlot) -> void: + if battle.field.weather == weather: return + await battle.announce_ability(slot) + await battle.set_weather(weather, slot) diff --git a/game/battle/abilities/on_enter_weather_ability.gd.uid b/game/battle/abilities/on_enter_weather_ability.gd.uid new file mode 100644 index 0000000..1824c82 --- /dev/null +++ b/game/battle/abilities/on_enter_weather_ability.gd.uid @@ -0,0 +1 @@ +uid://ddsup6cge6ys2 diff --git a/game/battle/abilities/pinch_boost_ability.gd b/game/battle/abilities/pinch_boost_ability.gd new file mode 100644 index 0000000..183314e --- /dev/null +++ b/game/battle/abilities/pinch_boost_ability.gd @@ -0,0 +1,15 @@ +class_name PinchBoostAbility +extends BattleEffect + +#Engrais, Brasier, Torrent, Essaim : capacites d'un type x1.5 quand les PV sont a 1/3 ou moins + +var boosted_type : String + +func _init(p_type : String) -> void: + id = &"pinch_boost" + boosted_type = p_type + +func modify_outgoing_damage(attacker : BattleMon, _defender : BattleMon, move : MoveData, damage : float) -> float: + if move.type.to_lower() != boosted_type: return damage + if attacker.pokemon.current_hp * 3 <= attacker.pokemon.get_max_hp(): return damage * 1.5 + return damage diff --git a/game/battle/abilities/pinch_boost_ability.gd.uid b/game/battle/abilities/pinch_boost_ability.gd.uid new file mode 100644 index 0000000..93c30e5 --- /dev/null +++ b/game/battle/abilities/pinch_boost_ability.gd.uid @@ -0,0 +1 @@ +uid://dvudfonq4gidn diff --git a/game/battle/abilities/rough_skin_ability.gd b/game/battle/abilities/rough_skin_ability.gd new file mode 100644 index 0000000..d369679 --- /dev/null +++ b/game/battle/abilities/rough_skin_ability.gd @@ -0,0 +1,12 @@ +class_name RoughSkinAbility +extends BattleEffect + +#Peau Dure : l'attaquant au contact perd 1/8 de ses PV + +func _init() -> void: + id = &"rough_skin" + +func on_after_hit(battle : Battle, attacker : BattleSlot, defender : BattleSlot, move : MoveData, damage : int) -> void: + if damage <= 0 or move.category != "physical" or attacker == defender or !attacker.is_active(): return + await battle.announce_ability(defender) + await battle.hurt(attacker, maxi(1, floori(attacker.mon.pokemon.get_max_hp() / 8.0)), "%s est blessé !" % attacker.mon.display_name()) diff --git a/game/battle/abilities/rough_skin_ability.gd.uid b/game/battle/abilities/rough_skin_ability.gd.uid new file mode 100644 index 0000000..a90d261 --- /dev/null +++ b/game/battle/abilities/rough_skin_ability.gd.uid @@ -0,0 +1 @@ +uid://cvmilgxfovbed diff --git a/game/battle/abilities/speed_boost_ability.gd b/game/battle/abilities/speed_boost_ability.gd new file mode 100644 index 0000000..18838fb --- /dev/null +++ b/game/battle/abilities/speed_boost_ability.gd @@ -0,0 +1,11 @@ +class_name SpeedBoostAbility +extends BattleEffect + +#Turbo : la Vitesse monte d'un cran a la fin de chaque tour + +func _init() -> void: + id = &"speed_boost" + +func on_turn_end(battle : Battle, slot : BattleSlot) -> void: + await battle.announce_ability(slot) + await battle.change_stat(slot, "speed", 1) diff --git a/game/battle/abilities/speed_boost_ability.gd.uid b/game/battle/abilities/speed_boost_ability.gd.uid new file mode 100644 index 0000000..76dbb9c --- /dev/null +++ b/game/battle/abilities/speed_boost_ability.gd.uid @@ -0,0 +1 @@ +uid://cl54nloqah31k diff --git a/game/battle/abilities/technician_ability.gd b/game/battle/abilities/technician_ability.gd new file mode 100644 index 0000000..625fd9b --- /dev/null +++ b/game/battle/abilities/technician_ability.gd @@ -0,0 +1,10 @@ +class_name TechnicianAbility +extends BattleEffect + +#Technicien : capacites de puissance 60 ou moins x1.5 + +func _init() -> void: + id = &"technician" + +func modify_outgoing_damage(_attacker : BattleMon, _defender : BattleMon, move : MoveData, damage : float) -> float: + return damage * 1.5 if move.power > 0 and move.power <= 60 else damage diff --git a/game/battle/abilities/technician_ability.gd.uid b/game/battle/abilities/technician_ability.gd.uid new file mode 100644 index 0000000..b3448d0 --- /dev/null +++ b/game/battle/abilities/technician_ability.gd.uid @@ -0,0 +1 @@ +uid://ppremfcljor2 diff --git a/game/battle/abilities/type_absorb_ability.gd b/game/battle/abilities/type_absorb_ability.gd new file mode 100644 index 0000000..3d982a2 --- /dev/null +++ b/game/battle/abilities/type_absorb_ability.gd @@ -0,0 +1,33 @@ +class_name TypeAbsorbAbility +extends BattleEffect + +#annule les capacites d'un type, avec un bonus eventuel : +# Absorb Eau / Absorb Volt : soigne 1/4 des PV ; Paratonnerre / Lavabo : Att. Spe. +1 ; +# Herbivore : Attaque +1 ; Motorisé : Vitesse +1 ; Levitation : aucun bonus (capacites de degats seulement) + +var absorbed_type : String +var heal_fraction : float +var boost_stat : String +var damaging_only : bool + +func _init(p_type : String, p_heal_fraction : float = 0.0, p_boost_stat : String = "", p_damaging_only : bool = false) -> void: + id = &"type_absorb" + absorbed_type = p_type + heal_fraction = p_heal_fraction + boost_stat = p_boost_stat + damaging_only = p_damaging_only + +func on_before_hit(battle : Battle, attacker : BattleSlot, defender : BattleSlot, move : MoveData) -> bool: + if attacker == defender or move.type.to_lower() != absorbed_type: return false + if damaging_only and move.power <= 0: return false + + await battle.announce_ability(defender) + var defender_name : String = defender.mon.display_name() + if heal_fraction > 0.0: + var healed : int = await battle.heal(defender, maxi(1, floori(defender.mon.pokemon.get_max_hp() * heal_fraction)), "%s récupère des PV !" % defender_name) + if healed == 0: await battle.message("Ça n'affecte pas %s..." % defender_name) + elif boost_stat != "": + await battle.change_stat(defender, boost_stat, 1) + else: + await battle.message("Ça n'affecte pas %s..." % defender_name) + return true diff --git a/game/battle/abilities/type_absorb_ability.gd.uid b/game/battle/abilities/type_absorb_ability.gd.uid new file mode 100644 index 0000000..7fe407b --- /dev/null +++ b/game/battle/abilities/type_absorb_ability.gd.uid @@ -0,0 +1 @@ +uid://b1k6et1vy3bfl diff --git a/game/battle/abilities/weather_speed_ability.gd b/game/battle/abilities/weather_speed_ability.gd new file mode 100644 index 0000000..66a975a --- /dev/null +++ b/game/battle/abilities/weather_speed_ability.gd @@ -0,0 +1,15 @@ +class_name WeatherSpeedAbility +extends BattleEffect + +#Glissade, Chlorophylle, Baigne Sable, Chasse-Neige : Vitesse x2 sous une meteo donnee + +var weather : BattleField.Weather + +func _init(p_weather : BattleField.Weather) -> void: + id = &"weather_speed" + weather = p_weather + +func modify_stat(mon : BattleMon, stat : PokemonNature.Stat, value : float) -> float: + if stat == PokemonNature.Stat.SPEED and mon.field != null and mon.field.weather == weather: + return value * 2.0 + return value diff --git a/game/battle/abilities/weather_speed_ability.gd.uid b/game/battle/abilities/weather_speed_ability.gd.uid new file mode 100644 index 0000000..a087318 --- /dev/null +++ b/game/battle/abilities/weather_speed_ability.gd.uid @@ -0,0 +1 @@ +uid://caxdy1eiaw58j diff --git a/game/battle/battle.gd b/game/battle/battle.gd index ec6db23..be061f1 100644 --- a/game/battle/battle.gd +++ b/game/battle/battle.gd @@ -28,6 +28,7 @@ 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 @@ -46,7 +47,7 @@ func _build_slots() -> void: 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) + _place(slot, pokemon) slots.append(slot) #---------- boucle principale ---------- @@ -55,6 +56,8 @@ 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() @@ -143,6 +146,21 @@ func heal(slot : BattleSlot, amount : int, text : String = "") -> int: 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: @@ -267,28 +285,44 @@ 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)) - slot.set_pokemon(action.switch_to) + _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 - 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)) - - for slot in ordered: + 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 @@ -300,8 +334,27 @@ func _replace_fainted() -> void: 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) + _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) ---------- diff --git a/game/battle/battle_ai.gd b/game/battle/battle_ai.gd index 2f05850..0260e71 100644 --- a/game/battle/battle_ai.gd +++ b/game/battle/battle_ai.gd @@ -139,7 +139,7 @@ func _types_of(pokemon : PokemonInstance) -> Array[String]: 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 _status_score(slot, move, targets) + if move.power <= 0 or move.category == "status": return _status_score(battle, slot, move, targets) var accuracy : float = 1.0 if move.accuracy <= 0 else move.accuracy / 100.0 var spread : bool = targets.size() > 1 @@ -155,10 +155,15 @@ func _score(battle : Battle, slot : BattleSlot, move : MoveData, candidate : Var if damage >= remaining: score += KILL_SCORE return score -func _status_score(slot : BattleSlot, move : MoveData, targets : Array[BattleSlot]) -> float: +func _status_score(battle : Battle, slot : BattleSlot, move : MoveData, targets : Array[BattleSlot]) -> float: #valeur approximative d'une capacite de statut : toujours bien inferieure a une bonne attaque, #mais choisie quand elle apporte vraiment quelque chose (statut sur un adversaire sain, boost utile, soin) var score : float = 0.1 + + var weather : int = BattleField.weather_for_move(move.api_name) + if weather != BattleField.Weather.NONE: + return 6.0 if battle.field.weather != weather else 0.1 + for t in targets: var enemy : bool = t.side != slot.side diff --git a/game/battle/battle_debug.gd b/game/battle/battle_debug.gd index 3734089..c1f847d 100644 --- a/game/battle/battle_debug.gd +++ b/game/battle/battle_debug.gd @@ -184,7 +184,8 @@ func _seeded_team(rng : RandomNumberGenerator, ids : Array, size : int) -> Pokem for i in size: var species : PokemonData = Pokedex.get_pokemon(ids[rng.randi_range(0, ids.size() - 1)]) if species == null: continue - team.add_pokemon(PokemonInstance.create(species, rng.randi_range(level_min, level_max), rng.randi_range(0, 24))) + var ability : AbilityData = PokemonInstance.pick_default_ability(species, rng) + team.add_pokemon(PokemonInstance.create(species, rng.randi_range(level_min, level_max), rng.randi_range(0, 24), null, ability)) return team func _describe(setup : BattleSetup) -> String: diff --git a/game/battle/battle_event.gd b/game/battle/battle_event.gd index ddc5580..327f161 100644 --- a/game/battle/battle_event.gd +++ b/game/battle/battle_event.gd @@ -10,7 +10,7 @@ enum Type { MOVE_USED, MOVE_MISSED, DAMAGE, CRITICAL, SUPER_EFFECTIVE, NOT_VERY_EFFECTIVE, NO_EFFECT, FAINTED, FLED, MESSAGE, - STATUS_CHANGED, HEAL, + STATUS_CHANGED, HEAL, WEATHER_CHANGED, } var type : Type = Type.MESSAGE @@ -52,5 +52,5 @@ func describe() -> String: 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, Type.STATUS_CHANGED, Type.HEAL: return text + Type.MESSAGE, Type.STATUS_CHANGED, Type.HEAL, Type.WEATHER_CHANGED: return text return "" diff --git a/game/battle/battle_field.gd b/game/battle/battle_field.gd new file mode 100644 index 0000000..5d19458 --- /dev/null +++ b/game/battle/battle_field.gd @@ -0,0 +1,66 @@ +class_name BattleField +extends RefCounted + +#etat du terrain, commun aux deux camps : meteo (terrains et barrieres viendront plus tard). +#les talents (Crachin, Sechesse...) et les capacites (Danse Pluie...) la modifient via Battle.set_weather(). + +enum Weather { NONE, SUN, RAIN, SANDSTORM, SNOW } + +const DEFAULT_WEATHER_TURNS : int = 5 + +#capacites qui changent la meteo (nom d'API) +const WEATHER_MOVES : Dictionary = { + "sunny-day": Weather.SUN, + "rain-dance": Weather.RAIN, + "sandstorm": Weather.SANDSTORM, + "hail": Weather.SNOW, + "snowscape": Weather.SNOW, +} + +var weather : Weather = Weather.NONE +var weather_turns : int = 0 + +static func weather_for_move(move_api_name : String) -> int: + return int(WEATHER_MOVES.get(move_api_name, Weather.NONE)) + +func set_weather(new_weather : Weather, turns : int = DEFAULT_WEATHER_TURNS) -> void: + weather = new_weather + weather_turns = turns if new_weather != Weather.NONE else 0 + +## Multiplicateur de degats selon la meteo et le type de la capacite. +func damage_multiplier(move_type : String) -> float: + match weather: + Weather.SUN: + if move_type == "fire": return 1.5 + if move_type == "water": return 0.5 + Weather.RAIN: + if move_type == "water": return 1.5 + if move_type == "fire": return 0.5 + return 1.0 + +## Bonus defensifs de la meteo : tempete de sable (Roche, Def. Spe. x1.5), neige (Glace, Defense x1.5). +func modify_stat(mon : BattleMon, stat : PokemonNature.Stat, value : float) -> float: + if weather == Weather.SANDSTORM and stat == PokemonNature.Stat.SP_DEFENSE and mon.get_types().has("rock"): + return value * 1.5 + if weather == Weather.SNOW and stat == PokemonNature.Stat.DEFENSE and mon.get_types().has("ice"): + return value * 1.5 + return value + +static func is_sandstorm_immune(types : Array[String]) -> bool: + return types.has("rock") or types.has("ground") or types.has("steel") + +static func start_message(w : int) -> String: + match w: + Weather.SUN: return "Les rayons du soleil brillent !" + Weather.RAIN: return "Il commence à pleuvoir !" + Weather.SANDSTORM: return "Une tempête de sable se lève !" + Weather.SNOW: return "Il commence à neiger !" + return "" + +static func end_message(w : int) -> String: + match w: + Weather.SUN: return "Le soleil se couche." + Weather.RAIN: return "La pluie s'arrête." + Weather.SANDSTORM: return "La tempête de sable se calme." + Weather.SNOW: return "La neige s'arrête." + return "" diff --git a/game/battle/battle_field.gd.uid b/game/battle/battle_field.gd.uid new file mode 100644 index 0000000..d044011 --- /dev/null +++ b/game/battle/battle_field.gd.uid @@ -0,0 +1 @@ +uid://thpl65yj8dpy diff --git a/game/battle/battle_mon.gd b/game/battle/battle_mon.gd index cc64335..9a6bf62 100644 --- a/game/battle/battle_mon.gd +++ b/game/battle/battle_mon.gd @@ -36,11 +36,16 @@ var volatile : Array[BattleEffect] = [] #effets temporaires : confusion, peur... var status_turns : int = 0 #sommeil : nombre de tentatives d'action restantes var toxic_counter : int = 0 #poison grave : nombre de tours ecoules var acted_this_turn : bool = false +var field : BattleField = null #terrain du combat (meteo...), renseigne par Battle +var flags : Dictionary = {} #petits etats libres pour les talents (ex. "flash_fire") +var _ability_effect : BattleEffect = null func _init(p_pokemon : PokemonInstance) -> void: pokemon = p_pokemon if pokemon.status == PokemonInstance.Status.SLEEP: status_turns = 2 #deja endormi en entrant en combat + if pokemon.ability != null: + _ability_effect = AbilityRegistry.create(pokemon.ability.api_name) func display_name() -> String: return pokemon.get_display_name() @@ -61,6 +66,7 @@ func get_effects() -> Array[BattleEffect]: var result : Array[BattleEffect] = [] var status_effect : BattleEffect = StatusRegistry.effect_for(int(pokemon.status)) if status_effect != null: result.append(status_effect) + if _ability_effect != null: result.append(_ability_effect) result.append_array(volatile) return result @@ -106,6 +112,7 @@ func get_stat(stat : PokemonNature.Stat) -> float: var value : float = pokemon.get_stat(stat) * _stage_multiplier(get_stage(stat)) for effect in get_effects(): value = effect.modify_stat(self, stat, value) + if field != null: value = field.modify_stat(self, stat, value) return value ## Multiplicateur de precision de cette attaque contre une cible (boost de precision - esquive). diff --git a/game/battle/damage_calculator.gd b/game/battle/damage_calculator.gd index 8d2211f..d7ae424 100644 --- a/game/battle/damage_calculator.gd +++ b/game/battle/damage_calculator.gd @@ -28,6 +28,9 @@ static func calculate(attacker : BattleMon, defender : BattleMon, move : MoveDat var stab : float = STAB_MULTIPLIER if attacker.get_types().has(move.type.to_lower()) else 1.0 damage *= roll * stab * effectiveness + if attacker.field != null: damage *= attacker.field.damage_multiplier(move.type.to_lower()) + for effect in attacker.get_effects(): + damage = effect.modify_outgoing_damage(attacker, defender, move, damage) if critical: damage *= CRIT_MULTIPLIER if spread: damage *= SPREAD_MULTIPLIER diff --git a/game/battle/effects/battle_effect.gd b/game/battle/effects/battle_effect.gd index d7fe01b..1d1483d 100644 --- a/game/battle/effects/battle_effect.gd +++ b/game/battle/effects/battle_effect.gd @@ -15,6 +15,22 @@ func modify_stat(_mon : BattleMon, _stat : PokemonNature.Stat, value : float) -> func before_action(_battle : Battle, _slot : BattleSlot, _action : BattleAction) -> bool: return true +## Appele quand le pokemon entre en combat (Intimidation, Crachin...). +func on_enter(_battle : Battle, _slot : BattleSlot) -> void: + pass + +## Appele sur le DEFENSEUR avant qu'une capacite ne le touche. Renvoyer true = la capacite est annulee (talent d'absorption...). +func on_before_hit(_battle : Battle, _attacker : BattleSlot, _defender : BattleSlot, _move : MoveData) -> bool: + return false + +## Appele sur le DEFENSEUR apres chaque coup recu (talents de contact...). +func on_after_hit(_battle : Battle, _attacker : BattleSlot, _defender : BattleSlot, _move : MoveData, _damage : int) -> void: + pass + +## Appele sur l'ATTAQUANT pendant le calcul des degats : renvoie les degats modifies (Technicien, Adaptabilite...). +func modify_outgoing_damage(_attacker : BattleMon, _defender : BattleMon, _move : MoveData, damage : float) -> float: + return damage + ## Appele a la fin de chaque tour (degats de brulure, de poison...). func on_turn_end(_battle : Battle, _slot : BattleSlot) -> void: pass diff --git a/game/battle/move_executor.gd b/game/battle/move_executor.gd index 35e3d12..6185e58 100644 --- a/game/battle/move_executor.gd +++ b/game/battle/move_executor.gd @@ -52,6 +52,7 @@ static func _use_damaging(battle : Battle, actor : BattleSlot, move : MoveData, if !_hits(battle, actor, target, move): await battle.emit_event(BattleEvent.make(BattleEvent.Type.MOVE_MISSED, actor, target)) continue + if await _blocked_before_hit(battle, actor, target, move): continue var hits : int = _roll_hit_count(battle, move) var landed : int = 0 @@ -61,8 +62,10 @@ static func _use_damaging(battle : Battle, actor : BattleSlot, move : MoveData, var calc : Dictionary = DamageCalculator.calculate(actor.mon, target.mon, move, battle.rng, spread) effectiveness = calc["effectiveness"] if effectiveness == 0.0: break - total_dealt += await battle.deal_damage(actor, target, int(calc["damage"])) + var dealt : int = await battle.deal_damage(actor, target, int(calc["damage"])) + total_dealt += dealt landed += 1 + await _after_hit(battle, actor, target, move, dealt) if calc["critical"]: await battle.emit_event(BattleEvent.make(BattleEvent.Type.CRITICAL, actor, target)) if landed == 0: @@ -127,6 +130,12 @@ static func _secondary_effects(battle : Battle, actor : BattleSlot, target : Bat #---------- capacites de statut ---------- static func _use_status(battle : Battle, actor : BattleSlot, move : MoveData, targets : Array[BattleSlot]) -> void: + #capacites de meteo : une seule fois, quelles que soient les cibles + var weather : int = BattleField.weather_for_move(move.api_name) + if weather != BattleField.Weather.NONE: + if !await battle.set_weather(weather, actor): await battle.message("Mais cela échoue !") + return + var handled : bool = false for target in targets: @@ -134,6 +143,7 @@ static func _use_status(battle : Battle, actor : BattleSlot, move : MoveData, ta if target != actor and !_hits(battle, actor, target, move): await battle.emit_event(BattleEvent.make(BattleEvent.Type.MOVE_MISSED, actor, target)) continue + if target != actor and await _blocked_before_hit(battle, actor, target, move): continue if move.ailment != "none": handled = true @@ -157,6 +167,19 @@ static func _use_status(battle : Battle, actor : BattleSlot, move : MoveData, ta if !handled: await battle.message("Mais cela n'a pas d'effet pour l'instant.") +#---------- crochets des talents ---------- + +static func _blocked_before_hit(battle : Battle, actor : BattleSlot, target : BattleSlot, move : MoveData) -> bool: + #un talent du defenseur peut annuler la capacite (Absorb Eau, Levitation...) + for effect in target.mon.get_effects(): + if await effect.on_before_hit(battle, actor, target, move): return true + return false + +static func _after_hit(battle : Battle, actor : BattleSlot, target : BattleSlot, move : MoveData, damage : int) -> void: + #talents de contact du defenseur (Statik, Peau Dure...) + for effect in target.mon.get_effects(): + await effect.on_after_hit(battle, actor, target, move, damage) + #---------- precision et coups multiples ---------- static func _hits(battle : Battle, actor : BattleSlot, target : BattleSlot, move : MoveData) -> bool: diff --git a/game/pokemon/pokemon_instance.gd b/game/pokemon/pokemon_instance.gd index 1f9f196..946403a 100644 --- a/game/pokemon/pokemon_instance.gd +++ b/game/pokemon/pokemon_instance.gd @@ -15,6 +15,7 @@ const MAX_LEVEL : int = 100 @export var nature : PokemonNature.Nature = PokemonNature.Nature.HARDY @export var current_hp : int = 1 @export var status : Status = Status.NONE +@export var ability : AbilityData = null #talent (null = aucun) @export_group("Capacités") @export var learned_moves : Array[MoveData] = [] #toutes les capacites debloquees @@ -25,16 +26,29 @@ const MAX_LEVEL : int = 100 #---------- creation ---------- -static func create(p_species : PokemonData, p_level : int, p_nature : int = -1, p_mount_data : MountProfile = null) -> PokemonInstance: +static func create(p_species : PokemonData, p_level : int, p_nature : int = -1, p_mount_data : MountProfile = null, p_ability : AbilityData = null) -> PokemonInstance: var p := PokemonInstance.new() p.species = p_species p.level = clampi(p_level, 1, MAX_LEVEL) p.nature = (p_nature as PokemonNature.Nature) if p_nature >= 0 else PokemonNature.random_nature() p.mount_data = p_mount_data + p.ability = p_ability if p_ability != null else pick_default_ability(p_species) p.fill_moves_for_level() p.current_hp = p.get_max_hp() return p +## Talent par defaut d'une espece : tire au hasard parmi les talents normaux (le talent cache seulement s'il n'y en a pas d'autre). +## rng : generateur a utiliser pour un resultat reproductible (tests) ; sinon hasard global. +static func pick_default_ability(p_species : PokemonData, rng : RandomNumberGenerator = null) -> AbilityData: + var normal : Array[AbilityData] = [] + var hidden : Array[AbilityData] = [] + for entry in p_species.abilities: + if entry == null or entry.ability == null: continue + (hidden if entry.is_hidden else normal).append(entry.ability) + var pool : Array[AbilityData] = normal if !normal.is_empty() else hidden + if pool.is_empty(): return null + return pool[rng.randi_range(0, pool.size() - 1)] if rng != null else pool.pick_random() + #---------- infos ---------- func get_display_name() -> String: @@ -172,6 +186,7 @@ func to_dict() -> Dictionary: "learned_moves": learned, "active_moves": slots, "mount_data": mount_data.resource_path if mount_data else "", + "ability": ability.api_name if ability else "", } static func from_dict(data : Dictionary) -> PokemonInstance: @@ -200,5 +215,8 @@ static func from_dict(data : Dictionary) -> PokemonInstance: if mount_path.begins_with("res://") and ResourceLoader.exists(mount_path): p.mount_data = load(mount_path) as MountProfile + var ability_name : String = data.get("ability", "") + p.ability = Pokedex.get_ability(ability_name) if ability_name != "" else pick_default_ability(species_data) + p.current_hp = clampi(int(data.get("hp", p.get_max_hp())), 0, p.get_max_hp()) return p diff --git a/game/pokemon/trainer_party_entry.gd b/game/pokemon/trainer_party_entry.gd index 206cc3e..a9ed1ba 100644 --- a/game/pokemon/trainer_party_entry.gd +++ b/game/pokemon/trainer_party_entry.gd @@ -11,6 +11,10 @@ extends Resource @export var random_nature : bool = true @export var nature : PokemonNature.Nature = PokemonNature.Nature.HARDY +@export_group("Talent") +## Vide = talent tire au hasard parmi les talents normaux de l'espece. +@export var ability : AbilityData + @export_group("Capacités") ## Vide = capacites automatiques selon le niveau. Sinon : ces capacites (max 4) sont equipees. @export var forced_moves : Array[MoveData] = [] @@ -18,8 +22,8 @@ extends Resource func create_instance() -> PokemonInstance: if species == null: return null var nature_value : int = -1 if random_nature else int(nature) - var p := PokemonInstance.create(species, level, nature_value) + var p := PokemonInstance.create(species, level, nature_value, null, ability) p.nickname = nickname if !forced_moves.is_empty(): p.set_active_moves(forced_moves) - return p \ No newline at end of file + return p diff --git a/game/storage/PCScreen.gd b/game/storage/PCScreen.gd index 5969929..cb9fee2 100644 --- a/game/storage/PCScreen.gd +++ b/game/storage/PCScreen.gd @@ -324,6 +324,7 @@ func _update_info() -> void: lines.append("%s Niv.%d" % [p.get_display_name(), p.level]) lines.append("PV %d / %d" % [p.current_hp, p.get_max_hp()]) lines.append("Nature : %s" % String(PokemonNature.Nature.keys()[p.nature]).capitalize()) + if p.ability != null: lines.append("Talent : %s" % p.ability.get_display_name()) if p.can_be_mounted(): lines.append("Peut être monté") lines.append("") for slot in p.active_moves: diff --git a/game/storage/team_screen.gd b/game/storage/team_screen.gd index eade3eb..60fade8 100644 --- a/game/storage/team_screen.gd +++ b/game/storage/team_screen.gd @@ -231,6 +231,7 @@ func _update_info(index : int) -> void: var lines : Array[String] = [] lines.append("%s Niv.%d" % [p.get_display_name(), p.level]) lines.append("Nature : %s" % String(PokemonNature.Nature.keys()[p.nature]).capitalize()) + if p.ability != null: lines.append("Talent : %s" % p.ability.get_display_name()) if p.can_be_mounted(): lines.append("Peut être monté") lines.append("") lines.append("PV : %d / %d" % [p.current_hp, p.get_max_hp()]) diff --git a/game/world/test_level.tscn b/game/world/test_level.tscn index bd605ca..347fe9a 100644 --- a/game/world/test_level.tscn +++ b/game/world/test_level.tscn @@ -55,7 +55,11 @@ albedo_texture = ExtResource("5_xly4w") [sub_resource type="Resource" id="Resource_yebv1"] script = ExtResource("8_tfb7h") species = ExtResource("10_htcyl") +level = null nickname = "Testkatchu" +random_nature = null +nature = null +forced_moves = null metadata/_custom_type_script = "uid://cqsokrfi2d05y" [sub_resource type="Resource" id="Resource_h63fm"] @@ -73,6 +77,9 @@ albedo_color = Color(1, 1, 1, 0) script = ExtResource("8_tfb7h") species = ExtResource("14_2rm86") level = 13 +nickname = null +random_nature = null +nature = null forced_moves = Array[ExtResource("9_2rm86")]([ExtResource("11_g0lob"), ExtResource("12_08mbr"), ExtResource("13_tfb7h")]) metadata/_custom_type_script = "uid://cqsokrfi2d05y"