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
|
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(
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue