Compare commits

9 Commits
Author SHA1 Message Date
Dotts b0b99668ab Merge pull request 'Feat/fidelity pass 2' (#23) from feat/fidelity-pass-2 into main
Reviewed-on: #23
2026-07-28 10:31:27 -07:00
Nicholas ButzkeandClaude Opus 5 61669627db chore(debug): track the UID Godot generated for menu_capture.gd
Godot writes a .uid sidecar for every script and this one landed after the
commit that added debug/menu_capture.gd. All 140 of the others are in version
control; an untracked one drifts to a different UID on the next machine that
imports the project.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-07-28 13:09:15 -04:00
Nicholas ButzkeandClaude Opus 5 2efc21b18d feat(menu): PLAY is one press, and the level cards are photographs
Reaching a game was Singleplayer → pick a level. Reaching a multiplayer game
was Multiplayer → Host Game → wait → pick a level → wait, and the gamemode
dropdown had exactly one entry which set a string nothing read.

PLAY now starts the last map and mode played, and says which — "Akiba Crossing
• Deathmatch" under the button, so pressing it is a promise rather than a leap.
Everything else on the home screen is a detour from that, which is the right
shape for a menu: the common case is a button, not a path. Hosting is one press
and lands in the lobby already hosting; the lobby puts map, mode, players and
start on ONE screen; Enter connects, so typing an address does not then require
reaching for the mouse.

This is the lesson HoYoverse published about Zenless Zone Zero's first months
more loudly than anything else they have written. Their postmortem on the TV
mode names three complaints — it took too long, it sat between the player and
the combat, and there was too much of it early — and 1.2 removed it from the
story entirely rather than shortening it. Time spent BEFORE the thing the
player came for is not neutral, it is a cost.

The cards were a two-stop gradient generated from two colours in a meta file,
whose hover state was `use_hdr = true` — not a visible change on any of them.
They are photographs now, shot by debug/map_preview_capture.gd, which took
three attempts to get right and each attempt is a comment in the file:

  - framing each map from OUTSIDE by merging every VisualInstance3D's AABB
    produced five tiny dioramas floating on a table, and one solid black
    rectangle. That is a minimap, and a minimap is not a photograph.
  - standing at a spawn point fixed three maps and left two black: fps_blockout
    is genuinely dark and most of its spawns face an unlit wall, and
    procedural_arena builds its geometry at runtime so no fixed offset is
    reliably inside it.
  - so the tool now RENDERS several vantages and scores each result — mean
    luminance times its standard deviation, because brightness alone picks the
    empty sky and variance alone picks a high-contrast corner of a dark room.
    Their product picks a photograph. A black rectangle passes any check that
    only asks whether the camera ended up somewhere sensible.

Also fixed, and caught by looking at the screenshot: the selected mode chip was
unreadable. Its glyph is ink on volt, and the theme gave every button a 5 px INK
outline — an ink glyph inside an ink outline is not outlined, it is five pixels
fatter, and on a small chip that is a solid blob. Button labels now carry no
outline at all, which is the same reasoning debug/ui_contrast_check.gd already
encodes: an outline separates a glyph from a backdrop it cannot beat alone, and
a label on a solid chip does not have that problem. The chips keep their heavy
ink border, so nothing about the drawn look changes.

`current_gamemode` is normalised on the way in, because it used to hold a
display string and six callers still pass one. An unrecognised id compares
unequal to GameMode.GUN_GAME and would silently disable that mode's weapon
issuing — half a mode is worse than none.

The menu's background character is the player's OWN skin holding their weapon,
rather than the box-and-capsule mannequin that stood there before.

spawn smoke 0, game modes 30/30, movement 11/11, contrast 108/108, HUD layout
PASS, weapon holds 0, dances 0.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-07-28 12:40:50 -04:00
Nicholas ButzkeandClaude Opus 5 986179854d feat(modes): matches now end, and something is at stake when they do
There was a string — `current_gamemode`, always "Deathmatch" — an OptionButton
with one entry in it, and a five-minute timer that counted to zero, set
`match_active = false`, and did nothing else. No winner was declared, no summary
appeared, the clock froze at 00:00, and players carried on shooting each other
in a match that had stopped counting. Nothing was tracked beyond a running kill
count. A match did not end so much as stop mattering, and everything a player
does in the last minute only matters if there is a last minute.

globals/game_mode.gd answers the three questions a mode has to answer — how you
score, when it ends, who won — for three modes:

  Deathmatch        25 frags or 10 minutes.
  Team Deathmatch   two squads to 50, teams balanced by COUNT on join (a 4v4
                    that loses three from one side must refill the short side;
                    round-robin on join order leaves it 4v1 forever), friendly
                    fire off and enforced on the server before damage is even
                    broadcast — a mode where the damage lands but the kill does
                    not count is worse than either.
  Gun Game          every kill promotes you a rung and swaps your weapon. The
                    score IS the rung and the limit IS the ladder's length, so
                    finishing the ladder and reaching the score limit are the
                    same event and only one win condition exists.

`check_win` is a pure function of the stats and the clock, deliberately, because
a win condition that can only be exercised by playing a whole match is one
nobody tests — and the previous one never was. debug/game_mode_check.gd runs 30
cases over it: a tie is a DRAW rather than a win for whoever came first out of
the dictionary; a team match is decided on the TEAM's total, which a per-player
check never reaches; every ladder rung names a weapon that exists, or that rung
softlocks the mode; and promoting past the top clamps, because the winning kill
promotes before the match-end RPC lands.

Tracking now covers score, team, current streak, best streak and ladder rung.
Score and kills are separate numbers because in Gun Game they coincide and in
anything with an objective they would not. The scoreboard ranks by score, shows
the mode's own noun for it, and puts the limit on the top line — a win condition
players cannot see is one they cannot play toward.

ui/match_summary.gd gives the ending somewhere to happen: who won, WHY (time and
frag limit are different stories about the same scoreline), team totals, full
standings, and a way out that is not alt-F4. Play Again is host-only on a server.

Three bugs found on the way, all of which only became bugs once matches could
actually end: loading a level reset the clock but not the scores, so the second
match on a server would have ended on its first kill; `.rpc()` on an offline
peer does not call locally, so singleplayer had no killfeed and no stat sync at
all; and a rocket already in the air at the whistle could change the result
after the summary was on screen.

spawn smoke 0, game modes 30/30, movement 11/11.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-07-28 12:28:32 -04:00
Nicholas ButzkeandClaude Opus 5 f1a4f7df52 feat(emotes): five dances, built like animation, behind a radial dial
The shared clip library ships exactly one `Dance_Loop`, and five copies of one
clip is not five dances. What the runtime does have is a procedural pose layer
over a real skeleton with spring-driven hair and cloth, which is enough — if
the motion is constructed the way an animator would construct it rather than
the way a programmer reaches for first.

Wiring sine waves to bones is that first reach, and everyone can tell. A raw
sine moves fastest through the middle and slowest at the ends by the same
amount on every channel, all in phase, forever. It 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 it, and all four are cheap:

  OVERLAP        the body is a chain. Hips lead, spine follows a beat later,
                 head last. One subtraction — `beat - lag * i` — and the spring
                 solver then carries it out through the hair and skirt for free,
                 because the dance layer runs before it.
  ACCENT         a dance HITS poses. `shape` bends the wave so it hangs at the
                 extremes and snaps between them, which is what a key-and-
                 breakdown pass produces by hand.
  WEIGHT         the HIPS translate, not just rotate. A body that never leaves
                 its own axis reads as a puppet on a stick.
  CONTRAST       Robot deliberately breaks all of the above — zero lag,
                 quantised motion — and reads as mechanical precisely because
                 the other four do not.

Spin spots its head: it holds a heading against the turn and whips round to
catch up, which is what a real dancer does to keep from getting dizzy and the
most recognisable thing about a turn.

The dial is a radial menu because every option is then the SAME DISTANCE from
where the pointer starts — the choice is a direction, and a direction becomes
muscle memory in a way "the fourth row down" does not. Selection is by ANGLE
alone, so a flick and a careful nudge do the same thing. HOLD to open, release
to commit; a tap too short to have aimed replays the last emote, which is what
the button did before, so the old habit still works. Pressing while already
dancing just stops — having to aim at something in order to STOP would be the
most annoying possible way to build this.

debug/dance_check.gd asserts the overlap, and getting it to measure that took
four wrong measurements, each of which is now a comment where it was made:

  - correlating the hips' TRANSLATION against the head's position relative to
    them compared two different quantities at different periods; it ranked the
    Robot, whose lag is zero by construction, as the most overlapped routine.
  - a signed scalar `angle * sign of the axis's largest component` is
    DISCONTINUOUS — as a rocking bone passes back through rest the axis flips —
    so smooth Two-Step measured a full-range jump per frame, which is exactly
    what quantised motion looks like.
  - a bone's GLOBAL rotation carries every ancestor's, so the head correlates
    with the hips at lag zero however delayed the head itself is.
  - and the hips and head are driven by different channels anyway.

Measuring two links of the SAME chain, as local rotation vectors, agrees with
the authored lag: Spin measures 9 frames against 8.4 authored, Two-Step 7
against 6.6, Robot 0. The Robot is checked on the property it actually has —
its jump per frame is 0.41 of its range against 0.03-0.06 for the others.

RigRoles is pulled out of ShooterPoseModifier so the dance layer resolves bones
the same way rather than carrying a second copy. Two copies is how a rig ends up
animating correctly under one modifier and not the other.

spawn smoke 0 failures, 11/11 movement, 21/21 weapon-hold pairs, contrast 108/108.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-07-28 12:20:33 -04:00
Nicholas ButzkeandClaude Opus 5 a13ae50f95 feat(rig): every weapon gets its own hold, so the silhouette names the gun
`_apply_rifle_hold` did exactly what its name said, to everything. A knife, an
AWP and a rocket launcher were all solved as a rifle — stock in the shoulder
pocket, support hand out along the barrel, muzzle on the aim line — so in third
person every character stood the same way whatever they carried, and the only
thing distinguishing a sniper from a shotgun was the ~30 cm of gun mesh in
their hands. At the distance an enemy is usually seen that is nothing.

It costs more than looking wrong. A character's pose is the fastest available
answer to "what is about to happen to me": a tube on a shoulder means take
cover, a blade held low means they have to close, a rifle at low ready means
they have not seen you. One hold throws all of that away. It is also the thing
HoYoverse's team say they chase in Zenless Zone Zero — characters read by
silhouette first, and they refuse to settle on one construction method because
one method limits how distinguishable the results can be.

weapons/weapon_hold_profiles.gd gives each of the twelve weapons a style, and a
style is not a bundle of slider values. Three of its differences cannot be
expressed on the rifle solve at all, and those are the ones that carry:

  support   WHERE the off hand goes and HOW IT IS TURNED there — wrapped round
            a handguard, cupped against the firing fist, hooked under a tube
            palm-up, or released entirely. Sending a hand somewhere new without
            re-orienting it gives a hand teleported to the new spot still shaped
            for the old one.
  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, or leans away to clear a
            tube. The cheek weld is the sniper silhouette, and its inverse is
            what says a launcher is resting on that shoulder.

A blade RELEASES the off arm back to the animation, so it swings with the run
cycle instead of gripping a handguard that is not there — most of what makes a
one-handed weapon read as one-handed — and closes a full fist, because an index
left straight along a knife handle reads as a mistake, not as discipline.

Layered strictly UNDER the existing tuning, so aria's hand-tuned AK-47 hold is
byte-for-byte what it was. WeaponHoldTuning.default_for is weapon-aware now,
which it had to be: the rig lab SAVES every knob it shows, so without it,
opening the lab on the knife and pressing save would silently overwrite the
blade profile with the rifle spec and put the character back to holding a knife
like an AK with nothing to indicate it had happened.

debug/weapon_hold_check.gd asserts the consequence, not the plumbing — storing
an enum and reading it back proves nothing. It measures where the hands and head
ACTUALLY end up, from inside the modifier pass (outside it, Godot restores the
local poses and every weapon reports an identical rifle) and in the shoulder's
own frame, because the hold breathes and two samples of the SAME weapon
otherwise differ by more than two different weapons do. 21 weapon pairs, all
distinguishable; the knife's off-hand weight measured at 0.01.

Two findings only measuring produced. Pushing `gun_fore` further out for the
sniper does NOTHING — the reach solver slides the support hand back down the
handguard until the arm can get there, so it landed at 0.388 m against the
rifle's 0.387. Raising the whole weapon is what makes a scoped rifle read.

And one only LOOKING produced, via debug/hold_capture.gd: the shotgun's barrel
passed through the character's chest. Every assertion passed — the hands were
exactly where they had been asked to go — but +x is toward the centreline, so
dropping the pocket and pushing it across at once swings the muzzle into the
torso.

rig_anchor_check still reports aria: her hand-tuned wrist rotates the anchor
offset. Pre-existing, and this halves it — it was 2 failures on main, now 1.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-07-28 12:05:43 -04:00
Nicholas ButzkeandClaude Opus 5 c4bcbc7fd1 feat(ui): the first-person HUD is one thing, in the game's own hand
The reticle was five white ColorRects, pasted byte-identically into three
level runtime scripts. The vitals were two stock ProgressBars with a flat
colour override, inlined 1200 lines into the movement controller. The ammo
count — the number a shooter's player looks at most — was drawn by the LEVEL,
in a black rounded panel that shared nothing with the menus, and it had a
special case in it (`elif active_weapon is DoubleBarrelShotgun`) because that
weapon never declared a name or a capacity.

Everything that describes A PLAYER now belongs to ui/player_hud.gd, and a
level owns the level. The immediate symptom that fixed: the screen had two
ammo panels on it at once, in two different styles, overlapping in the corner.

What each piece now says, rather than merely shows:

  ui/crosshair.gd    one drawn reticle instead of five rectangles, so it can
                     BLOOM — open with speed, airtime and each shot, snap shut
                     on ADS. That is the accuracy readout of the whole game and
                     five ColorRects could not express it. Every stroke is
                     drawn twice, ink underneath, because a 2 px white line
                     disappears over pale concrete exactly when aim matters.
                     The hit confirmation is the same cross at 45 degrees, so
                     it lands where the eye already is.
  ui/vital_bar.gd    segmented, so remaining health can be COUNTED rather than
                     estimated, with a drain ghost that holds the old value for
                     a beat — the gap between fill and ghost is the size of the
                     hit, which a bar that merely gets shorter never tells you.
  ui/ability_chip.gd dash and grapple as a wipe across a chip rather than a
                     tinted JPEG with a 12 px number under it. A shape changing
                     size is readable in peripheral vision; 12 px type is not.
  chain meter        promoted out of the debug panel. Movement is this game's
                     first stated pillar and chaining is its skill expression,
                     so the count is a score, not a diagnostic.

The numerals moved OFF the bars and beside them. Text centred on a two-tone
bar cannot be given a colour that beats both the fill and the trough — that is
the 2.4:1 debug/ui_contrast_check.gd measured on the old HUD — so this fixes it
at the source rather than leaning on an outline to rescue it.

debug/hud_layout_check.gd measures where every element actually lands, which is
how three real bugs were found rather than squinted at: `set_anchors_preset`
moves the anchors and LEAVES THE OFFSETS, so the reticle spanned the viewport
with a size of exactly (0,0) and drew itself in the top-left corner; a
PRESET_CENTER applied after the ring's own `_ready` undid its centring; and a
BOX CONTAINER's own `alignment` is what pushes content to an edge, not a
SHRINK_END flag on the box, whose minimum width depends on children that may be
hidden. The ammo card was hanging off the right edge of the screen because of
the last one.

DoubleBarrelShotgun now declares `weapon_name` and `max_shells` like every
other weapon, and the four inline `2`s are gone.

spawn smoke 0 failures, 11/11 movement tests, contrast 108/108, layout PASS.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-07-28 11:51:22 -04:00
Nicholas ButzkeandClaude Opus 5 414026f001 feat(ui): every control state is checked for readability, not trusted
The palette has two light accents and one very dark one, and a control
changes its FILL on hover and press. Paper text that reads at 18:1 on the
resting near-black chip inverts to paper-on-yellow the moment the pointer
arrives, which is 1.1:1 — invisible. That is the standard way a stylised UI
becomes unreadable, and it was live on the OptionButton dropdown that every
settings row uses: PopupMenu draws its papaya hover fill but keeps
`font_color` unless `font_hover_color` is set, and it was not set.

So the label now follows the fill. `ink_for(fill)` picks the legible glyph
colour by contrast ratio, and every state's text, icon and outline is derived
from its own fill through it — including the states nobody remembers exist:
`hover_pressed` on a toggle (a CheckButton read as OFF while you touched it),
icon colours on a CheckBox that is all icon, and the list hover that used to
be the same papaya as list SELECTION, so the row you pointed at looked like
the row you had chosen.

debug/ui_contrast_check.gd interrogates the BUILT theme rather than the
palette — a table compared against itself agrees by construction and catches
nothing — and fails below the WCAG floor. It found the PopupMenu gap, a Tree
hover asking for a `font_hovered_color` Godot does not have, and the health
bar's readout at 2.4:1 over its own fill.

That last one is not fixable as a colour pair: a centred readout straddles a
hot papaya fill and a near-black trough, and no single colour beats both. What
carries it is the heavy ink outline this theme puts on every glyph, which is
its first stated rule and the same mechanism that keeps menu text legible
straight over the 3D scene. The check models that as a fallback route —
outline vs backdrop 3:1, glyph vs outline 4.5:1, at least 4 px — granted only
where the backdrop genuinely varies, and substituting two ratios for one
rather than waiving the requirement.

Disabled text moves from 3.2:1 to 5.2:1 on the way past. A greyed-out
"Start Match" is information; an illegible smudge is not.

Also styled, because the theme had simply never mentioned them and Godot's
defaults are grey-on-grey: scrollbars, Tree, SpinBox, ProgressBar, tooltips,
LineEdit read-only and selected text.

108 pairs checked, all passing.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-07-28 02:39:25 -04:00
Dotts f0b0d19847 Merge pull request 'Feat/outline thickness and tp weapon hold' (#22) from feat/outline-thickness-and-tp-weapon-hold into main
Reviewed-on: #22
2026-07-27 23:22:53 -07:00
70 changed files with 6120 additions and 1784 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 611 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 765 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 246 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 690 KiB

+239
View File
@@ -0,0 +1,239 @@
extends SkeletonModifier3D
class_name DanceModifier
## Drives a dance routine onto the skeleton, over whatever clip is playing.
##
## Runs AFTER the shooter pose layer and blends over it, so starting a dance
## takes the arms off the weapon smoothly rather than cutting, and stopping one
## hands them back the same way. The spring solver runs after both, so hair and
## cloth follow the dance without anything being asked to make that happen.
##
## The routine data — and the reasoning behind why the motion is built the way it
## is — lives in characters/dance_routines.gd. This is the machine that plays it.
## The routine to play. See DanceRoutines.ROUTINES.
var routine: Dictionary = {}
## 0..1. Eased by the owner, so a dance fades in and out rather than snapping.
var weight: float = 0.0
## `<model>.rig.json` roles, for bone resolution. See RigRoles.
var roles: Dictionary = {}
var _idx: Dictionary = {}
var _resolved := false
var _t: float = 0.0
## Per-link overlap, in seconds, from the hips outward. Index 0 is the hips.
const CHAIN := ["DEF-hips", "DEF-spine.001", "DEF-spine.002", "DEF-spine.003"]
func _process_modification() -> void:
var skel := get_skeleton()
if skel == null or weight <= 0.001 or routine.is_empty():
return
if not _resolved:
_idx = RigRoles.resolve(skel, roles)
_resolved = true
var delta := get_physics_process_delta_time() if Engine.is_in_physics_frame() \
else get_process_delta_time()
_t += delta
var bpm: float = float(routine.get("bpm", 100.0))
var beat := _t * bpm / 60.0
var lag: float = float(routine.get("lag", 0.05)) * bpm / 60.0
var w := weight
# ── Hips: the driver ────────────────────────────────────────────────────
#
# Translated, not just rotated. A body that never leaves its own axis reads
# as a puppet on a stick; moving the hips is what makes the legs look like
# they are carrying someone, and every other channel below is a reaction to
# this one.
var swing := _wave(beat, 2.0, 0.0)
var bob := _wave(beat, float(routine.get("hip_bob_beats", 1.0)), 0.25)
var hips: int = _idx.get("DEF-hips", -1)
if hips >= 0:
# Character-right is -X in skeleton space, up is +Y.
var offset := Vector3(-swing * float(routine.get("hip_swing", 0.0)),
bob * float(routine.get("hip_bob", 0.0)), 0.0) * w
_offset_bone(skel, hips, offset)
_add_space(skel, hips,
Quaternion(Vector3(0, 0, 1), swing * float(routine.get("hip_roll", 0.0)) * w)
* Quaternion(Vector3(0, 1, 0), swing * float(routine.get("hip_yaw", 0.0)) * w)
* Quaternion(Vector3(1, 0, 0), bob * float(routine.get("hip_pitch", 0.0)) * w))
# ── Spine: the same motion, later ───────────────────────────────────────
#
# Each link reads the wave at `beat - lag * i`, which is the whole of
# overlapping action. It is one subtraction and it is the difference between
# a rig oscillating and a person moving.
var counter: float = float(routine.get("spine_counter", 0.0))
var links := CHAIN.slice(1)
var n := maxf(links.size(), 1)
for i in links.size():
var b: int = _idx.get(links[i], -1)
if b < 0:
continue
var at := beat - lag * float(i + 1)
var s := _wave(at, 2.0, 0.0)
var v := _wave(at, float(routine.get("hip_bob_beats", 1.0)), 0.25)
var q := Quaternion(Vector3(0, 0, 1),
s * float(routine.get("spine_roll", 0.0)) * w / n) \
* Quaternion(Vector3(0, 1, 0),
(s * float(routine.get("spine_yaw", 0.0))
- s * float(routine.get("hip_yaw", 0.0)) * counter) * w / n) \
* Quaternion(Vector3(1, 0, 0),
v * float(routine.get("spine_pitch", 0.0)) * w / n)
_add_space(skel, b, q)
_head(skel, beat, lag, w)
_arms(skel, beat, lag, w)
_legs(skel, beat, w)
## Head and neck: the last link in the chain, and the one carrying the spot.
func _head(skel: Skeleton3D, beat: float, lag: float, w: float) -> void:
var at := beat - lag * float(CHAIN.size())
var s := _wave(at, 2.0, 0.0)
var v := _wave(at, float(routine.get("hip_bob_beats", 1.0)), 0.25)
var yaw: float = s * float(routine.get("head_yaw", 0.0))
# SPOTTING. A dancer turning keeps their head pointed at one place for as
# long as they can, then whips it round to catch up. It is what stops them
# getting dizzy, and it is the single most recognisable thing about a turn —
# a head that simply rotates with the shoulders reads as a mannequin on a
# turntable.
#
# So this is not a wave. The head COUNTERS the body's yaw exactly while the
# hold lasts, then releases over a short window and lets the neck catch up.
var spot: float = float(routine.get("spot", 0.0))
if spot > 0.001:
var body_yaw: float = s * float(routine.get("hip_yaw", 0.0)) \
+ s * float(routine.get("spine_yaw", 0.0))
# Where in the two-beat turn we are, 0..1.
var u := fposmod(at / 2.0, 1.0)
# Hold for the first 70%, then whip round over the next 20%, then arrive.
var hold := 1.0 - smoothstep(0.70, 0.90, u)
yaw -= body_yaw * spot * hold
var q := Quaternion(Vector3(0, 1, 0), yaw * w) \
* Quaternion(Vector3(0, 0, 1), s * float(routine.get("head_roll", 0.0)) * w) \
* Quaternion(Vector3(1, 0, 0),
(v * float(routine.get("head_bob", 0.0))
+ v * float(routine.get("head_pitch", 0.0))) * w)
# Split, so the whole column leans rather than the skull hinging off a rigid
# neck.
_add_space(skel, _idx.get("DEF-neck", -1), Quaternion.IDENTITY.slerp(q, 0.4))
_add_space(skel, _idx.get("DEF-head", -1), Quaternion.IDENTITY.slerp(q, 0.6))
## Arms: out from the body, swinging in opposition, elbows folding on the beat.
##
## The two arms are half a cycle apart, which is what opposition is. Both read
## the wave later than the spine did, so the hands are the last thing to arrive —
## the end of the chain, where overlap is most visible.
func _arms(skel: Skeleton3D, beat: float, lag: float, w: float) -> void:
var at := beat - lag * float(CHAIN.size() + 1)
var beats: float = float(routine.get("arm_beats", 2.0))
var out: float = float(routine.get("arm_out", 0.0))
var swing: float = float(routine.get("arm_swing", 0.0))
var elbow: float = float(routine.get("elbow", 0.0))
for sign_i in 2:
var right := sign_i == 0
var side := 1.0 if right else -1.0
var s := _wave(at + (0.0 if right else beats * 0.5), beats, 0.0)
var ua: int = _idx.get("DEF-upper_arm." + ("R" if right else "L"), -1)
var fa: int = _idx.get("DEF-forearm." + ("R" if right else "L"), -1)
if ua >= 0:
# Character-right is -X, so a positive Z rotation lifts the LEFT arm
# and drops the right — hence the side flip. `out` is a static lift
# that the swing then rides on top of, which is what stops the arms
# from passing through the body at the bottom of the stroke.
_add_space(skel, ua,
Quaternion(Vector3(0, 0, 1), -side * (out + s * 0.35 * swing) * w)
* Quaternion(Vector3(1, 0, 0), s * swing * w))
if fa >= 0:
# The elbow only ever folds, never hyperextends: a signed wave here
# bends the forearm backwards through the joint on half of every
# cycle, which is the most obvious possible tell.
var fold := (0.5 + 0.5 * s) * elbow
_add_space(skel, fa, Quaternion(Vector3(1, 0, 0), fold * w))
## Legs: knees absorbing the bob, out of phase with each other so the weight
## visibly transfers from one to the other.
func _legs(skel: Skeleton3D, beat: float, w: float) -> void:
var knee: float = float(routine.get("knee", 0.0))
if knee <= 0.001:
return
var beats: float = float(routine.get("hip_bob_beats", 1.0))
for sign_i in 2:
var right := sign_i == 0
var s := _wave(beat + (0.0 if right else beats * 0.5), beats, 0.25)
var thigh: int = _idx.get("DEF-thigh." + ("R" if right else "L"), -1)
var shin: int = _idx.get("DEF-shin." + ("R" if right else "L"), -1)
var bend := (0.5 + 0.5 * s) * knee
# Thigh forward and shin back by twice as much, so the foot stays roughly
# under the hip instead of the whole leg swinging out in front.
_add_space(skel, thigh, Quaternion(Vector3(1, 0, 0), -bend * 0.5 * w))
_add_space(skel, shin, Quaternion(Vector3(1, 0, 0), bend * w))
# ── The wave ─────────────────────────────────────────────────────────────────
## One channel's value at `beat`, in -1..1.
##
## `shape` bends a sine so it HANGS at the extremes and SNAPS between them, which
## is what an animator's key-and-breakdown pass produces and what a raw sine
## cannot. At shape 1 this is exactly `sin`; above it the curve gets punchier
## while staying continuous and staying in -1..1, so no amount of shaping can
## make a channel overshoot its authored amplitude.
##
## `steps` quantises the result instead, for the robot — the one routine whose
## whole point is that it does NOT move like the others.
func _wave(beat: float, beats: float, phase: float) -> float:
if beats <= 0.001:
return 0.0
var u := beat / beats + phase
var steps: int = int(routine.get("steps", 0))
if steps > 0:
# Hold a value for a whole step, then jump. Rounded rather than floored
# so the extremes are actually reached — a floor never returns +1.
return sin(TAU * (round(u * float(steps)) / float(steps)))
var s := sin(TAU * u)
var shape: float = float(routine.get("shape", 1.0))
if is_equal_approx(shape, 1.0):
return s
return signf(s) * pow(absf(s), 1.0 / shape)
# ── Bone plumbing ────────────────────────────────────────────────────────────
## Move a bone by an offset expressed in SKELETON space.
##
## A bone's pose position is in its PARENT's space, so the offset has to be
## rotated out of skeleton space by the parent's rest basis first. Skipping that
## sends the hips sideways in whatever direction the rig happens to have called
## "x", which differs per character.
func _offset_bone(skel: Skeleton3D, idx: int, offset: Vector3) -> void:
if idx < 0 or offset == Vector3.ZERO:
return
var parent := skel.get_bone_parent(idx)
var local := offset
if parent >= 0:
local = skel.get_bone_global_rest(parent).basis.inverse() * offset
skel.set_bone_pose_position(idx, skel.get_bone_rest(idx).origin + local)
## Compose a skeleton-space rotation onto a bone's animated local pose. Same
## contract as ShooterPoseModifier._add_space.
func _add_space(skel: Skeleton3D, idx: int, q_space: Quaternion) -> void:
if idx < 0:
return
var b := skel.get_bone_global_rest(idx).basis.get_rotation_quaternion()
var local := b.inverse() * q_space * b
skel.set_bone_pose_rotation(idx, skel.get_bone_pose_rotation(idx) * local)
+1
View File
@@ -0,0 +1 @@
uid://bab4y7ba0yl87
+204
View File
@@ -0,0 +1,204 @@
extends Object
class_name DanceRoutines
## The five emotes, as data.
##
## They are procedural rather than authored clips because the shared animation
## library ships exactly one `Dance_Loop`, and five copies of one clip is not
## five dances. What the runtime DOES have is a working procedural pose layer
## over a real skeleton with spring-driven hair and cloth, which is enough to
## build a dance out of if the motion is constructed the way an animator would
## construct it rather than the way a programmer reaches for first.
##
## ── Why not just wire sine waves to the bones ───────────────────────────────
##
## Because that is what "programmer animation" looks like, and everyone can tell.
## A raw sine moves fastest through the middle and slowest at the ends by exactly
## the same amount on every channel, all in phase, forever. The result floats. It
## has no weight, no accent, and no sense that one part of the body is driving and
## the rest is following.
##
## Four principles fix that, and all four are cheap:
##
## OVERLAP the body is a chain, and a chain does not move as one piece.
## Hips lead, spine follows a beat later, chest later still,
## head last. `lag` below is that, in seconds per link. It is
## the single largest difference between "a rig oscillating"
## and "a person moving", and the spring bones then carry it
## out through the hair and the skirt for free.
## ACCENT a dance HITS poses. `shape` bends the wave so it hangs at
## the extremes and snaps between them — the same asymmetry a
## key-and-breakdown pass produces by hand. `sin` is shape 1.0;
## above that it gets punchier.
## WEIGHT a body that never leaves its own axis reads as a puppet. Real
## dances move the HIPS — side to side, up and down — and the
## rest of the body reacts. `hip_swing` and `hip_bob` are
## translations, not rotations, and they are what make the legs
## look like they are carrying someone.
## ANTICIPATION the counter-move before the move. Handled per routine by
## running a channel at a fraction of a beat AHEAD of the one it
## precedes, rather than by a separate mechanism.
##
## ── The shape of a routine ──────────────────────────────────────────────────
##
## Everything is derived from one `bpm`, so no two channels can drift apart no
## matter how long the emote runs — which is the other thing that goes wrong when
## channels are given independent frequencies that are not exact ratios.
##
## Channel amounts are radians (rotations) or metres (the two hip translations).
## `beats` is how many beats that channel takes for one full cycle, so 2 is a
## side-to-side that takes two beats to return, 1 is once per beat, and 0.5 is
## twice per beat. Fractions of a beat are how a routine gets a cross-rhythm
## without leaving the grid.
## `id` is what gets networked, so these strings must stay stable.
const ROUTINES := [
{
"id": "two_step",
"name": "Two-Step",
"icon": "",
"bpm": 104.0,
"lag": 0.055,
"shape": 1.35,
# The foundation step: weight rocks side to side, the shoulders counter
# the hips, the arms hang and swing off the shoulders a beat behind.
"hip_swing": 0.075,
"hip_bob": 0.022,
"hip_bob_beats": 1.0,
"hip_roll": 0.16,
"hip_yaw": 0.20,
"spine_roll": 0.13,
"spine_counter": 0.55,
"head_roll": 0.22,
"head_bob": 0.10,
"arm_swing": 0.55,
"arm_out": 0.42,
"arm_beats": 2.0,
"elbow": 0.75,
"knee": 0.30,
},
{
"id": "body_wave",
"name": "Body Wave",
"icon": "",
"bpm": 88.0,
# The whole point of this one is the lag: a wave travelling up the spine
# IS overlap, made visible. At 0.13 s per link the crest takes most of a
# beat to get from the hips to the head.
"lag": 0.13,
"shape": 1.15,
"hip_swing": 0.03,
"hip_bob": 0.045,
"hip_bob_beats": 2.0,
"hip_pitch": 0.26,
"spine_pitch": 0.30,
"spine_counter": 0.0,
"head_pitch": 0.22,
"head_roll": 0.06,
"arm_out": 0.85,
"arm_swing": 0.20,
"arm_beats": 4.0,
"elbow": 0.55,
"knee": 0.18,
},
{
"id": "robot",
"name": "Robot",
"icon": "",
"bpm": 112.0,
# No lag, and the motion is QUANTISED — see `steps`. Both are deliberate
# violations of everything above, and they work for exactly that reason:
# the robot reads as mechanical because the viewer has been shown four
# other routines that do not.
"lag": 0.0,
"shape": 1.0,
"steps": 4,
"hip_swing": 0.04,
"hip_bob": 0.012,
"hip_bob_beats": 1.0,
"hip_yaw": 0.30,
"spine_yaw": 0.34,
"spine_counter": 0.0,
"head_yaw": 0.42,
"arm_out": 1.05,
"arm_swing": 0.85,
"arm_beats": 2.0,
"elbow": 1.35,
"knee": 0.10,
},
{
"id": "bounce",
"name": "Bounce",
"icon": "",
"bpm": 128.0,
"lag": 0.035,
# The punchiest shape in the set. A bounce lives entirely in the accent:
# the body hangs at the top and slams through the bottom, which is a
# gravity read, and a plain sine cannot express it.
"shape": 2.2,
"hip_swing": 0.03,
"hip_bob": 0.070,
"hip_bob_beats": 1.0,
"hip_roll": 0.08,
"spine_pitch": 0.14,
"spine_counter": 0.30,
"head_bob": 0.16,
"head_roll": 0.10,
"arm_swing": 0.95,
"arm_out": 0.30,
"arm_beats": 1.0,
"elbow": 1.05,
# Deep knees. This is the routine where the legs do the work, and a bounce
# with straight legs looks like a character being shaken.
"knee": 0.85,
},
{
"id": "spin",
"name": "Spin",
"icon": "",
"bpm": 96.0,
"lag": 0.07,
"shape": 1.5,
"hip_swing": 0.05,
"hip_bob": 0.030,
"hip_bob_beats": 2.0,
"hip_yaw": 0.85,
"spine_yaw": 0.30,
"spine_counter": 0.0,
"spine_roll": 0.14,
# SPOTTING: the head holds its heading while the body turns under it, then
# whips round to catch up. It is what a real dancer does to keep from
# getting dizzy, and it is the most recognisable thing about a turn. See
# `spot` in DanceModifier — this is not a wave, it is a hold and a snap.
"spot": 1.0,
"head_yaw": 0.0,
"arm_out": 1.15,
"arm_swing": 0.25,
"arm_beats": 4.0,
"elbow": 0.35,
"knee": 0.22,
},
]
static func count() -> int:
return ROUTINES.size()
## A routine by index, wrapped so an out-of-range network value cannot crash a
## remote peer's model.
static func get_routine(index: int) -> Dictionary:
if ROUTINES.is_empty():
return {}
return ROUTINES[posmod(index, ROUTINES.size())]
static func name_of(index: int) -> String:
return String(get_routine(index).get("name", ""))
static func index_of(id: String) -> int:
for i in ROUTINES.size():
if ROUTINES[i]["id"] == id:
return i
return 0
+1
View File
@@ -0,0 +1 @@
uid://8p4yow40ni8p
+56
View File
@@ -0,0 +1,56 @@
extends Object
class_name RigRoles
## Canonical bone names -> this rig's actual bone indices.
##
## Pulled out of `ShooterPoseModifier._resolve` so the dance layer can use the
## same resolution instead of carrying a second copy of it. Two copies is how a
## rig ends up animating correctly under one modifier and not the other.
##
## Non-negotiable: never look a bone up by name. `tools/rig_map.py` resolves
## every rig to ROLES and writes them to the `<model>.rig.json` sidecar, and this
## reads that. The name fallback below exists only for a model with no sidecar,
## and it must never be the first thing tried — four characters could not hold a
## gun because of exactly one hardcoded spelling.
## The library skeleton's spine, hips first. A rig that kept its own names maps
## onto this through the sidecar's `spine` chain.
const SPINE := ["DEF-hips", "DEF-spine.001", "DEF-spine.002", "DEF-spine.003"]
## Everything either pose layer asks for.
const CANONICAL := SPINE + ["DEF-neck", "DEF-head",
"DEF-upper_arm.R", "DEF-forearm.R", "DEF-hand.R",
"DEF-upper_arm.L", "DEF-forearm.L", "DEF-hand.L",
"DEF-thigh.R", "DEF-shin.R", "DEF-thigh.L", "DEF-shin.L"]
## Resolve `names` against a skeleton, given the sidecar's role table.
##
## The spine needs its own handling because a rig that kept its own skeleton
## names things differently AND has a different number of spine bones — Taila's
## hips are `DEF-spine` and her head is `DEF-spine.006`, and she has no bone with
## "neck" in its name at all. Unresolved, every lean and aim pitch silently did
## nothing.
static func resolve(skel: Skeleton3D, roles: Dictionary,
names: Array = CANONICAL) -> Dictionary:
var alias := {}
if not roles.is_empty():
var neck: String = roles.get("neck", "")
var head: String = roles.get("head", "")
var torso: Array = []
for n in roles.get("spine", []):
if n != neck and n != head:
torso.append(n)
for i in mini(torso.size(), SPINE.size() - 1):
alias[SPINE[i + 1]] = torso[i]
var idx := {}
for n in names:
# Canonical names are the role keys with the DEF- prefix, so the limbs,
# hips, neck and head all map straight through.
var actual: String = alias.get(n, roles.get(String(n).trim_prefix("DEF-"), n))
var b := skel.find_bone(actual)
if b < 0:
b = skel.find_bone(String(n))
idx[n] = b
return idx
+1
View File
@@ -0,0 +1 @@
uid://coh341vhya5x5
+167 -43
View File
@@ -96,7 +96,12 @@ var _rig_info: Dictionary = {}
## the same sidecar; drives the per-class cel look and answers `surfaces_of()`. ## 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():
+13 -1
View File
@@ -169,7 +169,19 @@ static func all_knobs() -> Array:
## ##
## Across ALL poses, not just the one on screen: a reset or a save has to know ## 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)
+1 -1
View File
@@ -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:
+435
View File
@@ -0,0 +1,435 @@
extends SceneTree
## Do the five emotes move the character, differ from each other, and OVERLAP?
##
## godot --path . -s res://debug/dance_check.gd
##
## Three properties, and the third is the one worth checking. "It moves" and
## "they are different" are easy to satisfy by accident — five sine waves at five
## frequencies would pass both and would still look like programmer animation.
## What separates a dance from an oscillation is that the body moves as a CHAIN:
## the hips lead and the head arrives later. That is measurable, so it is.
##
## Sampled from inside the modifier pass, like every other pose check here.
## Outside it Godot restores the local poses and what gets measured is the
## animation clip alone — every routine would report identical motion whether or
## not the dance layer exists at all.
const CAPTURE_BEATS := 4.0
const SAMPLES := 90
## A routine has to move the character at least this far, in metres of total
## head travel over the sample window. Below this it is not an emote.
const MIN_TRAVEL := 0.05
## Two routines must differ by at least this, comparing their per-frame pose
## trajectories.
const MIN_DISTINCT := 0.02
var _fails := 0
var _probe: Probe = null
class Probe extends SkeletonModifier3D:
var pose: Array = []
## Each bone's OWN local pose rotation, which is what a phase measurement
## needs. A bone's GLOBAL rotation contains every ancestor's rotation too, so
## the head's global carries the hips' un-lagged swing as a large component
## and correlates with it at a lag of zero no matter how much the head itself
## is delayed. Measuring globals reported Two-Step as having no overlap at
## all when its head is delayed by five links.
var local: Array = []
func _process_modification() -> void:
var skel := get_skeleton()
if skel == null:
return
pose.resize(skel.get_bone_count())
local.resize(skel.get_bone_count())
for i in skel.get_bone_count():
pose[i] = skel.get_bone_global_pose(i)
local[i] = skel.get_bone_pose_rotation(i)
func _init() -> void:
await process_frame
var mgr = root.get_node_or_null("SkinManager")
var skin = mgr.get_skin("taila") if mgr else null
if skin == null or skin.model_path == "":
print("dance_check: no rigged skin to test with")
quit(1)
return
var model := SkinnedPlayerModel.new()
root.add_child(model)
model.skin_id = "taila"
model.load_model(skin.model_path)
for _i in 60:
model.update_state("idle", 0.0, false)
await process_frame
if model._dance_mod == null:
_expect(false, "the model built a dance layer")
_done()
return
var skel: Skeleton3D = model.skeleton
_probe = Probe.new()
skel.add_child(_probe)
skel.move_child(_probe, skel.get_child_count() - 1)
_expect(DanceRoutines.count() == 5,
"there are five emotes (%d)" % DanceRoutines.count())
var tracks := {}
for i in DanceRoutines.count():
tracks[i] = await _sample(model, skel, i)
_report(tracks)
_compare(tracks)
_done()
## One routine's trajectory: the hips' and head's positions, per frame, in the
## character's own space, plus the elbow angles for the joint-limit check.
func _sample(model, skel: Skeleton3D, index: int) -> Dictionary:
model.set_dancing(true, index)
# Let the blend arrive fully before recording, or the first routine sampled
# reports smaller motion than the rest purely because it was still fading in.
for _i in 40:
model.update_state("idle", 0.0, false)
await process_frame
var mod = model._pose_mod
var hips: int = mod._idx.get("DEF-hips", -1)
var head: int = mod._idx.get("DEF-head", -1)
# Overlap is measured between two links of the SAME chain, not between the
# hips and the head.
#
# Every spine link is driven by the same channels (`spine_roll`, `spine_yaw`,
# `spine_pitch`) at `beat - lag * i`, so the only difference between them IS
# the lag. The hips and the head are driven by DIFFERENT channels, often at
# different periods — Two-Step's hips roll on a two-beat cycle while its head
# bobs on a one-beat one — so correlating those two compares signals that do
# not have a phase relationship to find.
var link_a: int = mod._idx.get("DEF-spine.001", -1)
var link_b: int = mod._idx.get("DEF-spine.003", -1)
var fa_r: int = mod._idx.get("DEF-forearm.R", -1)
var ua_r: int = mod._idx.get("DEF-upper_arm.R", -1)
var hand_r: int = mod._idx.get("DEF-hand.R", -1)
var hip_track: Array = []
var head_track: Array = []
# The SAME quantity at two points in the chain: how far each bone has been
# rotated away from its own rest pose, signed. Correlating the hips' world
# TRANSLATION against the head's position RELATIVE to the hips was comparing
# two different physical quantities driven by different channels at different
# periods, and the peak landed anywhere — it reported the Robot, whose lag is
# zero by construction, as the most overlapped routine in the set.
var hip_rot: Array = []
var head_rot: Array = []
var worst_elbow := 180.0
for _i in SAMPLES:
model.update_state("idle", 0.0, false)
await process_frame
var pose: Array = _probe.pose
if pose.size() <= maxi(hips, head) or hips < 0 or head < 0:
continue
var origin: Vector3 = pose[hips].origin
hip_track.append(origin)
head_track.append(pose[head].origin - origin)
hip_rot.append(_twist(skel, _probe.local, link_a))
head_rot.append(_twist(skel, _probe.local, link_b))
# The elbow must never open past straight. A signed wave on a forearm
# bends it backwards through the joint on half of every cycle, which is
# the single most obvious tell in procedural animation.
if ua_r >= 0 and fa_r >= 0 and hand_r >= 0 and pose.size() > hand_r:
var upper: Vector3 = (pose[ua_r].origin - pose[fa_r].origin).normalized()
var lower: Vector3 = (pose[hand_r].origin - pose[fa_r].origin).normalized()
worst_elbow = minf(worst_elbow, rad_to_deg(acos(clampf(
upper.dot(lower), -1.0, 1.0))))
model.set_dancing(false)
for _i in 30:
model.update_state("idle", 0.0, false)
await process_frame
return {"hips": hip_track, "head": head_track, "elbow": worst_elbow,
"hip_rot": hip_rot, "head_rot": head_rot}
func _report(tracks: Dictionary) -> void:
print("\n=== EMOTES ===")
for i in tracks:
var t: Dictionary = tracks[i]
var travel := _travel(t["head"])
var hip_travel := _travel(t["hips"])
var lag := _lag(t["hip_rot"], t["head_rot"])
print(" %-12s head %.3f m hips %.3f m upper spine lags lower by %d frames min elbow %.0f deg"
% [DanceRoutines.name_of(i), travel, hip_travel, lag, t["elbow"]])
func _compare(tracks: Dictionary) -> void:
for i in tracks:
var t: Dictionary = tracks[i]
var nm := DanceRoutines.name_of(i)
_expect(_travel(t["head"]) >= MIN_TRAVEL,
"%s actually moves the character (%.3f m)" % [nm, _travel(t["head"])])
# 8 degrees of slack: the IK and the idle clip underneath both contribute,
# and an elbow that never quite straightens is correct anyway.
_expect(t["elbow"] >= 8.0,
"%s never hyperextends the elbow (min %.0f deg)" % [nm, t["elbow"]])
var ids: Array = tracks.keys()
for i in ids.size():
for j in range(i + 1, ids.size()):
var d := _difference(tracks[ids[i]]["head"], tracks[ids[j]]["head"])
_expect(d >= MIN_DISTINCT,
"%s and %s are different dances (%.3f)"
% [DanceRoutines.name_of(ids[i]), DanceRoutines.name_of(ids[j]), d])
# OVERLAP. The head must trail the hips, because the body is a chain — this
# is the property that separates a dance from five bones oscillating in
# phase, and it is the whole reason `lag` exists in the routine data.
#
# The robot is exempt and deliberately so: its lag is zero on purpose, which
# is what makes it read as mechanical against the other four.
for i in tracks:
var rid: String = String(DanceRoutines.get_routine(i).get("id", ""))
# Robot: lag zero by construction, which is the point of it.
# Spin: the head SPOTS — it holds its heading against the turn and whips
# round to catch up, so it is deliberately not a delayed copy of the
# hips. Asserting that it follows them would be asserting the opposite of
# the technique.
if rid == "robot" or rid == "spin":
continue
var lag := _lag(tracks[i]["hip_rot"], tracks[i]["head_rot"])
_expect(lag > 0,
"%s moves as a chain — the upper spine trails the lower by %d frames"
% [DanceRoutines.name_of(i), lag])
# The Robot's own property is that its motion is QUANTISED: it holds a pose
# and jumps, where the others move continuously. That is what `steps` in the
# routine data produces and what makes it read as mechanical against the
# other four.
#
# Its LAG is deliberately not asserted. Zero lag ought to correlate perfectly
# at shift 0, but the signal is a staircase with 16-frame plateaus, so many
# shifts score nearly identically and the measured peak wanders — it reported
# 21 frames. Asserting a number the measurement cannot resolve would be
# asserting noise; the hold fraction below is the property that is actually
# there.
var robot := DanceRoutines.index_of("robot")
var robot_step := _step_size(tracks[robot]["head_rot"])
for i in tracks:
if i == robot:
continue
var other := _step_size(tracks[i]["head_rot"])
_expect(robot_step > other * 1.5,
"Robot JUMPS between poses where %s flows (%.2f vs %.2f of range per frame)"
% [DanceRoutines.name_of(i), robot_step, other])
## The largest single-frame change, as a fraction of the track's whole range.
##
## This is what quantised motion looks like from the outside: long flat stretches
## punctuated by one big jump. A smooth wave never moves more than a few percent
## of its range in a frame however punchy its easing.
##
## Measured as a JUMP rather than as time-spent-still, which was the first
## attempt and does not separate them: a shaped wave hangs at its extremes by
## design, so Two-Step scored the same 0.97 "holding" as the Robot did. The
## routines differ in HOW THEY LEAVE a pose, not in how long they sit in one.
func _step_size(rot_track: Array) -> float:
var track := _project(rot_track)
var n := track.size()
if n < 4:
return 0.0
var lo := 1e30
var hi := -1e30
for v in track:
lo = minf(lo, v)
hi = maxf(hi, v)
var span: float = hi - lo
if span < 0.000001:
return 0.0
var biggest := 0.0
for i in range(1, n):
biggest = maxf(biggest, absf(track[i] - track[i - 1]))
return biggest / span
## Total path length of a track.
func _travel(track: Array) -> float:
var sum := 0.0
for i in range(1, track.size()):
sum += (track[i] as Vector3).distance_to(track[i - 1])
return sum
## Mean per-frame distance between two tracks, after removing each one's own
## average position — otherwise two identical dances at different heights would
## read as different, and two different dances at the same height as the same.
func _difference(a: Array, b: Array) -> float:
var n := mini(a.size(), b.size())
if n == 0:
return 0.0
var ca := Vector3.ZERO
var cb := Vector3.ZERO
for i in n:
ca += a[i]
cb += b[i]
ca /= float(n)
cb /= float(n)
var sum := 0.0
for i in n:
sum += ((a[i] - ca) - (b[i] - cb)).length()
return sum / float(n)
## How many frames the head's rotation trails the hips', by NORMALISED
## cross-correlation.
##
## Both signals are the same quantity — a bone's rotation away from its own rest
## pose — sampled at two ends of the same chain, so the only thing that can
## differ between them is timing. That is the whole point: an unnormalised
## correlation between two DIFFERENT quantities peaks wherever their amplitudes
## happen to line up, which reported the Robot (lag zero by construction) as the
## most overlapped routine in the set.
##
## Pearson, so amplitude cannot influence where the peak falls — a head that
## moves further than the hips must not read as a head that moves later.
func _lag(hips: Array, head: Array) -> int:
# BOTH ends projected onto the HIPS' axis, not each onto its own.
#
# Overlap is "the same motion, later", so the measurement has to be of the
# same motion. Projecting each end onto its own dominant axis compares
# whatever channel happens to dominate at that end, and routines drive
# different channels at the two ends: Two-Step's hips are dominated by a
# two-beat roll while its head is dominated by a one-beat bob, so the
# correlation was between signals of different PERIOD and peaked wherever.
var axis := _dominant_axis(hips)
var a := _centre(_project(hips, axis))
var b := _centre(_project(head, axis))
var n := mini(a.size(), b.size())
if n < 16:
return 0
var best := 0
var best_score := -1e30
# Out to half the window. The correlation of a periodic signal repeats every
# period, so the search must stay inside one; Body Wave has the largest lag
# in the set by design (0.13 s per link over five links, most of a beat at
# 88 bpm) and a short window could not see it at all.
for shift in range(0, n / 2):
var sum := 0.0
var na := 0.0
var nb := 0.0
for i in range(0, n - shift):
sum += a[i] * b[i + shift]
na += a[i] * a[i]
nb += b[i + shift] * b[i + shift]
if na < 0.000001 or nb < 0.000001:
continue
var score: float = sum / sqrt(na * nb)
if score > best_score:
best_score = score
best = shift
return best
## Mean-removed copy of a scalar track.
func _centre(track: Array) -> Array:
var n := track.size()
if n == 0:
return []
var mean := 0.0
for v in track:
mean += v
mean /= float(n)
var out: Array = []
for v in track:
out.append(v - mean)
return out
## How far a bone has been rotated away from its rest pose, as a ROTATION VECTOR
## (axis times angle).
##
## A vector, not a signed scalar. The first version returned `angle * sign of the
## axis's largest component`, and that is discontinuous: as a rocking bone passes
## back through its rest pose the angle goes to zero and the axis FLIPS, so the
## signal jumped the full width of its range in a single frame. Two-Step measured
## a per-frame step of 0.99 of its own range — which looked exactly like the
## quantised motion the Robot is supposed to have exclusively, on a routine that
## is perfectly smooth.
##
## The rotation vector passes through zero and comes out the other side pointing
## the opposite way, which is continuous, and projecting it onto a fixed axis
## afterwards gives the signed wave the analysis actually wants.
func _twist(skel: Skeleton3D, local: Array, idx: int) -> Vector3:
if idx < 0 or idx >= local.size():
return Vector3.ZERO
# The bone's OWN rotation away from its rest — not its global, which carries
# every ancestor's along with it. See Probe.local.
var rest: Quaternion = skel.get_bone_rest(idx).basis.get_rotation_quaternion()
var d := (rest.inverse() * (local[idx] as Quaternion)).normalized()
# Shortest arc, so a rotation just past 180 degrees does not read as one just
# under -180.
if d.w < 0.0:
d = Quaternion(-d.x, -d.y, -d.z, -d.w)
var ang := d.get_angle()
if ang < 0.000001:
return Vector3.ZERO
return d.get_axis() * ang
## The axis a track of rotation vectors varies most about.
func _dominant_axis(track: Array) -> Vector3:
var n := track.size()
if n == 0:
return Vector3.ZERO
var mean := Vector3.ZERO
for v in track:
mean += v
mean /= float(n)
var axis := Vector3.ZERO
var best := 0.0
for v in track:
var d: Vector3 = v - mean
if d.length() > best:
best = d.length()
axis = d
return axis.normalized() if axis.length() > 0.000001 else Vector3.ZERO
## A track of rotation vectors flattened to one signed scalar per frame, along
## `axis` — or along the track's own dominant axis if none is given.
func _project(track: Array, axis: Vector3 = Vector3.ZERO) -> Array:
var n := track.size()
if n == 0:
return []
var use := axis if axis.length() > 0.000001 else _dominant_axis(track)
if use.length() < 0.000001:
return []
var mean := Vector3.ZERO
for v in track:
mean += v
mean /= float(n)
var out: Array = []
for v in track:
out.append((v - mean).dot(use))
return out
func _expect(ok: bool, what: String) -> void:
if ok:
print(" OK: ", what)
else:
print(" FAIL: ", what)
_fails += 1
func _done() -> void:
print("\n=== DANCE SUMMARY ===")
print("Failures: %d" % _fails)
quit(1 if _fails > 0 else 0)
+1
View File
@@ -0,0 +1 @@
uid://tds0trjfr2as
+54
View File
@@ -0,0 +1,54 @@
extends SceneTree
## Photograph the radial emote dial, resting and with a wedge aimed at.
##
## godot --path . --windowed --resolution 1280x720 \
## -s res://debug/emote_wheel_capture.gd -- <out_dir>
##
## Two shots, because the hover state is the one that goes wrong: a wedge whose
## fill changes without its label changing with it is the exact failure the
## theme's contrast work exists to prevent, and it is invisible in a shot of the
## wheel at rest.
var _out := "."
func _initialize() -> void:
var args := OS.get_cmdline_user_args()
if args.size() > 0:
_out = String(args[0])
_run()
func _run() -> void:
await process_frame
UITheme.apply_global(self)
var layer := CanvasLayer.new()
root.add_child(layer)
# Something behind it, so the ink edges are being judged against a real
# backdrop rather than against black.
var back := ColorRect.new()
back.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
back.color = Color(0.42, 0.55, 0.72)
layer.add_child(back)
var wheel := EmoteWheel.new()
layer.add_child(wheel)
wheel.open()
for _i in 30:
await process_frame
await process_frame
root.get_texture().get_image().save_png("%s/emote_wheel_rest.png" % _out)
print("emote_wheel_capture: saved rest")
# Aim up-and-right, which lands on the second wedge.
wheel.aim_by_vector(Vector2(0.7, -0.7))
for _i in 12:
await process_frame
await process_frame
root.get_texture().get_image().save_png("%s/emote_wheel_hover.png" % _out)
print("emote_wheel_capture: saved hover, picked=%d" % wheel._hover)
quit(0)
+1
View File
@@ -0,0 +1 @@
uid://cxxajtpm3o8bj
+1 -1
View File
@@ -41,7 +41,7 @@ func _process(_delta: float) -> bool:
if _frames == 40: 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):
+157
View File
@@ -0,0 +1,157 @@
extends SceneTree
## Do the game modes end, and end on the RIGHT thing?
##
## godot --headless --path . -s res://debug/game_mode_check.gd
##
## `GameMode.check_win` is a pure function of the stats and the clock, which is
## the point of writing it that way: a win condition that can only be exercised
## by playing a whole match is a win condition nobody tests, and this project's
## previous one — a five-minute timer that set `match_active = false` and did
## nothing else — was never exercised at all.
##
## The cases below are the ones that actually go wrong: a draw reported as a win
## for whoever happens to be first in a dictionary, a team mode decided on one
## player's score instead of the team's, and a team kill counting toward the
## killer.
var _fails := 0
func _init() -> void:
# Autoloads are not up during `_init`; LoadoutManager is needed below.
await process_frame
_check_table()
_check_deathmatch()
_check_team()
_check_gun_game()
_check_standings()
_done()
## Every mode must be able to end. A mode with no score limit and no time limit
## runs forever, and that is the failure that produced this whole task.
func _check_table() -> void:
for id in GameMode.all_ids():
_expect(GameMode.is_valid(id),
"'%s' can end (score %d, time %.0fs)"
% [id, GameMode.score_limit(id), GameMode.time_limit(id)])
_expect(GameMode.display_name(id) != "",
"'%s' has a display name" % id)
_expect(GameMode.score_limit(GameMode.GUN_GAME) == GameMode.LADDER.size(),
"Gun Game's limit IS its ladder length (%d), so adding a rung cannot make the match unwinnable"
% GameMode.LADDER.size())
func _check_deathmatch() -> void:
var id := GameMode.DEATHMATCH
var limit := GameMode.score_limit(id)
var mid := {1: _p("A", limit - 1), 2: _p("B", 3)}
_expect(GameMode.check_win(id, mid, 120.0).is_empty(),
"deathmatch keeps running one short of the limit")
var done := {1: _p("A", limit), 2: _p("B", 3)}
var r := GameMode.check_win(id, done, 120.0)
_expect(r.get("reason", "") == "score" and r.get("name", "") == "A",
"deathmatch ends when someone reaches %d, and A won" % limit)
# On time, the leader takes it.
var timed := GameMode.check_win(id, {1: _p("A", 7), 2: _p("B", 4)}, 0.0)
_expect(timed.get("reason", "") == "time" and timed.get("name", "") == "A",
"deathmatch on time goes to the leader")
# A DRAW is a draw. Reporting a winner here means reporting whichever key
# the dictionary happened to hand over first, which is not a rule.
var drawn := GameMode.check_win(id, {1: _p("A", 5), 2: _p("B", 5)}, 0.0)
_expect(drawn.get("name", "") == "DRAW",
"a tie on time is a DRAW, not a win for whoever is first in the dictionary")
func _check_team() -> void:
var id := GameMode.TEAM_DEATHMATCH
var limit := GameMode.score_limit(id)
# Two on team 1 with half the limit each: the TEAM has won, though neither
# player is close on their own. Deciding this on individual score — which is
# what a per-player check would do — never ends a team match.
var half := int(limit / 2.0)
var stats := {
1: _p("A", half, 1), 2: _p("B", half, 1),
3: _p("C", 4, 2), 4: _p("D", 4, 2),
}
var r := GameMode.check_win(id, stats, 300.0)
_expect(r.get("reason", "") == "score" and r.get("team", 0) == 1,
"team deathmatch is decided by the TEAM's total, not one player's")
var totals := GameMode.team_scores(id, stats)
_expect(totals.get(1, 0) == half * 2 and totals.get(2, 0) == 8,
"team totals add up (%d / %d)" % [totals.get(1, 0), totals.get(2, 0)])
# Balance on join, by count and not by turn — a 4v4 that loses three from one
# side must refill the short side, which round-robin on join order does not.
var lopsided := {1: _p("A", 0, 1), 2: _p("B", 0, 1), 3: _p("C", 0, 1)}
_expect(GameMode.assign_team(id, lopsided) == 2,
"a joining player goes to the SHORT team")
var tied := GameMode.check_win(id, {1: _p("A", 5, 1), 2: _p("B", 5, 2)}, 0.0)
_expect(tied.get("name", "") == "DRAW", "level teams on time is a DRAW")
func _check_gun_game() -> void:
var id := GameMode.GUN_GAME
var top := GameMode.LADDER.size()
_expect(GameMode.check_win(id, {1: _p("A", top - 1)}, 300.0).is_empty(),
"gun game keeps running on the last rung")
var r := GameMode.check_win(id, {1: _p("A", top)}, 300.0)
_expect(r.get("reason", "") == "score",
"gun game ends when someone finishes the ladder")
# Every rung must name a weapon that exists, or a promotion hands the player
# nothing and the mode softlocks on that rung.
#
# Reached through the tree rather than by the autoload's global identifier: a
# `-s` tool script compiles its dependencies BEFORE autoloads register, so
# naming `LoadoutManager` directly stops this file from loading at all.
var loadouts = root.get_node_or_null("LoadoutManager")
for i in GameMode.LADDER.size():
var w := GameMode.ladder_weapon(i)
_expect(loadouts != null and loadouts.weapon_db.has(w),
"ladder rung %d ('%s') is a real weapon" % [i, w])
# Past the end clamps rather than going out of bounds — the winning kill
# promotes past the top rung before the match-end RPC lands.
_expect(GameMode.ladder_weapon(999) == GameMode.LADDER[-1],
"promoting past the top rung clamps instead of erroring")
func _check_standings() -> void:
var stats := {
1: _p("A", 5), 2: _p("B", 9), 3: _p("C", 5),
}
stats[1]["deaths"] = 2
stats[3]["deaths"] = 7
var order := GameMode.standings(stats)
_expect(order[0] == 2, "standings put the highest score first")
_expect(order[1] == 1 and order[2] == 3,
"equal scores break on FEWEST DEATHS (kills are equal by definition there)")
func _p(who: String, score: int, team: int = 0) -> Dictionary:
return {"username": who, "score": score, "kills": score, "deaths": 0,
"assists": 0, "team": team, "best_streak": 0, "rung": score}
func _expect(ok: bool, what: String) -> void:
if ok:
print(" OK: ", what)
else:
print(" FAIL: ", what)
_fails += 1
func _done() -> void:
print("\n=== GAME MODE SUMMARY ===")
print("Failures: %d" % _fails)
quit(1 if _fails > 0 else 0)
+1
View File
@@ -0,0 +1 @@
uid://cjpesfuv0vmcs
+105
View File
@@ -0,0 +1,105 @@
extends SceneTree
## One photograph of a character holding each weapon, from far enough away to
## see the SILHOUETTE.
##
## godot --path . --windowed --resolution 640x900 \
## -s res://debug/hold_capture.gd -- <out_dir> [skin]
##
## The rig lab's camera is a close-up on the hands, which is right for tuning a
## grip and useless for the question this feature exists to answer: can you tell
## what someone is carrying from across a map. That is a whole-body question, so
## this frames the whole body, side-on, where the difference between a shouldered
## tube and a low-ready rifle actually lives.
##
## Side-on and not three-quarter, deliberately. A profile is the harshest test of
## a hold — it shows exactly how far the weapon sits from the shoulder and how
## the head is tilted, with no foreshortening to hide behind.
const WEAPONS := ["m4", "mp7", "awp", "double_barrel_shotgun",
"rocket_launcher", "knife"]
var _out := "."
var _skin := "taila"
func _initialize() -> void:
var args := OS.get_cmdline_user_args()
if args.size() > 0:
_out = String(args[0])
if args.size() > 1:
_skin = String(args[1])
_run()
func _run() -> void:
# `_initialize` runs BEFORE the autoloads' `_ready`, so SkinManager's table is
# still empty here and even its "default" fallback is missing — asking it for
# a skin at this point fails on the fallback rather than on the skin asked
# for, which reads as "no skin 'taila'" and sends you looking in the wrong
# place entirely.
await process_frame
var world := Node3D.new()
root.add_child(world)
var env := WorldEnvironment.new()
env.environment = LevelEnvironment.make_environment("sunset")
world.add_child(env)
var key := DirectionalLight3D.new()
key.rotation_degrees = Vector3(-38, 155, 0)
key.light_energy = 2.0
world.add_child(key)
var fill := DirectionalLight3D.new()
fill.rotation_degrees = Vector3(-20, -40, 0)
fill.light_energy = 0.7
fill.light_color = Color(0.6, 0.7, 1.0)
world.add_child(fill)
var cam := Camera3D.new()
world.add_child(cam)
# Side-on, at chest height, far enough back that the whole figure and the
# whole weapon are in frame.
cam.position = Vector3(3.4, 1.05, 0.15)
cam.look_at(Vector3(0, 0.95, 0))
cam.fov = 42.0
var mgr = root.get_node_or_null("SkinManager")
if mgr == null:
print("hold_capture: no SkinManager")
quit(1)
return
var skin = mgr.get_skin(_skin)
if skin == null:
print("hold_capture: no skin '%s'" % _skin)
quit(1)
return
for weapon in WEAPONS:
var model := SkinnedPlayerModel.new()
model.skin_id = _skin
model.first_person_mode = false
world.add_child(model)
model.load_model(skin.model_path)
for _i in 40:
await process_frame
model.set_weapon("res://weapons/%s.gd" % weapon)
model.update_state("idle", 0.0, false)
# The hold blends in at ~8/s and the weapon measures itself on `ready`,
# so give it several time constants before believing the pose.
for _i in 100:
model.update_state("idle", 0.0, false)
await process_frame
await process_frame
var img := root.get_texture().get_image()
var path := "%s/hold_%s.png" % [_out, weapon]
img.save_png(path)
print("hold_capture: saved ", path)
model.queue_free()
for _i in 4:
await process_frame
quit(0)
+1
View File
@@ -0,0 +1 @@
uid://c25wpt58v328r
+110
View File
@@ -0,0 +1,110 @@
extends SceneTree
## Dev tool: build a PlayerHUD against a stand-in player, let it lay out, and
## print where every element actually landed.
##
## godot --headless --path . -s res://debug/hud_layout_check.gd
##
## A HUD element that lands at (0, 0) with zero size is the single most common
## thing to get wrong here, and it is invisible in a headless smoke test and
## easy to miss in a screenshot. This prints the rects so the answer is a number.
var _frames := 0
var _hud: PlayerHUD
func _initialize() -> void:
var stand_in := CharacterBody3D.new()
stand_in.name = "StandInPlayer"
root.add_child(stand_in)
_hud = PlayerHUD.new()
_hud.player = stand_in
root.add_child(_hud)
func _process(_delta: float) -> bool:
_frames += 1
# A hidden Control keeps its last rect and contributes nothing to its
# container's minimum size, so measuring the ammo card while the stand-in
# player holds no weapon reads a stale position from a collapsed column and
# reports a layout bug that is not there. Force the states that matter to be
# visible, then give the containers a frame to re-sort before measuring.
if _frames == 4:
# Freeze the HUD first. Its own `_process` hides the ammo card whenever
# the player holds no weapon, so anything forced visible here is undone
# again before the measurement and what gets read is the stale rect of a
# hidden control inside a collapsed column.
_hud.set_process(false)
_hud._ammo_card.visible = true
_hud._ammo_num.text = "30"
_hud._ammo_max.text = "/ 30"
_hud._weapon_name.text = "DOUBLE BARREL SHOTGUN"
_hud._chain_row.visible = true
for pip in _hud._grenade_row.get_children():
(pip as Control).visible = true
if _frames < 8:
return false
print("viewport = ", root.get_visible_rect().size)
if OS.has_environment("HUD_TREE"):
_dump(_hud, 0)
var fails := 0
for node in _hud.find_children("*", "Control", true, false):
var c := node as Control
# Only the elements that are supposed to occupy space. Containers that
# legitimately shrink to their content are not interesting.
if c.name in ["Crosshair", "ReloadRing", "DeathScreen"]:
var r := c.get_global_rect() # GLOBAL: get_rect() is parent-relative and lies about where a nested element really sits
print(" %-12s pos=%s size=%s" % [c.name, r.position, r.size])
if r.size.x < 100.0 or r.size.y < 100.0:
print(" FAIL: expected to fill the viewport")
fails += 1
# The ammo card, which must hug the RIGHT edge inside its margin. It sat
# flush against the left instead, because a VBoxContainer hands each child
# its own horizontal placement and the alignment flag was on the column.
var vr := root.get_visible_rect().size
var card := _find_by_class("PanelContainer")
if card == null:
print(" MISSING ammo card")
fails += 1
else:
var r := card.get_global_rect()
var right_gap := vr.x - (r.position.x + r.size.x)
print(" %-12s pos=%s size=%s right_gap=%.0f"
% ["ammo", r.position, r.size, right_gap])
if r.size.x < 60.0:
print(" FAIL: collapsed to nothing")
fails += 1
elif right_gap < 0.0:
print(" FAIL: runs off the right edge of the screen")
fails += 1
elif right_gap > 120.0:
print(" FAIL: not anchored to the right edge (gap %.0f)" % right_gap)
fails += 1
print("HUD LAYOUT: %s" % ("PASS" if fails == 0 else "FAIL (%d)" % fails))
quit(1 if fails > 0 else 0)
return true
## The whole Control tree with GLOBAL rects. `HUD_TREE=1` to switch on — when a
## container collapses, the answer is always visible in its parent chain and
## never in the leaf you noticed the problem on.
func _dump(node: Node, depth: int) -> void:
for child in node.get_children():
if child is Control:
var c := child as Control
var r := c.get_global_rect()
print("%s%s [%s] pos=%s size=%s vis=%s"
% [" ".repeat(depth + 1), c.name, c.get_class(),
r.position, r.size, c.visible])
_dump(child, depth + 1)
func _find_by_class(cls: String) -> Control:
for n in _hud.find_children("*", cls, true, false):
return n as Control
return null
+1
View File
@@ -0,0 +1 @@
uid://c340r7urelhhx
+201
View File
@@ -0,0 +1,201 @@
extends SceneTree
## Photograph every map, for the level select cards.
##
## godot --path . --windowed --resolution 640x400 \
## -s res://debug/map_preview_capture.gd
##
## Writes assets/ui/map_previews/<folder>.png, which the menu picks up by
## convention — no map_meta key to add, so a new map that ships a preview gets
## one and a map that does not falls back to its gradient.
##
## The cards used to be a two-stop gradient built from `color1` and `color2` in
## the map's meta file. That tells a player which card they clicked last time and
## nothing whatsoever about the map, which is the entire job of a level select.
##
## ── Framing ─────────────────────────────────────────────────────────────────
##
## The camera stands INSIDE the map, at a spawn point, at eye height, looking
## across it.
##
## The first version framed the whole level from outside by merging every
## VisualInstance3D's AABB and backing off until it fit. Every map came out as a
## tiny diorama floating on a table in the middle of an empty sky — and one came
## out entirely black, because from orbit there was nothing lit in frame. That is
## a minimap, and a minimap is a different thing from a photograph.
##
## What a player wants off a card is the FEEL of a place: its light, its colour,
## how enclosed it is, what the skyline looks like. All of that only exists from
## where a player will actually stand, so that is where the camera goes — and
## spawn points are already authored in every map, so it costs nothing.
const OUT_DIR := "res://assets/ui/map_previews"
const MAPS_DIR := "res://scenes/maps/"
## How long to let a map build itself. The procedural ones generate geometry in
## `_ready` and the neon map streams in props, so a shot taken too early is a
## photograph of an empty skybox.
const SETTLE_FRAMES := 90
func _initialize() -> void:
_run()
func _run() -> void:
await process_frame
DirAccess.make_dir_recursive_absolute(ProjectSettings.globalize_path(OUT_DIR))
for folder in _map_folders():
var cfg := ConfigFile.new()
if cfg.load(MAPS_DIR + folder + "/map_meta.cfg") != OK:
continue
var scene_path: String = cfg.get_value("map", "scene_path", "")
if scene_path == "" or not ResourceLoader.exists(scene_path):
continue
var packed: PackedScene = load(scene_path)
if packed == null:
print("map_preview: could not load ", scene_path)
continue
var level: Node = packed.instantiate()
root.add_child(level)
for _i in SETTLE_FRAMES:
await process_frame
var shot: Image = await _best_shot(level)
var path := "%s/%s.png" % [OUT_DIR, folder]
if shot == null:
print("map_preview: no usable vantage in ", folder)
else:
shot.save_png(ProjectSettings.globalize_path(path))
print("map_preview: saved ", path)
level.queue_free()
for _i in 6:
await process_frame
quit(0)
func _map_folders() -> Array:
var out: Array = []
var dir := DirAccess.open(MAPS_DIR)
if dir == null:
return out
dir.list_dir_begin()
var name := dir.get_next()
while name != "":
if dir.current_is_dir() and not name.begins_with("."):
if FileAccess.file_exists(MAPS_DIR + name + "/map_meta.cfg"):
out.append(name)
name = dir.get_next()
out.sort()
return out
## Try several vantages and keep the one that produces the best PICTURE.
##
## Not the one that satisfies a rule about where cameras should go. Two maps
## defeated every positional rule tried: fps_blockout is a genuinely dark map
## where most spawns face an unlit wall, and procedural_arena builds its geometry
## at runtime so no fixed offset is inside it. Both came out as black rectangles,
## and a black rectangle passes any check that asks "did the camera end up
## somewhere sensible".
##
## So the tool renders each candidate and SCORES the result. The score wants an
## image that is both bright and varied: mean luminance alone picks a shot of the
## empty sky, and variance alone picks a high-contrast corner of a dark room.
## Their product picks a photograph.
func _best_shot(level: Node):
var bounds := _bounds(level)
var centre := bounds.get_center() if bounds.size.length() > 0.01 else Vector3.ZERO
var extent: float = maxf(maxf(bounds.size.x, bounds.size.z), 8.0)
var cam := Camera3D.new()
level.add_child(cam)
cam.far = maxf(bounds.size.length() * 2.0, 800.0)
# A little wider than the game's own view, so a card shows more of the space
# than a screenshot of play would.
cam.fov = 68.0
cam.current = true
var best_img: Image = null
var best_score := -1.0
for vantage in _vantages(centre, extent):
cam.global_position = vantage[0]
var look: Vector3 = vantage[1]
if cam.global_position.distance_to(look) < 1.0:
continue
cam.look_at(look)
# Two frames: one for the transform, one for the frame drawn with it.
for _i in 3:
await process_frame
var img := root.get_texture().get_image()
var score := _score(img)
if score > best_score:
best_score = score
best_img = img
cam.queue_free()
return best_img
## Candidate camera placements, as `[eye, look_at]` pairs.
##
## Every authored spawn point, plus a raised view from each side of the map for
## the levels that generate themselves and have no spawns by the time this runs.
func _vantages(centre: Vector3, extent: float) -> Array:
var out: Array = []
for s in get_nodes_in_group("spawn_points"):
if s is Node3D:
var p: Vector3 = (s as Node3D).global_position
out.append([p + Vector3.UP * 2.2, centre + Vector3.UP * 1.6])
# Raised corners, looking in. Higher than eye level, because a generated
# arena's floor is not necessarily at the origin and standing "on" it is a
# guess where looking down at it is not.
for dir in [Vector3(1, 0, 1), Vector3(-1, 0, 1), Vector3(1, 0, -1),
Vector3(-1, 0, -1)]:
var d: Vector3 = dir.normalized()
out.append([centre + d * extent * 0.42 + Vector3.UP * extent * 0.22,
centre + Vector3.UP * extent * 0.04])
return out
## How good a picture is: bright AND varied. See `_best_shot`.
func _score(img: Image) -> float:
var small := img.duplicate() as Image
small.resize(40, 24, Image.INTERPOLATE_BILINEAR)
var n := 40 * 24
var mean := 0.0
for y in 24:
for x in 40:
var c := small.get_pixel(x, y)
mean += 0.2126 * c.r + 0.7152 * c.g + 0.0722 * c.b
mean /= float(n)
var variance := 0.0
for y in 24:
for x in 40:
var c := small.get_pixel(x, y)
var l := 0.2126 * c.r + 0.7152 * c.g + 0.0722 * c.b
variance += (l - mean) * (l - mean)
variance /= float(n)
return mean * sqrt(variance)
func _bounds(node: Node) -> AABB:
var out := AABB()
var any := false
for child in node.find_children("*", "VisualInstance3D", true, false):
var vi := child as VisualInstance3D
# Skip anything enormous: a WorldEnvironment's sky or a directional
# light's own AABB would swallow the map and push the camera to orbit.
var box := vi.get_aabb()
if box.size.length() > 100000.0 or box.size.length() < 0.001:
continue
box = vi.global_transform * box
if not any:
out = box
any = true
else:
out = out.merge(box)
return out if any else AABB()
+1
View File
@@ -0,0 +1 @@
uid://esvuwcyncpeb
+65
View File
@@ -0,0 +1,65 @@
extends SceneTree
## One photograph of every main-menu screen, including the lobby.
##
## godot --path . --windowed --resolution 1280x720 \
## -s res://debug/menu_capture.gd -- <out_dir>
##
## The lobby is the reason this exists. It only appears after hosting, so it is
## the screen least likely to be looked at and the one where an unstyled list or
## a collapsed card would sit unnoticed — which is exactly the class of defect
## that this project keeps finding by LOOKING rather than by asserting.
const MENU := "res://ui/main_menu/main_menu.tscn"
var _out := "."
func _initialize() -> void:
var args := OS.get_cmdline_user_args()
if args.size() > 0:
_out = String(args[0])
_run()
func _run() -> void:
await process_frame
change_scene_to_file(MENU)
for _i in 60:
await process_frame
var menu := current_scene
if menu == null:
print("menu_capture: no menu")
quit(1)
return
await _shot("home")
menu._show(menu.LEVELS)
await _shot("levels")
menu._show(menu.MULTIPLAYER)
await _shot("multiplayer")
# Host for real, so the lobby is photographed in the state a player reaches
# it in — host controls enabled, own name in the list — rather than in an
# empty one that would hide a broken player list.
var nm = root.get_node_or_null("NetworkManager")
if nm and nm.host_game() == OK:
menu._on_connection_succeeded()
for _i in 20:
await process_frame
await _shot("lobby")
nm.disconnect_game()
else:
print("menu_capture: could not host, lobby not shot")
quit(0)
func _shot(tag: String) -> void:
for _i in 12:
await process_frame
root.get_texture().get_image().save_png("%s/menu_%s.png" % [_out, tag])
print("menu_capture: saved ", tag)
+1
View File
@@ -0,0 +1 @@
uid://c2fen0sy3bw87
+1 -1
View File
@@ -25,7 +25,7 @@ func _process(_delta: float) -> bool:
sm.set_active_skin("taila") 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
View File
@@ -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
+1 -1
View File
@@ -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")
+1 -1
View File
@@ -56,7 +56,7 @@ func _test_spawn_with_skin(skin_id: String, expect_skinned: bool) -> void:
var nm = root.get_node_or_null("/root/NetworkManager") 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.
+71
View File
@@ -0,0 +1,71 @@
extends SceneTree
## Photograph the end-of-match summary, for a free-for-all and for a team mode.
##
## godot --path . --windowed --resolution 1280x800 \
## -s res://debug/summary_capture.gd -- <out_dir>
##
## Fed a synthetic result rather than a played match, so the screen can be looked
## at without spending ten minutes reaching a frag limit — and so the awkward
## cases (a draw, a long name, a team scoreline) can be put on screen on purpose
## instead of waiting for them to happen.
var _out := "."
func _initialize() -> void:
var args := OS.get_cmdline_user_args()
if args.size() > 0:
_out = String(args[0])
_run()
func _run() -> void:
await process_frame
UITheme.apply_global(self)
var layer := CanvasLayer.new()
root.add_child(layer)
var back := ColorRect.new()
back.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
back.color = Color(0.30, 0.42, 0.62)
layer.add_child(back)
var summary := MatchSummary.new()
layer.add_child(summary)
await _shot(summary, "ffa", {
"reason": "score", "name": "Nicholas", "mode": GameMode.DEATHMATCH,
"standings": [1, 2, 3],
"stats": {
1: _p("Nicholas", 25, 25, 11, 4, 7),
2: _p("Papaya Enjoyer", 19, 19, 17, 2, 4),
3: _p("guest_4471", 8, 8, 24, 9, 2),
}})
await _shot(summary, "team", {
"reason": "time", "team": 2, "name": "MAGENTA TEAM",
"mode": GameMode.TEAM_DEATHMATCH,
"standings": [3, 1, 4, 2],
"stats": {
1: _p("Nicholas", 14, 14, 9, 3, 5, 1),
2: _p("Papaya Enjoyer", 6, 6, 15, 1, 2, 1),
3: _p("guest_4471", 17, 17, 8, 6, 6, 2),
4: _p("bananaboat", 12, 12, 10, 4, 3, 2),
}})
quit(0)
func _shot(summary: MatchSummary, tag: String, result: Dictionary) -> void:
summary.show_result(result)
for _i in 6:
await process_frame
root.get_texture().get_image().save_png("%s/summary_%s.png" % [_out, tag])
print("summary_capture: saved ", tag)
func _p(who: String, score: int, kills: int, deaths: int, assists: int,
streak: int, team: int = 0) -> Dictionary:
return {"username": who, "score": score, "kills": kills, "deaths": deaths,
"assists": assists, "best_streak": streak, "team": team}
+1
View File
@@ -0,0 +1 @@
uid://bnw4gima0hkli
+7 -307
View File
@@ -4,18 +4,7 @@ class_name TestLevelBuilder
## Builds a full test environment from code — parkour geometry, lighting, player, HUD. ## 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
View File
@@ -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")
+229
View File
@@ -0,0 +1,229 @@
extends SceneTree
## Reads the BUILT theme and fails if any state puts text on a fill it cannot be
## read against.
##
## godot --headless --path . -s res://debug/ui_contrast_check.gd
##
## The point is that this interrogates `UITheme.build()` rather than the palette
## constants. A table of colours checked against itself agrees by construction
## and catches nothing; what goes wrong in practice is a stylebox whose fill was
## changed without the matching `font_*_color`, or a state Godot draws that
## nobody remembered exists — `hover_pressed` on a toggle, `font_hover_color` on
## a PopupMenu. Those are exactly what this finds, because it asks the theme what
## it will actually draw.
##
## WCAG 2.1 floors: 4.5:1 for body text, 3:1 for text at or above ~24 px.
##
## ── Two ways text can be legible, and only one of them is a colour pair ───────
##
## A label on a BUTTON sits on a solid chip. The fill is right behind the glyphs
## and nothing else is, so the only thing that can make it readable is the pair,
## and the fix when it fails is to invert the label with the fill. Those are
## checked as `SOLID`.
##
## A label on a PROGRESS BAR does not have one backdrop. It straddles the filled
## part and the empty part, and no single text colour can beat both a hot papaya
## and a near-black at once — the pair is unfixable by construction. What carries
## it is the heavy ink outline the theme puts on every glyph, which is the same
## mechanism that keeps menu text readable straight over the 3D scene, and it is
## the theme's first stated rule for exactly this reason.
##
## So that case is checked as `OUTLINED`, against what actually does the work:
##
## outline vs fill >= 3.0 the outline has to separate from the backdrop
## text vs outline >= 4.5 and the glyph has to separate from its outline
## outline_size >= 4 px thin enough and there is no outline to read
##
## This is a narrower allowance than it looks. It is only granted where the
## backdrop genuinely varies, and it substitutes two ratios for one rather than
## waiving the requirement — an unoutlined label over a bar still fails.
##
## Semi-transparent fills (PANEL, DEAD_FILL) are measured as if opaque. They are
## drawn over the dark 3D scene or over a darker panel, so the opaque reading is
## the pessimistic one for paper-on-dark and the accurate one for ink-on-light.
const FLOOR_BODY := 4.5
const FLOOR_LARGE := 3.0
## An outline thinner than this is a hairline, not a backdrop.
const MIN_OUTLINE := 4
const SOLID := "solid"
const OUTLINED := "outlined"
## class -> [[stylebox, font colour, large?, mode], ...]
##
## Only the pairs Godot really composites. `focus` is drawn OVER `normal` rather
## than instead of it, so it is checked against its own fill (which is the same
## fill) and its own font colour.
##
## Tree has no `font_hovered_color` in Godot 4 — a hovered row keeps `font_color`
## and a hovered-selected one keeps `font_selected_color` — so its hover fills
## are checked against the colours that will really be drawn on them. Asking for
## a colour the control does not have is itself reported, which is how this was
## found.
const PAIRS := {
"Button": [
["normal", "font_color", false, SOLID],
["hover", "font_hover_color", false, SOLID],
["pressed", "font_pressed_color", false, SOLID],
["hover_pressed", "font_hover_pressed_color", false, SOLID],
["focus", "font_focus_color", false, SOLID],
["disabled", "font_disabled_color", false, SOLID],
],
"ItemList": [
["panel", "font_color", false, SOLID],
["hovered", "font_hovered_color", false, SOLID],
["selected", "font_selected_color", false, SOLID],
["selected_focus", "font_selected_color", false, SOLID],
["hovered_selected", "font_selected_color", false, SOLID],
],
"Tree": [
["panel", "font_color", false, SOLID],
["hovered", "font_color", false, SOLID],
["selected", "font_selected_color", false, SOLID],
["hovered_selected", "font_selected_color", false, SOLID],
],
"PopupMenu": [
["panel", "font_color", false, SOLID],
["panel", "font_disabled_color", false, SOLID],
["panel", "font_accelerator_color", false, SOLID],
["hover", "font_hover_color", false, SOLID],
],
"TabContainer": [
["tab_selected", "font_selected_color", false, SOLID],
["tab_unselected", "font_unselected_color", false, SOLID],
["tab_hovered", "font_hovered_color", false, SOLID],
["tab_disabled", "font_disabled_color", false, SOLID],
],
"LineEdit": [
["normal", "font_color", false, SOLID],
["normal", "font_placeholder_color", false, SOLID],
["normal", "selection_color:font_selected_color", false, SOLID],
["read_only", "font_uneditable_color", false, SOLID],
],
# The readout straddles both halves of the bar — see the note above.
"ProgressBar": [
["background", "font_color", false, OUTLINED],
["fill", "font_color", false, OUTLINED],
],
}
## Every button-ish theme type gets Button's pair list, since `_button_look`
## gives them all the same treatment.
const BUTTON_LIKE := ["OptionButton", "MenuButton", "CheckBox", "CheckButton",
"LinkButton"]
func _init() -> void:
var t: Theme = UITheme.build()
var fails: Array = []
var checked := 0
var classes := PAIRS.duplicate()
for cls in BUTTON_LIKE:
classes[cls] = PAIRS["Button"]
for cls in classes:
for pair in classes[cls]:
var sb_name: String = pair[0]
var fg_name: String = pair[1]
var large: bool = pair[2]
var mode: String = pair[3]
if not _has_flat(t, cls, sb_name):
fails.append("%s: no StyleBoxFlat '%s'" % [cls, sb_name])
continue
var bg: Color = _fill_of(t, cls, sb_name)
# "a:b" means the fill comes from colour `a` rather than a stylebox —
# LineEdit's text selection paints a colour, not a box.
var fg_key := fg_name
if ":" in fg_name:
var parts := fg_name.split(":")
if not t.has_color(parts[0], cls):
fails.append("%s: no colour '%s'" % [cls, parts[0]])
continue
bg = t.get_color(parts[0], cls)
fg_key = parts[1]
if not t.has_color(fg_key, cls):
fails.append("%s: no colour '%s'" % [cls, fg_key])
continue
var fg: Color = t.get_color(fg_key, cls)
checked += 1
var floor_ := FLOOR_LARGE if large else FLOOR_BODY
var where := "%s/%s + %s" % [cls, sb_name, fg_key]
if mode == OUTLINED:
fails.append_array(_check_outlined(t, cls, where, bg, fg, floor_))
continue
var ratio := UITheme.contrast(fg, bg)
if ratio < floor_:
fails.append("%s = %.2f:1 (needs %.1f) fill=%s text=%s"
% [where, ratio, floor_, bg.to_html(false), fg.to_html(false)])
# The declared table too, for the pairs no theme entry describes — a Label
# over a panel, the level card's caption over its scrim.
for entry in UITheme.state_table():
var name: String = entry[0]
var fill: Color = entry[1]
var text: Color = entry[2]
var large: bool = entry[3]
checked += 1
var ratio := UITheme.contrast(text, fill)
var floor_ := FLOOR_LARGE if large else FLOOR_BODY
if ratio < floor_:
fails.append("table %s = %.2f:1 (needs %.1f)" % [name, ratio, floor_])
print("UI CONTRAST: %d pairs checked" % checked)
if fails.is_empty():
print("PASS — every state reads")
quit(0)
return
for f in fails:
print(" FAIL ", f)
print("FAIL — %d unreadable state(s)" % fails.size())
quit(1)
## Text whose backdrop varies, read either directly or against its own outline.
##
## The outline is a FALLBACK, not a replacement: where the glyph already beats the
## backdrop on its own the outline is free to be the same colour as that backdrop
## and simply do nothing, which is what a paper readout over the bar's near-black
## trough is. It is only when the direct pair fails — paper over hot papaya — that
## the outline has to carry it, and then both of its ratios must hold.
##
## Returns the failures rather than printing, so the caller keeps the tally.
func _check_outlined(t: Theme, cls: String, where: String, bg: Color, fg: Color,
floor_: float) -> Array:
var out: Array = []
if UITheme.contrast(fg, bg) >= floor_:
return out
if not t.has_color("font_outline_color", cls):
return ["%s: outlined but no font_outline_color" % where]
var edge: Color = t.get_color("font_outline_color", cls)
var width: int = t.get_constant("outline_size", cls) \
if t.has_constant("outline_size", cls) else 0
if width < MIN_OUTLINE:
out.append("%s: outline is %d px, needs %d" % [where, width, MIN_OUTLINE])
var edge_bg := UITheme.contrast(edge, bg)
if edge_bg < FLOOR_LARGE:
out.append("%s: outline vs fill = %.2f:1 (needs %.1f) outline=%s fill=%s"
% [where, edge_bg, FLOOR_LARGE, edge.to_html(false), bg.to_html(false)])
var fg_edge := UITheme.contrast(fg, edge)
if fg_edge < floor_:
out.append("%s: text vs outline = %.2f:1 (needs %.1f) text=%s outline=%s"
% [where, fg_edge, floor_, fg.to_html(false), edge.to_html(false)])
return out
## Whether the theme has a MEASURABLE box under that name. Only StyleBoxFlat
## carries a fill colour; anything else is not something to take a ratio against.
func _has_flat(t: Theme, cls: String, sb_name: String) -> bool:
return t.has_stylebox(sb_name, cls) and t.get_stylebox(sb_name, cls) is StyleBoxFlat
func _fill_of(t: Theme, cls: String, sb_name: String) -> Color:
return (t.get_stylebox(sb_name, cls) as StyleBoxFlat).bg_color
+1
View File
@@ -0,0 +1 @@
uid://78mssihccx1s
+1 -1
View File
@@ -26,7 +26,7 @@ func _process(_delta: float) -> bool:
_shot("menu") _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")
+250
View File
@@ -0,0 +1,250 @@
extends SceneTree
## Does the character actually hold each weapon DIFFERENTLY?
##
## godot --path . -s res://debug/weapon_hold_check.gd
##
## Every weapon used to be solved as a rifle: stock in the shoulder pocket,
## support hand out along the barrel, muzzle on the aim line. So a knife, an AWP
## and a rocket launcher produced the same pose, and the only thing telling a
## viewer what was being carried was the weapon mesh itself — which at the
## distance an enemy is usually seen is a few pixels.
##
## This asserts the consequence, not the plumbing. Setting `support_mode` and
## reading it back proves nothing; a hold system is easy to build so that the
## profile loads, the enum is stored and the JSON round-trips while the arms do
## not move. So it measures WHERE THE HANDS AND HEAD ACTUALLY ARE, per weapon,
## and requires the poses to be distinguishable from each other.
##
## ── Measured from inside the modifier pass ──────────────────────────────────
##
## Godot restores every bone's local pose after `SkeletonModifier3D` runs, so
## reading `get_bone_global_pose` from a SceneTree script recomputes the globals
## from the ANIMATION alone — the shooter hold is not in what you measure, and
## every weapon would report an identical pose whether or not this feature
## exists. That is the single most expensive trap in this repo; see
## `references/verification.md`. The `PoseProbe` below is the fix.
##
## ── Measured in the SHOULDER's frame ────────────────────────────────────────
##
## Not in world space, and not even in skeleton space. The hold breathes — a
## `sin(_time * 2.2) * 0.012` on the muzzle pitch — and the idle clip moves the
## whole torso, so no hand is ever at the same place twice and two samples of the
## SAME weapon would differ by more than two different weapons do. Taking each
## hand relative to the right shoulder joint, in the chest's own basis, cancels
## both, because they move the shoulder and the hand together.
const LAB := "res://debug/rig_lab.tscn"
## One weapon per style, plus the two rifles, so the table covers every branch
## and also shows that two weapons of the SAME style stay close together.
const CASES := [
["ak47", WeaponHoldProfiles.RIFLE],
["m4", WeaponHoldProfiles.RIFLE],
["mp7", WeaponHoldProfiles.SMG],
["awp", WeaponHoldProfiles.SNIPER],
["double_barrel_shotgun", WeaponHoldProfiles.SHOTGUN],
["rocket_launcher", WeaponHoldProfiles.LAUNCHER],
["knife", WeaponHoldProfiles.BLADE],
]
## Two weapons of DIFFERENT styles must place their hands at least this far
## apart, in metres, measured in the shoulder frame. Small — these are stylised
## characters with ~0.47 m arms — but far above the millimetre of noise the
## shoulder-frame measurement leaves behind.
const MIN_STYLE_SEPARATION := 0.045
## Two weapons of the SAME style should agree to within this.
const MAX_SAME_STYLE := 0.06
var _fails := 0
var _probe: PoseProbe = null
## Snapshot the pose from INSIDE the modifier pass. See the note above.
class PoseProbe extends SkeletonModifier3D:
var pose: Array = []
func _process_modification() -> void:
var skel := get_skeleton()
if skel == null:
return
pose.resize(skel.get_bone_count())
for i in skel.get_bone_count():
pose[i] = skel.get_bone_global_pose(i)
func _init() -> void:
await process_frame
var lab: Node = load(LAB).instantiate()
root.add_child(lab)
for _i in 200:
await process_frame
if lab._model == null or lab._model._pose_mod == null:
_expect(false, "the lab built a character with a pose layer")
_done()
return
var skel: Skeleton3D = lab._model.skeleton
_probe = PoseProbe.new()
skel.add_child(_probe)
# AFTER the pose layer, so what it reads is what renders.
skel.move_child(_probe, skel.get_child_count() - 1)
var samples := {}
for case in CASES:
var weapon_id: String = case[0]
var want_style: String = case[1]
_expect(WeaponHoldProfiles.style_for(weapon_id) == want_style,
"%s is held as a %s" % [weapon_id, want_style])
# Whether this character has a SAVED hold for this weapon, which is
# allowed to disagree with the style profile. See `_compare`.
var tuned: bool = not WeaponHoldTuning.resolve(
WeaponHoldTuning.load_all(), lab._skins[lab._skin].id,
weapon_id).is_empty()
lab._model.hold_tune = {}
lab._model.set_weapon("res://weapons/%s.gd" % weapon_id)
# Long enough for HOLD_SMOOTH to arrive and for the weapon's `ready` to
# have measured it. The hold blends in at ~8/s, so ~60 frames is several
# time constants.
for _i in 90:
await process_frame
samples[weapon_id] = _sample(skel, lab._model)
if not samples[weapon_id].is_empty():
samples[weapon_id]["tuned"] = tuned
_report(samples)
_compare(samples)
_done()
## Both hands and the head, relative to the right shoulder, in the chest's basis.
func _sample(skel: Skeleton3D, model) -> Dictionary:
var mod = model._pose_mod
var sh_i: int = mod._idx.get("DEF-upper_arm.R", -1)
var chest_i: int = mod._idx.get("DEF-spine.003", -1)
if chest_i < 0:
chest_i = mod._idx.get("DEF-spine.002", -1)
var hand_r: int = mod._idx.get("DEF-hand.R", -1)
var hand_l: int = mod._idx.get("DEF-hand.L", -1)
var head_i: int = mod._idx.get("DEF-head", -1)
if sh_i < 0 or hand_r < 0 or hand_l < 0:
return {}
var pose: Array = _probe.pose
if pose.size() <= maxi(maxi(sh_i, hand_r), hand_l):
return {}
var shoulder: Vector3 = pose[sh_i].origin
# The chest's rotation, so a torso lean does not read as a moved hand.
var frame: Basis = Basis.IDENTITY
if chest_i >= 0 and chest_i < pose.size():
frame = pose[chest_i].basis.orthonormalized()
var inv := frame.inverse()
var out := {
"r": inv * (pose[hand_r].origin - shoulder),
"l": inv * (pose[hand_l].origin - shoulder),
"style": model.hold_style,
"support": mod.support_mode,
"hold_l": mod._hold_l,
}
if head_i >= 0 and head_i < pose.size():
# The head's TILT, which is what a cheek weld is. Taken as the angle
# between the head's up axis and the chest's, signed about forward, so a
# weld (toward the weapon) and a lean-away come out opposite.
var head_up: Vector3 = pose[head_i].basis.orthonormalized().y
var local := inv * head_up
out["cheek_deg"] = rad_to_deg(atan2(local.x, local.y))
return out
func _report(samples: Dictionary) -> void:
print("\n=== HOLD POSE PER WEAPON (metres, in the shoulder's frame) ===")
for id in samples:
var s: Dictionary = samples[id]
if s.is_empty():
continue
print(" %-22s %-9s support=%-7s handR=(%.3f %.3f %.3f) handL=(%.3f %.3f %.3f) offhand=%.2f tilt=%+.1f deg"
% [id, s["style"], s["support"],
s["r"].x, s["r"].y, s["r"].z, s["l"].x, s["l"].y, s["l"].z,
s["hold_l"], s.get("cheek_deg", 0.0)])
func _compare(samples: Dictionary) -> void:
# Every pair of DIFFERENT styles must be distinguishable.
var ids: Array = samples.keys()
for i in ids.size():
for j in range(i + 1, ids.size()):
var a: Dictionary = samples[ids[i]]
var b: Dictionary = samples[ids[j]]
if a.is_empty() or b.is_empty():
continue
# The blade releases its off hand to the animation, so comparing its
# left hand measures the idle clip, not the hold. Its trigger hand
# and its free offhand weight are what distinguish it.
var d: float = float(a["r"].distance_to(b["r"]))
if a["hold_l"] > 0.5 and b["hold_l"] > 0.5:
d = maxf(d, a["l"].distance_to(b["l"]))
if a["style"] == b["style"]:
# ...unless an artist has tuned one of them for THIS character.
# The profile is only a defaults layer; a saved hold is meant to
# be able to disagree with it, and aria's hand-tuned AK-47 sits
# 0.22 m from the untuned M4 for exactly that reason. Asserting
# they match would be asserting that the rig lab does nothing.
if a["tuned"] or b["tuned"]:
print(" -- %s and %s are both %s but %s is hand-tuned (%.3f m apart)"
% [ids[i], ids[j], a["style"],
ids[i] if a["tuned"] else ids[j], d])
continue
_expect(d <= MAX_SAME_STYLE,
"%s and %s are both %s and hold alike (%.3f m apart)"
% [ids[i], ids[j], a["style"], d])
else:
_expect(d >= MIN_STYLE_SEPARATION,
"%s (%s) and %s (%s) are held differently (%.3f m apart)"
% [ids[i], a["style"], ids[j], b["style"], d])
# The blade must actually let go of the off arm — that released arm is most
# of what makes a one-handed weapon read as one-handed.
if samples.has("knife"):
_expect(samples["knife"]["hold_l"] < 0.05,
"the knife releases the off arm to the animation (%.2f)"
% samples["knife"]["hold_l"])
for id in ["ak47", "awp", "rocket_launcher"]:
if samples.has(id):
_expect(samples[id]["hold_l"] > 0.9,
"%s keeps both hands on the weapon" % id)
# The cheek weld, and its inverse on a shouldered tube. These are the two
# poses that read at the greatest distance, so they get their own assertion
# rather than relying on the pairwise distance.
if samples.has("awp") and samples.has("ak47"):
var d: float = float(samples["awp"].get("cheek_deg", 0.0)) - float(samples["ak47"].get("cheek_deg", 0.0))
_expect(absf(d) > 1.5,
"the sniper welds its head to the stock (%+.1f deg vs the rifle)" % d)
if samples.has("rocket_launcher") and samples.has("ak47"):
var dl: float = float(samples["rocket_launcher"].get("cheek_deg", 0.0))
var dr: float = float(samples["ak47"].get("cheek_deg", 0.0))
var da: float = float(samples["awp"].get("cheek_deg", 0.0))
_expect((dl - dr) * (da - dr) < 0.0,
"the launcher leans the head AWAY, opposite the sniper (%+.1f vs %+.1f)"
% [dl - dr, da - dr])
func _expect(ok: bool, what: String) -> void:
if ok:
print(" OK: ", what)
else:
print(" FAIL: ", what)
_fails += 1
func _done() -> void:
print("\n=== WEAPON HOLD SUMMARY ===")
print("Failures: %d" % _fails)
quit(1 if _fails > 0 else 0)
+1
View File
@@ -0,0 +1 @@
uid://f7600k001iqr
+265
View File
@@ -0,0 +1,265 @@
extends Object
class_name GameMode
## What a match IS: how you score, when it ends, and who won.
##
## Before this there was a string — `NetworkManager.current_gamemode`, always
## "Deathmatch" — an OptionButton with one entry in it, and a five-minute timer
## that counted to zero, set `match_active = false`, and did nothing else. No
## winner was ever declared, no summary was ever shown, and nothing was tracked
## beyond a running kill count. A match did not end so much as stop mattering.
##
## The three things a mode has to answer are answered here:
##
## HOW YOU SCORE what a kill is worth, and to whom. In Gun Game a kill is
## worth a rung on a ladder rather than a point.
## WHEN IT ENDS a score limit, a time limit, or both, whichever comes
## first. A mode with neither cannot end and is not a mode.
## WHO WON the top player, or the top TEAM, which are different
## questions and used to be the same one.
##
## Deliberately a plain data table with static helpers rather than an autoload:
## the menu, the lobby, the HUD and the server all need to ask what a mode is,
## and half of them run before any autoload would be ready.
const DEATHMATCH := "deathmatch"
const TEAM_DEATHMATCH := "team_deathmatch"
const GUN_GAME := "gun_game"
## The order they appear in menus. Deathmatch first because it is the one that
## needs no explanation.
const ORDER := [DEATHMATCH, TEAM_DEATHMATCH, GUN_GAME]
## `score_limit` of 0 means the mode has no score limit and ends on time alone.
## `time_limit` is in seconds; 0 means it ends on score alone. At least one of
## the two must be non-zero or the match cannot end — `is_valid` checks that.
const MODES := {
DEATHMATCH: {
"name": "Deathmatch",
"blurb": "Everyone for themselves. First to the frag limit takes it.",
"score_limit": 25,
"time_limit": 600.0,
"teams": 0,
"friendly_fire": true,
"score_noun": "KILLS",
# What one kill is worth to the killer.
"kill_score": 1,
# What dying costs. Zero: a deathmatch that punishes dying rewards
# hiding, and this is a game about momentum.
"death_score": 0,
},
TEAM_DEATHMATCH: {
"name": "Team Deathmatch",
"blurb": "Two squads. Team score is what counts.",
"score_limit": 50,
"time_limit": 600.0,
"teams": 2,
"friendly_fire": false,
"score_noun": "SCORE",
"kill_score": 1,
"death_score": 0,
},
GUN_GAME: {
"name": "Gun Game",
"blurb": "Every kill promotes you to the next weapon. Finish the ladder.",
# The limit IS the ladder length, filled in from LADDER below so the two
# can never disagree.
"score_limit": 0,
"time_limit": 900.0,
"teams": 0,
"friendly_fire": true,
"score_noun": "RUNG",
"kill_score": 1,
"death_score": 0,
},
}
## Gun Game's ladder, by weapon id (see LoadoutManager.weapon_db). Ordered
## roughly easiest to hardest to get a kill with, so the last rung is a real
## finish rather than a formality.
const LADDER := ["ak47", "m4", "mp7", "dmr", "plasma_gun", "nail_gun",
"double_barrel_shotgun", "rocket_launcher", "awp", "knife"]
static func all_ids() -> Array:
return ORDER.duplicate()
static func get_mode(id: String) -> Dictionary:
return MODES.get(id, MODES[DEATHMATCH])
static func display_name(id: String) -> String:
return String(get_mode(id).get("name", id))
static func blurb(id: String) -> String:
return String(get_mode(id).get("blurb", ""))
## The score that ends the match. For Gun Game this is the ladder's length, so
## adding a weapon to the ladder cannot leave the win condition unreachable.
static func score_limit(id: String) -> int:
if id == GUN_GAME:
return LADDER.size()
return int(get_mode(id).get("score_limit", 0))
static func time_limit(id: String) -> float:
return float(get_mode(id).get("time_limit", 0.0))
static func team_count(id: String) -> int:
return int(get_mode(id).get("teams", 0))
static func is_team_mode(id: String) -> bool:
return team_count(id) > 1
static func friendly_fire(id: String) -> bool:
return bool(get_mode(id).get("friendly_fire", true))
static func score_noun(id: String) -> String:
return String(get_mode(id).get("score_noun", "SCORE"))
## A mode with neither a score limit nor a time limit can never end. This is the
## one invariant worth asserting about the table itself, and debug/game_mode_check
## runs it over every entry.
static func is_valid(id: String) -> bool:
return score_limit(id) > 0 or time_limit(id) > 0.0
## The weapon a player on `rung` of the Gun Game ladder is holding.
static func ladder_weapon(rung: int) -> String:
if LADDER.is_empty():
return ""
return LADDER[clampi(rung, 0, LADDER.size() - 1)]
## Team colours, for the scoreboard, the killfeed and the HUD. Index 0 is no
## team — free-for-all — which reads as the game's own papaya.
const TEAM_COLORS := [Color(1.00, 0.47, 0.10), Color(0.22, 0.94, 1.00),
Color(1.00, 0.18, 0.52)]
const TEAM_NAMES := ["", "CYAN", "MAGENTA"]
static func team_color(team: int) -> Color:
return TEAM_COLORS[clampi(team, 0, TEAM_COLORS.size() - 1)]
static func team_name(team: int) -> String:
if team <= 0 or team >= TEAM_NAMES.size():
return ""
return TEAM_NAMES[team]
## Which team a joining player goes on: the smallest, ties broken toward team 1.
##
## Assigned by COUNT rather than round-robin on join order, because players
## leave. Round-robin on a 4v4 that loses three from one side stays 4v1 forever;
## this refills the short side.
static func assign_team(id: String, stats: Dictionary) -> int:
var teams := team_count(id)
if teams < 2:
return 0
var counts := []
counts.resize(teams + 1)
counts.fill(0)
for pid in stats:
var t: int = int(stats[pid].get("team", 0))
if t >= 1 and t <= teams:
counts[t] += 1
var best := 1
for t in range(1, teams + 1):
if counts[t] < counts[best]:
best = t
return best
## Every team's total, as `team index -> score`. Team 0 is never included.
static func team_scores(id: String, stats: Dictionary) -> Dictionary:
var out := {}
if not is_team_mode(id):
return out
for t in range(1, team_count(id) + 1):
out[t] = 0
for pid in stats:
var t: int = int(stats[pid].get("team", 0))
if out.has(t):
out[t] += int(stats[pid].get("score", 0))
return out
## Has anyone won yet? Returns `{}` if not, else the reason and the winner.
##
## Server-side only — this is the authority on when a match stops. It is a pure
## function of the stats so it can be unit-tested without a match running, which
## debug/game_mode_check.gd does.
static func check_win(id: String, stats: Dictionary, time_left: float) -> Dictionary:
var limit := score_limit(id)
if is_team_mode(id):
var totals := team_scores(id, stats)
if limit > 0:
for t in totals:
if totals[t] >= limit:
return {"reason": "score", "team": t,
"name": team_name(t) + " TEAM"}
if time_left <= 0.0:
var best := 0
var best_score := -1
var tied := false
for t in totals:
if totals[t] > best_score:
best_score = totals[t]
best = t
tied = false
elif totals[t] == best_score:
tied = true
if tied:
return {"reason": "time", "team": 0, "name": "DRAW"}
return {"reason": "time", "team": best,
"name": team_name(best) + " TEAM"}
return {}
# Free-for-all.
if limit > 0:
for pid in stats:
if int(stats[pid].get("score", 0)) >= limit:
return {"reason": "score", "player": pid,
"name": String(stats[pid].get("username", "Player"))}
if time_left <= 0.0:
var top = null
var top_score := -1
var drawn := false
for pid in stats:
var sc: int = int(stats[pid].get("score", 0))
if sc > top_score:
top_score = sc
top = pid
drawn = false
elif sc == top_score:
drawn = true
if top == null or drawn:
return {"reason": "time", "player": 0, "name": "DRAW"}
return {"reason": "time", "player": top,
"name": String(stats[top].get("username", "Player"))}
return {}
## Players sorted best-first, by score then by fewest deaths.
##
## Fewest deaths as the tiebreak, not most kills: at equal score the kills are
## equal by definition in every mode here, so kills would not break anything.
static func standings(stats: Dictionary) -> Array:
var ids: Array = stats.keys()
ids.sort_custom(func(a, b):
var sa: int = int(stats[a].get("score", 0))
var sb: int = int(stats[b].get("score", 0))
if sa != sb:
return sa > sb
return int(stats[a].get("deaths", 0)) < int(stats[b].get("deaths", 0)))
return ids
+1
View File
@@ -0,0 +1 @@
uid://b8k2ha37f47ji
+205 -21
View File
@@ -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,20 +208,93 @@ 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:
if match_time_remaining > 0: return
match_time_remaining -= delta if match_time_remaining > 0:
if match_time_remaining <= 0: match_time_remaining -= delta
match_time_remaining = 0 if match_time_remaining <= 0:
match_active = false match_time_remaining = 0
# We emit this so HUDs can update their timers smoothly # Time is a win CONDITION, not an off switch. This used to set
match_state_updated.emit() # `match_active = false` and stop, so the clock reached zero, the
# timer froze at 00:00 and nothing else happened — no winner, no
# summary, no way back to the menu. The match did not end so much as
# stop mattering.
if _is_authority():
_check_win()
# We emit this so HUDs can update their timers smoothly
match_state_updated.emit()
## Whether this peer decides the match. True for the server, and true in
## singleplayer, where there is no peer but somebody still has to call it.
func _is_authority() -> bool:
if not multiplayer.has_multiplayer_peer():
return true
if multiplayer.multiplayer_peer is OfflineMultiplayerPeer:
return true
return multiplayer.is_server()
## Has anyone won? Called on the server after every kill and when the clock runs
## out. GameMode.check_win is a pure function of the stats, so the rule lives
## next to the mode that defines it and can be tested without a match running.
func _check_win() -> void:
if not match_active:
return
var result := GameMode.check_win(current_gamemode, player_stats,
match_time_remaining)
if result.is_empty():
return
result["mode"] = current_gamemode
result["standings"] = GameMode.standings(player_stats)
result["stats"] = player_stats.duplicate(true)
if _is_authority() and multiplayer.has_multiplayer_peer() \
and not multiplayer.multiplayer_peer is OfflineMultiplayerPeer:
rpc("end_match", result)
else:
end_match(result)
@rpc("authority", "call_local", "reliable")
func end_match(result: Dictionary) -> void:
if not match_active:
return
match_active = false
last_result = result
match_state_updated.emit()
match_ended.emit(result)
## Wipe the scores and start the clock again on the same map and mode. The
## summary screen's "Play Again".
@rpc("authority", "call_local", "reliable")
func restart_match() -> void:
for pid in player_stats:
for key in ["kills", "deaths", "assists", "score", "streak",
"best_streak", "rung"]:
player_stats[pid][key] = 0
last_result = {}
match_active = true
match_time_remaining = GameMode.time_limit(current_gamemode)
stats_updated.emit()
match_state_updated.emit()
# --- PING SYSTEM --- # --- PING SYSTEM ---
func _on_ping_timer() -> void: func _on_ping_timer() -> void:
@@ -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
broadcast_killfeed.rpc(v_name, k_name, weapon_name, v_color, k_color) # `.rpc()` on an offline peer does not call locally, so singleplayer got
sync_player_stats.rpc(player_stats) # no killfeed and no stat sync at all. Both paths now go through the
# same two calls.
if multiplayer.has_multiplayer_peer() \
and not multiplayer.multiplayer_peer is OfflineMultiplayerPeer:
broadcast_killfeed.rpc(v_name, k_name, weapon_name, v_color, k_color)
sync_player_stats.rpc(player_stats)
else:
broadcast_killfeed(v_name, k_name, weapon_name, v_color, k_color)
sync_player_stats(player_stats)
# The scores just changed, so this is exactly when a match can end.
_check_win()
@rpc("authority", "call_local", "reliable") @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))
+90 -170
View File
@@ -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
View File
@@ -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"
+128
View File
@@ -0,0 +1,128 @@
extends Control
class_name AbilityChip
## A dash or grapple readout: a leaning chip that drains while the ability is on
## cooldown and snaps back to charged when it is ready again.
##
## It replaces a 32 px JPEG icon with a text label under it, tinted red or green
## by a colour multiply — an approach with three problems. The icon was a photo
## in a game drawn entirely in flat ink; the tint said "not ready" but not HOW not
## ready; and the number that did say it was rendered at 12 px, which is below
## what anyone reads mid-fight.
##
## What a player actually needs from a cooldown is one bit at a glance (can I go?)
## and one magnitude in peripheral vision (how soon?). So the chip answers the bit
## with COLOUR — volt when charged, ink when not, the same volt-means-now rule the
## rest of the UI runs on — and the magnitude with a WIPE across the chip, which
## can be read without focusing on it because it is a shape changing size rather
## than a number changing value.
##
## The ready transition overshoots slightly before settling. A cooldown that ends
## by silently going bright is easy to miss while looking somewhere else; one that
## pops is not, and it costs a tween.
## Ability name, drawn on the chip.
var label: String = "DASH":
set(v):
label = v
queue_redraw()
## 0 = fully charged, 1 = just used. Written each frame.
var cooldown: float = 0.0:
set(v):
var c := clampf(v, 0.0, 1.0)
var was_ready := cooldown <= 0.001
cooldown = c
if was_ready and c > 0.001:
_pop = 0.0
elif not was_ready and c <= 0.001:
# Just came back. Fire the overshoot.
_pop = 1.0
queue_redraw()
## Set while the ability is actively in use (mid-grapple), which is a third state
## and reads as neither charged nor recharging.
var active: bool = false:
set(v):
if v != active:
active = v
queue_redraw()
const SHEAR := 7.0
const INK_W := 3.0
const POP_DECAY := 4.0
var _pop: float = 0.0
func _ready() -> void:
mouse_filter = Control.MOUSE_FILTER_IGNORE
custom_minimum_size = Vector2(96, 34)
func _process(delta: float) -> void:
if _pop <= 0.0:
return
_pop = maxf(_pop - delta * POP_DECAY, 0.0)
queue_redraw()
func _draw() -> void:
var w := size.x
var h := size.y
if w <= 1.0 or h <= 1.0:
return
var ready := cooldown <= 0.001
# The overshoot: a brief lift in the fill, decaying to the resting colour.
var lift: float = _pop * _pop
var fill: Color = UITheme.INK
var text: Color = UITheme.PAPER_DIM
if active:
# In use. Cyan, so it cannot be confused with either of the other two.
fill = UITheme.CYAN
text = UITheme.ink_for(UITheme.CYAN)
elif ready:
fill = UITheme.VOLT.lerp(UITheme.PAPER, lift * 0.5)
text = UITheme.ink_for(UITheme.VOLT)
_shear(0.0, w, fill, h)
# The recharge wipe, left to right over the dark chip.
if not ready and not active:
_shear(0.0, w * (1.0 - cooldown), UITheme.PAPAYA, h)
# Anything the wipe has reached is papaya, anything it has not is ink,
# and one text colour has to sit on both. Ink loses against ink; paper
# wins against both, and the outline below covers the papaya case.
text = UITheme.PAPER
_shear_outline(0.0, w, UITheme.INK, h)
var font := get_theme_default_font()
if font == null:
return
var fs := 17
var text_size := font.get_string_size(label, HORIZONTAL_ALIGNMENT_LEFT, -1, fs)
var at := Vector2((w - text_size.x) * 0.5, (h + text_size.y * 0.62) * 0.5)
# Ink outline under the glyphs, same as every Label in the theme, so the
# label survives the wipe passing underneath it.
draw_string_outline(font, at, label, HORIZONTAL_ALIGNMENT_LEFT, -1, fs, 5,
UITheme.INK)
draw_string(font, at, label, HORIZONTAL_ALIGNMENT_LEFT, -1, fs, text)
func _shear(x0: float, x1: float, col: Color, h: float) -> void:
if x1 - x0 < 0.5:
return
draw_colored_polygon(PackedVector2Array([
Vector2(x0 + SHEAR, 0.0), Vector2(x1 + SHEAR, 0.0),
Vector2(x1, h), Vector2(x0, h),
]), col)
func _shear_outline(x0: float, x1: float, col: Color, h: float) -> void:
draw_polyline(PackedVector2Array([
Vector2(x0 + SHEAR, 0.0), Vector2(x1 + SHEAR, 0.0),
Vector2(x1, h), Vector2(x0, h), Vector2(x0 + SHEAR, 0.0),
]), col, INK_W)
+1
View File
@@ -0,0 +1 @@
uid://48ja2bu8k6xm
+175
View File
@@ -0,0 +1,175 @@
extends Control
class_name Crosshair
## The reticle, drawn rather than assembled out of ColorRects.
##
## It replaces five white rectangles that were pasted into three different level
## runtimes, and it exists as one drawn thing for two reasons.
##
## The first is that it has to say something. A static cross tells the player
## nothing they do not already know; a reticle that OPENS as they sprint, jump and
## fire, and snaps shut when they stop or shoulder the weapon, is the accuracy
## readout of the whole game and it costs one number per frame. Four separate
## ColorRects cannot express that without four separate position updates, which is
## why they never did.
##
## The second is the ink. Every other element in this UI carries a heavy dark edge
## — it is the theme's first rule, and the reason white type stays legible over a
## sunlit 3D scene. A 2 px white line does not: over pale concrete it disappears
## exactly when aim matters. Here every stroke is drawn twice, ink underneath and
## wider, so the reticle reads against the map instead of against luck.
##
## The hit confirmation lives here too, rather than as a separate centred Control
## fading on top. It is the same four strokes rotated 45°, which means the
## feedback arrives where the player's eye already is and shares the reticle's
## outline instead of needing its own.
## 0 = tight, 1 = fully bloomed. Written each frame by PlayerHUD from speed,
## airtime and fire cooldown.
var spread: float = 0.0:
set(v):
var c := clampf(v, 0.0, 1.0)
if absf(c - spread) > 0.002:
spread = c
queue_redraw()
else:
spread = c
## 0 = hip, 1 = down the sights. At full ADS the ticks retract entirely and only
## the centre dot remains, which is the convention every shooter uses and the
## clearest possible statement that the shot is going where the dot is.
var ads: float = 0.0:
set(v):
var c := clampf(v, 0.0, 1.0)
if absf(c - ads) > 0.002:
ads = c
queue_redraw()
else:
ads = c
## Hit and kill confirmations, 1 -> 0. Kill is the louder one and it wins.
var hit: float = 0.0
var kill: float = 0.0
## Geometry, in pixels at 1080p.
const GAP_TIGHT := 5.0
const GAP_BLOOM := 30.0
const TICK := 9.0
const STROKE := 2.0
const INK_GROW := 2.0
const DOT := 2.2
## How far the confirmation strokes sit out, and how long they are.
const HIT_GAP := 13.0
const HIT_TICK := 8.0
## Decay rates, per second. The hit pop is quick — it has to land inside the
## rhythm of firing, not linger into the next shot.
const HIT_DECAY := 3.2
const KILL_DECAY := 1.7
func _ready() -> void:
mouse_filter = Control.MOUSE_FILTER_IGNORE
# FULL RECT, and the drawing centres itself in `size`.
#
# A zero-sized Control on PRESET_CENTER is the obvious way to do this and it
# is wrong: the control draws once when it enters the tree, before the
# viewport has told it how big the screen is, so the whole reticle lands in
# the top-left corner and stays there until something else happens to make it
# redraw. Nothing does, because the reticle only redraws when the bloom
# changes. Owning the full rect and measuring the centre every draw cannot
# get that wrong.
# `set_anchors_AND_OFFSETS_preset`, not `set_anchors_preset`. The latter
# moves the anchors and leaves the offsets where they were, which for a
# control that has never been laid out means an empty rect: the measured
# result was anchors spanning the viewport and a size of exactly (0, 0).
set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
resized.connect(queue_redraw)
func _process(delta: float) -> void:
if hit <= 0.0 and kill <= 0.0:
return
hit = maxf(hit - delta * HIT_DECAY, 0.0)
kill = maxf(kill - delta * KILL_DECAY, 0.0)
queue_redraw()
## A landed shot. Kills pass `fatal` and get the louder, slower magenta pop.
func confirm(fatal: bool = false) -> void:
if fatal:
kill = 1.0
else:
hit = 1.0
queue_redraw()
func _draw() -> void:
_mid = size * 0.5
# ADS retracts the ticks and tightens what is left, so the bloom cannot
# fight the sight picture.
var open := 1.0 - ads
var gap: float = lerpf(GAP_TIGHT, GAP_BLOOM, spread) * lerpf(1.0, 0.45, ads)
var tick: float = TICK * open
if tick > 0.5:
# Vertical ticks are drawn slightly shorter than horizontal ones. A
# perfectly square cross reads taller than it is because the eye
# over-weights vertical extent; trimming the verticals is the standard
# correction and it is the difference between a reticle that looks
# centred and one that looks slightly high.
_stroke(Vector2(-gap - tick, 0), Vector2(-gap, 0), UITheme.PAPER)
_stroke(Vector2(gap, 0), Vector2(gap + tick, 0), UITheme.PAPER)
var v: float = tick * 0.86
_stroke(Vector2(0, -gap - v), Vector2(0, -gap), UITheme.PAPER)
_stroke(Vector2(0, gap), Vector2(0, gap + v), UITheme.PAPER)
# Centre dot: the one element that never moves and never fades. It goes
# papaya at the hip and volt down the sights, so shouldering the weapon
# changes the reticle's colour as well as its shape.
var dot_col: Color = UITheme.PAPAYA.lerp(UITheme.VOLT, ads)
var r: float = DOT * lerpf(1.0, 1.25, ads)
draw_circle(_mid, r + INK_GROW, UITheme.INK)
draw_circle(_mid, r, dot_col)
# Confirmations, over the top: the same cross rotated 45°.
if kill > 0.001:
_confirm_arms(kill, UITheme.MAGENTA, 1.35)
if hit > 0.001:
_confirm_arms(hit, UITheme.VOLT, 1.0)
## Four diagonal strokes, scaled and faded by `amount`.
##
## They grow slightly as they fade rather than merely fading, which reads as an
## impact rather than as a light being switched off — the same expansion-plus-
## dissolve that carries an anime impact frame.
func _confirm_arms(amount: float, col: Color, scale: float) -> void:
var grow: float = 1.0 + (1.0 - amount) * 0.5
var gap: float = HIT_GAP * scale * grow
var tick: float = HIT_TICK * scale
var c := Color(col.r, col.g, col.b, amount)
var ink := Color(UITheme.INK.r, UITheme.INK.g, UITheme.INK.b, amount)
const DIAGONALS: Array[Vector2] = [Vector2(1, 1), Vector2(1, -1),
Vector2(-1, 1), Vector2(-1, -1)]
for d in DIAGONALS:
var dir := d.normalized()
_stroke(dir * gap, dir * (gap + tick), c, ink)
## Screen centre, recomputed at the top of every `_draw`. Every offset below is
## relative to it.
var _mid: Vector2 = Vector2.ZERO
## One stroke, ink first and wider so the colour sits inside an outline.
##
## Endpoints are given RELATIVE TO CENTRE and offset here, so no caller has to
## remember to add it.
##
## `draw_line` with a round cap would leave the ink poking out past the ends as a
## dark bead; square caps keep the outline flush with the stroke it is edging.
func _stroke(a: Vector2, b: Vector2, col: Color,
ink: Color = UITheme.INK) -> void:
draw_line(_mid + a, _mid + b, ink, STROKE + INK_GROW * 2.0, false)
draw_line(_mid + a, _mid + b, col, STROKE, false)
+1
View File
@@ -0,0 +1 @@
uid://ck57a8waor1d1
+215
View File
@@ -0,0 +1,215 @@
extends Control
class_name EmoteWheel
## The radial emote dial: hold the emote button, point, release.
##
## A radial menu is the right shape for this and a list is not, for one reason:
## every option is the SAME DISTANCE from where the pointer starts. There is no
## scanning and no travel budget — the choice is a direction, and a direction can
## be learned as muscle memory in a way that "the fourth row down" cannot. After
## a few uses the player stops reading the wheel and just flicks.
##
## Which is why the selection is by ANGLE ALONE and not by distance. Pointing
## anywhere in a wedge selects it, however far out the cursor is, so a fast flick
## and a careful nudge do the same thing. A dead zone in the middle is the only
## exception, and it exists so releasing without moving cancels rather than
## picking whatever happened to be under the cursor at rest.
##
## Opened by HOLDING the button rather than toggled by tapping it, because an
## emote is a thing you do in a lull and a menu you have to close again is a
## thing that gets you killed. Release commits. A tap too short to have aimed
## replays the last emote instead, which is what the button did before the wheel
## existed.
signal picked(index: int)
signal cancelled
## The wheel is drawn at this radius, and a wedge is selected by pointing at it
## from further out than the dead zone.
const RADIUS := 190.0
const INNER := 76.0
const DEAD_ZONE := 46.0
const LABEL_R := 250.0
## How long the open/close ease takes. Short — this is a fast interaction and a
## slow bloom would defeat the point of it.
const OPEN_TIME := 0.11
var _routines: Array = []
var _hover: int = -1
var _open: float = 0.0
var _target_open: float = 0.0
## The direction the pointer has travelled from the wheel's centre since it
## opened. Accumulated from relative mouse motion rather than read from the
## cursor position, because the game captures the mouse and the OS cursor does
## not move.
var _aim: Vector2 = Vector2.ZERO
func _ready() -> void:
_routines = DanceRoutines.ROUTINES
set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
mouse_filter = Control.MOUSE_FILTER_IGNORE
visible = false
set_process(false)
set_process_input(false)
## Show the wheel and start tracking the pointer.
func open() -> void:
_aim = Vector2.ZERO
_hover = -1
_target_open = 1.0
visible = true
set_process(true)
set_process_input(true)
queue_redraw()
## Hide it, and report what was pointed at. Returns the index, or -1 for a
## cancel (nothing aimed at, or the pointer never left the dead zone).
func close() -> int:
_target_open = 0.0
set_process_input(false)
var chosen := _hover
if chosen >= 0:
picked.emit(chosen)
else:
cancelled.emit()
_hover = -1
queue_redraw()
return chosen
func _input(event: InputEvent) -> void:
if event is InputEventMouseMotion:
# Relative motion, because the game holds the mouse captured — the
# cursor's absolute position never changes and reading it would leave
# the wheel permanently pointing at nothing.
_aim += (event as InputEventMouseMotion).relative
_update_hover()
## Also drivable from a stick or the keyboard, for a controller or for a player
## who would rather not move the mouse. Same wedge maths, different source.
func aim_by_vector(v: Vector2) -> void:
_aim = v * (DEAD_ZONE + 1.0) if v.length() > 0.01 else Vector2.ZERO
_update_hover()
func _update_hover() -> void:
var was := _hover
if _aim.length() < DEAD_ZONE or _routines.is_empty():
_hover = -1
else:
# Angle from straight UP, clockwise, so the first emote is at twelve
# o'clock — the position a player will reach for without thinking.
var a := fposmod(atan2(_aim.x, -_aim.y), TAU)
var step := TAU / float(_routines.size())
_hover = int(floor((a + step * 0.5) / step)) % _routines.size()
if _hover != was:
queue_redraw()
var am := get_tree().root.get_node_or_null("AudioManager")
if am and _hover >= 0:
am.play_ui("ui_hover")
func _process(delta: float) -> void:
var t := 1.0 - exp(-delta / maxf(OPEN_TIME, 0.001))
_open = lerpf(_open, _target_open, t)
if _target_open <= 0.0 and _open < 0.01:
_open = 0.0
visible = false
set_process(false)
queue_redraw()
func _draw() -> void:
if _open < 0.01 or _routines.is_empty():
return
var mid := size * 0.5
# Scale up from 88% as it opens, and fade in. Small, because the wheel has to
# be usable the instant it appears — an animation the player has to wait out
# is an animation that makes the feature feel slower than the old toggle.
var k: float = lerpf(0.88, 1.0, _open)
var a: float = _open
var count := _routines.size()
var step := TAU / float(count)
# A scrim, so the wheel reads over a bright skybox without needing a heavier
# outline than everything else in the UI uses.
draw_circle(mid, RADIUS * k * 1.06,
Color(UITheme.INK.r, UITheme.INK.g, UITheme.INK.b, 0.55 * a))
for i in count:
var mid_angle := step * float(i)
var from := mid_angle - step * 0.5
var hovered := i == _hover
# Fill follows the state, and the label follows the fill — the theme's
# one rule for staying readable. A hovered wedge is papaya and takes ink
# glyphs; a resting one is near-black and takes paper.
var fill: Color = UITheme.PAPAYA if hovered else UITheme.INK_SOFT
_wedge(mid, from, step, INNER * k, RADIUS * k,
Color(fill.r, fill.g, fill.b, (0.95 if hovered else 0.82) * a))
var dir := Vector2(sin(mid_angle), -cos(mid_angle))
var r := (INNER + RADIUS) * 0.5 * k
_glyph(mid + dir * r, String(_routines[i].get("icon", "*")), 40,
UITheme.ink_for(fill) if hovered else UITheme.PAPER, a)
# The name sits OUTSIDE the ring rather than inside the wedge, so a long
# one is never clipped by its own slice and the type size does not have
# to shrink as emotes are added.
_glyph(mid + dir * (LABEL_R * k), String(_routines[i].get("name", "")), 22,
UITheme.VOLT if hovered else UITheme.PAPER, a)
# The hub. Volt while a wedge is aimed at, so committing is confirmed before
# the button is released rather than after.
var hub: Color = UITheme.VOLT if _hover >= 0 else UITheme.INK
draw_circle(mid, INNER * k * 0.42,
Color(hub.r, hub.g, hub.b, (0.9 if _hover >= 0 else 0.75) * a))
draw_arc(mid, INNER * k * 0.42, 0, TAU, 40,
Color(UITheme.INK.r, UITheme.INK.g, UITheme.INK.b, a), 3.0, true)
if _hover < 0:
_glyph(mid, "RELEASE TO CANCEL", 16, UITheme.PAPER_DIM, a * 0.9)
## One slice of the ring, as a triangle strip between the inner and outer radii.
func _wedge(mid: Vector2, from: float, span: float, r0: float, r1: float,
col: Color) -> void:
var segs := 14
var pts := PackedVector2Array()
for i in segs + 1:
var ang := from + span * (float(i) / float(segs))
# A one-degree gap either side, so adjacent wedges read as separate
# choices rather than as a solid ring with colour changes in it.
ang = from + deg_to_rad(1.2) + (span - deg_to_rad(2.4)) * (float(i) / float(segs))
var d := Vector2(sin(ang), -cos(ang))
pts.append(mid + d * r0)
pts.append(mid + d * r1)
# Build an outline path around the strip so the ink edge can be stroked.
var outer := PackedVector2Array()
var inner := PackedVector2Array()
for i in range(0, pts.size(), 2):
inner.append(pts[i])
outer.append(pts[i + 1])
var poly := PackedVector2Array()
poly.append_array(outer)
inner.reverse()
poly.append_array(inner)
draw_colored_polygon(poly, col)
poly.append(poly[0])
draw_polyline(poly, Color(UITheme.INK.r, UITheme.INK.g, UITheme.INK.b, col.a),
3.0)
## Centred text with the theme's ink outline under it.
func _glyph(at: Vector2, text: String, fs: int, col: Color, alpha: float) -> void:
var font := get_theme_default_font()
if font == null or text == "":
return
var w := font.get_string_size(text, HORIZONTAL_ALIGNMENT_LEFT, -1, fs)
var pos := at - Vector2(w.x * 0.5, -w.y * 0.32)
draw_string_outline(font, pos, text, HORIZONTAL_ALIGNMENT_LEFT, -1, fs, 6,
Color(UITheme.INK.r, UITheme.INK.g, UITheme.INK.b, alpha))
draw_string(font, pos, text, HORIZONTAL_ALIGNMENT_LEFT, -1, fs,
Color(col.r, col.g, col.b, alpha))
+1
View File
@@ -0,0 +1 @@
uid://dnxnbswrl04u8
+144
View File
@@ -0,0 +1,144 @@
extends Button
class_name LevelCard
## One map on the level select: its photograph, its name, and its mode.
##
## The cards used to be a two-stop gradient generated from `color1` and `color2`
## in the map's meta file. That tells a player which card they clicked last time
## and nothing at all about the map, which is the entire job of a level select —
## and the hover state was `use_hdr = true` on the gradient texture, which is not
## a visible change on any of them.
##
## The preview comes from assets/ui/map_previews/<folder>.png, shot by
## debug/map_preview_capture.gd from inside the map. A map with no preview falls
## back to its gradient, so nothing breaks for a map that has not been
## photographed yet.
##
## Built on Button rather than TextureButton so it gets focus, keyboard
## navigation and the theme's state machinery for free. The image sits inside the
## button's border rather than under it, so the border reads as a frame around a
## photograph instead of a rectangle behind one.
const INSET := 5
var _plate: ColorRect
var _label: Label
var _mode_label: Label
func setup(title: String, preview_path: String, gradient: Gradient,
card_size: Vector2) -> void:
custom_minimum_size = card_size
clip_contents = true
# The frame. Papaya at rest, volt on hover, and the fill stays transparent
# so the photograph is what the player sees.
var clear := Color(0, 0, 0, 0)
add_theme_stylebox_override("normal", _frame(clear, UITheme.PAPAYA, 3))
add_theme_stylebox_override("hover", _frame(clear, UITheme.VOLT, 5))
add_theme_stylebox_override("pressed", _frame(
Color(UITheme.VOLT.r, UITheme.VOLT.g, UITheme.VOLT.b, 0.25),
UITheme.VOLT, 5))
add_theme_stylebox_override("focus", _frame(clear, UITheme.CYAN, 5))
add_theme_stylebox_override("disabled", _frame(
Color(UITheme.INK.r, UITheme.INK.g, UITheme.INK.b, 0.7),
UITheme.DEAD_EDGE, 3))
if ResourceLoader.exists(preview_path):
var shot := TextureRect.new()
shot.texture = load(preview_path)
shot.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
# COVERED, not scaled: a photograph letterboxed inside a card reads as a
# thumbnail in a file browser. Filling the card and cropping reads as a
# poster, which is what this is.
shot.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_COVERED
shot.mouse_filter = Control.MOUSE_FILTER_IGNORE
_fill(shot)
add_child(shot)
else:
var grad := TextureRect.new()
var tex := GradientTexture2D.new()
tex.gradient = gradient
tex.width = int(card_size.x)
tex.height = int(card_size.y)
tex.fill_to = Vector2(1, 1)
grad.texture = tex
grad.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
grad.stretch_mode = TextureRect.STRETCH_SCALE
grad.mouse_filter = Control.MOUSE_FILTER_IGNORE
_fill(grad)
add_child(grad)
# The name plate: a hard ink band across the bottom rather than a gradient
# fade, because the theme is drawn and a soft fade is not.
_plate = ColorRect.new()
_plate.color = Color(UITheme.INK.r, UITheme.INK.g, UITheme.INK.b, 0.88)
_plate.mouse_filter = Control.MOUSE_FILTER_IGNORE
_plate.set_anchors_and_offsets_preset(Control.PRESET_BOTTOM_WIDE)
_plate.offset_top = -62
_plate.offset_left = INSET
_plate.offset_right = -INSET
_plate.offset_bottom = -INSET
add_child(_plate)
var stack := VBoxContainer.new()
stack.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
stack.add_theme_constant_override("separation", -2)
stack.mouse_filter = Control.MOUSE_FILTER_IGNORE
_plate.add_child(stack)
_label = Label.new()
_label.text = title
_label.add_theme_font_size_override("font_size", 28)
_label.add_theme_color_override("font_color", UITheme.PAPER)
_label.add_theme_color_override("font_outline_color", UITheme.INK)
_label.add_theme_constant_override("outline_size", 6)
_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
stack.add_child(_label)
_mode_label = Label.new()
_mode_label.add_theme_font_size_override("font_size", 16)
_mode_label.add_theme_color_override("font_color", UITheme.PAPER_DIM)
_mode_label.add_theme_color_override("font_outline_color", UITheme.INK)
_mode_label.add_theme_constant_override("outline_size", 4)
_mode_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
stack.add_child(_mode_label)
# The label follows the frame, same rule as every other control: hover turns
# the frame volt, so the name turns volt with it.
mouse_entered.connect(func(): _tint(UITheme.VOLT))
mouse_exited.connect(func(): _tint(UITheme.PAPER))
focus_entered.connect(func(): _tint(UITheme.CYAN))
focus_exited.connect(func(): _tint(UITheme.PAPER))
## What pressing this card will start. Shown under the name so the player is
## never guessing which mode the button they are about to press launches.
func set_mode_text(text: String) -> void:
if _mode_label:
_mode_label.text = text
func _tint(c: Color) -> void:
if _label:
_label.add_theme_color_override("font_color", c)
func _fill(c: Control) -> void:
c.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
c.offset_left = INSET
c.offset_top = INSET
c.offset_right = -INSET
c.offset_bottom = -INSET
func _frame(fill: Color, border: Color, width: int) -> StyleBoxFlat:
var sb := StyleBoxFlat.new()
sb.bg_color = fill
sb.border_color = border
sb.set_border_width_all(width)
sb.set_corner_radius_all(5)
sb.shadow_color = Color(UITheme.INK.r, UITheme.INK.g, UITheme.INK.b, 0.6)
sb.shadow_size = 4
sb.shadow_offset = Vector2(5, 5)
return sb
+1
View File
@@ -0,0 +1 @@
uid://2bfw74vxcitf
+669 -532
View File
File diff suppressed because it is too large Load Diff
+87 -39
View File
@@ -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()
+169
View File
@@ -0,0 +1,169 @@
extends Control
class_name MatchSummary
## The end of a match: who won, why, and what everyone did.
##
## There was nothing here before. The clock reached zero, `match_active` went
## false, the timer froze at 00:00, and the players kept shooting each other in a
## match that had stopped counting. A mode without an ending is a mode without
## stakes — everything a player does in the last minute only matters if there is
## a last minute.
##
## Built from the theme's own parts, so it reads as the same game as the menu it
## came from and the HUD it covers.
signal play_again
signal to_menu
var _result: Dictionary = {}
func show_result(result: Dictionary) -> void:
_result = result
for c in get_children():
c.queue_free()
_build()
visible = true
# The summary takes the mouse, which the match had captured.
Input.set_mouse_mode(Input.MOUSE_MODE_VISIBLE)
func _build() -> void:
set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
mouse_filter = Control.MOUSE_FILTER_STOP
var wash := ColorRect.new()
wash.color = Color(UITheme.INK.r, UITheme.INK.g, UITheme.INK.b, 0.86)
wash.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
wash.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(wash)
var centre := CenterContainer.new()
centre.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
add_child(centre)
var card := UITheme.card()
card.custom_minimum_size = Vector2(880, 0)
centre.add_child(card)
var col := VBoxContainer.new()
col.add_theme_constant_override("separation", 10)
card.add_child(col)
# ── Who won, and why ────────────────────────────────────────────────────
var winner := String(_result.get("name", "DRAW"))
var title := UITheme.title(winner if winner == "DRAW" else winner + " WINS",
68, -2.0)
title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
# A winning TEAM's name is written in that team's colour. The wordmark
# treatment is papaya by default, which on "MAGENTA TEAM WINS" says the
# opposite of what the words do.
var win_team := int(_result.get("team", 0))
if win_team > 0:
title.add_theme_color_override("font_color", GameMode.team_color(win_team))
col.add_child(title)
# The REASON, not just the result. "Time" and "frag limit" are different
# stories about the same scoreline and the player was in one of them.
var reason := "TIME"
if String(_result.get("reason", "")) == "score":
reason = "%s LIMIT REACHED" % GameMode.score_noun(
String(_result.get("mode", GameMode.DEATHMATCH)))
var sub := UITheme.caption("%s%s" % [
GameMode.display_name(String(_result.get("mode", GameMode.DEATHMATCH))),
reason], 22)
sub.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
col.add_child(sub)
var rule := UITheme.divider(0.42)
rule.custom_minimum_size = Vector2(0, 20)
col.add_child(rule)
# ── Team scores, if this was a team mode ────────────────────────────────
var mode := String(_result.get("mode", GameMode.DEATHMATCH))
var stats: Dictionary = _result.get("stats", {})
if GameMode.is_team_mode(mode):
var totals := GameMode.team_scores(mode, stats)
var row := HBoxContainer.new()
row.alignment = BoxContainer.ALIGNMENT_CENTER
row.add_theme_constant_override("separation", 46)
col.add_child(row)
for t in totals:
var box := VBoxContainer.new()
var nm := UITheme.heading(GameMode.team_name(t), 24)
nm.add_theme_color_override("font_color", GameMode.team_color(t))
nm.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
box.add_child(nm)
var sc := UITheme.heading(str(totals[t]), 54)
sc.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
box.add_child(sc)
row.add_child(box)
# ── The standings ───────────────────────────────────────────────────────
var grid := GridContainer.new()
grid.columns = 6
grid.add_theme_constant_override("h_separation", 34)
grid.add_theme_constant_override("v_separation", 6)
col.add_child(grid)
for h in ["#", "PLAYER", GameMode.score_noun(mode), "K", "D", "BEST RUN"]:
var lbl := UITheme.caption(h, 20)
grid.add_child(lbl)
var order: Array = _result.get("standings", [])
for i in order.size():
var pid = order[i]
var s: Dictionary = stats.get(pid, {})
var place := i + 1
# First place gets the volt. It is the only place in this UI outside the
# moment of input that volt appears, and winning is worth the exception.
var tint: Color = UITheme.VOLT if place == 1 else UITheme.PAPER
grid.add_child(_cell(str(place), tint, place == 1))
var who := _cell(String(s.get("username", "Player")), tint, place == 1)
if int(s.get("team", 0)) > 0:
who.add_theme_color_override("font_color",
GameMode.team_color(int(s.get("team", 0))))
grid.add_child(who)
grid.add_child(_cell(str(s.get("score", 0)), tint, place == 1))
grid.add_child(_cell(str(s.get("kills", 0)), tint, place == 1))
grid.add_child(_cell(str(s.get("deaths", 0)), tint, place == 1))
grid.add_child(_cell("x%d" % int(s.get("best_streak", 0)), tint, place == 1))
col.add_child(UITheme.divider(0.6))
# ── Out ─────────────────────────────────────────────────────────────────
var buttons := HBoxContainer.new()
buttons.alignment = BoxContainer.ALIGNMENT_CENTER
buttons.add_theme_constant_override("separation", 18)
col.add_child(buttons)
var again := UITheme.primary_button("PLAY AGAIN", 34)
again.pressed.connect(func(): play_again.emit())
buttons.add_child(again)
var menu := Button.new()
menu.text = "Main Menu"
menu.add_theme_font_size_override("font_size", 28)
menu.pressed.connect(func(): to_menu.emit())
buttons.add_child(menu)
# Only the host may restart a running server; everyone else waits for them.
var nm = get_tree().root.get_node_or_null("NetworkManager")
if nm and nm.multiplayer.has_multiplayer_peer() \
and not nm.multiplayer.multiplayer_peer is OfflineMultiplayerPeer \
and not nm.multiplayer.is_server():
again.disabled = true
again.text = "WAITING FOR HOST"
UITheme.wire_sounds(self)
again.grab_focus()
func _cell(text: String, tint: Color, bold: bool) -> Label:
var l := Label.new()
l.text = text
l.add_theme_font_size_override("font_size", 30 if bold else 26)
l.add_theme_color_override("font_color", tint)
l.add_theme_color_override("font_outline_color", UITheme.INK)
l.add_theme_constant_override("outline_size", 6)
return l
+1
View File
@@ -0,0 +1 @@
uid://ygajwj3bek8k
+682
View File
@@ -0,0 +1,682 @@
extends CanvasLayer
class_name PlayerHUD
## The first-person HUD, in the same hand as the rest of the game.
##
## It is one place now. The reticle used to be five white ColorRects pasted into
## three different level runtimes, the vitals were two stock ProgressBars with a
## flat colour override buried 1200 lines into the movement controller, and the
## ammo count — the single number a shooter's player looks at most — was not on
## screen at all. Nothing shared the ink edge, the lean or the palette that every
## menu in the game is built from, so crossing from the main menu into a match
## looked like crossing into a different product.
##
## The layout follows the one rule that matters for a HUD: the player is looking
## at the CENTRE of the screen, so everything the HUD says is arranged by how
## urgently it needs to interrupt that.
##
## centre the reticle, and confirmations, and the reload ring. Read
## constantly, without moving the eye.
## bottom left vitals, and the movement chain above them. Glanced at between
## engagements.
## bottom right ammo, grenades, ability cooldowns. Same.
## top left the debug readout, and only when a setting asks for it.
## nowhere else everything that is not one of those things.
##
## Match state — timer, score, killfeed, scoreboard — belongs to ui/match_hud.gd
## and is deliberately not duplicated here.
##
## ── Why this owns the abilities and the chain ────────────────────────────────
##
## Those used to be built by the LEVEL, in three separate runtime scripts that
## each carried their own byte-identical copy of a black rounded panel. A level
## cannot know a player's dash cooldown without reaching down into that player's
## state machine every frame, which is what all three did, and it cannot show the
## right thing in a split-screen or spectator case at all. More immediately: the
## level's ammo panel and this one's both existed, so the screen showed the ammo
## count twice, in two different styles, overlapping.
##
## The rule is that anything describing THE PLAYER belongs to the player's HUD,
## and a level owns the level.
## The player this HUD belongs to. Set before adding to the tree.
var player: Node = null
var crosshair: Crosshair
var reload_ring: ReloadRing
var death_screen: Control
var _health: VitalBar
var _shield: VitalBar
var _health_num: Label
var _shield_num: Label
var _shield_row: Control
var _ammo_num: Label
var _ammo_max: Label
var _weapon_name: Label
var _ammo_card: PanelContainer
var _grenade_row: HBoxContainer
var _chain_row: Control
var _chain_num: Label
var _chain_bonus: Label
var _shown_chain: int = 0
var _dash_chip: AbilityChip
var _grapple_chip: AbilityChip
var _debug_box: VBoxContainer
var _fps_line: Label
var _speed_line: Label
var _state_line: Label
var _wman: Node = null
var _machine: Node = null
## Crosshair bloom sources, all 0..1, combined as the largest rather than the sum
## so a sprinting player who fires does not blow the reticle off the screen.
var _fire_bloom: float = 0.0
var _shown_ammo: int = -1
## Speed at which movement alone fully blooms the reticle.
const BLOOM_SPEED := 14.0
## How fast a shot's bloom recovers. Roughly a fifth of a second, which is short
## enough to keep pace with an automatic weapon.
const FIRE_RECOVER := 5.0
const SPREAD_SMOOTH := 12.0
var _spread: float = 0.0
## Below this fraction of a magazine the ammo readout goes volt and the count
## starts to matter.
const LOW_AMMO := 0.34
func _ready() -> void:
layer = 1 # above the weapon viewmodel's canvas, which sits at 0
UITheme.apply_global(get_tree())
_build_reticle()
_build_vitals()
_build_ammo()
_build_abilities()
_build_debug()
_build_death_screen()
# ── Public API ───────────────────────────────────────────────────────────────
func set_vitals(hp: float, hp_max: float, sh: float, sh_max: float) -> void:
_health.max_value = hp_max
_health.value = hp
_health_num.text = str(int(ceil(hp)))
# Health goes volt-tinted when it is the last thing standing between the
# player and a respawn. The bar breathes at the same threshold.
var frac: float = hp / maxf(hp_max, 1.0)
_health_num.add_theme_color_override("font_color",
UITheme.VOLT if frac <= VitalBar.LOW_AT else UITheme.PAPER)
_shield.max_value = sh_max
_shield.value = sh
_shield_num.text = str(int(ceil(sh)))
# A depleted shield is not information worth a permanent row — it hides, and
# reappears the moment it starts recharging. One less thing on screen.
_shield_row.visible = sh > 0.5
## A landed shot. `fatal` gets the louder kill confirmation.
func confirm_hit(fatal: bool = false) -> void:
if crosshair:
crosshair.confirm(fatal)
# ── Build ────────────────────────────────────────────────────────────────────
func _build_reticle() -> void:
crosshair = Crosshair.new()
crosshair.name = "Crosshair"
add_child(crosshair)
reload_ring = load("res://ui/reload_ring.gd").new()
reload_ring.name = "ReloadRing"
# Deliberately NOT re-anchored here. The ring centres itself inside its own
# full rect; a PRESET_CENTER here ran after its `_ready` and undid that,
# which is how it ended up as a zero-sized control at (960, 960).
# The ring sits outside the reticle's bloom so a reload in progress never
# obscures the ticks, and it is papaya rather than white so it belongs.
reload_ring.radius = 34.0
reload_ring.thickness = 5.0
reload_ring.color = UITheme.PAPAYA
add_child(reload_ring)
func _build_vitals() -> void:
# Full rect plus margins — see the note in `_build_ammo` for why a corner
# preset collapses.
var margin := MarginContainer.new()
margin.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
margin.add_theme_constant_override("margin_left", 34)
margin.add_theme_constant_override("margin_bottom", 30)
margin.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(margin)
# Row pushes left, column pushes down — see the note in `_build_ammo`.
var row := HBoxContainer.new()
row.alignment = BoxContainer.ALIGNMENT_BEGIN
row.mouse_filter = Control.MOUSE_FILTER_IGNORE
margin.add_child(row)
var col := VBoxContainer.new()
col.alignment = BoxContainer.ALIGNMENT_END
col.size_flags_vertical = Control.SIZE_SHRINK_END
col.add_theme_constant_override("separation", 8)
col.mouse_filter = Control.MOUSE_FILTER_IGNORE
row.add_child(col)
_build_chain(col)
# Shield above health: it is the layer that goes first, so it reads as the
# outer one. Its whole row hides when empty rather than sitting at zero.
var sh := _vital_row("SHIELD", UITheme.CYAN, 30, 200.0)
_shield = sh[0]
_shield_num = sh[1]
_shield_row = sh[2]
col.add_child(_shield_row)
var hp := _vital_row("HEALTH", UITheme.PAPAYA, 46, 250.0)
_health = hp[0]
_health_num = hp[1]
col.add_child(hp[2])
## One vital: a tag, the bar, and the numeral BESIDE the bar.
##
## Beside, not centred on it — see the note in VitalBar. Returns
## `[bar, numeral, row]`, because the caller needs all three.
func _vital_row(tag: String, col: Color, num_size: int, width: float) -> Array:
var row := HBoxContainer.new()
row.add_theme_constant_override("separation", 12)
row.mouse_filter = Control.MOUSE_FILTER_IGNORE
var stack := VBoxContainer.new()
stack.add_theme_constant_override("separation", 1)
row.add_child(stack)
var label := Label.new()
label.text = tag
label.add_theme_font_size_override("font_size", 15)
label.add_theme_color_override("font_color", col)
label.add_theme_color_override("font_outline_color", UITheme.INK)
label.add_theme_constant_override("outline_size", 5)
stack.add_child(label)
var bar := VitalBar.new()
bar.fill_color = col
bar.custom_minimum_size = Vector2(width, 22)
stack.add_child(bar)
var num := Label.new()
num.text = "100"
num.add_theme_font_size_override("font_size", num_size)
num.add_theme_color_override("font_color", UITheme.PAPER)
num.add_theme_color_override("font_outline_color", UITheme.INK)
num.add_theme_constant_override("outline_size", 9)
num.vertical_alignment = VERTICAL_ALIGNMENT_BOTTOM
row.add_child(num)
return [bar, num, row]
func _build_ammo() -> void:
# FULL RECT plus margins, not a corner preset.
#
# A MarginContainer anchored to BOTTOM_RIGHT has zero size and grows from the
# corner, so its `margin_right` pushes the content INTO a container that is
# not there — the card collapsed to a sliver hanging off the right edge of
# the screen with the ammo count clipped inside it. Owning the whole rect and
# aligning to the end is unambiguous, and it is what the ability row does.
var margin := MarginContainer.new()
margin.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
margin.add_theme_constant_override("margin_right", 34)
margin.add_theme_constant_override("margin_bottom", 30)
margin.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(margin)
# A BoxContainer's own `alignment` is the reliable way to push content to an
# edge; a SHRINK_END size flag on the box itself is not, because the box's
# minimum width depends on children that may be hidden and it ends up placed
# by its own START. So the corner is built out of two boxes, each aligning on
# the axis it actually controls: the row pushes right, the column pushes
# down. This is the same scaffold the ability row uses.
var row := HBoxContainer.new()
row.alignment = BoxContainer.ALIGNMENT_END
row.mouse_filter = Control.MOUSE_FILTER_IGNORE
margin.add_child(row)
var col := VBoxContainer.new()
col.alignment = BoxContainer.ALIGNMENT_END
col.size_flags_vertical = Control.SIZE_SHRINK_END
col.add_theme_constant_override("separation", 6)
col.mouse_filter = Control.MOUSE_FILTER_IGNORE
row.add_child(col)
# Grenades as pips rather than a number: three of a thing is countable, and
# it matches the segmented vitals rather than introducing a second idiom.
_grenade_row = HBoxContainer.new()
_grenade_row.alignment = BoxContainer.ALIGNMENT_END
_grenade_row.add_theme_constant_override("separation", 5)
col.add_child(_grenade_row)
_ammo_card = UITheme.card()
_ammo_card.mouse_filter = Control.MOUSE_FILTER_IGNORE
col.add_child(_ammo_card)
var inner := VBoxContainer.new()
inner.alignment = BoxContainer.ALIGNMENT_END
inner.add_theme_constant_override("separation", 0)
_ammo_card.add_child(inner)
_weapon_name = Label.new()
_weapon_name.text = ""
_weapon_name.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
_weapon_name.add_theme_font_size_override("font_size", 18)
_weapon_name.add_theme_color_override("font_color", UITheme.PAPER_DIM)
_weapon_name.add_theme_color_override("font_outline_color", UITheme.INK)
_weapon_name.add_theme_constant_override("outline_size", 5)
inner.add_child(_weapon_name)
# The two halves of the count are separate labels at different sizes, so the
# magazine reads as the number and the reserve reads as context. One
# "12 / 30" at a single size makes the player parse a string.
var count := HBoxContainer.new()
count.alignment = BoxContainer.ALIGNMENT_END
count.add_theme_constant_override("separation", 4)
inner.add_child(count)
_ammo_num = Label.new()
_ammo_num.text = "--"
_ammo_num.add_theme_font_size_override("font_size", 58)
_ammo_num.add_theme_color_override("font_color", UITheme.PAPER)
_ammo_num.add_theme_color_override("font_outline_color", UITheme.INK)
_ammo_num.add_theme_constant_override("outline_size", 10)
_ammo_num.vertical_alignment = VERTICAL_ALIGNMENT_BOTTOM
count.add_child(_ammo_num)
_ammo_max = Label.new()
_ammo_max.text = ""
_ammo_max.add_theme_font_size_override("font_size", 24)
_ammo_max.add_theme_color_override("font_color", UITheme.PAPER_DIM)
_ammo_max.add_theme_color_override("font_outline_color", UITheme.INK)
_ammo_max.add_theme_constant_override("outline_size", 6)
_ammo_max.vertical_alignment = VERTICAL_ALIGNMENT_BOTTOM
count.add_child(_ammo_max)
## Dash and grapple, above the ammo card on the right.
##
## Right-hand side because they are the other half of "what can I do right now",
## which is the question the ammo count answers. Putting cooldowns on the left
## with the vitals would split that question across the screen.
func _build_abilities() -> void:
var margin := MarginContainer.new()
margin.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
margin.add_theme_constant_override("margin_right", 34)
margin.add_theme_constant_override("margin_bottom", 190)
margin.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(margin)
var row := HBoxContainer.new()
row.alignment = BoxContainer.ALIGNMENT_END
row.size_flags_vertical = Control.SIZE_SHRINK_END
row.add_theme_constant_override("separation", 8)
row.mouse_filter = Control.MOUSE_FILTER_IGNORE
margin.add_child(row)
_grapple_chip = AbilityChip.new()
_grapple_chip.label = "GRAPPLE"
_grapple_chip.custom_minimum_size = Vector2(116, 34)
row.add_child(_grapple_chip)
_dash_chip = AbilityChip.new()
_dash_chip.label = "DASH"
row.add_child(_dash_chip)
## The movement chain, above the vitals.
##
## Movement is this game's stated first pillar and chaining mechanics is its
## skill expression, so the chain count is not a debug readout — it is the score
## of the thing the game is about, and it belongs on the HUD at a size that says
## so. It hides at zero, because a chain of nothing is not worth screen space.
func _build_chain(into: Container) -> void:
_chain_row = HBoxContainer.new()
_chain_row.add_theme_constant_override("separation", 8)
_chain_row.mouse_filter = Control.MOUSE_FILTER_IGNORE
_chain_row.visible = false
into.add_child(_chain_row)
_chain_num = Label.new()
_chain_num.text = "x0"
_chain_num.add_theme_font_size_override("font_size", 40)
_chain_num.add_theme_color_override("font_color", UITheme.VOLT)
_chain_num.add_theme_color_override("font_outline_color", UITheme.INK)
_chain_num.add_theme_constant_override("outline_size", 9)
_chain_num.pivot_offset = Vector2(20, 20)
_chain_row.add_child(_chain_num)
_chain_bonus = Label.new()
_chain_bonus.text = ""
_chain_bonus.add_theme_font_size_override("font_size", 22)
_chain_bonus.add_theme_color_override("font_color", UITheme.PAPAYA)
_chain_bonus.add_theme_color_override("font_outline_color", UITheme.INK)
_chain_bonus.add_theme_constant_override("outline_size", 7)
_chain_bonus.vertical_alignment = VERTICAL_ALIGNMENT_BOTTOM
_chain_row.add_child(_chain_bonus)
## FPS / speed / state, top left, and only when a setting asks for them.
##
## Restyled rather than deleted: they are genuinely useful, and in a game about
## momentum the speed readout is arguably gameplay. What they are not is a reason
## to have neon green on pure black in the corner of an ink-drawn game, which is
## what three copies of this used to be.
func _build_debug() -> void:
var margin := MarginContainer.new()
margin.set_anchors_and_offsets_preset(Control.PRESET_TOP_LEFT)
margin.add_theme_constant_override("margin_left", 18)
margin.add_theme_constant_override("margin_top", 14)
margin.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(margin)
_debug_box = VBoxContainer.new()
_debug_box.add_theme_constant_override("separation", 0)
_debug_box.mouse_filter = Control.MOUSE_FILTER_IGNORE
margin.add_child(_debug_box)
_fps_line = UITheme.caption("", 22)
_fps_line.add_theme_color_override("font_color", UITheme.VOLT)
_debug_box.add_child(_fps_line)
_speed_line = UITheme.caption("", 22)
_speed_line.add_theme_color_override("font_color", UITheme.CYAN)
_debug_box.add_child(_speed_line)
_state_line = UITheme.caption("", 18)
_debug_box.add_child(_state_line)
func _build_death_screen() -> void:
death_screen = Control.new()
death_screen.name = "DeathScreen"
death_screen.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
death_screen.mouse_filter = Control.MOUSE_FILTER_IGNORE
death_screen.visible = false
add_child(death_screen)
# Ink wash rather than plain black: the whole game's darkness is a violet
# near-black, and a neutral 70% black over it reads as a bug.
var wash := ColorRect.new()
wash.color = Color(UITheme.INK.r, UITheme.INK.g, UITheme.INK.b, 0.78)
wash.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
wash.mouse_filter = Control.MOUSE_FILTER_IGNORE
death_screen.add_child(wash)
var center := CenterContainer.new()
center.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
center.mouse_filter = Control.MOUSE_FILTER_IGNORE
death_screen.add_child(center)
var col := VBoxContainer.new()
col.alignment = BoxContainer.ALIGNMENT_CENTER
col.add_theme_constant_override("separation", 10)
center.add_child(col)
var title := UITheme.title("DOWNED", 104, -3.0)
title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
col.add_child(title)
var rule := UITheme.divider(0.5)
rule.custom_minimum_size = Vector2(520, 18)
col.add_child(rule)
var prompt := UITheme.heading("PRESS FIRE TO REDEPLOY", 30)
prompt.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
col.add_child(prompt)
var tw := create_tween().set_loops()
tw.set_trans(Tween.TRANS_SINE)
tw.tween_property(prompt, "modulate:a", 0.25, 0.7)
tw.tween_property(prompt, "modulate:a", 1.0, 0.7)
# ── Per-frame ────────────────────────────────────────────────────────────────
func _process(delta: float) -> void:
if not is_instance_valid(player):
return
_update_weapon(delta)
_update_crosshair(delta)
_update_movement()
_update_debug()
## Chain count and ability cooldowns, both read off the movement state machine.
func _update_movement() -> void:
var sm := _state_machine()
if sm == null:
return
var chain := int(sm.chain_count) if "chain_count" in sm else 0
_chain_row.visible = chain > 0
if chain > 0:
_chain_num.text = "x%d" % chain
var bonus: float = float(sm.current_chain_bonus) if "current_chain_bonus" in sm else 0.0
_chain_bonus.text = "+%d%% SPEED" % int(round(bonus * 100.0))
# Each new link punches the number up and lets it settle. Momentum is the
# thing this game rewards, so extending a chain should feel like landing
# something rather than like a counter incrementing.
if chain > _shown_chain:
var tw := create_tween()
tw.set_trans(Tween.TRANS_BACK).set_ease(Tween.EASE_OUT)
_chain_num.scale = Vector2(1.35, 1.35)
tw.tween_property(_chain_num, "scale", Vector2.ONE, 0.28)
_shown_chain = chain
var state := String(sm.current_state) if "current_state" in sm else ""
var shooting: bool = "is_grapple_shooting" in sm and sm.is_grapple_shooting
_grapple_chip.active = state == "grapple" or shooting
if sm.has_method("get_dash_cooldown_remaining"):
var rem: float = sm.get_dash_cooldown_remaining()
# Normalised against the params' own cooldown, so retuning the dash
# retunes the readout with it instead of leaving the wipe lying.
var total := 1.0
if "params" in player and player.params and "dash_cooldown" in player.params:
total = maxf(float(player.params.dash_cooldown), 0.001)
_dash_chip.cooldown = clampf(rem / total, 0.0, 1.0)
func _update_debug() -> void:
# Resolved through the tree rather than by the autoload's global identifier.
# A tool script launched with `-s` compiles its dependencies BEFORE autoloads
# are registered, so naming `SettingsManager` directly makes this whole file
# fail to compile under debug/hud_layout_check.gd — which is precisely the
# harness that has to be able to load it.
var settings := _settings()
if settings == null:
return
var sm := _state_machine()
var show_debug: bool = settings.show_debug_ui
_fps_line.visible = settings.show_fps
if _fps_line.visible:
_fps_line.text = "FPS %d" % Engine.get_frames_per_second()
_speed_line.visible = settings.show_movement_speed or show_debug
if _speed_line.visible:
var v: Vector3 = player.velocity if "velocity" in player else Vector3.ZERO
var h := Vector2(v.x, v.z).length()
_speed_line.text = "%.1f m/s" % h if not show_debug \
else "%.1f m/s (total %.1f)" % [h, v.length()]
_state_line.visible = show_debug and sm != null
if _state_line.visible:
_state_line.text = String(sm.current_state).to_upper()
## Ammo, weapon name and grenades, read off whatever is in the player's hands.
##
## Read rather than pushed, because the weapon is the authority on its own ammo
## and there are eleven weapon scripts. A HUD that had to be notified would mean
## eleven places to forget to notify it.
func _update_weapon(_delta: float) -> void:
var w := _active_weapon()
if w == null:
_ammo_card.visible = false
return
_ammo_card.visible = true
# Named by the weapon, or derived from its script if it forgot to say. A HUD
# that silently shows a blank where the weapon's name goes is worse than one
# that shows a slightly ugly name.
if "weapon_name" in w and String(w.weapon_name) != "":
_weapon_name.text = String(w.weapon_name).to_upper()
else:
var src: Script = w.get_script()
_weapon_name.text = src.resource_path.get_file().get_basename() \
.replace("_", " ").to_upper() if src else "WEAPON"
# The shotgun counts shells, everything else counts rounds. Both are "how
# many more times can I pull the trigger", which is the only question the
# number answers.
var now := -1
var cap := -1
if "current_ammo" in w:
now = int(w.current_ammo)
cap = int(w.max_ammo) if "max_ammo" in w else -1
elif "shells" in w:
now = int(w.shells)
cap = int(w.max_shells) if "max_shells" in w else -1
if now < 0:
# A melee weapon has no count. Showing "0" would read as empty.
_ammo_num.text = ""
_ammo_max.text = ""
else:
_ammo_num.text = str(now)
_ammo_max.text = "/ %d" % cap if cap > 0 else ""
var frac: float = float(now) / float(maxi(cap, 1))
var reloading: bool = "reloading" in w and w.reloading
var col: Color = UITheme.PAPER
if now == 0:
col = UITheme.MAGENTA
elif frac <= LOW_AMMO:
col = UITheme.VOLT
if reloading:
col = UITheme.PAPER_DIM
_ammo_num.add_theme_color_override("font_color", col)
# A shot fired blooms the reticle. Detected from the count dropping
# rather than from a signal, for the same reason the count is read.
if _shown_ammo >= 0 and now < _shown_ammo:
_fire_bloom = 1.0
_shown_ammo = now
_update_grenades()
## Grenades as pips, rebuilt only when the count changes.
##
## Built once and then shown/hidden rather than freed, because `queue_free` is
## deferred: a loop that frees down to a target count sees the same child count
## on the next iteration and frees the whole row.
func _update_grenades() -> void:
var count := int(player.grenades) if "grenades" in player else 0
while _grenade_row.get_child_count() < maxi(count, _grenade_pips):
var pip := Panel.new()
pip.custom_minimum_size = Vector2(17, 17)
pip.mouse_filter = Control.MOUSE_FILTER_IGNORE
# A filled chip with the theme's ink edge, so a 17 px pip still reads
# against a bright skybox.
pip.add_theme_stylebox_override("panel", UITheme.row(UITheme.CYAN))
_grenade_row.add_child(pip)
for i in _grenade_row.get_child_count():
(_grenade_row.get_child(i) as Control).visible = i < count
## The most pips ever needed. Grenades only ever go down during a life and back
## up on respawn, so this is the starting count.
const _grenade_pips := 3
## Reticle bloom and the ADS blend.
##
## Sources are combined with `max`, not by adding: a sprinting player who fires
## should see the reticle at its bloomed size, not at twice it.
func _update_crosshair(delta: float) -> void:
_fire_bloom = maxf(_fire_bloom - delta * FIRE_RECOVER, 0.0)
var speed := 0.0
if "velocity" in player:
var v: Vector3 = player.velocity
speed = Vector2(v.x, v.z).length()
var move := clampf(speed / BLOOM_SPEED, 0.0, 1.0)
var state := String(player.synced_movement_state) if "synced_movement_state" in player else ""
# Airborne is the least accurate a player can be, and a reticle that says so
# is what stops mid-air spraying from feeling arbitrary.
var air := 0.75 if state in ["air", "dash", "grapple"] else 0.0
var want: float = maxf(maxf(move, air), _fire_bloom)
_spread = lerpf(_spread, want, 1.0 - exp(-SPREAD_SMOOTH * delta))
crosshair.spread = _spread
var ads := 0.0
if "synced_is_ads" in player and player.synced_is_ads:
ads = 1.0
crosshair.ads = lerpf(crosshair.ads, ads, 1.0 - exp(-14.0 * delta))
# The reload ring reads the weapon directly, same as the ammo count.
var w := _active_weapon()
if w and "reloading" in w and w.reloading and "reload_timer" in w \
and "reload_time" in w and w.reload_time > 0.0:
reload_ring.progress = 1.0 - (w.reload_timer / w.reload_time)
else:
reload_ring.progress = 0.0
## The settings autoload, or null when the HUD is being exercised outside a
## running game. See the note in `_update_debug`.
func _settings() -> Node:
if not is_instance_valid(_settings_node):
_settings_node = get_tree().root.get_node_or_null("SettingsManager")
return _settings_node if is_instance_valid(_settings_node) else null
var _settings_node: Node = null
## The player's movement state machine, or null. Cached, since it is asked for
## several times a frame and never moves.
func _state_machine() -> Node:
if not is_instance_valid(_machine):
_machine = player.get_node_or_null("MovementStateMachine")
return _machine if is_instance_valid(_machine) else null
## Whatever weapon is in the player's hands, or null.
func _active_weapon() -> Node:
if not is_instance_valid(_wman):
var cam = player.get("camera") if "camera" in player else null
if is_instance_valid(cam):
_wman = cam.get_node_or_null("WeaponManager")
if not is_instance_valid(_wman):
return null
var slot = _wman.get("active_slot")
if slot == null or not _wman.weapons.has(slot):
return null
var w = _wman.weapons[slot]
return w if is_instance_valid(w) else null
+1
View File
@@ -0,0 +1 @@
uid://hjsfcdnutb4t
+28 -6
View File
@@ -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
View File
@@ -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
View File
@@ -0,0 +1,186 @@
extends Control
class_name VitalBar
## A health or shield bar in the game's own hand: sheared, ink-edged, segmented,
## with a drain ghost behind the fill.
##
## It replaces a stock ProgressBar with a flat colour override, and each of the
## three things it adds answers a question the ProgressBar could not.
##
## SEGMENTS — how much is left, without reading a number. A continuous bar has to
## be measured against its own ends; a bar cut into blocks of 25 can be COUNTED,
## and counting is faster than estimating and survives being glimpsed in
## peripheral vision during a firefight. This is the same reason HoYoverse's
## action UIs chunk their meters rather than drawing one smooth sweep.
##
## THE DRAIN GHOST — how much was just lost. The fill snaps to the new value
## immediately, because the player must never be told they have more health than
## they do; a paler ghost holds the old value for a beat and then catches up. The
## gap between them is the size of the hit, which is information that does not
## exist anywhere on a bar that simply gets shorter.
##
## THE SHEAR — because everything else in this UI leans. A square meter under
## leaning chips reads as a widget from a different game.
##
## The numeral deliberately lives OUTSIDE this control, beside the bar rather
## than centred on it. Text over a two-tone bar cannot be given a colour that
## beats both the fill and the trough, which is what debug/ui_contrast_check.gd
## measured at 2.4:1 on the old HUD. Moving it off the fill fixes that at the
## source instead of relying on an outline to rescue it.
var value: float = 100.0:
set(v):
var c := clampf(v, 0.0, max_value)
if c < value - 0.01:
# Lost some: the ghost stays where it was and the flash fires.
_flash = 1.0
_lag_hold = LAG_HOLD
elif c > value + 0.01:
# Gained some: the ghost has nothing to show, so bring it along.
_lag = c
value = c
queue_redraw()
var max_value: float = 100.0:
set(v):
max_value = maxf(v, 1.0)
_lag = minf(_lag, max_value)
queue_redraw()
## The charged colour of the fill. Health is papaya, shield is cyan.
var fill_color: Color = UITheme.PAPAYA:
set(v):
fill_color = v
queue_redraw()
## One block per this many points. 25 gives a 100-point bar four blocks, which is
## the most the eye can count without moving.
var per_segment: float = 25.0
## Below this fraction the bar breathes, so low health is felt rather than read.
const LOW_AT := 0.3
const LOW_RATE := 5.5
## How long the ghost holds the old value before draining, and how fast it goes.
const LAG_HOLD := 0.35
const LAG_RATE := 55.0
const SHEAR := 6.0
const INK_W := 3.0
const GAP := 3.0
var _lag: float = 100.0
var _lag_hold: float = 0.0
var _flash: float = 0.0
var _pulse: float = 0.0
func _ready() -> void:
mouse_filter = Control.MOUSE_FILTER_IGNORE
custom_minimum_size = Vector2(230, 22)
_lag = value
func _process(delta: float) -> void:
var dirty := false
if _lag > value:
if _lag_hold > 0.0:
_lag_hold -= delta
else:
_lag = maxf(_lag - LAG_RATE * delta, value)
dirty = true
elif _lag < value:
_lag = value
dirty = true
if _flash > 0.0:
_flash = maxf(_flash - delta * 4.5, 0.0)
dirty = true
if value / max_value <= LOW_AT and value > 0.0:
_pulse += delta * LOW_RATE
dirty = true
elif _pulse != 0.0:
_pulse = 0.0
dirty = true
if dirty:
queue_redraw()
func _draw() -> void:
var w := size.x
var h := size.y
if w <= 1.0 or h <= 1.0:
return
# Trough: ink fill with a soft inner edge, so an empty bar is still a shape
# on the screen rather than a hole in it.
_shear_rect(0.0, w, UITheme.INK, h)
_shear_outline(0.0, w, UITheme.INK_SOFT, h)
var frac: float = clampf(value / max_value, 0.0, 1.0)
var lag_frac: float = clampf(_lag / max_value, 0.0, 1.0)
# Ghost first, so the live fill draws over its left end and only the
# difference between the two is visible.
if lag_frac > frac:
var ghost := Color(fill_color.r, fill_color.g, fill_color.b, 0.38)
_shear_rect(0.0, w * lag_frac, ghost, h)
if frac > 0.0:
var col := fill_color
# Low health breathes toward volt. It never goes fully volt: that colour
# means "input landed" everywhere else in the UI and spending it on a
# steady state would blunt it.
if frac <= LOW_AT:
var b: float = 0.5 + 0.5 * sin(_pulse)
col = col.lerp(UITheme.VOLT, 0.35 * b)
# The instant of damage whites the bar out briefly — the cheapest way to
# make a hit register before the number has been read.
if _flash > 0.0:
col = col.lerp(UITheme.PAPER, _flash * 0.7)
_segments(w, h, frac, col)
# Ink edge last so it sits on top of both fills and reads as a drawn border.
_shear_outline(0.0, w, UITheme.INK, h)
## The fill, cut into countable blocks.
##
## The last block is clipped rather than dropped, so the bar still moves
## continuously as damage lands inside a block — the segments are for reading
## the amount at a glance, not for quantising it.
func _segments(w: float, h: float, frac: float, col: Color) -> void:
var count := maxi(int(round(max_value / per_segment)), 1)
var seg_w := (w - GAP * (count - 1)) / count
var filled := w * frac
for i in count:
var x0 := i * (seg_w + GAP)
if x0 >= filled:
break
var x1: float = minf(x0 + seg_w, filled)
if x1 - x0 < 0.5:
continue
_shear_rect(x0, x1, col, h)
## A parallelogram from x0 to x1 — the bar's lean, matching the theme's chips.
func _shear_rect(x0: float, x1: float, col: Color, h: float) -> void:
draw_colored_polygon(PackedVector2Array([
Vector2(x0 + SHEAR, 0.0),
Vector2(x1 + SHEAR, 0.0),
Vector2(x1, h),
Vector2(x0, h),
]), col)
func _shear_outline(x0: float, x1: float, col: Color, h: float) -> void:
draw_polyline(PackedVector2Array([
Vector2(x0 + SHEAR, 0.0),
Vector2(x1 + SHEAR, 0.0),
Vector2(x1, h),
Vector2(x0, h),
Vector2(x0 + SHEAR, 0.0),
]), col, INK_W)
+1
View File
@@ -0,0 +1 @@
uid://1syuhwlflh2p
+13 -3
View File
@@ -3,7 +3,17 @@ class_name DoubleBarrelShotgun
@export var reload_time: float = 1.0 @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:
+292
View File
@@ -0,0 +1,292 @@
extends Object
class_name WeaponHoldProfiles
## How each weapon in the set is HELD — one archetype per class of weapon,
## rather than one rifle hold for all twelve.
##
## ── The problem this exists to fix ───────────────────────────────────────────
##
## `ShooterPoseModifier._apply_rifle_hold` did exactly what its name says, to
## everything. A knife, a rocket launcher and an AK were all solved as a rifle:
## stock in the shoulder pocket, support hand out along the barrel, muzzle on the
## aim line. In third person every character therefore stood in the same pose
## regardless of what they were carrying, and the ONLY thing distinguishing a
## sniper from a shotgun was the ~30 cm of gun mesh in their hands — which at the
## distance an enemy is usually seen is nothing.
##
## That matters beyond looking wrong. In a shooter the reason a character's pose
## is readable at range is that it is the fastest available answer to "what is
## about to happen to me". A shouldered tube means take cover; a blade held low
## means they have to close the distance; a rifle at low ready means they have not
## seen you yet. A single hold throws all of that away.
##
## It is also the specific thing HoYoverse's team say they chase in Zenless Zone
## Zero: characters read by SILHOUETTE first, and their designers deliberately
## refuse to settle on one construction method because a single method limits how
## distinguishable the results can be. The same argument applies one level down,
## to how a character holds a thing.
##
## ── What a profile controls ─────────────────────────────────────────────────
##
## A style is not a bundle of slider values. Three of the differences below
## cannot be expressed as a number on the existing rifle solve at all, and those
## are the ones that make the silhouette:
##
## support where the off hand goes, and how it is ORIENTED there —
## wrapped round a handguard, cupped under a pistol grip, hooked
## under a tube, or released entirely so the animation owns it
## mount whether the weapon's rear sits IN the shoulder pocket, ON TOP
## of the shoulder, or nowhere near it
## head whether the head comes down to the stock (a cheek weld) or
## leans away to clear a tube
##
## Everything else — the pocket offsets, the muzzle pitch at low ready, the elbow
## poles, the finger curls — is an ordinary knob, and the profile just supplies a
## better DEFAULT for that weapon than one global constant could.
##
## ── Layering ────────────────────────────────────────────────────────────────
##
## This is a defaults layer, underneath everything an artist has tuned:
##
## code constants the rifle solve's own fallbacks
## THIS FILE per weapon: what kind of thing it is
## weapon_holds.json defaults -> skins.<skin>._all -> skins.<skin>.<weapon>
##
## So a character with tuning saved from the rig lab is completely unaffected —
## aria's hand-tuned AK-47 hold still wins on every knob it sets — and a weapon
## nobody has tuned stops being held like an AK.
# ── The styles ───────────────────────────────────────────────────────────────
const RIFLE := "rifle"
const SMG := "smg"
const SNIPER := "sniper"
const SHOTGUN := "shotgun"
const LAUNCHER := "launcher"
const PISTOL := "pistol"
const BLADE := "blade"
# ── Where the support hand goes, and how it is turned ────────────────────────
## Wrapped round a handguard, fingers closing ACROSS the barrel. The rifle case.
const SUPPORT_BARREL := "barrel"
## Cupped under and around the firing fist. Two hands together, no shoulder
## contact — a pistol, or any weapon light enough to be held out in front.
const SUPPORT_CUPPED := "cupped"
## Hooked UNDER a tube from below, palm up, well forward of the shoulder. The
## launcher case, where there is no handguard to wrap and the weight is carried
## rather than aimed.
const SUPPORT_TUBE := "tube"
## No support hand at all. The animation keeps the arm, which is what a character
## carrying a blade should look like — the off hand swings with the run cycle.
const SUPPORT_FREE := "free"
## Style -> the structural rules for it.
##
## `pocket_hip` / `pocket_ads` are where the weapon's REAR sits, relative to the
## right shoulder joint, in skeleton space (x across, y up, z forward). They are
## the single most important number here: it is what decides whether a weapon
## reads as shouldered, carried, or held out.
##
## `cheek` is how far the head comes down and across to meet the stock, 0..1,
## applied only as the character shoulders the weapon. Negative leans the head
## AWAY, which is what a tube over the shoulder requires.
##
## `pitch_hip` is the muzzle's droop at low ready, in radians. A launcher's tube
## rides nose-UP because that is how you carry something you do not want pointed
## at your own feet; a blade points forward and in.
const STYLES := {
RIFLE: {
"support": SUPPORT_BARREL,
"pocket_hip": Vector3(0.03, -0.07, 0.06),
"pocket_ads": Vector3(0.05, 0.01, 0.07),
"pitch_hip": 0.16,
"cheek": 0.18,
"pole_r_hip": Vector3(-0.55, -0.85, -0.20),
"pole_l_hip": Vector3(0.45, -0.90, -0.10),
"curl_wrap": 1.0,
"curl_trigger": 1.0,
},
# Compact: the whole weapon is carried closer in, the support hand cannot go
# far because there is not much gun in front of the grip, and the firing elbow
# tucks rather than flares. This is what stops an MP7 from being posed as a
# short rifle with the support arm reaching for a handguard that ended.
SMG: {
"support": SUPPORT_BARREL,
"pocket_hip": Vector3(0.04, -0.10, 0.03),
"pocket_ads": Vector3(0.05, 0.00, 0.05),
"pitch_hip": 0.24,
"cheek": 0.10,
"pole_r_hip": Vector3(-0.40, -0.95, -0.15),
"pole_l_hip": Vector3(0.35, -0.95, -0.05),
"gun_fore": 0.17,
"curl_wrap": 1.05,
"curl_trigger": 1.0,
},
# The cheek weld IS the sniper silhouette. The head comes down onto the stock,
# the pocket sits high and tight so the optic lands at eye height, and the
# support hand goes far out because a long barrel gives it somewhere to go.
SNIPER: {
"support": SUPPORT_BARREL,
# Rides HIGH. A scoped rifle is held so the optic meets the eye, not so
# the butt meets the pocket, and raising the whole weapon is what makes
# that read — pushing the support hand further out does NOT, because the
# reach solver slides it back down the handguard until the arm can get
# there, so a longer `gun_fore` on these stylised arms lands the off hand
# in exactly the same place as a rifle's. Measured: 0.388 vs 0.387 m.
"pocket_hip": Vector3(0.015, 0.005, 0.045),
"pocket_ads": Vector3(0.040, 0.075, 0.055),
"pitch_hip": 0.12,
"cheek": 0.85,
"pole_r_hip": Vector3(-0.72, -0.55, -0.22),
"pole_l_hip": Vector3(0.22, -1.05, -0.02),
"gun_fore": 0.34,
"curl_wrap": 1.0,
"curl_trigger": 0.85,
},
# Held lower and squarer than a rifle, with the support hand back on the
# forend rather than out at the muzzle, and a hard wrap — a shotgun is gripped,
# not balanced.
SHOTGUN: {
"support": SUPPORT_BARREL,
# Carried low and FORWARD, the opposite of the sniper's high tuck. A
# shotgun is pointed rather than aimed and the elbows go wide, because
# the recoil comes back through them.
#
# Low and INBOARD does not work, however tempting the symmetry: +x is
# toward the character's centreline, so dropping the pocket and pushing
# it across at the same time swings the barrel through the chest. It is
# visible immediately in debug/hold_capture.gd and in no assertion —
# the hands were still exactly where they had been asked to go.
"pocket_hip": Vector3(0.030, -0.130, 0.080),
"pocket_ads": Vector3(0.045, -0.040, 0.075),
"pitch_hip": 0.30,
"cheek": 0.22,
"pole_r_hip": Vector3(-0.62, -0.80, -0.22),
"pole_l_hip": Vector3(0.58, -0.72, -0.16),
"gun_fore": 0.22,
"curl_wrap": 1.2,
"curl_trigger": 1.0,
},
# The tube goes ON the shoulder, not into it, and the head leans AWAY to clear
# it. The support hand hooks under from below, well forward, because there is
# nothing to wrap and the job of that arm is to carry weight.
LAUNCHER: {
"support": SUPPORT_TUBE,
"pocket_hip": Vector3(0.055, 0.06, 0.01),
"pocket_ads": Vector3(0.065, 0.105, 0.03),
"pitch_hip": -0.16,
"cheek": -0.30,
"pole_r_hip": Vector3(-0.85, -0.45, -0.10),
"pole_l_hip": Vector3(0.25, -0.85, 0.10),
"gun_stock": 0.30,
"gun_fore": 0.30,
"curl_wrap": 1.0,
"curl_trigger": 0.9,
},
# No shoulder contact at all. The pocket is pushed forward and outboard of the
# joint, which puts both arms out in front of the chest, and the support hand
# cups the firing fist instead of reaching for a barrel.
PISTOL: {
"support": SUPPORT_CUPPED,
"pocket_hip": Vector3(0.02, -0.16, 0.16),
"pocket_ads": Vector3(0.00, -0.04, 0.30),
"pitch_hip": 0.30,
"cheek": 0.05,
"pole_r_hip": Vector3(-0.45, -0.95, -0.05),
"pole_l_hip": Vector3(0.45, -0.95, -0.05),
"gun_stock": 0.03,
"gun_fore": 0.05,
"weapon_scale": 1.0,
"curl_wrap": 1.15,
"curl_trigger": 0.9,
},
# One hand. The off arm is RELEASED back to the animation, so it swings with
# the run cycle instead of gripping a handguard that does not exist — which is
# most of what makes a knife read as a knife at any distance.
BLADE: {
"support": SUPPORT_FREE,
"pocket_hip": Vector3(0.09, -0.20, 0.10),
"pocket_ads": Vector3(0.05, -0.06, 0.22),
"pitch_hip": 0.55,
"cheek": 0.0,
"pole_r_hip": Vector3(-0.35, -1.00, -0.10),
"pole_l_hip": Vector3(0.45, -0.90, -0.10),
"gun_stock": 0.02,
"gun_fore": 0.0,
"weapon_scale": 1.0,
# A blade is held in a full fist — there is no trigger to keep a finger
# straight along, and an extended index on a knife handle looks like a
# mistake rather than like discipline.
"curl_wrap": 1.25,
"curl_trigger": 1.25,
},
}
## Weapon id (the script's basename) -> style.
##
## Keyed on the basename rather than on the class, so this table does not have to
## load twelve weapon scripts to be read, and so a weapon that has not been
## written yet can be listed here the moment its file exists.
const WEAPON_STYLES := {
"ak47": RIFLE,
"m4": RIFLE,
"plasma_gun": RIFLE,
"mp7": SMG,
"nail_gun": SMG,
"dmr": SNIPER,
"awp": SNIPER,
"double_barrel_shotgun": SHOTGUN,
"rocket_launcher": LAUNCHER,
"rocket_swarm": LAUNCHER,
"mortar": LAUNCHER,
"knife": BLADE,
}
## What an unlisted weapon is held as. A rifle is the safe assumption: it is the
## only style that reaches for a handguard, and a weapon with no handguard held
## as a rifle looks odd, where a rifle held as anything else looks broken.
const FALLBACK := RIFLE
## The style name for a weapon id or script path.
static func style_for(weapon: String) -> String:
var id := _id_of(weapon)
return WEAPON_STYLES.get(id, FALLBACK)
## The default knob table for a weapon — the style's values, ready to be merged
## under whatever the JSON tuning says.
##
## Returns a COPY, because the caller merges the artist's values into it and a
## shared dictionary would accumulate one character's tuning into every other's.
static func knobs_for(weapon: String) -> Dictionary:
var style: Dictionary = STYLES.get(style_for(weapon), STYLES[FALLBACK])
var out := style.duplicate(true)
# `support` and `cheek` are structural, not knobs — they are read straight
# off the style by the pose layer and must not end up in the tuning table,
# where the rig lab would offer sliders for them.
out.erase("support")
out.erase("cheek")
return out
## How the off hand is used for this weapon. See the SUPPORT_* constants.
static func support_for(weapon: String) -> String:
var style: Dictionary = STYLES.get(style_for(weapon), STYLES[FALLBACK])
return style.get("support", SUPPORT_BARREL)
## How far the head comes to the stock, 0..1, negative to lean away.
static func cheek_for(weapon: String) -> float:
var style: Dictionary = STYLES.get(style_for(weapon), STYLES[FALLBACK])
return float(style.get("cheek", 0.0))
## A weapon id from either an id or a `res://weapons/<id>.gd` path.
static func _id_of(weapon: String) -> String:
if weapon.ends_with(".gd"):
return weapon.get_file().get_basename()
return weapon
+1
View File
@@ -0,0 +1 @@
uid://bhfyjgkpsvb2x
+52
View File
@@ -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):