Compare commits

...

6 commits

7 changed files with 350 additions and 10 deletions

80
game/battle/battle.md Normal file
View file

@ -0,0 +1,80 @@
# Gérer l'équipe d'un PNJ ou d'un Pokémon sauvage
*Tales of Pokémon · système de combat*
## Principe
Tout se décrit avec des ressources, sans écrire de code :
- **`TrainerPartyEntry`** : un Pokémon « modèle » (espèce, niveau, surnom, nature, capacités).
- **`TrainerData`** : un dresseur PNJ, avec une liste de `TrainerPartyEntry` (6 maximum).
- **`BattleEncounter`** : la rencontre complète (qui est en face, format du combat, règles).
- **`AimTarget`** : la cible du monde. Son champ `Encounter` contient le `BattleEncounter`, et son champ `World Actor` le node visible dans le monde (caché pendant le combat).
Ces ressources sont des modèles : à chaque combat, de **vrais Pokémon neufs** sont créés à partir d'elles. Un PNJ retrouve donc son équipe intacte à chaque revanche, et un même `BattleEncounter` peut être partagé entre plusieurs cibles.
Pour créer une ressource : clic droit dans le dock Système de fichiers > *Nouvelle ressource…*, choisir le type, puis l'enregistrer en `.tres`.
## Un Pokémon sauvage (1 contre 1)
1. Crée un `TrainerPartyEntry` : choisis l'**espèce** et le **niveau**.
2. Crée un `BattleEncounter` et ajoute l'entrée à `Wild Pokemon`.
3. Sur l'`AimTarget` de l'ennemi : assigne le `BattleEncounter` à `Encounter` et le node de l'ennemi à `World Actor`.
4. Laisse `Can Flee` coché : le joueur peut fuir un Pokémon sauvage.
Un Pokémon sauvage n'a pas de banc, il ne change donc jamais de Pokémon.
## Plusieurs Pokémon sauvages (1 contre plusieurs)
Ajoute **plusieurs entrées** dans `Wild Pokemon` : chacune devient un adversaire à part entière avec son propre emplacement (3 entrées = 1 contre 3). Chaque adversaire attaque, et peut être ciblé, séparément.
## Un dresseur PNJ
1. Crée un `TrainerData` : renseigne `Id`, `Names` (un nom par langue, par exemple `fr` et `en`) et ajoute ses Pokémon dans `Party` (6 maximum, dans l'ordre d'envoi).
2. Crée un `BattleEncounter` : assigne le `Trainer`, et **décoche `Can Flee`** (on ne fuit pas un dresseur).
3. Sur son `AimTarget` : `Encounter` et `World Actor`, comme pour un sauvage.
Son premier Pokémon (puis le suivant, pour un combat double) entre en combat, et les autres restent sur son banc. Il en envoie un autre quand le sien est KO, ou parfois de lui-même quand son Pokémon est très désavantagé face au tien.
## Combat double, boss et autres formats
Le format vient uniquement du nombre d'emplacements :
| Format | Réglage du `BattleEncounter` |
| --- | --- |
| 1 contre 1 sauvage | 1 entrée dans `Wild Pokemon` |
| 1 contre plusieurs | plusieurs entrées dans `Wild Pokemon` |
| Dresseur, combat simple | `Trainer`, `Trainer Slots` = 1 |
| Combat double | `Player Slots` = 2 et `Trainer Slots` = 2 |
| Plusieurs contre 1 (boss) | `Player Slots` = 3 et un seul adversaire (une entrée sauvage, ou un dresseur avec `Trainer Slots` = 1) |
`Player Slots` et `Trainer Slots` vont de 1 à 3. Si le joueur a moins de Pokémon aptes que d'emplacements, il en envoie moins.
## Capacités et nature
Pour chaque `TrainerPartyEntry` :
- **Capacités** : si `Forced Moves` est vide, le Pokémon connaît les capacités apprises par montée de niveau jusqu'à son niveau, et équipe les 4 dernières. Sinon, les capacités listées (4 maximum) sont équipées à la place.
- **Nature** : `Random Nature` coché tire une nature au hasard à chaque combat. Décoche-le et choisis `Nature` pour la fixer.
- **Surnom** : s'il est vide, le nom de l'espèce s'affiche.
## Quel Pokémon du joueur combat ?
- Si le joueur **lance** un Pokémon sur la cible, c'est lui qui entre en premier, puis les suivants de l'équipe pour un combat double.
- Si le **dresseur défie** le joueur (dialogue, zone de détection), c'est le premier Pokémon apte de l'équipe, puis le deuxième, etc. Pour cela, appelle depuis ton code :
```gdscript
launcher.start_encounter(encounter, position_du_dresseur, null, world_actor)
```
## Après le combat
- Si `Remove World Actor On Win` est coché, le `World Actor` disparaît après une victoire. Sinon il réapparaît.
- Le signal `battle_finished(outcome)` de l'`AimTarget` (et `GameData.battle_finished`) donne le résultat, une valeur de `Battle.Outcome` : `PLAYER_WON`, `PLAYER_LOST`, `FLED` ou `DRAW`. Branche-y tes dialogues, tes récompenses ou la suite de l'histoire.
- Après une défaite, l'équipe du joueur est soignée automatiquement (option `Heal Team On Defeat` sur le `BattleLauncher`). C'est provisoire, en attendant ton propre écran de défaite.
## Bon à savoir
- Un `TrainerData` ne doit pas dépasser 6 Pokémon : les suivants sont ignorés, avec un avertissement dans la console.
- Une entrée sans espèce est ignorée. Si un camp n'a plus aucun Pokémon, le combat ne démarre pas, et la console l'indique.
- Pas encore implémentés : les effets des capacités de statut, les objets, le gain d'XP et d'argent, et la capture.

