Discover facial features from asset directories

This commit is contained in:
Alexander Sellite 2026-08-05 08:18:29 -04:00
parent e489092604
commit 8cc4c0985d
3 changed files with 165 additions and 21 deletions

View file

@ -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/<id>.png
noses/<id>.png
mouth/<id>.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.

View file

@ -2,24 +2,6 @@ class_name PlayerVisualPresenter
extends RefCounted extends RefCounted
const PlayerScene: PackedScene = preload("res://player/player.tscn") 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" const RUNTIME_MATERIAL_META: StringName = &"netfishing_runtime_material"
@ -110,11 +92,21 @@ static func apply_appearance(
var nose_id: String = CharacterCustomizationCatalog.canonical_option_id( var nose_id: String = CharacterCustomizationCatalog.canonical_option_id(
"nose", str(snapshot.get("nose", "dog_round")) "nose", str(snapshot.get("nose", "dog_round"))
) )
_set_feature_texture(skeleton, "eyes", EYE_TEXTURES.get(eye_id) as Texture2D)
_set_feature_texture( _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( static func _set_feature_texture(

View file

@ -21,6 +21,31 @@ const CATEGORY_LABELS: Dictionary = {
"tail": "tail", "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 = { const OPTIONS: Dictionary = {
"species": [ "species": [
{"id": "round", "label": "round"}, {"id": "round", "label": "round"},
@ -66,6 +91,10 @@ const LEGACY_OPTION_ALIASES: Dictionary = {
"mouth": {"default": "three"}, "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: static func default_snapshot() -> Dictionary:
return { return {
@ -80,9 +109,108 @@ static func default_snapshot() -> Dictionary:
static func options_for(category_id: String) -> Array: 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, []) 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: static func category_label(category_id: String) -> String:
return str(CATEGORY_LABELS.get(category_id, category_id)) return str(CATEGORY_LABELS.get(category_id, category_id))