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', + ], ], ], ]);