feat: expand character customization
This commit is contained in:
parent
c85f709851
commit
9d0591c68d
9 changed files with 286 additions and 52 deletions
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -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"))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue