extends CharacterBody3D const GRAVITY: float = 18.0 const MOUSE_SENS: float = 0.0035 @export var stats_resource: PlayerStats @onready var pivot: Node3D = $Pivot @onready var pitch_pivot: Node3D = $Pivot/PitchPivot @onready var camera: Camera3D = $Pivot/PitchPivot/Camera @onready var mesh_holder: Node3D = $MeshHolder @onready var collision: CollisionShape3D = $Collision @onready var sync: MultiplayerSynchronizer = $Synchronizer @onready var name_label: Label3D = $NameLabel var peer_id: int = 1 var role: int = GameState.ROLE_DAMAGE var look_dragging: bool = false var current_target: Node = null var dead: bool = false var ui_capturing: bool = false var build_mode: bool = false @export var sync_position: Vector3 = Vector3.ZERO @export var sync_velocity: Vector3 = Vector3.ZERO @export var sync_yaw: float = 0.0 @export var sync_role: int = GameState.ROLE_DAMAGE func _enter_tree() -> void: peer_id = name.to_int() set_multiplayer_authority(peer_id) func _ready() -> void: add_to_group("player") if stats_resource == null: stats_resource = PlayerStats.new() Stats.register(self, stats_resource) Stats.set_stat(self, "role", role) name_label.text = Net.player_names.get(peer_id, "P%d" % peer_id) EventBus.entity_died.connect(_on_entity_died_clear_target) if is_multiplayer_authority(): camera.current = true Input.mouse_mode = Input.MOUSE_MODE_VISIBLE else: camera.current = false _apply_role_visual(role) func _on_entity_died_clear_target(entity: Node) -> void: if entity == current_target: current_target = null func _set_target(t: Node) -> void: current_target = t EventBus.target_changed.emit(self, t) if multiplayer.multiplayer_peer != null and not (multiplayer.multiplayer_peer is OfflineMultiplayerPeer) and not multiplayer.is_server(): _request_target.rpc_id(1, String(t.get_path()) if t else "") @rpc("any_peer", "reliable") func _request_target(path_str: String) -> void: if not multiplayer.is_server(): return var t: Node = get_node_or_null(NodePath(path_str)) if path_str != "" else null current_target = t func _exit_tree() -> void: Stats.deregister(self) func _input(event: InputEvent) -> void: if not is_multiplayer_authority(): return if ui_capturing: return if event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_RIGHT: look_dragging = event.pressed Input.mouse_mode = Input.MOUSE_MODE_CAPTURED if look_dragging else Input.MOUSE_MODE_VISIBLE elif event is InputEventMouseMotion and look_dragging: pivot.rotate_y(-event.relative.x * MOUSE_SENS) pitch_pivot.rotate_x(-event.relative.y * MOUSE_SENS) pitch_pivot.rotation.x = clamp(pitch_pivot.rotation.x, -1.2, 0.2) func _unhandled_input(event: InputEvent) -> void: if not is_multiplayer_authority(): return if dead: return if build_mode: return if event.is_action_pressed("class_tank"): _request_role(GameState.ROLE_TANK) elif event.is_action_pressed("class_damage"): _request_role(GameState.ROLE_DAMAGE) elif event.is_action_pressed("class_healer"): _request_role(GameState.ROLE_HEALER) elif event.is_action_pressed("ability_1"): EventBus.ability_use_requested.emit(self, 0) elif event.is_action_pressed("ability_2"): EventBus.ability_use_requested.emit(self, 1) elif event.is_action_pressed("ability_3"): EventBus.ability_use_requested.emit(self, 2) elif event.is_action_pressed("ability_4"): EventBus.ability_use_requested.emit(self, 3) elif event.is_action_pressed("target_next"): var nt := _cycle_target() _set_target(nt) elif event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_LEFT and event.pressed and not look_dragging: var t := _pick_target_under_mouse() if t: _set_target(t) func _physics_process(delta: float) -> void: if is_multiplayer_authority(): if not dead: _process_local(delta) sync_position = global_position sync_velocity = velocity sync_yaw = pivot.rotation.y sync_role = role else: global_position = global_position.lerp(sync_position, clamp(delta * 20.0, 0.0, 1.0)) pivot.rotation.y = lerp_angle(pivot.rotation.y, sync_yaw, clamp(delta * 20.0, 0.0, 1.0)) if sync_role != role: role = sync_role _apply_role_visual(role) func _process_local(delta: float) -> void: if not is_on_floor(): velocity.y -= GRAVITY * delta var move_dir: Vector2 = Input.get_vector("move_left", "move_right", "move_forward", "move_back") if not ui_capturing else Vector2.ZERO var speed: float = float(Stats.get_stat(self, "speed", 5.0)) var basis_y := Basis(Vector3.UP, pivot.rotation.y) var direction := basis_y * Vector3(move_dir.x, 0.0, move_dir.y) if direction.length() > 0.01: velocity.x = direction.x * speed velocity.z = direction.z * speed var look_dir := Vector3(velocity.x, 0.0, velocity.z).normalized() var target_basis := Basis.looking_at(look_dir, Vector3.UP) mesh_holder.basis = mesh_holder.basis.slerp(target_basis, clamp(delta * 12.0, 0.0, 1.0)) else: velocity.x = move_toward(velocity.x, 0.0, speed * 6.0 * delta) velocity.z = move_toward(velocity.z, 0.0, speed * 6.0 * delta) if Input.is_action_just_pressed("jump") and is_on_floor() and not ui_capturing: velocity.y = float(Stats.get_stat(self, "jump_velocity", 4.5)) move_and_slide() func _request_role(new_role: int) -> void: _set_role.rpc_id(1, new_role) @rpc("any_peer", "reliable", "call_local") func _set_role(new_role: int) -> void: if not multiplayer.is_server(): return if new_role == role: return role = new_role Stats.set_stat(self, "role", role) _apply_role.rpc(role) @rpc("any_peer", "reliable", "call_local") func _apply_role(new_role: int) -> void: if multiplayer.multiplayer_peer != null and not (multiplayer.multiplayer_peer is OfflineMultiplayerPeer) and multiplayer.get_remote_sender_id() != 0 and multiplayer.get_remote_sender_id() != 1: return role = new_role if Stats.has(self): Stats.set_stat(self, "role", role) _apply_role_visual(role) EventBus.role_changed.emit(self, role) func _apply_role_visual(r: int) -> void: var mesh: MeshInstance3D = mesh_holder.get_node("Mesh") var mat: StandardMaterial3D = mesh.get_active_material(0).duplicate() if mesh.get_active_material(0) else StandardMaterial3D.new() match r: GameState.ROLE_TANK: mat.albedo_color = Color(0.3, 0.5, 0.95) GameState.ROLE_DAMAGE: mat.albedo_color = Color(0.95, 0.3, 0.3) GameState.ROLE_HEALER: mat.albedo_color = Color(0.4, 0.85, 0.4) mesh.material_override = mat @rpc("any_peer", "reliable", "call_local") func set_dead(value: bool) -> void: if multiplayer.multiplayer_peer != null and not (multiplayer.multiplayer_peer is OfflineMultiplayerPeer) and multiplayer.get_remote_sender_id() != 0 and multiplayer.get_remote_sender_id() != 1: return dead = value visible = not value collision.disabled = value if value: velocity = Vector3.ZERO @rpc("any_peer", "reliable", "call_local") func teleport_to(pos: Vector3) -> void: if multiplayer.multiplayer_peer != null and not (multiplayer.multiplayer_peer is OfflineMultiplayerPeer) and multiplayer.get_remote_sender_id() != 0 and multiplayer.get_remote_sender_id() != 1: return global_position = pos sync_position = pos func set_ui_capturing(v: bool) -> void: ui_capturing = v func set_build_mode(v: bool) -> void: build_mode = v func _cycle_target() -> Node: if current_target != null and not is_instance_valid(current_target): current_target = null var candidates: Array = [] for n in get_tree().get_nodes_in_group("enemies"): if is_instance_valid(n): candidates.append(n) for n in get_tree().get_nodes_in_group("portals"): if is_instance_valid(n): candidates.append(n) for n in get_tree().get_nodes_in_group("gates"): if is_instance_valid(n): candidates.append(n) if candidates.is_empty(): current_target = null return null candidates.sort_custom(func(a, b): return (a as Node3D).global_position.distance_to(global_position) < (b as Node3D).global_position.distance_to(global_position)) if current_target == null or not current_target in candidates: current_target = candidates[0] else: var idx: int = candidates.find(current_target) current_target = candidates[(idx + 1) % candidates.size()] return current_target func _pick_target_under_mouse() -> Node: var mouse := get_viewport().get_mouse_position() var from := camera.project_ray_origin(mouse) var to := from + camera.project_ray_normal(mouse) * 100.0 var space := get_world_3d().direct_space_state var query := PhysicsRayQueryParameters3D.create(from, to) query.collision_mask = 0xFFFFFFFF query.exclude = [self] var hit := space.intersect_ray(query) if hit.is_empty(): return null var n: Node = hit.collider while n != null and not (n.is_in_group("enemies") or n.is_in_group("portals") or n.is_in_group("gates") or n.is_in_group("npc")): n = n.get_parent() return n