The reticle was five white ColorRects, pasted byte-identically into three
level runtime scripts. The vitals were two stock ProgressBars with a flat
colour override, inlined 1200 lines into the movement controller. The ammo
count — the number a shooter's player looks at most — was drawn by the LEVEL,
in a black rounded panel that shared nothing with the menus, and it had a
special case in it (`elif active_weapon is DoubleBarrelShotgun`) because that
weapon never declared a name or a capacity.
Everything that describes A PLAYER now belongs to ui/player_hud.gd, and a
level owns the level. The immediate symptom that fixed: the screen had two
ammo panels on it at once, in two different styles, overlapping in the corner.
What each piece now says, rather than merely shows:
ui/crosshair.gd one drawn reticle instead of five rectangles, so it can
BLOOM — open with speed, airtime and each shot, snap shut
on ADS. That is the accuracy readout of the whole game and
five ColorRects could not express it. Every stroke is
drawn twice, ink underneath, because a 2 px white line
disappears over pale concrete exactly when aim matters.
The hit confirmation is the same cross at 45 degrees, so
it lands where the eye already is.
ui/vital_bar.gd segmented, so remaining health can be COUNTED rather than
estimated, with a drain ghost that holds the old value for
a beat — the gap between fill and ghost is the size of the
hit, which a bar that merely gets shorter never tells you.
ui/ability_chip.gd dash and grapple as a wipe across a chip rather than a
tinted JPEG with a 12 px number under it. A shape changing
size is readable in peripheral vision; 12 px type is not.
chain meter promoted out of the debug panel. Movement is this game's
first stated pillar and chaining is its skill expression,
so the count is a score, not a diagnostic.
The numerals moved OFF the bars and beside them. Text centred on a two-tone
bar cannot be given a colour that beats both the fill and the trough — that is
the 2.4:1 debug/ui_contrast_check.gd measured on the old HUD — so this fixes it
at the source rather than leaning on an outline to rescue it.
debug/hud_layout_check.gd measures where every element actually lands, which is
how three real bugs were found rather than squinted at: `set_anchors_preset`
moves the anchors and LEAVES THE OFFSETS, so the reticle spanned the viewport
with a size of exactly (0,0) and drew itself in the top-left corner; a
PRESET_CENTER applied after the ring's own `_ready` undid its centring; and a
BOX CONTAINER's own `alignment` is what pushes content to an edge, not a
SHRINK_END flag on the box, whose minimum width depends on children that may be
hidden. The ammo card was hanging off the right edge of the screen because of
the last one.
DoubleBarrelShotgun now declares `weapon_name` and `max_shells` like every
other weapon, and the four inline `2`s are gone.
spawn smoke 0 failures, 11/11 movement tests, contrast 108/108, layout PASS.
Co-Authored-By: Claude Opus 5 <[email protected]>
226 lines
7.2 KiB
GDScript
226 lines
7.2 KiB
GDScript
extends Node3D
|
|
class_name DoubleBarrelShotgun
|
|
|
|
@export var reload_time: float = 1.0
|
|
|
|
## The name and the capacity every other weapon in the set declares.
|
|
##
|
|
## This one did not, and it had "2" written inline in four places. The HUD had no
|
|
## way to ask how big a full load was, so the level's ammo panel carried a
|
|
## special case — `elif active_weapon is DoubleBarrelShotgun` — to fill in the
|
|
## name, and the capacity was simply hardcoded there as well. Anything that ever
|
|
## wanted to display this weapon had to know about it specifically.
|
|
@export var weapon_name: String = "Double Barrel"
|
|
@export var max_shells: int = 2
|
|
|
|
var shells: int = max_shells
|
|
var reloading: bool = false
|
|
var reload_timer: float = 0.0
|
|
var _vm_kick: float = 0.0
|
|
|
|
var player: CharacterBody3D
|
|
var camera: Camera3D
|
|
var muzzle_flash: OmniLight3D
|
|
var fire_sound: AudioStreamPlayer3D
|
|
var reload_sound: AudioStreamPlayer3D
|
|
var model_root: Node3D
|
|
|
|
func _ready() -> void:
|
|
set_process_input(true)
|
|
set_process(true)
|
|
_build_model()
|
|
|
|
func _build_model() -> void:
|
|
if not model_root:
|
|
model_root = Node3D.new()
|
|
add_child(model_root)
|
|
position = Vector3(0.3, -0.3, -0.690)
|
|
|
|
var model_scene = load("res://assets/weapons/double barrel/source/Double Deuce.fbx")
|
|
if model_scene:
|
|
var model = model_scene.instantiate()
|
|
model_root.add_child(model)
|
|
model.scale = Vector3(0.275, 0.275, 0.275)
|
|
model.rotation_degrees.y = 90
|
|
for light in model.find_children("*", "Light3D", true, false):
|
|
light.queue_free()
|
|
# Muzzle Flash Light
|
|
muzzle_flash = OmniLight3D.new()
|
|
muzzle_flash.light_color = Color(1.0, 0.8, 0.3)
|
|
muzzle_flash.light_energy = 0.0
|
|
muzzle_flash.omni_range = 4.0
|
|
muzzle_flash.position = Vector3(0, 0.03, -0.45)
|
|
model_root.add_child(muzzle_flash)
|
|
|
|
# Audio Players
|
|
fire_sound = AudioStreamPlayer3D.new()
|
|
fire_sound.bus = "SFX"
|
|
fire_sound.stream = AudioManager.stream_for("shotgun_fire", "res://assets/sounds/shotgun_fire.wav")
|
|
fire_sound.position = muzzle_flash.position
|
|
fire_sound.volume_db = -20.0
|
|
add_child(fire_sound)
|
|
|
|
reload_sound = AudioStreamPlayer3D.new()
|
|
reload_sound.bus = "SFX"
|
|
reload_sound.stream = AudioManager.stream_for("shotgun_reload", "res://assets/sounds/shotgun_reload.wav")
|
|
add_child(reload_sound)
|
|
|
|
func _process(delta: float) -> void:
|
|
if reloading:
|
|
reload_timer -= delta
|
|
if reload_timer <= 0.0:
|
|
shells = max_shells
|
|
reloading = false
|
|
|
|
# Big single-shot shove that springs back.
|
|
if not reloading and _vm_kick > 0.001:
|
|
_vm_kick = lerpf(_vm_kick, 0.0, 1.0 - exp(-12.0 * delta))
|
|
ViewmodelAnim.apply_kick(self, _vm_kick, 0.10)
|
|
|
|
func _input(event: InputEvent) -> void:
|
|
if Input.get_mouse_mode() != Input.MOUSE_MODE_CAPTURED:
|
|
return
|
|
|
|
if event.is_action_pressed("fire"):
|
|
_try_fire()
|
|
elif event.is_action_released("fire"):
|
|
_try_fire()
|
|
|
|
if event.is_action_pressed("reload"):
|
|
if shells < max_shells and not reloading:
|
|
_start_reload()
|
|
|
|
func _start_reload() -> void:
|
|
reloading = true
|
|
reload_timer = reload_time
|
|
_vm_kick = 0.0
|
|
ViewmodelAnim.play_reload(self, "break", reload_time)
|
|
if reload_sound:
|
|
reload_sound.play()
|
|
|
|
|
|
## Called by WeaponManager when this weapon is holstered mid-anything.
|
|
func unequip() -> void:
|
|
ViewmodelAnim.stop(self)
|
|
_vm_kick = 0.0
|
|
|
|
func _try_fire() -> void:
|
|
if shells > 0 and not reloading:
|
|
shells -= 1
|
|
print("BANG! Shells left: ", shells)
|
|
_vm_kick = minf(_vm_kick + 1.0, 1.4)
|
|
_apply_impulse()
|
|
_shoot_hitscan()
|
|
_play_muzzle_flash()
|
|
if fire_sound:
|
|
fire_sound.play()
|
|
|
|
# Start reload timer if we just shot the LAST shell
|
|
if shells == 0:
|
|
_start_reload()
|
|
|
|
elif shells == 0 and not reloading:
|
|
# Empty click, force reload
|
|
_start_reload()
|
|
|
|
func _apply_impulse() -> void:
|
|
if not player or not camera:
|
|
return
|
|
|
|
# Get the shotgun impulse parameter from the player's movement controller
|
|
var impulse_strength = 20.0
|
|
if player.has_method("get") and "params" in player:
|
|
if player.params and "shotgun_jump_impulse" in player.params:
|
|
impulse_strength = player.params.shotgun_jump_impulse
|
|
|
|
# Impulse direction is opposite to the camera's forward direction
|
|
var push_dir = camera.global_transform.basis.z.normalized()
|
|
|
|
# Apply impulse to player velocity
|
|
var final_force = push_dir * impulse_strength
|
|
if multiplayer.has_multiplayer_peer() and not(multiplayer.multiplayer_peer is OfflineMultiplayerPeer):
|
|
player.server_apply_impulse.rpc_id(1, final_force)
|
|
else:
|
|
player.apply_impulse(final_force)
|
|
|
|
## This one extends Node3D directly rather than BaseHitscanWeapon, so it needs
|
|
## its own copy. See BaseHitscanWeapon.world_muzzle(): in third person the
|
|
## viewmodel's muzzle sits inside the player's head, so world-visible effects
|
|
## have to come off the gun the character is actually holding.
|
|
func world_muzzle() -> Node3D:
|
|
if player and "third_person" in player and player.third_person \
|
|
and player.has_method("get_visual_model"):
|
|
var vis = player.get_visual_model()
|
|
if vis and vis.has_method("get_muzzle_node"):
|
|
var m: Node3D = vis.get_muzzle_node()
|
|
if m:
|
|
return m
|
|
return muzzle_flash
|
|
|
|
|
|
func _play_muzzle_flash() -> void:
|
|
if not muzzle_flash:
|
|
return
|
|
var m := world_muzzle()
|
|
ExplosionVFX.muzzle_flash(m.get_parent(), m.position)
|
|
if m is OmniLight3D:
|
|
var lit: OmniLight3D = m
|
|
lit.light_energy = 8.0
|
|
create_tween().tween_property(lit, "light_energy", 0.0, 0.1)
|
|
if m != muzzle_flash:
|
|
muzzle_flash.light_energy = 8.0
|
|
create_tween().tween_property(muzzle_flash, "light_energy", 0.0, 0.1)
|
|
|
|
func _shoot_hitscan() -> void:
|
|
if not camera: return
|
|
|
|
var space_state = camera.get_world_3d().direct_space_state
|
|
var origin = camera.global_position
|
|
var forward = -camera.global_transform.basis.z.normalized()
|
|
var right = camera.global_transform.basis.x.normalized()
|
|
var up = camera.global_transform.basis.y.normalized()
|
|
|
|
var num_pellets = 12
|
|
var max_range = 50.0
|
|
var base_dmg = 15.0
|
|
var min_dmg = 3.0
|
|
var falloff_start = 8.0
|
|
var spread_angle = 0.08 # Radians
|
|
|
|
for i in range(num_pellets):
|
|
# Random spread inside cone
|
|
var r = sqrt(randf()) * spread_angle
|
|
var theta = randf() * PI * 2.0
|
|
var offset_dir = (right * cos(theta) + up * sin(theta)) * r
|
|
var pellet_dir = (forward + offset_dir).normalized()
|
|
|
|
var target = origin + pellet_dir * max_range
|
|
|
|
var query = PhysicsRayQueryParameters3D.create(origin, target)
|
|
if player:
|
|
query.exclude = [player.get_rid()]
|
|
|
|
var final_target = target
|
|
var result = space_state.intersect_ray(query)
|
|
if result:
|
|
final_target = result.position
|
|
var hit_dist = origin.distance_to(result.position)
|
|
var damage = base_dmg
|
|
|
|
if hit_dist > falloff_start:
|
|
var falloff_factor = clampf((hit_dist - falloff_start) / (max_range - falloff_start), 0.0, 1.0)
|
|
damage = lerpf(base_dmg, min_dmg, falloff_factor)
|
|
|
|
if result.collider.has_method("apply_impulse"):
|
|
result.collider.apply_impulse(pellet_dir * 2.5) # Apply strong physical force for ragdoll
|
|
|
|
if result.collider.has_method("take_damage"):
|
|
result.collider.take_damage(damage, result.position, player)
|
|
elif result.collider is StaticBody3D or result.collider is CSGShape3D:
|
|
# ImpactSpawner not available in this build, skip
|
|
pass
|
|
|
|
# Cel-styled cosmetic tracer per pellet
|
|
var visual_origin = muzzle_flash.global_position if muzzle_flash else global_position
|
|
HitscanTracer.spawn_bolt(get_tree().current_scene, visual_origin, final_target)
|