feat: implement modular character skinning system with procedural and imported model support

This commit is contained in:
DottsGit
2026-06-11 23:05:36 -04:00
parent 3bdf8f413b
commit 523fcc5226
14 changed files with 587 additions and 15 deletions
+71
View File
@@ -0,0 +1,71 @@
extends Resource
class_name CharacterSkin
## Data-driven character appearance resource.
## Supports both procedural (box) models and imported GLTF/GLB models.
## Create .tres files to add new skins with zero code changes.
# ── Identity ──────────────────────────────────────────────────────────────────
@export var skin_id: String = "default"
@export var display_name: String = "Default"
# ── Mode ──────────────────────────────────────────────────────────────────────
## "procedural" = built-in box humanoid, "model" = imported GLTF/GLB scene
@export_enum("procedural", "model") var skin_type: String = "procedural"
# ── Procedural Mode (box humanoid) ────────────────────────────────────────────
@export_group("Procedural Appearance")
@export var primary_color: Color = Color(0.2, 0.4, 0.8)
@export var accent_color: Color = Color(0.15, 0.3, 0.6)
@export var emission_color: Color = Color.BLACK
@export var emission_energy: float = 0.0
@export var roughness: float = 0.8
@export var metallic: float = 0.0
# ── Model Mode (Sketchfab GLTF/GLB) ──────────────────────────────────────────
@export_group("Imported Model")
## Path to the imported scene (.glb, .gltf, .tscn)
@export var model_scene_path: String = ""
## Path to a Mixamo AnimationLibrary resource for retargeted animations
@export var animation_library_path: String = ""
## Godot SkeletonProfile name for retargeting (e.g. "SkeletonProfileHumanoid")
@export var skeleton_profile: String = ""
@export var model_scale: Vector3 = Vector3.ONE
@export var model_offset: Vector3 = Vector3.ZERO
# ── Shared Attachments & Effects ──────────────────────────────────────────────
@export_group("Attachments & Effects")
## Optional path to a hat/accessory scene
@export var hat_scene_path: String = ""
@export var trail_enabled: bool = false
@export var trail_color: Color = Color(0.5, 0.5, 1.0, 0.5)
# ── Helpers ───────────────────────────────────────────────────────────────────
## Creates a StandardMaterial3D from this skin's procedural properties.
func create_material() -> StandardMaterial3D:
var mat := StandardMaterial3D.new()
mat.albedo_color = primary_color
mat.roughness = roughness
mat.metallic = metallic
if emission_color != Color.BLACK and emission_energy > 0.0:
mat.emission_enabled = true
mat.emission = emission_color
mat.emission_energy_multiplier = emission_energy
return mat
## Creates a material for accent parts (limbs, etc.)
func create_accent_material() -> StandardMaterial3D:
var mat := StandardMaterial3D.new()
mat.albedo_color = accent_color
mat.roughness = roughness
mat.metallic = metallic
if emission_color != Color.BLACK and emission_energy > 0.0:
mat.emission_enabled = true
mat.emission = emission_color
mat.emission_energy_multiplier = emission_energy * 0.5
return mat
## Returns true if this skin uses an imported 3D model.
func is_model_skin() -> bool:
return skin_type == "model" and model_scene_path != ""
+1
View File
@@ -0,0 +1 @@
uid://cnf5axdmy05i1
+161 -5
View File
@@ -1,10 +1,10 @@
extends Node3D
class_name HumanoidModel
@export var color: Color = Color.WHITE
@export var skin: CharacterSkin
@export var shadows_only: bool = false
# Internal Pivots
# Internal Pivots (Procedural mode)
var root_pivot: Node3D
var torso: MeshInstance3D
var head_pivot: Node3D
@@ -30,17 +30,112 @@ var thigh_r: MeshInstance3D
var calf_r_pivot: Node3D
var calf_r: MeshInstance3D
# Imported model mode
var _imported_model: Node3D
var _anim_player: AnimationPlayer
# Animation State Variables
var current_state: String = "idle"
var movement_speed: float = 0.0
var _anim_time: float = 0.0
# Legacy compat: allow setting color directly (falls back to creating a skin)
var color: Color = Color(0.2, 0.4, 0.8):
set(value):
color = value
if not skin:
skin = CharacterSkin.new()
skin.primary_color = value
skin.accent_color = value.darkened(0.2)
func _ready() -> void:
rotation_degrees.y = 180
var mat := StandardMaterial3D.new()
mat.albedo_color = color
mat.roughness = 0.8
# If no skin was set, try to get one from SkinManager or create from legacy color
if not skin:
if Engine.has_singleton("SkinManager") or get_node_or_null("/root/SkinManager"):
var sm = get_node_or_null("/root/SkinManager")
if sm:
skin = sm.get_player_skin()
if not skin:
skin = CharacterSkin.new()
skin.primary_color = color
skin.accent_color = color.darkened(0.2)
if skin.is_model_skin():
_build_imported_model()
else:
_build_procedural_model()
func apply_skin(new_skin: CharacterSkin) -> void:
skin = new_skin
if skin.is_model_skin():
# Clear procedural model if present
if root_pivot:
root_pivot.queue_free()
root_pivot = null
if _imported_model:
_imported_model.queue_free()
_imported_model = null
_build_imported_model()
else:
# Clear imported model if present
if _imported_model:
_imported_model.queue_free()
_imported_model = null
if root_pivot:
# Just update materials on existing procedural model
_update_procedural_materials()
else:
_build_procedural_model()
# ── Imported Model (GLTF/GLB from Sketchfab) ─────────────────────────────────
func _build_imported_model() -> void:
var scene = load(skin.model_scene_path)
if not scene:
push_warning("Failed to load model scene: " + skin.model_scene_path)
_build_procedural_model()
return
_imported_model = scene.instantiate()
_imported_model.scale = skin.model_scale
_imported_model.position = skin.model_offset
add_child(_imported_model)
# Find AnimationPlayer in the imported model
_anim_player = _find_node_of_type(_imported_model, "AnimationPlayer") as AnimationPlayer
# Load additional Mixamo animation library if specified
if skin.animation_library_path != "":
var lib = load(skin.animation_library_path)
if lib and _anim_player:
_anim_player.add_animation_library("mixamo", lib)
# Apply shadow setting to all meshes in the imported model
if shadows_only:
_set_shadow_mode_recursive(_imported_model)
func _find_node_of_type(root: Node, type_name: String) -> Node:
if root.get_class() == type_name:
return root
for child in root.get_children():
var result = _find_node_of_type(child, type_name)
if result:
return result
return null
func _set_shadow_mode_recursive(node: Node) -> void:
if node is GeometryInstance3D:
node.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_SHADOWS_ONLY
for child in node.get_children():
_set_shadow_mode_recursive(child)
# ── Procedural Model (box humanoid) ──────────────────────────────────────────
func _build_procedural_model() -> void:
var mat := skin.create_material()
var shadow_setting = GeometryInstance3D.SHADOW_CASTING_SETTING_SHADOWS_ONLY if shadows_only else GeometryInstance3D.SHADOW_CASTING_SETTING_ON
@@ -163,15 +258,31 @@ func _ready() -> void:
calf_r.cast_shadow = shadow_setting
calf_r_pivot.add_child(calf_r)
func _update_procedural_materials() -> void:
if not skin or not root_pivot:
return
var mat := skin.create_material()
# Update all mesh materials
for mesh_node in [torso, head, upper_arm_l, upper_arm_r, lower_arm_l, lower_arm_r,
thigh_l, thigh_r, calf_l, calf_r]:
if is_instance_valid(mesh_node) and mesh_node.mesh:
mesh_node.set_surface_override_material(0, mat)
func update_state(state: String, speed: float, is_crouching: bool = false) -> void:
current_state = state
movement_speed = speed
if is_crouching and current_state == "ground":
current_state = "crouch"
# For imported models, try to play matching animation
if _imported_model and _anim_player:
_play_imported_animation()
var is_holding_weapon: bool = false
func set_weapon(script_path: String) -> void:
if not root_pivot:
return
# Clear existing weapons from root_pivot
for child in root_pivot.get_children():
if child.has_meta("is_third_person_weapon"):
@@ -212,7 +323,52 @@ func _set_shadows_recursive(node: Node) -> void:
for child in node.get_children():
_set_shadows_recursive(child)
# ── Imported Model Animation ─────────────────────────────────────────────────
func _play_imported_animation() -> void:
if not _anim_player:
return
# Map movement states to common Mixamo animation names
var anim_map = {
"idle": ["Idle", "idle", "Standing Idle"],
"ground": ["Running", "Walking", "Run", "Walk", "running", "walking"],
"crouch": ["Crouching", "Crouch Idle", "crouch"],
"air": ["Falling", "Jump", "falling", "jump"],
"slide": ["Sliding", "Slide", "slide"],
"wall_run": ["Running", "Wall Run", "running"],
"wall_cling": ["Hanging Idle", "Climbing", "hanging"],
"dash": ["Sprint", "Dash", "sprint"],
"grapple": ["Falling", "falling"],
"death": ["Death", "Dying", "death"],
}
var state_key = current_state
if state_key == "ground" and movement_speed <= 1.0:
state_key = "idle"
if anim_map.has(state_key):
for anim_name in anim_map[state_key]:
# Check default library and mixamo library
if _anim_player.has_animation(anim_name):
if _anim_player.current_animation != anim_name:
_anim_player.play(anim_name)
return
var mixamo_name = "mixamo/" + anim_name
if _anim_player.has_animation(mixamo_name):
if _anim_player.current_animation != mixamo_name:
_anim_player.play(mixamo_name)
return
# ── Procedural Animation (unchanged from original) ──────────────────────────
func _process(delta: float) -> void:
# Imported models use AnimationPlayer, skip procedural animation
if _imported_model:
return
if not root_pivot:
return
var anim_speed = 1.0
if current_state == "ground" and movement_speed > 1.0:
anim_speed = movement_speed * 0.4
+12 -4
View File
@@ -3,10 +3,18 @@ class_name ProceduralRagdoll
var torso_body: RigidBody3D
func build_ragdoll(color: Color) -> void:
var mat := StandardMaterial3D.new()
mat.albedo_color = color
mat.roughness = 0.8
func build_ragdoll(skin_or_color = null) -> void:
var mat: StandardMaterial3D
if skin_or_color is CharacterSkin:
mat = skin_or_color.create_material()
elif skin_or_color is Color:
mat = StandardMaterial3D.new()
mat.albedo_color = skin_or_color
mat.roughness = 0.8
else:
mat = StandardMaterial3D.new()
mat.albedo_color = Color(0.2, 0.4, 0.8)
mat.roughness = 0.8
# Create bodies
torso_body = _create_body(Vector3(0.4, 0.7, 0.25), mat, Vector3(0, 1.15, 0), 20.0, "Torso")
+134
View File
@@ -0,0 +1,134 @@
extends Node
## SkinManager — Autoload singleton that manages character skins.
## Registers built-in skins on startup. Provides lookup by skin_id.
## Persists the player's selected skin to user://settings.json via SettingsManager.
var skins: Dictionary = {} # skin_id → CharacterSkin
var selected_skin_id: String = "default"
func _ready() -> void:
_register_builtin_skins()
_load_selected_skin()
# ── Registration ──────────────────────────────────────────────────────────────
func register_skin(skin: CharacterSkin) -> void:
skins[skin.skin_id] = skin
func get_skin(id: String) -> CharacterSkin:
if skins.has(id):
return skins[id]
if skins.has("default"):
return skins["default"]
# Emergency fallback: create a default on the fly
var fallback = CharacterSkin.new()
fallback.skin_id = "default"
fallback.display_name = "Default"
return fallback
func get_player_skin() -> CharacterSkin:
return get_skin(selected_skin_id)
func set_player_skin(id: String) -> void:
if skins.has(id):
selected_skin_id = id
_save_selected_skin()
_apply_skin_to_local_player()
func get_all_skin_ids() -> Array:
return skins.keys()
# ── Built-in Skins ────────────────────────────────────────────────────────────
func _register_builtin_skins() -> void:
_add_skin("default", "Default Blue",
Color(0.2, 0.4, 0.8), Color(0.15, 0.3, 0.6))
_add_skin("crimson", "Crimson",
Color(0.8, 0.15, 0.15), Color(0.6, 0.1, 0.1))
_add_skin("neon_green", "Neon Green",
Color(0.1, 0.9, 0.3), Color(0.05, 0.7, 0.2),
Color(0.1, 1.0, 0.3), 2.0)
_add_skin("gold", "Gold",
Color(0.85, 0.7, 0.2), Color(0.7, 0.55, 0.1),
Color.BLACK, 0.0, 0.3, 0.8)
_add_skin("ghost", "Ghost",
Color(0.9, 0.9, 0.95), Color(0.7, 0.7, 0.8),
Color(0.5, 0.5, 1.0), 0.5, 0.4, 0.1)
_add_skin("lava", "Lava",
Color(0.3, 0.05, 0.0), Color(0.2, 0.02, 0.0),
Color(1.0, 0.3, 0.0), 4.0, 0.9, 0.0)
_add_skin("midnight", "Midnight",
Color(0.08, 0.05, 0.15), Color(0.05, 0.03, 0.1),
Color(0.2, 0.1, 0.5), 1.0)
_add_skin("toxic", "Toxic",
Color(0.3, 0.5, 0.05), Color(0.2, 0.4, 0.02),
Color(0.5, 1.0, 0.0), 3.0)
func _add_skin(id: String, display: String, primary: Color, accent: Color,
emission: Color = Color.BLACK, emission_energy: float = 0.0,
roughness: float = 0.8, metallic: float = 0.0) -> void:
var skin = CharacterSkin.new()
skin.skin_id = id
skin.display_name = display
skin.primary_color = primary
skin.accent_color = accent
skin.emission_color = emission
skin.emission_energy = emission_energy
skin.roughness = roughness
skin.metallic = metallic
register_skin(skin)
# ── Persistence ───────────────────────────────────────────────────────────────
func _save_selected_skin() -> void:
# Piggyback on SettingsManager's save file
var save_path = "user://skin_selection.json"
var data = {"selected_skin_id": selected_skin_id}
var file = FileAccess.open(save_path, FileAccess.WRITE)
if file:
file.store_string(JSON.stringify(data))
file.close()
func _load_selected_skin() -> void:
var save_path = "user://skin_selection.json"
if FileAccess.file_exists(save_path):
var file = FileAccess.open(save_path, FileAccess.READ)
if file:
var json_string = file.get_as_text()
file.close()
var json = JSON.new()
if json.parse(json_string) == OK:
var data = json.get_data()
if data is Dictionary and data.has("selected_skin_id"):
var loaded_id = data["selected_skin_id"]
if skins.has(loaded_id):
selected_skin_id = loaded_id
# ── Live Update ───────────────────────────────────────────────────────────────
func _apply_skin_to_local_player() -> void:
if not get_tree():
return
var pid = multiplayer.get_unique_id()
var players = get_tree().get_nodes_in_group("players")
var local_player = null
for p in players:
if p.name == str(pid):
local_player = p
break
if not local_player:
return
# Update the synced skin ID. The setter on the player handles updating visuals.
if "synced_skin_id" in local_player:
local_player.synced_skin_id = selected_skin_id
+1
View File
@@ -0,0 +1 @@
uid://drls0sqj0oknp