feat: Sketchfab-to-game character pipeline, FP/TP animated views, client-auth netcode, audio system
Character pipeline (tools/): - sketchfab_import.py: search/download via Sketchfab Download API with license/attribution tracking (SKETCHFAB_API_TOKEN) - autorig.py: headless Blender auto-rig — fits a Mixamo-named skeleton to unrigged humanoids, binds automatic weights - merge_animations.py: merges the shared Mixamo-skeleton animation library onto any rigged character, strips root motion, canonical names - pipeline.py: one command chains download -> rig -> animate -> register In-game skin system: - SkinManager autoload reads skins.json (auto-written by the pipeline) - SkinnedPlayerModel rewritten: canonical clips with fallback chains, blend times, speed-scaled locomotion, weapon bone attachment - First-person: full animated body for the owner, head hidden via SkeletonModifier3D; third-person: full model for other players - Skin selector in main menu; skin id synced in multiplayer - Fixed GLBLoader crash (GLTFDocument.get_animation_count doesn't exist) Multiplayer sync overhaul: - Movement is now client-authoritative: the owning peer simulates locally (no input round-trip), server keeps health/kills/death - Remote players interpolate synced_position/velocity with extrapolation and snap-on-teleport - Knockback/impulses routed to the simulating peer Audio: - AudioManager autoload: SFX/Weapons/Footsteps/UI/Music buses, pooled 3D players, variation + pitch randomization, auto-registration from assets/sounds Docs: 3D_ASSET_PIPELINE.md rewritten end-to-end, new ASSET_SOURCES.md (non-procedural animation/map/sound sources) and SOUND_DESIGN.md. Verified with debug/spawn_smoke_test.gd (headless: 24/24 checks pass). Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
co-authored by
Claude Fable 5
parent
552338112e
commit
d05477c135
@@ -36,9 +36,8 @@ static func load(glb_path: String) -> Node3D:
|
||||
print("GLBLoader: failed to parse GLB, error: %d" % err)
|
||||
return null
|
||||
|
||||
print("GLBLoader: parsed successfully")
|
||||
print(" Animations: %d" % gltf.get_animation_count())
|
||||
print(" Meshes: %d" % gltf.get_mesh_count())
|
||||
print("GLBLoader: parsed successfully (%d animations, %d meshes)" % [
|
||||
state.animations.size(), state.meshes.size()])
|
||||
|
||||
# Generate scene — remove_immutable_tracks=false preserves all bone
|
||||
# animation tracks (default true strips tracks where rest==pose → T-pose).
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
uid://bb4cnikn41oot
|
||||
@@ -0,0 +1 @@
|
||||
uid://cjpuejdwwevdn
|
||||
+81
-43
@@ -1,62 +1,100 @@
|
||||
extends Node
|
||||
class_name SkinManager
|
||||
|
||||
## Manages all available player skins.
|
||||
## Add new skins here or load from a config file.
|
||||
## Autoload: manages all available player skins.
|
||||
##
|
||||
## Skins come from two places:
|
||||
## 1. Built-in color tints for the procedural model (registered below).
|
||||
## 2. assets/characters/skins/skins.json — written automatically by
|
||||
## tools/pipeline.py for every imported character. No code changes needed
|
||||
## to add a new model: run the pipeline, restart the game, it's there.
|
||||
##
|
||||
## The selected skin persists to user://skin_selection.cfg and is synced to
|
||||
## other players via PlayerMovementController.synced_skin_id.
|
||||
|
||||
signal skin_changed(skin_id: String)
|
||||
|
||||
const SKINS_JSON := "res://assets/characters/skins/skins.json"
|
||||
const SELECTION_CFG := "user://skin_selection.cfg"
|
||||
|
||||
var skins: Dictionary = {}
|
||||
var active_skin_id: String = "default"
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
_register_default_skins()
|
||||
_register_builtin_skins()
|
||||
_load_skins_json()
|
||||
_load_selection()
|
||||
print("SkinManager: %d skins available" % skins.size())
|
||||
|
||||
func _register_default_skins() -> void:
|
||||
# Default blue soldier
|
||||
var default_skin = PlayerSkin.new()
|
||||
default_skin.skin_name = "Default"
|
||||
default_skin.description = "Standard issue"
|
||||
default_skin.color_tint = Color(0.2, 0.4, 0.8)
|
||||
default_skin.is_unlocked = true
|
||||
skins["default"] = default_skin
|
||||
|
||||
# Red team
|
||||
var red_skin = PlayerSkin.new()
|
||||
red_skin.skin_name = "Red Team"
|
||||
red_skin.description = "Red team colors"
|
||||
red_skin.color_tint = Color(0.8, 0.2, 0.2)
|
||||
red_skin.is_unlocked = true
|
||||
skins["red_team"] = red_skin
|
||||
func _register_builtin_skins() -> void:
|
||||
_add_color_skin("default", "Default", "Standard issue", Color(0.2, 0.4, 0.8))
|
||||
_add_color_skin("red_team", "Red Team", "Red team colors", Color(0.8, 0.2, 0.2))
|
||||
_add_color_skin("forest", "Forest", "Forest camouflage", Color(0.2, 0.6, 0.2))
|
||||
|
||||
# Green soldier
|
||||
var green_skin = PlayerSkin.new()
|
||||
green_skin.skin_name = "Forest"
|
||||
green_skin.description = "Forest camouflage"
|
||||
green_skin.color_tint = Color(0.2, 0.6, 0.2)
|
||||
green_skin.is_unlocked = true
|
||||
skins["forest"] = green_skin
|
||||
|
||||
# Hatsune Miku
|
||||
var miku_skin = PlayerSkin.new()
|
||||
miku_skin.skin_name = "Miku"
|
||||
miku_skin.description = "Hatsune Miku — Virtual Idol"
|
||||
miku_skin.model_path = "res://assets/characters/skins/miku_proper_anim.glb"
|
||||
miku_skin.color_tint = Color(0.0, 0.75, 0.75)
|
||||
miku_skin.is_unlocked = true
|
||||
skins["miku"] = miku_skin
|
||||
func _add_color_skin(id: String, display_name: String, description: String, tint: Color) -> void:
|
||||
var skin := PlayerSkin.new()
|
||||
skin.skin_name = display_name
|
||||
skin.description = description
|
||||
skin.color_tint = tint
|
||||
skin.is_unlocked = true
|
||||
skins[id] = skin
|
||||
|
||||
print("SkinManager: registered %d skins" % skins.size())
|
||||
for id in skins:
|
||||
print(" - %s: %s" % [id, skins[id].skin_name])
|
||||
|
||||
func _load_skins_json() -> void:
|
||||
if not FileAccess.file_exists(SKINS_JSON):
|
||||
return
|
||||
var file := FileAccess.open(SKINS_JSON, FileAccess.READ)
|
||||
if not file:
|
||||
return
|
||||
var data = JSON.parse_string(file.get_as_text())
|
||||
file.close()
|
||||
if not data is Dictionary or not data.has("skins"):
|
||||
push_warning("SkinManager: skins.json is malformed")
|
||||
return
|
||||
for entry in data["skins"]:
|
||||
if not entry is Dictionary or not entry.has("id"):
|
||||
continue
|
||||
var skin := PlayerSkin.new()
|
||||
skin.skin_name = entry.get("name", entry["id"])
|
||||
skin.description = entry.get("description", "")
|
||||
skin.model_path = entry.get("model", "")
|
||||
skin.is_unlocked = entry.get("unlocked", true)
|
||||
skins[entry["id"]] = skin
|
||||
|
||||
|
||||
# ── Selection ─────────────────────────────────────────────────────────────────
|
||||
|
||||
func get_skin(skin_id: String) -> PlayerSkin:
|
||||
if skins.has(skin_id):
|
||||
return skins[skin_id]
|
||||
return skins["default"]
|
||||
return skins.get(skin_id, skins["default"])
|
||||
|
||||
|
||||
func get_active_skin() -> PlayerSkin:
|
||||
return get_skin(active_skin_id)
|
||||
|
||||
|
||||
func set_active_skin(skin_id: String) -> void:
|
||||
if skins.has(skin_id) and skins[skin_id].is_unlocked:
|
||||
active_skin_id = skin_id
|
||||
print("SkinManager: active skin set to '%s'" % skins[skin_id].skin_name)
|
||||
if not skins.has(skin_id) or not skins[skin_id].is_unlocked:
|
||||
return
|
||||
active_skin_id = skin_id
|
||||
_save_selection()
|
||||
skin_changed.emit(skin_id)
|
||||
|
||||
|
||||
func get_skin_ids() -> Array:
|
||||
return skins.keys()
|
||||
|
||||
|
||||
func _save_selection() -> void:
|
||||
var cfg := ConfigFile.new()
|
||||
cfg.set_value("skin", "active", active_skin_id)
|
||||
cfg.save(SELECTION_CFG)
|
||||
|
||||
|
||||
func _load_selection() -> void:
|
||||
var cfg := ConfigFile.new()
|
||||
if cfg.load(SELECTION_CFG) == OK:
|
||||
var saved: String = cfg.get_value("skin", "active", "default")
|
||||
if skins.has(saved):
|
||||
active_skin_id = saved
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
uid://corbsludoaige
|
||||
+250
-274
@@ -1,323 +1,299 @@
|
||||
extends Node3D
|
||||
class_name SkinnedPlayerModel
|
||||
|
||||
## A player model loaded from a GLB file with its own armature and animations.
|
||||
## Replaces the procedural HumanoidModel for characters with custom skins.
|
||||
## A player model loaded from a game-ready GLB (produced by tools/pipeline.py)
|
||||
## with a Mixamo-compatible skeleton and the canonical animation set.
|
||||
##
|
||||
## Drop-in replacement for the procedural HumanoidModel:
|
||||
## - update_state(state, speed, is_crouching) — drives animation selection
|
||||
## - set_weapon(script_path) — third-person weapon in hand
|
||||
## - shadows_only — legacy local-player mode
|
||||
##
|
||||
## View modes:
|
||||
## - first_person_mode = true → full body visible to the OWNER, head hidden
|
||||
## (shrunk via a SkeletonModifier3D so hair/face follow), fully animated.
|
||||
## - first_person_mode = false → full third-person model for other players.
|
||||
|
||||
@export var model_path: String = ""
|
||||
@export var scale_factor: float = 1.25
|
||||
@export var position_y_offset: float = -0.2
|
||||
@export var first_person_mode: bool = false
|
||||
@export var shadows_only: bool = false
|
||||
@export var facing_flip: bool = true # glTF forward is +Z; players face -Z
|
||||
|
||||
@export var locomotion_cycle_speed: float = 4.0
|
||||
## Horizontal speed (m/s) at which the Run clip plays at authored speed.
|
||||
@export var run_anim_reference_speed: float = 8.0
|
||||
@export var walk_anim_reference_speed: float = 3.0
|
||||
|
||||
## Canonical clip names -> fallback chain. First clip that exists wins, so a
|
||||
## model with only Idle/Walk/Run still animates in every movement state.
|
||||
const CLIP_FALLBACKS := {
|
||||
"Idle": ["Idle"],
|
||||
"Walk": ["Walk", "Run", "Idle"],
|
||||
"Run": ["Run", "Walk", "Idle"],
|
||||
"Sprint": ["Sprint", "Run", "Walk", "Idle"],
|
||||
"Jump": ["Jump", "Fall", "Idle"],
|
||||
"Fall": ["Fall", "Jump", "Idle"],
|
||||
"Crouch": ["CrouchIdle", "Crouch", "Idle"],
|
||||
"CrouchWalk": ["CrouchWalk", "Crouch", "CrouchIdle", "Walk"],
|
||||
"Slide": ["Slide", "CrouchIdle", "Crouch", "Idle"],
|
||||
"WallRun": ["WallRun", "Run", "Walk"],
|
||||
"WallCling": ["WallCling", "CrouchIdle", "Idle"],
|
||||
"Grapple": ["Grapple", "Fall", "Jump", "Idle"],
|
||||
"Dash": ["Dash", "Sprint", "Run", "Idle"],
|
||||
"Death": ["Death", "Fall"],
|
||||
}
|
||||
|
||||
const LOOPING_CLIPS := ["Idle", "Walk", "Run", "Sprint", "Fall", "Crouch",
|
||||
"CrouchIdle", "CrouchWalk", "Slide", "WallRun", "WallCling", "Grapple"]
|
||||
|
||||
const BLEND_TIME := 0.15
|
||||
|
||||
var skeleton: Skeleton3D
|
||||
var animation_player: AnimationPlayer
|
||||
var _anim_debug_timer: float = 0.0
|
||||
var _mesh_instance: MeshInstance3D
|
||||
var _bone_cache: Dictionary = {}
|
||||
var _bone_indices: Dictionary = {}
|
||||
var _code_driven_mode: bool = false
|
||||
var _current_anim: String = ""
|
||||
var _anim_time: float = 0.0
|
||||
var _bone_idxs: Dictionary = {}
|
||||
var loaded: bool = false
|
||||
|
||||
var _resolved_clips: Dictionary = {} # canonical name -> actual clip name
|
||||
var _current_clip: String = ""
|
||||
var _weapon_attachment: BoneAttachment3D
|
||||
var _head_hider: SkeletonModifier3D
|
||||
var is_holding_weapon: bool = false
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
if model_path != "":
|
||||
load_model(model_path)
|
||||
|
||||
|
||||
func load_model(path: String) -> void:
|
||||
for child in get_children():
|
||||
child.queue_free()
|
||||
_bone_cache.clear()
|
||||
_bone_indices.clear()
|
||||
_code_driven_mode = false
|
||||
_anim_time = 0.0
|
||||
_current_anim = ""
|
||||
_bone_idxs.clear()
|
||||
print("SkinnedPlayerModel: loading %s via GLTFDocument" % path)
|
||||
skeleton = null
|
||||
animation_player = null
|
||||
loaded = false
|
||||
_resolved_clips.clear()
|
||||
_current_clip = ""
|
||||
_weapon_attachment = null
|
||||
|
||||
if not FileAccess.file_exists(path):
|
||||
print("SkinnedPlayerModel: file not found: %s" % path)
|
||||
return
|
||||
|
||||
var file = FileAccess.open(path, FileAccess.READ)
|
||||
var bytes = file.get_buffer(file.get_length())
|
||||
file.close()
|
||||
|
||||
var gltf = GLTFDocument.new()
|
||||
var state = GLTFState.new()
|
||||
var err = gltf.append_from_buffer(bytes, "", state)
|
||||
if err != OK:
|
||||
print("SkinnedPlayerModel: GLTFDocument parse failed: %d" % err)
|
||||
return
|
||||
|
||||
print("SkinnedPlayerModel: parsed GLB, generating scene...")
|
||||
var scene = gltf.generate_scene(state, 30, false, false)
|
||||
var scene := GLBLoader.load(path)
|
||||
if not scene:
|
||||
print("SkinnedPlayerModel: failed to generate scene")
|
||||
push_warning("SkinnedPlayerModel: failed to load '%s'" % path)
|
||||
return
|
||||
|
||||
add_child(scene)
|
||||
print("SkinnedPlayerModel: scene added: %s" % scene.name)
|
||||
if facing_flip:
|
||||
scene.rotation_degrees.y = 180.0
|
||||
|
||||
scene.scale = Vector3(scale_factor, scale_factor, scale_factor)
|
||||
scene.position = Vector3(0, position_y_offset * scale_factor, 0)
|
||||
print("SkinnedPlayerModel: applied scale %.4f, y_offset %.4f" % [scale_factor, position_y_offset])
|
||||
skeleton = _find_node_of_type(scene, "Skeleton3D") as Skeleton3D
|
||||
animation_player = _find_node_of_type(scene, "AnimationPlayer") as AnimationPlayer
|
||||
|
||||
skeleton = _find_skeleton(scene)
|
||||
if skeleton:
|
||||
print("SkinnedPlayerModel: found skeleton '%s' with %d bones" % [skeleton.name, skeleton.get_bone_count()])
|
||||
for i in range(skeleton.get_bone_count()):
|
||||
_bone_cache[skeleton.get_bone_name(i)] = i
|
||||
_bone_indices[skeleton.get_bone_name(i)] = i
|
||||
if not skeleton:
|
||||
push_warning("SkinnedPlayerModel: no skeleton in '%s'" % path)
|
||||
if animation_player:
|
||||
_index_animations()
|
||||
else:
|
||||
print("SkinnedPlayerModel: WARNING - no skeleton found")
|
||||
push_warning("SkinnedPlayerModel: no animations in '%s' — model will T-pose" % path)
|
||||
|
||||
_mesh_instance = scene.find_child("Tda Miku for fbx_mesh", true, false)
|
||||
if not _mesh_instance:
|
||||
var mesh_instances = scene.find_children("*", "MeshInstance3D", true, false)
|
||||
if mesh_instances.size() > 0:
|
||||
_mesh_instance = mesh_instances[0]
|
||||
|
||||
if _mesh_instance and skeleton:
|
||||
_mesh_instance.skeleton = NodePath("..")
|
||||
print("SkinnedPlayerModel: fixed mesh skeleton binding -> '%s'" % _mesh_instance.skeleton)
|
||||
|
||||
if first_person_mode and _mesh_instance:
|
||||
if shadows_only:
|
||||
_set_shadows_recursive(self)
|
||||
if first_person_mode:
|
||||
_setup_first_person()
|
||||
|
||||
print("SkinnedPlayerModel: scene tree:")
|
||||
_print_tree(scene, 0)
|
||||
loaded = true
|
||||
_play_clip("Idle")
|
||||
|
||||
animation_player = _find_animation_player(scene)
|
||||
if animation_player:
|
||||
animation_player.process_mode = Node.PROCESS_MODE_ALWAYS
|
||||
animation_player.active = true
|
||||
print("SkinnedPlayerModel: AP process_mode=%d active=%s" % [animation_player.process_mode, animation_player.active])
|
||||
var anim_list = animation_player.get_animation_list()
|
||||
print("SkinnedPlayerModel: %d animations:" % anim_list.size())
|
||||
for a_name in anim_list:
|
||||
var a = animation_player.get_animation(a_name)
|
||||
print(" - %s (%.2fs, %d tracks)" % [a_name, a.length, a.get_track_count()])
|
||||
if animation_player.has_animation("Idle"):
|
||||
animation_player.play("Idle")
|
||||
print("SkinnedPlayerModel: playing 'Idle'")
|
||||
elif anim_list.size() > 0:
|
||||
animation_player.play(anim_list[0])
|
||||
_verify_animations(animation_player)
|
||||
else:
|
||||
print("SkinnedPlayerModel: WARNING - no AnimationPlayer found")
|
||||
|
||||
func _verify_animations(ap: AnimationPlayer) -> void:
|
||||
var has_real_anim := false
|
||||
for a_name in ap.get_animation_list():
|
||||
var anim = ap.get_animation(a_name)
|
||||
if anim.get_track_count() < 10:
|
||||
continue
|
||||
var max_delta := 0.0
|
||||
for t in range(anim.get_track_count()):
|
||||
var kc = anim.track_get_key_count(t)
|
||||
if kc < 2:
|
||||
continue
|
||||
for i in range(1, kc):
|
||||
var v = anim.track_get_key_value(t, i)
|
||||
var prev = anim.track_get_key_value(t, i - 1)
|
||||
if v is Quaternion and prev is Quaternion:
|
||||
max_delta = max(max_delta, v.angle_to(prev))
|
||||
if max_delta > 0.05:
|
||||
has_real_anim = true
|
||||
print(" OK: '%s' (delta=%.3f)" % [a_name, max_delta])
|
||||
else:
|
||||
print(" STRIPPED: '%s' (delta=%.4f)" % [a_name, max_delta])
|
||||
if not has_real_anim:
|
||||
print("SkinnedPlayerModel: All GLB anims stripped, using code-driven mode")
|
||||
_enable_code_driven_anim()
|
||||
## Map canonical clip names to whatever actually shipped in the GLB and set
|
||||
## loop modes (glTF has no loop flag, so we set it here).
|
||||
func _index_animations() -> void:
|
||||
var available := animation_player.get_animation_list()
|
||||
for canonical in CLIP_FALLBACKS:
|
||||
for candidate in CLIP_FALLBACKS[canonical]:
|
||||
var match_name := _find_clip(available, candidate)
|
||||
if match_name != "":
|
||||
_resolved_clips[canonical] = match_name
|
||||
break
|
||||
for canonical in LOOPING_CLIPS:
|
||||
if _resolved_clips.has(canonical):
|
||||
var anim := animation_player.get_animation(_resolved_clips[canonical])
|
||||
if anim:
|
||||
anim.loop_mode = Animation.LOOP_LINEAR
|
||||
|
||||
func _enable_code_driven_anim() -> void:
|
||||
_code_driven_mode = true
|
||||
|
||||
func _find_clip(available: PackedStringArray, wanted: String) -> String:
|
||||
for name in available:
|
||||
if name == wanted:
|
||||
return name
|
||||
# Tolerate library prefixes ("mixamo/Run") and case differences.
|
||||
var wanted_lower := wanted.to_lower()
|
||||
for name in available:
|
||||
var base := name.get_slice("/", name.get_slice_count("/") - 1).to_lower()
|
||||
if base == wanted_lower:
|
||||
return name
|
||||
return ""
|
||||
|
||||
|
||||
# ── View modes ────────────────────────────────────────────────────────────────
|
||||
|
||||
func _setup_first_person() -> void:
|
||||
if not _mesh_instance:
|
||||
return
|
||||
_mesh_instance.visible = false
|
||||
|
||||
func _print_tree(node: Node, depth: int) -> void:
|
||||
var indent = " ".repeat(depth)
|
||||
var extra = ""
|
||||
if node is AnimationPlayer:
|
||||
extra = " [ANIMATION PLAYER]"
|
||||
elif node is Skeleton3D:
|
||||
extra = " [SKELETON]"
|
||||
print("%s%s (%s)%s" % [indent, node.name, node.get_class(), extra])
|
||||
for child in node.get_children():
|
||||
_print_tree(child, depth + 1)
|
||||
|
||||
func _find_skeleton(node: Node) -> Skeleton3D:
|
||||
if node is Skeleton3D:
|
||||
return node
|
||||
for child in node.get_children():
|
||||
var result = _find_skeleton(child)
|
||||
if result:
|
||||
return result
|
||||
return null
|
||||
|
||||
func _find_animation_player(node: Node) -> AnimationPlayer:
|
||||
if node is AnimationPlayer:
|
||||
return node
|
||||
for child in node.get_children():
|
||||
var result = _find_animation_player(child)
|
||||
if result:
|
||||
return result
|
||||
return null
|
||||
|
||||
func _process(delta: float) -> void:
|
||||
if not skeleton:
|
||||
return
|
||||
|
||||
var parent = get_parent()
|
||||
if not parent:
|
||||
var head_idx := _find_bone(["Head"])
|
||||
if head_idx < 0:
|
||||
# No head bone — fall back to shadows-only so the local player at least
|
||||
# isn't staring at the inside of a face.
|
||||
_set_shadows_recursive(self)
|
||||
return
|
||||
var sm = parent.get_node_or_null("MovementStateMachine")
|
||||
if not sm:
|
||||
_head_hider = HeadHider.new()
|
||||
_head_hider.name = "HeadHider"
|
||||
_head_hider.head_bone = head_idx
|
||||
skeleton.add_child(_head_hider)
|
||||
|
||||
|
||||
func set_first_person(enabled: bool) -> void:
|
||||
first_person_mode = enabled
|
||||
if enabled and not _head_hider:
|
||||
_setup_first_person()
|
||||
elif not enabled and _head_hider:
|
||||
_head_hider.queue_free()
|
||||
_head_hider = null
|
||||
|
||||
|
||||
## Shrinks the head bone after each animation update so the owner's camera
|
||||
## never sees their own face/hair, while shadows and other players see the
|
||||
## full head (the modifier only runs on this local instance).
|
||||
class HeadHider extends SkeletonModifier3D:
|
||||
var head_bone: int = -1
|
||||
|
||||
func _process_modification() -> void:
|
||||
var skel := get_skeleton()
|
||||
if skel and head_bone >= 0:
|
||||
skel.set_bone_pose_scale(head_bone, Vector3(0.001, 0.001, 0.001))
|
||||
|
||||
|
||||
# ── Animation state ───────────────────────────────────────────────────────────
|
||||
|
||||
## Same contract as HumanoidModel.update_state(). Called by the movement
|
||||
## controller each frame with either local or network-synced state.
|
||||
func update_state(state: String, speed: float, is_crouching: bool = false) -> void:
|
||||
if not loaded or not animation_player:
|
||||
return
|
||||
|
||||
var state = sm.current_state
|
||||
var speed = 0.0
|
||||
if sm.player:
|
||||
var vel = sm.player.velocity
|
||||
speed = Vector2(vel.x, vel.z).length()
|
||||
var clip := "Idle"
|
||||
match state:
|
||||
"ground", "idle":
|
||||
if is_crouching:
|
||||
clip = "CrouchWalk" if speed > 0.5 else "Crouch"
|
||||
elif speed > run_anim_reference_speed * 1.35:
|
||||
clip = "Sprint"
|
||||
elif speed > walk_anim_reference_speed * 1.2:
|
||||
clip = "Run"
|
||||
elif speed > 0.5:
|
||||
clip = "Walk"
|
||||
"air":
|
||||
clip = "Fall"
|
||||
"slide":
|
||||
clip = "Slide"
|
||||
"wall_run":
|
||||
clip = "WallRun"
|
||||
"wall_cling", "wall_climb":
|
||||
clip = "WallCling"
|
||||
"grapple":
|
||||
clip = "Grapple"
|
||||
"dash":
|
||||
clip = "Dash"
|
||||
"death":
|
||||
clip = "Death"
|
||||
|
||||
var is_crouching = sm.input_crouch if sm else false
|
||||
var is_dead: bool = false
|
||||
if parent and "is_dead" in parent:
|
||||
is_dead = parent.is_dead
|
||||
_play_clip(clip)
|
||||
|
||||
var anim_name = "Idle"
|
||||
if is_dead:
|
||||
anim_name = "Death"
|
||||
elif is_crouching:
|
||||
anim_name = "Crouch"
|
||||
# Scale locomotion playback so feet keep up with actual movement speed.
|
||||
match clip:
|
||||
"Walk":
|
||||
animation_player.speed_scale = clampf(speed / walk_anim_reference_speed, 0.7, 1.6)
|
||||
"Run", "Sprint", "WallRun":
|
||||
animation_player.speed_scale = clampf(speed / run_anim_reference_speed, 0.7, 1.8)
|
||||
_:
|
||||
animation_player.speed_scale = 1.0
|
||||
|
||||
|
||||
func _play_clip(canonical: String) -> void:
|
||||
if not animation_player or not _resolved_clips.has(canonical):
|
||||
return
|
||||
var clip_name: String = _resolved_clips[canonical]
|
||||
if _current_clip == clip_name and animation_player.is_playing():
|
||||
return
|
||||
animation_player.play(clip_name, BLEND_TIME)
|
||||
_current_clip = clip_name
|
||||
|
||||
|
||||
# ── Third-person weapon ───────────────────────────────────────────────────────
|
||||
|
||||
## Attach a weapon (by weapon script path) to the right hand bone so other
|
||||
## players see what this player is holding. Mirrors HumanoidModel.set_weapon().
|
||||
func set_weapon(script_path: String) -> void:
|
||||
if _weapon_attachment:
|
||||
_weapon_attachment.queue_free()
|
||||
_weapon_attachment = null
|
||||
is_holding_weapon = script_path != ""
|
||||
if script_path == "" or not skeleton:
|
||||
return
|
||||
|
||||
var script = load(script_path)
|
||||
if not script:
|
||||
return
|
||||
var w = script.new()
|
||||
w.name = "ThirdPersonWeapon"
|
||||
w.set_meta("is_third_person_weapon", true)
|
||||
w.ready.connect(func():
|
||||
w.set_process(false)
|
||||
w.set_process_input(false)
|
||||
if shadows_only:
|
||||
_set_shadows_recursive(w)
|
||||
# Undo the first-person viewmodel placement from the weapon's _ready.
|
||||
w.position = Vector3(0.0, 0.08, 0.03)
|
||||
w.rotation_degrees = Vector3(0, 90, 0)
|
||||
w.scale = Vector3(0.8, 0.8, 0.8)
|
||||
)
|
||||
|
||||
var hand_idx := _find_bone(["RightHand", "Hand_R", "hand.R"])
|
||||
if hand_idx >= 0:
|
||||
_weapon_attachment = BoneAttachment3D.new()
|
||||
_weapon_attachment.name = "WeaponAttachment"
|
||||
skeleton.add_child(_weapon_attachment)
|
||||
_weapon_attachment.bone_idx = hand_idx
|
||||
_weapon_attachment.add_child(w)
|
||||
else:
|
||||
var walk_speed = 10.0
|
||||
if sm and sm.params:
|
||||
walk_speed = sm.params.walk_speed
|
||||
match state:
|
||||
"ground", "idle":
|
||||
if speed > walk_speed * 1.2:
|
||||
anim_name = "Run"
|
||||
elif speed > 0.5:
|
||||
anim_name = "Walk"
|
||||
"slide", "wall_run", "grapple", "dash":
|
||||
anim_name = "Run"
|
||||
"air":
|
||||
anim_name = "Jump"
|
||||
_:
|
||||
anim_name = "Idle"
|
||||
# No hand bone — hold it at chest height like the procedural model did.
|
||||
w.ready.connect(func():
|
||||
w.position = Vector3(-0.15, 1.0, 0.4)
|
||||
w.rotation_degrees = Vector3(0, 180, 0)
|
||||
)
|
||||
add_child(w)
|
||||
|
||||
if animation_player and (animation_player.current_animation != anim_name or not animation_player.is_playing()):
|
||||
if animation_player.has_animation(anim_name):
|
||||
animation_player.play(anim_name)
|
||||
|
||||
_anim_time += delta
|
||||
_current_anim = anim_name
|
||||
# ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
if _code_driven_mode:
|
||||
_apply_code_animation(anim_name, _anim_time)
|
||||
else:
|
||||
# GLB animations are applied by AP, just force skeleton update
|
||||
skeleton.force_update_all_bone_transforms()
|
||||
|
||||
_anim_debug_timer += delta
|
||||
if _anim_debug_timer > 2.0:
|
||||
_anim_debug_timer = 0.0
|
||||
print("SkinnedPlayerModel: '%s' t=%.1f mode=%s" % [anim_name, _anim_time, "code" if _code_driven_mode else "AP"])
|
||||
|
||||
func _apply_code_animation(anim: String, time: float) -> void:
|
||||
if _bone_idxs.is_empty():
|
||||
_cache_bone_indices()
|
||||
for bone_name in _bone_idxs:
|
||||
var idx: int = _bone_idxs[bone_name]
|
||||
var offset := _get_offset(anim, idx, time)
|
||||
if offset == Vector3.ZERO:
|
||||
# Reset this bone to rest (clear any previous override)
|
||||
skeleton.set_bone_global_pose_override(idx, Transform3D(), 0.0, true)
|
||||
continue
|
||||
var rest: Transform3D = skeleton.get_bone_rest(idx)
|
||||
var q := Quaternion.from_euler(offset)
|
||||
var new_basis: Basis = Basis(q) * rest.basis
|
||||
skeleton.set_bone_global_pose_override(idx, Transform3D(new_basis, rest.origin), 1.0, true)
|
||||
|
||||
func _cache_bone_indices() -> void:
|
||||
_bone_idxs.clear()
|
||||
func _find_bone(name_parts: Array) -> int:
|
||||
if not skeleton:
|
||||
return -1
|
||||
for i in range(skeleton.get_bone_count()):
|
||||
_bone_idxs[skeleton.get_bone_name(i)] = i
|
||||
var bone_name := skeleton.get_bone_name(i)
|
||||
for part in name_parts:
|
||||
if bone_name.findn(part) != -1:
|
||||
return i
|
||||
return -1
|
||||
|
||||
func _get_offset(anim: String, bone_idx: int, time: float) -> Vector3:
|
||||
if not skeleton:
|
||||
return Vector3.ZERO
|
||||
var bone_name: String = ""
|
||||
for k in _bone_idxs:
|
||||
if _bone_idxs[k] == bone_idx:
|
||||
bone_name = k
|
||||
break
|
||||
match anim:
|
||||
"Walk": return _walk_offset(bone_name, time)
|
||||
"Run": return _run_offset(bone_name, time)
|
||||
"Jump": return _jump_offset(bone_name, time)
|
||||
"Idle": return _idle_offset(bone_name, time)
|
||||
"Crouch": return _crouch_offset(bone_name, time)
|
||||
return Vector3.ZERO
|
||||
|
||||
func _walk_offset(bone: String, time: float) -> Vector3:
|
||||
var c: float = 1.0 / locomotion_cycle_speed
|
||||
var t: float = fmod(time, c) / c * 6.28318
|
||||
match bone:
|
||||
"LeftUpperLeg": return Vector3(sin(t) * 0.8, 0, 0)
|
||||
"RightUpperLeg": return Vector3(sin(t + 3.14159) * 0.8, 0, 0)
|
||||
"LeftUpperArm": return Vector3(sin(t + 3.14159) * 0.6, 0, 0)
|
||||
"RightUpperArm": return Vector3(sin(t) * 0.6, 0, 0)
|
||||
"Spine": return Vector3(0, 0, sin(t) * 0.15)
|
||||
"Chest": return Vector3(0, 0, sin(t) * 0.1)
|
||||
"Hips": return Vector3(0, sin(t) * 0.05, 0)
|
||||
return Vector3.ZERO
|
||||
func _find_node_of_type(node: Node, type_name: String) -> Node:
|
||||
if node.is_class(type_name):
|
||||
return node
|
||||
for child in node.get_children():
|
||||
var found := _find_node_of_type(child, type_name)
|
||||
if found:
|
||||
return found
|
||||
return null
|
||||
|
||||
func _run_offset(bone: String, time: float) -> Vector3:
|
||||
var c: float = 1.0 / locomotion_cycle_speed * 1.8
|
||||
var t: float = fmod(time, c) / c * 6.28318
|
||||
match bone:
|
||||
"LeftUpperLeg": return Vector3(sin(t) * 1.0, 0, 0)
|
||||
"RightUpperLeg": return Vector3(sin(t + 3.14159) * 1.0, 0, 0)
|
||||
"LeftUpperArm": return Vector3(sin(t + 3.14159) * 0.8, 0, 0)
|
||||
"RightUpperArm": return Vector3(sin(t) * 0.8, 0, 0)
|
||||
"Spine": return Vector3(0, 0, sin(t) * 0.2)
|
||||
"Hips": return Vector3(0, sin(t) * 0.1, 0)
|
||||
return Vector3.ZERO
|
||||
|
||||
func _jump_offset(bone: String, time: float) -> Vector3:
|
||||
var c: float = 0.83
|
||||
var t: float = fmod(time, c) / c
|
||||
var peak: float = max(0.0, 1.0 - abs(t - 0.5) * 3.0)
|
||||
peak = clamp(peak, 0.0, 1.0)
|
||||
match bone:
|
||||
"LeftUpperArm": return Vector3(-2.5 * peak, 0, -0.3 * peak)
|
||||
"RightUpperArm": return Vector3(-2.5 * peak, 0, 0.3 * peak)
|
||||
"LeftUpperLeg": return Vector3(-0.5 * peak, 0, 0)
|
||||
"RightUpperLeg": return Vector3(-0.5 * peak, 0, 0)
|
||||
"Spine": return Vector3(0.2 * peak, 0, 0)
|
||||
"Hips": return Vector3(0, 0.4 * peak, 0)
|
||||
return Vector3.ZERO
|
||||
|
||||
func _idle_offset(bone: String, time: float) -> Vector3:
|
||||
var t: float = time * 0.5
|
||||
match bone:
|
||||
"Spine": return Vector3(0, sin(t) * 0.05, 0)
|
||||
"Head": return Vector3(sin(t * 0.7) * 0.05, sin(t * 0.5) * 0.05, 0)
|
||||
"LeftUpperArm": return Vector3(sin(t) * 0.05, 0, sin(t * 0.5) * 0.15)
|
||||
"RightUpperArm": return Vector3(sin(t) * 0.05, 0, -sin(t * 0.5) * 0.15)
|
||||
"Hips": return Vector3(0, sin(t) * 0.03, 0)
|
||||
return Vector3.ZERO
|
||||
|
||||
func _crouch_offset(bone: String, time: float) -> Vector3:
|
||||
# Static crouch pose if not animated by AP
|
||||
return Vector3.ZERO
|
||||
func _set_shadows_recursive(node: Node) -> void:
|
||||
if node is GeometryInstance3D:
|
||||
node.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_SHADOWS_ONLY
|
||||
for child in node.get_children():
|
||||
_set_shadows_recursive(child)
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
uid://b3sy8q0weklf
|
||||
Reference in New Issue
Block a user