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()`.
|
## the same sidecar; drives the per-class cel look and answers `surfaces_of()`.
|
||||||
var _surfaces: SkinSurfaces = null
|
var _surfaces: SkinSurfaces = null
|
||||||
var _spring_mod: SpringBones
|
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
|
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
|
## 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.
|
## whoever spawns the model; falls back to the GLB's basename.
|
||||||
var skin_id: String = ""
|
var skin_id: String = ""
|
||||||
@@ -239,8 +244,17 @@ func load_model(path: String) -> void:
|
|||||||
_pose_mod.fingers = _rig_info.get("fingers", {})
|
_pose_mod.fingers = _rig_info.get("fingers", {})
|
||||||
_pose_mod.name = "ShooterPose"
|
_pose_mod.name = "ShooterPose"
|
||||||
skeleton.add_child(_pose_mod)
|
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 —
|
# 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():
|
if not _rig_info.is_empty():
|
||||||
_spring_mod = SpringBones.new()
|
_spring_mod = SpringBones.new()
|
||||||
_spring_mod.name = "SpringBones"
|
_spring_mod.name = "SpringBones"
|
||||||
@@ -550,6 +564,14 @@ func _set_shadow_mode_recursive(node: Node, mode: int) -> void:
|
|||||||
var _prev_state: String = ""
|
var _prev_state: String = ""
|
||||||
var _oneshot_lock: float = 0.0 # seconds left where a one-shot owns playback
|
var _oneshot_lock: float = 0.0 # seconds left where a one-shot owns playback
|
||||||
var _dancing: bool = false
|
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.
|
## 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
|
## Emote toggle (Dance). Shown while grounded and near-idle; any real
|
||||||
## movement breaks it (the controller clears the flag too).
|
## 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
|
_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.
|
## 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)
|
_cur_slide = lerpf(_cur_slide, slide_target, t)
|
||||||
var wall_target := _target_wall if _pose_mod.state == "wall_run" else 0.0
|
var wall_target := _target_wall if _pose_mod.state == "wall_run" else 0.0
|
||||||
_cur_wall = lerpf(_cur_wall, wall_target, lean_t)
|
_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)
|
_update_travel(delta, drive)
|
||||||
_pose_mod.strafe = _cur_strafe
|
_pose_mod.strafe = _cur_strafe
|
||||||
_pose_mod.fwd = _cur_fwd
|
_pose_mod.fwd = _cur_fwd
|
||||||
@@ -782,7 +818,12 @@ func _process(delta: float) -> void:
|
|||||||
var hold_l := 0.0
|
var hold_l := 0.0
|
||||||
if is_holding_weapon and not clip_owns_arms:
|
if is_holding_weapon and not clip_owns_arms:
|
||||||
hold_r = 1.0
|
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:
|
match st:
|
||||||
"slide":
|
"slide":
|
||||||
hold_l = 0.0 # trailing arm braces the ground
|
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 != ""
|
is_holding_weapon = script_path != ""
|
||||||
if script_path == "" or not skeleton:
|
if script_path == "" or not skeleton:
|
||||||
return
|
return
|
||||||
# Per-character, per-weapon hold overrides, if any have been tuned. Empty is
|
# How this KIND of weapon is held, then the per-character tuning on top.
|
||||||
# the normal case and means "use what the code derives".
|
#
|
||||||
|
# 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 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():
|
if hold_tune.is_empty():
|
||||||
hold_tune = WeaponHoldTuning.resolve(WeaponHoldTuning.load_all(), sid,
|
hold_tune = WeaponHoldProfiles.knobs_for(weapon_id)
|
||||||
script_path.get_file().get_basename())
|
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
|
# 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
|
# 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.
|
# unless the lab has pushed a live set in.
|
||||||
@@ -1297,6 +1353,16 @@ class ShooterPoseModifier extends SkeletonModifier3D:
|
|||||||
var gun_stock: float = 0.20
|
var gun_stock: float = 0.20
|
||||||
# 0..1 through a reload — drives the support hand to the mag well and back.
|
# 0..1 through a reload — drives the support hand to the mag well and back.
|
||||||
var reload_phase: float = 0.0
|
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.
|
# [child_bone, helper_bone] pairs; see SkinJointHelper.
|
||||||
var joint_helpers: Array = []
|
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 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 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 _idx: Dictionary = {}
|
||||||
var _resolved := false
|
var _resolved := false
|
||||||
@@ -1395,33 +1463,13 @@ class ShooterPoseModifier extends SkeletonModifier3D:
|
|||||||
|
|
||||||
func _resolve() -> void:
|
func _resolve() -> void:
|
||||||
var skel := get_skeleton()
|
var skel := get_skeleton()
|
||||||
var names := SPINE + ["DEF-neck", "DEF-head",
|
# The canonical names are the LIBRARY skeleton's, and a model that kept
|
||||||
"DEF-upper_arm.R", "DEF-forearm.R", "DEF-hand.R",
|
# its own rig names things differently — Taila's hips are DEF-spine, her
|
||||||
"DEF-upper_arm.L", "DEF-forearm.L", "DEF-hand.L",
|
# head is DEF-spine.006, and she has no bone with "neck" in its name at
|
||||||
"DEF-thigh.R", "DEF-shin.R", "DEF-thigh.L", "DEF-shin.L"]
|
# all. Unresolved, every lean, aim pitch and slide head-lift below
|
||||||
# The names above are the LIBRARY skeleton's. A model that kept its own
|
# silently did nothing. RigRoles maps them through the sidecar; the dance
|
||||||
# rig names things differently and three of them simply do not exist on
|
# layer uses the same call rather than a second copy of it.
|
||||||
# it — Taila's hips are DEF-spine, her head is DEF-spine.006, and she has
|
_idx = RigRoles.resolve(skel, roles)
|
||||||
# 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
|
|
||||||
_resolve_hands(skel)
|
_resolve_hands(skel)
|
||||||
_resolved = true
|
_resolved = true
|
||||||
|
|
||||||
@@ -1514,6 +1562,8 @@ class ShooterPoseModifier extends SkeletonModifier3D:
|
|||||||
_apply_grapple(skel)
|
_apply_grapple(skel)
|
||||||
if _hold_r > 0.01 or _hold_l > 0.01:
|
if _hold_r > 0.01 or _hold_l > 0.01:
|
||||||
_apply_rifle_hold(skel)
|
_apply_rifle_hold(skel)
|
||||||
|
if absf(cheek) > 0.01:
|
||||||
|
_apply_cheek(skel)
|
||||||
_close_hands(skel)
|
_close_hands(skel)
|
||||||
if recoil > 0.01:
|
if recoil > 0.01:
|
||||||
_apply_recoil(skel)
|
_apply_recoil(skel)
|
||||||
@@ -1763,9 +1813,22 @@ class ShooterPoseModifier extends SkeletonModifier3D:
|
|||||||
dbg_fore = fore_pos
|
dbg_fore = fore_pos
|
||||||
dbg_stock = stock_pos
|
dbg_stock = stock_pos
|
||||||
|
|
||||||
# 3. Support hand goes to the mag well during a reload (under the
|
# 3. Where the SUPPORT hand goes, which is most of what tells a viewer
|
||||||
# receiver — the correct side), otherwise to the handguard.
|
# 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
|
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:
|
if reload_phase > 0.001:
|
||||||
var mag_well := grip_pos + aim_dir * (gun_fore * 0.35) - gun_up * 0.10
|
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
|
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"):
|
and _hand_frame.has("L"):
|
||||||
var hand_l: int = _idx.get("DEF-hand.L", -1)
|
var hand_l: int = _idx.get("DEF-hand.L", -1)
|
||||||
if hand_l >= 0:
|
if hand_l >= 0:
|
||||||
# -aim_dir so the hand comes at the handguard from the body side
|
# The hand's target frame, as (along, palm, curl) — the same
|
||||||
# rather than reaching over it backwards.
|
# three axes `_hand_frame` measured off the rest pose.
|
||||||
var want := Basis(gun_up.cross(-aim_dir).normalized(), gun_up,
|
#
|
||||||
-aim_dir)
|
# 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
|
# Then the artist's wrist. Rolling about the barrel is the one
|
||||||
# axis a hand wrapping a cylinder is genuinely free in, and it
|
# 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
|
# 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()
|
* 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)
|
_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.
|
# How far each segment of a finger closes, knuckle -> tip, in radians.
|
||||||
#
|
#
|
||||||
# Two different grips. The SUPPORT hand wraps a handguard, so all four
|
# Two different grips. The SUPPORT hand wraps a handguard, so all four
|
||||||
@@ -1904,8 +2025,11 @@ class ShooterPoseModifier extends SkeletonModifier3D:
|
|||||||
PackedInt32Array())
|
PackedInt32Array())
|
||||||
if bones.is_empty():
|
if bones.is_empty():
|
||||||
continue
|
continue
|
||||||
# The right index rides the trigger; everything else wraps.
|
# The right index rides the trigger; everything else wraps. A blade
|
||||||
var trigger: bool = side == "R" and digit == "index"
|
# 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 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)
|
var scale: float = _t("curl_trigger", 1.0) if trigger else _t("curl_wrap", 1.0)
|
||||||
for i in bones.size():
|
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
|
## 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.
|
## 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)
|
return TuningStore.default_for(all_knobs(), key)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ func _process(_delta: float) -> bool:
|
|||||||
sm.set_active_skin(_skin)
|
sm.set_active_skin(_skin)
|
||||||
var nm = root.get_node_or_null("NetworkManager")
|
var nm = root.get_node_or_null("NetworkManager")
|
||||||
if nm and nm.has_method("start_singleplayer_match"):
|
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")
|
change_scene_to_file("res://scenes/maps/test_level/test_level.tscn")
|
||||||
return false
|
return false
|
||||||
if _frames < 160:
|
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:
|
if _frames == 40:
|
||||||
var nm = root.get_node_or_null("NetworkManager")
|
var nm = root.get_node_or_null("NetworkManager")
|
||||||
if nm and nm.has_method("start_singleplayer_match"):
|
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")
|
change_scene_to_file("res://scenes/maps/test_level/test_level.tscn")
|
||||||
elif _frames >= 160:
|
elif _frames >= 160:
|
||||||
for p in root.find_children("*", "CharacterBody3D", true, false):
|
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")
|
sm.set_active_skin("taila")
|
||||||
var nm = root.get_node_or_null("NetworkManager")
|
var nm = root.get_node_or_null("NetworkManager")
|
||||||
if nm and nm.has_method("start_singleplayer_match"):
|
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")
|
change_scene_to_file("res://scenes/maps/test_level/test_level.tscn")
|
||||||
elif _frames == 160:
|
elif _frames == 160:
|
||||||
for p in root.find_children("*", "CharacterBody3D", true, false):
|
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 key := _knob_for(_drag_marker)
|
||||||
var spec := _spec_for("hold", key)
|
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):
|
if not (cur is Vector3):
|
||||||
cur = Vector3.ZERO
|
cur = Vector3.ZERO
|
||||||
var lo: float = spec[2]
|
var lo: float = spec[2]
|
||||||
@@ -490,9 +491,15 @@ func _hold_pose() -> String:
|
|||||||
return WeaponHoldTuning.pose_for_ads(POSES[_pose][3])
|
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):
|
func _default_for(group: String, key: String):
|
||||||
return RigAnchors.default_for(key) if group == "anchors" \
|
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
|
## 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]
|
_out_dir = args[0]
|
||||||
var nm = root.get_node_or_null("NetworkManager")
|
var nm = root.get_node_or_null("NetworkManager")
|
||||||
if nm and nm.has_method("start_singleplayer_match"):
|
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")
|
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")
|
var nm = root.get_node_or_null("/root/NetworkManager")
|
||||||
if not _check(nm != null, "NetworkManager autoload exists"):
|
if not _check(nm != null, "NetworkManager autoload exists"):
|
||||||
return
|
return
|
||||||
nm.start_singleplayer_match("Deathmatch")
|
nm.start_singleplayer_match(GameMode.DEATHMATCH)
|
||||||
change_scene_to_file("res://scenes/maps/test_level/test_level.tscn")
|
change_scene_to_file("res://scenes/maps/test_level/test_level.tscn")
|
||||||
|
|
||||||
# Let the level build and the player spawn + settle.
|
# 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.
|
## Builds a full test environment from code — parkour geometry, lighting, player, HUD.
|
||||||
## Attach to TestLevel root, press F5.
|
## 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 _player: CharacterBody3D
|
||||||
var _debug_ui_panel: PanelContainer
|
|
||||||
|
|
||||||
|
|
||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
@@ -24,7 +13,6 @@ func _ready() -> void:
|
|||||||
|
|
||||||
_build_materials()
|
_build_materials()
|
||||||
_build_geometry()
|
_build_geometry()
|
||||||
_build_hud()
|
|
||||||
|
|
||||||
# Multiplayer Spawning
|
# Multiplayer Spawning
|
||||||
var spawner = MultiplayerSpawner.new()
|
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_is_ads")
|
||||||
client_rep_config.add_property(":synced_wall_side")
|
client_rep_config.add_property(":synced_wall_side")
|
||||||
client_rep_config.add_property(":synced_is_dancing")
|
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_grapple_point")
|
||||||
client_rep_config.add_property(":synced_is_grapple_shooting")
|
client_rep_config.add_property(":synced_is_grapple_shooting")
|
||||||
client_rep_config.add_property(":synced_skin_id")
|
client_rep_config.add_property(":synced_skin_id")
|
||||||
@@ -417,298 +407,8 @@ func _spawn_player(pid: int) -> CharacterBody3D:
|
|||||||
return player
|
return player
|
||||||
|
|
||||||
|
|
||||||
# ── HUD ───────────────────────────────────────────────────────────────────────
|
# ── HUD ───────────────────────────────────────────────────────────────
|
||||||
|
#
|
||||||
func _build_hud() -> void:
|
# There is no HUD here any more, and no _process to drive one. See the note in
|
||||||
var canvas := CanvasLayer.new()
|
# scenes/maps/level_runtime.gd: everything that describes A PLAYER belongs to
|
||||||
canvas.name = "UI"
|
# ui/player_hud.gd, which that player spawns for itself.
|
||||||
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"
|
|
||||||
|
|||||||
+1
-1
@@ -35,7 +35,7 @@ func _process(_delta: float) -> bool:
|
|||||||
# rather than a flat colour — that its legibility has to survive.
|
# rather than a flat colour — that its legibility has to survive.
|
||||||
var nm = root.get_node_or_null("NetworkManager")
|
var nm = root.get_node_or_null("NetworkManager")
|
||||||
if nm and nm.has_method("start_singleplayer_match"):
|
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")
|
change_scene_to_file("res://scenes/maps/test_level/test_level.tscn")
|
||||||
200:
|
200:
|
||||||
_open("_show_main_menu")
|
_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")
|
_shot("menu")
|
||||||
var nm = root.get_node_or_null("NetworkManager")
|
var nm = root.get_node_or_null("NetworkManager")
|
||||||
if nm and nm.has_method("start_singleplayer_match"):
|
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)
|
change_scene_to_file(_scene)
|
||||||
elif _frames == 240:
|
elif _frames == 240:
|
||||||
_shot("level")
|
_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
|
||||||
+197
-13
@@ -7,18 +7,34 @@ signal connection_succeeded
|
|||||||
signal match_state_updated
|
signal match_state_updated
|
||||||
signal stats_updated
|
signal stats_updated
|
||||||
signal killfeed_event(victim: String, killer: String, weapon: String, v_color: String, k_color: String)
|
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
|
const DEFAULT_PORT = 31415
|
||||||
|
|
||||||
var connected_players: Array[int] = []
|
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 player_stats: Dictionary = {}
|
||||||
|
|
||||||
var match_active: bool = false
|
var match_active: bool = false
|
||||||
var match_time_remaining: float = 0.0
|
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 = ""
|
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
|
var _ping_timer: Timer
|
||||||
|
|
||||||
@@ -62,23 +78,39 @@ func disconnect_game() -> void:
|
|||||||
match_active = false
|
match_active = false
|
||||||
_ping_timer.stop()
|
_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
|
# Ensure peer is offline if not already connected
|
||||||
if not multiplayer.has_multiplayer_peer() or multiplayer.multiplayer_peer is OfflineMultiplayerPeer:
|
if not multiplayer.has_multiplayer_peer() or multiplayer.multiplayer_peer is OfflineMultiplayerPeer:
|
||||||
multiplayer.multiplayer_peer = OfflineMultiplayerPeer.new()
|
multiplayer.multiplayer_peer = OfflineMultiplayerPeer.new()
|
||||||
|
|
||||||
connected_players.clear()
|
connected_players.clear()
|
||||||
connected_players.append(1)
|
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)
|
_init_player_stats(1, SettingsManager.username)
|
||||||
|
|
||||||
|
last_result = {}
|
||||||
match_active = true
|
match_active = true
|
||||||
match_time_remaining = 300.0
|
# The mode's own clock, not a hardcoded five minutes. Gun Game is a fifteen
|
||||||
current_gamemode = gamemode
|
# 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)
|
rpc("sync_match_state", match_active, match_time_remaining, current_gamemode)
|
||||||
|
|
||||||
func _init_player_stats(id: int, username: String) -> void:
|
func _init_player_stats(id: int, username: String) -> void:
|
||||||
# Keep existing color if player re-joins, or generate new
|
# 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 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] = {
|
player_stats[id] = {
|
||||||
"username": username,
|
"username": username,
|
||||||
@@ -86,7 +118,14 @@ func _init_player_stats(id: int, username: String) -> void:
|
|||||||
"deaths": 0,
|
"deaths": 0,
|
||||||
"assists": 0,
|
"assists": 0,
|
||||||
"ping": 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()
|
stats_updated.emit()
|
||||||
|
|
||||||
@@ -169,21 +208,94 @@ func rpc_load_level(scene_path: String) -> void:
|
|||||||
print("Loading level: ", scene_path)
|
print("Loading level: ", scene_path)
|
||||||
current_scene_path = scene_path
|
current_scene_path = scene_path
|
||||||
if multiplayer.is_server():
|
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_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)
|
rpc("sync_match_state", match_active, match_time_remaining, current_gamemode)
|
||||||
get_tree().change_scene_to_file(scene_path)
|
get_tree().change_scene_to_file(scene_path)
|
||||||
|
|
||||||
func _process(delta: float) -> void:
|
func _process(delta: float) -> void:
|
||||||
if match_active:
|
if not match_active:
|
||||||
|
return
|
||||||
if match_time_remaining > 0:
|
if match_time_remaining > 0:
|
||||||
match_time_remaining -= delta
|
match_time_remaining -= delta
|
||||||
if match_time_remaining <= 0:
|
if match_time_remaining <= 0:
|
||||||
match_time_remaining = 0
|
match_time_remaining = 0
|
||||||
match_active = false
|
# 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
|
# We emit this so HUDs can update their timers smoothly
|
||||||
match_state_updated.emit()
|
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 ---
|
# --- PING SYSTEM ---
|
||||||
func _on_ping_timer() -> void:
|
func _on_ping_timer() -> void:
|
||||||
if multiplayer.is_server():
|
if multiplayer.is_server():
|
||||||
@@ -208,14 +320,49 @@ func receive_ping_response(server_time: int) -> void:
|
|||||||
# --- GAMEPLAY EVENTS ---
|
# --- GAMEPLAY EVENTS ---
|
||||||
@rpc("any_peer", "call_local", "reliable")
|
@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:
|
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 _is_authority():
|
||||||
if killer_id != 0 and killer_id != victim_id:
|
# A kill after the final whistle counts for nothing. Without this a
|
||||||
if player_stats.has(killer_id):
|
# rocket already in the air when the clock hit zero could change the
|
||||||
player_stats[killer_id]["kills"] += 1
|
# 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 victim_id != -1:
|
||||||
if player_stats.has(victim_id):
|
if player_stats.has(victim_id):
|
||||||
player_stats[victim_id]["deaths"] += 1
|
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:
|
for aid in assist_ids:
|
||||||
if player_stats.has(aid) and aid != killer_id and aid != victim_id:
|
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:
|
if killer_id == 0 or killer_id == victim_id:
|
||||||
k_name = "" # Suicide or world death
|
k_name = "" # Suicide or world death
|
||||||
|
|
||||||
|
# `.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)
|
broadcast_killfeed.rpc(v_name, k_name, weapon_name, v_color, k_color)
|
||||||
sync_player_stats.rpc(player_stats)
|
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")
|
@rpc("authority", "call_local", "reliable")
|
||||||
func broadcast_killfeed(victim: String, killer: String, weapon: String, v_color: String = "cccccc", k_color: String = "cccccc") -> void:
|
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)
|
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
|
var is_dead: bool = false
|
||||||
|
|
||||||
# UI
|
# UI
|
||||||
var health_bar: ProgressBar
|
## The first-person HUD — reticle, vitals, ammo, death screen. See ui/player_hud.gd.
|
||||||
var shield_bar: ProgressBar
|
var _hud: PlayerHUD = null
|
||||||
var health_label: Label
|
## Kept because the controller drives them directly: the death screen is toggled
|
||||||
var shield_label: Label
|
## by die()/respawn, and the ring is read by the reload logic.
|
||||||
var death_screen: Control
|
var death_screen: Control
|
||||||
|
|
||||||
var ragdoll_instance: Node3D
|
var ragdoll_instance: Node3D
|
||||||
@@ -64,8 +64,7 @@ var grapple_latch_player: AudioStreamPlayer
|
|||||||
var grapple_swing_player: AudioStreamPlayer
|
var grapple_swing_player: AudioStreamPlayer
|
||||||
|
|
||||||
# UI
|
# UI
|
||||||
var hit_marker: Control
|
## The reload ring, owned by the HUD. Kept here because the reload logic reads it.
|
||||||
var _hit_marker_tween: Tween
|
|
||||||
var reload_ring: Control
|
var reload_ring: Control
|
||||||
|
|
||||||
var grenades: int = 2
|
var grenades: int = 2
|
||||||
@@ -89,6 +88,13 @@ var synced_velocity: Vector3 = Vector3.ZERO
|
|||||||
var synced_is_ads: bool = false
|
var synced_is_ads: bool = false
|
||||||
var synced_wall_side: float = 0.0 # -1 wall left, +1 wall right (wall-run lean)
|
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
|
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)
|
# Anime speed-lines overlay (local player only)
|
||||||
var _speedlines: ColorRect = null
|
var _speedlines: ColorRect = null
|
||||||
@@ -146,7 +152,6 @@ func _ready() -> void:
|
|||||||
if is_multiplayer_authority():
|
if is_multiplayer_authority():
|
||||||
_damage_layer = CanvasLayer.new()
|
_damage_layer = CanvasLayer.new()
|
||||||
add_child(_damage_layer)
|
add_child(_damage_layer)
|
||||||
_setup_hit_marker()
|
|
||||||
_setup_hud()
|
_setup_hud()
|
||||||
_setup_speedlines()
|
_setup_speedlines()
|
||||||
else:
|
else:
|
||||||
@@ -395,32 +400,6 @@ func _setup_speedlines() -> void:
|
|||||||
_damage_layer.add_child(_speedlines)
|
_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:
|
func _setup_grapple() -> void:
|
||||||
grapple_rope = MeshInstance3D.new()
|
grapple_rope = MeshInstance3D.new()
|
||||||
var rope_mesh = CylinderMesh.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()
|
var label = load("res://ui/floating_damage_text.gd").new()
|
||||||
label.text = str(round(amount))
|
label.text = str(round(amount))
|
||||||
label.add_theme_font_size_override("font_size", 24)
|
label.add_theme_font_size_override("font_size", 28)
|
||||||
label.add_theme_color_override("font_color", Color(1.0, 0.6, 0.1))
|
# The theme's papaya and its violet ink, not an approximate orange on pure
|
||||||
label.add_theme_color_override("font_outline_color", Color(0, 0, 0, 0.8))
|
# black — these numbers fly over the same 3D scene the rest of the HUD does.
|
||||||
label.add_theme_constant_override("outline_size", 4)
|
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
|
# 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))
|
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:
|
if now - _last_hit_sound_time > 10:
|
||||||
_last_hit_sound_time = now
|
_last_hit_sound_time = now
|
||||||
hit_player.play()
|
hit_player.play()
|
||||||
|
# The confirmation is part of the reticle now, so it lands where the eye
|
||||||
if _hit_marker_tween and _hit_marker_tween.is_valid():
|
# already is and shares its ink outline.
|
||||||
_hit_marker_tween.kill()
|
if is_instance_valid(_hud):
|
||||||
|
_hud.confirm_hit()
|
||||||
hit_marker.modulate.a = 1.0
|
|
||||||
_hit_marker_tween = create_tween()
|
|
||||||
_hit_marker_tween.tween_property(hit_marker, "modulate:a", 0.0, 0.4)
|
|
||||||
|
|
||||||
func apply_impulse(force: Vector3) -> void:
|
func apply_impulse(force: Vector3) -> void:
|
||||||
velocity += force
|
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:
|
func server_take_damage(amount: float, hit_pos: Vector3, attacker_id: int, weapon_name: String, impulse: Vector3) -> void:
|
||||||
if not multiplayer.is_server(): return
|
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.
|
# 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,
|
# Knockback is applied inside rpc_take_damage on the victim's own peer,
|
||||||
# since that peer simulates this body.
|
# 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"):
|
if Input.is_action_just_pressed("toggle_camera_view"):
|
||||||
set_third_person(not third_person)
|
set_third_person(not third_person)
|
||||||
|
|
||||||
# Dance emote (B): toggles while grounded and idle-ish; any
|
# Emote (B): HOLD to open the radial dial and point at a dance,
|
||||||
# movement/jump/crouch input breaks it.
|
# 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"):
|
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 m := _ensure_machine()
|
||||||
var slow: bool = Vector2(velocity.x, velocity.z).length() < 1.0
|
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
|
synced_is_dancing = true
|
||||||
else:
|
|
||||||
synced_is_dancing = false
|
|
||||||
if synced_is_dancing:
|
if synced_is_dancing:
|
||||||
var m2 := _ensure_machine()
|
var m2 := _ensure_machine()
|
||||||
var moving := raw_input.length() > 0.1 or input_jump or input_crouch or input_dash
|
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"
|
var airborne: bool = m2 and m2.current_state != "ground"
|
||||||
if moving or airborne:
|
if moving or airborne:
|
||||||
synced_is_dancing = false
|
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()
|
var machine := _ensure_machine()
|
||||||
if machine:
|
if machine:
|
||||||
@@ -949,7 +961,7 @@ func _physics_process(_delta: float) -> void:
|
|||||||
if visual.has_method("set_wall_side"):
|
if visual.has_method("set_wall_side"):
|
||||||
visual.set_wall_side(sm.wall_side)
|
visual.set_wall_side(sm.wall_side)
|
||||||
if visual.has_method("set_dancing"):
|
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":
|
if visual.has_method("set_grapple_target") and sm.current_state == "grapple":
|
||||||
visual.set_grapple_target(synced_grapple_point)
|
visual.set_grapple_target(synced_grapple_point)
|
||||||
|
|
||||||
@@ -1034,7 +1046,7 @@ func _process(delta: float) -> void:
|
|||||||
if visual.has_method("set_wall_side"):
|
if visual.has_method("set_wall_side"):
|
||||||
visual.set_wall_side(synced_wall_side)
|
visual.set_wall_side(synced_wall_side)
|
||||||
if visual.has_method("set_dancing"):
|
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":
|
if visual.has_method("set_grapple_target") and synced_movement_state == "grapple":
|
||||||
visual.set_grapple_target(synced_grapple_point)
|
visual.set_grapple_target(synced_grapple_point)
|
||||||
# Upper body follows the owner's synced camera pitch
|
# Upper body follows the owner's synced camera pitch
|
||||||
@@ -1088,27 +1100,11 @@ func _process(delta: float) -> void:
|
|||||||
lvisual.add_gun_recoil()
|
lvisual.add_gun_recoil()
|
||||||
_last_local_ammo = ammo_now
|
_last_local_ammo = ammo_now
|
||||||
|
|
||||||
# Update UI
|
# Vitals are pushed (the controller owns health and shield); ammo, the
|
||||||
if is_instance_valid(health_bar):
|
# reticle bloom and the reload ring are pulled by the HUD from the weapon,
|
||||||
health_bar.value = health
|
# which is the authority on all three.
|
||||||
health_label.text = "%d / %d" % [ceil(health), max_health]
|
if is_instance_valid(_hud):
|
||||||
if is_instance_valid(shield_bar):
|
_hud.set_vitals(health, max_health, shield, max_shield)
|
||||||
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
|
|
||||||
|
|
||||||
if is_multiplayer_authority() and not is_dead:
|
if is_multiplayer_authority() and not is_dead:
|
||||||
if is_holding_grenade and grenades > 0:
|
if is_holding_grenade and grenades > 0:
|
||||||
@@ -1139,112 +1135,36 @@ func _process(delta: float) -> void:
|
|||||||
if shield > max_shield:
|
if shield > max_shield:
|
||||||
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:
|
func _setup_hud() -> void:
|
||||||
var margin = MarginContainer.new()
|
# Match state (timer, score, killfeed, scoreboard) is a separate overlay and
|
||||||
margin.set_anchors_preset(Control.PRESET_BOTTOM_LEFT)
|
# is deliberately not part of the player's own HUD.
|
||||||
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
|
|
||||||
if is_multiplayer_authority():
|
if is_multiplayer_authority():
|
||||||
var match_hud_scene = load("res://ui/match_hud.tscn")
|
var match_hud_scene = load("res://ui/match_hud.tscn")
|
||||||
if match_hud_scene:
|
if match_hud_scene:
|
||||||
var match_hud = match_hud_scene.instantiate()
|
add_child(match_hud_scene.instantiate())
|
||||||
add_child(match_hud)
|
|
||||||
|
|
||||||
# Shield Bar (Top)
|
_hud = PlayerHUD.new()
|
||||||
var shield_box = VBoxContainer.new()
|
_hud.name = "PlayerHUD"
|
||||||
var s_title = Label.new()
|
_hud.player = self
|
||||||
s_title.text = "SHIELD"
|
add_child(_hud)
|
||||||
s_title.add_theme_font_size_override("font_size", 12)
|
|
||||||
s_title.add_theme_color_override("font_color", Color(0.4, 0.7, 1.0))
|
|
||||||
shield_box.add_child(s_title)
|
|
||||||
|
|
||||||
shield_bar = ProgressBar.new()
|
# The emote dial rides on the HUD's canvas, above the viewmodel.
|
||||||
shield_bar.custom_minimum_size = Vector2(200, 20)
|
_emote_wheel = EmoteWheel.new()
|
||||||
shield_bar.max_value = max_shield
|
_emote_wheel.name = "EmoteWheel"
|
||||||
shield_bar.value = shield
|
_hud.add_child(_emote_wheel)
|
||||||
shield_bar.show_percentage = false
|
|
||||||
|
|
||||||
var s_sb = StyleBoxFlat.new()
|
# The controller still owns these two — it toggles the death screen on death
|
||||||
s_sb.bg_color = Color(0.1, 0.4, 0.8)
|
# and the ring is read by the reload logic — so keep the references it had.
|
||||||
shield_bar.add_theme_stylebox_override("fill", s_sb)
|
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:
|
func die(impulse: Vector3 = Vector3.ZERO) -> void:
|
||||||
if is_dead: return
|
if is_dead: return
|
||||||
|
|||||||
+15
-293
@@ -1,18 +1,7 @@
|
|||||||
extends Node3D
|
extends Node3D
|
||||||
class_name LevelRuntime
|
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 _player: CharacterBody3D
|
||||||
var _debug_ui_panel: PanelContainer
|
|
||||||
|
|
||||||
|
|
||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
@@ -24,7 +13,6 @@ func _ready() -> void:
|
|||||||
if not has_node("WorldEnvironment"):
|
if not has_node("WorldEnvironment"):
|
||||||
LevelEnvironment.add_to(self)
|
LevelEnvironment.add_to(self)
|
||||||
|
|
||||||
_build_hud()
|
|
||||||
|
|
||||||
# Multiplayer Spawning
|
# Multiplayer Spawning
|
||||||
var spawner = MultiplayerSpawner.new()
|
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_is_ads")
|
||||||
client_rep_config.add_property(":synced_wall_side")
|
client_rep_config.add_property(":synced_wall_side")
|
||||||
client_rep_config.add_property(":synced_is_dancing")
|
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_grapple_point")
|
||||||
client_rep_config.add_property(":synced_is_grapple_shooting")
|
client_rep_config.add_property(":synced_is_grapple_shooting")
|
||||||
client_rep_config.add_property(":synced_skin_id")
|
client_rep_config.add_property(":synced_skin_id")
|
||||||
@@ -252,284 +242,16 @@ func _spawn_player(pid: int) -> CharacterBody3D:
|
|||||||
return player
|
return player
|
||||||
|
|
||||||
|
|
||||||
# ── HUD ───────────────────────────────────────────────────────────────────────
|
# ── HUD ───────────────────────────────────────────────────────────────
|
||||||
|
#
|
||||||
func _build_hud() -> void:
|
# There is no HUD here any more, and no _process to drive one.
|
||||||
var canvas := CanvasLayer.new()
|
#
|
||||||
canvas.name = "UI"
|
# Reticle, vitals, ammo, ability cooldowns, the chain meter and the debug
|
||||||
add_child(canvas)
|
# 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
|
||||||
# ── Crosshair ─────────────────────────────────────────────────────────
|
# into that player's state machine and weapon manager every frame to fill them
|
||||||
var crosshair := Control.new()
|
# in — which is exactly what this did, from three byte-identical copies across
|
||||||
crosshair.name = "Crosshair"
|
# the three level runtimes. The visible symptom was two ammo panels on screen at
|
||||||
crosshair.set_anchors_preset(Control.PRESET_CENTER)
|
# once, in two different styles, overlapping in the bottom-right corner.
|
||||||
crosshair.custom_minimum_size = Vector2(20, 20)
|
#
|
||||||
canvas.add_child(crosshair)
|
# A level owns the level.
|
||||||
|
|
||||||
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"
|
|
||||||
@@ -1,18 +1,7 @@
|
|||||||
extends Node3D
|
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 _player: CharacterBody3D
|
||||||
var _debug_ui_panel: PanelContainer
|
|
||||||
|
|
||||||
|
|
||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
@@ -29,7 +18,6 @@ func _ready() -> void:
|
|||||||
LevelEnvironment.add_to(self)
|
LevelEnvironment.add_to(self)
|
||||||
LevelMaterials.apply_toon_recursive(self, 0.0)
|
LevelMaterials.apply_toon_recursive(self, 0.0)
|
||||||
|
|
||||||
_build_hud()
|
|
||||||
|
|
||||||
# Multiplayer Spawning
|
# Multiplayer Spawning
|
||||||
var spawner = MultiplayerSpawner.new()
|
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_is_ads")
|
||||||
client_rep_config.add_property(":synced_wall_side")
|
client_rep_config.add_property(":synced_wall_side")
|
||||||
client_rep_config.add_property(":synced_is_dancing")
|
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_grapple_point")
|
||||||
client_rep_config.add_property(":synced_is_grapple_shooting")
|
client_rep_config.add_property(":synced_is_grapple_shooting")
|
||||||
client_rep_config.add_property(":synced_skin_id")
|
client_rep_config.add_property(":synced_skin_id")
|
||||||
@@ -221,297 +211,8 @@ func _spawn_player(pid: int) -> CharacterBody3D:
|
|||||||
return player
|
return player
|
||||||
|
|
||||||
|
|
||||||
# ── HUD ───────────────────────────────────────────────────────────────────────
|
# ── HUD ───────────────────────────────────────────────────────────────
|
||||||
|
#
|
||||||
func _build_hud() -> void:
|
# There is no HUD here any more, and no _process to drive one. See the note in
|
||||||
var canvas := CanvasLayer.new()
|
# scenes/maps/level_runtime.gd: everything that describes A PLAYER belongs to
|
||||||
canvas.name = "UI"
|
# ui/player_hud.gd, which that player spawns for itself.
|
||||||
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"
|
|
||||||
|
|||||||
@@ -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
|
||||||
+700
-563
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_panel: PanelContainer
|
||||||
var _scoreboard_grid: GridContainer
|
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:
|
func _ready() -> void:
|
||||||
_nm = get_node_or_null("/root/NetworkManager")
|
_nm = get_node_or_null("/root/NetworkManager")
|
||||||
# Ensure the comic theme is active even when a level is loaded directly
|
# Ensure the comic theme is active even when a level is loaded directly
|
||||||
@@ -98,12 +127,27 @@ func _ready() -> void:
|
|||||||
|
|
||||||
_populate_scoreboard_headers()
|
_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
|
# Signals
|
||||||
if _nm:
|
if _nm:
|
||||||
_nm.stats_updated.connect(_update_hud)
|
_nm.stats_updated.connect(_update_hud)
|
||||||
_nm.killfeed_event.connect(_on_killfeed_event)
|
_nm.killfeed_event.connect(_on_killfeed_event)
|
||||||
_nm.match_state_updated.connect(_update_timer)
|
_nm.match_state_updated.connect(_update_timer)
|
||||||
|
_nm.match_ended.connect(_on_match_ended)
|
||||||
_update_hud()
|
_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:
|
func _process(_delta: float) -> void:
|
||||||
if Input.is_action_just_pressed("scoreboard"):
|
if Input.is_action_just_pressed("scoreboard"):
|
||||||
@@ -126,7 +170,11 @@ func _populate_scoreboard_headers() -> void:
|
|||||||
for child in _scoreboard_grid.get_children():
|
for child in _scoreboard_grid.get_children():
|
||||||
child.queue_free()
|
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:
|
for h in headers:
|
||||||
var l = Label.new()
|
var l = Label.new()
|
||||||
l.text = h
|
l.text = h
|
||||||
@@ -138,59 +186,59 @@ func _update_hud() -> void:
|
|||||||
if not _nm: return
|
if not _nm: return
|
||||||
|
|
||||||
var my_id = multiplayer.get_unique_id()
|
var my_id = multiplayer.get_unique_id()
|
||||||
|
var mode: String = _nm.current_gamemode
|
||||||
var leader_name = "None"
|
var noun := GameMode.score_noun(mode)
|
||||||
var max_kills = -1
|
|
||||||
|
|
||||||
var stats = _nm.player_stats
|
var stats = _nm.player_stats
|
||||||
|
|
||||||
# Sort players by kills for scoreboard
|
# Ranked by SCORE, which is what the mode counts — not by kills, which in
|
||||||
var players = stats.keys()
|
# Gun Game is the same number by coincidence and in a mode with objectives
|
||||||
players.sort_custom(func(a, b): return stats[a].get("kills", 0) > stats[b].get("kills", 0))
|
# would not be.
|
||||||
|
var players := GameMode.standings(stats)
|
||||||
_populate_scoreboard_headers()
|
_populate_scoreboard_headers()
|
||||||
|
|
||||||
for pid in players:
|
for pid in players:
|
||||||
var p_data = stats[pid]
|
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:
|
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()
|
var c_name = Label.new()
|
||||||
c_name.text = p_data.get("username", "Player")
|
c_name.text = p_data.get("username", "Player")
|
||||||
c_name.add_theme_font_size_override("font_size", 24)
|
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)
|
_scoreboard_grid.add_child(c_name)
|
||||||
|
|
||||||
var c_k = Label.new()
|
for value in [str(p_score), str(p_data.get("kills", 0)),
|
||||||
c_k.text = str(p_kills)
|
str(p_data.get("deaths", 0)), str(p_data.get("assists", 0)),
|
||||||
c_k.add_theme_font_size_override("font_size", 24)
|
"x%d" % int(p_data.get("best_streak", 0)),
|
||||||
_scoreboard_grid.add_child(c_k)
|
"%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()
|
# The top line: who is leading, and how far there is to go. A score limit
|
||||||
c_d.text = str(p_data.get("deaths", 0))
|
# nobody can see is a win condition players cannot play toward.
|
||||||
c_d.add_theme_font_size_override("font_size", 24)
|
var limit := GameMode.score_limit(mode)
|
||||||
_scoreboard_grid.add_child(c_d)
|
if GameMode.is_team_mode(mode):
|
||||||
|
var totals := GameMode.team_scores(mode, stats)
|
||||||
var c_a = Label.new()
|
var parts: PackedStringArray = []
|
||||||
c_a.text = str(p_data.get("assists", 0))
|
for t in totals:
|
||||||
c_a.add_theme_font_size_override("font_size", 24)
|
parts.append("%s %d" % [GameMode.team_name(t), totals[t]])
|
||||||
_scoreboard_grid.add_child(c_a)
|
_leader_label.text = " / ".join(parts) + (" — to %d" % limit if limit > 0 else "")
|
||||||
|
elif players.is_empty():
|
||||||
var c_p = Label.new()
|
_leader_label.text = "Leader: None"
|
||||||
c_p.text = str(p_data.get("ping", 0)) + "ms"
|
else:
|
||||||
c_p.add_theme_font_size_override("font_size", 24)
|
var top = players[0]
|
||||||
_scoreboard_grid.add_child(c_p)
|
_leader_label.text = "Leader: %s (%s%s)" % [
|
||||||
|
stats[top].get("username", "Player " + str(top)),
|
||||||
_leader_label.text = "Leader: %s (%s Kills)" % [leader_name, max_kills]
|
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:
|
func _on_killfeed_event(victim: String, killer: String, weapon: String, v_color: String = "cccccc", k_color: String = "cccccc") -> void:
|
||||||
var l = RichTextLabel.new()
|
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
|
extends Control
|
||||||
class_name ReloadRing
|
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:
|
var progress: float = 0.0:
|
||||||
set(val):
|
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()
|
queue_redraw()
|
||||||
|
|
||||||
var radius: float = 16.0
|
var radius: float = 16.0
|
||||||
var thickness: float = 4.0
|
var thickness: float = 4.0
|
||||||
var color: Color = Color(1.0, 1.0, 1.0, 0.8)
|
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:
|
func _draw() -> void:
|
||||||
if progress <= 0.0 or progress >= 1.0:
|
if progress <= 0.0 or progress >= 1.0:
|
||||||
return
|
return
|
||||||
|
var mid := size * 0.5
|
||||||
|
|
||||||
# Draw background arc (darker)
|
# Ink track first, wider than the arc, so the ring reads over a bright
|
||||||
draw_arc(Vector2.ZERO, radius, 0, PI * 2.0, 32, Color(0, 0, 0, 0.4), thickness, true)
|
# 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
|
# The charged part, sweeping from twelve o'clock.
|
||||||
var end_angle = -PI / 2.0 + (PI * 2.0 * progress)
|
draw_arc(mid, radius, -PI / 2.0, -PI / 2.0 + TAU * progress, 48, color,
|
||||||
draw_arc(Vector2.ZERO, radius, -PI / 2.0, end_angle, 32, color, thickness, true)
|
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
|
## 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.
|
## 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.
|
## INK EVERYTHING every panel, chip and letter carries a heavy dark edge.
|
||||||
## It is what makes flat colour read as drawn rather than
|
## 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.
|
## VOLT MEANS NOW yellow is reserved for the pressed state and the bolt.
|
||||||
## Spend it anywhere else and the moment of input stops
|
## Spend it anywhere else and the moment of input stops
|
||||||
## standing out.
|
## 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
|
## Everything here is static. Screens ask for `apply_global` once and then use
|
||||||
## the helpers — `heading`, `card`, `divider`, `chip_button` — instead of
|
## the helpers — `heading`, `card`, `divider`, `chip_button` — instead of
|
||||||
## hand-rolling styleboxes, which is how the pause menu drifted 900 lines away
|
## hand-rolling styleboxes, which is how the pause menu drifted 900 lines away
|
||||||
## from the rest of the game's look.
|
## 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"
|
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.
|
## Older screens name the accent `TEAL`; it is the electric cyan now.
|
||||||
const TEAL := CYAN
|
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
|
## 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.
|
## nearly square, which is as close to a skew as a StyleBoxFlat can get.
|
||||||
const LEAN := 18
|
const LEAN := 18
|
||||||
@@ -49,6 +74,85 @@ const LEAN := 18
|
|||||||
static var _theme: Theme = null
|
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.
|
## 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
|
## 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
|
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:
|
static func build() -> Theme:
|
||||||
if _theme:
|
if _theme:
|
||||||
return _theme
|
return _theme
|
||||||
@@ -105,12 +227,10 @@ static func build() -> Theme:
|
|||||||
# ── Buttons ──────────────────────────────────────────────────────────
|
# ── Buttons ──────────────────────────────────────────────────────────
|
||||||
# Dark chip with a papaya edge at rest; the chip FILLS papaya on hover and
|
# 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
|
# flashes volt on press. The press state drops its shadow, so the chip
|
||||||
# visibly slams down into the page rather than just changing colour.
|
# visibly slams down into the page rather than just changing colour. Every
|
||||||
_button_look(t, "Button")
|
# state's text colour comes from `ink_for` its own fill, so the label
|
||||||
for cls in ["OptionButton", "MenuButton", "CheckBox", "CheckButton",
|
# inverts WITH the chip instead of vanishing into it.
|
||||||
"LinkButton"]:
|
for cls in BUTTON_CLASSES:
|
||||||
# Godot theme types do not inherit styling from Button, so each one has
|
|
||||||
# to be told the same thing.
|
|
||||||
_button_look(t, cls)
|
_button_look(t, cls)
|
||||||
|
|
||||||
# ── Labels: ink outline everywhere, for readability straight over 3D ──
|
# ── Labels: ink outline everywhere, for readability straight over 3D ──
|
||||||
@@ -125,45 +245,110 @@ static func build() -> Theme:
|
|||||||
# ── Inputs ───────────────────────────────────────────────────────────
|
# ── Inputs ───────────────────────────────────────────────────────────
|
||||||
t.set_stylebox("normal", "LineEdit", box(INK_SOFT, PAPAYA, 4, 3, false))
|
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("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_color", "LineEdit", PAPER)
|
||||||
|
t.set_color("font_uneditable_color", "LineEdit", DEAD_TEXT)
|
||||||
t.set_color("caret_color", "LineEdit", VOLT)
|
t.set_color("caret_color", "LineEdit", VOLT)
|
||||||
t.set_color("font_placeholder_color", "LineEdit", PAPER_DIM)
|
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 ─────────────────────
|
# ── Sliders: the filled part is the charged part ─────────────────────
|
||||||
t.set_stylebox("slider", "HSlider", box(INK_SOFT, INK, 2, 2, false))
|
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", "HSlider", box(PAPAYA, INK, 2, 2, false))
|
||||||
t.set_stylebox("grabber_area_highlight", "HSlider", box(VOLT, 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 ───────────────────────────────────────────────────
|
# ── Panels / lists ───────────────────────────────────────────────────
|
||||||
t.set_stylebox("panel", "PanelContainer", panel())
|
t.set_stylebox("panel", "PanelContainer", panel())
|
||||||
t.set_stylebox("panel", "Panel", panel())
|
t.set_stylebox("panel", "Panel", panel())
|
||||||
t.set_stylebox("panel", "ItemList", panel(PANEL_DEEP, INK, 3))
|
t.set_stylebox("panel", "ItemList", panel(PANEL_DEEP, INK, 3))
|
||||||
t.set_color("font_color", "ItemList", PAPER)
|
t.set_color("font_color", "ItemList", PAPER)
|
||||||
t.set_color("font_selected_color", "ItemList", INK)
|
t.set_color("font_selected_color", "ItemList", ink_for(PAPAYA))
|
||||||
t.set_stylebox("selected", "ItemList", box(PAPAYA, INK, 4, 2, false))
|
t.set_color("font_hovered_color", "ItemList", PAPER)
|
||||||
t.set_stylebox("selected_focus", "ItemList", box(PAPAYA_HOT, CYAN, 4, 2, false))
|
t.set_color("font_outline_color", "ItemList", INK)
|
||||||
t.set_stylebox("hovered", "ItemList", box(INK_SOFT, PAPAYA, 4, 2, false))
|
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 ─────────────────────────────────────────────────────────────
|
# ── Tabs ─────────────────────────────────────────────────────────────
|
||||||
t.set_stylebox("panel", "TabContainer", panel())
|
t.set_stylebox("panel", "TabContainer", panel())
|
||||||
t.set_stylebox("tab_selected", "TabContainer", box(PAPAYA, INK, 6, 3, false))
|
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_unselected", "TabContainer", box(INK_SOFT, INK, 6, 3, false))
|
||||||
t.set_stylebox("tab_hovered", "TabContainer", box(PAPAYA_HOT, 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_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) ──────────────────────────────────
|
# ── 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("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_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 ──────────────────────────────────────────────────────────
|
# ── Dialogs ──────────────────────────────────────────────────────────
|
||||||
t.set_stylebox("panel", "AcceptDialog", panel(PANEL_DEEP, PAPAYA, 4))
|
t.set_stylebox("panel", "AcceptDialog", panel(PANEL_DEEP, PAPAYA, 4))
|
||||||
t.set_stylebox("embedded_border", "Window", panel(PANEL_DEEP, PAPAYA, 4))
|
t.set_stylebox("embedded_border", "Window", panel(PANEL_DEEP, PAPAYA, 4))
|
||||||
t.set_color("title_color", "Window", VOLT)
|
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
|
_theme = t
|
||||||
return 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("normal", cls, box(INK_SOFT, PAPAYA))
|
||||||
t.set_stylebox("hover", cls, box(PAPAYA, INK))
|
t.set_stylebox("hover", cls, box(PAPAYA, INK))
|
||||||
t.set_stylebox("pressed", cls, box(VOLT, INK, 10, 3, false))
|
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("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_color", cls, PAPER)
|
||||||
t.set_color("font_hover_color", cls, INK)
|
t.set_color("font_hover_color", cls, ink_for(PAPAYA))
|
||||||
t.set_color("font_pressed_color", cls, INK)
|
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_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_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
|
## 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
|
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.
|
## A bolt-struck rule, for separating sections.
|
||||||
static func divider(strike_at: float = 0.34) -> BoltRule:
|
static func divider(strike_at: float = 0.34) -> BoltRule:
|
||||||
var b := BoltRule.new()
|
var b := BoltRule.new()
|
||||||
@@ -240,6 +461,28 @@ static func card() -> PanelContainer:
|
|||||||
return p
|
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).
|
## 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
|
## 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
|
@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 reloading: bool = false
|
||||||
var reload_timer: float = 0.0
|
var reload_timer: float = 0.0
|
||||||
var _vm_kick: float = 0.0
|
var _vm_kick: float = 0.0
|
||||||
@@ -59,7 +69,7 @@ func _process(delta: float) -> void:
|
|||||||
if reloading:
|
if reloading:
|
||||||
reload_timer -= delta
|
reload_timer -= delta
|
||||||
if reload_timer <= 0.0:
|
if reload_timer <= 0.0:
|
||||||
shells = 2
|
shells = max_shells
|
||||||
reloading = false
|
reloading = false
|
||||||
|
|
||||||
# Big single-shot shove that springs back.
|
# Big single-shot shove that springs back.
|
||||||
@@ -77,7 +87,7 @@ func _input(event: InputEvent) -> void:
|
|||||||
_try_fire()
|
_try_fire()
|
||||||
|
|
||||||
if event.is_action_pressed("reload"):
|
if event.is_action_pressed("reload"):
|
||||||
if shells < 2 and not reloading:
|
if shells < max_shells and not reloading:
|
||||||
_start_reload()
|
_start_reload()
|
||||||
|
|
||||||
func _start_reload() -> void:
|
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()
|
_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
|
# Wait one frame for LoadoutManager to be fully ready if needed
|
||||||
await get_tree().process_frame
|
await get_tree().process_frame
|
||||||
_build_loadout()
|
_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:
|
func _setup_viewmodel_viewport() -> void:
|
||||||
if not camera: return
|
if not camera: return
|
||||||
|
|
||||||
@@ -200,6 +215,16 @@ func _build_loadout() -> void:
|
|||||||
player.synced_loadout_melee = ""
|
player.synced_loadout_melee = ""
|
||||||
player.synced_loadout_ready = true
|
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(1, l["primary_1"])
|
||||||
_spawn_weapon(2, l["primary_2"])
|
_spawn_weapon(2, l["primary_2"])
|
||||||
_spawn_weapon(3, l["special"])
|
_spawn_weapon(3, l["special"])
|
||||||
@@ -208,6 +233,33 @@ func _build_loadout() -> void:
|
|||||||
|
|
||||||
_equip_slot(1)
|
_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:
|
func _build_remote_loadout(p1: String, p2: String, sp: String, melee: String) -> void:
|
||||||
for w in weapons.values():
|
for w in weapons.values():
|
||||||
if is_instance_valid(w):
|
if is_instance_valid(w):
|
||||||
|
|||||||
Reference in New Issue
Block a user