From 17bd93dea41eed48de8800fac4e5c11fd6415374 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Sat, 14 Mar 2026 07:21:30 +0200 Subject: [PATCH 1/4] feat(collider): improve code readability and organization with consistent formatting and comments --- src/Core/GameObject.php | 117 ++++++++- src/Core/Prefab.php | 18 +- src/Core/Scenes/SceneManager.php | 246 ++++++++++++++---- src/Physics/Collider.php | 187 ++++++------- src/Physics/Rigidbody.php | 8 + src/UI/Interfaces/UIElementInterface.php | 100 +++---- src/UI/UIElement.php | 89 +++++-- .../Scenes/scene_with_named_objects.scene.php | 43 +++ tests/Unit/Core/GameObjectTest.php | 30 +++ tests/Unit/Core/Scenes/SceneManagerTest.php | 106 ++++++++ 10 files changed, 706 insertions(+), 238 deletions(-) create mode 100644 tests/Mocks/Scenes/scene_with_named_objects.scene.php diff --git a/src/Core/GameObject.php b/src/Core/GameObject.php index 912ab5a..38bc782 100644 --- a/src/Core/GameObject.php +++ b/src/Core/GameObject.php @@ -3,6 +3,8 @@ namespace Sendama\Engine\Core; use InvalidArgumentException; +use ReflectionObject; +use Sendama\Engine\Core\Behaviours\Attributes\SerializeField; use Sendama\Engine\Core\Interfaces\CanCompare; use Sendama\Engine\Core\Interfaces\CanEquate; use Sendama\Engine\Core\Interfaces\ComponentInterface; @@ -236,6 +238,8 @@ public static function findAllWithTag(string $gameObjectTag): array */ public function __serialize(): array { + $sprite = $this->renderer->getSprite(); + return [ "hash" => $this->hash, "name" => $this->name, @@ -245,7 +249,7 @@ public function __serialize(): array "scale" => $this->scale, "transform" => $this->transform, "render" => $this->renderer, - "sprite" => $this->sprite, + "sprite" => $sprite, ]; } @@ -282,15 +286,117 @@ public function getScene(): SceneInterface public function __clone(): void { $this->hash = md5(__CLASS__) . '-' . uniqid($this->name, true); + $this->started = false; + $this->starting = false; + + $originalComponents = $this->components; + $position = clone $this->transform->getPosition(); + $rotation = clone $this->transform->getRotation(); + $scale = clone $this->transform->getScale(); + $parent = $this->transform->getParent(); + $currentSprite = $this->renderer->getSprite(); + $sprite = $currentSprite ? clone $currentSprite : null; + + $this->position = clone $position; + $this->rotation = clone $rotation; + $this->scale = clone $scale; + $this->sprite = $sprite; + $this->transform = new Transform($this, $position, $scale, $rotation, $parent); + $this->renderer = new Renderer($this, $sprite); + $this->components = [$this->transform, $this->renderer]; - $this->transform = clone $this->transform; - $this->renderer = clone $this->renderer; + foreach ($originalComponents as $component) { + if ($component instanceof Transform || $component instanceof Renderer) { + continue; + } - if ($this->sprite) { - $this->sprite = clone $this->sprite; + $this->components[] = $this->cloneComponentForInstance($component); } } + /** + * Rebuild a component for a cloned game object and copy its serializable state. + * + * @param ComponentInterface $component + * @return ComponentInterface + */ + private function cloneComponentForInstance(ComponentInterface $component): ComponentInterface + { + $componentClass = $component::class; + /** @var ComponentInterface $componentClone */ + $componentClone = new $componentClass($this); + $reflection = new ReflectionObject($componentClone); + + foreach ($this->extractSerializableComponentData($component) as $propertyName => $value) { + if (!$reflection->hasProperty($propertyName)) { + continue; + } + + $property = $reflection->getProperty($propertyName); + $property->setValue($componentClone, self::duplicateComponentValue($value)); + } + + if (!$component->isEnabled()) { + $enabledProperty = new \ReflectionProperty(Component::class, 'enabled'); + $enabledProperty->setValue($componentClone, false); + } + + return $componentClone; + } + + /** + * Read serializable component data while skipping virtual accessors like activeScene/scene. + * + * @param ComponentInterface $component + * @return array + */ + private function extractSerializableComponentData(ComponentInterface $component): array + { + $data = []; + $reflection = new ReflectionObject($component); + + foreach ($reflection->getProperties() as $property) { + $isSerializable = $property->isPublic() || $property->getAttributes(SerializeField::class); + + if (!$isSerializable) { + continue; + } + + if (method_exists($property, 'isVirtual') && $property->isVirtual()) { + continue; + } + + $data[$property->getName()] = $property->getValue($component); + } + + return $data; + } + + /** + * Duplicate nested values so prefab instances do not share mutable component state. + * + * @param mixed $value + * @return mixed + */ + private static function duplicateComponentValue(mixed $value): mixed + { + if (is_array($value)) { + $duplicate = []; + + foreach ($value as $key => $item) { + $duplicate[$key] = self::duplicateComponentValue($item); + } + + return $duplicate; + } + + if (is_object($value)) { + return clone $value; + } + + return $value; + } + /** * Returns the transform of the game object. * @@ -721,6 +827,7 @@ public function setSpriteFromTexture(Texture|array|string $texture, Vector2 $pos */ public function setSprite(Sprite $sprite): void { + $this->sprite = $sprite; $this->getRenderer()->setSprite($sprite); } diff --git a/src/Core/Prefab.php b/src/Core/Prefab.php index 46d7c50..cf7c769 100644 --- a/src/Core/Prefab.php +++ b/src/Core/Prefab.php @@ -5,7 +5,7 @@ use Sendama\Engine\Core\Interfaces\GameObjectInterface; use Sendama\Engine\Core\Interfaces\PrefabCallbackInterface; use Sendama\Engine\Core\Interfaces\PrefabInterface; -use Sendama\Engine\Exceptions\FileNotFoundException; +use Sendama\Engine\Core\Scenes\SceneManager; /** * Class Prefab is a class that represents a prefab in the engine. @@ -84,19 +84,7 @@ public function instantiate(): GameObject */ public function load(string $path): void { - // TODO: Implement load() method. - // Check if the file exists - if (! file_exists($path)) { - throw new FileNotFoundException("The file does not exist: $path"); - } - - // Load the file - $data = require($path); - - // Deserialize the file - - - // Set the game object + $this->gameObject = SceneManager::loadPrefabFromPath($path); } public function __serialize(): array @@ -118,4 +106,4 @@ public function pool(int $size): array { return GameObject::pool($this->gameObject, $size); } -} \ No newline at end of file +} diff --git a/src/Core/Scenes/SceneManager.php b/src/Core/Scenes/SceneManager.php index 5b1e635..0ef1d23 100644 --- a/src/Core/Scenes/SceneManager.php +++ b/src/Core/Scenes/SceneManager.php @@ -2,7 +2,10 @@ namespace Sendama\Engine\Core\Scenes; +use ReflectionNamedType; use ReflectionObject; +use ReflectionProperty; +use ReflectionUnionType; use Assegai\Collections\ItemList; use Sendama\Engine\Core\Behaviours\Attributes\SerializeField; use Sendama\Engine\Core\GameObject; @@ -31,6 +34,8 @@ use Sendama\Engine\Physics\Physics; use Sendama\Engine\UI\Label\Label; use Sendama\Engine\UI\Text\Text; +use Sendama\Engine\Util\Path; +use Throwable; use function dispatchEvent; /** @@ -411,7 +416,7 @@ public function awake(): void continue; } - $itemName = $item?->name . " - $index" ?? throw new SceneManagementException("Invalid game object name"); + $itemName = $item->name ?? throw new SceneManagementException("Invalid game object name"); $position = new Vector2(); if (isset($item->position)) { @@ -427,55 +432,7 @@ public function awake(): void switch ($item->type) { case GameObject::class: - $rotation = new Vector2(); - if (isset($item->rotation)) { - $rotation = Vector2::fromArray((array)$item->rotation); - } - - $scale = new Vector2(); - if (isset($item->scale)) { - $scale = Vector2::fromArray((array)$item->scale); - } - - $gameObject = new GameObject( - $itemName, - $item?->tag, - $position, - $rotation, - $scale - ); - - if (isset($item->sprite)) { - if (!isset($item->sprite->texture)) { - throw new SceneManagementException("Sprite texture not defined for game object: " . $gameObject->getName()); - } - - $spriteTextureMetadata = $item->sprite->texture; - $spriteTexture = new Texture($spriteTextureMetadata->path ?? throw new SceneManagementException("Invalid sprite texture path")); - $spritePosition = new Vector2(); - if (isset($spriteTextureMetadata->position)) { - $spritePosition = Vector2::fromArray((array)$spriteTextureMetadata->position); - } - $spriteSize = new Vector2(); - if (isset($spriteTextureMetadata->size)) { - $spriteSize = Vector2::fromArray((array)$spriteTextureMetadata->size); - } - - $gameObject->setSpriteFromTexture($spriteTexture, $spritePosition, $spriteSize); - } - - if (isset($item->components)) { - foreach ($item->components as $componentMetadata) { - if (!isset($componentMetadata->class)) { - throw new SceneManagementException("Component class not defined for game object: " . $gameObject->getName()); - } - - $componentClass = $componentMetadata->class; - $component = $gameObject->addComponent($componentClass); - SceneManager::applySceneComponentMetadata($component, $componentClass, $componentMetadata); - } - } - + $gameObject = SceneManager::inflateGameObjectMetadata($item); break; default: @@ -502,6 +459,150 @@ public function awake(): void $this->addScene($scene); } + /** + * Inflates a game object from scene/prefab metadata without attaching it to a scene. + * + * @param object|array $itemMetadata + * @return GameObject + * @throws SceneManagementException + */ + public static function inflateGameObjectMetadata(object|array $itemMetadata): GameObject + { + $item = self::normalizeMetadata($itemMetadata); + + if (($item->type ?? null) !== GameObject::class) { + throw new SceneManagementException('Prefab metadata must describe a ' . GameObject::class . '.'); + } + + $itemName = $item->name ?? throw new SceneManagementException('Invalid game object name'); + $position = isset($item->position) + ? Vector2::fromArray((array)$item->position) + : new Vector2(); + $rotation = isset($item->rotation) + ? Vector2::fromArray((array)$item->rotation) + : new Vector2(); + $scale = isset($item->scale) + ? Vector2::fromArray((array)$item->scale) + : new Vector2(); + + $gameObject = new GameObject( + $itemName, + $item->tag ?? null, + $position, + $rotation, + $scale + ); + + if (isset($item->sprite)) { + if (!isset($item->sprite->texture)) { + throw new SceneManagementException('Sprite texture not defined for game object: ' . $gameObject->getName()); + } + + $spriteTextureMetadata = $item->sprite->texture; + $spriteTexture = new Texture($spriteTextureMetadata->path ?? throw new SceneManagementException('Invalid sprite texture path')); + $spritePosition = isset($spriteTextureMetadata->position) + ? Vector2::fromArray((array)$spriteTextureMetadata->position) + : new Vector2(); + $spriteSize = isset($spriteTextureMetadata->size) + ? Vector2::fromArray((array)$spriteTextureMetadata->size) + : new Vector2(); + + $gameObject->setSpriteFromTexture($spriteTexture, $spritePosition, $spriteSize); + } + + if (isset($item->components)) { + foreach ($item->components as $componentMetadata) { + $componentMetadataObject = self::normalizeMetadata($componentMetadata); + + if (!isset($componentMetadataObject->class)) { + throw new SceneManagementException('Component class not defined for game object: ' . $gameObject->getName()); + } + + $componentClass = $componentMetadataObject->class; + $component = $gameObject->addComponent($componentClass); + self::applySceneComponentMetadata($component, $componentClass, $componentMetadataObject); + } + } + + return $gameObject; + } + + /** + * Loads and inflates a prefab reference into a concrete game object template. + * + * @param string $path + * @return GameObject + * @throws SceneManagementException + */ + public static function loadPrefabFromPath(string $path): GameObject + { + $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."); + } + + return self::inflateGameObjectMetadata($prefabMetadata); + } + + /** + * 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]; + $assetsRelativePath = Path::join(Path::getWorkingDirectoryAssetsPath(), $path); + $candidates[] = $assetsRelativePath; + + if (!str_ends_with(strtolower($path), '.prefab.php')) { + $candidates[] = $path . '.prefab.php'; + $candidates[] = $assetsRelativePath . '.prefab.php'; + } + + foreach ($candidates as $candidate) { + if (is_file($candidate)) { + return Path::normalize($candidate); + } + } + + return null; + } + + /** + * 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; + } + + return json_decode(json_encode($metadata, JSON_UNESCAPED_SLASHES), false); + } + /** * Applies editor/file-scene component metadata onto the instantiated component. * @@ -548,7 +649,50 @@ public static function applySceneComponentMetadata(object $component, string $co continue; } - $property->setValue($component, $value); + $property->setValue($component, self::hydrateSceneComponentPropertyValue($property, $value)); + } + } + + /** + * Converts serialized scene values into runtime objects when a typed property requires it. + * + * @param ReflectionProperty $property + * @param mixed $value + * @return mixed + * @throws SceneManagementException + */ + private static function hydrateSceneComponentPropertyValue(ReflectionProperty $property, mixed $value): mixed + { + if (is_string($value) && self::propertyAcceptsClass($property, GameObject::class)) { + return self::loadPrefabFromPath($value); } + + return $value; + } + + /** + * Determines whether the property type can accept the given class. + * + * @param ReflectionProperty $property + * @param class-string $className + * @return bool + */ + private static function propertyAcceptsClass(ReflectionProperty $property, string $className): bool + { + $type = $property->getType(); + + if ($type instanceof ReflectionNamedType) { + return !$type->isBuiltin() && is_a($className, $type->getName(), true); + } + + if ($type instanceof ReflectionUnionType) { + foreach ($type->getTypes() as $namedType) { + if ($namedType instanceof ReflectionNamedType && !$namedType->isBuiltin() && is_a($className, $namedType->getName(), true)) { + return true; + } + } + } + + return false; } } diff --git a/src/Physics/Collider.php b/src/Physics/Collider.php index adfba6b..b1a2dde 100644 --- a/src/Physics/Collider.php +++ b/src/Physics/Collider.php @@ -20,120 +20,105 @@ */ class Collider extends Component implements ColliderInterface { - use BoundTrait; + use BoundTrait; - /** - * The physics. - * - * @var Physics|null - */ - protected ?Physics $physics = null; + /** @var Physics|null $physics The physics. */ + protected ?Physics $physics = null; - #[SerializeField] - /** - * Whether the collider is a trigger. - * - * @var bool - */ - protected bool $isTrigger = false; - /** - * The physics material used when resolving friction and bounce. - * - * @var PhysicsMaterial - */ - protected PhysicsMaterial $material; - /** - * The collision detection strategy. - * - * @var CollisionDetectionStrategyInterface - */ - protected CollisionDetectionStrategyInterface $collisionDetectionStrategy; + #[SerializeField] + /** @var bool $isTrigger Whether the collider is a trigger. */ + protected bool $isTrigger = false; + #[SerializeField] + /** @var PhysicsMaterial $material The physics material used when resolving friction and bounce. */ + protected PhysicsMaterial $material; + /** @var CollisionDetectionStrategyInterface $collisionDetectionStrategy The collision detection strategy. */ + protected CollisionDetectionStrategyInterface $collisionDetectionStrategy; - /** - * @inheritDoc - */ - #[Override] - public final function awake(): void - { - $this->physics = Physics::getInstance(); - $this->collisionDetectionStrategy = new AABBCollisionDetectionStrategy($this); - $this->material = new PhysicsMaterial(); - } + /** + * @inheritDoc + */ + #[Override] + public final function awake(): void + { + $this->physics = Physics::getInstance(); + $this->collisionDetectionStrategy = new AABBCollisionDetectionStrategy($this); + $this->material = new PhysicsMaterial(); + } - /** - * @inheritDoc - */ - public function isTouching(ColliderInterface $collider): bool - { - return $this->collisionDetectionStrategy->isTouching($collider); - } + /** + * @inheritDoc + */ + public function isTouching(ColliderInterface $collider): bool + { + return $this->collisionDetectionStrategy->isTouching($collider); + } - /** - * @inheritDoc - */ - public function isTrigger(): bool - { - return $this->isTrigger; - } + /** + * @inheritDoc + */ + public function isTrigger(): bool + { + return $this->isTrigger; + } - /** - * @inheritDoc - */ - public function setTrigger(bool $isTrigger): void - { - $this->isTrigger = $isTrigger; - } + /** + * @inheritDoc + */ + public function setCollisionDetectionStrategy(CollisionDetectionStrategyInterface $collisionDetectionStrategy): void + { + $this->collisionDetectionStrategy = $collisionDetectionStrategy; + } - /** - * @inheritDoc - */ - public function setCollisionDetectionStrategy(CollisionDetectionStrategyInterface $collisionDetectionStrategy): void - { - $this->collisionDetectionStrategy = $collisionDetectionStrategy; - } + /** + * @inheritDoc + */ + public function configure(array $options = []): void + { + if (array_key_exists('isTrigger', $options)) { + $this->setTrigger((bool)$options['isTrigger']); + } - /** - * @inheritDoc - */ - public function configure(array $options = []): void - { - if (array_key_exists('isTrigger', $options)) { - $this->setTrigger((bool)$options['isTrigger']); + if (array_key_exists('material', $options)) { + $this->setMaterial($options['material']); + } } - if (array_key_exists('material', $options)) { - $this->setMaterial($options['material']); + /** + * @inheritDoc + */ + public function setTrigger(bool $isTrigger): void + { + $this->isTrigger = $isTrigger; } - } - /** - * Returns the collider's physics material. - * - * @return PhysicsMaterial - */ - public function getMaterial(): PhysicsMaterial - { - return $this->material; - } + /** + * Returns the collider's physics material. + * + * @return PhysicsMaterial + */ + public function getMaterial(): PhysicsMaterial + { + return $this->material; + } - /** - * Sets the collider's physics material. - * - * @param mixed $material - * @return void - */ - public function setMaterial(mixed $material): void - { - $this->material = PhysicsMaterial::fromMetadata($material); - } + /** + * Sets the collider's physics material. + * + * @param mixed $material + * @return void + */ + public function setMaterial(mixed $material): void + { + $this->material = PhysicsMaterial::fromMetadata($material); + } - /** - * @inheritDoc - */ - public function simulate(): void - { - if (method_exists($this->getGameObject(), 'fixedUpdate')) { - $this->getGameObject()->fixedUpdate(); + /** + * @inheritDoc + */ + public function simulate(): void + { + if (method_exists($this->getGameObject(), 'fixedUpdate')) { + $this->getGameObject()->fixedUpdate(); + } } - } } diff --git a/src/Physics/Rigidbody.php b/src/Physics/Rigidbody.php index fcc2a50..2e8009d 100644 --- a/src/Physics/Rigidbody.php +++ b/src/Physics/Rigidbody.php @@ -2,6 +2,7 @@ namespace Sendama\Engine\Physics; +use Sendama\Engine\Core\Behaviours\Attributes\SerializeField; use Sendama\Engine\Core\Time; use Sendama\Engine\Core\Vector2; use Sendama\Engine\Metadata\PhysicsMaterialMetadata; @@ -19,12 +20,19 @@ class Rigidbody extends Collider private const float DEFAULT_FIXED_DELTA_TIME = 0.0166666667; private const float VELOCITY_EPSILON = 0.0001; + #[SerializeField] protected float $mass = 1.0; + #[SerializeField] protected float $drag = 0.0; + #[SerializeField] protected float $angularDrag = 0.0; + #[SerializeField] protected bool $useGravity = false; + #[SerializeField] protected bool $freezePositionX = false; + #[SerializeField] protected bool $freezePositionY = false; + #[SerializeField] protected bool $freezeRotation = false; protected float $velocityX = 0.0; diff --git a/src/UI/Interfaces/UIElementInterface.php b/src/UI/Interfaces/UIElementInterface.php index 18962f3..97f5326 100644 --- a/src/UI/Interfaces/UIElementInterface.php +++ b/src/UI/Interfaces/UIElementInterface.php @@ -17,61 +17,61 @@ */ interface UIElementInterface extends CanUpdate, CanRender, CanStart, CanResume, CanActivate, CanAwake { - /** - * Gets the name of the UI element. - * - * @return string - */ - public function getName(): string; + /** + * Finds a UI element by its name. + * + * @param string $uiElementName The name of the UI element. + * @return UIElementInterface|null The UI element if found, null otherwise. + */ + public static function find(string $uiElementName): ?self; - /** - * Sets the name of the UI element. - * - * @param string $name The name of the UI element. - */ - public function setName(string $name): void; + /** + * Finds all UI elements by their name. + * + * @param string $uiElementName The name of the UI element. + * @return UIElementInterface[] The UI elements if found, an empty array otherwise. + */ + public static function findAll(string $uiElementName): array; - /** - * Returns the screen position of the UI element. - * - * @return Vector2 The screen position of the UI element. - */ - public function getPosition(): Vector2; + /** + * Gets the name of the UI element. + * + * @return string + */ + public function getName(): string; - /** - * Sets the screen position of the UI element. - * - * @param Vector2 $position The screen position of the UI element. - */ - public function setPosition(Vector2 $position): void; + /** + * Sets the name of the UI element. + * + * @param string $name The name of the UI element. + */ + public function setName(string $name): void; - /** - * Returns the size of the UI element. - * - * @return Vector2 The size of the UI element. - */ - public function getSize(): Vector2; + /** + * Returns the screen position of the UI element. + * + * @return Vector2 The screen position of the UI element. + */ + public function getPosition(): Vector2; - /** - * Sets the size of the UI element. - * - * @param Vector2 $size The size of the UI element. - */ - public function setSize(Vector2 $size): void; + /** + * Sets the screen position of the UI element. + * + * @param Vector2 $position The screen position of the UI element. + */ + public function setPosition(Vector2 $position): void; - /** - * Finds a UI element by its name. - * - * @param string $uiElementName The name of the UI element. - * @return UIElementInterface|null The UI element if found, null otherwise. - */ - public static function find(string $uiElementName): ?self; + /** + * Returns the size of the UI element. + * + * @return Vector2 The size of the UI element. + */ + public function getSize(): Vector2; - /** - * Finds all UI elements by their name. - * - * @param string $uiElementName The name of the UI element. - * @return UIElementInterface[] The UI elements if found, an empty array otherwise. - */ - public static function findAll(string $uiElementName): array; + /** + * Sets the size of the UI element. + * + * @param Vector2 $size The size of the UI element. + */ + public function setSize(Vector2 $size): void; } \ No newline at end of file diff --git a/src/UI/UIElement.php b/src/UI/UIElement.php index 09d080b..0188e0e 100644 --- a/src/UI/UIElement.php +++ b/src/UI/UIElement.php @@ -34,6 +34,7 @@ public function __construct( protected string $name, protected Vector2 $position = new Vector2(0, 0), protected Vector2 $size = new Vector2(0, 0), + protected string $tag = '', ) { $this->awake(); @@ -63,6 +64,46 @@ public static function find(string $uiElementName): ?UIElementInterface return null; } + /** + * Finds a UI element by its tag. + * + * @param string $uiElementTagName The tag of the UI element. + * @return self|null The UI element if found, null otherwise. + */ + public static function findByTag(string $uiElementTagName): ?UIElementInterface + { + if ($activeScene = SceneManager::getInstance()->getActiveScene()) { + foreach ($activeScene->getUIElements() as $element) { + if ($element->getTag() === $uiElementTagName) { + return $element; + } + } + } + + return null; + } + + /** + * Finds all UI elements by their tag + * + * @param string $uiElementTagName The tag of the UI element. + * @return UIElementInterface[] The UI elements if found, an empty array otherwise. + */ + public static function findAllByTag(string $uiElementTagName): array + { + $elements = []; + + if ($activeScene = SceneManager::getInstance()->getActiveScene()) { + foreach ($activeScene->getUIElements() as $element) { + if ($element->getTag() === $uiElementTagName) { + $elements[] = $element; + } + } + } + + return $elements; + } + /** * @inheritDoc */ @@ -92,25 +133,33 @@ public static function findAll(string $uiElementName): array /** * @inheritDoc */ - public function activate(): void + public function getTag(): string { - $this->active = true; + return $this->tag; } /** * @inheritDoc */ - public function deactivate(): void + public function setTag(string $tag): void { - $this->active = false; + $this->tag = $tag; } /** * @inheritDoc */ - public function isActive(): bool + public function activate(): void { - return $this->active; + $this->active = true; + } + + /** + * @inheritDoc + */ + public function deactivate(): void + { + $this->active = false; } /** @@ -123,6 +172,24 @@ public function resume(): void } } + /** + * Checks whether the element should write to the console right now. + * + * @return bool + */ + protected function shouldRenderWithinScene(): bool + { + return $this->isActive() && $this->scene->isStarted(); + } + + /** + * @inheritDoc + */ + public function isActive(): bool + { + return $this->active; + } + /** * @inheritDoc */ @@ -182,14 +249,4 @@ public function setSize(Vector2 $size): void { $this->size = $size; } - - /** - * Checks whether the element should write to the console right now. - * - * @return bool - */ - protected function shouldRenderWithinScene(): bool - { - return $this->isActive() && $this->scene->isStarted(); - } } diff --git a/tests/Mocks/Scenes/scene_with_named_objects.scene.php b/tests/Mocks/Scenes/scene_with_named_objects.scene.php new file mode 100644 index 0000000..3d58ed4 --- /dev/null +++ b/tests/Mocks/Scenes/scene_with_named_objects.scene.php @@ -0,0 +1,43 @@ + 'Scene With Named Objects', + 'width' => 20, + 'height' => 10, + 'hierarchy' => [ + [ + 'type' => GameObject::class, + 'name' => 'Player', + 'tag' => 'Player', + 'position' => [ + 'x' => 1, + 'y' => 1, + ], + 'rotation' => [ + 'x' => 0, + 'y' => 0, + ], + 'scale' => [ + 'x' => 1, + 'y' => 1, + ], + ], + [ + 'type' => Label::class, + 'name' => 'Score', + 'tag' => 'UI', + 'position' => [ + 'x' => 1, + 'y' => 1, + ], + 'size' => [ + 'x' => 10, + 'y' => 1, + ], + 'text' => 'Score: 0', + ], + ], +]; diff --git a/tests/Unit/Core/GameObjectTest.php b/tests/Unit/Core/GameObjectTest.php index 513ef48..5ae84ec 100644 --- a/tests/Unit/Core/GameObjectTest.php +++ b/tests/Unit/Core/GameObjectTest.php @@ -112,6 +112,36 @@ public function onStart(): void expect($mockBehaviour2->updateCount)->toEqual(1); }); + it('clones independent component instances for prefab-style duplication', function () { + $mockBehaviour = $this->gameObject->addComponent(MockBehavior::class); + + $clone = clone $this->gameObject; + $cloneBehaviour = $clone->getComponent(MockBehavior::class); + + expect($cloneBehaviour) + ->toBeInstanceOf(MockBehavior::class) + ->and($cloneBehaviour)->not()->toBe($mockBehaviour) + ->and($cloneBehaviour->getGameObject())->toBe($clone) + ->and($mockBehaviour->getGameObject())->toBe($this->gameObject); + + $clone->update(); + + expect($cloneBehaviour->updateCount)->toEqual(1) + ->and($mockBehaviour->updateCount)->toEqual(0); + }); + + it('preserves the renderer sprite when cloning prefab-style game objects', function () { + $texturePath = getcwd() . '/tests/Mocks/Textures/test.texture'; + $this->gameObject->setSpriteFromTexture(new Texture($texturePath), new Vector2(0, 0), new Vector2(1, 1)); + + $clone = clone $this->gameObject; + + expect($clone->getSprite()) + ->not()->toBeNull() + ->and($clone->getSprite()->getRect()->getWidth())->toEqual(1) + ->and($clone->getSprite()->getRect()->getHeight())->toEqual(1); + }); + it('can broadcast a message to all components', function () { $mockBehaviour1 = $this->gameObject->addComponent(MockBehavior::class); $mockBehaviour2 = $this->gameObject->addComponent(MockBehavior::class); diff --git a/tests/Unit/Core/Scenes/SceneManagerTest.php b/tests/Unit/Core/Scenes/SceneManagerTest.php index 7f5f1c3..b46c73b 100644 --- a/tests/Unit/Core/Scenes/SceneManagerTest.php +++ b/tests/Unit/Core/Scenes/SceneManagerTest.php @@ -2,11 +2,15 @@ use Sendama\Engine\Core\Behaviours\Attributes\SerializeField; use Sendama\Engine\Core\Behaviours\Behaviour; +use Sendama\Engine\Core\GameObject; use Sendama\Engine\Core\Rect; use Sendama\Engine\Core\Scenes\SceneManager; use Sendama\Engine\Core\Vector2; use Sendama\Engine\IO\Console\Console; +use Sendama\Engine\Mocks\MockBehavior; use Sendama\Engine\Physics\Physics; +use Sendama\Engine\UI\Label\Label; +use Sendama\Engine\UI\UIElement; use Sendama\Engine\Util\Path; if (!class_exists(SceneManagerDataProbe::class)) { @@ -24,8 +28,16 @@ public function getPower(): int } } +if (!class_exists(SceneManagerPrefabProbe::class)) { + class SceneManagerPrefabProbe extends Behaviour + { + public ?GameObject $enemyPrefab = null; + } +} + beforeEach(function () { resetSceneManagerStaticProperty(SceneManager::class, 'instance', null); + $this->originalCwd = getcwd(); Console::refreshLayout( 160, @@ -45,6 +57,13 @@ public function getPower(): int $this->scenePath = Path::join(dirname(__DIR__, 3), 'Mocks', 'Scenes', 'scene_with_dimensions'); $this->sceneWithComponentDataPath = Path::join(dirname(__DIR__, 3), 'Mocks', 'Scenes', 'scene_with_component_data'); $this->sceneWithEnvironmentCollisionPath = Path::join(dirname(__DIR__, 3), 'Mocks', 'Scenes', 'scene_with_environment_collision'); + $this->sceneWithNamedObjectsPath = Path::join(dirname(__DIR__, 3), 'Mocks', 'Scenes', 'scene_with_named_objects'); +}); + +afterEach(function () { + if (is_string($this->originalCwd) && $this->originalCwd !== '') { + chdir($this->originalCwd); + } }); it('applies file scene dimensions to the active viewport and centered layout', function () { @@ -100,6 +119,93 @@ public function getPower(): int ->and(Physics::getInstance()->isTouchingStaticObject(new Vector2(0, 0)))->toBeFalse(); }); +it('preserves authored scene object names for find lookups', function () { + ob_start(); + $this->sceneManager->loadSceneFromFile($this->sceneWithNamedObjectsPath); + $this->sceneManager->loadScene('Scene With Named Objects'); + ob_end_clean(); + + $gameObject = GameObject::find('Player'); + $uiElement = UIElement::find('Score'); + + expect($gameObject)->not()->toBeNull() + ->and($gameObject->getName())->toBe('Player') + ->and($uiElement)->toBeInstanceOf(Label::class) + ->and($uiElement->getName())->toBe('Score'); +}); + +it('inflates prefab reference fields into concrete game object templates', function () { + $workspace = sys_get_temp_dir() . '/sendama-prefab-' . uniqid('', true); + $prefabsDirectory = $workspace . '/assets/Prefabs'; + $scenesDirectory = $workspace . '/assets/Scenes'; + + mkdir($prefabsDirectory, 0777, true); + mkdir($scenesDirectory, 0777, true); + + file_put_contents($prefabsDirectory . '/enemy.prefab.php', <<<'PHP' + \Sendama\Engine\Core\GameObject::class, + 'name' => 'Enemy Prefab', + 'tag' => 'Enemy', + 'position' => ['x' => 0, 'y' => 0], + 'rotation' => ['x' => 0, 'y' => 0], + 'scale' => ['x' => 1, 'y' => 1], + 'components' => [ + [ + 'class' => \Sendama\Engine\Mocks\MockBehavior::class, + 'data' => [], + ], + ], +]; +PHP); + + file_put_contents($scenesDirectory . '/prefab_field.scene.php', <<<'PHP' + 'Prefab Field Scene', + 'width' => 20, + 'height' => 10, + 'hierarchy' => [ + [ + 'type' => \Sendama\Engine\Core\GameObject::class, + 'name' => 'Controller', + 'tag' => 'Manager', + 'position' => ['x' => 0, 'y' => 0], + 'rotation' => ['x' => 0, 'y' => 0], + 'scale' => ['x' => 1, 'y' => 1], + 'components' => [ + [ + 'class' => \SceneManagerPrefabProbe::class, + 'data' => [ + 'enemyPrefab' => 'Prefabs/enemy.prefab.php', + ], + ], + ], + ], + ], +]; +PHP); + + chdir($workspace); + + ob_start(); + $this->sceneManager->loadSceneFromFile($scenesDirectory . '/prefab_field'); + $this->sceneManager->loadScene('Prefab Field Scene'); + ob_end_clean(); + + $scene = $this->sceneManager->getActiveScene(); + $controller = $scene?->getRootGameObjects()[0] ?? null; + $probe = $controller?->getComponent(SceneManagerPrefabProbe::class); + + expect($probe)->toBeInstanceOf(SceneManagerPrefabProbe::class) + ->and($probe->enemyPrefab)->toBeInstanceOf(GameObject::class) + ->and($probe->enemyPrefab->getName())->toBe('Enemy Prefab') + ->and($probe->enemyPrefab->getComponent(MockBehavior::class))->toBeInstanceOf(MockBehavior::class); +}); + function resetSceneManagerStaticProperty(string $className, string $propertyName, mixed $value): void { $reflection = new \ReflectionClass($className); From 8c4f12d04d5cfb2dec774148b5c76cdee2b85846 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Sat, 14 Mar 2026 08:20:48 +0200 Subject: [PATCH 2/4] chore(dependencies): update package versions and hashes in composer.lock --- composer.lock | 284 ++++++++++++++++++++++++++------------------------ 1 file changed, 147 insertions(+), 137 deletions(-) diff --git a/composer.lock b/composer.lock index 05a6882..d4d7646 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "e355cc5c432fb93dcff70e25bb7e81a4", + "content-hash": "c2c6223392d9aee3c45be9b6e47c8746", "packages": [ { "name": "amasiye/figlet", @@ -617,16 +617,16 @@ }, { "name": "symfony/console", - "version": "v7.4.4", + "version": "v7.4.7", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "41e38717ac1dd7a46b6bda7d6a82af2d98a78894" + "reference": "e1e6770440fb9c9b0cf725f81d1361ad1835329d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/41e38717ac1dd7a46b6bda7d6a82af2d98a78894", - "reference": "41e38717ac1dd7a46b6bda7d6a82af2d98a78894", + "url": "https://api.github.com/repos/symfony/console/zipball/e1e6770440fb9c9b0cf725f81d1361ad1835329d", + "reference": "e1e6770440fb9c9b0cf725f81d1361ad1835329d", "shasum": "" }, "require": { @@ -691,7 +691,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v7.4.4" + "source": "https://github.com/symfony/console/tree/v7.4.7" }, "funding": [ { @@ -711,7 +711,7 @@ "type": "tidelift" } ], - "time": "2026-01-13T11:36:38+00:00" + "time": "2026-03-06T14:06:20+00:00" }, { "name": "symfony/deprecation-contracts", @@ -1368,16 +1368,16 @@ }, { "name": "symfony/string", - "version": "v8.0.4", + "version": "v8.0.6", "source": { "type": "git", "url": "https://github.com/symfony/string.git", - "reference": "758b372d6882506821ed666032e43020c4f57194" + "reference": "6c9e1108041b5dce21a9a4984b531c4923aa9ec4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/string/zipball/758b372d6882506821ed666032e43020c4f57194", - "reference": "758b372d6882506821ed666032e43020c4f57194", + "url": "https://api.github.com/repos/symfony/string/zipball/6c9e1108041b5dce21a9a4984b531c4923aa9ec4", + "reference": "6c9e1108041b5dce21a9a4984b531c4923aa9ec4", "shasum": "" }, "require": { @@ -1434,7 +1434,7 @@ "utf8" ], "support": { - "source": "https://github.com/symfony/string/tree/v8.0.4" + "source": "https://github.com/symfony/string/tree/v8.0.6" }, "funding": [ { @@ -1454,7 +1454,7 @@ "type": "tidelift" } ], - "time": "2026-01-12T12:37:40+00:00" + "time": "2026-02-09T10:14:57+00:00" }, { "name": "vlucas/phpdotenv", @@ -1544,16 +1544,16 @@ "packages-dev": [ { "name": "brianium/paratest", - "version": "v7.16.1", + "version": "v7.19.0", "source": { "type": "git", "url": "https://github.com/paratestphp/paratest.git", - "reference": "f0fdfd8e654e0d38bc2ba756a6cabe7be287390b" + "reference": "7c6c29af7c4b406b49ce0c6b0a3a81d3684474e6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/paratestphp/paratest/zipball/f0fdfd8e654e0d38bc2ba756a6cabe7be287390b", - "reference": "f0fdfd8e654e0d38bc2ba756a6cabe7be287390b", + "url": "https://api.github.com/repos/paratestphp/paratest/zipball/7c6c29af7c4b406b49ce0c6b0a3a81d3684474e6", + "reference": "7c6c29af7c4b406b49ce0c6b0a3a81d3684474e6", "shasum": "" }, "require": { @@ -1564,24 +1564,24 @@ "fidry/cpu-core-counter": "^1.3.0", "jean85/pretty-package-versions": "^2.1.1", "php": "~8.3.0 || ~8.4.0 || ~8.5.0", - "phpunit/php-code-coverage": "^12.5.2", - "phpunit/php-file-iterator": "^6", - "phpunit/php-timer": "^8", - "phpunit/phpunit": "^12.5.4", - "sebastian/environment": "^8.0.3", - "symfony/console": "^7.3.4 || ^8.0.0", - "symfony/process": "^7.3.4 || ^8.0.0" + "phpunit/php-code-coverage": "^12.5.3 || ^13.0.1", + "phpunit/php-file-iterator": "^6.0.1 || ^7", + "phpunit/php-timer": "^8 || ^9", + "phpunit/phpunit": "^12.5.9 || ^13", + "sebastian/environment": "^8.0.3 || ^9", + "symfony/console": "^7.4.4 || ^8.0.4", + "symfony/process": "^7.4.5 || ^8.0.5" }, "require-dev": { "doctrine/coding-standard": "^14.0.0", "ext-pcntl": "*", "ext-pcov": "*", "ext-posix": "*", - "phpstan/phpstan": "^2.1.33", + "phpstan/phpstan": "^2.1.38", "phpstan/phpstan-deprecation-rules": "^2.0.3", - "phpstan/phpstan-phpunit": "^2.0.11", - "phpstan/phpstan-strict-rules": "^2.0.7", - "symfony/filesystem": "^7.3.2 || ^8.0.0" + "phpstan/phpstan-phpunit": "^2.0.12", + "phpstan/phpstan-strict-rules": "^2.0.8", + "symfony/filesystem": "^7.4.0 || ^8.0.1" }, "bin": [ "bin/paratest", @@ -1621,7 +1621,7 @@ ], "support": { "issues": "https://github.com/paratestphp/paratest/issues", - "source": "https://github.com/paratestphp/paratest/tree/v7.16.1" + "source": "https://github.com/paratestphp/paratest/tree/v7.19.0" }, "funding": [ { @@ -1633,33 +1633,33 @@ "type": "paypal" } ], - "time": "2026-01-08T07:23:06+00:00" + "time": "2026-02-06T10:53:26+00:00" }, { "name": "doctrine/deprecations", - "version": "1.1.5", + "version": "1.1.6", "source": { "type": "git", "url": "https://github.com/doctrine/deprecations.git", - "reference": "459c2f5dd3d6a4633d3b5f46ee2b1c40f57d3f38" + "reference": "d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/deprecations/zipball/459c2f5dd3d6a4633d3b5f46ee2b1c40f57d3f38", - "reference": "459c2f5dd3d6a4633d3b5f46ee2b1c40f57d3f38", + "url": "https://api.github.com/repos/doctrine/deprecations/zipball/d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca", + "reference": "d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca", "shasum": "" }, "require": { "php": "^7.1 || ^8.0" }, "conflict": { - "phpunit/phpunit": "<=7.5 || >=13" + "phpunit/phpunit": "<=7.5 || >=14" }, "require-dev": { - "doctrine/coding-standard": "^9 || ^12 || ^13", - "phpstan/phpstan": "1.4.10 || 2.1.11", + "doctrine/coding-standard": "^9 || ^12 || ^14", + "phpstan/phpstan": "1.4.10 || 2.1.30", "phpstan/phpstan-phpunit": "^1.0 || ^2", - "phpunit/phpunit": "^7.5 || ^8.5 || ^9.6 || ^10.5 || ^11.5 || ^12", + "phpunit/phpunit": "^7.5 || ^8.5 || ^9.6 || ^10.5 || ^11.5 || ^12.4 || ^13.0", "psr/log": "^1 || ^2 || ^3" }, "suggest": { @@ -1679,9 +1679,9 @@ "homepage": "https://www.doctrine-project.org/", "support": { "issues": "https://github.com/doctrine/deprecations/issues", - "source": "https://github.com/doctrine/deprecations/tree/1.1.5" + "source": "https://github.com/doctrine/deprecations/tree/1.1.6" }, - "time": "2025-04-07T20:06:18+00:00" + "time": "2026-02-07T07:09:04+00:00" }, { "name": "fidry/cpu-core-counter", @@ -1995,39 +1995,36 @@ }, { "name": "nunomaduro/collision", - "version": "v8.8.3", + "version": "v8.9.1", "source": { "type": "git", "url": "https://github.com/nunomaduro/collision.git", - "reference": "1dc9e88d105699d0fee8bb18890f41b274f6b4c4" + "reference": "a1ed3fa530fd60bc515f9303e8520fcb7d4bd935" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nunomaduro/collision/zipball/1dc9e88d105699d0fee8bb18890f41b274f6b4c4", - "reference": "1dc9e88d105699d0fee8bb18890f41b274f6b4c4", + "url": "https://api.github.com/repos/nunomaduro/collision/zipball/a1ed3fa530fd60bc515f9303e8520fcb7d4bd935", + "reference": "a1ed3fa530fd60bc515f9303e8520fcb7d4bd935", "shasum": "" }, "require": { - "filp/whoops": "^2.18.1", - "nunomaduro/termwind": "^2.3.1", + "filp/whoops": "^2.18.4", + "nunomaduro/termwind": "^2.4.0", "php": "^8.2.0", - "symfony/console": "^7.3.0" + "symfony/console": "^7.4.4 || ^8.0.4" }, "conflict": { - "laravel/framework": "<11.44.2 || >=13.0.0", - "phpunit/phpunit": "<11.5.15 || >=13.0.0" + "laravel/framework": "<11.48.0 || >=14.0.0", + "phpunit/phpunit": "<11.5.50 || >=14.0.0" }, "require-dev": { - "brianium/paratest": "^7.8.3", - "larastan/larastan": "^3.4.2", - "laravel/framework": "^11.44.2 || ^12.18", - "laravel/pint": "^1.22.1", - "laravel/sail": "^1.43.1", - "laravel/sanctum": "^4.1.1", - "laravel/tinker": "^2.10.1", - "orchestra/testbench-core": "^9.12.0 || ^10.4", - "pestphp/pest": "^3.8.2 || ^4.0.0", - "sebastian/environment": "^7.2.1 || ^8.0" + "brianium/paratest": "^7.8.5", + "larastan/larastan": "^3.9.2", + "laravel/framework": "^11.48.0 || ^12.52.0", + "laravel/pint": "^1.27.1", + "orchestra/testbench-core": "^9.12.0 || ^10.9.0", + "pestphp/pest": "^3.8.5 || ^4.4.1 || ^5.0.0", + "sebastian/environment": "^7.2.1 || ^8.0.3 || ^9.0.0" }, "type": "library", "extra": { @@ -2090,35 +2087,35 @@ "type": "patreon" } ], - "time": "2025-11-20T02:55:25+00:00" + "time": "2026-02-17T17:33:08+00:00" }, { "name": "nunomaduro/termwind", - "version": "v2.3.3", + "version": "v2.4.0", "source": { "type": "git", "url": "https://github.com/nunomaduro/termwind.git", - "reference": "6fb2a640ff502caace8e05fd7be3b503a7e1c017" + "reference": "712a31b768f5daea284c2169a7d227031001b9a8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nunomaduro/termwind/zipball/6fb2a640ff502caace8e05fd7be3b503a7e1c017", - "reference": "6fb2a640ff502caace8e05fd7be3b503a7e1c017", + "url": "https://api.github.com/repos/nunomaduro/termwind/zipball/712a31b768f5daea284c2169a7d227031001b9a8", + "reference": "712a31b768f5daea284c2169a7d227031001b9a8", "shasum": "" }, "require": { "ext-mbstring": "*", "php": "^8.2", - "symfony/console": "^7.3.6" + "symfony/console": "^7.4.4 || ^8.0.4" }, "require-dev": { - "illuminate/console": "^11.46.1", - "laravel/pint": "^1.25.1", + "illuminate/console": "^11.47.0", + "laravel/pint": "^1.27.1", "mockery/mockery": "^1.6.12", - "pestphp/pest": "^2.36.0 || ^3.8.4 || ^4.1.3", + "pestphp/pest": "^2.36.0 || ^3.8.4 || ^4.3.2", "phpstan/phpstan": "^1.12.32", "phpstan/phpstan-strict-rules": "^1.6.2", - "symfony/var-dumper": "^7.3.5", + "symfony/var-dumper": "^7.3.5 || ^8.0.4", "thecodingmachine/phpstan-strict-rules": "^1.0.0" }, "type": "library", @@ -2150,7 +2147,7 @@ "email": "enunomaduro@gmail.com" } ], - "description": "Its like Tailwind CSS, but for the console.", + "description": "It's like Tailwind CSS, but for the console.", "keywords": [ "cli", "console", @@ -2161,7 +2158,7 @@ ], "support": { "issues": "https://github.com/nunomaduro/termwind/issues", - "source": "https://github.com/nunomaduro/termwind/tree/v2.3.3" + "source": "https://github.com/nunomaduro/termwind/tree/v2.4.0" }, "funding": [ { @@ -2177,45 +2174,45 @@ "type": "github" } ], - "time": "2025-11-20T02:34:59+00:00" + "time": "2026-02-16T23:10:27+00:00" }, { "name": "pestphp/pest", - "version": "v4.3.2", + "version": "v4.4.2", "source": { "type": "git", "url": "https://github.com/pestphp/pest.git", - "reference": "3a4329ddc7a2b67c19fca8342a668b39be3ae398" + "reference": "5d42e8fe3ae1d9fdf7c9f73ee88138fd30265701" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/pestphp/pest/zipball/3a4329ddc7a2b67c19fca8342a668b39be3ae398", - "reference": "3a4329ddc7a2b67c19fca8342a668b39be3ae398", + "url": "https://api.github.com/repos/pestphp/pest/zipball/5d42e8fe3ae1d9fdf7c9f73ee88138fd30265701", + "reference": "5d42e8fe3ae1d9fdf7c9f73ee88138fd30265701", "shasum": "" }, "require": { - "brianium/paratest": "^7.16.1", - "nunomaduro/collision": "^8.8.3", - "nunomaduro/termwind": "^2.3.3", + "brianium/paratest": "^7.19.0", + "nunomaduro/collision": "^8.9.1", + "nunomaduro/termwind": "^2.4.0", "pestphp/pest-plugin": "^4.0.0", "pestphp/pest-plugin-arch": "^4.0.0", "pestphp/pest-plugin-mutate": "^4.0.1", "pestphp/pest-plugin-profanity": "^4.2.1", "php": "^8.3.0", - "phpunit/phpunit": "^12.5.8", - "symfony/process": "^7.4.4|^8.0.0" + "phpunit/phpunit": "^12.5.12", + "symfony/process": "^7.4.5|^8.0.5" }, "conflict": { "filp/whoops": "<2.18.3", - "phpunit/phpunit": ">12.5.8", + "phpunit/phpunit": ">12.5.12", "sebastian/exporter": "<7.0.0", "webmozart/assert": "<1.11.0" }, "require-dev": { - "pestphp/pest-dev-tools": "^4.0.0", - "pestphp/pest-plugin-browser": "^4.2.1", + "pestphp/pest-dev-tools": "^4.1.0", + "pestphp/pest-plugin-browser": "^4.3.0", "pestphp/pest-plugin-type-coverage": "^4.0.3", - "psy/psysh": "^0.12.18" + "psy/psysh": "^0.12.21" }, "bin": [ "bin/pest" @@ -2281,7 +2278,7 @@ ], "support": { "issues": "https://github.com/pestphp/pest/issues", - "source": "https://github.com/pestphp/pest/tree/v4.3.2" + "source": "https://github.com/pestphp/pest/tree/v4.4.2" }, "funding": [ { @@ -2293,7 +2290,7 @@ "type": "github" } ], - "time": "2026-01-28T01:01:19+00:00" + "time": "2026-03-10T21:09:12+00:00" }, { "name": "pestphp/pest-plugin", @@ -2740,16 +2737,16 @@ }, { "name": "phpdocumentor/reflection-docblock", - "version": "6.0.1", + "version": "6.0.2", "source": { "type": "git", "url": "https://github.com/phpDocumentor/ReflectionDocBlock.git", - "reference": "2f5cbed597cb261d1ea458f3da3a9ad32e670b1e" + "reference": "897b5986ece6b4f9d8413fea345c7d49c757d6bf" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpDocumentor/ReflectionDocBlock/zipball/2f5cbed597cb261d1ea458f3da3a9ad32e670b1e", - "reference": "2f5cbed597cb261d1ea458f3da3a9ad32e670b1e", + "url": "https://api.github.com/repos/phpDocumentor/ReflectionDocBlock/zipball/897b5986ece6b4f9d8413fea345c7d49c757d6bf", + "reference": "897b5986ece6b4f9d8413fea345c7d49c757d6bf", "shasum": "" }, "require": { @@ -2799,9 +2796,9 @@ "description": "With this component, a library can provide support for annotations via DocBlocks or otherwise retrieve information that is embedded in a DocBlock.", "support": { "issues": "https://github.com/phpDocumentor/ReflectionDocBlock/issues", - "source": "https://github.com/phpDocumentor/ReflectionDocBlock/tree/6.0.1" + "source": "https://github.com/phpDocumentor/ReflectionDocBlock/tree/6.0.2" }, - "time": "2026-01-20T15:30:42+00:00" + "time": "2026-03-01T18:43:49+00:00" }, { "name": "phpdocumentor/type-resolver", @@ -2910,11 +2907,11 @@ }, { "name": "phpstan/phpstan", - "version": "1.12.32", + "version": "1.12.33", "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/2770dcdf5078d0b0d53f94317e06affe88419aa8", - "reference": "2770dcdf5078d0b0d53f94317e06affe88419aa8", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/37982d6fc7cbb746dda7773530cda557cdf119e1", + "reference": "37982d6fc7cbb746dda7773530cda557cdf119e1", "shasum": "" }, "require": { @@ -2959,20 +2956,20 @@ "type": "github" } ], - "time": "2025-09-30T10:16:31+00:00" + "time": "2026-02-28T20:30:03+00:00" }, { "name": "phpunit/php-code-coverage", - "version": "12.5.2", + "version": "12.5.3", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-code-coverage.git", - "reference": "4a9739b51cbcb355f6e95659612f92e282a7077b" + "reference": "b015312f28dd75b75d3422ca37dff2cd1a565e8d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/4a9739b51cbcb355f6e95659612f92e282a7077b", - "reference": "4a9739b51cbcb355f6e95659612f92e282a7077b", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/b015312f28dd75b75d3422ca37dff2cd1a565e8d", + "reference": "b015312f28dd75b75d3422ca37dff2cd1a565e8d", "shasum": "" }, "require": { @@ -3028,7 +3025,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy", - "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/12.5.2" + "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/12.5.3" }, "funding": [ { @@ -3048,20 +3045,20 @@ "type": "tidelift" } ], - "time": "2025-12-24T07:03:04+00:00" + "time": "2026-02-06T06:01:44+00:00" }, { "name": "phpunit/php-file-iterator", - "version": "6.0.0", + "version": "6.0.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-file-iterator.git", - "reference": "961bc913d42fe24a257bfff826a5068079ac7782" + "reference": "3d1cd096ef6bea4bf2762ba586e35dbd317cbfd5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/961bc913d42fe24a257bfff826a5068079ac7782", - "reference": "961bc913d42fe24a257bfff826a5068079ac7782", + "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/3d1cd096ef6bea4bf2762ba586e35dbd317cbfd5", + "reference": "3d1cd096ef6bea4bf2762ba586e35dbd317cbfd5", "shasum": "" }, "require": { @@ -3101,15 +3098,27 @@ "support": { "issues": "https://github.com/sebastianbergmann/php-file-iterator/issues", "security": "https://github.com/sebastianbergmann/php-file-iterator/security/policy", - "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/6.0.0" + "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/6.0.1" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-file-iterator", + "type": "tidelift" } ], - "time": "2025-02-07T04:58:37+00:00" + "time": "2026-02-02T14:04:18+00:00" }, { "name": "phpunit/php-invoker", @@ -3297,16 +3306,16 @@ }, { "name": "phpunit/phpunit", - "version": "12.5.8", + "version": "12.5.12", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "37ddb96c14bfee10304825edbb7e66d341ec6889" + "reference": "418e06b3b46b0d54bad749ff4907fc7dfb530199" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/37ddb96c14bfee10304825edbb7e66d341ec6889", - "reference": "37ddb96c14bfee10304825edbb7e66d341ec6889", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/418e06b3b46b0d54bad749ff4907fc7dfb530199", + "reference": "418e06b3b46b0d54bad749ff4907fc7dfb530199", "shasum": "" }, "require": { @@ -3320,8 +3329,8 @@ "phar-io/manifest": "^2.0.4", "phar-io/version": "^3.2.1", "php": ">=8.3", - "phpunit/php-code-coverage": "^12.5.2", - "phpunit/php-file-iterator": "^6.0.0", + "phpunit/php-code-coverage": "^12.5.3", + "phpunit/php-file-iterator": "^6.0.1", "phpunit/php-invoker": "^6.0.0", "phpunit/php-text-template": "^5.0.0", "phpunit/php-timer": "^8.0.0", @@ -3332,6 +3341,7 @@ "sebastian/exporter": "^7.0.2", "sebastian/global-state": "^8.0.2", "sebastian/object-enumerator": "^7.0.0", + "sebastian/recursion-context": "^7.0.1", "sebastian/type": "^6.0.3", "sebastian/version": "^6.0.0", "staabm/side-effects-detector": "^1.0.5" @@ -3374,7 +3384,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/phpunit/issues", "security": "https://github.com/sebastianbergmann/phpunit/security/policy", - "source": "https://github.com/sebastianbergmann/phpunit/tree/12.5.8" + "source": "https://github.com/sebastianbergmann/phpunit/tree/12.5.12" }, "funding": [ { @@ -3398,7 +3408,7 @@ "type": "tidelift" } ], - "time": "2026-01-27T06:12:29+00:00" + "time": "2026-02-16T08:34:36+00:00" }, { "name": "psr/simple-cache", @@ -4402,16 +4412,16 @@ }, { "name": "symfony/finder", - "version": "v8.0.5", + "version": "v8.0.6", "source": { "type": "git", "url": "https://github.com/symfony/finder.git", - "reference": "8bd576e97c67d45941365bf824e18dc8538e6eb0" + "reference": "441404f09a54de6d1bd6ad219e088cdf4c91f97c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/finder/zipball/8bd576e97c67d45941365bf824e18dc8538e6eb0", - "reference": "8bd576e97c67d45941365bf824e18dc8538e6eb0", + "url": "https://api.github.com/repos/symfony/finder/zipball/441404f09a54de6d1bd6ad219e088cdf4c91f97c", + "reference": "441404f09a54de6d1bd6ad219e088cdf4c91f97c", "shasum": "" }, "require": { @@ -4446,7 +4456,7 @@ "description": "Finds files and directories via an intuitive fluent interface", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/finder/tree/v8.0.5" + "source": "https://github.com/symfony/finder/tree/v8.0.6" }, "funding": [ { @@ -4466,7 +4476,7 @@ "type": "tidelift" } ], - "time": "2026-01-26T15:08:38+00:00" + "time": "2026-01-29T09:41:02+00:00" }, { "name": "symfony/process", @@ -4535,23 +4545,23 @@ }, { "name": "ta-tikoma/phpunit-architecture-test", - "version": "0.8.6", + "version": "0.8.7", "source": { "type": "git", "url": "https://github.com/ta-tikoma/phpunit-architecture-test.git", - "reference": "ad48430b92901fd7d003fdaf2d7b139f96c0906e" + "reference": "1248f3f506ca9641d4f68cebcd538fa489754db8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/ta-tikoma/phpunit-architecture-test/zipball/ad48430b92901fd7d003fdaf2d7b139f96c0906e", - "reference": "ad48430b92901fd7d003fdaf2d7b139f96c0906e", + "url": "https://api.github.com/repos/ta-tikoma/phpunit-architecture-test/zipball/1248f3f506ca9641d4f68cebcd538fa489754db8", + "reference": "1248f3f506ca9641d4f68cebcd538fa489754db8", "shasum": "" }, "require": { "nikic/php-parser": "^4.18.0 || ^5.0.0", "php": "^8.1.0", "phpdocumentor/reflection-docblock": "^5.3.0 || ^6.0.0", - "phpunit/phpunit": "^10.5.5 || ^11.0.0 || ^12.0.0", + "phpunit/phpunit": "^10.5.5 || ^11.0.0 || ^12.0.0 || ^13.0.0", "symfony/finder": "^6.4.0 || ^7.0.0 || ^8.0.0" }, "require-dev": { @@ -4588,9 +4598,9 @@ ], "support": { "issues": "https://github.com/ta-tikoma/phpunit-architecture-test/issues", - "source": "https://github.com/ta-tikoma/phpunit-architecture-test/tree/0.8.6" + "source": "https://github.com/ta-tikoma/phpunit-architecture-test/tree/0.8.7" }, - "time": "2026-01-30T07:16:00+00:00" + "time": "2026-02-17T17:25:14+00:00" }, { "name": "theseer/tokenizer", @@ -4644,16 +4654,16 @@ }, { "name": "webmozart/assert", - "version": "2.1.2", + "version": "2.1.6", "source": { "type": "git", "url": "https://github.com/webmozarts/assert.git", - "reference": "ce6a2f100c404b2d32a1dd1270f9b59ad4f57649" + "reference": "ff31ad6efc62e66e518fbab1cde3453d389bcdc8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/webmozarts/assert/zipball/ce6a2f100c404b2d32a1dd1270f9b59ad4f57649", - "reference": "ce6a2f100c404b2d32a1dd1270f9b59ad4f57649", + "url": "https://api.github.com/repos/webmozarts/assert/zipball/ff31ad6efc62e66e518fbab1cde3453d389bcdc8", + "reference": "ff31ad6efc62e66e518fbab1cde3453d389bcdc8", "shasum": "" }, "require": { @@ -4700,9 +4710,9 @@ ], "support": { "issues": "https://github.com/webmozarts/assert/issues", - "source": "https://github.com/webmozarts/assert/tree/2.1.2" + "source": "https://github.com/webmozarts/assert/tree/2.1.6" }, - "time": "2026-01-13T14:02:24+00:00" + "time": "2026-02-27T10:28:38+00:00" } ], "aliases": [], @@ -4711,7 +4721,7 @@ "prefer-stable": false, "prefer-lowest": false, "platform": { - "php": "^8.3", + "php": "^8.4", "ext-pcntl": "*" }, "platform-dev": {}, From 55b44c162d73b97c5018ffe4bc2af88a9a3c5b4a Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Sat, 14 Mar 2026 13:19:04 +0200 Subject: [PATCH 3/4] fix(scene): adjust scene file path handling to check for lowercase 'assets' fix(path): correct assets directory path casing in getWorkingDirectoryAssetsPath method --- src/Core/Scenes/SceneManager.php | 7 ++++++- src/Util/Path.php | 2 +- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/Core/Scenes/SceneManager.php b/src/Core/Scenes/SceneManager.php index 0ef1d23..793747b 100644 --- a/src/Core/Scenes/SceneManager.php +++ b/src/Core/Scenes/SceneManager.php @@ -373,7 +373,12 @@ public function loadSceneFromFile(string $path): void $filename = $path . self::SCENE_FILE_EXTENSION; if (!file_exists($filename)) { - throw new SceneNotFoundException($path); + // Try assets rather than Assets + $filename = str_replace('Assets', 'assets', $filename); + + if (!file_exists($filename)) { + throw new SceneNotFoundException($path); + } } $sceneMetadata = require($filename); diff --git a/src/Util/Path.php b/src/Util/Path.php index 4a23f7a..ba12623 100644 --- a/src/Util/Path.php +++ b/src/Util/Path.php @@ -122,7 +122,7 @@ public static function getVendorAssetsDirectory(): string */ public static function getWorkingDirectoryAssetsPath(): string { - return self::join(getcwd() ?: '', 'assets'); + return self::join(getcwd() ?: '', 'Assets'); } /** From 83658c75cbe4670819ec89766b0725c5a1ea2cec Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Sun, 15 Mar 2026 07:16:59 +0200 Subject: [PATCH 4/4] feat(console): enhance console initialization with tmux support and asset path resolution --- src/Core/Rendering/SplashScreen.php | 5 +- src/Core/Scenes/SceneManager.php | 8 + src/Game.php | 217 +++++++++++++++++++++++++--- src/IO/Console/Console.php | 7 +- src/Util/Path.php | 30 +++- tests/Unit/GameTest.php | 26 ++++ tests/Unit/Util/PathTest.php | 24 +++ 7 files changed, 294 insertions(+), 23 deletions(-) create mode 100644 tests/Unit/Util/PathTest.php diff --git a/src/Core/Rendering/SplashScreen.php b/src/Core/Rendering/SplashScreen.php index 07379ba..b4288b1 100644 --- a/src/Core/Rendering/SplashScreen.php +++ b/src/Core/Rendering/SplashScreen.php @@ -27,7 +27,10 @@ public function show(): void // Check if a splash texture can be loaded if (!file_exists($this->getSettings('splash_texture'))) { Debug::warn("Splash screen texture not found: {$this->settings[SettingsKey::SPLASH_TEXTURE->value]}"); - $this->settings[SettingsKey::SPLASH_TEXTURE->value] = Path::join(Path::getVendorAssetsDirectory(), DEFAULT_SPLASH_TEXTURE_PATH); + $this->settings[SettingsKey::SPLASH_TEXTURE->value] = Path::join( + Path::getVendorAssetsDirectory(), + basename(DEFAULT_SPLASH_TEXTURE_PATH) + ); } Debug::info("Loading splash screen texture"); diff --git a/src/Core/Scenes/SceneManager.php b/src/Core/Scenes/SceneManager.php index 793747b..2fb2d13 100644 --- a/src/Core/Scenes/SceneManager.php +++ b/src/Core/Scenes/SceneManager.php @@ -68,6 +68,7 @@ final class SceneManager implements SingletonInterface, CanStart, CanResume, Can */ protected EventManager $eventManager; protected Physics $physics; + protected static ?string $metadataAssetsRoot = null; /** * Constructs a SceneManager @@ -383,6 +384,7 @@ public function loadSceneFromFile(string $path): void $sceneMetadata = require($filename); $sceneMetadata = json_decode(json_encode($sceneMetadata, JSON_UNESCAPED_SLASHES), false); + self::$metadataAssetsRoot = Path::normalize(dirname($filename, 2)); $sceneName = $sceneMetadata->name ?? basename($path); @@ -576,11 +578,17 @@ private static function resolvePrefabPath(string $path): ?string } $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'; } diff --git a/src/Game.php b/src/Game.php index 6ccfc41..b2648dd 100644 --- a/src/Game.php +++ b/src/Game.php @@ -53,6 +53,8 @@ class Game implements ObservableInterface { const int DEBUG_WINDOW_HEIGHT = 5; + private const string TMUX_CHILD_ENV_KEY = 'SENDAMA_TMUX_CHILD'; + private const string TMUX_SESSION_ENV_KEY = 'SENDAMA_TMUX_SESSION'; /** * @var SceneState $sceneState */ @@ -116,7 +118,7 @@ class Game implements ObservableInterface /** * @var Cursor $consoleCursor */ - private Cursor $consoleCursor; + private ?Cursor $consoleCursor = null; /** * @var Window $debugWindow */ @@ -134,7 +136,8 @@ class Game implements ObservableInterface */ private GameStateInterface $state; - private SplashScreen $splashScreen; + private ?SplashScreen $splashScreen = null; + private bool $consoleInitialized = false; /** * Game constructor. @@ -149,13 +152,11 @@ public function __construct(private readonly string $name, private readonly int try { $this->initializeObservers(); $this->configureErrorAndExceptionHandlers(); - $this->initializeConsole(); $this->initializeConfigStore(); $this->initializeManagers(); $this->initializeSettings(); $this->initializeGameStates(); $this->configureWindowChangeSignalHandler(); - $this->splashScreen = new SplashScreen($this->consoleCursor, $this->settings); } catch (Error|Throwable $exception) { $this->handleException($exception); } @@ -219,21 +220,23 @@ private function handleException(Throwable|Error $exception): never */ public function stop(): void { - Console::reset(); - Debug::info("Stopping game"); - // Disable non-blocking input mode - InputManager::disableNonBlockingMode(); + if ($this->consoleInitialized) { + Console::reset(); + + // Disable non-blocking input mode + InputManager::disableNonBlockingMode(); - // Enable echo - InputManager::enableEcho(); + // Enable echo + InputManager::enableEcho(); - // Show cursor - $this->consoleCursor->show(); + // Show cursor + $this->consoleCursor?->show(); - // Restore the terminal settings - Console::restoreSettings(); + // Restore the terminal settings + Console::restoreSettings(); + } // Remove observers $this->removeObservers(); @@ -336,10 +339,16 @@ private function handleError(int $errno, string $errstr, string $errfile, int $e /** * @return void */ - protected function initializeConsole(): void + protected function initializeConsole(bool $clearOnInit = true): void { $this->consoleCursor = Console::cursor(); - Console::init($this, []); + Console::init($this, [ + 'width' => $this->getLogicalScreenWidth(), + 'height' => $this->getLogicalScreenHeight(), + 'clear_on_init' => $clearOnInit, + ]); + $this->splashScreen = new SplashScreen($this->consoleCursor, $this->settings); + $this->consoleInitialized = true; } /** @@ -391,7 +400,7 @@ private function initializeSettings(): void $this->screenHeight ); $this->settings[SettingsKey::FPS->value] = DEFAULT_FPS; - $this->settings[SettingsKey::ASSETS_DIR->value] = Path::join(getcwd(), DEFAULT_ASSETS_PATH); + $this->settings[SettingsKey::ASSETS_DIR->value] = Path::getWorkingDirectoryAssetsPath(); $this->settings[SettingsKey::INITIAL_SCENE->value] = null; @@ -497,7 +506,8 @@ public function loadSettings(?array $settings = null): self $this->screenHeight ); $this->settings[SettingsKey::FPS->value] = $settings[SettingsKey::FPS->value] ?? DEFAULT_FPS; - $this->settings[SettingsKey::ASSETS_DIR->value] = $settings[SettingsKey::ASSETS_DIR->value] ?? getcwd() . DEFAULT_ASSETS_PATH; + $this->settings[SettingsKey::ASSETS_DIR->value] = $settings[SettingsKey::ASSETS_DIR->value] + ?? Path::getWorkingDirectoryAssetsPath(); Debug::info('Loading scene settings'); // Scene @@ -575,7 +585,9 @@ public static function quit(): void */ public function __destruct() { - Console::restoreSettings(); + if ($this->consoleInitialized) { + Console::restoreSettings(); + } if ($lastError = error_get_last()) { $this->handleError($lastError['type'], $lastError['message'], $lastError['file'], $lastError['line']); @@ -591,6 +603,11 @@ public function __destruct() public function run(): void { try { + if ($this->handoffToTmuxSessionIfAvailable()) { + return; + } + + $this->initializeConsole(clearOnInit: !$this->isTmuxChildProcess()); $sleepTime = (int)(1000000 / $this->getSettings('fps')); $this->start(); $nextFrameTime = microtime(true) + 1; @@ -630,6 +647,14 @@ private function start(): void { Debug::info("Starting game"); + if (!$this->consoleInitialized) { + $this->initializeConsole(clearOnInit: !$this->isTmuxChildProcess()); + } + + if (!$this->consoleCursor instanceof Cursor || !$this->splashScreen instanceof SplashScreen) { + throw new InitializationException('Console runtime was not initialized.'); + } + // Save the terminal settings Console::saveSettings(); @@ -920,6 +945,160 @@ private static function isTruthySetting(mixed $value): bool }; } + /** + * Hands off the game runtime to a dedicated tmux session when supported. + * + * @return bool True when control was transferred and the current process should return. + */ + private function handoffToTmuxSessionIfAvailable(): bool + { + if ( + $this->isTmuxChildProcess() + || !self::isTmuxInstalled() + || !self::canRelaunchCurrentCommand($_SERVER['argv'] ?? null) + ) { + return false; + } + + $sessionName = self::buildTmuxSessionName((string)$this->getSettings(SettingsKey::GAME_NAME->value)); + $workingDirectory = getcwd() ?: $this->workingDirectory ?? '.'; + $command = self::buildTmuxRuntimeCommand($sessionName, $_SERVER['argv'] ?? []); + + if (self::tmuxSessionExists($sessionName)) { + self::destroyTmuxSession($sessionName); + } + + $createExitCode = 0; + exec( + sprintf( + 'tmux new-session -d -s %s -c %s %s', + escapeshellarg($sessionName), + escapeshellarg($workingDirectory), + escapeshellarg($command), + ), + result_code: $createExitCode, + ); + + if ($createExitCode !== 0) { + return false; + } + + $attachCommand = getenv('TMUX') + ? sprintf('tmux switch-client -t %s', escapeshellarg($sessionName)) + : sprintf('tmux attach-session -t %s', escapeshellarg($sessionName)); + + passthru($attachCommand, $attachExitCode); + + if ($attachExitCode !== 0) { + self::destroyTmuxSession($sessionName); + return false; + } + + return true; + } + + /** + * Determine whether this process is already running inside a Sendama-managed tmux session. + * + * @return bool + */ + private function isTmuxChildProcess(): bool + { + $envValue = $_ENV[self::TMUX_CHILD_ENV_KEY] + ?? getenv(self::TMUX_CHILD_ENV_KEY) + ?? false; + + return self::isTruthySetting($envValue); + } + + /** + * Checks whether tmux is available in the executing environment. + * + * @return bool + */ + private static function isTmuxInstalled(): bool + { + $tmuxPath = shell_exec('command -v tmux 2>/dev/null'); + + return is_string($tmuxPath) && trim($tmuxPath) !== ''; + } + + /** + * Determine whether the current runtime command can be relaunched safely. + * + * @param mixed $argv + * @return bool + */ + private static function canRelaunchCurrentCommand(mixed $argv): bool + { + return PHP_SAPI === 'cli' && is_array($argv) && $argv !== []; + } + + /** + * Builds a tmux-safe session name from the configured game title. + * + * @param string $gameName + * @return string + */ + private static function buildTmuxSessionName(string $gameName): string + { + $sanitizedName = preg_replace('/[^A-Za-z0-9_-]+/', '-', trim($gameName)) ?? ''; + $sanitizedName = trim($sanitizedName, '-_'); + + return $sanitizedName !== '' ? $sanitizedName : 'sendama-game'; + } + + /** + * Reconstructs the current PHP command line for tmux handoff. + * + * @param string $sessionName + * @param array $argv + * @return string + */ + private static function buildTmuxRuntimeCommand(string $sessionName, array $argv): string + { + $commandParts = array_merge( + [ + self::TMUX_CHILD_ENV_KEY . '=1', + self::TMUX_SESSION_ENV_KEY . '=' . escapeshellarg($sessionName), + escapeshellarg(PHP_BINARY), + ], + array_map( + static fn(mixed $argument): string => escapeshellarg((string)$argument), + $argv, + ), + ); + + return implode(' ', $commandParts); + } + + /** + * Checks whether a tmux session already exists. + * + * @param string $sessionName + * @return bool + */ + private static function tmuxSessionExists(string $sessionName): bool + { + exec( + sprintf('tmux has-session -t %s 2>/dev/null', escapeshellarg($sessionName)), + result_code: $exitCode, + ); + + return $exitCode === 0; + } + + /** + * Destroys an existing tmux session. + * + * @param string $sessionName + * @return void + */ + private static function destroyTmuxSession(string $sessionName): void + { + exec(sprintf('tmux kill-session -t %s 2>/dev/null', escapeshellarg($sessionName))); + } + /** * Add scenes to the game. * diff --git a/src/IO/Console/Console.php b/src/IO/Console/Console.php index 694cbe2..0f0b818 100644 --- a/src/IO/Console/Console.php +++ b/src/IO/Console/Console.php @@ -90,13 +90,18 @@ private function __construct() public static function init(Game $game, array $options = [ 'width' => DEFAULT_SCREEN_WIDTH, 'height' => DEFAULT_SCREEN_HEIGHT, + 'clear_on_init' => true, ]): void { self::$game = $game; self::$logicalWidth = $options['width'] ?? DEFAULT_SCREEN_WIDTH; self::$logicalHeight = $options['height'] ?? DEFAULT_SCREEN_HEIGHT; self::refreshLayout(self::$logicalWidth, self::$logicalHeight, clearWhenChanged: false); - self::clear(); + if (($options['clear_on_init'] ?? true) === true) { + self::clear(); + } else { + self::$buffer = self::getEmptyBuffer(); + } Console::cursor()->disableBlinking(); self::$output = new ConsoleOutput(); } diff --git a/src/Util/Path.php b/src/Util/Path.php index ba12623..5079465 100644 --- a/src/Util/Path.php +++ b/src/Util/Path.php @@ -122,7 +122,33 @@ public static function getVendorAssetsDirectory(): string */ public static function getWorkingDirectoryAssetsPath(): string { - return self::join(getcwd() ?: '', 'Assets'); + $baseDirectory = self::$workingDirectory !== '' + ? self::$workingDirectory + : (getcwd() ?: ''); + + return self::resolveAssetsDirectory($baseDirectory); + } + + /** + * Resolve a project assets directory while supporting both legacy `Assets` + * and newer `assets` layouts. + * + * @param string $baseDirectory + * @return string + */ + public static function resolveAssetsDirectory(string $baseDirectory): string + { + $baseDirectory = self::normalize($baseDirectory); + + foreach (['assets', 'Assets'] as $candidateName) { + $candidate = self::join($baseDirectory, $candidateName); + + if (is_dir($candidate)) { + return $candidate; + } + } + + return self::join($baseDirectory, DEFAULT_ASSETS_PATH); } /** @@ -177,4 +203,4 @@ public static function getCurrentWorkingDirectory(): string { return getcwd() ?: ''; } -} \ No newline at end of file +} diff --git a/tests/Unit/GameTest.php b/tests/Unit/GameTest.php index 7b40626..440768f 100644 --- a/tests/Unit/GameTest.php +++ b/tests/Unit/GameTest.php @@ -62,6 +62,32 @@ ], 'info'))->toBe('warn'); }); +it('builds tmux-safe session names from the configured game title', function () { + expect(invokePrivateStaticMethod(Game::class, 'buildTmuxSessionName', 'The Collector'))->toBe('The-Collector') + ->and(invokePrivateStaticMethod(Game::class, 'buildTmuxSessionName', ' !!! '))->toBe('sendama-game'); +}); + +it('builds a managed tmux relaunch command for the current php entrypoint', function () { + $command = invokePrivateStaticMethod( + Game::class, + 'buildTmuxRuntimeCommand', + 'The-Collector', + ['/tmp/game.php', '--scene=level01', 'Arcade Mode'] + ); + + expect($command)->toContain('SENDAMA_TMUX_CHILD=1') + ->toContain("SENDAMA_TMUX_SESSION='The-Collector'") + ->toContain(escapeshellarg(PHP_BINARY)) + ->toContain("'/tmp/game.php'") + ->toContain("'--scene=level01'") + ->toContain("'Arcade Mode'"); +}); + +it('only uses the tmux handoff when the current command can be relaunched', function () { + expect(invokePrivateStaticMethod(Game::class, 'canRelaunchCurrentCommand', []))->toBeFalse() + ->and(invokePrivateStaticMethod(Game::class, 'canRelaunchCurrentCommand', ['/tmp/game.php']))->toBe(PHP_SAPI === 'cli'); +}); + function invokePrivateStaticMethod(string $className, string $methodName, mixed ...$args): mixed { $reflection = new \ReflectionClass($className); diff --git a/tests/Unit/Util/PathTest.php b/tests/Unit/Util/PathTest.php new file mode 100644 index 0000000..e16566a --- /dev/null +++ b/tests/Unit/Util/PathTest.php @@ -0,0 +1,24 @@ +toBe($workspace . '/assets'); +}); + +it('falls back to a legacy uppercase Assets directory when present', function () { + $workspace = sys_get_temp_dir() . '/sendama-path-upper-' . uniqid('', true); + mkdir($workspace . '/Assets', 0777, true); + + expect(Path::resolveAssetsDirectory($workspace))->toBe($workspace . '/Assets'); +}); + +it('defaults to the modern lowercase assets path when no directory exists yet', function () { + $workspace = sys_get_temp_dir() . '/sendama-path-default-' . uniqid('', true); + mkdir($workspace, 0777, true); + + expect(Path::resolveAssetsDirectory($workspace))->toBe($workspace . '/assets'); +});