feat(M03): add entity presentation facade

This commit is contained in:
2026-07-16 01:11:58 +04:00
parent 3c5880f190
commit 1807c3a363
15 changed files with 829 additions and 7 deletions
@@ -0,0 +1,104 @@
class_name EntityPresentationSnapshot
extends RefCounted
## Immutable renderer input for one session-local world entity visual.
## Gameplay authority, identity allocation and asset selection remain external.
const CONTRACT_VERSION := 1
const SCRIPT_PATH := "res://src/render/entities/entity_presentation_snapshot.gd"
const ENTITY_ID_SCRIPT := preload("res://src/domain/identity/entity_id.gd")
const GODOT_WORLD_POSITION_SCRIPT := preload("res://src/domain/coordinates/godot_world_position.gd")
var is_valid: bool:
get:
return _failure_code.is_empty()
var failure_code: StringName:
get:
return _failure_code
var entity_id: RefCounted:
get:
return _entity_id
var world_position: RefCounted:
get:
return _world_position
var visual_scene_path: String:
get:
return _visual_scene_path
var yaw_radians: float:
get:
return _yaw_radians
var uniform_scale: float:
get:
return _uniform_scale
var is_visible: bool:
get:
return _is_visible
var _failure_code: StringName
var _entity_id: RefCounted
var _world_position: RefCounted
var _visual_scene_path: String
var _yaw_radians: float
var _uniform_scale: float
var _is_visible: bool
## Creates a version-1 full visual snapshot. Invalid values remain inspectable and
## are rejected without scene mutation by the facade/presenter.
static func create(
entity_id_value: RefCounted,
world_position_value: RefCounted,
visual_scene_path_value: String,
yaw_radians_value: float = 0.0,
uniform_scale_value: float = 1.0,
is_visible_value: bool = true
) -> RefCounted:
return load(SCRIPT_PATH).new(
entity_id_value,
world_position_value,
visual_scene_path_value,
yaw_radians_value,
uniform_scale_value,
is_visible_value
)
func _init(
entity_id_value: RefCounted,
world_position_value: RefCounted,
visual_scene_path_value: String,
yaw_radians_value: float,
uniform_scale_value: float,
is_visible_value: bool
) -> void:
_entity_id = entity_id_value
_world_position = world_position_value
_visual_scene_path = visual_scene_path_value.strip_edges()
_yaw_radians = yaw_radians_value
_uniform_scale = uniform_scale_value
_is_visible = is_visible_value
_failure_code = _validate()
func _validate() -> StringName:
if _entity_id == null or _entity_id.get_script() != ENTITY_ID_SCRIPT or not bool(_entity_id.call("is_valid")):
return &"entity_presentation_invalid_entity_id"
if _world_position == null or _world_position.get_script() != GODOT_WORLD_POSITION_SCRIPT:
return &"entity_presentation_invalid_world_position"
for coordinate_name in [&"x_units", &"y_units", &"z_units"]:
if not is_finite(float(_world_position.get(coordinate_name))):
return &"entity_presentation_position_not_finite"
if _visual_scene_path.is_empty():
return &"entity_presentation_visual_path_empty"
if not is_finite(_yaw_radians):
return &"entity_presentation_yaw_not_finite"
if not is_finite(_uniform_scale) or _uniform_scale <= 0.0:
return &"entity_presentation_scale_invalid"
return &""
@@ -0,0 +1 @@
uid://djvnorls0yf06
@@ -0,0 +1,130 @@
@tool
class_name WorldEntityPresenter
extends Node3D
## Main-thread owner of visual subtrees for session-local world entities.
## The full immutable snapshot is reapplied idempotently by entity identity.
const ENTITY_PRESENTATION_SNAPSHOT_SCRIPT := preload("res://src/render/entities/entity_presentation_snapshot.gd")
const ENTITY_ID_SCRIPT := preload("res://src/domain/identity/entity_id.gd")
var _presented_entities: Dictionary = {}
## Creates or updates one entity visual. A changed visual path is instantiated
## successfully before the previous subtree is detached, so failed replacement
## retains the last valid presentation.
func present_entity(entity_presentation_snapshot: RefCounted) -> bool:
if not _is_valid_snapshot(entity_presentation_snapshot):
return false
var entity_id: RefCounted = entity_presentation_snapshot.get("entity_id")
var entity_key := _entity_key(entity_id)
var visual_scene_path := String(entity_presentation_snapshot.get("visual_scene_path"))
var current_entry: Dictionary = _presented_entities.get(entity_key, {})
var visual_root := current_entry.get("visual_root") as Node3D
if visual_root == null or not is_instance_valid(visual_root) or String(current_entry.get("visual_scene_path", "")) != visual_scene_path:
var replacement_root := _instantiate_visual_root(entity_id, visual_scene_path)
if replacement_root == null:
return false
if visual_root != null and is_instance_valid(visual_root):
remove_child(visual_root)
add_child(replacement_root)
_apply_snapshot_transform(replacement_root, entity_presentation_snapshot)
if visual_root != null and is_instance_valid(visual_root):
visual_root.queue_free()
visual_root = replacement_root
else:
_apply_snapshot_transform(visual_root, entity_presentation_snapshot)
_presented_entities[entity_key] = {
"visual_root": visual_root,
"visual_scene_path": visual_scene_path,
"snapshot": entity_presentation_snapshot,
}
return true
## Removes one owned entity visual. Returns false for an invalid/unknown identity.
func remove_entity(entity_id: RefCounted) -> bool:
if entity_id == null or entity_id.get_script() != ENTITY_ID_SCRIPT or not bool(entity_id.call("is_valid")):
return false
var entity_key := _entity_key(entity_id)
if not _presented_entities.has(entity_key):
return false
var entry: Dictionary = _presented_entities[entity_key]
var visual_root := entry.get("visual_root") as Node3D
_presented_entities.erase(entity_key)
if visual_root != null and is_instance_valid(visual_root):
remove_child(visual_root)
visual_root.queue_free()
return true
## Returns detached scalar diagnostics without exposing owned Nodes or snapshots.
func entity_presentation_snapshot() -> Dictionary:
var entities: Array[Dictionary] = []
var sorted_entity_keys: Array = _presented_entities.keys()
sorted_entity_keys.sort()
for entity_key_variant in sorted_entity_keys:
var entity_key := String(entity_key_variant)
var entry: Dictionary = _presented_entities[entity_key]
var snapshot: RefCounted = entry.get("snapshot")
entities.append({
"entity_key": entity_key,
"visual_scene_path": String(entry.get("visual_scene_path", "")),
"visible": bool(snapshot.get("is_visible")),
})
return {
"contract_version": ENTITY_PRESENTATION_SNAPSHOT_SCRIPT.CONTRACT_VERSION,
"entity_count": entities.size(),
"entities": entities,
}
func _exit_tree() -> void:
_presented_entities.clear()
func _is_valid_snapshot(entity_presentation_snapshot: RefCounted) -> bool:
return (
entity_presentation_snapshot != null
and entity_presentation_snapshot.get_script() == ENTITY_PRESENTATION_SNAPSHOT_SCRIPT
and bool(entity_presentation_snapshot.get("is_valid"))
)
func _entity_key(entity_id: RefCounted) -> String:
return String(entity_id.call("to_debug_key"))
func _instantiate_visual_root(entity_id: RefCounted, visual_scene_path: String) -> Node3D:
if not ResourceLoader.exists(visual_scene_path, "PackedScene"):
push_warning("WorldEntityPresenter cannot find visual scene: %s" % visual_scene_path)
return null
var visual_scene := load(visual_scene_path) as PackedScene
if visual_scene == null:
push_warning("WorldEntityPresenter cannot load visual scene: %s" % visual_scene_path)
return null
var visual_instance := visual_scene.instantiate()
if not (visual_instance is Node3D):
visual_instance.free()
push_warning("WorldEntityPresenter visual scene root is not Node3D: %s" % visual_scene_path)
return null
var visual_root := visual_instance as Node3D
visual_root.name = "EntityVisual_%020d" % int(entity_id.get("session_sequence"))
return visual_root
func _apply_snapshot_transform(visual_root: Node3D, entity_presentation_snapshot: RefCounted) -> void:
var world_position: RefCounted = entity_presentation_snapshot.get("world_position")
var position_units := Vector3(
float(world_position.get("x_units")),
float(world_position.get("y_units")),
float(world_position.get("z_units"))
)
var uniform_scale := float(entity_presentation_snapshot.get("uniform_scale"))
var yaw_radians := float(entity_presentation_snapshot.get("yaw_radians"))
visual_root.global_transform = Transform3D(
Basis(Vector3.UP, yaw_radians).scaled(Vector3.ONE * uniform_scale),
position_units
)
visual_root.visible = bool(entity_presentation_snapshot.get("is_visible"))
@@ -0,0 +1 @@
uid://b0keiyi1wuu5i