extends Node3D class_name WeaponManager var player: CharacterBody3D var camera: Camera3D var active_slot: int = 1 # 1: Primary 1, 2: Primary 2, 3: Special, 4: Melee var weapons: Dictionary = {} # slot_index: Node3D var canvas_layer: CanvasLayer var sub_viewport: SubViewport var vm_camera: Camera3D func _ready() -> void: set_process_input(true) set_process(true) _setup_viewmodel_viewport() # Gun Game promotions arrive as a signal from the server. var nm = get_node_or_null("/root/NetworkManager") if nm and nm.has_signal("ladder_promoted"): nm.ladder_promoted.connect(_on_ladder_promoted) # Wait one frame for LoadoutManager to be fully ready if needed await get_tree().process_frame _build_loadout() func _on_ladder_promoted(peer_id: int, _rung: int, weapon_id: String) -> void: # Only the promoted player's own manager acts; every peer receives the # signal because the killfeed uses it too. if player == null or not player.is_multiplayer_authority(): return if multiplayer.has_multiplayer_peer() and peer_id != multiplayer.get_unique_id(): return equip_ladder_weapon(weapon_id) func _setup_viewmodel_viewport() -> void: if not camera: return # Remove viewmodel layer (20) from main camera camera.cull_mask &= ~(1 << 19) canvas_layer = CanvasLayer.new() canvas_layer.layer = 0 # On top of 3D, but below UI add_child(canvas_layer) var sv_container = SubViewportContainer.new() sv_container.set_anchors_preset(Control.PRESET_FULL_RECT) sv_container.stretch = true sv_container.mouse_filter = Control.MOUSE_FILTER_IGNORE canvas_layer.add_child(sv_container) sub_viewport = SubViewport.new() sub_viewport.transparent_bg = true # Shared world so it renders main scene's objects that have layer 20 # (In Godot 4, own_world_3d is false by default, but let's be explicit) sub_viewport.own_world_3d = false sub_viewport.msaa_3d = Viewport.MSAA_4X # keep ink outlines crisp on the viewmodel sv_container.add_child(sub_viewport) vm_camera = Camera3D.new() vm_camera.cull_mask = 1 << 19 # Only see layer 20 vm_camera.near = 0.01 # viewmodel sits close to the lens; avoid clipping sub_viewport.add_child(vm_camera) # Add a light specifically for the viewmodel in case main lights don't reach var dir_light = DirectionalLight3D.new() dir_light.layers = 1 << 19 dir_light.rotation_degrees = Vector3(-60, 45, 0) dir_light.light_energy = 0.5 add_child(dir_light) var _bob_timer: float = 0.0 var _current_slide_tilt_z: float = 0.0 var _current_slide_tilt_x: float = 0.0 var _current_slide_offset_x: float = 0.0 var _current_slide_offset_y: float = 0.0 var _recoil_pitch: float = 0.0 var _recoil_yaw: float = 0.0 var _target_drift_offset: Vector3 = Vector3.ZERO var _current_drift_offset: Vector3 = Vector3.ZERO var _swap_pitch: float = 0.0 var _reload_dip: float = 0.0 func add_recoil(pitch: float, yaw: float) -> void: _recoil_pitch += pitch * 20.0 # Scale up for visual model recoil _recoil_yaw += yaw * 20.0 func _process(_delta: float) -> void: if is_instance_valid(vm_camera) and is_instance_valid(camera): vm_camera.global_transform = camera.global_transform var target_fov = 70.0 if is_instance_valid(player) and "velocity" in player: var hspeed = Vector2(player.velocity.x, player.velocity.z).length() if hspeed <= 11.0: var speed_factor = hspeed / 11.0 target_fov = lerpf(70.0, 72.0, speed_factor) else: var over_speed_factor = clampf((hspeed - 11.0) / 19.0, 0.0, 1.0) # maxes out at 30 m/s target_fov = lerpf(72.0, 80.0, over_speed_factor) # Weapon Viewmodel Bobbing, Drift, and Slide Tilt var is_sliding = player.has_method("get_node") and player.get_node_or_null("MovementStateMachine") and player.get_node("MovementStateMachine").current_state == "slide" if is_sliding: _bob_timer = 0.0 # Target tilt when sliding (inwards towards center, pitched slightly down) # Assuming weapon is on right side (standard FPS), tilting left (positive Z rotation) _current_slide_tilt_z = lerpf(_current_slide_tilt_z, deg_to_rad(15.0), 12.0 * _delta) _current_slide_tilt_x = lerpf(_current_slide_tilt_x, 0.0, 12.0 * _delta) # Move closer to center (negative X) and slightly down (negative Y) _current_slide_offset_x = lerpf(_current_slide_offset_x, -0.15, 12.0 * _delta) _current_slide_offset_y = lerpf(_current_slide_offset_y, -0.1, 12.0 * _delta) else: _current_slide_tilt_z = lerpf(_current_slide_tilt_z, 0.0, 10.0 * _delta) _current_slide_tilt_x = lerpf(_current_slide_tilt_x, 0.0, 10.0 * _delta) _current_slide_offset_x = lerpf(_current_slide_offset_x, 0.0, 10.0 * _delta) _current_slide_offset_y = lerpf(_current_slide_offset_y, 0.0, 10.0 * _delta) if player.is_on_floor() and hspeed > 1.0: # Scale bobbing frequency by walk speed _bob_timer += _delta * 12.0 * (hspeed / 11.0) var bob_y = sin(_bob_timer) * 0.015 var bob_x = cos(_bob_timer * 0.5) * 0.01 vm_camera.translate_object_local(Vector3(bob_x, bob_y, 0)) else: _bob_timer = 0.0 if not player.is_on_floor(): # Airborne Drift # Convert player velocity into camera's local space var local_vel = camera.global_transform.basis.inverse() * player.velocity _target_drift_offset = Vector3(local_vel.x * 0.004, local_vel.y * 0.004, 0) # Clamp the drift _target_drift_offset.x = clampf(_target_drift_offset.x, -0.05, 0.05) _target_drift_offset.y = clampf(_target_drift_offset.y, -0.05, 0.05) else: _target_drift_offset = Vector3.ZERO # Smoothly apply drift so jumping/landing doesn't snap _current_drift_offset = _current_drift_offset.lerp(_target_drift_offset, 15.0 * _delta) vm_camera.translate_object_local(_current_drift_offset) vm_camera.rotate_object_local(Vector3.UP, deg_to_rad(-_current_drift_offset.x * 30.0)) vm_camera.rotate_object_local(Vector3.RIGHT, deg_to_rad(_current_drift_offset.y * 30.0)) # Apply slide tilt and offset if absf(_current_slide_tilt_z) > 0.001 or absf(_current_slide_offset_x) > 0.001: vm_camera.translate_object_local(Vector3(_current_slide_offset_x, _current_slide_offset_y, 0)) vm_camera.rotate_object_local(Vector3.FORWARD, _current_slide_tilt_z) vm_camera.rotate_object_local(Vector3.RIGHT, _current_slide_tilt_x) # Visual Recoil _recoil_pitch = lerpf(_recoil_pitch, 0.0, 15.0 * _delta) _recoil_yaw = lerpf(_recoil_yaw, 0.0, 15.0 * _delta) vm_camera.rotate_object_local(Vector3.RIGHT, deg_to_rad(_recoil_pitch)) vm_camera.rotate_object_local(Vector3.UP, deg_to_rad(_recoil_yaw)) # Add a slight backward kick along the local Z axis vm_camera.translate_object_local(Vector3(0, 0, _recoil_pitch * 0.01)) # Swap Animation _swap_pitch = lerpf(_swap_pitch, 0.0, 15.0 * _delta) if _swap_pitch > 0.1: vm_camera.rotate_object_local(Vector3.RIGHT, deg_to_rad(_swap_pitch)) vm_camera.translate_object_local(Vector3(0, -_swap_pitch * 0.005, 0)) # Reload animation: the weapon dips down-and-inward with a roll, # holds through the reload, and rises as it completes. var aw = weapons.get(active_slot) if aw and "reloading" in aw and aw.reloading \ and "reload_timer" in aw and "reload_time" in aw and aw.reload_time > 0.0: var t: float = clampf(1.0 - aw.reload_timer / aw.reload_time, 0.0, 1.0) _reload_dip = lerpf(_reload_dip, sin(minf(t * 1.4, 1.0) * PI), 10.0 * _delta) else: _reload_dip = lerpf(_reload_dip, 0.0, 10.0 * _delta) if _reload_dip > 0.01: vm_camera.translate_object_local(Vector3(0.03 * _reload_dip, 0.14 * _reload_dip, 0)) vm_camera.rotate_object_local(Vector3.FORWARD, deg_to_rad(-14.0 * _reload_dip)) vm_camera.rotate_object_local(Vector3.RIGHT, deg_to_rad(9.0 * _reload_dip)) vm_camera.fov = lerpf(vm_camera.fov, target_fov, 10.0 * _delta) func _build_loadout() -> void: var is_auth = false if player: is_auth = player.is_multiplayer_authority() else: is_auth = is_multiplayer_authority() print("DEBUG [", multiplayer.get_unique_id(), "]: _build_loadout is_auth=", is_auth, " player=", str(player.name) if player else "null") if not is_auth: return # Let the player_movement_controller's remote _process call _build_remote_loadout # Clear existing weapons for w in weapons.values(): if is_instance_valid(w): w.queue_free() weapons.clear() var l = LoadoutManager.get_active_loadout() print("DEBUG [", multiplayer.get_unique_id(), "]: Loadout primary1=", l["primary_1"]) if player: player.synced_loadout_p1 = l["primary_1"] player.synced_loadout_p2 = l["primary_2"] player.synced_loadout_sp = l["special"] if l.has("melee"): player.synced_loadout_melee = l["melee"] else: player.synced_loadout_melee = "" player.synced_loadout_ready = true # Gun Game hands out the weapon, so the player's own loadout is ignored — # the whole mode is "you get what your rung gives you". Checked here rather # than in the mode so there is one place a loadout is built. if _ladder_mode(): var nm = get_node_or_null("/root/NetworkManager") var rung: int = int(nm.player_stats.get(multiplayer.get_unique_id(), {}) .get("rung", 0)) if nm else 0 equip_ladder_weapon(GameMode.ladder_weapon(rung)) return _spawn_weapon(1, l["primary_1"]) _spawn_weapon(2, l["primary_2"]) _spawn_weapon(3, l["special"]) if l.has("melee"): _spawn_weapon(4, l["melee"]) _equip_slot(1) ## Whether the current match issues weapons instead of letting players pick. func _ladder_mode() -> bool: var nm = get_node_or_null("/root/NetworkManager") return nm != null and nm.current_gamemode == GameMode.GUN_GAME ## Replace everything in hand with one issued weapon. Gun Game's promotion. func equip_ladder_weapon(weapon_id: String) -> void: if weapon_id == "": return for w in weapons.values(): if is_instance_valid(w): w.queue_free() weapons.clear() _spawn_weapon(1, weapon_id) if player: # Remote peers build their view of this player from the synced loadout, # so a promotion has to move that too or everyone else keeps seeing the # gun from the previous rung in their hands. player.synced_loadout_p1 = weapon_id player.synced_loadout_p2 = "" player.synced_loadout_sp = "" player.synced_loadout_melee = "" player.synced_loadout_ready = true _equip_slot(1) func _build_remote_loadout(p1: String, p2: String, sp: String, melee: String) -> void: for w in weapons.values(): if is_instance_valid(w): w.queue_free() weapons.clear() _spawn_weapon(1, p1) _spawn_weapon(2, p2) _spawn_weapon(3, sp) if melee != "": _spawn_weapon(4, melee) _equip_slot(1) func _spawn_weapon(slot: int, weapon_id: String) -> void: if weapon_id == "" or weapon_id == "none" or not LoadoutManager.weapon_db.has(weapon_id): print("DEBUG [", multiplayer.get_unique_id(), "]: _spawn_weapon failed check for ", weapon_id) return var w_data = LoadoutManager.weapon_db[weapon_id] var script_path = w_data["script"] if script_path == "": print("DEBUG [", multiplayer.get_unique_id(), "]: _spawn_weapon failed script path for ", weapon_id) return var script = load(script_path) if script: print("DEBUG [", multiplayer.get_unique_id(), "]: Successfully loaded script, adding weapon ", weapon_id) var w = script.new() w.name = "Weapon_" + str(slot) + "_" + weapon_id # Assuming all weapons have player and camera vars if "player" in w: w.player = player if "camera" in w: w.camera = camera add_child(w) weapons[slot] = w w.visible = false w.set_process_input(false) w.set_meta("script_path", script_path) _add_procedural_arms(w) # Cel-shade the view model + arms so the first-person weapon matches # the world's look. No inverted-hull outline: FBX weapon models have # hard-edged normals that make the hull tear into scratchy artifacts. LevelMaterials.apply_toon_recursive(w, 0.0) # Set weapons to viewmodel layer _set_layer_recursive(w, 1 << 19) func _set_layer_recursive(node: Node, layer_mask: int) -> void: if node is VisualInstance3D: if node is GeometryInstance3D: node.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF if node is Light3D: # Lights (e.g. muzzle flash) illuminate both world and viewmodel node.layers = 1 | layer_mask else: node.layers = layer_mask for child in node.get_children(): _set_layer_recursive(child, layer_mask) func _add_procedural_arms(weapon: Node3D) -> void: # Attach to weapon instead of model_root so reload choreography can move # the gun (model_root) and each hand (named pivots) independently. # Hand points come from WeaponGrips so the third-person model reaches for # exactly the same spots this viewmodel uses. _build_arm(weapon, Vector3(0.25, -0.3, 0.5), WeaponGrips.GRIP, "ArmR") if "weapon_name" in weapon and weapon.weapon_name != "Knife": _build_arm(weapon, Vector3(-0.25, -0.3, 0.4), WeaponGrips.SUPPORT, "ArmL") ## A first-person arm styled after the character skin: dark detached sleeve, ## glowing cuff, bare hand — instead of the old featureless blue slab. func _build_arm(weapon: Node3D, shoulder: Vector3, hand: Vector3, arm_name: String = "") -> void: var sleeve_mat = StandardMaterial3D.new() sleeve_mat.albedo_color = Color(0.10, 0.11, 0.14) # near-black sleeve sleeve_mat.roughness = 0.8 var cuff_mat = StandardMaterial3D.new() cuff_mat.albedo_color = Color(0.25, 0.95, 0.90) # signature teal cuff cuff_mat.emission_enabled = true cuff_mat.emission = Color(0.10, 0.55, 0.52) var skin_mat = StandardMaterial3D.new() skin_mat.albedo_color = Color(0.98, 0.88, 0.82) # skin skin_mat.roughness = 0.9 var pivot = Node3D.new() if arm_name != "": pivot.name = arm_name pivot.position = shoulder pivot.look_at_from_position(shoulder, hand, Vector3.UP) var sleeve = MeshInstance3D.new() var sleeve_mesh = BoxMesh.new() sleeve_mesh.size = Vector3(0.075, 0.075, 0.46) sleeve_mesh.material = sleeve_mat sleeve.mesh = sleeve_mesh sleeve.position.z = -0.27 pivot.add_child(sleeve) var cuff = MeshInstance3D.new() var cuff_mesh = BoxMesh.new() cuff_mesh.size = Vector3(0.085, 0.085, 0.05) cuff_mesh.material = cuff_mat cuff.mesh = cuff_mesh cuff.position.z = -0.52 pivot.add_child(cuff) var hand_box = MeshInstance3D.new() var hand_mesh = BoxMesh.new() hand_mesh.size = Vector3(0.06, 0.055, 0.12) hand_mesh.material = skin_mat hand_box.mesh = hand_mesh hand_box.position.z = -0.60 pivot.add_child(hand_box) weapon.add_child(pivot) func _equip_slot(slot: int) -> void: if weapons.has(active_slot): var w = weapons[active_slot] w.visible = false w.set_process_input(false) if "is_firing" in w: w.is_firing = false if w.has_method("unequip"): w.unequip() if active_slot != slot: _swap_pitch = 60.0 active_slot = slot if weapons.has(active_slot): var w = weapons[active_slot] w.visible = true w.set_process_input(true) # Sync 3rd person weapon on the visual model (skinned or procedural) if player and w.has_meta("script_path"): var visual = player.get_visual_model() if player.has_method("get_visual_model") else player.get_node_or_null("HumanoidModel") if visual and visual.has_method("set_weapon"): visual.set_weapon(w.get_meta("script_path")) # Update the synced variable so remote peers pick it up if "synced_weapon_path" in player: player.synced_weapon_path = w.get_meta("script_path") func _input(event: InputEvent) -> void: if Input.get_mouse_mode() != Input.MOUSE_MODE_CAPTURED: return if event.is_action_pressed("weapon_1"): _equip_slot(1) elif event.is_action_pressed("weapon_2"): _equip_slot(2) elif event.is_action_pressed("weapon_3"): _equip_slot(3) elif event.is_action_pressed("weapon_4"): _equip_slot(4) elif event.is_action_pressed("melee"): if active_slot != 4: var prev = active_slot _equip_slot(4) if weapons.has(4): if weapons[4].has_method("do_quick_melee"): weapons[4].do_quick_melee(prev)