View file

@ -0,0 +1,52 @@
extends CanvasLayer
class_name AimReticle
#reticule 2D au centre de l'ecran (visee libre / charge du lancer)
#cree a la volee, une seule instance partagee par AimState et ThrowState
static var _instance : AimReticle
static func get_instance(from : Node) -> AimReticle:
if is_instance_valid(_instance): return _instance
_instance = AimReticle.new()
from.get_tree().current_scene.add_child(_instance)
return _instance
static func show_instance(from : Node) -> void:
get_instance(from).visible = true
static func hide_instance() -> void:
if is_instance_valid(_instance): _instance.visible = false
func _init() -> void:
layer = 10
visible = false
add_child(Drawer.new())
class Drawer extends Control:
@export var radius : float = 14.0
@export var tick_length : float = 8.0
@export var color : Color = Color(1.0, 1.0, 1.0, 0.9)
@export var outline_color : Color = Color(0.0, 0.0, 0.0, 0.5) #contour sombre pour rester lisible sur fond clair
func _init() -> void:
set_anchors_preset(Control.PRESET_FULL_RECT)
mouse_filter = Control.MOUSE_FILTER_IGNORE
func _ready() -> void:
resized.connect(queue_redraw)
func _draw() -> void:
var c : Vector2 = size / 2.0
#contour sombre, puis trait clair par-dessus
draw_arc(c, radius, 0.0, TAU, 40, outline_color, 4.0, true)
draw_circle(c, 3.0, outline_color)
for dir : Vector2 in [Vector2.UP, Vector2.DOWN, Vector2.LEFT, Vector2.RIGHT]:
draw_line(c + dir * (radius + 2.0), c + dir * (radius + 2.0 + tick_length), outline_color, 4.0)
draw_arc(c, radius, 0.0, TAU, 40, color, 2.0, true)
draw_circle(c, 1.5, color)
for dir : Vector2 in [Vector2.UP, Vector2.DOWN, Vector2.LEFT, Vector2.RIGHT]:
draw_line(c + dir * (radius + 2.0), c + dir * (radius + 2.0 + tick_length), color, 2.0)

View file

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

View file

