Avatars actually show; camera stops hugging the player
checkpoint / checkpoint (push) Successful in 1m40s
checkpoint / checkpoint (push) Successful in 1m40s
- SpringArm3D was colliding with the player's own capsule (same layer) -> camera yanked up against the head. add_excluded_object(self) in _ready + gentler default pitch, shorter arm, lower pivot. - AvatarSystem.build_current_avatar() is now self-sufficient: loads the catalog itself and falls back to a bare default loadout if login didn't prime one. So the in-world player always shows an avatar, never the capsule, as long as the assets are packed (they are now). - New AvatarSystem.assemble_avatar(): builds a standalone avatar with a throwaway loader, touching no shared state -- so it's safe to call once for the local player AND once per remote peer. (load_avatar()'s shared loader reset()s and freed the previous avatar on every call.) - game.gd: remote players render as the assembled avatar too (shared default look, tinted per-peer, name tag) instead of a plain capsule. Client-side -- rebuild the exe. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015jtxAiu5dAW4WxCNdGkrkJ
This commit is contained in:
+2
-2
@@ -20,10 +20,10 @@ shape = SubResource("CapsuleShape1")
|
|||||||
mesh = SubResource("CapsuleMesh1")
|
mesh = SubResource("CapsuleMesh1")
|
||||||
|
|
||||||
[node name="CameraPivot" type="Node3D" parent="."]
|
[node name="CameraPivot" type="Node3D" parent="."]
|
||||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1.4, 0)
|
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1.2, 0)
|
||||||
|
|
||||||
[node name="SpringArm3D" type="SpringArm3D" parent="CameraPivot"]
|
[node name="SpringArm3D" type="SpringArm3D" parent="CameraPivot"]
|
||||||
spring_length = 4.0
|
spring_length = 3.5
|
||||||
margin = 0.2
|
margin = 0.2
|
||||||
|
|
||||||
[node name="Camera3D" type="Camera3D" parent="CameraPivot/SpringArm3D"]
|
[node name="Camera3D" type="Camera3D" parent="CameraPivot/SpringArm3D"]
|
||||||
|
|||||||
@@ -270,6 +270,53 @@ func set_loadout_from_dict(loadout_dict: Dictionary) -> bool:
|
|||||||
## into a scene (e.g. onto the in-world player). Returns null if there's no
|
## into a scene (e.g. onto the in-world player). Returns null if there's no
|
||||||
## loadout or the catalog isn't loaded or assembly failed.
|
## loadout or the catalog isn't loaded or assembly failed.
|
||||||
func build_current_avatar() -> Node3D:
|
func build_current_avatar() -> Node3D:
|
||||||
if current_loadout == null or not is_catalog_loaded:
|
# Self-sufficient: load the catalog if needed, and fall back to a bare
|
||||||
|
# default loadout when login didn't prime one -- the player should always
|
||||||
|
# get *an* avatar, never a placeholder capsule, as long as the assets exist.
|
||||||
|
if not ensure_catalog():
|
||||||
|
push_warning("AvatarSystem: catalog unavailable; cannot build avatar")
|
||||||
return null
|
return null
|
||||||
return load_avatar(current_loadout)
|
if current_loadout == null:
|
||||||
|
var bases := get_assets_for_slot("base_body")
|
||||||
|
if bases.is_empty():
|
||||||
|
return null
|
||||||
|
set_loadout_from_dict({"base_body": bases[0], "slots": {}})
|
||||||
|
return assemble_avatar(current_loadout)
|
||||||
|
|
||||||
|
|
||||||
|
## Assemble a standalone avatar node from [param loadout] using a throwaway
|
||||||
|
## loader -- unlike load_avatar(), this touches no shared state, so it's safe
|
||||||
|
## to call many times (local player + one per remote peer). Returns null only
|
||||||
|
## if the assets are genuinely missing.
|
||||||
|
func assemble_avatar(loadout) -> Node3D:
|
||||||
|
if loadout == null or not is_catalog_loaded:
|
||||||
|
return null
|
||||||
|
var validation := catalog.validate_loadout(loadout)
|
||||||
|
if not validation.valid:
|
||||||
|
push_warning("AvatarSystem: invalid loadout, skipping: %s" % str(validation.errors))
|
||||||
|
return null
|
||||||
|
|
||||||
|
var l = AvatarLoader.new()
|
||||||
|
l.init_loader(catalog)
|
||||||
|
var base := l.load_base_body(loadout.base_body)
|
||||||
|
if base == null:
|
||||||
|
l.free()
|
||||||
|
return null
|
||||||
|
|
||||||
|
for slot_id in AvatarSlotRegistry.SLOT_IDS:
|
||||||
|
var v = loadout.slots.get(slot_id)
|
||||||
|
if slot_id == "accessory":
|
||||||
|
if v is Array:
|
||||||
|
for aid in v:
|
||||||
|
if aid is String and not (aid as String).is_empty():
|
||||||
|
l.load_attachment(slot_id, aid)
|
||||||
|
elif v is String and not (v as String).is_empty():
|
||||||
|
l.load_attachment(slot_id, v)
|
||||||
|
|
||||||
|
for slot_id in AvatarSlotRegistry.BODY_PART_SLOTS:
|
||||||
|
var v = loadout.slots.get(slot_id)
|
||||||
|
if v is String and not (v as String).is_empty():
|
||||||
|
l.set_base_mesh_visible(slot_id, false)
|
||||||
|
|
||||||
|
l.free()
|
||||||
|
return base
|
||||||
|
|||||||
+35
-11
@@ -91,20 +91,36 @@ func _make_remote(pid: int) -> Node3D:
|
|||||||
var root := Node3D.new()
|
var root := Node3D.new()
|
||||||
root.name = "Remote_%d" % pid
|
root.name = "Remote_%d" % pid
|
||||||
|
|
||||||
var body := MeshInstance3D.new()
|
# Other players get the assembled avatar too (everyone shares the default
|
||||||
var capsule := CapsuleMesh.new()
|
# look for now -- per-player loadout replication is the follow-up), tinted
|
||||||
capsule.radius = 0.35
|
# by peer id so they're still tellable apart, plus a name tag.
|
||||||
capsule.height = 1.7
|
var tint := Color.from_hsv(fmod(float(pid) * 0.61803, 1.0), 0.55, 0.95)
|
||||||
body.mesh = capsule
|
var avatars := get_node_or_null("/root/AvatarSystem")
|
||||||
body.position = Vector3(0, 0.85, 0)
|
var visual: Node3D = null
|
||||||
var mat := StandardMaterial3D.new()
|
if avatars and avatars.has_method("build_current_avatar"):
|
||||||
mat.albedo_color = Color.from_hsv(fmod(float(pid) * 0.61803, 1.0), 0.6, 0.9)
|
visual = avatars.build_current_avatar()
|
||||||
body.material_override = mat
|
if visual != null:
|
||||||
root.add_child(body)
|
visual.position = Vector3(0, -0.85, 0) # feet -> ground (root is at hip height from server)
|
||||||
|
for mi in _all_mesh_instances(visual):
|
||||||
|
var m := StandardMaterial3D.new()
|
||||||
|
m.albedo_color = tint
|
||||||
|
mi.material_override = m
|
||||||
|
root.add_child(visual)
|
||||||
|
else:
|
||||||
|
var body := MeshInstance3D.new()
|
||||||
|
var capsule := CapsuleMesh.new()
|
||||||
|
capsule.radius = 0.35
|
||||||
|
capsule.height = 1.7
|
||||||
|
body.mesh = capsule
|
||||||
|
body.position = Vector3(0, 0.85, 0)
|
||||||
|
var mat := StandardMaterial3D.new()
|
||||||
|
mat.albedo_color = tint
|
||||||
|
body.material_override = mat
|
||||||
|
root.add_child(body)
|
||||||
|
|
||||||
var tag := Label3D.new()
|
var tag := Label3D.new()
|
||||||
tag.text = "Player %d" % pid
|
tag.text = "Player %d" % pid
|
||||||
tag.position = Vector3(0, 2.0, 0)
|
tag.position = Vector3(0, 2.1, 0)
|
||||||
tag.billboard = BaseMaterial3D.BILLBOARD_ENABLED
|
tag.billboard = BaseMaterial3D.BILLBOARD_ENABLED
|
||||||
tag.no_depth_test = true
|
tag.no_depth_test = true
|
||||||
tag.pixel_size = 0.004
|
tag.pixel_size = 0.004
|
||||||
@@ -113,6 +129,14 @@ func _make_remote(pid: int) -> Node3D:
|
|||||||
return root
|
return root
|
||||||
|
|
||||||
|
|
||||||
|
func _all_mesh_instances(node: Node, out: Array = []) -> Array:
|
||||||
|
if node is MeshInstance3D:
|
||||||
|
out.append(node)
|
||||||
|
for c in node.get_children():
|
||||||
|
_all_mesh_instances(c, out)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
func _on_server_lost() -> void:
|
func _on_server_lost() -> void:
|
||||||
_networked = false
|
_networked = false
|
||||||
_show_status("Disconnected from the server. Returning to worlds…")
|
_show_status("Disconnected from the server. Returning to worlds…")
|
||||||
|
|||||||
+12
-7
@@ -25,7 +25,7 @@ const MOUSE_SENSITIVITY: float = 0.003
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
var _input_direction := Vector2.ZERO
|
var _input_direction := Vector2.ZERO
|
||||||
var _camera_pitch: float = -0.25
|
var _camera_pitch: float = -0.12
|
||||||
|
|
||||||
## Vertical offset applied to the assembled avatar so its feet (origin y=0)
|
## Vertical offset applied to the assembled avatar so its feet (origin y=0)
|
||||||
## line up with the bottom of the player's collision capsule.
|
## line up with the bottom of the player's collision capsule.
|
||||||
@@ -70,6 +70,12 @@ func _ready() -> void:
|
|||||||
push_warning("player.gd: no PlayerSpawn marker found; spawning at origin")
|
push_warning("player.gd: no PlayerSpawn marker found; spawning at origin")
|
||||||
global_position = Vector3(0, 1.0, 0)
|
global_position = Vector3(0, 1.0, 0)
|
||||||
|
|
||||||
|
# The spring arm must not collide with the player's own capsule (they're
|
||||||
|
# on the same layer), or the camera gets yanked right up against the
|
||||||
|
# player's head.
|
||||||
|
var arm := $CameraPivot/SpringArm3D as SpringArm3D
|
||||||
|
arm.add_excluded_object(self)
|
||||||
|
|
||||||
# Start the camera arm tilted down a little so the avatar is framed.
|
# Start the camera arm tilted down a little so the avatar is framed.
|
||||||
_camera_pivot.rotation.x = _camera_pitch
|
_camera_pivot.rotation.x = _camera_pitch
|
||||||
|
|
||||||
@@ -81,18 +87,17 @@ func _ready() -> void:
|
|||||||
_apply_avatar()
|
_apply_avatar()
|
||||||
|
|
||||||
|
|
||||||
## Replace the placeholder capsule with the player's assembled avatar, if the
|
## Show the player's assembled avatar and hide the placeholder capsule.
|
||||||
## AvatarSystem has a loadout primed (set at login by main_menu.gd). Falls back
|
## AvatarSystem.build_current_avatar() is self-sufficient now (loads the
|
||||||
## to the capsule silently when there's no avatar to show -- e.g. running this
|
## catalog + falls back to a default loadout), so this only no-ops if the
|
||||||
## scene directly in a test, or an offline/failed catalog load.
|
## avatar assets themselves are missing.
|
||||||
## TODO(PC3): a third-person camera so the local player actually sees this;
|
|
||||||
## for now it's mainly for other players (Phase 2) and looking down.
|
|
||||||
func _apply_avatar() -> void:
|
func _apply_avatar() -> void:
|
||||||
var avatars := get_node_or_null("/root/AvatarSystem")
|
var avatars := get_node_or_null("/root/AvatarSystem")
|
||||||
if avatars == null or not avatars.has_method("build_current_avatar"):
|
if avatars == null or not avatars.has_method("build_current_avatar"):
|
||||||
return
|
return
|
||||||
var avatar: Node3D = avatars.build_current_avatar()
|
var avatar: Node3D = avatars.build_current_avatar()
|
||||||
if avatar == null:
|
if avatar == null:
|
||||||
|
push_warning("player.gd: no avatar to show; keeping the placeholder capsule")
|
||||||
return
|
return
|
||||||
avatar.position = Vector3(0.0, AVATAR_FEET_OFFSET, 0.0)
|
avatar.position = Vector3(0.0, AVATAR_FEET_OFFSET, 0.0)
|
||||||
add_child(avatar)
|
add_child(avatar)
|
||||||
|
|||||||
Reference in New Issue
Block a user