Discover facial features from asset directories
This commit is contained in:
parent
e489092604
commit
8cc4c0985d
3 changed files with 165 additions and 21 deletions
24
art/exported/characters/faces/README.md
Normal file
24
art/exported/characters/faces/README.md
Normal 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.
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue