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 var _viewmodel_skin_override := "" 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) var skin_manager = get_node_or_null("/root/SkinManager") if skin_manager and skin_manager.has_signal("skin_changed"): skin_manager.skin_changed.connect(_on_skin_changed) # 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) # Camera-relative two-light rig. The previous light lived in world space, so # merely turning the player could put the receiver on its dark side and # collapse the whole weapon into a black silhouette. Parenting these to the # viewmodel camera makes their graphic key/fill relationship stable in every # direction, like lighting an animation cel rather than a world prop. var key_light := DirectionalLight3D.new() key_light.name = "ViewmodelKey" key_light.light_cull_mask = 1 << 19 key_light.rotation_degrees = Vector3(-34, -42, 0) key_light.light_color = Color(1.0, 0.93, 0.84) # The shader carries the graphic four-tone key. This real light only gives # the narrow custom specular something to react to; the previous energy of # 3.0 flooded every material into the same plastic middle grey. key_light.light_energy = 0.80 key_light.shadow_enabled = false vm_camera.add_child(key_light) var fill_light := DirectionalLight3D.new() fill_light.name = "ViewmodelFill" fill_light.light_cull_mask = 1 << 19 fill_light.rotation_degrees = Vector3(18, 148, 0) fill_light.light_color = Color(0.46, 0.62, 1.0) fill_light.light_energy = 0.045 fill_light.shadow_enabled = false vm_camera.add_child(fill_light) var _bob_timer: 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 _movement_pose_position := Vector3.ZERO var _movement_pose_rotation := Vector3.ZERO var _movement_pose_state := "idle" var _movement_pose_time := 0.0 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) # Authored first-person handling. The movement state changes HOW the # character carries the gun; recoil/reload/ADS still layer afterward. var machine = player.get_node_or_null("MovementStateMachine") var state: String = machine.current_state if machine else "idle" if state != _movement_pose_state: _movement_pose_state = state _movement_pose_time = 0.0 else: _movement_pose_time += _delta var local_vel := camera.global_transform.basis.inverse() * player.velocity var state_pose := _movement_pose_target( state, machine, local_vel, hspeed) var target_position: Vector3 = state_pose[0] var target_rotation: Vector3 = state_pose[1] var active_weapon = weapons.get(active_slot) var ads_weight := 1.0 if active_weapon \ and "is_ads" in active_weapon and active_weapon.is_ads else 0.0 # Keep sights readable without erasing all physical response. target_position *= lerpf(1.0, 0.28, ads_weight) target_rotation *= lerpf(1.0, 0.24, ads_weight) var pose_rate := _movement_pose_rate(state) var pose_blend := 1.0 - exp(-pose_rate * _delta) _movement_pose_position = _movement_pose_position.lerp( target_position, pose_blend) _movement_pose_rotation = _lerp_euler( _movement_pose_rotation, target_rotation, pose_blend) if state == "slide": _bob_timer = 0.0 else: if state in ["ground", "idle"] and 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 _target_drift_offset = Vector3( local_vel.x * 0.0025, local_vel.y * 0.0025, 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, 1.0 - exp(-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)) # The targets above describe the WEAPON'S desired movement. Moving # the isolated viewmodel camera by the inverse composes that pose # with every weapon without overwriting weapon-local reload tweens. vm_camera.translate_object_local(-_movement_pose_position) vm_camera.rotate_object_local( Vector3.RIGHT, -_movement_pose_rotation.x) vm_camera.rotate_object_local( Vector3.UP, -_movement_pose_rotation.y) vm_camera.rotate_object_local( Vector3.FORWARD, -_movement_pose_rotation.z) # 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 _movement_pose_target(state: String, machine: Node, local_velocity: Vector3, speed: float) -> Array[Vector3]: var position := Vector3.ZERO var rotation := Vector3.ZERO var phase := _movement_pose_time match state: "ground", "idle": if machine and machine.input_crouch: position = Vector3(-0.035, -0.035, 0.018) rotation = Vector3(3.0, -2.0, 3.0) "air": if local_velocity.y > 0.2: var rise := clampf(local_velocity.y / 10.0, 0.0, 1.0) position = Vector3(0.035, -0.045, 0.035 + 0.025 * rise) rotation = Vector3(6.0 + 3.0 * rise, -4.0, 3.0) else: var fall := clampf(-local_velocity.y / 15.0, 0.0, 1.0) position = Vector3(-0.018, 0.008 + 0.018 * fall, 0.025) rotation = Vector3(-2.0 - 3.0 * fall, 3.0, -2.0) rotation.z += sin(phase * 4.0) * 0.8 "dash": # Drop and tuck right so the burst clears the crosshair. A negative # roll preserves the weapon's low diagonal instead of standing a # long gun vertically through the center of the screen. position = Vector3(0.12, -0.10, 0.080) rotation = Vector3(8.0, 4.0, -10.0) "slide": position = Vector3(0.14, -0.12, 0.055) position.y += sin(phase * 15.0) * 0.003 rotation = Vector3(8.0, 3.0, -12.0) "wall_run": var side: float = machine.wall_side if machine else 0.0 if is_zero_approx(side): side = 1.0 position = Vector3(-side * 0.085, -0.035, 0.060) position.y += sin(phase * 10.0) * 0.006 rotation = Vector3( 7.0, -side * 6.0, -side * 15.0 + sin(phase * 7.0) * 1.2) "wall_cling", "wall_climb": var side: float = machine.wall_side if machine else 1.0 position = Vector3(-side * 0.11, -0.095, 0.10) rotation = Vector3(15.0, -side * 9.0, -side * 18.0) "grapple": var pull := clampf(speed / 20.0, 0.0, 1.0) position = Vector3(0.11, -0.060, 0.065 + 0.025 * pull) position.y += sin(phase * 5.0) * 0.006 rotation = Vector3(7.0, 11.0, -15.0) _: pass return [position, rotation * (PI / 180.0)] func _movement_pose_rate(state: String) -> float: match state: "dash": return 22.0 "slide": return 16.0 "wall_run", "wall_cling", "wall_climb": return 11.0 "grapple": return 10.0 "air": return 8.0 _: return 9.0 func _lerp_euler(from: Vector3, to: Vector3, weight: float) -> Vector3: return Vector3( lerp_angle(from.x, to.x, weight), lerp_angle(from.y, to.y, weight), lerp_angle(from.z, to.z, weight)) func movement_pose_debug() -> Dictionary: return { "state": _movement_pose_state, "position": _movement_pose_position, "rotation": _movement_pose_rotation, } 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) # Toon conversion gives every semantic hand piece its own material. # Apply the selected glove/bare/android style after that split so a # shared construction material cannot make the last fingertip color # overwrite the palm. for child in w.get_children(): if child.has_meta("viewmodel_arm"): _restyle_arm(child) LevelMaterials.apply_viewmodel_look(w) # 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. var grip := WeaponGrips.GRIP if "weapon_name" in weapon and weapon.weapon_name == "Knife": # The knife FBX's knuckle-guard grip is offset from the gun-set's # pistol-grip origin. Put the articulated fingers around the orange # handle instead of leaving the hand floating behind it. grip = Vector3(0.17, 0.02, 0.05) _build_arm(weapon, Vector3(0.25, -0.3, 0.5), 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") ## Rebuild the lightweight first-person arms as soon as the local selection ## changes; each weapon owns its own animated ArmR/ArmL pivots. func _on_skin_changed(skin_id: String) -> void: # The signal fires immediately before the player controller mirrors the # selection into synced_skin_id, so use its payload for this rebuild. _viewmodel_skin_override = skin_id for weapon in weapons.values(): if not is_instance_valid(weapon): continue var found_arm := false for child in weapon.get_children(): if child.has_meta("viewmodel_arm"): found_arm = true _restyle_arm(child) if not found_arm: _add_procedural_arms(weapon) LevelMaterials.apply_toon_recursive(weapon, 0.0) LevelMaterials.apply_viewmodel_look(weapon) _set_layer_recursive(weapon, 1 << 19) _viewmodel_skin_override = "" func _viewmodel_skin() -> PlayerSkin: var skin_manager = get_node_or_null("/root/SkinManager") if skin_manager == null: return null var skin_id: String = _viewmodel_skin_override if skin_id.is_empty() and is_instance_valid(player) and "synced_skin_id" in player: skin_id = player.synced_skin_id if skin_id.is_empty(): skin_id = skin_manager.active_skin_id return skin_manager.get_skin(skin_id) func _set_arm_piece_color(piece: MeshInstance3D, color: Color) -> void: if piece == null or piece.mesh == null: return for surface in piece.mesh.get_surface_count(): var material := piece.get_active_material(surface) if material is ShaderMaterial: material.set_shader_parameter("albedo_color", color) elif material is BaseMaterial3D: material.albedo_color = color func _arm_surface_color(surface: String, skin: PlayerSkin) -> Color: match surface: "sleeve": return skin.viewmodel_sleeve_color "cuff", "hand_accent": return skin.viewmodel_accent_color "nail": return skin.viewmodel_nail_color "forearm": return skin.viewmodel_skin_color "hand_palm", "finger_prox": if skin.viewmodel_hand_style in ["fingerless", "glove"]: return skin.viewmodel_glove_color if skin.viewmodel_hand_style == "android": return skin.viewmodel_accent_color return skin.viewmodel_skin_color "fingertip": if skin.viewmodel_hand_style == "glove": return skin.viewmodel_glove_color return skin.viewmodel_skin_color return skin.viewmodel_skin_color func _arm_surface_visible(surface: String, skin: PlayerSkin) -> bool: if surface == "nail": return skin.viewmodel_hand_style in ["bare", "fingerless"] if surface == "hand_accent": return skin.viewmodel_hand_style in ["fingerless", "glove", "android"] return true func _restyle_arm(pivot: Node3D) -> void: var selected_skin := _viewmodel_skin() if selected_skin == null: return var sleeve := pivot.get_node_or_null("Sleeve") as MeshInstance3D var cuff := pivot.get_node_or_null("Cuff") as MeshInstance3D var forearm := pivot.get_node_or_null("Forearm") as MeshInstance3D for piece in pivot.find_children("*", "MeshInstance3D", true, false): var surface: String = str(piece.get_meta("viewmodel_surface", "")) if surface.is_empty(): continue piece.visible = _arm_surface_visible(surface, selected_skin) _set_arm_piece_color(piece, _arm_surface_color(surface, selected_skin)) var arm_length: float = float(pivot.get_meta("arm_length", 0.58)) var hand_length := minf(0.15, arm_length * 0.25) var cuff_length := 0.045 var clothed_length := clampf( (arm_length - hand_length) * selected_skin.viewmodel_sleeve_ratio, 0.10, arm_length - hand_length - cuff_length) var bare_length := maxf(0.0, arm_length - clothed_length - cuff_length - hand_length) if sleeve: sleeve.mesh = _tapered_limb_mesh( clothed_length, Vector2(0.068, 0.052), Vector2(0.055, 0.043), sleeve.get_active_material(0)) sleeve.position = Vector3.ZERO if cuff: cuff.mesh = _tapered_limb_mesh( cuff_length, Vector2(0.058, 0.045), Vector2(0.052, 0.040), cuff.get_active_material(0)) cuff.position = Vector3(0, 0, -clothed_length) if forearm: forearm.visible = bare_length > 0.005 forearm.mesh = _tapered_limb_mesh( maxf(bare_length, 0.01), Vector2(0.050, 0.039), Vector2(0.044, 0.034), forearm.get_active_material(0)) forearm.position = Vector3(0, 0, -(clothed_length + cuff_length)) var hand_root := pivot.get_node_or_null("Hand") as Node3D if hand_root: hand_root.position = Vector3(0, 0, -(arm_length - hand_length)) func _tapered_limb_mesh(length: float, near_radius: Vector2, far_radius: Vector2, material: Material) -> ArrayMesh: var tool := SurfaceTool.new() tool.begin(Mesh.PRIMITIVE_TRIANGLES) var sides := 8 var near_points: Array[Vector3] = [] var far_points: Array[Vector3] = [] for i in sides: var angle := TAU * float(i) / float(sides) + PI * 0.125 near_points.append(Vector3( cos(angle) * near_radius.x, sin(angle) * near_radius.y, 0)) far_points.append(Vector3( cos(angle) * far_radius.x, sin(angle) * far_radius.y, -length)) for i in sides: var next := (i + 1) % sides tool.add_vertex(near_points[i]) tool.add_vertex(far_points[i]) tool.add_vertex(far_points[next]) tool.add_vertex(near_points[i]) tool.add_vertex(far_points[next]) tool.add_vertex(near_points[next]) tool.add_vertex(Vector3.ZERO) tool.add_vertex(near_points[next]) tool.add_vertex(near_points[i]) tool.add_vertex(Vector3(0, 0, -length)) tool.add_vertex(far_points[i]) tool.add_vertex(far_points[next]) tool.generate_normals() var mesh := tool.commit() if material: mesh.surface_set_material(0, material) return mesh func _new_arm_material(color: Color, roughness: float = 0.9) -> StandardMaterial3D: var material := StandardMaterial3D.new() material.albedo_color = color material.roughness = roughness return material func _tag_arm_piece(piece: MeshInstance3D, surface: String) -> void: piece.set_meta("viewmodel_surface", surface) func _add_hand_piece(parent: Node3D, piece_name: String, mesh: Mesh, surface: String, position: Vector3 = Vector3.ZERO) -> MeshInstance3D: var piece := MeshInstance3D.new() piece.name = piece_name piece.mesh = mesh piece.position = position _tag_arm_piece(piece, surface) parent.add_child(piece) return piece func _capsule_between(parent: Node3D, piece_name: String, from: Vector3, to: Vector3, radius: float, material: Material, surface: String) -> MeshInstance3D: var direction := to - from var capsule := CapsuleMesh.new() capsule.radius = radius capsule.height = maxf(direction.length() + radius * 1.35, radius * 2.05) capsule.radial_segments = 8 capsule.rings = 2 capsule.material = material var piece := _add_hand_piece( parent, piece_name, capsule, surface, (from + to) * 0.5) if direction.length_squared() > 0.000001: piece.basis = Basis(Quaternion(Vector3.UP, direction.normalized())) return piece func _build_anime_hand(parent: Node3D, hand_length: float, is_left: bool, skin_mat: Material, glove_mat: Material, accent_mat: Material, nail_mat: Material) -> void: parent.name = "Hand" # The palm is a flattened, tapered eight-plane volume. A discrete palm plus # articulated digits gives the hand a readable anime silhouette even when # the weapon covers half of it. var palm := _add_hand_piece( parent, "Palm", _tapered_limb_mesh(hand_length * 0.62, Vector2(0.053, 0.028), Vector2(0.046, 0.023), skin_mat), "hand_palm") palm.position = Vector3.ZERO var pad_mesh := BoxMesh.new() pad_mesh.size = Vector3(0.068, 0.007, hand_length * 0.30) pad_mesh.material = accent_mat _add_hand_piece(parent, "BackhandPanel", pad_mesh, "hand_accent", Vector3(0, 0.028, -hand_length * 0.34)) var finger_x := [-0.034, -0.012, 0.012, 0.034] var finger_scale := [0.82, 1.0, 0.96, 0.76] for i in 4: var x: float = finger_x[i] var scale_f: float = finger_scale[i] var base := Vector3(x, -0.002, -hand_length * 0.52) var joint := Vector3( x, -0.012, -hand_length * (0.70 + 0.08 * scale_f)) var tip := Vector3( x, -0.047, -hand_length * (0.79 + 0.08 * scale_f)) var radius := 0.0105 - float(abs(i - 1)) * 0.0008 _capsule_between(parent, "Finger%dProx" % i, base, joint, radius, glove_mat, "finger_prox") _capsule_between(parent, "Finger%dTip" % i, joint, tip, radius * 0.92, skin_mat, "fingertip") var nail_mesh := BoxMesh.new() nail_mesh.size = Vector3(radius * 1.15, 0.0035, radius * 1.7) nail_mesh.material = nail_mat _add_hand_piece(parent, "Nail%d" % i, nail_mesh, "nail", tip + Vector3(0, 0.006, 0.002)) # Opposed thumb: two separate phalanges angle across the grip instead of # reading as a fifth parallel tube. var thumb_side := 1.0 if is_left else -1.0 var thumb_base := Vector3( thumb_side * 0.043, -0.002, -hand_length * 0.23) var thumb_joint := Vector3( thumb_side * 0.069, -0.015, -hand_length * 0.46) var thumb_tip := Vector3( thumb_side * 0.049, -0.047, -hand_length * 0.62) _capsule_between(parent, "ThumbProx", thumb_base, thumb_joint, 0.0125, glove_mat, "finger_prox") _capsule_between(parent, "ThumbTip", thumb_joint, thumb_tip, 0.0115, skin_mat, "fingertip") func _build_arm(weapon: Node3D, shoulder: Vector3, hand: Vector3, arm_name: String = "") -> void: var selected_skin := _viewmodel_skin() var sleeve_color := selected_skin.viewmodel_sleeve_color \ if selected_skin else Color(0.16, 0.34, 0.72) var accent_color := selected_skin.viewmodel_accent_color \ if selected_skin else Color(0.28, 0.72, 1.0) var hand_color := selected_skin.viewmodel_skin_color \ if selected_skin else Color(0.96, 0.80, 0.72) var glove_color := selected_skin.viewmodel_glove_color \ if selected_skin else Color(0.10, 0.11, 0.16) var nail_color := selected_skin.viewmodel_nail_color \ if selected_skin else Color(0.92, 0.68, 0.72) var sleeve_ratio := selected_skin.viewmodel_sleeve_ratio if selected_skin else 0.68 var sleeve_mat := _new_arm_material(sleeve_color, 0.82) var cuff_mat := _new_arm_material(accent_color, 0.78) var skin_mat := _new_arm_material(hand_color, 0.94) var glove_mat := _new_arm_material(glove_color, 0.88) var nail_mat := _new_arm_material(nail_color, 0.74) var pivot := Node3D.new() if arm_name != "": pivot.name = arm_name pivot.set_meta("viewmodel_arm", true) pivot.set_meta("arm_length", shoulder.distance_to(hand)) pivot.position = shoulder pivot.look_at_from_position(shoulder, hand, Vector3.UP) var arm_length := shoulder.distance_to(hand) var hand_length := minf(0.15, arm_length * 0.25) var cuff_length := 0.045 var clothed_length := clampf( (arm_length - hand_length) * sleeve_ratio, 0.10, arm_length - hand_length - cuff_length) var bare_length := maxf(0.0, arm_length - clothed_length - cuff_length - hand_length) var sleeve := MeshInstance3D.new() sleeve.name = "Sleeve" sleeve.mesh = _tapered_limb_mesh( clothed_length, Vector2(0.068, 0.052), Vector2(0.055, 0.043), sleeve_mat) _tag_arm_piece(sleeve, "sleeve") pivot.add_child(sleeve) var cuff := MeshInstance3D.new() cuff.name = "Cuff" cuff.mesh = _tapered_limb_mesh( cuff_length, Vector2(0.058, 0.045), Vector2(0.052, 0.040), cuff_mat) cuff.position.z = -clothed_length _tag_arm_piece(cuff, "cuff") pivot.add_child(cuff) var forearm := MeshInstance3D.new() forearm.name = "Forearm" forearm.mesh = _tapered_limb_mesh( maxf(bare_length, 0.01), Vector2(0.050, 0.039), Vector2(0.044, 0.034), skin_mat) forearm.position.z = -(clothed_length + cuff_length) forearm.visible = bare_length > 0.005 _tag_arm_piece(forearm, "forearm") pivot.add_child(forearm) var hand_root := Node3D.new() hand_root.position.z = -(arm_length - hand_length) pivot.add_child(hand_root) _build_anime_hand( hand_root, hand_length, arm_name == "ArmL", skin_mat, glove_mat, cuff_mat, nail_mat) 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)