From 6ad1fd6f4701c78f0c3a4a8fb50bd23f42615f22 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Fri, 20 Mar 2026 15:26:29 +0200 Subject: [PATCH] feat(attributes): add Range attribute for property constraints and enhance pause/resume logic in game states --- src/Core/Attributes/Range.php | 16 + src/Core/Scenes/SceneManager.php | 1813 ++++++++++++++++--- src/Core/Texture.php | 34 + src/States/PausedState.php | 6 +- src/States/SceneState.php | 8 +- tests/Unit/Core/Scenes/SceneManagerTest.php | 195 ++ tests/Unit/States/PausedStateTest.php | 113 ++ tests/Unit/States/SceneStateTest.php | 178 ++ 8 files changed, 2124 insertions(+), 239 deletions(-) create mode 100644 src/Core/Attributes/Range.php create mode 100644 tests/Unit/States/SceneStateTest.php diff --git a/src/Core/Attributes/Range.php b/src/Core/Attributes/Range.php new file mode 100644 index 0000000..4c85b99 --- /dev/null +++ b/src/Core/Attributes/Range.php @@ -0,0 +1,16 @@ + $scenes The list of scenes. */ @@ -70,7 +76,6 @@ final class SceneManager implements SingletonInterface, CanStart, CanResume, Can */ protected EventManager $eventManager; protected Physics $physics; - protected static ?string $metadataAssetsRoot = null; /** * Constructs a SceneManager @@ -96,16 +101,6 @@ public static function getInstance(): self return self::$instance; } - /** - * Returns the currently active scene. - * - * @return SceneInterface|null - */ - public function getActiveScene(): ?SceneInterface - { - return $this->activeSceneNode?->getScene(); - } - /** * Checks whether a scene exists at the given index or name. * @@ -134,20 +129,6 @@ public function hasScene(int|string $index): bool return false; } - /** - * Adds a scene to the SceneManager. - * - * @param SceneInterface $scene The scene to add. - * @param mixed|null $data The data to associate with the scene. - * @return $this The SceneManager instance. - */ - public function addScene(SceneInterface $scene, mixed $data = null): self - { - $this->scenes->add($scene); - - return $this; - } - /** * Removes a scene from the SceneManager. * @@ -258,6 +239,48 @@ public function stop(): void $this->activeSceneNode?->getScene()->stop(); } + /** + * @inheritDoc + */ + public function unload(): void + { + $this->activeSceneNode?->getScene()->unload(); + } + + /** + * Returns the settings for the SceneManager. + * + * @param string|null $key + * @return mixed + */ + public function getSettings(?string $key = null): mixed + { + if ($key === null) { + return $this->settings; + } + + return array_key_exists($key, $this->settings) ? $this->settings[$key] : null; + } + + /** + * @inheritDoc + */ + public function load(): void + { + $this->physics->init(); + foreach ($this->activeSceneNode->getScene()->getRootGameObjects() as $gameObject) { + if ($collider = $gameObject->getComponent(ColliderInterface::class)) { + assert($collider instanceof ColliderInterface, new IncorrectComponentTypeException( + ColliderInterface::class, + get_class($collider) + )); + $this->physics->addCollider($collider);; + } + } + $this->activeSceneNode->getScene()->load(); + dispatchEvent(new SceneEvent(SceneEventType::LOAD_END)); + } + /** * @inheritDoc */ @@ -314,17 +337,6 @@ public function suspend(): void $this->activeSceneNode?->getScene()->suspend(); } - /** - * Updates the physics of the active scene. - */ - public function updatePhysics(): void - { - if ($this->activeSceneNode) { - $this->activeSceneNode->getScene()->updatePhysics(); - dispatchEvent(new SceneEvent(SceneEventType::UPDATE_PHYSICS, $this->activeSceneNode->getScene())); - } - } - /** * @inheritDoc */ @@ -339,57 +351,26 @@ public function update(): void } /** - * Loads the settings for the SceneManager. - * - * @param array $settings + * Updates the physics of the active scene. */ - public function loadSettings(?array $settings = null): void + public function updatePhysics(): void { - if ($settings) { - $this->settings = $settings; + if ($this->activeSceneNode) { + $this->activeSceneNode->getScene()->updatePhysics(); + dispatchEvent(new SceneEvent(SceneEventType::UPDATE_PHYSICS, $this->activeSceneNode->getScene())); } } /** - * Returns the settings for the SceneManager. + * Loads the settings for the SceneManager. * - * @param string|null $key - * @return mixed - */ - public function getSettings(?string $key = null): mixed - { - if ($key === null) { - return $this->settings; - } - - return array_key_exists($key, $this->settings) ? $this->settings[$key] : null; - } - - /** - * @inheritDoc + * @param array $settings */ - public function load(): void + public function loadSettings(?array $settings = null): void { - $this->physics->init(); - foreach ($this->activeSceneNode->getScene()->getRootGameObjects() as $gameObject) { - if ($collider = $gameObject->getComponent(ColliderInterface::class)) { - assert($collider instanceof ColliderInterface, new IncorrectComponentTypeException( - ColliderInterface::class, - get_class($collider) - )); - $this->physics->addCollider($collider);; - } + if ($settings) { + $this->settings = $settings; } - $this->activeSceneNode->getScene()->load(); - dispatchEvent(new SceneEvent(SceneEventType::LOAD_END)); - } - - /** - * @inheritDoc - */ - public function unload(): void - { - $this->activeSceneNode?->getScene()->unload(); } /** @@ -447,6 +428,8 @@ public function awake(): void // Build hierarchy if (isset($sceneMetadata->hierarchy)) { + $pendingComponentPropertyAssignments = []; + foreach ($sceneMetadata->hierarchy as $index => $item) { if (!isset($item->type)) { Debug::warn("The 'type' property is not supported in scene hierarchy items. Item: " . ($item->name ?? "Unnamed GameObject") . " - $index"); @@ -469,11 +452,15 @@ public function awake(): void switch ($item->type) { case GameObject::class: - $gameObject = SceneManager::inflateGameObjectMetadata($item); + $gameObject = SceneManager::inflateGameObjectMetadata( + $item, + $this, + $pendingComponentPropertyAssignments, + ); break; default: - $gameObject = match($item->type) { + $gameObject = match ($item->type) { Label::class => new Label($this, $itemName, $position, $size), Text::class => new Text($this, $itemName, $position, $size), GUITexture::class => new GUITexture($this, $itemName, $position, $size), @@ -506,6 +493,11 @@ public function awake(): void $this->add($gameObject); } + + SceneManager::resolvePendingSceneComponentPropertyAssignments( + $pendingComponentPropertyAssignments, + $this, + ); } } }; @@ -517,10 +509,16 @@ public function awake(): void * Inflates a game object from scene/prefab metadata without attaching it to a scene. * * @param object|array $itemMetadata + * @param SceneInterface|null $sceneContext + * @param array|null $pendingComponentPropertyAssignments * @return GameObject * @throws SceneManagementException */ - public static function inflateGameObjectMetadata(object|array $itemMetadata): GameObject + public static function inflateGameObjectMetadata( + object|array $itemMetadata, + ?SceneInterface $sceneContext = null, + ?array &$pendingComponentPropertyAssignments = null, + ): GameObject { $item = self::normalizeMetadata($itemMetadata); @@ -574,13 +572,23 @@ public static function inflateGameObjectMetadata(object|array $itemMetadata): Ga $componentClass = $componentMetadataObject->class; $component = $gameObject->addComponent($componentClass); - self::applySceneComponentMetadata($component, $componentClass, $componentMetadataObject); + self::applySceneComponentMetadata( + $component, + $componentClass, + $componentMetadataObject, + $sceneContext, + $pendingComponentPropertyAssignments, + ); } } if (isset($item->children) && is_iterable($item->children)) { foreach ($item->children as $childMetadata) { - $child = self::inflateGameObjectMetadata($childMetadata); + $child = self::inflateGameObjectMetadata( + $childMetadata, + $sceneContext, + $pendingComponentPropertyAssignments, + ); $child->getTransform()->setParent($gameObject->getTransform()); } } @@ -589,224 +597,413 @@ public static function inflateGameObjectMetadata(object|array $itemMetadata): Ga } /** - * Loads and inflates a prefab reference into a concrete game object template. + * Normalizes scene/prefab metadata to an object for consistent property access. * - * @param string $path - * @return GameObject - * @throws SceneManagementException + * @param object|array $metadata + * @return object */ - public static function loadPrefabFromPath(string $path): GameObject + private static function normalizeMetadata(object|array $metadata): object { - $resolvedPath = self::resolvePrefabPath($path); - - if (!$resolvedPath) { - throw new SceneManagementException("Prefab not found: {$path}"); - } - - try { - $prefabMetadata = require $resolvedPath; - } catch (Throwable $throwable) { - throw new SceneManagementException( - "Failed to load prefab at {$resolvedPath}: {$throwable->getMessage()}", - previous: $throwable - ); - } - - if (!is_array($prefabMetadata) && !is_object($prefabMetadata)) { - throw new SceneManagementException("Prefab metadata at {$resolvedPath} did not return a valid game object description."); + if (is_object($metadata)) { + return $metadata; } - return self::inflateGameObjectMetadata($prefabMetadata); + return json_decode(json_encode($metadata, JSON_UNESCAPED_SLASHES), false); } /** - * Resolves a prefab reference from either an absolute filesystem path or an assets-relative path. + * Applies editor/file-scene component metadata onto the instantiated component. * - * @param string $path - * @return string|null + * Supports legacy `proerties`, current `properties`, and editor-authored `data` payloads. + * + * @param object $component + * @param string $componentClass + * @param object $componentMetadata + * @param SceneInterface|null $sceneContext + * @param array|null $pendingComponentPropertyAssignments + * @return void */ - private static function resolvePrefabPath(string $path): ?string + public static function applySceneComponentMetadata( + object $component, + string $componentClass, + object $componentMetadata, + ?SceneInterface $sceneContext = null, + ?array &$pendingComponentPropertyAssignments = null, + ): void { - if ($path === '') { - return null; + $componentProperties = $componentMetadata->properties + ?? $componentMetadata->proerties + ?? $componentMetadata->data + ?? null; + + if (!$componentProperties) { + return; } - $candidates = [$path]; - if (is_string(self::$metadataAssetsRoot) && self::$metadataAssetsRoot !== '') { - $candidates[] = Path::join(self::$metadataAssetsRoot, $path); + $componentOptions = (array)$componentProperties; + + if (method_exists($component, 'configure')) { + $component->configure($componentOptions); } - $assetsRelativePath = Path::join(Path::getWorkingDirectoryAssetsPath(), $path); - $candidates[] = $assetsRelativePath; - if (!str_ends_with(strtolower($path), '.prefab.php')) { - $candidates[] = $path . '.prefab.php'; - if (is_string(self::$metadataAssetsRoot) && self::$metadataAssetsRoot !== '') { - $candidates[] = Path::join(self::$metadataAssetsRoot, $path . '.prefab.php'); + $reflection = new ReflectionObject($component); + + foreach ($componentOptions as $key => $value) { + if ($key === 'material' && ($component instanceof Collider || $component instanceof Rigidbody)) { + $component->setMaterial(PhysicsMaterial::fromMetadata((array)$value)); + continue; } - $candidates[] = $assetsRelativePath . '.prefab.php'; - } - foreach ($candidates as $candidate) { - if (is_file($candidate)) { - return Path::normalize($candidate); + if (!$reflection->hasProperty($key)) { + Debug::warn("Property '$key' does not exist on component of type: " . $componentClass); + continue; } - } - return null; - } + $property = $reflection->getProperty($key); - /** - * Normalizes scene/prefab metadata to an object for consistent property access. - * - * @param object|array $metadata - * @return object - */ - private static function normalizeMetadata(object|array $metadata): object - { - if (is_object($metadata)) { - return $metadata; - } + if (!$property->isPublic() && !$property->getAttributes(SerializeField::class)) { + continue; + } - return json_decode(json_encode($metadata, JSON_UNESCAPED_SLASHES), false); + $assignment = self::resolveSceneComponentPropertyAssignment($property, $value, $sceneContext); + + if (($assignment['shouldAssign'] ?? true) !== true) { + $referenceName = $assignment['referenceName'] ?? null; + + if ( + is_array($pendingComponentPropertyAssignments) + && is_string($referenceName) + && $referenceName !== '' + ) { + $pendingComponentPropertyAssignments[] = [ + 'component' => $component, + 'property' => $property, + 'referenceName' => $referenceName, + ]; + } + + continue; + } + + $property->setValue($component, $assignment['value'] ?? null); + } } /** - * Extracts a UI texture path from scene metadata. + * Converts serialized scene values into runtime objects when a typed property requires it. * - * @param mixed $textureMetadata - * @return string|null + * @param ReflectionProperty $property + * @param mixed $value + * @param SceneInterface|null $sceneContext + * @return array{shouldAssign: bool, value?: mixed, referenceName?: string} + * @throws SceneManagementException */ - public static function extractTexturePathFromMetadata(mixed $textureMetadata): ?string + private static function resolveSceneComponentPropertyAssignment( + ReflectionProperty $property, + mixed $value, + ?SceneInterface $sceneContext = null, + ): array { - if (is_string($textureMetadata)) { - $texturePath = trim($textureMetadata); + if (is_string($value) && self::propertyAcceptsClass($property, GameObject::class)) { + return [ + 'shouldAssign' => true, + 'value' => self::loadPrefabFromPath($value), + ]; + } - return $texturePath !== '' ? $texturePath : null; + if (self::propertyReferencesClassHierarchy($property, UIElement::class)) { + return self::resolveUIElementComponentPropertyAssignment($property, $value, $sceneContext); } - if (is_array($textureMetadata)) { - $texturePath = $textureMetadata['path'] ?? null; + if (self::propertyAcceptsClass($property, Vector2::class)) { + return [ + 'shouldAssign' => true, + 'value' => self::hydrateVector2PropertyValue($property, $value), + ]; + } - return is_string($texturePath) && trim($texturePath) !== '' - ? trim($texturePath) - : null; + if (self::propertyAcceptsClass($property, Rect::class)) { + return [ + 'shouldAssign' => true, + 'value' => self::hydrateRectPropertyValue($property, $value), + ]; } - if (is_object($textureMetadata)) { - $texturePath = $textureMetadata->path ?? null; + if (self::propertyAcceptsClass($property, Texture::class)) { + return [ + 'shouldAssign' => true, + 'value' => self::hydrateTexturePropertyValue($property, $value), + ]; + } - return is_string($texturePath) && trim($texturePath) !== '' - ? trim($texturePath) - : null; + if (self::propertyAcceptsClass($property, Sprite::class)) { + return [ + 'shouldAssign' => true, + 'value' => self::hydrateSpritePropertyValue($property, $value), + ]; } - return null; + $enumClass = self::resolvePropertyEnumClass($property); + + if ($enumClass !== null) { + return [ + 'shouldAssign' => true, + 'value' => self::hydrateEnumPropertyValue($property, $enumClass, $value), + ]; + } + + if (self::propertyAcceptsBuiltinType($property, 'array')) { + return [ + 'shouldAssign' => true, + 'value' => self::hydrateCollectionPropertyValue($property, $value, $sceneContext), + ]; + } + + $compoundClass = self::resolvePropertyCompoundClass($property); + + if ($compoundClass !== null) { + return [ + 'shouldAssign' => true, + 'value' => self::hydrateCompoundPropertyValue($property, $compoundClass, $value, $sceneContext), + ]; + } + + return [ + 'shouldAssign' => true, + 'value' => $value, + ]; } /** - * Resolves scene metadata into a runtime console color. + * Determines whether the property type can accept the given class. * - * @param mixed $colorMetadata - * @return EngineColor|null + * @param ReflectionProperty $property + * @param class-string $className + * @return bool */ - public static function resolveColorMetadataValue(mixed $colorMetadata): ?EngineColor + private static function propertyAcceptsClass(ReflectionProperty $property, string $className): bool { - if ($colorMetadata instanceof EngineColor) { - return $colorMetadata; - } + $type = $property->getType(); - if (!is_string($colorMetadata) || trim($colorMetadata) === '') { - return null; + if ($type instanceof ReflectionNamedType) { + return !$type->isBuiltin() && is_a($className, $type->getName(), true); } - $normalizedColor = strtoupper(str_replace([' ', '-'], '_', trim($colorMetadata))); - - foreach (EngineColor::cases() as $color) { - $normalizedCaseName = strtoupper($color->name); - $normalizedPhoneticName = strtoupper(str_replace([' ', '-'], '_', $color->getPhoneticName())); - $normalizedEscapeValue = strtoupper(trim($color->value)); - - if ( - $normalizedColor === $normalizedCaseName - || $normalizedColor === $normalizedPhoneticName - || $normalizedColor === $normalizedEscapeValue - ) { - return $color; + if ($type instanceof ReflectionUnionType) { + foreach ($type->getTypes() as $namedType) { + if ($namedType instanceof ReflectionNamedType && !$namedType->isBuiltin() && is_a($className, $namedType->getName(), true)) { + return true; + } } } - return null; + return false; } /** - * Applies editor/file-scene component metadata onto the instantiated component. - * - * Supports legacy `proerties`, current `properties`, and editor-authored `data` payloads. + * Loads and inflates a prefab reference into a concrete game object template. * - * @param object $component - * @param string $componentClass - * @param object $componentMetadata - * @return void + * @param string $path + * @return GameObject + * @throws SceneManagementException */ - public static function applySceneComponentMetadata(object $component, string $componentClass, object $componentMetadata): void + public static function loadPrefabFromPath(string $path): GameObject { - $componentProperties = $componentMetadata->properties - ?? $componentMetadata->proerties - ?? $componentMetadata->data - ?? null; + $resolvedPath = self::resolvePrefabPath($path); - if (!$componentProperties) { - return; + if (!$resolvedPath) { + throw new SceneManagementException("Prefab not found: {$path}"); } - $componentOptions = (array)$componentProperties; + try { + $prefabMetadata = require $resolvedPath; + } catch (Throwable $throwable) { + throw new SceneManagementException( + "Failed to load prefab at {$resolvedPath}: {$throwable->getMessage()}", + previous: $throwable + ); + } - if (method_exists($component, 'configure')) { - $component->configure($componentOptions); + if (!is_array($prefabMetadata) && !is_object($prefabMetadata)) { + throw new SceneManagementException("Prefab metadata at {$resolvedPath} did not return a valid game object description."); } - $reflection = new ReflectionObject($component); + return self::inflateGameObjectMetadata($prefabMetadata); + } - foreach ($componentOptions as $key => $value) { - if ($key === 'material' && ($component instanceof Collider || $component instanceof Rigidbody)) { - $component->setMaterial(PhysicsMaterial::fromMetadata((array)$value)); - continue; + /** + * Resolves a prefab reference from either an absolute filesystem path or an assets-relative path. + * + * @param string $path + * @return string|null + */ + private static function resolvePrefabPath(string $path): ?string + { + if ($path === '') { + return null; + } + + $candidates = [$path]; + if (is_string(self::$metadataAssetsRoot) && self::$metadataAssetsRoot !== '') { + $candidates[] = Path::join(self::$metadataAssetsRoot, $path); + } + $assetsRelativePath = Path::join(Path::getWorkingDirectoryAssetsPath(), $path); + $candidates[] = $assetsRelativePath; + + if (!str_ends_with(strtolower($path), '.prefab.php')) { + $candidates[] = $path . '.prefab.php'; + if (is_string(self::$metadataAssetsRoot) && self::$metadataAssetsRoot !== '') { + $candidates[] = Path::join(self::$metadataAssetsRoot, $path . '.prefab.php'); } + $candidates[] = $assetsRelativePath . '.prefab.php'; + } - if (!$reflection->hasProperty($key)) { - Debug::warn("Property '$key' does not exist on component of type: " . $componentClass); - continue; + foreach ($candidates as $candidate) { + if (is_file($candidate)) { + return Path::normalize($candidate); } + } - $property = $reflection->getProperty($key); + return null; + } - if (!$property->isPublic() && !$property->getAttributes(SerializeField::class)) { - continue; + /** + * Determines whether the property's declared type belongs to the given class hierarchy. + * + * @param ReflectionProperty $property + * @param class-string $className + * @return bool + */ + private static function propertyReferencesClassHierarchy(ReflectionProperty $property, string $className): bool + { + $type = $property->getType(); + + if ($type instanceof ReflectionNamedType) { + if ($type->isBuiltin()) { + return false; } - $property->setValue($component, self::hydrateSceneComponentPropertyValue($property, $value)); + $typeName = $type->getName(); + + return is_a($typeName, $className, true) || is_a($className, $typeName, true); + } + + if ($type instanceof ReflectionUnionType) { + foreach ($type->getTypes() as $namedType) { + if (!$namedType instanceof ReflectionNamedType || $namedType->isBuiltin()) { + continue; + } + + $typeName = $namedType->getName(); + + if (is_a($typeName, $className, true) || is_a($className, $typeName, true)) { + return true; + } + } } + + return false; } /** - * Converts serialized scene values into runtime objects when a typed property requires it. + * Resolves serialized scene UI element references into runtime UI elements. * * @param ReflectionProperty $property * @param mixed $value - * @return mixed - * @throws SceneManagementException + * @param SceneInterface|null $sceneContext + * @return array{shouldAssign: bool, value?: mixed, referenceName?: string} */ - private static function hydrateSceneComponentPropertyValue(ReflectionProperty $property, mixed $value): mixed + private static function resolveUIElementComponentPropertyAssignment( + ReflectionProperty $property, + mixed $value, + ?SceneInterface $sceneContext = null, + ): array { - if (is_string($value) && self::propertyAcceptsClass($property, GameObject::class)) { - return self::loadPrefabFromPath($value); + if ($value === null) { + return [ + 'shouldAssign' => true, + 'value' => null, + ]; } - if (self::propertyAcceptsClass($property, Vector2::class)) { - return self::hydrateVector2PropertyValue($property, $value); + if (is_object($value) && self::propertyAcceptsClass($property, $value::class)) { + return [ + 'shouldAssign' => true, + 'value' => $value, + ]; } - return $value; + if (!is_string($value)) { + return [ + 'shouldAssign' => true, + 'value' => $value, + ]; + } + + $referenceName = trim($value); + + if ($referenceName === '') { + return [ + 'shouldAssign' => true, + 'value' => null, + ]; + } + + $referenceScene = $sceneContext ?? self::getInstance()->getActiveScene(); + $resolvedReference = $referenceScene + ? self::resolveSceneUIElementReferenceByName($referenceScene, $property, $referenceName) + : null; + + if ($resolvedReference instanceof UIElement) { + return [ + 'shouldAssign' => true, + 'value' => $resolvedReference, + ]; + } + + return [ + 'shouldAssign' => false, + 'referenceName' => $referenceName, + ]; + } + + /** + * Returns the currently active scene. + * + * @return SceneInterface|null + */ + public function getActiveScene(): ?SceneInterface + { + return $this->activeSceneNode?->getScene(); + } + + /** + * Finds a scene UI element by name while respecting the receiving property type. + * + * @param SceneInterface $sceneContext + * @param ReflectionProperty $property + * @param string $referenceName + * @return UIElement|null + */ + private static function resolveSceneUIElementReferenceByName( + SceneInterface $sceneContext, + ReflectionProperty $property, + string $referenceName, + ): ?UIElement + { + foreach ($sceneContext->getUIElements() as $uiElement) { + if ( + !$uiElement instanceof UIElement + || $uiElement->getName() !== $referenceName + || !self::propertyAcceptsClass($property, $uiElement::class) + ) { + continue; + } + + return $uiElement; + } + + return null; } /** @@ -934,28 +1131,1172 @@ private static function propertyAllowsNull(ReflectionProperty $property): bool } /** - * Determines whether the property type can accept the given class. + * Hydrates scene metadata into a Rect-compatible runtime value. * * @param ReflectionProperty $property - * @param class-string $className - * @return bool + * @param mixed $value + * @return Rect|null */ - private static function propertyAcceptsClass(ReflectionProperty $property, string $className): bool + private static function hydrateRectPropertyValue(ReflectionProperty $property, mixed $value): ?Rect { - $type = $property->getType(); + if ($value instanceof Rect) { + return new Rect($value->getPosition(), $value->getSize()); + } - if ($type instanceof ReflectionNamedType) { - return !$type->isBuiltin() && is_a($className, $type->getName(), true); + if ($value === null) { + return null; } - if ($type instanceof ReflectionUnionType) { - foreach ($type->getTypes() as $namedType) { - if ($namedType instanceof ReflectionNamedType && !$namedType->isBuiltin() && is_a($className, $namedType->getName(), true)) { - return true; - } - } + $rectPayload = self::extractRectMetadataPayload($value); + + if (is_array($rectPayload)) { + return Rect::fromArray($rectPayload); } - return false; + Debug::warn(sprintf( + "Unable to hydrate Rect property '%s::%s' from scene metadata; falling back to %s.", + $property->getDeclaringClass()->getName(), + $property->getName(), + self::propertyAllowsNull($property) ? 'null' : 'Rect(0,0,1,1)' + )); + + return self::propertyAllowsNull($property) + ? null + : new Rect(new Vector2(0, 0), new Vector2(1, 1)); + } + + /** + * Attempts to normalize serialized rect metadata from arrays, objects, or legacy strings. + * + * @param mixed $value + * @return array{x: int, y: int, width: int, height: int}|null + */ + private static function extractRectMetadataPayload(mixed $value): ?array + { + if ($value instanceof Rect) { + return [ + 'x' => $value->getX(), + 'y' => $value->getY(), + 'width' => $value->getWidth(), + 'height' => $value->getHeight(), + ]; + } + + if (is_array($value)) { + if (array_is_list($value)) { + return [ + 'x' => (int)($value[0] ?? 0), + 'y' => (int)($value[1] ?? 0), + 'width' => max(1, (int)($value[2] ?? 1)), + 'height' => max(1, (int)($value[3] ?? 1)), + ]; + } + + if (array_key_exists('position', $value) || array_key_exists('size', $value)) { + $position = self::extractVector2MetadataPayload($value['position'] ?? null) ?? ['x' => 0, 'y' => 0]; + $size = self::extractVector2MetadataPayload($value['size'] ?? null) ?? ['x' => 1, 'y' => 1]; + + return [ + 'x' => (int)($position['x'] ?? 0), + 'y' => (int)($position['y'] ?? 0), + 'width' => max(1, (int)($size['x'] ?? 1)), + 'height' => max(1, (int)($size['y'] ?? 1)), + ]; + } + + if ( + array_key_exists('x', $value) + || array_key_exists('y', $value) + || array_key_exists('width', $value) + || array_key_exists('height', $value) + ) { + return [ + 'x' => (int)($value['x'] ?? 0), + 'y' => (int)($value['y'] ?? 0), + 'width' => max(1, (int)($value['width'] ?? 1)), + 'height' => max(1, (int)($value['height'] ?? 1)), + ]; + } + + return null; + } + + if (is_object($value)) { + return self::extractRectMetadataPayload((array)$value); + } + + if (!is_string($value)) { + return null; + } + + $normalizedValue = trim($value); + + if ($normalizedValue === '') { + return null; + } + + $decodedValue = json_decode($normalizedValue, true); + + if (is_array($decodedValue)) { + return self::extractRectMetadataPayload($decodedValue); + } + + if (preg_match('/^\[\s*(-?\d+)\s*,\s*(-?\d+)\s*,\s*(-?\d+)\s*,\s*(-?\d+)\s*\]$/', $normalizedValue, $matches) === 1) { + return [ + 'x' => (int)$matches[1], + 'y' => (int)$matches[2], + 'width' => max(1, (int)$matches[3]), + 'height' => max(1, (int)$matches[4]), + ]; + } + + return null; + } + + /** + * Hydrates scene metadata into a Texture-compatible runtime value. + * + * @param ReflectionProperty $property + * @param mixed $value + * @return Texture|null + */ + private static function hydrateTexturePropertyValue(ReflectionProperty $property, mixed $value): ?Texture + { + if ($value instanceof Texture) { + return new Texture( + $value->getPath(), + $value->getRequestedWidth(), + $value->getRequestedHeight(), + $value->getColor(), + ); + } + + if ($value === null) { + return null; + } + + $texturePayload = self::extractTextureMetadataPayload($value); + + if (!is_array($texturePayload) || !is_string($texturePayload['path'] ?? null) || trim($texturePayload['path']) === '') { + Debug::warn(sprintf( + "Unable to hydrate Texture property '%s::%s' from scene metadata; falling back to null.", + $property->getDeclaringClass()->getName(), + $property->getName(), + )); + + return null; + } + + try { + return new Texture( + $texturePayload['path'], + (int)($texturePayload['width'] ?? -1), + (int)($texturePayload['height'] ?? -1), + self::resolveColorMetadataValue($texturePayload['color'] ?? null), + ); + } catch (Throwable $throwable) { + Debug::warn(sprintf( + "Unable to hydrate Texture property '%s::%s': %s", + $property->getDeclaringClass()->getName(), + $property->getName(), + $throwable->getMessage(), + )); + } + + return null; + } + + /** + * Normalizes serialized texture metadata from arrays, objects, or legacy strings. + * + * @param mixed $value + * @return array{path: string, width?: int, height?: int, color?: mixed}|null + */ + private static function extractTextureMetadataPayload(mixed $value): ?array + { + if ($value instanceof Texture) { + $payload = ['path' => $value->getPath()]; + + if ($value->getRequestedWidth() > 0) { + $payload['width'] = $value->getRequestedWidth(); + } + + if ($value->getRequestedHeight() > 0) { + $payload['height'] = $value->getRequestedHeight(); + } + + if ($value->getColor() !== null) { + $payload['color'] = $value->getColor(); + } + + return $payload; + } + + if (is_string($value)) { + $normalizedValue = trim($value); + + if ($normalizedValue === '') { + return null; + } + + $decodedValue = json_decode($normalizedValue, true); + + if (is_array($decodedValue)) { + return self::extractTextureMetadataPayload($decodedValue); + } + + return ['path' => $normalizedValue]; + } + + $texturePath = self::extractTexturePathFromMetadata($value); + + if (!is_string($texturePath) || trim($texturePath) === '') { + return null; + } + + $payload = ['path' => trim($texturePath)]; + + if (is_array($value)) { + if (is_numeric($value['width'] ?? null)) { + $payload['width'] = (int)$value['width']; + } + + if (is_numeric($value['height'] ?? null)) { + $payload['height'] = (int)$value['height']; + } + + if (array_key_exists('color', $value)) { + $payload['color'] = $value['color']; + } + + return $payload; + } + + if (is_object($value)) { + if (is_numeric($value->width ?? null)) { + $payload['width'] = (int)$value->width; + } + + if (is_numeric($value->height ?? null)) { + $payload['height'] = (int)$value->height; + } + + if (property_exists($value, 'color')) { + $payload['color'] = $value->color; + } + } + + return $payload; + } + + /** + * Extracts a UI texture path from scene metadata. + * + * @param mixed $textureMetadata + * @return string|null + */ + public static function extractTexturePathFromMetadata(mixed $textureMetadata): ?string + { + if (is_string($textureMetadata)) { + $texturePath = trim($textureMetadata); + + return $texturePath !== '' ? $texturePath : null; + } + + if (is_array($textureMetadata)) { + $texturePath = $textureMetadata['path'] ?? null; + + return is_string($texturePath) && trim($texturePath) !== '' + ? trim($texturePath) + : null; + } + + if (is_object($textureMetadata)) { + $texturePath = $textureMetadata->path ?? null; + + return is_string($texturePath) && trim($texturePath) !== '' + ? trim($texturePath) + : null; + } + + return null; + } + + /** + * Resolves scene metadata into a runtime console color. + * + * @param mixed $colorMetadata + * @return EngineColor|null + */ + public static function resolveColorMetadataValue(mixed $colorMetadata): ?EngineColor + { + if ($colorMetadata instanceof EngineColor) { + return $colorMetadata; + } + + if (!is_string($colorMetadata) || trim($colorMetadata) === '') { + return null; + } + + $normalizedColor = strtoupper(str_replace([' ', '-'], '_', trim($colorMetadata))); + + foreach (EngineColor::cases() as $color) { + $normalizedCaseName = strtoupper($color->name); + $normalizedPhoneticName = strtoupper(str_replace([' ', '-'], '_', $color->getPhoneticName())); + $normalizedEscapeValue = strtoupper(trim($color->value)); + + if ( + $normalizedColor === $normalizedCaseName + || $normalizedColor === $normalizedPhoneticName + || $normalizedColor === $normalizedEscapeValue + ) { + return $color; + } + } + + return null; + } + + /** + * Hydrates scene metadata into a Sprite-compatible runtime value. + * + * @param ReflectionProperty $property + * @param mixed $value + * @return Sprite|null + */ + private static function hydrateSpritePropertyValue(ReflectionProperty $property, mixed $value): ?Sprite + { + if ($value instanceof Sprite) { + return new Sprite( + self::hydrateTexturePropertyValue($property, $value->getTexture()) ?? $value->getTexture(), + $value->getRect(), + Vector2::getClone($value->getPivot()), + ); + } + + if ($value === null) { + return null; + } + + if (is_string($value)) { + $decodedValue = json_decode(trim($value), true); + + if (is_array($decodedValue)) { + $value = $decodedValue; + } + } + + if (!is_array($value) && !is_object($value)) { + return null; + } + + $spriteMetadata = self::normalizeMetadata($value); + $texture = self::hydrateTexturePropertyValue($property, $spriteMetadata->texture ?? null); + $rect = self::hydrateRectPropertyValue($property, $spriteMetadata->rect ?? null); + $pivot = self::hydrateVector2PropertyValue($property, $spriteMetadata->pivot ?? ['x' => 0, 'y' => 0]); + + if (!$texture instanceof Texture || !$rect instanceof Rect || !$pivot instanceof Vector2) { + Debug::warn(sprintf( + "Unable to hydrate Sprite property '%s::%s' from scene metadata; falling back to null.", + $property->getDeclaringClass()->getName(), + $property->getName(), + )); + + return null; + } + + return new Sprite($texture, $rect, $pivot); + } + + /** + * Resolves the enum type declared on a property, if any. + * + * @param ReflectionProperty $property + * @return class-string<\UnitEnum>|null + */ + private static function resolvePropertyEnumClass(ReflectionProperty $property): ?string + { + $type = $property->getType(); + + if ($type instanceof ReflectionNamedType) { + $typeName = $type->getName(); + + return !$type->isBuiltin() && enum_exists($typeName) + ? $typeName + : null; + } + + if ($type instanceof ReflectionUnionType) { + foreach ($type->getTypes() as $namedType) { + if (!$namedType instanceof ReflectionNamedType || $namedType->isBuiltin()) { + continue; + } + + $typeName = $namedType->getName(); + + if (enum_exists($typeName)) { + return $typeName; + } + } + } + + return null; + } + + /** + * Hydrates scalar metadata into an enum case. + * + * @param ReflectionProperty $property + * @param class-string<\UnitEnum> $enumClass + * @param mixed $value + * @return \UnitEnum|null + */ + private static function hydrateEnumPropertyValue( + ReflectionProperty $property, + string $enumClass, + mixed $value, + ): ?\UnitEnum + { + if ($value instanceof $enumClass) { + return $value; + } + + if ($value === null) { + return self::propertyAllowsNull($property) ? null : (($enumClass::cases()[0] ?? null) ?: null); + } + + if ( + is_subclass_of($enumClass, \BackedEnum::class) + && (is_string($value) || is_int($value)) + ) { + $resolvedCase = $enumClass::tryFrom($value); + + if ($resolvedCase instanceof \UnitEnum) { + return $resolvedCase; + } + } + + if (is_string($value)) { + $normalizedValue = strtoupper(str_replace([' ', '-'], '_', trim($value))); + + foreach ($enumClass::cases() as $case) { + $caseName = strtoupper($case->name); + + if ($normalizedValue === $caseName) { + return $case; + } + + if (method_exists($case, 'getPhoneticName')) { + $phoneticName = strtoupper(str_replace([' ', '-'], '_', $case->getPhoneticName())); + + if ($normalizedValue === $phoneticName) { + return $case; + } + } + } + } + + Debug::warn(sprintf( + "Unable to hydrate enum property '%s::%s' as %s from scene metadata.", + $property->getDeclaringClass()->getName(), + $property->getName(), + $enumClass, + )); + + return self::propertyAllowsNull($property) ? null : (($enumClass::cases()[0] ?? null) ?: null); + } + + /** + * Determines whether the property can accept the specified builtin type. + * + * @param ReflectionProperty $property + * @param string $typeName + * @return bool + */ + private static function propertyAcceptsBuiltinType(ReflectionProperty $property, string $typeName): bool + { + $type = $property->getType(); + $normalizedTypeName = strtolower(trim($typeName)); + + if ($type instanceof ReflectionNamedType) { + return $type->isBuiltin() && strtolower($type->getName()) === $normalizedTypeName; + } + + if ($type instanceof ReflectionUnionType) { + foreach ($type->getTypes() as $namedType) { + if ( + $namedType instanceof ReflectionNamedType + && $namedType->isBuiltin() + && strtolower($namedType->getName()) === $normalizedTypeName + ) { + return true; + } + } + } + + return false; + } + + /** + * Hydrates list/array metadata using an optional @param ReflectionProperty $property + * @param mixed $value + * @param SceneInterface|null $sceneContext + * @return array + * @var item type. + * + */ + private static function hydrateCollectionPropertyValue( + ReflectionProperty $property, + mixed $value, + ?SceneInterface $sceneContext = null, + ): array + { + if ($value === null) { + return []; + } + + if (is_string($value)) { + $decodedValue = json_decode(trim($value), true); + + if (is_array($decodedValue)) { + $value = $decodedValue; + } + } + + if (is_object($value)) { + $value = (array)$value; + } + + if (!is_array($value)) { + return []; + } + + $itemType = self::resolveCollectionItemType($property); + + if (!is_string($itemType) || $itemType === '') { + return $value; + } + + $hydrated = []; + + foreach ($value as $key => $item) { + $hydrated[$key] = self::hydrateValueForDeclaredType($itemType, $item, $sceneContext); + } + + return $hydrated; + } + + /** + * Resolves @param ReflectionProperty $property + * @return class-string|string|null + * @var item types declared on array properties. + * + */ + private static function resolveCollectionItemType(ReflectionProperty $property): ?string + { + $docComment = $property->getDocComment(); + + if (!is_string($docComment) || $docComment === '') { + return null; + } + + if (preg_match('/@var\s+([^\s]+)/', $docComment, $matches) !== 1) { + return null; + } + + $itemType = self::extractCollectionItemTypeExpression(trim($matches[1])); + + if ($itemType === null) { + return null; + } + + return self::resolveDocblockTypeReference($property->getDeclaringClass(), $itemType); + } + + /** + * Extracts a collection item type from a @param string $typeExpression + * @return string|null + * @var expression. + * + */ + private static function extractCollectionItemTypeExpression(string $typeExpression): ?string + { + $unionMembers = array_values(array_filter(array_map('trim', explode('|', $typeExpression)))); + + foreach ($unionMembers as $unionMember) { + if (strtolower($unionMember) === 'null') { + continue; + } + + if (preg_match('/^(.+)\[\]$/', $unionMember, $matches) === 1) { + return trim($matches[1]); + } + + if (preg_match('/^(?:array|list)<(.+)>$/', $unionMember, $matches) === 1) { + $innerType = trim($matches[1]); + $segments = array_values(array_filter(array_map('trim', explode(',', $innerType)))); + + return $segments === [] ? null : end($segments); + } + } + + return null; + } + + /** + * Resolves a short docblock type against the declaring class imports. + * + * @param ReflectionClass $scope + * @param string $typeReference + * @return string|null + */ + private static function resolveDocblockTypeReference(ReflectionClass $scope, string $typeReference): ?string + { + $normalizedTypeReference = trim($typeReference); + + if ($normalizedTypeReference === '') { + return null; + } + + if ($normalizedTypeReference[0] === '\\') { + return ltrim($normalizedTypeReference, '\\'); + } + + if (in_array(strtolower($normalizedTypeReference), ['int', 'float', 'string', 'bool', 'array', 'mixed'], true)) { + return strtolower($normalizedTypeReference); + } + + if (str_contains($normalizedTypeReference, '\\')) { + return ltrim($normalizedTypeReference, '\\'); + } + + $aliases = self::resolveClassImportAliases($scope); + $normalizedAlias = strtolower($normalizedTypeReference); + + if (isset($aliases[$normalizedAlias])) { + return $aliases[$normalizedAlias]; + } + + $namespace = $scope->getNamespaceName(); + + return $namespace !== '' + ? $namespace . '\\' . $normalizedTypeReference + : $normalizedTypeReference; + } + + /** + * Parses simple use aliases for a reflected class file. + * + * @param ReflectionClass $scope + * @return array + */ + private static function resolveClassImportAliases(ReflectionClass $scope): array + { + $scopeName = $scope->getName(); + + if (array_key_exists($scopeName, self::$classImportAliasCache)) { + return self::$classImportAliasCache[$scopeName]; + } + + $fileName = $scope->getFileName(); + + if (!is_string($fileName) || !is_file($fileName)) { + return self::$classImportAliasCache[$scopeName] = []; + } + + $source = file_get_contents($fileName); + + if (!is_string($source) || $source === '') { + return self::$classImportAliasCache[$scopeName] = []; + } + + $aliases = []; + + if (preg_match_all('/^\s*use\s+([^;]+);/mi', $source, $matches) > 0) { + foreach ($matches[1] as $importClause) { + if (!is_string($importClause) || str_contains($importClause, '{')) { + continue; + } + + $normalizedClause = trim($importClause); + $alias = basename(str_replace('\\', '/', $normalizedClause)); + $typeReference = $normalizedClause; + + if (preg_match('/^(.+)\s+as\s+([A-Za-z_][A-Za-z0-9_]*)$/i', $normalizedClause, $aliasMatches) === 1) { + $typeReference = trim($aliasMatches[1]); + $alias = trim($aliasMatches[2]); + } + + $aliases[strtolower($alias)] = ltrim($typeReference, '\\'); + } + } + + return self::$classImportAliasCache[$scopeName] = $aliases; + } + + /** + * Hydrates a value according to a declared class or builtin type. + * + * @param string $declaredType + * @param mixed $value + * @param SceneInterface|null $sceneContext + * @return mixed + */ + private static function hydrateValueForDeclaredType( + string $declaredType, + mixed $value, + ?SceneInterface $sceneContext = null, + ): mixed + { + $normalizedType = ltrim(trim($declaredType), '\\'); + $builtinType = strtolower($normalizedType); + + if ($value === null) { + return null; + } + + return match ($builtinType) { + 'int' => (int)$value, + 'float' => (float)$value, + 'string' => is_scalar($value) ? (string)$value : json_encode($value, JSON_UNESCAPED_SLASHES), + 'bool' => (bool)$value, + 'array' => is_array($value) ? $value : (is_object($value) ? (array)$value : [$value]), + default => self::hydrateDeclaredObjectType($normalizedType, $value, $sceneContext), + }; + } + + /** + * Hydrates non-builtin declared types. + * + * @param class-string|string $declaredType + * @param mixed $value + * @param SceneInterface|null $sceneContext + * @return mixed + */ + private static function hydrateDeclaredObjectType( + string $declaredType, + mixed $value, + ?SceneInterface $sceneContext = null, + ): mixed + { + if (enum_exists($declaredType)) { + return self::hydrateEnumValueByClass($declaredType, $value); + } + + if ($value instanceof $declaredType) { + return $value; + } + + if (is_a($declaredType, GameObject::class, true) && is_string($value)) { + return self::loadPrefabFromPath($value); + } + + if (is_a($declaredType, UIElement::class, true)) { + if (!is_string($value) || !$sceneContext instanceof SceneInterface) { + return null; + } + + return self::resolveSceneUIElementReferenceByTypeName($sceneContext, $declaredType, $value); + } + + if (is_a($declaredType, Vector2::class, true)) { + $vectorPayload = self::extractVector2MetadataPayload($value); + + return is_array($vectorPayload) + ? Vector2::fromArray($vectorPayload) + : null; + } + + if (is_a($declaredType, Rect::class, true)) { + $rectPayload = self::extractRectMetadataPayload($value); + + return is_array($rectPayload) + ? Rect::fromArray($rectPayload) + : null; + } + + if (is_a($declaredType, Texture::class, true)) { + $texturePayload = self::extractTextureMetadataPayload($value); + + if (!is_array($texturePayload) || !is_string($texturePayload['path'] ?? null) || trim($texturePayload['path']) === '') { + return null; + } + + try { + return new Texture( + $texturePayload['path'], + (int)($texturePayload['width'] ?? -1), + (int)($texturePayload['height'] ?? -1), + self::resolveColorMetadataValue($texturePayload['color'] ?? null), + ); + } catch (Throwable) { + return null; + } + } + + if (is_a($declaredType, Sprite::class, true)) { + if (is_string($value)) { + $decodedValue = json_decode(trim($value), true); + + if (is_array($decodedValue)) { + $value = $decodedValue; + } + } + + if (!is_array($value) && !is_object($value)) { + return null; + } + + $spriteMetadata = self::normalizeMetadata($value); + $texture = self::hydrateDeclaredObjectType(Texture::class, $spriteMetadata->texture ?? null, $sceneContext); + $rect = self::hydrateDeclaredObjectType(Rect::class, $spriteMetadata->rect ?? null, $sceneContext); + $pivot = self::hydrateDeclaredObjectType(Vector2::class, $spriteMetadata->pivot ?? ['x' => 0, 'y' => 0], $sceneContext); + + return $texture instanceof Texture && $rect instanceof Rect && $pivot instanceof Vector2 + ? new Sprite($texture, $rect, $pivot) + : null; + } + + if (self::isCompoundStructureType($declaredType)) { + return self::hydrateCompoundValueByClass($declaredType, $value, $sceneContext); + } + + return $value; + } + + /** + * Hydrates enum values outside the context of a reflected property. + * + * @param class-string<\UnitEnum> $enumClass + * @param mixed $value + * @return \UnitEnum|null + */ + private static function hydrateEnumValueByClass(string $enumClass, mixed $value): ?\UnitEnum + { + if ($value instanceof $enumClass) { + return $value; + } + + if (is_subclass_of($enumClass, \BackedEnum::class) && (is_string($value) || is_int($value))) { + $resolvedCase = $enumClass::tryFrom($value); + + if ($resolvedCase instanceof \UnitEnum) { + return $resolvedCase; + } + } + + if (is_string($value)) { + $normalizedValue = strtoupper(str_replace([' ', '-'], '_', trim($value))); + + foreach ($enumClass::cases() as $case) { + if ($normalizedValue === strtoupper($case->name)) { + return $case; + } + + if (method_exists($case, 'getPhoneticName')) { + $phoneticName = strtoupper(str_replace([' ', '-'], '_', $case->getPhoneticName())); + + if ($normalizedValue === $phoneticName) { + return $case; + } + } + } + } + + return $enumClass::cases()[0] ?? null; + } + + /** + * Resolves a UI element by name and declared type. + * + * @param SceneInterface $sceneContext + * @param class-string|string $declaredType + * @param string $referenceName + * @return UIElement|null + */ + private static function resolveSceneUIElementReferenceByTypeName( + SceneInterface $sceneContext, + string $declaredType, + string $referenceName, + ): ?UIElement + { + foreach ($sceneContext->getUIElements() as $uiElement) { + if ( + !$uiElement instanceof UIElement + || $uiElement->getName() !== $referenceName + || !is_a($uiElement::class, $declaredType, true) + ) { + continue; + } + + return $uiElement; + } + + return null; + } + + /** + * Determines whether a class should be treated as a compound structure. + * + * @param class-string|string $typeName + * @return bool + */ + private static function isCompoundStructureType(string $typeName): bool + { + $normalizedType = ltrim(trim($typeName), '\\'); + + if ( + $normalizedType === '' + || !class_exists($normalizedType) + || interface_exists($normalizedType) + || enum_exists($normalizedType) + || is_a($normalizedType, GameObject::class, true) + || is_a($normalizedType, UIElement::class, true) + || is_a($normalizedType, 'Sendama\\Engine\\Core\\Component', true) + ) { + return false; + } + + if (in_array($normalizedType, [ + Vector2::class, + Rect::class, + Texture::class, + Sprite::class, + ], true)) { + return false; + } + + return true; + } + + /** + * Instantiates and hydrates a compound object by class. + * + * @param class-string $compoundClass + * @param mixed $value + * @param SceneInterface|null $sceneContext + * @return object|null + */ + private static function hydrateCompoundValueByClass( + string $compoundClass, + mixed $value, + ?SceneInterface $sceneContext = null, + ): ?object + { + if ($value instanceof $compoundClass) { + return $value; + } + + if (is_string($value)) { + $decodedValue = json_decode(trim($value), true); + + if (is_array($decodedValue)) { + $value = $decodedValue; + } + } + + if (is_object($value)) { + $value = (array)$value; + } + + if (!is_array($value)) { + return null; + } + + $instance = self::instantiateCompoundStructure($compoundClass); + + if (!is_object($instance)) { + return null; + } + + $reflection = new ReflectionObject($instance); + + foreach ($reflection->getProperties() as $property) { + if ( + $property->isStatic() + || (!$property->isPublic() && !$property->getAttributes(SerializeField::class)) + || (method_exists($property, 'isVirtual') && $property->isVirtual()) + || !array_key_exists($property->getName(), $value) + ) { + continue; + } + + $assignment = self::resolveSceneComponentPropertyAssignment( + $property, + $value[$property->getName()], + $sceneContext, + ); + + if (($assignment['shouldAssign'] ?? true) !== true || !array_key_exists('value', $assignment)) { + continue; + } + + try { + $property->setValue($instance, $assignment['value']); + } catch (Throwable) { + continue; + } + } + + return $instance; + } + + /** + * Creates an empty instance of a compound structure. + * + * @param class-string $compoundClass + * @return object|null + */ + private static function instantiateCompoundStructure(string $compoundClass): ?object + { + try { + $reflection = new ReflectionClass($compoundClass); + + if (!$reflection->isInstantiable()) { + return null; + } + + $constructor = $reflection->getConstructor(); + + if ($constructor === null || $constructor->getNumberOfRequiredParameters() === 0) { + return $reflection->newInstance(); + } + + return $reflection->newInstanceWithoutConstructor(); + } catch (Throwable) { + return null; + } + } + + /** + * Resolves a compound class declared on a property, if that class can be hydrated from metadata. + * + * @param ReflectionProperty $property + * @return class-string|null + */ + private static function resolvePropertyCompoundClass(ReflectionProperty $property): ?string + { + $candidateTypes = []; + $type = $property->getType(); + + if ($type instanceof ReflectionNamedType && !$type->isBuiltin()) { + $candidateTypes[] = $type->getName(); + } + + if ($type instanceof ReflectionUnionType) { + foreach ($type->getTypes() as $namedType) { + if ($namedType instanceof ReflectionNamedType && !$namedType->isBuiltin()) { + $candidateTypes[] = $namedType->getName(); + } + } + } + + foreach (array_values(array_unique(array_filter($candidateTypes))) as $candidateType) { + if (self::isCompoundStructureType($candidateType)) { + return $candidateType; + } + } + + return null; + } + + /** + * Hydrates metadata into a custom compound structure. + * + * @param ReflectionProperty $property + * @param class-string $compoundClass + * @param mixed $value + * @param SceneInterface|null $sceneContext + * @return object|null + */ + private static function hydrateCompoundPropertyValue( + ReflectionProperty $property, + string $compoundClass, + mixed $value, + ?SceneInterface $sceneContext = null, + ): ?object + { + if ($value === null) { + return self::propertyAllowsNull($property) + ? null + : self::instantiateCompoundStructure($compoundClass); + } + + $hydrated = self::hydrateCompoundValueByClass($compoundClass, $value, $sceneContext); + + if (is_object($hydrated)) { + return $hydrated; + } + + Debug::warn(sprintf( + "Unable to hydrate compound property '%s::%s' as %s from scene metadata.", + $property->getDeclaringClass()->getName(), + $property->getName(), + $compoundClass, + )); + + return self::propertyAllowsNull($property) + ? null + : self::instantiateCompoundStructure($compoundClass); + } + + /** + * Resolves deferred scene component property references once the scene hierarchy has been added. + * + * @param array $pendingComponentPropertyAssignments + * @param SceneInterface $sceneContext + * @return void + */ + public static function resolvePendingSceneComponentPropertyAssignments( + array $pendingComponentPropertyAssignments, + SceneInterface $sceneContext, + ): void + { + foreach ($pendingComponentPropertyAssignments as $assignment) { + $component = $assignment['component'] ?? null; + $property = $assignment['property'] ?? null; + $referenceName = $assignment['referenceName'] ?? null; + + if ( + !is_object($component) + || !$property instanceof ReflectionProperty + || !is_string($referenceName) + || $referenceName === '' + ) { + continue; + } + + $resolvedReference = self::resolveSceneUIElementReferenceByName( + $sceneContext, + $property, + $referenceName, + ); + + if (!$resolvedReference instanceof UIElement) { + Debug::warn(sprintf( + "Unable to resolve UI element reference '%s' for %s::%s during scene hydration.", + $referenceName, + $property->getDeclaringClass()->getName(), + $property->getName(), + )); + continue; + } + + $property->setValue($component, $resolvedReference); + } + } + + /** + * Adds a scene to the SceneManager. + * + * @param SceneInterface $scene The scene to add. + * @param mixed|null $data The data to associate with the scene. + * @return $this The SceneManager instance. + */ + public function addScene(SceneInterface $scene, mixed $data = null): self + { + $this->scenes->add($scene); + + return $this; } } diff --git a/src/Core/Texture.php b/src/Core/Texture.php index 001393c..58e534a 100644 --- a/src/Core/Texture.php +++ b/src/Core/Texture.php @@ -30,6 +30,8 @@ class Texture implements Stringable */ protected array $pixels = []; private string $path; + private int $requestedWidth; + private int $requestedHeight; /** * Creates a new instance of the Texture class. @@ -41,6 +43,8 @@ class Texture implements Stringable public function __construct(string $path, int $width = -1, int $height = -1, private ?Color $color = null, protected array $options = []) { $this->path = $path; + $this->requestedWidth = $width; + $this->requestedHeight = $height; if (!str_ends_with($this->getAbsolutePath(), self::TEXTURE_EXTENSION)) { $this->path .= self::TEXTURE_EXTENSION; @@ -56,6 +60,36 @@ public function __construct(string $path, int $width = -1, int $height = -1, pri $this->loadImage(); } + /** + * Returns the authored texture path. + * + * @return string + */ + public function getPath(): string + { + return $this->path; + } + + /** + * Returns the width constraint used when the texture was authored. + * + * @return int + */ + public function getRequestedWidth(): int + { + return $this->requestedWidth; + } + + /** + * Returns the height constraint used when the texture was authored. + * + * @return int + */ + public function getRequestedHeight(): int + { + return $this->requestedHeight; + } + /** * Returns the absolute path to the texture. * diff --git a/src/States/PausedState.php b/src/States/PausedState.php index 401201a..858d31f 100644 --- a/src/States/PausedState.php +++ b/src/States/PausedState.php @@ -24,8 +24,12 @@ class PausedState extends GameState */ public function update(): void { - if (Input::isKeyDown($this->game->getSettings('pause_key'))) { + if ( + Input::isKeyDown($this->game->getSettings('pause_key')) + && $this->game->getState('scene') + ) { $this->resume(); + return; } $this->menu?->update(); diff --git a/src/States/SceneState.php b/src/States/SceneState.php index 5743344..445f538 100644 --- a/src/States/SceneState.php +++ b/src/States/SceneState.php @@ -25,8 +25,12 @@ public function render(): void */ public function update(): void { - if (Input::isKeyDown($this->game->getSettings(SettingsKey::PAUSE_KEY->value))) { + if ( + Input::isKeyDown($this->game->getSettings(SettingsKey::PAUSE_KEY->value)) + && $this->game->getState('paused') + ) { $this->suspend(); + return; } $this->sceneManager->update(); @@ -50,4 +54,4 @@ public function resume(): void { // Do nothing } -} \ No newline at end of file +} diff --git a/tests/Unit/Core/Scenes/SceneManagerTest.php b/tests/Unit/Core/Scenes/SceneManagerTest.php index 8060164..1c35905 100644 --- a/tests/Unit/Core/Scenes/SceneManagerTest.php +++ b/tests/Unit/Core/Scenes/SceneManagerTest.php @@ -5,6 +5,8 @@ use Sendama\Engine\Core\GameObject; use Sendama\Engine\Core\Rect; use Sendama\Engine\Core\Scenes\SceneManager; +use Sendama\Engine\Core\Sprite; +use Sendama\Engine\Core\Texture; use Sendama\Engine\Core\Vector2; use Sendama\Engine\IO\Console\Console; use Sendama\Engine\IO\Enumerations\Color as EngineColor; @@ -45,6 +47,42 @@ class SceneManagerVectorProbe extends Behaviour } } +if (!class_exists(SceneManagerUIElementProbe::class)) { + class SceneManagerUIElementProbe extends Behaviour + { + public ?UIElement $statusUi = null; + public ?GUITexture $heart = null; + } +} + +if (!class_exists(SceneManagerNativeTypeProbe::class)) { + class SceneManagerNativeTypeProbe extends Behaviour + { + public ?Texture $bulletTexture = null; + public ?Rect $clipRect = null; + public ?Sprite $aimSprite = null; + public ?EngineColor $tint = null; + } +} + +if (!class_exists(SceneManagerCompoundSettings::class)) { + class SceneManagerCompoundSettings + { + public int $waves = 0; + public ?Vector2 $origin = null; + } +} + +if (!class_exists(SceneManagerCompoundProbe::class)) { + class SceneManagerCompoundProbe extends Behaviour + { + /** @var Vector2[] */ + public array $waypoints = []; + + public ?SceneManagerCompoundSettings $settings = null; + } +} + beforeEach(function () { resetSceneManagerStaticProperty(SceneManager::class, 'instance', null); $this->originalCwd = getcwd(); @@ -190,6 +228,78 @@ class SceneManagerVectorProbe extends Behaviour ->and($uiElement?->getColor())->toBe(EngineColor::YELLOW); }); +it('hydrates component ui element references from scene metadata after the scene ui is built', function () { + $workspace = sys_get_temp_dir() . '/sendama-ui-reference-' . uniqid('', true); + $texturesDirectory = $workspace . '/assets/Textures'; + $scenesDirectory = $workspace . '/assets/Scenes'; + + mkdir($texturesDirectory, 0777, true); + mkdir($scenesDirectory, 0777, true); + + file_put_contents($texturesDirectory . '/heart.texture', "[]\n"); + file_put_contents($scenesDirectory . '/ui_reference.scene.php', <<<'PHP' + 'UI Reference Scene', + 'width' => 20, + 'height' => 10, + 'hierarchy' => [ + [ + 'type' => \Sendama\Engine\Core\GameObject::class, + 'name' => 'Level Manager', + 'tag' => 'Manager', + 'position' => ['x' => 0, 'y' => 0], + 'rotation' => ['x' => 0, 'y' => 0], + 'scale' => ['x' => 1, 'y' => 1], + 'components' => [ + [ + 'class' => \SceneManagerUIElementProbe::class, + 'data' => [ + 'statusUi' => 'Score', + 'heart' => 'Heart #1', + ], + ], + ], + ], + [ + 'type' => \Sendama\Engine\UI\Label\Label::class, + 'name' => 'Score', + 'tag' => 'UI', + 'position' => ['x' => 1, 'y' => 1], + 'size' => ['x' => 8, 'y' => 1], + 'text' => 'Score: 0', + ], + [ + 'type' => \Sendama\Engine\UI\GUITexture\GUITexture::class, + 'name' => 'Heart #1', + 'tag' => 'UI', + 'position' => ['x' => 1, 'y' => 2], + 'size' => ['x' => 1, 'y' => 1], + 'texture' => 'Textures/heart', + 'color' => 'White', + ], + ], +]; +PHP); + + chdir($workspace); + + ob_start(); + $this->sceneManager->loadSceneFromFile($scenesDirectory . '/ui_reference'); + $this->sceneManager->loadScene('UI Reference Scene'); + ob_end_clean(); + + $scene = $this->sceneManager->getActiveScene(); + $controller = $scene?->getRootGameObjects()[0]?->getComponent(SceneManagerUIElementProbe::class); + + expect($controller)->toBeInstanceOf(SceneManagerUIElementProbe::class) + ->and($controller?->statusUi)->toBeInstanceOf(Label::class) + ->and($controller?->statusUi?->getName())->toBe('Score') + ->and($controller?->heart)->toBeInstanceOf(GUITexture::class) + ->and($controller?->heart?->getName())->toBe('Heart #1'); +}); + it('hydrates nested hierarchy children as runtime game objects', function () { ob_start(); $this->sceneManager->loadSceneFromFile($this->sceneWithNestedObjectsPath); @@ -305,6 +415,91 @@ class SceneManagerVectorProbe extends Behaviour ->and($probe->maxBound?->getY())->toBe(25); }); +it('hydrates native engine component fields from scene metadata', function () { + $workspace = sys_get_temp_dir() . '/sendama-native-type-hydration-' . uniqid('', true); + $texturesDirectory = $workspace . '/assets/Textures'; + mkdir($texturesDirectory, 0777, true); + file_put_contents($texturesDirectory . '/bullet.texture', "<>\n[]\n"); + + chdir($workspace); + + $probe = new SceneManagerNativeTypeProbe(new GameObject('Weapon')); + + SceneManager::applySceneComponentMetadata( + $probe, + SceneManagerNativeTypeProbe::class, + (object) [ + 'data' => (object) [ + 'bulletTexture' => 'Textures/bullet', + 'clipRect' => [ + 'x' => 1, + 'y' => 2, + 'width' => 3, + 'height' => 4, + ], + 'aimSprite' => [ + 'texture' => 'Textures/bullet', + 'rect' => [ + 'x' => 0, + 'y' => 1, + 'width' => 1, + 'height' => 1, + ], + 'pivot' => [ + 'x' => 1, + 'y' => 0, + ], + ], + 'tint' => 'Light Red', + ], + ], + ); + + expect($probe->bulletTexture)->toBeInstanceOf(Texture::class) + ->and($probe->bulletTexture?->getPath())->toBe('Textures/bullet.texture') + ->and($probe->clipRect)->toBeInstanceOf(Rect::class) + ->and($probe->clipRect?->getX())->toBe(1) + ->and($probe->clipRect?->getY())->toBe(2) + ->and($probe->clipRect?->getWidth())->toBe(3) + ->and($probe->clipRect?->getHeight())->toBe(4) + ->and($probe->aimSprite)->toBeInstanceOf(Sprite::class) + ->and($probe->aimSprite?->getTexture()->getPath())->toBe('Textures/bullet.texture') + ->and($probe->aimSprite?->getRect()->getY())->toBe(1) + ->and($probe->aimSprite?->getPivot()->getX())->toBe(1) + ->and($probe->tint)->toBe(EngineColor::LIGHT_RED); +}); + +it('hydrates compound component structures and typed vector lists from scene metadata', function () { + $probe = new SceneManagerCompoundProbe(new GameObject('Spawner')); + + SceneManager::applySceneComponentMetadata( + $probe, + SceneManagerCompoundProbe::class, + (object) [ + 'data' => (object) [ + 'waypoints' => [ + ['x' => 1, 'y' => 2], + ['x' => 3, 'y' => 4], + ], + 'settings' => [ + 'waves' => 3, + 'origin' => ['x' => 8, 'y' => 9], + ], + ], + ], + ); + + expect($probe->waypoints)->toHaveCount(2) + ->and($probe->waypoints[0])->toBeInstanceOf(Vector2::class) + ->and($probe->waypoints[0]->getX())->toBe(1) + ->and($probe->waypoints[1]->getY())->toBe(4) + ->and($probe->settings)->toBeInstanceOf(SceneManagerCompoundSettings::class) + ->and($probe->settings?->waves)->toBe(3) + ->and($probe->settings?->origin)->toBeInstanceOf(Vector2::class) + ->and($probe->settings?->origin?->getX())->toBe(8) + ->and($probe->settings?->origin?->getY())->toBe(9); +}); + function resetSceneManagerStaticProperty(string $className, string $propertyName, mixed $value): void { $reflection = new \ReflectionClass($className); diff --git a/tests/Unit/States/PausedStateTest.php b/tests/Unit/States/PausedStateTest.php index 16b9ab1..5b44c8f 100644 --- a/tests/Unit/States/PausedStateTest.php +++ b/tests/Unit/States/PausedStateTest.php @@ -7,12 +7,15 @@ use Sendama\Engine\Core\Vector2; use Sendama\Engine\Events\EventManager; use Sendama\Engine\Game; +use Sendama\Engine\Interfaces\GameStateInterface; use Sendama\Engine\IO\Console\Console; +use Sendama\Engine\IO\InputManager; use Sendama\Engine\Messaging\Notifications\NotificationsManager; use Sendama\Engine\States\GameStateContext; use Sendama\Engine\States\PausedState; use Sendama\Engine\UI\Label\Label; use Sendama\Engine\UI\Modals\ModalManager; +use Sendama\Engine\UI\Menus\Menu; use Sendama\Engine\UI\UIManager; beforeEach(function () { @@ -21,6 +24,8 @@ resetPausedStateSingleton(ModalManager::class, 'instance'); resetPausedStateSingleton(NotificationsManager::class, 'instance'); resetPausedStateSingleton(UIManager::class, 'instance'); + setPausedStateInputManagerState('previousKeyPress', ''); + setPausedStateInputManagerState('keyPress', ''); }); it('centers the default pause text over the occupied scene bounds instead of the full logical canvas', function () { @@ -73,14 +78,97 @@ public function awake(): void expect($output)->toContain("\033[14;38HPAUSED"); }); +it('does not update the pause menu on the frame resume is requested', function () { + $sceneState = new class implements GameStateInterface { + public function enter(GameStateContext $context): void + { + // Do nothing. + } + + public function exit(GameStateContext $context): void + { + // Do nothing. + } + + public function update(): void + { + // Do nothing. + } + + public function render(): void + { + // Do nothing. + } + + public function suspend(): void + { + // Do nothing. + } + + public function resume(): void + { + // Do nothing. + } + }; + + $game = new TestGame([ + SettingsKey::PAUSE_KEY->value => 'escape', + ]); + $game->registerState('scene', $sceneState); + + $state = new PausedState(new GameStateContext( + $game, + SceneManager::getInstance(), + EventManager::getInstance(), + ModalManager::getInstance(), + NotificationsManager::getInstance(), + UIManager::getInstance(), + )); + + $game->registerState('paused', $state); + $game->setCurrentState($state); + + $menu = new class('', '') extends Menu { + public int $updateCount = 0; + + public function update(): void + { + $this->updateCount++; + } + }; + + $reflection = new ReflectionClass($state); + $reflection->getProperty('menu')->setValue($state, $menu); + + setPausedStateInputManagerState('previousKeyPress', ''); + setPausedStateInputManagerState('keyPress', "\033"); + + ob_start(); + $state->update(); + ob_end_clean(); + + expect($game->getCurrentState())->toBe($sceneState) + ->and($menu->updateCount)->toBe(0); +}); + function resetPausedStateSingleton(string $className, string $property): void { $reflection = new \ReflectionClass($className); $reflection->getProperty($property)->setValue(null, null); } +function setPausedStateInputManagerState(string $property, string $value): void +{ + $reflection = new ReflectionClass(InputManager::class); + $reflection->getProperty($property)->setValue(null, $value); +} + final class TestGame extends Game { + /** @var array */ + private array $states = []; + private ?GameStateInterface $currentState = null; + public function __construct(private array $testSettings = []) { // Intentionally skip the parent bootstrapping for unit tests. @@ -105,4 +193,29 @@ public function getSettings(string|SettingsKey|null $key = null): mixed return $this->testSettings[$key] ?? null; } + + public function registerState(string $name, GameStateInterface $state): void + { + $this->states[$name] = $state; + } + + public function setCurrentState(GameStateInterface $state): void + { + $this->currentState = $state; + } + + public function getCurrentState(): ?GameStateInterface + { + return $this->currentState; + } + + public function getState(string $stateName): ?GameStateInterface + { + return $this->states[$stateName] ?? null; + } + + public function setState(GameStateInterface $state): void + { + $this->currentState = $state; + } } diff --git a/tests/Unit/States/SceneStateTest.php b/tests/Unit/States/SceneStateTest.php new file mode 100644 index 0000000..122b2e3 --- /dev/null +++ b/tests/Unit/States/SceneStateTest.php @@ -0,0 +1,178 @@ +loadSettings([ + 'screen_width' => 80, + 'screen_height' => 25, + ]); + + $scene = new class('Playfield') extends AbstractScene { + public function awake(): void + { + // Do nothing. + } + }; + + $player = new GameObject('Player'); + /** @var MockBehavior $behavior */ + $behavior = $player->addComponent(MockBehavior::class); + $scene->add($player); + $sceneManager->addScene($scene); + + ob_start(); + $sceneManager->loadScene('Playfield'); + ob_end_clean(); + + $pausedState = new class implements GameStateInterface { + public function enter(GameStateContext $context): void + { + // Do nothing. + } + + public function exit(GameStateContext $context): void + { + // Do nothing. + } + + public function update(): void + { + // Do nothing. + } + + public function render(): void + { + // Do nothing. + } + + public function suspend(): void + { + // Do nothing. + } + + public function resume(): void + { + // Do nothing. + } + }; + + $game = new SceneStateTestGame([ + SettingsKey::PAUSE_KEY->value => 'escape', + ]); + $game->registerState('paused', $pausedState); + + $state = new SceneState(new GameStateContext( + $game, + $sceneManager, + EventManager::getInstance(), + ModalManager::getInstance(), + NotificationsManager::getInstance(), + UIManager::getInstance(), + )); + + $game->registerState('scene', $state); + $game->setCurrentState($state); + + setSceneStateInputManagerState('previousKeyPress', ''); + setSceneStateInputManagerState('keyPress', "\033"); + + $state->update(); + + expect($game->getCurrentState())->toBe($pausedState) + ->and($behavior->fixedUpdateCount)->toBe(0) + ->and($behavior->updateCount)->toBe(0); +}); + +function resetSceneStateSingleton(string $className, string $property): void +{ + $reflection = new ReflectionClass($className); + $reflection->getProperty($property)->setValue(null, null); +} + +function setSceneStateInputManagerState(string $property, string $value): void +{ + $reflection = new ReflectionClass(InputManager::class); + $reflection->getProperty($property)->setValue(null, $value); +} + +final class SceneStateTestGame extends Game +{ + /** @var array */ + private array $states = []; + private ?GameStateInterface $currentState = null; + + public function __construct(private array $testSettings = []) + { + // Intentionally skip the parent bootstrapping for unit tests. + } + + public function __destruct() + { + // No-op for tests. + } + + public function registerState(string $name, GameStateInterface $state): void + { + $this->states[$name] = $state; + } + + public function setCurrentState(GameStateInterface $state): void + { + $this->currentState = $state; + } + + public function getCurrentState(): ?GameStateInterface + { + return $this->currentState; + } + + public function getSettings(string|SettingsKey|null $key = null): mixed + { + $key = match (true) { + $key === null => null, + is_string($key) => $key, + default => $key->value, + }; + + if ($key === null) { + return $this->testSettings; + } + + return $this->testSettings[$key] ?? null; + } + + public function getState(string $stateName): ?GameStateInterface + { + return $this->states[$stateName] ?? null; + } + + public function setState(GameStateInterface $state): void + { + $this->currentState = $state; + } +}