feat(M03): add entity presentation facade
This commit is contained in:
@@ -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
|
||||
@@ -9,6 +9,8 @@ const GODOT_WORLD_POSITION_SCRIPT := preload("res://src/domain/coordinates/godot
|
||||
const STREAMING_FOCUS_SCRIPT := preload("res://src/domain/streaming/streaming_focus.gd")
|
||||
const RENDERED_GROUND_SAMPLE_SCRIPT := preload("res://src/render/terrain/rendered_ground_sample.gd")
|
||||
const WORLD_ENVIRONMENT_SNAPSHOT_SCRIPT := preload("res://src/render/environment/world_environment_snapshot.gd")
|
||||
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")
|
||||
|
||||
## Internal renderer implementation resolved relative to this node.
|
||||
## The referenced node remains owned by its scene parent.
|
||||
@@ -18,14 +20,19 @@ const WORLD_ENVIRONMENT_SNAPSHOT_SCRIPT := preload("res://src/render/environment
|
||||
## The facade delegates snapshots but owns neither the controller nor Environment.
|
||||
@export var world_environment_controller_path: NodePath = NodePath("../WowSkyController")
|
||||
|
||||
## Scene-owned service that creates and owns session-local world entity visuals.
|
||||
@export var world_entity_presenter_path: NodePath = NodePath("../WorldEntityPresenter")
|
||||
|
||||
## Optional explicit Node3D source sampled in Godot world coordinates.
|
||||
## Runtime scenes use the player; capture and probe tools replace it with their camera.
|
||||
@export var streaming_focus_source_path: NodePath
|
||||
|
||||
var _streaming_world_loader: Node
|
||||
var _world_environment_controller: Node
|
||||
var _world_entity_presenter: Node
|
||||
var _missing_loader_reported := false
|
||||
var _missing_environment_controller_reported := false
|
||||
var _missing_entity_presenter_reported := false
|
||||
var _missing_focus_source_reported := false
|
||||
|
||||
|
||||
@@ -116,6 +123,43 @@ func apply_environment_snapshot(world_environment_snapshot: RefCounted) -> bool:
|
||||
return bool(controller.call("apply_environment_snapshot", world_environment_snapshot))
|
||||
|
||||
|
||||
## Idempotently creates or updates one world entity visual through the renderer-
|
||||
## owned presenter. The immutable full snapshot remains caller-owned.
|
||||
func present_entity(entity_presentation_snapshot: RefCounted) -> bool:
|
||||
if (
|
||||
entity_presentation_snapshot == null
|
||||
or entity_presentation_snapshot.get_script() != ENTITY_PRESENTATION_SNAPSHOT_SCRIPT
|
||||
or not bool(entity_presentation_snapshot.get("is_valid"))
|
||||
):
|
||||
return false
|
||||
var presenter := _resolve_world_entity_presenter()
|
||||
if presenter == null:
|
||||
return false
|
||||
return bool(presenter.call("present_entity", entity_presentation_snapshot))
|
||||
|
||||
|
||||
## Removes one session-local entity visual without changing authoritative state.
|
||||
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 presenter := _resolve_world_entity_presenter()
|
||||
if presenter == null:
|
||||
return false
|
||||
return bool(presenter.call("remove_entity", entity_id))
|
||||
|
||||
|
||||
## Returns detached entity-presentation diagnostics with no Node references.
|
||||
func entity_presentation_snapshot() -> Dictionary:
|
||||
var presenter := _resolve_world_entity_presenter()
|
||||
if presenter == null:
|
||||
return {}
|
||||
var snapshot_variant = presenter.call("entity_presentation_snapshot")
|
||||
if not (snapshot_variant is Dictionary):
|
||||
push_error("WorldRenderFacade received a non-Dictionary entity presentation snapshot.")
|
||||
return {}
|
||||
return (snapshot_variant as Dictionary).duplicate(true)
|
||||
|
||||
|
||||
func _capture_streaming_focus_from_source() -> void:
|
||||
if streaming_focus_source_path == NodePath():
|
||||
return
|
||||
@@ -177,3 +221,23 @@ func _resolve_world_environment_controller() -> Node:
|
||||
_world_environment_controller = candidate
|
||||
_missing_environment_controller_reported = false
|
||||
return _world_environment_controller
|
||||
|
||||
|
||||
func _resolve_world_entity_presenter() -> Node:
|
||||
if is_instance_valid(_world_entity_presenter):
|
||||
return _world_entity_presenter
|
||||
var candidate := get_node_or_null(world_entity_presenter_path)
|
||||
if candidate == null:
|
||||
if not _missing_entity_presenter_reported:
|
||||
push_error("WorldRenderFacade cannot resolve entity presenter at %s" % world_entity_presenter_path)
|
||||
_missing_entity_presenter_reported = true
|
||||
return null
|
||||
for required_method in [&"present_entity", &"remove_entity", &"entity_presentation_snapshot"]:
|
||||
if not candidate.has_method(required_method):
|
||||
if not _missing_entity_presenter_reported:
|
||||
push_error("WorldRenderFacade entity presenter lacks %s" % required_method)
|
||||
_missing_entity_presenter_reported = true
|
||||
return null
|
||||
_world_entity_presenter = candidate
|
||||
_missing_entity_presenter_reported = false
|
||||
return _world_entity_presenter
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
[ext_resource type="Script" uid="uid://bhqbo1ftrt3a3" path="res://src/scenes/character/character_appearance_presenter.gd" id="5_appearance"]
|
||||
[ext_resource type="Script" uid="uid://c2jnn0e3mmymc" path="res://src/scenes/character/character_animation_presenter.gd" id="6_animation"]
|
||||
[ext_resource type="Script" path="res://src/render/world_render_facade.gd" id="7_render_facade"]
|
||||
[ext_resource type="Script" path="res://src/render/entities/world_entity_presenter.gd" id="8_entity_presenter"]
|
||||
|
||||
[sub_resource type="CapsuleShape3D" id="PlayerCapsuleShape_1"]
|
||||
radius = 0.45
|
||||
@@ -99,6 +100,10 @@ script = ExtResource("7_render_facade")
|
||||
streaming_world_loader_path = NodePath("..")
|
||||
streaming_focus_source_path = NodePath("../ThirdPersonPlayer")
|
||||
world_environment_controller_path = NodePath("../WowSkyController")
|
||||
world_entity_presenter_path = NodePath("../WorldEntityPresenter")
|
||||
|
||||
[node name="WorldEntityPresenter" type="Node3D" parent="."]
|
||||
script = ExtResource("8_entity_presenter")
|
||||
|
||||
[node name="ThirdPersonPlayer" type="CharacterBody3D" parent="." unique_id=502573687]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 16800, 80, 26400)
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
[ext_resource type="Script" path="res://src/scenes/character/character_appearance_presenter.gd" id="5_appearance"]
|
||||
[ext_resource type="Script" path="res://src/scenes/character/character_animation_presenter.gd" id="6_animation"]
|
||||
[ext_resource type="Script" path="res://src/render/world_render_facade.gd" id="7_render_facade"]
|
||||
[ext_resource type="Script" path="res://src/render/entities/world_entity_presenter.gd" id="8_entity_presenter"]
|
||||
|
||||
[sub_resource type="CapsuleShape3D" id="PlayerCapsuleShape_1"]
|
||||
radius = 0.45
|
||||
@@ -100,6 +101,10 @@ script = ExtResource("7_render_facade")
|
||||
streaming_world_loader_path = NodePath("..")
|
||||
streaming_focus_source_path = NodePath("../ThirdPersonPlayer")
|
||||
world_environment_controller_path = NodePath("../WowSkyController")
|
||||
world_entity_presenter_path = NodePath("../WorldEntityPresenter")
|
||||
|
||||
[node name="WorldEntityPresenter" type="Node3D" parent="."]
|
||||
script = ExtResource("8_entity_presenter")
|
||||
|
||||
[node name="ThirdPersonPlayer" type="CharacterBody3D" parent="." unique_id=502573687]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 16800, 80, 26400)
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
[gd_scene load_steps=2 format=3]
|
||||
|
||||
[sub_resource type="SphereMesh" id="SphereMesh_replacement"]
|
||||
radius = 0.5
|
||||
height = 1.0
|
||||
|
||||
[node name="ReplacementVisual" type="Node3D"]
|
||||
|
||||
[node name="Marker" type="MeshInstance3D" parent="."]
|
||||
mesh = SubResource("SphereMesh_replacement")
|
||||
@@ -0,0 +1,157 @@
|
||||
extends SceneTree
|
||||
|
||||
## Asset-free contract, lifecycle and ownership regression for world entity visuals.
|
||||
|
||||
const SNAPSHOT_SCRIPT := preload("res://src/render/entities/entity_presentation_snapshot.gd")
|
||||
const PRESENTER_SCRIPT := preload("res://src/render/entities/world_entity_presenter.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")
|
||||
|
||||
const PRIMARY_VISUAL_PATH := "res://src/tests/scenes/character_presentation_model_fixture.tscn"
|
||||
const REPLACEMENT_VISUAL_PATH := "res://src/tests/scenes/world_entity_visual_replacement_fixture.tscn"
|
||||
const SNAPSHOT_SOURCE_PATH := "res://src/render/entities/entity_presentation_snapshot.gd"
|
||||
const PRESENTER_SOURCE_PATH := "res://src/render/entities/world_entity_presenter.gd"
|
||||
const FACADE_SOURCE_PATH := "res://src/render/world_render_facade.gd"
|
||||
const RUNTIME_SCENE_PATHS: Array[String] = [
|
||||
"res://src/scenes/streaming/eastern_kingdoms_streaming.tscn",
|
||||
"res://src/scenes/streaming/kalimdor_streaming.tscn",
|
||||
]
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
_run_verification.call_deferred()
|
||||
|
||||
|
||||
func _run_verification() -> void:
|
||||
var failures: Array[String] = []
|
||||
_verify_snapshot_contract(failures)
|
||||
await _verify_presenter_lifecycle(failures)
|
||||
_verify_source_boundaries(failures)
|
||||
|
||||
if not failures.is_empty():
|
||||
for failure in failures:
|
||||
push_error("WORLD_ENTITY_PRESENTATION: %s" % failure)
|
||||
quit(1)
|
||||
return
|
||||
print("WORLD_ENTITY_PRESENTATION PASS snapshot_cases=8 lifecycle_cases=14 runtime_scenes=2")
|
||||
quit(0)
|
||||
|
||||
|
||||
func _verify_snapshot_contract(failures: Array[String]) -> void:
|
||||
var entity_id: RefCounted = ENTITY_ID_SCRIPT.new(7)
|
||||
var position: RefCounted = GODOT_WORLD_POSITION_SCRIPT.new(10.0, 20.0, 30.0)
|
||||
var valid_snapshot := SNAPSHOT_SCRIPT.create(entity_id, position, PRIMARY_VISUAL_PATH, 0.5, 2.0, false)
|
||||
_expect_true(bool(valid_snapshot.get("is_valid")), "valid snapshot", failures)
|
||||
_expect_true(int(SNAPSHOT_SCRIPT.CONTRACT_VERSION) == 1, "contract version", failures)
|
||||
_expect_failure(SNAPSHOT_SCRIPT.create(ENTITY_ID_SCRIPT.new(0), position, PRIMARY_VISUAL_PATH), &"entity_presentation_invalid_entity_id", "invalid entity", failures)
|
||||
_expect_failure(SNAPSHOT_SCRIPT.create(entity_id, RefCounted.new(), PRIMARY_VISUAL_PATH), &"entity_presentation_invalid_world_position", "invalid position type", failures)
|
||||
_expect_failure(SNAPSHOT_SCRIPT.create(entity_id, GODOT_WORLD_POSITION_SCRIPT.new(INF, 0.0, 0.0), PRIMARY_VISUAL_PATH), &"entity_presentation_position_not_finite", "non-finite position", failures)
|
||||
_expect_failure(SNAPSHOT_SCRIPT.create(entity_id, position, ""), &"entity_presentation_visual_path_empty", "empty visual path", failures)
|
||||
_expect_failure(SNAPSHOT_SCRIPT.create(entity_id, position, PRIMARY_VISUAL_PATH, NAN), &"entity_presentation_yaw_not_finite", "non-finite yaw", failures)
|
||||
_expect_failure(SNAPSHOT_SCRIPT.create(entity_id, position, PRIMARY_VISUAL_PATH, 0.0, 0.0), &"entity_presentation_scale_invalid", "zero scale", failures)
|
||||
|
||||
|
||||
func _verify_presenter_lifecycle(failures: Array[String]) -> void:
|
||||
var presenter: Node3D = PRESENTER_SCRIPT.new()
|
||||
presenter.name = "WorldEntityPresenter"
|
||||
root.add_child(presenter)
|
||||
var entity_id: RefCounted = ENTITY_ID_SCRIPT.new(7)
|
||||
var initial_snapshot := SNAPSHOT_SCRIPT.create(
|
||||
entity_id,
|
||||
GODOT_WORLD_POSITION_SCRIPT.new(10.0, 20.0, 30.0),
|
||||
PRIMARY_VISUAL_PATH,
|
||||
0.5,
|
||||
2.0,
|
||||
false
|
||||
)
|
||||
_expect_true(bool(presenter.call("present_entity", initial_snapshot)), "initial present", failures)
|
||||
var visual_root := presenter.get_node_or_null("EntityVisual_00000000000000000007") as Node3D
|
||||
_expect_true(visual_root != null, "owned visual attached", failures)
|
||||
if visual_root != null:
|
||||
_expect_vector_near(visual_root.global_position, Vector3(10.0, 20.0, 30.0), "initial position", failures)
|
||||
_expect_near(visual_root.scale.x, 2.0, "initial scale", failures)
|
||||
_expect_true(not visual_root.visible, "initial visibility", failures)
|
||||
var original_instance_id := visual_root.get_instance_id() if visual_root != null else 0
|
||||
|
||||
var updated_snapshot := SNAPSHOT_SCRIPT.create(
|
||||
entity_id,
|
||||
GODOT_WORLD_POSITION_SCRIPT.new(40.0, 50.0, 60.0),
|
||||
PRIMARY_VISUAL_PATH,
|
||||
1.0,
|
||||
1.5,
|
||||
true
|
||||
)
|
||||
_expect_true(bool(presenter.call("present_entity", updated_snapshot)), "idempotent update", failures)
|
||||
visual_root = presenter.get_node_or_null("EntityVisual_00000000000000000007") as Node3D
|
||||
_expect_true(visual_root != null and visual_root.get_instance_id() == original_instance_id, "same visual root retained", failures)
|
||||
if visual_root != null:
|
||||
_expect_vector_near(visual_root.global_position, Vector3(40.0, 50.0, 60.0), "updated position", failures)
|
||||
_expect_true(visual_root.visible, "updated visibility", failures)
|
||||
|
||||
var failed_replacement := SNAPSHOT_SCRIPT.create(entity_id, GODOT_WORLD_POSITION_SCRIPT.new(1.0, 2.0, 3.0), "res://missing/entity_visual.tscn")
|
||||
_expect_true(not bool(presenter.call("present_entity", failed_replacement)), "missing replacement rejected", failures)
|
||||
visual_root = presenter.get_node_or_null("EntityVisual_00000000000000000007") as Node3D
|
||||
_expect_true(visual_root != null and visual_root.get_instance_id() == original_instance_id, "failed replacement retains visual", failures)
|
||||
|
||||
var replacement_snapshot := SNAPSHOT_SCRIPT.create(entity_id, GODOT_WORLD_POSITION_SCRIPT.new(70.0, 80.0, 90.0), REPLACEMENT_VISUAL_PATH)
|
||||
_expect_true(bool(presenter.call("present_entity", replacement_snapshot)), "valid replacement", failures)
|
||||
visual_root = presenter.get_node_or_null("EntityVisual_00000000000000000007") as Node3D
|
||||
_expect_true(visual_root != null and visual_root.get_instance_id() != original_instance_id, "replacement swaps root", failures)
|
||||
|
||||
var diagnostic_snapshot: Dictionary = presenter.call("entity_presentation_snapshot")
|
||||
_expect_true(int(diagnostic_snapshot.get("contract_version", 0)) == 1, "diagnostic version", failures)
|
||||
_expect_true(int(diagnostic_snapshot.get("entity_count", 0)) == 1, "diagnostic entity count", failures)
|
||||
(diagnostic_snapshot.get("entities") as Array).clear()
|
||||
_expect_true(int((presenter.call("entity_presentation_snapshot") as Dictionary).get("entity_count", 0)) == 1, "diagnostic detached", failures)
|
||||
|
||||
_expect_true(bool(presenter.call("remove_entity", entity_id)), "known entity removed", failures)
|
||||
_expect_true(not bool(presenter.call("remove_entity", entity_id)), "duplicate removal rejected", failures)
|
||||
_expect_true(presenter.get_child_count() == 0, "owned visual detached", failures)
|
||||
presenter.free()
|
||||
await process_frame
|
||||
|
||||
|
||||
func _verify_source_boundaries(failures: Array[String]) -> void:
|
||||
var snapshot_source := _read_text(SNAPSHOT_SOURCE_PATH, failures)
|
||||
for forbidden_snapshot_text in ["extends Node", "Node3D", "PackedScene", "Dictionary", "Vector3"]:
|
||||
_expect_true(not snapshot_source.contains(forbidden_snapshot_text), "snapshot omits %s" % forbidden_snapshot_text, failures)
|
||||
var presenter_source := _read_text(PRESENTER_SOURCE_PATH, failures)
|
||||
for forbidden_presenter_text in ["MoveIntent", "TerrainQuery", "WowGuid", "network", "server_data"]:
|
||||
_expect_true(not presenter_source.contains(forbidden_presenter_text), "presenter omits %s" % forbidden_presenter_text, failures)
|
||||
for required_presenter_text in ["ResourceLoader.exists", "remove_child(visual_root)", "visual_root.queue_free()"]:
|
||||
_expect_true(presenter_source.contains(required_presenter_text), "presenter contains %s" % required_presenter_text, failures)
|
||||
var facade_source := _read_text(FACADE_SOURCE_PATH, failures)
|
||||
for required_facade_method in ["func present_entity(", "func remove_entity(", "func entity_presentation_snapshot()"]:
|
||||
_expect_true(facade_source.contains(required_facade_method), "facade contains %s" % required_facade_method, failures)
|
||||
for scene_path in RUNTIME_SCENE_PATHS:
|
||||
var scene_source := _read_text(scene_path, failures)
|
||||
_expect_true(scene_source.contains("world_entity_presenter.gd"), "%s loads presenter" % scene_path, failures)
|
||||
_expect_true(scene_source.contains('world_entity_presenter_path = NodePath("../WorldEntityPresenter")'), "%s facade path" % scene_path, failures)
|
||||
|
||||
|
||||
func _read_text(path: String, failures: Array[String]) -> String:
|
||||
var file := FileAccess.open(path, FileAccess.READ)
|
||||
if file == null:
|
||||
failures.append("cannot open %s" % path)
|
||||
return ""
|
||||
return file.get_as_text()
|
||||
|
||||
|
||||
func _expect_failure(snapshot: RefCounted, expected_code: StringName, label: String, failures: Array[String]) -> void:
|
||||
_expect_true(not bool(snapshot.get("is_valid")), "%s invalid" % label, failures)
|
||||
_expect_true(StringName(snapshot.get("failure_code")) == expected_code, "%s failure code" % label, failures)
|
||||
|
||||
|
||||
func _expect_vector_near(actual_value: Vector3, expected_value: Vector3, label: String, failures: Array[String]) -> void:
|
||||
if not actual_value.is_equal_approx(expected_value):
|
||||
failures.append("%s expected %s, got %s" % [label, expected_value, actual_value])
|
||||
|
||||
|
||||
func _expect_near(actual_value: float, expected_value: float, label: String, failures: Array[String]) -> void:
|
||||
if not is_equal_approx(actual_value, expected_value):
|
||||
failures.append("%s expected %.3f, got %.3f" % [label, expected_value, actual_value])
|
||||
|
||||
|
||||
func _expect_true(actual_value: bool, label: String, failures: Array[String]) -> void:
|
||||
if not actual_value:
|
||||
failures.append("%s expected true" % label)
|
||||
@@ -0,0 +1 @@
|
||||
uid://dkkjbc8pcglcp
|
||||
@@ -8,6 +8,8 @@ const STREAMING_FOCUS_SCRIPT := preload("res://src/domain/streaming/streaming_fo
|
||||
const RENDERED_GROUND_SAMPLE_SCRIPT := preload("res://src/render/terrain/rendered_ground_sample.gd")
|
||||
const WORLD_ENVIRONMENT_SNAPSHOT_SCRIPT := preload("res://src/render/environment/world_environment_snapshot.gd")
|
||||
const WOW_SKY_CONTROLLER_SCRIPT := preload("res://src/scenes/sky/wow_sky_controller.gd")
|
||||
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")
|
||||
|
||||
const RUNTIME_SCENE_PATHS: Array[String] = [
|
||||
"res://src/scenes/streaming/eastern_kingdoms_streaming.tscn",
|
||||
@@ -57,6 +59,23 @@ class WorldEnvironmentControllerDouble extends Node:
|
||||
return true
|
||||
|
||||
|
||||
class WorldEntityPresenterDouble extends Node:
|
||||
var presented_snapshot: RefCounted
|
||||
var removed_entity_id: RefCounted
|
||||
var diagnostics := {"entity_count": 1, "entities": [{"entity_key": "entity:7"}]}
|
||||
|
||||
func present_entity(entity_presentation_snapshot: RefCounted) -> bool:
|
||||
presented_snapshot = entity_presentation_snapshot
|
||||
return true
|
||||
|
||||
func remove_entity(entity_id: RefCounted) -> bool:
|
||||
removed_entity_id = entity_id
|
||||
return true
|
||||
|
||||
func entity_presentation_snapshot() -> Dictionary:
|
||||
return diagnostics
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
var failures: Array[String] = []
|
||||
_verify_rendered_ground_sample_contract(failures)
|
||||
@@ -70,7 +89,7 @@ func _initialize() -> void:
|
||||
quit(1)
|
||||
return
|
||||
|
||||
print("WORLD_RENDER_FACADE PASS delegation=6 ground_contract=2 environment_contract=7 runtime_scenes=2 tools=3")
|
||||
print("WORLD_RENDER_FACADE PASS delegation=9 ground_contract=2 environment_contract=7 entity_contract=6 runtime_scenes=2 tools=3")
|
||||
quit(0)
|
||||
|
||||
|
||||
@@ -116,14 +135,18 @@ func _verify_delegation_and_snapshot_isolation(failures: Array[String]) -> void:
|
||||
source.position = Vector3(10.0, 20.0, 30.0)
|
||||
var environment_controller := WorldEnvironmentControllerDouble.new()
|
||||
environment_controller.name = "WorldEnvironmentControllerDouble"
|
||||
var entity_presenter := WorldEntityPresenterDouble.new()
|
||||
entity_presenter.name = "WorldEntityPresenterDouble"
|
||||
var facade = WORLD_RENDER_FACADE_SCRIPT.new()
|
||||
facade.name = "WorldRenderFacade"
|
||||
facade.streaming_world_loader_path = NodePath("../StreamingWorldLoaderDouble")
|
||||
facade.streaming_focus_source_path = NodePath("../FocusSource")
|
||||
facade.world_environment_controller_path = NodePath("../WorldEnvironmentControllerDouble")
|
||||
facade.world_entity_presenter_path = NodePath("../WorldEntityPresenterDouble")
|
||||
test_root.add_child(loader)
|
||||
test_root.add_child(source)
|
||||
test_root.add_child(environment_controller)
|
||||
test_root.add_child(entity_presenter)
|
||||
test_root.add_child(facade)
|
||||
get_root().add_child(test_root)
|
||||
await process_frame
|
||||
@@ -181,6 +204,21 @@ func _verify_delegation_and_snapshot_isolation(failures: Array[String]) -> void:
|
||||
"invalid environment snapshot rejected",
|
||||
failures
|
||||
)
|
||||
|
||||
var entity_id: RefCounted = ENTITY_ID_SCRIPT.new(7)
|
||||
var entity_snapshot: RefCounted = ENTITY_PRESENTATION_SNAPSHOT_SCRIPT.create(
|
||||
entity_id,
|
||||
GODOT_WORLD_POSITION_SCRIPT.new(1.0, 2.0, 3.0),
|
||||
"res://src/tests/scenes/character_presentation_model_fixture.tscn"
|
||||
)
|
||||
_expect_true(facade.present_entity(entity_snapshot), "entity presentation delegates", failures)
|
||||
_expect_true(entity_presenter.presented_snapshot == entity_snapshot, "entity snapshot reference delegates", failures)
|
||||
_expect_true(facade.remove_entity(entity_id), "entity removal delegates", failures)
|
||||
_expect_true(entity_presenter.removed_entity_id == entity_id, "entity identity reference delegates", failures)
|
||||
var entity_diagnostics: Dictionary = facade.entity_presentation_snapshot()
|
||||
(entity_diagnostics.get("entities") as Array).clear()
|
||||
_expect_true(entity_presenter.diagnostics.entities.size() == 1, "entity diagnostics detached", failures)
|
||||
_expect_true(not facade.present_entity(ENTITY_PRESENTATION_SNAPSHOT_SCRIPT.create(ENTITY_ID_SCRIPT.new(0), GODOT_WORLD_POSITION_SCRIPT.new(0.0, 0.0, 0.0), "res://fixture.tscn")), "invalid entity snapshot rejected", failures)
|
||||
test_root.queue_free()
|
||||
|
||||
|
||||
@@ -191,6 +229,8 @@ func _verify_repository_wiring(failures: Array[String]) -> void:
|
||||
_expect_true(scene_source.contains('[node name="WorldRenderFacade" type="Node" parent="."]'), "%s owns facade node" % scene_path, failures)
|
||||
_expect_true(scene_source.contains('streaming_focus_source_path = NodePath("../ThirdPersonPlayer")'), "%s facade uses player focus" % scene_path, failures)
|
||||
_expect_true(scene_source.contains('world_environment_controller_path = NodePath("../WowSkyController")'), "%s facade uses sky controller" % scene_path, failures)
|
||||
_expect_true(scene_source.contains('world_entity_presenter_path = NodePath("../WorldEntityPresenter")'), "%s facade uses entity presenter" % scene_path, failures)
|
||||
_expect_true(scene_source.contains('[node name="WorldEntityPresenter" type="Node3D" parent="."]'), "%s owns entity presenter service" % scene_path, failures)
|
||||
|
||||
for tool_path in TOOL_PATHS:
|
||||
var tool_source := _read_text(tool_path, failures)
|
||||
|
||||
Reference in New Issue
Block a user