From 0796a63e0c45839cb4a4dc56f0a4d5a2648af3cc Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Fri, 27 Feb 2026 20:18:07 +0200 Subject: [PATCH 01/13] feat(Physics): add PhysicsMaterial and PhysicsMaterialMetadata classes for defining physical properties --- src/Metadata/PhysicsMaterialMetadata.php | 42 ++++++++++++++++++++++++ src/Physics/PhysicsMaterial.php | 23 +++++++++++++ 2 files changed, 65 insertions(+) create mode 100644 src/Metadata/PhysicsMaterialMetadata.php create mode 100644 src/Physics/PhysicsMaterial.php diff --git a/src/Metadata/PhysicsMaterialMetadata.php b/src/Metadata/PhysicsMaterialMetadata.php new file mode 100644 index 0000000..f0c0775 --- /dev/null +++ b/src/Metadata/PhysicsMaterialMetadata.php @@ -0,0 +1,42 @@ +name = $data['name']; + $instance->friction = $data['friction'] ?? self::DEFAULT_FRICTION; + $instance->bounciness = $data['bounciness'] ?? self::DEFAULT_BOUNCINESS; + + return $instance; + } + + public function toArray(): array + { + return [ + 'name' => $this->name, + 'friction' => $this->friction, + 'bounciness' => $this->bounciness, + ]; + } +} \ No newline at end of file diff --git a/src/Physics/PhysicsMaterial.php b/src/Physics/PhysicsMaterial.php new file mode 100644 index 0000000..b352c2b --- /dev/null +++ b/src/Physics/PhysicsMaterial.php @@ -0,0 +1,23 @@ + Date: Fri, 27 Feb 2026 20:19:18 +0200 Subject: [PATCH 02/13] feat(examples): update example projects * pong * blasters * collector --- examples/blasters/assets/Scenes/Level01.php | 4 +- examples/collector/assets/Scenes/Level01.php | 6 +-- examples/collector/collector.php | 2 +- examples/pong/assets/Maps/level.tmap | 25 ++++++++++++ examples/pong/assets/Scenes/level.scene.php | 39 +++++++++++++++++++ .../pong/assets/Scripts/PaddleController.php | 18 +++++++++ 6 files changed, 88 insertions(+), 6 deletions(-) create mode 100644 examples/pong/assets/Maps/level.tmap create mode 100644 examples/pong/assets/Scenes/level.scene.php create mode 100644 examples/pong/assets/Scripts/PaddleController.php diff --git a/examples/blasters/assets/Scenes/Level01.php b/examples/blasters/assets/Scenes/Level01.php index 7b84066..1c1c181 100644 --- a/examples/blasters/assets/Scenes/Level01.php +++ b/examples/blasters/assets/Scenes/Level01.php @@ -6,7 +6,7 @@ use Sendama\Engine\Core\GameObject; use Sendama\Engine\Core\Scenes\AbstractScene; use Sendama\Engine\Core\Sprite; -use Sendama\Engine\Core\Texture2D; +use Sendama\Engine\Core\Texture; use Sendama\Engine\Core\Vector2; use Sendama\Examples\Blasters\Scripts\Game\LevelManager; use Sendama\Examples\Blasters\Scripts\Player\WeaponManager; @@ -37,7 +37,7 @@ public function awake(): void $playerStartingX = 4; $playerStartingY = $screenHeight / 2; - $playerTexture = new Texture2D('Textures/player.texture'); + $playerTexture = new Texture('Textures/player.texture'); $player->setSpriteFromTexture($playerTexture, new Vector2(0, 1), new Vector2(5, 3)); $player->getTransform()->setPosition(new Vector2($playerStartingX, $playerStartingY)); /** diff --git a/examples/collector/assets/Scenes/Level01.php b/examples/collector/assets/Scenes/Level01.php index 943c1f1..0b954c4 100644 --- a/examples/collector/assets/Scenes/Level01.php +++ b/examples/collector/assets/Scenes/Level01.php @@ -6,7 +6,7 @@ use Sendama\Engine\Core\GameObject; use Sendama\Engine\Core\Scenes\AbstractScene; use Sendama\Engine\Core\Sprite; -use Sendama\Engine\Core\Texture2D; +use Sendama\Engine\Core\Texture; use Sendama\Engine\Core\Vector2; use Sendama\Engine\Physics\CharacterController; use Sendama\Engine\Physics\Collider; @@ -56,7 +56,7 @@ public function awake(): void $playerStartingX = 4; $playerStartingY = $screenHeight / 2; - $playerTexture = new Texture2D('Textures/player.texture'); + $playerTexture = new Texture('Textures/player.texture'); $player->getTransform()->setPosition(new Vector2($playerStartingX, $playerStartingY)); /** * @var CharacterMovement $playerMovementController @@ -73,7 +73,7 @@ public function awake(): void $player->setSpriteFromTexture($playerTexture, Vector2::zero(), Vector2::one()); // Set up the apple - $appleTexture = new Texture2D('Textures/apple.texture'); + $appleTexture = new Texture('Textures/apple.texture'); $apple->addComponent(CollectableController::class); $apple->addComponent(Collider::class); $apple->setSpriteFromTexture($appleTexture, Vector2::zero(), Vector2::one()); diff --git a/examples/collector/collector.php b/examples/collector/collector.php index 3b89e4d..ad83e5c 100644 --- a/examples/collector/collector.php +++ b/examples/collector/collector.php @@ -18,7 +18,7 @@ function bootstrap(): void $settingsScene = new SettingsScene('Settings'); $titleScene = new TitleScene('Title Screen'); - $titleScene->setTitle($gameName); + $titleScene->setMenuTitle($gameName); $titleScene ->setTitleFont(FontName::BASIC) ->setNewGameSceneIndex(2) diff --git a/examples/pong/assets/Maps/level.tmap b/examples/pong/assets/Maps/level.tmap new file mode 100644 index 0000000..0e3d5ad --- /dev/null +++ b/examples/pong/assets/Maps/level.tmap @@ -0,0 +1,25 @@ +xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx +x x +x x +x x +x x +x x +x x +x x +x x +x x +x x +x x +x x +x x +x x +x x +x x +x x +x x +x x +x x +x x +x x +x x +xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx \ No newline at end of file diff --git a/examples/pong/assets/Scenes/level.scene.php b/examples/pong/assets/Scenes/level.scene.php new file mode 100644 index 0000000..d431136 --- /dev/null +++ b/examples/pong/assets/Scenes/level.scene.php @@ -0,0 +1,39 @@ + "Level", + "width" => 120, + "height" => 30, + "environmentTileMapPath" => "Maps/example", + "hierarchy" => [ + [ + "name" => "Game Manager", + "tag" => "GameManager", + "components" => [ + [ "class" => SimpleQuitListener::class ] + ] + ], + [ + "name" => "PaddleLeft", + "tag" => "Player", + "position" => ["x" => 3, "y" => 15], + "rotation" => ["x" => 0, "y" => 0], + "scale" => ["x" => 1, "y" => 1], + "sprite" => [ + "texture" => [ + "path" => "Textures/paddle", + "position" => ["x" => 0, "y" => 0], + "size" => ["x" => 1, "y" => 5] + ] + ], + "components" => [ + [ + "class" => PaddleController::class + ] + ] + ] + ] +]; diff --git a/examples/pong/assets/Scripts/PaddleController.php b/examples/pong/assets/Scripts/PaddleController.php new file mode 100644 index 0000000..f7e7080 --- /dev/null +++ b/examples/pong/assets/Scripts/PaddleController.php @@ -0,0 +1,18 @@ + Date: Fri, 27 Feb 2026 20:19:41 +0200 Subject: [PATCH 03/13] feat(GameObject): add serialization and unserialization methods for game objects --- src/Core/GameObject.php | 39 +++++++++++++++++++++++++++++ src/Core/Rendering/SplashScreen.php | 2 +- src/Core/Scenes/SceneManager.php | 10 +++++++- 3 files changed, 49 insertions(+), 2 deletions(-) diff --git a/src/Core/GameObject.php b/src/Core/GameObject.php index 0588b09..a13c542 100644 --- a/src/Core/GameObject.php +++ b/src/Core/GameObject.php @@ -64,6 +64,45 @@ 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. * diff --git a/src/Core/Rendering/SplashScreen.php b/src/Core/Rendering/SplashScreen.php index 7db798a..b54424c 100644 --- a/src/Core/Rendering/SplashScreen.php +++ b/src/Core/Rendering/SplashScreen.php @@ -13,7 +13,7 @@ final class SplashScreen { public function __construct( private readonly Cursor $consoleCursor, - private readonly array $settings + private array $settings ) { } diff --git a/src/Core/Scenes/SceneManager.php b/src/Core/Scenes/SceneManager.php index ea48bc0..0eceb69 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\Physics\Collider; use Sendama\Engine\Physics\Interfaces\ColliderInterface; use Sendama\Engine\Physics\Physics; use Sendama\Engine\UI\Label\Label; @@ -350,6 +351,10 @@ public function loadSceneFromFile(string $path): void $sceneName = $sceneMetadata->name ?? basename($path); $scene = new class($sceneName, $sceneMetadata) extends AbstractScene { + /** + * @return void + * @throws SceneManagementException + */ public function awake(): void { $sceneMetadata = $this->sceneMetadata; @@ -435,7 +440,10 @@ public function awake(): void continue; } - $component->$key = $value; + $component->$key = match(true) { + $componentClass === Collider::class && $key === 'material' => null, + default => $value + }; } } } From 27229b61b0893c2ceda6d930f26979683b384d08 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Fri, 27 Feb 2026 20:20:11 +0200 Subject: [PATCH 04/13] refactor(EventManager): improve code formatting and consistency --- src/Events/EventManager.php | 133 ++++++++++++++++-------------------- 1 file changed, 60 insertions(+), 73 deletions(-) diff --git a/src/Events/EventManager.php b/src/Events/EventManager.php index 4b3d5c7..f240f12 100644 --- a/src/Events/EventManager.php +++ b/src/Events/EventManager.php @@ -13,92 +13,79 @@ */ class EventManager implements SingletonInterface, EventTargetInterface { - protected static ?EventManager $instance = null; + protected static ?EventManager $instance = null; - /** - * @var array $listeners - */ - protected array $listeners = []; + /** + * @var array $listeners + */ + protected array $listeners = []; - /** - * Returns the instance of the event manager. - * - * @return EventManager The instance of the event manager. - */ - public static function getInstance(): self - { - if (!self::$instance) + /** + * Constructs an event manager. + */ + private function __construct() { - self::$instance = new self(); + // This is a singleton class. } - return self::$instance; - } + /** + * Returns the instance of the event manager. + * + * @return EventManager The instance of the event manager. + */ + public static function getInstance(): self + { + if (!self::$instance) { + self::$instance = new self(); + } - /** - * Constructs an event manager. - */ - private function __construct() - { - // This is a singleton class. - } + return self::$instance; + } - /** - * @inheritDoc - */ - public function addEventListener(EventType $type, callable $listener, bool $useCapture = false): void - { - $this->listeners[$type->value][] = $listener; - } + /** + * @inheritDoc + */ + public function addEventListener(EventType $type, callable $listener, bool $useCapture = false): void + { + $this->listeners[$type->value][] = $listener; + } - /** - * @inheritDoc - */ - public function removeEventListener(EventType $type, callable $listener, bool $useCapture = false): void - { - if (isset($this->listeners[$type->value])) + /** + * @inheritDoc + */ + public function removeEventListener(EventType $type, callable $listener, bool $useCapture = false): void { - foreach ($this->listeners[$type->value] as $index => $entry) - { - if ($entry instanceof EventListenerInterface) - { - if ($listener->equals($entry)) - { - unset($this->listeners[$type->value][$index]); - } + if (isset($this->listeners[$type->value])) { + foreach ($this->listeners[$type->value] as $index => $entry) { + if ($entry instanceof EventListenerInterface) { + if ($listener->equals($entry)) { + unset($this->listeners[$type->value][$index]); + } + } else { + if ($listener === $entry) { + unset($this->listeners[$type->value][$index]); + } + } + } } - else - { - if ($listener === $entry) - { - unset($this->listeners[$type->value][$index]); - } - } - } } - } - /** - * @inheritDoc - */ - public function dispatchEvent(EventInterface $event): bool - { - if (isset($this->listeners[$event->getType()->value])) + /** + * @inheritDoc + */ + public function dispatchEvent(EventInterface $event): bool { - foreach ($this->listeners[$event->getType()->value] as $listener) - { - if ($listener instanceof EventListenerInterface) - { - $listener->handle($event); - } - // TODO: Check if this is correct, should be callable. - else - { - $listener($event); + if (isset($this->listeners[$event->getType()->value])) { + foreach ($this->listeners[$event->getType()->value] as $listener) { + if ($listener instanceof EventListenerInterface) { + $listener->handle($event); + } // TODO: Check if this is correct, should be callable. + else { + $listener($event); + } + } } - } - } - return true; - } + return true; + } } \ No newline at end of file From 41c285d3a7807b9633566f1fb3294f1773b52f79 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Fri, 27 Feb 2026 20:20:17 +0200 Subject: [PATCH 05/13] refactor(InputManager): simplify key press checks using array functions --- src/IO/InputManager.php | 23 +++-------------------- 1 file changed, 3 insertions(+), 20 deletions(-) diff --git a/src/IO/InputManager.php b/src/IO/InputManager.php index e18bbb4..63ab5a4 100644 --- a/src/IO/InputManager.php +++ b/src/IO/InputManager.php @@ -197,13 +197,7 @@ public static function getAxis(AxisName|string $axisName): float */ public static function isAnyKeyPressed(array $keyCodes, bool $ignoreCase = true): bool { - foreach ($keyCodes as $keyCode) { - if (self::isKeyDown($keyCode, $ignoreCase)) { - return true; - } - } - - return false; + return array_any($keyCodes, fn($keyCode) => self::isKeyDown($keyCode, $ignoreCase)); } /** @@ -235,12 +229,7 @@ public static function isKeyDown(KeyCode $keyCode, bool $ignoreCase = true): boo */ public static function areAllKeysPressed(array $keyCodes): bool { - foreach ($keyCodes as $keyCode) { - if (!self::isKeyPressed($keyCode)) { - return false; - } - } - return true; + return array_all($keyCodes, fn($keyCode) => self::isKeyPressed($keyCode)); } /** @@ -262,13 +251,7 @@ public static function isKeyPressed(KeyCode $keyCode): bool */ public static function isAnyKeyReleased(array $keyCodes): bool { - foreach ($keyCodes as $keyCode) { - if (self::isKeyUp($keyCode)) { - return true; - } - } - - return false; + return array_any($keyCodes, fn($keyCode) => self::isKeyUp($keyCode)); } /** From 508ef4cc0b631f9747443eb0893617d7ca10d4a2 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Fri, 27 Feb 2026 20:20:24 +0200 Subject: [PATCH 06/13] refactor(UIElement): improve code formatting and consistency --- src/UI/UIElement.php | 314 +++++++++++++++++++++---------------------- 1 file changed, 154 insertions(+), 160 deletions(-) diff --git a/src/UI/UIElement.php b/src/UI/UIElement.php index 0ee0924..f7b7c06 100644 --- a/src/UI/UIElement.php +++ b/src/UI/UIElement.php @@ -14,172 +14,166 @@ */ abstract class UIElement implements UIElementInterface { - /** - * Whether the UI element is active. - * - * @var bool - */ - protected bool $active = true; - - /** - * Constructs a UI element. - * - * @param SceneInterface $scene The scene. - * @param string $name The name of the UI element. - * @param Vector2 $position The position of the UI element. - * @param Vector2 $size The size of the UI element. - */ - public function __construct( - protected SceneInterface $scene, - protected string $name, - protected Vector2 $position = new Vector2(0, 0), - protected Vector2 $size = new Vector2(0, 0), - ) - { - $this->awake(); - } - - /** - * @inheritDoc - */ - public function awake(): void - { - // Do nothing. - } - - /** - * @inheritDoc - */ - public function activate(): void - { - $this->active = true; - } - - /** - * @inheritDoc - */ - public function deactivate(): void - { - $this->active = false; - } - - /** - * @inheritDoc - */ - public function isActive(): bool - { - return $this->active; - } - - /** - * @inheritDoc - */ - public function resume(): void - { - $this->render(); - } - - /** - * @inheritDoc - */ - public function suspend(): void - { - $this->erase(); - } - - /** - * @inheritDoc - */ - public function stop(): void - { - $this->erase(); - } - - /** - * @inheritDoc - */ - public function getName(): string - { - return $this->name; - } - - /** - * @inheritDoc - */ - public function setName(string $name): void - { - $this->name = $name; - } - - /** - * @inheritDoc - */ - public function getPosition(): Vector2 - { - return $this->position; - } - - /** - * @inheritDoc - */ - public function setPosition(Vector2 $position): void - { - $this->position = $position; - } - - /** - * @inheritDoc - */ - public function getSize(): Vector2 - { - return $this->size; - } - - /** - * @inheritDoc - */ - public function setSize(Vector2 $size): void - { - $this->size = $size; - } - - /** - * @inheritDoc - */ - public static function find(string $uiElementName): ?UIElementInterface - { - if ($activeScene = SceneManager::getInstance()->getActiveScene()) - { - foreach ($activeScene->getUIElements() as $element) - { - if ($element->getName() === $uiElementName) - { - return $element; - } - } + /** + * Whether the UI element is active. + * + * @var bool + */ + protected bool $active = true; + + /** + * Constructs a UI element. + * + * @param SceneInterface $scene The scene. + * @param string $name The name of the UI element. + * @param Vector2 $position The position of the UI element. + * @param Vector2 $size The size of the UI element. + */ + public function __construct( + protected SceneInterface $scene, + protected string $name, + protected Vector2 $position = new Vector2(0, 0), + protected Vector2 $size = new Vector2(0, 0), + ) + { + $this->awake(); } - return null; - } + /** + * @inheritDoc + */ + public function awake(): void + { + // Do nothing. + } - /** - * @inheritDoc - */ - public static function findAll(string $uiElementName): array - { - $elements = []; + /** + * @inheritDoc + */ + public static function find(string $uiElementName): ?UIElementInterface + { + if ($activeScene = SceneManager::getInstance()->getActiveScene()) { + foreach ($activeScene->getUIElements() as $element) { + if ($element->getName() === $uiElementName) { + return $element; + } + } + } + + return null; + } + + /** + * @inheritDoc + */ + public function getName(): string + { + return $this->name; + } - if ($activeScene = SceneManager::getInstance()->getActiveScene()) + /** + * @inheritDoc + */ + public static function findAll(string $uiElementName): array { - foreach ($activeScene->getUIElements() as $element) - { - if ($element->getName() === $uiElementName) - { - $elements[] = $element; + $elements = []; + + if ($activeScene = SceneManager::getInstance()->getActiveScene()) { + foreach ($activeScene->getUIElements() as $element) { + if ($element->getName() === $uiElementName) { + $elements[] = $element; + } + } } - } + + return $elements; + } + + /** + * @inheritDoc + */ + public function activate(): void + { + $this->active = true; + } + + /** + * @inheritDoc + */ + public function deactivate(): void + { + $this->active = false; + } + + /** + * @inheritDoc + */ + public function isActive(): bool + { + return $this->active; + } + + /** + * @inheritDoc + */ + public function resume(): void + { + $this->render(); + } + + /** + * @inheritDoc + */ + public function suspend(): void + { + $this->erase(); + } + + /** + * @inheritDoc + */ + public function stop(): void + { + $this->erase(); + } + + /** + * @inheritDoc + */ + public function setName(string $name): void + { + $this->name = $name; } - return $elements; - } + /** + * @inheritDoc + */ + public function getPosition(): Vector2 + { + return $this->position; + } + + /** + * @inheritDoc + */ + public function setPosition(Vector2 $position): void + { + $this->position = $position; + } + + /** + * @inheritDoc + */ + public function getSize(): Vector2 + { + return $this->size; + } + + /** + * @inheritDoc + */ + public function setSize(Vector2 $size): void + { + $this->size = $size; + } } \ No newline at end of file From 7e774c6bd6e457515ae50e0b6a874977adedaaf0 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Fri, 27 Feb 2026 20:20:38 +0200 Subject: [PATCH 07/13] refactor(Game): enhance debug setting checks for improved flexibility --- src/Game.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Game.php b/src/Game.php index d57ebb5..53e4c59 100644 --- a/src/Game.php +++ b/src/Game.php @@ -713,7 +713,7 @@ private function isDebug(): bool { return match (gettype($this->getSettings('debug'))) { 'boolean' => $this->getSettings('debug'), - 'string' => strtolower($this->getSettings('debug')) === 'true', + 'string' => in_array(strtolower($this->getSettings('debug')), ['true', '1', 'yes'], true), 'integer' => $this->getSettings('debug') === 1, default => false }; From 311ad5ab090fa0977ea0d7caa3e0072c2043b6a1 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Fri, 27 Feb 2026 20:20:45 +0200 Subject: [PATCH 08/13] chore(composer): update PHP version requirement to ^8.4 --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 2e19187..48f90fb 100644 --- a/composer.json +++ b/composer.json @@ -25,7 +25,7 @@ } ], "require": { - "php": "^8.3", + "php": "^8.4", "assegaiphp/collections": "^0.3.2", "amasiye/figlet": "^1.2", "vlucas/phpdotenv": "^5.6", From 9e52838e0b5c1564f878a07e40c1b49abec7e8a0 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Wed, 11 Mar 2026 01:30:54 +0200 Subject: [PATCH 09/13] fix(collision): improve collision detection logic and update strategy references --- .../Collectable/CollectableController.php | 13 ++- src/Core/Rect.php | 25 +++++- src/Debug/Debug.php | 52 ++++++----- src/Physics/CharacterController.php | 26 +++--- src/Physics/Collider.php | 6 +- src/Physics/Collision.php | 7 +- src/Physics/Physics.php | 42 +++++++-- src/Physics/Rigidbody.php | 6 +- .../AABBCollisionDetectionStrategy.php | 18 +--- .../BasicCollisionDetectionStrategy.php | 4 +- ...arationBasedCollisionDetectionStrategy.php | 6 +- .../SimpleCollisionDetectionStrategy.php | 14 +-- .../Unit/Physics/CharacterControllerTest.php | 88 +++++++++++++++++++ 13 files changed, 227 insertions(+), 80 deletions(-) create mode 100644 tests/Unit/Physics/CharacterControllerTest.php diff --git a/examples/collector/assets/Scripts/Collectable/CollectableController.php b/examples/collector/assets/Scripts/Collectable/CollectableController.php index ee89f36..c655d93 100644 --- a/examples/collector/assets/Scripts/Collectable/CollectableController.php +++ b/examples/collector/assets/Scripts/Collectable/CollectableController.php @@ -32,7 +32,16 @@ class CollectableController extends Behaviour #[Override] public function onStart(): void { - $this->getGameObject()->addComponent(Collider::class); + $collider = $this->getComponent(Collider::class); + + if (!$collider) { + $collider = $this->getGameObject()->addComponent(Collider::class); + } + + if ($collider instanceof Collider) { + $collider->setTrigger(true); + } + $this->randomizePosition(); if ($levelManagerGO = GameObject::find(Name::LEVEL_MANAGER->value)) @@ -70,4 +79,4 @@ protected function randomizePosition(): void ) ); } -} \ No newline at end of file +} diff --git a/src/Core/Rect.php b/src/Core/Rect.php index f62977c..b4a4cc2 100644 --- a/src/Core/Rect.php +++ b/src/Core/Rect.php @@ -92,7 +92,28 @@ public function set(?Vector2 $position = null, ?Vector2 $size = null): void */ public function overlaps(Rect $other): bool { - return $this->contains(new Vector2($other->getX(), $other->getY())) || $this->contains(new Vector2($other->getX() + $other->getWidth(), $other->getY())) || $this->contains(new Vector2($other->getX(), $other->getY() + $other->getHeight())) || $this->contains(new Vector2($other->getX() + $other->getWidth(), $other->getY() + $other->getHeight())); + return + $this->getX() < $other->getX() + $other->getWidth() && + $this->getX() + $this->getWidth() > $other->getX() && + $this->getY() < $other->getY() + $other->getHeight() && + $this->getY() + $this->getHeight() > $other->getY(); + } + + /** + * Returns a translated copy of the rect. + * + * @param Vector2 $translation The translation to apply. + * @return self + */ + public function translated(Vector2 $translation): self + { + return new self( + new Vector2( + $this->getX() + $translation->getX(), + $this->getY() + $translation->getY() + ), + $this->getSize(), + ); } /** @@ -105,4 +126,4 @@ public function contains(Vector2 $point): bool { return $point->getX() >= $this->getX() && $point->getX() <= $this->getX() + $this->getWidth() && $point->getY() >= $this->getY() && $point->getY() <= $this->getY() + $this->getHeight(); } -} \ No newline at end of file +} diff --git a/src/Debug/Debug.php b/src/Debug/Debug.php index 112f27c..314162e 100644 --- a/src/Debug/Debug.php +++ b/src/Debug/Debug.php @@ -84,17 +84,7 @@ public static function log( return; } - - if (!file_exists($filename)) { - if (!is_writeable(self::getLogDirectory())) { - throw new DebuggingException("The directory, " . self::getLogDirectory() . ", is not writable."); - } - - if (false === $file = fopen($filename, 'w')) { - throw new DebuggingException("Failed to create the debug log file."); - } - fclose($file); - } + self::ensureLogFile($filename, 'debug'); $message = sprintf("[%s] %s - %s", date('Y-m-d H:i:s'), $prefix, $message) . PHP_EOL; if (false === error_log($message, 3, $filename)) { @@ -117,17 +107,7 @@ public static function error(string $message, string $prefix = '[ERROR]'): void $filename = Path::join(self::getLogDirectory(), 'error.log'); - if (!file_exists($filename)) { - if (!is_writeable(self::getLogDirectory())) { - throw new DebuggingException("The directory, " . self::getLogDirectory() . ", is not writable."); - } - - if (false === $file = fopen($filename, 'w')) { - throw new DebuggingException("Failed to create the error log file."); - } - - fclose($file); - } + self::ensureLogFile($filename, 'error'); $message = sprintf("[%s] %s - %s", date('Y-m-d H:i:s'), $prefix, $message) . PHP_EOL; if (false === error_log($message, 3, $filename)) { @@ -156,4 +136,32 @@ public static function info(string $message, ?string $prefix = null): void { self::log($message, $prefix ?? '[INFO]', LogLevel::INFO); } + + /** + * Ensures the log directory and file exist before writing. + * + * @param string $filename The file to create if needed. + * @param string $type The type of log being created. + * @return void + */ + private static function ensureLogFile(string $filename, string $type): void + { + $directory = self::getLogDirectory(); + + if (!file_exists($directory) && !mkdir($directory, 0777, true) && !is_dir($directory)) { + throw new DebuggingException("Failed to create the log directory at $directory."); + } + + if (!is_writeable($directory)) { + throw new DebuggingException("The directory, $directory, is not writable."); + } + + if (!file_exists($filename)) { + if (false === $file = fopen($filename, 'w')) { + throw new DebuggingException("Failed to create the $type log file."); + } + + fclose($file); + } + } } diff --git a/src/Physics/CharacterController.php b/src/Physics/CharacterController.php index f539f95..0b2cf9b 100644 --- a/src/Physics/CharacterController.php +++ b/src/Physics/CharacterController.php @@ -10,7 +10,7 @@ use Sendama\Engine\Events\Interfaces\ObserverInterface; use Sendama\Engine\Events\Interfaces\StaticObserverInterface; use Sendama\Engine\Physics\Interfaces\CollisionInterface; -use Sendama\Engine\Physics\Strategies\SimpleCollisionDetectionStrategy; +use Sendama\Engine\Physics\Strategies\AABBCollisionDetectionStrategy; /** * The class CharacterController. It allows you to do movement constrained by collisions without having to deal with a @@ -43,7 +43,7 @@ class CharacterController extends Collider implements ObservableInterface public function onStart(): void { - $this->collisionDetectionStrategy = new SimpleCollisionDetectionStrategy($this); + $this->collisionDetectionStrategy = new AABBCollisionDetectionStrategy($this); /** @var ItemList $observers */ $observers = new ItemList(ObserverInterface::class); @@ -62,18 +62,22 @@ public function onStart(): void */ public function move(Vector2 $motion): void { - $collisions = $this->physics?->checkCollisions($this, $motion); - $canMove = true; + $collisions = $this->physics?->checkCollisions($this, $motion) ?? []; + $blockingCollisionCount = 0; - // If there are collisions, resolve them. - foreach ($collisions ?? [] as $collision) { - if ($collision->getContact(0)?->getSeparation()) { - $this->resolveCollision($collision); + foreach ($collisions as $collision) { + $this->resolveCollision($collision); + + if (!($collision->getContact(0)?->getOtherCollider()?->isTrigger() ?? false)) { + $blockingCollisionCount++; } } - // If there are no collisions, move the character. - $this->getTransform()->translate($motion); + $this->previousCollisions = $collisions; + + if ($blockingCollisionCount === 0) { + $this->getTransform()->translate($motion); + } } /** @@ -165,4 +169,4 @@ public function notify(EventInterface $event): void $staticObserver::onNotify($this, $event); } } -} \ No newline at end of file +} diff --git a/src/Physics/Collider.php b/src/Physics/Collider.php index 5996e86..f854120 100644 --- a/src/Physics/Collider.php +++ b/src/Physics/Collider.php @@ -6,7 +6,7 @@ use Sendama\Engine\Core\Component; use Sendama\Engine\Physics\Interfaces\ColliderInterface; use Sendama\Engine\Physics\Interfaces\CollisionDetectionStrategyInterface; -use Sendama\Engine\Physics\Strategies\SimpleCollisionDetectionStrategy; +use Sendama\Engine\Physics\Strategies\AABBCollisionDetectionStrategy; use Sendama\Engine\Physics\Traits\BoundTrait; /** @@ -48,7 +48,7 @@ class Collider extends Component implements ColliderInterface public final function awake(): void { $this->physics = Physics::getInstance(); - $this->collisionDetectionStrategy = new SimpleCollisionDetectionStrategy($this); + $this->collisionDetectionStrategy = new AABBCollisionDetectionStrategy($this); } /** @@ -100,4 +100,4 @@ public function simulate(): void $this->getGameObject()->fixedUpdate(); } } -} \ No newline at end of file +} diff --git a/src/Physics/Collision.php b/src/Physics/Collision.php index 8b0744f..4030304 100644 --- a/src/Physics/Collision.php +++ b/src/Physics/Collision.php @@ -4,6 +4,7 @@ use Sendama\Engine\Core\GameObject; use Sendama\Engine\Core\Transform; +use Sendama\Engine\Physics\Interfaces\ColliderInterface; use Sendama\Engine\Physics\Interfaces\CollisionInterface; /** @@ -17,11 +18,11 @@ class Collision implements CollisionInterface /** * Collision constructor. * - * @param Collider $collider The collider that collided. + * @param ColliderInterface $collider The collider that collided. * @param ContactPoint[] $contacts The contacts of the collision. */ public function __construct( - protected Collider $collider, + protected ColliderInterface $collider, protected array $contacts ) { @@ -58,4 +59,4 @@ public function getContacts(): array { return $this->contacts; } -} \ No newline at end of file +} diff --git a/src/Physics/Physics.php b/src/Physics/Physics.php index 5148332..6a3c626 100644 --- a/src/Physics/Physics.php +++ b/src/Physics/Physics.php @@ -5,6 +5,7 @@ use Assegai\Collections\ItemList; use Sendama\Engine\Core\Grid; use Sendama\Engine\Core\Interfaces\SingletonInterface; +use Sendama\Engine\Core\Rect; use Sendama\Engine\Core\Vector2; use Sendama\Engine\Debug\Debug; use Sendama\Engine\Physics\Interfaces\ColliderInterface; @@ -85,7 +86,7 @@ public function init(): void protected function clearWorld(): void { $this->world = new Grid($this->worldWidth, $this->worldHeight); - $this->staticCollisionMap = new Grid($this->worldWidth, $this->worldWidth); + $this->staticCollisionMap = new Grid($this->worldWidth, $this->worldHeight); } /** @@ -184,10 +185,21 @@ public function removeCollider(ColliderInterface $collider): void public function checkCollisions(ColliderInterface $collider, Vector2 $motion): array { $collisions = []; + $projectedBounds = $this->getProjectedBounds($collider, $motion); foreach ($this->colliders as $otherCollider) { - if ($collider->isTouching($otherCollider)) { - $collisions[] = new Collision($otherCollider, [new ContactPoint(Vector2::sum($collider->getTransform()->getPosition(), $motion), $collider, $otherCollider)]); + if ($otherCollider === $collider || $otherCollider->getGameObject() === $collider->getGameObject()) { + continue; + } + + if ($projectedBounds->overlaps($otherCollider->getBoundingBox())) { + $collisions[] = new Collision($otherCollider, [ + new ContactPoint( + Vector2::sum($collider->getTransform()->getPosition(), $motion), + $collider, + $otherCollider + ) + ]); } } @@ -224,14 +236,32 @@ public function isTouchingStaticObject(Vector2 $position): bool * @param Vector2 $position The position to check. * @return bool Whether the given position is touching a dynamic object or not. */ - public function isTouchingDynamicObject(Vector2 $position): bool + public function isTouchingDynamicObject(Vector2 $position, ?ColliderInterface $ignoreCollider = null): bool { foreach ($this->colliders as $collider) { - if ($collider->getTransform()->getPosition() === $position) { + if ($ignoreCollider && $collider === $ignoreCollider) { + continue; + } + + $otherPosition = $collider->getTransform()->getPosition(); + + if ($otherPosition->getX() === $position->getX() && $otherPosition->getY() === $position->getY()) { return true; } } return false; } -} \ No newline at end of file + + /** + * Returns the collider bounds after applying motion. + * + * @param ColliderInterface $collider The collider to project. + * @param Vector2 $motion The movement to apply. + * @return Rect + */ + private function getProjectedBounds(ColliderInterface $collider, Vector2 $motion): Rect + { + return $collider->getBoundingBox()->translated($motion); + } +} diff --git a/src/Physics/Rigidbody.php b/src/Physics/Rigidbody.php index 0635dd1..655c6f2 100644 --- a/src/Physics/Rigidbody.php +++ b/src/Physics/Rigidbody.php @@ -5,7 +5,7 @@ use Sendama\Engine\Core\Component; use Sendama\Engine\Physics\Interfaces\ColliderInterface; use Sendama\Engine\Physics\Interfaces\CollisionDetectionStrategyInterface; -use Sendama\Engine\Physics\Strategies\SimpleCollisionDetectionStrategy; +use Sendama\Engine\Physics\Strategies\AABBCollisionDetectionStrategy; use Sendama\Engine\Physics\Traits\BoundTrait; /** @@ -30,7 +30,7 @@ class Rigidbody extends Component implements ColliderInterface */ public function onStart(): void { - $this->collisionDetectionStrategy = new SimpleCollisionDetectionStrategy($this); + $this->collisionDetectionStrategy = new AABBCollisionDetectionStrategy($this); } /** @@ -86,4 +86,4 @@ public function simulate(): void { // TODO: Implement simulate() method. } -} \ No newline at end of file +} diff --git a/src/Physics/Strategies/AABBCollisionDetectionStrategy.php b/src/Physics/Strategies/AABBCollisionDetectionStrategy.php index 017262c..03e4489 100644 --- a/src/Physics/Strategies/AABBCollisionDetectionStrategy.php +++ b/src/Physics/Strategies/AABBCollisionDetectionStrategy.php @@ -21,31 +21,19 @@ class AABBCollisionDetectionStrategy extends AbstractCollisionDetectionStrategy */ public function isTouching(ColliderInterface $collider): bool { - if ($this->collider->getGameObject()->getName() === $collider->getGameObject()->getName()) + if ($this->collider === $collider || $this->collider->getGameObject() === $collider->getGameObject()) { return false; } - $box1 = $this->collider->getBoundingBox(); - $box2 = $collider->getBoundingBox(); - - if ( - $box1->getX() < $box2->getX() + $box2->getWidth() && - $box1->getX() + $box1->getWidth() > $box2->getX() && - $box1->getY() < $box2->getY() + $box2->getHeight() && - $box1->getY() + $box1->getHeight() > $box2->getY() - ) + if ($this->collider->getBoundingBox()->overlaps($collider->getBoundingBox())) { Debug::log(__CLASS__ . ' detected a collision between ' . $this->collider->getGameObject()->getName() . ' and ' . $collider->getGameObject()->getName() . '.'); Debug::log($this->collider->getGameObject()->getName() . ' is at ' . $this->collider->getTransform()->getPosition()); Debug::log($collider->getGameObject()->getName() . ' is at ' . $collider->getTransform()->getPosition()); - Debug::log(sprintf("box1.x < box2.x + box2.width: %s < %s + %s", $box1->getX(), $box2->getX(), $box2->getWidth())); - Debug::log(sprintf("box1.x + box1.width > box2.x: %s + %s > %s", $box1->getX(), $box1->getWidth(), $box2->getX())); - Debug::log(sprintf("box1.y < box2.y + box2.height: %s < %s + %s", $box1->getY(), $box2->getY(), $box2->getHeight())); - Debug::log(sprintf("box1.y + box1.height > box2.y: %s + %s > %s", $box1->getY(), $box1->getHeight(), $box2->getY())); return true; } return false; } -} \ No newline at end of file +} diff --git a/src/Physics/Strategies/BasicCollisionDetectionStrategy.php b/src/Physics/Strategies/BasicCollisionDetectionStrategy.php index 0d4eb4a..d5b0b57 100644 --- a/src/Physics/Strategies/BasicCollisionDetectionStrategy.php +++ b/src/Physics/Strategies/BasicCollisionDetectionStrategy.php @@ -22,7 +22,7 @@ class BasicCollisionDetectionStrategy extends AbstractCollisionDetectionStrategy */ public function isTouching(ColliderInterface $collider): bool { - if ($this->collider->getGameObject()->getName() === $collider->getGameObject()->getName()) { + if ($this->collider === $collider || $this->collider->getGameObject() === $collider->getGameObject()) { return false; } @@ -36,4 +36,4 @@ public function isTouching(ColliderInterface $collider): bool return true; } -} \ No newline at end of file +} diff --git a/src/Physics/Strategies/SeparationBasedCollisionDetectionStrategy.php b/src/Physics/Strategies/SeparationBasedCollisionDetectionStrategy.php index b304724..2d7ba90 100644 --- a/src/Physics/Strategies/SeparationBasedCollisionDetectionStrategy.php +++ b/src/Physics/Strategies/SeparationBasedCollisionDetectionStrategy.php @@ -22,9 +22,13 @@ class SeparationBasedCollisionDetectionStrategy extends AbstractCollisionDetecti */ public function isTouching(ColliderInterface $collider): bool { + if ($this->collider === $collider || $this->collider->getGameObject() === $collider->getGameObject()) { + return false; + } + return Vector2::distance( $this->collider->getTransform()->getPosition(), $collider->getTransform()->getPosition() ) < 1; } -} \ No newline at end of file +} diff --git a/src/Physics/Strategies/SimpleCollisionDetectionStrategy.php b/src/Physics/Strategies/SimpleCollisionDetectionStrategy.php index 89ac550..2c64a6c 100644 --- a/src/Physics/Strategies/SimpleCollisionDetectionStrategy.php +++ b/src/Physics/Strategies/SimpleCollisionDetectionStrategy.php @@ -17,16 +17,10 @@ class SimpleCollisionDetectionStrategy extends AbstractCollisionDetectionStrateg */ public function isTouching(ColliderInterface $collider): bool { - $position = $collider->getTransform()->getPosition(); - - if ($this->physics->isTouchingStaticObject($position)) { - return true; - } - - if ($this->physics->isTouchingDynamicObject($position)) { - return true; + if ($this->collider === $collider || $this->collider->getGameObject() === $collider->getGameObject()) { + return false; } - return false; + return $this->collider->getBoundingBox()->overlaps($collider->getBoundingBox()); } -} \ No newline at end of file +} diff --git a/tests/Unit/Physics/CharacterControllerTest.php b/tests/Unit/Physics/CharacterControllerTest.php new file mode 100644 index 0000000..300ddfb --- /dev/null +++ b/tests/Unit/Physics/CharacterControllerTest.php @@ -0,0 +1,88 @@ +init(); + + $testsDirectory = dirname(__DIR__, 2); + $texturePath = Path::join($testsDirectory, 'Mocks', 'Textures', 'test.texture'); + + $this->makeSprite = fn() => new Sprite( + new Texture($texturePath), + ['x' => 0, 'y' => 0, 'width' => 1, 'height' => 1] + ); + + $this->makeCollider = function ( + string $name, + Vector2 $position, + string $componentClass = Collider::class, + bool $isTrigger = false, + ): array { + $gameObject = new GameObject($name); + $gameObject->setSprite(($this->makeSprite)()); + $gameObject->getTransform()->setPosition($position); + + $collider = $gameObject->addComponent($componentClass); + assert($collider instanceof ColliderInterface); + + $collider->setTrigger($isTrigger); + Physics::getInstance()->addCollider($collider); + + return [$gameObject, $collider]; + }; +}); + +it('checks projected bounds against only the colliders that overlap the motion path', function () { + [, $controller] = ($this->makeCollider)('Player', new Vector2(0, 0), CharacterController::class); + [, $blocker] = ($this->makeCollider)('Wall', new Vector2(1, 0)); + [, $distant] = ($this->makeCollider)('Crate', new Vector2(10, 0)); + + $collisions = Physics::getInstance()->checkCollisions($controller, new Vector2(1, 0)); + + expect($collisions) + ->toHaveCount(1) + ->and($collisions[0]->getGameObject()->getName())->toBe('Wall') + ->and($collisions[0]->getContact(0)?->getOtherCollider())->toBe($blocker) + ->and($collisions[0]->getGameObject()->getName())->not()->toBe($distant->getGameObject()->getName()); +}); + +it('stops a character controller before it moves into a solid collider', function () { + [$player, $controller] = ($this->makeCollider)('Player', new Vector2(0, 0), CharacterController::class); + ($this->makeCollider)('Wall', new Vector2(1, 0)); + + ob_start(); + $controller->move(new Vector2(1, 0)); + ob_end_clean(); + + expect($player->getTransform()->getPosition()->getX())->toBe(0) + ->and($player->getTransform()->getPosition()->getY())->toBe(0); +}); + +it('allows movement through trigger colliders', function () { + [$player, $controller] = ($this->makeCollider)('Player', new Vector2(0, 0), CharacterController::class); + ($this->makeCollider)('Coin', new Vector2(1, 0), Collider::class, true); + + ob_start(); + $controller->move(new Vector2(1, 0)); + ob_end_clean(); + + expect($player->getTransform()->getPosition()->getX())->toBe(1) + ->and($player->getTransform()->getPosition()->getY())->toBe(0); +}); + +it('ignores self while still detecting different colliders on objects with the same name', function () { + [, $firstCollider] = ($this->makeCollider)('Enemy', new Vector2(3, 3)); + [, $secondCollider] = ($this->makeCollider)('Enemy', new Vector2(3, 3)); + + expect($firstCollider->isTouching($firstCollider))->toBeFalse() + ->and($firstCollider->isTouching($secondCollider))->toBeTrue(); +}); From ab1c139013402c3b252c382eafa7bc45869c6f34 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Wed, 11 Mar 2026 12:41:10 +0200 Subject: [PATCH 10/13] fix(collision): enhance collision detection and improve rendering logic --- README.md | 2 +- docs/Collision.md | 94 ++++++ docs/DOCS.md | 5 +- examples/collector/assets/Scenes/Level01.php | 14 +- .../assets/Scripts/Game/LevelManager.php | 211 +++++++------ examples/collector/preferences.json | 2 + src/Core/Rendering/Camera.php | 10 +- src/Core/Rendering/Renderer.php | 107 ++++--- src/Core/Scenes/AbstractScene.php | 15 +- src/Core/Scenes/SceneManager.php | 8 +- src/Core/Scenes/TitleScene.php | 48 ++- src/Core/Sprite.php | 2 +- src/Game.php | 117 +++++-- src/IO/Console/Console.php | 297 +++++++++++++++--- src/Physics/Traits/BoundTrait.php | 34 +- src/States/PausedState.php | 12 +- src/UI/Label/Label.php | 6 +- src/UI/UIElement.php | 24 +- src/UI/Windows/Window.php | 22 +- tests/Unit/Core/Scenes/TitleSceneTest.php | 49 +++ tests/Unit/IO/Console/ConsoleLayoutTest.php | 51 +++ .../Unit/Physics/CharacterControllerTest.php | 16 + tests/Unit/UI/LabelTest.php | 61 ++++ 23 files changed, 917 insertions(+), 290 deletions(-) create mode 100644 docs/Collision.md create mode 100644 examples/collector/preferences.json create mode 100644 tests/Unit/Core/Scenes/TitleSceneTest.php create mode 100644 tests/Unit/IO/Console/ConsoleLayoutTest.php create mode 100644 tests/Unit/UI/LabelTest.php diff --git a/README.md b/README.md index 65ca666..8acfe43 100644 --- a/README.md +++ b/README.md @@ -75,7 +75,7 @@ composer require sendamaphp/engine ``` ## Usage -See [examples](examples/EXAMPLES.md) and [Documentation](docs/DOCS.md). +See [examples](examples/EXAMPLES.md), [Documentation](docs/DOCS.md), and the [Collision Detection Guide](docs/Collision.md). ## Notes The examples in [examples]() are made to demonstrate how Sendama may be used to make simple 2D games. diff --git a/docs/Collision.md b/docs/Collision.md new file mode 100644 index 0000000..8c5a6d2 --- /dev/null +++ b/docs/Collision.md @@ -0,0 +1,94 @@ +# Collision Detection Guide + +This guide shows the basic collision workflow in Sendama and how to swap collision strategies when you need different behavior. + +## Quick Setup + +1. Add a collider component to the game object before you add it to a scene. +2. Give the object a sprite, because collider bounds are derived from the sprite rectangle. +3. Move the object with `CharacterController` if you want movement to stop on solid collisions. +4. Handle collision callbacks in a behaviour. + +```php +getGameObject()->getName(); + } +} + +$player = new GameObject('Player'); +$player->setSpriteFromTexture('Textures/player.texture', Vector2::zero(), Vector2::one()); +$player->addComponent(CharacterController::class); +$player->addComponent(PlayerCollisionHandler::class); +``` + +## Built-in Strategies + +- `AABBCollisionDetectionStrategy`: compares bounding boxes. This is the default for `Collider`, `CharacterController`, and `Rigidbody`. +- `SimpleCollisionDetectionStrategy`: currently uses the same bounding-box overlap check, but without the extra debug logging. +- `BasicCollisionDetectionStrategy`: only reports a hit when two colliders share the exact same position. +- `SeparationBasedCollisionDetectionStrategy`: reports a hit when the distance between colliders is less than `1`. + +## Changing a Strategy + +Use `setCollisionDetectionStrategy()` on the collider component instance itself. In practice that means a `Collider`, `CharacterController`, or `Rigidbody`, because those are the components that implement `ColliderInterface`. + +```php +addComponent(CharacterController::class); +$controller->setCollisionDetectionStrategy( + new BasicCollisionDetectionStrategy($controller) +); +``` + +## Solid vs Trigger Colliders + +- Solid colliders block `CharacterController::move()`. +- Trigger colliders do not block movement. +- To make a pickup or checkpoint a trigger, call `setTrigger(true)`. + +```php +$coinCollider->setTrigger(true); +``` + +At the moment, the `CharacterController` path dispatches `onCollisionEnter()` and `onCollisionStay()` for both solid and trigger contacts, so those are the safest callbacks to implement for gameplay reactions. + +## Manual Collision Checks + +If you want to query collisions yourself: + +```php +checkCollisions($playerCollider, new Vector2(1, 0)); + +if ($collisions !== []) { + // The move would overlap another collider. +} +``` + +`checkCollisions()` tests the collider's projected bounds after applying the motion vector, which is useful for predicting whether a move will be blocked before translating the object. + +## Notes + +- Colliders are registered with physics when the game object is added to the scene. +- If you add a collider after the object is already running, register it with `Physics::getInstance()->addCollider(...)` yourself. +- Collision bounds come from the game object's sprite rect, so a missing sprite means unreliable collision bounds. diff --git a/docs/DOCS.md b/docs/DOCS.md index ef80c53..80712fe 100644 --- a/docs/DOCS.md +++ b/docs/DOCS.md @@ -19,8 +19,9 @@ - [Removing a Scene](#removing-a-scene) - [Game Objects](#game-objects) - [Creating a Game Object](#creating-a-game-object) - - [Moving a Game Object](#moving-a-game-object) + - [Moving a Game Object](#moving-a-game-object) - [Components](#components) +- [Collision Detection Guide](Collision.md) ## Introduction Sendama is a 2D game engine for terminal based games. It is written in PHP and is designed to be easy to use and extend. It is a work in progress and is not yet ready for production use. @@ -181,4 +182,4 @@ use \Sendama\Engine\Core\Scenes\SceneManager; SceneManager::getInstance()->addScene($scene); ``` -#### Removing a Scene \ No newline at end of file +#### Removing a Scene diff --git a/examples/collector/assets/Scenes/Level01.php b/examples/collector/assets/Scenes/Level01.php index 0b954c4..79c5746 100644 --- a/examples/collector/assets/Scenes/Level01.php +++ b/examples/collector/assets/Scenes/Level01.php @@ -36,12 +36,12 @@ public function awake(): void $apple = new GameObject(Name::APPLE->value); // GUI Elements - $collectedLabel = new Label($this, 'Collected Label', new Vector2(0, 27), new Vector2(15, 1)); - $collected = 0; - $collectedLabel->setText(sprintf("%-12s %03d", 'Collected: ',$collected)); - - $stepsLabel = new Label($this, 'Steps Label', new Vector2(65, 27), new Vector2(15, 1)); - $stepsLabel->setText(sprintf("%-9s %06d", 'Steps: ', 0)); +// $collectedLabel = new Label($this, 'Collected Label', new Vector2(0, 27), new Vector2(15, 1)); +// $collected = 0; +// $collectedLabel->setText(sprintf("%-12s %03d", 'Collected: ',$collected)); +// +// $stepsLabel = new Label($this, 'Steps Label', new Vector2(65, 27), new Vector2(15, 1)); +// $stepsLabel->setText(sprintf("%-9s %06d", 'Steps: ', 0)); // Set up the level manager $levelManager->addComponent(LevelManager::class); @@ -82,7 +82,5 @@ public function awake(): void $this->add($levelManager); $this->add($player); $this->add($apple); - $this->add($collectedLabel); - $this->add($stepsLabel); } } \ No newline at end of file diff --git a/examples/collector/assets/Scripts/Game/LevelManager.php b/examples/collector/assets/Scripts/Game/LevelManager.php index ea97a01..50c02bc 100644 --- a/examples/collector/assets/Scripts/Game/LevelManager.php +++ b/examples/collector/assets/Scripts/Game/LevelManager.php @@ -4,6 +4,7 @@ use Sendama\Engine\Core\Behaviours\Attributes\SerializeField; use Sendama\Engine\Core\Behaviours\Behaviour; +use Sendama\Engine\Core\Scenes\SceneManager; use Sendama\Engine\Core\Vector2; use Sendama\Engine\Events\EventManager; use Sendama\Engine\Events\Interfaces\EventInterface; @@ -12,6 +13,7 @@ use Sendama\Engine\Events\Interfaces\StaticObserverInterface; use Sendama\Engine\IO\Enumerations\KeyCode; use Sendama\Engine\IO\Input; +use Sendama\Engine\UI\Label\Label; use Sendama\Examples\Collector\Scripts\Game\Events\ScoreUpdateEvent; /** @@ -21,117 +23,136 @@ */ class LevelManager extends Behaviour implements ObservableInterface { - /** - * @var Vector2|array - */ - public Vector2|array $playerStartingPosition = [0, 0]; - /** - * @var int $score The current score. - */ - #[SerializeField] - protected int $score = 0; - /** - * @var array $observers The observers. - */ - protected array $observers = []; - /** - * @var EventManager $eventManager The event manager. - */ - protected EventManager $eventManager; - - public function onStart(): void - { - $this->eventManager = EventManager::getInstance(); - - if (is_array($this->playerStartingPosition)) + /** + * @var Vector2|array + */ + public Vector2|array $playerStartingPosition = [0, 0]; + public Label $collectedLabel; + public Label $stepsLabel; + + /** + * @var int $score The current score. + */ + #[SerializeField] + protected int $score = 0; + #[SerializeField] + protected int $totalStepsTaken = 0; + /** + * @var array $observers The observers. + */ + protected array $observers = []; + /** + * @var EventManager $eventManager The event manager. + */ + protected EventManager $eventManager; + protected Vector2 $previousPlayerPosition; + + public function onStart(): void { - $this->playerStartingPosition = Vector2::fromArray($this->playerStartingPosition); + $this->eventManager = EventManager::getInstance(); + + if (is_array($this->playerStartingPosition)) { + $this->playerStartingPosition = Vector2::fromArray($this->playerStartingPosition); + } + + $activeScene = SceneManager::getInstance()->getActiveScene(); + + $this->collectedLabel = new Label($activeScene, 'Collected Label', new Vector2(0, 27), new Vector2(15, 1)); + $this->stepsLabel = new Label($activeScene, 'Steps Label', new Vector2(65, 27), new Vector2(15, 1)); + + $activeScene->add($this->collectedLabel); + $activeScene->add($this->stepsLabel); + + $this->previousPlayerPosition = $this->getTransform()->getPosition(); } - } - - /** - * @inheritDoc - */ - public function onUpdate(): void - { - if (Input::isKeyDown(KeyCode::ESCAPE)) + + /** + * @inheritDoc + */ + public function onUpdate(): void { - pauseGame(); + if (Input::isKeyDown(KeyCode::ESCAPE)) { + pauseGame(); + } + + if (Input::isAnyKeyPressed([KeyCode::Q, KeyCode::q])) { + quitGame(); + } + + if ($this->getTransform()->getPosition() !== $this->previousPlayerPosition) { + $this->totalStepsTaken++; + $this->previousPlayerPosition = $this->getTransform()->getPosition(); + } + $this->updateLabels(); } - if (Input::isAnyKeyPressed([KeyCode::Q, KeyCode::q])) + /** + * Sets the player's starting position. + * + * @param Vector2|array $position The player's starting position. + */ + public function setPlayerStartingPosition(Vector2|array $position): void { - quitGame(); + $this->playerStartingPosition = match (true) { + is_array($position) => Vector2::fromArray($position), + default => $position + }; } - } - - /** - * Sets the player's starting position. - * - * @param Vector2|array $position The player's starting position. - */ - public function setPlayerStartingPosition(Vector2|array $position): void - { - $this->playerStartingPosition = match (true) + + /** + * Increments the score by the given amount. + * + * @param int $increment The amount to increment the score by. + */ + public function incrementScore(int $increment = 1): void { - is_array($position) => Vector2::fromArray($position), - default => $position - }; - } - - /** - * Increments the score by the given amount. - * - * @param int $increment The amount to increment the score by. - */ - public function incrementScore(int $increment = 1): void - { - $this->score += $increment; - $this->notify(new ScoreUpdateEvent($this->score)); - } - - /** - * Returns the current score. - * - * @return int The current score. - */ - public function getScore(): int - { - return $this->score; - } - - public function addObservers(string|StaticObserverInterface|ObserverInterface ...$observers): void - { - foreach ($observers as $observer) + $this->score += $increment; + $this->notify(new ScoreUpdateEvent($this->score)); + } + + public function notify(EventInterface $event): void { - $this->observers[] = $observer; + foreach ($this->observers as $observer) { + if ($observer instanceof StaticObserverInterface) { + $observer::onNotify($this, $event); + continue; + } + + $observer->onNotify($this, $event); + } } - } - public function removeObservers(string|StaticObserverInterface|ObserverInterface|null ...$observers): void - { - foreach ($observers as $observer) + /** + * Returns the current score. + * + * @return int The current score. + */ + public function getScore(): int { - $index = array_search($observer, $this->observers, true); + return $this->score; + } - if ($index !== false) - { - unset($this->observers[$index]); - } + public function addObservers(string|StaticObserverInterface|ObserverInterface ...$observers): void + { + foreach ($observers as $observer) { + $this->observers[] = $observer; + } } - } - public function notify(EventInterface $event): void - { - foreach ($this->observers as $observer) + public function removeObservers(string|StaticObserverInterface|ObserverInterface|null ...$observers): void { - if ($observer instanceof StaticObserverInterface) - { - $observer::onNotify($this, $event); - continue; - } + foreach ($observers as $observer) { + $index = array_search($observer, $this->observers, true); + + if ($index !== false) { + unset($this->observers[$index]); + } + } + } - $observer->onNotify($this, $event); + private function updateLabels(): void + { + $this->collectedLabel->setText(sprintf("%-12s %03d", 'Collected: ', $this->score)); + $this->stepsLabel->setText(sprintf("%-9s %06d", 'Steps: ', $this->totalStepsTaken)); } - } } \ No newline at end of file diff --git a/examples/collector/preferences.json b/examples/collector/preferences.json new file mode 100644 index 0000000..311847d --- /dev/null +++ b/examples/collector/preferences.json @@ -0,0 +1,2 @@ +{} + diff --git a/src/Core/Rendering/Camera.php b/src/Core/Rendering/Camera.php index cba9ac6..0f1aaae 100644 --- a/src/Core/Rendering/Camera.php +++ b/src/Core/Rendering/Camera.php @@ -206,8 +206,10 @@ public function update(): void public function renderWorldSpace(): void { Console::clear(); - $this->cursor->moveTo(0, 0); - - echo $this->scene->getWorldSpace(); + Console::writeLines( + explode("\n", (string)$this->scene->getWorldSpace()), + 1, + 1 + ); } -} \ No newline at end of file +} diff --git a/src/Core/Rendering/Renderer.php b/src/Core/Rendering/Renderer.php index 7aa0a34..ebdc445 100644 --- a/src/Core/Rendering/Renderer.php +++ b/src/Core/Rendering/Renderer.php @@ -6,11 +6,16 @@ use Sendama\Engine\Core\GameObject; use Sendama\Engine\Core\Interfaces\CanRender; use Sendama\Engine\Core\Sprite; +use Sendama\Engine\Core\Scenes\SceneManager; use Sendama\Engine\IO\Console\Console; use Sendama\Engine\IO\Console\Cursor; class Renderer extends Component implements CanRender { + /** + * @var array{x: int, y: int, width: int, height: int}|null + */ + protected ?array $lastRenderedBounds = null; /** * The console cursor. * @@ -58,76 +63,92 @@ public final function onUpdate(): void * @inheritDoc */ public final function render(): void + { + $this->renderAt(); + } + + /** + * @inheritDoc + */ + public final function renderAt(?int $x = null, ?int $y = null): void { if (!$this->sprite) { return; } - $xOffset = $this->getGameObject()->getTransform()->getPosition()->getX(); - $yOffset = $this->getGameObject()->getTransform()->getPosition()->getY(); + $xOffset = $this->getGameObject()->getTransform()->getPosition()->getX() + ($x ?? 0); + $yOffset = $this->getGameObject()->getTransform()->getPosition()->getY() + ($y ?? 0); $spriteBufferedImage = $this->sprite->getBufferedImage(); - for ($y = 0; $y < $this->sprite->getRect()->getHeight(); $y++) { - for ($x = 0; $x < $this->sprite->getRect()->getWidth(); $x++) { - $targetX = max(1, $xOffset + $x); - $targetY = max(1, $yOffset + $y); - - if ($targetX < 0 || $targetY < 0) { - continue; - } - - // Move the console cursor to the position of the sprite. - $this->consoleCursor->moveTo($targetX, $targetY); - - // Render the sprite. - echo $spriteBufferedImage[$y][$x]; - } + for ($row = 0; $row < $this->sprite->getRect()->getHeight(); $row++) { + Console::write( + implode($spriteBufferedImage[$row] ?? []), + $xOffset, + $yOffset + $row + ); } + + $this->lastRenderedBounds = [ + 'x' => $xOffset, + 'y' => $yOffset, + 'width' => $this->sprite->getRect()->getWidth(), + 'height' => $this->sprite->getRect()->getHeight(), + ]; } /** * @inheritDoc */ - public final function renderAt(?int $x = null, ?int $y = null): void + public final function erase(): void { - // Do nothing. + $this->eraseAt(); } /** * @inheritDoc */ - public final function erase(): void + public final function eraseAt(?int $x = null, ?int $y = null): void { - if (!$this->sprite) { + if (!$this->sprite || !$this->lastRenderedBounds) { return; } - $xOffset = $this->getGameObject()->getTransform()->getPosition()->getX(); - $yOffset = $this->getGameObject()->getTransform()->getPosition()->getY(); - - for ($y = 0; $y < $this->sprite->getRect()->getHeight(); $y++) { - for ($x = 0; $x < $this->sprite->getRect()->getWidth(); $x++) { - $targetX = max(1, $xOffset + $x); - $targetY = max(1, $yOffset + $y); - - if ($targetX < 0 || $targetY < 0) { - continue; - } - - // Move the console cursor to the position of the sprite. - Console::cursor()->moveTo($targetX, $targetY); - - // Erase the sprite. - echo ' '; - } + $xOffset = $this->lastRenderedBounds['x']; + $yOffset = $this->lastRenderedBounds['y']; + $width = $this->lastRenderedBounds['width']; + $height = $this->lastRenderedBounds['height']; + + for ($row = 0; $row < $height; $row++) { + Console::write( + $this->getBackgroundRowSegment($xOffset, $yOffset + $row, $width), + $xOffset, + $yOffset + $row + ); } + + $this->lastRenderedBounds = null; } /** - * @inheritDoc + * Returns the static world-space row segment underneath the sprite. + * + * @param int $xOffset + * @param int $yOffset + * @param int $width + * @return string */ - public final function eraseAt(?int $x = null, ?int $y = null): void + private function getBackgroundRowSegment(int $xOffset, int $yOffset, int $width): string { - // Do nothing. + $worldRows = SceneManager::getInstance()->getActiveScene()?->getWorldSpace()->toArray() ?? []; + $worldY = max(0, $yOffset - 1); + $startX = max(0, $xOffset - 1); + $buffer = ''; + + for ($column = 0; $column < $width; $column++) { + $worldX = $startX + $column; + $buffer .= $worldRows[$worldY][$worldX] ?? ' '; + } + + return $buffer; } -} \ No newline at end of file +} diff --git a/src/Core/Scenes/AbstractScene.php b/src/Core/Scenes/AbstractScene.php index 6ffe718..55f1cbf 100644 --- a/src/Core/Scenes/AbstractScene.php +++ b/src/Core/Scenes/AbstractScene.php @@ -448,20 +448,15 @@ private function loadStaticEnvironment(): void return; } - // Parse the tile map data - $buffer = new Grid(); $lines = explode("\n", $this->environmentTileMapData); foreach ($lines as $y => $line) { $lineLength = strlen($line); for ($x = 0; $x < $lineLength; $x++) { - $buffer->set($x, $y, $line[$x]); + $this->worldsSpace->set($x, $y, $line[$x]); } } - // Fill the world space with the static tiles - $this->setWorldSpace($buffer); - $this->camera->renderWorldSpace(); } @@ -542,7 +537,11 @@ public function getCamera(): CameraInterface */ public function getSettings(?string $key): mixed { - return $this->settings[$key] ?? $this->settings; + if ($key === null) { + return $this->settings; + } + + return array_key_exists($key, $this->settings) ? $this->settings[$key] : null; } /** @@ -563,4 +562,4 @@ private function setCollisionWorldSpace(Grid $worldSpace): void { $this->collisionWorldSpace = $worldSpace; } -} \ No newline at end of file +} diff --git a/src/Core/Scenes/SceneManager.php b/src/Core/Scenes/SceneManager.php index 0eceb69..0e066b8 100644 --- a/src/Core/Scenes/SceneManager.php +++ b/src/Core/Scenes/SceneManager.php @@ -300,7 +300,11 @@ public function loadSettings(?array $settings = null): void */ public function getSettings(?string $key = null): mixed { - return $this->settings[$key] ?? $this->settings; + if ($key === null) { + return $this->settings; + } + + return array_key_exists($key, $this->settings) ? $this->settings[$key] : null; } /** @@ -474,4 +478,4 @@ public function awake(): void $this->addScene($scene); } -} \ No newline at end of file +} diff --git a/src/Core/Scenes/TitleScene.php b/src/Core/Scenes/TitleScene.php index ed16b5a..965306b 100644 --- a/src/Core/Scenes/TitleScene.php +++ b/src/Core/Scenes/TitleScene.php @@ -83,7 +83,12 @@ public function awake(): void $this->sceneManager = SceneManager::getInstance(); $gameName = getGameName() ?? $this->name; - $this->titleText = new Text(scene: $this, name: $gameName, position: new Vector2(0, $this->titleTopMargin), size: new Vector2(DEFAULT_SCREEN_WIDTH, 5)); + $this->titleText = new Text( + scene: $this, + name: $gameName, + position: new Vector2(0, $this->titleTopMargin), + size: new Vector2($this->resolveScreenWidth(), 5) + ); $this->titleText->setFontName(FontName::BIG->value); $this->setTitleText($gameName); @@ -112,7 +117,7 @@ public function awake(): void */ private function getMenuLeftMargin(): int { - $screenWidth = $this->screenWidth ?? get_screen_width(); + $screenWidth = $this->resolveScreenWidth(); return (int)round($screenWidth / 2) - (int)round($this->menuWidth / 2); } @@ -132,7 +137,8 @@ private function getMenuTopMargin(): int public function setTitleText(string $text): self { $this->titleText->setText($text); - $this->titleLeftMargin = round((get_screen_width() / 2) - ($this->titleText->getWidth() / 2)); + $screenWidth = $this->resolveScreenWidth(); + $this->titleLeftMargin = round(($screenWidth / 2) - ($this->titleText->getWidth() / 2)); $this->titleTopMargin = self::TOP_MARGIN_OFFSET; $this->titleText->setPosition(new Vector2(round($this->titleLeftMargin), round($this->titleTopMargin))); @@ -251,4 +257,38 @@ public function addMenuItems(MenuItemInterface ...$item): self $this->menu->addItem($quitItem); return $this; } -} \ No newline at end of file + + /** + * Resolves the screen width even while the scene is still in awake() and local scene settings are empty. + * + * @return int + */ + private function resolveScreenWidth(): int + { + return $this->resolveDimension( + $this->screenWidth, + $this->sceneManager->getSettings('screen_width'), + $this->getSettings('screen_width'), + DEFAULT_SCREEN_WIDTH + ); + } + + /** + * @param mixed ...$values + * @return int + */ + private function resolveDimension(mixed ...$values): int + { + foreach ($values as $value) { + if (is_int($value)) { + return $value; + } + + if (is_string($value) && is_numeric($value)) { + return (int)$value; + } + } + + return DEFAULT_SCREEN_WIDTH; + } +} diff --git a/src/Core/Sprite.php b/src/Core/Sprite.php index d594794..ed464b0 100644 --- a/src/Core/Sprite.php +++ b/src/Core/Sprite.php @@ -116,7 +116,7 @@ public function getBufferedImage(): array if (!isset($pixels[$row][$column])) { $pixels[$row][$column] = ' '; - Debug::warn("Pixel column $column does not exist - " . json_encode($pixels[$row], JSON_PRETTY_PRINT)); + Debug::warn("Sprite: Pixel column $column does not exist - " . json_encode($pixels[$row], JSON_PRETTY_PRINT)); } if (!isset($buffer[$y])) { diff --git a/src/Game.php b/src/Game.php index 53e4c59..abf5832 100644 --- a/src/Game.php +++ b/src/Game.php @@ -302,7 +302,12 @@ public function getSettings(string|SettingsKey|null $key = null): mixed is_string($key) => $key, default => $key->value }; - return $this->settings[$key] ?? $this->settings; + + if ($key === null) { + return $this->settings; + } + + return array_key_exists($key, $this->settings) ? $this->settings[$key] : null; } /** @@ -381,8 +386,16 @@ private function initializeSettings(): void $this->settings[SettingsKey::INITIAL_SCENE->value] = null; // Load environment settings - $this->settings[SettingsKey::DEBUG->value] = $_ENV['DEBUG_MODE'] ?? false; - $this->settings[SettingsKey::DEBUG_INFO->value] = $_ENV['SHOW_DEBUG_INFO'] ?? false; + $this->settings[SettingsKey::DEBUG->value] = self::resolveConfiguredSetting( + 'DEBUG_MODE', + [SettingsKey::DEBUG->value, 'debugMode'], + false + ); + $this->settings[SettingsKey::DEBUG_INFO->value] = self::resolveConfiguredSetting( + 'SHOW_DEBUG_INFO', + ['showDebugInfo', SettingsKey::DEBUG_INFO->value, 'show_debug_info', 'debug.showInfo', 'debug.showDebugInfo'], + false + ); $this->settings[SettingsKey::LOG_LEVEL->value] = $_ENV['LOG_LEVEL'] ?? 'info'; Debug::setLogLevel(LogLevel::tryFrom($this->getSettings('log_level')) ?? LogLevel::DEBUG); @@ -419,10 +432,25 @@ private function initializeSettings(): void public function loadSettings(?array $settings = null): self { try { + $settings ??= []; + Debug::info("Loading environment settings"); // Environment - $this->settings[SettingsKey::DEBUG->value] = $_ENV['DEBUG_MODE'] ?? false; - $this->settings[SettingsKey::DEBUG_INFO->value] = $_ENV['SHOW_DEBUG_INFO'] ?? false; + $this->settings[SettingsKey::DEBUG->value] = $settings[SettingsKey::DEBUG->value] + ?? $settings['debugMode'] + ?? self::resolveConfiguredSetting( + 'DEBUG_MODE', + [SettingsKey::DEBUG->value, 'debugMode'], + false + ); + $this->settings[SettingsKey::DEBUG_INFO->value] = $settings[SettingsKey::DEBUG_INFO->value] + ?? $settings['showDebugInfo'] + ?? $settings['show_debug_info'] + ?? self::resolveConfiguredSetting( + 'SHOW_DEBUG_INFO', + ['showDebugInfo', SettingsKey::DEBUG_INFO->value, 'show_debug_info', 'debug.showInfo', 'debug.showDebugInfo'], + false + ); $this->settings[SettingsKey::LOG_LEVEL->value] = $_ENV['LOG_LEVEL'] ?? DEFAULT_LOG_LEVEL; $this->settings[SettingsKey::LOG_DIR->value] = $_ENV['LOG_DIR'] ?? Path::join(getcwd(), DEFAULT_LOGS_DIR); @@ -486,14 +514,13 @@ protected function initializeGameStates(): void */ protected function configureWindowChangeSignalHandler(): void { + pcntl_async_signals(true); pcntl_signal(SIGWINCH, function () { - $terminalSize = Console::getSize(); - $currentScreenWidth = $terminalSize->getWidth(); - $currentScreenHeight = $terminalSize->getHeight(); - - $this->screenWidth = min($currentScreenWidth, $this->screenWidth, DEFAULT_SCREEN_WIDTH); - $this->screenHeight = min($currentScreenHeight, $this->screenHeight, DEFAULT_SCREEN_HEIGHT); - + Console::refreshLayout( + (int)$this->getSettings(SettingsKey::SCREEN_WIDTH->value), + (int)$this->getSettings(SettingsKey::SCREEN_HEIGHT->value), + Console::getSize(force: true) + ); Debug::info("SIGWINCH received"); }); } @@ -580,6 +607,11 @@ private function start(): void // 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) + ); // Hide the cursor $this->consoleCursor->hide(); @@ -683,6 +715,10 @@ 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->state->render(); $this->uiManager->render(); $this->renderDebugInfo(); @@ -711,20 +747,57 @@ private function renderDebugInfo(): void */ private function isDebug(): bool { - return match (gettype($this->getSettings('debug'))) { - 'boolean' => $this->getSettings('debug'), - 'string' => in_array(strtolower($this->getSettings('debug')), ['true', '1', 'yes'], true), - 'integer' => $this->getSettings('debug') === 1, - default => false - }; + return self::isTruthySetting($this->getSettings(SettingsKey::DEBUG)); } private function showDebugInfo(): bool { - return match (gettype($this->getSettings('debug_info'))) { - 'boolean' => $this->getSettings('debug_info'), - 'string' => strtolower($this->getSettings('debug_info')) === 'true', - 'integer' => $this->getSettings('debug_info') === 1, + return self::isTruthySetting($this->getSettings(SettingsKey::DEBUG_INFO)); + } + + /** + * Resolve a setting from the environment first, then the app config file. + * + * @param string $envKey The environment variable name. + * @param string[] $configPaths Candidate config paths to try. + * @param mixed $default The default value. + * @return mixed + */ + private static function resolveConfiguredSetting(string $envKey, array $configPaths, mixed $default = null): mixed + { + if (array_key_exists($envKey, $_ENV)) { + return $_ENV[$envKey]; + } + + if (ConfigStore::doesntHave(AppConfig::class)) { + return $default; + } + + $config = ConfigStore::get(AppConfig::class); + + foreach ($configPaths as $path) { + $value = $config->get($path); + + if ($value !== null) { + return $value; + } + } + + return $default; + } + + /** + * Normalize mixed config values to booleans. + * + * @param mixed $value The value to normalize. + * @return bool + */ + private static function isTruthySetting(mixed $value): bool + { + return match (gettype($value)) { + 'boolean' => $value, + 'string' => in_array(strtolower($value), ['true', '1', 'yes', 'on'], true), + 'integer' => $value === 1, default => false }; } diff --git a/src/IO/Console/Console.php b/src/IO/Console/Console.php index 403e31c..dcf5411 100644 --- a/src/IO/Console/Console.php +++ b/src/IO/Console/Console.php @@ -19,6 +19,7 @@ */ class Console { + private const float TERMINAL_SIZE_POLL_INTERVAL_SECONDS = 0.1; /** * @var Game|null $game The game instance. */ @@ -31,10 +32,34 @@ class Console * @var int $height The height of the console */ protected static int $height = DEFAULT_SCREEN_HEIGHT; + /** + * @var int $logicalWidth The logical width of the game viewport. + */ + protected static int $logicalWidth = DEFAULT_SCREEN_WIDTH; + /** + * @var int $logicalHeight The logical height of the game viewport. + */ + protected static int $logicalHeight = DEFAULT_SCREEN_HEIGHT; + /** + * @var float $renderScale The current scale applied to logical coordinates. + */ + protected static float $renderScale = 1.0; + /** + * @var int $renderOffsetX The x-offset used to center the logical viewport. + */ + protected static int $renderOffsetX = 1; + /** + * @var int $renderOffsetY The y-offset used to center the logical viewport. + */ + protected static int $renderOffsetY = 1; /** * @var ConsoleOutput|null $output The console output stream */ protected static ?ConsoleOutput $output = null; + /** + * @var float $lastSizeCheckAt The last time the terminal size was polled. + */ + protected static float $lastSizeCheckAt = 0.0; /** * @var Grid $buffer The buffer. @@ -66,10 +91,11 @@ public static function init(Game $game, array $options = [ ]): 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(); Console::cursor()->disableBlinking(); - self::$width = $options['width'] ?? DEFAULT_SCREEN_WIDTH; - self::$height = $options['height'] ?? DEFAULT_SCREEN_HEIGHT; self::$output = new ConsoleOutput(); } @@ -95,7 +121,7 @@ public static function clear(): void */ private static function getEmptyBuffer(): Grid { - return new Grid(DEFAULT_SCREEN_HEIGHT, DEFAULT_SCREEN_WIDTH, ' '); + return new Grid(self::$width, self::$height, ' '); } /** @@ -203,14 +229,106 @@ public static function setSize(int $width, int $height): void * @return Rect The terminal size. * @throws Exception If the terminal size cannot be retrieved. */ - public static function getSize(): Rect + public static function getSize(bool $force = false): Rect { - $width = (int)trim(shell_exec("tput cols")) ?: throw new Exception('Failed to get terminal width.'); - $height = (int)trim(shell_exec("tput lines")) ?: throw new Exception('Failed to get terminal height.'); + if ( + !$force && + self::$lastSizeCheckAt > 0 && + (microtime(true) - self::$lastSizeCheckAt) < self::TERMINAL_SIZE_POLL_INTERVAL_SECONDS + ) { + return new Rect(new Vector2(1, 1), new Vector2(self::$width, self::$height)); + } + + $width = (int)trim(shell_exec("tput cols 2>/dev/null") ?? ''); + $height = (int)trim(shell_exec("tput lines 2>/dev/null") ?? ''); + self::$lastSizeCheckAt = microtime(true); + + if ($width < 1) { + $width = self::$width; + } + + if ($height < 1) { + $height = self::$height; + } return new Rect(new Vector2(1, 1), new Vector2($width, $height)); } + /** + * Refreshes the logical viewport within the current terminal size. + * + * @param int $logicalWidth The logical width of the game. + * @param int $logicalHeight The logical height of the game. + * @param Rect|null $terminalSize The terminal size override. + * @param bool $clearWhenChanged Whether to clear the terminal when the layout changes. + * @return bool True when the layout changed. + */ + public static function refreshLayout( + int $logicalWidth, + int $logicalHeight, + ?Rect $terminalSize = null, + bool $clearWhenChanged = true, + ): bool + { + $terminalSize ??= self::getSize(); + + $terminalWidth = max(1, $terminalSize->getWidth()); + $terminalHeight = max(1, $terminalSize->getHeight()); + $logicalWidth = max(1, $logicalWidth); + $logicalHeight = max(1, $logicalHeight); + + $renderScale = 1.0; + $offsetX = (int)floor(($terminalWidth - $logicalWidth) / 2) + 1; + $offsetY = (int)floor(($terminalHeight - $logicalHeight) / 2) + 1; + + $changed = + self::$width !== $terminalWidth || + self::$height !== $terminalHeight || + self::$logicalWidth !== $logicalWidth || + self::$logicalHeight !== $logicalHeight || + abs(self::$renderScale - $renderScale) > 0.0001 || + self::$renderOffsetX !== $offsetX || + self::$renderOffsetY !== $offsetY; + + self::$width = $terminalWidth; + self::$height = $terminalHeight; + self::$logicalWidth = $logicalWidth; + self::$logicalHeight = $logicalHeight; + self::$renderScale = $renderScale; + self::$renderOffsetX = $offsetX; + self::$renderOffsetY = $offsetY; + + if ($changed && $clearWhenChanged) { + self::clear(); + } elseif ($changed) { + self::$buffer = self::getEmptyBuffer(); + } elseif (!isset(self::$buffer)) { + self::$buffer = self::getEmptyBuffer(); + } + + return $changed; + } + + /** + * Returns the current render offset. + * + * @return Vector2 + */ + public static function getRenderOffset(): Vector2 + { + return new Vector2(self::$renderOffsetX, self::$renderOffsetY); + } + + /** + * Returns the current uniform render scale. + * + * @return float + */ + public static function getRenderScale(): float + { + return self::$renderScale; + } + /** * Saves the terminal settings. * @@ -242,16 +360,7 @@ public static function restoreSettings(): void */ public static function writeChar(string $character, int $x, int $y): void { - $cursor = self::cursor(); - - $x = max(1, $x); - $y = max(1, $y); - - self::$buffer->set($x, $y, substr($character, 0, 1)); - $cursor->moveTo($x, $y); - echo self::$buffer->toArray()[$y][$x]; - - $cursor->moveTo($x + 1, $y); + self::write($character, $x, $y); } /** @@ -264,19 +373,7 @@ public static function writeChar(string $character, int $x, int $y): void */ public static function write(string $message, int $x, int $y): void { - $cursor = self::cursor(); - $messageLength = strlen($message); - - $x = max(1, $x); - $y = max(1, $y); - - for ($index = 0; $index < $messageLength; ++$index) { - self::$buffer->set($x + $index, $y, $message[$index]); - $cursor->moveTo($x + $index, $y); - echo self::$buffer->toArray()[$y][$x + $index]; - } - - $cursor->moveTo($x + $messageLength, $y); + self::writeLine($message, $x, $y); } /** @@ -289,16 +386,9 @@ public static function write(string $message, int $x, int $y): void */ public static function writeLines(array $linesOfText, int $x, int $y): void { - $cursor = self::cursor(); - - $x = max(1, $x); - $y = max(1, $y); - foreach ($linesOfText as $rowIndex => $text) { self::writeLine($text, $x, $y + $rowIndex); } - - $cursor->moveTo(0, $y); } /** @@ -311,22 +401,35 @@ public static function writeLines(array $linesOfText, int $x, int $y): void */ public static function writeLine(string $message, int $x, int $y): void { - $cursor = self::cursor(); - $x = max(1, $x); - $y = max(1, $y); + $row = self::getTerminalRow($y); + if ($row < 1 || $row > self::$height) { + return; + } - $messageLength = strlen($message); - $columnStart = $x; - $columnEnd = $x + $messageLength; + $columnStart = self::getTerminalColumn($x); + $skipVisibleChars = max(0, 1 - $columnStart); + $columnStart = max(1, $columnStart); + $availableWidth = self::$width - $columnStart + 1; + $containsAnsi = str_contains($message, "\033"); - for ($i = $columnStart; $i < $columnEnd; $i++) { - self::$buffer->set($i, $y, $message[$i - $columnStart]); + if ($availableWidth < 1) { + return; } - $cursor->moveTo(0, $y); - echo implode(self::$buffer->toArray()[$y]); - $cursor->moveTo(0, $y + 1); + if (!$containsAnsi && $skipVisibleChars === 0 && strlen($message) <= $availableWidth) { + $visibleMessage = $message; + } else { + $visibleMessage = self::sliceTextForDisplay($message, $skipVisibleChars, $availableWidth); + } + + if ($visibleMessage === '') { + return; + } + + $cursor = self::cursor(); + $cursor->moveTo($columnStart, $row); + echo $visibleMessage; } /** @@ -340,9 +443,7 @@ public static function writeLine(string $message, int $x, int $y): void */ public static function writeInColor(Color $color, string $message, int $x, int $y): void { - echo $color->value; - self::writeLine($message, $x, $y); - echo Color::RESET->value; + self::writeLine(Color::apply($color, $message), $x, $y); } /** @@ -464,4 +565,98 @@ public static function output(int $verbosity = OutputInterface::VERBOSITY_NORMAL { return new ConsoleOutput($verbosity, $decorated, $formatter); } -} \ No newline at end of file + + /** + * Returns the terminal column for the given logical column. + * + * @param int $x The logical x position. + * @return int + */ + private static function getTerminalColumn(int $x): int + { + return self::$renderOffsetX + max(1, $x) - 1; + } + + /** + * Returns the terminal row for the given logical row. + * + * @param int $y The logical y position. + * @return int + */ + private static function getTerminalRow(int $y): int + { + return self::$renderOffsetY + max(1, $y) - 1; + } + + /** + * Returns a clipped slice of text for terminal output. + * + * @param string $message The message to clip. + * @param int $skipVisibleChars The number of visible characters to skip. + * @param int $maxVisibleChars The maximum number of visible characters to keep. + * @return string + */ + private static function sliceTextForDisplay(string $message, int $skipVisibleChars, int $maxVisibleChars): string + { + if ($maxVisibleChars < 1 || $message === '') { + return ''; + } + + if (!str_contains($message, "\033")) { + return substr($message, $skipVisibleChars, $maxVisibleChars); + } + + return self::sliceStyledText($message, $skipVisibleChars, $maxVisibleChars); + } + + /** + * Returns a clipped slice of a styled string while preserving ANSI color sequences. + * + * @param string $message The styled message to clip. + * @param int $skipVisibleChars The number of visible characters to skip. + * @param int $maxVisibleChars The maximum number of visible characters to keep. + * @return string + */ + private static function sliceStyledText(string $message, int $skipVisibleChars, int $maxVisibleChars): string + { + $glyphs = self::toStyledGlyphs($message); + + if ($glyphs === [] || $maxVisibleChars < 1 || $skipVisibleChars >= count($glyphs)) { + return ''; + } + + return implode('', array_slice($glyphs, $skipVisibleChars, $maxVisibleChars)); + } + + /** + * Breaks a styled string into visible glyphs with ANSI color preserved per glyph. + * + * @param string $message The styled string. + * @return string[] + */ + private static function toStyledGlyphs(string $message): array + { + preg_match_all('/\033\[[0-9;]*m|./us', $message, $matches); + + $glyphs = []; + $activeStyle = ''; + + foreach ($matches[0] ?? [] as $token) { + if (preg_match('/^\033\[[0-9;]*m$/', $token) === 1) { + if ($token === Color::RESET->value) { + $activeStyle = ''; + } else { + $activeStyle .= $token; + } + + continue; + } + + $glyphs[] = $activeStyle !== '' + ? $activeStyle . $token . Color::RESET->value + : $token; + } + + return $glyphs; + } +} diff --git a/src/Physics/Traits/BoundTrait.php b/src/Physics/Traits/BoundTrait.php index e1e6de6..6a65dc7 100644 --- a/src/Physics/Traits/BoundTrait.php +++ b/src/Physics/Traits/BoundTrait.php @@ -17,36 +17,18 @@ trait BoundTrait */ public function getBoundingBox(): Rect { - $x = - $this->getTransform() - ->getPosition() - ->getX() + - $this->getGameObject() - ->getSprite() - ->getPivot() - ->getX() - - $this->getGameObject() - ->getSprite() - ->getRect() - ->getX(); - $y = - $this->getTransform() - ->getPosition() - ->getY() + - $this->getGameObject() - ->getSprite() - ->getPivot() - ->getY() - - $this->getGameObject() - ->getSprite() - ->getRect() - ->getY(); + $x = $this->getTransform() + ->getPosition() + ->getX(); + $y = $this->getTransform() + ->getPosition() + ->getY(); return new Rect( - new Vector2($x,$y), + new Vector2($x, $y), new Vector2( $this->getGameObject()->getSprite()->getRect()->getWidth(), $this->getGameObject()->getSprite()->getRect()->getHeight() ) ); } -} \ No newline at end of file +} diff --git a/src/States/PausedState.php b/src/States/PausedState.php index 27197c9..f2f542f 100644 --- a/src/States/PausedState.php +++ b/src/States/PausedState.php @@ -70,10 +70,12 @@ public function suspend(): void */ private function renderDefaultPauseText(): void { + $activeScene = $this->sceneManager->getActiveScene(); $promptText = 'PAUSED'; - $leftMargin = intval(($this->game->getSettings('screen_width') / 2) - (strlen($promptText) / 2)); - $topMargin = intval(($this->game->getSettings('screen_height') / 2) - 1); - Console::cursor()->moveTo($leftMargin, $topMargin); - echo $promptText; + $screenWidth = $activeScene?->getSettings('screen_width') ?? $this->game->getSettings('screen_width'); + $screenHeight = $activeScene?->getSettings('screen_height') ?? $this->game->getSettings('screen_height'); + $leftMargin = (int)(($screenWidth / 2) - (strlen($promptText) / 2)); + $topMargin = (int)(($screenHeight / 2) - 1); + Console::write($promptText, $leftMargin, $topMargin); } -} \ No newline at end of file +} diff --git a/src/UI/Label/Label.php b/src/UI/Label/Label.php index 18efdde..9db49bb 100644 --- a/src/UI/Label/Label.php +++ b/src/UI/Label/Label.php @@ -37,13 +37,13 @@ public function getText(): string */ public function setText(string $text): void { - if ($this->isActive()) { + if ($this->shouldRenderWithinScene()) { $this->erase(); } $this->text = $text; - if ($this->isActive()) { + if ($this->shouldRenderWithinScene()) { $this->render(); } } @@ -104,4 +104,4 @@ public function update(): void { // Do nothing } -} \ No newline at end of file +} diff --git a/src/UI/UIElement.php b/src/UI/UIElement.php index f7b7c06..09d080b 100644 --- a/src/UI/UIElement.php +++ b/src/UI/UIElement.php @@ -118,7 +118,9 @@ public function isActive(): bool */ public function resume(): void { - $this->render(); + if ($this->shouldRenderWithinScene()) { + $this->render(); + } } /** @@ -126,7 +128,9 @@ public function resume(): void */ public function suspend(): void { - $this->erase(); + if ($this->shouldRenderWithinScene()) { + $this->erase(); + } } /** @@ -134,7 +138,9 @@ public function suspend(): void */ public function stop(): void { - $this->erase(); + if ($this->shouldRenderWithinScene()) { + $this->erase(); + } } /** @@ -176,4 +182,14 @@ public function setSize(Vector2 $size): void { $this->size = $size; } -} \ No newline at end of file + + /** + * 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/src/UI/Windows/Window.php b/src/UI/Windows/Window.php index 206e47c..4134cb1 100644 --- a/src/UI/Windows/Window.php +++ b/src/UI/Windows/Window.php @@ -219,8 +219,7 @@ public function renderAt(?int $x = null, ?int $y = null): void // Render the top border $topBorderHeight = 1; $output = $this->getTopBorder(); - $this->cursor->moveTo($leftMargin, $topMargin); - echo $output; + Console::writeLine($output, $leftMargin, $topMargin); // Render content $linesOfContent = $this->getLinesOfContent(); @@ -229,15 +228,17 @@ public function renderAt(?int $x = null, ?int $y = null): void } foreach ($linesOfContent as $index => $line) { - $this->cursor->moveTo($leftMargin, $topMargin + $index + $topBorderHeight); - echo mb_substr($line, 0, $this->width); + Console::writeLine( + mb_substr($line, 0, $this->width), + $leftMargin, + $topMargin + $index + $topBorderHeight + ); } // Render the bottom border $topMargin = $topMargin + count($linesOfContent) + $topBorderHeight; $output = $this->getBottomBorder(); - $this->cursor->moveTo($leftMargin, $topMargin); - echo $output; + Console::writeLine($output, $leftMargin, $topMargin); } /** @@ -253,12 +254,11 @@ public function erase(): void */ public function eraseAt(?int $x = null, ?int $y = null): void { - $leftMargin = $this->position->getX() + $x; - $topMargin = $this->position->getY() + $y; + $leftMargin = $this->position->getX() + ($x ?? 0); + $topMargin = $this->position->getY() + ($y ?? 0); for ($i = 0; $i < $this->height; $i++) { - $this->cursor->moveTo($leftMargin, $topMargin + $i); - echo str_repeat(' ', $this->width); + Console::writeLine(str_repeat(' ', $this->width), $leftMargin, $topMargin + $i); } } @@ -485,4 +485,4 @@ private function getRightAlignedContent(): array return $rightAlignedContent; } -} \ No newline at end of file +} diff --git a/tests/Unit/Core/Scenes/TitleSceneTest.php b/tests/Unit/Core/Scenes/TitleSceneTest.php new file mode 100644 index 0000000..e6b3a22 --- /dev/null +++ b/tests/Unit/Core/Scenes/TitleSceneTest.php @@ -0,0 +1,49 @@ +loadSettings([ + 'game_name' => 'Blasters', + 'screen_width' => 140, + 'screen_height' => 30, + ]); +}); + +it('uses scene manager dimensions while title scenes are still waking up', function () { + $scene = new TitleScene('Blasters'); + + $menu = getProtectedProperty($scene, 'menu'); + $titleText = getProtectedProperty($scene, 'titleText'); + + expect($scene->getSettings('screen_width'))->toBeNull() + ->and($menu)->toBeInstanceOf(Menu::class) + ->and($menu->getPosition()->getX())->toBe(60) + ->and($titleText)->toBeInstanceOf(Text::class) + ->and($titleText->getPosition()->getX())->toBe((int)round((140 / 2) - ($titleText->getWidth() / 2))) + ->and($titleText->getPosition()->getY())->toBe(TitleScene::TOP_MARGIN_OFFSET); +}); + +it('returns null for missing settings keys instead of the full settings payload', function () { + $scene = new class('Test Scene') extends AbstractScene { + public function awake(): void + { + // Do nothing. + } + }; + + expect($scene->getSettings('missing'))->toBeNull() + ->and($scene->getSettings(null))->toBe([]) + ->and(SceneManager::getInstance()->getSettings('missing'))->toBeNull() + ->and(SceneManager::getInstance()->getSettings('screen_width'))->toBe(140); +}); + +function getProtectedProperty(object $object, string $property): mixed +{ + $reflection = new ReflectionClass($object); + return $reflection->getProperty($property)->getValue($object); +} diff --git a/tests/Unit/IO/Console/ConsoleLayoutTest.php b/tests/Unit/IO/Console/ConsoleLayoutTest.php new file mode 100644 index 0000000..79cfd77 --- /dev/null +++ b/tests/Unit/IO/Console/ConsoleLayoutTest.php @@ -0,0 +1,51 @@ +toBe(1.0) + ->and($offset->getX())->toBe(21) + ->and($offset->getY())->toBe(19); +}); + +it('renders text from the centered viewport origin without duplicating glyphs', function () { + Console::refreshLayout( + 80, + 24, + new Rect(new Vector2(1, 1), new Vector2(200, 40)), + clearWhenChanged: false + ); + + ob_start(); + Console::write('A', 1, 1); + $output = ob_get_clean(); + + expect($output)->toContain("\033[9;61HA") + ->not()->toContain('AA'); +}); + +it('clips centered output when the terminal is smaller than the scene', function () { + Console::refreshLayout( + 6, + 4, + new Rect(new Vector2(1, 1), new Vector2(4, 4)), + clearWhenChanged: false + ); + + ob_start(); + Console::write('ABCDEF', 1, 1); + $output = ob_get_clean(); + + expect($output)->toContain("\033[1;1HBCDE"); +}); diff --git a/tests/Unit/Physics/CharacterControllerTest.php b/tests/Unit/Physics/CharacterControllerTest.php index 300ddfb..2a3c64e 100644 --- a/tests/Unit/Physics/CharacterControllerTest.php +++ b/tests/Unit/Physics/CharacterControllerTest.php @@ -79,6 +79,22 @@ ->and($player->getTransform()->getPosition()->getY())->toBe(0); }); +it('detects vertical collisions even when the sprite comes from an offset sprite-sheet frame', function () { + [$player, $controller] = ($this->makeCollider)('Player', new Vector2(5, 0), CharacterController::class); + [, $apple] = ($this->makeCollider)('Apple', new Vector2(5, 1)); + + $player->setSprite(new Sprite( + new Texture(getcwd() . '/tests/Mocks/Textures/test.texture'), + ['x' => 2, 'y' => 0, 'width' => 1, 'height' => 1] + )); + + $collisions = Physics::getInstance()->checkCollisions($controller, new Vector2(0, 1)); + + expect($collisions) + ->toHaveCount(1) + ->and($collisions[0]->getContact(0)?->getOtherCollider())->toBe($apple); +}); + it('ignores self while still detecting different colliders on objects with the same name', function () { [, $firstCollider] = ($this->makeCollider)('Enemy', new Vector2(3, 3)); [, $secondCollider] = ($this->makeCollider)('Enemy', new Vector2(3, 3)); diff --git a/tests/Unit/UI/LabelTest.php b/tests/Unit/UI/LabelTest.php new file mode 100644 index 0000000..a55ffa1 --- /dev/null +++ b/tests/Unit/UI/LabelTest.php @@ -0,0 +1,61 @@ +getProperty('buffer'); + $bufferProperty->setValue(null, new Grid(DEFAULT_SCREEN_HEIGHT, DEFAULT_SCREEN_WIDTH, ' ')); + Console::refreshLayout( + DEFAULT_SCREEN_WIDTH, + DEFAULT_SCREEN_HEIGHT, + new Rect(new Vector2(1, 1), new Vector2(DEFAULT_SCREEN_WIDTH, DEFAULT_SCREEN_HEIGHT)), + clearWhenChanged: false + ); +}); + +it('does not render labels before their scene starts', function () { + $scene = new class('Test Scene') extends AbstractScene { + public function awake(): void + { + // Do nothing. + } + }; + + ob_start(); + new Label($scene, 'Score', new Vector2(1, 1), new Vector2(10, 1)); + $output = ob_get_clean(); + + expect($scene->isStarted())->toBeFalse() + ->and($output)->toBe(''); +}); + +it('renders label updates after the owning scene starts', function () { + $scene = new class('Test Scene') extends AbstractScene { + public function awake(): void + { + // Do nothing. + } + }; + + $label = new Label($scene, 'Score', new Vector2(1, 1), new Vector2(10, 1)); + $scene->add($label); + $scene->loadSceneSettings([ + 'screen_width' => DEFAULT_SCREEN_WIDTH, + 'screen_height' => DEFAULT_SCREEN_HEIGHT, + ]); + $scene->start(); + + ob_start(); + $label->setText('Score: 1'); + $output = ob_get_clean(); + $plainTextOutput = preg_replace('/\e\[[0-9;]*[A-Za-z]/', '', $output); + + expect($output)->not()->toBe('') + ->and($plainTextOutput)->toContain('Score: 1'); +}); From f7fd3ad823f214ca087f746e04ef7822a514f812 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Wed, 11 Mar 2026 12:42:05 +0200 Subject: [PATCH 11/13] test(collision): add unit tests for sprite rendering and environment restoration --- examples/blasters/config/input.php | 4 + examples/blasters/preferences.json | 2 + examples/collector/config/input.php | 4 + tests/Unit/Core/Rendering/RendererTest.php | 112 ++++++++++++++++++++ tests/Unit/GameTest.php | 113 +++++++++++++++++++++ 5 files changed, 235 insertions(+) create mode 100644 examples/blasters/config/input.php create mode 100644 examples/blasters/preferences.json create mode 100644 examples/collector/config/input.php create mode 100644 tests/Unit/Core/Rendering/RendererTest.php create mode 100644 tests/Unit/GameTest.php diff --git a/examples/blasters/config/input.php b/examples/blasters/config/input.php new file mode 100644 index 0000000..c5c23e4 --- /dev/null +++ b/examples/blasters/config/input.php @@ -0,0 +1,4 @@ +setSpriteFromTexture( + new Texture(getcwd() . '/tests/Mocks/Textures/test.texture'), + new Vector2(0, 0), + new Vector2(1, 1) + ); + + ob_start(); + $gameObject->render(); + $output = ob_get_clean(); + + expect($output)->toContain("\033[1;1H>"); +}); + +it('does not erase anything before a sprite has rendered for the first time', function () { + $sceneManager = SceneManager::getInstance(); + $sceneManager->loadSettings([ + 'screen_width' => 10, + 'screen_height' => 10, + ]); + + $scene = new class('Test Scene') extends AbstractScene { + public function awake(): void + { + // Do nothing. + } + }; + + $sceneManager->addScene($scene); + $sceneManager->loadScene('Test Scene'); + + $gameObject = new GameObject('Player', position: new Vector2(0, 0)); + $gameObject->setSpriteFromTexture( + new Texture(getcwd() . '/tests/Mocks/Textures/test.texture'), + new Vector2(0, 0), + new Vector2(1, 1) + ); + $scene->add($gameObject); + + ob_start(); + $gameObject->erase(); + $output = ob_get_clean(); + + expect($output)->toBe(''); +}); + +it('restores the environment tile map under a sprite when it is erased', function () { + $sceneManager = SceneManager::getInstance(); + $sceneManager->loadSettings([ + 'screen_width' => 10, + 'screen_height' => 10, + ]); + + $scene = new class('Test Scene') extends AbstractScene { + public function awake(): void + { + // Do nothing. + } + }; + + $sceneManager->addScene($scene); + $sceneManager->loadScene('Test Scene'); + $scene->getWorldSpace()->set(0, 0, '#'); + + $gameObject = new GameObject('Player', position: new Vector2(1, 1)); + $gameObject->setSpriteFromTexture( + new Texture(getcwd() . '/tests/Mocks/Textures/test.texture'), + new Vector2(0, 0), + new Vector2(1, 1) + ); + $scene->add($gameObject); + + ob_start(); + $gameObject->render(); + ob_end_clean(); + + ob_start(); + $gameObject->erase(); + $output = ob_get_clean(); + + expect($output)->toContain("\033[1;1H#"); +}); + +function resetSingleton(string $className, string $property): void +{ + $reflection = new ReflectionClass($className); + $reflection->getProperty($property)->setValue(null, null); +} diff --git a/tests/Unit/GameTest.php b/tests/Unit/GameTest.php new file mode 100644 index 0000000..15d9f9f --- /dev/null +++ b/tests/Unit/GameTest.php @@ -0,0 +1,113 @@ + true, + 'showDebugInfo' => true, + ])); + + expect(invokePrivateStaticMethod(Game::class, 'resolveConfiguredSetting', 'DEBUG_MODE', ['debug', 'debugMode'], false))->toBeTrue() + ->and(invokePrivateStaticMethod(Game::class, 'resolveConfiguredSetting', 'SHOW_DEBUG_INFO', ['showDebugInfo', 'debug_info'], false))->toBeTrue() + ->and(invokePrivateStaticMethod(Game::class, 'isTruthySetting', true))->toBeTrue(); +}); + +it('prefers env debug flags over sendama config values', function () { + $_ENV['DEBUG_MODE'] = 'false'; + $_ENV['SHOW_DEBUG_INFO'] = '0'; + + ConfigStore::put(AppConfig::class, new ArrayConfig([ + 'debug' => true, + 'showDebugInfo' => true, + ])); + + expect(invokePrivateStaticMethod(Game::class, 'resolveConfiguredSetting', 'DEBUG_MODE', ['debug', 'debugMode'], false))->toBe('false') + ->and(invokePrivateStaticMethod(Game::class, 'resolveConfiguredSetting', 'SHOW_DEBUG_INFO', ['showDebugInfo', 'debug_info'], false))->toBe('0') + ->and(invokePrivateStaticMethod(Game::class, 'isTruthySetting', 'false'))->toBeFalse() + ->and(invokePrivateStaticMethod(Game::class, 'isTruthySetting', '0'))->toBeFalse(); +}); + +function invokePrivateStaticMethod(string $className, string $methodName, mixed ...$args): mixed +{ + $reflection = new \ReflectionClass($className); + $method = $reflection->getMethod($methodName); + + return $method->invoke(null, ...$args); +} + +function resetStaticProperty(string $className, string $propertyName, mixed $value): void +{ + $reflection = new \ReflectionClass($className); + $property = $reflection->getProperty($propertyName); + $property->setValue(null, $value); +} + +final class ArrayConfig implements ConfigInterface +{ + public function __construct(private array $config) + { + } + + public function get(string $path, mixed $default = null): mixed + { + $config = $this->config; + + foreach (explode('.', $path) as $segment) { + if (!is_array($config) || !array_key_exists($segment, $config)) { + return $default; + } + + $config = $config[$segment]; + } + + return $config; + } + + public function set(string $path, mixed $value): void + { + $config = &$this->config; + + foreach (explode('.', $path) as $segment) { + if (!isset($config[$segment]) || !is_array($config[$segment])) { + $config[$segment] = []; + } + + $config = &$config[$segment]; + } + + $config = $value; + } + + public function has(string $path): bool + { + $config = $this->config; + + foreach (explode('.', $path) as $segment) { + if (!is_array($config) || !array_key_exists($segment, $config)) { + return false; + } + + $config = $config[$segment]; + } + + return true; + } + + public function persist(): void + { + // No-op for tests. + } +} From d9745a5bb908c68dc68eadb1d4fc24f6c3481467 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Wed, 11 Mar 2026 12:49:31 +0200 Subject: [PATCH 12/13] fix(debug): improve logging logic and add shouldLog method --- src/Debug/Debug.php | 15 +++++- src/Debug/Enumerations/LogLevel.php | 10 ++-- src/Game.php | 21 +++++--- tests/Unit/Debug/DebugTest.php | 79 +++++++++++++++++++++++++++++ 4 files changed, 112 insertions(+), 13 deletions(-) create mode 100644 tests/Unit/Debug/DebugTest.php diff --git a/src/Debug/Debug.php b/src/Debug/Debug.php index 314162e..877748d 100644 --- a/src/Debug/Debug.php +++ b/src/Debug/Debug.php @@ -80,7 +80,7 @@ public static function log( { $filename = Path::join(self::getLogDirectory(), 'debug.log'); - if (self::$logLevel->getPriority() > $logLevel->getPriority()) { + if (!self::shouldLog($logLevel)) { return; } @@ -101,7 +101,7 @@ public static function log( */ public static function error(string $message, string $prefix = '[ERROR]'): void { - if (self::$logLevel->getPriority() > LogLevel::ERROR->getPriority()) { + if (!self::shouldLog(LogLevel::ERROR)) { return; } @@ -137,6 +137,17 @@ public static function info(string $message, ?string $prefix = null): void self::log($message, $prefix ?? '[INFO]', LogLevel::INFO); } + /** + * Determines whether a message at the given level should be logged. + * + * @param LogLevel $messageLevel The level of the message being logged. + * @return bool + */ + private static function shouldLog(LogLevel $messageLevel): bool + { + return $messageLevel->getPriority() >= self::$logLevel->getPriority(); + } + /** * Ensures the log directory and file exist before writing. * diff --git a/src/Debug/Enumerations/LogLevel.php b/src/Debug/Enumerations/LogLevel.php index ea7e092..415ad39 100644 --- a/src/Debug/Enumerations/LogLevel.php +++ b/src/Debug/Enumerations/LogLevel.php @@ -16,16 +16,18 @@ enum LogLevel: string /** * Returns the priority of the log level. * + * Higher numbers represent more severe log levels. + * * @return int The priority of the log level. */ public function getPriority(): int { return match ($this) { - LogLevel::FATAL => 0, - LogLevel::ERROR => 1, + LogLevel::DEBUG => 0, + LogLevel::INFO => 1, LogLevel::WARN => 2, - LogLevel::INFO => 3, - LogLevel::DEBUG => 4, + LogLevel::ERROR => 3, + LogLevel::FATAL => 4, }; } } diff --git a/src/Game.php b/src/Game.php index abf5832..f79e328 100644 --- a/src/Game.php +++ b/src/Game.php @@ -396,8 +396,8 @@ private function initializeSettings(): void ['showDebugInfo', SettingsKey::DEBUG_INFO->value, 'show_debug_info', 'debug.showInfo', 'debug.showDebugInfo'], false ); - $this->settings[SettingsKey::LOG_LEVEL->value] = $_ENV['LOG_LEVEL'] ?? 'info'; - Debug::setLogLevel(LogLevel::tryFrom($this->getSettings('log_level')) ?? LogLevel::DEBUG); + $this->settings[SettingsKey::LOG_LEVEL->value] = $_ENV['LOG_LEVEL'] ?? DEFAULT_LOG_LEVEL; + Debug::setLogLevel(LogLevel::tryFrom((string)$this->getSettings('log_level')) ?? LogLevel::DEBUG); $this->settings[SettingsKey::LOG_DIR->value] = Path::join(getcwd(), DEFAULT_LOGS_DIR); Debug::info("Log directory initialized: {$this->settings[SettingsKey::LOG_DIR->value]}"); @@ -434,6 +434,18 @@ public function loadSettings(?array $settings = null): self try { $settings ??= []; + $this->settings[SettingsKey::LOG_LEVEL->value] = $settings[SettingsKey::LOG_LEVEL->value] + ?? $_ENV['LOG_LEVEL'] + ?? $this->settings[SettingsKey::LOG_LEVEL->value] + ?? DEFAULT_LOG_LEVEL; + $this->settings[SettingsKey::LOG_DIR->value] = $settings[SettingsKey::LOG_DIR->value] + ?? $_ENV['LOG_DIR'] + ?? $this->settings[SettingsKey::LOG_DIR->value] + ?? Path::join(getcwd(), DEFAULT_LOGS_DIR); + + Debug::setLogDirectory($this->settings[SettingsKey::LOG_DIR->value]); + Debug::setLogLevel(LogLevel::tryFrom((string)$this->settings[SettingsKey::LOG_LEVEL->value]) ?? LogLevel::DEBUG); + Debug::info("Loading environment settings"); // Environment $this->settings[SettingsKey::DEBUG->value] = $settings[SettingsKey::DEBUG->value] @@ -451,9 +463,6 @@ public function loadSettings(?array $settings = null): self ['showDebugInfo', SettingsKey::DEBUG_INFO->value, 'show_debug_info', 'debug.showInfo', 'debug.showDebugInfo'], false ); - $this->settings[SettingsKey::LOG_LEVEL->value] = $_ENV['LOG_LEVEL'] ?? DEFAULT_LOG_LEVEL; - $this->settings[SettingsKey::LOG_DIR->value] = $_ENV['LOG_DIR'] ?? Path::join(getcwd(), DEFAULT_LOGS_DIR); - Debug::info("Loading game settings"); // Game $this->settings[SettingsKey::GAME_NAME->value] = $settings[SettingsKey::GAME_NAME->value] ?? $this->name; @@ -475,8 +484,6 @@ public function loadSettings(?array $settings = null): self // Debug settings Debug::info('Loading debug settings'); - Debug::setLogDirectory($this->getSettings('log_dir')); - Debug::setLogLevel(LogLevel::tryFrom($this->getSettings('log_level')) ?? LogLevel::DEBUG); $this->debugWindow->setPosition([0, $this->settings[SettingsKey::SCREEN_HEIGHT->value] - self::DEBUG_WINDOW_HEIGHT]); // Input settings diff --git a/tests/Unit/Debug/DebugTest.php b/tests/Unit/Debug/DebugTest.php new file mode 100644 index 0000000..963d328 --- /dev/null +++ b/tests/Unit/Debug/DebugTest.php @@ -0,0 +1,79 @@ +logDirectory = sys_get_temp_dir() . '/sendama-debug-' . uniqid('', true); + + Debug::setLogDirectory($this->logDirectory); + Debug::setLogLevel(LogLevel::DEBUG); +}); + +afterEach(function () { + deleteDirectory($this->logDirectory); +}); + +it('suppresses messages below the configured log level threshold', function () { + Debug::setLogLevel(LogLevel::INFO); + + Debug::log('debug message'); + Debug::info('info message'); + Debug::warn('warn message'); + Debug::error('error message'); + + $debugLog = readLogFile($this->logDirectory . '/debug.log'); + $errorLog = readLogFile($this->logDirectory . '/error.log'); + + expect($debugLog)->not()->toContain('debug message') + ->and($debugLog)->toContain('info message') + ->and($debugLog)->toContain('warn message') + ->and($errorLog)->toContain('error message'); +}); + +it('suppresses error logs when the threshold is fatal', function () { + Debug::setLogLevel(LogLevel::FATAL); + + Debug::error('error message'); + + expect(readLogFile($this->logDirectory . '/error.log'))->toBe(''); +}); + +function readLogFile(string $path): string +{ + if (!file_exists($path)) { + return ''; + } + + return file_get_contents($path) ?: ''; +} + +function deleteDirectory(string $path): void +{ + if (!is_dir($path)) { + return; + } + + $files = scandir($path); + + if ($files === false) { + return; + } + + foreach ($files as $file) { + if ($file === '.' || $file === '..') { + continue; + } + + $childPath = $path . '/' . $file; + + if (is_dir($childPath)) { + deleteDirectory($childPath); + continue; + } + + unlink($childPath); + } + + rmdir($path); +} From 176f3405aeb42b54bb9edce49b41e4043ad643ba Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Wed, 11 Mar 2026 13:13:42 +0200 Subject: [PATCH 13/13] fix(settings): enhance screen width and height resolution logic --- .../collector/assets/Scenes/SettingsScene.php | 11 +- examples/collector/sendama.json | 10 +- src/Game.php | 62 +++++++++- src/States/PausedState.php | 68 ++++++++++- tests/Unit/GameTest.php | 8 ++ tests/Unit/States/PausedStateTest.php | 108 ++++++++++++++++++ 6 files changed, 254 insertions(+), 13 deletions(-) create mode 100644 tests/Unit/States/PausedStateTest.php diff --git a/examples/collector/assets/Scenes/SettingsScene.php b/examples/collector/assets/Scenes/SettingsScene.php index 25529f5..c6ac782 100644 --- a/examples/collector/assets/Scenes/SettingsScene.php +++ b/examples/collector/assets/Scenes/SettingsScene.php @@ -7,6 +7,7 @@ use Sendama\Engine\Core\GameObject; use Sendama\Engine\Core\Rect; use Sendama\Engine\Core\Scenes\AbstractScene; +use Sendama\Engine\Core\Scenes\SceneManager; use Sendama\Engine\Core\Vector2; use Sendama\Engine\Debug\Debug; use Sendama\Engine\UI\Menus\Menu; @@ -29,9 +30,13 @@ public function awake(): void $settingsMenuWidth = 30; $settingsMenuHeight = DEFAULT_MENU_HEIGHT; + $sceneManager = SceneManager::getInstance(); + $screenWidth = $sceneManager->getSettings('screen_width') ?? DEFAULT_SCREEN_WIDTH; + $screenHeight = $sceneManager->getSettings('screen_height') ?? DEFAULT_SCREEN_HEIGHT; + Debug::log(var_export($this->settings, true)); - $leftMargin = round(DEFAULT_SCREEN_WIDTH / 2 - $settingsMenuWidth / 2); - $topMargin = round(DEFAULT_SCREEN_HEIGHT / 2 - $settingsMenuHeight / 2); + $leftMargin = round($screenWidth / 2 - $settingsMenuWidth / 2); + $topMargin = round($screenHeight / 2 - $settingsMenuHeight / 2); $settingsMenuBorderPack = new BorderPack(Path::join(Path::getVendorAssetsDirectory(), 'border-packs', 'slim.border.php')); $settingsPosition = new Vector2($leftMargin, $topMargin); @@ -53,4 +58,4 @@ public function awake(): void $this->add($levelManager); $this->add($settingsMenu); } -} \ No newline at end of file +} diff --git a/examples/collector/sendama.json b/examples/collector/sendama.json index 4083d08..9c17e48 100644 --- a/examples/collector/sendama.json +++ b/examples/collector/sendama.json @@ -2,5 +2,11 @@ "name": "collector", "description": "A simple 2D game where you collect coins.", "version": "1.0.0", - "main": "collector.php" -} \ No newline at end of file + "main": "collector.php", + "player": { + "screen": { + "width": 80, + "height": 28 + } + } +} diff --git a/src/Game.php b/src/Game.php index f79e328..f73efb1 100644 --- a/src/Game.php +++ b/src/Game.php @@ -378,8 +378,16 @@ private function initializeSettings(): void } $this->settings[SettingsKey::GAME_NAME->value] = $_ENV['GAME_NAME'] ?? $this->name; - $this->settings[SettingsKey::SCREEN_WIDTH->value] = $this->screenWidth; - $this->settings[SettingsKey::SCREEN_HEIGHT->value] = $this->screenHeight; + $this->settings[SettingsKey::SCREEN_WIDTH->value] = self::resolveConfiguredIntSetting( + 'SCREEN_WIDTH', + ['player.screen.width', 'screenWidth', SettingsKey::SCREEN_WIDTH->value], + $this->screenWidth + ); + $this->settings[SettingsKey::SCREEN_HEIGHT->value] = self::resolveConfiguredIntSetting( + 'SCREEN_HEIGHT', + ['player.screen.height', 'screenHeight', SettingsKey::SCREEN_HEIGHT->value], + $this->screenHeight + ); $this->settings[SettingsKey::FPS->value] = DEFAULT_FPS; $this->settings[SettingsKey::ASSETS_DIR->value] = Path::join(getcwd(), DEFAULT_ASSETS_PATH); @@ -466,8 +474,26 @@ public function loadSettings(?array $settings = null): self Debug::info("Loading game settings"); // Game $this->settings[SettingsKey::GAME_NAME->value] = $settings[SettingsKey::GAME_NAME->value] ?? $this->name; - $this->settings[SettingsKey::SCREEN_WIDTH->value] = $settings[SettingsKey::SCREEN_WIDTH->value] ?? $this->screenWidth; - $this->settings[SettingsKey::SCREEN_HEIGHT->value] = $settings[SettingsKey::SCREEN_HEIGHT->value] ?? $this->screenHeight; + $this->settings[SettingsKey::SCREEN_WIDTH->value] = self::resolveIntSettingValue( + $settings[SettingsKey::SCREEN_WIDTH->value] + ?? $settings['screenWidth'] + ?? self::resolveConfiguredSetting( + 'SCREEN_WIDTH', + ['player.screen.width', 'screenWidth', SettingsKey::SCREEN_WIDTH->value], + $this->screenWidth + ), + $this->screenWidth + ); + $this->settings[SettingsKey::SCREEN_HEIGHT->value] = self::resolveIntSettingValue( + $settings[SettingsKey::SCREEN_HEIGHT->value] + ?? $settings['screenHeight'] + ?? self::resolveConfiguredSetting( + 'SCREEN_HEIGHT', + ['player.screen.height', 'screenHeight', SettingsKey::SCREEN_HEIGHT->value], + $this->screenHeight + ), + $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; @@ -793,6 +819,34 @@ private static function resolveConfiguredSetting(string $envKey, array $configPa return $default; } + /** + * Resolve an integer setting from the environment first, then the app config file. + * + * @param string $envKey The environment variable name. + * @param string[] $configPaths Candidate config paths to try. + * @param int $default The default value. + * @return int + */ + private static function resolveConfiguredIntSetting(string $envKey, array $configPaths, int $default): int + { + return self::resolveIntSettingValue( + self::resolveConfiguredSetting($envKey, $configPaths, $default), + $default + ); + } + + /** + * Normalize an integer-like config value. + * + * @param mixed $value The value to normalize. + * @param int $default The default value. + * @return int + */ + private static function resolveIntSettingValue(mixed $value, int $default): int + { + return is_numeric($value) ? (int)$value : $default; + } + /** * Normalize mixed config values to booleans. * diff --git a/src/States/PausedState.php b/src/States/PausedState.php index f2f542f..401201a 100644 --- a/src/States/PausedState.php +++ b/src/States/PausedState.php @@ -4,6 +4,7 @@ use Sendama\Engine\IO\Console\Console; use Sendama\Engine\IO\Input; +use Sendama\Engine\Core\Scenes\Interfaces\SceneInterface; use Sendama\Engine\UI\Menus\Menu; /** @@ -72,10 +73,69 @@ private function renderDefaultPauseText(): void { $activeScene = $this->sceneManager->getActiveScene(); $promptText = 'PAUSED'; - $screenWidth = $activeScene?->getSettings('screen_width') ?? $this->game->getSettings('screen_width'); - $screenHeight = $activeScene?->getSettings('screen_height') ?? $this->game->getSettings('screen_height'); - $leftMargin = (int)(($screenWidth / 2) - (strlen($promptText) / 2)); - $topMargin = (int)(($screenHeight / 2) - 1); + [$left, $top, $width, $height] = $this->getPauseBounds($activeScene); + $leftMargin = $left + (int)floor(($width - strlen($promptText)) / 2); + $topMargin = $top + (int)floor(($height - 1) / 2); Console::write($promptText, $leftMargin, $topMargin); } + + /** + * Returns the bounds that should be used for pause overlay placement. + * + * @param SceneInterface|null $activeScene The active scene. + * @return array{0: int, 1: int, 2: int, 3: int} + */ + private function getPauseBounds(?SceneInterface $activeScene): array + { + $defaultWidth = (int)($activeScene?->getSettings('screen_width') ?? $this->game->getSettings('screen_width') ?? DEFAULT_SCREEN_WIDTH); + $defaultHeight = (int)($activeScene?->getSettings('screen_height') ?? $this->game->getSettings('screen_height') ?? DEFAULT_SCREEN_HEIGHT); + + if (!$activeScene) { + return [1, 1, $defaultWidth, $defaultHeight]; + } + + $minX = null; + $minY = null; + $maxX = null; + $maxY = null; + + foreach ($activeScene->getWorldSpace()->toArray() as $rowIndex => $row) { + foreach ($row as $columnIndex => $cell) { + if ($cell === ' ' || $cell === '' || $cell === 0) { + continue; + } + + $logicalX = $columnIndex + 1; + $logicalY = $rowIndex + 1; + $minX = $minX === null ? $logicalX : min($minX, $logicalX); + $minY = $minY === null ? $logicalY : min($minY, $logicalY); + $maxX = $maxX === null ? $logicalX : max($maxX, $logicalX); + $maxY = $maxY === null ? $logicalY : max($maxY, $logicalY); + } + } + + foreach ($activeScene->getUIElements() as $uiElement) { + if (!$uiElement->isActive()) { + continue; + } + + $position = $uiElement->getPosition(); + $size = $uiElement->getSize(); + $logicalX = max(1, $position->getX()); + $logicalY = max(1, $position->getY()); + $logicalMaxX = $logicalX + max(0, $size->getX() - 1); + $logicalMaxY = $logicalY + max(0, $size->getY() - 1); + + $minX = $minX === null ? $logicalX : min($minX, $logicalX); + $minY = $minY === null ? $logicalY : min($minY, $logicalY); + $maxX = $maxX === null ? $logicalMaxX : max($maxX, $logicalMaxX); + $maxY = $maxY === null ? $logicalMaxY : max($maxY, $logicalMaxY); + } + + if ($minX === null || $minY === null || $maxX === null || $maxY === null) { + return [1, 1, $defaultWidth, $defaultHeight]; + } + + return [$minX, $minY, $maxX - $minX + 1, $maxY - $minY + 1]; + } } diff --git a/tests/Unit/GameTest.php b/tests/Unit/GameTest.php index 15d9f9f..589a371 100644 --- a/tests/Unit/GameTest.php +++ b/tests/Unit/GameTest.php @@ -18,10 +18,18 @@ ConfigStore::put(AppConfig::class, new ArrayConfig([ 'debug' => true, 'showDebugInfo' => true, + 'player' => [ + 'screen' => [ + 'width' => 80, + 'height' => 28, + ], + ], ])); expect(invokePrivateStaticMethod(Game::class, 'resolveConfiguredSetting', 'DEBUG_MODE', ['debug', 'debugMode'], false))->toBeTrue() ->and(invokePrivateStaticMethod(Game::class, 'resolveConfiguredSetting', 'SHOW_DEBUG_INFO', ['showDebugInfo', 'debug_info'], false))->toBeTrue() + ->and(invokePrivateStaticMethod(Game::class, 'resolveConfiguredIntSetting', 'SCREEN_WIDTH', ['player.screen.width', 'screenWidth', 'screen_width'], 160))->toBe(80) + ->and(invokePrivateStaticMethod(Game::class, 'resolveConfiguredIntSetting', 'SCREEN_HEIGHT', ['player.screen.height', 'screenHeight', 'screen_height'], 40))->toBe(28) ->and(invokePrivateStaticMethod(Game::class, 'isTruthySetting', true))->toBeTrue(); }); diff --git a/tests/Unit/States/PausedStateTest.php b/tests/Unit/States/PausedStateTest.php new file mode 100644 index 0000000..16b9ab1 --- /dev/null +++ b/tests/Unit/States/PausedStateTest.php @@ -0,0 +1,108 @@ +loadSettings([ + 'screen_width' => 160, + 'screen_height' => 40, + ]); + + $scene = new class('Playfield') extends AbstractScene { + public function awake(): void + { + // Do nothing. + } + }; + + $scene->add(new Label($scene, 'Collected Label', new Vector2(65, 27), new Vector2(15, 1))); + $sceneManager->addScene($scene); + + ob_start(); + $sceneManager->loadScene('Playfield'); + ob_end_clean(); + + $scene->getWorldSpace()->set(0, 0, 'x'); + $scene->getWorldSpace()->set(79, 24, 'x'); + + Console::refreshLayout( + 160, + 40, + new Rect(new Vector2(1, 1), new Vector2(160, 40)), + clearWhenChanged: false + ); + + $state = new PausedState(new GameStateContext( + new TestGame([ + SettingsKey::SCREEN_WIDTH->value => 160, + SettingsKey::SCREEN_HEIGHT->value => 40, + ]), + $sceneManager, + EventManager::getInstance(), + ModalManager::getInstance(), + NotificationsManager::getInstance(), + UIManager::getInstance(), + )); + + ob_start(); + $state->render(); + $output = ob_get_clean(); + + expect($output)->toContain("\033[14;38HPAUSED"); +}); + +function resetPausedStateSingleton(string $className, string $property): void +{ + $reflection = new \ReflectionClass($className); + $reflection->getProperty($property)->setValue(null, null); +} + +final class TestGame extends Game +{ + public function __construct(private array $testSettings = []) + { + // Intentionally skip the parent bootstrapping for unit tests. + } + + public function __destruct() + { + // No-op for tests. + } + + public function getSettings(string|SettingsKey|null $key = null): mixed + { + $key = match (true) { + $key === null => null, + is_string($key) => $key, + default => $key->value, + }; + + if ($key === null) { + return $this->testSettings; + } + + return $this->testSettings[$key] ?? null; + } +}