diff --git a/README.md b/README.md index 887b4cc..16dda96 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,10 @@ Sendama CLI is a console application that provides a command line interface for ![Screenshot](docs/screenshot.png) +## Editor Guide + +For the current editor manual, see [Editor.md](docs/Editor.md). + ## Requirements - PHP 8.3 or newer @@ -122,4 +126,4 @@ sendama generate:texture mytexture #### Generate a new texture with a specific size ```bash sendama generate:texture mytexture --width=32 --height=32 -``` \ No newline at end of file +``` diff --git a/docs/Editor.md b/docs/Editor.md new file mode 100644 index 0000000..35c9294 --- /dev/null +++ b/docs/Editor.md @@ -0,0 +1,477 @@ +# Sendama Editor Manual + +This document is a living guide to the Sendama editor. + +It is meant to track the editor as it exists today, including current hotkeys, panel workflows, and known behavior. Update this file whenever the editor gains new tools, panels, controls, or shortcuts. + +## Starting the Editor + +Open the editor from inside a Sendama project: + +```bash +sendama edit +``` + +Or point it at a project directory explicitly: + +```bash +sendama edit --directory /path/to/project +``` + +The editor expects a valid Sendama project workspace. In particular: + +- editor settings and project settings must be present +- the project should contain an `Assets` folder +- the active scene is loaded from the configured scene metadata + +## Layout Overview + +The editor currently uses five main panels: + +- `Hierarchy`: scene tree and scene object management +- `Assets`: project browser rooted at the project's `Assets` folder +- `Main`: workspace area with `Scene`, `Game`, and `Sprite` tabs +- `Console`: project log view +- `Inspector`: object and asset details, plus property editing + +## Global Shortcuts + +These shortcuts work regardless of the currently focused panel unless a modal is open. + +| Key | Action | +| --- | --- | +| `Shift+Up` | Move focus to the panel above the current one, if a sibling exists | +| `Shift+Right` | Move focus to the panel on the right, if a sibling exists | +| `Shift+Down` | Move focus to the panel below the current one, if a sibling exists | +| `Shift+Left` | Move focus to the panel on the left, if a sibling exists | +| `Shift+1` | Open the panel list modal | +| `Shift+5` | Toggle play mode globally | +| `Ctrl+C` | Close the editor gracefully | +| `Ctrl+S` | Save the loaded scene | +| `Shift+A` | Open the Hierarchy add workflow, or create a Sprite asset when the Sprite tab is focused | + +## Panel List Modal + +Press `Shift+1` to open a modal listing all panels. + +Controls: + +- `Up` / `Down`: move selection +- `Enter`: focus the selected panel +- `Escape`: close the modal + +## Main Panel + +The main panel has three tabs: + +- `Scene` +- `Game` +- `Sprite` + +Controls: + +- `Tab`: cycle to the next tab +- `Shift+Tab`: cycle to the previous tab + +### Scene Tab + +When the `Scene` tab is active, the main panel renders the current scene graph using each object's stored position. + +Current scene rendering behavior: + +- objects are drawn at their `position` +- sprite-backed objects render character data from their `.texture` files +- the scene's `environmentTileMapPath` is rendered as a static background layer and is not selectable +- sprite rendering uses: + - `sprite.texture.path` + - `sprite.texture.position` + - `sprite.texture.size` +- texture paths are resolved relative to the editor's configured project directory +- scene coordinates are rendered into a scrollable viewport +- UI text objects render their `text` +- objects without a visible representation are not currently drawn in the scene tab +- the main panel help line shows the current scene controls on the left and the active mode on the right + +When the main panel has focus and the `Scene` tab is active, it uses scene-view modes. + +#### Scene View Modes + +| Key | Mode | +| --- | --- | +| `Shift+Q` | Select Mode | +| `Shift+W` | Move Mode | +| `Shift+E` | Pan Mode | + +#### Select Mode + +Use Select Mode to move between visible scene objects without changing them. + +Controls: + +- `Up` / `Left`: select the previous visible scene object +- `Down` / `Right`: select the next visible scene object +- changing the selection immediately syncs the Inspector and Hierarchy to the selected object +- `Enter`: reload the selected object into the Inspector + +#### Move Mode + +Use Move Mode to reposition the currently selected scene object. + +Controls: + +- `Up`: decrement `transform.position.y` +- `Right`: increment `transform.position.x` +- `Down`: increment `transform.position.y` +- `Left`: decrement `transform.position.x` + +Moving an object updates `transform.position` and marks the scene dirty. +If the moved object is loaded in the Inspector, its transform values update immediately as it moves. + +#### Pan Mode + +Use Pan Mode to scroll the visible scene viewport when the scene is larger than the panel. + +Controls: + +- `Up`: pan the view upward +- `Right`: pan the view to the right +- `Down`: pan the view downward +- `Left`: pan the view to the left + +### Game Tab + +When the `Game` tab is selected and the editor is not in play mode: + +- the panel shows a shaded idle view +- the centered prompt reads `Shift+5 to Play` + +When play mode is entered: + +- focus immediately shifts to the `Main` panel +- the `Game` tab becomes active +- the main panel focus border changes to a warmer play-mode color + +### Sprite Tab + +The `Sprite` tab is the asset grid editor for `.texture` and `.tmap` files. + +Current behavior: + +- selecting a `.texture` or `.tmap` file in `Assets` loads it into the `Sprite` tab +- the editor works on a character grid backed directly by the selected file +- the visible canvas is only the editable grid itself; asset metadata is shown in the Inspector +- textures load into an editable area that can grow up to `16x16` +- new tile maps are created at the current terminal-size bounds +- the right side of the main-panel help line shows the live cursor position as `Col x Row` +- edits are written to the asset file immediately + +Controls: + +- `Up` / `Right` / `Down` / `Left`: move the sprite cursor +- type any printable character: draw that character at the cursor +- `Shift+2`: open the character selector modal for special characters +- `Space`: place a blank character +- `Backspace`: erase the current cell +- `Shift+A`: open the create-asset modal +- `Ctrl+Z`: undo the last grid change +- `Ctrl+Y`: redo the last undone grid change +- `Shift+R`: reset the loaded asset back to the state it had when it was opened +- `Delete`: open the delete-asset confirmation modal + +Create workflow: + +- `Shift+A` opens a modal with `Texture`, `Tile Map`, and `Cancel` +- choosing `Texture` creates a new `.texture` file in `Assets/Textures` +- choosing `Tile Map` creates a new `.tmap` file in `Assets/Maps` +- the new asset is loaded into the sprite editor immediately + +Delete workflow: + +- `Delete` opens a confirmation modal for the currently loaded asset +- confirming deletes the file and clears the Sprite editor view + +Character selector workflow: + +- `Shift+2` opens a modal of curated special characters useful for sprites and maps +- `Up` / `Down`: move selection +- `Enter`: insert the selected character at the current cursor position +- `Escape`: close the modal without inserting anything + +## Hierarchy Panel + +The hierarchy shows the loaded scene as a tree. + +Current structure: + +- the scene name is the root node +- a dirty scene is shown as `*` +- child scene objects appear under the scene root +- selecting the scene root and pressing `Enter` loads the scene details into the Inspector + +Controls: + +- `Up` / `Down`: move selection +- `Right`: expand a collapsed node, or move into its children +- `Left`: collapse an expanded node, or move to its parent +- `Enter`: load the selected object into the Inspector +- `Shift+A`: open the add-object workflow +- `Delete`: open the delete confirmation dialog + +Selected rows are highlighted, and when the hierarchy has focus the selected row blinks. + +### Add Object Workflow + +Press `Shift+A` to add a new scene object while the editor is in edit mode. + +Flow: + +1. Choose `GameObject` or `UIElement` +2. If `UIElement` is selected, choose a concrete type + +Currently supported UI element types: + +- `Text` +- `Label` + +Default names use this format: + +- ` #` + +Examples: + +- `GameObject #1` +- `Label #2` + +### Delete Workflow + +Press `Delete` on a selected hierarchy object to open a confirmation modal: + +```text +Are you sure you want to delete ? +``` + +Controls: + +- `Up` / `Down`: choose `Delete` or `Cancel` +- `Enter`: confirm the selection +- `Escape`: cancel + +## Assets Panel + +The Assets panel is a tree view rooted at the project's `Assets` directory. + +Controls: + +- `Up` / `Down`: move selection +- `Right`: expand a folder, or move into it +- `Left`: collapse a folder, or move to its parent +- `Enter`: load the selected asset into the Inspector +- `Delete`: open the delete confirmation dialog + +Inspector type mapping: + +- directories are shown as `Folder` +- files are shown as `File` + +### Asset Delete Workflow + +Press `Delete` on a selected asset to open a confirmation modal: + +```text +Are you sure you want to delete ? +``` + +Controls: + +- `Up` / `Down`: choose `Delete` or `Cancel` +- `Enter`: confirm the selection +- `Escape`: cancel + +## Inspector Panel + +The Inspector shows details for the currently inspected target. + +Current target sources: + +- Hierarchy selection +- Scene tab selection +- Assets selection + +For file assets, the Inspector currently shows: + +- `Type` +- editable `Name` +- read-only `Path` + +Renaming a texture or tile map from the Inspector renames the file on disk and updates known scene references in memory, such as `sprite.texture.path` and `environmentTileMapPath`. + +### Inspector Hotkeys + +When the Inspector has focus: + +- `Tab`: move to the next control +- `Shift+Tab`: move to the previous control + +The Inspector uses a small state machine. + +### Inspector States + +#### 1. Control Selection + +This is the default state when the Inspector gains focus. + +Controls: + +- `Up` / `Down`: move between controls +- `Enter`: activate the selected control + +#### 2. Property Selection + +Used for compound controls such as vectors. + +Examples: + +- `Position` +- `Rotation` +- `Scale` +- renderer `Offset` +- renderer `Size` + +Controls: + +- `Up` / `Down`: move between sub-properties +- `Enter`: edit the selected sub-property +- `Escape`: return to Control Selection + +#### 3. Control Edit + +Used when editing a concrete value. + +##### Text Input + +Controls: + +- type letters, numbers, and symbols to edit text +- `Backspace`: delete backward +- `Left` / `Right`: move the cursor +- `Enter`: commit the value +- `Escape`: cancel the edit + +##### Number Input + +Controls: + +- type numbers directly +- `Up`: increment +- `Down`: decrement +- `Left` / `Right`: move the cursor when applicable +- `Enter`: commit the value +- `Escape`: cancel the edit + +### Current Hierarchy Inspection Layout + +For hierarchy objects, the Inspector currently renders: + +1. Global properties: + - `Type` + - `Name` + - `Tag` +2. Built-in sections: + - `Transform` + - `Renderer` +3. Script/component sections from the scene metadata + +Component headers are visually marked as collapsible sections. + +### Renderer Section + +The renderer reads from the object's `sprite` metadata. + +Current fields: + +- `Texture` +- `Offset` +- `Size` +- `Preview` + +The preview is cropped from the texture file using the same texture path, offset, and size information used by the scene tab for sprite-backed scene rendering. +Texture paths are resolved relative to the project working directory that was used to open the editor. + +### Path Input Workflow + +Path-based controls, such as renderer texture paths, behave differently from normal text inputs. + +Pressing `Enter` on a path input first opens a modal with: + +- `Choose file` +- `Edit path` + +#### Choose File + +Opens a file tree dialog rooted at the control's working directory. + +When a control specifies allowed extensions, the dialog limits visible files to matching extensions and hides directories that do not contain any matching files. + +Controls: + +- `Up` / `Down`: move selection +- `Right`: expand a folder, or move into it +- `Left`: collapse a folder, or move to its parent +- `Enter`: select the highlighted file +- `Escape`: cancel or go back + +Submitting a file writes the path back as a path relative to the configured working directory. + +#### Edit Path + +Enters normal text input editing for the path field. + +Submitting either path mode returns the Inspector to control-selection state. + +## Console Panel + +The Console panel currently reads from: + +```text +/logs/debug.log +``` + +Current behavior: + +- on editor startup it loads the last three log lines +- log display is clipped to the console viewport +- when the console has focus and the editor is not in play mode, it supports scrolling + +Controls: + +- `Up`: scroll up through older log lines +- `Down`: scroll down through newer log lines + +The scroll stops: + +- at the beginning of the file +- at the point where the last log line is at the top of the viewport + +Current tag colors: + +- `[ERROR]`: red +- `[WARN]`: yellow +- `[INFO]`: blue +- `[DEBUG]`: light gray + +## Saving + +Press `Ctrl+S` to save the loaded scene. + +Current save behavior: + +- inspector edits are written back to the loaded scene model +- scene-view moves are written back to the loaded scene model +- hierarchy additions and deletions update the loaded scene model +- the dirty marker on the scene root clears after a successful save + +## Notes + +- the editor is currently keyboard-first +- panels and modals are designed to restore the previous view cleanly when dismissed +- this document should be updated whenever a new shortcut, panel workflow, or modal flow is added diff --git a/src/Editor/DTOs/HierarchyObjectDTO.php b/src/Editor/DTOs/HierarchyObjectDTO.php index 0b3cc57..8be06ac 100644 --- a/src/Editor/DTOs/HierarchyObjectDTO.php +++ b/src/Editor/DTOs/HierarchyObjectDTO.php @@ -2,45 +2,158 @@ namespace Sendama\Console\Editor\DTOs; -use Serializable; - /** * HierarchyObjectDTO class. */ class HierarchyObjectDTO { public function __construct( + public string $type, public string $name, - public string $tag = '', - public array $position = [1, 1], - public array $rotation = [0, 0], - public array $scale = [1, 1] + public ?string $tag = null, + public array $position = ['x' => 0, 'y' => 0], + public ?array $rotation = null, + public ?array $scale = null, + public ?array $size = null, + public ?array $sprite = null, + public ?string $text = null, + public array $components = [], + public array $children = [], ) { } public function __serialize(): array { - return [ + $data = [ + 'type' => $this->type, 'name' => $this->name, - 'tag' => $this->tag, 'position' => $this->position, - 'rotation' => $this->rotation, - 'scale' => $this->scale ]; + + if ($this->tag !== null && $this->tag !== '') { + $data['tag'] = $this->tag; + } + + if (is_array($this->rotation)) { + $data['rotation'] = $this->rotation; + } + + if (is_array($this->scale)) { + $data['scale'] = $this->scale; + } + + if (is_array($this->size)) { + $data['size'] = $this->size; + } + + if (is_array($this->sprite)) { + $data['sprite'] = $this->sprite; + } + + if ($this->text !== null) { + $data['text'] = $this->text; + } + + if ($this->components !== []) { + $data['components'] = $this->components; + } + + if ($this->children !== []) { + $data['children'] = array_map( + fn (mixed $child) => $child instanceof self ? $child->__serialize() : $child, + $this->children, + ); + } + + return $data; } public function __unserialize(array $data): void { - $this->name = $data['name'] ?? ''; - $this->tag = $data['tag'] ?? ''; - $this->position = $data['position'] ?? [1, 1]; - $this->rotation = $data['rotation'] ?? [0, 0]; - $this->scale = $data['scale'] ?? [1, 1]; + $dto = self::fromArray($data); + $this->type = $dto->type; + $this->name = $dto->name; + $this->tag = $dto->tag; + $this->position = $dto->position; + $this->rotation = $dto->rotation; + $this->scale = $dto->scale; + $this->size = $dto->size; + $this->sprite = $dto->sprite; + $this->text = $dto->text; + $this->components = $dto->components; + $this->children = $dto->children; } public static function fromArray(array $data): self { + $isUiElement = self::isUiElementData($data); + + return new self( + type: is_string($data['type'] ?? null) ? $data['type'] : '', + name: is_string($data['name'] ?? null) ? $data['name'] : '', + tag: is_string($data['tag'] ?? null) ? $data['tag'] : null, + position: self::normalizeVector($data['position'] ?? null), + rotation: $isUiElement ? null : self::normalizeVector($data['rotation'] ?? null), + scale: $isUiElement ? null : self::normalizeVector($data['scale'] ?? ['x' => 1, 'y' => 1], ['x' => 1, 'y' => 1]), + size: $isUiElement || array_key_exists('size', $data) + ? self::normalizeVector($data['size'] ?? null) + : null, + sprite: is_array($data['sprite'] ?? null) ? $data['sprite'] : null, + text: is_string($data['text'] ?? null) ? $data['text'] : null, + components: is_array($data['components'] ?? null) ? array_values($data['components']) : [], + children: self::normalizeChildren($data['children'] ?? []), + ); + } + + private static function isUiElementData(array $data): bool + { + $type = is_string($data['type'] ?? null) ? ltrim($data['type'], '\\') : ''; + $type = preg_replace('/::class$/', '', $type) ?? $type; + + if ($type !== '' && str_starts_with($type, 'Sendama\\Engine\\UI\\')) { + return true; + } + + return array_key_exists('size', $data) || array_key_exists('text', $data); + } + + private static function normalizeVector(mixed $value, array $default = ['x' => 0, 'y' => 0]): array + { + if (!is_array($value)) { + return $default; + } + + return [ + 'x' => self::normalizeNumeric($value['x'] ?? $default['x']), + 'y' => self::normalizeNumeric($value['y'] ?? $default['y']), + ]; + } + + private static function normalizeNumeric(mixed $value): int|float + { + if (is_int($value) || is_float($value)) { + return $value; + } + + if (is_numeric($value)) { + return str_contains((string) $value, '.') ? (float) $value : (int) $value; + } + + return 0; + } + + private static function normalizeChildren(mixed $children): array + { + if (!is_array($children)) { + return []; + } + return array_values(array_map( + fn (mixed $child) => $child instanceof self + ? $child + : (is_array($child) ? self::fromArray($child) : $child), + $children, + )); } -} \ No newline at end of file +} diff --git a/src/Editor/DTOs/SceneDTO.php b/src/Editor/DTOs/SceneDTO.php index 7a1e10c..d52ba98 100644 --- a/src/Editor/DTOs/SceneDTO.php +++ b/src/Editor/DTOs/SceneDTO.php @@ -14,6 +14,9 @@ public function __construct( public string $environmentTileMapPath = "Maps/example", public bool $isDirty = false, public array $hierarchy = [], + public ?string $sourcePath = null, + public array $rawData = [], + public array $sourceData = [], ) { } @@ -27,6 +30,9 @@ public function __serialize(): array "environmentTileMapPath" => $this->environmentTileMapPath, "isDirty" => $this->isDirty, "hierarchy" => $this->hierarchy, + "sourcePath" => $this->sourcePath, + "rawData" => $this->rawData, + "sourceData" => $this->sourceData, ]; } @@ -38,5 +44,8 @@ public function __unserialize(array $data): void $this->environmentTileMapPath = $data['environmentTileMapPath'] ?? "Maps/example"; $this->isDirty = $data['isDirty'] ?? false; $this->hierarchy = $data['hierarchy'] ?? []; + $this->sourcePath = $data['sourcePath'] ?? null; + $this->rawData = $data['rawData'] ?? []; + $this->sourceData = $data['sourceData'] ?? []; } } diff --git a/src/Editor/Editor.php b/src/Editor/Editor.php index 24f8c95..6821987 100644 --- a/src/Editor/Editor.php +++ b/src/Editor/Editor.php @@ -130,6 +130,7 @@ final class Editor implements ObservableInterface protected PanelListModal $panelListModal; protected bool $shouldRefreshBackgroundUnderModal = false; protected bool $didRenderOverlayLastFrame = false; + protected SceneWriter $sceneWriter; /** * @param string $name @@ -152,6 +153,7 @@ public function __construct( $this->refreshTerminalSize(force: true); $this->initializeManagers(); $this->initializeConsole(); + $this->sceneWriter = new SceneWriter(); $this->initializeWidgets(); $this->initializeEditorStates(); $this->splashScreen = new SplashScreen( @@ -372,6 +374,13 @@ private function update(): void $panel->update(); } + $this->synchronizeAssetDeletions(); + $this->synchronizeHierarchyDeletions(); + $this->synchronizeHierarchyAdditions(); + $this->synchronizeMainPanelSceneChanges(); + $this->synchronizeMainPanelAssetChanges(); + $this->synchronizeInspectorSceneChanges(); + $this->synchronizeInspectorAssetChanges(); $this->synchronizeInspectorPanel(); $this->notify(new EditorEvent(EventType::EDITOR_UPDATED->value, $this)); @@ -577,15 +586,26 @@ private function initializeWidgets(): void sceneName: $this->loadedScene?->name ?? 'Scene', isSceneDirty: $this->loadedScene?->isDirty ?? false, hierarchy: $this->loadedScene?->hierarchy ?? [], + sceneWidth: $this->loadedScene?->width ?? DEFAULT_TERMINAL_WIDTH, + sceneHeight: $this->loadedScene?->height ?? DEFAULT_TERMINAL_HEIGHT, + environmentTileMapPath: $this->loadedScene?->environmentTileMapPath ?? 'Maps/example', ); $this->assetsPanel = new AssetsPanel( assetsDirectoryPath: $this->assetsDirectoryPath, ); - $this->mainPanel = new MainPanel(); + $this->mainPanel = new MainPanel( + sceneObjects: $this->loadedScene?->hierarchy ?? [], + workingDirectory: $this->workingDirectory, + sceneWidth: $this->loadedScene?->width ?? DEFAULT_TERMINAL_WIDTH, + sceneHeight: $this->loadedScene?->height ?? DEFAULT_TERMINAL_HEIGHT, + environmentTileMapPath: $this->loadedScene?->environmentTileMapPath ?? 'Maps/example', + ); $this->consolePanel = new ConsolePanel( logFilePath: Path::join($this->workingDirectory, 'logs', 'debug.log'), ); - $this->inspectorPanel = new InspectorPanel(); + $this->inspectorPanel = new InspectorPanel( + workingDirectory: $this->workingDirectory, + ); $this->panels->add($this->hierarchyPanel); $this->panels->add($this->assetsPanel); @@ -642,6 +662,16 @@ private function createFocusTargetContext(): FocusTargetContext private function handlePanelKeyboardWorkflow(): void { + if (Input::isKeyDown(IO\Enumerations\KeyCode::CTRL_C)) { + $this->stop(); + return; + } + + if (Input::isKeyDown(IO\Enumerations\KeyCode::CTRL_S)) { + $this->saveLoadedScene(); + return; + } + if (Input::isKeyDown(IO\Enumerations\KeyCode::PLAY_TOGGLE, false)) { $this->togglePlayMode(); return; @@ -661,6 +691,18 @@ private function handlePanelKeyboardWorkflow(): void return; } + if (Input::getCurrentInput() === 'A' && !($this->editorState instanceof PlayState)) { + if ($this->focusedPanel === $this->mainPanel && $this->mainPanel->beginSpriteCreateWorkflow()) { + $this->shouldRefreshBackgroundUnderModal = true; + return; + } + + $this->setFocusedPanel($this->hierarchyPanel); + $this->hierarchyPanel->beginAddWorkflow(); + $this->shouldRefreshBackgroundUnderModal = true; + return; + } + if (Input::isKeyDown(IO\Enumerations\KeyCode::SHIFT_UP)) { $this->focusSiblingPanel('top'); return; @@ -877,15 +919,755 @@ private function handlePanelListModalInput(): void private function synchronizeInspectorPanel(): void { $selectedItem = $this->hierarchyPanel->consumeInspectionRequest() - ?? $this->assetsPanel->consumeInspectionRequest(); + ?? $this->assetsPanel->consumeInspectionRequest() + ?? $this->mainPanel->consumeInspectionRequest(); if ($selectedItem === null) { return; } + if (($selectedItem['context'] ?? null) === 'hierarchy' && is_string($selectedItem['path'] ?? null)) { + $this->hierarchyPanel->selectPath($selectedItem['path']); + $this->mainPanel->selectSceneObject($selectedItem['path']); + } elseif (($selectedItem['context'] ?? null) === 'scene') { + $this->hierarchyPanel->selectPath('scene'); + } elseif (($selectedItem['context'] ?? null) === 'asset') { + $this->mainPanel->loadSpriteAsset(is_array($selectedItem['value'] ?? null) ? $selectedItem['value'] : null); + } + $this->inspectorPanel->inspectTarget($selectedItem); } + private function synchronizeInspectorSceneChanges(): void + { + $mutation = $this->inspectorPanel->consumeHierarchyMutation(); + + if ( + !is_array($mutation) + || !isset($mutation['path'], $mutation['value']) + || !$this->loadedScene instanceof DTOs\SceneDTO + || !is_string($mutation['path']) + || !is_array($mutation['value']) + ) { + return; + } + + if ($mutation['path'] === 'scene') { + if (!$this->applySceneMutation($mutation['value'])) { + return; + } + + $this->loadedScene->isDirty = true; + $this->syncScenePanels(true); + $this->inspectorPanel->syncSceneTarget($this->buildSceneInspectionValue()); + return; + } + + if (!$this->applyHierarchyMutation($mutation['path'], $mutation['value'])) { + return; + } + + $this->loadedScene->isDirty = true; + $this->loadedScene->rawData['hierarchy'] = $this->loadedScene->hierarchy; + $this->hierarchyPanel->syncHierarchy($this->loadedScene->hierarchy); + $this->hierarchyPanel->selectPath($mutation['path']); + $this->syncScenePanels(true); + $this->mainPanel->setSceneObjects($this->loadedScene->hierarchy); + $this->mainPanel->selectSceneObject($mutation['path']); + $this->inspectorPanel->syncHierarchyTarget($mutation['path'], $mutation['value']); + } + + private function synchronizeInspectorAssetChanges(): void + { + $mutation = $this->inspectorPanel->consumeAssetMutation(); + + if ( + !is_array($mutation) + || !is_string($mutation['path'] ?? null) + || $mutation['path'] === '' + || !is_string($mutation['name'] ?? null) + ) { + return; + } + + $renamedAsset = $this->renameAssetAndCascadeReferences( + $mutation['path'], + $mutation['relativePath'] ?? null, + $mutation['name'], + ); + + if ($renamedAsset === null) { + if (is_file($mutation['path'])) { + $this->inspectorPanel->syncAssetTarget([ + 'name' => basename($mutation['path']), + 'path' => $mutation['path'], + 'relativePath' => is_string($mutation['relativePath'] ?? null) + ? $mutation['relativePath'] + : basename($mutation['path']), + 'isDirectory' => false, + 'children' => [], + ]); + } + return; + } + + $this->assetsPanel->reloadAssets(); + $this->assetsPanel->selectAssetByAbsolutePath($renamedAsset['path']); + $assetInspectionTarget = $this->buildAssetInspectionTarget($renamedAsset); + $this->inspectorPanel->inspectTarget($assetInspectionTarget); + $this->mainPanel->loadSpriteAsset($renamedAsset); + } + + private function synchronizeHierarchyAdditions(): void + { + $newItem = $this->hierarchyPanel->consumeCreationRequest(); + + if (!$this->loadedScene instanceof DTOs\SceneDTO || !is_array($newItem) || $newItem === []) { + return; + } + + $this->loadedScene->hierarchy[] = $newItem; + $this->loadedScene->isDirty = true; + $this->loadedScene->rawData['hierarchy'] = $this->loadedScene->hierarchy; + $this->hierarchyPanel->syncHierarchy($this->loadedScene->hierarchy); + $newPath = 'scene.' . (count($this->loadedScene->hierarchy) - 1); + $this->hierarchyPanel->selectPath($newPath); + $this->syncScenePanels(true); + $this->mainPanel->setSceneObjects($this->loadedScene->hierarchy); + $this->mainPanel->selectSceneObject($newPath); + } + + private function synchronizeHierarchyDeletions(): void + { + $deletionRequest = $this->hierarchyPanel->consumeDeletionRequest(); + + if ( + !$this->loadedScene instanceof DTOs\SceneDTO + || !is_array($deletionRequest) + || !is_string($deletionRequest['path'] ?? null) + || $deletionRequest['path'] === '' + ) { + return; + } + + if (!$this->deleteHierarchyNode($deletionRequest['path'])) { + return; + } + + $this->loadedScene->isDirty = true; + $this->loadedScene->rawData['hierarchy'] = $this->loadedScene->hierarchy; + $this->hierarchyPanel->syncHierarchy($this->loadedScene->hierarchy); + $this->syncScenePanels(true); + $this->mainPanel->setSceneObjects($this->loadedScene->hierarchy); + $this->inspectorPanel->inspectTarget(null); + } + + private function synchronizeAssetDeletions(): void + { + $deletionRequest = $this->assetsPanel->consumeDeletionRequest(); + + if ( + !is_array($deletionRequest) + || !is_string($deletionRequest['assetPath'] ?? null) + || $deletionRequest['assetPath'] === '' + ) { + return; + } + + if (!$this->deleteAssetPath($deletionRequest['assetPath'])) { + return; + } + + $this->assetsPanel->reloadAssets(); + $this->inspectorPanel->inspectTarget(null); + } + + private function synchronizeMainPanelSceneChanges(): void + { + $mutation = $this->mainPanel->consumeHierarchyMutation(); + + if ( + !is_array($mutation) + || !isset($mutation['path'], $mutation['value']) + || !$this->loadedScene instanceof DTOs\SceneDTO + || !is_string($mutation['path']) + || !is_array($mutation['value']) + ) { + return; + } + + if (!$this->applyHierarchyMutation($mutation['path'], $mutation['value'])) { + return; + } + + $this->loadedScene->isDirty = true; + $this->loadedScene->rawData['hierarchy'] = $this->loadedScene->hierarchy; + $this->hierarchyPanel->syncHierarchy($this->loadedScene->hierarchy); + $this->hierarchyPanel->selectPath($mutation['path']); + $this->syncScenePanels(true); + $this->mainPanel->setSceneObjects($this->loadedScene->hierarchy); + $this->mainPanel->selectSceneObject($mutation['path']); + $this->inspectorPanel->syncHierarchyTarget($mutation['path'], $mutation['value']); + } + + private function synchronizeMainPanelAssetChanges(): void + { + $assetSyncRequest = $this->mainPanel->consumeAssetSyncRequest(); + + if (!is_array($assetSyncRequest)) { + return; + } + + $this->assetsPanel->reloadAssets(); + + if (is_string($assetSyncRequest['path'] ?? null)) { + $this->assetsPanel->selectAssetByAbsolutePath($assetSyncRequest['path']); + } + + if (is_array($assetSyncRequest['inspectionTarget'] ?? null)) { + $this->inspectorPanel->inspectTarget($assetSyncRequest['inspectionTarget']); + return; + } + + if (($assetSyncRequest['clearInspection'] ?? false) === true) { + $this->inspectorPanel->inspectTarget(null); + } + } + + private function applyHierarchyMutation(string $path, array $value): bool + { + if (!$this->loadedScene instanceof DTOs\SceneDTO) { + return false; + } + + $segments = explode('.', $path); + + if (($segments[0] ?? null) !== 'scene') { + return false; + } + + array_shift($segments); + + if ($segments === []) { + return false; + } + + $hierarchy = $this->loadedScene->hierarchy; + $nodeArray = &$hierarchy; + $lastIndex = count($segments) - 1; + + foreach ($segments as $index => $segment) { + if (!ctype_digit((string) $segment)) { + return false; + } + + $numericSegment = (int) $segment; + + if (!isset($nodeArray[$numericSegment]) || !is_array($nodeArray[$numericSegment])) { + return false; + } + + if ($index === $lastIndex) { + $nodeArray[$numericSegment] = $value; + $this->loadedScene->hierarchy = array_values($hierarchy); + + return true; + } + + if (!isset($nodeArray[$numericSegment]['children']) || !is_array($nodeArray[$numericSegment]['children'])) { + return false; + } + + $nodeArray = &$nodeArray[$numericSegment]['children']; + } + + return false; + } + + private function deleteHierarchyNode(string $path): bool + { + if (!$this->loadedScene instanceof DTOs\SceneDTO) { + return false; + } + + $segments = explode('.', $path); + + if (($segments[0] ?? null) !== 'scene') { + return false; + } + + array_shift($segments); + + if ($segments === []) { + return false; + } + + $hierarchy = $this->loadedScene->hierarchy; + $nodeArray = &$hierarchy; + $lastIndex = count($segments) - 1; + + foreach ($segments as $index => $segment) { + if (!ctype_digit((string) $segment)) { + return false; + } + + $numericSegment = (int) $segment; + + if (!isset($nodeArray[$numericSegment])) { + return false; + } + + if ($index === $lastIndex) { + unset($nodeArray[$numericSegment]); + $nodeArray = array_values($nodeArray); + $this->loadedScene->hierarchy = array_values($hierarchy); + + return true; + } + + if (!isset($nodeArray[$numericSegment]['children']) || !is_array($nodeArray[$numericSegment]['children'])) { + return false; + } + + $nodeArray = &$nodeArray[$numericSegment]['children']; + } + + return false; + } + + private function deleteAssetPath(string $path): bool + { + if (is_file($path) || is_link($path)) { + return unlink($path); + } + + if (!is_dir($path)) { + return false; + } + + $entries = scandir($path); + + if ($entries === false) { + return false; + } + + foreach ($entries as $entry) { + if ($entry === '.' || $entry === '..') { + continue; + } + + $entryPath = Path::join($path, $entry); + + if (!$this->deleteAssetPath($entryPath)) { + return false; + } + } + + return rmdir($path); + } + + private function renameAssetAndCascadeReferences( + string $currentAbsolutePath, + mixed $currentRelativePath, + string $requestedName, + ): ?array { + if (!is_file($currentAbsolutePath)) { + return null; + } + + $normalizedName = $this->normalizeAssetFileName($requestedName, $currentAbsolutePath); + + if ($normalizedName === '') { + return null; + } + + $targetAbsolutePath = Path::join(dirname($currentAbsolutePath), $normalizedName); + + if ($targetAbsolutePath !== $currentAbsolutePath) { + if (file_exists($targetAbsolutePath)) { + $this->consolePanel->append('[ERROR] - Cannot rename asset: target file already exists.'); + return null; + } + + if (!rename($currentAbsolutePath, $targetAbsolutePath)) { + $this->consolePanel->append('[ERROR] - Failed to rename asset.'); + return null; + } + } + + $oldRelativePath = is_string($currentRelativePath) && $currentRelativePath !== '' + ? str_replace('\\', '/', $currentRelativePath) + : $this->buildRelativeAssetPath($currentAbsolutePath); + $newRelativePath = $this->buildRelativeAssetPath($targetAbsolutePath); + + if ($this->updateSceneAssetReferences($oldRelativePath, $newRelativePath)) { + if ($this->loadedScene instanceof DTOs\SceneDTO) { + $this->loadedScene->rawData['hierarchy'] = $this->loadedScene->hierarchy; + $this->loadedScene->rawData['environmentTileMapPath'] = $this->loadedScene->environmentTileMapPath; + $this->loadedScene->isDirty = true; + $this->hierarchyPanel->syncHierarchy($this->loadedScene->hierarchy); + $this->mainPanel->setSceneObjects($this->loadedScene->hierarchy); + $this->syncScenePanels(true); + } + } + + return [ + 'name' => basename($targetAbsolutePath), + 'path' => $targetAbsolutePath, + 'relativePath' => $newRelativePath, + 'isDirectory' => false, + 'children' => [], + ]; + } + + private function normalizeAssetFileName(string $requestedName, string $currentAbsolutePath): string + { + $trimmedName = trim(str_replace('\\', '/', $requestedName)); + $trimmedName = basename($trimmedName); + $currentExtension = strtolower((string) pathinfo($currentAbsolutePath, PATHINFO_EXTENSION)); + + if ($trimmedName === '') { + return basename($currentAbsolutePath); + } + + $requestedBaseName = (string) pathinfo($trimmedName, PATHINFO_FILENAME); + + if ($requestedBaseName === '') { + $requestedBaseName = (string) pathinfo(basename($currentAbsolutePath), PATHINFO_FILENAME); + } + + return $currentExtension !== '' + ? $requestedBaseName . '.' . $currentExtension + : $requestedBaseName; + } + + private function buildRelativeAssetPath(string $absolutePath): string + { + $assetsDirectory = $this->assetsDirectoryPath; + + if (!is_string($assetsDirectory) || $assetsDirectory === '') { + return basename($absolutePath); + } + + $relativePath = substr($absolutePath, strlen($assetsDirectory)); + + return ltrim(str_replace('\\', '/', (string) $relativePath), '/'); + } + + private function updateSceneAssetReferences(string $oldRelativePath, string $newRelativePath): bool + { + if (!$this->loadedScene instanceof DTOs\SceneDTO) { + return false; + } + + $hasChanges = false; + $oldWithExtension = str_replace('\\', '/', $oldRelativePath); + $newWithExtension = str_replace('\\', '/', $newRelativePath); + $oldWithoutExtension = preg_replace('/\.[^.]+$/', '', $oldWithExtension) ?? $oldWithExtension; + $newWithoutExtension = preg_replace('/\.[^.]+$/', '', $newWithExtension) ?? $newWithExtension; + + if ($this->loadedScene->environmentTileMapPath === $oldWithExtension) { + $this->loadedScene->environmentTileMapPath = $newWithExtension; + $hasChanges = true; + } elseif ($this->loadedScene->environmentTileMapPath === $oldWithoutExtension) { + $this->loadedScene->environmentTileMapPath = $newWithoutExtension; + $hasChanges = true; + } + + $this->loadedScene->hierarchy = $this->updateHierarchyAssetReferences( + $this->loadedScene->hierarchy, + $oldWithExtension, + $oldWithoutExtension, + $newWithExtension, + $newWithoutExtension, + $hasChanges, + ); + + return $hasChanges; + } + + private function updateHierarchyAssetReferences( + array $items, + string $oldWithExtension, + string $oldWithoutExtension, + string $newWithExtension, + string $newWithoutExtension, + bool &$hasChanges, + ): array { + foreach ($items as $index => $item) { + if (!is_array($item)) { + continue; + } + + if (is_string($item['sprite']['texture']['path'] ?? null)) { + if ($item['sprite']['texture']['path'] === $oldWithExtension) { + $items[$index]['sprite']['texture']['path'] = $newWithExtension; + $hasChanges = true; + } elseif ($item['sprite']['texture']['path'] === $oldWithoutExtension) { + $items[$index]['sprite']['texture']['path'] = $newWithoutExtension; + $hasChanges = true; + } + } + + if (is_array($item['children'] ?? null)) { + $items[$index]['children'] = $this->updateHierarchyAssetReferences( + $item['children'], + $oldWithExtension, + $oldWithoutExtension, + $newWithExtension, + $newWithoutExtension, + $hasChanges, + ); + } + } + + return array_values($items); + } + + private function buildAssetInspectionTarget(array $asset): array + { + return [ + 'context' => 'asset', + 'name' => $asset['name'] ?? basename((string) ($asset['path'] ?? '')), + 'type' => ($asset['isDirectory'] ?? false) ? 'Folder' : 'File', + 'value' => $asset, + ]; + } + + private function saveLoadedScene(): void + { + if (!$this->loadedScene instanceof DTOs\SceneDTO) { + $this->consolePanel->append('[INFO] - No scene loaded to save.'); + return; + } + + $sceneWasDirty = $this->loadedScene->isDirty; + $this->loadedScene->isDirty = false; + $originalSourcePath = $this->loadedScene->sourcePath; + $targetSourcePath = $this->resolveTargetSceneSourcePath($this->loadedScene); + + $saveSucceeded = is_string($targetSourcePath) + && is_string($originalSourcePath) + && $targetSourcePath !== '' + && $originalSourcePath !== '' + && $targetSourcePath !== $originalSourcePath + ? $this->saveRenamedScene($this->loadedScene, $targetSourcePath) + : $this->sceneWriter->save($this->loadedScene); + + if ($saveSucceeded) { + if ( + is_string($targetSourcePath) + && $targetSourcePath !== '' + && is_string($originalSourcePath) + && $originalSourcePath !== '' + && $targetSourcePath !== $originalSourcePath + ) { + $this->loadedScene->sourcePath = $targetSourcePath; + $this->updateEditorSceneReference($originalSourcePath, $targetSourcePath); + } + + $snapshot = $this->sceneWriter->snapshot($this->loadedScene); + $this->loadedScene->rawData = $snapshot; + $this->loadedScene->sourceData = $snapshot; + $this->syncScenePanels(false); + $this->consolePanel->append('[INFO] - Saved scene ' . $this->loadedScene->name . '.scene.php'); + return; + } + + $this->loadedScene->isDirty = $sceneWasDirty; + $this->syncScenePanels($sceneWasDirty); + $this->consolePanel->append('[ERROR] - Failed to save scene.'); + } + + private function applySceneMutation(array $value): bool + { + if (!$this->loadedScene instanceof DTOs\SceneDTO) { + return false; + } + + if (is_string($value['name'] ?? null)) { + $nextSceneName = $this->normalizeSceneName($value['name']); + + if ($nextSceneName !== '') { + $this->loadedScene->name = $nextSceneName; + } + } + + if (isset($value['width']) && is_numeric($value['width'])) { + $this->loadedScene->width = max(1, (int) round((float) $value['width'])); + } + + if (isset($value['height']) && is_numeric($value['height'])) { + $this->loadedScene->height = max(1, (int) round((float) $value['height'])); + } + + if (is_string($value['environmentTileMapPath'] ?? null)) { + $this->loadedScene->environmentTileMapPath = trim($value['environmentTileMapPath']) !== '' + ? trim($value['environmentTileMapPath']) + : 'Maps/example'; + } + + $this->loadedScene->rawData['width'] = $this->loadedScene->width; + $this->loadedScene->rawData['height'] = $this->loadedScene->height; + $this->loadedScene->rawData['environmentTileMapPath'] = $this->loadedScene->environmentTileMapPath; + + return true; + } + + private function buildSceneInspectionValue(): array + { + if (!$this->loadedScene instanceof DTOs\SceneDTO) { + return []; + } + + return [ + 'name' => $this->loadedScene->name, + 'width' => $this->loadedScene->width, + 'height' => $this->loadedScene->height, + 'environmentTileMapPath' => $this->loadedScene->environmentTileMapPath, + ]; + } + + private function syncScenePanels(bool $isDirty): void + { + if (!$this->loadedScene instanceof DTOs\SceneDTO) { + return; + } + + $this->hierarchyPanel->setSceneState( + $this->loadedScene->name, + $isDirty, + $this->loadedScene->width, + $this->loadedScene->height, + $this->loadedScene->environmentTileMapPath, + ); + $this->mainPanel->setSceneDimensions($this->loadedScene->width, $this->loadedScene->height); + $this->mainPanel->setEnvironmentTileMapPath($this->loadedScene->environmentTileMapPath); + } + + private function resolveTargetSceneSourcePath(DTOs\SceneDTO $scene): ?string + { + if (!is_string($scene->sourcePath) || $scene->sourcePath === '') { + return null; + } + + $sceneDirectory = dirname($scene->sourcePath); + $sceneName = $this->normalizeSceneName($scene->name); + + if ($sceneName === '') { + return $scene->sourcePath; + } + + return Path::join($sceneDirectory, $sceneName . '.scene.php'); + } + + private function normalizeSceneName(string $sceneName): string + { + $normalizedSceneName = trim($sceneName); + $normalizedSceneName = preg_replace('/\.scene\.php$/', '', $normalizedSceneName) ?? $normalizedSceneName; + $normalizedSceneName = preg_replace('#[\\\\/]#', '-', $normalizedSceneName) ?? $normalizedSceneName; + + return trim($normalizedSceneName); + } + + private function saveRenamedScene(DTOs\SceneDTO $scene, string $targetSourcePath): bool + { + $serializedScene = $this->sceneWriter->serialize($scene); + + if (file_put_contents($targetSourcePath, $serializedScene) === false) { + return false; + } + + $originalSourcePath = $scene->sourcePath; + + if ( + is_string($originalSourcePath) + && $originalSourcePath !== '' + && $originalSourcePath !== $targetSourcePath + && is_file($originalSourcePath) + && !unlink($originalSourcePath) + ) { + $this->consolePanel->append('[WARN] - Saved renamed scene but could not remove the old scene file.'); + } + + return true; + } + + private function updateEditorSceneReference(string $originalSourcePath, string $targetSourcePath): void + { + $settingsPath = Path::join($this->workingDirectory, 'sendama.json'); + + if (!is_file($settingsPath)) { + return; + } + + $settingsContents = file_get_contents($settingsPath); + + if ($settingsContents === false) { + return; + } + + $settingsData = json_decode($settingsContents, true); + + if (!is_array($settingsData)) { + return; + } + + $activeSceneIndex = $this->settings->scenes->active; + $configuredScenes = $settingsData['scenes']['loaded'] ?? []; + $configuredSceneReference = $configuredScenes[$activeSceneIndex] ?? $configuredScenes[0] ?? null; + $updatedSceneReference = $this->buildUpdatedSceneReference( + is_string($configuredSceneReference) ? $configuredSceneReference : null, + $originalSourcePath, + $targetSourcePath, + ); + + $targetSceneIndex = array_key_exists($activeSceneIndex, $configuredScenes) ? $activeSceneIndex : 0; + $settingsData['scenes']['loaded'][$targetSceneIndex] = $updatedSceneReference; + + if (file_put_contents( + $settingsPath, + json_encode($settingsData, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . PHP_EOL + ) === false) { + return; + } + + $this->settings->scenes->loaded[$targetSceneIndex] = $updatedSceneReference; + } + + private function buildUpdatedSceneReference( + ?string $configuredSceneReference, + string $originalSourcePath, + string $targetSourcePath, + ): string { + if (!is_string($configuredSceneReference) || trim($configuredSceneReference) === '') { + return basename($targetSourcePath, '.scene.php'); + } + + if ($this->isAbsolutePath($configuredSceneReference)) { + return $targetSourcePath; + } + + $configuredSceneReference = str_replace('\\', '/', trim($configuredSceneReference)); + $hasExtension = str_ends_with($configuredSceneReference, '.scene.php'); + $directory = dirname($configuredSceneReference); + $replacement = $hasExtension + ? basename($targetSourcePath) + : basename($targetSourcePath, '.scene.php'); + + if ($directory === '.' || $directory === '') { + return $replacement; + } + + return rtrim($directory, '/') . '/' . $replacement; + } + + private function isAbsolutePath(string $path): bool + { + return str_starts_with($path, '/') + || preg_match('/^[A-Za-z]:[\/\\\\]/', $path) === 1; + } + private function getPanelDisplayNames(): array { $names = []; diff --git a/src/Editor/IO/Enumerations/KeyCode.php b/src/Editor/IO/Enumerations/KeyCode.php index 4f9257f..2bb94cd 100644 --- a/src/Editor/IO/Enumerations/KeyCode.php +++ b/src/Editor/IO/Enumerations/KeyCode.php @@ -20,6 +20,10 @@ enum KeyCode: string case BACKSPACE = 'backspace'; case ESCAPE = 'escape'; case DELETE = 'delete'; + case CTRL_C = 'ctrl_c'; + case CTRL_S = 'ctrl_s'; + case CTRL_Y = 'ctrl_y'; + case CTRL_Z = 'ctrl_z'; case UP = 'up'; case DOWN = 'down'; case RIGHT = 'right'; diff --git a/src/Editor/IO/InputManager.php b/src/Editor/IO/InputManager.php index 01fa4c1..bf5c6e9 100644 --- a/src/Editor/IO/InputManager.php +++ b/src/Editor/IO/InputManager.php @@ -22,9 +22,11 @@ class InputManager implements StaticObservableInterface * @var string The previous key press. */ private static string $previousKeyPress = ""; + private static array $inputQueue = []; private static array $axes = []; private static array $buttons = []; private static ?MouseEvent $mouseEvent = null; + private static ?string $terminalModeSnapshot = null; /** * Initializes the InputManager. @@ -34,6 +36,7 @@ class InputManager implements StaticObservableInterface public static function init(): void { self::$previousKeyPress = self::$keyPress = ""; + self::$inputQueue = []; self::$mouseEvent = null; self::initializeObservers(); } @@ -71,7 +74,16 @@ public static function disableNonBlockingMode(): void */ public static function disableEcho(): void { - system('stty cbreak -echo'); + if (self::$terminalModeSnapshot === null) { + $snapshot = shell_exec('stty -g 2>/dev/null'); + + if (is_string($snapshot)) { + $snapshot = trim($snapshot); + self::$terminalModeSnapshot = $snapshot !== '' ? $snapshot : null; + } + } + + system('stty cbreak -echo -ixon -ixoff -isig'); } /** @@ -85,13 +97,24 @@ public static function enableEcho(): void // Turn on cursor blinking echo "\033[?12l"; - system('stty -cbreak echo'); + + if (is_string(self::$terminalModeSnapshot) && self::$terminalModeSnapshot !== '') { + system('stty ' . escapeshellarg(self::$terminalModeSnapshot)); + self::$terminalModeSnapshot = null; + return; + } + + system('stty -cbreak echo ixon ixoff isig'); } public static function handleInput(): void { self::$previousKeyPress = self::$keyPress; - self::$keyPress = self::normalizeInput(stream_get_contents(STDIN) ?: ''); + if (self::$inputQueue === []) { + self::$inputQueue = self::tokenizeInput(stream_get_contents(STDIN) ?: ''); + } + + self::$keyPress = array_shift(self::$inputQueue) ?? ''; self::$mouseEvent = self::parseMouseEvent(self::$keyPress); if (self::$mouseEvent) { @@ -139,6 +162,10 @@ private static function getKey(?string $keyPress): string "\033[1;2C", "\033[c" => KeyCode::SHIFT_RIGHT->value, "\033[1;2D", "\033[d" => KeyCode::SHIFT_LEFT->value, "\033[Z", "\033[1;2Z" => KeyCode::SHIFT_TAB->value, + "\x03" => KeyCode::CTRL_C->value, + "\x13" => KeyCode::CTRL_S->value, + "\x19" => KeyCode::CTRL_Y->value, + "\x1A" => KeyCode::CTRL_Z->value, "\n" => KeyCode::ENTER->value, " " => KeyCode::SPACE->value, "\010", "\177" => KeyCode::BACKSPACE->value, @@ -330,19 +357,92 @@ private static function parseMouseEvent(string $input): ?MouseEvent } private static function normalizeInput(string $input): string + { + return self::tokenizeInput($input)[0] ?? ''; + } + + private static function tokenizeInput(string $input): array { if ($input === '') { - return ''; + return []; } - if (preg_match('/\033\[<(\d+);(\d+);(\d+)([Mm])/', $input, $matches) === 1) { - return $matches[0]; + $tokens = []; + + while ($input !== '') { + if (preg_match('/^\033\[<(\d+);(\d+);(\d+)([Mm])/', $input, $matches) === 1) { + $tokens[] = $matches[0]; + $input = substr($input, strlen($matches[0])); + continue; + } + + if (str_starts_with($input, "\033")) { + $escapeSequence = self::extractEscapeSequence($input); + $tokens[] = $escapeSequence; + $input = substr($input, strlen($escapeSequence)); + continue; + } + + $character = mb_substr($input, 0, 1); + + if ($character === '') { + break; + } + + $tokens[] = $character; + $input = substr($input, strlen($character)); } - if (str_starts_with($input, "\033")) { - return $input; + return $tokens; + } + + private static function extractEscapeSequence(string $input): string + { + $knownSequences = [ + "\033[1;2Z", + "\033[1;2A", + "\033[1;2B", + "\033[1;2C", + "\033[1;2D", + "\033[24~", + "\033[23~", + "\033[21~", + "\033[20~", + "\033[19~", + "\033[18~", + "\033[17~", + "\033[15~", + "\033[14~", + "\033[13~", + "\033[12~", + "\033[11~", + "\033[10~", + "\033[8~", + "\033[7~", + "\033[6~", + "\033[5~", + "\033[4~", + "\033[3~", + "\033[2~", + "\033[1~", + "\033[Z", + "\033[a", + "\033[b", + "\033[c", + "\033[d", + "\033[A", + "\033[B", + "\033[C", + "\033[D", + "\033", + ]; + + foreach ($knownSequences as $sequence) { + if (str_starts_with($input, $sequence)) { + return $sequence; + } } - return mb_substr($input, -1); + return "\033"; } } diff --git a/src/Editor/SceneLoader.php b/src/Editor/SceneLoader.php index a917642..7c61444 100644 --- a/src/Editor/SceneLoader.php +++ b/src/Editor/SceneLoader.php @@ -32,6 +32,9 @@ public function load(EditorSceneSettings $sceneSettings): ?SceneDTO environmentTileMapPath: $sceneData['environmentTileMapPath'] ?? 'Maps/example', isDirty: $sceneData['isDirty'] ?? false, hierarchy: $sceneData['hierarchy'] ?? [], + sourcePath: $scenePath, + rawData: $sceneData, + sourceData: $sceneData, ); } @@ -148,6 +151,12 @@ private function buildScenePathCandidates(string $configuredScene, ?string $scen private function loadSceneData(string $scenePath): array { + $isolatedSceneData = $this->loadSceneDataInIsolatedProcess($scenePath); + + if (is_array($isolatedSceneData)) { + return $isolatedSceneData; + } + try { $sceneData = require $scenePath; @@ -163,6 +172,83 @@ private function loadSceneData(string $scenePath): array return $this->extractSceneDataFromSource($scenePath); } + private function loadSceneDataInIsolatedProcess(string $scenePath): ?array + { + $autoloadPath = Path::join($this->workingDirectory, 'vendor', 'autoload.php'); + $script = <<<'PHP' +$autoloadPath = $argv[1] ?? ''; +$scenePath = $argv[2] ?? ''; + +if ($scenePath === '' || !is_file($scenePath)) { + fwrite(STDERR, "Scene file not found.\n"); + exit(1); +} + +ob_start(); + +try { + if ($autoloadPath !== '' && is_file($autoloadPath)) { + require $autoloadPath; + } + + $sceneData = require $scenePath; +} finally { + ob_end_clean(); +} + +if (!is_array($sceneData ?? null)) { + fwrite(STDERR, "Scene metadata did not return an array.\n"); + exit(2); +} + +$encodedSceneData = json_encode($sceneData, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + +if (!is_string($encodedSceneData)) { + fwrite(STDERR, "Failed to encode scene metadata.\n"); + exit(3); +} + +echo $encodedSceneData; +PHP; + + $command = [PHP_BINARY, '-d', 'display_errors=stderr', '-r', $script, $autoloadPath, $scenePath]; + $descriptors = [ + 0 => ['pipe', 'r'], + 1 => ['pipe', 'w'], + 2 => ['pipe', 'w'], + ]; + $process = proc_open($command, $descriptors, $pipes, $this->workingDirectory); + + if (!is_resource($process)) { + return null; + } + + fclose($pipes[0]); + $stdout = stream_get_contents($pipes[1]) ?: ''; + $stderr = stream_get_contents($pipes[2]) ?: ''; + fclose($pipes[1]); + fclose($pipes[2]); + + $exitCode = proc_close($process); + + if ($exitCode !== 0) { + Debug::warn( + "Failed to load scene metadata in isolated process at {$scenePath}: " . trim($stderr) + ); + + return null; + } + + $sceneData = json_decode($stdout, true); + + if (!is_array($sceneData)) { + Debug::warn("Failed to decode isolated scene metadata at {$scenePath}."); + return null; + } + + return $sceneData; + } + private function extractSceneDataFromSource(string $scenePath): array { $source = file_get_contents($scenePath); diff --git a/src/Editor/SceneSourceParser.php b/src/Editor/SceneSourceParser.php new file mode 100644 index 0000000..096a257 --- /dev/null +++ b/src/Editor/SceneSourceParser.php @@ -0,0 +1,276 @@ +parse($source); + } + + public function parse(string $source): ?array + { + $tokens = token_get_all($source); + $returnIndex = $this->findReturnIndex($tokens); + + if ($returnIndex === null) { + return null; + } + + $valueIndex = $returnIndex + 1; + $this->skipTrivia($tokens, $valueIndex); + + if (!isset($tokens[$valueIndex])) { + return null; + } + + $prefix = $this->sliceTokens($tokens, 0, $valueIndex); + $root = $this->parseNode($tokens, $valueIndex, [';']); + + if (($root['kind'] ?? null) !== 'array') { + return null; + } + + $suffix = $this->sliceTokens($tokens, $valueIndex); + + return [ + 'prefix' => $prefix, + 'root' => $root, + 'suffix' => $suffix, + ]; + } + + private function parseNode(array $tokens, int &$index, array $terminators, bool $stopAtArrow = false): array + { + $this->skipTrivia($tokens, $index); + + if (!isset($tokens[$index])) { + return [ + 'kind' => 'value', + 'source' => '', + ]; + } + + if ($this->tokenText($tokens[$index]) === '[') { + return $this->parseShortArray($tokens, $index); + } + + if ($this->isLongArrayStart($tokens, $index)) { + return $this->parseLongArray($tokens, $index); + } + + return $this->parseValue($tokens, $index, $terminators, $stopAtArrow); + } + + private function parseShortArray(array $tokens, int &$index): array + { + $start = $index; + $items = []; + $index++; + + while (isset($tokens[$index])) { + $this->skipTrivia($tokens, $index); + + if ($this->tokenText($tokens[$index] ?? null) === ']') { + $index++; + break; + } + + $items[] = $this->parseArrayItem($tokens, $index, ']'); + $this->skipTrivia($tokens, $index); + + if ($this->tokenText($tokens[$index] ?? null) === ',') { + $index++; + } + } + + return [ + 'kind' => 'array', + 'source' => $this->sliceTokens($tokens, $start, $index), + 'items' => $items, + ]; + } + + private function parseLongArray(array $tokens, int &$index): array + { + $start = $index; + $index++; + $this->skipTrivia($tokens, $index); + + if ($this->tokenText($tokens[$index] ?? null) !== '(') { + return [ + 'kind' => 'value', + 'source' => $this->sliceTokens($tokens, $start, $index), + ]; + } + + $items = []; + $index++; + + while (isset($tokens[$index])) { + $this->skipTrivia($tokens, $index); + + if ($this->tokenText($tokens[$index] ?? null) === ')') { + $index++; + break; + } + + $items[] = $this->parseArrayItem($tokens, $index, ')'); + $this->skipTrivia($tokens, $index); + + if ($this->tokenText($tokens[$index] ?? null) === ',') { + $index++; + } + } + + return [ + 'kind' => 'array', + 'source' => $this->sliceTokens($tokens, $start, $index), + 'items' => $items, + ]; + } + + private function parseArrayItem(array $tokens, int &$index, string $terminator): array + { + $firstNode = $this->parseNode($tokens, $index, [$terminator, ','], true); + $lookaheadIndex = $index; + $this->skipTrivia($tokens, $lookaheadIndex); + + if ($this->isDoubleArrow($tokens[$lookaheadIndex] ?? null)) { + $index = $lookaheadIndex + 1; + $valueNode = $this->parseNode($tokens, $index, [$terminator, ',']); + + return [ + 'keySource' => rtrim($firstNode['source']), + 'node' => $valueNode, + ]; + } + + return [ + 'keySource' => null, + 'node' => $firstNode, + ]; + } + + private function parseValue(array $tokens, int &$index, array $terminators, bool $stopAtArrow): array + { + $start = $index; + $parenthesisDepth = 0; + $squareDepth = 0; + $braceDepth = 0; + + while (isset($tokens[$index])) { + $token = $tokens[$index]; + $text = $this->tokenText($token); + + if ( + $parenthesisDepth === 0 + && $squareDepth === 0 + && $braceDepth === 0 + ) { + if ($stopAtArrow && $this->isDoubleArrow($token)) { + break; + } + + if (in_array($text, $terminators, true)) { + break; + } + } + + switch ($text) { + case '(': + $parenthesisDepth++; + break; + case ')': + $parenthesisDepth = max(0, $parenthesisDepth - 1); + break; + case '[': + $squareDepth++; + break; + case ']': + $squareDepth = max(0, $squareDepth - 1); + break; + case '{': + $braceDepth++; + break; + case '}': + $braceDepth = max(0, $braceDepth - 1); + break; + } + + $index++; + } + + return [ + 'kind' => 'value', + 'source' => rtrim($this->sliceTokens($tokens, $start, $index)), + ]; + } + + private function findReturnIndex(array $tokens): ?int + { + foreach ($tokens as $index => $token) { + if (is_array($token) && $token[0] === T_RETURN) { + return $index; + } + } + + return null; + } + + private function isLongArrayStart(array $tokens, int $index): bool + { + if (!is_array($tokens[$index] ?? null) || $tokens[$index][0] !== T_ARRAY) { + return false; + } + + $lookaheadIndex = $index + 1; + $this->skipTrivia($tokens, $lookaheadIndex); + + return $this->tokenText($tokens[$lookaheadIndex] ?? null) === '('; + } + + private function skipTrivia(array $tokens, int &$index): void + { + while (isset($tokens[$index])) { + if (!is_array($tokens[$index])) { + break; + } + + if (!in_array($tokens[$index][0], [T_WHITESPACE, T_COMMENT, T_DOC_COMMENT], true)) { + break; + } + + $index++; + } + } + + private function isDoubleArrow(mixed $token): bool + { + return is_array($token) && $token[0] === T_DOUBLE_ARROW; + } + + private function tokenText(mixed $token): string + { + if ($token === null) { + return ''; + } + + return is_array($token) ? $token[1] : $token; + } + + private function sliceTokens(array $tokens, int $start, ?int $end = null): string + { + $end ??= count($tokens); + $slice = array_slice($tokens, $start, $end - $start); + + return implode('', array_map(fn (mixed $token) => $this->tokenText($token), $slice)); + } +} diff --git a/src/Editor/SceneWriter.php b/src/Editor/SceneWriter.php new file mode 100644 index 0000000..d98f9ca --- /dev/null +++ b/src/Editor/SceneWriter.php @@ -0,0 +1,284 @@ +sourcePath) || $scene->sourcePath === '') { + return false; + } + + $serializedScene = $this->serialize($scene); + + return file_put_contents($scene->sourcePath, $serializedScene) !== false; + } + + public function serialize(SceneDTO $scene): string + { + $sceneData = $this->snapshot($scene); + $parsedSource = $this->parseSceneSource($scene); + $originalSceneData = is_array($scene->sourceData) ? $scene->sourceData : []; + + if ($parsedSource !== null && $originalSceneData !== []) { + $mergedSource = $this->renderMergedValue( + $sceneData, + $originalSceneData, + $parsedSource['root'] + ); + + return $parsedSource['prefix'] . $mergedSource . $parsedSource['suffix']; + } + + return "exportValue($sceneData) . ";\n"; + } + + public function snapshot(SceneDTO $scene): array + { + $sceneData = is_array($scene->rawData) ? $scene->rawData : []; + $sceneData['width'] = $scene->width; + $sceneData['height'] = $scene->height; + $sceneData['environmentTileMapPath'] = $scene->environmentTileMapPath; + $sceneData['hierarchy'] = $scene->hierarchy; + + unset($sceneData['isDirty']); + + return $sceneData; + } + + private function parseSceneSource(SceneDTO $scene): ?array + { + if (!is_string($scene->sourcePath) || $scene->sourcePath === '' || !is_file($scene->sourcePath)) { + return null; + } + + return $this->sourceParser->parseFile($scene->sourcePath); + } + + private function renderMergedValue( + mixed $currentValue, + mixed $originalValue, + array $sourceNode, + int $depth = 0, + ): string { + if ($currentValue === $originalValue && isset($sourceNode['source'])) { + return $sourceNode['source']; + } + + if ( + is_array($currentValue) + && is_array($originalValue) + && ($sourceNode['kind'] ?? null) === 'array' + && $this->canRenderMergedArray($currentValue, $originalValue, $sourceNode) + ) { + return $this->renderMergedArray($currentValue, $originalValue, $sourceNode, $depth); + } + + return $this->exportValue($currentValue, $depth); + } + + private function canRenderMergedArray(array $currentValue, array $originalValue, array $sourceNode): bool + { + if (($sourceNode['kind'] ?? null) !== 'array') { + return false; + } + + if (array_is_list($currentValue) !== array_is_list($originalValue)) { + return false; + } + + if (array_is_list($currentValue)) { + return true; + } + + return true; + } + + private function renderMergedArray( + array $currentValue, + array $originalValue, + array $sourceNode, + int $depth, + ): string { + if ($currentValue === []) { + return '[]'; + } + + $indent = str_repeat(' ', $depth); + $childIndent = str_repeat(' ', $depth + 1); + $isList = array_is_list($currentValue); + $lines = []; + + if ($isList) { + foreach (array_keys($currentValue) as $index) { + $itemNode = $sourceNode['items'][$index] ?? null; + + if ( + is_array($itemNode) + && isset($itemNode['node']) + && array_key_exists($index, $originalValue) + ) { + $lines[] = $childIndent + . $this->renderMergedValue( + $currentValue[$index], + $originalValue[$index], + $itemNode['node'], + $depth + 1 + ) + . ','; + + continue; + } + + $lines[] = $childIndent + . $this->exportValue($currentValue[$index], $depth + 1) + . ','; + } + + return "[\n" . implode("\n", $lines) . "\n" . $indent . "]"; + } + + $renderedKeys = []; + + foreach (($sourceNode['items'] ?? []) as $itemNode) { + if (!is_array($itemNode) || !isset($itemNode['node'])) { + return $this->exportArray($currentValue, $depth); + } + + $resolvedKey = $this->resolveSourceArrayKey($itemNode['keySource'] ?? null); + + if (!is_int($resolvedKey) && !is_string($resolvedKey)) { + return $this->exportArray($currentValue, $depth); + } + + $valuePrefix = $this->renderArrayKeyPrefix($resolvedKey, $itemNode['keySource'] ?? null); + + if (array_key_exists($resolvedKey, $currentValue)) { + $renderedKeys[$resolvedKey] = true; + $value = $currentValue[$resolvedKey]; + $originalItemValue = array_key_exists($resolvedKey, $originalValue) + ? $originalValue[$resolvedKey] + : null; + + $renderedValue = array_key_exists($resolvedKey, $originalValue) + ? $this->renderMergedValue($value, $originalItemValue, $itemNode['node'], $depth + 1) + : $this->exportValue($value, $depth + 1); + + $lines[] = $childIndent . $valuePrefix . $renderedValue . ','; + continue; + } + + $lines[] = $childIndent . $valuePrefix . $itemNode['node']['source'] . ','; + } + + foreach ($currentValue as $key => $value) { + if (isset($renderedKeys[$key])) { + continue; + } + + $lines[] = $childIndent + . $this->renderArrayKeyPrefix($key, null) + . $this->exportValue($value, $depth + 1, is_string($key) ? $key : null) + . ','; + } + + return "[\n" . implode("\n", $lines) . "\n" . $indent . "]"; + } + + private function renderArrayKeyPrefix(int|string $key, ?string $keySource): string + { + if (is_string($keySource) && trim($keySource) !== '') { + return rtrim($keySource) . ' => '; + } + + return var_export($key, true) . ' => '; + } + + private function resolveSourceArrayKey(?string $keySource): int|string|null + { + if (!is_string($keySource)) { + return null; + } + + $trimmedKey = trim($keySource); + + if ($trimmedKey === '') { + return null; + } + + if (preg_match('/^([\'"])(.*)\\1$/s', $trimmedKey, $matches) === 1) { + return stripcslashes($matches[2]); + } + + if (preg_match('/^-?\\d+$/', $trimmedKey) === 1) { + return (int) $trimmedKey; + } + + return $trimmedKey; + } + + private function exportValue(mixed $value, int $depth = 0, ?string $contextKey = null): string + { + if (is_array($value)) { + return $this->exportArray($value, $depth); + } + + if (is_string($value) && in_array($contextKey, ['type', 'class'], true)) { + return $this->exportClassReference($value); + } + + return var_export($value, true); + } + + private function exportArray(array $value, int $depth): string + { + if ($value === []) { + return '[]'; + } + + $indent = str_repeat(' ', $depth); + $childIndent = str_repeat(' ', $depth + 1); + $lines = []; + + foreach ($value as $key => $item) { + $prefix = array_is_list($value) + ? '' + : var_export($key, true) . ' => '; + + $lines[] = $childIndent + . $prefix + . $this->exportValue($item, $depth + 1, is_string($key) ? $key : null) + . ','; + } + + return "[\n" . implode("\n", $lines) . "\n" . $indent . "]"; + } + + private function exportClassReference(string $value): string + { + $normalizedValue = trim($value); + + if ($normalizedValue === '') { + return var_export($value, true); + } + + if (preg_match('/^[A-Za-z_\\\\][A-Za-z0-9_\\\\]*::class$/', $normalizedValue) === 1) { + return $normalizedValue; + } + + if (preg_match('/^[A-Za-z_\\\\][A-Za-z0-9_\\\\]*$/', $normalizedValue) === 1) { + return '\\' . ltrim($normalizedValue, '\\') . '::class'; + } + + return var_export($value, true); + } +} diff --git a/src/Editor/States/EditState.php b/src/Editor/States/EditState.php index dbee754..491b63d 100644 --- a/src/Editor/States/EditState.php +++ b/src/Editor/States/EditState.php @@ -2,23 +2,15 @@ namespace Sendama\Console\Editor\States; -use Exception; -use Sendama\Console\Editor\IO\Enumerations\KeyCode; -use Sendama\Console\Editor\IO\Input; - class EditState extends EditorState { /** * @inheritDoc - * @throws Exception */ public function update(): void { - // TODO: Implement update() method. - if (Input::isAnyKeyPressed([KeyCode::Q], true)) { - $this->editor->stop(); - } + // Intentionally empty. Global editor shortcuts are handled by the editor itself. } /** @@ -28,4 +20,4 @@ public function render(): void { // TODO: Implement render() method. } -} \ No newline at end of file +} diff --git a/src/Editor/Widgets/AssetsPanel.php b/src/Editor/Widgets/AssetsPanel.php index fe25d6d..6681703 100644 --- a/src/Editor/Widgets/AssetsPanel.php +++ b/src/Editor/Widgets/AssetsPanel.php @@ -15,6 +15,7 @@ */ class AssetsPanel extends Widget { + private const string DELETE_MODAL_CONFIRM = 'delete_confirm'; private const string COLLAPSED_ICON = '►'; private const string EXPANDED_ICON = '▼'; private const string LEAF_ICON = '•'; @@ -26,6 +27,9 @@ class AssetsPanel extends Widget protected array $expandedPaths = []; protected ?string $selectedPath = null; protected ?array $pendingInspectionTarget = null; + protected ?array $pendingDeletionTarget = null; + protected OptionListModal $deleteConfirmModal; + protected ?string $modalState = null; public function __construct( array $position = ['x' => 1, 'y' => 15], @@ -35,6 +39,7 @@ public function __construct( ) { parent::__construct('Assets', '', $position, $width, $height); + $this->deleteConfirmModal = new OptionListModal(title: 'Delete Asset'); $this->loadAssetEntries(); $this->refreshContent(); } @@ -133,6 +138,63 @@ public function consumeInspectionRequest(): ?array return $pendingInspectionTarget; } + public function consumeDeletionRequest(): ?array + { + $pendingDeletionTarget = $this->pendingDeletionTarget; + $this->pendingDeletionTarget = null; + + return $pendingDeletionTarget; + } + + public function hasActiveModal(): bool + { + return $this->deleteConfirmModal->isVisible(); + } + + public function isModalDirty(): bool + { + return $this->deleteConfirmModal->isDirty(); + } + + public function markModalClean(): void + { + $this->deleteConfirmModal->markClean(); + } + + public function syncModalLayout(int $terminalWidth, int $terminalHeight): void + { + $this->deleteConfirmModal->syncLayout($terminalWidth, $terminalHeight); + } + + public function renderActiveModal(): void + { + if ($this->deleteConfirmModal->isVisible()) { + $this->deleteConfirmModal->render(); + } + } + + public function reloadAssets(): void + { + $this->loadAssetEntries(); + $this->refreshContent(); + } + + public function selectAssetByAbsolutePath(?string $absolutePath): void + { + if (!is_string($absolutePath) || $absolutePath === '') { + return; + } + + $matchedPath = $this->findAssetPathByAbsolutePath($this->assetTree, $absolutePath); + + if ($matchedPath === null) { + return; + } + + $this->selectedPath = $matchedPath; + $this->refreshContent(); + } + public function handleMouseClick(int $x, int $y): void { if (!$this->containsPoint($x, $y)) { @@ -156,6 +218,11 @@ public function update(): void return; } + if ($this->hasActiveModal()) { + $this->handleModalInput(); + return; + } + if (Input::isKeyDown(KeyCode::UP)) { $this->moveSelection(-1); return; @@ -178,6 +245,11 @@ public function update(): void if (Input::isKeyDown(KeyCode::ENTER)) { $this->activateSelection(); + return; + } + + if (Input::isKeyDown(KeyCode::DELETE)) { + $this->showDeleteConfirmModal(); } } @@ -404,4 +476,109 @@ private function getParentPath(string $path): ?string return substr($path, 0, $separatorPosition); } + + private function findAssetPathByAbsolutePath(array $items, string $targetAbsolutePath, string $parentPath = ''): ?string + { + foreach (array_values($items) as $index => $item) { + if (!is_array($item)) { + continue; + } + + $path = $parentPath === '' ? (string) $index : $parentPath . '.' . $index; + + if (($item['path'] ?? null) === $targetAbsolutePath) { + return $path; + } + + $children = $item['children'] ?? []; + + if (!is_array($children) || $children === []) { + continue; + } + + $childPath = $this->findAssetPathByAbsolutePath($children, $targetAbsolutePath, $path); + + if ($childPath !== null) { + $this->expandedPaths[$path] = true; + return $childPath; + } + } + + return null; + } + + private function showDeleteConfirmModal(): void + { + $selectedAsset = $this->getSelectedAssetEntry(); + + if ($selectedAsset === null) { + return; + } + + $selectedName = $selectedAsset['name'] ?? 'this asset'; + $this->deleteConfirmModal->show( + ['Delete', 'Cancel'], + 1, + 'Are you sure you want to delete ' . $selectedName . '?' + ); + $this->modalState = self::DELETE_MODAL_CONFIRM; + } + + private function dismissModal(): void + { + $this->deleteConfirmModal->hide(); + $this->modalState = null; + } + + private function handleModalInput(): void + { + if (Input::isKeyDown(KeyCode::ESCAPE)) { + $this->dismissModal(); + return; + } + + if ($this->modalState !== self::DELETE_MODAL_CONFIRM) { + return; + } + + if (Input::isKeyDown(KeyCode::UP)) { + $this->deleteConfirmModal->moveSelection(-1); + return; + } + + if (Input::isKeyDown(KeyCode::DOWN)) { + $this->deleteConfirmModal->moveSelection(1); + return; + } + + if (!Input::isKeyDown(KeyCode::ENTER)) { + return; + } + + $selection = $this->deleteConfirmModal->getSelectedOption(); + + if ($selection !== 'Delete') { + $this->dismissModal(); + return; + } + + $selectedVisibleAsset = $this->getSelectedVisibleAsset(); + $selectedAsset = $selectedVisibleAsset['item'] ?? null; + + if (!is_array($selectedAsset)) { + $this->dismissModal(); + return; + } + + $this->pendingDeletionTarget = [ + 'path' => $selectedVisibleAsset['path'] ?? null, + 'assetPath' => $selectedAsset['path'] ?? null, + 'name' => $selectedAsset['name'] ?? 'Unnamed Asset', + 'isDirectory' => (bool) ($selectedAsset['isDirectory'] ?? false), + ]; + $this->selectedPath = is_string($selectedVisibleAsset['path'] ?? null) + ? $this->getParentPath($selectedVisibleAsset['path']) + : $this->selectedPath; + $this->dismissModal(); + } } diff --git a/src/Editor/Widgets/Controls/PathInputControl.php b/src/Editor/Widgets/Controls/PathInputControl.php index 4ebaf49..a9f6387 100644 --- a/src/Editor/Widgets/Controls/PathInputControl.php +++ b/src/Editor/Widgets/Controls/PathInputControl.php @@ -10,12 +10,17 @@ public function __construct( string $label, mixed $value, protected string $workingDirectory, + protected array $allowedExtensions = [], int $indentLevel = 1, bool $isReadOnly = false, ) { parent::__construct($label, $value, $indentLevel, $isReadOnly); $this->workingDirectory = Path::normalize($workingDirectory); + $this->allowedExtensions = array_values(array_filter(array_map( + static fn(string $extension): string => ltrim(strtolower($extension), '.'), + array_filter($allowedExtensions, 'is_string'), + ))); } public function getWorkingDirectory(): string @@ -29,4 +34,9 @@ public function setValueFromRelativePath(string $relativePath): void $normalizedPath = ltrim($normalizedPath, '/'); $this->setValue($normalizedPath); } + + public function getAllowedExtensions(): array + { + return $this->allowedExtensions; + } } diff --git a/src/Editor/Widgets/FileDialogModal.php b/src/Editor/Widgets/FileDialogModal.php index fb46129..c482bf5 100644 --- a/src/Editor/Widgets/FileDialogModal.php +++ b/src/Editor/Widgets/FileDialogModal.php @@ -19,6 +19,7 @@ class FileDialogModal extends Widget protected array $visibleEntries = []; protected array $expandedPaths = []; protected ?string $selectedPath = null; + protected array $allowedExtensions = []; public function __construct() { @@ -31,9 +32,14 @@ public function __construct() ); } - public function show(string $workingDirectory, ?string $selectedRelativePath = null): void + public function show( + string $workingDirectory, + ?string $selectedRelativePath = null, + array $allowedExtensions = [], + ): void { $this->workingDirectory = Path::normalize($workingDirectory); + $this->allowedExtensions = $this->normalizeAllowedExtensions($allowedExtensions); $this->entryTree = $this->buildEntryTree($this->workingDirectory); $this->expandedPaths = []; $this->selectedPath = null; @@ -229,13 +235,26 @@ private function buildEntryTree(string $directory): array $entryPath = Path::join($directory, $entryName); $isDirectory = is_dir($entryPath); + $children = $isDirectory ? $this->buildEntryTree($entryPath) : []; + + if (!$isDirectory && !$this->matchesAllowedExtension($entryName)) { + continue; + } + + if ( + $isDirectory + && $this->allowedExtensions !== [] + && $children === [] + ) { + continue; + } $tree[] = [ 'name' => $entryName, 'absolutePath' => $entryPath, 'relativePath' => $this->buildRelativePath($entryPath), 'isDirectory' => $isDirectory, - 'children' => $isDirectory ? $this->buildEntryTree($entryPath) : [], + 'children' => $children, ]; } @@ -250,6 +269,25 @@ private function buildEntryTree(string $directory): array return $tree; } + private function normalizeAllowedExtensions(array $allowedExtensions): array + { + return array_values(array_unique(array_filter(array_map( + static fn(string $extension): string => ltrim(strtolower($extension), '.'), + array_filter($allowedExtensions, 'is_string'), + )))); + } + + private function matchesAllowedExtension(string $entryName): bool + { + if ($this->allowedExtensions === []) { + return true; + } + + $extension = strtolower((string) pathinfo($entryName, PATHINFO_EXTENSION)); + + return $extension !== '' && in_array($extension, $this->allowedExtensions, true); + } + private function buildRelativePath(string $absolutePath): string { $relativePath = substr($absolutePath, strlen($this->workingDirectory)); diff --git a/src/Editor/Widgets/HierarchyPanel.php b/src/Editor/Widgets/HierarchyPanel.php index 308daf6..777fda2 100644 --- a/src/Editor/Widgets/HierarchyPanel.php +++ b/src/Editor/Widgets/HierarchyPanel.php @@ -5,6 +5,7 @@ use Atatusoft\Termutil\Events\Interfaces\ObservableInterface; use Atatusoft\Termutil\Events\Traits\ObservableTrait; use Atatusoft\Termutil\IO\Enumerations\Color; +use Sendama\Console\Editor\FocusTargetContext; use Sendama\Console\Editor\Events\EditorEvent; use Sendama\Console\Editor\Events\Enumerations\EventType; use Sendama\Console\Editor\IO\Enumerations\KeyCode; @@ -20,19 +21,34 @@ class HierarchyPanel extends Widget implements ObservableInterface use ObservableTrait; private const string ROOT_PATH = 'scene'; + private const string ADD_MODAL_OBJECT_KIND = 'object_kind'; + private const string ADD_MODAL_UI_KIND = 'ui_kind'; + private const string DELETE_MODAL_CONFIRM = 'delete_confirm'; private const string COLLAPSED_ICON = '►'; private const string EXPANDED_ICON = '▼'; private const string LEAF_ICON = '•'; private const string SELECTED_ROW_SEQUENCE = "\033[30;46m"; private const string SELECTED_ROW_FOCUSED_SEQUENCE = "\033[5;30;46m"; + private const string GAME_OBJECT_TYPE = 'Sendama\\Engine\\Core\\GameObject'; + private const string LABEL_TYPE = 'Sendama\\Engine\\UI\\Label\\Label'; + private const string TEXT_TYPE = 'Sendama\\Engine\\UI\\Text\\Text'; protected string $sceneName = 'Scene'; protected bool $isSceneDirty = false; + protected int $sceneWidth = DEFAULT_TERMINAL_WIDTH; + protected int $sceneHeight = DEFAULT_TERMINAL_HEIGHT; + protected string $environmentTileMapPath = 'Maps/example'; protected array $hierarchy = []; protected array $visibleHierarchy = []; protected array $expandedPaths = []; protected ?string $selectedPath = null; protected ?array $pendingInspectionItem = null; + protected ?array $pendingCreationItem = null; + protected ?array $pendingDeletionItem = null; + protected OptionListModal $addObjectModal; + protected OptionListModal $addUiElementModal; + protected OptionListModal $deleteConfirmModal; + protected ?string $addModalState = null; public function __construct( array $position = ['x' => 1, 'y' => 1], @@ -40,13 +56,22 @@ public function __construct( int $height = 14, string $sceneName = 'Scene', bool $isSceneDirty = false, - array $hierarchy = [] + array $hierarchy = [], + int $sceneWidth = DEFAULT_TERMINAL_WIDTH, + int $sceneHeight = DEFAULT_TERMINAL_HEIGHT, + string $environmentTileMapPath = 'Maps/example', ) { $this->initializeObservers(); parent::__construct('Hierarchy', '', $position, $width, $height); + $this->addObjectModal = new OptionListModal(title: 'Add Object'); + $this->addUiElementModal = new OptionListModal(title: 'Add UI Element'); + $this->deleteConfirmModal = new OptionListModal(title: 'Delete Object'); $this->sceneName = $sceneName; $this->isSceneDirty = $isSceneDirty; + $this->sceneWidth = $sceneWidth; + $this->sceneHeight = $sceneHeight; + $this->environmentTileMapPath = $environmentTileMapPath; $this->setHierarchy($hierarchy); } @@ -65,10 +90,35 @@ public function setHierarchy(array $hierarchy): void $this->notify(new EditorEvent(EventType::HIERARCHY_CHANGED->value, $this)); } - public function setSceneState(string $sceneName, bool $isDirty = false): void + public function syncHierarchy(array $hierarchy): void + { + $this->hierarchy = array_values($hierarchy); + $this->refreshContent(); + } + + public function setSceneState( + string $sceneName, + bool $isDirty = false, + ?int $sceneWidth = null, + ?int $sceneHeight = null, + ?string $environmentTileMapPath = null, + ): void { $this->sceneName = $sceneName; $this->isSceneDirty = $isDirty; + $this->sceneWidth = $sceneWidth ?? $this->sceneWidth; + $this->sceneHeight = $sceneHeight ?? $this->sceneHeight; + $this->environmentTileMapPath = $environmentTileMapPath ?? $this->environmentTileMapPath; + $this->refreshContent(); + } + + public function selectPath(?string $path): void + { + if ($path === null) { + return; + } + + $this->selectedPath = $path; $this->refreshContent(); } @@ -152,6 +202,22 @@ public function activateSelection(): void { $selectedNode = $this->getSelectedVisibleNode(); + if (($selectedNode['kind'] ?? null) === 'scene') { + $this->pendingInspectionItem = [ + 'context' => 'scene', + 'name' => $this->sceneName, + 'type' => 'Scene', + 'path' => self::ROOT_PATH, + 'value' => [ + 'name' => $this->sceneName, + 'width' => $this->sceneWidth, + 'height' => $this->sceneHeight, + 'environmentTileMapPath' => $this->environmentTileMapPath, + ], + ]; + return; + } + if (($selectedNode['kind'] ?? null) !== 'object') { return; } @@ -166,6 +232,7 @@ public function activateSelection(): void 'context' => 'hierarchy', 'name' => $selectedItem['name'] ?? 'Unnamed Object', 'type' => $this->resolveInspectableType($selectedItem), + 'path' => $selectedNode['path'] ?? null, 'value' => $selectedItem, ]; } @@ -178,6 +245,83 @@ public function consumeInspectionRequest(): ?array return $pendingInspectionItem; } + public function consumeCreationRequest(): ?array + { + $pendingCreationItem = $this->pendingCreationItem; + $this->pendingCreationItem = null; + + return $pendingCreationItem; + } + + public function consumeDeletionRequest(): ?array + { + $pendingDeletionItem = $this->pendingDeletionItem; + $this->pendingDeletionItem = null; + + return $pendingDeletionItem; + } + + public function beginAddWorkflow(): void + { + $this->showAddObjectModal(); + } + + public function hasActiveModal(): bool + { + return $this->addObjectModal->isVisible() + || $this->addUiElementModal->isVisible() + || $this->deleteConfirmModal->isVisible(); + } + + public function isModalDirty(): bool + { + return $this->addObjectModal->isDirty() + || $this->addUiElementModal->isDirty() + || $this->deleteConfirmModal->isDirty(); + } + + public function markModalClean(): void + { + $this->addObjectModal->markClean(); + $this->addUiElementModal->markClean(); + $this->deleteConfirmModal->markClean(); + } + + public function syncModalLayout(int $terminalWidth, int $terminalHeight): void + { + $this->addObjectModal->syncLayout($terminalWidth, $terminalHeight); + $this->addUiElementModal->syncLayout($terminalWidth, $terminalHeight); + $this->deleteConfirmModal->syncLayout($terminalWidth, $terminalHeight); + } + + public function renderActiveModal(): void + { + if ($this->addObjectModal->isVisible()) { + $this->addObjectModal->render(); + } + + if ($this->addUiElementModal->isVisible()) { + $this->addUiElementModal->render(); + } + + if ($this->deleteConfirmModal->isVisible()) { + $this->deleteConfirmModal->render(); + } + } + + public function focus(FocusTargetContext $context): void + { + parent::focus($context); + $this->refreshContent(); + } + + public function blur(FocusTargetContext $context): void + { + $this->dismissAddModals(); + parent::blur($context); + $this->refreshContent(); + } + public function handleMouseClick(int $x, int $y): void { if (!$this->containsPoint($x, $y)) { @@ -203,6 +347,21 @@ public function update(): void return; } + if ($this->hasActiveModal()) { + $this->handleModalInput(); + return; + } + + if (Input::getCurrentInput() === 'A') { + $this->showAddObjectModal(); + return; + } + + if (Input::isKeyDown(KeyCode::DELETE)) { + $this->showDeleteConfirmModal(); + return; + } + if (Input::isKeyDown(KeyCode::UP)) { $this->moveSelection(-1); return; @@ -275,8 +434,12 @@ private function buildVisibleHierarchy(): array 'kind' => 'scene', 'path' => self::ROOT_PATH, 'item' => [ - 'name' => $this->getDisplaySceneName(), + 'name' => $this->sceneName, + 'displayName' => $this->getDisplaySceneName(), 'type' => 'Scene', + 'width' => $this->sceneWidth, + 'height' => $this->sceneHeight, + 'environmentTileMapPath' => $this->environmentTileMapPath, ], 'depth' => 0, 'hasChildren' => $this->hierarchy !== [], @@ -387,7 +550,7 @@ private function formatVisibleHierarchyEntry(array $entry): string ($entry['hasChildren'] ?? false) => self::COLLAPSED_ICON, default => self::LEAF_ICON, }; - $name = $entry['item']['name'] ?? 'Unnamed Object'; + $name = $entry['item']['displayName'] ?? $entry['item']['name'] ?? 'Unnamed Object'; $indentation = str_repeat(' ', (int)($entry['depth'] ?? 0)); return $indentation . $icon . ' ' . $name; @@ -434,4 +597,204 @@ private function getParentPath(string $path): ?string return substr($path, 0, $separatorPosition); } + + private function showAddObjectModal(): void + { + $this->addObjectModal->show(['GameObject', 'UIElement']); + $this->addUiElementModal->hide(); + $this->deleteConfirmModal->hide(); + $this->addModalState = self::ADD_MODAL_OBJECT_KIND; + } + + private function showAddUiElementModal(): void + { + $this->addUiElementModal->show(['Text', 'Label']); + $this->addObjectModal->hide(); + $this->deleteConfirmModal->hide(); + $this->addModalState = self::ADD_MODAL_UI_KIND; + } + + private function showDeleteConfirmModal(): void + { + $selectedNode = $this->getSelectedVisibleNode(); + + if (($selectedNode['kind'] ?? null) !== 'object') { + return; + } + + $selectedItem = $selectedNode['item'] ?? null; + $selectedName = is_array($selectedItem) ? ($selectedItem['name'] ?? 'this object') : 'this object'; + + $this->deleteConfirmModal->show( + ['Delete', 'Cancel'], + 1, + 'Are you sure you want to delete ' . $selectedName . '?' + ); + $this->addObjectModal->hide(); + $this->addUiElementModal->hide(); + $this->addModalState = self::DELETE_MODAL_CONFIRM; + } + + private function dismissAddModals(): void + { + $this->addObjectModal->hide(); + $this->addUiElementModal->hide(); + $this->deleteConfirmModal->hide(); + $this->addModalState = null; + } + + private function handleModalInput(): void + { + if (Input::isKeyDown(KeyCode::ESCAPE)) { + if ($this->addModalState === self::ADD_MODAL_UI_KIND) { + $this->showAddObjectModal(); + return; + } + + $this->dismissAddModals(); + return; + } + + $activeModal = match ($this->addModalState) { + self::ADD_MODAL_OBJECT_KIND => $this->addObjectModal, + self::ADD_MODAL_UI_KIND => $this->addUiElementModal, + self::DELETE_MODAL_CONFIRM => $this->deleteConfirmModal, + default => null, + }; + + if (!$activeModal instanceof OptionListModal) { + return; + } + + if (Input::isKeyDown(KeyCode::UP)) { + $activeModal->moveSelection(-1); + return; + } + + if (Input::isKeyDown(KeyCode::DOWN)) { + $activeModal->moveSelection(1); + return; + } + + if (!Input::isKeyDown(KeyCode::ENTER)) { + return; + } + + $selectedOption = $activeModal->getSelectedOption(); + + if (!is_string($selectedOption) || $selectedOption === '') { + return; + } + + if ($this->addModalState === self::ADD_MODAL_OBJECT_KIND) { + $this->handleAddObjectTypeSelection($selectedOption); + return; + } + + if ($this->addModalState === self::ADD_MODAL_UI_KIND) { + $this->handleAddUiElementSelection($selectedOption); + return; + } + + if ($this->addModalState === self::DELETE_MODAL_CONFIRM) { + $this->handleDeleteConfirmationSelection($selectedOption); + } + } + + private function handleAddObjectTypeSelection(string $selection): void + { + if ($selection === 'UIElement') { + $this->showAddUiElementModal(); + return; + } + + $this->pendingCreationItem = $this->buildDefaultObjectDefinition($selection); + $this->dismissAddModals(); + } + + private function handleAddUiElementSelection(string $selection): void + { + $this->pendingCreationItem = $this->buildDefaultObjectDefinition($selection); + $this->dismissAddModals(); + } + + private function handleDeleteConfirmationSelection(string $selection): void + { + if ($selection !== 'Delete') { + $this->dismissAddModals(); + return; + } + + $selectedNode = $this->getSelectedVisibleNode(); + + if (($selectedNode['kind'] ?? null) !== 'object') { + $this->dismissAddModals(); + return; + } + + $selectedItem = $selectedNode['item'] ?? null; + $this->pendingDeletionItem = [ + 'path' => $selectedNode['path'] ?? null, + 'name' => is_array($selectedItem) ? ($selectedItem['name'] ?? 'Unnamed Object') : 'Unnamed Object', + ]; + $this->dismissAddModals(); + } + + private function buildDefaultObjectDefinition(string $selection): array + { + $instanceName = $selection . ' #' . $this->getNextInstanceCount($selection); + + return match ($selection) { + 'GameObject' => [ + 'type' => self::GAME_OBJECT_TYPE, + 'name' => $instanceName, + 'tag' => 'None', + 'position' => ['x' => 0, 'y' => 0], + 'rotation' => ['x' => 0, 'y' => 0], + 'scale' => ['x' => 1, 'y' => 1], + 'components' => [], + ], + 'Text' => [ + 'type' => self::TEXT_TYPE, + 'name' => $instanceName, + 'tag' => 'UI', + 'position' => ['x' => 0, 'y' => 0], + 'size' => ['x' => 1, 'y' => 1], + 'text' => $instanceName, + ], + 'Label' => [ + 'type' => self::LABEL_TYPE, + 'name' => $instanceName, + 'tag' => 'UI', + 'position' => ['x' => 0, 'y' => 0], + 'size' => ['x' => 1, 'y' => 1], + 'text' => $instanceName, + ], + default => [], + }; + } + + private function getNextInstanceCount(string $type): int + { + return $this->countInstancesOfType($this->hierarchy, $type) + 1; + } + + private function countInstancesOfType(array $items, string $type): int + { + $count = 0; + + foreach ($items as $item) { + if (!is_array($item)) { + continue; + } + + if ($this->resolveInspectableType($item) === $type) { + $count++; + } + + $count += $this->countInstancesOfType($this->getChildItems($item), $type); + } + + return $count; + } } diff --git a/src/Editor/Widgets/InspectorPanel.php b/src/Editor/Widgets/InspectorPanel.php index ba603a0..d70adaa 100644 --- a/src/Editor/Widgets/InspectorPanel.php +++ b/src/Editor/Widgets/InspectorPanel.php @@ -9,6 +9,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\NumberInputControl; use Sendama\Console\Editor\Widgets\Controls\PathInputControl; use Sendama\Console\Editor\Widgets\Controls\PreviewWindowControl; use Sendama\Console\Editor\Widgets\Controls\TextInputControl; @@ -43,17 +44,25 @@ class InspectorPanel extends Widget protected OptionListModal $pathInputActionModal; protected FileDialogModal $fileDialogModal; protected ?PathInputControl $activePathInputControl = null; + protected array $controlBindings = []; + protected ?array $pendingHierarchyMutation = null; + protected ?array $pendingAssetMutation = null; + protected string $projectDirectory; public function __construct( array $position = ['x' => 135, 'y' => 1], int $width = 35, - int $height = 29 + int $height = 29, + ?string $workingDirectory = null, ) { parent::__construct('Inspector', '', $position, $width, $height); $this->inputControlFactory = new InputControlFactory(); $this->pathInputActionModal = new OptionListModal(title: 'Path Input'); $this->fileDialogModal = new FileDialogModal(); + $this->projectDirectory = is_string($workingDirectory) && $workingDirectory !== '' + ? $workingDirectory + : (getcwd() ?: '.'); } public function inspectTarget(?array $target): void @@ -70,6 +79,9 @@ public function inspectTarget(?array $target): void $this->pathInputActionModal->hide(); $this->fileDialogModal->hide(); $this->activePathInputControl = null; + $this->controlBindings = []; + $this->pendingHierarchyMutation = null; + $this->pendingAssetMutation = null; if ($target === null) { $this->content = []; @@ -83,6 +95,10 @@ public function inspectTarget(?array $target): void if ($context === 'hierarchy' && is_array($value)) { $this->buildHierarchyControls($target, $value); + } elseif ($context === 'scene' && is_array($value)) { + $this->buildSceneControls($target, $value); + } elseif ($context === 'asset' && is_array($value)) { + $this->buildAssetControls($target, $value); } else { $this->buildGenericControls($target); } @@ -148,6 +164,75 @@ public function renderActiveModal(): void } } + public function consumeHierarchyMutation(): ?array + { + $pendingHierarchyMutation = $this->pendingHierarchyMutation; + $this->pendingHierarchyMutation = null; + + return $pendingHierarchyMutation; + } + + public function consumeAssetMutation(): ?array + { + $pendingAssetMutation = $this->pendingAssetMutation; + $this->pendingAssetMutation = null; + + return $pendingAssetMutation; + } + + public function syncHierarchyTarget(string $path, array $value): void + { + if ( + !is_array($this->inspectionTarget) + || ($this->inspectionTarget['context'] ?? null) !== 'hierarchy' + || ($this->inspectionTarget['path'] ?? null) !== $path + ) { + return; + } + + $target = $this->inspectionTarget; + $target['name'] = $value['name'] ?? ($target['name'] ?? 'Unnamed Object'); + $target['type'] = $this->resolveDisplayType($target, $value); + $target['value'] = $value; + + $this->inspectTarget($target); + } + + public function syncSceneTarget(array $value): void + { + if ( + !is_array($this->inspectionTarget) + || ($this->inspectionTarget['context'] ?? null) !== 'scene' + ) { + return; + } + + $target = $this->inspectionTarget; + $target['name'] = $value['name'] ?? ($target['name'] ?? 'Scene'); + $target['type'] = 'Scene'; + $target['path'] = 'scene'; + $target['value'] = $value; + + $this->inspectTarget($target); + } + + public function syncAssetTarget(array $value): void + { + if ( + !is_array($this->inspectionTarget) + || ($this->inspectionTarget['context'] ?? null) !== 'asset' + ) { + return; + } + + $target = $this->inspectionTarget; + $target['name'] = $value['name'] ?? ($target['name'] ?? 'Unnamed Asset'); + $target['type'] = ($value['isDirectory'] ?? false) ? 'Folder' : 'File'; + $target['value'] = $value; + + $this->inspectTarget($target); + } + public function cycleFocusForward(): bool { if ($this->interactionState !== self::STATE_CONTROL_SELECTION || $this->focusableControls === []) { @@ -282,16 +367,34 @@ private function decorateStatefulControlLine( private function buildHierarchyControls(array $target, array $item): void { $this->addControl(new TextInputControl('Type', $this->resolveDisplayType($target, $item), 0, true)); - $this->addControl(new TextInputControl('Name', $item['name'] ?? $target['name'] ?? 'Unnamed Object', 0)); - $this->addControl(new TextInputControl('Tag', $item['tag'] ?? 'None', 0)); + $this->addBoundControl( + new TextInputControl('Name', $item['name'] ?? $target['name'] ?? 'Unnamed Object', 0), + ['name'], + ); + $this->addBoundControl( + new TextInputControl('Tag', $item['tag'] ?? 'None', 0), + ['tag'], + ); $this->addSectionHeader('Transform'); - $this->addControl(new VectorInputControl('Position', $this->normalizeVector($item['position'] ?? null), 1)); - $this->addControl(new VectorInputControl('Rotation', $this->normalizeVector($item['rotation'] ?? null), 1)); - $this->addControl(new VectorInputControl('Scale', $this->normalizeVector($item['scale'] ?? ['x' => 1, 'y' => 1]), 1)); + $this->addBoundControl( + new VectorInputControl('Position', $this->normalizeVector($item['position'] ?? null), 1), + ['position'], + ); + $this->addBoundControl( + new VectorInputControl('Rotation', $this->normalizeVector($item['rotation'] ?? null), 1), + ['rotation'], + ); + $this->addBoundControl( + new VectorInputControl('Scale', $this->normalizeVector($item['scale'] ?? ['x' => 1, 'y' => 1]), 1), + ['scale'], + ); if (isset($item['size']) && is_array($item['size'])) { - $this->addControl(new VectorInputControl('Size', $this->normalizeVector($item['size']), 1)); + $this->addBoundControl( + new VectorInputControl('Size', $this->normalizeVector($item['size']), 1), + ['size'], + ); } $this->addSectionHeader('Renderer'); @@ -299,6 +402,33 @@ private function buildHierarchyControls(array $target, array $item): void $this->addScriptComponents($item['components'] ?? []); } + private function buildSceneControls(array $target, array $scene): void + { + $this->addControl(new TextInputControl('Type', 'Scene', 0, true)); + $this->addBoundControl( + new TextInputControl('Name', $scene['name'] ?? $target['name'] ?? 'Scene', 0), + ['name'], + ); + $this->addBoundControl( + new NumberInputControl('Width', $scene['width'] ?? DEFAULT_TERMINAL_WIDTH, 0), + ['width'], + ); + $this->addBoundControl( + new NumberInputControl('Height', $scene['height'] ?? DEFAULT_TERMINAL_HEIGHT, 0), + ['height'], + ); + $this->addBoundControl( + new PathInputControl( + 'Environment Tile Map', + $scene['environmentTileMapPath'] ?? 'Maps/example', + $this->resolveAssetsWorkingDirectory(), + ['tmap'], + 0, + ), + ['environmentTileMapPath'], + ); + } + private function buildGenericControls(array $target): void { if (isset($target['type'])) { @@ -310,6 +440,26 @@ private function buildGenericControls(array $target): void } } + private function buildAssetControls(array $target, array $asset): void + { + $isDirectory = (bool) ($asset['isDirectory'] ?? false); + $assetName = $asset['name'] ?? $target['name'] ?? 'Unnamed Asset'; + $assetPath = is_string($asset['path'] ?? null) ? $asset['path'] : ''; + + $this->addControl(new TextInputControl('Type', $isDirectory ? 'Folder' : 'File', 0, true)); + + if ($isDirectory) { + $this->addControl(new TextInputControl('Name', $assetName, 0, true)); + } else { + $this->addBoundControl( + new TextInputControl('Name', $assetName, 0), + ['name'], + ); + } + + $this->addControl(new TextInputControl('Path', $assetPath, 0, true)); + } + private function addRendererControls(array $item): void { $sprite = is_array($item['sprite'] ?? null) ? $item['sprite'] : []; @@ -324,6 +474,7 @@ private function addRendererControls(array $item): void 'Texture', $texturePath, $this->resolveAssetsWorkingDirectory(), + ['texture'], 1, ); $this->rendererOffsetControl = new VectorInputControl('Offset', $offset, 1); @@ -334,13 +485,13 @@ private function addRendererControls(array $item): void 1, ); - $this->addControl($this->rendererTextureControl); - $this->addControl($this->rendererOffsetControl); - $this->addControl($this->rendererSizeControl); + $this->addBoundControl($this->rendererTextureControl, ['sprite', 'texture', 'path']); + $this->addBoundControl($this->rendererOffsetControl, ['sprite', 'texture', 'position']); + $this->addBoundControl($this->rendererSizeControl, ['sprite', 'texture', 'size']); $this->addControl($this->rendererPreviewControl); if (array_key_exists('text', $item)) { - $this->addControl(new TextInputControl('Text', $item['text'], 1)); + $this->addBoundControl(new TextInputControl('Text', $item['text'], 1), ['text']); } } @@ -350,7 +501,7 @@ private function addScriptComponents(mixed $components): void return; } - foreach ($components as $component) { + foreach ($components as $componentIndex => $component) { if (!is_array($component)) { continue; } @@ -362,11 +513,14 @@ private function addScriptComponents(mixed $components): void continue; } - $this->addControl($this->inputControlFactory->create( - $this->humanizeKey((string) $key), - $value, - 1, - )); + $this->addBoundControl( + $this->inputControlFactory->create( + $this->humanizeKey((string) $key), + $value, + 1, + ), + ['components', $componentIndex, $key], + ); } } } @@ -388,6 +542,12 @@ private function addControl(InputControl $control): void $this->focusableControls[] = $control; } + private function addBoundControl(InputControl $control, array $valuePath): void + { + $this->controlBindings[spl_object_id($control)] = $valuePath; + $this->addControl($control); + } + private function refreshContent(): void { $this->refreshDerivedControls(); @@ -592,12 +752,17 @@ private function handleControlEditInput(InputControl $selectedControl): void private function commitSelectedEdit(InputControl $selectedControl): void { if ($selectedControl instanceof CompoundInputControl) { - $selectedControl->commitActiveEdit(); + if ($selectedControl->commitActiveEdit()) { + $this->applyControlValueToInspectionTarget($selectedControl); + } + $this->interactionState = self::STATE_PROPERTY_SELECTION; return; } - $selectedControl->commitEdit(); + if ($selectedControl->commitEdit()) { + $this->applyControlValueToInspectionTarget($selectedControl); + } if ($selectedControl instanceof PathInputControl) { $this->activePathInputControl = null; @@ -673,6 +838,7 @@ private function handlePathInputActionInput(): void $this->fileDialogModal->show( $this->activePathInputControl->getWorkingDirectory(), (string) $this->activePathInputControl->getValue(), + $this->activePathInputControl->getAllowedExtensions(), ); $this->interactionState = self::STATE_PATH_INPUT_FILE_DIALOG; } @@ -738,6 +904,7 @@ private function handlePathInputFileDialogInput(): void } $this->activePathInputControl->setValueFromRelativePath($selectedPath); + $this->applyControlValueToInspectionTarget($this->activePathInputControl); $this->closePathInputModals(); $this->interactionState = self::STATE_CONTROL_SELECTION; $this->refreshContent(); @@ -852,7 +1019,7 @@ private function resolveTextureFilePath(string $texturePath): ?string $candidatePaths[] = $normalizedTexturePath . '.texture'; } - $workingDirectory = getcwd() ?: '.'; + $workingDirectory = $this->projectDirectory; $assetsRoots = [ $workingDirectory, $workingDirectory . '/Assets', @@ -937,9 +1104,86 @@ private function humanizeKey(string $key): string return ucwords(trim($spacedKey)); } + private function applyControlValueToInspectionTarget(InputControl $control): void + { + if ( + !is_array($this->inspectionTarget) + || !isset($this->inspectionTarget['value']) + || !is_array($this->inspectionTarget['value']) + ) { + return; + } + + $valuePath = $this->controlBindings[spl_object_id($control)] ?? null; + + if (!is_array($valuePath) || $valuePath === []) { + return; + } + + $inspectionValue = $this->inspectionTarget['value']; + $this->setNestedValue($inspectionValue, $valuePath, $control->getValue()); + $this->inspectionTarget['value'] = $inspectionValue; + + if ($valuePath === ['name']) { + $this->inspectionTarget['name'] = (string) $control->getValue(); + } + + $context = $this->inspectionTarget['context'] ?? null; + + if ($context === 'asset') { + if ( + $valuePath === ['name'] + && !($inspectionValue['isDirectory'] ?? false) + && is_string($inspectionValue['path'] ?? null) + ) { + $this->pendingAssetMutation = [ + 'path' => $inspectionValue['path'], + 'relativePath' => $inspectionValue['relativePath'] ?? basename($inspectionValue['path']), + 'name' => (string) $control->getValue(), + ]; + } + + return; + } + + if (!in_array($context, ['hierarchy', 'scene'], true)) { + return; + } + + $hierarchyPath = $this->inspectionTarget['path'] ?? null; + + if (!is_string($hierarchyPath) || $hierarchyPath === '') { + return; + } + + $this->pendingHierarchyMutation = [ + 'path' => $hierarchyPath, + 'value' => $inspectionValue, + ]; + } + + private function setNestedValue(array &$value, array $path, mixed $nextValue): void + { + $current = &$value; + $lastIndex = count($path) - 1; + + foreach ($path as $index => $segment) { + if ($index === $lastIndex) { + $current[$segment] = $nextValue; + return; + } + + if (!isset($current[$segment]) || !is_array($current[$segment])) { + $current[$segment] = []; + } + + $current = &$current[$segment]; + } + } + private function resolveAssetsWorkingDirectory(): string { - $workingDirectory = getcwd() ?: '.'; + $workingDirectory = $this->projectDirectory; $assetRoots = [ $workingDirectory . '/Assets', $workingDirectory . '/assets', diff --git a/src/Editor/Widgets/MainPanel.php b/src/Editor/Widgets/MainPanel.php index 75fc62b..1f60340 100644 --- a/src/Editor/Widgets/MainPanel.php +++ b/src/Editor/Widgets/MainPanel.php @@ -3,16 +3,63 @@ namespace Sendama\Console\Editor\Widgets; use Atatusoft\Termutil\IO\Enumerations\Color; +use Sendama\Console\Editor\IO\Enumerations\KeyCode; +use Sendama\Console\Editor\IO\Input; +use Sendama\Console\Util\Path; class MainPanel extends Widget { private const string DIVIDER_LINE_CHARACTER = '─'; private const string TAB_DIVIDER_LINE_CHARACTER = '■'; private const array TAB_TITLES = ['Scene', 'Game', 'Sprite']; + private const string SCENE_TAB_TITLE = 'Scene'; + private const string SCENE_VIEW_MODE_SELECT = 'select'; + private const string SCENE_VIEW_MODE_MOVE = 'move'; + private const string SCENE_VIEW_MODE_PAN = 'pan'; + private const string SCENE_SELECTION_SEQUENCE = "\033[30;46m"; + private const string SCENE_SELECTION_FOCUSED_SEQUENCE = "\033[5;30;46m"; + private const string SCENE_MOVE_SEQUENCE = "\033[30;43m"; + private const string SCENE_MOVE_FOCUSED_SEQUENCE = "\033[5;30;43m"; + private const string SCENE_PAN_SEQUENCE = "\033[30;44m"; + private const string SCENE_PAN_FOCUSED_SEQUENCE = "\033[5;30;44m"; + private const string SPRITE_CURSOR_SEQUENCE = "\033[30;47m"; + private const string SPRITE_CURSOR_FOCUSED_SEQUENCE = "\033[5;30;47m"; private const string GAME_IDLE_PATTERN_CHARACTER = '/'; private const string GAME_IDLE_PROMPT = 'Shift+5 to Play'; private const Color DEFAULT_FOCUS_COLOR = Color::LIGHT_CYAN; private const Color PLAY_MODE_FOCUS_COLOR = Color::BROWN; + private const string SPRITE_MODAL_CREATE = 'create_asset'; + private const string SPRITE_MODAL_DELETE = 'delete_asset'; + private const string SPRITE_MODAL_CHARACTER = 'character_picker'; + private const int DEFAULT_TEXTURE_WIDTH = 16; + private const int DEFAULT_TEXTURE_HEIGHT = 16; + private const array SPECIAL_CHARACTER_OPTIONS = [ + '█ Full Block', + '▓ Dark Shade', + '▒ Medium Shade', + '░ Light Shade', + '■ Square', + '□ Hollow Square', + '▲ Triangle Up', + '▼ Triangle Down', + '◄ Triangle Left', + '► Triangle Right', + '● Circle', + '○ Hollow Circle', + '★ Star', + '♥ Heart', + '│ Vertical', + '─ Horizontal', + '┌ Corner TL', + '┐ Corner TR', + '└ Corner BL', + '┘ Corner BR', + '┼ Cross', + '← Arrow Left', + '↑ Arrow Up', + '→ Arrow Right', + '↓ Arrow Down', + ]; protected int $activeTabIndex = 0; protected int $activeTabOffset = 0; @@ -21,15 +68,63 @@ class MainPanel extends Widget protected bool $isPlayModeActive = false; protected array $gameIdleContentIndexes = []; protected ?int $gameIdlePromptContentIndex = null; + protected array $sceneObjects = []; + protected array $visibleSceneObjects = []; + protected ?string $selectedScenePath = null; + protected ?array $pendingInspectionItem = null; + protected ?array $pendingHierarchyMutation = null; + protected string $sceneInteractionMode = self::SCENE_VIEW_MODE_SELECT; + protected array $sceneLineHighlights = []; + protected string $projectDirectory; + protected int $sceneWidth = DEFAULT_TERMINAL_WIDTH; + protected int $sceneHeight = DEFAULT_TERMINAL_HEIGHT; + protected string $environmentTileMapPath = 'Maps/example'; + protected int $sceneViewportOffsetX = 0; + protected int $sceneViewportOffsetY = 0; + protected string $modeHelpLabel = ''; + protected array $spriteLineHighlights = []; + protected ?array $activeSpriteAsset = null; + protected array $spriteGrid = []; + protected int $spriteGridWidth = 0; + protected int $spriteGridHeight = 0; + protected int $spriteCursorX = 0; + protected int $spriteCursorY = 0; + protected int $spriteViewportOffsetX = 0; + protected int $spriteViewportOffsetY = 0; + protected array $spriteOriginalGrid = []; + protected array $spriteUndoStack = []; + protected array $spriteRedoStack = []; + protected OptionListModal $createSpriteAssetModal; + protected OptionListModal $deleteSpriteAssetModal; + protected OptionListModal $characterPickerModal; + protected ?string $spriteModalState = null; + protected ?array $pendingAssetSyncRequest = null; public function __construct( array $position = ['x' => 37, 'y' => 1], int $width = 96, - int $height = 21 + int $height = 21, + array $sceneObjects = [], + ?string $workingDirectory = null, + ?int $sceneWidth = null, + ?int $sceneHeight = null, + ?string $environmentTileMapPath = null, ) { parent::__construct('', '', $position, $width, $height); $this->focusBorderColor = self::DEFAULT_FOCUS_COLOR; + $this->createSpriteAssetModal = new OptionListModal(title: 'Create Asset'); + $this->deleteSpriteAssetModal = new OptionListModal(title: 'Delete Asset'); + $this->characterPickerModal = new OptionListModal(title: 'Insert Character'); + $this->sceneObjects = array_values($sceneObjects); + $this->projectDirectory = is_string($workingDirectory) && $workingDirectory !== '' + ? $workingDirectory + : (getcwd() ?: '.'); + $this->sceneWidth = max(1, $sceneWidth ?? DEFAULT_TERMINAL_WIDTH); + $this->sceneHeight = max(1, $sceneHeight ?? DEFAULT_TERMINAL_HEIGHT); + $this->environmentTileMapPath = is_string($environmentTileMapPath) && $environmentTileMapPath !== '' + ? $environmentTileMapPath + : 'Maps/example'; $this->refreshContent(); } @@ -63,6 +158,59 @@ public function selectTab(string $tabTitle): void $this->refreshContent(); } + public function setSceneObjects(array $sceneObjects): void + { + $this->sceneObjects = array_values($sceneObjects); + $this->syncSelectedScenePath(); + $this->refreshContent(); + } + + public function selectSceneObject(?string $path): void + { + $this->selectedScenePath = $path; + $this->syncSelectedScenePath(); + $this->refreshContent(); + } + + public function setSceneDimensions(int $sceneWidth, int $sceneHeight): void + { + $this->sceneWidth = max(1, $sceneWidth); + $this->sceneHeight = max(1, $sceneHeight); + $this->refreshContent(); + } + + public function setEnvironmentTileMapPath(string $environmentTileMapPath): void + { + $this->environmentTileMapPath = $environmentTileMapPath !== '' + ? $environmentTileMapPath + : 'Maps/example'; + $this->refreshContent(); + } + + public function consumeInspectionRequest(): ?array + { + $pendingInspectionItem = $this->pendingInspectionItem; + $this->pendingInspectionItem = null; + + return $pendingInspectionItem; + } + + public function consumeHierarchyMutation(): ?array + { + $pendingHierarchyMutation = $this->pendingHierarchyMutation; + $this->pendingHierarchyMutation = null; + + return $pendingHierarchyMutation; + } + + public function consumeAssetSyncRequest(): ?array + { + $pendingAssetSyncRequest = $this->pendingAssetSyncRequest; + $this->pendingAssetSyncRequest = null; + + return $pendingAssetSyncRequest; + } + public function cycleFocusForward(): bool { $this->activateNextTab(); @@ -90,8 +238,179 @@ public function setPlayModeActive(bool $isPlayModeActive): void $this->refreshContent(); } + public function hasActiveModal(): bool + { + return $this->createSpriteAssetModal->isVisible() + || $this->deleteSpriteAssetModal->isVisible() + || $this->characterPickerModal->isVisible(); + } + + public function isModalDirty(): bool + { + return $this->createSpriteAssetModal->isDirty() + || $this->deleteSpriteAssetModal->isDirty() + || $this->characterPickerModal->isDirty(); + } + + public function markModalClean(): void + { + $this->createSpriteAssetModal->markClean(); + $this->deleteSpriteAssetModal->markClean(); + $this->characterPickerModal->markClean(); + } + + public function syncModalLayout(int $terminalWidth, int $terminalHeight): void + { + $this->createSpriteAssetModal->syncLayout($terminalWidth, $terminalHeight); + $this->deleteSpriteAssetModal->syncLayout($terminalWidth, $terminalHeight); + $this->characterPickerModal->syncLayout($terminalWidth, $terminalHeight); + } + + public function renderActiveModal(): void + { + if ($this->createSpriteAssetModal->isVisible()) { + $this->createSpriteAssetModal->render(); + } + + if ($this->deleteSpriteAssetModal->isVisible()) { + $this->deleteSpriteAssetModal->render(); + } + + if ($this->characterPickerModal->isVisible()) { + $this->characterPickerModal->render(); + } + } + + public function loadSpriteAsset(?array $asset): void + { + if (!$this->isEditableSpriteAsset($asset)) { + $this->activeSpriteAsset = null; + $this->spriteGrid = []; + $this->spriteGridWidth = 0; + $this->spriteGridHeight = 0; + $this->spriteCursorX = 0; + $this->spriteCursorY = 0; + $this->spriteViewportOffsetX = 0; + $this->spriteViewportOffsetY = 0; + $this->spriteOriginalGrid = []; + $this->spriteUndoStack = []; + $this->spriteRedoStack = []; + $this->refreshContent(); + return; + } + + $absolutePath = $asset['path']; + $grid = $this->loadSpriteGridFromFile( + $absolutePath, + strtolower((string) pathinfo($absolutePath, PATHINFO_EXTENSION)), + ); + $this->activeSpriteAsset = [ + 'name' => $asset['name'] ?? basename($absolutePath), + 'path' => $absolutePath, + 'relativePath' => $asset['relativePath'] ?? basename($absolutePath), + 'extension' => strtolower((string) pathinfo($absolutePath, PATHINFO_EXTENSION)), + ]; + $this->spriteGrid = $grid['rows']; + $this->spriteGridWidth = $grid['width']; + $this->spriteGridHeight = $grid['height']; + $this->spriteCursorX = 0; + $this->spriteCursorY = 0; + $this->spriteViewportOffsetX = 0; + $this->spriteViewportOffsetY = 0; + $this->spriteOriginalGrid = $this->copySpriteGrid($this->spriteGrid); + $this->spriteUndoStack = []; + $this->spriteRedoStack = []; + $this->refreshContent(); + } + + public function beginSpriteCreateWorkflow(): bool + { + if (!$this->isSpriteTabActive() || $this->isPlayModeActive || $this->hasActiveModal()) { + return false; + } + + $this->showCreateSpriteAssetModal(); + + return true; + } + public function update(): void { + if ($this->hasFocus() && $this->isSceneTabActive() && !$this->isPlayModeActive) { + if (Input::getCurrentInput() === 'Q') { + $this->sceneInteractionMode = self::SCENE_VIEW_MODE_SELECT; + $this->refreshContent(); + return; + } + + if (Input::getCurrentInput() === 'W') { + $this->sceneInteractionMode = self::SCENE_VIEW_MODE_MOVE; + $this->syncSelectedScenePath(); + $this->queueInspectionForSelectedSceneObject(); + $this->refreshContent(); + return; + } + + if (Input::getCurrentInput() === 'E') { + $this->sceneInteractionMode = self::SCENE_VIEW_MODE_PAN; + $this->refreshContent(); + return; + } + + if ($this->sceneInteractionMode === self::SCENE_VIEW_MODE_MOVE) { + if ($this->handleSceneMoveModeInput()) { + return; + } + } elseif ($this->sceneInteractionMode === self::SCENE_VIEW_MODE_PAN) { + if ($this->handleScenePanModeInput()) { + return; + } + } elseif ($this->handleSceneSelectModeInput()) { + return; + } + } + + if ($this->hasFocus() && $this->isSpriteTabActive() && !$this->isPlayModeActive) { + if ($this->hasActiveModal()) { + $this->handleSpriteModalInput(); + return; + } + + if (Input::getCurrentInput() === 'A') { + $this->showCreateSpriteAssetModal(); + return; + } + + if (Input::getCurrentInput() === '@') { + $this->showCharacterPickerModal(); + return; + } + + if (Input::isKeyDown(KeyCode::DELETE)) { + $this->showDeleteSpriteAssetModal(); + return; + } + + if (Input::isKeyDown(KeyCode::CTRL_Z)) { + $this->undoSpriteEdit(); + return; + } + + if (Input::isKeyDown(KeyCode::CTRL_Y)) { + $this->redoSpriteEdit(); + return; + } + + if (Input::getCurrentInput() === 'R') { + $this->resetSpriteEdits(); + return; + } + + if ($this->handleSpriteEditorInput()) { + return; + } + } + $this->refreshContent(); } @@ -126,6 +445,14 @@ protected function decorateContentLine(string $line, ?Color $contentColor, int $ $contentIndex = $lineIndex - $this->padding->topPadding; if ($lineIndex !== 1) { + if (isset($this->spriteLineHighlights[$contentIndex])) { + return $this->decorateSpriteLine($line, $contentColor, $contentIndex); + } + + if (isset($this->sceneLineHighlights[$contentIndex])) { + return $this->decorateSceneLine($line, $contentColor, $contentIndex); + } + if (!in_array($contentIndex, $this->gameIdleContentIndexes, true)) { return parent::decorateContentLine($line, $contentColor, $lineIndex); } @@ -157,6 +484,61 @@ protected function decorateContentLine(string $line, ?Color $contentColor, int $ . $this->wrapWithColor($rightBorder, $borderColor); } + protected function buildBorderLine(string $label, bool $isTopBorder): string + { + if ($isTopBorder) { + return parent::buildBorderLine($label, true); + } + + return $this->buildSplitHelpBorder($this->help, $this->modeHelpLabel); + } + + private function decorateSpriteLine(string $line, ?Color $contentColor, int $contentIndex): string + { + $highlight = $this->spriteLineHighlights[$contentIndex] ?? null; + + if (!is_array($highlight)) { + return parent::decorateContentLine($line, $contentColor, $contentIndex); + } + + $visibleLine = mb_substr($line, 0, $this->width); + $visibleLength = mb_strlen($visibleLine); + + if ($visibleLength <= 1) { + return parent::decorateContentLine($line, $contentColor, $contentIndex); + } + + $leftBorder = mb_substr($visibleLine, 0, 1); + $middle = $visibleLength > 2 ? mb_substr($visibleLine, 1, $visibleLength - 2) : ''; + $rightBorder = mb_substr($visibleLine, -1); + $borderColor = $this->hasFocus() ? $this->focusBorderColor : $contentColor; + $highlightStart = min( + max(0, $this->padding->leftPadding + (int) ($highlight['start'] ?? 0)), + mb_strlen($middle), + ); + $highlightLength = max( + 0, + min((int) ($highlight['length'] ?? 0), mb_strlen($middle) - $highlightStart), + ); + + if ($highlightLength === 0) { + return parent::decorateContentLine($line, $contentColor, $contentIndex); + } + + $beforeHighlight = mb_substr($middle, 0, $highlightStart); + $highlightText = mb_substr($middle, $highlightStart, $highlightLength); + $afterHighlight = mb_substr($middle, $highlightStart + $highlightLength); + $highlightSequence = $this->hasFocus() + ? self::SPRITE_CURSOR_FOCUSED_SEQUENCE + : self::SPRITE_CURSOR_SEQUENCE; + + return $this->wrapWithColor($leftBorder, $borderColor) + . $this->wrapWithColor($beforeHighlight, $contentColor) + . $this->wrapWithSequence($highlightText, $highlightSequence) + . $this->wrapWithColor($afterHighlight, $contentColor) + . $this->wrapWithColor($rightBorder, $borderColor); + } + private function decorateGameIdleLine(string $line, ?Color $contentColor, int $contentIndex): string { $visibleLine = mb_substr($line, 0, $this->width); @@ -211,6 +593,11 @@ private function refreshContent(): void $this->activeTabOffset = 0; $this->gameIdleContentIndexes = []; $this->gameIdlePromptContentIndex = null; + $this->sceneLineHighlights = []; + $this->spriteLineHighlights = []; + $this->visibleSceneObjects = $this->flattenSceneObjects($this->sceneObjects); + $this->syncSelectedScenePath(); + $this->updateHelpInfo(); foreach (self::TAB_TITLES as $index => $tabTitle) { if ($index > 0) { @@ -230,7 +617,11 @@ private function refreshContent(): void $dividerLine = $this->buildDividerLine($dividerWidth); $content = [$tabsLine, $dividerLine]; - if ($this->shouldRenderIdleGameView()) { + if ($this->isSceneTabActive()) { + $content = [...$content, ...$this->buildSceneCanvasContent()]; + } elseif ($this->isSpriteTabActive()) { + $content = [...$content, ...$this->buildSpriteEditorContent()]; + } elseif ($this->shouldRenderIdleGameView()) { $contentWidth = max(0, $this->innerWidth - $this->padding->leftPadding - $this->padding->rightPadding); $idleRows = max(0, $this->innerHeight - count($content)); $promptRow = $idleRows > 0 ? intdiv($idleRows, 2) : null; @@ -253,11 +644,1340 @@ private function refreshContent(): void $this->content = $content; } + private function updateHelpInfo(): void + { + if ($this->isPlayModeActive) { + $this->help = 'Tab/Shift+Tab tabs Shift+5 stop play'; + $this->modeHelpLabel = 'Mode: Play'; + return; + } + + if ($this->isSceneTabActive()) { + [$this->help, $this->modeHelpLabel] = match ($this->sceneInteractionMode) { + self::SCENE_VIEW_MODE_MOVE => [ + 'Arrows move Shift+Q select Shift+E pan', + 'Mode: Scene Move', + ], + self::SCENE_VIEW_MODE_PAN => [ + 'Arrows pan Shift+Q select Shift+W move', + 'Mode: Scene Pan', + ], + default => [ + 'Arrows cycle Enter inspect Shift+W move Shift+E pan', + 'Mode: Scene Select', + ], + }; + return; + } + + if ($this->isSpriteTabActive()) { + if ($this->activeSpriteAsset === null) { + $this->help = 'Select .texture or .tmap Shift+A new Shift+2 chars'; + $this->modeHelpLabel = 'Mode: Sprite Editor'; + return; + } + + $this->help = 'Arrows move Type draw Shift+2 chars Shift+A new Ctrl+Z/Y undo redo Shift+R reset Del delete'; + $this->modeHelpLabel = 'Mode: Sprite Editor ' . $this->buildSpriteCursorPositionLabel(); + return; + } + + if ($this->getActiveTab() === 'Game') { + $this->help = 'Tab/Shift+Tab tabs Shift+5 play'; + $this->modeHelpLabel = 'Mode: Game'; + return; + } + + $this->help = 'Tab/Shift+Tab tabs'; + $this->modeHelpLabel = 'Mode: ' . $this->getActiveTab(); + } + + private function buildSpriteCursorPositionLabel(): string + { + return 'Col x Row: ' . ($this->spriteCursorX + 1) . ' x ' . ($this->spriteCursorY + 1); + } + private function shouldRenderIdleGameView(): bool { return $this->getActiveTab() === 'Game' && !$this->isPlayModeActive; } + private function isSceneTabActive(): bool + { + return $this->getActiveTab() === self::SCENE_TAB_TITLE; + } + + private function isSpriteTabActive(): bool + { + return $this->getActiveTab() === 'Sprite'; + } + + private function handleSceneSelectModeInput(): bool + { + if ($this->visibleSceneObjects === []) { + return false; + } + + if (Input::isKeyDown(KeyCode::UP) || Input::isKeyDown(KeyCode::LEFT)) { + $this->moveSceneSelection(-1); + return true; + } + + if (Input::isKeyDown(KeyCode::DOWN) || Input::isKeyDown(KeyCode::RIGHT)) { + $this->moveSceneSelection(1); + return true; + } + + if (Input::isKeyDown(KeyCode::ENTER)) { + $this->activateSceneSelection(); + return true; + } + + return false; + } + + private function handleSceneMoveModeInput(): bool + { + if ($this->visibleSceneObjects === []) { + return false; + } + + $this->syncSelectedScenePath(); + + if (Input::isKeyDown(KeyCode::UP)) { + $this->moveSelectedSceneObject(0, -1); + return true; + } + + if (Input::isKeyDown(KeyCode::RIGHT)) { + $this->moveSelectedSceneObject(1, 0); + return true; + } + + if (Input::isKeyDown(KeyCode::DOWN)) { + $this->moveSelectedSceneObject(0, 1); + return true; + } + + if (Input::isKeyDown(KeyCode::LEFT)) { + $this->moveSelectedSceneObject(-1, 0); + return true; + } + + return false; + } + + private function handleScenePanModeInput(): bool + { + if (Input::isKeyDown(KeyCode::UP)) { + $this->panSceneViewport(0, -1); + return true; + } + + if (Input::isKeyDown(KeyCode::RIGHT)) { + $this->panSceneViewport(1, 0); + return true; + } + + if (Input::isKeyDown(KeyCode::DOWN)) { + $this->panSceneViewport(0, 1); + return true; + } + + if (Input::isKeyDown(KeyCode::LEFT)) { + $this->panSceneViewport(-1, 0); + return true; + } + + return false; + } + + private function handleSpriteEditorInput(): bool + { + if ($this->activeSpriteAsset === null) { + return false; + } + + if (Input::isKeyDown(KeyCode::UP)) { + $this->moveSpriteCursor(0, -1); + return true; + } + + if (Input::isKeyDown(KeyCode::RIGHT)) { + $this->moveSpriteCursor(1, 0); + return true; + } + + if (Input::isKeyDown(KeyCode::DOWN)) { + $this->moveSpriteCursor(0, 1); + return true; + } + + if (Input::isKeyDown(KeyCode::LEFT)) { + $this->moveSpriteCursor(-1, 0); + return true; + } + + if (Input::isKeyDown(KeyCode::BACKSPACE)) { + $this->writeSpriteCharacter(' '); + return true; + } + + if (Input::isKeyDown(KeyCode::SPACE)) { + $this->writeSpriteCharacter(' '); + return true; + } + + $currentInput = Input::getCurrentInput(); + + if ($this->isPrintableSpriteCharacter($currentInput)) { + $this->writeSpriteCharacter($currentInput); + return true; + } + + return false; + } + + private function showCreateSpriteAssetModal(): void + { + $this->createSpriteAssetModal->show(['Texture', 'Tile Map', 'Cancel'], 0, 'Create Asset'); + $this->deleteSpriteAssetModal->hide(); + $this->spriteModalState = self::SPRITE_MODAL_CREATE; + } + + private function showDeleteSpriteAssetModal(): void + { + if ($this->activeSpriteAsset === null) { + return; + } + + $this->deleteSpriteAssetModal->show( + ['Delete', 'Cancel'], + 1, + 'Delete ' . ($this->activeSpriteAsset['name'] ?? 'asset') . '?' + ); + $this->createSpriteAssetModal->hide(); + $this->characterPickerModal->hide(); + $this->spriteModalState = self::SPRITE_MODAL_DELETE; + } + + private function showCharacterPickerModal(): void + { + if ($this->activeSpriteAsset === null) { + return; + } + + $this->characterPickerModal->show(self::SPECIAL_CHARACTER_OPTIONS, 0, 'Insert Character'); + $this->createSpriteAssetModal->hide(); + $this->deleteSpriteAssetModal->hide(); + $this->spriteModalState = self::SPRITE_MODAL_CHARACTER; + } + + private function dismissSpriteModals(): void + { + $this->createSpriteAssetModal->hide(); + $this->deleteSpriteAssetModal->hide(); + $this->characterPickerModal->hide(); + $this->spriteModalState = null; + } + + private function handleSpriteModalInput(): void + { + if (Input::isKeyDown(KeyCode::ESCAPE)) { + $this->dismissSpriteModals(); + return; + } + + $activeModal = match ($this->spriteModalState) { + self::SPRITE_MODAL_CREATE => $this->createSpriteAssetModal, + self::SPRITE_MODAL_DELETE => $this->deleteSpriteAssetModal, + self::SPRITE_MODAL_CHARACTER => $this->characterPickerModal, + default => null, + }; + + if (!$activeModal instanceof OptionListModal) { + $this->dismissSpriteModals(); + return; + } + + if (Input::isKeyDown(KeyCode::UP)) { + $activeModal->moveSelection(-1); + return; + } + + if (Input::isKeyDown(KeyCode::DOWN)) { + $activeModal->moveSelection(1); + return; + } + + if (!Input::isKeyDown(KeyCode::ENTER)) { + return; + } + + $selection = $activeModal->getSelectedOption(); + + if ($selection === null || $selection === 'Cancel') { + $this->dismissSpriteModals(); + return; + } + + if ($this->spriteModalState === self::SPRITE_MODAL_CREATE) { + $this->createSpriteAsset($selection); + $this->dismissSpriteModals(); + return; + } + + if ($this->spriteModalState === self::SPRITE_MODAL_DELETE && $selection === 'Delete') { + $this->deleteActiveSpriteAsset(); + } + + if ($this->spriteModalState === self::SPRITE_MODAL_CHARACTER) { + $character = $this->resolveCharacterPickerSelection($selection); + + if ($character !== null) { + $this->writeSpriteCharacter($character); + } + } + + $this->dismissSpriteModals(); + } + + private function resolveCharacterPickerSelection(?string $selection): ?string + { + if (!is_string($selection) || $selection === '') { + return null; + } + + return mb_substr($selection, 0, 1) ?: null; + } + + private function moveSceneSelection(int $offset): void + { + if ($this->visibleSceneObjects === []) { + return; + } + + $selectedIndex = $this->getSelectedSceneObjectIndex() ?? 0; + $nextIndex = ($selectedIndex + $offset + count($this->visibleSceneObjects)) % count($this->visibleSceneObjects); + $this->selectedScenePath = $this->visibleSceneObjects[$nextIndex]['path'] ?? $this->selectedScenePath; + $this->queueInspectionForSelectedSceneObject(); + $this->refreshContent(); + } + + private function activateSceneSelection(): void + { + $this->queueInspectionForSelectedSceneObject(); + } + + private function moveSelectedSceneObject(int $deltaX, int $deltaY): void + { + $selectedNode = $this->getSelectedSceneNode(); + + if (!is_array($selectedNode) || !is_array($selectedNode['item'] ?? null)) { + return; + } + + $selectedPath = $selectedNode['path'] ?? null; + + if (!is_string($selectedPath) || $selectedPath === '') { + return; + } + + $selectedItem = $selectedNode['item']; + $position = $this->normalizeVector($selectedItem['position'] ?? null); + $selectedItem['position'] = [ + 'x' => $position['x'] + $deltaX, + 'y' => $position['y'] + $deltaY, + ]; + + if (!$this->applySceneObjectMutation($selectedPath, $selectedItem)) { + return; + } + + $this->pendingHierarchyMutation = [ + 'path' => $selectedPath, + 'value' => $selectedItem, + ]; + $this->pendingInspectionItem = [ + 'context' => 'hierarchy', + 'name' => $selectedItem['name'] ?? 'Unnamed Object', + 'type' => $this->resolveInspectableType($selectedItem), + 'path' => $selectedPath, + 'value' => $selectedItem, + ]; + $this->refreshContent(); + } + + private function panSceneViewport(int $deltaX, int $deltaY): void + { + $canvasWidth = max(0, $this->innerWidth - $this->padding->leftPadding - $this->padding->rightPadding); + $canvasHeight = max(0, $this->innerHeight - 2); + $maxOffsetX = max(0, $this->sceneWidth - max(1, $canvasWidth)); + $maxOffsetY = max(0, $this->sceneHeight - max(1, $canvasHeight)); + + $this->sceneViewportOffsetX = max(0, min($this->sceneViewportOffsetX + $deltaX, $maxOffsetX)); + $this->sceneViewportOffsetY = max(0, min($this->sceneViewportOffsetY + $deltaY, $maxOffsetY)); + $this->refreshContent(); + } + + private function decorateSceneLine(string $line, ?Color $contentColor, int $contentIndex): string + { + $highlight = $this->sceneLineHighlights[$contentIndex] ?? null; + + if (!is_array($highlight)) { + return parent::decorateContentLine($line, $contentColor, $contentIndex); + } + + $visibleLine = mb_substr($line, 0, $this->width); + $visibleLength = mb_strlen($visibleLine); + + if ($visibleLength <= 1) { + return parent::decorateContentLine($line, $contentColor, $contentIndex); + } + + $leftBorder = mb_substr($visibleLine, 0, 1); + $middle = $visibleLength > 2 ? mb_substr($visibleLine, 1, $visibleLength - 2) : ''; + $rightBorder = mb_substr($visibleLine, -1); + $borderColor = $this->hasFocus() ? $this->focusBorderColor : $contentColor; + $highlightStart = min( + max(0, $this->padding->leftPadding + (int) ($highlight['start'] ?? 0)), + mb_strlen($middle), + ); + $highlightLength = max( + 0, + min((int) ($highlight['length'] ?? 0), mb_strlen($middle) - $highlightStart), + ); + + if ($highlightLength === 0) { + return parent::decorateContentLine($line, $contentColor, $contentIndex); + } + + $beforeHighlight = mb_substr($middle, 0, $highlightStart); + $highlightText = mb_substr($middle, $highlightStart, $highlightLength); + $afterHighlight = mb_substr($middle, $highlightStart + $highlightLength); + + return $this->wrapWithColor($leftBorder, $borderColor) + . $this->wrapWithColor($beforeHighlight, $contentColor) + . $this->wrapWithSequence($highlightText, $this->resolveSceneHighlightSequence()) + . $this->wrapWithColor($afterHighlight, $contentColor) + . $this->wrapWithColor($rightBorder, $borderColor); + } + + private function resolveSceneHighlightSequence(): string + { + return match ($this->sceneInteractionMode) { + self::SCENE_VIEW_MODE_MOVE => $this->hasFocus() + ? self::SCENE_MOVE_FOCUSED_SEQUENCE + : self::SCENE_MOVE_SEQUENCE, + self::SCENE_VIEW_MODE_PAN => $this->hasFocus() + ? self::SCENE_PAN_FOCUSED_SEQUENCE + : self::SCENE_PAN_SEQUENCE, + default => $this->hasFocus() + ? self::SCENE_SELECTION_FOCUSED_SEQUENCE + : self::SCENE_SELECTION_SEQUENCE, + }; + } + + private function buildSceneCanvasContent(): array + { + $canvasWidth = max(0, $this->innerWidth - $this->padding->leftPadding - $this->padding->rightPadding); + $canvasHeight = max(0, $this->innerHeight - 2); + + if ($canvasWidth <= 0 || $canvasHeight <= 0) { + return []; + } + + $canvas = []; + + for ($row = 0; $row < $canvasHeight; $row++) { + $canvas[$row] = array_fill(0, $canvasWidth, ' '); + } + + $this->renderEnvironmentTileMap($canvas, $canvasWidth, $canvasHeight); + + foreach ($this->visibleSceneObjects as $sceneObject) { + $item = $sceneObject['item'] ?? null; + + if (!is_array($item)) { + continue; + } + + $position = $sceneObject['position'] ?? $this->normalizeVector($item['position'] ?? null); + $row = (int) ($position['y'] ?? 0) - $this->sceneViewportOffsetY; + $column = (int) ($position['x'] ?? 0) - $this->sceneViewportOffsetX; + $renderLines = is_array($sceneObject['renderLines'] ?? null) + ? $sceneObject['renderLines'] + : []; + + foreach ($renderLines as $lineOffset => $renderLine) { + $targetRow = $row + $lineOffset; + + if ($targetRow < 0 || $targetRow >= $canvasHeight) { + continue; + } + + $characters = preg_split('//u', $renderLine, -1, PREG_SPLIT_NO_EMPTY); + + if (!is_array($characters) || $characters === []) { + continue; + } + + $startCharacterIndex = max(0, -$column); + $targetColumn = max(0, $column); + + for ( + $characterIndex = $startCharacterIndex; + $characterIndex < count($characters) && $targetColumn < $canvasWidth; + $characterIndex++, $targetColumn++ + ) { + $canvas[$targetRow][$targetColumn] = $characters[$characterIndex]; + } + + if (($sceneObject['path'] ?? null) !== $this->selectedScenePath) { + continue; + } + + $visibleLength = min( + count($characters) - $startCharacterIndex, + $canvasWidth - max(0, $column), + ); + + if ($visibleLength <= 0) { + continue; + } + + $this->sceneLineHighlights[2 + $targetRow] = [ + 'start' => max(0, $column), + 'length' => $visibleLength, + ]; + } + } + + return array_map( + static fn(array $lineCharacters): string => implode('', $lineCharacters), + $canvas, + ); + } + + private function buildSpriteEditorContent(): array + { + $contentWidth = max(0, $this->innerWidth - $this->padding->leftPadding - $this->padding->rightPadding); + $contentHeight = max(0, $this->innerHeight - 2); + + if ($contentWidth <= 0 || $contentHeight <= 0) { + return []; + } + + if ($this->activeSpriteAsset === null) { + return [ + 'Sprite editor', + 'Select a .texture or .tmap asset in Assets to edit it here.', + ]; + } + + $visibleGridHeight = $contentHeight; + $rows = []; + + for ($row = 0; $row < $visibleGridHeight; $row++) { + $gridRowIndex = $this->spriteViewportOffsetY + $row; + + if ($gridRowIndex >= $this->spriteGridHeight) { + $rows[] = ''; + continue; + } + + $rowCharacters = $this->spriteGrid[$gridRowIndex] ?? []; + $line = ''; + + for ($column = 0; $column < $contentWidth; $column++) { + $gridColumnIndex = $this->spriteViewportOffsetX + $column; + $line .= $rowCharacters[$gridColumnIndex] ?? ' '; + } + + if ( + $gridRowIndex === $this->spriteCursorY + && $this->spriteCursorX >= $this->spriteViewportOffsetX + && $this->spriteCursorX < $this->spriteViewportOffsetX + $contentWidth + ) { + $this->spriteLineHighlights[2 + $row] = [ + 'start' => $this->spriteCursorX - $this->spriteViewportOffsetX, + 'length' => 1, + ]; + } + + $rows[] = $line; + } + + return $rows; + } + + private function flattenSceneObjects(array $items, string $parentPath = 'scene'): array + { + $flattenedObjects = []; + + foreach (array_values($items) as $index => $item) { + if (!is_array($item)) { + continue; + } + + $path = $parentPath . '.' . $index; + $renderLines = $this->resolveSceneObjectRenderLines($item); + + if ($renderLines !== []) { + $flattenedObjects[] = [ + 'path' => $path, + 'item' => $item, + 'position' => $this->normalizeVector($item['position'] ?? null), + 'renderLines' => $renderLines, + ]; + } + + if (is_array($item['children'] ?? null) && $item['children'] !== []) { + $flattenedObjects = [ + ...$flattenedObjects, + ...$this->flattenSceneObjects($item['children'], $path), + ]; + } + } + + return $flattenedObjects; + } + + private function isEditableSpriteAsset(?array $asset): bool + { + if (!is_array($asset) || ($asset['isDirectory'] ?? false) || !is_string($asset['path'] ?? null)) { + return false; + } + + $extension = strtolower((string) pathinfo((string) $asset['path'], PATHINFO_EXTENSION)); + + return in_array($extension, ['texture', 'tmap'], true); + } + + private function loadSpriteGridFromFile(string $absolutePath, string $extension): array + { + $contents = file_get_contents($absolutePath); + [$defaultWidth, $defaultHeight] = $this->resolveDefaultSpriteDimensions($extension); + + if ($contents === false) { + return $this->createBlankSpriteGrid($defaultWidth, $defaultHeight); + } + + $normalizedContents = str_replace(["\r\n", "\r"], "\n", $contents); + $lines = explode("\n", rtrim($normalizedContents, "\n")); + + if ($lines === [''] || $lines === []) { + return $this->createBlankSpriteGrid($defaultWidth, $defaultHeight); + } + + $width = max(1, ...array_map(static fn(string $line): int => mb_strlen($line), $lines)); + $rows = []; + + foreach ($lines as $line) { + $characters = preg_split('//u', $line, -1, PREG_SPLIT_NO_EMPTY); + $characters = is_array($characters) ? $characters : []; + $rows[] = array_pad($characters, $width, ' '); + } + + $grid = [ + 'rows' => $rows, + 'width' => $width, + 'height' => count($rows), + ]; + + if ($extension === 'texture') { + return $this->expandSpriteGrid($grid, self::DEFAULT_TEXTURE_WIDTH, self::DEFAULT_TEXTURE_HEIGHT); + } + + return $grid; + } + + private function createBlankSpriteGrid(int $width, int $height): array + { + $rows = []; + + for ($row = 0; $row < $height; $row++) { + $rows[] = array_fill(0, $width, ' '); + } + + return [ + 'rows' => $rows, + 'width' => $width, + 'height' => $height, + ]; + } + + private function moveSpriteCursor(int $deltaX, int $deltaY): void + { + if ($this->activeSpriteAsset === null || $this->spriteGridWidth <= 0 || $this->spriteGridHeight <= 0) { + return; + } + + $this->spriteCursorX = max(0, min($this->spriteCursorX + $deltaX, $this->spriteGridWidth - 1)); + $this->spriteCursorY = max(0, min($this->spriteCursorY + $deltaY, $this->spriteGridHeight - 1)); + $this->syncSpriteViewport(); + $this->refreshContent(); + } + + private function syncSpriteViewport(): void + { + $contentWidth = max(1, $this->innerWidth - $this->padding->leftPadding - $this->padding->rightPadding); + $visibleGridHeight = max(1, $this->innerHeight - 2); + $maxOffsetX = max(0, $this->spriteGridWidth - $contentWidth); + $maxOffsetY = max(0, $this->spriteGridHeight - $visibleGridHeight); + + if ($this->spriteCursorX < $this->spriteViewportOffsetX) { + $this->spriteViewportOffsetX = $this->spriteCursorX; + } elseif ($this->spriteCursorX >= $this->spriteViewportOffsetX + $contentWidth) { + $this->spriteViewportOffsetX = $this->spriteCursorX - $contentWidth + 1; + } + + if ($this->spriteCursorY < $this->spriteViewportOffsetY) { + $this->spriteViewportOffsetY = $this->spriteCursorY; + } elseif ($this->spriteCursorY >= $this->spriteViewportOffsetY + $visibleGridHeight) { + $this->spriteViewportOffsetY = $this->spriteCursorY - $visibleGridHeight + 1; + } + + $this->spriteViewportOffsetX = max(0, min($this->spriteViewportOffsetX, $maxOffsetX)); + $this->spriteViewportOffsetY = max(0, min($this->spriteViewportOffsetY, $maxOffsetY)); + } + + private function isPrintableSpriteCharacter(string $input): bool + { + return mb_strlen($input) === 1 && !preg_match('/[\x00-\x1F\x7F]/', $input); + } + + private function writeSpriteCharacter(string $character): void + { + if ($this->activeSpriteAsset === null) { + return; + } + + $nextCharacter = mb_substr($character, 0, 1); + + if (($this->spriteGrid[$this->spriteCursorY][$this->spriteCursorX] ?? ' ') === $nextCharacter) { + return; + } + + $this->pushSpriteUndoSnapshot(); + $this->spriteGrid[$this->spriteCursorY][$this->spriteCursorX] = $nextCharacter; + $this->persistActiveSpriteAsset(); + + if ($this->spriteCursorX < $this->spriteGridWidth - 1) { + $this->spriteCursorX++; + } + + $this->syncSpriteViewport(); + $this->refreshContent(); + } + + private function undoSpriteEdit(): void + { + if ($this->activeSpriteAsset === null || $this->spriteUndoStack === []) { + return; + } + + $this->spriteRedoStack[] = $this->copySpriteGrid($this->spriteGrid); + $this->spriteGrid = array_pop($this->spriteUndoStack); + $this->persistActiveSpriteAsset(); + $this->syncSpriteViewport(); + $this->refreshContent(); + } + + private function redoSpriteEdit(): void + { + if ($this->activeSpriteAsset === null || $this->spriteRedoStack === []) { + return; + } + + $this->spriteUndoStack[] = $this->copySpriteGrid($this->spriteGrid); + $this->spriteGrid = array_pop($this->spriteRedoStack); + $this->persistActiveSpriteAsset(); + $this->syncSpriteViewport(); + $this->refreshContent(); + } + + private function resetSpriteEdits(): void + { + if ($this->activeSpriteAsset === null || $this->spriteOriginalGrid === []) { + return; + } + + if ($this->spriteGrid === $this->spriteOriginalGrid) { + return; + } + + $this->pushSpriteUndoSnapshot(); + $this->spriteGrid = $this->copySpriteGrid($this->spriteOriginalGrid); + $this->persistActiveSpriteAsset(); + $this->syncSpriteViewport(); + $this->refreshContent(); + } + + private function pushSpriteUndoSnapshot(): void + { + $this->spriteUndoStack[] = $this->copySpriteGrid($this->spriteGrid); + $this->spriteRedoStack = []; + } + + private function copySpriteGrid(array $grid): array + { + return array_map( + static fn(array $row): array => array_values($row), + $grid, + ); + } + + private function resolveDefaultSpriteDimensions(string $extension): array + { + if ($extension === 'tmap') { + $terminalSize = get_max_terminal_size(); + + return [ + max(1, (int) ($terminalSize['width'] ?? DEFAULT_TERMINAL_WIDTH)), + max(1, (int) ($terminalSize['height'] ?? DEFAULT_TERMINAL_HEIGHT)), + ]; + } + + return [self::DEFAULT_TEXTURE_WIDTH, self::DEFAULT_TEXTURE_HEIGHT]; + } + + private function expandSpriteGrid(array $grid, int $targetWidth, int $targetHeight): array + { + $expandedWidth = max($targetWidth, (int) ($grid['width'] ?? 0)); + $expandedHeight = max($targetHeight, (int) ($grid['height'] ?? 0)); + $rows = array_map( + static fn(array $row): array => array_pad(array_values($row), $expandedWidth, ' '), + is_array($grid['rows'] ?? null) ? $grid['rows'] : [], + ); + + while (count($rows) < $expandedHeight) { + $rows[] = array_fill(0, $expandedWidth, ' '); + } + + return [ + 'rows' => $rows, + 'width' => $expandedWidth, + 'height' => $expandedHeight, + ]; + } + + private function persistActiveSpriteAsset(): void + { + if ($this->activeSpriteAsset === null || !is_string($this->activeSpriteAsset['path'] ?? null)) { + return; + } + + $lines = array_map( + static fn(array $row): string => implode('', $row), + $this->spriteGrid + ); + file_put_contents($this->activeSpriteAsset['path'], implode("\n", $lines) . "\n"); + } + + private function createSpriteAsset(string $selection): void + { + $assetsDirectory = $this->resolveAssetsDirectory(); + + if ($assetsDirectory === null) { + return; + } + + $isTileMap = $selection === 'Tile Map'; + $targetDirectory = Path::join($assetsDirectory, $isTileMap ? 'Maps' : 'Textures'); + + if (!is_dir($targetDirectory)) { + mkdir($targetDirectory, 0777, true); + } + + $baseName = $isTileMap ? 'new-map' : 'new-texture'; + $extension = $isTileMap ? 'tmap' : 'texture'; + $absolutePath = $this->createNextAvailableAssetPath($targetDirectory, $baseName, $extension); + [$defaultWidth, $defaultHeight] = $this->resolveDefaultSpriteDimensions($extension); + $grid = $this->createBlankSpriteGrid($defaultWidth, $defaultHeight); + $this->spriteGrid = $grid['rows']; + $this->spriteGridWidth = $grid['width']; + $this->spriteGridHeight = $grid['height']; + $this->spriteCursorX = 0; + $this->spriteCursorY = 0; + $this->spriteViewportOffsetX = 0; + $this->spriteViewportOffsetY = 0; + $this->spriteOriginalGrid = $this->copySpriteGrid($this->spriteGrid); + $this->spriteUndoStack = []; + $this->spriteRedoStack = []; + $this->activeSpriteAsset = [ + 'name' => basename($absolutePath), + 'path' => $absolutePath, + 'relativePath' => $this->buildAssetRelativePath($absolutePath), + 'extension' => $extension, + ]; + $this->persistActiveSpriteAsset(); + $this->pendingAssetSyncRequest = [ + 'path' => $absolutePath, + 'inspectionTarget' => [ + 'context' => 'asset', + 'name' => basename($absolutePath), + 'type' => 'File', + 'value' => [ + 'name' => basename($absolutePath), + 'path' => $absolutePath, + 'relativePath' => $this->buildAssetRelativePath($absolutePath), + 'isDirectory' => false, + ], + ], + ]; + $this->refreshContent(); + } + + private function deleteActiveSpriteAsset(): void + { + if ($this->activeSpriteAsset === null || !is_string($this->activeSpriteAsset['path'] ?? null)) { + return; + } + + $deletedPath = $this->activeSpriteAsset['path']; + + if (!is_file($deletedPath) || !unlink($deletedPath)) { + return; + } + + $this->activeSpriteAsset = null; + $this->spriteGrid = []; + $this->spriteGridWidth = 0; + $this->spriteGridHeight = 0; + $this->spriteCursorX = 0; + $this->spriteCursorY = 0; + $this->spriteViewportOffsetX = 0; + $this->spriteViewportOffsetY = 0; + $this->spriteOriginalGrid = []; + $this->spriteUndoStack = []; + $this->spriteRedoStack = []; + $this->pendingAssetSyncRequest = [ + 'path' => $deletedPath, + 'clearInspection' => true, + ]; + $this->refreshContent(); + } + + private function resolveAssetsDirectory(): ?string + { + $candidates = [ + Path::join($this->projectDirectory, 'Assets'), + Path::join($this->projectDirectory, 'assets'), + ]; + + foreach ($candidates as $candidate) { + if (is_dir($candidate)) { + return $candidate; + } + } + + return null; + } + + private function createNextAvailableAssetPath(string $targetDirectory, string $baseName, string $extension): string + { + $index = 1; + + do { + $candidatePath = Path::join($targetDirectory, $baseName . '-' . $index . '.' . $extension); + $index++; + } while (file_exists($candidatePath)); + + return $candidatePath; + } + + private function buildAssetRelativePath(string $absolutePath): string + { + $assetsDirectory = $this->resolveAssetsDirectory(); + + if ($assetsDirectory === null) { + return basename($absolutePath); + } + + $relativePath = substr($absolutePath, strlen($assetsDirectory)); + + return ltrim((string) $relativePath, DIRECTORY_SEPARATOR); + } + + private function syncSelectedScenePath(): void + { + if ($this->visibleSceneObjects === []) { + $this->selectedScenePath = null; + return; + } + + foreach ($this->visibleSceneObjects as $sceneObject) { + if (($sceneObject['path'] ?? null) === $this->selectedScenePath) { + return; + } + } + + $this->selectedScenePath = $this->visibleSceneObjects[0]['path'] ?? null; + } + + private function queueInspectionForSelectedSceneObject(): void + { + $selectedNode = $this->getSelectedSceneNode(); + + if (!is_array($selectedNode) || !is_array($selectedNode['item'] ?? null)) { + return; + } + + $selectedItem = $selectedNode['item']; + $selectedPath = $selectedNode['path'] ?? null; + + if (!is_string($selectedPath) || $selectedPath === '') { + return; + } + + $this->pendingInspectionItem = [ + 'context' => 'hierarchy', + 'name' => $selectedItem['name'] ?? 'Unnamed Object', + 'type' => $this->resolveInspectableType($selectedItem), + 'path' => $selectedPath, + 'value' => $selectedItem, + ]; + } + + private function getSelectedSceneNode(): ?array + { + $selectedIndex = $this->getSelectedSceneObjectIndex(); + + if ($selectedIndex === null) { + return null; + } + + return $this->visibleSceneObjects[$selectedIndex] ?? null; + } + + private function getSelectedSceneObjectIndex(): ?int + { + if ($this->selectedScenePath === null) { + return null; + } + + foreach ($this->visibleSceneObjects as $index => $sceneObject) { + if (($sceneObject['path'] ?? null) === $this->selectedScenePath) { + return $index; + } + } + + return null; + } + + private function resolveSceneObjectRenderLines(array $item): array + { + $spriteRenderLines = $this->buildSpriteRenderLines($item); + + if ($spriteRenderLines !== []) { + return $spriteRenderLines; + } + + if (is_string($item['text'] ?? null) && $item['text'] !== '') { + $textLines = preg_split('/\R/u', (string) $item['text']); + + return is_array($textLines) ? $textLines : [(string) $item['text']]; + } + + return []; + } + + private function resolveInspectableType(array $item): string + { + $type = $item['type'] ?? null; + + if (!is_string($type) || $type === '') { + return 'Unknown'; + } + + $normalizedType = ltrim($type, '\\'); + $normalizedType = preg_replace('/::class$/', '', $normalizedType) ?? $normalizedType; + $typeSegments = explode('\\', $normalizedType); + + return end($typeSegments) ?: $normalizedType; + } + + private function normalizeVector(mixed $value): array + { + if (!is_array($value)) { + return ['x' => 0, 'y' => 0]; + } + + return [ + 'x' => $this->normalizeSceneCoordinate($value['x'] ?? 0), + 'y' => $this->normalizeSceneCoordinate($value['y'] ?? 0), + ]; + } + + private function normalizeSceneCoordinate(mixed $value): int + { + if (is_int($value)) { + return $value; + } + + if (is_float($value)) { + return (int) round($value); + } + + if (is_numeric($value)) { + return (int) round((float) $value); + } + + return 0; + } + + private function buildSpriteRenderLines(array $item): array + { + $sprite = is_array($item['sprite'] ?? null) ? $item['sprite'] : []; + $texture = is_array($sprite['texture'] ?? null) ? $sprite['texture'] : []; + $texturePath = is_string($texture['path'] ?? null) && $texture['path'] !== '' + ? $texture['path'] + : null; + + if ($texturePath === null) { + return []; + } + + $offset = $this->normalizeVector($texture['position'] ?? null); + $size = $this->normalizeVector($texture['size'] ?? null); + + return $this->buildTexturePreviewLines($texturePath, $offset, $size); + } + + private function buildTexturePreviewLines(string $texturePath, array $offset, array $size): array + { + if ((int) $size['x'] <= 0 || (int) $size['y'] <= 0) { + return []; + } + + $resolvedTextureFilePath = $this->resolveAssetFilePath($texturePath, 'texture'); + + if ($resolvedTextureFilePath === null) { + return []; + } + + $textureContents = file_get_contents($resolvedTextureFilePath); + + if ($textureContents === false || $textureContents === '') { + return []; + } + + $textureRows = preg_split('/\R/u', rtrim($textureContents, "\r\n")); + + if ($textureRows === false) { + return []; + } + + if (count($textureRows) <= 1) { + $textureRows = $this->expandSingleLineTexture( + $textureRows[0] ?? '', + (int) $size['x'], + ); + } + + $previewWidth = (int) $size['x']; + $previewHeight = (int) $size['y']; + $offsetX = max(0, (int) $offset['x']); + $offsetY = max(0, (int) $offset['y']); + $previewLines = []; + + for ($rowIndex = 0; $rowIndex < $previewHeight; $rowIndex++) { + $sourceRow = $textureRows[$offsetY + $rowIndex] ?? ''; + $previewLine = mb_substr($sourceRow, $offsetX, $previewWidth); + + if ($previewLine === '') { + $previewLine = str_repeat(' ', $previewWidth); + } + + $previewLines[] = $previewLine; + } + + return $previewLines; + } + + private function resolveAssetFilePath(string $assetPath, string $defaultExtension): ?string + { + $normalizedTexturePath = str_replace('\\', '/', $assetPath); + $candidatePaths = []; + + if ($this->hasFileExtension($normalizedTexturePath)) { + $candidatePaths[] = $normalizedTexturePath; + } else { + $candidatePaths[] = $normalizedTexturePath . '.' . ltrim($defaultExtension, '.'); + } + + $workingDirectory = $this->projectDirectory; + $assetsRoots = [ + $workingDirectory, + $workingDirectory . '/Assets', + $workingDirectory . '/assets', + ]; + + foreach ($assetsRoots as $assetsRoot) { + $trimmedAssetsRoot = rtrim($assetsRoot, '/'); + + foreach ($candidatePaths as $candidatePath) { + $resolvedPath = $trimmedAssetsRoot . '/' . ltrim($candidatePath, '/'); + + if (is_file($resolvedPath)) { + return $resolvedPath; + } + } + } + + return null; + } + + private function hasFileExtension(string $path): bool + { + return pathinfo($path, PATHINFO_EXTENSION) !== ''; + } + + private function renderEnvironmentTileMap(array &$canvas, int $canvasWidth, int $canvasHeight): void + { + $tileMapLines = $this->buildEnvironmentTileMapLines(); + + if ($tileMapLines === []) { + return; + } + + foreach ($tileMapLines as $rowIndex => $tileMapLine) { + $targetRow = $rowIndex - $this->sceneViewportOffsetY; + + if ($targetRow < 0 || $targetRow >= $canvasHeight) { + continue; + } + + $characters = preg_split('//u', $tileMapLine, -1, PREG_SPLIT_NO_EMPTY); + + if (!is_array($characters) || $characters === []) { + continue; + } + + $startCharacterIndex = max(0, $this->sceneViewportOffsetX); + $targetColumn = 0; + + for ( + $characterIndex = $startCharacterIndex; + $characterIndex < count($characters) && $targetColumn < $canvasWidth; + $characterIndex++, $targetColumn++ + ) { + $canvas[$targetRow][$targetColumn] = $characters[$characterIndex]; + } + } + } + + private function buildEnvironmentTileMapLines(): array + { + if ($this->environmentTileMapPath === '') { + return []; + } + + $resolvedTileMapPath = $this->resolveAssetFilePath($this->environmentTileMapPath, 'tmap'); + + if ($resolvedTileMapPath === null) { + return []; + } + + $tileMapContents = file_get_contents($resolvedTileMapPath); + + if ($tileMapContents === false || $tileMapContents === '') { + return []; + } + + $tileMapLines = preg_split('/\R/u', rtrim($tileMapContents, "\r\n")); + + return is_array($tileMapLines) ? $tileMapLines : []; + } + + private function buildSplitHelpBorder(string $leftLabel, string $rightLabel): string + { + $availableLabelWidth = max(0, $this->width - 3); + $visibleRightLabel = $this->clipContentToWidth($rightLabel, $availableLabelWidth); + $remainingWidth = max(0, $availableLabelWidth - mb_strlen($visibleRightLabel)); + $visibleLeftLabel = $this->clipContentToWidth($leftLabel, $remainingWidth); + $fillerWidth = max(0, $availableLabelWidth - mb_strlen($visibleLeftLabel) - mb_strlen($visibleRightLabel)); + + return $this->borderPack->bottomLeft + . $this->borderPack->horizontal + . $visibleLeftLabel + . str_repeat($this->borderPack->horizontal, $fillerWidth) + . $visibleRightLabel + . $this->borderPack->bottomRight; + } + + private function expandSingleLineTexture(string $textureContents, int $rowWidth): array + { + if ($textureContents === '') { + return []; + } + + $characters = preg_split('//u', $textureContents, -1, PREG_SPLIT_NO_EMPTY); + + if ($characters === false || $characters === []) { + return []; + } + + if ($rowWidth <= 1) { + return $characters; + } + + $rows = []; + + for ($index = 0; $index < count($characters); $index += $rowWidth) { + $rows[] = implode('', array_slice($characters, $index, $rowWidth)); + } + + return $rows; + } + + private function applySceneObjectMutation(string $path, array $value): bool + { + $segments = explode('.', $path); + + if (($segments[0] ?? null) !== 'scene') { + return false; + } + + array_shift($segments); + + if ($segments === []) { + return false; + } + + $sceneObjects = $this->sceneObjects; + $nodeArray = &$sceneObjects; + $lastIndex = count($segments) - 1; + + foreach ($segments as $index => $segment) { + if (!ctype_digit((string) $segment)) { + return false; + } + + $numericSegment = (int) $segment; + + if (!isset($nodeArray[$numericSegment]) || !is_array($nodeArray[$numericSegment])) { + return false; + } + + if ($index === $lastIndex) { + $nodeArray[$numericSegment] = $value; + $this->sceneObjects = array_values($sceneObjects); + + return true; + } + + if (!isset($nodeArray[$numericSegment]['children']) || !is_array($nodeArray[$numericSegment]['children'])) { + return false; + } + + $nodeArray = &$nodeArray[$numericSegment]['children']; + } + + return false; + } + private function buildGameIdleLine(int $width, int $row, bool $includePrompt): string { if ($width <= 0) { diff --git a/src/Editor/Widgets/OptionListModal.php b/src/Editor/Widgets/OptionListModal.php index 1aaccf7..a3e1bf5 100644 --- a/src/Editor/Widgets/OptionListModal.php +++ b/src/Editor/Widgets/OptionListModal.php @@ -27,8 +27,12 @@ public function __construct( ); } - public function show(array $options, int $selectedIndex = 0): void + public function show(array $options, int $selectedIndex = 0, ?string $title = null): void { + if (is_string($title) && $title !== '') { + $this->title = $title; + } + $this->options = array_values($options); $optionCount = count($this->options); $this->selectedIndex = $optionCount > 0 diff --git a/tests/Unit/AssetsPanelTest.php b/tests/Unit/AssetsPanelTest.php index 37540c8..cbed7d2 100644 --- a/tests/Unit/AssetsPanelTest.php +++ b/tests/Unit/AssetsPanelTest.php @@ -91,3 +91,74 @@ ], ]); }); + +test('assets panel queues the selected asset for deletion when confirmed', function () { + $workspace = sys_get_temp_dir() . '/sendama-assets-panel-' . uniqid(); + mkdir($workspace . '/Assets', 0777, true); + file_put_contents($workspace . '/Assets/readme.txt', 'docs'); + + $panel = new AssetsPanel( + width: 40, + height: 12, + assetsDirectoryPath: $workspace . '/Assets', + ); + + $showDeleteConfirmModal = new ReflectionMethod(AssetsPanel::class, 'showDeleteConfirmModal'); + $showDeleteConfirmModal->setAccessible(true); + $showDeleteConfirmModal->invoke($panel); + + $handleModalInput = new ReflectionMethod(AssetsPanel::class, 'handleModalInput'); + $handleModalInput->setAccessible(true); + + $keyPress = new ReflectionProperty(\Sendama\Console\Editor\IO\InputManager::class, 'keyPress'); + $previousKeyPress = new ReflectionProperty(\Sendama\Console\Editor\IO\InputManager::class, 'previousKeyPress'); + $keyPress->setAccessible(true); + $previousKeyPress->setAccessible(true); + $previousKeyPress->setValue(''); + $keyPress->setValue("\n"); + + $deleteConfirmModal = new ReflectionProperty(AssetsPanel::class, 'deleteConfirmModal'); + $deleteConfirmModal->setAccessible(true); + $modal = $deleteConfirmModal->getValue($panel); + $moveSelection = new ReflectionMethod($modal, 'moveSelection'); + $moveSelection->invoke($modal, -1); + + $handleModalInput->invoke($panel); + + expect($panel->consumeDeletionRequest())->toBe([ + 'path' => '0', + 'assetPath' => $workspace . '/Assets/readme.txt', + 'name' => 'readme.txt', + 'isDirectory' => false, + ]); +}); + +test('assets panel cancels delete confirmation without queuing a deletion', function () { + $workspace = sys_get_temp_dir() . '/sendama-assets-panel-' . uniqid(); + mkdir($workspace . '/Assets', 0777, true); + file_put_contents($workspace . '/Assets/readme.txt', 'docs'); + + $panel = new AssetsPanel( + width: 40, + height: 12, + assetsDirectoryPath: $workspace . '/Assets', + ); + + $showDeleteConfirmModal = new ReflectionMethod(AssetsPanel::class, 'showDeleteConfirmModal'); + $showDeleteConfirmModal->setAccessible(true); + $showDeleteConfirmModal->invoke($panel); + + $handleModalInput = new ReflectionMethod(AssetsPanel::class, 'handleModalInput'); + $handleModalInput->setAccessible(true); + + $keyPress = new ReflectionProperty(\Sendama\Console\Editor\IO\InputManager::class, 'keyPress'); + $previousKeyPress = new ReflectionProperty(\Sendama\Console\Editor\IO\InputManager::class, 'previousKeyPress'); + $keyPress->setAccessible(true); + $previousKeyPress->setAccessible(true); + $previousKeyPress->setValue(''); + $keyPress->setValue("\n"); + + $handleModalInput->invoke($panel); + + expect($panel->consumeDeletionRequest())->toBeNull(); +}); diff --git a/tests/Unit/FileDialogModalTest.php b/tests/Unit/FileDialogModalTest.php index fba5c53..7723558 100644 --- a/tests/Unit/FileDialogModalTest.php +++ b/tests/Unit/FileDialogModalTest.php @@ -63,3 +63,24 @@ expect($modal->isDirty())->toBeTrue(); }); + +test('file dialog modal filters files and directories by allowed extensions', function () { + $workspace = sys_get_temp_dir() . '/sendama-file-dialog-' . uniqid(); + mkdir($workspace . '/Assets/Textures', 0777, true); + mkdir($workspace . '/Assets/Maps', 0777, true); + file_put_contents($workspace . '/Assets/Textures/player.texture', 'texture'); + file_put_contents($workspace . '/Assets/Maps/level.tmap', 'tile map'); + file_put_contents($workspace . '/Assets/notes.txt', 'notes'); + + $modal = new FileDialogModal(); + $modal->show($workspace . '/Assets', allowedExtensions: ['texture']); + + expect($modal->content)->toBe(['► Textures']); + + $modal->expandSelection(); + + expect($modal->content)->toBe([ + '▼ Textures', + ' • player.texture', + ]); +}); diff --git a/tests/Unit/HierarchyObjectDTOTest.php b/tests/Unit/HierarchyObjectDTOTest.php new file mode 100644 index 0000000..166fd34 --- /dev/null +++ b/tests/Unit/HierarchyObjectDTOTest.php @@ -0,0 +1,53 @@ + 'Sendama\\Engine\\Core\\GameObject', + 'name' => 'Player', + 'tag' => 'Player', + 'position' => ['x' => 4, 'y' => 12], + 'rotation' => ['x' => 0, 'y' => 0], + 'scale' => ['x' => 1, 'y' => 1], + 'sprite' => [ + 'texture' => [ + 'path' => 'Textures/player', + ], + ], + 'components' => [ + ['class' => 'PlayerController'], + ], + ]); + + expect($dto)->toBeInstanceOf(HierarchyObjectDTO::class); + expect($dto->type)->toBe('Sendama\\Engine\\Core\\GameObject'); + expect($dto->name)->toBe('Player'); + expect($dto->rotation)->toBe(['x' => 0, 'y' => 0]); + expect($dto->scale)->toBe(['x' => 1, 'y' => 1]); + expect($dto->size)->toBeNull(); + expect($dto->components)->toBe([ + ['class' => 'PlayerController'], + ]); +}); + +test('hierarchy object dto builds ui elements from arrays', function () { + $dto = HierarchyObjectDTO::fromArray([ + 'type' => 'Sendama\\Engine\\UI\\Label\\Label', + 'name' => 'Score', + 'position' => ['x' => 4, 'y' => 1], + 'size' => ['x' => 10, 'y' => 1], + 'text' => 'Score: 000', + ]); + + expect($dto)->toBeInstanceOf(HierarchyObjectDTO::class); + expect($dto->type)->toBe('Sendama\\Engine\\UI\\Label\\Label'); + expect($dto->name)->toBe('Score'); + expect($dto->position)->toBe(['x' => 4, 'y' => 1]); + expect($dto->size)->toBe(['x' => 10, 'y' => 1]); + expect($dto->rotation)->toBeNull(); + expect($dto->scale)->toBeNull(); + expect($dto->text)->toBe('Score: 000'); + expect($dto->__serialize())->not->toHaveKey('rotation'); + expect($dto->__serialize())->not->toHaveKey('scale'); +}); diff --git a/tests/Unit/HierarchyPanelTest.php b/tests/Unit/HierarchyPanelTest.php index 51531ef..314dc1d 100644 --- a/tests/Unit/HierarchyPanelTest.php +++ b/tests/Unit/HierarchyPanelTest.php @@ -88,6 +88,7 @@ 'context' => 'hierarchy', 'name' => 'Player', 'type' => 'GameObject', + 'path' => 'scene.0', 'value' => ['type' => 'Sendama\\Engine\\Core\\GameObject', 'name' => 'Player'], ]); expect($panel->consumeInspectionRequest())->toBeNull(); @@ -121,11 +122,14 @@ expect($panel->content[0])->toBe('• level01*'); }); -test('hierarchy panel does not inspect the scene root', function () { +test('hierarchy panel queues the scene root for inspection', function () { $panel = new HierarchyPanel( width: 40, height: 12, sceneName: 'level01', + sceneWidth: 80, + sceneHeight: 25, + environmentTileMapPath: 'Maps/level', hierarchy: [ ['type' => 'Sendama\\Engine\\Core\\GameObject', 'name' => 'Player'], ], @@ -133,5 +137,118 @@ $panel->activateSelection(); - expect($panel->consumeInspectionRequest())->toBeNull(); + expect($panel->consumeInspectionRequest())->toBe([ + 'context' => 'scene', + 'name' => 'level01', + 'type' => 'Scene', + 'path' => 'scene', + 'value' => [ + 'name' => 'level01', + 'width' => 80, + 'height' => 25, + 'environmentTileMapPath' => 'Maps/level', + ], + ]); +}); + +test('hierarchy panel queues default game objects from the add workflow', function () { + $panel = new HierarchyPanel( + width: 40, + height: 12, + sceneName: 'level01', + hierarchy: [ + ['type' => 'Sendama\\Engine\\Core\\GameObject', 'name' => 'GameObject #1'], + ], + ); + + $showAddObjectModal = new ReflectionMethod(HierarchyPanel::class, 'showAddObjectModal'); + $showAddObjectModal->setAccessible(true); + $showAddObjectModal->invoke($panel); + + $handleAddObjectTypeSelection = new ReflectionMethod(HierarchyPanel::class, 'handleAddObjectTypeSelection'); + $handleAddObjectTypeSelection->setAccessible(true); + $handleAddObjectTypeSelection->invoke($panel, 'GameObject'); + + expect($panel->consumeCreationRequest())->toBe([ + 'type' => 'Sendama\\Engine\\Core\\GameObject', + 'name' => 'GameObject #2', + 'tag' => 'None', + 'position' => ['x' => 0, 'y' => 0], + 'rotation' => ['x' => 0, 'y' => 0], + 'scale' => ['x' => 1, 'y' => 1], + 'components' => [], + ]); +}); + +test('hierarchy panel queues default ui elements from the add workflow', function () { + $panel = new HierarchyPanel( + width: 40, + height: 12, + sceneName: 'level01', + hierarchy: [ + ['type' => 'Sendama\\Engine\\UI\\Label\\Label', 'name' => 'Label #1'], + ], + ); + + $handleAddUiElementSelection = new ReflectionMethod(HierarchyPanel::class, 'handleAddUiElementSelection'); + $handleAddUiElementSelection->setAccessible(true); + $handleAddUiElementSelection->invoke($panel, 'Label'); + + expect($panel->consumeCreationRequest())->toBe([ + 'type' => 'Sendama\\Engine\\UI\\Label\\Label', + 'name' => 'Label #2', + 'tag' => 'UI', + 'position' => ['x' => 0, 'y' => 0], + 'size' => ['x' => 1, 'y' => 1], + 'text' => 'Label #2', + ]); +}); + +test('hierarchy panel queues the selected object for deletion when confirmed', function () { + $panel = new HierarchyPanel( + width: 40, + height: 12, + sceneName: 'level01', + hierarchy: [ + ['type' => 'Sendama\\Engine\\Core\\GameObject', 'name' => 'Player'], + ], + ); + + $panel->expandSelection(); + + $showDeleteConfirmModal = new ReflectionMethod(HierarchyPanel::class, 'showDeleteConfirmModal'); + $showDeleteConfirmModal->setAccessible(true); + $showDeleteConfirmModal->invoke($panel); + + $handleDeleteConfirmationSelection = new ReflectionMethod(HierarchyPanel::class, 'handleDeleteConfirmationSelection'); + $handleDeleteConfirmationSelection->setAccessible(true); + $handleDeleteConfirmationSelection->invoke($panel, 'Delete'); + + expect($panel->consumeDeletionRequest())->toBe([ + 'path' => 'scene.0', + 'name' => 'Player', + ]); +}); + +test('hierarchy panel cancels delete confirmation without queuing a deletion', function () { + $panel = new HierarchyPanel( + width: 40, + height: 12, + sceneName: 'level01', + hierarchy: [ + ['type' => 'Sendama\\Engine\\Core\\GameObject', 'name' => 'Player'], + ], + ); + + $panel->expandSelection(); + + $showDeleteConfirmModal = new ReflectionMethod(HierarchyPanel::class, 'showDeleteConfirmModal'); + $showDeleteConfirmModal->setAccessible(true); + $showDeleteConfirmModal->invoke($panel); + + $handleDeleteConfirmationSelection = new ReflectionMethod(HierarchyPanel::class, 'handleDeleteConfirmationSelection'); + $handleDeleteConfirmationSelection->setAccessible(true); + $handleDeleteConfirmationSelection->invoke($panel, 'Cancel'); + + expect($panel->consumeDeletionRequest())->toBeNull(); }); diff --git a/tests/Unit/InputManagerTest.php b/tests/Unit/InputManagerTest.php index e448f41..59d2551 100644 --- a/tests/Unit/InputManagerTest.php +++ b/tests/Unit/InputManagerTest.php @@ -49,3 +49,46 @@ expect($getKey->invoke(null, '%'))->toBe(KeyCode::PLAY_TOGGLE->value); }); + +test('input manager normalizes ctrl+c to the editor quit shortcut', function () { + $getKey = new ReflectionMethod(InputManager::class, 'getKey'); + $getKey->setAccessible(true); + + expect($getKey->invoke(null, "\x03"))->toBe(KeyCode::CTRL_C->value); +}); + +test('input manager normalizes ctrl+s to the save shortcut', function () { + $getKey = new ReflectionMethod(InputManager::class, 'getKey'); + $getKey->setAccessible(true); + + expect($getKey->invoke(null, "\x13"))->toBe(KeyCode::CTRL_S->value); +}); + +test('input manager normalizes ctrl+z to undo', function () { + $getKey = new ReflectionMethod(InputManager::class, 'getKey'); + $getKey->setAccessible(true); + + expect($getKey->invoke(null, "\x1A"))->toBe(KeyCode::CTRL_Z->value); +}); + +test('input manager normalizes ctrl+y to redo', function () { + $getKey = new ReflectionMethod(InputManager::class, 'getKey'); + $getKey->setAccessible(true); + + expect($getKey->invoke(null, "\x19"))->toBe(KeyCode::CTRL_Y->value); +}); + +test('input manager tokenizes multi-character printable input without dropping earlier characters', function () { + $tokenizeInput = new ReflectionMethod(InputManager::class, 'tokenizeInput'); + $tokenizeInput->setAccessible(true); + + expect($tokenizeInput->invoke(null, '02'))->toBe(['0', '2']); + expect($tokenizeInput->invoke(null, 'level02'))->toBe(['l', 'e', 'v', 'e', 'l', '0', '2']); +}); + +test('input manager tokenizes mixed escape sequences and printable characters', function () { + $tokenizeInput = new ReflectionMethod(InputManager::class, 'tokenizeInput'); + $tokenizeInput->setAccessible(true); + + expect($tokenizeInput->invoke(null, "\033[B0"))->toBe(["\033[B", '0']); +}); diff --git a/tests/Unit/InspectorPanelTest.php b/tests/Unit/InspectorPanelTest.php index 940b093..35ebcf1 100644 --- a/tests/Unit/InspectorPanelTest.php +++ b/tests/Unit/InspectorPanelTest.php @@ -100,6 +100,46 @@ expect($renderedLine)->toContain("\033[30;47m"); }); +test('inspector panel resolves texture previews from the configured project directory', function () { + $workspace = sys_get_temp_dir() . '/sendama-inspector-project-root-' . uniqid(); + mkdir($workspace . '/Assets/Textures', 0777, true); + file_put_contents($workspace . '/Assets/Textures/player.texture', "abcd\nefgh\nijkl\n"); + $panel = new InspectorPanel(width: 48, height: 24, workingDirectory: $workspace); + + $originalWorkingDirectory = getcwd(); + chdir(sys_get_temp_dir()); + + try { + $panel->inspectTarget([ + 'context' => 'hierarchy', + 'name' => 'Player', + 'type' => 'GameObject', + 'value' => [ + 'type' => 'Sendama\\Engine\\Core\\GameObject', + 'name' => 'Player', + 'tag' => 'Player', + 'position' => ['x' => 4, 'y' => 12], + 'rotation' => ['x' => 0, 'y' => 0], + 'scale' => ['x' => 1, 'y' => 1], + 'sprite' => [ + 'texture' => [ + 'path' => 'Textures/player', + 'position' => ['x' => 1, 'y' => 1], + 'size' => ['x' => 2, 'y' => 2], + ], + ], + ], + ]); + + expect($panel->content)->toContain(' fg'); + expect($panel->content)->toContain(' jk'); + } finally { + if ($originalWorkingDirectory !== false) { + chdir($originalWorkingDirectory); + } + } +}); + test('inspector panel cycles focus through controls within the panel', function () { $panelWidth = 32; $panel = new InspectorPanel(width: $panelWidth, height: 12); @@ -156,12 +196,89 @@ 'context' => 'asset', 'name' => 'Textures', 'type' => 'Folder', - 'value' => ['name' => 'Textures'], + 'value' => [ + 'name' => 'Textures', + 'path' => '/tmp/project/Assets/Textures', + 'isDirectory' => true, + ], ]); expect($panel->content)->toBe([ 'Type: Folder', 'Name: Textures', + 'Path: /tmp/project/Assets/Textures', + ]); +}); + +test('inspector panel allows file assets to rename through the name control', function () { + $panel = new InspectorPanel(width: 48, height: 16); + + $panel->inspectTarget([ + 'context' => 'asset', + 'name' => 'player.texture', + 'type' => 'File', + 'value' => [ + 'name' => 'player.texture', + 'path' => '/tmp/project/Assets/Textures/player.texture', + 'relativePath' => 'Textures/player.texture', + 'isDirectory' => false, + ], + ]); + + $hasFocus = new ReflectionProperty(\Sendama\Console\Editor\Widgets\Widget::class, 'hasFocus'); + $hasFocus->setAccessible(true); + $hasFocus->setValue($panel, true); + + $keyPress = new ReflectionProperty(\Sendama\Console\Editor\IO\InputManager::class, 'keyPress'); + $previousKeyPress = new ReflectionProperty(\Sendama\Console\Editor\IO\InputManager::class, 'previousKeyPress'); + $keyPress->setAccessible(true); + $previousKeyPress->setAccessible(true); + + $panel->cycleFocusForward(); + + $previousKeyPress->setValue(''); + $keyPress->setValue("\n"); + $panel->update(); + + foreach (str_split('2') as $character) { + $previousKeyPress->setValue(''); + $keyPress->setValue($character); + $panel->update(); + } + + $previousKeyPress->setValue(''); + $keyPress->setValue("\n"); + $panel->update(); + + expect($panel->consumeAssetMutation())->toBe([ + 'path' => '/tmp/project/Assets/Textures/player.texture', + 'relativePath' => 'Textures/player.texture', + 'name' => 'player.texture2', + ]); +}); + +test('inspector panel renders editable scene controls', function () { + $panel = new InspectorPanel(width: 48, height: 16); + + $panel->inspectTarget([ + 'context' => 'scene', + 'name' => 'level01', + 'type' => 'Scene', + 'path' => 'scene', + 'value' => [ + 'name' => 'level01', + 'width' => 80, + 'height' => 25, + 'environmentTileMapPath' => 'Maps/level', + ], + ]); + + expect($panel->content)->toBe([ + 'Type: Scene', + 'Name: level01', + 'Width: 80', + 'Height: 25', + 'Environment Tile Map: Maps/level', ]); }); @@ -225,3 +342,112 @@ } } }); + +test('inspector panel emits hierarchy mutations when edits are committed', function () { + $panel = new InspectorPanel(width: 48, height: 24); + + $panel->inspectTarget([ + 'context' => 'hierarchy', + 'name' => 'Player', + 'type' => 'GameObject', + 'path' => 'scene.0', + 'value' => [ + 'type' => 'Sendama\\Engine\\Core\\GameObject', + 'name' => 'Player', + 'tag' => 'Player', + 'position' => ['x' => 4, 'y' => 12], + 'rotation' => ['x' => 0, 'y' => 0], + 'scale' => ['x' => 1, 'y' => 1], + ], + ]); + + $hasFocus = new ReflectionProperty(\Sendama\Console\Editor\Widgets\Widget::class, 'hasFocus'); + $hasFocus->setAccessible(true); + $hasFocus->setValue($panel, true); + + $keyPress = new ReflectionProperty(\Sendama\Console\Editor\IO\InputManager::class, 'keyPress'); + $previousKeyPress = new ReflectionProperty(\Sendama\Console\Editor\IO\InputManager::class, 'previousKeyPress'); + $keyPress->setAccessible(true); + $previousKeyPress->setAccessible(true); + + $panel->cycleFocusForward(); + + $previousKeyPress->setValue(''); + $keyPress->setValue("\n"); + $panel->update(); + + foreach (str_split(' 2') as $character) { + $previousKeyPress->setValue(''); + $keyPress->setValue($character); + $panel->update(); + } + + $previousKeyPress->setValue(''); + $keyPress->setValue("\n"); + $panel->update(); + + expect($panel->consumeHierarchyMutation())->toBe([ + 'path' => 'scene.0', + 'value' => [ + 'type' => 'Sendama\\Engine\\Core\\GameObject', + 'name' => 'Player 2', + 'tag' => 'Player', + 'position' => ['x' => 4, 'y' => 12], + 'rotation' => ['x' => 0, 'y' => 0], + 'scale' => ['x' => 1, 'y' => 1], + ], + ]); +}); + +test('inspector panel emits scene mutations when scene details are committed', function () { + $panel = new InspectorPanel(width: 48, height: 24); + + $panel->inspectTarget([ + 'context' => 'scene', + 'name' => 'level01', + 'type' => 'Scene', + 'path' => 'scene', + 'value' => [ + 'name' => 'level01', + 'width' => 80, + 'height' => 25, + 'environmentTileMapPath' => 'Maps/level', + ], + ]); + + $hasFocus = new ReflectionProperty(\Sendama\Console\Editor\Widgets\Widget::class, 'hasFocus'); + $hasFocus->setAccessible(true); + $hasFocus->setValue($panel, true); + + $keyPress = new ReflectionProperty(\Sendama\Console\Editor\IO\InputManager::class, 'keyPress'); + $previousKeyPress = new ReflectionProperty(\Sendama\Console\Editor\IO\InputManager::class, 'previousKeyPress'); + $keyPress->setAccessible(true); + $previousKeyPress->setAccessible(true); + + $panel->cycleFocusForward(); + $panel->cycleFocusForward(); + + $previousKeyPress->setValue(''); + $keyPress->setValue("\n"); + $panel->update(); + + foreach (str_split('2') as $character) { + $previousKeyPress->setValue(''); + $keyPress->setValue($character); + $panel->update(); + } + + $previousKeyPress->setValue(''); + $keyPress->setValue("\n"); + $panel->update(); + + expect($panel->consumeHierarchyMutation())->toBe([ + 'path' => 'scene', + 'value' => [ + 'name' => 'level01', + 'width' => 802, + 'height' => 25, + 'environmentTileMapPath' => 'Maps/level', + ], + ]); +}); diff --git a/tests/Unit/MainPanelTest.php b/tests/Unit/MainPanelTest.php index b8e1ca1..95bc8a3 100644 --- a/tests/Unit/MainPanelTest.php +++ b/tests/Unit/MainPanelTest.php @@ -1,9 +1,32 @@ setAccessible(true); + $previousKeyPress->setAccessible(true); + $previousKeyPress->setValue(''); + $currentKeyPress->setValue($keyPress); +} + +function createMainPanelWorkspace(): string +{ + $workspace = sys_get_temp_dir() . '/sendama-main-panel-' . uniqid(); + mkdir($workspace . '/Assets/Textures', 0777, true); + mkdir($workspace . '/Assets/Maps', 0777, true); + file_put_contents($workspace . '/Assets/Textures/player.texture', "abcd\nefgh\nijkl\n"); + file_put_contents($workspace . '/Assets/Textures/enemy.texture', "QRST\nUVWX\nYZ12\n"); + file_put_contents($workspace . '/Assets/Maps/level.tmap', "xxxxx\nx x\nxxxxx\n"); + + return $workspace; +} + test('main panel cycles forward through tabs', function () { $panel = new MainPanel(width: 60, height: 12); @@ -76,3 +99,638 @@ expect($panel->content[1])->toContain('■■■■■■'); expect(mb_strlen($panel->content[1]))->toBe($panel->innerWidth - 2); }); + +test('main panel renders scene objects at their positions on the scene tab', function () { + $workspace = createMainPanelWorkspace(); + $panel = new MainPanel( + width: 40, + height: 12, + sceneObjects: [ + [ + 'type' => 'Sendama\\Engine\\Core\\GameObject', + 'name' => 'Player', + 'position' => ['x' => 2, 'y' => 0], + 'sprite' => [ + 'texture' => [ + 'path' => 'Textures/player', + 'position' => ['x' => 1, 'y' => 1], + 'size' => ['x' => 2, 'y' => 2], + ], + ], + ], + [ + 'type' => 'Sendama\\Engine\\UI\\Label\\Label', + 'name' => 'Score', + 'position' => ['x' => 4, 'y' => 3], + 'text' => 'Score: 000', + ], + ], + workingDirectory: $workspace, + ); + + expect(array_any($panel->content, fn(string $line) => str_contains($line, 'fg')))->toBeTrue(); + expect(array_any($panel->content, fn(string $line) => str_contains($line, 'jk')))->toBeTrue(); + expect(array_any($panel->content, fn(string $line) => str_contains($line, 'Score: 000')))->toBeTrue(); +}); + +test('main panel renders the environment tile map behind scene objects', function () { + $workspace = createMainPanelWorkspace(); + $panel = new MainPanel( + width: 24, + height: 10, + sceneObjects: [ + [ + 'type' => 'Sendama\\Engine\\Core\\GameObject', + 'name' => 'Player', + 'position' => ['x' => 2, 'y' => 1], + 'sprite' => [ + 'texture' => [ + 'path' => 'Textures/player', + 'position' => ['x' => 0, 'y' => 0], + 'size' => ['x' => 1, 'y' => 1], + ], + ], + ], + ], + workingDirectory: $workspace, + environmentTileMapPath: 'Maps/level', + ); + + expect(array_any($panel->content, fn(string $line) => str_contains($line, 'xxxxx')))->toBeTrue(); + expect(array_any($panel->content, fn(string $line) => str_contains($line, 'x a x')))->toBeTrue(); +}); + +test('main panel resolves scene textures from the configured project directory', function () { + $workspace = createMainPanelWorkspace(); + $originalWorkingDirectory = getcwd(); + $panel = new MainPanel( + width: 40, + height: 12, + sceneObjects: [ + [ + 'type' => 'Sendama\\Engine\\Core\\GameObject', + 'name' => 'Player', + 'position' => ['x' => 1, 'y' => 0], + 'sprite' => [ + 'texture' => [ + 'path' => 'Textures/player', + 'position' => ['x' => 0, 'y' => 0], + 'size' => ['x' => 2, 'y' => 2], + ], + ], + ], + ], + workingDirectory: $workspace, + ); + + chdir(sys_get_temp_dir()); + + try { + expect(array_any($panel->content, fn(string $line) => str_contains($line, 'ab')))->toBeTrue(); + expect(array_any($panel->content, fn(string $line) => str_contains($line, 'ef')))->toBeTrue(); + } finally { + if ($originalWorkingDirectory !== false) { + chdir($originalWorkingDirectory); + } + } +}); + +test('main panel select mode loads the selected scene object into the inspector payload', function () { + $workspace = createMainPanelWorkspace(); + $panel = new MainPanel( + width: 40, + height: 12, + sceneObjects: [ + [ + 'type' => 'Sendama\\Engine\\Core\\GameObject', + 'name' => 'Player', + 'position' => ['x' => 2, 'y' => 0], + 'sprite' => [ + 'texture' => [ + 'path' => 'Textures/player', + 'position' => ['x' => 0, 'y' => 0], + 'size' => ['x' => 1, 'y' => 1], + ], + ], + ], + [ + 'type' => 'Sendama\\Engine\\Core\\GameObject', + 'name' => 'Enemy', + 'position' => ['x' => 10, 'y' => 4], + 'sprite' => [ + 'texture' => [ + 'path' => 'Textures/enemy', + 'position' => ['x' => 0, 'y' => 0], + 'size' => ['x' => 1, 'y' => 1], + ], + ], + ], + ], + workingDirectory: $workspace, + ); + $hasFocus = new ReflectionProperty(Widget::class, 'hasFocus'); + $hasFocus->setAccessible(true); + $hasFocus->setValue($panel, true); + + pressMainPanelKey('Q'); + $panel->update(); + + pressMainPanelKey("\033[B"); + $panel->update(); + + pressMainPanelKey("\n"); + $panel->update(); + + expect($panel->consumeInspectionRequest())->toBe([ + 'context' => 'hierarchy', + 'name' => 'Enemy', + 'type' => 'GameObject', + 'path' => 'scene.1', + 'value' => [ + 'type' => 'Sendama\\Engine\\Core\\GameObject', + 'name' => 'Enemy', + 'position' => ['x' => 10, 'y' => 4], + 'sprite' => [ + 'texture' => [ + 'path' => 'Textures/enemy', + 'position' => ['x' => 0, 'y' => 0], + 'size' => ['x' => 1, 'y' => 1], + ], + ], + ], + ]); +}); + +test('main panel select mode emits an inspection payload as scene selection changes', function () { + $workspace = createMainPanelWorkspace(); + $panel = new MainPanel( + width: 40, + height: 12, + sceneObjects: [ + [ + 'type' => 'Sendama\\Engine\\Core\\GameObject', + 'name' => 'Player', + 'position' => ['x' => 2, 'y' => 0], + 'sprite' => [ + 'texture' => [ + 'path' => 'Textures/player', + 'position' => ['x' => 0, 'y' => 0], + 'size' => ['x' => 1, 'y' => 1], + ], + ], + ], + [ + 'type' => 'Sendama\\Engine\\Core\\GameObject', + 'name' => 'Enemy', + 'position' => ['x' => 10, 'y' => 4], + 'sprite' => [ + 'texture' => [ + 'path' => 'Textures/enemy', + 'position' => ['x' => 0, 'y' => 0], + 'size' => ['x' => 1, 'y' => 1], + ], + ], + ], + ], + workingDirectory: $workspace, + ); + $hasFocus = new ReflectionProperty(Widget::class, 'hasFocus'); + $hasFocus->setAccessible(true); + $hasFocus->setValue($panel, true); + + pressMainPanelKey('Q'); + $panel->update(); + + pressMainPanelKey("\033[B"); + $panel->update(); + + expect($panel->consumeInspectionRequest())->toBe([ + 'context' => 'hierarchy', + 'name' => 'Enemy', + 'type' => 'GameObject', + 'path' => 'scene.1', + 'value' => [ + 'type' => 'Sendama\\Engine\\Core\\GameObject', + 'name' => 'Enemy', + 'position' => ['x' => 10, 'y' => 4], + 'sprite' => [ + 'texture' => [ + 'path' => 'Textures/enemy', + 'position' => ['x' => 0, 'y' => 0], + 'size' => ['x' => 1, 'y' => 1], + ], + ], + ], + ]); +}); + +test('main panel move mode updates the selected scene object position', function () { + $workspace = createMainPanelWorkspace(); + $panel = new MainPanel( + width: 40, + height: 12, + sceneObjects: [ + [ + 'type' => 'Sendama\\Engine\\Core\\GameObject', + 'name' => 'Player', + 'position' => ['x' => 2, 'y' => 1], + 'sprite' => [ + 'texture' => [ + 'path' => 'Textures/player', + 'position' => ['x' => 0, 'y' => 0], + 'size' => ['x' => 1, 'y' => 1], + ], + ], + ], + ], + workingDirectory: $workspace, + ); + $hasFocus = new ReflectionProperty(Widget::class, 'hasFocus'); + $hasFocus->setAccessible(true); + $hasFocus->setValue($panel, true); + + pressMainPanelKey('W'); + $panel->update(); + + pressMainPanelKey("\033[C"); + $panel->update(); + + expect($panel->consumeHierarchyMutation())->toBe([ + 'path' => 'scene.0', + 'value' => [ + 'type' => 'Sendama\\Engine\\Core\\GameObject', + 'name' => 'Player', + 'position' => ['x' => 3, 'y' => 1], + 'sprite' => [ + 'texture' => [ + 'path' => 'Textures/player', + 'position' => ['x' => 0, 'y' => 0], + 'size' => ['x' => 1, 'y' => 1], + ], + ], + ], + ]); + expect(array_any($panel->content, fn(string $line) => str_contains($line, 'a')))->toBeTrue(); +}); + +test('main panel move mode emits an updated inspection payload for the selected object', function () { + $workspace = createMainPanelWorkspace(); + $panel = new MainPanel( + width: 40, + height: 12, + sceneObjects: [ + [ + 'type' => 'Sendama\\Engine\\Core\\GameObject', + 'name' => 'Player', + 'position' => ['x' => 2, 'y' => 1], + 'rotation' => ['x' => 0, 'y' => 0], + 'scale' => ['x' => 1, 'y' => 1], + 'sprite' => [ + 'texture' => [ + 'path' => 'Textures/player', + 'position' => ['x' => 0, 'y' => 0], + 'size' => ['x' => 1, 'y' => 1], + ], + ], + ], + ], + workingDirectory: $workspace, + ); + $hasFocus = new ReflectionProperty(Widget::class, 'hasFocus'); + $hasFocus->setAccessible(true); + $hasFocus->setValue($panel, true); + + pressMainPanelKey('W'); + $panel->update(); + + expect($panel->consumeInspectionRequest())->toBe([ + 'context' => 'hierarchy', + 'name' => 'Player', + 'type' => 'GameObject', + 'path' => 'scene.0', + 'value' => [ + 'type' => 'Sendama\\Engine\\Core\\GameObject', + 'name' => 'Player', + 'position' => ['x' => 2, 'y' => 1], + 'rotation' => ['x' => 0, 'y' => 0], + 'scale' => ['x' => 1, 'y' => 1], + 'sprite' => [ + 'texture' => [ + 'path' => 'Textures/player', + 'position' => ['x' => 0, 'y' => 0], + 'size' => ['x' => 1, 'y' => 1], + ], + ], + ], + ]); + + pressMainPanelKey("\033[C"); + $panel->update(); + + expect($panel->consumeInspectionRequest())->toBe([ + 'context' => 'hierarchy', + 'name' => 'Player', + 'type' => 'GameObject', + 'path' => 'scene.0', + 'value' => [ + 'type' => 'Sendama\\Engine\\Core\\GameObject', + 'name' => 'Player', + 'position' => ['x' => 3, 'y' => 1], + 'rotation' => ['x' => 0, 'y' => 0], + 'scale' => ['x' => 1, 'y' => 1], + 'sprite' => [ + 'texture' => [ + 'path' => 'Textures/player', + 'position' => ['x' => 0, 'y' => 0], + 'size' => ['x' => 1, 'y' => 1], + ], + ], + ], + ]); +}); + +test('main panel pan mode scrolls the scene viewport', function () { + $workspace = createMainPanelWorkspace(); + $panel = new MainPanel( + width: 16, + height: 8, + sceneObjects: [ + [ + 'type' => 'Sendama\\Engine\\Core\\GameObject', + 'name' => 'Enemy', + 'position' => ['x' => 16, 'y' => 1], + 'sprite' => [ + 'texture' => [ + 'path' => 'Textures/enemy', + 'position' => ['x' => 0, 'y' => 0], + 'size' => ['x' => 1, 'y' => 1], + ], + ], + ], + ], + workingDirectory: $workspace, + sceneWidth: 30, + sceneHeight: 10, + ); + $hasFocus = new ReflectionProperty(Widget::class, 'hasFocus'); + $hasFocus->setAccessible(true); + $hasFocus->setValue($panel, true); + + expect(array_any($panel->content, fn(string $line) => str_contains($line, 'Q')))->toBeFalse(); + + pressMainPanelKey('E'); + $panel->update(); + + for ($index = 0; $index < 8; $index++) { + pressMainPanelKey("\033[C"); + $panel->update(); + } + + expect(array_any($panel->content, fn(string $line) => str_contains($line, 'Q')))->toBeTrue(); +}); + +test('main panel help line shows controls on the left and the active mode on the right', function () { + $panel = new MainPanel(width: 72, height: 10); + $buildBorderLine = new ReflectionMethod(MainPanel::class, 'buildBorderLine'); + $buildBorderLine->setAccessible(true); + + $selectHelpLine = $buildBorderLine->invoke($panel, '', false); + + expect($selectHelpLine)->toContain('Arrows cycle'); + expect($selectHelpLine)->toContain('Mode: Scene Select'); + + pressMainPanelKey('E'); + $hasFocus = new ReflectionProperty(Widget::class, 'hasFocus'); + $hasFocus->setAccessible(true); + $hasFocus->setValue($panel, true); + $panel->update(); + + $panHelpLine = $buildBorderLine->invoke($panel, '', false); + + expect($panHelpLine)->toContain('Arrows pan'); + expect($panHelpLine)->toContain('Mode: Scene Pan'); +}); + +test('main panel sprite tab edits the selected asset grid and persists it', function () { + $workspace = createMainPanelWorkspace(); + $panel = new MainPanel(width: 30, height: 12, workingDirectory: $workspace); + $hasFocus = new ReflectionProperty(Widget::class, 'hasFocus'); + $hasFocus->setAccessible(true); + $hasFocus->setValue($panel, true); + + $panel->selectTab('Sprite'); + $panel->loadSpriteAsset([ + 'name' => 'player.texture', + 'path' => $workspace . '/Assets/Textures/player.texture', + 'relativePath' => 'Textures/player.texture', + 'isDirectory' => false, + ]); + + pressMainPanelKey('Z'); + $panel->update(); + + expect(file_get_contents($workspace . '/Assets/Textures/player.texture'))->toStartWith("Zbcd\n"); + expect(array_any($panel->content, fn(string $line) => str_contains($line, 'Zbcd')))->toBeTrue(); +}); + +test('main panel sprite tab expands loaded textures to a 16x16 editing grid', function () { + $workspace = createMainPanelWorkspace(); + $panel = new MainPanel(width: 30, height: 12, workingDirectory: $workspace); + $spriteGridWidth = new ReflectionProperty(MainPanel::class, 'spriteGridWidth'); + $spriteGridHeight = new ReflectionProperty(MainPanel::class, 'spriteGridHeight'); + $spriteGridWidth->setAccessible(true); + $spriteGridHeight->setAccessible(true); + + $panel->selectTab('Sprite'); + $panel->loadSpriteAsset([ + 'name' => 'player.texture', + 'path' => $workspace . '/Assets/Textures/player.texture', + 'relativePath' => 'Textures/player.texture', + 'isDirectory' => false, + ]); + + expect($spriteGridWidth->getValue($panel))->toBe(16); + expect($spriteGridHeight->getValue($panel))->toBe(16); +}); + +test('main panel sprite tab can create a new texture asset', function () { + $workspace = createMainPanelWorkspace(); + $panel = new MainPanel(width: 30, height: 12, workingDirectory: $workspace); + $hasFocus = new ReflectionProperty(Widget::class, 'hasFocus'); + $hasFocus->setAccessible(true); + $hasFocus->setValue($panel, true); + + $panel->selectTab('Sprite'); + + pressMainPanelKey('A'); + $panel->update(); + + pressMainPanelKey("\n"); + $panel->update(); + + $assetSyncRequest = $panel->consumeAssetSyncRequest(); + + expect($assetSyncRequest)->toBeArray(); + expect($assetSyncRequest['path'] ?? null)->toBeString(); + expect($assetSyncRequest['path'])->toEndWith('.texture'); + expect(file_exists($assetSyncRequest['path']))->toBeTrue(); + expect($assetSyncRequest['inspectionTarget']['value']['relativePath'] ?? null)->toBe('Textures/new-texture-1.texture'); +}); + +test('main panel sprite tab creates tile maps at the current terminal-size bounds', function () { + $workspace = createMainPanelWorkspace(); + $panel = new MainPanel(width: 30, height: 12, workingDirectory: $workspace); + $hasFocus = new ReflectionProperty(Widget::class, 'hasFocus'); + $hasFocus->setAccessible(true); + $hasFocus->setValue($panel, true); + + $panel->selectTab('Sprite'); + + pressMainPanelKey('A'); + $panel->update(); + + pressMainPanelKey("\033[B"); + $panel->update(); + + pressMainPanelKey("\n"); + $panel->update(); + + $assetSyncRequest = $panel->consumeAssetSyncRequest(); + $terminalSize = get_max_terminal_size(); + $expectedWidth = max(1, (int) ($terminalSize['width'] ?? DEFAULT_TERMINAL_WIDTH)); + $expectedHeight = max(1, (int) ($terminalSize['height'] ?? DEFAULT_TERMINAL_HEIGHT)); + $lines = explode("\n", rtrim((string) file_get_contents($assetSyncRequest['path']), "\n")); + + expect($assetSyncRequest['path'])->toEndWith('.tmap'); + expect(count($lines))->toBe($expectedHeight); + expect(mb_strlen($lines[0] ?? ''))->toBe($expectedWidth); +}); + +test('main panel sprite tab can delete the active asset after confirmation', function () { + $workspace = createMainPanelWorkspace(); + $panel = new MainPanel(width: 30, height: 12, workingDirectory: $workspace); + $hasFocus = new ReflectionProperty(Widget::class, 'hasFocus'); + $hasFocus->setAccessible(true); + $hasFocus->setValue($panel, true); + + $panel->selectTab('Sprite'); + $panel->loadSpriteAsset([ + 'name' => 'player.texture', + 'path' => $workspace . '/Assets/Textures/player.texture', + 'relativePath' => 'Textures/player.texture', + 'isDirectory' => false, + ]); + + pressMainPanelKey("\033[3~"); + $panel->update(); + + pressMainPanelKey("\033[A"); + $panel->update(); + + pressMainPanelKey("\n"); + $panel->update(); + + $assetSyncRequest = $panel->consumeAssetSyncRequest(); + + expect(file_exists($workspace . '/Assets/Textures/player.texture'))->toBeFalse(); + expect($assetSyncRequest)->toBe([ + 'path' => $workspace . '/Assets/Textures/player.texture', + 'clearInspection' => true, + ]); + expect(array_any($panel->content, fn(string $line) => str_contains($line, 'Select a .texture or .tmap asset')))->toBeTrue(); +}); + +test('main panel sprite tab supports undo redo and reset', function () { + $workspace = createMainPanelWorkspace(); + $panel = new MainPanel(width: 30, height: 12, workingDirectory: $workspace); + $hasFocus = new ReflectionProperty(Widget::class, 'hasFocus'); + $hasFocus->setAccessible(true); + $hasFocus->setValue($panel, true); + + $panel->selectTab('Sprite'); + $panel->loadSpriteAsset([ + 'name' => 'player.texture', + 'path' => $workspace . '/Assets/Textures/player.texture', + 'relativePath' => 'Textures/player.texture', + 'isDirectory' => false, + ]); + + pressMainPanelKey('Z'); + $panel->update(); + + expect(file_get_contents($workspace . '/Assets/Textures/player.texture'))->toStartWith("Zbcd\n"); + + pressMainPanelKey("\x1A"); + $panel->update(); + + expect(file_get_contents($workspace . '/Assets/Textures/player.texture'))->toStartWith("abcd\n"); + + pressMainPanelKey("\x19"); + $panel->update(); + + expect(file_get_contents($workspace . '/Assets/Textures/player.texture'))->toStartWith("Zbcd\n"); + + pressMainPanelKey('R'); + $panel->update(); + + expect(file_get_contents($workspace . '/Assets/Textures/player.texture'))->toStartWith("abcd\n"); +}); + +test('main panel sprite tab can insert a special character from the character picker modal', function () { + $workspace = createMainPanelWorkspace(); + $panel = new MainPanel(width: 30, height: 12, workingDirectory: $workspace); + $hasFocus = new ReflectionProperty(Widget::class, 'hasFocus'); + $hasFocus->setAccessible(true); + $hasFocus->setValue($panel, true); + + $panel->selectTab('Sprite'); + $panel->loadSpriteAsset([ + 'name' => 'player.texture', + 'path' => $workspace . '/Assets/Textures/player.texture', + 'relativePath' => 'Textures/player.texture', + 'isDirectory' => false, + ]); + + pressMainPanelKey('@'); + $panel->update(); + + expect($panel->hasActiveModal())->toBeTrue(); + + pressMainPanelKey("\n"); + $panel->update(); + + expect($panel->hasActiveModal())->toBeFalse(); + expect(file_get_contents($workspace . '/Assets/Textures/player.texture'))->toStartWith("█bcd\n"); +}); + +test('main panel sprite tab shows the cursor column x row position in the help line', function () { + $workspace = createMainPanelWorkspace(); + $panel = new MainPanel(width: 84, height: 12, workingDirectory: $workspace); + $hasFocus = new ReflectionProperty(Widget::class, 'hasFocus'); + $hasFocus->setAccessible(true); + $hasFocus->setValue($panel, true); + $buildBorderLine = new ReflectionMethod(MainPanel::class, 'buildBorderLine'); + $buildBorderLine->setAccessible(true); + + $panel->selectTab('Sprite'); + $panel->loadSpriteAsset([ + 'name' => 'player.texture', + 'path' => $workspace . '/Assets/Textures/player.texture', + 'relativePath' => 'Textures/player.texture', + 'isDirectory' => false, + ]); + + $initialHelpLine = $buildBorderLine->invoke($panel, '', false); + + expect($initialHelpLine)->toContain('Col x Row: 1 x 1'); + + pressMainPanelKey("\033[C"); + $panel->update(); + + pressMainPanelKey("\033[B"); + $panel->update(); + + $updatedHelpLine = $buildBorderLine->invoke($panel, '', false); + + expect($updatedHelpLine)->toContain('Col x Row: 2 x 2'); +}); diff --git a/tests/Unit/SceneLoaderTest.php b/tests/Unit/SceneLoaderTest.php index 63516aa..0c5e50a 100644 --- a/tests/Unit/SceneLoaderTest.php +++ b/tests/Unit/SceneLoaderTest.php @@ -34,6 +34,123 @@ expect($scene->hierarchy[1]['name'])->toBe('Player'); }); +test('scene loader evaluates scene metadata in an isolated project context', function () { + $workspace = sys_get_temp_dir() . '/sendama-scene-loader-' . uniqid(); + mkdir($workspace . '/assets/Scenes', 0777, true); + mkdir($workspace . '/vendor', 0777, true); + + file_put_contents( + $workspace . '/vendor/autoload.php', + <<<'PHP' + DEFAULT_SCREEN_WIDTH, + 'height' => DEFAULT_SCREEN_HEIGHT, + 'hierarchy' => [ + [ + 'type' => GameObject::class, + 'name' => 'Player', + 'tag' => Tag::Player->value, + 'position' => ['x' => 4, 'y' => DEFAULT_SCREEN_HEIGHT / 2], + 'rotation' => ['x' => 0, 'y' => 0], + 'scale' => ['x' => 1, 'y' => 1], + 'sprite' => [ + 'texture' => [ + 'path' => 'Textures/player', + 'position' => ['x' => 0, 'y' => 0], + 'size' => ['x' => 1, 'y' => 5], + ], + ], + 'components' => [ + ['class' => 'Sendama\\Game\\PlayerController'], + ], + ], + [ + 'type' => Label::class, + 'name' => 'Score', + 'tag' => Tag::UI->value, + 'position' => ['x' => 4, 'y' => LEVEL_HEIGHT - 2], + 'size' => ['x' => 10, 'y' => 1], + 'text' => 'Score: 000', + ], + ], +]; +PHP + ); + + $loader = new SceneLoader($workspace); + $scene = $loader->load(new EditorSceneSettings(active: 0, loaded: ['level01'])); + + expect($scene)->not->toBeNull(); + expect($scene->width)->toBe(120); + expect($scene->height)->toBe(40); + expect($scene->hierarchy[0])->toBe([ + 'type' => 'Sendama\\Engine\\Core\\GameObject', + 'name' => 'Player', + 'tag' => 'Player', + 'position' => ['x' => 4, 'y' => 20], + 'rotation' => ['x' => 0, 'y' => 0], + 'scale' => ['x' => 1, 'y' => 1], + 'sprite' => [ + 'texture' => [ + 'path' => 'Textures/player', + 'position' => ['x' => 0, 'y' => 0], + 'size' => ['x' => 1, 'y' => 5], + ], + ], + 'components' => [ + ['class' => 'Sendama\\Game\\PlayerController'], + ], + ]); + expect($scene->hierarchy[1])->toBe([ + 'type' => 'Sendama\\Engine\\UI\\Label\\Label', + 'name' => 'Score', + 'tag' => 'UI', + 'position' => ['x' => 4, 'y' => 23], + 'size' => ['x' => 10, 'y' => 1], + 'text' => 'Score: 000', + ]); +}); + test('scene loader falls back to the first available scene when none is configured', function () { $workspace = sys_get_temp_dir() . '/sendama-scene-loader-' . uniqid(); mkdir($workspace . '/assets/Scenes', 0777, true); diff --git a/tests/Unit/SceneWriterTest.php b/tests/Unit/SceneWriterTest.php new file mode 100644 index 0000000..8f8d82f --- /dev/null +++ b/tests/Unit/SceneWriterTest.php @@ -0,0 +1,293 @@ + 'Sendama\\Engine\\Core\\GameObject', + 'name' => 'Player 2', + 'tag' => 'Player', + 'position' => ['x' => 4, 'y' => 12], + ], + ], + rawData: [ + 'customFlag' => true, + ], + ); + + $writer = new SceneWriter(); + $serializedScene = $writer->serialize($scene); + + expect($serializedScene)->toContain("toContain("'width' => 120"); + expect($serializedScene)->toContain("'environmentTileMapPath' => 'Maps/level'"); + expect($serializedScene)->toContain("'name' => 'Player 2'"); + expect($serializedScene)->toContain("'customFlag' => true"); + expect($serializedScene)->not->toContain("'isDirty'"); +}); + +test('scene writer saves scenes to the source path', function () { + $workspace = sys_get_temp_dir() . '/sendama-scene-writer-' . uniqid(); + mkdir($workspace . '/Assets/Scenes', 0777, true); + $scenePath = $workspace . '/Assets/Scenes/level01.scene.php'; + + $scene = new SceneDTO( + name: 'level01', + hierarchy: [ + ['name' => 'Player', 'type' => 'Sendama\\Engine\\Core\\GameObject'], + ], + sourcePath: $scenePath, + ); + + $writer = new SceneWriter(); + + expect($writer->save($scene))->toBeTrue(); + expect(file_get_contents($scenePath))->toContain("'name' => 'Player'"); +}); + +test('scene writer preserves unchanged source expressions when saving edited scenes', function () { + $workspace = sys_get_temp_dir() . '/sendama-scene-writer-preserve-' . uniqid(); + mkdir($workspace . '/Assets/Scenes', 0777, true); + $scenePath = $workspace . '/Assets/Scenes/level01.scene.php'; + + $source = <<<'PHP' + DEFAULT_SCREEN_WIDTH, + 'height' => DEFAULT_SCREEN_HEIGHT, + 'environmentTileMapPath' => 'Maps/level', + 'hierarchy' => [ + [ + 'type' => GameObject::class, + 'name' => 'Player', + 'tag' => 'Player', + 'position' => ['x' => 4, 'y' => DEFAULT_SCREEN_HEIGHT / 2], + ], + ], +]; +PHP; + + file_put_contents($scenePath, $source); + + $scene = new SceneDTO( + name: 'level01', + width: 120, + height: 40, + environmentTileMapPath: 'Maps/level', + hierarchy: [ + [ + 'type' => 'Sendama\\Engine\\Core\\GameObject', + 'name' => 'Player 2', + 'tag' => 'Player', + 'position' => ['x' => 4, 'y' => 20], + ], + ], + sourcePath: $scenePath, + rawData: [ + 'width' => 120, + 'height' => 40, + 'environmentTileMapPath' => 'Maps/level', + 'hierarchy' => [ + [ + 'type' => 'Sendama\\Engine\\Core\\GameObject', + 'name' => 'Player 2', + 'tag' => 'Player', + 'position' => ['x' => 4, 'y' => 20], + ], + ], + ], + sourceData: [ + 'width' => 120, + 'height' => 40, + 'environmentTileMapPath' => 'Maps/level', + 'hierarchy' => [ + [ + 'type' => 'Sendama\\Engine\\Core\\GameObject', + 'name' => 'Player', + 'tag' => 'Player', + 'position' => ['x' => 4, 'y' => 20], + ], + ], + ], + ); + + $writer = new SceneWriter(); + $serializedScene = $writer->serialize($scene); + + expect($serializedScene)->toContain("GameObject::class"); + expect($serializedScene)->toContain("'y' => DEFAULT_SCREEN_HEIGHT / 2"); + expect($serializedScene)->toContain("'name' => 'Player 2'"); +}); + +test('scene writer preserves unknown source fields when the loaded snapshot is partial', function () { + $workspace = sys_get_temp_dir() . '/sendama-scene-writer-partial-' . uniqid(); + mkdir($workspace . '/Assets/Scenes', 0777, true); + $scenePath = $workspace . '/Assets/Scenes/level01.scene.php'; + + file_put_contents( + $scenePath, + <<<'PHP' + [ + [ + 'type' => GameObject::class, + 'name' => 'Player', + 'tag' => 'Player', + 'position' => ['x' => 4, 'y' => DEFAULT_SCREEN_HEIGHT / 2], + ], + ], +]; +PHP + ); + + $scene = new SceneDTO( + name: 'level01', + hierarchy: [ + [ + 'type' => 'Sendama\\Engine\\Core\\GameObject', + 'name' => 'Player 2', + ], + ], + sourcePath: $scenePath, + rawData: [ + 'hierarchy' => [ + [ + 'type' => 'Sendama\\Engine\\Core\\GameObject', + 'name' => 'Player 2', + ], + ], + ], + sourceData: [ + 'hierarchy' => [ + [ + 'type' => 'Sendama\\Engine\\Core\\GameObject', + 'name' => 'Player', + ], + ], + ], + ); + + $writer = new SceneWriter(); + $serializedScene = $writer->serialize($scene); + + expect($serializedScene)->toContain("'name' => 'Player 2'"); + expect($serializedScene)->toContain("'tag' => 'Player'"); + expect($serializedScene)->toContain("'y' => DEFAULT_SCREEN_HEIGHT / 2"); +}); + +test('scene writer preserves existing list entries when appending to a partial hierarchy', function () { + $workspace = sys_get_temp_dir() . '/sendama-scene-writer-append-' . uniqid(); + mkdir($workspace . '/Assets/Scenes', 0777, true); + $scenePath = $workspace . '/Assets/Scenes/level01.scene.php'; + + file_put_contents( + $scenePath, + <<<'PHP' + [ + [ + 'type' => GameObject::class, + 'name' => 'Player', + 'tag' => 'Player', + 'position' => ['x' => 4, 'y' => DEFAULT_SCREEN_HEIGHT / 2], + 'rotation' => ['x' => 0, 'y' => 0], + 'scale' => ['x' => 1, 'y' => 1], + ], + [ + 'type' => Label::class, + 'name' => 'Score', + 'position' => ['x' => 4, 'y' => 1], + 'size' => ['x' => 10, 'y' => 1], + 'text' => 'Score: 000', + ], + ], +]; +PHP + ); + + $scene = new SceneDTO( + name: 'level01', + hierarchy: [ + [ + 'type' => 'GameObject::class', + 'name' => 'Player', + ], + [ + 'type' => 'Label::class', + 'name' => 'Score', + ], + [ + 'type' => 'Sendama\\Engine\\Core\\GameObject', + 'name' => 'Power Up', + 'tag' => 'None', + 'position' => ['x' => 0, 'y' => 0], + 'rotation' => ['x' => 0, 'y' => 0], + 'scale' => ['x' => 1, 'y' => 1], + 'components' => [], + ], + ], + sourcePath: $scenePath, + rawData: [ + 'hierarchy' => [ + [ + 'type' => 'GameObject::class', + 'name' => 'Player', + ], + [ + 'type' => 'Label::class', + 'name' => 'Score', + ], + [ + 'type' => 'Sendama\\Engine\\Core\\GameObject', + 'name' => 'Power Up', + 'tag' => 'None', + 'position' => ['x' => 0, 'y' => 0], + 'rotation' => ['x' => 0, 'y' => 0], + 'scale' => ['x' => 1, 'y' => 1], + 'components' => [], + ], + ], + ], + sourceData: [ + 'hierarchy' => [ + [ + 'type' => 'GameObject::class', + 'name' => 'Player', + ], + [ + 'type' => 'Label::class', + 'name' => 'Score', + ], + ], + ], + ); + + $writer = new SceneWriter(); + $serializedScene = $writer->serialize($scene); + + expect($serializedScene)->toContain("'tag' => 'Player'"); + expect($serializedScene)->toContain("'rotation' => ['x' => 0, 'y' => 0]"); + expect($serializedScene)->toContain("'size' => ['x' => 10, 'y' => 1]"); + expect($serializedScene)->toContain("'type' => \\Sendama\\Engine\\Core\\GameObject::class"); + expect($serializedScene)->not->toContain("'type' => 'GameObject::class'"); +});