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:
Nicholas Butzke
2026-07-06 02:28:58 -04:00
co-authored by Claude Fable 5
parent 552338112e
commit d05477c135
52 changed files with 1933 additions and 466 deletions
+2 -3
View File
@@ -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).
+1
View File
@@ -0,0 +1 @@
uid://bb4cnikn41oot
+1
View File
@@ -0,0 +1 @@
uid://cjpuejdwwevdn
+81 -43
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
uid://corbsludoaige
+250 -274
View File
@@ -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)
+1
View File
@@ -0,0 +1 @@
uid://b3sy8q0weklf