mirror of
https://codeberg.org/sunder/sunder.git
synced 2026-07-16 06:24:36 +00:00
351 lines
13 KiB
GDScript
351 lines
13 KiB
GDScript
# This file is part of sunder.
|
|
#
|
|
# This program is free software: you can redistribute it and/or modify
|
|
# it under the terms of the GNU Affero General Public License as
|
|
# published by the Free Software Foundation, either version 3 of the
|
|
# License, or (at your option) any later version.
|
|
#
|
|
# This program is distributed in the hope that it will be useful,
|
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
# GNU Affero General Public License for more details.
|
|
#
|
|
# You should have received a copy of the GNU Affero General Public License
|
|
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
class_name Player extends RigidBody3D
|
|
|
|
signal killed(victim: Player, killer:int)
|
|
## Emitted when a source wants to damage this body
|
|
signal damage(source: Node, target: Node, amount: int)
|
|
## Emitted when the player respawns, see [member Player.respawn].
|
|
signal respawned(player: Player)
|
|
|
|
## depends on hardcoded path but allows to instantiate players from anywhere in the code without the
|
|
const scene:PackedScene = preload("res://scenes/entities/player/player.tscn")
|
|
static func instantiate() -> Player:
|
|
return scene.instantiate()
|
|
|
|
#@export var profile:Profile
|
|
|
|
@export var peer_id:int = 1
|
|
@export var username:String = "Newblood"
|
|
|
|
@export var team_id:int = -1
|
|
@export var input:InputSynchronizer
|
|
|
|
## Which side the player is holding equipped weapon (right-handed by default)
|
|
@export_enum("Left", "Right") var dominant_laterality:int = 1
|
|
|
|
@export_group("Motion")
|
|
## The ground speed of the player
|
|
@export_range(0, 12, .01, "suffix:m/s") var ground_speed:float = 32 / 3.6 # m/s
|
|
## The aerial acceleration is an horizontal acceleration component which a body can use to
|
|
## slightly adjust their trajectory while not [member InputSynchronizer.jetting] but airborne
|
|
@export_range(0, 12, 1., "suffix:m/s²") var aerial_control_acceleration:int = 4
|
|
|
|
@export_range(0., 90, 1., "suffix:°") var max_floor_angle:float = 60
|
|
@export var jump_height:float = 1.0
|
|
|
|
@onready var spring_arm:SpringArm3D = $Pivot/SpringArm3D
|
|
@onready var tp_head_remote_transform:RemoteTransform3D = $Pivot/Mesh/Node/Skeleton3D/Head/RT_Head
|
|
|
|
@export_category("Components")
|
|
## The is player [IFF] (Indicator Friend or Foe) component.
|
|
@export var iff:IFF
|
|
## This is the player [Health] component.
|
|
@export var health:Health
|
|
## This is the player [Energy] component.
|
|
@export var energy:Energy
|
|
## This is the player [FlagHolder] component.
|
|
@export var flag_holder:FlagHolder
|
|
## This is the player [Inventory] component.
|
|
@export var inventory:Inventory
|
|
## This is the player [HUD] component.
|
|
@export var hud:HUD
|
|
## This is the player [Jetpack] component.
|
|
@export var jetpack:Jetpack
|
|
## This is the visual representation of the [Player]
|
|
@export var mesh:Node3D
|
|
|
|
@export var ground_sensor:ShapeCast3D
|
|
@export var pivot:Node3D
|
|
@export var camera:Camera3D
|
|
@onready var collider:CollisionShape3D = $CollisionShape3D
|
|
|
|
var gravity:float = ProjectSettings.get_setting("physics/3d/default_gravity") # in m/s²
|
|
var _jumping:bool = false
|
|
|
|
## The [Queue] to store local prediction states
|
|
var local_predictions:Queue
|
|
|
|
## The [Queue] to store authoritative control states
|
|
var control_states:Queue
|
|
|
|
@rpc("any_peer", "call_local", "reliable")
|
|
func req_loadout(idx:int = 0) -> void:
|
|
inventory.clear()
|
|
if is_multiplayer_authority():
|
|
# spawn new weapons
|
|
pass
|
|
|
|
func _ready() -> void:
|
|
input.set_multiplayer_authority(peer_id)
|
|
iff.username = username
|
|
|
|
local_predictions = Queue.new()
|
|
control_states = Queue.new()
|
|
|
|
input.jump.connect(_jump)
|
|
input.throw.connect(flag_holder.throw)
|
|
|
|
energy.changed.connect(hud.energy_bar.set_value)
|
|
health.changed.connect(hud.health_bar.set_value)
|
|
health.changed.connect(iff.health_bar.set_value)
|
|
health.killed.connect(_on_killed)
|
|
|
|
if is_pawn():
|
|
camera.current = true
|
|
camera.fov = Settings.get_value("video", "fov")
|
|
iff.hide()
|
|
mesh.hide()
|
|
else:
|
|
hud.hide()
|
|
|
|
func _process(_delta:float) -> void:
|
|
if not is_alive():
|
|
return
|
|
|
|
if is_pawn():
|
|
match camera.state:
|
|
camera.CameraState.THIRD_PERSON:
|
|
_animate_third_person()
|
|
camera.CameraState.FIRST_PERSON:
|
|
pass
|
|
_input_rotations()
|
|
else:
|
|
_animate_third_person()
|
|
|
|
func _input_rotations() -> void:
|
|
spring_arm.rotation.x = lerp_angle(spring_arm.rotation.x, input.rotation.x, .5)
|
|
pivot.rotation.y = lerp_angle(pivot.rotation.y, input.rotation.y, .5)
|
|
|
|
func _physics_process(_delta:float) -> void:
|
|
if not is_alive():
|
|
return
|
|
|
|
func apply_inputs(sample:Variant, delta:float) -> void:
|
|
if not sample: return
|
|
var local_direction:Vector3 = pivot.global_transform.basis * sample.direction
|
|
if sample.jetting and energy.value > 0 and not jetpack.stuttering:
|
|
apply_force(mass * (Vector3.UP * (gravity + jetpack.vertical_acceleration) + local_direction * jetpack.horizontal_acceleration))
|
|
|
|
physics_material_override.friction = !sample.skiing
|
|
linear_damp_mode = DAMP_MODE_COMBINE
|
|
|
|
if is_on_floor():
|
|
# retrieve collision normal
|
|
var normal:Vector3 = ground_sensor.get_collision_normal(0)
|
|
if not sample.direction.is_zero_approx():
|
|
if sample.skiing:
|
|
# zero damping ski
|
|
linear_damp_mode = DAMP_MODE_REPLACE
|
|
# naive carving
|
|
linear_velocity = linear_velocity.lerp(linear_velocity.rotated(normal, Input.get_axis("right", "left") * PI/32), .1)
|
|
else:
|
|
# calculate the angle between the ground normal and the up vector
|
|
var slope_angle:float = rad_to_deg(acos(normal.dot(Vector3.UP)))
|
|
# check if the slope angle exceeds the maximum slope angle
|
|
if slope_angle <= max_floor_angle:
|
|
# retrieve normalized projection of local direction on tangent plane to normal
|
|
var tangential_direction := (local_direction - normal * local_direction.dot(normal)).normalized()
|
|
var target_velocity := (tangential_direction * ground_speed) - linear_velocity
|
|
# acceleration limiter
|
|
var accel_time := .1
|
|
var max_change := (ground_speed / accel_time) * delta
|
|
if target_velocity.length() > max_change:
|
|
target_velocity = target_velocity.normalized() * max_change
|
|
apply_central_force(mass * target_velocity / delta)
|
|
|
|
if _jumping:
|
|
apply_central_impulse(basis.y * mass * sqrt(2 * gravity * jump_height))
|
|
_jumping = false
|
|
|
|
else:
|
|
# handle aerial control
|
|
if not sample.jetting:
|
|
apply_force(mass * aerial_control_acceleration * local_direction)
|
|
|
|
## This rpc can be called remotly by the authority to send specified [member control_state] unreliably,
|
|
## which simply adds it to [member control_states]
|
|
@rpc("authority", "call_remote", "reliable")
|
|
func new_state(control_state:Dictionary) -> void:
|
|
if not control_states.is_empty():
|
|
var back:Dictionary = control_states.back()
|
|
# drop late control states
|
|
if control_state.id < back.id:
|
|
return
|
|
control_states.enqueue(control_state)
|
|
|
|
func replay_inputs(from:int, delta:float) -> void:
|
|
# reset local_predictions since this node needs to resync, past predictions are wrong
|
|
local_predictions = Queue.new()
|
|
# reapply unacknowledged inputs if any
|
|
if not input.history.is_empty():
|
|
for i in range(input.history.size()):
|
|
var sample:Dictionary = input.history.dequeue()
|
|
if sample.id > from:
|
|
input.history.enqueue(sample)
|
|
apply_inputs(sample, delta)
|
|
var state := PhysicsServer3D.body_get_direct_state(get_rid())
|
|
state.integrate_forces()
|
|
# regenerate predictions
|
|
local_predictions.enqueue(get_state(sample.id))
|
|
|
|
func reconcile(prediction:Dictionary, control:Dictionary, delta:float) -> void:
|
|
var sigma:float = .05
|
|
var resync := [
|
|
(prediction.state[0] - control.state[0]).length() > sigma,
|
|
(prediction.state[1] - control.state[1]).length() > sigma
|
|
]
|
|
if resync[0]:
|
|
global_position = control.state[0]
|
|
if resync[1]:
|
|
apply_force(mass * (control.state[1] - linear_velocity) / get_physics_process_delta_time())
|
|
|
|
if false:
|
|
spring_arm.global_rotation.x = control.state[2].x
|
|
pivot.global_rotation.y = control.state[2].y # same as spring arm
|
|
|
|
if resync.max():
|
|
replay_inputs(control.id, delta)
|
|
|
|
func get_state(id:int) -> Dictionary:
|
|
return {
|
|
"id": id,
|
|
"state": [ global_position, linear_velocity, global_rotation ]
|
|
}
|
|
|
|
var last_sent_state := -1
|
|
var last_control := 0
|
|
|
|
func _integrate_forces(state:PhysicsDirectBodyState3D) -> void:
|
|
if not is_alive():
|
|
return
|
|
if is_pawn():
|
|
# local prediction for pawns
|
|
var sample:Dictionary = input.sample()
|
|
if multiplayer.is_server():
|
|
apply_inputs(sample, state.step)
|
|
else:
|
|
input.history.enqueue(sample)
|
|
apply_inputs(sample, state.step)
|
|
local_predictions.enqueue(get_state(sample.id))
|
|
if not control_states.is_empty():
|
|
var prediction:Dictionary = local_predictions.front()
|
|
var control:Dictionary = control_states.dequeue()
|
|
prints("%s:" % multiplayer.get_unique_id(), "A", prediction.id, control.id)
|
|
while prediction.id != control.id:
|
|
if local_predictions.is_empty():
|
|
break
|
|
prediction = local_predictions.dequeue()
|
|
if prediction.id == control.id:
|
|
prints("%s:" % multiplayer.get_unique_id(), "B", prediction.id, control.id)
|
|
reconcile(prediction, control, state.step)
|
|
else:
|
|
prints("%s:" % multiplayer.get_unique_id(), "B no prediction / control match, wait for next control state")
|
|
else:
|
|
if multiplayer.is_server():
|
|
var sample:Dictionary = input.sample()
|
|
# make sure synchronized inputs are processed once
|
|
if sample.id != last_sent_state:
|
|
last_sent_state = sample.id
|
|
apply_inputs(sample, state.step)
|
|
spring_arm.rotation.x = input.rotation.x
|
|
pivot.rotation.y = input.rotation.y
|
|
new_state.rpc(get_state(sample.id))
|
|
|
|
func apply_state(control:Dictionary, do_rotate:bool = false) -> void:
|
|
global_position = control.state[0]
|
|
# apply central force to reach control state velocity
|
|
apply_force(mass * (control.state[1] - linear_velocity) / get_physics_process_delta_time())
|
|
if do_rotate:
|
|
spring_arm.global_rotation.x = control.state[2].x
|
|
pivot.global_rotation.y = control.state[2].y # same as spring arm
|
|
|
|
func _unhandled_input(event: InputEvent) -> void:
|
|
if is_pawn() and event.is_action("pov") and event.pressed and not event.echo:
|
|
if camera.state == camera.CameraState.FIRST_PERSON:
|
|
tp_head_remote_transform.update_position = false
|
|
# @TODO: check if we can alt + mouse wheel to control the length of the spring arm
|
|
spring_arm.spring_length = 3.
|
|
mesh.show()
|
|
camera.state = camera.CameraState.THIRD_PERSON
|
|
else:
|
|
spring_arm.spring_length = 0.
|
|
tp_head_remote_transform.update_position = true
|
|
mesh.animation_tree.set("parameters/locomotion/blend_position", Vector2.ZERO)
|
|
mesh.animation_tree.set("parameters/mix/blend_amount", .0)
|
|
mesh.hide()
|
|
camera.state = camera.CameraState.FIRST_PERSON
|
|
|
|
func _animate_third_person() -> void:
|
|
if not is_alive():
|
|
mesh.animation_tree.set("parameters/transition/transition_request", "dead")
|
|
return
|
|
|
|
if is_on_floor():
|
|
mesh.animation_tree.set("parameters/transition/transition_request", "grounded")
|
|
if input.skiing:
|
|
mesh.animation_tree.set("parameters/locomotion/blend_position", Vector2.ZERO)
|
|
mesh.animation_tree.set("parameters/mix/blend_amount", .0)
|
|
else:
|
|
# update animation direction
|
|
var blend_pos:Vector2 = mesh.animation_tree.get("parameters/locomotion/blend_position")
|
|
var new_blend_pos:Vector3 = (mesh.global_basis.inverse() * linear_velocity).normalized()
|
|
mesh.animation_tree.set("parameters/locomotion/blend_position", blend_pos.lerp(Vector2(new_blend_pos.x, new_blend_pos.z), .5))
|
|
# update animation intensity
|
|
var blend:float = mesh.animation_tree["parameters/mix/blend_amount"]
|
|
mesh.animation_tree.set("parameters/mix/blend_amount", lerp(blend, input.direction.length(), .5))
|
|
else:
|
|
mesh.animation_tree.set("parameters/transition/transition_request", "mid_air")
|
|
|
|
## This method returns wether the local system multiplayer peer has input authority over this node
|
|
func is_pawn() -> bool:
|
|
return multiplayer.get_unique_id() == peer_id
|
|
|
|
func _jump() -> void:
|
|
_jumping = is_alive()
|
|
|
|
func is_on_floor() -> bool:
|
|
return ground_sensor.is_colliding()
|
|
|
|
func is_alive() -> bool:
|
|
return health.state == health.HealthState.ALIVE
|
|
|
|
## Whether the player [FlagHolder] holds a flag or not
|
|
func has_flag() -> bool:
|
|
return flag_holder.flag != null
|
|
|
|
func _on_killed(by_peer_id:int) -> void:
|
|
flag_holder.drop.rpc_id(1)
|
|
if is_pawn():
|
|
mesh.animation_player.play("death")
|
|
tp_head_remote_transform.update_rotation = true
|
|
tp_head_remote_transform.update_position = true
|
|
killed.emit(self, by_peer_id)
|
|
|
|
@rpc("authority", "call_local", "reliable")
|
|
func respawn(location: Vector3) -> void:
|
|
mesh.animation_player.stop()
|
|
linear_velocity = Vector3.ZERO
|
|
health.heal()
|
|
global_position = location
|
|
#for weapon: Weapon in inventory.weapons.get_children():
|
|
#weapon.set_ammo.rpc(weapon.max_ammo)
|
|
respawned.emit(self)
|
|
|
|
func _on_animation_player_animation_finished(animation_name: StringName) -> void:
|
|
if animation_name == "death":
|
|
tp_head_remote_transform.update_rotation = false
|
|
tp_head_remote_transform.update_position = false
|
|
camera.rotation = Vector3.ZERO
|