diff --git a/composer.lock b/composer.lock index 57bae00..65dfbb6 100644 --- a/composer.lock +++ b/composer.lock @@ -113,16 +113,16 @@ }, { "name": "atatusoft-ltd/termutil", - "version": "1.1.12", + "version": "1.1.13", "source": { "type": "git", "url": "https://github.com/atatusoft-ltd/termutil.git", - "reference": "d38fc06d218526453dbe6cac261ab96ac1012421" + "reference": "9790fc7cc1ef333173c26c64f83673fe416a521e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/atatusoft-ltd/termutil/zipball/d38fc06d218526453dbe6cac261ab96ac1012421", - "reference": "d38fc06d218526453dbe6cac261ab96ac1012421", + "url": "https://api.github.com/repos/atatusoft-ltd/termutil/zipball/9790fc7cc1ef333173c26c64f83673fe416a521e", + "reference": "9790fc7cc1ef333173c26c64f83673fe416a521e", "shasum": "" }, "require": { @@ -156,9 +156,9 @@ "description": "A collection of utilities to help control ANSI powered terminals.", "support": { "issues": "https://github.com/atatusoft-ltd/termutil/issues", - "source": "https://github.com/atatusoft-ltd/termutil/tree/1.1.12" + "source": "https://github.com/atatusoft-ltd/termutil/tree/1.1.13" }, - "time": "2026-02-21T21:44:50+00:00" + "time": "2026-03-14T05:50:15+00:00" }, { "name": "graham-campbell/result-type", @@ -352,16 +352,16 @@ }, { "name": "symfony/console", - "version": "v7.4.4", + "version": "v7.4.7", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "41e38717ac1dd7a46b6bda7d6a82af2d98a78894" + "reference": "e1e6770440fb9c9b0cf725f81d1361ad1835329d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/41e38717ac1dd7a46b6bda7d6a82af2d98a78894", - "reference": "41e38717ac1dd7a46b6bda7d6a82af2d98a78894", + "url": "https://api.github.com/repos/symfony/console/zipball/e1e6770440fb9c9b0cf725f81d1361ad1835329d", + "reference": "e1e6770440fb9c9b0cf725f81d1361ad1835329d", "shasum": "" }, "require": { @@ -426,7 +426,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v7.4.4" + "source": "https://github.com/symfony/console/tree/v7.4.7" }, "funding": [ { @@ -446,7 +446,7 @@ "type": "tidelift" } ], - "time": "2026-01-13T11:36:38+00:00" + "time": "2026-03-06T14:06:20+00:00" }, { "name": "symfony/deprecation-contracts", @@ -1023,16 +1023,16 @@ }, { "name": "symfony/string", - "version": "v8.0.4", + "version": "v8.0.6", "source": { "type": "git", "url": "https://github.com/symfony/string.git", - "reference": "758b372d6882506821ed666032e43020c4f57194" + "reference": "6c9e1108041b5dce21a9a4984b531c4923aa9ec4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/string/zipball/758b372d6882506821ed666032e43020c4f57194", - "reference": "758b372d6882506821ed666032e43020c4f57194", + "url": "https://api.github.com/repos/symfony/string/zipball/6c9e1108041b5dce21a9a4984b531c4923aa9ec4", + "reference": "6c9e1108041b5dce21a9a4984b531c4923aa9ec4", "shasum": "" }, "require": { @@ -1089,7 +1089,7 @@ "utf8" ], "support": { - "source": "https://github.com/symfony/string/tree/v8.0.4" + "source": "https://github.com/symfony/string/tree/v8.0.6" }, "funding": [ { @@ -1109,7 +1109,7 @@ "type": "tidelift" } ], - "time": "2026-01-12T12:37:40+00:00" + "time": "2026-02-09T10:14:57+00:00" }, { "name": "vlucas/phpdotenv", diff --git a/docs/Editor.md b/docs/Editor.md index ae9e5db..3b9ee17 100644 --- a/docs/Editor.md +++ b/docs/Editor.md @@ -27,7 +27,7 @@ The editor expects a valid Sendama project workspace. In particular: - the active scene is loaded from the configured scene metadata When the editor opens a project, it also runs a startup sanity check. -If it finds missing structure such as `config/input.php`, `configuration.json`, log files, or required asset directories, it opens a normalize prompt: +If it finds missing structure such as `config/input.php`, `preferences.json`, log files, or required asset directories, it opens a normalize prompt: - `Normalize`: create the missing directories and bootstrap files - `Cancel` or `Escape`: continue without changing the project @@ -63,6 +63,13 @@ These shortcuts work regardless of the currently focused panel unless a modal is - in `Assets`, it opens the create-asset workflow - in `Inspector`, it opens the add-component menu when a hierarchy object is loaded +`Shift+E` is also panel-local: + +- in `Hierarchy`, it exports the selected object as a prefab into `Assets/Prefabs`, expands that folder, selects the new prefab, and opens it in the `Inspector` +- in `Main > Scene`, it enters Pan Mode + +`Shift+D` is panel-local in `Hierarchy` and duplicates the selected object beside the original. + ## Panel List Modal Press `Shift+1` to open a modal listing all panels. @@ -103,6 +110,7 @@ Current scene rendering behavior: - scene coordinates are rendered into a scrollable viewport - UI text objects render their `text` - selected objects without a visible representation render as a muted `x` +- `Ctrl+Click` adds another visible object to the current Scene selection set - 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. @@ -126,6 +134,10 @@ Controls: - `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 +- `Shift+D`: duplicate the selected Scene object set +- clicking a visible scene object selects it +- `Ctrl+Click` adds the clicked object to the current Scene selection set +- double-clicking a visible scene object activates it like `Enter` #### Move Mode @@ -190,6 +202,9 @@ Controls: - `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 +- left-clicking a cell paints with the current brush +- left-click-dragging paints continuously across the grid +- right-clicking or right-click-dragging erases without changing the current brush Delete workflow: @@ -199,6 +214,7 @@ Delete workflow: Character selector workflow: - `Shift+2` opens a modal of curated special characters useful for sprites and maps +- list-based modals and file pickers support mouse selection - `Up` / `Down`: move selection - `Enter`: insert the selected character at the current cursor position - `Escape`: close the modal without inserting anything @@ -221,6 +237,10 @@ Controls: - `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 +- `Shift+D`: duplicate the selected object set +- `Shift+E`: create a prefab from the selected object and open it in the Inspector +- `Shift+W`: enter hierarchy move mode for the selected object +- `Shift+Q`: return to hierarchy select mode - `Delete`: open the delete confirmation dialog Selected rows are highlighted, and when the hierarchy has focus the selected row blinks. @@ -229,10 +249,17 @@ Selected rows are highlighted, and when the hierarchy has focus the selected row Press `Shift+A` to add a new scene object while the editor is in edit mode. +Press `Shift+E` on a selected hierarchy object to export that object to a `.prefab.php` file under `Assets/Prefabs`. The editor expands the `Prefabs` folder in `Assets`, selects the new prefab, and moves focus to the `Inspector`. + +Press `Shift+D` on a selected hierarchy object to duplicate it in place. The duplicate is inserted immediately after the original, its name is adjusted to avoid a clash, and it becomes the newly selected object. Trailing numbers are incremented, so `Enemy 01` becomes `Enemy 02`; names without trailing numbers become `Enemy 1`. + +`Ctrl+Click` in `Hierarchy` adds the clicked node to the current selection set. When multiple hierarchy objects are selected, `Shift+D` duplicates the full selected set. + Flow: 1. Choose `GameObject` or `UIElement` -2. If `UIElement` is selected, choose a concrete type +2. If `GameObject` is selected while a `GameObject` row is selected, choose whether to add it at the scene root or as a child of that selected object +3. If `UIElement` is selected, choose a concrete type Currently supported UI element types: @@ -248,6 +275,17 @@ Examples: - `GameObject #1` - `Label #2` +### Hierarchy Children And Move Mode + +When a hierarchy object has children, the row shows an expand/collapse icon and can be opened into a descendant tree. Each expanded object tracks its own expand state, so nested branches stay open until you collapse them. + +Press `Shift+W` on a selected hierarchy object to enter hierarchy move mode. In move mode: + +- `Up` / `Down` reinsert the selected object before or after other visible hierarchy objects +- objects can move between branches, so a root object can be moved into another object's child list +- `Delete` still opens the normal delete confirmation flow +- `Shift+Q` returns the hierarchy to normal select mode + ### Delete Workflow Press `Delete` on a selected hierarchy object to open a confirmation modal: @@ -362,6 +400,7 @@ Controls: - `Up` / `Down`: move between controls - `Enter`: activate the selected control +- double-clicking a control activates it too - `Shift+A`: open the add-component menu when a hierarchy object is being inspected - `Shift+W`: toggle component move mode when a component header is focused - `Delete`: open the remove-component confirmation modal when a component header is focused @@ -410,6 +449,18 @@ Controls: - `Enter`: commit the value - `Escape`: cancel the edit +##### Prefab Reference + +For exposed component fields typed as `GameObject`, `Enter` opens a prefab picker instead of entering text edit. + +Controls: + +- `Up` / `Down`: choose a prefab +- `Enter`: assign the selected prefab +- `Escape`: cancel + +The stored value is the prefab asset path, for example `Prefabs/enemy.prefab.php`. When the scene metadata is loaded again, the Inspector resolves that path back to the referenced prefab. + ### Current Hierarchy Inspection Layout For hierarchy objects, the Inspector currently renders: @@ -425,6 +476,12 @@ For hierarchy objects, the Inspector currently renders: Component headers are visually marked as collapsible sections. +Exposed `GameObject` component fields are treated as prefab references: + +- pressing `Enter` on the field opens the prefab picker +- choosing a prefab stores its relative prefab path in component `data` +- the control displays the referenced prefab name when that metadata is loaded again + ### Add Component Workflow When the Inspector is focused on a hierarchy object other than the scene root, press `Shift+A` to open `Add Component`. @@ -532,6 +589,7 @@ Current behavior: - when the console has focus and the editor is not in play mode, it supports scrolling - if no refresh interval is configured, the editor uses a default of `5` seconds - each tab can be filtered by log level with a modal picker +- the `Debug` tab defaults to the `DEBUG` filter on startup Filter options: @@ -554,6 +612,19 @@ Clear behavior: - after rotation, the active log file is cleared - on cancel, nothing changes and the Console panel returns to its normal state +## Notifications + +Current behavior: + +- the editor now has a top-right snackbar for transient system notifications +- it slides in from the right, stays visible for the configured duration, then slides back out to the right +- status colors currently map as follows: + - `success`: green + - `error`: red + - `info`: blue + - `warn`: yellow +- scene save success and failure currently use the snackbar + The scroll stops: - at the beginning of the file @@ -573,6 +644,9 @@ Configuration: "editor": { "console": { "refreshInterval": 5 + }, + "notifications": { + "duration": 4 } } } diff --git a/docs/guides/README.md b/docs/guides/README.md index e50f7be..da47c89 100644 --- a/docs/guides/README.md +++ b/docs/guides/README.md @@ -20,20 +20,21 @@ Read these guides in order if you are learning the editor for the first time: - Browse the `Assets` tree. - Inspect scene roots, scene objects, and file assets. - Add top-level `GameObject`, `Text`, and `Label` entries to the active scene. +- Export hierarchy objects as reusable prefabs. - Select visible objects directly in the Scene tab and move them with the keyboard. - Edit scene, transform, renderer, text, and serialized component fields in the Inspector. -- Add components to hierarchy objects from the Inspector. -- Create scripts, scenes, textures, tile maps, and events from the Assets panel. +- Add, remove, and reorder components from the Inspector. +- Create scripts, scenes, prefabs, textures, tile maps, and events from the Assets panel. +- Inspect and edit prefab files from the editor. - Create, edit, rename, and delete `.texture` and `.tmap` assets. - Save the active scene back to its `.scene.php` source file. -- Watch project logs from inside the editor. +- Watch, filter, and clear project logs from inside the editor. ## What Still Happens Outside The Editor The editor is best used as part of a hybrid workflow. You will still use the CLI or code for some tasks: - Writing the PHP logic inside generated scripts, events, and engine classes. -- Removing or reordering components after they have been added. - Reparenting hierarchy objects or creating new child objects under an existing object. - Running the full game runtime in a dedicated session. diff --git a/docs/guides/building-scenes.md b/docs/guides/building-scenes.md index 143d7a3..6b1a0b2 100644 --- a/docs/guides/building-scenes.md +++ b/docs/guides/building-scenes.md @@ -198,6 +198,20 @@ Components can also be managed after they are added: For a full breakdown, continue with [Inspector and Properties](inspector-and-properties.md). +## Exporting Objects As Prefabs + +When a scene object is selected in `Hierarchy`, press `Shift+E` to export it as a prefab. + +What happens next: + +- the editor writes a `.prefab.php` file under `Assets/Prefabs` +- the filename is derived from the object's name in kebab-case, with a numeric suffix if needed +- the `Prefabs` folder expands in `Assets` +- the new prefab is selected automatically +- focus moves to `Inspector` so you can keep refining the prefab + +Prefab export writes the prefab file immediately. It does not wait for `Ctrl+S`, because prefabs are separate assets from the current scene file. + ## Saving Press `Ctrl+S` to write the active scene back to disk. @@ -226,7 +240,8 @@ Here is a practical level-building sequence: 4. Assign a texture to its renderer and set its crop rectangle. 5. In `Inspector`, press `Shift+A` to add a controller or movement component. 6. Add a `Label` for score or health. -7. Switch to `Scene`, enter Move mode, and place the player and UI elements. -8. Press `Ctrl+S`. +7. If the player or an enemy should be reusable, select it in `Hierarchy` and press `Shift+E` to export a prefab. +8. Switch to `Scene`, enter Move mode, and place the player and UI elements. +9. Press `Ctrl+S`. If your scene also depends on new textures or a new map, build those next in [Working with Assets](working-with-assets.md). diff --git a/docs/guides/getting-started.md b/docs/guides/getting-started.md index 0e4be99..fc1fd3f 100644 --- a/docs/guides/getting-started.md +++ b/docs/guides/getting-started.md @@ -25,7 +25,7 @@ The editor expects a valid Sendama workspace. At minimum, make sure these are in If the project is missing common bootstrap files or folders, the editor now detects that on startup and offers to normalize the project for you. Normalization fills in common gaps such as: - `config/input.php` -- `configuration.json` +- `preferences.json` - missing `logs` files - missing standard asset subdirectories @@ -33,6 +33,7 @@ A typical project layout looks like this: ```text your-game/ +├── preferences.json ├── sendama.json ├── game.php ├── Assets/ @@ -66,6 +67,9 @@ Example: }, "console": { "refreshInterval": 5 + }, + "notifications": { + "duration": 4 } } } diff --git a/docs/guides/inspector-and-properties.md b/docs/guides/inspector-and-properties.md index 8a02438..c89898b 100644 --- a/docs/guides/inspector-and-properties.md +++ b/docs/guides/inspector-and-properties.md @@ -28,7 +28,7 @@ Controls: - `Up` / `Down`: move between controls - `Enter`: activate the selected control -- `Shift+A`: open the add-component menu when a hierarchy object is being inspected +- `Shift+A`: open the add-component menu when a hierarchy object or prefab is being inspected - `Shift+W`: enter or leave component move mode when a component header is selected - `/`: collapse or expand the selected section header - `Tab` / `Shift+Tab`: move forward or backward through focusable controls @@ -110,9 +110,18 @@ Each serialized component becomes its own collapsible section. The section title If the component exposes serialized data, the Inspector renders typed controls for it. +If a serialized component field is typed as `GameObject`, the Inspector treats it as a prefab reference instead of plain text. + +- focus the field +- press `Enter` to open the prefab picker +- choose a prefab from `Assets/Prefabs` +- the saved value becomes that prefab's relative path, for example `Prefabs/enemy.prefab.php` + +When the scene is loaded again, the Inspector resolves that saved path back to the referenced prefab so the field stays readable and editable. + ### Add Component Menu -When the Inspector is showing a hierarchy object other than the scene root, press `Shift+A` to open `Add Component`. +When the Inspector is showing a hierarchy object or prefab other than the scene root, press `Shift+A` to open `Add Component`. The menu can pull candidates from: @@ -145,7 +154,7 @@ To reorder components: ## Asset Controls -When a file asset is inspected, the Inspector renders: +When a regular file asset is inspected, the Inspector renders: - `Type` - editable `Name` @@ -153,6 +162,16 @@ When a file asset is inspected, the Inspector renders: If the file is a PHP class-backed asset under `Assets/Scripts` or `Assets/Events`, renaming it in the Inspector also updates the class declaration inside the source file to match the new filename. +When a prefab asset is activated from `Assets`, the Inspector switches to object-style editing instead of the plain file view. + +Prefab inspection keeps these concerns separate: + +- `File Name` renames the prefab file on disk +- `Name` changes the object name stored inside the prefab metadata +- other fields and components edit the prefab's serialized object data + +Prefab field edits and component changes are written back to the `.prefab.php` file immediately. + When a folder is inspected, the Inspector renders: - `Type` diff --git a/docs/guides/layout-and-navigation.md b/docs/guides/layout-and-navigation.md index 8a9a53c..33060b6 100644 --- a/docs/guides/layout-and-navigation.md +++ b/docs/guides/layout-and-navigation.md @@ -42,7 +42,12 @@ Mouse support is intentionally small and practical: - click a panel to focus it - click a row in `Hierarchy` or `Assets` to select it +- click an expand icon in `Hierarchy` or `Assets` to toggle it +- double-click a row in `Hierarchy` or `Assets` to activate it - click a tab title in the `Main` panel to switch tabs +- click a visible object in `Scene` view to select it +- double-click an Inspector control to activate it +- in `Sprite`, left-click to paint with the current brush, left-drag to draw, and right-click/right-drag to erase without changing the brush You should still expect the editor to behave primarily like a keyboard UI. @@ -82,6 +87,8 @@ The editor uses modals for focused tasks such as: - asset creation - delete confirmations - add-component selection +- prefab selection for `GameObject` reference fields +- console filter and clear confirmations - special character selection - path input actions - file selection for texture and map paths @@ -102,6 +109,8 @@ Each panel owns its own local navigation model: - `Hierarchy` and `Assets` use a tree-browser pattern. - `Shift+A` is panel-local: it adds objects in `Hierarchy`, opens the create menu in `Assets`, and opens the add-component menu in `Inspector` when a hierarchy object is loaded. +- `Shift+E` is also panel-local: it exports the selected hierarchy object as a prefab, and in `Main -> Scene` it switches to Pan mode. +- In `Hierarchy`, `Shift+W` enters hierarchy move mode for the selected object, and `Shift+Q` returns to normal selection. - `Main` switches between scene interaction, play view, and sprite editing. - `Inspector` switches between selecting controls, selecting sub-properties, and editing. - `Console` switches between tabs and scroll positions. diff --git a/docs/guides/playtest-and-debug.md b/docs/guides/playtest-and-debug.md index 2eeff5a..83283cf 100644 --- a/docs/guides/playtest-and-debug.md +++ b/docs/guides/playtest-and-debug.md @@ -69,6 +69,11 @@ Tabs: On startup, each tab loads the last three lines from its log file if the file exists. +Default filters: + +- `Debug` starts on the `DEBUG` filter +- `Error` starts on `ALL` + ## Console Controls | Key | Action | @@ -78,6 +83,8 @@ On startup, each tab loads the last three lines from its log file if the file ex | `Up` | Scroll up when not in play state | | `Down` | Scroll down when not in play state | | `Shift+R` | Refresh the active tab manually when not in play state | +| `Shift+F` | Open the log-level filter modal for the active tab | +| `Shift+C` | Rotate and clear the active log file after confirmation | Tag colors: @@ -86,6 +93,21 @@ Tag colors: - `[INFO]`: blue - `[DEBUG]`: light gray +Filter options: + +- `Debug`: `ALL`, `DEBUG`, `INFO`, `WARN`, `ERROR` +- `Error`: `ALL`, `ERROR`, `CRITICAL`, `FATAL` + +## Filtering And Clearing Logs + +Use `Shift+F` when the `Console` has focus to filter the active tab without leaving the editor. + +Use `Shift+C` when you want a clean log window for the next run: + +- confirm the modal to rotate the active file to the next backup, such as `debug.log.1` +- the active log file is then cleared +- cancel leaves the log file unchanged + ## Auto Refresh While the editor is in play state, the Console panel refreshes itself from disk every `editor.console.refreshInterval` seconds. @@ -97,6 +119,9 @@ Example config: "editor": { "console": { "refreshInterval": 5 + }, + "notifications": { + "duration": 4 } } } @@ -104,6 +129,16 @@ Example config: If you do not configure a value, the editor uses `5` seconds. +## Notifications + +The editor also shows a top-right snackbar for transient system messages. + +Current behavior: + +- scene save success and failure use the snackbar +- the display duration comes from `editor.notifications.duration` +- if you do not configure it, the editor uses `4` seconds + ## Recommended Debug Loop For most gameplay iteration, this loop works well: diff --git a/docs/guides/reference.md b/docs/guides/reference.md index d8e08fa..725110a 100644 --- a/docs/guides/reference.md +++ b/docs/guides/reference.md @@ -13,6 +13,8 @@ This page gathers the most useful shortcuts, file locations, persistence rules, | `Shift+1` | Open panel list | | `Shift+5` | Toggle play state | | `Shift+A` | Panel-local create action in `Hierarchy`, `Assets`, and `Inspector` | +| `Shift+D` | Panel-local duplicate action in `Hierarchy` | +| `Shift+E` | Panel-local secondary action in `Hierarchy` and `Main > Scene` | | `Ctrl+S` | Save the loaded scene | | `Ctrl+C` | Close the editor | @@ -25,6 +27,10 @@ This page gathers the most useful shortcuts, file locations, persistence rules, | `Left` | Collapse node or move to parent | | `Enter` | Inspect selection | | `Shift+A` | Add object | +| `Shift+D` | Duplicate selected object set | +| `Shift+E` | Export selected object as prefab | +| `Shift+W` | Enter hierarchy move mode | +| `Shift+Q` | Return to hierarchy select mode | | `Delete` | Delete selected object | Add-object types: @@ -33,6 +39,8 @@ Add-object types: - `UIElement -> Text` - `UIElement -> Label` +When `GameObject` is chosen and a `GameObject` is currently selected, the editor lets you choose whether the new object is created at the root or as a child of that selected object. + ## Assets Panel | Key | Action | @@ -40,7 +48,7 @@ Add-object types: | `Up` / `Down` | Move selection | | `Right` | Expand folder or move into children | | `Left` | Collapse folder or move to parent | -| `Enter` | Inspect file or folder, or load a prefab into the object-style Inspector view | +| `Enter` | Activate the selected asset; textures and tile maps open `Sprite`, prefabs open the object-style Inspector view | | `Shift+A` | Create asset from the Assets create menu | | `Delete` | Delete selected asset | @@ -53,6 +61,10 @@ Create targets: - `Tile Map` - `Event` +Notes: + +- changing the highlighted row still updates the basic file or folder view in `Inspector` + ## Main Panel ### Tabs @@ -79,6 +91,12 @@ Select mode: | `Up` / `Left` | Previous visible object | | `Down` / `Right` | Next visible object | | `Enter` | Inspect selected object | +| `Shift+D` | Duplicate selected object set | + +Mouse: + +- click a visible object to select it +- `Ctrl+Click` adds the clicked object to the current Scene selection set Move mode: @@ -109,6 +127,12 @@ Pan mode: | `Shift+R` | Reset loaded asset | | `Delete` | Delete active asset | +Mouse: + +- left-click a cell to paint with the current sprite brush +- left-click and drag to draw across multiple cells +- right-click and drag to erase without replacing the active brush + ## Inspector Panel Selection state: @@ -117,12 +141,19 @@ Selection state: | --- | --- | | `Up` / `Down` | Move between controls | | `Enter` | Activate control | -| `Shift+A` | Add a component to the inspected hierarchy object | +| `Shift+A` | Add a component to the inspected hierarchy object or prefab | | `Shift+W` | Toggle component move mode when a component header is focused | | `Delete` | Remove the focused component after confirmation | | `/` | Collapse or expand section | | `Tab` / `Shift+Tab` | Move between focusable controls | +Notes: + +- On text and number controls, `Enter` begins editing. +- On `GameObject` component fields, `Enter` opens the prefab picker and assigns a prefab reference. +- Activated prefabs expose `File Name` separately from the prefab object's `Name`. +- double-clicking a control activates it, the same as focusing it and pressing `Enter` + Property-selection state: | Key | Action | @@ -157,6 +188,13 @@ Path inputs: | `Up` | Scroll up when not in play state | | `Down` | Scroll down when not in play state | | `Shift+R` | Manual refresh when not in play state | +| `Shift+F` | Open the active tab's filter modal | +| `Shift+C` | Rotate and clear the active log after confirmation | + +Filter sets: + +- `Debug`: `ALL`, `DEBUG`, `INFO`, `WARN`, `ERROR` +- `Error`: `ALL`, `ERROR`, `CRITICAL`, `FATAL` ## Common Modal Controls @@ -181,6 +219,8 @@ Tree-style modals also use: | object edits | active `.scene.php` file | when you press `Ctrl+S` | | scene-view moves | active `.scene.php` file | when you press `Ctrl+S` | | hierarchy additions and deletions | active `.scene.php` file | when you press `Ctrl+S` | +| prefab export from `Hierarchy` | generated `.prefab.php` file under `Assets/Prefabs` | immediately | +| prefab field and component edits | selected `.prefab.php` file | immediately | | scene rename | renamed `.scene.php` file | when you press `Ctrl+S` | | asset creation from `Assets` | generated asset file | immediately | | texture and tile map drawing | selected asset file | immediately | @@ -205,6 +245,8 @@ These limits matter when planning your workflow: ## Practical Workflow Tips - Save the scene after any asset rename that affects textures or tile maps already used in the scene. +- Export a prefab after you finish a reusable object's components, so the saved prefab starts from a complete setup. - Treat folder deletion as destructive because it is recursive. - Use the Inspector file picker for path fields to avoid typos. +- Use `Shift+F` in `Console` to narrow noisy logs before you switch tabs or playtest again. - Use `sendama play` for full runtime checks and keep the editor open beside it. diff --git a/docs/guides/working-with-assets.md b/docs/guides/working-with-assets.md index 90b2d30..02b2720 100644 --- a/docs/guides/working-with-assets.md +++ b/docs/guides/working-with-assets.md @@ -21,9 +21,10 @@ Controls: What inspection does: +- changing the highlighted row updates the `Inspector` - folders open in the `Inspector` as `Folder` -- files open in the `Inspector` as `File` -- selecting a `.texture` or `.tmap` also opens `Main -> Sprite` and moves focus there +- regular files open in the `Inspector` as `File` +- pressing `Enter` on a selected `.texture` or `.tmap` opens it in `Main -> Sprite` and moves focus there - pressing `Enter` on a selected `.prefab.php` opens it in the `Inspector` with the same object-style layout used for hierarchy objects ## Sprite Tab Overview @@ -35,9 +36,8 @@ The `Sprite` tab is the editor's character-grid workspace for: How loading works: -- select a `.texture` or `.tmap` in `Assets` -- the file opens in `Inspector` -- the same file loads into `Sprite` +- highlight a `.texture` or `.tmap` in `Assets` to inspect its file metadata +- press `Enter` to load that asset into `Sprite` - focus shifts to `Main -> Sprite` - textures expand to a `16x16` editable grid - tile maps expand to the current terminal-size bounds @@ -83,13 +83,15 @@ Each prefab returns a single PHP array in the same shape used by one scene `hier - a UI element such as `Label` - a UI element such as `Text` -Current prefab support in the editor is focused on generation and project organization: +Current prefab support in the editor covers creation, export, inspection, and editing: - the `Assets` create menu can generate new prefab files +- `Hierarchy -> Shift+E` can export the selected scene object as a prefab immediately - the CLI can generate prefabs directly with `sendama generate:prefab ` - the generated file is normal scene-style metadata, so it is easy to author by hand too - activating a prefab from the `Assets` panel loads its configured object data into the `Inspector` - prefab inspection exposes `File Name` separately from the prefab object's `Name` +- editing prefab fields or components in the `Inspector` writes back to the prefab file immediately ## Sprite Editing Controls diff --git a/src/Commands/EditGame.php b/src/Commands/EditGame.php index 4d51263..df3d26e 100644 --- a/src/Commands/EditGame.php +++ b/src/Commands/EditGame.php @@ -5,6 +5,7 @@ use Sendama\Console\Editor\Editor; use Sendama\Console\Editor\GameSettings; use Sendama\Console\Exceptions\IOException; +use Sendama\Console\Util\Path; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputArgument; @@ -34,8 +35,21 @@ public function execute(InputInterface $input, OutputInterface $output): int { $output->writeln("Opening game configuration for editing...", OutputInterface::VERBOSITY_VERBOSE); - $directory = $input->getOption('directory') ?? '.'; + $directory = Path::normalize((string) ($input->getOption('directory') ?? '.')); + + if ($directory === '' || $directory === '.') { + $directory = getcwd() ?: '.'; + } elseif (!str_starts_with($directory, '/')) { + $directory = Path::join(getcwd() ?: '.', $directory); + } + + $resolvedDirectory = realpath($directory); + $directory = is_string($resolvedDirectory) && $resolvedDirectory !== '' + ? Path::normalize($resolvedDirectory) + : Path::normalize($directory); + $gameSettings = GameSettings::loadFromDirectory($directory); + $editor = new Editor(name: $gameSettings->name, workingDirectory: $directory); $editor->run(); diff --git a/src/Commands/NewGame.php b/src/Commands/NewGame.php index 07b5838..05b704f 100644 --- a/src/Commands/NewGame.php +++ b/src/Commands/NewGame.php @@ -23,580 +23,584 @@ )] class NewGame extends Command { - /** - * @var OutputInterface|null The output interface. - */ - private ?OutputInterface $output = null; - /** - * @var InputInterface|null The input interface. - */ - private ?InputInterface $input = null; - - // Directories - /** - * @var string The target directory. - */ - private string $targetDirectory = ''; - /** - * @var string The maps' directory. - */ - private string $mapsDirectory = ''; - - /** - * @inheritDoc - */ - public function configure(): void - { - $this - ->addArgument('name', InputArgument::REQUIRED, 'The name of the game') - ->addOption('directory', ['d', 'dir'], InputArgument::OPTIONAL, 'The directory to create the game in', getcwd()); - $this->output = new ConsoleOutput(); - } - - /** - * @inheritDoc - */ - public function execute(InputInterface $input, OutputInterface $output): int - { - $this->input = $input; - $this->output = $output; - - // Configure the target directory - $projectName = $input->getArgument('name'); - $output->writeln("Creating $projectName...", OutputInterface::VERBOSITY_VERBOSE); - $this->targetDirectory = Path::join( - $this->targetDirectory, - $input->getOption('directory'), - strtolower(filter_string($projectName)) - ); - - // Create project directory - $this->createProjectDirectory(); - - // Create project structure - $this->output->writeln('Creating project structure...', OutputInterface::VERBOSITY_VERBOSE); - - $this->createConfigDirectory(); - $this->createLogsDirectory(); - $assetsDirectory = $this->createAssetsDirectory(); - $this->createAssetsScenesDirectory($assetsDirectory); - $this->createAssetsScriptsDirectory($assetsDirectory); - $this->createAssetsMapsDirectory($assetsDirectory); - $this->createAssetsPrefabsDirectory($assetsDirectory); - $this->createAssetsTexturesDirectory($assetsDirectory); - - // Create project files - $this->output->writeln('Creating project files...', OutputInterface::VERBOSITY_VERBOSE); - - $this->createMainFile($projectName); - $this->createDotEnvFile($this->targetDirectory); - $this->createGitIgnoreFile($this->targetDirectory); - $this->createSplashScreenTextureFile($assetsDirectory); - $this->createPlayerTextureFile($assetsDirectory); - $this->createTheExampleMapFile($this->mapsDirectory); - $this->createDefaultSceneFile($assetsDirectory); - $this->createDocsDirectory($this->targetDirectory); - $this->createReadmeFile($this->targetDirectory); - - // Create project configuration - $this->createProjectConfiguration($projectName); - - // Done - $this->output->writeln("\nDone! 🎮🎮🎮"); - - // Tell user cd into the project directory - $this->output->writeln("\nTo get started:"); - $targetDirectory = basename($this->targetDirectory); - - $this->output->writeln("\n\tcd $targetDirectory"); - $this->output->writeln("\tphp $targetDirectory.php\n"); - - return Command::SUCCESS; - } - - /** - * Ask the user to confirm an action. - * - * @param string $question The question to ask the user. - * @param bool $default The default response. Default is false. - * @return bool The user's response. - */ - private function confirm(string $question, bool $default = false): bool - { - /** @var QuestionHelper $helper */ - $helper = $this->getHelper('question'); - $question = new ConfirmationQuestion($question, $default); - - return $helper->ask($this->input, $this->output, $question); - } - - /** - * Get the project configuration. - * - * @param string $projectName The project name. - * @return string The project configuration. - */ - private function getProjectConfiguration(string $projectName): string - { - $mainFilename = strtolower(filter_string($projectName)) . '.php'; - - return ProjectNormalizer::buildSendamaConfiguration( - projectName: $projectName, - description: 'A 2D ASCII terminal game.', - version: '0.0.1', - mainFile: $mainFilename, - loadedScenes: ['Scenes/Level.scene.php'], - consoleRefreshInterval: 5.0, - ); - } - - /** - * Get the project configuration. - * - * @param string $packageName The package name. - * @return string The project configuration. - */ - private function getComposerConfiguration(string $packageName): string - { - [$organization, $projectName] = explode('/', $packageName); - $namespace = to_title_case($organization) . '\\' . to_title_case($projectName) . '\\'; - - return json_encode([ - 'name' => $packageName, - 'version' => '1.0.0', - 'description' => 'A new 2D ASCII terminal game.', - 'type' => 'project', - 'require' => [ - 'php' => '^8.3', - 'sendamaphp/engine' => '*' - ], - 'require-dev' => [ - 'pestphp/pest' => '^4.3', - 'phpstan/phpstan' => '^1.10', - ], - 'autoload' => [ - 'psr-4' => [ - $namespace => 'Assets/' - ] - ], - 'config' => [ - 'allow-plugins' => [ - 'pestphp/pest-plugin' => true - ] - ] - ], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); - } - - /** - * Get the package name. - * - * @param string $default The default package name. - * @return string The package name. - */ - private function getPackageName(string $default): string - { - /** @var QuestionHelper $helper */ - $helper = $this->getHelper('question'); - $question = new Question("? Package name: ($default) ", $default); - - $packageName = $helper->ask($this->input, $this->output, $question); - - $validPackageNamePattern = '/[a-zA-Z0-9_]+(-*[a-zA-Z0-9_]*)*\/[a-zA-Z0-9_]+(-*[a-zA-Z0-9_]*)*/'; - if (! preg_match($validPackageNamePattern, $packageName) ) { - throw new RuntimeException('Invalid package name'); + const string ENGINE_PACKAGE_NAME = 'sendamaphp/engine'; + + /** + * @var OutputInterface|null The output interface. + */ + private ?OutputInterface $output = null; + /** + * @var InputInterface|null The input interface. + */ + private ?InputInterface $input = null; + + // Directories + /** + * @var string The target directory. + */ + private string $targetDirectory = ''; + /** + * @var string The maps' directory. + */ + private string $mapsDirectory = ''; + + /** + * @inheritDoc + */ + public function configure(): void + { + $this + ->addArgument('name', InputArgument::REQUIRED, 'The name of the game') + ->addOption('directory', ['d', 'dir'], InputArgument::OPTIONAL, 'The directory to create the game in', getcwd()); + $this->output = new ConsoleOutput(); + } + + /** + * @inheritDoc + */ + public function execute(InputInterface $input, OutputInterface $output): int + { + $this->input = $input; + $this->output = $output; + + // Configure the target directory + $projectName = $input->getArgument('name'); + $output->writeln("Creating $projectName...", OutputInterface::VERBOSITY_VERBOSE); + $this->targetDirectory = Path::join( + $this->targetDirectory, + $input->getOption('directory'), + strtolower(filter_string($projectName)) + ); + + // Create project directory + $this->createProjectDirectory(); + + // Create project structure + $this->output->writeln('Creating project structure...', OutputInterface::VERBOSITY_VERBOSE); + + $this->createConfigDirectory(); + $this->createLogsDirectory(); + $assetsDirectory = $this->createAssetsDirectory(); + $this->createAssetsScenesDirectory($assetsDirectory); + $this->createAssetsScriptsDirectory($assetsDirectory); + $this->createAssetsMapsDirectory($assetsDirectory); + $this->createAssetsPrefabsDirectory($assetsDirectory); + $this->createAssetsTexturesDirectory($assetsDirectory); + + // Create project files + $this->output->writeln('Creating project files...', OutputInterface::VERBOSITY_VERBOSE); + + $this->createMainFile($projectName); + $this->createDotEnvFile($this->targetDirectory); + $this->createGitIgnoreFile($this->targetDirectory); + $this->createSplashScreenTextureFile($assetsDirectory); + $this->createPlayerTextureFile($assetsDirectory); + $this->createTheExampleMapFile($this->mapsDirectory); + $this->createDefaultSceneFile($assetsDirectory); + $this->createDocsDirectory($this->targetDirectory); + $this->createReadmeFile($this->targetDirectory); + + // Create project configuration + $this->createProjectConfiguration($projectName); + + // Done + $this->output->writeln("\nDone! 🎮🎮🎮"); + + // Tell user cd into the project directory + $this->output->writeln("\nTo get started:"); + $targetDirectory = basename($this->targetDirectory); + + $this->output->writeln("\n\tcd $targetDirectory"); + $this->output->writeln("\tphp $targetDirectory.php\n"); + + return Command::SUCCESS; + } + + /** + * Create the project directory. + */ + private function createProjectDirectory(): void + { + $this->output->writeln('Creating project directory...', OutputInterface::VERBOSITY_VERBOSE); + if (file_exists($this->targetDirectory)) { + $this->output->writeln('Project directory already exists...', OutputInterface::VERBOSITY_VERBOSE); + return; + } + + if (!mkdir($this->targetDirectory) && !is_dir($this->targetDirectory)) { + throw new RuntimeException(sprintf('Directory "%s" was not created', $this->targetDirectory)); + } + } + + /** + * Create the config directory. + * @return void + */ + private function createConfigDirectory(): void + { + $configDirectory = Path::join($this->targetDirectory, 'config'); + if (file_exists($configDirectory)) { + $this->output->writeln('Config directory already exists...', OutputInterface::VERBOSITY_VERBOSE); + return; + } + + if (!mkdir($configDirectory) && !is_dir($configDirectory)) { + throw new RuntimeException(sprintf('Directory "%s" was not created', $configDirectory)); + } + } + + /** + * Create the logs' directory. + */ + private function createLogsDirectory(): void + { + $logsDirectory = Path::join($this->targetDirectory, 'logs'); + if (file_exists($logsDirectory)) { + $this->output->writeln('Logs directory already exists...', OutputInterface::VERBOSITY_VERBOSE); + return; + } + + if (!mkdir($logsDirectory) && !is_dir($logsDirectory)) { + throw new RuntimeException(sprintf('Directory "%s" was not created', $logsDirectory)); + } + } + + /** + * Create the assets' directory. + * + * @return string The assets' directory. + */ + private function createAssetsDirectory(): string + { + $assetsDirectory = Path::join($this->targetDirectory, 'Assets'); + if (file_exists($assetsDirectory)) { + return $assetsDirectory; + } + + if (!mkdir($assetsDirectory) && !is_dir($assetsDirectory)) { + throw new RuntimeException(sprintf('Directory "%s" was not created', $assetsDirectory)); + } + + return $assetsDirectory; + } + + /** + * Create the assets' scenes directory. + * + * @param string $assetsDirectory The assets' directory. + */ + private function createAssetsScenesDirectory(string $assetsDirectory): void + { + $scenesDirectory = Path::join($assetsDirectory, 'Scenes'); + if (file_exists($scenesDirectory)) { + $this->output->writeln('Scenes directory already exists...', OutputInterface::VERBOSITY_VERBOSE); + return; + } + + if (!mkdir($scenesDirectory) && !is_dir($scenesDirectory)) { + throw new RuntimeException(sprintf('Directory "%s" was not created', $scenesDirectory)); + } + } + + /** + * Create the assets' scripts directory. + * + * @param string $assetsDirectory The assets' directory. + */ + private function createAssetsScriptsDirectory(string $assetsDirectory): void + { + $scriptsDirectory = Path::join($assetsDirectory, 'Scripts'); + if (file_exists($scriptsDirectory)) { + $this->output->writeln('Scripts directory already exists...', OutputInterface::VERBOSITY_VERBOSE); + return; + } + + if (!mkdir($scriptsDirectory) && !is_dir($scriptsDirectory)) { + throw new RuntimeException(sprintf('Directory "%s" was not created', $scriptsDirectory)); + } + } + + /** + * Create the assets' maps directory. + * + * @param string $assetsDirectory The assets' directory. + */ + private function createAssetsMapsDirectory(string $assetsDirectory): void + { + $this->mapsDirectory = Path::join($assetsDirectory, 'Maps'); + if (file_exists($this->mapsDirectory)) { + $this->output->writeln('Maps directory already exists...', OutputInterface::VERBOSITY_VERBOSE); + return; + } + + if (!mkdir($this->mapsDirectory) && !is_dir($this->mapsDirectory)) { + throw new RuntimeException(sprintf('Directory "%s" was not created', $this->mapsDirectory)); + } + } + + /** + * Create the assets' prefabs directory. + * + * @param string $assetsDirectory The assets' directory. + */ + private function createAssetsPrefabsDirectory(string $assetsDirectory): void + { + $prefabsDirectory = Path::join($assetsDirectory, 'Prefabs'); + if (file_exists($prefabsDirectory)) { + $this->output->writeln('Prefabs directory already exists...', OutputInterface::VERBOSITY_VERBOSE); + return; + } + + if (!mkdir($prefabsDirectory) && !is_dir($prefabsDirectory)) { + throw new RuntimeException(sprintf('Directory "%s" was not created', $prefabsDirectory)); + } + } + + /** + * Create the assets' textures directory. + * + * @param string $assetsDirectory The assets' directory. + */ + private function createAssetsTexturesDirectory(string $assetsDirectory): void + { + $texturesDirectory = Path::join($assetsDirectory, 'Textures'); + if (file_exists($texturesDirectory)) { + $this->output->writeln('Textures directory already exists...', OutputInterface::VERBOSITY_VERBOSE); + return; + } + + if (!mkdir($texturesDirectory) && !is_dir($texturesDirectory)) { + throw new RuntimeException(sprintf('Directory "%s" was not created', $texturesDirectory)); + } + } + + /** + * Create the main file. + * + * @param mixed $projectName The project name. + */ + private function createMainFile(string $projectName): void + { + $targetMainFilename = Path::join( + $this->targetDirectory, + basename($this->targetDirectory) . '.php' + ); + $sourceMainFilename = Path::join(dirname(__DIR__, 2), 'templates', 'game.php'); + if (!copy($sourceMainFilename, $targetMainFilename)) { + throw new RuntimeException(sprintf('File "%s" was not copied to "%s"', $sourceMainFilename, $targetMainFilename)); + } + + ## Replace the game name in the main file + $mainFileContents = file_get_contents($targetMainFilename); + $mainFileContents = str_replace('%GAME_NAME%', $projectName, $mainFileContents); + if (false === file_put_contents($targetMainFilename, $mainFileContents)) { + throw new RuntimeException(sprintf('Unable to write to file "%s"', $targetMainFilename)); + } + + } + + /** + * Create the .gitignore file. + * + * @param string $targetDirectory The target directory. + */ + private function createDotEnvFile(string $targetDirectory): void + { + $this->output->writeln('Creating .env file...', OutputInterface::VERBOSITY_VERBOSE); + $targetDotEnvFilename = Path::join($targetDirectory, '.env'); + $sourceDotEnvFilename = Path::join(dirname(__DIR__, 2), 'templates', '.env'); + + if (!copy($sourceDotEnvFilename, $targetDotEnvFilename)) { + throw new RuntimeException(sprintf('File "%s" was not copied to "%s"', $sourceDotEnvFilename, $targetDotEnvFilename)); + } + } + + /** + * Create the .gitignore file. + * + * @param string $targetDirectory The target directory. + * @return void + */ + private function createGitIgnoreFile(string $targetDirectory): void + { + $this->output->writeln('Creating .gitignore file...', OutputInterface::VERBOSITY_VERBOSE); + $targetGitIgnoreFilename = Path::join($targetDirectory, '.gitignore'); + $sourceGitIgnoreFilename = Path::join(dirname(__DIR__, 2), 'templates', '.gitignore'); + + if (!copy($sourceGitIgnoreFilename, $targetGitIgnoreFilename)) { + throw new RuntimeException(sprintf('File "%s" was not copied to "%s"', $sourceGitIgnoreFilename, $targetGitIgnoreFilename)); + } + } + + /** + * Create the splash screen texture file. + * + * @param string $assetsDirectory The assets' directory. + */ + private function createSplashScreenTextureFile(string $assetsDirectory): void + { + $this->output->writeln('Creating splash screen texture file...', OutputInterface::VERBOSITY_VERBOSE); + $targetSplashScreenTextureFilename = Path::join($assetsDirectory, 'splash.texture'); + + ## Load the splash screen texture from Assets/splash.texture + $sourceSplashScreenTextureFilename = Path::join(dirname(__DIR__, 2), 'templates', 'Assets', 'splash.texture'); + if (!copy($sourceSplashScreenTextureFilename, $targetSplashScreenTextureFilename)) { + throw new RuntimeException(sprintf('File "%s" was not copied to "%s"', $sourceSplashScreenTextureFilename, $targetSplashScreenTextureFilename)); + } + } + + /** + * Create the player texture file. + * + * @param string $assetsDirectory The assets' directory. + */ + private function createPlayerTextureFile(string $assetsDirectory): void + { + $this->output->writeln('Creating player texture file...', OutputInterface::VERBOSITY_VERBOSE); + $targetPlayerTextureFilename = Path::join($assetsDirectory, 'Textures', 'player.texture'); + $sourcePlayerTextureFilename = Path::join(dirname(__DIR__, 2), 'templates', 'Assets', 'Textures', 'player.texture'); + + if (!copy($sourcePlayerTextureFilename, $targetPlayerTextureFilename)) { + throw new RuntimeException(sprintf('File "%s" was not copied to "%s"', $sourcePlayerTextureFilename, $targetPlayerTextureFilename)); + } + } + + /** + * Create the example map file. + * + * @param string $mapsDirectory The maps' directory. + */ + private function createTheExampleMapFile(string $mapsDirectory): void + { + $this->output->writeln('Creating example map file...', OutputInterface::VERBOSITY_VERBOSE); + $targetExampleMapFilename = Path::join($mapsDirectory, 'example.tmap'); + $sourceExampleMapFilename = Path::join(dirname(__DIR__, 2), 'templates', 'Assets', 'Maps', 'example.tmap'); + + if (!copy($sourceExampleMapFilename, $targetExampleMapFilename)) { + throw new RuntimeException(sprintf('File "%s" was not copied to "%s"', $sourceExampleMapFilename, $targetExampleMapFilename)); + } + + } + + private function createDefaultSceneFile(string $assetsDirectory): void + { + $targetSceneFilename = Path::join($assetsDirectory, 'Scenes', 'Level.scene.php'); + + if (false === file_put_contents($targetSceneFilename, SceneFileGenerationStrategy::buildMetaSceneContents())) { + throw new RuntimeException(sprintf('Unable to write to file "%s"', $targetSceneFilename)); + } + } + + /** + * Create the docs' directory. + * + * @param string $targetDirectory The target directory. + * @return void + */ + private function createDocsDirectory(string $targetDirectory): void + { + $this->output->writeln('Creating docs directory...', OutputInterface::VERBOSITY_VERBOSE); + $docsDirectory = Path::join($targetDirectory, 'docs'); + $sourceDocsDirectory = Path::join(dirname(__DIR__, 2), 'templates', 'docs'); + + if (file_exists($docsDirectory)) { + $this->output->writeln('Docs directory already exists...', OutputInterface::VERBOSITY_VERBOSE); + return; + } + + # Copy the docs directory + if (false === passthru("cp -r $sourceDocsDirectory $docsDirectory")) { + throw new RuntimeException(sprintf('Directory "%s" was not copied to "%s"', $sourceDocsDirectory, $docsDirectory)); + } + } + + /** + * Create the README file. + * + * @param string $targetDirectory The target directory. + * @return void + */ + private function createReadmeFile(string $targetDirectory): void + { + $this->output->writeln('Creating README file...', OutputInterface::VERBOSITY_VERBOSE); + $targetReadmeFilename = Path::join($targetDirectory, 'README.md'); + $sourceReadmeFilename = Path::join(dirname(__DIR__, 2), 'templates', 'README.md'); + + if (!copy($sourceReadmeFilename, $targetReadmeFilename)) { + throw new RuntimeException(sprintf('File "%s" was not copied to "%s"', $sourceReadmeFilename, $targetReadmeFilename)); + } + } + + /** + * Create the project configuration. + * + * @param string $projectName The project name. + */ + private function createProjectConfiguration(string $projectName): void + { + $this->output->writeln('Creating project configuration...', OutputInterface::VERBOSITY_VERBOSE); + + $targetConfigFilename = Path::join($this->targetDirectory, 'sendama.json'); + if (false === file_put_contents($targetConfigFilename, $this->getProjectConfiguration($projectName))) { + throw new RuntimeException(sprintf('Unable to write to file "%s"', $targetConfigFilename)); + } + + $this->createConfigurationJsonFile($projectName); + + $this->output->writeln('Creating package configuration', OutputInterface::VERBOSITY_VERBOSE); + $projectName = strtolower(filter_string($projectName)); + $packageName = $this->getPackageName("sendama-engine/$projectName"); + + $this->createInputConfigurationFile($packageName); + + $targetConfigFilename = Path::join($this->targetDirectory, 'composer.json'); + if (false === file_put_contents($targetConfigFilename, $this->getComposerConfiguration($packageName))) { + throw new RuntimeException(sprintf('Unable to write to file "%s"', $targetConfigFilename)); + } + + if ($this->confirm('? Would you like to install the dependencies? (Y/n) ', 'y')) { + $this->installDependencies($this->targetDirectory); + } + } + + /** + * Get the project configuration. + * + * @param string $projectName The project name. + * @return string The project configuration. + */ + private function getProjectConfiguration(string $projectName): string + { + $mainFilename = strtolower(filter_string($projectName)) . '.php'; + + return ProjectNormalizer::buildSendamaConfiguration( + projectName: $projectName, + description: 'A 2D ASCII terminal game.', + version: '0.0.1', + mainFile: $mainFilename, + loadedScenes: ['Scenes/Level.scene.php'], + consoleRefreshInterval: 5.0, + ); + } + + private function createConfigurationJsonFile(string $projectName): void + { + $targetConfigurationFilename = Path::join($this->targetDirectory, 'preferences.json'); + $mainFilename = strtolower(filter_string($projectName)) . '.php'; + + if (false === file_put_contents( + $targetConfigurationFilename, + ProjectNormalizer::buildPreferencesJson() + )) { + throw new RuntimeException(sprintf('Unable to write to file "%s"', $targetConfigurationFilename)); + } + } + + /** + * Get the package name. + * + * @param string $default The default package name. + * @return string The package name. + */ + private function getPackageName(string $default): string + { + /** @var QuestionHelper $helper */ + $helper = $this->getHelper('question'); + $question = new Question("? Package name: ($default) ", $default); + + $packageName = $helper->ask($this->input, $this->output, $question); + + $validPackageNamePattern = '/[a-zA-Z0-9_]+(-*[a-zA-Z0-9_]*)*\/[a-zA-Z0-9_]+(-*[a-zA-Z0-9_]*)*/'; + if (!preg_match($validPackageNamePattern, $packageName)) { + throw new RuntimeException('Invalid package name'); + } + + return $packageName; + } + + private function createInputConfigurationFile(string $packageName): void + { + $targetConfigFilename = Path::join($this->targetDirectory, 'config', 'input.php'); + $inputConfigContents = ProjectNormalizer::buildInputConfiguration(); + $inputConfigContents = str_replace('%PACKAGE_NAME%', $packageName, $inputConfigContents); + + if (false === file_put_contents($targetConfigFilename, $inputConfigContents)) { + throw new RuntimeException(sprintf('Unable to write to file "%s"', $targetConfigFilename)); + } + } + + /** + * Get the project configuration. + * + * @param string $packageName The package name. + * @return string The project configuration. + */ + private function getComposerConfiguration(string $packageName): string + { + [$organization, $projectName] = explode('/', $packageName); + $namespace = to_title_case($organization) . '\\' . to_title_case($projectName) . '\\'; + + return json_encode([ + 'name' => $packageName, + 'version' => '1.0.0', + 'description' => 'A new 2D ASCII terminal game.', + 'type' => 'project', + 'require' => [ + 'php' => '^8.3', + 'sendamaphp/engine' => '*' + ], + 'require-dev' => [ + 'pestphp/pest' => '^4.3', + 'phpstan/phpstan' => '^1.10', + ], + 'autoload' => [ + 'psr-4' => [ + $namespace => 'Assets/' + ] + ], + 'config' => [ + 'allow-plugins' => [ + 'pestphp/pest-plugin' => true + ] + ] + ], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); + } + + /** + * Ask the user to confirm an action. + * + * @param string $question The question to ask the user. + * @param bool $default The default response. Default is false. + * @return bool The user's response. + */ + private function confirm(string $question, bool $default = false): bool + { + /** @var QuestionHelper $helper */ + $helper = $this->getHelper('question'); + $question = new ConfirmationQuestion($question, $default); + + return $helper->ask($this->input, $this->output, $question); + } + + /** + * Install the dependencies. + * + * @param string $targetDirectory The target directory. + */ + private function installDependencies(string $targetDirectory): void + { + // Install dependencies + $this->output->writeln('Installing dependencies...', OutputInterface::VERBOSITY_VERBOSE); + + $installEngineCommand = "composer require --working-dir " . escapeshellarg($targetDirectory) . " --ansi " . self::ENGINE_PACKAGE_NAME; + + if (false === shell_exec($installEngineCommand)) { + throw new RuntimeException(sprintf('Unable to install dependencies: %s', $installEngineCommand)); + } + + $installCommand = "composer install --working-dir=" . escapeshellarg($targetDirectory) . " --ansi"; + if (false === shell_exec($installCommand)) { + throw new RuntimeException('Unable to install dependencies'); + } } - - return $packageName; - } - - /** - * Install the dependencies. - * - * @param string $targetDirectory The target directory. - */ - private function installDependencies(string $targetDirectory): void - { - // Install dependencies - $this->output->writeln('Installing dependencies...', OutputInterface::VERBOSITY_VERBOSE); - $installCommand = "composer install --working-dir=" . escapeshellarg($targetDirectory) . " --ansi"; - if (false === shell_exec($installCommand)) { - throw new RuntimeException('Unable to install dependencies'); - } - } - - /** - * Create the project configuration. - * - * @param string $projectName The project name. - */ - private function createProjectConfiguration(string $projectName): void - { - $this->output->writeln('Creating project configuration...', OutputInterface::VERBOSITY_VERBOSE); - - $targetConfigFilename = Path::join($this->targetDirectory, 'sendama.json'); - if (false === file_put_contents($targetConfigFilename, $this->getProjectConfiguration($projectName))) { - throw new RuntimeException(sprintf('Unable to write to file "%s"', $targetConfigFilename)); - } - - $this->createConfigurationJsonFile($projectName); - - $this->output->writeln('Creating package configuration', OutputInterface::VERBOSITY_VERBOSE); - $projectName = strtolower(filter_string($projectName)); - $packageName = $this->getPackageName("sendama-engine/$projectName"); - - $this->createInputConfigurationFile($packageName); - - $targetConfigFilename = Path::join($this->targetDirectory, 'composer.json'); - if (false === file_put_contents($targetConfigFilename, $this->getComposerConfiguration($packageName))) { - throw new RuntimeException(sprintf('Unable to write to file "%s"', $targetConfigFilename)); - } - - if ($this->confirm('? Would you like to install the dependencies? (Y/n) ', 'y') ) { - $this->installDependencies($this->targetDirectory); - } - } - - private function createConfigurationJsonFile(string $projectName): void - { - $targetConfigurationFilename = Path::join($this->targetDirectory, 'configuration.json'); - $mainFilename = strtolower(filter_string($projectName)) . '.php'; - - if (false === file_put_contents( - $targetConfigurationFilename, - ProjectNormalizer::buildConfigurationJson( - projectName: $projectName, - description: 'A simple ASCII terminal game', - version: '0.0.1', - mainFile: $mainFilename, - ) - )) { - throw new RuntimeException(sprintf('Unable to write to file "%s"', $targetConfigurationFilename)); - } - } - - private function createInputConfigurationFile(string $packageName): void - { - $targetConfigFilename = Path::join($this->targetDirectory, 'config', 'input.php'); - $inputConfigContents = ProjectNormalizer::buildInputConfiguration(); - $inputConfigContents = str_replace('%PACKAGE_NAME%', $packageName, $inputConfigContents); - - if (false === file_put_contents($targetConfigFilename, $inputConfigContents)) { - throw new RuntimeException(sprintf('Unable to write to file "%s"', $targetConfigFilename)); - } - } - - private function createDefaultSceneFile(string $assetsDirectory): void - { - $targetSceneFilename = Path::join($assetsDirectory, 'Scenes', 'Level.scene.php'); - - if (false === file_put_contents($targetSceneFilename, SceneFileGenerationStrategy::buildMetaSceneContents())) { - throw new RuntimeException(sprintf('Unable to write to file "%s"', $targetSceneFilename)); - } - } - - /** - * Create the main file. - * - * @param mixed $projectName The project name. - */ - private function createMainFile(string $projectName): void - { - $targetMainFilename = Path::join( - $this->targetDirectory, - basename($this->targetDirectory) . '.php' - ); - $sourceMainFilename = Path::join(dirname(__DIR__, 2), 'templates', 'game.php'); - if (! copy($sourceMainFilename, $targetMainFilename) ) { - throw new RuntimeException(sprintf('File "%s" was not copied to "%s"', $sourceMainFilename, $targetMainFilename)); - } - - ## Replace the game name in the main file - $mainFileContents = file_get_contents($targetMainFilename); - $mainFileContents = str_replace('%GAME_NAME%', $projectName, $mainFileContents); - if (false === file_put_contents($targetMainFilename, $mainFileContents)) { - throw new RuntimeException(sprintf('Unable to write to file "%s"', $targetMainFilename)); - } - - } - - /** - * Create the splash screen texture file. - * - * @param string $assetsDirectory The assets' directory. - */ - private function createSplashScreenTextureFile(string $assetsDirectory): void - { - $this->output->writeln('Creating splash screen texture file...', OutputInterface::VERBOSITY_VERBOSE); - $targetSplashScreenTextureFilename = Path::join($assetsDirectory, 'splash.texture'); - - ## Load the splash screen texture from Assets/splash.texture - $sourceSplashScreenTextureFilename = Path::join(dirname(__DIR__, 2), 'templates', 'assets', 'splash.texture'); - if (! copy($sourceSplashScreenTextureFilename, $targetSplashScreenTextureFilename) ) { - throw new RuntimeException(sprintf('File "%s" was not copied to "%s"', $sourceSplashScreenTextureFilename, $targetSplashScreenTextureFilename)); - } - } - - /** - * Create the example map file. - * - * @param string $mapsDirectory The maps' directory. - */ - private function createTheExampleMapFile(string $mapsDirectory): void - { - $this->output->writeln('Creating example map file...', OutputInterface::VERBOSITY_VERBOSE); - $targetExampleMapFilename = Path::join($mapsDirectory, 'example.tmap'); - $sourceExampleMapFilename = Path::join(dirname(__DIR__, 2), 'templates', 'assets', 'Maps', 'example.tmap'); - - if (! copy($sourceExampleMapFilename, $targetExampleMapFilename) ) { - throw new RuntimeException(sprintf('File "%s" was not copied to "%s"', $sourceExampleMapFilename, $targetExampleMapFilename)); - } - - } - - /** - * Create the assets' textures directory. - * - * @param string $assetsDirectory The assets' directory. - */ - private function createAssetsTexturesDirectory(string $assetsDirectory): void - { - $texturesDirectory = Path::join($assetsDirectory, 'Textures'); - if (file_exists($texturesDirectory)) { - $this->output->writeln('Textures directory already exists...', OutputInterface::VERBOSITY_VERBOSE); - return; - } - - if (! mkdir($texturesDirectory) && ! is_dir($texturesDirectory)) { - throw new RuntimeException(sprintf('Directory "%s" was not created', $texturesDirectory)); - } - } - - /** - * Create the assets' prefabs directory. - * - * @param string $assetsDirectory The assets' directory. - */ - private function createAssetsPrefabsDirectory(string $assetsDirectory): void - { - $prefabsDirectory = Path::join($assetsDirectory, 'Prefabs'); - if (file_exists($prefabsDirectory)) { - $this->output->writeln('Prefabs directory already exists...', OutputInterface::VERBOSITY_VERBOSE); - return; - } - - if (! mkdir($prefabsDirectory) && ! is_dir($prefabsDirectory)) { - throw new RuntimeException(sprintf('Directory "%s" was not created', $prefabsDirectory)); - } - } - - /** - * Create the assets' maps directory. - * - * @param string $assetsDirectory The assets' directory. - */ - private function createAssetsMapsDirectory(string $assetsDirectory): void - { - $this->mapsDirectory = Path::join($assetsDirectory, 'Maps'); - if (file_exists($this->mapsDirectory)) { - $this->output->writeln('Maps directory already exists...', OutputInterface::VERBOSITY_VERBOSE); - return; - } - - if (! mkdir($this->mapsDirectory) && ! is_dir($this->mapsDirectory)) { - throw new RuntimeException(sprintf('Directory "%s" was not created', $this->mapsDirectory)); - } - } - - /** - * Create the assets' scripts directory. - * - * @param string $assetsDirectory The assets' directory. - */ - private function createAssetsScriptsDirectory(string $assetsDirectory): void - { - $scriptsDirectory = Path::join($assetsDirectory, 'Scripts'); - if (file_exists($scriptsDirectory)) { - $this->output->writeln('Scripts directory already exists...', OutputInterface::VERBOSITY_VERBOSE); - return; - } - - if (! mkdir($scriptsDirectory) && ! is_dir($scriptsDirectory)) { - throw new RuntimeException(sprintf('Directory "%s" was not created', $scriptsDirectory)); - } - } - - /** - * Create the assets' scenes directory. - * - * @param string $assetsDirectory The assets' directory. - */ - private function createAssetsScenesDirectory(string $assetsDirectory): void - { - $scenesDirectory = Path::join($assetsDirectory, 'Scenes'); - if (file_exists($scenesDirectory)) { - $this->output->writeln('Scenes directory already exists...', OutputInterface::VERBOSITY_VERBOSE); - return; - } - - if (! mkdir($scenesDirectory) && ! is_dir($scenesDirectory)) { - throw new RuntimeException(sprintf('Directory "%s" was not created', $scenesDirectory)); - } - } - - /** - * Create the assets' directory. - * - * @return string The assets' directory. - */ - private function createAssetsDirectory(): string - { - $assetsDirectory = Path::join($this->targetDirectory, 'Assets'); - if (file_exists($assetsDirectory)) { - return $assetsDirectory; - } - - if (! mkdir($assetsDirectory) && ! is_dir($assetsDirectory)) { - throw new RuntimeException(sprintf('Directory "%s" was not created', $assetsDirectory)); - } - - return $assetsDirectory; - } - - /** - * Create the logs' directory. - */ - private function createLogsDirectory(): void - { - $logsDirectory = Path::join($this->targetDirectory, 'logs'); - if (file_exists($logsDirectory)) { - $this->output->writeln('Logs directory already exists...', OutputInterface::VERBOSITY_VERBOSE); - return; - } - - if (! mkdir($logsDirectory) && ! is_dir($logsDirectory)) { - throw new RuntimeException(sprintf('Directory "%s" was not created', $logsDirectory)); - } - } - - /** - * Create the config directory. - * @return void - */ - private function createConfigDirectory(): void - { - $configDirectory = Path::join($this->targetDirectory, 'config'); - if (file_exists($configDirectory)) { - $this->output->writeln('Config directory already exists...', OutputInterface::VERBOSITY_VERBOSE); - return; - } - - if (! mkdir($configDirectory) && ! is_dir($configDirectory)) { - throw new RuntimeException(sprintf('Directory "%s" was not created', $configDirectory)); - } - } - - /** - * Create the project directory. - */ - private function createProjectDirectory(): void - { - $this->output->writeln('Creating project directory...', OutputInterface::VERBOSITY_VERBOSE); - if (file_exists($this->targetDirectory)) { - $this->output->writeln('Project directory already exists...', OutputInterface::VERBOSITY_VERBOSE); - return; - } - - if (! mkdir($this->targetDirectory) && ! is_dir($this->targetDirectory)) { - throw new RuntimeException(sprintf('Directory "%s" was not created', $this->targetDirectory)); - } - } - - /** - * Create the player texture file. - * - * @param string $assetsDirectory The assets' directory. - */ - private function createPlayerTextureFile(string $assetsDirectory): void - { - $this->output->writeln('Creating player texture file...', OutputInterface::VERBOSITY_VERBOSE); - $targetPlayerTextureFilename = Path::join($assetsDirectory, 'Textures', 'player.texture'); - $sourcePlayerTextureFilename = Path::join(dirname(__DIR__, 2), 'templates', 'assets', 'Textures', 'player.texture'); - - if (! copy($sourcePlayerTextureFilename, $targetPlayerTextureFilename) ) { - throw new RuntimeException(sprintf('File "%s" was not copied to "%s"', $sourcePlayerTextureFilename, $targetPlayerTextureFilename)); - } - } - - /** - * Create the .gitignore file. - * - * @param string $targetDirectory The target directory. - */ - private function createDotEnvFile(string $targetDirectory): void - { - $this->output->writeln('Creating .env file...', OutputInterface::VERBOSITY_VERBOSE); - $targetDotEnvFilename = Path::join($targetDirectory, '.env'); - $sourceDotEnvFilename = Path::join(dirname(__DIR__, 2), 'templates', '.env'); - - if (! copy($sourceDotEnvFilename, $targetDotEnvFilename) ) { - throw new RuntimeException(sprintf('File "%s" was not copied to "%s"', $sourceDotEnvFilename, $targetDotEnvFilename)); - } - } - - /** - * Create the .gitignore file. - * - * @param string $targetDirectory The target directory. - * @return void - */ - private function createGitIgnoreFile(string $targetDirectory): void - { - $this->output->writeln('Creating .gitignore file...', OutputInterface::VERBOSITY_VERBOSE); - $targetGitIgnoreFilename = Path::join($targetDirectory, '.gitignore'); - $sourceGitIgnoreFilename = Path::join(dirname(__DIR__, 2), 'templates', '.gitignore'); - - if (! copy($sourceGitIgnoreFilename, $targetGitIgnoreFilename) ) { - throw new RuntimeException(sprintf('File "%s" was not copied to "%s"', $sourceGitIgnoreFilename, $targetGitIgnoreFilename)); - } - } - - /** - * Create the docs' directory. - * - * @param string $targetDirectory The target directory. - * @return void - */ - private function createDocsDirectory(string $targetDirectory): void - { - $this->output->writeln('Creating docs directory...', OutputInterface::VERBOSITY_VERBOSE); - $docsDirectory = Path::join($targetDirectory, 'docs'); - $sourceDocsDirectory = Path::join(dirname(__DIR__, 2), 'templates', 'docs'); - - if (file_exists($docsDirectory)) { - $this->output->writeln('Docs directory already exists...', OutputInterface::VERBOSITY_VERBOSE); - return; - } - - # Copy the docs directory - if (false === passthru("cp -r $sourceDocsDirectory $docsDirectory") ) { - throw new RuntimeException(sprintf('Directory "%s" was not copied to "%s"', $sourceDocsDirectory, $docsDirectory)); - } - } - - /** - * Create the README file. - * - * @param string $targetDirectory The target directory. - * @return void - */ - private function createReadmeFile(string $targetDirectory): void - { - $this->output->writeln('Creating README file...', OutputInterface::VERBOSITY_VERBOSE); - $targetReadmeFilename = Path::join($targetDirectory, 'README.md'); - $sourceReadmeFilename = Path::join(dirname(__DIR__, 2), 'templates', 'README.md'); - - if (! copy($sourceReadmeFilename, $targetReadmeFilename) ) { - throw new RuntimeException(sprintf('File "%s" was not copied to "%s"', $sourceReadmeFilename, $targetReadmeFilename)); - } - } } diff --git a/src/Commands/PlayGame.php b/src/Commands/PlayGame.php index 9adba63..835d85e 100644 --- a/src/Commands/PlayGame.php +++ b/src/Commands/PlayGame.php @@ -3,6 +3,7 @@ namespace Sendama\Console\Commands; use Dotenv\Dotenv; +use Sendama\Console\Util\Path; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputArgument; @@ -23,7 +24,7 @@ public function configure(): void public function execute(InputInterface $input, OutputInterface $output): int { - $directory = $input->getOption('directory') ?? '.'; + $directory = $this->resolveAbsoluteDirectory((string) ($input->getOption('directory') ?? '.')); $sendamaConfigFilename = 'sendama.json'; $sendamaDotEnvFilename = '.env'; @@ -59,12 +60,22 @@ public function execute(InputInterface $input, OutputInterface $output): int } // Start the game using the main file - if (false === passthru("php $directory/$config->main" ) ) { + $command = escapeshellarg(PHP_BINARY) . ' ' . escapeshellarg((string) $config->main); + $descriptors = [ + 0 => ['file', 'php://stdin', 'r'], + 1 => ['file', 'php://stdout', 'w'], + 2 => ['file', 'php://stderr', 'w'], + ]; + $process = proc_open($command, $descriptors, $pipes, $directory); + + if (!is_resource($process)) { $output->writeln('Failed to start the game.'); return Command::FAILURE; } - return Command::SUCCESS; + $exitCode = proc_close($process); + + return $exitCode === 0 ? Command::SUCCESS : Command::FAILURE; } /** @@ -77,4 +88,23 @@ private function isValidDirectory(string $directory): bool { return file_exists($directory . '/sendama.json'); } -} \ No newline at end of file + + private function resolveAbsoluteDirectory(string $directory): string + { + $normalizedDirectory = Path::normalize(trim($directory)); + + if ($normalizedDirectory === '' || $normalizedDirectory === '.') { + $normalizedDirectory = getcwd() ?: '.'; + } elseif (!str_starts_with($normalizedDirectory, '/')) { + $normalizedDirectory = Path::join(getcwd() ?: '.', $normalizedDirectory); + } + + $resolvedDirectory = realpath($normalizedDirectory); + + if (is_string($resolvedDirectory) && $resolvedDirectory !== '') { + return Path::normalize($resolvedDirectory); + } + + return Path::normalize($normalizedDirectory); + } +} diff --git a/src/Editor/Editor.php b/src/Editor/Editor.php index 8d3a542..e7e4bfb 100644 --- a/src/Editor/Editor.php +++ b/src/Editor/Editor.php @@ -3,6 +3,7 @@ namespace Sendama\Console\Editor; use Assegai\Collections\ItemList; +use Atatusoft\Termutil\Events\MouseEvent; use Atatusoft\Termutil\Events\Interfaces\ObservableInterface; use Atatusoft\Termutil\Events\Traits\ObservableTrait; use Atatusoft\Termutil\IO\Console\Console; @@ -33,6 +34,7 @@ use Sendama\Console\Editor\Widgets\MainPanel; use Sendama\Console\Editor\Widgets\OptionListModal; use Sendama\Console\Editor\Widgets\PanelListModal; +use Sendama\Console\Editor\Widgets\Snackbar; use Sendama\Console\Editor\Widgets\Widget; use Sendama\Console\Exceptions\IOException; use Sendama\Console\Exceptions\SendamaConsoleException; @@ -43,6 +45,8 @@ use Symfony\Component\Console\Output\BufferedOutput; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Output\ConsoleOutput; +use RecursiveDirectoryIterator; +use RecursiveIteratorIterator; use Throwable; /** @@ -55,6 +59,9 @@ final class Editor implements ObservableInterface use ObservableTrait; const int FPS = 60; + private const float ASSET_WATCH_INTERVAL_SECONDS = 0.5; + private const string TMUX_GAME_CHILD_ENV_KEY = 'SENDAMA_TMUX_CHILD'; + private const int TMUX_PLAY_PANE_PERCENT = 40; /** * @var bool Whether the editor is currently running. */ @@ -147,6 +154,11 @@ final class Editor implements ObservableInterface protected PrefabWriter $prefabWriter; protected ?ProjectNormalizer $projectNormalizer = null; protected array $projectDiscrepancies = []; + protected Snackbar $snackbar; + protected ?string $tmuxPlayPaneId = null; + protected ?string $tmuxPreviousStatusValue = null; + protected array $watchedAssetSnapshot = []; + protected float $lastAssetWatchPollAt = 0.0; /** * @param string $name @@ -158,6 +170,8 @@ public function __construct( ) { try { + $this->workingDirectory = $this->resolveAbsoluteDirectory($this->workingDirectory); + register_shutdown_function(function () { $this->finish(); }); @@ -172,6 +186,8 @@ public function __construct( $this->sceneWriter = new SceneWriter(); $this->prefabWriter = new PrefabWriter(); $this->initializeWidgets(); + $this->watchedAssetSnapshot = $this->captureWatchedAssetSnapshot(); + $this->lastAssetWatchPollAt = microtime(true); $this->initializeEditorStates(); $this->initializeProjectIntegrityCheck(); $this->splashScreen = new SplashScreen( @@ -208,7 +224,7 @@ public function setWorkingDirectory(string $directory): self $this->stop(); } - $this->workingDirectory = $directory; + $this->workingDirectory = $this->resolveAbsoluteDirectory($directory); if ($restartAfterSettingWorkingDirectory) { $this->start(); @@ -217,6 +233,58 @@ public function setWorkingDirectory(string $directory): self return $this; } + private function resolveAbsoluteDirectory(string $directory): string + { + $normalizedDirectory = Path::normalize(trim($directory)); + + if ($normalizedDirectory === '' || $normalizedDirectory === '.') { + $normalizedDirectory = getcwd() ?: '.'; + } + + if (!str_starts_with($normalizedDirectory, '/')) { + $normalizedDirectory = Path::join(getcwd() ?: '.', $normalizedDirectory); + } + + $resolvedDirectory = realpath($normalizedDirectory); + + if (is_string($resolvedDirectory) && $resolvedDirectory !== '') { + return Path::normalize($resolvedDirectory); + } + + return Path::normalize($normalizedDirectory); + } + + private function resolveAbsolutePath(string $path, ?string $baseDirectory = null): string + { + $normalizedPath = Path::normalize(trim($path)); + + if ($normalizedPath === '') { + $resolvedBaseDirectory = is_string($baseDirectory) && $baseDirectory !== '' + ? $this->resolveAbsoluteDirectory($baseDirectory) + : $this->resolveAbsoluteDirectory('.'); + + return $resolvedBaseDirectory; + } + + if (str_starts_with($normalizedPath, '/')) { + $resolvedPath = realpath($normalizedPath); + + return is_string($resolvedPath) && $resolvedPath !== '' + ? Path::normalize($resolvedPath) + : $normalizedPath; + } + + $resolvedBaseDirectory = is_string($baseDirectory) && $baseDirectory !== '' + ? $this->resolveAbsoluteDirectory($baseDirectory) + : $this->resolveAbsoluteDirectory('.'); + $candidatePath = Path::join($resolvedBaseDirectory, $normalizedPath); + $resolvedPath = realpath($candidatePath); + + return is_string($resolvedPath) && $resolvedPath !== '' + ? Path::normalize($resolvedPath) + : Path::normalize($candidatePath); + } + /** * Starts the editor. * @@ -259,6 +327,7 @@ public function start(): void */ public function stop(): void { + $this->stopManagedTmuxPlayPane(); Console::reset(); Debug::info("Stopping editor"); @@ -285,6 +354,7 @@ public function stop(): void */ public function finish(): void { + $this->stopManagedTmuxPlayPane(); Debug::info("Shutting down editor"); Console::restoreSettings(); @@ -379,6 +449,8 @@ private function update(): void $this->refreshTerminalSize(); } + $this->snackbar->update(); + if ($this->projectNormalizationModal?->isVisible()) { $this->handleProjectNormalizationModalInput(); $this->notify(new EditorEvent(EventType::EDITOR_UPDATED->value, $this)); @@ -403,12 +475,16 @@ private function update(): void $this->synchronizeAssetCreations(); $this->synchronizeHierarchyDeletions(); $this->synchronizeHierarchyAdditions(); + $this->synchronizeHierarchyMoves(); + $this->synchronizeHierarchyDuplications(); + $this->synchronizeHierarchyPrefabCreations(); $this->synchronizeMainPanelSceneChanges(); $this->synchronizeMainPanelAssetChanges(); $this->synchronizeInspectorSceneChanges(); $this->synchronizeInspectorPrefabChanges(); $this->synchronizeInspectorAssetChanges(); $this->synchronizeInspectorPanel(); + $this->synchronizeWatchedAssetChanges(); $this->notify(new EditorEvent(EventType::EDITOR_UPDATED->value, $this)); } @@ -416,16 +492,26 @@ private function update(): void private function render(): void { $this->frameCount++; + $hasActiveSnackbar = $this->snackbar->hasActiveNotice(); + $snackbarIsDirty = $this->snackbar->isDirty(); + $shouldRefreshForSnackbar = $snackbarIsDirty; + if ($this->projectNormalizationModal?->isVisible()) { $this->didRenderOverlayLastFrame = true; - if ($this->shouldRefreshBackgroundUnderModal) { + if ($this->shouldRefreshBackgroundUnderModal || $shouldRefreshForSnackbar) { $this->renderEditorFrame(); } - if ($this->shouldRefreshBackgroundUnderModal || $this->projectNormalizationModal->isDirty()) { + if ($this->shouldRefreshBackgroundUnderModal || $this->projectNormalizationModal->isDirty() || $shouldRefreshForSnackbar) { $this->projectNormalizationModal->render(); + + if ($hasActiveSnackbar) { + $this->snackbar->render(); + } + $this->projectNormalizationModal->markClean(); + $this->snackbar->markClean(); $this->shouldRefreshBackgroundUnderModal = false; } @@ -436,13 +522,19 @@ private function render(): void if ($this->panelListModal->isVisible()) { $this->didRenderOverlayLastFrame = true; - if ($this->shouldRefreshBackgroundUnderModal) { + if ($this->shouldRefreshBackgroundUnderModal || $shouldRefreshForSnackbar) { $this->renderEditorFrame(); } - if ($this->shouldRefreshBackgroundUnderModal || $this->panelListModal->isDirty()) { + if ($this->shouldRefreshBackgroundUnderModal || $this->panelListModal->isDirty() || $shouldRefreshForSnackbar) { $this->panelListModal->render(); + + if ($hasActiveSnackbar) { + $this->snackbar->render(); + } + $this->panelListModal->markClean(); + $this->snackbar->markClean(); $this->shouldRefreshBackgroundUnderModal = false; } @@ -458,13 +550,19 @@ private function render(): void $this->shouldRefreshBackgroundUnderModal = true; } - if ($this->shouldRefreshBackgroundUnderModal) { + if ($this->shouldRefreshBackgroundUnderModal || $shouldRefreshForSnackbar) { $this->renderEditorFrame(); } - if ($this->shouldRefreshBackgroundUnderModal || $this->focusedPanel->isModalDirty()) { + if ($this->shouldRefreshBackgroundUnderModal || $this->focusedPanel->isModalDirty() || $shouldRefreshForSnackbar) { $this->focusedPanel->renderActiveModal(); + + if ($hasActiveSnackbar) { + $this->snackbar->render(); + } + $this->focusedPanel->markModalClean(); + $this->snackbar->markClean(); $this->shouldRefreshBackgroundUnderModal = false; } @@ -480,6 +578,14 @@ private function render(): void $this->shouldRefreshBackgroundUnderModal = false; $this->renderEditorFrame(); + if ($hasActiveSnackbar) { + $this->snackbar->render(); + } + + if ($snackbarIsDirty || $hasActiveSnackbar) { + $this->snackbar->markClean(); + } + $this->notify(new EditorEvent(EventType::EDITOR_RENDERED->value, $this)); } @@ -607,8 +713,14 @@ private function togglePlayMode(): void { if ($this->editorState instanceof PlayState) { $this->setState($this->editState); + $this->stopManagedTmuxPlayPane(); } else { $this->setState($this->playState); + + if ($this->canUseTmuxIntegration() && !$this->startManagedTmuxPlayPaneIfAvailable()) { + $this->setState($this->editState); + $this->pushNotification('Failed to launch the game pane for play mode.', 'error'); + } } $this->shouldRefreshBackgroundUnderModal = true; @@ -688,6 +800,7 @@ private function initializeWidgets(): void workingDirectory: $this->workingDirectory, ); $this->inspectorPanel->setSceneHierarchy($this->loadedScene?->hierarchy ?? []); + $this->snackbar = new Snackbar($this->settings->notificationDurationSeconds); $this->panels->add($this->hierarchyPanel); $this->panels->add($this->assetsPanel); @@ -702,33 +815,119 @@ private function initializeWidgets(): void private function handlePanelFocus(): void { - if ( - $this->projectNormalizationModal?->isVisible() - || $this->panelListModal->isVisible() - || $this->focusedPanel?->hasActiveModal() - ) { + $mouseEvent = Input::getMouseEvent(); + + if (!$mouseEvent) { return; } - if (!Input::isLeftMouseButtonDown()) { + if ($this->projectNormalizationModal?->isVisible()) { + $this->handleProjectNormalizationModalMouseEvent($mouseEvent); return; } - $mouseEvent = Input::getMouseEvent(); + if ($this->panelListModal->isVisible()) { + $this->handlePanelListModalMouseEvent($mouseEvent); + return; + } - if (!$mouseEvent) { + if ($this->focusedPanel?->hasActiveModal()) { + $this->focusedPanel->handleModalMouseEvent($mouseEvent); + return; + } + + if (!in_array($mouseEvent->buttonIndex, [0, 2], true)) { + return; + } + + if ($mouseEvent->action === 'Dragged') { + if ($this->focusedPanel?->containsPoint($mouseEvent->x, $mouseEvent->y)) { + $this->focusedPanel->handleMouseEvent($mouseEvent); + } + + return; + } + + if ($mouseEvent->action === 'Released') { + $this->focusedPanel?->handleMouseEvent($mouseEvent); + return; + } + + if ($mouseEvent->buttonIndex === 2) { + if ($this->focusedPanel?->containsPoint($mouseEvent->x, $mouseEvent->y)) { + $this->focusedPanel->handleMouseEvent($mouseEvent); + } + + return; + } + + if (!Input::isLeftMouseButtonPressed()) { return; } foreach ($this->panels as $panel) { if ($panel->containsPoint($mouseEvent->x, $mouseEvent->y)) { $this->setFocusedPanel($panel); - $panel->handleMouseClick($mouseEvent->x, $mouseEvent->y); + $panel->handleMouseEvent($mouseEvent); return; } } } + private function handleProjectNormalizationModalMouseEvent(MouseEvent $mouseEvent): void + { + if (!$this->projectNormalizationModal instanceof OptionListModal) { + return; + } + + if ($this->projectNormalizationModal->handleScrollbarMouseEvent($mouseEvent)) { + return; + } + + if ($mouseEvent->buttonIndex !== 0 || $mouseEvent->action !== 'Pressed') { + return; + } + + $selectedOption = $this->projectNormalizationModal->clickOptionAtPoint($mouseEvent->x, $mouseEvent->y); + + if (!is_string($selectedOption) || $selectedOption === '') { + return; + } + + $this->projectNormalizationModal->hide(); + + if ($selectedOption === 'Normalize') { + $this->normalizeLoadedProject(); + } + + $this->shouldRefreshBackgroundUnderModal = true; + } + + private function handlePanelListModalMouseEvent(MouseEvent $mouseEvent): void + { + if ($mouseEvent->buttonIndex !== 0 || $mouseEvent->action !== 'Pressed') { + return; + } + + $selectedIndex = $this->panelListModal->clickPanelAtPoint($mouseEvent->x, $mouseEvent->y); + + if (!is_int($selectedIndex)) { + return; + } + + $this->panelListModal->hide(); + $panels = $this->panels->toArray(); + + if (!isset($panels[$selectedIndex])) { + return; + } + + /** @var Widget $panel */ + $panel = $panels[$selectedIndex]; + $this->setFocusedPanel($panel); + $this->shouldRefreshBackgroundUnderModal = true; + } + private function setFocusedPanel(Widget $panel): void { if ($this->focusedPanel === $panel) { @@ -875,6 +1074,7 @@ private function layoutPanels(): void $this->panelListModal->syncLayout($this->terminalWidth, $this->terminalHeight); $this->projectNormalizationModal?->syncLayout($this->terminalWidth, $this->terminalHeight); $this->focusedPanel?->syncModalLayout($this->terminalWidth, $this->terminalHeight); + $this->snackbar->syncLayout($this->terminalWidth, $this->terminalHeight); } private function handleProjectNormalizationModalInput(): void @@ -1077,10 +1277,15 @@ private function synchronizeInspectorPanel(): void } elseif (($selectedItem['context'] ?? null) === 'asset') { $asset = is_array($selectedItem['value'] ?? null) ? $selectedItem['value'] : null; $openInMainPanel = ($selectedItem['openInMainPanel'] ?? false) === true; + $openInTerminalEditor = ($selectedItem['openInTerminalEditor'] ?? false) === true; $selectedItem = is_array($asset) ? $this->buildAssetInspectionTarget($asset, $openInMainPanel) : $selectedItem; + if ($openInTerminalEditor && is_array($asset)) { + $this->openAssetInConfiguredEditor($asset); + } + if ($openInMainPanel && $this->isEditableSpriteAsset($asset)) { $this->mainPanel->loadSpriteAsset($asset); $this->mainPanel->selectTab('Sprite'); @@ -1091,6 +1296,48 @@ private function synchronizeInspectorPanel(): void $this->inspectorPanel->inspectTarget($selectedItem); } + private function synchronizeWatchedAssetChanges(bool $force = false): void + { + if (!isset($this->assetsPanel) || !isset($this->inspectorPanel)) { + return; + } + + $assetsDirectory = $this->resolveWatchedAssetsDirectory(); + $now = microtime(true); + + if ($assetsDirectory === null) { + $this->watchedAssetSnapshot = []; + $this->lastAssetWatchPollAt = $now; + return; + } + + if ( + !$force + && $this->lastAssetWatchPollAt > 0.0 + && ($now - $this->lastAssetWatchPollAt) < self::ASSET_WATCH_INTERVAL_SECONDS + ) { + return; + } + + $currentSnapshot = $this->captureWatchedAssetSnapshot($assetsDirectory); + $previousSnapshot = $this->watchedAssetSnapshot; + $this->watchedAssetSnapshot = $currentSnapshot; + $this->lastAssetWatchPollAt = $now; + + if ($previousSnapshot === []) { + return; + } + + $changedAssetPaths = $this->detectChangedWatchedAssetPaths($previousSnapshot, $currentSnapshot); + + if ($changedAssetPaths === []) { + return; + } + + $this->assetsPanel->reloadAssets(); + $this->refreshInspectionAfterWatchedAssetChanges($changedAssetPaths); + } + private function synchronizeInspectorSceneChanges(): void { $mutation = $this->inspectorPanel->consumeHierarchyMutation(); @@ -1203,6 +1450,7 @@ private function synchronizeInspectorPrefabChanges(): void if (!$this->prefabWriter->save($mutation['prefabPath'], $mutation['value'])) { $this->consolePanel->append('[ERROR] - Failed to save prefab ' . basename($mutation['prefabPath']) . '.'); + $this->pushNotification('Failed to save prefab ' . basename($mutation['prefabPath']) . '.', 'error'); return; } @@ -1229,19 +1477,170 @@ private function synchronizeHierarchyAdditions(): void { $newItem = $this->hierarchyPanel->consumeCreationRequest(); - if (!$this->loadedScene instanceof DTOs\SceneDTO || !is_array($newItem) || $newItem === []) { + if ( + !$this->loadedScene instanceof DTOs\SceneDTO + || !is_array($newItem) + || !is_array($newItem['value'] ?? null) + ) { + return; + } + + $parentPath = is_string($newItem['parentPath'] ?? null) ? $newItem['parentPath'] : null; + $newPath = $parentPath !== null + ? $this->appendHierarchyChild($parentPath, $newItem['value']) + : $this->appendHierarchyRoot($newItem['value']); + + if (!is_string($newPath) || $newPath === '') { + return; + } + + $this->loadedScene->isDirty = true; + $this->loadedScene->rawData['hierarchy'] = $this->loadedScene->hierarchy; + $this->hierarchyPanel->syncHierarchy($this->loadedScene->hierarchy); + + if ($parentPath !== null) { + $this->hierarchyPanel->expandPath($parentPath); + } + + $this->hierarchyPanel->selectPath($newPath); + $this->syncScenePanels(true); + $this->mainPanel->setSceneObjects($this->loadedScene->hierarchy); + $this->mainPanel->selectSceneObject($newPath); + } + + private function synchronizeHierarchyMoves(): void + { + $moveRequest = $this->hierarchyPanel->consumeMoveRequest(); + + if ( + !$this->loadedScene instanceof DTOs\SceneDTO + || !is_array($moveRequest) + || !is_string($moveRequest['path'] ?? null) + || ($moveRequest['path'] ?? '') === '' + || !is_string($moveRequest['targetPath'] ?? null) + || ($moveRequest['targetPath'] ?? '') === '' + || !in_array($moveRequest['position'] ?? null, ['before', 'after', 'append_child'], true) + ) { + return; + } + + $targetPathForExpansion = $moveRequest['position'] === 'append_child' + ? $this->adjustHierarchyPathAfterRemoval($moveRequest['path'], $moveRequest['targetPath']) + : null; + + $newPath = $this->moveHierarchyNodeRelative( + $moveRequest['path'], + $moveRequest['targetPath'], + $moveRequest['position'], + ); + + if (!is_string($newPath) || $newPath === '') { return; } - $this->loadedScene->hierarchy[] = $newItem; + $movedValue = $this->findHierarchyNodeByPath($newPath); + $this->loadedScene->isDirty = true; $this->loadedScene->rawData['hierarchy'] = $this->loadedScene->hierarchy; $this->hierarchyPanel->syncHierarchy($this->loadedScene->hierarchy); - $newPath = 'scene.' . (count($this->loadedScene->hierarchy) - 1); + + if (is_string($targetPathForExpansion) && $targetPathForExpansion !== '') { + $this->hierarchyPanel->expandPath($targetPathForExpansion); + } + $this->hierarchyPanel->selectPath($newPath); $this->syncScenePanels(true); $this->mainPanel->setSceneObjects($this->loadedScene->hierarchy); $this->mainPanel->selectSceneObject($newPath); + + if (is_array($movedValue)) { + $this->inspectorPanel->inspectTarget([ + 'context' => 'hierarchy', + 'name' => $movedValue['name'] ?? 'Unnamed Object', + 'type' => $this->resolveHierarchyInspectionType($movedValue), + 'path' => $newPath, + 'value' => $movedValue, + ]); + } + } + + private function synchronizeHierarchyPrefabCreations(): void + { + $prefabCreationRequest = $this->hierarchyPanel->consumePrefabCreationRequest(); + + if ( + !is_array($prefabCreationRequest) + || !is_array($prefabCreationRequest['value'] ?? null) + ) { + return; + } + + $createdPrefabAsset = $this->createPrefabFromHierarchyObject($prefabCreationRequest['value']); + + if (!is_array($createdPrefabAsset)) { + return; + } + + $this->assetsPanel->reloadAssets(); + $this->assetsPanel->selectAssetByAbsolutePath($createdPrefabAsset['path']); + $this->assetsPanel->consumeInspectionRequest(); + $this->inspectorPanel->inspectTarget($this->buildAssetInspectionTarget($createdPrefabAsset, true)); + $this->setFocusedPanel($this->inspectorPanel); + } + + private function synchronizeHierarchyDuplications(): void + { + $duplicationRequest = $this->hierarchyPanel->consumeDuplicationRequest() + ?? $this->mainPanel->consumeDuplicationRequest(); + + if ( + !$this->loadedScene instanceof DTOs\SceneDTO + || !is_array($duplicationRequest) + || !is_array($duplicationRequest['items'] ?? null) + ) { + return; + } + + $duplicationItems = $this->filterRedundantDuplicationItems($duplicationRequest['items']); + + if ($duplicationItems === []) { + return; + } + + usort($duplicationItems, [$this, 'compareHierarchyPathsDescending']); + $newPaths = []; + + foreach ($duplicationItems as $duplicationItem) { + $path = $duplicationItem['path'] ?? null; + $value = $duplicationItem['value'] ?? null; + + if (!is_string($path) || $path === '' || !is_array($value)) { + continue; + } + + $duplicatedValue = $value; + $duplicatedValue['name'] = $this->buildUniqueDuplicateHierarchyName($path, $value['name'] ?? 'Object'); + $newPath = $this->insertHierarchyNodeAfter($path, $duplicatedValue); + + if (is_string($newPath) && $newPath !== '') { + $newPaths[] = $newPath; + } + } + + if ($newPaths === []) { + return; + } + + usort($newPaths, [$this, 'compareHierarchyPathsAscending']); + $primaryPath = end($newPaths); + + $this->loadedScene->isDirty = true; + $this->loadedScene->rawData['hierarchy'] = $this->loadedScene->hierarchy; + $this->hierarchyPanel->syncHierarchy($this->loadedScene->hierarchy); + $this->hierarchyPanel->selectPaths($newPaths, is_string($primaryPath) ? $primaryPath : null); + $this->syncScenePanels(true); + $this->mainPanel->setSceneObjects($this->loadedScene->hierarchy); + $this->mainPanel->selectSceneObjects($newPaths, is_string($primaryPath) ? $primaryPath : null); } private function synchronizeHierarchyDeletions(): void @@ -1309,8 +1708,17 @@ private function synchronizeAssetCreations(): void $this->assetsPanel->reloadAssets(); $this->assetsPanel->selectAssetByAbsolutePath($createdAsset['path']); - $this->inspectorPanel->inspectTarget($this->buildAssetInspectionTarget($createdAsset)); + $inspectionTarget = $this->buildAssetInspectionTarget($createdAsset); + $this->inspectorPanel->inspectTarget($inspectionTarget); $this->mainPanel->loadSpriteAsset($createdAsset); + + if ($this->isEditableSpriteAsset($createdAsset)) { + $this->mainPanel->selectTab('Sprite'); + $this->setFocusedPanel($this->mainPanel); + return; + } + + $this->setFocusedPanel($this->inspectorPanel); } private function synchronizeMainPanelSceneChanges(): void @@ -1365,72 +1773,619 @@ private function synchronizeMainPanelAssetChanges(): void } } - private function applyHierarchyMutation(string $path, array $value): bool + private function resolveWatchedAssetsDirectory(): ?string { - if (!$this->loadedScene instanceof DTOs\SceneDTO) { - return false; + $assetsDirectory = is_string($this->assetsDirectoryPath) && $this->assetsDirectoryPath !== '' + ? $this->resolveAbsolutePath($this->assetsDirectoryPath, $this->workingDirectory) + : Path::resolveAssetsDirectory($this->workingDirectory); + + if (!is_string($assetsDirectory) || $assetsDirectory === '' || !is_dir($assetsDirectory)) { + return null; } - $segments = explode('.', $path); + return Path::normalize($assetsDirectory); + } - if (($segments[0] ?? null) !== 'scene') { - return false; + private function captureWatchedAssetSnapshot(?string $assetsDirectory = null): array + { + $resolvedAssetsDirectory = $assetsDirectory ?? $this->resolveWatchedAssetsDirectory(); + + if (!is_string($resolvedAssetsDirectory) || $resolvedAssetsDirectory === '' || !is_dir($resolvedAssetsDirectory)) { + return []; } - array_shift($segments); + clearstatcache(); - if ($segments === []) { - return false; + $snapshot = [ + $resolvedAssetsDirectory => $this->buildWatchedAssetSignature($resolvedAssetsDirectory, true), + ]; + $iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($resolvedAssetsDirectory, RecursiveDirectoryIterator::SKIP_DOTS), + RecursiveIteratorIterator::SELF_FIRST, + ); + + foreach ($iterator as $entry) { + $entryPath = Path::normalize($entry->getPathname()); + $snapshot[$entryPath] = $this->buildWatchedAssetSignature($entryPath, $entry->isDir()); } - $hierarchy = $this->loadedScene->hierarchy; - $nodeArray = &$hierarchy; - $lastIndex = count($segments) - 1; + ksort($snapshot); - foreach ($segments as $index => $segment) { + return $snapshot; + } + + private function buildWatchedAssetSignature(string $path, bool $isDirectory): string + { + $mtime = @filemtime($path); + + if ($isDirectory) { + return 'd:' . ($mtime === false ? 'missing' : (string) $mtime); + } + + $size = @filesize($path); + $signature = sprintf( + 'f:%s:%s', + $mtime === false ? 'missing' : (string) $mtime, + $size === false ? 'missing' : (string) $size, + ); + + if (str_ends_with(strtolower($path), '.php')) { + $hash = @md5_file($path); + + if (is_string($hash) && $hash !== '') { + $signature .= ':' . $hash; + } + } + + return $signature; + } + + private function detectChangedWatchedAssetPaths(array $previousSnapshot, array $currentSnapshot): array + { + $changedPaths = []; + $allPaths = array_values(array_unique([ + ...array_keys($previousSnapshot), + ...array_keys($currentSnapshot), + ])); + + foreach ($allPaths as $path) { + if (($previousSnapshot[$path] ?? null) === ($currentSnapshot[$path] ?? null)) { + continue; + } + + if (is_string($path) && $path !== '') { + $changedPaths[] = $path; + } + } + + sort($changedPaths); + + return $changedPaths; + } + + private function refreshInspectionAfterWatchedAssetChanges(array $changedAssetPaths): void + { + if (!isset($this->inspectorPanel)) { + return; + } + + $inspectionTarget = $this->inspectorPanel->getInspectionTarget(); + + if (!is_array($inspectionTarget)) { + return; + } + + $hasChangedPhpAsset = $this->hasChangedPhpAsset($changedAssetPaths); + + switch ($inspectionTarget['context'] ?? null) { + case 'hierarchy': + if (!$hasChangedPhpAsset || !$this->refreshLoadedSceneComponentMetadata()) { + return; + } + + $path = is_string($inspectionTarget['path'] ?? null) ? $inspectionTarget['path'] : null; + + if (!is_string($path) || $path === '') { + return; + } + + $value = $this->findHierarchyNodeByPath($path); + + if (!is_array($value)) { + $this->inspectorPanel->inspectTarget(null); + return; + } + + $this->hierarchyPanel->selectPath($path); + $this->mainPanel->selectSceneObject($path); + $this->inspectorPanel->inspectTarget([ + 'context' => 'hierarchy', + 'name' => $value['name'] ?? 'Unnamed Object', + 'type' => $this->resolveHierarchyInspectionType($value), + 'path' => $path, + 'value' => $value, + ]); + return; + + case 'scene': + if (!$hasChangedPhpAsset || !$this->refreshLoadedSceneComponentMetadata()) { + return; + } + + $this->hierarchyPanel->selectPath('scene'); + $this->inspectorPanel->inspectTarget($this->buildSceneInspectionTarget()); + return; + + case 'prefab': + $prefabPath = $this->resolveInspectionAssetAbsolutePath($inspectionTarget); + + if ( + !is_string($prefabPath) + || $prefabPath === '' + || (!$hasChangedPhpAsset && !$this->didWatchedAssetChange($prefabPath, $changedAssetPaths)) + ) { + return; + } + + $asset = $this->resolveAssetEntryByAbsolutePath($prefabPath); + + if (!is_array($asset)) { + $this->inspectorPanel->inspectTarget(null); + return; + } + + $prefabInspectionTarget = $this->buildPrefabInspectionTarget($asset); + + if (!is_array($prefabInspectionTarget)) { + $this->inspectorPanel->inspectTarget(null); + return; + } + + $this->inspectorPanel->inspectTarget($prefabInspectionTarget); + return; + + case 'asset': + $assetPath = $this->resolveInspectionAssetAbsolutePath($inspectionTarget); + + if ( + !is_string($assetPath) + || $assetPath === '' + || !$this->didWatchedAssetChange($assetPath, $changedAssetPaths) + ) { + return; + } + + $asset = $this->resolveAssetEntryByAbsolutePath($assetPath); + + if (!is_array($asset)) { + $this->inspectorPanel->inspectTarget(null); + return; + } + + $this->inspectorPanel->inspectTarget($this->buildAssetInspectionTarget($asset)); + return; + } + } + + private function hasChangedPhpAsset(array $changedAssetPaths): bool + { + foreach ($changedAssetPaths as $path) { + if (is_string($path) && str_ends_with(strtolower($path), '.php')) { + return true; + } + } + + return false; + } + + private function didWatchedAssetChange(string $absolutePath, array $changedAssetPaths): bool + { + $normalizedPath = Path::normalize($absolutePath); + + foreach ($changedAssetPaths as $changedPath) { + if (!is_string($changedPath) || $changedPath === '') { + continue; + } + + if (Path::normalize($changedPath) === $normalizedPath) { + return true; + } + } + + return false; + } + + private function resolveInspectionAssetAbsolutePath(array $inspectionTarget): ?string + { + $candidatePath = $inspectionTarget['asset']['path'] ?? $inspectionTarget['value']['path'] ?? null; + + if (!is_string($candidatePath) || $candidatePath === '') { + return null; + } + + return Path::normalize($candidatePath); + } + + private function resolveAssetEntryByAbsolutePath(string $absolutePath): ?array + { + if (!isset($this->assetsPanel)) { + return null; + } + + $normalizedAbsolutePath = Path::normalize($absolutePath); + $this->assetsPanel->selectAssetByAbsolutePath($normalizedAbsolutePath); + $this->assetsPanel->consumeInspectionRequest(); + $selectedAsset = $this->assetsPanel->getSelectedAssetEntry(); + $selectedAssetPath = is_string($selectedAsset['path'] ?? null) + ? Path::normalize($selectedAsset['path']) + : null; + + return $selectedAssetPath === $normalizedAbsolutePath + ? $selectedAsset + : null; + } + + private function refreshLoadedSceneComponentMetadata(): bool + { + if ( + !$this->loadedScene instanceof DTOs\SceneDTO + || !isset($this->hierarchyPanel) + || !isset($this->mainPanel) + ) { + return false; + } + + if (!isset($this->sceneWriter)) { + $this->sceneWriter = new SceneWriter(); + } + + $temporarySceneSeed = tempnam(sys_get_temp_dir(), 'sendama-editor-watch-'); + + if (!is_string($temporarySceneSeed) || $temporarySceneSeed === '') { + return false; + } + + @unlink($temporarySceneSeed); + $temporaryScenePath = $temporarySceneSeed . '.scene.php'; + + if (file_put_contents($temporaryScenePath, $this->sceneWriter->serialize($this->loadedScene)) === false) { + @unlink($temporaryScenePath); + return false; + } + + try { + $refreshedScene = (new SceneLoader($this->workingDirectory))->loadFromPath($temporaryScenePath); + } finally { + @unlink($temporaryScenePath); + } + + if (!$refreshedScene instanceof DTOs\SceneDTO) { + return false; + } + + $sceneWasDirty = $this->loadedScene->isDirty; + $this->loadedScene->hierarchy = $refreshedScene->hierarchy; + $this->loadedScene->rawData['hierarchy'] = $this->sceneWriter->snapshot($this->loadedScene)['hierarchy'] + ?? $this->loadedScene->hierarchy; + $this->loadedScene->isDirty = $sceneWasDirty; + $this->hierarchyPanel->syncHierarchy($this->loadedScene->hierarchy); + $this->mainPanel->setSceneObjects($this->loadedScene->hierarchy); + $this->syncScenePanels($sceneWasDirty); + + return true; + } + + 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 insertHierarchyNodeAfter(string $path, array $value): ?string + { + if (!$this->loadedScene instanceof DTOs\SceneDTO) { + return null; + } + + $segments = explode('.', $path); + + if (($segments[0] ?? null) !== 'scene') { + return null; + } + + array_shift($segments); + + if ($segments === []) { + return null; + } + + $hierarchy = $this->loadedScene->hierarchy; + $nodeArray = &$hierarchy; + $lastIndex = count($segments) - 1; + + foreach ($segments as $index => $segment) { + if (!ctype_digit((string) $segment)) { + return null; + } + + $numericSegment = (int) $segment; + + if (!isset($nodeArray[$numericSegment])) { + return null; + } + + if ($index === $lastIndex) { + array_splice($nodeArray, $numericSegment + 1, 0, [$value]); + $this->loadedScene->hierarchy = array_values($hierarchy); + + $parentSegments = $segments; + array_pop($parentSegments); + $newPathSegments = ['scene', ...$parentSegments, (string) ($numericSegment + 1)]; + + return implode('.', $newPathSegments); + } + + if (!isset($nodeArray[$numericSegment]['children']) || !is_array($nodeArray[$numericSegment]['children'])) { + return null; + } + + $nodeArray = &$nodeArray[$numericSegment]['children']; + } + + return null; + } + + private function insertHierarchyNodeBefore(string $path, array $value): ?string + { + if (!$this->loadedScene instanceof DTOs\SceneDTO) { + return null; + } + + $segments = explode('.', $path); + + if (($segments[0] ?? null) !== 'scene') { + return null; + } + + array_shift($segments); + + if ($segments === []) { + return null; + } + + $hierarchy = $this->loadedScene->hierarchy; + $nodeArray = &$hierarchy; + $lastIndex = count($segments) - 1; + + foreach ($segments as $index => $segment) { + if (!ctype_digit((string) $segment)) { + return null; + } + + $numericSegment = (int) $segment; + + if (!isset($nodeArray[$numericSegment])) { + return null; + } + + if ($index === $lastIndex) { + array_splice($nodeArray, $numericSegment, 0, [$value]); + $this->loadedScene->hierarchy = array_values($hierarchy); + + $parentSegments = $segments; + array_pop($parentSegments); + $newPathSegments = ['scene', ...$parentSegments, (string) $numericSegment]; + + return implode('.', $newPathSegments); + } + + if (!isset($nodeArray[$numericSegment]['children']) || !is_array($nodeArray[$numericSegment]['children'])) { + return null; + } + + $nodeArray = &$nodeArray[$numericSegment]['children']; + } + + return null; + } + + private function appendHierarchyRoot(array $value): string + { + $this->loadedScene->hierarchy[] = $value; + + return 'scene.' . (count($this->loadedScene->hierarchy) - 1); + } + + private function appendHierarchyChild(string $parentPath, array $value): ?string + { + if (!$this->loadedScene instanceof DTOs\SceneDTO) { + return null; + } + + $segments = explode('.', $parentPath); + + if (($segments[0] ?? null) !== 'scene') { + return null; + } + + array_shift($segments); + + if ($segments === []) { + return $this->appendHierarchyRoot($value); + } + + $hierarchy = $this->loadedScene->hierarchy; + $nodeArray = &$hierarchy; + $lastIndex = count($segments) - 1; + + foreach ($segments as $index => $segment) { if (!ctype_digit((string) $segment)) { - return false; + return null; } $numericSegment = (int) $segment; if (!isset($nodeArray[$numericSegment]) || !is_array($nodeArray[$numericSegment])) { - return false; + return null; } if ($index === $lastIndex) { - $nodeArray[$numericSegment] = $value; + $nodeArray[$numericSegment]['children'] ??= []; + + if (!is_array($nodeArray[$numericSegment]['children'])) { + return null; + } + + $nodeArray[$numericSegment]['children'][] = $value; + $newChildIndex = count($nodeArray[$numericSegment]['children']) - 1; $this->loadedScene->hierarchy = array_values($hierarchy); - return true; + return $parentPath . '.' . $newChildIndex; } if (!isset($nodeArray[$numericSegment]['children']) || !is_array($nodeArray[$numericSegment]['children'])) { - return false; + return null; } $nodeArray = &$nodeArray[$numericSegment]['children']; } - return false; + return null; } - private function deleteHierarchyNode(string $path): bool + private function moveHierarchyNodeRelative(string $path, string $targetPath, string $position): ?string + { + if ($path === $targetPath || str_starts_with($targetPath, $path . '.')) { + return null; + } + + $node = $this->extractHierarchyNode($path); + + if (!is_array($node)) { + return null; + } + + $adjustedTargetPath = $this->adjustHierarchyPathAfterRemoval($path, $targetPath); + + return match ($position) { + 'before' => $this->insertHierarchyNodeBefore($adjustedTargetPath, $node), + 'after' => $this->insertHierarchyNodeAfter($adjustedTargetPath, $node), + 'append_child' => $this->appendHierarchyChild($adjustedTargetPath, $node), + default => null, + }; + } + + private function extractHierarchyNode(string $path): ?array { if (!$this->loadedScene instanceof DTOs\SceneDTO) { - return false; + return null; } $segments = explode('.', $path); if (($segments[0] ?? null) !== 'scene') { - return false; + return null; } array_shift($segments); if ($segments === []) { - return false; + return null; } $hierarchy = $this->loadedScene->hierarchy; @@ -1439,31 +2394,274 @@ private function deleteHierarchyNode(string $path): bool foreach ($segments as $index => $segment) { if (!ctype_digit((string) $segment)) { - return false; + return null; } $numericSegment = (int) $segment; if (!isset($nodeArray[$numericSegment])) { - return false; + return null; } if ($index === $lastIndex) { + $node = $nodeArray[$numericSegment]; unset($nodeArray[$numericSegment]); $nodeArray = array_values($nodeArray); $this->loadedScene->hierarchy = array_values($hierarchy); - return true; + return is_array($node) ? $node : null; } if (!isset($nodeArray[$numericSegment]['children']) || !is_array($nodeArray[$numericSegment]['children'])) { - return false; + return null; } $nodeArray = &$nodeArray[$numericSegment]['children']; } - return false; + return null; + } + + private function adjustHierarchyPathAfterRemoval(string $removedPath, string $targetPath): string + { + $removedSegments = array_slice(explode('.', $removedPath), 1); + $targetSegments = array_slice(explode('.', $targetPath), 1); + + if ($removedSegments === [] || $targetSegments === []) { + return $targetPath; + } + + $removedIndex = (int) array_pop($removedSegments); + $removedParentSegments = $removedSegments; + $removedDepth = count($removedParentSegments); + + if (count($targetSegments) <= $removedDepth) { + return $targetPath; + } + + if (array_slice($targetSegments, 0, $removedDepth) !== $removedParentSegments) { + return $targetPath; + } + + $targetIndex = (int) ($targetSegments[$removedDepth] ?? -1); + + if ($targetIndex <= $removedIndex) { + return $targetPath; + } + + $targetSegments[$removedDepth] = (string) ($targetIndex - 1); + + return 'scene.' . implode('.', $targetSegments); + } + + private function findHierarchyNodeByPath(string $path): ?array + { + if (!$this->loadedScene instanceof DTOs\SceneDTO) { + return null; + } + + $segments = explode('.', $path); + + if (($segments[0] ?? null) !== 'scene') { + return null; + } + + array_shift($segments); + + if ($segments === []) { + return null; + } + + $nodeArray = $this->loadedScene->hierarchy; + $lastIndex = count($segments) - 1; + + foreach ($segments as $index => $segment) { + if (!ctype_digit((string) $segment)) { + return null; + } + + $numericSegment = (int) $segment; + + if (!isset($nodeArray[$numericSegment]) || !is_array($nodeArray[$numericSegment])) { + return null; + } + + if ($index === $lastIndex) { + return $nodeArray[$numericSegment]; + } + + if (!isset($nodeArray[$numericSegment]['children']) || !is_array($nodeArray[$numericSegment]['children'])) { + return null; + } + + $nodeArray = $nodeArray[$numericSegment]['children']; + } + + return null; + } + + private function resolveHierarchyInspectionType(array $value): string + { + $type = $value['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 filterRedundantDuplicationItems(array $items): array + { + $normalizedItems = []; + + foreach ($items as $item) { + $path = $item['path'] ?? null; + $value = $item['value'] ?? null; + + if (!is_string($path) || $path === '' || !is_array($value)) { + continue; + } + + $normalizedItems[$path] = [ + 'path' => $path, + 'value' => $value, + ]; + } + + $paths = array_keys($normalizedItems); + usort($paths, static function (string $left, string $right): int { + return substr_count($left, '.') <=> substr_count($right, '.'); + }); + + $filteredItems = []; + + foreach ($paths as $path) { + $hasSelectedAncestor = false; + + foreach (array_keys($filteredItems) as $keptPath) { + if (str_starts_with($path, $keptPath . '.')) { + $hasSelectedAncestor = true; + break; + } + } + + if ($hasSelectedAncestor) { + continue; + } + + $filteredItems[$path] = $normalizedItems[$path]; + } + + return array_values($filteredItems); + } + + private function buildUniqueDuplicateHierarchyName(string $path, string $originalName): string + { + $siblingNames = $this->collectSiblingHierarchyNames($path); + $trimmedName = trim($originalName); + $baseName = $trimmedName !== '' ? $trimmedName : 'Object'; + + if (preg_match('/^(.*?)(\d+)$/', $baseName, $matches) === 1) { + $prefix = $matches[1]; + $numericSuffix = $matches[2]; + $nextNumber = ((int) $numericSuffix) + 1; + $padding = strlen($numericSuffix); + + do { + $candidateName = $prefix . str_pad((string) $nextNumber, $padding, '0', STR_PAD_LEFT); + $nextNumber++; + } while (in_array($candidateName, $siblingNames, true)); + + return $candidateName; + } + + $prefix = rtrim($baseName) . ' '; + $nextNumber = 1; + + do { + $candidateName = $prefix . $nextNumber; + $nextNumber++; + } while (in_array($candidateName, $siblingNames, true)); + + return $candidateName; + } + + private function collectSiblingHierarchyNames(string $path): array + { + if (!$this->loadedScene instanceof DTOs\SceneDTO) { + return []; + } + + $segments = explode('.', $path); + + if (($segments[0] ?? null) !== 'scene') { + return []; + } + + array_shift($segments); + + if ($segments === []) { + return []; + } + + $nodeArray = $this->loadedScene->hierarchy; + $lastIndex = count($segments) - 1; + + foreach ($segments as $index => $segment) { + if (!ctype_digit((string) $segment)) { + return []; + } + + $numericSegment = (int) $segment; + + if ($index === $lastIndex) { + return array_values(array_filter(array_map( + static fn (mixed $item): ?string => is_array($item) && is_string($item['name'] ?? null) ? $item['name'] : null, + $nodeArray + ))); + } + + if (!isset($nodeArray[$numericSegment]['children']) || !is_array($nodeArray[$numericSegment]['children'])) { + return []; + } + + $nodeArray = $nodeArray[$numericSegment]['children']; + } + + return []; + } + + private function compareHierarchyPathsDescending(array $left, array $right): int + { + return $this->compareHierarchyPaths($right['path'] ?? '', $left['path'] ?? ''); + } + + private function compareHierarchyPathsAscending(string $left, string $right): int + { + return $this->compareHierarchyPaths($left, $right); + } + + private function compareHierarchyPaths(string $left, string $right): int + { + $leftSegments = array_map('intval', array_slice(explode('.', $left), 1)); + $rightSegments = array_map('intval', array_slice(explode('.', $right), 1)); + $maxLength = max(count($leftSegments), count($rightSegments)); + + for ($index = 0; $index < $maxLength; $index++) { + $leftSegment = $leftSegments[$index] ?? -1; + $rightSegment = $rightSegments[$index] ?? -1; + + if ($leftSegment !== $rightSegment) { + return $leftSegment <=> $rightSegment; + } + } + + return count($leftSegments) <=> count($rightSegments); } private function deleteAssetPath(string $path): bool @@ -1513,8 +2711,10 @@ private function createAssetUsingCliCommand(string $kind): ?array return null; } + $projectDirectory = $this->resolveAbsoluteDirectory($this->workingDirectory); + try { - if (!@chdir($this->workingDirectory)) { + if (!@chdir($projectDirectory)) { $this->consolePanel->append('[ERROR] - Failed to switch to the project directory.'); return null; } @@ -1540,6 +2740,53 @@ private function createAssetUsingCliCommand(string $kind): ?array return null; } + private function createPrefabFromHierarchyObject(array $item): ?array + { + $assetsDirectory = $this->assetsDirectoryPath; + + if (!is_string($assetsDirectory) || $assetsDirectory === '') { + $assetsDirectory = Path::resolveAssetsDirectory($this->workingDirectory); + } + + if (!is_string($assetsDirectory) || $assetsDirectory === '') { + $this->consolePanel->append('[ERROR] - Failed to resolve the assets directory for prefab export.'); + $this->pushNotification('Failed to resolve the assets directory for prefab export.', 'error'); + return null; + } + + $prefabsDirectory = Path::join($assetsDirectory, 'Prefabs'); + + if (!is_dir($prefabsDirectory) && !@mkdir($prefabsDirectory, 0777, true) && !is_dir($prefabsDirectory)) { + $this->consolePanel->append('[ERROR] - Failed to create the Prefabs directory.'); + $this->pushNotification('Failed to create the Prefabs directory.', 'error'); + return null; + } + + if (!isset($this->prefabWriter)) { + $this->prefabWriter = new PrefabWriter(); + } + + $prefabPath = $this->buildUniquePrefabPath($prefabsDirectory, $item['name'] ?? null); + + if (!$this->prefabWriter->save($prefabPath, $item)) { + $this->consolePanel->append('[ERROR] - Failed to create prefab ' . basename($prefabPath) . '.'); + $this->pushNotification('Failed to create prefab ' . basename($prefabPath) . '.', 'error'); + return null; + } + + $relativePath = $this->buildRelativeAssetPath($prefabPath); + $this->consolePanel->append('[INFO] - Created prefab ' . $relativePath . '.'); + $this->pushNotification('Created prefab ' . basename($prefabPath) . '.', 'success'); + + return [ + 'name' => basename($prefabPath), + 'path' => $prefabPath, + 'relativePath' => $relativePath, + 'isDirectory' => false, + 'children' => [], + ]; + } + private function resolveAssetCreationDefinition(string $kind): ?array { return match ($kind) { @@ -1598,6 +2845,7 @@ private function runAssetGenerationCommand(array $definition, string $candidateN ? preg_replace('/\s+/', ' ', strip_tags($commandOutput)) : 'Asset generation failed.'; $this->consolePanel->append('[ERROR] - ' . $message); + $this->pushNotification($message, 'error'); return ['status' => 'fatal']; } @@ -1606,6 +2854,7 @@ private function runAssetGenerationCommand(array $definition, string $candidateN if (!is_string($relativeFilename) || $relativeFilename === '') { $this->consolePanel->append('[ERROR] - Asset generation succeeded but the created file could not be resolved.'); + $this->pushNotification('Created asset could not be resolved.', 'error'); return ['status' => 'fatal']; } @@ -1613,6 +2862,7 @@ private function runAssetGenerationCommand(array $definition, string $candidateN if (!is_string($absolutePath) || !is_file($absolutePath)) { $this->consolePanel->append('[ERROR] - Generated asset file could not be found.'); + $this->pushNotification('Generated asset file could not be found.', 'error'); return ['status' => 'fatal']; } @@ -1620,10 +2870,12 @@ private function runAssetGenerationCommand(array $definition, string $candidateN if (!is_string($finalPath) || !is_file($finalPath)) { $this->consolePanel->append('[ERROR] - Generated asset file could not be activated in the current assets directory.'); + $this->pushNotification('Generated asset file could not be activated.', 'error'); return ['status' => 'fatal']; } $this->consolePanel->append('[INFO] - Created asset ' . $this->buildRelativeAssetPath($finalPath) . '.'); + $this->pushNotification('Created asset ' . basename($finalPath) . '.', 'success'); return [ 'status' => 'success', @@ -1653,16 +2905,18 @@ private function extractCreatedRelativeFilename(string $output): ?string private function resolveGeneratedAssetAbsolutePath(string $relativeFilename): ?string { $normalizedRelativeFilename = str_replace('\\', '/', $relativeFilename); + $projectDirectory = $this->resolveAbsoluteDirectory($this->workingDirectory); $candidatePaths = [ - Path::join($this->workingDirectory, $normalizedRelativeFilename), + $this->resolveAbsolutePath($normalizedRelativeFilename, $projectDirectory), ]; if (is_string($this->assetsDirectoryPath) && $this->assetsDirectoryPath !== '') { + $assetsDirectory = $this->resolveAbsolutePath($this->assetsDirectoryPath, $projectDirectory); $segments = explode('/', $normalizedRelativeFilename); if (count($segments) > 1 && strcasecmp($segments[0], 'assets') === 0) { array_shift($segments); - $candidatePaths[] = Path::join($this->assetsDirectoryPath, ...$segments); + $candidatePaths[] = Path::join($assetsDirectory, ...$segments); } } @@ -1672,6 +2926,47 @@ private function resolveGeneratedAssetAbsolutePath(string $relativeFilename): ?s } } + $relativeTail = ltrim(preg_replace('/^assets\//i', '', $normalizedRelativeFilename) ?? $normalizedRelativeFilename, '/'); + $relativeTailSegments = $relativeTail !== '' ? explode('/', $relativeTail) : []; + $relativeTailBasename = $relativeTailSegments !== [] ? end($relativeTailSegments) : null; + + $searchRoots = []; + + if (is_string($this->assetsDirectoryPath) && $this->assetsDirectoryPath !== '') { + $searchRoots[] = $this->resolveAbsolutePath($this->assetsDirectoryPath, $projectDirectory); + } + + $searchRoots[] = Path::resolveAssetsDirectory($projectDirectory); + $searchRoots = array_values(array_unique(array_filter($searchRoots, static fn (mixed $root): bool => is_string($root) && $root !== '' && is_dir($root)))); + + foreach ($searchRoots as $searchRoot) { + $iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($searchRoot, RecursiveDirectoryIterator::SKIP_DOTS), + RecursiveIteratorIterator::LEAVES_ONLY + ); + + foreach ($iterator as $entry) { + if (!$entry->isFile()) { + continue; + } + + $entryPath = Path::normalize($entry->getPathname()); + + if ($relativeTailBasename !== null && basename($entryPath) !== $relativeTailBasename) { + continue; + } + + $relativeToSearchRoot = ltrim(substr($entryPath, strlen($searchRoot)), '/'); + + if ( + $relativeTail !== '' + && str_ends_with(str_replace('\\', '/', $relativeToSearchRoot), $relativeTail) + ) { + return $entryPath; + } + } + } + return null; } @@ -1681,6 +2976,8 @@ private function relocateGeneratedAssetToActiveRoot(string $absolutePath, string return $absolutePath; } + $projectDirectory = $this->resolveAbsoluteDirectory($this->workingDirectory); + $activeAssetsDirectory = $this->resolveAbsolutePath($this->assetsDirectoryPath, $projectDirectory); $normalizedRelativeFilename = str_replace('\\', '/', $relativeFilename); $segments = explode('/', $normalizedRelativeFilename); $generatedRootSegment = $segments[0] ?? 'Assets'; @@ -1690,7 +2987,7 @@ private function relocateGeneratedAssetToActiveRoot(string $absolutePath, string } array_shift($segments); - $targetPath = Path::join($this->assetsDirectoryPath, ...$segments); + $targetPath = Path::join($activeAssetsDirectory, ...$segments); if ($targetPath === $absolutePath) { return $absolutePath; @@ -1708,7 +3005,7 @@ private function relocateGeneratedAssetToActiveRoot(string $absolutePath, string return $absolutePath; } - $generatedAssetsRoot = Path::join($this->workingDirectory, $generatedRootSegment); + $generatedAssetsRoot = Path::join($projectDirectory, $generatedRootSegment); if (str_starts_with($absolutePath, $generatedAssetsRoot)) { $this->cleanupEmptyDirectories(dirname($absolutePath), $generatedAssetsRoot); @@ -1967,11 +3264,37 @@ private function buildRelativeAssetPath(string $absolutePath): string return basename($absolutePath); } + $assetsDirectory = $this->resolveAbsolutePath($assetsDirectory, $this->workingDirectory); $relativePath = substr($absolutePath, strlen($assetsDirectory)); return ltrim(str_replace('\\', '/', (string) $relativePath), '/'); } + private function buildUniquePrefabPath(string $prefabsDirectory, mixed $displayName): string + { + $baseName = is_string($displayName) ? trim($displayName) : ''; + $baseName = $baseName !== '' ? to_kebab_case($baseName) : 'new-prefab'; + $baseName = preg_replace('/[^A-Za-z0-9]+/', '-', $baseName) ?? $baseName; + $baseName = trim($baseName, '-'); + $baseName = strtolower($baseName); + $baseName = $baseName !== '' ? $baseName : 'new-prefab'; + $candidatePath = Path::join($prefabsDirectory, $baseName . '.prefab.php'); + + if (!file_exists($candidatePath)) { + return $candidatePath; + } + + for ($index = 2; $index <= 200; $index++) { + $candidatePath = Path::join($prefabsDirectory, $baseName . '-' . $index . '.prefab.php'); + + if (!file_exists($candidatePath)) { + return $candidatePath; + } + } + + return Path::join($prefabsDirectory, $baseName . '-' . uniqid() . '.prefab.php'); + } + private function updateSceneAssetReferences(string $oldRelativePath, string $newRelativePath): bool { if (!$this->loadedScene instanceof DTOs\SceneDTO) { @@ -2166,10 +3489,447 @@ private function isEditableSpriteAsset(?array $asset): bool return in_array($extension, ['texture', 'tmap'], true); } + private function openAssetInConfiguredEditor(array $asset): bool + { + $assetPath = is_string($asset['path'] ?? null) ? $asset['path'] : null; + + if (!is_string($assetPath) || $assetPath === '') { + $this->consolePanel->append('[ERROR] - Selected script path could not be resolved.'); + $this->pushNotification('Selected script path could not be resolved.', 'error'); + return false; + } + + $command = $this->buildExternalEditorCommand($assetPath); + + if ($command === null) { + $this->consolePanel->append('[ERROR] - No editor command found. Configure editor.externalEditor or set $VISUAL/$EDITOR.'); + $this->pushNotification('No editor command found. Configure editor.externalEditor or set $VISUAL/$EDITOR.', 'error'); + return false; + } + + $workingDirectory = dirname($assetPath); + $editorMode = $this->resolveExternalEditorMode($command); + $shouldBlock = $this->shouldBlockOnExternalEditor($command, $editorMode); + $opened = $editorMode === 'terminal' + ? ( + $this->canUseTmuxIntegration() + ? $this->launchCommandInTmuxWindow( + $command, + $workingDirectory, + self::buildTmuxLabel((string) pathinfo($assetPath, PATHINFO_FILENAME), 'sendama-script') + ) + : $this->launchForegroundExternalCommand($command, $workingDirectory) + ) + : ( + $shouldBlock + ? $this->launchForegroundExternalCommand($command, $workingDirectory) + : $this->launchDetachedExternalCommand($command, $workingDirectory) + ); + + if ($opened) { + $this->consolePanel->append('[INFO] - Opened script in editor: ' . ($asset['relativePath'] ?? basename($assetPath)) . '.'); + return true; + } + + $this->consolePanel->append('[ERROR] - Failed to open script in editor.'); + $this->pushNotification('Failed to open script in editor.', 'error'); + + return false; + } + + private function buildExternalEditorCommand(string $assetPath): ?string + { + $configuredEditor = ''; + + if ( + isset($this->settings) + && is_string($this->settings->externalEditorCommand) + && trim($this->settings->externalEditorCommand) !== '' + ) { + $configuredEditor = trim($this->settings->externalEditorCommand); + } else { + $configuredEditor = trim( + (string) ( + $_ENV['VISUAL'] + ?? getenv('VISUAL') + ?? $_ENV['EDITOR'] + ?? getenv('EDITOR') + ?? '' + ) + ); + } + + if ($configuredEditor !== '') { + return self::buildEditorCommandFromTemplate($configuredEditor, $assetPath); + } + + $fallbackEditor = self::findFirstAvailableCommand(['vim', 'vi', 'nano', 'nvim']); + + if ($fallbackEditor === null) { + return null; + } + + return $fallbackEditor . ' ' . escapeshellarg($assetPath); + } + + private function resolveExternalEditorMode(string $command): string + { + $configuredMode = isset($this->settings) + ? $this->settings->externalEditorMode + : EditorSettings::DEFAULT_EXTERNAL_EDITOR_MODE; + + if (in_array($configuredMode, ['terminal', 'gui'], true)) { + return $configuredMode; + } + + return self::isLikelyGuiEditorCommand($command) ? 'gui' : 'terminal'; + } + + private function shouldBlockOnExternalEditor(string $command, string $editorMode): bool + { + if (isset($this->settings) && is_bool($this->settings->externalEditorBlocking)) { + return $this->settings->externalEditorBlocking; + } + + if ($editorMode === 'terminal') { + return true; + } + + return preg_match('/(^|\s)(--wait|-w)(\s|$)/', $command) === 1; + } + + private function launchForegroundExternalCommand(string $command, string $workingDirectory): bool + { + $this->suspendTerminalForExternalCommand(); + + try { + passthru( + sprintf( + 'cd %s && %s', + escapeshellarg($workingDirectory), + $command, + ), + $exitCode, + ); + } finally { + $this->resumeTerminalAfterExternalCommand(); + } + + return $exitCode === 0; + } + + private function launchDetachedExternalCommand(string $command, string $workingDirectory): bool + { + exec( + sprintf( + 'sh -lc %s >/dev/null 2>&1 &', + escapeshellarg(sprintf( + 'cd %s && %s', + escapeshellarg($workingDirectory), + $command, + )), + ), + result_code: $exitCode, + ); + + return $exitCode === 0; + } + + private function suspendTerminalForExternalCommand(): void + { + Console::disableMouseReporting(); + Console::cursor()->show(); + InputManager::disableNonBlockingMode(); + InputManager::enableEcho(); + } + + private function resumeTerminalAfterExternalCommand(): void + { + Console::restoreSettings(); + $this->refreshTerminalSize(force: true); + Console::setName('Sendama Editor | ' . ($this->gameSettings?->name ?? 'Unknown Game')); + Console::setSize($this->terminalWidth, $this->terminalHeight); + Console::cursor()->hide(); + Console::enableMouseReporting(); + InputManager::disableEcho(); + InputManager::enableNonBlockingMode(); + $this->shouldRefreshBackgroundUnderModal = true; + } + + private function startManagedTmuxPlayPaneIfAvailable(): bool + { + if (!$this->canUseTmuxIntegration()) { + return false; + } + + $command = $this->buildTmuxPlayCommand(); + + if ($command === null) { + $this->consolePanel->append('[WARN] - Unable to resolve the Sendama CLI entrypoint for tmux play mode.'); + return false; + } + + $this->stopManagedTmuxPlayPane(); + $paneCommand = self::buildTmuxSplitPaneCommand($this->workingDirectory, $command); + $output = []; + $exitCode = 0; + + exec($paneCommand, $output, $exitCode); + + if ($exitCode !== 0) { + $this->consolePanel->append('[WARN] - Failed to open a tmux pane for play mode.'); + return false; + } + + $paneId = trim(implode("\n", $output)); + + if ($paneId === '') { + $this->consolePanel->append('[WARN] - Tmux play pane started without returning a pane id.'); + return false; + } + + $this->tmuxPlayPaneId = $paneId; + $this->disableTmuxStatusBarForPlayPaneIfNeeded(); + $this->consolePanel->append('[INFO] - Play mode launched in tmux pane ' . $paneId . '.'); + + return true; + } + + private function stopManagedTmuxPlayPane(): void + { + if (is_string($this->tmuxPlayPaneId) && $this->tmuxPlayPaneId !== '' && self::isTmuxInstalled()) { + exec(sprintf('tmux kill-pane -t %s 2>/dev/null', escapeshellarg($this->tmuxPlayPaneId))); + } + + $this->tmuxPlayPaneId = null; + $this->restoreTmuxStatusBarAfterPlayPane(); + } + + private function disableTmuxStatusBarForPlayPaneIfNeeded(): void + { + if ( + !$this->canUseTmuxIntegration() + || ($this->gameSettings?->isDebugMode ?? false) + || $this->tmuxPreviousStatusValue !== null + ) { + return; + } + + $statusValue = trim((string) shell_exec('tmux show-options -v status 2>/dev/null')); + + if ($statusValue === '') { + return; + } + + $this->tmuxPreviousStatusValue = $statusValue; + exec('tmux set-option status off 2>/dev/null'); + } + + private function restoreTmuxStatusBarAfterPlayPane(): void + { + if ( + !$this->canUseTmuxIntegration() + || !is_string($this->tmuxPreviousStatusValue) + || $this->tmuxPreviousStatusValue === '' + ) { + $this->tmuxPreviousStatusValue = null; + return; + } + + exec(sprintf( + 'tmux set-option status %s 2>/dev/null', + escapeshellarg($this->tmuxPreviousStatusValue), + )); + $this->tmuxPreviousStatusValue = null; + } + + private function buildTmuxPlayCommand(): ?string + { + $cliEntrypoint = $this->resolveSendamaCliEntrypoint(); + + if ($cliEntrypoint === null) { + return null; + } + + return sprintf( + '%s=1 %s %s play --directory %s', + self::TMUX_GAME_CHILD_ENV_KEY, + escapeshellarg(PHP_BINARY), + escapeshellarg($cliEntrypoint), + escapeshellarg($this->workingDirectory), + ); + } + + private function resolveSendamaCliEntrypoint(): ?string + { + $candidates = [ + Path::join(dirname(__DIR__, 2), 'bin', 'sendama'), + ]; + $argvEntrypoint = $_SERVER['argv'][0] ?? null; + + if (is_string($argvEntrypoint) && $argvEntrypoint !== '') { + $candidates[] = $this->resolveAbsolutePath($argvEntrypoint, getcwd() ?: $this->workingDirectory); + } + + foreach (array_unique($candidates) as $candidate) { + if (is_string($candidate) && $candidate !== '' && is_file($candidate)) { + return Path::normalize($candidate); + } + } + + return null; + } + + private function launchCommandInTmuxWindow(string $command, string $workingDirectory, string $windowName): bool + { + if (!$this->canUseTmuxIntegration()) { + return false; + } + + exec( + self::buildTmuxNewWindowCommand($windowName, $workingDirectory, $command), + result_code: $exitCode, + ); + + return $exitCode === 0; + } + + private function canUseTmuxIntegration(): bool + { + return self::isTmuxInstalled() && $this->isInsideTmuxSession(); + } + + private function isInsideTmuxSession(): bool + { + $tmuxValue = getenv('TMUX'); + + return is_string($tmuxValue) && trim($tmuxValue) !== ''; + } + + private static function isTmuxInstalled(): bool + { + $tmuxPath = shell_exec('command -v tmux 2>/dev/null'); + + return is_string($tmuxPath) && trim($tmuxPath) !== ''; + } + + private static function findFirstAvailableCommand(array $commands): ?string + { + foreach ($commands as $command) { + if (!is_string($command) || $command === '') { + continue; + } + + $resolvedCommand = shell_exec('command -v ' . escapeshellarg($command) . ' 2>/dev/null'); + + if (is_string($resolvedCommand) && trim($resolvedCommand) !== '') { + return $command; + } + } + + return null; + } + + private static function buildEditorCommandFromTemplate(string $commandTemplate, string $assetPath): string + { + $replacements = [ + '{path}' => escapeshellarg($assetPath), + '{file}' => escapeshellarg($assetPath), + '{dir}' => escapeshellarg(dirname($assetPath)), + '{name}' => escapeshellarg(basename($assetPath)), + ]; + + foreach ($replacements as $placeholder => $replacement) { + if (str_contains($commandTemplate, $placeholder)) { + return strtr($commandTemplate, $replacements); + } + } + + return $commandTemplate . ' ' . escapeshellarg($assetPath); + } + + private static function isLikelyGuiEditorCommand(string $command): bool + { + $binary = self::extractCommandBinary($command); + + if ($binary === null) { + return false; + } + + return in_array($binary, [ + 'code', + 'code-insiders', + 'codium', + 'cursor', + 'fleet', + 'idea', + 'phpstorm', + 'pycharm', + 'webstorm', + 'goland', + 'clion', + 'rubymine', + 'zed', + 'subl', + 'sublime_text', + 'mate', + 'open', + ], true); + } + + private static function extractCommandBinary(string $command): ?string + { + $trimmedCommand = ltrim($command); + + if ($trimmedCommand === '') { + return null; + } + + if (!preg_match('/^([^\s]+)/', $trimmedCommand, $matches)) { + return null; + } + + return strtolower(basename(trim($matches[1], "'\""))); + } + + private static function buildTmuxNewWindowCommand(string $windowName, string $workingDirectory, string $command): string + { + return sprintf( + 'tmux new-window -n %s -c %s %s', + escapeshellarg($windowName), + escapeshellarg($workingDirectory), + escapeshellarg($command), + ); + } + + private static function buildTmuxSplitPaneCommand(string $workingDirectory, string $command): string + { + return sprintf( + 'tmux split-window -v -d -P -F %s -p %d -c %s %s', + escapeshellarg('#{pane_id}'), + self::TMUX_PLAY_PANE_PERCENT, + escapeshellarg($workingDirectory), + escapeshellarg($command), + ); + } + + private static function buildTmuxLabel(string $label, string $fallback): string + { + $sanitizedLabel = preg_replace('/[^A-Za-z0-9_-]+/', '-', trim($label)) ?? ''; + $sanitizedLabel = trim($sanitizedLabel, '-_'); + + if ($sanitizedLabel === '') { + return $fallback; + } + + return substr($sanitizedLabel, 0, 30); + } + private function saveLoadedScene(): void { if (!$this->loadedScene instanceof DTOs\SceneDTO) { $this->consolePanel->append('[INFO] - No scene loaded to save.'); + $this->pushNotification('No scene loaded to save.', 'info'); return; } @@ -2203,12 +3963,14 @@ private function saveLoadedScene(): void $this->loadedScene->sourceData = $snapshot; $this->syncScenePanels(false); $this->consolePanel->append('[INFO] - Saved scene ' . $this->loadedScene->name . '.scene.php'); + $this->pushNotification('Saved scene ' . $this->loadedScene->name . '.scene.php', 'success'); return; } $this->loadedScene->isDirty = $sceneWasDirty; $this->syncScenePanels($sceneWasDirty); $this->consolePanel->append('[ERROR] - Failed to save scene.'); + $this->pushNotification('Failed to save scene.', 'error'); } private function applySceneMutation(array $value): bool @@ -2268,6 +4030,17 @@ private function buildSceneInspectionValue(): array ]; } + private function buildSceneInspectionTarget(): array + { + return [ + 'context' => 'scene', + 'name' => $this->loadedScene?->name ?? 'Scene', + 'type' => 'Scene', + 'path' => 'scene', + 'value' => $this->buildSceneInspectionValue(), + ]; + } + private function normalizeEnvironmentTileMapPath(mixed $value): string { if (!is_string($value)) { @@ -2317,6 +4090,11 @@ private function syncScenePanels(bool $isDirty): void $this->mainPanel->setEnvironmentTileMapPath($this->loadedScene->environmentTileMapPath); } + private function pushNotification(string $message, string $status = 'info'): void + { + $this->snackbar->enqueue($message, $status); + } + private function resolveTargetSceneSourcePath(DTOs\SceneDTO $scene): ?string { if (!is_string($scene->sourcePath) || $scene->sourcePath === '') { diff --git a/src/Editor/EditorSettings.php b/src/Editor/EditorSettings.php index db64287..5814602 100644 --- a/src/Editor/EditorSettings.php +++ b/src/Editor/EditorSettings.php @@ -8,6 +8,8 @@ class EditorSettings { public const float DEFAULT_CONSOLE_REFRESH_INTERVAL_SECONDS = 5.0; + public const float DEFAULT_NOTIFICATION_DURATION_SECONDS = 4.0; + public const string DEFAULT_EXTERNAL_EDITOR_MODE = 'auto'; protected(set) int $width { get { @@ -27,6 +29,10 @@ class EditorSettings public function __construct( public readonly EditorSceneSettings $scenes, public readonly float $consoleRefreshIntervalSeconds = self::DEFAULT_CONSOLE_REFRESH_INTERVAL_SECONDS, + public readonly float $notificationDurationSeconds = self::DEFAULT_NOTIFICATION_DURATION_SECONDS, + public readonly ?string $externalEditorCommand = null, + public readonly string $externalEditorMode = self::DEFAULT_EXTERNAL_EDITOR_MODE, + public readonly ?bool $externalEditorBlocking = null, ) { $terminalSize = get_max_terminal_size(); @@ -71,28 +77,133 @@ public static function fromArray(array $data): self $editorData = is_array($data['editor'] ?? null) ? $data['editor'] : $data; $scenesData = $editorData['scenes'] ?? $data['scenes'] ?? []; $consoleData = is_array($editorData['console'] ?? null) ? $editorData['console'] : []; + $notificationData = is_array($editorData['notifications'] ?? null) ? $editorData['notifications'] : []; + $externalEditorData = self::normalizeExternalEditorData( + $editorData['externalEditor'] + ?? $editorData['defaultEditor'] + ?? $data['externalEditor'] + ?? $data['defaultEditor'] + ?? null + ); $refreshInterval = $consoleData['refreshInterval'] ?? $editorData['consoleRefreshInterval'] ?? self::DEFAULT_CONSOLE_REFRESH_INTERVAL_SECONDS; + $notificationDuration = $notificationData['duration'] + ?? $editorData['notificationDuration'] + ?? self::DEFAULT_NOTIFICATION_DURATION_SECONDS; + $externalEditorCommand = $externalEditorData['command'] + ?? self::normalizeOptionalString( + $editorData['externalEditorCommand'] + ?? $editorData['defaultEditorCommand'] + ?? $data['externalEditorCommand'] + ?? $data['defaultEditorCommand'] + ?? null + ); + $externalEditorMode = self::normalizeExternalEditorMode( + $externalEditorData['mode'] + ?? $editorData['externalEditorMode'] + ?? $editorData['defaultEditorMode'] + ?? $data['externalEditorMode'] + ?? $data['defaultEditorMode'] + ?? self::DEFAULT_EXTERNAL_EDITOR_MODE + ); + $externalEditorBlocking = self::normalizeNullableBool( + $externalEditorData['blocking'] + ?? $editorData['externalEditorBlocking'] + ?? $editorData['defaultEditorBlocking'] + ?? $data['externalEditorBlocking'] + ?? $data['defaultEditorBlocking'] + ?? null + ); return new self( scenes: EditorSceneSettings::fromArray(is_array($scenesData) ? $scenesData : []), - consoleRefreshIntervalSeconds: self::normalizeRefreshInterval($refreshInterval), + consoleRefreshIntervalSeconds: self::normalizePositiveFloat($refreshInterval, self::DEFAULT_CONSOLE_REFRESH_INTERVAL_SECONDS), + notificationDurationSeconds: self::normalizePositiveFloat($notificationDuration, self::DEFAULT_NOTIFICATION_DURATION_SECONDS), + externalEditorCommand: $externalEditorCommand, + externalEditorMode: $externalEditorMode, + externalEditorBlocking: $externalEditorBlocking, ); } - private static function normalizeRefreshInterval(mixed $refreshInterval): float + private static function normalizePositiveFloat(mixed $value, float $fallback): float { - if (!is_numeric($refreshInterval)) { - return self::DEFAULT_CONSOLE_REFRESH_INTERVAL_SECONDS; + if (!is_numeric($value)) { + return $fallback; } - $normalizedRefreshInterval = (float) $refreshInterval; + $normalizedValue = (float) $value; - if ($normalizedRefreshInterval <= 0) { - return self::DEFAULT_CONSOLE_REFRESH_INTERVAL_SECONDS; + if ($normalizedValue <= 0) { + return $fallback; } - return $normalizedRefreshInterval; + return $normalizedValue; + } + + /** + * @return array{command:?string, mode:?string, blocking:?bool} + */ + private static function normalizeExternalEditorData(mixed $value): array + { + if (is_string($value)) { + return [ + 'command' => self::normalizeOptionalString($value), + 'mode' => null, + 'blocking' => null, + ]; + } + + if (!is_array($value)) { + return [ + 'command' => null, + 'mode' => null, + 'blocking' => null, + ]; + } + + return [ + 'command' => self::normalizeOptionalString($value['command'] ?? $value['cmd'] ?? null), + 'mode' => is_string($value['mode'] ?? null) ? $value['mode'] : null, + 'blocking' => self::normalizeNullableBool($value['blocking'] ?? $value['wait'] ?? null), + ]; + } + + private static function normalizeOptionalString(mixed $value): ?string + { + if (!is_string($value)) { + return null; + } + + $normalizedValue = trim($value); + + return $normalizedValue !== '' ? $normalizedValue : null; + } + + private static function normalizeExternalEditorMode(mixed $value): string + { + $normalizedValue = strtolower(trim((string) $value)); + + return in_array($normalizedValue, ['auto', 'terminal', 'gui'], true) + ? $normalizedValue + : self::DEFAULT_EXTERNAL_EDITOR_MODE; + } + + private static function normalizeNullableBool(mixed $value): ?bool + { + return match (true) { + is_bool($value) => $value, + is_string($value) => match (strtolower(trim($value))) { + '1', 'true', 'yes', 'on' => true, + '0', 'false', 'no', 'off' => false, + default => null, + }, + is_int($value) => match ($value) { + 1 => true, + 0 => false, + default => null, + }, + default => null, + }; } } diff --git a/src/Editor/IO/Input.php b/src/Editor/IO/Input.php index 9da1d34..9e33773 100644 --- a/src/Editor/IO/Input.php +++ b/src/Editor/IO/Input.php @@ -105,6 +105,11 @@ public static function isLeftMouseButtonDown(): bool return InputManager::isLeftMouseButtonDown(); } + public static function isLeftMouseButtonPressed(): bool + { + return InputManager::isLeftMouseButtonPressed(); + } + /** * Checks if the given button is pressed. * diff --git a/src/Editor/IO/InputManager.php b/src/Editor/IO/InputManager.php index b88c6f7..51a0242 100644 --- a/src/Editor/IO/InputManager.php +++ b/src/Editor/IO/InputManager.php @@ -162,6 +162,12 @@ public static function isLeftMouseButtonDown(): bool return self::$mouseEvent?->buttonIndex === 0; } + public static function isLeftMouseButtonPressed(): bool + { + return self::$mouseEvent?->buttonIndex === 0 + && self::$mouseEvent?->action === 'Pressed'; + } + /** * Takes the raw string value of a key press and returns it as a simplified string. * diff --git a/src/Editor/PrefabLoader.php b/src/Editor/PrefabLoader.php index ac88b76..c8ee0be 100644 --- a/src/Editor/PrefabLoader.php +++ b/src/Editor/PrefabLoader.php @@ -120,7 +120,7 @@ function build_vector(mixed $value, array $default = ['x' => 0, 'y' => 0]): ?obj return null; } - $vectorValue = is_array($value) ? $value : $default; + $vectorValue = parse_vector_value($value) ?? $default; return new \Sendama\Engine\Core\Vector2( (int) ($vectorValue['x'] ?? $default['x']), @@ -128,6 +128,100 @@ function build_vector(mixed $value, array $default = ['x' => 0, 'y' => 0]): ?obj ); } +function parse_vector_value(mixed $value): ?array +{ + if (is_array($value)) { + if (array_is_list($value)) { + return [ + 'x' => (int) ($value[0] ?? 0), + 'y' => (int) ($value[1] ?? 0), + ]; + } + + if (array_key_exists('x', $value) || array_key_exists('y', $value)) { + return [ + 'x' => (int) ($value['x'] ?? 0), + 'y' => (int) ($value['y'] ?? 0), + ]; + } + + return null; + } + + if (is_object($value)) { + if (method_exists($value, 'getX') && method_exists($value, 'getY')) { + return [ + 'x' => (int) $value->getX(), + 'y' => (int) $value->getY(), + ]; + } + + return parse_vector_value((array) $value); + } + + if (!is_string($value)) { + return null; + } + + $normalizedValue = trim($value); + + if ($normalizedValue === '') { + return null; + } + + $decodedValue = json_decode($normalizedValue, true); + + if (is_array($decodedValue)) { + return parse_vector_value($decodedValue); + } + + if ( + preg_match('/^\[\s*(-?\d+)\s*,\s*(-?\d+)\s*\]$/', $normalizedValue, $matches) === 1 + || preg_match('/^\s*(-?\d+)\s*,\s*(-?\d+)\s*$/', $normalizedValue, $matches) === 1 + ) { + return [ + 'x' => (int) $matches[1], + 'y' => (int) $matches[2], + ]; + } + + return null; +} + +function is_vector_field_type(?string $fieldType): bool +{ + if (!is_string($fieldType) || trim($fieldType) === '') { + return false; + } + + $normalizedTypes = array_map( + static fn (string $type): string => ltrim(trim($type), '\\'), + explode('|', $fieldType), + ); + + return in_array('Sendama\Engine\Core\Vector2', $normalizedTypes, true); +} + +function normalize_component_data_by_field_types(array $componentData, array $fieldTypes): array +{ + $normalizedData = $componentData; + + foreach ($normalizedData as $key => $value) { + $fieldType = $fieldTypes[$key] ?? null; + + if (is_string($fieldType) && is_vector_field_type($fieldType)) { + $normalizedData[$key] = parse_vector_value($value) ?? $value; + continue; + } + + if (is_array($fieldType) && is_array($value) && !array_is_list($value)) { + $normalizedData[$key] = normalize_component_data_by_field_types($value, $fieldType); + } + } + + return $normalizedData; +} + function build_dummy_game_object(array $item): ?object { if ( @@ -203,6 +297,64 @@ function extract_component_serializable_data(object $component): array return $serializedData; } +function extract_component_editor_field_types(object $component): array +{ + $fieldTypes = []; + $reflection = new ReflectionObject($component); + + foreach ($reflection->getProperties() as $property) { + $isSerializable = $property->isPublic() + || $property->getAttributes('Sendama\Engine\Core\Behaviours\Attributes\SerializeField') !== []; + + if (!$isSerializable) { + continue; + } + + if (method_exists($property, 'isVirtual') && $property->isVirtual()) { + continue; + } + + $resolvedType = resolve_property_type($property); + + if ($resolvedType !== null) { + $fieldTypes[$property->getName()] = $resolvedType; + } + } + + return $fieldTypes; +} + +function resolve_property_type(ReflectionProperty $property): ?string +{ + $type = $property->getType(); + + if ($type instanceof ReflectionNamedType) { + $resolvedType = $type->getName(); + + if ($type->allowsNull() && $resolvedType !== 'null') { + return $resolvedType . '|null'; + } + + return $resolvedType; + } + + if ($type instanceof ReflectionUnionType) { + $resolvedTypes = []; + + foreach ($type->getTypes() as $namedType) { + if ($namedType instanceof ReflectionNamedType) { + $resolvedTypes[] = $namedType->getName(); + } + } + + $resolvedTypes = array_values(array_unique(array_filter($resolvedTypes))); + + return $resolvedTypes !== [] ? implode('|', $resolvedTypes) : null; + } + + return null; +} + function merge_component_data(array $defaultData, array $existingData): array { if ($existingData === []) { @@ -239,11 +391,30 @@ function enrich_component_entry(mixed $component, array $item): mixed $defaultComponentData = is_string($componentClass) && $componentClass !== '' ? serialize_component_data($componentClass, $item) : null; + $defaultComponentFieldTypes = is_string($componentClass) && $componentClass !== '' + && class_exists($componentClass) + && class_exists('\Sendama\Engine\Core\Component') + && is_a($componentClass, '\Sendama\Engine\Core\Component', true) + && !empty($gameObject = build_dummy_game_object($item)) + ? (function () use ($componentClass, $gameObject): array { + try { + $componentInstance = new $componentClass($gameObject); + + return extract_component_editor_field_types($componentInstance); + } catch (Throwable) { + return []; + } + })() + : []; if (array_key_exists('data', $component)) { $existingComponentData = is_array($component['data']) ? normalize_editor_value($component['data']) : normalize_editor_value((array) $component['data']); + $existingComponentData = normalize_component_data_by_field_types( + is_array($existingComponentData) ? $existingComponentData : [], + $defaultComponentFieldTypes, + ); if (is_array($defaultComponentData)) { $component['data'] = merge_component_data($defaultComponentData, $existingComponentData); @@ -251,6 +422,10 @@ function enrich_component_entry(mixed $component, array $item): mixed $component['data'] = $existingComponentData; } + if ($defaultComponentFieldTypes !== []) { + $component['__editorFieldTypes'] = $defaultComponentFieldTypes; + } + return $component; } @@ -258,6 +433,10 @@ function enrich_component_entry(mixed $component, array $item): mixed $component['data'] = $defaultComponentData; } + if ($defaultComponentFieldTypes !== []) { + $component['__editorFieldTypes'] = $defaultComponentFieldTypes; + } + return $component; } diff --git a/src/Editor/PrefabWriter.php b/src/Editor/PrefabWriter.php index 57e6e8a..c5e37fa 100644 --- a/src/Editor/PrefabWriter.php +++ b/src/Editor/PrefabWriter.php @@ -13,7 +13,7 @@ public function save(string $prefabPath, array $prefabData): bool public function serialize(array $prefabData): string { - return "exportValue($prefabData) . ";\n"; + return "exportValue($this->stripEditorOnlyMetadata($prefabData)) . ";\n"; } private function exportValue(mixed $value, int $depth = 0, ?string $contextKey = null): string @@ -71,4 +71,23 @@ private function exportClassReference(string $value): string return var_export($value, true); } + + private function stripEditorOnlyMetadata(mixed $value): mixed + { + if (!is_array($value)) { + return $value; + } + + $sanitizedValue = []; + + foreach ($value as $key => $item) { + if (is_string($key) && str_starts_with($key, '__editor')) { + continue; + } + + $sanitizedValue[$key] = $this->stripEditorOnlyMetadata($item); + } + + return $sanitizedValue; + } } diff --git a/src/Editor/SceneLoader.php b/src/Editor/SceneLoader.php index 408ed5a..5cc451b 100644 --- a/src/Editor/SceneLoader.php +++ b/src/Editor/SceneLoader.php @@ -23,7 +23,18 @@ public function load(EditorSceneSettings $sceneSettings): ?SceneDTO return null; } - $sceneDataBundle = $this->loadSceneDataBundle($scenePath); + return $this->loadFromPath($scenePath); + } + + public function loadFromPath(string $scenePath): ?SceneDTO + { + $normalizedScenePath = Path::normalize(trim($scenePath)); + + if ($normalizedScenePath === '' || !is_file($normalizedScenePath)) { + return null; + } + + $sceneDataBundle = $this->loadSceneDataBundle($normalizedScenePath); $sceneData = $sceneDataBundle['editor'] ?? []; $sourceSceneData = $sceneDataBundle['source'] ?? $sceneData; $normalizedEnvironmentTileMapPath = $this->normalizeEnvironmentTileMapPath( @@ -38,14 +49,14 @@ public function load(EditorSceneSettings $sceneSettings): ?SceneDTO $sourceSceneData['environmentCollisionMapPath'] = $normalizedEnvironmentCollisionMapPath; return new SceneDTO( - name: basename($scenePath, '.scene.php'), + name: basename($normalizedScenePath, '.scene.php'), width: $sceneData['width'] ?? DEFAULT_TERMINAL_WIDTH, height: $sceneData['height'] ?? DEFAULT_TERMINAL_HEIGHT, environmentTileMapPath: $normalizedEnvironmentTileMapPath, environmentCollisionMapPath: $normalizedEnvironmentCollisionMapPath, isDirty: $sceneData['isDirty'] ?? false, hierarchy: $sceneData['hierarchy'] ?? [], - sourcePath: $scenePath, + sourcePath: $normalizedScenePath, rawData: $sceneData, sourceData: $sourceSceneData, ); @@ -285,7 +296,7 @@ function build_vector(mixed $value, array $default = ['x' => 0, 'y' => 0]): ?obj return null; } - $vectorValue = is_array($value) ? $value : $default; + $vectorValue = parse_vector_value($value) ?? $default; return new \Sendama\Engine\Core\Vector2( (int) ($vectorValue['x'] ?? $default['x']), @@ -293,6 +304,100 @@ function build_vector(mixed $value, array $default = ['x' => 0, 'y' => 0]): ?obj ); } +function parse_vector_value(mixed $value): ?array +{ + if (is_array($value)) { + if (array_is_list($value)) { + return [ + 'x' => (int) ($value[0] ?? 0), + 'y' => (int) ($value[1] ?? 0), + ]; + } + + if (array_key_exists('x', $value) || array_key_exists('y', $value)) { + return [ + 'x' => (int) ($value['x'] ?? 0), + 'y' => (int) ($value['y'] ?? 0), + ]; + } + + return null; + } + + if (is_object($value)) { + if (method_exists($value, 'getX') && method_exists($value, 'getY')) { + return [ + 'x' => (int) $value->getX(), + 'y' => (int) $value->getY(), + ]; + } + + return parse_vector_value((array) $value); + } + + if (!is_string($value)) { + return null; + } + + $normalizedValue = trim($value); + + if ($normalizedValue === '') { + return null; + } + + $decodedValue = json_decode($normalizedValue, true); + + if (is_array($decodedValue)) { + return parse_vector_value($decodedValue); + } + + if ( + preg_match('/^\[\s*(-?\d+)\s*,\s*(-?\d+)\s*\]$/', $normalizedValue, $matches) === 1 + || preg_match('/^\s*(-?\d+)\s*,\s*(-?\d+)\s*$/', $normalizedValue, $matches) === 1 + ) { + return [ + 'x' => (int) $matches[1], + 'y' => (int) $matches[2], + ]; + } + + return null; +} + +function is_vector_field_type(?string $fieldType): bool +{ + if (!is_string($fieldType) || trim($fieldType) === '') { + return false; + } + + $normalizedTypes = array_map( + static fn (string $type): string => ltrim(trim($type), '\\'), + explode('|', $fieldType), + ); + + return in_array('Sendama\Engine\Core\Vector2', $normalizedTypes, true); +} + +function normalize_component_data_by_field_types(array $componentData, array $fieldTypes): array +{ + $normalizedData = $componentData; + + foreach ($normalizedData as $key => $value) { + $fieldType = $fieldTypes[$key] ?? null; + + if (is_string($fieldType) && is_vector_field_type($fieldType)) { + $normalizedData[$key] = parse_vector_value($value) ?? $value; + continue; + } + + if (is_array($fieldType) && is_array($value) && !array_is_list($value)) { + $normalizedData[$key] = normalize_component_data_by_field_types($value, $fieldType); + } + } + + return $normalizedData; +} + function build_sprite(array $item): ?object { $texture = is_array($item['sprite']['texture'] ?? null) ? $item['sprite']['texture'] : null; @@ -388,6 +493,64 @@ function extract_component_serializable_data(object $component): array return $serializedData; } +function extract_component_editor_field_types(object $component): array +{ + $fieldTypes = []; + $reflection = new ReflectionObject($component); + + foreach ($reflection->getProperties() as $property) { + $isSerializable = $property->isPublic() + || $property->getAttributes('Sendama\Engine\Core\Behaviours\Attributes\SerializeField') !== []; + + if (!$isSerializable) { + continue; + } + + if (method_exists($property, 'isVirtual') && $property->isVirtual()) { + continue; + } + + $resolvedType = resolve_property_type($property); + + if ($resolvedType !== null) { + $fieldTypes[$property->getName()] = $resolvedType; + } + } + + return $fieldTypes; +} + +function resolve_property_type(ReflectionProperty $property): ?string +{ + $type = $property->getType(); + + if ($type instanceof ReflectionNamedType) { + $resolvedType = $type->getName(); + + if ($type->allowsNull() && $resolvedType !== 'null') { + return $resolvedType . '|null'; + } + + return $resolvedType; + } + + if ($type instanceof ReflectionUnionType) { + $resolvedTypes = []; + + foreach ($type->getTypes() as $namedType) { + if ($namedType instanceof ReflectionNamedType) { + $resolvedTypes[] = $namedType->getName(); + } + } + + $resolvedTypes = array_values(array_unique(array_filter($resolvedTypes))); + + return $resolvedTypes !== [] ? implode('|', $resolvedTypes) : null; + } + + return null; +} + function enrich_component_entry(mixed $component, array $item): mixed { if (!is_array($component)) { @@ -398,11 +561,30 @@ function enrich_component_entry(mixed $component, array $item): mixed $defaultComponentData = is_string($componentClass) && $componentClass !== '' ? serialize_component_data($componentClass, $item) : null; + $defaultComponentFieldTypes = is_string($componentClass) && $componentClass !== '' + && class_exists($componentClass) + && class_exists('\Sendama\Engine\Core\Component') + && is_a($componentClass, '\Sendama\Engine\Core\Component', true) + && !empty($gameObject = build_dummy_game_object($item)) + ? (function () use ($componentClass, $gameObject): array { + try { + $componentInstance = new $componentClass($gameObject); + + return extract_component_editor_field_types($componentInstance); + } catch (Throwable) { + return []; + } + })() + : []; if (array_key_exists('data', $component)) { $existingComponentData = is_array($component['data']) ? normalize_editor_value($component['data']) : normalize_editor_value((array) $component['data']); + $existingComponentData = normalize_component_data_by_field_types( + is_array($existingComponentData) ? $existingComponentData : [], + $defaultComponentFieldTypes, + ); if (is_array($defaultComponentData)) { $component['data'] = merge_component_data($defaultComponentData, $existingComponentData); @@ -410,6 +592,10 @@ function enrich_component_entry(mixed $component, array $item): mixed $component['data'] = $existingComponentData; } + if ($defaultComponentFieldTypes !== []) { + $component['__editorFieldTypes'] = $defaultComponentFieldTypes; + } + return $component; } @@ -421,6 +607,10 @@ function enrich_component_entry(mixed $component, array $item): mixed $component['data'] = $defaultComponentData; } + if ($defaultComponentFieldTypes !== []) { + $component['__editorFieldTypes'] = $defaultComponentFieldTypes; + } + return $component; } diff --git a/src/Editor/SceneWriter.php b/src/Editor/SceneWriter.php index ea72e41..6e3a897 100644 --- a/src/Editor/SceneWriter.php +++ b/src/Editor/SceneWriter.php @@ -53,7 +53,7 @@ public function snapshot(SceneDTO $scene): array unset($sceneData['isDirty']); - return $sceneData; + return $this->stripEditorOnlyMetadata($sceneData); } private function parseSceneSource(SceneDTO $scene): ?array @@ -70,6 +70,7 @@ private function renderMergedValue( mixed $originalValue, array $sourceNode, int $depth = 0, + array $path = [], ): string { if ($currentValue === $originalValue && isset($sourceNode['source'])) { return $sourceNode['source']; @@ -81,7 +82,7 @@ private function renderMergedValue( && ($sourceNode['kind'] ?? null) === 'array' && $this->canRenderMergedArray($currentValue, $originalValue, $sourceNode) ) { - return $this->renderMergedArray($currentValue, $originalValue, $sourceNode, $depth); + return $this->renderMergedArray($currentValue, $originalValue, $sourceNode, $depth, $path); } return $this->exportValue($currentValue, $depth); @@ -109,6 +110,7 @@ private function renderMergedArray( array $originalValue, array $sourceNode, int $depth, + array $path, ): string { if ($currentValue === []) { return '[]'; @@ -139,7 +141,8 @@ private function renderMergedArray( $currentValue[$index], $originalValue[$originalIndex], $itemNode['node'], - $depth + 1 + $depth + 1, + [...$path, (string) $index] ) . ','; @@ -177,14 +180,16 @@ private function renderMergedArray( : null; $renderedValue = array_key_exists($resolvedKey, $originalValue) - ? $this->renderMergedValue($value, $originalItemValue, $itemNode['node'], $depth + 1) + ? $this->renderMergedValue($value, $originalItemValue, $itemNode['node'], $depth + 1, [...$path, (string) $resolvedKey]) : $this->exportValue($value, $depth + 1); $lines[] = $childIndent . $valuePrefix . $renderedValue . ','; continue; } + if ($this->shouldPreserveMissingAssociativeKey($path)) { $lines[] = $childIndent . $valuePrefix . $itemNode['node']['source'] . ','; + } } foreach ($currentValue as $key => $value) { @@ -201,6 +206,14 @@ private function renderMergedArray( return "[\n" . implode("\n", $lines) . "\n" . $indent . "]"; } + private function shouldPreserveMissingAssociativeKey(array $path): bool + { + // Serialized component data is authoritative. If a key disappears there, + // it should be removed from the saved metadata instead of being revived + // from the original source snapshot. + return !in_array('data', $path, true); + } + private function buildListItemMappings(array $currentValue, array $originalValue): array { $mappings = []; @@ -253,6 +266,25 @@ private function buildListItemMappings(array $currentValue, array $originalValue return $mappings; } + private function stripEditorOnlyMetadata(mixed $value): mixed + { + if (!is_array($value)) { + return $value; + } + + $sanitizedValue = []; + + foreach ($value as $key => $item) { + if (is_string($key) && str_starts_with($key, '__editor')) { + continue; + } + + $sanitizedValue[$key] = $this->stripEditorOnlyMetadata($item); + } + + return $sanitizedValue; + } + private function resolveListItemIdentity(mixed $value): ?string { if (is_scalar($value) || $value === null) { diff --git a/src/Editor/Widgets/AssetsPanel.php b/src/Editor/Widgets/AssetsPanel.php index 908075e..d1dc599 100644 --- a/src/Editor/Widgets/AssetsPanel.php +++ b/src/Editor/Widgets/AssetsPanel.php @@ -2,6 +2,7 @@ namespace Sendama\Console\Editor\Widgets; +use Atatusoft\Termutil\Events\MouseEvent; use Atatusoft\Termutil\IO\Enumerations\Color; use Sendama\Console\Debug\Debug; use Sendama\Console\Editor\IO\Enumerations\KeyCode; @@ -15,6 +16,7 @@ */ class AssetsPanel extends Widget { + private const float DOUBLE_CLICK_THRESHOLD_SECONDS = 0.35; private const string CREATE_MODAL_ASSET_KIND = 'create_asset_kind'; private const string DELETE_MODAL_CONFIRM = 'delete_confirm'; private const string COLLAPSED_ICON = '►'; @@ -33,6 +35,8 @@ class AssetsPanel extends Widget protected OptionListModal $createAssetModal; protected OptionListModal $deleteConfirmModal; protected ?string $modalState = null; + protected ?string $lastClickedPath = null; + protected float $lastClickedAt = 0.0; public function __construct( array $position = ['x' => 1, 'y' => 15], @@ -141,6 +145,7 @@ private function queueInspectionTarget(bool $openInMainPanel = false): void 'type' => ($selectedAsset['isDirectory'] ?? false) ? 'Folder' : 'File', 'value' => $selectedAsset, 'openInMainPanel' => $openInMainPanel, + 'openInTerminalEditor' => $openInMainPanel && $this->isScriptAsset($selectedAsset), ]; } @@ -211,6 +216,38 @@ public function renderActiveModal(): void } } + public function handleModalMouseEvent(MouseEvent $mouseEvent): bool + { + if ( + $this->modalState !== self::DELETE_MODAL_CONFIRM + && $this->modalState !== self::CREATE_MODAL_ASSET_KIND + ) { + return false; + } + + $activeModal = $this->modalState === self::CREATE_MODAL_ASSET_KIND + ? $this->createAssetModal + : $this->deleteConfirmModal; + + if ($activeModal->handleScrollbarMouseEvent($mouseEvent)) { + return true; + } + + if ($mouseEvent->buttonIndex !== 0 || $mouseEvent->action !== 'Pressed') { + return false; + } + + $selection = $activeModal->clickOptionAtPoint($mouseEvent->x, $mouseEvent->y); + + if (!is_string($selection) || $selection === '') { + return false; + } + + $this->handleModalSelection($selection); + + return true; + } + public function reloadAssets(): void { $this->loadAssetEntries(); @@ -240,15 +277,39 @@ public function handleMouseClick(int $x, int $y): void return; } - $index = $y - $this->getContentAreaTop(); + $index = $this->resolveContentIndexFromPointY($y); + + if (!is_int($index) || !isset($this->visibleAssets[$index])) { + return; + } + + $entry = $this->visibleAssets[$index] ?? null; - if (!isset($this->visibleAssets[$index])) { + if (!is_array($entry)) { + return; + } + + $path = is_string($entry['path'] ?? null) ? $entry['path'] : null; + + if ($path === null) { return; } - $this->selectedPath = $this->visibleAssets[$index]['path'] ?? $this->selectedPath; + $this->selectedPath = $path; + + if ($this->isExpandToggleClick($entry, $x)) { + $this->toggleEntryExpansion($entry); + $this->resetClickTracking(); + return; + } + + $isDoubleClick = $this->registerClickAndCheckDoubleClick($path); $this->refreshContent(); $this->queueInspectionTarget(); + + if ($isDoubleClick) { + $this->activateSelection(); + } } public function update(): void @@ -300,9 +361,9 @@ public function update(): void protected function decorateContentLine(string $line, ?Color $contentColor, int $lineIndex): string { $selectedVisibleIndex = $this->getSelectedVisibleIndex(); - $selectedLineIndex = $selectedVisibleIndex === null - ? null - : $this->padding->topPadding + $selectedVisibleIndex; + $selectedLineIndex = is_int($selectedVisibleIndex) + ? $this->getRenderedLineIndexForContentIndex($selectedVisibleIndex) + : null; if ($lineIndex !== $selectedLineIndex) { return parent::decorateContentLine($line, $contentColor, $lineIndex); @@ -393,6 +454,30 @@ private function buildRelativePath(string $path): string return ltrim($relativePath ?: basename($path), DIRECTORY_SEPARATOR); } + private function isScriptAsset(array $asset): bool + { + if (($asset['isDirectory'] ?? false) === true) { + return false; + } + + $assetPath = is_string($asset['path'] ?? null) + ? $asset['path'] + : (is_string($asset['relativePath'] ?? null) ? $asset['relativePath'] : null); + $relativePath = is_string($asset['relativePath'] ?? null) ? $asset['relativePath'] : ''; + + if (!is_string($assetPath) || $assetPath === '') { + return false; + } + + if (strtolower((string) pathinfo($assetPath, PATHINFO_EXTENSION)) !== 'php') { + return false; + } + + $normalizedRelativePath = ltrim(str_replace('\\', '/', strtolower($relativePath)), '/'); + + return str_starts_with($normalizedRelativePath, 'scripts/'); + } + private function refreshContent(): void { $this->visibleAssets = $this->buildVisibleAssets($this->assetTree); @@ -401,6 +486,7 @@ private function refreshContent(): void fn(array $entry) => $this->formatVisibleAssetEntry($entry), $this->visibleAssets ); + $this->ensureContentLineVisible($this->getSelectedVisibleIndex()); } private function buildVisibleAssets(array $items, int $depth = 0, string $parentPath = ''): array @@ -551,6 +637,54 @@ private function findAssetPathByAbsolutePath(array $items, string $targetAbsolut return null; } + private function isExpandToggleClick(array $entry, int $x): bool + { + if (!($entry['isDirectory'] ?? false)) { + return false; + } + + $depth = max(0, (int) ($entry['depth'] ?? 0)); + $iconColumn = $this->getContentAreaLeft() + ($depth * 2); + + return $x === $iconColumn; + } + + private function toggleEntryExpansion(array $entry): void + { + $path = $entry['path'] ?? null; + + if (!is_string($path) || !($entry['isDirectory'] ?? false)) { + return; + } + + if ($entry['isExpanded'] ?? false) { + unset($this->expandedPaths[$path]); + } else { + $this->expandedPaths[$path] = true; + } + + $this->refreshContent(); + $this->queueInspectionTarget(); + } + + private function registerClickAndCheckDoubleClick(string $path): bool + { + $now = microtime(true); + $isDoubleClick = $this->lastClickedPath === $path + && ($now - $this->lastClickedAt) <= self::DOUBLE_CLICK_THRESHOLD_SECONDS; + + $this->lastClickedPath = $path; + $this->lastClickedAt = $now; + + return $isDoubleClick; + } + + private function resetClickTracking(): void + { + $this->lastClickedPath = null; + $this->lastClickedAt = 0.0; + } + private function showDeleteConfirmModal(): void { $selectedAsset = $this->getSelectedAssetEntry(); @@ -608,6 +742,14 @@ private function handleModalInput(): void } $selection = $activeModal->getSelectedOption(); + $this->handleModalSelection($selection); + } + + private function handleModalSelection(?string $selection): void + { + if (!is_string($selection) || $selection === '') { + return; + } if ($this->modalState === self::CREATE_MODAL_ASSET_KIND) { $assetKind = match ($selection) { diff --git a/src/Editor/Widgets/ConsolePanel.php b/src/Editor/Widgets/ConsolePanel.php index d64b395..153fd63 100644 --- a/src/Editor/Widgets/ConsolePanel.php +++ b/src/Editor/Widgets/ConsolePanel.php @@ -2,6 +2,7 @@ namespace Sendama\Console\Editor\Widgets; +use Atatusoft\Termutil\Events\MouseEvent; use Atatusoft\Termutil\IO\Enumerations\Color; use Sendama\Console\Editor\IO\Enumerations\KeyCode; use Sendama\Console\Editor\IO\Input; @@ -40,7 +41,7 @@ class ConsolePanel extends Widget protected int $activeTabLength = 0; protected Color $activeIndicatorColor = Color::LIGHT_CYAN; protected array $activeFiltersByTab = [ - 'Debug' => 'ALL', + 'Debug' => 'DEBUG', 'Error' => 'ALL', ]; protected OptionListModal $filterModal; @@ -101,11 +102,59 @@ public function renderActiveModal(): void } } + public function handleModalMouseEvent(MouseEvent $mouseEvent): bool + { + if ($this->filterModal->isVisible()) { + if ($this->filterModal->handleScrollbarMouseEvent($mouseEvent)) { + return true; + } + + if ($mouseEvent->buttonIndex !== 0 || $mouseEvent->action !== 'Pressed') { + return false; + } + + $selection = $this->filterModal->clickOptionAtPoint($mouseEvent->x, $mouseEvent->y); + + if (!is_string($selection) || $selection === '') { + return false; + } + + $this->applyFilterSelection($selection); + return true; + } + + if ($this->clearConfirmModal->isVisible()) { + if ($this->clearConfirmModal->handleScrollbarMouseEvent($mouseEvent)) { + return true; + } + + if ($mouseEvent->buttonIndex !== 0 || $mouseEvent->action !== 'Pressed') { + return false; + } + + $selection = $this->clearConfirmModal->clickOptionAtPoint($mouseEvent->x, $mouseEvent->y); + + if (!is_string($selection) || $selection === '') { + return false; + } + + $this->applyClearConfirmSelection($selection); + return true; + } + + return false; + } + public function getActiveTab(): string { return self::TAB_TITLES[$this->activeTabIndex]; } + public function getActiveFilter(): string + { + return $this->activeFiltersByTab[$this->getActiveTab()] ?? 'ALL'; + } + public function cycleFocusForward(): bool { $this->activateNextTab(); @@ -120,6 +169,35 @@ public function cycleFocusBackward(): bool return true; } + public function handleMouseClick(int $x, int $y): void + { + if (!$this->containsPoint($x, $y)) { + return; + } + + if ($y !== $this->getContentAreaTop()) { + return; + } + + $currentX = $this->getContentAreaLeft(); + + foreach (self::TAB_TITLES as $index => $tabTitle) { + if ($index > 0) { + $currentX += 2; + } + + $tabStart = $currentX; + $tabEnd = $tabStart + mb_strlen($tabTitle) - 1; + + if ($x >= $tabStart && $x <= $tabEnd) { + $this->activateTabByIndex($index); + return; + } + + $currentX = $tabEnd + 1; + } + } + public function append(string $message): void { $timestamp = date('Y-m-d H:i:s'); @@ -237,6 +315,35 @@ public function update(): void $this->refreshVisibleContent(); } + protected function usesAutomaticVerticalScrolling(): bool + { + return false; + } + + protected function setScrollbarOffset(int $offset): void + { + $this->scrollOffset = $this->clampScrollOffsetValue($offset, count($this->messages)); + $this->persistScrollOffset(); + $this->refreshVisibleContent(); + } + + protected function resolveVerticalScrollbarState(): ?array + { + $visibleLineCount = $this->getVisibleLogLineCount(); + $messageCount = count($this->messages); + + if ($visibleLineCount <= 0 || $messageCount <= $visibleLineCount) { + return null; + } + + return [ + 'offset' => $this->scrollOffset, + 'visible' => $visibleLineCount, + 'total' => $messageCount, + 'start' => 2, + ]; + } + protected function decorateContentLine(string $line, ?Color $contentColor, int $lineIndex): string { if ($lineIndex === 1) { @@ -266,17 +373,23 @@ protected function decorateContentLine(string $line, ?Color $contentColor, int $ private function activateNextTab(): void { - $previousTabTitle = $this->getActiveTab(); - $this->scrollOffsetsByTab[$previousTabTitle] = $this->scrollOffset; - $this->activeTabIndex = ($this->activeTabIndex + 1) % count(self::TAB_TITLES); - $this->restoreActiveTabState(); + $this->activateTabByIndex(($this->activeTabIndex + 1) % count(self::TAB_TITLES)); } private function activatePreviousTab(): void { + $this->activateTabByIndex(($this->activeTabIndex - 1 + count(self::TAB_TITLES)) % count(self::TAB_TITLES)); + } + + private function activateTabByIndex(int $tabIndex): void + { + if (!isset(self::TAB_TITLES[$tabIndex])) { + return; + } + $previousTabTitle = $this->getActiveTab(); $this->scrollOffsetsByTab[$previousTabTitle] = $this->scrollOffset; - $this->activeTabIndex = ($this->activeTabIndex - 1 + count(self::TAB_TITLES)) % count(self::TAB_TITLES); + $this->activeTabIndex = $tabIndex; $this->restoreActiveTabState(); } @@ -480,7 +593,7 @@ private function messagesForTab(string $tabTitle): array ...($this->sessionMessagesByTab[$tabTitle] ?? []), ]; - return $this->applyActiveFilter($tabTitle, $messages); + return $this->wrapMessagesForDisplay($this->applyActiveFilter($tabTitle, $messages)); } private function shouldRefreshFromLogFile(): bool @@ -637,6 +750,11 @@ private function handleFilterModalInput(): void } $selection = $this->filterModal->getSelectedOption(); + $this->applyFilterSelection($selection); + } + + private function applyFilterSelection(?string $selection): void + { $this->filterModal->hide(); if (!is_string($selection) || $selection === '') { @@ -686,6 +804,11 @@ private function handleClearConfirmModalInput(): void } $selection = $this->clearConfirmModal->getSelectedOption(); + $this->applyClearConfirmSelection($selection); + } + + private function applyClearConfirmSelection(?string $selection): void + { $this->clearConfirmModal->hide(); if ($selection !== 'Clear') { @@ -748,14 +871,14 @@ private function applyActiveFilter(string $tabTitle, array $messages): array return array_values(array_filter( $messages, - fn(string $message): bool => $this->messageMatchesFilter($message, $activeFilter) + fn(string $message): bool => $this->messageMatchesFilter($tabTitle, $message, $activeFilter) )); } - private function messageMatchesFilter(string $message, string $filter): bool + private function messageMatchesFilter(string $tabTitle, string $message, string $filter): bool { if (preg_match('/\[(ERROR|CRITICAL|FATAL|INFO|WARN|WARNING|DEBUG)\]/', $message, $matches) !== 1) { - return false; + return $tabTitle === 'Debug' && $filter === 'DEBUG'; } $level = $matches[1]; @@ -766,4 +889,55 @@ private function messageMatchesFilter(string $message, string $filter): bool return $level === $filter; } + + private function wrapMessagesForDisplay(array $messages): array + { + $wrappedMessages = []; + + foreach ($messages as $message) { + $wrappedMessages = [ + ...$wrappedMessages, + ...$this->wrapMessageForDisplay((string) $message), + ]; + } + + return $wrappedMessages; + } + + private function wrapMessageForDisplay(string $message): array + { + $availableWidth = $this->getWrappedMessageWidth(); + $segments = preg_split("/\\R/u", $message) ?: [$message]; + $wrappedLines = []; + + foreach ($segments as $segment) { + $remaining = (string) $segment; + + if ($remaining === '') { + $wrappedLines[] = ''; + continue; + } + + while ($remaining !== '') { + $visibleSegment = mb_strimwidth($remaining, 0, $availableWidth, '', 'UTF-8'); + + if ($visibleSegment === '') { + $visibleSegment = mb_substr($remaining, 0, 1); + } + + $wrappedLines[] = $visibleSegment; + $remaining = mb_substr($remaining, mb_strlen($visibleSegment), null, 'UTF-8'); + } + } + + return $wrappedLines === [] ? [''] : $wrappedLines; + } + + private function getWrappedMessageWidth(): int + { + return max( + 1, + $this->innerWidth - $this->padding->leftPadding - $this->padding->rightPadding, + ); + } } diff --git a/src/Editor/Widgets/Controls/InputControlFactory.php b/src/Editor/Widgets/Controls/InputControlFactory.php index 7adbfbf..a99b0cd 100644 --- a/src/Editor/Widgets/Controls/InputControlFactory.php +++ b/src/Editor/Widgets/Controls/InputControlFactory.php @@ -15,6 +15,19 @@ public function create(string $label, mixed $value, int $indentLevel = 1): Input }; } + public function createForFieldType(string $label, mixed $value, ?string $fieldType, int $indentLevel = 1): InputControl + { + if ($this->isVectorFieldType($fieldType)) { + return new VectorInputControl( + $label, + $this->normalizeVectorFieldValue($value), + $indentLevel, + ); + } + + return $this->create($label, $value, $indentLevel); + } + private function isVector(array $value): bool { if ($value === []) { @@ -50,4 +63,65 @@ private function normalizeTextValue(mixed $value): string default => (string) $value, }; } + + private function isVectorFieldType(?string $fieldType): bool + { + if (!is_string($fieldType) || trim($fieldType) === '') { + return false; + } + + $normalizedTypes = array_map( + static fn(string $type): string => ltrim(trim($type), '\\'), + explode('|', $fieldType), + ); + + return in_array('Sendama\\Engine\\Core\\Vector2', $normalizedTypes, true); + } + + private function normalizeVectorFieldValue(mixed $value): array + { + if (is_array($value)) { + if (array_is_list($value)) { + return [ + 'x' => (int)($value[0] ?? 0), + 'y' => (int)($value[1] ?? 0), + ]; + } + + if (array_key_exists('x', $value) || array_key_exists('y', $value)) { + return [ + 'x' => (int)($value['x'] ?? 0), + 'y' => (int)($value['y'] ?? 0), + ]; + } + } + + if (is_object($value) && method_exists($value, 'getX') && method_exists($value, 'getY')) { + return [ + 'x' => (int)$value->getX(), + 'y' => (int)$value->getY(), + ]; + } + + if (is_string($value)) { + $normalizedValue = trim($value); + $decodedValue = json_decode($normalizedValue, true); + + if (is_array($decodedValue)) { + return $this->normalizeVectorFieldValue($decodedValue); + } + + if ( + preg_match('/^\[\s*(-?\d+)\s*,\s*(-?\d+)\s*\]$/', $normalizedValue, $matches) === 1 + || preg_match('/^\s*(-?\d+)\s*,\s*(-?\d+)\s*$/', $normalizedValue, $matches) === 1 + ) { + return [ + 'x' => (int)$matches[1], + 'y' => (int)$matches[2], + ]; + } + } + + return ['x' => 0, 'y' => 0]; + } } diff --git a/src/Editor/Widgets/Controls/PrefabReferenceInputControl.php b/src/Editor/Widgets/Controls/PrefabReferenceInputControl.php new file mode 100644 index 0000000..aedf751 --- /dev/null +++ b/src/Editor/Widgets/Controls/PrefabReferenceInputControl.php @@ -0,0 +1,51 @@ +normalizeValue($value), $indentLevel, $isReadOnly); + } + + public function setValue(mixed $value): void + { + $this->value = $this->normalizeValue($value); + } + + public function renderLines(): array + { + return [ + $this->indentation() . $this->label . ': ' . $this->resolveDisplayValue(), + ]; + } + + private function normalizeValue(mixed $value): ?string + { + if (!is_string($value)) { + return null; + } + + $normalizedValue = trim(str_replace('\\', '/', $value)); + + return $normalizedValue !== '' ? $normalizedValue : null; + } + + private function resolveDisplayValue(): string + { + $value = $this->value; + + if (!is_string($value) || $value === '') { + return 'None'; + } + + return $this->displayLabelsByPath[$value] ?? $value; + } +} diff --git a/src/Editor/Widgets/FileDialogModal.php b/src/Editor/Widgets/FileDialogModal.php index c482bf5..b84a33b 100644 --- a/src/Editor/Widgets/FileDialogModal.php +++ b/src/Editor/Widgets/FileDialogModal.php @@ -7,6 +7,7 @@ class FileDialogModal extends Widget { + private const float DOUBLE_CLICK_THRESHOLD_SECONDS = 0.35; private const string COLLAPSED_ICON = '►'; private const string EXPANDED_ICON = '▼'; private const string LEAF_ICON = '•'; @@ -20,6 +21,8 @@ class FileDialogModal extends Widget protected array $expandedPaths = []; protected ?string $selectedPath = null; protected array $allowedExtensions = []; + protected ?string $lastClickedPath = null; + protected float $lastClickedAt = 0.0; public function __construct() { @@ -43,6 +46,8 @@ public function show( $this->entryTree = $this->buildEntryTree($this->workingDirectory); $this->expandedPaths = []; $this->selectedPath = null; + $this->lastClickedPath = null; + $this->lastClickedAt = 0.0; $this->isVisible = true; $this->refreshContent(); $this->markDirty(); @@ -157,6 +162,43 @@ public function submitSelection(): ?string return $selectedEntry['item']['relativePath'] ?? null; } + public function clickEntryAtPoint(int $x, int $y): ?string + { + if (!$this->isVisible || !$this->containsPoint($x, $y)) { + return null; + } + + $entryIndex = $this->resolveContentIndexFromPointY($y); + $entry = $this->visibleEntries[$entryIndex] ?? null; + + if (!is_array($entry)) { + return null; + } + + if ($this->isExpandToggleClick($entry, $x)) { + $this->toggleEntryExpansion($entry); + $this->lastClickedPath = null; + $this->lastClickedAt = 0.0; + return null; + } + + $path = is_string($entry['path'] ?? null) ? $entry['path'] : null; + + if ($path === null) { + return null; + } + + $isDoubleClick = $this->registerClickAndCheckDoubleClick($path); + $this->selectedPath = $path; + $this->refreshContent(); + + if (!$isDoubleClick) { + return null; + } + + return $this->submitSelection(); + } + public function syncLayout(int $terminalWidth, int $terminalHeight): void { $desiredWidth = max( @@ -187,12 +229,22 @@ public function update(): void { } + protected function usesAutomaticVerticalScrolling(): bool + { + return true; + } + + protected function handleScrollbarOffsetChanged(): void + { + $this->markDirty(); + } + protected function decorateContentLine(string $line, ?Color $contentColor, int $lineIndex): string { $selectedVisibleIndex = $this->getSelectedVisibleIndex(); - $selectedLineIndex = $selectedVisibleIndex === null - ? null - : $this->padding->topPadding + $selectedVisibleIndex; + $selectedLineIndex = is_int($selectedVisibleIndex) + ? $this->getRenderedLineIndexForContentIndex($selectedVisibleIndex) + : null; if ($lineIndex !== $selectedLineIndex) { return parent::decorateContentLine($line, $contentColor, $lineIndex); @@ -300,6 +352,7 @@ private function refreshContent(): void $this->visibleEntries = $this->buildVisibleEntries($this->entryTree); $this->syncSelectedPath(); $this->content = array_map(fn(array $entry) => $this->formatVisibleEntry($entry), $this->visibleEntries); + $this->ensureContentLineVisible($this->getSelectedVisibleIndex()); $this->markDirty(); } @@ -410,6 +463,46 @@ private function getParentPath(string $path): ?string return substr($path, 0, $separatorPosition); } + private function isExpandToggleClick(array $entry, int $x): bool + { + if (!($entry['isDirectory'] ?? false)) { + return false; + } + + $iconColumn = $this->getContentAreaLeft() + (((int) ($entry['depth'] ?? 0)) * 2); + + return $x === $iconColumn; + } + + private function toggleEntryExpansion(array $entry): void + { + $path = $entry['path'] ?? null; + + if (!is_string($path) || $path === '' || !($entry['isDirectory'] ?? false)) { + return; + } + + if ($entry['isExpanded'] ?? false) { + unset($this->expandedPaths[$path]); + } else { + $this->expandedPaths[$path] = true; + } + + $this->refreshContent(); + } + + private function registerClickAndCheckDoubleClick(string $path): bool + { + $now = microtime(true); + $isDoubleClick = $this->lastClickedPath === $path + && ($now - $this->lastClickedAt) <= self::DOUBLE_CLICK_THRESHOLD_SECONDS; + + $this->lastClickedPath = $path; + $this->lastClickedAt = $now; + + return $isDoubleClick; + } + private function selectRelativePath(string $relativePath): void { $normalizedTarget = str_replace('\\', '/', $relativePath); diff --git a/src/Editor/Widgets/HierarchyPanel.php b/src/Editor/Widgets/HierarchyPanel.php index 92b89af..99bcb27 100644 --- a/src/Editor/Widgets/HierarchyPanel.php +++ b/src/Editor/Widgets/HierarchyPanel.php @@ -2,6 +2,7 @@ namespace Sendama\Console\Editor\Widgets; +use Atatusoft\Termutil\Events\MouseEvent; use Atatusoft\Termutil\Events\Interfaces\ObservableInterface; use Atatusoft\Termutil\Events\Traits\ObservableTrait; use Atatusoft\Termutil\IO\Enumerations\Color; @@ -20,16 +21,23 @@ class HierarchyPanel extends Widget implements ObservableInterface { use ObservableTrait; + private const float DOUBLE_CLICK_THRESHOLD_SECONDS = 0.35; private const string ROOT_PATH = 'scene'; private const string ADD_MODAL_OBJECT_KIND = 'object_kind'; + private const string ADD_MODAL_OBJECT_PLACEMENT = 'object_placement'; private const string ADD_MODAL_UI_KIND = 'ui_kind'; private const string DELETE_MODAL_CONFIRM = 'delete_confirm'; + private const string HIERARCHY_MODE_SELECT = 'select'; + private const string HIERARCHY_MODE_MOVE = 'move'; 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 MOVE_ROW_SEQUENCE = "\033[30;43m"; + private const string MOVE_ROW_FOCUSED_SEQUENCE = "\033[5;30;43m"; private const string GAME_OBJECT_TYPE = 'Sendama\\Engine\\Core\\GameObject'; + private const string GUI_TEXTURE_TYPE = 'Sendama\\Engine\\UI\\GUITexture\\GUITexture'; private const string LABEL_TYPE = 'Sendama\\Engine\\UI\\Label\\Label'; private const string TEXT_TYPE = 'Sendama\\Engine\\UI\\Text\\Text'; @@ -43,13 +51,23 @@ class HierarchyPanel extends Widget implements ObservableInterface protected array $visibleHierarchy = []; protected array $expandedPaths = []; protected ?string $selectedPath = null; + protected array $selectedPaths = []; protected ?array $pendingInspectionItem = null; protected ?array $pendingCreationItem = null; protected ?array $pendingDeletionItem = null; + protected ?array $pendingDuplicationItems = null; + protected ?array $pendingPrefabCreationItem = null; + protected ?array $pendingMoveItem = null; + protected ?string $draggedPath = null; + protected ?string $dragHoverPath = null; protected OptionListModal $addObjectModal; + protected OptionListModal $addPlacementModal; protected OptionListModal $addUiElementModal; protected OptionListModal $deleteConfirmModal; protected ?string $addModalState = null; + protected string $interactionMode = self::HIERARCHY_MODE_SELECT; + protected ?string $lastClickedPath = null; + protected float $lastClickedAt = 0.0; public function __construct( array $position = ['x' => 1, 'y' => 1], @@ -67,6 +85,7 @@ public function __construct( $this->initializeObservers(); parent::__construct('Hierarchy', '', $position, $width, $height); $this->addObjectModal = new OptionListModal(title: 'Add Object'); + $this->addPlacementModal = new OptionListModal(title: 'Place GameObject'); $this->addUiElementModal = new OptionListModal(title: 'Add UI Element'); $this->deleteConfirmModal = new OptionListModal(title: 'Delete Object'); $this->sceneName = $sceneName; @@ -88,6 +107,7 @@ public function setHierarchy(array $hierarchy): void $this->hierarchy = array_values($hierarchy); $this->expandedPaths = [self::ROOT_PATH => true]; $this->selectedPath = self::ROOT_PATH; + $this->selectedPaths = [self::ROOT_PATH]; $this->refreshContent(); $this->notify(new EditorEvent(EventType::HIERARCHY_CHANGED->value, $this)); @@ -124,6 +144,25 @@ public function selectPath(?string $path): void } $this->selectedPath = $path; + $this->selectedPaths = [$path]; + $this->refreshContent(); + } + + public function selectPaths(array $paths, ?string $primaryPath = null): void + { + $normalizedPaths = array_values(array_unique(array_filter( + $paths, + static fn (mixed $path): bool => is_string($path) && $path !== '' + ))); + + if ($normalizedPaths === []) { + return; + } + + $this->selectedPath = is_string($primaryPath) && in_array($primaryPath, $normalizedPaths, true) + ? $primaryPath + : end($normalizedPaths); + $this->selectedPaths = $normalizedPaths; $this->refreshContent(); } @@ -147,6 +186,7 @@ public function moveSelection(int $offset): void $selectedIndex = $this->getSelectedVisibleIndex() ?? 0; $nextIndex = max(0, min($selectedIndex + $offset, count($this->visibleHierarchy) - 1)); $this->selectedPath = $this->visibleHierarchy[$nextIndex]['path'] ?? $this->selectedPath; + $this->selectedPaths = $this->selectedPath !== null ? [$this->selectedPath] : []; $this->refreshContent(); } @@ -173,6 +213,7 @@ public function expandSelection(): void && $entry['depth'] === $selectedDepth + 1 ) { $this->selectedPath = $entry['path']; + $this->selectedPaths = $this->selectedPath !== null ? [$this->selectedPath] : []; $this->refreshContent(); return; } @@ -200,6 +241,7 @@ public function collapseSelection(): void } $this->selectedPath = $parentPath; + $this->selectedPaths = [$parentPath]; $this->refreshContent(); } @@ -267,14 +309,98 @@ public function consumeDeletionRequest(): ?array return $pendingDeletionItem; } + public function consumeDuplicationRequest(): ?array + { + $pendingDuplicationItems = $this->pendingDuplicationItems; + $this->pendingDuplicationItems = null; + + return $pendingDuplicationItems; + } + + public function consumePrefabCreationRequest(): ?array + { + $pendingPrefabCreationItem = $this->pendingPrefabCreationItem; + $this->pendingPrefabCreationItem = null; + + return $pendingPrefabCreationItem; + } + + public function consumeMoveRequest(): ?array + { + $pendingMoveItem = $this->pendingMoveItem; + $this->pendingMoveItem = null; + + return $pendingMoveItem; + } + + public function expandPath(string $path): void + { + $this->expandedPaths[$path] = true; + $this->refreshContent(); + } + public function beginAddWorkflow(): void { $this->showAddObjectModal(); } + public function beginPrefabCreationWorkflow(): void + { + $selectedNode = $this->getSelectedVisibleNode(); + + if (($selectedNode['kind'] ?? null) !== 'object') { + return; + } + + $selectedItem = $selectedNode['item'] ?? null; + + if (!is_array($selectedItem)) { + return; + } + + $this->pendingPrefabCreationItem = [ + 'path' => $selectedNode['path'] ?? null, + 'name' => $selectedItem['name'] ?? 'Prefab', + 'value' => $selectedItem, + ]; + } + + public function beginDuplicationWorkflow(): void + { + $selectedItems = []; + + foreach ($this->visibleHierarchy as $entry) { + $path = $entry['path'] ?? null; + + if ( + !is_string($path) + || !in_array($path, $this->selectedPaths, true) + || ($entry['kind'] ?? null) !== 'object' + || !is_array($entry['item'] ?? null) + ) { + continue; + } + + $selectedItems[] = [ + 'path' => $path, + 'value' => $entry['item'], + ]; + } + + if ($selectedItems === []) { + return; + } + + $this->pendingDuplicationItems = [ + 'items' => $selectedItems, + 'primaryPath' => $this->selectedPath, + ]; + } + public function hasActiveModal(): bool { return $this->addObjectModal->isVisible() + || $this->addPlacementModal->isVisible() || $this->addUiElementModal->isVisible() || $this->deleteConfirmModal->isVisible(); } @@ -282,6 +408,7 @@ public function hasActiveModal(): bool public function isModalDirty(): bool { return $this->addObjectModal->isDirty() + || $this->addPlacementModal->isDirty() || $this->addUiElementModal->isDirty() || $this->deleteConfirmModal->isDirty(); } @@ -289,6 +416,7 @@ public function isModalDirty(): bool public function markModalClean(): void { $this->addObjectModal->markClean(); + $this->addPlacementModal->markClean(); $this->addUiElementModal->markClean(); $this->deleteConfirmModal->markClean(); } @@ -296,6 +424,7 @@ public function markModalClean(): void public function syncModalLayout(int $terminalWidth, int $terminalHeight): void { $this->addObjectModal->syncLayout($terminalWidth, $terminalHeight); + $this->addPlacementModal->syncLayout($terminalWidth, $terminalHeight); $this->addUiElementModal->syncLayout($terminalWidth, $terminalHeight); $this->deleteConfirmModal->syncLayout($terminalWidth, $terminalHeight); } @@ -306,6 +435,10 @@ public function renderActiveModal(): void $this->addObjectModal->render(); } + if ($this->addPlacementModal->isVisible()) { + $this->addPlacementModal->render(); + } + if ($this->addUiElementModal->isVisible()) { $this->addUiElementModal->render(); } @@ -315,6 +448,57 @@ public function renderActiveModal(): void } } + public function handleModalMouseEvent(MouseEvent $mouseEvent): bool + { + $activeModal = match ($this->addModalState) { + self::ADD_MODAL_OBJECT_KIND => $this->addObjectModal, + self::ADD_MODAL_OBJECT_PLACEMENT => $this->addPlacementModal, + self::ADD_MODAL_UI_KIND => $this->addUiElementModal, + self::DELETE_MODAL_CONFIRM => $this->deleteConfirmModal, + default => null, + }; + + if (!$activeModal instanceof OptionListModal) { + return false; + } + + if ($activeModal->handleScrollbarMouseEvent($mouseEvent)) { + return true; + } + + if ($mouseEvent->buttonIndex !== 0 || $mouseEvent->action !== 'Pressed') { + return false; + } + + $selectedOption = $activeModal->clickOptionAtPoint($mouseEvent->x, $mouseEvent->y); + + if (!is_string($selectedOption) || $selectedOption === '') { + return false; + } + + if ($this->addModalState === self::ADD_MODAL_OBJECT_KIND) { + $this->handleAddObjectTypeSelection($selectedOption); + return true; + } + + if ($this->addModalState === self::ADD_MODAL_OBJECT_PLACEMENT) { + $this->handleAddObjectPlacementSelection($selectedOption); + return true; + } + + if ($this->addModalState === self::ADD_MODAL_UI_KIND) { + $this->handleAddUiElementSelection($selectedOption); + return true; + } + + if ($this->addModalState === self::DELETE_MODAL_CONFIRM) { + $this->handleDeleteConfirmationSelection($selectedOption); + return true; + } + + return false; + } + public function focus(FocusTargetContext $context): void { parent::focus($context); @@ -324,6 +508,8 @@ public function focus(FocusTargetContext $context): void public function blur(FocusTargetContext $context): void { $this->dismissAddModals(); + $this->draggedPath = null; + $this->dragHoverPath = null; parent::blur($context); $this->refreshContent(); } @@ -334,14 +520,117 @@ public function handleMouseClick(int $x, int $y): void return; } - $index = $y - $this->getContentAreaTop(); + $index = $this->resolveContentIndexFromPointY($y); + + if (!is_int($index) || !isset($this->visibleHierarchy[$index])) { + return; + } + + $entry = $this->visibleHierarchy[$index] ?? null; + + if (!is_array($entry)) { + return; + } + + $path = is_string($entry['path'] ?? null) ? $entry['path'] : null; + + if ($path === null) { + return; + } + + $this->draggedPath = ($entry['kind'] ?? null) === 'object' ? $path : null; + $this->dragHoverPath = null; + + $isAdditiveSelection = Input::getMouseEvent()?->isCtrlPressed === true; - if (!isset($this->visibleHierarchy[$index])) { + if ($isAdditiveSelection) { + $this->selectedPath = $path; + + if (!in_array($path, $this->selectedPaths, true)) { + $this->selectedPaths[] = $path; + } + } else { + $this->selectedPath = $path; + $this->selectedPaths = [$path]; + } + + if ($this->isExpandToggleClick($entry, $x)) { + $this->draggedPath = null; + $this->toggleEntryExpansion($entry); + $this->resetClickTracking(); return; } - $this->selectedPath = $this->visibleHierarchy[$index]['path'] ?? $this->selectedPath; + $isDoubleClick = $this->registerClickAndCheckDoubleClick($path); $this->refreshContent(); + + if ($isDoubleClick) { + $this->activateSelection(); + } + } + + public function handleMouseDrag(int $x, int $y): void + { + if ( + !$this->containsPoint($x, $y) + || !is_string($this->draggedPath) + || $this->draggedPath === '' + ) { + $this->dragHoverPath = null; + return; + } + + $contentIndex = $this->resolveContentIndexFromPointY($y); + $entry = is_int($contentIndex) ? ($this->visibleHierarchy[$contentIndex] ?? null) : null; + $candidatePath = is_array($entry) ? ($entry['path'] ?? null) : null; + $candidateKind = is_array($entry) ? ($entry['kind'] ?? null) : null; + + if ( + !is_string($candidatePath) + || $candidatePath === $this->draggedPath + || !in_array($candidateKind, ['object', 'scene'], true) + || str_starts_with($candidatePath, $this->draggedPath . '.') + ) { + $this->dragHoverPath = null; + return; + } + + $this->dragHoverPath = $candidatePath; + } + + public function handleMouseRelease(int $x, int $y): void + { + if (!is_string($this->draggedPath) || $this->draggedPath === '') { + $this->dragHoverPath = null; + return; + } + + $targetPath = $this->dragHoverPath; + + if ($targetPath === null && $this->containsPoint($x, $y)) { + $contentIndex = $this->resolveContentIndexFromPointY($y); + $entry = is_int($contentIndex) ? ($this->visibleHierarchy[$contentIndex] ?? null) : null; + $candidatePath = is_array($entry) ? ($entry['path'] ?? null) : null; + $candidateKind = is_array($entry) ? ($entry['kind'] ?? null) : null; + + if ( + is_string($candidatePath) + && $candidatePath !== $this->draggedPath + && in_array($candidateKind, ['object', 'scene'], true) + && !str_starts_with($candidatePath, $this->draggedPath . '.') + ) { + $targetPath = $candidatePath; + } + } + + if (is_string($targetPath) && $targetPath !== '') { + $this->queueMouseDropMoveRequest($targetPath); + } elseif ($this->containsPoint($x, $y)) { + $this->queueMouseDropMoveRequest(self::ROOT_PATH); + } + + $this->draggedPath = null; + $this->dragHoverPath = null; } /** @@ -358,11 +647,51 @@ public function update(): void return; } + if (Input::getCurrentInput() === 'Q') { + $this->interactionMode = self::HIERARCHY_MODE_SELECT; + $this->refreshContent(); + return; + } + + if (Input::getCurrentInput() === 'W') { + $this->beginMoveWorkflow(); + return; + } + + if ($this->interactionMode === self::HIERARCHY_MODE_MOVE) { + if (Input::isKeyDown(KeyCode::DELETE)) { + $this->showDeleteConfirmModal(); + return; + } + + if (Input::isKeyDown(KeyCode::UP)) { + $this->queueMoveSelection(-1); + return; + } + + if (Input::isKeyDown(KeyCode::DOWN)) { + $this->queueMoveSelection(1); + return; + } + + return; + } + if (Input::getCurrentInput() === 'A') { $this->showAddObjectModal(); return; } + if (Input::getCurrentInput() === 'E') { + $this->beginPrefabCreationWorkflow(); + return; + } + + if (Input::getCurrentInput() === 'D') { + $this->beginDuplicationWorkflow(); + return; + } + if (Input::isKeyDown(KeyCode::DELETE)) { $this->showDeleteConfirmModal(); return; @@ -395,12 +724,16 @@ public function update(): void protected function decorateContentLine(string $line, ?Color $contentColor, int $lineIndex): string { - $selectedVisibleIndex = $this->getSelectedVisibleIndex(); - $selectedLineIndex = $selectedVisibleIndex === null - ? null - : $this->padding->topPadding + $selectedVisibleIndex; + $contentIndex = $this->getContentIndexForLineIndex($lineIndex); - if ($lineIndex !== $selectedLineIndex) { + if (!is_int($contentIndex)) { + return parent::decorateContentLine($line, $contentColor, $lineIndex); + } + + $entry = $this->visibleHierarchy[$contentIndex] ?? null; + $path = is_array($entry) ? ($entry['path'] ?? null) : null; + + if (!is_string($path) || !in_array($path, $this->selectedPaths, true)) { return parent::decorateContentLine($line, $contentColor, $lineIndex); } @@ -416,8 +749,12 @@ protected function decorateContentLine(string $line, ?Color $contentColor, int $ $rightBorder = mb_substr($visibleLine, -1); $borderColor = $this->hasFocus() ? $this->focusBorderColor : $contentColor; $selectionSequence = $this->hasFocus() - ? self::SELECTED_ROW_FOCUSED_SEQUENCE - : self::SELECTED_ROW_SEQUENCE; + ? ($this->interactionMode === self::HIERARCHY_MODE_MOVE && $path === $this->selectedPath + ? self::MOVE_ROW_FOCUSED_SEQUENCE + : self::SELECTED_ROW_FOCUSED_SEQUENCE) + : ($this->interactionMode === self::HIERARCHY_MODE_MOVE && $path === $this->selectedPath + ? self::MOVE_ROW_SEQUENCE + : self::SELECTED_ROW_SEQUENCE); return $this->wrapWithColor($leftBorder, $borderColor) . $this->wrapWithSequence($middle, $selectionSequence) @@ -432,6 +769,7 @@ private function refreshContent(): void fn(array $entry) => $this->formatVisibleHierarchyEntry($entry), $this->visibleHierarchy ); + $this->ensureContentLineVisible($this->getSelectedVisibleIndex()); } private function buildVisibleHierarchy(): array @@ -500,7 +838,15 @@ private function buildChildHierarchy(array $items, int $depth, string $parentPat private function syncSelectedPath(): void { + $this->selectedPaths = array_values(array_filter( + $this->selectedPaths, + fn (mixed $path): bool => is_string($path) && $this->findVisibleIndexByPath($path) !== null + )); + if ($this->selectedPath !== null && $this->findVisibleIndexByPath($this->selectedPath) !== null) { + if ($this->selectedPaths === []) { + $this->selectedPaths = [$this->selectedPath]; + } return; } @@ -511,11 +857,13 @@ private function syncSelectedPath(): void if ($candidatePath !== null && $this->findVisibleIndexByPath($candidatePath) !== null) { $this->selectedPath = $candidatePath; + $this->selectedPaths = [$candidatePath]; return; } } $this->selectedPath = $this->visibleHierarchy[0]['path'] ?? null; + $this->selectedPaths = $this->selectedPath !== null ? [$this->selectedPath] : []; } private function getSelectedVisibleNode(): ?array @@ -593,6 +941,53 @@ private function getChildItems(array $item): array return array_values($children); } + private function isExpandToggleClick(array $entry, int $x): bool + { + if (!($entry['hasChildren'] ?? false)) { + return false; + } + + $depth = max(0, (int) ($entry['depth'] ?? 0)); + $iconColumn = $this->getContentAreaLeft() + ($depth * 2); + + return $x === $iconColumn; + } + + private function toggleEntryExpansion(array $entry): void + { + $path = $entry['path'] ?? null; + + if (!is_string($path) || !($entry['hasChildren'] ?? false)) { + return; + } + + if ($entry['isExpanded'] ?? false) { + unset($this->expandedPaths[$path]); + } else { + $this->expandedPaths[$path] = true; + } + + $this->refreshContent(); + } + + private function registerClickAndCheckDoubleClick(string $path): bool + { + $now = microtime(true); + $isDoubleClick = $this->lastClickedPath === $path + && ($now - $this->lastClickedAt) <= self::DOUBLE_CLICK_THRESHOLD_SECONDS; + + $this->lastClickedPath = $path; + $this->lastClickedAt = $now; + + return $isDoubleClick; + } + + private function resetClickTracking(): void + { + $this->lastClickedPath = null; + $this->lastClickedAt = 0.0; + } + private function getParentPath(string $path): ?string { $separatorPosition = strrpos($path, '.'); @@ -604,18 +999,58 @@ private function getParentPath(string $path): ?string return substr($path, 0, $separatorPosition); } + private function queueMouseDropMoveRequest(string $targetPath): void + { + if (!is_string($this->draggedPath) || $this->draggedPath === '') { + return; + } + + $normalizedTargetPath = $targetPath === '' ? self::ROOT_PATH : $targetPath; + + if ( + $normalizedTargetPath === self::ROOT_PATH + && $this->getParentPath($this->draggedPath) === self::ROOT_PATH + ) { + return; + } + + if ( + $normalizedTargetPath !== self::ROOT_PATH + && ($normalizedTargetPath === $this->draggedPath || str_starts_with($normalizedTargetPath, $this->draggedPath . '.')) + ) { + return; + } + + $this->pendingMoveItem = [ + 'path' => $this->draggedPath, + 'targetPath' => $normalizedTargetPath, + 'position' => 'append_child', + ]; + } + private function showAddObjectModal(): void { $this->addObjectModal->show(['GameObject', 'UIElement']); + $this->addPlacementModal->hide(); $this->addUiElementModal->hide(); $this->deleteConfirmModal->hide(); $this->addModalState = self::ADD_MODAL_OBJECT_KIND; } + private function showAddPlacementModal(string $selectedName): void + { + $this->addPlacementModal->show(['Root', 'Child of ' . $selectedName]); + $this->addObjectModal->hide(); + $this->addUiElementModal->hide(); + $this->deleteConfirmModal->hide(); + $this->addModalState = self::ADD_MODAL_OBJECT_PLACEMENT; + } + private function showAddUiElementModal(): void { - $this->addUiElementModal->show(['Text', 'Label']); + $this->addUiElementModal->show(['Text', 'Label', 'GUITexture']); $this->addObjectModal->hide(); + $this->addPlacementModal->hide(); $this->deleteConfirmModal->hide(); $this->addModalState = self::ADD_MODAL_UI_KIND; } @@ -637,6 +1072,7 @@ private function showDeleteConfirmModal(): void 'Are you sure you want to delete ' . $selectedName . '?' ); $this->addObjectModal->hide(); + $this->addPlacementModal->hide(); $this->addUiElementModal->hide(); $this->addModalState = self::DELETE_MODAL_CONFIRM; } @@ -644,6 +1080,7 @@ private function showDeleteConfirmModal(): void private function dismissAddModals(): void { $this->addObjectModal->hide(); + $this->addPlacementModal->hide(); $this->addUiElementModal->hide(); $this->deleteConfirmModal->hide(); $this->addModalState = null; @@ -652,6 +1089,11 @@ private function dismissAddModals(): void private function handleModalInput(): void { if (Input::isKeyDown(KeyCode::ESCAPE)) { + if ($this->addModalState === self::ADD_MODAL_OBJECT_PLACEMENT) { + $this->showAddObjectModal(); + return; + } + if ($this->addModalState === self::ADD_MODAL_UI_KIND) { $this->showAddObjectModal(); return; @@ -663,6 +1105,7 @@ private function handleModalInput(): void $activeModal = match ($this->addModalState) { self::ADD_MODAL_OBJECT_KIND => $this->addObjectModal, + self::ADD_MODAL_OBJECT_PLACEMENT => $this->addPlacementModal, self::ADD_MODAL_UI_KIND => $this->addUiElementModal, self::DELETE_MODAL_CONFIRM => $this->deleteConfirmModal, default => null, @@ -697,6 +1140,11 @@ private function handleModalInput(): void return; } + if ($this->addModalState === self::ADD_MODAL_OBJECT_PLACEMENT) { + $this->handleAddObjectPlacementSelection($selectedOption); + return; + } + if ($this->addModalState === self::ADD_MODAL_UI_KIND) { $this->handleAddUiElementSelection($selectedOption); return; @@ -714,13 +1162,33 @@ private function handleAddObjectTypeSelection(string $selection): void return; } - $this->pendingCreationItem = $this->buildDefaultObjectDefinition($selection); + $selectedNode = $this->getSelectedVisibleNode(); + $selectedName = is_array($selectedNode['item'] ?? null) + ? (string) ($selectedNode['item']['name'] ?? 'Selected') + : 'Selected'; + + if ($selection === 'GameObject' && $this->resolveSelectedGameObjectPathForPlacement() !== null) { + $this->showAddPlacementModal($selectedName); + return; + } + + $this->pendingCreationItem = $this->buildCreationRequest($selection, null); $this->dismissAddModals(); } private function handleAddUiElementSelection(string $selection): void { - $this->pendingCreationItem = $this->buildDefaultObjectDefinition($selection); + $this->pendingCreationItem = $this->buildCreationRequest($selection, null); + $this->dismissAddModals(); + } + + private function handleAddObjectPlacementSelection(string $selection): void + { + $parentPath = $selection === 'Root' + ? null + : $this->resolveSelectedGameObjectPathForPlacement(); + + $this->pendingCreationItem = $this->buildCreationRequest('GameObject', $parentPath); $this->dismissAddModals(); } @@ -746,6 +1214,14 @@ private function handleDeleteConfirmationSelection(string $selection): void $this->dismissAddModals(); } + private function buildCreationRequest(string $selection, ?string $parentPath): array + { + return [ + 'value' => $this->buildDefaultObjectDefinition($selection), + 'parentPath' => $parentPath, + ]; + } + private function buildDefaultObjectDefinition(string $selection): array { $instanceName = $selection . ' #' . $this->getNextInstanceCount($selection); @@ -776,6 +1252,15 @@ private function buildDefaultObjectDefinition(string $selection): array 'size' => ['x' => 1, 'y' => 1], 'text' => $instanceName, ], + 'GUITexture' => [ + 'type' => self::GUI_TEXTURE_TYPE, + 'name' => $instanceName, + 'tag' => 'UI', + 'position' => ['x' => 0, 'y' => 0], + 'size' => ['x' => 1, 'y' => 1], + 'texture' => 'None', + 'color' => 'White', + ], default => [], }; } @@ -803,4 +1288,83 @@ private function countInstancesOfType(array $items, string $type): int return $count; } + + private function resolveSelectedGameObjectPathForPlacement(): ?string + { + $selectedNode = $this->getSelectedVisibleNode(); + + if (($selectedNode['kind'] ?? null) !== 'object') { + return null; + } + + $selectedItem = $selectedNode['item'] ?? null; + $selectedPath = $selectedNode['path'] ?? null; + + if (!is_array($selectedItem) || !is_string($selectedPath) || $selectedPath === '') { + return null; + } + + $normalizedType = ltrim((string) ($selectedItem['type'] ?? ''), '\\'); + $normalizedType = preg_replace('/::class$/', '', $normalizedType) ?? $normalizedType; + + return $normalizedType === self::GAME_OBJECT_TYPE ? $selectedPath : null; + } + + private function beginMoveWorkflow(): void + { + $selectedNode = $this->getSelectedVisibleNode(); + + if (($selectedNode['kind'] ?? null) !== 'object') { + return; + } + + $this->interactionMode = self::HIERARCHY_MODE_MOVE; + $this->selectedPaths = $this->selectedPath !== null ? [$this->selectedPath] : []; + $this->refreshContent(); + } + + private function queueMoveSelection(int $offset): void + { + if (!is_string($this->selectedPath) || $this->selectedPath === '') { + return; + } + + $movableEntries = array_values(array_filter( + $this->visibleHierarchy, + fn(array $entry): bool => ($entry['kind'] ?? null) === 'object' + && ($entry['path'] ?? null) !== null + && !str_starts_with((string) ($entry['path'] ?? ''), $this->selectedPath . '.') + )); + + $selectedIndex = null; + + foreach ($movableEntries as $index => $entry) { + if (($entry['path'] ?? null) === $this->selectedPath) { + $selectedIndex = $index; + break; + } + } + + if (!is_int($selectedIndex)) { + return; + } + + $targetIndex = max(0, min($selectedIndex + $offset, count($movableEntries) - 1)); + + if ($targetIndex === $selectedIndex) { + return; + } + + $targetPath = $movableEntries[$targetIndex]['path'] ?? null; + + if (!is_string($targetPath) || $targetPath === '') { + return; + } + + $this->pendingMoveItem = [ + 'path' => $this->selectedPath, + 'targetPath' => $targetPath, + 'position' => $offset < 0 ? 'before' : 'after', + ]; + } } diff --git a/src/Editor/Widgets/InspectorPanel.php b/src/Editor/Widgets/InspectorPanel.php index 24814ea..21e5ed3 100644 --- a/src/Editor/Widgets/InspectorPanel.php +++ b/src/Editor/Widgets/InspectorPanel.php @@ -2,10 +2,16 @@ namespace Sendama\Console\Editor\Widgets; +use Atatusoft\Termutil\Events\MouseEvent; use Atatusoft\Termutil\IO\Enumerations\Color; use RecursiveDirectoryIterator; use RecursiveIteratorIterator; use ReflectionClass; +use ReflectionNamedType; +use ReflectionProperty; +use ReflectionUnionType; +use Sendama\Engine\IO\Enumerations\Color as EngineColor; +use Sendama\Console\Editor\PrefabLoader; use Throwable; use Sendama\Console\Editor\FocusTargetContext; use Sendama\Console\Editor\IO\Enumerations\KeyCode; @@ -16,18 +22,22 @@ 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\PrefabReferenceInputControl; use Sendama\Console\Editor\Widgets\Controls\PreviewWindowControl; use Sendama\Console\Editor\Widgets\Controls\SectionControl; +use Sendama\Console\Editor\Widgets\Controls\SelectInputControl; use Sendama\Console\Editor\Widgets\Controls\TextInputControl; use Sendama\Console\Editor\Widgets\Controls\VectorInputControl; class InspectorPanel extends Widget { + private const float DOUBLE_CLICK_THRESHOLD_SECONDS = 0.35; private const string STATE_CONTROL_SELECTION = 'control_selection'; private const string STATE_PROPERTY_SELECTION = 'property_selection'; private const string STATE_CONTROL_EDIT = 'control_edit'; private const string STATE_PATH_INPUT_ACTION_SELECTION = 'path_input_action_selection'; private const string STATE_PATH_INPUT_FILE_DIALOG = 'path_input_file_dialog'; + private const string STATE_PREFAB_REFERENCE_SELECTION = 'prefab_reference_selection'; private const string SECTION_HEADER_SEQUENCE = "\033[30;47m"; private const string SECTION_HEADER_SELECTED_SEQUENCE = "\033[30;104m"; private const string SELECTED_CONTROL_SEQUENCE = "\033[30;46m"; @@ -50,17 +60,17 @@ class InspectorPanel extends Widget protected ?int $selectedControlIndex = null; protected array $lineKinds = []; protected array $lineStates = []; + protected array $lineControlIndexes = []; protected string $interactionState = self::STATE_CONTROL_SELECTION; protected InputControlFactory $inputControlFactory; - protected ?PathInputControl $rendererTextureControl = null; - protected ?VectorInputControl $rendererOffsetControl = null; - protected ?VectorInputControl $rendererSizeControl = null; - protected ?PreviewWindowControl $rendererPreviewControl = null; + protected array $texturePreviewRegistrations = []; protected OptionListModal $pathInputActionModal; protected FileDialogModal $fileDialogModal; protected OptionListModal $addComponentModal; protected OptionListModal $deleteComponentModal; + protected OptionListModal $prefabReferenceModal; protected ?PathInputControl $activePathInputControl = null; + protected ?PrefabReferenceInputControl $activePrefabReferenceControl = null; protected array $controlBindings = []; protected array $controlMetadata = []; protected ?array $pendingHierarchyMutation = null; @@ -72,8 +82,30 @@ class InspectorPanel extends Widget protected ?array $cachedProjectComponentCandidates = null; protected bool $isComponentMoveModeActive = false; protected ?int $pendingComponentDeletionIndex = null; + protected array $prefabReferenceOptions = []; protected string $modeHelpLabel = ''; protected bool $shouldRefreshModalBackground = false; + protected ?int $lastClickedControlIndex = null; + protected float $lastClickedControlAt = 0.0; + private const string GUI_TEXTURE_TYPE = 'Sendama\\Engine\\UI\\GUITexture\\GUITexture'; + private const array GUI_TEXTURE_COLOR_OPTIONS = [ + 'Black', + 'Dark Gray', + 'Blue', + 'Light Blue', + 'Green', + 'Light Green', + 'Cyan', + 'Light Cyan', + 'Red', + 'Light Red', + 'Purple', + 'Light Purple', + 'Brown', + 'Yellow', + 'Light Gray', + 'White', + ]; public function __construct( array $position = ['x' => 135, 'y' => 1], @@ -88,6 +120,7 @@ public function __construct( $this->fileDialogModal = new FileDialogModal(); $this->addComponentModal = new OptionListModal(title: 'Add Component'); $this->deleteComponentModal = new OptionListModal(title: 'Remove Component'); + $this->prefabReferenceModal = new OptionListModal(title: 'Choose Prefab'); $this->projectDirectory = is_string($workingDirectory) && $workingDirectory !== '' ? $workingDirectory : (getcwd() ?: '.'); @@ -98,6 +131,36 @@ public function setSceneHierarchy(array $hierarchy): void $this->sceneHierarchy = $hierarchy; } + public function handleMouseClick(int $x, int $y): void + { + if (!$this->containsPoint($x, $y) || $this->hasActiveModal()) { + return; + } + + $controlIndex = $this->resolveControlIndexFromPoint($x, $y); + + if (!is_int($controlIndex)) { + return; + } + + if ($this->interactionState !== self::STATE_CONTROL_SELECTION) { + $this->resetInteractionState(); + } + + $this->selectControlByIndex($controlIndex); + $selectedControl = $this->getSelectedControl(); + + if (!$selectedControl instanceof InputControl) { + return; + } + + if (!$this->registerControlClickAndCheckDoubleClick($controlIndex)) { + return; + } + + $this->activateSelectedControl($selectedControl); + } + public function inspectTarget(?array $target): void { $preserveSelectedControl = $this->shouldPreserveSelectedControl($this->inspectionTarget, $target); @@ -110,15 +173,16 @@ public function inspectTarget(?array $target): void $this->focusableControls = []; $this->selectedControlIndex = null; $this->interactionState = self::STATE_CONTROL_SELECTION; - $this->rendererTextureControl = null; - $this->rendererOffsetControl = null; - $this->rendererSizeControl = null; - $this->rendererPreviewControl = null; + $this->lastClickedControlIndex = null; + $this->lastClickedControlAt = 0.0; + $this->texturePreviewRegistrations = []; $this->pathInputActionModal->hide(); $this->fileDialogModal->hide(); $this->addComponentModal->hide(); $this->deleteComponentModal->hide(); + $this->prefabReferenceModal->hide(); $this->activePathInputControl = null; + $this->activePrefabReferenceControl = null; $this->controlBindings = []; $this->controlMetadata = []; $this->pendingHierarchyMutation = null; @@ -127,6 +191,7 @@ public function inspectTarget(?array $target): void $this->componentMenuDefinitions = []; $this->isComponentMoveModeActive = false; $this->pendingComponentDeletionIndex = null; + $this->prefabReferenceOptions = []; if ($target === null) { $this->content = []; @@ -162,6 +227,11 @@ public function inspectTarget(?array $target): void $this->refreshContent(); } + public function getInspectionTarget(): ?array + { + return $this->inspectionTarget; + } + public function focus(FocusTargetContext $context): void { parent::focus($context); @@ -178,6 +248,8 @@ public function focus(FocusTargetContext $context): void public function blur(FocusTargetContext $context): void { $this->resetInteractionState(); + $this->lastClickedControlIndex = null; + $this->lastClickedControlAt = 0.0; parent::blur($context); $this->refreshContent(); } @@ -187,7 +259,8 @@ public function hasActiveModal(): bool return $this->pathInputActionModal->isVisible() || $this->fileDialogModal->isVisible() || $this->addComponentModal->isVisible() - || $this->deleteComponentModal->isVisible(); + || $this->deleteComponentModal->isVisible() + || $this->prefabReferenceModal->isVisible(); } public function isModalDirty(): bool @@ -195,7 +268,8 @@ public function isModalDirty(): bool return $this->pathInputActionModal->isDirty() || $this->fileDialogModal->isDirty() || $this->addComponentModal->isDirty() - || $this->deleteComponentModal->isDirty(); + || $this->deleteComponentModal->isDirty() + || $this->prefabReferenceModal->isDirty(); } public function markModalClean(): void @@ -204,6 +278,7 @@ public function markModalClean(): void $this->fileDialogModal->markClean(); $this->addComponentModal->markClean(); $this->deleteComponentModal->markClean(); + $this->prefabReferenceModal->markClean(); } public function syncModalLayout(int $terminalWidth, int $terminalHeight): void @@ -212,6 +287,7 @@ public function syncModalLayout(int $terminalWidth, int $terminalHeight): void $this->fileDialogModal->syncLayout($terminalWidth, $terminalHeight); $this->addComponentModal->syncLayout($terminalWidth, $terminalHeight); $this->deleteComponentModal->syncLayout($terminalWidth, $terminalHeight); + $this->prefabReferenceModal->syncLayout($terminalWidth, $terminalHeight); } public function renderActiveModal(): void @@ -231,6 +307,115 @@ public function renderActiveModal(): void if ($this->deleteComponentModal->isVisible()) { $this->deleteComponentModal->render(); } + + if ($this->prefabReferenceModal->isVisible()) { + $this->prefabReferenceModal->render(); + } + } + + public function handleModalMouseEvent(MouseEvent $mouseEvent): bool + { + if ($this->addComponentModal->isVisible()) { + if ($this->addComponentModal->handleScrollbarMouseEvent($mouseEvent)) { + return true; + } + + $isWithinModal = $this->addComponentModal->containsPoint($mouseEvent->x, $mouseEvent->y); + + if ($mouseEvent->buttonIndex !== 0 || $mouseEvent->action !== 'Pressed') { + return $isWithinModal; + } + + $selection = $this->addComponentModal->clickOptionAtPoint($mouseEvent->x, $mouseEvent->y); + + if (is_string($selection) && $selection !== '') { + $this->applyAddComponentSelection($selection); + } + + return $isWithinModal; + } + + if ($this->deleteComponentModal->isVisible()) { + if ($this->deleteComponentModal->handleScrollbarMouseEvent($mouseEvent)) { + return true; + } + + $isWithinModal = $this->deleteComponentModal->containsPoint($mouseEvent->x, $mouseEvent->y); + + if ($mouseEvent->buttonIndex !== 0 || $mouseEvent->action !== 'Pressed') { + return $isWithinModal; + } + + $selection = $this->deleteComponentModal->clickOptionAtPoint($mouseEvent->x, $mouseEvent->y); + + if (is_string($selection) && $selection !== '') { + $this->applyDeleteComponentSelection($selection); + } + + return $isWithinModal; + } + + if ($this->prefabReferenceModal->isVisible()) { + if ($this->prefabReferenceModal->handleScrollbarMouseEvent($mouseEvent)) { + return true; + } + + $isWithinModal = $this->prefabReferenceModal->containsPoint($mouseEvent->x, $mouseEvent->y); + + if ($mouseEvent->buttonIndex !== 0 || $mouseEvent->action !== 'Pressed') { + return $isWithinModal; + } + + $selection = $this->prefabReferenceModal->clickOptionAtPoint($mouseEvent->x, $mouseEvent->y); + + if (is_string($selection) && $selection !== '') { + $this->applyPrefabReferenceSelection($selection); + } + + return $isWithinModal; + } + + if ($this->pathInputActionModal->isVisible()) { + if ($this->pathInputActionModal->handleScrollbarMouseEvent($mouseEvent)) { + return true; + } + + $isWithinModal = $this->pathInputActionModal->containsPoint($mouseEvent->x, $mouseEvent->y); + + if ($mouseEvent->buttonIndex !== 0 || $mouseEvent->action !== 'Pressed') { + return $isWithinModal; + } + + $selection = $this->pathInputActionModal->clickOptionAtPoint($mouseEvent->x, $mouseEvent->y); + + if (is_string($selection) && $selection !== '') { + $this->applyPathInputActionSelection($selection); + } + + return $isWithinModal; + } + + if ($this->fileDialogModal->isVisible()) { + if ($this->fileDialogModal->handleScrollbarMouseEvent($mouseEvent)) { + return true; + } + + $isWithinModal = $this->fileDialogModal->containsPoint($mouseEvent->x, $mouseEvent->y); + + if ($mouseEvent->buttonIndex !== 0 || $mouseEvent->action !== 'Pressed') { + return $isWithinModal; + } + + $selectedPath = $this->fileDialogModal->clickEntryAtPoint($mouseEvent->x, $mouseEvent->y); + + if (is_string($selectedPath) && $selectedPath !== '') { + $this->applyPathInputFileSelection($selectedPath); + } + + return $isWithinModal; + } + + return false; } public function consumeHierarchyMutation(): ?array @@ -386,6 +571,11 @@ public function update(): void return; } + if ($this->prefabReferenceModal->isVisible()) { + $this->handlePrefabReferenceModalInput(); + return; + } + if ($this->interactionState === self::STATE_PATH_INPUT_ACTION_SELECTION) { $this->handlePathInputActionInput(); return; @@ -424,7 +614,12 @@ public function renderAt(?int $x = null, ?int $y = null): void protected function decorateContentLine(string $line, ?Color $contentColor, int $lineIndex): string { - $contentIndex = $lineIndex - $this->padding->topPadding; + $contentIndex = $this->getContentIndexForLineIndex($lineIndex); + + if (!is_int($contentIndex)) { + return parent::decorateContentLine($line, $contentColor, $lineIndex); + } + $lineKind = $this->lineKinds[$contentIndex] ?? null; if ($lineKind === 'section_header') { @@ -451,7 +646,12 @@ protected function buildBorderLine(string $label, bool $isTopBorder): string private function decorateSectionHeaderLine(string $line, ?Color $contentColor, int $lineIndex): string { - $contentIndex = $lineIndex - $this->padding->topPadding; + $contentIndex = $this->getContentIndexForLineIndex($lineIndex); + + if (!is_int($contentIndex)) { + return parent::decorateContentLine($line, $contentColor, $lineIndex); + } + $lineState = $this->lineStates[$contentIndex] ?? 'normal'; $visibleLine = mb_substr($line, 0, $this->width); $visibleLength = mb_strlen($visibleLine); @@ -538,15 +738,21 @@ private function buildHierarchyControls(array $target, array $item): void ['scale'], ); + $sizeControl = null; + if (isset($item['size']) && is_array($item['size'])) { - $this->addBoundControl( - new VectorInputControl('Size', $this->normalizeVector($item['size']), 1), - ['size'], - ); + $sizeControl = new VectorInputControl('Size', $this->resolveInspectableSize($item), 1); + $this->addBoundControl($sizeControl, ['size']); + } + + if ($this->isGuiTextureItem($item)) { + $this->addControl($this->addSectionHeader('Texture')); + $this->addGuiTextureControls($item, $sizeControl instanceof VectorInputControl ? $sizeControl : null); + } else { + $this->addControl($this->addSectionHeader('Renderer')); + $this->addRendererControls($item); } - $this->addControl($this->addSectionHeader('Renderer')); - $this->addRendererControls($item); $this->addScriptComponents($item['components'] ?? []); } @@ -583,15 +789,21 @@ private function buildPrefabControls(array $target, array $item): void ['scale'], ); + $sizeControl = null; + if (isset($item['size']) && is_array($item['size'])) { - $this->addBoundControl( - new VectorInputControl('Size', $this->normalizeVector($item['size']), 1), - ['size'], - ); + $sizeControl = new VectorInputControl('Size', $this->resolveInspectableSize($item), 1); + $this->addBoundControl($sizeControl, ['size']); + } + + if ($this->isGuiTextureItem($item)) { + $this->addControl($this->addSectionHeader('Texture')); + $this->addGuiTextureControls($item, $sizeControl instanceof VectorInputControl ? $sizeControl : null); + } else { + $this->addControl($this->addSectionHeader('Renderer')); + $this->addRendererControls($item); } - $this->addControl($this->addSectionHeader('Renderer')); - $this->addRendererControls($item); $this->addScriptComponents($item['components'] ?? []); } @@ -673,31 +885,67 @@ private function addRendererControls(array $item): void $offset = $this->normalizeVector($texture['position'] ?? null); $size = $this->normalizeVector($texture['size'] ?? null); - $this->rendererTextureControl = new PathInputControl( + $textureControl = new PathInputControl( 'Texture', $texturePath, $this->resolveAssetsWorkingDirectory(), ['texture'], 1, ); - $this->rendererOffsetControl = new VectorInputControl('Offset', $offset, 1); - $this->rendererSizeControl = new VectorInputControl('Size', $size, 1); - $this->rendererPreviewControl = new PreviewWindowControl( + $offsetControl = new VectorInputControl('Offset', $offset, 1); + $sizeControl = new VectorInputControl('Size', $size, 1); + $previewControl = new PreviewWindowControl( 'Preview', - $this->buildTexturePreviewLines($texturePath, $offset, $size), + $this->buildTexturePreviewLines($texturePath, $offset, $size, true), 1, ); - $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); + $this->addBoundControl($textureControl, ['sprite', 'texture', 'path']); + $this->addBoundControl($offsetControl, ['sprite', 'texture', 'position']); + $this->addBoundControl($sizeControl, ['sprite', 'texture', 'size']); + $this->addControl($previewControl); + $this->registerTexturePreview($textureControl, $sizeControl, $previewControl, $offsetControl, true); if (array_key_exists('text', $item)) { $this->addBoundControl(new TextInputControl('Text', $item['text'], 1), ['text']); } } + private function addGuiTextureControls(array $item, ?VectorInputControl $sizeControl = null): void + { + $texturePath = is_string($item['texture'] ?? null) && trim($item['texture']) !== '' + ? trim((string) $item['texture']) + : 'None'; + $colorOptions = $this->resolveGuiTextureColorOptions(); + $selectedColor = $this->normalizeGuiTextureColor($item['color'] ?? null); + $resolvedSizeControl = $sizeControl ?? new VectorInputControl('Size', $this->normalizeGuiTextureSize($this->normalizeVector($item['size'] ?? null)), 1); + $textureControl = new PathInputControl( + 'Texture', + $texturePath, + $this->resolveAssetsWorkingDirectory(), + ['texture'], + 1, + ); + $previewControl = new PreviewWindowControl( + 'Preview', + $this->buildTexturePreviewLines($texturePath, ['x' => 0, 'y' => 0], $resolvedSizeControl->getValue(), false), + 1, + ); + + $this->addBoundControl($textureControl, ['texture']); + $this->addBoundControl( + new SelectInputControl( + 'Color', + $colorOptions, + in_array($selectedColor, $colorOptions, true) ? $selectedColor : EngineColor::WHITE->getPhoneticName(), + 1, + ), + ['color'], + ); + $this->addControl($previewControl); + $this->registerTexturePreview($textureControl, $resolvedSizeControl, $previewControl, null, false); + } + private function addScriptComponents(mixed $components): void { if (!is_array($components)) { @@ -710,6 +958,9 @@ private function addScriptComponents(mixed $components): void } $serializedComponentData = is_array($component['data'] ?? null) ? $component['data'] : null; + $componentFieldTypes = is_array($component['__editorFieldTypes'] ?? null) + ? $component['__editorFieldTypes'] + : []; if (is_array($serializedComponentData)) { $this->addControl( @@ -724,6 +975,8 @@ private function addScriptComponents(mixed $components): void $this->addComponentPropertyControls( $serializedComponentData, ['components', $componentIndex, 'data'], + 1, + $componentFieldTypes, ); continue; } @@ -751,11 +1004,18 @@ private function addScriptComponents(mixed $components): void $this->addComponentPropertyControls( $legacyComponentData, ['components', $componentIndex], + 1, + $componentFieldTypes, ); } } - private function addComponentPropertyControls(array $properties, array $basePath, int $indentLevel = 1): void + private function addComponentPropertyControls( + array $properties, + array $basePath, + int $indentLevel = 1, + array $fieldTypes = [], + ): void { foreach ($properties as $key => $value) { if (!is_string($key)) { @@ -771,21 +1031,64 @@ private function addComponentPropertyControls(array $properties, array $basePath $value, [...$basePath, $key], $indentLevel + 1, + is_array($fieldTypes[$key] ?? null) ? $fieldTypes[$key] : [], ); continue; } $this->addBoundControl( - $this->inputControlFactory->create( + $this->buildComponentPropertyControl( $this->humanizeKey($key), $value, $indentLevel, + is_string($fieldTypes[$key] ?? null) ? $fieldTypes[$key] : null, ), [...$basePath, $key], ); } } + private function buildComponentPropertyControl( + string $label, + mixed $value, + int $indentLevel, + ?string $fieldType = null, + ): InputControl { + if ($this->isPrefabAssignableGameObjectField($fieldType)) { + return new PrefabReferenceInputControl( + $label, + $value, + $this->resolvePrefabDisplayLabelsByPath(), + $indentLevel, + ); + } + + return $this->inputControlFactory->createForFieldType($label, $value, $fieldType, $indentLevel); + } + + private function isPrefabAssignableGameObjectField(?string $fieldType): bool + { + if (!is_string($fieldType) || trim($fieldType) === '') { + return false; + } + + $normalizedTypes = array_map( + static fn(string $type): string => ltrim(trim($type), '\\'), + explode('|', $fieldType), + ); + + foreach ($normalizedTypes as $normalizedType) { + if (in_array($normalizedType, [ + 'Sendama\\Engine\\Core\\GameObject', + 'Sendama\\Engine\\Core\\Interfaces\\GameObjectInterface', + ], true)) { + return true; + } + } + + return false; + } + private function shouldRenderNestedComponentProperties(mixed $value): bool { if (!is_array($value) || $value === []) { @@ -867,6 +1170,7 @@ private function refreshContent(): void $content = []; $lineKinds = []; $lineStates = []; + $lineControlIndexes = []; $collapsedSectionIndentLevels = []; foreach ($this->elements as $element) { @@ -889,10 +1193,14 @@ private function refreshContent(): void continue; } + $controlIndex = array_search($control, $this->focusableControls, true); + $lineControlIndex = is_int($controlIndex) ? $controlIndex : null; + foreach ($control->renderLineDefinitions() as $lineDefinition) { $content[] = $lineDefinition['text'] ?? ''; $lineKinds[] = $lineDefinition['kind'] ?? 'control'; $lineStates[] = $lineDefinition['state'] ?? 'normal'; + $lineControlIndexes[] = $lineControlIndex; } if ($control instanceof SectionControl && $control->isCollapsed()) { @@ -903,6 +1211,8 @@ private function refreshContent(): void $this->content = $content; $this->lineKinds = $lineKinds; $this->lineStates = $lineStates; + $this->lineControlIndexes = $lineControlIndexes; + $this->ensureContentLineVisible($this->resolveSelectedContentIndex()); } private function updateHelpInfo(): void @@ -919,6 +1229,12 @@ private function updateHelpInfo(): void return; } + if ($this->prefabReferenceModal->isVisible()) { + $this->help = 'Up/Down choose Enter assign Esc cancel'; + $this->modeHelpLabel = 'Mode: Prefab Picker'; + return; + } + if ($this->interactionState === self::STATE_PATH_INPUT_ACTION_SELECTION) { $this->help = 'Up/Down choose Enter select Esc cancel'; $this->modeHelpLabel = 'Mode: Path Action'; @@ -987,6 +1303,12 @@ private function updateHelpInfo(): void return; } + if ($selectedControl instanceof PrefabReferenceInputControl) { + $this->help = 'Up/Down select Enter choose prefab Tab next'; + $this->modeHelpLabel = 'Mode: Control Select'; + return; + } + $this->help = 'Up/Down select Enter edit Shift+A add Tab next'; $this->modeHelpLabel = 'Mode: Control Select'; } @@ -1012,26 +1334,35 @@ private function buildSplitHelpBorder(string $leftLabel, string $rightLabel): st private function refreshDerivedControls(): void { - if ( - !$this->rendererTextureControl instanceof PathInputControl - || !$this->rendererOffsetControl instanceof VectorInputControl - || !$this->rendererSizeControl instanceof VectorInputControl - || !$this->rendererPreviewControl instanceof PreviewWindowControl - ) { - return; - } + foreach ($this->texturePreviewRegistrations as $registration) { + $textureControl = $registration['texture'] ?? null; + $sizeControl = $registration['size'] ?? null; + $previewControl = $registration['preview'] ?? null; + $offsetControl = $registration['offset'] ?? null; + $naturalSizeFallback = (bool) ($registration['naturalSizeFallback'] ?? true); - $texturePath = (string) $this->rendererTextureControl->getValue(); - $offset = $this->rendererOffsetControl->getValue(); - $size = $this->rendererSizeControl->getValue(); + if ( + !$textureControl instanceof PathInputControl + || !$sizeControl instanceof VectorInputControl + || !$previewControl instanceof PreviewWindowControl + ) { + continue; + } - if (!is_array($offset) || !is_array($size)) { - return; - } + $texturePath = (string) $textureControl->getValue(); + $size = $sizeControl->getValue(); + $offset = $offsetControl instanceof VectorInputControl + ? $offsetControl->getValue() + : ['x' => 0, 'y' => 0]; - $this->rendererPreviewControl->setValue( - $this->buildTexturePreviewLines($texturePath, $offset, $size) - ); + if (!is_array($size) || !is_array($offset)) { + continue; + } + + $previewControl->setValue( + $this->buildTexturePreviewLines($texturePath, $offset, $size, $naturalSizeFallback) + ); + } } private function applyControlSelection(): void @@ -1162,6 +1493,16 @@ private function handleControlSelectionInput(InputControl $selectedControl): voi return; } + $this->activateSelectedControl($selectedControl); + } + + private function activateSelectedControl(InputControl $selectedControl): void + { + if ($selectedControl instanceof PrefabReferenceInputControl) { + $this->showPrefabReferenceModal($selectedControl); + return; + } + if ($selectedControl instanceof PathInputControl) { $this->showPathInputActionModal($selectedControl); return; @@ -1170,6 +1511,7 @@ private function handleControlSelectionInput(InputControl $selectedControl): voi if ($selectedControl instanceof CompoundInputControl) { if ($selectedControl->beginPropertySelection()) { $this->interactionState = self::STATE_PROPERTY_SELECTION; + $this->refreshContent(); } return; @@ -1177,6 +1519,7 @@ private function handleControlSelectionInput(InputControl $selectedControl): voi if ($selectedControl->enterEditMode()) { $this->interactionState = self::STATE_CONTROL_EDIT; + $this->refreshContent(); } } @@ -1290,6 +1633,7 @@ private function resetInteractionState(): void $this->closePathInputModals(); $this->closeAddComponentModal(); $this->closeDeleteComponentModal(); + $this->closePrefabReferenceModal(); $selectedControl = $this->getSelectedControl(); if ($selectedControl instanceof CompoundInputControl) { @@ -1328,22 +1672,7 @@ private function handleAddComponentModalInput(): void return; } - $selection = $this->addComponentModal->getSelectedOption(); - - if (!is_string($selection) || $selection === '' || $selection === 'Cancel') { - $this->closeAddComponentModal(); - $this->refreshContent(); - return; - } - - $componentDefinition = $this->componentMenuDefinitions[$selection] ?? null; - - if (is_array($componentDefinition)) { - $this->appendComponentToInspectionTarget($componentDefinition); - } - - $this->closeAddComponentModal(); - $this->refreshContent(); + $this->applyAddComponentSelection($this->addComponentModal->getSelectedOption()); } private function handleDeleteComponentModalInput(): void @@ -1368,14 +1697,7 @@ private function handleDeleteComponentModalInput(): void return; } - $selection = $this->deleteComponentModal->getSelectedOption(); - - if ($selection === 'Delete' && is_int($this->pendingComponentDeletionIndex)) { - $this->removeComponentAtIndex($this->pendingComponentDeletionIndex); - } - - $this->closeDeleteComponentModal(); - $this->refreshContent(); + $this->applyDeleteComponentSelection($this->deleteComponentModal->getSelectedOption()); } private function handlePathInputActionInput(): void @@ -1401,34 +1723,7 @@ private function handlePathInputActionInput(): void return; } - $selectedOption = $this->pathInputActionModal->getSelectedOption(); - - if ($selectedOption === 'Choose file') { - $this->pathInputActionModal->hide(); - - if ($this->activePathInputControl instanceof PathInputControl) { - $this->fileDialogModal->show( - $this->activePathInputControl->getWorkingDirectory(), - (string) $this->activePathInputControl->getValue(), - $this->activePathInputControl->getAllowedExtensions(), - ); - $this->interactionState = self::STATE_PATH_INPUT_FILE_DIALOG; - } - - return; - } - - if ($selectedOption === 'Edit path' && $this->activePathInputControl instanceof PathInputControl) { - $this->requestModalBackgroundRefresh(); - $this->pathInputActionModal->hide(); - - if ($this->activePathInputControl->enterEditMode()) { - $this->interactionState = self::STATE_CONTROL_EDIT; - } else { - $this->closePathInputModals(); - $this->interactionState = self::STATE_CONTROL_SELECTION; - } - } + $this->applyPathInputActionSelection($this->pathInputActionModal->getSelectedOption()); } private function handlePathInputFileDialogInput(): void @@ -1471,17 +1766,7 @@ private function handlePathInputFileDialogInput(): void return; } - $selectedPath = $this->fileDialogModal->submitSelection(); - - if ($selectedPath === null || !$this->activePathInputControl instanceof PathInputControl) { - return; - } - - $this->activePathInputControl->setValueFromRelativePath($selectedPath); - $this->applyControlValueToInspectionTarget($this->activePathInputControl); - $this->closePathInputModals(); - $this->interactionState = self::STATE_CONTROL_SELECTION; - $this->refreshContent(); + $this->applyPathInputFileSelection($this->fileDialogModal->submitSelection()); } private function showPathInputActionModal(PathInputControl $control): void @@ -1502,30 +1787,195 @@ private function closePathInputModals(): void $this->activePathInputControl = null; } - private function requestModalBackgroundRefresh(): void + private function showPrefabReferenceModal(PrefabReferenceInputControl $control): void { - $this->shouldRefreshModalBackground = true; - } + $this->activePrefabReferenceControl = $control; + $this->prefabReferenceOptions = $this->resolveAvailablePrefabOptions(); + $options = ['None', ...array_keys($this->prefabReferenceOptions), 'Cancel']; + $selectedIndex = 0; + $currentValue = $control->getValue(); - private function canOpenAddComponentModal(): bool - { - return is_array($this->inspectionTarget) - && in_array($this->inspectionTarget['context'] ?? null, ['hierarchy', 'prefab'], true) - && is_string($this->inspectionTarget['path'] ?? null) - && ($this->inspectionTarget['path'] ?? null) !== 'scene' - && is_array($this->inspectionTarget['value'] ?? null); - } + if (is_string($currentValue) && $currentValue !== '') { + foreach ($this->prefabReferenceOptions as $label => $definition) { + if (($definition['path'] ?? null) === $currentValue) { + $optionIndex = array_search($label, $options, true); - private function queueObjectInspectionMutation(array $target, array $inspectionValue): void - { - $context = $target['context'] ?? null; - $path = $target['path'] ?? null; + if (is_int($optionIndex)) { + $selectedIndex = $optionIndex; + } - if (!is_string($path) || $path === '') { - return; + break; + } + } } - if ($context === 'hierarchy') { + $this->prefabReferenceModal->show($options, $selectedIndex, 'Choose Prefab'); + $this->interactionState = self::STATE_PREFAB_REFERENCE_SELECTION; + $terminalSize = get_max_terminal_size(); + $terminalWidth = $terminalSize['width'] ?? DEFAULT_TERMINAL_WIDTH; + $terminalHeight = $terminalSize['height'] ?? DEFAULT_TERMINAL_HEIGHT; + $this->syncModalLayout($terminalWidth, $terminalHeight); + } + + private function handlePrefabReferenceModalInput(): void + { + if (Input::isKeyDown(KeyCode::ESCAPE)) { + $this->closePrefabReferenceModal(); + $this->interactionState = self::STATE_CONTROL_SELECTION; + $this->refreshContent(); + return; + } + + if (Input::isKeyDown(KeyCode::UP)) { + $this->prefabReferenceModal->moveSelection(-1); + return; + } + + if (Input::isKeyDown(KeyCode::DOWN)) { + $this->prefabReferenceModal->moveSelection(1); + return; + } + + if (!Input::isKeyDown(KeyCode::ENTER)) { + return; + } + + $this->applyPrefabReferenceSelection($this->prefabReferenceModal->getSelectedOption()); + } + + private function closePrefabReferenceModal(): void + { + $this->prefabReferenceModal->hide(); + $this->activePrefabReferenceControl = null; + $this->prefabReferenceOptions = []; + } + + private function applyAddComponentSelection(?string $selection): void + { + if (!is_string($selection) || $selection === '' || $selection === 'Cancel') { + $this->closeAddComponentModal(); + $this->refreshContent(); + return; + } + + $componentDefinition = $this->componentMenuDefinitions[$selection] ?? null; + + if (is_array($componentDefinition)) { + $this->appendComponentToInspectionTarget($componentDefinition); + } + + $this->closeAddComponentModal(); + $this->refreshContent(); + } + + private function applyDeleteComponentSelection(?string $selection): void + { + if ($selection === 'Delete' && is_int($this->pendingComponentDeletionIndex)) { + $this->removeComponentAtIndex($this->pendingComponentDeletionIndex); + } + + $this->closeDeleteComponentModal(); + $this->refreshContent(); + } + + private function applyPathInputActionSelection(?string $selection): void + { + if ($selection === 'Choose file') { + $this->pathInputActionModal->hide(); + + if ($this->activePathInputControl instanceof PathInputControl) { + $this->fileDialogModal->show( + $this->activePathInputControl->getWorkingDirectory(), + (string) $this->activePathInputControl->getValue(), + $this->activePathInputControl->getAllowedExtensions(), + ); + $this->interactionState = self::STATE_PATH_INPUT_FILE_DIALOG; + } + + return; + } + + if ($selection !== 'Edit path' || !$this->activePathInputControl instanceof PathInputControl) { + return; + } + + $this->requestModalBackgroundRefresh(); + $this->pathInputActionModal->hide(); + + if ($this->activePathInputControl->enterEditMode()) { + $this->interactionState = self::STATE_CONTROL_EDIT; + } else { + $this->closePathInputModals(); + $this->interactionState = self::STATE_CONTROL_SELECTION; + } + + $this->refreshContent(); + } + + private function applyPathInputFileSelection(?string $selectedPath): void + { + if ($selectedPath === null || !$this->activePathInputControl instanceof PathInputControl) { + return; + } + + $this->activePathInputControl->setValueFromRelativePath($selectedPath); + $this->applyControlValueToInspectionTarget($this->activePathInputControl); + $this->closePathInputModals(); + $this->interactionState = self::STATE_CONTROL_SELECTION; + $this->refreshContent(); + } + + private function applyPrefabReferenceSelection(?string $selection): void + { + if (!$this->activePrefabReferenceControl instanceof PrefabReferenceInputControl) { + $this->closePrefabReferenceModal(); + $this->interactionState = self::STATE_CONTROL_SELECTION; + $this->refreshContent(); + return; + } + + if ($selection === 'Cancel') { + $this->closePrefabReferenceModal(); + $this->interactionState = self::STATE_CONTROL_SELECTION; + $this->refreshContent(); + return; + } + + $nextValue = $selection === 'None' + ? null + : ($this->prefabReferenceOptions[$selection]['path'] ?? null); + + $this->activePrefabReferenceControl->setValue($nextValue); + $this->applyControlValueToInspectionTarget($this->activePrefabReferenceControl); + $this->closePrefabReferenceModal(); + $this->interactionState = self::STATE_CONTROL_SELECTION; + $this->refreshContent(); + } + + private function requestModalBackgroundRefresh(): void + { + $this->shouldRefreshModalBackground = true; + } + + private function canOpenAddComponentModal(): bool + { + return is_array($this->inspectionTarget) + && in_array($this->inspectionTarget['context'] ?? null, ['hierarchy', 'prefab'], true) + && is_string($this->inspectionTarget['path'] ?? null) + && ($this->inspectionTarget['path'] ?? null) !== 'scene' + && is_array($this->inspectionTarget['value'] ?? null); + } + + private function queueObjectInspectionMutation(array $target, array $inspectionValue): void + { + $context = $target['context'] ?? null; + $path = $target['path'] ?? null; + + if (!is_string($path) || $path === '') { + return; + } + + if ($context === 'hierarchy') { $this->pendingHierarchyMutation = [ 'path' => $path, 'value' => $inspectionValue, @@ -1584,6 +2034,28 @@ private function resolveAvailableComponentDefinitions(): array $candidateClasses = $this->resolveComponentCandidateClasses($currentItem); $definitions = $this->loadComponentDefinitionsInIsolatedProcess($candidateClasses, $currentItem); + $resolvedDefinitionClasses = array_values(array_unique(array_filter( + array_map( + static fn(array $definition): ?string => is_string($definition['class'] ?? null) + ? $definition['class'] + : null, + $definitions, + ), + static fn(?string $class): bool => is_string($class) && $class !== '', + ))); + + $missingCandidateClasses = array_values(array_filter( + $candidateClasses, + static fn(string $candidateClass): bool => !in_array($candidateClass, $resolvedDefinitionClasses, true), + )); + + if ($missingCandidateClasses !== []) { + $definitions = [ + ...$definitions, + ...$this->buildFallbackComponentDefinitions($missingCandidateClasses), + ]; + } + if ($definitions === []) { return []; } @@ -1617,6 +2089,7 @@ private function resolveAvailableComponentDefinitions(): array $resolvedDefinitions[$label] = [ 'class' => $componentClass, 'data' => is_array($definition['data'] ?? null) ? $definition['data'] : [], + 'fieldTypes' => is_array($definition['fieldTypes'] ?? null) ? $definition['fieldTypes'] : [], ]; } @@ -1642,6 +2115,56 @@ private function resolveComponentCandidateClasses(array $currentItem): array return $candidates; } + private function buildFallbackComponentDefinitions(array $candidateClasses): array + { + $autoloadPath = Path::join($this->projectDirectory, 'vendor', 'autoload.php'); + + if (is_file($autoloadPath)) { + require_once $autoloadPath; + } + + $componentBaseClass = 'Sendama\\Engine\\Core\\Component'; + + if (!class_exists($componentBaseClass)) { + return []; + } + + $definitions = []; + + foreach ($candidateClasses as $candidateClass) { + if (!is_string($candidateClass) || $candidateClass === '') { + continue; + } + + if (in_array($candidateClass, ['Sendama\\Engine\\Core\\Transform', 'Sendama\\Engine\\Core\\Rendering\\Renderer'], true)) { + continue; + } + + if (!class_exists($candidateClass) || !is_a($candidateClass, $componentBaseClass, true)) { + continue; + } + + try { + $reflection = new ReflectionClass($candidateClass); + + if ($reflection->isAbstract()) { + continue; + } + + $definitions[] = [ + 'class' => $candidateClass, + 'label' => $this->resolveClassName($candidateClass, $candidateClass), + 'data' => $this->extractFallbackSerializableComponentData($reflection), + 'fieldTypes' => $this->extractFallbackComponentFieldTypes($reflection), + ]; + } catch (Throwable) { + continue; + } + } + + return $definitions; + } + private function discoverProjectComponentCandidates(): array { if (is_array($this->cachedProjectComponentCandidates)) { @@ -1932,6 +2455,64 @@ function extract_component_serializable_data(object $component): array return $serializedData; } +function extract_component_field_types(object $component): array +{ + $fieldTypes = []; + $reflection = new ReflectionObject($component); + + foreach ($reflection->getProperties() as $property) { + $isSerializable = $property->isPublic() + || $property->getAttributes('Sendama\Engine\Core\Behaviours\Attributes\SerializeField') !== []; + + if (!$isSerializable) { + continue; + } + + if (method_exists($property, 'isVirtual') && $property->isVirtual()) { + continue; + } + + $resolvedType = resolve_property_type($property); + + if ($resolvedType !== null) { + $fieldTypes[$property->getName()] = $resolvedType; + } + } + + return $fieldTypes; +} + +function resolve_property_type(ReflectionProperty $property): ?string +{ + $type = $property->getType(); + + if ($type instanceof ReflectionNamedType) { + $resolvedType = $type->getName(); + + if ($type->allowsNull() && $resolvedType !== 'null') { + return $resolvedType . '|null'; + } + + return $resolvedType; + } + + if ($type instanceof ReflectionUnionType) { + $resolvedTypes = []; + + foreach ($type->getTypes() as $namedType) { + if ($namedType instanceof ReflectionNamedType) { + $resolvedTypes[] = $namedType->getName(); + } + } + + $resolvedTypes = array_values(array_unique(array_filter($resolvedTypes))); + + return $resolvedTypes !== [] ? implode('|', $resolvedTypes) : null; + } + + return null; +} + function serialize_component_data(string $componentClass, array $item): ?array { if ( @@ -2006,6 +2587,15 @@ function short_class_name(string $classReference): string 'class' => $candidateClass, 'label' => short_class_name($candidateClass), 'data' => serialize_component_data($candidateClass, is_array($item) ? $item : []) ?? [], + 'fieldTypes' => is_object($gameObject = build_dummy_game_object(is_array($item) ? $item : [])) + ? (function () use ($candidateClass, $gameObject): array { + try { + return extract_component_field_types(new $candidateClass($gameObject)); + } catch (Throwable) { + return []; + } + })() + : [], ]; } @@ -2050,6 +2640,136 @@ function short_class_name(string $classReference): string return is_array($definitions) ? $definitions : []; } + private function extractFallbackSerializableComponentData(ReflectionClass $reflection): array + { + $data = []; + $defaults = $reflection->getDefaultProperties(); + + foreach ($reflection->getProperties() as $property) { + if (!$this->isSerializableComponentProperty($property) || $property->isStatic()) { + continue; + } + + $propertyName = $property->getName(); + + if (!array_key_exists($propertyName, $defaults)) { + continue; + } + + $data[$propertyName] = $this->normalizeEditorValue($defaults[$propertyName]); + } + + return $data; + } + + private function extractFallbackComponentFieldTypes(ReflectionClass $reflection): array + { + $fieldTypes = []; + + foreach ($reflection->getProperties() as $property) { + if (!$this->isSerializableComponentProperty($property) || $property->isStatic()) { + continue; + } + + $resolvedType = $this->resolveReflectionPropertyType($property); + + if ($resolvedType !== null) { + $fieldTypes[$property->getName()] = $resolvedType; + } + } + + return $fieldTypes; + } + + private function isSerializableComponentProperty(ReflectionProperty $property): bool + { + return $property->isPublic() + || $property->getAttributes('Sendama\\Engine\\Core\\Behaviours\\Attributes\\SerializeField') !== []; + } + + private function resolveReflectionPropertyType(ReflectionProperty $property): ?string + { + $type = $property->getType(); + + if ($type instanceof ReflectionNamedType) { + $resolvedType = $type->getName(); + + if ($type->allowsNull() && $resolvedType !== 'null') { + return $resolvedType . '|null'; + } + + return $resolvedType; + } + + if ($type instanceof ReflectionUnionType) { + $resolvedTypes = []; + + foreach ($type->getTypes() as $namedType) { + if ($namedType instanceof ReflectionNamedType) { + $resolvedTypes[] = $namedType->getName(); + } + } + + $resolvedTypes = array_values(array_unique(array_filter($resolvedTypes))); + + return $resolvedTypes !== [] ? implode('|', $resolvedTypes) : null; + } + + return null; + } + + private function normalizeEditorValue(mixed $value): mixed + { + if (is_array($value)) { + $normalized = []; + + foreach ($value as $key => $item) { + $normalized[$key] = $this->normalizeEditorValue($item); + } + + return $normalized; + } + + if ($value instanceof \UnitEnum) { + return $value instanceof \BackedEnum ? $value->value : $value->name; + } + + if (!is_object($value)) { + return $value; + } + + if (method_exists($value, 'getX') && method_exists($value, 'getY')) { + return [ + 'x' => $this->normalizeEditorValue($value->getX()), + 'y' => $this->normalizeEditorValue($value->getY()), + ]; + } + + if (method_exists($value, 'getName')) { + try { + return $value->getName(); + } catch (Throwable) { + } + } + + if (method_exists($value, '__serialize')) { + try { + $serialized = $value->__serialize(); + + return is_array($serialized) + ? $this->normalizeEditorValue($serialized) + : $this->normalizeEditorValue((array) $serialized); + } catch (Throwable) { + } + } + + if ($value instanceof \Stringable) { + return (string) $value; + } + + return get_class($value); + } + private function buildUniqueComponentMenuLabel(string $baseLabel, string $componentClass, array &$usedLabels): string { if (!isset($usedLabels[$baseLabel])) { @@ -2093,6 +2813,10 @@ private function appendComponentToInspectionTarget(array $componentDefinition): $componentEntry['data'] = $componentDefinition['data']; } + if (array_key_exists('fieldTypes', $componentDefinition) && is_array($componentDefinition['fieldTypes'])) { + $componentEntry['__editorFieldTypes'] = $componentDefinition['fieldTypes']; + } + $inspectionValue['components'][] = $componentEntry; $updatedTarget = $this->inspectionTarget; $updatedTarget['value'] = $inspectionValue; @@ -2407,16 +3131,57 @@ private function selectControlByIndex(int $index): void $this->selectedControlIndex = $index; $this->applyControlSelection(); + + if (!$this->isSelectedComponentHeader($this->getSelectedControl())) { + $this->isComponentMoveModeActive = false; + } + $this->refreshContent(); } - private function buildTexturePreviewLines(string $texturePath, array $offset, array $size): array + private function resolveControlIndexFromPoint(int $x, int $y): ?int { - if ($texturePath === 'None') { - return ['[unavailable]']; + $contentIndex = $this->resolveContentIndexFromPointY($y); + + if (!is_int($contentIndex)) { + return null; + } + + $controlIndex = $this->lineControlIndexes[$contentIndex] ?? null; + + return is_int($controlIndex) ? $controlIndex : null; + } + + private function resolveSelectedContentIndex(): ?int + { + if ($this->selectedControlIndex === null) { + return null; } - if ((int) $size['x'] <= 0 || (int) $size['y'] <= 0) { + foreach ($this->lineControlIndexes as $contentIndex => $controlIndex) { + if ($controlIndex === $this->selectedControlIndex) { + return $contentIndex; + } + } + + return null; + } + + private function registerControlClickAndCheckDoubleClick(int $controlIndex): bool + { + $now = microtime(true); + $isDoubleClick = $this->lastClickedControlIndex === $controlIndex + && ($now - $this->lastClickedControlAt) <= self::DOUBLE_CLICK_THRESHOLD_SECONDS; + + $this->lastClickedControlIndex = $controlIndex; + $this->lastClickedControlAt = $now; + + return $isDoubleClick; + } + + private function buildTexturePreviewLines(string $texturePath, array $offset, array $size, bool $naturalSizeFallback = true): array + { + if ($texturePath === 'None') { return ['[unavailable]']; } @@ -2438,17 +3203,35 @@ private function buildTexturePreviewLines(string $texturePath, array $offset, ar return ['[unavailable]']; } + $offsetX = max(0, (int) ($offset['x'] ?? 0)); + $offsetY = max(0, (int) ($offset['y'] ?? 0)); + $previewWidth = (int) ($size['x'] ?? 0); + $previewHeight = (int) ($size['y'] ?? 0); + + if ($naturalSizeFallback && $previewWidth <= 0) { + $previewWidth = $this->resolveTextureRowWidth($textureRows) - $offsetX; + } + + if ($naturalSizeFallback && $previewHeight <= 0) { + $previewHeight = count($textureRows) - $offsetY; + } + + if (!$naturalSizeFallback) { + $previewWidth = max(1, $previewWidth); + $previewHeight = max(1, $previewHeight); + } + + if ($previewWidth <= 0 || $previewHeight <= 0) { + return ['[unavailable]']; + } + if (count($textureRows) <= 1) { $textureRows = $this->expandSingleLineTexture( $textureRows[0] ?? '', - (int) $size['x'] + $previewWidth ); } - $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++) { @@ -2465,6 +3248,41 @@ private function buildTexturePreviewLines(string $texturePath, array $offset, ar return $previewLines === [] ? ['[unavailable]'] : $previewLines; } + private function registerTexturePreview( + PathInputControl $textureControl, + VectorInputControl $sizeControl, + PreviewWindowControl $previewControl, + ?VectorInputControl $offsetControl = null, + bool $naturalSizeFallback = true, + ): void { + $this->texturePreviewRegistrations[] = [ + 'texture' => $textureControl, + 'size' => $sizeControl, + 'preview' => $previewControl, + 'offset' => $offsetControl, + 'naturalSizeFallback' => $naturalSizeFallback, + ]; + } + + private function resolveInspectableSize(array $item): array + { + $size = $this->normalizeVector($item['size'] ?? null); + + if ($this->isGuiTextureItem($item)) { + return $this->normalizeGuiTextureSize($size); + } + + return $size; + } + + private function normalizeGuiTextureSize(array $size): array + { + return [ + 'x' => max(1, (int) ($size['x'] ?? 0)), + 'y' => max(1, (int) ($size['y'] ?? 0)), + ]; + } + private function resolveDisplayType(array $target, array $item): string { $displayType = $target['type'] ?? null; @@ -2523,6 +3341,176 @@ private function resolveTextureFilePath(string $texturePath): ?string return null; } + private function isGuiTextureItem(array $item): bool + { + $type = $item['type'] ?? null; + + if (!is_string($type) || $type === '') { + return false; + } + + $normalizedType = ltrim($type, '\\'); + $normalizedType = preg_replace('/::class$/', '', $normalizedType) ?? $normalizedType; + + return $normalizedType === self::GUI_TEXTURE_TYPE; + } + + private function resolveGuiTextureColorOptions(): array + { + return self::GUI_TEXTURE_COLOR_OPTIONS; + } + + private function normalizeGuiTextureColor(mixed $value): string + { + if (enum_exists(EngineColor::class) && $value instanceof EngineColor) { + return $value->getPhoneticName(); + } + + if (!is_string($value) || trim($value) === '') { + return 'White'; + } + + $normalizedColor = strtoupper(str_replace([' ', '-'], '_', trim($value))); + + if (enum_exists(EngineColor::class)) { + foreach (EngineColor::cases() as $color) { + $normalizedCaseName = strtoupper($color->name); + $normalizedPhoneticName = strtoupper(str_replace([' ', '-'], '_', $color->getPhoneticName())); + $normalizedEscapeValue = strtoupper(trim($color->value)); + + if ( + $normalizedColor === $normalizedCaseName + || $normalizedColor === $normalizedPhoneticName + || $normalizedColor === $normalizedEscapeValue + ) { + return $color === EngineColor::RESET + ? 'White' + : $color->getPhoneticName(); + } + } + } + + foreach (self::GUI_TEXTURE_COLOR_OPTIONS as $colorLabel) { + if (strtoupper(str_replace([' ', '-'], '_', $colorLabel)) === $normalizedColor) { + return $colorLabel; + } + } + + return 'White'; + } + + private function resolvePrefabDisplayLabelsByPath(): array + { + $displayLabelsByPath = []; + + foreach ($this->resolveAvailablePrefabOptions() as $prefabOption) { + $path = $prefabOption['path'] ?? null; + $label = $prefabOption['display'] ?? null; + + if (is_string($path) && $path !== '' && is_string($label) && $label !== '') { + $displayLabelsByPath[$path] = $label; + } + } + + return $displayLabelsByPath; + } + + private function resolveAvailablePrefabOptions(): array + { + $prefabsDirectory = Path::join($this->resolveAssetsWorkingDirectory(), 'Prefabs'); + + if (!is_dir($prefabsDirectory)) { + return []; + } + + $prefabLoader = new PrefabLoader($this->projectDirectory); + $prefabOptions = []; + $usedLabels = []; + $iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($prefabsDirectory, RecursiveDirectoryIterator::SKIP_DOTS) + ); + + foreach ($iterator as $file) { + if (!$file->isFile()) { + continue; + } + + $fileName = $file->getFilename(); + + if (!is_string($fileName) || !str_ends_with(strtolower($fileName), '.prefab.php')) { + continue; + } + + $absolutePath = $file->getPathname(); + $relativePath = $this->buildRelativePrefabPath($absolutePath); + + if ($relativePath === null) { + continue; + } + + $prefabData = $prefabLoader->load($absolutePath); + $displayName = is_array($prefabData) && is_string($prefabData['name'] ?? null) && $prefabData['name'] !== '' + ? $prefabData['name'] + : basename($relativePath); + $displayLabel = $this->buildUniquePrefabOptionLabel( + $displayName, + basename($relativePath), + $usedLabels, + ); + + $prefabOptions[$displayLabel] = [ + 'path' => $relativePath, + 'display' => $displayLabel, + 'name' => $displayName, + ]; + } + + ksort($prefabOptions); + + return $prefabOptions; + } + + private function buildRelativePrefabPath(string $absolutePath): ?string + { + $assetsDirectory = $this->resolveAssetsWorkingDirectory(); + $normalizedAssetsDirectory = rtrim(str_replace('\\', '/', $assetsDirectory), '/'); + $normalizedAbsolutePath = str_replace('\\', '/', $absolutePath); + + if (!str_starts_with($normalizedAbsolutePath, $normalizedAssetsDirectory . '/')) { + return null; + } + + return substr($normalizedAbsolutePath, strlen($normalizedAssetsDirectory) + 1) ?: null; + } + + private function buildUniquePrefabOptionLabel(string $displayName, string $fileName, array &$usedLabels): string + { + $label = $displayName; + + if (!isset($usedLabels[$label])) { + $usedLabels[$label] = true; + return $label; + } + + $label = $displayName . ' (' . $fileName . ')'; + + if (!isset($usedLabels[$label])) { + $usedLabels[$label] = true; + return $label; + } + + $suffix = 2; + + while (isset($usedLabels[$label . ' #' . $suffix])) { + $suffix++; + } + + $label .= ' #' . $suffix; + $usedLabels[$label] = true; + + return $label; + } + private function hasFileExtension(string $path): bool { return pathinfo($path, PATHINFO_EXTENSION) !== ''; @@ -2578,6 +3566,22 @@ private function expandSingleLineTexture(string $textureContents, int $rowWidth) return $rows; } + private function resolveTextureRowWidth(array $textureRows): int + { + $maxWidth = 0; + + foreach ($textureRows as $textureRow) { + $maxWidth = max( + $maxWidth, + function_exists('mb_strlen') + ? mb_strlen((string) $textureRow, 'UTF-8') + : strlen((string) $textureRow), + ); + } + + return $maxWidth; + } + private function humanizeKey(string $key): string { $spacedKey = preg_replace('/(?projectDirectory; - $assetRoots = [ - $workingDirectory . '/Assets', - $workingDirectory . '/assets', - $workingDirectory, - ]; - - foreach ($assetRoots as $assetRoot) { - if (is_dir($assetRoot)) { - return $assetRoot; - } - } - - return $workingDirectory; + return Path::resolveAssetsDirectory($this->projectDirectory); } } diff --git a/src/Editor/Widgets/MainPanel.php b/src/Editor/Widgets/MainPanel.php index 6151f76..b1fdddd 100644 --- a/src/Editor/Widgets/MainPanel.php +++ b/src/Editor/Widgets/MainPanel.php @@ -2,6 +2,7 @@ namespace Sendama\Console\Editor\Widgets; +use Atatusoft\Termutil\Events\MouseEvent; use Atatusoft\Termutil\IO\Enumerations\Color; use Sendama\Console\Editor\FocusTargetContext; use Sendama\Console\Editor\IO\Enumerations\KeyCode; @@ -35,6 +36,7 @@ class MainPanel extends Widget private const string SPRITE_MODAL_CHARACTER = 'character_picker'; private const int DEFAULT_TEXTURE_WIDTH = 16; private const int DEFAULT_TEXTURE_HEIGHT = 16; + private const float DOUBLE_CLICK_THRESHOLD_SECONDS = 0.35; private const array SPECIAL_CHARACTER_OPTIONS = [ '█ Full Block', '▓ Dark Shade', @@ -116,10 +118,13 @@ class MainPanel extends Widget protected array $sceneObjects = []; protected array $visibleSceneObjects = []; protected ?string $selectedScenePath = null; + protected array $selectedScenePaths = []; protected ?array $pendingInspectionItem = null; protected ?array $pendingHierarchyMutation = null; + protected ?array $pendingDuplicationItems = null; protected string $sceneInteractionMode = self::SCENE_VIEW_MODE_SELECT; protected array $sceneLineHighlights = []; + protected array $sceneClickTargets = []; protected string $projectDirectory; protected int $sceneWidth = DEFAULT_TERMINAL_WIDTH; protected int $sceneHeight = DEFAULT_TERMINAL_HEIGHT; @@ -145,6 +150,11 @@ class MainPanel extends Widget protected ?string $spriteModalState = null; protected ?array $pendingAssetSyncRequest = null; protected ?string $lastPrintedSpriteCharacter = null; + protected ?string $lastClickedScenePath = null; + protected float $lastClickedSceneAt = 0.0; + protected bool $isSpriteMousePainting = false; + protected bool $isSpriteMouseErasing = false; + protected ?array $lastSpritePaintedCell = null; public function __construct( array $position = ['x' => 37, 'y' => 1], @@ -188,6 +198,11 @@ public function focus(FocusTargetContext $context): void public function blur(FocusTargetContext $context): void { + $this->isSpriteMousePainting = false; + $this->isSpriteMouseErasing = false; + $this->lastSpritePaintedCell = null; + $this->lastClickedScenePath = null; + $this->lastClickedSceneAt = 0.0; parent::blur($context); $this->refreshContent(); } @@ -226,6 +241,26 @@ public function setSceneObjects(array $sceneObjects): void public function selectSceneObject(?string $path): void { $this->selectedScenePath = $path; + $this->selectedScenePaths = is_string($path) && $path !== '' ? [$path] : []; + $this->syncSelectedScenePath(); + $this->refreshContent(); + } + + public function selectSceneObjects(array $paths, ?string $primaryPath = null): void + { + $normalizedPaths = array_values(array_unique(array_filter( + $paths, + static fn (mixed $path): bool => is_string($path) && $path !== '' + ))); + + if ($normalizedPaths === []) { + return; + } + + $this->selectedScenePath = is_string($primaryPath) && in_array($primaryPath, $normalizedPaths, true) + ? $primaryPath + : end($normalizedPaths); + $this->selectedScenePaths = $normalizedPaths; $this->syncSelectedScenePath(); $this->refreshContent(); } @@ -261,6 +296,14 @@ public function consumeHierarchyMutation(): ?array return $pendingHierarchyMutation; } + public function consumeDuplicationRequest(): ?array + { + $pendingDuplicationItems = $this->pendingDuplicationItems; + $this->pendingDuplicationItems = null; + + return $pendingDuplicationItems; + } + public function consumeAssetSyncRequest(): ?array { $pendingAssetSyncRequest = $this->pendingAssetSyncRequest; @@ -339,6 +382,38 @@ public function renderActiveModal(): void } } + public function handleModalMouseEvent(MouseEvent $mouseEvent): bool + { + $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) { + return false; + } + + if ($activeModal->handleScrollbarMouseEvent($mouseEvent)) { + return true; + } + + if ($mouseEvent->buttonIndex !== 0 || $mouseEvent->action !== 'Pressed') { + return false; + } + + $selection = $activeModal->clickOptionAtPoint($mouseEvent->x, $mouseEvent->y); + + if (!is_string($selection) || $selection === '') { + return false; + } + + $this->handleSpriteModalSelection($selection); + + return true; + } + public function loadSpriteAsset(?array $asset): void { if (!$this->isEditableSpriteAsset($asset)) { @@ -417,6 +492,11 @@ public function update(): void return; } + if (Input::getCurrentInput() === 'D') { + $this->beginSceneDuplicationWorkflow(); + return; + } + if ($this->sceneInteractionMode === self::SCENE_VIEW_MODE_MOVE) { if ($this->handleSceneMoveModeInput()) { return; @@ -471,30 +551,61 @@ public function update(): void public function handleMouseClick(int $x, int $y): void { - if (!$this->containsPoint($x, $y) || $y !== $this->getContentAreaTop()) { + if (!$this->containsPoint($x, $y)) { return; } - $currentX = $this->getContentAreaLeft(); + if ($y === $this->getContentAreaTop()) { + $currentX = $this->getContentAreaLeft(); - foreach (self::TAB_TITLES as $index => $tabTitle) { - if ($index > 0) { - $currentX += 2; - } + foreach (self::TAB_TITLES as $index => $tabTitle) { + if ($index > 0) { + $currentX += 2; + } - $tabStart = $currentX; - $tabEnd = $tabStart + mb_strlen($tabTitle) - 1; + $tabStart = $currentX; + $tabEnd = $tabStart + mb_strlen($tabTitle) - 1; - if ($x >= $tabStart && $x <= $tabEnd) { - $this->activeTabIndex = $index; - $this->refreshContent(); - return; + if ($x >= $tabStart && $x <= $tabEnd) { + $this->activeTabIndex = $index; + $this->refreshContent(); + return; + } + + $currentX = $tabEnd + 1; } - $currentX = $tabEnd + 1; + return; + } + + if ($this->isSceneTabActive() && !$this->isPlayModeActive) { + $this->handleSceneCanvasClick($x, $y); + return; + } + + if ($this->isSpriteTabActive() && !$this->isPlayModeActive) { + $this->handleSpriteCanvasPress($x, $y); + } + } + + public function handleMouseDrag(int $x, int $y): void + { + if (!$this->containsPoint($x, $y)) { + return; + } + + if ($this->isSpriteTabActive() && !$this->isPlayModeActive) { + $this->handleSpriteCanvasDrag($x, $y); } } + public function handleMouseRelease(int $x, int $y): void + { + $this->isSpriteMousePainting = false; + $this->isSpriteMouseErasing = false; + $this->lastSpritePaintedCell = null; + } + protected function decorateContentLine(string $line, ?Color $contentColor, int $lineIndex): string { $contentIndex = $lineIndex - $this->padding->topPadding; @@ -652,6 +763,7 @@ private function refreshContent(): void $this->gameIdleContentIndexes = []; $this->gameIdlePromptContentIndex = null; $this->sceneLineHighlights = []; + $this->sceneClickTargets = []; $this->spriteLineHighlights = []; $this->visibleSceneObjects = $this->flattenSceneObjects($this->sceneObjects); $this->syncSelectedScenePath(); @@ -721,7 +833,7 @@ private function updateHelpInfo(): void 'Mode: Scene Pan', ], default => [ - 'Arrows cycle Enter inspect Shift+W move Shift+E pan', + 'Arrows cycle Enter inspect Shift+D duplicate Shift+W move Shift+E pan', 'Mode: Scene Select', ], }; @@ -986,7 +1098,11 @@ private function handleSpriteModalInput(): void } $selection = $activeModal->getSelectedOption(); + $this->handleSpriteModalSelection($selection); + } + private function handleSpriteModalSelection(?string $selection): void + { if ($selection === null || $selection === 'Cancel') { $this->dismissSpriteModals(); return; @@ -1046,6 +1162,7 @@ private function moveSceneSelection(int $offset): void $selectedIndex = $this->getSelectedSceneObjectIndex() ?? 0; $nextIndex = ($selectedIndex + $offset + count($this->visibleSceneObjects)) % count($this->visibleSceneObjects); $this->selectedScenePath = $this->visibleSceneObjects[$nextIndex]['path'] ?? $this->selectedScenePath; + $this->selectedScenePaths = $this->selectedScenePath !== null ? [$this->selectedScenePath] : []; $this->queueInspectionForSelectedSceneObject(); $this->refreshContent(); } @@ -1098,8 +1215,9 @@ 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)); + $sceneBounds = $this->resolveSceneViewportBounds(); + $maxOffsetX = max(0, $sceneBounds['width'] - max(1, $canvasWidth)); + $maxOffsetY = max(0, $sceneBounds['height'] - max(1, $canvasHeight)); $this->sceneViewportOffsetX = max(0, min($this->sceneViewportOffsetX + $deltaX, $maxOffsetX)); $this->sceneViewportOffsetY = max(0, min($this->sceneViewportOffsetY + $deltaY, $maxOffsetY)); @@ -1108,13 +1226,24 @@ private function panSceneViewport(int $deltaX, int $deltaY): void private function decorateSceneLine(string $line, ?Color $contentColor, int $contentIndex): string { - $highlight = $this->sceneLineHighlights[$contentIndex] ?? null; + $highlights = $this->sceneLineHighlights[$contentIndex] ?? null; - if (!is_array($highlight)) { + if (!is_array($highlights)) { return parent::decorateContentLine($line, $contentColor, $contentIndex); } - if (!$this->hasFocus() && ($highlight['kind'] ?? null) !== 'placeholder') { + if (isset($highlights['start'])) { + $highlights = [$highlights]; + } + + if (!$this->hasFocus()) { + $highlights = array_values(array_filter( + $highlights, + static fn (mixed $highlight): bool => is_array($highlight) && (($highlight['kind'] ?? null) === 'placeholder') + )); + } + + if ($highlights === []) { return parent::decorateContentLine($line, $contentColor, $contentIndex); } @@ -1130,38 +1259,72 @@ private function decorateSceneLine(string $line, ?Color $contentColor, int $cont $rightBorder = mb_substr($visibleLine, -1); $borderColor = $this->hasFocus() ? $this->focusBorderColor : $contentColor; $middleWidth = $this->getDisplayWidth($middle); - $highlightStart = min( - max(0, $this->padding->leftPadding + (int) ($highlight['start'] ?? 0)), - $middleWidth, - ); - $highlightLength = max( - 0, - min((int) ($highlight['length'] ?? 0), $middleWidth - $highlightStart), - ); + $normalizedHighlights = []; - if ($highlightLength === 0) { + foreach ($highlights as $highlight) { + if (!is_array($highlight)) { + continue; + } + + $highlightStart = min( + max(0, $this->padding->leftPadding + (int) ($highlight['start'] ?? 0)), + $middleWidth, + ); + $highlightLength = max( + 0, + min((int) ($highlight['length'] ?? 0), $middleWidth - $highlightStart), + ); + + if ($highlightLength <= 0) { + continue; + } + + $normalizedHighlights[] = [ + 'start' => $highlightStart, + 'length' => $highlightLength, + 'kind' => $highlight['kind'] ?? 'selection', + ]; + } + + if ($normalizedHighlights === []) { return parent::decorateContentLine($line, $contentColor, $contentIndex); } - [ - 'before' => $beforeHighlight, - 'highlight' => $highlightText, - 'after' => $afterHighlight, - ] = $this->splitContentByDisplayWidth($middle, $highlightStart, $highlightLength); + usort($normalizedHighlights, static function (array $left, array $right): int { + return ($left['start'] <=> $right['start']) ?: ($left['length'] <=> $right['length']); + }); + + $output = $this->wrapWithColor($leftBorder, $borderColor); + $remainingText = $middle; + $currentOffset = 0; + + foreach ($normalizedHighlights as $highlight) { + $highlightStart = max($currentOffset, (int) $highlight['start']); + $highlightLength = (int) $highlight['length'] - max(0, $currentOffset - (int) $highlight['start']); + + if ($highlightLength <= 0) { + continue; + } + + $relativeStart = max(0, $highlightStart - $currentOffset); + [ + 'before' => $beforeHighlight, + 'highlight' => $highlightText, + 'after' => $afterHighlight, + ] = $this->splitContentByDisplayWidth($remainingText, $relativeStart, $highlightLength); + + $output .= $this->wrapWithColor($beforeHighlight, $contentColor); + $output .= ($highlight['kind'] ?? null) === 'placeholder' + ? $this->wrapWithColor($highlightText, Color::DARK_GRAY) + : $this->wrapWithSequence($highlightText, $this->resolveSceneHighlightSequence()); - if (($highlight['kind'] ?? null) === 'placeholder') { - return $this->wrapWithColor($leftBorder, $borderColor) - . $this->wrapWithColor($beforeHighlight, $contentColor) - . $this->wrapWithColor($highlightText, Color::DARK_GRAY) - . $this->wrapWithColor($afterHighlight, $contentColor) - . $this->wrapWithColor($rightBorder, $borderColor); + $remainingText = $afterHighlight; + $currentOffset = $highlightStart + $highlightLength; } - return $this->wrapWithColor($leftBorder, $borderColor) - . $this->wrapWithColor($beforeHighlight, $contentColor) - . $this->wrapWithSequence($highlightText, $this->resolveSceneHighlightSequence()) - . $this->wrapWithColor($afterHighlight, $contentColor) - . $this->wrapWithColor($rightBorder, $borderColor); + $output .= $this->wrapWithColor($remainingText, $contentColor); + + return $output . $this->wrapWithColor($rightBorder, $borderColor); } private function resolveSceneHighlightSequence(): string @@ -1183,6 +1346,7 @@ private function buildSceneCanvasContent(): array { $canvasWidth = max(0, $this->innerWidth - $this->padding->leftPadding - $this->padding->rightPadding); $canvasHeight = max(0, $this->innerHeight - 2); + $this->sceneClickTargets = []; if ($canvasWidth <= 0 || $canvasHeight <= 0) { return []; @@ -1204,14 +1368,18 @@ private function buildSceneCanvasContent(): array } $position = $sceneObject['position'] ?? $this->normalizeVector($item['position'] ?? null); - $row = (int) ($position['y'] ?? 0) - $this->sceneViewportOffsetY; - $column = (int) ($position['x'] ?? 0) - $this->sceneViewportOffsetX; + $projectedPosition = $this->projectScenePositionToCanvas($position); + $row = $projectedPosition['y'] - $this->sceneViewportOffsetY; + $column = $projectedPosition['x'] - $this->sceneViewportOffsetX; $renderLines = is_array($sceneObject['renderLines'] ?? null) ? $sceneObject['renderLines'] : []; if ($renderLines === []) { - if (($sceneObject['path'] ?? null) !== $this->selectedScenePath || !$this->hasFocus()) { + if ( + !in_array($sceneObject['path'] ?? null, $this->selectedScenePaths, true) + || !$this->hasFocus() + ) { continue; } @@ -1220,7 +1388,7 @@ private function buildSceneCanvasContent(): array } $canvas[$row][$column] = self::SCENE_PLACEHOLDER_CHARACTER; - $this->sceneLineHighlights[2 + $row] = [ + $this->sceneLineHighlights[2 + $row][] = [ 'start' => $column, 'length' => 1, 'kind' => 'placeholder', @@ -1242,17 +1410,25 @@ private function buildSceneCanvasContent(): array $canvasWidth, ); - if (($sceneObject['path'] ?? null) !== $this->selectedScenePath) { + if (($placement['length'] ?? 0) <= 0) { continue; } - if (($placement['length'] ?? 0) <= 0) { + $this->sceneClickTargets[$targetRow] ??= []; + $this->sceneClickTargets[$targetRow][] = [ + 'path' => $sceneObject['path'] ?? null, + 'start' => $placement['start'] ?? max(0, $column), + 'length' => $placement['length'], + ]; + + if (!in_array($sceneObject['path'] ?? null, $this->selectedScenePaths, true)) { continue; } - $this->sceneLineHighlights[2 + $targetRow] = [ + $this->sceneLineHighlights[2 + $targetRow][] = [ 'start' => $placement['start'] ?? max(0, $column), 'length' => $placement['length'], + 'kind' => 'selection', ]; } } @@ -1418,12 +1594,21 @@ private function moveSpriteCursor(int $deltaX, int $deltaY): void 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->setSpriteCursorPosition($this->spriteCursorX + $deltaX, $this->spriteCursorY + $deltaY); $this->refreshContent(); } + private function setSpriteCursorPosition(int $x, int $y): void + { + if ($this->activeSpriteAsset === null || $this->spriteGridWidth <= 0 || $this->spriteGridHeight <= 0) { + return; + } + + $this->spriteCursorX = max(0, min($x, $this->spriteGridWidth - 1)); + $this->spriteCursorY = max(0, min($y, $this->spriteGridHeight - 1)); + $this->syncSpriteViewport(); + } + private function syncSpriteViewport(): void { $contentWidth = max(1, $this->innerWidth - $this->padding->leftPadding - $this->padding->rightPadding); @@ -1453,6 +1638,15 @@ private function isPrintableSpriteCharacter(string $input): bool } private function writeSpriteCharacter(string $character): void + { + $this->writeSpriteCharacterAtCursor($character); + } + + private function writeSpriteCharacterAtCursor( + string $character, + bool $advanceCursor = true, + bool $rememberCharacter = true, + ): void { if ($this->activeSpriteAsset === null) { return; @@ -1464,9 +1658,12 @@ private function writeSpriteCharacter(string $character): void return; } - $this->lastPrintedSpriteCharacter = $nextCharacter; + if ($rememberCharacter) { + $this->lastPrintedSpriteCharacter = $nextCharacter; + } if (($this->spriteGrid[$this->spriteCursorY][$this->spriteCursorX] ?? ' ') === $nextCharacter) { + $this->refreshContent(); return; } @@ -1474,7 +1671,7 @@ private function writeSpriteCharacter(string $character): void $this->spriteGrid[$this->spriteCursorY][$this->spriteCursorX] = $nextCharacter; $this->persistActiveSpriteAsset(); - if ($this->spriteCursorX < $this->spriteGridWidth - 1) { + if ($advanceCursor && $this->spriteCursorX < $this->spriteGridWidth - 1) { $this->spriteCursorX++; } @@ -1708,16 +1905,26 @@ private function syncSelectedScenePath(): void { if ($this->visibleSceneObjects === []) { $this->selectedScenePath = null; + $this->selectedScenePaths = []; return; } + $this->selectedScenePaths = array_values(array_filter( + $this->selectedScenePaths, + fn (mixed $path): bool => is_string($path) && $this->findVisibleSceneObjectIndexByPath($path) !== null + )); + foreach ($this->visibleSceneObjects as $sceneObject) { if (($sceneObject['path'] ?? null) === $this->selectedScenePath) { + if ($this->selectedScenePaths === []) { + $this->selectedScenePaths = [$this->selectedScenePath]; + } return; } } $this->selectedScenePath = $this->visibleSceneObjects[0]['path'] ?? null; + $this->selectedScenePaths = $this->selectedScenePath !== null ? [$this->selectedScenePath] : []; } private function queueInspectionForSelectedSceneObject(): void @@ -1757,12 +1964,101 @@ private function getSelectedSceneNode(): ?array private function getSelectedSceneObjectIndex(): ?int { - if ($this->selectedScenePath === null) { + return $this->findVisibleSceneObjectIndexByPath($this->selectedScenePath); + } + + private function handleSceneCanvasClick(int $x, int $y): void + { + $canvasRow = $y - $this->getContentAreaTop() - 2; + + if ($canvasRow < 0) { + return; + } + + $canvasColumn = $x - $this->getContentAreaLeft(); + + if ($canvasColumn < 0) { + return; + } + + $clickedPath = $this->resolveSceneClickPath($canvasRow, $canvasColumn); + + if (!is_string($clickedPath) || $clickedPath === '') { + return; + } + + $isDoubleClick = $this->registerSceneClickAndCheckDoubleClick($clickedPath); + $isAdditiveSelection = Input::getMouseEvent()?->isCtrlPressed === true; + + $this->selectedScenePath = $clickedPath; + + if ($isAdditiveSelection) { + if (!in_array($clickedPath, $this->selectedScenePaths, true)) { + $this->selectedScenePaths[] = $clickedPath; + } + } else { + $this->selectedScenePaths = [$clickedPath]; + } + + $this->queueInspectionForSelectedSceneObject(); + $this->refreshContent(); + + if ($isDoubleClick) { + $this->activateSceneSelection(); + } + } + + private function registerSceneClickAndCheckDoubleClick(string $path): bool + { + $now = microtime(true); + $isDoubleClick = $this->lastClickedScenePath === $path + && ($now - $this->lastClickedSceneAt) <= self::DOUBLE_CLICK_THRESHOLD_SECONDS; + + $this->lastClickedScenePath = $path; + $this->lastClickedSceneAt = $now; + + return $isDoubleClick; + } + + private function beginSceneDuplicationWorkflow(): void + { + $selectedItems = []; + + foreach ($this->visibleSceneObjects as $sceneObject) { + $path = $sceneObject['path'] ?? null; + + if ( + !is_string($path) + || !in_array($path, $this->selectedScenePaths, true) + || !is_array($sceneObject['item'] ?? null) + ) { + continue; + } + + $selectedItems[] = [ + 'path' => $path, + 'value' => $sceneObject['item'], + ]; + } + + if ($selectedItems === []) { + return; + } + + $this->pendingDuplicationItems = [ + 'items' => $selectedItems, + 'primaryPath' => $this->selectedScenePath, + ]; + } + + private function findVisibleSceneObjectIndexByPath(?string $path): ?int + { + if (!is_string($path) || $path === '') { return null; } foreach ($this->visibleSceneObjects as $index => $sceneObject) { - if (($sceneObject['path'] ?? null) === $this->selectedScenePath) { + if (($sceneObject['path'] ?? null) === $path) { return $index; } } @@ -1770,16 +2066,116 @@ private function getSelectedSceneObjectIndex(): ?int return null; } + private function handleSpriteCanvasPress(int $x, int $y): void + { + $mouseEvent = Input::getMouseEvent(); + $buttonIndex = $mouseEvent?->buttonIndex; + $this->lastSpritePaintedCell = null; + $this->isSpriteMouseErasing = $buttonIndex === 2; + $this->isSpriteMousePainting = $this->isSpriteMouseErasing || $this->lastPrintedSpriteCharacter !== null; + $this->applySpriteMouseInteraction($x, $y, $this->isSpriteMousePainting, $this->isSpriteMouseErasing); + } + + private function handleSpriteCanvasDrag(int $x, int $y): void + { + if (!$this->isSpriteMousePainting && !$this->isSpriteMouseErasing) { + return; + } + + $this->applySpriteMouseInteraction($x, $y, true, $this->isSpriteMouseErasing); + } + + private function applySpriteMouseInteraction(int $x, int $y, bool $shouldPaint, bool $shouldErase = false): void + { + $gridPosition = $this->resolveSpriteGridPositionFromPoint($x, $y); + + if (!is_array($gridPosition)) { + return; + } + + if ($shouldPaint && $this->lastSpritePaintedCell === $gridPosition) { + return; + } + + $this->setSpriteCursorPosition($gridPosition['x'], $gridPosition['y']); + + if (!$shouldPaint) { + $this->refreshContent(); + return; + } + + $character = $shouldErase ? ' ' : $this->lastPrintedSpriteCharacter; + + if ($character === null) { + $this->refreshContent(); + return; + } + + $this->lastSpritePaintedCell = $gridPosition; + $this->writeSpriteCharacterAtCursor($character, false, !$shouldErase); + } + + private function resolveSpriteGridPositionFromPoint(int $x, int $y): ?array + { + if ($this->activeSpriteAsset === null) { + return null; + } + + $gridRow = $y - $this->getContentAreaTop() - 2; + $gridColumn = $x - $this->getContentAreaLeft(); + + if ($gridRow < 0 || $gridColumn < 0) { + return null; + } + + $gridX = $this->spriteViewportOffsetX + $gridColumn; + $gridY = $this->spriteViewportOffsetY + $gridRow; + + if ($gridX < 0 || $gridY < 0 || $gridX >= $this->spriteGridWidth || $gridY >= $this->spriteGridHeight) { + return null; + } + + return ['x' => $gridX, 'y' => $gridY]; + } + + private function resolveSceneClickPath(int $row, int $column): ?string + { + $rowTargets = $this->sceneClickTargets[$row] ?? null; + + if (!is_array($rowTargets) || $rowTargets === []) { + return null; + } + + for ($index = count($rowTargets) - 1; $index >= 0; $index--) { + $target = $rowTargets[$index] ?? null; + + if (!is_array($target)) { + continue; + } + + $start = (int) ($target['start'] ?? -1); + $length = (int) ($target['length'] ?? 0); + + if ($length <= 0 || $column < $start || $column >= ($start + $length)) { + continue; + } + + return is_string($target['path'] ?? null) ? $target['path'] : null; + } + + return null; + } + private function resolveSceneObjectRenderLines(array $item): array { if ($this->isSceneObjectRendererDisabled($item)) { return []; } - $spriteRenderLines = $this->buildSpriteRenderLines($item); + $textureRenderLines = $this->buildTextureRenderLines($item); - if ($spriteRenderLines !== []) { - return $spriteRenderLines; + if ($textureRenderLines !== []) { + return $textureRenderLines; } if (is_string($item['text'] ?? null) && $item['text'] !== '') { @@ -1877,30 +2273,37 @@ private function normalizeSceneCoordinate(mixed $value): int return 0; } - private function buildSpriteRenderLines(array $item): array + private function projectScenePositionToCanvas(array $position): 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 [ + 'x' => $this->projectSceneCoordinateToCanvas((int) ($position['x'] ?? 0)), + 'y' => $this->projectSceneCoordinateToCanvas((int) ($position['y'] ?? 0)), + ]; + } - return $this->buildTexturePreviewLines($texturePath, $offset, $size); + private function projectSceneCoordinateToCanvas(int $coordinate): int + { + return max(1, $coordinate) - 1; } - private function buildTexturePreviewLines(string $texturePath, array $offset, array $size): array + private function buildTextureRenderLines(array $item): array { - if ((int) $size['x'] <= 0 || (int) $size['y'] <= 0) { + $textureSource = $this->resolveTextureRenderSource($item); + + if (!is_array($textureSource)) { return []; } + return $this->buildTexturePreviewLines( + $textureSource['path'], + $textureSource['offset'], + $textureSource['size'], + (bool) ($textureSource['naturalSizeFallback'] ?? true), + ); + } + + private function buildTexturePreviewLines(string $texturePath, array $offset, array $size, bool $naturalSizeFallback = true): array + { $resolvedTextureFilePath = $this->resolveAssetFilePath($texturePath, 'texture'); if ($resolvedTextureFilePath === null) { @@ -1919,17 +2322,35 @@ private function buildTexturePreviewLines(string $texturePath, array $offset, ar return []; } + $offsetX = max(0, (int) ($offset['x'] ?? 0)); + $offsetY = max(0, (int) ($offset['y'] ?? 0)); + $previewWidth = (int) ($size['x'] ?? 0); + $previewHeight = (int) ($size['y'] ?? 0); + + if ($naturalSizeFallback && $previewWidth <= 0) { + $previewWidth = $this->resolveTextureRowWidth($textureRows) - $offsetX; + } + + if ($naturalSizeFallback && $previewHeight <= 0) { + $previewHeight = count($textureRows) - $offsetY; + } + + if (!$naturalSizeFallback) { + $previewWidth = max(1, $previewWidth); + $previewHeight = max(1, $previewHeight); + } + + if ($previewWidth <= 0 || $previewHeight <= 0) { + return []; + } + if (count($textureRows) <= 1) { $textureRows = $this->expandSingleLineTexture( $textureRows[0] ?? '', - (int) $size['x'], + $previewWidth, ); } - $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++) { @@ -2096,6 +2517,63 @@ private function buildEnvironmentTileMapLines(): array return is_array($tileMapLines) ? $tileMapLines : []; } + private function resolveSceneViewportBounds(): array + { + $bounds = [ + 'width' => max(1, $this->sceneWidth), + 'height' => max(1, $this->sceneHeight), + ]; + + $tileMapBounds = $this->resolveEnvironmentTileMapBounds(); + $bounds['width'] = max($bounds['width'], $tileMapBounds['width']); + $bounds['height'] = max($bounds['height'], $tileMapBounds['height']); + + foreach ($this->visibleSceneObjects as $sceneObject) { + $item = is_array($sceneObject['item'] ?? null) ? $sceneObject['item'] : null; + $position = is_array($sceneObject['position'] ?? null) + ? $sceneObject['position'] + : $this->normalizeVector($item['position'] ?? null); + $renderLines = is_array($sceneObject['renderLines'] ?? null) + ? $sceneObject['renderLines'] + : []; + + $projectedPosition = $this->projectScenePositionToCanvas($position); + $objectWidth = $this->resolveRenderLinesWidth($renderLines); + $objectHeight = max(1, count($renderLines)); + + $bounds['width'] = max($bounds['width'], $projectedPosition['x'] + max(1, $objectWidth)); + $bounds['height'] = max($bounds['height'], $projectedPosition['y'] + $objectHeight); + } + + return $bounds; + } + + private function resolveEnvironmentTileMapBounds(): array + { + $tileMapLines = $this->buildEnvironmentTileMapLines(); + $maxWidth = 0; + + foreach ($tileMapLines as $tileMapLine) { + $maxWidth = max($maxWidth, $this->getDisplayWidth((string) $tileMapLine)); + } + + return [ + 'width' => max(1, $maxWidth), + 'height' => max(1, count($tileMapLines)), + ]; + } + + private function resolveRenderLinesWidth(array $renderLines): int + { + $maxWidth = 0; + + foreach ($renderLines as $renderLine) { + $maxWidth = max($maxWidth, $this->getDisplayWidth((string) $renderLine)); + } + + return $maxWidth; + } + private function buildSplitHelpBorder(string $leftLabel, string $rightLabel): string { $availableLabelWidth = max(0, $this->width - 3); @@ -2140,6 +2618,63 @@ private function expandSingleLineTexture(string $textureContents, int $rowWidth) return $rows; } + private function resolveTextureRowWidth(array $textureRows): int + { + $maxWidth = 0; + + foreach ($textureRows as $textureRow) { + $maxWidth = max( + $maxWidth, + function_exists('mb_strlen') + ? mb_strlen((string) $textureRow, 'UTF-8') + : strlen((string) $textureRow), + ); + } + + return $maxWidth; + } + + private function resolveTextureRenderSource(array $item): ?array + { + $sprite = is_array($item['sprite'] ?? null) ? $item['sprite'] : []; + $texture = is_array($sprite['texture'] ?? null) ? $sprite['texture'] : []; + $spriteTexturePath = is_string($texture['path'] ?? null) && trim((string) $texture['path']) !== '' + ? trim((string) $texture['path']) + : null; + + if ($spriteTexturePath !== null && strcasecmp($spriteTexturePath, 'None') !== 0) { + return [ + 'path' => $spriteTexturePath, + 'offset' => $this->normalizeVector($texture['position'] ?? null), + 'size' => $this->normalizeVector($texture['size'] ?? null), + 'naturalSizeFallback' => true, + ]; + } + + $directTexturePath = is_string($item['texture'] ?? null) && trim((string) $item['texture']) !== '' + ? trim((string) $item['texture']) + : null; + + if ($directTexturePath !== null && strcasecmp($directTexturePath, 'None') !== 0) { + return [ + 'path' => $directTexturePath, + 'offset' => ['x' => 0, 'y' => 0], + 'size' => $this->normalizeTextureElementSize($this->normalizeVector($item['size'] ?? null)), + 'naturalSizeFallback' => false, + ]; + } + + return null; + } + + private function normalizeTextureElementSize(array $size): array + { + return [ + 'x' => max(1, (int) ($size['x'] ?? 0)), + 'y' => max(1, (int) ($size['y'] ?? 0)), + ]; + } + private function applySceneObjectMutation(string $path, array $value): bool { $segments = explode('.', $path); diff --git a/src/Editor/Widgets/OptionListModal.php b/src/Editor/Widgets/OptionListModal.php index e94a917..8c2f635 100644 --- a/src/Editor/Widgets/OptionListModal.php +++ b/src/Editor/Widgets/OptionListModal.php @@ -90,6 +90,26 @@ public function getSelectedOption(): ?string return $this->options[$this->selectedIndex] ?? null; } + public function clickOptionAtPoint(int $x, int $y): ?string + { + if (!$this->isVisible || !$this->containsPoint($x, $y)) { + return null; + } + + $optionIndex = $this->resolveOptionIndexFromPoint($y); + + if ($optionIndex === null) { + return null; + } + + $this->selectedIndex = $optionIndex; + $this->syncScrollOffset(); + $this->refreshContent(); + $this->markDirty(); + + return $this->getSelectedOption(); + } + public function syncLayout(int $terminalWidth, int $terminalHeight): void { $longestOptionLength = 0; @@ -131,6 +151,37 @@ public function update(): void { } + protected function usesAutomaticVerticalScrolling(): bool + { + return false; + } + + protected function setScrollbarOffset(int $offset): void + { + $visibleOptionCount = $this->getVisibleOptionCount(); + $maxScrollOffset = max(0, count($this->options) - $visibleOptionCount); + $this->scrollOffset = max(0, min($offset, $maxScrollOffset)); + $this->refreshContent(); + $this->markDirty(); + } + + protected function resolveVerticalScrollbarState(): ?array + { + $visibleOptionCount = $this->getVisibleOptionCount(); + $optionCount = count($this->options); + + if ($visibleOptionCount <= 0 || $optionCount <= $visibleOptionCount) { + return null; + } + + return [ + 'offset' => $this->scrollOffset, + 'visible' => $visibleOptionCount, + 'total' => $optionCount, + 'start' => 0, + ]; + } + protected function decorateContentLine(string $line, ?Color $contentColor, int $lineIndex): string { $selectedVisibleIndex = $this->selectedIndex - $this->scrollOffset; @@ -207,4 +258,21 @@ private function syncScrollOffset(): void $this->scrollOffset = $this->selectedIndex - $visibleOptionCount + 1; } } + + private function resolveOptionIndexFromPoint(int $y): ?int + { + $lineIndex = $y - $this->getContentAreaTop(); + + if ($lineIndex < 0) { + return null; + } + + $optionIndex = $this->scrollOffset + $lineIndex; + + if (!isset($this->options[$optionIndex])) { + return null; + } + + return $optionIndex; + } } diff --git a/src/Editor/Widgets/PanelListModal.php b/src/Editor/Widgets/PanelListModal.php index 5cb48d6..9984534 100644 --- a/src/Editor/Widgets/PanelListModal.php +++ b/src/Editor/Widgets/PanelListModal.php @@ -52,6 +52,25 @@ public function getSelectedIndex(): int return $this->selectedIndex; } + public function clickPanelAtPoint(int $x, int $y): ?int + { + if (!$this->isVisible || !$this->containsPoint($x, $y)) { + return null; + } + + $lineIndex = $y - $this->getContentAreaTop(); + + if ($lineIndex < 0 || !isset($this->panelNames[$lineIndex])) { + return null; + } + + $this->selectedIndex = $lineIndex; + $this->refreshContent(); + $this->markDirty(); + + return $this->selectedIndex; + } + public function isDirty(): bool { return $this->isDirty; diff --git a/src/Editor/Widgets/Snackbar.php b/src/Editor/Widgets/Snackbar.php new file mode 100644 index 0000000..dab45c8 --- /dev/null +++ b/src/Editor/Widgets/Snackbar.php @@ -0,0 +1,355 @@ + */ + private array $queue = []; + private ?array $currentNotice = null; + private string $phase = 'hidden'; + private float $visibleUntil = 0.0; + private int $terminalWidth = DEFAULT_TERMINAL_WIDTH; + private int $terminalHeight = DEFAULT_TERMINAL_HEIGHT; + private float $defaultDurationSeconds; + private bool $isDirty = false; + + public function __construct(float $defaultDurationSeconds = self::DEFAULT_DURATION_SECONDS) + { + parent::__construct( + title: '', + help: '', + position: ['x' => 1, 'y' => -2], + width: 24, + height: 3, + ); + $this->defaultDurationSeconds = max(0.5, $defaultDurationSeconds); + } + + public function enqueue(string $message, string $status = 'info', ?float $durationSeconds = null): void + { + $normalizedMessage = trim(preg_replace('/\s+/', ' ', $message) ?? $message); + + if ($normalizedMessage === '') { + return; + } + + $resolvedDurationSeconds = is_numeric($durationSeconds) + ? (float) $durationSeconds + : $this->defaultDurationSeconds; + + $this->queue[] = [ + 'message' => $normalizedMessage, + 'status' => $this->normalizeStatus($status), + 'duration' => max(0.5, $resolvedDurationSeconds), + ]; + + if ($this->currentNotice === null) { + $this->activateNextNotice(); + } + } + + public function hasActiveNotice(): bool + { + return $this->currentNotice !== null; + } + + public function isDirty(): bool + { + return $this->isDirty; + } + + public function markClean(): void + { + $this->isDirty = false; + } + + public function renderAt(?int $x = null, ?int $y = null): void + { + if ($this->currentNotice === null) { + return; + } + + $position = $this->position; + $positionX = $position['x'] ?? $position[0] ?? 0; + $positionY = $position['y'] ?? $position[1] ?? 0; + $leftMargin = $positionX + ($x ?? 0); + $topMargin = $positionY + ($y ?? 0); + $rightMargin = $leftMargin + $this->width - 1; + $bottomMargin = $topMargin + $this->height - 1; + + if ($leftMargin > $this->terminalWidth || $rightMargin < 1 || $topMargin < 1 || $topMargin > $this->terminalHeight || $bottomMargin < 1) { + return; + } + + $visibleStartColumn = max(1, $leftMargin); + $clipOffset = max(0, 1 - $leftMargin); + $visibleWidth = max(0, min($this->terminalWidth, $rightMargin) - $visibleStartColumn + 1); + + if ($visibleWidth <= 0) { + return; + } + + $contentColor = $this->foregroundColor; + $this->foregroundColor = null; + $linesOfContent = $this->buildRenderedContentLines(); + $this->foregroundColor = $contentColor; + + if ($linesOfContent === []) { + $linesOfContent = ['']; + } + + $renderedLines = [ + ['kind' => 'border', 'line' => $this->buildBorderLine($this->title, true)], + ]; + + foreach ($linesOfContent as $index => $line) { + $renderedLines[] = [ + 'kind' => 'content', + 'line' => $line, + 'index' => $index, + ]; + } + + $renderedLines[] = ['kind' => 'border', 'line' => $this->buildBorderLine($this->help, false)]; + + $visibleStartRow = max(1, $topMargin); + $verticalClipOffset = max(0, 1 - $topMargin); + $visibleHeight = max(0, min($this->terminalHeight, $bottomMargin) - $visibleStartRow + 1); + + if ($visibleHeight <= 0) { + return; + } + + $visibleLines = array_slice($renderedLines, $verticalClipOffset, $visibleHeight); + + foreach ($visibleLines as $visibleIndex => $lineData) { + $this->cursor->moveTo($visibleStartColumn, $visibleStartRow + $visibleIndex); + + $clippedLine = $this->clipRenderedLine((string) ($lineData['line'] ?? ''), $clipOffset, $visibleWidth); + + if (($lineData['kind'] ?? '') === 'content') { + echo $this->decorateContentLine( + $clippedLine, + $contentColor, + (int) ($lineData['index'] ?? 0), + ); + continue; + } + + echo $this->decorateBorderLine($clippedLine, $contentColor); + } + } + + public function syncLayout(int $terminalWidth, int $terminalHeight): void + { + $this->terminalWidth = max(10, $terminalWidth); + $this->terminalHeight = max(3, $terminalHeight); + $targetX = $this->resolveTargetX($this->width); + + if ($this->currentNotice === null) { + $this->setPosition($targetX, $this->resolveHiddenY()); + return; + } + + $desiredWidth = min( + self::MAX_WIDTH, + max( + 24, + $this->getDisplayWidth((string) ($this->currentNotice['message'] ?? '')) + 4, + $this->getDisplayWidth($this->title) + 4, + ) + ); + $width = min($desiredWidth, max(10, $this->terminalWidth - 2)); + $targetX = $this->resolveTargetX($width); + $targetY = $this->resolveTargetY(); + $hiddenY = $this->resolveHiddenY(); + + $previousX = $this->x; + $this->setDimensions($width, 3); + $this->setPosition($targetX, $this->y); + $this->markDirtyIfChanged($previousX !== $this->x); + + if ($this->phase === 'visible') { + $this->moveToY($targetY); + return; + } + + if ($this->phase === 'hidden') { + $this->moveToY($hiddenY); + return; + } + + $this->moveToY(max($hiddenY, min($this->y, $targetY))); + } + + public function update(): void + { + if ($this->currentNotice === null) { + if ($this->queue !== []) { + $this->activateNextNotice(); + } + + return; + } + + $targetY = $this->resolveTargetY(); + $hiddenY = $this->resolveHiddenY(); + + switch ($this->phase) { + case 'entering': + $this->moveToY(min($targetY, $this->y + self::SLIDE_STEP)); + + if ($this->y >= $targetY) { + $this->moveToY($targetY); + $this->phase = 'visible'; + $this->isDirty = true; + $this->visibleUntil = microtime(true) + (float) ($this->currentNotice['duration'] ?? self::DEFAULT_DURATION_SECONDS); + } + break; + + case 'visible': + if (microtime(true) >= $this->visibleUntil) { + $this->phase = 'exiting'; + $this->isDirty = true; + } + break; + + case 'exiting': + $this->moveToY(max($hiddenY, $this->y - self::SLIDE_STEP)); + + if ($this->y <= $hiddenY) { + $this->currentNotice = null; + $this->content = []; + $this->title = ''; + $this->phase = 'hidden'; + $this->isDirty = true; + + if ($this->queue !== []) { + $this->activateNextNotice(); + } + } + break; + } + } + + protected function decorateBorderLine(string $line, ?Color $contentColor): string + { + return $this->wrapWithColor(mb_substr($line, 0, $this->width), $this->resolveStatusColor()); + } + + protected function decorateContentLine(string $line, ?Color $contentColor, int $lineIndex): string + { + $visibleLine = mb_substr($line, 0, $this->width); + $visibleLength = mb_strlen($visibleLine); + + if ($visibleLength <= 1) { + return parent::decorateContentLine($line, $contentColor, $lineIndex); + } + + $leftBorder = mb_substr($visibleLine, 0, 1); + $middle = $visibleLength > 2 ? mb_substr($visibleLine, 1, $visibleLength - 2) : ''; + $rightBorder = mb_substr($visibleLine, -1); + $borderColor = $this->resolveStatusColor(); + + return $this->wrapWithColor($leftBorder, $borderColor) + . $this->wrapWithSequence($middle, $this->resolveStatusSequence()) + . $this->wrapWithColor($rightBorder, $borderColor); + } + + private function activateNextNotice(): void + { + $nextNotice = array_shift($this->queue); + + if (!is_array($nextNotice)) { + return; + } + + $this->currentNotice = $nextNotice; + $this->title = ucfirst((string) $nextNotice['status']); + $this->help = ''; + $this->content = [(string) $nextNotice['message']]; + $this->phase = 'entering'; + $this->isDirty = true; + $this->syncLayout($this->terminalWidth, $this->terminalHeight); + $this->moveToY($this->resolveHiddenY()); + } + + private function clipRenderedLine(string $line, int $offset, int $visibleWidth): string + { + if ($visibleWidth <= 0) { + return ''; + } + + if ($offset <= 0 && $this->getDisplayWidth($line) <= $visibleWidth) { + return $line; + } + + return mb_strimwidth($line, $offset, $visibleWidth, '', 'UTF-8'); + } + + private function normalizeStatus(string $status): string + { + return match (strtolower(trim($status))) { + 'success' => 'success', + 'error' => 'error', + 'warning', 'warn' => 'warn', + default => 'info', + }; + } + + private function resolveStatusColor(): Color + { + return match ($this->currentNotice['status'] ?? 'info') { + 'success' => Color::LIGHT_GREEN, + 'error' => Color::LIGHT_RED, + 'warn' => Color::YELLOW, + default => Color::LIGHT_BLUE, + }; + } + + private function resolveStatusSequence(): string + { + return match ($this->currentNotice['status'] ?? 'info') { + 'success' => "\033[30;42m", + 'error' => "\033[30;41m", + 'warn' => "\033[30;43m", + default => "\033[30;44m", + }; + } + + private function resolveTargetX(int $width): int + { + return max(1, intdiv(max(0, $this->terminalWidth - $width), 2) + 1); + } + + private function resolveTargetY(): int + { + return 1; + } + + private function resolveHiddenY(): int + { + return 0; + } + + private function moveToY(int $y): void + { + $didChange = $this->y !== $y; + $this->setPosition($this->x, $y); + $this->markDirtyIfChanged($didChange); + } + + private function markDirtyIfChanged(bool $didChange): void + { + if ($didChange) { + $this->isDirty = true; + } + } +} diff --git a/src/Editor/Widgets/Widget.php b/src/Editor/Widgets/Widget.php index 4a50073..b6767e9 100644 --- a/src/Editor/Widgets/Widget.php +++ b/src/Editor/Widgets/Widget.php @@ -2,6 +2,7 @@ namespace Sendama\Console\Editor\Widgets; +use Atatusoft\Termutil\Events\MouseEvent; use Atatusoft\Termutil\IO\Enumerations\Color; use Atatusoft\Termutil\UI\Windows\Enumerations\HorizontalAlignment; use Atatusoft\Termutil\UI\Windows\Window; @@ -13,6 +14,9 @@ */ abstract class Widget extends Window implements FocusableInterface { + private const string SCROLLBAR_TRACK_CHARACTER = '░'; + private const string SCROLLBAR_THUMB_CHARACTER = '█'; + protected ?Widget $topSibling = null; protected ?Widget $rightSibling = null; protected ?Widget $bottomSibling = null; @@ -45,6 +49,8 @@ abstract class Widget extends Window implements FocusableInterface protected(set) bool $isEnabled = true; protected bool $hasFocus = false; protected Color $focusBorderColor = Color::LIGHT_CYAN; + protected int $verticalScrollOffset = 0; + protected bool $isScrollbarDragging = false; /** * Enables the widget. @@ -107,6 +113,98 @@ public function handleMouseClick(int $x, int $y): void { } + public function handleMouseDrag(int $x, int $y): void + { + } + + public function handleMouseRelease(int $x, int $y): void + { + } + + public function handleMouseEvent(MouseEvent $mouseEvent): void + { + if ($this->handleScrollbarMouseEvent($mouseEvent)) { + return; + } + + if ($mouseEvent->action === 'Pressed') { + $this->handleMouseClick($mouseEvent->x, $mouseEvent->y); + return; + } + + if ($mouseEvent->action === 'Dragged') { + $this->handleMouseDrag($mouseEvent->x, $mouseEvent->y); + return; + } + + if ($mouseEvent->action === 'Released') { + $this->handleMouseRelease($mouseEvent->x, $mouseEvent->y); + } + } + + public function handleScrollbarMouseEvent(MouseEvent $mouseEvent): bool + { + if ($this->isScrollbarDragging) { + if (in_array($mouseEvent->action, ['Dragged', 'Released'], true)) { + $this->applyScrollbarPointerPosition($mouseEvent->y); + + if ($mouseEvent->action === 'Released') { + $this->isScrollbarDragging = false; + } + + return true; + } + + if ($mouseEvent->action === 'Pressed' && $mouseEvent->buttonIndex !== 0) { + $this->isScrollbarDragging = false; + return false; + } + } + + if ( + $mouseEvent->buttonIndex === 0 + && $mouseEvent->action === 'Pressed' + && $this->containsScrollbarPoint($mouseEvent->x, $mouseEvent->y) + ) { + $this->isScrollbarDragging = true; + $this->applyScrollbarPointerPosition($mouseEvent->y); + + return true; + } + + return false; + } + + public function containsScrollbarPoint(int $x, int $y): bool + { + $scrollbarState = $this->resolveVerticalScrollbarState(); + + if ( + $scrollbarState === null + || !$this->containsPoint($x, $y) + || $x !== $this->getScrollbarColumnX() + ) { + return false; + } + + $visibleLineIndex = $y - $this->getContentAreaTop(); + $contentAreaRowIndex = $visibleLineIndex - $this->padding->topPadding; + + if (!is_int($contentAreaRowIndex) || $contentAreaRowIndex < 0) { + return false; + } + + $start = max(0, (int) ($scrollbarState['start'] ?? 0)); + $visible = max(0, (int) ($scrollbarState['visible'] ?? 0)); + + return $contentAreaRowIndex >= $start && $contentAreaRowIndex < ($start + $visible); + } + + public function isScrollbarDragging(): bool + { + return $this->isScrollbarDragging; + } + public function cycleFocusForward(): bool { return false; @@ -139,6 +237,11 @@ public function renderActiveModal(): void { } + public function handleModalMouseEvent(MouseEvent $mouseEvent): bool + { + return false; + } + public function consumeModalBackgroundRefreshRequest(): bool { return false; @@ -301,34 +404,154 @@ protected function buildRenderedContentLines(): array { $innerWidth = max(1, $this->innerWidth); $innerHeight = max(1, $this->innerHeight); - $blankLine = $this->borderPack->vertical . str_repeat(' ', $innerWidth) . $this->borderPack->vertical; + $scrollbarState = $this->resolveVerticalScrollbarState(); + $textInnerWidth = max(0, $innerWidth - ($scrollbarState !== null ? 1 : 0)); $lines = []; + $contentAreaRowIndex = 0; for ($row = 0; $row < $this->padding->topPadding && count($lines) < $innerHeight; $row++) { - $lines[] = $blankLine; + $lines[] = $this->buildViewportLine('', $textInnerWidth, $scrollbarState, null); } $contentLineLimit = max(0, $innerHeight - $this->padding->bottomPadding); + $contentLines = $this->resolveRenderableContentLines(); - foreach ($this->content as $lineOfContent) { + foreach ($contentLines as $lineOfContent) { if (count($lines) >= $contentLineLimit) { break; } - $lines[] = $this->buildContentLine((string) $lineOfContent, $innerWidth); + $lines[] = $this->buildViewportLine((string) $lineOfContent, $textInnerWidth, $scrollbarState, $contentAreaRowIndex); + $contentAreaRowIndex++; } while (count($lines) < $contentLineLimit) { - $lines[] = $blankLine; + $lines[] = $this->buildViewportLine('', $textInnerWidth, $scrollbarState, $contentAreaRowIndex); + $contentAreaRowIndex++; } while (count($lines) < $innerHeight) { - $lines[] = $blankLine; + $lines[] = $this->buildViewportLine('', $textInnerWidth, $scrollbarState, null); } return $lines; } + protected function usesAutomaticVerticalScrolling(): bool + { + return true; + } + + protected function getVerticalScrollViewportLineCount(): int + { + return max(1, $this->innerHeight - $this->padding->topPadding - $this->padding->bottomPadding); + } + + protected function ensureContentLineVisible(?int $contentIndex): void + { + if (!$this->usesAutomaticVerticalScrolling() || !is_int($contentIndex) || $contentIndex < 0) { + return; + } + + $visibleLineCount = $this->getVerticalScrollViewportLineCount(); + + if ($visibleLineCount <= 0) { + $this->verticalScrollOffset = 0; + return; + } + + $this->clampVerticalScrollOffset(); + + if ($contentIndex < $this->verticalScrollOffset) { + $this->verticalScrollOffset = $contentIndex; + } elseif ($contentIndex >= $this->verticalScrollOffset + $visibleLineCount) { + $this->verticalScrollOffset = $contentIndex - $visibleLineCount + 1; + } + + $this->clampVerticalScrollOffset(); + } + + protected function getContentIndexForLineIndex(int $lineIndex): ?int + { + $contentRowIndex = $lineIndex - $this->padding->topPadding; + + if ($contentRowIndex < 0) { + return null; + } + + $contentIndex = $this->usesAutomaticVerticalScrolling() + ? $this->getClampedVerticalScrollOffset() + $contentRowIndex + : $contentRowIndex; + + if (!array_key_exists($contentIndex, $this->content)) { + return null; + } + + return $contentIndex; + } + + protected function getRenderedLineIndexForContentIndex(int $contentIndex): ?int + { + if ($contentIndex < 0) { + return null; + } + + if ($this->usesAutomaticVerticalScrolling()) { + $scrollOffset = $this->getClampedVerticalScrollOffset(); + $visibleLineCount = $this->getVerticalScrollViewportLineCount(); + + if ($contentIndex < $scrollOffset || $contentIndex >= $scrollOffset + $visibleLineCount) { + return null; + } + + return $this->padding->topPadding + ($contentIndex - $scrollOffset); + } + + return array_key_exists($contentIndex, $this->content) + ? $this->padding->topPadding + $contentIndex + : null; + } + + protected function resolveContentIndexFromPointY(int $y): ?int + { + return $this->getContentIndexForLineIndex($y - $this->getContentAreaTop()); + } + + protected function setScrollbarOffset(int $offset): void + { + $this->verticalScrollOffset = max(0, $offset); + $this->clampVerticalScrollOffset(); + $this->handleScrollbarOffsetChanged(); + } + + protected function handleScrollbarOffsetChanged(): void + { + } + + /** + * @return array{offset:int, visible:int, total:int, start:int}|null + */ + protected function resolveVerticalScrollbarState(): ?array + { + if (!$this->usesAutomaticVerticalScrolling()) { + return null; + } + + $visibleLineCount = $this->getVerticalScrollViewportLineCount(); + $totalLineCount = count($this->content); + + if ($visibleLineCount <= 0 || $totalLineCount <= $visibleLineCount) { + return null; + } + + return [ + 'offset' => $this->getClampedVerticalScrollOffset(), + 'visible' => $visibleLineCount, + 'total' => $totalLineCount, + 'start' => 0, + ]; + } + protected function buildContentLine(string $content, int $innerWidth): string { $leftPadding = max(0, $this->padding->leftPadding); @@ -536,6 +759,139 @@ protected function wrapWithSequence(string $content, ?string $sequence): string return $sequence . $content . Color::RESET->value; } + private function resolveRenderableContentLines(): array + { + if (!$this->usesAutomaticVerticalScrolling()) { + return $this->content; + } + + $this->clampVerticalScrollOffset(); + + return array_slice( + $this->content, + $this->verticalScrollOffset, + $this->getVerticalScrollViewportLineCount(), + ); + } + + private function clampVerticalScrollOffset(): void + { + $this->verticalScrollOffset = $this->getClampedVerticalScrollOffset(); + } + + private function getScrollbarColumnX(): int + { + return ($this->position['x'] ?? 0) + $this->innerWidth; + } + + private function applyScrollbarPointerPosition(int $y): void + { + $scrollbarState = $this->resolveVerticalScrollbarState(); + + if ($scrollbarState === null) { + $this->isScrollbarDragging = false; + return; + } + + $visibleLineIndex = $y - $this->getContentAreaTop(); + $contentAreaRowIndex = $visibleLineIndex - $this->padding->topPadding; + $scrollbarStart = max(0, (int) ($scrollbarState['start'] ?? 0)); + $visibleLineCount = max(0, (int) ($scrollbarState['visible'] ?? 0)); + $totalLineCount = max(0, (int) ($scrollbarState['total'] ?? 0)); + + if ($visibleLineCount <= 0 || $totalLineCount <= 0) { + return; + } + + $relativeRow = max(0, min($visibleLineCount - 1, $contentAreaRowIndex - $scrollbarStart)); + $maxScrollOffset = max(0, $totalLineCount - $visibleLineCount); + + if ($maxScrollOffset === 0) { + $this->setScrollbarOffset(0); + return; + } + + $ratio = $visibleLineCount <= 1 ? 0.0 : ($relativeRow / ($visibleLineCount - 1)); + $this->setScrollbarOffset((int) round($ratio * $maxScrollOffset)); + } + + private function getClampedVerticalScrollOffset(): int + { + if (!$this->usesAutomaticVerticalScrolling()) { + return 0; + } + + $maxScrollOffset = max(0, count($this->content) - $this->getVerticalScrollViewportLineCount()); + + return max(0, min($this->verticalScrollOffset, $maxScrollOffset)); + } + + /** + * @param array{offset:int, visible:int, total:int, start:int}|null $scrollbarState + */ + private function buildViewportLine( + string $content, + int $textInnerWidth, + ?array $scrollbarState, + ?int $contentAreaRowIndex, + ): string + { + $line = $this->buildContentLine($content, $textInnerWidth); + + if ($scrollbarState === null) { + return $line; + } + + $visibleLength = mb_strlen($line); + + if ($visibleLength === 0) { + return $line; + } + + $lineWithoutRightBorder = mb_substr($line, 0, $visibleLength - 1); + $rightBorder = mb_substr($line, -1); + + return $lineWithoutRightBorder + . $this->resolveScrollbarCharacter($scrollbarState, $contentAreaRowIndex) + . $rightBorder; + } + + /** + * @param array{offset:int, visible:int, total:int, start:int} $scrollbarState + */ + private function resolveScrollbarCharacter(array $scrollbarState, ?int $contentAreaRowIndex): string + { + if (!is_int($contentAreaRowIndex)) { + return ' '; + } + + $scrollbarStart = max(0, (int) ($scrollbarState['start'] ?? 0)); + $visibleLineCount = max(0, (int) ($scrollbarState['visible'] ?? 0)); + $totalLineCount = max(0, (int) ($scrollbarState['total'] ?? 0)); + + if ( + $visibleLineCount === 0 + || $totalLineCount === 0 + || $contentAreaRowIndex < $scrollbarStart + || $contentAreaRowIndex >= ($scrollbarStart + $visibleLineCount) + ) { + return ' '; + } + + $relativeRow = $contentAreaRowIndex - $scrollbarStart; + $scrollOffset = max(0, (int) ($scrollbarState['offset'] ?? 0)); + $thumbHeight = max(1, (int) ceil(($visibleLineCount * $visibleLineCount) / max(1, $totalLineCount))); + $maxThumbStart = max(0, $visibleLineCount - $thumbHeight); + $maxScrollOffset = max(0, $totalLineCount - $visibleLineCount); + $thumbStart = $maxScrollOffset === 0 + ? 0 + : (int) round(($scrollOffset / $maxScrollOffset) * $maxThumbStart); + + return ($relativeRow >= $thumbStart && $relativeRow < ($thumbStart + $thumbHeight)) + ? self::SCROLLBAR_THUMB_CHARACTER + : self::SCROLLBAR_TRACK_CHARACTER; + } + /** * @return void */ diff --git a/src/Util/Path.php b/src/Util/Path.php index 5083554..0c86004 100644 --- a/src/Util/Path.php +++ b/src/Util/Path.php @@ -138,10 +138,8 @@ public static function resolveAssetsDirectory(string $rootDirectory): string $legacyExists = is_dir($legacyAssetsDirectory); if ($canonicalExists && $legacyExists) { - $canonicalEntries = scandir($canonicalAssetsDirectory); - $legacyEntries = scandir($legacyAssetsDirectory); - $canonicalHasContent = $canonicalEntries !== false && array_diff($canonicalEntries, ['.', '..']) !== []; - $legacyHasContent = $legacyEntries !== false && array_diff($legacyEntries, ['.', '..']) !== []; + $canonicalHasContent = self::directoryContainsFiles($canonicalAssetsDirectory); + $legacyHasContent = self::directoryContainsFiles($legacyAssetsDirectory); if (!$canonicalHasContent && $legacyHasContent) { return $legacyAssetsDirectory; @@ -157,6 +155,26 @@ public static function resolveAssetsDirectory(string $rootDirectory): string return $legacyAssetsDirectory; } + private static function directoryContainsFiles(string $directory): bool + { + if (!is_dir($directory)) { + return false; + } + + $iterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($directory, \RecursiveDirectoryIterator::SKIP_DOTS), + \RecursiveIteratorIterator::SELF_FIRST + ); + + foreach ($iterator as $entry) { + if ($entry->isFile()) { + return true; + } + } + + return false; + } + /** * Normalizes the given path. * diff --git a/src/Util/ProjectNormalizer.php b/src/Util/ProjectNormalizer.php index e3351c1..54c48f6 100644 --- a/src/Util/ProjectNormalizer.php +++ b/src/Util/ProjectNormalizer.php @@ -42,8 +42,8 @@ public function inspect(): array $issues[] = 'Missing sendama.json.'; } - if (!is_file(Path::join($this->projectRoot, 'configuration.json'))) { - $issues[] = 'Missing configuration.json.'; + if (!is_file(Path::join($this->projectRoot, 'preferences.json'))) { + $issues[] = 'Missing preferences.json.'; } if (!is_dir($configDirectory)) { @@ -121,14 +121,9 @@ public function normalize(): array ); $this->ensureFile( - Path::join($this->projectRoot, 'configuration.json'), - self::buildConfigurationJson( - projectName: $projectMetadata['name'], - description: $projectMetadata['description'], - version: $projectMetadata['version'], - mainFile: $projectMetadata['main'], - ), - 'Created configuration.json.', + Path::join($this->projectRoot, 'preferences.json'), + self::buildPreferencesJson(), + 'Created preferences.json.', $changes, ); @@ -158,6 +153,7 @@ public static function buildSendamaConfiguration( string $mainFile = 'main.php', array $loadedScenes = [], float $consoleRefreshInterval = 5.0, + float $notificationDuration = 4.0, ): string { return json_encode([ 'name' => $projectName, @@ -172,24 +168,15 @@ public static function buildSendamaConfiguration( 'console' => [ 'refreshInterval' => $consoleRefreshInterval, ], + 'notifications' => [ + 'duration' => $notificationDuration, + ], ], ], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . PHP_EOL; } - public static function buildConfigurationJson( - string $projectName, - string $description = 'A simple ASCII terminal game', - string $version = '0.0.1', - string $mainFile = 'main.php', - ): string { - return json_encode([ - 'project' => [ - 'name' => $projectName, - 'description' => $description, - 'version' => $version, - 'main' => $mainFile, - ], - ], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . PHP_EOL; + public static function buildPreferencesJson(): string { + return json_encode([], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . PHP_EOL; } public static function buildInputConfiguration(): string @@ -236,10 +223,10 @@ private function resolveProjectMetadata(): array } } - $configurationPath = Path::join($this->projectRoot, 'configuration.json'); + $preferencesPath = Path::join($this->projectRoot, 'preferences.json'); - if (is_file($configurationPath)) { - $configurationContents = file_get_contents($configurationPath); + if (is_file($preferencesPath)) { + $configurationContents = file_get_contents($preferencesPath); $configurationData = $configurationContents !== false ? json_decode($configurationContents, true) : null; $projectData = is_array($configurationData['project'] ?? null) ? $configurationData['project'] : null; diff --git a/templates/assets/Maps/example.tmap b/templates/Assets/Maps/example.tmap similarity index 98% rename from templates/assets/Maps/example.tmap rename to templates/Assets/Maps/example.tmap index b8c4bea..0e3d5ad 100644 --- a/templates/assets/Maps/example.tmap +++ b/templates/Assets/Maps/example.tmap @@ -1,25 +1,25 @@ -xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx -x x -x x -x x -x x -x x -x x -x x -x x -x x -x x -x x -x x -x x -x x -x x -x x -x x -x x -x x -x x -x x -x x -x x +xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx +x x +x x +x x +x x +x x +x x +x x +x x +x x +x x +x x +x x +x x +x x +x x +x x +x x +x x +x x +x x +x x +x x +x x xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx \ No newline at end of file diff --git a/templates/assets/Textures/player.texture b/templates/Assets/Textures/player.texture similarity index 100% rename from templates/assets/Textures/player.texture rename to templates/Assets/Textures/player.texture diff --git a/templates/assets/splash.texture b/templates/Assets/splash.texture similarity index 97% rename from templates/assets/splash.texture rename to templates/Assets/splash.texture index f80deef..83bed70 100644 --- a/templates/assets/splash.texture +++ b/templates/Assets/splash.texture @@ -1,26 +1,26 @@ -Powered by - - .d8888b. 888 -d88P Y88b 888 -Y88b. 888 - "Y888b. .d88b. 88888b. .d88888 8888b. 88888b.d88b. 8888b. - "Y88b. d8P Y8b 888 "88b d88" 888 "88b 888 "888 "88b "88b - "888 88888888 888 888 888 888 .d888888 888 888 888 .d888888 -Y88b d88P Y8b. 888 888 Y88b 888 888 888 888 888 888 888 888 - "Y8888P" "Y8888 888 888 "Y88888 "Y888888 888 888 888 "Y888888 - - - - 8888888888 d8b - 888 Y8P - 888 - 8888888 88888b. .d88b. 888 88888b. .d88b. - 888 888 "88b d88P"88b 888 888 "88b d8P Y8b - 888 888 888 888 888 888 888 888 88888888 - 888 888 888 Y88b 888 888 888 888 Y8b. - 8888888888 888 888 "Y88888 888 888 888 "Y8888 - 888 - Y8b d88P - "Y88P" - +Powered by + + .d8888b. 888 +d88P Y88b 888 +Y88b. 888 + "Y888b. .d88b. 88888b. .d88888 8888b. 88888b.d88b. 8888b. + "Y88b. d8P Y8b 888 "88b d88" 888 "88b 888 "888 "88b "88b + "888 88888888 888 888 888 888 .d888888 888 888 888 .d888888 +Y88b d88P Y8b. 888 888 Y88b 888 888 888 888 888 888 888 888 + "Y8888P" "Y8888 888 888 "Y88888 "Y888888 888 888 888 "Y888888 + + + + 8888888888 d8b + 888 Y8P + 888 + 8888888 88888b. .d88b. 888 88888b. .d88b. + 888 888 "88b d88P"88b 888 888 "88b d8P Y8b + 888 888 888 888 888 888 888 888 88888888 + 888 888 888 Y88b 888 888 888 888 Y8b. + 8888888888 888 888 "Y88888 888 888 888 "Y8888 + 888 + Y8b d88P + "Y88P" + v1.0.0 \ No newline at end of file diff --git a/templates/composer.json b/templates/composer.json index 7e1b295..3fd18e0 100644 --- a/templates/composer.json +++ b/templates/composer.json @@ -3,12 +3,11 @@ "description": "A simple ASCII terminal game.", "license": "proprietary", "require": { - "php": ">=8.3", - "atatusoft-ltd/sendama-engine": "^0.3" + "php": ">=8.3" }, "autoload": { "psr-4": { - "Sendama\\Game\\": "assets/" + "Sendama\\Game\\": "Assets/" } } } \ No newline at end of file diff --git a/templates/game.php b/templates/game.php index a2910fb..2aef894 100644 --- a/templates/game.php +++ b/templates/game.php @@ -1,7 +1,7 @@ -setTitle($gameName); + + $titleScene = new TitleScene('Title Screen'); + $titleScene->setMenuTitle($gameName); $game ->addScenes($titleScene) @@ -19,5 +19,5 @@ function bootstrap(): void ->loadSettings() ->run(); } - + bootstrap(); diff --git a/templates/preferences.json b/templates/preferences.json new file mode 100644 index 0000000..7a73a41 --- /dev/null +++ b/templates/preferences.json @@ -0,0 +1,2 @@ +{ +} \ No newline at end of file diff --git a/templates/sendama.json b/templates/sendama.json index afdc0be..feadbcf 100644 --- a/templates/sendama.json +++ b/templates/sendama.json @@ -12,6 +12,9 @@ }, "console": { "refreshInterval": 5 + }, + "notifications": { + "duration": 4 } } } diff --git a/tests/Unit/AssetsPanelTest.php b/tests/Unit/AssetsPanelTest.php index d01b587..862bbdf 100644 --- a/tests/Unit/AssetsPanelTest.php +++ b/tests/Unit/AssetsPanelTest.php @@ -1,8 +1,23 @@ $getContentAreaLeft->invoke($panel), + 'y' => $getContentAreaTop->invoke($panel), + ]; +} + test('assets panel loads and traverses nested project assets', function () { $workspace = sys_get_temp_dir() . '/sendama-assets-panel-' . uniqid(); mkdir($workspace . '/Assets/Textures', 0777, true); @@ -18,15 +33,15 @@ assetsDirectoryPath: $workspace . '/Assets', ); - expect($panel->getSelectedAssetEntry()['name'])->toBe('Scripts'); - expect($panel->content[0])->toBe('► Scripts'); - expect($panel->content[1])->toBe('► Textures'); - expect($panel->content[2])->toBe('• readme.txt'); + expect($panel->getSelectedAssetEntry()['name'])->toBe('Scripts') + ->and($panel->content[0])->toBe('► Scripts') + ->and($panel->content[1])->toBe('► Textures') + ->and($panel->content[2])->toBe('• readme.txt'); $panel->expandSelection(); - expect($panel->content[0])->toBe('▼ Scripts'); - expect($panel->content[1])->toBe(' ► Player'); + expect($panel->content[0])->toBe('▼ Scripts') + ->and($panel->content[1])->toBe(' ► Player'); $panel->expandSelection(); @@ -64,8 +79,9 @@ 'children' => [], ], 'openInMainPanel' => true, - ]); - expect($panel->consumeInspectionRequest())->toBeNull(); + 'openInTerminalEditor' => false, + ]) + ->and($panel->consumeInspectionRequest())->toBeNull(); }); test('assets panel queues inspection when selection changes', function () { @@ -96,6 +112,7 @@ 'children' => [], ], 'openInMainPanel' => false, + 'openInTerminalEditor' => false, ]); }); @@ -123,10 +140,43 @@ 'children' => [], ], 'openInMainPanel' => true, + 'openInTerminalEditor' => false, + ]); +}); + +test('assets panel marks activated script files for terminal-editor opening', function () { + $workspace = sys_get_temp_dir() . '/sendama-assets-panel-' . uniqid(); + mkdir($workspace . '/Assets/Scripts', 0777, true); + file_put_contents($workspace . '/Assets/Scripts/PlayerController.php', 'expandSelection(); + $panel->moveSelection(1); + $panel->activateSelection(); + + expect($panel->consumeInspectionRequest())->toBe([ + 'context' => 'asset', + 'name' => 'PlayerController.php', + 'type' => 'File', + 'value' => [ + 'name' => 'PlayerController.php', + 'path' => $workspace . '/Assets/Scripts/PlayerController.php', + 'relativePath' => 'Scripts/PlayerController.php', + 'isDirectory' => false, + 'children' => [], + ], + 'openInMainPanel' => true, + 'openInTerminalEditor' => true, ]); }); -test('assets panel queues the selected asset for deletion when confirmed', function () { + +test('assets panel queues the selected asset for deletion when confirmed', /** @throws Exception */function () { $workspace = sys_get_temp_dir() . '/sendama-assets-panel-' . uniqid(); mkdir($workspace . '/Assets', 0777, true); file_put_contents($workspace . '/Assets/readme.txt', 'docs'); @@ -138,21 +188,16 @@ ); $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"); + $keyPress = new ReflectionProperty(InputManager::class, 'keyPress'); + $previousKeyPress = new ReflectionProperty(InputManager::class, 'previousKeyPress'); + $previousKeyPress->setValue(null, ''); + $keyPress->setValue(null, "\n"); $deleteConfirmModal = new ReflectionProperty(AssetsPanel::class, 'deleteConfirmModal'); - $deleteConfirmModal->setAccessible(true); $modal = $deleteConfirmModal->getValue($panel); $moveSelection = new ReflectionMethod($modal, 'moveSelection'); $moveSelection->invoke($modal, -1); @@ -167,7 +212,7 @@ ]); }); -test('assets panel cancels delete confirmation without queuing a deletion', function () { +test('assets panel cancels delete confirmation without queuing a deletion', /** @throws Exception */ function () { $workspace = sys_get_temp_dir() . '/sendama-assets-panel-' . uniqid(); mkdir($workspace . '/Assets', 0777, true); file_put_contents($workspace . '/Assets/readme.txt', 'docs'); @@ -179,25 +224,21 @@ ); $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"); + $keyPress = new ReflectionProperty(InputManager::class, 'keyPress'); + $previousKeyPress = new ReflectionProperty(InputManager::class, 'previousKeyPress'); + $previousKeyPress->setValue(null, ''); + $keyPress->setValue(null, "\n"); $handleModalInput->invoke($panel); expect($panel->consumeDeletionRequest())->toBeNull(); }); -test('assets panel queues the selected asset type for creation when confirmed', function () { +test('assets panel queues the selected asset type for creation when confirmed', /** @throws Exception */ function () { $workspace = sys_get_temp_dir() . '/sendama-assets-panel-' . uniqid(); mkdir($workspace . '/Assets', 0777, true); @@ -211,14 +252,11 @@ $panel->beginCreateWorkflow(); $handleModalInput = new ReflectionMethod(AssetsPanel::class, 'handleModalInput'); - $handleModalInput->setAccessible(true); - $keyPress = new ReflectionProperty(\Sendama\Console\Editor\IO\InputManager::class, 'keyPress'); - $previousKeyPress = new ReflectionProperty(\Sendama\Console\Editor\IO\InputManager::class, 'previousKeyPress'); - $keyPress->setAccessible(true); - $previousKeyPress->setAccessible(true); - $previousKeyPress->setValue(''); - $keyPress->setValue("\n"); + $keyPress = new ReflectionProperty(InputManager::class, 'keyPress'); + $previousKeyPress = new ReflectionProperty(InputManager::class, 'previousKeyPress'); + $previousKeyPress->setValue(null, ''); + $keyPress->setValue(null, "\n"); $handleModalInput->invoke($panel); @@ -242,21 +280,17 @@ $panel->beginCreateWorkflow(); $handleModalInput = new ReflectionMethod(AssetsPanel::class, 'handleModalInput'); - $handleModalInput->setAccessible(true); - $keyPress = new ReflectionProperty(\Sendama\Console\Editor\IO\InputManager::class, 'keyPress'); - $previousKeyPress = new ReflectionProperty(\Sendama\Console\Editor\IO\InputManager::class, 'previousKeyPress'); - $keyPress->setAccessible(true); - $previousKeyPress->setAccessible(true); + $keyPress = new ReflectionProperty(InputManager::class, 'keyPress'); + $previousKeyPress = new ReflectionProperty(InputManager::class, 'previousKeyPress'); $createAssetModal = new ReflectionProperty(AssetsPanel::class, 'createAssetModal'); - $createAssetModal->setAccessible(true); $modal = $createAssetModal->getValue($panel); $moveSelection = new ReflectionMethod($modal, 'moveSelection'); $moveSelection->invoke($modal, 2); - $previousKeyPress->setValue(''); - $keyPress->setValue("\n"); + $previousKeyPress->setValue(null, ''); + $keyPress->setValue(null, "\n"); $handleModalInput->invoke($panel); expect($panel->consumeCreationRequest())->toBe([ @@ -277,18 +311,71 @@ ); $hasFocus = new ReflectionProperty(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); - $previousKeyPress->setValue(''); - $keyPress->setValue('A'); + $keyPress = new ReflectionProperty(InputManager::class, 'keyPress'); + $previousKeyPress = new ReflectionProperty(InputManager::class, 'previousKeyPress'); + $previousKeyPress->setValue(null, ''); + $keyPress->setValue(null, 'A'); $panel->update(); - expect($panel->hasActiveModal())->toBeTrue(); - expect($panel->consumeCreationRequest())->toBeNull(); + expect($panel->hasActiveModal())->toBeTrue() + ->and($panel->consumeCreationRequest())->toBeNull(); +}); + +test('assets panel toggles folder expand and collapse when the icon is clicked', /** @throws Exception */ function () { + $workspace = sys_get_temp_dir() . '/sendama-assets-panel-' . uniqid(); + mkdir($workspace . '/Assets/Scripts/Player', 0777, true); + file_put_contents($workspace . '/Assets/Scripts/Player/controller.php', 'handleMouseClick($contentArea['x'], $contentArea['y']); + + expect($panel->content[0])->toBe('▼ Scripts') + ->and($panel->content[1])->toBe(' ► Player'); + + $panel->handleMouseClick($contentArea['x'], $contentArea['y']); + + expect($panel->content[0])->toBe('► Scripts'); +}); + +test('assets panel activates the selected row on double click', 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', + ); + + $contentArea = getAssetsContentAreaPosition($panel); + $clickX = $contentArea['x'] + 2; + $clickY = $contentArea['y']; + + $panel->handleMouseClick($clickX, $clickY); + $panel->handleMouseClick($clickX, $clickY); + + expect($panel->consumeInspectionRequest())->toBe([ + 'context' => 'asset', + 'name' => 'readme.txt', + 'type' => 'File', + 'value' => [ + 'name' => 'readme.txt', + 'path' => $workspace . '/Assets/readme.txt', + 'relativePath' => 'readme.txt', + 'isDirectory' => false, + 'children' => [], + ], + 'openInMainPanel' => true, + 'openInTerminalEditor' => false, + ]); }); diff --git a/tests/Unit/CliAssetsDirectoryTest.php b/tests/Unit/CliAssetsDirectoryTest.php index 382d5af..f25b837 100644 --- a/tests/Unit/CliAssetsDirectoryTest.php +++ b/tests/Unit/CliAssetsDirectoryTest.php @@ -82,7 +82,6 @@ function runGeneratorCommandInWorkspace(object $command, string $workspace, arra test('new game composer configuration uses Assets autoload path', function () { $command = new NewGame(); $method = new ReflectionMethod(NewGame::class, 'getComposerConfiguration'); - $method->setAccessible(true); $configuration = json_decode($method->invoke($command, 'sendama-engine/test-game'), true, flags: JSON_THROW_ON_ERROR); @@ -92,13 +91,13 @@ function runGeneratorCommandInWorkspace(object $command, string $workspace, arra test('new game project configuration includes editor defaults for the Level scene and console refresh', function () { $command = new NewGame(); $method = new ReflectionMethod(NewGame::class, 'getProjectConfiguration'); - $method->setAccessible(true); $configuration = json_decode($method->invoke($command, 'Test Game'), true, flags: JSON_THROW_ON_ERROR); expect($configuration['editor']['scenes']['active'])->toBe(0); expect($configuration['editor']['scenes']['loaded'])->toBe(['Scenes/Level.scene.php']); expect($configuration['editor']['console']['refreshInterval'])->toBe(5); + expect($configuration['editor']['notifications']['duration'])->toBe(4); }); test('new game creates an Assets directory', function () { @@ -107,59 +106,61 @@ function runGeneratorCommandInWorkspace(object $command, string $workspace, arra $command = new NewGame(); $property = new ReflectionProperty(NewGame::class, 'targetDirectory'); - $property->setAccessible(true); $property->setValue($command, $workspace); $method = new ReflectionMethod(NewGame::class, 'createAssetsDirectory'); - $method->setAccessible(true); $assetsDirectory = $method->invoke($command); expect($assetsDirectory)->toBe($workspace . '/Assets'); expect(is_dir($workspace . '/Assets'))->toBeTrue(); }); +test('asset root resolution prefers populated legacy assets over empty canonical Assets', function () { + $workspace = sys_get_temp_dir() . '/sendama-assets-root-resolution-' . uniqid(); + mkdir($workspace . '/Assets/Prefabs', 0777, true); + mkdir($workspace . '/assets/Prefabs', 0777, true); + file_put_contents($workspace . '/assets/Prefabs/enemy.prefab.php', " 'Enemy'];"); + + expect(Path::resolveAssetsDirectory($workspace))->toBe($workspace . '/assets'); +}); + test('new game creates configuration json content for new projects', function () { $workspace = sys_get_temp_dir() . '/sendama-new-game-configuration-' . uniqid(); mkdir($workspace, 0777, true); $command = new NewGame(); $property = new ReflectionProperty(NewGame::class, 'targetDirectory'); - $property->setAccessible(true); $property->setValue($command, $workspace); $method = new ReflectionMethod(NewGame::class, 'createConfigurationJsonFile'); - $method->setAccessible(true); $method->invoke($command, 'Test Game'); $configuration = json_decode( - file_get_contents($workspace . '/configuration.json'), + file_get_contents($workspace . '/preferences.json'), true, flags: JSON_THROW_ON_ERROR ); - expect($configuration['project']['name'])->toBe('Test Game'); - expect($configuration['project']['main'])->toBe('test-game.php'); + expect($configuration)->toBeEmpty(); }); -test('new game creates a default Level scene metadata file', function () { +test('new game creates a default Level scene metadata file', /** @throws Exception */ function () { $workspace = sys_get_temp_dir() . '/sendama-new-game-scene-' . uniqid(); mkdir($workspace, 0777, true); mkdir($workspace . '/Assets/Scenes', 0777, true); $command = new NewGame(); $property = new ReflectionProperty(NewGame::class, 'targetDirectory'); - $property->setAccessible(true); $property->setValue($command, $workspace); $method = new ReflectionMethod(NewGame::class, 'createDefaultSceneFile'); - $method->setAccessible(true); $method->invoke($command, $workspace . '/Assets'); $sceneContents = file_get_contents($workspace . '/Assets/Scenes/Level.scene.php'); - expect(is_file($workspace . '/Assets/Scenes/Level.scene.php'))->toBeTrue(); - expect($sceneContents)->toContain('"environmentTileMapPath" => "Maps/example"'); - expect($sceneContents)->toContain('"position" => ["x" => 0, "y" => 0]'); + expect(is_file($workspace . '/Assets/Scenes/Level.scene.php'))->toBeTrue() + ->and($sceneContents)->toContain('"environmentTileMapPath" => "Maps/example"') + ->and($sceneContents)->toContain('"position" => ["x" => 0, "y" => 0]'); }); test('new game main template loads the default Level scene metadata file', function () { @@ -168,17 +169,15 @@ function runGeneratorCommandInWorkspace(object $command, string $workspace, arra $command = new NewGame(); $property = new ReflectionProperty(NewGame::class, 'targetDirectory'); - $property->setAccessible(true); $property->setValue($command, $workspace); $method = new ReflectionMethod(NewGame::class, 'createMainFile'); - $method->setAccessible(true); $method->invoke($command, 'Test Game'); $mainContents = file_get_contents($workspace . '/' . basename($workspace) . '.php'); - expect($mainContents)->toContain("loadScenes('Scenes/Level')"); - expect($mainContents)->not->toContain('ExampleScene'); + expect($mainContents)->toContain("loadScenes('Scenes/Level')") + ->and($mainContents)->not->toContain('ExampleScene'); }); test('generate script creates files under Assets', function () { @@ -189,8 +188,8 @@ function runGeneratorCommandInWorkspace(object $command, string $workspace, arra ['name' => 'player'], ); - expect($exitCode)->toBe(0); - expect(is_file($workspace . '/Assets/Scripts/Player.php'))->toBeTrue(); + expect($exitCode)->toBe(0) + ->and(is_file($workspace . '/Assets/Scripts/Player.php'))->toBeTrue(); }); test('generate texture creates files under Assets', function () { @@ -201,8 +200,8 @@ function runGeneratorCommandInWorkspace(object $command, string $workspace, arra ['name' => 'player'], ); - expect($exitCode)->toBe(0); - expect(is_file($workspace . '/Assets/Textures/player.texture'))->toBeTrue(); + expect($exitCode)->toBe(0) + ->and(is_file($workspace . '/Assets/Textures/player.texture'))->toBeTrue(); }); test('generate scene creates files under Assets', function () { @@ -213,8 +212,8 @@ function runGeneratorCommandInWorkspace(object $command, string $workspace, arra ['name' => 'level01'], ); - expect($exitCode)->toBe(0); - expect(is_file($workspace . '/Assets/Scenes/level01.scene.php'))->toBeTrue(); + expect($exitCode)->toBe(0) + ->and(is_file($workspace . '/Assets/Scenes/level01.scene.php'))->toBeTrue(); }); test('generate prefab creates metadata prefab files under Assets', function () { @@ -228,10 +227,10 @@ function runGeneratorCommandInWorkspace(object $command, string $workspace, arra $prefabPath = $workspace . '/Assets/Prefabs/enemy.prefab.php'; $prefabContents = file_get_contents($prefabPath); - expect($exitCode)->toBe(0); - expect(is_file($prefabPath))->toBeTrue(); - expect($prefabContents)->toContain("'type' => GameObject::class"); - expect($prefabContents)->toContain("'name' => 'Enemy'"); + expect($exitCode)->toBe(0) + ->and(is_file($prefabPath))->toBeTrue() + ->and($prefabContents)->toContain("'type' => GameObject::class") + ->and($prefabContents)->toContain("'name' => 'Enemy'"); }); test('generate prefab can create ui element prefab metadata', function () { @@ -245,11 +244,11 @@ function runGeneratorCommandInWorkspace(object $command, string $workspace, arra $prefabPath = $workspace . '/Assets/Prefabs/score-label.prefab.php'; $prefabContents = file_get_contents($prefabPath); - expect($exitCode)->toBe(0); - expect(is_file($prefabPath))->toBeTrue(); - expect($prefabContents)->toContain("'type' => Label::class"); - expect($prefabContents)->toContain("'tag' => 'UI'"); - expect($prefabContents)->toContain("'text' => 'Score Label'"); + expect($exitCode)->toBe(0) + ->and(is_file($prefabPath))->toBeTrue() + ->and($prefabContents)->toContain("'type' => Label::class") + ->and($prefabContents)->toContain("'tag' => 'UI'") + ->and($prefabContents)->toContain("'text' => 'Score Label'"); }); test('working directory assets path prefers Assets', function () { @@ -274,9 +273,9 @@ function runGeneratorCommandInWorkspace(object $command, string $workspace, arra ['name' => 'player'], ); - expect($exitCode)->toBe(0); - expect(is_file($workspace . '/assets/Scripts/Player.php'))->toBeTrue(); - expect(is_dir($workspace . '/Assets'))->toBeFalse(); + expect($exitCode)->toBe(0) + ->and(is_file($workspace . '/assets/Scripts/Player.php'))->toBeTrue() + ->and(is_dir($workspace . '/Assets'))->toBeFalse(); }); test('working directory assets path falls back to legacy lowercase assets when needed', function () { diff --git a/tests/Unit/ConsolePanelTest.php b/tests/Unit/ConsolePanelTest.php index 335b381..7f2c00a 100644 --- a/tests/Unit/ConsolePanelTest.php +++ b/tests/Unit/ConsolePanelTest.php @@ -1,16 +1,26 @@ setAccessible(true); - $previousKeyPress->setAccessible(true); - $previousKeyPress->setValue(''); - $currentKeyPress->setValue($keyPress); + $currentKeyPress = new ReflectionProperty(InputManager::class, 'keyPress'); + $previousKeyPress = new ReflectionProperty(InputManager::class, 'previousKeyPress'); + $previousKeyPress->setValue(null, ''); + $currentKeyPress->setValue(null, $keyPress); +} + +function getConsoleContentAreaPosition(ConsolePanel $panel): array +{ + $getContentAreaLeft = new ReflectionMethod($panel, 'getContentAreaLeft'); + $getContentAreaTop = new ReflectionMethod($panel, 'getContentAreaTop'); + + return [ + 'x' => $getContentAreaLeft->invoke($panel), + 'y' => $getContentAreaTop->invoke($panel), + ]; } test('console panel loads the last three debug log lines on startup', function () { @@ -33,12 +43,11 @@ function pressConsoleKey(string $keyPress): void logFilePath: $workspace . '/logs/debug.log', ); - expect($panel->getActiveTab())->toBe('Debug'); - expect(array_slice($panel->content, 2))->toBe([ - '[2026-03-11 10:00:01] [INFO] - Second', - '[2026-03-11 10:00:02] [WARN] - Third', - '[2026-03-11 10:00:03] [ERROR] - Fourth', - ]); + expect($panel->getActiveTab())->toBe('Debug') + ->and($panel->getActiveFilter())->toBe('DEBUG') + ->and(array_slice($panel->content, 2))->toBe([ + '[2026-03-11 10:00:00] [DEBUG] - First', + ]); }); test('console panel switches between debug and error tabs', function () { @@ -69,27 +78,52 @@ function pressConsoleKey(string $keyPress): void errorLogFilePath: $workspace . '/logs/error.log', ); - expect($panel->content[0])->toContain('Debug'); - expect($panel->content[0])->toContain('Error'); - expect(array_slice($panel->content, 2))->toBe([ - 'debug 1', - 'debug 2', - 'debug 3', - ]); + expect($panel->content[0])->toContain('Debug') + ->and($panel->content[0])->toContain('Error') + ->and(array_slice($panel->content, 2))->toBe([ + 'debug 1', + 'debug 2', + 'debug 3', + ]); $panel->cycleFocusForward(); - expect($panel->getActiveTab())->toBe('Error'); - expect(array_slice($panel->content, 2))->toBe([ - 'error 1', - 'error 2', - ]); + expect($panel->getActiveTab())->toBe('Error') + ->and(array_slice($panel->content, 2))->toBe([ + 'error 1', + 'error 2', + ]); $panel->cycleFocusBackward(); expect($panel->getActiveTab())->toBe('Debug'); }); +test('console panel switches tabs when the tab label is clicked', function () { + $workspace = sys_get_temp_dir() . '/sendama-console-panel-' . uniqid(); + mkdir($workspace . '/logs', 0777, true); + + file_put_contents($workspace . '/logs/debug.log', "debug 1\n"); + file_put_contents($workspace . '/logs/error.log', "error 1\n"); + + $panel = new ConsolePanel( + width: 60, + height: 8, + logFilePath: $workspace . '/logs/debug.log', + errorLogFilePath: $workspace . '/logs/error.log', + ); + + $contentArea = getConsoleContentAreaPosition($panel); + $errorTabOffset = mb_strpos($panel->content[0], 'Error'); + + expect($errorTabOffset)->not->toBeFalse(); + + $panel->handleMouseClick($contentArea['x'] + $errorTabOffset, $contentArea['y']); + + expect($panel->getActiveTab())->toBe('Error') + ->and(array_slice($panel->content, 2))->toBe(['error 1']); +}); + test('console panel ignores missing tab log files', function () { $workspace = sys_get_temp_dir() . '/sendama-console-panel-' . uniqid(); mkdir($workspace . '/logs', 0777, true); @@ -113,11 +147,11 @@ function pressConsoleKey(string $keyPress): void $panel->cycleFocusForward(); - expect($panel->getActiveTab())->toBe('Error'); - expect(array_slice($panel->content, 2))->toBe([ - '[2026-03-11 10:00:01] [ERROR] - First', - '[2026-03-11 10:00:02] [ERROR] - Second', - ]); + expect($panel->getActiveTab())->toBe('Error') + ->and(array_slice($panel->content, 2))->toBe([ + '[2026-03-11 10:00:01] [ERROR] - First', + '[2026-03-11 10:00:02] [ERROR] - Second', + ]); }); test('console panel scrolls upward through older log lines', function () { @@ -198,6 +232,58 @@ function pressConsoleKey(string $keyPress): void ]); }); +test('console panel wraps long log lines across visible rows', function () { + $workspace = sys_get_temp_dir() . '/sendama-console-panel-' . uniqid(); + mkdir($workspace . '/logs', 0777, true); + + $message = '[2026-03-15 10:00:00] [DEBUG] - This is a very long log line that should wrap cleanly.'; + + file_put_contents( + $workspace . '/logs/debug.log', + $message . PHP_EOL + ); + + $panel = new ConsolePanel( + width: 36, + height: 8, + logFilePath: $workspace . '/logs/debug.log', + ); + + $visibleMessages = array_slice($panel->content, 2); + + expect(count($visibleMessages))->toBeGreaterThan(1) + ->and(implode('', $visibleMessages))->toBe($message) + ->and(array_all( + $visibleMessages, + static fn(string $line): bool => mb_strwidth($line, 'UTF-8') <= 32, + ))->toBeTrue(); +}); + +test('console panel renders a scrollbar when there are more wrapped lines than the viewport can show', function () { + $workspace = sys_get_temp_dir() . '/sendama-console-panel-' . uniqid(); + mkdir($workspace . '/logs', 0777, true); + + file_put_contents( + $workspace . '/logs/debug.log', + implode(PHP_EOL, array_map( + static fn (int $index): string => '[2026-03-15 10:00:0' . $index . '] [DEBUG] - line ' . $index, + range(1, 8), + )) . PHP_EOL + ); + + $panel = new ConsolePanel( + width: 36, + height: 8, + logFilePath: $workspace . '/logs/debug.log', + ); + + $buildRenderedContentLines = new ReflectionMethod(Widget::class, 'buildRenderedContentLines'); + $buildRenderedContentLines->setAccessible(true); + $lines = $buildRenderedContentLines->invoke($panel); + + expect(array_any($lines, static fn (string $line): bool => str_contains($line, '█') || str_contains($line, '░')))->toBeTrue(); +}); + test('console panel refreshes the active tab from disk on shift+r when focused', function () { $workspace = sys_get_temp_dir() . '/sendama-console-panel-' . uniqid(); mkdir($workspace . '/logs', 0777, true); @@ -233,26 +319,23 @@ function pressConsoleKey(string $keyPress): void ]) . PHP_EOL ); - $hasFocus = new ReflectionProperty(\Sendama\Console\Editor\Widgets\Widget::class, 'hasFocus'); - $hasFocus->setAccessible(true); + $hasFocus = new ReflectionProperty(Widget::class, 'hasFocus'); $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); - $previousKeyPress->setValue(''); - $keyPress->setValue('R'); + $keyPress = new ReflectionProperty(InputManager::class, 'keyPress'); + $previousKeyPress = new ReflectionProperty(InputManager::class, 'previousKeyPress'); + $previousKeyPress->setValue(null, ''); + $keyPress->setValue(null, 'R'); $panel->update(); - expect($panel->getActiveTab())->toBe('Error'); - expect(array_slice($panel->content, 2))->toBe([ - 'line 3', - 'line 4', - 'line 5', - 'line 6', - ]); + expect($panel->getActiveTab())->toBe('Error') + ->and(array_slice($panel->content, 2))->toBe([ + 'line 3', + 'line 4', + 'line 5', + 'line 6', + ]); }); test('console panel does not auto refresh outside play mode', function () { @@ -289,7 +372,6 @@ function pressConsoleKey(string $keyPress): void ); $lastLogRefreshAt = new ReflectionProperty(ConsolePanel::class, 'lastLogRefreshAt'); - $lastLogRefreshAt->setAccessible(true); $lastLogRefreshAt->setValue($panel, microtime(true) - 2); $panel->update(); @@ -337,7 +419,6 @@ function pressConsoleKey(string $keyPress): void ); $lastLogRefreshAt = new ReflectionProperty(ConsolePanel::class, 'lastLogRefreshAt'); - $lastLogRefreshAt->setAccessible(true); $lastLogRefreshAt->setValue($panel, microtime(true) - 2); $panel->update(); @@ -371,7 +452,6 @@ function pressConsoleKey(string $keyPress): void ); $hasFocus = new ReflectionProperty(Widget::class, 'hasFocus'); - $hasFocus->setAccessible(true); $hasFocus->setValue($panel, true); pressConsoleKey('F'); @@ -379,17 +459,15 @@ function pressConsoleKey(string $keyPress): void expect($panel->hasActiveModal())->toBeTrue(); - pressConsoleKey("\033[B"); - $panel->update(); pressConsoleKey("\033[B"); $panel->update(); pressConsoleKey("\n"); $panel->update(); - expect($panel->hasActiveModal())->toBeFalse(); - expect(array_slice($panel->content, 2))->toBe([ - '[2026-03-13 10:00:01] [INFO] - Second', - ]); + expect($panel->hasActiveModal())->toBeFalse() + ->and(array_slice($panel->content, 2))->toBe([ + '[2026-03-13 10:00:01] [INFO] - Second', + ]); }); test('console panel filters error logs with error-tab-specific levels', function () { @@ -412,7 +490,6 @@ function pressConsoleKey(string $keyPress): void ); $hasFocus = new ReflectionProperty(Widget::class, 'hasFocus'); - $hasFocus->setAccessible(true); $hasFocus->setValue($panel, true); $panel->cycleFocusForward(); @@ -428,10 +505,10 @@ function pressConsoleKey(string $keyPress): void pressConsoleKey("\n"); $panel->update(); - expect($panel->getActiveTab())->toBe('Error'); - expect(array_slice($panel->content, 2))->toBe([ - '[2026-03-13 10:00:02] [FATAL] - Third', - ]); + expect($panel->getActiveTab())->toBe('Error') + ->and(array_slice($panel->content, 2))->toBe([ + '[2026-03-13 10:00:02] [FATAL] - Third', + ]); }); test('console panel rotates and clears the active log file on confirmed shift+c', function () { @@ -454,7 +531,6 @@ function pressConsoleKey(string $keyPress): void ); $hasFocus = new ReflectionProperty(Widget::class, 'hasFocus'); - $hasFocus->setAccessible(true); $hasFocus->setValue($panel, true); pressConsoleKey('C'); @@ -467,10 +543,10 @@ function pressConsoleKey(string $keyPress): void pressConsoleKey("\n"); $panel->update(); - expect($panel->hasActiveModal())->toBeFalse(); - expect(file_get_contents($logFilePath))->toBe(''); - expect(file_get_contents($logFilePath . '.1'))->toContain('[2026-03-13 10:00:00] [DEBUG] - First'); - expect(array_slice($panel->content, 2))->toBe([]); + expect($panel->hasActiveModal())->toBeFalse() + ->and(file_get_contents($logFilePath))->toBe('') + ->and(file_get_contents($logFilePath . '.1'))->toContain('[2026-03-13 10:00:00] [DEBUG] - First') + ->and(array_slice($panel->content, 2))->toBe([]); }); test('console panel leaves the active log file unchanged when clear is cancelled', function () { @@ -493,7 +569,6 @@ function pressConsoleKey(string $keyPress): void ); $hasFocus = new ReflectionProperty(Widget::class, 'hasFocus'); - $hasFocus->setAccessible(true); $hasFocus->setValue($panel, true); $panel->cycleFocusForward(); @@ -505,7 +580,7 @@ function pressConsoleKey(string $keyPress): void pressConsoleKey("\n"); $panel->update(); - expect($panel->hasActiveModal())->toBeFalse(); - expect(file_get_contents($logFilePath))->toContain('[2026-03-13 10:00:01] [FATAL] - Second'); - expect(file_exists($logFilePath . '.1'))->toBeFalse(); + expect($panel->hasActiveModal())->toBeFalse() + ->and(file_get_contents($logFilePath))->toContain('[2026-03-13 10:00:01] [FATAL] - Second') + ->and(file_exists($logFilePath . '.1'))->toBeFalse(); }); diff --git a/tests/Unit/EditorAssetSelectionTest.php b/tests/Unit/EditorAssetSelectionTest.php index c76e5d6..ebf7a7c 100644 --- a/tests/Unit/EditorAssetSelectionTest.php +++ b/tests/Unit/EditorAssetSelectionTest.php @@ -1,10 +1,18 @@ and($mainPanel->getActiveTab())->toBe('Scene'); }); +test('editor creates a prefab from the selected hierarchy object and focuses the inspector', function () { + $workspace = createEditorPrefabExportWorkspace(); + [$editor, $reflection, $hierarchyPanel, $assetsPanel, $mainPanel, $inspectorPanel] = createEditorForPrefabExport($workspace); + + $hierarchyPanel->expandSelection(); + $hierarchyPanel->beginPrefabCreationWorkflow(); + + $synchronizeHierarchyPrefabCreations = $reflection->getMethod('synchronizeHierarchyPrefabCreations'); + $synchronizeHierarchyPrefabCreations->setAccessible(true); + $synchronizeHierarchyPrefabCreations->invoke($editor); + + $focusedPanel = $reflection->getProperty('focusedPanel'); + $inspectionTarget = new ReflectionProperty(InspectorPanel::class, 'inspectionTarget'); + $focusedPanel->setAccessible(true); + $inspectionTarget->setAccessible(true); + + $createdPrefabPath = $workspace . '/Assets/Prefabs/enemy-ship.prefab.php'; + + expect(is_file($createdPrefabPath))->toBeTrue() + ->and($assetsPanel->content)->toContain('▼ Prefabs') + ->and($assetsPanel->getSelectedAssetEntry()['name'] ?? null)->toBe('enemy-ship.prefab.php') + ->and($inspectionTarget->getValue($inspectorPanel))->toMatchArray([ + 'context' => 'prefab', + 'name' => 'Enemy Ship', + 'type' => 'GameObject', + ]) + ->and($focusedPanel->getValue($editor))->toBe($inspectorPanel) + ->and($mainPanel->getActiveTab())->toBe('Scene'); +}); + +test('editor duplicates the selected hierarchy object beside the original and selects it', function () { + $workspace = createEditorPrefabExportWorkspace(); + [$editor, $reflection, $hierarchyPanel, $assetsPanel, $mainPanel, $inspectorPanel] = createEditorForPrefabExport($workspace); + + $loadedScene = new SceneDTO( + name: 'level01', + hierarchy: [ + [ + 'type' => 'Sendama\\Engine\\Core\\GameObject', + 'name' => 'Enemy Ship', + 'tag' => 'Enemy', + 'position' => ['x' => 60, 'y' => 12], + 'rotation' => ['x' => 0, 'y' => 0], + 'scale' => ['x' => 1, 'y' => 1], + 'components' => [], + ], + ], + rawData: [ + 'hierarchy' => [ + [ + 'type' => 'Sendama\\Engine\\Core\\GameObject', + 'name' => 'Enemy Ship', + 'tag' => 'Enemy', + 'position' => ['x' => 60, 'y' => 12], + 'rotation' => ['x' => 0, 'y' => 0], + 'scale' => ['x' => 1, 'y' => 1], + 'components' => [], + ], + ], + ], + ); + $reflection->getProperty('loadedScene')->setValue($editor, $loadedScene); + + $hierarchyPanel->expandSelection(); + $hierarchyPanel->beginDuplicationWorkflow(); + + $synchronizeHierarchyDuplications = $reflection->getMethod('synchronizeHierarchyDuplications'); + $synchronizeHierarchyDuplications->setAccessible(true); + $synchronizeHierarchyDuplications->invoke($editor); + + $sceneObjects = new ReflectionProperty(MainPanel::class, 'sceneObjects'); + $sceneObjects->setAccessible(true); + + expect($loadedScene->hierarchy)->toHaveCount(2) + ->and($loadedScene->hierarchy[0]['name'] ?? null)->toBe('Enemy Ship') + ->and($loadedScene->hierarchy[1]['name'] ?? null)->toBe('Enemy Ship 1') + ->and($hierarchyPanel->getSelectedHierarchyObject()['name'] ?? null)->toBe('Enemy Ship 1') + ->and($sceneObjects->getValue($mainPanel)[1]['name'] ?? null)->toBe('Enemy Ship 1') + ->and($assetsPanel)->toBeInstanceOf(AssetsPanel::class) + ->and($inspectorPanel)->toBeInstanceOf(InspectorPanel::class); +}); + +test('editor duplicates all selected hierarchy objects when hierarchy multiselect is active', function () { + $workspace = createEditorPrefabExportWorkspace(); + [$editor, $reflection, $hierarchyPanel, $assetsPanel, $mainPanel, $inspectorPanel] = createEditorForPrefabExport($workspace); + + $loadedScene = new SceneDTO( + name: 'level01', + hierarchy: [ + [ + 'type' => 'Sendama\\Engine\\Core\\GameObject', + 'name' => 'Player', + 'components' => [], + ], + [ + 'type' => 'Sendama\\Engine\\Core\\GameObject', + 'name' => 'Enemy', + 'components' => [], + ], + ], + rawData: [ + 'hierarchy' => [ + ['type' => 'Sendama\\Engine\\Core\\GameObject', 'name' => 'Player', 'components' => []], + ['type' => 'Sendama\\Engine\\Core\\GameObject', 'name' => 'Enemy', 'components' => []], + ], + ], + ); + $reflection->getProperty('loadedScene')->setValue($editor, $loadedScene); + $hierarchyPanel->syncHierarchy($loadedScene->hierarchy); + $hierarchyPanel->selectPaths(['scene.0', 'scene.1'], 'scene.1'); + $hierarchyPanel->beginDuplicationWorkflow(); + + $synchronizeHierarchyDuplications = $reflection->getMethod('synchronizeHierarchyDuplications'); + $synchronizeHierarchyDuplications->setAccessible(true); + $synchronizeHierarchyDuplications->invoke($editor); + + $sceneObjects = new ReflectionProperty(MainPanel::class, 'sceneObjects'); + $sceneObjects->setAccessible(true); + + expect(array_column($loadedScene->hierarchy, 'name'))->toBe(['Player', 'Player 1', 'Enemy', 'Enemy 1']) + ->and($hierarchyPanel->getSelectedHierarchyObject()['name'] ?? null)->toBe('Enemy 1') + ->and(array_column($sceneObjects->getValue($mainPanel), 'name'))->toBe(['Player', 'Player 1', 'Enemy', 'Enemy 1']) + ->and($assetsPanel)->toBeInstanceOf(AssetsPanel::class) + ->and($inspectorPanel)->toBeInstanceOf(InspectorPanel::class); +}); + +test('editor adds a new hierarchy game object as a child of the selected parent', function () { + $workspace = createEditorPrefabExportWorkspace(); + [$editor, $reflection, $hierarchyPanel, $assetsPanel, $mainPanel, $inspectorPanel] = createEditorForPrefabExport($workspace); + + $loadedScene = new SceneDTO( + name: 'level01', + hierarchy: [ + [ + 'type' => 'Sendama\\Engine\\Core\\GameObject', + 'name' => 'Player', + 'children' => [], + 'components' => [], + ], + ], + rawData: [ + 'hierarchy' => [ + [ + 'type' => 'Sendama\\Engine\\Core\\GameObject', + 'name' => 'Player', + 'children' => [], + 'components' => [], + ], + ], + ], + ); + $reflection->getProperty('loadedScene')->setValue($editor, $loadedScene); + $hierarchyPanel->syncHierarchy($loadedScene->hierarchy); + + $pendingCreationItem = new ReflectionProperty(HierarchyPanel::class, 'pendingCreationItem'); + $pendingCreationItem->setAccessible(true); + $pendingCreationItem->setValue($hierarchyPanel, [ + 'value' => [ + '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' => [], + ], + 'parentPath' => 'scene.0', + ]); + + $synchronizeHierarchyAdditions = $reflection->getMethod('synchronizeHierarchyAdditions'); + $synchronizeHierarchyAdditions->setAccessible(true); + $synchronizeHierarchyAdditions->invoke($editor); + + $inspectionTarget = new ReflectionProperty(InspectorPanel::class, 'inspectionTarget'); + $inspectionTarget->setAccessible(true); + + expect($loadedScene->hierarchy[0]['children'][0]['name'] ?? null)->toBe('GameObject #2') + ->and($hierarchyPanel->content)->toContain(' ▼ Player') + ->and($hierarchyPanel->content)->toContain(' • GameObject #2') + ->and($hierarchyPanel->getSelectedHierarchyObject()['name'] ?? null)->toBe('GameObject #2') + ->and($mainPanel)->toBeInstanceOf(MainPanel::class) + ->and($assetsPanel)->toBeInstanceOf(AssetsPanel::class) + ->and($inspectorPanel)->toBeInstanceOf(InspectorPanel::class) + ->and($inspectionTarget->getValue($inspectorPanel))->not->toBeNull(); +}); + +test('editor moves a hierarchy object into another tree during hierarchy move mode', function () { + $workspace = createEditorPrefabExportWorkspace(); + [$editor, $reflection, $hierarchyPanel, $assetsPanel, $mainPanel, $inspectorPanel] = createEditorForPrefabExport($workspace); + + $loadedScene = new SceneDTO( + name: 'level01', + hierarchy: [ + [ + 'type' => 'Sendama\\Engine\\Core\\GameObject', + 'name' => 'Player', + 'children' => [ + ['type' => 'Sendama\\Engine\\Core\\GameObject', 'name' => 'Gun', 'components' => []], + ], + 'components' => [], + ], + [ + 'type' => 'Sendama\\Engine\\Core\\GameObject', + 'name' => 'Enemy', + 'components' => [], + ], + ], + rawData: [ + 'hierarchy' => [ + [ + 'type' => 'Sendama\\Engine\\Core\\GameObject', + 'name' => 'Player', + 'children' => [ + ['type' => 'Sendama\\Engine\\Core\\GameObject', 'name' => 'Gun', 'components' => []], + ], + 'components' => [], + ], + [ + 'type' => 'Sendama\\Engine\\Core\\GameObject', + 'name' => 'Enemy', + 'components' => [], + ], + ], + ], + ); + $reflection->getProperty('loadedScene')->setValue($editor, $loadedScene); + $hierarchyPanel->syncHierarchy($loadedScene->hierarchy); + + $pendingMoveItem = new ReflectionProperty(HierarchyPanel::class, 'pendingMoveItem'); + $pendingMoveItem->setAccessible(true); + $pendingMoveItem->setValue($hierarchyPanel, [ + 'path' => 'scene.1', + 'targetPath' => 'scene.0.0', + 'position' => 'before', + ]); + + $synchronizeHierarchyMoves = $reflection->getMethod('synchronizeHierarchyMoves'); + $synchronizeHierarchyMoves->setAccessible(true); + $synchronizeHierarchyMoves->invoke($editor); + + $inspectionTarget = new ReflectionProperty(InspectorPanel::class, 'inspectionTarget'); + $inspectionTarget->setAccessible(true); + $sceneObjects = new ReflectionProperty(MainPanel::class, 'sceneObjects'); + $sceneObjects->setAccessible(true); + + expect($loadedScene->hierarchy[0]['children'][0]['name'] ?? null)->toBe('Enemy') + ->and($loadedScene->hierarchy[1] ?? null)->toBeNull() + ->and($hierarchyPanel->getSelectedHierarchyObject()['name'] ?? null)->toBe('Enemy') + ->and(($sceneObjects->getValue($mainPanel)[0]['children'][0]['name'] ?? null))->toBe('Enemy') + ->and($inspectionTarget->getValue($inspectorPanel))->toMatchArray([ + 'context' => 'hierarchy', + 'name' => 'Enemy', + 'path' => 'scene.0.0', + ]) + ->and($assetsPanel)->toBeInstanceOf(AssetsPanel::class); +}); + +test('editor reparents a hierarchy object as a child when append child move requests are synchronized', function () { + $workspace = createEditorPrefabExportWorkspace(); + [$editor, $reflection, $hierarchyPanel, $assetsPanel, $mainPanel, $inspectorPanel] = createEditorForPrefabExport($workspace); + + $loadedScene = new SceneDTO( + name: 'level01', + hierarchy: [ + [ + 'type' => 'Sendama\\Engine\\Core\\GameObject', + 'name' => 'Player', + 'components' => [], + ], + [ + 'type' => 'Sendama\\Engine\\Core\\GameObject', + 'name' => 'Enemy', + 'children' => [], + 'components' => [], + ], + ], + rawData: [ + 'hierarchy' => [ + ['type' => 'Sendama\\Engine\\Core\\GameObject', 'name' => 'Player', 'components' => []], + ['type' => 'Sendama\\Engine\\Core\\GameObject', 'name' => 'Enemy', 'children' => [], 'components' => []], + ], + ], + ); + $reflection->getProperty('loadedScene')->setValue($editor, $loadedScene); + $hierarchyPanel->syncHierarchy($loadedScene->hierarchy); + + $pendingMoveItem = new ReflectionProperty(HierarchyPanel::class, 'pendingMoveItem'); + $pendingMoveItem->setAccessible(true); + $pendingMoveItem->setValue($hierarchyPanel, [ + 'path' => 'scene.0', + 'targetPath' => 'scene.1', + 'position' => 'append_child', + ]); + + $synchronizeHierarchyMoves = $reflection->getMethod('synchronizeHierarchyMoves'); + $synchronizeHierarchyMoves->setAccessible(true); + $synchronizeHierarchyMoves->invoke($editor); + + $inspectionTarget = new ReflectionProperty(InspectorPanel::class, 'inspectionTarget'); + $inspectionTarget->setAccessible(true); + $sceneObjects = new ReflectionProperty(MainPanel::class, 'sceneObjects'); + $sceneObjects->setAccessible(true); + + expect($loadedScene->hierarchy)->toHaveCount(1) + ->and($loadedScene->hierarchy[0]['name'] ?? null)->toBe('Enemy') + ->and($loadedScene->hierarchy[0]['children'][0]['name'] ?? null)->toBe('Player') + ->and($hierarchyPanel->getSelectedHierarchyObject()['name'] ?? null)->toBe('Player') + ->and(($sceneObjects->getValue($mainPanel)[0]['children'][0]['name'] ?? null))->toBe('Player') + ->and($inspectionTarget->getValue($inspectorPanel))->toMatchArray([ + 'context' => 'hierarchy', + 'name' => 'Player', + 'path' => 'scene.0.0', + ]) + ->and($assetsPanel)->toBeInstanceOf(AssetsPanel::class); +}); + +test('editor moves a hierarchy child back to the scene root when a root append move request is synchronized', function () { + $workspace = createEditorPrefabExportWorkspace(); + [$editor, $reflection, $hierarchyPanel, $assetsPanel, $mainPanel, $inspectorPanel] = createEditorForPrefabExport($workspace); + + $loadedScene = new SceneDTO( + name: 'level01', + hierarchy: [ + [ + 'type' => 'Sendama\\Engine\\Core\\GameObject', + 'name' => 'Player', + 'children' => [ + ['type' => 'Sendama\\Engine\\Core\\GameObject', 'name' => 'Gun', 'components' => []], + ], + 'components' => [], + ], + ], + rawData: [ + 'hierarchy' => [ + [ + 'type' => 'Sendama\\Engine\\Core\\GameObject', + 'name' => 'Player', + 'children' => [ + ['type' => 'Sendama\\Engine\\Core\\GameObject', 'name' => 'Gun', 'components' => []], + ], + 'components' => [], + ], + ], + ], + ); + $reflection->getProperty('loadedScene')->setValue($editor, $loadedScene); + $hierarchyPanel->syncHierarchy($loadedScene->hierarchy); + + $pendingMoveItem = new ReflectionProperty(HierarchyPanel::class, 'pendingMoveItem'); + $pendingMoveItem->setAccessible(true); + $pendingMoveItem->setValue($hierarchyPanel, [ + 'path' => 'scene.0.0', + 'targetPath' => 'scene', + 'position' => 'append_child', + ]); + + $synchronizeHierarchyMoves = $reflection->getMethod('synchronizeHierarchyMoves'); + $synchronizeHierarchyMoves->setAccessible(true); + $synchronizeHierarchyMoves->invoke($editor); + + $inspectionTarget = new ReflectionProperty(InspectorPanel::class, 'inspectionTarget'); + $inspectionTarget->setAccessible(true); + $sceneObjects = new ReflectionProperty(MainPanel::class, 'sceneObjects'); + $sceneObjects->setAccessible(true); + + expect($loadedScene->hierarchy)->toHaveCount(2) + ->and($loadedScene->hierarchy[0]['name'] ?? null)->toBe('Player') + ->and($loadedScene->hierarchy[0]['children'] ?? [])->toBe([]) + ->and($loadedScene->hierarchy[1]['name'] ?? null)->toBe('Gun') + ->and($hierarchyPanel->getSelectedHierarchyObject()['name'] ?? null)->toBe('Gun') + ->and(($sceneObjects->getValue($mainPanel)[1]['name'] ?? null))->toBe('Gun') + ->and($inspectionTarget->getValue($inspectorPanel))->toMatchArray([ + 'context' => 'hierarchy', + 'name' => 'Gun', + 'path' => 'scene.1', + ]) + ->and($assetsPanel)->toBeInstanceOf(AssetsPanel::class); +}); + +test('editor duplicates all selected scene objects when scene multiselect is active', function () { + $workspace = createEditorPrefabExportWorkspace(); + [$editor, $reflection, $hierarchyPanel, $assetsPanel, $mainPanel, $inspectorPanel] = createEditorForPrefabExport($workspace); + + $loadedScene = new SceneDTO( + name: 'level01', + hierarchy: [ + [ + 'type' => 'Sendama\\Engine\\Core\\GameObject', + 'name' => 'Player', + 'position' => ['x' => 2, 'y' => 1], + 'components' => [], + ], + [ + 'type' => 'Sendama\\Engine\\Core\\GameObject', + 'name' => 'Enemy', + 'position' => ['x' => 8, 'y' => 1], + 'components' => [], + ], + ], + rawData: [ + 'hierarchy' => [ + ['type' => 'Sendama\\Engine\\Core\\GameObject', 'name' => 'Player', 'position' => ['x' => 2, 'y' => 1], 'components' => []], + ['type' => 'Sendama\\Engine\\Core\\GameObject', 'name' => 'Enemy', 'position' => ['x' => 8, 'y' => 1], 'components' => []], + ], + ], + ); + $reflection->getProperty('loadedScene')->setValue($editor, $loadedScene); + $hierarchyPanel->syncHierarchy($loadedScene->hierarchy); + $mainPanel->setSceneObjects($loadedScene->hierarchy); + $mainPanel->selectSceneObjects(['scene.0', 'scene.1'], 'scene.1'); + + $beginSceneDuplicationWorkflow = new ReflectionMethod(MainPanel::class, 'beginSceneDuplicationWorkflow'); + $beginSceneDuplicationWorkflow->setAccessible(true); + $beginSceneDuplicationWorkflow->invoke($mainPanel); + + $synchronizeHierarchyDuplications = $reflection->getMethod('synchronizeHierarchyDuplications'); + $synchronizeHierarchyDuplications->setAccessible(true); + $synchronizeHierarchyDuplications->invoke($editor); + + $sceneObjects = new ReflectionProperty(MainPanel::class, 'sceneObjects'); + $sceneObjects->setAccessible(true); + + expect(array_column($loadedScene->hierarchy, 'name'))->toBe(['Player', 'Player 1', 'Enemy', 'Enemy 1']) + ->and($hierarchyPanel->getSelectedHierarchyObject()['name'] ?? null)->toBe('Enemy 1') + ->and(array_column($sceneObjects->getValue($mainPanel), 'name'))->toBe(['Player', 'Player 1', 'Enemy', 'Enemy 1']) + ->and($assetsPanel)->toBeInstanceOf(AssetsPanel::class) + ->and($inspectorPanel)->toBeInstanceOf(InspectorPanel::class); +}); + +test('editor selects panels when they are left-clicked', function () { + $workspace = createEditorAssetSelectionWorkspace(); + [$editor, $reflection, $assetsPanel, $mainPanel, $inspectorPanel] = createEditorForAssetSelection($workspace); + + $mouseEvent = new ReflectionProperty(InputManager::class, 'mouseEvent'); + $mouseEvent->setAccessible(true); + $mouseEvent->setValue(new MouseEvent("\033[<0;45;4M")); + + $handlePanelFocus = $reflection->getMethod('handlePanelFocus'); + $handlePanelFocus->setAccessible(true); + $handlePanelFocus->invoke($editor); + + $focusedPanel = $reflection->getProperty('focusedPanel'); + $focusedPanel->setAccessible(true); + + expect($focusedPanel->getValue($editor))->toBe($mainPanel) + ->and($mainPanel->hasFocus())->toBeTrue() + ->and($assetsPanel->hasFocus())->toBeFalse() + ->and($inspectorPanel->hasFocus())->toBeFalse(); +}); + +test('editor focuses the inspector after creating a script asset from the assets panel', function () { + $workspace = createEditorAssetCreationWorkspace(); + [$editor, $reflection, $assetsPanel, $mainPanel, $inspectorPanel] = createEditorForAssetSelection($workspace); + + $pendingCreationRequest = new ReflectionProperty(AssetsPanel::class, 'pendingCreationRequest'); + $pendingCreationRequest->setAccessible(true); + $pendingCreationRequest->setValue($assetsPanel, [ + 'kind' => 'script', + 'workingDirectory' => $workspace, + ]); + + $synchronizeAssetCreations = $reflection->getMethod('synchronizeAssetCreations'); + $synchronizeAssetCreations->setAccessible(true); + $synchronizeAssetCreations->invoke($editor); + + $focusedPanel = $reflection->getProperty('focusedPanel'); + $focusedPanel->setAccessible(true); + $inspectionTarget = new ReflectionProperty(InspectorPanel::class, 'inspectionTarget'); + $inspectionTarget->setAccessible(true); + + expect(is_file($workspace . '/Assets/Scripts/NewScript1.php'))->toBeTrue() + ->and($assetsPanel->content)->toContain('▼ Scripts') + ->and($assetsPanel->content)->toContain(' • NewScript1.php') + ->and($inspectionTarget->getValue($inspectorPanel)['name'] ?? null)->toBe('NewScript1.php') + ->and($focusedPanel->getValue($editor))->toBe($inspectorPanel) + ->and($mainPanel->getActiveTab())->toBe('Scene'); +}); + +test('editor resolves generated script assets when project paths are relative to the launch directory', function () { + $parentDirectory = sys_get_temp_dir() . '/sendama-editor-relative-asset-creation-' . uniqid(); + $workspace = $parentDirectory . '/repro-game'; + mkdir($workspace . '/Assets/Scripts', 0777, true); + mkdir($workspace . '/Assets/Textures', 0777, true); + mkdir($workspace . '/logs', 0777, true); + file_put_contents($workspace . '/Assets/Textures/player.texture', ">\n"); + file_put_contents($workspace . '/logs/debug.log', ''); + file_put_contents($workspace . '/logs/error.log', ''); + file_put_contents($workspace . '/sendama.json', json_encode([ + 'name' => 'Relative Asset Creation Test', + 'editor' => [ + 'scenes' => [ + 'active' => 0, + 'loaded' => [], + ], + 'console' => [ + 'refreshInterval' => 5, + ], + ], + ], JSON_PRETTY_PRINT)); + file_put_contents($workspace . '/composer.json', json_encode([ + 'name' => 'tmp/relative-asset-creation-test', + 'require' => [ + 'sendamaphp/engine' => '*', + ], + 'autoload' => [ + 'psr-4' => [ + 'Tmp\\RelativeAssetCreation\\' => 'Assets/', + ], + ], + ], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); + + $editorReflection = new ReflectionClass(Editor::class); + $editor = $editorReflection->newInstanceWithoutConstructor(); + $editorReflection->getProperty('workingDirectory')->setValue($editor, 'repro-game'); + $editorReflection->getProperty('assetsDirectoryPath')->setValue($editor, 'repro-game/Assets'); + $editorReflection->getProperty('consolePanel')->setValue( + $editor, + new ConsolePanel( + logFilePath: $workspace . '/logs/debug.log', + errorLogFilePath: $workspace . '/logs/error.log', + ) + ); + $editorReflection->getProperty('snackbar')->setValue($editor, new Snackbar()); + + $createAssetUsingCliCommand = $editorReflection->getMethod('createAssetUsingCliCommand'); + $createAssetUsingCliCommand->setAccessible(true); + + $originalWorkingDirectory = getcwd(); + + try { + chdir($parentDirectory); + $createdAsset = $createAssetUsingCliCommand->invoke($editor, 'script'); + } finally { + if ($originalWorkingDirectory !== false) { + chdir($originalWorkingDirectory); + } + } + + expect($createdAsset)->toBeArray() + ->and($createdAsset['path'] ?? null)->toBe($workspace . '/Assets/Scripts/NewScript1.php') + ->and($createdAsset['relativePath'] ?? null)->toBe('Scripts/NewScript1.php') + ->and(is_file($workspace . '/Assets/Scripts/NewScript1.php'))->toBeTrue(); +}); + function createEditorAssetSelectionWorkspace(): string { $workspace = sys_get_temp_dir() . '/sendama-editor-asset-selection-' . uniqid(); @@ -87,6 +639,39 @@ function createEditorAssetSelectionWorkspace(): string return $workspace; } +function createEditorAssetCreationWorkspace(): string +{ + $workspace = sys_get_temp_dir() . '/sendama-editor-asset-creation-' . uniqid(); + mkdir($workspace . '/Assets/Scripts', 0777, true); + mkdir($workspace . '/Assets/Textures', 0777, true); + file_put_contents($workspace . '/Assets/Textures/player.texture', ">\n"); + file_put_contents($workspace . '/sendama.json', json_encode([ + 'name' => 'Asset Creation Test', + 'editor' => [ + 'scenes' => [ + 'active' => 0, + 'loaded' => [], + ], + 'console' => [ + 'refreshInterval' => 5, + ], + ], + ], JSON_PRETTY_PRINT)); + file_put_contents($workspace . '/composer.json', json_encode([ + 'name' => 'tmp/asset-creation-test', + 'require' => [ + 'sendamaphp/engine' => '*', + ], + 'autoload' => [ + 'psr-4' => [ + 'Tmp\\AssetCreation\\' => 'Assets/', + ], + ], + ], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); + + return $workspace; +} + function createEditorPrefabSelectionWorkspace(): string { $workspace = sys_get_temp_dir() . '/sendama-editor-prefab-selection-' . uniqid(); @@ -184,11 +769,76 @@ class EnemyComponent extends Component return $workspace; } +function createEditorPrefabExportWorkspace(): string +{ + $workspace = sys_get_temp_dir() . '/sendama-editor-prefab-export-' . uniqid(); + mkdir($workspace . '/Assets/Prefabs', 0777, true); + + return $workspace; +} + function createEditorForAssetSelection(string $workspace): array { $editorReflection = new ReflectionClass(Editor::class); $editor = $editorReflection->newInstanceWithoutConstructor(); - $hierarchyPanel = new HierarchyPanel(); + $hierarchyPanel = new HierarchyPanel(width: 20, height: 12); + $hierarchyPanel->setPosition(1, 1); + $assetsPanel = new AssetsPanel( + width: 20, + height: 12, + assetsDirectoryPath: $workspace . '/Assets', + workingDirectory: $workspace, + ); + $assetsPanel->setPosition(1, 13); + $mainPanel = new MainPanel(width: 60, height: 12, workingDirectory: $workspace); + $mainPanel->setPosition(21, 1); + $consolePanel = new ConsolePanel(width: 60, height: 12); + $consolePanel->setPosition(21, 13); + $inspectorPanel = new InspectorPanel(width: 20, height: 12, workingDirectory: $workspace); + $inspectorPanel->setPosition(81, 1); + + $editorReflection->getProperty('workingDirectory')->setValue($editor, $workspace); + $editorReflection->getProperty('assetsDirectoryPath')->setValue($editor, $workspace . '/Assets'); + $editorReflection->getProperty('hierarchyPanel')->setValue($editor, $hierarchyPanel); + $editorReflection->getProperty('assetsPanel')->setValue($editor, $assetsPanel); + $editorReflection->getProperty('mainPanel')->setValue($editor, $mainPanel); + $editorReflection->getProperty('consolePanel')->setValue($editor, $consolePanel); + $editorReflection->getProperty('inspectorPanel')->setValue($editor, $inspectorPanel); + $editorReflection->getProperty('panelListModal')->setValue($editor, new PanelListModal()); + $editorReflection->getProperty('snackbar')->setValue($editor, new Snackbar()); + $editorReflection->getProperty('panels')->setValue($editor, new ItemList(\Sendama\Console\Editor\Widgets\Widget::class, [ + $hierarchyPanel, + $assetsPanel, + $mainPanel, + $consolePanel, + $inspectorPanel, + ])); + $editorReflection->getProperty('focusedPanel')->setValue($editor, $assetsPanel); + $assetsPanel->focus(new \Sendama\Console\Editor\FocusTargetContext($editor, new \Sendama\Console\Editor\GameSettings(name: 'Test Game'))); + + return [$editor, $editorReflection, $assetsPanel, $mainPanel, $inspectorPanel]; +} + +function createEditorForPrefabExport(string $workspace): array +{ + $editorReflection = new ReflectionClass(Editor::class); + $editor = $editorReflection->newInstanceWithoutConstructor(); + $hierarchyPanel = new HierarchyPanel( + width: 40, + height: 12, + sceneName: 'level01', + hierarchy: [ + [ + 'type' => 'Sendama\\Engine\\Core\\GameObject', + 'name' => 'Enemy Ship', + 'tag' => 'Enemy', + 'position' => ['x' => 60, 'y' => 12], + 'rotation' => ['x' => 0, 'y' => 0], + 'scale' => ['x' => 1, 'y' => 1], + 'components' => [], + ], + ], + ); $assetsPanel = new AssetsPanel( width: 40, height: 12, @@ -197,12 +847,18 @@ function createEditorForAssetSelection(string $workspace): array ); $mainPanel = new MainPanel(width: 60, height: 12, workingDirectory: $workspace); $inspectorPanel = new InspectorPanel(width: 40, height: 12, workingDirectory: $workspace); + $consolePanel = new ConsolePanel(width: 60, height: 12); + $editorReflection->getProperty('workingDirectory')->setValue($editor, $workspace); + $editorReflection->getProperty('assetsDirectoryPath')->setValue($editor, $workspace . '/Assets'); $editorReflection->getProperty('hierarchyPanel')->setValue($editor, $hierarchyPanel); $editorReflection->getProperty('assetsPanel')->setValue($editor, $assetsPanel); $editorReflection->getProperty('mainPanel')->setValue($editor, $mainPanel); $editorReflection->getProperty('inspectorPanel')->setValue($editor, $inspectorPanel); - $editorReflection->getProperty('focusedPanel')->setValue($editor, $assetsPanel); + $editorReflection->getProperty('consolePanel')->setValue($editor, $consolePanel); + $editorReflection->getProperty('prefabWriter')->setValue($editor, new PrefabWriter()); + $editorReflection->getProperty('snackbar')->setValue($editor, new Snackbar()); + $editorReflection->getProperty('focusedPanel')->setValue($editor, $hierarchyPanel); - return [$editor, $editorReflection, $assetsPanel, $mainPanel, $inspectorPanel]; + return [$editor, $editorReflection, $hierarchyPanel, $assetsPanel, $mainPanel, $inspectorPanel]; } diff --git a/tests/Unit/EditorFileWatchTest.php b/tests/Unit/EditorFileWatchTest.php new file mode 100644 index 0000000..78e7e14 --- /dev/null +++ b/tests/Unit/EditorFileWatchTest.php @@ -0,0 +1,256 @@ +getMethod('synchronizeWatchedAssetChanges'); + $synchronizeWatchedAssetChanges->setAccessible(true); + + $loadedScene->isDirty = true; + + expect(implode("\n", $inspectorPanel->content)) + ->toContain('Speed: 1') + ->not->toContain('Lives: 3'); + + $synchronizeWatchedAssetChanges->invoke($editor, true); + + file_put_contents( + $workspace . '/Assets/Scripts/WatcherComponent.php', + <<<'PHP' +invoke($editor, true); + + $inspectionTarget = $inspectorPanel->getInspectionTarget(); + $componentData = $loadedScene->hierarchy[0]['components'][0]['data'] ?? []; + + expect($inspectionTarget)->toMatchArray([ + 'context' => 'hierarchy', + 'path' => 'scene.0', + ]); + expect(implode("\n", $inspectorPanel->content)) + ->toContain('Speed: 1') + ->toContain('Lives: 3'); + expect($componentData)->toMatchArray([ + 'speed' => 1, + 'lives' => 3, + ]); + expect($loadedScene->isDirty)->toBeTrue(); +}); + +function createEditorFileWatchWorkspace(): string +{ + $workspace = sys_get_temp_dir() . '/sendama-editor-file-watch-' . uniqid(); + mkdir($workspace . '/Assets/Scenes', 0777, true); + mkdir($workspace . '/Assets/Scripts', 0777, true); + mkdir($workspace . '/vendor', 0777, true); + + file_put_contents( + $workspace . '/vendor/autoload.php', + <<<'PHP' +x; + } + + public function getY(): int + { + return $this->y; + } + } + + class Component + { + public function __construct(private ?object $gameObject = null) + { + } + } + + class GameObject + { + public function __construct( + private string $name, + private ?string $tag = null, + private ?Vector2 $position = null, + private ?Vector2 $rotation = null, + private ?Vector2 $scale = null, + private mixed $sprite = null, + ) { + } + } +} +PHP + ); + + file_put_contents( + $workspace . '/Assets/Scripts/WatcherComponent.php', + <<<'PHP' + 120, + 'height' => 40, + 'hierarchy' => [ + [ + 'type' => 'Sendama\\Engine\\Core\\GameObject', + 'name' => 'Player', + 'tag' => 'Player', + 'position' => ['x' => 4, 'y' => 20], + 'rotation' => ['x' => 0, 'y' => 0], + 'scale' => ['x' => 1, 'y' => 1], + 'components' => [ + ['class' => 'Sendama\\Game\\Scripts\\WatcherComponent'], + ], + ], + ], +]; +PHP + ); + + return $workspace; +} + +function createEditorForFileWatch(string $workspace): array +{ + $loadedScene = (new SceneLoader($workspace))->load(new EditorSceneSettings(active: 0, loaded: ['level01'])); + + expect($loadedScene)->toBeInstanceOf(SceneDTO::class); + + $editorReflection = new ReflectionClass(Editor::class); + $editor = $editorReflection->newInstanceWithoutConstructor(); + $hierarchyPanel = new HierarchyPanel( + width: 40, + height: 12, + sceneName: $loadedScene->name, + hierarchy: $loadedScene->hierarchy, + sceneWidth: $loadedScene->width, + sceneHeight: $loadedScene->height, + environmentTileMapPath: $loadedScene->environmentTileMapPath, + environmentCollisionMapPath: $loadedScene->environmentCollisionMapPath, + ); + $assetsPanel = new AssetsPanel( + width: 40, + height: 12, + assetsDirectoryPath: $workspace . '/Assets', + workingDirectory: $workspace, + ); + $mainPanel = new MainPanel( + width: 60, + height: 12, + sceneObjects: $loadedScene->hierarchy, + workingDirectory: $workspace, + sceneWidth: $loadedScene->width, + sceneHeight: $loadedScene->height, + environmentTileMapPath: $loadedScene->environmentTileMapPath, + ); + $consolePanel = new ConsolePanel(width: 60, height: 12); + $inspectorPanel = new InspectorPanel(width: 40, height: 12, workingDirectory: $workspace); + + $editorReflection->getProperty('workingDirectory')->setValue($editor, $workspace); + $editorReflection->getProperty('assetsDirectoryPath')->setValue($editor, $workspace . '/Assets'); + $editorReflection->getProperty('loadedScene')->setValue($editor, $loadedScene); + $editorReflection->getProperty('sceneWriter')->setValue($editor, new SceneWriter()); + $editorReflection->getProperty('hierarchyPanel')->setValue($editor, $hierarchyPanel); + $editorReflection->getProperty('assetsPanel')->setValue($editor, $assetsPanel); + $editorReflection->getProperty('mainPanel')->setValue($editor, $mainPanel); + $editorReflection->getProperty('consolePanel')->setValue($editor, $consolePanel); + $editorReflection->getProperty('inspectorPanel')->setValue($editor, $inspectorPanel); + $editorReflection->getProperty('panelListModal')->setValue($editor, new PanelListModal()); + $editorReflection->getProperty('snackbar')->setValue($editor, new Snackbar()); + $editorReflection->getProperty('panels')->setValue($editor, new ItemList(Widget::class, [ + $hierarchyPanel, + $assetsPanel, + $mainPanel, + $consolePanel, + $inspectorPanel, + ])); + + $hierarchyPanel->selectPath('scene.0'); + $mainPanel->selectSceneObject('scene.0'); + $inspectorPanel->setSceneHierarchy($loadedScene->hierarchy); + $inspectorPanel->inspectTarget([ + 'context' => 'hierarchy', + 'name' => $loadedScene->hierarchy[0]['name'] ?? 'Player', + 'type' => 'GameObject', + 'path' => 'scene.0', + 'value' => $loadedScene->hierarchy[0], + ]); + + return [$editor, $editorReflection, $inspectorPanel, $loadedScene]; +} diff --git a/tests/Unit/EditorSettingsTest.php b/tests/Unit/EditorSettingsTest.php index 702adfb..01232cd 100644 --- a/tests/Unit/EditorSettingsTest.php +++ b/tests/Unit/EditorSettingsTest.php @@ -15,6 +15,14 @@ 'console' => [ 'refreshInterval' => 2.5, ], + 'notifications' => [ + 'duration' => 6.0, + ], + 'externalEditor' => [ + 'command' => 'code --wait {path}', + 'mode' => 'gui', + 'blocking' => true, + ], ], ]); @@ -24,6 +32,10 @@ 'Scenes/beta.scene.php', ]); expect($settings->consoleRefreshIntervalSeconds)->toBe(2.5); + expect($settings->notificationDurationSeconds)->toBe(6.0); + expect($settings->externalEditorCommand)->toBe('code --wait {path}'); + expect($settings->externalEditorMode)->toBe('gui'); + expect($settings->externalEditorBlocking)->toBeTrue(); }); test('editor settings default the console refresh interval to five seconds', function () { @@ -37,6 +49,7 @@ ]); expect($settings->consoleRefreshIntervalSeconds)->toBe(5.0); + expect($settings->notificationDurationSeconds)->toBe(4.0); }); test('editor settings load editor config from sendama json', function () { @@ -55,6 +68,9 @@ 'console' => [ 'refreshInterval' => 3, ], + 'notifications' => [ + 'duration' => 4.5, + ], ], ], JSON_PRETTY_PRINT) ); @@ -63,6 +79,7 @@ expect($settings->scenes->loaded)->toBe(['Scenes/level01.scene.php']); expect($settings->consoleRefreshIntervalSeconds)->toBe(3.0); + expect($settings->notificationDurationSeconds)->toBe(4.5); }); test('editor settings fall back to defaults when sendama json is missing', function () { @@ -74,6 +91,7 @@ expect($settings->scenes->loaded)->toBe([]); expect($settings->scenes->active)->toBe(0); expect($settings->consoleRefreshIntervalSeconds)->toBe(5.0); + expect($settings->notificationDurationSeconds)->toBe(4.0); }); test('editor settings fall back to defaults when sendama json is invalid', function () { @@ -85,4 +103,29 @@ expect($settings->scenes->loaded)->toBe([]); expect($settings->consoleRefreshIntervalSeconds)->toBe(5.0); + expect($settings->notificationDurationSeconds)->toBe(4.0); +}); + +test('editor settings fall back to the notification default when notification duration is invalid', function () { + $settings = EditorSettings::fromArray([ + 'editor' => [ + 'notifications' => [ + 'duration' => 0, + ], + ], + ]); + + expect($settings->notificationDurationSeconds)->toBe(4.0); +}); + +test('editor settings support the string form of external editor configuration', function () { + $settings = EditorSettings::fromArray([ + 'editor' => [ + 'externalEditor' => 'zed {path}', + ], + ]); + + expect($settings->externalEditorCommand)->toBe('zed {path}'); + expect($settings->externalEditorMode)->toBe('auto'); + expect($settings->externalEditorBlocking)->toBeNull(); }); diff --git a/tests/Unit/EditorTmuxTest.php b/tests/Unit/EditorTmuxTest.php new file mode 100644 index 0000000..479ec71 --- /dev/null +++ b/tests/Unit/EditorTmuxTest.php @@ -0,0 +1,121 @@ +getMethod('buildExternalEditorCommand'); + + $command = $method->invoke($editor, '/tmp/Assets/Scripts/PlayerController.php'); + + expect($command)->toBe("nvim '/tmp/Assets/Scripts/PlayerController.php'"); +}); + +test('editor builds an external editor command from configured templates', function () { + [$reflection, $editor] = newBareEditorForTmuxTests(); + $reflection->getProperty('settings')->setValue( + $editor, + new EditorSettings( + scenes: EditorSceneSettings::fromArray([]), + externalEditorCommand: 'code --wait {path}', + externalEditorMode: 'gui', + externalEditorBlocking: true, + ), + ); + + $method = $reflection->getMethod('buildExternalEditorCommand'); + $command = $method->invoke($editor, '/tmp/Assets/Scripts/PlayerController.php'); + + expect($command)->toBe("code --wait '/tmp/Assets/Scripts/PlayerController.php'"); +}); + +test('editor auto-detects gui editor commands when the mode is automatic', function () { + [$reflection, $editor] = newBareEditorForTmuxTests(); + $reflection->getProperty('settings')->setValue( + $editor, + new EditorSettings( + scenes: EditorSceneSettings::fromArray([]), + externalEditorCommand: 'code {path}', + ), + ); + + $commandMethod = $reflection->getMethod('buildExternalEditorCommand'); + $modeMethod = $reflection->getMethod('resolveExternalEditorMode'); + $blockingMethod = $reflection->getMethod('shouldBlockOnExternalEditor'); + + $command = $commandMethod->invoke($editor, '/tmp/Assets/Scripts/PlayerController.php'); + + expect($modeMethod->invoke($editor, $command))->toBe('gui') + ->and($blockingMethod->invoke($editor, $command, 'gui'))->toBeFalse(); +}); + +test('editor builds a tmux play command that keeps the game inside the pane', function () { + $workspace = sys_get_temp_dir() . '/sendama-editor-tmux-' . uniqid(); + mkdir($workspace, 0777, true); + + [$reflection, $editor] = newBareEditorForTmuxTests(); + $reflection->getProperty('workingDirectory')->setValue($editor, $workspace); + + $method = $reflection->getMethod('buildTmuxPlayCommand'); + + $command = $method->invoke($editor); + + expect($command)->toContain('SENDAMA_TMUX_CHILD=1') + ->toContain(escapeshellarg(PHP_BINARY)) + ->toContain(escapeshellarg('/home/amasiye/development/games/sendama/console/bin/sendama')) + ->toContain(' play --directory ') + ->toContain(escapeshellarg($workspace)); +}); + +test('editor builds a tmux pane command for play mode', function () { + $reflection = new ReflectionClass(Editor::class); + $method = $reflection->getMethod('buildTmuxSplitPaneCommand'); + + $command = $method->invoke(null, '/tmp/game', "php '/tmp/sendama' play --directory '/tmp/game'"); + + expect($command)->toContain('tmux split-window -v -d -P') + ->toContain("-p 40") + ->toContain(escapeshellarg('/tmp/game')) + ->toContain(escapeshellarg("php '/tmp/sendama' play --directory '/tmp/game'")); +}); + +test('editor builds a tmux window command for external script editors', function () { + $reflection = new ReflectionClass(Editor::class); + $method = $reflection->getMethod('buildTmuxNewWindowCommand'); + + $command = $method->invoke(null, 'PlayerController', '/tmp/project', "nvim '/tmp/project/Assets/Scripts/PlayerController.php'"); + + expect($command)->toContain('tmux new-window -n') + ->toContain(escapeshellarg('PlayerController')) + ->toContain(escapeshellarg('/tmp/project')) + ->toContain(escapeshellarg("nvim '/tmp/project/Assets/Scripts/PlayerController.php'")); +}); + +/** + * @return array{ReflectionClass, Editor} + */ +function newBareEditorForTmuxTests(): array +{ + $reflection = new ReflectionClass(Editor::class); + $editor = $reflection->newInstanceWithoutConstructor(); + $initializeObservers = $reflection->getMethod('initializeObservers'); + $initializeObservers->invoke($editor); + + return [$reflection, $editor]; +} diff --git a/tests/Unit/FileDialogModalTest.php b/tests/Unit/FileDialogModalTest.php index 7723558..8d15f2a 100644 --- a/tests/Unit/FileDialogModalTest.php +++ b/tests/Unit/FileDialogModalTest.php @@ -2,6 +2,17 @@ use Sendama\Console\Editor\Widgets\FileDialogModal; +function getFileDialogModalContentAreaPosition(FileDialogModal $modal): array +{ + $getContentAreaLeft = new ReflectionMethod($modal, 'getContentAreaLeft'); + $getContentAreaTop = new ReflectionMethod($modal, 'getContentAreaTop'); + + return [ + 'x' => $getContentAreaLeft->invoke($modal), + 'y' => $getContentAreaTop->invoke($modal), + ]; +} + test('file dialog modal returns a relative path for the selected file', function () { $workspace = sys_get_temp_dir() . '/sendama-file-dialog-' . uniqid(); mkdir($workspace . '/Assets/Textures', 0777, true); @@ -84,3 +95,25 @@ ' • player.texture', ]); }); + +test('file dialog modal toggles directories and activates files with mouse clicks', function () { + $workspace = sys_get_temp_dir() . '/sendama-file-dialog-' . uniqid(); + mkdir($workspace . '/Assets/Textures', 0777, true); + file_put_contents($workspace . '/Assets/Textures/player.texture', 'texture'); + + $modal = new FileDialogModal(); + $modal->show($workspace . '/Assets'); + $contentArea = getFileDialogModalContentAreaPosition($modal); + + expect($modal->clickEntryAtPoint($contentArea['x'], $contentArea['y']))->toBeNull(); + expect($modal->content)->toBe([ + '▼ Textures', + ' • player.texture', + ]); + + $fileX = $contentArea['x'] + 3; + $fileY = $contentArea['y'] + 1; + + expect($modal->clickEntryAtPoint($fileX, $fileY))->toBeNull(); + expect($modal->clickEntryAtPoint($fileX, $fileY))->toBe('Textures/player.texture'); +}); diff --git a/tests/Unit/HierarchyPanelTest.php b/tests/Unit/HierarchyPanelTest.php index 8e32668..0cdde2e 100644 --- a/tests/Unit/HierarchyPanelTest.php +++ b/tests/Unit/HierarchyPanelTest.php @@ -1,7 +1,44 @@ setAccessible(true); + $previousKeyPress->setAccessible(true); + $previousKeyPress->setValue(''); + $currentKeyPress->setValue($keyPress); +} + +function getHierarchyContentAreaPosition(HierarchyPanel $panel): array +{ + $getContentAreaLeft = new ReflectionMethod($panel, 'getContentAreaLeft'); + $getContentAreaTop = new ReflectionMethod($panel, 'getContentAreaTop'); + + return [ + 'x' => $getContentAreaLeft->invoke($panel), + 'y' => $getContentAreaTop->invoke($panel), + ]; +} + +function setHierarchyPanelMouseEvent(?MouseEvent $event): void +{ + $mouseEvent = new ReflectionProperty(InputManager::class, 'mouseEvent'); + $mouseEvent->setAccessible(true); + $mouseEvent->setValue($event); +} + +function focusHierarchyPanel(HierarchyPanel $panel): void +{ + $hasFocus = new ReflectionProperty(\Sendama\Console\Editor\Widgets\Widget::class, 'hasFocus'); + $hasFocus->setAccessible(true); + $hasFocus->setValue($panel, true); +} + test('hierarchy panel expands nested objects and traverses visible children', function () { $panel = new HierarchyPanel( width: 40, @@ -153,6 +190,43 @@ ]); }); +test('hierarchy panel offers gui textures in the ui element creation workflow', function () { + $panel = new HierarchyPanel( + width: 40, + height: 12, + sceneName: 'level01', + hierarchy: [], + ); + + $showAddUiElementModal = new ReflectionMethod(HierarchyPanel::class, 'showAddUiElementModal'); + $showAddUiElementModal->setAccessible(true); + $showAddUiElementModal->invoke($panel); + + $addUiElementModal = new ReflectionProperty(HierarchyPanel::class, 'addUiElementModal'); + $modalOptions = new ReflectionProperty(\Sendama\Console\Editor\Widgets\OptionListModal::class, 'options'); + $handleAddUiElementSelection = new ReflectionMethod(HierarchyPanel::class, 'handleAddUiElementSelection'); + $addUiElementModal->setAccessible(true); + $modalOptions->setAccessible(true); + $handleAddUiElementSelection->setAccessible(true); + + expect($modalOptions->getValue($addUiElementModal->getValue($panel)))->toContain('GUITexture'); + + $handleAddUiElementSelection->invoke($panel, 'GUITexture'); + + expect($panel->consumeCreationRequest())->toBe([ + 'value' => [ + 'type' => 'Sendama\\Engine\\UI\\GUITexture\\GUITexture', + 'name' => 'GUITexture #1', + 'tag' => 'UI', + 'position' => ['x' => 0, 'y' => 0], + 'size' => ['x' => 1, 'y' => 1], + 'texture' => 'None', + 'color' => 'White', + ], + 'parentPath' => null, + ]); +}); + test('hierarchy panel queues default game objects from the add workflow', function () { $panel = new HierarchyPanel( width: 40, @@ -172,13 +246,54 @@ $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' => [], + 'value' => [ + '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' => [], + ], + 'parentPath' => null, + ]); +}); + +test('hierarchy panel can queue a new game object as a child of the selected game object', function () { + $panel = new HierarchyPanel( + width: 40, + height: 12, + sceneName: 'level01', + hierarchy: [ + [ + 'type' => 'Sendama\\Engine\\Core\\GameObject', + 'name' => 'Player', + 'children' => [], + ], + ], + ); + + $panel->expandSelection(); + + $handleAddObjectTypeSelection = new ReflectionMethod(HierarchyPanel::class, 'handleAddObjectTypeSelection'); + $handleAddObjectTypeSelection->setAccessible(true); + $handleAddObjectTypeSelection->invoke($panel, 'GameObject'); + + $handleAddObjectPlacementSelection = new ReflectionMethod(HierarchyPanel::class, 'handleAddObjectPlacementSelection'); + $handleAddObjectPlacementSelection->setAccessible(true); + $handleAddObjectPlacementSelection->invoke($panel, 'Child of Player'); + + expect($panel->consumeCreationRequest())->toBe([ + 'value' => [ + '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' => [], + ], + 'parentPath' => 'scene.0', ]); }); @@ -197,12 +312,15 @@ $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', + 'value' => [ + 'type' => 'Sendama\\Engine\\UI\\Label\\Label', + 'name' => 'Label #2', + 'tag' => 'UI', + 'position' => ['x' => 0, 'y' => 0], + 'size' => ['x' => 1, 'y' => 1], + 'text' => 'Label #2', + ], + 'parentPath' => null, ]); }); @@ -254,3 +372,327 @@ expect($panel->consumeDeletionRequest())->toBeNull(); }); + +test('hierarchy panel can queue prefab creation for the selected object', function () { + $panel = new HierarchyPanel( + width: 40, + height: 12, + sceneName: 'level01', + hierarchy: [ + [ + 'type' => 'Sendama\\Engine\\Core\\GameObject', + 'name' => 'Enemy Ship', + 'tag' => 'Enemy', + 'position' => ['x' => 60, 'y' => 12], + 'rotation' => ['x' => 0, 'y' => 0], + 'scale' => ['x' => 1, 'y' => 1], + 'components' => [], + ], + ], + ); + + $panel->expandSelection(); + $panel->beginPrefabCreationWorkflow(); + + expect($panel->consumePrefabCreationRequest())->toBe([ + 'path' => 'scene.0', + 'name' => 'Enemy Ship', + 'value' => [ + 'type' => 'Sendama\\Engine\\Core\\GameObject', + 'name' => 'Enemy Ship', + 'tag' => 'Enemy', + 'position' => ['x' => 60, 'y' => 12], + 'rotation' => ['x' => 0, 'y' => 0], + 'scale' => ['x' => 1, 'y' => 1], + 'components' => [], + ], + ]); + expect($panel->consumePrefabCreationRequest())->toBeNull(); +}); + +test('hierarchy panel scrolls to keep the selected row visible when content overflows', function () { + $panel = new HierarchyPanel( + width: 32, + height: 6, + sceneName: 'level01', + hierarchy: array_map( + static fn (int $index): array => [ + 'type' => 'Sendama\\Engine\\Core\\GameObject', + 'name' => 'Object ' . $index, + ], + range(1, 8), + ), + ); + + $panel->moveSelection(8); + + $buildRenderedContentLines = new ReflectionMethod($panel, 'buildRenderedContentLines'); + $buildRenderedContentLines->setAccessible(true); + $lines = $buildRenderedContentLines->invoke($panel); + + expect(array_any($lines, static fn (string $line): bool => str_contains($line, 'Object 8')))->toBeTrue() + ->and(array_any($lines, static fn (string $line): bool => str_contains($line, '█') || str_contains($line, '░')))->toBeTrue(); +}); + +test('hierarchy panel can queue duplication for the selected object and append a differentiating number', function () { + $panel = new HierarchyPanel( + width: 40, + height: 12, + sceneName: 'level01', + hierarchy: [ + ['type' => 'Sendama\\Engine\\Core\\GameObject', 'name' => 'Enemy'], + ], + ); + + $panel->expandSelection(); + $panel->beginDuplicationWorkflow(); + + expect($panel->consumeDuplicationRequest())->toBe([ + 'items' => [ + [ + 'path' => 'scene.0', + 'value' => [ + 'type' => 'Sendama\\Engine\\Core\\GameObject', + 'name' => 'Enemy', + ], + ], + ], + 'primaryPath' => 'scene.0', + ]); +}); + +test('hierarchy panel adds ctrl-clicked rows to the duplication selection set', function () { + $panel = new HierarchyPanel( + width: 40, + height: 12, + sceneName: 'level01', + hierarchy: [ + ['type' => 'Sendama\\Engine\\Core\\GameObject', 'name' => 'Enemy 01'], + ['type' => 'Sendama\\Engine\\Core\\GameObject', 'name' => 'Enemy 02'], + ], + ); + + $panel->expandSelection(); + $panel->expandSelection(); + $panel->expandSelection(); + $contentArea = getHierarchyContentAreaPosition($panel); + setHierarchyPanelMouseEvent(new MouseEvent("\033[<16;" . ($contentArea['x'] + 4) . ';' . ($contentArea['y'] + 2) . 'M')); + $panel->handleMouseClick($contentArea['x'] + 4, $contentArea['y'] + 2); + setHierarchyPanelMouseEvent(null); + $panel->beginDuplicationWorkflow(); + + expect($panel->consumeDuplicationRequest())->toBe([ + 'items' => [ + [ + 'path' => 'scene.0', + 'value' => ['type' => 'Sendama\\Engine\\Core\\GameObject', 'name' => 'Enemy 01'], + ], + [ + 'path' => 'scene.1', + 'value' => ['type' => 'Sendama\\Engine\\Core\\GameObject', 'name' => 'Enemy 02'], + ], + ], + 'primaryPath' => 'scene.1', + ]); +}); + +test('hierarchy panel toggles expand and collapse when the icon is clicked', function () { + $panel = new HierarchyPanel( + width: 40, + height: 12, + sceneName: 'level01', + hierarchy: [ + [ + 'type' => 'Sendama\\Engine\\Core\\GameObject', + 'name' => 'Player', + 'children' => [ + ['type' => 'Sendama\\Engine\\Core\\GameObject', 'name' => 'Gun'], + ], + ], + ], + ); + + $contentArea = getHierarchyContentAreaPosition($panel); + $panel->handleMouseClick($contentArea['x'] + 2, $contentArea['y'] + 1); + + expect($panel->content[1])->toBe(' ▼ Player') + ->and($panel->content[2])->toBe(' • Gun'); + + $panel->handleMouseClick($contentArea['x'] + 2, $contentArea['y'] + 1); + + expect($panel->content[1])->toBe(' ► Player'); +}); + +test('hierarchy panel activates the selected row on double click', function () { + $panel = new HierarchyPanel( + width: 40, + height: 12, + sceneName: 'level01', + hierarchy: [ + ['type' => 'Sendama\\Engine\\Core\\GameObject', 'name' => 'Player'], + ], + ); + + $contentArea = getHierarchyContentAreaPosition($panel); + $clickX = $contentArea['x'] + 4; + $clickY = $contentArea['y'] + 1; + + $panel->handleMouseClick($clickX, $clickY); + $panel->handleMouseClick($clickX, $clickY); + + expect($panel->consumeInspectionRequest())->toBe([ + 'context' => 'hierarchy', + 'name' => 'Player', + 'type' => 'GameObject', + 'path' => 'scene.0', + 'value' => ['type' => 'Sendama\\Engine\\Core\\GameObject', 'name' => 'Player'], + ]); +}); + +test('hierarchy panel reparents a dragged object onto another object on mouse release', function () { + $panel = new HierarchyPanel( + width: 40, + height: 12, + sceneName: 'level01', + hierarchy: [ + ['type' => 'Sendama\\Engine\\Core\\GameObject', 'name' => 'Player'], + ['type' => 'Sendama\\Engine\\Core\\GameObject', 'name' => 'Enemy'], + ], + ); + + $contentArea = getHierarchyContentAreaPosition($panel); + + $panel->handleMouseClick($contentArea['x'] + 4, $contentArea['y'] + 1); + $panel->handleMouseDrag($contentArea['x'] + 4, $contentArea['y'] + 2); + $panel->handleMouseRelease($contentArea['x'] + 4, $contentArea['y'] + 2); + + expect($panel->consumeMoveRequest())->toBe([ + 'path' => 'scene.0', + 'targetPath' => 'scene.1', + 'position' => 'append_child', + ]); +}); + +test('hierarchy panel moves a dragged child onto the scene root on mouse release', function () { + $panel = new HierarchyPanel( + width: 40, + height: 12, + sceneName: 'level01', + hierarchy: [ + [ + 'type' => 'Sendama\\Engine\\Core\\GameObject', + 'name' => 'Player', + 'children' => [ + ['type' => 'Sendama\\Engine\\Core\\GameObject', 'name' => 'Gun'], + ], + ], + ], + ); + + $panel->expandSelection(); + $panel->expandSelection(); + $panel->expandSelection(); + $contentArea = getHierarchyContentAreaPosition($panel); + + $panel->handleMouseClick($contentArea['x'] + 4, $contentArea['y'] + 2); + $panel->handleMouseDrag($contentArea['x'] + 4, $contentArea['y']); + $panel->handleMouseRelease($contentArea['x'] + 4, $contentArea['y']); + + expect($panel->consumeMoveRequest())->toBe([ + 'path' => 'scene.0.0', + 'targetPath' => 'scene', + 'position' => 'append_child', + ]); +}); + +test('hierarchy panel moves a dragged child to the scene root when released over empty space', function () { + $panel = new HierarchyPanel( + width: 40, + height: 12, + sceneName: 'level01', + hierarchy: [ + [ + 'type' => 'Sendama\\Engine\\Core\\GameObject', + 'name' => 'Player', + 'children' => [ + ['type' => 'Sendama\\Engine\\Core\\GameObject', 'name' => 'Gun'], + ], + ], + ], + ); + + $panel->expandSelection(); + $panel->expandSelection(); + $contentArea = getHierarchyContentAreaPosition($panel); + + $panel->handleMouseClick($contentArea['x'] + 4, $contentArea['y'] + 2); + $panel->handleMouseDrag($contentArea['x'] + 4, $contentArea['y'] + 6); + $panel->handleMouseRelease($contentArea['x'] + 4, $contentArea['y'] + 6); + + expect($panel->consumeMoveRequest())->toBe([ + 'path' => 'scene.0.0', + 'targetPath' => 'scene', + 'position' => 'append_child', + ]); +}); + +test('hierarchy panel queues move requests that can place objects into other trees', function () { + $panel = new HierarchyPanel( + width: 40, + height: 12, + sceneName: 'level01', + hierarchy: [ + [ + 'type' => 'Sendama\\Engine\\Core\\GameObject', + 'name' => 'Player', + 'children' => [ + ['type' => 'Sendama\\Engine\\Core\\GameObject', 'name' => 'Gun'], + ], + ], + ['type' => 'Sendama\\Engine\\Core\\GameObject', 'name' => 'Enemy'], + ], + ); + + $panel->expandSelection(); + $panel->expandSelection(); + $panel->moveSelection(2); + focusHierarchyPanel($panel); + + pressHierarchyPanelKey('W'); + $panel->update(); + pressHierarchyPanelKey("\033[A"); + $panel->update(); + + expect($panel->consumeMoveRequest())->toBe([ + 'path' => 'scene.1', + 'targetPath' => 'scene.0.0', + 'position' => 'before', + ]); +}); + +test('hierarchy panel returns to select mode on shift+q after move mode is active', function () { + $panel = new HierarchyPanel( + width: 40, + height: 12, + sceneName: 'level01', + hierarchy: [ + ['type' => 'Sendama\\Engine\\Core\\GameObject', 'name' => 'Player'], + ], + ); + + $panel->expandSelection(); + focusHierarchyPanel($panel); + + $interactionMode = new ReflectionProperty(HierarchyPanel::class, 'interactionMode'); + $interactionMode->setAccessible(true); + + pressHierarchyPanelKey('W'); + $panel->update(); + + expect($interactionMode->getValue($panel))->toBe('move'); + + pressHierarchyPanelKey('Q'); + $panel->update(); + + expect($interactionMode->getValue($panel))->toBe('select'); +}); diff --git a/tests/Unit/InputManagerTest.php b/tests/Unit/InputManagerTest.php index 08fcc09..0b12e02 100644 --- a/tests/Unit/InputManagerTest.php +++ b/tests/Unit/InputManagerTest.php @@ -1,174 +1,160 @@ setAccessible(true); - expect($getKey->invoke(null, "\033[Z"))->toBe(KeyCode::SHIFT_TAB->value); - expect($getKey->invoke(null, "\033[1;2Z"))->toBe(KeyCode::SHIFT_TAB->value); + expect($getKey->invoke(null, "\033[Z"))->toBe(KeyCode::SHIFT_TAB->value) + ->and($getKey->invoke(null, "\033[1;2Z"))->toBe(KeyCode::SHIFT_TAB->value); }); test('input manager normalizes shift up sequences', function () { $getKey = new ReflectionMethod(InputManager::class, 'getKey'); - $getKey->setAccessible(true); - expect($getKey->invoke(null, "\033[1;2A"))->toBe(KeyCode::SHIFT_UP->value); - expect($getKey->invoke(null, "\033[a"))->toBe(KeyCode::SHIFT_UP->value); + expect($getKey->invoke(null, "\033[1;2A"))->toBe(KeyCode::SHIFT_UP->value) + ->and($getKey->invoke(null, "\033[a"))->toBe(KeyCode::SHIFT_UP->value); }); test('input manager normalizes shift down sequences', function () { $getKey = new ReflectionMethod(InputManager::class, 'getKey'); - $getKey->setAccessible(true); - expect($getKey->invoke(null, "\033[1;2B"))->toBe(KeyCode::SHIFT_DOWN->value); - expect($getKey->invoke(null, "\033[b"))->toBe(KeyCode::SHIFT_DOWN->value); + expect($getKey->invoke(null, "\033[1;2B"))->toBe(KeyCode::SHIFT_DOWN->value) + ->and($getKey->invoke(null, "\033[b"))->toBe(KeyCode::SHIFT_DOWN->value); }); test('input manager normalizes shift right sequences', function () { $getKey = new ReflectionMethod(InputManager::class, 'getKey'); - $getKey->setAccessible(true); - expect($getKey->invoke(null, "\033[1;2C"))->toBe(KeyCode::SHIFT_RIGHT->value); - expect($getKey->invoke(null, "\033[c"))->toBe(KeyCode::SHIFT_RIGHT->value); + expect($getKey->invoke(null, "\033[1;2C"))->toBe(KeyCode::SHIFT_RIGHT->value) + ->and($getKey->invoke(null, "\033[c"))->toBe(KeyCode::SHIFT_RIGHT->value); }); test('input manager normalizes shift left sequences', function () { $getKey = new ReflectionMethod(InputManager::class, 'getKey'); - $getKey->setAccessible(true); - expect($getKey->invoke(null, "\033[1;2D"))->toBe(KeyCode::SHIFT_LEFT->value); - expect($getKey->invoke(null, "\033[d"))->toBe(KeyCode::SHIFT_LEFT->value); + expect($getKey->invoke(null, "\033[1;2D"))->toBe(KeyCode::SHIFT_LEFT->value) + ->and($getKey->invoke(null, "\033[d"))->toBe(KeyCode::SHIFT_LEFT->value); }); test('input manager preserves the shift+5 play toggle input', function () { $getKey = new ReflectionMethod(InputManager::class, 'getKey'); - $getKey->setAccessible(true); 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']); + expect($tokenizeInput->invoke(null, '02'))->toBe(['0', '2']) + ->and($tokenizeInput->invoke(null, 'level02'))->toBe(['l', 'e', 'v', 'e', 'l', '0', '2']); }); test('input manager preserves buffered 0 input instead of treating it as empty', function () { $normalizeBufferedInput = new ReflectionMethod(InputManager::class, 'normalizeBufferedInput'); - $normalizeBufferedInput->setAccessible(true); - expect($normalizeBufferedInput->invoke(null, false))->toBe(''); - expect($normalizeBufferedInput->invoke(null, '0'))->toBe('0'); - expect($normalizeBufferedInput->invoke(null, 'level02'))->toBe('level02'); + expect($normalizeBufferedInput->invoke(null, false))->toBe('') + ->and($normalizeBufferedInput->invoke(null, '0'))->toBe('0') + ->and($normalizeBufferedInput->invoke(null, 'level02'))->toBe('level02'); }); 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']); }); test('input manager coalesces repeated arrow tokens to avoid held-key drift', function () { $coalesceRepeatableTokens = new ReflectionMethod(InputManager::class, 'coalesceRepeatableTokens'); - $coalesceRepeatableTokens->setAccessible(true); expect($coalesceRepeatableTokens->invoke(null, ["\033[C", "\033[C", "\033[C"]))->toBe([ "\033[C", - ]); - expect($coalesceRepeatableTokens->invoke(null, ["\033[C", "\033[D", "\033[D"]))->toBe([ - "\033[C", - "\033[D", - ]); - expect($coalesceRepeatableTokens->invoke(null, ['0', '0']))->toBe(['0', '0']); + ]) + ->and($coalesceRepeatableTokens->invoke(null, ["\033[C", "\033[D", "\033[D"]))->toBe([ + "\033[C", + "\033[D", + ]) + ->and($coalesceRepeatableTokens->invoke(null, ['0', '0']))->toBe(['0', '0']); }); test('input manager treats repeated arrow input as both pressed and down', function () { $keyPress = new ReflectionProperty(InputManager::class, 'keyPress'); $previousKeyPress = new ReflectionProperty(InputManager::class, 'previousKeyPress'); $currentKeyPressWasBuffered = new ReflectionProperty(InputManager::class, 'currentKeyPressWasBuffered'); - $keyPress->setAccessible(true); - $previousKeyPress->setAccessible(true); - $currentKeyPressWasBuffered->setAccessible(true); $previousKeyPress->setValue("\033[C"); $keyPress->setValue("\033[C"); $currentKeyPressWasBuffered->setValue(true); - expect(InputManager::isKeyPressed(KeyCode::RIGHT))->toBeTrue(); - expect(InputManager::isKeyDown(KeyCode::RIGHT))->toBeTrue(); + expect(InputManager::isKeyPressed(KeyCode::RIGHT))->toBeTrue() + ->and(InputManager::isKeyDown(KeyCode::RIGHT))->toBeTrue(); }); test('input manager still treats repeated non-repeatable input as not down', function () { $keyPress = new ReflectionProperty(InputManager::class, 'keyPress'); $previousKeyPress = new ReflectionProperty(InputManager::class, 'previousKeyPress'); - $keyPress->setAccessible(true); - $previousKeyPress->setAccessible(true); $previousKeyPress->setValue("\n"); $keyPress->setValue("\n"); - expect(InputManager::isKeyPressed(KeyCode::ENTER))->toBeTrue(); - expect(InputManager::isKeyDown(KeyCode::ENTER))->toBeFalse(); + expect(InputManager::isKeyPressed(KeyCode::ENTER))->toBeTrue() + ->and(InputManager::isKeyDown(KeyCode::ENTER))->toBeFalse(); }); test('input manager briefly holds repeatable arrows between raw repeat events', function () { $resolveCurrentKeyPress = new ReflectionMethod(InputManager::class, 'resolveCurrentKeyPress'); $heldRepeatableKeyPress = new ReflectionProperty(InputManager::class, 'heldRepeatableKeyPress'); $heldRepeatableKeySeenAt = new ReflectionProperty(InputManager::class, 'heldRepeatableKeySeenAt'); - $resolveCurrentKeyPress->setAccessible(true); - $heldRepeatableKeyPress->setAccessible(true); - $heldRepeatableKeySeenAt->setAccessible(true); $heldRepeatableKeyPress->setValue("\033[C"); $heldRepeatableKeySeenAt->setValue(10.0); - expect($resolveCurrentKeyPress->invoke(null, '', 10.04))->toBe("\033[C"); - expect($resolveCurrentKeyPress->invoke(null, '', 10.06))->toBe(''); + expect($resolveCurrentKeyPress->invoke(null, '', 10.04))->toBe("\033[C") + ->and($resolveCurrentKeyPress->invoke(null, '', 10.06))->toBe(''); }); test('input manager does not treat held repeatable fallback frames as pressed or down', function () { $keyPress = new ReflectionProperty(InputManager::class, 'keyPress'); $previousKeyPress = new ReflectionProperty(InputManager::class, 'previousKeyPress'); $currentKeyPressWasBuffered = new ReflectionProperty(InputManager::class, 'currentKeyPressWasBuffered'); - $keyPress->setAccessible(true); - $previousKeyPress->setAccessible(true); - $currentKeyPressWasBuffered->setAccessible(true); $previousKeyPress->setValue("\033[C"); $keyPress->setValue("\033[C"); $currentKeyPressWasBuffered->setValue(false); - expect(InputManager::isKeyPressed(KeyCode::RIGHT))->toBeFalse(); - expect(InputManager::isKeyDown(KeyCode::RIGHT))->toBeFalse(); + expect(InputManager::isKeyPressed(KeyCode::RIGHT))->toBeFalse() + ->and(InputManager::isKeyDown(KeyCode::RIGHT))->toBeFalse(); +}); + +test('input manager distinguishes left button press from release for mouse focus workflows', function () { + $mouseEvent = new ReflectionProperty(InputManager::class, 'mouseEvent'); + + $mouseEvent->setValue(new MouseEvent("\033[<0;12;8M")); + expect(InputManager::isLeftMouseButtonPressed())->toBeTrue(); + + $mouseEvent->setValue(new MouseEvent("\033[<0;12;8m")); + expect(InputManager::isLeftMouseButtonPressed())->toBeFalse(); }); diff --git a/tests/Unit/InspectorPanelTest.php b/tests/Unit/InspectorPanelTest.php index 4769e1a..72ce2cb 100644 --- a/tests/Unit/InspectorPanelTest.php +++ b/tests/Unit/InspectorPanelTest.php @@ -1,5 +1,6 @@ getLabel(); } +function getInspectorContentAreaPosition(InspectorPanel $panel): array +{ + $getContentAreaLeft = new ReflectionMethod($panel, 'getContentAreaLeft'); + $getContentAreaTop = new ReflectionMethod($panel, 'getContentAreaTop'); + + return [ + 'x' => $getContentAreaLeft->invoke($panel), + 'y' => $getContentAreaTop->invoke($panel), + ]; +} + +function getWidgetContentAreaPosition(object $widget): array +{ + $getContentAreaLeft = new ReflectionMethod($widget, 'getContentAreaLeft'); + $getContentAreaTop = new ReflectionMethod($widget, 'getContentAreaTop'); + + return [ + 'x' => $getContentAreaLeft->invoke($widget), + 'y' => $getContentAreaTop->invoke($widget), + ]; +} + +function createMousePressEvent(int $x, int $y, int $buttonIndex = 0): MouseEvent +{ + return new MouseEvent(sprintf("\033[<%d;%d;%dM", $buttonIndex, $x, $y)); +} + function createInspectorComponentWorkspace(): string { $workspace = sys_get_temp_dir() . '/sendama-inspector-components-' . uniqid(); @@ -187,6 +215,142 @@ class PlayerController extends Behaviour return $workspace; } +function createInspectorStandardComponentWorkspace(): string +{ + $workspace = sys_get_temp_dir() . '/sendama-inspector-standard-components-' . uniqid(); + mkdir($workspace . '/Assets', 0777, true); + mkdir($workspace . '/vendor', 0777, true); + + file_put_contents( + $workspace . '/vendor/autoload.php', + <<<'PHP' +inspectTarget([ + 'context' => 'hierarchy', + 'name' => 'HUD Logo', + 'type' => 'GUITexture', + 'path' => 'scene.0', + 'value' => [ + 'type' => 'Sendama\\Engine\\UI\\GUITexture\\GUITexture', + 'name' => 'HUD Logo', + 'tag' => 'UI', + 'position' => ['x' => 1, 'y' => 1], + 'size' => ['x' => 2, 'y' => 2], + 'texture' => 'Textures/hud', + 'color' => 'Yellow', + ], + ]); + + $content = implode("\n", $panel->content); + + expect($content)->toContain('▼ Texture') + ->toContain('Texture: Textures/hud') + ->toContain('Color: ') + ->toContain('Preview:') + ->toContain('##') + ->toContain('@@') + ->not->toContain('▼ Renderer'); +}); + +test('inspector panel enters edit mode when a control is double clicked', function () { + $panel = new InspectorPanel(width: 48, height: 24); + $interactionState = new ReflectionProperty(InspectorPanel::class, 'interactionState'); + $interactionState->setAccessible(true); + + focusInspectorPanel($panel); + $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], + 'components' => [], + ], + ]); + + $contentArea = getInspectorContentAreaPosition($panel); + $nameLineIndex = array_search('Name: Player', $panel->content, true); + + expect($nameLineIndex)->toBeInt(); + + $panel->handleMouseClick($contentArea['x'] + 1, $contentArea['y'] + $nameLineIndex); + $panel->handleMouseClick($contentArea['x'] + 1, $contentArea['y'] + $nameLineIndex); + + expect(selectedInspectorControlLabel($panel))->toBe('Name'); + expect($interactionState->getValue($panel))->toBe('control_edit'); +}); + +test('inspector panel path selection modals can be operated with the mouse', function () { + $workspace = sys_get_temp_dir() . '/sendama-inspector-path-modal-' . uniqid(); + mkdir($workspace . '/Assets/Maps', 0777, true); + file_put_contents($workspace . '/Assets/Maps/level.tmap', "xxxx\n"); + + $panel = new InspectorPanel(width: 48, height: 24, workingDirectory: $workspace); + $pathInputActionModal = new ReflectionProperty(InspectorPanel::class, 'pathInputActionModal'); + $fileDialogModal = new ReflectionProperty(InspectorPanel::class, 'fileDialogModal'); + $pathInputActionModal->setAccessible(true); + $fileDialogModal->setAccessible(true); + + focusInspectorPanel($panel); + $panel->inspectTarget([ + 'context' => 'scene', + 'name' => 'Level', + 'type' => 'Scene', + 'path' => 'scene', + 'value' => [ + 'name' => 'Level', + 'width' => 80, + 'height' => 25, + 'environmentTileMapPath' => '', + 'environmentCollisionMapPath' => '', + ], + ]); + + selectInspectorControlByLabel($panel, 'Map'); + setInspectorInput("\n"); + $panel->update(); + + /** @var object $actionModal */ + $actionModal = $pathInputActionModal->getValue($panel); + $actionContentArea = getWidgetContentAreaPosition($actionModal); + $panel->handleModalMouseEvent(createMousePressEvent($actionContentArea['x'] + 1, $actionContentArea['y'])); + + /** @var object $dialogModal */ + $dialogModal = $fileDialogModal->getValue($panel); + $dialogContentArea = getWidgetContentAreaPosition($dialogModal); + + $panel->handleModalMouseEvent(createMousePressEvent($dialogContentArea['x'], $dialogContentArea['y'])); + $panel->handleModalMouseEvent(createMousePressEvent($dialogContentArea['x'] + 3, $dialogContentArea['y'] + 1)); + $panel->handleModalMouseEvent(createMousePressEvent($dialogContentArea['x'] + 3, $dialogContentArea['y'] + 1)); + + expect($panel->hasActiveModal())->toBeFalse(); + expect($panel->consumeHierarchyMutation())->toBe([ + 'path' => 'scene', + 'value' => [ + 'name' => 'Level', + 'width' => 80, + 'height' => 25, + 'environmentTileMapPath' => 'Maps/level.tmap', + 'environmentCollisionMapPath' => '', + ], + ]); +}); + +test('inspector panel add component workflow includes standard engine components without project scripts', function () { + $workspace = createInspectorStandardComponentWorkspace(); + $panel = new InspectorPanel(width: 48, height: 24, workingDirectory: $workspace); + $showAddComponentModal = new ReflectionMethod(InspectorPanel::class, 'showAddComponentModal'); + $showAddComponentModal->setAccessible(true); + $addComponentModal = new ReflectionProperty(InspectorPanel::class, 'addComponentModal'); + $addComponentModal->setAccessible(true); + + focusInspectorPanel($panel); + $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], + 'components' => [], + ], + ]); + + $showAddComponentModal->invoke($panel); + + $modal = $addComponentModal->getValue($panel); + + expect($modal->content)->toContain('> AnimationController') + ->toContain(' CharacterController') + ->toContain(' CharacterMovement') + ->toContain(' Collider') + ->toContain(' Rigidbody') + ->toContain(' SimpleBackListener') + ->toContain(' SimpleQuitListener'); +}); + test('inspector panel styles component headers with a white background', function () { $panelWidth = 32; $panel = new InspectorPanel(width: $panelWidth, height: 12); @@ -786,6 +1112,126 @@ class PlayerController extends Behaviour ->and($panel->hasActiveModal())->toBeTrue(); }); +test('inspector panel opens a prefab picker for GameObject component fields and saves the selected prefab path', function () { + $workspace = sys_get_temp_dir() . '/sendama-inspector-prefab-reference-' . uniqid(); + mkdir($workspace . '/Assets/Prefabs', 0777, true); + mkdir($workspace . '/assets/Prefabs', 0777, true); + mkdir($workspace . '/vendor', 0777, true); + + file_put_contents( + $workspace . '/vendor/autoload.php', + <<<'PHP' + GameObject::class, + 'name' => 'Enemy', +]; +PHP + ); + + $panel = new InspectorPanel(width: 48, height: 24, workingDirectory: $workspace); + $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], + 'components' => [ + [ + 'class' => 'Sendama\\Game\\Scripts\\Gun', + 'data' => [ + 'bulletPrefab' => null, + ], + '__editorFieldTypes' => [ + 'bulletPrefab' => 'Sendama\\Engine\\Core\\GameObject|null', + ], + ], + ], + ], + ]); + + focusInspectorPanel($panel); + selectInspectorControlByLabel($panel, 'Bullet Prefab'); + + expect($panel->content)->toContain(' Bullet Prefab: None'); + + setInspectorInput("\n"); + $panel->update(); + + expect($panel->hasActiveModal())->toBeTrue(); + + setInspectorInput("\033[B"); + $panel->update(); + + setInspectorInput("\n"); + $panel->update(); + + $mutation = $panel->consumeHierarchyMutation(); + + expect($mutation['path'] ?? null)->toBe('scene.0') + ->and($mutation['value']['components'][0]['class'] ?? null)->toBe('Sendama\\Game\\Scripts\\Gun') + ->and($mutation['value']['components'][0]['data']['bulletPrefab'] ?? null)->toBe('Prefabs/enemy.prefab.php') + ->and($mutation['value']['components'][0]['__editorFieldTypes']['bulletPrefab'] ?? null)->toBe('Sendama\\Engine\\Core\\GameObject|null'); + + expect(implode("\n", $panel->content))->toContain('Bullet Prefab: Enemy'); +}); + +test('inspector panel renders typed Vector2 component fields as compound controls', 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], + 'components' => [ + [ + 'class' => 'Sendama\\Game\\Scripts\\Spawner', + 'data' => [ + 'spawnOffset' => null, + ], + '__editorFieldTypes' => [ + 'spawnOffset' => 'Sendama\\Engine\\Core\\Vector2|null', + ], + ], + ], + ], + ]); + + expect($panel->content)->toContain('▼ Spawner') + ->toContain(' Spawn Offset:') + ->toContain(' X: 0') + ->toContain(' Y: 0'); +}); + test('inspector panel separates prefab file renames from prefab metadata edits', function () { $panel = new InspectorPanel(width: 48, height: 24); $panel->inspectTarget([ @@ -852,13 +1298,11 @@ class PlayerController extends Behaviour $nameControl->setValue('Boss'); $applyControlValueToInspectionTarget->invoke($panel, $nameControl); - expect($panel->consumePrefabMutation())->toMatchArray([ - 'path' => 'Prefabs/enemy.prefab.php', - 'prefabPath' => '/tmp/project/Assets/Prefabs/enemy.prefab.php', - 'value' => [ - 'name' => 'Boss', - ], - ]); + $prefabMutation = $panel->consumePrefabMutation(); + + expect($prefabMutation['path'] ?? null)->toBe('Prefabs/enemy.prefab.php') + ->and($prefabMutation['prefabPath'] ?? null)->toBe('/tmp/project/Assets/Prefabs/enemy.prefab.php') + ->and($prefabMutation['value']['name'] ?? null)->toBe('Boss'); }); test('inspector panel emits hierarchy mutations when edits are committed', function () { diff --git a/tests/Unit/MainPanelTest.php b/tests/Unit/MainPanelTest.php index 010b192..0cffb6f 100644 --- a/tests/Unit/MainPanelTest.php +++ b/tests/Unit/MainPanelTest.php @@ -1,5 +1,6 @@ setValue($keyPress); } +function getMainPanelContentAreaPosition(MainPanel $panel): array +{ + $getContentAreaLeft = new ReflectionMethod($panel, 'getContentAreaLeft'); + $getContentAreaTop = new ReflectionMethod($panel, 'getContentAreaTop'); + + return [ + 'x' => $getContentAreaLeft->invoke($panel), + 'y' => $getContentAreaTop->invoke($panel), + ]; +} + function createMainPanelWorkspace(): string { $workspace = sys_get_temp_dir() . '/sendama-main-panel-' . uniqid(); @@ -27,6 +39,13 @@ function createMainPanelWorkspace(): string return $workspace; } +function setMainPanelMouseEvent(?MouseEvent $event): void +{ + $mouseEvent = new ReflectionProperty(InputManager::class, 'mouseEvent'); + $mouseEvent->setAccessible(true); + $mouseEvent->setValue($event); +} + test('main panel cycles forward through tabs', function () { $panel = new MainPanel(width: 60, height: 12); @@ -133,6 +152,30 @@ function createMainPanelWorkspace(): string expect(array_any($panel->content, fn(string $line) => str_contains($line, 'Score: 000')))->toBeTrue(); }); +test('main panel renders gui textures from their texture metadata on the scene tab', function () { + $workspace = createMainPanelWorkspace(); + file_put_contents($workspace . '/Assets/Textures/hud.texture', "HUD\n@@@\n"); + + $panel = new MainPanel( + width: 40, + height: 12, + sceneObjects: [ + [ + 'type' => 'Sendama\\Engine\\UI\\GUITexture\\GUITexture', + 'name' => 'HUD Logo', + 'position' => ['x' => 2, 'y' => 1], + 'size' => ['x' => 3, 'y' => 2], + 'texture' => 'Textures/hud', + 'color' => 'Yellow', + ], + ], + workingDirectory: $workspace, + ); + + expect(array_any($panel->content, fn(string $line) => str_contains($line, 'HUD')))->toBeTrue(); + expect(array_any($panel->content, fn(string $line) => str_contains($line, '@@@')))->toBeTrue(); +}); + test('main panel renders the environment tile map behind scene objects', function () { $workspace = createMainPanelWorkspace(); $panel = new MainPanel( @@ -156,8 +199,8 @@ function createMainPanelWorkspace(): string 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(); + expect(array_any($panel->content, fn(string $line) => str_contains($line, 'xaxxx')))->toBeTrue(); + expect(array_any($panel->content, fn(string $line) => str_contains($line, 'x x')))->toBeTrue(); }); test('main panel preserves scene row width when a sprite uses a wide multibyte glyph', function () { @@ -189,9 +232,9 @@ function createMainPanelWorkspace(): string $buildSceneCanvasContent->setAccessible(true); $sceneRows = $buildSceneCanvasContent->invoke($panel); - expect($sceneRows[1])->toContain('👾'); - expect(mb_strwidth($sceneRows[1], 'UTF-8'))->toBe(mb_strlen($sceneRows[1]) + 1); - expect(rtrim($sceneRows[1]))->toEndWith('x'); + expect($sceneRows[0])->toContain('👾'); + expect(mb_strwidth($sceneRows[0], 'UTF-8'))->toBe(mb_strlen($sceneRows[0]) + 1); + expect(rtrim($sceneRows[0]))->toEndWith('x'); $buildRenderedContentLines = new ReflectionMethod($panel, 'buildRenderedContentLines'); $buildRenderedContentLines->setAccessible(true); @@ -241,7 +284,7 @@ function createMainPanelWorkspace(): string $selectedScenePath->setValue($panel, 'scene.0'); $panel->selectTab('Scene'); $renderedLines = $buildRenderedContentLines->invoke($panel); - $decoratedLine = $decorateSceneLine->invoke($panel, $renderedLines[3], null, 2); + $decoratedLine = $decorateSceneLine->invoke($panel, $renderedLines[2], null, 2); expect(substr_count($decoratedLine, '👾'))->toBe(1); expect(is_string($highlightSequence))->toBeTrue(); @@ -249,6 +292,230 @@ function createMainPanelWorkspace(): string expect(substr_count($decoratedLine, $highlightSequence . ' '))->toBe(0); }); +test('main panel selects a scene object when it is clicked in scene view', 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, + ); + + $contentArea = getMainPanelContentAreaPosition($panel); + $panel->handleMouseClick($contentArea['x'] + 1, $contentArea['y'] + 2); + + 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], + 'sprite' => [ + 'texture' => [ + 'path' => 'Textures/player', + 'position' => ['x' => 0, 'y' => 0], + 'size' => ['x' => 1, 'y' => 1], + ], + ], + ], + ]); +}); + +test('main panel can select a different scene object when it is clicked in scene view', 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], + ], + ], + ], + [ + 'type' => 'Sendama\\Engine\\Core\\GameObject', + 'name' => 'Enemy', + 'position' => ['x' => 8, 'y' => 1], + 'sprite' => [ + 'texture' => [ + 'path' => 'Textures/enemy', + 'position' => ['x' => 0, 'y' => 0], + 'size' => ['x' => 1, 'y' => 1], + ], + ], + ], + ], + workingDirectory: $workspace, + ); + + $contentArea = getMainPanelContentAreaPosition($panel); + $panel->handleMouseClick($contentArea['x'] + 7, $contentArea['y'] + 2); + + expect($panel->consumeInspectionRequest())->toBe([ + 'context' => 'hierarchy', + 'name' => 'Enemy', + 'type' => 'GameObject', + 'path' => 'scene.1', + 'value' => [ + 'type' => 'Sendama\\Engine\\Core\\GameObject', + 'name' => 'Enemy', + 'position' => ['x' => 8, 'y' => 1], + 'sprite' => [ + 'texture' => [ + 'path' => 'Textures/enemy', + 'position' => ['x' => 0, 'y' => 0], + 'size' => ['x' => 1, 'y' => 1], + ], + ], + ], + ]); +}); + +test('main panel adds ctrl-clicked scene objects to the selection set', 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], + ], + ], + ], + [ + 'type' => 'Sendama\\Engine\\Core\\GameObject', + 'name' => 'Enemy', + 'position' => ['x' => 8, 'y' => 1], + 'sprite' => [ + 'texture' => [ + 'path' => 'Textures/enemy', + 'position' => ['x' => 0, 'y' => 0], + 'size' => ['x' => 1, 'y' => 1], + ], + ], + ], + ], + workingDirectory: $workspace, + ); + + $contentArea = getMainPanelContentAreaPosition($panel); + setMainPanelMouseEvent(new MouseEvent("\033[<16;" . ($contentArea['x'] + 7) . ';' . ($contentArea['y'] + 2) . 'M')); + $panel->handleMouseClick($contentArea['x'] + 7, $contentArea['y'] + 2); + setMainPanelMouseEvent(null); + + $selectedScenePaths = new ReflectionProperty(MainPanel::class, 'selectedScenePaths'); + $selectedScenePaths->setAccessible(true); + + expect($selectedScenePaths->getValue($panel))->toBe(['scene.0', 'scene.1']) + ->and($panel->consumeInspectionRequest()['name'] ?? null)->toBe('Enemy'); +}); + +test('main panel can queue duplication for multiple selected scene objects', 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], + ], + ], + ], + [ + 'type' => 'Sendama\\Engine\\Core\\GameObject', + 'name' => 'Enemy', + 'position' => ['x' => 8, 'y' => 1], + 'sprite' => [ + 'texture' => [ + 'path' => 'Textures/enemy', + 'position' => ['x' => 0, 'y' => 0], + 'size' => ['x' => 1, 'y' => 1], + ], + ], + ], + ], + workingDirectory: $workspace, + ); + + $panel->selectSceneObjects(['scene.0', 'scene.1'], 'scene.1'); + $beginSceneDuplicationWorkflow = new ReflectionMethod(MainPanel::class, 'beginSceneDuplicationWorkflow'); + $beginSceneDuplicationWorkflow->setAccessible(true); + $beginSceneDuplicationWorkflow->invoke($panel); + + expect($panel->consumeDuplicationRequest())->toBe([ + 'items' => [ + [ + 'path' => 'scene.0', + 'value' => [ + '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], + ], + ], + ], + ], + [ + 'path' => 'scene.1', + 'value' => [ + 'type' => 'Sendama\\Engine\\Core\\GameObject', + 'name' => 'Enemy', + 'position' => ['x' => 8, 'y' => 1], + 'sprite' => [ + 'texture' => [ + 'path' => 'Textures/enemy', + 'position' => ['x' => 0, 'y' => 0], + 'size' => ['x' => 1, 'y' => 1], + ], + ], + ], + ], + ], + 'primaryPath' => 'scene.1', + ]); +}); + test('main panel resolves scene textures from the configured project directory', function () { $workspace = createMainPanelWorkspace(); $originalWorkingDirectory = getcwd(); @@ -481,21 +748,45 @@ function createMainPanelWorkspace(): string $hasFocus->setValue($panel, true); $refreshContent->invoke($panel); - $focusedLine = '|' . str_pad($panel->content[3], $panelWidth - 2) . '|'; - $focusedRenderedLine = $decorateSceneLine->invoke($panel, $focusedLine, null, 3); + $focusedLine = '|' . str_pad($panel->content[2], $panelWidth - 2) . '|'; + $focusedRenderedLine = $decorateSceneLine->invoke($panel, $focusedLine, null, 2); expect($focusedRenderedLine)->toContain("\033[5;30;46m"); $hasFocus->setValue($panel, false); $refreshContent->invoke($panel); - $blurredLine = '|' . str_pad($panel->content[3], $panelWidth - 2) . '|'; - $blurredRenderedLine = $decorateSceneLine->invoke($panel, $blurredLine, null, 3); + $blurredLine = '|' . str_pad($panel->content[2], $panelWidth - 2) . '|'; + $blurredRenderedLine = $decorateSceneLine->invoke($panel, $blurredLine, null, 2); expect($blurredRenderedLine)->not->toContain("\033[5;30;46m"); expect($blurredRenderedLine)->not->toContain("\033[30;46m"); }); +test('main panel projects scene labels using the engine display coordinates', function () { + $panel = new MainPanel( + width: 40, + height: 12, + sceneObjects: [ + [ + 'type' => 'Sendama\\Engine\\UI\\Label\\Label', + 'name' => 'Score', + 'position' => ['x' => 3, 'y' => 8], + 'text' => 'Score: 000', + ], + ], + sceneWidth: 20, + sceneHeight: 10, + ); + + $buildSceneCanvasContent = new ReflectionMethod(MainPanel::class, 'buildSceneCanvasContent'); + $buildSceneCanvasContent->setAccessible(true); + $sceneRows = $buildSceneCanvasContent->invoke($panel); + + expect($sceneRows[7] ?? '')->toContain('Score: 000'); + expect($sceneRows[8] ?? '')->not->toContain('Score: 000'); +}); + test('main panel hides the selected placeholder marker on blur for non-renderable scene objects', function () { $workspace = createMainPanelWorkspace(); $panel = new MainPanel( @@ -785,6 +1076,35 @@ function createMainPanelWorkspace(): string expect(array_any($panel->content, fn(string $line) => str_contains($line, 'Q')))->toBeTrue(); }); +test('main panel pans across environment tile maps larger than the scene metadata bounds', function () { + $workspace = createMainPanelWorkspace(); + file_put_contents($workspace . '/Assets/Maps/large.tmap', "ABCDEFGHIJKLMN\n"); + + $panel = new MainPanel( + width: 12, + height: 8, + workingDirectory: $workspace, + sceneWidth: 6, + sceneHeight: 3, + environmentTileMapPath: 'Maps/large', + ); + $hasFocus = new ReflectionProperty(Widget::class, 'hasFocus'); + $hasFocus->setAccessible(true); + $hasFocus->setValue($panel, true); + + expect(array_any($panel->content, fn(string $line) => str_contains($line, 'N')))->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, 'N')))->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'); @@ -829,6 +1149,63 @@ function createMainPanelWorkspace(): string expect(array_any($panel->content, fn(string $line) => str_contains($line, 'Zbcd')))->toBeTrue(); }); +test('main panel sprite tab paints with mouse click and drag', function () { + $workspace = createMainPanelWorkspace(); + $panel = new MainPanel(width: 30, height: 12, workingDirectory: $workspace); + $hasFocus = new ReflectionProperty(Widget::class, 'hasFocus'); + $lastPrintedSpriteCharacter = new ReflectionProperty(MainPanel::class, 'lastPrintedSpriteCharacter'); + $hasFocus->setAccessible(true); + $lastPrintedSpriteCharacter->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, + ]); + $lastPrintedSpriteCharacter->setValue($panel, 'Z'); + + $contentArea = getMainPanelContentAreaPosition($panel); + $panel->handleMouseClick($contentArea['x'] + 1, $contentArea['y'] + 3); + $panel->handleMouseDrag($contentArea['x'] + 2, $contentArea['y'] + 3); + $panel->handleMouseRelease($contentArea['x'] + 2, $contentArea['y'] + 3); + + expect(file_get_contents($workspace . '/Assets/Textures/player.texture'))->toContain('eZZh'); +}); + +test('main panel sprite tab erases with right click without changing the active brush', function () { + $workspace = createMainPanelWorkspace(); + $panel = new MainPanel(width: 30, height: 12, workingDirectory: $workspace); + $hasFocus = new ReflectionProperty(Widget::class, 'hasFocus'); + $lastPrintedSpriteCharacter = new ReflectionProperty(MainPanel::class, 'lastPrintedSpriteCharacter'); + $hasFocus->setAccessible(true); + $lastPrintedSpriteCharacter->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, + ]); + $lastPrintedSpriteCharacter->setValue($panel, 'Z'); + + $contentArea = getMainPanelContentAreaPosition($panel); + setMainPanelMouseEvent(new MouseEvent("\033[<2;" . ($contentArea['x'] + 1) . ';' . ($contentArea['y'] + 3) . 'M')); + $panel->handleMouseClick($contentArea['x'] + 1, $contentArea['y'] + 3); + $panel->handleMouseDrag($contentArea['x'] + 2, $contentArea['y'] + 3); + $panel->handleMouseRelease($contentArea['x'] + 2, $contentArea['y'] + 3); + + setMainPanelMouseEvent(new MouseEvent("\033[<0;" . ($contentArea['x'] + 3) . ';' . ($contentArea['y'] + 3) . 'M')); + $panel->handleMouseClick($contentArea['x'] + 3, $contentArea['y'] + 3); + setMainPanelMouseEvent(null); + + expect(file_get_contents($workspace . '/Assets/Textures/player.texture'))->toContain('e Z'); +}); + 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); diff --git a/tests/Unit/OptionListModalTest.php b/tests/Unit/OptionListModalTest.php index 0110e5a..f1d3aa7 100644 --- a/tests/Unit/OptionListModalTest.php +++ b/tests/Unit/OptionListModalTest.php @@ -1,7 +1,19 @@ $getContentAreaLeft->invoke($modal), + 'y' => $getContentAreaTop->invoke($modal), + ]; +} + test('option list modal scrolls long option lists to keep the selected item visible', function () { $modal = new OptionListModal(); $scrollOffset = new ReflectionProperty(OptionListModal::class, 'scrollOffset'); @@ -19,3 +31,54 @@ expect(array_any($modal->content, fn(string $line) => str_contains($line, '> Option 6')))->toBeTrue(); expect(array_any($modal->content, fn(string $line) => str_contains($line, 'Option 1')))->toBeFalse(); }); + +test('option list modal selects an option when it is clicked', function () { + $modal = new OptionListModal(); + $modal->show(['Alpha', 'Beta', 'Gamma']); + $modal->syncLayout(28, 8); + $contentArea = getOptionListModalContentAreaPosition($modal); + + $selection = $modal->clickOptionAtPoint($contentArea['x'] + 1, $contentArea['y'] + 1); + + expect($selection)->toBe('Beta'); + expect($modal->getSelectedOption())->toBe('Beta'); +}); + +test('option list modal renders a scrollbar when the list overflows', function () { + $modal = new OptionListModal(); + $modal->show(array_map( + static fn (int $index): string => 'Option ' . $index, + range(1, 12), + )); + $modal->syncLayout(28, 8); + + $buildRenderedContentLines = new ReflectionMethod($modal, 'buildRenderedContentLines'); + $buildRenderedContentLines->setAccessible(true); + $lines = $buildRenderedContentLines->invoke($modal); + + expect(array_any($lines, static fn (string $line): bool => str_contains($line, '█') || str_contains($line, '░')))->toBeTrue(); +}); + +test('option list modal scrollbars are draggable without changing the selected option', function () { + $modal = new OptionListModal(); + $modal->show(array_map( + static fn (int $index): string => 'Option ' . $index, + range(1, 20), + )); + $modal->syncLayout(28, 8); + + $contentArea = getOptionListModalContentAreaPosition($modal); + $scrollbarX = $modal->x + $modal->innerWidth; + $dragStartY = $contentArea['y']; + $dragEndY = $contentArea['y'] + 4; + + $modal->handleScrollbarMouseEvent(new MouseEvent("\033[<0;{$scrollbarX};{$dragStartY}M")); + $modal->handleScrollbarMouseEvent(new MouseEvent("\033[<32;{$scrollbarX};{$dragEndY}M")); + $modal->handleScrollbarMouseEvent(new MouseEvent("\033[<0;{$scrollbarX};{$dragEndY}m")); + + $scrollOffset = new ReflectionProperty(OptionListModal::class, 'scrollOffset'); + $scrollOffset->setAccessible(true); + + expect($modal->getSelectedOption())->toBe('Option 1') + ->and($scrollOffset->getValue($modal))->toBeGreaterThan(0); +}); diff --git a/tests/Unit/PlayGameTest.php b/tests/Unit/PlayGameTest.php new file mode 100644 index 0000000..964ecea --- /dev/null +++ b/tests/Unit/PlayGameTest.php @@ -0,0 +1,43 @@ + 'Break Out', + 'main' => 'break-out.php', + ], JSON_PRETTY_PRINT)); + + file_put_contents( + $projectDirectory . '/break-out.php', + <<<'PHP' + 'break-out', + ]); + $output = new BufferedOutput(); + $originalWorkingDirectory = getcwd(); + + try { + chdir($parentDirectory); + $exitCode = $command->run($input, $output); + } finally { + if ($originalWorkingDirectory !== false) { + chdir($originalWorkingDirectory); + } + } + + expect($exitCode)->toBe(0) + ->and(file_get_contents($projectDirectory . '/cwd.txt'))->toBe($projectDirectory); +}); diff --git a/tests/Unit/PrefabLoaderTest.php b/tests/Unit/PrefabLoaderTest.php new file mode 100644 index 0000000..99ea714 --- /dev/null +++ b/tests/Unit/PrefabLoaderTest.php @@ -0,0 +1,113 @@ +x; + } + + public function getY(): int + { + return $this->y; + } + } + + class GameObject + { + public function __construct( + private string $name, + private ?string $tag = null, + private Vector2 $position = new Vector2(), + private Vector2 $rotation = new Vector2(), + private Vector2 $scale = new Vector2(1, 1), + private ?object $sprite = null, + ) { + } + + public function getName(): string + { + return $this->name; + } + } + + abstract class Component + { + public function __construct(private readonly GameObject $gameObject) + { + } + + public function getGameObject(): GameObject + { + return $this->gameObject; + } + } +} + +namespace Sendama\Blasters\Scripts\Weapon { + class Bullet extends \Sendama\Engine\Core\Component + { + public ?\Sendama\Engine\Core\Vector2 $minBound = null; + public ?\Sendama\Engine\Core\Vector2 $maxBound = null; + } +} +PHP + ); + + $prefabPath = $workspace . '/assets/Prefabs/bullet.prefab.php'; + + file_put_contents( + $prefabPath, + <<<'PHP' + \Sendama\Engine\Core\GameObject::class, + 'name' => 'Bullet', + 'tag' => 'Bullet', + 'position' => ['x' => 0, 'y' => 0], + 'rotation' => ['x' => 0, 'y' => 0], + 'scale' => ['x' => 1, 'y' => 1], + 'components' => [ + [ + 'class' => \Sendama\Blasters\Scripts\Weapon\Bullet::class, + 'data' => [ + 'minBound' => '[1,1]', + 'maxBound' => '[120,25]', + ], + ], + ], +]; +PHP + ); + + $loader = new PrefabLoader($workspace); + $prefab = $loader->load($prefabPath); + + expect($prefab)->not->toBeNull() + ->and($prefab['components'][0]['data'])->toBe([ + 'minBound' => ['x' => 1, 'y' => 1], + 'maxBound' => ['x' => 120, 'y' => 25], + ]) + ->and($prefab['components'][0]['__editorFieldTypes'] ?? null)->toBe([ + 'minBound' => 'Sendama\\Engine\\Core\\Vector2|null', + 'maxBound' => 'Sendama\\Engine\\Core\\Vector2|null', + ]); +}); diff --git a/tests/Unit/ProjectNormalizerTest.php b/tests/Unit/ProjectNormalizerTest.php index 093b854..06a91f4 100644 --- a/tests/Unit/ProjectNormalizerTest.php +++ b/tests/Unit/ProjectNormalizerTest.php @@ -9,7 +9,7 @@ $issues = (new ProjectNormalizer($workspace))->inspect(); expect($issues)->toContain('Missing sendama.json.'); - expect($issues)->toContain('Missing configuration.json.'); + expect($issues)->toContain('Missing preferences.json.'); expect($issues)->toContain('Missing config/input.php.'); expect($issues)->toContain('Missing logs/debug.log.'); expect($issues)->toContain('Missing logs/error.log.'); @@ -31,12 +31,12 @@ $changes = (new ProjectNormalizer($workspace))->normalize(); - expect($changes)->toContain('Created configuration.json.'); + expect($changes)->toContain('Created preferences.json.'); expect($changes)->toContain('Created config/input.php.'); expect($changes)->toContain('Created logs/debug.log.'); expect($changes)->toContain('Created logs/error.log.'); expect($changes)->toContain('Created assets/Scenes directory.'); - expect(is_file($workspace . '/configuration.json'))->toBeTrue(); + expect(is_file($workspace . '/preferences.json'))->toBeTrue(); expect(is_file($workspace . '/config/input.php'))->toBeTrue(); expect(is_file($workspace . '/logs/debug.log'))->toBeTrue(); expect(is_file($workspace . '/logs/error.log'))->toBeTrue(); @@ -44,7 +44,6 @@ expect(is_dir($workspace . '/assets/Scripts'))->toBeTrue(); expect(is_dir($workspace . '/Assets'))->toBeFalse(); - $configuration = json_decode(file_get_contents($workspace . '/configuration.json'), true, flags: JSON_THROW_ON_ERROR); - expect($configuration['project']['name'])->toBe('Legacy Test Game'); - expect($configuration['project']['main'])->toBe('legacy.php'); + $preferences = json_decode(file_get_contents($workspace . '/preferences.json'), true, flags: JSON_THROW_ON_ERROR); + expect($preferences)->toBeEmpty(); }); diff --git a/tests/Unit/SceneLoaderTest.php b/tests/Unit/SceneLoaderTest.php index c5aa1f8..e48db58 100644 --- a/tests/Unit/SceneLoaderTest.php +++ b/tests/Unit/SceneLoaderTest.php @@ -399,6 +399,11 @@ public function __construct(GameObject $gameObject) 'speed' => 3, 'spawnOffset' => ['x' => 2, 'y' => 1], ], + '__editorFieldTypes' => [ + 'enabledInEditor' => 'bool', + 'speed' => 'int', + 'spawnOffset' => 'Sendama\\Engine\\Core\\Vector2|array', + ], ], ]); }); @@ -629,10 +634,120 @@ class Gun extends Behaviour 'maxBullets' => 10, 'bulletTexture' => null, ], + '__editorFieldTypes' => [ + 'fireRate' => 'float', + 'maxBullets' => 'int', + 'bulletTexture' => 'Sendama\\Engine\\Core\\Texture|null', + ], ], ]); }); +test('scene loader annotates GameObject component fields for prefab assignment', function () { + $workspace = sys_get_temp_dir() . '/sendama-scene-loader-prefab-field-' . uniqid(); + mkdir($workspace . '/Assets/Scenes', 0777, true); + mkdir($workspace . '/vendor', 0777, true); + + file_put_contents( + $workspace . '/vendor/autoload.php', + <<<'PHP' +x; + } + + public function getY(): int + { + return $this->y; + } + } + + class Component + { + public function __construct(private ?GameObject $gameObject = null) + { + } + } + + class GameObject + { + public function __construct( + private string $name, + private ?string $tag = null, + private ?Vector2 $position = null, + private ?Vector2 $rotation = null, + private ?Vector2 $scale = null, + private mixed $sprite = null, + ) { + } + } +} + +namespace Sendama\Game\Scripts { + use Sendama\Engine\Core\Component; + use Sendama\Engine\Core\GameObject; + + class Gun extends Component + { + public ?GameObject $bulletPrefab = null; + public int $maxBullets = 10; + } +} +PHP + ); + + file_put_contents( + $workspace . '/Assets/Scenes/level01.scene.php', + <<<'PHP' + [ + [ + 'type' => GameObject::class, + 'name' => 'Player', + 'components' => [ + [ + 'class' => Gun::class, + 'data' => [ + 'bulletPrefab' => 'Prefabs/enemy.prefab.php', + ], + ], + ], + ], + ], +]; +PHP + ); + + $loader = new SceneLoader($workspace); + $scene = $loader->load(new EditorSceneSettings(active: 0, loaded: ['level01'])); + $component = $scene?->hierarchy[0]['components'][0] ?? null; + + expect($component)->toBeArray() + ->and($component['data']['bulletPrefab'] ?? null)->toBe('Prefabs/enemy.prefab.php') + ->and($component['__editorFieldTypes']['bulletPrefab'] ?? null)->toBe('Sendama\\Engine\\Core\\GameObject|null'); +}); + 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 index b5cc27f..944e9ed 100644 --- a/tests/Unit/SceneWriterTest.php +++ b/tests/Unit/SceneWriterTest.php @@ -55,6 +55,53 @@ expect(file_get_contents($scenePath))->toContain("'name' => 'Player'"); }); +test('scene writer strips editor-only component metadata while preserving prefab references', function () { + $scene = new SceneDTO( + name: 'level01', + hierarchy: [ + [ + 'type' => 'Sendama\\Engine\\Core\\GameObject', + 'name' => 'Player', + 'components' => [ + [ + 'class' => 'Sendama\\Game\\Scripts\\Gun', + 'data' => [ + 'bulletPrefab' => 'Prefabs/enemy.prefab.php', + ], + '__editorFieldTypes' => [ + 'bulletPrefab' => 'Sendama\\Engine\\Core\\GameObject|null', + ], + ], + ], + ], + ], + rawData: [ + 'hierarchy' => [ + [ + 'type' => 'Sendama\\Engine\\Core\\GameObject', + 'name' => 'Player', + 'components' => [ + [ + 'class' => 'Sendama\\Game\\Scripts\\Gun', + 'data' => [ + 'bulletPrefab' => 'Prefabs/enemy.prefab.php', + ], + '__editorFieldTypes' => [ + 'bulletPrefab' => 'Sendama\\Engine\\Core\\GameObject|null', + ], + ], + ], + ], + ], + ], + ); + + $serializedScene = (new SceneWriter())->serialize($scene); + + expect($serializedScene)->toContain("'bulletPrefab' => 'Prefabs/enemy.prefab.php'") + ->and($serializedScene)->not->toContain('__editorFieldTypes'); +}); + 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); @@ -521,3 +568,93 @@ expect($serializedScene)->toContain("'speed' => 3"); expect($serializedScene)->toContain("'spawnOffset' => ["); }); + +test('scene writer removes orphaned component data keys when serialized properties are renamed', function () { + $workspace = sys_get_temp_dir() . '/sendama-scene-writer-component-rename-' . uniqid(); + mkdir($workspace . '/Assets/Scenes', 0777, true); + $scenePath = $workspace . '/Assets/Scenes/level01.scene.php'; + + file_put_contents( + $scenePath, + <<<'PHP' + [ + [ + 'type' => GameObject::class, + 'name' => 'Enemy', + 'components' => [ + [ + 'class' => EnemyController::class, + 'data' => [ + 'moveSpeed' => 1, + ], + ], + ], + ], + ], +]; +PHP + ); + + $scene = new SceneDTO( + name: 'level01', + hierarchy: [ + [ + 'type' => 'Sendama\\Engine\\Core\\GameObject', + 'name' => 'Enemy', + 'components' => [ + [ + 'class' => 'Sendama\\Game\\EnemyController', + 'data' => [ + 'speed' => 1, + ], + ], + ], + ], + ], + sourcePath: $scenePath, + rawData: [ + 'hierarchy' => [ + [ + 'type' => 'Sendama\\Engine\\Core\\GameObject', + 'name' => 'Enemy', + 'components' => [ + [ + 'class' => 'Sendama\\Game\\EnemyController', + 'data' => [ + 'speed' => 1, + ], + ], + ], + ], + ], + ], + sourceData: [ + 'hierarchy' => [ + [ + 'type' => 'Sendama\\Engine\\Core\\GameObject', + 'name' => 'Enemy', + 'components' => [ + [ + 'class' => 'Sendama\\Game\\EnemyController', + 'data' => [ + 'moveSpeed' => 1, + ], + ], + ], + ], + ], + ], + ); + + $writer = new SceneWriter(); + $serializedScene = $writer->serialize($scene); + + expect($serializedScene)->toContain("'speed' => 1"); + expect($serializedScene)->not->toContain("'moveSpeed' => 1"); +}); diff --git a/tests/Unit/SnackbarTest.php b/tests/Unit/SnackbarTest.php new file mode 100644 index 0000000..a8e5226 --- /dev/null +++ b/tests/Unit/SnackbarTest.php @@ -0,0 +1,71 @@ +syncLayout(80, 24); + $snackbar->enqueue('Saved scene level01.scene.php', 'success', 0.5); + + expect($snackbar->hasActiveNotice())->toBeTrue(); + + $initialY = $snackbar->y; + $initialX = $snackbar->x; + $snackbar->update(); + + expect($snackbar->y)->toBeGreaterThan($initialY); + + for ($index = 0; $index < 20; $index++) { + $snackbar->update(); + } + + $width = new ReflectionProperty(Snackbar::class, 'width'); + $expectedCenteredX = intdiv(80 - $width->getValue($snackbar), 2) + 1; + + expect($snackbar->x)->toBe($expectedCenteredX) + ->and($snackbar->y)->toBe(1); + + $visibleUntil = new ReflectionProperty(Snackbar::class, 'visibleUntil'); + $phase = new ReflectionProperty(Snackbar::class, 'phase'); + $visibleUntil->setValue($snackbar, microtime(true) - 1); + + $snackbar->update(); + + expect($phase->getValue($snackbar))->toBe('exiting'); + + for ($index = 0; $index < 30; $index++) { + $snackbar->update(); + } + + expect($snackbar->hasActiveNotice())->toBeFalse(); +}); + +test('snackbar renders status titles and colorized content', function () { + $snackbar = new Snackbar(); + $snackbar->syncLayout(80, 24); + $snackbar->enqueue('Failed to save scene.', 'error', 1.0); + + for ($index = 0; $index < 20; $index++) { + $snackbar->update(); + } + + ob_start(); + $snackbar->renderAt(); + $output = ob_get_clean(); + + expect($output)->toContain('Error') + ->and($output)->toContain('Failed to save scene.') + ->and($output)->toContain("\033[30;41m"); +}); + +test('snackbar does not render while fully off-screen above the viewport', function () { + $snackbar = new Snackbar(); + $snackbar->syncLayout(80, 24); + $snackbar->enqueue('Saved scene level01.scene.php', 'success', 1.0); + + ob_start(); + $snackbar->renderAt(); + $output = ob_get_clean(); + + expect($output)->toBe(''); +}); diff --git a/tests/Unit/WidgetTest.php b/tests/Unit/WidgetTest.php index b47208c..5183c94 100644 --- a/tests/Unit/WidgetTest.php +++ b/tests/Unit/WidgetTest.php @@ -89,13 +89,12 @@ public function update(): void }; $buildRenderedContentLines = new ReflectionMethod($widget, 'buildRenderedContentLines'); - $buildRenderedContentLines->setAccessible(true); $lines = $buildRenderedContentLines->invoke($widget); - expect($lines)->toHaveCount(4); - expect($lines[0])->toBe('│ Texture: Texture │'); - expect(mb_strlen($lines[0]))->toBe(20); - expect(mb_substr($lines[0], -1))->toBe('│'); + expect($lines)->toHaveCount(4) + ->and($lines[0])->toBe('│ Texture: Texture │') + ->and(mb_strlen($lines[0]))->toBe(20) + ->and(mb_substr($lines[0], -1))->toBe('│'); }); test('widget keeps borders intact when content contains wide multibyte glyphs', function () { @@ -112,10 +111,36 @@ public function update(): void }; $buildRenderedContentLines = new ReflectionMethod($widget, 'buildRenderedContentLines'); - $buildRenderedContentLines->setAccessible(true); $lines = $buildRenderedContentLines->invoke($widget); - expect($lines)->toHaveCount(4); - expect(mb_strwidth($lines[0], 'UTF-8'))->toBe(12); - expect(mb_substr($lines[0], -1))->toBe('│'); + expect($lines)->toHaveCount(4) + ->and(mb_strwidth($lines[0], 'UTF-8'))->toBe(12) + ->and(mb_substr($lines[0], -1))->toBe('│'); +}); + +test('widget scrolls overflowing content and renders a scrollbar thumb', function () { + $widget = new class extends Widget { + public function __construct() + { + parent::__construct('Inspector', '', ['x' => 1, 'y' => 1], 20, 6); + $this->content = ['Line 1', 'Line 2', 'Line 3', 'Line 4', 'Line 5', 'Line 6']; + } + + public function revealContentLine(int $contentIndex): void + { + $this->ensureContentLineVisible($contentIndex); + } + + public function update(): void + { + } + }; + + $buildRenderedContentLines = new ReflectionMethod($widget, 'buildRenderedContentLines'); + $widget->revealContentLine(5); + $lines = $buildRenderedContentLines->invoke($widget); + + expect($lines)->toHaveCount(4) + ->and(array_any($lines, static fn (string $line): bool => str_contains($line, 'Line 6')))->toBeTrue() + ->and(array_any($lines, static fn (string $line): bool => str_contains($line, '█') || str_contains($line, '░')))->toBeTrue(); });