Compare commits
9
Commits
742d68e318
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b0b99668ab | ||
|
|
61669627db | ||
|
|
2efc21b18d | ||
|
|
986179854d | ||
|
|
f1a4f7df52 | ||
|
|
a13ae50f95 | ||
|
|
c4bcbc7fd1 | ||
|
|
414026f001 | ||
|
|
f0b0d19847 |
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 |
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://bab4y7ba0yl87
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
uid://8p4yow40ni8p
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
uid://coh341vhya5x5
|
||||
@@ -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():
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://tds0trjfr2as
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://cxxajtpm3o8bj
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://cjpesfuv0vmcs
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://c25wpt58v328r
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
uid://c340r7urelhhx
|
||||
@@ -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()
|
||||
@@ -0,0 +1 @@
|
||||
uid://esvuwcyncpeb
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://c2fen0sy3bw87
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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}
|
||||
@@ -0,0 +1 @@
|
||||
uid://bnw4gima0hkli
|
||||
+7
-307
@@ -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
@@ -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")
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
uid://78mssihccx1s
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://f7600k001iqr
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
uid://b8k2ha37f47ji
|
||||
+205
-21
@@ -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,23 +78,39 @@ 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,
|
||||
@@ -86,7 +118,14 @@ func _init_player_stats(id: int, username: String) -> void:
|
||||
"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,14 +320,49 @@ 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:
|
||||
@@ -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))
|
||||
|
||||
@@ -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,10 +441,12 @@ 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))
|
||||
@@ -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,27 +1100,11 @@ 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:
|
||||
@@ -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)
|
||||
add_child(match_hud_scene.instantiate())
|
||||
|
||||
# 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)
|
||||
_hud = PlayerHUD.new()
|
||||
_hud.name = "PlayerHUD"
|
||||
_hud.player = self
|
||||
add_child(_hud)
|
||||
|
||||
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
|
||||
# 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)
|
||||
|
||||
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)
|
||||
# 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)
|
||||
|
||||
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)
|
||||
|
||||
func die(impulse: Vector3 = Vector3.ZERO) -> void:
|
||||
if is_dead: return
|
||||
|
||||
+15
-293
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://48ja2bu8k6xm
|
||||
+175
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://ck57a8waor1d1
|
||||
@@ -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))
|
||||
@@ -0,0 +1 @@
|
||||
uid://dnxnbswrl04u8
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
uid://2bfw74vxcitf
|
||||
+669
-532
File diff suppressed because it is too large
Load Diff
+87
-39
@@ -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"):
|
||||
@@ -126,7 +170,11 @@ 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)
|
||||
var p_score = p_data.get("score", 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
|
||||
if pid == my_id:
|
||||
_local_kills_label.text = str(p_kills) + " Kills"
|
||||
_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)))
|
||||
|
||||
# Build Scoreboard Row
|
||||
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)
|
||||
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)
|
||||
|
||||
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]
|
||||
# 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()
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
uid://ygajwj3bek8k
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
uid://hjsfcdnutb4t
|
||||
+28
-6
@@ -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
|
||||
var mid := size * 0.5
|
||||
|
||||
# Draw background arc (darker)
|
||||
draw_arc(Vector2.ZERO, radius, 0, PI * 2.0, 32, Color(0, 0, 0, 0.4), thickness, true)
|
||||
# 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)
|
||||
|
||||
# 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)
|
||||
# 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
@@ -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
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://1syuhwlflh2p
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
uid://bhfyjgkpsvb2x
|
||||
@@ -17,10 +17,25 @@ func _ready() -> void:
|
||||
|
||||
_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,6 +215,16 @@ 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"])
|
||||
@@ -208,6 +233,33 @@ func _build_loadout() -> void:
|
||||
|
||||
_equip_slot(1)
|
||||
|
||||
|
||||
## Whether the current match issues weapons instead of letting players pick.
|
||||
func _ladder_mode() -> bool:
|
||||
var nm = get_node_or_null("/root/NetworkManager")
|
||||
return nm != null and nm.current_gamemode == GameMode.GUN_GAME
|
||||
|
||||
|
||||
## Replace everything in hand with one issued weapon. Gun Game's promotion.
|
||||
func equip_ladder_weapon(weapon_id: String) -> void:
|
||||
if weapon_id == "":
|
||||
return
|
||||
for w in weapons.values():
|
||||
if is_instance_valid(w):
|
||||
w.queue_free()
|
||||
weapons.clear()
|
||||
_spawn_weapon(1, weapon_id)
|
||||
if player:
|
||||
# Remote peers build their view of this player from the synced loadout,
|
||||
# so a promotion has to move that too or everyone else keeps seeing the
|
||||
# gun from the previous rung in their hands.
|
||||
player.synced_loadout_p1 = weapon_id
|
||||
player.synced_loadout_p2 = ""
|
||||
player.synced_loadout_sp = ""
|
||||
player.synced_loadout_melee = ""
|
||||
player.synced_loadout_ready = true
|
||||
_equip_slot(1)
|
||||
|
||||
func _build_remote_loadout(p1: String, p2: String, sp: String, melee: String) -> void:
|
||||
for w in weapons.values():
|
||||
if is_instance_valid(w):
|
||||
|
||||
Reference in New Issue
Block a user