From 1b85a4172803efd70665197f79ee24bf5e037a5f Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Thu, 12 Mar 2026 10:07:57 +0200 Subject: [PATCH 1/3] fix(rendering): improve special character rendering and handle unicode glyphs --- src/Core/Component.php | 666 ++++++++++---------- src/Core/Scenes/AbstractScene.php | 11 +- src/Core/Texture.php | 16 +- src/IO/Console/Console.php | 5 +- src/UI/Label/Label.php | 3 +- src/Util/Unicode.php | 83 +++ tests/Mocks/Textures/unicode.texture | 2 + tests/Unit/Core/Texture2DTest.php | 14 +- tests/Unit/IO/Console/ConsoleLayoutTest.php | 17 + 9 files changed, 470 insertions(+), 347 deletions(-) create mode 100644 src/Util/Unicode.php create mode 100644 tests/Mocks/Textures/unicode.texture diff --git a/src/Core/Component.php b/src/Core/Component.php index 2667f44..d365ba7 100644 --- a/src/Core/Component.php +++ b/src/Core/Component.php @@ -15,339 +15,335 @@ */ abstract class Component implements ComponentInterface { - /** - * @var bool $enabled Whether the component is enabled or not. - */ - protected bool $enabled = true; - - /** - * @var string - */ - protected string $hash; - - /** - * Component constructor. - * - * @param GameObject $gameObject The game object. - */ - public function __construct(private readonly GameObject $gameObject) - { - $this->hash = md5(__CLASS__) . '-' . uniqid($this->gameObject->getName(), true); - - $this->awake(); - } - - /** - * @inheritDoc - */ - public final function getGameObject(): GameObject - { - return $this->gameObject; - } - - /** - * @inheritDoc - */ - public final function getTransform(): Transform - { - return $this->gameObject->getTransform(); - } - - /** - * @inheritDoc - */ - public final function getRenderer(): Renderer - { - return $this->getGameObject()->getRenderer(); - } - - /** - * Enables the component. - * - * @inheritDoc - */ - public final function enable(): void - { - $this->enabled = true; - $this->start(); - } - - /** - * @inheritDoc - */ - public final function disable(): void - { - $this->enabled = false; - $this->stop(); - } - - /** - * Called when the component is disabled. - * - * @return void - */ - public function onDisable(): void - { - // Do nothing - } - - /** - * @inheritDoc - */ - public final function isEnabled(): bool - { - return $this->enabled; - } - - /** - * @inheritDoc - */ - public function compareTo(CanCompare $other): int - { - if (! $other instanceof Component) - { - throw new InvalidArgumentException('Cannot compare a component with a non-component.'); - } - - return strcmp($this->getHash(), $other->getHash()); - } - - /** - * @inheritDoc - */ - public function greaterThan(CanCompare $other): bool - { - return $this->compareTo($other) > 0; - } - - /** - * @inheritDoc - */ - public function greaterThanOrEqual(CanCompare $other): bool - { - return $this->compareTo($other) >= 0; - } - - /** - * @inheritDoc - */ - public function lessThan(CanCompare $other): bool - { - return $this->compareTo($other) < 0; - } - - /** - * @inheritDoc - */ - public function lessThanOrEqual(CanCompare $other): bool - { - return $this->compareTo($other) <= 0; - } - - /** - * @inheritDoc - */ - public function equals(CanEquate $equatable): bool - { - return $this->getHash() === $equatable->getHash(); - } - - /** - * @inheritDoc - */ - public function notEquals(CanEquate $equatable): bool - { - return $this->getHash() !== $equatable->getHash(); - } - - /** - * @inheritDoc - */ - public function getHash(): string - { - return $this->hash; - } - - /** - * @inheritDoc - */ - public final function resume(): void - { - $this->onResume(); - } - - /** - * Called when the component is resumed. - * - * @return void - */ - public function onResume(): void - { - // Do nothing - } - - /** - * @inheritDoc - */ - public final function suspend(): void - { - $this->onSuspend(); - } - - /** - * Called when the component is suspended. - * - * @return void - */ - public function onSuspend(): void - { - // Do nothing - } - - /** - * @inheritDoc - */ - public final function start(): void - { - $this->onStart(); - } - - /** - * Called when the component is started. - * - * @return void - */ - public function onStart(): void - { - // Do nothing - } - - /** - * @inheritDoc - */ - public final function stop(): void - { - $this->onStop(); - } - - /** - * Called when the component is stopped. - * - * @return void - */ - public function onStop(): void - { - // Do nothing - } - - /** - * @inheritDoc - */ - public final function fixedUpdate(): void - { - if ($this->isEnabled()) { - $this->onFixedUpdate(); - } - } - - /** - * Called once every physics step. - * - * @return void - */ - public function onFixedUpdate(): void - { - // Do nothing - } - - /** - * @inheritDoc - */ - public final function update(): void - { - if ($this->isEnabled()) { - $this->onUpdate(); - } - } - - /** - * Called when the component is updated. - * - * @return void - */ - public function onUpdate(): void - { - // Do nothing - } - - /** - * @inheritDoc - */ - public function awake(): void - { - // Do nothing - } - - /** - * @inheritDoc - */ - public function broadcast(string $methodName, array $args = []): void - { - $this->gameObject->broadcast($methodName, $args); - } - - /** - * @inheritDoc - */ - public function hasTag(string $tag): bool - { - return $this->gameObject->getTag() === $tag; - } - - /** - * Serializes the component. - */ - public function __serialize(): array - { - $data = []; - $reflection = new ReflectionObject($this); - $properties = $reflection->getProperties(); - - foreach ($properties as $property) - { - if ($property->isPublic() || $property->getAttributes(SerializeField::class)) - { - $data[$property->getName()] = $property->getValue($this); - } - } - - return $data; - } - - /** - * Deserializes the component. - */ - public function __unserialize(array $data): void - { - foreach ($data as $key => $value) - { - $this->{$key} = $value; - } - } - - /** - * @inheritDoc - */ - public function getComponent(string $componentClass): ?ComponentInterface - { - return $this->getGameObject()->getComponent($componentClass); - } - - /** - * @inheritDoc - */ - public function getComponents(string $componentClass): array - { - return $this->getGameObject()->getComponents($componentClass); - } + /** + * @var bool $enabled Whether the component is enabled or not. + */ + protected bool $enabled = true; + + /** + * @var string + */ + protected string $hash; + + /** + * Component constructor. + * + * @param GameObject $gameObject The game object. + */ + public function __construct(private readonly GameObject $gameObject) + { + $this->hash = md5(__CLASS__) . '-' . uniqid($this->gameObject->getName(), true); + + $this->awake(); + } + + /** + * @inheritDoc + */ + public function awake(): void + { + // Do nothing + } + + /** + * @inheritDoc + */ + public final function getTransform(): Transform + { + return $this->gameObject->getTransform(); + } + + /** + * @inheritDoc + */ + public final function getRenderer(): Renderer + { + return $this->getGameObject()->getRenderer(); + } + + /** + * @inheritDoc + */ + public final function getGameObject(): GameObject + { + return $this->gameObject; + } + + /** + * Enables the component. + * + * @inheritDoc + */ + public final function enable(): void + { + $this->enabled = true; + $this->start(); + } + + /** + * @inheritDoc + */ + public final function start(): void + { + $this->onStart(); + } + + /** + * Called when the component is started. + * + * @return void + */ + public function onStart(): void + { + // Do nothing + } + + /** + * @inheritDoc + */ + public final function disable(): void + { + $this->enabled = false; + $this->stop(); + } + + /** + * @inheritDoc + */ + public final function stop(): void + { + $this->onStop(); + } + + /** + * Called when the component is stopped. + * + * @return void + */ + public function onStop(): void + { + // Do nothing + } + + /** + * Called when the component is disabled. + * + * @return void + */ + public function onDisable(): void + { + // Do nothing + } + + /** + * @inheritDoc + */ + public function greaterThan(CanCompare $other): bool + { + return $this->compareTo($other) > 0; + } + + /** + * @inheritDoc + */ + public function compareTo(CanCompare $other): int + { + if (!$other instanceof Component) { + throw new InvalidArgumentException('Cannot compare a component with a non-component.'); + } + + return strcmp($this->getHash(), $other->getHash()); + } + + /** + * @inheritDoc + */ + public function getHash(): string + { + return $this->hash; + } + + /** + * @inheritDoc + */ + public function greaterThanOrEqual(CanCompare $other): bool + { + return $this->compareTo($other) >= 0; + } + + /** + * @inheritDoc + */ + public function lessThan(CanCompare $other): bool + { + return $this->compareTo($other) < 0; + } + + /** + * @inheritDoc + */ + public function lessThanOrEqual(CanCompare $other): bool + { + return $this->compareTo($other) <= 0; + } + + /** + * @inheritDoc + */ + public function equals(CanEquate $equatable): bool + { + return $this->getHash() === $equatable->getHash(); + } + + /** + * @inheritDoc + */ + public function notEquals(CanEquate $equatable): bool + { + return $this->getHash() !== $equatable->getHash(); + } + + /** + * @inheritDoc + */ + public final function resume(): void + { + $this->onResume(); + } + + /** + * Called when the component is resumed. + * + * @return void + */ + public function onResume(): void + { + // Do nothing + } + + /** + * @inheritDoc + */ + public final function suspend(): void + { + $this->onSuspend(); + } + + /** + * Called when the component is suspended. + * + * @return void + */ + public function onSuspend(): void + { + // Do nothing + } + + /** + * @inheritDoc + */ + public final function fixedUpdate(): void + { + if ($this->isEnabled()) { + $this->onFixedUpdate(); + } + } + + /** + * @inheritDoc + */ + public final function isEnabled(): bool + { + return $this->enabled; + } + + /** + * Called once every physics step. + * + * @return void + */ + public function onFixedUpdate(): void + { + // Do nothing + } + + /** + * @inheritDoc + */ + public final function update(): void + { + if ($this->isEnabled()) { + $this->onUpdate(); + } + } + + /** + * Called when the component is updated. + * + * @return void + */ + public function onUpdate(): void + { + // Do nothing + } + + /** + * @inheritDoc + */ + public function broadcast(string $methodName, array $args = []): void + { + $this->gameObject->broadcast($methodName, $args); + } + + /** + * @inheritDoc + */ + public function hasTag(string $tag): bool + { + return $this->gameObject->getTag() === $tag; + } + + /** + * Serializes the component. + */ + public function __serialize(): array + { + $data = []; + $reflection = new ReflectionObject($this); + $properties = $reflection->getProperties(); + + foreach ($properties as $property) { + if ($property->isPublic() || $property->getAttributes(SerializeField::class)) { + $data[$property->getName()] = $property->getValue($this); + } + } + + return $data; + } + + /** + * Deserializes the component. + */ + public function __unserialize(array $data): void + { + foreach ($data as $key => $value) { + $this->{$key} = $value; + } + } + + /** + * @inheritDoc + */ + public function getComponent(string $componentClass): ?ComponentInterface + { + return $this->getGameObject()->getComponent($componentClass); + } + + /** + * @inheritDoc + */ + public function getComponents(string $componentClass): array + { + return $this->getGameObject()->getComponents($componentClass); + } } \ No newline at end of file diff --git a/src/Core/Scenes/AbstractScene.php b/src/Core/Scenes/AbstractScene.php index 55f1cbf..fb0e977 100644 --- a/src/Core/Scenes/AbstractScene.php +++ b/src/Core/Scenes/AbstractScene.php @@ -15,6 +15,7 @@ use Sendama\Engine\Physics\Physics; use Sendama\Engine\UI\Interfaces\UIElementInterface; use Sendama\Engine\Util\Path; +use Sendama\Engine\Util\Unicode; /** * The abstract scene class. @@ -450,10 +451,14 @@ private function loadStaticEnvironment(): void $lines = explode("\n", $this->environmentTileMapData); + if (end($lines) === '') { + array_pop($lines); + } + foreach ($lines as $y => $line) { - $lineLength = strlen($line); - for ($x = 0; $x < $lineLength; $x++) { - $this->worldsSpace->set($x, $y, $line[$x]); + $glyphs = Unicode::characters($line); + foreach ($glyphs as $x => $glyph) { + $this->worldsSpace->set($x, $y, $glyph); } } diff --git a/src/Core/Texture.php b/src/Core/Texture.php index 79bdae2..b80872e 100644 --- a/src/Core/Texture.php +++ b/src/Core/Texture.php @@ -6,6 +6,7 @@ use Sendama\Engine\Core\Traits\DimensionTrait; use Sendama\Engine\IO\Enumerations\Color; use Sendama\Engine\Util\Path; +use Sendama\Engine\Util\Unicode; use Stringable; /** @@ -83,14 +84,19 @@ protected function loadImage(): void // Convert the image to an array of pixels. $imageMatrix = explode("\n", $image); + + if (end($imageMatrix) === '') { + array_pop($imageMatrix); + } + $height = 0; $longestRow = 0; foreach ($imageMatrix as $row) { - $width = $this->width < 1 ? strlen($row) : $this->width; - $chunks = str_split(substr($row, 0, $width)); + $chunks = Unicode::characters($row, $this->width < 1 ? null : $this->width); + $width = $this->width < 1 ? count($chunks) : $this->width; $this->pixels[] = $chunks; - $longestRow = max($longestRow, $width); + $longestRow = max($longestRow, count($chunks)); $height++; } @@ -159,7 +165,7 @@ public function setPixel(int $x, int $y, string $pixel): void $output = Color::apply($this->color, to: $output); } - $this->pixels[$y][$x] = substr($output, 0, 1); + $this->pixels[$y][$x] = Unicode::substring($output, 0, 1); } public function __toString(): string @@ -184,4 +190,4 @@ public function getPixels(): array { return $this->pixels; } -} \ No newline at end of file +} diff --git a/src/IO/Console/Console.php b/src/IO/Console/Console.php index dcf5411..112bd92 100644 --- a/src/IO/Console/Console.php +++ b/src/IO/Console/Console.php @@ -10,6 +10,7 @@ use Sendama\Engine\IO\Enumerations\Color; use Sendama\Engine\UI\Modals\ModalManager; use Sendama\Engine\UI\Windows\Enumerations\WindowPosition; +use Sendama\Engine\Util\Unicode; use Symfony\Component\Console\Formatter\OutputFormatterInterface; use Symfony\Component\Console\Output\ConsoleOutput; use Symfony\Component\Console\Output\OutputInterface; @@ -417,7 +418,7 @@ public static function writeLine(string $message, int $x, int $y): void return; } - if (!$containsAnsi && $skipVisibleChars === 0 && strlen($message) <= $availableWidth) { + if (!$containsAnsi && $skipVisibleChars === 0 && Unicode::length($message) <= $availableWidth) { $visibleMessage = $message; } else { $visibleMessage = self::sliceTextForDisplay($message, $skipVisibleChars, $availableWidth); @@ -603,7 +604,7 @@ private static function sliceTextForDisplay(string $message, int $skipVisibleCha } if (!str_contains($message, "\033")) { - return substr($message, $skipVisibleChars, $maxVisibleChars); + return Unicode::substring($message, $skipVisibleChars, $maxVisibleChars); } return self::sliceStyledText($message, $skipVisibleChars, $maxVisibleChars); diff --git a/src/UI/Label/Label.php b/src/UI/Label/Label.php index 9db49bb..16a16eb 100644 --- a/src/UI/Label/Label.php +++ b/src/UI/Label/Label.php @@ -4,6 +4,7 @@ use Sendama\Engine\IO\Console\Console; use Sendama\Engine\UI\UIElement; +use Sendama\Engine\Util\Unicode; /** * Represents a label UI element. @@ -77,7 +78,7 @@ public function erase(): void */ public function eraseAt(?int $x = null, ?int $y = null): void { - $buffer = str_repeat(' ', strlen($this->text)); + $buffer = str_repeat(' ', Unicode::length($this->text)); Console::write($buffer, $x ?? 0, $y ?? 0); } diff --git a/src/Util/Unicode.php b/src/Util/Unicode.php new file mode 100644 index 0000000..c89926b --- /dev/null +++ b/src/Util/Unicode.php @@ -0,0 +1,83 @@ +workingDirectory = dirname(__DIR__, 2); $this->texturePath = Path::join($this->workingDirectory, 'Mocks/Textures/test.texture'); + $this->unicodeTexturePath = Path::join($this->workingDirectory, 'Mocks/Textures/unicode.texture'); }); it ('can be created', function () { @@ -63,4 +64,15 @@ expect(strval($texture)) ->toBe($expectedString); }); -}); \ No newline at end of file + + it('preserves unicode glyphs as single pixels', function () { + $texture = new Texture($this->unicodeTexturePath); + + expect($texture->getWidth())->toBe(2) + ->and($texture->getHeight())->toBe(2) + ->and($texture->getPixel(0, 0))->toBe('←') + ->and($texture->getPixel(1, 0))->toBe('↑') + ->and($texture->getPixel(0, 1))->toBe('→') + ->and($texture->getPixel(1, 1))->toBe('↓'); + }); +}); diff --git a/tests/Unit/IO/Console/ConsoleLayoutTest.php b/tests/Unit/IO/Console/ConsoleLayoutTest.php index 79cfd77..34768ec 100644 --- a/tests/Unit/IO/Console/ConsoleLayoutTest.php +++ b/tests/Unit/IO/Console/ConsoleLayoutTest.php @@ -49,3 +49,20 @@ expect($output)->toContain("\033[1;1HBCDE"); }); + +it('renders unclipped unicode glyphs without breaking them into replacement characters', function () { + Console::refreshLayout( + 1, + 1, + new Rect(new Vector2(1, 1), new Vector2(1, 1)), + clearWhenChanged: false + ); + + ob_start(); + Console::write('→', 1, 1); + $output = ob_get_clean(); + + expect($output)->toContain("\033[1;1H→") + ->not()->toContain('�') + ->not()->toContain('?'); +}); From b2e8b44e88f8f58e44526b8110b0f669f50513e7 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Thu, 12 Mar 2026 10:55:30 +0200 Subject: [PATCH 2/3] feat(behaviour): add activeScene property and enhance GameObject serialization --- src/Core/Behaviours/Behaviour.php | 140 ++++++++++++++++-------------- src/Core/GameObject.php | 85 +++++++++--------- 2 files changed, 122 insertions(+), 103 deletions(-) diff --git a/src/Core/Behaviours/Behaviour.php b/src/Core/Behaviours/Behaviour.php index 238c5d2..873b30f 100644 --- a/src/Core/Behaviours/Behaviour.php +++ b/src/Core/Behaviours/Behaviour.php @@ -4,6 +4,7 @@ use Sendama\Engine\Core\Component; use Sendama\Engine\Core\GameObject; +use Sendama\Engine\Core\Scenes\Interfaces\SceneInterface; use Sendama\Engine\Physics\Interfaces\ColliderInterface; use Sendama\Engine\Physics\Interfaces\CollisionInterface; @@ -12,74 +13,85 @@ */ abstract class Behaviour extends Component { - public final function __construct(GameObject $gameObject) - { - parent::__construct($gameObject); - } + public SceneInterface $activeScene { + get { + return $this->getGameObject()->activeScene; + } + } - /** - * Called when the collider enters another collider. - * - * @param CollisionInterface $collision The collision. - * @return void - */ - public function onCollisionEnter(CollisionInterface $collision): void - { - // Override this method to handle collision enter events. - } + public SceneInterface $scene { + get { + return $this->getGameObject()->getScene(); + } + } + public final function __construct(GameObject $gameObject) + { + parent::__construct($gameObject); + } - /** - * Called when the collider exits another collider. - * - * @param CollisionInterface $collision The collision. - * @return void - */ - public function onCollisionExit(CollisionInterface $collision): void - { - // Override this method to handle collision exit events. - } + /** + * Called when the collider enters another collider. + * + * @param CollisionInterface $collision The collision. + * @return void + */ + public function onCollisionEnter(CollisionInterface $collision): void + { + // Override this method to handle collision enter events. + } - /** - * Called when the collider stays in another collider. - * - * @param CollisionInterface $collision The collision. - * @return void - */ - public function onCollisionStay(CollisionInterface $collision): void - { - // Override this method to handle collision stay events. - } + /** + * Called when the collider exits another collider. + * + * @param CollisionInterface $collision The collision. + * @return void + */ + public function onCollisionExit(CollisionInterface $collision): void + { + // Override this method to handle collision exit events. + } - /** - * Called when the collider enters a trigger. - * - * @param ColliderInterface $collider The collider. - * @return void - */ - public function onTriggerEnter(ColliderInterface $collider): void - { - // Override this method to handle trigger enter events. - } + /** + * Called when the collider stays in another collider. + * + * @param CollisionInterface $collision The collision. + * @return void + */ + public function onCollisionStay(CollisionInterface $collision): void + { + // Override this method to handle collision stay events. + } - /** - * Called when the collider exits a trigger. - * - * @param ColliderInterface $collider The collider. - * @return void - */ - public function onTriggerExit(ColliderInterface $collider): void - { - // Override this method to handle trigger exit events. - } + /** + * Called when the collider enters a trigger. + * + * @param ColliderInterface $collider The collider. + * @return void + */ + public function onTriggerEnter(ColliderInterface $collider): void + { + // Override this method to handle trigger enter events. + } - /** - * Called when the collider stays in a trigger. - * - * @param ColliderInterface $collider The collider. - * @return void - */ - public function onTriggerStay(ColliderInterface $collider): void - { - // Override this method to handle trigger stay events. - } + /** + * Called when the collider exits a trigger. + * + * @param ColliderInterface $collider The collider. + * @return void + */ + public function onTriggerExit(ColliderInterface $collider): void + { + // Override this method to handle trigger exit events. + } + + /** + * Called when the collider stays in a trigger. + * + * @param ColliderInterface $collider The collider. + * @return void + */ + public function onTriggerStay(ColliderInterface $collider): void + { + // Override this method to handle trigger stay events. + } } \ No newline at end of file diff --git a/src/Core/GameObject.php b/src/Core/GameObject.php index a13c542..273ab20 100644 --- a/src/Core/GameObject.php +++ b/src/Core/GameObject.php @@ -9,6 +9,7 @@ use Sendama\Engine\Core\Interfaces\GameObjectInterface; use Sendama\Engine\Core\Rendering\Renderer; use Sendama\Engine\Core\Scenes\Interfaces\SceneInterface; +use Sendama\Engine\Core\Scenes\Scene; use Sendama\Engine\Core\Scenes\SceneManager; use Sendama\Engine\UI\Interfaces\UIElementInterface; @@ -44,6 +45,12 @@ class GameObject implements GameObjectInterface */ protected Renderer $renderer; + public SceneInterface $activeScene { + get { + return SceneManager::getInstance()->getActiveScene(); + } + } + /** * GameObject constructor. * @@ -64,45 +71,6 @@ public function __construct(protected string $name, protected ?string $tag = nul $this->components[] = $this->renderer; } - /** - * Serializes the game object into an array. - * - * @return array The serialized game object. - */ - public function __serialize(): array - { - return [ - "hash" => $this->hash, - "name" => $this->name, - "tag" => $this->tag, - "position" => $this->position, - "rotation" => $this->rotation, - "scale" => $this->scale, - "transform" => $this->transform, - "render" => $this->renderer, - "sprite" => $this->sprite, - ]; - } - - /** - * Unserializes the game object from an array. - * - * @param array $data The data to unserialize the game object from. - * @return void - */ - public function __unserialize(array $data): void - { - $this->name = $data["name"]; - $this->tag = $data["tag"]; - $this->position = $data["position"]; - $this->rotation = $data["rotation"]; - $this->scale = $data["scale"]; - $this->transform = $data["transform"]; - $this->renderer = $data["render"]; - $this->sprite = $data["sprite"]; - $this->hash = $data["hash"] ?? md5(__CLASS__) . '-' . uniqid($data["name"] ?? 'GameObject', true); - } - /** * Clones the original game object and returns the clone. * @@ -257,6 +225,45 @@ public static function findAllWithTag(string $gameObjectTag): array return $gameObjects; } + /** + * Serializes the game object into an array. + * + * @return array The serialized game object. + */ + public function __serialize(): array + { + return [ + "hash" => $this->hash, + "name" => $this->name, + "tag" => $this->tag, + "position" => $this->position, + "rotation" => $this->rotation, + "scale" => $this->scale, + "transform" => $this->transform, + "render" => $this->renderer, + "sprite" => $this->sprite, + ]; + } + + /** + * Unserializes the game object from an array. + * + * @param array $data The data to unserialize the game object from. + * @return void + */ + public function __unserialize(array $data): void + { + $this->name = $data["name"]; + $this->tag = $data["tag"]; + $this->position = $data["position"]; + $this->rotation = $data["rotation"]; + $this->scale = $data["scale"]; + $this->transform = $data["transform"]; + $this->renderer = $data["render"]; + $this->sprite = $data["sprite"]; + $this->hash = $data["hash"] ?? md5(__CLASS__) . '-' . uniqid($data["name"] ?? 'GameObject', true); + } + /** * @inheritDoc */ From 4d4732c3c268a496805a27c51b236ec178fd8313 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Thu, 12 Mar 2026 12:44:50 +0200 Subject: [PATCH 3/3] feat(console): add maximizeWindow method and enhance console layout handling --- src/Core/Rendering/SplashScreen.php | 22 +++--- src/Core/Scenes/SceneManager.php | 30 +++++++- src/Game.php | 70 ++++++++++++++----- src/IO/Console/Console.php | 17 +++++ .../Scenes/scene_with_dimensions.scene.php | 8 +++ tests/Unit/Core/Rendering/RendererTest.php | 3 +- tests/Unit/Core/Scenes/SceneManagerTest.php | 54 ++++++++++++++ tests/Unit/IO/Console/ConsoleLayoutTest.php | 8 +++ 8 files changed, 186 insertions(+), 26 deletions(-) create mode 100644 tests/Mocks/Scenes/scene_with_dimensions.scene.php create mode 100644 tests/Unit/Core/Scenes/SceneManagerTest.php diff --git a/src/Core/Rendering/SplashScreen.php b/src/Core/Rendering/SplashScreen.php index b54424c..07379ba 100644 --- a/src/Core/Rendering/SplashScreen.php +++ b/src/Core/Rendering/SplashScreen.php @@ -8,6 +8,7 @@ use Sendama\Engine\IO\Console\Console; use Sendama\Engine\IO\Console\Cursor; use Sendama\Engine\Util\Path; +use Sendama\Engine\Util\Unicode; final class SplashScreen { @@ -22,7 +23,6 @@ public function show(): void { try { Debug::info("Showing splash screen"); - Console::setSize(MAX_SCREEN_WIDTH, MAX_SCREEN_HEIGHT); // Check if a splash texture can be loaded if (!file_exists($this->getSettings('splash_texture'))) { @@ -33,14 +33,21 @@ public function show(): void Debug::info("Loading splash screen texture"); $splashScreen = file_get_contents($this->getSettings('splash_texture')); $splashScreenRows = explode("\n", $splashScreen); - $splashScreenWidth = 75; - $splashScreenHeight = 25; $splashByLine = 'SendamaEngine ™'; - $splashScreenRows[] = sprintf("%s%s", str_repeat(' ', $splashScreenWidth - 12), "powered by"); - $splashScreenRows[] = sprintf("%s%s", str_repeat(' ', $splashScreenWidth - strlen($splashByLine)), $splashByLine); + $contentWidth = 75; + $splashScreenRows[] = sprintf("%s%s", str_repeat(' ', $contentWidth - 12), "powered by"); + $splashScreenRows[] = sprintf("%s%s", str_repeat(' ', $contentWidth - Unicode::length($splashByLine)), $splashByLine); + $splashScreenHeight = count($splashScreenRows); + $splashScreenWidth = 0; - $leftMargin = (MAX_SCREEN_WIDTH / 2) - ($splashScreenWidth / 2); - $topMargin = (MAX_SCREEN_HEIGHT / 2) - ($splashScreenHeight / 2); + foreach ($splashScreenRows as $row) { + $splashScreenWidth = max($splashScreenWidth, Unicode::length($row)); + } + + $terminalSize = Console::getSize(force: true); + + $leftMargin = max(1, (int)floor(($terminalSize->getWidth() - $splashScreenWidth) / 2) + 1); + $topMargin = max(1, (int)floor(($terminalSize->getHeight() - $splashScreenHeight) / 2) + 1); Debug::info("Rendering splash screen texture"); foreach ($splashScreenRows as $rowIndex => $row) { @@ -51,7 +58,6 @@ public function show(): void $duration = (int)($this->getSettings('splash_screen_duration') * 1000000); usleep($duration); - Console::setSize($this->getSettings('screen_width'), $this->getSettings('screen_height')); Console::clear(); Debug::info("Splash screen hidden"); diff --git a/src/Core/Scenes/SceneManager.php b/src/Core/Scenes/SceneManager.php index 0e066b8..08d3efb 100644 --- a/src/Core/Scenes/SceneManager.php +++ b/src/Core/Scenes/SceneManager.php @@ -21,6 +21,7 @@ use Sendama\Engine\Exceptions\IncorrectComponentTypeException; use Sendama\Engine\Exceptions\Scenes\SceneManagementException; use Sendama\Engine\Exceptions\Scenes\SceneNotFoundException; +use Sendama\Engine\IO\Console\Console; use Sendama\Engine\Physics\Collider; use Sendama\Engine\Physics\Interfaces\ColliderInterface; use Sendama\Engine\Physics\Physics; @@ -185,7 +186,24 @@ public function loadScene(int|string $index): self $this->stop(); $this->unload(); - $this->activeSceneNode = new SceneNode($sceneToBeLoaded->loadSceneSettings($this->settings), $this->activeSceneNode); + + $sceneSettings = $this->settings; + $localSceneSettings = $sceneToBeLoaded->getSettings(null); + + if (is_array($localSceneSettings)) { + $sceneSettings = array_replace($sceneSettings, $localSceneSettings); + } + + $loadedScene = $sceneToBeLoaded->loadSceneSettings($sceneSettings); + $viewport = $loadedScene->getCamera()->getViewport(); + + Console::refreshLayout( + $viewport->getWidth(), + $viewport->getHeight(), + Console::getSize(force: true) + ); + + $this->activeSceneNode = new SceneNode($loadedScene, $this->activeSceneNode); $this->load(); $this->start(); @@ -363,6 +381,16 @@ public function awake(): void { $sceneMetadata = $this->sceneMetadata; + $sceneWidth = $sceneMetadata->screen_width ?? $sceneMetadata->screenWidth ?? $sceneMetadata->width ?? null; + if (is_numeric($sceneWidth)) { + $this->settings['screen_width'] = (int)$sceneWidth; + } + + $sceneHeight = $sceneMetadata->screen_height ?? $sceneMetadata->screenHeight ?? $sceneMetadata->height ?? null; + if (is_numeric($sceneHeight)) { + $this->settings['screen_height'] = (int)$sceneHeight; + } + if (isset($sceneMetadata->environmentTileMapPath)) { $this->environmentTileMapPath = $sceneMetadata->environmentTileMapPath; } diff --git a/src/Game.php b/src/Game.php index f73efb1..9830624 100644 --- a/src/Game.php +++ b/src/Game.php @@ -549,11 +549,7 @@ protected function configureWindowChangeSignalHandler(): void { pcntl_async_signals(true); pcntl_signal(SIGWINCH, function () { - Console::refreshLayout( - (int)$this->getSettings(SettingsKey::SCREEN_WIDTH->value), - (int)$this->getSettings(SettingsKey::SCREEN_HEIGHT->value), - Console::getSize(force: true) - ); + $this->refreshConsoleLayout(forceTerminalSize: true); Debug::info("SIGWINCH received"); }); } @@ -638,13 +634,9 @@ private function start(): void // Set the terminal name Console::setName($this->getSettings('game_name')); - // Set the terminal size - Console::setSize($this->getSettings('screen_width'), $this->getSettings('screen_height')); - Console::refreshLayout( - (int)$this->getSettings(SettingsKey::SCREEN_WIDTH->value), - (int)$this->getSettings(SettingsKey::SCREEN_HEIGHT->value), - Console::getSize(force: true) - ); + // Treat the terminal as the container and center the scene within it. + Console::maximizeWindow(); + $this->refreshConsoleLayout(forceTerminalSize: true); // Hide the cursor $this->consoleCursor->hide(); @@ -748,10 +740,7 @@ private function update(): void private function render(): void { $this->frameCount++; - Console::refreshLayout( - (int)$this->getSettings(SettingsKey::SCREEN_WIDTH->value), - (int)$this->getSettings(SettingsKey::SCREEN_HEIGHT->value) - ); + $this->refreshConsoleLayout(); $this->state->render(); $this->uiManager->render(); $this->renderDebugInfo(); @@ -768,11 +757,60 @@ private function renderDebugInfo(): void if ($this->isDebug() && $this->showDebugInfo()) { $content = ["FPS: $this->frameRate", "Delta: " . round(Time::getDeltaTime(), 2), "Time: " . Time::getPrettyTime(ChronoUnit::SECONDS)]; + $this->debugWindow->setPosition([0, max(0, $this->getLogicalScreenHeight() - self::DEBUG_WINDOW_HEIGHT)]); $this->debugWindow->setContent($content); $this->debugWindow->render(); } } + /** + * Refresh the console layout using the active scene viewport when available. + * + * @param bool $forceTerminalSize Whether to re-read the terminal size immediately. + * @return void + * @throws Exception + */ + private function refreshConsoleLayout(bool $forceTerminalSize = false): void + { + Console::refreshLayout( + $this->getLogicalScreenWidth(), + $this->getLogicalScreenHeight(), + $forceTerminalSize ? Console::getSize(force: true) : null + ); + } + + /** + * Returns the current logical render width. + * + * @return int + */ + private function getLogicalScreenWidth(): int + { + $activeScene = $this->sceneManager->getActiveScene(); + + if ($activeScene) { + return max(1, $activeScene->getCamera()->getViewport()->getWidth()); + } + + return max(1, (int)$this->getSettings(SettingsKey::SCREEN_WIDTH->value)); + } + + /** + * Returns the current logical render height. + * + * @return int + */ + private function getLogicalScreenHeight(): int + { + $activeScene = $this->sceneManager->getActiveScene(); + + if ($activeScene) { + return max(1, $activeScene->getCamera()->getViewport()->getHeight()); + } + + return max(1, (int)$this->getSettings(SettingsKey::SCREEN_HEIGHT->value)); + } + /** * Get the debug status. * diff --git a/src/IO/Console/Console.php b/src/IO/Console/Console.php index 112bd92..20a866f 100644 --- a/src/IO/Console/Console.php +++ b/src/IO/Console/Console.php @@ -21,6 +21,7 @@ class Console { private const float TERMINAL_SIZE_POLL_INTERVAL_SECONDS = 0.1; + private const int WINDOW_STATE_SETTLE_DELAY_MICROSECONDS = 50000; /** * @var Game|null $game The game instance. */ @@ -224,6 +225,22 @@ public static function setSize(int $width, int $height): void echo "\033[8;$height;{$width}t"; } + /** + * Requests the terminal window to maximize when the terminal emulator supports it. + * + * This is an xterm-compatible window operation; terminals that do not support it + * will safely ignore the request and keep their current size. + * + * @return void + */ + public static function maximizeWindow(): void + { + echo "\033[9;1t"; + flush(); + self::$lastSizeCheckAt = 0.0; + usleep(self::WINDOW_STATE_SETTLE_DELAY_MICROSECONDS); + } + /** * Returns the terminal size. * diff --git a/tests/Mocks/Scenes/scene_with_dimensions.scene.php b/tests/Mocks/Scenes/scene_with_dimensions.scene.php new file mode 100644 index 0000000..e7fa115 --- /dev/null +++ b/tests/Mocks/Scenes/scene_with_dimensions.scene.php @@ -0,0 +1,8 @@ + 'Scene With Dimensions', + 'width' => 80, + 'height' => 25, + 'hierarchy' => [], +]; diff --git a/tests/Unit/Core/Rendering/RendererTest.php b/tests/Unit/Core/Rendering/RendererTest.php index 68c339f..6101751 100644 --- a/tests/Unit/Core/Rendering/RendererTest.php +++ b/tests/Unit/Core/Rendering/RendererTest.php @@ -102,7 +102,8 @@ public function awake(): void $gameObject->erase(); $output = ob_get_clean(); - expect($output)->toContain("\033[1;1H#"); + $offset = Console::getRenderOffset(); + expect($output)->toContain("\033[{$offset->getY()};{$offset->getX()}H#"); }); function resetSingleton(string $className, string $property): void diff --git a/tests/Unit/Core/Scenes/SceneManagerTest.php b/tests/Unit/Core/Scenes/SceneManagerTest.php new file mode 100644 index 0000000..fb051ef --- /dev/null +++ b/tests/Unit/Core/Scenes/SceneManagerTest.php @@ -0,0 +1,54 @@ +sceneManager = SceneManager::getInstance(); + $this->sceneManager->loadSettings([ + 'screen_width' => 160, + 'screen_height' => 40, + ]); + + $this->scenePath = Path::join(dirname(__DIR__, 3), 'Mocks', 'Scenes', 'scene_with_dimensions'); +}); + +it('applies file scene dimensions to the active viewport and centered layout', function () { + ob_start(); + $this->sceneManager->loadSceneFromFile($this->scenePath); + $this->sceneManager->loadScene('Scene With Dimensions'); + ob_end_clean(); + + $scene = $this->sceneManager->getActiveScene(); + $terminalSize = Console::getSize(force: true); + $offset = Console::getRenderOffset(); + $expectedOffsetX = (int)floor(($terminalSize->getWidth() - 80) / 2) + 1; + $expectedOffsetY = (int)floor(($terminalSize->getHeight() - 25) / 2) + 1; + + expect($scene)->not()->toBeNull() + ->and($scene->getSettings('screen_width'))->toBe(80) + ->and($scene->getSettings('screen_height'))->toBe(25) + ->and($scene->getCamera()->getViewport()->getWidth())->toBe(80) + ->and($scene->getCamera()->getViewport()->getHeight())->toBe(25) + ->and($offset->getX())->toBe($expectedOffsetX) + ->and($offset->getY())->toBe($expectedOffsetY); +}); + +function resetSceneManagerStaticProperty(string $className, string $propertyName, mixed $value): void +{ + $reflection = new \ReflectionClass($className); + $property = $reflection->getProperty($propertyName); + $property->setValue(null, $value); +} diff --git a/tests/Unit/IO/Console/ConsoleLayoutTest.php b/tests/Unit/IO/Console/ConsoleLayoutTest.php index 34768ec..e2572ce 100644 --- a/tests/Unit/IO/Console/ConsoleLayoutTest.php +++ b/tests/Unit/IO/Console/ConsoleLayoutTest.php @@ -19,6 +19,14 @@ ->and($offset->getY())->toBe(19); }); +it('requests terminal maximization instead of resizing the character grid', function () { + ob_start(); + Console::maximizeWindow(); + $output = ob_get_clean(); + + expect($output)->toBe("\033[9;1t"); +}); + it('renders text from the centered viewport origin without duplicating glyphs', function () { Console::refreshLayout( 80,