diff --git a/assets/schema/scene.schema.json b/assets/schema/scene.schema.json index a410821..0f1eb3a 100644 --- a/assets/schema/scene.schema.json +++ b/assets/schema/scene.schema.json @@ -11,6 +11,12 @@ "height": { "type": "number" }, + "environmentTileMapPath": { + "type": "string" + }, + "environmentCollisionMapPath": { + "type": "string" + }, "hierarchy": { "type": "array", "items": { @@ -18,4 +24,4 @@ } } } -} \ No newline at end of file +} diff --git a/examples/blasters/configuration.json b/examples/blasters/configuration.json new file mode 100644 index 0000000..66b52a4 --- /dev/null +++ b/examples/blasters/configuration.json @@ -0,0 +1,8 @@ +{ + "project": { + "name": "blasters", + "description": "A simple 2D Shoot 'em up.", + "version": "1.0.0", + "main": "blasters.php" + } +} diff --git a/src/Core/GameObject.php b/src/Core/GameObject.php index 273ab20..912ab5a 100644 --- a/src/Core/GameObject.php +++ b/src/Core/GameObject.php @@ -11,6 +11,8 @@ use Sendama\Engine\Core\Scenes\Interfaces\SceneInterface; use Sendama\Engine\Core\Scenes\Scene; use Sendama\Engine\Core\Scenes\SceneManager; +use Sendama\Engine\Physics\Physics; +use Sendama\Engine\Physics\Interfaces\ColliderInterface; use Sendama\Engine\UI\Interfaces\UIElementInterface; /** @@ -44,6 +46,8 @@ class GameObject implements GameObjectInterface * @var Renderer $renderer The renderer for the game object. */ protected Renderer $renderer; + protected bool $started = false; + protected bool $starting = false; public SceneInterface $activeScene { get { @@ -426,13 +430,26 @@ public function suspend(): void */ public function start(): void { - if ($this->isActive()) { - foreach ($this->components as $component) { - if ($component->isEnabled()) { - $component->start(); + if (!$this->isActive() || $this->started) { + return; + } + + $this->starting = true; + + for ($index = 0; $index < count($this->components); $index++) { + $component = $this->components[$index]; + + if ($component->isEnabled()) { + $component->start(); + + if ($component instanceof ColliderInterface) { + Physics::getInstance()->addCollider($component); } } } + + $this->starting = false; + $this->started = true; } /** @@ -482,7 +499,15 @@ public function update(): void */ public function activate(): void { + $wasActive = $this->active; $this->active = true; + + if (!$this->started) { + $this->start(); + } elseif (!$wasActive) { + $this->resume(); + } + $this->getRenderer()->render(); } @@ -511,6 +536,10 @@ public function getRenderer(): Renderer */ public function deactivate(): void { + if ($this->active) { + $this->suspend(); + } + $this->active = false; $this->getRenderer()->erase(); } @@ -556,6 +585,15 @@ public function addComponent(string $componentType): Component $component = new $componentType($this); $this->components[] = $component; + + if ($this->started && $this->belongsToActiveScene()) { + $component->start(); + } + + if ($component instanceof ColliderInterface && $this->belongsToActiveScene()) { + Physics::getInstance()->addCollider($component); + } + return $component; } @@ -569,6 +607,22 @@ public function getComponentCount(): int return count($this->components); } + /** + * Determines whether a newly-added runtime component should be initialized immediately. + * + * @return bool + */ + private function belongsToActiveScene(): bool + { + $activeScene = SceneManager::getInstance()->getActiveScene(); + + if ($activeScene === null) { + return false; + } + + return in_array($this, $activeScene->getRootGameObjects(), true); + } + /** * Gets the index of the component specified on the specified GameObject. * @@ -677,4 +731,4 @@ public function getSprite(): Sprite { return $this->getRenderer()->getSprite(); } -} \ No newline at end of file +} diff --git a/src/Core/Rendering/Renderer.php b/src/Core/Rendering/Renderer.php index ebdc445..c1c69c2 100644 --- a/src/Core/Rendering/Renderer.php +++ b/src/Core/Rendering/Renderer.php @@ -9,6 +9,7 @@ use Sendama\Engine\Core\Scenes\SceneManager; use Sendama\Engine\IO\Console\Console; use Sendama\Engine\IO\Console\Cursor; +use Sendama\Engine\Util\Unicode; class Renderer extends Component implements CanRender { @@ -16,6 +17,10 @@ class Renderer extends Component implements CanRender * @var array{x: int, y: int, width: int, height: int}|null */ protected ?array $lastRenderedBounds = null; + /** + * @var string[]|null + */ + protected ?array $lastRenderedBackground = null; /** * The console cursor. * @@ -78,9 +83,25 @@ public final function renderAt(?int $x = null, ?int $y = null): void $xOffset = $this->getGameObject()->getTransform()->getPosition()->getX() + ($x ?? 0); $yOffset = $this->getGameObject()->getTransform()->getPosition()->getY() + ($y ?? 0); + $width = $this->sprite->getRect()->getWidth(); + $height = $this->sprite->getRect()->getHeight(); $spriteBufferedImage = $this->sprite->getBufferedImage(); + $currentBounds = [ + 'x' => $xOffset, + 'y' => $yOffset, + 'width' => $width, + 'height' => $height, + ]; + + if ($this->lastRenderedBounds !== null && $this->lastRenderedBounds !== $currentBounds) { + $this->restoreLastRenderedBackground(); + } - for ($row = 0; $row < $this->sprite->getRect()->getHeight(); $row++) { + if ($this->lastRenderedBounds !== $currentBounds) { + $this->lastRenderedBackground = $this->captureBackground($xOffset, $yOffset, $width, $height); + } + + for ($row = 0; $row < $height; $row++) { Console::write( implode($spriteBufferedImage[$row] ?? []), $xOffset, @@ -88,12 +109,7 @@ public final function renderAt(?int $x = null, ?int $y = null): void ); } - $this->lastRenderedBounds = [ - 'x' => $xOffset, - 'y' => $yOffset, - 'width' => $this->sprite->getRect()->getWidth(), - 'height' => $this->sprite->getRect()->getHeight(), - ]; + $this->lastRenderedBounds = $currentBounds; } /** @@ -113,6 +129,20 @@ public final function eraseAt(?int $x = null, ?int $y = null): void return; } + $this->restoreLastRenderedBackground(); + } + + /** + * Restores the previously drawn region underneath this renderer. + * + * @return void + */ + private function restoreLastRenderedBackground(): void + { + if (!$this->lastRenderedBounds) { + return; + } + $xOffset = $this->lastRenderedBounds['x']; $yOffset = $this->lastRenderedBounds['y']; $width = $this->lastRenderedBounds['width']; @@ -120,13 +150,60 @@ public final function eraseAt(?int $x = null, ?int $y = null): void for ($row = 0; $row < $height; $row++) { Console::write( - $this->getBackgroundRowSegment($xOffset, $yOffset + $row, $width), + $this->lastRenderedBackground[$row] ?? $this->getBackgroundRowSegment($xOffset, $yOffset + $row, $width), $xOffset, $yOffset + $row ); } $this->lastRenderedBounds = null; + $this->lastRenderedBackground = null; + } + + /** + * Captures the visible background currently underneath the sprite bounds. + * + * @param int $xOffset + * @param int $yOffset + * @param int $width + * @param int $height + * @return string[] + */ + private function captureBackground(int $xOffset, int $yOffset, int $width, int $height): array + { + $background = []; + + for ($row = 0; $row < $height; $row++) { + $background[] = $this->getCurrentBackgroundRowSegment($xOffset, $yOffset + $row, $width); + } + + return $background; + } + + /** + * Returns the best background segment for the given row by preferring current console content + * and falling back to static world-space tiles where the buffer is blank. + * + * @param int $xOffset + * @param int $yOffset + * @param int $width + * @return string + */ + private function getCurrentBackgroundRowSegment(int $xOffset, int $yOffset, int $width): string + { + $bufferSegment = Console::readLineSegment($xOffset, $yOffset, $width); + $worldSegment = $this->getBackgroundRowSegment($xOffset, $yOffset, $width); + $bufferGlyphs = Unicode::characters($bufferSegment); + $worldGlyphs = Unicode::characters($worldSegment); + $composed = ''; + + for ($column = 0; $column < $width; $column++) { + $bufferGlyph = $bufferGlyphs[$column] ?? ' '; + $worldGlyph = $worldGlyphs[$column] ?? ' '; + $composed .= $bufferGlyph !== ' ' ? $bufferGlyph : $worldGlyph; + } + + return $composed; } /** diff --git a/src/Core/Scenes/AbstractScene.php b/src/Core/Scenes/AbstractScene.php index fb0e977..dd2da12 100644 --- a/src/Core/Scenes/AbstractScene.php +++ b/src/Core/Scenes/AbstractScene.php @@ -64,9 +64,17 @@ abstract class AbstractScene implements SceneInterface */ protected string $environmentTileMapPath = ''; /** - * @var string $environmentTileMapPath + * @var string $environmentCollisionMapPath + */ + protected string $environmentCollisionMapPath = ''; + /** + * @var string $environmentTileMapData */ protected string $environmentTileMapData = ''; + /** + * @var string $environmentCollisionMapData + */ + protected string $environmentCollisionMapData = ''; /** * @var bool $started @@ -91,6 +99,10 @@ public final function __construct(protected string $name, protected ?object $sce if ($this->environmentTileMapPath) { $this->loadEnvironmentTileMapData($this->environmentTileMapPath); } + + if ($this->environmentCollisionMapPath) { + $this->loadEnvironmentCollisionMapData($this->environmentCollisionMapPath); + } } /** @@ -108,27 +120,51 @@ public abstract function awake(): void; private function loadEnvironmentTileMapData(?string $path = null): void { Debug::info("Loading environment tile map data: $path"); - // Check if the file exists - if (!file_exists($this->getAbsoluteEnvironmentTileMapPath())) { - throw new FileNotFoundException($this->getAbsoluteEnvironmentTileMapPath()); - } + $this->environmentTileMapData = $this->loadEnvironmentMapData($this->getAbsoluteEnvironmentMapPath($path)); + } - if (!is_file($this->getAbsoluteEnvironmentTileMapPath())) { - throw new FileNotFoundException($this->getAbsoluteEnvironmentTileMapPath()); - } + /** + * Loads the environment collision map data from a file on disk. + * + * @param string|null $path The path to the environment collision map file. + * @return void + * @throws FileNotFoundException If the file does not exist. + */ + private function loadEnvironmentCollisionMapData(?string $path = null): void + { + Debug::info("Loading environment collision map data: $path"); + $this->environmentCollisionMapData = $this->loadEnvironmentMapData($this->getAbsoluteEnvironmentMapPath($path)); + } - // Get the contents of the file - $this->environmentTileMapData = file_get_contents($this->getAbsoluteEnvironmentTileMapPath()); + /** + * Returns the absolute path to an environment map file. + * + * @param string|null $path The relative map path. + * @return string The absolute path to the environment map file. + */ + private function getAbsoluteEnvironmentMapPath(?string $path): string + { + return Path::join(Path::getWorkingDirectoryAssetsPath(), $path ?? '') . self::MAP_FILE_EXTENSION; } /** - * Returns the absolute path to the environment tile map file. + * Loads a generic environment map file from disk. * - * @return string The absolute path to the environment tile map file. + * @param string $absolutePath + * @return string + * @throws FileNotFoundException */ - private function getAbsoluteEnvironmentTileMapPath(): string + private function loadEnvironmentMapData(string $absolutePath): string { - return Path::join(Path::getWorkingDirectoryAssetsPath(), $this->environmentTileMapPath) . self::MAP_FILE_EXTENSION; + if (!file_exists($absolutePath)) { + throw new FileNotFoundException($absolutePath); + } + + if (!is_file($absolutePath)) { + throw new FileNotFoundException($absolutePath); + } + + return file_get_contents($absolutePath); } /** @@ -414,6 +450,7 @@ public final function start(): void $this->createWorldSpace(); $this->loadStaticEnvironment(); + $this->loadStaticCollisionEnvironment(); foreach ($this->rootGameObjects as $gameObject) { $gameObject->start(); @@ -465,6 +502,33 @@ private function loadStaticEnvironment(): void $this->camera->renderWorldSpace(); } + /** + * Loads the static environment collision map for the scene. + * + * @return void + */ + private function loadStaticCollisionEnvironment(): void + { + Debug::info('Loading static collision environment for ' . $this->name); + + if ($this->environmentCollisionMapData) { + $lines = explode("\n", $this->environmentCollisionMapData); + + if (end($lines) === '') { + array_pop($lines); + } + + foreach ($lines as $y => $line) { + $glyphs = Unicode::characters($line); + foreach ($glyphs as $x => $glyph) { + $this->collisionWorldSpace->set($x, $y, trim($glyph) === '' ? 0 : 1); + } + } + } + + $this->physics->loadStaticCollisionMap($this->collisionWorldSpace); + } + /** * Sets the world space. * diff --git a/src/Core/Scenes/SceneManager.php b/src/Core/Scenes/SceneManager.php index 08d3efb..5b1e635 100644 --- a/src/Core/Scenes/SceneManager.php +++ b/src/Core/Scenes/SceneManager.php @@ -2,7 +2,9 @@ namespace Sendama\Engine\Core\Scenes; +use ReflectionObject; use Assegai\Collections\ItemList; +use Sendama\Engine\Core\Behaviours\Attributes\SerializeField; use Sendama\Engine\Core\GameObject; use Sendama\Engine\Core\Interfaces\CanLoad; use Sendama\Engine\Core\Interfaces\CanRender; @@ -23,6 +25,8 @@ use Sendama\Engine\Exceptions\Scenes\SceneNotFoundException; use Sendama\Engine\IO\Console\Console; 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\Label\Label; @@ -395,6 +399,10 @@ public function awake(): void $this->environmentTileMapPath = $sceneMetadata->environmentTileMapPath; } + if (isset($sceneMetadata->environmentCollisionMapPath)) { + $this->environmentCollisionMapPath = $sceneMetadata->environmentCollisionMapPath; + } + // Build hierarchy if (isset($sceneMetadata->hierarchy)) { foreach ($sceneMetadata->hierarchy as $index => $item) { @@ -464,20 +472,7 @@ public function awake(): void $componentClass = $componentMetadata->class; $component = $gameObject->addComponent($componentClass); - - if (isset($componentMetadata->proerties)) { - foreach ($componentMetadata->proerties as $key => $value) { - if (!property_exists($component, $key)) { - Debug::warn("Property '$key' does not exist on component of type: " . $componentClass); - continue; - } - - $component->$key = match(true) { - $componentClass === Collider::class && $key === 'material' => null, - default => $value - }; - } - } + SceneManager::applySceneComponentMetadata($component, $componentClass, $componentMetadata); } } @@ -506,4 +501,54 @@ public function awake(): void $this->addScene($scene); } + + /** + * Applies editor/file-scene component metadata onto the instantiated component. + * + * Supports legacy `proerties`, current `properties`, and editor-authored `data` payloads. + * + * @param object $component + * @param string $componentClass + * @param object $componentMetadata + * @return void + */ + public static function applySceneComponentMetadata(object $component, string $componentClass, object $componentMetadata): void + { + $componentProperties = $componentMetadata->properties + ?? $componentMetadata->proerties + ?? $componentMetadata->data + ?? null; + + if (!$componentProperties) { + return; + } + + $componentOptions = (array)$componentProperties; + + if (method_exists($component, 'configure')) { + $component->configure($componentOptions); + } + + $reflection = new ReflectionObject($component); + + foreach ($componentOptions as $key => $value) { + if ($key === 'material' && ($component instanceof Collider || $component instanceof Rigidbody)) { + $component->setMaterial(PhysicsMaterial::fromMetadata((array)$value)); + continue; + } + + if (!$reflection->hasProperty($key)) { + Debug::warn("Property '$key' does not exist on component of type: " . $componentClass); + continue; + } + + $property = $reflection->getProperty($key); + + if (!$property->isPublic() && !$property->getAttributes(SerializeField::class)) { + continue; + } + + $property->setValue($component, $value); + } + } } diff --git a/src/Game.php b/src/Game.php index 9830624..6ccfc41 100644 --- a/src/Game.php +++ b/src/Game.php @@ -372,8 +372,10 @@ protected function initializeManagers(): void private function initializeSettings(): void { // Load environment variables - if (file_exists($this->workingDirectory ?? getcwd() . '/.env')) { - $dotenv = Dotenv::createImmutable(getcwd()); + $environmentDirectory = $this->workingDirectory ?? getcwd(); + + if (file_exists(Path::join($environmentDirectory, '.env'))) { + $dotenv = Dotenv::createImmutable($environmentDirectory); $dotenv->load(); } @@ -404,7 +406,7 @@ private function initializeSettings(): void ['showDebugInfo', SettingsKey::DEBUG_INFO->value, 'show_debug_info', 'debug.showInfo', 'debug.showDebugInfo'], false ); - $this->settings[SettingsKey::LOG_LEVEL->value] = $_ENV['LOG_LEVEL'] ?? DEFAULT_LOG_LEVEL; + $this->settings[SettingsKey::LOG_LEVEL->value] = self::resolveConfiguredLogLevelValue(); Debug::setLogLevel(LogLevel::tryFrom((string)$this->getSettings('log_level')) ?? LogLevel::DEBUG); $this->settings[SettingsKey::LOG_DIR->value] = Path::join(getcwd(), DEFAULT_LOGS_DIR); @@ -442,10 +444,10 @@ public function loadSettings(?array $settings = null): self try { $settings ??= []; - $this->settings[SettingsKey::LOG_LEVEL->value] = $settings[SettingsKey::LOG_LEVEL->value] - ?? $_ENV['LOG_LEVEL'] - ?? $this->settings[SettingsKey::LOG_LEVEL->value] - ?? DEFAULT_LOG_LEVEL; + $this->settings[SettingsKey::LOG_LEVEL->value] = self::resolveConfiguredLogLevelValue( + $settings, + $this->settings[SettingsKey::LOG_LEVEL->value] ?? null + ); $this->settings[SettingsKey::LOG_DIR->value] = $settings[SettingsKey::LOG_DIR->value] ?? $_ENV['LOG_DIR'] ?? $this->settings[SettingsKey::LOG_DIR->value] @@ -811,6 +813,23 @@ private function getLogicalScreenHeight(): int return max(1, (int)$this->getSettings(SettingsKey::SCREEN_HEIGHT->value)); } + /** + * Resolves the configured log level, allowing env to override file settings. + * + * @param array $settings + * @param string|null $existingValue + * @return string + */ + private static function resolveConfiguredLogLevelValue(array $settings = [], ?string $existingValue = null): string + { + $envValue = isset($_ENV['LOG_LEVEL']) ? trim((string)$_ENV['LOG_LEVEL']) : null; + $settingsValue = $settings[SettingsKey::LOG_LEVEL->value] + ?? $settings['logLevel'] + ?? null; + + return trim((string)($envValue ?? $settingsValue ?? $existingValue ?? DEFAULT_LOG_LEVEL)); + } + /** * Get the debug status. * diff --git a/src/IO/Console/Console.php b/src/IO/Console/Console.php index 20a866f..694cbe2 100644 --- a/src/IO/Console/Console.php +++ b/src/IO/Console/Console.php @@ -448,6 +448,7 @@ public static function writeLine(string $message, int $x, int $y): void $cursor = self::cursor(); $cursor->moveTo($columnStart, $row); echo $visibleMessage; + self::updateBuffer($visibleMessage, $columnStart, $row); } /** @@ -495,12 +496,52 @@ public static function getBuffer(): Grid */ public static function charAt(int $x, int $y): string { - if ($x < 0 || $x > DEFAULT_SCREEN_WIDTH || $y < 1 || $y > DEFAULT_SCREEN_HEIGHT) { + if ($x < 1 || $x > self::$width || $y < 1 || $y > self::$height) { return ''; } - $char = substr(self::$buffer[$y], $x, 1); - return ord($char) === 0 ? ' ' : $char; + return self::$buffer->get($x - 1, $y - 1); + } + + /** + * Returns a plain-text segment from the current terminal buffer using logical coordinates. + * + * @param int $x The logical x position. + * @param int $y The logical y position. + * @param int $width The number of visible glyphs to read. + * @return string + */ + public static function readLineSegment(int $x, int $y, int $width): string + { + if ($width < 1) { + return ''; + } + + if (!isset(self::$buffer)) { + self::$buffer = self::getEmptyBuffer(); + } + + $row = self::$renderOffsetY + $y - 1; + $columnStart = self::$renderOffsetX + $x - 1; + $buffer = ''; + + for ($column = 0; $column < $width; $column++) { + $terminalColumn = $columnStart + $column; + + if ( + $row < 1 || + $row > self::$height || + $terminalColumn < 1 || + $terminalColumn > self::$width + ) { + $buffer .= ' '; + continue; + } + + $buffer .= self::$buffer->get($terminalColumn - 1, $row - 1); + } + + return $buffer; } /** @@ -677,4 +718,38 @@ private static function toStyledGlyphs(string $message): array return $glyphs; } + + /** + * Mirrors visible console output into the terminal buffer. + * + * @param string $visibleMessage The already-clipped message written to the terminal. + * @param int $columnStart The terminal column where the write started. + * @param int $row The terminal row where the write started. + * @return void + */ + private static function updateBuffer(string $visibleMessage, int $columnStart, int $row): void + { + if (!isset(self::$buffer)) { + self::$buffer = self::getEmptyBuffer(); + } + + $plainText = preg_replace('/\033\[[0-9;]*m/', '', $visibleMessage) ?? ''; + $glyphs = Unicode::characters($plainText); + + foreach ($glyphs as $index => $glyph) { + $bufferX = ($columnStart - 1) + $index; + $bufferY = $row - 1; + + if ( + $bufferX < 0 || + $bufferX >= self::$width || + $bufferY < 0 || + $bufferY >= self::$height + ) { + continue; + } + + self::$buffer->set($bufferX, $bufferY, $glyph); + } + } } diff --git a/src/IO/Input.php b/src/IO/Input.php index 0476168..e4ca76e 100644 --- a/src/IO/Input.php +++ b/src/IO/Input.php @@ -26,10 +26,10 @@ public static function getAxis(AxisName $axisName): float /** * Checks if a key is pressed. * - * @param KeyCode $keyCode The key code to check. + * @param KeyCode|string $keyCode The key code to check. * @return bool Returns true if the key is pressed, false otherwise. */ - public static function isKeyPressed(KeyCode $keyCode): bool + public static function isKeyPressed(KeyCode|string $keyCode): bool { return InputManager::isKeyPressed($keyCode); } @@ -37,7 +37,7 @@ public static function isKeyPressed(KeyCode $keyCode): bool /** * Checks if all the given keys are pressed. * - * @param array $keyCodes The key codes to check. + * @param array $keyCodes The key codes to check. * @return bool Returns true if any key is pressed, false otherwise. */ public static function areAllKeysPressed(array $keyCodes): bool @@ -48,7 +48,7 @@ public static function areAllKeysPressed(array $keyCodes): bool /** * Checks if any of the given keys are pressed. * - * @param array $keyCodes + * @param array $keyCodes * @return bool Returns true if any key is pressed, false otherwise. */ public static function isAnyKeyPressed(array $keyCodes, bool $ignoreCase = true): bool @@ -59,7 +59,7 @@ public static function isAnyKeyPressed(array $keyCodes, bool $ignoreCase = true) /** * Checks if any of the given keys are released. * - * @param array $keyCodes The key codes to check. + * @param array $keyCodes The key codes to check. * @return bool Returns true if any key is released, false otherwise. */ public static function isAnyKeyReleased(array $keyCodes): bool @@ -70,10 +70,10 @@ public static function isAnyKeyReleased(array $keyCodes): bool /** * Checks if the given key is pressed. * - * @param KeyCode $keyCode The key code to check. + * @param KeyCode|string $keyCode The key code to check. * @return bool Returns true if the key is pressed, false otherwise. */ - public static function isKeyDown(KeyCode $keyCode): bool + public static function isKeyDown(KeyCode|string $keyCode): bool { return InputManager::isKeyDown($keyCode); } @@ -81,10 +81,10 @@ public static function isKeyDown(KeyCode $keyCode): bool /** * Checks if the given key was released. * - * @param KeyCode $keyCode The key code to check. + * @param KeyCode|string $keyCode The key code to check. * @return bool Returns true if the key was released, false otherwise. */ - public static function isKeyUp(KeyCode $keyCode): bool + public static function isKeyUp(KeyCode|string $keyCode): bool { return InputManager::isKeyUp($keyCode); } diff --git a/src/IO/InputManager.php b/src/IO/InputManager.php index 63ab5a4..7d872eb 100644 --- a/src/IO/InputManager.php +++ b/src/IO/InputManager.php @@ -192,7 +192,7 @@ public static function getAxis(AxisName|string $axisName): float /** * Checks if any key is pressed. * - * @param KeyCode[] $keyCodes The key codes to check. + * @param array $keyCodes The key codes to check. * @return bool Returns true if any key is pressed, false otherwise. */ public static function isAnyKeyPressed(array $keyCodes, bool $ignoreCase = true): bool @@ -203,11 +203,17 @@ public static function isAnyKeyPressed(array $keyCodes, bool $ignoreCase = true) /** * Checks if a key is pressed down. * - * @param KeyCode $keyCode The key code to check. + * @param KeyCode|string $keyCode The key code to check. * @return bool Returns true if the key is pressed down, false otherwise. */ - public static function isKeyDown(KeyCode $keyCode, bool $ignoreCase = true): bool + public static function isKeyDown(KeyCode|string $keyCode, bool $ignoreCase = true): bool { + $keyCode = self::normalizeKeyCode($keyCode); + + if ($keyCode === null) { + return false; + } + $key = self::getKey(self::$keyPress); $previousKey = self::getKey(self::$previousKeyPress); $keyCodeValue = $keyCode->value; @@ -224,7 +230,7 @@ public static function isKeyDown(KeyCode $keyCode, bool $ignoreCase = true): boo /** * Checks if all keys are pressed. * - * @param KeyCode[] $keyCodes The key codes to check. + * @param array $keyCodes The key codes to check. * @return bool Returns true if all keys are pressed, false otherwise. */ public static function areAllKeysPressed(array $keyCodes): bool @@ -235,18 +241,24 @@ public static function areAllKeysPressed(array $keyCodes): bool /** * Checks if a key is pressed. * - * @param KeyCode $keyCode The key code to check. + * @param KeyCode|string $keyCode The key code to check. * @return bool Returns true if the key is pressed, false otherwise. */ - public static function isKeyPressed(KeyCode $keyCode): bool + public static function isKeyPressed(KeyCode|string $keyCode): bool { + $keyCode = self::normalizeKeyCode($keyCode); + + if ($keyCode === null) { + return false; + } + return self::$keyPress === $keyCode->value; } /** * Checks if any of the given key codes was released. * - * @param KeyCode[] $keyCodes The key codes to check. + * @param array $keyCodes The key codes to check. * @return bool Returns true if any key is released, false otherwise. */ public static function isAnyKeyReleased(array $keyCodes): bool @@ -257,11 +269,17 @@ public static function isAnyKeyReleased(array $keyCodes): bool /** * Checks if a key is released. * - * @param KeyCode $keyCode The key code to check. + * @param KeyCode|string $keyCode The key code to check. * @return bool Returns true if the key is released, false otherwise. */ - public static function isKeyUp(KeyCode $keyCode): bool + public static function isKeyUp(KeyCode|string $keyCode): bool { + $keyCode = self::normalizeKeyCode($keyCode); + + if ($keyCode === null) { + return false; + } + $key = self::getKey(self::$keyPress); $previousKey = self::getKey(self::$previousKeyPress); @@ -309,4 +327,32 @@ private static function findAxis(string $axisName): ?VirtualAxis { return array_filter(self::$axes, fn($axis) => $axis->getName() === $axisName)[0] ?? null; } + + /** + * Normalizes persisted/string key codes into enum instances. + * + * Supports raw enum values like `escape`, single characters like `R`, and editor-style wrapped values like ``. + * + * @param KeyCode|string $keyCode + * @return KeyCode|null + */ + private static function normalizeKeyCode(KeyCode|string $keyCode): ?KeyCode + { + if ($keyCode instanceof KeyCode) { + return $keyCode; + } + + $candidate = trim($keyCode); + + if ($candidate === '') { + return null; + } + + if (preg_match('/^<(.+)>$/', $candidate, $matches) === 1) { + $candidate = trim($matches[1]); + } + + return KeyCode::tryFrom($candidate) + ?? KeyCode::tryFrom(mb_strtolower($candidate)); + } } diff --git a/src/Metadata/SceneMetadata.php b/src/Metadata/SceneMetadata.php index 1b54245..34fbdda 100644 --- a/src/Metadata/SceneMetadata.php +++ b/src/Metadata/SceneMetadata.php @@ -9,6 +9,7 @@ class SceneMetadata public int $width = DEFAULT_SCREEN_WIDTH; public int $height = DEFAULT_SCREEN_HEIGHT; public string $environmentTileMapPath = ''; + public string $environmentCollisionMapPath = ''; /** @var SceneObjectMetadataInterface[] */ public array $hierarchy = []; -} \ No newline at end of file +} diff --git a/src/Physics/CharacterController.php b/src/Physics/CharacterController.php index 0b2cf9b..0f8cbee 100644 --- a/src/Physics/CharacterController.php +++ b/src/Physics/CharacterController.php @@ -37,7 +37,7 @@ class CharacterController extends Collider implements ObservableInterface protected ItemList $staticObservers; /** - * @var array> The previous collisions. + * @var array> The previous collisions. */ private array $previousCollisions = []; @@ -64,16 +64,31 @@ public function move(Vector2 $motion): void { $collisions = $this->physics?->checkCollisions($this, $motion) ?? []; $blockingCollisionCount = 0; + $currentCollisions = []; foreach ($collisions as $collision) { - $this->resolveCollision($collision); + $collisionKey = $this->getCollisionKey($collision); + + if ($collisionKey !== null && !isset($currentCollisions[$collisionKey])) { + $currentCollisions[$collisionKey] = $collision; + $this->dispatchCollision( + isset($this->previousCollisions[$collisionKey]) ? 'onCollisionStay' : 'onCollisionEnter', + $collision + ); + } if (!($collision->getContact(0)?->getOtherCollider()?->isTrigger() ?? false)) { $blockingCollisionCount++; } } - $this->previousCollisions = $collisions; + foreach ($this->previousCollisions as $collisionKey => $collision) { + if (!isset($currentCollisions[$collisionKey])) { + $this->dispatchCollision('onCollisionExit', $collision); + } + } + + $this->previousCollisions = $currentCollisions; if ($blockingCollisionCount === 0) { $this->getTransform()->translate($motion); @@ -86,35 +101,55 @@ public function move(Vector2 $motion): void * @param CollisionInterface $collision The collision. * @return void */ - private function resolveCollision(CollisionInterface $collision): void + private function dispatchCollision(string $methodName, CollisionInterface $collision): void { - // TODO: Implement collision resolution. - $methodName = match (true) { - $this->previousCollisionsIncludes($collision) => "onCollisionStay", - default => "onCollisionEnter" - }; - - $collision->getContact(0)?->getThisCollider()->getGameObject()->broadcast($methodName, ['collision' => $collision]); - $collision->getContact(0)?->getOtherCollider()->getGameObject()->broadcast($methodName, ['collision' => $collision]); + $contact = $collision->getContact(0); + $otherCollider = $contact?->getOtherCollider(); + + $this->getGameObject()->broadcast($methodName, ['collision' => $collision]); + + if ($otherCollider !== null) { + $mirroredCollision = new Collision( + $this, + [ + new ContactPoint( + Vector2::getClone($contact->getPoint()), + $otherCollider, + $this, + ), + ], + ); + + $otherCollider->getGameObject()->broadcast($methodName, ['collision' => $mirroredCollision]); + } Debug::log("Collision for {$collision->getGameObject()->getName()} at " . $collision->getContact(0)?->getPoint()); } /** - * Checks if the previous collisions includes the collision. + * Returns a stable key for the collision target. * * @param CollisionInterface $collision The collision. - * @return bool + * @return string|null */ - private function previousCollisionsIncludes(CollisionInterface $collision): bool + private function getCollisionKey(CollisionInterface $collision): ?string { - foreach ($this->previousCollisions as $previousCollision) { - if ($previousCollision->getContact(0)?->getOtherCollider() === $collision->getContact(0)?->getOtherCollider()) { - return true; + $contact = $collision->getContact(0); + $otherCollider = $contact?->getOtherCollider(); + + if ($otherCollider !== null) { + return $otherCollider->getHash(); + } + + if ($collision instanceof EnvironmentCollision) { + $point = $contact?->getPoint(); + + if ($point !== null) { + return 'environment:' . $point->getX() . ':' . $point->getY(); } } - return false; + return null; } /** diff --git a/src/Physics/Collider.php b/src/Physics/Collider.php index f854120..adfba6b 100644 --- a/src/Physics/Collider.php +++ b/src/Physics/Collider.php @@ -3,6 +3,7 @@ namespace Sendama\Engine\Physics; use Override; +use Sendama\Engine\Core\Behaviours\Attributes\SerializeField; use Sendama\Engine\Core\Component; use Sendama\Engine\Physics\Interfaces\ColliderInterface; use Sendama\Engine\Physics\Interfaces\CollisionDetectionStrategyInterface; @@ -28,12 +29,19 @@ class Collider extends Component implements ColliderInterface */ protected ?Physics $physics = null; + #[SerializeField] /** * Whether the collider is a trigger. * * @var bool */ protected bool $isTrigger = false; + /** + * The physics material used when resolving friction and bounce. + * + * @var PhysicsMaterial + */ + protected PhysicsMaterial $material; /** * The collision detection strategy. * @@ -49,6 +57,7 @@ public final function awake(): void { $this->physics = Physics::getInstance(); $this->collisionDetectionStrategy = new AABBCollisionDetectionStrategy($this); + $this->material = new PhysicsMaterial(); } /** @@ -88,7 +97,34 @@ public function setCollisionDetectionStrategy(CollisionDetectionStrategyInterfac */ public function configure(array $options = []): void { - // Do nothing + if (array_key_exists('isTrigger', $options)) { + $this->setTrigger((bool)$options['isTrigger']); + } + + if (array_key_exists('material', $options)) { + $this->setMaterial($options['material']); + } + } + + /** + * Returns the collider's physics material. + * + * @return PhysicsMaterial + */ + public function getMaterial(): PhysicsMaterial + { + return $this->material; + } + + /** + * Sets the collider's physics material. + * + * @param mixed $material + * @return void + */ + public function setMaterial(mixed $material): void + { + $this->material = PhysicsMaterial::fromMetadata($material); } /** diff --git a/src/Physics/ContactPoint.php b/src/Physics/ContactPoint.php index bc68fd0..e39a127 100644 --- a/src/Physics/ContactPoint.php +++ b/src/Physics/ContactPoint.php @@ -17,12 +17,12 @@ * * @param Vector2 $point The point of contact. * @param ColliderInterface $thisCollider The collider of the game object that this contact point belongs to. - * @param ColliderInterface $otherCollider The collider of the other game object that this contact point belongs to. + * @param ColliderInterface|null $otherCollider The collider of the other game object that this contact point belongs to. */ public function __construct( protected Vector2 $point, protected ColliderInterface $thisCollider, - protected ColliderInterface $otherCollider, + protected ?ColliderInterface $otherCollider, ) { } @@ -50,9 +50,9 @@ public function getThisCollider(): ColliderInterface /** * Get the collider of the other game object that this contact point belongs to. * - * @return ColliderInterface The collider of the other game object that this contact point belongs to. + * @return ColliderInterface|null The collider of the other game object that this contact point belongs to. */ - public function getOtherCollider(): ColliderInterface + public function getOtherCollider(): ?ColliderInterface { return $this->otherCollider; } @@ -64,6 +64,10 @@ public function getOtherCollider(): ColliderInterface */ public function getNormal(): Vector2 { + if ($this->otherCollider === null) { + return Vector2::difference($this->point, $this->thisCollider->getTransform()->getPosition())->getNormalized(); + } + $otherPosition = $this->otherCollider->getTransform()->getPosition(); $thisPosition = $this->thisCollider->getTransform()->getPosition(); @@ -77,9 +81,13 @@ 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()->getPosition(), $this->otherCollider->getTransform()->getPosition() ); } -} \ No newline at end of file +} diff --git a/src/Physics/EnvironmentCollision.php b/src/Physics/EnvironmentCollision.php new file mode 100644 index 0000000..ef58f85 --- /dev/null +++ b/src/Physics/EnvironmentCollision.php @@ -0,0 +1,51 @@ + + */ +class EnvironmentCollision extends Collision +{ + private GameObject $environmentGameObject; + + public function __construct(ColliderInterface $collider, Vector2 $point) + { + parent::__construct( + $collider, + [ + new ContactPoint( + Vector2::getClone($point), + $collider, + null, + ), + ], + ); + + $this->environmentGameObject = new GameObject( + name: 'Environment', + tag: 'Environment', + position: Vector2::getClone($point), + ); + } + + public function getGameObject(): GameObject + { + return $this->environmentGameObject; + } + + public function getTransform(): Transform + { + return $this->environmentGameObject->getTransform(); + } +} diff --git a/src/Physics/Physics.php b/src/Physics/Physics.php index 6a3c626..e52c337 100644 --- a/src/Physics/Physics.php +++ b/src/Physics/Physics.php @@ -159,9 +159,36 @@ protected function dispatchCollisions(): void */ public function addCollider(ColliderInterface $collider): void { + foreach ($this->colliders as $existingCollider) { + if ($existingCollider === $collider) { + return; + } + } + $this->colliders->add($collider); } + /** + * Returns the scalar gravity strength used by rigid bodies. + * + * @return float + */ + public function getGravity(): float + { + return $this->gravity; + } + + /** + * Sets the scalar gravity strength used by rigid bodies. + * + * @param float $gravity + * @return void + */ + public function setGravity(float $gravity): void + { + $this->gravity = $gravity; + } + /** * Removes a collider from the physics engine. * @@ -187,6 +214,10 @@ public function checkCollisions(ColliderInterface $collider, Vector2 $motion): a $collisions = []; $projectedBounds = $this->getProjectedBounds($collider, $motion); + if ($environmentCollision = $this->getEnvironmentCollision($collider, $projectedBounds)) { + $collisions[] = $environmentCollision; + } + foreach ($this->colliders as $otherCollider) { if ($otherCollider === $collider || $otherCollider->getGameObject() === $collider->getGameObject()) { continue; @@ -264,4 +295,35 @@ private function getProjectedBounds(ColliderInterface $collider, Vector2 $motion { return $collider->getBoundingBox()->translated($motion); } + + /** + * Returns an environment collision if the projected bounds overlap the static collision map. + * + * @param ColliderInterface $collider + * @param Rect $projectedBounds + * @return EnvironmentCollision|null + */ + private function getEnvironmentCollision(ColliderInterface $collider, Rect $projectedBounds): ?EnvironmentCollision + { + $minX = max(0, $projectedBounds->getX()); + $minY = max(0, $projectedBounds->getY()); + $maxX = min($this->staticCollisionMap->getWidth() - 1, $projectedBounds->getX() + $projectedBounds->getWidth() - 1); + $maxY = min($this->staticCollisionMap->getHeight() - 1, $projectedBounds->getY() + $projectedBounds->getHeight() - 1); + + if ($maxX < $minX || $maxY < $minY) { + return null; + } + + for ($y = $minY; $y <= $maxY; $y++) { + for ($x = $minX; $x <= $maxX; $x++) { + if ($this->staticCollisionMap->get($x, $y) !== 1) { + continue; + } + + return new EnvironmentCollision($collider, new Vector2($x, $y)); + } + } + + return null; + } } diff --git a/src/Physics/PhysicsMaterial.php b/src/Physics/PhysicsMaterial.php index b352c2b..632a65c 100644 --- a/src/Physics/PhysicsMaterial.php +++ b/src/Physics/PhysicsMaterial.php @@ -2,11 +2,13 @@ namespace Sendama\Engine\Physics; +use Sendama\Engine\Metadata\PhysicsMaterialMetadata; + /** * A physics material defines the physical properties of a collider, such as its friction and bounciness. * It can be used to create different types of surfaces, such as slippery ice or sticky mud. */ -final readonly class PhysicsMaterial +final class PhysicsMaterial { /** * PhysicsMaterial constructor. @@ -19,5 +21,89 @@ public function __construct( public float $bounciness = 0.5 ) { + $this->friction = self::clamp01($this->friction); + $this->bounciness = self::clamp01($this->bounciness); + } + + /** + * Creates a material from scene metadata, a plain array, or an existing material. + * + * @param mixed $source + * @return self + */ + public static function fromMetadata(mixed $source): self + { + if ($source instanceof self) { + return $source; + } + + if ($source instanceof PhysicsMaterialMetadata) { + return new self($source->friction, $source->bounciness); + } + + if (is_array($source)) { + return new self( + (float)($source['friction'] ?? PhysicsMaterialMetadata::DEFAULT_FRICTION), + (float)($source['bounciness'] ?? PhysicsMaterialMetadata::DEFAULT_BOUNCINESS) + ); + } + + if (is_object($source)) { + return new self( + (float)($source->friction ?? PhysicsMaterialMetadata::DEFAULT_FRICTION), + (float)($source->bounciness ?? PhysicsMaterialMetadata::DEFAULT_BOUNCINESS) + ); + } + + return new self(); + } + + /** + * Returns a combined material for two colliders touching one another. + * + * @param self|null $other + * @return self + */ + public function combine(?self $other = null): self + { + $other ??= new self(); + + return new self( + ($this->friction + $other->friction) / 2, + ($this->bounciness + $other->bounciness) / 2 + ); + } + + /** + * Applies friction to a tangential velocity component. + * + * @param float $velocity + * @return float + */ + public function applyFriction(float $velocity): float + { + return $velocity * max(0.0, 1.0 - $this->friction); + } + + /** + * Applies restitution to a normal velocity component. + * + * @param float $velocity + * @return float + */ + public function applyBounce(float $velocity): float + { + return -$velocity * $this->bounciness; + } + + /** + * Clamp a normalized physics coefficient to the valid range. + * + * @param float $value + * @return float + */ + private static function clamp01(float $value): float + { + return max(0.0, min(1.0, $value)); } -} \ No newline at end of file +} diff --git a/src/Physics/Rigidbody.php b/src/Physics/Rigidbody.php index 655c6f2..fcc2a50 100644 --- a/src/Physics/Rigidbody.php +++ b/src/Physics/Rigidbody.php @@ -2,88 +2,794 @@ namespace Sendama\Engine\Physics; -use Sendama\Engine\Core\Component; +use Sendama\Engine\Core\Time; +use Sendama\Engine\Core\Vector2; +use Sendama\Engine\Metadata\PhysicsMaterialMetadata; use Sendama\Engine\Physics\Interfaces\ColliderInterface; -use Sendama\Engine\Physics\Interfaces\CollisionDetectionStrategyInterface; -use Sendama\Engine\Physics\Strategies\AABBCollisionDetectionStrategy; -use Sendama\Engine\Physics\Traits\BoundTrait; +use Sendama\Engine\Physics\Interfaces\CollisionInterface; /** - * The class Rigidbody. + * A Rigidbody provides force-driven movement that is still constrained by the engine's collider checks. * - * @package Sendama\Engine\Physics - * - * @template T - * @implements ColliderInterface + * The implementation keeps float simulation state internally so forces and drag can accumulate smoothly, + * while final movement is resolved against the integer terminal grid one cell at a time. */ -class Rigidbody extends Component implements ColliderInterface +class Rigidbody extends Collider { - use BoundTrait; - - /** - * @var CollisionDetectionStrategyInterface|null The collision detection strategy. - */ - protected ?CollisionDetectionStrategyInterface $collisionDetectionStrategy = null; - - /** - * @inheritDoc - */ - public function onStart(): void - { - $this->collisionDetectionStrategy = new AABBCollisionDetectionStrategy($this); - } - - /** - * @inheritDoc - * - * @param ColliderInterface $collider The collider to check if it is touching. - */ - public function isTouching(ColliderInterface $collider): bool - { - if (!$this->collisionDetectionStrategy) { - return false; - } - - return $this->collisionDetectionStrategy->isTouching($collider); - } - - /** - * @inheritDoc - */ - public function isTrigger(): bool - { - return false; - } - - /** - * @inheritDoc - */ - public function setTrigger(bool $isTrigger): void - { - // Do nothing. - } - - /** - * @inheritDoc - */ - public function setCollisionDetectionStrategy(CollisionDetectionStrategyInterface $collisionDetectionStrategy): void - { - $this->collisionDetectionStrategy = $collisionDetectionStrategy; - } - - /** - * @inheritDoc - */ - public function configure(array $options = []): void - { - // Do nothing - } - - /** - * @inheritDoc - */ - public function simulate(): void - { - // TODO: Implement simulate() method. - } + private const float DEFAULT_FIXED_DELTA_TIME = 0.0166666667; + private const float VELOCITY_EPSILON = 0.0001; + + protected float $mass = 1.0; + protected float $drag = 0.0; + protected float $angularDrag = 0.0; + protected bool $useGravity = false; + protected bool $freezePositionX = false; + protected bool $freezePositionY = false; + protected bool $freezeRotation = false; + + protected float $velocityX = 0.0; + protected float $velocityY = 0.0; + protected float $angularVelocity = 0.0; + protected float $accumulatedForceX = 0.0; + protected float $accumulatedForceY = 0.0; + protected float $accumulatedTorque = 0.0; + protected float $simulatedPositionX = 0.0; + protected float $simulatedPositionY = 0.0; + protected float $simulatedRotationX = 0.0; + protected float $simulatedRotationY = 0.0; + protected bool $simulationStateInitialized = false; + /** + * @var array + */ + protected array $previousCollisions = []; + /** + * @var array + */ + protected array $currentCollisions = []; + + protected ?Vector2 $queuedPositionTarget = null; + protected ?Vector2 $queuedRotationTarget = null; + + public function onStart(): void + { + $this->syncSimulationState(force: true); + } + + public function onFixedUpdate(): void + { + $this->simulate(); + } + + /** + * Moves the rigidbody toward an absolute world position on the next physics step. + * + * @param Vector2 $position + * @return void + */ + public function movePosition(Vector2 $position): void + { + $targetPosition = Vector2::getClone($position); + + // Guard against callers mutating the live transform position before queueing a constrained move. + if ($position === $this->getTransform()->getPosition()) { + $this->restoreTransformPositionFromSimulationState(); + } + + $this->queuedPositionTarget = $targetPosition; + } + + /** + * Moves the rigidbody toward an absolute world position and rotation on the next physics step. + * + * @param Vector2 $position + * @param Vector2 $rotation + * @return void + */ + public function movePositionAndRotation(Vector2 $position, Vector2 $rotation): void + { + $this->movePosition($position); + $this->moveRotation($rotation); + } + + /** + * Rotates the rigidbody toward an absolute rotation on the next physics step. + * + * @param Vector2 $rotation + * @return void + */ + public function moveRotation(Vector2 $rotation): void + { + $this->queuedRotationTarget = Vector2::getClone($rotation); + } + + /** + * Adds a world-space force to the rigidbody. + * + * @param Vector2 $force + * @return void + */ + public function addForce(Vector2 $force): void + { + $this->accumulatedForceX += $force->getX(); + $this->accumulatedForceY += $force->getY(); + } + + /** + * Adds a force in the rigidbody's local space. + * + * @param Vector2 $force + * @return void + */ + public function addRelativeForce(Vector2 $force): void + { + [$x, $y] = $this->rotateVectorToWorld($force->getX(), $force->getY()); + $this->addForceComponents($x, $y); + } + + /** + * Adds a force at the given world-space position and accumulates torque. + * + * @param Vector2 $force + * @param Vector2 $position + * @return void + */ + public function addForceAtPosition(Vector2 $force, Vector2 $position): void + { + $this->addForce($force); + + $bounds = $this->getBoundingBox(); + $centerX = $bounds->getX() + ($bounds->getWidth() / 2); + $centerY = $bounds->getY() + ($bounds->getHeight() / 2); + $offsetX = $position->getX() - $centerX; + $offsetY = $position->getY() - $centerY; + + $this->accumulatedTorque += ($offsetX * $force->getY()) - ($offsetY * $force->getX()); + } + + /** + * Adds a local-space force at a local-space offset from the rigidbody center. + * + * @param Vector2 $force + * @param Vector2 $position + * @return void + */ + public function addRelativeForceAtPosition(Vector2 $force, Vector2 $position): void + { + [$forceX, $forceY] = $this->rotateVectorToWorld($force->getX(), $force->getY()); + [$offsetX, $offsetY] = $this->rotateVectorToWorld($position->getX(), $position->getY()); + $worldPosition = new Vector2( + (int)round($this->simulatedPositionX + $offsetX), + (int)round($this->simulatedPositionY + $offsetY) + ); + + $this->addForceAtPosition( + new Vector2((int)round($forceX), (int)round($forceY)), + $worldPosition + ); + } + + /** + * Adds force along the world-space x-axis. + * + * @param int|float $force + * @return void + */ + public function addForceX(int|float $force): void + { + $this->accumulatedForceX += $force; + } + + /** + * Adds force along the world-space y-axis. + * + * @param int|float $force + * @return void + */ + public function addForceY(int|float $force): void + { + $this->accumulatedForceY += $force; + } + + /** + * Adds force along the rigidbody's local x-axis. + * + * @param int|float $force + * @return void + */ + public function addRelativeForceX(int|float $force): void + { + [$x, $y] = $this->rotateVectorToWorld($force, 0.0); + $this->addForceComponents($x, $y); + } + + /** + * Adds force along the rigidbody's local y-axis. + * + * @param int|float $force + * @return void + */ + public function addRelativeForceY(int|float $force): void + { + [$x, $y] = $this->rotateVectorToWorld(0.0, $force); + $this->addForceComponents($x, $y); + } + + /** + * @inheritDoc + */ + public function configure(array $options = []): void + { + parent::configure($options); + + if (array_key_exists('mass', $options)) { + $this->setMass((float)$options['mass']); + } + + if (array_key_exists('drag', $options)) { + $this->setDrag((float)$options['drag']); + } + + if (array_key_exists('angularDrag', $options)) { + $this->setAngularDrag((float)$options['angularDrag']); + } + + if (array_key_exists('useGravity', $options)) { + $this->setUseGravity((bool)$options['useGravity']); + } + } + + /** + * Advances the rigidbody simulation by one fixed step. + * + * @return void + */ + public function simulate(): void + { + $this->syncSimulationState(); + $this->currentCollisions = []; + + $deltaTime = $this->resolveDeltaTime(); + $this->integrateForces($deltaTime); + + $targetPositionX = $this->queuedPositionTarget?->getX() ?? ($this->simulatedPositionX + ($this->velocityX * $deltaTime)); + $targetPositionY = $this->queuedPositionTarget?->getY() ?? ($this->simulatedPositionY + ($this->velocityY * $deltaTime)); + + if ($this->freezePositionX) { + $targetPositionX = $this->simulatedPositionX; + $this->velocityX = 0.0; + } + + if ($this->freezePositionY) { + $targetPositionY = $this->simulatedPositionY; + $this->velocityY = 0.0; + } + + [$this->simulatedPositionX, $this->simulatedPositionY] = $this->applyLinearMotion( + $targetPositionX, + $targetPositionY, + $deltaTime + ); + + $targetRotationX = $this->queuedRotationTarget?->getX() ?? ($this->simulatedRotationX + ($this->angularVelocity * $deltaTime)); + $targetRotationY = $this->queuedRotationTarget?->getY() ?? $this->simulatedRotationY; + + if ($this->freezeRotation) { + $targetRotationX = $this->simulatedRotationX; + $targetRotationY = $this->simulatedRotationY; + $this->angularVelocity = 0.0; + } + + $this->applyRotationalMotion($targetRotationX, $targetRotationY); + $this->dispatchExitedCollisions(); + $this->previousCollisions = $this->currentCollisions; + $this->clearQueuedMovement(); + } + + /** + * Returns the current world-space velocity rounded to grid units. + * + * @return Vector2 + */ + public function getVelocity(): Vector2 + { + return new Vector2( + (int)round($this->velocityX), + (int)round($this->velocityY) + ); + } + + /** + * Sets the current world-space velocity. + * + * @param Vector2 $velocity + * @return void + */ + public function setVelocity(Vector2 $velocity): void + { + $this->velocityX = $velocity->getX(); + $this->velocityY = $velocity->getY(); + } + + /** + * Returns the current angular velocity. + * + * @return float + */ + public function getAngularVelocity(): float + { + return $this->angularVelocity; + } + + /** + * Sets the current angular velocity. + * + * @param float $angularVelocity + * @return void + */ + public function setAngularVelocity(float $angularVelocity): void + { + $this->angularVelocity = $angularVelocity; + } + + /** + * Returns the rigidbody mass. + * + * @return float + */ + public function getMass(): float + { + return $this->mass; + } + + /** + * Sets the rigidbody mass. + * + * @param float $mass + * @return void + */ + public function setMass(float $mass): void + { + $this->mass = max(0.0001, $mass); + } + + /** + * Sets linear drag. + * + * @param float $drag + * @return void + */ + public function setDrag(float $drag): void + { + $this->drag = max(0.0, $drag); + } + + /** + * Sets angular drag. + * + * @param float $drag + * @return void + */ + public function setAngularDrag(float $drag): void + { + $this->angularDrag = max(0.0, $drag); + } + + /** + * Toggles gravity for this rigidbody. + * + * @param bool $useGravity + * @return void + */ + public function setUseGravity(bool $useGravity): void + { + $this->useGravity = $useGravity; + } + + /** + * Sets the physics material from metadata or a concrete material. + * + * @param mixed $material + * @return void + */ + public function setMaterial(mixed $material): void + { + parent::setMaterial($material); + } + + /** + * Integrates forces and drag into the current velocity state. + * + * @param float $deltaTime + * @return void + */ + private function integrateForces(float $deltaTime): void + { + $inverseMass = 1.0 / $this->mass; + $forceX = $this->accumulatedForceX; + $forceY = $this->accumulatedForceY; + + if ($this->useGravity && $this->physics) { + $forceY += $this->physics->getGravity() * $this->mass; + } + + $this->velocityX += ($forceX * $inverseMass) * $deltaTime; + $this->velocityY += ($forceY * $inverseMass) * $deltaTime; + $this->angularVelocity += ($this->accumulatedTorque * $inverseMass) * $deltaTime; + + $linearDragFactor = max(0.0, 1.0 - ($this->drag * $deltaTime)); + $angularDragFactor = max(0.0, 1.0 - ($this->angularDrag * $deltaTime)); + + $this->velocityX *= $linearDragFactor; + $this->velocityY *= $linearDragFactor; + $this->angularVelocity *= $angularDragFactor; + + $this->velocityX = $this->sanitizeVelocity($this->velocityX); + $this->velocityY = $this->sanitizeVelocity($this->velocityY); + $this->angularVelocity = $this->sanitizeVelocity($this->angularVelocity); + } + + /** + * Applies collision-constrained translation to the target float position. + * + * @param float $targetPositionX + * @param float $targetPositionY + * @param float $deltaTime + * @return array{0: float, 1: float} + */ + private function applyLinearMotion(float $targetPositionX, float $targetPositionY, float $deltaTime): array + { + $resolvedPositionX = $this->applyAxisMotion('x', $targetPositionX); + $this->simulatedPositionX = $resolvedPositionX; + + if ($this->queuedPositionTarget === null) { + $targetPositionY = $this->simulatedPositionY + ($this->velocityY * $deltaTime); + } + + $resolvedPositionY = $this->applyAxisMotion('y', $targetPositionY); + + return [$resolvedPositionX, $resolvedPositionY]; + } + + /** + * Applies integer cell-by-cell movement along a single axis. + * + * @param string $axis + * @param float $targetPosition + * @return float + */ + private function applyAxisMotion(string $axis, float $targetPosition): float + { + $currentGrid = $axis === 'x' + ? $this->getTransform()->getPosition()->getX() + : $this->getTransform()->getPosition()->getY(); + $desiredGrid = $this->gridCoordinateFromFloat($targetPosition); + $remainingSteps = $desiredGrid - $currentGrid; + + if ($remainingSteps === 0) { + return $targetPosition; + } + + $direction = $remainingSteps < 0 ? -1 : 1; + + while ($remainingSteps !== 0) { + $motion = $axis === 'x' + ? new Vector2($direction, 0) + : new Vector2(0, $direction); + $collisions = $this->physics?->checkCollisions($this, $motion) ?? []; + $this->dispatchCollisions($collisions); + $blockingCollision = $this->getFirstBlockingCollision($collisions); + + if ($blockingCollision) { + $this->applyCollisionResponse($axis, $blockingCollision); + return (float)$currentGrid; + } + + $this->getTransform()->translate($motion); + $currentGrid += $direction; + $remainingSteps -= $direction; + } + + return $targetPosition; + } + + /** + * Applies a simple material-based bounce and tangential friction response. + * + * @param string $axis + * @param CollisionInterface $collision + * @return void + */ + private function applyCollisionResponse(string $axis, CollisionInterface $collision): void + { + $otherCollider = $collision->getContact(0)?->getOtherCollider(); + $otherMaterial = $otherCollider && method_exists($otherCollider, 'getMaterial') + ? $otherCollider->getMaterial() + : new PhysicsMaterial(); + $combinedMaterial = $this->getMaterial()->combine($otherMaterial); + + if ($axis === 'x') { + $this->velocityX = $this->sanitizeVelocity($combinedMaterial->applyBounce($this->velocityX)); + $this->velocityY = $this->sanitizeVelocity($combinedMaterial->applyFriction($this->velocityY)); + return; + } + + $this->velocityY = $this->sanitizeVelocity($combinedMaterial->applyBounce($this->velocityY)); + $this->velocityX = $this->sanitizeVelocity($combinedMaterial->applyFriction($this->velocityX)); + } + + /** + * Returns the first collision that should block rigidbody movement. + * + * @param array $collisions + * @return CollisionInterface|null + */ + private function getFirstBlockingCollision(array $collisions): ?CollisionInterface + { + foreach ($collisions as $collision) { + if (!($collision->getContact(0)?->getOtherCollider()?->isTrigger() ?? false)) { + return $collision; + } + } + + return null; + } + + /** + * Broadcasts enter/stay collision events for the unique collisions detected during this simulation step. + * + * @param array $collisions + * @return void + */ + private function dispatchCollisions(array $collisions): void + { + foreach ($collisions as $collision) { + $collisionKey = $this->getCollisionKey($collision); + + if ($collisionKey === null || isset($this->currentCollisions[$collisionKey])) { + continue; + } + + $this->currentCollisions[$collisionKey] = $collision; + $methodName = isset($this->previousCollisions[$collisionKey]) + ? 'onCollisionStay' + : 'onCollisionEnter'; + + $this->broadcastCollisionEvent($methodName, $collision); + } + } + + /** + * Broadcasts collision exit events for any collision that ended this simulation step. + * + * @return void + */ + private function dispatchExitedCollisions(): void + { + foreach ($this->previousCollisions as $collisionKey => $collision) { + if (isset($this->currentCollisions[$collisionKey])) { + continue; + } + + $this->broadcastCollisionEvent('onCollisionExit', $collision); + } + } + + /** + * Broadcasts the collision event to this rigidbody and a mirrored collision event to the other collider. + * + * @param string $methodName + * @param CollisionInterface $collision + * @return void + */ + private function broadcastCollisionEvent(string $methodName, CollisionInterface $collision): void + { + $contact = $collision->getContact(0); + $otherCollider = $contact?->getOtherCollider(); + + if ($contact === null) { + return; + } + + $this->getGameObject()->broadcast($methodName, ['collision' => $collision]); + + if ($otherCollider === null) { + return; + } + + $mirroredCollision = new Collision( + $this, + [ + new ContactPoint( + Vector2::getClone($contact->getPoint()), + $otherCollider, + $this, + ), + ], + ); + + $otherCollider->getGameObject()->broadcast($methodName, ['collision' => $mirroredCollision]); + } + + /** + * Returns a stable key for the other collider participating in a collision. + * + * @param CollisionInterface $collision + * @return string|null + */ + private function getCollisionKey(CollisionInterface $collision): ?string + { + $contact = $collision->getContact(0); + $otherCollider = $contact?->getOtherCollider(); + + if ($otherCollider !== null) { + return $otherCollider->getHash(); + } + + if ($collision instanceof EnvironmentCollision) { + $point = $contact?->getPoint(); + + if ($point !== null) { + return 'environment:' . $point->getX() . ':' . $point->getY(); + } + } + + return null; + } + + /** + * Applies the resolved rotation to the transform. + * + * @param float $targetRotationX + * @param float $targetRotationY + * @return void + */ + private function applyRotationalMotion(float $targetRotationX, float $targetRotationY): void + { + $this->simulatedRotationX = $targetRotationX; + $this->simulatedRotationY = $targetRotationY; + + $this->getTransform()->setRotation( + new Vector2( + (int)round($targetRotationX), + (int)round($targetRotationY) + ) + ); + } + + /** + * Sync internal float state from the current transform when needed. + * + * @param bool $force + * @return void + */ + private function syncSimulationState(bool $force = false): void + { + $position = $this->getTransform()->getPosition(); + $rotation = $this->getTransform()->getRotation(); + + if ( + $force || + !$this->simulationStateInitialized || + $this->gridCoordinateFromFloat($this->simulatedPositionX) !== $position->getX() || + $this->gridCoordinateFromFloat($this->simulatedPositionY) !== $position->getY() || + (int)round($this->simulatedRotationX) !== $rotation->getX() || + (int)round($this->simulatedRotationY) !== $rotation->getY() + ) { + $this->simulatedPositionX = $position->getX(); + $this->simulatedPositionY = $position->getY(); + $this->simulatedRotationX = $rotation->getX(); + $this->simulatedRotationY = $rotation->getY(); + $this->simulationStateInitialized = true; + } + } + + /** + * Restores the transform position to the last physics-simulated cell. + * + * This keeps queued rigidbody movement constrained even if a caller mutated the live transform position vector. + * + * @return void + */ + private function restoreTransformPositionFromSimulationState(): void + { + if (!$this->simulationStateInitialized) { + $this->syncSimulationState(force: true); + return; + } + + $this->getTransform()->setPosition( + new Vector2( + $this->gridCoordinateFromFloat($this->simulatedPositionX), + $this->gridCoordinateFromFloat($this->simulatedPositionY), + ) + ); + } + + /** + * Clears one-shot movement and force accumulators after a simulation step. + * + * @return void + */ + private function clearQueuedMovement(): void + { + $this->queuedPositionTarget = null; + $this->queuedRotationTarget = null; + $this->accumulatedForceX = 0.0; + $this->accumulatedForceY = 0.0; + $this->accumulatedTorque = 0.0; + } + + /** + * Resolves the current fixed-step delta time. + * + * @return float + */ + private function resolveDeltaTime(): float + { + $deltaTime = Time::getDeltaTime(); + + if ($deltaTime <= self::VELOCITY_EPSILON) { + return self::DEFAULT_FIXED_DELTA_TIME; + } + + return $deltaTime; + } + + /** + * Rotates local-space vector components into world space using the current x rotation as the 2D angle. + * + * @param float $x + * @param float $y + * @return array{0: float, 1: float} + */ + private function rotateVectorToWorld(float $x, float $y): array + { + $this->syncSimulationState(); + + $angleInRadians = deg2rad($this->simulatedRotationX); + $cosine = cos($angleInRadians); + $sine = sin($angleInRadians); + + return [ + ($x * $cosine) - ($y * $sine), + ($x * $sine) + ($y * $cosine), + ]; + } + + /** + * Adds raw float force components to the accumulator. + * + * @param float $x + * @param float $y + * @return void + */ + private function addForceComponents(float $x, float $y): void + { + $this->accumulatedForceX += $x; + $this->accumulatedForceY += $y; + } + + /** + * Converts a float simulation coordinate to the corresponding grid cell without losing sub-cell accumulation. + * + * @param float $value + * @return int + */ + private function gridCoordinateFromFloat(float $value): int + { + return $value >= 0 + ? (int)floor($value) + : (int)ceil($value); + } + + /** + * Zeroes tiny velocities so materials and drag settle cleanly. + * + * @param float $velocity + * @return float + */ + private function sanitizeVelocity(float $velocity): float + { + return abs($velocity) < self::VELOCITY_EPSILON ? 0.0 : $velocity; + } } diff --git a/src/Util/Functions.php b/src/Util/Functions.php index 68debe3..3e52f0a 100644 --- a/src/Util/Functions.php +++ b/src/Util/Functions.php @@ -3,6 +3,7 @@ use Sendama\Engine\Core\GameObject; use Sendama\Engine\Core\Scenes\SceneManager; use Sendama\Engine\Core\Vector2; +use Sendama\Engine\Events\Enumerations\EventType; use Sendama\Engine\Events\Enumerations\GameEventType; use Sendama\Engine\Events\EventManager; use Sendama\Engine\Events\GameEvent; @@ -22,43 +23,59 @@ /* Application */ function getGameName(): string { - return SceneManager::getInstance()->getSettings('game_name') ?? $_ENV['GAME_NAME']; + return SceneManager::getInstance()->getSettings('game_name') ?? $_ENV['GAME_NAME']; } -if (! function_exists('dispatchEvent') ) { - /** - * Dispatches the given event. - * - * @param EventInterface $event The event to dispatch. - * @return bool True if the event was dispatched successfully, false otherwise. - */ - function dispatchEvent(EventInterface $event): bool - { - return EventManager::getInstance()->dispatchEvent($event); - } +if (!function_exists('dispatchEvent')) { + /** + * Dispatches the given event. + * + * @param EventInterface $event The event to dispatch. + * @return bool True if the event was dispatched successfully, false otherwise. + */ + function dispatchEvent(EventInterface $event): bool + { + return EventManager::getInstance()->dispatchEvent($event); + } } -/** - * Quits the game with the given exit code. - * - * @param int|null $code The exit code. Defaults to null. - * @return void - */ -function quitGame(?int $code = null): void -{ - EventManager::getInstance()->dispatchEvent(new GameEvent(GameEventType::QUIT, data: $code)); +if (!function_exists('addEventListener')) { + function addEventListener(EventType $type, callable $listener, bool $useCapture = false): void { + EventManager::getInstance()->addEventListener($type, $listener, $useCapture); + } } -/** - * Pauses the game. - * - * @return void - */ -function pauseGame(): void -{ - EventManager::getInstance()->dispatchEvent(new GameEvent(GameEventType::PAUSE)); +if (!function_exists('removeEventListener')) { + function removeEventListener(EventType $type, callable $listener, bool $useCapture = false): void { + EventManager::getInstance()->removeEventListener($type, $listener, $useCapture); + } +} + +if (!function_exists('quitGame')) { + /** + * Quits the game with the given exit code. + * + * @param int|null $code The exit code. Defaults to null. + * @return void + */ + function quitGame(?int $code = null): void + { + EventManager::getInstance()->dispatchEvent(new GameEvent(GameEventType::QUIT, data: $code)); + } + } +if (!function_exists('pauseGame')) { + /** + * Pauses the game. + * + * @return void + */ + function pauseGame(): void + { + EventManager::getInstance()->dispatchEvent(new GameEvent(GameEventType::PAUSE)); + } +} /** * Resumes the game. * @@ -66,414 +83,420 @@ function pauseGame(): void */ function resumeGame(): void { - EventManager::getInstance()->dispatchEvent(new GameEvent(GameEventType::RESUME)); + EventManager::getInstance()->dispatchEvent(new GameEvent(GameEventType::RESUME)); } -/** - * Loads the scene with the given index. - * - * @param string|int $index The index of the scene to load. - * @return void - * @throws SceneNotFoundException - */ -function loadScene(string|int $index): void -{ - SceneManager::getInstance()->loadScene($index); +if (!function_exists('loadScene')) { + /** + * Loads the scene with the given index. + * + * @param string|int $index The index of the scene to load. + * @return void + * @throws SceneNotFoundException + */ + function loadScene(string|int $index): void + { + SceneManager::getInstance()->loadScene($index); + } } -/** - * Loads the next scene. - * - * @return void - * @throws SceneNotFoundException If the previous scene is not found. - */ -function loadPreviousScene(): void -{ - SceneManager::getInstance()->loadPreviousScene(); +if (!function_exists('loadPreviousScene')) { + /** + * Loads the next scene. + * + * @return void + * @throws SceneNotFoundException If the previous scene is not found. + */ + function loadPreviousScene(): void + { + SceneManager::getInstance()->loadPreviousScene(); + } } /* Math */ -if (! function_exists('clamp') ) { - /** - * Returns the given value clamped between the given min and max values. - * - * @param int|float $value The value to clamp. - * @param int|float $min The minimum value. - * @param int|float $max The maximum value. - * @return int|float The clamped value. - */ - function clamp(int|float $value, int|float $min, int|float $max): int|float - { - return max($min, min($max, $value)); - } -} - -if (! function_exists('lerp') ) { - /** - * Linearly interpolates between the given start and end values. - * - * @param float $start The start value. - * @param float $end The end value. - * @param float $amount The amount to interpolate. - * @return float The interpolated value. - */ - function lerp(float $start, float $end, float $amount): float - { - $amount = clamp($amount, 0, 1); - - return $start + ($end - $start) * $amount; - } -} - -if (! function_exists('wrap') ) { - /** - * Wraps the given value between the given min and max values. - * - * @param int $value The value to wrap. - * @param int $min The minimum value. - * @param int $max The maximum value. - * @return int The wrapped value. - */ - function wrap(int $value, int $min, int $max): int - { - $range = $max - $min + 1; - - if ($range == 0) { - return $min; +if (!function_exists('clamp')) { + /** + * Returns the given value clamped between the given min and max values. + * + * @param int|float $value The value to clamp. + * @param int|float $min The minimum value. + * @param int|float $max The maximum value. + * @return int|float The clamped value. + */ + function clamp(int|float $value, int|float $min, int|float $max): int|float + { + return max($min, min($max, $value)); } +} - if ($value < $min) { - $value += $range * ceil(($min - $value) / $range); +if (!function_exists('lerp')) { + /** + * Linearly interpolates between the given start and end values. + * + * @param float $start The start value. + * @param float $end The end value. + * @param float $amount The amount to interpolate. + * @return float The interpolated value. + */ + function lerp(float $start, float $end, float $amount): float + { + $amount = clamp($amount, 0, 1); + + return $start + ($end - $start) * $amount; } +} - return $min + (($value - $min) % $range + $range) % $range; - } -} - -if (! function_exists('wrap_text') ) { - /** - * Returns the given text wrapped to the given width. - * - * @param string $text The text to wrap. - * @param int $width The width to wrap to. - * @param bool $breakWords Whether to break words or not. - * @return string The wrapped text. - */ - function wrap_text(string $text, int $width, bool $breakWords = true): string - { - $lines = explode("\n", $text); - $wrappedLines = []; - - foreach ($lines as $line) { - $wrappedLines = array_merge($wrappedLines, explode("\n", wordwrap($line, $width, "\n", $breakWords))); +if (!function_exists('wrap')) { + /** + * Wraps the given value between the given min and max values. + * + * @param int $value The value to wrap. + * @param int $min The minimum value. + * @param int $max The maximum value. + * @return int The wrapped value. + */ + function wrap(int $value, int $min, int $max): int + { + $range = $max - $min + 1; + + if ($range == 0) { + return $min; + } + + if ($value < $min) { + $value += $range * ceil(($min - $value) / $range); + } + + return $min + (($value - $min) % $range + $range) % $range; } +} - return implode("\n", $wrappedLines); - } +if (!function_exists('wrap_text')) { + /** + * Returns the given text wrapped to the given width. + * + * @param string $text The text to wrap. + * @param int $width The width to wrap to. + * @param bool $breakWords Whether to break words or not. + * @return string The wrapped text. + */ + function wrap_text(string $text, int $width, bool $breakWords = true): string + { + $lines = explode("\n", $text); + $wrappedLines = []; + + foreach ($lines as $line) { + $wrappedLines = array_merge($wrappedLines, explode("\n", wordwrap($line, $width, "\n", $breakWords))); + } + + return implode("\n", $wrappedLines); + } } /* Dialog functions */ -if (! function_exists('alert') ) { - /** - * Shows an alert dialog with the given message and title. - * - * @param string $message The message to show. - * @param string $title The title of the dialog. Defaults to "Alert". - * @param int $width The width of the dialog. Defaults to 34. - * @return void - */ - function alert(string $message, string $title = '', int $width = DEFAULT_DIALOG_WIDTH): void - { - Console::alert($message, $title, $width); - } -} - -if (! function_exists('confirm') ) { - /** - * Shows a confirm dialog with the given message and title. Returns true if the user confirmed, false otherwise. - * - * @param string $message The message to show. - * @param string $title The title of the dialog. Defaults to "Confirm". - * @param int $width The width of the dialog. Defaults to 34. - * @return bool Whether the user confirmed or not. - */ - function confirm(string $message, string $title = 'Confirm', int $width = DEFAULT_DIALOG_WIDTH): bool - { - return Console::confirm($message, $title, $width); - } -} - -if (! function_exists('prompt') ) { - /** - * Shows a prompt dialog with the given message and title. Returns the user's input. - * - * @param string $message The message to show. - * @param string $title The title of the dialog. Defaults to "Prompt". - * @param string $default The default value of the input. Defaults to an empty string. - * @param int $width The width of the dialog. Defaults to 34. - * @return string The user's input. - */ - function prompt( - string $message, - string $title = 'Prompt', - string $default = '', - int $width = DEFAULT_DIALOG_WIDTH - ): string - { - return Console::prompt($message, $title, $default, $width); - } -} - -if (! function_exists('select') ) { - /** - * Shows a select dialog with the given message and title. Returns the index of the selected option. - * - * @param string $message The message to show. - * @param string[] $options The options to show. - * @param string $title The title of the dialog. Defaults to "Select". - * @param int $default The default option. Defaults to 0. - * @param Vector2|null $position The position of the dialog. Defaults to null. - * @param int $width The width of the dialog. Defaults to 34. - * @return int The index of the selected option. - */ - function select( - string $message, - array $options, - string $title = '', - int $default = 0, - ?Vector2 $position = null, - int $width = DEFAULT_SELECT_DIALOG_WIDTH - ): int - { - return Console::select($message, $options, $title, $default, $position, $width); - } -} - -if (! function_exists('show_text') ) { - /** - * Shows a text dialog with the given message and title. - * - * @param string $message The message to show. - * @param string $title The title of the dialog. Defaults to "". - * @param string $help The help text to show. Defaults to "". - * @param WindowPosition $position The position of the dialog. Defaults to BOTTOM (i.e. the bottom of the screen). - * @param float $charactersPerSecond The number of characters to display per second. - * @return void - */ - function show_text( - string $message, - string $title = '', - string $help = '', - WindowPosition $position = WindowPosition::BOTTOM, - float $charactersPerSecond = 1 - ): void - { - Console::showText($message, $title, $help, $position, $charactersPerSecond); - } -} - -if (! function_exists('notify') ) { - /** - * Creates a new notification with the given channel, title, text, and duration. - * - * @param NotificationChannel $channel The channel to send the notification to. - * @param string $title The title of the notification. - * @param string $text The text of the notification. - * @param NotificationDuration|float $duration The duration of the notification. - * @return void - */ - function notify( - NotificationChannel $channel, - string $title, - string $text, - NotificationDuration|float $duration = NotificationDuration::SHORT - ): void - { - $notification = new Notification($channel, $title, $text, $duration); - NotificationsManager::getInstance()->notify($notification); - } +if (!function_exists('alert')) { + /** + * Shows an alert dialog with the given message and title. + * + * @param string $message The message to show. + * @param string $title The title of the dialog. Defaults to "Alert". + * @param int $width The width of the dialog. Defaults to 34. + * @return void + */ + function alert(string $message, string $title = '', int $width = DEFAULT_DIALOG_WIDTH): void + { + Console::alert($message, $title, $width); + } +} + +if (!function_exists('confirm')) { + /** + * Shows a confirm dialog with the given message and title. Returns true if the user confirmed, false otherwise. + * + * @param string $message The message to show. + * @param string $title The title of the dialog. Defaults to "Confirm". + * @param int $width The width of the dialog. Defaults to 34. + * @return bool Whether the user confirmed or not. + */ + function confirm(string $message, string $title = 'Confirm', int $width = DEFAULT_DIALOG_WIDTH): bool + { + return Console::confirm($message, $title, $width); + } +} + +if (!function_exists('prompt')) { + /** + * Shows a prompt dialog with the given message and title. Returns the user's input. + * + * @param string $message The message to show. + * @param string $title The title of the dialog. Defaults to "Prompt". + * @param string $default The default value of the input. Defaults to an empty string. + * @param int $width The width of the dialog. Defaults to 34. + * @return string The user's input. + */ + function prompt( + string $message, + string $title = 'Prompt', + string $default = '', + int $width = DEFAULT_DIALOG_WIDTH + ): string + { + return Console::prompt($message, $title, $default, $width); + } +} + +if (!function_exists('select')) { + /** + * Shows a select dialog with the given message and title. Returns the index of the selected option. + * + * @param string $message The message to show. + * @param string[] $options The options to show. + * @param string $title The title of the dialog. Defaults to "Select". + * @param int $default The default option. Defaults to 0. + * @param Vector2|null $position The position of the dialog. Defaults to null. + * @param int $width The width of the dialog. Defaults to 34. + * @return int The index of the selected option. + */ + function select( + string $message, + array $options, + string $title = '', + int $default = 0, + ?Vector2 $position = null, + int $width = DEFAULT_SELECT_DIALOG_WIDTH + ): int + { + return Console::select($message, $options, $title, $default, $position, $width); + } +} + +if (!function_exists('show_text')) { + /** + * Shows a text dialog with the given message and title. + * + * @param string $message The message to show. + * @param string $title The title of the dialog. Defaults to "". + * @param string $help The help text to show. Defaults to "". + * @param WindowPosition $position The position of the dialog. Defaults to BOTTOM (i.e. the bottom of the screen). + * @param float $charactersPerSecond The number of characters to display per second. + * @return void + */ + function show_text( + string $message, + string $title = '', + string $help = '', + WindowPosition $position = WindowPosition::BOTTOM, + float $charactersPerSecond = 1 + ): void + { + Console::showText($message, $title, $help, $position, $charactersPerSecond); + } +} + +if (!function_exists('notify')) { + /** + * Creates a new notification with the given channel, title, text, and duration. + * + * @param NotificationChannel $channel The channel to send the notification to. + * @param string $title The title of the notification. + * @param string $text The text of the notification. + * @param NotificationDuration|float $duration The duration of the notification. + * @return void + */ + function notify( + NotificationChannel $channel, + string $title, + string $text, + NotificationDuration|float $duration = NotificationDuration::SHORT + ): void + { + $notification = new Notification($channel, $title, $text, $duration); + NotificationsManager::getInstance()->notify($notification); + } } /* Events */ -if (! function_exists('broadcast') ) { - /** - * Broadcasts the given event. - * - * @param EventInterface $event The event to broadcast. - * @return void - */ - function broadcast(EventInterface $event): void - { - $eventManager = EventManager::getInstance(); - $eventManager->dispatchEvent($event); - } +if (!function_exists('broadcast')) { + /** + * Broadcasts the given event. + * + * @param EventInterface $event The event to broadcast. + * @return void + */ + function broadcast(EventInterface $event): void + { + $eventManager = EventManager::getInstance(); + $eventManager->dispatchEvent($event); + } } /* Text */ -if (! function_exists('strip_ansi') ) { - /** - * Returns the given text with all ANSI escape sequences removed. - * - * @param string $input The text to remove the escape sequences from. - * @return string The text with all escape sequences removed. - */ - function strip_ansi(string $input): string - { - $pattern = "/\e\[[0-9;]*m/"; - return preg_replace($pattern, '', $input) ?? ''; - } +if (!function_exists('strip_ansi')) { + /** + * Returns the given text with all ANSI escape sequences removed. + * + * @param string $input The text to remove the escape sequences from. + * @return string The text with all escape sequences removed. + */ + function strip_ansi(string $input): string + { + $pattern = "/\e\[[0-9;]*m/"; + return preg_replace($pattern, '', $input) ?? ''; + } } /* Game Objects */ -if (! function_exists('instantiate') ) { - /** - * Instantiates a new game object from the given original game object at the given position. - * - * @param GameObject $original The original game object. - * @param Vector2 $position The position to instantiate the new game object at. - * @return GameObject The new game object. - */ - function instantiate(GameObject $original, Vector2 $position): GameObject - { - $newObject = clone $original; - $newObject->getTransform()->setPosition($position); - - return $newObject; - } -} - -if (! function_exists('in_range') ) { - /** - * Checks if the given value is within the given range. - * - * @param int $value The value to check. - * @param int $min The minimum value. - * @param int $max The maximum value. - * @return bool Whether the value is within the range or not. - */ - function in_range(int $value, int $min, int $max): bool - { - return $value >= $min && $value <= $max; - } -} - -if (! function_exists('within_bounds') ) { - /** - * Checks if the given point is within the given bounds. - * - * @param Vector2 $point The point to check. - * @param Vector2 $min The minimum bounds. - * @param Vector2 $max The maximum bounds. - * @return bool Whether the point is within the bounds or not. - */ - function within_bounds(Vector2 $point, Vector2 $min, Vector2 $max): bool - { - return in_range($point->getX(), $min->getX(), $max->getX()) && in_range($point->getY(), $min->getY(), $max->getY()); - } +if (!function_exists('instantiate')) { + /** + * Instantiates a new game object from the given original game object at the given position. + * + * @param GameObject $original The original game object. + * @param Vector2 $position The position to instantiate the new game object at. + * @return GameObject The new game object. + */ + function instantiate(GameObject $original, Vector2 $position): GameObject + { + $newObject = clone $original; + $newObject->getTransform()->setPosition($position); + + return $newObject; + } +} + +if (!function_exists('in_range')) { + /** + * Checks if the given value is within the given range. + * + * @param int $value The value to check. + * @param int $min The minimum value. + * @param int $max The maximum value. + * @return bool Whether the value is within the range or not. + */ + function in_range(int $value, int $min, int $max): bool + { + return $value >= $min && $value <= $max; + } +} + +if (!function_exists('within_bounds')) { + /** + * Checks if the given point is within the given bounds. + * + * @param Vector2 $point The point to check. + * @param Vector2 $min The minimum bounds. + * @param Vector2 $max The maximum bounds. + * @return bool Whether the point is within the bounds or not. + */ + function within_bounds(Vector2 $point, Vector2 $min, Vector2 $max): bool + { + return in_range($point->getX(), $min->getX(), $max->getX()) && in_range($point->getY(), $min->getY(), $max->getY()); + } } if (!function_exists('env')) { - /** - * Gets the value of an environment variable. - * - * @param string $key The environment variable key. - * @param mixed|null $default The default value to return if the environment variable is not set. - * @return mixed - */ - function env(string $key, mixed $default = null): mixed - { - return $_ENV[$key] ?? $default; - } -} - -if (! function_exists('config') ) { - /** - * Gets the value of the given configuration path. - * - * @param class-string $configClassname The classname of the configuration. - * @param string $path The path of the configuration. - * @param mixed $default The default value. - * - * @return mixed The value of the configuration. - */ - function config(string $configClassname, string $path, mixed $default = null): mixed - { - return ConfigStore::get($configClassname)->get($path, $default); - } -} - -if (! function_exists('get_screen_width')) { - /** - * Returns the screen width. - * - * @return int The screen width. - * @throws Exception - */ - function get_screen_width(): int - { - return Console::getSize()->getWidth(); - } -} - -if (! function_exists('get_configured_screen_width') ) { - /** - * Returns the screen width. - * - * @return int The screen width. - */ - function get_configured_screen_width(): int - { - return config(AppConfig::class, 'player.screen.width', DEFAULT_SCREEN_WIDTH); - } -} - -if (! function_exists('get_screen_height')) { - /** - * Returns the screen height. - * - * @return int The screen height. - * @throws Exception - */ - function get_screen_height(): int - { - return Console::getSize()->getHeight(); - } -} - -if (! function_exists('get_configured_screen_height') ) { - /** - * Returns the screen height. - * - * @return int The screen height. - */ - function get_configured_screen_height(): int - { - return config(AppConfig::class, 'player.screen.height', DEFAULT_SCREEN_HEIGHT); - } -} - -if (! function_exists('get_player_pref') ) { - /** - * Gets the player preference with the given key. - * - * @param string $key The key of the player preference. - * @param mixed|null $default The default value to return if the player preference is not set. - * @return mixed The value of the player preference. - */ - function get_player_pref(string $key, mixed $default = null): mixed { - return ConfigStore::get(PlayerPreferences::class)->get($key, $default); - } -} - -if (! function_exists('set_player_pref') ) { - /** - * Sets the player preference with the given key to the given value. - * - * @param string $key The key of the player preference. - * @param mixed $value The value to set the player preference to. - * @return void - */ - function set_player_pref(string $key, mixed $value): void { - ConfigStore::get(PlayerPreferences::class)->set($key, $value); - } + /** + * Gets the value of an environment variable. + * + * @param string $key The environment variable key. + * @param mixed|null $default The default value to return if the environment variable is not set. + * @return mixed + */ + function env(string $key, mixed $default = null): mixed + { + return $_ENV[$key] ?? $default; + } +} + +if (!function_exists('config')) { + /** + * Gets the value of the given configuration path. + * + * @param class-string $configClassname The classname of the configuration. + * @param string $path The path of the configuration. + * @param mixed $default The default value. + * + * @return mixed The value of the configuration. + */ + function config(string $configClassname, string $path, mixed $default = null): mixed + { + return ConfigStore::get($configClassname)->get($path, $default); + } +} + +if (!function_exists('get_screen_width')) { + /** + * Returns the screen width. + * + * @return int The screen width. + * @throws Exception + */ + function get_screen_width(): int + { + return Console::getSize()->getWidth(); + } +} + +if (!function_exists('get_configured_screen_width')) { + /** + * Returns the screen width. + * + * @return int The screen width. + */ + function get_configured_screen_width(): int + { + return config(AppConfig::class, 'player.screen.width', DEFAULT_SCREEN_WIDTH); + } +} + +if (!function_exists('get_screen_height')) { + /** + * Returns the screen height. + * + * @return int The screen height. + * @throws Exception + */ + function get_screen_height(): int + { + return Console::getSize()->getHeight(); + } +} + +if (!function_exists('get_configured_screen_height')) { + /** + * Returns the screen height. + * + * @return int The screen height. + */ + function get_configured_screen_height(): int + { + return config(AppConfig::class, 'player.screen.height', DEFAULT_SCREEN_HEIGHT); + } +} + +if (!function_exists('get_player_pref')) { + /** + * Gets the player preference with the given key. + * + * @param string $key The key of the player preference. + * @param mixed|null $default The default value to return if the player preference is not set. + * @return mixed The value of the player preference. + */ + function get_player_pref(string $key, mixed $default = null): mixed + { + return ConfigStore::get(PlayerPreferences::class)->get($key, $default); + } +} + +if (!function_exists('set_player_pref')) { + /** + * Sets the player preference with the given key to the given value. + * + * @param string $key The key of the player preference. + * @param mixed $value The value to set the player preference to. + * @return void + */ + function set_player_pref(string $key, mixed $value): void + { + ConfigStore::get(PlayerPreferences::class)->set($key, $value); + } } diff --git a/tests/Mocks/Maps/scene_collision.tmap b/tests/Mocks/Maps/scene_collision.tmap new file mode 100644 index 0000000..8b3bf61 --- /dev/null +++ b/tests/Mocks/Maps/scene_collision.tmap @@ -0,0 +1,3 @@ + + # + diff --git a/tests/Mocks/Scenes/scene_with_component_data.scene.php b/tests/Mocks/Scenes/scene_with_component_data.scene.php new file mode 100644 index 0000000..7bc3041 --- /dev/null +++ b/tests/Mocks/Scenes/scene_with_component_data.scene.php @@ -0,0 +1,37 @@ + 'Scene With Component Data', + 'width' => 80, + 'height' => 25, + 'hierarchy' => [ + [ + 'type' => GameObject::class, + 'name' => 'Probe', + 'tag' => 'Probe', + 'position' => [ + 'x' => 1, + 'y' => 1, + ], + 'rotation' => [ + 'x' => 0, + 'y' => 0, + ], + 'scale' => [ + 'x' => 1, + 'y' => 1, + ], + 'components' => [ + [ + 'class' => SceneManagerDataProbe::class, + 'data' => [ + 'speed' => 3, + 'power' => 7, + ], + ], + ], + ], + ], +]; diff --git a/tests/Mocks/Scenes/scene_with_environment_collision.scene.php b/tests/Mocks/Scenes/scene_with_environment_collision.scene.php new file mode 100644 index 0000000..a799a6b --- /dev/null +++ b/tests/Mocks/Scenes/scene_with_environment_collision.scene.php @@ -0,0 +1,9 @@ + 'Scene With Environment Collision', + 'width' => 5, + 'height' => 3, + 'environmentCollisionMapPath' => '../tests/Mocks/Maps/scene_collision', + 'hierarchy' => [], +]; diff --git a/tests/Unit/Core/GameObjectTest.php b/tests/Unit/Core/GameObjectTest.php index 9662d87..513ef48 100644 --- a/tests/Unit/Core/GameObjectTest.php +++ b/tests/Unit/Core/GameObjectTest.php @@ -1,8 +1,27 @@ getGameObject()->addComponent(Collider::class); + } + } +} describe('GameObject', function () { @@ -102,4 +121,109 @@ expect($mockBehaviour1->updateCount)->toEqual(1); expect($mockBehaviour2->updateCount)->toEqual(1); }); -}); \ No newline at end of file + + it('starts components the first time an inactive game object is activated', function () { + $this->gameObject->deactivate(); + $mockBehaviour = $this->gameObject->addComponent(MockBehavior::class); + + expect($mockBehaviour->startCount)->toEqual(0); + + $this->gameObject->activate(); + + expect($mockBehaviour->startCount)->toEqual(1); + }); + + it('registers runtime-added collider components for 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'; + $mover = new GameObject('Mover', position: new Vector2(0, 0)); + $mover->setSpriteFromTexture(new Texture($texturePath), new Vector2(0, 0), new Vector2(1, 1)); + $wall = new GameObject('Wall', position: new Vector2(1, 0)); + $wall->setSpriteFromTexture(new Texture($texturePath), new Vector2(0, 0), new Vector2(1, 1)); + + $scene->add($mover); + $scene->add($wall); + $scene->start(); + + $rigidbody = $mover->addComponent(Rigidbody::class); + $wallCollider = $wall->addComponent(Collider::class); + + expect($rigidbody)->toBeInstanceOf(Rigidbody::class) + ->and($wallCollider)->toBeInstanceOf(Collider::class); + + $collisions = $physics->checkCollisions($rigidbody, new Vector2(1, 0)); + + expect($collisions) + ->toHaveCount(1) + ->and($collisions[0]->getGameObject()->getName())->toEqual('Wall'); + }); + + it('registers collider components that are added during another component start callback', 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'; + $mover = new GameObject('Mover', position: new Vector2(0, 0)); + $mover->setSpriteFromTexture(new Texture($texturePath), new Vector2(0, 0), new Vector2(1, 1)); + $mover->addComponent(Rigidbody::class); + + $wall = new GameObject('Wall', position: new Vector2(1, 0)); + $wall->setSpriteFromTexture(new Texture($texturePath), new Vector2(0, 0), new Vector2(1, 1)); + $wall->addComponent(StartAddsColliderBehavior::class); + + $scene->add($mover); + $scene->add($wall); + $scene->start(); + + $rigidbody = $mover->getComponent(Rigidbody::class); + + expect($rigidbody)->toBeInstanceOf(Rigidbody::class); + + $collisions = $physics->checkCollisions($rigidbody, new Vector2(1, 0)); + + expect($collisions) + ->toHaveCount(1) + ->and($collisions[0]->getGameObject()->getName())->toEqual('Wall'); + }); +}); + +function resetGameObjectSingleton(string $className, string $propertyName): void +{ + $reflection = new ReflectionClass($className); + $property = $reflection->getProperty($propertyName); + $property->setValue(null, null); +} diff --git a/tests/Unit/Core/Rendering/RendererTest.php b/tests/Unit/Core/Rendering/RendererTest.php index 6101751..06e6f38 100644 --- a/tests/Unit/Core/Rendering/RendererTest.php +++ b/tests/Unit/Core/Rendering/RendererTest.php @@ -106,6 +106,65 @@ public function awake(): void expect($output)->toContain("\033[{$offset->getY()};{$offset->getX()}H#"); }); +it('restores buffered console content after repeated renders at the same position', function () { + Console::write('-----', 2, 2); + + $gameObject = new GameObject('Player', position: new Vector2(2, 2)); + $gameObject->setSpriteFromTexture( + new Texture(getcwd() . '/tests/Mocks/Textures/test.texture'), + new Vector2(0, 0), + new Vector2(1, 1) + ); + + ob_start(); + $gameObject->render(); + ob_end_clean(); + + ob_start(); + $gameObject->render(); + ob_end_clean(); + + ob_start(); + $gameObject->erase(); + $output = ob_get_clean(); + + $offset = Console::getRenderOffset(); + $row = $offset->getY() + 1; + $column = $offset->getX() + 1; + + expect($output)->toContain("\033[{$row};{$column}H-"); +}); + +it('restores the previous bounds when a transform position is mutated directly before render', function () { + Console::write('-----', 2, 2); + + $gameObject = new GameObject('Player', position: new Vector2(2, 2)); + $gameObject->setSpriteFromTexture( + new Texture(getcwd() . '/tests/Mocks/Textures/test.texture'), + new Vector2(0, 0), + new Vector2(1, 1) + ); + + ob_start(); + $gameObject->render(); + ob_end_clean(); + + $gameObject->getTransform()->getPosition()->add(new Vector2(2, 0)); + + ob_start(); + $gameObject->render(); + $output = ob_get_clean(); + + $offset = Console::getRenderOffset(); + $oldRow = $offset->getY() + 1; + $oldColumn = $offset->getX() + 1; + $newColumn = $offset->getX() + 3; + + expect($output) + ->toContain("\033[{$oldRow};{$oldColumn}H-") + ->toContain("\033[{$oldRow};{$newColumn}H>"); +}); + 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 fb051ef..7f5f1c3 100644 --- a/tests/Unit/Core/Scenes/SceneManagerTest.php +++ b/tests/Unit/Core/Scenes/SceneManagerTest.php @@ -1,11 +1,29 @@ power; + } + } +} + beforeEach(function () { resetSceneManagerStaticProperty(SceneManager::class, 'instance', null); @@ -22,7 +40,11 @@ 'screen_height' => 40, ]); + Physics::getInstance()->init(); + $this->scenePath = Path::join(dirname(__DIR__, 3), 'Mocks', 'Scenes', 'scene_with_dimensions'); + $this->sceneWithComponentDataPath = Path::join(dirname(__DIR__, 3), 'Mocks', 'Scenes', 'scene_with_component_data'); + $this->sceneWithEnvironmentCollisionPath = Path::join(dirname(__DIR__, 3), 'Mocks', 'Scenes', 'scene_with_environment_collision'); }); it('applies file scene dimensions to the active viewport and centered layout', function () { @@ -46,6 +68,38 @@ ->and($offset->getY())->toBe($expectedOffsetY); }); +it('hydrates component data from editor scene files', function () { + ob_start(); + $this->sceneManager->loadSceneFromFile($this->sceneWithComponentDataPath); + $this->sceneManager->loadScene('Scene With Component Data'); + ob_end_clean(); + + $scene = $this->sceneManager->getActiveScene(); + + expect($scene)->not()->toBeNull(); + + $probeGameObject = $scene->getRootGameObjects()[0] ?? null; + $probeComponent = $probeGameObject?->getComponent(SceneManagerDataProbe::class); + + expect($probeComponent)->toBeInstanceOf(SceneManagerDataProbe::class) + ->and($probeComponent->speed)->toBe(3) + ->and($probeComponent->getPower())->toBe(7); +}); + +it('loads static collision maps from scene metadata without requiring a rendered tile map', function () { + ob_start(); + $this->sceneManager->loadSceneFromFile($this->sceneWithEnvironmentCollisionPath); + $this->sceneManager->loadScene('Scene With Environment Collision'); + ob_end_clean(); + + $scene = $this->sceneManager->getActiveScene(); + + expect($scene)->not()->toBeNull() + ->and($scene->getCollisionWorldSpace()->get(2, 1))->toBe(1) + ->and(Physics::getInstance()->isTouchingStaticObject(new Vector2(2, 1)))->toBeTrue() + ->and(Physics::getInstance()->isTouchingStaticObject(new Vector2(0, 0)))->toBeFalse(); +}); + function resetSceneManagerStaticProperty(string $className, string $propertyName, mixed $value): void { $reflection = new \ReflectionClass($className); diff --git a/tests/Unit/Core/Texture2DTest.php b/tests/Unit/Core/Texture2DTest.php index c7ae686..3db67ac 100644 --- a/tests/Unit/Core/Texture2DTest.php +++ b/tests/Unit/Core/Texture2DTest.php @@ -30,7 +30,7 @@ ->toBe($height); }); - it('can control the texture coloer', function() { + it('can control the texture color', function() { $texture = new Texture($this->texturePath); $color = Color::RED; diff --git a/tests/Unit/GameTest.php b/tests/Unit/GameTest.php index 589a371..7b40626 100644 --- a/tests/Unit/GameTest.php +++ b/tests/Unit/GameTest.php @@ -7,11 +7,11 @@ beforeEach(function () { resetStaticProperty(ConfigStore::class, 'store', []); - unset($_ENV['DEBUG_MODE'], $_ENV['SHOW_DEBUG_INFO']); + unset($_ENV['DEBUG_MODE'], $_ENV['SHOW_DEBUG_INFO'], $_ENV['LOG_LEVEL']); }); afterEach(function () { - unset($_ENV['DEBUG_MODE'], $_ENV['SHOW_DEBUG_INFO']); + unset($_ENV['DEBUG_MODE'], $_ENV['SHOW_DEBUG_INFO'], $_ENV['LOG_LEVEL']); }); it('reads debug flags from sendama config when env overrides are absent', function () { @@ -48,6 +48,20 @@ ->and(invokePrivateStaticMethod(Game::class, 'isTruthySetting', '0'))->toBeFalse(); }); +it('prefers env log level over file settings and normalizes the value', function () { + $_ENV['LOG_LEVEL'] = 'debug'; + + expect(invokePrivateStaticMethod(Game::class, 'resolveConfiguredLogLevelValue', [ + 'log_level' => 'info', + ], 'warn'))->toBe('debug'); +}); + +it('falls back to configured log level when env is absent', function () { + expect(invokePrivateStaticMethod(Game::class, 'resolveConfiguredLogLevelValue', [ + 'log_level' => 'warn', + ], 'info'))->toBe('warn'); +}); + function invokePrivateStaticMethod(string $className, string $methodName, mixed ...$args): mixed { $reflection = new \ReflectionClass($className); diff --git a/tests/Unit/IO/InputManagerTest.php b/tests/Unit/IO/InputManagerTest.php new file mode 100644 index 0000000..eccac6c --- /dev/null +++ b/tests/Unit/IO/InputManagerTest.php @@ -0,0 +1,40 @@ +toBeTrue() + ->and(InputManager::isAnyKeyPressed(['']))->toBeTrue() + ->and(InputManager::isAnyKeyPressed(['r']))->toBeTrue(); +}); + +it('accepts string key codes through the input facade', function () { + setInputManagerState('previousKeyPress', ''); + setInputManagerState('keyPress', "\033"); + + expect(Input::isKeyDown('escape'))->toBeTrue() + ->and(Input::isKeyDown(''))->toBeTrue(); +}); + +it('returns false for unknown serialized key codes', function () { + setInputManagerState('previousKeyPress', ''); + setInputManagerState('keyPress', 'q'); + + expect(InputManager::isAnyKeyPressed(['']))->toBeFalse() + ->and(Input::isKeyDown('not_a_key'))->toBeFalse(); +}); + +function setInputManagerState(string $property, string $value): void +{ + $reflection = new ReflectionClass(InputManager::class); + $reflection->getProperty($property)->setValue(null, $value); +} diff --git a/tests/Unit/Physics/CharacterControllerTest.php b/tests/Unit/Physics/CharacterControllerTest.php index 2a3c64e..23bb7f6 100644 --- a/tests/Unit/Physics/CharacterControllerTest.php +++ b/tests/Unit/Physics/CharacterControllerTest.php @@ -1,15 +1,31 @@ collisionTypes[] = get_class($collision); + } + } +} + beforeEach(function () { Physics::getInstance()->init(); @@ -102,3 +118,19 @@ expect($firstCollider->isTouching($firstCollider))->toBeFalse() ->and($firstCollider->isTouching($secondCollider))->toBeTrue(); }); + +it('creates environment collisions for static collision map cells', function () { + $staticMap = new Grid(10, 10, 0); + $staticMap->set(1, 0, 1); + Physics::getInstance()->loadStaticCollisionMap($staticMap); + + [$player, $controller] = ($this->makeCollider)('Player', new Vector2(0, 0), CharacterController::class); + $probe = $player->addComponent(CharacterControllerCollisionProbe::class); + + ob_start(); + $controller->move(new Vector2(1, 0)); + ob_end_clean(); + + expect($player->getTransform()->getPosition()->getX())->toBe(0) + ->and($probe->collisionTypes)->toBe([EnvironmentCollision::class]); +}); diff --git a/tests/Unit/Physics/RigidbodyTest.php b/tests/Unit/Physics/RigidbodyTest.php new file mode 100644 index 0000000..3bd3a81 --- /dev/null +++ b/tests/Unit/Physics/RigidbodyTest.php @@ -0,0 +1,296 @@ +events[] = 'enter:' . $collision->getGameObject()->getName(); + $this->collisionTypes[] = get_class($collision); + } + + public function onCollisionExit(CollisionInterface $collision): void + { + $this->events[] = 'exit:' . $collision->getGameObject()->getName(); + } + + public function onCollisionStay(CollisionInterface $collision): void + { + $this->events[] = 'stay:' . $collision->getGameObject()->getName(); + } + } +} + +beforeEach(function () { + resetPhysicsSingleton(Physics::class, 'instance'); + $this->physics = Physics::getInstance(); + $this->physics->init(); + $this->texturePath = getcwd() . '/tests/Mocks/Textures/test.texture'; + 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('constrains movePosition against solid colliders', function () { + [$mover, $rigidbody] = createPhysicsObject('Mover', $this->texturePath, new Vector2(1, 1), Rigidbody::class); + [, $wallCollider] = createPhysicsObject('Wall', $this->texturePath, new Vector2(3, 1), Collider::class); + + $this->physics->addCollider($rigidbody); + $this->physics->addCollider($wallCollider); + + $rigidbody->movePosition(new Vector2(5, 1)); + + ob_start(); + $rigidbody->simulate(); + ob_end_clean(); + + expect($mover->getTransform()->getPosition()->getX())->toBe(2) + ->and($mover->getTransform()->getPosition()->getY())->toBe(1); +}); + +it('moves position and rotation together on the next simulation step', function () { + [$mover, $rigidbody] = createPhysicsObject('Mover', $this->texturePath, new Vector2(0, 0), Rigidbody::class); + + $rigidbody->movePositionAndRotation(new Vector2(3, 4), new Vector2(90, 0)); + + ob_start(); + $rigidbody->simulate(); + ob_end_clean(); + + expect($mover->getTransform()->getPosition()->getX())->toBe(3) + ->and($mover->getTransform()->getPosition()->getY())->toBe(4) + ->and($mover->getTransform()->getRotation()->getX())->toBe(90) + ->and($mover->getTransform()->getRotation()->getY())->toBe(0); +}); + +it('applies accumulated force as constrained movement', function () { + [$mover, $rigidbody] = createPhysicsObject('Mover', $this->texturePath, new Vector2(0, 0), Rigidbody::class); + + $rigidbody->addForce(new Vector2(3600, 0)); + + ob_start(); + $rigidbody->simulate(); + ob_end_clean(); + + expect($mover->getTransform()->getPosition()->getX())->toBe(1) + ->and($mover->getTransform()->getPosition()->getY())->toBe(0); +}); + +it('rotates relative force by the current rigidbody rotation', function () { + [$mover, $rigidbody] = createPhysicsObject('Mover', $this->texturePath, new Vector2(0, 0), Rigidbody::class); + $mover->getTransform()->setRotation(new Vector2(90, 0)); + + $rigidbody->addRelativeForceX(3600); + + ob_start(); + $rigidbody->simulate(); + ob_end_clean(); + + expect($mover->getTransform()->getPosition()->getX())->toBe(0) + ->and($mover->getTransform()->getPosition()->getY())->toBe(1); +}); + +it('adds torque when force is applied away from the rigidbody center', function () { + [$mover, $rigidbody] = createPhysicsObject('Mover', $this->texturePath, new Vector2(0, 0), Rigidbody::class); + + $rigidbody->addForceAtPosition(new Vector2(0, 3600), new Vector2(1, 0)); + + ob_start(); + $rigidbody->simulate(); + ob_end_clean(); + + expect($mover->getTransform()->getRotation()->getX())->toBe(1) + ->and($mover->getTransform()->getRotation()->getY())->toBe(0); +}); + +it('applies physics materials when bouncing off a solid collider', function () { + [$mover, $rigidbody] = createPhysicsObject('Mover', $this->texturePath, new Vector2(0, 0), Rigidbody::class); + [, $wallCollider] = createPhysicsObject('Wall', $this->texturePath, new Vector2(1, 0), Collider::class); + + $rigidbody->setMaterial(new PhysicsMaterial(1.0, 1.0)); + $wallCollider->setMaterial(new PhysicsMaterial(1.0, 1.0)); + $rigidbody->setVelocity(new Vector2(60, 60)); + + $this->physics->addCollider($rigidbody); + $this->physics->addCollider($wallCollider); + + ob_start(); + $rigidbody->simulate(); + ob_end_clean(); + + expect($mover->getTransform()->getPosition()->getX())->toBe(0) + ->and($mover->getTransform()->getPosition()->getY())->toBe(0) + ->and($rigidbody->getVelocity()->getX())->toBe(-60) + ->and($rigidbody->getVelocity()->getY())->toBe(0); +}); + +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); + + $bulletProbe = $bullet->addComponent(RigidbodyCollisionProbe::class); + $enemyProbe = $enemy->addComponent(RigidbodyCollisionProbe::class); + + expect($bulletProbe)->toBeInstanceOf(RigidbodyCollisionProbe::class); + expect($enemyProbe)->toBeInstanceOf(RigidbodyCollisionProbe::class); + + $this->physics->addCollider($rigidbody); + $this->physics->addCollider($enemyController); + + $rigidbody->movePosition(new Vector2(1, 0)); + + ob_start(); + $rigidbody->simulate(); + ob_end_clean(); + + expect($bulletProbe->events)->toBe(['enter:Enemy']) + ->and($enemyProbe->events)->toBe(['enter:Bullet']); +}); + +it('restores buffered console content when movePosition advances a rigidbody', function () { + Console::write('-----', 2, 2); + + [$mover, $rigidbody] = createPhysicsObject('Mover', $this->texturePath, new Vector2(2, 2), Rigidbody::class); + + ob_start(); + $mover->render(); + ob_end_clean(); + + ob_start(); + $mover->render(); + ob_end_clean(); + + $rigidbody->movePosition(new Vector2(4, 2)); + + ob_start(); + $rigidbody->simulate(); + $output = ob_get_clean(); + + $offset = Console::getRenderOffset(); + $row = $offset->getY() + 1; + $column = $offset->getX() + 1; + + expect($output)->toContain("\033[{$row};{$column}H-") + ->and($mover->getTransform()->getPosition()->getX())->toBe(4) + ->and($mover->getTransform()->getPosition()->getY())->toBe(2); +}); + +it('cleans up stale render bounds when movePosition is given a mutated live transform position', function () { + Console::write('-----', 2, 2); + + [$mover, $rigidbody] = createPhysicsObject('Mover', $this->texturePath, new Vector2(2, 2), Rigidbody::class); + + ob_start(); + $mover->render(); + ob_end_clean(); + + $position = $mover->getTransform()->getPosition(); + $position->add(new Vector2(2, 0)); + $rigidbody->movePosition($position); + + ob_start(); + $rigidbody->simulate(); + $mover->render(); + $output = ob_get_clean(); + + $offset = Console::getRenderOffset(); + $row = $offset->getY() + 1; + $oldColumn = $offset->getX() + 1; + $newColumn = $offset->getX() + 3; + + expect($output) + ->toContain("\033[{$row};{$oldColumn}H-") + ->toContain("\033[{$row};{$newColumn}H>") + ->and($mover->getTransform()->getPosition()->getX())->toBe(4) + ->and($mover->getTransform()->getPosition()->getY())->toBe(2); +}); + +it('still resolves collisions when movePosition is given a mutated live transform position', 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); + + $bulletProbe = $bullet->addComponent(RigidbodyCollisionProbe::class); + $enemyProbe = $enemy->addComponent(RigidbodyCollisionProbe::class); + + $this->physics->addCollider($rigidbody); + $this->physics->addCollider($enemyController); + + $rigidbody->start(); + + $position = $bullet->getTransform()->getPosition(); + $position->add(new Vector2(1, 0)); + $rigidbody->movePosition($position); + + ob_start(); + $rigidbody->simulate(); + ob_end_clean(); + + expect($bullet->getTransform()->getPosition()->getX())->toBe(0) + ->and($bulletProbe->events)->toBe(['enter:Enemy']) + ->and($enemyProbe->events)->toBe(['enter:Bullet']); +}); + +it('dispatches environment collisions for static collision maps', function () { + $staticMap = new Grid(10, 10, 0); + $staticMap->set(1, 0, 1); + $this->physics->loadStaticCollisionMap($staticMap); + + [$bullet, $rigidbody] = createPhysicsObject('Bullet', $this->texturePath, new Vector2(0, 0), Rigidbody::class); + $bulletProbe = $bullet->addComponent(RigidbodyCollisionProbe::class); + + $rigidbody->movePosition(new Vector2(1, 0)); + + ob_start(); + $rigidbody->simulate(); + ob_end_clean(); + + expect($bullet->getTransform()->getPosition()->getX())->toBe(0) + ->and($bulletProbe->events)->toBe(['enter:Environment']) + ->and($bulletProbe->collisionTypes)->toBe([EnvironmentCollision::class]); +}); + +/** + * @param class-string $componentClass + * @return array{0: GameObject, 1: Collider|Rigidbody} + */ +function createPhysicsObject(string $name, string $texturePath, Vector2 $position, string $componentClass): array +{ + $gameObject = new GameObject($name, position: $position); + $gameObject->setSpriteFromTexture( + new \Sendama\Engine\Core\Texture($texturePath), + new Vector2(0, 0), + new Vector2(1, 1) + ); + + $component = $gameObject->addComponent($componentClass); + + expect($component)->toBeInstanceOf($componentClass); + + return [$gameObject, $component]; +} + +function resetPhysicsSingleton(string $className, string $propertyName): void +{ + $reflection = new ReflectionClass($className); + $reflection->getProperty($propertyName)->setValue(null, null); +}