feat: expand character customization

This commit is contained in:
Alexander Sellite 2026-08-08 10:25:17 -04:00
parent c85f709851
commit 9d0591c68d
9 changed files with 286 additions and 52 deletions

View file

@ -3,6 +3,22 @@ extends RefCounted
const PlayerScene: PackedScene = preload("res://player/player.tscn")
const RUNTIME_MATERIAL_META: StringName = &"netfishing_runtime_material"
const EAR_MESH_NAMES: Array[String] = [
"ears_antlers_round",
"ears_bear",
"ears_bunny",
"ears_pointy_long",
"ears_pointy_short",
"ears_pointy_wide",
]
const TAIL_MESH_NAMES: Array[String] = [
"tails_bear",
"tails_bunny",
"tails_cat",
"tails_fox",
"tails_gator",
"tails_pointy",
]
static var _feature_uv_aspects: Dictionary = {}
@ -113,15 +129,17 @@ static func apply_appearance(
var selected_ears: String = CharacterCustomizationCatalog.canonical_option_id(
"ears", str(snapshot.get("ears", "none"))
)
var ear_names: Array[String] = [
"ears_pointy_long",
"ears_pointy_short",
"ears_pointy_wide",
]
for ear_name: String in ear_names:
for ear_name: String in EAR_MESH_NAMES:
var ears: Node3D = skeleton.get_node_or_null(ear_name) as Node3D
if ears != null:
ears.visible = selected_ears == ear_name.trim_prefix("ears_")
var selected_tail: String = CharacterCustomizationCatalog.canonical_option_id(
"tail", str(snapshot.get("tail", "none"))
)
for tail_name: String in TAIL_MESH_NAMES:
var tail: Node3D = skeleton.get_node_or_null(tail_name) as Node3D
if tail != null:
tail.visible = selected_tail == tail_name.trim_prefix("tails_")
var fur_color: Color = CharacterCustomizationCatalog.option_color(
"fur_pattern", str(snapshot.get("fur_pattern", "white"))
@ -130,8 +148,10 @@ static func apply_appearance(
_set_mesh_color(skeleton, "body_main", fur_color)
_set_mesh_color(skeleton, "head_pointy", fur_color)
_set_mesh_color(skeleton, "head_round", fur_color)
for ear_name: String in ear_names:
for ear_name: String in EAR_MESH_NAMES:
_set_mesh_color(skeleton, ear_name, fur_color)
for tail_name: String in TAIL_MESH_NAMES:
_set_mesh_color(skeleton, tail_name, fur_color)
var eye_id: String = CharacterCustomizationCatalog.canonical_option_id(
"eyes", str(snapshot.get("eyes", "simple_shine"))

View file

@ -24,8 +24,8 @@ const CATEGORY_LABELS: Dictionary = {
}
const SCALE_CATEGORY_ID: String = "scale"
const MIN_CHARACTER_SCALE: float = 0.75
const MAX_CHARACTER_SCALE: float = 1.25
const MIN_CHARACTER_SCALE: float = 0.5
const MAX_CHARACTER_SCALE: float = 1.5
const DEFAULT_CHARACTER_SCALE: float = 1.0
const CHARACTER_SCALE_STEP: float = 0.05
@ -51,16 +51,24 @@ const OPTIONS: Dictionary = {
# authored pattern layer is ready.
"fur_pattern": [
{"id": "white", "label": "white", "color": Color("f2f0e8")},
{"id": "cream", "label": "cream", "color": Color("e6d39b")},
{"id": "gray", "label": "gray", "color": Color("819398")},
{"id": "charcoal", "label": "charcoal", "color": Color("34444a")},
{"id": "brown", "label": "brown", "color": Color("7b4a32")},
{"id": "orange", "label": "orange", "color": Color("c86c36")},
{"id": "red", "label": "red", "color": Color("a9433f")},
{"id": "pink", "label": "pink", "color": Color("d8899e")},
{"id": "yellow", "label": "yellow", "color": Color("d8c545")},
{"id": "green", "label": "green", "color": Color("6f913c")},
{"id": "teal", "label": "teal", "color": Color("3a8790")},
{"id": "blue", "label": "blue", "color": Color("4d76a8")},
{"id": "purple", "label": "purple", "color": Color("76558f")},
],
"ears": [
{"id": "none", "label": "none"},
{"id": "antlers_round", "label": "antlers"},
{"id": "bear", "label": "bear"},
{"id": "bunny", "label": "bunny"},
{"id": "pointy_long", "label": "long"},
{"id": "pointy_short", "label": "short"},
{"id": "pointy_wide", "label": "wide"},
@ -74,13 +82,22 @@ const OPTIONS: Dictionary = {
],
"nose": [{"id": "dog_round", "label": "round"}],
"mouth": [{"id": "three", "label": "three"}],
"tail": [{"id": "none", "label": "none"}],
"tail": [
{"id": "none", "label": "none"},
{"id": "bear", "label": "bear"},
{"id": "bunny", "label": "bunny"},
{"id": "cat", "label": "cat"},
{"id": "fox", "label": "fox"},
{"id": "gator", "label": "gator"},
{"id": "pointy", "label": "pointy"},
],
}
const LEGACY_OPTION_ALIASES: Dictionary = {
"species": {"default": "round"},
"fur_pattern": {"solid": "white"},
"ears": {"default": "none"},
"tail": {"default": "none"},
"eyes": {"default": "simple_shine"},
"nose": {"default": "dog_round"},
"mouth": {"default": "three"},
@ -127,6 +144,82 @@ static func options_for(category_id: String) -> Array:
return OPTIONS.get(category_id, [])
static func feature_option_groups(category_id: String) -> Array:
if category_id not in FEATURE_CATEGORIES:
return []
var options: Array = options_for(category_id)
var option_ids: Array[String] = []
for option: Dictionary in options:
option_ids.append(str(option.get("id", "")))
var grouped_options: Dictionary = {}
var group_order: Array[String] = []
for option: Dictionary in options:
var option_id := str(option.get("id", ""))
var group_id := _feature_variant_root(option_id, option_ids)
if not grouped_options.has(group_id):
grouped_options[group_id] = []
group_order.append(group_id)
var group: Array = grouped_options[group_id] as Array
group.append(option)
grouped_options[group_id] = group
var result: Array = []
for group_id: String in group_order:
result.append({
"id": group_id,
"options": grouped_options[group_id],
})
return result
static func _feature_variant_root(
option_id: String,
option_ids: Array[String],
) -> String:
if option_id == "none":
return option_id
# Prefer the shortest authored base option. This keeps nested names such as
# `beady_extra_shine` in the `beady` drawer rather than creating drawers
# inside drawers.
var authored_root := ""
for candidate_id: String in option_ids:
if candidate_id == "none":
continue
if (
option_id != candidate_id
and not option_id.begins_with(candidate_id + "_")
):
continue
var has_descendant := false
for descendant_id: String in option_ids:
if descendant_id.begins_with(candidate_id + "_"):
has_descendant = true
break
if (
has_descendant
and (
authored_root.is_empty()
or candidate_id.length() < authored_root.length()
)
):
authored_root = candidate_id
if not authored_root.is_empty():
return authored_root
# Some families have no unqualified base asset. Group those siblings by
# their leading name segment, making the sorted first asset representative.
var separator_index := option_id.find("_")
if separator_index <= 0:
return option_id
var leading_segment := option_id.substr(0, separator_index)
var sibling_count := 0
for candidate_id: String in option_ids:
if candidate_id.begins_with(leading_segment + "_"):
sibling_count += 1
if sibling_count >= 2:
return leading_segment
return option_id
static func texture_for(category_id: String, option_id: String) -> Texture2D:
if category_id not in FEATURE_CATEGORIES:
return null

View file

@ -3,6 +3,7 @@ extends Control
const CHECK_DEBOUNCE_SECONDS: float = 0.4
const OPTION_GRID_COLUMNS: int = 6
const FEATURE_DRAWER_ANIMATION_SECONDS: float = 0.16
const ControllerMappingManagerType = preload(
"res://settings/controller_mapping_manager.gd"
)
@ -45,6 +46,8 @@ var _experience_level: Label
var _experience_progress: ProgressBar
var _experience_value: Label
var _feature_preview_cache: Dictionary = {}
var _expanded_feature_drawers: Dictionary = {}
var _feature_drawer_animation_key: String = ""
var _scale_value_label: Label
var _voice_preview: AnimaleseVoiceType
var _voice_preview_tween: Tween
@ -630,7 +633,7 @@ func _build_scale_option() -> void:
_option_list.add_child(gameplay_note)
func _build_feature_preview_options(options: Array) -> void:
func _build_feature_preview_options(_options: Array) -> void:
var scroll := ScrollContainer.new()
scroll.custom_minimum_size = Vector2(190.0, 0.0)
scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL
@ -646,45 +649,153 @@ func _build_feature_preview_options(options: Array) -> void:
var selected_id := CharacterCustomizationCatalog.canonical_option_id(
_category_id, str(_draft_appearance.get(_category_id, ""))
)
for option: Dictionary in options:
var option_id := str(option.get("id", ""))
var button := Button.new()
var is_none := option_id == "none"
button.text = "×" if is_none else ""
button.tooltip_text = "none" if is_none else str(
option.get("label", option_id)
var groups: Array = CharacterCustomizationCatalog.feature_option_groups(
_category_id
)
var expanded_drawer_id := str(
_expanded_feature_drawers.get(_category_id, "")
)
if expanded_drawer_id.is_empty():
for group: Dictionary in groups:
var group_options: Array = group.get("options", []) as Array
if group_options.size() <= 1:
continue
var representative_id := str(
(group_options[0] as Dictionary).get("id", "")
)
for option: Dictionary in group_options:
if (
str(option.get("id", "")) == selected_id
and selected_id != representative_id
):
expanded_drawer_id = str(group.get("id", ""))
_expanded_feature_drawers[_category_id] = (
expanded_drawer_id
)
break
if not expanded_drawer_id.is_empty():
break
var animation_key := _feature_drawer_animation_key
for group: Dictionary in groups:
var group_id := str(group.get("id", ""))
var group_options: Array = group.get("options", []) as Array
if group_options.is_empty():
continue
var has_variants := group_options.size() > 1
var is_expanded := has_variants and group_id == expanded_drawer_id
for option_index: int in range(group_options.size()):
if option_index > 0 and not is_expanded:
continue
var option: Dictionary = group_options[option_index] as Dictionary
var button := _build_feature_option_button(
option,
selected_id,
group_id,
option_index == 0,
has_variants,
is_expanded,
)
grid.add_child(button)
if (
option_index > 0
and animation_key == "%s:%s" % [_category_id, group_id]
):
_animate_feature_drawer_button(button)
_feature_drawer_animation_key = ""
func _build_feature_option_button(
option: Dictionary,
selected_id: String,
drawer_id: String,
is_representative: bool,
has_variants: bool,
is_expanded: bool,
) -> Button:
var option_id := str(option.get("id", ""))
var button := Button.new()
var is_none := option_id == "none"
button.text = "×" if is_none else ""
button.tooltip_text = "none" if is_none else str(
option.get("label", option_id)
)
if is_representative and has_variants:
button.tooltip_text += (
" (hide variants)" if is_expanded else " (show variants)"
)
button.toggle_mode = true
button.button_pressed = selected_id == option_id
button.custom_minimum_size = Vector2(84.0, 64.0)
button.alignment = HORIZONTAL_ALIGNMENT_CENTER
button.clip_contents = true
button.toggle_mode = true
button.button_pressed = selected_id == option_id
button.custom_minimum_size = Vector2(84.0, 64.0)
button.alignment = HORIZONTAL_ALIGNMENT_CENTER
button.clip_contents = true
if is_representative and has_variants:
button.pressed.connect(
_select_feature_drawer.bind(_category_id, drawer_id, option_id)
)
else:
button.pressed.connect(_select_option.bind(_category_id, option_id))
UtilityPageStyle.apply_compact_ocean_button(button)
button.custom_minimum_size = Vector2(84.0, 64.0)
if is_none:
button.add_theme_font_size_override("font_size", 28)
else:
var preview := TextureRect.new()
preview.texture = _feature_preview_texture(
_category_id, option_id
)
preview.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
preview.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
preview.mouse_filter = Control.MOUSE_FILTER_IGNORE
var preview_height := 50.0
var preview_width := clampf(
preview_height * PlayerVisualPresenter.feature_uv_aspect(
_category_id
),
32.0,
74.0,
)
preview.custom_minimum_size = Vector2(preview_width, preview_height)
preview.size = Vector2(preview_width, preview_height)
preview.position = Vector2((84.0 - preview_width) * 0.5, 7.0)
button.add_child(preview)
grid.add_child(button)
UtilityPageStyle.apply_compact_ocean_button(button)
button.custom_minimum_size = Vector2(84.0, 64.0)
if is_none:
button.add_theme_font_size_override("font_size", 28)
else:
var preview := TextureRect.new()
preview.texture = _feature_preview_texture(_category_id, option_id)
preview.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
preview.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
preview.mouse_filter = Control.MOUSE_FILTER_IGNORE
var preview_height := 50.0
var preview_width := clampf(
preview_height * PlayerVisualPresenter.feature_uv_aspect(_category_id),
32.0,
74.0,
)
preview.custom_minimum_size = Vector2(preview_width, preview_height)
preview.size = Vector2(preview_width, preview_height)
preview.position = Vector2((84.0 - preview_width) * 0.5, 7.0)
button.add_child(preview)
if is_representative and has_variants:
var indicator := Label.new()
indicator.text = "<" if is_expanded else ">"
indicator.position = Vector2(66.0, 1.0)
indicator.size = Vector2(14.0, 16.0)
indicator.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
indicator.mouse_filter = Control.MOUSE_FILTER_IGNORE
indicator.add_theme_font_size_override("font_size", 13)
indicator.add_theme_color_override(
"font_color", UtilityPageStyle.OCEAN_TEXT_PRIMARY
)
button.add_child(indicator)
return button
func _select_feature_drawer(
category_id: String,
drawer_id: String,
option_id: String,
) -> void:
if str(_expanded_feature_drawers.get(category_id, "")) == drawer_id:
_expanded_feature_drawers.erase(category_id)
else:
_expanded_feature_drawers[category_id] = drawer_id
_feature_drawer_animation_key = "%s:%s" % [category_id, drawer_id]
_select_option(category_id, option_id)
func _animate_feature_drawer_button(button: Button) -> void:
button.scale = Vector2(0.05, 1.0)
var faded := button.modulate
faded.a = 0.0
button.modulate = faded
var tween := button.create_tween()
tween.set_parallel(true)
tween.tween_property(
button, "scale", Vector2.ONE, FEATURE_DRAWER_ANIMATION_SECONDS
).set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_OUT)
tween.tween_property(
button, "modulate:a", 1.0, FEATURE_DRAWER_ANIMATION_SECONDS
).set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_OUT)
func _feature_preview_texture(

View file

@ -9,6 +9,7 @@ const UIReferencePresentationType = preload(
)
const FRONT_FACING_YAW: float = PI
const DEFAULT_CAMERA_DISTANCE: float = 1.9
const DEFAULT_CAMERA_PITCH: float = 0.12
const MIN_CAMERA_DISTANCE: float = 1.05
const MAX_CAMERA_DISTANCE: float = 2.8
const CAMERA_ZOOM_STEP: float = 0.14
@ -25,7 +26,7 @@ const CAMERA_TARGET_HEIGHT: float = 0.95
var _dragging: bool = false
var _camera_yaw: float = 0.0
var _camera_pitch: float = 0.0
var _camera_pitch: float = DEFAULT_CAMERA_PITCH
var _camera_distance: float = DEFAULT_CAMERA_DISTANCE
var _visuals: Node3D
var _controller_mapping_manager: ControllerMappingManagerType
@ -85,11 +86,9 @@ func apply_appearance_profile(profile: Dictionary) -> void:
func reset_view() -> void:
_preview_root.rotation.y = FRONT_FACING_YAW
_camera_yaw = 0.0
_camera_pitch = 0.0
_camera_pitch = DEFAULT_CAMERA_PITCH
_camera_distance = DEFAULT_CAMERA_DISTANCE
_apply_camera_orbit()
if _preview_camera != null:
_preview_camera.position.z = DEFAULT_CAMERA_DISTANCE
func _process(delta: float) -> void:

View file

@ -15,6 +15,7 @@ script = ExtResource("3_display")
species_id = "pointy"
fur_color_id = "white"
ears_id = "pointy_wide"
tail_id = "fox"
eyes_id = "punk_purple"
nose_id = "triangle_small"
mouth_id = "slanted"

View file

@ -25,6 +25,15 @@ const PlayerVisualPresenterType = preload(
"pointy_short",
"pointy_wide",
) var ears_id: String = "none"
@export_enum(
"none",
"bear",
"bunny",
"cat",
"fox",
"gator",
"pointy",
) var tail_id: String = "none"
@export var eyes_id: String = "simple_shine"
@export var nose_id: String = "dog_round"
@export var mouth_id: String = "three"
@ -48,6 +57,7 @@ func _ready() -> void:
appearance["species"] = species_id
appearance["fur_pattern"] = fur_color_id
appearance["ears"] = ears_id
appearance["tail"] = tail_id
appearance["eyes"] = eyes_id
appearance["nose"] = nose_id
appearance["mouth"] = mouth_id