diff --git a/composer.json b/composer.json
index 25caf86..09ca527 100644
--- a/composer.json
+++ b/composer.json
@@ -20,7 +20,7 @@
],
"require": {
"php": "^8.4",
- "symfony/console": "^7.0",
+ "symfony/console": "^7.0 || ^8.0",
"amasiye/figlet": "^1.2",
"vlucas/phpdotenv": "^5.6",
"atatusoft-ltd/termutil": "^1.1"
diff --git a/src/Commands/GenerateMaterial.php b/src/Commands/GenerateMaterial.php
new file mode 100644
index 0000000..5c4ba78
--- /dev/null
+++ b/src/Commands/GenerateMaterial.php
@@ -0,0 +1,34 @@
+addArgument('name', InputArgument::REQUIRED, 'The name of the material');
+ }
+
+ public function execute(InputInterface $input, OutputInterface $output): int
+ {
+ $strategy = new MaterialFileGenerationStrategy(
+ $input,
+ $output,
+ $input->getArgument('name') ?? 'material',
+ 'materials',
+ );
+
+ return $strategy->generate();
+ }
+}
diff --git a/src/Commands/NewGame.php b/src/Commands/NewGame.php
index 05b704f..7f853fc 100644
--- a/src/Commands/NewGame.php
+++ b/src/Commands/NewGame.php
@@ -84,6 +84,7 @@ public function execute(InputInterface $input, OutputInterface $output): int
$this->createAssetsScenesDirectory($assetsDirectory);
$this->createAssetsScriptsDirectory($assetsDirectory);
$this->createAssetsMapsDirectory($assetsDirectory);
+ $this->createAssetsMaterialsDirectory($assetsDirectory);
$this->createAssetsPrefabsDirectory($assetsDirectory);
$this->createAssetsTexturesDirectory($assetsDirectory);
@@ -238,6 +239,25 @@ private function createAssetsMapsDirectory(string $assetsDirectory): void
}
}
+ /**
+ * Create the assets' materials directory.
+ *
+ * @param string $assetsDirectory The assets' directory.
+ */
+ private function createAssetsMaterialsDirectory(string $assetsDirectory): void
+ {
+ $materialsDirectory = Path::join($assetsDirectory, 'Materials');
+
+ if (file_exists($materialsDirectory)) {
+ $this->output->writeln('Materials directory already exists...', OutputInterface::VERBOSITY_VERBOSE);
+ return;
+ }
+
+ if (!mkdir($materialsDirectory) && !is_dir($materialsDirectory)) {
+ throw new RuntimeException(sprintf('Directory "%s" was not created', $materialsDirectory));
+ }
+ }
+
/**
* Create the assets' prefabs directory.
*
diff --git a/src/Editor/Editor.php b/src/Editor/Editor.php
index a43fd6e..f7bc32e 100644
--- a/src/Editor/Editor.php
+++ b/src/Editor/Editor.php
@@ -9,6 +9,7 @@
use Atatusoft\Termutil\IO\Console\Console;
use Atatusoft\Termutil\UI\Windows\Window;
use Sendama\Console\Commands\GenerateEvent;
+use Sendama\Console\Commands\GenerateMaterial;
use Sendama\Console\Commands\GeneratePrefab;
use Sendama\Console\Commands\GenerateScene;
use Sendama\Console\Commands\GenerateScript;
@@ -150,12 +151,14 @@ final class Editor implements ObservableInterface
protected int $terminalHeight = DEFAULT_TERMINAL_HEIGHT;
protected PanelListModal $panelListModal;
protected ?OptionListModal $projectNormalizationModal = null;
+ protected OptionListModal $closeConfirmModal;
protected CommandLineModal $commandLineModal;
protected CommandHelpModal $commandHelpModal;
protected bool $shouldRefreshBackgroundUnderModal = false;
protected bool $didRenderOverlayLastFrame = false;
protected SceneWriter $sceneWriter;
protected PrefabWriter $prefabWriter;
+ protected MaterialWriter $materialWriter;
protected ?ProjectNormalizer $projectNormalizer = null;
protected array $projectDiscrepancies = [];
protected Snackbar $snackbar;
@@ -189,6 +192,7 @@ public function __construct(
$this->initializeConsole();
$this->sceneWriter = new SceneWriter();
$this->prefabWriter = new PrefabWriter();
+ $this->materialWriter = new MaterialWriter();
$this->initializeWidgets();
$this->watchedAssetSnapshot = $this->captureWatchedAssetSnapshot();
$this->lastAssetWatchPollAt = microtime(true);
@@ -550,6 +554,30 @@ private function render(): void
return;
}
+ if ($this->closeConfirmModal->isVisible()) {
+ $this->didRenderOverlayLastFrame = true;
+ $this->closeConfirmModal->syncLayout($this->terminalWidth, $this->terminalHeight);
+
+ if ($this->shouldRefreshBackgroundUnderModal || $shouldRefreshForSnackbar) {
+ $this->renderEditorFrame();
+ }
+
+ if ($this->shouldRefreshBackgroundUnderModal || $this->closeConfirmModal->isDirty() || $shouldRefreshForSnackbar) {
+ $this->closeConfirmModal->render();
+
+ if ($hasActiveSnackbar) {
+ $this->snackbar->render();
+ }
+
+ $this->closeConfirmModal->markClean();
+ $this->snackbar->markClean();
+ $this->shouldRefreshBackgroundUnderModal = false;
+ }
+
+ $this->notify(new EditorEvent(EventType::EDITOR_RENDERED->value, $this));
+ return;
+ }
+
if ($this->commandLineModal->isVisible()) {
$this->didRenderOverlayLastFrame = true;
$this->commandLineModal->syncLayout($this->terminalWidth, $this->terminalHeight);
@@ -827,6 +855,7 @@ private function initializeWidgets(): void
{
$this->panels = new ItemList(Widget::class);
$this->panelListModal = new PanelListModal();
+ $this->closeConfirmModal = new OptionListModal(title: 'Unsaved Changes');
$this->commandLineModal = new CommandLineModal();
$this->commandHelpModal = new CommandHelpModal();
$this->hierarchyPanel = new HierarchyPanel(
@@ -889,6 +918,11 @@ private function handlePanelFocus(): void
return;
}
+ if ($this->closeConfirmModal->isVisible()) {
+ $this->handleCloseConfirmModalMouseEvent($mouseEvent);
+ return;
+ }
+
if ($this->commandLineModal->isVisible() || $this->commandHelpModal->isVisible()) {
return;
}
@@ -990,6 +1024,25 @@ private function handlePanelListModalMouseEvent(MouseEvent $mouseEvent): void
$this->shouldRefreshBackgroundUnderModal = true;
}
+ private function handleCloseConfirmModalMouseEvent(MouseEvent $mouseEvent): void
+ {
+ if ($this->closeConfirmModal->handleScrollbarMouseEvent($mouseEvent)) {
+ return;
+ }
+
+ if ($mouseEvent->buttonIndex !== 0 || $mouseEvent->action !== 'Pressed') {
+ return;
+ }
+
+ $selection = $this->closeConfirmModal->clickOptionAtPoint($mouseEvent->x, $mouseEvent->y);
+
+ if (!is_string($selection) || $selection === '') {
+ return;
+ }
+
+ $this->handleCloseConfirmSelection($selection);
+ }
+
private function setFocusedPanel(Widget $panel): void
{
if ($this->focusedPanel === $panel) {
@@ -1014,7 +1067,7 @@ private function createFocusTargetContext(): FocusTargetContext
private function handlePanelKeyboardWorkflow(): void
{
if (Input::isKeyDown(IO\Enumerations\KeyCode::CTRL_C)) {
- $this->stop();
+ $this->requestEditorClose();
return;
}
@@ -1043,6 +1096,11 @@ private function handlePanelKeyboardWorkflow(): void
return;
}
+ if ($this->closeConfirmModal->isVisible()) {
+ $this->handleCloseConfirmModalInput();
+ return;
+ }
+
if (Input::getCurrentInput() === '!') {
$this->showPanelListModal();
return;
@@ -1340,6 +1398,68 @@ private function handlePanelListModalInput(): void
}
}
+ private function handleCloseConfirmModalInput(): void
+ {
+ if (Input::isKeyDown(IO\Enumerations\KeyCode::ESCAPE)) {
+ $this->closeConfirmModal->hide();
+ $this->shouldRefreshBackgroundUnderModal = true;
+ return;
+ }
+
+ if (Input::isKeyDown(IO\Enumerations\KeyCode::UP)) {
+ $this->closeConfirmModal->moveSelection(-1);
+ return;
+ }
+
+ if (Input::isKeyDown(IO\Enumerations\KeyCode::DOWN)) {
+ $this->closeConfirmModal->moveSelection(1);
+ return;
+ }
+
+ if (Input::isKeyDown(IO\Enumerations\KeyCode::ENTER)) {
+ $selection = $this->closeConfirmModal->getSelectedOption();
+
+ if (is_string($selection) && $selection !== '') {
+ $this->handleCloseConfirmSelection($selection);
+ }
+ }
+ }
+
+ private function requestEditorClose(): void
+ {
+ if ($this->loadedScene instanceof DTOs\SceneDTO && $this->loadedScene->isDirty) {
+ $this->closeConfirmModal->show(
+ ['Save and Quit', 'Quit Without Saving', 'Cancel'],
+ title: 'Unsaved Changes'
+ );
+ $this->closeConfirmModal->syncLayout($this->terminalWidth, $this->terminalHeight);
+ $this->shouldRefreshBackgroundUnderModal = true;
+ return;
+ }
+
+ $this->stop();
+ }
+
+ private function handleCloseConfirmSelection(string $selection): void
+ {
+ $this->closeConfirmModal->hide();
+ $this->shouldRefreshBackgroundUnderModal = true;
+
+ if ($selection === 'Cancel') {
+ return;
+ }
+
+ if ($selection === 'Save and Quit') {
+ $didSave = $this->saveLoadedScene();
+
+ if (!$didSave || ($this->loadedScene instanceof DTOs\SceneDTO && $this->loadedScene->isDirty)) {
+ return;
+ }
+ }
+
+ $this->stop();
+ }
+
private function showCommandLineModal(): void
{
$this->commandLineModal->show();
@@ -1483,6 +1603,11 @@ private function synchronizeInspectorPanel(): void
$this->openAssetInConfiguredEditor($asset);
}
+ if ($openInMainPanel && $this->isSceneAsset($asset)) {
+ $this->loadSceneAssetIntoEditor($asset);
+ return;
+ }
+
if ($openInMainPanel && $this->isEditableSpriteAsset($asset)) {
$this->mainPanel->loadSpriteAsset($asset);
$this->mainPanel->selectTab('Sprite');
@@ -1578,9 +1703,17 @@ private function synchronizeInspectorAssetChanges(): void
{
$mutation = $this->inspectorPanel->consumeAssetMutation();
+ if (!is_array($mutation)) {
+ return;
+ }
+
+ if (($mutation['operation'] ?? null) === 'save_material') {
+ $this->saveMaterialAssetMutation($mutation);
+ return;
+ }
+
if (
- !is_array($mutation)
- || !is_string($mutation['path'] ?? null)
+ !is_string($mutation['path'] ?? null)
|| $mutation['path'] === ''
|| !is_string($mutation['name'] ?? null)
) {
@@ -2074,6 +2207,10 @@ private function refreshInspectionAfterWatchedAssetChanges(array $changedAssetPa
$hasChangedPhpAsset = $this->hasChangedPhpAsset($changedAssetPaths);
+ if ($hasChangedPhpAsset) {
+ $this->inspectorPanel->invalidateProjectScriptMetadataCaches();
+ }
+
switch ($inspectionTarget['context'] ?? null) {
case 'hierarchy':
if (!$hasChangedPhpAsset || !$this->refreshLoadedSceneComponentMetadata()) {
@@ -2161,6 +2298,34 @@ private function refreshInspectionAfterWatchedAssetChanges(array $changedAssetPa
$this->inspectorPanel->inspectTarget($this->buildAssetInspectionTarget($asset));
return;
+
+ case 'material_asset':
+ $assetPath = $this->resolveInspectionAssetAbsolutePath($inspectionTarget);
+
+ if (
+ !is_string($assetPath)
+ || $assetPath === ''
+ || !$this->didWatchedAssetChange($assetPath, $changedAssetPaths)
+ ) {
+ return;
+ }
+
+ $asset = $this->resolveAssetEntryByAbsolutePath($assetPath);
+
+ if (!is_array($asset)) {
+ $this->inspectorPanel->inspectTarget(null);
+ return;
+ }
+
+ $materialInspectionTarget = $this->buildMaterialInspectionTarget($asset);
+
+ if (!is_array($materialInspectionTarget)) {
+ $this->inspectorPanel->inspectTarget($this->buildAssetInspectionTarget($asset));
+ return;
+ }
+
+ $this->inspectorPanel->inspectTarget($materialInspectionTarget);
+ return;
}
}
@@ -2999,6 +3164,10 @@ private function resolveAssetCreationDefinition(string $kind): ?array
'command' => GeneratePrefab::class,
'baseName' => 'new-prefab',
],
+ 'material' => [
+ 'command' => GenerateMaterial::class,
+ 'baseName' => 'new-material',
+ ],
'texture' => [
'command' => GenerateTexture::class,
'baseName' => 'new-texture',
@@ -3572,6 +3741,14 @@ private function updateHierarchyAssetReferences(
private function buildAssetInspectionTarget(array $asset, bool $activatePrefab = false): array
{
+ if ($activatePrefab && $this->isMaterialAsset($asset)) {
+ $materialInspectionTarget = $this->buildMaterialInspectionTarget($asset);
+
+ if (is_array($materialInspectionTarget)) {
+ return $materialInspectionTarget;
+ }
+ }
+
if ($activatePrefab && $this->isPrefabAsset($asset)) {
$prefabInspectionTarget = $this->buildPrefabInspectionTarget($asset);
@@ -3588,6 +3765,48 @@ private function buildAssetInspectionTarget(array $asset, bool $activatePrefab =
];
}
+ private function buildMaterialInspectionTarget(array $asset): ?array
+ {
+ $materialPath = is_string($asset['path'] ?? null) ? $asset['path'] : null;
+
+ if (!is_string($materialPath) || $materialPath === '' || !is_file($materialPath)) {
+ return null;
+ }
+
+ try {
+ $materialData = require $materialPath;
+ } catch (Throwable) {
+ return null;
+ }
+
+ if (!is_array($materialData)) {
+ return null;
+ }
+
+ $materialType = strtolower(trim((string) ($materialData['type'] ?? 'physics')));
+
+ if ($materialType !== 'physics') {
+ return null;
+ }
+
+ $normalizedMaterial = [
+ 'type' => 'physics',
+ 'name' => is_string($materialData['name'] ?? null) && trim($materialData['name']) !== ''
+ ? trim($materialData['name'])
+ : basename((string) ($asset['name'] ?? basename($materialPath)), '.material.php'),
+ 'friction' => max(0.0, min(1.0, (float) ($materialData['friction'] ?? 0.5))),
+ 'bounciness' => max(0.0, min(1.0, (float) ($materialData['bounciness'] ?? 0.5))),
+ ];
+
+ return [
+ 'context' => 'material_asset',
+ 'name' => $normalizedMaterial['name'],
+ 'type' => 'Physics Material',
+ 'asset' => $asset,
+ 'value' => $normalizedMaterial,
+ ];
+ }
+
private function buildPrefabInspectionTarget(array $asset): ?array
{
$prefabPath = is_string($asset['path'] ?? null) ? $asset['path'] : null;
@@ -3612,6 +3831,46 @@ private function buildPrefabInspectionTarget(array $asset): ?array
];
}
+ private function loadSceneAssetIntoEditor(array $asset): bool
+ {
+ $scenePath = is_string($asset['path'] ?? null) ? Path::normalize($asset['path']) : null;
+
+ if (!is_string($scenePath) || $scenePath === '') {
+ $this->consolePanel->append('[ERROR] - Selected scene path could not be resolved.');
+ $this->pushNotification('Selected scene path could not be resolved.', 'error');
+ return false;
+ }
+
+ if ($this->loadedScene instanceof DTOs\SceneDTO && $this->loadedScene->isDirty) {
+ $this->consolePanel->append('[WARN] - Save the current scene before loading another one.');
+ $this->pushNotification('Save the current scene before loading another one.', 'warning');
+ return false;
+ }
+
+ $scene = (new SceneLoader($this->workingDirectory))->loadFromPath($scenePath);
+
+ if (!$scene instanceof DTOs\SceneDTO) {
+ $this->consolePanel->append('[ERROR] - Failed to load the selected scene.');
+ $this->pushNotification('Failed to load the selected scene.', 'error');
+ return false;
+ }
+
+ $this->persistLoadedSceneSelection($scenePath);
+ $this->loadedScene = $scene;
+ $this->hierarchyPanel->syncHierarchy($scene->hierarchy);
+ $this->hierarchyPanel->selectPath('scene');
+ $this->mainPanel->setSceneObjects($scene->hierarchy);
+ $this->mainPanel->selectTab('Scene');
+ $this->mainPanel->selectSceneObject(null);
+ $this->syncScenePanels($scene->isDirty);
+ $this->inspectorPanel->inspectTarget($this->buildSceneInspectionTarget());
+ $this->setFocusedPanel($this->hierarchyPanel);
+ $this->consolePanel->append('[INFO] - Loaded scene ' . basename($scenePath) . '.');
+ $this->pushNotification('Loaded scene ' . basename($scenePath) . '.', 'success');
+
+ return true;
+ }
+
private function isPrefabAsset(?array $asset): bool
{
if (!is_array($asset) || ($asset['isDirectory'] ?? false)) {
@@ -3625,6 +3884,141 @@ private function isPrefabAsset(?array $asset): bool
return is_string($assetPath) && str_ends_with(strtolower($assetPath), '.prefab.php');
}
+ private function isSceneAsset(?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), '.scene.php');
+ }
+
+ private function isMaterialAsset(?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), '.material.php');
+ }
+
+ private function saveMaterialAssetMutation(array $mutation): void
+ {
+ $materialPath = is_string($mutation['path'] ?? null) ? $mutation['path'] : null;
+ $materialValue = is_array($mutation['value'] ?? null) ? $mutation['value'] : null;
+
+ if (!is_string($materialPath) || $materialPath === '' || !is_array($materialValue)) {
+ return;
+ }
+
+ if (!isset($this->materialWriter)) {
+ $this->materialWriter = new MaterialWriter();
+ }
+
+ if (!$this->materialWriter->save($materialPath, $materialValue)) {
+ $this->consolePanel->append('[ERROR] - Failed to save material ' . basename($materialPath) . '.');
+ $this->pushNotification('Failed to save material ' . basename($materialPath) . '.', 'error');
+ return;
+ }
+
+ $asset = is_array($mutation['asset'] ?? null)
+ ? $mutation['asset']
+ : [
+ 'name' => basename($materialPath),
+ 'path' => $materialPath,
+ 'relativePath' => $this->buildRelativeAssetPath($materialPath),
+ 'isDirectory' => false,
+ 'children' => [],
+ ];
+
+ $asset['name'] = basename($materialPath);
+ $asset['path'] = $materialPath;
+ $asset['relativePath'] = $this->buildRelativeAssetPath($materialPath);
+
+ $this->assetsPanel->reloadAssets();
+ $this->assetsPanel->selectAssetByAbsolutePath($materialPath);
+ $this->assetsPanel->consumeInspectionRequest();
+
+ $materialInspectionTarget = $this->buildMaterialInspectionTarget($asset);
+
+ if (is_array($materialInspectionTarget)) {
+ $this->inspectorPanel->inspectTarget($materialInspectionTarget);
+ }
+
+ $this->consolePanel->append('[INFO] - Saved material ' . basename($materialPath) . '.');
+ $this->pushNotification('Saved material ' . basename($materialPath) . '.', 'success');
+ }
+
+ private function persistLoadedSceneSelection(string $sceneSourcePath): void
+ {
+ $sceneReference = $this->buildRelativeAssetPath($sceneSourcePath);
+
+ if ($sceneReference === '') {
+ return;
+ }
+
+ $loadedScenes = $this->settings->scenes->loaded;
+ $existingIndex = array_search($sceneReference, $loadedScenes, true);
+
+ if ($existingIndex !== false) {
+ $activeIndex = $existingIndex;
+ } elseif ($loadedScenes === []) {
+ $loadedScenes[] = $sceneReference;
+ $activeIndex = 0;
+ } else {
+ $activeIndex = $this->settings->scenes->active;
+
+ if (isset($loadedScenes[$activeIndex])) {
+ $loadedScenes[$activeIndex] = $sceneReference;
+ } else {
+ $loadedScenes[] = $sceneReference;
+ $activeIndex = count($loadedScenes) - 1;
+ }
+ }
+
+ $this->settings->scenes->loaded = array_values($loadedScenes);
+ $this->settings->scenes->active = $activeIndex;
+
+ $settingsPath = Path::join($this->workingDirectory, 'sendama.json');
+ $settingsData = [];
+
+ if (is_file($settingsPath)) {
+ $settingsContents = file_get_contents($settingsPath);
+ $decodedSettings = is_string($settingsContents) ? json_decode($settingsContents, true) : null;
+
+ if (is_array($decodedSettings)) {
+ $settingsData = $decodedSettings;
+ }
+ }
+
+ if (!is_array($settingsData['editor'] ?? null)) {
+ $settingsData['editor'] = [];
+ }
+
+ $settingsData['editor']['scenes'] = [
+ 'active' => $activeIndex,
+ 'loaded' => $this->settings->scenes->loaded,
+ ];
+
+ if (is_array($settingsData['scenes'] ?? null)) {
+ $settingsData['scenes']['active'] = $activeIndex;
+ $settingsData['scenes']['loaded'] = $this->settings->scenes->loaded;
+ }
+
+ file_put_contents(
+ $settingsPath,
+ json_encode($settingsData, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . PHP_EOL
+ );
+ }
+
private function resolveProjectDirectoryForAsset(array $asset): string
{
if (isset($this->workingDirectory) && is_string($this->workingDirectory) && $this->workingDirectory !== '') {
@@ -4122,12 +4516,12 @@ private static function buildTmuxLabel(string $label, string $fallback): string
return substr($sanitizedLabel, 0, 30);
}
- private function saveLoadedScene(): void
+ private function saveLoadedScene(): bool
{
if (!$this->loadedScene instanceof DTOs\SceneDTO) {
$this->consolePanel->append('[INFO] - No scene loaded to save.');
$this->pushNotification('No scene loaded to save.', 'info');
- return;
+ return false;
}
$sceneWasDirty = $this->loadedScene->isDirty;
@@ -4161,13 +4555,14 @@ private function saveLoadedScene(): void
$this->syncScenePanels(false);
$this->consolePanel->append('[INFO] - Saved scene ' . $this->loadedScene->name . '.scene.php');
$this->pushNotification('Saved scene ' . $this->loadedScene->name . '.scene.php', 'success');
- return;
+ return true;
}
$this->loadedScene->isDirty = $sceneWasDirty;
$this->syncScenePanels($sceneWasDirty);
$this->consolePanel->append('[ERROR] - Failed to save scene.');
$this->pushNotification('Failed to save scene.', 'error');
+ return false;
}
private function applySceneMutation(array $value): bool
diff --git a/src/Editor/MaterialWriter.php b/src/Editor/MaterialWriter.php
new file mode 100644
index 0000000..cb488f6
--- /dev/null
+++ b/src/Editor/MaterialWriter.php
@@ -0,0 +1,39 @@
+serialize($materialData)) !== false;
+ }
+
+ public function serialize(array $materialData): string
+ {
+ $normalizedMaterial = [
+ 'type' => 'physics',
+ 'name' => $this->normalizeName($materialData['name'] ?? null),
+ 'friction' => $this->normalizeUnitFloat($materialData['friction'] ?? 0.5),
+ 'bounciness' => $this->normalizeUnitFloat($materialData['bounciness'] ?? 0.5),
+ ];
+
+ return "getAssetPath()
+ : ($value->assetPath ?? $value->path ?? null);
+ $assetPath = is_string($assetPath) ? trim(str_replace('\\', '/', $assetPath)) : '';
+
+ if ($assetPath !== '') {
+ return $assetPath;
+ }
+
+ $normalizedMaterial = [
+ 'friction' => (float)($value->friction ?? 0.5),
+ 'bounciness' => (float)($value->bounciness ?? 0.5),
+ ];
+ $name = method_exists($value, 'getName')
+ ? $value->getName()
+ : ($value->name ?? null);
+
+ if (is_string($name) && trim($name) !== '') {
+ $normalizedMaterial['name'] = trim($name);
+ }
+
+ return $normalizedMaterial;
+ }
+
if (
(is_a($value, '\Sendama\Engine\Core\Rect')
|| (method_exists($value, 'getWidth') && method_exists($value, 'getHeight')))
@@ -475,6 +500,393 @@ function resolve_property_type(ReflectionProperty $property): ?string
return null;
}
+function is_builtin_type_name(string $typeName): bool
+{
+ return in_array(strtolower($typeName), [
+ 'array',
+ 'bool',
+ 'callable',
+ 'false',
+ 'float',
+ 'int',
+ 'iterable',
+ 'mixed',
+ 'never',
+ 'null',
+ 'object',
+ 'self',
+ 'static',
+ 'string',
+ 'true',
+ ], true);
+}
+
+function resolve_primary_field_type_name(?string $fieldType): ?string
+{
+ if (!is_string($fieldType) || trim($fieldType) === '') {
+ return null;
+ }
+
+ foreach (explode('|', $fieldType) as $candidateType) {
+ $normalizedType = ltrim(trim($candidateType), '\\');
+
+ if ($normalizedType === '' || strtolower($normalizedType) === 'null') {
+ continue;
+ }
+
+ return $normalizedType;
+ }
+
+ return null;
+}
+
+function resolve_representative_collection_value(mixed $value): mixed
+{
+ if (!is_array($value) || $value === []) {
+ return null;
+ }
+
+ foreach ($value as $item) {
+ return $item;
+ }
+
+ return null;
+}
+
+function resolve_range_attribute_metadata(ReflectionProperty $property): ?array
+{
+ $attributes = $property->getAttributes('Sendama\Engine\Core\Attributes\Range');
+
+ if ($attributes === []) {
+ return null;
+ }
+
+ try {
+ $attribute = $attributes[0]->newInstance();
+ $minimum = $attribute->min ?? null;
+ $maximum = $attribute->max ?? null;
+ $step = $attribute->step ?? 1;
+ } catch (Throwable) {
+ return null;
+ }
+
+ if (!is_int($minimum) && !is_float($minimum)) {
+ return null;
+ }
+
+ if (!is_int($maximum) && !is_float($maximum)) {
+ return null;
+ }
+
+ if (!is_int($step) && !is_float($step)) {
+ $step = 1;
+ }
+
+ if ($step == 0) {
+ $step = 1;
+ }
+
+ if ($minimum > $maximum) {
+ [$minimum, $maximum] = [$maximum, $minimum];
+ }
+
+ return [
+ 'min' => $minimum,
+ 'max' => $maximum,
+ 'step' => abs($step),
+ ];
+}
+
+function resolve_class_import_aliases(ReflectionClass $scope): array
+{
+ $fileName = $scope->getFileName();
+
+ if (!is_string($fileName) || !is_file($fileName)) {
+ return [];
+ }
+
+ $source = file_get_contents($fileName);
+
+ if (!is_string($source) || $source === '') {
+ return [];
+ }
+
+ $aliases = [];
+
+ if (preg_match_all('/^\s*use\s+([^;]+);/mi', $source, $matches) === 1 || count($matches[1] ?? []) > 0) {
+ foreach ($matches[1] as $importClause) {
+ if (!is_string($importClause) || str_contains($importClause, '{')) {
+ continue;
+ }
+
+ $normalizedClause = trim($importClause);
+ $alias = basename(str_replace('\\', '/', $normalizedClause));
+ $typeReference = $normalizedClause;
+
+ if (preg_match('/^(.+)\s+as\s+([A-Za-z_][A-Za-z0-9_]*)$/i', $normalizedClause, $aliasMatches) === 1) {
+ $typeReference = trim($aliasMatches[1]);
+ $alias = trim($aliasMatches[2]);
+ }
+
+ $aliases[strtolower($alias)] = ltrim($typeReference, '\\');
+ }
+ }
+
+ return $aliases;
+}
+
+function resolve_docblock_type_reference(ReflectionClass $scope, string $typeReference): ?string
+{
+ $normalizedTypeReference = trim($typeReference);
+
+ if ($normalizedTypeReference === '') {
+ return null;
+ }
+
+ if ($normalizedTypeReference[0] === '\\') {
+ return ltrim($normalizedTypeReference, '\\');
+ }
+
+ if (is_builtin_type_name($normalizedTypeReference)) {
+ return strtolower($normalizedTypeReference);
+ }
+
+ if (str_contains($normalizedTypeReference, '\\')) {
+ return ltrim($normalizedTypeReference, '\\');
+ }
+
+ $importAliases = resolve_class_import_aliases($scope);
+ $normalizedAlias = strtolower($normalizedTypeReference);
+
+ if (isset($importAliases[$normalizedAlias])) {
+ return $importAliases[$normalizedAlias];
+ }
+
+ $namespace = $scope->getNamespaceName();
+
+ return $namespace !== ''
+ ? $namespace . '\\' . $normalizedTypeReference
+ : $normalizedTypeReference;
+}
+
+function extract_collection_item_type_expression(string $typeExpression): ?string
+{
+ $normalizedExpression = trim($typeExpression);
+
+ if ($normalizedExpression === '') {
+ return null;
+ }
+
+ $unionMembers = array_values(array_filter(array_map('trim', explode('|', $normalizedExpression))));
+
+ foreach ($unionMembers as $unionMember) {
+ if (strtolower($unionMember) === 'null') {
+ continue;
+ }
+
+ if (preg_match('/^(.+)\[\]$/', $unionMember, $matches) === 1) {
+ return trim($matches[1]);
+ }
+
+ if (preg_match('/^(?:array|list)<(.+)>$/', $unionMember, $matches) === 1) {
+ $innerType = trim($matches[1]);
+ $segments = array_values(array_filter(array_map('trim', explode(',', $innerType))));
+
+ return $segments === [] ? null : end($segments);
+ }
+ }
+
+ return null;
+}
+
+function resolve_collection_item_field_type(ReflectionProperty $property): ?string
+{
+ $docComment = $property->getDocComment();
+
+ if (!is_string($docComment) || $docComment === '') {
+ return null;
+ }
+
+ if (preg_match('/@var\s+([^\s]+)/', $docComment, $matches) !== 1) {
+ return null;
+ }
+
+ $typeExpression = trim($matches[1]);
+ $collectionItemType = extract_collection_item_type_expression($typeExpression);
+
+ if ($collectionItemType === null) {
+ return null;
+ }
+
+ return resolve_docblock_type_reference($property->getDeclaringClass(), $collectionItemType);
+}
+
+function is_compound_structure_type(string $typeName): bool
+{
+ $normalizedType = ltrim(trim($typeName), '\\');
+
+ if (
+ $normalizedType === ''
+ || is_builtin_type_name($normalizedType)
+ || enum_exists($normalizedType)
+ || interface_exists($normalizedType)
+ || !class_exists($normalizedType)
+ ) {
+ return false;
+ }
+
+ if (in_array($normalizedType, [
+ 'Sendama\\Engine\\Core\\GameObject',
+ 'Sendama\\Engine\\UI\\UIElement',
+ 'Sendama\\Engine\\UI\\Interfaces\\UIElementInterface',
+ 'Sendama\\Engine\\Core\\Vector2',
+ 'Sendama\\Engine\\Core\\Rect',
+ 'Sendama\\Engine\\Core\\Texture',
+ 'Sendama\\Engine\\Core\\Sprite',
+ 'Sendama\\Engine\\Physics\\PhysicsMaterial',
+ ], true)) {
+ return false;
+ }
+
+ if (
+ is_a($normalizedType, 'Sendama\\Engine\\Core\\Component', true)
+ || is_a($normalizedType, 'Sendama\\Engine\\Core\\GameObject', true)
+ || is_a($normalizedType, 'Sendama\\Engine\\UI\\UIElement', true)
+ ) {
+ return false;
+ }
+
+ return true;
+}
+
+function resolve_compound_structure_field_schemas(string $typeName, array $currentValue): array
+{
+ try {
+ $reflection = new ReflectionClass($typeName);
+ } catch (Throwable) {
+ return [];
+ }
+
+ $schemas = [];
+
+ foreach ($reflection->getProperties() as $property) {
+ if (
+ $property->isStatic()
+ || !(
+ $property->isPublic()
+ || $property->getAttributes('Sendama\Engine\Core\Behaviours\Attributes\SerializeField') !== []
+ )
+ || (method_exists($property, 'isVirtual') && $property->isVirtual())
+ ) {
+ continue;
+ }
+
+ $propertyName = $property->getName();
+ $schemas[$propertyName] = resolve_component_property_field_schema(
+ $property,
+ $currentValue[$propertyName] ?? null,
+ );
+ }
+
+ return $schemas;
+}
+
+function build_component_field_schema(
+ ?string $fieldType,
+ mixed $currentValue,
+ ?ReflectionClass $scope = null,
+): array {
+ $schema = [];
+
+ if (is_string($fieldType) && trim($fieldType) !== '') {
+ $schema['type'] = $fieldType;
+ }
+
+ $primaryType = resolve_primary_field_type_name($fieldType);
+
+ if ($primaryType !== null && is_compound_structure_type($primaryType)) {
+ $schema['properties'] = resolve_compound_structure_field_schemas(
+ $primaryType,
+ is_array($currentValue) ? $currentValue : [],
+ );
+ }
+
+ if (
+ !isset($schema['item'])
+ && is_array($currentValue)
+ && array_is_list($currentValue)
+ && $currentValue !== []
+ ) {
+ $schema['item'] = build_component_field_schema(
+ null,
+ resolve_representative_collection_value($currentValue),
+ $scope,
+ );
+ }
+
+ return $schema;
+}
+
+function resolve_component_property_field_schema(
+ ReflectionProperty $property,
+ mixed $currentValue,
+ ?string $fallbackType = null,
+): array {
+ $resolvedType = resolve_property_type($property) ?? $fallbackType;
+ $schema = build_component_field_schema(
+ $resolvedType,
+ $currentValue,
+ $property->getDeclaringClass(),
+ );
+ $range = resolve_range_attribute_metadata($property);
+
+ if ($range !== null) {
+ $schema['range'] = $range;
+ }
+
+ $collectionItemType = resolve_collection_item_field_type($property);
+
+ if ($collectionItemType !== null) {
+ $schema['item'] = build_component_field_schema(
+ $collectionItemType,
+ resolve_representative_collection_value($currentValue),
+ $property->getDeclaringClass(),
+ );
+ }
+
+ return $schema;
+}
+
+function extract_component_editor_field_schemas(object $component): array
+{
+ $fieldSchemas = [];
+ $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 {
+ $fieldSchemas[$property->getName()] = resolve_component_property_field_schema(
+ $property,
+ normalize_editor_value($property->getValue($component)),
+ );
+ } catch (Throwable) {
+ continue;
+ }
+ }
+
+ return $fieldSchemas;
+}
+
function merge_component_data(array $defaultData, array $existingData): array
{
if ($existingData === []) {
@@ -526,6 +938,21 @@ function enrich_component_entry(mixed $component, array $item): mixed
}
})()
: [];
+ $defaultComponentFieldSchemas = is_string($componentClass) && $componentClass !== ''
+ && class_exists($componentClass)
+ && class_exists('\Sendama\Engine\Core\Component')
+ && is_a($componentClass, '\Sendama\Engine\Core\Component', true)
+ && !empty($gameObject = build_dummy_game_object($item))
+ ? (function () use ($componentClass, $gameObject): array {
+ try {
+ $componentInstance = new $componentClass($gameObject);
+
+ return extract_component_editor_field_schemas($componentInstance);
+ } catch (Throwable) {
+ return [];
+ }
+ })()
+ : [];
if (array_key_exists('data', $component)) {
$existingComponentData = is_array($component['data'])
@@ -546,6 +973,10 @@ function enrich_component_entry(mixed $component, array $item): mixed
$component['__editorFieldTypes'] = $defaultComponentFieldTypes;
}
+ if ($defaultComponentFieldSchemas !== []) {
+ $component['__editorFieldSchemas'] = $defaultComponentFieldSchemas;
+ }
+
return $component;
}
@@ -557,6 +988,10 @@ function enrich_component_entry(mixed $component, array $item): mixed
$component['__editorFieldTypes'] = $defaultComponentFieldTypes;
}
+ if ($defaultComponentFieldSchemas !== []) {
+ $component['__editorFieldSchemas'] = $defaultComponentFieldSchemas;
+ }
+
return $component;
}
diff --git a/src/Editor/SceneLoader.php b/src/Editor/SceneLoader.php
index 5035fbe..591ecf5 100644
--- a/src/Editor/SceneLoader.php
+++ b/src/Editor/SceneLoader.php
@@ -315,6 +315,31 @@ function normalize_editor_value(mixed $value): mixed
}
}
+ if (is_a($value, '\Sendama\Engine\Physics\PhysicsMaterial')) {
+ $assetPath = method_exists($value, 'getAssetPath')
+ ? $value->getAssetPath()
+ : ($value->assetPath ?? $value->path ?? null);
+ $assetPath = is_string($assetPath) ? trim(str_replace('\\', '/', $assetPath)) : '';
+
+ if ($assetPath !== '') {
+ return $assetPath;
+ }
+
+ $normalizedMaterial = [
+ 'friction' => (float)($value->friction ?? 0.5),
+ 'bounciness' => (float)($value->bounciness ?? 0.5),
+ ];
+ $name = method_exists($value, 'getName')
+ ? $value->getName()
+ : ($value->name ?? null);
+
+ if (is_string($name) && trim($name) !== '') {
+ $normalizedMaterial['name'] = trim($name);
+ }
+
+ return $normalizedMaterial;
+ }
+
if (
(is_a($value, '\Sendama\Engine\Core\Rect')
|| (method_exists($value, 'getWidth') && method_exists($value, 'getHeight')))
@@ -671,6 +696,393 @@ function resolve_property_type(ReflectionProperty $property): ?string
return null;
}
+function is_builtin_type_name(string $typeName): bool
+{
+ return in_array(strtolower($typeName), [
+ 'array',
+ 'bool',
+ 'callable',
+ 'false',
+ 'float',
+ 'int',
+ 'iterable',
+ 'mixed',
+ 'never',
+ 'null',
+ 'object',
+ 'self',
+ 'static',
+ 'string',
+ 'true',
+ ], true);
+}
+
+function resolve_primary_field_type_name(?string $fieldType): ?string
+{
+ if (!is_string($fieldType) || trim($fieldType) === '') {
+ return null;
+ }
+
+ foreach (explode('|', $fieldType) as $candidateType) {
+ $normalizedType = ltrim(trim($candidateType), '\\');
+
+ if ($normalizedType === '' || strtolower($normalizedType) === 'null') {
+ continue;
+ }
+
+ return $normalizedType;
+ }
+
+ return null;
+}
+
+function resolve_representative_collection_value(mixed $value): mixed
+{
+ if (!is_array($value) || $value === []) {
+ return null;
+ }
+
+ foreach ($value as $item) {
+ return $item;
+ }
+
+ return null;
+}
+
+function resolve_range_attribute_metadata(ReflectionProperty $property): ?array
+{
+ $attributes = $property->getAttributes('Sendama\Engine\Core\Attributes\Range');
+
+ if ($attributes === []) {
+ return null;
+ }
+
+ try {
+ $attribute = $attributes[0]->newInstance();
+ $minimum = $attribute->min ?? null;
+ $maximum = $attribute->max ?? null;
+ $step = $attribute->step ?? 1;
+ } catch (Throwable) {
+ return null;
+ }
+
+ if (!is_int($minimum) && !is_float($minimum)) {
+ return null;
+ }
+
+ if (!is_int($maximum) && !is_float($maximum)) {
+ return null;
+ }
+
+ if (!is_int($step) && !is_float($step)) {
+ $step = 1;
+ }
+
+ if ($step == 0) {
+ $step = 1;
+ }
+
+ if ($minimum > $maximum) {
+ [$minimum, $maximum] = [$maximum, $minimum];
+ }
+
+ return [
+ 'min' => $minimum,
+ 'max' => $maximum,
+ 'step' => abs($step),
+ ];
+}
+
+function resolve_class_import_aliases(ReflectionClass $scope): array
+{
+ $fileName = $scope->getFileName();
+
+ if (!is_string($fileName) || !is_file($fileName)) {
+ return [];
+ }
+
+ $source = file_get_contents($fileName);
+
+ if (!is_string($source) || $source === '') {
+ return [];
+ }
+
+ $aliases = [];
+
+ if (preg_match_all('/^\s*use\s+([^;]+);/mi', $source, $matches) === 1 || count($matches[1] ?? []) > 0) {
+ foreach ($matches[1] as $importClause) {
+ if (!is_string($importClause) || str_contains($importClause, '{')) {
+ continue;
+ }
+
+ $normalizedClause = trim($importClause);
+ $alias = basename(str_replace('\\', '/', $normalizedClause));
+ $typeReference = $normalizedClause;
+
+ if (preg_match('/^(.+)\s+as\s+([A-Za-z_][A-Za-z0-9_]*)$/i', $normalizedClause, $aliasMatches) === 1) {
+ $typeReference = trim($aliasMatches[1]);
+ $alias = trim($aliasMatches[2]);
+ }
+
+ $aliases[strtolower($alias)] = ltrim($typeReference, '\\');
+ }
+ }
+
+ return $aliases;
+}
+
+function resolve_docblock_type_reference(ReflectionClass $scope, string $typeReference): ?string
+{
+ $normalizedTypeReference = trim($typeReference);
+
+ if ($normalizedTypeReference === '') {
+ return null;
+ }
+
+ if ($normalizedTypeReference[0] === '\\') {
+ return ltrim($normalizedTypeReference, '\\');
+ }
+
+ if (is_builtin_type_name($normalizedTypeReference)) {
+ return strtolower($normalizedTypeReference);
+ }
+
+ if (str_contains($normalizedTypeReference, '\\')) {
+ return ltrim($normalizedTypeReference, '\\');
+ }
+
+ $importAliases = resolve_class_import_aliases($scope);
+ $normalizedAlias = strtolower($normalizedTypeReference);
+
+ if (isset($importAliases[$normalizedAlias])) {
+ return $importAliases[$normalizedAlias];
+ }
+
+ $namespace = $scope->getNamespaceName();
+
+ return $namespace !== ''
+ ? $namespace . '\\' . $normalizedTypeReference
+ : $normalizedTypeReference;
+}
+
+function extract_collection_item_type_expression(string $typeExpression): ?string
+{
+ $normalizedExpression = trim($typeExpression);
+
+ if ($normalizedExpression === '') {
+ return null;
+ }
+
+ $unionMembers = array_values(array_filter(array_map('trim', explode('|', $normalizedExpression))));
+
+ foreach ($unionMembers as $unionMember) {
+ if (strtolower($unionMember) === 'null') {
+ continue;
+ }
+
+ if (preg_match('/^(.+)\[\]$/', $unionMember, $matches) === 1) {
+ return trim($matches[1]);
+ }
+
+ if (preg_match('/^(?:array|list)<(.+)>$/', $unionMember, $matches) === 1) {
+ $innerType = trim($matches[1]);
+ $segments = array_values(array_filter(array_map('trim', explode(',', $innerType))));
+
+ return $segments === [] ? null : end($segments);
+ }
+ }
+
+ return null;
+}
+
+function resolve_collection_item_field_type(ReflectionProperty $property): ?string
+{
+ $docComment = $property->getDocComment();
+
+ if (!is_string($docComment) || $docComment === '') {
+ return null;
+ }
+
+ if (preg_match('/@var\s+([^\s]+)/', $docComment, $matches) !== 1) {
+ return null;
+ }
+
+ $typeExpression = trim($matches[1]);
+ $collectionItemType = extract_collection_item_type_expression($typeExpression);
+
+ if ($collectionItemType === null) {
+ return null;
+ }
+
+ return resolve_docblock_type_reference($property->getDeclaringClass(), $collectionItemType);
+}
+
+function is_compound_structure_type(string $typeName): bool
+{
+ $normalizedType = ltrim(trim($typeName), '\\');
+
+ if (
+ $normalizedType === ''
+ || is_builtin_type_name($normalizedType)
+ || enum_exists($normalizedType)
+ || interface_exists($normalizedType)
+ || !class_exists($normalizedType)
+ ) {
+ return false;
+ }
+
+ if (in_array($normalizedType, [
+ 'Sendama\\Engine\\Core\\GameObject',
+ 'Sendama\\Engine\\UI\\UIElement',
+ 'Sendama\\Engine\\UI\\Interfaces\\UIElementInterface',
+ 'Sendama\\Engine\\Core\\Vector2',
+ 'Sendama\\Engine\\Core\\Rect',
+ 'Sendama\\Engine\\Core\\Texture',
+ 'Sendama\\Engine\\Core\\Sprite',
+ 'Sendama\\Engine\\Physics\\PhysicsMaterial',
+ ], true)) {
+ return false;
+ }
+
+ if (
+ is_a($normalizedType, 'Sendama\\Engine\\Core\\Component', true)
+ || is_a($normalizedType, 'Sendama\\Engine\\Core\\GameObject', true)
+ || is_a($normalizedType, 'Sendama\\Engine\\UI\\UIElement', true)
+ ) {
+ return false;
+ }
+
+ return true;
+}
+
+function resolve_compound_structure_field_schemas(string $typeName, array $currentValue): array
+{
+ try {
+ $reflection = new ReflectionClass($typeName);
+ } catch (Throwable) {
+ return [];
+ }
+
+ $schemas = [];
+
+ foreach ($reflection->getProperties() as $property) {
+ if (
+ $property->isStatic()
+ || !(
+ $property->isPublic()
+ || $property->getAttributes('Sendama\Engine\Core\Behaviours\Attributes\SerializeField') !== []
+ )
+ || (method_exists($property, 'isVirtual') && $property->isVirtual())
+ ) {
+ continue;
+ }
+
+ $propertyName = $property->getName();
+ $schemas[$propertyName] = resolve_component_property_field_schema(
+ $property,
+ $currentValue[$propertyName] ?? null,
+ );
+ }
+
+ return $schemas;
+}
+
+function build_component_field_schema(
+ ?string $fieldType,
+ mixed $currentValue,
+ ?ReflectionClass $scope = null,
+): array {
+ $schema = [];
+
+ if (is_string($fieldType) && trim($fieldType) !== '') {
+ $schema['type'] = $fieldType;
+ }
+
+ $primaryType = resolve_primary_field_type_name($fieldType);
+
+ if ($primaryType !== null && is_compound_structure_type($primaryType)) {
+ $schema['properties'] = resolve_compound_structure_field_schemas(
+ $primaryType,
+ is_array($currentValue) ? $currentValue : [],
+ );
+ }
+
+ if (
+ !isset($schema['item'])
+ && is_array($currentValue)
+ && array_is_list($currentValue)
+ && $currentValue !== []
+ ) {
+ $schema['item'] = build_component_field_schema(
+ null,
+ resolve_representative_collection_value($currentValue),
+ $scope,
+ );
+ }
+
+ return $schema;
+}
+
+function resolve_component_property_field_schema(
+ ReflectionProperty $property,
+ mixed $currentValue,
+ ?string $fallbackType = null,
+): array {
+ $resolvedType = resolve_property_type($property) ?? $fallbackType;
+ $schema = build_component_field_schema(
+ $resolvedType,
+ $currentValue,
+ $property->getDeclaringClass(),
+ );
+ $range = resolve_range_attribute_metadata($property);
+
+ if ($range !== null) {
+ $schema['range'] = $range;
+ }
+
+ $collectionItemType = resolve_collection_item_field_type($property);
+
+ if ($collectionItemType !== null) {
+ $schema['item'] = build_component_field_schema(
+ $collectionItemType,
+ resolve_representative_collection_value($currentValue),
+ $property->getDeclaringClass(),
+ );
+ }
+
+ return $schema;
+}
+
+function extract_component_editor_field_schemas(object $component): array
+{
+ $fieldSchemas = [];
+ $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 {
+ $fieldSchemas[$property->getName()] = resolve_component_property_field_schema(
+ $property,
+ normalize_editor_value($property->getValue($component)),
+ );
+ } catch (Throwable) {
+ continue;
+ }
+ }
+
+ return $fieldSchemas;
+}
+
function enrich_component_entry(mixed $component, array $item): mixed
{
if (!is_array($component)) {
@@ -696,6 +1108,21 @@ function enrich_component_entry(mixed $component, array $item): mixed
}
})()
: [];
+ $defaultComponentFieldSchemas = is_string($componentClass) && $componentClass !== ''
+ && class_exists($componentClass)
+ && class_exists('\Sendama\Engine\Core\Component')
+ && is_a($componentClass, '\Sendama\Engine\Core\Component', true)
+ && !empty($gameObject = build_dummy_game_object($item))
+ ? (function () use ($componentClass, $gameObject): array {
+ try {
+ $componentInstance = new $componentClass($gameObject);
+
+ return extract_component_editor_field_schemas($componentInstance);
+ } catch (Throwable) {
+ return [];
+ }
+ })()
+ : [];
if (array_key_exists('data', $component)) {
$existingComponentData = is_array($component['data'])
@@ -716,6 +1143,10 @@ function enrich_component_entry(mixed $component, array $item): mixed
$component['__editorFieldTypes'] = $defaultComponentFieldTypes;
}
+ if ($defaultComponentFieldSchemas !== []) {
+ $component['__editorFieldSchemas'] = $defaultComponentFieldSchemas;
+ }
+
return $component;
}
@@ -731,6 +1162,10 @@ function enrich_component_entry(mixed $component, array $item): mixed
$component['__editorFieldTypes'] = $defaultComponentFieldTypes;
}
+ if ($defaultComponentFieldSchemas !== []) {
+ $component['__editorFieldSchemas'] = $defaultComponentFieldSchemas;
+ }
+
return $component;
}
diff --git a/src/Editor/Widgets/AssetsPanel.php b/src/Editor/Widgets/AssetsPanel.php
index 2a9fc98..d51a452 100644
--- a/src/Editor/Widgets/AssetsPanel.php
+++ b/src/Editor/Widgets/AssetsPanel.php
@@ -178,7 +178,7 @@ public function beginCreateWorkflow(): void
{
$this->modalState = self::CREATE_MODAL_ASSET_KIND;
$this->createAssetModal->show(
- ['Script', 'Scene', 'Prefab', 'Texture', 'Tile Map', 'Event'],
+ ['Script', 'Scene', 'Prefab', 'Material', 'Texture', 'Tile Map', 'Event'],
title: 'Create Asset',
);
}
@@ -757,6 +757,7 @@ private function handleModalSelection(?string $selection): void
'Script' => 'script',
'Scene' => 'scene',
'Prefab' => 'prefab',
+ 'Material' => 'material',
'Texture' => 'texture',
'Tile Map' => 'tilemap',
'Event' => 'event',
diff --git a/src/Editor/Widgets/Controls/MaterialReferenceInputControl.php b/src/Editor/Widgets/Controls/MaterialReferenceInputControl.php
new file mode 100644
index 0000000..e0ebfb4
--- /dev/null
+++ b/src/Editor/Widgets/Controls/MaterialReferenceInputControl.php
@@ -0,0 +1,51 @@
+normalizeValue($value), $indentLevel, $isReadOnly);
+ }
+
+ public function setValue(mixed $value): void
+ {
+ $this->value = $this->normalizeValue($value);
+ }
+
+ public function renderLines(): array
+ {
+ return [
+ $this->indentation() . $this->label . ': ' . $this->resolveDisplayValue(),
+ ];
+ }
+
+ private function normalizeValue(mixed $value): ?string
+ {
+ if (!is_string($value)) {
+ return null;
+ }
+
+ $normalizedValue = trim(str_replace('\\', '/', $value));
+
+ return $normalizedValue !== '' ? $normalizedValue : null;
+ }
+
+ private function resolveDisplayValue(): string
+ {
+ $value = $this->value;
+
+ if (!is_string($value) || $value === '') {
+ return 'Default';
+ }
+
+ return $this->displayLabelsByPath[$value] ?? $value;
+ }
+}
diff --git a/src/Editor/Widgets/InspectorPanel.php b/src/Editor/Widgets/InspectorPanel.php
index 93e0626..768e000 100644
--- a/src/Editor/Widgets/InspectorPanel.php
+++ b/src/Editor/Widgets/InspectorPanel.php
@@ -22,6 +22,7 @@
use Sendama\Console\Editor\Widgets\Controls\CompoundInputControl;
use Sendama\Console\Editor\Widgets\Controls\InputControl;
use Sendama\Console\Editor\Widgets\Controls\InputControlFactory;
+use Sendama\Console\Editor\Widgets\Controls\MaterialReferenceInputControl;
use Sendama\Console\Editor\Widgets\Controls\NumberInputControl;
use Sendama\Console\Editor\Widgets\Controls\PathInputControl;
use Sendama\Console\Editor\Widgets\Controls\PrefabReferenceInputControl;
@@ -42,6 +43,7 @@ class InspectorPanel extends Widget
private const string STATE_PATH_INPUT_ACTION_SELECTION = 'path_input_action_selection';
private const string STATE_PATH_INPUT_FILE_DIALOG = 'path_input_file_dialog';
private const string STATE_PREFAB_REFERENCE_SELECTION = 'prefab_reference_selection';
+ private const string STATE_MATERIAL_REFERENCE_SELECTION = 'material_reference_selection';
private const string STATE_UI_ELEMENT_REFERENCE_SELECTION = 'ui_element_reference_selection';
private const string SECTION_HEADER_SEQUENCE = EditorColorScheme::SURFACE_SEQUENCE;
private const string SECTION_HEADER_SELECTED_SEQUENCE = EditorColorScheme::SELECTED_ROW_SEQUENCE;
@@ -74,9 +76,11 @@ class InspectorPanel extends Widget
protected OptionListModal $addComponentModal;
protected OptionListModal $deleteComponentModal;
protected OptionListModal $prefabReferenceModal;
+ protected OptionListModal $materialReferenceModal;
protected OptionListModal $uiElementReferenceModal;
protected ?PathInputControl $activePathInputControl = null;
protected ?PrefabReferenceInputControl $activePrefabReferenceControl = null;
+ protected ?MaterialReferenceInputControl $activeMaterialReferenceControl = null;
protected ?UIElementReferenceInputControl $activeUIElementReferenceControl = null;
protected array $controlBindings = [];
protected array $controlMetadata = [];
@@ -90,6 +94,7 @@ class InspectorPanel extends Widget
protected bool $isComponentMoveModeActive = false;
protected ?int $pendingComponentDeletionIndex = null;
protected array $prefabReferenceOptions = [];
+ protected array $materialReferenceOptions = [];
protected array $uiElementReferenceOptions = [];
protected string $modeHelpLabel = '';
protected bool $shouldRefreshModalBackground = false;
@@ -97,6 +102,7 @@ class InspectorPanel extends Widget
protected float $lastClickedControlAt = 0.0;
protected array $classImportAliasCache = [];
private const string GUI_TEXTURE_TYPE = 'Sendama\\Engine\\UI\\GUITexture\\GUITexture';
+ private const string PHYSICS_MATERIAL_TYPE = 'Sendama\\Engine\\Physics\\PhysicsMaterial';
private const string UI_ELEMENT_TYPE = 'Sendama\\Engine\\UI\\UIElement';
private const string UI_ELEMENT_INTERFACE_TYPE = 'Sendama\\Engine\\UI\\Interfaces\\UIElementInterface';
private const array GUI_TEXTURE_COLOR_OPTIONS = [
@@ -132,6 +138,7 @@ public function __construct(
$this->addComponentModal = new OptionListModal(title: 'Add Component');
$this->deleteComponentModal = new OptionListModal(title: 'Remove Component');
$this->prefabReferenceModal = new OptionListModal(title: 'Choose Prefab');
+ $this->materialReferenceModal = new OptionListModal(title: 'Choose Physics Material');
$this->uiElementReferenceModal = new OptionListModal(title: 'Choose UI Element');
$this->projectDirectory = is_string($workingDirectory) && $workingDirectory !== ''
? $workingDirectory
@@ -220,6 +227,8 @@ public function inspectTarget(?array $target): void
if ($context === 'prefab' && is_array($value)) {
$this->buildPrefabControls($target, $value);
+ } elseif ($context === 'material_asset' && is_array($value)) {
+ $this->buildMaterialAssetControls($target, $value);
} elseif ($context === 'hierarchy' && is_array($value)) {
$this->buildHierarchyControls($target, $value);
} elseif ($context === 'scene' && is_array($value)) {
@@ -276,6 +285,7 @@ public function hasActiveModal(): bool
|| $this->addComponentModal->isVisible()
|| $this->deleteComponentModal->isVisible()
|| $this->prefabReferenceModal->isVisible()
+ || $this->materialReferenceModal->isVisible()
|| $this->uiElementReferenceModal->isVisible();
}
@@ -286,6 +296,7 @@ public function isModalDirty(): bool
|| $this->addComponentModal->isDirty()
|| $this->deleteComponentModal->isDirty()
|| $this->prefabReferenceModal->isDirty()
+ || $this->materialReferenceModal->isDirty()
|| $this->uiElementReferenceModal->isDirty();
}
@@ -296,6 +307,7 @@ public function markModalClean(): void
$this->addComponentModal->markClean();
$this->deleteComponentModal->markClean();
$this->prefabReferenceModal->markClean();
+ $this->materialReferenceModal->markClean();
$this->uiElementReferenceModal->markClean();
}
@@ -306,6 +318,7 @@ public function syncModalLayout(int $terminalWidth, int $terminalHeight): void
$this->addComponentModal->syncLayout($terminalWidth, $terminalHeight);
$this->deleteComponentModal->syncLayout($terminalWidth, $terminalHeight);
$this->prefabReferenceModal->syncLayout($terminalWidth, $terminalHeight);
+ $this->materialReferenceModal->syncLayout($terminalWidth, $terminalHeight);
$this->uiElementReferenceModal->syncLayout($terminalWidth, $terminalHeight);
}
@@ -331,6 +344,10 @@ public function renderActiveModal(): void
$this->prefabReferenceModal->render();
}
+ if ($this->materialReferenceModal->isVisible()) {
+ $this->materialReferenceModal->render();
+ }
+
if ($this->uiElementReferenceModal->isVisible()) {
$this->uiElementReferenceModal->render();
}
@@ -398,6 +415,26 @@ public function handleModalMouseEvent(MouseEvent $mouseEvent): bool
return $isWithinModal;
}
+ if ($this->materialReferenceModal->isVisible()) {
+ if ($this->materialReferenceModal->handleScrollbarMouseEvent($mouseEvent)) {
+ return true;
+ }
+
+ $isWithinModal = $this->materialReferenceModal->containsPoint($mouseEvent->x, $mouseEvent->y);
+
+ if ($mouseEvent->buttonIndex !== 0 || $mouseEvent->action !== 'Pressed') {
+ return $isWithinModal;
+ }
+
+ $selection = $this->materialReferenceModal->clickOptionAtPoint($mouseEvent->x, $mouseEvent->y);
+
+ if (is_string($selection) && $selection !== '') {
+ $this->applyMaterialReferenceSelection($selection);
+ }
+
+ return $isWithinModal;
+ }
+
if ($this->uiElementReferenceModal->isVisible()) {
if ($this->uiElementReferenceModal->handleScrollbarMouseEvent($mouseEvent)) {
return true;
@@ -553,7 +590,7 @@ public function syncAssetTarget(array $value): void
{
if (
!is_array($this->inspectionTarget)
- || ($this->inspectionTarget['context'] ?? null) !== 'asset'
+ || !in_array($this->inspectionTarget['context'] ?? null, ['asset', 'material_asset'], true)
) {
return;
}
@@ -561,7 +598,9 @@ public function syncAssetTarget(array $value): void
$selectedControlSnapshot = $this->captureSelectedControlSnapshot($this->getSelectedControl());
$target = $this->inspectionTarget;
$target['name'] = $value['name'] ?? ($target['name'] ?? 'Unnamed Asset');
- $target['type'] = ($value['isDirectory'] ?? false) ? 'Folder' : 'File';
+ $target['type'] = ($this->inspectionTarget['context'] ?? null) === 'material_asset'
+ ? 'Physics Material'
+ : (($value['isDirectory'] ?? false) ? 'Folder' : 'File');
$target['value'] = $value;
$this->inspectTarget($target);
@@ -619,6 +658,11 @@ public function update(): void
return;
}
+ if ($this->materialReferenceModal->isVisible()) {
+ $this->handleMaterialReferenceModalInput();
+ return;
+ }
+
if ($this->uiElementReferenceModal->isVisible()) {
$this->handleUIElementReferenceModalInput();
return;
@@ -923,6 +967,29 @@ private function buildAssetControls(array $target, array $asset): void
$this->addControl(new TextInputControl('Path', $assetPath, 0, true));
}
+ private function buildMaterialAssetControls(array $target, array $material): void
+ {
+ $asset = is_array($target['asset'] ?? null) ? $target['asset'] : [];
+ $assetName = is_string($asset['name'] ?? null) ? $asset['name'] : basename((string) ($asset['path'] ?? 'material.material.php'));
+ $assetPath = is_string($asset['path'] ?? null) ? $asset['path'] : '';
+
+ $this->addControl(new TextInputControl('Type', 'Physics Material', 0, true));
+ $this->addControl(new TextInputControl('File', $assetName, 0, true));
+ $this->addControl(new TextInputControl('Path', $assetPath, 0, true));
+ $this->addBoundControl(
+ new TextInputControl('Name', (string) ($material['name'] ?? $target['name'] ?? 'Material'), 0),
+ ['name'],
+ );
+ $this->addBoundControl(
+ new SliderInputControl('Friction', (float) ($material['friction'] ?? 0.5), 0, 1, 0.05, 0),
+ ['friction'],
+ );
+ $this->addBoundControl(
+ new SliderInputControl('Bounciness', (float) ($material['bounciness'] ?? 0.5), 0, 1, 0.05, 0),
+ ['bounciness'],
+ );
+ }
+
private function addRendererControls(array $item): void
{
$sprite = is_array($item['sprite'] ?? null) ? $item['sprite'] : [];
@@ -1009,11 +1076,13 @@ private function addScriptComponents(mixed $components): void
$componentFieldTypes = is_array($component['__editorFieldTypes'] ?? null)
? $component['__editorFieldTypes']
: [];
- $componentFieldSchemas = $this->resolveComponentFieldSchemas(
- is_string($component['class'] ?? null) ? $component['class'] : null,
- $componentFieldTypes,
- $serializedComponentData ?? [],
- );
+ $componentFieldSchemas = is_array($component['__editorFieldSchemas'] ?? null)
+ ? $component['__editorFieldSchemas']
+ : $this->resolveComponentFieldSchemas(
+ is_string($component['class'] ?? null) ? $component['class'] : null,
+ $componentFieldTypes,
+ $serializedComponentData ?? [],
+ );
if (is_array($serializedComponentData)) {
$this->addControl(
@@ -1063,6 +1132,13 @@ private function addScriptComponents(mixed $components): void
}
}
+ public function invalidateProjectScriptMetadataCaches(): void
+ {
+ $this->cachedProjectComponentCandidates = null;
+ $this->classImportAliasCache = [];
+ $this->componentMenuDefinitions = [];
+ }
+
private function addComponentPropertyControls(
array $properties,
array $basePath,
@@ -1134,6 +1210,15 @@ private function buildComponentPropertyControl(
);
}
+ if ($this->isPhysicsMaterialAssignableField($fieldType)) {
+ return new MaterialReferenceInputControl(
+ $label,
+ $this->normalizePhysicsMaterialComponentFieldValue($value),
+ $this->resolvePhysicsMaterialDisplayLabelsByPath(),
+ $indentLevel,
+ );
+ }
+
$uiElementFieldType = $this->resolveAssignableUIElementFieldType($fieldType);
if (is_string($uiElementFieldType)) {
@@ -1227,6 +1312,20 @@ private function isTextureAssignableField(?string $fieldType): bool
return in_array('Sendama\\Engine\\Core\\Texture', $normalizedTypes, true);
}
+ private function isPhysicsMaterialAssignableField(?string $fieldType): bool
+ {
+ if (!is_string($fieldType) || trim($fieldType) === '') {
+ return false;
+ }
+
+ $normalizedTypes = array_map(
+ static fn(string $type): string => ltrim(trim($type), '\\'),
+ explode('|', $fieldType),
+ );
+
+ return in_array(self::PHYSICS_MATERIAL_TYPE, $normalizedTypes, true);
+ }
+
private function shouldRenderNestedComponentProperties(mixed $value, array $fieldSchema = []): bool
{
$hasNestedSchema = is_array($fieldSchema['properties'] ?? null)
@@ -1514,6 +1613,7 @@ enum_exists($normalizedType)
'Sendama\\Engine\\Core\\Rect',
'Sendama\\Engine\\Core\\Texture',
'Sendama\\Engine\\Core\\Sprite',
+ self::PHYSICS_MATERIAL_TYPE,
], true)) {
return false;
}
@@ -1887,6 +1987,12 @@ private function updateHelpInfo(): void
return;
}
+ if ($this->materialReferenceModal->isVisible()) {
+ $this->help = 'Up/Down choose Enter assign Esc cancel';
+ $this->modeHelpLabel = 'Mode: Material Picker';
+ return;
+ }
+
if ($this->uiElementReferenceModal->isVisible()) {
$this->help = 'Up/Down choose Enter assign Esc cancel';
$this->modeHelpLabel = 'Mode: UI Element Picker';
@@ -1911,6 +2017,12 @@ private function updateHelpInfo(): void
return;
}
+ if ($this->interactionState === self::STATE_MATERIAL_REFERENCE_SELECTION) {
+ $this->help = 'Up/Down choose Enter assign Esc cancel';
+ $this->modeHelpLabel = 'Mode: Material Assign';
+ return;
+ }
+
$selectedControl = $this->getSelectedControl();
if ($this->interactionState === self::STATE_CONTROL_EDIT) {
@@ -1979,6 +2091,12 @@ private function updateHelpInfo(): void
return;
}
+ if ($selectedControl instanceof MaterialReferenceInputControl) {
+ $this->help = 'Up/Down select Enter choose material Tab next';
+ $this->modeHelpLabel = 'Mode: Control Select';
+ return;
+ }
+
if ($selectedControl instanceof UIElementReferenceInputControl) {
$this->help = 'Up/Down select Enter choose UI element Tab next';
$this->modeHelpLabel = 'Mode: Control Select';
@@ -2190,6 +2308,11 @@ private function activateSelectedControl(InputControl $selectedControl): void
return;
}
+ if ($selectedControl instanceof MaterialReferenceInputControl) {
+ $this->showMaterialReferenceModal($selectedControl);
+ return;
+ }
+
if ($selectedControl instanceof UIElementReferenceInputControl) {
$this->showUIElementReferenceModal($selectedControl);
return;
@@ -2334,6 +2457,7 @@ private function resetInteractionState(): void
$this->closeAddComponentModal();
$this->closeDeleteComponentModal();
$this->closePrefabReferenceModal();
+ $this->closeMaterialReferenceModal();
$this->closeUIElementReferenceModal();
$selectedControl = $this->getSelectedControl();
@@ -2518,6 +2642,36 @@ private function showPrefabReferenceModal(PrefabReferenceInputControl $control):
$this->syncModalLayout($terminalWidth, $terminalHeight);
}
+ private function showMaterialReferenceModal(MaterialReferenceInputControl $control): void
+ {
+ $this->activeMaterialReferenceControl = $control;
+ $this->materialReferenceOptions = $this->resolveAvailablePhysicsMaterialOptions();
+ $options = ['Default', ...array_keys($this->materialReferenceOptions), 'Cancel'];
+ $selectedIndex = 0;
+ $currentValue = $control->getValue();
+
+ if (is_string($currentValue) && $currentValue !== '') {
+ foreach ($this->materialReferenceOptions as $label => $definition) {
+ if (($definition['path'] ?? null) === $currentValue) {
+ $optionIndex = array_search($label, $options, true);
+
+ if (is_int($optionIndex)) {
+ $selectedIndex = $optionIndex;
+ }
+
+ break;
+ }
+ }
+ }
+
+ $this->materialReferenceModal->show($options, $selectedIndex, 'Choose Physics Material');
+ $this->interactionState = self::STATE_MATERIAL_REFERENCE_SELECTION;
+ $terminalSize = get_max_terminal_size();
+ $terminalWidth = $terminalSize['width'] ?? DEFAULT_TERMINAL_WIDTH;
+ $terminalHeight = $terminalSize['height'] ?? DEFAULT_TERMINAL_HEIGHT;
+ $this->syncModalLayout($terminalWidth, $terminalHeight);
+ }
+
private function showUIElementReferenceModal(UIElementReferenceInputControl $control): void
{
$this->activeUIElementReferenceControl = $control;
@@ -2579,6 +2733,32 @@ private function handlePrefabReferenceModalInput(): void
$this->applyPrefabReferenceSelection($this->prefabReferenceModal->getSelectedOption());
}
+ private function handleMaterialReferenceModalInput(): void
+ {
+ if (Input::isKeyDown(KeyCode::ESCAPE)) {
+ $this->closeMaterialReferenceModal();
+ $this->interactionState = self::STATE_CONTROL_SELECTION;
+ $this->refreshContent();
+ return;
+ }
+
+ if (Input::isKeyDown(KeyCode::UP)) {
+ $this->materialReferenceModal->moveSelection(-1);
+ return;
+ }
+
+ if (Input::isKeyDown(KeyCode::DOWN)) {
+ $this->materialReferenceModal->moveSelection(1);
+ return;
+ }
+
+ if (!Input::isKeyDown(KeyCode::ENTER)) {
+ return;
+ }
+
+ $this->applyMaterialReferenceSelection($this->materialReferenceModal->getSelectedOption());
+ }
+
private function handleUIElementReferenceModalInput(): void
{
if (Input::isKeyDown(KeyCode::ESCAPE)) {
@@ -2612,6 +2792,13 @@ private function closePrefabReferenceModal(): void
$this->prefabReferenceOptions = [];
}
+ private function closeMaterialReferenceModal(): void
+ {
+ $this->materialReferenceModal->hide();
+ $this->activeMaterialReferenceControl = null;
+ $this->materialReferenceOptions = [];
+ }
+
private function closeUIElementReferenceModal(): void
{
$this->uiElementReferenceModal->hide();
@@ -2721,6 +2908,33 @@ private function applyPrefabReferenceSelection(?string $selection): void
$this->refreshContent();
}
+ private function applyMaterialReferenceSelection(?string $selection): void
+ {
+ if (!$this->activeMaterialReferenceControl instanceof MaterialReferenceInputControl) {
+ $this->closeMaterialReferenceModal();
+ $this->interactionState = self::STATE_CONTROL_SELECTION;
+ $this->refreshContent();
+ return;
+ }
+
+ if ($selection === 'Cancel') {
+ $this->closeMaterialReferenceModal();
+ $this->interactionState = self::STATE_CONTROL_SELECTION;
+ $this->refreshContent();
+ return;
+ }
+
+ $nextValue = $selection === 'Default'
+ ? null
+ : ($this->materialReferenceOptions[$selection]['path'] ?? null);
+
+ $this->activeMaterialReferenceControl->setValue($nextValue);
+ $this->applyControlValueToInspectionTarget($this->activeMaterialReferenceControl);
+ $this->closeMaterialReferenceModal();
+ $this->interactionState = self::STATE_CONTROL_SELECTION;
+ $this->refreshContent();
+ }
+
private function applyUIElementReferenceSelection(?string $selection): void
{
if (!$this->activeUIElementReferenceControl instanceof UIElementReferenceInputControl) {
@@ -3214,6 +3428,31 @@ function normalize_editor_value(mixed $value): mixed
}
}
+ if (is_a($value, '\Sendama\Engine\Physics\PhysicsMaterial')) {
+ $assetPath = method_exists($value, 'getAssetPath')
+ ? $value->getAssetPath()
+ : ($value->assetPath ?? $value->path ?? null);
+ $assetPath = is_string($assetPath) ? trim(str_replace('\\', '/', $assetPath)) : '';
+
+ if ($assetPath !== '') {
+ return $assetPath;
+ }
+
+ $normalizedMaterial = [
+ 'friction' => (float)($value->friction ?? 0.5),
+ 'bounciness' => (float)($value->bounciness ?? 0.5),
+ ];
+ $name = method_exists($value, 'getName')
+ ? $value->getName()
+ : ($value->name ?? null);
+
+ if (is_string($name) && trim($name) !== '') {
+ $normalizedMaterial['name'] = trim($name);
+ }
+
+ return $normalizedMaterial;
+ }
+
if (
(is_a($value, '\Sendama\Engine\Core\Rect')
|| (method_exists($value, 'getWidth') && method_exists($value, 'getHeight')))
@@ -3710,6 +3949,31 @@ private function normalizeEditorValue(mixed $value): mixed
}
}
+ if (is_a($value, '\Sendama\Engine\Physics\PhysicsMaterial')) {
+ $assetPath = method_exists($value, 'getAssetPath')
+ ? $value->getAssetPath()
+ : ($value->assetPath ?? $value->path ?? null);
+ $assetPath = is_string($assetPath) ? trim(str_replace('\\', '/', $assetPath)) : '';
+
+ if ($assetPath !== '') {
+ return $assetPath;
+ }
+
+ $normalizedMaterial = [
+ 'friction' => (float)($value->friction ?? 0.5),
+ 'bounciness' => (float)($value->bounciness ?? 0.5),
+ ];
+ $name = method_exists($value, 'getName')
+ ? $value->getName()
+ : ($value->name ?? null);
+
+ if (is_string($name) && trim($name) !== '') {
+ $normalizedMaterial['name'] = trim($name);
+ }
+
+ return $normalizedMaterial;
+ }
+
if (
(is_a($value, '\Sendama\Engine\Core\Rect')
|| (method_exists($value, 'getWidth') && method_exists($value, 'getHeight')))
@@ -3830,6 +4094,35 @@ private function normalizeTextureComponentFieldValue(mixed $value): string
return 'None';
}
+ private function normalizePhysicsMaterialComponentFieldValue(mixed $value): ?string
+ {
+ if (is_string($value)) {
+ $normalizedValue = trim(str_replace('\\', '/', $value));
+
+ return $normalizedValue !== '' ? $normalizedValue : null;
+ }
+
+ if (is_array($value)) {
+ $path = $value['path'] ?? null;
+
+ return is_string($path) && trim($path) !== ''
+ ? trim(str_replace('\\', '/', $path))
+ : null;
+ }
+
+ if (is_object($value)) {
+ $path = method_exists($value, 'getAssetPath')
+ ? $value->getAssetPath()
+ : ($value->path ?? null);
+
+ return is_string($path) && trim($path) !== ''
+ ? trim(str_replace('\\', '/', $path))
+ : null;
+ }
+
+ return null;
+ }
+
private function buildUniqueComponentMenuLabel(string $baseLabel, string $componentClass, array &$usedLabels): string
{
if (!isset($usedLabels[$baseLabel])) {
@@ -4475,6 +4768,22 @@ private function resolvePrefabDisplayLabelsByPath(): array
return $displayLabelsByPath;
}
+ private function resolvePhysicsMaterialDisplayLabelsByPath(): array
+ {
+ $displayLabelsByPath = [];
+
+ foreach ($this->resolveAvailablePhysicsMaterialOptions() as $materialOption) {
+ $path = $materialOption['path'] ?? null;
+ $label = $materialOption['display'] ?? null;
+
+ if (is_string($path) && $path !== '' && is_string($label) && $label !== '') {
+ $displayLabelsByPath[$path] = $label;
+ }
+ }
+
+ return $displayLabelsByPath;
+ }
+
private function resolveAvailablePrefabOptions(): array
{
$prefabsDirectory = Path::join($this->resolveAssetsWorkingDirectory(), 'Prefabs');
@@ -4530,6 +4839,73 @@ private function resolveAvailablePrefabOptions(): array
return $prefabOptions;
}
+ private function resolveAvailablePhysicsMaterialOptions(): array
+ {
+ $materialsDirectory = Path::join($this->resolveAssetsWorkingDirectory(), 'Materials');
+
+ if (!is_dir($materialsDirectory)) {
+ return [];
+ }
+
+ $materialOptions = [];
+ $usedLabels = [];
+ $iterator = new RecursiveIteratorIterator(
+ new RecursiveDirectoryIterator($materialsDirectory, RecursiveDirectoryIterator::SKIP_DOTS)
+ );
+
+ foreach ($iterator as $file) {
+ if (!$file->isFile()) {
+ continue;
+ }
+
+ $fileName = $file->getFilename();
+
+ if (!is_string($fileName) || !str_ends_with(strtolower($fileName), '.material.php')) {
+ continue;
+ }
+
+ $absolutePath = $file->getPathname();
+ $relativePath = $this->buildRelativeAssetPath($absolutePath);
+
+ if ($relativePath === null) {
+ continue;
+ }
+
+ $materialMetadata = null;
+
+ try {
+ $materialMetadata = require $absolutePath;
+ } catch (Throwable) {
+ $materialMetadata = null;
+ }
+
+ $materialType = is_array($materialMetadata)
+ ? ($materialMetadata['type'] ?? 'physics')
+ : (is_object($materialMetadata) ? ($materialMetadata->type ?? 'physics') : 'physics');
+
+ if (!is_string($materialType) || strtolower(trim($materialType)) !== 'physics') {
+ continue;
+ }
+
+ $displayName = is_array($materialMetadata) && is_string($materialMetadata['name'] ?? null) && trim($materialMetadata['name']) !== ''
+ ? trim($materialMetadata['name'])
+ : (is_object($materialMetadata) && is_string($materialMetadata->name ?? null) && trim($materialMetadata->name) !== ''
+ ? trim($materialMetadata->name)
+ : basename($relativePath, '.material.php'));
+ $label = $this->buildUniqueReferenceOptionLabel($displayName, $usedLabels);
+
+ $materialOptions[$label] = [
+ 'path' => $relativePath,
+ 'display' => $label,
+ 'name' => $displayName,
+ ];
+ }
+
+ ksort($materialOptions);
+
+ return $materialOptions;
+ }
+
private function resolveUIElementDisplayLabelsByName(?string $fieldType = null): array
{
$displayLabelsByName = [];
@@ -4746,7 +5122,7 @@ private function shortTypeName(string $type): string
return $segments[array_key_last($segments)] ?? $normalizedType;
}
- private function buildRelativePrefabPath(string $absolutePath): ?string
+ private function buildRelativeAssetPath(string $absolutePath): ?string
{
$assetsDirectory = $this->resolveAssetsWorkingDirectory();
$normalizedAssetsDirectory = rtrim(str_replace('\\', '/', $assetsDirectory), '/');
@@ -4759,6 +5135,11 @@ private function buildRelativePrefabPath(string $absolutePath): ?string
return substr($normalizedAbsolutePath, strlen($normalizedAssetsDirectory) + 1) ?: null;
}
+ private function buildRelativePrefabPath(string $absolutePath): ?string
+ {
+ return $this->buildRelativeAssetPath($absolutePath);
+ }
+
private function buildUniquePrefabOptionLabel(string $displayName, string $fileName, array &$usedLabels): string
{
$label = $displayName;
@@ -4913,6 +5294,20 @@ private function applyControlValueToInspectionTarget(InputControl $control): voi
$this->inspectionTarget['name'] = (string) $control->getValue();
}
+ if ($context === 'material_asset') {
+ if (is_array($this->inspectionTarget['asset'] ?? null)) {
+ $this->pendingAssetMutation = [
+ 'operation' => 'save_material',
+ 'path' => $this->inspectionTarget['asset']['path'] ?? null,
+ 'relativePath' => $this->inspectionTarget['asset']['relativePath'] ?? null,
+ 'asset' => $this->inspectionTarget['asset'],
+ 'value' => $inspectionValue,
+ ];
+ }
+
+ return;
+ }
+
if ($context === 'asset') {
if (
$valuePath === ['name']
diff --git a/src/Strategies/AssetFileGeneration/MaterialFileGenerationStrategy.php b/src/Strategies/AssetFileGeneration/MaterialFileGenerationStrategy.php
new file mode 100644
index 0000000..8e165cd
--- /dev/null
+++ b/src/Strategies/AssetFileGeneration/MaterialFileGenerationStrategy.php
@@ -0,0 +1,48 @@
+fileExtension) {
+ $this->fileExtension = '.material.php';
+ }
+
+ $nameTokens = explode('/', $this->filename);
+ $this->classPath = to_pascal_case($this->directory);
+
+ foreach ($nameTokens as $token) {
+ $this->classPath = Path::join($this->classPath, to_kebab_case($token));
+ }
+
+ $this->className = basename($this->classPath);
+ $this->relativeFilename = Path::join($this->assetsDirectoryName, $this->classPath . $this->fileExtension);
+ $displayName = $this->buildDisplayName($this->className);
+
+ $this->content = << 'physics',
+ 'name' => '{$displayName}',
+ 'friction' => 0.5,
+ 'bounciness' => 0.5,
+];
+PHP;
+ }
+
+ private function buildDisplayName(string $fileName): string
+ {
+ $normalized = trim(str_replace(['-', '_'], ' ', $fileName));
+
+ if ($normalized === '') {
+ return 'New Material';
+ }
+
+ return ucwords($normalized);
+ }
+}
diff --git a/tests/Unit/AssetsPanelTest.php b/tests/Unit/AssetsPanelTest.php
index 862bbdf..a1fc5f9 100644
--- a/tests/Unit/AssetsPanelTest.php
+++ b/tests/Unit/AssetsPanelTest.php
@@ -175,6 +175,32 @@ function getAssetsContentAreaPosition(AssetsPanel $panel): array
]);
});
+test('assets panel queues material asset creation requests', function () {
+ $workspace = sys_get_temp_dir() . '/sendama-assets-panel-material-create-' . uniqid();
+ mkdir($workspace . '/Assets', 0777, true);
+
+ $panel = new AssetsPanel(
+ width: 40,
+ height: 12,
+ assetsDirectoryPath: $workspace . '/Assets',
+ workingDirectory: $workspace,
+ );
+
+ $handleModalSelection = new ReflectionMethod(AssetsPanel::class, 'handleModalSelection');
+ $handleModalSelection->setAccessible(true);
+
+ $modalState = new ReflectionProperty(AssetsPanel::class, 'modalState');
+ $modalState->setAccessible(true);
+ $modalState->setValue($panel, 'create_asset_kind');
+
+ $handleModalSelection->invoke($panel, 'Material');
+
+ expect($panel->consumeCreationRequest())->toBe([
+ 'kind' => 'material',
+ 'workingDirectory' => $workspace,
+ ]);
+});
+
test('assets panel queues the selected asset for deletion when confirmed', /** @throws Exception */function () {
$workspace = sys_get_temp_dir() . '/sendama-assets-panel-' . uniqid();
diff --git a/tests/Unit/CliAssetsDirectoryTest.php b/tests/Unit/CliAssetsDirectoryTest.php
index f25b837..1c0801d 100644
--- a/tests/Unit/CliAssetsDirectoryTest.php
+++ b/tests/Unit/CliAssetsDirectoryTest.php
@@ -2,6 +2,7 @@
use Sendama\Console\Commands\GenerateScene;
use Sendama\Console\Commands\GeneratePrefab;
+use Sendama\Console\Commands\GenerateMaterial;
use Sendama\Console\Commands\GenerateScript;
use Sendama\Console\Commands\GenerateTexture;
use Sendama\Console\Commands\NewGame;
@@ -115,6 +116,21 @@ function runGeneratorCommandInWorkspace(object $command, string $workspace, arra
expect(is_dir($workspace . '/Assets'))->toBeTrue();
});
+test('new game creates a Materials directory inside Assets', function () {
+ $workspace = sys_get_temp_dir() . '/sendama-new-game-materials-' . uniqid();
+ mkdir($workspace, 0777, true);
+ mkdir($workspace . '/Assets', 0777, true);
+
+ $command = new NewGame();
+ $property = new ReflectionProperty(NewGame::class, 'targetDirectory');
+ $property->setValue($command, $workspace);
+
+ $method = new ReflectionMethod(NewGame::class, 'createAssetsMaterialsDirectory');
+ $method->invoke($command, $workspace . '/Assets');
+
+ expect(is_dir($workspace . '/Assets/Materials'))->toBeTrue();
+});
+
test('asset root resolution prefers populated legacy assets over empty canonical Assets', function () {
$workspace = sys_get_temp_dir() . '/sendama-assets-root-resolution-' . uniqid();
mkdir($workspace . '/Assets/Prefabs', 0777, true);
@@ -204,6 +220,23 @@ function runGeneratorCommandInWorkspace(object $command, string $workspace, arra
->and(is_file($workspace . '/Assets/Textures/player.texture'))->toBeTrue();
});
+test('generate material creates files under Assets', function () {
+ $workspace = createCliAssetsWorkspace();
+ $exitCode = runGeneratorCommandInWorkspace(
+ new GenerateMaterial(),
+ $workspace,
+ ['name' => 'perfectly-elastic'],
+ );
+
+ $materialPath = $workspace . '/Assets/Materials/perfectly-elastic.material.php';
+ $materialContents = file_get_contents($materialPath);
+
+ expect($exitCode)->toBe(0)
+ ->and(is_file($materialPath))->toBeTrue()
+ ->and($materialContents)->toContain("'type' => 'physics'")
+ ->and($materialContents)->toContain("'name' => 'Perfectly Elastic'");
+});
+
test('generate scene creates files under Assets', function () {
$workspace = createCliAssetsWorkspace();
$exitCode = runGeneratorCommandInWorkspace(
diff --git a/tests/Unit/EditorAssetSelectionTest.php b/tests/Unit/EditorAssetSelectionTest.php
index ae5f826..f282a9d 100644
--- a/tests/Unit/EditorAssetSelectionTest.php
+++ b/tests/Unit/EditorAssetSelectionTest.php
@@ -3,6 +3,7 @@
use Assegai\Collections\ItemList;
use Atatusoft\Termutil\Events\MouseEvent;
use Sendama\Console\Editor\Editor;
+use Sendama\Console\Editor\EditorSettings;
use Sendama\Console\Editor\DTOs\SceneDTO;
use Sendama\Console\Editor\PrefabWriter;
use Sendama\Console\Editor\IO\InputManager;
@@ -13,6 +14,7 @@
use Sendama\Console\Editor\Widgets\HierarchyPanel;
use Sendama\Console\Editor\Widgets\InspectorPanel;
use Sendama\Console\Editor\Widgets\MainPanel;
+use Sendama\Console\Editor\Widgets\OptionListModal;
use Sendama\Console\Editor\Widgets\PanelListModal;
use Sendama\Console\Editor\Widgets\Snackbar;
@@ -88,6 +90,172 @@
->and($mainPanel->getActiveTab())->toBe('Scene');
});
+test('editor loads the selected scene asset into the hierarchy on enter activation', function () {
+ $workspace = createEditorSceneSelectionWorkspace();
+ [$editor, $reflection, $assetsPanel, $mainPanel, $inspectorPanel] = createEditorForAssetSelection($workspace);
+
+ $assetsPanel->expandSelection();
+ $assetsPanel->moveSelection(1);
+ $assetsPanel->activateSelection();
+
+ $synchronizeInspectorPanel = $reflection->getMethod('synchronizeInspectorPanel');
+ $synchronizeInspectorPanel->setAccessible(true);
+ $synchronizeInspectorPanel->invoke($editor);
+
+ $loadedScene = $reflection->getProperty('loadedScene');
+ $focusedPanel = $reflection->getProperty('focusedPanel');
+ $hierarchyPanel = $reflection->getProperty('hierarchyPanel');
+ $inspectionTarget = new ReflectionProperty(InspectorPanel::class, 'inspectionTarget');
+ $sceneObjects = new ReflectionProperty(MainPanel::class, 'sceneObjects');
+ $loadedScene->setAccessible(true);
+ $focusedPanel->setAccessible(true);
+ $hierarchyPanel->setAccessible(true);
+ $inspectionTarget->setAccessible(true);
+ $sceneObjects->setAccessible(true);
+
+ $activeHierarchyPanel = $hierarchyPanel->getValue($editor);
+ $activeLoadedScene = $loadedScene->getValue($editor);
+
+ expect($activeLoadedScene)->toBeInstanceOf(SceneDTO::class)
+ ->and($activeLoadedScene->name)->toBe('level01')
+ ->and($activeLoadedScene->width)->toBe(96)
+ ->and($activeLoadedScene->height)->toBe(28)
+ ->and($activeLoadedScene->hierarchy[0]['name'] ?? null)->toBe('Player')
+ ->and($inspectionTarget->getValue($inspectorPanel))->toMatchArray([
+ 'context' => 'scene',
+ 'name' => 'level01',
+ 'type' => 'Scene',
+ 'path' => 'scene',
+ ])
+ ->and($sceneObjects->getValue($mainPanel)[0]['name'] ?? null)->toBe('Player')
+ ->and($mainPanel->getActiveTab())->toBe('Scene')
+ ->and($focusedPanel->getValue($editor))->toBe($activeHierarchyPanel)
+ ->and($activeHierarchyPanel->content[0] ?? null)->toContain('level01')
+ ->and(implode("\n", $activeHierarchyPanel->content))->toContain('• Player');
+
+ $configuration = json_decode((string) file_get_contents($workspace . '/sendama.json'), true);
+
+ expect($configuration['editor']['scenes']['active'] ?? null)->toBe(0)
+ ->and($configuration['editor']['scenes']['loaded'] ?? null)->toBe(['Scenes/level01.scene.php']);
+});
+
+test('editor opens the selected material asset into an editable material inspector on enter activation', function () {
+ $workspace = createEditorMaterialSelectionWorkspace();
+ [$editor, $reflection, $assetsPanel, $mainPanel, $inspectorPanel] = createEditorForAssetSelection($workspace);
+
+ $assetsPanel->expandSelection();
+ $assetsPanel->moveSelection(1);
+ $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' => 'material_asset',
+ 'name' => 'Perfectly Elastic',
+ 'type' => 'Physics Material',
+ ]);
+ expect($contentText)->toContain('Type: Physics Material')
+ ->toContain('File: perfectly-elastic.material.php')
+ ->toContain('Name: Perfectly Elastic')
+ ->toContain('Friction:')
+ ->toContain('Bounciness:')
+ ->and($mainPanel->getActiveTab())->toBe('Scene');
+});
+
+test('editor saves physics material edits back to the asset file', function () {
+ $workspace = createEditorMaterialSelectionWorkspace();
+ [$editor, $reflection, $assetsPanel, $mainPanel, $inspectorPanel] = createEditorForAssetSelection($workspace);
+
+ $assetsPanel->expandSelection();
+ $assetsPanel->moveSelection(1);
+ $assetsPanel->activateSelection();
+
+ $synchronizeInspectorPanel = $reflection->getMethod('synchronizeInspectorPanel');
+ $synchronizeInspectorPanel->setAccessible(true);
+ $synchronizeInspectorPanel->invoke($editor);
+
+ $focusableControls = new ReflectionProperty(InspectorPanel::class, 'focusableControls');
+ $focusableControls->setAccessible(true);
+ $applyControlValueToInspectionTarget = new ReflectionMethod(InspectorPanel::class, 'applyControlValueToInspectionTarget');
+ $applyControlValueToInspectionTarget->setAccessible(true);
+
+ $frictionControl = null;
+
+ foreach ($focusableControls->getValue($inspectorPanel) as $control) {
+ if ($control instanceof \Sendama\Console\Editor\Widgets\Controls\InputControl && $control->getLabel() === 'Friction') {
+ $frictionControl = $control;
+ break;
+ }
+ }
+
+ expect($frictionControl)->toBeInstanceOf(\Sendama\Console\Editor\Widgets\Controls\InputControl::class);
+
+ $frictionControl->setValue(0.3);
+ $applyControlValueToInspectionTarget->invoke($inspectorPanel, $frictionControl);
+
+ $synchronizeInspectorAssetChanges = $reflection->getMethod('synchronizeInspectorAssetChanges');
+ $synchronizeInspectorAssetChanges->setAccessible(true);
+ $synchronizeInspectorAssetChanges->invoke($editor);
+
+ $material = require $workspace . '/Assets/Materials/perfectly-elastic.material.php';
+
+ expect($material['friction'] ?? null)->toBe(0.3)
+ ->and($material['bounciness'] ?? null)->toBe(1.0)
+ ->and($mainPanel->getActiveTab())->toBe('Scene');
+});
+
+test('editor remembers the last loaded scene across restarts', function () {
+ $workspace = createEditorSceneSelectionWorkspace();
+ [$editor, $reflection, $assetsPanel] = createEditorForAssetSelection($workspace);
+
+ $assetsPanel->expandSelection();
+ $assetsPanel->moveSelection(1);
+ $assetsPanel->activateSelection();
+
+ $synchronizeInspectorPanel = $reflection->getMethod('synchronizeInspectorPanel');
+ $synchronizeInspectorPanel->setAccessible(true);
+ $synchronizeInspectorPanel->invoke($editor);
+
+ $settings = EditorSettings::loadFromDirectory($workspace);
+ $sceneLoader = new \Sendama\Console\Editor\SceneLoader($workspace);
+ $reloadedScene = $sceneLoader->load($settings->scenes);
+
+ expect($reloadedScene)->toBeInstanceOf(SceneDTO::class)
+ ->and($reloadedScene->name)->toBe('level01')
+ ->and($reloadedScene->sourcePath)->toBe($workspace . '/Assets/Scenes/level01.scene.php');
+});
+
+test('editor requests confirmation before closing with unsaved scene changes', function () {
+ $workspace = createEditorSceneSelectionWorkspace();
+ [$editor, $reflection] = createEditorForAssetSelection($workspace);
+
+ $loadedScene = new SceneDTO(
+ name: 'level01',
+ isDirty: true,
+ hierarchy: [],
+ sourcePath: $workspace . '/Assets/Scenes/level01.scene.php',
+ );
+ $reflection->getProperty('loadedScene')->setValue($editor, $loadedScene);
+
+ $requestEditorClose = $reflection->getMethod('requestEditorClose');
+ $requestEditorClose->setAccessible(true);
+ $requestEditorClose->invoke($editor);
+
+ $closeConfirmModal = $reflection->getProperty('closeConfirmModal');
+ $closeConfirmModal->setAccessible(true);
+ $modal = $closeConfirmModal->getValue($editor);
+
+ expect($modal)->toBeInstanceOf(OptionListModal::class)
+ ->and($modal->isVisible())->toBeTrue()
+ ->and($modal->getSelectedOption())->toBe('Save and Quit');
+});
+
test('editor creates a prefab from the selected hierarchy object and focuses the inspector', function () {
$workspace = createEditorPrefabExportWorkspace();
[$editor, $reflection, $hierarchyPanel, $assetsPanel, $mainPanel, $inspectorPanel] = createEditorForPrefabExport($workspace);
@@ -641,6 +809,65 @@ function createEditorAssetSelectionWorkspace(): string
return $workspace;
}
+function createEditorSceneSelectionWorkspace(): string
+{
+ $workspace = sys_get_temp_dir() . '/sendama-editor-scene-selection-' . uniqid();
+ mkdir($workspace . '/Assets/Scenes', 0777, true);
+
+ file_put_contents($workspace . '/sendama.json', json_encode([
+ 'name' => 'Scene Selection Test',
+ 'editor' => [
+ 'scenes' => [
+ 'active' => 0,
+ 'loaded' => ['Scenes/bootstrap.scene.php'],
+ ],
+ ],
+ ], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
+
+ file_put_contents($workspace . '/Assets/Scenes/level01.scene.php', <<<'PHP'
+ 'level01',
+ 'width' => 96,
+ 'height' => 28,
+ 'environmentTileMapPath' => 'Maps/example',
+ 'hierarchy' => [
+ [
+ 'type' => 'Sendama\\Engine\\Core\\GameObject',
+ 'name' => 'Player',
+ 'tag' => 'Player',
+ 'position' => ['x' => 12, 'y' => 8],
+ 'rotation' => ['x' => 0, 'y' => 0],
+ 'scale' => ['x' => 1, 'y' => 1],
+ 'components' => [],
+ ],
+ ],
+];
+PHP);
+
+ return $workspace;
+}
+
+function createEditorMaterialSelectionWorkspace(): string
+{
+ $workspace = sys_get_temp_dir() . '/sendama-editor-material-selection-' . uniqid();
+ mkdir($workspace . '/Assets/Materials', 0777, true);
+
+ file_put_contents($workspace . '/Assets/Materials/perfectly-elastic.material.php', <<<'PHP'
+ 'physics',
+ 'name' => 'Perfectly Elastic',
+ 'friction' => 0.0,
+ 'bounciness' => 1.0,
+];
+PHP);
+
+ return $workspace;
+}
+
function createEditorAssetCreationWorkspace(): string
{
$workspace = sys_get_temp_dir() . '/sendama-editor-asset-creation-' . uniqid();
@@ -801,12 +1028,14 @@ function createEditorForAssetSelection(string $workspace): array
$editorReflection->getProperty('workingDirectory')->setValue($editor, $workspace);
$editorReflection->getProperty('assetsDirectoryPath')->setValue($editor, $workspace . '/Assets');
+ $editorReflection->getProperty('settings')->setValue($editor, EditorSettings::loadFromDirectory($workspace));
$editorReflection->getProperty('hierarchyPanel')->setValue($editor, $hierarchyPanel);
$editorReflection->getProperty('assetsPanel')->setValue($editor, $assetsPanel);
$editorReflection->getProperty('mainPanel')->setValue($editor, $mainPanel);
$editorReflection->getProperty('consolePanel')->setValue($editor, $consolePanel);
$editorReflection->getProperty('inspectorPanel')->setValue($editor, $inspectorPanel);
$editorReflection->getProperty('panelListModal')->setValue($editor, new PanelListModal());
+ $editorReflection->getProperty('closeConfirmModal')->setValue($editor, new OptionListModal(title: 'Unsaved Changes'));
$editorReflection->getProperty('commandLineModal')->setValue($editor, new CommandLineModal());
$editorReflection->getProperty('commandHelpModal')->setValue($editor, new CommandHelpModal());
$editorReflection->getProperty('snackbar')->setValue($editor, new Snackbar());
diff --git a/tests/Unit/EditorFileWatchTest.php b/tests/Unit/EditorFileWatchTest.php
index 68939ab..76cad82 100644
--- a/tests/Unit/EditorFileWatchTest.php
+++ b/tests/Unit/EditorFileWatchTest.php
@@ -27,6 +27,7 @@
expect(implode("\n", $inspectorPanel->content))
->toContain('Speed: 1')
+ ->not->toContain('Speed: [')
->not->toContain('Lives: 3');
$synchronizeWatchedAssetChanges->invoke($editor, true);
@@ -39,9 +40,11 @@
namespace Sendama\Game\Scripts;
use Sendama\Engine\Core\Component;
+use Sendama\Engine\Core\Attributes\Range;
class WatcherComponent extends Component
{
+ #[Range(min: 0, max: 10)]
public int $speed = 1;
public int $lives = 3;
}
@@ -52,18 +55,24 @@ class WatcherComponent extends Component
$inspectionTarget = $inspectorPanel->getInspectionTarget();
$componentData = $loadedScene->hierarchy[0]['components'][0]['data'] ?? [];
+ $componentFieldSchemas = $loadedScene->hierarchy[0]['components'][0]['__editorFieldSchemas'] ?? [];
expect($inspectionTarget)->toMatchArray([
'context' => 'hierarchy',
'path' => 'scene.0',
]);
expect(implode("\n", $inspectorPanel->content))
- ->toContain('Speed: 1')
+ ->toContain('Speed: [')
->toContain('Lives: 3');
expect($componentData)->toMatchArray([
'speed' => 1,
'lives' => 3,
]);
+ expect($componentFieldSchemas['speed']['range'] ?? null)->toBe([
+ 'min' => 0,
+ 'max' => 10,
+ 'step' => 1,
+ ]);
expect($loadedScene->isDirty)->toBeTrue();
});
@@ -103,6 +112,19 @@ class SerializeField
}
}
+namespace Sendama\Engine\Core\Attributes {
+ #[\Attribute(\Attribute::TARGET_PROPERTY)]
+ class Range
+ {
+ public function __construct(
+ public int|float $min,
+ public int|float $max,
+ public int|float $step = 1,
+ ) {
+ }
+ }
+}
+
namespace Sendama\Engine\Core {
class Vector2
{
diff --git a/tests/Unit/InspectorPanelTest.php b/tests/Unit/InspectorPanelTest.php
index f06a020..89f32dc 100644
--- a/tests/Unit/InspectorPanelTest.php
+++ b/tests/Unit/InspectorPanelTest.php
@@ -731,6 +731,76 @@ public function __construct(GameObject $gameObject)
expect($inspectionTarget->getValue($panel)['value']['components'][0]['data']['statusUi'] ?? null)->toBe('Score');
});
+test('inspector panel filters physics material pickers to material assets', function () {
+ $workspace = sys_get_temp_dir() . '/sendama-inspector-material-picker-' . uniqid();
+ mkdir($workspace . '/Assets/Materials', 0777, true);
+ file_put_contents($workspace . '/Assets/Materials/perfectly-elastic.material.php', <<<'PHP'
+ 'physics',
+ 'name' => 'Perfectly Elastic',
+ 'friction' => 0.0,
+ 'bounciness' => 1.0,
+];
+PHP);
+ file_put_contents($workspace . '/Assets/Materials/sticky.material.php', <<<'PHP'
+ 'physics',
+ 'name' => 'Sticky',
+ 'friction' => 1.0,
+ 'bounciness' => 0.0,
+];
+PHP);
+
+ $panel = new InspectorPanel(width: 56, height: 24, workingDirectory: $workspace);
+ $materialReferenceOptions = new ReflectionProperty(InspectorPanel::class, 'materialReferenceOptions');
+ $materialReferenceModal = new ReflectionProperty(InspectorPanel::class, 'materialReferenceModal');
+ $materialReferenceOptions->setAccessible(true);
+ $materialReferenceModal->setAccessible(true);
+
+ focusInspectorPanel($panel);
+ $panel->inspectTarget([
+ 'context' => 'hierarchy',
+ 'name' => 'Ball',
+ 'type' => 'GameObject',
+ 'path' => 'scene.0',
+ 'value' => [
+ 'type' => 'Sendama\\Engine\\Core\\GameObject',
+ 'name' => 'Ball',
+ 'components' => [
+ [
+ 'class' => 'Sendama\\Engine\\Physics\\Rigidbody',
+ 'data' => [
+ 'material' => 'Materials/perfectly-elastic.material.php',
+ ],
+ '__editorFieldTypes' => [
+ 'material' => 'Sendama\\Engine\\Physics\\PhysicsMaterial|null',
+ ],
+ ],
+ ],
+ ],
+ ]);
+
+ expect(implode("\n", $panel->content))->toContain('Material: Perfectly Elastic');
+
+ selectInspectorControlByLabel($panel, 'Material');
+ setInspectorInput('enter');
+ $panel->update();
+
+ /** @var array $options */
+ $options = $materialReferenceOptions->getValue($panel);
+ $modal = $materialReferenceModal->getValue($panel);
+
+ expect($modal->isVisible())->toBeTrue()
+ ->and(array_values(array_map(
+ static fn(array $option): string => $option['name'],
+ $options,
+ )))->toBe(['Perfectly Elastic', 'Sticky']);
+});
+
test('inspector panel enters edit mode when a control is double clicked', function () {
$panel = new InspectorPanel(width: 48, height: 24);
$interactionState = new ReflectionProperty(InspectorPanel::class, 'interactionState');
@@ -1150,6 +1220,91 @@ public function __construct(GameObject $gameObject)
]);
});
+test('inspector panel exposes editable controls for physics material assets', function () {
+ $panel = new InspectorPanel(width: 48, height: 16);
+
+ $panel->inspectTarget([
+ 'context' => 'material_asset',
+ 'name' => 'Perfectly Elastic',
+ 'type' => 'Physics Material',
+ 'asset' => [
+ 'name' => 'perfectly-elastic.material.php',
+ 'path' => '/tmp/project/Assets/Materials/perfectly-elastic.material.php',
+ 'relativePath' => 'Materials/perfectly-elastic.material.php',
+ 'isDirectory' => false,
+ ],
+ 'value' => [
+ 'type' => 'physics',
+ 'name' => 'Perfectly Elastic',
+ 'friction' => 0.0,
+ 'bounciness' => 1.0,
+ ],
+ ]);
+
+ expect($panel->content)->toContain('Type: Physics Material')
+ ->and(implode("\n", $panel->content))->toContain('Friction:')
+ ->toContain('Bounciness:');
+});
+
+test('inspector panel emits material save mutations when material asset properties change', function () {
+ $panel = new InspectorPanel(width: 48, height: 16);
+
+ $panel->inspectTarget([
+ 'context' => 'material_asset',
+ 'name' => 'Perfectly Elastic',
+ 'type' => 'Physics Material',
+ 'asset' => [
+ 'name' => 'perfectly-elastic.material.php',
+ 'path' => '/tmp/project/Assets/Materials/perfectly-elastic.material.php',
+ 'relativePath' => 'Materials/perfectly-elastic.material.php',
+ 'isDirectory' => false,
+ ],
+ 'value' => [
+ 'type' => 'physics',
+ 'name' => 'Perfectly Elastic',
+ 'friction' => 0.0,
+ 'bounciness' => 1.0,
+ ],
+ ]);
+
+ $focusableControls = new ReflectionProperty(InspectorPanel::class, 'focusableControls');
+ $focusableControls->setAccessible(true);
+ $applyControlValueToInspectionTarget = new ReflectionMethod(InspectorPanel::class, 'applyControlValueToInspectionTarget');
+ $applyControlValueToInspectionTarget->setAccessible(true);
+
+ $frictionControl = null;
+
+ foreach ($focusableControls->getValue($panel) as $control) {
+ if ($control instanceof InputControl && $control->getLabel() === 'Friction') {
+ $frictionControl = $control;
+ break;
+ }
+ }
+
+ expect($frictionControl)->toBeInstanceOf(InputControl::class);
+
+ $frictionControl->setValue(0.35);
+ $applyControlValueToInspectionTarget->invoke($panel, $frictionControl);
+
+ expect($panel->consumeAssetMutation())->toBe([
+ 'operation' => 'save_material',
+ 'path' => '/tmp/project/Assets/Materials/perfectly-elastic.material.php',
+ 'relativePath' => 'Materials/perfectly-elastic.material.php',
+ 'asset' => [
+ 'name' => 'perfectly-elastic.material.php',
+ 'path' => '/tmp/project/Assets/Materials/perfectly-elastic.material.php',
+ 'relativePath' => 'Materials/perfectly-elastic.material.php',
+ 'isDirectory' => false,
+ ],
+ 'value' => [
+ 'type' => 'physics',
+ 'name' => 'Perfectly Elastic',
+ 'friction' => 0.35,
+ 'bounciness' => 1.0,
+ ],
+ ]);
+});
+
test('inspector panel text edit supports repeated backspace input while held', function () {
$panel = new InspectorPanel(width: 48, height: 16);
diff --git a/tests/Unit/PrefabLoaderTest.php b/tests/Unit/PrefabLoaderTest.php
index c7aacb6..4e025ba 100644
--- a/tests/Unit/PrefabLoaderTest.php
+++ b/tests/Unit/PrefabLoaderTest.php
@@ -185,12 +185,31 @@ public function __construct(private readonly GameObject $gameObject)
}
}
+namespace Sendama\Engine\Physics {
+ class PhysicsMaterial
+ {
+ public function __construct(
+ public float $friction = 0.5,
+ public float $bounciness = 0.5,
+ public ?string $name = null,
+ private ?string $assetPath = null,
+ ) {
+ }
+
+ public function getAssetPath(): ?string
+ {
+ return $this->assetPath;
+ }
+ }
+}
+
namespace Sendama\Game\Scripts {
use Sendama\Engine\Core\Behaviours\Attributes\SerializeField;
use Sendama\Engine\Core\Component;
use Sendama\Engine\Core\GameObject;
use Sendama\Engine\Core\Sprite;
use Sendama\Engine\Core\Texture;
+ use Sendama\Engine\Physics\PhysicsMaterial;
class WeaponConfig extends Component
{
@@ -200,6 +219,9 @@ class WeaponConfig extends Component
#[SerializeField]
protected ?Sprite $aimSprite = null;
+ #[SerializeField]
+ protected ?PhysicsMaterial $impactMaterial = null;
+
public function __construct(GameObject $gameObject)
{
parent::__construct($gameObject);
@@ -209,6 +231,7 @@ public function __construct(GameObject $gameObject)
['x' => 1, 'y' => 2, 'width' => 3, 'height' => 4],
['x' => 0, 'y' => 1],
);
+ $this->impactMaterial = new PhysicsMaterial(0.0, 1.0, 'Perfectly Elastic', 'Materials/perfectly-elastic.material.php');
}
}
}
@@ -258,10 +281,12 @@ public function __construct(GameObject $gameObject)
'y' => 1,
],
],
+ 'impactMaterial' => 'Materials/perfectly-elastic.material.php',
])
->and($prefab['components'][0]['__editorFieldTypes'] ?? null)->toBe([
'bulletTexture' => 'Sendama\\Engine\\Core\\Texture|null',
'aimSprite' => 'Sendama\\Engine\\Core\\Sprite|null',
+ 'impactMaterial' => 'Sendama\\Engine\\Physics\\PhysicsMaterial|null',
]);
});
diff --git a/tests/Unit/SceneLoaderTest.php b/tests/Unit/SceneLoaderTest.php
index 7a7b69f..4c2c45d 100644
--- a/tests/Unit/SceneLoaderTest.php
+++ b/tests/Unit/SceneLoaderTest.php
@@ -716,12 +716,31 @@ public function __construct(private readonly GameObject $gameObject)
}
}
+namespace Sendama\Engine\Physics {
+ class PhysicsMaterial
+ {
+ public function __construct(
+ public float $friction = 0.5,
+ public float $bounciness = 0.5,
+ public ?string $name = null,
+ private ?string $assetPath = null,
+ ) {
+ }
+
+ public function getAssetPath(): ?string
+ {
+ return $this->assetPath;
+ }
+ }
+}
+
namespace Sendama\Game {
use Sendama\Engine\Core\Behaviours\Attributes\SerializeField;
use Sendama\Engine\Core\Component;
use Sendama\Engine\Core\GameObject;
use Sendama\Engine\Core\Sprite;
use Sendama\Engine\Core\Texture;
+ use Sendama\Engine\Physics\PhysicsMaterial;
class WeaponConfig extends Component
{
@@ -731,6 +750,9 @@ class WeaponConfig extends Component
#[SerializeField]
protected ?Sprite $aimSprite = null;
+ #[SerializeField]
+ protected ?PhysicsMaterial $impactMaterial = null;
+
public function __construct(GameObject $gameObject)
{
parent::__construct($gameObject);
@@ -740,6 +762,7 @@ public function __construct(GameObject $gameObject)
['x' => 1, 'y' => 2, 'width' => 3, 'height' => 4],
['x' => 0, 'y' => 1],
);
+ $this->impactMaterial = new PhysicsMaterial(0.0, 1.0, 'Perfectly Elastic', 'Materials/perfectly-elastic.material.php');
}
}
}
@@ -795,10 +818,23 @@ public function __construct(GameObject $gameObject)
'y' => 1,
],
],
+ 'impactMaterial' => 'Materials/perfectly-elastic.material.php',
],
'__editorFieldTypes' => [
'bulletTexture' => 'Sendama\\Engine\\Core\\Texture|null',
'aimSprite' => 'Sendama\\Engine\\Core\\Sprite|null',
+ 'impactMaterial' => 'Sendama\\Engine\\Physics\\PhysicsMaterial|null',
+ ],
+ '__editorFieldSchemas' => [
+ 'bulletTexture' => [
+ 'type' => 'Sendama\\Engine\\Core\\Texture|null',
+ ],
+ 'aimSprite' => [
+ 'type' => 'Sendama\\Engine\\Core\\Sprite|null',
+ ],
+ 'impactMaterial' => [
+ 'type' => 'Sendama\\Engine\\Physics\\PhysicsMaterial|null',
+ ],
],
],
]);