Files
Papay-Shooter/ui/bolt_rule.gd
Nicholas ButzkeandClaude Opus 5 08ae85b286 feat(characters): rig anchors, and a lab that edits any tuning group
The weapon lab could tune how a character HOLDS a gun. It could not tune where
the gun sits in the hand, and that is a different question with a different
scope: a hold is per character and weapon, an anchor is a fact about the hand.

RigAnchors adds it. A hand bone's origin is the WRIST, not the palm, and how far
down the palm a grip should sit — and how the gun rolls in the fingers — depends
on how big that character's hand is and how the artist posed the thumb. It
cannot be derived, it differs per character, and it is small. So it is an offset
that defaults to identity, and identity means exactly what the code derived
before anchors existed: an untuned character is bit-for-bit unchanged.

Deliberately NOT a fixed rotation on the mount. There used to be one, and a bone
attachment is expressed in the BONE's axes, which no two rigs agree on — that
constant is why the hand mount points were wrong on every character. The derived
mount stays derived; the anchor is a nudge on top of it, and nothing here spells
a bone name.

The layering, the JSON round trip and the res://-then-user:// write are now
TuningStore, because none of that was ever specific to weapons and two copies of
it would mean two places for "an exported build's tuning pass is silently
discarded" to come back. WeaponHoldTuning is built on it with its file format
unchanged.

The lab is a rig lab now, and it grew three things:

  ANCHORS   a second knob group. It cost a spec table — everything in the lab is
            written against "which specs, which file, keyed on what" rather than
            twice against the two groups, so anchors got sliders, live preview,
            reset, save and clipboard for free. A third group is a third row in
            GROUPS.
  CLIP      audition one animation on its own. The four pose buttons are the
            states the game drives; watching a whole clip end to end is how you
            see where a retarget went wrong, and there was no way to do it.
  SURFACES  what the importer decided each surface IS, and a click to isolate a
            class. Isolating is how the decision gets CHECKED: click `hair` and
            anything else still standing was misclassified. Hidden by swapping
            in a transparent material rather than hiding the node, because a
            mesh is not one class — Miku's body, face and hair are three
            surfaces of one mesh.

And it takes the game's theme, applied to its own panel rather than only to the
Window, for the same reason the pause menu needed it: a CanvasLayer is not a
Control, so theme inheritance stops at one.

debug/rig_anchor_check.gd asserts the physical consequence rather than the
plumbing — an anchor system is easy to build so that the sliders move, the file
saves, the JSON round-trips and the gun does not budge. All six characters move
their weapon by exactly the offset asked for, measured in the attachment's frame
because a world-space delta on an animating skeleton is mostly the idle
animation, and all six return exactly to the derived mount when it is cleared.

Also fixes BoltRule, whose zigzag was self-intersecting: draw_colored_polygon
triangulates, and a crossing outline fails triangulation and draws nothing at
all except a console full of "Invalid polygon data". Same silhouette, walked as
a closed loop, with the ink edge as a polyline rather than a grown polygon —
growing a concave shape from its centroid reintroduces the crossing.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-07-27 14:56:03 -04:00

66 lines
2.7 KiB
GDScript

extends Control
class_name BoltRule
## A lightning bolt struck through a horizontal rule.
##
## The motif the whole UI is built around, in one reusable widget: menus use it
## as a section divider, the HUD uses it to underline a heading. It is drawn
## rather than shipped as an image so it takes the theme's colours and scales to
## any width without a second asset.
##
## The zigzag is deliberately not centred. A bolt centred in its rule reads as an
## ornament; struck a third of the way along, it reads as something that HIT the
## line — which is the difference between decorative and energetic.
@export var line_color: Color = Color(1.0, 0.47, 0.10)
@export var bolt_color: Color = Color(1.0, 0.93, 0.22)
@export var ink: Color = Color(0.045, 0.040, 0.075)
## Where along the rule the bolt strikes, 0..1.
@export var strike_at: float = 0.34
@export var thickness: float = 3.0
func _init() -> void:
custom_minimum_size = Vector2(0, 22)
mouse_filter = Control.MOUSE_FILTER_IGNORE
func _draw() -> void:
var w := size.x
var h := size.y
if w <= 1.0:
return
var mid := h * 0.5
var strike := w * clampf(strike_at, 0.05, 0.95)
var half := h * 0.42
# The rule, broken where the bolt lands so the bolt reads as passing
# THROUGH it rather than sitting on top.
var gap := h * 0.55
draw_line(Vector2(0, mid), Vector2(maxf(0.0, strike - gap), mid),
line_color, thickness)
draw_line(Vector2(minf(w, strike + gap), mid), Vector2(w, mid),
line_color, thickness)
# The bolt, as a SIMPLE polygon — six points, no edge crossing another.
# `draw_colored_polygon` triangulates, and a self-intersecting outline fails
# triangulation outright and draws nothing but a console full of "Invalid
# polygon data". The obvious zigzag (down-right, back-left, down-right)
# crosses itself; this is the same silhouette walked as a closed loop
# instead: down the left edge, out to the tip, back up the right edge.
var pts := PackedVector2Array([
Vector2(strike + half * 0.55, mid - half), # top, right of centre
Vector2(strike - half * 0.45, mid + half * 0.05), # down-left to the notch
Vector2(strike + half * 0.00, mid + half * 0.05), # step right
Vector2(strike - half * 0.35, mid + half), # down-left to the tip
Vector2(strike + half * 0.55, mid - half * 0.15), # back up the right edge
Vector2(strike + half * 0.10, mid - half * 0.15), # step left
])
draw_colored_polygon(pts, bolt_color)
# Ink edge ON TOP as a closed polyline, not as a second, larger polygon —
# growing a concave shape from its centroid can push a point past its
# neighbour and produce exactly the self-intersection above.
var loop := pts.duplicate()
loop.append(pts[0])
draw_polyline(loop, ink, 2.5)