diff --git a/scenes/player.tscn b/scenes/player.tscn index 5a62e4a..68faebe 100644 --- a/scenes/player.tscn +++ b/scenes/player.tscn @@ -20,10 +20,10 @@ shape = SubResource("CapsuleShape1") mesh = SubResource("CapsuleMesh1") [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"] -spring_length = 4.0 +spring_length = 3.5 margin = 0.2 [node name="Camera3D" type="Camera3D" parent="CameraPivot/SpringArm3D"] diff --git a/scripts/avatar/avatar_system.gd b/scripts/avatar/avatar_system.gd index a48ab68..cc6b65c 100644 --- a/scripts/avatar/avatar_system.gd +++ b/scripts/avatar/avatar_system.gd @@ -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 ## loadout or the catalog isn't loaded or assembly failed. 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 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 diff --git a/scripts/game.gd b/scripts/game.gd index afe1de7..b120261 100644 --- a/scripts/game.gd +++ b/scripts/game.gd @@ -91,20 +91,36 @@ func _make_remote(pid: int) -> Node3D: var root := Node3D.new() root.name = "Remote_%d" % pid - 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 = Color.from_hsv(fmod(float(pid) * 0.61803, 1.0), 0.6, 0.9) - body.material_override = mat - root.add_child(body) + # Other players get the assembled avatar too (everyone shares the default + # look for now -- per-player loadout replication is the follow-up), tinted + # by peer id so they're still tellable apart, plus a name tag. + var tint := Color.from_hsv(fmod(float(pid) * 0.61803, 1.0), 0.55, 0.95) + var avatars := get_node_or_null("/root/AvatarSystem") + var visual: Node3D = null + if avatars and avatars.has_method("build_current_avatar"): + visual = avatars.build_current_avatar() + if visual != null: + 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() 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.no_depth_test = true tag.pixel_size = 0.004 @@ -113,6 +129,14 @@ func _make_remote(pid: int) -> Node3D: 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: _networked = false _show_status("Disconnected from the server. Returning to worlds…") diff --git a/scripts/player.gd b/scripts/player.gd index ef7348a..cefcb2e 100644 --- a/scripts/player.gd +++ b/scripts/player.gd @@ -25,7 +25,7 @@ const MOUSE_SENSITIVITY: float = 0.003 # --------------------------------------------------------------------------- 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) ## 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") 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. _camera_pivot.rotation.x = _camera_pitch @@ -81,18 +87,17 @@ func _ready() -> void: _apply_avatar() -## Replace the placeholder capsule with the player's assembled avatar, if the -## AvatarSystem has a loadout primed (set at login by main_menu.gd). Falls back -## to the capsule silently when there's no avatar to show -- e.g. running this -## scene directly in a test, or an offline/failed catalog load. -## 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. +## Show the player's assembled avatar and hide the placeholder capsule. +## AvatarSystem.build_current_avatar() is self-sufficient now (loads the +## catalog + falls back to a default loadout), so this only no-ops if the +## avatar assets themselves are missing. func _apply_avatar() -> void: var avatars := get_node_or_null("/root/AvatarSystem") if avatars == null or not avatars.has_method("build_current_avatar"): return var avatar: Node3D = avatars.build_current_avatar() if avatar == null: + push_warning("player.gd: no avatar to show; keeping the placeholder capsule") return avatar.position = Vector3(0.0, AVATAR_FEET_OFFSET, 0.0) add_child(avatar)