From 8cc4c0985db1ed15421606e441102502a45dd367 Mon Sep 17 00:00:00 2001 From: Voyager Date: Wed, 5 Aug 2026 08:18:29 -0400 Subject: [PATCH] Discover facial features from asset directories --- art/exported/characters/faces/README.md | 24 ++++ player/player_visual_presenter.gd | 34 ++--- .../character_customization_catalog.gd | 128 ++++++++++++++++++ 3 files changed, 165 insertions(+), 21 deletions(-) create mode 100644 art/exported/characters/faces/README.md diff --git a/art/exported/characters/faces/README.md b/art/exported/characters/faces/README.md new file mode 100644 index 0000000..06f3960 --- /dev/null +++ b/art/exported/characters/faces/README.md @@ -0,0 +1,24 @@ +# Drop-in facial features + +Facial feature textures are discovered when the game starts. Put new PNGs in +the matching directory, restart the game, and the option will appear in the +Profile customization page. + +```text +eyes/.png +noses/.png +mouth/.png +``` + +The category directory is authoritative. A category prefix is optional, so +both `sleepy.png` and `eyes_sleepy.png` become the option ID `sleepy`. +Filenames are normalized to lowercase snake_case. Use stable names because +the resulting IDs are stored in appearance snapshots and sent to peers. + +Keep each PNG on the established facial-feature canvas with an RGBA +transparent background. Godot imports the PNG automatically in the editor; +the runtime registry ignores `.import` files and other non-PNG files. + +The current game bundles its facial assets under `res://`. Adding a file to a +development checkout is picked up on the next startup. An exported build must +be rebuilt to include newly added `res://` files in its package. diff --git a/player/player_visual_presenter.gd b/player/player_visual_presenter.gd index a6bdbee..fb291ba 100644 --- a/player/player_visual_presenter.gd +++ b/player/player_visual_presenter.gd @@ -2,24 +2,6 @@ class_name PlayerVisualPresenter extends RefCounted const PlayerScene: PackedScene = preload("res://player/player.tscn") -const EYE_TEXTURES: Dictionary = { - "simple_shine": preload( - "res://art/exported/characters/faces/eyes/eyes_simple_shine.png" - ), - "simple_shine_eyebrows": preload( - "res://art/exported/characters/faces/eyes/eyes_simple_shine_eyebrows.png" - ), -} -const MOUTH_TEXTURES: Dictionary = { - "three": preload( - "res://art/exported/characters/faces/mouth/mouth_three.png" - ), -} -const NOSE_TEXTURES: Dictionary = { - "dog_round": preload( - "res://art/exported/characters/faces/noses/nose_dog_round.png" - ), -} const RUNTIME_MATERIAL_META: StringName = &"netfishing_runtime_material" @@ -110,11 +92,21 @@ static func apply_appearance( var nose_id: String = CharacterCustomizationCatalog.canonical_option_id( "nose", str(snapshot.get("nose", "dog_round")) ) - _set_feature_texture(skeleton, "eyes", EYE_TEXTURES.get(eye_id) as Texture2D) _set_feature_texture( - skeleton, "mouth", MOUTH_TEXTURES.get(mouth_id) as Texture2D + skeleton, + "eyes", + CharacterCustomizationCatalog.texture_for("eyes", eye_id), + ) + _set_feature_texture( + skeleton, + "mouth", + CharacterCustomizationCatalog.texture_for("mouth", mouth_id), + ) + _set_feature_texture( + skeleton, + "nose", + CharacterCustomizationCatalog.texture_for("nose", nose_id), ) - _set_feature_texture(skeleton, "nose", NOSE_TEXTURES.get(nose_id) as Texture2D) static func _set_feature_texture( diff --git a/progression/character_customization_catalog.gd b/progression/character_customization_catalog.gd index 09a6bad..e6fa987 100644 --- a/progression/character_customization_catalog.gd +++ b/progression/character_customization_catalog.gd @@ -21,6 +21,31 @@ const CATEGORY_LABELS: Dictionary = { "tail": "tail", } +const FEATURE_CATEGORIES: PackedStringArray = ["eyes", "nose", "mouth"] +const FEATURE_ASSET_ROOTS: Dictionary = { + "eyes": "res://art/exported/characters/faces/eyes", + "nose": "res://art/exported/characters/faces/noses", + "mouth": "res://art/exported/characters/faces/mouth", +} +const FEATURE_LABEL_OVERRIDES: Dictionary = { + "simple_shine": "simple shine", + "simple_shine_eyebrows": "simple shine + eyebrows", + "dog_round": "round", + "three": "three", +} +const FEATURE_FALLBACK_TEXTURES: Dictionary = { + "eyes": { + "simple_shine": "res://art/exported/characters/faces/eyes/eyes_simple_shine.png", + "simple_shine_eyebrows": "res://art/exported/characters/faces/eyes/eyes_simple_shine_eyebrows.png", + }, + "mouth": { + "three": "res://art/exported/characters/faces/mouth/mouth_three.png", + }, + "nose": { + "dog_round": "res://art/exported/characters/faces/noses/nose_dog_round.png", + }, +} + const OPTIONS: Dictionary = { "species": [ {"id": "round", "label": "round"}, @@ -66,6 +91,10 @@ const LEGACY_OPTION_ALIASES: Dictionary = { "mouth": {"default": "three"}, } +static var _feature_assets_ready: bool = false +static var _feature_options: Dictionary = {} +static var _feature_textures: Dictionary = {} + static func default_snapshot() -> Dictionary: return { @@ -80,9 +109,108 @@ static func default_snapshot() -> Dictionary: static func options_for(category_id: String) -> Array: + if category_id in FEATURE_CATEGORIES: + _ensure_feature_assets() + return _feature_options.get(category_id, []) as Array return OPTIONS.get(category_id, []) +static func texture_for(category_id: String, option_id: String) -> Texture2D: + if category_id not in FEATURE_CATEGORIES: + return null + _ensure_feature_assets() + var canonical_id := canonical_option_id(category_id, option_id) + var textures: Dictionary = _feature_textures.get(category_id, {}) as Dictionary + return textures.get(canonical_id) as Texture2D + + +static func refresh_feature_assets() -> void: + _feature_assets_ready = false + _feature_options.clear() + _feature_textures.clear() + _ensure_feature_assets() + + +static func _ensure_feature_assets() -> void: + if _feature_assets_ready: + return + _feature_assets_ready = true + for category_id: String in FEATURE_CATEGORIES: + var options: Array = [] + var textures: Dictionary = {} + var root_path := str(FEATURE_ASSET_ROOTS[category_id]) + var directory := DirAccess.open(root_path) + if directory != null: + var files := directory.get_files() + files.sort() + for file_name: String in files: + if not file_name.to_lower().ends_with(".png"): + continue + var option_id := _feature_id_from_filename( + category_id, file_name + ) + if option_id.is_empty() or textures.has(option_id): + continue + var resource_path := root_path.path_join(file_name) + var texture := ResourceLoader.load(resource_path) as Texture2D + if texture == null: + push_warning( + "Ignoring facial feature without a loadable texture: " + + resource_path + ) + continue + textures[option_id] = texture + options.append({ + "id": option_id, + "label": _feature_label(option_id), + }) + + if options.is_empty(): + for fallback: Dictionary in OPTIONS.get(category_id, []): + var fallback_id := str(fallback.get("id", "")) + options.append(fallback.duplicate(true)) + var fallback_path := str( + (FEATURE_FALLBACK_TEXTURES.get(category_id, {}) as Dictionary).get( + fallback_id, "" + ) + ) + if not fallback_path.is_empty(): + var fallback_texture := ResourceLoader.load( + fallback_path + ) as Texture2D + if fallback_texture != null: + textures[fallback_id] = fallback_texture + + _feature_options[category_id] = options + _feature_textures[category_id] = textures + + +static func _feature_id_from_filename( + category_id: String, + file_name: String, +) -> String: + var option_id := file_name.get_basename().to_lower() + var prefix := category_id + "_" + if option_id.begins_with(prefix): + option_id = option_id.trim_prefix(prefix) + option_id = option_id.replace("-", "_") + option_id = option_id.replace(" ", "_") + option_id = option_id.replace(".", "_") + while option_id.contains("__"): + option_id = option_id.replace("__", "_") + while option_id.begins_with("_"): + option_id = option_id.substr(1) + while option_id.ends_with("_"): + option_id = option_id.substr(0, option_id.length() - 1) + return option_id + + +static func _feature_label(option_id: String) -> String: + if FEATURE_LABEL_OVERRIDES.has(option_id): + return str(FEATURE_LABEL_OVERRIDES[option_id]) + return option_id.replace("_", " ") + + static func category_label(category_id: String) -> String: return str(CATEGORY_LABELS.get(category_id, category_id))