There was a string — `current_gamemode`, always "Deathmatch" — an OptionButton
with one entry in it, and a five-minute timer that counted to zero, set
`match_active = false`, and did nothing else. No winner was declared, no summary
appeared, the clock froze at 00:00, and players carried on shooting each other
in a match that had stopped counting. Nothing was tracked beyond a running kill
count. A match did not end so much as stop mattering, and everything a player
does in the last minute only matters if there is a last minute.
globals/game_mode.gd answers the three questions a mode has to answer — how you
score, when it ends, who won — for three modes:
Deathmatch 25 frags or 10 minutes.
Team Deathmatch two squads to 50, teams balanced by COUNT on join (a 4v4
that loses three from one side must refill the short side;
round-robin on join order leaves it 4v1 forever), friendly
fire off and enforced on the server before damage is even
broadcast — a mode where the damage lands but the kill does
not count is worse than either.
Gun Game every kill promotes you a rung and swaps your weapon. The
score IS the rung and the limit IS the ladder's length, so
finishing the ladder and reaching the score limit are the
same event and only one win condition exists.
`check_win` is a pure function of the stats and the clock, deliberately, because
a win condition that can only be exercised by playing a whole match is one
nobody tests — and the previous one never was. debug/game_mode_check.gd runs 30
cases over it: a tie is a DRAW rather than a win for whoever came first out of
the dictionary; a team match is decided on the TEAM's total, which a per-player
check never reaches; every ladder rung names a weapon that exists, or that rung
softlocks the mode; and promoting past the top clamps, because the winning kill
promotes before the match-end RPC lands.
Tracking now covers score, team, current streak, best streak and ladder rung.
Score and kills are separate numbers because in Gun Game they coincide and in
anything with an objective they would not. The scoreboard ranks by score, shows
the mode's own noun for it, and puts the limit on the top line — a win condition
players cannot see is one they cannot play toward.
ui/match_summary.gd gives the ending somewhere to happen: who won, WHY (time and
frag limit are different stories about the same scoreline), team totals, full
standings, and a way out that is not alt-F4. Play Again is host-only on a server.
Three bugs found on the way, all of which only became bugs once matches could
actually end: loading a level reset the clock but not the scores, so the second
match on a server would have ended on its first kill; `.rpc()` on an offline
peer does not call locally, so singleplayer had no killfeed and no stat sync at
all; and a rocket already in the air at the whistle could change the result
after the summary was on screen.
spawn smoke 0, game modes 30/30, movement 11/11.
Co-Authored-By: Claude Opus 5 <[email protected]>
430 lines
16 KiB
GDScript
430 lines
16 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()
|
|
|
|
# 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)
|