1. Ankle cuffs. The previous pass re-weighted the offending vertices to the nearer leg, which was the wrong call: the strip between the ankles is REAL geometry spanning the gap (~10 cm of ClothCAndW crossing x = -0.05 .. +0.05 at ankle height), so re-weighting only tore it in half — a visible seam that still stretched. A triangle with one corner weighted to each leg has no correct pose; it must stretch the moment the legs separate. SkinMeshRepair now deletes those triangles instead (48 on Taila: the cloth band plus its outline shell). The cut is limited to BELOW THE KNEE, taken from the skeleton's own rest pose rather than a hardcoded height, because above the knee cross-leg geometry is legitimate — the skirt and shorts genuinely span left-thigh to right-thigh weights at the crotch. 2. The M4 floated because _measure_weapon derived the grip from mesh AABBs, and the FBX guns report bind-pose bounds tens of metres across — it measured the M4 as 24 m long and pushed the gun 7.5 m in front of the character. Bounding boxes are simply not trustworthy for these meshes. The grip was already authored elsewhere: WeaponManager places the first-person viewmodel's hands at fixed points in weapon space, and every weapon marks its barrel tip with muzzle_flash.position. Those move to WeaponGrips (dependency-free, so both the weapon system and the character models can use it without dragging each other's load order along) and third person now reaches for exactly the points the viewmodel uses. Every weapon's grip now lands 0.073 m from the hand — the M4 included, down from 2.32 m — with barrel lengths that match the models (0.30 m for the MP7, 0.68 m for the DMR). 3. Muzzle flash and tracers were spawned off the viewmodel's muzzle. The viewmodel is parented to the camera, so its muzzle sits inside the player's head — in third person the flash appeared by the character's shoulder. world_muzzle() returns the muzzle of the gun actually in the character's hands whenever the character is what the viewer sees, and the networked fire-effect RPC now sends that position too, so remote players stop seeing tracers leave the shooter's head. Measured: the third-person origin sits at the held gun, 0.54 m below the head, instead of on the camera. FSM tests 11/11, spawn smoke test 0 failures. Co-Authored-By: Claude Opus 4.8 <[email protected]>
378 lines
14 KiB
GDScript
378 lines
14 KiB
GDScript
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()
|
|
|
|
# Wait one frame for LoadoutManager to be fully ready if needed
|
|
await get_tree().process_frame
|
|
_build_loadout()
|
|
|
|
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
|
|
|
|
_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)
|
|
|
|
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)
|