From 5cefe7203bd75f40c37e7821aba721e403e8e527 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Fri, 13 Mar 2026 16:44:13 +0200 Subject: [PATCH 1/2] feat(editor): enhance asset management with prefab support and improve inspector functionality --- README.md | 10 + Sprite | 0 composer.json | 3 + composer.lock | 3174 ++++++++++++++++- docs/Editor.md | 6 +- docs/guides/building-scenes.md | 7 +- docs/guides/getting-started.md | 5 +- docs/guides/inspector-and-properties.md | 22 +- docs/guides/layout-and-navigation.md | 1 - docs/guides/reference.md | 7 +- docs/guides/working-with-assets.md | 27 +- src/Commands/GeneratePrefab.php | 71 +- src/Commands/NewGame.php | 13 + src/Editor/Editor.php | 82 +- src/Editor/IO/InputManager.php | 48 +- src/Editor/SceneLoader.php | 22 +- src/Editor/Widgets/AssetsPanel.php | 15 +- src/Editor/Widgets/ConsolePanel.php | 6 +- src/Editor/Widgets/InspectorPanel.php | 163 +- src/Editor/Widgets/MainPanel.php | 199 +- src/Editor/Widgets/OptionListModal.php | 55 +- src/Editor/Widgets/Widget.php | 23 +- .../PrefabFileGenerationStrategy.php | 111 + .../SceneFileGenerationStrategy.php | 76 +- src/Util/ProjectNormalizer.php | 35 + templates/game.php | 34 +- templates/sendama.json | 25 +- tests/Unit/AssetsPanelTest.php | 70 + tests/Unit/CliAssetsDirectoryTest.php | 88 + tests/Unit/EditorAssetRenameTest.php | 53 + tests/Unit/EditorAssetSelectionTest.php | 82 + tests/Unit/InputManagerTest.php | 31 +- tests/Unit/InspectorPanelTest.php | 142 + tests/Unit/MainPanelTest.php | 68 + tests/Unit/OptionListModalTest.php | 21 + tests/Unit/SceneLoaderTest.php | 27 + tests/Unit/WidgetTest.php | 22 + 37 files changed, 4649 insertions(+), 195 deletions(-) create mode 100644 Sprite create mode 100644 src/Strategies/AssetFileGeneration/PrefabFileGenerationStrategy.php create mode 100644 tests/Unit/EditorAssetSelectionTest.php create mode 100644 tests/Unit/OptionListModalTest.php diff --git a/README.md b/README.md index 31ef6d7..24df5f1 100644 --- a/README.md +++ b/README.md @@ -120,6 +120,16 @@ sendama new mygame sendama generate:scene myscene ``` +### Generate a new prefab +```bash +sendama generate:prefab enemy +``` + +#### Generate a UI prefab +```bash +sendama generate:prefab score-label --kind=label +``` + ### Generate a new texture ```bash sendama generate:texture mytexture diff --git a/Sprite b/Sprite new file mode 100644 index 0000000..e69de29 diff --git a/composer.json b/composer.json index 313a48a..25caf86 100644 --- a/composer.json +++ b/composer.json @@ -35,5 +35,8 @@ ], "scripts": { "test": "vendor/bin/pest tests" + }, + "require-dev": { + "pestphp/pest": "^4.4" } } diff --git a/composer.lock b/composer.lock index 43323ed..57bae00 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "8364fb1f6301a3ea496ad1e53b186692", + "content-hash": "4b802d59001bb5f0253f3a58568a7873", "packages": [ { "name": "amasiye/figlet", @@ -1196,7 +1196,3177 @@ "time": "2025-12-27T19:49:13+00:00" } ], - "packages-dev": [], + "packages-dev": [ + { + "name": "brianium/paratest", + "version": "v7.19.0", + "source": { + "type": "git", + "url": "https://github.com/paratestphp/paratest.git", + "reference": "7c6c29af7c4b406b49ce0c6b0a3a81d3684474e6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/paratestphp/paratest/zipball/7c6c29af7c4b406b49ce0c6b0a3a81d3684474e6", + "reference": "7c6c29af7c4b406b49ce0c6b0a3a81d3684474e6", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-pcre": "*", + "ext-reflection": "*", + "ext-simplexml": "*", + "fidry/cpu-core-counter": "^1.3.0", + "jean85/pretty-package-versions": "^2.1.1", + "php": "~8.3.0 || ~8.4.0 || ~8.5.0", + "phpunit/php-code-coverage": "^12.5.3 || ^13.0.1", + "phpunit/php-file-iterator": "^6.0.1 || ^7", + "phpunit/php-timer": "^8 || ^9", + "phpunit/phpunit": "^12.5.9 || ^13", + "sebastian/environment": "^8.0.3 || ^9", + "symfony/console": "^7.4.4 || ^8.0.4", + "symfony/process": "^7.4.5 || ^8.0.5" + }, + "require-dev": { + "doctrine/coding-standard": "^14.0.0", + "ext-pcntl": "*", + "ext-pcov": "*", + "ext-posix": "*", + "phpstan/phpstan": "^2.1.38", + "phpstan/phpstan-deprecation-rules": "^2.0.3", + "phpstan/phpstan-phpunit": "^2.0.12", + "phpstan/phpstan-strict-rules": "^2.0.8", + "symfony/filesystem": "^7.4.0 || ^8.0.1" + }, + "bin": [ + "bin/paratest", + "bin/paratest_for_phpstorm" + ], + "type": "library", + "autoload": { + "psr-4": { + "ParaTest\\": [ + "src/" + ] + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Brian Scaturro", + "email": "scaturrob@gmail.com", + "role": "Developer" + }, + { + "name": "Filippo Tessarotto", + "email": "zoeslam@gmail.com", + "role": "Developer" + } + ], + "description": "Parallel testing for PHP", + "homepage": "https://github.com/paratestphp/paratest", + "keywords": [ + "concurrent", + "parallel", + "phpunit", + "testing" + ], + "support": { + "issues": "https://github.com/paratestphp/paratest/issues", + "source": "https://github.com/paratestphp/paratest/tree/v7.19.0" + }, + "funding": [ + { + "url": "https://github.com/sponsors/Slamdunk", + "type": "github" + }, + { + "url": "https://paypal.me/filippotessarotto", + "type": "paypal" + } + ], + "time": "2026-02-06T10:53:26+00:00" + }, + { + "name": "doctrine/deprecations", + "version": "1.1.6", + "source": { + "type": "git", + "url": "https://github.com/doctrine/deprecations.git", + "reference": "d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/deprecations/zipball/d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca", + "reference": "d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0" + }, + "conflict": { + "phpunit/phpunit": "<=7.5 || >=14" + }, + "require-dev": { + "doctrine/coding-standard": "^9 || ^12 || ^14", + "phpstan/phpstan": "1.4.10 || 2.1.30", + "phpstan/phpstan-phpunit": "^1.0 || ^2", + "phpunit/phpunit": "^7.5 || ^8.5 || ^9.6 || ^10.5 || ^11.5 || ^12.4 || ^13.0", + "psr/log": "^1 || ^2 || ^3" + }, + "suggest": { + "psr/log": "Allows logging deprecations via PSR-3 logger implementation" + }, + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\Deprecations\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "A small layer on top of trigger_error(E_USER_DEPRECATED) or PSR-3 logging with options to disable all deprecations or selectively for packages.", + "homepage": "https://www.doctrine-project.org/", + "support": { + "issues": "https://github.com/doctrine/deprecations/issues", + "source": "https://github.com/doctrine/deprecations/tree/1.1.6" + }, + "time": "2026-02-07T07:09:04+00:00" + }, + { + "name": "fidry/cpu-core-counter", + "version": "1.3.0", + "source": { + "type": "git", + "url": "https://github.com/theofidry/cpu-core-counter.git", + "reference": "db9508f7b1474469d9d3c53b86f817e344732678" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/theofidry/cpu-core-counter/zipball/db9508f7b1474469d9d3c53b86f817e344732678", + "reference": "db9508f7b1474469d9d3c53b86f817e344732678", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "require-dev": { + "fidry/makefile": "^0.2.0", + "fidry/php-cs-fixer-config": "^1.1.2", + "phpstan/extension-installer": "^1.2.0", + "phpstan/phpstan": "^2.0", + "phpstan/phpstan-deprecation-rules": "^2.0.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpstan/phpstan-strict-rules": "^2.0", + "phpunit/phpunit": "^8.5.31 || ^9.5.26", + "webmozarts/strict-phpunit": "^7.5" + }, + "type": "library", + "autoload": { + "psr-4": { + "Fidry\\CpuCoreCounter\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Théo FIDRY", + "email": "theo.fidry@gmail.com" + } + ], + "description": "Tiny utility to get the number of CPU cores.", + "keywords": [ + "CPU", + "core" + ], + "support": { + "issues": "https://github.com/theofidry/cpu-core-counter/issues", + "source": "https://github.com/theofidry/cpu-core-counter/tree/1.3.0" + }, + "funding": [ + { + "url": "https://github.com/theofidry", + "type": "github" + } + ], + "time": "2025-08-14T07:29:31+00:00" + }, + { + "name": "filp/whoops", + "version": "2.18.4", + "source": { + "type": "git", + "url": "https://github.com/filp/whoops.git", + "reference": "d2102955e48b9fd9ab24280a7ad12ed552752c4d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/filp/whoops/zipball/d2102955e48b9fd9ab24280a7ad12ed552752c4d", + "reference": "d2102955e48b9fd9ab24280a7ad12ed552752c4d", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0", + "psr/log": "^1.0.1 || ^2.0 || ^3.0" + }, + "require-dev": { + "mockery/mockery": "^1.0", + "phpunit/phpunit": "^7.5.20 || ^8.5.8 || ^9.3.3", + "symfony/var-dumper": "^4.0 || ^5.0" + }, + "suggest": { + "symfony/var-dumper": "Pretty print complex values better with var-dumper available", + "whoops/soap": "Formats errors as SOAP responses" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.7-dev" + } + }, + "autoload": { + "psr-4": { + "Whoops\\": "src/Whoops/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Filipe Dobreira", + "homepage": "https://github.com/filp", + "role": "Developer" + } + ], + "description": "php error handling for cool kids", + "homepage": "https://filp.github.io/whoops/", + "keywords": [ + "error", + "exception", + "handling", + "library", + "throwable", + "whoops" + ], + "support": { + "issues": "https://github.com/filp/whoops/issues", + "source": "https://github.com/filp/whoops/tree/2.18.4" + }, + "funding": [ + { + "url": "https://github.com/denis-sokolov", + "type": "github" + } + ], + "time": "2025-08-08T12:00:00+00:00" + }, + { + "name": "jean85/pretty-package-versions", + "version": "2.1.1", + "source": { + "type": "git", + "url": "https://github.com/Jean85/pretty-package-versions.git", + "reference": "4d7aa5dab42e2a76d99559706022885de0e18e1a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Jean85/pretty-package-versions/zipball/4d7aa5dab42e2a76d99559706022885de0e18e1a", + "reference": "4d7aa5dab42e2a76d99559706022885de0e18e1a", + "shasum": "" + }, + "require": { + "composer-runtime-api": "^2.1.0", + "php": "^7.4|^8.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3.2", + "jean85/composer-provided-replaced-stub-package": "^1.0", + "phpstan/phpstan": "^2.0", + "phpunit/phpunit": "^7.5|^8.5|^9.6", + "rector/rector": "^2.0", + "vimeo/psalm": "^4.3 || ^5.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Jean85\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Alessandro Lai", + "email": "alessandro.lai85@gmail.com" + } + ], + "description": "A library to get pretty versions strings of installed dependencies", + "keywords": [ + "composer", + "package", + "release", + "versions" + ], + "support": { + "issues": "https://github.com/Jean85/pretty-package-versions/issues", + "source": "https://github.com/Jean85/pretty-package-versions/tree/2.1.1" + }, + "time": "2025-03-19T14:43:43+00:00" + }, + { + "name": "myclabs/deep-copy", + "version": "1.13.4", + "source": { + "type": "git", + "url": "https://github.com/myclabs/DeepCopy.git", + "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/07d290f0c47959fd5eed98c95ee5602db07e0b6a", + "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0" + }, + "conflict": { + "doctrine/collections": "<1.6.8", + "doctrine/common": "<2.13.3 || >=3 <3.2.2" + }, + "require-dev": { + "doctrine/collections": "^1.6.8", + "doctrine/common": "^2.13.3 || ^3.2.2", + "phpspec/prophecy": "^1.10", + "phpunit/phpunit": "^7.5.20 || ^8.5.23 || ^9.5.13" + }, + "type": "library", + "autoload": { + "files": [ + "src/DeepCopy/deep_copy.php" + ], + "psr-4": { + "DeepCopy\\": "src/DeepCopy/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Create deep copies (clones) of your objects", + "keywords": [ + "clone", + "copy", + "duplicate", + "object", + "object graph" + ], + "support": { + "issues": "https://github.com/myclabs/DeepCopy/issues", + "source": "https://github.com/myclabs/DeepCopy/tree/1.13.4" + }, + "funding": [ + { + "url": "https://tidelift.com/funding/github/packagist/myclabs/deep-copy", + "type": "tidelift" + } + ], + "time": "2025-08-01T08:46:24+00:00" + }, + { + "name": "nikic/php-parser", + "version": "v5.7.0", + "source": { + "type": "git", + "url": "https://github.com/nikic/PHP-Parser.git", + "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/dca41cd15c2ac9d055ad70dbfd011130757d1f82", + "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82", + "shasum": "" + }, + "require": { + "ext-ctype": "*", + "ext-json": "*", + "ext-tokenizer": "*", + "php": ">=7.4" + }, + "require-dev": { + "ircmaxell/php-yacc": "^0.0.7", + "phpunit/phpunit": "^9.0" + }, + "bin": [ + "bin/php-parse" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.x-dev" + } + }, + "autoload": { + "psr-4": { + "PhpParser\\": "lib/PhpParser" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Nikita Popov" + } + ], + "description": "A PHP parser written in PHP", + "keywords": [ + "parser", + "php" + ], + "support": { + "issues": "https://github.com/nikic/PHP-Parser/issues", + "source": "https://github.com/nikic/PHP-Parser/tree/v5.7.0" + }, + "time": "2025-12-06T11:56:16+00:00" + }, + { + "name": "nunomaduro/collision", + "version": "v8.9.1", + "source": { + "type": "git", + "url": "https://github.com/nunomaduro/collision.git", + "reference": "a1ed3fa530fd60bc515f9303e8520fcb7d4bd935" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nunomaduro/collision/zipball/a1ed3fa530fd60bc515f9303e8520fcb7d4bd935", + "reference": "a1ed3fa530fd60bc515f9303e8520fcb7d4bd935", + "shasum": "" + }, + "require": { + "filp/whoops": "^2.18.4", + "nunomaduro/termwind": "^2.4.0", + "php": "^8.2.0", + "symfony/console": "^7.4.4 || ^8.0.4" + }, + "conflict": { + "laravel/framework": "<11.48.0 || >=14.0.0", + "phpunit/phpunit": "<11.5.50 || >=14.0.0" + }, + "require-dev": { + "brianium/paratest": "^7.8.5", + "larastan/larastan": "^3.9.2", + "laravel/framework": "^11.48.0 || ^12.52.0", + "laravel/pint": "^1.27.1", + "orchestra/testbench-core": "^9.12.0 || ^10.9.0", + "pestphp/pest": "^3.8.5 || ^4.4.1 || ^5.0.0", + "sebastian/environment": "^7.2.1 || ^8.0.3 || ^9.0.0" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "NunoMaduro\\Collision\\Adapters\\Laravel\\CollisionServiceProvider" + ] + }, + "branch-alias": { + "dev-8.x": "8.x-dev" + } + }, + "autoload": { + "files": [ + "./src/Adapters/Phpunit/Autoload.php" + ], + "psr-4": { + "NunoMaduro\\Collision\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nuno Maduro", + "email": "enunomaduro@gmail.com" + } + ], + "description": "Cli error handling for console/command-line PHP applications.", + "keywords": [ + "artisan", + "cli", + "command-line", + "console", + "dev", + "error", + "handling", + "laravel", + "laravel-zero", + "php", + "symfony" + ], + "support": { + "issues": "https://github.com/nunomaduro/collision/issues", + "source": "https://github.com/nunomaduro/collision" + }, + "funding": [ + { + "url": "https://www.paypal.com/paypalme/enunomaduro", + "type": "custom" + }, + { + "url": "https://github.com/nunomaduro", + "type": "github" + }, + { + "url": "https://www.patreon.com/nunomaduro", + "type": "patreon" + } + ], + "time": "2026-02-17T17:33:08+00:00" + }, + { + "name": "nunomaduro/termwind", + "version": "v2.4.0", + "source": { + "type": "git", + "url": "https://github.com/nunomaduro/termwind.git", + "reference": "712a31b768f5daea284c2169a7d227031001b9a8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nunomaduro/termwind/zipball/712a31b768f5daea284c2169a7d227031001b9a8", + "reference": "712a31b768f5daea284c2169a7d227031001b9a8", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "php": "^8.2", + "symfony/console": "^7.4.4 || ^8.0.4" + }, + "require-dev": { + "illuminate/console": "^11.47.0", + "laravel/pint": "^1.27.1", + "mockery/mockery": "^1.6.12", + "pestphp/pest": "^2.36.0 || ^3.8.4 || ^4.3.2", + "phpstan/phpstan": "^1.12.32", + "phpstan/phpstan-strict-rules": "^1.6.2", + "symfony/var-dumper": "^7.3.5 || ^8.0.4", + "thecodingmachine/phpstan-strict-rules": "^1.0.0" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Termwind\\Laravel\\TermwindServiceProvider" + ] + }, + "branch-alias": { + "dev-2.x": "2.x-dev" + } + }, + "autoload": { + "files": [ + "src/Functions.php" + ], + "psr-4": { + "Termwind\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nuno Maduro", + "email": "enunomaduro@gmail.com" + } + ], + "description": "It's like Tailwind CSS, but for the console.", + "keywords": [ + "cli", + "console", + "css", + "package", + "php", + "style" + ], + "support": { + "issues": "https://github.com/nunomaduro/termwind/issues", + "source": "https://github.com/nunomaduro/termwind/tree/v2.4.0" + }, + "funding": [ + { + "url": "https://www.paypal.com/paypalme/enunomaduro", + "type": "custom" + }, + { + "url": "https://github.com/nunomaduro", + "type": "github" + }, + { + "url": "https://github.com/xiCO2k", + "type": "github" + } + ], + "time": "2026-02-16T23:10:27+00:00" + }, + { + "name": "pestphp/pest", + "version": "v4.4.2", + "source": { + "type": "git", + "url": "https://github.com/pestphp/pest.git", + "reference": "5d42e8fe3ae1d9fdf7c9f73ee88138fd30265701" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/pestphp/pest/zipball/5d42e8fe3ae1d9fdf7c9f73ee88138fd30265701", + "reference": "5d42e8fe3ae1d9fdf7c9f73ee88138fd30265701", + "shasum": "" + }, + "require": { + "brianium/paratest": "^7.19.0", + "nunomaduro/collision": "^8.9.1", + "nunomaduro/termwind": "^2.4.0", + "pestphp/pest-plugin": "^4.0.0", + "pestphp/pest-plugin-arch": "^4.0.0", + "pestphp/pest-plugin-mutate": "^4.0.1", + "pestphp/pest-plugin-profanity": "^4.2.1", + "php": "^8.3.0", + "phpunit/phpunit": "^12.5.12", + "symfony/process": "^7.4.5|^8.0.5" + }, + "conflict": { + "filp/whoops": "<2.18.3", + "phpunit/phpunit": ">12.5.12", + "sebastian/exporter": "<7.0.0", + "webmozart/assert": "<1.11.0" + }, + "require-dev": { + "pestphp/pest-dev-tools": "^4.1.0", + "pestphp/pest-plugin-browser": "^4.3.0", + "pestphp/pest-plugin-type-coverage": "^4.0.3", + "psy/psysh": "^0.12.21" + }, + "bin": [ + "bin/pest" + ], + "type": "library", + "extra": { + "pest": { + "plugins": [ + "Pest\\Mutate\\Plugins\\Mutate", + "Pest\\Plugins\\Configuration", + "Pest\\Plugins\\Bail", + "Pest\\Plugins\\Cache", + "Pest\\Plugins\\Coverage", + "Pest\\Plugins\\Init", + "Pest\\Plugins\\Environment", + "Pest\\Plugins\\Help", + "Pest\\Plugins\\Memory", + "Pest\\Plugins\\Only", + "Pest\\Plugins\\Printer", + "Pest\\Plugins\\ProcessIsolation", + "Pest\\Plugins\\Profile", + "Pest\\Plugins\\Retry", + "Pest\\Plugins\\Snapshot", + "Pest\\Plugins\\Verbose", + "Pest\\Plugins\\Version", + "Pest\\Plugins\\Shard", + "Pest\\Plugins\\Parallel" + ] + }, + "phpstan": { + "includes": [ + "extension.neon" + ] + } + }, + "autoload": { + "files": [ + "src/Functions.php", + "src/Pest.php" + ], + "psr-4": { + "Pest\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nuno Maduro", + "email": "enunomaduro@gmail.com" + } + ], + "description": "The elegant PHP Testing Framework.", + "keywords": [ + "framework", + "pest", + "php", + "test", + "testing", + "unit" + ], + "support": { + "issues": "https://github.com/pestphp/pest/issues", + "source": "https://github.com/pestphp/pest/tree/v4.4.2" + }, + "funding": [ + { + "url": "https://www.paypal.com/paypalme/enunomaduro", + "type": "custom" + }, + { + "url": "https://github.com/nunomaduro", + "type": "github" + } + ], + "time": "2026-03-10T21:09:12+00:00" + }, + { + "name": "pestphp/pest-plugin", + "version": "v4.0.0", + "source": { + "type": "git", + "url": "https://github.com/pestphp/pest-plugin.git", + "reference": "9d4b93d7f73d3f9c3189bb22c220fef271cdf568" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/pestphp/pest-plugin/zipball/9d4b93d7f73d3f9c3189bb22c220fef271cdf568", + "reference": "9d4b93d7f73d3f9c3189bb22c220fef271cdf568", + "shasum": "" + }, + "require": { + "composer-plugin-api": "^2.0.0", + "composer-runtime-api": "^2.2.2", + "php": "^8.3" + }, + "conflict": { + "pestphp/pest": "<4.0.0" + }, + "require-dev": { + "composer/composer": "^2.8.10", + "pestphp/pest": "^4.0.0", + "pestphp/pest-dev-tools": "^4.0.0" + }, + "type": "composer-plugin", + "extra": { + "class": "Pest\\Plugin\\Manager" + }, + "autoload": { + "psr-4": { + "Pest\\Plugin\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "The Pest plugin manager", + "keywords": [ + "framework", + "manager", + "pest", + "php", + "plugin", + "test", + "testing", + "unit" + ], + "support": { + "source": "https://github.com/pestphp/pest-plugin/tree/v4.0.0" + }, + "funding": [ + { + "url": "https://www.paypal.com/cgi-bin/webscr?cmd=_s-xclick&hosted_button_id=66BYDWAT92N6L", + "type": "custom" + }, + { + "url": "https://github.com/nunomaduro", + "type": "github" + }, + { + "url": "https://www.patreon.com/nunomaduro", + "type": "patreon" + } + ], + "time": "2025-08-20T12:35:58+00:00" + }, + { + "name": "pestphp/pest-plugin-arch", + "version": "v4.0.0", + "source": { + "type": "git", + "url": "https://github.com/pestphp/pest-plugin-arch.git", + "reference": "25bb17e37920ccc35cbbcda3b00d596aadf3e58d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/pestphp/pest-plugin-arch/zipball/25bb17e37920ccc35cbbcda3b00d596aadf3e58d", + "reference": "25bb17e37920ccc35cbbcda3b00d596aadf3e58d", + "shasum": "" + }, + "require": { + "pestphp/pest-plugin": "^4.0.0", + "php": "^8.3", + "ta-tikoma/phpunit-architecture-test": "^0.8.5" + }, + "require-dev": { + "pestphp/pest": "^4.0.0", + "pestphp/pest-dev-tools": "^4.0.0" + }, + "type": "library", + "extra": { + "pest": { + "plugins": [ + "Pest\\Arch\\Plugin" + ] + } + }, + "autoload": { + "files": [ + "src/Autoload.php" + ], + "psr-4": { + "Pest\\Arch\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "The Arch plugin for Pest PHP.", + "keywords": [ + "arch", + "architecture", + "framework", + "pest", + "php", + "plugin", + "test", + "testing", + "unit" + ], + "support": { + "source": "https://github.com/pestphp/pest-plugin-arch/tree/v4.0.0" + }, + "funding": [ + { + "url": "https://www.paypal.com/paypalme/enunomaduro", + "type": "custom" + }, + { + "url": "https://github.com/nunomaduro", + "type": "github" + } + ], + "time": "2025-08-20T13:10:51+00:00" + }, + { + "name": "pestphp/pest-plugin-mutate", + "version": "v4.0.1", + "source": { + "type": "git", + "url": "https://github.com/pestphp/pest-plugin-mutate.git", + "reference": "d9b32b60b2385e1688a68cc227594738ec26d96c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/pestphp/pest-plugin-mutate/zipball/d9b32b60b2385e1688a68cc227594738ec26d96c", + "reference": "d9b32b60b2385e1688a68cc227594738ec26d96c", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^5.6.1", + "pestphp/pest-plugin": "^4.0.0", + "php": "^8.3", + "psr/simple-cache": "^3.0.0" + }, + "require-dev": { + "pestphp/pest": "^4.0.0", + "pestphp/pest-dev-tools": "^4.0.0", + "pestphp/pest-plugin-type-coverage": "^4.0.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Pest\\Mutate\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nuno Maduro", + "email": "enunomaduro@gmail.com" + }, + { + "name": "Sandro Gehri", + "email": "sandrogehri@gmail.com" + } + ], + "description": "Mutates your code to find untested cases", + "keywords": [ + "framework", + "mutate", + "mutation", + "pest", + "php", + "plugin", + "test", + "testing", + "unit" + ], + "support": { + "source": "https://github.com/pestphp/pest-plugin-mutate/tree/v4.0.1" + }, + "funding": [ + { + "url": "https://www.paypal.com/paypalme/enunomaduro", + "type": "custom" + }, + { + "url": "https://github.com/gehrisandro", + "type": "github" + }, + { + "url": "https://github.com/nunomaduro", + "type": "github" + } + ], + "time": "2025-08-21T20:19:25+00:00" + }, + { + "name": "pestphp/pest-plugin-profanity", + "version": "v4.2.1", + "source": { + "type": "git", + "url": "https://github.com/pestphp/pest-plugin-profanity.git", + "reference": "343cfa6f3564b7e35df0ebb77b7fa97039f72b27" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/pestphp/pest-plugin-profanity/zipball/343cfa6f3564b7e35df0ebb77b7fa97039f72b27", + "reference": "343cfa6f3564b7e35df0ebb77b7fa97039f72b27", + "shasum": "" + }, + "require": { + "pestphp/pest-plugin": "^4.0.0", + "php": "^8.3" + }, + "require-dev": { + "faissaloux/pest-plugin-inside": "^1.9", + "pestphp/pest": "^4.0.0", + "pestphp/pest-dev-tools": "^4.0.0" + }, + "type": "library", + "extra": { + "pest": { + "plugins": [ + "Pest\\Profanity\\Plugin" + ] + } + }, + "autoload": { + "psr-4": { + "Pest\\Profanity\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "The Pest Profanity Plugin", + "keywords": [ + "framework", + "pest", + "php", + "plugin", + "profanity", + "test", + "testing", + "unit" + ], + "support": { + "source": "https://github.com/pestphp/pest-plugin-profanity/tree/v4.2.1" + }, + "time": "2025-12-08T00:13:17+00:00" + }, + { + "name": "phar-io/manifest", + "version": "2.0.4", + "source": { + "type": "git", + "url": "https://github.com/phar-io/manifest.git", + "reference": "54750ef60c58e43759730615a392c31c80e23176" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phar-io/manifest/zipball/54750ef60c58e43759730615a392c31c80e23176", + "reference": "54750ef60c58e43759730615a392c31c80e23176", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "ext-phar": "*", + "ext-xmlwriter": "*", + "phar-io/version": "^3.0.1", + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + }, + { + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" + } + ], + "description": "Component for reading phar.io manifest information from a PHP Archive (PHAR)", + "support": { + "issues": "https://github.com/phar-io/manifest/issues", + "source": "https://github.com/phar-io/manifest/tree/2.0.4" + }, + "funding": [ + { + "url": "https://github.com/theseer", + "type": "github" + } + ], + "time": "2024-03-03T12:33:53+00:00" + }, + { + "name": "phar-io/version", + "version": "3.2.1", + "source": { + "type": "git", + "url": "https://github.com/phar-io/version.git", + "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phar-io/version/zipball/4f7fd7836c6f332bb2933569e566a0d6c4cbed74", + "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + }, + { + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" + } + ], + "description": "Library for handling version information and constraints", + "support": { + "issues": "https://github.com/phar-io/version/issues", + "source": "https://github.com/phar-io/version/tree/3.2.1" + }, + "time": "2022-02-21T01:04:05+00:00" + }, + { + "name": "phpdocumentor/reflection-common", + "version": "2.2.0", + "source": { + "type": "git", + "url": "https://github.com/phpDocumentor/ReflectionCommon.git", + "reference": "1d01c49d4ed62f25aa84a747ad35d5a16924662b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpDocumentor/ReflectionCommon/zipball/1d01c49d4ed62f25aa84a747ad35d5a16924662b", + "reference": "1d01c49d4ed62f25aa84a747ad35d5a16924662b", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-2.x": "2.x-dev" + } + }, + "autoload": { + "psr-4": { + "phpDocumentor\\Reflection\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jaap van Otterdijk", + "email": "opensource@ijaap.nl" + } + ], + "description": "Common reflection classes used by phpdocumentor to reflect the code structure", + "homepage": "http://www.phpdoc.org", + "keywords": [ + "FQSEN", + "phpDocumentor", + "phpdoc", + "reflection", + "static analysis" + ], + "support": { + "issues": "https://github.com/phpDocumentor/ReflectionCommon/issues", + "source": "https://github.com/phpDocumentor/ReflectionCommon/tree/2.x" + }, + "time": "2020-06-27T09:03:43+00:00" + }, + { + "name": "phpdocumentor/reflection-docblock", + "version": "6.0.2", + "source": { + "type": "git", + "url": "https://github.com/phpDocumentor/ReflectionDocBlock.git", + "reference": "897b5986ece6b4f9d8413fea345c7d49c757d6bf" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpDocumentor/ReflectionDocBlock/zipball/897b5986ece6b4f9d8413fea345c7d49c757d6bf", + "reference": "897b5986ece6b4f9d8413fea345c7d49c757d6bf", + "shasum": "" + }, + "require": { + "doctrine/deprecations": "^1.1", + "ext-filter": "*", + "php": "^7.4 || ^8.0", + "phpdocumentor/reflection-common": "^2.2", + "phpdocumentor/type-resolver": "^2.0", + "phpstan/phpdoc-parser": "^2.0", + "webmozart/assert": "^1.9.1 || ^2" + }, + "require-dev": { + "mockery/mockery": "~1.3.5 || ~1.6.0", + "phpstan/extension-installer": "^1.1", + "phpstan/phpstan": "^1.8", + "phpstan/phpstan-mockery": "^1.1", + "phpstan/phpstan-webmozart-assert": "^1.2", + "phpunit/phpunit": "^9.5", + "psalm/phar": "^5.26", + "shipmonk/dead-code-detector": "^0.5.1" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.x-dev" + } + }, + "autoload": { + "psr-4": { + "phpDocumentor\\Reflection\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Mike van Riel", + "email": "me@mikevanriel.com" + }, + { + "name": "Jaap van Otterdijk", + "email": "opensource@ijaap.nl" + } + ], + "description": "With this component, a library can provide support for annotations via DocBlocks or otherwise retrieve information that is embedded in a DocBlock.", + "support": { + "issues": "https://github.com/phpDocumentor/ReflectionDocBlock/issues", + "source": "https://github.com/phpDocumentor/ReflectionDocBlock/tree/6.0.2" + }, + "time": "2026-03-01T18:43:49+00:00" + }, + { + "name": "phpdocumentor/type-resolver", + "version": "2.0.0", + "source": { + "type": "git", + "url": "https://github.com/phpDocumentor/TypeResolver.git", + "reference": "327a05bbee54120d4786a0dc67aad30226ad4cf9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpDocumentor/TypeResolver/zipball/327a05bbee54120d4786a0dc67aad30226ad4cf9", + "reference": "327a05bbee54120d4786a0dc67aad30226ad4cf9", + "shasum": "" + }, + "require": { + "doctrine/deprecations": "^1.0", + "php": "^7.4 || ^8.0", + "phpdocumentor/reflection-common": "^2.0", + "phpstan/phpdoc-parser": "^2.0" + }, + "require-dev": { + "ext-tokenizer": "*", + "phpbench/phpbench": "^1.2", + "phpstan/extension-installer": "^1.4", + "phpstan/phpstan": "^2.1", + "phpstan/phpstan-phpunit": "^2.0", + "phpunit/phpunit": "^9.5", + "psalm/phar": "^4" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-1.x": "1.x-dev", + "dev-2.x": "2.x-dev" + } + }, + "autoload": { + "psr-4": { + "phpDocumentor\\Reflection\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Mike van Riel", + "email": "me@mikevanriel.com" + } + ], + "description": "A PSR-5 based resolver of Class names, Types and Structural Element Names", + "support": { + "issues": "https://github.com/phpDocumentor/TypeResolver/issues", + "source": "https://github.com/phpDocumentor/TypeResolver/tree/2.0.0" + }, + "time": "2026-01-06T21:53:42+00:00" + }, + { + "name": "phpstan/phpdoc-parser", + "version": "2.3.2", + "source": { + "type": "git", + "url": "https://github.com/phpstan/phpdoc-parser.git", + "reference": "a004701b11273a26cd7955a61d67a7f1e525a45a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/a004701b11273a26cd7955a61d67a7f1e525a45a", + "reference": "a004701b11273a26cd7955a61d67a7f1e525a45a", + "shasum": "" + }, + "require": { + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "doctrine/annotations": "^2.0", + "nikic/php-parser": "^5.3.0", + "php-parallel-lint/php-parallel-lint": "^1.2", + "phpstan/extension-installer": "^1.0", + "phpstan/phpstan": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpstan/phpstan-strict-rules": "^2.0", + "phpunit/phpunit": "^9.6", + "symfony/process": "^5.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "PHPStan\\PhpDocParser\\": [ + "src/" + ] + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "PHPDoc parser with support for nullable, intersection and generic types", + "support": { + "issues": "https://github.com/phpstan/phpdoc-parser/issues", + "source": "https://github.com/phpstan/phpdoc-parser/tree/2.3.2" + }, + "time": "2026-01-25T14:56:51+00:00" + }, + { + "name": "phpunit/php-code-coverage", + "version": "12.5.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-code-coverage.git", + "reference": "b015312f28dd75b75d3422ca37dff2cd1a565e8d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/b015312f28dd75b75d3422ca37dff2cd1a565e8d", + "reference": "b015312f28dd75b75d3422ca37dff2cd1a565e8d", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "ext-xmlwriter": "*", + "nikic/php-parser": "^5.7.0", + "php": ">=8.3", + "phpunit/php-file-iterator": "^6.0", + "phpunit/php-text-template": "^5.0", + "sebastian/complexity": "^5.0", + "sebastian/environment": "^8.0.3", + "sebastian/lines-of-code": "^4.0", + "sebastian/version": "^6.0", + "theseer/tokenizer": "^2.0.1" + }, + "require-dev": { + "phpunit/phpunit": "^12.5.1" + }, + "suggest": { + "ext-pcov": "PHP extension that provides line coverage", + "ext-xdebug": "PHP extension that provides line coverage as well as branch and path coverage" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "12.5.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library that provides collection, processing, and rendering functionality for PHP code coverage information.", + "homepage": "https://github.com/sebastianbergmann/php-code-coverage", + "keywords": [ + "coverage", + "testing", + "xunit" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", + "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy", + "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/12.5.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-code-coverage", + "type": "tidelift" + } + ], + "time": "2026-02-06T06:01:44+00:00" + }, + { + "name": "phpunit/php-file-iterator", + "version": "6.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-file-iterator.git", + "reference": "3d1cd096ef6bea4bf2762ba586e35dbd317cbfd5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/3d1cd096ef6bea4bf2762ba586e35dbd317cbfd5", + "reference": "3d1cd096ef6bea4bf2762ba586e35dbd317cbfd5", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "FilterIterator implementation that filters files based on a list of suffixes.", + "homepage": "https://github.com/sebastianbergmann/php-file-iterator/", + "keywords": [ + "filesystem", + "iterator" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-file-iterator/issues", + "security": "https://github.com/sebastianbergmann/php-file-iterator/security/policy", + "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/6.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-file-iterator", + "type": "tidelift" + } + ], + "time": "2026-02-02T14:04:18+00:00" + }, + { + "name": "phpunit/php-invoker", + "version": "6.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-invoker.git", + "reference": "12b54e689b07a25a9b41e57736dfab6ec9ae5406" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-invoker/zipball/12b54e689b07a25a9b41e57736dfab6ec9ae5406", + "reference": "12b54e689b07a25a9b41e57736dfab6ec9ae5406", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "require-dev": { + "ext-pcntl": "*", + "phpunit/phpunit": "^12.0" + }, + "suggest": { + "ext-pcntl": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Invoke callables with a timeout", + "homepage": "https://github.com/sebastianbergmann/php-invoker/", + "keywords": [ + "process" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-invoker/issues", + "security": "https://github.com/sebastianbergmann/php-invoker/security/policy", + "source": "https://github.com/sebastianbergmann/php-invoker/tree/6.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2025-02-07T04:58:58+00:00" + }, + { + "name": "phpunit/php-text-template", + "version": "5.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-text-template.git", + "reference": "e1367a453f0eda562eedb4f659e13aa900d66c53" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/e1367a453f0eda562eedb4f659e13aa900d66c53", + "reference": "e1367a453f0eda562eedb4f659e13aa900d66c53", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Simple template engine.", + "homepage": "https://github.com/sebastianbergmann/php-text-template/", + "keywords": [ + "template" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-text-template/issues", + "security": "https://github.com/sebastianbergmann/php-text-template/security/policy", + "source": "https://github.com/sebastianbergmann/php-text-template/tree/5.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2025-02-07T04:59:16+00:00" + }, + { + "name": "phpunit/php-timer", + "version": "8.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-timer.git", + "reference": "f258ce36aa457f3aa3339f9ed4c81fc66dc8c2cc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/f258ce36aa457f3aa3339f9ed4c81fc66dc8c2cc", + "reference": "f258ce36aa457f3aa3339f9ed4c81fc66dc8c2cc", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "8.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Utility class for timing", + "homepage": "https://github.com/sebastianbergmann/php-timer/", + "keywords": [ + "timer" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-timer/issues", + "security": "https://github.com/sebastianbergmann/php-timer/security/policy", + "source": "https://github.com/sebastianbergmann/php-timer/tree/8.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2025-02-07T04:59:38+00:00" + }, + { + "name": "phpunit/phpunit", + "version": "12.5.12", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/phpunit.git", + "reference": "418e06b3b46b0d54bad749ff4907fc7dfb530199" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/418e06b3b46b0d54bad749ff4907fc7dfb530199", + "reference": "418e06b3b46b0d54bad749ff4907fc7dfb530199", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-json": "*", + "ext-libxml": "*", + "ext-mbstring": "*", + "ext-xml": "*", + "ext-xmlwriter": "*", + "myclabs/deep-copy": "^1.13.4", + "phar-io/manifest": "^2.0.4", + "phar-io/version": "^3.2.1", + "php": ">=8.3", + "phpunit/php-code-coverage": "^12.5.3", + "phpunit/php-file-iterator": "^6.0.1", + "phpunit/php-invoker": "^6.0.0", + "phpunit/php-text-template": "^5.0.0", + "phpunit/php-timer": "^8.0.0", + "sebastian/cli-parser": "^4.2.0", + "sebastian/comparator": "^7.1.4", + "sebastian/diff": "^7.0.0", + "sebastian/environment": "^8.0.3", + "sebastian/exporter": "^7.0.2", + "sebastian/global-state": "^8.0.2", + "sebastian/object-enumerator": "^7.0.0", + "sebastian/recursion-context": "^7.0.1", + "sebastian/type": "^6.0.3", + "sebastian/version": "^6.0.0", + "staabm/side-effects-detector": "^1.0.5" + }, + "bin": [ + "phpunit" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "12.5-dev" + } + }, + "autoload": { + "files": [ + "src/Framework/Assert/Functions.php" + ], + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "The PHP Unit Testing framework.", + "homepage": "https://phpunit.de/", + "keywords": [ + "phpunit", + "testing", + "xunit" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/phpunit/issues", + "security": "https://github.com/sebastianbergmann/phpunit/security/policy", + "source": "https://github.com/sebastianbergmann/phpunit/tree/12.5.12" + }, + "funding": [ + { + "url": "https://phpunit.de/sponsors.html", + "type": "custom" + }, + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/phpunit", + "type": "tidelift" + } + ], + "time": "2026-02-16T08:34:36+00:00" + }, + { + "name": "psr/log", + "version": "3.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/log.git", + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/log/zipball/f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "shasum": "" + }, + "require": { + "php": ">=8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Log\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for logging libraries", + "homepage": "https://github.com/php-fig/log", + "keywords": [ + "log", + "psr", + "psr-3" + ], + "support": { + "source": "https://github.com/php-fig/log/tree/3.0.2" + }, + "time": "2024-09-11T13:17:53+00:00" + }, + { + "name": "psr/simple-cache", + "version": "3.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/simple-cache.git", + "reference": "764e0b3939f5ca87cb904f570ef9be2d78a07865" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/simple-cache/zipball/764e0b3939f5ca87cb904f570ef9be2d78a07865", + "reference": "764e0b3939f5ca87cb904f570ef9be2d78a07865", + "shasum": "" + }, + "require": { + "php": ">=8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\SimpleCache\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interfaces for simple caching", + "keywords": [ + "cache", + "caching", + "psr", + "psr-16", + "simple-cache" + ], + "support": { + "source": "https://github.com/php-fig/simple-cache/tree/3.0.0" + }, + "time": "2021-10-29T13:26:27+00:00" + }, + { + "name": "sebastian/cli-parser", + "version": "4.2.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/cli-parser.git", + "reference": "90f41072d220e5c40df6e8635f5dafba2d9d4d04" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/90f41072d220e5c40df6e8635f5dafba2d9d4d04", + "reference": "90f41072d220e5c40df6e8635f5dafba2d9d4d04", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.2-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for parsing CLI options", + "homepage": "https://github.com/sebastianbergmann/cli-parser", + "support": { + "issues": "https://github.com/sebastianbergmann/cli-parser/issues", + "security": "https://github.com/sebastianbergmann/cli-parser/security/policy", + "source": "https://github.com/sebastianbergmann/cli-parser/tree/4.2.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/cli-parser", + "type": "tidelift" + } + ], + "time": "2025-09-14T09:36:45+00:00" + }, + { + "name": "sebastian/comparator", + "version": "7.1.4", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/comparator.git", + "reference": "6a7de5df2e094f9a80b40a522391a7e6022df5f6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/6a7de5df2e094f9a80b40a522391a7e6022df5f6", + "reference": "6a7de5df2e094f9a80b40a522391a7e6022df5f6", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-mbstring": "*", + "php": ">=8.3", + "sebastian/diff": "^7.0", + "sebastian/exporter": "^7.0" + }, + "require-dev": { + "phpunit/phpunit": "^12.2" + }, + "suggest": { + "ext-bcmath": "For comparing BcMath\\Number objects" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.1-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Volker Dusch", + "email": "github@wallbash.com" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@2bepublished.at" + } + ], + "description": "Provides the functionality to compare PHP values for equality", + "homepage": "https://github.com/sebastianbergmann/comparator", + "keywords": [ + "comparator", + "compare", + "equality" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/comparator/issues", + "security": "https://github.com/sebastianbergmann/comparator/security/policy", + "source": "https://github.com/sebastianbergmann/comparator/tree/7.1.4" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/comparator", + "type": "tidelift" + } + ], + "time": "2026-01-24T09:28:48+00:00" + }, + { + "name": "sebastian/complexity", + "version": "5.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/complexity.git", + "reference": "bad4316aba5303d0221f43f8cee37eb58d384bbb" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/complexity/zipball/bad4316aba5303d0221f43f8cee37eb58d384bbb", + "reference": "bad4316aba5303d0221f43f8cee37eb58d384bbb", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^5.0", + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for calculating the complexity of PHP code units", + "homepage": "https://github.com/sebastianbergmann/complexity", + "support": { + "issues": "https://github.com/sebastianbergmann/complexity/issues", + "security": "https://github.com/sebastianbergmann/complexity/security/policy", + "source": "https://github.com/sebastianbergmann/complexity/tree/5.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2025-02-07T04:55:25+00:00" + }, + { + "name": "sebastian/diff", + "version": "7.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/diff.git", + "reference": "7ab1ea946c012266ca32390913653d844ecd085f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/7ab1ea946c012266ca32390913653d844ecd085f", + "reference": "7ab1ea946c012266ca32390913653d844ecd085f", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.0", + "symfony/process": "^7.2" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Kore Nordmann", + "email": "mail@kore-nordmann.de" + } + ], + "description": "Diff implementation", + "homepage": "https://github.com/sebastianbergmann/diff", + "keywords": [ + "diff", + "udiff", + "unidiff", + "unified diff" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/diff/issues", + "security": "https://github.com/sebastianbergmann/diff/security/policy", + "source": "https://github.com/sebastianbergmann/diff/tree/7.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2025-02-07T04:55:46+00:00" + }, + { + "name": "sebastian/environment", + "version": "8.0.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/environment.git", + "reference": "24a711b5c916efc6d6e62aa65aa2ec98fef77f68" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/24a711b5c916efc6d6e62aa65aa2ec98fef77f68", + "reference": "24a711b5c916efc6d6e62aa65aa2ec98fef77f68", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.0" + }, + "suggest": { + "ext-posix": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "8.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Provides functionality to handle HHVM/PHP environments", + "homepage": "https://github.com/sebastianbergmann/environment", + "keywords": [ + "Xdebug", + "environment", + "hhvm" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/environment/issues", + "security": "https://github.com/sebastianbergmann/environment/security/policy", + "source": "https://github.com/sebastianbergmann/environment/tree/8.0.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/environment", + "type": "tidelift" + } + ], + "time": "2025-08-12T14:11:56+00:00" + }, + { + "name": "sebastian/exporter", + "version": "7.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/exporter.git", + "reference": "016951ae10980765e4e7aee491eb288c64e505b7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/016951ae10980765e4e7aee491eb288c64e505b7", + "reference": "016951ae10980765e4e7aee491eb288c64e505b7", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "php": ">=8.3", + "sebastian/recursion-context": "^7.0" + }, + "require-dev": { + "phpunit/phpunit": "^12.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Volker Dusch", + "email": "github@wallbash.com" + }, + { + "name": "Adam Harvey", + "email": "aharvey@php.net" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@gmail.com" + } + ], + "description": "Provides the functionality to export PHP variables for visualization", + "homepage": "https://www.github.com/sebastianbergmann/exporter", + "keywords": [ + "export", + "exporter" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/exporter/issues", + "security": "https://github.com/sebastianbergmann/exporter/security/policy", + "source": "https://github.com/sebastianbergmann/exporter/tree/7.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/exporter", + "type": "tidelift" + } + ], + "time": "2025-09-24T06:16:11+00:00" + }, + { + "name": "sebastian/global-state", + "version": "8.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/global-state.git", + "reference": "ef1377171613d09edd25b7816f05be8313f9115d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/ef1377171613d09edd25b7816f05be8313f9115d", + "reference": "ef1377171613d09edd25b7816f05be8313f9115d", + "shasum": "" + }, + "require": { + "php": ">=8.3", + "sebastian/object-reflector": "^5.0", + "sebastian/recursion-context": "^7.0" + }, + "require-dev": { + "ext-dom": "*", + "phpunit/phpunit": "^12.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "8.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Snapshotting of global state", + "homepage": "https://www.github.com/sebastianbergmann/global-state", + "keywords": [ + "global state" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/global-state/issues", + "security": "https://github.com/sebastianbergmann/global-state/security/policy", + "source": "https://github.com/sebastianbergmann/global-state/tree/8.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/global-state", + "type": "tidelift" + } + ], + "time": "2025-08-29T11:29:25+00:00" + }, + { + "name": "sebastian/lines-of-code", + "version": "4.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/lines-of-code.git", + "reference": "97ffee3bcfb5805568d6af7f0f893678fc076d2f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/97ffee3bcfb5805568d6af7f0f893678fc076d2f", + "reference": "97ffee3bcfb5805568d6af7f0f893678fc076d2f", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^5.0", + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for counting the lines of code in PHP source code", + "homepage": "https://github.com/sebastianbergmann/lines-of-code", + "support": { + "issues": "https://github.com/sebastianbergmann/lines-of-code/issues", + "security": "https://github.com/sebastianbergmann/lines-of-code/security/policy", + "source": "https://github.com/sebastianbergmann/lines-of-code/tree/4.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2025-02-07T04:57:28+00:00" + }, + { + "name": "sebastian/object-enumerator", + "version": "7.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/object-enumerator.git", + "reference": "1effe8e9b8e068e9ae228e542d5d11b5d16db894" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/1effe8e9b8e068e9ae228e542d5d11b5d16db894", + "reference": "1effe8e9b8e068e9ae228e542d5d11b5d16db894", + "shasum": "" + }, + "require": { + "php": ">=8.3", + "sebastian/object-reflector": "^5.0", + "sebastian/recursion-context": "^7.0" + }, + "require-dev": { + "phpunit/phpunit": "^12.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Traverses array structures and object graphs to enumerate all referenced objects", + "homepage": "https://github.com/sebastianbergmann/object-enumerator/", + "support": { + "issues": "https://github.com/sebastianbergmann/object-enumerator/issues", + "security": "https://github.com/sebastianbergmann/object-enumerator/security/policy", + "source": "https://github.com/sebastianbergmann/object-enumerator/tree/7.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2025-02-07T04:57:48+00:00" + }, + { + "name": "sebastian/object-reflector", + "version": "5.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/object-reflector.git", + "reference": "4bfa827c969c98be1e527abd576533293c634f6a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/4bfa827c969c98be1e527abd576533293c634f6a", + "reference": "4bfa827c969c98be1e527abd576533293c634f6a", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Allows reflection of object attributes, including inherited and non-public ones", + "homepage": "https://github.com/sebastianbergmann/object-reflector/", + "support": { + "issues": "https://github.com/sebastianbergmann/object-reflector/issues", + "security": "https://github.com/sebastianbergmann/object-reflector/security/policy", + "source": "https://github.com/sebastianbergmann/object-reflector/tree/5.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2025-02-07T04:58:17+00:00" + }, + { + "name": "sebastian/recursion-context", + "version": "7.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/recursion-context.git", + "reference": "0b01998a7d5b1f122911a66bebcb8d46f0c82d8c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/0b01998a7d5b1f122911a66bebcb8d46f0c82d8c", + "reference": "0b01998a7d5b1f122911a66bebcb8d46f0c82d8c", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Adam Harvey", + "email": "aharvey@php.net" + } + ], + "description": "Provides functionality to recursively process PHP variables", + "homepage": "https://github.com/sebastianbergmann/recursion-context", + "support": { + "issues": "https://github.com/sebastianbergmann/recursion-context/issues", + "security": "https://github.com/sebastianbergmann/recursion-context/security/policy", + "source": "https://github.com/sebastianbergmann/recursion-context/tree/7.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/recursion-context", + "type": "tidelift" + } + ], + "time": "2025-08-13T04:44:59+00:00" + }, + { + "name": "sebastian/type", + "version": "6.0.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/type.git", + "reference": "e549163b9760b8f71f191651d22acf32d56d6d4d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/e549163b9760b8f71f191651d22acf32d56d6d4d", + "reference": "e549163b9760b8f71f191651d22acf32d56d6d4d", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Collection of value objects that represent the types of the PHP type system", + "homepage": "https://github.com/sebastianbergmann/type", + "support": { + "issues": "https://github.com/sebastianbergmann/type/issues", + "security": "https://github.com/sebastianbergmann/type/security/policy", + "source": "https://github.com/sebastianbergmann/type/tree/6.0.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/type", + "type": "tidelift" + } + ], + "time": "2025-08-09T06:57:12+00:00" + }, + { + "name": "sebastian/version", + "version": "6.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/version.git", + "reference": "3e6ccf7657d4f0a59200564b08cead899313b53c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/3e6ccf7657d4f0a59200564b08cead899313b53c", + "reference": "3e6ccf7657d4f0a59200564b08cead899313b53c", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library that helps with managing the version number of Git-hosted PHP projects", + "homepage": "https://github.com/sebastianbergmann/version", + "support": { + "issues": "https://github.com/sebastianbergmann/version/issues", + "security": "https://github.com/sebastianbergmann/version/security/policy", + "source": "https://github.com/sebastianbergmann/version/tree/6.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2025-02-07T05:00:38+00:00" + }, + { + "name": "staabm/side-effects-detector", + "version": "1.0.5", + "source": { + "type": "git", + "url": "https://github.com/staabm/side-effects-detector.git", + "reference": "d8334211a140ce329c13726d4a715adbddd0a163" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/staabm/side-effects-detector/zipball/d8334211a140ce329c13726d4a715adbddd0a163", + "reference": "d8334211a140ce329c13726d4a715adbddd0a163", + "shasum": "" + }, + "require": { + "ext-tokenizer": "*", + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^1.12.6", + "phpunit/phpunit": "^9.6.21", + "symfony/var-dumper": "^5.4.43", + "tomasvotruba/type-coverage": "1.0.0", + "tomasvotruba/unused-public": "1.0.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "lib/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "A static analysis tool to detect side effects in PHP code", + "keywords": [ + "static analysis" + ], + "support": { + "issues": "https://github.com/staabm/side-effects-detector/issues", + "source": "https://github.com/staabm/side-effects-detector/tree/1.0.5" + }, + "funding": [ + { + "url": "https://github.com/staabm", + "type": "github" + } + ], + "time": "2024-10-20T05:08:20+00:00" + }, + { + "name": "symfony/finder", + "version": "v8.0.6", + "source": { + "type": "git", + "url": "https://github.com/symfony/finder.git", + "reference": "441404f09a54de6d1bd6ad219e088cdf4c91f97c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/finder/zipball/441404f09a54de6d1bd6ad219e088cdf4c91f97c", + "reference": "441404f09a54de6d1bd6ad219e088cdf4c91f97c", + "shasum": "" + }, + "require": { + "php": ">=8.4" + }, + "require-dev": { + "symfony/filesystem": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Finder\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Finds files and directories via an intuitive fluent interface", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/finder/tree/v8.0.6" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-01-29T09:41:02+00:00" + }, + { + "name": "symfony/process", + "version": "v8.0.5", + "source": { + "type": "git", + "url": "https://github.com/symfony/process.git", + "reference": "b5f3aa6762e33fd95efbaa2ec4f4bc9fdd16d674" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/process/zipball/b5f3aa6762e33fd95efbaa2ec4f4bc9fdd16d674", + "reference": "b5f3aa6762e33fd95efbaa2ec4f4bc9fdd16d674", + "shasum": "" + }, + "require": { + "php": ">=8.4" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Process\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Executes commands in sub-processes", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/process/tree/v8.0.5" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-01-26T15:08:38+00:00" + }, + { + "name": "ta-tikoma/phpunit-architecture-test", + "version": "0.8.7", + "source": { + "type": "git", + "url": "https://github.com/ta-tikoma/phpunit-architecture-test.git", + "reference": "1248f3f506ca9641d4f68cebcd538fa489754db8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ta-tikoma/phpunit-architecture-test/zipball/1248f3f506ca9641d4f68cebcd538fa489754db8", + "reference": "1248f3f506ca9641d4f68cebcd538fa489754db8", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^4.18.0 || ^5.0.0", + "php": "^8.1.0", + "phpdocumentor/reflection-docblock": "^5.3.0 || ^6.0.0", + "phpunit/phpunit": "^10.5.5 || ^11.0.0 || ^12.0.0 || ^13.0.0", + "symfony/finder": "^6.4.0 || ^7.0.0 || ^8.0.0" + }, + "require-dev": { + "laravel/pint": "^1.13.7", + "phpstan/phpstan": "^1.10.52" + }, + "type": "library", + "autoload": { + "psr-4": { + "PHPUnit\\Architecture\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ni Shi", + "email": "futik0ma011@gmail.com" + }, + { + "name": "Nuno Maduro", + "email": "enunomaduro@gmail.com" + } + ], + "description": "Methods for testing application architecture", + "keywords": [ + "architecture", + "phpunit", + "stucture", + "test", + "testing" + ], + "support": { + "issues": "https://github.com/ta-tikoma/phpunit-architecture-test/issues", + "source": "https://github.com/ta-tikoma/phpunit-architecture-test/tree/0.8.7" + }, + "time": "2026-02-17T17:25:14+00:00" + }, + { + "name": "theseer/tokenizer", + "version": "2.0.1", + "source": { + "type": "git", + "url": "https://github.com/theseer/tokenizer.git", + "reference": "7989e43bf381af0eac72e4f0ca5bcbfa81658be4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/theseer/tokenizer/zipball/7989e43bf381af0eac72e4f0ca5bcbfa81658be4", + "reference": "7989e43bf381af0eac72e4f0ca5bcbfa81658be4", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-tokenizer": "*", + "ext-xmlwriter": "*", + "php": "^8.1" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + } + ], + "description": "A small library for converting tokenized PHP source code into XML and potentially other formats", + "support": { + "issues": "https://github.com/theseer/tokenizer/issues", + "source": "https://github.com/theseer/tokenizer/tree/2.0.1" + }, + "funding": [ + { + "url": "https://github.com/theseer", + "type": "github" + } + ], + "time": "2025-12-08T11:19:18+00:00" + }, + { + "name": "webmozart/assert", + "version": "2.1.6", + "source": { + "type": "git", + "url": "https://github.com/webmozarts/assert.git", + "reference": "ff31ad6efc62e66e518fbab1cde3453d389bcdc8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/webmozarts/assert/zipball/ff31ad6efc62e66e518fbab1cde3453d389bcdc8", + "reference": "ff31ad6efc62e66e518fbab1cde3453d389bcdc8", + "shasum": "" + }, + "require": { + "ext-ctype": "*", + "ext-date": "*", + "ext-filter": "*", + "php": "^8.2" + }, + "suggest": { + "ext-intl": "", + "ext-simplexml": "", + "ext-spl": "" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-feature/2-0": "2.0-dev" + } + }, + "autoload": { + "psr-4": { + "Webmozart\\Assert\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Bernhard Schussek", + "email": "bschussek@gmail.com" + }, + { + "name": "Woody Gilk", + "email": "woody.gilk@gmail.com" + } + ], + "description": "Assertions to validate method input/output with nice error messages.", + "keywords": [ + "assert", + "check", + "validate" + ], + "support": { + "issues": "https://github.com/webmozarts/assert/issues", + "source": "https://github.com/webmozarts/assert/tree/2.1.6" + }, + "time": "2026-02-27T10:28:38+00:00" + } + ], "aliases": [], "minimum-stability": "stable", "stability-flags": {}, diff --git a/docs/Editor.md b/docs/Editor.md index 010df3c..8923522 100644 --- a/docs/Editor.md +++ b/docs/Editor.md @@ -279,6 +279,7 @@ Inspector type mapping: - directories are shown as `Folder` - files are shown as `File` +- selecting a `.texture` or `.tmap` also opens `Main -> Sprite` and moves focus there ### Asset Delete Workflow @@ -302,6 +303,7 @@ Current create targets: - `Script` - `Scene` +- `Prefab` - `Texture` - `Tile Map` - `Event` @@ -312,6 +314,8 @@ Behavior: - the editor creates the asset with the next available default name for that asset family - after creation, the Assets tree refreshes, the new asset is selected, and the Inspector loads it - if the created asset is a texture or tile map, the Sprite tab loads it too +- prefab assets are created as `.prefab.php` metadata files under `Assets/Prefabs` +- prefab metadata returns a single array shaped like one scene `hierarchy` entry, so it can describe either a `GameObject` or a UI element such as `Label` or `Text` ## Inspector Panel @@ -330,7 +334,7 @@ For file assets, the Inspector currently shows: - read-only `Path` Renaming a file asset from the Inspector renames the file on disk. If the current scene references that file through `sprite.texture.path` or `environmentTileMapPath`, those scene references are updated in memory and should be saved with `Ctrl+S`. -If the renamed asset is a PHP script under `Assets/Scripts`, the editor also rewrites the class declaration inside the source file to match the new filename. +If the renamed asset is a PHP class-backed file under `Assets/Scripts` or `Assets/Events`, the editor also rewrites the class declaration inside the source file to match the new filename. ### Inspector Hotkeys diff --git a/docs/guides/building-scenes.md b/docs/guides/building-scenes.md index 42a2fd0..143d7a3 100644 --- a/docs/guides/building-scenes.md +++ b/docs/guides/building-scenes.md @@ -189,9 +189,12 @@ Selecting a component: - loads any serializable default data the editor can discover - immediately refreshes the Inspector so you can keep editing the new component -Current limit: +Components can also be managed after they are added: -- components can be added, but they cannot yet be removed or reordered from the editor UI +- focus a component header and press `Delete` to open the remove confirmation dialog +- focus a component header and press `Shift+W` to enter component move mode +- while move mode is active, use `Up` / `Down` to reorder the selected component with wraparound +- press `Escape` or `Shift+W` again to leave move mode For a full breakdown, continue with [Inspector and Properties](inspector-and-properties.md). diff --git a/docs/guides/getting-started.md b/docs/guides/getting-started.md index 8454d5b..0e4be99 100644 --- a/docs/guides/getting-started.md +++ b/docs/guides/getting-started.md @@ -100,8 +100,9 @@ A good first session looks like this: 3. In `Inspector`, set the scene `Name`, `Width`, `Height`, and `Environment Tile Map`. 4. In `Assets`, inspect your existing textures and maps. 5. In `Hierarchy`, press `Shift+A` to create your first scene object. -6. In `Assets`, press `Shift+A` if you need a new script, scene, texture, tile map, or event file. -7. Press `Ctrl+S` after your first scene change so you know where persistence happens. +6. In `Assets`, press `Shift+A` if you need a new script, scene, prefab, texture, tile map, or event file. +7. Select a texture or tile map in `Assets` and notice that `Main -> Sprite` opens automatically. +8. Press `Ctrl+S` after your first scene change so you know where persistence happens. ## What Gets Saved, And When diff --git a/docs/guides/inspector-and-properties.md b/docs/guides/inspector-and-properties.md index 9d38b2d..8a02438 100644 --- a/docs/guides/inspector-and-properties.md +++ b/docs/guides/inspector-and-properties.md @@ -29,6 +29,7 @@ Controls: - `Up` / `Down`: move between controls - `Enter`: activate the selected control - `Shift+A`: open the add-component menu when a hierarchy object is being inspected +- `Shift+W`: enter or leave component move mode when a component header is selected - `/`: collapse or expand the selected section header - `Tab` / `Shift+Tab`: move forward or backward through focusable controls @@ -125,6 +126,23 @@ When you choose a component: - any serializable default data the editor can discover is added immediately - the new section appears in the Inspector right away +## Removing And Reordering Components + +Component headers are interactive controls. + +To remove a component: + +- focus the component header +- press `Delete` +- confirm removal in the modal + +To reorder components: + +- focus the component header you want to move +- press `Shift+W` to enter component move mode +- press `Up` or `Down` to move that component through the list with wraparound +- press `Escape` or `Shift+W` again to leave move mode + ## Asset Controls When a file asset is inspected, the Inspector renders: @@ -133,7 +151,7 @@ When a file asset is inspected, the Inspector renders: - editable `Name` - read-only `Path` -If the file is a PHP script under `Assets/Scripts`, renaming it in the Inspector also updates the class declaration inside the source file to match the new filename. +If the file is a PHP class-backed asset under `Assets/Scripts` or `Assets/Events`, renaming it in the Inspector also updates the class declaration inside the source file to match the new filename. When a folder is inspected, the Inspector renders: @@ -207,8 +225,6 @@ These habits make the Inspector much easier to use: The Inspector edits what already exists in the loaded data model. It does not currently provide UI for: -- removing components -- reordering components - changing hierarchy parenting - creating new nested child objects diff --git a/docs/guides/layout-and-navigation.md b/docs/guides/layout-and-navigation.md index 53cea30..8a9a53c 100644 --- a/docs/guides/layout-and-navigation.md +++ b/docs/guides/layout-and-navigation.md @@ -81,7 +81,6 @@ The editor uses modals for focused tasks such as: - add-object flow - asset creation - delete confirmations -- sprite quick creation - add-component selection - special character selection - path input actions diff --git a/docs/guides/reference.md b/docs/guides/reference.md index 1b48cfe..0a18940 100644 --- a/docs/guides/reference.md +++ b/docs/guides/reference.md @@ -12,7 +12,7 @@ This page gathers the most useful shortcuts, file locations, persistence rules, | `Shift+Left` | Focus panel to the left | | `Shift+1` | Open panel list | | `Shift+5` | Toggle play state | -| `Shift+A` | Panel-local create action in `Hierarchy`, `Assets`, `Inspector`, and `Main -> Sprite` | +| `Shift+A` | Panel-local create action in `Hierarchy`, `Assets`, and `Inspector` | | `Ctrl+S` | Save the loaded scene | | `Ctrl+C` | Close the editor | @@ -48,6 +48,7 @@ Create targets: - `Script` - `Scene` +- `Prefab` - `Texture` - `Tile Map` - `Event` @@ -181,14 +182,14 @@ Tree-style modals also use: | scene-view moves | active `.scene.php` file | when you press `Ctrl+S` | | hierarchy additions and deletions | active `.scene.php` file | when you press `Ctrl+S` | | scene rename | renamed `.scene.php` file | when you press `Ctrl+S` | -| asset creation from `Assets` or `Sprite` | generated asset file | immediately | +| asset creation from `Assets` | generated asset file | immediately | | texture and tile map drawing | selected asset file | immediately | | file asset rename | selected asset file path | immediately | | asset delete | selected file or folder | immediately | Special rename behavior: -- renaming a script under `Assets/Scripts` also rewrites its PHP class declaration immediately +- renaming a PHP class-backed asset under `Assets/Scripts` or `Assets/Events` also rewrites its class declaration immediately ## Current Editor Limits diff --git a/docs/guides/working-with-assets.md b/docs/guides/working-with-assets.md index 6911648..c5e89a6 100644 --- a/docs/guides/working-with-assets.md +++ b/docs/guides/working-with-assets.md @@ -23,7 +23,7 @@ What inspection does: - folders open in the `Inspector` as `Folder` - files open in the `Inspector` as `File` -- `.texture` and `.tmap` files also load into `Main -> Sprite` +- selecting a `.texture` or `.tmap` also opens `Main -> Sprite` and moves focus there ## Sprite Tab Overview @@ -35,9 +35,9 @@ The `Sprite` tab is the editor's character-grid workspace for: How loading works: - select a `.texture` or `.tmap` in `Assets` -- press `Enter` - the file opens in `Inspector` - the same file loads into `Sprite` +- focus shifts to `Main -> Sprite` ## Creating New Assets @@ -47,6 +47,7 @@ Current create options: - `Script` - `Scene` +- `Prefab` - `Texture` - `Tile Map` - `Event` @@ -62,26 +63,28 @@ Default name families: - scripts: `new-script-1`, `new-script-2`, and so on - scenes: `new-scene-1`, `new-scene-2`, and so on +- prefabs: `new-prefab-1`, `new-prefab-2`, and so on - textures: `new-texture-1`, `new-texture-2`, and so on - tile maps: `new-map-1`, `new-map-2`, and so on - events: `new-event-1`, `new-event-2`, and so on If the created asset is a `.texture` or `.tmap`, the editor also loads it into `Main -> Sprite`. -## Quick Create From The Sprite Tab +## Prefab Assets -There is also a faster create path for art assets. +Prefab files live under `Assets/Prefabs` and use the `.prefab.php` extension. -When `Main` is focused and the `Sprite` tab is active, press `Shift+A` to create: +Each prefab returns a single PHP array in the same shape used by one scene `hierarchy` item. That means a prefab can describe: -- `Texture` -- `Tile Map` +- a `GameObject` +- a UI element such as `Label` +- a UI element such as `Text` -This quick-create flow: +Current prefab support in the editor is focused on generation and project organization: -- is limited to sprite-editable asset types -- creates the file immediately under the active asset root's `Textures` or `Maps` directory -- opens the new asset directly in the sprite editor +- the `Assets` create menu can generate new prefab files +- the CLI can generate prefabs directly with `sendama generate:prefab ` +- the generated file is normal scene-style metadata, so it is easy to author by hand too ## Sprite Editing Controls @@ -138,7 +141,7 @@ Behavior to know: - folder names are read-only in the current Inspector UI - renaming preserves the current file extension - if the current scene references that file through `sprite.texture.path` or `environmentTileMapPath`, those in-memory scene references are updated -- renaming a script file also rewrites the PHP class declaration inside that file to match the new filename +- renaming a script or event PHP file also rewrites the class declaration inside that file to match the new filename Very important: diff --git a/src/Commands/GeneratePrefab.php b/src/Commands/GeneratePrefab.php index 4b457a1..d7f28e7 100644 --- a/src/Commands/GeneratePrefab.php +++ b/src/Commands/GeneratePrefab.php @@ -1,27 +1,44 @@ -writeln('Not implemented yet.'); - - return Command::SUCCESS; - } -} \ No newline at end of file +addArgument('name', InputArgument::REQUIRED, 'The name of the prefab') + ->addOption( + 'kind', + 'k', + InputOption::VALUE_REQUIRED, + 'The prefab shape to generate (gameobject, label, text)', + 'gameobject', + ); + } + + public function execute(InputInterface $input, OutputInterface $output): int + { + $prefabGenerationStrategy = new PrefabFileGenerationStrategy( + $input, + $output, + $input->getArgument('name') ?? 'prefab', + 'prefabs', + strtolower((string) $input->getOption('kind')), + ); + + return $prefabGenerationStrategy->generate(); + } +} diff --git a/src/Commands/NewGame.php b/src/Commands/NewGame.php index f381943..07b5838 100644 --- a/src/Commands/NewGame.php +++ b/src/Commands/NewGame.php @@ -3,6 +3,7 @@ namespace Sendama\Console\Commands; use RuntimeException; +use Sendama\Console\Strategies\AssetFileGeneration\SceneFileGenerationStrategy; use Sendama\Console\Util\Path; use Sendama\Console\Util\ProjectNormalizer; use Symfony\Component\Console\Attribute\AsCommand; @@ -93,6 +94,7 @@ public function execute(InputInterface $input, OutputInterface $output): int $this->createSplashScreenTextureFile($assetsDirectory); $this->createPlayerTextureFile($assetsDirectory); $this->createTheExampleMapFile($this->mapsDirectory); + $this->createDefaultSceneFile($assetsDirectory); $this->createDocsDirectory($this->targetDirectory); $this->createReadmeFile($this->targetDirectory); @@ -143,6 +145,8 @@ private function getProjectConfiguration(string $projectName): string description: 'A 2D ASCII terminal game.', version: '0.0.1', mainFile: $mainFilename, + loadedScenes: ['Scenes/Level.scene.php'], + consoleRefreshInterval: 5.0, ); } @@ -281,6 +285,15 @@ private function createInputConfigurationFile(string $packageName): void } } + private function createDefaultSceneFile(string $assetsDirectory): void + { + $targetSceneFilename = Path::join($assetsDirectory, 'Scenes', 'Level.scene.php'); + + if (false === file_put_contents($targetSceneFilename, SceneFileGenerationStrategy::buildMetaSceneContents())) { + throw new RuntimeException(sprintf('Unable to write to file "%s"', $targetSceneFilename)); + } + } + /** * Create the main file. * diff --git a/src/Editor/Editor.php b/src/Editor/Editor.php index 357f10a..754bb1b 100644 --- a/src/Editor/Editor.php +++ b/src/Editor/Editor.php @@ -8,6 +8,7 @@ use Atatusoft\Termutil\IO\Console\Console; use Atatusoft\Termutil\UI\Windows\Window; use Sendama\Console\Commands\GenerateEvent; +use Sendama\Console\Commands\GeneratePrefab; use Sendama\Console\Commands\GenerateScene; use Sendama\Console\Commands\GenerateScript; use Sendama\Console\Commands\GenerateTexture; @@ -226,7 +227,8 @@ public function start(): void Console::saveSettings(); - Console::setName($this->gameSettings?->name ?? "Sendama Editor | Unknown Game"); + $terminalTitle = "Sendama Editor | "; + Console::setName($terminalTitle . ($this->gameSettings?->name ?? "Unknown Game")); Console::setSize($this->terminalWidth, $this->terminalHeight); @@ -1062,7 +1064,13 @@ private function synchronizeInspectorPanel(): void } elseif (($selectedItem['context'] ?? null) === 'scene') { $this->hierarchyPanel->selectPath('scene'); } elseif (($selectedItem['context'] ?? null) === 'asset') { - $this->mainPanel->loadSpriteAsset(is_array($selectedItem['value'] ?? null) ? $selectedItem['value'] : null); + $asset = is_array($selectedItem['value'] ?? null) ? $selectedItem['value'] : null; + + if (($selectedItem['openInMainPanel'] ?? false) === true && $this->isEditableSpriteAsset($asset)) { + $this->mainPanel->loadSpriteAsset($asset); + $this->mainPanel->selectTab('Sprite'); + $this->setFocusedPanel($this->mainPanel); + } } $this->inspectorPanel->inspectTarget($selectedItem); @@ -1474,6 +1482,10 @@ private function resolveAssetCreationDefinition(string $kind): ?array 'command' => GenerateScene::class, 'baseName' => 'new-scene', ], + 'prefab' => [ + 'command' => GeneratePrefab::class, + 'baseName' => 'new-prefab', + ], 'texture' => [ 'command' => GenerateTexture::class, 'baseName' => 'new-texture', @@ -1702,7 +1714,7 @@ private function renameAssetAndCascadeReferences( : $this->buildRelativeAssetPath($currentAbsolutePath); $newRelativePath = $this->buildRelativeAssetPath($targetAbsolutePath); - if (!$this->synchronizeScriptClassNameWithFileRename($targetAbsolutePath, $oldRelativePath, $newRelativePath)) { + if (!$this->synchronizePhpAssetClassNameWithFileRename($targetAbsolutePath, $oldRelativePath, $newRelativePath)) { if ( $targetAbsolutePath !== $currentAbsolutePath && is_file($targetAbsolutePath) @@ -1734,12 +1746,15 @@ private function renameAssetAndCascadeReferences( ]; } - private function synchronizeScriptClassNameWithFileRename( + private function synchronizePhpAssetClassNameWithFileRename( string $targetAbsolutePath, string $oldRelativePath, string $newRelativePath, ): bool { - if (!$this->isScriptAssetPath($oldRelativePath) && !$this->isScriptAssetPath($newRelativePath)) { + if ( + !$this->isPhpClassBackedAssetPath($oldRelativePath) + && !$this->isPhpClassBackedAssetPath($newRelativePath) + ) { return true; } @@ -1750,7 +1765,7 @@ private function synchronizeScriptClassNameWithFileRename( $source = file_get_contents($targetAbsolutePath); if (!is_string($source) || $source === '') { - $this->consolePanel->append('[ERROR] - Failed to update the renamed script source.'); + $this->consolePanel->append('[ERROR] - Failed to update the renamed asset source.'); return false; } @@ -1758,7 +1773,7 @@ private function synchronizeScriptClassNameWithFileRename( $newClassName = $this->derivePhpAssetClassNameFromRelativePath($newRelativePath); if ($newClassName === '') { - $this->consolePanel->append('[ERROR] - Failed to derive the renamed script class name.'); + $this->consolePanel->append('[ERROR] - Failed to derive the renamed asset class name.'); return false; } @@ -1774,7 +1789,7 @@ private function synchronizeScriptClassNameWithFileRename( ); if (!is_string($updatedSource)) { - $this->consolePanel->append('[ERROR] - Failed to update the renamed script class.'); + $this->consolePanel->append('[ERROR] - Failed to update the renamed asset class.'); return false; } @@ -1792,22 +1807,25 @@ private function synchronizeScriptClassNameWithFileRename( ); if (!is_string($updatedSource) || $updatedSource === $source) { - $this->consolePanel->append('[ERROR] - Failed to locate the script class declaration after rename.'); + $this->consolePanel->append('[ERROR] - Failed to locate the asset class declaration after rename.'); return false; } } if (file_put_contents($targetAbsolutePath, $updatedSource) === false) { - $this->consolePanel->append('[ERROR] - Failed to write the renamed script source.'); + $this->consolePanel->append('[ERROR] - Failed to write the renamed asset source.'); return false; } return true; } - private function isScriptAssetPath(string $relativePath): bool + private function isPhpClassBackedAssetPath(string $relativePath): bool { - return str_starts_with(str_replace('\\', '/', $relativePath), 'Scripts/'); + $normalizedPath = str_replace('\\', '/', $relativePath); + + return str_starts_with($normalizedPath, 'Scripts/') + || str_starts_with($normalizedPath, 'Events/'); } private function derivePhpAssetClassNameFromRelativePath(string $relativePath): string @@ -1947,6 +1965,25 @@ private function buildAssetInspectionTarget(array $asset): array ]; } + private function isEditableSpriteAsset(?array $asset): bool + { + if (!is_array($asset) || ($asset['isDirectory'] ?? false)) { + return false; + } + + $assetPath = is_string($asset['path'] ?? null) + ? $asset['path'] + : (is_string($asset['relativePath'] ?? null) ? $asset['relativePath'] : null); + + if (!is_string($assetPath) || $assetPath === '') { + return false; + } + + $extension = strtolower((string) pathinfo($assetPath, PATHINFO_EXTENSION)); + + return in_array($extension, ['texture', 'tmap'], true); + } + private function saveLoadedScene(): void { if (!$this->loadedScene instanceof DTOs\SceneDTO) { @@ -2015,9 +2052,9 @@ private function applySceneMutation(array $value): bool } if (is_string($value['environmentTileMapPath'] ?? null)) { - $this->loadedScene->environmentTileMapPath = trim($value['environmentTileMapPath']) !== '' - ? trim($value['environmentTileMapPath']) - : 'Maps/example'; + $this->loadedScene->environmentTileMapPath = $this->normalizeEnvironmentTileMapPath( + $value['environmentTileMapPath'] + ); } $this->loadedScene->rawData['width'] = $this->loadedScene->width; @@ -2041,6 +2078,21 @@ private function buildSceneInspectionValue(): array ]; } + private function normalizeEnvironmentTileMapPath(mixed $value): string + { + if (!is_string($value)) { + return 'Maps/example'; + } + + $normalizedValue = trim(str_replace('\\', '/', $value)); + + if ($normalizedValue === '') { + return 'Maps/example'; + } + + return preg_replace('/\.tmap$/i', '', $normalizedValue) ?? $normalizedValue; + } + private function syncScenePanels(bool $isDirty): void { if (!$this->loadedScene instanceof DTOs\SceneDTO) { diff --git a/src/Editor/IO/InputManager.php b/src/Editor/IO/InputManager.php index 2828025..f691d10 100644 --- a/src/Editor/IO/InputManager.php +++ b/src/Editor/IO/InputManager.php @@ -14,6 +14,7 @@ class InputManager implements StaticObservableInterface { use StaticObservableTrait; + private const float REPEATABLE_KEY_HOLD_WINDOW_SECONDS = 0.05; private const array COALESCED_REPEATABLE_KEYS = [ KeyCode::UP->value, KeyCode::RIGHT->value, @@ -34,6 +35,8 @@ class InputManager implements StaticObservableInterface private static array $buttons = []; private static ?MouseEvent $mouseEvent = null; private static ?string $terminalModeSnapshot = null; + private static string $heldRepeatableKeyPress = ''; + private static float $heldRepeatableKeySeenAt = 0.0; /** * Initializes the InputManager. @@ -45,6 +48,8 @@ public static function init(): void self::$previousKeyPress = self::$keyPress = ""; self::$inputQueue = []; self::$mouseEvent = null; + self::$heldRepeatableKeyPress = ''; + self::$heldRepeatableKeySeenAt = 0.0; self::initializeObservers(); } @@ -127,8 +132,8 @@ public static function handleInput(): void } self::$inputQueue = self::coalesceRepeatableTokens(self::$inputQueue); - - self::$keyPress = array_shift(self::$inputQueue) ?? ''; + $nextKeyPress = array_shift(self::$inputQueue) ?? ''; + self::$keyPress = self::resolveCurrentKeyPress($nextKeyPress, microtime(true)); self::$mouseEvent = self::parseMouseEvent(self::$keyPress); if (self::$mouseEvent) { @@ -269,7 +274,15 @@ public static function isKeyDown(KeyCode $keyCode, bool $ignoreCase = true): boo $keyCodeValue = mb_strtolower($keyCode->value); } - return $key === $keyCodeValue && $previousKey !== $key; + if ($key !== $keyCodeValue) { + return false; + } + + if (in_array($keyCodeValue, self::COALESCED_REPEATABLE_KEYS, true)) { + return true; + } + + return $previousKey !== $key; } /** @@ -445,6 +458,35 @@ private static function coalesceRepeatableTokens(array $tokens): array return $coalescedTokens; } + private static function resolveCurrentKeyPress(string $nextKeyPress, float $now): string + { + if ($nextKeyPress !== '') { + $normalizedToken = self::getKey($nextKeyPress); + + if (in_array($normalizedToken, self::COALESCED_REPEATABLE_KEYS, true)) { + self::$heldRepeatableKeyPress = $nextKeyPress; + self::$heldRepeatableKeySeenAt = $now; + } else { + self::$heldRepeatableKeyPress = ''; + self::$heldRepeatableKeySeenAt = 0.0; + } + + return $nextKeyPress; + } + + if ( + self::$heldRepeatableKeyPress !== '' + && ($now - self::$heldRepeatableKeySeenAt) <= self::REPEATABLE_KEY_HOLD_WINDOW_SECONDS + ) { + return self::$heldRepeatableKeyPress; + } + + self::$heldRepeatableKeyPress = ''; + self::$heldRepeatableKeySeenAt = 0.0; + + return ''; + } + private static function extractEscapeSequence(string $input): string { $knownSequences = [ diff --git a/src/Editor/SceneLoader.php b/src/Editor/SceneLoader.php index 67ea6a4..030c012 100644 --- a/src/Editor/SceneLoader.php +++ b/src/Editor/SceneLoader.php @@ -26,12 +26,17 @@ public function load(EditorSceneSettings $sceneSettings): ?SceneDTO $sceneDataBundle = $this->loadSceneDataBundle($scenePath); $sceneData = $sceneDataBundle['editor'] ?? []; $sourceSceneData = $sceneDataBundle['source'] ?? $sceneData; + $normalizedEnvironmentTileMapPath = $this->normalizeEnvironmentTileMapPath( + $sceneData['environmentTileMapPath'] ?? $sourceSceneData['environmentTileMapPath'] ?? 'Maps/example', + ); + $sceneData['environmentTileMapPath'] = $normalizedEnvironmentTileMapPath; + $sourceSceneData['environmentTileMapPath'] = $normalizedEnvironmentTileMapPath; return new SceneDTO( name: basename($scenePath, '.scene.php'), width: $sceneData['width'] ?? DEFAULT_TERMINAL_WIDTH, height: $sceneData['height'] ?? DEFAULT_TERMINAL_HEIGHT, - environmentTileMapPath: $sceneData['environmentTileMapPath'] ?? 'Maps/example', + environmentTileMapPath: $normalizedEnvironmentTileMapPath, isDirty: $sceneData['isDirty'] ?? false, hierarchy: $sceneData['hierarchy'] ?? [], sourcePath: $scenePath, @@ -82,6 +87,21 @@ private function resolveScenesDirectory(): ?string return null; } + private function normalizeEnvironmentTileMapPath(mixed $value): string + { + if (!is_string($value)) { + return 'Maps/example'; + } + + $normalizedValue = trim(str_replace('\\', '/', $value)); + + if ($normalizedValue === '') { + return 'Maps/example'; + } + + return preg_replace('/\.tmap$/i', '', $normalizedValue) ?? $normalizedValue; + } + private function resolveConfiguredScenePath( EditorSceneSettings $sceneSettings, ?string $scenesDirectory diff --git a/src/Editor/Widgets/AssetsPanel.php b/src/Editor/Widgets/AssetsPanel.php index 02b100a..908075e 100644 --- a/src/Editor/Widgets/AssetsPanel.php +++ b/src/Editor/Widgets/AssetsPanel.php @@ -64,6 +64,7 @@ public function moveSelection(int $offset): void $nextIndex = max(0, min($selectedIndex + $offset, count($this->visibleAssets) - 1)); $this->selectedPath = $this->visibleAssets[$nextIndex]['path'] ?? $this->selectedPath; $this->refreshContent(); + $this->queueInspectionTarget(); } public function expandSelection(): void @@ -90,6 +91,7 @@ public function expandSelection(): void ) { $this->selectedPath = $entry['path']; $this->refreshContent(); + $this->queueInspectionTarget(); return; } } @@ -117,9 +119,15 @@ public function collapseSelection(): void $this->selectedPath = $parentPath; $this->refreshContent(); + $this->queueInspectionTarget(); } public function activateSelection(): void + { + $this->queueInspectionTarget(true); + } + + private function queueInspectionTarget(bool $openInMainPanel = false): void { $selectedAsset = $this->getSelectedAssetEntry(); @@ -132,6 +140,7 @@ public function activateSelection(): void 'name' => $selectedAsset['name'] ?? 'Unnamed Asset', 'type' => ($selectedAsset['isDirectory'] ?? false) ? 'Folder' : 'File', 'value' => $selectedAsset, + 'openInMainPanel' => $openInMainPanel, ]; } @@ -163,7 +172,7 @@ public function beginCreateWorkflow(): void { $this->modalState = self::CREATE_MODAL_ASSET_KIND; $this->createAssetModal->show( - ['Script', 'Scene', 'Texture', 'Tile Map', 'Event'], + ['Script', 'Scene', 'Prefab', 'Texture', 'Tile Map', 'Event'], title: 'Create Asset', ); } @@ -222,6 +231,7 @@ public function selectAssetByAbsolutePath(?string $absolutePath): void $this->selectedPath = $matchedPath; $this->refreshContent(); + $this->queueInspectionTarget(); } public function handleMouseClick(int $x, int $y): void @@ -238,7 +248,7 @@ public function handleMouseClick(int $x, int $y): void $this->selectedPath = $this->visibleAssets[$index]['path'] ?? $this->selectedPath; $this->refreshContent(); - $this->activateSelection(); + $this->queueInspectionTarget(); } public function update(): void @@ -603,6 +613,7 @@ private function handleModalInput(): void $assetKind = match ($selection) { 'Script' => 'script', 'Scene' => 'scene', + 'Prefab' => 'prefab', 'Texture' => 'texture', 'Tile Map' => 'tilemap', 'Event' => 'event', diff --git a/src/Editor/Widgets/ConsolePanel.php b/src/Editor/Widgets/ConsolePanel.php index 4cca5c5..a4c60d3 100644 --- a/src/Editor/Widgets/ConsolePanel.php +++ b/src/Editor/Widgets/ConsolePanel.php @@ -75,8 +75,10 @@ public function cycleFocusBackward(): bool public function append(string $message): void { - $tabTitle = $this->resolveSessionTabTitle($message); - $this->sessionMessagesByTab[$tabTitle][] = $message; + $timestamp = date(DATE_ATOM); + $timestampedMessage = "[$timestamp] $message"; + $tabTitle = $this->resolveSessionTabTitle($timestampedMessage); + $this->sessionMessagesByTab[$tabTitle][] = $timestampedMessage; if ($tabTitle !== $this->getActiveTab()) { return; diff --git a/src/Editor/Widgets/InspectorPanel.php b/src/Editor/Widgets/InspectorPanel.php index 3eb8721..59c89ee 100644 --- a/src/Editor/Widgets/InspectorPanel.php +++ b/src/Editor/Widgets/InspectorPanel.php @@ -98,6 +98,11 @@ public function setSceneHierarchy(array $hierarchy): void public function inspectTarget(?array $target): void { + $preserveSelectedControl = $this->shouldPreserveSelectedControl($this->inspectionTarget, $target); + $selectedControlSnapshot = $preserveSelectedControl + ? $this->captureSelectedControlSnapshot($this->getSelectedControl()) + : []; + $this->inspectionTarget = $target; $this->elements = []; $this->focusableControls = []; @@ -145,6 +150,10 @@ public function inspectTarget(?array $target): void $this->applyControlSelection(); } + if ($preserveSelectedControl) { + $this->restoreSelectedControlSnapshot($selectedControlSnapshot); + } + $this->refreshContent(); } @@ -252,6 +261,7 @@ public function syncHierarchyTarget(string $path, array $value): void : null; $shouldPreserveComponentMoveMode = $this->isComponentMoveModeActive && $this->isSelectedComponentHeader($selectedControl); + $selectedControlSnapshot = $this->captureSelectedControlSnapshot($selectedControl); $target = $this->inspectionTarget; $target['name'] = $value['name'] ?? ($target['name'] ?? 'Unnamed Object'); @@ -268,6 +278,8 @@ public function syncHierarchyTarget(string $path, array $value): void if ($componentCount > 0) { $this->focusComponentHeaderByIndex(min($selectedComponentIndex, $componentCount - 1)); } + } else { + $this->restoreSelectedControlSnapshot($selectedControlSnapshot); } if ($shouldPreserveComponentMoveMode) { @@ -285,6 +297,7 @@ public function syncSceneTarget(array $value): void return; } + $selectedControlSnapshot = $this->captureSelectedControlSnapshot($this->getSelectedControl()); $target = $this->inspectionTarget; $target['name'] = $value['name'] ?? ($target['name'] ?? 'Scene'); $target['type'] = 'Scene'; @@ -292,6 +305,7 @@ public function syncSceneTarget(array $value): void $target['value'] = $value; $this->inspectTarget($target); + $this->restoreSelectedControlSnapshot($selectedControlSnapshot); } public function syncAssetTarget(array $value): void @@ -303,12 +317,14 @@ public function syncAssetTarget(array $value): void return; } + $selectedControlSnapshot = $this->captureSelectedControlSnapshot($this->getSelectedControl()); $target = $this->inspectionTarget; $target['name'] = $value['name'] ?? ($target['name'] ?? 'Unnamed Asset'); $target['type'] = ($value['isDirectory'] ?? false) ? 'Folder' : 'File'; $target['value'] = $value; $this->inspectTarget($target); + $this->restoreSelectedControlSnapshot($selectedControlSnapshot); } public function cycleFocusForward(): bool @@ -530,7 +546,7 @@ private function buildSceneControls(array $target, array $scene): void ); $this->addBoundControl( new PathInputControl( - 'Environment Tile Map', + 'Map', $scene['environmentTileMapPath'] ?? 'Maps/example', $this->resolveAssetsWorkingDirectory(), ['tmap'], @@ -538,6 +554,16 @@ private function buildSceneControls(array $target, array $scene): void ), ['environmentTileMapPath'], ); + $this->addBoundControl( + new PathInputControl( + 'Collider', + $scene['environmentCollisionMapPath'] ?? '', + $this->resolveAssetsWorkingDirectory(), + ['tmap'], + 0 + ), + ['environmentCollisionMapPath'] + ); } private function buildGenericControls(array $target): void @@ -897,9 +923,12 @@ private function buildSplitHelpBorder(string $leftLabel, string $rightLabel): st { $availableLabelWidth = max(0, $this->width - 3); $visibleRightLabel = $this->clipContentToWidth($rightLabel, $availableLabelWidth); - $remainingWidth = max(0, $availableLabelWidth - mb_strlen($visibleRightLabel)); + $remainingWidth = max(0, $availableLabelWidth - $this->getDisplayWidth($visibleRightLabel)); $visibleLeftLabel = $this->clipContentToWidth($leftLabel, $remainingWidth); - $fillerWidth = max(0, $availableLabelWidth - mb_strlen($visibleLeftLabel) - mb_strlen($visibleRightLabel)); + $fillerWidth = max( + 0, + $availableLabelWidth - $this->getDisplayWidth($visibleLeftLabel) - $this->getDisplayWidth($visibleRightLabel), + ); return $this->borderPack->bottomLeft . $this->borderPack->horizontal @@ -1960,6 +1989,119 @@ private function getSelectedControlMetadata(?InputControl $control): array return $this->controlMetadata[spl_object_id($control)] ?? []; } + private function captureSelectedControlSnapshot(?InputControl $control): array + { + if (!$control instanceof InputControl) { + return []; + } + + $snapshot = [ + 'class' => $control::class, + 'label' => $control->getLabel(), + ]; + $bindingPath = $this->controlBindings[spl_object_id($control)] ?? null; + $metadata = $this->getSelectedControlMetadata($control); + + if (is_array($bindingPath) && $bindingPath !== []) { + $snapshot['bindingPath'] = $bindingPath; + } + + if ($metadata !== []) { + $snapshot['metadata'] = $metadata; + } + + return $snapshot; + } + + private function shouldPreserveSelectedControl(?array $currentTarget, ?array $nextTarget): bool + { + return $this->resolveInspectionIdentity($currentTarget) !== null + && $this->resolveInspectionIdentity($currentTarget) === $this->resolveInspectionIdentity($nextTarget); + } + + private function resolveInspectionIdentity(?array $target): ?string + { + if (!is_array($target)) { + return null; + } + + $context = $target['context'] ?? null; + + if (!is_string($context) || $context === '') { + return null; + } + + $path = $target['path'] ?? null; + + if (is_string($path) && $path !== '') { + return $context . ':' . $path; + } + + $value = $target['value'] ?? null; + $valuePath = is_array($value) ? ($value['path'] ?? null) : null; + + if (is_string($valuePath) && $valuePath !== '') { + return $context . ':' . $valuePath; + } + + $name = $target['name'] ?? null; + + if (is_string($name) && $name !== '') { + return $context . ':' . $name; + } + + return null; + } + + private function restoreSelectedControlSnapshot(array $snapshot): bool + { + if ($snapshot === [] || $this->focusableControls === []) { + return false; + } + + $bindingPath = $snapshot['bindingPath'] ?? null; + + if (is_array($bindingPath) && $bindingPath !== []) { + foreach ($this->focusableControls as $index => $control) { + $candidateBindingPath = $this->controlBindings[spl_object_id($control)] ?? null; + + if ($candidateBindingPath === $bindingPath) { + $this->selectControlByIndex($index); + return true; + } + } + } + + $metadata = $snapshot['metadata'] ?? null; + + if (is_array($metadata) && $metadata !== []) { + foreach ($this->focusableControls as $index => $control) { + $candidateMetadata = $this->getSelectedControlMetadata($control); + + if ($candidateMetadata === $metadata) { + $this->selectControlByIndex($index); + return true; + } + } + } + + $label = $snapshot['label'] ?? null; + $class = $snapshot['class'] ?? null; + + if (!is_string($label) || $label === '' || !is_string($class) || $class === '') { + return false; + } + + foreach ($this->focusableControls as $index => $control) { + if ($control instanceof $class && $control->getLabel() === $label) { + $this->selectControlByIndex($index); + return true; + } + } + + return false; + } + private function isSelectedComponentHeader(?InputControl $control): bool { if (!$control instanceof SectionControl) { @@ -2126,14 +2268,23 @@ private function focusComponentHeaderByIndex(int $componentIndex): void ($metadata['kind'] ?? null) === 'component_header' && ($metadata['componentIndex'] ?? null) === $componentIndex ) { - $this->selectedControlIndex = $index; - $this->applyControlSelection(); - $this->refreshContent(); + $this->selectControlByIndex($index); return; } } } + private function selectControlByIndex(int $index): void + { + if (!isset($this->focusableControls[$index])) { + return; + } + + $this->selectedControlIndex = $index; + $this->applyControlSelection(); + $this->refreshContent(); + } + private function buildTexturePreviewLines(string $texturePath, array $offset, array $size): array { if ($texturePath === 'None') { diff --git a/src/Editor/Widgets/MainPanel.php b/src/Editor/Widgets/MainPanel.php index 1383f48..947dcdb 100644 --- a/src/Editor/Widgets/MainPanel.php +++ b/src/Editor/Widgets/MainPanel.php @@ -42,25 +42,68 @@ class MainPanel extends Widget '░ Light Shade', '■ Square', '□ Hollow Square', + '▣ Filled Outline Square', + '▢ Dotted Outline Square', '▲ Triangle Up', '▼ Triangle Down', '◄ Triangle Left', '► Triangle Right', + '◢ Triangle Lower Right', + '◣ Triangle Lower Left', + '◤ Triangle Upper Left', + '◥ Triangle Upper Right', '● Circle', '○ Hollow Circle', '★ Star', + '☆ Hollow Star', + '◆ Diamond', + '◇ Hollow Diamond', '♥ Heart', + '♦ Diamond Suit', + '♣ Club', + '♠ Spade', + '• Bullet', + '◦ Hollow Bullet', + '· Dot', '│ Vertical', '─ Horizontal', '┌ Corner TL', '┐ Corner TR', '└ Corner BL', '┘ Corner BR', + '├ Tee Left', + '┤ Tee Right', + '┬ Tee Down', + '┴ Tee Up', '┼ Cross', + '╭ Rounded Corner TL', + '╮ Rounded Corner TR', + '╰ Rounded Corner BL', + '╯ Rounded Corner BR', + '║ Double Vertical', + '═ Double Horizontal', + '╔ Double Corner TL', + '╗ Double Corner TR', + '╚ Double Corner BL', + '╝ Double Corner BR', + '╠ Double Tee Left', + '╣ Double Tee Right', + '╦ Double Tee Down', + '╩ Double Tee Up', + '╬ Double Cross', + '╱ Diagonal Slash', + '╲ Diagonal Backslash', + '╳ Diagonal Cross', '← Arrow Left', '↑ Arrow Up', '→ Arrow Right', '↓ Arrow Down', + '↔ Arrow Left Right', + '↕ Arrow Up Down', + '⇐ Double Arrow Left', + '⇑ Double Arrow Up', + '⇒ Double Arrow Right', + '⇓ Double Arrow Down', ]; protected int $activeTabIndex = 0; @@ -101,6 +144,7 @@ class MainPanel extends Widget protected OptionListModal $characterPickerModal; protected ?string $spriteModalState = null; protected ?array $pendingAssetSyncRequest = null; + protected ?string $lastPrintedSpriteCharacter = null; public function __construct( array $position = ['x' => 37, 'y' => 1], @@ -309,6 +353,7 @@ public function loadSpriteAsset(?array $asset): void $this->spriteOriginalGrid = []; $this->spriteUndoStack = []; $this->spriteRedoStack = []; + $this->lastPrintedSpriteCharacter = null; $this->refreshContent(); return; } @@ -334,6 +379,7 @@ public function loadSpriteAsset(?array $asset): void $this->spriteOriginalGrid = $this->copySpriteGrid($this->spriteGrid); $this->spriteUndoStack = []; $this->spriteRedoStack = []; + $this->lastPrintedSpriteCharacter = null; $this->refreshContent(); } @@ -827,6 +873,15 @@ private function handleSpriteEditorInput(): bool return true; } + if (Input::isKeyDown(KeyCode::ENTER)) { + if ($this->lastPrintedSpriteCharacter === null) { + return false; + } + + $this->writeSpriteCharacter($this->lastPrintedSpriteCharacter); + return true; + } + if (Input::isKeyDown(KeyCode::BACKSPACE)) { $this->writeSpriteCharacter(' '); return true; @@ -876,7 +931,11 @@ private function showCharacterPickerModal(): void return; } - $this->characterPickerModal->show(self::SPECIAL_CHARACTER_OPTIONS, 0, 'Insert Character'); + $this->characterPickerModal->show( + self::SPECIAL_CHARACTER_OPTIONS, + $this->resolveCharacterPickerSelectedIndex(), + 'Insert Character' + ); $this->createSpriteAssetModal->hide(); $this->deleteSpriteAssetModal->hide(); $this->spriteModalState = self::SPRITE_MODAL_CHARACTER; @@ -960,6 +1019,21 @@ private function resolveCharacterPickerSelection(?string $selection): ?string return mb_substr($selection, 0, 1) ?: null; } + private function resolveCharacterPickerSelectedIndex(): int + { + if ($this->lastPrintedSpriteCharacter === null) { + return 0; + } + + foreach (self::SPECIAL_CHARACTER_OPTIONS as $index => $option) { + if (mb_substr($option, 0, 1) === $this->lastPrintedSpriteCharacter) { + return $index; + } + } + + return 0; + } + private function moveSceneSelection(int $offset): void { if ($this->visibleSceneObjects === []) { @@ -1155,39 +1229,24 @@ private function buildSceneCanvasContent(): array continue; } - $characters = preg_split('//u', $renderLine, -1, PREG_SPLIT_NO_EMPTY); - - if (!is_array($characters) || $characters === []) { - continue; - } - - $startCharacterIndex = max(0, -$column); - $targetColumn = max(0, $column); - - for ( - $characterIndex = $startCharacterIndex; - $characterIndex < count($characters) && $targetColumn < $canvasWidth; - $characterIndex++, $targetColumn++ - ) { - $canvas[$targetRow][$targetColumn] = $characters[$characterIndex]; - } + $placement = $this->writeRenderLineToCanvas( + $canvas[$targetRow], + $renderLine, + $column, + $canvasWidth, + ); if (($sceneObject['path'] ?? null) !== $this->selectedScenePath) { continue; } - $visibleLength = min( - count($characters) - $startCharacterIndex, - $canvasWidth - max(0, $column), - ); - - if ($visibleLength <= 0) { + if (($placement['length'] ?? 0) <= 0) { continue; } $this->sceneLineHighlights[2 + $targetRow] = [ - 'start' => max(0, $column), - 'length' => $visibleLength, + 'start' => $placement['start'] ?? max(0, $column), + 'length' => $placement['length'], ]; } } @@ -1391,6 +1450,12 @@ private function writeSpriteCharacter(string $character): void $nextCharacter = mb_substr($character, 0, 1); + if ($nextCharacter === '') { + return; + } + + $this->lastPrintedSpriteCharacter = $nextCharacter; + if (($this->spriteGrid[$this->spriteCursorY][$this->spriteCursorX] ?? ' ') === $nextCharacter) { return; } @@ -1541,6 +1606,7 @@ private function createSpriteAsset(string $selection): void $this->spriteOriginalGrid = $this->copySpriteGrid($this->spriteGrid); $this->spriteUndoStack = []; $this->spriteRedoStack = []; + $this->lastPrintedSpriteCharacter = null; $this->activeSpriteAsset = [ 'name' => basename($absolutePath), 'path' => $absolutePath, @@ -1588,6 +1654,7 @@ private function deleteActiveSpriteAsset(): void $this->spriteOriginalGrid = []; $this->spriteUndoStack = []; $this->spriteRedoStack = []; + $this->lastPrintedSpriteCharacter = null; $this->pendingAssetSyncRequest = [ 'path' => $deletedPath, 'clearInspection' => true, @@ -1922,23 +1989,78 @@ private function renderEnvironmentTileMap(array &$canvas, int $canvasWidth, int continue; } - $characters = preg_split('//u', $tileMapLine, -1, PREG_SPLIT_NO_EMPTY); + $this->writeRenderLineToCanvas( + $canvas[$targetRow], + $tileMapLine, + -$this->sceneViewportOffsetX, + $canvasWidth, + ); + } + } + + private function writeRenderLineToCanvas( + array &$targetRow, + string $renderLine, + int $startColumn, + int $canvasWidth, + ): array { + $characters = preg_split('//u', $renderLine, -1, PREG_SPLIT_NO_EMPTY); + + if (!is_array($characters) || $characters === []) { + return ['start' => max(0, $startColumn), 'length' => 0]; + } + + $column = $startColumn; + $firstVisibleColumn = null; + $lastVisibleColumn = null; - if (!is_array($characters) || $characters === []) { + foreach ($characters as $character) { + $characterWidth = max(1, $this->getDisplayWidth($character)); + + if (($column + $characterWidth) <= 0) { + $column += $characterWidth; continue; } - $startCharacterIndex = max(0, $this->sceneViewportOffsetX); - $targetColumn = 0; + if ($column >= $canvasWidth) { + break; + } - for ( - $characterIndex = $startCharacterIndex; - $characterIndex < count($characters) && $targetColumn < $canvasWidth; - $characterIndex++, $targetColumn++ - ) { - $canvas[$targetRow][$targetColumn] = $characters[$characterIndex]; + $visibleColumn = max(0, $column); + $remainingWidth = $canvasWidth - $visibleColumn; + + if ($remainingWidth <= 0) { + break; + } + + if ($column >= 0 && $characterWidth > $remainingWidth) { + break; + } + + $occupyWidth = min($characterWidth, $remainingWidth); + $targetRow[$visibleColumn] = $character; + + for ($paddingColumn = 1; $paddingColumn < $occupyWidth; $paddingColumn++) { + if (!array_key_exists($visibleColumn + $paddingColumn, $targetRow)) { + break; + } + + $targetRow[$visibleColumn + $paddingColumn] = ''; } + + $firstVisibleColumn ??= $visibleColumn; + $lastVisibleColumn = $visibleColumn + $occupyWidth - 1; + $column += $characterWidth; } + + if ($firstVisibleColumn === null || $lastVisibleColumn === null) { + return ['start' => max(0, $startColumn), 'length' => 0]; + } + + return [ + 'start' => $firstVisibleColumn, + 'length' => max(0, $lastVisibleColumn - $firstVisibleColumn + 1), + ]; } private function buildEnvironmentTileMapLines(): array @@ -1968,9 +2090,12 @@ private function buildSplitHelpBorder(string $leftLabel, string $rightLabel): st { $availableLabelWidth = max(0, $this->width - 3); $visibleRightLabel = $this->clipContentToWidth($rightLabel, $availableLabelWidth); - $remainingWidth = max(0, $availableLabelWidth - mb_strlen($visibleRightLabel)); + $remainingWidth = max(0, $availableLabelWidth - $this->getDisplayWidth($visibleRightLabel)); $visibleLeftLabel = $this->clipContentToWidth($leftLabel, $remainingWidth); - $fillerWidth = max(0, $availableLabelWidth - mb_strlen($visibleLeftLabel) - mb_strlen($visibleRightLabel)); + $fillerWidth = max( + 0, + $availableLabelWidth - $this->getDisplayWidth($visibleLeftLabel) - $this->getDisplayWidth($visibleRightLabel), + ); return $this->borderPack->bottomLeft . $this->borderPack->horizontal diff --git a/src/Editor/Widgets/OptionListModal.php b/src/Editor/Widgets/OptionListModal.php index a3e1bf5..e94a917 100644 --- a/src/Editor/Widgets/OptionListModal.php +++ b/src/Editor/Widgets/OptionListModal.php @@ -12,6 +12,7 @@ class OptionListModal extends Widget protected bool $isDirty = false; protected array $options = []; protected int $selectedIndex = 0; + protected int $scrollOffset = 0; public function __construct( string $title = 'Choose Action', @@ -38,7 +39,9 @@ public function show(array $options, int $selectedIndex = 0, ?string $title = nu $this->selectedIndex = $optionCount > 0 ? max(0, min($selectedIndex, $optionCount - 1)) : 0; + $this->scrollOffset = 0; $this->isVisible = true; + $this->syncScrollOffset(); $this->refreshContent(); $this->markDirty(); } @@ -77,6 +80,7 @@ public function moveSelection(int $offset): void } $this->selectedIndex = ($this->selectedIndex + $offset + $optionCount) % $optionCount; + $this->syncScrollOffset(); $this->refreshContent(); $this->markDirty(); } @@ -115,6 +119,8 @@ public function syncLayout(int $terminalWidth, int $terminalHeight): void $this->setDimensions($modalWidth, $modalHeight); $this->setPosition($modalX, $modalY); + $this->syncScrollOffset(); + $this->refreshContent(); if ($layoutChanged) { $this->markDirty(); @@ -127,9 +133,10 @@ public function update(): void protected function decorateContentLine(string $line, ?Color $contentColor, int $lineIndex): string { - $selectedLineIndex = $this->padding->topPadding + $this->selectedIndex; + $selectedVisibleIndex = $this->selectedIndex - $this->scrollOffset; + $selectedLineIndex = $this->padding->topPadding + $selectedVisibleIndex; - if ($lineIndex !== $selectedLineIndex) { + if ($selectedVisibleIndex < 0 || $lineIndex !== $selectedLineIndex) { return parent::decorateContentLine($line, $contentColor, $lineIndex); } @@ -151,10 +158,18 @@ protected function decorateContentLine(string $line, ?Color $contentColor, int $ private function refreshContent(): void { - $this->content = array_map( - fn(string $option, int $index) => (($index === $this->selectedIndex) ? '>' : ' ') . ' ' . $option, + $visibleOptions = array_slice( $this->options, - array_keys($this->options), + $this->scrollOffset, + $this->getVisibleOptionCount(), + ); + + $this->content = array_map( + fn(string $option, int $index) => ( + (($this->scrollOffset + $index) === $this->selectedIndex) ? '>' : ' ' + ) . ' ' . $option, + $visibleOptions, + array_keys($visibleOptions), ); } @@ -162,4 +177,34 @@ private function markDirty(): void { $this->isDirty = true; } + + private function getVisibleOptionCount(): int + { + return max(1, $this->innerHeight - $this->padding->topPadding - $this->padding->bottomPadding); + } + + private function syncScrollOffset(): void + { + $optionCount = count($this->options); + + if ($optionCount <= 0) { + $this->scrollOffset = 0; + return; + } + + $visibleOptionCount = $this->getVisibleOptionCount(); + $maxScrollOffset = max(0, $optionCount - $visibleOptionCount); + $this->scrollOffset = max(0, min($this->scrollOffset, $maxScrollOffset)); + + if ($this->selectedIndex < $this->scrollOffset) { + $this->scrollOffset = $this->selectedIndex; + return; + } + + $visibleEnd = $this->scrollOffset + $visibleOptionCount - 1; + + if ($this->selectedIndex > $visibleEnd) { + $this->scrollOffset = $this->selectedIndex - $visibleOptionCount + 1; + } + } } diff --git a/src/Editor/Widgets/Widget.php b/src/Editor/Widgets/Widget.php index b05dc8b..7966696 100644 --- a/src/Editor/Widgets/Widget.php +++ b/src/Editor/Widgets/Widget.php @@ -330,7 +330,7 @@ protected function buildContentLine(string $content, int $innerWidth): string $rightPadding = max(0, $this->padding->rightPadding); $availableTextWidth = max(0, $innerWidth - $leftPadding - $rightPadding); $visibleContent = $this->clipContentToWidth($content, $availableTextWidth); - $visibleLength = mb_strlen($visibleContent); + $visibleLength = $this->getDisplayWidth($visibleContent); $contentArea = match ($this->alignment->horizontalAlignment) { HorizontalAlignment::CENTER => $this->buildCenteredContentArea( @@ -404,7 +404,7 @@ protected function buildRightAlignedContentArea( protected function padContentArea(string $contentArea, int $innerWidth, int $direction): string { $visibleArea = $this->clipContentToWidth($contentArea, $innerWidth); - $visibleLength = mb_strlen($visibleArea); + $visibleLength = $this->getDisplayWidth($visibleArea); if ($visibleLength >= $innerWidth) { return $visibleArea; @@ -427,18 +427,18 @@ protected function clipContentToWidth(string $content, int $maxWidth): string return ''; } - if (mb_strlen($content) <= $maxWidth) { + if ($this->getDisplayWidth($content) <= $maxWidth) { return $content; } - return mb_substr($content, 0, $maxWidth); + return mb_strimwidth($content, 0, $maxWidth, '', 'UTF-8'); } protected function buildBorderLine(string $label, bool $isTopBorder): string { $availableLabelWidth = max(0, $this->width - 3); - $visibleLabel = mb_substr($label, 0, $availableLabelWidth); - $labelWidth = mb_strlen($visibleLabel); + $visibleLabel = $this->clipContentToWidth($label, $availableLabelWidth); + $labelWidth = $this->getDisplayWidth($visibleLabel); $remainderWidth = max(0, $this->width - $labelWidth - 3); $leftCorner = $isTopBorder ? $this->borderPack->topLeft : $this->borderPack->bottomLeft; @@ -451,6 +451,17 @@ protected function buildBorderLine(string $label, bool $isTopBorder): string . $rightCorner; } + protected function getDisplayWidth(string $content): int + { + if ($content === '') { + return 0; + } + + return function_exists('mb_strwidth') + ? mb_strwidth($content, 'UTF-8') + : mb_strlen($content); + } + protected function wrapWithColor(string $content, ?Color $color): string { return $this->wrapWithSequence($content, $color?->value); diff --git a/src/Strategies/AssetFileGeneration/PrefabFileGenerationStrategy.php b/src/Strategies/AssetFileGeneration/PrefabFileGenerationStrategy.php new file mode 100644 index 0000000..f8e8caa --- /dev/null +++ b/src/Strategies/AssetFileGeneration/PrefabFileGenerationStrategy.php @@ -0,0 +1,111 @@ + << Label::class, + 'name' => '{$displayName}', + 'tag' => 'UI', + 'position' => [ + 'x' => 0, + 'y' => 0, + ], + 'size' => [ + 'x' => 10, + 'y' => 1, + ], + 'text' => '{$displayName}', +]; + +PHP, + 'text' => << Text::class, + 'name' => '{$displayName}', + 'tag' => 'UI', + 'position' => [ + 'x' => 0, + 'y' => 0, + ], + 'size' => [ + 'x' => 10, + 'y' => 1, + ], + 'text' => '{$displayName}', +]; + +PHP, + default => << GameObject::class, + 'name' => '{$displayName}', + 'tag' => 'None', + 'position' => [ + 'x' => 0, + 'y' => 0, + ], + 'rotation' => [ + 'x' => 0, + 'y' => 0, + ], + 'scale' => [ + 'x' => 1, + 'y' => 1, + ], + 'components' => [], +]; + +PHP, + }; + } + + public function __construct( + InputInterface $input, + OutputInterface $output, + string $filename, + string $directory, + private readonly string $kind = 'gameobject', + ) + { + parent::__construct($input, $output, $filename, $directory, '.prefab.php'); + } + + protected function configure(): void + { + $filename = Path::join(dirname($this->classPath), to_kebab_case($this->className)); + $this->relativeFilename = Path::join($this->assetsDirectoryName, $filename . ($this->fileExtension ?? '.php')); + $this->content = self::buildPrefabContents($this->buildDisplayName(), $this->kind); + } + + private function buildDisplayName(): string + { + $kebabName = to_kebab_case($this->className); + $spacedName = str_replace('-', ' ', $kebabName); + + return ucwords($spacedName); + } +} diff --git a/src/Strategies/AssetFileGeneration/SceneFileGenerationStrategy.php b/src/Strategies/AssetFileGeneration/SceneFileGenerationStrategy.php index 33a2000..b1b6142 100644 --- a/src/Strategies/AssetFileGeneration/SceneFileGenerationStrategy.php +++ b/src/Strategies/AssetFileGeneration/SceneFileGenerationStrategy.php @@ -8,6 +8,48 @@ class SceneFileGenerationStrategy extends AbstractAssetFileGenerationStrategy { + public static function buildMetaSceneContents( + string $environmentTileMapPath = 'Maps/example', + string $managerName = 'Level Manager', + string $objectName = 'GameObject', + ): string { + return << DEFAULT_SCREEN_WIDTH, + "height" => DEFAULT_SCREEN_HEIGHT, + "environmentTileMapPath" => "{$environmentTileMapPath}", + "hierarchy" => [ + [ + "type" => GameObject::class, + "name" => "{$managerName}", + "tag" => "None", + "position" => ["x" => 0, "y" => 0], + "rotation" => ["x" => 0, "y" => 0], + "scale" => ["x" => 1, "y" => 1], + "components" => [ + [ "class" => SimpleQuitListener::class ], + ] + ], + [ + "type" => GameObject::class, + "name" => "{$objectName}", + "tag" => "None", + "position" => ["x" => 1, "y" => 1], + "rotation" => ["x" => 0, "y" => 0], + "scale" => ["x" => 1, "y" => 1], + "components" => [] + ] + ], +]; + +PHP; + } + public function __construct( InputInterface $input, OutputInterface $output, @@ -32,39 +74,7 @@ protected function configure(): void if ($this->asMetaFile) { $filename = Path::join(dirname($this->classPath), to_kebab_case($this->className)); $this->relativeFilename = Path::join($this->assetsDirectoryName, $filename . ($this->fileExtension ?? '.php')); - $this->content = << DEFAULT_SCREEN_WIDTH, - "height" => DEFAULT_SCREEN_HEIGHT, - "environmentTileMapPath" => "Maps/example", - "hierarchy" => [ - [ - "type" => GameObject::class, - "name" => "Level Manager", - "position" => [0, 0], - "rotation" => [0, 0], - "scale" => [1, 1], - "components" => [ - [ "class" => SimpleQuitListener::class ], - ] - ], - [ - "type" => GameObject::class, - "name" => "GameObject", - "position" => [1, 1], - "rotation" => [0, 0], - "scale" => [1, 1], - "components" => [] - ] - ], -]; - -PHP; + $this->content = self::buildMetaSceneContents(); } else { $this->content = <<resolveLoadedScenes($assetsDirectory); + $this->ensureFile( Path::join($this->projectRoot, 'sendama.json'), self::buildSendamaConfiguration( @@ -112,6 +114,7 @@ public function normalize(): array description: $projectMetadata['description'], version: $projectMetadata['version'], mainFile: $projectMetadata['main'], + loadedScenes: $loadedScenes, ), 'Created sendama.json.', $changes, @@ -153,12 +156,23 @@ public static function buildSendamaConfiguration( string $description = 'A 2D ASCII terminal game.', string $version = '0.0.1', string $mainFile = 'main.php', + array $loadedScenes = [], + float $consoleRefreshInterval = 5.0, ): string { return json_encode([ 'name' => $projectName, 'description' => $description, 'version' => $version, 'main' => $mainFile, + 'editor' => [ + 'scenes' => [ + 'active' => 0, + 'loaded' => array_values($loadedScenes), + ], + 'console' => [ + 'refreshInterval' => $consoleRefreshInterval, + ], + ], ], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . PHP_EOL; } @@ -250,6 +264,27 @@ private function resolveProjectMetadata(): array return $defaultMetadata; } + /** + * @param string $assetsDirectory + * @return string[] + */ + private function resolveLoadedScenes(string $assetsDirectory): array + { + $sceneDirectory = Path::join($assetsDirectory, 'Scenes'); + + if (!is_dir($sceneDirectory)) { + return []; + } + + $sceneFiles = glob(Path::join($sceneDirectory, '*.scene.php')) ?: []; + sort($sceneFiles); + + return array_map( + static fn(string $sceneFile) => 'Scenes/' . basename($sceneFile), + $sceneFiles, + ); + } + private function guessProjectName(): string { $directoryName = basename($this->projectRoot); diff --git a/templates/game.php b/templates/game.php index fb8bac3..a2910fb 100644 --- a/templates/game.php +++ b/templates/game.php @@ -2,26 +2,22 @@ require __DIR__ . '/vendor/autoload.php'; -use Sendama\Engine\Game; -use Sendama\Engine\Core\Scenes\TitleScene; -use Sendama\Engine\Core\Scenes\ExampleScene; - -function bootstrap(): void -{ - $gameName = '%GAME_NAME%'; // This will be overwritten by the .env file if GAME_NAME is set - $game = new Game($gameName); +use Sendama\Engine\Game; +use Sendama\Engine\Core\Scenes\TitleScene; + +function bootstrap(): void +{ + $gameName = '%GAME_NAME%'; // This will be overwritten by the .env file if GAME_NAME is set + $game = new Game($gameName); $titleScene = new TitleScene('Title Screen'); $titleScene->setTitle($gameName); + + $game + ->addScenes($titleScene) + ->loadScenes('Scenes/Level') + ->loadSettings() + ->run(); +} - $game->addScenes( - $titleScene, - new ExampleScene('Level 01') - ); - - $game - ->loadSettings() - ->run(); -} - -bootstrap(); \ No newline at end of file +bootstrap(); diff --git a/templates/sendama.json b/templates/sendama.json index deba597..afdc0be 100644 --- a/templates/sendama.json +++ b/templates/sendama.json @@ -1,14 +1,17 @@ { - "project": { - "name": "game", - "description": "A simple ASCII terminal game", - "version": "0.0.1", - "main": "game.php", - "player": { - "screen": { - "width": 110, - "height": 30 - } + "name": "game", + "description": "A 2D ASCII terminal game.", + "version": "0.0.1", + "main": "game.php", + "editor": { + "scenes": { + "active": 0, + "loaded": [ + "Scenes/Level.scene.php" + ] + }, + "console": { + "refreshInterval": 5 } } -} \ No newline at end of file +} diff --git a/tests/Unit/AssetsPanelTest.php b/tests/Unit/AssetsPanelTest.php index e6d44a6..d01b587 100644 --- a/tests/Unit/AssetsPanelTest.php +++ b/tests/Unit/AssetsPanelTest.php @@ -63,10 +63,42 @@ 'isDirectory' => false, 'children' => [], ], + 'openInMainPanel' => true, ]); expect($panel->consumeInspectionRequest())->toBeNull(); }); +test('assets panel queues inspection when selection changes', function () { + $workspace = sys_get_temp_dir() . '/sendama-assets-panel-' . uniqid(); + mkdir($workspace . '/Assets/Maps', 0777, true); + mkdir($workspace . '/Assets/Textures', 0777, true); + file_put_contents($workspace . '/Assets/Maps/level.tmap', "xx\n"); + file_put_contents($workspace . '/Assets/Textures/player.texture', ">\n"); + + $panel = new AssetsPanel( + width: 40, + height: 12, + assetsDirectoryPath: $workspace . '/Assets', + ); + + $panel->expandSelection(); + $panel->moveSelection(1); + + expect($panel->consumeInspectionRequest())->toBe([ + 'context' => 'asset', + 'name' => 'level.tmap', + 'type' => 'File', + 'value' => [ + 'name' => 'level.tmap', + 'path' => $workspace . '/Assets/Maps/level.tmap', + 'relativePath' => 'Maps/level.tmap', + 'isDirectory' => false, + 'children' => [], + ], + 'openInMainPanel' => false, + ]); +}); + test('assets panel reports folders as folder type in inspector payload', function () { $workspace = sys_get_temp_dir() . '/sendama-assets-panel-' . uniqid(); mkdir($workspace . '/Assets/Scripts', 0777, true); @@ -90,6 +122,7 @@ 'isDirectory' => true, 'children' => [], ], + 'openInMainPanel' => true, ]); }); @@ -195,6 +228,43 @@ ]); }); +test('assets panel can queue prefab creation requests', function () { + $workspace = sys_get_temp_dir() . '/sendama-assets-panel-' . uniqid(); + mkdir($workspace . '/Assets', 0777, true); + + $panel = new AssetsPanel( + width: 40, + height: 12, + assetsDirectoryPath: $workspace . '/Assets', + workingDirectory: $workspace, + ); + + $panel->beginCreateWorkflow(); + + $handleModalInput = new ReflectionMethod(AssetsPanel::class, 'handleModalInput'); + $handleModalInput->setAccessible(true); + + $keyPress = new ReflectionProperty(\Sendama\Console\Editor\IO\InputManager::class, 'keyPress'); + $previousKeyPress = new ReflectionProperty(\Sendama\Console\Editor\IO\InputManager::class, 'previousKeyPress'); + $keyPress->setAccessible(true); + $previousKeyPress->setAccessible(true); + + $createAssetModal = new ReflectionProperty(AssetsPanel::class, 'createAssetModal'); + $createAssetModal->setAccessible(true); + $modal = $createAssetModal->getValue($panel); + $moveSelection = new ReflectionMethod($modal, 'moveSelection'); + $moveSelection->invoke($modal, 2); + + $previousKeyPress->setValue(''); + $keyPress->setValue("\n"); + $handleModalInput->invoke($panel); + + expect($panel->consumeCreationRequest())->toBe([ + 'kind' => 'prefab', + 'workingDirectory' => $workspace, + ]); +}); + test('assets panel opens the create modal with shift+a while focused', function () { $workspace = sys_get_temp_dir() . '/sendama-assets-panel-' . uniqid(); mkdir($workspace . '/Assets', 0777, true); diff --git a/tests/Unit/CliAssetsDirectoryTest.php b/tests/Unit/CliAssetsDirectoryTest.php index 7c730b6..382d5af 100644 --- a/tests/Unit/CliAssetsDirectoryTest.php +++ b/tests/Unit/CliAssetsDirectoryTest.php @@ -1,6 +1,7 @@ toBe('Assets/'); }); +test('new game project configuration includes editor defaults for the Level scene and console refresh', function () { + $command = new NewGame(); + $method = new ReflectionMethod(NewGame::class, 'getProjectConfiguration'); + $method->setAccessible(true); + + $configuration = json_decode($method->invoke($command, 'Test Game'), true, flags: JSON_THROW_ON_ERROR); + + expect($configuration['editor']['scenes']['active'])->toBe(0); + expect($configuration['editor']['scenes']['loaded'])->toBe(['Scenes/Level.scene.php']); + expect($configuration['editor']['console']['refreshInterval'])->toBe(5); +}); + test('new game creates an Assets directory', function () { $workspace = sys_get_temp_dir() . '/sendama-new-game-assets-' . uniqid(); mkdir($workspace, 0777, true); @@ -128,6 +141,46 @@ function runGeneratorCommandInWorkspace(object $command, string $workspace, arra expect($configuration['project']['main'])->toBe('test-game.php'); }); +test('new game creates a default Level scene metadata file', function () { + $workspace = sys_get_temp_dir() . '/sendama-new-game-scene-' . uniqid(); + mkdir($workspace, 0777, true); + mkdir($workspace . '/Assets/Scenes', 0777, true); + + $command = new NewGame(); + $property = new ReflectionProperty(NewGame::class, 'targetDirectory'); + $property->setAccessible(true); + $property->setValue($command, $workspace); + + $method = new ReflectionMethod(NewGame::class, 'createDefaultSceneFile'); + $method->setAccessible(true); + $method->invoke($command, $workspace . '/Assets'); + + $sceneContents = file_get_contents($workspace . '/Assets/Scenes/Level.scene.php'); + + expect(is_file($workspace . '/Assets/Scenes/Level.scene.php'))->toBeTrue(); + expect($sceneContents)->toContain('"environmentTileMapPath" => "Maps/example"'); + expect($sceneContents)->toContain('"position" => ["x" => 0, "y" => 0]'); +}); + +test('new game main template loads the default Level scene metadata file', function () { + $workspace = sys_get_temp_dir() . '/sendama-new-game-main-' . uniqid(); + mkdir($workspace, 0777, true); + + $command = new NewGame(); + $property = new ReflectionProperty(NewGame::class, 'targetDirectory'); + $property->setAccessible(true); + $property->setValue($command, $workspace); + + $method = new ReflectionMethod(NewGame::class, 'createMainFile'); + $method->setAccessible(true); + $method->invoke($command, 'Test Game'); + + $mainContents = file_get_contents($workspace . '/' . basename($workspace) . '.php'); + + expect($mainContents)->toContain("loadScenes('Scenes/Level')"); + expect($mainContents)->not->toContain('ExampleScene'); +}); + test('generate script creates files under Assets', function () { $workspace = createCliAssetsWorkspace(); $exitCode = runGeneratorCommandInWorkspace( @@ -164,6 +217,41 @@ function runGeneratorCommandInWorkspace(object $command, string $workspace, arra expect(is_file($workspace . '/Assets/Scenes/level01.scene.php'))->toBeTrue(); }); +test('generate prefab creates metadata prefab files under Assets', function () { + $workspace = createCliAssetsWorkspace(); + $exitCode = runGeneratorCommandInWorkspace( + new GeneratePrefab(), + $workspace, + ['name' => 'enemy'], + ); + + $prefabPath = $workspace . '/Assets/Prefabs/enemy.prefab.php'; + $prefabContents = file_get_contents($prefabPath); + + expect($exitCode)->toBe(0); + expect(is_file($prefabPath))->toBeTrue(); + expect($prefabContents)->toContain("'type' => GameObject::class"); + expect($prefabContents)->toContain("'name' => 'Enemy'"); +}); + +test('generate prefab can create ui element prefab metadata', function () { + $workspace = createCliAssetsWorkspace(); + $exitCode = runGeneratorCommandInWorkspace( + new GeneratePrefab(), + $workspace, + ['name' => 'score-label', '--kind' => 'label'], + ); + + $prefabPath = $workspace . '/Assets/Prefabs/score-label.prefab.php'; + $prefabContents = file_get_contents($prefabPath); + + expect($exitCode)->toBe(0); + expect(is_file($prefabPath))->toBeTrue(); + expect($prefabContents)->toContain("'type' => Label::class"); + expect($prefabContents)->toContain("'tag' => 'UI'"); + expect($prefabContents)->toContain("'text' => 'Score Label'"); +}); + test('working directory assets path prefers Assets', function () { $workspace = createCliAssetsWorkspace(); $originalWorkingDirectory = getcwd(); diff --git a/tests/Unit/EditorAssetRenameTest.php b/tests/Unit/EditorAssetRenameTest.php index 2dc75d1..6f6345a 100644 --- a/tests/Unit/EditorAssetRenameTest.php +++ b/tests/Unit/EditorAssetRenameTest.php @@ -55,3 +55,56 @@ class PlayerController extends Behaviour expect(file_get_contents($workspace . '/Assets/Scripts/EnemyController.php')) ->toContain('class EnemyController extends Behaviour'); }); + +test('renaming an event asset updates its class declaration', function () { + $workspace = sys_get_temp_dir() . '/sendama-editor-event-rename-' . uniqid(); + mkdir($workspace . '/Assets/Events', 0777, true); + $eventPath = $workspace . '/Assets/Events/PlayerDiedEvent.php'; + + file_put_contents($eventPath, <<<'PHP' +newInstanceWithoutConstructor(); + + $workingDirectory = $editorReflection->getProperty('workingDirectory'); + $assetsDirectoryPath = $editorReflection->getProperty('assetsDirectoryPath'); + $consolePanel = $editorReflection->getProperty('consolePanel'); + $workingDirectory->setAccessible(true); + $assetsDirectoryPath->setAccessible(true); + $consolePanel->setAccessible(true); + $workingDirectory->setValue($editor, $workspace); + $assetsDirectoryPath->setValue($editor, $workspace . '/Assets'); + $consolePanel->setValue($editor, new ConsolePanel()); + + $renameMethod = $editorReflection->getMethod('renameAssetAndCascadeReferences'); + $renameMethod->setAccessible(true); + + $renamedAsset = $renameMethod->invoke( + $editor, + $eventPath, + 'Events/PlayerDiedEvent.php', + 'EnemyDiedEvent.php', + ); + + expect($renamedAsset)->toBe([ + 'name' => 'EnemyDiedEvent.php', + 'path' => $workspace . '/Assets/Events/EnemyDiedEvent.php', + 'relativePath' => 'Events/EnemyDiedEvent.php', + 'isDirectory' => false, + 'children' => [], + ]); + expect(file_exists($eventPath))->toBeFalse(); + expect(is_file($workspace . '/Assets/Events/EnemyDiedEvent.php'))->toBeTrue(); + expect(file_get_contents($workspace . '/Assets/Events/EnemyDiedEvent.php')) + ->toContain('class EnemyDiedEvent extends Event'); +}); diff --git a/tests/Unit/EditorAssetSelectionTest.php b/tests/Unit/EditorAssetSelectionTest.php new file mode 100644 index 0000000..ef0b75e --- /dev/null +++ b/tests/Unit/EditorAssetSelectionTest.php @@ -0,0 +1,82 @@ +expandSelection(); + $assetsPanel->moveSelection(1); + + $synchronizeInspectorPanel = $reflection->getMethod('synchronizeInspectorPanel'); + $synchronizeInspectorPanel->setAccessible(true); + $synchronizeInspectorPanel->invoke($editor); + + $activeSpriteAsset = new ReflectionProperty(MainPanel::class, 'activeSpriteAsset'); + $inspectionTarget = new ReflectionProperty(InspectorPanel::class, 'inspectionTarget'); + $activeSpriteAsset->setAccessible(true); + $inspectionTarget->setAccessible(true); + + expect($mainPanel->getActiveTab())->toBe('Scene') + ->and($activeSpriteAsset->getValue($mainPanel))->toBeNull() + ->and($inspectionTarget->getValue($inspectorPanel)['name'] ?? null)->toBe('player.texture'); +}); + +test('editor opens the selected texture asset in the main panel only on enter activation', function () { + $workspace = createEditorAssetSelectionWorkspace(); + [$editor, $reflection, $assetsPanel, $mainPanel] = createEditorForAssetSelection($workspace); + + $assetsPanel->expandSelection(); + $assetsPanel->moveSelection(1); + $assetsPanel->activateSelection(); + + $synchronizeInspectorPanel = $reflection->getMethod('synchronizeInspectorPanel'); + $synchronizeInspectorPanel->setAccessible(true); + $synchronizeInspectorPanel->invoke($editor); + + $activeSpriteAsset = new ReflectionProperty(MainPanel::class, 'activeSpriteAsset'); + $focusedPanel = $reflection->getProperty('focusedPanel'); + $activeSpriteAsset->setAccessible(true); + $focusedPanel->setAccessible(true); + + expect($mainPanel->getActiveTab())->toBe('Sprite') + ->and($activeSpriteAsset->getValue($mainPanel)['name'] ?? null)->toBe('player.texture') + ->and($focusedPanel->getValue($editor))->toBe($mainPanel); +}); + +function createEditorAssetSelectionWorkspace(): string +{ + $workspace = sys_get_temp_dir() . '/sendama-editor-asset-selection-' . uniqid(); + mkdir($workspace . '/Assets/Textures', 0777, true); + file_put_contents($workspace . '/Assets/Textures/player.texture', ">\n"); + + return $workspace; +} + +function createEditorForAssetSelection(string $workspace): array +{ + $editorReflection = new ReflectionClass(Editor::class); + $editor = $editorReflection->newInstanceWithoutConstructor(); + $hierarchyPanel = new HierarchyPanel(); + $assetsPanel = new AssetsPanel( + width: 40, + height: 12, + assetsDirectoryPath: $workspace . '/Assets', + workingDirectory: $workspace, + ); + $mainPanel = new MainPanel(width: 60, height: 12, workingDirectory: $workspace); + $inspectorPanel = new InspectorPanel(width: 40, height: 12, workingDirectory: $workspace); + + $editorReflection->getProperty('hierarchyPanel')->setValue($editor, $hierarchyPanel); + $editorReflection->getProperty('assetsPanel')->setValue($editor, $assetsPanel); + $editorReflection->getProperty('mainPanel')->setValue($editor, $mainPanel); + $editorReflection->getProperty('inspectorPanel')->setValue($editor, $inspectorPanel); + $editorReflection->getProperty('focusedPanel')->setValue($editor, $assetsPanel); + + return [$editor, $editorReflection, $assetsPanel, $mainPanel, $inspectorPanel]; +} diff --git a/tests/Unit/InputManagerTest.php b/tests/Unit/InputManagerTest.php index 8ba23c2..1edf5d4 100644 --- a/tests/Unit/InputManagerTest.php +++ b/tests/Unit/InputManagerTest.php @@ -116,7 +116,7 @@ expect($coalesceRepeatableTokens->invoke(null, ['0', '0']))->toBe(['0', '0']); }); -test('input manager treats repeated arrow input as pressed', function () { +test('input manager treats repeated arrow input as both pressed and down', function () { $keyPress = new ReflectionProperty(InputManager::class, 'keyPress'); $previousKeyPress = new ReflectionProperty(InputManager::class, 'previousKeyPress'); $keyPress->setAccessible(true); @@ -125,5 +125,32 @@ $keyPress->setValue("\033[C"); expect(InputManager::isKeyPressed(KeyCode::RIGHT))->toBeTrue(); - expect(InputManager::isKeyDown(KeyCode::RIGHT))->toBeFalse(); + expect(InputManager::isKeyDown(KeyCode::RIGHT))->toBeTrue(); +}); + +test('input manager still treats repeated non-repeatable input as not down', function () { + $keyPress = new ReflectionProperty(InputManager::class, 'keyPress'); + $previousKeyPress = new ReflectionProperty(InputManager::class, 'previousKeyPress'); + $keyPress->setAccessible(true); + $previousKeyPress->setAccessible(true); + $previousKeyPress->setValue("\n"); + $keyPress->setValue("\n"); + + expect(InputManager::isKeyPressed(KeyCode::ENTER))->toBeTrue(); + expect(InputManager::isKeyDown(KeyCode::ENTER))->toBeFalse(); +}); + +test('input manager briefly holds repeatable arrows between raw repeat events', function () { + $resolveCurrentKeyPress = new ReflectionMethod(InputManager::class, 'resolveCurrentKeyPress'); + $heldRepeatableKeyPress = new ReflectionProperty(InputManager::class, 'heldRepeatableKeyPress'); + $heldRepeatableKeySeenAt = new ReflectionProperty(InputManager::class, 'heldRepeatableKeySeenAt'); + $resolveCurrentKeyPress->setAccessible(true); + $heldRepeatableKeyPress->setAccessible(true); + $heldRepeatableKeySeenAt->setAccessible(true); + + $heldRepeatableKeyPress->setValue("\033[C"); + $heldRepeatableKeySeenAt->setValue(10.0); + + expect($resolveCurrentKeyPress->invoke(null, '', 10.04))->toBe("\033[C"); + expect($resolveCurrentKeyPress->invoke(null, '', 10.06))->toBe(''); }); diff --git a/tests/Unit/InspectorPanelTest.php b/tests/Unit/InspectorPanelTest.php index 7c027b7..6cef62e 100644 --- a/tests/Unit/InspectorPanelTest.php +++ b/tests/Unit/InspectorPanelTest.php @@ -57,6 +57,24 @@ function inspectorComponentHeaders(InspectorPanel $panel): array )); } +function selectedInspectorControlLabel(InspectorPanel $panel): ?string +{ + $focusableControls = new ReflectionProperty(InspectorPanel::class, 'focusableControls'); + $selectedControlIndex = new ReflectionProperty(InspectorPanel::class, 'selectedControlIndex'); + $focusableControls->setAccessible(true); + $selectedControlIndex->setAccessible(true); + + /** @var array $controls */ + $controls = $focusableControls->getValue($panel); + $selectedIndex = $selectedControlIndex->getValue($panel); + + if (!is_int($selectedIndex) || !isset($controls[$selectedIndex])) { + return null; + } + + return $controls[$selectedIndex]->getLabel(); +} + function createInspectorComponentWorkspace(): string { $workspace = sys_get_temp_dir() . '/sendama-inspector-components-' . uniqid(); @@ -840,6 +858,130 @@ class PlayerController extends Behaviour ]); }); +test('inspector panel preserves the selected hierarchy control across syncs', function () { + $panel = new InspectorPanel(width: 48, height: 24); + + $panel->inspectTarget([ + 'context' => 'hierarchy', + 'name' => 'Player', + 'type' => 'GameObject', + 'path' => 'scene.0', + 'value' => [ + 'type' => 'Sendama\\Engine\\Core\\GameObject', + 'name' => 'Player', + 'tag' => 'Player', + 'position' => ['x' => 4, 'y' => 12], + 'rotation' => ['x' => 0, 'y' => 0], + 'scale' => ['x' => 1, 'y' => 1], + ], + ]); + + focusInspectorPanel($panel); + selectInspectorControlByLabel($panel, 'Tag'); + + $panel->syncHierarchyTarget('scene.0', [ + 'type' => 'Sendama\\Engine\\Core\\GameObject', + 'name' => 'Player', + 'tag' => 'Hero', + 'position' => ['x' => 4, 'y' => 12], + 'rotation' => ['x' => 0, 'y' => 0], + 'scale' => ['x' => 1, 'y' => 1], + ]); + + expect(selectedInspectorControlLabel($panel))->toBe('Tag'); +}); + +test('inspector panel preserves the selected scene control across syncs', function () { + $panel = new InspectorPanel(width: 48, height: 24); + + $panel->inspectTarget([ + 'context' => 'scene', + 'name' => 'level01', + 'type' => 'Scene', + 'path' => 'scene', + 'value' => [ + 'name' => 'level01', + 'width' => 80, + 'height' => 25, + 'environmentTileMapPath' => 'Maps/level', + ], + ]); + + focusInspectorPanel($panel); + selectInspectorControlByLabel($panel, 'Height'); + + $panel->syncSceneTarget([ + 'name' => 'level01', + 'width' => 80, + 'height' => 30, + 'environmentTileMapPath' => 'Maps/level', + ]); + + expect(selectedInspectorControlLabel($panel))->toBe('Height'); +}); + +test('inspector panel preserves the selected asset control across syncs', function () { + $panel = new InspectorPanel(width: 48, height: 24); + + $panel->inspectTarget([ + 'context' => 'asset', + 'name' => 'level01.tmap', + 'type' => 'File', + 'value' => [ + 'name' => 'level01.tmap', + 'path' => '/tmp/project/Assets/Maps/level01.tmap', + 'relativePath' => 'Maps/level01.tmap', + 'isDirectory' => false, + ], + ]); + + focusInspectorPanel($panel); + selectInspectorControlByLabel($panel, 'Name'); + + $panel->syncAssetTarget([ + 'name' => 'level02.tmap', + 'path' => '/tmp/project/Assets/Maps/level02.tmap', + 'relativePath' => 'Maps/level02.tmap', + 'isDirectory' => false, + ]); + + expect(selectedInspectorControlLabel($panel))->toBe('Name'); +}); + +test('inspector panel preserves the selected control when the same target is re-inspected', function () { + $panel = new InspectorPanel(width: 48, height: 24); + $target = [ + 'context' => 'hierarchy', + 'name' => 'Player', + 'type' => 'GameObject', + 'path' => 'scene.0', + 'value' => [ + 'type' => 'Sendama\\Engine\\Core\\GameObject', + 'name' => 'Player', + 'tag' => 'Player', + 'position' => ['x' => 4, 'y' => 12], + 'rotation' => ['x' => 0, 'y' => 0], + 'scale' => ['x' => 1, 'y' => 1], + 'sprite' => [ + 'texture' => [ + 'path' => 'Textures/player', + 'position' => ['x' => 0, 'y' => 0], + 'size' => ['x' => 1, 'y' => 5], + ], + ], + ], + ]; + + $panel->inspectTarget($target); + focusInspectorPanel($panel); + selectInspectorControlByLabel($panel, 'Offset'); + + $target['value']['sprite']['texture']['position']['x'] = 1; + $panel->inspectTarget($target); + + expect(selectedInspectorControlLabel($panel))->toBe('Offset'); +}); + test('inspector panel opens a component menu with shift+a and appends the selected component', function () { $workspace = createInspectorComponentWorkspace(); $panel = new InspectorPanel(width: 48, height: 24, workingDirectory: $workspace); diff --git a/tests/Unit/MainPanelTest.php b/tests/Unit/MainPanelTest.php index e600ef3..35f14b3 100644 --- a/tests/Unit/MainPanelTest.php +++ b/tests/Unit/MainPanelTest.php @@ -160,6 +160,47 @@ function createMainPanelWorkspace(): string expect(array_any($panel->content, fn(string $line) => str_contains($line, 'x a x')))->toBeTrue(); }); +test('main panel preserves scene row width when a sprite uses a wide multibyte glyph', function () { + $workspace = createMainPanelWorkspace(); + file_put_contents($workspace . '/Assets/Textures/enemy.texture', "👾\n"); + + $panel = new MainPanel( + width: 24, + height: 10, + sceneObjects: [ + [ + 'type' => 'Sendama\\Engine\\Core\\GameObject', + 'name' => 'Enemy', + 'position' => ['x' => 2, 'y' => 1], + 'sprite' => [ + 'texture' => [ + 'path' => 'Textures/enemy', + 'position' => ['x' => 0, 'y' => 0], + 'size' => ['x' => 1, 'y' => 1], + ], + ], + ], + ], + workingDirectory: $workspace, + environmentTileMapPath: 'Maps/level', + ); + + $buildSceneCanvasContent = new ReflectionMethod(MainPanel::class, 'buildSceneCanvasContent'); + $buildSceneCanvasContent->setAccessible(true); + $sceneRows = $buildSceneCanvasContent->invoke($panel); + + expect($sceneRows[1])->toContain('👾'); + expect(mb_strwidth($sceneRows[1], 'UTF-8'))->toBe(mb_strlen($sceneRows[1]) + 1); + expect(rtrim($sceneRows[1]))->toEndWith('x'); + + $buildRenderedContentLines = new ReflectionMethod($panel, 'buildRenderedContentLines'); + $buildRenderedContentLines->setAccessible(true); + $renderedLines = $buildRenderedContentLines->invoke($panel); + + expect(mb_strwidth($renderedLines[3], 'UTF-8'))->toBe(24); + expect(mb_substr($renderedLines[3], -1))->toBe('│'); +}); + test('main panel resolves scene textures from the configured project directory', function () { $workspace = createMainPanelWorkspace(); $originalWorkingDirectory = getcwd(); @@ -922,6 +963,33 @@ function createMainPanelWorkspace(): string expect(file_get_contents($workspace . '/Assets/Textures/player.texture'))->toStartWith("█bcd\n"); }); +test('main panel sprite tab repeats the last printed special character when enter is pressed', function () { + $workspace = createMainPanelWorkspace(); + $panel = new MainPanel(width: 30, height: 12, workingDirectory: $workspace); + $hasFocus = new ReflectionProperty(Widget::class, 'hasFocus'); + $hasFocus->setAccessible(true); + $hasFocus->setValue($panel, true); + + $panel->selectTab('Sprite'); + $panel->loadSpriteAsset([ + 'name' => 'player.texture', + 'path' => $workspace . '/Assets/Textures/player.texture', + 'relativePath' => 'Textures/player.texture', + 'isDirectory' => false, + ]); + + pressMainPanelKey('@'); + $panel->update(); + + pressMainPanelKey("\n"); + $panel->update(); + + pressMainPanelKey("\n"); + $panel->update(); + + expect(file_get_contents($workspace . '/Assets/Textures/player.texture'))->toStartWith("██cd\n"); +}); + test('main panel sprite tab shows the cursor column x row position in the help line', function () { $workspace = createMainPanelWorkspace(); $panel = new MainPanel(width: 84, height: 12, workingDirectory: $workspace); diff --git a/tests/Unit/OptionListModalTest.php b/tests/Unit/OptionListModalTest.php new file mode 100644 index 0000000..0110e5a --- /dev/null +++ b/tests/Unit/OptionListModalTest.php @@ -0,0 +1,21 @@ +setAccessible(true); + + $modal->show(array_map( + static fn(int $index): string => 'Option ' . $index, + range(1, 12), + )); + $modal->syncLayout(28, 8); + $modal->moveSelection(5); + + expect($modal->getSelectedOption())->toBe('Option 6'); + expect($scrollOffset->getValue($modal))->toBeGreaterThan(0); + expect(array_any($modal->content, fn(string $line) => str_contains($line, '> Option 6')))->toBeTrue(); + expect(array_any($modal->content, fn(string $line) => str_contains($line, 'Option 1')))->toBeFalse(); +}); diff --git a/tests/Unit/SceneLoaderTest.php b/tests/Unit/SceneLoaderTest.php index 1b3ce11..6ebaee3 100644 --- a/tests/Unit/SceneLoaderTest.php +++ b/tests/Unit/SceneLoaderTest.php @@ -34,6 +34,33 @@ expect($scene->hierarchy[1]['name'])->toBe('Player'); }); +test('scene loader normalizes environment tile map paths to extensionless asset paths', function () { + $workspace = sys_get_temp_dir() . '/sendama-scene-loader-map-path-' . uniqid(); + mkdir($workspace . '/Assets/Scenes', 0777, true); + mkdir($workspace . '/vendor', 0777, true); + + file_put_contents($workspace . '/vendor/autoload.php', " 'Maps/level.tmap', + 'hierarchy' => [], +]; +PHP + ); + + $loader = new SceneLoader($workspace); + $scene = $loader->load(new EditorSceneSettings(active: 0, loaded: ['level01'])); + + expect($scene)->not->toBeNull(); + expect($scene->environmentTileMapPath)->toBe('Maps/level'); + expect($scene->rawData['environmentTileMapPath'])->toBe('Maps/level'); + expect($scene->sourceData['environmentTileMapPath'])->toBe('Maps/level'); +}); + test('scene loader evaluates scene metadata in an isolated project context', function () { $workspace = sys_get_temp_dir() . '/sendama-scene-loader-' . uniqid(); mkdir($workspace . '/assets/Scenes', 0777, true); diff --git a/tests/Unit/WidgetTest.php b/tests/Unit/WidgetTest.php index 14fde7f..b47208c 100644 --- a/tests/Unit/WidgetTest.php +++ b/tests/Unit/WidgetTest.php @@ -97,3 +97,25 @@ public function update(): void expect(mb_strlen($lines[0]))->toBe(20); expect(mb_substr($lines[0], -1))->toBe('│'); }); + +test('widget keeps borders intact when content contains wide multibyte glyphs', function () { + $widget = new class extends Widget { + public function __construct() + { + parent::__construct('Scene', '', ['x' => 1, 'y' => 1], 12, 6); + $this->content = [' 👾 x']; + } + + public function update(): void + { + } + }; + + $buildRenderedContentLines = new ReflectionMethod($widget, 'buildRenderedContentLines'); + $buildRenderedContentLines->setAccessible(true); + $lines = $buildRenderedContentLines->invoke($widget); + + expect($lines)->toHaveCount(4); + expect(mb_strwidth($lines[0], 'UTF-8'))->toBe(12); + expect(mb_substr($lines[0], -1))->toBe('│'); +}); From bc145ea1ef2eef3b5218a75033df50ce626e4b18 Mon Sep 17 00:00:00 2001 From: Andrew Masiye Date: Fri, 13 Mar 2026 19:27:43 +0200 Subject: [PATCH 2/2] feat(prefabs): add support for loading and saving prefab data, including collision map paths --- docs/Editor.md | 18 +- docs/guides/reference.md | 2 +- docs/guides/working-with-assets.md | 5 + src/Editor/DTOs/SceneDTO.php | 3 + src/Editor/Editor.php | 232 +++++++++++++++- src/Editor/IO/InputManager.php | 17 +- src/Editor/PrefabLoader.php | 355 ++++++++++++++++++++++++ src/Editor/PrefabWriter.php | 74 +++++ src/Editor/SceneLoader.php | 25 ++ src/Editor/SceneWriter.php | 1 + src/Editor/Widgets/ConsolePanel.php | 254 ++++++++++++++++- src/Editor/Widgets/HierarchyPanel.php | 6 + src/Editor/Widgets/InspectorPanel.php | 198 +++++++++++-- src/Editor/Widgets/MainPanel.php | 46 +-- src/Editor/Widgets/Widget.php | 60 ++++ tests/Unit/ConsolePanelTest.php | 171 ++++++++++++ tests/Unit/EditorAssetRenameTest.php | 48 ++++ tests/Unit/EditorAssetSelectionTest.php | 126 +++++++++ tests/Unit/HierarchyPanelTest.php | 2 + tests/Unit/InputManagerTest.php | 18 ++ tests/Unit/InspectorPanelTest.php | 118 +++++++- tests/Unit/MainPanelTest.php | 72 +++++ tests/Unit/SceneLoaderTest.php | 46 +++ tests/Unit/SceneWriterTest.php | 2 + 24 files changed, 1830 insertions(+), 69 deletions(-) create mode 100644 src/Editor/PrefabLoader.php create mode 100644 src/Editor/PrefabWriter.php diff --git a/docs/Editor.md b/docs/Editor.md index 8923522..ae9e5db 100644 --- a/docs/Editor.md +++ b/docs/Editor.md @@ -175,7 +175,7 @@ Current behavior: - the editor works on a character grid backed directly by the selected file - the visible canvas is only the editable grid itself; asset metadata is shown in the Inspector - textures load into an editable area that can grow up to `16x16` -- new tile maps are created at the current terminal-size bounds +- tile maps open and create at the current terminal-size bounds - the right side of the main-panel help line shows the live cursor position as `Col x Row` - edits are written to the asset file immediately @@ -316,6 +316,8 @@ Behavior: - if the created asset is a texture or tile map, the Sprite tab loads it too - prefab assets are created as `.prefab.php` metadata files under `Assets/Prefabs` - prefab metadata returns a single array shaped like one scene `hierarchy` entry, so it can describe either a `GameObject` or a UI element such as `Label` or `Text` +- pressing `Enter` on a selected prefab loads it into the `Inspector` using the same object-style layout as a hierarchy object +- prefab inspection keeps `File Name` separate from `Name`, so the prefab file can be renamed independently from the object name stored in the metadata ## Inspector Panel @@ -529,6 +531,12 @@ Current behavior: - it auto-refreshes the console tabs from disk every `editor.console.refreshInterval` seconds while the editor is in Play Mode - when the console has focus and the editor is not in play mode, it supports scrolling - if no refresh interval is configured, the editor uses a default of `5` seconds +- each tab can be filtered by log level with a modal picker + +Filter options: + +- `Debug`: `ALL`, `DEBUG`, `INFO`, `WARN`, `ERROR` +- `Error`: `ALL`, `ERROR`, `CRITICAL`, `FATAL` Controls: @@ -537,6 +545,14 @@ Controls: - `Up`: scroll up through older log lines - `Down`: scroll down through newer log lines - `Shift+R`: manually refresh the active log tab from disk and jump to the newest visible lines +- `Shift+F`: open the log-level filter modal for the active console tab +- `Shift+C`: open a confirm modal to rotate and clear the active log file + +Clear behavior: + +- on confirm, the active log file is copied to the next rotated file such as `debug.log.1` +- after rotation, the active log file is cleared +- on cancel, nothing changes and the Console panel returns to its normal state The scroll stops: diff --git a/docs/guides/reference.md b/docs/guides/reference.md index 0a18940..d8e08fa 100644 --- a/docs/guides/reference.md +++ b/docs/guides/reference.md @@ -40,7 +40,7 @@ Add-object types: | `Up` / `Down` | Move selection | | `Right` | Expand folder or move into children | | `Left` | Collapse folder or move to parent | -| `Enter` | Inspect file or folder | +| `Enter` | Inspect file or folder, or load a prefab into the object-style Inspector view | | `Shift+A` | Create asset from the Assets create menu | | `Delete` | Delete selected asset | diff --git a/docs/guides/working-with-assets.md b/docs/guides/working-with-assets.md index c5e89a6..90b2d30 100644 --- a/docs/guides/working-with-assets.md +++ b/docs/guides/working-with-assets.md @@ -24,6 +24,7 @@ What inspection does: - folders open in the `Inspector` as `Folder` - files open in the `Inspector` as `File` - selecting a `.texture` or `.tmap` also opens `Main -> Sprite` and moves focus there +- pressing `Enter` on a selected `.prefab.php` opens it in the `Inspector` with the same object-style layout used for hierarchy objects ## Sprite Tab Overview @@ -38,6 +39,8 @@ How loading works: - the file opens in `Inspector` - the same file loads into `Sprite` - focus shifts to `Main -> Sprite` +- textures expand to a `16x16` editable grid +- tile maps expand to the current terminal-size bounds ## Creating New Assets @@ -85,6 +88,8 @@ Current prefab support in the editor is focused on generation and project organi - the `Assets` create menu can generate new prefab files - the CLI can generate prefabs directly with `sendama generate:prefab ` - the generated file is normal scene-style metadata, so it is easy to author by hand too +- activating a prefab from the `Assets` panel loads its configured object data into the `Inspector` +- prefab inspection exposes `File Name` separately from the prefab object's `Name` ## Sprite Editing Controls diff --git a/src/Editor/DTOs/SceneDTO.php b/src/Editor/DTOs/SceneDTO.php index d52ba98..1b73cff 100644 --- a/src/Editor/DTOs/SceneDTO.php +++ b/src/Editor/DTOs/SceneDTO.php @@ -12,6 +12,7 @@ public function __construct( public int $width = DEFAULT_TERMINAL_WIDTH, public int $height = DEFAULT_TERMINAL_HEIGHT, public string $environmentTileMapPath = "Maps/example", + public string $environmentCollisionMapPath = "", public bool $isDirty = false, public array $hierarchy = [], public ?string $sourcePath = null, @@ -28,6 +29,7 @@ public function __serialize(): array "width" => $this->width, "height" => $this->height, "environmentTileMapPath" => $this->environmentTileMapPath, + "environmentCollisionMapPath" => $this->environmentCollisionMapPath, "isDirty" => $this->isDirty, "hierarchy" => $this->hierarchy, "sourcePath" => $this->sourcePath, @@ -42,6 +44,7 @@ public function __unserialize(array $data): void $this->width = $data['width'] ?? DEFAULT_TERMINAL_WIDTH; $this->height = $data['height'] ?? DEFAULT_TERMINAL_HEIGHT; $this->environmentTileMapPath = $data['environmentTileMapPath'] ?? "Maps/example"; + $this->environmentCollisionMapPath = $data['environmentCollisionMapPath'] ?? ""; $this->isDirty = $data['isDirty'] ?? false; $this->hierarchy = $data['hierarchy'] ?? []; $this->sourcePath = $data['sourcePath'] ?? null; diff --git a/src/Editor/Editor.php b/src/Editor/Editor.php index 754bb1b..8d3a542 100644 --- a/src/Editor/Editor.php +++ b/src/Editor/Editor.php @@ -144,6 +144,7 @@ final class Editor implements ObservableInterface protected bool $shouldRefreshBackgroundUnderModal = false; protected bool $didRenderOverlayLastFrame = false; protected SceneWriter $sceneWriter; + protected PrefabWriter $prefabWriter; protected ?ProjectNormalizer $projectNormalizer = null; protected array $projectDiscrepancies = []; @@ -169,6 +170,7 @@ public function __construct( $this->initializeManagers(); $this->initializeConsole(); $this->sceneWriter = new SceneWriter(); + $this->prefabWriter = new PrefabWriter(); $this->initializeWidgets(); $this->initializeEditorStates(); $this->initializeProjectIntegrityCheck(); @@ -404,6 +406,7 @@ private function update(): void $this->synchronizeMainPanelSceneChanges(); $this->synchronizeMainPanelAssetChanges(); $this->synchronizeInspectorSceneChanges(); + $this->synchronizeInspectorPrefabChanges(); $this->synchronizeInspectorAssetChanges(); $this->synchronizeInspectorPanel(); @@ -451,8 +454,15 @@ private function render(): void $this->didRenderOverlayLastFrame = true; $this->focusedPanel->syncModalLayout($this->terminalWidth, $this->terminalHeight); - if ($this->shouldRefreshBackgroundUnderModal || $this->focusedPanel->isModalDirty()) { + if ($this->focusedPanel->consumeModalBackgroundRefreshRequest()) { + $this->shouldRefreshBackgroundUnderModal = true; + } + + if ($this->shouldRefreshBackgroundUnderModal) { $this->renderEditorFrame(); + } + + if ($this->shouldRefreshBackgroundUnderModal || $this->focusedPanel->isModalDirty()) { $this->focusedPanel->renderActiveModal(); $this->focusedPanel->markModalClean(); $this->shouldRefreshBackgroundUnderModal = false; @@ -656,6 +666,7 @@ private function initializeWidgets(): void sceneWidth: $this->loadedScene?->width ?? DEFAULT_TERMINAL_WIDTH, sceneHeight: $this->loadedScene?->height ?? DEFAULT_TERMINAL_HEIGHT, environmentTileMapPath: $this->loadedScene?->environmentTileMapPath ?? 'Maps/example', + environmentCollisionMapPath: $this->loadedScene?->environmentCollisionMapPath ?? '', ); $this->assetsPanel = new AssetsPanel( assetsDirectoryPath: $this->assetsDirectoryPath, @@ -1065,8 +1076,12 @@ private function synchronizeInspectorPanel(): void $this->hierarchyPanel->selectPath('scene'); } elseif (($selectedItem['context'] ?? null) === 'asset') { $asset = is_array($selectedItem['value'] ?? null) ? $selectedItem['value'] : null; + $openInMainPanel = ($selectedItem['openInMainPanel'] ?? false) === true; + $selectedItem = is_array($asset) + ? $this->buildAssetInspectionTarget($asset, $openInMainPanel) + : $selectedItem; - if (($selectedItem['openInMainPanel'] ?? false) === true && $this->isEditableSpriteAsset($asset)) { + if ($openInMainPanel && $this->isEditableSpriteAsset($asset)) { $this->mainPanel->loadSpriteAsset($asset); $this->mainPanel->selectTab('Sprite'); $this->setFocusedPanel($this->mainPanel); @@ -1136,7 +1151,7 @@ private function synchronizeInspectorAssetChanges(): void if ($renamedAsset === null) { if (is_file($mutation['path'])) { - $this->inspectorPanel->syncAssetTarget([ + $fallbackAsset = [ 'name' => basename($mutation['path']), 'path' => $mutation['path'], 'relativePath' => is_string($mutation['relativePath'] ?? null) @@ -1144,16 +1159,70 @@ private function synchronizeInspectorAssetChanges(): void : basename($mutation['path']), 'isDirectory' => false, 'children' => [], - ]); + ]; + + if (($mutation['activatePrefab'] ?? false) === true) { + $this->inspectorPanel->inspectTarget($this->buildAssetInspectionTarget($fallbackAsset, true)); + } else { + $this->inspectorPanel->syncAssetTarget($fallbackAsset); + } } return; } $this->assetsPanel->reloadAssets(); $this->assetsPanel->selectAssetByAbsolutePath($renamedAsset['path']); - $assetInspectionTarget = $this->buildAssetInspectionTarget($renamedAsset); + $this->assetsPanel->consumeInspectionRequest(); + $assetInspectionTarget = $this->buildAssetInspectionTarget( + $renamedAsset, + ($mutation['activatePrefab'] ?? false) === true + ); $this->inspectorPanel->inspectTarget($assetInspectionTarget); - $this->mainPanel->loadSpriteAsset($renamedAsset); + + if ($this->isEditableSpriteAsset($renamedAsset)) { + $this->mainPanel->loadSpriteAsset($renamedAsset); + } + } + + private function synchronizeInspectorPrefabChanges(): void + { + $mutation = $this->inspectorPanel->consumePrefabMutation(); + + if ( + !is_array($mutation) + || !is_string($mutation['prefabPath'] ?? null) + || $mutation['prefabPath'] === '' + || !is_array($mutation['value'] ?? null) + ) { + return; + } + + if (!isset($this->prefabWriter)) { + $this->prefabWriter = new PrefabWriter(); + } + + if (!$this->prefabWriter->save($mutation['prefabPath'], $mutation['value'])) { + $this->consolePanel->append('[ERROR] - Failed to save prefab ' . basename($mutation['prefabPath']) . '.'); + return; + } + + $asset = is_array($mutation['asset'] ?? null) + ? $mutation['asset'] + : [ + 'name' => basename($mutation['prefabPath']), + 'path' => $mutation['prefabPath'], + 'relativePath' => basename($mutation['prefabPath']), + 'isDirectory' => false, + 'children' => [], + ]; + $asset['name'] = basename($mutation['prefabPath']); + $asset['path'] = $mutation['prefabPath']; + $asset['relativePath'] = $this->buildRelativeAssetPath($mutation['prefabPath']); + + $this->assetsPanel->reloadAssets(); + $this->assetsPanel->selectAssetByAbsolutePath($mutation['prefabPath']); + $this->assetsPanel->consumeInspectionRequest(); + $this->inspectorPanel->inspectTarget($this->buildAssetInspectionTarget($asset, true)); } private function synchronizeHierarchyAdditions(): void @@ -1730,6 +1799,7 @@ private function renameAssetAndCascadeReferences( if ($this->loadedScene instanceof DTOs\SceneDTO) { $this->loadedScene->rawData['hierarchy'] = $this->loadedScene->hierarchy; $this->loadedScene->rawData['environmentTileMapPath'] = $this->loadedScene->environmentTileMapPath; + $this->loadedScene->rawData['environmentCollisionMapPath'] = $this->loadedScene->environmentCollisionMapPath; $this->loadedScene->isDirty = true; $this->hierarchyPanel->syncHierarchy($this->loadedScene->hierarchy); $this->mainPanel->setSceneObjects($this->loadedScene->hierarchy); @@ -1855,21 +1925,38 @@ private function normalizeAssetFileName(string $requestedName, string $currentAb { $trimmedName = trim(str_replace('\\', '/', $requestedName)); $trimmedName = basename($trimmedName); - $currentExtension = strtolower((string) pathinfo($currentAbsolutePath, PATHINFO_EXTENSION)); + $fileNameSuffix = $this->resolveAssetFileNameSuffix($currentAbsolutePath); if ($trimmedName === '') { return basename($currentAbsolutePath); } - $requestedBaseName = (string) pathinfo($trimmedName, PATHINFO_FILENAME); + $requestedBaseName = str_ends_with(strtolower($trimmedName), strtolower($fileNameSuffix)) + ? substr($trimmedName, 0, -strlen($fileNameSuffix)) + : (string) pathinfo($trimmedName, PATHINFO_FILENAME); if ($requestedBaseName === '') { - $requestedBaseName = (string) pathinfo(basename($currentAbsolutePath), PATHINFO_FILENAME); + $requestedBaseName = basename(basename($currentAbsolutePath), $fileNameSuffix); } - return $currentExtension !== '' - ? $requestedBaseName . '.' . $currentExtension - : $requestedBaseName; + return $requestedBaseName . $fileNameSuffix; + } + + private function resolveAssetFileNameSuffix(string $absolutePath): string + { + $normalizedBaseName = strtolower(basename(str_replace('\\', '/', $absolutePath))); + + foreach (['.prefab.php', '.scene.php'] as $compoundSuffix) { + if (str_ends_with($normalizedBaseName, $compoundSuffix)) { + return substr(basename($absolutePath), -strlen($compoundSuffix)); + } + } + + $extension = pathinfo($absolutePath, PATHINFO_EXTENSION); + + return $extension !== '' + ? '.' . $extension + : ''; } private function buildRelativeAssetPath(string $absolutePath): string @@ -1905,6 +1992,14 @@ private function updateSceneAssetReferences(string $oldRelativePath, string $new $hasChanges = true; } + if ($this->loadedScene->environmentCollisionMapPath === $oldWithExtension) { + $this->loadedScene->environmentCollisionMapPath = $newWithExtension; + $hasChanges = true; + } elseif ($this->loadedScene->environmentCollisionMapPath === $oldWithoutExtension) { + $this->loadedScene->environmentCollisionMapPath = $newWithoutExtension; + $hasChanges = true; + } + $this->loadedScene->hierarchy = $this->updateHierarchyAssetReferences( $this->loadedScene->hierarchy, $oldWithExtension, @@ -1955,8 +2050,16 @@ private function updateHierarchyAssetReferences( return array_values($items); } - private function buildAssetInspectionTarget(array $asset): array + private function buildAssetInspectionTarget(array $asset, bool $activatePrefab = false): array { + if ($activatePrefab && $this->isPrefabAsset($asset)) { + $prefabInspectionTarget = $this->buildPrefabInspectionTarget($asset); + + if (is_array($prefabInspectionTarget)) { + return $prefabInspectionTarget; + } + } + return [ 'context' => 'asset', 'name' => $asset['name'] ?? basename((string) ($asset['path'] ?? '')), @@ -1965,6 +2068,85 @@ private function buildAssetInspectionTarget(array $asset): array ]; } + private function buildPrefabInspectionTarget(array $asset): ?array + { + $prefabPath = is_string($asset['path'] ?? null) ? $asset['path'] : null; + + if (!is_string($prefabPath) || $prefabPath === '') { + return null; + } + + $prefabData = (new PrefabLoader($this->resolveProjectDirectoryForAsset($asset)))->load($prefabPath); + + if (!is_array($prefabData)) { + return null; + } + + return [ + 'context' => 'prefab', + 'path' => $asset['relativePath'] ?? basename($prefabPath), + 'name' => $prefabData['name'] ?? ($asset['name'] ?? basename($prefabPath)), + 'type' => $this->resolveClassReferenceDisplayName($prefabData['type'] ?? null, 'Prefab'), + 'value' => $prefabData, + 'asset' => $asset, + ]; + } + + private function isPrefabAsset(?array $asset): bool + { + if (!is_array($asset) || ($asset['isDirectory'] ?? false)) { + return false; + } + + $assetPath = is_string($asset['relativePath'] ?? null) + ? $asset['relativePath'] + : (is_string($asset['path'] ?? null) ? $asset['path'] : null); + + return is_string($assetPath) && str_ends_with(strtolower($assetPath), '.prefab.php'); + } + + private function resolveProjectDirectoryForAsset(array $asset): string + { + if (isset($this->workingDirectory) && is_string($this->workingDirectory) && $this->workingDirectory !== '') { + return $this->workingDirectory; + } + + $assetPath = is_string($asset['path'] ?? null) ? Path::normalize($asset['path']) : null; + $relativePath = is_string($asset['relativePath'] ?? null) + ? Path::normalize($asset['relativePath']) + : null; + + if (!is_string($assetPath) || $assetPath === '' || !is_string($relativePath) || $relativePath === '') { + return '.'; + } + + if (!str_ends_with($assetPath, $relativePath)) { + return dirname($assetPath); + } + + $normalizedAssetRoot = rtrim(substr($assetPath, 0, -strlen($relativePath)), '/'); + + if (str_ends_with(strtolower($normalizedAssetRoot), '/assets')) { + return dirname($normalizedAssetRoot); + } + + return dirname($assetPath); + } + + private function resolveClassReferenceDisplayName(mixed $classReference, string $default = 'Unknown'): string + { + if (!is_string($classReference) || $classReference === '') { + return $default; + } + + $normalizedClassReference = ltrim($classReference, '\\'); + $normalizedClassReference = preg_replace('/::class$/', '', $normalizedClassReference) + ?? $normalizedClassReference; + $classSegments = explode('\\', $normalizedClassReference); + + return end($classSegments) ?: $default; + } + private function isEditableSpriteAsset(?array $asset): bool { if (!is_array($asset) || ($asset['isDirectory'] ?? false)) { @@ -2057,9 +2239,16 @@ private function applySceneMutation(array $value): bool ); } + if (is_string($value['environmentCollisionMapPath'] ?? null)) { + $this->loadedScene->environmentCollisionMapPath = $this->normalizeEnvironmentCollisionMapPath( + $value['environmentCollisionMapPath'] + ); + } + $this->loadedScene->rawData['width'] = $this->loadedScene->width; $this->loadedScene->rawData['height'] = $this->loadedScene->height; $this->loadedScene->rawData['environmentTileMapPath'] = $this->loadedScene->environmentTileMapPath; + $this->loadedScene->rawData['environmentCollisionMapPath'] = $this->loadedScene->environmentCollisionMapPath; return true; } @@ -2075,6 +2264,7 @@ private function buildSceneInspectionValue(): array 'width' => $this->loadedScene->width, 'height' => $this->loadedScene->height, 'environmentTileMapPath' => $this->loadedScene->environmentTileMapPath, + 'environmentCollisionMapPath' => $this->loadedScene->environmentCollisionMapPath, ]; } @@ -2093,6 +2283,21 @@ private function normalizeEnvironmentTileMapPath(mixed $value): string return preg_replace('/\.tmap$/i', '', $normalizedValue) ?? $normalizedValue; } + private function normalizeEnvironmentCollisionMapPath(mixed $value): string + { + if (!is_string($value)) { + return ''; + } + + $normalizedValue = trim(str_replace('\\', '/', $value)); + + if ($normalizedValue === '') { + return ''; + } + + return preg_replace('/\.tmap$/i', '', $normalizedValue) ?? $normalizedValue; + } + private function syncScenePanels(bool $isDirty): void { if (!$this->loadedScene instanceof DTOs\SceneDTO) { @@ -2105,6 +2310,7 @@ private function syncScenePanels(bool $isDirty): void $this->loadedScene->width, $this->loadedScene->height, $this->loadedScene->environmentTileMapPath, + $this->loadedScene->environmentCollisionMapPath, ); $this->inspectorPanel->setSceneHierarchy($this->loadedScene->hierarchy); $this->mainPanel->setSceneDimensions($this->loadedScene->width, $this->loadedScene->height); diff --git a/src/Editor/IO/InputManager.php b/src/Editor/IO/InputManager.php index f691d10..b88c6f7 100644 --- a/src/Editor/IO/InputManager.php +++ b/src/Editor/IO/InputManager.php @@ -37,6 +37,7 @@ class InputManager implements StaticObservableInterface private static ?string $terminalModeSnapshot = null; private static string $heldRepeatableKeyPress = ''; private static float $heldRepeatableKeySeenAt = 0.0; + private static bool $currentKeyPressWasBuffered = false; /** * Initializes the InputManager. @@ -50,6 +51,7 @@ public static function init(): void self::$mouseEvent = null; self::$heldRepeatableKeyPress = ''; self::$heldRepeatableKeySeenAt = 0.0; + self::$currentKeyPressWasBuffered = false; self::initializeObservers(); } @@ -133,6 +135,7 @@ public static function handleInput(): void self::$inputQueue = self::coalesceRepeatableTokens(self::$inputQueue); $nextKeyPress = array_shift(self::$inputQueue) ?? ''; + self::$currentKeyPressWasBuffered = $nextKeyPress !== ''; self::$keyPress = self::resolveCurrentKeyPress($nextKeyPress, microtime(true)); self::$mouseEvent = self::parseMouseEvent(self::$keyPress); @@ -279,7 +282,7 @@ public static function isKeyDown(KeyCode $keyCode, bool $ignoreCase = true): boo } if (in_array($keyCodeValue, self::COALESCED_REPEATABLE_KEYS, true)) { - return true; + return self::$currentKeyPressWasBuffered || $previousKey !== $key; } return $previousKey !== $key; @@ -304,7 +307,17 @@ public static function areAllKeysPressed(array $keyCodes): bool */ public static function isKeyPressed(KeyCode $keyCode): bool { - return self::getKey(self::$keyPress) === $keyCode->value; + $key = self::getKey(self::$keyPress); + + if ($key !== $keyCode->value) { + return false; + } + + if (!in_array($keyCode->value, self::COALESCED_REPEATABLE_KEYS, true)) { + return true; + } + + return self::$currentKeyPressWasBuffered || self::getKey(self::$previousKeyPress) !== $key; } /** diff --git a/src/Editor/PrefabLoader.php b/src/Editor/PrefabLoader.php new file mode 100644 index 0000000..ac88b76 --- /dev/null +++ b/src/Editor/PrefabLoader.php @@ -0,0 +1,355 @@ +loadPrefabDataBundle($prefabPath); + + return is_array($prefabDataBundle['editor'] ?? null) + ? $prefabDataBundle['editor'] + : null; + } + + private function loadPrefabDataBundle(string $prefabPath): ?array + { + $isolatedPrefabDataBundle = $this->loadPrefabDataInIsolatedProcess($prefabPath); + + if ( + is_array($isolatedPrefabDataBundle) + && is_array($isolatedPrefabDataBundle['source'] ?? null) + && is_array($isolatedPrefabDataBundle['editor'] ?? null) + ) { + return $isolatedPrefabDataBundle; + } + + try { + $prefabData = require $prefabPath; + + if (is_array($prefabData)) { + return [ + 'source' => $prefabData, + 'editor' => $prefabData, + ]; + } + + Debug::warn("Prefab metadata at {$prefabPath} did not return an array."); + } catch (Throwable $throwable) { + Debug::warn("Failed to load prefab metadata at {$prefabPath}: {$throwable->getMessage()}"); + } + + return null; + } + + private function loadPrefabDataInIsolatedProcess(string $prefabPath): ?array + { + $autoloadPath = Path::join($this->workingDirectory, 'vendor', 'autoload.php'); + $script = <<<'PHP' +$autoloadPath = $argv[1] ?? ''; +$prefabPath = $argv[2] ?? ''; + +function normalize_editor_value(mixed $value): mixed +{ + if (is_array($value)) { + $normalized = []; + + foreach ($value as $key => $item) { + $normalized[$key] = normalize_editor_value($item); + } + + return $normalized; + } + + if ($value instanceof UnitEnum) { + return $value instanceof BackedEnum ? $value->value : $value->name; + } + + if (!is_object($value)) { + return $value; + } + + if (method_exists($value, 'getX') && method_exists($value, 'getY')) { + return [ + 'x' => normalize_editor_value($value->getX()), + 'y' => normalize_editor_value($value->getY()), + ]; + } + + if (method_exists($value, 'getName')) { + try { + return $value->getName(); + } catch (Throwable) { + // Ignore and continue. + } + } + + if (method_exists($value, '__serialize')) { + try { + $serializedValue = $value->__serialize(); + + return is_array($serializedValue) + ? normalize_editor_value($serializedValue) + : normalize_editor_value((array) $serializedValue); + } catch (Throwable) { + // Ignore and continue. + } + } + + if ($value instanceof Stringable) { + return (string) $value; + } + + return get_class($value); +} + +function build_vector(mixed $value, array $default = ['x' => 0, 'y' => 0]): ?object +{ + if (!class_exists('\Sendama\Engine\Core\Vector2')) { + return null; + } + + $vectorValue = is_array($value) ? $value : $default; + + return new \Sendama\Engine\Core\Vector2( + (int) ($vectorValue['x'] ?? $default['x']), + (int) ($vectorValue['y'] ?? $default['y']), + ); +} + +function build_dummy_game_object(array $item): ?object +{ + if ( + !class_exists('\Sendama\Engine\Core\GameObject') + || !class_exists('\Sendama\Engine\Core\Vector2') + ) { + return null; + } + + $tag = is_string($item['tag'] ?? null) && $item['tag'] !== 'None' + ? $item['tag'] + : null; + + return new \Sendama\Engine\Core\GameObject( + is_string($item['name'] ?? null) ? $item['name'] : 'GameObject', + $tag, + build_vector($item['position'] ?? null) ?? new \Sendama\Engine\Core\Vector2(), + build_vector($item['rotation'] ?? null) ?? new \Sendama\Engine\Core\Vector2(), + build_vector($item['scale'] ?? ['x' => 1, 'y' => 1], ['x' => 1, 'y' => 1]) ?? new \Sendama\Engine\Core\Vector2(1, 1), + null, + ); +} + +function serialize_component_data(string $componentClass, array $item): ?array +{ + if ( + !class_exists($componentClass) + || !class_exists('\Sendama\Engine\Core\Component') + || !is_a($componentClass, '\Sendama\Engine\Core\Component', true) + ) { + return null; + } + + try { + $gameObject = build_dummy_game_object($item); + + if (!is_object($gameObject)) { + return null; + } + + $component = new $componentClass($gameObject); + + return normalize_editor_value(extract_component_serializable_data($component)); + } catch (Throwable) { + return null; + } +} + +function extract_component_serializable_data(object $component): array +{ + $serializedData = []; + $reflection = new ReflectionObject($component); + + foreach ($reflection->getProperties() as $property) { + $isSerializable = $property->isPublic() + || $property->getAttributes('Sendama\Engine\Core\Behaviours\Attributes\SerializeField') !== []; + + if (!$isSerializable) { + continue; + } + + if (method_exists($property, 'isVirtual') && $property->isVirtual()) { + continue; + } + + try { + $serializedData[$property->getName()] = $property->getValue($component); + } catch (Throwable) { + continue; + } + } + + return $serializedData; +} + +function merge_component_data(array $defaultData, array $existingData): array +{ + if ($existingData === []) { + return $defaultData; + } + + $mergedData = $defaultData; + + foreach ($existingData as $key => $value) { + if ( + array_key_exists($key, $defaultData) + && is_array($defaultData[$key]) + && is_array($value) + && !array_is_list($defaultData[$key]) + && !array_is_list($value) + ) { + $mergedData[$key] = merge_component_data($defaultData[$key], $value); + continue; + } + + $mergedData[$key] = $value; + } + + return $mergedData; +} + +function enrich_component_entry(mixed $component, array $item): mixed +{ + if (!is_array($component)) { + return $component; + } + + $componentClass = $component['class'] ?? null; + $defaultComponentData = is_string($componentClass) && $componentClass !== '' + ? serialize_component_data($componentClass, $item) + : null; + + if (array_key_exists('data', $component)) { + $existingComponentData = is_array($component['data']) + ? normalize_editor_value($component['data']) + : normalize_editor_value((array) $component['data']); + + if (is_array($defaultComponentData)) { + $component['data'] = merge_component_data($defaultComponentData, $existingComponentData); + } else { + $component['data'] = $existingComponentData; + } + + return $component; + } + + if (is_array($defaultComponentData)) { + $component['data'] = $defaultComponentData; + } + + return $component; +} + +function enrich_prefab_item(mixed $item): mixed +{ + if (!is_array($item)) { + return $item; + } + + if (is_array($item['components'] ?? null)) { + $item['components'] = array_values(array_map( + static fn (mixed $component): mixed => enrich_component_entry($component, $item), + $item['components'], + )); + } + + if (is_array($item['children'] ?? null)) { + $item['children'] = array_values(array_map( + static fn (mixed $child): mixed => enrich_prefab_item($child), + $item['children'], + )); + } + + return $item; +} + +if ($prefabPath === '' || !is_file($prefabPath)) { + fwrite(STDERR, "Prefab file not found.\n"); + exit(1); +} + +ob_start(); + +try { + if ($autoloadPath !== '' && is_file($autoloadPath)) { + require $autoloadPath; + } + + $prefabData = require $prefabPath; +} finally { + ob_end_clean(); +} + +if (!is_array($prefabData ?? null)) { + fwrite(STDERR, "Prefab metadata did not return an array.\n"); + exit(2); +} + +$payload = [ + 'source' => $prefabData, + 'editor' => enrich_prefab_item($prefabData), +]; + +$encodedPrefabData = json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + +if (!is_string($encodedPrefabData)) { + fwrite(STDERR, "Failed to encode prefab metadata.\n"); + exit(3); +} + +echo $encodedPrefabData; +PHP; + + $command = [PHP_BINARY, '-d', 'display_errors=stderr', '-r', $script, $autoloadPath, $prefabPath]; + $descriptors = [ + 0 => ['pipe', 'r'], + 1 => ['pipe', 'w'], + 2 => ['pipe', 'w'], + ]; + $process = proc_open($command, $descriptors, $pipes, $this->workingDirectory); + + if (!is_resource($process)) { + return null; + } + + fclose($pipes[0]); + $stdout = stream_get_contents($pipes[1]) ?: ''; + $stderr = stream_get_contents($pipes[2]) ?: ''; + fclose($pipes[1]); + fclose($pipes[2]); + $exitCode = proc_close($process); + + if ($exitCode !== 0 || $stdout === '') { + if ($stderr !== '') { + Debug::warn("Failed to evaluate prefab metadata at {$prefabPath}: {$stderr}"); + } + + return null; + } + + $decodedPrefabData = json_decode($stdout, true); + + return is_array($decodedPrefabData) ? $decodedPrefabData : null; + } +} diff --git a/src/Editor/PrefabWriter.php b/src/Editor/PrefabWriter.php new file mode 100644 index 0000000..57e6e8a --- /dev/null +++ b/src/Editor/PrefabWriter.php @@ -0,0 +1,74 @@ +serialize($prefabData); + + return file_put_contents($prefabPath, $serializedPrefab) !== false; + } + + public function serialize(array $prefabData): string + { + return "exportValue($prefabData) . ";\n"; + } + + private function exportValue(mixed $value, int $depth = 0, ?string $contextKey = null): string + { + if (is_array($value)) { + return $this->exportArray($value, $depth); + } + + if (is_string($value) && in_array($contextKey, ['type', 'class'], true)) { + return $this->exportClassReference($value); + } + + return var_export($value, true); + } + + private function exportArray(array $value, int $depth): string + { + if ($value === []) { + return '[]'; + } + + $indent = str_repeat(' ', $depth); + $childIndent = str_repeat(' ', $depth + 1); + $lines = []; + + foreach ($value as $key => $item) { + $prefix = array_is_list($value) + ? '' + : var_export($key, true) . ' => '; + + $lines[] = $childIndent + . $prefix + . $this->exportValue($item, $depth + 1, is_string($key) ? $key : null) + . ','; + } + + return "[\n" . implode("\n", $lines) . "\n" . $indent . "]"; + } + + private function exportClassReference(string $value): string + { + $normalizedValue = trim($value); + + if ($normalizedValue === '') { + return var_export($value, true); + } + + if (preg_match('/^[A-Za-z_\\\\][A-Za-z0-9_\\\\]*::class$/', $normalizedValue) === 1) { + return $normalizedValue; + } + + if (preg_match('/^[A-Za-z_\\\\][A-Za-z0-9_\\\\]*$/', $normalizedValue) === 1) { + return '\\' . ltrim($normalizedValue, '\\') . '::class'; + } + + return var_export($value, true); + } +} diff --git a/src/Editor/SceneLoader.php b/src/Editor/SceneLoader.php index 030c012..408ed5a 100644 --- a/src/Editor/SceneLoader.php +++ b/src/Editor/SceneLoader.php @@ -29,14 +29,20 @@ public function load(EditorSceneSettings $sceneSettings): ?SceneDTO $normalizedEnvironmentTileMapPath = $this->normalizeEnvironmentTileMapPath( $sceneData['environmentTileMapPath'] ?? $sourceSceneData['environmentTileMapPath'] ?? 'Maps/example', ); + $normalizedEnvironmentCollisionMapPath = $this->normalizeEnvironmentCollisionMapPath( + $sceneData['environmentCollisionMapPath'] ?? $sourceSceneData['environmentCollisionMapPath'] ?? '', + ); $sceneData['environmentTileMapPath'] = $normalizedEnvironmentTileMapPath; $sourceSceneData['environmentTileMapPath'] = $normalizedEnvironmentTileMapPath; + $sceneData['environmentCollisionMapPath'] = $normalizedEnvironmentCollisionMapPath; + $sourceSceneData['environmentCollisionMapPath'] = $normalizedEnvironmentCollisionMapPath; return new SceneDTO( name: basename($scenePath, '.scene.php'), width: $sceneData['width'] ?? DEFAULT_TERMINAL_WIDTH, height: $sceneData['height'] ?? DEFAULT_TERMINAL_HEIGHT, environmentTileMapPath: $normalizedEnvironmentTileMapPath, + environmentCollisionMapPath: $normalizedEnvironmentCollisionMapPath, isDirty: $sceneData['isDirty'] ?? false, hierarchy: $sceneData['hierarchy'] ?? [], sourcePath: $scenePath, @@ -102,6 +108,21 @@ private function normalizeEnvironmentTileMapPath(mixed $value): string return preg_replace('/\.tmap$/i', '', $normalizedValue) ?? $normalizedValue; } + private function normalizeEnvironmentCollisionMapPath(mixed $value): string + { + if (!is_string($value)) { + return ''; + } + + $normalizedValue = trim(str_replace('\\', '/', $value)); + + if ($normalizedValue === '') { + return ''; + } + + return preg_replace('/\.tmap$/i', '', $normalizedValue) ?? $normalizedValue; + } + private function resolveConfiguredScenePath( EditorSceneSettings $sceneSettings, ?string $scenesDirectory @@ -549,6 +570,8 @@ private function extractSceneDataFromSource(string $scenePath): array return []; } + preg_match('/["\']environmentTileMapPath["\']\s*=>\s*["\']([^"\']+)["\']/', $source, $tileMapPathMatch); + preg_match('/["\']environmentCollisionMapPath["\']\s*=>\s*["\']([^"\']+)["\']/', $source, $collisionMapPathMatch); preg_match_all('/["\']name["\']\s*=>\s*["\']([^"\']+)["\']/', $source, $nameMatches); preg_match_all( '/["\']type["\']\s*=>\s*(?:"([^"]+)"|\'([^\']+)\'|([A-Za-z_\\\\][A-Za-z0-9_\\\\]*::class))/', @@ -574,6 +597,8 @@ private function extractSceneDataFromSource(string $scenePath): array } return [ + 'environmentTileMapPath' => $this->normalizeEnvironmentTileMapPath($tileMapPathMatch[1] ?? 'Maps/example'), + 'environmentCollisionMapPath' => $this->normalizeEnvironmentCollisionMapPath($collisionMapPathMatch[1] ?? ''), 'hierarchy' => $hierarchy, ]; } diff --git a/src/Editor/SceneWriter.php b/src/Editor/SceneWriter.php index 0dc9bfc..ea72e41 100644 --- a/src/Editor/SceneWriter.php +++ b/src/Editor/SceneWriter.php @@ -48,6 +48,7 @@ public function snapshot(SceneDTO $scene): array $sceneData['width'] = $scene->width; $sceneData['height'] = $scene->height; $sceneData['environmentTileMapPath'] = $scene->environmentTileMapPath; + $sceneData['environmentCollisionMapPath'] = $scene->environmentCollisionMapPath; $sceneData['hierarchy'] = $scene->hierarchy; unset($sceneData['isDirty']); diff --git a/src/Editor/Widgets/ConsolePanel.php b/src/Editor/Widgets/ConsolePanel.php index a4c60d3..d64b395 100644 --- a/src/Editor/Widgets/ConsolePanel.php +++ b/src/Editor/Widgets/ConsolePanel.php @@ -13,6 +13,10 @@ class ConsolePanel extends Widget private const string DIVIDER_LINE_CHARACTER = '─'; private const string TAB_DIVIDER_LINE_CHARACTER = '■'; private const array TAB_TITLES = ['Debug', 'Error']; + private const array FILTER_OPTIONS_BY_TAB = [ + 'Debug' => ['ALL', 'DEBUG', 'INFO', 'WARN', 'ERROR'], + 'Error' => ['ALL', 'ERROR', 'CRITICAL', 'FATAL'], + ]; protected array $logMessagesByTab = [ 'Debug' => [], @@ -35,6 +39,12 @@ class ConsolePanel extends Widget protected int $activeTabOffset = 0; protected int $activeTabLength = 0; protected Color $activeIndicatorColor = Color::LIGHT_CYAN; + protected array $activeFiltersByTab = [ + 'Debug' => 'ALL', + 'Error' => 'ALL', + ]; + protected OptionListModal $filterModal; + protected OptionListModal $clearConfirmModal; public function __construct( array $position = ['x' => 37, 'y' => 22], @@ -46,6 +56,8 @@ public function __construct( ) { parent::__construct('Console', '', $position, $width, $height); + $this->filterModal = new OptionListModal('Filter Logs'); + $this->clearConfirmModal = new OptionListModal('Clear Log'); $this->refreshIntervalSeconds = $refreshIntervalSeconds > 0 ? $refreshIntervalSeconds : self::DEFAULT_REFRESH_INTERVAL_SECONDS; @@ -54,6 +66,41 @@ public function __construct( $this->refreshVisibleContent(); } + public function hasActiveModal(): bool + { + return $this->filterModal->isVisible() + || $this->clearConfirmModal->isVisible(); + } + + public function isModalDirty(): bool + { + return $this->filterModal->isDirty() + || $this->clearConfirmModal->isDirty(); + } + + public function markModalClean(): void + { + $this->filterModal->markClean(); + $this->clearConfirmModal->markClean(); + } + + public function syncModalLayout(int $terminalWidth, int $terminalHeight): void + { + $this->filterModal->syncLayout($terminalWidth, $terminalHeight); + $this->clearConfirmModal->syncLayout($terminalWidth, $terminalHeight); + } + + public function renderActiveModal(): void + { + if ($this->filterModal->isVisible()) { + $this->filterModal->render(); + } + + if ($this->clearConfirmModal->isVisible()) { + $this->clearConfirmModal->render(); + } + } + public function getActiveTab(): string { return self::TAB_TITLES[$this->activeTabIndex]; @@ -75,7 +122,7 @@ public function cycleFocusBackward(): bool public function append(string $message): void { - $timestamp = date(DATE_ATOM); + $timestamp = date('Y-m-d H:i:s'); $timestampedMessage = "[$timestamp] $message"; $tabTitle = $this->resolveSessionTabTitle($timestampedMessage); $this->sessionMessagesByTab[$tabTitle][] = $timestampedMessage; @@ -146,22 +193,42 @@ public function scrollDown(): void public function update(): void { + if ($this->clearConfirmModal->isVisible()) { + $this->handleClearConfirmModalInput(); + return; + } + + if ($this->filterModal->isVisible()) { + $this->handleFilterModalInput(); + return; + } + if ($this->shouldRefreshFromLogFile()) { $this->refreshAllTabsFromLogFiles(); } - if ($this->hasFocus() && !$this->isPlayModeActive) { - if (Input::isKeyDown(KeyCode::R)) { + if ($this->hasFocus()) { + if (Input::isKeyDown(KeyCode::F)) { + $this->openFilterModal(); + return; + } + + if (Input::isKeyDown(KeyCode::C)) { + $this->openClearConfirmModal(); + return; + } + + if (!$this->isPlayModeActive && Input::isKeyDown(KeyCode::R)) { $this->refreshFromLogFile(); return; } - if (Input::isKeyDown(KeyCode::UP)) { + if (!$this->isPlayModeActive && Input::isKeyDown(KeyCode::UP)) { $this->scrollUp(); return; } - if (Input::isKeyDown(KeyCode::DOWN)) { + if (!$this->isPlayModeActive && Input::isKeyDown(KeyCode::DOWN)) { $this->scrollDown(); return; } @@ -318,7 +385,7 @@ private function decorateDividerLine(string $line, ?Color $contentColor, int $li private function colorizeLogTag(string $content): string { - if (preg_match('/\[(ERROR|INFO|WARN|WARNING|DEBUG)\]/', $content, $matches, PREG_OFFSET_CAPTURE) !== 1) { + if (preg_match('/\[(ERROR|CRITICAL|FATAL|INFO|WARN|WARNING|DEBUG)\]/', $content, $matches, PREG_OFFSET_CAPTURE) !== 1) { return $content; } @@ -337,6 +404,7 @@ private function resolveLogLevelColor(string $level): ?Color { return match ($level) { 'ERROR' => Color::LIGHT_RED, + 'CRITICAL', 'FATAL' => Color::RED, 'INFO' => Color::LIGHT_BLUE, 'WARN', 'WARNING' => Color::YELLOW, 'DEBUG' => Color::LIGHT_GRAY, @@ -407,10 +475,12 @@ private function rebuildMessagesForTab(string $tabTitle): void private function messagesForTab(string $tabTitle): array { - return [ + $messages = [ ...($this->logMessagesByTab[$tabTitle] ?? []), ...($this->sessionMessagesByTab[$tabTitle] ?? []), ]; + + return $this->applyActiveFilter($tabTitle, $messages); } private function shouldRefreshFromLogFile(): bool @@ -474,8 +544,8 @@ private function resolveLatestVisibleScrollOffsetForTab(string $tabTitle): int private function updateHelpInfo(): void { $this->help = $this->isPlayModeActive - ? 'Tab/Shift+Tab tabs Up/Down scroll Auto refresh on' - : 'Tab/Shift+Tab tabs Up/Down scroll Shift+R refresh'; + ? 'Tab/Shift+Tab tabs Shift+F filter Shift+C clear Auto refresh on' + : 'Tab/Shift+Tab tabs Up/Down scroll Shift+R refresh Shift+F filter Shift+C clear'; } private function buildTabsLine(): string @@ -530,4 +600,170 @@ private function persistScrollOffset(): void { $this->scrollOffsetsByTab[$this->getActiveTab()] = $this->scrollOffset; } + + private function openFilterModal(): void + { + $tabTitle = $this->getActiveTab(); + $options = self::FILTER_OPTIONS_BY_TAB[$tabTitle] ?? ['ALL']; + $currentFilter = $this->activeFiltersByTab[$tabTitle] ?? 'ALL'; + $selectedIndex = array_search($currentFilter, $options, true); + + $this->filterModal->show( + $options, + is_int($selectedIndex) ? $selectedIndex : 0, + $tabTitle . ' Filter' + ); + } + + private function handleFilterModalInput(): void + { + if (Input::isKeyDown(KeyCode::ESCAPE)) { + $this->filterModal->hide(); + return; + } + + if (Input::isKeyDown(KeyCode::UP)) { + $this->filterModal->moveSelection(-1); + return; + } + + if (Input::isKeyDown(KeyCode::DOWN)) { + $this->filterModal->moveSelection(1); + return; + } + + if (!Input::isKeyDown(KeyCode::ENTER)) { + return; + } + + $selection = $this->filterModal->getSelectedOption(); + $this->filterModal->hide(); + + if (!is_string($selection) || $selection === '') { + return; + } + + $tabTitle = $this->getActiveTab(); + $this->activeFiltersByTab[$tabTitle] = $selection; + $this->scrollOffsetsByTab[$tabTitle] = $this->resolveLatestVisibleScrollOffsetForTab($tabTitle); + $this->restoreActiveTabState(); + } + + private function openClearConfirmModal(): void + { + $logFilePath = $this->resolveLogFilePathForTab($this->getActiveTab()); + + if (!is_string($logFilePath) || $logFilePath === '' || !is_file($logFilePath)) { + return; + } + + $this->clearConfirmModal->show( + ['Cancel', 'Clear'], + 0, + 'Clear ' . basename($logFilePath) . '?' + ); + } + + private function handleClearConfirmModalInput(): void + { + if (Input::isKeyDown(KeyCode::ESCAPE)) { + $this->clearConfirmModal->hide(); + return; + } + + if (Input::isKeyDown(KeyCode::UP)) { + $this->clearConfirmModal->moveSelection(-1); + return; + } + + if (Input::isKeyDown(KeyCode::DOWN)) { + $this->clearConfirmModal->moveSelection(1); + return; + } + + if (!Input::isKeyDown(KeyCode::ENTER)) { + return; + } + + $selection = $this->clearConfirmModal->getSelectedOption(); + $this->clearConfirmModal->hide(); + + if ($selection !== 'Clear') { + return; + } + + $this->rotateAndClearActiveLogFile(); + } + + private function rotateAndClearActiveLogFile(): void + { + $tabTitle = $this->getActiveTab(); + $logFilePath = $this->resolveLogFilePathForTab($tabTitle); + + if (!is_string($logFilePath) || $logFilePath === '' || !is_file($logFilePath)) { + return; + } + + $contents = file_get_contents($logFilePath); + + if ($contents === false) { + return; + } + + $rotatedPath = $this->resolveNextRotatedLogPath($logFilePath); + + if (file_put_contents($rotatedPath, $contents) === false) { + return; + } + + if (file_put_contents($logFilePath, '') === false) { + return; + } + + $this->logMessagesByTab[$tabTitle] = []; + $this->scrollOffsetsByTab[$tabTitle] = 0; + $this->lastLogRefreshAt = microtime(true); + $this->restoreActiveTabState(); + } + + private function resolveNextRotatedLogPath(string $logFilePath): string + { + $index = 1; + + do { + $candidatePath = $logFilePath . '.' . $index; + $index++; + } while (file_exists($candidatePath)); + + return $candidatePath; + } + + private function applyActiveFilter(string $tabTitle, array $messages): array + { + $activeFilter = $this->activeFiltersByTab[$tabTitle] ?? 'ALL'; + + if ($activeFilter === 'ALL') { + return $messages; + } + + return array_values(array_filter( + $messages, + fn(string $message): bool => $this->messageMatchesFilter($message, $activeFilter) + )); + } + + private function messageMatchesFilter(string $message, string $filter): bool + { + if (preg_match('/\[(ERROR|CRITICAL|FATAL|INFO|WARN|WARNING|DEBUG)\]/', $message, $matches) !== 1) { + return false; + } + + $level = $matches[1]; + + if ($filter === 'WARN') { + return in_array($level, ['WARN', 'WARNING'], true); + } + + return $level === $filter; + } } diff --git a/src/Editor/Widgets/HierarchyPanel.php b/src/Editor/Widgets/HierarchyPanel.php index 777fda2..92b89af 100644 --- a/src/Editor/Widgets/HierarchyPanel.php +++ b/src/Editor/Widgets/HierarchyPanel.php @@ -38,6 +38,7 @@ class HierarchyPanel extends Widget implements ObservableInterface protected int $sceneWidth = DEFAULT_TERMINAL_WIDTH; protected int $sceneHeight = DEFAULT_TERMINAL_HEIGHT; protected string $environmentTileMapPath = 'Maps/example'; + protected string $environmentCollisionMapPath = ''; protected array $hierarchy = []; protected array $visibleHierarchy = []; protected array $expandedPaths = []; @@ -60,6 +61,7 @@ public function __construct( int $sceneWidth = DEFAULT_TERMINAL_WIDTH, int $sceneHeight = DEFAULT_TERMINAL_HEIGHT, string $environmentTileMapPath = 'Maps/example', + string $environmentCollisionMapPath = '', ) { $this->initializeObservers(); @@ -72,6 +74,7 @@ public function __construct( $this->sceneWidth = $sceneWidth; $this->sceneHeight = $sceneHeight; $this->environmentTileMapPath = $environmentTileMapPath; + $this->environmentCollisionMapPath = $environmentCollisionMapPath; $this->setHierarchy($hierarchy); } @@ -102,6 +105,7 @@ public function setSceneState( ?int $sceneWidth = null, ?int $sceneHeight = null, ?string $environmentTileMapPath = null, + ?string $environmentCollisionMapPath = null, ): void { $this->sceneName = $sceneName; @@ -109,6 +113,7 @@ public function setSceneState( $this->sceneWidth = $sceneWidth ?? $this->sceneWidth; $this->sceneHeight = $sceneHeight ?? $this->sceneHeight; $this->environmentTileMapPath = $environmentTileMapPath ?? $this->environmentTileMapPath; + $this->environmentCollisionMapPath = $environmentCollisionMapPath ?? $this->environmentCollisionMapPath; $this->refreshContent(); } @@ -213,6 +218,7 @@ public function activateSelection(): void 'width' => $this->sceneWidth, 'height' => $this->sceneHeight, 'environmentTileMapPath' => $this->environmentTileMapPath, + 'environmentCollisionMapPath' => $this->environmentCollisionMapPath, ], ]; return; diff --git a/src/Editor/Widgets/InspectorPanel.php b/src/Editor/Widgets/InspectorPanel.php index 59c89ee..24814ea 100644 --- a/src/Editor/Widgets/InspectorPanel.php +++ b/src/Editor/Widgets/InspectorPanel.php @@ -64,6 +64,7 @@ class InspectorPanel extends Widget protected array $controlBindings = []; protected array $controlMetadata = []; protected ?array $pendingHierarchyMutation = null; + protected ?array $pendingPrefabMutation = null; protected ?array $pendingAssetMutation = null; protected string $projectDirectory; protected array $sceneHierarchy = []; @@ -72,6 +73,7 @@ class InspectorPanel extends Widget protected bool $isComponentMoveModeActive = false; protected ?int $pendingComponentDeletionIndex = null; protected string $modeHelpLabel = ''; + protected bool $shouldRefreshModalBackground = false; public function __construct( array $position = ['x' => 135, 'y' => 1], @@ -120,6 +122,7 @@ public function inspectTarget(?array $target): void $this->controlBindings = []; $this->controlMetadata = []; $this->pendingHierarchyMutation = null; + $this->pendingPrefabMutation = null; $this->pendingAssetMutation = null; $this->componentMenuDefinitions = []; $this->isComponentMoveModeActive = false; @@ -135,7 +138,9 @@ public function inspectTarget(?array $target): void $context = $target['context'] ?? null; $value = $target['value'] ?? null; - if ($context === 'hierarchy' && is_array($value)) { + if ($context === 'prefab' && is_array($value)) { + $this->buildPrefabControls($target, $value); + } elseif ($context === 'hierarchy' && is_array($value)) { $this->buildHierarchyControls($target, $value); } elseif ($context === 'scene' && is_array($value)) { $this->buildSceneControls($target, $value); @@ -236,6 +241,14 @@ public function consumeHierarchyMutation(): ?array return $pendingHierarchyMutation; } + public function consumePrefabMutation(): ?array + { + $pendingPrefabMutation = $this->pendingPrefabMutation; + $this->pendingPrefabMutation = null; + + return $pendingPrefabMutation; + } + public function consumeAssetMutation(): ?array { $pendingAssetMutation = $this->pendingAssetMutation; @@ -327,6 +340,14 @@ public function syncAssetTarget(array $value): void $this->restoreSelectedControlSnapshot($selectedControlSnapshot); } + public function consumeModalBackgroundRefreshRequest(): bool + { + $shouldRefreshModalBackground = $this->shouldRefreshModalBackground; + $this->shouldRefreshModalBackground = false; + + return $shouldRefreshModalBackground; + } + public function cycleFocusForward(): bool { if ($this->interactionState !== self::STATE_CONTROL_SELECTION || $this->focusableControls === []) { @@ -529,6 +550,51 @@ private function buildHierarchyControls(array $target, array $item): void $this->addScriptComponents($item['components'] ?? []); } + private function buildPrefabControls(array $target, array $item): void + { + $asset = is_array($target['asset'] ?? null) ? $target['asset'] : []; + $assetName = $asset['name'] ?? basename((string) ($asset['path'] ?? '')); + + $this->addControl(new TextInputControl('Type', $this->resolveDisplayType($target, $item), 0, true)); + $this->addControl( + new TextInputControl('File Name', $assetName, 0), + ['kind' => 'prefab_file_name'], + ); + $this->addBoundControl( + new TextInputControl('Name', $item['name'] ?? $target['name'] ?? 'Unnamed Object', 0), + ['name'], + ); + $this->addBoundControl( + new TextInputControl('Tag', $item['tag'] ?? 'None', 0), + ['tag'], + ); + + $this->addControl($this->addSectionHeader('Transform')); + $this->addBoundControl( + new VectorInputControl('Position', $this->normalizeVector($item['position'] ?? null), 1), + ['position'], + ); + $this->addBoundControl( + new VectorInputControl('Rotation', $this->normalizeVector($item['rotation'] ?? null), 1), + ['rotation'], + ); + $this->addBoundControl( + new VectorInputControl('Scale', $this->normalizeVector($item['scale'] ?? ['x' => 1, 'y' => 1]), 1), + ['scale'], + ); + + if (isset($item['size']) && is_array($item['size'])) { + $this->addBoundControl( + new VectorInputControl('Size', $this->normalizeVector($item['size']), 1), + ['size'], + ); + } + + $this->addControl($this->addSectionHeader('Renderer')); + $this->addRendererControls($item); + $this->addScriptComponents($item['components'] ?? []); + } + private function buildSceneControls(array $target, array $scene): void { $this->addControl(new TextInputControl('Type', 'Scene', 0, true)); @@ -891,14 +957,20 @@ private function updateHelpInfo(): void return; } - if ($this->isComponentMoveModeActive && $this->isSelectedComponentHeader($selectedControl)) { + if ( + $this->isComponentMoveModeActive + && $this->isSelectedComponentHeader($selectedControl) + && $this->canMutateCurrentComponentList() + ) { $this->help = 'Up/Down reorder Shift+W done Esc cancel'; $this->modeHelpLabel = 'Mode: Component Move'; return; } if ($this->isSelectedComponentHeader($selectedControl)) { - $this->help = 'Up/Down select / toggle Shift+A add Shift+W move Del remove'; + $this->help = $this->canMutateCurrentComponentList() + ? 'Up/Down select / toggle Shift+A add Shift+W move Del remove' + : 'Up/Down select / toggle Tab next'; $this->modeHelpLabel = 'Mode: Control Select'; return; } @@ -1035,17 +1107,25 @@ private function handleControlSelectionInput(InputControl $selectedControl): voi return; } - if (Input::getCurrentInput() === 'W') { + if (Input::getCurrentInput() === 'W' && $this->canMutateCurrentComponentList()) { $this->handleComponentMoveModeToggle($selectedControl); return; } - if (Input::isKeyDown(KeyCode::DELETE) && $this->isSelectedComponentHeader($selectedControl)) { + if ( + Input::isKeyDown(KeyCode::DELETE) + && $this->isSelectedComponentHeader($selectedControl) + && $this->canMutateCurrentComponentList() + ) { $this->showDeleteComponentModal($selectedControl); return; } - if ($this->isComponentMoveModeActive && $this->isSelectedComponentHeader($selectedControl)) { + if ( + $this->isComponentMoveModeActive + && $this->isSelectedComponentHeader($selectedControl) + && $this->canMutateCurrentComponentList() + ) { if (Input::isKeyDown(KeyCode::ESCAPE)) { $this->isComponentMoveModeActive = false; return; @@ -1339,6 +1419,7 @@ private function handlePathInputActionInput(): void } if ($selectedOption === 'Edit path' && $this->activePathInputControl instanceof PathInputControl) { + $this->requestModalBackgroundRefresh(); $this->pathInputActionModal->hide(); if ($this->activePathInputControl->enterEditMode()) { @@ -1353,6 +1434,7 @@ private function handlePathInputActionInput(): void private function handlePathInputFileDialogInput(): void { if (Input::isKeyDown(KeyCode::ESCAPE)) { + $this->requestModalBackgroundRefresh(); $this->fileDialogModal->hide(); if ($this->activePathInputControl instanceof PathInputControl) { @@ -1420,15 +1502,56 @@ private function closePathInputModals(): void $this->activePathInputControl = null; } + private function requestModalBackgroundRefresh(): void + { + $this->shouldRefreshModalBackground = true; + } + private function canOpenAddComponentModal(): bool { return is_array($this->inspectionTarget) - && ($this->inspectionTarget['context'] ?? null) === 'hierarchy' + && in_array($this->inspectionTarget['context'] ?? null, ['hierarchy', 'prefab'], true) && is_string($this->inspectionTarget['path'] ?? null) && ($this->inspectionTarget['path'] ?? null) !== 'scene' && is_array($this->inspectionTarget['value'] ?? null); } + private function queueObjectInspectionMutation(array $target, array $inspectionValue): void + { + $context = $target['context'] ?? null; + $path = $target['path'] ?? null; + + if (!is_string($path) || $path === '') { + return; + } + + if ($context === 'hierarchy') { + $this->pendingHierarchyMutation = [ + 'path' => $path, + 'value' => $inspectionValue, + ]; + return; + } + + if ($context !== 'prefab') { + return; + } + + $asset = is_array($target['asset'] ?? null) ? $target['asset'] : []; + $prefabPath = is_string($asset['path'] ?? null) ? $asset['path'] : null; + + if (!is_string($prefabPath) || $prefabPath === '') { + return; + } + + $this->pendingPrefabMutation = [ + 'path' => $path, + 'prefabPath' => $prefabPath, + 'asset' => $asset, + 'value' => $inspectionValue, + ]; + } + private function showAddComponentModal(): void { $this->componentMenuDefinitions = $this->resolveAvailableComponentDefinitions(); @@ -1944,7 +2067,7 @@ private function appendComponentToInspectionTarget(array $componentDefinition): { if ( !is_array($this->inspectionTarget) - || ($this->inspectionTarget['context'] ?? null) !== 'hierarchy' + || !in_array($this->inspectionTarget['context'] ?? null, ['hierarchy', 'prefab'], true) || !is_string($this->inspectionTarget['path'] ?? null) || !is_array($this->inspectionTarget['value'] ?? null) ) { @@ -1974,10 +2097,7 @@ private function appendComponentToInspectionTarget(array $componentDefinition): $updatedTarget = $this->inspectionTarget; $updatedTarget['value'] = $inspectionValue; $this->inspectTarget($updatedTarget); - $this->pendingHierarchyMutation = [ - 'path' => $updatedTarget['path'], - 'value' => $inspectionValue, - ]; + $this->queueObjectInspectionMutation($updatedTarget, $inspectionValue); } private function getSelectedControlMetadata(?InputControl $control): array @@ -2116,7 +2236,7 @@ private function isSelectedComponentHeader(?InputControl $control): bool private function handleComponentMoveModeToggle(InputControl $selectedControl): void { - if (!$this->isSelectedComponentHeader($selectedControl)) { + if (!$this->isSelectedComponentHeader($selectedControl) || !$this->canMutateCurrentComponentList()) { $this->isComponentMoveModeActive = false; return; } @@ -2124,6 +2244,14 @@ private function handleComponentMoveModeToggle(InputControl $selectedControl): v $this->isComponentMoveModeActive = !$this->isComponentMoveModeActive; } + private function canMutateCurrentComponentList(): bool + { + return is_array($this->inspectionTarget) + && in_array($this->inspectionTarget['context'] ?? null, ['hierarchy', 'prefab'], true) + && is_string($this->inspectionTarget['path'] ?? null) + && $this->inspectionTarget['path'] !== ''; + } + private function showDeleteComponentModal(InputControl $selectedControl): void { $metadata = $this->getSelectedControlMetadata($selectedControl); @@ -2163,7 +2291,7 @@ private function removeComponentAtIndex(int $componentIndex): void { if ( !is_array($this->inspectionTarget) - || ($this->inspectionTarget['context'] ?? null) !== 'hierarchy' + || !in_array($this->inspectionTarget['context'] ?? null, ['hierarchy', 'prefab'], true) || !is_string($this->inspectionTarget['path'] ?? null) || !is_array($this->inspectionTarget['value'] ?? null) ) { @@ -2199,7 +2327,7 @@ private function moveSelectedComponent(int $direction): void !is_int($componentIndex) || !in_array($direction, [-1, 1], true) || !is_array($this->inspectionTarget) - || ($this->inspectionTarget['context'] ?? null) !== 'hierarchy' + || !in_array($this->inspectionTarget['context'] ?? null, ['hierarchy', 'prefab'], true) || !is_array($this->inspectionTarget['value'] ?? null) ) { return; @@ -2232,7 +2360,7 @@ private function rebuildHierarchyInspection( { if ( !is_array($this->inspectionTarget) - || ($this->inspectionTarget['context'] ?? null) !== 'hierarchy' + || !in_array($this->inspectionTarget['context'] ?? null, ['hierarchy', 'prefab'], true) || !is_string($this->inspectionTarget['path'] ?? null) ) { return; @@ -2249,10 +2377,7 @@ private function rebuildHierarchyInspection( } $this->isComponentMoveModeActive = $preserveMoveMode && is_int($focusComponentIndex); - $this->pendingHierarchyMutation = [ - 'path' => $updatedTarget['path'], - 'value' => $inspectionValue, - ]; + $this->queueObjectInspectionMutation($updatedTarget, $inspectionValue); } private function focusComponentHeaderByIndex(int $componentIndex): void @@ -2463,9 +2588,32 @@ private function humanizeKey(string $key): string private function applyControlValueToInspectionTarget(InputControl $control): void { + if (!is_array($this->inspectionTarget)) { + return; + } + + $controlMetadata = $this->getSelectedControlMetadata($control); + $context = $this->inspectionTarget['context'] ?? null; + if ( - !is_array($this->inspectionTarget) - || !isset($this->inspectionTarget['value']) + ($controlMetadata['kind'] ?? null) === 'prefab_file_name' + && $context === 'prefab' + && is_array($this->inspectionTarget['asset'] ?? null) + ) { + $asset = $this->inspectionTarget['asset']; + $asset['name'] = (string) $control->getValue(); + $this->inspectionTarget['asset'] = $asset; + $this->pendingAssetMutation = [ + 'path' => $asset['path'] ?? null, + 'relativePath' => $asset['relativePath'] ?? null, + 'name' => (string) $control->getValue(), + 'activatePrefab' => true, + ]; + return; + } + + if ( + !isset($this->inspectionTarget['value']) || !is_array($this->inspectionTarget['value']) ) { return; @@ -2485,8 +2633,6 @@ private function applyControlValueToInspectionTarget(InputControl $control): voi $this->inspectionTarget['name'] = (string) $control->getValue(); } - $context = $this->inspectionTarget['context'] ?? null; - if ($context === 'asset') { if ( $valuePath === ['name'] @@ -2504,6 +2650,10 @@ private function applyControlValueToInspectionTarget(InputControl $control): voi } if (!in_array($context, ['hierarchy', 'scene'], true)) { + if ($context === 'prefab') { + $this->queueObjectInspectionMutation($this->inspectionTarget, $inspectionValue); + } + return; } diff --git a/src/Editor/Widgets/MainPanel.php b/src/Editor/Widgets/MainPanel.php index 947dcdb..6151f76 100644 --- a/src/Editor/Widgets/MainPanel.php +++ b/src/Editor/Widgets/MainPanel.php @@ -556,33 +556,36 @@ private function decorateSpriteLine(string $line, ?Color $contentColor, int $con return parent::decorateContentLine($line, $contentColor, $contentIndex); } - $visibleLine = mb_substr($line, 0, $this->width); - $visibleLength = mb_strlen($visibleLine); + $visibleLine = $this->clipContentToWidth($line, $this->width); + $visibleCharacterLength = mb_strlen($visibleLine); - if ($visibleLength <= 1) { + if ($visibleCharacterLength <= 1) { return parent::decorateContentLine($line, $contentColor, $contentIndex); } $leftBorder = mb_substr($visibleLine, 0, 1); - $middle = $visibleLength > 2 ? mb_substr($visibleLine, 1, $visibleLength - 2) : ''; + $middle = $visibleCharacterLength > 2 ? mb_substr($visibleLine, 1, $visibleCharacterLength - 2) : ''; $rightBorder = mb_substr($visibleLine, -1); $borderColor = $this->hasFocus() ? $this->focusBorderColor : $contentColor; + $middleWidth = $this->getDisplayWidth($middle); $highlightStart = min( max(0, $this->padding->leftPadding + (int) ($highlight['start'] ?? 0)), - mb_strlen($middle), + $middleWidth, ); $highlightLength = max( 0, - min((int) ($highlight['length'] ?? 0), mb_strlen($middle) - $highlightStart), + min((int) ($highlight['length'] ?? 0), $middleWidth - $highlightStart), ); if ($highlightLength === 0) { return parent::decorateContentLine($line, $contentColor, $contentIndex); } - $beforeHighlight = mb_substr($middle, 0, $highlightStart); - $highlightText = mb_substr($middle, $highlightStart, $highlightLength); - $afterHighlight = mb_substr($middle, $highlightStart + $highlightLength); + [ + 'before' => $beforeHighlight, + 'highlight' => $highlightText, + 'after' => $afterHighlight, + ] = $this->splitContentByDisplayWidth($middle, $highlightStart, $highlightLength); $highlightSequence = $this->hasFocus() ? self::SPRITE_CURSOR_FOCUSED_SEQUENCE : self::SPRITE_CURSOR_SEQUENCE; @@ -1115,33 +1118,36 @@ private function decorateSceneLine(string $line, ?Color $contentColor, int $cont return parent::decorateContentLine($line, $contentColor, $contentIndex); } - $visibleLine = mb_substr($line, 0, $this->width); - $visibleLength = mb_strlen($visibleLine); + $visibleLine = $this->clipContentToWidth($line, $this->width); + $visibleCharacterLength = mb_strlen($visibleLine); - if ($visibleLength <= 1) { + if ($visibleCharacterLength <= 1) { return parent::decorateContentLine($line, $contentColor, $contentIndex); } $leftBorder = mb_substr($visibleLine, 0, 1); - $middle = $visibleLength > 2 ? mb_substr($visibleLine, 1, $visibleLength - 2) : ''; + $middle = $visibleCharacterLength > 2 ? mb_substr($visibleLine, 1, $visibleCharacterLength - 2) : ''; $rightBorder = mb_substr($visibleLine, -1); $borderColor = $this->hasFocus() ? $this->focusBorderColor : $contentColor; + $middleWidth = $this->getDisplayWidth($middle); $highlightStart = min( max(0, $this->padding->leftPadding + (int) ($highlight['start'] ?? 0)), - mb_strlen($middle), + $middleWidth, ); $highlightLength = max( 0, - min((int) ($highlight['length'] ?? 0), mb_strlen($middle) - $highlightStart), + min((int) ($highlight['length'] ?? 0), $middleWidth - $highlightStart), ); if ($highlightLength === 0) { return parent::decorateContentLine($line, $contentColor, $contentIndex); } - $beforeHighlight = mb_substr($middle, 0, $highlightStart); - $highlightText = mb_substr($middle, $highlightStart, $highlightLength); - $afterHighlight = mb_substr($middle, $highlightStart + $highlightLength); + [ + 'before' => $beforeHighlight, + 'highlight' => $highlightText, + 'after' => $afterHighlight, + ] = $this->splitContentByDisplayWidth($middle, $highlightStart, $highlightLength); if (($highlight['kind'] ?? null) === 'placeholder') { return $this->wrapWithColor($leftBorder, $borderColor) @@ -1384,6 +1390,10 @@ private function loadSpriteGridFromFile(string $absolutePath, string $extension) return $this->expandSpriteGrid($grid, self::DEFAULT_TEXTURE_WIDTH, self::DEFAULT_TEXTURE_HEIGHT); } + if ($extension === 'tmap') { + return $this->expandSpriteGrid($grid, $defaultWidth, $defaultHeight); + } + return $grid; } diff --git a/src/Editor/Widgets/Widget.php b/src/Editor/Widgets/Widget.php index 7966696..4a50073 100644 --- a/src/Editor/Widgets/Widget.php +++ b/src/Editor/Widgets/Widget.php @@ -139,6 +139,11 @@ public function renderActiveModal(): void { } + public function consumeModalBackgroundRefreshRequest(): bool + { + return false; + } + public function setTopSibling(?Widget $widget): void { $this->topSibling = $widget; @@ -434,6 +439,61 @@ protected function clipContentToWidth(string $content, int $maxWidth): string return mb_strimwidth($content, 0, $maxWidth, '', 'UTF-8'); } + /** + * @return array{before: string, highlight: string, after: string} + */ + protected function splitContentByDisplayWidth(string $content, int $highlightStart, int $highlightLength): array + { + $highlightStart = max(0, $highlightStart); + $highlightLength = max(0, $highlightLength); + + if ($content === '' || $highlightLength === 0) { + return [ + 'before' => $content, + 'highlight' => '', + 'after' => '', + ]; + } + + $characters = preg_split('//u', $content, -1, PREG_SPLIT_NO_EMPTY); + + if (!is_array($characters) || $characters === []) { + return [ + 'before' => $content, + 'highlight' => '', + 'after' => '', + ]; + } + + $before = ''; + $highlight = ''; + $after = ''; + $currentWidth = 0; + $highlightEnd = $highlightStart + $highlightLength; + + foreach ($characters as $character) { + $characterWidth = max(1, $this->getDisplayWidth($character)); + $characterStart = $currentWidth; + $characterEnd = $currentWidth + $characterWidth; + + if ($characterEnd <= $highlightStart) { + $before .= $character; + } elseif ($characterStart >= $highlightEnd) { + $after .= $character; + } else { + $highlight .= $character; + } + + $currentWidth = $characterEnd; + } + + return [ + 'before' => $before, + 'highlight' => $highlight, + 'after' => $after, + ]; + } + protected function buildBorderLine(string $label, bool $isTopBorder): string { $availableLabelWidth = max(0, $this->width - 3); diff --git a/tests/Unit/ConsolePanelTest.php b/tests/Unit/ConsolePanelTest.php index 3ef26b7..335b381 100644 --- a/tests/Unit/ConsolePanelTest.php +++ b/tests/Unit/ConsolePanelTest.php @@ -1,6 +1,17 @@ setAccessible(true); + $previousKeyPress->setAccessible(true); + $previousKeyPress->setValue(''); + $currentKeyPress->setValue($keyPress); +} test('console panel loads the last three debug log lines on startup', function () { $workspace = sys_get_temp_dir() . '/sendama-console-panel-' . uniqid(); @@ -338,3 +349,163 @@ 'line 5', ]); }); + +test('console panel opens a filter modal on shift+f and filters debug logs by level', function () { + $workspace = sys_get_temp_dir() . '/sendama-console-panel-' . uniqid(); + mkdir($workspace . '/logs', 0777, true); + + file_put_contents( + $workspace . '/logs/debug.log', + implode(PHP_EOL, [ + '[2026-03-13 10:00:00] [DEBUG] - First', + '[2026-03-13 10:00:01] [INFO] - Second', + '[2026-03-13 10:00:02] [WARN] - Third', + '[2026-03-13 10:00:03] [ERROR] - Fourth', + ]) . PHP_EOL + ); + + $panel = new ConsolePanel( + width: 60, + height: 8, + logFilePath: $workspace . '/logs/debug.log', + ); + + $hasFocus = new ReflectionProperty(Widget::class, 'hasFocus'); + $hasFocus->setAccessible(true); + $hasFocus->setValue($panel, true); + + pressConsoleKey('F'); + $panel->update(); + + expect($panel->hasActiveModal())->toBeTrue(); + + pressConsoleKey("\033[B"); + $panel->update(); + pressConsoleKey("\033[B"); + $panel->update(); + pressConsoleKey("\n"); + $panel->update(); + + expect($panel->hasActiveModal())->toBeFalse(); + expect(array_slice($panel->content, 2))->toBe([ + '[2026-03-13 10:00:01] [INFO] - Second', + ]); +}); + +test('console panel filters error logs with error-tab-specific levels', function () { + $workspace = sys_get_temp_dir() . '/sendama-console-panel-' . uniqid(); + mkdir($workspace . '/logs', 0777, true); + + file_put_contents( + $workspace . '/logs/error.log', + implode(PHP_EOL, [ + '[2026-03-13 10:00:00] [ERROR] - First', + '[2026-03-13 10:00:01] [CRITICAL] - Second', + '[2026-03-13 10:00:02] [FATAL] - Third', + ]) . PHP_EOL + ); + + $panel = new ConsolePanel( + width: 60, + height: 8, + errorLogFilePath: $workspace . '/logs/error.log', + ); + + $hasFocus = new ReflectionProperty(Widget::class, 'hasFocus'); + $hasFocus->setAccessible(true); + $hasFocus->setValue($panel, true); + + $panel->cycleFocusForward(); + + pressConsoleKey('F'); + $panel->update(); + pressConsoleKey("\033[B"); + $panel->update(); + pressConsoleKey("\033[B"); + $panel->update(); + pressConsoleKey("\033[B"); + $panel->update(); + pressConsoleKey("\n"); + $panel->update(); + + expect($panel->getActiveTab())->toBe('Error'); + expect(array_slice($panel->content, 2))->toBe([ + '[2026-03-13 10:00:02] [FATAL] - Third', + ]); +}); + +test('console panel rotates and clears the active log file on confirmed shift+c', function () { + $workspace = sys_get_temp_dir() . '/sendama-console-panel-' . uniqid(); + mkdir($workspace . '/logs', 0777, true); + $logFilePath = $workspace . '/logs/debug.log'; + + file_put_contents( + $logFilePath, + implode(PHP_EOL, [ + '[2026-03-13 10:00:00] [DEBUG] - First', + '[2026-03-13 10:00:01] [INFO] - Second', + ]) . PHP_EOL + ); + + $panel = new ConsolePanel( + width: 60, + height: 8, + logFilePath: $logFilePath, + ); + + $hasFocus = new ReflectionProperty(Widget::class, 'hasFocus'); + $hasFocus->setAccessible(true); + $hasFocus->setValue($panel, true); + + pressConsoleKey('C'); + $panel->update(); + + expect($panel->hasActiveModal())->toBeTrue(); + + pressConsoleKey("\033[B"); + $panel->update(); + pressConsoleKey("\n"); + $panel->update(); + + expect($panel->hasActiveModal())->toBeFalse(); + expect(file_get_contents($logFilePath))->toBe(''); + expect(file_get_contents($logFilePath . '.1'))->toContain('[2026-03-13 10:00:00] [DEBUG] - First'); + expect(array_slice($panel->content, 2))->toBe([]); +}); + +test('console panel leaves the active log file unchanged when clear is cancelled', function () { + $workspace = sys_get_temp_dir() . '/sendama-console-panel-' . uniqid(); + mkdir($workspace . '/logs', 0777, true); + $logFilePath = $workspace . '/logs/error.log'; + + file_put_contents( + $logFilePath, + implode(PHP_EOL, [ + '[2026-03-13 10:00:00] [ERROR] - First', + '[2026-03-13 10:00:01] [FATAL] - Second', + ]) . PHP_EOL + ); + + $panel = new ConsolePanel( + width: 60, + height: 8, + errorLogFilePath: $logFilePath, + ); + + $hasFocus = new ReflectionProperty(Widget::class, 'hasFocus'); + $hasFocus->setAccessible(true); + $hasFocus->setValue($panel, true); + $panel->cycleFocusForward(); + + pressConsoleKey('C'); + $panel->update(); + + expect($panel->hasActiveModal())->toBeTrue(); + + pressConsoleKey("\n"); + $panel->update(); + + expect($panel->hasActiveModal())->toBeFalse(); + expect(file_get_contents($logFilePath))->toContain('[2026-03-13 10:00:01] [FATAL] - Second'); + expect(file_exists($logFilePath . '.1'))->toBeFalse(); +}); diff --git a/tests/Unit/EditorAssetRenameTest.php b/tests/Unit/EditorAssetRenameTest.php index 6f6345a..1ea2ad3 100644 --- a/tests/Unit/EditorAssetRenameTest.php +++ b/tests/Unit/EditorAssetRenameTest.php @@ -108,3 +108,51 @@ class PlayerController extends Behaviour expect(file_get_contents($workspace . '/Assets/Events/EnemyDiedEvent.php')) ->toContain('class EnemyDiedEvent extends Event'); }); + +test('renaming a prefab asset preserves the .prefab.php suffix', function () { + $workspace = sys_get_temp_dir() . '/sendama-editor-prefab-rename-' . uniqid(); + mkdir($workspace . '/Assets/Prefabs', 0777, true); + $prefabPath = $workspace . '/Assets/Prefabs/enemy.prefab.php'; + + file_put_contents($prefabPath, <<<'PHP' + \Sendama\Engine\Core\GameObject::class, + 'name' => 'Enemy', +]; +PHP); + + $editorReflection = new ReflectionClass(Editor::class); + $editor = $editorReflection->newInstanceWithoutConstructor(); + + $workingDirectory = $editorReflection->getProperty('workingDirectory'); + $assetsDirectoryPath = $editorReflection->getProperty('assetsDirectoryPath'); + $consolePanel = $editorReflection->getProperty('consolePanel'); + $workingDirectory->setAccessible(true); + $assetsDirectoryPath->setAccessible(true); + $consolePanel->setAccessible(true); + $workingDirectory->setValue($editor, $workspace); + $assetsDirectoryPath->setValue($editor, $workspace . '/Assets'); + $consolePanel->setValue($editor, new ConsolePanel()); + + $renameMethod = $editorReflection->getMethod('renameAssetAndCascadeReferences'); + $renameMethod->setAccessible(true); + + $renamedAsset = $renameMethod->invoke( + $editor, + $prefabPath, + 'Prefabs/enemy.prefab.php', + 'boss', + ); + + expect($renamedAsset)->toBe([ + 'name' => 'boss.prefab.php', + 'path' => $workspace . '/Assets/Prefabs/boss.prefab.php', + 'relativePath' => 'Prefabs/boss.prefab.php', + 'isDirectory' => false, + 'children' => [], + ]); + expect(file_exists($prefabPath))->toBeFalse(); + expect(is_file($workspace . '/Assets/Prefabs/boss.prefab.php'))->toBeTrue(); +}); diff --git a/tests/Unit/EditorAssetSelectionTest.php b/tests/Unit/EditorAssetSelectionTest.php index ef0b75e..c76e5d6 100644 --- a/tests/Unit/EditorAssetSelectionTest.php +++ b/tests/Unit/EditorAssetSelectionTest.php @@ -49,6 +49,35 @@ ->and($focusedPanel->getValue($editor))->toBe($mainPanel); }); +test('editor loads the selected prefab asset into a hierarchy-style inspector on enter activation', function () { + $workspace = createEditorPrefabSelectionWorkspace(); + [$editor, $reflection, $assetsPanel, $mainPanel, $inspectorPanel] = createEditorForAssetSelection($workspace); + + $assetsPanel->expandSelection(); + $assetsPanel->expandSelection(); + $assetsPanel->activateSelection(); + + $synchronizeInspectorPanel = $reflection->getMethod('synchronizeInspectorPanel'); + $synchronizeInspectorPanel->setAccessible(true); + $synchronizeInspectorPanel->invoke($editor); + + $inspectionTarget = new ReflectionProperty(InspectorPanel::class, 'inspectionTarget'); + $inspectionTarget->setAccessible(true); + $contentText = implode("\n", $inspectorPanel->content); + + expect($inspectionTarget->getValue($inspectorPanel))->toMatchArray([ + 'context' => 'prefab', + 'name' => 'Enemy', + 'type' => 'GameObject', + ]); + expect($contentText)->toContain('Type: GameObject') + ->toContain('Name: Enemy') + ->toContain('Tag: Enemy') + ->toContain('▼ EnemyComponent') + ->toContain('Move Speed: 1') + ->and($mainPanel->getActiveTab())->toBe('Scene'); +}); + function createEditorAssetSelectionWorkspace(): string { $workspace = sys_get_temp_dir() . '/sendama-editor-asset-selection-' . uniqid(); @@ -58,6 +87,103 @@ function createEditorAssetSelectionWorkspace(): string return $workspace; } +function createEditorPrefabSelectionWorkspace(): string +{ + $workspace = sys_get_temp_dir() . '/sendama-editor-prefab-selection-' . uniqid(); + mkdir($workspace . '/Assets/Prefabs', 0777, true); + mkdir($workspace . '/vendor', 0777, true); + + file_put_contents( + $workspace . '/vendor/autoload.php', + <<<'PHP' +x; + } + + public function getY(): int + { + return $this->y; + } + } + + class Component + { + public function __construct(private ?object $gameObject = null) + { + } + } + + class GameObject + { + public function __construct( + private string $name, + private ?string $tag = null, + private ?Vector2 $position = null, + private ?Vector2 $rotation = null, + private ?Vector2 $scale = null, + private mixed $sprite = null, + ) { + } + } +} + +namespace Sendama\Game\Scripts { + use Sendama\Engine\Core\Behaviours\Attributes\SerializeField; + use Sendama\Engine\Core\Component; + + class EnemyComponent extends Component + { + public int $moveSpeed = 1; + + #[SerializeField] + protected bool $isTrigger = true; + } +} +PHP + ); + + file_put_contents( + $workspace . '/Assets/Prefabs/enemy.prefab.php', + <<<'PHP' + GameObject::class, + 'name' => 'Enemy', + 'tag' => 'Enemy', + 'position' => ['x' => 60, 'y' => 12], + 'rotation' => ['x' => 0, 'y' => 0], + 'scale' => ['x' => 1, 'y' => 1], + 'components' => [ + ['class' => EnemyComponent::class], + ], +]; +PHP + ); + + return $workspace; +} + function createEditorForAssetSelection(string $workspace): array { $editorReflection = new ReflectionClass(Editor::class); diff --git a/tests/Unit/HierarchyPanelTest.php b/tests/Unit/HierarchyPanelTest.php index 314dc1d..8e32668 100644 --- a/tests/Unit/HierarchyPanelTest.php +++ b/tests/Unit/HierarchyPanelTest.php @@ -130,6 +130,7 @@ sceneWidth: 80, sceneHeight: 25, environmentTileMapPath: 'Maps/level', + environmentCollisionMapPath: 'Maps/level.collider', hierarchy: [ ['type' => 'Sendama\\Engine\\Core\\GameObject', 'name' => 'Player'], ], @@ -147,6 +148,7 @@ 'width' => 80, 'height' => 25, 'environmentTileMapPath' => 'Maps/level', + 'environmentCollisionMapPath' => 'Maps/level.collider', ], ]); }); diff --git a/tests/Unit/InputManagerTest.php b/tests/Unit/InputManagerTest.php index 1edf5d4..08fcc09 100644 --- a/tests/Unit/InputManagerTest.php +++ b/tests/Unit/InputManagerTest.php @@ -119,10 +119,13 @@ test('input manager treats repeated arrow input as both pressed and down', function () { $keyPress = new ReflectionProperty(InputManager::class, 'keyPress'); $previousKeyPress = new ReflectionProperty(InputManager::class, 'previousKeyPress'); + $currentKeyPressWasBuffered = new ReflectionProperty(InputManager::class, 'currentKeyPressWasBuffered'); $keyPress->setAccessible(true); $previousKeyPress->setAccessible(true); + $currentKeyPressWasBuffered->setAccessible(true); $previousKeyPress->setValue("\033[C"); $keyPress->setValue("\033[C"); + $currentKeyPressWasBuffered->setValue(true); expect(InputManager::isKeyPressed(KeyCode::RIGHT))->toBeTrue(); expect(InputManager::isKeyDown(KeyCode::RIGHT))->toBeTrue(); @@ -154,3 +157,18 @@ expect($resolveCurrentKeyPress->invoke(null, '', 10.04))->toBe("\033[C"); expect($resolveCurrentKeyPress->invoke(null, '', 10.06))->toBe(''); }); + +test('input manager does not treat held repeatable fallback frames as pressed or down', function () { + $keyPress = new ReflectionProperty(InputManager::class, 'keyPress'); + $previousKeyPress = new ReflectionProperty(InputManager::class, 'previousKeyPress'); + $currentKeyPressWasBuffered = new ReflectionProperty(InputManager::class, 'currentKeyPressWasBuffered'); + $keyPress->setAccessible(true); + $previousKeyPress->setAccessible(true); + $currentKeyPressWasBuffered->setAccessible(true); + $previousKeyPress->setValue("\033[C"); + $keyPress->setValue("\033[C"); + $currentKeyPressWasBuffered->setValue(false); + + expect(InputManager::isKeyPressed(KeyCode::RIGHT))->toBeFalse(); + expect(InputManager::isKeyDown(KeyCode::RIGHT))->toBeFalse(); +}); diff --git a/tests/Unit/InspectorPanelTest.php b/tests/Unit/InspectorPanelTest.php index 6cef62e..4769e1a 100644 --- a/tests/Unit/InspectorPanelTest.php +++ b/tests/Unit/InspectorPanelTest.php @@ -620,6 +620,7 @@ class PlayerController extends Behaviour 'width' => 80, 'height' => 25, 'environmentTileMapPath' => 'Maps/level', + 'environmentCollisionMapPath' => 'Maps/level.collider', ], ]); @@ -628,7 +629,8 @@ class PlayerController extends Behaviour 'Name: level01', 'Width: 80', 'Height: 25', - 'Environment Tile Map: Maps/level', + 'Map: Maps/level', + 'Collider: Maps/level.collider', ]); }); @@ -749,6 +751,116 @@ class PlayerController extends Behaviour } }); +test('inspector panel requests a background refresh when a larger file dialog closes back to the path action modal', function () { + $workspace = sys_get_temp_dir() . '/sendama-inspector-path-refresh-' . uniqid(); + mkdir($workspace . '/Assets/Maps', 0777, true); + file_put_contents($workspace . '/Assets/Maps/example.tmap', "xx\n"); + $panel = new InspectorPanel(width: 48, height: 24, workingDirectory: $workspace); + + $panel->inspectTarget([ + 'context' => 'scene', + 'name' => 'level01', + 'type' => 'Scene', + 'path' => 'scene', + 'value' => [ + 'name' => 'level01', + 'width' => 80, + 'height' => 25, + 'environmentTileMapPath' => 'Maps/example', + ], + ]); + + focusInspectorPanel($panel); + selectInspectorControlByLabel($panel, 'Map'); + + setInspectorInput("\n"); + $panel->update(); + + setInspectorInput("\n"); + $panel->update(); + + setInspectorInput(chr(27)); + $panel->update(); + + expect($panel->consumeModalBackgroundRefreshRequest())->toBeTrue() + ->and($panel->hasActiveModal())->toBeTrue(); +}); + +test('inspector panel separates prefab file renames from prefab metadata edits', function () { + $panel = new InspectorPanel(width: 48, height: 24); + $panel->inspectTarget([ + 'context' => 'prefab', + 'path' => 'Prefabs/enemy.prefab.php', + 'name' => 'Enemy', + 'type' => 'GameObject', + 'asset' => [ + 'name' => 'enemy.prefab.php', + 'path' => '/tmp/project/Assets/Prefabs/enemy.prefab.php', + 'relativePath' => 'Prefabs/enemy.prefab.php', + 'isDirectory' => false, + 'children' => [], + ], + 'value' => [ + 'type' => 'Sendama\\Engine\\Core\\GameObject', + 'name' => 'Enemy', + 'tag' => 'Enemy', + 'position' => ['x' => 60, 'y' => 12], + 'rotation' => ['x' => 0, 'y' => 0], + 'scale' => ['x' => 1, 'y' => 1], + 'components' => [], + ], + ]); + + expect($panel->content)->toContain('File Name: enemy.prefab.php') + ->toContain('Name: Enemy'); + + $focusableControls = new ReflectionProperty(InspectorPanel::class, 'focusableControls'); + $focusableControls->setAccessible(true); + $applyControlValueToInspectionTarget = new ReflectionMethod(InspectorPanel::class, 'applyControlValueToInspectionTarget'); + $applyControlValueToInspectionTarget->setAccessible(true); + + $fileNameControl = null; + $nameControl = null; + + foreach ($focusableControls->getValue($panel) as $control) { + if (!$control instanceof InputControl) { + continue; + } + + if ($control->getLabel() === 'File Name') { + $fileNameControl = $control; + } + + if ($control->getLabel() === 'Name') { + $nameControl = $control; + } + } + + expect($fileNameControl)->toBeInstanceOf(InputControl::class) + ->and($nameControl)->toBeInstanceOf(InputControl::class); + + $fileNameControl->setValue('boss.prefab.php'); + $applyControlValueToInspectionTarget->invoke($panel, $fileNameControl); + + expect($panel->consumeAssetMutation())->toBe([ + 'path' => '/tmp/project/Assets/Prefabs/enemy.prefab.php', + 'relativePath' => 'Prefabs/enemy.prefab.php', + 'name' => 'boss.prefab.php', + 'activatePrefab' => true, + ]); + + $nameControl->setValue('Boss'); + $applyControlValueToInspectionTarget->invoke($panel, $nameControl); + + expect($panel->consumePrefabMutation())->toMatchArray([ + 'path' => 'Prefabs/enemy.prefab.php', + 'prefabPath' => '/tmp/project/Assets/Prefabs/enemy.prefab.php', + 'value' => [ + 'name' => 'Boss', + ], + ]); +}); + test('inspector panel emits hierarchy mutations when edits are committed', function () { $panel = new InspectorPanel(width: 48, height: 24); @@ -818,6 +930,7 @@ class PlayerController extends Behaviour 'width' => 80, 'height' => 25, 'environmentTileMapPath' => 'Maps/level', + 'environmentCollisionMapPath' => '', ], ]); @@ -854,6 +967,7 @@ class PlayerController extends Behaviour 'width' => 802, 'height' => 25, 'environmentTileMapPath' => 'Maps/level', + 'environmentCollisionMapPath' => '', ], ]); }); @@ -904,6 +1018,7 @@ class PlayerController extends Behaviour 'width' => 80, 'height' => 25, 'environmentTileMapPath' => 'Maps/level', + 'environmentCollisionMapPath' => '', ], ]); @@ -915,6 +1030,7 @@ class PlayerController extends Behaviour 'width' => 80, 'height' => 30, 'environmentTileMapPath' => 'Maps/level', + 'environmentCollisionMapPath' => '', ]); expect(selectedInspectorControlLabel($panel))->toBe('Height'); diff --git a/tests/Unit/MainPanelTest.php b/tests/Unit/MainPanelTest.php index 35f14b3..010b192 100644 --- a/tests/Unit/MainPanelTest.php +++ b/tests/Unit/MainPanelTest.php @@ -201,6 +201,54 @@ function createMainPanelWorkspace(): string expect(mb_substr($renderedLines[3], -1))->toBe('│'); }); +test('main panel scene selection highlight stays aligned for wide multibyte glyphs', function () { + $workspace = createMainPanelWorkspace(); + file_put_contents($workspace . '/Assets/Textures/enemy.texture', "👾\n"); + + $panel = new MainPanel( + width: 24, + height: 10, + sceneObjects: [ + [ + 'type' => 'Sendama\\Engine\\Core\\GameObject', + 'name' => 'Enemy', + 'position' => ['x' => 2, 'y' => 1], + 'sprite' => [ + 'texture' => [ + 'path' => 'Textures/enemy', + 'position' => ['x' => 0, 'y' => 0], + 'size' => ['x' => 1, 'y' => 1], + ], + ], + ], + ], + workingDirectory: $workspace, + ); + + $hasFocus = new ReflectionProperty(Widget::class, 'hasFocus'); + $selectedScenePath = new ReflectionProperty(MainPanel::class, 'selectedScenePath'); + $decorateSceneLine = new ReflectionMethod(MainPanel::class, 'decorateSceneLine'); + $buildRenderedContentLines = new ReflectionMethod($panel, 'buildRenderedContentLines'); + $highlightSequence = (new ReflectionClass(MainPanel::class)) + ->getReflectionConstant('SCENE_SELECTION_FOCUSED_SEQUENCE') + ?->getValue(); + $hasFocus->setAccessible(true); + $selectedScenePath->setAccessible(true); + $decorateSceneLine->setAccessible(true); + $buildRenderedContentLines->setAccessible(true); + + $hasFocus->setValue($panel, true); + $selectedScenePath->setValue($panel, 'scene.0'); + $panel->selectTab('Scene'); + $renderedLines = $buildRenderedContentLines->invoke($panel); + $decoratedLine = $decorateSceneLine->invoke($panel, $renderedLines[3], null, 2); + + expect(substr_count($decoratedLine, '👾'))->toBe(1); + expect(is_string($highlightSequence))->toBeTrue(); + expect(substr_count($decoratedLine, $highlightSequence))->toBe(1); + expect(substr_count($decoratedLine, $highlightSequence . ' '))->toBe(0); +}); + test('main panel resolves scene textures from the configured project directory', function () { $workspace = createMainPanelWorkspace(); $originalWorkingDirectory = getcwd(); @@ -801,6 +849,30 @@ function createMainPanelWorkspace(): string expect($spriteGridHeight->getValue($panel))->toBe(16); }); +test('main panel sprite tab expands loaded tile maps to the current terminal-size bounds', function () { + $workspace = createMainPanelWorkspace(); + $panel = new MainPanel(width: 30, height: 12, workingDirectory: $workspace); + $spriteGridWidth = new ReflectionProperty(MainPanel::class, 'spriteGridWidth'); + $spriteGridHeight = new ReflectionProperty(MainPanel::class, 'spriteGridHeight'); + $spriteGridWidth->setAccessible(true); + $spriteGridHeight->setAccessible(true); + + $panel->selectTab('Sprite'); + $panel->loadSpriteAsset([ + 'name' => 'level.tmap', + 'path' => $workspace . '/Assets/Maps/level.tmap', + 'relativePath' => 'Maps/level.tmap', + 'isDirectory' => false, + ]); + + $terminalSize = get_max_terminal_size(); + $expectedWidth = max(1, (int) ($terminalSize['width'] ?? DEFAULT_TERMINAL_WIDTH)); + $expectedHeight = max(1, (int) ($terminalSize['height'] ?? DEFAULT_TERMINAL_HEIGHT)); + + expect($spriteGridWidth->getValue($panel))->toBe($expectedWidth); + expect($spriteGridHeight->getValue($panel))->toBe($expectedHeight); +}); + test('main panel sprite create workflow can create a new texture asset', function () { $workspace = createMainPanelWorkspace(); $panel = new MainPanel(width: 30, height: 12, workingDirectory: $workspace); diff --git a/tests/Unit/SceneLoaderTest.php b/tests/Unit/SceneLoaderTest.php index 6ebaee3..c5aa1f8 100644 --- a/tests/Unit/SceneLoaderTest.php +++ b/tests/Unit/SceneLoaderTest.php @@ -61,6 +61,52 @@ expect($scene->sourceData['environmentTileMapPath'])->toBe('Maps/level'); }); +test('scene loader normalizes environment collision map paths and supports empty values', function () { + $workspace = sys_get_temp_dir() . '/sendama-scene-loader-collision-map-path-' . uniqid(); + mkdir($workspace . '/Assets/Scenes', 0777, true); + mkdir($workspace . '/vendor', 0777, true); + + file_put_contents($workspace . '/vendor/autoload.php', " 'Maps/level.tmap', + 'environmentCollisionMapPath' => 'Maps/level.collider.tmap', + 'hierarchy' => [], +]; +PHP + ); + + $loader = new SceneLoader($workspace); + $scene = $loader->load(new EditorSceneSettings(active: 0, loaded: ['level01'])); + + expect($scene)->not->toBeNull(); + expect($scene->environmentCollisionMapPath)->toBe('Maps/level.collider'); + expect($scene->rawData['environmentCollisionMapPath'])->toBe('Maps/level.collider'); + expect($scene->sourceData['environmentCollisionMapPath'])->toBe('Maps/level.collider'); + + file_put_contents( + $workspace . '/Assets/Scenes/level02.scene.php', + <<<'PHP' + 'Maps/level.tmap', + 'environmentCollisionMapPath' => '', + 'hierarchy' => [], +]; +PHP + ); + + $emptyScene = $loader->load(new EditorSceneSettings(active: 0, loaded: ['level02'])); + + expect($emptyScene)->not->toBeNull(); + expect($emptyScene->environmentCollisionMapPath)->toBe(''); +}); + test('scene loader evaluates scene metadata in an isolated project context', function () { $workspace = sys_get_temp_dir() . '/sendama-scene-loader-' . uniqid(); mkdir($workspace . '/assets/Scenes', 0777, true); diff --git a/tests/Unit/SceneWriterTest.php b/tests/Unit/SceneWriterTest.php index 5f8008d..b5cc27f 100644 --- a/tests/Unit/SceneWriterTest.php +++ b/tests/Unit/SceneWriterTest.php @@ -9,6 +9,7 @@ width: 120, height: 40, environmentTileMapPath: 'Maps/level', + environmentCollisionMapPath: 'Maps/level.collider', isDirty: true, hierarchy: [ [ @@ -29,6 +30,7 @@ expect($serializedScene)->toContain("toContain("'width' => 120"); expect($serializedScene)->toContain("'environmentTileMapPath' => 'Maps/level'"); + expect($serializedScene)->toContain("'environmentCollisionMapPath' => 'Maps/level.collider'"); expect($serializedScene)->toContain("'name' => 'Player 2'"); expect($serializedScene)->toContain("'customFlag' => true"); expect($serializedScene)->not->toContain("'isDirty'");