@ -10,6 +10,7 @@ var play_char : CharacterBody3D
@export var switch_target_action : StringName = &"player_aim_switch_target"
@export var unlock_action : StringName = &"player_aim_unlock"
@export var throw_action : StringName = &"player_throw" #RT / clic gauche (cree automatiquement s'il n'existe pas)
@export var cancel_action : StringName = &"player_throw_cancel" #annule la charge d'un lancer (cree par ThrowState)
@export_group("Targeting")
@export var throw_range : float = 20.0
@ -27,6 +28,7 @@ var auto_lock_blocked : bool = false #true apres un deverrouillage manuel, tant
var dodging_from_aim : bool = false #true quand on quitte Aim pour rouler, pour ne pas relacher la cible verrouillee
var came_from_crouch : bool = false #true si Aim a ete declenche depuis CrouchState, pour y retourner en sortant
var keep_aim_on_exit : bool = false #true quand on part vers ThrowState : camera et verrouillage restent en place
var charging_throw : bool = false #true quand on arrive depuis ThrowState en pleine charge : RT est encore maintenue
func _ready() -> void:
if InputMap.has_action(throw_action): return
@ -43,7 +45,10 @@ func enter(play_char_ref : CharacterBody3D) -> void:
play_char = play_char_ref
auto_lock_blocked = false
#curr_state_name n'est pas encore mis a jour a ce stade : il reflete encore l'etat qu'on est en train de quitter
came_from_crouch = play_char.state_machine.curr_state_name == "Crouch"
#si on revient de ThrowState, came_from_crouch a deja ete transmis par ThrowState : on ne l'ecrase pas
var previous : String = play_char.state_machine.curr_state_name
if previous != "Throw" and previous != "Dodge":
came_from_crouch = previous == "Crouch"
verifications()
func verifications() -> void:
@ -51,9 +56,11 @@ func verifications() -> void:
play_char.cam_holder.cam_aimed = true
func exit() -> void:
charging_throw = false
if keep_aim_on_exit:
keep_aim_on_exit = false
return
AimReticle.hide_instance()
if dodging_from_aim:
#on roule en visant : la camera et la cible verrouillee restent actives pendant la roulade
dodging_from_aim = false
@ -66,6 +73,7 @@ func physics_update(delta : float) -> void:
play_char.gravity_apply(delta)
if input_management(): return
update_target()
update_reticle()
move(delta)
func applies() -> bool:
@ -81,12 +89,22 @@ func input_management() -> bool:
return true
if !Input.is_action_pressed(aim_action):
if charging_throw and Input.is_action_pressed(throw_action) and can_throw():
transitioned.emit(self, "ThrowState") #visee relachee en pleine charge : on reprend la charge hors visee
return true
if came_from_crouch:
transitioned.emit(self, "CrouchState")
else:
transitioned.emit(self, play_char.walk_or_run if play_char.move_dir else "IdleState")
return true
if charging_throw:
if Input.is_action_just_pressed(cancel_action):
charging_throw = false #lancer annule, on reste en visee
elif !Input.is_action_pressed(throw_action):
charging_throw = false
if start_throw(true): return true #RT relachee : le lancer part avec la cible verrouillee
if Input.is_action_just_pressed(throw_action):
#si le pokemon selectionne est deja dehors, un nouvel appui le rappelle au lieu de relancer
var selected : PokemonInstance = GameData.get_selected_pokemon()
@ -103,10 +121,15 @@ func input_management() -> bool:
switch_target()
return false
func start_throw() -> bool:
func can_throw() -> bool:
if GameData.get_selected_pokemon() == null: return false #personne a envoyer
if !play_char.state_machine.states.has("throwstate"): return false #pas de node ThrowState dans la StateMachine
return play_char.state_machine.states.has("throwstate") #sinon : pas de node ThrowState dans la StateMachine
func start_throw(released : bool = false) -> bool:
if !can_throw(): return false
keep_aim_on_exit = true
var throw : Node = play_char.state_machine.states["throwstate"]
throw.resume_released = released #true : on saute la charge, le windup demarre directement
transitioned.emit(self, "ThrowState")
return true
@ -120,6 +143,12 @@ func move(delta : float) -> void:
play_char.velocity.x = lerp(play_char.velocity.x, 0.0, play_char.move_deccel * delta)
play_char.velocity.z = lerp(play_char.velocity.z, 0.0, play_char.move_deccel * delta)
func update_reticle() -> void:
if is_instance_valid(play_char.aim_target):
AimReticle.hide_instance() #cible verrouillee : son propre indicateur suffit
else:
AimReticle.show_instance(self)
#---------- targeting ----------
func update_target() -> void:

