feat(client): add BoneAttachment3D overhead anchor above Head bone (#712)

Infrastructure for floating UI elements (status indicators, thought
bubbles, alert markers). Marker3D at Vector3(0, 0.3, 0) offset from
Head bone, exposed via get_overhead_anchor() public API.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-04 23:34:00 +02:00
co-authored by Claude Opus 4.6
parent ba059ab101
commit e8a053080c
@@ -23,6 +23,7 @@ extends Node3D
## get_clothing_node_count() — number of clothing MeshInstance3D nodes
## get_accessory_node_count() — number of accessory BoneAttachment3D nodes
## get_skin_tone_texture_name(index) — skin tone texture filename key for index
## get_overhead_anchor() — Marker3D above Head bone for floating UI (#712)
##
## D-159 (11 body types), D-160 (18 segments), D-161 (head separate),
## D-162 (clothing pre-fitted), D-163 (heads via BoneAttachment3D), D-164 (skeleton fork)
@@ -101,6 +102,8 @@ var _clothing_meshes: Array[MeshInstance3D] = []
var _bone_attachments: Array[BoneAttachment3D] = []
var _outline_nodes: Array[MeshInstance3D] = []
var _accessory_attachments: Array[BoneAttachment3D] = []
var _overhead_anchor: Marker3D = null
var _overhead_attachment: BoneAttachment3D = null
# Inspectable state for tests
var _active_torso_variant: String = "full"
@@ -218,6 +221,13 @@ func get_skin_tone_texture_name(index: int) -> String:
return SKIN_TONES[clampi(index, 0, SKIN_TONES.size() - 1)]["tex"]
## Return the overhead anchor Marker3D (#712). Null if skeleton not loaded.
## Anchor point for floating UI elements: status indicators, thought bubbles,
## alert markers, speech icons. Positioned ~0.3m above the Head bone.
func get_overhead_anchor() -> Marker3D:
return _overhead_anchor
# =============================================================================
# Internal — teardown
# =============================================================================
@@ -234,6 +244,13 @@ func _clear() -> void:
node.free()
_outline_nodes.clear()
# #712: free overhead anchor before general bone attachments
if is_instance_valid(_overhead_attachment) and _overhead_attachment.get_parent():
_overhead_attachment.get_parent().remove_child(_overhead_attachment)
_overhead_attachment.free()
_overhead_attachment = null
_overhead_anchor = null
for att in _bone_attachments:
if is_instance_valid(att) and att.get_parent():
att.get_parent().remove_child(att)
@@ -291,6 +308,7 @@ func _load_skeleton() -> void:
for m in armature_meshes:
print(" armature mesh: ", m.name, " visible=", m.visible)
_validate_slot_bones()
_create_overhead_anchor()
## Warn on any SLOT_TO_BONE entry that doesn't exist in the loaded skeleton.
@@ -302,6 +320,27 @@ func _validate_slot_bones() -> void:
push_warning("CharacterVisual: SLOT_TO_BONE['%s'] = '%s' — bone not found in skeleton" % [slot, bone_name])
## #712: Create a Marker3D anchored ~0.3m above the Head bone via BoneAttachment3D.
## Anchor point for floating UI elements (status indicators, thought bubbles, etc.).
func _create_overhead_anchor() -> void:
if _skeleton == null:
return
var bone_idx := _skeleton.find_bone("Head")
if bone_idx == -1:
push_warning("CharacterVisual: Head bone not found — overhead anchor not created")
return
_overhead_attachment = BoneAttachment3D.new()
_overhead_attachment.bone_name = "Head"
_overhead_attachment.bone_idx = bone_idx
_overhead_attachment.name = "OverheadAttachment"
_skeleton.add_child(_overhead_attachment)
_overhead_anchor = Marker3D.new()
_overhead_anchor.name = "OverheadAnchor"
_overhead_anchor.position = Vector3(0, 0.3, 0)
_overhead_attachment.add_child(_overhead_anchor)
# =============================================================================
# Internal — body segments (D-160)
# =============================================================================