431 lines
15 KiB
GDScript
431 lines
15 KiB
GDScript
class_name Fighter
|
|||
|
|
extends CharacterBody3D
|
||
|
|
|
||
|
|
signal respawn_requested(fighter: Fighter)
|
||
|
|
signal combat_requested(fighter: Fighter)
|
||
|
|
signal bot_update_requested(fighter: Fighter, delta: float)
|
||
|
|
signal fall_damage_requested(fighter: Fighter)
|
||
|
|
signal footstep_requested(position: Vector3, id: int)
|
||
|
|
|
||
|
|
var movement := PlayerMovement.new()
|
||
|
|
var weapon_controller := PlayerWeaponController.new()
|
||
|
|
var replication_state := FighterReplicationState.new()
|
||
|
|
var game: Node3D
|
||
|
|
var peer_id := 0
|
||
|
|
var nickname := "Operator"
|
||
|
|
var team := 0
|
||
|
|
var bot := false
|
||
|
|
var operator_id := "steve"
|
||
|
|
var visual_grounded := true
|
||
|
|
var visual_elapsed := 0.0
|
||
|
|
var cached_stats: Dictionary = {}
|
||
|
|
var cached_stats_weapon := -1
|
||
|
|
var cached_stats_design := -1
|
||
|
|
var hp := 100.0
|
||
|
|
var shield_left := 0.0
|
||
|
|
var shield_charges := 1
|
||
|
|
var trigger_held := false
|
||
|
|
var life := 0
|
||
|
|
var kills := 0
|
||
|
|
var streak := 0
|
||
|
|
var ult_charge := 0
|
||
|
|
var infinite_left := 0.0
|
||
|
|
var recon_left := 0.0
|
||
|
|
var shot_serial := 0
|
||
|
|
var deaths := 0
|
||
|
|
var yaw := 0.0
|
||
|
|
var pitch := 0.0
|
||
|
|
var primary := 0
|
||
|
|
var pending_class: Dictionary = {}
|
||
|
|
var class_update_time := -10000
|
||
|
|
var weapon := 0
|
||
|
|
var magazines := Arsenal.ammunition()
|
||
|
|
var reserves := Arsenal.ammunition(true)
|
||
|
|
var grenades := 2
|
||
|
|
var cooldown := 0.0
|
||
|
|
var melee_left := 0.0
|
||
|
|
var reload_left := 0.0
|
||
|
|
var respawn_left := 0.0
|
||
|
|
var protection := 0.0
|
||
|
|
var slide_left := 0.0
|
||
|
|
var slide_lock := 0.0
|
||
|
|
var last_hurt := 0.0
|
||
|
|
var crouched := false
|
||
|
|
var aiming := false
|
||
|
|
var aim_blend := 0.0
|
||
|
|
var input_data := {}
|
||
|
|
var input_age := 0.0
|
||
|
|
var sequence := 0
|
||
|
|
var last_sequence := -1
|
||
|
|
var last_action_sequence := -1
|
||
|
|
var queued_actions := {}
|
||
|
|
var target_position := Vector3.ZERO
|
||
|
|
var target_velocity := Vector3.ZERO
|
||
|
|
var body_mesh: OperatorModel
|
||
|
|
var view_controller := FpsCamera.new()
|
||
|
|
var camera: Camera3D
|
||
|
|
var gun: Node3D
|
||
|
|
var world_gun: Node3D
|
||
|
|
var world_weapon := -1
|
||
|
|
var skin_designs: Dictionary = {}
|
||
|
|
var shape: CollisionShape3D
|
||
|
|
var capsule: CapsuleShape3D
|
||
|
|
var step_clock := 0.0
|
||
|
|
var blast_shake := 0.0
|
||
|
|
var bot_route := PackedVector2Array()
|
||
|
|
var bot_path := PackedVector3Array()
|
||
|
|
var bot_grenade_cooldown := 0.0
|
||
|
|
var flash_left := 0.0
|
||
|
|
var flashes := 1
|
||
|
|
var radar_left := 0.0
|
||
|
|
var mantle_left := 0.0
|
||
|
|
var rope_path := PackedVector3Array()
|
||
|
|
var rope_active := false
|
||
|
|
var mantle_start := Vector3.ZERO
|
||
|
|
var mantle_target := Vector3.ZERO
|
||
|
|
var bot_think := 0.0
|
||
|
|
var bot_target := Vector3.ZERO
|
||
|
|
var bot_phase := 0
|
||
|
|
var bot_enemy := 0
|
||
|
|
var bot_acquired := 0.0
|
||
|
|
var killer := ""
|
||
|
|
var killer_weapon := ""
|
||
|
|
var killer_id := 0
|
||
|
|
|
||
|
|
func setup(owner_game: Node3D, id: int, display_name: String, team_index: int, ai: bool, loadout: int, appearance: String = "steve") -> void:
|
||
|
|
add_child(movement)
|
||
|
|
add_child(weapon_controller)
|
||
|
|
add_child(replication_state)
|
||
|
|
game = owner_game
|
||
|
|
peer_id = id
|
||
|
|
nickname = display_name
|
||
|
|
team = team_index
|
||
|
|
bot = ai
|
||
|
|
operator_id = "gas_mask" if appearance == "gas_mask" else "steve"
|
||
|
|
primary = Arsenal.primary_id(loadout)
|
||
|
|
weapon = primary
|
||
|
|
if not bot and peer_id == game.local_id:
|
||
|
|
skin_designs = WeaponSkins.loadout_designs(primary)
|
||
|
|
name = "Fighter_%s" % id
|
||
|
|
collision_layer = 2
|
||
|
|
collision_mask = 3
|
||
|
|
floor_snap_length = 0.3
|
||
|
|
floor_max_angle = deg_to_rad(48)
|
||
|
|
shape = CollisionShape3D.new()
|
||
|
|
capsule = CapsuleShape3D.new()
|
||
|
|
capsule.radius = 0.36
|
||
|
|
capsule.height = 1.8
|
||
|
|
shape.shape = capsule
|
||
|
|
shape.position.y = 0.9
|
||
|
|
add_child(shape)
|
||
|
|
body_mesh = OperatorModel.new()
|
||
|
|
add_child(body_mesh)
|
||
|
|
body_mesh.setup(team, operator_id, not game.headless)
|
||
|
|
world_gun = body_mesh.weapon_mount
|
||
|
|
camera = Camera3D.new()
|
||
|
|
camera.position.y = 1.65
|
||
|
|
camera.near = 0.04
|
||
|
|
camera.fov = 88
|
||
|
|
add_child(camera)
|
||
|
|
gun = WeaponView.new()
|
||
|
|
camera.add_child(gun)
|
||
|
|
gun.position = Vector3(0.18, -0.22, -0.43)
|
||
|
|
gun.visible = false
|
||
|
|
if not game.headless:
|
||
|
|
var shield := EnergyShield.new()
|
||
|
|
shield.fighter = self
|
||
|
|
add_child(shield)
|
||
|
|
|
||
|
|
func is_local() -> bool:
|
||
|
|
return not bot and peer_id == game.local_id
|
||
|
|
|
||
|
|
func eye() -> Vector3:
|
||
|
|
return global_position + Vector3.UP * (1.0 if crouched else 1.65)
|
||
|
|
|
||
|
|
func _process(delta: float) -> void:
|
||
|
|
if not game.active:
|
||
|
|
return
|
||
|
|
if not game.is_host:
|
||
|
|
cooldown = maxf(0, cooldown - delta)
|
||
|
|
if not is_local():
|
||
|
|
global_position = global_position.lerp(target_position, minf(delta * 18, 1))
|
||
|
|
body_mesh.rotation.y = yaw
|
||
|
|
if body_mesh.imported == null: world_gun.rotation.x = pitch
|
||
|
|
body_mesh.visible = not is_local() and not game.history.playing and (hp > 0 or body_mesh.imported != null)
|
||
|
|
body_mesh.scale.y = 0.67 if crouched and body_mesh.imported == null else 1.0
|
||
|
|
if not game.headless and body_mesh.visible:
|
||
|
|
visual_elapsed += delta
|
||
|
|
var viewer := get_viewport().get_camera_3d()
|
||
|
|
var distance := global_position.distance_to(viewer.global_position) if viewer != null else 0.0
|
||
|
|
var interval := 1.0 / 15.0 if distance > 45 else (1.0 / 30.0 if distance > 20 else 0.0)
|
||
|
|
if visual_elapsed >= interval:
|
||
|
|
body_mesh.animate_state(visual_elapsed, animation_state())
|
||
|
|
visual_elapsed = 0.0
|
||
|
|
if not game.headless and not is_local() and world_weapon != weapon:
|
||
|
|
for child in world_gun.get_children():
|
||
|
|
world_gun.remove_child(child)
|
||
|
|
child.queue_free()
|
||
|
|
var model := WeaponModels.build(weapon)
|
||
|
|
WeaponSkins.apply(model, skin_designs.get(str(weapon), {}))
|
||
|
|
world_gun.add_child(model)
|
||
|
|
world_weapon = weapon
|
||
|
|
if is_local():
|
||
|
|
gun.visible = hp > 0
|
||
|
|
if game.update_death_camera(self, delta):
|
||
|
|
return
|
||
|
|
camera.make_current()
|
||
|
|
view_controller.render(self, delta)
|
||
|
|
var sight_ready := aiming and reload_left <= 0 and melee_left <= 0 and mantle_left <= 0 and not rope_active
|
||
|
|
aim_blend = move_toward(aim_blend, 1.0 if sight_ready else 0.0, delta / float(weapon_stats().ads))
|
||
|
|
var sight_mix := WeaponHandling.smooth(aim_blend)
|
||
|
|
var scope := 32.0 if weapon == 3 and not ReflexAlignment.equipped(self) else 64.0
|
||
|
|
camera.fov = lerpf(game.base_fov, scope, sight_mix)
|
||
|
|
gun.visible = hp > 0
|
||
|
|
var bob := 0.0 # WeaponView owns movement so camera and weapon bob are not stacked.
|
||
|
|
var sight_height: float = [0.11, 0.11, 0.06, 0.108, 0.068][Arsenal.FAMILIES[weapon]]
|
||
|
|
var hip := Vector3(0.17, -0.18 + bob, -0.22) if weapon != 4 else Vector3(0.18, -0.22 + bob, -0.43)
|
||
|
|
var gun_target := hip.lerp(Vector3(0, -sight_height, -0.20 if weapon != 4 else -0.36), sight_mix)
|
||
|
|
if reload_left > 0:
|
||
|
|
gun_target.y -= 0.10 * WeaponHandling.reload_pose(1 - reload_left / float(weapon_stats().reload))
|
||
|
|
gun.position = gun.position.lerp(gun_target, 1.0 - exp(-delta * 16))
|
||
|
|
if not game.headless:
|
||
|
|
gun.animate(self, delta)
|
||
|
|
|
||
|
|
func _physics_process(delta: float) -> void:
|
||
|
|
view_controller.begin_physics(self)
|
||
|
|
if game.active and not game.is_host and is_local():
|
||
|
|
var error := target_position - global_position
|
||
|
|
if error.length() > 3:
|
||
|
|
global_position = target_position
|
||
|
|
view_controller.previous_position = target_position
|
||
|
|
elif error.length() > 0.08:
|
||
|
|
global_position += error * minf(delta * 8, 0.4)
|
||
|
|
if not game.active or game.match_over:
|
||
|
|
return
|
||
|
|
if game.is_host:
|
||
|
|
bot_grenade_cooldown = maxf(0, bot_grenade_cooldown - delta)
|
||
|
|
flash_left = maxf(0, flash_left - delta)
|
||
|
|
shield_left = maxf(0, shield_left - delta)
|
||
|
|
infinite_left = maxf(0, infinite_left - delta)
|
||
|
|
recon_left = maxf(0, recon_left - delta)
|
||
|
|
radar_left = maxf(0, radar_left - delta)
|
||
|
|
if bot:
|
||
|
|
bot_update_requested.emit(self, delta)
|
||
|
|
input_age += delta
|
||
|
|
if not bot and input_age > 0.3:
|
||
|
|
input_data = {}
|
||
|
|
if hp <= 0:
|
||
|
|
queued_actions.clear()
|
||
|
|
respawn_left -= delta
|
||
|
|
if respawn_left <= 0:
|
||
|
|
respawn_requested.emit(self)
|
||
|
|
return
|
||
|
|
protection = maxf(0, protection - delta)
|
||
|
|
|
||
|
|
last_hurt += delta
|
||
|
|
if last_hurt > 5:
|
||
|
|
hp = minf(100, hp + delta * 18)
|
||
|
|
var weapon_state := weapon_controller.tick(cooldown, melee_left, reload_left, magazines[weapon], reserves[weapon], int(weapon_stats().mag), delta)
|
||
|
|
cooldown = weapon_state.cooldown
|
||
|
|
melee_left = weapon_state.melee
|
||
|
|
reload_left = weapon_state.reload
|
||
|
|
magazines[weapon] = weapon_state.magazine
|
||
|
|
reserves[weapon] = weapon_state.reserve
|
||
|
|
input_data.merge(queued_actions, true)
|
||
|
|
queued_actions.clear()
|
||
|
|
if input_data.get("interact", false): RopeLift.start(self)
|
||
|
|
input_data["interact"] = false
|
||
|
|
move_character(delta)
|
||
|
|
combat_requested.emit(self)
|
||
|
|
elif is_local() and hp > 0:
|
||
|
|
move_character(delta)
|
||
|
|
|
||
|
|
func move_character(delta: float) -> void:
|
||
|
|
if rope_active:
|
||
|
|
if game.is_host: RopeLift.advance(self, delta)
|
||
|
|
return
|
||
|
|
if mantle_left > 0:
|
||
|
|
mantle_left = maxf(0, mantle_left - delta)
|
||
|
|
var t := 1 - mantle_left / 0.35
|
||
|
|
# Follow the same up-then-forward sweep that Mantle.destination validated.
|
||
|
|
# A diagonal interpolation collided with the ledge before reaching its top.
|
||
|
|
var next := mantle_start.lerp(mantle_target, maxf(0, (t - 0.5) * 2))
|
||
|
|
next.y = lerpf(mantle_start.y, mantle_target.y, minf(t * 2, 1))
|
||
|
|
if test_move(global_transform, next - global_position):
|
||
|
|
mantle_left = 0
|
||
|
|
else:
|
||
|
|
global_position = next
|
||
|
|
velocity = Vector3.ZERO
|
||
|
|
return
|
||
|
|
if input_data.get("jump", false) and is_on_floor():
|
||
|
|
var ledge := Mantle.destination(self)
|
||
|
|
if ledge.is_finite():
|
||
|
|
mantle_start = global_position
|
||
|
|
mantle_target = ledge
|
||
|
|
mantle_left = 0.35
|
||
|
|
input_data["jump"] = false
|
||
|
|
return
|
||
|
|
aiming = bool(input_data.get("ads", false)) and melee_left <= 0
|
||
|
|
var axis: Vector2 = input_data.get("move", Vector2.ZERO)
|
||
|
|
axis = axis.limit_length()
|
||
|
|
var sprint: bool = input_data.get("sprint", false) and not aiming
|
||
|
|
var duck: bool = input_data.get("crouch", false)
|
||
|
|
slide_left = maxf(0, slide_left - delta)
|
||
|
|
slide_lock = maxf(0, slide_lock - delta)
|
||
|
|
if duck and sprint and is_on_floor() and velocity.length() > 5 and slide_lock <= 0:
|
||
|
|
slide_left = 0.65
|
||
|
|
slide_lock = 1.4
|
||
|
|
var want_crouch := duck or slide_left > 0
|
||
|
|
if crouched and not want_crouch:
|
||
|
|
var q := PhysicsRayQueryParameters3D.create(global_position + Vector3.UP * 0.9, global_position + Vector3.UP * 1.85, 1)
|
||
|
|
want_crouch = not get_world_3d().direct_space_state.intersect_ray(q).is_empty()
|
||
|
|
crouched = want_crouch
|
||
|
|
capsule.height = 1.15 if crouched else 1.8
|
||
|
|
shape.position.y = capsule.height * 0.5
|
||
|
|
velocity = movement.calculate(velocity, axis, yaw, sprint, crouched, aiming, slide_left > 0, is_on_floor(), input_data.get("jump", false), float(weapon_stats().speed), delta)
|
||
|
|
input_data["jump"] = false
|
||
|
|
# Engine collision sweeps implement a small stair step, never teleport through walls.
|
||
|
|
var horizontal := Vector3(velocity.x, 0, velocity.z) * delta
|
||
|
|
if is_on_floor() and horizontal.length() > 0.001 and test_move(global_transform, horizontal):
|
||
|
|
var raised := global_transform
|
||
|
|
raised.origin.y += 0.24
|
||
|
|
if not test_move(global_transform, Vector3.UP * 0.24) and not test_move(raised, horizontal):
|
||
|
|
global_position.y += 0.24
|
||
|
|
move_and_slide()
|
||
|
|
if global_position.y < -6 and game.is_host:
|
||
|
|
fall_damage_requested.emit(self)
|
||
|
|
step_clock -= delta
|
||
|
|
if game.is_host and is_on_floor() and velocity.length() > 2 and step_clock <= 0:
|
||
|
|
step_clock = 0.28 if sprint else 0.43
|
||
|
|
footstep_requested.emit(global_position, peer_id)
|
||
|
|
|
||
|
|
func reset_at(pos: Vector3) -> void:
|
||
|
|
life += 1
|
||
|
|
rope_path.clear()
|
||
|
|
rope_active = false
|
||
|
|
mantle_left = 0
|
||
|
|
killer_id = 0
|
||
|
|
killer = ""
|
||
|
|
killer_weapon = ""
|
||
|
|
respawn_left = 0
|
||
|
|
blast_shake = 0
|
||
|
|
aim_blend = 0
|
||
|
|
global_position = pos
|
||
|
|
view_controller.previous_position = pos
|
||
|
|
target_position = pos
|
||
|
|
velocity = Vector3.ZERO
|
||
|
|
hp = 100
|
||
|
|
protection = 1.8
|
||
|
|
reload_left = 0
|
||
|
|
cooldown = 0
|
||
|
|
melee_left = 0
|
||
|
|
grenades = 2
|
||
|
|
flashes = 1
|
||
|
|
shield_left = 0
|
||
|
|
shield_charges = 1
|
||
|
|
streak = 0
|
||
|
|
infinite_left = 0
|
||
|
|
recon_left = 0
|
||
|
|
trigger_held = false
|
||
|
|
flash_left = 0
|
||
|
|
radar_left = 0
|
||
|
|
bot_grenade_cooldown = 4
|
||
|
|
last_hurt = 0
|
||
|
|
weapon = primary
|
||
|
|
magazines = Arsenal.ammunition()
|
||
|
|
for index in [primary, 4]: magazines[index] = WeaponAttachments.stats(index, skin_designs.get(str(index), {})).mag
|
||
|
|
reserves = Arsenal.ammunition(true)
|
||
|
|
input_data = {}
|
||
|
|
queued_actions.clear()
|
||
|
|
bot_route.clear()
|
||
|
|
bot_path.clear()
|
||
|
|
bot_enemy = 0
|
||
|
|
bot_acquired = 0
|
||
|
|
yaw = 0 if pos.z > 0 else PI
|
||
|
|
pitch = 0
|
||
|
|
# Queue after any pending death disable, including an immediate round reset.
|
||
|
|
shape.set_deferred("disabled", false)
|
||
|
|
|
||
|
|
func animation_state() -> Dictionary:
|
||
|
|
var visual_velocity := velocity if game.is_host else target_velocity
|
||
|
|
return {"id": peer_id, "life": life, "hp": hp, "shot": shot_serial, "weapon": weapon,
|
||
|
|
"velocity": Basis(Vector3.UP, yaw).inverse() * visual_velocity,
|
||
|
|
"yaw": yaw, "pitch": pitch, "crouch": crouched, "aim": aiming,
|
||
|
|
"grounded": is_on_floor() if game.is_host else visual_grounded,
|
||
|
|
"slide": slide_left, "reload": reload_left, "reload_duration": float(weapon_stats().reload),
|
||
|
|
"grenades": grenades, "flashes": flashes}
|
||
|
|
|
||
|
|
func snapshot() -> Dictionary:
|
||
|
|
replication_state.capture(self)
|
||
|
|
return {"id": peer_id, "name": nickname, "team": team, "bot": bot, "primary": primary, "operator": operator_id,
|
||
|
|
"ads": aiming, "slide": slide_left, "grounded": is_on_floor(),
|
||
|
|
"p": global_position, "v": velocity, "yaw": yaw, "pitch": pitch, "hp": hp,
|
||
|
|
"kills": kills, "deaths": deaths, "weapon": weapon, "mag": magazines, "reserve": reserves,
|
||
|
|
"rewards": [streak, ult_charge, snappedf(infinite_left, 0.1), snappedf(recon_left, 0.1), shot_serial], "skins": skin_designs, "shield": shield_left, "shield_charges": shield_charges, "rope": rope_active, "cooldown": cooldown, "melee": melee_left, "reload": reload_left, "respawn": respawn_left, "guard": protection, "duck": crouched,
|
||
|
|
"grenades": grenades, "killer": killer, "killer_weapon": killer_weapon, "killer_id": killer_id,
|
||
|
|
"flash": flash_left, "flashes": flashes, "radar": radar_left, "mantle": mantle_left, "life": life}
|
||
|
|
|
||
|
|
func apply_snapshot(s: Dictionary) -> void:
|
||
|
|
primary = Arsenal.primary_id(int(s.primary))
|
||
|
|
var incoming := WeaponSkins.clean_loadout(s.get("skins", {}), primary)
|
||
|
|
if incoming != skin_designs:
|
||
|
|
skin_designs = incoming
|
||
|
|
world_weapon = -1
|
||
|
|
var new_life := life != int(s.get("life", life))
|
||
|
|
life = int(s.get("life", life))
|
||
|
|
var rewards: Array = s.get("rewards", [0, 0, 0.0, 0.0, 0])
|
||
|
|
streak = rewards[0]
|
||
|
|
ult_charge = rewards[1]
|
||
|
|
infinite_left = rewards[2]
|
||
|
|
recon_left = rewards[3]
|
||
|
|
shot_serial = rewards[4]
|
||
|
|
var was_dead := hp <= 0
|
||
|
|
target_position = s.p
|
||
|
|
target_velocity = s.v
|
||
|
|
hp = s.hp
|
||
|
|
kills = s.kills
|
||
|
|
deaths = s.deaths
|
||
|
|
if weapon != int(s.weapon): aim_blend = 0
|
||
|
|
weapon = s.weapon
|
||
|
|
magazines = s.mag
|
||
|
|
reserves = s.reserve
|
||
|
|
rope_active = bool(s.get("rope", false))
|
||
|
|
cooldown = float(s.get("cooldown", 0))
|
||
|
|
melee_left = float(s.get("melee", 0))
|
||
|
|
reload_left = s.reload
|
||
|
|
respawn_left = s.respawn
|
||
|
|
protection = s.guard
|
||
|
|
crouched = s.duck
|
||
|
|
visual_grounded = bool(s.get("grounded", true))
|
||
|
|
if not is_local():
|
||
|
|
aiming = bool(s.get("ads", false))
|
||
|
|
slide_left = float(s.get("slide", 0))
|
||
|
|
capsule.height = 1.15 if crouched else 1.8
|
||
|
|
shape.position.y = capsule.height * 0.5
|
||
|
|
grenades = s.grenades
|
||
|
|
killer = s.killer
|
||
|
|
killer_weapon = s.killer_weapon
|
||
|
|
killer_id = s.get("killer_id", 0)
|
||
|
|
flash_left = float(s.get("flash", 0))
|
||
|
|
flashes = int(s.get("flashes", 1))
|
||
|
|
shield_left = float(s.get("shield", 0))
|
||
|
|
shield_charges = int(s.get("shield_charges", 1))
|
||
|
|
radar_left = float(s.get("radar", 0))
|
||
|
|
if not is_local() or was_dead:
|
||
|
|
yaw = s.yaw
|
||
|
|
pitch = s.pitch
|
||
|
|
if (was_dead or new_life) and hp > 0:
|
||
|
|
global_position = s.p
|
||
|
|
velocity = Vector3.ZERO
|
||
|
|
mantle_left = 0
|
||
|
|
shape.disabled = hp <= 0
|
||
|
|
|
||
|
|
func weapon_stats() -> Dictionary:
|
||
|
|
var design: Dictionary = skin_designs.get(str(weapon), {})
|
||
|
|
var signature := design.hash()
|
||
|
|
if cached_stats_weapon != weapon or cached_stats_design != signature:
|
||
|
|
cached_stats = WeaponAttachments.stats(weapon, design)
|
||
|
|
cached_stats_weapon = weapon
|
||
|
|
cached_stats_design = signature
|
||
|
|
return cached_stats
|