View file

@ -11,10 +11,21 @@ var play_char : CharacterBody3D
var dodge_dir : Vector2 = Vector2.ZERO
var dodge_timer : float = 0.0
var dodge_duration_ref : float = 0.0
var came_from_crouch : bool = false #true si l'esquive vient du crouch (directement, ou via Aim declenche depuis le crouch)
func enter(play_char_ref : CharacterBody3D) -> void:
play_char = play_char_ref
#curr_state_name n'est pas encore mis a jour a ce stade : il reflete encore l'etat qu'on quitte
var previous : String = play_char.state_machine.curr_state_name
if previous == "Crouch":
came_from_crouch = true
elif previous == "Aim":
var aim : Node = play_char.state_machine.states.get("aimstate")
came_from_crouch = aim != null and aim.came_from_crouch
else:
came_from_crouch = false
verifications()
func verifications() -> void:
@ -69,7 +80,12 @@ func end_dodge() -> void:
if !play_char.is_on_floor():
transitioned.emit(self, "InairState")
elif Input.is_action_pressed(play_char.aim_cam_action):
#AimState ne verra que "Dodge" comme etat precedent : on lui transmet l'origine crouch
var aim : Node = play_char.state_machine.states.get("aimstate")
if aim: aim.came_from_crouch = came_from_crouch
transitioned.emit(self, "AimState") #le joueur a garde la visee enfoncee pendant la roulade
elif came_from_crouch:
transitioned.emit(self, "CrouchState")
elif play_char.move_dir:
transitioned.emit(self, play_char.walk_or_run)
else:

View file

@ -3,6 +3,9 @@ extends Node
class_name StateMachine
@export var initial_state : State
@export var throw_action : StringName = &"player_throw"
const THROW_ENTRY_STATES : Array[String] = ["Idle", "Walk", "Run", "Crouch"] #etats depuis lesquels RT lance sans viser
var curr_state : State
var curr_state_name : String
@ -26,9 +29,30 @@ func _ready() -> void:
func _process(delta : float) -> void:
if curr_state: curr_state.update(delta)
func _physics_process(delta: float) -> void:
if curr_state: curr_state.physics_update(delta)
if curr_state:
if try_start_throw(): return
curr_state.physics_update(delta)
func try_start_throw() -> bool:
if !(curr_state_name in THROW_ENTRY_STATES): return false
if !Input.is_action_just_pressed(throw_action): return false
if !char_ref.is_on_floor(): return false
var selected : PokemonInstance = GameData.get_selected_pokemon()
if selected == null: return false
#pokemon deja dehors : l'appui le rappelle au lieu de lancer
var already_out : SentPokemon = SentPokemon.find_for(get_tree(), selected)
if already_out:
already_out.recall()
return false
if !states.has("throwstate"): return false
on_state_child_transition(curr_state, "ThrowState")
return true
func on_state_child_transition(state : State, new_state_name : String) -> void:
#manage the transition from one state to another

View file

