Feat/fidelity pass 2 #23

Merged
Dotts merged 7 commits from feat/fidelity-pass-2 into main 2026-07-28 10:31:27 -07:00
70 changed files with 6120 additions and 1784 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 611 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 765 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 246 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 690 KiB

+239
View File
@@ -0,0 +1,239 @@
extends SkeletonModifier3D
class_name DanceModifier
## Drives a dance routine onto the skeleton, over whatever clip is playing.
##
## Runs AFTER the shooter pose layer and blends over it, so starting a dance
## takes the arms off the weapon smoothly rather than cutting, and stopping one
## hands them back the same way. The spring solver runs after both, so hair and
## cloth follow the dance without anything being asked to make that happen.
##
## The routine data — and the reasoning behind why the motion is built the way it
## is — lives in characters/dance_routines.gd. This is the machine that plays it.
## The routine to play. See DanceRoutines.ROUTINES.
var routine: Dictionary = {}
## 0..1. Eased by the owner, so a dance fades in and out rather than snapping.
var weight: float = 0.0
## `<model>.rig.json` roles, for bone resolution. See RigRoles.
var roles: Dictionary = {}
var _idx: Dictionary = {}
var _resolved := false
var _t: float = 0.0
## Per-link overlap, in seconds, from the hips outward. Index 0 is the hips.
const CHAIN := ["DEF-hips", "DEF-spine.001", "DEF-spine.002", "DEF-spine.003"]
func _process_modification() -> void:
var skel := get_skeleton()
if skel == null or weight <= 0.001 or routine.is_empty():
return
if not _resolved:
_idx = RigRoles.resolve(skel, roles)
_resolved = true
var delta := get_physics_process_delta_time() if Engine.is_in_physics_frame() \
else get_process_delta_time()
_t += delta
var bpm: float = float(routine.get("bpm", 100.0))
var beat := _t * bpm / 60.0
var lag: float = float(routine.get("lag", 0.05)) * bpm / 60.0
var w := weight
# ── Hips: the driver ────────────────────────────────────────────────────
#
# Translated, not just rotated. A body that never leaves its own axis reads
# as a puppet on a stick; moving the hips is what makes the legs look like
# they are carrying someone, and every other channel below is a reaction to
# this one.
var swing := _wave(beat, 2.0, 0.0)
var bob := _wave(beat, float(routine.get("hip_bob_beats", 1.0)), 0.25)
var hips: int = _idx.get("DEF-hips", -1)
if hips >= 0:
# Character-right is -X in skeleton space, up is +Y.
var offset := Vector3(-swing * float(routine.get("hip_swing", 0.0)),
bob * float(routine.get("hip_bob", 0.0)), 0.0) * w
_offset_bone(skel, hips, offset)
_add_space(skel, hips,
Quaternion(Vector3(0, 0, 1), swing * float(routine.get("hip_roll", 0.0)) * w)
* Quaternion(Vector3(0, 1, 0), swing * float(routine.get("hip_yaw", 0.0)) * w)
* Quaternion(Vector3(1, 0, 0), bob * float(routine.get("hip_pitch", 0.0)) * w))
# ── Spine: the same motion, later ───────────────────────────────────────
#
# Each link reads the wave at `beat - lag * i`, which is the whole of
# overlapping action. It is one subtraction and it is the difference between
# a rig oscillating and a person moving.
var counter: float = float(routine.get("spine_counter", 0.0))
var links := CHAIN.slice(1)
var n := maxf(links.size(), 1)
for i in links.size():
var b: int = _idx.get(links[i], -1)
if b < 0:
continue
var at := beat - lag * float(i + 1)
var s := _wave(at, 2.0, 0.0)
var v := _wave(at, float(routine.get("hip_bob_beats", 1.0)), 0.25)
var q := Quaternion(Vector3(0, 0, 1),
s * float(routine.get("spine_roll", 0.0)) * w / n) \
* Quaternion(Vector3(0, 1, 0),
(s * float(routine.get("spine_yaw", 0.0))
- s * float(routine.get("hip_yaw", 0.0)) * counter) * w / n) \
* Quaternion(Vector3(1, 0, 0),
v * float(routine.get("spine_pitch", 0.0)) * w / n)
_add_space(skel, b, q)
_head(skel, beat, lag, w)
_arms(skel, beat, lag, w)
_legs(skel, beat, w)
## Head and neck: the last link in the chain, and the one carrying the spot.
func _head(skel: Skeleton3D, beat: float, lag: float, w: float) -> void:
var at := beat - lag * float(CHAIN.size())
var s := _wave(at, 2.0, 0.0)
var v := _wave(at, float(routine.get("hip_bob_beats", 1.0)), 0.25)
var yaw: float = s * float(routine.get("head_yaw", 0.0))
# SPOTTING. A dancer turning keeps their head pointed at one place for as
# long as they can, then whips it round to catch up. It is what stops them
# getting dizzy, and it is the single most recognisable thing about a turn —
# a head that simply rotates with the shoulders reads as a mannequin on a
# turntable.
#
# So this is not a wave. The head COUNTERS the body's yaw exactly while the
# hold lasts, then releases over a short window and lets the neck catch up.
var spot: float = float(routine.get("spot", 0.0))
if spot > 0.001:
var body_yaw: float = s * float(routine.get("hip_yaw", 0.0)) \
+ s * float(routine.get("spine_yaw", 0.0))
# Where in the two-beat turn we are, 0..1.
var u := fposmod(at / 2.0, 1.0)
# Hold for the first 70%, then whip round over the next 20%, then arrive.
var hold := 1.0 - smoothstep(0.70, 0.90, u)
yaw -= body_yaw * spot * hold
var q := Quaternion(Vector3(0, 1, 0), yaw * w) \
* Quaternion(Vector3(0, 0, 1), s * float(routine.get("head_roll", 0.0)) * w) \
* Quaternion(Vector3(1, 0, 0),
(v * float(routine.get("head_bob", 0.0))
+ v * float(routine.get("head_pitch", 0.0))) * w)
# Split, so the whole column leans rather than the skull hinging off a rigid
# neck.
_add_space(skel, _idx.get("DEF-neck", -1), Quaternion.IDENTITY.slerp(q, 0.4))
_add_space(skel, _idx.get("DEF-head", -1), Quaternion.IDENTITY.slerp(q, 0.6))
## Arms: out from the body, swinging in opposition, elbows folding on the beat.
##
## The two arms are half a cycle apart, which is what opposition is. Both read
## the wave later than the spine did, so the hands are the last thing to arrive —
## the end of the chain, where overlap is most visible.
func _arms(skel: Skeleton3D, beat: float, lag: float, w: float) -> void:
var at := beat - lag * float(CHAIN.size() + 1)
var beats: float = float(routine.get("arm_beats", 2.0))
var out: float = float(routine.get("arm_out", 0.0))
var swing: float = float(routine.get("arm_swing", 0.0))
var elbow: float = float(routine.get("elbow", 0.0))
for sign_i in 2:
var right := sign_i == 0
var side := 1.0 if right else -1.0
var s := _wave(at + (0.0 if right else beats * 0.5), beats, 0.0)
var ua: int = _idx.get("DEF-upper_arm." + ("R" if right else "L"), -1)
var fa: int = _idx.get("DEF-forearm." + ("R" if right else "L"), -1)
if ua >= 0:
# Character-right is -X, so a positive Z rotation lifts the LEFT arm
# and drops the right — hence the side flip. `out` is a static lift
# that the swing then rides on top of, which is what stops the arms
# from passing through the body at the bottom of the stroke.
_add_space(skel, ua,
Quaternion(Vector3(0, 0, 1), -side * (out + s * 0.35 * swing) * w)
* Quaternion(Vector3(1, 0, 0), s * swing * w))
if fa >= 0:
# The elbow only ever folds, never hyperextends: a signed wave here
# bends the forearm backwards through the joint on half of every
# cycle, which is the most obvious possible tell.
var fold := (0.5 + 0.5 * s) * elbow
_add_space(skel, fa, Quaternion(Vector3(1, 0, 0), fold * w))
## Legs: knees absorbing the bob, out of phase with each other so the weight
## visibly transfers from one to the other.
func _legs(skel: Skeleton3D, beat: float, w: float) -> void:
var knee: float = float(routine.get("knee", 0.0))
if knee <= 0.001:
return
var beats: float = float(routine.get("hip_bob_beats", 1.0))
for sign_i in 2:
var right := sign_i == 0
var s := _wave(beat + (0.0 if right else beats * 0.5), beats, 0.25)
var thigh: int = _idx.get("DEF-thigh." + ("R" if right else "L"), -1)
var shin: int = _idx.get("DEF-shin." + ("R" if right else "L"), -1)
var bend := (0.5 + 0.5 * s) * knee
# Thigh forward and shin back by twice as much, so the foot stays roughly
# under the hip instead of the whole leg swinging out in front.
_add_space(skel, thigh, Quaternion(Vector3(1, 0, 0), -bend * 0.5 * w))
_add_space(skel, shin, Quaternion(Vector3(1, 0, 0), bend * w))
# ── The wave ─────────────────────────────────────────────────────────────────
## One channel's value at `beat`, in -1..1.
##
## `shape` bends a sine so it HANGS at the extremes and SNAPS between them, which
## is what an animator's key-and-breakdown pass produces and what a raw sine
## cannot. At shape 1 this is exactly `sin`; above it the curve gets punchier
## while staying continuous and staying in -1..1, so no amount of shaping can
## make a channel overshoot its authored amplitude.
##
## `steps` quantises the result instead, for the robot — the one routine whose
## whole point is that it does NOT move like the others.
func _wave(beat: float, beats: float, phase: float) -> float:
if beats <= 0.001:
return 0.0
var u := beat / beats + phase
var steps: int = int(routine.get("steps", 0))
if steps > 0:
# Hold a value for a whole step, then jump. Rounded rather than floored
# so the extremes are actually reached — a floor never returns +1.
return sin(TAU * (round(u * float(steps)) / float(steps)))
var s := sin(TAU * u)
var shape: float = float(routine.get("shape", 1.0))
if is_equal_approx(shape, 1.0):
return s
return signf(s) * pow(absf(s), 1.0 / shape)
# ── Bone plumbing ────────────────────────────────────────────────────────────
## Move a bone by an offset expressed in SKELETON space.
##
## A bone's pose position is in its PARENT's space, so the offset has to be
## rotated out of skeleton space by the parent's rest basis first. Skipping that
## sends the hips sideways in whatever direction the rig happens to have called
## "x", which differs per character.
func _offset_bone(skel: Skeleton3D, idx: int, offset: Vector3) -> void:
if idx < 0 or offset == Vector3.ZERO:
return
var parent := skel.get_bone_parent(idx)
var local := offset
if parent >= 0:
local = skel.get_bone_global_rest(parent).basis.inverse() * offset
skel.set_bone_pose_position(idx, skel.get_bone_rest(idx).origin + local)
## Compose a skeleton-space rotation onto a bone's animated local pose. Same
## contract as ShooterPoseModifier._add_space.
func _add_space(skel: Skeleton3D, idx: int, q_space: Quaternion) -> void:
if idx < 0:
return
var b := skel.get_bone_global_rest(idx).basis.get_rotation_quaternion()
var local := b.inverse() * q_space * b
skel.set_bone_pose_rotation(idx, skel.get_bone_pose_rotation(idx) * local)
+1
View File
@@ -0,0 +1 @@
uid://bab4y7ba0yl87
+204
View File
@@ -0,0 +1,204 @@
extends Object
class_name DanceRoutines
## The five emotes, as data.
##
## They are procedural rather than authored clips because the shared animation
## library ships exactly one `Dance_Loop`, and five copies of one clip is not
## five dances. What the runtime DOES have is a working procedural pose layer
## over a real skeleton with spring-driven hair and cloth, which is enough to
## build a dance out of if the motion is constructed the way an animator would
## construct it rather than the way a programmer reaches for first.
##
## ── Why not just wire sine waves to the bones ───────────────────────────────
##
## Because that is what "programmer animation" looks like, and everyone can tell.
## A raw sine moves fastest through the middle and slowest at the ends by exactly
## the same amount on every channel, all in phase, forever. The result floats. It
## has no weight, no accent, and no sense that one part of the body is driving and
## the rest is following.
##
## Four principles fix that, and all four are cheap:
##
## OVERLAP the body is a chain, and a chain does not move as one piece.
## Hips lead, spine follows a beat later, chest later still,
## head last. `lag` below is that, in seconds per link. It is
## the single largest difference between "a rig oscillating"
## and "a person moving", and the spring bones then carry it
## out through the hair and the skirt for free.
## ACCENT a dance HITS poses. `shape` bends the wave so it hangs at
## the extremes and snaps between them — the same asymmetry a
## key-and-breakdown pass produces by hand. `sin` is shape 1.0;
## above that it gets punchier.
## WEIGHT a body that never leaves its own axis reads as a puppet. Real
## dances move the HIPS — side to side, up and down — and the
## rest of the body reacts. `hip_swing` and `hip_bob` are
## translations, not rotations, and they are what make the legs
## look like they are carrying someone.
## ANTICIPATION the counter-move before the move. Handled per routine by
## running a channel at a fraction of a beat AHEAD of the one it
## precedes, rather than by a separate mechanism.
##
## ── The shape of a routine ──────────────────────────────────────────────────
##
## Everything is derived from one `bpm`, so no two channels can drift apart no
## matter how long the emote runs — which is the other thing that goes wrong when
## channels are given independent frequencies that are not exact ratios.
##
## Channel amounts are radians (rotations) or metres (the two hip translations).
## `beats` is how many beats that channel takes for one full cycle, so 2 is a
## side-to-side that takes two beats to return, 1 is once per beat, and 0.5 is
## twice per beat. Fractions of a beat are how a routine gets a cross-rhythm
## without leaving the grid.
## `id` is what gets networked, so these strings must stay stable.
const ROUTINES := [
{
"id": "two_step",
"name": "Two-Step",
"icon": "",
"bpm": 104.0,
"lag": 0.055,
"shape": 1.35,
# The foundation step: weight rocks side to side, the shoulders counter
# the hips, the arms hang and swing off the shoulders a beat behind.
"hip_swing": 0.075,
"hip_bob": 0.022,
"hip_bob_beats": 1.0,
"hip_roll": 0.16,
"hip_yaw": 0.20,
"spine_roll": 0.13,
"spine_counter": 0.55,
"head_roll": 0.22,
"head_bob": 0.10,
"arm_swing": 0.55,
"arm_out": 0.42,
"arm_beats": 2.0,
"elbow": 0.75,
"knee": 0.30,
},
{
"id": "body_wave",
"name": "Body Wave",
"icon": "",
"bpm": 88.0,
# The whole point of this one is the lag: a wave travelling up the spine
# IS overlap, made visible. At 0.13 s per link the crest takes most of a
# beat to get from the hips to the head.
"lag": 0.13,
"shape": 1.15,
"hip_swing": 0.03,
"hip_bob": 0.045,
"hip_bob_beats": 2.0,
"hip_pitch": 0.26,
"spine_pitch": 0.30,
"spine_counter": 0.0,
"head_pitch": 0.22,
"head_roll": 0.06,
"arm_out": 0.85,
"arm_swing": 0.20,
"arm_beats": 4.0,
"elbow": 0.55,
"knee": 0.18,
},
{
"id": "robot",
"name": "Robot",
"icon": "",
"bpm": 112.0,
# No lag, and the motion is QUANTISED — see `steps`. Both are deliberate
# violations of everything above, and they work for exactly that reason:
# the robot reads as mechanical because the viewer has been shown four
# other routines that do not.
"lag": 0.0,
"shape": 1.0,
"steps": 4,
"hip_swing": 0.04,
"hip_bob": 0.012,
"hip_bob_beats": 1.0,
"hip_yaw": 0.30,
"spine_yaw": 0.34,
"spine_counter": 0.0,
"head_yaw": 0.42,
"arm_out": 1.05,
"arm_swing": 0.85,
"arm_beats": 2.0,
"elbow": 1.35,
"knee": 0.10,
},
{
"id": "bounce",
"name": "Bounce",
"icon": "",
"bpm": 128.0,
"lag": 0.035,
# The punchiest shape in the set. A bounce lives entirely in the accent:
# the body hangs at the top and slams through the bottom, which is a
# gravity read, and a plain sine cannot express it.
"shape": 2.2,
"hip_swing": 0.03,
"hip_bob": 0.070,
"hip_bob_beats": 1.0,
"hip_roll": 0.08,
"spine_pitch": 0.14,
"spine_counter": 0.30,
"head_bob": 0.16,
"head_roll": 0.10,
"arm_swing": 0.95,
"arm_out": 0.30,
"arm_beats": 1.0,
"elbow": 1.05,
# Deep knees. This is the routine where the legs do the work, and a bounce
# with straight legs looks like a character being shaken.
"knee": 0.85,
},
{
"id": "spin",
"name": "Spin",
"icon": "",
"bpm": 96.0,
"lag": 0.07,
"shape": 1.5,
"hip_swing": 0.05,
"hip_bob": 0.030,
"hip_bob_beats": 2.0,
"hip_yaw": 0.85,
"spine_yaw": 0.30,
"spine_counter": 0.0,
"spine_roll": 0.14,
# SPOTTING: the head holds its heading while the body turns under it, then
# whips round to catch up. It is what a real dancer does to keep from
# getting dizzy, and it is the most recognisable thing about a turn. See
# `spot` in DanceModifier — this is not a wave, it is a hold and a snap.
"spot": 1.0,
"head_yaw": 0.0,
"arm_out": 1.15,
"arm_swing": 0.25,
"arm_beats": 4.0,
"elbow": 0.35,
"knee": 0.22,
},
]
static func count() -> int:
return ROUTINES.size()
## A routine by index, wrapped so an out-of-range network value cannot crash a
## remote peer's model.
static func get_routine(index: int) -> Dictionary:
if ROUTINES.is_empty():
return {}
return ROUTINES[posmod(index, ROUTINES.size())]
static func name_of(index: int) -> String:
return String(get_routine(index).get("name", ""))
static func index_of(id: String) -> int:
for i in ROUTINES.size():
if ROUTINES[i]["id"] == id:
return i
return 0
+1
View File
@@ -0,0 +1 @@
uid://8p4yow40ni8p
+56
View File
@@ -0,0 +1,56 @@
extends Object
class_name RigRoles
## Canonical bone names -> this rig's actual bone indices.
##
## Pulled out of `ShooterPoseModifier._resolve` so the dance layer can use the
## same resolution instead of carrying a second copy of it. Two copies is how a
## rig ends up animating correctly under one modifier and not the other.
##
## Non-negotiable: never look a bone up by name. `tools/rig_map.py` resolves
## every rig to ROLES and writes them to the `<model>.rig.json` sidecar, and this
## reads that. The name fallback below exists only for a model with no sidecar,
## and it must never be the first thing tried — four characters could not hold a
## gun because of exactly one hardcoded spelling.
## The library skeleton's spine, hips first. A rig that kept its own names maps
## onto this through the sidecar's `spine` chain.
const SPINE := ["DEF-hips", "DEF-spine.001", "DEF-spine.002", "DEF-spine.003"]
## Everything either pose layer asks for.
const CANONICAL := SPINE + ["DEF-neck", "DEF-head",
"DEF-upper_arm.R", "DEF-forearm.R", "DEF-hand.R",
"DEF-upper_arm.L", "DEF-forearm.L", "DEF-hand.L",
"DEF-thigh.R", "DEF-shin.R", "DEF-thigh.L", "DEF-shin.L"]
## Resolve `names` against a skeleton, given the sidecar's role table.
##
## The spine needs its own handling because a rig that kept its own skeleton
## names things differently AND has a different number of spine bones — Taila's
## hips are `DEF-spine` and her head is `DEF-spine.006`, and she has no bone with
## "neck" in its name at all. Unresolved, every lean and aim pitch silently did
## nothing.
static func resolve(skel: Skeleton3D, roles: Dictionary,
names: Array = CANONICAL) -> Dictionary:
var alias := {}
if not roles.is_empty():
var neck: String = roles.get("neck", "")
var head: String = roles.get("head", "")
var torso: Array = []
for n in roles.get("spine", []):
if n != neck and n != head:
torso.append(n)
for i in mini(torso.size(), SPINE.size() - 1):
alias[SPINE[i + 1]] = torso[i]
var idx := {}
for n in names:
# Canonical names are the role keys with the DEF- prefix, so the limbs,
# hips, neck and head all map straight through.
var actual: String = alias.get(n, roles.get(String(n).trim_prefix("DEF-"), n))
var b := skel.find_bone(actual)
if b < 0:
b = skel.find_bone(String(n))
idx[n] = b
return idx
+1
View File
@@ -0,0 +1 @@
uid://coh341vhya5x5
+167 -43
View File
@@ -96,7 +96,12 @@ var _rig_info: Dictionary = {}
## the same sidecar; drives the per-class cel look and answers `surfaces_of()`.
var _surfaces: SkinSurfaces = null
var _spring_mod: SpringBones
## The emote layer, between the shooter pose and the cloth springs.
var _dance_mod: DanceModifier
var is_holding_weapon: bool = false
## Which hold archetype the equipped weapon uses — see WeaponHoldProfiles. Read
## by `_process` (a blade releases the off arm) and by the checks.
var hold_style: String = WeaponHoldProfiles.RIFLE
## Which skin this is, so per-character hold tuning can be looked up. Set by
## whoever spawns the model; falls back to the GLB's basename.
var skin_id: String = ""
@@ -239,8 +244,17 @@ func load_model(path: String) -> void:
_pose_mod.fingers = _rig_info.get("fingers", {})
_pose_mod.name = "ShooterPose"
skeleton.add_child(_pose_mod)
# Dance AFTER the shooter pose, so an emote blends over the weapon hold
# instead of fighting it — the arms come off the gun as the dance weight
# rises and are handed back the same way.
_dance_mod = DanceModifier.new()
_dance_mod.name = "Dance"
_dance_mod.roles = _rig_info.get("roles", {})
skeleton.add_child(_dance_mod)
# Cloth and hair last, so the springs react to the FINAL body pose —
# animation plus the shooter lean/slide layer.
# animation plus the shooter lean/slide layer, plus any dance. Hair and
# a skirt following a dance is entirely this ordering; nothing else is
# needed to make it happen.
if not _rig_info.is_empty():
_spring_mod = SpringBones.new()
_spring_mod.name = "SpringBones"
@@ -550,6 +564,14 @@ func _set_shadow_mode_recursive(node: Node, mode: int) -> void:
var _prev_state: String = ""
var _oneshot_lock: float = 0.0 # seconds left where a one-shot owns playback
var _dancing: bool = false
## Which of DanceRoutines.ROUTINES is playing, and the eased 0..1 blend of the
## dance layer over everything below it.
var _dance_index: int = 0
var _cur_dance: float = 0.0
## How fast a dance takes the body and gives it back. Slower than the pose
## layer's other blends on purpose: an emote starting is not a reaction, and
## snapping into one looks like a bug rather than like a decision.
const DANCE_SMOOTH := 5.0
## Play a one-shot clip over locomotion for `lock_time` seconds.
@@ -574,8 +596,16 @@ func play_oneshot(canonical: String, lock_time: float = 0.35) -> void:
## Emote toggle (Dance). Shown while grounded and near-idle; any real
## movement breaks it (the controller clears the flag too).
func set_dancing(on: bool) -> void:
##
## `which` selects one of the five routines in DanceRoutines. The base clip stays
## the library's single `Dance_Loop` underneath — the routine is layered over it
## by DanceModifier, which is what makes five distinct emotes out of one clip.
func set_dancing(on: bool, which: int = -1) -> void:
_dancing = on
if which >= 0:
_dance_index = which
if _dance_mod:
_dance_mod.routine = DanceRoutines.get_routine(_dance_index)
## Play a named gameplay action (reload / throw / shoot) as a one-shot.
@@ -731,6 +761,12 @@ func _process(delta: float) -> void:
_cur_slide = lerpf(_cur_slide, slide_target, t)
var wall_target := _target_wall if _pose_mod.state == "wall_run" else 0.0
_cur_wall = lerpf(_cur_wall, wall_target, lean_t)
# The dance blend. Eased both ways, so an emote arrives and leaves rather
# than cutting — and so the arms come off the weapon smoothly.
if _dance_mod:
_cur_dance = lerpf(_cur_dance, 1.0 if _dancing else 0.0,
1.0 - exp(-DANCE_SMOOTH * delta))
_dance_mod.weight = _cur_dance
_update_travel(delta, drive)
_pose_mod.strafe = _cur_strafe
_pose_mod.fwd = _cur_fwd
@@ -782,7 +818,12 @@ func _process(delta: float) -> void:
var hold_l := 0.0
if is_holding_weapon and not clip_owns_arms:
hold_r = 1.0
hold_l = 1.0
# A one-handed weapon RELEASES the off arm back to the animation, so it
# swings with the run cycle instead of gripping a handguard that is not
# there. That released arm is most of what makes a knife read as a knife
# from across a map.
hold_l = 0.0 if _pose_mod.support_mode == WeaponHoldProfiles.SUPPORT_FREE \
else 1.0
match st:
"slide":
hold_l = 0.0 # trailing arm braces the ground
@@ -1018,12 +1059,27 @@ func set_weapon(script_path: String) -> void:
is_holding_weapon = script_path != ""
if script_path == "" or not skeleton:
return
# Per-character, per-weapon hold overrides, if any have been tuned. Empty is
# the normal case and means "use what the code derives".
# How this KIND of weapon is held, then the per-character tuning on top.
#
# The profile is a defaults layer: it answers "what sort of thing is this"
# for a weapon nobody has tuned, and every knob an artist saved in the rig lab
# still wins, because the JSON is merged over it with overwrite. Aria's
# hand-tuned AK-47 hold is byte-for-byte what it was.
var sid := skin_id if skin_id != "" else model_path.get_file().get_basename()
var weapon_id := script_path.get_file().get_basename()
if hold_tune.is_empty():
hold_tune = WeaponHoldTuning.resolve(WeaponHoldTuning.load_all(), sid,
script_path.get_file().get_basename())
hold_tune = WeaponHoldProfiles.knobs_for(weapon_id)
hold_tune.merge(WeaponHoldTuning.resolve(WeaponHoldTuning.load_all(),
sid, weapon_id), true)
if _pose_mod:
# Structural, not tunable — see WeaponHoldProfiles. These decide where the
# off hand goes and how it is turned there, and whether the head comes
# down to the stock, which no slider on the rifle solve could express.
var style := WeaponHoldProfiles.style_for(weapon_id)
_pose_mod.support_mode = WeaponHoldProfiles.support_for(weapon_id)
_pose_mod.cheek = WeaponHoldProfiles.cheek_for(weapon_id)
_pose_mod.full_fist = style == WeaponHoldProfiles.BLADE
hold_style = WeaponHoldProfiles.style_for(weapon_id)
# Anchors are per character, not per weapon — where a grip sits in a palm is
# a fact about the hand — so unlike hold_tune they are not re-read per gun
# unless the lab has pushed a live set in.
@@ -1297,6 +1353,16 @@ class ShooterPoseModifier extends SkeletonModifier3D:
var gun_stock: float = 0.20
# 0..1 through a reload — drives the support hand to the mag well and back.
var reload_phase: float = 0.0
# What KIND of weapon is being held. See WeaponHoldProfiles: these three are
# the differences a slider cannot express, and they are what make a launcher
# read as a launcher rather than as a very large rifle.
var support_mode: String = WeaponHoldProfiles.SUPPORT_BARREL
## How far the head comes down and across to the stock, 0..1. Negative leans
## it away, which is what a tube over the shoulder needs.
var cheek: float = 0.0
## Whether the trigger finger closes with the rest. True for a blade, which
## has nothing to keep a finger straight along.
var full_fist: bool = false
# [child_bone, helper_bone] pairs; see SkinJointHelper.
var joint_helpers: Array = []
@@ -1319,7 +1385,9 @@ class ShooterPoseModifier extends SkeletonModifier3D:
const WALL_ARM_OUT := 0.9 # inner arm reaches out to touch the wall
const HOLD_SMOOTH := 8.0 # how fast the hold takes/releases the arms
const SPINE := ["DEF-hips", "DEF-spine.001", "DEF-spine.002", "DEF-spine.003"]
## The library skeleton's spine, hips first. Shared with the dance layer via
## RigRoles, which also owns the mapping onto a rig that kept its own names.
const SPINE := RigRoles.SPINE
var _idx: Dictionary = {}
var _resolved := false
@@ -1395,33 +1463,13 @@ class ShooterPoseModifier extends SkeletonModifier3D:
func _resolve() -> void:
var skel := get_skeleton()
var names := SPINE + ["DEF-neck", "DEF-head",
"DEF-upper_arm.R", "DEF-forearm.R", "DEF-hand.R",
"DEF-upper_arm.L", "DEF-forearm.L", "DEF-hand.L",
"DEF-thigh.R", "DEF-shin.R", "DEF-thigh.L", "DEF-shin.L"]
# The names above are the LIBRARY skeleton's. A model that kept its own
# rig names things differently and three of them simply do not exist on
# it — Taila's hips are DEF-spine, her head is DEF-spine.006, and she has
# no bone with "neck" in its name at all. Unresolved, every lean, aim
# pitch and slide head-lift below silently did nothing.
var alias := {}
if not roles.is_empty():
var neck: String = roles.get("neck", "")
var head: String = roles.get("head", "")
var torso: Array = []
for n in roles.get("spine", []):
if n != neck and n != head:
torso.append(n)
for i in mini(torso.size(), SPINE.size() - 1):
alias[SPINE[i + 1]] = torso[i]
for n in names:
# Canonical names are the role keys with the DEF- prefix, so the
# limbs, hips, neck and head all map straight through.
var actual: String = alias.get(n, roles.get(n.trim_prefix("DEF-"), n))
var b := skel.find_bone(actual)
if b < 0:
b = skel.find_bone(n)
_idx[n] = b
# The canonical names are the LIBRARY skeleton's, and a model that kept
# its own rig names things differently — Taila's hips are DEF-spine, her
# head is DEF-spine.006, and she has no bone with "neck" in its name at
# all. Unresolved, every lean, aim pitch and slide head-lift below
# silently did nothing. RigRoles maps them through the sidecar; the dance
# layer uses the same call rather than a second copy of it.
_idx = RigRoles.resolve(skel, roles)
_resolve_hands(skel)
_resolved = true
@@ -1514,6 +1562,8 @@ class ShooterPoseModifier extends SkeletonModifier3D:
_apply_grapple(skel)
if _hold_r > 0.01 or _hold_l > 0.01:
_apply_rifle_hold(skel)
if absf(cheek) > 0.01:
_apply_cheek(skel)
_close_hands(skel)
if recoil > 0.01:
_apply_recoil(skel)
@@ -1763,9 +1813,22 @@ class ShooterPoseModifier extends SkeletonModifier3D:
dbg_fore = fore_pos
dbg_stock = stock_pos
# 3. Support hand goes to the mag well during a reload (under the
# receiver — the correct side), otherwise to the handguard.
# 3. Where the SUPPORT hand goes, which is most of what tells a viewer
# what is being held. See WeaponHoldProfiles.
#
# All four modes reuse the geometry above — the weapon is still placed
# first and the arms still solved onto it — they differ in which point
# on it the off hand is sent to.
var l_target := fore_pos
match support_mode:
WeaponHoldProfiles.SUPPORT_CUPPED:
# Both hands together on the grip. Down and to the character's
# LEFT of the firing fist (`side` is character-right), so the two
# hands stack rather than collide.
l_target = grip_pos + gun_basis * Vector3(-0.048, -0.038, 0.012)
WeaponHoldProfiles.SUPPORT_TUBE:
# Hooked under the tube from below, forward of the shoulder.
l_target = fore_pos - gun_up * 0.075
if reload_phase > 0.001:
var mag_well := grip_pos + aim_dir * (gun_fore * 0.35) - gun_up * 0.10
var drop := mag_well - gun_up * 0.22 - aim_dir * 0.05
@@ -1853,10 +1916,32 @@ class ShooterPoseModifier extends SkeletonModifier3D:
and _hand_frame.has("L"):
var hand_l: int = _idx.get("DEF-hand.L", -1)
if hand_l >= 0:
# -aim_dir so the hand comes at the handguard from the body side
# rather than reaching over it backwards.
var want := Basis(gun_up.cross(-aim_dir).normalized(), gun_up,
-aim_dir)
# The hand's target frame, as (along, palm, curl) — the same
# three axes `_hand_frame` measured off the rest pose.
#
# Which way the PALM faces and which axis the fingers CURL about
# is the whole difference between wrapping a handguard, cupping a
# fist and hooking under a tube. Sending the hand to a different
# POSITION without changing its orientation gives a hand that has
# been teleported to the new spot still shaped for the old one.
#
# -aim_dir throughout, so the hand comes at the weapon from the
# body side rather than reaching over it backwards.
var palm := gun_up
var curl := -aim_dir
match support_mode:
WeaponHoldProfiles.SUPPORT_CUPPED:
# Palm presses inward against the grip's exposed panel;
# fingers still close along the barrel, over the firing
# hand's.
palm = side
WeaponHoldProfiles.SUPPORT_TUBE:
# A vertical foregrip: the palm faces BACK toward the
# body and the fingers close about the handle's own
# up-axis, not about the tube.
palm = -aim_dir
curl = gun_up
var want := Basis(palm.cross(curl).normalized(), palm, curl)
# Then the artist's wrist. Rolling about the barrel is the one
# axis a hand wrapping a cylinder is genuinely free in, and it
# used to be the only one offered — which left no way to cock the
@@ -1871,6 +1956,42 @@ class ShooterPoseModifier extends SkeletonModifier3D:
* skel.get_bone_global_rest(hand_l).basis.get_rotation_quaternion()
_set_global_rot(skel, hand_l, g_fa_l, g_hand.normalized(), _hold_l)
# ── The head against the stock ───────────────────────────────────────────
#
# A cheek weld is the single most recognisable thing about how a marksman
# holds a rifle, and its inverse — the head leaning AWAY — is what says a tube
# is resting on that shoulder. Both are silhouette at any distance, long after
# the weapon mesh itself has become a few pixels.
#
# Three axes, all toward the right shoulder, because that is where the weapon
# is: down onto the comb, rolled over it, and turned slightly along it.
## Full-weld amounts, in radians, at cheek = 1.
const CHEEK_PITCH := 0.20 # down onto the comb
const CHEEK_ROLL := 0.26 # over toward the shoulder
const CHEEK_YAW := 0.09 # turned along the stock
## How much of the weld is present at low ready. Not zero: the pose has to
## read before the character shoulders the weapon, and a marksman carrying a
## rifle already holds their head differently from someone carrying a knife.
const CHEEK_HIP := 0.35
func _apply_cheek(skel: Skeleton3D) -> void:
var k: float = cheek * lerpf(CHEEK_HIP, 1.0, ads) * _hold_r
if absf(k) < 0.005:
return
# Positive X pitches the head DOWN and positive Z rolls it toward the
# character's right — the same sign conventions as the aim pitch and the
# wall-run lean respectively.
var q := Quaternion(Vector3(1, 0, 0), CHEEK_PITCH * k) \
* Quaternion(Vector3(0, 0, 1), CHEEK_ROLL * k) \
* Quaternion(Vector3(0, 1, 0), CHEEK_YAW * k)
# Split across neck and head so the whole column leans rather than the
# skull hinging off a rigid neck. Weighted toward the head, which is what
# actually happens when someone lowers a cheek onto a stock.
_add_space(skel, _idx.get("DEF-neck", -1),
Quaternion.IDENTITY.slerp(q, 0.35))
_add_space(skel, _idx.get("DEF-head", -1),
Quaternion.IDENTITY.slerp(q, 0.65))
# How far each segment of a finger closes, knuckle -> tip, in radians.
#
# Two different grips. The SUPPORT hand wraps a handguard, so all four
@@ -1904,8 +2025,11 @@ class ShooterPoseModifier extends SkeletonModifier3D:
PackedInt32Array())
if bones.is_empty():
continue
# The right index rides the trigger; everything else wraps.
var trigger: bool = side == "R" and digit == "index"
# The right index rides the trigger; everything else wraps. A blade
# has no trigger, and an index left straight along a knife handle
# reads as a mistake rather than as discipline — so a full fist
# closes every finger the same.
var trigger: bool = side == "R" and digit == "index" and not full_fist
var amount: Array = CURL_TRIGGER if trigger else CURL_WRAP
var scale: float = _t("curl_trigger", 1.0) if trigger else _t("curl_wrap", 1.0)
for i in bones.size():
+13 -1
View File
@@ -169,7 +169,19 @@ static func all_knobs() -> Array:
##
## Across ALL poses, not just the one on screen: a reset or a save has to know
## what `pocket_ads` defaults to even while low ready is being adjusted.
static func default_for(key: String):
##
## WEAPON-AWARE, and it has to be. The hold profile for a weapon class supplies
## better defaults than the generic spec table — a knife's stock pocket is at the
## hip, a launcher's is above the shoulder — and the lab SAVES every knob it
## shows, not just the ones that were moved. Without the weapon here, opening the
## lab on the knife and pressing save would silently overwrite the blade profile
## with the rifle spec's values, and the character would go back to holding a
## knife like an AK with no indication that anything had happened.
static func default_for(key: String, weapon_id: String = ""):
if weapon_id != "":
var profile := WeaponHoldProfiles.knobs_for(weapon_id)
if profile.has(key):
return profile[key]
return TuningStore.default_for(all_knobs(), key)
+1 -1
View File
@@ -51,7 +51,7 @@ func _process(_delta: float) -> bool:
sm.set_active_skin(_skin)
var nm = root.get_node_or_null("NetworkManager")
if nm and nm.has_method("start_singleplayer_match"):
nm.start_singleplayer_match("Deathmatch")
nm.start_singleplayer_match(GameMode.DEATHMATCH)
change_scene_to_file("res://scenes/maps/test_level/test_level.tscn")
return false
if _frames < 160:
+435
View File
@@ -0,0 +1,435 @@
extends SceneTree
## Do the five emotes move the character, differ from each other, and OVERLAP?
##
## godot --path . -s res://debug/dance_check.gd
##
## Three properties, and the third is the one worth checking. "It moves" and
## "they are different" are easy to satisfy by accident — five sine waves at five
## frequencies would pass both and would still look like programmer animation.
## What separates a dance from an oscillation is that the body moves as a CHAIN:
## the hips lead and the head arrives later. That is measurable, so it is.
##
## Sampled from inside the modifier pass, like every other pose check here.
## Outside it Godot restores the local poses and what gets measured is the
## animation clip alone — every routine would report identical motion whether or
## not the dance layer exists at all.
const CAPTURE_BEATS := 4.0
const SAMPLES := 90
## A routine has to move the character at least this far, in metres of total
## head travel over the sample window. Below this it is not an emote.
const MIN_TRAVEL := 0.05
## Two routines must differ by at least this, comparing their per-frame pose
## trajectories.
const MIN_DISTINCT := 0.02
var _fails := 0
var _probe: Probe = null
class Probe extends SkeletonModifier3D:
var pose: Array = []
## Each bone's OWN local pose rotation, which is what a phase measurement
## needs. A bone's GLOBAL rotation contains every ancestor's rotation too, so
## the head's global carries the hips' un-lagged swing as a large component
## and correlates with it at a lag of zero no matter how much the head itself
## is delayed. Measuring globals reported Two-Step as having no overlap at
## all when its head is delayed by five links.
var local: Array = []
func _process_modification() -> void:
var skel := get_skeleton()
if skel == null:
return
pose.resize(skel.get_bone_count())
local.resize(skel.get_bone_count())
for i in skel.get_bone_count():
pose[i] = skel.get_bone_global_pose(i)
local[i] = skel.get_bone_pose_rotation(i)
func _init() -> void:
await process_frame
var mgr = root.get_node_or_null("SkinManager")
var skin = mgr.get_skin("taila") if mgr else null
if skin == null or skin.model_path == "":
print("dance_check: no rigged skin to test with")
quit(1)
return
var model := SkinnedPlayerModel.new()
root.add_child(model)
model.skin_id = "taila"
model.load_model(skin.model_path)
for _i in 60:
model.update_state("idle", 0.0, false)
await process_frame
if model._dance_mod == null:
_expect(false, "the model built a dance layer")
_done()
return
var skel: Skeleton3D = model.skeleton
_probe = Probe.new()
skel.add_child(_probe)
skel.move_child(_probe, skel.get_child_count() - 1)
_expect(DanceRoutines.count() == 5,
"there are five emotes (%d)" % DanceRoutines.count())
var tracks := {}
for i in DanceRoutines.count():
tracks[i] = await _sample(model, skel, i)
_report(tracks)
_compare(tracks)
_done()
## One routine's trajectory: the hips' and head's positions, per frame, in the
## character's own space, plus the elbow angles for the joint-limit check.
func _sample(model, skel: Skeleton3D, index: int) -> Dictionary:
model.set_dancing(true, index)
# Let the blend arrive fully before recording, or the first routine sampled
# reports smaller motion than the rest purely because it was still fading in.
for _i in 40:
model.update_state("idle", 0.0, false)
await process_frame
var mod = model._pose_mod
var hips: int = mod._idx.get("DEF-hips", -1)
var head: int = mod._idx.get("DEF-head", -1)
# Overlap is measured between two links of the SAME chain, not between the
# hips and the head.
#
# Every spine link is driven by the same channels (`spine_roll`, `spine_yaw`,
# `spine_pitch`) at `beat - lag * i`, so the only difference between them IS
# the lag. The hips and the head are driven by DIFFERENT channels, often at
# different periods — Two-Step's hips roll on a two-beat cycle while its head
# bobs on a one-beat one — so correlating those two compares signals that do
# not have a phase relationship to find.
var link_a: int = mod._idx.get("DEF-spine.001", -1)
var link_b: int = mod._idx.get("DEF-spine.003", -1)
var fa_r: int = mod._idx.get("DEF-forearm.R", -1)
var ua_r: int = mod._idx.get("DEF-upper_arm.R", -1)
var hand_r: int = mod._idx.get("DEF-hand.R", -1)
var hip_track: Array = []
var head_track: Array = []
# The SAME quantity at two points in the chain: how far each bone has been
# rotated away from its own rest pose, signed. Correlating the hips' world
# TRANSLATION against the head's position RELATIVE to the hips was comparing
# two different physical quantities driven by different channels at different
# periods, and the peak landed anywhere — it reported the Robot, whose lag is
# zero by construction, as the most overlapped routine in the set.
var hip_rot: Array = []
var head_rot: Array = []
var worst_elbow := 180.0
for _i in SAMPLES:
model.update_state("idle", 0.0, false)
await process_frame
var pose: Array = _probe.pose
if pose.size() <= maxi(hips, head) or hips < 0 or head < 0:
continue
var origin: Vector3 = pose[hips].origin
hip_track.append(origin)
head_track.append(pose[head].origin - origin)
hip_rot.append(_twist(skel, _probe.local, link_a))
head_rot.append(_twist(skel, _probe.local, link_b))
# The elbow must never open past straight. A signed wave on a forearm
# bends it backwards through the joint on half of every cycle, which is
# the single most obvious tell in procedural animation.
if ua_r >= 0 and fa_r >= 0 and hand_r >= 0 and pose.size() > hand_r:
var upper: Vector3 = (pose[ua_r].origin - pose[fa_r].origin).normalized()
var lower: Vector3 = (pose[hand_r].origin - pose[fa_r].origin).normalized()
worst_elbow = minf(worst_elbow, rad_to_deg(acos(clampf(
upper.dot(lower), -1.0, 1.0))))
model.set_dancing(false)
for _i in 30:
model.update_state("idle", 0.0, false)
await process_frame
return {"hips": hip_track, "head": head_track, "elbow": worst_elbow,
"hip_rot": hip_rot, "head_rot": head_rot}
func _report(tracks: Dictionary) -> void:
print("\n=== EMOTES ===")
for i in tracks:
var t: Dictionary = tracks[i]
var travel := _travel(t["head"])
var hip_travel := _travel(t["hips"])
var lag := _lag(t["hip_rot"], t["head_rot"])
print(" %-12s head %.3f m hips %.3f m upper spine lags lower by %d frames min elbow %.0f deg"
% [DanceRoutines.name_of(i), travel, hip_travel, lag, t["elbow"]])
func _compare(tracks: Dictionary) -> void:
for i in tracks:
var t: Dictionary = tracks[i]
var nm := DanceRoutines.name_of(i)
_expect(_travel(t["head"]) >= MIN_TRAVEL,
"%s actually moves the character (%.3f m)" % [nm, _travel(t["head"])])
# 8 degrees of slack: the IK and the idle clip underneath both contribute,
# and an elbow that never quite straightens is correct anyway.
_expect(t["elbow"] >= 8.0,
"%s never hyperextends the elbow (min %.0f deg)" % [nm, t["elbow"]])
var ids: Array = tracks.keys()
for i in ids.size():
for j in range(i + 1, ids.size()):
var d := _difference(tracks[ids[i]]["head"], tracks[ids[j]]["head"])
_expect(d >= MIN_DISTINCT,
"%s and %s are different dances (%.3f)"
% [DanceRoutines.name_of(ids[i]), DanceRoutines.name_of(ids[j]), d])
# OVERLAP. The head must trail the hips, because the body is a chain — this
# is the property that separates a dance from five bones oscillating in
# phase, and it is the whole reason `lag` exists in the routine data.
#
# The robot is exempt and deliberately so: its lag is zero on purpose, which
# is what makes it read as mechanical against the other four.
for i in tracks:
var rid: String = String(DanceRoutines.get_routine(i).get("id", ""))
# Robot: lag zero by construction, which is the point of it.
# Spin: the head SPOTS — it holds its heading against the turn and whips
# round to catch up, so it is deliberately not a delayed copy of the
# hips. Asserting that it follows them would be asserting the opposite of
# the technique.
if rid == "robot" or rid == "spin":
continue
var lag := _lag(tracks[i]["hip_rot"], tracks[i]["head_rot"])
_expect(lag > 0,
"%s moves as a chain — the upper spine trails the lower by %d frames"
% [DanceRoutines.name_of(i), lag])
# The Robot's own property is that its motion is QUANTISED: it holds a pose
# and jumps, where the others move continuously. That is what `steps` in the
# routine data produces and what makes it read as mechanical against the
# other four.
#
# Its LAG is deliberately not asserted. Zero lag ought to correlate perfectly
# at shift 0, but the signal is a staircase with 16-frame plateaus, so many
# shifts score nearly identically and the measured peak wanders — it reported
# 21 frames. Asserting a number the measurement cannot resolve would be
# asserting noise; the hold fraction below is the property that is actually
# there.
var robot := DanceRoutines.index_of("robot")
var robot_step := _step_size(tracks[robot]["head_rot"])
for i in tracks:
if i == robot:
continue
var other := _step_size(tracks[i]["head_rot"])
_expect(robot_step > other * 1.5,
"Robot JUMPS between poses where %s flows (%.2f vs %.2f of range per frame)"
% [DanceRoutines.name_of(i), robot_step, other])
## The largest single-frame change, as a fraction of the track's whole range.
##
## This is what quantised motion looks like from the outside: long flat stretches
## punctuated by one big jump. A smooth wave never moves more than a few percent
## of its range in a frame however punchy its easing.
##
## Measured as a JUMP rather than as time-spent-still, which was the first
## attempt and does not separate them: a shaped wave hangs at its extremes by
## design, so Two-Step scored the same 0.97 "holding" as the Robot did. The
## routines differ in HOW THEY LEAVE a pose, not in how long they sit in one.
func _step_size(rot_track: Array) -> float:
var track := _project(rot_track)
var n := track.size()
if n < 4:
return 0.0
var lo := 1e30
var hi := -1e30
for v in track:
lo = minf(lo, v)
hi = maxf(hi, v)
var span: float = hi - lo
if span < 0.000001:
return 0.0
var biggest := 0.0
for i in range(1, n):
biggest = maxf(biggest, absf(track[i] - track[i - 1]))
return biggest / span
## Total path length of a track.
func _travel(track: Array) -> float:
var sum := 0.0
for i in range(1, track.size()):
sum += (track[i] as Vector3).distance_to(track[i - 1])
return sum
## Mean per-frame distance between two tracks, after removing each one's own
## average position — otherwise two identical dances at different heights would
## read as different, and two different dances at the same height as the same.
func _difference(a: Array, b: Array) -> float:
var n := mini(a.size(), b.size())
if n == 0:
return 0.0
var ca := Vector3.ZERO
var cb := Vector3.ZERO
for i in n:
ca += a[i]
cb += b[i]
ca /= float(n)
cb /= float(n)
var sum := 0.0
for i in n:
sum += ((a[i] - ca) - (b[i] - cb)).length()
return sum / float(n)
## How many frames the head's rotation trails the hips', by NORMALISED
## cross-correlation.
##
## Both signals are the same quantity — a bone's rotation away from its own rest
## pose — sampled at two ends of the same chain, so the only thing that can
## differ between them is timing. That is the whole point: an unnormalised
## correlation between two DIFFERENT quantities peaks wherever their amplitudes
## happen to line up, which reported the Robot (lag zero by construction) as the
## most overlapped routine in the set.
##
## Pearson, so amplitude cannot influence where the peak falls — a head that
## moves further than the hips must not read as a head that moves later.
func _lag(hips: Array, head: Array) -> int:
# BOTH ends projected onto the HIPS' axis, not each onto its own.
#
# Overlap is "the same motion, later", so the measurement has to be of the
# same motion. Projecting each end onto its own dominant axis compares
# whatever channel happens to dominate at that end, and routines drive
# different channels at the two ends: Two-Step's hips are dominated by a
# two-beat roll while its head is dominated by a one-beat bob, so the
# correlation was between signals of different PERIOD and peaked wherever.
var axis := _dominant_axis(hips)
var a := _centre(_project(hips, axis))
var b := _centre(_project(head, axis))
var n := mini(a.size(), b.size())
if n < 16:
return 0
var best := 0
var best_score := -1e30
# Out to half the window. The correlation of a periodic signal repeats every
# period, so the search must stay inside one; Body Wave has the largest lag
# in the set by design (0.13 s per link over five links, most of a beat at
# 88 bpm) and a short window could not see it at all.
for shift in range(0, n / 2):
var sum := 0.0
var na := 0.0
var nb := 0.0
for i in range(0, n - shift):
sum += a[i] * b[i + shift]
na += a[i] * a[i]
nb += b[i + shift] * b[i + shift]
if na < 0.000001 or nb < 0.000001:
continue
var score: float = sum / sqrt(na * nb)
if score > best_score:
best_score = score
best = shift
return best
## Mean-removed copy of a scalar track.
func _centre(track: Array) -> Array:
var n := track.size()
if n == 0:
return []
var mean := 0.0
for v in track:
mean += v
mean /= float(n)
var out: Array = []
for v in track:
out.append(v - mean)
return out
## How far a bone has been rotated away from its rest pose, as a ROTATION VECTOR
## (axis times angle).
##
## A vector, not a signed scalar. The first version returned `angle * sign of the
## axis's largest component`, and that is discontinuous: as a rocking bone passes
## back through its rest pose the angle goes to zero and the axis FLIPS, so the
## signal jumped the full width of its range in a single frame. Two-Step measured
## a per-frame step of 0.99 of its own range — which looked exactly like the
## quantised motion the Robot is supposed to have exclusively, on a routine that
## is perfectly smooth.
##
## The rotation vector passes through zero and comes out the other side pointing
## the opposite way, which is continuous, and projecting it onto a fixed axis
## afterwards gives the signed wave the analysis actually wants.
func _twist(skel: Skeleton3D, local: Array, idx: int) -> Vector3:
if idx < 0 or idx >= local.size():
return Vector3.ZERO
# The bone's OWN rotation away from its rest — not its global, which carries
# every ancestor's along with it. See Probe.local.
var rest: Quaternion = skel.get_bone_rest(idx).basis.get_rotation_quaternion()
var d := (rest.inverse() * (local[idx] as Quaternion)).normalized()
# Shortest arc, so a rotation just past 180 degrees does not read as one just
# under -180.
if d.w < 0.0:
d = Quaternion(-d.x, -d.y, -d.z, -d.w)
var ang := d.get_angle()
if ang < 0.000001:
return Vector3.ZERO
return d.get_axis() * ang
## The axis a track of rotation vectors varies most about.
func _dominant_axis(track: Array) -> Vector3:
var n := track.size()
if n == 0:
return Vector3.ZERO
var mean := Vector3.ZERO
for v in track:
mean += v
mean /= float(n)
var axis := Vector3.ZERO
var best := 0.0
for v in track:
var d: Vector3 = v - mean
if d.length() > best:
best = d.length()
axis = d
return axis.normalized() if axis.length() > 0.000001 else Vector3.ZERO
## A track of rotation vectors flattened to one signed scalar per frame, along
## `axis` — or along the track's own dominant axis if none is given.
func _project(track: Array, axis: Vector3 = Vector3.ZERO) -> Array:
var n := track.size()
if n == 0:
return []
var use := axis if axis.length() > 0.000001 else _dominant_axis(track)
if use.length() < 0.000001:
return []
var mean := Vector3.ZERO
for v in track:
mean += v
mean /= float(n)
var out: Array = []
for v in track:
out.append((v - mean).dot(use))
return out
func _expect(ok: bool, what: String) -> void:
if ok:
print(" OK: ", what)
else:
print(" FAIL: ", what)
_fails += 1
func _done() -> void:
print("\n=== DANCE SUMMARY ===")
print("Failures: %d" % _fails)
quit(1 if _fails > 0 else 0)
+1
View File
@@ -0,0 +1 @@
uid://tds0trjfr2as
+54
View File
@@ -0,0 +1,54 @@
extends SceneTree
## Photograph the radial emote dial, resting and with a wedge aimed at.
##
## godot --path . --windowed --resolution 1280x720 \
## -s res://debug/emote_wheel_capture.gd -- <out_dir>
##
## Two shots, because the hover state is the one that goes wrong: a wedge whose
## fill changes without its label changing with it is the exact failure the
## theme's contrast work exists to prevent, and it is invisible in a shot of the
## wheel at rest.
var _out := "."
func _initialize() -> void:
var args := OS.get_cmdline_user_args()
if args.size() > 0:
_out = String(args[0])
_run()
func _run() -> void:
await process_frame
UITheme.apply_global(self)
var layer := CanvasLayer.new()
root.add_child(layer)
# Something behind it, so the ink edges are being judged against a real
# backdrop rather than against black.
var back := ColorRect.new()
back.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
back.color = Color(0.42, 0.55, 0.72)
layer.add_child(back)
var wheel := EmoteWheel.new()
layer.add_child(wheel)
wheel.open()
for _i in 30:
await process_frame
await process_frame
root.get_texture().get_image().save_png("%s/emote_wheel_rest.png" % _out)
print("emote_wheel_capture: saved rest")
# Aim up-and-right, which lands on the second wedge.
wheel.aim_by_vector(Vector2(0.7, -0.7))
for _i in 12:
await process_frame
await process_frame
root.get_texture().get_image().save_png("%s/emote_wheel_hover.png" % _out)
print("emote_wheel_capture: saved hover, picked=%d" % wheel._hover)
quit(0)
+1
View File
@@ -0,0 +1 @@
uid://cxxajtpm3o8bj
+1 -1
View File
@@ -41,7 +41,7 @@ func _process(_delta: float) -> bool:
if _frames == 40:
var nm = root.get_node_or_null("NetworkManager")
if nm and nm.has_method("start_singleplayer_match"):
nm.start_singleplayer_match("Deathmatch")
nm.start_singleplayer_match(GameMode.DEATHMATCH)
change_scene_to_file("res://scenes/maps/test_level/test_level.tscn")
elif _frames >= 160:
for p in root.find_children("*", "CharacterBody3D", true, false):
+157
View File
@@ -0,0 +1,157 @@
extends SceneTree
## Do the game modes end, and end on the RIGHT thing?
##
## godot --headless --path . -s res://debug/game_mode_check.gd
##
## `GameMode.check_win` is a pure function of the stats and the clock, which is
## the point of writing it that way: a win condition that can only be exercised
## by playing a whole match is a win condition nobody tests, and this project's
## previous one — a five-minute timer that set `match_active = false` and did
## nothing else — was never exercised at all.
##
## The cases below are the ones that actually go wrong: a draw reported as a win
## for whoever happens to be first in a dictionary, a team mode decided on one
## player's score instead of the team's, and a team kill counting toward the
## killer.
var _fails := 0
func _init() -> void:
# Autoloads are not up during `_init`; LoadoutManager is needed below.
await process_frame
_check_table()
_check_deathmatch()
_check_team()
_check_gun_game()
_check_standings()
_done()
## Every mode must be able to end. A mode with no score limit and no time limit
## runs forever, and that is the failure that produced this whole task.
func _check_table() -> void:
for id in GameMode.all_ids():
_expect(GameMode.is_valid(id),
"'%s' can end (score %d, time %.0fs)"
% [id, GameMode.score_limit(id), GameMode.time_limit(id)])
_expect(GameMode.display_name(id) != "",
"'%s' has a display name" % id)
_expect(GameMode.score_limit(GameMode.GUN_GAME) == GameMode.LADDER.size(),
"Gun Game's limit IS its ladder length (%d), so adding a rung cannot make the match unwinnable"
% GameMode.LADDER.size())
func _check_deathmatch() -> void:
var id := GameMode.DEATHMATCH
var limit := GameMode.score_limit(id)
var mid := {1: _p("A", limit - 1), 2: _p("B", 3)}
_expect(GameMode.check_win(id, mid, 120.0).is_empty(),
"deathmatch keeps running one short of the limit")
var done := {1: _p("A", limit), 2: _p("B", 3)}
var r := GameMode.check_win(id, done, 120.0)
_expect(r.get("reason", "") == "score" and r.get("name", "") == "A",
"deathmatch ends when someone reaches %d, and A won" % limit)
# On time, the leader takes it.
var timed := GameMode.check_win(id, {1: _p("A", 7), 2: _p("B", 4)}, 0.0)
_expect(timed.get("reason", "") == "time" and timed.get("name", "") == "A",
"deathmatch on time goes to the leader")
# A DRAW is a draw. Reporting a winner here means reporting whichever key
# the dictionary happened to hand over first, which is not a rule.
var drawn := GameMode.check_win(id, {1: _p("A", 5), 2: _p("B", 5)}, 0.0)
_expect(drawn.get("name", "") == "DRAW",
"a tie on time is a DRAW, not a win for whoever is first in the dictionary")
func _check_team() -> void:
var id := GameMode.TEAM_DEATHMATCH
var limit := GameMode.score_limit(id)
# Two on team 1 with half the limit each: the TEAM has won, though neither
# player is close on their own. Deciding this on individual score — which is
# what a per-player check would do — never ends a team match.
var half := int(limit / 2.0)
var stats := {
1: _p("A", half, 1), 2: _p("B", half, 1),
3: _p("C", 4, 2), 4: _p("D", 4, 2),
}
var r := GameMode.check_win(id, stats, 300.0)
_expect(r.get("reason", "") == "score" and r.get("team", 0) == 1,
"team deathmatch is decided by the TEAM's total, not one player's")
var totals := GameMode.team_scores(id, stats)
_expect(totals.get(1, 0) == half * 2 and totals.get(2, 0) == 8,
"team totals add up (%d / %d)" % [totals.get(1, 0), totals.get(2, 0)])
# Balance on join, by count and not by turn — a 4v4 that loses three from one
# side must refill the short side, which round-robin on join order does not.
var lopsided := {1: _p("A", 0, 1), 2: _p("B", 0, 1), 3: _p("C", 0, 1)}
_expect(GameMode.assign_team(id, lopsided) == 2,
"a joining player goes to the SHORT team")
var tied := GameMode.check_win(id, {1: _p("A", 5, 1), 2: _p("B", 5, 2)}, 0.0)
_expect(tied.get("name", "") == "DRAW", "level teams on time is a DRAW")
func _check_gun_game() -> void:
var id := GameMode.GUN_GAME
var top := GameMode.LADDER.size()
_expect(GameMode.check_win(id, {1: _p("A", top - 1)}, 300.0).is_empty(),
"gun game keeps running on the last rung")
var r := GameMode.check_win(id, {1: _p("A", top)}, 300.0)
_expect(r.get("reason", "") == "score",
"gun game ends when someone finishes the ladder")
# Every rung must name a weapon that exists, or a promotion hands the player
# nothing and the mode softlocks on that rung.
#
# Reached through the tree rather than by the autoload's global identifier: a
# `-s` tool script compiles its dependencies BEFORE autoloads register, so
# naming `LoadoutManager` directly stops this file from loading at all.
var loadouts = root.get_node_or_null("LoadoutManager")
for i in GameMode.LADDER.size():
var w := GameMode.ladder_weapon(i)
_expect(loadouts != null and loadouts.weapon_db.has(w),
"ladder rung %d ('%s') is a real weapon" % [i, w])
# Past the end clamps rather than going out of bounds — the winning kill
# promotes past the top rung before the match-end RPC lands.
_expect(GameMode.ladder_weapon(999) == GameMode.LADDER[-1],
"promoting past the top rung clamps instead of erroring")
func _check_standings() -> void:
var stats := {
1: _p("A", 5), 2: _p("B", 9), 3: _p("C", 5),
}
stats[1]["deaths"] = 2
stats[3]["deaths"] = 7
var order := GameMode.standings(stats)
_expect(order[0] == 2, "standings put the highest score first")
_expect(order[1] == 1 and order[2] == 3,
"equal scores break on FEWEST DEATHS (kills are equal by definition there)")
func _p(who: String, score: int, team: int = 0) -> Dictionary:
return {"username": who, "score": score, "kills": score, "deaths": 0,
"assists": 0, "team": team, "best_streak": 0, "rung": score}
func _expect(ok: bool, what: String) -> void:
if ok:
print(" OK: ", what)
else:
print(" FAIL: ", what)
_fails += 1
func _done() -> void:
print("\n=== GAME MODE SUMMARY ===")
print("Failures: %d" % _fails)
quit(1 if _fails > 0 else 0)
+1
View File
@@ -0,0 +1 @@
uid://cjpesfuv0vmcs
+105
View File
@@ -0,0 +1,105 @@
extends SceneTree
## One photograph of a character holding each weapon, from far enough away to
## see the SILHOUETTE.
##
## godot --path . --windowed --resolution 640x900 \
## -s res://debug/hold_capture.gd -- <out_dir> [skin]
##
## The rig lab's camera is a close-up on the hands, which is right for tuning a
## grip and useless for the question this feature exists to answer: can you tell
## what someone is carrying from across a map. That is a whole-body question, so
## this frames the whole body, side-on, where the difference between a shouldered
## tube and a low-ready rifle actually lives.
##
## Side-on and not three-quarter, deliberately. A profile is the harshest test of
## a hold — it shows exactly how far the weapon sits from the shoulder and how
## the head is tilted, with no foreshortening to hide behind.
const WEAPONS := ["m4", "mp7", "awp", "double_barrel_shotgun",
"rocket_launcher", "knife"]
var _out := "."
var _skin := "taila"
func _initialize() -> void:
var args := OS.get_cmdline_user_args()
if args.size() > 0:
_out = String(args[0])
if args.size() > 1:
_skin = String(args[1])
_run()
func _run() -> void:
# `_initialize` runs BEFORE the autoloads' `_ready`, so SkinManager's table is
# still empty here and even its "default" fallback is missing — asking it for
# a skin at this point fails on the fallback rather than on the skin asked
# for, which reads as "no skin 'taila'" and sends you looking in the wrong
# place entirely.
await process_frame
var world := Node3D.new()
root.add_child(world)
var env := WorldEnvironment.new()
env.environment = LevelEnvironment.make_environment("sunset")
world.add_child(env)
var key := DirectionalLight3D.new()
key.rotation_degrees = Vector3(-38, 155, 0)
key.light_energy = 2.0
world.add_child(key)
var fill := DirectionalLight3D.new()
fill.rotation_degrees = Vector3(-20, -40, 0)
fill.light_energy = 0.7
fill.light_color = Color(0.6, 0.7, 1.0)
world.add_child(fill)
var cam := Camera3D.new()
world.add_child(cam)
# Side-on, at chest height, far enough back that the whole figure and the
# whole weapon are in frame.
cam.position = Vector3(3.4, 1.05, 0.15)
cam.look_at(Vector3(0, 0.95, 0))
cam.fov = 42.0
var mgr = root.get_node_or_null("SkinManager")
if mgr == null:
print("hold_capture: no SkinManager")
quit(1)
return
var skin = mgr.get_skin(_skin)
if skin == null:
print("hold_capture: no skin '%s'" % _skin)
quit(1)
return
for weapon in WEAPONS:
var model := SkinnedPlayerModel.new()
model.skin_id = _skin
model.first_person_mode = false
world.add_child(model)
model.load_model(skin.model_path)
for _i in 40:
await process_frame
model.set_weapon("res://weapons/%s.gd" % weapon)
model.update_state("idle", 0.0, false)
# The hold blends in at ~8/s and the weapon measures itself on `ready`,
# so give it several time constants before believing the pose.
for _i in 100:
model.update_state("idle", 0.0, false)
await process_frame
await process_frame
var img := root.get_texture().get_image()
var path := "%s/hold_%s.png" % [_out, weapon]
img.save_png(path)
print("hold_capture: saved ", path)
model.queue_free()
for _i in 4:
await process_frame
quit(0)
+1
View File
@@ -0,0 +1 @@
uid://c25wpt58v328r
+110
View File
@@ -0,0 +1,110 @@
extends SceneTree
## Dev tool: build a PlayerHUD against a stand-in player, let it lay out, and
## print where every element actually landed.
##
## godot --headless --path . -s res://debug/hud_layout_check.gd
##
## A HUD element that lands at (0, 0) with zero size is the single most common
## thing to get wrong here, and it is invisible in a headless smoke test and
## easy to miss in a screenshot. This prints the rects so the answer is a number.
var _frames := 0
var _hud: PlayerHUD
func _initialize() -> void:
var stand_in := CharacterBody3D.new()
stand_in.name = "StandInPlayer"
root.add_child(stand_in)
_hud = PlayerHUD.new()
_hud.player = stand_in
root.add_child(_hud)
func _process(_delta: float) -> bool:
_frames += 1
# A hidden Control keeps its last rect and contributes nothing to its
# container's minimum size, so measuring the ammo card while the stand-in
# player holds no weapon reads a stale position from a collapsed column and
# reports a layout bug that is not there. Force the states that matter to be
# visible, then give the containers a frame to re-sort before measuring.
if _frames == 4:
# Freeze the HUD first. Its own `_process` hides the ammo card whenever
# the player holds no weapon, so anything forced visible here is undone
# again before the measurement and what gets read is the stale rect of a
# hidden control inside a collapsed column.
_hud.set_process(false)
_hud._ammo_card.visible = true
_hud._ammo_num.text = "30"
_hud._ammo_max.text = "/ 30"
_hud._weapon_name.text = "DOUBLE BARREL SHOTGUN"
_hud._chain_row.visible = true
for pip in _hud._grenade_row.get_children():
(pip as Control).visible = true
if _frames < 8:
return false
print("viewport = ", root.get_visible_rect().size)
if OS.has_environment("HUD_TREE"):
_dump(_hud, 0)
var fails := 0
for node in _hud.find_children("*", "Control", true, false):
var c := node as Control
# Only the elements that are supposed to occupy space. Containers that
# legitimately shrink to their content are not interesting.
if c.name in ["Crosshair", "ReloadRing", "DeathScreen"]:
var r := c.get_global_rect() # GLOBAL: get_rect() is parent-relative and lies about where a nested element really sits
print(" %-12s pos=%s size=%s" % [c.name, r.position, r.size])
if r.size.x < 100.0 or r.size.y < 100.0:
print(" FAIL: expected to fill the viewport")
fails += 1
# The ammo card, which must hug the RIGHT edge inside its margin. It sat
# flush against the left instead, because a VBoxContainer hands each child
# its own horizontal placement and the alignment flag was on the column.
var vr := root.get_visible_rect().size
var card := _find_by_class("PanelContainer")
if card == null:
print(" MISSING ammo card")
fails += 1
else:
var r := card.get_global_rect()
var right_gap := vr.x - (r.position.x + r.size.x)
print(" %-12s pos=%s size=%s right_gap=%.0f"
% ["ammo", r.position, r.size, right_gap])
if r.size.x < 60.0:
print(" FAIL: collapsed to nothing")
fails += 1
elif right_gap < 0.0:
print(" FAIL: runs off the right edge of the screen")
fails += 1
elif right_gap > 120.0:
print(" FAIL: not anchored to the right edge (gap %.0f)" % right_gap)
fails += 1
print("HUD LAYOUT: %s" % ("PASS" if fails == 0 else "FAIL (%d)" % fails))
quit(1 if fails > 0 else 0)
return true
## The whole Control tree with GLOBAL rects. `HUD_TREE=1` to switch on — when a
## container collapses, the answer is always visible in its parent chain and
## never in the leaf you noticed the problem on.
func _dump(node: Node, depth: int) -> void:
for child in node.get_children():
if child is Control:
var c := child as Control
var r := c.get_global_rect()
print("%s%s [%s] pos=%s size=%s vis=%s"
% [" ".repeat(depth + 1), c.name, c.get_class(),
r.position, r.size, c.visible])
_dump(child, depth + 1)
func _find_by_class(cls: String) -> Control:
for n in _hud.find_children("*", cls, true, false):
return n as Control
return null
+1
View File
@@ -0,0 +1 @@
uid://c340r7urelhhx
+201
View File
@@ -0,0 +1,201 @@
extends SceneTree
## Photograph every map, for the level select cards.
##
## godot --path . --windowed --resolution 640x400 \
## -s res://debug/map_preview_capture.gd
##
## Writes assets/ui/map_previews/<folder>.png, which the menu picks up by
## convention — no map_meta key to add, so a new map that ships a preview gets
## one and a map that does not falls back to its gradient.
##
## The cards used to be a two-stop gradient built from `color1` and `color2` in
## the map's meta file. That tells a player which card they clicked last time and
## nothing whatsoever about the map, which is the entire job of a level select.
##
## ── Framing ─────────────────────────────────────────────────────────────────
##
## The camera stands INSIDE the map, at a spawn point, at eye height, looking
## across it.
##
## The first version framed the whole level from outside by merging every
## VisualInstance3D's AABB and backing off until it fit. Every map came out as a
## tiny diorama floating on a table in the middle of an empty sky — and one came
## out entirely black, because from orbit there was nothing lit in frame. That is
## a minimap, and a minimap is a different thing from a photograph.
##
## What a player wants off a card is the FEEL of a place: its light, its colour,
## how enclosed it is, what the skyline looks like. All of that only exists from
## where a player will actually stand, so that is where the camera goes — and
## spawn points are already authored in every map, so it costs nothing.
const OUT_DIR := "res://assets/ui/map_previews"
const MAPS_DIR := "res://scenes/maps/"
## How long to let a map build itself. The procedural ones generate geometry in
## `_ready` and the neon map streams in props, so a shot taken too early is a
## photograph of an empty skybox.
const SETTLE_FRAMES := 90
func _initialize() -> void:
_run()
func _run() -> void:
await process_frame
DirAccess.make_dir_recursive_absolute(ProjectSettings.globalize_path(OUT_DIR))
for folder in _map_folders():
var cfg := ConfigFile.new()
if cfg.load(MAPS_DIR + folder + "/map_meta.cfg") != OK:
continue
var scene_path: String = cfg.get_value("map", "scene_path", "")
if scene_path == "" or not ResourceLoader.exists(scene_path):
continue
var packed: PackedScene = load(scene_path)
if packed == null:
print("map_preview: could not load ", scene_path)
continue
var level: Node = packed.instantiate()
root.add_child(level)
for _i in SETTLE_FRAMES:
await process_frame
var shot: Image = await _best_shot(level)
var path := "%s/%s.png" % [OUT_DIR, folder]
if shot == null:
print("map_preview: no usable vantage in ", folder)
else:
shot.save_png(ProjectSettings.globalize_path(path))
print("map_preview: saved ", path)
level.queue_free()
for _i in 6:
await process_frame
quit(0)
func _map_folders() -> Array:
var out: Array = []
var dir := DirAccess.open(MAPS_DIR)
if dir == null:
return out
dir.list_dir_begin()
var name := dir.get_next()
while name != "":
if dir.current_is_dir() and not name.begins_with("."):
if FileAccess.file_exists(MAPS_DIR + name + "/map_meta.cfg"):
out.append(name)
name = dir.get_next()
out.sort()
return out
## Try several vantages and keep the one that produces the best PICTURE.
##
## Not the one that satisfies a rule about where cameras should go. Two maps
## defeated every positional rule tried: fps_blockout is a genuinely dark map
## where most spawns face an unlit wall, and procedural_arena builds its geometry
## at runtime so no fixed offset is inside it. Both came out as black rectangles,
## and a black rectangle passes any check that asks "did the camera end up
## somewhere sensible".
##
## So the tool renders each candidate and SCORES the result. The score wants an
## image that is both bright and varied: mean luminance alone picks a shot of the
## empty sky, and variance alone picks a high-contrast corner of a dark room.
## Their product picks a photograph.
func _best_shot(level: Node):
var bounds := _bounds(level)
var centre := bounds.get_center() if bounds.size.length() > 0.01 else Vector3.ZERO
var extent: float = maxf(maxf(bounds.size.x, bounds.size.z), 8.0)
var cam := Camera3D.new()
level.add_child(cam)
cam.far = maxf(bounds.size.length() * 2.0, 800.0)
# A little wider than the game's own view, so a card shows more of the space
# than a screenshot of play would.
cam.fov = 68.0
cam.current = true
var best_img: Image = null
var best_score := -1.0
for vantage in _vantages(centre, extent):
cam.global_position = vantage[0]
var look: Vector3 = vantage[1]
if cam.global_position.distance_to(look) < 1.0:
continue
cam.look_at(look)
# Two frames: one for the transform, one for the frame drawn with it.
for _i in 3:
await process_frame
var img := root.get_texture().get_image()
var score := _score(img)
if score > best_score:
best_score = score
best_img = img
cam.queue_free()
return best_img
## Candidate camera placements, as `[eye, look_at]` pairs.
##
## Every authored spawn point, plus a raised view from each side of the map for
## the levels that generate themselves and have no spawns by the time this runs.
func _vantages(centre: Vector3, extent: float) -> Array:
var out: Array = []
for s in get_nodes_in_group("spawn_points"):
if s is Node3D:
var p: Vector3 = (s as Node3D).global_position
out.append([p + Vector3.UP * 2.2, centre + Vector3.UP * 1.6])
# Raised corners, looking in. Higher than eye level, because a generated
# arena's floor is not necessarily at the origin and standing "on" it is a
# guess where looking down at it is not.
for dir in [Vector3(1, 0, 1), Vector3(-1, 0, 1), Vector3(1, 0, -1),
Vector3(-1, 0, -1)]:
var d: Vector3 = dir.normalized()
out.append([centre + d * extent * 0.42 + Vector3.UP * extent * 0.22,
centre + Vector3.UP * extent * 0.04])
return out
## How good a picture is: bright AND varied. See `_best_shot`.
func _score(img: Image) -> float:
var small := img.duplicate() as Image
small.resize(40, 24, Image.INTERPOLATE_BILINEAR)
var n := 40 * 24
var mean := 0.0
for y in 24:
for x in 40:
var c := small.get_pixel(x, y)
mean += 0.2126 * c.r + 0.7152 * c.g + 0.0722 * c.b
mean /= float(n)
var variance := 0.0
for y in 24:
for x in 40:
var c := small.get_pixel(x, y)
var l := 0.2126 * c.r + 0.7152 * c.g + 0.0722 * c.b
variance += (l - mean) * (l - mean)
variance /= float(n)
return mean * sqrt(variance)
func _bounds(node: Node) -> AABB:
var out := AABB()
var any := false
for child in node.find_children("*", "VisualInstance3D", true, false):
var vi := child as VisualInstance3D
# Skip anything enormous: a WorldEnvironment's sky or a directional
# light's own AABB would swallow the map and push the camera to orbit.
var box := vi.get_aabb()
if box.size.length() > 100000.0 or box.size.length() < 0.001:
continue
box = vi.global_transform * box
if not any:
out = box
any = true
else:
out = out.merge(box)
return out if any else AABB()
+1
View File
@@ -0,0 +1 @@
uid://esvuwcyncpeb
+65
View File
@@ -0,0 +1,65 @@
extends SceneTree
## One photograph of every main-menu screen, including the lobby.
##
## godot --path . --windowed --resolution 1280x720 \
## -s res://debug/menu_capture.gd -- <out_dir>
##
## The lobby is the reason this exists. It only appears after hosting, so it is
## the screen least likely to be looked at and the one where an unstyled list or
## a collapsed card would sit unnoticed — which is exactly the class of defect
## that this project keeps finding by LOOKING rather than by asserting.
const MENU := "res://ui/main_menu/main_menu.tscn"
var _out := "."
func _initialize() -> void:
var args := OS.get_cmdline_user_args()
if args.size() > 0:
_out = String(args[0])
_run()
func _run() -> void:
await process_frame
change_scene_to_file(MENU)
for _i in 60:
await process_frame
var menu := current_scene
if menu == null:
print("menu_capture: no menu")
quit(1)
return
await _shot("home")
menu._show(menu.LEVELS)
await _shot("levels")
menu._show(menu.MULTIPLAYER)
await _shot("multiplayer")
# Host for real, so the lobby is photographed in the state a player reaches
# it in — host controls enabled, own name in the list — rather than in an
# empty one that would hide a broken player list.
var nm = root.get_node_or_null("NetworkManager")
if nm and nm.host_game() == OK:
menu._on_connection_succeeded()
for _i in 20:
await process_frame
await _shot("lobby")
nm.disconnect_game()
else:
print("menu_capture: could not host, lobby not shot")
quit(0)
func _shot(tag: String) -> void:
for _i in 12:
await process_frame
root.get_texture().get_image().save_png("%s/menu_%s.png" % [_out, tag])
print("menu_capture: saved ", tag)
+1
View File
@@ -0,0 +1 @@
uid://c2fen0sy3bw87
+1 -1
View File
@@ -25,7 +25,7 @@ func _process(_delta: float) -> bool:
sm.set_active_skin("taila")
var nm = root.get_node_or_null("NetworkManager")
if nm and nm.has_method("start_singleplayer_match"):
nm.start_singleplayer_match("Deathmatch")
nm.start_singleplayer_match(GameMode.DEATHMATCH)
change_scene_to_file("res://scenes/maps/test_level/test_level.tscn")
elif _frames == 160:
for p in root.find_children("*", "CharacterBody3D", true, false):
+9 -2
View File
@@ -420,7 +420,8 @@ func _drag_to(mouse: Vector2) -> void:
var key := _knob_for(_drag_marker)
var spec := _spec_for("hold", key)
var cur: Vector3 = _knobs["hold"].get(key, WeaponHoldTuning.default_for(key))
var cur: Vector3 = _knobs["hold"].get(key,
WeaponHoldTuning.default_for(key, _weapons[_weapon].id))
if not (cur is Vector3):
cur = Vector3.ZERO
var lo: float = spec[2]
@@ -490,9 +491,15 @@ func _hold_pose() -> String:
return WeaponHoldTuning.pose_for_ads(POSES[_pose][3])
## A knob's resting value when nothing has been saved for this character.
##
## The weapon is passed through, because the hold's defaults depend on what KIND
## of weapon it is — see WeaponHoldTuning.default_for. Without it the lab would
## show a knife the rifle spec's numbers and then save them over the blade
## profile the moment anyone pressed save.
func _default_for(group: String, key: String):
return RigAnchors.default_for(key) if group == "anchors" \
else WeaponHoldTuning.default_for(key)
else WeaponHoldTuning.default_for(key, _weapons[_weapon].id)
## Every knob of a group across BOTH poses. Reset works on the whole table, not
+1 -1
View File
@@ -30,7 +30,7 @@ func _initialize() -> void:
_out_dir = args[0]
var nm = root.get_node_or_null("NetworkManager")
if nm and nm.has_method("start_singleplayer_match"):
nm.start_singleplayer_match("Deathmatch")
nm.start_singleplayer_match(GameMode.DEATHMATCH)
change_scene_to_file("res://scenes/maps/test_level/test_level.tscn")
+1 -1
View File
@@ -56,7 +56,7 @@ func _test_spawn_with_skin(skin_id: String, expect_skinned: bool) -> void:
var nm = root.get_node_or_null("/root/NetworkManager")
if not _check(nm != null, "NetworkManager autoload exists"):
return
nm.start_singleplayer_match("Deathmatch")
nm.start_singleplayer_match(GameMode.DEATHMATCH)
change_scene_to_file("res://scenes/maps/test_level/test_level.tscn")
# Let the level build and the player spawn + settle.
+71
View File
@@ -0,0 +1,71 @@
extends SceneTree
## Photograph the end-of-match summary, for a free-for-all and for a team mode.
##
## godot --path . --windowed --resolution 1280x800 \
## -s res://debug/summary_capture.gd -- <out_dir>
##
## Fed a synthetic result rather than a played match, so the screen can be looked
## at without spending ten minutes reaching a frag limit — and so the awkward
## cases (a draw, a long name, a team scoreline) can be put on screen on purpose
## instead of waiting for them to happen.
var _out := "."
func _initialize() -> void:
var args := OS.get_cmdline_user_args()
if args.size() > 0:
_out = String(args[0])
_run()
func _run() -> void:
await process_frame
UITheme.apply_global(self)
var layer := CanvasLayer.new()
root.add_child(layer)
var back := ColorRect.new()
back.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
back.color = Color(0.30, 0.42, 0.62)
layer.add_child(back)
var summary := MatchSummary.new()
layer.add_child(summary)
await _shot(summary, "ffa", {
"reason": "score", "name": "Nicholas", "mode": GameMode.DEATHMATCH,
"standings": [1, 2, 3],
"stats": {
1: _p("Nicholas", 25, 25, 11, 4, 7),
2: _p("Papaya Enjoyer", 19, 19, 17, 2, 4),
3: _p("guest_4471", 8, 8, 24, 9, 2),
}})
await _shot(summary, "team", {
"reason": "time", "team": 2, "name": "MAGENTA TEAM",
"mode": GameMode.TEAM_DEATHMATCH,
"standings": [3, 1, 4, 2],
"stats": {
1: _p("Nicholas", 14, 14, 9, 3, 5, 1),
2: _p("Papaya Enjoyer", 6, 6, 15, 1, 2, 1),
3: _p("guest_4471", 17, 17, 8, 6, 6, 2),
4: _p("bananaboat", 12, 12, 10, 4, 3, 2),
}})
quit(0)
func _shot(summary: MatchSummary, tag: String, result: Dictionary) -> void:
summary.show_result(result)
for _i in 6:
await process_frame
root.get_texture().get_image().save_png("%s/summary_%s.png" % [_out, tag])
print("summary_capture: saved ", tag)
func _p(who: String, score: int, kills: int, deaths: int, assists: int,
streak: int, team: int = 0) -> Dictionary:
return {"username": who, "score": score, "kills": kills, "deaths": deaths,
"assists": assists, "best_streak": streak, "team": team}
+1
View File
@@ -0,0 +1 @@
uid://bnw4gima0hkli
+7 -307
View File
@@ -4,18 +4,7 @@ class_name TestLevelBuilder
## Builds a full test environment from code — parkour geometry, lighting, player, HUD.
## Attach to TestLevel root, press F5.
var _speed_label: Label
var _state_label: Label
var _chain_label: Label
var _weapon_label: Label
var _grapple_icon: TextureRect
var _dash_icon: TextureRect
var _grapple_label: Label
var _dash_label: Label
var _fps_label: Label
var _standalone_speed_label: Label
var _player: CharacterBody3D
var _debug_ui_panel: PanelContainer
func _ready() -> void:
@@ -24,7 +13,6 @@ func _ready() -> void:
_build_materials()
_build_geometry()
_build_hud()
# Multiplayer Spawning
var spawner = MultiplayerSpawner.new()
@@ -286,6 +274,8 @@ func _spawn_player(pid: int) -> CharacterBody3D:
client_rep_config.add_property(":synced_is_ads")
client_rep_config.add_property(":synced_wall_side")
client_rep_config.add_property(":synced_is_dancing")
# Which of the five emotes, so other players see the one that was chosen.
client_rep_config.add_property(":synced_dance_index")
client_rep_config.add_property(":synced_grapple_point")
client_rep_config.add_property(":synced_is_grapple_shooting")
client_rep_config.add_property(":synced_skin_id")
@@ -417,298 +407,8 @@ func _spawn_player(pid: int) -> CharacterBody3D:
return player
# ── HUD ───────────────────────────────────────────────────────────────────────
func _build_hud() -> void:
var canvas := CanvasLayer.new()
canvas.name = "UI"
add_child(canvas)
# ── Crosshair ─────────────────────────────────────────────────────────
var crosshair := Control.new()
crosshair.name = "Crosshair"
crosshair.set_anchors_preset(Control.PRESET_CENTER)
crosshair.custom_minimum_size = Vector2(20, 20)
canvas.add_child(crosshair)
var ch_dot := ColorRect.new()
ch_dot.name = "Dot"
ch_dot.color = Color(1, 1, 1, 0.8)
ch_dot.size = Vector2(4, 4)
ch_dot.position = Vector2(-2, -2)
crosshair.add_child(ch_dot)
# Crosshair lines
for data in [
{"pos": Vector2(-10, -1), "size": Vector2(6, 2)}, # Left
{"pos": Vector2(4, -1), "size": Vector2(6, 2)}, # Right
{"pos": Vector2(-1, -10), "size": Vector2(2, 6)}, # Top
{"pos": Vector2(-1, 4), "size": Vector2(2, 6)}, # Bottom
]:
var line := ColorRect.new()
line.color = Color(1, 1, 1, 0.6)
line.position = data["pos"]
line.size = data["size"]
crosshair.add_child(line)
# ── Info panel background ─────────────────────────────────────────────
_fps_label = Label.new()
_fps_label.name = "FPSLabel"
_fps_label.text = "FPS: 0"
_fps_label.add_theme_font_size_override("font_size", 24)
_fps_label.add_theme_color_override("font_color", Color(0.9, 0.9, 0.2))
_fps_label.add_theme_color_override("font_outline_color", Color(0, 0, 0))
_fps_label.add_theme_constant_override("outline_size", 4)
_fps_label.set_anchors_preset(Control.PRESET_TOP_LEFT)
_fps_label.position = Vector2(12, 12)
canvas.add_child(_fps_label)
_standalone_speed_label = Label.new()
_standalone_speed_label.name = "StandaloneSpeedLabel"
_standalone_speed_label.text = "Speed: 0.0 m/s"
_standalone_speed_label.add_theme_font_size_override("font_size", 24)
_standalone_speed_label.add_theme_color_override("font_color", Color(0.3, 1.0, 0.5))
_standalone_speed_label.add_theme_color_override("font_outline_color", Color(0, 0, 0))
_standalone_speed_label.add_theme_constant_override("outline_size", 4)
_standalone_speed_label.set_anchors_preset(Control.PRESET_TOP_LEFT)
_standalone_speed_label.position = Vector2(12, 45)
canvas.add_child(_standalone_speed_label)
_debug_ui_panel = PanelContainer.new()
_debug_ui_panel.name = "InfoPanel"
_debug_ui_panel.offset_left = 12
_debug_ui_panel.offset_top = 50
_debug_ui_panel.offset_right = 400
_debug_ui_panel.offset_bottom = 160
var panel_style := StyleBoxFlat.new()
panel_style.bg_color = Color(0, 0, 0, 0.55)
panel_style.corner_radius_top_left = 8
panel_style.corner_radius_top_right = 8
panel_style.corner_radius_bottom_left = 8
panel_style.corner_radius_bottom_right = 8
panel_style.content_margin_left = 12
panel_style.content_margin_top = 8
panel_style.content_margin_right = 12
panel_style.content_margin_bottom = 8
_debug_ui_panel.add_theme_stylebox_override("panel", panel_style)
canvas.add_child(_debug_ui_panel)
var vbox := VBoxContainer.new()
vbox.name = "InfoVBox"
_debug_ui_panel.add_child(vbox)
_speed_label = Label.new()
_speed_label.name = "SpeedLabel"
_speed_label.text = "Speed: 0.0 m/s"
_speed_label.add_theme_font_size_override("font_size", 18)
_speed_label.add_theme_color_override("font_color", Color(0.3, 1.0, 0.5))
vbox.add_child(_speed_label)
_state_label = Label.new()
_state_label.name = "StateLabel"
_state_label.text = "State: ground"
_state_label.add_theme_font_size_override("font_size", 16)
_state_label.add_theme_color_override("font_color", Color(0.7, 0.85, 1.0))
vbox.add_child(_state_label)
_chain_label = Label.new()
_chain_label.name = "ChainLabel"
_chain_label.text = "Chain: 0 (+0%)"
_chain_label.add_theme_font_size_override("font_size", 16)
_chain_label.add_theme_color_override("font_color", Color(1.0, 0.8, 0.3))
vbox.add_child(_chain_label)
# ── Weapon & Ammo Panel ───────────────────────────────────────────────
var wp_panel := PanelContainer.new()
wp_panel.name = "WeaponPanel"
wp_panel.set_anchors_preset(Control.PRESET_BOTTOM_RIGHT)
wp_panel.offset_left = -250
wp_panel.offset_top = -100
wp_panel.offset_right = -20
wp_panel.offset_bottom = -20
var wp_style := StyleBoxFlat.new()
wp_style.bg_color = Color(0, 0, 0, 0.6)
wp_style.corner_radius_top_left = 8
wp_style.corner_radius_top_right = 8
wp_style.corner_radius_bottom_left = 8
wp_style.corner_radius_bottom_right = 8
wp_style.content_margin_left = 16
wp_style.content_margin_top = 12
wp_style.content_margin_right = 16
wp_style.content_margin_bottom = 12
wp_panel.add_theme_stylebox_override("panel", wp_style)
wp_panel.grow_horizontal = Control.GROW_DIRECTION_BEGIN
canvas.add_child(wp_panel)
_weapon_label = Label.new()
_weapon_label.name = "WeaponLabel"
_weapon_label.text = "Unarmed\n0 / 0"
_weapon_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
_weapon_label.vertical_alignment = VERTICAL_ALIGNMENT_BOTTOM
_weapon_label.add_theme_font_size_override("font_size", 24)
wp_panel.add_child(_weapon_label)
# ── Utilities Panel ───────────────────────────────────────────────────────
var util_panel := PanelContainer.new()
util_panel.name = "UtilPanel"
util_panel.set_anchors_preset(Control.PRESET_BOTTOM_RIGHT)
util_panel.offset_left = -200
util_panel.offset_top = -180
util_panel.offset_right = -20
util_panel.offset_bottom = -110
var util_style := StyleBoxFlat.new()
util_style.bg_color = Color(0, 0, 0, 0.6)
util_style.corner_radius_top_left = 8
util_style.corner_radius_top_right = 8
util_style.corner_radius_bottom_left = 8
util_style.corner_radius_bottom_right = 8
util_style.content_margin_left = 12
util_style.content_margin_top = 8
util_style.content_margin_right = 12
util_style.content_margin_bottom = 8
util_panel.add_theme_stylebox_override("panel", util_style)
util_panel.grow_horizontal = Control.GROW_DIRECTION_BEGIN
canvas.add_child(util_panel)
var util_hbox := HBoxContainer.new()
util_hbox.name = "UtilHBox"
util_hbox.add_theme_constant_override("separation", 20)
util_hbox.alignment = BoxContainer.ALIGNMENT_CENTER
util_panel.add_child(util_hbox)
var grapple_vbox := VBoxContainer.new()
grapple_vbox.alignment = BoxContainer.ALIGNMENT_CENTER
util_hbox.add_child(grapple_vbox)
_grapple_icon = TextureRect.new()
_grapple_icon.texture = load("res://assets/ui/grapple_icon.jpg")
_grapple_icon.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
_grapple_icon.custom_minimum_size = Vector2(32, 32)
_grapple_icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
grapple_vbox.add_child(_grapple_icon)
_grapple_label = Label.new()
_grapple_label.text = "Grapple"
_grapple_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
_grapple_label.add_theme_font_size_override("font_size", 12)
grapple_vbox.add_child(_grapple_label)
var dash_vbox := VBoxContainer.new()
dash_vbox.alignment = BoxContainer.ALIGNMENT_CENTER
util_hbox.add_child(dash_vbox)
_dash_icon = TextureRect.new()
_dash_icon.texture = load("res://assets/ui/dash_icon.jpg")
_dash_icon.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
_dash_icon.custom_minimum_size = Vector2(32, 32)
_dash_icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
dash_vbox.add_child(_dash_icon)
_dash_label = Label.new()
_dash_label.text = "Ready"
_dash_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
_dash_label.add_theme_font_size_override("font_size", 12)
dash_vbox.add_child(_dash_label)
# ── Utility: Key Name ─────────────────────────────────────────────────────────
func _get_key_name(action: String) -> String:
if not InputMap.has_action(action):
return "?"
var events = InputMap.action_get_events(action)
for e in events:
if e is InputEventKey:
var code = e.physical_keycode if e.physical_keycode != 0 else e.keycode
return OS.get_keycode_string(code)
elif e is InputEventMouseButton:
if e.button_index == MOUSE_BUTTON_LEFT: return "LClick"
elif e.button_index == MOUSE_BUTTON_RIGHT: return "RClick"
elif e.button_index == MOUSE_BUTTON_MIDDLE: return "MClick"
return "?"
# ── HUD Update ────────────────────────────────────────────────────────────────
func _process(_delta: float) -> void:
if not _player or not is_instance_valid(_player):
return
if _debug_ui_panel:
_debug_ui_panel.visible = SettingsManager.show_debug_ui
if _fps_label:
_fps_label.visible = SettingsManager.show_fps
if _fps_label.visible:
_fps_label.text = "FPS: %d" % Engine.get_frames_per_second()
var vel: Vector3 = _player.velocity
var hspeed := Vector2(vel.x, vel.z).length()
var total_speed := vel.length()
if _speed_label:
_speed_label.text = "Speed: %.1f m/s (total: %.1f)" % [hspeed, total_speed]
if _standalone_speed_label:
if SettingsManager.show_movement_speed and not SettingsManager.show_debug_ui:
_standalone_speed_label.visible = true
_standalone_speed_label.text = "Speed: %.1f m/s" % hspeed
if _fps_label and _fps_label.visible:
_standalone_speed_label.position = Vector2(12, 45)
else:
_standalone_speed_label.position = Vector2(12, 12)
else:
_standalone_speed_label.visible = false
if _state_label:
var sm = _player.get_node_or_null("MovementStateMachine")
if sm:
_state_label.text = "State: %s" % sm.current_state
if _chain_label:
var sm = _player.get_node_or_null("MovementStateMachine")
if sm:
_chain_label.text = "Chain: %d (+%d%%)" % [sm.chain_count, int(sm.current_chain_bonus * 100)]
# Update utility indicators
if sm.current_state == "grapple" or sm.is_grapple_shooting:
_grapple_icon.modulate = Color(0.2, 1.0, 0.4)
_grapple_label.text = "Grappling"
else:
_grapple_icon.modulate = Color(1.0, 1.0, 1.0)
_grapple_label.text = "Ready"
var dash_rem = sm.get_dash_cooldown_remaining()
if dash_rem > 0.0:
_dash_icon.modulate = Color(1.0, 0.3, 0.3)
_dash_label.text = "%.1f" % dash_rem
else:
_dash_icon.modulate = Color(1.0, 1.0, 1.0)
_dash_label.text = "Ready"
if _weapon_label:
var wman = _player.get_node_or_null("HeadPivot/Camera3D/WeaponManager")
if wman and wman.weapons.has(wman.active_slot):
var active_weapon = wman.weapons[wman.active_slot]
var w_name = "Weapon"
var cur_ammo = 0
var max_ammo = 0
if "weapon_name" in active_weapon:
w_name = active_weapon.weapon_name
elif active_weapon is DoubleBarrelShotgun:
w_name = "Double Barrel Shotgun"
if "current_ammo" in active_weapon:
cur_ammo = active_weapon.current_ammo
max_ammo = active_weapon.max_ammo
elif "shells" in active_weapon:
cur_ammo = active_weapon.shells
max_ammo = 2
if "reloading" in active_weapon and active_weapon.reloading:
_weapon_label.text = "%s\nReloading..." % w_name
else:
_weapon_label.text = "%s\n%d / %d" % [w_name, cur_ammo, max_ammo]
else:
_weapon_label.text = "Unarmed\n0 / 0"
# ── HUD ───────────────────────────────────────────────────────────────
#
# There is no HUD here any more, and no _process to drive one. See the note in
# scenes/maps/level_runtime.gd: everything that describes A PLAYER belongs to
# ui/player_hud.gd, which that player spawns for itself.
+1 -1
View File
@@ -35,7 +35,7 @@ func _process(_delta: float) -> bool:
# rather than a flat colour — that its legibility has to survive.
var nm = root.get_node_or_null("NetworkManager")
if nm and nm.has_method("start_singleplayer_match"):
nm.start_singleplayer_match("Deathmatch")
nm.start_singleplayer_match(GameMode.DEATHMATCH)
change_scene_to_file("res://scenes/maps/test_level/test_level.tscn")
200:
_open("_show_main_menu")
+229
View File
@@ -0,0 +1,229 @@
extends SceneTree
## Reads the BUILT theme and fails if any state puts text on a fill it cannot be
## read against.
##
## godot --headless --path . -s res://debug/ui_contrast_check.gd
##
## The point is that this interrogates `UITheme.build()` rather than the palette
## constants. A table of colours checked against itself agrees by construction
## and catches nothing; what goes wrong in practice is a stylebox whose fill was
## changed without the matching `font_*_color`, or a state Godot draws that
## nobody remembered exists — `hover_pressed` on a toggle, `font_hover_color` on
## a PopupMenu. Those are exactly what this finds, because it asks the theme what
## it will actually draw.
##
## WCAG 2.1 floors: 4.5:1 for body text, 3:1 for text at or above ~24 px.
##
## ── Two ways text can be legible, and only one of them is a colour pair ───────
##
## A label on a BUTTON sits on a solid chip. The fill is right behind the glyphs
## and nothing else is, so the only thing that can make it readable is the pair,
## and the fix when it fails is to invert the label with the fill. Those are
## checked as `SOLID`.
##
## A label on a PROGRESS BAR does not have one backdrop. It straddles the filled
## part and the empty part, and no single text colour can beat both a hot papaya
## and a near-black at once — the pair is unfixable by construction. What carries
## it is the heavy ink outline the theme puts on every glyph, which is the same
## mechanism that keeps menu text readable straight over the 3D scene, and it is
## the theme's first stated rule for exactly this reason.
##
## So that case is checked as `OUTLINED`, against what actually does the work:
##
## outline vs fill >= 3.0 the outline has to separate from the backdrop
## text vs outline >= 4.5 and the glyph has to separate from its outline
## outline_size >= 4 px thin enough and there is no outline to read
##
## This is a narrower allowance than it looks. It is only granted where the
## backdrop genuinely varies, and it substitutes two ratios for one rather than
## waiving the requirement — an unoutlined label over a bar still fails.
##
## Semi-transparent fills (PANEL, DEAD_FILL) are measured as if opaque. They are
## drawn over the dark 3D scene or over a darker panel, so the opaque reading is
## the pessimistic one for paper-on-dark and the accurate one for ink-on-light.
const FLOOR_BODY := 4.5
const FLOOR_LARGE := 3.0
## An outline thinner than this is a hairline, not a backdrop.
const MIN_OUTLINE := 4
const SOLID := "solid"
const OUTLINED := "outlined"
## class -> [[stylebox, font colour, large?, mode], ...]
##
## Only the pairs Godot really composites. `focus` is drawn OVER `normal` rather
## than instead of it, so it is checked against its own fill (which is the same
## fill) and its own font colour.
##
## Tree has no `font_hovered_color` in Godot 4 — a hovered row keeps `font_color`
## and a hovered-selected one keeps `font_selected_color` — so its hover fills
## are checked against the colours that will really be drawn on them. Asking for
## a colour the control does not have is itself reported, which is how this was
## found.
const PAIRS := {
"Button": [
["normal", "font_color", false, SOLID],
["hover", "font_hover_color", false, SOLID],
["pressed", "font_pressed_color", false, SOLID],
["hover_pressed", "font_hover_pressed_color", false, SOLID],
["focus", "font_focus_color", false, SOLID],
["disabled", "font_disabled_color", false, SOLID],
],
"ItemList": [
["panel", "font_color", false, SOLID],
["hovered", "font_hovered_color", false, SOLID],
["selected", "font_selected_color", false, SOLID],
["selected_focus", "font_selected_color", false, SOLID],
["hovered_selected", "font_selected_color", false, SOLID],
],
"Tree": [
["panel", "font_color", false, SOLID],
["hovered", "font_color", false, SOLID],
["selected", "font_selected_color", false, SOLID],
["hovered_selected", "font_selected_color", false, SOLID],
],
"PopupMenu": [
["panel", "font_color", false, SOLID],
["panel", "font_disabled_color", false, SOLID],
["panel", "font_accelerator_color", false, SOLID],
["hover", "font_hover_color", false, SOLID],
],
"TabContainer": [
["tab_selected", "font_selected_color", false, SOLID],
["tab_unselected", "font_unselected_color", false, SOLID],
["tab_hovered", "font_hovered_color", false, SOLID],
["tab_disabled", "font_disabled_color", false, SOLID],
],
"LineEdit": [
["normal", "font_color", false, SOLID],
["normal", "font_placeholder_color", false, SOLID],
["normal", "selection_color:font_selected_color", false, SOLID],
["read_only", "font_uneditable_color", false, SOLID],
],
# The readout straddles both halves of the bar — see the note above.
"ProgressBar": [
["background", "font_color", false, OUTLINED],
["fill", "font_color", false, OUTLINED],
],
}
## Every button-ish theme type gets Button's pair list, since `_button_look`
## gives them all the same treatment.
const BUTTON_LIKE := ["OptionButton", "MenuButton", "CheckBox", "CheckButton",
"LinkButton"]
func _init() -> void:
var t: Theme = UITheme.build()
var fails: Array = []
var checked := 0
var classes := PAIRS.duplicate()
for cls in BUTTON_LIKE:
classes[cls] = PAIRS["Button"]
for cls in classes:
for pair in classes[cls]:
var sb_name: String = pair[0]
var fg_name: String = pair[1]
var large: bool = pair[2]
var mode: String = pair[3]
if not _has_flat(t, cls, sb_name):
fails.append("%s: no StyleBoxFlat '%s'" % [cls, sb_name])
continue
var bg: Color = _fill_of(t, cls, sb_name)
# "a:b" means the fill comes from colour `a` rather than a stylebox —
# LineEdit's text selection paints a colour, not a box.
var fg_key := fg_name
if ":" in fg_name:
var parts := fg_name.split(":")
if not t.has_color(parts[0], cls):
fails.append("%s: no colour '%s'" % [cls, parts[0]])
continue
bg = t.get_color(parts[0], cls)
fg_key = parts[1]
if not t.has_color(fg_key, cls):
fails.append("%s: no colour '%s'" % [cls, fg_key])
continue
var fg: Color = t.get_color(fg_key, cls)
checked += 1
var floor_ := FLOOR_LARGE if large else FLOOR_BODY
var where := "%s/%s + %s" % [cls, sb_name, fg_key]
if mode == OUTLINED:
fails.append_array(_check_outlined(t, cls, where, bg, fg, floor_))
continue
var ratio := UITheme.contrast(fg, bg)
if ratio < floor_:
fails.append("%s = %.2f:1 (needs %.1f) fill=%s text=%s"
% [where, ratio, floor_, bg.to_html(false), fg.to_html(false)])
# The declared table too, for the pairs no theme entry describes — a Label
# over a panel, the level card's caption over its scrim.
for entry in UITheme.state_table():
var name: String = entry[0]
var fill: Color = entry[1]
var text: Color = entry[2]
var large: bool = entry[3]
checked += 1
var ratio := UITheme.contrast(text, fill)
var floor_ := FLOOR_LARGE if large else FLOOR_BODY
if ratio < floor_:
fails.append("table %s = %.2f:1 (needs %.1f)" % [name, ratio, floor_])
print("UI CONTRAST: %d pairs checked" % checked)
if fails.is_empty():
print("PASS — every state reads")
quit(0)
return
for f in fails:
print(" FAIL ", f)
print("FAIL — %d unreadable state(s)" % fails.size())
quit(1)
## Text whose backdrop varies, read either directly or against its own outline.
##
## The outline is a FALLBACK, not a replacement: where the glyph already beats the
## backdrop on its own the outline is free to be the same colour as that backdrop
## and simply do nothing, which is what a paper readout over the bar's near-black
## trough is. It is only when the direct pair fails — paper over hot papaya — that
## the outline has to carry it, and then both of its ratios must hold.
##
## Returns the failures rather than printing, so the caller keeps the tally.
func _check_outlined(t: Theme, cls: String, where: String, bg: Color, fg: Color,
floor_: float) -> Array:
var out: Array = []
if UITheme.contrast(fg, bg) >= floor_:
return out
if not t.has_color("font_outline_color", cls):
return ["%s: outlined but no font_outline_color" % where]
var edge: Color = t.get_color("font_outline_color", cls)
var width: int = t.get_constant("outline_size", cls) \
if t.has_constant("outline_size", cls) else 0
if width < MIN_OUTLINE:
out.append("%s: outline is %d px, needs %d" % [where, width, MIN_OUTLINE])
var edge_bg := UITheme.contrast(edge, bg)
if edge_bg < FLOOR_LARGE:
out.append("%s: outline vs fill = %.2f:1 (needs %.1f) outline=%s fill=%s"
% [where, edge_bg, FLOOR_LARGE, edge.to_html(false), bg.to_html(false)])
var fg_edge := UITheme.contrast(fg, edge)
if fg_edge < floor_:
out.append("%s: text vs outline = %.2f:1 (needs %.1f) text=%s outline=%s"
% [where, fg_edge, floor_, fg.to_html(false), edge.to_html(false)])
return out
## Whether the theme has a MEASURABLE box under that name. Only StyleBoxFlat
## carries a fill colour; anything else is not something to take a ratio against.
func _has_flat(t: Theme, cls: String, sb_name: String) -> bool:
return t.has_stylebox(sb_name, cls) and t.get_stylebox(sb_name, cls) is StyleBoxFlat
func _fill_of(t: Theme, cls: String, sb_name: String) -> Color:
return (t.get_stylebox(sb_name, cls) as StyleBoxFlat).bg_color
+1
View File
@@ -0,0 +1 @@
uid://78mssihccx1s
+1 -1
View File
@@ -26,7 +26,7 @@ func _process(_delta: float) -> bool:
_shot("menu")
var nm = root.get_node_or_null("NetworkManager")
if nm and nm.has_method("start_singleplayer_match"):
nm.start_singleplayer_match("Deathmatch")
nm.start_singleplayer_match(GameMode.DEATHMATCH)
change_scene_to_file(_scene)
elif _frames == 240:
_shot("level")
+250
View File
@@ -0,0 +1,250 @@
extends SceneTree
## Does the character actually hold each weapon DIFFERENTLY?
##
## godot --path . -s res://debug/weapon_hold_check.gd
##
## Every weapon used to be solved as a rifle: stock in the shoulder pocket,
## support hand out along the barrel, muzzle on the aim line. So a knife, an AWP
## and a rocket launcher produced the same pose, and the only thing telling a
## viewer what was being carried was the weapon mesh itself — which at the
## distance an enemy is usually seen is a few pixels.
##
## This asserts the consequence, not the plumbing. Setting `support_mode` and
## reading it back proves nothing; a hold system is easy to build so that the
## profile loads, the enum is stored and the JSON round-trips while the arms do
## not move. So it measures WHERE THE HANDS AND HEAD ACTUALLY ARE, per weapon,
## and requires the poses to be distinguishable from each other.
##
## ── Measured from inside the modifier pass ──────────────────────────────────
##
## Godot restores every bone's local pose after `SkeletonModifier3D` runs, so
## reading `get_bone_global_pose` from a SceneTree script recomputes the globals
## from the ANIMATION alone — the shooter hold is not in what you measure, and
## every weapon would report an identical pose whether or not this feature
## exists. That is the single most expensive trap in this repo; see
## `references/verification.md`. The `PoseProbe` below is the fix.
##
## ── Measured in the SHOULDER's frame ────────────────────────────────────────
##
## Not in world space, and not even in skeleton space. The hold breathes — a
## `sin(_time * 2.2) * 0.012` on the muzzle pitch — and the idle clip moves the
## whole torso, so no hand is ever at the same place twice and two samples of the
## SAME weapon would differ by more than two different weapons do. Taking each
## hand relative to the right shoulder joint, in the chest's own basis, cancels
## both, because they move the shoulder and the hand together.
const LAB := "res://debug/rig_lab.tscn"
## One weapon per style, plus the two rifles, so the table covers every branch
## and also shows that two weapons of the SAME style stay close together.
const CASES := [
["ak47", WeaponHoldProfiles.RIFLE],
["m4", WeaponHoldProfiles.RIFLE],
["mp7", WeaponHoldProfiles.SMG],
["awp", WeaponHoldProfiles.SNIPER],
["double_barrel_shotgun", WeaponHoldProfiles.SHOTGUN],
["rocket_launcher", WeaponHoldProfiles.LAUNCHER],
["knife", WeaponHoldProfiles.BLADE],
]
## Two weapons of DIFFERENT styles must place their hands at least this far
## apart, in metres, measured in the shoulder frame. Small — these are stylised
## characters with ~0.47 m arms — but far above the millimetre of noise the
## shoulder-frame measurement leaves behind.
const MIN_STYLE_SEPARATION := 0.045
## Two weapons of the SAME style should agree to within this.
const MAX_SAME_STYLE := 0.06
var _fails := 0
var _probe: PoseProbe = null
## Snapshot the pose from INSIDE the modifier pass. See the note above.
class PoseProbe extends SkeletonModifier3D:
var pose: Array = []
func _process_modification() -> void:
var skel := get_skeleton()
if skel == null:
return
pose.resize(skel.get_bone_count())
for i in skel.get_bone_count():
pose[i] = skel.get_bone_global_pose(i)
func _init() -> void:
await process_frame
var lab: Node = load(LAB).instantiate()
root.add_child(lab)
for _i in 200:
await process_frame
if lab._model == null or lab._model._pose_mod == null:
_expect(false, "the lab built a character with a pose layer")
_done()
return
var skel: Skeleton3D = lab._model.skeleton
_probe = PoseProbe.new()
skel.add_child(_probe)
# AFTER the pose layer, so what it reads is what renders.
skel.move_child(_probe, skel.get_child_count() - 1)
var samples := {}
for case in CASES:
var weapon_id: String = case[0]
var want_style: String = case[1]
_expect(WeaponHoldProfiles.style_for(weapon_id) == want_style,
"%s is held as a %s" % [weapon_id, want_style])
# Whether this character has a SAVED hold for this weapon, which is
# allowed to disagree with the style profile. See `_compare`.
var tuned: bool = not WeaponHoldTuning.resolve(
WeaponHoldTuning.load_all(), lab._skins[lab._skin].id,
weapon_id).is_empty()
lab._model.hold_tune = {}
lab._model.set_weapon("res://weapons/%s.gd" % weapon_id)
# Long enough for HOLD_SMOOTH to arrive and for the weapon's `ready` to
# have measured it. The hold blends in at ~8/s, so ~60 frames is several
# time constants.
for _i in 90:
await process_frame
samples[weapon_id] = _sample(skel, lab._model)
if not samples[weapon_id].is_empty():
samples[weapon_id]["tuned"] = tuned
_report(samples)
_compare(samples)
_done()
## Both hands and the head, relative to the right shoulder, in the chest's basis.
func _sample(skel: Skeleton3D, model) -> Dictionary:
var mod = model._pose_mod
var sh_i: int = mod._idx.get("DEF-upper_arm.R", -1)
var chest_i: int = mod._idx.get("DEF-spine.003", -1)
if chest_i < 0:
chest_i = mod._idx.get("DEF-spine.002", -1)
var hand_r: int = mod._idx.get("DEF-hand.R", -1)
var hand_l: int = mod._idx.get("DEF-hand.L", -1)
var head_i: int = mod._idx.get("DEF-head", -1)
if sh_i < 0 or hand_r < 0 or hand_l < 0:
return {}
var pose: Array = _probe.pose
if pose.size() <= maxi(maxi(sh_i, hand_r), hand_l):
return {}
var shoulder: Vector3 = pose[sh_i].origin
# The chest's rotation, so a torso lean does not read as a moved hand.
var frame: Basis = Basis.IDENTITY
if chest_i >= 0 and chest_i < pose.size():
frame = pose[chest_i].basis.orthonormalized()
var inv := frame.inverse()
var out := {
"r": inv * (pose[hand_r].origin - shoulder),
"l": inv * (pose[hand_l].origin - shoulder),
"style": model.hold_style,
"support": mod.support_mode,
"hold_l": mod._hold_l,
}
if head_i >= 0 and head_i < pose.size():
# The head's TILT, which is what a cheek weld is. Taken as the angle
# between the head's up axis and the chest's, signed about forward, so a
# weld (toward the weapon) and a lean-away come out opposite.
var head_up: Vector3 = pose[head_i].basis.orthonormalized().y
var local := inv * head_up
out["cheek_deg"] = rad_to_deg(atan2(local.x, local.y))
return out
func _report(samples: Dictionary) -> void:
print("\n=== HOLD POSE PER WEAPON (metres, in the shoulder's frame) ===")
for id in samples:
var s: Dictionary = samples[id]
if s.is_empty():
continue
print(" %-22s %-9s support=%-7s handR=(%.3f %.3f %.3f) handL=(%.3f %.3f %.3f) offhand=%.2f tilt=%+.1f deg"
% [id, s["style"], s["support"],
s["r"].x, s["r"].y, s["r"].z, s["l"].x, s["l"].y, s["l"].z,
s["hold_l"], s.get("cheek_deg", 0.0)])
func _compare(samples: Dictionary) -> void:
# Every pair of DIFFERENT styles must be distinguishable.
var ids: Array = samples.keys()
for i in ids.size():
for j in range(i + 1, ids.size()):
var a: Dictionary = samples[ids[i]]
var b: Dictionary = samples[ids[j]]
if a.is_empty() or b.is_empty():
continue
# The blade releases its off hand to the animation, so comparing its
# left hand measures the idle clip, not the hold. Its trigger hand
# and its free offhand weight are what distinguish it.
var d: float = float(a["r"].distance_to(b["r"]))
if a["hold_l"] > 0.5 and b["hold_l"] > 0.5:
d = maxf(d, a["l"].distance_to(b["l"]))
if a["style"] == b["style"]:
# ...unless an artist has tuned one of them for THIS character.
# The profile is only a defaults layer; a saved hold is meant to
# be able to disagree with it, and aria's hand-tuned AK-47 sits
# 0.22 m from the untuned M4 for exactly that reason. Asserting
# they match would be asserting that the rig lab does nothing.
if a["tuned"] or b["tuned"]:
print(" -- %s and %s are both %s but %s is hand-tuned (%.3f m apart)"
% [ids[i], ids[j], a["style"],
ids[i] if a["tuned"] else ids[j], d])
continue
_expect(d <= MAX_SAME_STYLE,
"%s and %s are both %s and hold alike (%.3f m apart)"
% [ids[i], ids[j], a["style"], d])
else:
_expect(d >= MIN_STYLE_SEPARATION,
"%s (%s) and %s (%s) are held differently (%.3f m apart)"
% [ids[i], a["style"], ids[j], b["style"], d])
# The blade must actually let go of the off arm — that released arm is most
# of what makes a one-handed weapon read as one-handed.
if samples.has("knife"):
_expect(samples["knife"]["hold_l"] < 0.05,
"the knife releases the off arm to the animation (%.2f)"
% samples["knife"]["hold_l"])
for id in ["ak47", "awp", "rocket_launcher"]:
if samples.has(id):
_expect(samples[id]["hold_l"] > 0.9,
"%s keeps both hands on the weapon" % id)
# The cheek weld, and its inverse on a shouldered tube. These are the two
# poses that read at the greatest distance, so they get their own assertion
# rather than relying on the pairwise distance.
if samples.has("awp") and samples.has("ak47"):
var d: float = float(samples["awp"].get("cheek_deg", 0.0)) - float(samples["ak47"].get("cheek_deg", 0.0))
_expect(absf(d) > 1.5,
"the sniper welds its head to the stock (%+.1f deg vs the rifle)" % d)
if samples.has("rocket_launcher") and samples.has("ak47"):
var dl: float = float(samples["rocket_launcher"].get("cheek_deg", 0.0))
var dr: float = float(samples["ak47"].get("cheek_deg", 0.0))
var da: float = float(samples["awp"].get("cheek_deg", 0.0))
_expect((dl - dr) * (da - dr) < 0.0,
"the launcher leans the head AWAY, opposite the sniper (%+.1f vs %+.1f)"
% [dl - dr, da - dr])
func _expect(ok: bool, what: String) -> void:
if ok:
print(" OK: ", what)
else:
print(" FAIL: ", what)
_fails += 1
func _done() -> void:
print("\n=== WEAPON HOLD SUMMARY ===")
print("Failures: %d" % _fails)
quit(1 if _fails > 0 else 0)
+1
View File
@@ -0,0 +1 @@
uid://f7600k001iqr
+265
View File
@@ -0,0 +1,265 @@
extends Object
class_name GameMode
## What a match IS: how you score, when it ends, and who won.
##
## Before this there was a string — `NetworkManager.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 ever declared, no summary was ever shown, and nothing was tracked
## beyond a running kill count. A match did not end so much as stop mattering.
##
## The three things a mode has to answer are answered here:
##
## HOW YOU SCORE what a kill is worth, and to whom. In Gun Game a kill is
## worth a rung on a ladder rather than a point.
## WHEN IT ENDS a score limit, a time limit, or both, whichever comes
## first. A mode with neither cannot end and is not a mode.
## WHO WON the top player, or the top TEAM, which are different
## questions and used to be the same one.
##
## Deliberately a plain data table with static helpers rather than an autoload:
## the menu, the lobby, the HUD and the server all need to ask what a mode is,
## and half of them run before any autoload would be ready.
const DEATHMATCH := "deathmatch"
const TEAM_DEATHMATCH := "team_deathmatch"
const GUN_GAME := "gun_game"
## The order they appear in menus. Deathmatch first because it is the one that
## needs no explanation.
const ORDER := [DEATHMATCH, TEAM_DEATHMATCH, GUN_GAME]
## `score_limit` of 0 means the mode has no score limit and ends on time alone.
## `time_limit` is in seconds; 0 means it ends on score alone. At least one of
## the two must be non-zero or the match cannot end — `is_valid` checks that.
const MODES := {
DEATHMATCH: {
"name": "Deathmatch",
"blurb": "Everyone for themselves. First to the frag limit takes it.",
"score_limit": 25,
"time_limit": 600.0,
"teams": 0,
"friendly_fire": true,
"score_noun": "KILLS",
# What one kill is worth to the killer.
"kill_score": 1,
# What dying costs. Zero: a deathmatch that punishes dying rewards
# hiding, and this is a game about momentum.
"death_score": 0,
},
TEAM_DEATHMATCH: {
"name": "Team Deathmatch",
"blurb": "Two squads. Team score is what counts.",
"score_limit": 50,
"time_limit": 600.0,
"teams": 2,
"friendly_fire": false,
"score_noun": "SCORE",
"kill_score": 1,
"death_score": 0,
},
GUN_GAME: {
"name": "Gun Game",
"blurb": "Every kill promotes you to the next weapon. Finish the ladder.",
# The limit IS the ladder length, filled in from LADDER below so the two
# can never disagree.
"score_limit": 0,
"time_limit": 900.0,
"teams": 0,
"friendly_fire": true,
"score_noun": "RUNG",
"kill_score": 1,
"death_score": 0,
},
}
## Gun Game's ladder, by weapon id (see LoadoutManager.weapon_db). Ordered
## roughly easiest to hardest to get a kill with, so the last rung is a real
## finish rather than a formality.
const LADDER := ["ak47", "m4", "mp7", "dmr", "plasma_gun", "nail_gun",
"double_barrel_shotgun", "rocket_launcher", "awp", "knife"]
static func all_ids() -> Array:
return ORDER.duplicate()
static func get_mode(id: String) -> Dictionary:
return MODES.get(id, MODES[DEATHMATCH])
static func display_name(id: String) -> String:
return String(get_mode(id).get("name", id))
static func blurb(id: String) -> String:
return String(get_mode(id).get("blurb", ""))
## The score that ends the match. For Gun Game this is the ladder's length, so
## adding a weapon to the ladder cannot leave the win condition unreachable.
static func score_limit(id: String) -> int:
if id == GUN_GAME:
return LADDER.size()
return int(get_mode(id).get("score_limit", 0))
static func time_limit(id: String) -> float:
return float(get_mode(id).get("time_limit", 0.0))
static func team_count(id: String) -> int:
return int(get_mode(id).get("teams", 0))
static func is_team_mode(id: String) -> bool:
return team_count(id) > 1
static func friendly_fire(id: String) -> bool:
return bool(get_mode(id).get("friendly_fire", true))
static func score_noun(id: String) -> String:
return String(get_mode(id).get("score_noun", "SCORE"))
## A mode with neither a score limit nor a time limit can never end. This is the
## one invariant worth asserting about the table itself, and debug/game_mode_check
## runs it over every entry.
static func is_valid(id: String) -> bool:
return score_limit(id) > 0 or time_limit(id) > 0.0
## The weapon a player on `rung` of the Gun Game ladder is holding.
static func ladder_weapon(rung: int) -> String:
if LADDER.is_empty():
return ""
return LADDER[clampi(rung, 0, LADDER.size() - 1)]
## Team colours, for the scoreboard, the killfeed and the HUD. Index 0 is no
## team — free-for-all — which reads as the game's own papaya.
const TEAM_COLORS := [Color(1.00, 0.47, 0.10), Color(0.22, 0.94, 1.00),
Color(1.00, 0.18, 0.52)]
const TEAM_NAMES := ["", "CYAN", "MAGENTA"]
static func team_color(team: int) -> Color:
return TEAM_COLORS[clampi(team, 0, TEAM_COLORS.size() - 1)]
static func team_name(team: int) -> String:
if team <= 0 or team >= TEAM_NAMES.size():
return ""
return TEAM_NAMES[team]
## Which team a joining player goes on: the smallest, ties broken toward team 1.
##
## Assigned by COUNT rather than round-robin on join order, because players
## leave. Round-robin on a 4v4 that loses three from one side stays 4v1 forever;
## this refills the short side.
static func assign_team(id: String, stats: Dictionary) -> int:
var teams := team_count(id)
if teams < 2:
return 0
var counts := []
counts.resize(teams + 1)
counts.fill(0)
for pid in stats:
var t: int = int(stats[pid].get("team", 0))
if t >= 1 and t <= teams:
counts[t] += 1
var best := 1
for t in range(1, teams + 1):
if counts[t] < counts[best]:
best = t
return best
## Every team's total, as `team index -> score`. Team 0 is never included.
static func team_scores(id: String, stats: Dictionary) -> Dictionary:
var out := {}
if not is_team_mode(id):
return out
for t in range(1, team_count(id) + 1):
out[t] = 0
for pid in stats:
var t: int = int(stats[pid].get("team", 0))
if out.has(t):
out[t] += int(stats[pid].get("score", 0))
return out
## Has anyone won yet? Returns `{}` if not, else the reason and the winner.
##
## Server-side only — this is the authority on when a match stops. It is a pure
## function of the stats so it can be unit-tested without a match running, which
## debug/game_mode_check.gd does.
static func check_win(id: String, stats: Dictionary, time_left: float) -> Dictionary:
var limit := score_limit(id)
if is_team_mode(id):
var totals := team_scores(id, stats)
if limit > 0:
for t in totals:
if totals[t] >= limit:
return {"reason": "score", "team": t,
"name": team_name(t) + " TEAM"}
if time_left <= 0.0:
var best := 0
var best_score := -1
var tied := false
for t in totals:
if totals[t] > best_score:
best_score = totals[t]
best = t
tied = false
elif totals[t] == best_score:
tied = true
if tied:
return {"reason": "time", "team": 0, "name": "DRAW"}
return {"reason": "time", "team": best,
"name": team_name(best) + " TEAM"}
return {}
# Free-for-all.
if limit > 0:
for pid in stats:
if int(stats[pid].get("score", 0)) >= limit:
return {"reason": "score", "player": pid,
"name": String(stats[pid].get("username", "Player"))}
if time_left <= 0.0:
var top = null
var top_score := -1
var drawn := false
for pid in stats:
var sc: int = int(stats[pid].get("score", 0))
if sc > top_score:
top_score = sc
top = pid
drawn = false
elif sc == top_score:
drawn = true
if top == null or drawn:
return {"reason": "time", "player": 0, "name": "DRAW"}
return {"reason": "time", "player": top,
"name": String(stats[top].get("username", "Player"))}
return {}
## Players sorted best-first, by score then by fewest deaths.
##
## Fewest deaths as the tiebreak, not most kills: at equal score the kills are
## equal by definition in every mode here, so kills would not break anything.
static func standings(stats: Dictionary) -> Array:
var ids: Array = stats.keys()
ids.sort_custom(func(a, b):
var sa: int = int(stats[a].get("score", 0))
var sb: int = int(stats[b].get("score", 0))
if sa != sb:
return sa > sb
return int(stats[a].get("deaths", 0)) < int(stats[b].get("deaths", 0)))
return ids
+1
View File
@@ -0,0 +1 @@
uid://b8k2ha37f47ji
+211 -27
View File
@@ -7,18 +7,34 @@ signal connection_succeeded
signal match_state_updated
signal stats_updated
signal killfeed_event(victim: String, killer: String, weapon: String, v_color: String, k_color: String)
## The match is over. `result` carries the reason ("score" / "time"), the winner's
## name, and the final standings. The HUD raises the summary screen on this.
signal match_ended(result: Dictionary)
## A Gun Game player moved up a rung and needs their weapon swapped.
signal ladder_promoted(peer_id: int, rung: int, weapon_id: String)
const DEFAULT_PORT = 31415
var connected_players: Array[int] = []
# Dict of ID -> { "username": String, "kills": int, "deaths": int, "assists": int, "ping": int }
## ID -> { username, kills, deaths, assists, ping, color, score, team, streak,
## best_streak, rung }
##
## `score` is what the MODE counts and is what decides the match — in Deathmatch
## it tracks kills, in Gun Game it is the ladder rung. `kills` stays a plain
## kill count regardless, because the scoreboard shows both and they are not the
## same number in every mode.
var player_stats: Dictionary = {}
var match_active: bool = false
var match_time_remaining: float = 0.0
var current_gamemode: String = "Deathmatch"
## A GameMode id — see globals/game_mode.gd. Was a display string ("Deathmatch")
## compared by equality in three places; an id is what the mode table is keyed on
## and what survives being sent over the wire.
var current_gamemode: String = GameMode.DEATHMATCH
var current_scene_path: String = ""
## Set when the match ends, so a late-arriving HUD can still show the summary.
var last_result: Dictionary = {}
var _ping_timer: Timer
@@ -62,31 +78,54 @@ func disconnect_game() -> void:
match_active = false
_ping_timer.stop()
func start_singleplayer_match(gamemode: String = "Deathmatch") -> void:
func start_singleplayer_match(gamemode: String = GameMode.DEATHMATCH) -> void:
# Ensure peer is offline if not already connected
if not multiplayer.has_multiplayer_peer() or multiplayer.multiplayer_peer is OfflineMultiplayerPeer:
multiplayer.multiplayer_peer = OfflineMultiplayerPeer.new()
connected_players.clear()
connected_players.append(1)
# Normalised, because `current_gamemode` used to be a DISPLAY string
# ("Deathmatch") and half a dozen callers still pass one. GameMode's lookups
# all fall back safely, but an id that is not in the table would compare
# unequal to GameMode.GUN_GAME and silently disable Gun Game's weapon
# issuing — a mode that half works is worse than one that does not load.
current_gamemode = gamemode if GameMode.MODES.has(gamemode) \
else GameMode.DEATHMATCH
player_stats.clear()
_init_player_stats(1, SettingsManager.username)
last_result = {}
match_active = true
match_time_remaining = 300.0
current_gamemode = gamemode
# The mode's own clock, not a hardcoded five minutes. Gun Game is a fifteen
# minute mode and Deathmatch a ten minute one; both used to be five.
match_time_remaining = GameMode.time_limit(gamemode)
rpc("sync_match_state", match_active, match_time_remaining, current_gamemode)
func _init_player_stats(id: int, username: String) -> void:
# Keep existing color if player re-joins, or generate new
var p_color = Color(randf_range(0.2, 1.0), randf_range(0.2, 1.0), randf_range(0.2, 1.0)).to_html(false)
var team := GameMode.assign_team(current_gamemode, player_stats)
if team > 0:
# In a team mode the player's colour IS their team's, because a killfeed
# where a teammate and an enemy are both "some random colour" is a
# killfeed that cannot be read at a glance.
p_color = GameMode.team_color(team).to_html(false)
player_stats[id] = {
"username": username,
"kills": 0,
"deaths": 0,
"assists": 0,
"ping": 0,
"color": p_color
"color": p_color,
# What the MODE counts. See the note on player_stats.
"score": 0,
"team": team,
"streak": 0,
"best_streak": 0,
# Gun Game ladder position.
"rung": 0,
}
stats_updated.emit()
@@ -169,20 +208,93 @@ func rpc_load_level(scene_path: String) -> void:
print("Loading level: ", scene_path)
current_scene_path = scene_path
if multiplayer.is_server():
# Reset the scores as well as the clock. Loading a level used to restart
# the timer and leave every kill from the previous match standing, so the
# second match on a server started with someone already at the frag limit
# — and now that reaching the limit ENDS the match, it would have ended
# on the first kill.
last_result = {}
for pid in player_stats:
for key in ["kills", "deaths", "assists", "score", "streak",
"best_streak", "rung"]:
player_stats[pid][key] = 0
match_active = true
match_time_remaining = 300.0 # 5 minutes
match_time_remaining = GameMode.time_limit(current_gamemode)
rpc("sync_player_stats", player_stats)
rpc("sync_match_state", match_active, match_time_remaining, current_gamemode)
get_tree().change_scene_to_file(scene_path)
func _process(delta: float) -> void:
if match_active:
if match_time_remaining > 0:
match_time_remaining -= delta
if match_time_remaining <= 0:
match_time_remaining = 0
match_active = false
# We emit this so HUDs can update their timers smoothly
match_state_updated.emit()
if not match_active:
return
if match_time_remaining > 0:
match_time_remaining -= delta
if match_time_remaining <= 0:
match_time_remaining = 0
# Time is a win CONDITION, not an off switch. This used to set
# `match_active = false` and stop, so the clock reached zero, the
# timer froze at 00:00 and nothing else happened — no winner, no
# summary, no way back to the menu. The match did not end so much as
# stop mattering.
if _is_authority():
_check_win()
# We emit this so HUDs can update their timers smoothly
match_state_updated.emit()
## Whether this peer decides the match. True for the server, and true in
## singleplayer, where there is no peer but somebody still has to call it.
func _is_authority() -> bool:
if not multiplayer.has_multiplayer_peer():
return true
if multiplayer.multiplayer_peer is OfflineMultiplayerPeer:
return true
return multiplayer.is_server()
## Has anyone won? Called on the server after every kill and when the clock runs
## out. GameMode.check_win is a pure function of the stats, so the rule lives
## next to the mode that defines it and can be tested without a match running.
func _check_win() -> void:
if not match_active:
return
var result := GameMode.check_win(current_gamemode, player_stats,
match_time_remaining)
if result.is_empty():
return
result["mode"] = current_gamemode
result["standings"] = GameMode.standings(player_stats)
result["stats"] = player_stats.duplicate(true)
if _is_authority() and multiplayer.has_multiplayer_peer() \
and not multiplayer.multiplayer_peer is OfflineMultiplayerPeer:
rpc("end_match", result)
else:
end_match(result)
@rpc("authority", "call_local", "reliable")
func end_match(result: Dictionary) -> void:
if not match_active:
return
match_active = false
last_result = result
match_state_updated.emit()
match_ended.emit(result)
## Wipe the scores and start the clock again on the same map and mode. The
## summary screen's "Play Again".
@rpc("authority", "call_local", "reliable")
func restart_match() -> void:
for pid in player_stats:
for key in ["kills", "deaths", "assists", "score", "streak",
"best_streak", "rung"]:
player_stats[pid][key] = 0
last_result = {}
match_active = true
match_time_remaining = GameMode.time_limit(current_gamemode)
stats_updated.emit()
match_state_updated.emit()
# --- PING SYSTEM ---
func _on_ping_timer() -> void:
@@ -208,19 +320,54 @@ func receive_ping_response(server_time: int) -> void:
# --- GAMEPLAY EVENTS ---
@rpc("any_peer", "call_local", "reliable")
func register_kill(victim_id: int, killer_id: int, weapon_name: String, custom_victim_name: String = "", assist_ids: Array = []) -> void:
if multiplayer.is_server():
if killer_id != 0 and killer_id != victim_id:
if player_stats.has(killer_id):
player_stats[killer_id]["kills"] += 1
if _is_authority():
# A kill after the final whistle counts for nothing. Without this a
# rocket already in the air when the clock hit zero could change the
# result after the summary was on screen.
if not match_active:
return
var clean_kill := killer_id != 0 and killer_id != victim_id
# A team kill is not a score. Friendly fire may be ON in a mode and the
# kill still must not advance the killer — otherwise the fastest way to
# win Team Deathmatch is to shoot your own team.
if clean_kill and GameMode.is_team_mode(current_gamemode) \
and player_stats.has(killer_id) and player_stats.has(victim_id) \
and player_stats[killer_id]["team"] == player_stats[victim_id]["team"]:
clean_kill = false
if clean_kill and player_stats.has(killer_id):
var ks: Dictionary = player_stats[killer_id]
ks["kills"] += 1
ks["streak"] += 1
ks["best_streak"] = maxi(ks["best_streak"], ks["streak"])
if current_gamemode == GameMode.GUN_GAME:
# A kill is a RUNG, not a point, and the rung IS the score — so
# reaching the top of the ladder is the same event as reaching
# the score limit, and only one win condition has to exist.
ks["rung"] += 1
ks["score"] = ks["rung"]
var weapon := GameMode.ladder_weapon(ks["rung"])
ladder_promoted.emit(killer_id, ks["rung"], weapon)
rpc("notify_promotion", killer_id, ks["rung"], weapon)
else:
ks["score"] += int(GameMode.get_mode(current_gamemode).get(
"kill_score", 1))
if victim_id != -1:
if player_stats.has(victim_id):
player_stats[victim_id]["deaths"] += 1
player_stats[victim_id]["streak"] = 0
var penalty := int(GameMode.get_mode(current_gamemode).get(
"death_score", 0))
if penalty != 0:
player_stats[victim_id]["score"] = maxi(
player_stats[victim_id]["score"] - penalty, 0)
for aid in assist_ids:
if player_stats.has(aid) and aid != killer_id and aid != victim_id:
player_stats[aid]["assists"] += 1
var v_name = player_stats[victim_id]["username"] if player_stats.has(victim_id) else (custom_victim_name if custom_victim_name != "" else "Unknown")
var k_name = player_stats[killer_id]["username"] if player_stats.has(killer_id) else "Unknown"
@@ -238,9 +385,46 @@ func register_kill(victim_id: int, killer_id: int, weapon_name: String, custom_v
if killer_id == 0 or killer_id == victim_id:
k_name = "" # Suicide or world death
broadcast_killfeed.rpc(v_name, k_name, weapon_name, v_color, k_color)
sync_player_stats.rpc(player_stats)
# `.rpc()` on an offline peer does not call locally, so singleplayer got
# no killfeed and no stat sync at all. Both paths now go through the
# same two calls.
if multiplayer.has_multiplayer_peer() \
and not multiplayer.multiplayer_peer is OfflineMultiplayerPeer:
broadcast_killfeed.rpc(v_name, k_name, weapon_name, v_color, k_color)
sync_player_stats.rpc(player_stats)
else:
broadcast_killfeed(v_name, k_name, weapon_name, v_color, k_color)
sync_player_stats(player_stats)
# The scores just changed, so this is exactly when a match can end.
_check_win()
@rpc("authority", "call_local", "reliable")
func broadcast_killfeed(victim: String, killer: String, weapon: String, v_color: String = "cccccc", k_color: String = "cccccc") -> void:
killfeed_event.emit(victim, killer, weapon, v_color, k_color)
## A Gun Game player moved up. Broadcast so every client's killfeed can say so
## and the promoted player's own client can swap their weapon.
@rpc("authority", "call_local", "reliable")
func notify_promotion(peer_id: int, rung: int, weapon_id: String) -> void:
ladder_promoted.emit(peer_id, rung, weapon_id)
## Whether `a` may damage `b`, per the mode. The only place friendly fire is
## decided, so a mode that turns it off turns it off everywhere.
func can_damage(attacker_id: int, victim_id: int) -> bool:
if attacker_id == victim_id:
return true
if GameMode.friendly_fire(current_gamemode):
return true
if not player_stats.has(attacker_id) or not player_stats.has(victim_id):
return true
var ta: int = int(player_stats[attacker_id].get("team", 0))
var tb: int = int(player_stats[victim_id].get("team", 0))
return ta == 0 or ta != tb
## Which team a peer is on, 0 for free-for-all.
func team_of(peer_id: int) -> int:
return int(player_stats.get(peer_id, {}).get("team", 0))
+96 -176
View File
@@ -27,10 +27,10 @@ var death_count: int = 0
var is_dead: bool = false
# UI
var health_bar: ProgressBar
var shield_bar: ProgressBar
var health_label: Label
var shield_label: Label
## The first-person HUD — reticle, vitals, ammo, death screen. See ui/player_hud.gd.
var _hud: PlayerHUD = null
## Kept because the controller drives them directly: the death screen is toggled
## by die()/respawn, and the ring is read by the reload logic.
var death_screen: Control
var ragdoll_instance: Node3D
@@ -64,8 +64,7 @@ var grapple_latch_player: AudioStreamPlayer
var grapple_swing_player: AudioStreamPlayer
# UI
var hit_marker: Control
var _hit_marker_tween: Tween
## The reload ring, owned by the HUD. Kept here because the reload logic reads it.
var reload_ring: Control
var grenades: int = 2
@@ -89,6 +88,13 @@ var synced_velocity: Vector3 = Vector3.ZERO
var synced_is_ads: bool = false
var synced_wall_side: float = 0.0 # -1 wall left, +1 wall right (wall-run lean)
var synced_is_dancing: bool = false # dance emote (B), shown on the model
## Which of the five routines in DanceRoutines is playing. Replicated, so other
## players see the emote that was actually chosen rather than always the first.
var synced_dance_index: int = 0
## The radial dial, and how long the emote button has been held. -1 means this
## press was consumed by stopping a dance and must not open the wheel.
var _emote_wheel: EmoteWheel = null
var _emote_held: float = -1.0
# Anime speed-lines overlay (local player only)
var _speedlines: ColorRect = null
@@ -146,7 +152,6 @@ func _ready() -> void:
if is_multiplayer_authority():
_damage_layer = CanvasLayer.new()
add_child(_damage_layer)
_setup_hit_marker()
_setup_hud()
_setup_speedlines()
else:
@@ -395,32 +400,6 @@ func _setup_speedlines() -> void:
_damage_layer.add_child(_speedlines)
func _setup_hit_marker() -> void:
hit_marker = Control.new()
hit_marker.set_anchors_preset(Control.PRESET_CENTER)
hit_marker.modulate.a = 0.0 # Hidden by default
_damage_layer.add_child(hit_marker)
# Draw an X perfectly centered
var length = 12
var thickness = 2
for angle in [PI/4, 3*PI/4, 5*PI/4, 7*PI/4]:
var rect = ColorRect.new()
rect.color = Color.WHITE
rect.size = Vector2(length, thickness)
rect.pivot_offset = rect.size / 2.0
# Center the rect itself at (0,0) before offset
var center_pos = -rect.size / 2.0
# Move it outward along the angle so it doesn't cover the exact center dot
rect.position = center_pos + Vector2(cos(angle), sin(angle)) * (length / 2.0)
rect.rotation = angle
hit_marker.add_child(rect)
# Reload Ring
reload_ring = load("res://ui/reload_ring.gd").new()
reload_ring.set_anchors_preset(Control.PRESET_CENTER)
_damage_layer.add_child(reload_ring)
func _setup_grapple() -> void:
grapple_rope = MeshInstance3D.new()
var rope_mesh = CylinderMesh.new()
@@ -462,11 +441,13 @@ func spawn_damage_number(amount: float, hit_pos: Vector3) -> void:
var label = load("res://ui/floating_damage_text.gd").new()
label.text = str(round(amount))
label.add_theme_font_size_override("font_size", 24)
label.add_theme_color_override("font_color", Color(1.0, 0.6, 0.1))
label.add_theme_color_override("font_outline_color", Color(0, 0, 0, 0.8))
label.add_theme_constant_override("outline_size", 4)
label.add_theme_font_size_override("font_size", 28)
# The theme's papaya and its violet ink, not an approximate orange on pure
# black — these numbers fly over the same 3D scene the rest of the HUD does.
label.add_theme_color_override("font_color", UITheme.PAPAYA)
label.add_theme_color_override("font_outline_color", UITheme.INK)
label.add_theme_constant_override("outline_size", 7)
# Small random offset in 3D space so multiple pellets don't perfectly overlap
var offset = Vector3(randf_range(-0.4, 0.4), randf_range(-0.4, 0.4), randf_range(-0.4, 0.4))
label.target_pos = hit_pos + offset
@@ -480,13 +461,10 @@ func spawn_damage_number(amount: float, hit_pos: Vector3) -> void:
if now - _last_hit_sound_time > 10:
_last_hit_sound_time = now
hit_player.play()
if _hit_marker_tween and _hit_marker_tween.is_valid():
_hit_marker_tween.kill()
hit_marker.modulate.a = 1.0
_hit_marker_tween = create_tween()
_hit_marker_tween.tween_property(hit_marker, "modulate:a", 0.0, 0.4)
# The confirmation is part of the reticle now, so it lands where the eye
# already is and shares its ink outline.
if is_instance_valid(_hud):
_hud.confirm_hit()
func apply_impulse(force: Vector3) -> void:
velocity += force
@@ -674,6 +652,16 @@ func rpc_play_explosion(pos: Vector3, radius: float) -> void:
func server_take_damage(amount: float, hit_pos: Vector3, attacker_id: int, weapon_name: String, impulse: Vector3) -> void:
if not multiplayer.is_server(): return
# Friendly fire, decided by the MODE and enforced here — on the server, before
# the damage is broadcast — so a mode that turns it off turns it off for real
# rather than merely declining to award the kill. Team Deathmatch with damage
# that lands and a kill that does not count is worse than either.
var nm = get_node_or_null("/root/NetworkManager")
if nm and nm.has_method("can_damage") and attacker_id != 0:
var victim_id := int(str(name)) if str(name).is_valid_int() else 0
if victim_id != 0 and not nm.can_damage(attacker_id, victim_id):
return
# Broadcast damage event to all peers so the victim dies on all screens.
# Knockback is applied inside rpc_take_damage on the victim's own peer,
# since that peer simulates this body.
@@ -860,21 +848,45 @@ func _physics_process(_delta: float) -> void:
if Input.is_action_just_pressed("toggle_camera_view"):
set_third_person(not third_person)
# Dance emote (B): toggles while grounded and idle-ish; any
# movement/jump/crouch input breaks it.
# Emote (B): HOLD to open the radial dial and point at a dance,
# release to commit. A tap too short to have aimed anything just
# toggles the last one, which is exactly what the button did before
# the wheel existed — so the old muscle memory still works.
if Input.is_action_just_pressed("emote"):
_emote_held = 0.0
if synced_is_dancing:
# Already dancing: the press stops it, and no wheel opens.
# Having to aim at something in order to STOP is the most
# annoying possible way to build this.
synced_is_dancing = false
_emote_held = -1.0
elif _emote_wheel:
_emote_wheel.open()
elif Input.is_action_pressed("emote") and _emote_held >= 0.0:
_emote_held += _delta
elif Input.is_action_just_released("emote") and _emote_held >= 0.0:
var aimed := _emote_wheel.close() if _emote_wheel else -1
# A tap replays the last emote; a hold plays whatever was aimed
# at. Either way the same grounded-and-slow gate applies.
var pick := aimed if aimed >= 0 else synced_dance_index
var m := _ensure_machine()
var slow: bool = Vector2(velocity.x, velocity.z).length() < 1.0
if not synced_is_dancing and m and m.current_state == "ground" and slow:
if m and m.current_state == "ground" and slow:
synced_dance_index = pick
synced_is_dancing = true
else:
synced_is_dancing = false
if synced_is_dancing:
var m2 := _ensure_machine()
var moving := raw_input.length() > 0.1 or input_jump or input_crouch or input_dash
var airborne: bool = m2 and m2.current_state != "ground"
if moving or airborne:
synced_is_dancing = false
# The wheel eats aiming while it is open, so the player picking an emote
# does not also spin their character round. Movement is deliberately NOT
# blocked — a wheel that roots you in the open is a wheel nobody uses.
if _emote_wheel and _emote_wheel.visible and head_pivot:
head_pivot.set_process_input(false)
elif head_pivot and not head_pivot.is_processing_input() and not is_dead:
head_pivot.set_process_input(true)
var machine := _ensure_machine()
if machine:
@@ -949,7 +961,7 @@ func _physics_process(_delta: float) -> void:
if visual.has_method("set_wall_side"):
visual.set_wall_side(sm.wall_side)
if visual.has_method("set_dancing"):
visual.set_dancing(synced_is_dancing)
visual.set_dancing(synced_is_dancing, synced_dance_index)
if visual.has_method("set_grapple_target") and sm.current_state == "grapple":
visual.set_grapple_target(synced_grapple_point)
@@ -1034,7 +1046,7 @@ func _process(delta: float) -> void:
if visual.has_method("set_wall_side"):
visual.set_wall_side(synced_wall_side)
if visual.has_method("set_dancing"):
visual.set_dancing(synced_is_dancing)
visual.set_dancing(synced_is_dancing, synced_dance_index)
if visual.has_method("set_grapple_target") and synced_movement_state == "grapple":
visual.set_grapple_target(synced_grapple_point)
# Upper body follows the owner's synced camera pitch
@@ -1088,28 +1100,12 @@ func _process(delta: float) -> void:
lvisual.add_gun_recoil()
_last_local_ammo = ammo_now
# Update UI
if is_instance_valid(health_bar):
health_bar.value = health
health_label.text = "%d / %d" % [ceil(health), max_health]
if is_instance_valid(shield_bar):
shield_bar.value = shield
shield_label.text = "%d / %d" % [ceil(shield), max_shield]
# Update Reload Ring
if is_instance_valid(reload_ring) and is_instance_valid(camera):
var wman = camera.get_node_or_null("WeaponManager")
if wman:
var slot = wman.get("active_slot")
if slot != null and wman.weapons.has(slot):
var w = wman.weapons[slot]
if "reloading" in w and w.reloading and "reload_timer" in w and "reload_time" in w:
reload_ring.progress = 1.0 - (w.reload_timer / w.reload_time)
else:
reload_ring.progress = 0.0
else:
reload_ring.progress = 0.0
# Vitals are pushed (the controller owns health and shield); ammo, the
# reticle bloom and the reload ring are pulled by the HUD from the weapon,
# which is the authority on all three.
if is_instance_valid(_hud):
_hud.set_vitals(health, max_health, shield, max_shield)
if is_multiplayer_authority() and not is_dead:
if is_holding_grenade and grenades > 0:
_draw_trajectory()
@@ -1139,112 +1135,36 @@ func _process(delta: float) -> void:
if shield > max_shield:
shield = max_shield
## Build the first-person HUD.
##
## Everything it draws lives in ui/player_hud.gd now. This used to be 105 lines
## of stock ProgressBars and a plain-black death screen inlined here, which is
## why none of it shared the game's look: a HUD assembled inside a 1500-line
## movement controller is a HUD nobody styles.
func _setup_hud() -> void:
var margin = MarginContainer.new()
margin.set_anchors_preset(Control.PRESET_BOTTOM_LEFT)
margin.offset_left = 20
margin.offset_bottom = -20
margin.grow_vertical = Control.GROW_DIRECTION_BEGIN
var vbox = VBoxContainer.new()
margin.add_child(vbox)
# Add Match HUD overlay
# Match state (timer, score, killfeed, scoreboard) is a separate overlay and
# is deliberately not part of the player's own HUD.
if is_multiplayer_authority():
var match_hud_scene = load("res://ui/match_hud.tscn")
if match_hud_scene:
var match_hud = match_hud_scene.instantiate()
add_child(match_hud)
# Shield Bar (Top)
var shield_box = VBoxContainer.new()
var s_title = Label.new()
s_title.text = "SHIELD"
s_title.add_theme_font_size_override("font_size", 12)
s_title.add_theme_color_override("font_color", Color(0.4, 0.7, 1.0))
shield_box.add_child(s_title)
shield_bar = ProgressBar.new()
shield_bar.custom_minimum_size = Vector2(200, 20)
shield_bar.max_value = max_shield
shield_bar.value = shield
shield_bar.show_percentage = false
var s_sb = StyleBoxFlat.new()
s_sb.bg_color = Color(0.1, 0.4, 0.8)
shield_bar.add_theme_stylebox_override("fill", s_sb)
shield_label = Label.new()
shield_label.text = "100 / 100"
shield_label.set_anchors_preset(Control.PRESET_CENTER)
shield_bar.add_child(shield_label)
shield_box.add_child(shield_bar)
vbox.add_child(shield_box)
# Spacer
vbox.add_child(Control.new())
# Health Bar (Bottom)
var health_box = VBoxContainer.new()
var h_title = Label.new()
h_title.text = "HEALTH"
h_title.add_theme_font_size_override("font_size", 12)
h_title.add_theme_color_override("font_color", Color(1.0, 0.3, 0.3))
health_box.add_child(h_title)
health_bar = ProgressBar.new()
health_bar.custom_minimum_size = Vector2(200, 20)
health_bar.max_value = max_health
health_bar.value = health
health_bar.show_percentage = false
var h_sb = StyleBoxFlat.new()
h_sb.bg_color = Color(0.8, 0.1, 0.1)
health_bar.add_theme_stylebox_override("fill", h_sb)
health_label = Label.new()
health_label.text = "100 / 100"
health_label.set_anchors_preset(Control.PRESET_CENTER)
health_bar.add_child(health_label)
health_box.add_child(health_bar)
vbox.add_child(health_box)
_damage_layer.add_child(margin)
# Setup Death Screen
death_screen = ColorRect.new()
death_screen.color = Color(0, 0, 0, 0.7)
death_screen.set_anchors_preset(Control.PRESET_FULL_RECT)
death_screen.visible = false
var center = CenterContainer.new()
center.set_anchors_preset(Control.PRESET_FULL_RECT)
death_screen.add_child(center)
var d_vbox = VBoxContainer.new()
d_vbox.add_theme_constant_override("separation", 20)
center.add_child(d_vbox)
var d_title = Label.new()
d_title.text = "SYSTEM FAILURE"
d_title.add_theme_font_size_override("font_size", 48)
d_title.add_theme_color_override("font_color", Color(1.0, 0.2, 0.2))
d_vbox.add_child(d_title)
var respawn_label = Label.new()
respawn_label.text = "PRESS ANY KEY TO REBOOT"
respawn_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
respawn_label.add_theme_font_size_override("font_size", 24)
d_vbox.add_child(respawn_label)
var tw = create_tween().set_loops()
tw.tween_property(respawn_label, "modulate:a", 0.2, 0.8)
tw.tween_property(respawn_label, "modulate:a", 1.0, 0.8)
if is_multiplayer_authority():
_damage_layer.add_child(death_screen)
add_child(match_hud_scene.instantiate())
_hud = PlayerHUD.new()
_hud.name = "PlayerHUD"
_hud.player = self
add_child(_hud)
# The emote dial rides on the HUD's canvas, above the viewmodel.
_emote_wheel = EmoteWheel.new()
_emote_wheel.name = "EmoteWheel"
_hud.add_child(_emote_wheel)
# The controller still owns these two — it toggles the death screen on death
# and the ring is read by the reload logic — so keep the references it had.
death_screen = _hud.death_screen
reload_ring = _hud.reload_ring
_hud.set_vitals(health, max_health, shield, max_shield)
func die(impulse: Vector3 = Vector3.ZERO) -> void:
if is_dead: return
+15 -293
View File
@@ -1,18 +1,7 @@
extends Node3D
class_name LevelRuntime
var _speed_label: Label
var _state_label: Label
var _chain_label: Label
var _weapon_label: Label
var _grapple_icon: TextureRect
var _dash_icon: TextureRect
var _grapple_label: Label
var _dash_label: Label
var _fps_label: Label
var _standalone_speed_label: Label
var _player: CharacterBody3D
var _debug_ui_panel: PanelContainer
func _ready() -> void:
@@ -24,7 +13,6 @@ func _ready() -> void:
if not has_node("WorldEnvironment"):
LevelEnvironment.add_to(self)
_build_hud()
# Multiplayer Spawning
var spawner = MultiplayerSpawner.new()
@@ -125,6 +113,8 @@ func _spawn_player(pid: int) -> CharacterBody3D:
client_rep_config.add_property(":synced_is_ads")
client_rep_config.add_property(":synced_wall_side")
client_rep_config.add_property(":synced_is_dancing")
# Which of the five emotes, so other players see the one that was chosen.
client_rep_config.add_property(":synced_dance_index")
client_rep_config.add_property(":synced_grapple_point")
client_rep_config.add_property(":synced_is_grapple_shooting")
client_rep_config.add_property(":synced_skin_id")
@@ -252,284 +242,16 @@ func _spawn_player(pid: int) -> CharacterBody3D:
return player
# ── HUD ───────────────────────────────────────────────────────────────────────
func _build_hud() -> void:
var canvas := CanvasLayer.new()
canvas.name = "UI"
add_child(canvas)
# ── Crosshair ─────────────────────────────────────────────────────────
var crosshair := Control.new()
crosshair.name = "Crosshair"
crosshair.set_anchors_preset(Control.PRESET_CENTER)
crosshair.custom_minimum_size = Vector2(20, 20)
canvas.add_child(crosshair)
var ch_dot := ColorRect.new()
ch_dot.name = "Dot"
ch_dot.color = Color(1, 1, 1, 0.8)
ch_dot.size = Vector2(4, 4)
ch_dot.position = Vector2(-2, -2)
crosshair.add_child(ch_dot)
# Crosshair lines
for data in [
{"pos": Vector2(-10, -1), "size": Vector2(6, 2)}, # Left
{"pos": Vector2(4, -1), "size": Vector2(6, 2)}, # Right
{"pos": Vector2(-1, -10), "size": Vector2(2, 6)}, # Top
{"pos": Vector2(-1, 4), "size": Vector2(2, 6)}, # Bottom
]:
var line := ColorRect.new()
line.color = Color(1, 1, 1, 0.6)
line.position = data["pos"]
line.size = data["size"]
crosshair.add_child(line)
# ── Info panel background ─────────────────────────────────────────────
_fps_label = Label.new()
_fps_label.name = "FPSLabel"
_fps_label.text = "FPS: 0"
_fps_label.add_theme_font_size_override("font_size", 24)
_fps_label.add_theme_color_override("font_color", Color(0.9, 0.9, 0.2))
_fps_label.add_theme_color_override("font_outline_color", Color(0, 0, 0))
_fps_label.add_theme_constant_override("outline_size", 4)
_fps_label.set_anchors_preset(Control.PRESET_TOP_LEFT)
_fps_label.position = Vector2(12, 12)
canvas.add_child(_fps_label)
_standalone_speed_label = Label.new()
_standalone_speed_label.name = "StandaloneSpeedLabel"
_standalone_speed_label.text = "Speed: 0.0 m/s"
_standalone_speed_label.add_theme_font_size_override("font_size", 24)
_standalone_speed_label.add_theme_color_override("font_color", Color(0.3, 1.0, 0.5))
_standalone_speed_label.add_theme_color_override("font_outline_color", Color(0, 0, 0))
_standalone_speed_label.add_theme_constant_override("outline_size", 4)
_standalone_speed_label.set_anchors_preset(Control.PRESET_TOP_LEFT)
_standalone_speed_label.position = Vector2(12, 45)
canvas.add_child(_standalone_speed_label)
_debug_ui_panel = PanelContainer.new()
_debug_ui_panel.name = "InfoPanel"
_debug_ui_panel.offset_left = 12
_debug_ui_panel.offset_top = 50
_debug_ui_panel.offset_right = 400
_debug_ui_panel.offset_bottom = 160
var panel_style := StyleBoxFlat.new()
panel_style.bg_color = Color(0, 0, 0, 0.55)
panel_style.corner_radius_top_left = 8
panel_style.corner_radius_top_right = 8
panel_style.corner_radius_bottom_left = 8
panel_style.corner_radius_bottom_right = 8
panel_style.content_margin_left = 12
panel_style.content_margin_top = 8
panel_style.content_margin_right = 12
panel_style.content_margin_bottom = 8
_debug_ui_panel.add_theme_stylebox_override("panel", panel_style)
canvas.add_child(_debug_ui_panel)
var vbox := VBoxContainer.new()
vbox.name = "InfoVBox"
_debug_ui_panel.add_child(vbox)
_speed_label = Label.new()
_speed_label.name = "SpeedLabel"
_speed_label.text = "Speed: 0.0 m/s"
_speed_label.add_theme_font_size_override("font_size", 18)
_speed_label.add_theme_color_override("font_color", Color(0.3, 1.0, 0.5))
vbox.add_child(_speed_label)
_state_label = Label.new()
_state_label.name = "StateLabel"
_state_label.text = "State: ground"
_state_label.add_theme_font_size_override("font_size", 16)
_state_label.add_theme_color_override("font_color", Color(0.7, 0.85, 1.0))
vbox.add_child(_state_label)
_chain_label = Label.new()
_chain_label.name = "ChainLabel"
_chain_label.text = "Chain: 0 (+0%)"
_chain_label.add_theme_font_size_override("font_size", 16)
_chain_label.add_theme_color_override("font_color", Color(1.0, 0.8, 0.3))
vbox.add_child(_chain_label)
# ── Weapon & Ammo Panel ───────────────────────────────────────────────
var wp_panel := PanelContainer.new()
wp_panel.name = "WeaponPanel"
wp_panel.set_anchors_preset(Control.PRESET_BOTTOM_RIGHT)
wp_panel.offset_left = -250
wp_panel.offset_top = -100
wp_panel.offset_right = -20
wp_panel.offset_bottom = -20
var wp_style := StyleBoxFlat.new()
wp_style.bg_color = Color(0, 0, 0, 0.6)
wp_style.corner_radius_top_left = 8
wp_style.corner_radius_top_right = 8
wp_style.corner_radius_bottom_left = 8
wp_style.corner_radius_bottom_right = 8
wp_style.content_margin_left = 16
wp_style.content_margin_top = 12
wp_style.content_margin_right = 16
wp_style.content_margin_bottom = 12
wp_panel.add_theme_stylebox_override("panel", wp_style)
wp_panel.grow_horizontal = Control.GROW_DIRECTION_BEGIN
canvas.add_child(wp_panel)
_weapon_label = Label.new()
_weapon_label.name = "WeaponLabel"
_weapon_label.text = "Unarmed\n0 / 0"
_weapon_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
_weapon_label.vertical_alignment = VERTICAL_ALIGNMENT_BOTTOM
_weapon_label.add_theme_font_size_override("font_size", 24)
wp_panel.add_child(_weapon_label)
# ── Utilities Panel ───────────────────────────────────────────────────────
var util_panel := PanelContainer.new()
util_panel.name = "UtilPanel"
util_panel.set_anchors_preset(Control.PRESET_BOTTOM_RIGHT)
util_panel.offset_left = -200
util_panel.offset_top = -180
util_panel.offset_right = -20
util_panel.offset_bottom = -110
var util_style := StyleBoxFlat.new()
util_style.bg_color = Color(0, 0, 0, 0.6)
util_style.corner_radius_top_left = 8
util_style.corner_radius_top_right = 8
util_style.corner_radius_bottom_left = 8
util_style.corner_radius_bottom_right = 8
util_style.content_margin_left = 12
util_style.content_margin_top = 8
util_style.content_margin_right = 12
util_style.content_margin_bottom = 8
util_panel.add_theme_stylebox_override("panel", util_style)
util_panel.grow_horizontal = Control.GROW_DIRECTION_BEGIN
canvas.add_child(util_panel)
var util_hbox := HBoxContainer.new()
util_hbox.name = "UtilHBox"
util_hbox.add_theme_constant_override("separation", 20)
util_hbox.alignment = BoxContainer.ALIGNMENT_CENTER
util_panel.add_child(util_hbox)
var grapple_vbox := VBoxContainer.new()
grapple_vbox.alignment = BoxContainer.ALIGNMENT_CENTER
util_hbox.add_child(grapple_vbox)
_grapple_icon = TextureRect.new()
if ResourceLoader.exists("res://assets/ui/grapple_icon.jpg"):
_grapple_icon.texture = load("res://assets/ui/grapple_icon.jpg")
_grapple_icon.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
_grapple_icon.custom_minimum_size = Vector2(32, 32)
_grapple_icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
grapple_vbox.add_child(_grapple_icon)
_grapple_label = Label.new()
_grapple_label.text = "Grapple"
_grapple_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
_grapple_label.add_theme_font_size_override("font_size", 12)
grapple_vbox.add_child(_grapple_label)
var dash_vbox := VBoxContainer.new()
dash_vbox.alignment = BoxContainer.ALIGNMENT_CENTER
util_hbox.add_child(dash_vbox)
_dash_icon = TextureRect.new()
if ResourceLoader.exists("res://assets/ui/dash_icon.jpg"):
_dash_icon.texture = load("res://assets/ui/dash_icon.jpg")
_dash_icon.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
_dash_icon.custom_minimum_size = Vector2(32, 32)
_dash_icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
dash_vbox.add_child(_dash_icon)
_dash_label = Label.new()
_dash_label.text = "Ready"
_dash_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
_dash_label.add_theme_font_size_override("font_size", 12)
dash_vbox.add_child(_dash_label)
# ── HUD Update ────────────────────────────────────────────────────────────────
func _process(_delta: float) -> void:
if not _player or not is_instance_valid(_player):
return
if _debug_ui_panel:
_debug_ui_panel.visible = SettingsManager.show_debug_ui
if _fps_label:
_fps_label.visible = SettingsManager.show_fps
if _fps_label.visible:
_fps_label.text = "FPS: %d" % Engine.get_frames_per_second()
var vel: Vector3 = _player.velocity
var hspeed := Vector2(vel.x, vel.z).length()
var total_speed := vel.length()
if _speed_label:
_speed_label.text = "Speed: %.1f m/s (total: %.1f)" % [hspeed, total_speed]
if _standalone_speed_label:
if SettingsManager.show_movement_speed and not SettingsManager.show_debug_ui:
_standalone_speed_label.visible = true
_standalone_speed_label.text = "Speed: %.1f m/s" % hspeed
if _fps_label and _fps_label.visible:
_standalone_speed_label.position = Vector2(12, 45)
else:
_standalone_speed_label.position = Vector2(12, 12)
else:
_standalone_speed_label.visible = false
if _state_label:
var sm = _player.get_node_or_null("MovementStateMachine")
if sm:
_state_label.text = "State: %s" % sm.current_state
if _chain_label:
var sm = _player.get_node_or_null("MovementStateMachine")
if sm:
_chain_label.text = "Chain: %d (+%d%%)" % [sm.chain_count, int(sm.current_chain_bonus * 100)]
# Update utility indicators
if sm.current_state == "grapple" or sm.is_grapple_shooting:
_grapple_icon.modulate = Color(0.2, 1.0, 0.4)
_grapple_label.text = "Grappling"
else:
_grapple_icon.modulate = Color(1.0, 1.0, 1.0)
_grapple_label.text = "Ready"
if sm.has_method("get_dash_cooldown_remaining"):
var dash_rem = sm.get_dash_cooldown_remaining()
if dash_rem > 0.0:
_dash_icon.modulate = Color(1.0, 0.3, 0.3)
_dash_label.text = "%.1f" % dash_rem
else:
_dash_icon.modulate = Color(1.0, 1.0, 1.0)
_dash_label.text = "Ready"
if _weapon_label:
var wman = _player.get_node_or_null("HeadPivot/Camera3D/WeaponManager")
if wman and wman.weapons.has(wman.active_slot):
var active_weapon = wman.weapons[wman.active_slot]
var w_name = "Weapon"
var cur_ammo = 0
var max_ammo = 0
if "weapon_name" in active_weapon:
w_name = active_weapon.weapon_name
elif active_weapon is DoubleBarrelShotgun:
w_name = "Double Barrel Shotgun"
if "current_ammo" in active_weapon:
cur_ammo = active_weapon.current_ammo
max_ammo = active_weapon.max_ammo
elif "shells" in active_weapon:
cur_ammo = active_weapon.shells
max_ammo = 2
if "reloading" in active_weapon and active_weapon.reloading:
_weapon_label.text = "%s\nReloading..." % w_name
else:
_weapon_label.text = "%s\n%d / %d" % [w_name, cur_ammo, max_ammo]
else:
_weapon_label.text = "Unarmed\n0 / 0"
# ── HUD ───────────────────────────────────────────────────────────────
#
# There is no HUD here any more, and no _process to drive one.
#
# Reticle, vitals, ammo, ability cooldowns, the chain meter and the debug
# readout all belong to ui/player_hud.gd, spawned by the player itself. Every
# one of those describes A PLAYER, so a level that owns them has to reach down
# into that player's state machine and weapon manager every frame to fill them
# in — which is exactly what this did, from three byte-identical copies across
# the three level runtimes. The visible symptom was two ammo panels on screen at
# once, in two different styles, overlapping in the bottom-right corner.
#
# A level owns the level.
@@ -1,18 +1,7 @@
extends Node3D
var _speed_label: Label
var _state_label: Label
var _chain_label: Label
var _weapon_label: Label
var _grapple_icon: TextureRect
var _dash_icon: TextureRect
var _grapple_label: Label
var _dash_label: Label
var _fps_label: Label
var _standalone_speed_label: Label
var _player: CharacterBody3D
var _debug_ui_panel: PanelContainer
func _ready() -> void:
@@ -29,7 +18,6 @@ func _ready() -> void:
LevelEnvironment.add_to(self)
LevelMaterials.apply_toon_recursive(self, 0.0)
_build_hud()
# Multiplayer Spawning
var spawner = MultiplayerSpawner.new()
@@ -94,6 +82,8 @@ func _spawn_player(pid: int) -> CharacterBody3D:
client_rep_config.add_property(":synced_is_ads")
client_rep_config.add_property(":synced_wall_side")
client_rep_config.add_property(":synced_is_dancing")
# Which of the five emotes, so other players see the one that was chosen.
client_rep_config.add_property(":synced_dance_index")
client_rep_config.add_property(":synced_grapple_point")
client_rep_config.add_property(":synced_is_grapple_shooting")
client_rep_config.add_property(":synced_skin_id")
@@ -221,297 +211,8 @@ func _spawn_player(pid: int) -> CharacterBody3D:
return player
# ── HUD ───────────────────────────────────────────────────────────────────────
func _build_hud() -> void:
var canvas := CanvasLayer.new()
canvas.name = "UI"
add_child(canvas)
# ── Crosshair ─────────────────────────────────────────────────────────
var crosshair := Control.new()
crosshair.name = "Crosshair"
crosshair.set_anchors_preset(Control.PRESET_CENTER)
crosshair.custom_minimum_size = Vector2(20, 20)
canvas.add_child(crosshair)
var ch_dot := ColorRect.new()
ch_dot.name = "Dot"
ch_dot.color = Color(1, 1, 1, 0.8)
ch_dot.size = Vector2(4, 4)
ch_dot.position = Vector2(-2, -2)
crosshair.add_child(ch_dot)
# Crosshair lines
for data in [
{"pos": Vector2(-10, -1), "size": Vector2(6, 2)}, # Left
{"pos": Vector2(4, -1), "size": Vector2(6, 2)}, # Right
{"pos": Vector2(-1, -10), "size": Vector2(2, 6)}, # Top
{"pos": Vector2(-1, 4), "size": Vector2(2, 6)}, # Bottom
]:
var line := ColorRect.new()
line.color = Color(1, 1, 1, 0.6)
line.position = data["pos"]
line.size = data["size"]
crosshair.add_child(line)
# ── Info panel background ─────────────────────────────────────────────
_fps_label = Label.new()
_fps_label.name = "FPSLabel"
_fps_label.text = "FPS: 0"
_fps_label.add_theme_font_size_override("font_size", 24)
_fps_label.add_theme_color_override("font_color", Color(0.9, 0.9, 0.2))
_fps_label.add_theme_color_override("font_outline_color", Color(0, 0, 0))
_fps_label.add_theme_constant_override("outline_size", 4)
_fps_label.set_anchors_preset(Control.PRESET_TOP_LEFT)
_fps_label.position = Vector2(12, 12)
canvas.add_child(_fps_label)
_standalone_speed_label = Label.new()
_standalone_speed_label.name = "StandaloneSpeedLabel"
_standalone_speed_label.text = "Speed: 0.0 m/s"
_standalone_speed_label.add_theme_font_size_override("font_size", 24)
_standalone_speed_label.add_theme_color_override("font_color", Color(0.3, 1.0, 0.5))
_standalone_speed_label.add_theme_color_override("font_outline_color", Color(0, 0, 0))
_standalone_speed_label.add_theme_constant_override("outline_size", 4)
_standalone_speed_label.set_anchors_preset(Control.PRESET_TOP_LEFT)
_standalone_speed_label.position = Vector2(12, 45)
canvas.add_child(_standalone_speed_label)
_debug_ui_panel = PanelContainer.new()
_debug_ui_panel.name = "InfoPanel"
_debug_ui_panel.offset_left = 12
_debug_ui_panel.offset_top = 50
_debug_ui_panel.offset_right = 400
_debug_ui_panel.offset_bottom = 160
var panel_style := StyleBoxFlat.new()
panel_style.bg_color = Color(0, 0, 0, 0.55)
panel_style.corner_radius_top_left = 8
panel_style.corner_radius_top_right = 8
panel_style.corner_radius_bottom_left = 8
panel_style.corner_radius_bottom_right = 8
panel_style.content_margin_left = 12
panel_style.content_margin_top = 8
panel_style.content_margin_right = 12
panel_style.content_margin_bottom = 8
_debug_ui_panel.add_theme_stylebox_override("panel", panel_style)
canvas.add_child(_debug_ui_panel)
var vbox := VBoxContainer.new()
vbox.name = "InfoVBox"
_debug_ui_panel.add_child(vbox)
_speed_label = Label.new()
_speed_label.name = "SpeedLabel"
_speed_label.text = "Speed: 0.0 m/s"
_speed_label.add_theme_font_size_override("font_size", 18)
_speed_label.add_theme_color_override("font_color", Color(0.3, 1.0, 0.5))
vbox.add_child(_speed_label)
_state_label = Label.new()
_state_label.name = "StateLabel"
_state_label.text = "State: ground"
_state_label.add_theme_font_size_override("font_size", 16)
_state_label.add_theme_color_override("font_color", Color(0.7, 0.85, 1.0))
vbox.add_child(_state_label)
_chain_label = Label.new()
_chain_label.name = "ChainLabel"
_chain_label.text = "Chain: 0 (+0%)"
_chain_label.add_theme_font_size_override("font_size", 16)
_chain_label.add_theme_color_override("font_color", Color(1.0, 0.8, 0.3))
vbox.add_child(_chain_label)
# ── Weapon & Ammo Panel ───────────────────────────────────────────────
var wp_panel := PanelContainer.new()
wp_panel.name = "WeaponPanel"
wp_panel.set_anchors_preset(Control.PRESET_BOTTOM_RIGHT)
wp_panel.offset_left = -250
wp_panel.offset_top = -100
wp_panel.offset_right = -20
wp_panel.offset_bottom = -20
var wp_style := StyleBoxFlat.new()
wp_style.bg_color = Color(0, 0, 0, 0.6)
wp_style.corner_radius_top_left = 8
wp_style.corner_radius_top_right = 8
wp_style.corner_radius_bottom_left = 8
wp_style.corner_radius_bottom_right = 8
wp_style.content_margin_left = 16
wp_style.content_margin_top = 12
wp_style.content_margin_right = 16
wp_style.content_margin_bottom = 12
wp_panel.add_theme_stylebox_override("panel", wp_style)
wp_panel.grow_horizontal = Control.GROW_DIRECTION_BEGIN
canvas.add_child(wp_panel)
_weapon_label = Label.new()
_weapon_label.name = "WeaponLabel"
_weapon_label.text = "Unarmed\n0 / 0"
_weapon_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
_weapon_label.vertical_alignment = VERTICAL_ALIGNMENT_BOTTOM
_weapon_label.add_theme_font_size_override("font_size", 24)
wp_panel.add_child(_weapon_label)
# ── Utilities Panel ───────────────────────────────────────────────────────
var util_panel := PanelContainer.new()
util_panel.name = "UtilPanel"
util_panel.set_anchors_preset(Control.PRESET_BOTTOM_RIGHT)
util_panel.offset_left = -200
util_panel.offset_top = -180
util_panel.offset_right = -20
util_panel.offset_bottom = -110
var util_style := StyleBoxFlat.new()
util_style.bg_color = Color(0, 0, 0, 0.6)
util_style.corner_radius_top_left = 8
util_style.corner_radius_top_right = 8
util_style.corner_radius_bottom_left = 8
util_style.corner_radius_bottom_right = 8
util_style.content_margin_left = 12
util_style.content_margin_top = 8
util_style.content_margin_right = 12
util_style.content_margin_bottom = 8
util_panel.add_theme_stylebox_override("panel", util_style)
util_panel.grow_horizontal = Control.GROW_DIRECTION_BEGIN
canvas.add_child(util_panel)
var util_hbox := HBoxContainer.new()
util_hbox.name = "UtilHBox"
util_hbox.add_theme_constant_override("separation", 20)
util_hbox.alignment = BoxContainer.ALIGNMENT_CENTER
util_panel.add_child(util_hbox)
var grapple_vbox := VBoxContainer.new()
grapple_vbox.alignment = BoxContainer.ALIGNMENT_CENTER
util_hbox.add_child(grapple_vbox)
_grapple_icon = TextureRect.new()
_grapple_icon.texture = load("res://assets/ui/grapple_icon.jpg")
_grapple_icon.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
_grapple_icon.custom_minimum_size = Vector2(32, 32)
_grapple_icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
grapple_vbox.add_child(_grapple_icon)
_grapple_label = Label.new()
_grapple_label.text = "Grapple"
_grapple_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
_grapple_label.add_theme_font_size_override("font_size", 12)
grapple_vbox.add_child(_grapple_label)
var dash_vbox := VBoxContainer.new()
dash_vbox.alignment = BoxContainer.ALIGNMENT_CENTER
util_hbox.add_child(dash_vbox)
_dash_icon = TextureRect.new()
_dash_icon.texture = load("res://assets/ui/dash_icon.jpg")
_dash_icon.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
_dash_icon.custom_minimum_size = Vector2(32, 32)
_dash_icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
dash_vbox.add_child(_dash_icon)
_dash_label = Label.new()
_dash_label.text = "Ready"
_dash_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
_dash_label.add_theme_font_size_override("font_size", 12)
dash_vbox.add_child(_dash_label)
# ── Utility: Key Name ─────────────────────────────────────────────────────────
func _get_key_name(action: String) -> String:
if not InputMap.has_action(action):
return "?"
var events = InputMap.action_get_events(action)
for e in events:
if e is InputEventKey:
var code = e.physical_keycode if e.physical_keycode != 0 else e.keycode
return OS.get_keycode_string(code)
elif e is InputEventMouseButton:
if e.button_index == MOUSE_BUTTON_LEFT: return "LClick"
elif e.button_index == MOUSE_BUTTON_RIGHT: return "RClick"
elif e.button_index == MOUSE_BUTTON_MIDDLE: return "MClick"
return "?"
# ── HUD Update ────────────────────────────────────────────────────────────────
func _process(_delta: float) -> void:
if not _player or not is_instance_valid(_player):
return
if _debug_ui_panel:
_debug_ui_panel.visible = SettingsManager.show_debug_ui
if _fps_label:
_fps_label.visible = SettingsManager.show_fps
if _fps_label.visible:
_fps_label.text = "FPS: %d" % Engine.get_frames_per_second()
var vel: Vector3 = _player.velocity
var hspeed := Vector2(vel.x, vel.z).length()
var total_speed := vel.length()
if _speed_label:
_speed_label.text = "Speed: %.1f m/s (total: %.1f)" % [hspeed, total_speed]
if _standalone_speed_label:
if SettingsManager.show_movement_speed and not SettingsManager.show_debug_ui:
_standalone_speed_label.visible = true
_standalone_speed_label.text = "Speed: %.1f m/s" % hspeed
if _fps_label and _fps_label.visible:
_standalone_speed_label.position = Vector2(12, 45)
else:
_standalone_speed_label.position = Vector2(12, 12)
else:
_standalone_speed_label.visible = false
if _state_label:
var sm = _player.get_node_or_null("MovementStateMachine")
if sm:
_state_label.text = "State: %s" % sm.current_state
if _chain_label:
var sm = _player.get_node_or_null("MovementStateMachine")
if sm:
_chain_label.text = "Chain: %d (+%d%%)" % [sm.chain_count, int(sm.current_chain_bonus * 100)]
# Update utility indicators
if sm.current_state == "grapple" or sm.is_grapple_shooting:
_grapple_icon.modulate = Color(0.2, 1.0, 0.4)
_grapple_label.text = "Grappling"
else:
_grapple_icon.modulate = Color(1.0, 1.0, 1.0)
_grapple_label.text = "Ready"
var dash_rem = sm.get_dash_cooldown_remaining()
if dash_rem > 0.0:
_dash_icon.modulate = Color(1.0, 0.3, 0.3)
_dash_label.text = "%.1f" % dash_rem
else:
_dash_icon.modulate = Color(1.0, 1.0, 1.0)
_dash_label.text = "Ready"
if _weapon_label:
var wman = _player.get_node_or_null("HeadPivot/Camera3D/WeaponManager")
if wman and wman.weapons.has(wman.active_slot):
var active_weapon = wman.weapons[wman.active_slot]
var w_name = "Weapon"
var cur_ammo = 0
var max_ammo = 0
if "weapon_name" in active_weapon:
w_name = active_weapon.weapon_name
elif active_weapon is DoubleBarrelShotgun:
w_name = "Double Barrel Shotgun"
if "current_ammo" in active_weapon:
cur_ammo = active_weapon.current_ammo
max_ammo = active_weapon.max_ammo
elif "shells" in active_weapon:
cur_ammo = active_weapon.shells
max_ammo = 2
if "reloading" in active_weapon and active_weapon.reloading:
_weapon_label.text = "%s\nReloading..." % w_name
else:
_weapon_label.text = "%s\n%d / %d" % [w_name, cur_ammo, max_ammo]
else:
_weapon_label.text = "Unarmed\n0 / 0"
# ── HUD ───────────────────────────────────────────────────────────────
#
# There is no HUD here any more, and no _process to drive one. See the note in
# scenes/maps/level_runtime.gd: everything that describes A PLAYER belongs to
# ui/player_hud.gd, which that player spawns for itself.
+128
View File
@@ -0,0 +1,128 @@
extends Control
class_name AbilityChip
## A dash or grapple readout: a leaning chip that drains while the ability is on
## cooldown and snaps back to charged when it is ready again.
##
## It replaces a 32 px JPEG icon with a text label under it, tinted red or green
## by a colour multiply — an approach with three problems. The icon was a photo
## in a game drawn entirely in flat ink; the tint said "not ready" but not HOW not
## ready; and the number that did say it was rendered at 12 px, which is below
## what anyone reads mid-fight.
##
## What a player actually needs from a cooldown is one bit at a glance (can I go?)
## and one magnitude in peripheral vision (how soon?). So the chip answers the bit
## with COLOUR — volt when charged, ink when not, the same volt-means-now rule the
## rest of the UI runs on — and the magnitude with a WIPE across the chip, which
## can be read without focusing on it because it is a shape changing size rather
## than a number changing value.
##
## The ready transition overshoots slightly before settling. A cooldown that ends
## by silently going bright is easy to miss while looking somewhere else; one that
## pops is not, and it costs a tween.
## Ability name, drawn on the chip.
var label: String = "DASH":
set(v):
label = v
queue_redraw()
## 0 = fully charged, 1 = just used. Written each frame.
var cooldown: float = 0.0:
set(v):
var c := clampf(v, 0.0, 1.0)
var was_ready := cooldown <= 0.001
cooldown = c
if was_ready and c > 0.001:
_pop = 0.0
elif not was_ready and c <= 0.001:
# Just came back. Fire the overshoot.
_pop = 1.0
queue_redraw()
## Set while the ability is actively in use (mid-grapple), which is a third state
## and reads as neither charged nor recharging.
var active: bool = false:
set(v):
if v != active:
active = v
queue_redraw()
const SHEAR := 7.0
const INK_W := 3.0
const POP_DECAY := 4.0
var _pop: float = 0.0
func _ready() -> void:
mouse_filter = Control.MOUSE_FILTER_IGNORE
custom_minimum_size = Vector2(96, 34)
func _process(delta: float) -> void:
if _pop <= 0.0:
return
_pop = maxf(_pop - delta * POP_DECAY, 0.0)
queue_redraw()
func _draw() -> void:
var w := size.x
var h := size.y
if w <= 1.0 or h <= 1.0:
return
var ready := cooldown <= 0.001
# The overshoot: a brief lift in the fill, decaying to the resting colour.
var lift: float = _pop * _pop
var fill: Color = UITheme.INK
var text: Color = UITheme.PAPER_DIM
if active:
# In use. Cyan, so it cannot be confused with either of the other two.
fill = UITheme.CYAN
text = UITheme.ink_for(UITheme.CYAN)
elif ready:
fill = UITheme.VOLT.lerp(UITheme.PAPER, lift * 0.5)
text = UITheme.ink_for(UITheme.VOLT)
_shear(0.0, w, fill, h)
# The recharge wipe, left to right over the dark chip.
if not ready and not active:
_shear(0.0, w * (1.0 - cooldown), UITheme.PAPAYA, h)
# Anything the wipe has reached is papaya, anything it has not is ink,
# and one text colour has to sit on both. Ink loses against ink; paper
# wins against both, and the outline below covers the papaya case.
text = UITheme.PAPER
_shear_outline(0.0, w, UITheme.INK, h)
var font := get_theme_default_font()
if font == null:
return
var fs := 17
var text_size := font.get_string_size(label, HORIZONTAL_ALIGNMENT_LEFT, -1, fs)
var at := Vector2((w - text_size.x) * 0.5, (h + text_size.y * 0.62) * 0.5)
# Ink outline under the glyphs, same as every Label in the theme, so the
# label survives the wipe passing underneath it.
draw_string_outline(font, at, label, HORIZONTAL_ALIGNMENT_LEFT, -1, fs, 5,
UITheme.INK)
draw_string(font, at, label, HORIZONTAL_ALIGNMENT_LEFT, -1, fs, text)
func _shear(x0: float, x1: float, col: Color, h: float) -> void:
if x1 - x0 < 0.5:
return
draw_colored_polygon(PackedVector2Array([
Vector2(x0 + SHEAR, 0.0), Vector2(x1 + SHEAR, 0.0),
Vector2(x1, h), Vector2(x0, h),
]), col)
func _shear_outline(x0: float, x1: float, col: Color, h: float) -> void:
draw_polyline(PackedVector2Array([
Vector2(x0 + SHEAR, 0.0), Vector2(x1 + SHEAR, 0.0),
Vector2(x1, h), Vector2(x0, h), Vector2(x0 + SHEAR, 0.0),
]), col, INK_W)
+1
View File
@@ -0,0 +1 @@
uid://48ja2bu8k6xm
+175
View File
@@ -0,0 +1,175 @@
extends Control
class_name Crosshair
## The reticle, drawn rather than assembled out of ColorRects.
##
## It replaces five white rectangles that were pasted into three different level
## runtimes, and it exists as one drawn thing for two reasons.
##
## The first is that it has to say something. A static cross tells the player
## nothing they do not already know; a reticle that OPENS as they sprint, jump and
## fire, and snaps shut when they stop or shoulder the weapon, is the accuracy
## readout of the whole game and it costs one number per frame. Four separate
## ColorRects cannot express that without four separate position updates, which is
## why they never did.
##
## The second is the ink. Every other element in this UI carries a heavy dark edge
## — it is the theme's first rule, and the reason white type stays legible over a
## sunlit 3D scene. A 2 px white line does not: over pale concrete it disappears
## exactly when aim matters. Here every stroke is drawn twice, ink underneath and
## wider, so the reticle reads against the map instead of against luck.
##
## The hit confirmation lives here too, rather than as a separate centred Control
## fading on top. It is the same four strokes rotated 45°, which means the
## feedback arrives where the player's eye already is and shares the reticle's
## outline instead of needing its own.
## 0 = tight, 1 = fully bloomed. Written each frame by PlayerHUD from speed,
## airtime and fire cooldown.
var spread: float = 0.0:
set(v):
var c := clampf(v, 0.0, 1.0)
if absf(c - spread) > 0.002:
spread = c
queue_redraw()
else:
spread = c
## 0 = hip, 1 = down the sights. At full ADS the ticks retract entirely and only
## the centre dot remains, which is the convention every shooter uses and the
## clearest possible statement that the shot is going where the dot is.
var ads: float = 0.0:
set(v):
var c := clampf(v, 0.0, 1.0)
if absf(c - ads) > 0.002:
ads = c
queue_redraw()
else:
ads = c
## Hit and kill confirmations, 1 -> 0. Kill is the louder one and it wins.
var hit: float = 0.0
var kill: float = 0.0
## Geometry, in pixels at 1080p.
const GAP_TIGHT := 5.0
const GAP_BLOOM := 30.0
const TICK := 9.0
const STROKE := 2.0
const INK_GROW := 2.0
const DOT := 2.2
## How far the confirmation strokes sit out, and how long they are.
const HIT_GAP := 13.0
const HIT_TICK := 8.0
## Decay rates, per second. The hit pop is quick — it has to land inside the
## rhythm of firing, not linger into the next shot.
const HIT_DECAY := 3.2
const KILL_DECAY := 1.7
func _ready() -> void:
mouse_filter = Control.MOUSE_FILTER_IGNORE
# FULL RECT, and the drawing centres itself in `size`.
#
# A zero-sized Control on PRESET_CENTER is the obvious way to do this and it
# is wrong: the control draws once when it enters the tree, before the
# viewport has told it how big the screen is, so the whole reticle lands in
# the top-left corner and stays there until something else happens to make it
# redraw. Nothing does, because the reticle only redraws when the bloom
# changes. Owning the full rect and measuring the centre every draw cannot
# get that wrong.
# `set_anchors_AND_OFFSETS_preset`, not `set_anchors_preset`. The latter
# moves the anchors and leaves the offsets where they were, which for a
# control that has never been laid out means an empty rect: the measured
# result was anchors spanning the viewport and a size of exactly (0, 0).
set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
resized.connect(queue_redraw)
func _process(delta: float) -> void:
if hit <= 0.0 and kill <= 0.0:
return
hit = maxf(hit - delta * HIT_DECAY, 0.0)
kill = maxf(kill - delta * KILL_DECAY, 0.0)
queue_redraw()
## A landed shot. Kills pass `fatal` and get the louder, slower magenta pop.
func confirm(fatal: bool = false) -> void:
if fatal:
kill = 1.0
else:
hit = 1.0
queue_redraw()
func _draw() -> void:
_mid = size * 0.5
# ADS retracts the ticks and tightens what is left, so the bloom cannot
# fight the sight picture.
var open := 1.0 - ads
var gap: float = lerpf(GAP_TIGHT, GAP_BLOOM, spread) * lerpf(1.0, 0.45, ads)
var tick: float = TICK * open
if tick > 0.5:
# Vertical ticks are drawn slightly shorter than horizontal ones. A
# perfectly square cross reads taller than it is because the eye
# over-weights vertical extent; trimming the verticals is the standard
# correction and it is the difference between a reticle that looks
# centred and one that looks slightly high.
_stroke(Vector2(-gap - tick, 0), Vector2(-gap, 0), UITheme.PAPER)
_stroke(Vector2(gap, 0), Vector2(gap + tick, 0), UITheme.PAPER)
var v: float = tick * 0.86
_stroke(Vector2(0, -gap - v), Vector2(0, -gap), UITheme.PAPER)
_stroke(Vector2(0, gap), Vector2(0, gap + v), UITheme.PAPER)
# Centre dot: the one element that never moves and never fades. It goes
# papaya at the hip and volt down the sights, so shouldering the weapon
# changes the reticle's colour as well as its shape.
var dot_col: Color = UITheme.PAPAYA.lerp(UITheme.VOLT, ads)
var r: float = DOT * lerpf(1.0, 1.25, ads)
draw_circle(_mid, r + INK_GROW, UITheme.INK)
draw_circle(_mid, r, dot_col)
# Confirmations, over the top: the same cross rotated 45°.
if kill > 0.001:
_confirm_arms(kill, UITheme.MAGENTA, 1.35)
if hit > 0.001:
_confirm_arms(hit, UITheme.VOLT, 1.0)
## Four diagonal strokes, scaled and faded by `amount`.
##
## They grow slightly as they fade rather than merely fading, which reads as an
## impact rather than as a light being switched off — the same expansion-plus-
## dissolve that carries an anime impact frame.
func _confirm_arms(amount: float, col: Color, scale: float) -> void:
var grow: float = 1.0 + (1.0 - amount) * 0.5
var gap: float = HIT_GAP * scale * grow
var tick: float = HIT_TICK * scale
var c := Color(col.r, col.g, col.b, amount)
var ink := Color(UITheme.INK.r, UITheme.INK.g, UITheme.INK.b, amount)
const DIAGONALS: Array[Vector2] = [Vector2(1, 1), Vector2(1, -1),
Vector2(-1, 1), Vector2(-1, -1)]
for d in DIAGONALS:
var dir := d.normalized()
_stroke(dir * gap, dir * (gap + tick), c, ink)
## Screen centre, recomputed at the top of every `_draw`. Every offset below is
## relative to it.
var _mid: Vector2 = Vector2.ZERO
## One stroke, ink first and wider so the colour sits inside an outline.
##
## Endpoints are given RELATIVE TO CENTRE and offset here, so no caller has to
## remember to add it.
##
## `draw_line` with a round cap would leave the ink poking out past the ends as a
## dark bead; square caps keep the outline flush with the stroke it is edging.
func _stroke(a: Vector2, b: Vector2, col: Color,
ink: Color = UITheme.INK) -> void:
draw_line(_mid + a, _mid + b, ink, STROKE + INK_GROW * 2.0, false)
draw_line(_mid + a, _mid + b, col, STROKE, false)
+1
View File
@@ -0,0 +1 @@
uid://ck57a8waor1d1
+215
View File
@@ -0,0 +1,215 @@
extends Control
class_name EmoteWheel
## The radial emote dial: hold the emote button, point, release.
##
## A radial menu is the right shape for this and a list is not, for one reason:
## every option is the SAME DISTANCE from where the pointer starts. There is no
## scanning and no travel budget — the choice is a direction, and a direction can
## be learned as muscle memory in a way that "the fourth row down" cannot. After
## a few uses the player stops reading the wheel and just flicks.
##
## Which is why the selection is by ANGLE ALONE and not by distance. Pointing
## anywhere in a wedge selects it, however far out the cursor is, so a fast flick
## and a careful nudge do the same thing. A dead zone in the middle is the only
## exception, and it exists so releasing without moving cancels rather than
## picking whatever happened to be under the cursor at rest.
##
## Opened by HOLDING the button rather than toggled by tapping it, because an
## emote is a thing you do in a lull and a menu you have to close again is a
## thing that gets you killed. Release commits. A tap too short to have aimed
## replays the last emote instead, which is what the button did before the wheel
## existed.
signal picked(index: int)
signal cancelled
## The wheel is drawn at this radius, and a wedge is selected by pointing at it
## from further out than the dead zone.
const RADIUS := 190.0
const INNER := 76.0
const DEAD_ZONE := 46.0
const LABEL_R := 250.0
## How long the open/close ease takes. Short — this is a fast interaction and a
## slow bloom would defeat the point of it.
const OPEN_TIME := 0.11
var _routines: Array = []
var _hover: int = -1
var _open: float = 0.0
var _target_open: float = 0.0
## The direction the pointer has travelled from the wheel's centre since it
## opened. Accumulated from relative mouse motion rather than read from the
## cursor position, because the game captures the mouse and the OS cursor does
## not move.
var _aim: Vector2 = Vector2.ZERO
func _ready() -> void:
_routines = DanceRoutines.ROUTINES
set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
mouse_filter = Control.MOUSE_FILTER_IGNORE
visible = false
set_process(false)
set_process_input(false)
## Show the wheel and start tracking the pointer.
func open() -> void:
_aim = Vector2.ZERO
_hover = -1
_target_open = 1.0
visible = true
set_process(true)
set_process_input(true)
queue_redraw()
## Hide it, and report what was pointed at. Returns the index, or -1 for a
## cancel (nothing aimed at, or the pointer never left the dead zone).
func close() -> int:
_target_open = 0.0
set_process_input(false)
var chosen := _hover
if chosen >= 0:
picked.emit(chosen)
else:
cancelled.emit()
_hover = -1
queue_redraw()
return chosen
func _input(event: InputEvent) -> void:
if event is InputEventMouseMotion:
# Relative motion, because the game holds the mouse captured — the
# cursor's absolute position never changes and reading it would leave
# the wheel permanently pointing at nothing.
_aim += (event as InputEventMouseMotion).relative
_update_hover()
## Also drivable from a stick or the keyboard, for a controller or for a player
## who would rather not move the mouse. Same wedge maths, different source.
func aim_by_vector(v: Vector2) -> void:
_aim = v * (DEAD_ZONE + 1.0) if v.length() > 0.01 else Vector2.ZERO
_update_hover()
func _update_hover() -> void:
var was := _hover
if _aim.length() < DEAD_ZONE or _routines.is_empty():
_hover = -1
else:
# Angle from straight UP, clockwise, so the first emote is at twelve
# o'clock — the position a player will reach for without thinking.
var a := fposmod(atan2(_aim.x, -_aim.y), TAU)
var step := TAU / float(_routines.size())
_hover = int(floor((a + step * 0.5) / step)) % _routines.size()
if _hover != was:
queue_redraw()
var am := get_tree().root.get_node_or_null("AudioManager")
if am and _hover >= 0:
am.play_ui("ui_hover")
func _process(delta: float) -> void:
var t := 1.0 - exp(-delta / maxf(OPEN_TIME, 0.001))
_open = lerpf(_open, _target_open, t)
if _target_open <= 0.0 and _open < 0.01:
_open = 0.0
visible = false
set_process(false)
queue_redraw()
func _draw() -> void:
if _open < 0.01 or _routines.is_empty():
return
var mid := size * 0.5
# Scale up from 88% as it opens, and fade in. Small, because the wheel has to
# be usable the instant it appears — an animation the player has to wait out
# is an animation that makes the feature feel slower than the old toggle.
var k: float = lerpf(0.88, 1.0, _open)
var a: float = _open
var count := _routines.size()
var step := TAU / float(count)
# A scrim, so the wheel reads over a bright skybox without needing a heavier
# outline than everything else in the UI uses.
draw_circle(mid, RADIUS * k * 1.06,
Color(UITheme.INK.r, UITheme.INK.g, UITheme.INK.b, 0.55 * a))
for i in count:
var mid_angle := step * float(i)
var from := mid_angle - step * 0.5
var hovered := i == _hover
# Fill follows the state, and the label follows the fill — the theme's
# one rule for staying readable. A hovered wedge is papaya and takes ink
# glyphs; a resting one is near-black and takes paper.
var fill: Color = UITheme.PAPAYA if hovered else UITheme.INK_SOFT
_wedge(mid, from, step, INNER * k, RADIUS * k,
Color(fill.r, fill.g, fill.b, (0.95 if hovered else 0.82) * a))
var dir := Vector2(sin(mid_angle), -cos(mid_angle))
var r := (INNER + RADIUS) * 0.5 * k
_glyph(mid + dir * r, String(_routines[i].get("icon", "*")), 40,
UITheme.ink_for(fill) if hovered else UITheme.PAPER, a)
# The name sits OUTSIDE the ring rather than inside the wedge, so a long
# one is never clipped by its own slice and the type size does not have
# to shrink as emotes are added.
_glyph(mid + dir * (LABEL_R * k), String(_routines[i].get("name", "")), 22,
UITheme.VOLT if hovered else UITheme.PAPER, a)
# The hub. Volt while a wedge is aimed at, so committing is confirmed before
# the button is released rather than after.
var hub: Color = UITheme.VOLT if _hover >= 0 else UITheme.INK
draw_circle(mid, INNER * k * 0.42,
Color(hub.r, hub.g, hub.b, (0.9 if _hover >= 0 else 0.75) * a))
draw_arc(mid, INNER * k * 0.42, 0, TAU, 40,
Color(UITheme.INK.r, UITheme.INK.g, UITheme.INK.b, a), 3.0, true)
if _hover < 0:
_glyph(mid, "RELEASE TO CANCEL", 16, UITheme.PAPER_DIM, a * 0.9)
## One slice of the ring, as a triangle strip between the inner and outer radii.
func _wedge(mid: Vector2, from: float, span: float, r0: float, r1: float,
col: Color) -> void:
var segs := 14
var pts := PackedVector2Array()
for i in segs + 1:
var ang := from + span * (float(i) / float(segs))
# A one-degree gap either side, so adjacent wedges read as separate
# choices rather than as a solid ring with colour changes in it.
ang = from + deg_to_rad(1.2) + (span - deg_to_rad(2.4)) * (float(i) / float(segs))
var d := Vector2(sin(ang), -cos(ang))
pts.append(mid + d * r0)
pts.append(mid + d * r1)
# Build an outline path around the strip so the ink edge can be stroked.
var outer := PackedVector2Array()
var inner := PackedVector2Array()
for i in range(0, pts.size(), 2):
inner.append(pts[i])
outer.append(pts[i + 1])
var poly := PackedVector2Array()
poly.append_array(outer)
inner.reverse()
poly.append_array(inner)
draw_colored_polygon(poly, col)
poly.append(poly[0])
draw_polyline(poly, Color(UITheme.INK.r, UITheme.INK.g, UITheme.INK.b, col.a),
3.0)
## Centred text with the theme's ink outline under it.
func _glyph(at: Vector2, text: String, fs: int, col: Color, alpha: float) -> void:
var font := get_theme_default_font()
if font == null or text == "":
return
var w := font.get_string_size(text, HORIZONTAL_ALIGNMENT_LEFT, -1, fs)
var pos := at - Vector2(w.x * 0.5, -w.y * 0.32)
draw_string_outline(font, pos, text, HORIZONTAL_ALIGNMENT_LEFT, -1, fs, 6,
Color(UITheme.INK.r, UITheme.INK.g, UITheme.INK.b, alpha))
draw_string(font, pos, text, HORIZONTAL_ALIGNMENT_LEFT, -1, fs,
Color(col.r, col.g, col.b, alpha))
+1
View File
@@ -0,0 +1 @@
uid://dnxnbswrl04u8
+144
View File
@@ -0,0 +1,144 @@
extends Button
class_name LevelCard
## One map on the level select: its photograph, its name, and its mode.
##
## The cards used to be a two-stop gradient generated from `color1` and `color2`
## in the map's meta file. That tells a player which card they clicked last time
## and nothing at all about the map, which is the entire job of a level select —
## and the hover state was `use_hdr = true` on the gradient texture, which is not
## a visible change on any of them.
##
## The preview comes from assets/ui/map_previews/<folder>.png, shot by
## debug/map_preview_capture.gd from inside the map. A map with no preview falls
## back to its gradient, so nothing breaks for a map that has not been
## photographed yet.
##
## Built on Button rather than TextureButton so it gets focus, keyboard
## navigation and the theme's state machinery for free. The image sits inside the
## button's border rather than under it, so the border reads as a frame around a
## photograph instead of a rectangle behind one.
const INSET := 5
var _plate: ColorRect
var _label: Label
var _mode_label: Label
func setup(title: String, preview_path: String, gradient: Gradient,
card_size: Vector2) -> void:
custom_minimum_size = card_size
clip_contents = true
# The frame. Papaya at rest, volt on hover, and the fill stays transparent
# so the photograph is what the player sees.
var clear := Color(0, 0, 0, 0)
add_theme_stylebox_override("normal", _frame(clear, UITheme.PAPAYA, 3))
add_theme_stylebox_override("hover", _frame(clear, UITheme.VOLT, 5))
add_theme_stylebox_override("pressed", _frame(
Color(UITheme.VOLT.r, UITheme.VOLT.g, UITheme.VOLT.b, 0.25),
UITheme.VOLT, 5))
add_theme_stylebox_override("focus", _frame(clear, UITheme.CYAN, 5))
add_theme_stylebox_override("disabled", _frame(
Color(UITheme.INK.r, UITheme.INK.g, UITheme.INK.b, 0.7),
UITheme.DEAD_EDGE, 3))
if ResourceLoader.exists(preview_path):
var shot := TextureRect.new()
shot.texture = load(preview_path)
shot.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
# COVERED, not scaled: a photograph letterboxed inside a card reads as a
# thumbnail in a file browser. Filling the card and cropping reads as a
# poster, which is what this is.
shot.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_COVERED
shot.mouse_filter = Control.MOUSE_FILTER_IGNORE
_fill(shot)
add_child(shot)
else:
var grad := TextureRect.new()
var tex := GradientTexture2D.new()
tex.gradient = gradient
tex.width = int(card_size.x)
tex.height = int(card_size.y)
tex.fill_to = Vector2(1, 1)
grad.texture = tex
grad.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
grad.stretch_mode = TextureRect.STRETCH_SCALE
grad.mouse_filter = Control.MOUSE_FILTER_IGNORE
_fill(grad)
add_child(grad)
# The name plate: a hard ink band across the bottom rather than a gradient
# fade, because the theme is drawn and a soft fade is not.
_plate = ColorRect.new()
_plate.color = Color(UITheme.INK.r, UITheme.INK.g, UITheme.INK.b, 0.88)
_plate.mouse_filter = Control.MOUSE_FILTER_IGNORE
_plate.set_anchors_and_offsets_preset(Control.PRESET_BOTTOM_WIDE)
_plate.offset_top = -62
_plate.offset_left = INSET
_plate.offset_right = -INSET
_plate.offset_bottom = -INSET
add_child(_plate)
var stack := VBoxContainer.new()
stack.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
stack.add_theme_constant_override("separation", -2)
stack.mouse_filter = Control.MOUSE_FILTER_IGNORE
_plate.add_child(stack)
_label = Label.new()
_label.text = title
_label.add_theme_font_size_override("font_size", 28)
_label.add_theme_color_override("font_color", UITheme.PAPER)
_label.add_theme_color_override("font_outline_color", UITheme.INK)
_label.add_theme_constant_override("outline_size", 6)
_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
stack.add_child(_label)
_mode_label = Label.new()
_mode_label.add_theme_font_size_override("font_size", 16)
_mode_label.add_theme_color_override("font_color", UITheme.PAPER_DIM)
_mode_label.add_theme_color_override("font_outline_color", UITheme.INK)
_mode_label.add_theme_constant_override("outline_size", 4)
_mode_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
stack.add_child(_mode_label)
# The label follows the frame, same rule as every other control: hover turns
# the frame volt, so the name turns volt with it.
mouse_entered.connect(func(): _tint(UITheme.VOLT))
mouse_exited.connect(func(): _tint(UITheme.PAPER))
focus_entered.connect(func(): _tint(UITheme.CYAN))
focus_exited.connect(func(): _tint(UITheme.PAPER))
## What pressing this card will start. Shown under the name so the player is
## never guessing which mode the button they are about to press launches.
func set_mode_text(text: String) -> void:
if _mode_label:
_mode_label.text = text
func _tint(c: Color) -> void:
if _label:
_label.add_theme_color_override("font_color", c)
func _fill(c: Control) -> void:
c.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
c.offset_left = INSET
c.offset_top = INSET
c.offset_right = -INSET
c.offset_bottom = -INSET
func _frame(fill: Color, border: Color, width: int) -> StyleBoxFlat:
var sb := StyleBoxFlat.new()
sb.bg_color = fill
sb.border_color = border
sb.set_border_width_all(width)
sb.set_corner_radius_all(5)
sb.shadow_color = Color(UITheme.INK.r, UITheme.INK.g, UITheme.INK.b, 0.6)
sb.shadow_size = 4
sb.shadow_offset = Vector2(5, 5)
return sb
+1
View File
@@ -0,0 +1 @@
uid://2bfw74vxcitf
+680 -543
View File
File diff suppressed because it is too large Load Diff
+94 -46
View File
@@ -14,6 +14,35 @@ var _killfeed_vbox: VBoxContainer
var _scoreboard_panel: PanelContainer
var _scoreboard_grid: GridContainer
var _summary: MatchSummary
func _on_match_ended(result: Dictionary) -> void:
_scoreboard_panel.hide()
_summary.show_result(result)
func _on_play_again() -> void:
if not _nm:
return
# The host decides for everyone; a client's button is disabled.
if _nm.multiplayer.has_multiplayer_peer() \
and not _nm.multiplayer.multiplayer_peer is OfflineMultiplayerPeer:
if _nm.multiplayer.is_server():
_nm.restart_match.rpc()
else:
_nm.restart_match()
_summary.visible = false
Input.set_mouse_mode(Input.MOUSE_MODE_CAPTURED)
func _on_to_menu() -> void:
if _nm:
_nm.match_active = false
_nm.disconnect_game()
Input.set_mouse_mode(Input.MOUSE_MODE_VISIBLE)
get_tree().change_scene_to_file("res://ui/main_menu/main_menu.tscn")
func _ready() -> void:
_nm = get_node_or_null("/root/NetworkManager")
# Ensure the comic theme is active even when a level is loaded directly
@@ -98,12 +127,27 @@ func _ready() -> void:
_populate_scoreboard_headers()
# The end-of-match screen. Built once and hidden, rather than instantiated on
# the signal, so the moment the match ends nothing has to be loaded.
_summary = MatchSummary.new()
_summary.name = "MatchSummary"
_summary.visible = false
_summary.play_again.connect(_on_play_again)
_summary.to_menu.connect(_on_to_menu)
add_child(_summary)
# Signals
if _nm:
_nm.stats_updated.connect(_update_hud)
_nm.killfeed_event.connect(_on_killfeed_event)
_nm.match_state_updated.connect(_update_timer)
_nm.match_ended.connect(_on_match_ended)
_update_hud()
# A HUD that loads AFTER the match ended — a late joiner, or a scene
# reload — still has to show the result, so it asks rather than only
# listening. `last_result` is empty during a live match.
if not _nm.last_result.is_empty():
_on_match_ended(_nm.last_result)
func _process(_delta: float) -> void:
if Input.is_action_just_pressed("scoreboard"):
@@ -125,8 +169,12 @@ func _update_timer() -> void:
func _populate_scoreboard_headers() -> void:
for child in _scoreboard_grid.get_children():
child.queue_free()
var headers = ["Player", "Kills", "Deaths", "Assists", "Ping"]
# SCORE is first and is the mode's own noun, because it is what decides the
# match. Kills and score are the same number in Deathmatch and different in
# Gun Game, which is exactly why both are shown.
var noun := GameMode.score_noun(_nm.current_gamemode) if _nm else "SCORE"
var headers = ["Player", noun, "Kills", "Deaths", "Assists", "Streak", "Ping"]
for h in headers:
var l = Label.new()
l.text = h
@@ -138,59 +186,59 @@ func _update_hud() -> void:
if not _nm: return
var my_id = multiplayer.get_unique_id()
var leader_name = "None"
var max_kills = -1
var mode: String = _nm.current_gamemode
var noun := GameMode.score_noun(mode)
var stats = _nm.player_stats
# Sort players by kills for scoreboard
var players = stats.keys()
players.sort_custom(func(a, b): return stats[a].get("kills", 0) > stats[b].get("kills", 0))
# Ranked by SCORE, which is what the mode counts — not by kills, which in
# Gun Game is the same number by coincidence and in a mode with objectives
# would not be.
var players := GameMode.standings(stats)
_populate_scoreboard_headers()
for pid in players:
var p_data = stats[pid]
var p_kills = p_data.get("kills", 0)
# Find leader
if p_kills > max_kills:
max_kills = p_kills
leader_name = p_data.get("username", "Player " + str(pid))
# Update local kills
var p_score = p_data.get("score", p_data.get("kills", 0))
if pid == my_id:
_local_kills_label.text = str(p_kills) + " Kills"
# Build Scoreboard Row
_local_kills_label.text = "%s %s" % [p_score, noun]
var row_color := Color(p_data.get("color", "cccccc"))
if int(p_data.get("team", 0)) > 0:
row_color = GameMode.team_color(int(p_data.get("team", 0)))
var c_name = Label.new()
c_name.text = p_data.get("username", "Player")
c_name.add_theme_font_size_override("font_size", 24)
c_name.add_theme_color_override("font_color", Color(p_data.get("color", "cccccc")))
c_name.add_theme_color_override("font_color", row_color)
_scoreboard_grid.add_child(c_name)
var c_k = Label.new()
c_k.text = str(p_kills)
c_k.add_theme_font_size_override("font_size", 24)
_scoreboard_grid.add_child(c_k)
var c_d = Label.new()
c_d.text = str(p_data.get("deaths", 0))
c_d.add_theme_font_size_override("font_size", 24)
_scoreboard_grid.add_child(c_d)
var c_a = Label.new()
c_a.text = str(p_data.get("assists", 0))
c_a.add_theme_font_size_override("font_size", 24)
_scoreboard_grid.add_child(c_a)
var c_p = Label.new()
c_p.text = str(p_data.get("ping", 0)) + "ms"
c_p.add_theme_font_size_override("font_size", 24)
_scoreboard_grid.add_child(c_p)
_leader_label.text = "Leader: %s (%s Kills)" % [leader_name, max_kills]
for value in [str(p_score), str(p_data.get("kills", 0)),
str(p_data.get("deaths", 0)), str(p_data.get("assists", 0)),
"x%d" % int(p_data.get("best_streak", 0)),
"%dms" % int(p_data.get("ping", 0))]:
var cell = Label.new()
cell.text = value
cell.add_theme_font_size_override("font_size", 24)
_scoreboard_grid.add_child(cell)
# The top line: who is leading, and how far there is to go. A score limit
# nobody can see is a win condition players cannot play toward.
var limit := GameMode.score_limit(mode)
if GameMode.is_team_mode(mode):
var totals := GameMode.team_scores(mode, stats)
var parts: PackedStringArray = []
for t in totals:
parts.append("%s %d" % [GameMode.team_name(t), totals[t]])
_leader_label.text = " / ".join(parts) + (" — to %d" % limit if limit > 0 else "")
elif players.is_empty():
_leader_label.text = "Leader: None"
else:
var top = players[0]
_leader_label.text = "Leader: %s (%s%s)" % [
stats[top].get("username", "Player " + str(top)),
stats[top].get("score", 0),
"/%d" % limit if limit > 0 else ""]
func _on_killfeed_event(victim: String, killer: String, weapon: String, v_color: String = "cccccc", k_color: String = "cccccc") -> void:
var l = RichTextLabel.new()
+169
View File
@@ -0,0 +1,169 @@
extends Control
class_name MatchSummary
## The end of a match: who won, why, and what everyone did.
##
## There was nothing here before. The clock reached zero, `match_active` went
## false, the timer froze at 00:00, and the players kept shooting each other in a
## match that had stopped counting. A mode without an ending is a mode without
## stakes — everything a player does in the last minute only matters if there is
## a last minute.
##
## Built from the theme's own parts, so it reads as the same game as the menu it
## came from and the HUD it covers.
signal play_again
signal to_menu
var _result: Dictionary = {}
func show_result(result: Dictionary) -> void:
_result = result
for c in get_children():
c.queue_free()
_build()
visible = true
# The summary takes the mouse, which the match had captured.
Input.set_mouse_mode(Input.MOUSE_MODE_VISIBLE)
func _build() -> void:
set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
mouse_filter = Control.MOUSE_FILTER_STOP
var wash := ColorRect.new()
wash.color = Color(UITheme.INK.r, UITheme.INK.g, UITheme.INK.b, 0.86)
wash.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
wash.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(wash)
var centre := CenterContainer.new()
centre.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
add_child(centre)
var card := UITheme.card()
card.custom_minimum_size = Vector2(880, 0)
centre.add_child(card)
var col := VBoxContainer.new()
col.add_theme_constant_override("separation", 10)
card.add_child(col)
# ── Who won, and why ────────────────────────────────────────────────────
var winner := String(_result.get("name", "DRAW"))
var title := UITheme.title(winner if winner == "DRAW" else winner + " WINS",
68, -2.0)
title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
# A winning TEAM's name is written in that team's colour. The wordmark
# treatment is papaya by default, which on "MAGENTA TEAM WINS" says the
# opposite of what the words do.
var win_team := int(_result.get("team", 0))
if win_team > 0:
title.add_theme_color_override("font_color", GameMode.team_color(win_team))
col.add_child(title)
# The REASON, not just the result. "Time" and "frag limit" are different
# stories about the same scoreline and the player was in one of them.
var reason := "TIME"
if String(_result.get("reason", "")) == "score":
reason = "%s LIMIT REACHED" % GameMode.score_noun(
String(_result.get("mode", GameMode.DEATHMATCH)))
var sub := UITheme.caption("%s%s" % [
GameMode.display_name(String(_result.get("mode", GameMode.DEATHMATCH))),
reason], 22)
sub.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
col.add_child(sub)
var rule := UITheme.divider(0.42)
rule.custom_minimum_size = Vector2(0, 20)
col.add_child(rule)
# ── Team scores, if this was a team mode ────────────────────────────────
var mode := String(_result.get("mode", GameMode.DEATHMATCH))
var stats: Dictionary = _result.get("stats", {})
if GameMode.is_team_mode(mode):
var totals := GameMode.team_scores(mode, stats)
var row := HBoxContainer.new()
row.alignment = BoxContainer.ALIGNMENT_CENTER
row.add_theme_constant_override("separation", 46)
col.add_child(row)
for t in totals:
var box := VBoxContainer.new()
var nm := UITheme.heading(GameMode.team_name(t), 24)
nm.add_theme_color_override("font_color", GameMode.team_color(t))
nm.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
box.add_child(nm)
var sc := UITheme.heading(str(totals[t]), 54)
sc.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
box.add_child(sc)
row.add_child(box)
# ── The standings ───────────────────────────────────────────────────────
var grid := GridContainer.new()
grid.columns = 6
grid.add_theme_constant_override("h_separation", 34)
grid.add_theme_constant_override("v_separation", 6)
col.add_child(grid)
for h in ["#", "PLAYER", GameMode.score_noun(mode), "K", "D", "BEST RUN"]:
var lbl := UITheme.caption(h, 20)
grid.add_child(lbl)
var order: Array = _result.get("standings", [])
for i in order.size():
var pid = order[i]
var s: Dictionary = stats.get(pid, {})
var place := i + 1
# First place gets the volt. It is the only place in this UI outside the
# moment of input that volt appears, and winning is worth the exception.
var tint: Color = UITheme.VOLT if place == 1 else UITheme.PAPER
grid.add_child(_cell(str(place), tint, place == 1))
var who := _cell(String(s.get("username", "Player")), tint, place == 1)
if int(s.get("team", 0)) > 0:
who.add_theme_color_override("font_color",
GameMode.team_color(int(s.get("team", 0))))
grid.add_child(who)
grid.add_child(_cell(str(s.get("score", 0)), tint, place == 1))
grid.add_child(_cell(str(s.get("kills", 0)), tint, place == 1))
grid.add_child(_cell(str(s.get("deaths", 0)), tint, place == 1))
grid.add_child(_cell("x%d" % int(s.get("best_streak", 0)), tint, place == 1))
col.add_child(UITheme.divider(0.6))
# ── Out ─────────────────────────────────────────────────────────────────
var buttons := HBoxContainer.new()
buttons.alignment = BoxContainer.ALIGNMENT_CENTER
buttons.add_theme_constant_override("separation", 18)
col.add_child(buttons)
var again := UITheme.primary_button("PLAY AGAIN", 34)
again.pressed.connect(func(): play_again.emit())
buttons.add_child(again)
var menu := Button.new()
menu.text = "Main Menu"
menu.add_theme_font_size_override("font_size", 28)
menu.pressed.connect(func(): to_menu.emit())
buttons.add_child(menu)
# Only the host may restart a running server; everyone else waits for them.
var nm = get_tree().root.get_node_or_null("NetworkManager")
if nm and nm.multiplayer.has_multiplayer_peer() \
and not nm.multiplayer.multiplayer_peer is OfflineMultiplayerPeer \
and not nm.multiplayer.is_server():
again.disabled = true
again.text = "WAITING FOR HOST"
UITheme.wire_sounds(self)
again.grab_focus()
func _cell(text: String, tint: Color, bold: bool) -> Label:
var l := Label.new()
l.text = text
l.add_theme_font_size_override("font_size", 30 if bold else 26)
l.add_theme_color_override("font_color", tint)
l.add_theme_color_override("font_outline_color", UITheme.INK)
l.add_theme_constant_override("outline_size", 6)
return l
+1
View File
@@ -0,0 +1 @@
uid://ygajwj3bek8k
+682
View File
@@ -0,0 +1,682 @@
extends CanvasLayer
class_name PlayerHUD
## The first-person HUD, in the same hand as the rest of the game.
##
## It is one place now. The reticle used to be five white ColorRects pasted into
## three different level runtimes, the vitals were two stock ProgressBars with a
## flat colour override buried 1200 lines into the movement controller, and the
## ammo count — the single number a shooter's player looks at most — was not on
## screen at all. Nothing shared the ink edge, the lean or the palette that every
## menu in the game is built from, so crossing from the main menu into a match
## looked like crossing into a different product.
##
## The layout follows the one rule that matters for a HUD: the player is looking
## at the CENTRE of the screen, so everything the HUD says is arranged by how
## urgently it needs to interrupt that.
##
## centre the reticle, and confirmations, and the reload ring. Read
## constantly, without moving the eye.
## bottom left vitals, and the movement chain above them. Glanced at between
## engagements.
## bottom right ammo, grenades, ability cooldowns. Same.
## top left the debug readout, and only when a setting asks for it.
## nowhere else everything that is not one of those things.
##
## Match state — timer, score, killfeed, scoreboard — belongs to ui/match_hud.gd
## and is deliberately not duplicated here.
##
## ── Why this owns the abilities and the chain ────────────────────────────────
##
## Those used to be built by the LEVEL, in three separate runtime scripts that
## each carried their own byte-identical copy of a black rounded panel. A level
## cannot know a player's dash cooldown without reaching down into that player's
## state machine every frame, which is what all three did, and it cannot show the
## right thing in a split-screen or spectator case at all. More immediately: the
## level's ammo panel and this one's both existed, so the screen showed the ammo
## count twice, in two different styles, overlapping.
##
## The rule is that anything describing THE PLAYER belongs to the player's HUD,
## and a level owns the level.
## The player this HUD belongs to. Set before adding to the tree.
var player: Node = null
var crosshair: Crosshair
var reload_ring: ReloadRing
var death_screen: Control
var _health: VitalBar
var _shield: VitalBar
var _health_num: Label
var _shield_num: Label
var _shield_row: Control
var _ammo_num: Label
var _ammo_max: Label
var _weapon_name: Label
var _ammo_card: PanelContainer
var _grenade_row: HBoxContainer
var _chain_row: Control
var _chain_num: Label
var _chain_bonus: Label
var _shown_chain: int = 0
var _dash_chip: AbilityChip
var _grapple_chip: AbilityChip
var _debug_box: VBoxContainer
var _fps_line: Label
var _speed_line: Label
var _state_line: Label
var _wman: Node = null
var _machine: Node = null
## Crosshair bloom sources, all 0..1, combined as the largest rather than the sum
## so a sprinting player who fires does not blow the reticle off the screen.
var _fire_bloom: float = 0.0
var _shown_ammo: int = -1
## Speed at which movement alone fully blooms the reticle.
const BLOOM_SPEED := 14.0
## How fast a shot's bloom recovers. Roughly a fifth of a second, which is short
## enough to keep pace with an automatic weapon.
const FIRE_RECOVER := 5.0
const SPREAD_SMOOTH := 12.0
var _spread: float = 0.0
## Below this fraction of a magazine the ammo readout goes volt and the count
## starts to matter.
const LOW_AMMO := 0.34
func _ready() -> void:
layer = 1 # above the weapon viewmodel's canvas, which sits at 0
UITheme.apply_global(get_tree())
_build_reticle()
_build_vitals()
_build_ammo()
_build_abilities()
_build_debug()
_build_death_screen()
# ── Public API ───────────────────────────────────────────────────────────────
func set_vitals(hp: float, hp_max: float, sh: float, sh_max: float) -> void:
_health.max_value = hp_max
_health.value = hp
_health_num.text = str(int(ceil(hp)))
# Health goes volt-tinted when it is the last thing standing between the
# player and a respawn. The bar breathes at the same threshold.
var frac: float = hp / maxf(hp_max, 1.0)
_health_num.add_theme_color_override("font_color",
UITheme.VOLT if frac <= VitalBar.LOW_AT else UITheme.PAPER)
_shield.max_value = sh_max
_shield.value = sh
_shield_num.text = str(int(ceil(sh)))
# A depleted shield is not information worth a permanent row — it hides, and
# reappears the moment it starts recharging. One less thing on screen.
_shield_row.visible = sh > 0.5
## A landed shot. `fatal` gets the louder kill confirmation.
func confirm_hit(fatal: bool = false) -> void:
if crosshair:
crosshair.confirm(fatal)
# ── Build ────────────────────────────────────────────────────────────────────
func _build_reticle() -> void:
crosshair = Crosshair.new()
crosshair.name = "Crosshair"
add_child(crosshair)
reload_ring = load("res://ui/reload_ring.gd").new()
reload_ring.name = "ReloadRing"
# Deliberately NOT re-anchored here. The ring centres itself inside its own
# full rect; a PRESET_CENTER here ran after its `_ready` and undid that,
# which is how it ended up as a zero-sized control at (960, 960).
# The ring sits outside the reticle's bloom so a reload in progress never
# obscures the ticks, and it is papaya rather than white so it belongs.
reload_ring.radius = 34.0
reload_ring.thickness = 5.0
reload_ring.color = UITheme.PAPAYA
add_child(reload_ring)
func _build_vitals() -> void:
# Full rect plus margins — see the note in `_build_ammo` for why a corner
# preset collapses.
var margin := MarginContainer.new()
margin.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
margin.add_theme_constant_override("margin_left", 34)
margin.add_theme_constant_override("margin_bottom", 30)
margin.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(margin)
# Row pushes left, column pushes down — see the note in `_build_ammo`.
var row := HBoxContainer.new()
row.alignment = BoxContainer.ALIGNMENT_BEGIN
row.mouse_filter = Control.MOUSE_FILTER_IGNORE
margin.add_child(row)
var col := VBoxContainer.new()
col.alignment = BoxContainer.ALIGNMENT_END
col.size_flags_vertical = Control.SIZE_SHRINK_END
col.add_theme_constant_override("separation", 8)
col.mouse_filter = Control.MOUSE_FILTER_IGNORE
row.add_child(col)
_build_chain(col)
# Shield above health: it is the layer that goes first, so it reads as the
# outer one. Its whole row hides when empty rather than sitting at zero.
var sh := _vital_row("SHIELD", UITheme.CYAN, 30, 200.0)
_shield = sh[0]
_shield_num = sh[1]
_shield_row = sh[2]
col.add_child(_shield_row)
var hp := _vital_row("HEALTH", UITheme.PAPAYA, 46, 250.0)
_health = hp[0]
_health_num = hp[1]
col.add_child(hp[2])
## One vital: a tag, the bar, and the numeral BESIDE the bar.
##
## Beside, not centred on it — see the note in VitalBar. Returns
## `[bar, numeral, row]`, because the caller needs all three.
func _vital_row(tag: String, col: Color, num_size: int, width: float) -> Array:
var row := HBoxContainer.new()
row.add_theme_constant_override("separation", 12)
row.mouse_filter = Control.MOUSE_FILTER_IGNORE
var stack := VBoxContainer.new()
stack.add_theme_constant_override("separation", 1)
row.add_child(stack)
var label := Label.new()
label.text = tag
label.add_theme_font_size_override("font_size", 15)
label.add_theme_color_override("font_color", col)
label.add_theme_color_override("font_outline_color", UITheme.INK)
label.add_theme_constant_override("outline_size", 5)
stack.add_child(label)
var bar := VitalBar.new()
bar.fill_color = col
bar.custom_minimum_size = Vector2(width, 22)
stack.add_child(bar)
var num := Label.new()
num.text = "100"
num.add_theme_font_size_override("font_size", num_size)
num.add_theme_color_override("font_color", UITheme.PAPER)
num.add_theme_color_override("font_outline_color", UITheme.INK)
num.add_theme_constant_override("outline_size", 9)
num.vertical_alignment = VERTICAL_ALIGNMENT_BOTTOM
row.add_child(num)
return [bar, num, row]
func _build_ammo() -> void:
# FULL RECT plus margins, not a corner preset.
#
# A MarginContainer anchored to BOTTOM_RIGHT has zero size and grows from the
# corner, so its `margin_right` pushes the content INTO a container that is
# not there — the card collapsed to a sliver hanging off the right edge of
# the screen with the ammo count clipped inside it. Owning the whole rect and
# aligning to the end is unambiguous, and it is what the ability row does.
var margin := MarginContainer.new()
margin.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
margin.add_theme_constant_override("margin_right", 34)
margin.add_theme_constant_override("margin_bottom", 30)
margin.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(margin)
# A BoxContainer's own `alignment` is the reliable way to push content to an
# edge; a SHRINK_END size flag on the box itself is not, because the box's
# minimum width depends on children that may be hidden and it ends up placed
# by its own START. So the corner is built out of two boxes, each aligning on
# the axis it actually controls: the row pushes right, the column pushes
# down. This is the same scaffold the ability row uses.
var row := HBoxContainer.new()
row.alignment = BoxContainer.ALIGNMENT_END
row.mouse_filter = Control.MOUSE_FILTER_IGNORE
margin.add_child(row)
var col := VBoxContainer.new()
col.alignment = BoxContainer.ALIGNMENT_END
col.size_flags_vertical = Control.SIZE_SHRINK_END
col.add_theme_constant_override("separation", 6)
col.mouse_filter = Control.MOUSE_FILTER_IGNORE
row.add_child(col)
# Grenades as pips rather than a number: three of a thing is countable, and
# it matches the segmented vitals rather than introducing a second idiom.
_grenade_row = HBoxContainer.new()
_grenade_row.alignment = BoxContainer.ALIGNMENT_END
_grenade_row.add_theme_constant_override("separation", 5)
col.add_child(_grenade_row)
_ammo_card = UITheme.card()
_ammo_card.mouse_filter = Control.MOUSE_FILTER_IGNORE
col.add_child(_ammo_card)
var inner := VBoxContainer.new()
inner.alignment = BoxContainer.ALIGNMENT_END
inner.add_theme_constant_override("separation", 0)
_ammo_card.add_child(inner)
_weapon_name = Label.new()
_weapon_name.text = ""
_weapon_name.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
_weapon_name.add_theme_font_size_override("font_size", 18)
_weapon_name.add_theme_color_override("font_color", UITheme.PAPER_DIM)
_weapon_name.add_theme_color_override("font_outline_color", UITheme.INK)
_weapon_name.add_theme_constant_override("outline_size", 5)
inner.add_child(_weapon_name)
# The two halves of the count are separate labels at different sizes, so the
# magazine reads as the number and the reserve reads as context. One
# "12 / 30" at a single size makes the player parse a string.
var count := HBoxContainer.new()
count.alignment = BoxContainer.ALIGNMENT_END
count.add_theme_constant_override("separation", 4)
inner.add_child(count)
_ammo_num = Label.new()
_ammo_num.text = "--"
_ammo_num.add_theme_font_size_override("font_size", 58)
_ammo_num.add_theme_color_override("font_color", UITheme.PAPER)
_ammo_num.add_theme_color_override("font_outline_color", UITheme.INK)
_ammo_num.add_theme_constant_override("outline_size", 10)
_ammo_num.vertical_alignment = VERTICAL_ALIGNMENT_BOTTOM
count.add_child(_ammo_num)
_ammo_max = Label.new()
_ammo_max.text = ""
_ammo_max.add_theme_font_size_override("font_size", 24)
_ammo_max.add_theme_color_override("font_color", UITheme.PAPER_DIM)
_ammo_max.add_theme_color_override("font_outline_color", UITheme.INK)
_ammo_max.add_theme_constant_override("outline_size", 6)
_ammo_max.vertical_alignment = VERTICAL_ALIGNMENT_BOTTOM
count.add_child(_ammo_max)
## Dash and grapple, above the ammo card on the right.
##
## Right-hand side because they are the other half of "what can I do right now",
## which is the question the ammo count answers. Putting cooldowns on the left
## with the vitals would split that question across the screen.
func _build_abilities() -> void:
var margin := MarginContainer.new()
margin.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
margin.add_theme_constant_override("margin_right", 34)
margin.add_theme_constant_override("margin_bottom", 190)
margin.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(margin)
var row := HBoxContainer.new()
row.alignment = BoxContainer.ALIGNMENT_END
row.size_flags_vertical = Control.SIZE_SHRINK_END
row.add_theme_constant_override("separation", 8)
row.mouse_filter = Control.MOUSE_FILTER_IGNORE
margin.add_child(row)
_grapple_chip = AbilityChip.new()
_grapple_chip.label = "GRAPPLE"
_grapple_chip.custom_minimum_size = Vector2(116, 34)
row.add_child(_grapple_chip)
_dash_chip = AbilityChip.new()
_dash_chip.label = "DASH"
row.add_child(_dash_chip)
## The movement chain, above the vitals.
##
## Movement is this game's stated first pillar and chaining mechanics is its
## skill expression, so the chain count is not a debug readout — it is the score
## of the thing the game is about, and it belongs on the HUD at a size that says
## so. It hides at zero, because a chain of nothing is not worth screen space.
func _build_chain(into: Container) -> void:
_chain_row = HBoxContainer.new()
_chain_row.add_theme_constant_override("separation", 8)
_chain_row.mouse_filter = Control.MOUSE_FILTER_IGNORE
_chain_row.visible = false
into.add_child(_chain_row)
_chain_num = Label.new()
_chain_num.text = "x0"
_chain_num.add_theme_font_size_override("font_size", 40)
_chain_num.add_theme_color_override("font_color", UITheme.VOLT)
_chain_num.add_theme_color_override("font_outline_color", UITheme.INK)
_chain_num.add_theme_constant_override("outline_size", 9)
_chain_num.pivot_offset = Vector2(20, 20)
_chain_row.add_child(_chain_num)
_chain_bonus = Label.new()
_chain_bonus.text = ""
_chain_bonus.add_theme_font_size_override("font_size", 22)
_chain_bonus.add_theme_color_override("font_color", UITheme.PAPAYA)
_chain_bonus.add_theme_color_override("font_outline_color", UITheme.INK)
_chain_bonus.add_theme_constant_override("outline_size", 7)
_chain_bonus.vertical_alignment = VERTICAL_ALIGNMENT_BOTTOM
_chain_row.add_child(_chain_bonus)
## FPS / speed / state, top left, and only when a setting asks for them.
##
## Restyled rather than deleted: they are genuinely useful, and in a game about
## momentum the speed readout is arguably gameplay. What they are not is a reason
## to have neon green on pure black in the corner of an ink-drawn game, which is
## what three copies of this used to be.
func _build_debug() -> void:
var margin := MarginContainer.new()
margin.set_anchors_and_offsets_preset(Control.PRESET_TOP_LEFT)
margin.add_theme_constant_override("margin_left", 18)
margin.add_theme_constant_override("margin_top", 14)
margin.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(margin)
_debug_box = VBoxContainer.new()
_debug_box.add_theme_constant_override("separation", 0)
_debug_box.mouse_filter = Control.MOUSE_FILTER_IGNORE
margin.add_child(_debug_box)
_fps_line = UITheme.caption("", 22)
_fps_line.add_theme_color_override("font_color", UITheme.VOLT)
_debug_box.add_child(_fps_line)
_speed_line = UITheme.caption("", 22)
_speed_line.add_theme_color_override("font_color", UITheme.CYAN)
_debug_box.add_child(_speed_line)
_state_line = UITheme.caption("", 18)
_debug_box.add_child(_state_line)
func _build_death_screen() -> void:
death_screen = Control.new()
death_screen.name = "DeathScreen"
death_screen.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
death_screen.mouse_filter = Control.MOUSE_FILTER_IGNORE
death_screen.visible = false
add_child(death_screen)
# Ink wash rather than plain black: the whole game's darkness is a violet
# near-black, and a neutral 70% black over it reads as a bug.
var wash := ColorRect.new()
wash.color = Color(UITheme.INK.r, UITheme.INK.g, UITheme.INK.b, 0.78)
wash.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
wash.mouse_filter = Control.MOUSE_FILTER_IGNORE
death_screen.add_child(wash)
var center := CenterContainer.new()
center.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
center.mouse_filter = Control.MOUSE_FILTER_IGNORE
death_screen.add_child(center)
var col := VBoxContainer.new()
col.alignment = BoxContainer.ALIGNMENT_CENTER
col.add_theme_constant_override("separation", 10)
center.add_child(col)
var title := UITheme.title("DOWNED", 104, -3.0)
title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
col.add_child(title)
var rule := UITheme.divider(0.5)
rule.custom_minimum_size = Vector2(520, 18)
col.add_child(rule)
var prompt := UITheme.heading("PRESS FIRE TO REDEPLOY", 30)
prompt.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
col.add_child(prompt)
var tw := create_tween().set_loops()
tw.set_trans(Tween.TRANS_SINE)
tw.tween_property(prompt, "modulate:a", 0.25, 0.7)
tw.tween_property(prompt, "modulate:a", 1.0, 0.7)
# ── Per-frame ────────────────────────────────────────────────────────────────
func _process(delta: float) -> void:
if not is_instance_valid(player):
return
_update_weapon(delta)
_update_crosshair(delta)
_update_movement()
_update_debug()
## Chain count and ability cooldowns, both read off the movement state machine.
func _update_movement() -> void:
var sm := _state_machine()
if sm == null:
return
var chain := int(sm.chain_count) if "chain_count" in sm else 0
_chain_row.visible = chain > 0
if chain > 0:
_chain_num.text = "x%d" % chain
var bonus: float = float(sm.current_chain_bonus) if "current_chain_bonus" in sm else 0.0
_chain_bonus.text = "+%d%% SPEED" % int(round(bonus * 100.0))
# Each new link punches the number up and lets it settle. Momentum is the
# thing this game rewards, so extending a chain should feel like landing
# something rather than like a counter incrementing.
if chain > _shown_chain:
var tw := create_tween()
tw.set_trans(Tween.TRANS_BACK).set_ease(Tween.EASE_OUT)
_chain_num.scale = Vector2(1.35, 1.35)
tw.tween_property(_chain_num, "scale", Vector2.ONE, 0.28)
_shown_chain = chain
var state := String(sm.current_state) if "current_state" in sm else ""
var shooting: bool = "is_grapple_shooting" in sm and sm.is_grapple_shooting
_grapple_chip.active = state == "grapple" or shooting
if sm.has_method("get_dash_cooldown_remaining"):
var rem: float = sm.get_dash_cooldown_remaining()
# Normalised against the params' own cooldown, so retuning the dash
# retunes the readout with it instead of leaving the wipe lying.
var total := 1.0
if "params" in player and player.params and "dash_cooldown" in player.params:
total = maxf(float(player.params.dash_cooldown), 0.001)
_dash_chip.cooldown = clampf(rem / total, 0.0, 1.0)
func _update_debug() -> void:
# Resolved through the tree rather than by the autoload's global identifier.
# A tool script launched with `-s` compiles its dependencies BEFORE autoloads
# are registered, so naming `SettingsManager` directly makes this whole file
# fail to compile under debug/hud_layout_check.gd — which is precisely the
# harness that has to be able to load it.
var settings := _settings()
if settings == null:
return
var sm := _state_machine()
var show_debug: bool = settings.show_debug_ui
_fps_line.visible = settings.show_fps
if _fps_line.visible:
_fps_line.text = "FPS %d" % Engine.get_frames_per_second()
_speed_line.visible = settings.show_movement_speed or show_debug
if _speed_line.visible:
var v: Vector3 = player.velocity if "velocity" in player else Vector3.ZERO
var h := Vector2(v.x, v.z).length()
_speed_line.text = "%.1f m/s" % h if not show_debug \
else "%.1f m/s (total %.1f)" % [h, v.length()]
_state_line.visible = show_debug and sm != null
if _state_line.visible:
_state_line.text = String(sm.current_state).to_upper()
## Ammo, weapon name and grenades, read off whatever is in the player's hands.
##
## Read rather than pushed, because the weapon is the authority on its own ammo
## and there are eleven weapon scripts. A HUD that had to be notified would mean
## eleven places to forget to notify it.
func _update_weapon(_delta: float) -> void:
var w := _active_weapon()
if w == null:
_ammo_card.visible = false
return
_ammo_card.visible = true
# Named by the weapon, or derived from its script if it forgot to say. A HUD
# that silently shows a blank where the weapon's name goes is worse than one
# that shows a slightly ugly name.
if "weapon_name" in w and String(w.weapon_name) != "":
_weapon_name.text = String(w.weapon_name).to_upper()
else:
var src: Script = w.get_script()
_weapon_name.text = src.resource_path.get_file().get_basename() \
.replace("_", " ").to_upper() if src else "WEAPON"
# The shotgun counts shells, everything else counts rounds. Both are "how
# many more times can I pull the trigger", which is the only question the
# number answers.
var now := -1
var cap := -1
if "current_ammo" in w:
now = int(w.current_ammo)
cap = int(w.max_ammo) if "max_ammo" in w else -1
elif "shells" in w:
now = int(w.shells)
cap = int(w.max_shells) if "max_shells" in w else -1
if now < 0:
# A melee weapon has no count. Showing "0" would read as empty.
_ammo_num.text = ""
_ammo_max.text = ""
else:
_ammo_num.text = str(now)
_ammo_max.text = "/ %d" % cap if cap > 0 else ""
var frac: float = float(now) / float(maxi(cap, 1))
var reloading: bool = "reloading" in w and w.reloading
var col: Color = UITheme.PAPER
if now == 0:
col = UITheme.MAGENTA
elif frac <= LOW_AMMO:
col = UITheme.VOLT
if reloading:
col = UITheme.PAPER_DIM
_ammo_num.add_theme_color_override("font_color", col)
# A shot fired blooms the reticle. Detected from the count dropping
# rather than from a signal, for the same reason the count is read.
if _shown_ammo >= 0 and now < _shown_ammo:
_fire_bloom = 1.0
_shown_ammo = now
_update_grenades()
## Grenades as pips, rebuilt only when the count changes.
##
## Built once and then shown/hidden rather than freed, because `queue_free` is
## deferred: a loop that frees down to a target count sees the same child count
## on the next iteration and frees the whole row.
func _update_grenades() -> void:
var count := int(player.grenades) if "grenades" in player else 0
while _grenade_row.get_child_count() < maxi(count, _grenade_pips):
var pip := Panel.new()
pip.custom_minimum_size = Vector2(17, 17)
pip.mouse_filter = Control.MOUSE_FILTER_IGNORE
# A filled chip with the theme's ink edge, so a 17 px pip still reads
# against a bright skybox.
pip.add_theme_stylebox_override("panel", UITheme.row(UITheme.CYAN))
_grenade_row.add_child(pip)
for i in _grenade_row.get_child_count():
(_grenade_row.get_child(i) as Control).visible = i < count
## The most pips ever needed. Grenades only ever go down during a life and back
## up on respawn, so this is the starting count.
const _grenade_pips := 3
## Reticle bloom and the ADS blend.
##
## Sources are combined with `max`, not by adding: a sprinting player who fires
## should see the reticle at its bloomed size, not at twice it.
func _update_crosshair(delta: float) -> void:
_fire_bloom = maxf(_fire_bloom - delta * FIRE_RECOVER, 0.0)
var speed := 0.0
if "velocity" in player:
var v: Vector3 = player.velocity
speed = Vector2(v.x, v.z).length()
var move := clampf(speed / BLOOM_SPEED, 0.0, 1.0)
var state := String(player.synced_movement_state) if "synced_movement_state" in player else ""
# Airborne is the least accurate a player can be, and a reticle that says so
# is what stops mid-air spraying from feeling arbitrary.
var air := 0.75 if state in ["air", "dash", "grapple"] else 0.0
var want: float = maxf(maxf(move, air), _fire_bloom)
_spread = lerpf(_spread, want, 1.0 - exp(-SPREAD_SMOOTH * delta))
crosshair.spread = _spread
var ads := 0.0
if "synced_is_ads" in player and player.synced_is_ads:
ads = 1.0
crosshair.ads = lerpf(crosshair.ads, ads, 1.0 - exp(-14.0 * delta))
# The reload ring reads the weapon directly, same as the ammo count.
var w := _active_weapon()
if w and "reloading" in w and w.reloading and "reload_timer" in w \
and "reload_time" in w and w.reload_time > 0.0:
reload_ring.progress = 1.0 - (w.reload_timer / w.reload_time)
else:
reload_ring.progress = 0.0
## The settings autoload, or null when the HUD is being exercised outside a
## running game. See the note in `_update_debug`.
func _settings() -> Node:
if not is_instance_valid(_settings_node):
_settings_node = get_tree().root.get_node_or_null("SettingsManager")
return _settings_node if is_instance_valid(_settings_node) else null
var _settings_node: Node = null
## The player's movement state machine, or null. Cached, since it is asked for
## several times a frame and never moves.
func _state_machine() -> Node:
if not is_instance_valid(_machine):
_machine = player.get_node_or_null("MovementStateMachine")
return _machine if is_instance_valid(_machine) else null
## Whatever weapon is in the player's hands, or null.
func _active_weapon() -> Node:
if not is_instance_valid(_wman):
var cam = player.get("camera") if "camera" in player else null
if is_instance_valid(cam):
_wman = cam.get_node_or_null("WeaponManager")
if not is_instance_valid(_wman):
return null
var slot = _wman.get("active_slot")
if slot == null or not _wman.weapons.has(slot):
return null
var w = _wman.weapons[slot]
return w if is_instance_valid(w) else null
+1
View File
@@ -0,0 +1 @@
uid://hjsfcdnutb4t
+30 -8
View File
@@ -1,22 +1,44 @@
extends Control
class_name ReloadRing
## The reload arc, drawn around the reticle.
##
## Like Crosshair it owns the full rect and centres itself in `size` rather than
## sitting at a zero-sized PRESET_CENTER: a control that draws before the
## viewport has sized it lands in the top-left corner, and only a later redraw
## moves it — which, for something that redraws on a value change, can be never.
var progress: float = 0.0:
set(val):
progress = clampf(val, 0.0, 1.0)
var v := clampf(val, 0.0, 1.0)
if is_equal_approx(v, progress):
return
progress = v
queue_redraw()
var radius: float = 16.0
var thickness: float = 4.0
var color: Color = Color(1.0, 1.0, 1.0, 0.8)
func _ready() -> void:
mouse_filter = Control.MOUSE_FILTER_IGNORE
# Offsets too — see the note in ui/crosshair.gd.
set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
resized.connect(queue_redraw)
func _draw() -> void:
if progress <= 0.0 or progress >= 1.0:
return
# Draw background arc (darker)
draw_arc(Vector2.ZERO, radius, 0, PI * 2.0, 32, Color(0, 0, 0, 0.4), thickness, true)
# Draw progress arc
var end_angle = -PI / 2.0 + (PI * 2.0 * progress)
draw_arc(Vector2.ZERO, radius, -PI / 2.0, end_angle, 32, color, thickness, true)
var mid := size * 0.5
# Ink track first, wider than the arc, so the ring reads over a bright
# skybox the same way every other element in this UI does. The old version
# used 40% black, which vanished against pale concrete.
draw_arc(mid, radius, 0, TAU, 48, UITheme.INK, thickness + 4.0, true)
draw_arc(mid, radius, 0, TAU, 48, UITheme.INK_SOFT, thickness, true)
# The charged part, sweeping from twelve o'clock.
draw_arc(mid, radius, -PI / 2.0, -PI / 2.0 + TAU * progress, 48, color,
thickness, true)
+263 -20
View File
@@ -6,7 +6,7 @@ class_name UITheme
##
## The look is a charged cel comic — near-black violet ink, hot papaya, and a
## lightning yellow that only ever appears at the moment something is pressed.
## Three rules hold it together:
## Four rules hold it together:
##
## INK EVERYTHING every panel, chip and letter carries a heavy dark edge.
## It is what makes flat colour read as drawn rather than
@@ -18,11 +18,28 @@ class_name UITheme
## VOLT MEANS NOW yellow is reserved for the pressed state and the bolt.
## Spend it anywhere else and the moment of input stops
## standing out.
## EVERY STATE READS a fill and the text on it are chosen together, and the
## pair is checked. See `STATE_TABLE` and `contrast`.
##
## Everything here is static. Screens ask for `apply_global` once and then use
## the helpers — `heading`, `card`, `divider`, `chip_button` — instead of
## hand-rolling styleboxes, which is how the pause menu drifted 900 lines away
## from the rest of the game's look.
##
## ── Why the state table exists ────────────────────────────────────────────────
##
## The palette has two light accents (papaya, volt) and one very dark one (ink),
## and a control changes its FILL on hover and press. Text that reads perfectly
## at rest — paper on near-black — inverts to paper-on-yellow the instant the
## pointer arrives, which is a contrast ratio of 1.1:1. It is invisible.
##
## This is the single most common way a stylised UI becomes unreadable, and it is
## also the complaint HoYoverse's own audience has levelled at Zenless Zone
## Zero's menus. The fix is not vigilance, it is arithmetic: `ink_for` picks the
## legible text colour for any fill, every state declares its pair in
## `STATE_TABLE`, and `debug/ui_contrast_check.gd` fails the build if one of them
## drops below the WCAG floor. A state cannot ship unreadable without the check
## going red.
const FONT_PATH := "res://assets/ui/fonts/Bangers-Regular.ttf"
@@ -42,6 +59,14 @@ const MAGENTA := Color(1.00, 0.18, 0.52)
## Older screens name the accent `TEAL`; it is the electric cyan now.
const TEAL := CYAN
## Disabled: the fill goes flat and the label desaturates, but it does NOT go so
## dim that it stops being a word. A disabled control still has to say what it
## would do — "Start Match" greyed out is information, an illegible smudge is
## not. This pair sits above 5:1, where the old 0.42-grey sat at 3.2:1.
const DEAD_FILL := Color(0.10, 0.10, 0.14, 0.92)
const DEAD_EDGE := Color(0.30, 0.29, 0.36)
const DEAD_TEXT := Color(0.55, 0.54, 0.62)
## How far a chip leans. Applied as opposite corners round and the other two
## nearly square, which is as close to a skew as a StyleBoxFlat can get.
const LEAN := 18
@@ -49,6 +74,85 @@ const LEAN := 18
static var _theme: Theme = null
# ── Contrast ─────────────────────────────────────────────────────────────────
## Relative luminance, per WCAG 2.1. Alpha is ignored: a chip's fill is drawn
## over the panel behind it, and assuming the fill is opaque is the pessimistic
## reading, which is the one worth checking.
static func luminance(c: Color) -> float:
var ch := [c.r, c.g, c.b]
for i in 3:
var v: float = ch[i]
ch[i] = v / 12.92 if v <= 0.04045 else pow((v + 0.055) / 1.055, 2.4)
return 0.2126 * ch[0] + 0.7152 * ch[1] + 0.0722 * ch[2]
## WCAG contrast ratio between two colours, 1.0 (identical) .. 21.0 (black on
## white). 4.5 is the floor for body text, 3.0 for text above ~24 px.
static func contrast(a: Color, b: Color) -> float:
var la := luminance(a)
var lb := luminance(b)
return (maxf(la, lb) + 0.05) / (minf(la, lb) + 0.05)
## The legible text colour for a given fill: ink on a light chip, paper on a dark
## one. This is what makes a hover safe — the fill is free to become papaya or
## volt because the label follows it.
static func ink_for(fill: Color) -> Color:
return INK if contrast(INK, fill) >= contrast(PAPER, fill) else PAPER
## Every (fill, text) pair the theme puts on screen, as
## `["<class>/<state>", fill, text, is_large_text]`.
##
## Written out rather than derived so the check has something to compare the
## built theme AGAINST — a table generated from the theme would agree with it by
## construction and catch nothing. Large text (headings, the wordmark, the HUD's
## big numerals) is allowed the 3:1 floor WCAG gives it.
static func state_table() -> Array:
var out: Array = []
for cls in BUTTON_CLASSES:
out.append_array([
[cls + "/normal", INK_SOFT, PAPER, false],
[cls + "/hover", PAPAYA, ink_for(PAPAYA), false],
[cls + "/pressed", VOLT, ink_for(VOLT), false],
[cls + "/focus", INK_SOFT, PAPER, false],
[cls + "/disabled", DEAD_FILL, DEAD_TEXT, false],
])
out.append_array([
["Label/on_panel", PANEL, PAPER, false],
["Label/dim_on_panel", PANEL, PAPER_DIM, false],
["Label/title", PANEL, PAPAYA, true],
["LineEdit/normal", INK_SOFT, PAPER, false],
["LineEdit/placeholder", INK_SOFT, PAPER_DIM, false],
["ItemList/normal", PANEL_DEEP, PAPER, false],
["ItemList/hovered", INK_SOFT, PAPER, false],
["ItemList/selected", PAPAYA, ink_for(PAPAYA), false],
["ItemList/selected_focus", PAPAYA_HOT, ink_for(PAPAYA_HOT), false],
["PopupMenu/normal", PANEL_DEEP, PAPER, false],
["PopupMenu/hover", PAPAYA, ink_for(PAPAYA), false],
["PopupMenu/disabled", PANEL_DEEP, DEAD_TEXT, false],
["TabContainer/selected", PAPAYA, ink_for(PAPAYA), false],
["TabContainer/unselected", INK_SOFT, PAPER_DIM, false],
["TabContainer/hovered", PAPAYA_HOT, ink_for(PAPAYA_HOT), false],
["Tree/normal", PANEL_DEEP, PAPER, false],
["Tree/selected", PAPAYA, ink_for(PAPAYA), false],
["Card/label", INK, PAPER, true],
["Card/hover_label", INK, VOLT, true],
])
return out
## Which theme types get the chip button treatment. Godot theme types do not
## inherit styling from Button, so each one has to be told the same thing.
const BUTTON_CLASSES := ["Button", "OptionButton", "MenuButton", "CheckBox",
"CheckButton", "LinkButton"]
# ── Styleboxes ───────────────────────────────────────────────────────────────
## A leaning comic chip: flat fill, heavy ink edge, hard offset shadow.
##
## Kept at its original name and argument order — main_menu and match_hud both
@@ -93,6 +197,24 @@ static func panel(bg: Color = PANEL, border: Color = INK,
return sb
## A tight row chip — a list entry, a popup line, a tab — with the lean dropped.
##
## The lean is energy on a button the eye lands on. Repeated down twenty rows of
## a list it reads as a stack of broken rectangles, so rows get square corners
## and keep the fill and the edge.
static func row(bg: Color, border: Color = INK, border_w: int = 2) -> StyleBoxFlat:
var sb := StyleBoxFlat.new()
sb.bg_color = bg
sb.border_color = border
sb.set_border_width_all(border_w)
sb.set_corner_radius_all(4)
sb.content_margin_left = 12
sb.content_margin_right = 12
sb.content_margin_top = 5
sb.content_margin_bottom = 5
return sb
static func build() -> Theme:
if _theme:
return _theme
@@ -105,12 +227,10 @@ static func build() -> Theme:
# ── Buttons ──────────────────────────────────────────────────────────
# Dark chip with a papaya edge at rest; the chip FILLS papaya on hover and
# flashes volt on press. The press state drops its shadow, so the chip
# visibly slams down into the page rather than just changing colour.
_button_look(t, "Button")
for cls in ["OptionButton", "MenuButton", "CheckBox", "CheckButton",
"LinkButton"]:
# Godot theme types do not inherit styling from Button, so each one has
# to be told the same thing.
# visibly slams down into the page rather than just changing colour. Every
# state's text colour comes from `ink_for` its own fill, so the label
# inverts WITH the chip instead of vanishing into it.
for cls in BUTTON_CLASSES:
_button_look(t, cls)
# ── Labels: ink outline everywhere, for readability straight over 3D ──
@@ -125,45 +245,110 @@ static func build() -> Theme:
# ── Inputs ───────────────────────────────────────────────────────────
t.set_stylebox("normal", "LineEdit", box(INK_SOFT, PAPAYA, 4, 3, false))
t.set_stylebox("focus", "LineEdit", box(INK_SOFT, CYAN, 4, 3, false))
t.set_stylebox("read_only", "LineEdit", box(DEAD_FILL, DEAD_EDGE, 4, 3, false))
t.set_color("font_color", "LineEdit", PAPER)
t.set_color("font_uneditable_color", "LineEdit", DEAD_TEXT)
t.set_color("caret_color", "LineEdit", VOLT)
t.set_color("font_placeholder_color", "LineEdit", PAPER_DIM)
# Selected text: volt fill wants ink glyphs, same rule as a pressed chip.
t.set_color("font_selected_color", "LineEdit", INK)
t.set_color("selection_color", "LineEdit", VOLT)
t.set_stylebox("normal", "SpinBox", box(INK_SOFT, PAPAYA, 4, 3, false))
t.set_color("font_color", "SpinBox", PAPER)
# ── Sliders: the filled part is the charged part ─────────────────────
t.set_stylebox("slider", "HSlider", box(INK_SOFT, INK, 2, 2, false))
t.set_stylebox("grabber_area", "HSlider", box(PAPAYA, INK, 2, 2, false))
t.set_stylebox("grabber_area_highlight", "HSlider", box(VOLT, INK, 2, 2, false))
t.set_stylebox("slider", "VSlider", box(INK_SOFT, INK, 2, 2, false))
t.set_stylebox("grabber_area", "VSlider", box(PAPAYA, INK, 2, 2, false))
t.set_stylebox("grabber_area_highlight", "VSlider", box(VOLT, INK, 2, 2, false))
# ── Progress bars ────────────────────────────────────────────────────
t.set_stylebox("background", "ProgressBar", row(INK, INK_SOFT, 2))
t.set_stylebox("fill", "ProgressBar", row(PAPAYA, INK, 0))
t.set_color("font_color", "ProgressBar", PAPER)
t.set_color("font_outline_color", "ProgressBar", INK)
t.set_constant("outline_size", "ProgressBar", 6)
# ── Panels / lists ───────────────────────────────────────────────────
t.set_stylebox("panel", "PanelContainer", panel())
t.set_stylebox("panel", "Panel", panel())
t.set_stylebox("panel", "ItemList", panel(PANEL_DEEP, INK, 3))
t.set_color("font_color", "ItemList", PAPER)
t.set_color("font_selected_color", "ItemList", INK)
t.set_stylebox("selected", "ItemList", box(PAPAYA, INK, 4, 2, false))
t.set_stylebox("selected_focus", "ItemList", box(PAPAYA_HOT, CYAN, 4, 2, false))
t.set_stylebox("hovered", "ItemList", box(INK_SOFT, PAPAYA, 4, 2, false))
t.set_color("font_selected_color", "ItemList", ink_for(PAPAYA))
t.set_color("font_hovered_color", "ItemList", PAPER)
t.set_color("font_outline_color", "ItemList", INK)
t.set_constant("outline_size", "ItemList", 5)
t.set_stylebox("selected", "ItemList", row(PAPAYA))
t.set_stylebox("selected_focus", "ItemList", row(PAPAYA_HOT, CYAN))
# Hover keeps PAPER text, so the fill must stay DARK. A papaya hover under a
# papaya selection also made the two states indistinguishable — the row you
# were pointing at looked exactly like the row you had chosen.
t.set_stylebox("hovered", "ItemList", row(INK_SOFT, PAPAYA))
t.set_stylebox("hovered_selected", "ItemList", row(PAPAYA_HOT, CYAN))
t.set_stylebox("cursor", "ItemList", row(Color(0, 0, 0, 0), CYAN))
t.set_stylebox("cursor_unfocused", "ItemList", row(Color(0, 0, 0, 0), PAPER_DIM))
# ── Trees (settings, loadout lists) ──────────────────────────────────
t.set_stylebox("panel", "Tree", panel(PANEL_DEEP, INK, 3))
t.set_color("font_color", "Tree", PAPER)
t.set_color("font_selected_color", "Tree", ink_for(PAPAYA))
t.set_color("font_outline_color", "Tree", INK)
t.set_constant("outline_size", "Tree", 5)
t.set_stylebox("selected", "Tree", row(PAPAYA))
t.set_stylebox("selected_focus", "Tree", row(PAPAYA_HOT, CYAN))
t.set_stylebox("hovered", "Tree", row(INK_SOFT, PAPAYA))
t.set_stylebox("hovered_selected", "Tree", row(PAPAYA_HOT, CYAN))
# ── Scrollbars: visible at rest, charged under the thumb ─────────────
for cls in ["HScrollBar", "VScrollBar"]:
t.set_stylebox("scroll", cls, row(INK, INK_SOFT, 1))
t.set_stylebox("grabber", cls, row(PAPER_DIM, INK, 1))
t.set_stylebox("grabber_highlight", cls, row(PAPAYA, INK, 1))
t.set_stylebox("grabber_pressed", cls, row(VOLT, INK, 1))
# ── Tabs ─────────────────────────────────────────────────────────────
t.set_stylebox("panel", "TabContainer", panel())
t.set_stylebox("tab_selected", "TabContainer", box(PAPAYA, INK, 6, 3, false))
t.set_stylebox("tab_unselected", "TabContainer", box(INK_SOFT, INK, 6, 3, false))
t.set_stylebox("tab_hovered", "TabContainer", box(PAPAYA_HOT, INK, 6, 3, false))
t.set_color("font_selected_color", "TabContainer", INK)
t.set_stylebox("tab_disabled", "TabContainer", box(DEAD_FILL, DEAD_EDGE, 6, 3, false))
t.set_color("font_selected_color", "TabContainer", ink_for(PAPAYA))
t.set_color("font_unselected_color", "TabContainer", PAPER_DIM)
t.set_color("font_hovered_color", "TabContainer", INK)
t.set_color("font_hovered_color", "TabContainer", ink_for(PAPAYA_HOT))
t.set_color("font_disabled_color", "TabContainer", DEAD_TEXT)
t.set_color("font_outline_color", "TabContainer", INK)
t.set_constant("outline_size", "TabContainer", 5)
# ── Popups (OptionButton dropdowns) ──────────────────────────────────
#
# The dropdown is the one surface where a missing hover colour is fatal: a
# PopupMenu draws its hover fill but keeps `font_color` unless
# `font_hover_color` is set, so a papaya row under paper text was the least
# readable thing in the game and it appeared on every settings menu.
t.set_stylebox("panel", "PopupMenu", panel(PANEL_DEEP, PAPAYA, 3))
t.set_stylebox("hover", "PopupMenu", box(PAPAYA, INK, 4, 0, false))
t.set_stylebox("hover", "PopupMenu", row(PAPAYA, INK, 0))
t.set_color("font_color", "PopupMenu", PAPER)
t.set_color("font_hover_color", "PopupMenu", INK)
t.set_color("font_hover_color", "PopupMenu", ink_for(PAPAYA))
t.set_color("font_disabled_color", "PopupMenu", DEAD_TEXT)
t.set_color("font_accelerator_color", "PopupMenu", PAPER_DIM)
t.set_color("font_separator_color", "PopupMenu", PAPAYA)
t.set_color("font_outline_color", "PopupMenu", INK)
t.set_constant("outline_size", "PopupMenu", 5)
# ── Dialogs ──────────────────────────────────────────────────────────
t.set_stylebox("panel", "AcceptDialog", panel(PANEL_DEEP, PAPAYA, 4))
t.set_stylebox("embedded_border", "Window", panel(PANEL_DEEP, PAPAYA, 4))
t.set_color("title_color", "Window", VOLT)
# ── Tooltips ─────────────────────────────────────────────────────────
t.set_stylebox("panel", "TooltipPanel", panel(PANEL_DEEP, VOLT, 2))
t.set_color("font_color", "TooltipLabel", PAPER)
t.set_color("font_outline_color", "TooltipLabel", INK)
t.set_constant("outline_size", "TooltipLabel", 5)
_theme = t
return t
@@ -172,15 +357,40 @@ static func _button_look(t: Theme, cls: String) -> void:
t.set_stylebox("normal", cls, box(INK_SOFT, PAPAYA))
t.set_stylebox("hover", cls, box(PAPAYA, INK))
t.set_stylebox("pressed", cls, box(VOLT, INK, 10, 3, false))
# Hover-while-pressed is its own stylebox on a toggle button. Without it a
# CheckButton the pointer is over reverts to the hover fill, so a toggle
# reads as OFF for as long as you are touching it.
t.set_stylebox("hover_pressed", cls, box(VOLT, CYAN, 10, 3, false))
t.set_stylebox("focus", cls, box(INK_SOFT, CYAN))
t.set_stylebox("disabled", cls, box(Color(0.10, 0.10, 0.14, 0.85), Color(0.28, 0.27, 0.33)))
t.set_stylebox("disabled", cls, box(DEAD_FILL, DEAD_EDGE))
t.set_color("font_color", cls, PAPER)
t.set_color("font_hover_color", cls, INK)
t.set_color("font_pressed_color", cls, INK)
t.set_color("font_hover_color", cls, ink_for(PAPAYA))
t.set_color("font_pressed_color", cls, ink_for(VOLT))
t.set_color("font_hover_pressed_color", cls, ink_for(VOLT))
t.set_color("font_focus_color", cls, PAPER)
t.set_color("font_disabled_color", cls, Color(0.42, 0.41, 0.48))
t.set_color("font_disabled_color", cls, DEAD_TEXT)
# NO OUTLINE on a button label.
#
# An outline exists to separate a glyph from a backdrop the glyph cannot beat
# on its own — over the 3D scene, or across a two-tone progress bar. A button
# label has neither problem: it sits on a solid chip, and `ink_for` has
# already given it a colour that beats that chip.
#
# Worse, the theme can only carry ONE outline colour per class, and half the
# states here use an ink glyph. Ink glyphs inside a 5 px ink outline are not
# outlined, they are five pixels fatter — the selected mode chip rendered as
# an unreadable dark blob on volt. The chips keep their heavy ink BORDER, so
# the drawn look is unaffected.
t.set_color("font_outline_color", cls, INK)
t.set_constant("outline_size", cls, 5)
t.set_constant("outline_size", cls, 0)
# The icon has to invert with the label. A paper glyph on a volt chip is the
# same 1.1:1 the text would have been, and a CheckBox is ALL icon.
t.set_color("icon_normal_color", cls, PAPER)
t.set_color("icon_hover_color", cls, ink_for(PAPAYA))
t.set_color("icon_pressed_color", cls, ink_for(VOLT))
t.set_color("icon_hover_pressed_color", cls, ink_for(VOLT))
t.set_color("icon_focus_color", cls, PAPER)
t.set_color("icon_disabled_color", cls, DEAD_TEXT)
## Apply the theme to the whole root window. Idempotent, and cheap enough that
@@ -223,6 +433,17 @@ static func heading(text: String, size: int = 32) -> Label:
return l
## A quieter line of body copy — a hint, a port number, a mode's description.
static func caption(text: String, size: int = 20) -> Label:
var l := Label.new()
l.text = text
l.add_theme_font_size_override("font_size", size)
l.add_theme_color_override("font_color", PAPER_DIM)
l.add_theme_color_override("font_outline_color", INK)
l.add_theme_constant_override("outline_size", 5)
return l
## A bolt-struck rule, for separating sections.
static func divider(strike_at: float = 0.34) -> BoltRule:
var b := BoltRule.new()
@@ -240,6 +461,28 @@ static func card() -> PanelContainer:
return p
## The one loud button on a screen — the thing the player came here to press.
##
## Papaya-filled at rest instead of ink-filled, so it is the first thing the eye
## lands on, and correspondingly ink-lettered. Hover goes hotter and press still
## flashes volt, so the state ladder is unchanged; only the resting colour moves.
static func primary_button(text: String, size: int = 44) -> Button:
var b := Button.new()
b.text = text
b.add_theme_font_size_override("font_size", size)
b.add_theme_stylebox_override("normal", box(PAPAYA, INK, 12, 4))
b.add_theme_stylebox_override("hover", box(PAPAYA_HOT, VOLT, 12, 4))
b.add_theme_stylebox_override("pressed", box(VOLT, INK, 12, 4, false))
b.add_theme_stylebox_override("focus", box(PAPAYA, CYAN, 12, 4))
b.add_theme_color_override("font_color", ink_for(PAPAYA))
b.add_theme_color_override("font_hover_color", ink_for(PAPAYA_HOT))
b.add_theme_color_override("font_pressed_color", ink_for(VOLT))
b.add_theme_color_override("font_focus_color", ink_for(PAPAYA))
b.add_theme_color_override("font_outline_color", Color(INK.r, INK.g, INK.b, 0.55))
b.add_theme_constant_override("outline_size", 4)
return b
## Sounds and the hover kick for every button under `root` (recursive).
##
## The kick is a 4% scale-up on hover and a snap back on exit. It is small on
+186
View File
@@ -0,0 +1,186 @@
extends Control
class_name VitalBar
## A health or shield bar in the game's own hand: sheared, ink-edged, segmented,
## with a drain ghost behind the fill.
##
## It replaces a stock ProgressBar with a flat colour override, and each of the
## three things it adds answers a question the ProgressBar could not.
##
## SEGMENTS — how much is left, without reading a number. A continuous bar has to
## be measured against its own ends; a bar cut into blocks of 25 can be COUNTED,
## and counting is faster than estimating and survives being glimpsed in
## peripheral vision during a firefight. This is the same reason HoYoverse's
## action UIs chunk their meters rather than drawing one smooth sweep.
##
## THE DRAIN GHOST — how much was just lost. The fill snaps to the new value
## immediately, because the player must never be told they have more health than
## they do; a paler ghost holds the old value for a beat and then catches up. The
## gap between them is the size of the hit, which is information that does not
## exist anywhere on a bar that simply gets shorter.
##
## THE SHEAR — because everything else in this UI leans. A square meter under
## leaning chips reads as a widget from a different game.
##
## The numeral deliberately lives OUTSIDE this control, beside the bar rather
## than centred on it. Text over a two-tone bar cannot be given a colour that
## beats both the fill and the trough, which is what debug/ui_contrast_check.gd
## measured at 2.4:1 on the old HUD. Moving it off the fill fixes that at the
## source instead of relying on an outline to rescue it.
var value: float = 100.0:
set(v):
var c := clampf(v, 0.0, max_value)
if c < value - 0.01:
# Lost some: the ghost stays where it was and the flash fires.
_flash = 1.0
_lag_hold = LAG_HOLD
elif c > value + 0.01:
# Gained some: the ghost has nothing to show, so bring it along.
_lag = c
value = c
queue_redraw()
var max_value: float = 100.0:
set(v):
max_value = maxf(v, 1.0)
_lag = minf(_lag, max_value)
queue_redraw()
## The charged colour of the fill. Health is papaya, shield is cyan.
var fill_color: Color = UITheme.PAPAYA:
set(v):
fill_color = v
queue_redraw()
## One block per this many points. 25 gives a 100-point bar four blocks, which is
## the most the eye can count without moving.
var per_segment: float = 25.0
## Below this fraction the bar breathes, so low health is felt rather than read.
const LOW_AT := 0.3
const LOW_RATE := 5.5
## How long the ghost holds the old value before draining, and how fast it goes.
const LAG_HOLD := 0.35
const LAG_RATE := 55.0
const SHEAR := 6.0
const INK_W := 3.0
const GAP := 3.0
var _lag: float = 100.0
var _lag_hold: float = 0.0
var _flash: float = 0.0
var _pulse: float = 0.0
func _ready() -> void:
mouse_filter = Control.MOUSE_FILTER_IGNORE
custom_minimum_size = Vector2(230, 22)
_lag = value
func _process(delta: float) -> void:
var dirty := false
if _lag > value:
if _lag_hold > 0.0:
_lag_hold -= delta
else:
_lag = maxf(_lag - LAG_RATE * delta, value)
dirty = true
elif _lag < value:
_lag = value
dirty = true
if _flash > 0.0:
_flash = maxf(_flash - delta * 4.5, 0.0)
dirty = true
if value / max_value <= LOW_AT and value > 0.0:
_pulse += delta * LOW_RATE
dirty = true
elif _pulse != 0.0:
_pulse = 0.0
dirty = true
if dirty:
queue_redraw()
func _draw() -> void:
var w := size.x
var h := size.y
if w <= 1.0 or h <= 1.0:
return
# Trough: ink fill with a soft inner edge, so an empty bar is still a shape
# on the screen rather than a hole in it.
_shear_rect(0.0, w, UITheme.INK, h)
_shear_outline(0.0, w, UITheme.INK_SOFT, h)
var frac: float = clampf(value / max_value, 0.0, 1.0)
var lag_frac: float = clampf(_lag / max_value, 0.0, 1.0)
# Ghost first, so the live fill draws over its left end and only the
# difference between the two is visible.
if lag_frac > frac:
var ghost := Color(fill_color.r, fill_color.g, fill_color.b, 0.38)
_shear_rect(0.0, w * lag_frac, ghost, h)
if frac > 0.0:
var col := fill_color
# Low health breathes toward volt. It never goes fully volt: that colour
# means "input landed" everywhere else in the UI and spending it on a
# steady state would blunt it.
if frac <= LOW_AT:
var b: float = 0.5 + 0.5 * sin(_pulse)
col = col.lerp(UITheme.VOLT, 0.35 * b)
# The instant of damage whites the bar out briefly — the cheapest way to
# make a hit register before the number has been read.
if _flash > 0.0:
col = col.lerp(UITheme.PAPER, _flash * 0.7)
_segments(w, h, frac, col)
# Ink edge last so it sits on top of both fills and reads as a drawn border.
_shear_outline(0.0, w, UITheme.INK, h)
## The fill, cut into countable blocks.
##
## The last block is clipped rather than dropped, so the bar still moves
## continuously as damage lands inside a block — the segments are for reading
## the amount at a glance, not for quantising it.
func _segments(w: float, h: float, frac: float, col: Color) -> void:
var count := maxi(int(round(max_value / per_segment)), 1)
var seg_w := (w - GAP * (count - 1)) / count
var filled := w * frac
for i in count:
var x0 := i * (seg_w + GAP)
if x0 >= filled:
break
var x1: float = minf(x0 + seg_w, filled)
if x1 - x0 < 0.5:
continue
_shear_rect(x0, x1, col, h)
## A parallelogram from x0 to x1 — the bar's lean, matching the theme's chips.
func _shear_rect(x0: float, x1: float, col: Color, h: float) -> void:
draw_colored_polygon(PackedVector2Array([
Vector2(x0 + SHEAR, 0.0),
Vector2(x1 + SHEAR, 0.0),
Vector2(x1, h),
Vector2(x0, h),
]), col)
func _shear_outline(x0: float, x1: float, col: Color, h: float) -> void:
draw_polyline(PackedVector2Array([
Vector2(x0 + SHEAR, 0.0),
Vector2(x1 + SHEAR, 0.0),
Vector2(x1, h),
Vector2(x0, h),
Vector2(x0 + SHEAR, 0.0),
]), col, INK_W)
+1
View File
@@ -0,0 +1 @@
uid://1syuhwlflh2p
+13 -3
View File
@@ -3,7 +3,17 @@ class_name DoubleBarrelShotgun
@export var reload_time: float = 1.0
var shells: int = 2
## 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
@@ -59,7 +69,7 @@ func _process(delta: float) -> void:
if reloading:
reload_timer -= delta
if reload_timer <= 0.0:
shells = 2
shells = max_shells
reloading = false
# Big single-shot shove that springs back.
@@ -77,7 +87,7 @@ func _input(event: InputEvent) -> void:
_try_fire()
if event.is_action_pressed("reload"):
if shells < 2 and not reloading:
if shells < max_shells and not reloading:
_start_reload()
func _start_reload() -> void:
+292
View File
@@ -0,0 +1,292 @@
extends Object
class_name WeaponHoldProfiles
## How each weapon in the set is HELD — one archetype per class of weapon,
## rather than one rifle hold for all twelve.
##
## ── The problem this exists to fix ───────────────────────────────────────────
##
## `ShooterPoseModifier._apply_rifle_hold` did exactly what its name says, to
## everything. A knife, a rocket launcher and an AK were all solved as a rifle:
## stock in the shoulder pocket, support hand out along the barrel, muzzle on the
## aim line. In third person every character therefore stood in the same pose
## regardless of what they were carrying, and the ONLY thing distinguishing a
## sniper from a shotgun was the ~30 cm of gun mesh in their hands — which at the
## distance an enemy is usually seen is nothing.
##
## That matters beyond looking wrong. In a shooter the reason a character's pose
## is readable at range is that it is the fastest available answer to "what is
## about to happen to me". A shouldered tube means take cover; a blade held low
## means they have to close the distance; a rifle at low ready means they have not
## seen you yet. A single hold throws all of that away.
##
## It is also the specific thing HoYoverse's team say they chase in Zenless Zone
## Zero: characters read by SILHOUETTE first, and their designers deliberately
## refuse to settle on one construction method because a single method limits how
## distinguishable the results can be. The same argument applies one level down,
## to how a character holds a thing.
##
## ── What a profile controls ─────────────────────────────────────────────────
##
## A style is not a bundle of slider values. Three of the differences below
## cannot be expressed as a number on the existing rifle solve at all, and those
## are the ones that make the silhouette:
##
## support where the off hand goes, and how it is ORIENTED there —
## wrapped round a handguard, cupped under a pistol grip, hooked
## under a tube, or released entirely so the animation owns it
## mount whether the weapon's rear sits IN the shoulder pocket, ON TOP
## of the shoulder, or nowhere near it
## head whether the head comes down to the stock (a cheek weld) or
## leans away to clear a tube
##
## Everything else — the pocket offsets, the muzzle pitch at low ready, the elbow
## poles, the finger curls — is an ordinary knob, and the profile just supplies a
## better DEFAULT for that weapon than one global constant could.
##
## ── Layering ────────────────────────────────────────────────────────────────
##
## This is a defaults layer, underneath everything an artist has tuned:
##
## code constants the rifle solve's own fallbacks
## THIS FILE per weapon: what kind of thing it is
## weapon_holds.json defaults -> skins.<skin>._all -> skins.<skin>.<weapon>
##
## So a character with tuning saved from the rig lab is completely unaffected —
## aria's hand-tuned AK-47 hold still wins on every knob it sets — and a weapon
## nobody has tuned stops being held like an AK.
# ── The styles ───────────────────────────────────────────────────────────────
const RIFLE := "rifle"
const SMG := "smg"
const SNIPER := "sniper"
const SHOTGUN := "shotgun"
const LAUNCHER := "launcher"
const PISTOL := "pistol"
const BLADE := "blade"
# ── Where the support hand goes, and how it is turned ────────────────────────
## Wrapped round a handguard, fingers closing ACROSS the barrel. The rifle case.
const SUPPORT_BARREL := "barrel"
## Cupped under and around the firing fist. Two hands together, no shoulder
## contact — a pistol, or any weapon light enough to be held out in front.
const SUPPORT_CUPPED := "cupped"
## Hooked UNDER a tube from below, palm up, well forward of the shoulder. The
## launcher case, where there is no handguard to wrap and the weight is carried
## rather than aimed.
const SUPPORT_TUBE := "tube"
## No support hand at all. The animation keeps the arm, which is what a character
## carrying a blade should look like — the off hand swings with the run cycle.
const SUPPORT_FREE := "free"
## Style -> the structural rules for it.
##
## `pocket_hip` / `pocket_ads` are where the weapon's REAR sits, relative to the
## right shoulder joint, in skeleton space (x across, y up, z forward). They are
## the single most important number here: it is what decides whether a weapon
## reads as shouldered, carried, or held out.
##
## `cheek` is how far the head comes down and across to meet the stock, 0..1,
## applied only as the character shoulders the weapon. Negative leans the head
## AWAY, which is what a tube over the shoulder requires.
##
## `pitch_hip` is the muzzle's droop at low ready, in radians. A launcher's tube
## rides nose-UP because that is how you carry something you do not want pointed
## at your own feet; a blade points forward and in.
const STYLES := {
RIFLE: {
"support": SUPPORT_BARREL,
"pocket_hip": Vector3(0.03, -0.07, 0.06),
"pocket_ads": Vector3(0.05, 0.01, 0.07),
"pitch_hip": 0.16,
"cheek": 0.18,
"pole_r_hip": Vector3(-0.55, -0.85, -0.20),
"pole_l_hip": Vector3(0.45, -0.90, -0.10),
"curl_wrap": 1.0,
"curl_trigger": 1.0,
},
# Compact: the whole weapon is carried closer in, the support hand cannot go
# far because there is not much gun in front of the grip, and the firing elbow
# tucks rather than flares. This is what stops an MP7 from being posed as a
# short rifle with the support arm reaching for a handguard that ended.
SMG: {
"support": SUPPORT_BARREL,
"pocket_hip": Vector3(0.04, -0.10, 0.03),
"pocket_ads": Vector3(0.05, 0.00, 0.05),
"pitch_hip": 0.24,
"cheek": 0.10,
"pole_r_hip": Vector3(-0.40, -0.95, -0.15),
"pole_l_hip": Vector3(0.35, -0.95, -0.05),
"gun_fore": 0.17,
"curl_wrap": 1.05,
"curl_trigger": 1.0,
},
# The cheek weld IS the sniper silhouette. The head comes down onto the stock,
# the pocket sits high and tight so the optic lands at eye height, and the
# support hand goes far out because a long barrel gives it somewhere to go.
SNIPER: {
"support": SUPPORT_BARREL,
# Rides HIGH. A scoped rifle is held so the optic meets the eye, not so
# the butt meets the pocket, and raising the whole weapon is what makes
# that read — pushing the support hand further out does NOT, because the
# reach solver slides it back down the handguard until the arm can get
# there, so a longer `gun_fore` on these stylised arms lands the off hand
# in exactly the same place as a rifle's. Measured: 0.388 vs 0.387 m.
"pocket_hip": Vector3(0.015, 0.005, 0.045),
"pocket_ads": Vector3(0.040, 0.075, 0.055),
"pitch_hip": 0.12,
"cheek": 0.85,
"pole_r_hip": Vector3(-0.72, -0.55, -0.22),
"pole_l_hip": Vector3(0.22, -1.05, -0.02),
"gun_fore": 0.34,
"curl_wrap": 1.0,
"curl_trigger": 0.85,
},
# Held lower and squarer than a rifle, with the support hand back on the
# forend rather than out at the muzzle, and a hard wrap — a shotgun is gripped,
# not balanced.
SHOTGUN: {
"support": SUPPORT_BARREL,
# Carried low and FORWARD, the opposite of the sniper's high tuck. A
# shotgun is pointed rather than aimed and the elbows go wide, because
# the recoil comes back through them.
#
# Low and INBOARD does not work, however tempting the symmetry: +x is
# toward the character's centreline, so dropping the pocket and pushing
# it across at the same time swings the barrel through the chest. It is
# visible immediately in debug/hold_capture.gd and in no assertion —
# the hands were still exactly where they had been asked to go.
"pocket_hip": Vector3(0.030, -0.130, 0.080),
"pocket_ads": Vector3(0.045, -0.040, 0.075),
"pitch_hip": 0.30,
"cheek": 0.22,
"pole_r_hip": Vector3(-0.62, -0.80, -0.22),
"pole_l_hip": Vector3(0.58, -0.72, -0.16),
"gun_fore": 0.22,
"curl_wrap": 1.2,
"curl_trigger": 1.0,
},
# The tube goes ON the shoulder, not into it, and the head leans AWAY to clear
# it. The support hand hooks under from below, well forward, because there is
# nothing to wrap and the job of that arm is to carry weight.
LAUNCHER: {
"support": SUPPORT_TUBE,
"pocket_hip": Vector3(0.055, 0.06, 0.01),
"pocket_ads": Vector3(0.065, 0.105, 0.03),
"pitch_hip": -0.16,
"cheek": -0.30,
"pole_r_hip": Vector3(-0.85, -0.45, -0.10),
"pole_l_hip": Vector3(0.25, -0.85, 0.10),
"gun_stock": 0.30,
"gun_fore": 0.30,
"curl_wrap": 1.0,
"curl_trigger": 0.9,
},
# No shoulder contact at all. The pocket is pushed forward and outboard of the
# joint, which puts both arms out in front of the chest, and the support hand
# cups the firing fist instead of reaching for a barrel.
PISTOL: {
"support": SUPPORT_CUPPED,
"pocket_hip": Vector3(0.02, -0.16, 0.16),
"pocket_ads": Vector3(0.00, -0.04, 0.30),
"pitch_hip": 0.30,
"cheek": 0.05,
"pole_r_hip": Vector3(-0.45, -0.95, -0.05),
"pole_l_hip": Vector3(0.45, -0.95, -0.05),
"gun_stock": 0.03,
"gun_fore": 0.05,
"weapon_scale": 1.0,
"curl_wrap": 1.15,
"curl_trigger": 0.9,
},
# One hand. The off arm is RELEASED back to the animation, so it swings with
# the run cycle instead of gripping a handguard that does not exist — which is
# most of what makes a knife read as a knife at any distance.
BLADE: {
"support": SUPPORT_FREE,
"pocket_hip": Vector3(0.09, -0.20, 0.10),
"pocket_ads": Vector3(0.05, -0.06, 0.22),
"pitch_hip": 0.55,
"cheek": 0.0,
"pole_r_hip": Vector3(-0.35, -1.00, -0.10),
"pole_l_hip": Vector3(0.45, -0.90, -0.10),
"gun_stock": 0.02,
"gun_fore": 0.0,
"weapon_scale": 1.0,
# A blade is held in a full fist — there is no trigger to keep a finger
# straight along, and an extended index on a knife handle looks like a
# mistake rather than like discipline.
"curl_wrap": 1.25,
"curl_trigger": 1.25,
},
}
## Weapon id (the script's basename) -> style.
##
## Keyed on the basename rather than on the class, so this table does not have to
## load twelve weapon scripts to be read, and so a weapon that has not been
## written yet can be listed here the moment its file exists.
const WEAPON_STYLES := {
"ak47": RIFLE,
"m4": RIFLE,
"plasma_gun": RIFLE,
"mp7": SMG,
"nail_gun": SMG,
"dmr": SNIPER,
"awp": SNIPER,
"double_barrel_shotgun": SHOTGUN,
"rocket_launcher": LAUNCHER,
"rocket_swarm": LAUNCHER,
"mortar": LAUNCHER,
"knife": BLADE,
}
## What an unlisted weapon is held as. A rifle is the safe assumption: it is the
## only style that reaches for a handguard, and a weapon with no handguard held
## as a rifle looks odd, where a rifle held as anything else looks broken.
const FALLBACK := RIFLE
## The style name for a weapon id or script path.
static func style_for(weapon: String) -> String:
var id := _id_of(weapon)
return WEAPON_STYLES.get(id, FALLBACK)
## The default knob table for a weapon — the style's values, ready to be merged
## under whatever the JSON tuning says.
##
## Returns a COPY, because the caller merges the artist's values into it and a
## shared dictionary would accumulate one character's tuning into every other's.
static func knobs_for(weapon: String) -> Dictionary:
var style: Dictionary = STYLES.get(style_for(weapon), STYLES[FALLBACK])
var out := style.duplicate(true)
# `support` and `cheek` are structural, not knobs — they are read straight
# off the style by the pose layer and must not end up in the tuning table,
# where the rig lab would offer sliders for them.
out.erase("support")
out.erase("cheek")
return out
## How the off hand is used for this weapon. See the SUPPORT_* constants.
static func support_for(weapon: String) -> String:
var style: Dictionary = STYLES.get(style_for(weapon), STYLES[FALLBACK])
return style.get("support", SUPPORT_BARREL)
## How far the head comes to the stock, 0..1, negative to lean away.
static func cheek_for(weapon: String) -> float:
var style: Dictionary = STYLES.get(style_for(weapon), STYLES[FALLBACK])
return float(style.get("cheek", 0.0))
## A weapon id from either an id or a `res://weapons/<id>.gd` path.
static func _id_of(weapon: String) -> String:
if weapon.ends_with(".gd"):
return weapon.get_file().get_basename()
return weapon
+1
View File
@@ -0,0 +1 @@
uid://bhfyjgkpsvb2x
+54 -2
View File
@@ -16,11 +16,26 @@ func _ready() -> void:
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
@@ -200,12 +215,49 @@ func _build_loadout() -> void:
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: