From 00bfe60c3273acf239ca121cc0979c468ebb7517 Mon Sep 17 00:00:00 2001 From: TheThomaas Date: Mon, 12 Oct 2026 10:53:50 +0200 Subject: [PATCH] Allow riding on pokemons --- game/mount/mount_rider.gd | 171 ++++++++++++++++++++++++++++ game/mount/mount_rider.gd.uid | 1 + game/player/player_character.gd | 41 +++++++ game/player/player_character.tscn | 16 +-- game/player/states/state_machine.gd | 7 ++ game/pokemon/sent_pokemon.gd | 22 +++- 6 files changed, 240 insertions(+), 18 deletions(-) create mode 100644 game/mount/mount_rider.gd create mode 100644 game/mount/mount_rider.gd.uid diff --git a/game/mount/mount_rider.gd b/game/mount/mount_rider.gd new file mode 100644 index 0000000..e99c6ee --- /dev/null +++ b/game/mount/mount_rider.gd @@ -0,0 +1,171 @@ +class_name MountRider +extends Node + +#composant a ajouter en enfant de PlayerCharacter (a cote de InputManagementComponent / RecallInput). +#gere la monte d'un SentPokemon dont l'espece a un MountProfile (voir MountRegistry) : +# - s'approcher + appuyer sur mount_action (A) pour monter, dismount_action pour descendre +# - le joueur garde ses etats Idle / Walk / Run / Jump / Aim / Throw +# - Dodge et Crouch sont refuses par la StateMachine tant qu'on est monte +# - vitesse, accel et saut sont remplaces par ceux du MountProfile + +signal mounted(sent : SentPokemon) +signal dismounted(sent : SentPokemon) +signal candidate_changed(sent : SentPokemon) #pokemon montable a portee (null = aucun) : pour afficher "A : Monter" dans le HUD + +@export var mount_action : StringName = &"player_interact" #A (manette) / E (clavier) : cree automatiquement s'il n'existe pas +@export var dismount_action : StringName = &"player_dismount" #Y (manette) / F (clavier) : cree automatiquement s'il n'existe pas +@export var mount_range : float = 2.5 #distance horizontale max entre le joueur et le pokemon +@export var max_height_diff : float = 2.0 +@export var blocked_states : PackedStringArray = ["DodgeState", "CrouchState"] #etats refuses pendant la monte +@export var swallow_jump_on_mount : bool = true #si A sert aussi a sauter : l'appui qui monte ne declenche pas de saut +@export var camera_follow_speed : float = 8.0 #vitesse a laquelle la camera monte a hauteur de selle + +const GROUNDED_STATES : Array[String] = ["Idle", "Walk", "Run"] #monter / descendre n'est possible que depuis ces etats + +@onready var player : PlayerCharacter = get_parent() +@onready var visual_root : Node3D = %VisualRoot +@onready var cam_holder : Node3D = %CameraHolder +@onready var state_machine : StateMachine = %StateMachine + +var mount : SentPokemon = null +var profile : MountProfile = null +var candidate : SentPokemon = null + +var _seat_applied : Vector3 = Vector3.ZERO +var _cam_rise : float = 0.0 + +func _ready() -> void: + process_physics_priority = -10 #avant la StateMachine : l'appui qui monte peut ainsi etre intercepte avant le saut + _ensure_action(mount_action, func(): + var pad := InputEventJoypadButton.new() + pad.button_index = JOY_BUTTON_A + InputMap.action_add_event(mount_action, pad) + var key := InputEventKey.new() + key.physical_keycode = KEY_E + InputMap.action_add_event(mount_action, key)) + _ensure_action(dismount_action, func(): + var pad := InputEventJoypadButton.new() + pad.button_index = JOY_BUTTON_Y + InputMap.action_add_event(dismount_action, pad) + var key := InputEventKey.new() + key.physical_keycode = KEY_F + InputMap.action_add_event(dismount_action, key)) + +func _ensure_action(action : StringName, add_events : Callable) -> void: + if InputMap.has_action(action): return + InputMap.add_action(action) + add_events.call() + +func is_mounted() -> bool: + return mount != null + +func _physics_process(_delta : float) -> void: + if mount != null: + if Input.is_action_just_pressed(dismount_action) and _can_switch_now(): + dismount() + return + + _set_candidate(_find_candidate() if _can_switch_now() else null) + if candidate != null and Input.is_action_just_pressed(mount_action): + mount_up(candidate) + +func _process(delta : float) -> void: + if mount != null and !is_instance_valid(mount): + dismount() #le pokemon a disparu sans passer par recall() + + var rise_target : float = 0.0 + if mount != null: + #la monture suit le joueur (position + orientation du modele) + mount.global_position = player.global_position + var yaw : float = visual_root.global_rotation.y + if !SentPokemon.MODEL_FACES_PLUS_Z: yaw += PI + mount.global_rotation = Vector3(0.0, yaw, 0.0) + + #le joueur est assis a la selle (seat_offset est exprime dans le repere de la monture) + var seat : Vector3 = mount.global_basis * profile.seat_offset + visual_root.position += seat - _seat_applied + _seat_applied = seat + rise_target = profile.seat_offset.y + + #la camera monte progressivement a hauteur de selle (en delta : ne gene pas les autres reglages de hauteur) + var new_rise : float = lerpf(_cam_rise, rise_target, clampf(camera_follow_speed * delta, 0.0, 1.0)) + if absf(new_rise - rise_target) < 0.001: new_rise = rise_target + cam_holder.position.y += new_rise - _cam_rise + _cam_rise = new_rise + +#---------- detection ---------- + +func _can_switch_now() -> bool: + return player.is_on_floor() and state_machine.curr_state_name in GROUNDED_STATES + +func _find_candidate() -> SentPokemon: + var best : SentPokemon = null + var best_dist : float = mount_range + for node in get_tree().get_nodes_in_group(SentPokemon.GROUP): + var s := node as SentPokemon + if s == null or s.is_queued_for_deletion() or s.rider != null: continue + if !_is_rideable_here(s): continue + var diff : Vector3 = s.global_position - player.global_position + if absf(diff.y) > max_height_diff: continue + var dist : float = Vector2(diff.x, diff.z).length() + if dist <= best_dist: + best_dist = dist + best = s + return best + +func _is_rideable_here(s : SentPokemon) -> bool: + var p : MountProfile = MountRegistry.get_profile(s.pokemon.species) + if p == null: return false + #sur terre : une monture terrestre, ou volante (qui pourra decoller). Une monture aquatique seule reste a quai. + return p.can_use_surface(MountProfile.Surface.GROUND) or p.can_use_surface(MountProfile.Surface.AIR) + +func _set_candidate(s : SentPokemon) -> void: + if s == candidate: return + candidate = s + candidate_changed.emit(s) + +#---------- monter / descendre ---------- + +func mount_up(s : SentPokemon) -> void: + if mount != null: return + mount = s + profile = MountRegistry.get_profile(s.pokemon.species) + s.rider = player + s.recalled.connect(_on_mount_recalled) + _set_candidate(null) + + player.is_mounted = true + player.enter_mount_stats(profile.mount_speed, profile.mount_accel, profile.mount_deccel, + -1.0 if profile.can_use_surface(MountProfile.Surface.AIR) else profile.mount_jump_height) #monture volante : le vol libre remplacera le saut + for state_name in blocked_states: + state_machine.set_state_blocked(state_name, true) + if swallow_jump_on_mount: _swallow_jump_this_frame() + + mounted.emit(s) + +func dismount() -> void: + if mount == null and profile == null: return + var s : SentPokemon = mount + mount = null + profile = null + + player.is_mounted = false + player.exit_mount_stats() + for state_name in blocked_states: + state_machine.set_state_blocked(state_name, false) + + visual_root.position -= _seat_applied + _seat_applied = Vector3.ZERO + + if is_instance_valid(s): + s.rider = null + if s.recalled.is_connected(_on_mount_recalled): s.recalled.disconnect(_on_mount_recalled) + dismounted.emit(s) + +func _on_mount_recalled() -> void: + dismount() #le pokemon monte vient d'etre rappele (ex : relance de ce meme pokemon depuis le HUD) + +func _swallow_jump_this_frame() -> void: + state_machine.set_state_blocked("JumpState", true) + await get_tree().physics_frame + state_machine.set_state_blocked("JumpState", false) diff --git a/game/mount/mount_rider.gd.uid b/game/mount/mount_rider.gd.uid new file mode 100644 index 0000000..eb55be6 --- /dev/null +++ b/game/mount/mount_rider.gd.uid @@ -0,0 +1 @@ +uid://c4vkkdl0an244 diff --git a/game/player/player_character.gd b/game/player/player_character.gd index 3e6c810..4cf5123 100644 --- a/game/player/player_character.gd +++ b/game/player/player_character.gd @@ -15,6 +15,8 @@ var was_on_floor : bool = false var walk_or_run : String = "WalkState" #keep in memory if play char was walking or running before being in the air var is_dodging : bool = false #true while DodgeState is active ; hook this up to a hurtbox/i-frame check later var is_crouching : bool = false #true while CrouchState is active ; hook this up to a collision-shape resize later +var is_mounted : bool = false #true tant que MountRider a le joueur sur un pokemon +var _base_ground_stats : Dictionary = {} @export_group("Walk variables") @export var walk_speed : float = 5.0 @@ -165,6 +167,45 @@ func gravity_apply(delta : float): if !is_on_floor(): if velocity.y >= 0.0: velocity.y -= jump_gravity * delta elif velocity.y < 0.0: velocity.y -= fall_gravity * delta + +func apply_jump_height(height : float) -> void: + #source unique des formules de saut (aussi utilisee pour les montures) + jump_velocity = ((2.0 * height) / jump_time_to_peak) * -1.0 + jump_gravity = (2.0 * height) / (jump_time_to_peak * jump_time_to_peak) + fall_gravity = (2.0 * height) / (jump_time_to_descent * jump_time_to_descent) + +func enter_mount_stats(speed : float, accel : float, deccel : float, mount_jump_height : float) -> void: + #mount_jump_height <= 0 : on garde le saut du joueur + if _base_ground_stats.is_empty(): + _base_ground_stats = { + "walk_speed": walk_speed, "walk_accel": walk_accel, "walk_deccel": walk_deccel, + "run_speed": run_speed, "run_accel": run_accel, "run_deccel": run_deccel, + } + walk_speed = speed + run_speed = speed + walk_accel = accel + run_accel = accel + walk_deccel = deccel + run_deccel = deccel + if mount_jump_height > 0.0: apply_jump_height(mount_jump_height) + #l'etat courant lit move_* sans repasser par enter() + move_speed = speed + move_accel = accel + move_deccel = deccel + +func exit_mount_stats() -> void: + if _base_ground_stats.is_empty(): return + walk_speed = _base_ground_stats["walk_speed"] + walk_accel = _base_ground_stats["walk_accel"] + walk_deccel = _base_ground_stats["walk_deccel"] + run_speed = _base_ground_stats["run_speed"] + run_accel = _base_ground_stats["run_accel"] + run_deccel = _base_ground_stats["run_deccel"] + _base_ground_stats.clear() + apply_jump_height(jump_height) #jump_height n'est jamais modifie : on revient aux valeurs du joueur + move_speed = walk_speed + move_accel = walk_accel + move_deccel = walk_deccel func squash_and_strech(value : float, timing : float): #create a tween that simulate a compression of the model (squash and strech ones) diff --git a/game/player/player_character.tscn b/game/player/player_character.tscn index 92d60e9..dbc26b9 100644 --- a/game/player/player_character.tscn +++ b/game/player/player_character.tscn @@ -13,6 +13,7 @@ [ext_resource type="Script" uid="uid://bwojkp7yrwswt" path="res://game/player/states/dodge_state.gd" id="9_6abew"] [ext_resource type="Script" uid="uid://d0577tj0c23bu" path="res://game/player/states/crouch_state.gd" id="10_mi63v"] [ext_resource type="Script" uid="uid://c820hnwuwpmlg" path="res://game/player/input/recall_input.gd" id="13_siyau"] +[ext_resource type="Script" uid="uid://c4vkkdl0an244" path="res://game/mount/mount_rider.gd" id="14_dkijf"] [sub_resource type="Curve" id="Curve_kxr4l"] _limits = [0.0, 10.0, 0.0, 1.0] @@ -86,18 +87,6 @@ script = ExtResource("6_lwgph") [node name="AimState" type="Node" parent="StateMachine" unique_id=296560188] script = ExtResource("7_kxr4l") -aim_action = null -switch_target_action = null -unlock_action = null -throw_action = null -throw_range = null -max_aim_angle = null -auto_lock_angle = null -lose_range_margin = null -check_line_of_sight = null -line_of_sight_mask = null -aim_move_speed = null -aim_move_accel = null [node name="ThrowState" type="Node" parent="StateMachine" unique_id=1159570489] script = ExtResource("8_k0r6x") @@ -116,3 +105,6 @@ script = ExtResource("2_r5dqo") [node name="RecallInput" type="Node" parent="." unique_id=1544887696] script = ExtResource("13_siyau") + +[node name="MountRider" type="Node" parent="." unique_id=639128028] +script = ExtResource("14_dkijf") diff --git a/game/player/states/state_machine.gd b/game/player/states/state_machine.gd index 8e49d86..e5d226c 100644 --- a/game/player/states/state_machine.gd +++ b/game/player/states/state_machine.gd @@ -10,6 +10,7 @@ const THROW_ENTRY_STATES : Array[String] = ["Idle", "Walk", "Run", "Crouch"] #et var curr_state : State var curr_state_name : String var states : Dictionary = {} +var blocked_states : Dictionary = {} #noms (en minuscules) des etats dont l'entree est refusee @onready var char_ref : CharacterBody3D = $".." @@ -62,6 +63,8 @@ func on_state_child_transition(state : State, new_state_name : String) -> void: var new_state = states.get(new_state_name.to_lower()) if !new_state: return + if blocked_states.has(new_state_name.to_lower()): return + #exit the current state if curr_state: curr_state.exit() @@ -70,3 +73,7 @@ func on_state_child_transition(state : State, new_state_name : String) -> void: curr_state = new_state curr_state_name = curr_state.state_name + +func set_state_blocked(state_name : String, blocked : bool) -> void: + if blocked: blocked_states[state_name.to_lower()] = true + else: blocked_states.erase(state_name.to_lower()) diff --git a/game/pokemon/sent_pokemon.gd b/game/pokemon/sent_pokemon.gd index 4d1dc4d..448befe 100644 --- a/game/pokemon/sent_pokemon.gd +++ b/game/pokemon/sent_pokemon.gd @@ -8,12 +8,17 @@ signal recalled const GROUP : StringName = &"sent_pokemon" const MODEL_FACES_PLUS_Z : bool = true #true si tes modeles regardent vers +Z (cas des glTF) : sinon mets false +const HEIGHT_TO_METERS : float = 1.0 #PokeAPI donne la taille en decimetres ; mets 1.0 si ton import l'a deja convertie en metres +const MIN_HEIGHT : float = 0.3 #bornes de la capsule en metres (augmente MAX_HEIGHT pour une echelle fidele aux tres gros pokemon) +const MAX_HEIGHT : float = 8.0 +const DEFAULT_HEIGHT : float = 1.0 #utilise si la taille n'est pas renseignee (0) static var _counter : int = 0 #ordre d'envoi, pour retrouver le plus recent var pokemon : PokemonInstance var model : Node3D var order : int = 0 +var rider : Node3D = null static func send(parent : Node, p : PokemonInstance, at : Vector3, look_at_pos : Vector3 = Vector3.INF) -> SentPokemon: #un pokemon deja sorti est d'abord rappele (il "se deplace" a l'endroit du nouveau lancer) @@ -46,7 +51,7 @@ static func get_latest(tree : SceneTree) -> SentPokemon: #le pokemon sorti le plus recemment var latest : SentPokemon = null for n in tree.get_nodes_in_group(GROUP): - if n is SentPokemon and !n.is_queued_for_deletion() and (latest == null or n.order > latest.order): + if n is SentPokemon and !n.is_queued_for_deletion() and (latest == null or n.order > latest.order) and n.rider == null: latest = n return latest @@ -70,20 +75,25 @@ static func create_model(p : PokemonInstance) -> Node3D: static func _create_placeholder(p : PokemonInstance) -> Node3D: var colors : Array[Color] = TypeColors.for_species(p.species) + #taille issue des donnees de l'espece ; le rayon suit la hauteur pour garder des proportions coherentes + var meters : float = p.species.height * HEIGHT_TO_METERS if p.species != null else 0.0 + var height : float = clampf(meters if meters > 0.0 else DEFAULT_HEIGHT, MIN_HEIGHT, MAX_HEIGHT) + var radius : float = clampf(height * 0.3, 0.12, 1.5) + var body := MeshInstance3D.new() var capsule := CapsuleMesh.new() - capsule.radius = 0.3 - capsule.height = 1.0 + capsule.radius = radius + capsule.height = maxf(height, radius * 2.0) #une capsule ne peut pas etre plus basse que son diametre body.mesh = capsule - body.position.y = 0.5 + body.position.y = capsule.height * 0.5 #les pieds posent sur le sol body.material_override = _make_material(colors[0]) #deuxieme type : anneau autour de la capsule if colors.size() > 1: var ring := MeshInstance3D.new() var torus := TorusMesh.new() - torus.inner_radius = 0.29 - torus.outer_radius = 0.36 + torus.inner_radius = radius * 0.97 + torus.outer_radius = radius * 1.2 ring.mesh = torus ring.material_override = _make_material(colors[1]) body.add_child(ring)