straywild/player/player_visual_presenter.gd

451 lines
13 KiB
GDScript

class_name PlayerVisualPresenter
extends RefCounted
const PlayerScene: PackedScene = preload("res://player/player.tscn")
const FurPatternShader: Shader = preload("res://player/fur_pattern.gdshader")
const RUNTIME_MATERIAL_META: StringName = &"straywild_runtime_material"
const RUNTIME_FUR_PATTERN_MATERIAL_META: StringName = (
&"straywild_runtime_fur_pattern_material"
)
const APPLIED_APPEARANCE_META: StringName = &"straywild_applied_appearance"
const MODEL_CATEGORY_IDS: PackedStringArray = [
"species",
"arms",
"ears",
"tail",
"special_back",
"special_beak",
"special_head",
"special_horns",
"special_tusks",
"special_wings",
]
const MODEL_CATEGORY_DEFAULTS: Dictionary = {
"species": "round",
"arms": "mammal",
"ears": "none",
"tail": "none",
"special_back": "none",
"special_beak": "none",
"special_head": "none",
"special_horns": "none",
"special_tusks": "none",
"special_wings": "none",
}
static var _feature_uv_aspects: Dictionary = {}
static func feature_uv_aspect(feature_name: String) -> float:
if _feature_uv_aspects.has(feature_name):
return float(_feature_uv_aspects[feature_name])
var visuals := instantiate_visuals()
var skeleton := visuals.find_child("Skeleton3D", true, false) as Skeleton3D
var aspects: Array[float] = []
for head_name: String in CharacterCustomizationCatalog.model_mesh_names(
"species"
):
var mesh_instance := skeleton.get_node_or_null(
"%s_%s" % [head_name, feature_name]
) as MeshInstance3D
if mesh_instance == null or mesh_instance.mesh == null:
continue
var arrays := mesh_instance.mesh.surface_get_arrays(0)
var uv: PackedVector2Array = arrays[Mesh.ARRAY_TEX_UV]
if uv.is_empty():
continue
var min_uv := Vector2(INF, INF)
var max_uv := Vector2(-INF, -INF)
for value: Vector2 in uv:
min_uv = min_uv.min(value)
max_uv = max_uv.max(value)
var size := max_uv - min_uv
if size.y > 0.001:
aspects.append(size.x / size.y)
visuals.free()
var result := 1.0
if not aspects.is_empty():
result = 0.0
for aspect: float in aspects:
result += aspect
result /= aspects.size()
result = clampf(result, 0.35, 2.5)
_feature_uv_aspects[feature_name] = result
return result
static func instantiate_visuals() -> Node3D:
var source: Player = PlayerScene.instantiate()
var visuals := source.get_node("Visuals") as Node3D
var source_rod_attachment: Node = visuals.find_child(
"FishingRodAttachment", true, false
)
var source_rod_parent: Node = null
var source_rod_index: int = -1
if source_rod_attachment != null:
source_rod_parent = source_rod_attachment.get_parent()
source_rod_index = source_rod_attachment.get_index()
source_rod_attachment.owner = null
source_rod_parent.remove_child(source_rod_attachment)
var presentation := visuals.duplicate(
Node.DUPLICATE_SIGNALS
| Node.DUPLICATE_GROUPS
| Node.DUPLICATE_SCRIPTS
| Node.DUPLICATE_USE_INSTANTIATION
) as Node3D
if source_rod_attachment != null and source_rod_parent != null:
source_rod_parent.add_child(source_rod_attachment)
source_rod_parent.move_child(source_rod_attachment, source_rod_index)
source.free()
presentation.name = "PlayerVisuals"
return presentation
static func apply_appearance(
visuals: Node3D,
snapshot: Dictionary,
) -> void:
if not CharacterCustomizationCatalog.validate_snapshot(snapshot):
return
# Gameplay and preview deliberately share this presentation seam. Keep all
# mesh selection here so profile previews and network avatars stay identical.
var character_scale: float = CharacterCustomizationCatalog.character_scale(
snapshot.get(
CharacterCustomizationCatalog.SCALE_CATEGORY_ID,
CharacterCustomizationCatalog.DEFAULT_CHARACTER_SCALE,
)
)
visuals.scale = Vector3.ONE * character_scale
var skeleton: Skeleton3D = visuals.find_child("Skeleton3D", true, false) as Skeleton3D
if skeleton == null:
return
var previous: Dictionary = visuals.get_meta(
APPLIED_APPEARANCE_META,
{},
) as Dictionary
var first_application := (
previous.is_empty()
or not CharacterCustomizationCatalog.validate_snapshot(previous)
)
var changed_model_categories: Array[String] = []
for category_id: String in MODEL_CATEGORY_IDS:
if (
first_application
or _model_category_selection(previous, category_id)
!= _model_category_selection(snapshot, category_id)
):
_set_model_category_visibility(skeleton, snapshot, category_id)
changed_model_categories.append(category_id)
var head_changed := first_application or "species" in changed_model_categories
if head_changed:
_set_head_feature_visibility(skeleton, snapshot)
else:
for feature_name: String in ["eyes", "mouth", "nose"]:
if _appearance_option_changed(previous, snapshot, feature_name):
_set_selected_head_feature_visibility(
skeleton,
snapshot,
feature_name,
)
var fur_color: Color = CharacterCustomizationCatalog.option_color(
"fur_pattern", str(snapshot.get("fur_pattern", "white"))
)
var fur_changed := first_application
for category_id: String in CharacterCustomizationCatalog.FUR_COLOR_IDS:
fur_changed = (
fur_changed
or _appearance_option_changed(previous, snapshot, category_id)
)
for category_id: String in CharacterCustomizationCatalog.FUR_STYLE_IDS:
fur_changed = (
fur_changed
or _appearance_option_changed(previous, snapshot, category_id)
)
if fur_changed:
_set_fur_mesh(skeleton, "body_main", snapshot, fur_color)
for category_id: String in MODEL_CATEGORY_IDS:
_set_selected_category_fur(
skeleton,
snapshot,
category_id,
fur_color,
)
else:
for category_id: String in changed_model_categories:
_set_selected_category_fur(
skeleton,
snapshot,
category_id,
fur_color,
)
var eye_id: String = CharacterCustomizationCatalog.canonical_option_id(
"eyes", str(snapshot.get("eyes", "simple_shine"))
)
var mouth_id: String = CharacterCustomizationCatalog.canonical_option_id(
"mouth", str(snapshot.get("mouth", "three"))
)
var nose_id: String = CharacterCustomizationCatalog.canonical_option_id(
"nose", str(snapshot.get("nose", "dog_round"))
)
if head_changed or _appearance_option_changed(previous, snapshot, "eyes"):
_set_selected_head_feature_texture(
skeleton,
snapshot,
"eyes",
CharacterCustomizationCatalog.texture_for("eyes", eye_id),
)
if head_changed or _appearance_option_changed(previous, snapshot, "mouth"):
_set_selected_head_feature_texture(
skeleton,
snapshot,
"mouth",
CharacterCustomizationCatalog.texture_for("mouth", mouth_id),
)
if head_changed or _appearance_option_changed(previous, snapshot, "nose"):
_set_selected_head_feature_texture(
skeleton,
snapshot,
"nose",
CharacterCustomizationCatalog.texture_for("nose", nose_id),
)
visuals.set_meta(APPLIED_APPEARANCE_META, snapshot.duplicate(true))
static func _set_selected_head_feature_texture(
skeleton: Skeleton3D,
snapshot: Dictionary,
feature_name: String,
texture: Texture2D,
) -> void:
var head_name := _model_category_selection(snapshot, "species")
var mesh_instance: MeshInstance3D = skeleton.get_node_or_null(
"%s_%s" % [head_name, feature_name]
) as MeshInstance3D
if texture == null:
if mesh_instance != null:
mesh_instance.visible = false
return
var material: StandardMaterial3D = _runtime_material(mesh_instance)
if material != null:
# Feature plates are transparent geometry. The exported base model may
# have no preview texture, so enforce the runtime alpha mode when a
# drop-in PNG is assigned.
material.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
material.albedo_color = Color.WHITE
material.albedo_texture = texture
static func _set_model_category_visibility(
skeleton: Skeleton3D,
snapshot: Dictionary,
category_id: String,
) -> void:
var selected_mesh := CharacterCustomizationCatalog.model_mesh_name(
category_id,
str(snapshot.get(category_id, "none")),
)
for mesh_name: String in CharacterCustomizationCatalog.model_mesh_names(
category_id
):
var mesh_node := skeleton.get_node_or_null(mesh_name) as Node3D
if mesh_node != null:
mesh_node.visible = mesh_name == selected_mesh
static func _set_head_feature_visibility(
skeleton: Skeleton3D,
snapshot: Dictionary,
) -> void:
var selected_head := _model_category_selection(snapshot, "species")
for head_name: String in CharacterCustomizationCatalog.model_mesh_names(
"species"
):
for feature_name: String in ["eyes", "mouth", "nose"]:
var decal := skeleton.get_node_or_null(
"%s_%s" % [head_name, feature_name]
) as Node3D
if decal != null:
decal.visible = (
head_name == selected_head
and _appearance_option(snapshot, feature_name, "none")
!= "none"
)
static func _set_selected_head_feature_visibility(
skeleton: Skeleton3D,
snapshot: Dictionary,
feature_name: String,
) -> void:
var head_name := _model_category_selection(snapshot, "species")
var decal := skeleton.get_node_or_null(
"%s_%s" % [head_name, feature_name]
) as Node3D
if decal != null:
decal.visible = _appearance_option(
snapshot,
feature_name,
"none",
) != "none"
static func _set_selected_category_fur(
skeleton: Skeleton3D,
snapshot: Dictionary,
category_id: String,
base_color: Color,
) -> void:
var mesh_name := _model_category_selection(snapshot, category_id)
if not mesh_name.is_empty():
_set_fur_mesh(skeleton, mesh_name, snapshot, base_color)
static func _model_category_selection(
snapshot: Dictionary,
category_id: String,
) -> String:
return CharacterCustomizationCatalog.model_mesh_name(
category_id,
_appearance_option(
snapshot,
category_id,
str(MODEL_CATEGORY_DEFAULTS.get(category_id, "none")),
),
)
static func _appearance_option(
snapshot: Dictionary,
category_id: String,
fallback: String = "",
) -> String:
return CharacterCustomizationCatalog.canonical_option_id(
category_id,
str(snapshot.get(category_id, fallback)),
)
static func _appearance_option_changed(
previous: Dictionary,
current: Dictionary,
category_id: String,
) -> bool:
return _appearance_option(previous, category_id) != _appearance_option(
current,
category_id,
)
static func _set_fur_mesh(
skeleton: Skeleton3D,
mesh_name: String,
snapshot: Dictionary,
base_color: Color,
) -> void:
var mesh_instance := skeleton.get_node_or_null(mesh_name) as MeshInstance3D
if mesh_instance == null:
return
var style_field := (
CharacterCustomizationCatalog.fur_style_field_for_component(mesh_name)
)
var style_id := CharacterCustomizationCatalog.canonical_option_id(
style_field,
str(snapshot.get(
style_field,
CharacterCustomizationCatalog.DEFAULT_FUR_STYLE,
)),
)
var pattern_texture := CharacterCustomizationCatalog.fur_pattern_texture(
style_id,
mesh_name,
)
if pattern_texture == null:
var solid_material := _runtime_material(mesh_instance)
if solid_material != null:
solid_material.albedo_color = base_color
mesh_instance.material_override = solid_material
return
var pattern_material := _runtime_fur_pattern_material(mesh_instance)
pattern_material.set_shader_parameter("pattern_texture", pattern_texture)
pattern_material.set_shader_parameter("base_color", base_color)
pattern_material.set_shader_parameter(
"red_zone_color",
_fur_snapshot_color(snapshot, "fur_color_2", "cream"),
)
pattern_material.set_shader_parameter(
"green_zone_color",
_fur_snapshot_color(snapshot, "fur_color_3", "brown"),
)
pattern_material.set_shader_parameter(
"blue_zone_color",
_fur_snapshot_color(snapshot, "fur_color_4", "red"),
)
mesh_instance.material_override = pattern_material
static func _fur_snapshot_color(
snapshot: Dictionary,
category_id: String,
default_id: String,
) -> Color:
return CharacterCustomizationCatalog.option_color(
category_id,
str(snapshot.get(category_id, default_id)),
)
static func _runtime_material(
mesh_instance: MeshInstance3D,
) -> StandardMaterial3D:
if mesh_instance == null or mesh_instance.mesh == null:
return null
if mesh_instance.has_meta(RUNTIME_MATERIAL_META):
var runtime := mesh_instance.get_meta(
RUNTIME_MATERIAL_META
) as StandardMaterial3D
_configure_matte_material(runtime)
return runtime
var source: StandardMaterial3D = mesh_instance.mesh.surface_get_material(
0
) as StandardMaterial3D
if source == null:
return null
var material: StandardMaterial3D = source.duplicate() as StandardMaterial3D
_configure_matte_material(material)
mesh_instance.material_override = material
mesh_instance.set_meta(RUNTIME_MATERIAL_META, material)
return material
static func _runtime_fur_pattern_material(
mesh_instance: MeshInstance3D,
) -> ShaderMaterial:
if mesh_instance.has_meta(RUNTIME_FUR_PATTERN_MATERIAL_META):
return mesh_instance.get_meta(
RUNTIME_FUR_PATTERN_MATERIAL_META
) as ShaderMaterial
var material := ShaderMaterial.new()
material.shader = FurPatternShader
mesh_instance.set_meta(RUNTIME_FUR_PATTERN_MATERIAL_META, material)
return material
static func _configure_matte_material(material: StandardMaterial3D) -> void:
if material == null:
return
material.texture_filter = BaseMaterial3D.TEXTURE_FILTER_NEAREST
# Character artwork is deliberately graphic and flat. Unshaded rendering
# keeps the authored fur and facial colors invariant across world weather,
# time of day, and the isolated Profile preview.
material.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
material.metallic = 0.0
material.metallic_specular = 0.0
material.roughness = 1.0
material.diffuse_mode = BaseMaterial3D.DIFFUSE_LAMBERT
material.anisotropy_enabled = false
material.clearcoat_enabled = false
material.rim_enabled = false