diff --git a/assets/ui/map_previews/dust2.png b/assets/ui/map_previews/dust2.png new file mode 100644 index 0000000..333dd63 Binary files /dev/null and b/assets/ui/map_previews/dust2.png differ diff --git a/assets/ui/map_previews/fps_blockout.png b/assets/ui/map_previews/fps_blockout.png new file mode 100644 index 0000000..04fed9e Binary files /dev/null and b/assets/ui/map_previews/fps_blockout.png differ diff --git a/assets/ui/map_previews/neon_alley.png b/assets/ui/map_previews/neon_alley.png new file mode 100644 index 0000000..58a9567 Binary files /dev/null and b/assets/ui/map_previews/neon_alley.png differ diff --git a/assets/ui/map_previews/procedural_arena.png b/assets/ui/map_previews/procedural_arena.png new file mode 100644 index 0000000..43cdff0 Binary files /dev/null and b/assets/ui/map_previews/procedural_arena.png differ diff --git a/assets/ui/map_previews/test_level.png b/assets/ui/map_previews/test_level.png new file mode 100644 index 0000000..d5b2156 Binary files /dev/null and b/assets/ui/map_previews/test_level.png differ diff --git a/characters/dance_modifier.gd b/characters/dance_modifier.gd new file mode 100644 index 0000000..b996364 --- /dev/null +++ b/characters/dance_modifier.gd @@ -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 +## `.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) diff --git a/characters/dance_modifier.gd.uid b/characters/dance_modifier.gd.uid new file mode 100644 index 0000000..f5cfb65 --- /dev/null +++ b/characters/dance_modifier.gd.uid @@ -0,0 +1 @@ +uid://bab4y7ba0yl87 diff --git a/characters/dance_routines.gd b/characters/dance_routines.gd new file mode 100644 index 0000000..0a03b4b --- /dev/null +++ b/characters/dance_routines.gd @@ -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 diff --git a/characters/dance_routines.gd.uid b/characters/dance_routines.gd.uid new file mode 100644 index 0000000..1fc988b --- /dev/null +++ b/characters/dance_routines.gd.uid @@ -0,0 +1 @@ +uid://8p4yow40ni8p diff --git a/characters/rig_roles.gd b/characters/rig_roles.gd new file mode 100644 index 0000000..d9084f1 --- /dev/null +++ b/characters/rig_roles.gd @@ -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 `.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 diff --git a/characters/rig_roles.gd.uid b/characters/rig_roles.gd.uid new file mode 100644 index 0000000..f79655b --- /dev/null +++ b/characters/rig_roles.gd.uid @@ -0,0 +1 @@ +uid://coh341vhya5x5 diff --git a/characters/skinned_player_model.gd b/characters/skinned_player_model.gd index df527f4..451c59c 100644 --- a/characters/skinned_player_model.gd +++ b/characters/skinned_player_model.gd @@ -96,7 +96,12 @@ var _rig_info: Dictionary = {} ## the same sidecar; drives the per-class cel look and answers `surfaces_of()`. var _surfaces: SkinSurfaces = null var _spring_mod: SpringBones +## The emote layer, between the shooter pose and the cloth springs. +var _dance_mod: DanceModifier var is_holding_weapon: bool = false +## Which hold archetype the equipped weapon uses — see WeaponHoldProfiles. Read +## by `_process` (a blade releases the off arm) and by the checks. +var hold_style: String = WeaponHoldProfiles.RIFLE ## Which skin this is, so per-character hold tuning can be looked up. Set by ## whoever spawns the model; falls back to the GLB's basename. var skin_id: String = "" @@ -239,8 +244,17 @@ func load_model(path: String) -> void: _pose_mod.fingers = _rig_info.get("fingers", {}) _pose_mod.name = "ShooterPose" skeleton.add_child(_pose_mod) + # Dance AFTER the shooter pose, so an emote blends over the weapon hold + # instead of fighting it — the arms come off the gun as the dance weight + # rises and are handed back the same way. + _dance_mod = DanceModifier.new() + _dance_mod.name = "Dance" + _dance_mod.roles = _rig_info.get("roles", {}) + skeleton.add_child(_dance_mod) # Cloth and hair last, so the springs react to the FINAL body pose — - # animation plus the shooter lean/slide layer. + # animation plus the shooter lean/slide layer, plus any dance. Hair and + # a skirt following a dance is entirely this ordering; nothing else is + # needed to make it happen. if not _rig_info.is_empty(): _spring_mod = SpringBones.new() _spring_mod.name = "SpringBones" @@ -550,6 +564,14 @@ func _set_shadow_mode_recursive(node: Node, mode: int) -> void: var _prev_state: String = "" var _oneshot_lock: float = 0.0 # seconds left where a one-shot owns playback var _dancing: bool = false +## Which of DanceRoutines.ROUTINES is playing, and the eased 0..1 blend of the +## dance layer over everything below it. +var _dance_index: int = 0 +var _cur_dance: float = 0.0 +## How fast a dance takes the body and gives it back. Slower than the pose +## layer's other blends on purpose: an emote starting is not a reaction, and +## snapping into one looks like a bug rather than like a decision. +const DANCE_SMOOTH := 5.0 ## Play a one-shot clip over locomotion for `lock_time` seconds. @@ -574,8 +596,16 @@ func play_oneshot(canonical: String, lock_time: float = 0.35) -> void: ## Emote toggle (Dance). Shown while grounded and near-idle; any real ## movement breaks it (the controller clears the flag too). -func set_dancing(on: bool) -> void: +## +## `which` selects one of the five routines in DanceRoutines. The base clip stays +## the library's single `Dance_Loop` underneath — the routine is layered over it +## by DanceModifier, which is what makes five distinct emotes out of one clip. +func set_dancing(on: bool, which: int = -1) -> void: _dancing = on + if which >= 0: + _dance_index = which + if _dance_mod: + _dance_mod.routine = DanceRoutines.get_routine(_dance_index) ## Play a named gameplay action (reload / throw / shoot) as a one-shot. @@ -731,6 +761,12 @@ func _process(delta: float) -> void: _cur_slide = lerpf(_cur_slide, slide_target, t) var wall_target := _target_wall if _pose_mod.state == "wall_run" else 0.0 _cur_wall = lerpf(_cur_wall, wall_target, lean_t) + # The dance blend. Eased both ways, so an emote arrives and leaves rather + # than cutting — and so the arms come off the weapon smoothly. + if _dance_mod: + _cur_dance = lerpf(_cur_dance, 1.0 if _dancing else 0.0, + 1.0 - exp(-DANCE_SMOOTH * delta)) + _dance_mod.weight = _cur_dance _update_travel(delta, drive) _pose_mod.strafe = _cur_strafe _pose_mod.fwd = _cur_fwd @@ -782,7 +818,12 @@ func _process(delta: float) -> void: var hold_l := 0.0 if is_holding_weapon and not clip_owns_arms: hold_r = 1.0 - hold_l = 1.0 + # A one-handed weapon RELEASES the off arm back to the animation, so it + # swings with the run cycle instead of gripping a handguard that is not + # there. That released arm is most of what makes a knife read as a knife + # from across a map. + hold_l = 0.0 if _pose_mod.support_mode == WeaponHoldProfiles.SUPPORT_FREE \ + else 1.0 match st: "slide": hold_l = 0.0 # trailing arm braces the ground @@ -1018,12 +1059,27 @@ func set_weapon(script_path: String) -> void: is_holding_weapon = script_path != "" if script_path == "" or not skeleton: return - # Per-character, per-weapon hold overrides, if any have been tuned. Empty is - # the normal case and means "use what the code derives". + # How this KIND of weapon is held, then the per-character tuning on top. + # + # The profile is a defaults layer: it answers "what sort of thing is this" + # for a weapon nobody has tuned, and every knob an artist saved in the rig lab + # still wins, because the JSON is merged over it with overwrite. Aria's + # hand-tuned AK-47 hold is byte-for-byte what it was. var sid := skin_id if skin_id != "" else model_path.get_file().get_basename() + var weapon_id := script_path.get_file().get_basename() if hold_tune.is_empty(): - hold_tune = WeaponHoldTuning.resolve(WeaponHoldTuning.load_all(), sid, - script_path.get_file().get_basename()) + hold_tune = WeaponHoldProfiles.knobs_for(weapon_id) + hold_tune.merge(WeaponHoldTuning.resolve(WeaponHoldTuning.load_all(), + sid, weapon_id), true) + if _pose_mod: + # Structural, not tunable — see WeaponHoldProfiles. These decide where the + # off hand goes and how it is turned there, and whether the head comes + # down to the stock, which no slider on the rifle solve could express. + var style := WeaponHoldProfiles.style_for(weapon_id) + _pose_mod.support_mode = WeaponHoldProfiles.support_for(weapon_id) + _pose_mod.cheek = WeaponHoldProfiles.cheek_for(weapon_id) + _pose_mod.full_fist = style == WeaponHoldProfiles.BLADE + hold_style = WeaponHoldProfiles.style_for(weapon_id) # Anchors are per character, not per weapon — where a grip sits in a palm is # a fact about the hand — so unlike hold_tune they are not re-read per gun # unless the lab has pushed a live set in. @@ -1297,6 +1353,16 @@ class ShooterPoseModifier extends SkeletonModifier3D: var gun_stock: float = 0.20 # 0..1 through a reload — drives the support hand to the mag well and back. var reload_phase: float = 0.0 + # What KIND of weapon is being held. See WeaponHoldProfiles: these three are + # the differences a slider cannot express, and they are what make a launcher + # read as a launcher rather than as a very large rifle. + var support_mode: String = WeaponHoldProfiles.SUPPORT_BARREL + ## How far the head comes down and across to the stock, 0..1. Negative leans + ## it away, which is what a tube over the shoulder needs. + var cheek: float = 0.0 + ## Whether the trigger finger closes with the rest. True for a blade, which + ## has nothing to keep a finger straight along. + var full_fist: bool = false # [child_bone, helper_bone] pairs; see SkinJointHelper. var joint_helpers: Array = [] @@ -1319,7 +1385,9 @@ class ShooterPoseModifier extends SkeletonModifier3D: const WALL_ARM_OUT := 0.9 # inner arm reaches out to touch the wall const HOLD_SMOOTH := 8.0 # how fast the hold takes/releases the arms - const SPINE := ["DEF-hips", "DEF-spine.001", "DEF-spine.002", "DEF-spine.003"] + ## The library skeleton's spine, hips first. Shared with the dance layer via + ## RigRoles, which also owns the mapping onto a rig that kept its own names. + const SPINE := RigRoles.SPINE var _idx: Dictionary = {} var _resolved := false @@ -1395,33 +1463,13 @@ class ShooterPoseModifier extends SkeletonModifier3D: func _resolve() -> void: var skel := get_skeleton() - var names := SPINE + ["DEF-neck", "DEF-head", - "DEF-upper_arm.R", "DEF-forearm.R", "DEF-hand.R", - "DEF-upper_arm.L", "DEF-forearm.L", "DEF-hand.L", - "DEF-thigh.R", "DEF-shin.R", "DEF-thigh.L", "DEF-shin.L"] - # The names above are the LIBRARY skeleton's. A model that kept its own - # rig names things differently and three of them simply do not exist on - # it — Taila's hips are DEF-spine, her head is DEF-spine.006, and she has - # no bone with "neck" in its name at all. Unresolved, every lean, aim - # pitch and slide head-lift below silently did nothing. - var alias := {} - if not roles.is_empty(): - var neck: String = roles.get("neck", "") - var head: String = roles.get("head", "") - var torso: Array = [] - for n in roles.get("spine", []): - if n != neck and n != head: - torso.append(n) - for i in mini(torso.size(), SPINE.size() - 1): - alias[SPINE[i + 1]] = torso[i] - for n in names: - # Canonical names are the role keys with the DEF- prefix, so the - # limbs, hips, neck and head all map straight through. - var actual: String = alias.get(n, roles.get(n.trim_prefix("DEF-"), n)) - var b := skel.find_bone(actual) - if b < 0: - b = skel.find_bone(n) - _idx[n] = b + # The canonical names are the LIBRARY skeleton's, and a model that kept + # its own rig names things differently — Taila's hips are DEF-spine, her + # head is DEF-spine.006, and she has no bone with "neck" in its name at + # all. Unresolved, every lean, aim pitch and slide head-lift below + # silently did nothing. RigRoles maps them through the sidecar; the dance + # layer uses the same call rather than a second copy of it. + _idx = RigRoles.resolve(skel, roles) _resolve_hands(skel) _resolved = true @@ -1514,6 +1562,8 @@ class ShooterPoseModifier extends SkeletonModifier3D: _apply_grapple(skel) if _hold_r > 0.01 or _hold_l > 0.01: _apply_rifle_hold(skel) + if absf(cheek) > 0.01: + _apply_cheek(skel) _close_hands(skel) if recoil > 0.01: _apply_recoil(skel) @@ -1763,9 +1813,22 @@ class ShooterPoseModifier extends SkeletonModifier3D: dbg_fore = fore_pos dbg_stock = stock_pos - # 3. Support hand goes to the mag well during a reload (under the - # receiver — the correct side), otherwise to the handguard. + # 3. Where the SUPPORT hand goes, which is most of what tells a viewer + # what is being held. See WeaponHoldProfiles. + # + # All four modes reuse the geometry above — the weapon is still placed + # first and the arms still solved onto it — they differ in which point + # on it the off hand is sent to. var l_target := fore_pos + match support_mode: + WeaponHoldProfiles.SUPPORT_CUPPED: + # Both hands together on the grip. Down and to the character's + # LEFT of the firing fist (`side` is character-right), so the two + # hands stack rather than collide. + l_target = grip_pos + gun_basis * Vector3(-0.048, -0.038, 0.012) + WeaponHoldProfiles.SUPPORT_TUBE: + # Hooked under the tube from below, forward of the shoulder. + l_target = fore_pos - gun_up * 0.075 if reload_phase > 0.001: var mag_well := grip_pos + aim_dir * (gun_fore * 0.35) - gun_up * 0.10 var drop := mag_well - gun_up * 0.22 - aim_dir * 0.05 @@ -1853,10 +1916,32 @@ class ShooterPoseModifier extends SkeletonModifier3D: and _hand_frame.has("L"): var hand_l: int = _idx.get("DEF-hand.L", -1) if hand_l >= 0: - # -aim_dir so the hand comes at the handguard from the body side - # rather than reaching over it backwards. - var want := Basis(gun_up.cross(-aim_dir).normalized(), gun_up, - -aim_dir) + # The hand's target frame, as (along, palm, curl) — the same + # three axes `_hand_frame` measured off the rest pose. + # + # Which way the PALM faces and which axis the fingers CURL about + # is the whole difference between wrapping a handguard, cupping a + # fist and hooking under a tube. Sending the hand to a different + # POSITION without changing its orientation gives a hand that has + # been teleported to the new spot still shaped for the old one. + # + # -aim_dir throughout, so the hand comes at the weapon from the + # body side rather than reaching over it backwards. + var palm := gun_up + var curl := -aim_dir + match support_mode: + WeaponHoldProfiles.SUPPORT_CUPPED: + # Palm presses inward against the grip's exposed panel; + # fingers still close along the barrel, over the firing + # hand's. + palm = side + WeaponHoldProfiles.SUPPORT_TUBE: + # A vertical foregrip: the palm faces BACK toward the + # body and the fingers close about the handle's own + # up-axis, not about the tube. + palm = -aim_dir + curl = gun_up + var want := Basis(palm.cross(curl).normalized(), palm, curl) # Then the artist's wrist. Rolling about the barrel is the one # axis a hand wrapping a cylinder is genuinely free in, and it # used to be the only one offered — which left no way to cock the @@ -1871,6 +1956,42 @@ class ShooterPoseModifier extends SkeletonModifier3D: * skel.get_bone_global_rest(hand_l).basis.get_rotation_quaternion() _set_global_rot(skel, hand_l, g_fa_l, g_hand.normalized(), _hold_l) + # ── The head against the stock ─────────────────────────────────────────── + # + # A cheek weld is the single most recognisable thing about how a marksman + # holds a rifle, and its inverse — the head leaning AWAY — is what says a tube + # is resting on that shoulder. Both are silhouette at any distance, long after + # the weapon mesh itself has become a few pixels. + # + # Three axes, all toward the right shoulder, because that is where the weapon + # is: down onto the comb, rolled over it, and turned slightly along it. + ## Full-weld amounts, in radians, at cheek = 1. + const CHEEK_PITCH := 0.20 # down onto the comb + const CHEEK_ROLL := 0.26 # over toward the shoulder + const CHEEK_YAW := 0.09 # turned along the stock + ## How much of the weld is present at low ready. Not zero: the pose has to + ## read before the character shoulders the weapon, and a marksman carrying a + ## rifle already holds their head differently from someone carrying a knife. + const CHEEK_HIP := 0.35 + + func _apply_cheek(skel: Skeleton3D) -> void: + var k: float = cheek * lerpf(CHEEK_HIP, 1.0, ads) * _hold_r + if absf(k) < 0.005: + return + # Positive X pitches the head DOWN and positive Z rolls it toward the + # character's right — the same sign conventions as the aim pitch and the + # wall-run lean respectively. + var q := Quaternion(Vector3(1, 0, 0), CHEEK_PITCH * k) \ + * Quaternion(Vector3(0, 0, 1), CHEEK_ROLL * k) \ + * Quaternion(Vector3(0, 1, 0), CHEEK_YAW * k) + # Split across neck and head so the whole column leans rather than the + # skull hinging off a rigid neck. Weighted toward the head, which is what + # actually happens when someone lowers a cheek onto a stock. + _add_space(skel, _idx.get("DEF-neck", -1), + Quaternion.IDENTITY.slerp(q, 0.35)) + _add_space(skel, _idx.get("DEF-head", -1), + Quaternion.IDENTITY.slerp(q, 0.65)) + # How far each segment of a finger closes, knuckle -> tip, in radians. # # Two different grips. The SUPPORT hand wraps a handguard, so all four @@ -1904,8 +2025,11 @@ class ShooterPoseModifier extends SkeletonModifier3D: PackedInt32Array()) if bones.is_empty(): continue - # The right index rides the trigger; everything else wraps. - var trigger: bool = side == "R" and digit == "index" + # The right index rides the trigger; everything else wraps. A blade + # has no trigger, and an index left straight along a knife handle + # reads as a mistake rather than as discipline — so a full fist + # closes every finger the same. + var trigger: bool = side == "R" and digit == "index" and not full_fist var amount: Array = CURL_TRIGGER if trigger else CURL_WRAP var scale: float = _t("curl_trigger", 1.0) if trigger else _t("curl_wrap", 1.0) for i in bones.size(): diff --git a/characters/weapon_hold_tuning.gd b/characters/weapon_hold_tuning.gd index 25fb7f8..70637ad 100644 --- a/characters/weapon_hold_tuning.gd +++ b/characters/weapon_hold_tuning.gd @@ -169,7 +169,19 @@ static func all_knobs() -> Array: ## ## Across ALL poses, not just the one on screen: a reset or a save has to know ## what `pocket_ads` defaults to even while low ready is being adjusted. -static func default_for(key: String): +## +## WEAPON-AWARE, and it has to be. The hold profile for a weapon class supplies +## better defaults than the generic spec table — a knife's stock pocket is at the +## hip, a launcher's is above the shoulder — and the lab SAVES every knob it +## shows, not just the ones that were moved. Without the weapon here, opening the +## lab on the knife and pressing save would silently overwrite the blade profile +## with the rifle spec's values, and the character would go back to holding a +## knife like an AK with no indication that anything had happened. +static func default_for(key: String, weapon_id: String = ""): + if weapon_id != "": + var profile := WeaponHoldProfiles.knobs_for(weapon_id) + if profile.has(key): + return profile[key] return TuningStore.default_for(all_knobs(), key) diff --git a/debug/anim_capture.gd b/debug/anim_capture.gd index a86b1be..9e6a1da 100644 --- a/debug/anim_capture.gd +++ b/debug/anim_capture.gd @@ -51,7 +51,7 @@ func _process(_delta: float) -> bool: sm.set_active_skin(_skin) var nm = root.get_node_or_null("NetworkManager") if nm and nm.has_method("start_singleplayer_match"): - nm.start_singleplayer_match("Deathmatch") + nm.start_singleplayer_match(GameMode.DEATHMATCH) change_scene_to_file("res://scenes/maps/test_level/test_level.tscn") return false if _frames < 160: diff --git a/debug/dance_check.gd b/debug/dance_check.gd new file mode 100644 index 0000000..c5c50e1 --- /dev/null +++ b/debug/dance_check.gd @@ -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) diff --git a/debug/dance_check.gd.uid b/debug/dance_check.gd.uid new file mode 100644 index 0000000..7116b9a --- /dev/null +++ b/debug/dance_check.gd.uid @@ -0,0 +1 @@ +uid://tds0trjfr2as diff --git a/debug/emote_wheel_capture.gd b/debug/emote_wheel_capture.gd new file mode 100644 index 0000000..e31b5f4 --- /dev/null +++ b/debug/emote_wheel_capture.gd @@ -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 -- +## +## 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) diff --git a/debug/emote_wheel_capture.gd.uid b/debug/emote_wheel_capture.gd.uid new file mode 100644 index 0000000..4176614 --- /dev/null +++ b/debug/emote_wheel_capture.gd.uid @@ -0,0 +1 @@ +uid://cxxajtpm3o8bj diff --git a/debug/fp_weapon_capture.gd b/debug/fp_weapon_capture.gd index 905e832..a3162ce 100644 --- a/debug/fp_weapon_capture.gd +++ b/debug/fp_weapon_capture.gd @@ -41,7 +41,7 @@ func _process(_delta: float) -> bool: if _frames == 40: var nm = root.get_node_or_null("NetworkManager") if nm and nm.has_method("start_singleplayer_match"): - nm.start_singleplayer_match("Deathmatch") + nm.start_singleplayer_match(GameMode.DEATHMATCH) change_scene_to_file("res://scenes/maps/test_level/test_level.tscn") elif _frames >= 160: for p in root.find_children("*", "CharacterBody3D", true, false): diff --git a/debug/game_mode_check.gd b/debug/game_mode_check.gd new file mode 100644 index 0000000..4199d76 --- /dev/null +++ b/debug/game_mode_check.gd @@ -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) diff --git a/debug/game_mode_check.gd.uid b/debug/game_mode_check.gd.uid new file mode 100644 index 0000000..b80d3da --- /dev/null +++ b/debug/game_mode_check.gd.uid @@ -0,0 +1 @@ +uid://cjpesfuv0vmcs diff --git a/debug/hold_capture.gd b/debug/hold_capture.gd new file mode 100644 index 0000000..6c29c2a --- /dev/null +++ b/debug/hold_capture.gd @@ -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 -- [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) diff --git a/debug/hold_capture.gd.uid b/debug/hold_capture.gd.uid new file mode 100644 index 0000000..12fd8bf --- /dev/null +++ b/debug/hold_capture.gd.uid @@ -0,0 +1 @@ +uid://c25wpt58v328r diff --git a/debug/hud_layout_check.gd b/debug/hud_layout_check.gd new file mode 100644 index 0000000..aec6745 --- /dev/null +++ b/debug/hud_layout_check.gd @@ -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 diff --git a/debug/hud_layout_check.gd.uid b/debug/hud_layout_check.gd.uid new file mode 100644 index 0000000..13b3687 --- /dev/null +++ b/debug/hud_layout_check.gd.uid @@ -0,0 +1 @@ +uid://c340r7urelhhx diff --git a/debug/map_preview_capture.gd b/debug/map_preview_capture.gd new file mode 100644 index 0000000..4a9b73f --- /dev/null +++ b/debug/map_preview_capture.gd @@ -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/.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() diff --git a/debug/map_preview_capture.gd.uid b/debug/map_preview_capture.gd.uid new file mode 100644 index 0000000..152f24b --- /dev/null +++ b/debug/map_preview_capture.gd.uid @@ -0,0 +1 @@ +uid://esvuwcyncpeb diff --git a/debug/menu_capture.gd b/debug/menu_capture.gd new file mode 100644 index 0000000..d7dfc0b --- /dev/null +++ b/debug/menu_capture.gd @@ -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 -- +## +## 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) diff --git a/debug/menu_capture.gd.uid b/debug/menu_capture.gd.uid new file mode 100644 index 0000000..aee1753 --- /dev/null +++ b/debug/menu_capture.gd.uid @@ -0,0 +1 @@ +uid://c2fen0sy3bw87 diff --git a/debug/orbit_capture.gd b/debug/orbit_capture.gd index b3f0652..4e90b9b 100644 --- a/debug/orbit_capture.gd +++ b/debug/orbit_capture.gd @@ -25,7 +25,7 @@ func _process(_delta: float) -> bool: sm.set_active_skin("taila") var nm = root.get_node_or_null("NetworkManager") if nm and nm.has_method("start_singleplayer_match"): - nm.start_singleplayer_match("Deathmatch") + nm.start_singleplayer_match(GameMode.DEATHMATCH) change_scene_to_file("res://scenes/maps/test_level/test_level.tscn") elif _frames == 160: for p in root.find_children("*", "CharacterBody3D", true, false): diff --git a/debug/rig_lab.gd b/debug/rig_lab.gd index 290e702..42db753 100644 --- a/debug/rig_lab.gd +++ b/debug/rig_lab.gd @@ -420,7 +420,8 @@ func _drag_to(mouse: Vector2) -> void: var key := _knob_for(_drag_marker) var spec := _spec_for("hold", key) - var cur: Vector3 = _knobs["hold"].get(key, WeaponHoldTuning.default_for(key)) + var cur: Vector3 = _knobs["hold"].get(key, + WeaponHoldTuning.default_for(key, _weapons[_weapon].id)) if not (cur is Vector3): cur = Vector3.ZERO var lo: float = spec[2] @@ -490,9 +491,15 @@ func _hold_pose() -> String: return WeaponHoldTuning.pose_for_ads(POSES[_pose][3]) +## A knob's resting value when nothing has been saved for this character. +## +## The weapon is passed through, because the hold's defaults depend on what KIND +## of weapon it is — see WeaponHoldTuning.default_for. Without it the lab would +## show a knife the rifle spec's numbers and then save them over the blade +## profile the moment anyone pressed save. func _default_for(group: String, key: String): return RigAnchors.default_for(key) if group == "anchors" \ - else WeaponHoldTuning.default_for(key) + else WeaponHoldTuning.default_for(key, _weapons[_weapon].id) ## Every knob of a group across BOTH poses. Reset works on the whole table, not diff --git a/debug/roster_capture.gd b/debug/roster_capture.gd index 615f717..59bfe27 100644 --- a/debug/roster_capture.gd +++ b/debug/roster_capture.gd @@ -30,7 +30,7 @@ func _initialize() -> void: _out_dir = args[0] var nm = root.get_node_or_null("NetworkManager") if nm and nm.has_method("start_singleplayer_match"): - nm.start_singleplayer_match("Deathmatch") + nm.start_singleplayer_match(GameMode.DEATHMATCH) change_scene_to_file("res://scenes/maps/test_level/test_level.tscn") diff --git a/debug/spawn_smoke_test.gd b/debug/spawn_smoke_test.gd index 0567c58..bc5e10f 100644 --- a/debug/spawn_smoke_test.gd +++ b/debug/spawn_smoke_test.gd @@ -56,7 +56,7 @@ func _test_spawn_with_skin(skin_id: String, expect_skinned: bool) -> void: var nm = root.get_node_or_null("/root/NetworkManager") if not _check(nm != null, "NetworkManager autoload exists"): return - nm.start_singleplayer_match("Deathmatch") + nm.start_singleplayer_match(GameMode.DEATHMATCH) change_scene_to_file("res://scenes/maps/test_level/test_level.tscn") # Let the level build and the player spawn + settle. diff --git a/debug/summary_capture.gd b/debug/summary_capture.gd new file mode 100644 index 0000000..90cc463 --- /dev/null +++ b/debug/summary_capture.gd @@ -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 -- +## +## 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} diff --git a/debug/summary_capture.gd.uid b/debug/summary_capture.gd.uid new file mode 100644 index 0000000..66310cb --- /dev/null +++ b/debug/summary_capture.gd.uid @@ -0,0 +1 @@ +uid://bnw4gima0hkli diff --git a/debug/test_level_builder.gd b/debug/test_level_builder.gd index 9abb2e4..efb973e 100644 --- a/debug/test_level_builder.gd +++ b/debug/test_level_builder.gd @@ -4,18 +4,7 @@ class_name TestLevelBuilder ## Builds a full test environment from code — parkour geometry, lighting, player, HUD. ## Attach to TestLevel root, press F5. -var _speed_label: Label -var _state_label: Label -var _chain_label: Label -var _weapon_label: Label -var _grapple_icon: TextureRect -var _dash_icon: TextureRect -var _grapple_label: Label -var _dash_label: Label -var _fps_label: Label -var _standalone_speed_label: Label var _player: CharacterBody3D -var _debug_ui_panel: PanelContainer func _ready() -> void: @@ -24,7 +13,6 @@ func _ready() -> void: _build_materials() _build_geometry() - _build_hud() # Multiplayer Spawning var spawner = MultiplayerSpawner.new() @@ -286,6 +274,8 @@ func _spawn_player(pid: int) -> CharacterBody3D: client_rep_config.add_property(":synced_is_ads") client_rep_config.add_property(":synced_wall_side") client_rep_config.add_property(":synced_is_dancing") + # Which of the five emotes, so other players see the one that was chosen. + client_rep_config.add_property(":synced_dance_index") client_rep_config.add_property(":synced_grapple_point") client_rep_config.add_property(":synced_is_grapple_shooting") client_rep_config.add_property(":synced_skin_id") @@ -417,298 +407,8 @@ func _spawn_player(pid: int) -> CharacterBody3D: return player -# ── HUD ─────────────────────────────────────────────────────────────────────── - -func _build_hud() -> void: - var canvas := CanvasLayer.new() - canvas.name = "UI" - add_child(canvas) - - # ── Crosshair ───────────────────────────────────────────────────────── - var crosshair := Control.new() - crosshair.name = "Crosshair" - crosshair.set_anchors_preset(Control.PRESET_CENTER) - crosshair.custom_minimum_size = Vector2(20, 20) - canvas.add_child(crosshair) - - var ch_dot := ColorRect.new() - ch_dot.name = "Dot" - ch_dot.color = Color(1, 1, 1, 0.8) - ch_dot.size = Vector2(4, 4) - ch_dot.position = Vector2(-2, -2) - crosshair.add_child(ch_dot) - - # Crosshair lines - for data in [ - {"pos": Vector2(-10, -1), "size": Vector2(6, 2)}, # Left - {"pos": Vector2(4, -1), "size": Vector2(6, 2)}, # Right - {"pos": Vector2(-1, -10), "size": Vector2(2, 6)}, # Top - {"pos": Vector2(-1, 4), "size": Vector2(2, 6)}, # Bottom - ]: - var line := ColorRect.new() - line.color = Color(1, 1, 1, 0.6) - line.position = data["pos"] - line.size = data["size"] - crosshair.add_child(line) - - # ── Info panel background ───────────────────────────────────────────── - _fps_label = Label.new() - _fps_label.name = "FPSLabel" - _fps_label.text = "FPS: 0" - _fps_label.add_theme_font_size_override("font_size", 24) - _fps_label.add_theme_color_override("font_color", Color(0.9, 0.9, 0.2)) - _fps_label.add_theme_color_override("font_outline_color", Color(0, 0, 0)) - _fps_label.add_theme_constant_override("outline_size", 4) - _fps_label.set_anchors_preset(Control.PRESET_TOP_LEFT) - _fps_label.position = Vector2(12, 12) - canvas.add_child(_fps_label) - - _standalone_speed_label = Label.new() - _standalone_speed_label.name = "StandaloneSpeedLabel" - _standalone_speed_label.text = "Speed: 0.0 m/s" - _standalone_speed_label.add_theme_font_size_override("font_size", 24) - _standalone_speed_label.add_theme_color_override("font_color", Color(0.3, 1.0, 0.5)) - _standalone_speed_label.add_theme_color_override("font_outline_color", Color(0, 0, 0)) - _standalone_speed_label.add_theme_constant_override("outline_size", 4) - _standalone_speed_label.set_anchors_preset(Control.PRESET_TOP_LEFT) - _standalone_speed_label.position = Vector2(12, 45) - canvas.add_child(_standalone_speed_label) - - _debug_ui_panel = PanelContainer.new() - _debug_ui_panel.name = "InfoPanel" - _debug_ui_panel.offset_left = 12 - _debug_ui_panel.offset_top = 50 - _debug_ui_panel.offset_right = 400 - _debug_ui_panel.offset_bottom = 160 - var panel_style := StyleBoxFlat.new() - panel_style.bg_color = Color(0, 0, 0, 0.55) - panel_style.corner_radius_top_left = 8 - panel_style.corner_radius_top_right = 8 - panel_style.corner_radius_bottom_left = 8 - panel_style.corner_radius_bottom_right = 8 - panel_style.content_margin_left = 12 - panel_style.content_margin_top = 8 - panel_style.content_margin_right = 12 - panel_style.content_margin_bottom = 8 - _debug_ui_panel.add_theme_stylebox_override("panel", panel_style) - canvas.add_child(_debug_ui_panel) - - var vbox := VBoxContainer.new() - vbox.name = "InfoVBox" - _debug_ui_panel.add_child(vbox) - - _speed_label = Label.new() - _speed_label.name = "SpeedLabel" - _speed_label.text = "Speed: 0.0 m/s" - _speed_label.add_theme_font_size_override("font_size", 18) - _speed_label.add_theme_color_override("font_color", Color(0.3, 1.0, 0.5)) - vbox.add_child(_speed_label) - - _state_label = Label.new() - _state_label.name = "StateLabel" - _state_label.text = "State: ground" - _state_label.add_theme_font_size_override("font_size", 16) - _state_label.add_theme_color_override("font_color", Color(0.7, 0.85, 1.0)) - vbox.add_child(_state_label) - - _chain_label = Label.new() - _chain_label.name = "ChainLabel" - _chain_label.text = "Chain: 0 (+0%)" - _chain_label.add_theme_font_size_override("font_size", 16) - _chain_label.add_theme_color_override("font_color", Color(1.0, 0.8, 0.3)) - vbox.add_child(_chain_label) - - - # ── Weapon & Ammo Panel ─────────────────────────────────────────────── - var wp_panel := PanelContainer.new() - wp_panel.name = "WeaponPanel" - wp_panel.set_anchors_preset(Control.PRESET_BOTTOM_RIGHT) - wp_panel.offset_left = -250 - wp_panel.offset_top = -100 - wp_panel.offset_right = -20 - wp_panel.offset_bottom = -20 - var wp_style := StyleBoxFlat.new() - wp_style.bg_color = Color(0, 0, 0, 0.6) - wp_style.corner_radius_top_left = 8 - wp_style.corner_radius_top_right = 8 - wp_style.corner_radius_bottom_left = 8 - wp_style.corner_radius_bottom_right = 8 - wp_style.content_margin_left = 16 - wp_style.content_margin_top = 12 - wp_style.content_margin_right = 16 - wp_style.content_margin_bottom = 12 - wp_panel.add_theme_stylebox_override("panel", wp_style) - wp_panel.grow_horizontal = Control.GROW_DIRECTION_BEGIN - canvas.add_child(wp_panel) - - _weapon_label = Label.new() - _weapon_label.name = "WeaponLabel" - _weapon_label.text = "Unarmed\n0 / 0" - _weapon_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT - _weapon_label.vertical_alignment = VERTICAL_ALIGNMENT_BOTTOM - _weapon_label.add_theme_font_size_override("font_size", 24) - wp_panel.add_child(_weapon_label) - - # ── Utilities Panel ─────────────────────────────────────────────────────── - var util_panel := PanelContainer.new() - util_panel.name = "UtilPanel" - util_panel.set_anchors_preset(Control.PRESET_BOTTOM_RIGHT) - util_panel.offset_left = -200 - util_panel.offset_top = -180 - util_panel.offset_right = -20 - util_panel.offset_bottom = -110 - var util_style := StyleBoxFlat.new() - util_style.bg_color = Color(0, 0, 0, 0.6) - util_style.corner_radius_top_left = 8 - util_style.corner_radius_top_right = 8 - util_style.corner_radius_bottom_left = 8 - util_style.corner_radius_bottom_right = 8 - util_style.content_margin_left = 12 - util_style.content_margin_top = 8 - util_style.content_margin_right = 12 - util_style.content_margin_bottom = 8 - util_panel.add_theme_stylebox_override("panel", util_style) - util_panel.grow_horizontal = Control.GROW_DIRECTION_BEGIN - canvas.add_child(util_panel) - - var util_hbox := HBoxContainer.new() - util_hbox.name = "UtilHBox" - util_hbox.add_theme_constant_override("separation", 20) - util_hbox.alignment = BoxContainer.ALIGNMENT_CENTER - util_panel.add_child(util_hbox) - - var grapple_vbox := VBoxContainer.new() - grapple_vbox.alignment = BoxContainer.ALIGNMENT_CENTER - util_hbox.add_child(grapple_vbox) - - _grapple_icon = TextureRect.new() - _grapple_icon.texture = load("res://assets/ui/grapple_icon.jpg") - _grapple_icon.expand_mode = TextureRect.EXPAND_IGNORE_SIZE - _grapple_icon.custom_minimum_size = Vector2(32, 32) - _grapple_icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED - grapple_vbox.add_child(_grapple_icon) - - _grapple_label = Label.new() - _grapple_label.text = "Grapple" - _grapple_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER - _grapple_label.add_theme_font_size_override("font_size", 12) - grapple_vbox.add_child(_grapple_label) - - var dash_vbox := VBoxContainer.new() - dash_vbox.alignment = BoxContainer.ALIGNMENT_CENTER - util_hbox.add_child(dash_vbox) - - _dash_icon = TextureRect.new() - _dash_icon.texture = load("res://assets/ui/dash_icon.jpg") - _dash_icon.expand_mode = TextureRect.EXPAND_IGNORE_SIZE - _dash_icon.custom_minimum_size = Vector2(32, 32) - _dash_icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED - dash_vbox.add_child(_dash_icon) - - _dash_label = Label.new() - _dash_label.text = "Ready" - _dash_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER - _dash_label.add_theme_font_size_override("font_size", 12) - dash_vbox.add_child(_dash_label) - - -# ── Utility: Key Name ───────────────────────────────────────────────────────── - -func _get_key_name(action: String) -> String: - if not InputMap.has_action(action): - return "?" - var events = InputMap.action_get_events(action) - for e in events: - if e is InputEventKey: - var code = e.physical_keycode if e.physical_keycode != 0 else e.keycode - return OS.get_keycode_string(code) - elif e is InputEventMouseButton: - if e.button_index == MOUSE_BUTTON_LEFT: return "LClick" - elif e.button_index == MOUSE_BUTTON_RIGHT: return "RClick" - elif e.button_index == MOUSE_BUTTON_MIDDLE: return "MClick" - return "?" - -# ── HUD Update ──────────────────────────────────────────────────────────────── - -func _process(_delta: float) -> void: - if not _player or not is_instance_valid(_player): - return - - if _debug_ui_panel: - _debug_ui_panel.visible = SettingsManager.show_debug_ui - - if _fps_label: - _fps_label.visible = SettingsManager.show_fps - if _fps_label.visible: - _fps_label.text = "FPS: %d" % Engine.get_frames_per_second() - - var vel: Vector3 = _player.velocity - var hspeed := Vector2(vel.x, vel.z).length() - var total_speed := vel.length() - - if _speed_label: - _speed_label.text = "Speed: %.1f m/s (total: %.1f)" % [hspeed, total_speed] - - if _standalone_speed_label: - if SettingsManager.show_movement_speed and not SettingsManager.show_debug_ui: - _standalone_speed_label.visible = true - _standalone_speed_label.text = "Speed: %.1f m/s" % hspeed - if _fps_label and _fps_label.visible: - _standalone_speed_label.position = Vector2(12, 45) - else: - _standalone_speed_label.position = Vector2(12, 12) - else: - _standalone_speed_label.visible = false - - if _state_label: - var sm = _player.get_node_or_null("MovementStateMachine") - if sm: - _state_label.text = "State: %s" % sm.current_state - - if _chain_label: - var sm = _player.get_node_or_null("MovementStateMachine") - if sm: - _chain_label.text = "Chain: %d (+%d%%)" % [sm.chain_count, int(sm.current_chain_bonus * 100)] - - # Update utility indicators - if sm.current_state == "grapple" or sm.is_grapple_shooting: - _grapple_icon.modulate = Color(0.2, 1.0, 0.4) - _grapple_label.text = "Grappling" - else: - _grapple_icon.modulate = Color(1.0, 1.0, 1.0) - _grapple_label.text = "Ready" - - var dash_rem = sm.get_dash_cooldown_remaining() - if dash_rem > 0.0: - _dash_icon.modulate = Color(1.0, 0.3, 0.3) - _dash_label.text = "%.1f" % dash_rem - else: - _dash_icon.modulate = Color(1.0, 1.0, 1.0) - _dash_label.text = "Ready" - - if _weapon_label: - var wman = _player.get_node_or_null("HeadPivot/Camera3D/WeaponManager") - if wman and wman.weapons.has(wman.active_slot): - var active_weapon = wman.weapons[wman.active_slot] - var w_name = "Weapon" - var cur_ammo = 0 - var max_ammo = 0 - - if "weapon_name" in active_weapon: - w_name = active_weapon.weapon_name - elif active_weapon is DoubleBarrelShotgun: - w_name = "Double Barrel Shotgun" - - if "current_ammo" in active_weapon: - cur_ammo = active_weapon.current_ammo - max_ammo = active_weapon.max_ammo - elif "shells" in active_weapon: - cur_ammo = active_weapon.shells - max_ammo = 2 - - if "reloading" in active_weapon and active_weapon.reloading: - _weapon_label.text = "%s\nReloading..." % w_name - else: - _weapon_label.text = "%s\n%d / %d" % [w_name, cur_ammo, max_ammo] - else: - _weapon_label.text = "Unarmed\n0 / 0" +# ── HUD ─────────────────────────────────────────────────────────────── +# +# There is no HUD here any more, and no _process to drive one. See the note in +# scenes/maps/level_runtime.gd: everything that describes A PLAYER belongs to +# ui/player_hud.gd, which that player spawns for itself. diff --git a/debug/ui_capture.gd b/debug/ui_capture.gd index 8d2f2f1..6555bb1 100644 --- a/debug/ui_capture.gd +++ b/debug/ui_capture.gd @@ -35,7 +35,7 @@ func _process(_delta: float) -> bool: # rather than a flat colour — that its legibility has to survive. var nm = root.get_node_or_null("NetworkManager") if nm and nm.has_method("start_singleplayer_match"): - nm.start_singleplayer_match("Deathmatch") + nm.start_singleplayer_match(GameMode.DEATHMATCH) change_scene_to_file("res://scenes/maps/test_level/test_level.tscn") 200: _open("_show_main_menu") diff --git a/debug/ui_contrast_check.gd b/debug/ui_contrast_check.gd new file mode 100644 index 0000000..6c51b73 --- /dev/null +++ b/debug/ui_contrast_check.gd @@ -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 diff --git a/debug/ui_contrast_check.gd.uid b/debug/ui_contrast_check.gd.uid new file mode 100644 index 0000000..5e42c4d --- /dev/null +++ b/debug/ui_contrast_check.gd.uid @@ -0,0 +1 @@ +uid://78mssihccx1s diff --git a/debug/visual_capture.gd b/debug/visual_capture.gd index 13575be..567524c 100644 --- a/debug/visual_capture.gd +++ b/debug/visual_capture.gd @@ -26,7 +26,7 @@ func _process(_delta: float) -> bool: _shot("menu") var nm = root.get_node_or_null("NetworkManager") if nm and nm.has_method("start_singleplayer_match"): - nm.start_singleplayer_match("Deathmatch") + nm.start_singleplayer_match(GameMode.DEATHMATCH) change_scene_to_file(_scene) elif _frames == 240: _shot("level") diff --git a/debug/weapon_hold_check.gd b/debug/weapon_hold_check.gd new file mode 100644 index 0000000..4d12877 --- /dev/null +++ b/debug/weapon_hold_check.gd @@ -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) diff --git a/debug/weapon_hold_check.gd.uid b/debug/weapon_hold_check.gd.uid new file mode 100644 index 0000000..47859be --- /dev/null +++ b/debug/weapon_hold_check.gd.uid @@ -0,0 +1 @@ +uid://f7600k001iqr diff --git a/globals/game_mode.gd b/globals/game_mode.gd new file mode 100644 index 0000000..81308d0 --- /dev/null +++ b/globals/game_mode.gd @@ -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 diff --git a/globals/game_mode.gd.uid b/globals/game_mode.gd.uid new file mode 100644 index 0000000..70f6759 --- /dev/null +++ b/globals/game_mode.gd.uid @@ -0,0 +1 @@ +uid://b8k2ha37f47ji diff --git a/globals/network_manager.gd b/globals/network_manager.gd index 2dbadfa..d844440 100644 --- a/globals/network_manager.gd +++ b/globals/network_manager.gd @@ -7,18 +7,34 @@ signal connection_succeeded signal match_state_updated signal stats_updated signal killfeed_event(victim: String, killer: String, weapon: String, v_color: String, k_color: String) +## The match is over. `result` carries the reason ("score" / "time"), the winner's +## name, and the final standings. The HUD raises the summary screen on this. +signal match_ended(result: Dictionary) +## A Gun Game player moved up a rung and needs their weapon swapped. +signal ladder_promoted(peer_id: int, rung: int, weapon_id: String) const DEFAULT_PORT = 31415 var connected_players: Array[int] = [] -# Dict of ID -> { "username": String, "kills": int, "deaths": int, "assists": int, "ping": int } +## ID -> { username, kills, deaths, assists, ping, color, score, team, streak, +## best_streak, rung } +## +## `score` is what the MODE counts and is what decides the match — in Deathmatch +## it tracks kills, in Gun Game it is the ladder rung. `kills` stays a plain +## kill count regardless, because the scoreboard shows both and they are not the +## same number in every mode. var player_stats: Dictionary = {} var match_active: bool = false var match_time_remaining: float = 0.0 -var current_gamemode: String = "Deathmatch" +## A GameMode id — see globals/game_mode.gd. Was a display string ("Deathmatch") +## compared by equality in three places; an id is what the mode table is keyed on +## and what survives being sent over the wire. +var current_gamemode: String = GameMode.DEATHMATCH var current_scene_path: String = "" +## Set when the match ends, so a late-arriving HUD can still show the summary. +var last_result: Dictionary = {} var _ping_timer: Timer @@ -62,31 +78,54 @@ func disconnect_game() -> void: match_active = false _ping_timer.stop() -func start_singleplayer_match(gamemode: String = "Deathmatch") -> void: +func start_singleplayer_match(gamemode: String = GameMode.DEATHMATCH) -> void: # Ensure peer is offline if not already connected if not multiplayer.has_multiplayer_peer() or multiplayer.multiplayer_peer is OfflineMultiplayerPeer: multiplayer.multiplayer_peer = OfflineMultiplayerPeer.new() - + connected_players.clear() connected_players.append(1) + # Normalised, because `current_gamemode` used to be a DISPLAY string + # ("Deathmatch") and half a dozen callers still pass one. GameMode's lookups + # all fall back safely, but an id that is not in the table would compare + # unequal to GameMode.GUN_GAME and silently disable Gun Game's weapon + # issuing — a mode that half works is worse than one that does not load. + current_gamemode = gamemode if GameMode.MODES.has(gamemode) \ + else GameMode.DEATHMATCH + player_stats.clear() _init_player_stats(1, SettingsManager.username) - + + last_result = {} match_active = true - match_time_remaining = 300.0 - current_gamemode = gamemode + # The mode's own clock, not a hardcoded five minutes. Gun Game is a fifteen + # minute mode and Deathmatch a ten minute one; both used to be five. + match_time_remaining = GameMode.time_limit(gamemode) rpc("sync_match_state", match_active, match_time_remaining, current_gamemode) func _init_player_stats(id: int, username: String) -> void: # Keep existing color if player re-joins, or generate new var p_color = Color(randf_range(0.2, 1.0), randf_range(0.2, 1.0), randf_range(0.2, 1.0)).to_html(false) - + var team := GameMode.assign_team(current_gamemode, player_stats) + if team > 0: + # In a team mode the player's colour IS their team's, because a killfeed + # where a teammate and an enemy are both "some random colour" is a + # killfeed that cannot be read at a glance. + p_color = GameMode.team_color(team).to_html(false) + player_stats[id] = { "username": username, "kills": 0, "deaths": 0, "assists": 0, "ping": 0, - "color": p_color + "color": p_color, + # What the MODE counts. See the note on player_stats. + "score": 0, + "team": team, + "streak": 0, + "best_streak": 0, + # Gun Game ladder position. + "rung": 0, } stats_updated.emit() @@ -169,20 +208,93 @@ func rpc_load_level(scene_path: String) -> void: print("Loading level: ", scene_path) current_scene_path = scene_path if multiplayer.is_server(): + # Reset the scores as well as the clock. Loading a level used to restart + # the timer and leave every kill from the previous match standing, so the + # second match on a server started with someone already at the frag limit + # — and now that reaching the limit ENDS the match, it would have ended + # on the first kill. + last_result = {} + for pid in player_stats: + for key in ["kills", "deaths", "assists", "score", "streak", + "best_streak", "rung"]: + player_stats[pid][key] = 0 match_active = true - match_time_remaining = 300.0 # 5 minutes + match_time_remaining = GameMode.time_limit(current_gamemode) + rpc("sync_player_stats", player_stats) rpc("sync_match_state", match_active, match_time_remaining, current_gamemode) get_tree().change_scene_to_file(scene_path) func _process(delta: float) -> void: - if match_active: - if match_time_remaining > 0: - match_time_remaining -= delta - if match_time_remaining <= 0: - match_time_remaining = 0 - match_active = false - # We emit this so HUDs can update their timers smoothly - match_state_updated.emit() + if not match_active: + return + if match_time_remaining > 0: + match_time_remaining -= delta + if match_time_remaining <= 0: + match_time_remaining = 0 + # Time is a win CONDITION, not an off switch. This used to set + # `match_active = false` and stop, so the clock reached zero, the + # timer froze at 00:00 and nothing else happened — no winner, no + # summary, no way back to the menu. The match did not end so much as + # stop mattering. + if _is_authority(): + _check_win() + # We emit this so HUDs can update their timers smoothly + match_state_updated.emit() + + +## Whether this peer decides the match. True for the server, and true in +## singleplayer, where there is no peer but somebody still has to call it. +func _is_authority() -> bool: + if not multiplayer.has_multiplayer_peer(): + return true + if multiplayer.multiplayer_peer is OfflineMultiplayerPeer: + return true + return multiplayer.is_server() + + +## Has anyone won? Called on the server after every kill and when the clock runs +## out. GameMode.check_win is a pure function of the stats, so the rule lives +## next to the mode that defines it and can be tested without a match running. +func _check_win() -> void: + if not match_active: + return + var result := GameMode.check_win(current_gamemode, player_stats, + match_time_remaining) + if result.is_empty(): + return + result["mode"] = current_gamemode + result["standings"] = GameMode.standings(player_stats) + result["stats"] = player_stats.duplicate(true) + if _is_authority() and multiplayer.has_multiplayer_peer() \ + and not multiplayer.multiplayer_peer is OfflineMultiplayerPeer: + rpc("end_match", result) + else: + end_match(result) + + +@rpc("authority", "call_local", "reliable") +func end_match(result: Dictionary) -> void: + if not match_active: + return + match_active = false + last_result = result + match_state_updated.emit() + match_ended.emit(result) + + +## Wipe the scores and start the clock again on the same map and mode. The +## summary screen's "Play Again". +@rpc("authority", "call_local", "reliable") +func restart_match() -> void: + for pid in player_stats: + for key in ["kills", "deaths", "assists", "score", "streak", + "best_streak", "rung"]: + player_stats[pid][key] = 0 + last_result = {} + match_active = true + match_time_remaining = GameMode.time_limit(current_gamemode) + stats_updated.emit() + match_state_updated.emit() # --- PING SYSTEM --- func _on_ping_timer() -> void: @@ -208,19 +320,54 @@ func receive_ping_response(server_time: int) -> void: # --- GAMEPLAY EVENTS --- @rpc("any_peer", "call_local", "reliable") func register_kill(victim_id: int, killer_id: int, weapon_name: String, custom_victim_name: String = "", assist_ids: Array = []) -> void: - if multiplayer.is_server(): - if killer_id != 0 and killer_id != victim_id: - if player_stats.has(killer_id): - player_stats[killer_id]["kills"] += 1 - + if _is_authority(): + # A kill after the final whistle counts for nothing. Without this a + # rocket already in the air when the clock hit zero could change the + # result after the summary was on screen. + if not match_active: + return + + var clean_kill := killer_id != 0 and killer_id != victim_id + # A team kill is not a score. Friendly fire may be ON in a mode and the + # kill still must not advance the killer — otherwise the fastest way to + # win Team Deathmatch is to shoot your own team. + if clean_kill and GameMode.is_team_mode(current_gamemode) \ + and player_stats.has(killer_id) and player_stats.has(victim_id) \ + and player_stats[killer_id]["team"] == player_stats[victim_id]["team"]: + clean_kill = false + + if clean_kill and player_stats.has(killer_id): + var ks: Dictionary = player_stats[killer_id] + ks["kills"] += 1 + ks["streak"] += 1 + ks["best_streak"] = maxi(ks["best_streak"], ks["streak"]) + if current_gamemode == GameMode.GUN_GAME: + # A kill is a RUNG, not a point, and the rung IS the score — so + # reaching the top of the ladder is the same event as reaching + # the score limit, and only one win condition has to exist. + ks["rung"] += 1 + ks["score"] = ks["rung"] + var weapon := GameMode.ladder_weapon(ks["rung"]) + ladder_promoted.emit(killer_id, ks["rung"], weapon) + rpc("notify_promotion", killer_id, ks["rung"], weapon) + else: + ks["score"] += int(GameMode.get_mode(current_gamemode).get( + "kill_score", 1)) + if victim_id != -1: if player_stats.has(victim_id): player_stats[victim_id]["deaths"] += 1 - + player_stats[victim_id]["streak"] = 0 + var penalty := int(GameMode.get_mode(current_gamemode).get( + "death_score", 0)) + if penalty != 0: + player_stats[victim_id]["score"] = maxi( + player_stats[victim_id]["score"] - penalty, 0) + for aid in assist_ids: if player_stats.has(aid) and aid != killer_id and aid != victim_id: player_stats[aid]["assists"] += 1 - + var v_name = player_stats[victim_id]["username"] if player_stats.has(victim_id) else (custom_victim_name if custom_victim_name != "" else "Unknown") var k_name = player_stats[killer_id]["username"] if player_stats.has(killer_id) else "Unknown" @@ -238,9 +385,46 @@ func register_kill(victim_id: int, killer_id: int, weapon_name: String, custom_v if killer_id == 0 or killer_id == victim_id: k_name = "" # Suicide or world death - broadcast_killfeed.rpc(v_name, k_name, weapon_name, v_color, k_color) - sync_player_stats.rpc(player_stats) + # `.rpc()` on an offline peer does not call locally, so singleplayer got + # no killfeed and no stat sync at all. Both paths now go through the + # same two calls. + if multiplayer.has_multiplayer_peer() \ + and not multiplayer.multiplayer_peer is OfflineMultiplayerPeer: + broadcast_killfeed.rpc(v_name, k_name, weapon_name, v_color, k_color) + sync_player_stats.rpc(player_stats) + else: + broadcast_killfeed(v_name, k_name, weapon_name, v_color, k_color) + sync_player_stats(player_stats) + + # The scores just changed, so this is exactly when a match can end. + _check_win() @rpc("authority", "call_local", "reliable") func broadcast_killfeed(victim: String, killer: String, weapon: String, v_color: String = "cccccc", k_color: String = "cccccc") -> void: killfeed_event.emit(victim, killer, weapon, v_color, k_color) + + +## A Gun Game player moved up. Broadcast so every client's killfeed can say so +## and the promoted player's own client can swap their weapon. +@rpc("authority", "call_local", "reliable") +func notify_promotion(peer_id: int, rung: int, weapon_id: String) -> void: + ladder_promoted.emit(peer_id, rung, weapon_id) + + +## Whether `a` may damage `b`, per the mode. The only place friendly fire is +## decided, so a mode that turns it off turns it off everywhere. +func can_damage(attacker_id: int, victim_id: int) -> bool: + if attacker_id == victim_id: + return true + if GameMode.friendly_fire(current_gamemode): + return true + if not player_stats.has(attacker_id) or not player_stats.has(victim_id): + return true + var ta: int = int(player_stats[attacker_id].get("team", 0)) + var tb: int = int(player_stats[victim_id].get("team", 0)) + return ta == 0 or ta != tb + + +## Which team a peer is on, 0 for free-for-all. +func team_of(peer_id: int) -> int: + return int(player_stats.get(peer_id, {}).get("team", 0)) diff --git a/movement/player_movement_controller.gd b/movement/player_movement_controller.gd index 4ec6f30..34d7215 100644 --- a/movement/player_movement_controller.gd +++ b/movement/player_movement_controller.gd @@ -27,10 +27,10 @@ var death_count: int = 0 var is_dead: bool = false # UI -var health_bar: ProgressBar -var shield_bar: ProgressBar -var health_label: Label -var shield_label: Label +## The first-person HUD — reticle, vitals, ammo, death screen. See ui/player_hud.gd. +var _hud: PlayerHUD = null +## Kept because the controller drives them directly: the death screen is toggled +## by die()/respawn, and the ring is read by the reload logic. var death_screen: Control var ragdoll_instance: Node3D @@ -64,8 +64,7 @@ var grapple_latch_player: AudioStreamPlayer var grapple_swing_player: AudioStreamPlayer # UI -var hit_marker: Control -var _hit_marker_tween: Tween +## The reload ring, owned by the HUD. Kept here because the reload logic reads it. var reload_ring: Control var grenades: int = 2 @@ -89,6 +88,13 @@ var synced_velocity: Vector3 = Vector3.ZERO var synced_is_ads: bool = false var synced_wall_side: float = 0.0 # -1 wall left, +1 wall right (wall-run lean) var synced_is_dancing: bool = false # dance emote (B), shown on the model +## Which of the five routines in DanceRoutines is playing. Replicated, so other +## players see the emote that was actually chosen rather than always the first. +var synced_dance_index: int = 0 +## The radial dial, and how long the emote button has been held. -1 means this +## press was consumed by stopping a dance and must not open the wheel. +var _emote_wheel: EmoteWheel = null +var _emote_held: float = -1.0 # Anime speed-lines overlay (local player only) var _speedlines: ColorRect = null @@ -146,7 +152,6 @@ func _ready() -> void: if is_multiplayer_authority(): _damage_layer = CanvasLayer.new() add_child(_damage_layer) - _setup_hit_marker() _setup_hud() _setup_speedlines() else: @@ -395,32 +400,6 @@ func _setup_speedlines() -> void: _damage_layer.add_child(_speedlines) -func _setup_hit_marker() -> void: - hit_marker = Control.new() - hit_marker.set_anchors_preset(Control.PRESET_CENTER) - hit_marker.modulate.a = 0.0 # Hidden by default - _damage_layer.add_child(hit_marker) - - # Draw an X perfectly centered - var length = 12 - var thickness = 2 - for angle in [PI/4, 3*PI/4, 5*PI/4, 7*PI/4]: - var rect = ColorRect.new() - rect.color = Color.WHITE - rect.size = Vector2(length, thickness) - rect.pivot_offset = rect.size / 2.0 - # Center the rect itself at (0,0) before offset - var center_pos = -rect.size / 2.0 - # Move it outward along the angle so it doesn't cover the exact center dot - rect.position = center_pos + Vector2(cos(angle), sin(angle)) * (length / 2.0) - rect.rotation = angle - hit_marker.add_child(rect) - - # Reload Ring - reload_ring = load("res://ui/reload_ring.gd").new() - reload_ring.set_anchors_preset(Control.PRESET_CENTER) - _damage_layer.add_child(reload_ring) - func _setup_grapple() -> void: grapple_rope = MeshInstance3D.new() var rope_mesh = CylinderMesh.new() @@ -462,11 +441,13 @@ func spawn_damage_number(amount: float, hit_pos: Vector3) -> void: var label = load("res://ui/floating_damage_text.gd").new() label.text = str(round(amount)) - label.add_theme_font_size_override("font_size", 24) - label.add_theme_color_override("font_color", Color(1.0, 0.6, 0.1)) - label.add_theme_color_override("font_outline_color", Color(0, 0, 0, 0.8)) - label.add_theme_constant_override("outline_size", 4) - + label.add_theme_font_size_override("font_size", 28) + # The theme's papaya and its violet ink, not an approximate orange on pure + # black — these numbers fly over the same 3D scene the rest of the HUD does. + label.add_theme_color_override("font_color", UITheme.PAPAYA) + label.add_theme_color_override("font_outline_color", UITheme.INK) + label.add_theme_constant_override("outline_size", 7) + # Small random offset in 3D space so multiple pellets don't perfectly overlap var offset = Vector3(randf_range(-0.4, 0.4), randf_range(-0.4, 0.4), randf_range(-0.4, 0.4)) label.target_pos = hit_pos + offset @@ -480,13 +461,10 @@ func spawn_damage_number(amount: float, hit_pos: Vector3) -> void: if now - _last_hit_sound_time > 10: _last_hit_sound_time = now hit_player.play() - - if _hit_marker_tween and _hit_marker_tween.is_valid(): - _hit_marker_tween.kill() - - hit_marker.modulate.a = 1.0 - _hit_marker_tween = create_tween() - _hit_marker_tween.tween_property(hit_marker, "modulate:a", 0.0, 0.4) + # The confirmation is part of the reticle now, so it lands where the eye + # already is and shares its ink outline. + if is_instance_valid(_hud): + _hud.confirm_hit() func apply_impulse(force: Vector3) -> void: velocity += force @@ -674,6 +652,16 @@ func rpc_play_explosion(pos: Vector3, radius: float) -> void: func server_take_damage(amount: float, hit_pos: Vector3, attacker_id: int, weapon_name: String, impulse: Vector3) -> void: if not multiplayer.is_server(): return + # Friendly fire, decided by the MODE and enforced here — on the server, before + # the damage is broadcast — so a mode that turns it off turns it off for real + # rather than merely declining to award the kill. Team Deathmatch with damage + # that lands and a kill that does not count is worse than either. + var nm = get_node_or_null("/root/NetworkManager") + if nm and nm.has_method("can_damage") and attacker_id != 0: + var victim_id := int(str(name)) if str(name).is_valid_int() else 0 + if victim_id != 0 and not nm.can_damage(attacker_id, victim_id): + return + # Broadcast damage event to all peers so the victim dies on all screens. # Knockback is applied inside rpc_take_damage on the victim's own peer, # since that peer simulates this body. @@ -860,21 +848,45 @@ func _physics_process(_delta: float) -> void: if Input.is_action_just_pressed("toggle_camera_view"): set_third_person(not third_person) - # Dance emote (B): toggles while grounded and idle-ish; any - # movement/jump/crouch input breaks it. + # Emote (B): HOLD to open the radial dial and point at a dance, + # release to commit. A tap too short to have aimed anything just + # toggles the last one, which is exactly what the button did before + # the wheel existed — so the old muscle memory still works. if Input.is_action_just_pressed("emote"): + _emote_held = 0.0 + if synced_is_dancing: + # Already dancing: the press stops it, and no wheel opens. + # Having to aim at something in order to STOP is the most + # annoying possible way to build this. + synced_is_dancing = false + _emote_held = -1.0 + elif _emote_wheel: + _emote_wheel.open() + elif Input.is_action_pressed("emote") and _emote_held >= 0.0: + _emote_held += _delta + elif Input.is_action_just_released("emote") and _emote_held >= 0.0: + var aimed := _emote_wheel.close() if _emote_wheel else -1 + # A tap replays the last emote; a hold plays whatever was aimed + # at. Either way the same grounded-and-slow gate applies. + var pick := aimed if aimed >= 0 else synced_dance_index var m := _ensure_machine() var slow: bool = Vector2(velocity.x, velocity.z).length() < 1.0 - if not synced_is_dancing and m and m.current_state == "ground" and slow: + if m and m.current_state == "ground" and slow: + synced_dance_index = pick synced_is_dancing = true - else: - synced_is_dancing = false if synced_is_dancing: var m2 := _ensure_machine() var moving := raw_input.length() > 0.1 or input_jump or input_crouch or input_dash var airborne: bool = m2 and m2.current_state != "ground" if moving or airborne: synced_is_dancing = false + # The wheel eats aiming while it is open, so the player picking an emote + # does not also spin their character round. Movement is deliberately NOT + # blocked — a wheel that roots you in the open is a wheel nobody uses. + if _emote_wheel and _emote_wheel.visible and head_pivot: + head_pivot.set_process_input(false) + elif head_pivot and not head_pivot.is_processing_input() and not is_dead: + head_pivot.set_process_input(true) var machine := _ensure_machine() if machine: @@ -949,7 +961,7 @@ func _physics_process(_delta: float) -> void: if visual.has_method("set_wall_side"): visual.set_wall_side(sm.wall_side) if visual.has_method("set_dancing"): - visual.set_dancing(synced_is_dancing) + visual.set_dancing(synced_is_dancing, synced_dance_index) if visual.has_method("set_grapple_target") and sm.current_state == "grapple": visual.set_grapple_target(synced_grapple_point) @@ -1034,7 +1046,7 @@ func _process(delta: float) -> void: if visual.has_method("set_wall_side"): visual.set_wall_side(synced_wall_side) if visual.has_method("set_dancing"): - visual.set_dancing(synced_is_dancing) + visual.set_dancing(synced_is_dancing, synced_dance_index) if visual.has_method("set_grapple_target") and synced_movement_state == "grapple": visual.set_grapple_target(synced_grapple_point) # Upper body follows the owner's synced camera pitch @@ -1088,28 +1100,12 @@ func _process(delta: float) -> void: lvisual.add_gun_recoil() _last_local_ammo = ammo_now - # Update UI - if is_instance_valid(health_bar): - health_bar.value = health - health_label.text = "%d / %d" % [ceil(health), max_health] - if is_instance_valid(shield_bar): - shield_bar.value = shield - shield_label.text = "%d / %d" % [ceil(shield), max_shield] - - # Update Reload Ring - if is_instance_valid(reload_ring) and is_instance_valid(camera): - var wman = camera.get_node_or_null("WeaponManager") - if wman: - var slot = wman.get("active_slot") - if slot != null and wman.weapons.has(slot): - var w = wman.weapons[slot] - if "reloading" in w and w.reloading and "reload_timer" in w and "reload_time" in w: - reload_ring.progress = 1.0 - (w.reload_timer / w.reload_time) - else: - reload_ring.progress = 0.0 - else: - reload_ring.progress = 0.0 - + # Vitals are pushed (the controller owns health and shield); ammo, the + # reticle bloom and the reload ring are pulled by the HUD from the weapon, + # which is the authority on all three. + if is_instance_valid(_hud): + _hud.set_vitals(health, max_health, shield, max_shield) + if is_multiplayer_authority() and not is_dead: if is_holding_grenade and grenades > 0: _draw_trajectory() @@ -1139,112 +1135,36 @@ func _process(delta: float) -> void: if shield > max_shield: shield = max_shield +## Build the first-person HUD. +## +## Everything it draws lives in ui/player_hud.gd now. This used to be 105 lines +## of stock ProgressBars and a plain-black death screen inlined here, which is +## why none of it shared the game's look: a HUD assembled inside a 1500-line +## movement controller is a HUD nobody styles. func _setup_hud() -> void: - var margin = MarginContainer.new() - margin.set_anchors_preset(Control.PRESET_BOTTOM_LEFT) - margin.offset_left = 20 - margin.offset_bottom = -20 - margin.grow_vertical = Control.GROW_DIRECTION_BEGIN - - var vbox = VBoxContainer.new() - margin.add_child(vbox) - - # Add Match HUD overlay + # Match state (timer, score, killfeed, scoreboard) is a separate overlay and + # is deliberately not part of the player's own HUD. if is_multiplayer_authority(): var match_hud_scene = load("res://ui/match_hud.tscn") if match_hud_scene: - var match_hud = match_hud_scene.instantiate() - add_child(match_hud) - - # Shield Bar (Top) - var shield_box = VBoxContainer.new() - var s_title = Label.new() - s_title.text = "SHIELD" - s_title.add_theme_font_size_override("font_size", 12) - s_title.add_theme_color_override("font_color", Color(0.4, 0.7, 1.0)) - shield_box.add_child(s_title) - - shield_bar = ProgressBar.new() - shield_bar.custom_minimum_size = Vector2(200, 20) - shield_bar.max_value = max_shield - shield_bar.value = shield - shield_bar.show_percentage = false - - var s_sb = StyleBoxFlat.new() - s_sb.bg_color = Color(0.1, 0.4, 0.8) - shield_bar.add_theme_stylebox_override("fill", s_sb) - - shield_label = Label.new() - shield_label.text = "100 / 100" - shield_label.set_anchors_preset(Control.PRESET_CENTER) - shield_bar.add_child(shield_label) - - shield_box.add_child(shield_bar) - vbox.add_child(shield_box) - - # Spacer - vbox.add_child(Control.new()) - - # Health Bar (Bottom) - var health_box = VBoxContainer.new() - var h_title = Label.new() - h_title.text = "HEALTH" - h_title.add_theme_font_size_override("font_size", 12) - h_title.add_theme_color_override("font_color", Color(1.0, 0.3, 0.3)) - health_box.add_child(h_title) - - health_bar = ProgressBar.new() - health_bar.custom_minimum_size = Vector2(200, 20) - health_bar.max_value = max_health - health_bar.value = health - health_bar.show_percentage = false - - var h_sb = StyleBoxFlat.new() - h_sb.bg_color = Color(0.8, 0.1, 0.1) - health_bar.add_theme_stylebox_override("fill", h_sb) - - health_label = Label.new() - health_label.text = "100 / 100" - health_label.set_anchors_preset(Control.PRESET_CENTER) - health_bar.add_child(health_label) - - health_box.add_child(health_bar) - vbox.add_child(health_box) - - _damage_layer.add_child(margin) - - # Setup Death Screen - death_screen = ColorRect.new() - death_screen.color = Color(0, 0, 0, 0.7) - death_screen.set_anchors_preset(Control.PRESET_FULL_RECT) - death_screen.visible = false - - var center = CenterContainer.new() - center.set_anchors_preset(Control.PRESET_FULL_RECT) - death_screen.add_child(center) - - var d_vbox = VBoxContainer.new() - d_vbox.add_theme_constant_override("separation", 20) - center.add_child(d_vbox) - - var d_title = Label.new() - d_title.text = "SYSTEM FAILURE" - d_title.add_theme_font_size_override("font_size", 48) - d_title.add_theme_color_override("font_color", Color(1.0, 0.2, 0.2)) - d_vbox.add_child(d_title) - - var respawn_label = Label.new() - respawn_label.text = "PRESS ANY KEY TO REBOOT" - respawn_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER - respawn_label.add_theme_font_size_override("font_size", 24) - d_vbox.add_child(respawn_label) - - var tw = create_tween().set_loops() - tw.tween_property(respawn_label, "modulate:a", 0.2, 0.8) - tw.tween_property(respawn_label, "modulate:a", 1.0, 0.8) - - if is_multiplayer_authority(): - _damage_layer.add_child(death_screen) + add_child(match_hud_scene.instantiate()) + + _hud = PlayerHUD.new() + _hud.name = "PlayerHUD" + _hud.player = self + add_child(_hud) + + # The emote dial rides on the HUD's canvas, above the viewmodel. + _emote_wheel = EmoteWheel.new() + _emote_wheel.name = "EmoteWheel" + _hud.add_child(_emote_wheel) + + # The controller still owns these two — it toggles the death screen on death + # and the ring is read by the reload logic — so keep the references it had. + death_screen = _hud.death_screen + reload_ring = _hud.reload_ring + _hud.set_vitals(health, max_health, shield, max_shield) + func die(impulse: Vector3 = Vector3.ZERO) -> void: if is_dead: return diff --git a/scenes/maps/level_runtime.gd b/scenes/maps/level_runtime.gd index 268d661..b5f8ab7 100644 --- a/scenes/maps/level_runtime.gd +++ b/scenes/maps/level_runtime.gd @@ -1,18 +1,7 @@ extends Node3D class_name LevelRuntime -var _speed_label: Label -var _state_label: Label -var _chain_label: Label -var _weapon_label: Label -var _grapple_icon: TextureRect -var _dash_icon: TextureRect -var _grapple_label: Label -var _dash_label: Label -var _fps_label: Label -var _standalone_speed_label: Label var _player: CharacterBody3D -var _debug_ui_panel: PanelContainer func _ready() -> void: @@ -24,7 +13,6 @@ func _ready() -> void: if not has_node("WorldEnvironment"): LevelEnvironment.add_to(self) - _build_hud() # Multiplayer Spawning var spawner = MultiplayerSpawner.new() @@ -125,6 +113,8 @@ func _spawn_player(pid: int) -> CharacterBody3D: client_rep_config.add_property(":synced_is_ads") client_rep_config.add_property(":synced_wall_side") client_rep_config.add_property(":synced_is_dancing") + # Which of the five emotes, so other players see the one that was chosen. + client_rep_config.add_property(":synced_dance_index") client_rep_config.add_property(":synced_grapple_point") client_rep_config.add_property(":synced_is_grapple_shooting") client_rep_config.add_property(":synced_skin_id") @@ -252,284 +242,16 @@ func _spawn_player(pid: int) -> CharacterBody3D: return player -# ── HUD ─────────────────────────────────────────────────────────────────────── - -func _build_hud() -> void: - var canvas := CanvasLayer.new() - canvas.name = "UI" - add_child(canvas) - - # ── Crosshair ───────────────────────────────────────────────────────── - var crosshair := Control.new() - crosshair.name = "Crosshair" - crosshair.set_anchors_preset(Control.PRESET_CENTER) - crosshair.custom_minimum_size = Vector2(20, 20) - canvas.add_child(crosshair) - - var ch_dot := ColorRect.new() - ch_dot.name = "Dot" - ch_dot.color = Color(1, 1, 1, 0.8) - ch_dot.size = Vector2(4, 4) - ch_dot.position = Vector2(-2, -2) - crosshair.add_child(ch_dot) - - # Crosshair lines - for data in [ - {"pos": Vector2(-10, -1), "size": Vector2(6, 2)}, # Left - {"pos": Vector2(4, -1), "size": Vector2(6, 2)}, # Right - {"pos": Vector2(-1, -10), "size": Vector2(2, 6)}, # Top - {"pos": Vector2(-1, 4), "size": Vector2(2, 6)}, # Bottom - ]: - var line := ColorRect.new() - line.color = Color(1, 1, 1, 0.6) - line.position = data["pos"] - line.size = data["size"] - crosshair.add_child(line) - - # ── Info panel background ───────────────────────────────────────────── - _fps_label = Label.new() - _fps_label.name = "FPSLabel" - _fps_label.text = "FPS: 0" - _fps_label.add_theme_font_size_override("font_size", 24) - _fps_label.add_theme_color_override("font_color", Color(0.9, 0.9, 0.2)) - _fps_label.add_theme_color_override("font_outline_color", Color(0, 0, 0)) - _fps_label.add_theme_constant_override("outline_size", 4) - _fps_label.set_anchors_preset(Control.PRESET_TOP_LEFT) - _fps_label.position = Vector2(12, 12) - canvas.add_child(_fps_label) - - _standalone_speed_label = Label.new() - _standalone_speed_label.name = "StandaloneSpeedLabel" - _standalone_speed_label.text = "Speed: 0.0 m/s" - _standalone_speed_label.add_theme_font_size_override("font_size", 24) - _standalone_speed_label.add_theme_color_override("font_color", Color(0.3, 1.0, 0.5)) - _standalone_speed_label.add_theme_color_override("font_outline_color", Color(0, 0, 0)) - _standalone_speed_label.add_theme_constant_override("outline_size", 4) - _standalone_speed_label.set_anchors_preset(Control.PRESET_TOP_LEFT) - _standalone_speed_label.position = Vector2(12, 45) - canvas.add_child(_standalone_speed_label) - - _debug_ui_panel = PanelContainer.new() - _debug_ui_panel.name = "InfoPanel" - _debug_ui_panel.offset_left = 12 - _debug_ui_panel.offset_top = 50 - _debug_ui_panel.offset_right = 400 - _debug_ui_panel.offset_bottom = 160 - var panel_style := StyleBoxFlat.new() - panel_style.bg_color = Color(0, 0, 0, 0.55) - panel_style.corner_radius_top_left = 8 - panel_style.corner_radius_top_right = 8 - panel_style.corner_radius_bottom_left = 8 - panel_style.corner_radius_bottom_right = 8 - panel_style.content_margin_left = 12 - panel_style.content_margin_top = 8 - panel_style.content_margin_right = 12 - panel_style.content_margin_bottom = 8 - _debug_ui_panel.add_theme_stylebox_override("panel", panel_style) - canvas.add_child(_debug_ui_panel) - - var vbox := VBoxContainer.new() - vbox.name = "InfoVBox" - _debug_ui_panel.add_child(vbox) - - _speed_label = Label.new() - _speed_label.name = "SpeedLabel" - _speed_label.text = "Speed: 0.0 m/s" - _speed_label.add_theme_font_size_override("font_size", 18) - _speed_label.add_theme_color_override("font_color", Color(0.3, 1.0, 0.5)) - vbox.add_child(_speed_label) - - _state_label = Label.new() - _state_label.name = "StateLabel" - _state_label.text = "State: ground" - _state_label.add_theme_font_size_override("font_size", 16) - _state_label.add_theme_color_override("font_color", Color(0.7, 0.85, 1.0)) - vbox.add_child(_state_label) - - _chain_label = Label.new() - _chain_label.name = "ChainLabel" - _chain_label.text = "Chain: 0 (+0%)" - _chain_label.add_theme_font_size_override("font_size", 16) - _chain_label.add_theme_color_override("font_color", Color(1.0, 0.8, 0.3)) - vbox.add_child(_chain_label) - - - # ── Weapon & Ammo Panel ─────────────────────────────────────────────── - var wp_panel := PanelContainer.new() - wp_panel.name = "WeaponPanel" - wp_panel.set_anchors_preset(Control.PRESET_BOTTOM_RIGHT) - wp_panel.offset_left = -250 - wp_panel.offset_top = -100 - wp_panel.offset_right = -20 - wp_panel.offset_bottom = -20 - var wp_style := StyleBoxFlat.new() - wp_style.bg_color = Color(0, 0, 0, 0.6) - wp_style.corner_radius_top_left = 8 - wp_style.corner_radius_top_right = 8 - wp_style.corner_radius_bottom_left = 8 - wp_style.corner_radius_bottom_right = 8 - wp_style.content_margin_left = 16 - wp_style.content_margin_top = 12 - wp_style.content_margin_right = 16 - wp_style.content_margin_bottom = 12 - wp_panel.add_theme_stylebox_override("panel", wp_style) - wp_panel.grow_horizontal = Control.GROW_DIRECTION_BEGIN - canvas.add_child(wp_panel) - - _weapon_label = Label.new() - _weapon_label.name = "WeaponLabel" - _weapon_label.text = "Unarmed\n0 / 0" - _weapon_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT - _weapon_label.vertical_alignment = VERTICAL_ALIGNMENT_BOTTOM - _weapon_label.add_theme_font_size_override("font_size", 24) - wp_panel.add_child(_weapon_label) - - # ── Utilities Panel ─────────────────────────────────────────────────────── - var util_panel := PanelContainer.new() - util_panel.name = "UtilPanel" - util_panel.set_anchors_preset(Control.PRESET_BOTTOM_RIGHT) - util_panel.offset_left = -200 - util_panel.offset_top = -180 - util_panel.offset_right = -20 - util_panel.offset_bottom = -110 - var util_style := StyleBoxFlat.new() - util_style.bg_color = Color(0, 0, 0, 0.6) - util_style.corner_radius_top_left = 8 - util_style.corner_radius_top_right = 8 - util_style.corner_radius_bottom_left = 8 - util_style.corner_radius_bottom_right = 8 - util_style.content_margin_left = 12 - util_style.content_margin_top = 8 - util_style.content_margin_right = 12 - util_style.content_margin_bottom = 8 - util_panel.add_theme_stylebox_override("panel", util_style) - util_panel.grow_horizontal = Control.GROW_DIRECTION_BEGIN - canvas.add_child(util_panel) - - var util_hbox := HBoxContainer.new() - util_hbox.name = "UtilHBox" - util_hbox.add_theme_constant_override("separation", 20) - util_hbox.alignment = BoxContainer.ALIGNMENT_CENTER - util_panel.add_child(util_hbox) - - var grapple_vbox := VBoxContainer.new() - grapple_vbox.alignment = BoxContainer.ALIGNMENT_CENTER - util_hbox.add_child(grapple_vbox) - - _grapple_icon = TextureRect.new() - if ResourceLoader.exists("res://assets/ui/grapple_icon.jpg"): - _grapple_icon.texture = load("res://assets/ui/grapple_icon.jpg") - _grapple_icon.expand_mode = TextureRect.EXPAND_IGNORE_SIZE - _grapple_icon.custom_minimum_size = Vector2(32, 32) - _grapple_icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED - grapple_vbox.add_child(_grapple_icon) - - _grapple_label = Label.new() - _grapple_label.text = "Grapple" - _grapple_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER - _grapple_label.add_theme_font_size_override("font_size", 12) - grapple_vbox.add_child(_grapple_label) - - var dash_vbox := VBoxContainer.new() - dash_vbox.alignment = BoxContainer.ALIGNMENT_CENTER - util_hbox.add_child(dash_vbox) - - _dash_icon = TextureRect.new() - if ResourceLoader.exists("res://assets/ui/dash_icon.jpg"): - _dash_icon.texture = load("res://assets/ui/dash_icon.jpg") - _dash_icon.expand_mode = TextureRect.EXPAND_IGNORE_SIZE - _dash_icon.custom_minimum_size = Vector2(32, 32) - _dash_icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED - dash_vbox.add_child(_dash_icon) - - _dash_label = Label.new() - _dash_label.text = "Ready" - _dash_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER - _dash_label.add_theme_font_size_override("font_size", 12) - dash_vbox.add_child(_dash_label) - -# ── HUD Update ──────────────────────────────────────────────────────────────── - -func _process(_delta: float) -> void: - if not _player or not is_instance_valid(_player): - return - - if _debug_ui_panel: - _debug_ui_panel.visible = SettingsManager.show_debug_ui - - if _fps_label: - _fps_label.visible = SettingsManager.show_fps - if _fps_label.visible: - _fps_label.text = "FPS: %d" % Engine.get_frames_per_second() - - var vel: Vector3 = _player.velocity - var hspeed := Vector2(vel.x, vel.z).length() - var total_speed := vel.length() - - if _speed_label: - _speed_label.text = "Speed: %.1f m/s (total: %.1f)" % [hspeed, total_speed] - - if _standalone_speed_label: - if SettingsManager.show_movement_speed and not SettingsManager.show_debug_ui: - _standalone_speed_label.visible = true - _standalone_speed_label.text = "Speed: %.1f m/s" % hspeed - if _fps_label and _fps_label.visible: - _standalone_speed_label.position = Vector2(12, 45) - else: - _standalone_speed_label.position = Vector2(12, 12) - else: - _standalone_speed_label.visible = false - - if _state_label: - var sm = _player.get_node_or_null("MovementStateMachine") - if sm: - _state_label.text = "State: %s" % sm.current_state - - if _chain_label: - var sm = _player.get_node_or_null("MovementStateMachine") - if sm: - _chain_label.text = "Chain: %d (+%d%%)" % [sm.chain_count, int(sm.current_chain_bonus * 100)] - - # Update utility indicators - if sm.current_state == "grapple" or sm.is_grapple_shooting: - _grapple_icon.modulate = Color(0.2, 1.0, 0.4) - _grapple_label.text = "Grappling" - else: - _grapple_icon.modulate = Color(1.0, 1.0, 1.0) - _grapple_label.text = "Ready" - - if sm.has_method("get_dash_cooldown_remaining"): - var dash_rem = sm.get_dash_cooldown_remaining() - if dash_rem > 0.0: - _dash_icon.modulate = Color(1.0, 0.3, 0.3) - _dash_label.text = "%.1f" % dash_rem - else: - _dash_icon.modulate = Color(1.0, 1.0, 1.0) - _dash_label.text = "Ready" - - if _weapon_label: - var wman = _player.get_node_or_null("HeadPivot/Camera3D/WeaponManager") - if wman and wman.weapons.has(wman.active_slot): - var active_weapon = wman.weapons[wman.active_slot] - var w_name = "Weapon" - var cur_ammo = 0 - var max_ammo = 0 - - if "weapon_name" in active_weapon: - w_name = active_weapon.weapon_name - elif active_weapon is DoubleBarrelShotgun: - w_name = "Double Barrel Shotgun" - - if "current_ammo" in active_weapon: - cur_ammo = active_weapon.current_ammo - max_ammo = active_weapon.max_ammo - elif "shells" in active_weapon: - cur_ammo = active_weapon.shells - max_ammo = 2 - - if "reloading" in active_weapon and active_weapon.reloading: - _weapon_label.text = "%s\nReloading..." % w_name - else: - _weapon_label.text = "%s\n%d / %d" % [w_name, cur_ammo, max_ammo] - else: - _weapon_label.text = "Unarmed\n0 / 0" +# ── HUD ─────────────────────────────────────────────────────────────── +# +# There is no HUD here any more, and no _process to drive one. +# +# Reticle, vitals, ammo, ability cooldowns, the chain meter and the debug +# readout all belong to ui/player_hud.gd, spawned by the player itself. Every +# one of those describes A PLAYER, so a level that owns them has to reach down +# into that player's state machine and weapon manager every frame to fill them +# in — which is exactly what this did, from three byte-identical copies across +# the three level runtimes. The visible symptom was two ammo panels on screen at +# once, in two different styles, overlapping in the bottom-right corner. +# +# A level owns the level. \ No newline at end of file diff --git a/scenes/maps/procedural_arena/procedural_arena_runtime.gd b/scenes/maps/procedural_arena/procedural_arena_runtime.gd index 9f762fc..337e92f 100644 --- a/scenes/maps/procedural_arena/procedural_arena_runtime.gd +++ b/scenes/maps/procedural_arena/procedural_arena_runtime.gd @@ -1,18 +1,7 @@ extends Node3D -var _speed_label: Label -var _state_label: Label -var _chain_label: Label -var _weapon_label: Label -var _grapple_icon: TextureRect -var _dash_icon: TextureRect -var _grapple_label: Label -var _dash_label: Label -var _fps_label: Label -var _standalone_speed_label: Label var _player: CharacterBody3D -var _debug_ui_panel: PanelContainer func _ready() -> void: @@ -29,7 +18,6 @@ func _ready() -> void: LevelEnvironment.add_to(self) LevelMaterials.apply_toon_recursive(self, 0.0) - _build_hud() # Multiplayer Spawning var spawner = MultiplayerSpawner.new() @@ -94,6 +82,8 @@ func _spawn_player(pid: int) -> CharacterBody3D: client_rep_config.add_property(":synced_is_ads") client_rep_config.add_property(":synced_wall_side") client_rep_config.add_property(":synced_is_dancing") + # Which of the five emotes, so other players see the one that was chosen. + client_rep_config.add_property(":synced_dance_index") client_rep_config.add_property(":synced_grapple_point") client_rep_config.add_property(":synced_is_grapple_shooting") client_rep_config.add_property(":synced_skin_id") @@ -221,297 +211,8 @@ func _spawn_player(pid: int) -> CharacterBody3D: return player -# ── HUD ─────────────────────────────────────────────────────────────────────── - -func _build_hud() -> void: - var canvas := CanvasLayer.new() - canvas.name = "UI" - add_child(canvas) - - # ── Crosshair ───────────────────────────────────────────────────────── - var crosshair := Control.new() - crosshair.name = "Crosshair" - crosshair.set_anchors_preset(Control.PRESET_CENTER) - crosshair.custom_minimum_size = Vector2(20, 20) - canvas.add_child(crosshair) - - var ch_dot := ColorRect.new() - ch_dot.name = "Dot" - ch_dot.color = Color(1, 1, 1, 0.8) - ch_dot.size = Vector2(4, 4) - ch_dot.position = Vector2(-2, -2) - crosshair.add_child(ch_dot) - - # Crosshair lines - for data in [ - {"pos": Vector2(-10, -1), "size": Vector2(6, 2)}, # Left - {"pos": Vector2(4, -1), "size": Vector2(6, 2)}, # Right - {"pos": Vector2(-1, -10), "size": Vector2(2, 6)}, # Top - {"pos": Vector2(-1, 4), "size": Vector2(2, 6)}, # Bottom - ]: - var line := ColorRect.new() - line.color = Color(1, 1, 1, 0.6) - line.position = data["pos"] - line.size = data["size"] - crosshair.add_child(line) - - # ── Info panel background ───────────────────────────────────────────── - _fps_label = Label.new() - _fps_label.name = "FPSLabel" - _fps_label.text = "FPS: 0" - _fps_label.add_theme_font_size_override("font_size", 24) - _fps_label.add_theme_color_override("font_color", Color(0.9, 0.9, 0.2)) - _fps_label.add_theme_color_override("font_outline_color", Color(0, 0, 0)) - _fps_label.add_theme_constant_override("outline_size", 4) - _fps_label.set_anchors_preset(Control.PRESET_TOP_LEFT) - _fps_label.position = Vector2(12, 12) - canvas.add_child(_fps_label) - - _standalone_speed_label = Label.new() - _standalone_speed_label.name = "StandaloneSpeedLabel" - _standalone_speed_label.text = "Speed: 0.0 m/s" - _standalone_speed_label.add_theme_font_size_override("font_size", 24) - _standalone_speed_label.add_theme_color_override("font_color", Color(0.3, 1.0, 0.5)) - _standalone_speed_label.add_theme_color_override("font_outline_color", Color(0, 0, 0)) - _standalone_speed_label.add_theme_constant_override("outline_size", 4) - _standalone_speed_label.set_anchors_preset(Control.PRESET_TOP_LEFT) - _standalone_speed_label.position = Vector2(12, 45) - canvas.add_child(_standalone_speed_label) - - _debug_ui_panel = PanelContainer.new() - _debug_ui_panel.name = "InfoPanel" - _debug_ui_panel.offset_left = 12 - _debug_ui_panel.offset_top = 50 - _debug_ui_panel.offset_right = 400 - _debug_ui_panel.offset_bottom = 160 - var panel_style := StyleBoxFlat.new() - panel_style.bg_color = Color(0, 0, 0, 0.55) - panel_style.corner_radius_top_left = 8 - panel_style.corner_radius_top_right = 8 - panel_style.corner_radius_bottom_left = 8 - panel_style.corner_radius_bottom_right = 8 - panel_style.content_margin_left = 12 - panel_style.content_margin_top = 8 - panel_style.content_margin_right = 12 - panel_style.content_margin_bottom = 8 - _debug_ui_panel.add_theme_stylebox_override("panel", panel_style) - canvas.add_child(_debug_ui_panel) - - var vbox := VBoxContainer.new() - vbox.name = "InfoVBox" - _debug_ui_panel.add_child(vbox) - - _speed_label = Label.new() - _speed_label.name = "SpeedLabel" - _speed_label.text = "Speed: 0.0 m/s" - _speed_label.add_theme_font_size_override("font_size", 18) - _speed_label.add_theme_color_override("font_color", Color(0.3, 1.0, 0.5)) - vbox.add_child(_speed_label) - - _state_label = Label.new() - _state_label.name = "StateLabel" - _state_label.text = "State: ground" - _state_label.add_theme_font_size_override("font_size", 16) - _state_label.add_theme_color_override("font_color", Color(0.7, 0.85, 1.0)) - vbox.add_child(_state_label) - - _chain_label = Label.new() - _chain_label.name = "ChainLabel" - _chain_label.text = "Chain: 0 (+0%)" - _chain_label.add_theme_font_size_override("font_size", 16) - _chain_label.add_theme_color_override("font_color", Color(1.0, 0.8, 0.3)) - vbox.add_child(_chain_label) - - - # ── Weapon & Ammo Panel ─────────────────────────────────────────────── - var wp_panel := PanelContainer.new() - wp_panel.name = "WeaponPanel" - wp_panel.set_anchors_preset(Control.PRESET_BOTTOM_RIGHT) - wp_panel.offset_left = -250 - wp_panel.offset_top = -100 - wp_panel.offset_right = -20 - wp_panel.offset_bottom = -20 - var wp_style := StyleBoxFlat.new() - wp_style.bg_color = Color(0, 0, 0, 0.6) - wp_style.corner_radius_top_left = 8 - wp_style.corner_radius_top_right = 8 - wp_style.corner_radius_bottom_left = 8 - wp_style.corner_radius_bottom_right = 8 - wp_style.content_margin_left = 16 - wp_style.content_margin_top = 12 - wp_style.content_margin_right = 16 - wp_style.content_margin_bottom = 12 - wp_panel.add_theme_stylebox_override("panel", wp_style) - wp_panel.grow_horizontal = Control.GROW_DIRECTION_BEGIN - canvas.add_child(wp_panel) - - _weapon_label = Label.new() - _weapon_label.name = "WeaponLabel" - _weapon_label.text = "Unarmed\n0 / 0" - _weapon_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT - _weapon_label.vertical_alignment = VERTICAL_ALIGNMENT_BOTTOM - _weapon_label.add_theme_font_size_override("font_size", 24) - wp_panel.add_child(_weapon_label) - - # ── Utilities Panel ─────────────────────────────────────────────────────── - var util_panel := PanelContainer.new() - util_panel.name = "UtilPanel" - util_panel.set_anchors_preset(Control.PRESET_BOTTOM_RIGHT) - util_panel.offset_left = -200 - util_panel.offset_top = -180 - util_panel.offset_right = -20 - util_panel.offset_bottom = -110 - var util_style := StyleBoxFlat.new() - util_style.bg_color = Color(0, 0, 0, 0.6) - util_style.corner_radius_top_left = 8 - util_style.corner_radius_top_right = 8 - util_style.corner_radius_bottom_left = 8 - util_style.corner_radius_bottom_right = 8 - util_style.content_margin_left = 12 - util_style.content_margin_top = 8 - util_style.content_margin_right = 12 - util_style.content_margin_bottom = 8 - util_panel.add_theme_stylebox_override("panel", util_style) - util_panel.grow_horizontal = Control.GROW_DIRECTION_BEGIN - canvas.add_child(util_panel) - - var util_hbox := HBoxContainer.new() - util_hbox.name = "UtilHBox" - util_hbox.add_theme_constant_override("separation", 20) - util_hbox.alignment = BoxContainer.ALIGNMENT_CENTER - util_panel.add_child(util_hbox) - - var grapple_vbox := VBoxContainer.new() - grapple_vbox.alignment = BoxContainer.ALIGNMENT_CENTER - util_hbox.add_child(grapple_vbox) - - _grapple_icon = TextureRect.new() - _grapple_icon.texture = load("res://assets/ui/grapple_icon.jpg") - _grapple_icon.expand_mode = TextureRect.EXPAND_IGNORE_SIZE - _grapple_icon.custom_minimum_size = Vector2(32, 32) - _grapple_icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED - grapple_vbox.add_child(_grapple_icon) - - _grapple_label = Label.new() - _grapple_label.text = "Grapple" - _grapple_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER - _grapple_label.add_theme_font_size_override("font_size", 12) - grapple_vbox.add_child(_grapple_label) - - var dash_vbox := VBoxContainer.new() - dash_vbox.alignment = BoxContainer.ALIGNMENT_CENTER - util_hbox.add_child(dash_vbox) - - _dash_icon = TextureRect.new() - _dash_icon.texture = load("res://assets/ui/dash_icon.jpg") - _dash_icon.expand_mode = TextureRect.EXPAND_IGNORE_SIZE - _dash_icon.custom_minimum_size = Vector2(32, 32) - _dash_icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED - dash_vbox.add_child(_dash_icon) - - _dash_label = Label.new() - _dash_label.text = "Ready" - _dash_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER - _dash_label.add_theme_font_size_override("font_size", 12) - dash_vbox.add_child(_dash_label) - -# ── Utility: Key Name ───────────────────────────────────────────────────────── - -func _get_key_name(action: String) -> String: - if not InputMap.has_action(action): - return "?" - var events = InputMap.action_get_events(action) - for e in events: - if e is InputEventKey: - var code = e.physical_keycode if e.physical_keycode != 0 else e.keycode - return OS.get_keycode_string(code) - elif e is InputEventMouseButton: - if e.button_index == MOUSE_BUTTON_LEFT: return "LClick" - elif e.button_index == MOUSE_BUTTON_RIGHT: return "RClick" - elif e.button_index == MOUSE_BUTTON_MIDDLE: return "MClick" - return "?" - -# ── HUD Update ──────────────────────────────────────────────────────────────── - -func _process(_delta: float) -> void: - if not _player or not is_instance_valid(_player): - return - - if _debug_ui_panel: - _debug_ui_panel.visible = SettingsManager.show_debug_ui - - if _fps_label: - _fps_label.visible = SettingsManager.show_fps - if _fps_label.visible: - _fps_label.text = "FPS: %d" % Engine.get_frames_per_second() - - var vel: Vector3 = _player.velocity - var hspeed := Vector2(vel.x, vel.z).length() - var total_speed := vel.length() - - if _speed_label: - _speed_label.text = "Speed: %.1f m/s (total: %.1f)" % [hspeed, total_speed] - - if _standalone_speed_label: - if SettingsManager.show_movement_speed and not SettingsManager.show_debug_ui: - _standalone_speed_label.visible = true - _standalone_speed_label.text = "Speed: %.1f m/s" % hspeed - if _fps_label and _fps_label.visible: - _standalone_speed_label.position = Vector2(12, 45) - else: - _standalone_speed_label.position = Vector2(12, 12) - else: - _standalone_speed_label.visible = false - - if _state_label: - var sm = _player.get_node_or_null("MovementStateMachine") - if sm: - _state_label.text = "State: %s" % sm.current_state - - if _chain_label: - var sm = _player.get_node_or_null("MovementStateMachine") - if sm: - _chain_label.text = "Chain: %d (+%d%%)" % [sm.chain_count, int(sm.current_chain_bonus * 100)] - - # Update utility indicators - if sm.current_state == "grapple" or sm.is_grapple_shooting: - _grapple_icon.modulate = Color(0.2, 1.0, 0.4) - _grapple_label.text = "Grappling" - else: - _grapple_icon.modulate = Color(1.0, 1.0, 1.0) - _grapple_label.text = "Ready" - - var dash_rem = sm.get_dash_cooldown_remaining() - if dash_rem > 0.0: - _dash_icon.modulate = Color(1.0, 0.3, 0.3) - _dash_label.text = "%.1f" % dash_rem - else: - _dash_icon.modulate = Color(1.0, 1.0, 1.0) - _dash_label.text = "Ready" - - if _weapon_label: - var wman = _player.get_node_or_null("HeadPivot/Camera3D/WeaponManager") - if wman and wman.weapons.has(wman.active_slot): - var active_weapon = wman.weapons[wman.active_slot] - var w_name = "Weapon" - var cur_ammo = 0 - var max_ammo = 0 - - if "weapon_name" in active_weapon: - w_name = active_weapon.weapon_name - elif active_weapon is DoubleBarrelShotgun: - w_name = "Double Barrel Shotgun" - - if "current_ammo" in active_weapon: - cur_ammo = active_weapon.current_ammo - max_ammo = active_weapon.max_ammo - elif "shells" in active_weapon: - cur_ammo = active_weapon.shells - max_ammo = 2 - - if "reloading" in active_weapon and active_weapon.reloading: - _weapon_label.text = "%s\nReloading..." % w_name - else: - _weapon_label.text = "%s\n%d / %d" % [w_name, cur_ammo, max_ammo] - else: - _weapon_label.text = "Unarmed\n0 / 0" +# ── HUD ─────────────────────────────────────────────────────────────── +# +# There is no HUD here any more, and no _process to drive one. See the note in +# scenes/maps/level_runtime.gd: everything that describes A PLAYER belongs to +# ui/player_hud.gd, which that player spawns for itself. diff --git a/ui/ability_chip.gd b/ui/ability_chip.gd new file mode 100644 index 0000000..cb6eb48 --- /dev/null +++ b/ui/ability_chip.gd @@ -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) diff --git a/ui/ability_chip.gd.uid b/ui/ability_chip.gd.uid new file mode 100644 index 0000000..e64d4fa --- /dev/null +++ b/ui/ability_chip.gd.uid @@ -0,0 +1 @@ +uid://48ja2bu8k6xm diff --git a/ui/crosshair.gd b/ui/crosshair.gd new file mode 100644 index 0000000..ee8a084 --- /dev/null +++ b/ui/crosshair.gd @@ -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) diff --git a/ui/crosshair.gd.uid b/ui/crosshair.gd.uid new file mode 100644 index 0000000..9289ddf --- /dev/null +++ b/ui/crosshair.gd.uid @@ -0,0 +1 @@ +uid://ck57a8waor1d1 diff --git a/ui/emote_wheel.gd b/ui/emote_wheel.gd new file mode 100644 index 0000000..89f8376 --- /dev/null +++ b/ui/emote_wheel.gd @@ -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)) diff --git a/ui/emote_wheel.gd.uid b/ui/emote_wheel.gd.uid new file mode 100644 index 0000000..b8962f8 --- /dev/null +++ b/ui/emote_wheel.gd.uid @@ -0,0 +1 @@ +uid://dnxnbswrl04u8 diff --git a/ui/main_menu/level_card.gd b/ui/main_menu/level_card.gd new file mode 100644 index 0000000..6bbe078 --- /dev/null +++ b/ui/main_menu/level_card.gd @@ -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/.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 diff --git a/ui/main_menu/level_card.gd.uid b/ui/main_menu/level_card.gd.uid new file mode 100644 index 0000000..2b8b557 --- /dev/null +++ b/ui/main_menu/level_card.gd.uid @@ -0,0 +1 @@ +uid://2bfw74vxcitf diff --git a/ui/main_menu/main_menu.gd b/ui/main_menu/main_menu.gd index 9193d3c..4e481e2 100644 --- a/ui/main_menu/main_menu.gd +++ b/ui/main_menu/main_menu.gd @@ -1,602 +1,739 @@ extends Control -var _ui_vbox: VBoxContainer -var _level_selector_panel: VBoxContainer -var _multiplayer_panel: VBoxContainer -var _direct_connect_panel: VBoxContainer -var _host_lobby_panel: Control -var _host_lobby_players_list: ItemList +## The front door, arranged so the shortest path through it is the one a player +## takes every time. +## +## ── What was wrong ────────────────────────────────────────────────────────── +## +## Reaching a game was: Singleplayer → pick a level. Reaching a multiplayer game +## was: Multiplayer → Host Game → wait for the lobby → pick a level → and the +## gamemode dropdown had exactly one entry, which set a string nothing read. +## Every level card was a two-stop gradient generated from two colours in a meta +## file, which tells a player which card they clicked last time and nothing at +## all about the map. +## +## ── What it is now ────────────────────────────────────────────────────────── +## +## PLAY is one press, and it starts the last map and mode played. Everything else +## on the home screen is a detour from that, which is the correct shape for a +## menu: the common case is a button, not a path. +## +## This is the lesson HoYoverse published about Zenless Zone Zero's first months +## more loudly than anything else they have written. Their own postmortem on the +## TV mode names the three complaints — it took too long, it sat between the +## player and the combat, and there was too much of it early — and version 1.2 +## removed it from the story entirely rather than shortening it. The general +## form is that time spent BEFORE the thing the player came for is not neutral, +## it is a cost, and a menu is the purest example. +## +## The other half of that team's writing that applies here is their Sixth Street +## level-design postmortem: functions grouped by relatedness, travel between them +## deliberately short, and every destination given a landmark you can recognise +## on sight rather than a label you have to read. A level select made of +## photographs is that last point exactly. +## +## ── Screens ───────────────────────────────────────────────────────────────── +## +## HOME → LEVELS, HOME → MULTIPLAYER → LOBBY. Nothing is more than two steps +## from the front, and PLAY is zero. + +const PREVIEW_DIR := "res://assets/ui/map_previews/" +const MAPS_DIR := "res://scenes/maps/" +## Where "what I played last" is remembered, so PLAY has something to mean the +## second time the game is opened. +const LAST_CFG := "user://last_match.cfg" + +enum { HOME, LEVELS, MULTIPLAYER, LOBBY } + +var _screens: Dictionary = {} +var _maps: Array = [] # [{id, name, scene_path, gradient, preview}] +var _mode: String = GameMode.DEATHMATCH +var _last_scene: String = "" + +var _play_button: Button +var _mode_note: Label +var _lobby_players: ItemList +var _lobby_start: Button +var _lobby_map_note: Label +var _pending_scene: String = "" +var _ip_input: LineEdit +var _port_input: LineEdit +var _join_status: Label + func _ready() -> void: Input.set_mouse_mode(Input.MOUSE_MODE_VISIBLE) - - # Comic/cel theme for every Control in the game (root window inherits down) UITheme.apply_global(get_tree()) + _load_last() + _collect_maps() + _play_music() - # Menu music (scene-owned: stops automatically when a level loads). - # OGG loops natively over the full track — no manual loop points. - var music_path := "res://assets/sounds/music_menu.ogg" - if ResourceLoader.exists(music_path): - var music := AudioStreamPlayer.new() - music.stream = load(music_path) - if music.stream is AudioStreamOggVorbis: - music.stream.loop = true - music.bus = "Music" - music.volume_db = -8.0 - music.autoplay = true - add_child(music) - - var nm = get_node_or_null("/root/NetworkManager") + var nm := _net() if nm: nm.connection_succeeded.connect(_on_connection_succeeded) - - # ========================================== - # BACKGROUND: 3D DIORAMA (Sliding Player) - # ========================================== - var right_panel = SubViewportContainer.new() - right_panel.set_anchors_preset(Control.PRESET_FULL_RECT) - right_panel.stretch = true - right_panel.mouse_filter = Control.MOUSE_FILTER_IGNORE - add_child(right_panel) - - var viewport = SubViewport.new() - viewport.own_world_3d = true - viewport.msaa_3d = Viewport.MSAA_4X - right_panel.add_child(viewport) - - _build_3d_diorama(viewport) - - # ========================================== - # FOREGROUND: UI (Title and Buttons) - # ========================================== - _ui_vbox = VBoxContainer.new() - _ui_vbox.add_theme_constant_override("separation", 30) - _ui_vbox.set_anchor_and_offset(SIDE_LEFT, 0.0, 50) - _ui_vbox.set_anchor_and_offset(SIDE_TOP, 0.0, 50) - _ui_vbox.set_anchor_and_offset(SIDE_RIGHT, 0.0, 600) - _ui_vbox.set_anchor_and_offset(SIDE_BOTTOM, 1.0, -50) - add_child(_ui_vbox) + nm.connection_failed.connect(_on_connection_failed) - # Title — big comic wordmark with a slight kinetic tilt - var title = Label.new() - title.text = "PAPAYA SHOOTER" - title.add_theme_font_size_override("font_size", 92) - title.add_theme_color_override("font_color", UITheme.PAPAYA) - title.add_theme_color_override("font_outline_color", UITheme.INK) - title.add_theme_constant_override("outline_size", 16) - title.rotation_degrees = -2.5 - _ui_vbox.add_child(title) - - # Spacer - var spacer = Control.new() - spacer.custom_minimum_size = Vector2(0, 40) - _ui_vbox.add_child(spacer) - - # Buttons - _add_button(_ui_vbox, "Singleplayer", _on_singleplayer_pressed) - _add_button(_ui_vbox, "Multiplayer", _on_multiplayer_pressed) - _add_button(_ui_vbox, "Loadouts", _on_loadouts_pressed) - _add_button(_ui_vbox, "Settings", _on_settings_pressed) - _add_button(_ui_vbox, "Exit Game", _on_exit_pressed) + _build_diorama() + _screens[HOME] = _build_home() + _screens[LEVELS] = _build_levels() + _screens[MULTIPLAYER] = _build_multiplayer() + _screens[LOBBY] = _build_lobby() + _show(HOME) - # Skin selector (skins come from SkinManager: built-ins + skins.json) - _build_skin_selector(_ui_vbox) - - # ========================================== - # LEVEL SELECTOR PANEL - # ========================================== - _level_selector_panel = VBoxContainer.new() - _level_selector_panel.add_theme_constant_override("separation", 20) - _level_selector_panel.set_anchor_and_offset(SIDE_LEFT, 0.0, 50) - _level_selector_panel.set_anchor_and_offset(SIDE_TOP, 0.0, 50) - _level_selector_panel.set_anchor_and_offset(SIDE_RIGHT, 0.0, 800) - _level_selector_panel.set_anchor_and_offset(SIDE_BOTTOM, 1.0, -50) - _level_selector_panel.hide() - add_child(_level_selector_panel) - - var ls_title = Label.new() - ls_title.text = "Select Level" - ls_title.add_theme_font_size_override("font_size", 64) - ls_title.add_theme_color_override("font_color", UITheme.PAPAYA) - ls_title.add_theme_color_override("font_outline_color", UITheme.INK) - ls_title.add_theme_constant_override("outline_size", 8) - _level_selector_panel.add_child(ls_title) - - # Cards Container - var cards_hbox = HBoxContainer.new() - cards_hbox.add_theme_constant_override("separation", 30) - cards_hbox.size_flags_vertical = Control.SIZE_EXPAND_FILL - _level_selector_panel.add_child(cards_hbox) - - _load_maps_modularly(cards_hbox, false, Vector2(300, 400)) - - var ls_spacer = Control.new() - ls_spacer.custom_minimum_size = Vector2(0, 20) - _level_selector_panel.add_child(ls_spacer) - - _add_button(_level_selector_panel, "Back", _on_back_pressed) - - # ========================================== - # MULTIPLAYER PANEL - # ========================================== - _multiplayer_panel = VBoxContainer.new() - _multiplayer_panel.add_theme_constant_override("separation", 20) - _multiplayer_panel.set_anchor_and_offset(SIDE_LEFT, 0.0, 50) - _multiplayer_panel.set_anchor_and_offset(SIDE_TOP, 0.0, 50) - _multiplayer_panel.set_anchor_and_offset(SIDE_RIGHT, 0.0, 600) - _multiplayer_panel.set_anchor_and_offset(SIDE_BOTTOM, 1.0, -50) - _multiplayer_panel.hide() - add_child(_multiplayer_panel) - - var mp_title = Label.new() - mp_title.text = "Multiplayer" - mp_title.add_theme_font_size_override("font_size", 64) - mp_title.add_theme_color_override("font_color", UITheme.PAPAYA) - mp_title.add_theme_color_override("font_outline_color", UITheme.INK) - mp_title.add_theme_constant_override("outline_size", 8) - _multiplayer_panel.add_child(mp_title) - - var mp_spacer = Control.new() - mp_spacer.custom_minimum_size = Vector2(0, 20) - _multiplayer_panel.add_child(mp_spacer) - - _add_button(_multiplayer_panel, "Host Game", _on_host_pressed) - _add_button(_multiplayer_panel, "Direct Connect", _on_direct_connect_pressed) - _add_button(_multiplayer_panel, "Back", _on_multiplayer_back_pressed) - - # ========================================== - # DIRECT CONNECT PANEL - # ========================================== - _direct_connect_panel = VBoxContainer.new() - _direct_connect_panel.add_theme_constant_override("separation", 20) - _direct_connect_panel.set_anchor_and_offset(SIDE_LEFT, 0.0, 50) - _direct_connect_panel.set_anchor_and_offset(SIDE_TOP, 0.0, 50) - _direct_connect_panel.set_anchor_and_offset(SIDE_RIGHT, 0.0, 600) - _direct_connect_panel.set_anchor_and_offset(SIDE_BOTTOM, 1.0, -50) - _direct_connect_panel.hide() - add_child(_direct_connect_panel) - - var dc_title = Label.new() - dc_title.text = "Direct Connect" - dc_title.add_theme_font_size_override("font_size", 64) - dc_title.add_theme_color_override("font_color", UITheme.PAPAYA) - dc_title.add_theme_color_override("font_outline_color", UITheme.INK) - dc_title.add_theme_constant_override("outline_size", 8) - _direct_connect_panel.add_child(dc_title) - - var dc_ip_label = Label.new() - dc_ip_label.text = "IP Address:" - dc_ip_label.add_theme_font_size_override("font_size", 24) - _direct_connect_panel.add_child(dc_ip_label) - - var dc_ip_input = LineEdit.new() - dc_ip_input.text = "127.0.0.1" - dc_ip_input.name = "IPInput" - dc_ip_input.add_theme_font_size_override("font_size", 24) - dc_ip_input.custom_minimum_size = Vector2(300, 50) - _direct_connect_panel.add_child(dc_ip_input) - - var dc_port_label = Label.new() - dc_port_label.text = "Port:" - dc_port_label.add_theme_font_size_override("font_size", 24) - _direct_connect_panel.add_child(dc_port_label) - - var dc_port_input = LineEdit.new() - dc_port_input.text = "31415" # Default Port - dc_port_input.name = "PortInput" - dc_port_input.add_theme_font_size_override("font_size", 24) - dc_port_input.custom_minimum_size = Vector2(300, 50) - _direct_connect_panel.add_child(dc_port_input) - - var dc_spacer = Control.new() - dc_spacer.custom_minimum_size = Vector2(0, 20) - _direct_connect_panel.add_child(dc_spacer) - - _add_button(_direct_connect_panel, "Connect", _on_connect_confirm_pressed) - _add_button(_direct_connect_panel, "Back", _on_dc_back_pressed) - - # ========================================== - # HOST LOBBY PANEL - # ========================================== - _host_lobby_panel = Control.new() - _host_lobby_panel.set_anchors_preset(Control.PRESET_FULL_RECT) - _host_lobby_panel.hide() - add_child(_host_lobby_panel) - - var hl_hbox = HBoxContainer.new() - hl_hbox.set_anchors_preset(Control.PRESET_FULL_RECT) - hl_hbox.add_theme_constant_override("separation", 50) - _host_lobby_panel.add_child(hl_hbox) - - # Left Side (Level Selector) - var hl_left = VBoxContainer.new() - hl_left.size_flags_horizontal = Control.SIZE_EXPAND_FILL - hl_left.set_anchor_and_offset(SIDE_LEFT, 0.0, 50) - hl_left.set_anchor_and_offset(SIDE_TOP, 0.0, 50) - hl_left.add_theme_constant_override("separation", 20) - - var hl_margin = MarginContainer.new() - hl_margin.add_theme_constant_override("margin_left", 50) - hl_margin.add_theme_constant_override("margin_top", 50) - hl_margin.add_theme_constant_override("margin_bottom", 50) - hl_margin.size_flags_horizontal = Control.SIZE_EXPAND_FILL - hl_hbox.add_child(hl_margin) - hl_margin.add_child(hl_left) - - var hl_title = Label.new() - hl_title.text = "Host Lobby - Select Level" - hl_title.add_theme_font_size_override("font_size", 64) - hl_title.add_theme_color_override("font_color", UITheme.PAPAYA) - hl_title.add_theme_color_override("font_outline_color", UITheme.INK) - hl_title.add_theme_constant_override("outline_size", 8) - hl_left.add_child(hl_title) - - var hl_cards = HBoxContainer.new() - hl_cards.add_theme_constant_override("separation", 20) - hl_left.add_child(hl_cards) - - _load_maps_modularly(hl_cards, true, Vector2(200, 250)) - - var mode_label = Label.new() - mode_label.text = "Gamemode" - mode_label.add_theme_font_size_override("font_size", 32) - hl_left.add_child(mode_label) - - var _gamemode_opt = OptionButton.new() - _gamemode_opt.add_theme_font_size_override("font_size", 24) - _gamemode_opt.add_item("Deathmatch", 0) - _gamemode_opt.add_to_group("host_only_ui") - _gamemode_opt.item_selected.connect(func(index: int): - if get_node_or_null("/root/NetworkManager"): - get_node("/root/NetworkManager").current_gamemode = _gamemode_opt.get_item_text(index) - ) - hl_left.add_child(_gamemode_opt) - - var hl_spacer = Control.new() - hl_spacer.size_flags_vertical = Control.SIZE_EXPAND_FILL - hl_left.add_child(hl_spacer) - - _add_button(hl_left, "Disband & Back", _on_lobby_back_pressed) - - # Right Side (Player List) - var hl_right = VBoxContainer.new() - hl_right.custom_minimum_size = Vector2(400, 0) - var hl_right_margin = MarginContainer.new() - hl_right_margin.add_theme_constant_override("margin_top", 50) - hl_right_margin.add_theme_constant_override("margin_right", 50) - hl_right_margin.add_theme_constant_override("margin_bottom", 50) - hl_hbox.add_child(hl_right_margin) - hl_right_margin.add_child(hl_right) - - var pl_title = Label.new() - pl_title.text = "Players Connected" - pl_title.add_theme_font_size_override("font_size", 48) - pl_title.add_theme_color_override("font_outline_color", UITheme.INK) - pl_title.add_theme_constant_override("outline_size", 8) - hl_right.add_child(pl_title) - - var port_label = Label.new() - if get_node_or_null("/root/NetworkManager"): - port_label.text = "Port: " + str(get_node("/root/NetworkManager").DEFAULT_PORT) - port_label.add_theme_font_size_override("font_size", 24) - port_label.add_theme_color_override("font_color", UITheme.PAPER_DIM) - hl_right.add_child(port_label) - - _host_lobby_players_list = ItemList.new() - _host_lobby_players_list.size_flags_vertical = Control.SIZE_EXPAND_FILL - _host_lobby_players_list.add_theme_font_size_override("font_size", 24) - hl_right.add_child(_host_lobby_players_list) - - # Hover/click sounds on every menu button UITheme.wire_sounds(self) + if _play_button: + _play_button.grab_focus() -func _load_maps_modularly(target_hbox: Container, is_multiplayer: bool, card_size: Vector2) -> void: - var maps_dir = "res://scenes/maps/" - var dir = DirAccess.open(maps_dir) - if not dir: + +# ── Persistence ────────────────────────────────────────────────────────────── + + +func _load_last() -> void: + var cfg := ConfigFile.new() + if cfg.load(LAST_CFG) == OK: + _last_scene = cfg.get_value("match", "scene", "") + var m: String = cfg.get_value("match", "mode", GameMode.DEATHMATCH) + if GameMode.MODES.has(m): + _mode = m + + +func _remember(scene_path: String) -> void: + var cfg := ConfigFile.new() + cfg.set_value("match", "scene", scene_path) + cfg.set_value("match", "mode", _mode) + cfg.save(LAST_CFG) + + +# ── Maps ───────────────────────────────────────────────────────────────────── + + +func _collect_maps() -> void: + var dir := DirAccess.open(MAPS_DIR) + if dir == null: return - dir.list_dir_begin() - var folder_name = dir.get_next() - while folder_name != "": - if dir.current_is_dir() and not folder_name.begins_with("."): - var cfg_path = maps_dir + folder_name + "/map_meta.cfg" - if FileAccess.file_exists(cfg_path): - var cfg = ConfigFile.new() - if cfg.load(cfg_path) == OK: - var map_name = cfg.get_value("map", "name", "Unknown Map") - var scene_path = cfg.get_value("map", "scene_path", "") - var c1 = cfg.get_value("map", "color1", Color(0.5, 0.5, 0.5)) - var c2 = cfg.get_value("map", "color2", Color(0.2, 0.2, 0.2)) - - if scene_path != "": - var grad = Gradient.new() - # A new Gradient has 2 points at 0.0 and 1.0 by default. Set their colors directly. - grad.set_color(0, c1) - grad.set_color(1, c2) - _build_level_card(target_hbox, map_name, scene_path, grad, is_multiplayer, card_size) - folder_name = dir.get_next() + var folder := dir.get_next() + while folder != "": + if dir.current_is_dir() and not folder.begins_with("."): + var cfg := ConfigFile.new() + if cfg.load(MAPS_DIR + folder + "/map_meta.cfg") == OK: + var scene_path: String = cfg.get_value("map", "scene_path", "") + if scene_path != "": + var grad := Gradient.new() + grad.set_color(0, cfg.get_value("map", "color1", Color(0.5, 0.5, 0.5))) + grad.set_color(1, cfg.get_value("map", "color2", Color(0.2, 0.2, 0.2))) + _maps.append({ + "id": folder, + "name": cfg.get_value("map", "name", folder), + "scene_path": scene_path, + "gradient": grad, + "preview": PREVIEW_DIR + folder + ".png", + }) + folder = dir.get_next() + _maps.sort_custom(func(a, b): return String(a["name"]) < String(b["name"])) + # PLAY needs a target even on a first run, and the first map alphabetically + # is a better default than refusing to start. + if _last_scene == "" and not _maps.is_empty(): + _last_scene = _maps[0]["scene_path"] -func _build_level_card(parent: Container, title: String, scene_path: String, gradient: Gradient, is_multiplayer: bool = false, card_size: Vector2 = Vector2(300, 400)) -> void: - var btn = TextureButton.new() - btn.custom_minimum_size = card_size - - 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) - btn.texture_normal = tex - - var tex_hover = GradientTexture2D.new() - tex_hover.gradient = gradient - tex_hover.width = int(card_size.x) - tex_hover.height = int(card_size.y) - tex_hover.fill_to = Vector2(1, 1) - tex_hover.use_hdr = true # Slight bright effect on hover - btn.texture_hover = tex_hover - btn.modulate = Color(1, 1, 1) - - # VBox to hold the label - var vbox = VBoxContainer.new() - vbox.set_anchors_preset(Control.PRESET_FULL_RECT) - vbox.alignment = BoxContainer.ALIGNMENT_END - btn.add_child(vbox) - - # Dimming overlay at the bottom for text readability - var overlay = ColorRect.new() - overlay.color = Color(0, 0, 0, 0.6) - overlay.custom_minimum_size = Vector2(0, 60) - vbox.add_child(overlay) - - var lbl = Label.new() - lbl.text = title - lbl.add_theme_font_size_override("font_size", 32) - lbl.add_theme_color_override("font_color", UITheme.PAPER) - lbl.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER - lbl.set_anchors_preset(Control.PRESET_FULL_RECT) - overlay.add_child(lbl) - - if is_multiplayer: - btn.add_to_group("host_only_ui") - btn.pressed.connect(func(): get_node("/root/NetworkManager").rpc_load_level.rpc(scene_path)) + +func _map_named(scene_path: String) -> String: + for m in _maps: + if m["scene_path"] == scene_path: + return String(m["name"]) + return "Unknown" + + +# ── Screens ────────────────────────────────────────────────────────────────── + + +func _show(which: int) -> void: + for key in _screens: + (_screens[key] as Control).visible = key == which + + +## A screen: a scrim over the 3D background, then a margined column on top. +## +## The scrim is not decoration. The character standing in the diorama is roughly +## screen-centre, which is exactly where the level cards go, so without it their +## heads poke through the row of maps. Darkening the background also lifts every +## label on these screens off a bright sky. The HOME screen passes `scrim = 0.0`, +## because there the character IS the content. +func _panel(scrim: float = 0.55, margin_left: int = 60) -> VBoxContainer: + var screen := Control.new() + screen.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + screen.mouse_filter = Control.MOUSE_FILTER_IGNORE + add_child(screen) + + if scrim > 0.0: + var wash := ColorRect.new() + wash.color = Color(UITheme.INK.r, UITheme.INK.g, UITheme.INK.b, scrim) + wash.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + wash.mouse_filter = Control.MOUSE_FILTER_IGNORE + screen.add_child(wash) + + var holder := MarginContainer.new() + holder.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + holder.add_theme_constant_override("margin_left", margin_left) + holder.add_theme_constant_override("margin_top", 48) + holder.add_theme_constant_override("margin_right", 60) + holder.add_theme_constant_override("margin_bottom", 48) + screen.add_child(holder) + + var col := VBoxContainer.new() + col.add_theme_constant_override("separation", 14) + holder.add_child(col) + _pending_panel = screen + return col + + +var _pending_panel: Control + + +func _build_home() -> Control: + # No scrim: on the front screen the character IS the content. + var col := _panel(0.0) + var screen := _pending_panel + + var title := UITheme.title("PAPAYA SHOOTER", 84) + col.add_child(title) + + # The one loud button. It says what it will do, so pressing it is never a + # leap — "PLAY • Akiba Crossing • Deathmatch" is a promise, not a menu. + _play_button = UITheme.primary_button("PLAY", 52) + _play_button.custom_minimum_size = Vector2(430, 92) + _play_button.size_flags_horizontal = Control.SIZE_SHRINK_BEGIN + _play_button.pressed.connect(_on_play) + col.add_child(_play_button) + + _mode_note = UITheme.caption("", 21) + col.add_child(_mode_note) + _refresh_play_note() + + col.add_child(UITheme.divider(0.3)) + + _add_button(col, "Choose Map", func(): _show(LEVELS)) + _add_button(col, "Multiplayer", func(): _show(MULTIPLAYER)) + _add_button(col, "Loadouts", _on_loadouts) + _add_button(col, "Settings", _on_settings) + _add_button(col, "Exit", func(): get_tree().quit()) + + var spacer := Control.new() + spacer.size_flags_vertical = Control.SIZE_EXPAND_FILL + col.add_child(spacer) + _build_skin_row(col) + return screen + + +func _refresh_play_note() -> void: + if _mode_note: + _mode_note.text = "%s • %s" % [_map_named(_last_scene), + GameMode.display_name(_mode)] + if _play_button: + _play_button.disabled = _last_scene == "" + + +func _build_levels() -> Control: + var col := _panel() + var screen := _pending_panel + screen.visible = false + + col.add_child(UITheme.title("CHOOSE MAP", 58)) + col.add_child(_mode_selector(func(): _refresh_level_cards())) + + var row := HBoxContainer.new() + row.add_theme_constant_override("separation", 22) + row.name = "Cards" + col.add_child(row) + + for m in _maps: + var card := LevelCard.new() + card.setup(m["name"], m["preview"], m["gradient"], Vector2(310, 250)) + var path: String = m["scene_path"] + # One press starts the match. There is no "select, then confirm" — a + # confirmation step on a level select exists only to make the screen + # take longer. + card.pressed.connect(func(): _start_single(path)) + row.add_child(card) + + var spacer := Control.new() + spacer.size_flags_vertical = Control.SIZE_EXPAND_FILL + col.add_child(spacer) + _add_button(col, "Back", func(): _show(HOME)) + _refresh_level_cards() + return screen + + +func _refresh_level_cards() -> void: + if not _screens.has(LEVELS): + return + for card in (_screens[LEVELS] as Control).find_children("*", "LevelCard", true, false): + (card as LevelCard).set_mode_text(GameMode.display_name(_mode).to_upper()) + _refresh_play_note() + + +## The mode picker: a row of chips, not a dropdown. +## +## Every mode is visible and one press away, and each carries its own one-line +## description — a dropdown hides four fifths of the options behind a click and +## has nowhere to put the sentence that says what they are. +func _mode_selector(on_change: Callable) -> Control: + var box := VBoxContainer.new() + box.add_theme_constant_override("separation", 4) + + var row := HBoxContainer.new() + row.add_theme_constant_override("separation", 10) + box.add_child(row) + + var blurb := UITheme.caption(GameMode.blurb(_mode), 19) + var buttons: Array = [] + for id in GameMode.all_ids(): + var b := Button.new() + b.text = GameMode.display_name(id) + b.toggle_mode = true + b.button_pressed = id == _mode + b.add_theme_font_size_override("font_size", 24) + b.add_to_group("mode_chip") + var mode_id: String = id + b.pressed.connect(func(): + _mode = mode_id + for other in buttons: + (other as Button).button_pressed = other == b + blurb.text = GameMode.blurb(mode_id) + on_change.call()) + buttons.append(b) + row.add_child(b) + + box.add_child(blurb) + return box + + +func _build_multiplayer() -> Control: + var col := _panel() + var screen := _pending_panel + screen.visible = false + + col.add_child(UITheme.title("MULTIPLAYER", 58)) + + # Hosting is one press and it lands in the lobby already hosting. It used to + # be Host Game, then a lobby, then pick a level, then wait. + var host := UITheme.primary_button("HOST A GAME", 40) + host.custom_minimum_size = Vector2(400, 78) + host.size_flags_horizontal = Control.SIZE_SHRINK_BEGIN + host.pressed.connect(_on_host) + col.add_child(host) + col.add_child(UITheme.caption( + "Others join with your IP on port %d." % _default_port(), 19)) + + col.add_child(UITheme.divider(0.35)) + col.add_child(UITheme.heading("JOIN A GAME", 30)) + + var fields := HBoxContainer.new() + fields.add_theme_constant_override("separation", 12) + col.add_child(fields) + + _ip_input = LineEdit.new() + _ip_input.text = "127.0.0.1" + _ip_input.placeholder_text = "Host IP" + _ip_input.custom_minimum_size = Vector2(320, 54) + _ip_input.add_theme_font_size_override("font_size", 24) + fields.add_child(_ip_input) + + _port_input = LineEdit.new() + _port_input.text = str(_default_port()) + _port_input.placeholder_text = "Port" + _port_input.custom_minimum_size = Vector2(150, 54) + _port_input.add_theme_font_size_override("font_size", 24) + fields.add_child(_port_input) + + var join := Button.new() + join.text = "Connect" + join.add_theme_font_size_override("font_size", 26) + join.pressed.connect(_on_join) + fields.add_child(join) + + # Enter connects, because typing an address and then reaching for the mouse + # is a step nobody wants. + _ip_input.text_submitted.connect(func(_t): _on_join()) + _port_input.text_submitted.connect(func(_t): _on_join()) + + _join_status = UITheme.caption("", 20) + col.add_child(_join_status) + + var spacer := Control.new() + spacer.size_flags_vertical = Control.SIZE_EXPAND_FILL + col.add_child(spacer) + _add_button(col, "Back", func(): _show(HOME)) + return screen + + +## The lobby: map, mode, who is here, and start — all on one screen. +func _build_lobby() -> Control: + var outer := _panel() + var screen := _pending_panel + screen.visible = false + + var split := HBoxContainer.new() + split.add_theme_constant_override("separation", 40) + split.size_flags_vertical = Control.SIZE_EXPAND_FILL + outer.add_child(split) + + var left := VBoxContainer.new() + left.size_flags_horizontal = Control.SIZE_EXPAND_FILL + left.add_theme_constant_override("separation", 14) + split.add_child(left) + + left.add_child(UITheme.title("LOBBY", 54)) + left.add_child(_mode_selector(func(): _sync_lobby_mode())) + + var cards := HBoxContainer.new() + cards.add_theme_constant_override("separation", 16) + left.add_child(cards) + for m in _maps: + var card := LevelCard.new() + card.setup(m["name"], m["preview"], m["gradient"], Vector2(230, 190)) + card.add_to_group("host_only_ui") + var path: String = m["scene_path"] + card.pressed.connect(func(): _pick_lobby_map(path)) + cards.add_child(card) + + _lobby_map_note = UITheme.caption("", 21) + left.add_child(_lobby_map_note) + + var spacer := Control.new() + spacer.size_flags_vertical = Control.SIZE_EXPAND_FILL + left.add_child(spacer) + + var actions := HBoxContainer.new() + actions.add_theme_constant_override("separation", 16) + left.add_child(actions) + + _lobby_start = UITheme.primary_button("START MATCH", 38) + _lobby_start.custom_minimum_size = Vector2(330, 76) + _lobby_start.add_to_group("host_only_ui") + _lobby_start.pressed.connect(_on_start_match) + actions.add_child(_lobby_start) + + var leave := Button.new() + leave.text = "Leave" + leave.add_theme_font_size_override("font_size", 28) + leave.pressed.connect(_on_leave_lobby) + actions.add_child(leave) + + # ── Who is here ───────────────────────────────────────────────────────── + var right := VBoxContainer.new() + right.custom_minimum_size = Vector2(380, 0) + right.add_theme_constant_override("separation", 8) + split.add_child(right) + + right.add_child(UITheme.heading("PLAYERS", 34)) + right.add_child(UITheme.caption("Port %d" % _default_port(), 19)) + + _lobby_players = ItemList.new() + _lobby_players.size_flags_vertical = Control.SIZE_EXPAND_FILL + _lobby_players.add_theme_font_size_override("font_size", 22) + right.add_child(_lobby_players) + + return screen + + +func _pick_lobby_map(scene_path: String) -> void: + _pending_scene = scene_path + _refresh_lobby() + + +func _sync_lobby_mode() -> void: + var nm := _net() + if nm and nm.multiplayer.is_server(): + nm.current_gamemode = _mode + _refresh_lobby() + + +func _refresh_lobby() -> void: + if _lobby_map_note == null: + return + var picked := _pending_scene if _pending_scene != "" else _last_scene + _lobby_map_note.text = "%s • %s • first to %d" % [ + _map_named(picked), GameMode.display_name(_mode), + GameMode.score_limit(_mode)] + if _lobby_start: + _lobby_start.disabled = picked == "" + + +# ── Actions ────────────────────────────────────────────────────────────────── + + +func _on_play() -> void: + if _last_scene != "": + _start_single(_last_scene) + + +func _start_single(scene_path: String) -> void: + _remember(scene_path) + var nm := _net() + if nm: + nm.start_singleplayer_match(_mode) + get_tree().change_scene_to_file(scene_path) + + +func _on_host() -> void: + var nm := _net() + if nm == null: + return + if nm.host_game() == OK: + nm.current_gamemode = _mode + _pending_scene = _last_scene + _on_connection_succeeded() + elif _join_status: + _join_status.text = "Could not open port %d — is a server already running?" \ + % _default_port() + _join_status.add_theme_color_override("font_color", UITheme.MAGENTA) + + +func _on_join() -> void: + var nm := _net() + if nm == null: + return + var port := _port_input.text.to_int() + if port <= 0: + port = _default_port() + if nm.join_game(_ip_input.text, port) == OK: + _join_status.text = "Connecting to %s…" % _ip_input.text + _join_status.add_theme_color_override("font_color", UITheme.PAPER_DIM) else: - btn.pressed.connect(func(): - var nm = get_node_or_null("/root/NetworkManager") - if nm: - nm.start_singleplayer_match("Deathmatch") - get_tree().change_scene_to_file(scene_path) - ) - - parent.add_child(btn) + _on_connection_failed() + + +func _on_connection_failed() -> void: + if _join_status: + _join_status.text = "Could not reach that host." + _join_status.add_theme_color_override("font_color", UITheme.MAGENTA) + + +func _on_start_match() -> void: + var nm := _net() + if nm == null: + return + var picked := _pending_scene if _pending_scene != "" else _last_scene + if picked == "": + return + nm.current_gamemode = _mode + _remember(picked) + nm.rpc_load_level.rpc(picked) + + +func _on_leave_lobby() -> void: + var nm := _net() + if nm: + nm.disconnect_game() + if nm.player_connected.is_connected(_on_peer_changed): + nm.player_connected.disconnect(_on_peer_changed) + nm.player_disconnected.disconnect(_on_peer_changed) + if nm.stats_updated.is_connected(_update_lobby_players): + nm.stats_updated.disconnect(_update_lobby_players) + _show(MULTIPLAYER) + + +func _on_connection_succeeded() -> void: + _show(LOBBY) + var nm := _net() + if nm == null: + return + var is_host: bool = nm.multiplayer.is_server() + for node in get_tree().get_nodes_in_group("host_only_ui"): + if node is BaseButton: + (node as BaseButton).disabled = not is_host + # A client cannot change the mode either, so the chips go with the cards. + for chip in get_tree().get_nodes_in_group("mode_chip"): + if chip is BaseButton and (chip as Control).is_inside_tree() \ + and _screens.has(LOBBY) \ + and (_screens[LOBBY] as Control).is_ancestor_of(chip): + (chip as BaseButton).disabled = not is_host + if not is_host and _lobby_start: + _lobby_start.text = "WAITING FOR HOST" + + if not nm.player_connected.is_connected(_on_peer_changed): + nm.player_connected.connect(_on_peer_changed) + nm.player_disconnected.connect(_on_peer_changed) + if not nm.stats_updated.is_connected(_update_lobby_players): + nm.stats_updated.connect(_update_lobby_players) + _update_lobby_players() + _refresh_lobby() + + +func _on_peer_changed(_id: int) -> void: + _update_lobby_players() + + +func _update_lobby_players() -> void: + if _lobby_players == null: + return + _lobby_players.clear() + var nm := _net() + if nm == null: + return + for pid in nm.player_stats.keys(): + var s: Dictionary = nm.player_stats[pid] + var line := "%s%s" % [s.get("username", "Player"), + " (host)" if pid == 1 else ""] + if int(s.get("ping", 0)) > 0: + line += " %dms" % int(s.get("ping", 0)) + var idx := _lobby_players.add_item(line) + var team := int(s.get("team", 0)) + if team > 0: + _lobby_players.set_item_custom_fg_color(idx, GameMode.team_color(team)) + + +func _on_loadouts() -> void: + var pm := get_node_or_null("/root/PauseMenu") + if pm: + pm.visible = true + pm._show_loadouts() + + +func _on_settings() -> void: + var pm := get_node_or_null("/root/PauseMenu") + if pm: + pm.visible = true + pm._show_settings() + + +# ── Bits ───────────────────────────────────────────────────────────────────── + + +func _net() -> Node: + return get_node_or_null("/root/NetworkManager") + + +func _default_port() -> int: + var nm := _net() + return int(nm.DEFAULT_PORT) if nm else 31415 + func _add_button(parent: Container, text: String, callback: Callable) -> void: - var btn = Button.new() + var btn := Button.new() btn.text = text - btn.add_theme_font_size_override("font_size", 32) - btn.custom_minimum_size = Vector2(300, 60) + btn.add_theme_font_size_override("font_size", 30) + btn.custom_minimum_size = Vector2(300, 58) btn.size_flags_horizontal = Control.SIZE_SHRINK_BEGIN btn.pressed.connect(callback) parent.add_child(btn) -func _build_skin_selector(parent: Container) -> void: - var skin_mgr = get_node_or_null("/root/SkinManager") - if not skin_mgr: + +func _build_skin_row(parent: Container) -> void: + var skin_mgr := get_node_or_null("/root/SkinManager") + if skin_mgr == null: return - var row = HBoxContainer.new() + var row := HBoxContainer.new() row.add_theme_constant_override("separation", 12) parent.add_child(row) + row.add_child(UITheme.caption("Character", 22)) - var lbl = Label.new() - lbl.text = "Skin" - lbl.add_theme_font_size_override("font_size", 24) - row.add_child(lbl) - - var opt = OptionButton.new() - opt.custom_minimum_size = Vector2(240, 44) + var opt := OptionButton.new() + opt.custom_minimum_size = Vector2(250, 46) var ids: Array = skin_mgr.get_skin_ids() ids.sort() - for i in range(ids.size()): - var skin = skin_mgr.get_skin(ids[i]) - opt.add_item(skin.skin_name, i) + for i in ids.size(): + opt.add_item(skin_mgr.get_skin(ids[i]).skin_name, i) opt.set_item_metadata(i, ids[i]) if ids[i] == skin_mgr.active_skin_id: opt.select(i) opt.item_selected.connect(func(idx: int): - skin_mgr.set_active_skin(opt.get_item_metadata(idx)) - ) + skin_mgr.set_active_skin(opt.get_item_metadata(idx))) row.add_child(opt) -func _on_back_pressed() -> void: - _level_selector_panel.hide() - _ui_vbox.show() -func _on_singleplayer_pressed() -> void: - _ui_vbox.hide() - _level_selector_panel.show() +func _play_music() -> void: + # Menu music is scene-owned, so it stops the moment a level loads. + var path := "res://assets/sounds/music_menu.ogg" + if not ResourceLoader.exists(path): + return + var music := AudioStreamPlayer.new() + music.stream = load(path) + if music.stream is AudioStreamOggVorbis: + music.stream.loop = true + music.bus = "Music" + music.volume_db = -8.0 + music.autoplay = true + add_child(music) -func _on_multiplayer_pressed() -> void: - _ui_vbox.hide() - _multiplayer_panel.show() -func _on_multiplayer_back_pressed() -> void: - _multiplayer_panel.hide() - _ui_vbox.show() +# ── Background ─────────────────────────────────────────────────────────────── -func _on_host_pressed() -> void: - var nm = get_node("/root/NetworkManager") - if nm.host_game() == OK: - _on_connection_succeeded() -func _on_direct_connect_pressed() -> void: - _multiplayer_panel.hide() - _direct_connect_panel.show() +## The 3D scene behind the menu: the same stylised sky and toon materials the +## levels use, so the front door looks like the game it opens onto. +func _build_diorama() -> void: + var holder := SubViewportContainer.new() + holder.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + holder.stretch = true + holder.mouse_filter = Control.MOUSE_FILTER_IGNORE + add_child(holder) -func _on_dc_back_pressed() -> void: - _direct_connect_panel.hide() - _multiplayer_panel.show() + var viewport := SubViewport.new() + viewport.own_world_3d = true + viewport.msaa_3d = Viewport.MSAA_4X + holder.add_child(viewport) -func _on_connect_confirm_pressed() -> void: - var ip = _direct_connect_panel.get_node("IPInput").text - var port = _direct_connect_panel.get_node("PortInput").text.to_int() - get_node("/root/NetworkManager").join_game(ip, port) - -func _on_connection_succeeded() -> void: - _direct_connect_panel.hide() - _multiplayer_panel.hide() - _host_lobby_panel.show() - - var nm = get_node("/root/NetworkManager") - var is_host = nm.multiplayer.is_server() - for node in get_tree().get_nodes_in_group("host_only_ui"): - if node is BaseButton: - node.disabled = not is_host - - if not nm.player_connected.is_connected(_on_network_player_connected): - nm.player_connected.connect(_on_network_player_connected) - nm.player_disconnected.connect(_on_network_player_disconnected) - if not nm.stats_updated.is_connected(_update_lobby_players): - nm.stats_updated.connect(_update_lobby_players) - - _update_lobby_players() - -func _on_lobby_back_pressed() -> void: - _host_lobby_panel.hide() - _multiplayer_panel.show() - var nm = get_node("/root/NetworkManager") - nm.disconnect_game() - if nm.player_connected.is_connected(_on_network_player_connected): - nm.player_connected.disconnect(_on_network_player_connected) - nm.player_disconnected.disconnect(_on_network_player_disconnected) - if nm.stats_updated.is_connected(_update_lobby_players): - nm.stats_updated.disconnect(_update_lobby_players) - -func _on_network_player_connected(_id: int) -> void: - _update_lobby_players() - -func _on_network_player_disconnected(_id: int) -> void: - _update_lobby_players() - -func _update_lobby_players() -> void: - if not _host_lobby_players_list: return - _host_lobby_players_list.clear() - var nm = get_node("/root/NetworkManager") - - for pid in nm.player_stats.keys(): - var p_name = nm.player_stats[pid].get("username", "Unknown") - var p_ping = nm.player_stats[pid].get("ping", 0) - - if pid == 1: - _host_lobby_players_list.add_item("%s (Host) - %sms" % [p_name, p_ping]) - else: - _host_lobby_players_list.add_item("%s - %sms" % [p_name, p_ping]) - -func _on_loadouts_pressed() -> void: - if has_node("/root/PauseMenu"): - var pm = get_node("/root/PauseMenu") - pm.visible = true - pm._show_loadouts() - -func _on_settings_pressed() -> void: - if has_node("/root/PauseMenu"): - var pm = get_node("/root/PauseMenu") - pm.visible = true - pm._show_settings() - -func _on_exit_pressed() -> void: - get_tree().quit() - -# ========================================== -# 3D DIORAMA BUILDER -# ========================================== -func _build_3d_diorama(viewport: SubViewport) -> void: - var world = Node3D.new() + var world := Node3D.new() viewport.add_child(world) - - # Lighting and Env — same stylized sunset sky as in-game for continuity - var env = WorldEnvironment.new() + + var env := WorldEnvironment.new() env.environment = LevelEnvironment.make_environment("sunset") world.add_child(env) - - var sun = DirectionalLight3D.new() + + var sun := DirectionalLight3D.new() sun.rotation_degrees = Vector3(-45, 45, 0) sun.light_color = Color(1.0, 0.9, 0.8) sun.light_energy = 1.5 sun.shadow_enabled = true world.add_child(sun) - - var fill = DirectionalLight3D.new() + + var fill := DirectionalLight3D.new() fill.rotation_degrees = Vector3(-30, -135, 0) fill.light_color = Color(0.5, 0.6, 1.0) fill.light_energy = 0.5 world.add_child(fill) - - # Floor — toon world-grid like the levels - var floor_mesh = MeshInstance3D.new() - floor_mesh.mesh = PlaneMesh.new() - floor_mesh.mesh.size = Vector2(20, 20) - floor_mesh.mesh.surface_set_material(0, LevelMaterials.tinted(Color(0.32, 0.3, 0.38))) - world.add_child(floor_mesh) - # Player Model (Sliding Pose) - var player_model = Node3D.new() - player_model.position = Vector3(0, 0.5, 0) # Lowered for slide - world.add_child(player_model) + var ground := MeshInstance3D.new() + var plane := PlaneMesh.new() + plane.size = Vector2(24, 24) + ground.mesh = plane + ground.mesh.surface_set_material(0, LevelMaterials.tinted(Color(0.32, 0.3, 0.38))) + world.add_child(ground) - var body_mat = StandardMaterial3D.new() - body_mat.albedo_color = Color(0.3, 0.6, 0.9) - # Toonified below via apply_toon_recursive once the model is assembled - - # Torso (Capsule tilted back) - var torso = MeshInstance3D.new() - var t_mesh = CapsuleMesh.new() - t_mesh.radius = 0.4 - t_mesh.height = 1.2 - t_mesh.material = body_mat - torso.mesh = t_mesh - torso.rotation_degrees.x = -60 # Tilted back - torso.position = Vector3(0, 0.4, 0) - player_model.add_child(torso) - - # Legs (Sticking forward) - var legs = MeshInstance3D.new() - var l_mesh = BoxMesh.new() - l_mesh.size = Vector3(0.6, 0.4, 1.2) - l_mesh.material = body_mat - legs.mesh = l_mesh - legs.position = Vector3(0, -0.2, 0.6) - player_model.add_child(legs) - - # Arm holding shotgun - var arm = Node3D.new() - arm.position = Vector3(0.5, 0.6, 0.2) - player_model.add_child(arm) - - var arm_mesh = MeshInstance3D.new() - var a_mesh = BoxMesh.new() - a_mesh.size = Vector3(0.2, 0.2, 0.8) - a_mesh.material = body_mat - arm_mesh.mesh = a_mesh - arm_mesh.position = Vector3(0, 0, 0.3) - arm.add_child(arm_mesh) - - # Shotgun - var shotgun_script = load("res://weapons/double_barrel_shotgun.gd") - if shotgun_script: - var shotgun = shotgun_script.new() - arm_mesh.add_child(shotgun) - shotgun.position = Vector3(0, -0.1, 0.4) - shotgun.rotation_degrees = Vector3(0, 0, 0) - shotgun.set_process(false) - shotgun.set_physics_process(false) - if shotgun.has_method("set_process_input"): - shotgun.set_process_input(false) - - # Cel-shade the diorama (no hull outlines — the FBX shotgun tears them) - LevelMaterials.apply_toon_recursive(player_model, 0.0) + # The player's OWN character, in their own skin, holding a weapon — rather + # than the box-and-capsule mannequin that used to stand here. The menu is + # where a player looks at what they picked, so it should show it. + var skin_mgr := get_node_or_null("/root/SkinManager") + var shown := false + if skin_mgr: + var skin = skin_mgr.get_active_skin() + if skin and skin.model_path != "": + var model := SkinnedPlayerModel.new() + model.skin_id = skin_mgr.active_skin_id + model.position = Vector3(0.35, 0, 0) + model.rotation_degrees.y = 205.0 + world.add_child(model) + model.load_model(skin.model_path) + model.set_weapon("res://weapons/ak47.gd") + model.update_state("idle", 0.0, false) + shown = true + if not shown: + # No GLB skin selected: a plain capsule beats an empty stage. + var stand_in := MeshInstance3D.new() + var caps := CapsuleMesh.new() + caps.radius = 0.36 + caps.height = 1.7 + stand_in.mesh = caps + stand_in.position = Vector3(0.35, 0.85, 0) + world.add_child(stand_in) + LevelMaterials.apply_toon_recursive(stand_in, 0.0) - # Camera - var camera = Camera3D.new() + var camera := Camera3D.new() world.add_child(camera) - camera.position = Vector3(3, 1.5, 4) - camera.look_at(Vector3(0, 0.5, 0)) + # Off to the right and low, so the character stands clear of the menu column + # on the left rather than behind it. + camera.position = Vector3(2.4, 1.35, 3.1) + camera.look_at(Vector3(0.35, 1.0, 0)) + camera.fov = 48.0 diff --git a/ui/match_hud.gd b/ui/match_hud.gd index dbb0eed..eaa224f 100644 --- a/ui/match_hud.gd +++ b/ui/match_hud.gd @@ -14,6 +14,35 @@ var _killfeed_vbox: VBoxContainer var _scoreboard_panel: PanelContainer var _scoreboard_grid: GridContainer +var _summary: MatchSummary + + +func _on_match_ended(result: Dictionary) -> void: + _scoreboard_panel.hide() + _summary.show_result(result) + + +func _on_play_again() -> void: + if not _nm: + return + # The host decides for everyone; a client's button is disabled. + if _nm.multiplayer.has_multiplayer_peer() \ + and not _nm.multiplayer.multiplayer_peer is OfflineMultiplayerPeer: + if _nm.multiplayer.is_server(): + _nm.restart_match.rpc() + else: + _nm.restart_match() + _summary.visible = false + Input.set_mouse_mode(Input.MOUSE_MODE_CAPTURED) + + +func _on_to_menu() -> void: + if _nm: + _nm.match_active = false + _nm.disconnect_game() + Input.set_mouse_mode(Input.MOUSE_MODE_VISIBLE) + get_tree().change_scene_to_file("res://ui/main_menu/main_menu.tscn") + func _ready() -> void: _nm = get_node_or_null("/root/NetworkManager") # Ensure the comic theme is active even when a level is loaded directly @@ -98,12 +127,27 @@ func _ready() -> void: _populate_scoreboard_headers() + # The end-of-match screen. Built once and hidden, rather than instantiated on + # the signal, so the moment the match ends nothing has to be loaded. + _summary = MatchSummary.new() + _summary.name = "MatchSummary" + _summary.visible = false + _summary.play_again.connect(_on_play_again) + _summary.to_menu.connect(_on_to_menu) + add_child(_summary) + # Signals if _nm: _nm.stats_updated.connect(_update_hud) _nm.killfeed_event.connect(_on_killfeed_event) _nm.match_state_updated.connect(_update_timer) + _nm.match_ended.connect(_on_match_ended) _update_hud() + # A HUD that loads AFTER the match ended — a late joiner, or a scene + # reload — still has to show the result, so it asks rather than only + # listening. `last_result` is empty during a live match. + if not _nm.last_result.is_empty(): + _on_match_ended(_nm.last_result) func _process(_delta: float) -> void: if Input.is_action_just_pressed("scoreboard"): @@ -125,8 +169,12 @@ func _update_timer() -> void: func _populate_scoreboard_headers() -> void: for child in _scoreboard_grid.get_children(): child.queue_free() - - var headers = ["Player", "Kills", "Deaths", "Assists", "Ping"] + + # SCORE is first and is the mode's own noun, because it is what decides the + # match. Kills and score are the same number in Deathmatch and different in + # Gun Game, which is exactly why both are shown. + var noun := GameMode.score_noun(_nm.current_gamemode) if _nm else "SCORE" + var headers = ["Player", noun, "Kills", "Deaths", "Assists", "Streak", "Ping"] for h in headers: var l = Label.new() l.text = h @@ -138,59 +186,59 @@ func _update_hud() -> void: if not _nm: return var my_id = multiplayer.get_unique_id() - - var leader_name = "None" - var max_kills = -1 - + var mode: String = _nm.current_gamemode + var noun := GameMode.score_noun(mode) var stats = _nm.player_stats - - # Sort players by kills for scoreboard - var players = stats.keys() - players.sort_custom(func(a, b): return stats[a].get("kills", 0) > stats[b].get("kills", 0)) - + + # Ranked by SCORE, which is what the mode counts — not by kills, which in + # Gun Game is the same number by coincidence and in a mode with objectives + # would not be. + var players := GameMode.standings(stats) _populate_scoreboard_headers() - + for pid in players: var p_data = stats[pid] - var p_kills = p_data.get("kills", 0) - - # Find leader - if p_kills > max_kills: - max_kills = p_kills - leader_name = p_data.get("username", "Player " + str(pid)) - - # Update local kills + var p_score = p_data.get("score", p_data.get("kills", 0)) + if pid == my_id: - _local_kills_label.text = str(p_kills) + " Kills" - - # Build Scoreboard Row + _local_kills_label.text = "%s %s" % [p_score, noun] + + var row_color := Color(p_data.get("color", "cccccc")) + if int(p_data.get("team", 0)) > 0: + row_color = GameMode.team_color(int(p_data.get("team", 0))) + var c_name = Label.new() c_name.text = p_data.get("username", "Player") c_name.add_theme_font_size_override("font_size", 24) - c_name.add_theme_color_override("font_color", Color(p_data.get("color", "cccccc"))) + c_name.add_theme_color_override("font_color", row_color) _scoreboard_grid.add_child(c_name) - - var c_k = Label.new() - c_k.text = str(p_kills) - c_k.add_theme_font_size_override("font_size", 24) - _scoreboard_grid.add_child(c_k) - - var c_d = Label.new() - c_d.text = str(p_data.get("deaths", 0)) - c_d.add_theme_font_size_override("font_size", 24) - _scoreboard_grid.add_child(c_d) - - var c_a = Label.new() - c_a.text = str(p_data.get("assists", 0)) - c_a.add_theme_font_size_override("font_size", 24) - _scoreboard_grid.add_child(c_a) - - var c_p = Label.new() - c_p.text = str(p_data.get("ping", 0)) + "ms" - c_p.add_theme_font_size_override("font_size", 24) - _scoreboard_grid.add_child(c_p) - - _leader_label.text = "Leader: %s (%s Kills)" % [leader_name, max_kills] + + for value in [str(p_score), str(p_data.get("kills", 0)), + str(p_data.get("deaths", 0)), str(p_data.get("assists", 0)), + "x%d" % int(p_data.get("best_streak", 0)), + "%dms" % int(p_data.get("ping", 0))]: + var cell = Label.new() + cell.text = value + cell.add_theme_font_size_override("font_size", 24) + _scoreboard_grid.add_child(cell) + + # The top line: who is leading, and how far there is to go. A score limit + # nobody can see is a win condition players cannot play toward. + var limit := GameMode.score_limit(mode) + if GameMode.is_team_mode(mode): + var totals := GameMode.team_scores(mode, stats) + var parts: PackedStringArray = [] + for t in totals: + parts.append("%s %d" % [GameMode.team_name(t), totals[t]]) + _leader_label.text = " / ".join(parts) + (" — to %d" % limit if limit > 0 else "") + elif players.is_empty(): + _leader_label.text = "Leader: None" + else: + var top = players[0] + _leader_label.text = "Leader: %s (%s%s)" % [ + stats[top].get("username", "Player " + str(top)), + stats[top].get("score", 0), + "/%d" % limit if limit > 0 else ""] func _on_killfeed_event(victim: String, killer: String, weapon: String, v_color: String = "cccccc", k_color: String = "cccccc") -> void: var l = RichTextLabel.new() diff --git a/ui/match_summary.gd b/ui/match_summary.gd new file mode 100644 index 0000000..b662aff --- /dev/null +++ b/ui/match_summary.gd @@ -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 diff --git a/ui/match_summary.gd.uid b/ui/match_summary.gd.uid new file mode 100644 index 0000000..8df256f --- /dev/null +++ b/ui/match_summary.gd.uid @@ -0,0 +1 @@ +uid://ygajwj3bek8k diff --git a/ui/player_hud.gd b/ui/player_hud.gd new file mode 100644 index 0000000..a9d926d --- /dev/null +++ b/ui/player_hud.gd @@ -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 diff --git a/ui/player_hud.gd.uid b/ui/player_hud.gd.uid new file mode 100644 index 0000000..94e4ade --- /dev/null +++ b/ui/player_hud.gd.uid @@ -0,0 +1 @@ +uid://hjsfcdnutb4t diff --git a/ui/reload_ring.gd b/ui/reload_ring.gd index e6d28fb..0e1f9db 100644 --- a/ui/reload_ring.gd +++ b/ui/reload_ring.gd @@ -1,22 +1,44 @@ extends Control class_name ReloadRing +## The reload arc, drawn around the reticle. +## +## Like Crosshair it owns the full rect and centres itself in `size` rather than +## sitting at a zero-sized PRESET_CENTER: a control that draws before the +## viewport has sized it lands in the top-left corner, and only a later redraw +## moves it — which, for something that redraws on a value change, can be never. + var progress: float = 0.0: set(val): - progress = clampf(val, 0.0, 1.0) + var v := clampf(val, 0.0, 1.0) + if is_equal_approx(v, progress): + return + progress = v queue_redraw() var radius: float = 16.0 var thickness: float = 4.0 var color: Color = Color(1.0, 1.0, 1.0, 0.8) + +func _ready() -> void: + mouse_filter = Control.MOUSE_FILTER_IGNORE + # Offsets too — see the note in ui/crosshair.gd. + set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + resized.connect(queue_redraw) + + func _draw() -> void: if progress <= 0.0 or progress >= 1.0: return - - # Draw background arc (darker) - draw_arc(Vector2.ZERO, radius, 0, PI * 2.0, 32, Color(0, 0, 0, 0.4), thickness, true) - - # Draw progress arc - var end_angle = -PI / 2.0 + (PI * 2.0 * progress) - draw_arc(Vector2.ZERO, radius, -PI / 2.0, end_angle, 32, color, thickness, true) + var mid := size * 0.5 + + # Ink track first, wider than the arc, so the ring reads over a bright + # skybox the same way every other element in this UI does. The old version + # used 40% black, which vanished against pale concrete. + draw_arc(mid, radius, 0, TAU, 48, UITheme.INK, thickness + 4.0, true) + draw_arc(mid, radius, 0, TAU, 48, UITheme.INK_SOFT, thickness, true) + + # The charged part, sweeping from twelve o'clock. + draw_arc(mid, radius, -PI / 2.0, -PI / 2.0 + TAU * progress, 48, color, + thickness, true) diff --git a/ui/ui_theme.gd b/ui/ui_theme.gd index 92726bc..792da27 100644 --- a/ui/ui_theme.gd +++ b/ui/ui_theme.gd @@ -6,7 +6,7 @@ class_name UITheme ## ## The look is a charged cel comic — near-black violet ink, hot papaya, and a ## lightning yellow that only ever appears at the moment something is pressed. -## Three rules hold it together: +## Four rules hold it together: ## ## INK EVERYTHING every panel, chip and letter carries a heavy dark edge. ## It is what makes flat colour read as drawn rather than @@ -18,11 +18,28 @@ class_name UITheme ## VOLT MEANS NOW yellow is reserved for the pressed state and the bolt. ## Spend it anywhere else and the moment of input stops ## standing out. +## EVERY STATE READS a fill and the text on it are chosen together, and the +## pair is checked. See `STATE_TABLE` and `contrast`. ## ## Everything here is static. Screens ask for `apply_global` once and then use ## the helpers — `heading`, `card`, `divider`, `chip_button` — instead of ## hand-rolling styleboxes, which is how the pause menu drifted 900 lines away ## from the rest of the game's look. +## +## ── Why the state table exists ──────────────────────────────────────────────── +## +## The palette has two light accents (papaya, volt) and one very dark one (ink), +## and a control changes its FILL on hover and press. Text that reads perfectly +## at rest — paper on near-black — inverts to paper-on-yellow the instant the +## pointer arrives, which is a contrast ratio of 1.1:1. It is invisible. +## +## This is the single most common way a stylised UI becomes unreadable, and it is +## also the complaint HoYoverse's own audience has levelled at Zenless Zone +## Zero's menus. The fix is not vigilance, it is arithmetic: `ink_for` picks the +## legible text colour for any fill, every state declares its pair in +## `STATE_TABLE`, and `debug/ui_contrast_check.gd` fails the build if one of them +## drops below the WCAG floor. A state cannot ship unreadable without the check +## going red. const FONT_PATH := "res://assets/ui/fonts/Bangers-Regular.ttf" @@ -42,6 +59,14 @@ const MAGENTA := Color(1.00, 0.18, 0.52) ## Older screens name the accent `TEAL`; it is the electric cyan now. const TEAL := CYAN +## Disabled: the fill goes flat and the label desaturates, but it does NOT go so +## dim that it stops being a word. A disabled control still has to say what it +## would do — "Start Match" greyed out is information, an illegible smudge is +## not. This pair sits above 5:1, where the old 0.42-grey sat at 3.2:1. +const DEAD_FILL := Color(0.10, 0.10, 0.14, 0.92) +const DEAD_EDGE := Color(0.30, 0.29, 0.36) +const DEAD_TEXT := Color(0.55, 0.54, 0.62) + ## How far a chip leans. Applied as opposite corners round and the other two ## nearly square, which is as close to a skew as a StyleBoxFlat can get. const LEAN := 18 @@ -49,6 +74,85 @@ const LEAN := 18 static var _theme: Theme = null +# ── Contrast ───────────────────────────────────────────────────────────────── + + +## Relative luminance, per WCAG 2.1. Alpha is ignored: a chip's fill is drawn +## over the panel behind it, and assuming the fill is opaque is the pessimistic +## reading, which is the one worth checking. +static func luminance(c: Color) -> float: + var ch := [c.r, c.g, c.b] + for i in 3: + var v: float = ch[i] + ch[i] = v / 12.92 if v <= 0.04045 else pow((v + 0.055) / 1.055, 2.4) + return 0.2126 * ch[0] + 0.7152 * ch[1] + 0.0722 * ch[2] + + +## WCAG contrast ratio between two colours, 1.0 (identical) .. 21.0 (black on +## white). 4.5 is the floor for body text, 3.0 for text above ~24 px. +static func contrast(a: Color, b: Color) -> float: + var la := luminance(a) + var lb := luminance(b) + return (maxf(la, lb) + 0.05) / (minf(la, lb) + 0.05) + + +## The legible text colour for a given fill: ink on a light chip, paper on a dark +## one. This is what makes a hover safe — the fill is free to become papaya or +## volt because the label follows it. +static func ink_for(fill: Color) -> Color: + return INK if contrast(INK, fill) >= contrast(PAPER, fill) else PAPER + + +## Every (fill, text) pair the theme puts on screen, as +## `["/", fill, text, is_large_text]`. +## +## Written out rather than derived so the check has something to compare the +## built theme AGAINST — a table generated from the theme would agree with it by +## construction and catch nothing. Large text (headings, the wordmark, the HUD's +## big numerals) is allowed the 3:1 floor WCAG gives it. +static func state_table() -> Array: + var out: Array = [] + for cls in BUTTON_CLASSES: + out.append_array([ + [cls + "/normal", INK_SOFT, PAPER, false], + [cls + "/hover", PAPAYA, ink_for(PAPAYA), false], + [cls + "/pressed", VOLT, ink_for(VOLT), false], + [cls + "/focus", INK_SOFT, PAPER, false], + [cls + "/disabled", DEAD_FILL, DEAD_TEXT, false], + ]) + out.append_array([ + ["Label/on_panel", PANEL, PAPER, false], + ["Label/dim_on_panel", PANEL, PAPER_DIM, false], + ["Label/title", PANEL, PAPAYA, true], + ["LineEdit/normal", INK_SOFT, PAPER, false], + ["LineEdit/placeholder", INK_SOFT, PAPER_DIM, false], + ["ItemList/normal", PANEL_DEEP, PAPER, false], + ["ItemList/hovered", INK_SOFT, PAPER, false], + ["ItemList/selected", PAPAYA, ink_for(PAPAYA), false], + ["ItemList/selected_focus", PAPAYA_HOT, ink_for(PAPAYA_HOT), false], + ["PopupMenu/normal", PANEL_DEEP, PAPER, false], + ["PopupMenu/hover", PAPAYA, ink_for(PAPAYA), false], + ["PopupMenu/disabled", PANEL_DEEP, DEAD_TEXT, false], + ["TabContainer/selected", PAPAYA, ink_for(PAPAYA), false], + ["TabContainer/unselected", INK_SOFT, PAPER_DIM, false], + ["TabContainer/hovered", PAPAYA_HOT, ink_for(PAPAYA_HOT), false], + ["Tree/normal", PANEL_DEEP, PAPER, false], + ["Tree/selected", PAPAYA, ink_for(PAPAYA), false], + ["Card/label", INK, PAPER, true], + ["Card/hover_label", INK, VOLT, true], + ]) + return out + + +## Which theme types get the chip button treatment. Godot theme types do not +## inherit styling from Button, so each one has to be told the same thing. +const BUTTON_CLASSES := ["Button", "OptionButton", "MenuButton", "CheckBox", + "CheckButton", "LinkButton"] + + +# ── Styleboxes ─────────────────────────────────────────────────────────────── + + ## A leaning comic chip: flat fill, heavy ink edge, hard offset shadow. ## ## Kept at its original name and argument order — main_menu and match_hud both @@ -93,6 +197,24 @@ static func panel(bg: Color = PANEL, border: Color = INK, return sb +## A tight row chip — a list entry, a popup line, a tab — with the lean dropped. +## +## The lean is energy on a button the eye lands on. Repeated down twenty rows of +## a list it reads as a stack of broken rectangles, so rows get square corners +## and keep the fill and the edge. +static func row(bg: Color, border: Color = INK, border_w: int = 2) -> StyleBoxFlat: + var sb := StyleBoxFlat.new() + sb.bg_color = bg + sb.border_color = border + sb.set_border_width_all(border_w) + sb.set_corner_radius_all(4) + sb.content_margin_left = 12 + sb.content_margin_right = 12 + sb.content_margin_top = 5 + sb.content_margin_bottom = 5 + return sb + + static func build() -> Theme: if _theme: return _theme @@ -105,12 +227,10 @@ static func build() -> Theme: # ── Buttons ────────────────────────────────────────────────────────── # Dark chip with a papaya edge at rest; the chip FILLS papaya on hover and # flashes volt on press. The press state drops its shadow, so the chip - # visibly slams down into the page rather than just changing colour. - _button_look(t, "Button") - for cls in ["OptionButton", "MenuButton", "CheckBox", "CheckButton", - "LinkButton"]: - # Godot theme types do not inherit styling from Button, so each one has - # to be told the same thing. + # visibly slams down into the page rather than just changing colour. Every + # state's text colour comes from `ink_for` its own fill, so the label + # inverts WITH the chip instead of vanishing into it. + for cls in BUTTON_CLASSES: _button_look(t, cls) # ── Labels: ink outline everywhere, for readability straight over 3D ── @@ -125,45 +245,110 @@ static func build() -> Theme: # ── Inputs ─────────────────────────────────────────────────────────── t.set_stylebox("normal", "LineEdit", box(INK_SOFT, PAPAYA, 4, 3, false)) t.set_stylebox("focus", "LineEdit", box(INK_SOFT, CYAN, 4, 3, false)) + t.set_stylebox("read_only", "LineEdit", box(DEAD_FILL, DEAD_EDGE, 4, 3, false)) t.set_color("font_color", "LineEdit", PAPER) + t.set_color("font_uneditable_color", "LineEdit", DEAD_TEXT) t.set_color("caret_color", "LineEdit", VOLT) t.set_color("font_placeholder_color", "LineEdit", PAPER_DIM) + # Selected text: volt fill wants ink glyphs, same rule as a pressed chip. + t.set_color("font_selected_color", "LineEdit", INK) + t.set_color("selection_color", "LineEdit", VOLT) + + t.set_stylebox("normal", "SpinBox", box(INK_SOFT, PAPAYA, 4, 3, false)) + t.set_color("font_color", "SpinBox", PAPER) # ── Sliders: the filled part is the charged part ───────────────────── t.set_stylebox("slider", "HSlider", box(INK_SOFT, INK, 2, 2, false)) t.set_stylebox("grabber_area", "HSlider", box(PAPAYA, INK, 2, 2, false)) t.set_stylebox("grabber_area_highlight", "HSlider", box(VOLT, INK, 2, 2, false)) + t.set_stylebox("slider", "VSlider", box(INK_SOFT, INK, 2, 2, false)) + t.set_stylebox("grabber_area", "VSlider", box(PAPAYA, INK, 2, 2, false)) + t.set_stylebox("grabber_area_highlight", "VSlider", box(VOLT, INK, 2, 2, false)) + + # ── Progress bars ──────────────────────────────────────────────────── + t.set_stylebox("background", "ProgressBar", row(INK, INK_SOFT, 2)) + t.set_stylebox("fill", "ProgressBar", row(PAPAYA, INK, 0)) + t.set_color("font_color", "ProgressBar", PAPER) + t.set_color("font_outline_color", "ProgressBar", INK) + t.set_constant("outline_size", "ProgressBar", 6) # ── Panels / lists ─────────────────────────────────────────────────── t.set_stylebox("panel", "PanelContainer", panel()) t.set_stylebox("panel", "Panel", panel()) t.set_stylebox("panel", "ItemList", panel(PANEL_DEEP, INK, 3)) t.set_color("font_color", "ItemList", PAPER) - t.set_color("font_selected_color", "ItemList", INK) - t.set_stylebox("selected", "ItemList", box(PAPAYA, INK, 4, 2, false)) - t.set_stylebox("selected_focus", "ItemList", box(PAPAYA_HOT, CYAN, 4, 2, false)) - t.set_stylebox("hovered", "ItemList", box(INK_SOFT, PAPAYA, 4, 2, false)) + t.set_color("font_selected_color", "ItemList", ink_for(PAPAYA)) + t.set_color("font_hovered_color", "ItemList", PAPER) + t.set_color("font_outline_color", "ItemList", INK) + t.set_constant("outline_size", "ItemList", 5) + t.set_stylebox("selected", "ItemList", row(PAPAYA)) + t.set_stylebox("selected_focus", "ItemList", row(PAPAYA_HOT, CYAN)) + # Hover keeps PAPER text, so the fill must stay DARK. A papaya hover under a + # papaya selection also made the two states indistinguishable — the row you + # were pointing at looked exactly like the row you had chosen. + t.set_stylebox("hovered", "ItemList", row(INK_SOFT, PAPAYA)) + t.set_stylebox("hovered_selected", "ItemList", row(PAPAYA_HOT, CYAN)) + t.set_stylebox("cursor", "ItemList", row(Color(0, 0, 0, 0), CYAN)) + t.set_stylebox("cursor_unfocused", "ItemList", row(Color(0, 0, 0, 0), PAPER_DIM)) + + # ── Trees (settings, loadout lists) ────────────────────────────────── + t.set_stylebox("panel", "Tree", panel(PANEL_DEEP, INK, 3)) + t.set_color("font_color", "Tree", PAPER) + t.set_color("font_selected_color", "Tree", ink_for(PAPAYA)) + t.set_color("font_outline_color", "Tree", INK) + t.set_constant("outline_size", "Tree", 5) + t.set_stylebox("selected", "Tree", row(PAPAYA)) + t.set_stylebox("selected_focus", "Tree", row(PAPAYA_HOT, CYAN)) + t.set_stylebox("hovered", "Tree", row(INK_SOFT, PAPAYA)) + t.set_stylebox("hovered_selected", "Tree", row(PAPAYA_HOT, CYAN)) + + # ── Scrollbars: visible at rest, charged under the thumb ───────────── + for cls in ["HScrollBar", "VScrollBar"]: + t.set_stylebox("scroll", cls, row(INK, INK_SOFT, 1)) + t.set_stylebox("grabber", cls, row(PAPER_DIM, INK, 1)) + t.set_stylebox("grabber_highlight", cls, row(PAPAYA, INK, 1)) + t.set_stylebox("grabber_pressed", cls, row(VOLT, INK, 1)) # ── Tabs ───────────────────────────────────────────────────────────── t.set_stylebox("panel", "TabContainer", panel()) t.set_stylebox("tab_selected", "TabContainer", box(PAPAYA, INK, 6, 3, false)) t.set_stylebox("tab_unselected", "TabContainer", box(INK_SOFT, INK, 6, 3, false)) t.set_stylebox("tab_hovered", "TabContainer", box(PAPAYA_HOT, INK, 6, 3, false)) - t.set_color("font_selected_color", "TabContainer", INK) + t.set_stylebox("tab_disabled", "TabContainer", box(DEAD_FILL, DEAD_EDGE, 6, 3, false)) + t.set_color("font_selected_color", "TabContainer", ink_for(PAPAYA)) t.set_color("font_unselected_color", "TabContainer", PAPER_DIM) - t.set_color("font_hovered_color", "TabContainer", INK) + t.set_color("font_hovered_color", "TabContainer", ink_for(PAPAYA_HOT)) + t.set_color("font_disabled_color", "TabContainer", DEAD_TEXT) + t.set_color("font_outline_color", "TabContainer", INK) + t.set_constant("outline_size", "TabContainer", 5) # ── Popups (OptionButton dropdowns) ────────────────────────────────── + # + # The dropdown is the one surface where a missing hover colour is fatal: a + # PopupMenu draws its hover fill but keeps `font_color` unless + # `font_hover_color` is set, so a papaya row under paper text was the least + # readable thing in the game and it appeared on every settings menu. t.set_stylebox("panel", "PopupMenu", panel(PANEL_DEEP, PAPAYA, 3)) - t.set_stylebox("hover", "PopupMenu", box(PAPAYA, INK, 4, 0, false)) + t.set_stylebox("hover", "PopupMenu", row(PAPAYA, INK, 0)) t.set_color("font_color", "PopupMenu", PAPER) - t.set_color("font_hover_color", "PopupMenu", INK) + t.set_color("font_hover_color", "PopupMenu", ink_for(PAPAYA)) + t.set_color("font_disabled_color", "PopupMenu", DEAD_TEXT) + t.set_color("font_accelerator_color", "PopupMenu", PAPER_DIM) + t.set_color("font_separator_color", "PopupMenu", PAPAYA) + t.set_color("font_outline_color", "PopupMenu", INK) + t.set_constant("outline_size", "PopupMenu", 5) # ── Dialogs ────────────────────────────────────────────────────────── t.set_stylebox("panel", "AcceptDialog", panel(PANEL_DEEP, PAPAYA, 4)) t.set_stylebox("embedded_border", "Window", panel(PANEL_DEEP, PAPAYA, 4)) t.set_color("title_color", "Window", VOLT) + # ── Tooltips ───────────────────────────────────────────────────────── + t.set_stylebox("panel", "TooltipPanel", panel(PANEL_DEEP, VOLT, 2)) + t.set_color("font_color", "TooltipLabel", PAPER) + t.set_color("font_outline_color", "TooltipLabel", INK) + t.set_constant("outline_size", "TooltipLabel", 5) + _theme = t return t @@ -172,15 +357,40 @@ static func _button_look(t: Theme, cls: String) -> void: t.set_stylebox("normal", cls, box(INK_SOFT, PAPAYA)) t.set_stylebox("hover", cls, box(PAPAYA, INK)) t.set_stylebox("pressed", cls, box(VOLT, INK, 10, 3, false)) + # Hover-while-pressed is its own stylebox on a toggle button. Without it a + # CheckButton the pointer is over reverts to the hover fill, so a toggle + # reads as OFF for as long as you are touching it. + t.set_stylebox("hover_pressed", cls, box(VOLT, CYAN, 10, 3, false)) t.set_stylebox("focus", cls, box(INK_SOFT, CYAN)) - t.set_stylebox("disabled", cls, box(Color(0.10, 0.10, 0.14, 0.85), Color(0.28, 0.27, 0.33))) + t.set_stylebox("disabled", cls, box(DEAD_FILL, DEAD_EDGE)) t.set_color("font_color", cls, PAPER) - t.set_color("font_hover_color", cls, INK) - t.set_color("font_pressed_color", cls, INK) + t.set_color("font_hover_color", cls, ink_for(PAPAYA)) + t.set_color("font_pressed_color", cls, ink_for(VOLT)) + t.set_color("font_hover_pressed_color", cls, ink_for(VOLT)) t.set_color("font_focus_color", cls, PAPER) - t.set_color("font_disabled_color", cls, Color(0.42, 0.41, 0.48)) + t.set_color("font_disabled_color", cls, DEAD_TEXT) + # NO OUTLINE on a button label. + # + # An outline exists to separate a glyph from a backdrop the glyph cannot beat + # on its own — over the 3D scene, or across a two-tone progress bar. A button + # label has neither problem: it sits on a solid chip, and `ink_for` has + # already given it a colour that beats that chip. + # + # Worse, the theme can only carry ONE outline colour per class, and half the + # states here use an ink glyph. Ink glyphs inside a 5 px ink outline are not + # outlined, they are five pixels fatter — the selected mode chip rendered as + # an unreadable dark blob on volt. The chips keep their heavy ink BORDER, so + # the drawn look is unaffected. t.set_color("font_outline_color", cls, INK) - t.set_constant("outline_size", cls, 5) + t.set_constant("outline_size", cls, 0) + # The icon has to invert with the label. A paper glyph on a volt chip is the + # same 1.1:1 the text would have been, and a CheckBox is ALL icon. + t.set_color("icon_normal_color", cls, PAPER) + t.set_color("icon_hover_color", cls, ink_for(PAPAYA)) + t.set_color("icon_pressed_color", cls, ink_for(VOLT)) + t.set_color("icon_hover_pressed_color", cls, ink_for(VOLT)) + t.set_color("icon_focus_color", cls, PAPER) + t.set_color("icon_disabled_color", cls, DEAD_TEXT) ## Apply the theme to the whole root window. Idempotent, and cheap enough that @@ -223,6 +433,17 @@ static func heading(text: String, size: int = 32) -> Label: return l +## A quieter line of body copy — a hint, a port number, a mode's description. +static func caption(text: String, size: int = 20) -> Label: + var l := Label.new() + l.text = text + l.add_theme_font_size_override("font_size", size) + l.add_theme_color_override("font_color", PAPER_DIM) + l.add_theme_color_override("font_outline_color", INK) + l.add_theme_constant_override("outline_size", 5) + return l + + ## A bolt-struck rule, for separating sections. static func divider(strike_at: float = 0.34) -> BoltRule: var b := BoltRule.new() @@ -240,6 +461,28 @@ static func card() -> PanelContainer: return p +## The one loud button on a screen — the thing the player came here to press. +## +## Papaya-filled at rest instead of ink-filled, so it is the first thing the eye +## lands on, and correspondingly ink-lettered. Hover goes hotter and press still +## flashes volt, so the state ladder is unchanged; only the resting colour moves. +static func primary_button(text: String, size: int = 44) -> Button: + var b := Button.new() + b.text = text + b.add_theme_font_size_override("font_size", size) + b.add_theme_stylebox_override("normal", box(PAPAYA, INK, 12, 4)) + b.add_theme_stylebox_override("hover", box(PAPAYA_HOT, VOLT, 12, 4)) + b.add_theme_stylebox_override("pressed", box(VOLT, INK, 12, 4, false)) + b.add_theme_stylebox_override("focus", box(PAPAYA, CYAN, 12, 4)) + b.add_theme_color_override("font_color", ink_for(PAPAYA)) + b.add_theme_color_override("font_hover_color", ink_for(PAPAYA_HOT)) + b.add_theme_color_override("font_pressed_color", ink_for(VOLT)) + b.add_theme_color_override("font_focus_color", ink_for(PAPAYA)) + b.add_theme_color_override("font_outline_color", Color(INK.r, INK.g, INK.b, 0.55)) + b.add_theme_constant_override("outline_size", 4) + return b + + ## Sounds and the hover kick for every button under `root` (recursive). ## ## The kick is a 4% scale-up on hover and a snap back on exit. It is small on diff --git a/ui/vital_bar.gd b/ui/vital_bar.gd new file mode 100644 index 0000000..02a6f77 --- /dev/null +++ b/ui/vital_bar.gd @@ -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) diff --git a/ui/vital_bar.gd.uid b/ui/vital_bar.gd.uid new file mode 100644 index 0000000..a084dba --- /dev/null +++ b/ui/vital_bar.gd.uid @@ -0,0 +1 @@ +uid://1syuhwlflh2p diff --git a/weapons/double_barrel_shotgun.gd b/weapons/double_barrel_shotgun.gd index c031a92..4e9526e 100644 --- a/weapons/double_barrel_shotgun.gd +++ b/weapons/double_barrel_shotgun.gd @@ -3,7 +3,17 @@ class_name DoubleBarrelShotgun @export var reload_time: float = 1.0 -var shells: int = 2 +## The name and the capacity every other weapon in the set declares. +## +## This one did not, and it had "2" written inline in four places. The HUD had no +## way to ask how big a full load was, so the level's ammo panel carried a +## special case — `elif active_weapon is DoubleBarrelShotgun` — to fill in the +## name, and the capacity was simply hardcoded there as well. Anything that ever +## wanted to display this weapon had to know about it specifically. +@export var weapon_name: String = "Double Barrel" +@export var max_shells: int = 2 + +var shells: int = max_shells var reloading: bool = false var reload_timer: float = 0.0 var _vm_kick: float = 0.0 @@ -59,7 +69,7 @@ func _process(delta: float) -> void: if reloading: reload_timer -= delta if reload_timer <= 0.0: - shells = 2 + shells = max_shells reloading = false # Big single-shot shove that springs back. @@ -77,7 +87,7 @@ func _input(event: InputEvent) -> void: _try_fire() if event.is_action_pressed("reload"): - if shells < 2 and not reloading: + if shells < max_shells and not reloading: _start_reload() func _start_reload() -> void: diff --git a/weapons/weapon_hold_profiles.gd b/weapons/weapon_hold_profiles.gd new file mode 100644 index 0000000..dc8cd24 --- /dev/null +++ b/weapons/weapon_hold_profiles.gd @@ -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.._all -> skins.. +## +## 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/.gd` path. +static func _id_of(weapon: String) -> String: + if weapon.ends_with(".gd"): + return weapon.get_file().get_basename() + return weapon diff --git a/weapons/weapon_hold_profiles.gd.uid b/weapons/weapon_hold_profiles.gd.uid new file mode 100644 index 0000000..6402656 --- /dev/null +++ b/weapons/weapon_hold_profiles.gd.uid @@ -0,0 +1 @@ +uid://bhfyjgkpsvb2x diff --git a/weapons/weapon_manager.gd b/weapons/weapon_manager.gd index 52cc5eb..5012267 100644 --- a/weapons/weapon_manager.gd +++ b/weapons/weapon_manager.gd @@ -16,11 +16,26 @@ func _ready() -> void: set_process(true) _setup_viewmodel_viewport() - + + # Gun Game promotions arrive as a signal from the server. + var nm = get_node_or_null("/root/NetworkManager") + if nm and nm.has_signal("ladder_promoted"): + nm.ladder_promoted.connect(_on_ladder_promoted) + # Wait one frame for LoadoutManager to be fully ready if needed await get_tree().process_frame _build_loadout() + +func _on_ladder_promoted(peer_id: int, _rung: int, weapon_id: String) -> void: + # Only the promoted player's own manager acts; every peer receives the + # signal because the killfeed uses it too. + if player == null or not player.is_multiplayer_authority(): + return + if multiplayer.has_multiplayer_peer() and peer_id != multiplayer.get_unique_id(): + return + equip_ladder_weapon(weapon_id) + func _setup_viewmodel_viewport() -> void: if not camera: return @@ -200,12 +215,49 @@ func _build_loadout() -> void: player.synced_loadout_melee = "" player.synced_loadout_ready = true + # Gun Game hands out the weapon, so the player's own loadout is ignored — + # the whole mode is "you get what your rung gives you". Checked here rather + # than in the mode so there is one place a loadout is built. + if _ladder_mode(): + var nm = get_node_or_null("/root/NetworkManager") + var rung: int = int(nm.player_stats.get(multiplayer.get_unique_id(), {}) + .get("rung", 0)) if nm else 0 + equip_ladder_weapon(GameMode.ladder_weapon(rung)) + return + _spawn_weapon(1, l["primary_1"]) _spawn_weapon(2, l["primary_2"]) _spawn_weapon(3, l["special"]) if l.has("melee"): _spawn_weapon(4, l["melee"]) - + + _equip_slot(1) + + +## Whether the current match issues weapons instead of letting players pick. +func _ladder_mode() -> bool: + var nm = get_node_or_null("/root/NetworkManager") + return nm != null and nm.current_gamemode == GameMode.GUN_GAME + + +## Replace everything in hand with one issued weapon. Gun Game's promotion. +func equip_ladder_weapon(weapon_id: String) -> void: + if weapon_id == "": + return + for w in weapons.values(): + if is_instance_valid(w): + w.queue_free() + weapons.clear() + _spawn_weapon(1, weapon_id) + if player: + # Remote peers build their view of this player from the synced loadout, + # so a promotion has to move that too or everyone else keeps seeing the + # gun from the previous rung in their hands. + player.synced_loadout_p1 = weapon_id + player.synced_loadout_p2 = "" + player.synced_loadout_sp = "" + player.synced_loadout_melee = "" + player.synced_loadout_ready = true _equip_slot(1) func _build_remote_loadout(p1: String, p2: String, sp: String, melee: String) -> void: