feat: implement modular character skinning system with procedural and imported model support
This commit is contained in:
@@ -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 != ""
|
||||
@@ -0,0 +1 @@
|
||||
uid://cnf5axdmy05i1
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
uid://drls0sqj0oknp
|
||||
Reference in New Issue
Block a user