Files

431 lines
15 KiB
GDScript
Raw Permalink Normal View History

2026-09-12 11:01:37 +02:00
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