@ -7,9 +7,15 @@ var state_name : String = "Throw"
var play_char : CharacterBody3D
@export var aim_action : StringName = &"player_aim_camera"
@export var throw_action : StringName = &"player_throw" #RT / clic gauche : maintenir pour charger, relacher pour lancer
@export var cancel_action : StringName = &"player_throw_cancel" #B / clic droit (cree automatiquement s'il n'existe pas)
@export_group("Charge")
@export var charge_move_speed : float = 3.0 #vitesse de deplacement pendant que RT est maintenue
@export var charge_move_accel : float = 8.0
@export_group("Timing")
@export var windup_time : float = 0.25 #delai avant que la balle parte (a caler sur l'animation)
@export var windup_time : float = 0.25 #delai entre le relachement et le depart de la balle (a caler sur l'animation)
@export var recovery_time : float = 0.35 #temps de recuperation apres le lancer
@export_group("Ball")
@ -24,44 +30,102 @@ var play_char : CharacterBody3D
@export_group("Targeting")
@export var throw_range : float = 20.0
@export_flags_3d_physics var ground_mask : int = 1
@export var free_hit_radius : float = 2.0 #lancer sans cible verrouillee : rayon autour du point d'atterrissage pour trouver une cible de combat
var _elapsed : float = 0.0
var _released : bool = false #true des que RT est relachee (phase windup/recovery)
var _thrown : bool = false
var _keep_aim : bool = false #true quand on retourne a AimState : on garde camera et verrouillage
var _pokemon : PokemonInstance = null
var _target : Variant = null #AimTarget verrouille au moment du lancer
var _target : Variant = null #AimTarget verrouille au moment du relachement
var _free_aim_point : Vector3 = Vector3.ZERO
var _came_from_crouch : bool = false #true si le lancer vient du crouch (directement, ou via Aim declenche depuis le crouch)
var resume_released : bool = false #mis a true par AimState : RT a ete relachee pendant la visee, le lancer demarre directement en windup
func _ready() -> void:
_ensure_action(throw_action, func():
var trigger := InputEventJoypadMotion.new()
trigger.axis = JOY_AXIS_TRIGGER_RIGHT
trigger.axis_value = 1.0
InputMap.action_add_event(throw_action, trigger)
var click := InputEventMouseButton.new()
click.button_index = MOUSE_BUTTON_LEFT
InputMap.action_add_event(throw_action, click))
_ensure_action(cancel_action, func():
var pad := InputEventJoypadButton.new()
pad.button_index = JOY_BUTTON_B
InputMap.action_add_event(cancel_action, pad)
var click := InputEventMouseButton.new()
click.button_index = MOUSE_BUTTON_RIGHT
InputMap.action_add_event(cancel_action, click))
func _ensure_action(action : StringName, add_events : Callable) -> void:
if InputMap.has_action(action): return
InputMap.add_action(action)
add_events.call()
func enter(play_char_ref : CharacterBody3D) -> void:
play_char = play_char_ref
_elapsed = 0.0
_released = false
_thrown = false
_keep_aim = false
#curr_state_name n'est pas encore mis a jour a ce stade : il reflete encore l'etat qu'on quitte
var previous : String = play_char.state_machine.curr_state_name
if previous == "Crouch":
_came_from_crouch = true
elif previous == "Aim":
var aim : Node = play_char.state_machine.states.get("aimstate")
_came_from_crouch = aim != null and aim.came_from_crouch
else:
_came_from_crouch = false
_pokemon = GameData.get_selected_pokemon()
_target = play_char.aim_target if is_instance_valid(play_char.aim_target) else null
_free_aim_point = _compute_free_aim_point() #visee libre : le point est fige au moment ou on appuie
_target = null
_free_aim_point = Vector3.ZERO
play_char.floor_snap_length = 1.0
#play_char.godot_plush_skin.set_state("throw")
if resume_released:
resume_released = false
release() #fige la cible verrouillee / le point vise et lance le windup
func exit() -> void:
#retour a la visee : on garde tout tel quel ; sinon on libere camera et verrouillage (comme AimState)
if _keep_aim:
_keep_aim = false
return
AimReticle.hide_instance()
_release_aim()
func physics_update(delta : float) -> void:
if applies(): return
play_char.gravity_apply(delta)
slow_down(delta)
if _pokemon == null:
slow_down(delta)
finish() #rien a lancer (equipe vide ou tous K.O.)
return
#phase 1 : charge, tant que RT est maintenue
if !_released:
charge(delta)
return
#phase 2 : windup puis recovery
slow_down(delta)
if !_thrown and Input.is_action_just_pressed(cancel_action):
finish() #annule avant que la balle parte
return
if _thrown and Input.is_action_just_pressed(aim_action):
finish() #balle partie : on coupe la recuperation, finish() bascule en AimState
return
_elapsed += delta
if !_thrown and _elapsed >= windup_time:
throw_ball()
@ -74,6 +138,49 @@ func applies() -> bool:
return true
return false
#---------- charge ----------
func charge(delta : float) -> void:
if Input.is_action_just_pressed(cancel_action):
finish() #annule : rien n'est lance
return
if Input.is_action_just_pressed(aim_action):
enter_aim_while_charging()
return
if !Input.is_action_pressed(throw_action):
release()
return
move_while_charging(delta)
update_reticle()
func enter_aim_while_charging() -> void:
#on passe en visee sans perdre la charge : AimState se charge de relancer le lancer au relachement de RT
var aim : Node = play_char.state_machine.states.get("aimstate")
if aim == null: return
aim.came_from_crouch = _came_from_crouch
aim.charging_throw = true
_keep_aim = true
transitioned.emit(self, "AimState")
func release() -> void:
#RT relachee : on fige la cible et le point vise, puis le windup demarre
_released = true
_elapsed = 0.0
_target = play_char.aim_target if is_instance_valid(play_char.aim_target) else null
_free_aim_point = _compute_free_aim_point()
func move_while_charging(delta : float) -> void:
play_char.move_dir = Input.get_vector(play_char.move_left_action, play_char.move_right_action, play_char.move_forward_action, play_char.move_backward_action).rotated(-play_char.cam_holder.global_rotation.y)
if play_char.move_dir and play_char.is_on_floor():
play_char.velocity.x = lerp(play_char.velocity.x, play_char.move_dir.x * charge_move_speed, charge_move_accel * delta)
play_char.velocity.z = lerp(play_char.velocity.z, play_char.move_dir.y * charge_move_speed, charge_move_accel * delta)
else:
slow_down(delta)
func slow_down(delta : float) -> void:
play_char.velocity.x = lerp(play_char.velocity.x, 0.0, play_char.move_deccel * delta)
play_char.velocity.z = lerp(play_char.velocity.z, 0.0, play_char.move_deccel * delta)
@ -81,16 +188,30 @@ func slow_down(delta : float) -> void:
func finish() -> void:
if Input.is_action_pressed(aim_action):
_keep_aim = true
#AimState ne verra que "Throw" comme etat precedent : on lui transmet l'origine crouch
var aim : Node = play_char.state_machine.states.get("aimstate")
if aim: aim.came_from_crouch = _came_from_crouch
transitioned.emit(self, "AimState")
return
if _came_from_crouch:
transitioned.emit(self, "CrouchState")
return
var dir : Vector2 = Input.get_vector(play_char.move_left_action, play_char.move_right_action, play_char.move_forward_action, play_char.move_backward_action)
transitioned.emit(self, play_char.walk_or_run if dir != Vector2.ZERO else "IdleState")
func update_reticle() -> void:
if is_instance_valid(play_char.aim_target):
AimReticle.hide_instance() #cible verrouillee : son propre indicateur suffit
else:
AimReticle.show_instance(self)
#---------- lancer ----------
func throw_ball() -> void:
_thrown = true
AimReticle.hide_instance()
var from : Vector3 = play_char.global_position + Vector3.UP * hand_height
var to : Vector3 = _get_landing_point()
@ -136,6 +257,10 @@ func _on_ball_landed(landing_pos : Vector3, pokemon : PokemonInstance, target :
var scene : Node = get_tree().current_scene if is_inside_tree() else null
if scene == null: return
#lancer sans cible verrouillee : si la balle atterrit pres d'une cible de combat, c'est elle qui est visee
if !is_instance_valid(target):
target = _find_battle_target_near(landing_pos)
var target_valid : bool = is_instance_valid(target)
#le pokemon regarde la cible, ou dans la direction du lancer s'il n'y en a pas
var look_pos : Vector3 = target.global_position if target_valid else landing_pos + (landing_pos - thrower_pos)
@ -147,6 +272,19 @@ func _on_ball_landed(landing_pos : Vector3, pokemon : PokemonInstance, target :
#diagnostic : retire ce message quand tout fonctionne
push_warning("ThrowState : la cible '%s' ne lance pas de combat (starts_battle faux : Encounter vide, ou aim_target.gd pas remplace)." % target.name)
func _find_battle_target_near(point : Vector3) -> AimTarget:
#cible de combat active la plus proche du point d'atterrissage, dans free_hit_radius
var best : AimTarget = null
var best_dist : float = free_hit_radius
for node in get_tree().get_nodes_in_group("aim_targets"):
var t := node as AimTarget
if t == null or !t.enabled or !_starts_battle(t): continue
var dist : float = point.distance_to(t.global_position)
if dist <= best_dist:
best_dist = dist
best = t
return best
#---------- nettoyage ----------
func _release_aim() -> void: