feat(skin): add skin system + Miku character skin

Skin System:
- PlayerSkin resource class (model_path, color_tint, etc.)
- SkinManager with default skins: Default, Red Team, Forest, Miku
- HumanoidModel.apply_skin() supports both GLB model replacement
  and procedural color tinting
- Procedural meshes auto-hide when GLB model is loaded

Miku Skin:
- 43-part procedural model generated in Blender (headless)
- Twin tails, headphones, thigh-high socks, idol outfit
- Teal/cyan color scheme with red accents
- Exported to assets/characters/skins/miku.glb
- Applied to player 1 in test level builder

Pipeline:
- Blender 5.0.1 + MCP addon installed
- Bridge script for socket-based Blender control
- docs/3D_ASSET_PIPELINE.md documents full workflow
This commit is contained in:
2026-06-22 13:53:17 -04:00
parent 671b36c20c
commit a55eaff279
6 changed files with 177 additions and 0 deletions
+43
View File
@@ -163,6 +163,49 @@ func _ready() -> void:
calf_r.cast_shadow = shadow_setting
calf_r_pivot.add_child(calf_r)
## Skin System
var current_skin: PlayerSkin
var skin_model: Node3D
func apply_skin(skin: PlayerSkin) -> void:
current_skin = skin
if skin.model_path != "" and skin.model_path != null:
var loaded = load(skin.model_path)
if loaded:
_set_procedural_visible(false)
if skin_model:
skin_model.queue_free()
skin_model = null
skin_model = loaded.instantiate()
if skin_model:
skin_model.name = "SkinModel"
add_child(skin_model)
print("HumanoidModel: applied skin '%s'" % skin.skin_name)
return
_set_procedural_visible(true)
if skin_model:
skin_model.queue_free()
skin_model = null
_apply_color(skin.color_tint)
func _set_procedural_visible(visible: bool) -> void:
for part in [torso, head, upper_arm_l, lower_arm_l, upper_arm_r, lower_arm_r, thigh_l, calf_l, thigh_r, calf_r]:
if part:
part.visible = visible
func _apply_color(col: Color) -> void:
var mat = StandardMaterial3D.new()
mat.albedo_color = col
mat.roughness = 0.8
for part in [torso, upper_arm_l, lower_arm_l, upper_arm_r, lower_arm_r, thigh_l, calf_l, thigh_r, calf_r]:
if part and part.mesh:
part.mesh.material = mat
if head and head.mesh:
var hmat = StandardMaterial3D.new()
hmat.albedo_color = Color(0.95, 0.85, 0.78)
hmat.roughness = 0.8
head.mesh.material = hmat
func update_state(state: String, speed: float, is_crouching: bool = false) -> void:
current_state = state
movement_speed = speed
+12
View File
@@ -0,0 +1,12 @@
extends Resource
class_name PlayerSkin
## A player skin defines the visual appearance of the character.
## Can be either a color tint (for the default procedural model)
## or a full GLB model replacement.
@export var skin_name: String = "Default"
@export var description: String = ""
@export var model_path: String = "" # Path to .glb file, empty = use procedural
@export var color_tint: Color = Color(0.2, 0.4, 0.8) # For procedural model
@export var is_unlocked: bool = true
+62
View File
@@ -0,0 +1,62 @@
extends Node
class_name SkinManager
## Manages all available player skins.
## Add new skins here or load from a config file.
var skins: Dictionary = {}
var active_skin_id: String = "default"
func _ready() -> void:
_register_default_skins()
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
# 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.glb"
miku_skin.color_tint = Color(0.0, 0.75, 0.75)
miku_skin.is_unlocked = true
skins["miku"] = miku_skin
print("SkinManager: registered %d skins" % skins.size())
for id in skins:
print(" - %s: %s" % [id, skins[id].skin_name])
func get_skin(skin_id: String) -> PlayerSkin:
if skins.has(skin_id):
return skins[skin_id]
return 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)