Feat/14 movement overhaul #20
@@ -47,6 +47,16 @@ user_settings/
|
||||
*.pidb
|
||||
*.userprefs
|
||||
|
||||
# Secrets
|
||||
.sketchfab_token
|
||||
|
||||
# Python
|
||||
.venv/
|
||||
__pycache__/
|
||||
|
||||
# Asset pipeline staging (raw downloads, not game-ready)
|
||||
assets/characters/incoming/
|
||||
|
||||
# Misc
|
||||
*LF*
|
||||
tags
|
||||
|
||||
|
After Width: | Height: | Size: 1.0 MiB |
|
After Width: | Height: | Size: 767 KiB |
|
After Width: | Height: | Size: 357 KiB |
|
After Width: | Height: | Size: 572 KiB |
|
After Width: | Height: | Size: 978 KiB |
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 1.0 MiB |
|
After Width: | Height: | Size: 767 KiB |
|
After Width: | Height: | Size: 357 KiB |
|
After Width: | Height: | Size: 572 KiB |
|
After Width: | Height: | Size: 978 KiB |
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 1.0 MiB |
|
After Width: | Height: | Size: 767 KiB |
|
After Width: | Height: | Size: 357 KiB |
|
After Width: | Height: | Size: 572 KiB |
|
After Width: | Height: | Size: 978 KiB |
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 1.4 MiB |
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"skins": [
|
||||
{
|
||||
"id": "miku",
|
||||
"name": "Miku",
|
||||
"description": "Hatsune Miku — Virtual Idol",
|
||||
"model": "res://assets/characters/skins/miku_proper_anim.glb",
|
||||
"unlocked": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -1 +1 @@
|
||||
uid://dn5ndscghwovm
|
||||
uid://5ayptqm4yjih
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
extends SceneTree
|
||||
|
||||
## Headless smoke test for the player spawn + skin + animation pipeline.
|
||||
## Boots the test level as a singleplayer match, spawns the local player,
|
||||
## ticks frames, and verifies the visual model came up for both the
|
||||
## procedural skin and a GLB skin.
|
||||
##
|
||||
## Run: godot --headless --path . -s res://debug/spawn_smoke_test.gd
|
||||
|
||||
var _failures: Array = []
|
||||
|
||||
|
||||
func _init() -> void:
|
||||
call_deferred("_run")
|
||||
|
||||
|
||||
func _run() -> void:
|
||||
await process_frame
|
||||
await process_frame
|
||||
|
||||
var skin_mgr = root.get_node_or_null("/root/SkinManager")
|
||||
_check(skin_mgr != null, "SkinManager autoload exists")
|
||||
var audio_mgr = root.get_node_or_null("/root/AudioManager")
|
||||
_check(audio_mgr != null, "AudioManager autoload exists")
|
||||
if audio_mgr:
|
||||
_check(audio_mgr.has_sound("ak47_fire"), "AudioManager auto-registered ak47_fire")
|
||||
|
||||
# Pick the GLB skin if its model file is present, else default.
|
||||
var glb_skin_id := ""
|
||||
if skin_mgr:
|
||||
for id in skin_mgr.get_skin_ids():
|
||||
var s = skin_mgr.get_skin(id)
|
||||
if s.model_path != "" and ResourceLoader.exists(s.model_path):
|
||||
glb_skin_id = id
|
||||
break
|
||||
|
||||
await _test_spawn_with_skin("default", false)
|
||||
if glb_skin_id != "":
|
||||
await _test_spawn_with_skin(glb_skin_id, true)
|
||||
else:
|
||||
print("NOTE: no GLB skin available, skipped skinned model test")
|
||||
|
||||
print("\n=== SPAWN SMOKE SUMMARY ===")
|
||||
print("Failures: %d" % _failures.size())
|
||||
for f in _failures:
|
||||
print("FAIL: ", f)
|
||||
quit(0 if _failures.is_empty() else 1)
|
||||
|
||||
|
||||
func _test_spawn_with_skin(skin_id: String, expect_skinned: bool) -> void:
|
||||
print("\n--- Spawn test with skin '%s' ---" % skin_id)
|
||||
var skin_mgr = root.get_node_or_null("/root/SkinManager")
|
||||
if skin_mgr:
|
||||
skin_mgr.set_active_skin(skin_id)
|
||||
|
||||
var nm = root.get_node_or_null("/root/NetworkManager")
|
||||
if not _check(nm != null, "NetworkManager autoload exists"):
|
||||
return
|
||||
nm.start_singleplayer_match("Deathmatch")
|
||||
change_scene_to_file("res://scenes/maps/test_level/test_level.tscn")
|
||||
|
||||
# Let the level build and the player spawn + settle.
|
||||
for i in 30:
|
||||
await process_frame
|
||||
|
||||
var level = current_scene
|
||||
if not _check(level != null, "level scene loaded"):
|
||||
return
|
||||
var player = _find_player(level)
|
||||
if not _check(player != null, "player '1' spawned"):
|
||||
return
|
||||
|
||||
_check(player.get_node_or_null("MovementStateMachine") != null, "state machine present")
|
||||
var visual = player.get_visual_model() if player.has_method("get_visual_model") else null
|
||||
_check(visual != null, "visual model present")
|
||||
|
||||
if expect_skinned:
|
||||
var skinned = player.get_node_or_null("SkinnedModel")
|
||||
if _check(skinned != null, "SkinnedModel created for GLB skin"):
|
||||
_check(skinned.loaded, "GLB model loaded")
|
||||
_check(skinned.skeleton != null, "skeleton found in GLB")
|
||||
if skinned.animation_player:
|
||||
_check(skinned.animation_player.is_playing(), "animation playing")
|
||||
print(" clips resolved: ", skinned._resolved_clips)
|
||||
# Drive some movement states through the same API the game uses.
|
||||
for state in ["ground", "air", "slide", "wall_run", "dash"]:
|
||||
skinned.update_state(state, 9.0, false)
|
||||
await process_frame
|
||||
_check(true, "state cycling did not crash")
|
||||
else:
|
||||
var humanoid = player.get_node_or_null("HumanoidModel")
|
||||
_check(humanoid != null and humanoid.visible, "procedural model visible for color skin")
|
||||
|
||||
# Simulate a few physics frames of idle play.
|
||||
for i in 30:
|
||||
await physics_frame
|
||||
_check(is_instance_valid(player), "player survived 30 physics frames")
|
||||
|
||||
|
||||
func _find_player(node: Node) -> Node:
|
||||
if node is CharacterBody3D and node.name == "1" and node.has_method("get_visual_model"):
|
||||
return node
|
||||
for child in node.get_children():
|
||||
var found := _find_player(child)
|
||||
if found:
|
||||
return found
|
||||
return null
|
||||
|
||||
|
||||
func _check(ok: bool, msg: String) -> bool:
|
||||
if ok:
|
||||
print(" OK: ", msg)
|
||||
else:
|
||||
print(" FAIL: ", msg)
|
||||
_failures.append(msg)
|
||||
return ok
|
||||
@@ -299,15 +299,9 @@ func _spawn_player(pid: int) -> CharacterBody3D:
|
||||
server_sync.name = "ServerSynchronizer"
|
||||
server_sync.set_multiplayer_authority(1) # Host always controls these
|
||||
var server_rep_config = SceneReplicationConfig.new()
|
||||
server_rep_config.add_property(":position")
|
||||
server_rep_config.add_property(":synced_movement_state")
|
||||
server_rep_config.add_property(":synced_movement_speed")
|
||||
server_rep_config.add_property(":synced_is_crouching")
|
||||
server_rep_config.add_property(":health")
|
||||
server_rep_config.add_property(":shield")
|
||||
server_rep_config.add_property(":is_dead")
|
||||
server_rep_config.add_property(":synced_grapple_point")
|
||||
server_rep_config.add_property(":synced_is_grapple_shooting")
|
||||
server_sync.replication_config = server_rep_config
|
||||
player.add_child(server_sync)
|
||||
|
||||
@@ -316,8 +310,16 @@ func _spawn_player(pid: int) -> CharacterBody3D:
|
||||
client_sync.name = "MultiplayerSynchronizer" # Keep original name for compatibility if needed elsewhere
|
||||
client_sync.set_multiplayer_authority(pid)
|
||||
var client_rep_config = SceneReplicationConfig.new()
|
||||
client_rep_config.add_property(":synced_position")
|
||||
client_rep_config.add_property(":synced_velocity")
|
||||
client_rep_config.add_property(":rotation")
|
||||
client_rep_config.add_property("HeadPivot:rotation")
|
||||
client_rep_config.add_property(":synced_movement_state")
|
||||
client_rep_config.add_property(":synced_movement_speed")
|
||||
client_rep_config.add_property(":synced_is_crouching")
|
||||
client_rep_config.add_property(":synced_grapple_point")
|
||||
client_rep_config.add_property(":synced_is_grapple_shooting")
|
||||
client_rep_config.add_property(":synced_skin_id")
|
||||
client_rep_config.add_property(":synced_weapon_path")
|
||||
client_rep_config.add_property(":synced_loadout_p1")
|
||||
client_rep_config.add_property(":synced_loadout_p2")
|
||||
|
||||
@@ -1 +1 @@
|
||||
uid://c7ltcn37gfd71
|
||||
uid://btkp2l168jj45
|
||||
|
||||
@@ -1,52 +1,125 @@
|
||||
# 3D Asset Pipeline for Papaya-Shooter
|
||||
# Character Pipeline: Sketchfab → Rigged → Animated → In-Game
|
||||
|
||||
## Blender Setup
|
||||
- Blender 5.0.1 installed system-wide
|
||||
- Blender MCP addon at ~/.config/blender/5.0/scripts/addons/blender_mcp/
|
||||
- Bridge: ~/blender-mcp/blender_bridge.py (socket port 9876)
|
||||
- Start: xvfb-run -a blender --python ~/blender-mcp/blender_server.py &
|
||||
One command turns a Sketchfab model into a playable character skin:
|
||||
|
||||
## Export Workflow
|
||||
1. Write Blender Python script (generate model, rig, animate)
|
||||
2. Run: blender --background --python script.py
|
||||
3. Export as .glb to assets/ subdirectory
|
||||
4. Godot auto-imports on editor open
|
||||
```bash
|
||||
python tools/pipeline.py --uid <sketchfab-uid> --name space_marine
|
||||
```
|
||||
|
||||
## Directory Structure
|
||||
- assets/characters/skins/ — player skin models
|
||||
- assets/characters/animations/ — animation clips
|
||||
- assets/weapons/models/ — weapon meshes
|
||||
- assets/weapons/skins/ — weapon skin textures
|
||||
- assets/props/ — level props
|
||||
That downloads the model, auto-rigs it in Blender, merges the shared
|
||||
animation library onto it, writes `assets/characters/skins/space_marine.glb`,
|
||||
and registers it in `skins.json`. Restart the game — the skin is in the main
|
||||
menu dropdown, fully animated in first AND third person, synced in multiplayer.
|
||||
|
||||
## Character System
|
||||
- HumanoidModel: characters/humanoid_model.gd
|
||||
- Procedural rig: torso, head, 4 arms, 4 legs with pivot nodes
|
||||
- States: idle, run, crouch, slide, air, wall_run, wall_cling, grapple, dash, death
|
||||
- Color: @export var color (StandardMaterial3D)
|
||||
- shadows_only for local player (don't see own model)
|
||||
- Weapon attach: set_weapon(script_path) → root_pivot child
|
||||
## Prerequisites (one-time setup)
|
||||
|
||||
## Loadout System
|
||||
- LoadoutManager: globals/loadout_manager.gd (autoload)
|
||||
- 5 slots, each: primary_1, primary_2, special, melee
|
||||
- Save: user://loadouts.json
|
||||
1. **Sketchfab API token** — from <https://sketchfab.com/settings/password>.
|
||||
Put it in the env var `SKETCHFAB_API_TOKEN`, or in a file named
|
||||
`.sketchfab_token` in the project root (gitignored).
|
||||
2. **Blender 3.6+** — on PATH, or set `BLENDER_PATH` to `blender.exe`.
|
||||
3. **Animation library** — fill `assets/characters/animations/` with one clip
|
||||
per file on the Mixamo skeleton. This is done ONCE and reused for every
|
||||
character forever. See "Building the animation library" below.
|
||||
|
||||
## Weapon System
|
||||
- WeaponManager: weapons/weapon_manager.gd
|
||||
- 1st person: SubViewport + vm_camera (layer 20)
|
||||
- 3rd person: HumanoidModel.set_weapon()
|
||||
## The pipeline, step by step
|
||||
|
||||
## Skin System (NEW)
|
||||
- Store skin metadata in loadout or player settings
|
||||
- Each skin: display_name, model_path, texture_path, color_tint
|
||||
- Apply by replacing HumanoidModel mesh nodes or swapping material
|
||||
- For full model replacement: load GLB scene, reparent to player
|
||||
Each step is also runnable on its own:
|
||||
|
||||
## Godot 4.2.1 Compatibility
|
||||
- absf() → abs()
|
||||
- fog_sky_affinity → remove
|
||||
- is not Type → not (x is Type)
|
||||
- lerpf → lerp
|
||||
- := from untyped arrays → explicit type
|
||||
- preload() on parse-error scripts → load()
|
||||
| Step | Tool | What it does |
|
||||
|---|---|---|
|
||||
| 1. Find | `python tools/sketchfab_import.py search "anime robot" --rigged` | Search downloadable models (license shown per result) |
|
||||
| 2. Download | `python tools/sketchfab_import.py download <uid>` | GLB + license/attribution JSON into `assets/characters/incoming/` |
|
||||
| 3. Auto-rig | `blender --background --python tools/autorig.py -- in.glb out.glb` | Fits a Mixamo-named skeleton, binds automatic weights |
|
||||
| 4. Animate | `blender --background --python tools/merge_animations.py -- rigged.glb assets/characters/animations out.glb` | Merges the shared clip library, strips root motion, canonical names |
|
||||
| 5. Register | (automatic in pipeline.py) | Copies to `skins/`, adds entry to `skins.json` |
|
||||
|
||||
`tools/pipeline.py` chains all of it. Useful flags:
|
||||
|
||||
- `--input file.glb` instead of `--uid` for local files (GLB/FBX/OBJ).
|
||||
- `--rigged` — skip auto-rig for models that already have a skeleton
|
||||
(Mixamo/AccuRig/Tripo output). Bones get renamed to Mixamo convention
|
||||
automatically when recognizable.
|
||||
- `--height 1.6` — target character height in meters.
|
||||
|
||||
## Auto-rigging: what to use when
|
||||
|
||||
Mixamo has **no public API** (and Adobe has said one isn't coming), so full
|
||||
automation needs an alternative. In order of preference:
|
||||
|
||||
1. **Built-in autorig (`tools/autorig.py`)** — heuristic skeleton fit +
|
||||
Blender automatic weights, fully automated, zero cost. Works well for
|
||||
normal-proportioned upright humanoids. Weakest on flowing
|
||||
dresses/capes/extreme proportions.
|
||||
2. **Mixamo web (manual, ~2 min/model)** — upload FBX/OBJ at
|
||||
<https://www.mixamo.com>, place 7 markers, download rigged FBX "without
|
||||
animations", then `pipeline.py --input rigged.fbx --rigged --name x`.
|
||||
Best weight quality for the effort.
|
||||
3. **Reallusion AccuRig** (free desktop app) — better auto-weights than
|
||||
Mixamo for tricky meshes; export FBX and feed with `--rigged`.
|
||||
4. **UniRig / Tripo AI / Meshy / Anything World** — ML auto-riggers. UniRig is
|
||||
open source (VAST/Tripo); Tripo, Meshy, and Anything World offer paid HTTP
|
||||
APIs if you ever want a fully hosted rigging step with no Blender at all.
|
||||
|
||||
All roads lead to the same place: a GLB with Mixamo bone names, which is what
|
||||
`merge_animations.py` and the game expect.
|
||||
|
||||
## Building the animation library (once)
|
||||
|
||||
The library lives in `assets/characters/animations/` — one clip per file,
|
||||
filename = clip name (`idle.fbx` → `Idle`, `crouch_walk.fbx` → `CrouchWalk`).
|
||||
|
||||
**Recommended: Mixamo clips.** Go to mixamo.com, pick the X Bot character,
|
||||
download each clip as *FBX Binary, Without Skin, 30 fps* (use "In Place"
|
||||
variants where offered):
|
||||
|
||||
| File | Mixamo search |
|
||||
|---|---|
|
||||
| `idle.fbx` | Rifle Idle (or Idle) |
|
||||
| `walk.fbx` | Walking (In Place) |
|
||||
| `run.fbx` | Rifle Run / Fast Run (In Place) |
|
||||
| `sprint.fbx` | Sprint (In Place) |
|
||||
| `jump.fbx` | Jump |
|
||||
| `fall.fbx` | Falling Idle |
|
||||
| `crouch_idle.fbx` | Crouch Idle |
|
||||
| `crouch_walk.fbx` | Crouch Walk (In Place) |
|
||||
| `slide.fbx` | Running Slide |
|
||||
| `wall_run.fbx` | (see note) Run variant works fine |
|
||||
| `dash.fbx` | Sprint or Dive |
|
||||
| `death.fbx` | Dying |
|
||||
|
||||
CC0 alternative: **Quaternius Universal Animation Library** (quaternius.com,
|
||||
500+ clips, Mixamo-compatible skeleton) or **Kenney character assets** —
|
||||
both free for commercial use, no attribution required.
|
||||
|
||||
Missing clips are fine: the game falls back along sensible chains
|
||||
(`Slide → CrouchIdle → Idle`, `WallRun → Run`, etc. — see
|
||||
`CLIP_FALLBACKS` in `characters/skinned_player_model.gd`). A model with just
|
||||
Idle/Walk/Run still animates in every movement state.
|
||||
|
||||
## How it works in-game
|
||||
|
||||
- **`SkinManager` (autoload)** reads `assets/characters/skins/skins.json` at
|
||||
boot. Selected skin persists per-user and is synced to other players via
|
||||
`synced_skin_id`.
|
||||
- **`SkinnedPlayerModel`** loads the GLB at runtime, maps canonical clip
|
||||
names, sets loop modes, blends between clips (0.15 s), and scales
|
||||
locomotion playback speed to actual movement speed.
|
||||
- **First person (owner):** full body visible and animated; the head bone is
|
||||
shrunk via a `SkeletonModifier3D` so you never see your own face — you DO
|
||||
see your legs, arms and shadow.
|
||||
- **Third person (everyone else):** the full model, driven by the synced
|
||||
movement state, with the current weapon attached to the right-hand bone.
|
||||
- **Licensing:** every Sketchfab download writes `<name>.license.json`.
|
||||
CC-BY models require crediting the author — keep these files and surface
|
||||
them in your credits screen before shipping.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- *Model T-poses in game* — the GLB has no animations; re-run
|
||||
`merge_animations.py` and check it printed `Merged N clips`.
|
||||
- *Mesh deforms badly at shoulders/hips* — heuristic rig didn't fit; rig via
|
||||
Mixamo web or AccuRig and re-run with `--rigged`.
|
||||
- *Character slides while walking* — clips exported with root motion; re-run
|
||||
without `--keep-root-motion` (stripping is the default).
|
||||
- *Skin missing in exported build* — raw `.glb` files must be included in the
|
||||
export: Project → Export → Resources → include filter `*.glb, *.json`.
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
# Asset Sources — Replacing Procedural Content
|
||||
|
||||
Goal: move away from procedurally generated animations, maps, and sounds.
|
||||
Everything below is free for commercial use unless marked otherwise.
|
||||
**CC0 = no attribution needed. CC-BY = must credit the author.**
|
||||
|
||||
## Animations
|
||||
|
||||
| Source | License | Notes |
|
||||
|---|---|---|
|
||||
| [Mixamo](https://www.mixamo.com) | Free (Adobe account) | 2500+ humanoid clips, the de-facto standard skeleton this pipeline targets. No API — download clips manually once. |
|
||||
| [Quaternius Universal Animation Library](https://quaternius.com) | CC0 | 500+ clips on a Mixamo-compatible rig. Drop straight into `assets/characters/animations/`. |
|
||||
| [Kenney Character Assets](https://kenney.nl/assets) | CC0 | Simple rigged characters + basic clips. |
|
||||
| Sketchfab (filter: animated + downloadable) | per-model | Many characters ship with their own baked animations; `pipeline.py --rigged` keeps them. |
|
||||
|
||||
## Character models
|
||||
|
||||
| Source | License | Notes |
|
||||
|---|---|---|
|
||||
| [Sketchfab downloads](https://sketchfab.com/search?features=downloadable&type=models) | per-model (shown by our search tool) | Primary source — use `tools/sketchfab_import.py search`. |
|
||||
| [Quaternius](https://quaternius.com) | CC0 | Stylized low-poly packs, many pre-rigged. |
|
||||
| [KayKit](https://kaylousberg.itch.io) | CC0 | Character + dungeon packs, rigged, game-ready. |
|
||||
| [PolyPizza](https://poly.pizza) | mostly CC0/CC-BY | Searchable low-poly aggregator. |
|
||||
|
||||
## Maps / environments (replace the procedural arena)
|
||||
|
||||
| Source | License | Notes |
|
||||
|---|---|---|
|
||||
| [Kenney kits](https://kenney.nl/assets) (City, Prototype, Platformer) | CC0 | Modular kits — greybox with Prototype textures, dress with City kits. |
|
||||
| [KayKit Dungeon/City packs](https://kaylousberg.itch.io) | CC0 | Modular, snaps to grid, ideal for arena shooters. |
|
||||
| [Sketchfab scenes](https://sketchfab.com) | per-model | Whole environments exist; check poly counts before importing. |
|
||||
| [ambientCG](https://ambientcg.com) | CC0 | PBR materials/textures for level surfaces. |
|
||||
|
||||
Workflow: block out in Godot with CSG/GridMap for movement flow first, then
|
||||
replace geometry with kit pieces. Movement (wall-run surfaces, slide ramps)
|
||||
should drive layout, not the other way around.
|
||||
|
||||
## Sounds
|
||||
|
||||
See [SOUND_DESIGN.md](SOUND_DESIGN.md) for the full plan. Short list:
|
||||
|
||||
| Source | License | Notes |
|
||||
|---|---|---|
|
||||
| [Sonniss GDC Bundles](https://sonniss.com/gameaudiogdc) | Royalty-free | 100+ GB of pro game audio, free, commercial OK. THE first stop. |
|
||||
| [Kenney Audio packs](https://kenney.nl/assets?q=audio) | CC0 | UI, impacts, footsteps, sci-fi weapons. |
|
||||
| [Freesound](https://freesound.org) (filter CC0) | CC0/CC-BY | Searchable; check license per file. |
|
||||
| [BOOM Library free packs](https://www.boomlibrary.com/free-sound-effects/) | Royalty-free | Cinematic-quality freebies. |
|
||||
| [99Sounds](https://99sounds.org) | Royalty-free | Weapon/impact packs. |
|
||||
|
||||
## Attribution bookkeeping
|
||||
|
||||
- Sketchfab: `tools/sketchfab_import.py` writes `<model>.license.json` next to
|
||||
every download; the pipeline copies it beside the final skin GLB.
|
||||
- Anything CC-BY (models or sounds) must appear in a credits screen.
|
||||
Grep for license files before shipping: `ls assets/**/*.license.json`.
|
||||
@@ -0,0 +1,67 @@
|
||||
# Sound Design Plan
|
||||
|
||||
The current sounds are procedurally generated sine/noise blends
|
||||
(`generate_sounds.py`) — functional, but robotic. This doc is the path to
|
||||
real sound design with zero budget.
|
||||
|
||||
## What's in place now
|
||||
|
||||
`AudioManager` (autoload, `globals/audio_manager.gd`) provides the plumbing
|
||||
good sound design needs:
|
||||
|
||||
- **Bus layout**: `Master ← SFX ← {Weapons, Footsteps, UI}`, `Master ← Music`
|
||||
— created automatically at boot, so volume sliders and effects (EQ,
|
||||
compression, reverb zones) can target categories.
|
||||
- **Variation playback**: drop `footstep_01.wav`, `footstep_02.wav`,
|
||||
`footstep_03.wav` into `assets/sounds/` and `AudioManager.play_3d("footstep", pos)`
|
||||
picks one at random.
|
||||
- **Pitch randomization**: every playback is pitched ±6% by default — the
|
||||
single cheapest trick to stop repeated sounds sounding fake.
|
||||
- **Pooled 3D players**: `play_3d()` is safe to call every frame; no node
|
||||
churn, no cut-off management.
|
||||
|
||||
```gdscript
|
||||
# anywhere in gameplay code:
|
||||
AudioManager.play_3d("ak47_fire", muzzle.global_position)
|
||||
AudioManager.play_ui("hit_confirm")
|
||||
```
|
||||
|
||||
## Replacing the procedural sounds (priority order)
|
||||
|
||||
Sound is ~50% of "game feel" in an FPS. Replace in this order:
|
||||
|
||||
1. **Weapon fire** (heard constantly): layered = body (low thump) + crack
|
||||
(mid transient) + tail (room reflection). Sonniss GDC packs have complete
|
||||
layered gunshots. One file per weapon is fine to start; add `_01.._03`
|
||||
variations later.
|
||||
2. **Hit confirm + kill confirm**: short, bright, satisfying (this is the
|
||||
"touchdown" pillar in the design doc).
|
||||
3. **Footsteps**: 4+ variations minimum, quieter than you think, on the
|
||||
`Footsteps` bus so they can be ducked while firing.
|
||||
4. **Movement verbs**: slide (cloth+concrete scrape), dash (air whoosh),
|
||||
wall-run loop, jump/land (land intensity scaled by fall speed).
|
||||
5. **Ambience**: a quiet room tone per map kills the "dead air" feeling.
|
||||
6. **UI**: menu hover/click/equip from a Kenney UI pack.
|
||||
|
||||
Where to get files: see [ASSET_SOURCES.md](ASSET_SOURCES.md#sounds) —
|
||||
Sonniss GDC bundles first, Kenney CC0 packs second, Freesound (CC0 filter)
|
||||
for gaps.
|
||||
|
||||
## Conventions
|
||||
|
||||
- Files: `assets/sounds/<event>[_NN].wav` — 44.1 kHz, 16-bit, mono for 3D
|
||||
positional sounds, stereo only for UI/music.
|
||||
- Ids are auto-registered from filenames at boot (`ak47_fire.wav` →
|
||||
`"ak47_fire"`). Names containing `fire`/`reload` route to the Weapons bus,
|
||||
`footstep` to Footsteps, everything else to SFX
|
||||
(see `BUS_HINTS` in audio_manager.gd).
|
||||
- Keep source loudness consistent: normalize weapon shots to about -6 dBFS
|
||||
peak, footsteps/foley around -18 dBFS.
|
||||
|
||||
## Later polish (cheap wins, in order)
|
||||
|
||||
1. Distance low-pass on the Weapons bus (far gunfire sounds muffled).
|
||||
2. A short duck (sidechain) on Footsteps/ambience when local weapon fires.
|
||||
3. Reverb `AudioEffectReverb` per-map on SFX for indoor spaces.
|
||||
4. Surface-dependent footsteps: raycast down, map material → sound id
|
||||
(`footstep_metal`, `footstep_concrete`).
|
||||
@@ -0,0 +1 @@
|
||||
uid://cr1w53xtjptct
|
||||
@@ -0,0 +1 @@
|
||||
uid://bdes0ywhauamj
|
||||
@@ -73,5 +73,3 @@ func _wiggle() -> void:
|
||||
tween.tween_property(visual_node, "position", orig_pos + wiggle_dir, 0.05)
|
||||
tween.tween_property(visual_node, "position", orig_pos - wiggle_dir, 0.05)
|
||||
tween.tween_property(visual_node, "position", orig_pos, 0.05)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
extends Node
|
||||
|
||||
## Autoload: central sound playback with variation + pitch randomization.
|
||||
##
|
||||
## Why: identical samples played back-to-back sound robotic. Real games ship
|
||||
## 3-5 variations per event and randomize pitch a few percent per shot. This
|
||||
## manager makes that the default for every sound in the game.
|
||||
##
|
||||
## Usage:
|
||||
## AudioManager.play_3d("ak47_fire", muzzle_global_pos)
|
||||
## AudioManager.play_ui("hit_confirm")
|
||||
## AudioManager.register_sound("footstep_metal", [
|
||||
## "res://assets/sounds/footstep_metal_01.wav",
|
||||
## "res://assets/sounds/footstep_metal_02.wav",
|
||||
## ], "Footsteps")
|
||||
##
|
||||
## Sounds are auto-registered from assets/sounds/*.wav on startup (the file
|
||||
## stem is the sound id). Drop variations next to the original with _01/_02/…
|
||||
## suffixes and they are picked up as one id with random selection:
|
||||
## footstep.wav, footstep_01.wav, footstep_02.wav -> id "footstep" (3 vars)
|
||||
##
|
||||
## Buses: Master <- SFX <- {Weapons, Footsteps, UI}, Master <- Music.
|
||||
## See docs/SOUND_DESIGN.md for where to get good source audio.
|
||||
|
||||
const SOUNDS_DIR := "res://assets/sounds"
|
||||
const POOL_SIZE_3D := 32
|
||||
const POOL_SIZE_2D := 8
|
||||
|
||||
# id -> { "streams": Array[AudioStream], "bus": String, "pitch_var": float, "volume_db": float }
|
||||
var _library: Dictionary = {}
|
||||
var _pool_3d: Array[AudioStreamPlayer3D] = []
|
||||
var _pool_2d: Array[AudioStreamPlayer] = []
|
||||
var _next_3d: int = 0
|
||||
var _next_2d: int = 0
|
||||
|
||||
# Default routing/tuning per id prefix (applied during auto-registration).
|
||||
const BUS_HINTS := {
|
||||
"footstep": "Footsteps",
|
||||
"fire": "Weapons",
|
||||
"reload": "Weapons",
|
||||
}
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
_setup_buses()
|
||||
_build_pools()
|
||||
_auto_register_sounds()
|
||||
print("AudioManager: %d sounds registered" % _library.size())
|
||||
|
||||
|
||||
# ── Bus layout ────────────────────────────────────────────────────────────────
|
||||
|
||||
func _setup_buses() -> void:
|
||||
_ensure_bus("SFX", "Master")
|
||||
_ensure_bus("Weapons", "SFX")
|
||||
_ensure_bus("Footsteps", "SFX")
|
||||
_ensure_bus("UI", "SFX")
|
||||
_ensure_bus("Music", "Master")
|
||||
|
||||
|
||||
func _ensure_bus(bus_name: String, send_to: String) -> void:
|
||||
if AudioServer.get_bus_index(bus_name) != -1:
|
||||
return
|
||||
var idx := AudioServer.bus_count
|
||||
AudioServer.add_bus(idx)
|
||||
AudioServer.set_bus_name(idx, bus_name)
|
||||
AudioServer.set_bus_send(idx, send_to)
|
||||
|
||||
|
||||
func set_bus_volume_linear(bus_name: String, linear: float) -> void:
|
||||
var idx := AudioServer.get_bus_index(bus_name)
|
||||
if idx != -1:
|
||||
AudioServer.set_bus_volume_db(idx, linear_to_db(clampf(linear, 0.0001, 1.0)))
|
||||
|
||||
|
||||
# ── Registration ──────────────────────────────────────────────────────────────
|
||||
|
||||
func register_sound(id: String, paths: Array, bus: String = "SFX",
|
||||
pitch_var: float = 0.06, volume_db: float = 0.0) -> void:
|
||||
var streams: Array = []
|
||||
for p in paths:
|
||||
if ResourceLoader.exists(p):
|
||||
var s = load(p)
|
||||
if s:
|
||||
streams.append(s)
|
||||
if streams.is_empty():
|
||||
return
|
||||
_library[id] = {
|
||||
"streams": streams,
|
||||
"bus": bus,
|
||||
"pitch_var": pitch_var,
|
||||
"volume_db": volume_db,
|
||||
}
|
||||
|
||||
|
||||
func _auto_register_sounds() -> void:
|
||||
var dir := DirAccess.open(SOUNDS_DIR)
|
||||
if not dir:
|
||||
return
|
||||
# Group files: "footstep_01.wav" and "footstep.wav" both map to "footstep".
|
||||
var groups: Dictionary = {}
|
||||
for fname in dir.get_files():
|
||||
if not (fname.ends_with(".wav") or fname.ends_with(".ogg") or fname.ends_with(".mp3")):
|
||||
continue
|
||||
var stem := fname.get_basename()
|
||||
var base := stem
|
||||
var parts := stem.rsplit("_", true, 1)
|
||||
if parts.size() == 2 and parts[1].is_valid_int():
|
||||
base = parts[0]
|
||||
if not groups.has(base):
|
||||
groups[base] = []
|
||||
groups[base].append(SOUNDS_DIR + "/" + fname)
|
||||
|
||||
for id in groups:
|
||||
var bus := "SFX"
|
||||
for hint in BUS_HINTS:
|
||||
if id.findn(hint) != -1:
|
||||
bus = BUS_HINTS[hint]
|
||||
break
|
||||
register_sound(id, groups[id], bus)
|
||||
|
||||
|
||||
func has_sound(id: String) -> bool:
|
||||
return _library.has(id)
|
||||
|
||||
|
||||
# ── Playback ──────────────────────────────────────────────────────────────────
|
||||
|
||||
func _build_pools() -> void:
|
||||
for i in POOL_SIZE_3D:
|
||||
var p := AudioStreamPlayer3D.new()
|
||||
p.name = "Pool3D_%d" % i
|
||||
p.max_distance = 80.0
|
||||
p.unit_size = 8.0
|
||||
add_child(p)
|
||||
_pool_3d.append(p)
|
||||
for i in POOL_SIZE_2D:
|
||||
var p := AudioStreamPlayer.new()
|
||||
p.name = "Pool2D_%d" % i
|
||||
add_child(p)
|
||||
_pool_2d.append(p)
|
||||
|
||||
|
||||
## Play a positional sound. Safe to call every frame — players are pooled.
|
||||
func play_3d(id: String, global_pos: Vector3, volume_db_offset: float = 0.0,
|
||||
pitch_scale: float = 1.0) -> AudioStreamPlayer3D:
|
||||
var entry = _library.get(id)
|
||||
if entry == null:
|
||||
return null
|
||||
var player := _pool_3d[_next_3d]
|
||||
_next_3d = (_next_3d + 1) % POOL_SIZE_3D
|
||||
player.stream = entry["streams"].pick_random()
|
||||
player.bus = entry["bus"]
|
||||
player.volume_db = entry["volume_db"] + volume_db_offset
|
||||
player.pitch_scale = pitch_scale * randf_range(1.0 - entry["pitch_var"], 1.0 + entry["pitch_var"])
|
||||
player.global_position = global_pos
|
||||
player.play()
|
||||
return player
|
||||
|
||||
|
||||
## Play a non-positional sound (UI clicks, local confirms).
|
||||
func play_ui(id: String, volume_db_offset: float = 0.0) -> AudioStreamPlayer:
|
||||
var entry = _library.get(id)
|
||||
if entry == null:
|
||||
return null
|
||||
var player := _pool_2d[_next_2d]
|
||||
_next_2d = (_next_2d + 1) % POOL_SIZE_2D
|
||||
player.stream = entry["streams"].pick_random()
|
||||
player.bus = "UI"
|
||||
player.volume_db = entry["volume_db"] + volume_db_offset
|
||||
player.pitch_scale = randf_range(1.0 - entry["pitch_var"], 1.0 + entry["pitch_var"])
|
||||
player.play()
|
||||
return player
|
||||
@@ -71,7 +71,9 @@ func _ready() -> void:
|
||||
|
||||
|
||||
func _physics_process(delta: float) -> void:
|
||||
if player and not player.multiplayer.is_server(): return
|
||||
# Client-authoritative movement: only the peer that OWNS this player
|
||||
# simulates it. Everyone else interpolates the synced transform.
|
||||
if player and not player.is_multiplayer_authority(): return
|
||||
|
||||
# Update coyote time
|
||||
if on_ground:
|
||||
|
||||
@@ -59,10 +59,24 @@ var grapple_rope: MeshInstance3D
|
||||
# ── Multiplayer Sync Variables ─────────────────────────────────────────────
|
||||
# These are written by the authority each frame and replicated to remote peers
|
||||
# via the MultiplayerSynchronizer properties.
|
||||
# Movement is CLIENT-AUTHORITATIVE: the owning peer simulates locally and
|
||||
# broadcasts position/velocity; remote peers interpolate toward it (see
|
||||
# _process). The server stays authoritative for health, shield, and kills.
|
||||
var synced_movement_state: String = "idle"
|
||||
var synced_movement_speed: float = 0.0
|
||||
var synced_is_crouching: bool = false
|
||||
var synced_position: Vector3 = Vector3.ZERO
|
||||
var synced_velocity: Vector3 = Vector3.ZERO
|
||||
@export var synced_skin_id: String = ""
|
||||
@export var synced_weapon_path: String = ""
|
||||
|
||||
# Remote interpolation tuning
|
||||
const NET_LERP_RATE := 18.0 # exponential smoothing rate for remote players
|
||||
const NET_SNAP_DISTANCE := 6.0 # teleport if desync exceeds this (respawns etc.)
|
||||
const NET_EXTRAPOLATION := 0.05 # seconds of velocity extrapolation
|
||||
|
||||
var skinned_model: Node3D = null
|
||||
var _applied_skin_id: String = "__none__"
|
||||
@export var synced_grapple_point: Vector3 = Vector3.ZERO
|
||||
@export var synced_is_grapple_shooting: bool = false
|
||||
|
||||
@@ -114,6 +128,54 @@ func _ready() -> void:
|
||||
set_process(true)
|
||||
if is_multiplayer_authority():
|
||||
set_physics_process(true)
|
||||
var skin_mgr = get_node_or_null("/root/SkinManager")
|
||||
if skin_mgr:
|
||||
synced_skin_id = skin_mgr.active_skin_id
|
||||
synced_position = position
|
||||
# Build the visual model for whatever skin is selected (remote peers get
|
||||
# the id via the synchronizer and rebuild in _process when it arrives).
|
||||
_apply_skin_model(synced_skin_id)
|
||||
|
||||
## Returns the node that visually represents this player (skinned GLB model
|
||||
## if the active skin has one, otherwise the procedural HumanoidModel).
|
||||
func get_visual_model() -> Node3D:
|
||||
if is_instance_valid(skinned_model):
|
||||
return skinned_model
|
||||
return get_node_or_null("HumanoidModel")
|
||||
|
||||
## Swap the visual model to match a skin id. GLB skins replace the procedural
|
||||
## model entirely; color skins tint the procedural model.
|
||||
func _apply_skin_model(skin_id: String) -> void:
|
||||
_applied_skin_id = skin_id
|
||||
var humanoid = get_node_or_null("HumanoidModel")
|
||||
var old = get_node_or_null("SkinnedModel")
|
||||
if old:
|
||||
remove_child(old)
|
||||
old.queue_free()
|
||||
skinned_model = null
|
||||
|
||||
var skin_mgr = get_node_or_null("/root/SkinManager")
|
||||
var skin = skin_mgr.get_skin(skin_id) if skin_mgr else null
|
||||
var has_model: bool = skin != null and skin.model_path != "" \
|
||||
and (ResourceLoader.exists(skin.model_path) or FileAccess.file_exists(skin.model_path))
|
||||
|
||||
if has_model:
|
||||
var model := SkinnedPlayerModel.new()
|
||||
model.name = "SkinnedModel"
|
||||
model.model_path = skin.model_path
|
||||
# Owner gets the first-person body view (head hidden, fully animated);
|
||||
# everyone else sees the full third-person model.
|
||||
model.first_person_mode = is_multiplayer_authority()
|
||||
model.position = Vector3(0, -0.9, 0) # capsule center -> feet
|
||||
add_child(model)
|
||||
skinned_model = model
|
||||
if humanoid:
|
||||
humanoid.visible = false
|
||||
else:
|
||||
if humanoid:
|
||||
humanoid.visible = not is_dead
|
||||
if skin and humanoid.has_method("_apply_color"):
|
||||
humanoid._apply_color(skin.color_tint)
|
||||
|
||||
func _hide_remote_weapons() -> void:
|
||||
if camera:
|
||||
@@ -304,7 +366,19 @@ func server_apply_impulse(force: Vector3) -> void:
|
||||
if not multiplayer.is_server(): return
|
||||
var sender = multiplayer.get_remote_sender_id()
|
||||
if sender != 1 and sender != str(name).to_int(): return
|
||||
apply_impulse(force)
|
||||
# Movement is client-authoritative, so the impulse must land on the peer
|
||||
# that simulates this body.
|
||||
if is_multiplayer_authority():
|
||||
apply_impulse(force)
|
||||
else:
|
||||
authority_apply_impulse.rpc_id(get_multiplayer_authority(), force)
|
||||
|
||||
@rpc("any_peer", "call_local", "reliable")
|
||||
func authority_apply_impulse(force: Vector3) -> void:
|
||||
if multiplayer.has_multiplayer_peer() and not multiplayer.multiplayer_peer is OfflineMultiplayerPeer:
|
||||
if multiplayer.get_remote_sender_id() != 1: return
|
||||
if is_multiplayer_authority():
|
||||
apply_impulse(force)
|
||||
|
||||
func take_damage(amount: float, _hit_pos: Vector3, _source: Node3D = null, impulse: Vector3 = Vector3.ZERO) -> void:
|
||||
var attacker_id = 0
|
||||
@@ -509,10 +583,9 @@ func rpc_play_explosion(pos: Vector3, radius: float) -> void:
|
||||
func server_take_damage(amount: float, hit_pos: Vector3, attacker_id: int, weapon_name: String, impulse: Vector3) -> void:
|
||||
if not multiplayer.is_server(): return
|
||||
|
||||
if impulse.length_squared() > 0.01:
|
||||
apply_impulse(impulse)
|
||||
|
||||
# Broadcast damage event to all peers so the victim dies on all screens
|
||||
# Broadcast damage event to all peers so the victim dies on all screens.
|
||||
# Knockback is applied inside rpc_take_damage on the victim's own peer,
|
||||
# since that peer simulates this body.
|
||||
rpc_take_damage.rpc(amount, hit_pos, attacker_id, weapon_name, impulse)
|
||||
|
||||
@rpc("any_peer", "call_local", "reliable")
|
||||
@@ -538,6 +611,10 @@ func rpc_take_damage(amount: float, hit_pos: Vector3, attacker_id: int, weapon_n
|
||||
if attacker_id != 0:
|
||||
recent_attackers[attacker_id] = Time.get_ticks_msec() / 1000.0
|
||||
|
||||
# Knockback lands on the simulating peer (movement is client-authoritative)
|
||||
if is_multiplayer_authority() and impulse.length_squared() > 0.01:
|
||||
apply_impulse(impulse)
|
||||
|
||||
time_since_last_damage = 0.0
|
||||
|
||||
if shield > 0.0:
|
||||
@@ -647,32 +724,9 @@ func _ensure_machine() -> MovementStateMachine:
|
||||
_machine = null
|
||||
return null
|
||||
|
||||
var _client_input_dir: Vector2 = Vector2.ZERO
|
||||
var _client_wish_dir_world: Vector3 = Vector3.ZERO
|
||||
var _client_jump: bool = false
|
||||
var _client_jump_just_pressed: bool = false
|
||||
var _client_crouch: bool = false
|
||||
var _client_dash: bool = false
|
||||
var _client_grapple: bool = false
|
||||
var _client_grapple_just_pressed: bool = false
|
||||
|
||||
@rpc("any_peer", "call_local", "unreliable")
|
||||
func server_receive_inputs(input_dir: Vector2, wish_dir: Vector3, jump: bool, jump_just: bool, crouch: bool, dash: bool, grapple: bool, grapple_just: bool) -> void:
|
||||
if not multiplayer.is_server(): return
|
||||
var sender = multiplayer.get_remote_sender_id()
|
||||
if sender != str(name).to_int() and sender != 1: return # Accept from owner or host self
|
||||
|
||||
_client_input_dir = input_dir
|
||||
_client_wish_dir_world = wish_dir
|
||||
_client_jump = jump
|
||||
_client_jump_just_pressed = jump_just
|
||||
_client_crouch = crouch
|
||||
_client_dash = dash
|
||||
_client_grapple = grapple
|
||||
_client_grapple_just_pressed = grapple_just
|
||||
|
||||
func _physics_process(_delta: float) -> void:
|
||||
# Local client captures input and sends it
|
||||
# The owning peer reads input and feeds its own state machine directly.
|
||||
# No server round-trip: movement responds on the same frame it's pressed.
|
||||
if is_multiplayer_authority():
|
||||
var raw_input := Vector2.ZERO
|
||||
var input_jump := false
|
||||
@@ -706,7 +760,16 @@ func _physics_process(_delta: float) -> void:
|
||||
if Input.is_action_just_pressed("toggle_flashlight") and is_instance_valid(flashlight):
|
||||
flashlight.visible = !flashlight.visible
|
||||
|
||||
server_receive_inputs.rpc_id(1, raw_input, world_dir, input_jump, input_jump_just, input_crouch, input_dash, input_grapple, input_grapple_just)
|
||||
var machine := _ensure_machine()
|
||||
if machine:
|
||||
machine.input_dir = raw_input
|
||||
machine.wish_dir_world = world_dir
|
||||
machine.input_jump_pressed = input_jump
|
||||
machine.input_jump_just_pressed = input_jump_just
|
||||
machine.input_crouch = input_crouch
|
||||
machine.input_dash = input_dash
|
||||
machine.input_grapple = input_grapple
|
||||
machine.input_grapple_just_pressed = input_grapple_just
|
||||
|
||||
var speed = velocity.length()
|
||||
var wind_factor = clampf((speed - 10.0) / 25.0, 0.0, 1.0)
|
||||
@@ -718,23 +781,8 @@ func _physics_process(_delta: float) -> void:
|
||||
if not sm:
|
||||
return
|
||||
|
||||
# Only the Server sets input values to the StateMachine for evaluation
|
||||
if multiplayer.is_server():
|
||||
sm.input_dir = _client_input_dir
|
||||
sm.wish_dir_world = _client_wish_dir_world
|
||||
sm.input_jump_pressed = _client_jump
|
||||
sm.input_jump_just_pressed = _client_jump_just_pressed
|
||||
sm.input_crouch = _client_crouch
|
||||
sm.input_dash = _client_dash
|
||||
sm.input_grapple = _client_grapple
|
||||
sm.input_grapple_just_pressed = _client_grapple_just_pressed
|
||||
|
||||
# Reset one-frame actions
|
||||
_client_jump_just_pressed = false
|
||||
_client_dash = false
|
||||
_client_grapple_just_pressed = false
|
||||
|
||||
# Update synced properties for the grapple
|
||||
# The owning peer publishes its grapple state for everyone's rope visuals
|
||||
if is_multiplayer_authority():
|
||||
synced_grapple_point = sm.grapple_point
|
||||
synced_is_grapple_shooting = sm.is_grapple_shooting
|
||||
|
||||
@@ -761,18 +809,18 @@ func _physics_process(_delta: float) -> void:
|
||||
else:
|
||||
grapple_swing_player.volume_db = lerpf(grapple_swing_player.volume_db, -80.0, _delta * 15.0)
|
||||
|
||||
# Update humanoid model animation state (runs everywhere)
|
||||
|
||||
# Update humanoid model animation state
|
||||
var humanoid = get_node_or_null("HumanoidModel")
|
||||
if humanoid:
|
||||
# Drive the visual model (skinned GLB or procedural) from local state
|
||||
var visual = get_visual_model()
|
||||
if visual:
|
||||
var h_speed = Vector2(velocity.x, velocity.z).length()
|
||||
humanoid.update_state(sm.current_state, h_speed, sm.input_crouch)
|
||||
visual.update_state(sm.current_state, h_speed, sm.input_crouch)
|
||||
|
||||
# Write synced state for remote peers
|
||||
# Publish state for remote peers
|
||||
synced_movement_state = sm.current_state
|
||||
synced_movement_speed = Vector2(velocity.x, velocity.z).length()
|
||||
synced_is_crouching = sm.input_crouch
|
||||
synced_position = position
|
||||
synced_velocity = velocity
|
||||
|
||||
|
||||
func _on_movement_event(ev: String, data: Dictionary) -> void:
|
||||
@@ -784,7 +832,8 @@ func _on_movement_event(ev: String, data: Dictionary) -> void:
|
||||
grapple_latch_player.play()
|
||||
|
||||
func _process(delta: float) -> void:
|
||||
# Remote players: apply synced movement state to their HumanoidModel
|
||||
# Remote players: interpolate toward the owner's synced transform and
|
||||
# apply synced movement state to their visual model
|
||||
if not is_multiplayer_authority():
|
||||
if synced_loadout_ready and not has_meta("remote_weapons_built"):
|
||||
set_meta("remote_weapons_built", true)
|
||||
@@ -792,13 +841,28 @@ func _process(delta: float) -> void:
|
||||
if wman and wman.has_method("_build_remote_loadout"):
|
||||
wman._build_remote_loadout(synced_loadout_p1, synced_loadout_p2, synced_loadout_sp, synced_loadout_melee)
|
||||
|
||||
var humanoid = get_node_or_null("HumanoidModel")
|
||||
if humanoid:
|
||||
humanoid.update_state(synced_movement_state, synced_movement_speed, synced_is_crouching)
|
||||
# Skin can arrive/change after spawn — rebuild the model when it does
|
||||
if synced_skin_id != _applied_skin_id:
|
||||
_apply_skin_model(synced_skin_id)
|
||||
|
||||
# Snapshot interpolation: chase the owner's last known position with a
|
||||
# little velocity extrapolation so fast targets stay accurate between
|
||||
# packets. Snap on big desyncs (respawn/teleport).
|
||||
if not is_dead and synced_position != Vector3.ZERO:
|
||||
var target := synced_position + synced_velocity * NET_EXTRAPOLATION
|
||||
if position.distance_to(target) > NET_SNAP_DISTANCE:
|
||||
position = target
|
||||
else:
|
||||
position = position.lerp(target, 1.0 - exp(-NET_LERP_RATE * delta))
|
||||
velocity = synced_velocity
|
||||
|
||||
var visual = get_visual_model()
|
||||
if visual:
|
||||
visual.update_state(synced_movement_state, synced_movement_speed, synced_is_crouching)
|
||||
# Check for weapon changes
|
||||
if synced_weapon_path != "" and synced_weapon_path != humanoid.get_meta("current_weapon_path", ""):
|
||||
humanoid.set_weapon(synced_weapon_path)
|
||||
humanoid.set_meta("current_weapon_path", synced_weapon_path)
|
||||
if synced_weapon_path != "" and synced_weapon_path != visual.get_meta("current_weapon_path", ""):
|
||||
visual.set_weapon(synced_weapon_path)
|
||||
visual.set_meta("current_weapon_path", synced_weapon_path)
|
||||
return
|
||||
|
||||
# Update UI
|
||||
@@ -1217,14 +1281,21 @@ func rpc_respawn(spawn_pos: Vector3) -> void:
|
||||
if col:
|
||||
col.set_deferred("disabled", false)
|
||||
|
||||
var visual_model = get_node_or_null("HumanoidModel")
|
||||
var visual_model = get_visual_model()
|
||||
if visual_model:
|
||||
visual_model.visible = true
|
||||
var humanoid_model = get_node_or_null("HumanoidModel")
|
||||
if humanoid_model and humanoid_model != visual_model:
|
||||
humanoid_model.visible = not is_instance_valid(skinned_model)
|
||||
|
||||
# Server sets the actual position to sync to everyone
|
||||
if multiplayer.is_server() or not multiplayer.has_multiplayer_peer() or multiplayer.multiplayer_peer is OfflineMultiplayerPeer:
|
||||
position = spawn_pos
|
||||
velocity = Vector3.ZERO
|
||||
# Everyone applies the broadcast spawn position; the owning peer also
|
||||
# resets its synced transform so remotes snap instead of lerping across
|
||||
# the map.
|
||||
position = spawn_pos
|
||||
velocity = Vector3.ZERO
|
||||
if is_multiplayer_authority():
|
||||
synced_position = spawn_pos
|
||||
synced_velocity = Vector3.ZERO
|
||||
|
||||
# Local client resets UI and rebuilds weapons for their view
|
||||
if is_multiplayer_authority():
|
||||
|
||||
@@ -12,7 +12,7 @@ config_version=5
|
||||
|
||||
config/name="Papaya-Shooter"
|
||||
run/main_scene="res://ui/main_menu/main_menu.tscn"
|
||||
config/features=PackedStringArray("4.6", "Forward Plus")
|
||||
config/features=PackedStringArray("4.7", "Forward Plus")
|
||||
config/icon="res://icon.svg"
|
||||
|
||||
[autoload]
|
||||
@@ -21,6 +21,8 @@ SettingsManager="*res://globals/settings_manager.gd"
|
||||
LoadoutManager="*res://globals/loadout_manager.gd"
|
||||
PauseMenu="*res://ui/pause_menu.gd"
|
||||
NetworkManager="*res://globals/network_manager.gd"
|
||||
SkinManager="*res://characters/skin_manager.gd"
|
||||
AudioManager="*res://globals/audio_manager.gd"
|
||||
|
||||
[display]
|
||||
|
||||
|
||||
@@ -92,30 +92,34 @@ func _spawn_player(pid: int) -> CharacterBody3D:
|
||||
# Dynamic Spawning
|
||||
player.position = _get_dynamic_spawn_position()
|
||||
|
||||
# Server Synchronizer (Host is the ground truth)
|
||||
# Server Synchronizer — host-owned gameplay state (health, kills, death)
|
||||
var server_sync = MultiplayerSynchronizer.new()
|
||||
server_sync.name = "ServerSynchronizer"
|
||||
server_sync.set_multiplayer_authority(1) # Host always controls these
|
||||
var server_rep_config = SceneReplicationConfig.new()
|
||||
server_rep_config.add_property(":position")
|
||||
server_rep_config.add_property(":synced_movement_state")
|
||||
server_rep_config.add_property(":synced_movement_speed")
|
||||
server_rep_config.add_property(":synced_is_crouching")
|
||||
server_rep_config.add_property(":health")
|
||||
server_rep_config.add_property(":shield")
|
||||
server_rep_config.add_property(":is_dead")
|
||||
server_rep_config.add_property(":synced_grapple_point")
|
||||
server_rep_config.add_property(":synced_is_grapple_shooting")
|
||||
server_sync.replication_config = server_rep_config
|
||||
player.add_child(server_sync)
|
||||
|
||||
# Client Synchronizer (Client dictates their aim and loadout setup)
|
||||
# Client Synchronizer — owner-simulated movement, aim, animation state.
|
||||
# Movement is client-authoritative for instant response; remote peers
|
||||
# interpolate synced_position/velocity (see PlayerMovementController).
|
||||
var client_sync = MultiplayerSynchronizer.new()
|
||||
client_sync.name = "MultiplayerSynchronizer"
|
||||
client_sync.set_multiplayer_authority(pid)
|
||||
var client_rep_config = SceneReplicationConfig.new()
|
||||
client_rep_config.add_property(":synced_position")
|
||||
client_rep_config.add_property(":synced_velocity")
|
||||
client_rep_config.add_property(":rotation")
|
||||
client_rep_config.add_property("HeadPivot:rotation")
|
||||
client_rep_config.add_property(":synced_movement_state")
|
||||
client_rep_config.add_property(":synced_movement_speed")
|
||||
client_rep_config.add_property(":synced_is_crouching")
|
||||
client_rep_config.add_property(":synced_grapple_point")
|
||||
client_rep_config.add_property(":synced_is_grapple_shooting")
|
||||
client_rep_config.add_property(":synced_skin_id")
|
||||
client_rep_config.add_property(":synced_weapon_path")
|
||||
client_rep_config.add_property(":synced_loadout_p1")
|
||||
client_rep_config.add_property(":synced_loadout_p2")
|
||||
|
||||
@@ -63,15 +63,9 @@ func _spawn_player(pid: int) -> CharacterBody3D:
|
||||
server_sync.name = "ServerSynchronizer"
|
||||
server_sync.set_multiplayer_authority(1) # Host always controls these
|
||||
var server_rep_config = SceneReplicationConfig.new()
|
||||
server_rep_config.add_property(":position")
|
||||
server_rep_config.add_property(":synced_movement_state")
|
||||
server_rep_config.add_property(":synced_movement_speed")
|
||||
server_rep_config.add_property(":synced_is_crouching")
|
||||
server_rep_config.add_property(":health")
|
||||
server_rep_config.add_property(":shield")
|
||||
server_rep_config.add_property(":is_dead")
|
||||
server_rep_config.add_property(":synced_grapple_point")
|
||||
server_rep_config.add_property(":synced_is_grapple_shooting")
|
||||
server_sync.replication_config = server_rep_config
|
||||
player.add_child(server_sync)
|
||||
|
||||
@@ -80,8 +74,16 @@ func _spawn_player(pid: int) -> CharacterBody3D:
|
||||
client_sync.name = "MultiplayerSynchronizer"
|
||||
client_sync.set_multiplayer_authority(pid)
|
||||
var client_rep_config = SceneReplicationConfig.new()
|
||||
client_rep_config.add_property(":synced_position")
|
||||
client_rep_config.add_property(":synced_velocity")
|
||||
client_rep_config.add_property(":rotation")
|
||||
client_rep_config.add_property("HeadPivot:rotation")
|
||||
client_rep_config.add_property(":synced_movement_state")
|
||||
client_rep_config.add_property(":synced_movement_speed")
|
||||
client_rep_config.add_property(":synced_is_crouching")
|
||||
client_rep_config.add_property(":synced_grapple_point")
|
||||
client_rep_config.add_property(":synced_is_grapple_shooting")
|
||||
client_rep_config.add_property(":synced_skin_id")
|
||||
client_rep_config.add_property(":synced_weapon_path")
|
||||
client_rep_config.add_property(":synced_loadout_p1")
|
||||
client_rep_config.add_property(":synced_loadout_p2")
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Headless Blender auto-rigger for Papaya-Shooter.
|
||||
|
||||
Takes an UNRIGGED humanoid mesh (GLB/glTF/FBX/OBJ), fits a Mixamo-compatible
|
||||
skeleton to it, binds with automatic weights, and exports a rigged GLB.
|
||||
|
||||
Because the output skeleton uses standard Mixamo bone names, ANY Mixamo
|
||||
animation (or CC0 pack retargeted to Mixamo names) can be merged onto the
|
||||
result with tools/merge_animations.py — rig once, reuse every animation.
|
||||
|
||||
Usage:
|
||||
blender --background --python tools/autorig.py -- <input> <output.glb> [target_height]
|
||||
|
||||
If the input already contains an armature, it is kept as-is (bones are only
|
||||
renamed to Mixamo convention when obvious matches exist) and the mesh is
|
||||
re-exported normalized. Use tools/merge_animations.py next either way.
|
||||
|
||||
Heuristics assume a roughly upright humanoid in T-pose or A-pose. For models
|
||||
that fail (extreme proportions, non-humanoids), use an external auto-rigger
|
||||
(Mixamo web, AccuRig, Tripo/UniRig) and feed the rigged GLB straight to
|
||||
merge_animations.py instead.
|
||||
"""
|
||||
import bpy
|
||||
import sys
|
||||
import os
|
||||
from mathutils import Vector
|
||||
|
||||
argv = sys.argv
|
||||
argv = argv[argv.index("--") + 1:] if "--" in argv else []
|
||||
if len(argv) < 2:
|
||||
print("Usage: blender --background --python tools/autorig.py -- <input> <output.glb> [target_height]")
|
||||
sys.exit(1)
|
||||
|
||||
INPUT = argv[0]
|
||||
OUTPUT = argv[1]
|
||||
TARGET_HEIGHT = float(argv[2]) if len(argv) > 2 else 1.75
|
||||
|
||||
# Mixamo bone names Godot/our animation library expects.
|
||||
MIX = "mixamorig:"
|
||||
|
||||
# Common bone-name aliases -> Mixamo names, used when a rig already exists.
|
||||
BONE_ALIASES = {
|
||||
"hips": "Hips", "pelvis": "Hips",
|
||||
"spine": "Spine", "spine1": "Spine1", "spine2": "Spine2",
|
||||
"chest": "Spine1", "upperchest": "Spine2",
|
||||
"neck": "Neck", "head": "Head",
|
||||
"leftshoulder": "LeftShoulder", "rightshoulder": "RightShoulder",
|
||||
"leftarm": "LeftArm", "leftupperarm": "LeftArm",
|
||||
"rightarm": "RightArm", "rightupperarm": "RightArm",
|
||||
"leftforearm": "LeftForeArm", "leftlowerarm": "LeftForeArm",
|
||||
"rightforearm": "RightForeArm", "rightlowerarm": "RightForeArm",
|
||||
"lefthand": "LeftHand", "righthand": "RightHand",
|
||||
"leftupleg": "LeftUpLeg", "leftupperleg": "LeftUpLeg", "leftthigh": "LeftUpLeg",
|
||||
"rightupleg": "RightUpLeg", "rightupperleg": "RightUpLeg", "rightthigh": "RightUpLeg",
|
||||
"leftleg": "LeftLeg", "leftlowerleg": "LeftLeg", "leftshin": "LeftLeg", "leftcalf": "LeftLeg",
|
||||
"rightleg": "RightLeg", "rightlowerleg": "RightLeg", "rightshin": "RightLeg", "rightcalf": "RightLeg",
|
||||
"leftfoot": "LeftFoot", "rightfoot": "RightFoot",
|
||||
"lefttoebase": "LeftToeBase", "lefttoe": "LeftToeBase",
|
||||
"righttoebase": "RightToeBase", "righttoe": "RightToeBase",
|
||||
}
|
||||
|
||||
|
||||
def clear_scene():
|
||||
bpy.ops.object.select_all(action="SELECT")
|
||||
bpy.ops.object.delete()
|
||||
for block_list in (bpy.data.meshes, bpy.data.armatures, bpy.data.materials, bpy.data.actions):
|
||||
for block in list(block_list):
|
||||
if block.users == 0:
|
||||
block_list.remove(block)
|
||||
|
||||
|
||||
def import_model(path):
|
||||
ext = os.path.splitext(path)[1].lower()
|
||||
if ext in (".glb", ".gltf"):
|
||||
bpy.ops.import_scene.gltf(filepath=path)
|
||||
elif ext == ".fbx":
|
||||
bpy.ops.import_scene.fbx(filepath=path)
|
||||
elif ext == ".obj":
|
||||
if hasattr(bpy.ops.wm, "obj_import"):
|
||||
bpy.ops.wm.obj_import(filepath=path)
|
||||
else:
|
||||
bpy.ops.import_scene.obj(filepath=path)
|
||||
else:
|
||||
print(f"ERROR: unsupported format {ext}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def normalize_alias(name):
|
||||
key = name.lower().replace("mixamorig:", "").replace("_", "").replace(" ", "").replace(".", "")
|
||||
key = key.replace("l_", "left").replace("r_", "right")
|
||||
return BONE_ALIASES.get(key)
|
||||
|
||||
|
||||
def rename_existing_rig(arm):
|
||||
renamed = 0
|
||||
for bone in arm.data.bones:
|
||||
target = normalize_alias(bone.name)
|
||||
if target and not bone.name.startswith(MIX):
|
||||
bone.name = MIX + target
|
||||
renamed += 1
|
||||
print(f"Renamed {renamed} bones to Mixamo convention")
|
||||
|
||||
|
||||
def mesh_slice_width(obj, y_frac, height, min_z):
|
||||
"""Max |x| of vertices within a thin horizontal slice at y_frac of height."""
|
||||
z_lo = min_z + height * (y_frac - 0.03)
|
||||
z_hi = min_z + height * (y_frac + 0.03)
|
||||
max_x = 0.0
|
||||
mat = obj.matrix_world
|
||||
for v in obj.data.vertices:
|
||||
co = mat @ v.co
|
||||
if z_lo <= co.z <= z_hi:
|
||||
max_x = max(max_x, abs(co.x))
|
||||
return max_x
|
||||
|
||||
|
||||
def build_mixamo_armature(height, min_z, shoulder_w, hip_w):
|
||||
"""Create a Mixamo-named humanoid armature fitted to the mesh bounds.
|
||||
|
||||
Proportions are standard humanoid ratios of total height; good enough for
|
||||
automatic weights on typical game characters.
|
||||
"""
|
||||
bpy.ops.object.armature_add(enter_editmode=True, location=(0, 0, 0))
|
||||
arm = bpy.context.active_object
|
||||
arm.name = "Armature"
|
||||
eb = arm.data.edit_bones
|
||||
for b in list(eb):
|
||||
eb.remove(b)
|
||||
|
||||
def z(frac):
|
||||
return min_z + height * frac
|
||||
|
||||
def add(name, head, tail, parent=None, connect=False):
|
||||
b = eb.new(MIX + name)
|
||||
b.head = Vector(head)
|
||||
b.tail = Vector(tail)
|
||||
if parent:
|
||||
b.parent = eb[MIX + parent]
|
||||
b.use_connect = connect
|
||||
return b
|
||||
|
||||
sw = shoulder_w * 0.75 # shoulder joint x
|
||||
hw = max(hip_w * 0.45, height * 0.055) # hip joint x
|
||||
|
||||
add("Hips", (0, 0, z(0.53)), (0, 0, z(0.58)))
|
||||
add("Spine", (0, 0, z(0.58)), (0, 0, z(0.66)), "Hips", True)
|
||||
add("Spine1", (0, 0, z(0.66)), (0, 0, z(0.74)), "Spine", True)
|
||||
add("Spine2", (0, 0, z(0.74)), (0, 0, z(0.82)), "Spine1", True)
|
||||
add("Neck", (0, 0, z(0.82)), (0, 0, z(0.87)), "Spine2", True)
|
||||
add("Head", (0, 0, z(0.87)), (0, 0, z(1.00)), "Neck", True)
|
||||
|
||||
for side, sx in (("Left", 1), ("Right", -1)):
|
||||
add(f"{side}Shoulder", (sx * sw * 0.25, 0, z(0.80)), (sx * sw, 0, z(0.80)), "Spine2")
|
||||
# Arms along +/-X (T-pose-ish); automatic weights tolerate A-pose meshes.
|
||||
arm_len = height * 0.16
|
||||
add(f"{side}Arm", (sx * sw, 0, z(0.80)), (sx * (sw + arm_len), 0, z(0.78)), f"{side}Shoulder", True)
|
||||
add(f"{side}ForeArm", (sx * (sw + arm_len), 0, z(0.78)), (sx * (sw + arm_len * 2), 0, z(0.76)), f"{side}Arm", True)
|
||||
add(f"{side}Hand", (sx * (sw + arm_len * 2), 0, z(0.76)), (sx * (sw + arm_len * 2.4), 0, z(0.75)), f"{side}ForeArm", True)
|
||||
|
||||
add(f"{side}UpLeg", (sx * hw, 0, z(0.52)), (sx * hw, 0, z(0.29)), "Hips")
|
||||
add(f"{side}Leg", (sx * hw, 0, z(0.29)), (sx * hw, 0, z(0.06)), f"{side}UpLeg", True)
|
||||
add(f"{side}Foot", (sx * hw, 0, z(0.06)), (sx * hw, -height * 0.08, z(0.015)), f"{side}Leg", True)
|
||||
add(f"{side}ToeBase", (sx * hw, -height * 0.08, z(0.015)), (sx * hw, -height * 0.13, z(0.015)), f"{side}Foot", True)
|
||||
|
||||
bpy.ops.object.mode_set(mode="OBJECT")
|
||||
return arm
|
||||
|
||||
|
||||
def main():
|
||||
clear_scene()
|
||||
print(f"Importing {INPUT}...")
|
||||
import_model(INPUT)
|
||||
|
||||
meshes = [o for o in bpy.data.objects if o.type == "MESH"]
|
||||
armatures = [o for o in bpy.data.objects if o.type == "ARMATURE"]
|
||||
if not meshes:
|
||||
print("ERROR: no mesh in input")
|
||||
sys.exit(1)
|
||||
|
||||
# Join meshes so weights/normalization apply uniformly.
|
||||
bpy.ops.object.select_all(action="DESELECT")
|
||||
for m in meshes:
|
||||
m.select_set(True)
|
||||
bpy.context.view_layer.objects.active = meshes[0]
|
||||
if len(meshes) > 1:
|
||||
bpy.ops.object.join()
|
||||
mesh = bpy.context.active_object
|
||||
bpy.ops.object.transform_apply(location=True, rotation=True, scale=True)
|
||||
|
||||
if armatures:
|
||||
print("Input already rigged — keeping existing skeleton, renaming bones")
|
||||
rename_existing_rig(armatures[0])
|
||||
else:
|
||||
# Normalize: feet on ground, centered, target height.
|
||||
bb = [mesh.matrix_world @ Vector(c) for c in mesh.bound_box]
|
||||
min_z = min(c.z for c in bb)
|
||||
max_z = max(c.z for c in bb)
|
||||
cur_h = max_z - min_z
|
||||
if cur_h <= 0:
|
||||
print("ERROR: degenerate mesh bounds")
|
||||
sys.exit(1)
|
||||
s = TARGET_HEIGHT / cur_h
|
||||
mesh.scale = (s, s, s)
|
||||
bpy.ops.object.transform_apply(scale=True)
|
||||
bb = [mesh.matrix_world @ Vector(c) for c in mesh.bound_box]
|
||||
min_z = min(c.z for c in bb)
|
||||
cx = (min(c.x for c in bb) + max(c.x for c in bb)) / 2
|
||||
cy = (min(c.y for c in bb) + max(c.y for c in bb)) / 2
|
||||
mesh.location.x -= cx
|
||||
mesh.location.y -= cy
|
||||
mesh.location.z -= min_z
|
||||
bpy.ops.object.transform_apply(location=True)
|
||||
min_z = 0.0
|
||||
height = TARGET_HEIGHT
|
||||
|
||||
shoulder_w = mesh_slice_width(mesh, 0.80, height, min_z)
|
||||
hip_w = mesh_slice_width(mesh, 0.53, height, min_z)
|
||||
print(f"Fitted: height={height:.2f} shoulder_w={shoulder_w:.2f} hip_w={hip_w:.2f}")
|
||||
|
||||
arm = build_mixamo_armature(height, min_z, shoulder_w, hip_w)
|
||||
|
||||
# Bind with automatic weights.
|
||||
bpy.ops.object.select_all(action="DESELECT")
|
||||
mesh.select_set(True)
|
||||
arm.select_set(True)
|
||||
bpy.context.view_layer.objects.active = arm
|
||||
bpy.ops.object.parent_set(type="ARMATURE_AUTO")
|
||||
print("Bound mesh with automatic weights")
|
||||
|
||||
print(f"Exporting {OUTPUT}...")
|
||||
os.makedirs(os.path.dirname(os.path.abspath(OUTPUT)), exist_ok=True)
|
||||
bpy.ops.export_scene.gltf(
|
||||
filepath=OUTPUT,
|
||||
export_format="GLB",
|
||||
export_yup=True,
|
||||
export_apply=True,
|
||||
export_animations=True,
|
||||
export_skins=True,
|
||||
)
|
||||
print("Done.")
|
||||
|
||||
|
||||
main()
|
||||
@@ -0,0 +1,221 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Headless Blender animation merger for Papaya-Shooter.
|
||||
|
||||
Merges every animation clip from the shared animation library onto a rigged
|
||||
character (Mixamo-compatible bone names) and exports one game-ready GLB whose
|
||||
animations use the game's canonical clip names (Idle, Walk, Run, Jump, ...).
|
||||
|
||||
Animation library: a directory of FBX/GLB files, ONE clip per file, all using
|
||||
the Mixamo skeleton. Download once from Mixamo ("without skin") or use a CC0
|
||||
pack — see docs/ASSET_SOURCES.md. Filenames map to canonical clip names:
|
||||
idle.fbx -> Idle
|
||||
run.fbx -> Run
|
||||
crouch_walk.fbx -> CrouchWalk
|
||||
|
||||
Usage:
|
||||
blender --background --python tools/merge_animations.py -- \
|
||||
<rigged_character.glb> <animations_dir> <output.glb> [--keep-root-motion]
|
||||
|
||||
Locomotion clips are exported in place (hips horizontal motion stripped)
|
||||
unless --keep-root-motion is given, since the gameplay code moves the body.
|
||||
"""
|
||||
import bpy
|
||||
import sys
|
||||
import os
|
||||
|
||||
argv = sys.argv
|
||||
argv = argv[argv.index("--") + 1:] if "--" in argv else []
|
||||
if len(argv) < 3:
|
||||
print("Usage: blender --background --python tools/merge_animations.py -- "
|
||||
"<character.glb> <animations_dir> <output.glb> [--keep-root-motion]")
|
||||
sys.exit(1)
|
||||
|
||||
CHARACTER = argv[0]
|
||||
ANIM_DIR = argv[1]
|
||||
OUTPUT = argv[2]
|
||||
STRIP_ROOT_MOTION = "--keep-root-motion" not in argv
|
||||
|
||||
MIX = "mixamorig:"
|
||||
|
||||
|
||||
def clear_scene():
|
||||
bpy.ops.object.select_all(action="SELECT")
|
||||
bpy.ops.object.delete()
|
||||
|
||||
|
||||
def to_pascal(stem):
|
||||
return "".join(part.capitalize() for part in stem.replace("-", "_").split("_"))
|
||||
|
||||
|
||||
def find_armature(objects):
|
||||
for o in objects:
|
||||
if o.type == "ARMATURE":
|
||||
return o
|
||||
return None
|
||||
|
||||
|
||||
def normalize_prefix(name):
|
||||
"""mixamorig1:Hips / mixamorig_Hips / Hips -> mixamorig:Hips"""
|
||||
base = name
|
||||
for i in range(10):
|
||||
base = base.replace(f"mixamorig{i}:", MIX)
|
||||
base = base.replace("mixamorig_", MIX)
|
||||
if ":" not in base and base in CORE_BONES:
|
||||
base = MIX + base
|
||||
return base
|
||||
|
||||
|
||||
CORE_BONES = {
|
||||
"Hips", "Spine", "Spine1", "Spine2", "Neck", "Head",
|
||||
"LeftShoulder", "LeftArm", "LeftForeArm", "LeftHand",
|
||||
"RightShoulder", "RightArm", "RightForeArm", "RightHand",
|
||||
"LeftUpLeg", "LeftLeg", "LeftFoot", "LeftToeBase",
|
||||
"RightUpLeg", "RightLeg", "RightFoot", "RightToeBase",
|
||||
}
|
||||
|
||||
|
||||
def normalize_action_paths(action):
|
||||
for fc in action.fcurves:
|
||||
if 'pose.bones["' in fc.data_path:
|
||||
start = fc.data_path.index('"') + 1
|
||||
end = fc.data_path.index('"', start)
|
||||
bone = fc.data_path[start:end]
|
||||
fixed = normalize_prefix(bone)
|
||||
if fixed != bone:
|
||||
fc.data_path = fc.data_path[:start] + fixed + fc.data_path[end:]
|
||||
|
||||
|
||||
def hips_height(arm):
|
||||
for b in arm.data.bones:
|
||||
if b.name.endswith("Hips"):
|
||||
return (arm.matrix_world @ b.head_local).z
|
||||
return 1.0
|
||||
|
||||
|
||||
def scale_location_curves(action, ratio):
|
||||
if abs(ratio - 1.0) < 0.01:
|
||||
return
|
||||
for fc in action.fcurves:
|
||||
if fc.data_path.endswith(".location"):
|
||||
for kp in fc.keyframe_points:
|
||||
kp.co.y *= ratio
|
||||
kp.handle_left.y *= ratio
|
||||
kp.handle_right.y *= ratio
|
||||
|
||||
|
||||
def strip_hips_horizontal(action):
|
||||
"""Zero hips X (side) and Y (forward) location so clips play in place.
|
||||
|
||||
Blender bone-local axes for a Y-up-exported Mixamo hips: X = side,
|
||||
Y = up (bone axis), Z = forward. We keep Y (vertical bob) and zero X/Z.
|
||||
"""
|
||||
for fc in action.fcurves:
|
||||
if fc.data_path.endswith('.location') and "Hips" in fc.data_path and fc.array_index in (0, 2):
|
||||
for kp in fc.keyframe_points:
|
||||
kp.co.y = 0.0
|
||||
kp.handle_left.y = 0.0
|
||||
kp.handle_right.y = 0.0
|
||||
|
||||
|
||||
def main():
|
||||
clear_scene()
|
||||
print(f"Importing character {CHARACTER}...")
|
||||
bpy.ops.import_scene.gltf(filepath=CHARACTER)
|
||||
target_arm = find_armature(bpy.data.objects)
|
||||
if not target_arm:
|
||||
print("ERROR: character has no armature — run tools/autorig.py first")
|
||||
sys.exit(1)
|
||||
|
||||
# Normalize character bone names too.
|
||||
for bone in target_arm.data.bones:
|
||||
fixed = normalize_prefix(bone.name)
|
||||
if fixed != bone.name:
|
||||
bone.name = fixed
|
||||
|
||||
target_hips = hips_height(target_arm)
|
||||
print(f"Character hips height: {target_hips:.3f}")
|
||||
|
||||
if not target_arm.animation_data:
|
||||
target_arm.animation_data_create()
|
||||
|
||||
# Drop any pre-existing animation so only canonical clips ship.
|
||||
for track in list(target_arm.animation_data.nla_tracks):
|
||||
target_arm.animation_data.nla_tracks.remove(track)
|
||||
target_arm.animation_data.action = None
|
||||
|
||||
anim_files = sorted(
|
||||
f for f in os.listdir(ANIM_DIR)
|
||||
if f.lower().endswith((".fbx", ".glb", ".gltf")) and not f.startswith(".")
|
||||
)
|
||||
if not anim_files:
|
||||
print(f"ERROR: no animation files in {ANIM_DIR}")
|
||||
sys.exit(1)
|
||||
|
||||
merged = 0
|
||||
for fname in anim_files:
|
||||
clip_name = to_pascal(os.path.splitext(fname)[0])
|
||||
path = os.path.join(ANIM_DIR, fname)
|
||||
print(f"--- {fname} -> '{clip_name}'")
|
||||
|
||||
before = set(bpy.data.objects)
|
||||
before_actions = set(bpy.data.actions)
|
||||
try:
|
||||
if fname.lower().endswith(".fbx"):
|
||||
bpy.ops.import_scene.fbx(filepath=path, ignore_leaf_bones=True)
|
||||
else:
|
||||
bpy.ops.import_scene.gltf(filepath=path)
|
||||
except Exception as e:
|
||||
print(f" SKIP: import failed: {e}")
|
||||
continue
|
||||
|
||||
new_objects = [o for o in bpy.data.objects if o not in before]
|
||||
new_actions = [a for a in bpy.data.actions if a not in before_actions]
|
||||
src_arm = find_armature(new_objects)
|
||||
|
||||
if not new_actions:
|
||||
print(" SKIP: no action found in file")
|
||||
else:
|
||||
action = max(new_actions, key=lambda a: len(a.fcurves))
|
||||
action.name = clip_name
|
||||
normalize_action_paths(action)
|
||||
if src_arm:
|
||||
ratio = target_hips / max(hips_height(src_arm), 1e-6)
|
||||
scale_location_curves(action, ratio)
|
||||
if STRIP_ROOT_MOTION:
|
||||
strip_hips_horizontal(action)
|
||||
|
||||
track = target_arm.animation_data.nla_tracks.new()
|
||||
track.name = clip_name
|
||||
strip = track.strips.new(clip_name, 0, action)
|
||||
strip.name = clip_name
|
||||
track.mute = True
|
||||
action.use_fake_user = True
|
||||
merged += 1
|
||||
|
||||
# Remove the imported helper objects (keep the action).
|
||||
for o in new_objects:
|
||||
bpy.data.objects.remove(o, do_unlink=True)
|
||||
for a in new_actions:
|
||||
if a.name != clip_name:
|
||||
bpy.data.actions.remove(a)
|
||||
|
||||
if merged == 0:
|
||||
print("ERROR: no animations merged")
|
||||
sys.exit(1)
|
||||
|
||||
print(f"Merged {merged} clips. Exporting {OUTPUT}...")
|
||||
os.makedirs(os.path.dirname(os.path.abspath(OUTPUT)), exist_ok=True)
|
||||
bpy.ops.export_scene.gltf(
|
||||
filepath=OUTPUT,
|
||||
export_format="GLB",
|
||||
export_yup=True,
|
||||
export_animations=True,
|
||||
export_animation_mode="NLA_TRACKS",
|
||||
export_skins=True,
|
||||
export_bake_animation=True,
|
||||
)
|
||||
print("Done.")
|
||||
|
||||
|
||||
main()
|
||||
@@ -0,0 +1,171 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
One-command character pipeline for Papaya-Shooter:
|
||||
|
||||
Sketchfab model -> auto-rig -> merge animation library -> in game.
|
||||
|
||||
Examples:
|
||||
# From a Sketchfab UID (needs SKETCHFAB_API_TOKEN):
|
||||
python tools/pipeline.py --uid a1b2c3... --name space_marine
|
||||
|
||||
# From a local unrigged mesh:
|
||||
python tools/pipeline.py --input downloads/robot.glb --name robot
|
||||
|
||||
# From an ALREADY-RIGGED model (Mixamo/AccuRig/Tripo output) — skips autorig:
|
||||
python tools/pipeline.py --input rigged/knight.fbx --name knight --rigged
|
||||
|
||||
The result is assets/characters/skins/<name>.glb with the full canonical
|
||||
animation set, plus a registry entry in assets/characters/skins/skins.json
|
||||
that SkinManager picks up automatically — no code changes needed.
|
||||
|
||||
Requires Blender 3.6+ on PATH or BLENDER_PATH set.
|
||||
Animation library: assets/characters/animations/ (see docs/ASSET_SOURCES.md).
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
TOOLS = os.path.join(PROJECT_ROOT, "tools")
|
||||
SKINS_DIR = os.path.join(PROJECT_ROOT, "assets", "characters", "skins")
|
||||
ANIM_DIR = os.path.join(PROJECT_ROOT, "assets", "characters", "animations")
|
||||
STAGING = os.path.join(PROJECT_ROOT, "assets", "characters", "incoming")
|
||||
SKINS_JSON = os.path.join(SKINS_DIR, "skins.json")
|
||||
|
||||
|
||||
def find_blender() -> str:
|
||||
env = os.environ.get("BLENDER_PATH")
|
||||
if env and os.path.exists(env):
|
||||
return env
|
||||
on_path = shutil.which("blender")
|
||||
if on_path:
|
||||
return on_path
|
||||
candidates = []
|
||||
for pf in (r"C:\Program Files\Blender Foundation", r"C:\Program Files (x86)\Blender Foundation"):
|
||||
if os.path.isdir(pf):
|
||||
for d in sorted(os.listdir(pf), reverse=True):
|
||||
exe = os.path.join(pf, d, "blender.exe")
|
||||
if os.path.exists(exe):
|
||||
candidates.append(exe)
|
||||
if candidates:
|
||||
return candidates[0]
|
||||
print("ERROR: Blender not found. Install Blender or set BLENDER_PATH to blender.exe")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def run(cmd: list[str], step: str) -> None:
|
||||
print(f"\n=== {step} ===")
|
||||
print(" ".join(f'"{c}"' if " " in c else c for c in cmd))
|
||||
result = subprocess.run(cmd)
|
||||
if result.returncode != 0:
|
||||
print(f"ERROR: step '{step}' failed (exit {result.returncode})")
|
||||
sys.exit(result.returncode)
|
||||
|
||||
|
||||
def register_skin(name: str, display_name: str, model_res_path: str, description: str) -> None:
|
||||
registry = {"skins": []}
|
||||
if os.path.exists(SKINS_JSON):
|
||||
with open(SKINS_JSON, "r", encoding="utf-8") as f:
|
||||
registry = json.load(f)
|
||||
skins = registry.setdefault("skins", [])
|
||||
entry = {
|
||||
"id": name,
|
||||
"name": display_name,
|
||||
"description": description,
|
||||
"model": model_res_path,
|
||||
"unlocked": True,
|
||||
}
|
||||
for i, s in enumerate(skins):
|
||||
if s.get("id") == name:
|
||||
skins[i] = entry
|
||||
break
|
||||
else:
|
||||
skins.append(entry)
|
||||
os.makedirs(SKINS_DIR, exist_ok=True)
|
||||
with open(SKINS_JSON, "w", encoding="utf-8") as f:
|
||||
json.dump(registry, f, indent=2)
|
||||
print(f"Registered skin '{name}' in {os.path.relpath(SKINS_JSON, PROJECT_ROOT)}")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
p = argparse.ArgumentParser(description="Sketchfab -> rigged, animated, in-game character")
|
||||
src = p.add_mutually_exclusive_group(required=True)
|
||||
src.add_argument("--uid", help="Sketchfab model UID to download")
|
||||
src.add_argument("--input", help="local model file (glb/gltf/fbx/obj)")
|
||||
p.add_argument("--name", required=True, help="skin id (snake_case)")
|
||||
p.add_argument("--display-name", help="name shown in menus (default: from --name)")
|
||||
p.add_argument("--description", default="", help="skin description")
|
||||
p.add_argument("--rigged", action="store_true", help="input is already rigged — skip autorig")
|
||||
p.add_argument("--height", type=float, default=1.75, help="target character height in meters")
|
||||
p.add_argument("--keep-root-motion", action="store_true", help="don't strip hips motion from clips")
|
||||
p.add_argument("--anim-dir", default=ANIM_DIR, help="animation library directory")
|
||||
args = p.parse_args()
|
||||
|
||||
name = args.name
|
||||
display_name = args.display_name or name.replace("_", " ").title()
|
||||
os.makedirs(STAGING, exist_ok=True)
|
||||
|
||||
# 1. Acquire the model.
|
||||
if args.uid:
|
||||
run([sys.executable, os.path.join(TOOLS, "sketchfab_import.py"),
|
||||
"download", args.uid, "--name", name, "--out", STAGING],
|
||||
"Download from Sketchfab")
|
||||
input_path = os.path.join(STAGING, f"{name}.glb")
|
||||
if not os.path.exists(input_path):
|
||||
# glTF-zip fallback path used by the importer
|
||||
alt = os.path.join(STAGING, name)
|
||||
gltfs = [f for f in os.listdir(alt) if f.endswith((".gltf", ".glb"))] if os.path.isdir(alt) else []
|
||||
if not gltfs:
|
||||
print("ERROR: downloaded model not found in staging dir")
|
||||
sys.exit(1)
|
||||
input_path = os.path.join(alt, gltfs[0])
|
||||
else:
|
||||
input_path = os.path.abspath(args.input)
|
||||
if not os.path.exists(input_path):
|
||||
print(f"ERROR: input not found: {input_path}")
|
||||
sys.exit(1)
|
||||
|
||||
blender = find_blender()
|
||||
print(f"Using Blender: {blender}")
|
||||
|
||||
# 2. Auto-rig (or pass through if already rigged).
|
||||
rigged_path = os.path.join(STAGING, f"{name}_rigged.glb")
|
||||
if args.rigged:
|
||||
rigged_path = input_path
|
||||
print("Skipping autorig (--rigged)")
|
||||
else:
|
||||
run([blender, "--background", "--python", os.path.join(TOOLS, "autorig.py"),
|
||||
"--", input_path, rigged_path, str(args.height)],
|
||||
"Auto-rig (Blender)")
|
||||
|
||||
# 3. Merge the shared animation library.
|
||||
if not os.path.isdir(args.anim_dir) or not any(
|
||||
f.lower().endswith((".fbx", ".glb", ".gltf")) for f in os.listdir(args.anim_dir)):
|
||||
print(f"ERROR: animation library is empty: {args.anim_dir}")
|
||||
print("Fill it with one clip per file (idle.fbx, run.fbx, ...) — see docs/ASSET_SOURCES.md")
|
||||
sys.exit(1)
|
||||
|
||||
final_path = os.path.join(SKINS_DIR, f"{name}.glb")
|
||||
merge_cmd = [blender, "--background", "--python", os.path.join(TOOLS, "merge_animations.py"),
|
||||
"--", rigged_path, args.anim_dir, final_path]
|
||||
if args.keep_root_motion:
|
||||
merge_cmd.append("--keep-root-motion")
|
||||
run(merge_cmd, "Merge animation library (Blender)")
|
||||
|
||||
# 4. Carry the license file along if the model came from Sketchfab.
|
||||
lic_src = os.path.splitext(input_path)[0] + ".license.json"
|
||||
if os.path.exists(lic_src):
|
||||
shutil.copyfile(lic_src, os.path.join(SKINS_DIR, f"{name}.license.json"))
|
||||
|
||||
# 5. Register so the game sees it.
|
||||
register_skin(name, display_name, f"res://assets/characters/skins/{name}.glb", args.description)
|
||||
|
||||
print(f"\nDONE: {os.path.relpath(final_path, PROJECT_ROOT)}")
|
||||
print(f"'{display_name}' is now selectable in-game (SkinManager reads skins.json).")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,195 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Sketchfab model importer for Papaya-Shooter.
|
||||
|
||||
Searches and downloads models via the official Sketchfab Download API,
|
||||
extracts the GLB/glTF, and stages it for the rigging pipeline.
|
||||
|
||||
Auth (any one of):
|
||||
- env var SKETCHFAB_API_TOKEN
|
||||
- --token <token>
|
||||
- a file named .sketchfab_token in the project root (gitignored)
|
||||
|
||||
Usage:
|
||||
python tools/sketchfab_import.py search "anime character" [--limit 10]
|
||||
python tools/sketchfab_import.py download <model-uid> [--out assets/characters/incoming]
|
||||
python tools/sketchfab_import.py info <model-uid>
|
||||
|
||||
Notes:
|
||||
- Only models marked "downloadable" can be fetched (license permitting).
|
||||
- License + attribution metadata is saved next to the model as <name>.license.json.
|
||||
CC-BY requires crediting the author — keep those files!
|
||||
"""
|
||||
import argparse
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
import zipfile
|
||||
|
||||
API_BASE = "https://api.sketchfab.com/v3"
|
||||
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
DEFAULT_OUT = os.path.join(PROJECT_ROOT, "assets", "characters", "incoming")
|
||||
|
||||
|
||||
def get_token(cli_token: str | None) -> str:
|
||||
if cli_token:
|
||||
return cli_token
|
||||
tok = os.environ.get("SKETCHFAB_API_TOKEN", "").strip()
|
||||
if tok:
|
||||
return tok
|
||||
token_file = os.path.join(PROJECT_ROOT, ".sketchfab_token")
|
||||
if os.path.exists(token_file):
|
||||
with open(token_file, "r", encoding="utf-8") as f:
|
||||
tok = f.read().strip()
|
||||
if tok:
|
||||
return tok
|
||||
print("ERROR: no Sketchfab API token found.")
|
||||
print("Set SKETCHFAB_API_TOKEN, pass --token, or create .sketchfab_token in the project root.")
|
||||
print("Your token: https://sketchfab.com/settings/password (API token section)")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def api_get(path: str, token: str, params: dict | None = None) -> dict:
|
||||
url = API_BASE + path
|
||||
if params:
|
||||
url += "?" + urllib.parse.urlencode(params)
|
||||
req = urllib.request.Request(url, headers={"Authorization": f"Token {token}"})
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=60) as resp:
|
||||
return json.loads(resp.read().decode("utf-8"))
|
||||
except urllib.error.HTTPError as e:
|
||||
body = e.read().decode("utf-8", errors="replace")[:500]
|
||||
print(f"ERROR: API request failed ({e.code}) for {url}\n{body}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def slugify(name: str) -> str:
|
||||
slug = re.sub(r"[^a-z0-9]+", "_", name.lower()).strip("_")
|
||||
return slug or "model"
|
||||
|
||||
|
||||
def cmd_search(args) -> None:
|
||||
token = get_token(args.token)
|
||||
params = {
|
||||
"type": "models",
|
||||
"q": args.query,
|
||||
"downloadable": "true",
|
||||
"count": str(args.limit),
|
||||
"sort_by": "-likeCount",
|
||||
}
|
||||
if args.animated:
|
||||
params["animated"] = "true"
|
||||
if args.rigged:
|
||||
params["rigged"] = "true"
|
||||
data = api_get("/search", token, params)
|
||||
results = data.get("results", [])
|
||||
if not results:
|
||||
print("No downloadable results.")
|
||||
return
|
||||
print(f"{'UID':<34} {'License':<18} {'Faces':>9} Name")
|
||||
print("-" * 90)
|
||||
for m in results:
|
||||
lic = (m.get("license") or {}).get("label", "?")
|
||||
faces = m.get("faceCount", 0)
|
||||
print(f"{m['uid']:<34} {lic:<18} {faces:>9} {m['name']}")
|
||||
print("\nDownload one with: python tools/sketchfab_import.py download <UID>")
|
||||
|
||||
|
||||
def cmd_info(args) -> None:
|
||||
token = get_token(args.token)
|
||||
m = api_get(f"/models/{args.uid}", token)
|
||||
lic = m.get("license") or {}
|
||||
print(f"Name: {m.get('name')}")
|
||||
print(f"Author: {(m.get('user') or {}).get('displayName')}")
|
||||
print(f"License: {lic.get('label')} ({lic.get('slug')})")
|
||||
print(f"Downloadable:{m.get('isDownloadable')}")
|
||||
print(f"Faces: {m.get('faceCount')} Verts: {m.get('vertexCount')}")
|
||||
print(f"Animations: {m.get('animationCount')}")
|
||||
print(f"URL: {m.get('viewerUrl')}")
|
||||
|
||||
|
||||
def cmd_download(args) -> None:
|
||||
token = get_token(args.token)
|
||||
meta = api_get(f"/models/{args.uid}", token)
|
||||
name = args.name or slugify(meta.get("name", args.uid))
|
||||
out_dir = os.path.abspath(args.out)
|
||||
os.makedirs(out_dir, exist_ok=True)
|
||||
|
||||
dl = api_get(f"/models/{args.uid}/download", token)
|
||||
|
||||
# Prefer the single-file GLB; fall back to the glTF zip.
|
||||
glb_path = os.path.join(out_dir, f"{name}.glb")
|
||||
if "glb" in dl and dl["glb"].get("url"):
|
||||
print(f"Downloading GLB ({dl['glb'].get('size', 0) / 1e6:.1f} MB)...")
|
||||
urllib.request.urlretrieve(dl["glb"]["url"], glb_path)
|
||||
elif "gltf" in dl and dl["gltf"].get("url"):
|
||||
print(f"Downloading glTF zip ({dl['gltf'].get('size', 0) / 1e6:.1f} MB)...")
|
||||
with urllib.request.urlopen(dl["gltf"]["url"], timeout=300) as resp:
|
||||
zdata = resp.read()
|
||||
extract_dir = os.path.join(out_dir, name)
|
||||
os.makedirs(extract_dir, exist_ok=True)
|
||||
with zipfile.ZipFile(io.BytesIO(zdata)) as zf:
|
||||
zf.extractall(extract_dir)
|
||||
gltfs = [f for f in os.listdir(extract_dir) if f.endswith((".gltf", ".glb"))]
|
||||
if not gltfs:
|
||||
print(f"ERROR: no .gltf/.glb found in archive at {extract_dir}")
|
||||
sys.exit(1)
|
||||
glb_path = os.path.join(extract_dir, gltfs[0])
|
||||
else:
|
||||
print("ERROR: model has no downloadable GLB/glTF archive.")
|
||||
sys.exit(1)
|
||||
|
||||
# Save license/attribution metadata — required for CC-BY credit.
|
||||
lic = meta.get("license") or {}
|
||||
license_info = {
|
||||
"name": meta.get("name"),
|
||||
"uid": args.uid,
|
||||
"author": (meta.get("user") or {}).get("displayName"),
|
||||
"author_url": (meta.get("user") or {}).get("profileUrl"),
|
||||
"license": lic.get("label"),
|
||||
"license_slug": lic.get("slug"),
|
||||
"source_url": meta.get("viewerUrl"),
|
||||
}
|
||||
license_path = os.path.splitext(glb_path)[0] + ".license.json"
|
||||
with open(license_path, "w", encoding="utf-8") as f:
|
||||
json.dump(license_info, f, indent=2)
|
||||
|
||||
print(f"Saved: {glb_path}")
|
||||
print(f"License: {license_path} ({lic.get('label')})")
|
||||
print(f"\nNext step (auto-rig + animations + register in game):")
|
||||
print(f" python tools/pipeline.py --input \"{glb_path}\" --name {name}")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
p = argparse.ArgumentParser(description="Sketchfab importer")
|
||||
p.add_argument("--token", help="Sketchfab API token (else SKETCHFAB_API_TOKEN)")
|
||||
sub = p.add_subparsers(dest="cmd", required=True)
|
||||
|
||||
s = sub.add_parser("search", help="search downloadable models")
|
||||
s.add_argument("query")
|
||||
s.add_argument("--limit", type=int, default=10)
|
||||
s.add_argument("--animated", action="store_true", help="only models with animations")
|
||||
s.add_argument("--rigged", action="store_true", help="only rigged models")
|
||||
s.set_defaults(func=cmd_search)
|
||||
|
||||
i = sub.add_parser("info", help="show model metadata")
|
||||
i.add_argument("uid")
|
||||
i.set_defaults(func=cmd_info)
|
||||
|
||||
d = sub.add_parser("download", help="download a model by UID")
|
||||
d.add_argument("uid")
|
||||
d.add_argument("--out", default=DEFAULT_OUT)
|
||||
d.add_argument("--name", help="output base name (default: slugified model name)")
|
||||
d.set_defaults(func=cmd_download)
|
||||
|
||||
args = p.parse_args()
|
||||
# Propagate the global --token even when given after the subcommand.
|
||||
args.func(args)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -61,6 +61,9 @@ func _ready() -> void:
|
||||
_add_button(_ui_vbox, "Settings", _on_settings_pressed)
|
||||
_add_button(_ui_vbox, "Exit Game", _on_exit_pressed)
|
||||
|
||||
# Skin selector (skins come from SkinManager: built-ins + skins.json)
|
||||
_build_skin_selector(_ui_vbox)
|
||||
|
||||
# ==========================================
|
||||
# LEVEL SELECTOR PANEL
|
||||
# ==========================================
|
||||
@@ -355,6 +358,34 @@ func _add_button(parent: Container, text: String, callback: Callable) -> void:
|
||||
btn.pressed.connect(callback)
|
||||
parent.add_child(btn)
|
||||
|
||||
func _build_skin_selector(parent: Container) -> void:
|
||||
var skin_mgr = get_node_or_null("/root/SkinManager")
|
||||
if not skin_mgr:
|
||||
return
|
||||
var row = HBoxContainer.new()
|
||||
row.add_theme_constant_override("separation", 12)
|
||||
parent.add_child(row)
|
||||
|
||||
var lbl = Label.new()
|
||||
lbl.text = "Skin"
|
||||
lbl.add_theme_font_size_override("font_size", 24)
|
||||
row.add_child(lbl)
|
||||
|
||||
var opt = OptionButton.new()
|
||||
opt.custom_minimum_size = Vector2(240, 44)
|
||||
var ids: Array = skin_mgr.get_skin_ids()
|
||||
ids.sort()
|
||||
for i in range(ids.size()):
|
||||
var skin = skin_mgr.get_skin(ids[i])
|
||||
opt.add_item(skin.skin_name, i)
|
||||
opt.set_item_metadata(i, ids[i])
|
||||
if ids[i] == skin_mgr.active_skin_id:
|
||||
opt.select(i)
|
||||
opt.item_selected.connect(func(idx: int):
|
||||
skin_mgr.set_active_skin(opt.get_item_metadata(idx))
|
||||
)
|
||||
row.add_child(opt)
|
||||
|
||||
func _on_back_pressed() -> void:
|
||||
_level_selector_panel.hide()
|
||||
_ui_vbox.show()
|
||||
|
||||
@@ -313,11 +313,11 @@ func _equip_slot(slot: int) -> void:
|
||||
w.visible = true
|
||||
w.set_process_input(true)
|
||||
|
||||
# Sync 3rd person weapon
|
||||
if player and player.has_node("HumanoidModel"):
|
||||
var humanoid = player.get_node("HumanoidModel")
|
||||
if humanoid.has_method("set_weapon") and w.has_meta("script_path"):
|
||||
humanoid.set_weapon(w.get_meta("script_path"))
|
||||
# Sync 3rd person weapon on the visual model (skinned or procedural)
|
||||
if player and w.has_meta("script_path"):
|
||||
var visual = player.get_visual_model() if player.has_method("get_visual_model") else player.get_node_or_null("HumanoidModel")
|
||||
if visual and visual.has_method("set_weapon"):
|
||||
visual.set_weapon(w.get_meta("script_path"))
|
||||
# Update the synced variable so remote peers pick it up
|
||||
if "synced_weapon_path" in player:
|
||||
player.synced_weapon_path = w.get_meta("script_path")
|
||||
|
||||