diff --git a/src/Core/GameObject.php b/src/Core/GameObject.php index 38bc782..9dd8c58 100644 --- a/src/Core/GameObject.php +++ b/src/Core/GameObject.php @@ -150,8 +150,17 @@ public static function find(string $gameObjectName): ?GameObjectInterface { if ($activeScene = SceneManager::getInstance()->getActiveScene()) { foreach ($activeScene->getRootGameObjects() as $gameObject) { - if ($gameObject->getName() === $gameObjectName) { - return $gameObject; + if (!$gameObject instanceof GameObject) { + continue; + } + + $match = self::findFirstInHierarchy( + $gameObject, + static fn (GameObject $candidate): bool => $candidate->getName() === $gameObjectName + ); + + if ($match !== null) { + return $match; } } } @@ -176,8 +185,17 @@ public static function findWithTag(string $gameObjectTag): ?GameObjectInterface { if ($activeScene = SceneManager::getInstance()->getActiveScene()) { foreach ($activeScene->getRootGameObjects() as $gameObject) { - if ($gameObject->getTag() === $gameObjectTag) { - return $gameObject; + if (!$gameObject instanceof GameObject) { + continue; + } + + $match = self::findFirstInHierarchy( + $gameObject, + static fn (GameObject $candidate): bool => $candidate->getTag() === $gameObjectTag + ); + + if ($match !== null) { + return $match; } } } @@ -204,9 +222,15 @@ public static function findAll(string $gameObjectName): array if ($activeScene = SceneManager::getInstance()->getActiveScene()) { foreach ($activeScene->getRootGameObjects() as $gameObject) { - if ($gameObject->getName() === $gameObjectName) { - $gameObjects[] = $gameObject; + if (!$gameObject instanceof GameObject) { + continue; } + + self::collectHierarchyMatches( + $gameObject, + static fn (GameObject $candidate): bool => $candidate->getName() === $gameObjectName, + $gameObjects + ); } } @@ -222,9 +246,15 @@ public static function findAllWithTag(string $gameObjectTag): array if ($activeScene = SceneManager::getInstance()->getActiveScene()) { foreach ($activeScene->getRootGameObjects() as $gameObject) { - if ($gameObject->getTag() === $gameObjectTag) { - $gameObjects[] = $gameObject; + if (!$gameObject instanceof GameObject) { + continue; } + + self::collectHierarchyMatches( + $gameObject, + static fn (GameObject $candidate): bool => $candidate->getTag() === $gameObjectTag, + $gameObjects + ); } } @@ -290,6 +320,7 @@ public function __clone(): void $this->starting = false; $originalComponents = $this->components; + $originalChildren = $this->getChildren(); $position = clone $this->transform->getPosition(); $rotation = clone $this->transform->getRotation(); $scale = clone $this->transform->getScale(); @@ -312,6 +343,11 @@ public function __clone(): void $this->components[] = $this->cloneComponentForInstance($component); } + + foreach ($originalChildren as $child) { + $childClone = clone $child; + $childClone->getTransform()->setParent($this->transform); + } } /** @@ -407,6 +443,21 @@ public function getTransform(): Transform return $this->transform; } + /** + * Returns the direct child game objects parented to this object. + * + * @return array + */ + public function getChildren(): array + { + return array_values( + array_map( + static fn (Transform $childTransform): GameObject => $childTransform->getGameObject(), + $this->transform->getChildren() + ) + ); + } + /** * @inheritDoc */ @@ -480,9 +531,17 @@ public function equals(CanEquate $equatable): bool */ public function renderAt(?int $x = null, ?int $y = null): void { - if ($this->isActive() && $this->renderer->isEnabled()) { + if (!$this->isActive()) { + return; + } + + if ($this->renderer->isEnabled()) { $this->renderer->renderAt($x, $y); } + + foreach ($this->getChildren() as $child) { + $child->renderAt($x, $y); + } } /** @@ -498,7 +557,13 @@ public function isActive(): bool */ public function eraseAt(?int $x = null, ?int $y = null): void { - if ($this->isActive() && $this->renderer->isEnabled()) { + $children = array_reverse($this->getChildren()); + + foreach ($children as $child) { + $child->eraseAt($x, $y); + } + + if ($this->renderer->isEnabled()) { $this->renderer->eraseAt($x, $y); } } @@ -514,6 +579,10 @@ public function resume(): void $component->resume(); } } + + foreach ($this->getChildren() as $child) { + $child->resume(); + } } } @@ -523,6 +592,10 @@ public function resume(): void public function suspend(): void { if ($this->isActive()) { + foreach ($this->getChildren() as $child) { + $child->suspend(); + } + foreach ($this->components as $component) { if ($component->isEnabled()) { $component->suspend(); @@ -554,6 +627,10 @@ public function start(): void } } + foreach ($this->getChildren() as $child) { + $child->start(); + } + $this->starting = false; $this->started = true; } @@ -563,6 +640,10 @@ public function start(): void */ public function stop(): void { + foreach ($this->getChildren() as $child) { + $child->stop(); + } + if ($this->isActive()) { foreach ($this->components as $component) { if ($component->isEnabled()) { @@ -583,6 +664,10 @@ public function fixedUpdate(): void $component->fixedUpdate(); } } + + foreach ($this->getChildren() as $child) { + $child->fixedUpdate(); + } } } @@ -597,6 +682,10 @@ public function update(): void $component->update(); } } + + foreach ($this->getChildren() as $child) { + $child->update(); + } } } @@ -622,9 +711,17 @@ public function activate(): void */ public function render(): void { - if ($this->isActive() && $this->renderer->isEnabled()) { + if (!$this->isActive()) { + return; + } + + if ($this->renderer->isEnabled()) { $this->renderer->render(); } + + foreach ($this->getChildren() as $child) { + $child->render(); + } } /** @@ -646,8 +743,8 @@ public function deactivate(): void $this->suspend(); } + $this->erase(); $this->active = false; - $this->getRenderer()->erase(); } /** @@ -655,9 +752,7 @@ public function deactivate(): void */ public function erase(): void { - if ($this->isActive() && $this->renderer->isEnabled()) { - $this->renderer->erase(); - } + $this->eraseAt(); } /** @@ -669,11 +764,17 @@ public function erase(): void */ public function broadcast(string $methodName, array $args = []): void { + $arguments = array_is_list($args) ? $args : array_values($args); + foreach ($this->components as $component) { if (method_exists($component, $methodName)) { - $component->$methodName(...$args); + $component->$methodName(...$arguments); } } + + foreach ($this->getChildren() as $child) { + $child->broadcast($methodName, $arguments); + } } /** @@ -726,7 +827,78 @@ private function belongsToActiveScene(): bool return false; } - return in_array($this, $activeScene->getRootGameObjects(), true); + foreach ($activeScene->getRootGameObjects() as $gameObject) { + if ($gameObject instanceof GameObject && self::hierarchyContains($gameObject, $this)) { + return true; + } + } + + return false; + } + + /** + * Finds the first matching game object within a hierarchy branch. + * + * @param GameObject $gameObject + * @param callable(GameObject): bool $predicate + * @return GameObject|null + */ + private static function findFirstInHierarchy(GameObject $gameObject, callable $predicate): ?GameObject + { + if ($predicate($gameObject)) { + return $gameObject; + } + + foreach ($gameObject->getChildren() as $child) { + $match = self::findFirstInHierarchy($child, $predicate); + + if ($match !== null) { + return $match; + } + } + + return null; + } + + /** + * Collects every matching game object within a hierarchy branch. + * + * @param GameObject $gameObject + * @param callable(GameObject): bool $predicate + * @param array $matches + * @return void + */ + private static function collectHierarchyMatches(GameObject $gameObject, callable $predicate, array &$matches): void + { + if ($predicate($gameObject)) { + $matches[] = $gameObject; + } + + foreach ($gameObject->getChildren() as $child) { + self::collectHierarchyMatches($child, $predicate, $matches); + } + } + + /** + * Returns whether the target game object exists somewhere beneath the supplied root. + * + * @param GameObject $root + * @param GameObject $target + * @return bool + */ + private static function hierarchyContains(GameObject $root, GameObject $target): bool + { + if ($root === $target) { + return true; + } + + foreach ($root->getChildren() as $child) { + if (self::hierarchyContains($child, $target)) { + return true; + } + } + + return false; } /** diff --git a/src/Core/Rendering/Renderer.php b/src/Core/Rendering/Renderer.php index c1c69c2..b0302c3 100644 --- a/src/Core/Rendering/Renderer.php +++ b/src/Core/Rendering/Renderer.php @@ -81,8 +81,9 @@ public final function renderAt(?int $x = null, ?int $y = null): void return; } - $xOffset = $this->getGameObject()->getTransform()->getPosition()->getX() + ($x ?? 0); - $yOffset = $this->getGameObject()->getTransform()->getPosition()->getY() + ($y ?? 0); + $worldPosition = $this->getGameObject()->getTransform()->getWorldPosition(); + $xOffset = $worldPosition->getX() + ($x ?? 0); + $yOffset = $worldPosition->getY() + ($y ?? 0); $width = $this->sprite->getRect()->getWidth(); $height = $this->sprite->getRect()->getHeight(); $spriteBufferedImage = $this->sprite->getBufferedImage(); diff --git a/src/Core/Scenes/AbstractScene.php b/src/Core/Scenes/AbstractScene.php index dd2da12..b75bd02 100644 --- a/src/Core/Scenes/AbstractScene.php +++ b/src/Core/Scenes/AbstractScene.php @@ -2,6 +2,7 @@ namespace Sendama\Engine\Core\Scenes; +use Sendama\Engine\Core\GameObject; use Sendama\Engine\Core\Grid; use Sendama\Engine\Core\Interfaces\GameObjectInterface; use Sendama\Engine\Core\Rect; @@ -77,10 +78,15 @@ abstract class AbstractScene implements SceneInterface protected string $environmentCollisionMapData = ''; /** - * @var bool $started + * @var bool Determines whether the scene has started. */ protected bool $started = false; + /** + * @var SceneManager The scene manager that is managing this scene. + */ + protected SceneManager $sceneManager; + /** * Constructs a scene. * @@ -89,6 +95,7 @@ abstract class AbstractScene implements SceneInterface */ public final function __construct(protected string $name, protected ?object $sceneMetadata = null) { + $this->sceneManager = SceneManager::getInstance(); $this->worldsSpace = new Grid(); $this->collisionWorldSpace = new Grid(); $this->physics = Physics::getInstance(); @@ -124,16 +131,23 @@ private function loadEnvironmentTileMapData(?string $path = null): void } /** - * Loads the environment collision map data from a file on disk. + * Loads a generic environment map file from disk. * - * @param string|null $path The path to the environment collision map file. - * @return void - * @throws FileNotFoundException If the file does not exist. + * @param string $absolutePath + * @return string + * @throws FileNotFoundException */ - private function loadEnvironmentCollisionMapData(?string $path = null): void + private function loadEnvironmentMapData(string $absolutePath): string { - Debug::info("Loading environment collision map data: $path"); - $this->environmentCollisionMapData = $this->loadEnvironmentMapData($this->getAbsoluteEnvironmentMapPath($path)); + if (!file_exists($absolutePath)) { + throw new FileNotFoundException($absolutePath); + } + + if (!is_file($absolutePath)) { + throw new FileNotFoundException($absolutePath); + } + + return file_get_contents($absolutePath); } /** @@ -148,23 +162,16 @@ private function getAbsoluteEnvironmentMapPath(?string $path): string } /** - * Loads a generic environment map file from disk. + * Loads the environment collision map data from a file on disk. * - * @param string $absolutePath - * @return string - * @throws FileNotFoundException + * @param string|null $path The path to the environment collision map file. + * @return void + * @throws FileNotFoundException If the file does not exist. */ - private function loadEnvironmentMapData(string $absolutePath): string + private function loadEnvironmentCollisionMapData(?string $path = null): void { - if (!file_exists($absolutePath)) { - throw new FileNotFoundException($absolutePath); - } - - if (!is_file($absolutePath)) { - throw new FileNotFoundException($absolutePath); - } - - return file_get_contents($absolutePath); + Debug::info("Loading environment collision map data: $path"); + $this->environmentCollisionMapData = $this->loadEnvironmentMapData($this->getAbsoluteEnvironmentMapPath($path)); } /** @@ -410,7 +417,12 @@ public function add(GameObjectInterface|UIElementInterface $object): void { Debug::info('Adding game object ' . $object->getName()); if ($object instanceof GameObjectInterface) { - $this->rootGameObjects[] = $object; + $isParentedGameObject = $object instanceof GameObject && $object->getTransform()->hasParent(); + + if (!$isParentedGameObject && !in_array($object, $this->rootGameObjects, true)) { + $this->rootGameObjects[] = $object; + } + if ($collider = $object->getComponent(ColliderInterface::class)) { $this->physics->addCollider($collider); } @@ -529,18 +541,6 @@ private function loadStaticCollisionEnvironment(): void $this->physics->loadStaticCollisionMap($this->collisionWorldSpace); } - /** - * Sets the world space. - * - * @param Grid $worldSpace The new world space. - * @return void - */ - private function setWorldSpace(Grid $worldSpace): void - { - Debug::info('Setting world space for ' . $this->name); - $this->worldsSpace = $worldSpace; - } - /** * @inheritDoc */ @@ -548,25 +548,49 @@ public function remove(UIElementInterface|GameObjectInterface $object): void { Debug::info('Removing game object ' . $object->getName()); if ($object instanceof GameObjectInterface) { - $this->rootGameObjects = array_filter($this->rootGameObjects, fn($item) => $item !== $object, $this->rootGameObjects); - if ($collider = $object->getComponent('Collider')) { - $this->physics->removeCollider($collider); + $this->rootGameObjects = array_values( + array_filter($this->rootGameObjects, fn($item) => $item !== $object) + ); + + if ($object instanceof GameObject && $object->getTransform()->hasParent()) { + $object->getTransform()->setParent(null); + } + + foreach ($this->collectGameObjectHierarchy($object) as $gameObject) { + if ($collider = $gameObject->getComponent(ColliderInterface::class)) { + $this->physics->removeCollider($collider); + } } } else { - $this->uiElements = array_filter($this->uiElements, fn($item) => $item !== $object, $this->uiElements); + $this->uiElements = array_values( + array_filter($this->uiElements, fn($item) => $item !== $object) + ); } - if ($this->isStopped()) { + if ($this->isStarted()) { $object->stop(); } } /** - * @inheritDoc + * Flattens a game object branch so scene-level operations can handle descendants consistently. + * + * @param GameObjectInterface $object + * @return array */ - public function isStopped(): bool + private function collectGameObjectHierarchy(GameObjectInterface $object): array { - return !$this->isStarted(); + if (!$object instanceof GameObject) { + return []; + } + + $objects = [$object]; + + foreach ($object->getChildren() as $child) { + $objects = [...$objects, ...$this->collectGameObjectHierarchy($child)]; + } + + return $objects; } /** @@ -601,6 +625,14 @@ public function getCamera(): CameraInterface return $this->camera; } + /** + * @inheritDoc + */ + public function isStopped(): bool + { + return !$this->isStarted(); + } + /** * @inheritDoc */ @@ -621,6 +653,18 @@ public function getSceneManager(): SceneManager return SceneManager::getInstance(); } + /** + * Sets the world space. + * + * @param Grid $worldSpace The new world space. + * @return void + */ + private function setWorldSpace(Grid $worldSpace): void + { + Debug::info('Setting world space for ' . $this->name); + $this->worldsSpace = $worldSpace; + } + /** * Sets the collision world space. * diff --git a/src/Core/Scenes/SceneManager.php b/src/Core/Scenes/SceneManager.php index 2fb2d13..b8eaf32 100644 --- a/src/Core/Scenes/SceneManager.php +++ b/src/Core/Scenes/SceneManager.php @@ -27,11 +27,13 @@ use Sendama\Engine\Exceptions\Scenes\SceneManagementException; use Sendama\Engine\Exceptions\Scenes\SceneNotFoundException; use Sendama\Engine\IO\Console\Console; +use Sendama\Engine\IO\Enumerations\Color as EngineColor; use Sendama\Engine\Physics\Collider; use Sendama\Engine\Physics\PhysicsMaterial; use Sendama\Engine\Physics\Rigidbody; use Sendama\Engine\Physics\Interfaces\ColliderInterface; use Sendama\Engine\Physics\Physics; +use Sendama\Engine\UI\GUITexture\GUITexture; use Sendama\Engine\UI\Label\Label; use Sendama\Engine\UI\Text\Text; use Sendama\Engine\Util\Path; @@ -104,6 +106,34 @@ public function getActiveScene(): ?SceneInterface return $this->activeSceneNode?->getScene(); } + /** + * Checks whether a scene exists at the given index or name. + * + * @param int|string $index + * @return bool + */ + public function hasScene(int|string $index): bool + { + $sceneList = $this->scenes->toArray(); + + Debug::log(var_export([ + "index" => $index, + "total" => $this->scenes->count(), + "scenes" => array_map(fn(SceneInterface $scene) => $scene->getName(), $sceneList) + ], true)); + foreach ($sceneList as $i => $scene) { + if (is_int($index) && $i === $index) { + return true; + } + + if (is_string($index) && $scene->getName() === $index) { + return true; + } + } + + return false; + } + /** * Adds a scene to the SceneManager. * @@ -445,9 +475,17 @@ public function awake(): void default: $gameObject = match($item->type) { Label::class => new Label($this, $itemName, $position, $size), - Text::class => new Text($this, $itemName, $position, $size) + Text::class => new Text($this, $itemName, $position, $size), + GUITexture::class => new GUITexture($this, $itemName, $position, $size), + default => throw new SceneManagementException( + "Unsupported scene hierarchy item type: {$item->type}" + ), }; + if (isset($item->tag) && method_exists($gameObject, 'setTag')) { + $gameObject->setTag((string)$item->tag); + } + if (isset($item->text)) { if (!method_exists($gameObject, 'setText')) { throw new SceneManagementException("The 'text' property is not supported for game object of type: " . $item->type); @@ -455,6 +493,15 @@ public function awake(): void $gameObject->setText($item->text); } + + if ($gameObject instanceof GUITexture) { + $gameObject->setTexturePath( + SceneManager::extractTexturePathFromMetadata($item->texture ?? null) ?? '' + ); + $gameObject->setColor( + SceneManager::resolveColorMetadataValue($item->color ?? null) ?? EngineColor::WHITE + ); + } } $this->add($gameObject); @@ -531,6 +578,13 @@ public static function inflateGameObjectMetadata(object|array $itemMetadata): Ga } } + if (isset($item->children) && is_iterable($item->children)) { + foreach ($item->children as $childMetadata) { + $child = self::inflateGameObjectMetadata($childMetadata); + $child->getTransform()->setParent($gameObject->getTransform()); + } + } + return $gameObject; } @@ -616,6 +670,74 @@ private static function normalizeMetadata(object|array $metadata): object return json_decode(json_encode($metadata, JSON_UNESCAPED_SLASHES), false); } + /** + * Extracts a UI texture path from scene metadata. + * + * @param mixed $textureMetadata + * @return string|null + */ + public static function extractTexturePathFromMetadata(mixed $textureMetadata): ?string + { + if (is_string($textureMetadata)) { + $texturePath = trim($textureMetadata); + + return $texturePath !== '' ? $texturePath : null; + } + + if (is_array($textureMetadata)) { + $texturePath = $textureMetadata['path'] ?? null; + + return is_string($texturePath) && trim($texturePath) !== '' + ? trim($texturePath) + : null; + } + + if (is_object($textureMetadata)) { + $texturePath = $textureMetadata->path ?? null; + + return is_string($texturePath) && trim($texturePath) !== '' + ? trim($texturePath) + : null; + } + + return null; + } + + /** + * Resolves scene metadata into a runtime console color. + * + * @param mixed $colorMetadata + * @return EngineColor|null + */ + public static function resolveColorMetadataValue(mixed $colorMetadata): ?EngineColor + { + if ($colorMetadata instanceof EngineColor) { + return $colorMetadata; + } + + if (!is_string($colorMetadata) || trim($colorMetadata) === '') { + return null; + } + + $normalizedColor = strtoupper(str_replace([' ', '-'], '_', trim($colorMetadata))); + + foreach (EngineColor::cases() as $color) { + $normalizedCaseName = strtoupper($color->name); + $normalizedPhoneticName = strtoupper(str_replace([' ', '-'], '_', $color->getPhoneticName())); + $normalizedEscapeValue = strtoupper(trim($color->value)); + + if ( + $normalizedColor === $normalizedCaseName + || $normalizedColor === $normalizedPhoneticName + || $normalizedColor === $normalizedEscapeValue + ) { + return $color; + } + } + + return null; + } + /** * Applies editor/file-scene component metadata onto the instantiated component. * @@ -680,9 +802,137 @@ private static function hydrateSceneComponentPropertyValue(ReflectionProperty $p return self::loadPrefabFromPath($value); } + if (self::propertyAcceptsClass($property, Vector2::class)) { + return self::hydrateVector2PropertyValue($property, $value); + } + return $value; } + /** + * Hydrates scene metadata into a Vector2-compatible runtime value. + * + * @param ReflectionProperty $property + * @param mixed $value + * @return Vector2|null + */ + private static function hydrateVector2PropertyValue(ReflectionProperty $property, mixed $value): ?Vector2 + { + if ($value instanceof Vector2) { + return Vector2::getClone($value); + } + + if ($value === null) { + return null; + } + + $vectorPayload = self::extractVector2MetadataPayload($value); + + if (is_array($vectorPayload)) { + return Vector2::fromArray($vectorPayload); + } + + Debug::warn(sprintf( + "Unable to hydrate Vector2 property '%s::%s' from scene metadata; falling back to %s.", + $property->getDeclaringClass()->getName(), + $property->getName(), + self::propertyAllowsNull($property) ? 'null' : 'Vector2::zero()' + )); + + return self::propertyAllowsNull($property) ? null : Vector2::zero(); + } + + /** + * Attempts to normalize serialized vector metadata from arrays, objects, or legacy strings. + * + * @param mixed $value + * @return array{x: int, y: int}|null + */ + private static function extractVector2MetadataPayload(mixed $value): ?array + { + if ($value instanceof Vector2) { + return [ + 'x' => $value->getX(), + 'y' => $value->getY(), + ]; + } + + if (is_array($value)) { + if (array_is_list($value)) { + return [ + 'x' => (int)($value[0] ?? 0), + 'y' => (int)($value[1] ?? 0), + ]; + } + + if (array_key_exists('x', $value) || array_key_exists('y', $value)) { + return [ + 'x' => (int)($value['x'] ?? 0), + 'y' => (int)($value['y'] ?? 0), + ]; + } + + return null; + } + + if (is_object($value)) { + return self::extractVector2MetadataPayload((array)$value); + } + + if (!is_string($value)) { + return null; + } + + $normalizedValue = trim($value); + + if ($normalizedValue === '') { + return null; + } + + $decodedValue = json_decode($normalizedValue, true); + + if (is_array($decodedValue)) { + return self::extractVector2MetadataPayload($decodedValue); + } + + if ( + preg_match('/^\[\s*(-?\d+)\s*,\s*(-?\d+)\s*\]$/', $normalizedValue, $matches) === 1 + || preg_match('/^\s*(-?\d+)\s*,\s*(-?\d+)\s*$/', $normalizedValue, $matches) === 1 + ) { + return [ + 'x' => (int)$matches[1], + 'y' => (int)$matches[2], + ]; + } + + return null; + } + + /** + * Determines whether a typed property explicitly allows null values. + * + * @param ReflectionProperty $property + * @return bool + */ + private static function propertyAllowsNull(ReflectionProperty $property): bool + { + $type = $property->getType(); + + if ($type instanceof ReflectionNamedType) { + return $type->allowsNull(); + } + + if ($type instanceof ReflectionUnionType) { + foreach ($type->getTypes() as $namedType) { + if ($namedType instanceof ReflectionNamedType && $namedType->getName() === 'null') { + return true; + } + } + } + + return false; + } + /** * Determines whether the property type can accept the given class. * diff --git a/src/Core/Scenes/TitleScene.php b/src/Core/Scenes/TitleScene.php index 965306b..ed9906e 100644 --- a/src/Core/Scenes/TitleScene.php +++ b/src/Core/Scenes/TitleScene.php @@ -6,6 +6,7 @@ use Exception; use Sendama\Engine\Core\Rect; use Sendama\Engine\Core\Vector2; +use Sendama\Engine\Debug\Debug; use Sendama\Engine\IO\Enumerations\KeyCode; use Sendama\Engine\UI\Menus\Interfaces\MenuItemInterface; use Sendama\Engine\UI\Menus\Menu; @@ -73,6 +74,10 @@ class TitleScene extends AbstractScene * @var int $titleTopMargin */ protected int $titleTopMargin = 4; + /** + * @var int|string + */ + protected int|string $newGameSceneTarget = 1; /** * @inheritDoc @@ -80,7 +85,6 @@ class TitleScene extends AbstractScene */ public function awake(): void { - $this->sceneManager = SceneManager::getInstance(); $gameName = getGameName() ?? $this->name; $this->titleText = new Text( @@ -97,12 +101,11 @@ public function awake(): void } $this->menu = new Menu(title: $gameName, description: 'q:quit', dimensions: new Rect(new Vector2($this->getMenuLeftMargin(), $this->getMenuTopMargin()), new Vector2($this->menuWidth, $this->menuHeight)), cancelKey: [KeyCode::Q, KeyCode::q], onCancel: fn() => quitGame()); - $this->menu->addItem(new MenuItem(label: 'New Game', description: 'Start a new game', icon: '🎮', callback: function () { - loadScene(1); - })); + $this->menu->addItem(new MenuItem(label: 'New Game', description: 'Start a new game', icon: '🎮')); $this->menu->addItem(new MenuItem(label: 'Quit', description: 'Quit the game', icon: '🚪', callback: function () { quitGame(); })); + $this->configureNewGameMenuItem($this->newGameSceneTarget); $this->add($this->titleText); $this->add($this->menu); @@ -203,9 +206,8 @@ public function setBorderPack(BorderPackInterface $borderPack): self */ public function setNewGameSceneIndex(int $newGameSceneIndex): self { - $this->menu->getItemByIndex(0)->setCallback(function () use ($newGameSceneIndex) { - loadScene(max($newGameSceneIndex, 1)); - }); + $this->newGameSceneTarget = $newGameSceneIndex; + $this->configureNewGameMenuItem($newGameSceneIndex); return $this; } @@ -218,9 +220,8 @@ public function setNewGameSceneIndex(int $newGameSceneIndex): self */ public function setNewGameSceneIndexBySceneName(string $newGameSceneName): self { - $this->menu->getItemByIndex(0)->setCallback(function () use ($newGameSceneName) { - loadScene($newGameSceneName); - }); + $this->newGameSceneTarget = $newGameSceneName; + $this->configureNewGameMenuItem($newGameSceneName); return $this; } @@ -291,4 +292,41 @@ private function resolveDimension(mixed ...$values): int return DEFAULT_SCREEN_WIDTH; } + + /** + * @param int|string $sceneTarget + * @return void + */ + private function configureNewGameMenuItem(int|string $sceneTarget): void + { + $newGameItem = $this->menu->getItemByIndex(0); + + if (!$newGameItem) { + return; + } + + if ($this->sceneManager->hasScene($sceneTarget)) { + $newGameItem->setEnabled(true); + $newGameItem->setDescription('Start a new game'); + $newGameItem->setCallback(function () use ($sceneTarget) { + loadScene($sceneTarget); + }); + $this->menu->setActiveItemByIndex(max($this->menu->getActiveItemIndex(), 0)); + $this->menu->updateWindowContent(); + return; + } + + $newGameItem->setEnabled(false); + $newGameItem->setDescription('No playable scene configured'); + $newGameItem->setCallback(null); + + Debug::warn(sprintf( + 'Title scene "%s" could not enable "New Game" because scene target "%s" is not available.', + $this->getName(), + (string)$sceneTarget + )); + + $this->menu->setActiveItemByIndex(0); + $this->menu->updateWindowContent(); + } } diff --git a/src/Core/Texture.php b/src/Core/Texture.php index b80872e..001393c 100644 --- a/src/Core/Texture.php +++ b/src/Core/Texture.php @@ -93,6 +93,10 @@ protected function loadImage(): void $longestRow = 0; foreach ($imageMatrix as $row) { + if ($this->height > 0 && $height >= $this->height) { + break; + } + $chunks = Unicode::characters($row, $this->width < 1 ? null : $this->width); $width = $this->width < 1 ? count($chunks) : $this->width; $this->pixels[] = $chunks; diff --git a/src/Core/Transform.php b/src/Core/Transform.php index 6fa220c..7446087 100644 --- a/src/Core/Transform.php +++ b/src/Core/Transform.php @@ -2,8 +2,15 @@ namespace Sendama\Engine\Core; +use InvalidArgumentException; + class Transform extends Component { + /** + * @var array $children + */ + protected array $children = []; + /** * Transform constructor. * @@ -77,6 +84,45 @@ public function setPosition(Vector2 $position): void $this->position = $position; } + /** + * Returns the world-space position of the transform. + * + * @return Vector2 + */ + public function getWorldPosition(): Vector2 + { + if ($this->parent === null) { + return Vector2::getClone($this->position); + } + + $parentPosition = $this->parent->getWorldPosition(); + + return new Vector2( + $parentPosition->getX() + $this->position->getX(), + $parentPosition->getY() + $this->position->getY() + ); + } + + /** + * Sets the world-space position while keeping the current parent relationship. + * + * @param Vector2 $position + * @return void + */ + public function setWorldPosition(Vector2 $position): void + { + if ($this->parent === null) { + $this->setPosition(Vector2::getClone($position)); + return; + } + + $parentPosition = $this->parent->getWorldPosition(); + $this->position = new Vector2( + $position->getX() - $parentPosition->getX(), + $position->getY() - $parentPosition->getY() + ); + } + /** * Sets the rotation of the transform. * @@ -88,6 +134,45 @@ public function setRotation(Vector2 $rotation): void $this->rotation = $rotation; } + /** + * Returns the world-space rotation of the transform. + * + * @return Vector2 + */ + public function getWorldRotation(): Vector2 + { + if ($this->parent === null) { + return Vector2::getClone($this->rotation); + } + + $parentRotation = $this->parent->getWorldRotation(); + + return new Vector2( + $parentRotation->getX() + $this->rotation->getX(), + $parentRotation->getY() + $this->rotation->getY() + ); + } + + /** + * Sets the world-space rotation while keeping the current parent relationship. + * + * @param Vector2 $rotation + * @return void + */ + public function setWorldRotation(Vector2 $rotation): void + { + if ($this->parent === null) { + $this->setRotation(Vector2::getClone($rotation)); + return; + } + + $parentRotation = $this->parent->getWorldRotation(); + $this->rotation = new Vector2( + $rotation->getX() - $parentRotation->getX(), + $rotation->getY() - $parentRotation->getY() + ); + } + /** * Sets the scale of the transform. * @@ -105,9 +190,42 @@ public function setScale(Vector2 $scale): void * @param Transform|null $parent The parent of the transform. * @return void */ - public function setParent(?Transform $parent): void + public function setParent(?Transform $parent, bool $preserveWorldTransform = false): void { + if ($parent === $this->parent) { + return; + } + + if ($parent === $this) { + throw new InvalidArgumentException('A transform cannot be parented to itself.'); + } + + if ($parent !== null && $parent->isDescendantOf($this)) { + throw new InvalidArgumentException('A transform cannot be parented to one of its descendants.'); + } + + $worldPosition = $preserveWorldTransform ? $this->getWorldPosition() : null; + $worldRotation = $preserveWorldTransform ? $this->getWorldRotation() : null; + + if ($this->parent !== null) { + $this->parent->removeChild($this); + } + $this->parent = $parent; + + if ($this->parent !== null) { + $this->parent->addChild($this); + } + + if ($preserveWorldTransform) { + if ($worldPosition !== null) { + $this->setWorldPosition($worldPosition); + } + + if ($worldRotation !== null) { + $this->setWorldRotation($worldRotation); + } + } } /** @@ -119,4 +237,78 @@ public function getParent(): ?Transform { return $this->parent; } -} \ No newline at end of file + + /** + * Returns whether this transform currently has a parent. + * + * @return bool + */ + public function hasParent(): bool + { + return $this->parent !== null; + } + + /** + * Returns the direct child transforms. + * + * @return array + */ + public function getChildren(): array + { + return $this->children; + } + + /** + * Registers a child transform. + * + * @param Transform $child + * @return void + */ + private function addChild(Transform $child): void + { + foreach ($this->children as $existingChild) { + if ($existingChild === $child) { + return; + } + } + + $this->children[] = $child; + } + + /** + * Removes a child transform. + * + * @param Transform $child + * @return void + */ + private function removeChild(Transform $child): void + { + $this->children = array_values( + array_filter( + $this->children, + static fn (Transform $existingChild): bool => $existingChild !== $child + ) + ); + } + + /** + * Returns whether this transform descends from the given ancestor. + * + * @param Transform $ancestor + * @return bool + */ + private function isDescendantOf(Transform $ancestor): bool + { + $currentParent = $this->parent; + + while ($currentParent !== null) { + if ($currentParent === $ancestor) { + return true; + } + + $currentParent = $currentParent->getParent(); + } + + return false; + } +} diff --git a/src/Game.php b/src/Game.php index b2648dd..e91ac04 100644 --- a/src/Game.php +++ b/src/Game.php @@ -970,12 +970,7 @@ private function handoffToTmuxSessionIfAvailable(): bool $createExitCode = 0; exec( - sprintf( - 'tmux new-session -d -s %s -c %s %s', - escapeshellarg($sessionName), - escapeshellarg($workingDirectory), - escapeshellarg($command), - ), + self::buildTmuxSessionLaunchCommand($sessionName, $workingDirectory, $command, $this->isDebug()), result_code: $createExitCode, ); @@ -1072,6 +1067,40 @@ private static function buildTmuxRuntimeCommand(string $sessionName, array $argv return implode(' ', $commandParts); } + /** + * Builds the tmux bootstrap command used to create the managed runtime session. + * + * Non-debug runs disable the tmux status bar so the game gets the full terminal height. + * + * @param string $sessionName + * @param string $workingDirectory + * @param string $command + * @param bool $debugMode + * @return string + */ + private static function buildTmuxSessionLaunchCommand( + string $sessionName, + string $workingDirectory, + string $command, + bool $debugMode, + ): string { + $bootstrapCommand = sprintf( + 'tmux new-session -d -s %s -c %s %s', + escapeshellarg($sessionName), + escapeshellarg($workingDirectory), + escapeshellarg($command), + ); + + if ($debugMode) { + return $bootstrapCommand; + } + + return $bootstrapCommand . sprintf( + ' \; set-option -t %s status off', + escapeshellarg($sessionName), + ); + } + /** * Checks whether a tmux session already exists. * diff --git a/src/Physics/CharacterController.php b/src/Physics/CharacterController.php index 0f8cbee..0d980b2 100644 --- a/src/Physics/CharacterController.php +++ b/src/Physics/CharacterController.php @@ -105,8 +105,13 @@ private function dispatchCollision(string $methodName, CollisionInterface $colli { $contact = $collision->getContact(0); $otherCollider = $contact?->getOtherCollider(); + $triggerMethodName = $this->resolveTriggerMethodName($methodName); - $this->getGameObject()->broadcast($methodName, ['collision' => $collision]); + $this->getGameObject()->broadcast($methodName, [$collision]); + + if ($triggerMethodName !== null && $otherCollider !== null && ($this->isTrigger() || $otherCollider->isTrigger())) { + $this->getGameObject()->broadcast($triggerMethodName, [$otherCollider]); + } if ($otherCollider !== null) { $mirroredCollision = new Collision( @@ -120,12 +125,32 @@ private function dispatchCollision(string $methodName, CollisionInterface $colli ], ); - $otherCollider->getGameObject()->broadcast($methodName, ['collision' => $mirroredCollision]); + $otherCollider->getGameObject()->broadcast($methodName, [$mirroredCollision]); + + if ($triggerMethodName !== null && ($this->isTrigger() || $otherCollider->isTrigger())) { + $otherCollider->getGameObject()->broadcast($triggerMethodName, [$this]); + } } Debug::log("Collision for {$collision->getGameObject()->getName()} at " . $collision->getContact(0)?->getPoint()); } + /** + * Maps collision lifecycle methods onto their trigger equivalents. + * + * @param string $methodName + * @return string|null + */ + private function resolveTriggerMethodName(string $methodName): ?string + { + return match ($methodName) { + 'onCollisionEnter' => 'onTriggerEnter', + 'onCollisionStay' => 'onTriggerStay', + 'onCollisionExit' => 'onTriggerExit', + default => null, + }; + } + /** * Returns a stable key for the collision target. * diff --git a/src/Physics/ContactPoint.php b/src/Physics/ContactPoint.php index e39a127..2779364 100644 --- a/src/Physics/ContactPoint.php +++ b/src/Physics/ContactPoint.php @@ -65,11 +65,11 @@ public function getOtherCollider(): ?ColliderInterface public function getNormal(): Vector2 { if ($this->otherCollider === null) { - return Vector2::difference($this->point, $this->thisCollider->getTransform()->getPosition())->getNormalized(); + return Vector2::difference(Vector2::getClone($this->point), $this->thisCollider->getTransform()->getWorldPosition())->getNormalized(); } - $otherPosition = $this->otherCollider->getTransform()->getPosition(); - $thisPosition = $this->thisCollider->getTransform()->getPosition(); + $otherPosition = $this->otherCollider->getTransform()->getWorldPosition(); + $thisPosition = $this->thisCollider->getTransform()->getWorldPosition(); return Vector2::difference($otherPosition, $thisPosition)->getNormalized(); } @@ -82,12 +82,12 @@ public function getNormal(): Vector2 public function getSeparation(): float { if ($this->otherCollider === null) { - return Vector2::distance($this->thisCollider->getTransform()->getPosition(), $this->point); + return Vector2::distance($this->thisCollider->getTransform()->getWorldPosition(), $this->point); } return Vector2::distance( - $this->thisCollider->getTransform()->getPosition(), - $this->otherCollider->getTransform()->getPosition() + $this->thisCollider->getTransform()->getWorldPosition(), + $this->otherCollider->getTransform()->getWorldPosition() ); } } diff --git a/src/Physics/Physics.php b/src/Physics/Physics.php index e52c337..b630759 100644 --- a/src/Physics/Physics.php +++ b/src/Physics/Physics.php @@ -211,6 +211,10 @@ public function removeCollider(ColliderInterface $collider): void */ public function checkCollisions(ColliderInterface $collider, Vector2 $motion): array { + if (!$this->isColliderActiveForCollisionChecks($collider)) { + return []; + } + $collisions = []; $projectedBounds = $this->getProjectedBounds($collider, $motion); @@ -223,10 +227,14 @@ public function checkCollisions(ColliderInterface $collider, Vector2 $motion): a continue; } + if (!$this->isColliderActiveForCollisionChecks($otherCollider)) { + continue; + } + if ($projectedBounds->overlaps($otherCollider->getBoundingBox())) { $collisions[] = new Collision($otherCollider, [ new ContactPoint( - Vector2::sum($collider->getTransform()->getPosition(), $motion), + Vector2::sum($collider->getTransform()->getWorldPosition(), $motion), $collider, $otherCollider ) @@ -237,6 +245,20 @@ public function checkCollisions(ColliderInterface $collider, Vector2 $motion): a return $collisions; } + /** + * Determines whether a collider should participate in collision queries. + * + * Object pools keep colliders registered across deactivate/activate cycles, so inactive + * or disabled colliders must be skipped to avoid ghost collisions. + * + * @param ColliderInterface $collider + * @return bool + */ + private function isColliderActiveForCollisionChecks(ColliderInterface $collider): bool + { + return $collider->isEnabled() && $collider->getGameObject()->isActive(); + } + /** * Loads the static collision map. * @@ -274,7 +296,7 @@ public function isTouchingDynamicObject(Vector2 $position, ?ColliderInterface $i continue; } - $otherPosition = $collider->getTransform()->getPosition(); + $otherPosition = $collider->getTransform()->getWorldPosition(); if ($otherPosition->getX() === $position->getX() && $otherPosition->getY() === $position->getY()) { return true; diff --git a/src/Physics/Rigidbody.php b/src/Physics/Rigidbody.php index 2e8009d..e88d3d4 100644 --- a/src/Physics/Rigidbody.php +++ b/src/Physics/Rigidbody.php @@ -468,8 +468,8 @@ private function applyLinearMotion(float $targetPositionX, float $targetPosition private function applyAxisMotion(string $axis, float $targetPosition): float { $currentGrid = $axis === 'x' - ? $this->getTransform()->getPosition()->getX() - : $this->getTransform()->getPosition()->getY(); + ? $this->getTransform()->getWorldPosition()->getX() + : $this->getTransform()->getWorldPosition()->getY(); $desiredGrid = $this->gridCoordinateFromFloat($targetPosition); $remainingSteps = $desiredGrid - $currentGrid; @@ -593,12 +593,17 @@ private function broadcastCollisionEvent(string $methodName, CollisionInterface { $contact = $collision->getContact(0); $otherCollider = $contact?->getOtherCollider(); + $triggerMethodName = $this->resolveTriggerMethodName($methodName); if ($contact === null) { return; } - $this->getGameObject()->broadcast($methodName, ['collision' => $collision]); + $this->getGameObject()->broadcast($methodName, [$collision]); + + if ($triggerMethodName !== null && $otherCollider !== null && ($this->isTrigger() || $otherCollider->isTrigger())) { + $this->getGameObject()->broadcast($triggerMethodName, [$otherCollider]); + } if ($otherCollider === null) { return; @@ -615,7 +620,27 @@ private function broadcastCollisionEvent(string $methodName, CollisionInterface ], ); - $otherCollider->getGameObject()->broadcast($methodName, ['collision' => $mirroredCollision]); + $otherCollider->getGameObject()->broadcast($methodName, [$mirroredCollision]); + + if ($triggerMethodName !== null && ($this->isTrigger() || $otherCollider->isTrigger())) { + $otherCollider->getGameObject()->broadcast($triggerMethodName, [$this]); + } + } + + /** + * Maps collision lifecycle methods onto their trigger equivalents. + * + * @param string $methodName + * @return string|null + */ + private function resolveTriggerMethodName(string $methodName): ?string + { + return match ($methodName) { + 'onCollisionEnter' => 'onTriggerEnter', + 'onCollisionStay' => 'onTriggerStay', + 'onCollisionExit' => 'onTriggerExit', + default => null, + }; } /** @@ -656,7 +681,7 @@ private function applyRotationalMotion(float $targetRotationX, float $targetRota $this->simulatedRotationX = $targetRotationX; $this->simulatedRotationY = $targetRotationY; - $this->getTransform()->setRotation( + $this->getTransform()->setWorldRotation( new Vector2( (int)round($targetRotationX), (int)round($targetRotationY) @@ -672,8 +697,8 @@ private function applyRotationalMotion(float $targetRotationX, float $targetRota */ private function syncSimulationState(bool $force = false): void { - $position = $this->getTransform()->getPosition(); - $rotation = $this->getTransform()->getRotation(); + $position = $this->getTransform()->getWorldPosition(); + $rotation = $this->getTransform()->getWorldRotation(); if ( $force || @@ -705,7 +730,7 @@ private function restoreTransformPositionFromSimulationState(): void return; } - $this->getTransform()->setPosition( + $this->getTransform()->setWorldPosition( new Vector2( $this->gridCoordinateFromFloat($this->simulatedPositionX), $this->gridCoordinateFromFloat($this->simulatedPositionY), diff --git a/src/Physics/Strategies/AABBCollisionDetectionStrategy.php b/src/Physics/Strategies/AABBCollisionDetectionStrategy.php index 03e4489..410156e 100644 --- a/src/Physics/Strategies/AABBCollisionDetectionStrategy.php +++ b/src/Physics/Strategies/AABBCollisionDetectionStrategy.php @@ -29,8 +29,8 @@ public function isTouching(ColliderInterface $collider): bool 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($this->collider->getGameObject()->getName() . ' is at ' . $this->collider->getTransform()->getWorldPosition()); + Debug::log($collider->getGameObject()->getName() . ' is at ' . $collider->getTransform()->getWorldPosition()); return true; } diff --git a/src/Physics/Strategies/BasicCollisionDetectionStrategy.php b/src/Physics/Strategies/BasicCollisionDetectionStrategy.php index d5b0b57..e9644f0 100644 --- a/src/Physics/Strategies/BasicCollisionDetectionStrategy.php +++ b/src/Physics/Strategies/BasicCollisionDetectionStrategy.php @@ -26,11 +26,11 @@ public function isTouching(ColliderInterface $collider): bool return false; } - if ($this->collider->getTransform()->getPosition()->getX() !== $collider->getTransform()->getPosition()->getX()) { + if ($this->collider->getTransform()->getWorldPosition()->getX() !== $collider->getTransform()->getWorldPosition()->getX()) { return false; } - if ($this->collider->getTransform()->getPosition()->getY() !== $collider->getTransform()->getPosition()->getY()) { + if ($this->collider->getTransform()->getWorldPosition()->getY() !== $collider->getTransform()->getWorldPosition()->getY()) { return false; } diff --git a/src/Physics/Strategies/SeparationBasedCollisionDetectionStrategy.php b/src/Physics/Strategies/SeparationBasedCollisionDetectionStrategy.php index 2d7ba90..fa6a5d7 100644 --- a/src/Physics/Strategies/SeparationBasedCollisionDetectionStrategy.php +++ b/src/Physics/Strategies/SeparationBasedCollisionDetectionStrategy.php @@ -27,8 +27,8 @@ public function isTouching(ColliderInterface $collider): bool } return Vector2::distance( - $this->collider->getTransform()->getPosition(), - $collider->getTransform()->getPosition() + $this->collider->getTransform()->getWorldPosition(), + $collider->getTransform()->getWorldPosition() ) < 1; } } diff --git a/src/Physics/Traits/BoundTrait.php b/src/Physics/Traits/BoundTrait.php index 6a65dc7..bccd2c9 100644 --- a/src/Physics/Traits/BoundTrait.php +++ b/src/Physics/Traits/BoundTrait.php @@ -17,12 +17,9 @@ trait BoundTrait */ public function getBoundingBox(): Rect { - $x = $this->getTransform() - ->getPosition() - ->getX(); - $y = $this->getTransform() - ->getPosition() - ->getY(); + $worldPosition = $this->getTransform()->getWorldPosition(); + $x = $worldPosition->getX(); + $y = $worldPosition->getY(); return new Rect( new Vector2($x, $y), new Vector2( diff --git a/src/UI/GUITexture/GUITexture.php b/src/UI/GUITexture/GUITexture.php new file mode 100644 index 0000000..e8e240f --- /dev/null +++ b/src/UI/GUITexture/GUITexture.php @@ -0,0 +1,184 @@ +texturePath = $texturePath; + $this->color = $color; + + parent::__construct($scene, $name, $position, self::normalizeSize($size), $tag); + } + + public function awake(): void + { + $this->reloadTexture(); + } + + public function start(): void + { + // Do nothing. + } + + public function update(): void + { + // Do nothing. + } + + public function getTexturePath(): string + { + return $this->texturePath; + } + + public function setTexturePath(string $texturePath): void + { + $shouldRerender = $this->shouldRenderWithinScene(); + + if ($shouldRerender) { + $this->erase(); + } + + $this->texturePath = trim($texturePath); + $this->reloadTexture(); + + if ($shouldRerender) { + $this->render(); + } + } + + public function getColor(): Color + { + return $this->color; + } + + public function setColor(Color $color): void + { + $shouldRerender = $this->shouldRenderWithinScene(); + + if ($shouldRerender) { + $this->erase(); + } + + $this->color = $color; + + if ($shouldRerender) { + $this->render(); + } + } + + public function setSize(Vector2 $size): void + { + $shouldRerender = $this->shouldRenderWithinScene(); + + if ($shouldRerender) { + $this->erase(); + } + + parent::setSize(self::normalizeSize($size)); + $this->reloadTexture(); + + if ($shouldRerender) { + $this->render(); + } + } + + public function render(): void + { + $this->renderAt($this->position->getX(), $this->position->getY()); + } + + public function renderAt(?int $x = null, ?int $y = null): void + { + $renderedLines = $this->buildRenderedLines(); + + if ($renderedLines === []) { + return; + } + + Console::writeLines($renderedLines, $x ?? 0, $y ?? 0); + } + + public function erase(): void + { + $this->eraseAt($this->position->getX(), $this->position->getY()); + } + + public function eraseAt(?int $x = null, ?int $y = null): void + { + $width = $this->texture?->getWidth() ?? max(0, $this->size->getX()); + $height = $this->texture?->getHeight() ?? max(0, $this->size->getY()); + + if ($width <= 0 || $height <= 0) { + return; + } + + Console::writeLines( + array_fill(0, $height, str_repeat(' ', $width)), + $x ?? 0, + $y ?? 0, + ); + } + + private function reloadTexture(): void + { + if ($this->texturePath === '' || strcasecmp($this->texturePath, 'None') === 0) { + $this->texture = null; + return; + } + + $width = max(1, $this->size->getX()); + $height = max(1, $this->size->getY()); + + try { + $this->texture = new Texture($this->texturePath, $width, $height); + } catch (Throwable) { + $this->texture = null; + } + } + + private static function normalizeSize(Vector2 $size): Vector2 + { + return new Vector2( + max(1, $size->getX()), + max(1, $size->getY()), + ); + } + + private function buildRenderedLines(): array + { + if (!$this->texture instanceof Texture) { + return []; + } + + $lines = array_map( + static fn (array $row): string => implode('', $row), + $this->texture->getPixels(), + ); + + return array_map( + fn (string $line): string => Color::apply($this->color, $line), + $lines, + ); + } +} diff --git a/src/UI/Menus/Interfaces/MenuItemInterface.php b/src/UI/Menus/Interfaces/MenuItemInterface.php index d9803db..5a742e2 100644 --- a/src/UI/Menus/Interfaces/MenuItemInterface.php +++ b/src/UI/Menus/Interfaces/MenuItemInterface.php @@ -13,6 +13,21 @@ */ interface MenuItemInterface extends Stringable, ExecutableInterface { + /** + * Checks whether the menu item can currently be selected. + * + * @return bool + */ + public function isEnabled(): bool; + + /** + * Enables or disables the menu item. + * + * @param bool $enabled + * @return void + */ + public function setEnabled(bool $enabled): void; + /** * Returns the label of the menu item. * @@ -65,4 +80,4 @@ public function setDescription(string $description): void; * @return void */ public function setCallback(?Closure $callback = null): void; -} \ No newline at end of file +} diff --git a/src/UI/Menus/Menu.php b/src/UI/Menus/Menu.php index 085541d..a2e3383 100644 --- a/src/UI/Menus/Menu.php +++ b/src/UI/Menus/Menu.php @@ -24,6 +24,7 @@ use Sendama\Engine\UI\Windows\BorderPack; use Sendama\Engine\UI\Windows\Interfaces\BorderPackInterface; use Sendama\Engine\UI\Windows\Window; +use Sendama\Engine\Util\Unicode; /** * Class Menu. Represents a menu. @@ -32,672 +33,808 @@ */ class Menu implements MenuInterface { - /** - * @var bool $activated - */ - protected bool $activated = true; - /** - * @var MenuItemInterface|null $activeItem - */ - protected ?MenuItemInterface $activeItem = null; - /** - * @var ItemList $observers - */ - protected ItemList $observers; - /** - * @var ItemList $staticObservers - */ - protected ItemList $staticObservers; - /** - * @var MenuGraphNodeInterface|null $topSibling - */ - protected ?MenuGraphNodeInterface $topSibling = null; - /** - * @var MenuGraphNodeInterface|null $rightSibling - */ - protected ?MenuGraphNodeInterface $rightSibling = null; - /** - * @var MenuGraphNodeInterface|null $bottomSibling - */ - protected ?MenuGraphNodeInterface $bottomSibling = null; - /** - * @var MenuGraphNodeInterface|null $leftSibling - */ - protected ?MenuGraphNodeInterface $leftSibling = null; - /** - * @var Window $window - */ - protected Window $window; - /** - * @var bool $enabled - */ - protected bool $enabled = true; - /** - * @var bool $rememberCursorPosition - */ - protected bool $rememberCursorPosition = false; - /** - * @var int $savedCursorPosition - */ - protected int $savedCursorPosition = 0; - - /** - * Menu constructor. - * - * @param string $title The title of the menu. - * @param string $description The description of the menu. - * @param Rect $dimensions The dimensions of the menu. - * @param ItemList $items The items of the menu. - * @param string $cursor The cursor of the menu. - * @param Color $activeColor The active color of the menu. - * @param array|null $cancelKey The cancel key. - * @param Closure|null $onCancel The on cancel callback. - * @param bool $canNavigate Whether the menu can navigate or not. - */ - public function __construct( - string $title, - protected string $description = '', - protected Rect $dimensions = new Rect( - new Vector2(0, 0), - new Vector2(DEFAULT_MENU_WIDTH, DEFAULT_MENU_HEIGHT) - ), - protected ItemList $items = new ItemList(MenuItemInterface::class), - protected string $cursor = '>', - protected Color $activeColor = Color::BLUE, - protected ?array $cancelKey = null, - protected ?Closure $onCancel = null, - protected bool $canNavigate = true, - BorderPack $borderPack = new BorderPack('')) - { - if (!$this->canNavigate) { - $this->cursor = ''; - } - - $this->observers = new ItemList(ObserverInterface::class); - $this->staticObservers = new ItemList(StaticObserverInterface::class); - $this->window = new Window($title, $this->description, $this->dimensions->getPosition(), $this->dimensions->getWidth(), $this->dimensions->getHeight(), $borderPack); - - $this->awake(); - } - - /** - * @inheritDoc - */ - public function getPosition(): Vector2 - { - return $this->dimensions->getPosition(); - } - - /** - * @inheritDoc - */ - public function awake(): void - { - // Do nothing. - } - - /** - * Sets the border of the menu. - * - * @param BorderPackInterface $borderPack The border pack. - * @return void - */ - public function setBorderPack(BorderPackInterface $borderPack): void - { - $this->window->setBorderPack($borderPack); - } - - /** - * @inheritDoc - */ - public function render(): void - { - $this->window->render(); - } - - /** - * @inheritDoc - */ - public function renderAt(?int $x = null, ?int $y = null): void - { - $this->window->renderAt($x, $y); - } - - /** - * @inheritDoc - */ - public function eraseAt(?int $x = null, ?int $y = null): void - { - $this->window->eraseAt($x, $y); - } - - /** - * @inheritDoc - */ - public function update(): void - { - if ($this->canNavigate) { - $this->handleNavigation(); - - // Handle submitting the active item. - if (Input::isKeyDown(KeyCode::ENTER)) { - $this->getActiveItem()?->execute($this); - } - } - - // Handle cancel the menu. - if ($this->cancelKey && Input::isAnyKeyPressed($this->cancelKey)) { - $this->onCancel?->call($this); - } - } - - /** - * Handles navigation. - * - * @void - */ - private function handleNavigation(): void - { - $v = Input::getAxis(AxisName::VERTICAL); - - if ($v < 0) { - // Move up. - $this->setActiveItemByIndex(wrap($this->getActiveItemIndex() - 1, 0, $this->items->count() - 1)); - } - - if ($v > 0) { - // Move down - $this->setActiveItemByIndex(wrap($this->getActiveItemIndex() + 1, 0, $this->items->count() - 1)); - } - - // Update the window content - $this->updateWindowContent(); - } - - /** - * @inheritDoc - */ - public function setActiveItemByIndex(int $index): void - { - $this->activeItem = $this->getItemByIndex($index); - } - - /** - * @inheritDoc - */ - public function getItemByIndex(int $index): ?MenuItemInterface - { - return $this->items->toArray()[$index] ?? null; - } - - /** - * @inheritDoc - */ - public function getActiveItemIndex(): int - { - $index = -1; - - foreach ($this->items as $i => $item) { - if ($item === $this->activeItem) { - $index = $i; - break; - } - } - - return $index; - } - - /** - * Updates the content of the window. - */ - public function updateWindowContent(): void - { - $content = []; - - /** - * @var int $itemIndex - * @var MenuItemInterface $item - */ - foreach ($this->items as $itemIndex => $item) { - $output = ' ' . match (true) { - $item instanceof MenuControlInterface => $item, - default => $item->getLabel() - }; - - if ($itemIndex === $this->getActiveItemIndex()) { - $output = sprintf("%s %s", $this->cursor, match (true) { - $item instanceof MenuControlInterface => $item, - default => $item->getLabel() - }); - } - $content[] = $output; - } - - $this->window->setContent($content); - $this->notify(new MenuEvent(MenuEventType::UPDATE_CONTENT)); - } - - /** - * @inheritDoc - */ - public function getActiveItem(): ?MenuItemInterface - { - return $this->activeItem; - } - - /** - * @inheritDoc - */ - public function setActiveItem(MenuItemInterface $item): void - { - $this->activeItem = $item; - } - - /** - * @inheritDoc - */ - public function getDescription(): string - { - return $this->description; - } - - /** - * @inheritDoc - */ - public function setDescription(string $description): void - { - $this->description = $description; - } - - /** - * @inheritDoc - * - * @return ItemList - */ - public function getItems(): ItemList - { - return $this->items; - } - - /** - * @inheritDoc - * - * @param ItemList $items - */ - public function setItems(ItemList $items): void - { - $this->items = $items; - } - - /** - * @inheritDoc - */ - public function addItem(MenuItemInterface $item): void - { - $this->items->add($item); - if (!$this->getActiveItem()) { - $this->setActiveItem($item); - } - - if ($item instanceof MenuControlInterface) { - $item->addObservers($this); - } - - $this->updateWindowContent(); - } - - /** - * @inheritDoc - */ - public function addObservers(ObserverInterface|StaticObserverInterface|string ...$observers): void - { - foreach ($observers as $observer) { - if (is_object($observer)) { - if (get_class($observer) === ObserverInterface::class) { - $this->observers->add($observer); + /** + * @var bool $activated + */ + protected bool $activated = true; + /** + * @var MenuItemInterface|null $activeItem + */ + protected ?MenuItemInterface $activeItem = null; + /** + * @var ItemList $observers + */ + protected ItemList $observers; + /** + * @var ItemList $staticObservers + */ + protected ItemList $staticObservers; + /** + * @var MenuGraphNodeInterface|null $topSibling + */ + protected ?MenuGraphNodeInterface $topSibling = null; + /** + * @var MenuGraphNodeInterface|null $rightSibling + */ + protected ?MenuGraphNodeInterface $rightSibling = null; + /** + * @var MenuGraphNodeInterface|null $bottomSibling + */ + protected ?MenuGraphNodeInterface $bottomSibling = null; + /** + * @var MenuGraphNodeInterface|null $leftSibling + */ + protected ?MenuGraphNodeInterface $leftSibling = null; + /** + * @var Window $window + */ + protected Window $window; + /** + * @var bool $enabled + */ + protected bool $enabled = true; + /** + * @var bool $rememberCursorPosition + */ + protected bool $rememberCursorPosition = false; + /** + * @var int $savedCursorPosition + */ + protected int $savedCursorPosition = 0; + + public int $width { + get { + return $this->window->width; + } + } + + public int $height { + get { + return $this->window->height; + } + } + + /** + * Menu constructor. + * + * @param string $title The title of the menu. + * @param string $description The description of the menu. + * @param Rect $dimensions The dimensions of the menu. + * @param ItemList $items The items of the menu. + * @param string $cursor The cursor of the menu. + * @param Color $activeColor The active color of the menu. + * @param array|null $cancelKey The cancel key. + * @param Closure|null $onCancel The on cancel callback. + * @param bool $canNavigate Whether the menu can navigate or not. + */ + public function __construct( + string $title, + protected string $description = '', + protected Rect $dimensions = new Rect( + new Vector2(0, 0), + new Vector2(DEFAULT_MENU_WIDTH, DEFAULT_MENU_HEIGHT) + ), + protected ItemList $items = new ItemList(MenuItemInterface::class), + protected string $cursor = '>', + protected Color $activeColor = Color::BLUE, + protected ?array $cancelKey = null, + protected ?Closure $onCancel = null, + protected bool $canNavigate = true, + BorderPack $borderPack = new BorderPack(''), + protected Color $disabledColor = Color::DARK_GRAY + ) + { + if (!$this->canNavigate) { + $this->cursor = ''; + } + + $this->observers = new ItemList(ObserverInterface::class); + $this->staticObservers = new ItemList(StaticObserverInterface::class); + $this->window = new Window($title, $this->description, $this->dimensions->getPosition(), $this->dimensions->getWidth(), $this->dimensions->getHeight(), $borderPack); + + $this->awake(); + } + + /** + * @inheritDoc + */ + public function getPosition(): Vector2 + { + return $this->dimensions->getPosition(); + } + + /** + * @inheritDoc + */ + public function awake(): void + { + // Do nothing. + } + + /** + * Sets the border of the menu. + * + * @param BorderPackInterface $borderPack The border pack. + * @return void + */ + public function setBorderPack(BorderPackInterface $borderPack): void + { + $this->window->setBorderPack($borderPack); + } + + /** + * @inheritDoc + */ + public function render(): void + { + $this->window->render(); + } + + /** + * @inheritDoc + */ + public function renderAt(?int $x = null, ?int $y = null): void + { + $this->window->renderAt($x, $y); + } + + /** + * @inheritDoc + */ + public function eraseAt(?int $x = null, ?int $y = null): void + { + $this->window->eraseAt($x, $y); + } + + /** + * @inheritDoc + */ + public function update(): void + { + if ($this->canNavigate) { + $this->handleNavigation(); + + // Handle submitting the active item. + if (Input::isKeyDown(KeyCode::ENTER)) { + $activeItem = $this->getActiveItem(); + + if ($activeItem?->isEnabled()) { + $activeItem->execute($this); + } + } + } + + // Handle cancel the menu. + if ($this->cancelKey && Input::isAnyKeyPressed($this->cancelKey)) { + $this->onCancel?->call($this); + } + } + + /** + * Handles navigation. + * + * @void + */ + private function handleNavigation(): void + { + if ($this->items->count() === 0) { + $this->activeItem = null; + $this->updateWindowContent(); + return; + } + + if (!$this->hasEnabledItems()) { + $this->activeItem = null; + $this->updateWindowContent(); + return; + } + + if (!$this->activeItem?->isEnabled()) { + $this->activeItem = $this->findFirstEnabledItem(); + } + + $v = Input::getAxis(AxisName::VERTICAL); + $currentIndex = $this->getActiveItemIndex(); + + if ($v < 0 && $currentIndex >= 0) { + // Move up. + $nextIndex = $this->findNextEnabledIndex($currentIndex, -1); + + if ($nextIndex !== null) { + $this->setActiveItemByIndex($nextIndex); + } + } + + if ($v > 0 && $currentIndex >= 0) { + // Move down + $nextIndex = $this->findNextEnabledIndex($currentIndex, 1); + + if ($nextIndex !== null) { + $this->setActiveItemByIndex($nextIndex); + } + } + + // Update the window content + $this->updateWindowContent(); + } + + /** + * Updates the content of the window. + */ + public function updateWindowContent(): void + { + $content = []; + + /** + * @var int $itemIndex + * @var MenuItemInterface $item + */ + foreach ($this->items as $itemIndex => $item) { + $label = match (true) { + $item instanceof MenuControlInterface => (string)$item, + default => $item->getLabel() + }; + + if (!$item->isEnabled()) { + $content[] = $this->wrapWithColor(" $label", $this->disabledColor); + continue; + } + + if ($item->isEnabled() && $itemIndex === $this->getActiveItemIndex()) { + $content[] = $this->wrapWithColor(sprintf("%s %s", $this->cursor, $label), $this->activeColor); + continue; + } + + $content[] = " $label"; + } + + $this->window->setContent($content); + $this->notify(new MenuEvent(MenuEventType::UPDATE_CONTENT)); + } + + /** + * @inheritDoc + */ + public function getActiveItemIndex(): int + { + $index = -1; + + foreach ($this->items as $i => $item) { + if ($item === $this->activeItem) { + $index = $i; + break; + } + } + + return $index; + } + + /** + * @inheritDoc + */ + public function notify(EventInterface $event): void + { + foreach ($this->observers as $observer) { + if (is_object($observer)) { + if (get_class($observer) === ObserverInterface::class) { + $observer->onNotify($this, $event); + } + } + + if (get_class($observer) === StaticObserverInterface::class) { + $observer::onNotify($this, $event); + } + } + } + + /** + * @inheritDoc + */ + public function onNotify(ObservableInterface $observable, EventInterface $event): void + { + if ($event instanceof MenuEvent) { + $this->updateWindowContent(); + } + } + + /** + * @return bool + */ + private function hasEnabledItems(): bool + { + foreach ($this->items as $item) { + if ($item->isEnabled()) { + return true; + } + } + + return false; + } + + /** + * @return MenuItemInterface|null + */ + private function findFirstEnabledItem(): ?MenuItemInterface + { + foreach ($this->items as $item) { + if ($item->isEnabled()) { + return $item; + } + } + + return null; + } + + /** + * @param int $currentIndex + * @param int $direction + * @return int|null + */ + private function findNextEnabledIndex(int $currentIndex, int $direction): ?int + { + $itemCount = $this->items->count(); + + if ($itemCount === 0 || !$this->hasEnabledItems()) { + return null; + } + + for ($step = 1; $step <= $itemCount; $step++) { + $candidateIndex = wrap($currentIndex + ($direction * $step), 0, $itemCount - 1); + $candidate = $this->getItemByIndex($candidateIndex); + + if ($candidate?->isEnabled()) { + return $candidateIndex; + } + } + + return null; + } + + /** + * @inheritDoc + */ + public function getItemByIndex(int $index): ?MenuItemInterface + { + return $this->items->toArray()[$index] ?? null; + } + + /** + * @inheritDoc + */ + public function setActiveItemByIndex(int $index): void + { + $item = $this->getItemByIndex($index); + + if ($item?->isEnabled()) { + $this->activeItem = $item; + return; + } + + $this->activeItem = $this->findFirstEnabledItem(); + } + + /** + * @inheritDoc + */ + public function getActiveItem(): ?MenuItemInterface + { + return $this->activeItem; + } + + /** + * @inheritDoc + */ + public function setActiveItem(MenuItemInterface $item): void + { + $this->activeItem = $item; + } + + /** + * @inheritDoc + */ + public function getDescription(): string + { + return $this->description; + } + + /** + * @inheritDoc + */ + public function setDescription(string $description): void + { + $this->description = $description; + } + + /** + * @inheritDoc + * + * @return ItemList + */ + public function getItems(): ItemList + { + return $this->items; + } + + /** + * @inheritDoc + * + * @param ItemList $items + */ + public function setItems(ItemList $items): void + { + $this->items = $items; + } + + /** + * @inheritDoc + */ + public function addItem(MenuItemInterface $item): void + { + $this->items->add($item); + if (!$this->getActiveItem() || !$this->getActiveItem()?->isEnabled()) { + $this->activeItem = $this->findFirstEnabledItem(); } - if (get_class($observer) === StaticObserverInterface::class) { - $this->staticObservers->add($observer); + if ($item instanceof MenuControlInterface) { + $item->addObservers($this); } - } - } - } - - /** - * @inheritDoc - */ - public function removeItem(MenuItemInterface $item): void - { - $this->items->remove($item); - } - - /** - * @inheritDoc - */ - public function removeItemByIndex(int $index): void - { - $this->items->removeAt($index); - } - - /** - * @inheritDoc - */ - public function setActiveItemByLabel(string $label): void - { - if ($item = $this->getItemByLabel($label)) { - $this->activeItem = $item; - } - } - - /** - * @inheritDoc - */ - public function getItemByLabel(string $label): ?MenuItemInterface - { - return $this->items->find(fn(MenuItemInterface $item) => $item->getLabel() === $label); - } - - /** - * @inheritDoc - */ - public static function find(string $uiElementName): ?self - { - /** @var ?Menu $element */ - $element = self::findAll($uiElementName)[0] ?? null; - return $element; - } - - /** - * @inheritDoc - */ - public static function findAll(string $uiElementName): array - { - $elements = []; - - foreach (SceneManager::getInstance()->getActiveScene()?->getUIElements() ?? [] as $element) { - if ($element instanceof MenuInterface && $element->getName() === $uiElementName) { - $elements[] = $element; - } - } - - return $elements; - } - - /** - * @inheritDoc - */ - public function getName(): string - { - return $this->getTitle(); - } - - /** - * @inheritDoc - */ - public function getTitle(): string - { - return $this->window->getTitle(); - } - - /** - * @inheritDoc - */ - public function removeObservers(ObserverInterface|StaticObserverInterface|string|null ...$observers): void - { - foreach ($observers as $observer) { - if (is_object($observer)) { - if (get_class($observer) === ObserverInterface::class) { - $this->observers->remove($observer); + + $this->updateWindowContent(); + } + + /** + * @inheritDoc + */ + public function addObservers(ObserverInterface|StaticObserverInterface|string ...$observers): void + { + foreach ($observers as $observer) { + if (is_object($observer)) { + if (get_class($observer) === ObserverInterface::class) { + $this->observers->add($observer); + } + + if (get_class($observer) === StaticObserverInterface::class) { + $this->staticObservers->add($observer); + } + } } + } - if (get_class($observer) === StaticObserverInterface::class) { - $this->staticObservers->remove($observer); + /** + * @inheritDoc + */ + public function removeItem(MenuItemInterface $item): void + { + $this->items->remove($item); + + if ($this->activeItem === $item) { + $this->activeItem = $this->findFirstEnabledItem(); + } + } + + /** + * @inheritDoc + */ + public function removeItemByIndex(int $index): void + { + $removedItem = $this->getItemByIndex($index); + $this->items->removeAt($index); + + if ($removedItem !== null && $this->activeItem === $removedItem) { + $this->activeItem = $this->findFirstEnabledItem(); } - } - } - } - - /** - * @inheritDoc - */ - public function notify(EventInterface $event): void - { - foreach ($this->observers as $observer) { - if (is_object($observer)) { - if (get_class($observer) === ObserverInterface::class) { - $observer->onNotify($this, $event); + } + + /** + * @inheritDoc + */ + public function setActiveItemByLabel(string $label): void + { + if (($item = $this->getItemByLabel($label)) && $item->isEnabled()) { + $this->activeItem = $item; + } + } + + /** + * @inheritDoc + */ + public function getItemByLabel(string $label): ?MenuItemInterface + { + return $this->items->find(fn(MenuItemInterface $item) => $item->getLabel() === $label); + } + + /** + * @inheritDoc + */ + public static function find(string $uiElementName): ?self + { + /** @var ?Menu $element */ + $element = self::findAll($uiElementName)[0] ?? null; + return $element; + } + + /** + * @inheritDoc + */ + public static function findAll(string $uiElementName): array + { + $elements = []; + + foreach (SceneManager::getInstance()->getActiveScene()?->getUIElements() ?? [] as $element) { + if ($element instanceof MenuInterface && $element->getName() === $uiElementName) { + $elements[] = $element; + } + } + + return $elements; + } + + /** + * @inheritDoc + */ + public function getName(): string + { + return $this->getTitle(); + } + + /** + * @inheritDoc + */ + public function getTitle(): string + { + return $this->window->getTitle(); + } + + /** + * @inheritDoc + */ + public function removeObservers(ObserverInterface|StaticObserverInterface|string|null ...$observers): void + { + foreach ($observers as $observer) { + if (is_object($observer)) { + if (get_class($observer) === ObserverInterface::class) { + $this->observers->remove($observer); + } + + if (get_class($observer) === StaticObserverInterface::class) { + $this->staticObservers->remove($observer); + } + } } - } - - if (get_class($observer) === StaticObserverInterface::class) { - $observer::onNotify($this, $event); - } - } - } - - /** - * @inheritDoc - */ - public function onNotify(ObservableInterface $observable, EventInterface $event): void - { - if ($event instanceof MenuEvent) { - $this->updateWindowContent(); - } - } - - /** - * @inheritDoc - */ - public function onFocus(EventInterface $event): void - { - $this->resume(); - } - - /** - * @inheritDoc - */ - public function resume(): void - { - $this->setActiveItemByIndex(0); - $this->updateWindowContent(); - } - - /** - * @inheritDoc - */ - public function onBlur(EventInterface $event): void - { - $this->suspend(); - } - - /** - * @inheritDoc - */ - public function suspend(): void - { - $this->erase(); - } - - /** - * @inheritDoc - */ - public function erase(): void - { - $this->window->erase(); - } - - /** - * @inheritDoc - */ - public function getTop(): ?MenuGraphNodeInterface - { - return $this->topSibling; - } - - /** - * @inheritDoc - */ - public function setTop(?MenuGraphNodeInterface $top): void - { - $this->topSibling = $top; - } - - /** - * @inheritDoc - */ - public function getRight(): ?MenuGraphNodeInterface - { - return $this->rightSibling; - } - - /** - * @inheritDoc - */ - public function setRight(?MenuGraphNodeInterface $right): void - { - $this->rightSibling = $right; - } - - /** - * @inheritDoc - */ - public function getBottom(): ?MenuGraphNodeInterface - { - return $this->bottomSibling; - } - - /** - * @inheritDoc - */ - public function setBottom(?MenuGraphNodeInterface $bottom): void - { - $this->bottomSibling = $bottom; - } - - /** - * @inheritDoc - */ - public function getLeft(): ?MenuGraphNodeInterface - { - return $this->leftSibling; - } - - /** - * @inheritDoc - */ - public function setLeft(?MenuGraphNodeInterface $left): void - { - $this->leftSibling = $left; - } - - /** - * @inheritDoc - */ - public function getMenu(): ?MenuInterface - { - return $this; - } - - public function setCursor(string $cursor): void - { - $this->cursor = $cursor; - } - - public function setActiveColor(Color $color): void - { - $this->activeColor = $color; - } - - /** - * @inheritDoc - */ - public function getArgs(): array - { - return ['title' => $this->getTitle(), 'description' => $this->description, 'dimensions' => $this->dimensions, 'items' => $this->items, 'cursor' => $this->cursor, 'active_color' => $this->activeColor,]; - } - - /** - * @inheritDoc - */ - public function setName(string $name): void - { - $this->setTitle($name); - } - - /** - * @inheritDoc - */ - public function setTitle(string $title): void - { - $this->window->setTitle($title); - } - - /** - * @inheritDoc - */ - public function activate(): void - { - $this->activated = true; - $this->start(); - } - - /** - * @inheritDoc - */ - public function start(): void - { - // Do nothing - } - - /** - * @inheritDoc - */ - public function deactivate(): void - { - $this->activated = false; - $this->stop(); - } - - /** - * @inheritDoc - */ - public function stop(): void - { - // Do nothing - } - - /** - * @inheritDoc - */ - public function isActive(): bool - { - return $this->activated; - } - - /** - * @inheritDoc - */ - public function setPosition(Vector2 $position): void - { - $this->dimensions->setX($position->getX()); - $this->dimensions->setY($position->getY()); - } - - /** - * @inheritDoc - */ - public function getSize(): Vector2 - { - return $this->dimensions->getSize(); - } - - /** - * @inheritDoc - */ - public function setSize(Vector2 $size): void - { - $this->dimensions->setWidth($size->getX()); - $this->dimensions->setHeight($size->getY()); - } -} \ No newline at end of file + } + + /** + * @inheritDoc + */ + public function onFocus(EventInterface $event): void + { + $this->resume(); + } + + /** + * @inheritDoc + */ + public function resume(): void + { + $this->setActiveItemByIndex(0); + $this->updateWindowContent(); + } + + /** + * @inheritDoc + */ + public function onBlur(EventInterface $event): void + { + $this->suspend(); + } + + /** + * @inheritDoc + */ + public function suspend(): void + { + $this->erase(); + } + + /** + * @inheritDoc + */ + public function erase(): void + { + $this->window->erase(); + } + + /** + * @inheritDoc + */ + public function getTop(): ?MenuGraphNodeInterface + { + return $this->topSibling; + } + + /** + * @inheritDoc + */ + public function setTop(?MenuGraphNodeInterface $top): void + { + $this->topSibling = $top; + } + + /** + * @inheritDoc + */ + public function getRight(): ?MenuGraphNodeInterface + { + return $this->rightSibling; + } + + /** + * @inheritDoc + */ + public function setRight(?MenuGraphNodeInterface $right): void + { + $this->rightSibling = $right; + } + + /** + * @inheritDoc + */ + public function getBottom(): ?MenuGraphNodeInterface + { + return $this->bottomSibling; + } + + /** + * @inheritDoc + */ + public function setBottom(?MenuGraphNodeInterface $bottom): void + { + $this->bottomSibling = $bottom; + } + + /** + * @inheritDoc + */ + public function getLeft(): ?MenuGraphNodeInterface + { + return $this->leftSibling; + } + + /** + * @inheritDoc + */ + public function setLeft(?MenuGraphNodeInterface $left): void + { + $this->leftSibling = $left; + } + + /** + * @inheritDoc + */ + public function getMenu(): ?MenuInterface + { + return $this; + } + + public function setCursor(string $cursor): void + { + $this->cursor = $cursor; + } + + public function setActiveColor(Color $color): void + { + $this->activeColor = $color; + } + + /** + * @inheritDoc + */ + public function getArgs(): array + { + return ['title' => $this->getTitle(), 'description' => $this->description, 'dimensions' => $this->dimensions, 'items' => $this->items, 'cursor' => $this->cursor, 'active_color' => $this->activeColor,]; + } + + /** + * @inheritDoc + */ + public function setName(string $name): void + { + $this->setTitle($name); + } + + /** + * @inheritDoc + */ + public function setTitle(string $title): void + { + $this->window->setTitle($title); + } + + /** + * @inheritDoc + */ + public function activate(): void + { + $this->activated = true; + $this->start(); + } + + /** + * @inheritDoc + */ + public function start(): void + { + // Do nothing + } + + /** + * @inheritDoc + */ + public function deactivate(): void + { + $this->activated = false; + $this->stop(); + } + + /** + * @inheritDoc + */ + public function stop(): void + { + // Do nothing + } + + /** + * @inheritDoc + */ + public function isActive(): bool + { + return $this->activated; + } + + /** + * @inheritDoc + */ + public function setPosition(Vector2 $position): void + { + $this->dimensions->setX($position->getX()); + $this->dimensions->setY($position->getY()); + } + + /** + * @inheritDoc + */ + public function getSize(): Vector2 + { + return $this->dimensions->getSize(); + } + + /** + * @inheritDoc + */ + public function setSize(Vector2 $size): void + { + $this->dimensions->setWidth($size->getX()); + $this->dimensions->setHeight($size->getY()); + } + + private function decorateContentLine(string $line, ?Color $color, int $lineIndex): string + { + return $this->wrapWithColor($line, $color); + } + + /** + * Wraps the given content in the given ANSI color sequence if the content is not empty and the color is not null. + * @param string $content The content to be wrapped. + * @param Color|null $color The color sequence to wrap the content in. + * @return string The color wrapped content terminated by the RESET color sequence if a color was given. + */ + private function wrapWithColor(string $content, ?Color $color): string + { + if ($content === '' || $color === null) { + return $content; + } + + return $color->value . $content . Color::RESET->value; + } +} diff --git a/src/UI/Menus/MenuItems/MenuItem.php b/src/UI/Menus/MenuItems/MenuItem.php index ea2eab8..0d70267 100644 --- a/src/UI/Menus/MenuItems/MenuItem.php +++ b/src/UI/Menus/MenuItems/MenuItem.php @@ -25,16 +25,37 @@ public function __construct( protected string $label, protected string $description = '', protected string $icon = '', - protected ?Closure $callback = null + protected ?Closure $callback = null, + protected bool $enabled = true ) { } + /** + * @inheritDoc + */ + public function isEnabled(): bool + { + return $this->enabled; + } + + /** + * @inheritDoc + */ + public function setEnabled(bool $enabled): void + { + $this->enabled = $enabled; + } + /** * @inheritDoc */ public function execute(?ExecutionContextInterface $context = null): void { + if (!$this->enabled) { + return; + } + $this->callback?->call($context ?? $this); } @@ -104,4 +125,4 @@ public function __toString(): string { return sprintf("%s %s", $this->icon, $this->label); } -} \ No newline at end of file +} diff --git a/src/UI/Windows/Window.php b/src/UI/Windows/Window.php index 4134cb1..65f49e3 100644 --- a/src/UI/Windows/Window.php +++ b/src/UI/Windows/Window.php @@ -4,7 +4,6 @@ use Assegai\Collections\ItemList; use Sendama\Engine\Core\Vector2; -use Sendama\Engine\Debug\Debug; use Sendama\Engine\Events\Interfaces\EventInterface; use Sendama\Engine\Events\Interfaces\ObserverInterface; use Sendama\Engine\Events\Interfaces\StaticObserverInterface; @@ -23,466 +22,499 @@ */ class Window implements WindowInterface { - /** - * @var string[] $content - */ - protected array $content = []; - /** - * @var ItemList $observers - */ - protected ItemList $observers; - - /** - * @var ItemList $staticObservers - */ - protected ItemList $staticObservers; - /** - * @var Cursor $cursor - */ - protected Cursor $cursor; - - /** - * Constructs a window. - * - * @param string $title The window title. - * @param string $help The window's help message. - * @param Vector2 $position The position of the window. - * @param int $width The width of the window. - * @param int $height The height of the window. - * @param BorderPackInterface $borderPack The border pack to use when rendering the window border. - * @param WindowAlignment $alignment The window's alignment. - * @param WindowPadding $padding The window's padding. - * @param Color $backgroundColor The window's background color. - * @param Color|null $foregroundColor The window's foreground color. - */ - public function __construct( - protected string $title = '', - protected string $help = '', - protected Vector2 $position = new Vector2(), - protected int $width = DEFAULT_WINDOW_WIDTH, - protected int $height = DEFAULT_WINDOW_HEIGHT, - protected BorderPackInterface $borderPack = new BorderPack(''), - protected WindowAlignment $alignment = new WindowAlignment(HorizontalAlignment::LEFT, VerticalAlignment::MIDDLE), - protected WindowPadding $padding = new WindowPadding(rightPadding: 1, leftPadding: 1), - protected Color $backgroundColor = Color::BLACK, - protected ?Color $foregroundColor = null - ) - { - $this->cursor = Console::cursor(); - $this->observers = new ItemList(ObserverInterface::class); - } - - /** - * @inheritDoc - */ - public function getTitle(): string - { - return substr($this->title, 0, $this->width - 3); - } - - /** - * @inheritDoc - */ - public function setTitle(string $title): void - { - $this->title = $title; - } - - /** - * @inheritDoc - */ - public function getHelp(): string - { - return substr($this->help, 0, $this->width - 3); - } - - /** - * @inheritDoc - */ - public function setHelp(string $help): void - { - $this->help = $help; - } - - /** - * @inheritDoc - */ - public function setPosition(Vector2|array $position): void - { - $this->position = is_array($position) ? Vector2::fromArray($position) : $position; - } - - /** - * @inheritDoc - */ - public function getPosition(): Vector2 - { - return $this->position; - } - - /** - * @inheritDoc - */ - public function getBorderPack(): BorderPackInterface - { - return $this->borderPack; - } - - /** - * @inheritDoc - */ - public function setBorderPack(BorderPackInterface $borderPack): void - { - $this->borderPack = $borderPack; - } - - /** - * @inheritDoc - */ - public function getAlignment(): WindowAlignment - { - return $this->alignment; - } - - /** - * @inheritDoc - */ - public function setAlignment(WindowAlignment $alignment): void - { - $this->alignment = $alignment; - } - - /** - * @inheritDoc - */ - public function getBackgroundColor(): Color - { - return $this->backgroundColor; - } - - /** - * @inheritDoc - */ - public function setBackgroundColor(Color $backgroundColor): void - { - $this->backgroundColor = $backgroundColor; - } - - /** - * @inheritDoc - */ - public function getForegroundColor(): ?Color - { - return $this->foregroundColor; - } - - /** - * @inheritDoc - */ - public function setForegroundColor(Color $color): void - { - $this->foregroundColor = $color; - } - - /** - * @inheritDoc - */ - public function getContent(): array - { - return $this->content; - } - - /** - * @inheritDoc - */ - public function setContent(array $content): void - { - $this->content = $content; - } - - /** - * @inheritDoc - */ - public function render(): void - { - $this->renderAt(0, 0); - } - - /** - * @inheritDoc - */ - public function renderAt(?int $x = null, ?int $y = null): void - { - $leftMargin = $this->position->getX() + ($x ?? 0); - $topMargin = $this->position->getY() + ($y ?? 0); - - // Render the top border - $topBorderHeight = 1; - $output = $this->getTopBorder(); - Console::writeLine($output, $leftMargin, $topMargin); - - // Render content - $linesOfContent = $this->getLinesOfContent(); - if (!$linesOfContent) { - $linesOfContent = ['']; - } - - foreach ($linesOfContent as $index => $line) { - Console::writeLine( - mb_substr($line, 0, $this->width), - $leftMargin, - $topMargin + $index + $topBorderHeight - ); - } - - // Render the bottom border - $topMargin = $topMargin + count($linesOfContent) + $topBorderHeight; - $output = $this->getBottomBorder(); - Console::writeLine($output, $leftMargin, $topMargin); - } - - /** - * @inheritDoc - */ - public function erase(): void - { - $this->eraseAt(0, 0); - } - - /** - * @inheritDoc - */ - public function eraseAt(?int $x = null, ?int $y = null): void - { - $leftMargin = $this->position->getX() + ($x ?? 0); - $topMargin = $this->position->getY() + ($y ?? 0); - - for ($i = 0; $i < $this->height; $i++) { - Console::writeLine(str_repeat(' ', $this->width), $leftMargin, $topMargin + $i); - } - } - - /** - * @inheritDoc - */ - public function addObservers(ObserverInterface|StaticObserverInterface|string ...$observers): void - { - foreach ($observers as $observer) { - if ( is_object($observer) ) { - if (get_class($observer) === ObserverInterface::class) { - $this->observers->add($observer); + protected(set) int $width { + get { + return $this->width; } - - if (get_class($observer) === StaticObserverInterface::class) { - $this->staticObservers->add($observer); - } - } - } - } - - /** - * @inheritDoc - */ - public function removeObservers(ObserverInterface|StaticObserverInterface|string|null ...$observers): void - { - foreach ($observers as $observer) { - if ( is_object($observer) ) { - if (get_class($observer) === ObserverInterface::class) { - $this->observers->remove($observer); + } + protected(set) int $height { + get { + return $this->height; } + } + /** + * @var string[] $content + */ + protected array $content = []; + /** + * @var ItemList $observers + */ + protected ItemList $observers; + /** + * @var ItemList $staticObservers + */ + protected ItemList $staticObservers; + /** + * @var Cursor $cursor + */ + protected Cursor $cursor; + + /** + * Constructs a window. + * + * @param string $title The window title. + * @param string $help The window's help message. + * @param Vector2 $position The position of the window. + * @param int $width The width of the window. + * @param int $height The height of the window. + * @param BorderPackInterface $borderPack The border pack to use when rendering the window border. + * @param WindowAlignment $alignment The window's alignment. + * @param WindowPadding $padding The window's padding. + * @param Color $backgroundColor The window's background color. + * @param Color|null $foregroundColor The window's foreground color. + */ + public function __construct( + protected string $title = '', + protected string $help = '', + protected Vector2 $position = new Vector2(), + int $width = DEFAULT_WINDOW_WIDTH, + int $height = DEFAULT_WINDOW_HEIGHT, + protected BorderPackInterface $borderPack = new BorderPack(''), + protected WindowAlignment $alignment = new WindowAlignment(HorizontalAlignment::LEFT, VerticalAlignment::MIDDLE), + protected WindowPadding $padding = new WindowPadding(rightPadding: 1, leftPadding: 1), + protected Color $backgroundColor = Color::BLACK, + protected ?Color $foregroundColor = null + ) + { + $this->cursor = Console::cursor(); + $this->observers = new ItemList(ObserverInterface::class); + $this->width = $width; + $this->height = $height; + } - if (get_class($observer) === StaticObserverInterface::class) { - $this->staticObservers->remove($observer); - } - } + /** + * @inheritDoc + */ + public function setTitle(string $title): void + { + $this->title = $title; } - } - /** - * @inheritDoc - */ - public function notify(EventInterface $event): void - { - foreach ($this->observers as $observer) { - $observer->onNotify($event); + /** + * @inheritDoc + */ + public function setHelp(string $help): void + { + $this->help = $help; } - foreach ($this->staticObservers as $observer) { - $observer::onNotify($event); + /** + * @inheritDoc + */ + public function setPosition(Vector2|array $position): void + { + $this->position = is_array($position) ? Vector2::fromArray($position) : $position; } - } - /** - * Returns the window's top border. - * - * @return string The window's top border. - */ - private function getTopBorder(): string - { - $titleLength = strlen($this->getTitle()); - $borderLength = $this->width - $titleLength - 3; - $output = $this->borderPack->getTopLeftCorner() . $this->borderPack->getHorizontalBorder() . $this->title; - $output .= str_repeat($this->borderPack->getHorizontalBorder(), $borderLength); - $output .= $this->borderPack->getTopRightCorner(); + /** + * @inheritDoc + */ + public function getPosition(): Vector2 + { + return $this->position; + } - if ($this->foregroundColor) + /** + * @inheritDoc + */ + public function getBorderPack(): BorderPackInterface { - return $this->foregroundColor->value . $output . Color::RESET->value; + return $this->borderPack; } - return $output; - } + /** + * @inheritDoc + */ + public function setBorderPack(BorderPackInterface $borderPack): void + { + $this->borderPack = $borderPack; + } - /** - * Returns the window's lines of content - * - * @return string[] The window's lines of content. - */ - private function getLinesOfContent(): array - { - $content = []; + /** + * @inheritDoc + */ + public function getAlignment(): WindowAlignment + { + return $this->alignment; + } - // Top padding - for ($row = 0; $row < $this->padding->getTopPadding(); $row++) + /** + * @inheritDoc + */ + public function setAlignment(WindowAlignment $alignment): void { - $output = $this->borderPack->getVerticalBorder(); - $output .= str_repeat(' ', $this->width - 2); - $output .= $this->borderPack->getVerticalBorder(); - $content[] = $output; + $this->alignment = $alignment; } - $alignedContent = match ($this->alignment->horizontalAlignment) { - HorizontalAlignment::LEFT => $this->getLeftAlignedContent(), - HorizontalAlignment::CENTER => $this->getCenterAlignedContent(), - HorizontalAlignment::RIGHT => $this->getRightAlignedContent(), - }; + /** + * @inheritDoc + */ + public function getBackgroundColor(): Color + { + return $this->backgroundColor; + } - foreach ($alignedContent as $line) + /** + * @inheritDoc + */ + public function setBackgroundColor(Color $backgroundColor): void { - if ($this->foregroundColor) - { - $content[] = $this->foregroundColor->value . $line . Color::RESET->value; - } - else - { - $content[] = $line; - } + $this->backgroundColor = $backgroundColor; } - // Bottom padding - for ($row = 0; $row < $this->padding->getBottomPadding(); $row++) + /** + * @inheritDoc + */ + public function getForegroundColor(): ?Color { - $output = $this->borderPack->getVerticalBorder(); - $output .= str_repeat(' ', $this->width - 2); - $output .= $this->borderPack->getVerticalBorder(); - $content[] = $output; + return $this->foregroundColor; } - return $content; - } + /** + * @inheritDoc + */ + public function setForegroundColor(Color $color): void + { + $this->foregroundColor = $color; + } - /** - * Returns the window's bottom border. - * - * @return string The window's bottom border. - */ - private function getBottomBorder(): string - { - $helpLength = strlen($this->getHelp()); - $output = $this->borderPack->getBottomLeftCorner() . $this->borderPack->getHorizontalBorder() . $this->help; - $output .= str_repeat($this->borderPack->getHorizontalBorder(), $this->width - $helpLength - 3); - $output .= $this->borderPack->getBottomRightCorner(); + /** + * @inheritDoc + */ + public function getContent(): array + { + return $this->content; + } - if ($this->foregroundColor) + /** + * @inheritDoc + */ + public function setContent(array $content): void { - return $this->foregroundColor->value . $output . Color::RESET->value; + $this->content = $content; } - return $output; - } + /** + * @inheritDoc + */ + public function render(): void + { + $this->renderAt(0, 0); + } - /** - * Returns the window's left aligned content. - * - * @return string[] The window's left aligned content. - */ - private function getLeftAlignedContent(): array - { - $leftAlignedContent = []; + /** + * @inheritDoc + */ + public function renderAt(?int $x = null, ?int $y = null): void + { + $leftMargin = $this->position->getX() + ($x ?? 0); + $topMargin = $this->position->getY() + ($y ?? 0); + + // Render the top border + $topBorderHeight = 1; + $output = $this->getTopBorder(); + Console::writeLine($output, $leftMargin, $topMargin); + + // Render content + $linesOfContent = $this->getLinesOfContent(); + if (!$linesOfContent) { + $linesOfContent = ['']; + } + + foreach ($linesOfContent as $index => $line) { + Console::writeLine( + $line, + $leftMargin, + $topMargin + $index + $topBorderHeight + ); + } - foreach ($this->content as $content) + // Render the bottom border + $topMargin = $topMargin + count($linesOfContent) + $topBorderHeight; + $output = $this->getBottomBorder(); + Console::writeLine($output, $leftMargin, $topMargin); + } + + /** + * Returns the window's top border. + * + * @return string The window's top border. + */ + private function getTopBorder(): string { - $contentLength = mb_strlen($content); - $leftPaddingLength = $this->padding->getLeftPadding(); - $rightPaddingLength = $this->width - $contentLength - $this->padding->getRightPadding() - 2; + $titleLength = strlen($this->getTitle()); + $borderLength = $this->width - $titleLength - 3; + $output = $this->borderPack->getTopLeftCorner() . $this->borderPack->getHorizontalBorder() . $this->title; + $output .= str_repeat($this->borderPack->getHorizontalBorder(), $borderLength); + $output .= $this->borderPack->getTopRightCorner(); + + if ($this->foregroundColor) { + return $this->foregroundColor->value . $output . Color::RESET->value; + } - $output = $this->borderPack->getVerticalBorder(); - $output .= str_repeat(' ', max($leftPaddingLength, 0)); - $output .= $content; - $output .= str_repeat(' ', max($rightPaddingLength, 0)); - $output .= $this->borderPack->getVerticalBorder(); + return $output; + } - $leftAlignedContent[] = $output; + /** + * @inheritDoc + */ + public function getTitle(): string + { + return substr($this->title, 0, $this->width - 3); } - return $leftAlignedContent; - } + /** + * Returns the window's lines of content + * + * @return string[] The window's lines of content. + */ + private function getLinesOfContent(): array + { + $content = []; + + // Top padding + for ($row = 0; $row < $this->padding->getTopPadding(); $row++) { + $output = $this->borderPack->getVerticalBorder(); + $output .= str_repeat(' ', $this->width - 2); + $output .= $this->borderPack->getVerticalBorder(); + $content[] = $output; + } + + $alignedContent = match ($this->alignment->horizontalAlignment) { + HorizontalAlignment::LEFT => $this->getLeftAlignedContent(), + HorizontalAlignment::CENTER => $this->getCenterAlignedContent(), + HorizontalAlignment::RIGHT => $this->getRightAlignedContent(), + }; + + foreach ($alignedContent as $line) { + if ($this->foregroundColor) { + $content[] = $this->foregroundColor->value . $line . Color::RESET->value; + } else { + $content[] = $line; + } + } + + // Bottom padding + for ($row = 0; $row < $this->padding->getBottomPadding(); $row++) { + $output = $this->borderPack->getVerticalBorder(); + $output .= str_repeat(' ', $this->width - 2); + $output .= $this->borderPack->getVerticalBorder(); + $content[] = $output; + } - /** - * Returns the window's center aligned content. - * - * @return string[] The window's center aligned content. - */ - private function getCenterAlignedContent(): array - { - $centerAlignedContent = []; + return $content; + } - foreach ($this->content as $content) + /** + * Returns the window's left aligned content. + * + * @return string[] The window's left aligned content. + */ + private function getLeftAlignedContent(): array { - $contentLength = mb_strlen($content); - $totalPadding = $this->width - $this->padding->getLeftPadding() - $contentLength - $this->padding->getRightPadding() - 2; - $leftPaddingLength = max(floor($totalPadding / 2), 0); - $rightPaddingLength = max(ceil($totalPadding / 2), 0); + $leftAlignedContent = []; - $output = $this->borderPack->getVerticalBorder(); - $contentRender = str_repeat(' ', (int)max($leftPaddingLength, 0)); - $contentRender .= $content; - $contentRender .= str_repeat(' ', (int)max($rightPaddingLength, 0)); + foreach ($this->content as $content) + { + $contentLength = $this->visibleLength($content); + $leftPaddingLength = $this->padding->getLeftPadding(); + $rightPaddingLength = $this->width - $contentLength - $leftPaddingLength - 2; - $output .= str_pad($contentRender, $this->width - 2, ' ', STR_PAD_BOTH); - $output .= $this->borderPack->getVerticalBorder(); + $output = $this->borderPack->getVerticalBorder(); + $output .= str_repeat(' ', max($leftPaddingLength, 0)); + $output .= $content; + $output .= str_repeat(' ', max($rightPaddingLength, 0)); + $output .= $this->borderPack->getVerticalBorder(); - $centerAlignedContent[] = $output; + $leftAlignedContent[] = $output; + } + + return $leftAlignedContent; } - return $centerAlignedContent; - } + /** + * Returns the window's center aligned content. + * + * @return string[] The window's center aligned content. + */ + private function getCenterAlignedContent(): array + { + $centerAlignedContent = []; + + foreach ($this->content as $content) + { + $contentLength = $this->visibleLength($content); + $availableWidth = $this->width - 2; + $totalPadding = max(0, $availableWidth - $contentLength); + + $leftPaddingLength = intdiv($totalPadding, 2); + $rightPaddingLength = $totalPadding - $leftPaddingLength; + + $output = $this->borderPack->getVerticalBorder(); + $output .= str_repeat(' ', $leftPaddingLength); + $output .= $content; + $output .= str_repeat(' ', $rightPaddingLength); + $output .= $this->borderPack->getVerticalBorder(); - /** - * Returns the window's right aligned content. - * - * @return string[] The window's right aligned content. - */ - private function getRightAlignedContent(): array - { - $rightAlignedContent = []; + $centerAlignedContent[] = $output; + } + + return $centerAlignedContent; + } - foreach ($this->content as $content) + /** + * Returns the window's right aligned content. + * + * @return string[] The window's right aligned content. + */ + private function getRightAlignedContent(): array { - $contentLength = mb_strlen($content); - $leftPaddingLength = $this->width - $contentLength - $this->padding->getLeftPadding() - 2; - $rightPaddingLength = $this->padding->getRightPadding(); // -1 for the border + $rightAlignedContent = []; - $output = $this->borderPack->getVerticalBorder(); - $output .= str_repeat(' ', max($leftPaddingLength, 0)); - $output .= $content; - $output .= str_repeat(' ', max($rightPaddingLength, 0)); - $output .= $this->borderPack->getVerticalBorder(); + foreach ($this->content as $content) + { + $contentLength = $this->visibleLength($content); + $rightPaddingLength = $this->padding->getRightPadding(); + $leftPaddingLength = $this->width - $contentLength - $rightPaddingLength - 2; - $rightAlignedContent[] = $output; + $output = $this->borderPack->getVerticalBorder(); + $output .= str_repeat(' ', max($leftPaddingLength, 0)); + $output .= $content; + $output .= str_repeat(' ', max($rightPaddingLength, 0)); + $output .= $this->borderPack->getVerticalBorder(); + + $rightAlignedContent[] = $output; + } + + return $rightAlignedContent; + } + + /** + * Returns the window's bottom border. + * + * @return string The window's bottom border. + */ + private function getBottomBorder(): string + { + $helpLength = strlen($this->getHelp()); + $output = $this->borderPack->getBottomLeftCorner() . $this->borderPack->getHorizontalBorder() . $this->help; + $output .= str_repeat($this->borderPack->getHorizontalBorder(), $this->width - $helpLength - 3); + $output .= $this->borderPack->getBottomRightCorner(); + + if ($this->foregroundColor) { + return $this->foregroundColor->value . $output . Color::RESET->value; + } + + return $output; + } + + /** + * @inheritDoc + */ + public function getHelp(): string + { + return substr($this->help, 0, $this->width - 3); + } + + /** + * @inheritDoc + */ + public function erase(): void + { + $this->eraseAt(0, 0); + } + + /** + * @inheritDoc + */ + public function eraseAt(?int $x = null, ?int $y = null): void + { + $leftMargin = $this->position->getX() + ($x ?? 0); + $topMargin = $this->position->getY() + ($y ?? 0); + + for ($i = 0; $i < $this->height; $i++) { + Console::writeLine(str_repeat(' ', $this->width), $leftMargin, $topMargin + $i); + } + } + + /** + * @inheritDoc + */ + public function addObservers(ObserverInterface|StaticObserverInterface|string ...$observers): void + { + foreach ($observers as $observer) { + if (is_object($observer)) { + if (get_class($observer) === ObserverInterface::class) { + $this->observers->add($observer); + } + + if (get_class($observer) === StaticObserverInterface::class) { + $this->staticObservers->add($observer); + } + } + } } - return $rightAlignedContent; - } + /** + * @inheritDoc + */ + public function removeObservers(ObserverInterface|StaticObserverInterface|string|null ...$observers): void + { + foreach ($observers as $observer) { + if (is_object($observer)) { + if (get_class($observer) === ObserverInterface::class) { + $this->observers->remove($observer); + } + + if (get_class($observer) === StaticObserverInterface::class) { + $this->staticObservers->remove($observer); + } + } + } + } + + /** + * @inheritDoc + */ + public function notify(EventInterface $event): void + { + foreach ($this->observers as $observer) { + $observer->onNotify($event); + } + + foreach ($this->staticObservers as $observer) { + $observer::onNotify($event); + } + } + + private function padRightVisible(string $text, int $width): string + { + $pad = max(0, $width - $this->visibleLength($text)); + return $text . str_repeat(' ', $pad); + } + private function visibleLength(string $text): int + { + return mb_strlen($this->stripAnsi($text)); + } + + private function stripAnsi(string $text): string + { + return preg_replace('/\e\[[0-9;]*m/', '', $text) ?? $text; + } + + private function padLeftVisible(string $text, int $width): string + { + $pad = max(0, $width - $this->visibleLength($text)); + return str_repeat(' ', $pad) . $text; + } + + private function padBothVisible(string $text, int $width): string + { + $pad = max(0, $width - $this->visibleLength($text)); + $left = intdiv($pad, 2); + $right = $pad - $left; + + return str_repeat(' ', $left) . $text . str_repeat(' ', $right); + } } diff --git a/tests/Mocks/Scenes/scene_with_nested_objects.scene.php b/tests/Mocks/Scenes/scene_with_nested_objects.scene.php new file mode 100644 index 0000000..bf324a4 --- /dev/null +++ b/tests/Mocks/Scenes/scene_with_nested_objects.scene.php @@ -0,0 +1,47 @@ + 'Scene With Nested Objects', + 'width' => 20, + 'height' => 10, + 'hierarchy' => [ + [ + 'type' => GameObject::class, + 'name' => 'Player', + 'tag' => 'Player', + 'position' => [ + 'x' => 2, + 'y' => 2, + ], + 'rotation' => [ + 'x' => 0, + 'y' => 0, + ], + 'scale' => [ + 'x' => 1, + 'y' => 1, + ], + 'children' => [ + [ + 'type' => GameObject::class, + 'name' => 'Weapon', + 'tag' => 'Equipment', + 'position' => [ + 'x' => 1, + 'y' => 0, + ], + 'rotation' => [ + 'x' => 0, + 'y' => 0, + ], + 'scale' => [ + 'x' => 1, + 'y' => 1, + ], + ], + ], + ], + ], +]; diff --git a/tests/Unit/Core/GameObjectTest.php b/tests/Unit/Core/GameObjectTest.php index 5ae84ec..6109ea1 100644 --- a/tests/Unit/Core/GameObjectTest.php +++ b/tests/Unit/Core/GameObjectTest.php @@ -142,6 +142,21 @@ public function onStart(): void ->and($clone->getSprite()->getRect()->getHeight())->toEqual(1); }); + it('clones nested child hierarchies for prefab-style duplication', function () { + $child = new GameObject('Child', position: new Vector2(1, 0)); + $child->getTransform()->setParent($this->gameObject->getTransform()); + + $clone = clone $this->gameObject; + $cloneChildren = $clone->getChildren(); + + expect($cloneChildren) + ->toHaveCount(1) + ->and($cloneChildren[0])->not()->toBe($child) + ->and($cloneChildren[0]->getTransform()->getParent())->toBe($clone->getTransform()) + ->and($cloneChildren[0]->getTransform()->getPosition()->getX())->toEqual(1) + ->and($cloneChildren[0]->getTransform()->getPosition()->getY())->toEqual(0); + }); + it('can broadcast a message to all components', function () { $mockBehaviour1 = $this->gameObject->addComponent(MockBehavior::class); $mockBehaviour2 = $this->gameObject->addComponent(MockBehavior::class); @@ -249,6 +264,52 @@ public function awake(): void ->toHaveCount(1) ->and($collisions[0]->getGameObject()->getName())->toEqual('Wall'); }); + + it('registers runtime-added collider components for child objects already in the active scene', function () { + resetGameObjectSingleton(SceneManager::class, 'instance'); + resetGameObjectSingleton(Physics::class, 'instance'); + + $sceneManager = SceneManager::getInstance(); + $physics = Physics::getInstance(); + $scene = new class('Runtime Scene') extends Scene + { + public function awake(): void + { + } + }; + $scene->loadSceneSettings([ + 'screen_width' => 10, + 'screen_height' => 10, + ]); + + $activeSceneNode = new ReflectionProperty(SceneManager::class, 'activeSceneNode'); + $activeSceneNode->setValue($sceneManager, new SceneNode($scene)); + + $texturePath = getcwd() . '/tests/Mocks/Textures/test.texture'; + $parent = new GameObject('Parent', position: new Vector2(2, 0)); + $child = new GameObject('Child', position: new Vector2(1, 0)); + $child->setSpriteFromTexture(new Texture($texturePath), new Vector2(0, 0), new Vector2(1, 1)); + $child->getTransform()->setParent($parent->getTransform()); + + $wall = new GameObject('Wall', position: new Vector2(3, 0)); + $wall->setSpriteFromTexture(new Texture($texturePath), new Vector2(0, 0), new Vector2(1, 1)); + + $scene->add($parent); + $scene->add($wall); + $scene->start(); + + $childCollider = $child->addComponent(Collider::class); + $wallCollider = $wall->addComponent(Collider::class); + + expect($childCollider)->toBeInstanceOf(Collider::class) + ->and($wallCollider)->toBeInstanceOf(Collider::class); + + $collisions = $physics->checkCollisions($childCollider, new Vector2(0, 0)); + + expect($collisions) + ->toHaveCount(1) + ->and($collisions[0]->getGameObject()->getName())->toEqual('Wall'); + }); }); function resetGameObjectSingleton(string $className, string $propertyName): void diff --git a/tests/Unit/Core/Rendering/RendererTest.php b/tests/Unit/Core/Rendering/RendererTest.php index 06e6f38..7ffe217 100644 --- a/tests/Unit/Core/Rendering/RendererTest.php +++ b/tests/Unit/Core/Rendering/RendererTest.php @@ -165,6 +165,23 @@ public function awake(): void ->toContain("\033[{$oldRow};{$newColumn}H>"); }); +it('renders child sprites at their parent-relative world position', function () { + $parent = new GameObject('Player', position: new Vector2(2, 2)); + $child = new GameObject('Weapon', position: new Vector2(1, 0)); + $child->setSpriteFromTexture( + new Texture(getcwd() . '/tests/Mocks/Textures/test.texture'), + new Vector2(0, 0), + new Vector2(1, 1) + ); + $child->getTransform()->setParent($parent->getTransform()); + + ob_start(); + $parent->render(); + $output = ob_get_clean(); + + expect($output)->toContain("\033[2;3H>"); +}); + function resetSingleton(string $className, string $property): void { $reflection = new ReflectionClass($className); diff --git a/tests/Unit/Core/Scenes/SceneManagerTest.php b/tests/Unit/Core/Scenes/SceneManagerTest.php index b46c73b..8060164 100644 --- a/tests/Unit/Core/Scenes/SceneManagerTest.php +++ b/tests/Unit/Core/Scenes/SceneManagerTest.php @@ -7,8 +7,10 @@ use Sendama\Engine\Core\Scenes\SceneManager; use Sendama\Engine\Core\Vector2; use Sendama\Engine\IO\Console\Console; +use Sendama\Engine\IO\Enumerations\Color as EngineColor; use Sendama\Engine\Mocks\MockBehavior; use Sendama\Engine\Physics\Physics; +use Sendama\Engine\UI\GUITexture\GUITexture; use Sendama\Engine\UI\Label\Label; use Sendama\Engine\UI\UIElement; use Sendama\Engine\Util\Path; @@ -35,6 +37,14 @@ class SceneManagerPrefabProbe extends Behaviour } } +if (!class_exists(SceneManagerVectorProbe::class)) { + class SceneManagerVectorProbe extends Behaviour + { + public ?Vector2 $minBound = null; + public ?Vector2 $maxBound = null; + } +} + beforeEach(function () { resetSceneManagerStaticProperty(SceneManager::class, 'instance', null); $this->originalCwd = getcwd(); @@ -58,6 +68,7 @@ class SceneManagerPrefabProbe extends Behaviour $this->sceneWithComponentDataPath = Path::join(dirname(__DIR__, 3), 'Mocks', 'Scenes', 'scene_with_component_data'); $this->sceneWithEnvironmentCollisionPath = Path::join(dirname(__DIR__, 3), 'Mocks', 'Scenes', 'scene_with_environment_collision'); $this->sceneWithNamedObjectsPath = Path::join(dirname(__DIR__, 3), 'Mocks', 'Scenes', 'scene_with_named_objects'); + $this->sceneWithNestedObjectsPath = Path::join(dirname(__DIR__, 3), 'Mocks', 'Scenes', 'scene_with_nested_objects'); }); afterEach(function () { @@ -134,6 +145,72 @@ class SceneManagerPrefabProbe extends Behaviour ->and($uiElement->getName())->toBe('Score'); }); +it('hydrates gui textures from file scene metadata', function () { + $workspace = sys_get_temp_dir() . '/sendama-gui-texture-' . uniqid('', true); + $texturesDirectory = $workspace . '/assets/Textures'; + $scenesDirectory = $workspace . '/assets/Scenes'; + + mkdir($texturesDirectory, 0777, true); + mkdir($scenesDirectory, 0777, true); + + file_put_contents($texturesDirectory . '/hud.texture', "><\n[]\n"); + file_put_contents($scenesDirectory . '/gui_texture.scene.php', <<<'PHP' + 'GUI Texture Scene', + 'width' => 20, + 'height' => 10, + 'hierarchy' => [ + [ + 'type' => \Sendama\Engine\UI\GUITexture\GUITexture::class, + 'name' => 'HUD Logo', + 'tag' => 'HUD', + 'position' => ['x' => 2, 'y' => 1], + 'size' => ['x' => 2, 'y' => 2], + 'texture' => 'Textures/hud', + 'color' => 'Yellow', + ], + ], +]; +PHP); + + chdir($workspace); + + ob_start(); + $this->sceneManager->loadSceneFromFile($scenesDirectory . '/gui_texture'); + $this->sceneManager->loadScene('GUI Texture Scene'); + ob_end_clean(); + + $uiElement = UIElement::find('HUD Logo'); + + expect($uiElement)->toBeInstanceOf(GUITexture::class) + ->and($uiElement?->getTag())->toBe('HUD') + ->and($uiElement?->getTexturePath())->toBe('Textures/hud') + ->and($uiElement?->getColor())->toBe(EngineColor::YELLOW); +}); + +it('hydrates nested hierarchy children as runtime game objects', function () { + ob_start(); + $this->sceneManager->loadSceneFromFile($this->sceneWithNestedObjectsPath); + $this->sceneManager->loadScene('Scene With Nested Objects'); + ob_end_clean(); + + $scene = $this->sceneManager->getActiveScene(); + $player = GameObject::find('Player'); + $weapon = GameObject::find('Weapon'); + + expect($scene)->not()->toBeNull() + ->and($scene->getRootGameObjects())->toHaveCount(1) + ->and($player)->toBeInstanceOf(GameObject::class) + ->and($weapon)->toBeInstanceOf(GameObject::class) + ->and($weapon->getTransform()->getParent())->toBe($player->getTransform()) + ->and($weapon->getTransform()->getWorldPosition()->getX())->toBe(3) + ->and($weapon->getTransform()->getWorldPosition()->getY())->toBe(2) + ->and(GameObject::findWithTag('Equipment'))->toBe($weapon) + ->and(GameObject::findAllWithTag('Equipment'))->toHaveCount(1); +}); + it('inflates prefab reference fields into concrete game object templates', function () { $workspace = sys_get_temp_dir() . '/sendama-prefab-' . uniqid('', true); $prefabsDirectory = $workspace . '/assets/Prefabs'; @@ -206,6 +283,28 @@ class SceneManagerPrefabProbe extends Behaviour ->and($probe->enemyPrefab->getComponent(MockBehavior::class))->toBeInstanceOf(MockBehavior::class); }); +it('hydrates vector component fields from legacy string metadata', function () { + $probe = new SceneManagerVectorProbe(new GameObject('Bullet')); + + SceneManager::applySceneComponentMetadata( + $probe, + SceneManagerVectorProbe::class, + (object) [ + 'data' => (object) [ + 'minBound' => '[1,1]', + 'maxBound' => '[120,25]', + ], + ] + ); + + expect($probe->minBound)->toBeInstanceOf(Vector2::class) + ->and($probe->minBound?->getX())->toBe(1) + ->and($probe->minBound?->getY())->toBe(1) + ->and($probe->maxBound)->toBeInstanceOf(Vector2::class) + ->and($probe->maxBound?->getX())->toBe(120) + ->and($probe->maxBound?->getY())->toBe(25); +}); + function resetSceneManagerStaticProperty(string $className, string $propertyName, mixed $value): void { $reflection = new \ReflectionClass($className); diff --git a/tests/Unit/Core/Scenes/TitleSceneTest.php b/tests/Unit/Core/Scenes/TitleSceneTest.php index e6b3a22..8e4e7b2 100644 --- a/tests/Unit/Core/Scenes/TitleSceneTest.php +++ b/tests/Unit/Core/Scenes/TitleSceneTest.php @@ -4,9 +4,12 @@ use Sendama\Engine\Core\Scenes\SceneManager; use Sendama\Engine\Core\Scenes\TitleScene; use Sendama\Engine\UI\Menus\Menu; +use Sendama\Engine\UI\Menus\MenuItems\MenuItem; use Sendama\Engine\UI\Text\Text; beforeEach(function () { + resetTitleSceneSingleton(SceneManager::class, 'instance'); + SceneManager::getInstance()->loadSettings([ 'game_name' => 'Blasters', 'screen_width' => 140, @@ -42,8 +45,52 @@ public function awake(): void ->and(SceneManager::getInstance()->getSettings('screen_width'))->toBe(140); }); +it('disables new game when the default target scene is unavailable', function () { + $scene = new TitleScene('Blasters'); + /** @var Menu $menu */ + $menu = getProtectedProperty($scene, 'menu'); + /** @var MenuItem $newGameItem */ + $newGameItem = $menu->getItemByIndex(0); + $quitItem = $menu->getItemByIndex(1); + + expect($newGameItem->isEnabled())->toBeFalse() + ->and($menu->getActiveItem())->toBe($quitItem); +}); + +it('keeps new game enabled when its target scene exists', function () { + SceneManager::getInstance()->addScene(new class('Title Placeholder') extends AbstractScene { + public function awake(): void + { + // Do nothing. + } + }); + + SceneManager::getInstance()->addScene(new class('Game Scene') extends AbstractScene { + public function awake(): void + { + // Do nothing. + } + }); + + $scene = new TitleScene('Blasters'); + /** @var Menu $menu */ + $menu = getProtectedProperty($scene, 'menu'); + /** @var MenuItem $newGameItem */ + $newGameItem = $menu->getItemByIndex(0); + + expect($newGameItem->isEnabled())->toBeTrue() + ->and($menu->getActiveItem())->toBe($newGameItem); +}); + function getProtectedProperty(object $object, string $property): mixed { $reflection = new ReflectionClass($object); return $reflection->getProperty($property)->getValue($object); } + +function resetTitleSceneSingleton(string $className, string $propertyName): void +{ + $reflection = new ReflectionClass($className); + $property = $reflection->getProperty($propertyName); + $property->setValue(null, null); +} diff --git a/tests/Unit/Core/Texture2DTest.php b/tests/Unit/Core/Texture2DTest.php index 3db67ac..19901f4 100644 --- a/tests/Unit/Core/Texture2DTest.php +++ b/tests/Unit/Core/Texture2DTest.php @@ -75,4 +75,13 @@ ->and($texture->getPixel(0, 1))->toBe('→') ->and($texture->getPixel(1, 1))->toBe('↓'); }); + + it('respects the configured texture height when loading pixels', function () { + $texture = new Texture($this->unicodeTexturePath, 1, 1); + + expect($texture->getWidth())->toBe(1) + ->and($texture->getHeight())->toBe(1) + ->and($texture->getPixels())->toHaveCount(1) + ->and($texture->getPixel(0, 0))->toBe('←'); + }); }); diff --git a/tests/Unit/GameTest.php b/tests/Unit/GameTest.php index 440768f..7569cf3 100644 --- a/tests/Unit/GameTest.php +++ b/tests/Unit/GameTest.php @@ -83,6 +83,32 @@ ->toContain("'Arcade Mode'"); }); +it('disables the tmux status bar for non-debug managed runtime sessions', function () { + $command = invokePrivateStaticMethod( + Game::class, + 'buildTmuxSessionLaunchCommand', + 'The-Collector', + '/tmp/sendama', + 'php game.php', + false, + ); + + expect($command)->toContain("set-option -t 'The-Collector' status off"); +}); + +it('keeps the tmux status bar enabled for debug managed runtime sessions', function () { + $command = invokePrivateStaticMethod( + Game::class, + 'buildTmuxSessionLaunchCommand', + 'The-Collector', + '/tmp/sendama', + 'php game.php', + true, + ); + + expect($command)->not()->toContain('status off'); +}); + it('only uses the tmux handoff when the current command can be relaunched', function () { expect(invokePrivateStaticMethod(Game::class, 'canRelaunchCurrentCommand', []))->toBeFalse() ->and(invokePrivateStaticMethod(Game::class, 'canRelaunchCurrentCommand', ['/tmp/game.php']))->toBe(PHP_SAPI === 'cli'); diff --git a/tests/Unit/Physics/CharacterControllerTest.php b/tests/Unit/Physics/CharacterControllerTest.php index 23bb7f6..88939f7 100644 --- a/tests/Unit/Physics/CharacterControllerTest.php +++ b/tests/Unit/Physics/CharacterControllerTest.php @@ -18,10 +18,24 @@ class CharacterControllerCollisionProbe extends Behaviour { public array $collisionTypes = []; + public array $collisionTargets = []; + public array $triggerTargets = []; - public function onCollisionEnter(CollisionInterface $collision): void + public function onCollisionEnter(CollisionInterface $hit): void { - $this->collisionTypes[] = get_class($collision); + $this->collisionTypes[] = get_class($hit); + $this->collisionTargets[] = [ + 'self' => $this->getGameObject()->getTag(), + 'other' => $hit->getGameObject()->getTag(), + ]; + } + + public function onTriggerEnter(ColliderInterface $other): void + { + $this->triggerTargets[] = [ + 'self' => $this->getGameObject()->getTag(), + 'other' => $other->getGameObject()->getTag(), + ]; } } } @@ -42,8 +56,9 @@ public function onCollisionEnter(CollisionInterface $collision): void Vector2 $position, string $componentClass = Collider::class, bool $isTrigger = false, + ?string $tag = null, ): array { - $gameObject = new GameObject($name); + $gameObject = new GameObject($name, $tag); $gameObject->setSprite(($this->makeSprite)()); $gameObject->getTransform()->setPosition($position); @@ -71,6 +86,48 @@ public function onCollisionEnter(CollisionInterface $collision): void ->and($collisions[0]->getGameObject()->getName())->not()->toBe($distant->getGameObject()->getName()); }); +it('ignores inactive colliders that remain registered for object pool reuse', function () { + [, $controller] = ($this->makeCollider)('Player', new Vector2(0, 0), CharacterController::class); + [$pooledBullet] = ($this->makeCollider)('Bullet', new Vector2(1, 0)); + + $pooledBullet->deactivate(); + + $collisions = Physics::getInstance()->checkCollisions($controller, new Vector2(1, 0)); + + expect($collisions)->toBe([]); +}); + +it('emits mirrored trigger callbacks with the other collider identity for both participants', function () { + [$player, $controller] = ($this->makeCollider)('Player', new Vector2(0, 0), CharacterController::class, false, 'Player'); + [$coin, $triggerCollider] = ($this->makeCollider)('Coin', new Vector2(1, 0), Collider::class, true, 'Pickup'); + + $playerProbe = $player->addComponent(CharacterControllerCollisionProbe::class); + $coinProbe = $coin->addComponent(CharacterControllerCollisionProbe::class); + + ob_start(); + $controller->move(new Vector2(1, 0)); + ob_end_clean(); + + expect($playerProbe->collisionTargets)->toBe([[ + 'self' => 'Player', + 'other' => 'Pickup', + ]]) + ->and($coinProbe->collisionTargets)->toBe([[ + 'self' => 'Pickup', + 'other' => 'Player', + ]]) + ->and($playerProbe->triggerTargets)->toBe([[ + 'self' => 'Player', + 'other' => 'Pickup', + ]]) + ->and($coinProbe->triggerTargets)->toBe([[ + 'self' => 'Pickup', + 'other' => 'Player', + ]]) + ->and($player->getTransform()->getPosition()->getX())->toBe(1) + ->and($triggerCollider->isTrigger())->toBeTrue(); +}); + 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)); diff --git a/tests/Unit/Physics/RigidbodyTest.php b/tests/Unit/Physics/RigidbodyTest.php index 3bd3a81..48a29ee 100644 --- a/tests/Unit/Physics/RigidbodyTest.php +++ b/tests/Unit/Physics/RigidbodyTest.php @@ -9,6 +9,7 @@ use Sendama\Engine\Physics\CharacterController; use Sendama\Engine\Physics\Collider; use Sendama\Engine\Physics\EnvironmentCollision; +use Sendama\Engine\Physics\Interfaces\ColliderInterface; use Sendama\Engine\Physics\Interfaces\CollisionInterface; use Sendama\Engine\Physics\Physics; use Sendama\Engine\Physics\PhysicsMaterial; @@ -19,11 +20,14 @@ class RigidbodyCollisionProbe extends Behaviour { public array $events = []; public array $collisionTypes = []; + public array $collisionTags = []; + public array $triggerTags = []; - public function onCollisionEnter(CollisionInterface $collision): void + public function onCollisionEnter(CollisionInterface $hit): void { - $this->events[] = 'enter:' . $collision->getGameObject()->getName(); - $this->collisionTypes[] = get_class($collision); + $this->events[] = 'enter:' . $hit->getGameObject()->getName(); + $this->collisionTypes[] = get_class($hit); + $this->collisionTags[] = $hit->getGameObject()->getTag(); } public function onCollisionExit(CollisionInterface $collision): void @@ -35,6 +39,11 @@ public function onCollisionStay(CollisionInterface $collision): void { $this->events[] = 'stay:' . $collision->getGameObject()->getName(); } + + public function onTriggerEnter(ColliderInterface $other): void + { + $this->triggerTags[] = $other->getGameObject()->getTag(); + } } } @@ -145,8 +154,10 @@ public function onCollisionStay(CollisionInterface $collision): void }); it('dispatches mirrored collision enter events for rigidbody movement', function () { - [$bullet, $rigidbody] = createPhysicsObject('Bullet', $this->texturePath, new Vector2(0, 0), Rigidbody::class); - [$enemy, $enemyController] = createPhysicsObject('Enemy', $this->texturePath, new Vector2(1, 0), CharacterController::class); + [$bullet, $rigidbody] = createPhysicsObject('Bullet', $this->texturePath, new Vector2(0, 0), Rigidbody::class, 'Bullet'); + [$enemy, $enemyController] = createPhysicsObject('Enemy', $this->texturePath, new Vector2(1, 0), CharacterController::class, 'Enemy'); + + $rigidbody->setTrigger(true); $bulletProbe = $bullet->addComponent(RigidbodyCollisionProbe::class); $enemyProbe = $enemy->addComponent(RigidbodyCollisionProbe::class); @@ -164,7 +175,11 @@ public function onCollisionStay(CollisionInterface $collision): void ob_end_clean(); expect($bulletProbe->events)->toBe(['enter:Enemy']) - ->and($enemyProbe->events)->toBe(['enter:Bullet']); + ->and($enemyProbe->events)->toBe(['enter:Bullet']) + ->and($bulletProbe->collisionTags)->toBe(['Enemy']) + ->and($enemyProbe->collisionTags)->toBe(['Bullet']) + ->and($bulletProbe->triggerTags)->toBe(['Enemy']) + ->and($enemyProbe->triggerTags)->toBe(['Bullet']); }); it('restores buffered console content when movePosition advances a rigidbody', function () { @@ -273,9 +288,9 @@ public function onCollisionStay(CollisionInterface $collision): void * @param class-string $componentClass * @return array{0: GameObject, 1: Collider|Rigidbody} */ -function createPhysicsObject(string $name, string $texturePath, Vector2 $position, string $componentClass): array +function createPhysicsObject(string $name, string $texturePath, Vector2 $position, string $componentClass, ?string $tag = null): array { - $gameObject = new GameObject($name, position: $position); + $gameObject = new GameObject($name, $tag, $position); $gameObject->setSpriteFromTexture( new \Sendama\Engine\Core\Texture($texturePath), new Vector2(0, 0), diff --git a/tests/Unit/UI/GUITextureTest.php b/tests/Unit/UI/GUITextureTest.php new file mode 100644 index 0000000..f212ee7 --- /dev/null +++ b/tests/Unit/UI/GUITextureTest.php @@ -0,0 +1,80 @@ +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('renders gui textures with their configured color after the owning scene starts', function () { + $scene = new class('Test Scene') extends AbstractScene { + public function awake(): void + { + // Do nothing. + } + }; + + $guiTexture = new GUITexture( + $scene, + 'HUD Logo', + new Vector2(2, 2), + new Vector2(1, 1), + 'HUD', + getcwd() . '/tests/Mocks/Textures/test.texture', + Color::YELLOW, + ); + $scene->add($guiTexture); + $scene->loadSceneSettings([ + 'screen_width' => DEFAULT_SCREEN_WIDTH, + 'screen_height' => DEFAULT_SCREEN_HEIGHT, + ]); + $scene->start(); + + ob_start(); + $guiTexture->render(); + $output = ob_get_clean(); + + expect($output) + ->toContain("\033[2;2H") + ->toContain('>') + ->toContain(Color::YELLOW->value); +}); + +it('normalizes gui texture sizes to at least one cell', function () { + $scene = new class('Test Scene') extends AbstractScene { + public function awake(): void + { + // Do nothing. + } + }; + + $guiTexture = new GUITexture( + $scene, + 'HUD Logo', + new Vector2(2, 2), + new Vector2(0, 0), + 'HUD', + ); + + expect($guiTexture->getSize()->getX())->toBe(1) + ->and($guiTexture->getSize()->getY())->toBe(1); + + $guiTexture->setSize(new Vector2(0, 0)); + + expect($guiTexture->getSize()->getX())->toBe(1) + ->and($guiTexture->getSize()->getY())->toBe(1); +}); diff --git a/tests/Unit/UI/MenuTest.php b/tests/Unit/UI/MenuTest.php new file mode 100644 index 0000000..0f9d2bc --- /dev/null +++ b/tests/Unit/UI/MenuTest.php @@ -0,0 +1,61 @@ +addItem($disabledItem); + $menu->addItem($quitItem); + + expect($menu->getActiveItem())->toBe($quitItem) + ->and($disabledItem->isEnabled())->toBeFalse(); +}); + +it('skips disabled items while navigating', function () { + $menu = new Menu('Main Menu'); + $firstItem = new MenuItem(label: 'Start'); + $disabledItem = new MenuItem(label: 'Continue', enabled: false); + $thirdItem = new MenuItem(label: 'Quit'); + + $menu->addItem($firstItem); + $menu->addItem($disabledItem); + $menu->addItem($thirdItem); + + setMenuInputState('previousKeyPress', ''); + setMenuInputState('keyPress', "\033[B"); + + $menu->update(); + + expect($menu->getActiveItem())->toBe($thirdItem); +}); + +it('does not keep a disabled item active when no selectable items exist', function () { + $menu = new Menu('Main Menu'); + $disabledItem = new MenuItem(label: 'New Game', enabled: false); + + $menu->addItem($disabledItem); + $menu->setActiveItem($disabledItem); + + setMenuInputState('previousKeyPress', ''); + setMenuInputState('keyPress', "\n"); + + $menu->update(); + + expect($menu->getActiveItem())->toBeNull(); +}); + +function setMenuInputState(string $property, string $value): void +{ + $reflection = new ReflectionClass(InputManager::class); + $reflection->getProperty($property)->setValue(null, $value); +}