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 extends Node3D
class_name HumanoidModel class_name HumanoidModel
@export var color: Color = Color.WHITE @export var skin: CharacterSkin
@export var shadows_only: bool = false @export var shadows_only: bool = false
# Internal Pivots # Internal Pivots (Procedural mode)
var root_pivot: Node3D var root_pivot: Node3D
var torso: MeshInstance3D var torso: MeshInstance3D
var head_pivot: Node3D var head_pivot: Node3D
@@ -30,17 +30,112 @@ var thigh_r: MeshInstance3D
var calf_r_pivot: Node3D var calf_r_pivot: Node3D
var calf_r: MeshInstance3D var calf_r: MeshInstance3D
# Imported model mode
var _imported_model: Node3D
var _anim_player: AnimationPlayer
# Animation State Variables # Animation State Variables
var current_state: String = "idle" var current_state: String = "idle"
var movement_speed: float = 0.0 var movement_speed: float = 0.0
var _anim_time: 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: func _ready() -> void:
rotation_degrees.y = 180 rotation_degrees.y = 180
var mat := StandardMaterial3D.new() # If no skin was set, try to get one from SkinManager or create from legacy color
mat.albedo_color = color if not skin:
mat.roughness = 0.8 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 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.cast_shadow = shadow_setting
calf_r_pivot.add_child(calf_r) 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: func update_state(state: String, speed: float, is_crouching: bool = false) -> void:
current_state = state current_state = state
movement_speed = speed movement_speed = speed
if is_crouching and current_state == "ground": if is_crouching and current_state == "ground":
current_state = "crouch" 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 var is_holding_weapon: bool = false
func set_weapon(script_path: String) -> void: func set_weapon(script_path: String) -> void:
if not root_pivot:
return
# Clear existing weapons from root_pivot # Clear existing weapons from root_pivot
for child in root_pivot.get_children(): for child in root_pivot.get_children():
if child.has_meta("is_third_person_weapon"): 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(): for child in node.get_children():
_set_shadows_recursive(child) _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: 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 var anim_speed = 1.0
if current_state == "ground" and movement_speed > 1.0: if current_state == "ground" and movement_speed > 1.0:
anim_speed = movement_speed * 0.4 anim_speed = movement_speed * 0.4
+11 -3
View File
@@ -3,9 +3,17 @@ class_name ProceduralRagdoll
var torso_body: RigidBody3D var torso_body: RigidBody3D
func build_ragdoll(color: Color) -> void: func build_ragdoll(skin_or_color = null) -> void:
var mat := StandardMaterial3D.new() var mat: StandardMaterial3D
mat.albedo_color = color 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 mat.roughness = 0.8
# Create bodies # Create bodies
+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
+1
View File
@@ -31,6 +31,7 @@ func _build_geometry() -> void:
func _spawn_player(pid: int) -> CharacterBody3D: func _spawn_player(pid: int) -> CharacterBody3D:
var player = super._spawn_player(pid) var player = super._spawn_player(pid)
player.add_to_group("players")
player.position = Vector3(randf_range(-2, 2), 1, 50 + randf_range(-2, 2)) player.position = Vector3(randf_range(-2, 2), 1, 50 + randf_range(-2, 2))
# Looking at (0,1,0) from (0,1,50) is facing straight down the -Z axis, which is rotation = 0,0,0 # Looking at (0,1,0) from (0,1,50) is facing straight down the -Z axis, which is rotation = 0,0,0
player.rotation = Vector3.ZERO player.rotation = Vector3.ZERO
+7 -1
View File
@@ -289,6 +289,7 @@ func _build_lighting() -> void:
func _spawn_player(pid: int) -> CharacterBody3D: func _spawn_player(pid: int) -> CharacterBody3D:
var player := CharacterBody3D.new() var player := CharacterBody3D.new()
player.name = str(pid) player.name = str(pid)
player.add_to_group("players")
player.set_multiplayer_authority(pid) player.set_multiplayer_authority(pid)
# Use a slightly random spawn position to avoid exact overlapping # Use a slightly random spawn position to avoid exact overlapping
@@ -326,6 +327,7 @@ func _spawn_player(pid: int) -> CharacterBody3D:
client_rep_config.add_property(":synced_loadout_ready") client_rep_config.add_property(":synced_loadout_ready")
client_rep_config.add_property(":synced_is_targeting") client_rep_config.add_property(":synced_is_targeting")
client_rep_config.add_property(":synced_homing_target_pos") client_rep_config.add_property(":synced_homing_target_pos")
client_rep_config.add_property(":synced_skin_id")
client_sync.replication_config = client_rep_config client_sync.replication_config = client_rep_config
player.add_child(client_sync) player.add_child(client_sync)
@@ -346,7 +348,11 @@ func _spawn_player(pid: int) -> CharacterBody3D:
# Humanoid Model for Player (Shadows only locally, visible to others) # Humanoid Model for Player (Shadows only locally, visible to others)
var humanoid = load("res://characters/humanoid_model.gd").new() var humanoid = load("res://characters/humanoid_model.gd").new()
humanoid.name = "HumanoidModel" humanoid.name = "HumanoidModel"
humanoid.color = Color(0.2, 0.4, 0.8) # Blueish for player var skin_mgr = get_node_or_null("/root/SkinManager")
if skin_mgr and pid == multiplayer.get_unique_id():
humanoid.skin = skin_mgr.get_player_skin()
else:
humanoid.skin = skin_mgr.get_skin("default") if skin_mgr else null
humanoid.shadows_only = (pid == multiplayer.get_unique_id()) humanoid.shadows_only = (pid == multiplayer.get_unique_id())
humanoid.position = Vector3(0, -0.9, 0) # Offset from center to feet humanoid.position = Vector3(0, -0.9, 0) # Offset from center to feet
player.add_child(humanoid) player.add_child(humanoid)
+43 -1
View File
@@ -75,8 +75,38 @@ var synced_loadout_p2: String = ""
var synced_loadout_sp: String = "" var synced_loadout_sp: String = ""
var synced_loadout_melee: String = "" var synced_loadout_melee: String = ""
var synced_loadout_ready: bool = false var synced_loadout_ready: bool = false
@export var synced_skin_id: String = "default":
set(value):
var old = synced_skin_id
synced_skin_id = value
if old != value and is_node_ready():
_update_skin_visuals()
func _update_skin_visuals() -> void:
var sm = get_node_or_null("/root/SkinManager")
if not sm: return
var skin = sm.get_skin(synced_skin_id)
var humanoid = get_node_or_null("HumanoidModel")
if humanoid and humanoid.has_method("apply_skin"):
humanoid.apply_skin(skin)
var wman = get_node_or_null("HeadPivot/Camera3D/WeaponManager")
if wman and wman.has_method("refresh_arm_colors"):
wman.refresh_arm_colors()
func _ready() -> void: func _ready() -> void:
# Set skin ID from SkinManager on authority
if is_multiplayer_authority():
var sm_node = get_node_or_null("/root/SkinManager")
if sm_node:
synced_skin_id = sm_node.selected_skin_id
if not is_node_ready():
await ready
_update_skin_visuals()
var sm := _ensure_machine() var sm := _ensure_machine()
if sm: if sm:
sm.player = self sm.player = self
@@ -783,7 +813,14 @@ func _on_movement_event(ev: String, data: Dictionary) -> void:
elif ev == "grapple_latch": elif ev == "grapple_latch":
grapple_latch_player.play() grapple_latch_player.play()
var _last_synced_skin_id: String = ""
func _process(delta: float) -> void: func _process(delta: float) -> void:
if synced_skin_id != _last_synced_skin_id:
_last_synced_skin_id = synced_skin_id
if is_node_ready():
_update_skin_visuals()
# Remote players: apply synced movement state to their HumanoidModel # Remote players: apply synced movement state to their HumanoidModel
if not is_multiplayer_authority(): if not is_multiplayer_authority():
if synced_loadout_ready and not has_meta("remote_weapons_built"): if synced_loadout_ready and not has_meta("remote_weapons_built"):
@@ -984,7 +1021,12 @@ func die(impulse: Vector3 = Vector3.ZERO) -> void:
ragdoll_instance = ragdoll ragdoll_instance = ragdoll
get_tree().current_scene.add_child(ragdoll) get_tree().current_scene.add_child(ragdoll)
ragdoll.global_transform = global_transform ragdoll.global_transform = global_transform
ragdoll.build_ragdoll(Color(0.2, 0.4, 0.8)) # Blueish player color # Use skin for ragdoll appearance
var skin_mgr = get_node_or_null("/root/SkinManager")
if skin_mgr:
ragdoll.build_ragdoll(skin_mgr.get_skin(synced_skin_id))
else:
ragdoll.build_ragdoll(Color(0.2, 0.4, 0.8))
# Wait a frame for physics to initialize then apply velocity # Wait a frame for physics to initialize then apply velocity
get_tree().create_timer(0.01).timeout.connect(_apply_ragdoll_velocity.bind(ragdoll, velocity, impulse)) get_tree().create_timer(0.01).timeout.connect(_apply_ragdoll_velocity.bind(ragdoll, velocity, impulse))
+1
View File
@@ -21,6 +21,7 @@ SettingsManager="*res://globals/settings_manager.gd"
LoadoutManager="*res://globals/loadout_manager.gd" LoadoutManager="*res://globals/loadout_manager.gd"
PauseMenu="*res://ui/pause_menu.gd" PauseMenu="*res://ui/pause_menu.gd"
NetworkManager="*res://globals/network_manager.gd" NetworkManager="*res://globals/network_manager.gd"
SkinManager="*res://characters/skin_manager.gd"
[display] [display]
+6 -1
View File
@@ -124,6 +124,7 @@ func _spawn_player(pid: int) -> CharacterBody3D:
client_rep_config.add_property(":synced_loadout_ready") client_rep_config.add_property(":synced_loadout_ready")
client_rep_config.add_property(":synced_is_targeting") client_rep_config.add_property(":synced_is_targeting")
client_rep_config.add_property(":synced_homing_target_pos") client_rep_config.add_property(":synced_homing_target_pos")
client_rep_config.add_property(":synced_skin_id")
client_sync.replication_config = client_rep_config client_sync.replication_config = client_rep_config
player.add_child(client_sync) player.add_child(client_sync)
@@ -144,7 +145,11 @@ func _spawn_player(pid: int) -> CharacterBody3D:
# Humanoid Model for Player (Shadows only locally, visible to others) # Humanoid Model for Player (Shadows only locally, visible to others)
var humanoid = load("res://characters/humanoid_model.gd").new() var humanoid = load("res://characters/humanoid_model.gd").new()
humanoid.name = "HumanoidModel" humanoid.name = "HumanoidModel"
humanoid.color = Color(0.2, 0.4, 0.8) # Blueish for player var skin_mgr = get_node_or_null("/root/SkinManager")
if skin_mgr and pid == multiplayer.get_unique_id():
humanoid.skin = skin_mgr.get_player_skin()
else:
humanoid.skin = skin_mgr.get_skin("default") if skin_mgr else null
humanoid.shadows_only = (pid == multiplayer.get_unique_id()) humanoid.shadows_only = (pid == multiplayer.get_unique_id())
humanoid.position = Vector3(0, -0.9, 0) # Offset from center to feet humanoid.position = Vector3(0, -0.9, 0) # Offset from center to feet
player.add_child(humanoid) player.add_child(humanoid)
@@ -52,6 +52,7 @@ func _ready() -> void:
func _spawn_player(pid: int) -> CharacterBody3D: func _spawn_player(pid: int) -> CharacterBody3D:
var player := CharacterBody3D.new() var player := CharacterBody3D.new()
player.name = str(pid) player.name = str(pid)
player.add_to_group("players")
player.set_multiplayer_authority(pid) player.set_multiplayer_authority(pid)
# Alternate spawn sides (Team A / Team B style) # Alternate spawn sides (Team A / Team B style)
@@ -90,6 +91,7 @@ func _spawn_player(pid: int) -> CharacterBody3D:
client_rep_config.add_property(":synced_loadout_ready") client_rep_config.add_property(":synced_loadout_ready")
client_rep_config.add_property(":synced_is_targeting") client_rep_config.add_property(":synced_is_targeting")
client_rep_config.add_property(":synced_homing_target_pos") client_rep_config.add_property(":synced_homing_target_pos")
client_rep_config.add_property(":synced_skin_id")
client_sync.replication_config = client_rep_config client_sync.replication_config = client_rep_config
player.add_child(client_sync) player.add_child(client_sync)
@@ -110,7 +112,11 @@ func _spawn_player(pid: int) -> CharacterBody3D:
# Humanoid Model for Player (Shadows only locally, visible to others) # Humanoid Model for Player (Shadows only locally, visible to others)
var humanoid = load("res://characters/humanoid_model.gd").new() var humanoid = load("res://characters/humanoid_model.gd").new()
humanoid.name = "HumanoidModel" humanoid.name = "HumanoidModel"
humanoid.color = Color(0.2, 0.4, 0.8) # Blueish for player var skin_mgr = get_node_or_null("/root/SkinManager")
if skin_mgr and pid == multiplayer.get_unique_id():
humanoid.skin = skin_mgr.get_player_skin()
else:
humanoid.skin = skin_mgr.get_skin("default") if skin_mgr else null
humanoid.shadows_only = (pid == multiplayer.get_unique_id()) humanoid.shadows_only = (pid == multiplayer.get_unique_id())
humanoid.position = Vector3(0, -0.9, 0) # Offset from center to feet humanoid.position = Vector3(0, -0.9, 0) # Offset from center to feet
player.add_child(humanoid) player.add_child(humanoid)
+117
View File
@@ -5,6 +5,7 @@ var main_vbox: VBoxContainer
var resume_btn: Button var resume_btn: Button
var respawn_btn: Button var respawn_btn: Button
var loadouts_btn: Button var loadouts_btn: Button
var character_btn: Button
var settings_btn: Button var settings_btn: Button
var return_btn: Button var return_btn: Button
var exit_btn: Button var exit_btn: Button
@@ -51,6 +52,12 @@ var rebind_overlay: ColorRect
var rebind_label: Label var rebind_label: Label
var waiting_for_input_action: String = "" var waiting_for_input_action: String = ""
# Character Skin UI
var character_editor: Control
var character_back_btn: Button
var skin_grid: GridContainer
var skin_preview_label: Label
var quit_dialog: ConfirmationDialog var quit_dialog: ConfirmationDialog
var return_dialog: ConfirmationDialog var return_dialog: ConfirmationDialog
@@ -146,6 +153,11 @@ func _build_ui() -> void:
loadouts_btn.custom_minimum_size = Vector2(200, 50) loadouts_btn.custom_minimum_size = Vector2(200, 50)
main_vbox.add_child(loadouts_btn) main_vbox.add_child(loadouts_btn)
character_btn = Button.new()
character_btn.text = "Character"
character_btn.custom_minimum_size = Vector2(200, 50)
main_vbox.add_child(character_btn)
settings_btn = Button.new() settings_btn = Button.new()
settings_btn.text = "Settings" settings_btn.text = "Settings"
settings_btn.custom_minimum_size = Vector2(200, 50) settings_btn.custom_minimum_size = Vector2(200, 50)
@@ -667,6 +679,48 @@ func _build_ui() -> void:
rebind_label.add_theme_font_size_override("font_size", 32) rebind_label.add_theme_font_size_override("font_size", 32)
rebind_center.add_child(rebind_label) rebind_center.add_child(rebind_label)
# ── Character Editor Root ──
character_editor = Control.new()
character_editor.set_anchors_preset(Control.PRESET_FULL_RECT)
bg.add_child(character_editor)
var char_center = CenterContainer.new()
char_center.set_anchors_preset(Control.PRESET_FULL_RECT)
character_editor.add_child(char_center)
var char_vbox = VBoxContainer.new()
char_vbox.custom_minimum_size = Vector2(600, 500)
char_vbox.add_theme_constant_override("separation", 15)
char_center.add_child(char_vbox)
var char_title = Label.new()
char_title.text = "CHARACTER SKIN"
char_title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
char_title.add_theme_font_size_override("font_size", 32)
char_vbox.add_child(char_title)
skin_preview_label = Label.new()
skin_preview_label.text = "Selected: Default Blue"
skin_preview_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
skin_preview_label.add_theme_font_size_override("font_size", 18)
char_vbox.add_child(skin_preview_label)
var skin_scroll = ScrollContainer.new()
skin_scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL
char_vbox.add_child(skin_scroll)
skin_grid = GridContainer.new()
skin_grid.columns = 4
skin_grid.add_theme_constant_override("h_separation", 12)
skin_grid.add_theme_constant_override("v_separation", 12)
skin_grid.size_flags_horizontal = Control.SIZE_EXPAND_FILL
skin_scroll.add_child(skin_grid)
character_back_btn = Button.new()
character_back_btn.text = "Back to Menu"
character_back_btn.custom_minimum_size = Vector2(0, 40)
char_vbox.add_child(character_back_btn)
# ── Quit Dialog ── # ── Quit Dialog ──
quit_dialog = ConfirmationDialog.new() quit_dialog = ConfirmationDialog.new()
@@ -734,6 +788,8 @@ func _connect_signals() -> void:
save_loadout_btn.pressed.connect(_save_current_loadout) save_loadout_btn.pressed.connect(_save_current_loadout)
quit_dialog.confirmed.connect(_quit_game) quit_dialog.confirmed.connect(_quit_game)
return_dialog.confirmed.connect(_return_to_main_menu) return_dialog.confirmed.connect(_return_to_main_menu)
character_btn.pressed.connect(_show_character)
character_back_btn.pressed.connect(_show_main_menu)
func _show_return_dialog() -> void: func _show_return_dialog() -> void:
return_dialog.popup_centered() return_dialog.popup_centered()
@@ -753,14 +809,75 @@ func _show_main_menu() -> void:
main_vbox.visible = true main_vbox.visible = true
loadout_editor.visible = false loadout_editor.visible = false
settings_editor.visible = false settings_editor.visible = false
character_editor.visible = false
func _show_loadouts() -> void: func _show_loadouts() -> void:
main_vbox.visible = false main_vbox.visible = false
settings_editor.visible = false settings_editor.visible = false
character_editor.visible = false
loadout_editor.visible = true loadout_editor.visible = true
edit_panel.visible = false edit_panel.visible = false
_populate_loadout_list() _populate_loadout_list()
func _show_character() -> void:
main_vbox.visible = false
settings_editor.visible = false
loadout_editor.visible = false
character_editor.visible = true
_populate_skin_grid()
func _populate_skin_grid() -> void:
for c in skin_grid.get_children():
c.queue_free()
var skin_mgr = get_node_or_null("/root/SkinManager")
if not skin_mgr: return
var current_id = skin_mgr.selected_skin_id
var all_ids = skin_mgr.get_all_skin_ids()
for id in all_ids:
var skin = skin_mgr.get_skin(id)
var panel = PanelContainer.new()
var style = StyleBoxFlat.new()
style.bg_color = skin.primary_color
style.corner_radius_top_left = 8
style.corner_radius_top_right = 8
style.corner_radius_bottom_left = 8
style.corner_radius_bottom_right = 8
if id == current_id:
style.border_width_left = 4
style.border_width_right = 4
style.border_width_top = 4
style.border_width_bottom = 4
style.border_color = Color.WHITE
skin_preview_label.text = "Selected: " + skin.display_name
panel.add_theme_stylebox_override("panel", style)
panel.custom_minimum_size = Vector2(120, 120)
var btn = Button.new()
btn.text = skin.display_name
btn.set_anchors_preset(Control.PRESET_FULL_RECT)
var btn_style = StyleBoxEmpty.new()
btn.add_theme_stylebox_override("normal", btn_style)
btn.add_theme_stylebox_override("hover", btn_style)
btn.add_theme_stylebox_override("pressed", btn_style)
btn.add_theme_stylebox_override("focus", btn_style)
# Text shadow for readability
btn.add_theme_color_override("font_color", Color.WHITE)
btn.add_theme_color_override("font_outline_color", Color.BLACK)
btn.add_theme_constant_override("outline_size", 4)
btn.pressed.connect(func():
skin_mgr.set_player_skin(id)
_populate_skin_grid() # Refresh borders
)
panel.add_child(btn)
skin_grid.add_child(panel)
func _populate_video_ui() -> void: func _populate_video_ui() -> void:
if _display_mode_opt: if _display_mode_opt:
for i in range(_display_mode_opt.item_count): for i in range(_display_mode_opt.item_count):
+24 -1
View File
@@ -253,7 +253,12 @@ func _add_procedural_arms(weapon: Node3D) -> void:
# Attach to weapon instead of model_root to avoid arms spinning on reload # Attach to weapon instead of model_root to avoid arms spinning on reload
var mat = StandardMaterial3D.new() var mat = StandardMaterial3D.new()
mat.albedo_color = Color(0.2, 0.4, 0.8) # Player color var skin_mgr = get_node_or_null("/root/SkinManager")
if skin_mgr and is_instance_valid(player) and "synced_skin_id" in player:
var skin = skin_mgr.get_skin(player.synced_skin_id)
mat = skin.create_material()
else:
mat.albedo_color = Color(0.2, 0.4, 0.8) # Fallback
mat.roughness = 0.8 mat.roughness = 0.8
# Right Arm (Main Grip) # Right Arm (Main Grip)
@@ -264,6 +269,7 @@ func _add_procedural_arms(weapon: Node3D) -> void:
r_arm.mesh = r_mesh r_arm.mesh = r_mesh
var r_pivot = Node3D.new() var r_pivot = Node3D.new()
r_pivot.name = "ProceduralArmPivotR"
var r_shoulder = Vector3(0.25, -0.3, 0.5) var r_shoulder = Vector3(0.25, -0.3, 0.5)
var r_hand = Vector3(0.04, -0.05, 0.05) var r_hand = Vector3(0.04, -0.05, 0.05)
r_pivot.position = r_shoulder r_pivot.position = r_shoulder
@@ -283,6 +289,7 @@ func _add_procedural_arms(weapon: Node3D) -> void:
l_arm.mesh = l_mesh l_arm.mesh = l_mesh
var l_pivot = Node3D.new() var l_pivot = Node3D.new()
l_pivot.name = "ProceduralArmPivotL"
var l_shoulder = Vector3(-0.25, -0.3, 0.4) var l_shoulder = Vector3(-0.25, -0.3, 0.4)
var l_hand = Vector3(-0.02, -0.02, -0.3) var l_hand = Vector3(-0.02, -0.02, -0.3)
l_pivot.position = l_shoulder l_pivot.position = l_shoulder
@@ -341,3 +348,19 @@ func _input(event: InputEvent) -> void:
if weapons.has(4): if weapons.has(4):
if weapons[4].has_method("do_quick_melee"): if weapons[4].has_method("do_quick_melee"):
weapons[4].do_quick_melee(prev) weapons[4].do_quick_melee(prev)
## Re-equips the current weapon to refresh arm colors after a skin change.
func refresh_arm_colors() -> void:
var skin_mgr = get_node_or_null("/root/SkinManager")
if not skin_mgr or not is_instance_valid(player) or not "synced_skin_id" in player:
return
var skin = skin_mgr.get_skin(player.synced_skin_id)
var mat = skin.create_material()
for w in weapons.values():
if not is_instance_valid(w): continue
for child in w.get_children():
if child.name.begins_with("ProceduralArmPivot"):
for grand_child in child.get_children():
if grand_child is MeshInstance3D and grand_child.mesh:
grand_child.set_surface_override_material(0, mat)